Skip to content

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

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

学习目标 ​

  • 能够按 uni-app 自定义组件规范创建 custom-tabs 组件,完成组件命名、目录组织、模板与样式抽离,并在页面中通过 easycom 规则直接使用。
  • 能够为 custom-tabs 定义 props 接收外部传入的数组数据,使用 v-for 动态渲染标签项,并通过页面传参在调试工具中验证数据已正确传入。
  • 能够为标签项绑定点击事件并传递索引,使用 ref 维护当前选中索引,通过动态 class 实现“点谁谁高亮”的交互效果。
  • 能够使用 uni.createSelectorQuery 结合 getCurrentInstance() 查询每个标签节点的 left 与 width 信息,并通过控制台验证查询结果。
  • 能够将节点位置结果提取为组件内可复用的数据,按 left 和 width 处理每个标签的位置数据,为下划线游标居中及移动计算提供依据。

知识点 ​

先明确组件的边界与命名 ​

为什么需要:custom-tabs 后面项目还会继续用,如果每个人随意命名,自动导入和协作都会乱。转写稿里强调统一叫 custom-tabs,并放在 components 目录下。
怎么想:公共、全局组件放 components,页面私有小组件也可以正常导入;但既然本节要练 easycom 规范,就直接按规范建组件。
怎么做:在 components 下新建 custom-tabs/custom-tabs.vue,组件名与目录名保持规范一致。页面中使用时不需要手动注册、导入,直接写 <custom-tabs /> 即可获得提示。
易错点:组件名不要随便叫“张三李四”;忘记加 <script setup> 会导致后续逻辑写法不对;组件目录和文件名不规范时,自动导入可能失效。

从页面布局抽离模板与样式 ​

为什么需要:先把结构写出来,能直观看到 tab 长什么样;再抽成组件,页面会更干净,组件也便于复用。转写稿的做法是:先在首页 index 的配置区域下面放盒子,不要把内容粘到最外层,再把相关样式追加进去。
怎么想:组件本质仍是标准 Vue 组件:模板、样式、逻辑。写组件时没有任何“特殊语法”,只是把页面里的一段结构搬进来。
怎么做:把 tab 区域模板放进组件 <template>,把从相关注释往下的样式放进 <style>;页面里只保留组件标签。组件模板可写成:

vue
<template>
  <view class="custom-tabs">
    <view
      v-for="(item, index) in list"
      :key="item.text"
      class="tab"
      :class="{ active: tabIndex === index }"
      @click="onTab(index)"
    >
      {{ item.text }}
    </view>
    <view class="tab-line" :style="{ left: lineLeft + 'px' }"></view>
  </view>
</template>

易错点:粘样式时不要整页乱粘,避免污染;模板不要多套一层无关盒子;抽离后检查页面是否仍能正常显示。

用 props 接收外部数据 ​

为什么需要:tab 文字不能写死,“关注、推荐、护肤”等应由外部传入。转写稿明确:组件内部不负责查接口,外部给什么就展示什么。
怎么想:外部数据传到组件内部,最直接的方式是组件属性,也就是 props。组合式 API 中用 defineProps 定义。
怎么做:组件内定义 list 为数组;页面用 ref 定义数据并传进去:

js
// custom-tabs.vue
const props = defineProps({
  list: Array
})

// 页面 index.vue
import { ref } from 'vue'
const feedTabs = ref([
  { text: '关注' },
  { text: '推荐' },
  { text: '护肤' }
])
vue
<custom-tabs :list="feedTabs" />

调试时可在浏览器控制台选中组件,查看 props 中是否真的有三个数据;有数据说明传递成功。
易错点:忘记导入 ref;defineProps 是固定语法;外部传数组时不要漏掉绑定冒号;ref 使用时要记得 .value。

点击高亮与索引判断 ​

为什么需要:点谁谁高亮,必须知道点击的是第几个 tab。转写稿用索引值区分。
怎么想:先监听点击事件,把 index 传出来;再用一个变量 tabIndex 记录当前选中项;类名 active 根据 tabIndex === index 决定是否添加。
怎么做:

js
import { ref } from 'vue'
const tabIndex = ref(0)

function onTab(index) {
  tabIndex.value = index
}

