主题
uni-app 生命周期与适配(三)
本页对应 uni-app 生命周期与适配,共 6 段视频,约 1 小时 1 分。
学习目标
- 能够使用 Vue 3
computed根据当前选中索引与 tab 数据计算指示器的宽度和left值,并解释“父容器宽度减指示器宽度除以 2 加 tab 左偏移”的计算依据。 - 能够通过获取父容器与文字节点的位置计算相对偏移,修复 tabs 指示器跑偏问题,使指示器基于文字宽度居中。
- 能够使用
defineEmits定义自定义事件,在 tab 点击时向外传递当前项数据,并在父组件中监听事件完成子传父通信。 - 能够使用插槽和
position: sticky封装custom-sticky组件,使被包裹内容在页面滚动时实现吸顶效果。 - 能够为
custom-sticky定义并接收background-color、offset-top等 props,通过动态style控制背景色与吸顶位置。 - 能够将
top优化为padding-top,避免吸顶偏移时出现透明镂空,并说明该优化对多端显示的作用。
知识点
需求:下划线为什么要计算位置
custom-tabs 里有一排 tab,点击哪个 tab,哪个 tab 要高亮,同时底部的小下划线要移动到它下面并居中。这里真正麻烦的不是“移动”,而是“移动到哪里”。如果位置写死,第一个 tab 能用,切到第二个、第三个就会错位。所以我们需要根据当前点击的索引值,动态算出一个 left 值,再通过 :style="{ left: position + 'px' }" 设置给下划线。
为什么需要计算属性?因为这里有两个数据存在联动关系:索引值一变,下划线的位置就要跟着变。转写稿里反复问“索引值一变,left 是不是也跟着变”,这正是 computed 的典型场景:根据一个响应式数据的变化,生成一个新的数据。
思路:先取当前 tab 的 width 和 left
一开始的思路是:每个 tab 数据里都有 width 和 left,点击索引值为 1,就取数组中索引为 1 的那一项。下划线本身有固定宽度,转写稿中按 20 处理。要让下划线在 tab 中居中,公式是:
js
left + (width - 20) / 2推导过程是:大盒子宽度减去小下划线宽度,剩下的空间平均分到左右两边,所以除以 2。这个值再加上当前 tab 原始的 left,才是下划线真正要设置的位置。
按转写稿整理,核心代码类似:
js
import { computed } from 'vue'
const position = computed(() => {
if (tabBar.value.length === 0) return 0
const { width, left } = tabBar.value[activeIndex.value]
return left + (width - 20) / 2
})模板中绑定:
html
<div class="line" :style="{ left: position + 'px' }"></div>这里要注意单位:计算出来的是数字,样式里要拼上 px,否则浏览器不认识。
调试一:空数组解构导致报错
代码写完后页面报错,说位置找不到。原因不是公式错,而是解构时机太早。转写稿里说,tabBar 一开始是空数组,只有 mounted 执行完之后,数组里才会有单元。空数组直接取第 0 项,再解构 width 和 left,当然解构不出来。
一开始写的是“如果索引值等于 0 就 return”,后来修正为判断数组长度:
js
if (tabBar.value.length === 0) return 0这个易错点很关键:要判断的是“数组有没有数据”,不是“索引是不是 0”。索引为 0 是合法数据,不能当成空处理。
调试二:为什么看起来没有居中
下划线能跟着动了,但看起来偏了。转写稿里的分析很重要:它其实不是“没居中”,而是“以整个 tab 盒子居中”,不是“以文字居中”。我们取的是整个 tab 盒子的 width,而人眼判断时看的是文字。文字在盒子里还有一段偏移,所以视觉上会觉得偏。
解决方法是把“文字小盒子”的位置也取出来,用它相对父盒子的偏移来修正。转写稿中使用并集选择器同时取到大盒子和文字小盒子,拿到结果数组后,第一个是大盒子,后面是各个文字小盒子:
js
const res = document.querySelectorAll('.tabs-bar, .tabs-bar-text')
const [parent, ...texts] = res然后计算文字相对父盒子的偏移:
js
const parentLeft = parent.getBoundingClientRect().left
const { width, left } = texts[activeIndex.value].getBoundingClientRect()
return left - parentLeft + (width - 20) / 2这里 left - parentLeft 得到的是文字在父盒子内部的偏移量,再叠加居中偏移,才是最终位置。转写稿最后还故意把文字改成“护肤品”三个字验证,发现仍然能居中,说明计算基准已经从“整个盒子”改成了“文字宽度”。
易错点与验证
第一,空数组不要急着解构,先判断 length === 0。第二,left 是相对位置时要减掉父盒子的 left,否则会多出一段偏移。第三,下划线宽度要确认,转写稿中按 20 计算。第四,样式绑定别丢单位。第五,调试时不要只看“动没动”,还要看“以谁为基准居中”。转写稿的调试过程就是从“能动”走到“以文字为基准正确居中”的过程。
目标:让指示器跟着当前 tab 移动
为什么需要:custom-tabs 里每个 tab 下方都有一条小下划线,点击不同 tab 时,下划线要移动到对应 tab 下方并居中。只靠 CSS 写死位置不行,因为索引值会变。
怎么想:每个 tab 的数据里都有 left 和 width,索引值一变,就应该取出对应那一项的数据来算位置。也就是“一个数据变化,另一个数据跟着变”,这正是计算属性的典型场景。
怎么做:在组件中引入 computed,声明一个表示指示器位置的计算属性。转写稿中这个结果变量叫 position,实际命名不关键,关键是它依赖当前索引值和 tab 数据。
易错点:不要直接把 left 写死;计算属性里依赖的数据必须是响应式的,否则点击后不会重新计算。
计算属性:索引值变,位置跟着变
为什么需要:点击第 1 个 tab 时,应取第 1 项数据的 left;点击第 2 个时取第 2 项。索引值和数据项要对应起来。
怎么想:tabBar 是一个数组,数组里每一项有 width 和 left。用当前索引去取数组项,再解构出宽度和 left,后续计算会更清楚。
怎么做:核心代码可以整理成下面这样(当前索引变量按项目实际命名):
js
import { computed } from 'vue'
const position = computed(() => {
const { width, left } = tabBar.value[当前索引]
return (width - 20) / 2 + left
})易错点:转写稿里反复提到的 lifeve、leve 都是口误,正确是 left;with 是 width。另外别忘记从 vue 引入 computed。
位置公式:先居中,再加原始 left
为什么需要:小下划线宽度是固定的 20,而 tab 盒子宽度通常大于 20。要让小线在大盒子中居中,必须计算左右剩余空间。
怎么想:大盒子宽度减去小线宽度,剩下的空间平均分成两份,左边一份就是相对偏移;但小线最终定位还要加上 tab 盒子本身的 left。
怎么做:公式是:
js
(width - 20) / 2 + left其中 20 是小下划线宽度,width 是当前 tab 盒子宽度,left 是当前 tab 的原始 left。
易错点:括号不能丢,先减再除以 2;小线宽度要按实际样式确认,转写稿中明确是 20;模板绑定 left 时不要丢单位 px。
空数组报错:先判断 length 再解构
为什么需要:页面刚加载时,tabBar 可能还是空数组。此时直接取第 0 项再解构 { width, left },会得到 undefined,运行时报错。
怎么想:报错不是公式错,而是“数据还没准备好”。mounted 执行完之后,数组里才会有单元。因此计算属性里要先判断数组是否为空。
怎么做:
js
if (tabBar.value.length === 0) return易错点:转写稿中一开始说“判断索引值为 0”,后来修正为判断数组 length === 0。不要判断错对象;空数组里解构不出任何位置。
跑偏修正:以文字盒子为基准
为什么需要:第一版下划线虽然会动,但看起来偏了。检查后发现它并没有“没居中”,而是以整个 tab 盒子为基准居中;人的视觉判断却是以文字为基准。
怎么想:需要拿到“整个 tab 盒子”的 left,再拿到“文字小盒子”的 left,两者相减,得到文字相对于 tab 盒子的偏移量,再用这个偏移量参与计算。
怎么做:用并集选择器同时取大盒子和文字盒子:
js
const res = document.querySelectorAll('.tabs-bar, .tabs-bar-text')
const [parent, ...items] = res
const item = items[当前索引]
const left = item.left - parent.left最后公式变成:
js
(width - 20) / 2 + left易错点:选择器中间是逗号,表示并集;解构后第一个是 parent 大盒子,后面才是各个文字小盒子;left 要用“小盒子 left 减 parent left”的差值,不能直接用小盒子绝对 left。
模板绑定与调试验证
为什么需要:算出的 position 只有绑定到样式上,指示器才会真正移动。
怎么做:
html
:style="{ left: position + 'px' }"调试时可以 console.log(position),点击不同 tab 观察数值是否变化;转写稿中出现过 27.5、72.5 等变化值。再把某个 tab 文字改成三个字“护肤品”,验证下划线仍能按文字宽度居中。
易错点:position + 'px' 不要漏 px;刷新后先看初始位置;调试打印用 console.log,转写稿里的“Q住C”“C log”都是口述误差。
先把目标说清楚:子组件要把“点了谁”告诉父组件
custom-tabs 做到这一步,已经完成了三件事:数据用数组遍历渲染、点击谁谁高亮、下划线跟着移动并尽量居中。剩下的关键功能是:组件内部点击了哪个 tab,要把这个结果传给组件外部。
为什么需要?因为组件要灵活。父组件可能要根据用户点的是“推荐”还是“护肤”去切换内容、发请求或做别的逻辑。子组件自己知道点击结果,但父组件不知道,所以必须做“子传父”。
怎么想?父传子通常用 props,子传父通常用自定义事件。Vue2 里常见写法是 this.$emit,Vue3 里仍然用事件回调,只是定义事件的方式变成 defineEmits。所以不要直接照搬旧代码,要先理解:子组件触发事件,父组件监听事件,事件回调接收参数。
子组件里定义事件并触发
先定义这个组件能对外抛出什么事件。事件名可以叫 click,也可以叫 change,但一旦定了,父组件就必须监听同名事件。
js
const emit = defineEmits(['click'])
const handleClick = (item) => {
emit('click', item)
}模板里点击某个 tab 时调用 handleClick(item),把当前项传出去:
html
<div
v-for="item in tabs"
:key="item.id"
@click="handleClick(item)"
>
{{ item.title }}
</div>这里的“怎么想”是:子组件内部拿到的是遍历出来的 item,它比只传索引更有用,因为父组件通常需要知道点的是哪个标题、哪个 id。易错点是:defineEmits 里写的事件名,必须和 emit 里触发的名字完全一致;如果子组件定义的是 click,父组件却写 @change,事件就不会被正确接收。
父组件监听并接收数据
父组件使用 custom-tabs 时,在组件标签上监听这个事件:
html
<custom-tabs @click="onFeed" />js
const onFeed = (item) => {
console.log(item)
}调试时可以先只打印,确认点击切换时控制台有没有输出。转写稿里就是先 log 测试事件有没有触发,确认后再接收 item。易错点有两个:第一,事件名由子组件定义决定,不是父组件随便起名;第二,父组件回调参数就是子组件 emit 的第二个参数,不要把整个事件对象和业务数据混在一起。
指示器位置计算:数据传递前先把下划线放对
custom-tabs 的下划线位置也依赖“当前索引”。为什么需要计算?因为索引一变,下划线的 left 就要跟着变。怎么想?这正好符合 computed 的特点:根据一个响应式数据生成新数据。于是用计算属性,根据 activeIndex 取对应 tab 的 left 和 width。
js
const position = computed(() => {
if (tabs.value.length === 0) return
const { left, width } = tabs.value[activeIndex.value]
return left + (width - 20) / 2
})模板里设置样式时别丢单位:
html
<div class="line" :style="{ left: position + 'px' }"></div>这里转写稿里有一个重要调试过程:一开始数组是空数组,直接解构第 0 项会报错。修复方式不是判断“索引是否为 0”,而是判断数组长度是否为空:
js
if (tabs.value.length === 0) return后来又发现下划线“偏了”。原因不是公式完全错,而是居中参考的是整个 tab 盒子,不是文字。调整思路是:拿到文字盒子的 left,再减去父盒子的 left,得到文字相对父盒子的偏移,再加 (width - 20) / 2。这样下划线才真正按文字居中。易错点是:初始空数组要提前返回;left 要加 px;事件名、属性名、计算属性依赖的索引都要保持一致。
代码示例
示例一:custom-tabs —— 计算并调整指示器位置 + 数据传递
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>custom-tabs</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
.custom-tabs { position: relative; display: flex; background: #fff; }
.tab-bar { height: 45px; line-height: 45px; padding: 0 15px; }
.tab-bar .text { display: inline-block; color: #333; }
.tab-bar.active .text { color: #e93b3d; font-weight: bold; }
/* 小下划线:宽度写死 20,靠 left 挪位置 */
.line { position: absolute; bottom: 0; width: 20px; height: 3px; background: #e93b3d; transition: left .2s; }
</style>
</head>
<body>
<div id="app">
<!-- 外部监听子组件抛出的 click 事件 -->
<custom-tabs :list="list" @click="onTabClick"></custom-tabs>
</div>
<script>
const { createApp, ref, computed, onMounted, nextTick } = Vue
const CustomTabs = {
props: { list: { type: Array, default: () => [] } },
// 1. 定义事件:将来外部用 @click 就能监听到
emits: ['click'],
setup(props, { emit }) {
const rootRef = ref(null)
const activeIndex = ref(0)
const parentLeft = ref(0) // 组件根盒子相对视口的 left
const textBoxes = ref([]) // 每个文字盒子的 { width, left }
// 2. 计算属性:索引一变 → 下划线位置跟着变
const offsetLeft = computed(() => {
// 首次渲染时数组是空的,直接返回,不能解构
if (textBoxes.value.length === 0) return 0
const { width, left } = textBoxes.value[activeIndex.value]
// (大盒子宽 - 小下划线宽) / 2 让下划线在自己格子里居中
// + (文字 left - 根盒子 left) 换成相对根盒子的偏移量
return (width - 20) / 2 + (left - parentLeft.value)
})
const handleClick = (item, index) => {
activeIndex.value = index
// 3. 子传父:把点中的整条数据抛出去
emit('click', item)
}
onMounted(() => {
nextTick(() => {
const root = rootRef.value
parentLeft.value = root.getBoundingClientRect().left
textBoxes.value = Array.from(root.querySelectorAll('.text')).map(el => {
const rect = el.getBoundingClientRect()
return { width: rect.width, left: rect.left }
})
})
})
return { rootRef, activeIndex, offsetLeft, handleClick }
},
template: `
<div class="custom-tabs" ref="rootRef">
<div
class="tab-bar"
v-for="(item, index) in list"
:key="index"
:class="{ active: index === activeIndex }"
@click="handleClick(item, index)"
>
<span class="text">{{ item.name }}</span>
</div>
<div class="line" :style="{ left: offsetLeft + 'px' }"></div>
</div>
`
}
const App = {
components: { CustomTabs },
setup() {
const list = ref([
{ name: '商品' }, { name: '评价' }, { name: '详情' }, { name: '推荐' }
])
// 事件回调就是个普通函数,参数就是子组件 emit 出来的数据
const onTabClick = (ev) => {
console.log('父组件收到:', ev.name)
}
return { list, onTabClick }
}
}
createApp(App).mount('#app')
</script>
</body>
</html>要点解释:
computed用来计算位置:索引值(activeIndex)变化,指示器位置就跟着变,这正是“一个数据变化生成新数据”的计算属性场景。(width - 20) / 2 + left:大盒子减掉小下划线再除以 2,得到下划线在格子里的居中偏移;但left是相对视口的值,必须再减去父盒子的left,否则指示器会整体偏右。- 居中要以文字盒子为基准:量
getBoundingClientRect()的对象是内层.text,不是外层.tab-bar,否则看起来“没居中”。 - 易错点:数组为空不能解构。
onMounted之前数组是[],直接写tabs[activeIndex]会undefined解构报错,所以计算属性开头要判断length === 0直接return。 - 易错点:
left要带单位,模板里写offsetLeft + 'px',不能只丢数字。 emits: ['click']是子传父的关键:组件内定义了什么事件名,外部就必须用同名@click去监听;改叫abc,外部就得写@abc。没监听也不会报错,只是拿不到数据。
示例二:custom-sticky —— 插槽 + 吸顶定位 + 属性扩展
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>custom-sticky</title>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<style>
/* 核心:粘性定位,滚到 top 位置就自动吸住 */
.custom-sticky { position: sticky; top: 0; z-index: 9; }
.tabs { display: flex; height: 45px; line-height: 45px; }
.tabs .item { padding: 0 15px; }
.blank { height: 1500px; background: pink; }
</style>
</head>
<body>
<div id="app">
<!-- 套住谁,谁就具备吸顶能力 -->
<custom-sticky background-color="#fff" offset-top="10px">
<div class="tabs">
<div class="item">商品</div>
<div class="item">评价</div>
<div class="item">详情</div>
</div>
</custom-sticky>
<div class="blank"></div>
</div>
<script>
const { createApp } = Vue
const CustomSticky = {
props: {
// 背景色:不写死,交给使用方决定
backgroundColor: { type: String, default: '#fff' },
// 距顶部距离:数字或带单位的字符串都允许
offsetTop: { type: [String, Number], default: 0 }
},
template: `
<div class="custom-sticky"
:style="{ backgroundColor: backgroundColor, paddingTop: offsetTop }">
<slot></slot>
</div>
`
}
createApp({ components: { CustomSticky } }).mount('#app')
</script>
</body>
</html>要点解释:
position: sticky+top: 0是吸顶的全部核心,不用 JS 判断滚动;早期没有粘性定位时才用 JS 动态切换fixed。<slot>是“套住谁谁吸顶”的实现基础:组件内部不写内容,由外部把内容塞进插槽。- 属性用
backgroundColor,模板里传background-color:Vue 会自动做 kebab-case 与驼峰互转,写错大小写会传不进去。 - 易错点:
offset-top="10px"是字符串。若传数字10,paddingTop: 10是无效值,撑不开距离;要传数字就得在模板里补+ 'px'。 - 易错点:用
paddingTop而不是top。top只是把盒子整体往下挪,上面会露出透明缝隙,滚动时下层内容会透出来;paddingTop撑开的是盒子自身,背景色覆盖得住。 - 易错点:不写背景色就“假白”。不滚动时看着是白的,是因为父级默认白色;一滚动就露馅了,所以默认值给
#fff并允许外部覆盖。
常见错误
- 现象:页面一加载就报错,提示取不到位置或
undefined;原因:computed在mounted前执行,tabs还是空数组却直接解构tabs[activeIndex];修法:先判断tabs.length === 0并直接返回,或使用可选链/安全解构,不要只判断activeIndex === 0。 - 现象:指示器能跟着切换但整体偏右、看起来没对准文字;原因:直接用文字节点的
left当下划线left,没有减去父容器/外层盒子的left;修法:先算textRect.left - parentRect.left,再套居中偏移公式。 - 现象:两个字的 tab 看似居中,换成“护肤品”三个字后就偏了;原因:用整个 tab 盒子宽度做居中参考,而不是文字实际宽度;修法:取文字小盒或文本节点的
width/left计算,不要用整个 tab 盒子宽度。 - 现象:子组件点击后父组件收不到通知;原因:
defineEmits中定义的事件名与父组件@事件名不一致,或触发时写错事件名;修法:统一事件名,按“定义事件名→emit触发→父组件监听→回调接收”逐项核对。 - 现象:父组件打印到
Proxy或取值不符合预期;原因:直接把整个响应式对象emit出去,父组件未按需取值;修法:只传必要字段,如emit('change', item.value),或明确在父组件中取.value/具体属性。 - 现象:
custom-sticky设置偏移后出现透明空隙或背景断层;原因:用top做偏移让盒子整体下移,原位置留下空白;修法:吸顶仍用position: sticky; top: 0,偏移改用padding-top,并确保背景色覆盖。 - 现象:滚动后吸顶区域没有背景色或颜色写死无法复用;原因:组件未设置背景色,或背景色直接写死;修法:通过
props暴露backgroundColor,模板中用:style绑定,并给默认值。 - 现象:给组件传静态颜色时加冒号后不生效;原因:
:background-color="red"中的red被当成变量;修法:静态字符串不加冒号,只有传变量时才加冒号。
练习
- 将
custom-tabs的指示器计算改为基于文字节点:准备两个字数和三个字数的 tab,切换后肉眼检查下划线是否都相对文字居中,并记录使用的rect字段。 - 复现“空数组解构报错”:先保留错误代码截图/日志,再修复为判空返回,说明为什么不能只判断
activeIndex === 0。 - 给
custom-tabs设计子传父:父组件需要拿到被点击项的value和index,完成事件定义、触发、监听和回调接收,并验证事件名不一致时父组件确实收不到。 - 优化
custom-sticky:分别用top和padding-top做偏移,滚动验证透明空隙差异;再通过父组件传入背景色和偏移值,检查默认值是否生效。 - 给
custom-sticky增加使用检查清单:插槽内容是否显示、背景色是否可配、偏移是否可配、静态字符串与变量传参是否写对,逐项验收。
课堂画面






原视频对照
| # | 视频 | 时长 |
|---|---|---|
| 1 | 自定义组件-custom-tabs计算指示器位置 | 12:09 |
| 2 | 自定义组件custom-tabs调整指示器位置 | 11:59 |
| 3 | 自定义组件custom-tabs数据传递 | 13:10 |
| 4 | 自定义组件custom-sticky定义 | 09:22 |
| 5 | 自定义组件custom-sticky定义属性 | 08:40 |
| 6 | 自定义组件custom-sticky优化 | 05:39 |
小结
- 指示器位置要按“文字实际盒子”算,不能按整个 tab 盒子算。
- 初始空数据是
computed的高频边界,先判空再解构。 - 指示器偏移要同时考虑文字宽度、下划线宽度和父容器
left基准。 - 子传父优先用自定义事件,事件名必须“定义、触发、监听”三处一致。
sticky吸顶偏移优先用padding-top,可避免透明空隙和背景断层。- 组件属性要暴露背景色、偏移等可变项,并给默认值保证开箱可用。
- 调试顺序建议:先打印数组/
rect结构,再套公式,再换不同文字长度和小程序端验证。