Skip to content

uni-app 生命周期与适配(三) ​

本页对应 uni-app 生命周期与适配,共 6 段视频,约 1 小时 1 分。

学习目标 ​

  1. 能够使用 Vue 3 computed 根据当前选中索引与 tab 数据计算指示器的宽度和 left 值,并解释“父容器宽度减指示器宽度除以 2 加 tab 左偏移”的计算依据。
  2. 能够通过获取父容器与文字节点的位置计算相对偏移,修复 tabs 指示器跑偏问题,使指示器基于文字宽度居中。
  3. 能够使用 defineEmits 定义自定义事件,在 tab 点击时向外传递当前项数据,并在父组件中监听事件完成子传父通信。
  4. 能够使用插槽和 position: sticky 封装 custom-sticky 组件,使被包裹内容在页面滚动时实现吸顶效果。
  5. 能够为 custom-sticky 定义并接收 background-color、offset-top 等 props,通过动态 style 控制背景色与吸顶位置。
  6. 能够将 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 并允许外部覆盖。

常见错误 ​

  1. 现象:页面一加载就报错,提示取不到位置或 undefined;原因:computed 在 mounted 前执行,tabs 还是空数组却直接解构 tabs[activeIndex];修法:先判断 tabs.length === 0 并直接返回,或使用可选链/安全解构,不要只判断 activeIndex === 0。
  2. 现象:指示器能跟着切换但整体偏右、看起来没对准文字;原因:直接用文字节点的 left 当下划线 left,没有减去父容器/外层盒子的 left;修法:先算 textRect.left - parentRect.left,再套居中偏移公式。
  3. 现象:两个字的 tab 看似居中,换成“护肤品”三个字后就偏了;原因:用整个 tab 盒子宽度做居中参考,而不是文字实际宽度;修法:取文字小盒或文本节点的 width/left 计算,不要用整个 tab 盒子宽度。
  4. 现象:子组件点击后父组件收不到通知;原因:defineEmits 中定义的事件名与父组件 @事件名 不一致,或触发时写错事件名;修法:统一事件名,按“定义事件名→emit 触发→父组件监听→回调接收”逐项核对。
  5. 现象:父组件打印到 Proxy 或取值不符合预期;原因:直接把整个响应式对象 emit 出去,父组件未按需取值;修法:只传必要字段,如 emit('change', item.value),或明确在父组件中取 .value/具体属性。
  6. 现象:custom-sticky 设置偏移后出现透明空隙或背景断层;原因:用 top 做偏移让盒子整体下移,原位置留下空白;修法:吸顶仍用 position: sticky; top: 0,偏移改用 padding-top,并确保背景色覆盖。
  7. 现象:滚动后吸顶区域没有背景色或颜色写死无法复用;原因:组件未设置背景色,或背景色直接写死;修法:通过 props 暴露 backgroundColor,模板中用 :style 绑定,并给默认值。
  8. 现象:给组件传静态颜色时加冒号后不生效;原因::background-color="red" 中的 red 被当成变量;修法:静态字符串不加冒号,只有传变量时才加冒号。

练习 ​

  1. 将 custom-tabs 的指示器计算改为基于文字节点:准备两个字数和三个字数的 tab,切换后肉眼检查下划线是否都相对文字居中,并记录使用的 rect 字段。
  2. 复现“空数组解构报错”:先保留错误代码截图/日志,再修复为判空返回,说明为什么不能只判断 activeIndex === 0。
  3. 给 custom-tabs 设计子传父:父组件需要拿到被点击项的 value 和 index,完成事件定义、触发、监听和回调接收,并验证事件名不一致时父组件确实收不到。
  4. 优化 custom-sticky:分别用 top 和 padding-top 做偏移,滚动验证透明空隙差异;再通过父组件传入背景色和偏移值,检查默认值是否生效。
  5. 给 custom-sticky 增加使用检查清单:插槽内容是否显示、背景色是否可配、偏移是否可配、静态字符串与变量传参是否写对,逐项验收。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
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

小结 ​

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