模板中写 :class="{ active: tabIndex === index }"。默认 0 表示第一个高亮。
易错点:组合式 API 中事件回调就是普通函数;改变量要写 tabIndex.value = index,不要写成 this.tabIndex;类名判断不要永远写死 true。

查询节点位置并整理数据 ​

为什么需要:下划线要跟着 tab 移动,必须先拿到每个 tab 距左边的位置;还要拿宽度,才能让下划线在 tab 内居中。转写稿的推导是:大宽度减小宽度再除以 2,得到居中偏移。
怎么想:用 uni.createSelectorQuery() 查询 .tab 节点,获取 boundingClientRect 信息。自定义组件中要加 .in(getCurrentInstance()),因为组合式 API 里没有选项式里的 this。
怎么做:

js
import { ref, onMounted, getCurrentInstance } from 'vue'
const tabBarRect = ref([])

onMounted(() => {
  const query = uni.createSelectorQuery().in(getCurrentInstance())
  query.selectAll('.tab').boundingClientRect(res => {
    tabBarRect.value = res.map(({ width, left }) => ({ width, left }))
  }).exec()
})

调试时在控制台查看 tabBarRect,应看到每项只保留 width 和 left。转写稿提醒:onMounted 里的局部结果外部用不到,所以要定义外层 ref 再赋值。
易错点:忘记 .exec();忘记 .in(getCurrentInstance());组合式 API 不要写 this;map 返回对象时注意 { width, left } 是简写;ref 赋值不要漏 .value。

先把组件放对位置:公共组件与页面私有组件 ​

为什么需要:开发时如果所有组件都随意放,后面项目一大就难找、难复用。转写稿里强调:公共的、全局的组件按规范放到组件目录;某个页面自己的小组件,也可以正常导入使用。

怎么想:先判断组件复用范围。custom-tabs 后面项目还会用,所以统一命名并放到公共组件目录,避免“你叫张三、他叫李四”导致对不上。

怎么做:在组件目录下新建 custom-tabs,组件名统一。公共组件遵循 uni-app 组件规范后,页面里通常不需要手动注册、导入,直接写标签即可;页面私有组件则按普通导入方式使用。

易错点:不要因为能自动导入就把所有东西都塞进公共目录;页面私有组件强行放公共目录也能跑,但不利于维护。

把页面结构抽成组件:模板、样式、setup ​

为什么需要:先在页面里写布局能快速看效果,但最终要复用,就要把模板和样式抽到组件内部。

怎么想:转写稿的思路是“先把布局结构放到页面,再抽成组件也可以;既然组件规范讲完了,就直接在组件里写逻辑”。组件仍然是标准 Vue 组件:有模板、有样式、有逻辑。

怎么做:把页面中 tabs 相关模板移到 custom-tabs.vue 的 <template>,把对应样式移到 <style>,并补上 <script setup>。父页面中改为使用 <custom-tabs />。

易错点:最容易忘的是 <script setup>;转写稿反复强调“通过昨天练习发现大家很容易把它忘掉”。另外抽样式时不要连带把无关页面样式粘进去。

用 props 接收外部数据:列表不能写死 ​

为什么需要:tabs 的文字“关注、推荐、护肤”不能固定死在组件里,否则每换一处就要改组件。查询接口的逻辑应交给组件外部,组件只负责“你给我什么数据,我展示什么数据”。

怎么想:外部传内部,最直接的方式是组件属性。组合式 API 中用 defineProps 定义属性;这里需要数组,所以定义 list。

怎么做:

vue
<script setup>
const props = defineProps({
  list: Array
})
</script>

父页面定义数据并传递:

vue
<script setup>
import { ref } from 'vue'
const feed = ref([
  { text: '关注' },
  { text: '推荐' },
  { text: '护肤' }
])
</script>

<custom-tabs :list="feed" />

组件模板中遍历:

vue
<view
  v-for="(item, index) in list"
  :key="item.text"
  class="tab"
>
  {{ item.text }}
</view>

易错点:ref 用完要导入;父组件传值写 :list="feed",不要漏冒号;v-for 的 key 要唯一,这里用 item.text。

点击高亮:先监听点击,再用索引判断 ​

为什么需要:需求是“点谁谁高亮”,所以必须知道用户点的是第几个 tab。

怎么想:遍历时把索引 index 传给点击函数;组件内用一个变量记录当前选中项,例如 tabIndex。当 tabIndex === index 时加 active 类。

怎么做:

vue
<view
  v-for="(item, index) in list"
  :key="item.text"
  class="tab"
  :class="{ active: tabIndex === index }"
  @click="onTab(index)"
>
  {{ item.text }}
</view>
js
import { ref } from 'vue'
const tabIndex = ref(0)

function onTab(index) {
  tabIndex.value = index
}

易错点:组合式 API 中事件回调就是普通函数;修改 ref 必须写 tabIndex.value = index,不要写成 this.tabIndex。转写稿调试时发现“点了没变”,原因就是 tabIndex 一直是 0,后来在点击时改值才生效。

查询每个 tab 的位置:为下划线移动做准备 ​

为什么需要:下划线要跟着 tab 移动,必须先知道每个 tab 距离左边的位置和宽度。

怎么想:用 uni.createSelectorQuery() 查询所有 tab 节点,拿 boundingClientRect 的位置信息。注意:在自定义组件内使用节点查询,要加 .in(...) 指定组件实例。

怎么做:

js
import { onMounted, getCurrentInstance, ref } from 'vue'

const instance = getCurrentInstance()
const tabRect = ref([])

onMounted(() => {
  const query = uni.createSelectorQuery().in(instance)
  query.selectAll('.tab').boundingClientRect(res => {
    tabRect.value = res.map(({ left, width }) => ({ left, width }))
  }).exec()
})

易错点:组合式 API 里没有选项式 API 的 this,要用 getCurrentInstance() 获取实例;.in(instance) 不要漏,否则自定义组件内可能查不到节点。转写稿中先 console.log 结果,确认拿到数组、每项有 left,再继续。

把位置数据提到外层并处理:只保留 left 和 width ​

为什么需要:节点查询结果在 onMounted 回调里是局部数据,点击时还要用,所以必须存到组件外层可访问的变量中。

怎么想:定义 tabRect,在查询回调里赋值;原始结果字段很多,但下划线定位主要用 left 和 width,所以用 map 提取。

怎么做:如上段代码。后续计算下划线居中位置时,可用“tab 宽度减下划线宽度再除以 2”的思路得到偏移量。转写稿只讲到数据准备和居中计算思路,具体下划线移动逻辑需按后续步骤继续完成。

易错点:赋值时不要漏 .value;map 解构时字段名要和查询结果一致;先调试确认 tabRect 里每项只有 left、width,再写下划线计算,避免把未验证的数据直接用于定位。

先明确 custom-tabs 要解决的四件事 ​

这一节不是先写代码,而是先把需求拆开。转写稿里明确提到:文字内容不能写死;点谁谁高亮;底部小横线要跟着移动;组件内部还要把“当前点的是第几个”告诉外部。为什么需要先拆?因为后面每个写法都对应一个需求,否则很容易变成“照着敲但不知道在干嘛”。怎么想?先把数据流分成“外部传进来”和“内部状态”两类:列表数据由外部传入,当前选中索引由组件内部维护。怎么做?先定义组件,再接收数据,再渲染,再加点击,最后处理下划线位置。易错点是:不要一上来就写节点查询,先把数据和点击跑通。

组件定义与使用:先按规范抽组件 ​

转写稿里建议公共、全局组件放在规范目录下,页面私有小组件正常导入即可。这里统一新建 custom-tabs 组件,避免后面项目里名字对不上。组件模板和样式先搬进去,script setup 不要忘。为什么需要?因为 script setup 是组合式 API 的简写入口,忘了它后面的 defineProps、ref 都不能按预期工作。怎么做?组件内先保留模板和样式,外部页面按组件规范直接使用,不需要额外注册。易错点:转写稿反复强调“通过昨天练习发现大家很容易把 setup 忘掉”,这是第一个高频错误。

数据从父到子:用 defineProps 接收 list ​

为什么需要?文字不能固定死,而“查接口”这件事应该交给组件外部,组件只负责“你给我什么数据,我展示什么数据”。怎么想?外部到内部最直接的方式是组件属性,也就是 props。怎么做?组合式 API 里用 defineProps:

js
const props = defineProps({
  list: Array
})

父组件定义数据并传进去:

js
const feed = ref([
  { text: '关注' },
  { text: '推荐' },
  { text: '护肤' }
])
html
<custom-tabs :list="feed" />

转写稿里还现场调了一次 bug:用了 ref 却忘了导入。易错点:ref 要导入;父组件传数组时前面要有冒号;调试时可以在控制台选中 custom-tabs 组件,查看 props 里是否真的有三个数据。

用 v-for 渲染文字:不要把数据写死 ​

为什么需要?list 已经是数组,模板里不能再写死“关注、推荐、护肤”。怎么想?数组展示就遍历。怎么做?在组件模板里写:

html
<view
  v-for="(item, index) in list"
  :key="item.text"
  class="tab"
>
  {{ item.text }}
</view>

转写稿里提到“有的时候没有提示就复制一下”,是为了避免把 list 写错。易错点::key 要唯一,这里用 item.text;文字要改成 &#123;&#123; item.text &#125;&#125;,不要保留原来的静态文字。

点击高亮:索引 + active 类 ​

为什么需要?需求是“点谁谁亮”。怎么想?先监听点击,再知道点的是第几个,最后用变量控制类名。怎么做?遍历时绑定点击并传索引:

html
<view
  v-for="(item, index) in list"
  :key="item.text"
  class="tab"
  :class="{ active: tabIndex === index }"
  @click="onTab(index)"
>
  {{ item.text }}
</view>
js
const tabIndex = ref(0)

function onTab(index) {
  tabIndex.value = index
}

转写稿明确说:Vue 3 组合式 API 里事件回调就是普通函数;改 ref 要用 .value。易错点:tabIndex 默认给 0,所以第一个默认高亮;点击后忘记 tabIndex.value = index,就会“怎么点都不变”。

获取每个 tab 的左侧位置:组件内查询节点 ​

为什么需要?下划线要移动,必须先知道每个 tab 距离左边的位置。怎么想?用节点查询:创建查询器、选节点、拿位置、执行。怎么做?在 onMounted 里:

js
onMounted(() => {
  const query = uni.createSelectorQuery().in(getCurrentInstance())
  query.selectAll('.tab').boundingClientRect((res) => {
    console.log(res)
  }).exec()
})

转写稿特别补充:在自定义组件里要调 .in(...);组合式 API 没有选项式里的 this,要用 getCurrentInstance() 获取实例。易错点:忘记 .in(getCurrentInstance())、类名漏点、把 selectAll 写成 select、忘记 exec()。调试时看控制台,left 大约是 15、60、105,说明位置拿到了。

把查询结果保存到组件级数据并裁剪 ​

为什么需要?onMounted 回调里的 res 是局部数据,点击时用不到。怎么想?定义组件级 ref,在回调里赋值。怎么做?

js
const tabBar = ref([])

onMounted(() => {
  const query = uni.createSelectorQuery().in(getCurrentInstance())
  query.selectAll('.tab').boundingClientRect((res) => {
    tabBar.value = res.map(({ width, left }) => ({ width, left }))
  }).exec()
})

转写稿解释:只保留 width 和 left,因为后面下划线居中要用“tab 宽度减小横线宽度再除以 2”。易错点:赋值时漏掉 .value;map 解构写错;调试时在 Vue 面板查看 tabBar,应看到每项只含 width、left。这一节先做到这里,下一节再根据点击索引计算下划线位置。

代码示例 ​

1. 组件定义 + props 接收数据 + 点击高亮(custom-tabs.vue) ​

vue
<!-- components/custom-tabs/custom-tabs.vue -->
<template>
  <view class="custom-tabs">
    <view
      v-for="(item, index) in list"
      :key="item.text"
      class="tab"
      :class="{ active: tabIndex === index }"
      @click="onTab(index)"
    >
      {{ item.text }}
    </view>
    <view class="line" :style="{ left: lineLeft + 'px' }"></view>
  </view>
</template>

<script setup>
import { ref } from 'vue'

// 组件属性:接收组件外部传入的数据
defineProps({
  list: Array
})

// 当前选中的索引,默认第一个高亮
const tabIndex = ref(0)
// 下划线距离左边的位置
const lineLeft = ref(0)

// 组合式 API 中事件回调就是普通函数
const onTab = (index) => {
  tabIndex.value = index
}
</script>

<style lang="scss">
.custom-tabs {
  position: relative;
  display: flex;
  height: 80rpx;
  background: #fff;

  .tab {
    padding: 0 20rpx;
    line-height: 80rpx;
    font-size: 28rpx;
    color: #666;
  }

  .tab.active {
    color: #000;
    font-weight: 700;
  }

  .line {
    position: absolute;
    bottom: 0;
    left: 0;
    width: 40rpx;
    height: 4rpx;
    background: #f00;
    transition: left 0.3s;
  }
}
</style>

要点说明

  • 组件放在 components/custom-tabs/custom-tabs.vue,目录名与组件名同名,符合 easycom 规范,页面里不需要 import 和注册,直接写 <custom-tabs /> 即可。
  • 必须写 <script setup>(转写稿反复强调“很容易把它忘掉”)。
  • 外部数据进组件:最直接的方式就是组件属性,组合式 API 里用 defineProps({ list: Array }),写法是固定语法。
  • 点击谁谁亮:先监听 @click="onTab(index)",把 v-for 的索引传进去,再让 tabIndex 与索引比较,true 时挂上 active 类名。
  • 易错点:tabIndex.value = index 别漏 .value;:key 用 item.text,保证唯一不重复。

2. 页面中使用组件并传入数据(pages/index/index.vue) ​

vue
<!-- pages/index/index.vue -->
<template>
  <view class="index">
    <custom-tabs :list="feedList" />
  </view>
</template>

<script setup>
import { ref } from 'vue'

// 组件外部定义数据,具体怎么查由外部决定,组件只负责展示
const feedList = ref([
  { text: '关注' },
  { text: '推荐' },
  { text: '护肤' }
])
</script>

要点说明

  • 数据用对象数组,字段名 text 是自定义约定,好处是扩展性强(以后可加 id、count 等)。
  • 传递方式:属性名 list 对应组件里的 list,写 :list="feedList",一定要给数组。
  • 易错点:用 ref 时忘了 import { ref } from 'vue',运行时会直接报错(转写稿中就是这里出的 bug)。
  • 易错点:真实项目里数据应来自接口,本例写死只为演示;查询逻辑应放在组件外部,组件“给什么展示什么”。

3. 节点查询获取每个 tab 的位置并计算下划线居中位置 ​

vue
<!-- components/custom-tabs/custom-tabs.vue 的 script setup 部分 -->
<script setup>
import { ref, onMounted, getCurrentInstance } from 'vue'

defineProps({
  list: Array
})

const tabIndex = ref(0)
// 保存每个 tab 的宽度和左边距:[{ width, left }, ...]
const tabBar = ref([])
const lineLeft = ref(0)
// 下划线宽度,与样式里 .line 的 width 保持一致
const LINE_WIDTH = 20

// 计算下划线居中位置:tab左边距 + (tab宽度 - 下划线宽度) / 2
const setLine = (index) => {
  const item = tabBar.value[index]
  if (!item) return
  lineLeft.value = item.left + (item.width - LINE_WIDTH) / 2
}

const onTab = (index) => {
  tabIndex.value = index
  setLine(index)
}

onMounted(() => {
  // 1. 创建查询器(自定义组件内必须 .in(...))
  const query = uni.createSelectorQuery().in(getCurrentInstance())

  // 2. 查找元素 + 3. 执行查找
  query
    .selectAll('.tab')
    .boundingClientRect((res) => {
      // 只保留 width 和 left,交给 tabBar 使用
      tabBar.value = res.map(({ width, left }) => ({ width, left }))
      setLine(0)
    })
    .exec()
})
</script>

要点说明

  • 三步走:uni.createSelectorQuery() 创建查询器 → selectAll('.tab') 查找元素 → boundingClientRect(...) 拿位置尺寸 → .exec() 执行。
  • 自定义组件里必须调 .in(...),页面里可以不用;组合式 API 中没有 this,要用 getCurrentInstance() 获取组件实例,这是替代选项式 this 的写法。
  • 下划线居中的算法:(tab宽度 - 下划线宽度) / 2 + tab左边距,即“大减小再除以二”再加上偏移。
  • 易错点:onMounted 里拿到的 res 是局部数据,必须在外面用 ref 定义 tabBar 存起来,否则点击时拿不到。
  • 易错点:res.map(...) 后要赋值给 tabBar.value,漏掉 .value 是高频错误。
  • 单位注意:boundingClientRect 返回的是 px,而样式里常用 rpx,left 与下划线宽度混用时需保证口径一致,否则居中会偏。

常见错误 ​

  1. 现象:模板里写 <custom-tabs> 没有提示、不渲染。
    原因: 组件目录/文件名不符合 easycom 规范,或页面私有组件未显式导入。
    修法: 公共组件按 components/custom-tabs/custom-tabs.vue 放置;页面私有组件正常 import 后使用。

  2. 现象:把布局粘到首页后样式错乱、位置不对。
    原因: 代码粘到了最外层容器,或只粘了模板漏了对应样式。
    修法: 放回 index 配置对应容器内,模板和样式成对迁移,组件内保留完整模板与样式。

  3. 现象:组件逻辑不执行、页面空白或报 ref is not defined。
    原因: 忘记写 <script setup>,旧模板代码未清理,或 ref/onMounted/getCurrentInstance 未导入。
    修法: 确认使用 <script setup>,删除旧模板残留,按需导入组合式 API。

  4. 现象:list 拿到的是字符串,v-for 遍历异常。
    原因: 传数组时漏写冒号,写成 list="feedTabs"。
    修法: 改为 :list="feedTabs",并确认 props 类型为 Array、外部数据为 ref([])。

  5. 现象:点击后高亮始终停在第 0 项。
    原因: tabIndex 没有在点击回调中更新,或 class 判断写错。
    修法: 在 onTab(index) 中执行 tabIndex.value = index,模板用 tabIndex === index 控制 active 类。

  6. 现象:组件内 uni.createSelectorQuery() 查不到 tab 节点,结果为 null。
    原因: 自定义组件内查询节点未绑定当前实例,或组合式 API 里误用 this。
    修法: 使用 query.in(getCurrentInstance()).selectAll('.tab')...exec(),并确认类名、选择器正确。

  7. 现象:onMounted 里能打印位置,但点击时拿不到位置数据。
    原因: 查询结果只存在回调局部变量里,外部作用域无法使用。
    修法: 在外层定义 tabRect = ref([]),查询完成后赋值,点击逻辑统一读取该 ref。

  8. 现象:下划线位置偏移,没有对准 tab 居中。
    原因: 只用了 tab 的 left,没有结合 tab 宽度和下划线宽度计算居中。
    修法: 先保留每项的 left/width,再按居中规则计算下划线位置,并用点击索引联动。

练习 ​

  1. 按 easycom 规范创建 custom-tabs 组件,并在首页配置区域接入,确认无需手动注册即可显示。
  2. 在首页定义 feedTabs 数组,通过 :list 传入组件;组件内用 v-for 渲染文字,并保证 key 唯一。
  3. 实现点击高亮:点击任意 tab 后仅当前项添加 active 类,并在控制台验证传入的索引值正确。
  4. 在组件 onMounted 中查询所有 tab 的 left/width,整理成 [{ left, width }] 存入外层 ref,用调试工具确认数据完整。
  5. 基于上一步数据计算下划线居中位置,点击 tab 时下划线跟随移动;补充默认选中第 0 项的处理。

课堂画面 ​

课堂画面 01

课堂画面 02

课堂画面 03

课堂画面 04

课堂画面 05

课堂画面 06

原视频对照 ​

#视频时长
1自定义组件-custom-tabs布局模板04:24
2自定义组件-custom-tabs定义03:35
3自定义组件-custom-tabs组件数据10:21
4自定义组件-custom-tabs高亮显示10:01
5自定义组件-custom-tabs节点位置09:44
6自定义组件-custom-tabs位置数据处理08:19

小结 ​

  1. 先把布局和样式在页面中跑通,再抽成组件,排查成本更低。
  2. 公共全局组件走 easycom 规范;页面私有组件可正常导入,不必强行统一。
  3. custom-tabs 只负责展示与交互,tab 数据应由外部传入,查询数据的职责留在外部。
  4. 高亮状态用“当前索引”驱动,点击时更新索引,模板根据索引比较结果决定 active 类。
  5. 自定义组件内查节点必须绑定当前组件实例,组合式 API 下不要依赖 this。
  6. 节点位置数据要提升到组件外层作用域,否则 onMounted 的局部结果无法参与后续点击逻辑。
  7. 下划线联动的前提是先拿到每个 tab 的 left/width,再处理居中计算,最后与点击索引绑定。
  8. 调试时优先检查 props、ref、节点查询结果和当前索引,能快速定位大部分联动问题。