普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月17日首页

vue3 数据响应式遇到的问题

2026年4月17日 17:56

问题背景

是在vue2项目升级vue3项目中遇到的,因为升级项目并没有使用vue的Composition api 而是使用Options api,所有复杂类型变量默认使用reactive进行响应式,问题也是从这出现的

  1. 对象数组中,使用索引值更改数据,数据变化了页面没有变化
  • 类似代码 - options api使用的是this指针方式,但是问题是一样的
cosnt arr = reactive({
  arr1:[
    {
        "name": "test",
        "name2": "test2",
        "name3": "test3",
    },
    {
        "name": "test",
        "name2": "test2",
        "name3": "test3",
    }
]  
})
arr["arr1"][0] = {"name": "test11","name2": "test22","name3": "test33",}

这个时候我们从vue3的源代码入手,分析原因,具体只需要看proxy 的 get方法

源代码地址 packages\reactivity\src\baseHandlers.ts

有个BaseReactiveHandler方法

a48ac8538ee92c0bebe96aed437525ae.png

当我们触发get方法时,如果还是复杂类型,需要在调用reactive将其转化成响应式,所以Vue的依赖收集是"按需"的,具有一种懒惰性质,层级较深的复杂类型数据不是在声明式就被全部转化成响应式,而是在获取时逐层转化的

这个时候我们回看我们的问题,当我们执行arr["arr1"][0] = {"name": "test11","name2": "test22","name3": "test33",}的时候,只触发了arr["arr1"],但是再往下层级的并没有被转化成响应式,所以此时我们可以这样去解决

consr newArr = arr["arr1"]
newArr[0] = {"name": "test11","name2": "test22","name3": "test33",}
arr["arr1"] = newArr[0]

2. 解构失去响应式

    const test = reactive({
      arr: { name: '111'}
    })
    // 解构会失去响应性!
    const { arr } = test
    // 修改 user 不会触发界面更新
    arr.name = '李四'  //界面不更新

    //解决方案
    // 1、
    const { arr } = toRefs(test)
    // 2、尽量不要解构响应式数据

3. 新增属性不响应

    const test = reactive({
      arr: { name: '111'}
    })

    // 修改 user 不会触发界面更新
    test.arr.age= 22  //界面不更新

    // 解决方法 使用 Object.assign
    Object.assign(test.arr, { age: 22 }) 

总结

所以在vue3 开发中,如果使用options api方式,就需要尽量注意多层嵌套对象,如果使用Composition api,尽量使用 ref 去定义变量,并且针对嵌套层级较深的变量最好使用ShallowRef ShallowReactive 用于优化深层嵌套对象的性能问题

都知道AI大模型能生成文本内容,那你知道大模型是怎样生成文本的吗?

2026年4月17日 17:16

举个例子

想象一下,AI大模型用 0.5秒生成了这样一段文本:

"今天天气真好,我决定去公园散步,呼吸一下新鲜空气,放松身心。"

那么问题来了:这个AI怎样在这么短的时间内,"想出来"这条段文本的?

答案可能会颠覆你的想象。它并不是在"思考",而是在进行一个机械的、分步的数学运算。

今天,我就用你能理解的方式,把大模型这5个神奇的步骤拆解给你看。


第一步:你说什么,我就听什么

01 | 输入与拆分(Input & Tokenization)

场景还原:

假如你现在对大模型说:

"用最有趣的方式讲解一个笑话"

大模型听到这句话时,它不是像我们一样理解语义,而是做一个非常机械的动作:把你的文字拆成一个个最小的单元

这个最小单元叫做 Token(令牌)

Token 是什么?

你可以把它理解为"汉字"或"词"。比如:

  • "用" = 1个Token
  • "最" = 1个Token
  • "有趣" = 1个Token
  • "的" = 1个Token
  • "方式" = 1个Token
  • "讲解" = 1个Token

所以你的一句话被拆成了一串Token序列:

[用][最][有趣][的][方式][讲解][一][个][笑话]

为什么要这样拆?

因为大模型的"大脑"(神经网络)只能理解数字,不能直接理解文字。

每个Token都被转换成一串数字(向量),看起来像这样:

"用" → [0.2, -0.5, 0.8, 0.1, ...](几百个数字)
"最" → [0.3, -0.2, 0.5, 0.9, ...](几百个数字)
...

这就像把人类的语言翻译成计算机能理解的"密码"。


第二步:我是怎样理解你的意思的?

02 | 上下文编码(Context Encoding)

场景还原:

现在大模型有了一串数字(Token的向量表示),接下来它要做的是:理解这些数字之间的关系

这一步发生在大模型的"大脑"——Transformer架构中。

Transformer在做什么?

Transformer是一个特殊的神经网络结构,它做的事情听起来很复杂,但核心思想很简单:

它在计算你输入的每个词与其他词之间的"关系强度"。

具体例子:

如果你说:"小王很聪明,他喜欢编程,但他讨厌数学。"

Transformer会这样计算:

  • "他" 和 "小王" 的关系强度:95%("他"指代"小王")
  • "他" 和 "数学" 的关系强度:30%(有关系但不是指代)
  • "他" 和 "讨厌" 的关系强度:80%("他"是"讨厌"的主语)

通过这种"关系计算",Transformer把你的输入文本转化成一个包含丰富上下文信息的数学表示

换句话说,大模型现在"懂"你说的是什么意思了——不是真的懂,而是把你的意思转化成了数学。

一个有趣的观察:

这就是为什么大模型有时候能推断你没有明说的东西。因为Transformer计算了所有词之间的关系,它能从这些关系中"推断"出隐含的意思。


第三步:我给出每个词的概率

03 | 概率计算(Probability Calculation)

场景还原:

现在,大模型已经理解了你的意思(至少在数学层面)。

接下来,它要做一个关键的决定:下一个词应该是什么?

但大模型不是"想"出来的,而是通过计算所有可能词汇的概率

具体过程:

假如你说:"今天天气真好,我想……"

大模型此时会计算:在"我想"之后,所有词出现的概率。

结果可能是这样的:

[去散步]32%
[出门]28%
[休息]18%
[唱歌]8%
[吃饭]7%
[睡觉]4%
[其他]3%

这就像在说:"根据我读过的所有文本,当有人说'我想'的时候,接下来最有可能说'去散步'(32%的概率)。"

为什么是这个概率?

因为大模型在预训练时,接触过数万亿个词的组合。它把这些统计规律"记录"在自己的参数中。当你说"我想"时,它就从记忆中翻出:

  • 在所有"我想"的后面,"去散步"出现了多少次?
  • "出门"出现了多少次?
  • ...以此类推

重要的认识:

大模型这一步做的是统计,而不是思考。它问的不是"逻辑上下一个词应该是什么",而是"历史上这种情况下下一个词通常是什么"。

这解释了为什么大模型有时候会说出很聪明的话(因为统计规律确实反映了正确的知识),有时候也会说出荒唐的话(因为它只是在"赌概率")。


第四步:我选一个最可能的词

04 | 采样输出(Sampling Output)

场景还原:

现在大模型有了一个概率分布(如上面所示)。接下来的问题是:怎样选择?

有两种策略:

策略A:贪心采样(Greedy Sampling)

规则很简单:选概率最高的。

[去散步] → 32% ← 选这个!
[出门] → 28%
[休息] → 18%
...

这样做的好处是:结果最稳定,最有可能"正确"

坏处是:内容容易重复和千篇一律

如果你每次问大模型同一个问题,它会给你几乎完全相同的答案。

策略B:随机采样(Random Sampling)

按照概率,随机选择一个词。

就像转一个转盘,32%的区域里"去散步",28%的区域里"出门",然后随机转动指针。

这样做的好处是:结果多样化,每次回答都不一样

坏处是:有时候会选出一些"概率很低但突然出现"的词,导致内容有点奇怪

实际应用:

大多数大模型使用的是"温度"参数来控制这两个极端之间的平衡:

  • 温度=0:完全贪心采样(最稳定)
  • 温度=1:完全随机采样(最多样)
  • 温度=0.7:介于两者之间(大多数应用的默认值)

第五步:我把新词加到你的话里,然后重复

05 | 迭代生成(Iterative Generation)

场景还原:

现在大模型选了一个词——比如"去散步"。

接下来发生的事情很简单,但非常强大:

它把这个新词加到原文本的末尾,然后重复第2-4步。

让我展示整个过程:

1次循环:
输入:"今天天气真好,我想"
↓(经过第2-4步)
选择:"去"
结果:"今天天气真好,我想去"2次循环:
输入:"今天天气真好,我想去"
↓(经过第2-4步)
选择:"散"
结果:"今天天气真好,我想去散"3次循环:
输入:"今天天气真好,我想去散"
↓(经过第2-4步)
选择:"步"
结果:"今天天气真好,我想去散步"4次循环:
输入:"今天天气真好,我想去散步"
↓(经过第2-4步)
选择:","
结果:"今天天气真好,我想去散步,"5次循环:
输入:"今天天气真好,我想去散步,"
↓(经过第2-4步)
选择:"呼"
结果:"今天天气真好,我想去散步,呼"

...继续循环...

直到大模型选出了[结束标记],生成过程才停止。

最终结果:

"今天天气真好,我想去散步,呼吸一下新鲜空气,放松身心。"

完整的微博在你眼睛一眨眼的功夫就生成好了。


深层理解:为什么看起来这么聪明?

现在让我们回到最开始的问题:大模型为什么能生成这么连贯、这么"有意义"的文本?

答案其实很意外:它根本不是在思考,而是在进行一个机械但高度优化的数学运算。

具体来说:

1. 神奇的统计规律

大模型在训练时,接触过数万亿个词的组合。这创建了一个巨大的"统计记忆":

  • 在所有文本中,"今天天气很好"后面跟"去散步"的频率有多高?
  • "我想"后面通常跟什么词?
  • "放松身心"通常怎样结尾?

正是这些统计规律,使得大模型能生成"看起来很自然"的文本。

2. 参数的力量

大模型有数十亿甚至数万亿个参数(可调节的权重)。这些参数共同作用,把这些统计规律"压缩"存储在神经网络中。

所以当你输入一个问题时,大模型实际上是在:

  • 调用这些参数
  • 执行数学运算
  • 从概率分布中采样

3. 涌现能力

有趣的是,当参数数量足够多、训练数据足够大时,一些"意想不到"的能力会出现:

  • 模型能回答从未在训练数据中见过的问题
  • 模型能理解"含义"(虽然它实际上只是在做数学)
  • 模型能执行多步骤的逻辑推理

这些被称为"涌现能力"——大模型做的是统计,但统计足够复杂时,就呈现出了"智能"的样子。


这个过程有什么局限?

理解了这个5步过程后,你也就理解了为什么大模型有时候会:

1. 编造信息

因为它只是在"填概率",如果某个词的概率是正数,它就可能被选中——即使这个词在这个上下文里没有根据。

2. 处理数学计算很差

因为"1+1=2"这样的计算,根本不是概率问题。大模型没有专门的计算模块,只能靠概率去"猜"答案。

3. 知识过期

因为大模型的知识来自训练数据的统计。2024年的新闻事件,如果没有在训练数据中出现过,大模型就无从知晓。

4. 容易被欺骗

因为它只是在做模式匹配。如果你用巧妙的prompt,可以让它做出不该做的事情。


最后的思考

当你看到一条"聪明的AI回答"时,不妨停下来想一想:

这真的是AI在思考吗?还是它只是在用难以想象的复杂性来做统计?

答案是:两者都是。

从某种意义上,统计足够复杂,就变成了智能。正如人类的思维也是由神经元的电化学过程组成的,但我们说人在"思考"一样。

大模型的5步生成过程看似简单:

  1. 拆成Token
  2. 理解上下文
  3. 计算概率
  4. 选择词汇
  5. 重复迭代

但这个过程重复数百次、数千次,加上数万亿个参数的协同作用,就产生了让人惊叹的结果。

这也是为什么有人说:大模型是"大力出奇迹"——因为它真的就是靠着巨量的参数、巨量的数据、和巨量的计算,实现了这种表面看起来"智能"的行为。

下次当大模型给你一个答案时,你会想到它在背后经历的这5个步骤吗?


想了解如何开发设计图中的AI应用?右下角扫码了解

设计图(带二维码).png

6.png

vxe-table 自定义数字行主键,解决默认字符串主键与后端类型不匹配问题

作者 卤蛋fg6
2026年4月17日 17:14

vxe-table 自定义数字行主键,解决默认字符串主键与后端类型不匹配问题 在使用 vxe-table 表格组件时,组件默认自动生成的行主键为字符串类型,但后端接口通常要求主键为数值(number)类型,直接提交会因数据类型不匹配导致接口报错。 有两种最优解决方案,支持局部配置和全局统一配置,彻底解决类型不兼容问题。

核心解决方案

vxe-table 提供了灵活的主键配置能力,推荐两种实用方案:

  1. 指定业务字段为主键:直接使用后端返回的数字 ID 作为行主键(推荐已有数据场景)
  2. 自定义主键生成方法:自定义生成数字类型的自增主键(推荐新增行场景)

代码

定义行主键生成逻辑,生成规则可以通过 row-config.createKeyMethod 来自定义,也可以全局定义。

<template>
  <div>
    <!-- 新增行按钮 -->
    <vxe-button type="primary" @click="addEvent">新增数据</vxe-button>

    <!-- vxe-table 表格 -->
    <vxe-grid ref="gridRef" v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive } from 'vue'

// 表格行数据类型定义
interface TableRow {
  id: number; // 明确指定为数字类型主键
  name: string;
  role?: string;
  sex?: string;
  age?: number;
  address?: string;
}

// 表格实例引用
const gridRef = ref<InstanceType<typeof import('vxe-table')> | null>(null)

// 数字主键自增初始值(可根据业务调整)
let idSeed = 1000000000

// 表格配置项
const gridOptions = reactive({
  border: true,
  showOverflow: true,
  keepSource: true,
  height: 400,
  // 核心:自定义行主键配置
  rowConfig: {
    keyField: 'id', // 指定 id 字段作为行唯一主键
    // 自定义主键生成方法:返回数字类型,实现自增
    createKeyMethod: () => idSeed++
  },
  // 单元格编辑配置
  editConfig: {
    trigger: 'click',
    mode: 'cell',
    showStatus: true
  },
  // 表格列配置
  columns: [
    { type: 'seq', width: 70, title: '序号' },
    { field: 'name', title: '姓名', editRender: { name: 'input' } },
    { field: 'sex', title: '性别', editRender: { name: 'input' } },
    { field: 'age', title: '年龄', editRender: { name: 'input' } },
    { field: 'address', title: '地址', editRender: { name: 'input' } }
  ],
  // 初始化数据(id 均为数字类型)
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: '男', age: 28, address: 'test abc' },
    { id: 10002, name: 'Test2', role: 'Test', sex: '女', age: 22, address: '广州' },
    { id: 10003, name: 'Test3', role: 'PM', sex: '男', age: 32, address: '上海' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: '女', age: 24, address: '上海' }
  ]
})

// 新增行事件
const addEvent = async () => {
  const $grid = gridRef.value
  if (!$grid) return

  // 新增空数据,主键由自定义方法自动生成
  const newRecord = { name: `Name_${Date.now()}` }
  const { row: newRow } = await $grid.insert(newRecord)

  // 验证:主键为数字类型
  console.log('新增行主键类型:', typeof newRow.id, '主键值:', newRow.id)
  console.log('新增行数据:', newRow)

  // 自动聚焦编辑姓名单元格
  $grid.setEditCell(newRow, 'name')
}
</script>

image

关键配置说明

参数作用

rowConfig.keyField指定表格行的唯一主键字段(如 id),替代默认主键 rowConfig.createKeyMethod自定义主键生成函数,返回值即为最终主键

全局配置(推荐多页面复用)

// main.ts
import { VxeUI } from 'vxe-table'

let globalIdSeed = 1000000000

VxeUI.setConfig({
  table: {
    rowConfig: {
      keyField: 'id',
      createKeyMethod: () => globalIdSeed++
    }
  }
})

方案对比与选择

  • 指定业务字段为主键
    • 适用场景:表格数据由后端返回,自带数字 ID
    • 优点:无额外逻辑,直接复用后端 ID
    • 配置:仅需设置 rowConfig: { keyField: 'id' }
  • 自定义主键生成方法
    • 适用场景:前端新增临时数据、无后端 ID 场景
    • 优点:完全可控,强制生成数字类型,避免类型报错
    • 配置:keyField + createKeyMethod 组合使用

github文档: github.com/x-extends/v…
vxetable.cn

Vue 组件间通信

2026年4月17日 14:19

Vue 组件间通信

props 传递数据(父 → 子)

父组件将要传递的数据写入子组件的标签属性中,子组件通过 props 来获取父组件传递的数据。

<template>
  <Children name="jack" :age="18" />
</template>
<script>
export default {
  props: ['name', age],
};
</script>

$emit 触发自定义事件(子 → 父)

父组件将逻辑函数传递给子组件,子组件接受后触发逻辑函数返回给父子间信息。

<template>
  <Children @add="addUser" />
</template>
<script>
export default {
  // ...
  methods: {
    addUser(user) {
      // ...
    },
  },
};
</script>
<template>
  <button @click="addUser">添加用户</button>
</template>
<script>
export default {
  // ...
  methods: {
    addUser() {
      this.$emit('add', { name: 'wendy', age: 18 });
    },
  },
};
</script>

$refs 与 $children(子 → 父)

父组件可以通过 $refs$children 获取到子组件实例,通过实例直接获取子组件的数据信息。

可以通过多个 $refs$children 传递获取到各个后代的数据信息。

<template>
  <Children ref="children" />
</template>
<script>
export default {
  // ...
  methods: {
    getChildrenData(key) {
      // this.$children ...
      return this.$refs.children[key];
    },
  },
};
</script>

$parent 或 $root (父 → 子)

$refs类似 vue 组件可以访问this.$parent获取到直接父级实例,访问this.$root获取到根节点实例,并通过实例获取到组件信息。

由于可以直接拿到父级或根节点实例,那么也可以直接使用其实例的方法事件等,可以结合其他的传送方式达到同级传输信息的效果。但是这样通常会让项目信息传递变得混乱。

<script>
export default {
  // ...
  mounted() {
    // 在 mounted 后才可以访问当实例
    // this.$parent...
    // this.$root...
  },
};
</script>

$attrs 与 $listeners (父 → 子)

这里在讨论 vue3 中的$attrs$listeners。vue2 的$attrs$listeners可以转至 vue2 中的attrsattrs 与listeners

Vue3 已经不支持 $listeners 了。

在 vue 组件通过props传递数据时,子组件可能没有通过props接收,这部分没有被props接收的组件会保存在子组件的$attrs中(style 和 class 也会保存)

<template>
  <Children t0="t0" t1="t1" t2="t2" class="children" @click="clickCallback"></Children>
</template>
<script>
export default {
  // ...
props: ['t0']
  created() {
    console.log("Children", this.$attrs);
  },
};
</script>

输出如图:

alt text转存失败,建议直接上传图片文件

如果需要多层传递数据信息,可以使用v-bind批量绑定$attrs中的属性:

<template>
  <Children class="children" v-bind="$attrs"></Children>
</template>

provide 与 inject (父 → 孙)

vue2 provide-inject 介绍 / vue3 provide-inject 介绍

provideinject可以直接绕过目标组件之间的其他组件直接进行数据信息传递:

alt text转存失败,建议直接上传图片文件

vue2 中简单的使用:

<script>
export default {
  // ...
  provide: {
    name: 'Wendy',
    age: 18,
  },
};
</script>
<script>
export default {
  // ...
  // inject: ['name', 'age'], 使用数组接收
  inject: {
    userName: {
      from: 'name',
      default: 'noUser',
    },
    userAge: {
      from: 'age',
      default: 18,
    },
  },
};
</script>

EventBus 事件总线(任意 → 任意)

EventBus基于订阅/发布模式实现,相当于一个独立于各个组件的事件处理中心。每个组件都可以通过EventBus进行信息传递。

在创建好EventBus后,组件可以通过$on接收指定类型的信息,也可以通过$emit发送指定类型的信息。

// 通过 Vue 创建 EventBus
Vue.prototype.$bus = new Vue();

发送名为 eventName 的事件。

<script>
export default {
  // ...
  methods: {
    sendEvent(data) {
      this.$bus.$emit('eventName', data);
    },
  },
};
</script>

接收名为 eventName 的事件。

<script>
export default {
  // ...
  created() {
    this.$bus.$on('eventName', (data) => {
      // ...
    });
  },
};
</script>

vuex (任意 → 任意)

Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式 + 库。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化。

在 vue 组件中可以通过 watch 监听 vuex 数据的变化并作出相应改变:

<script>
export default {
  // ...
  watch: {
    '$store.state.count'(newVal, oldVal) {
      // ...
    },
    '$store.state.message'(newVal, oldVal) {
      // ...
    },
  },
};
</script>

参考

  1. 组件间通信的方案

Element Plus 主题构建方案

作者 一壶纱
2026年4月17日 11:33

Element Plus 主题构建步骤

第一步:安装依赖

项目里需要 sass 来编译 element-plus/theme-chalk 的 SCSS 源码。

pnpm add -D sass

第二步:新建主题构建插件文件

新建文件:

build/plugins/element-plus-theme.ts

最终代码如下:

import path from 'node:path'
import { promises as fs } from 'node:fs'

import { compileAsync } from 'sass'
import type { Plugin } from 'vite'

const OUTPUT_DIR = 'src/assets/generated'
const OUTPUT_FILE = 'element-plus-theme.css'
const TEMP_SCSS_FILE = 'element-plus-theme.scss'

const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']
const BASE_COMPONENTS = ['base']

const THEME_COLORS = {
  primary: '#215476',
  success: '#67c23a',
  warning: '#e6a23c',
  danger: '#f56c6c',
  error: '#f56c6c',
  info: '#909399',
}

const normalizePath = (targetPath: string) => targetPath.replace(/\\/g, '/')

const ensureRelativeImportPath = (targetPath: string) => {
  const normalizedPath = normalizePath(targetPath)

  if (normalizedPath.startsWith('./') || normalizedPath.startsWith('../')) {
    return normalizedPath
  }

  return `./${normalizedPath}`
}

const tagToComponentName = (tagName: string) => {
  return tagName.replace(/^el-/, '')
}

const scriptToComponentName = (componentName: string) => {
  return componentName
    .replace(/^El/, '')
    .replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)
    .replace(/^-/, '')
}

const createScssEntry = (themeChalkSrcImportPath: string, componentNames: string[]) => {
  const imports = componentNames
    .map(name => `@use "${themeChalkSrcImportPath}/${name}.scss" as *;`)
    .join('\n')

  return `@forward "${themeChalkSrcImportPath}/common/var.scss" with (
  $colors: (
    "primary": ("base": ${THEME_COLORS.primary}),
    "success": ("base": ${THEME_COLORS.success}),
    "warning": ("base": ${THEME_COLORS.warning}),
    "danger": ("base": ${THEME_COLORS.danger}),
    "error": ("base": ${THEME_COLORS.error}),
    "info": ("base": ${THEME_COLORS.info})
  )
);

${imports}
`
}

const scanUsedComponents = async (root: string) => {
  const srcDir = path.resolve(root, 'src')
  const usedComponents = new Set<string>([...BASE_COMPONENTS, ...SERVICE_COMPONENTS])

  const visit = async (targetPath: string): Promise<void> => {
    const stat = await fs.stat(targetPath)

    if (stat.isDirectory()) {
      const children = await fs.readdir(targetPath)
      await Promise.all(children.map(name => visit(path.join(targetPath, name))))
      return
    }

    if (!/\.(vue|ts|tsx|js|jsx)$/.test(targetPath)) {
      return
    }

    const source = await fs.readFile(targetPath, 'utf-8')
    const templateMatches = source.matchAll(/<\s*(el-[a-z0-9-]+)/g)
    const scriptMatches = source.matchAll(/\b(El[A-Z][A-Za-z]+)\b/g)

    for (const match of templateMatches) {
      usedComponents.add(tagToComponentName(match[1]))
    }

    for (const match of scriptMatches) {
      usedComponents.add(scriptToComponentName(match[1]))
    }
  }

  await visit(srcDir)
  return [...usedComponents].sort()
}

const buildThemeCss = async (root: string, command: 'serve' | 'build') => {
  const outputDir = path.resolve(root, OUTPUT_DIR)
  const outputPath = path.resolve(outputDir, OUTPUT_FILE)
  const tempScssPath = path.resolve(outputDir, TEMP_SCSS_FILE)
  const themeChalkSrcPath = path.resolve(root, 'node_modules/element-plus/theme-chalk/src')
  const themeChalkSrcImportPath = ensureRelativeImportPath(
    path.relative(outputDir, themeChalkSrcPath),
  )
  const componentNames = command === 'serve' ? ['index'] : await scanUsedComponents(root)
  const source = createScssEntry(themeChalkSrcImportPath, componentNames)

  await fs.mkdir(outputDir, { recursive: true })
  await fs.writeFile(tempScssPath, source, 'utf-8')

  const result = await compileAsync(tempScssPath, {
    loadPaths: [root],
    sourceMap: command === 'serve',
    style: command === 'serve' ? 'expanded' : 'compressed',
  })

  await fs.writeFile(outputPath, result.css, 'utf-8')
}

export const elementPlusThemePlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-plugin',
    apply: 'serve',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'serve')
    },
  }
}

export const elementPlusThemeBuildPlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-build-plugin',
    apply: 'build',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'build')
    },
  }
}

第三步:先看懂这几个常量

1. 输出目录

const OUTPUT_DIR = 'src/assets/generated'
const OUTPUT_FILE = 'element-plus-theme.css'
const TEMP_SCSS_FILE = 'element-plus-theme.scss'

最终会生成两个文件:

src/assets/generated/element-plus-theme.scss
src/assets/generated/element-plus-theme.css
  • scss 是临时入口文件
  • css 是项目真正引入的文件

2. 默认保留的组件

const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']
const BASE_COMPONENTS = ['base']

即使源码没扫到,也会保留这些样式。

原因:

  • base 是基础样式
  • messageloading 这类服务型组件容易漏

3. 主题色配置

const THEME_COLORS = {
  primary: '#215476',
  success: '#67c23a',
  warning: '#e6a23c',
  danger: '#f56c6c',
  error: '#f56c6c',
  info: '#909399',
}

后面改主题色,优先改这里。


第四步:处理 Sass 导入路径

看这两个函数:

const normalizePath = (targetPath: string) => targetPath.replace(/\\/g, '/')

const ensureRelativeImportPath = (targetPath: string) => {
  const normalizedPath = normalizePath(targetPath)

  if (normalizedPath.startsWith('./') || normalizedPath.startsWith('../')) {
    return normalizedPath
  }

  return `./${normalizedPath}`
}

这一步必须有。

因为 Sass 在 Windows 下处理路径时,如果路径不是:

./xxx
../xxx

就可能把它当成包名,然后报错:

Can't find stylesheet to import

所以这里做了两件事:

  1. 统一把 \ 转成 /
  2. 强制路径带上相对前缀

第五步:生成临时 SCSS 入口文件

核心函数:

const createScssEntry = (themeChalkSrcImportPath: string, componentNames: string[]) => {
  const imports = componentNames
    .map(name => `@use "${themeChalkSrcImportPath}/${name}.scss" as *;`)
    .join('\n')

  return `@forward "${themeChalkSrcImportPath}/common/var.scss" with (
  $colors: (
    "primary": ("base": ${THEME_COLORS.primary}),
    "success": ("base": ${THEME_COLORS.success}),
    "warning": ("base": ${THEME_COLORS.warning}),
    "danger": ("base": ${THEME_COLORS.danger}),
    "error": ("base": ${THEME_COLORS.error}),
    "info": ("base": ${THEME_COLORS.info})
  )
);

${imports}
`
}

这段代码做两件事:

1. 用 @forward 覆盖变量

最终会生成类似:

@forward "../../../node_modules/element-plus/theme-chalk/src/common/var.scss" with (
  $colors: (
    "primary": ("base": #215476),
    "success": ("base": #67c23a),
    "warning": ("base": #e6a23c),
    "danger": ("base": #f56c6c),
    "error": ("base": #f56c6c),
    "info": ("base": #909399)
  )
);

2. 用 @use 引入组件样式

开发环境会生成:

@use "../../../node_modules/element-plus/theme-chalk/src/index.scss" as *;

生产环境会生成:

@use "../../../node_modules/element-plus/theme-chalk/src/base.scss" as *;
@use "../../../node_modules/element-plus/theme-chalk/src/button.scss" as *;
@use "../../../node_modules/element-plus/theme-chalk/src/form.scss" as *;

所以这个函数的作用就是:

  • 先覆盖变量
  • 再拼接本次要构建的样式入口

第六步:扫描项目里实际使用到的 Element Plus 组件

核心函数:

const scanUsedComponents = async (root: string) => {
  const srcDir = path.resolve(root, 'src')
  const usedComponents = new Set<string>([...BASE_COMPONENTS, ...SERVICE_COMPONENTS])

  const visit = async (targetPath: string): Promise<void> => {
    const stat = await fs.stat(targetPath)

    if (stat.isDirectory()) {
      const children = await fs.readdir(targetPath)
      await Promise.all(children.map(name => visit(path.join(targetPath, name))))
      return
    }

    if (!/\.(vue|ts|tsx|js|jsx)$/.test(targetPath)) {
      return
    }

    const source = await fs.readFile(targetPath, 'utf-8')
    const templateMatches = source.matchAll(/<\s*(el-[a-z0-9-]+)/g)
    const scriptMatches = source.matchAll(/\b(El[A-Z][A-Za-z]+)\b/g)

    for (const match of templateMatches) {
      usedComponents.add(tagToComponentName(match[1]))
    }

    for (const match of scriptMatches) {
      usedComponents.add(scriptToComponentName(match[1]))
    }
  }

  await visit(srcDir)
  return [...usedComponents].sort()
}

它扫描哪些文件

  • .vue
  • .ts
  • .tsx
  • .js
  • .jsx

它怎么识别模板中的组件

靠这个正则:

/<\s*(el-[a-z0-9-]+)/g

比如:

<el-form />
<el-input />
<el-button />

会识别成:

  • form
  • input
  • button

它怎么识别服务型组件

靠这个正则:

/\b(El[A-Z][A-Za-z]+)\b/g

比如:

ElMessage.success('成功')
ElLoading.service(...)

会识别成:

  • message
  • loading

组件名为什么还要转换

因为模板和脚本中的名字,不是 theme-chalk 的文件名格式。

比如:

  • <el-form> 需要变成 form.scss
  • ElMessageBox 需要变成 message-box.scss

所以这里配了两个转换函数:

const tagToComponentName = (tagName: string) => {
  return tagName.replace(/^el-/, '')
}

const scriptToComponentName = (componentName: string) => {
  return componentName
    .replace(/^El/, '')
    .replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)
    .replace(/^-/, '')
}

第七步:真正执行主题构建

核心函数:

const buildThemeCss = async (root: string, command: 'serve' | 'build') => {
  const outputDir = path.resolve(root, OUTPUT_DIR)
  const outputPath = path.resolve(outputDir, OUTPUT_FILE)
  const tempScssPath = path.resolve(outputDir, TEMP_SCSS_FILE)
  const themeChalkSrcPath = path.resolve(root, 'node_modules/element-plus/theme-chalk/src')
  const themeChalkSrcImportPath = ensureRelativeImportPath(
    path.relative(outputDir, themeChalkSrcPath),
  )
  const componentNames = command === 'serve' ? ['index'] : await scanUsedComponents(root)
  const source = createScssEntry(themeChalkSrcImportPath, componentNames)

  await fs.mkdir(outputDir, { recursive: true })
  await fs.writeFile(tempScssPath, source, 'utf-8')

  const result = await compileAsync(tempScssPath, {
    loadPaths: [root],
    sourceMap: command === 'serve',
    style: command === 'serve' ? 'expanded' : 'compressed',
  })

  await fs.writeFile(outputPath, result.css, 'utf-8')
}

按执行顺序看:

1. 找到输出目录和输出文件

const outputDir = path.resolve(root, OUTPUT_DIR)
const outputPath = path.resolve(outputDir, OUTPUT_FILE)
const tempScssPath = path.resolve(outputDir, TEMP_SCSS_FILE)

2. 找到 theme-chalk/src

const themeChalkSrcPath = path.resolve(root, 'node_modules/element-plus/theme-chalk/src')

3. 转成 Sass 可识别的相对导入路径

const themeChalkSrcImportPath = ensureRelativeImportPath(
  path.relative(outputDir, themeChalkSrcPath),
)

4. 决定是全量还是按需

const componentNames = command === 'serve' ? ['index'] : await scanUsedComponents(root)
  • serve 时:直接用 index.scss
  • build 时:扫描后按需引入组件样式

5. 生成临时 SCSS 入口源码

const source = createScssEntry(themeChalkSrcImportPath, componentNames)

6. 写入临时文件

await fs.mkdir(outputDir, { recursive: true })
await fs.writeFile(tempScssPath, source, 'utf-8')

7. 调用 Sass 编译

const result = await compileAsync(tempScssPath, {
  loadPaths: [root],
  sourceMap: command === 'serve',
  style: command === 'serve' ? 'expanded' : 'compressed',
})

这里注意两个配置:

  • sourceMap: command === 'serve' 开发环境保留 sourceMap,方便调试。
  • style: command === 'serve' ? 'expanded' : 'compressed' 开发环境不压缩,生产环境压缩。

8. 输出最终 CSS

await fs.writeFile(outputPath, result.css, 'utf-8')

第八步:分别在开发和生产环境触发它

开发环境插件

export const elementPlusThemePlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-plugin',
    apply: 'serve',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'serve')
    },
  }
}

这段代码的意思是:

  • 只在 vite dev 时执行
  • 启动时构建一次全量主题

注意:

  • 不跟着业务源码热更新反复执行
  • 因为这里构建的是 Element Plus 主题,不是业务页面样式

生产环境插件

export const elementPlusThemeBuildPlugin = (): Plugin => {
  let root = ''

  return {
    name: 'element-plus-theme-build-plugin',
    apply: 'build',
    configResolved(resolvedConfig) {
      root = resolvedConfig.root
    },
    async buildStart() {
      await buildThemeCss(root, 'build')
    },
  }
}

这段代码的意思是:

  • 只在 vite build 时执行
  • 构建前按需扫描组件,再生成 CSS

第九步:在 Vite 中注册插件

文件: vite.config.ts

写法如下:

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import vueJsx from '@vitejs/plugin-vue-jsx'
import UnoCSS from 'unocss/vite'
import { elementPlusThemeBuildPlugin, elementPlusThemePlugin } from './build/plugins/element-plus-theme'

export default defineConfig({
  resolve: {
    alias: {
      '@': '/src',
    },
  },
  plugins: [
    elementPlusThemePlugin(),
    elementPlusThemeBuildPlugin(),
    vue(),
    vueJsx(),
    UnoCSS(),
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'],
      dts: 'src/types/auto-imports.d.ts',
    }),
  ],
})

虽然这里写了两个插件,但不会同时执行:

  • pnpm dev 只执行 elementPlusThemePlugin
  • pnpm build 只执行 elementPlusThemeBuildPlugin

第十步:在应用入口引入生成后的 CSS

文件: main.ts

最终写法:

import { createApp } from 'vue'
import ElementPlus from 'element-plus'

import App from './App.vue'
import './assets/styles/ress.min.css'
import './assets/generated/element-plus-theme.css'
import 'virtual:uno.css'
import i18n from './i18n'

import router from './router'
import pinia from './store'

const app = createApp(App)

app.use(ElementPlus)
app.use(router)
app.use(pinia)
app.use(i18n)
app.mount('#app')

这里最关键的是:

import './assets/generated/element-plus-theme.css'

如果你之前有:

import 'element-plus/dist/index.css'

要把它删掉,不然就会和生成后的主题文件重复。


第十一步:怎么验证它是否生效

开发环境验证

执行:

pnpm dev

检查:

  1. 是否生成了文件:
src/assets/generated/element-plus-theme.scss
src/assets/generated/element-plus-theme.css
  1. 页面中的 Element Plus 组件是否使用了新的主题色。

生产环境验证

执行:

pnpm build

检查:

  1. 构建是否通过。
  2. 生成后的 element-plus-theme.css 是否存在。
  3. 打包后的页面中 Element Plus 样式是否正常。

第十二步:这套实现的边界

1. 动态拼接组件名,可能扫不到

比如:

const name = 'Button'
const component = `El${name}`

这种写法不一定能被正则准确识别。

2. 新的服务型组件如果没进白名单,可能漏样式

如果以后用了新的服务型组件,而当前正则又没扫到,就要把它补到:

const SERVICE_COMPONENTS = ['message', 'message-box', 'notification', 'loading']

里。

3. 主题变量扩展时,继续沿用 @forward ... with (...)

如果后面要覆盖的不只是颜色,比如圆角、边框、文字颜色,也继续在 createScssEntry 里往下扩。

你的 Vue 3 TS 类型声明,VuReact 会处理成什么样的 React?

作者 Ruihong
2026年4月17日 10:09

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:VuReact 如何自动分析 Vue 3 中的响应式依赖,精准生成 React Hooks 的依赖数组

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 和 React 的响应式与依赖追踪机制。

编译对照

Vue 自动依赖分析 → React Hook 依赖数组生成

VuReact 编译器内置了自动依赖分析能力,遵循 React 规则,智能分析顶层箭头函数顶层变量声明中的响应式访问,并生成准确的依赖数组。

  • Vue 代码:
<script setup lang="ts">
  import { reactive, ref } from 'vue';

  const count = ref(0);
  const foo = ref(0);
  const state = reactive({ foo: 'bar', bar: { c: 1 } });

  const fn1 = () => {
    count.value += state.bar.c;
    console.log(count.value);
  };

  const fn = () => {};

  const fn2 = () => {
    const c = foo.value;
    fn();

    const fn4 = () => {
      state.bar.c--;
      c + count.value;
    };
  };

  const fn3 = () => {
    foo.value++;

    const state = ref('fake');
    const count = state.value + 'yoxi';
    count.charAt(1);
  };
</script>
  • VuReact 编译后 React 代码:
const count = useVRef(0);
const foo = useVRef(0);
const state = useReactive({ foo: 'bar', bar: { c: 1 } });

const fn1 = useCallback(() => {
  count.value += state.bar.c;
  console.log(count.value);
}, [count.value, state.bar?.c]);

const fn = () => {};

const fn2 = useCallback(() => {
  const c = foo.value;
  fn();

  const fn4 = () => {
    state.bar.c--;
    c + count.value;
  };
}, [foo.value, state.bar?.c, count.value]);

const fn3 = useCallback(() => {
  foo.value++;

  const state = useVRef('fake');
  const count = state.value + 'yoxi';
  count.charAt(1);
}, [foo.value]);

这段对比展示了:

  • fn1 会被识别为顶层箭头函数并收集 count.valuestate.bar.c
  • fn2 会溯源 c 并忽略局部函数 fn4
  • fn3 会忽略函数内部新建的响应式变量,只收集外部依赖 foo.value

Vue 组合访问与别名追踪

VuReact 也会对复杂别名链和解构访问进行溯源。

  • Vue 代码:
<script setup lang="ts">
  const objRef = ref({ a: 1, b: { c: 1 } });
  const listRef = ref([1, 2, 3]);
  const aliasA = state.foo;
  const aliasB = aliasA;
  const aliasC = aliasB;
  const { foo: stateFoo } = state;
  const [first] = listRef.value;

  const traceFn = () => {
    aliasC;
  };

  const destructureFn = () => {
    stateFoo;
    first;
  };
</script>
  • VuReact 编译后 React 代码:
const objRef = useVRef({ a: 1, b: { c: 1 } });
const listRef = useVRef([1, 2, 3]);
const aliasA = useMemo(() => state.foo, [state.foo]);
const aliasB = useMemo(() => aliasA, [aliasA]);
const aliasC = useMemo(() => aliasB, [aliasB]);
const { foo: stateFoo } = useMemo(() => state, [state]);
const [first] = useMemo(() => listRef.value, [listRef.value]);

const traceFn = useCallback(() => {
  aliasC;
}, [aliasC]);

const destructureFn = useCallback(() => {
  stateFoo;
  first;
}, [stateFoo, first]);

这样可见:

  • alias 链会被逐层溯源到真实响应式来源;
  • 解构后的变量也会通过 useMemo 转换为可追踪依赖。

Vue 顶层变量声明 → React useMemo 依赖数组生成

  • Vue 代码:
<script setup lang="ts">
  const fooRef = ref(0);
  const reactiveState = reactive({ foo: 'bar', bar: { c: 1 } });

  const memoizedObj = {
    title: 'test',
    bar: fooRef.value,
    add: () => {
      reactiveState.bar.c++;
    },
  };

  let staticObj = {
    foo: 1,
    state: { bar: { c: 1 } },
  };

  const reactiveList = [fooRef.value, 1, 2];

  const mixedList = [
    { name: reactiveState.foo, age: fooRef.value },
    { name: 'A', age: 20 },
  ];

  const nestedObj = {
    a: {
      b: {
        c: reactiveList[0],
        d: () => {
          return memoizedObj.bar;
        },
      },
      e: mixedList,
    },
  };
</script>
  • VuReact 编译后 React 代码:
const memoizedObj = useMemo(
  () => ({
    title: 'test',
    bar: fooRef.value,
    add: () => {
      reactiveState.bar.c++;
    },
  }),
  [fooRef.value, reactiveState.bar?.c],
);

let staticObj = {
  foo: 1,
  state: {
    bar: {
      c: 1,
    },
  },
};

const reactiveList = useMemo(() => [fooRef.value, 1, 2], [fooRef.value]);

const mixedList = useMemo(
  () => [
    { name: reactiveState.foo, age: fooRef.value },
    { name: 'A', age: 20 },
  ],
  [reactiveState.foo, fooRef.value],
);

const nestedObj = useMemo(
  () => ({
    a: {
      b: {
        c: reactiveList[0],
        d: () => {
          return memoizedObj.bar;
        },
      },
      e: mixedList,
    },
  }),
  [reactiveList[0], memoizedObj.bar, mixedList],
);

这里的核心对比是:

  • memoizedObj 会收集对象内部的响应式字段与方法依赖;
  • staticObj 因为不含响应式访问,不会被优化为 useMemo
  • reactiveListmixedListnestedObj 会根据结构递归补齐依赖数组。

自动依赖分析的三大原则

  1. 仅分析顶层可优化表达式:局部函数、嵌套作用域不纳入顶层 Hook 自动优化;
  2. 遵循 React 依赖规则:只收集函数/变量外部的响应式访问,而非内部局部变量;
  3. 避免过度优化:无外部响应式依赖的顶层箭头函数和变量不会被强制转换为 Hook。

为什么这很关键?

在 React 中,函数组件每次渲染会重新创建顶层函数与变量。如果这些顶层表达式依赖响应式状态且未获得稳定性处理,会带来:

  • 不必要的子组件重新渲染;
  • 频繁的 Hook 重新计算;
  • 性能不可控的回调变化。

VuReact 在编译阶段自动生成准确依赖数组,既保留了 Vue 写法的简洁性,又实现了 React 端的性能优化。

相关资源


如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

5.响应式系统比对:手写 React 响应式状态库 Mobx

作者 Cobyte
2026年4月17日 08:47

前言

我们从前几篇文章中学到了数据响应式的实现原理,虽然它们的实现方式并不相同,但本质原理都是一样的,都是在数据读取的时候进行依赖收集,在数据更改的时候触发依赖。我们知道在 React 的技术栈中也有一个状态管理库 —— Mobx 也是通过数据响应式的方式实现的,那么既然也是数据响应式,那么它的实现本质原理应该都跟 Vue 是一致的,但我们不应该它的代码设计方式改变了,就看不懂了,而恰恰相反正因为我们熟悉 Vue 的数据响应式原理,所以我们 Vue 技术栈的同学应该更容易理解 Mobx 的实现原理才对,不然你不能说你精通了 Vue 的数据响应式原理。

Mobx 与 Vue 的响应式数据的差异

具体来说就是如果在 Vue 中你创建了一个引用类型的响应式数据,你可以直接修改它:

const vueProxy = reactive({ name: 'Cobyte' })
// 直接修改
vueProxy.name = '掘金签约作者'

在 Vue 这种操作是很正常的,但在 Mobx 中这种行为却是不提倡的。那么在 Mobx 中需要怎么修改呢?在 Mobx 中你需要定义一个函数来进行修改:

const mobxProxy = observable({ name: 'Cobyte', update(value) { this.name = value }})
// 通过函数进行修改
mobxProxy.update('掘金签约作者')

当然,在 Mobx 中你也可以像 Vue 那样操作数据,但 Mobx 并不提倡,所以既然你使用了 Mobx 那就就要遵循它的规则,并学习它的优秀设计原理,然后融化为你知识的一部分,在将来你设计代码架构的时候,你所学习到的知识将在无形中响应式着你。

Mobx 的设计原理

我们知道虽然 Vue2 和 Vue3 数据响应式部分的实现有所不同,但实现思路还是一致的。那么跟 Vue 相比同样是实现响应式数据的 Mobx 最大的区别是什么呢?那么要了解这个就去了解 Mobx 的设计原理了。Mobx 的最核心设计原则就是跟 React 的单向数据流设计一致,也同样是单向数据流。也正是基于这个原则导致 Mobx 的代码架构跟 Vue 的数据响应式部分差别比较大。当然 Vue 也是单向数据流设计,并且 Vue 官方也提倡单向数据流,但只是从 Vue 框架层限制了组件的 props 的第一层,而并没有从数据响应式的底层进行限制,而 Mobx 则是从数据响应式的底层就进行限制,所以 Mobx 的单向数据流更为彻底。

我们在 Vue 中创建了一个响应式数据,如果这个响应式数据是引用类型的话,你可以在组件及任何一个其后代组件任何一个角度去修改它,这种方式对于开发功能的人员来说是非常方便的,但对于维护人员来说很可能就是灾难,因为维护人员有时候需要监听数据的更新行为,可并不知道这个响应式数据都在什么地方进行更新。

而在 Mobx 中你创建了一个响应式数据,即便这个响应式数据也是引用类型,在 Mobx 中如果你直接对响应式数据进行修改的话,Mobx 会发出警告,因为在 Mobx 中你需要 React 那样通过一个函数来进行修改,这样就保证了单向数据流的使用规范。

默认情况下,不允许在 actions 之外改变 state。这有助于在代码中清楚地对状态更新发生的位置进行定位。

上述引用来自 Mobx 中文官网,那么怎么可以做到直接修改响应式数据的属性值就发出警告,而通过响应式数据的函数就不会呢?其实原理很简单,我们可以设置一个全局开关,当这个开关打开的时候,我们就可以进行修改,否则就提示警告。在 Mobx 中会对修改函数进行一层封装,变成成一个高阶函数,在执行修改函数之前会打开开关,这样再去修改就不会提示警告了。

Mobx 的初步实现(observable 实现)

我们知道 Mobx 是参考了 Vue 的数据响应式原理,那么最初肯定是只有参考 Vue2 了,那么根据 Vue2 的数据响应式原理,我们很清楚知道一个对象要观察它的数据变化,需要通过 Object.defineProperty 劫持每一个属性的 gettersetter 的操作,同时属性值需要通过闭包进行缓存,还需要通过发布订阅模式来实现依赖(订阅者)和响应式数据之间的通信,具体就是在 getter 的时候进行订阅,在 setter 的时候进行发布,那么在 Vue2 数据响应式中每一个属性所形成的闭包就是一个发布者。那么在 Mobx 的属性值是否也需要通过闭包进行缓存呢?

在 Vue2 中需要一个 Observer 的观察器类来管理响应式数据的相关操作,在 Mobx 中同样需要一个观察器类来管理响应式数据的相关操作,它就是 ObservableObjectAdministration。那么根据我们前面的所学的经验可以很快得到 ObservableObjectAdministration 类的基础代码。如下:

// 对象观察器类
class ObservableObjectAdministration{
    constructor(target) {
        // 原始值保存
        this.target_ = target
        // 订阅者存储中心
        this.values_ = new Map()
    }
}

根据 Vue2 的数据响应式原理我们知道需要通过一个 observe 的函数创建响应式数据,在 Mobx 中也提供了一个叫 observable API 来创建响应式数据。那么根据 Vue2 我们知道需要实例化一个观察器对象,并且把观察器实例对象设置到需要观察的数据上,这样该数据就是响应式数据了。

function observable(target) {
    const adm = new ObservableObjectAdministration(target)
    // 把观察器实例对象设置到需要观察的数据上
    target.__ob__ = adm
    return target
}

在 Vue2 中是在观察器内部进行初始化对被观察数据进行遍历其属性通过 Object.defineProperty 劫持每一个属性的 gettersetter 操作。同样 Mobx 也需要这样,但 Mobx 的设计是在外部进行遍历属性,而不是在观察器内部进行遍历。

function observable(target) {
    const adm = new ObservableObjectAdministration(target)
    // 把观察器实例对象设置到需要观察的数据上
    target.__ob__ = adm
+    Object.keys(target).forEach(key => {
+        // 在这里通过 Object.defineProperty 劫持每一个属性的 `getter`、`setter` 操作
+        adm.defineObservableProperty_(key, target[key])
+    })
    return target
}

// 对象观察器
class ObservableObjectAdministration{
    constructor(target) {
        // 原始值保存
        this.target_ = target
        // 订阅者存储中心
        this.values_ = new Map()
    }
+    // 劫持属性的 getter、setter
+    defineObservableProperty_(key, value) {
+        Object.defineProperty(this.target_, key, {
+            get: () => {
+                // 获取值
+            },
+            set: (val) => {
+                // 设置值
+            }
+        })
+    }
}

在 Vue2 中循环劫持响应式对象的属性时是通过闭包的方式的,即每一个属性都会形成自己的一个闭包,最后读取和设置的值都是闭包中的变量值。而 Mobx 中则把每一个属性的值都包装成一个对象,本质上是通过沙箱模式将每个属性值进行隔离。

那么下面就让我们来实现 Mobx 中的属性劫持吧。

// 对象观察器
class ObservableObjectAdministration{
    constructor(target) {
        // 原始值保存
        this.target_ = target
        // 订阅者存储中心
        this.values_ = new Map()
    }
    // 劫持属性的 getter、setter
    defineObservableProperty_(key, value) {
+        // 将属性值包装成响应式对象
+        const observable = new ObservableValue(value)
        // 将每一个属性和属性值进行记录起来
+        this.values_.set(key, observable)
        Object.defineProperty(this.target_, key, {
            get: () => {
                // 获取值
+                return this.values_.get(key).get()
            },
            set: (val) => {
                // 设置值
+                this.values_.get(key).setNewVal(val)
            }
        })
    }
}
+ // 将属性值包装成响应式对象
+ class ObservableValue {
+    constructor(value) {
+        this.value_ = value
+    }

+    get() {
+        // 在这里进行依赖收集
+        console.log('依赖收集')
+        return this.value_
+    }

+    setNewVal(val) {
+        this.value_ = val
+        // 在这里进行依赖触发
+        console.log('依赖触发')
+    }
+ }

通过上面的代码我们可以看到 Mobx 在通过 Object.defineProperty 劫持对象属性的时候会把属性值通过一个对象进行包裹,也就是 ObservableValue 的实例对象 observable,并且通过键值对的方式保存在 ObservableObjectAdministrationthis.values_ 上,然后在 getter 的时候实际获取的是对应 keyobservable 对象中的值。那么很容易看出来每一个 ObservableValue 的实例对象 observable 都是一个发布者,或者叫被观察者更为贴切一些,反正是一个被观察的对象。

接下来我们就可以进行测试了:

// 创建响应式对象
const mobxProxy = observable({ name: 'Cobyte' })
// 读取触发依赖收集
mobxProxy.name
// 设置值触发依赖
mobxProxy.name = '我是掘金签约作者'

打印结果如下:

A01.png

小结

在前面的讲解 Vue2 的数据响应式原理的文章中,我们说其实每一个属性所形成的闭包就是一个发布者,可能大家还有点难以理解,那么在 Mobx 中每一个属性都通过一个沙箱对象进行包裹,那么这个沙箱对象就是一个发布者,而且代码结构和所谓传统发布订阅模式的代码结构也是比较相似。

在 Mobx 中实现发布订阅模式

那么根据上文我们知道 ObservableValue 是一个发布者,那么我们根据前面的所学的知识,可以很容易完善发布订阅模式的功能。代码如下:

+ // 全局属性
+ const globalState = {
+     trackingDerivation: null // Mobx 中的订阅者全局变量
+ }
 // 将属性值包装成响应式对象
class ObservableValue {
    constructor(value) {
        this.value_ = value,
+        // 订阅者存储中心
+        this.observers_ = new Set()
    }

    get() {
        // 在这里进行依赖收集
+        if (globalState.trackingDerivation) {
+            this.observers_.add(globalState.trackingDerivation)
+        }
        return this.value_
    }

    setNewVal(val) {
        this.value_ = val
        // 在这里进行依赖触发
+        this.observers_.forEach(derivation => derivation())
    }
}

我们经过上面的功能完善,我们从代码结构可以看得出 ObservableValue 是一个发布者。那么接下来我们就可以进行最简单的功能测试了。测试代码如下:

const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}

globalState.trackingDerivation = subscriber
subscriber()
globalState.trackingDerivation = null

// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

我们可以看到正确打印了我们期待的结果:

A02.png

实现订阅者中介 Reaction

接下来我们继续完善订阅者功能,根据我们前面所学习的知识,我们知道需要一个订阅者中介类,在 Mobx 中同样存在一个订阅者中介类,也就是 Reaction,那么根据 Vue2 的 Watcher 功能我们很快可以实现如下代码:

class Reaction {
    constructor(fn) {
        this._fn = fn
        this.get()
    }
    get() {
        globalState.trackingDerivation = this
        this._fn()
        globalState.trackingDerivation = null
    }
    update() {
        this._fn()
    }
}

因为订阅者的功能修改了,所以同时需要修改一下 ObservableValue 类:

// 将属性值包装成响应式对象
class ObservableValue {
    // 省略...
    setNewVal(val) {
        this.value_ = val
        // 在这里进行依赖触发
-        this.observers_.forEach(derivation => derivation())
+        this.observers_.forEach(derivation => derivation.update())
    }
}

接着我们进行测试:

const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}

new Reaction(subscriber)

// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

我们可以看到也是正确打印了我们期待的结果:

A02.png

但上述 Reaction 的实现是根据 Vue2 的 Watcher 类实现的,实现的特点是在初始化的时候进行传进来的副作用函数,并且进行依赖收集,在更新的时候则不再进行依赖收集。而 Mobx 中的实现并不是这样的,但基本原理是一致的,就是在初始化的时候进行依赖收集,更新的时候则不再进行依赖收集,所以我们根据 Mobx 中的实现重新改造一下 Reaction 类。

Reaction 改造如下:

class Reaction {
    constructor(onInvalidate) {
        this.onInvalidate_ = onInvalidate
    }

    track(fn) {
        globalState.trackingDerivation = this
        fn()
        globalState.trackingDerivation = null
    }
    // 更新的时候执行
    schedule_() {
        this.onInvalidate_()
    }
}

我们可以看到在 Reaction 初始化的时候会传进来一个回调函数,这个回调函数会在更新的时候进行,而依赖收集则在 track 函数中进行,看函数名都可以顾名思义了。

实现 autorun 函数

因为 Reaction 更新执行的函数变了,所以我们也需要修改 ObservableValue 类相关功能:

// 将属性值包装成响应式对象
class ObservableValue {
    // 省略...
    setNewVal(val) {
        this.value_ = val
        // 在这里进行依赖触发
-        this.observers_.forEach(derivation => derivation.update())
+        this.observers_.forEach(derivation => derivation.schedule_())
    }
}

那么接下来需要重新修改测试代码:

const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}
// 实例化订阅者中介
const reaction = new Reaction(
    () => {
        // 回调函数中执行依赖收集函数
        reaction.track(subscriber)
    }
)
// 立即执行
reaction.schedule_()
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

重新执行也同样打印了正确的结果:

A02.png

我们可以看到要像之前那样实现自动执行订阅者函数,需要在实例化 Reaction 的时候设置回调函数 onInvalidate,然后把依赖收集函数的执行放到 onInvalidate 函数中,然后需要开始的时候就立即执行更新方法。这部分相对 Vue2 的 Watcher 类的实现就没有那么容易理解,这主要是因为 Mobx 主要是服务于 React,受 React 的特点影响,所以才这么设计。在后续我们再详细讲解为什么这么设计。

其实上述对订阅者的实现方法,就是 Mobx 的 autorun API 的实现原理。我们将其进行封装实现。

function autorun(view) {
    // 实例化订阅者中介
    const reaction = new Reaction(
        () => {
            // 回调函数中执行依赖收集函数
            reaction.track(view)
        }
    )
    // 立即执行
    reaction.schedule_()  
}

然后我们的测试代码就可以修改成:

const mobxProxy = observable({ name: 'Cobyte' })
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}
autoruo(subscriber)
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

修改之后,同样打印了正确的结果:

A02.png

实现使用 actions 更新 state

通过上文对 Mobx 的设计原理的讲解,我们知道为了帮助开发人员清楚地知道状态修改的位置,默认情况下,Mobx 不允许在 actions 之外改变状态。

Mobx 使用单向数据流,利用 action 改变 state ,进而更新所有受影响的 view

上述引用来自 Mobx 中文官网,所谓 action 其实就是一个函数,例如下面的例子:

const mobxProxy = observable({ 
    name: 'Cobyte',
    update(value) { 
        this.name = value 
    }
})
// 通过函数进行修改
mobxProxy.update('掘金签约作者')

通过上文我们知道它的基本原理就是设置一个全局开关,当这个开关打开的时候,我们就可以进行修改,否则就提示警告。其实对修改函数会进行一层封装,变成成一个高阶函数,在执行修改函数之前会打开开关,这样再去修改就不会提示警告了。

我们知道每一个属性值都被封装成了一个 observable 对象,那么我们就可以在 ObservableValue 类中对包装的值进行处理,如果是函数的话,就封装成一个高阶函数(高阶函数(higher-order function)—— 如果一个函数接收的参数为或返回的值为函数,那么我们可以将这个函数称为高阶函数)。

首先我们添加一个全局开关变量:

const globalState = {
    trackingDerivation: null,
+    // 是否允许修改状态的开关
+    allowStateChanges: false
}

那么我们就可以在 ObservableValue 类中对包装的值进行处理:

class ObservableValue {
    constructor(value) {
+        let action
+        // 如果是函数则封装 action 高阶函数
+        if (typeof value === 'function') {
+            action = function(...agrs) {
+                // 在执行原始函数之前开启允许修改开关
+                globalState.allowStateChanges = true
+                // 通过 apply 执行原始函数
+                value.apply(this, agrs)
+                // 执行完原始函数后又关闭开关
+                globalState.allowStateChanges = false
+            }
+        }
-        this.value_ = value
+        // 判断如果是函数则使用封装的 action 高阶函数
+        this.value_ = typeof value === 'function' ? action : value,
        this.observers_ = new Set()
    }
}

接着我们就可以设置值的时候进行判断了:

class ObservableValue {
    setNewVal(val) {
+       if (!globalState.allowStateChanges) {
+          console.warn('Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed')
+       }
        this.value_ = val
        // 在这里进行依赖触发
        this.observers_.forEach(derivation => derivation.schedule_())
    }
}

这时我们就可以进行测试了:

const mobxProxy = observable({ 
    name: 'Cobyte',
    update(val) {
        this.name = val
    }
})
// 设置订阅者
const subscriber = function() {
    console.log(`我是:${mobxProxy.name}`)
}
autorun(subscriber)
// 设置值触发依赖
mobxProxy.name = '掘金签约作者'

A03.png 这个时候我们就可以看到直接通过属性进行修改值会发出警告了,然后我们再通过函数修改,则不会了。

mobxProxy.update('掘金签约作者')

通过函数修改则不会发出警告了。

接下来,我们再对我们的代码进行重构一下,让代码结构更接近 Mobx 源码。

+ function createAction(fn) {
+     // 这里有一个需要注意的点,返回函数需要使用 function 进行声明会比较方便获取原生对象的上下文,这里涉及到 this 的问题
+     function res() {
+         // 最后通过 executeAction 执行
+         return executeAction(fn, this, arguments)
+     }
+     return res
+ }

+ function executeAction(fn, scope, args) {
+     // 在执行原始函数之前开启允许修改开关
+     globalState.allowStateChanges = true
+     // 因为是用户写的函数,可能会存在错误,所以使用 try
+     try {
+         // 通过 apply 执行原始函数
+         return fn.apply(scope, args)
+    } catch (err) {
+         throw err
+     } finally {
+         // 执行完原始函数后又关闭开关
+         globalState.allowStateChanges = false
+     }
+ }

+ function deepEnhancer(value) {
+     // 如果是函数则封装 action 高阶函数
+     if (typeof value === 'function') {
+         return createAction(value) 
+     }

+     // todo

+     // 如果是 observable 对象就返回,不处理
+     // 如果是对象进行递归处理
+     // 如果是数组也进行数组的递归处理

+     return value
+ }

class ObservableValue {
    constructor(value) {
+        // 通过 deepEnhancer 处理 value 值
+        this.value_ = deepEnhancer(value)
        this.observers_ = new Set()
    }

    setNewVal(val) {
+        // 设置值之前进行判断是否允许修改
+        checkIfStateModificationsAreAllowed(this)
        this.value_ = val
        // 在这里进行依赖触发
        this.observers_.forEach(derivation => derivation.schedule_())
    }
}

+ function checkIfStateModificationsAreAllowed(atom) {
+    if (!globalState.allowStateChanges) {
+        console.warn('Since strict-mode is enabled, changing (observed) observable values without using an action is not allowed')
+    }
+ }

经过上面的重构我们的代码结构就更接近 Mobx 源码了,所以重构是我们日常编程中非常重要的组成部分。

实现 makeAutoObservable

我们知道 Redux 是函数式编程的推崇者,API 的设计对喜欢函数式编程的开发者非常友好,而 Mobx 的设计则更多偏向于面向对象编程(OOP),在 Mobx 中 class 是一等公民,这对喜欢 OOP 思想的开发者则非常友好。甚至于在 Mobx 的官网给出的实例代都是 OOP 实现的。

Mobx 官网 OOP 例子:

import { makeAutoObservable } from "mobx"

class Timer {
    secondsPassed = 0
    constructor() {
        makeAutoObservable(this)
    }
    increase() {
        this.secondsPassed += 1
    }
    reset() {
        this.secondsPassed = 0
    }
}

const myTimer = new Timer()

我们通过上面的例子可以看到 class 对象的响应式是通过 makeAutoObservable 这个 API 实现的,我们有了上述实现的 Mobx 基本原理的代码基础,再去实现 makeAutoObservable API 是很容易的。

在实现之前,我们需要对 ES class 的基础知识复习一下,class 中的属性在实例化是在实例化对象上的,而 class 的方法则是在原型上的,也就是说上述例子的实现等同于下面的实现:

class Timer {
    secondsPassed = 0
    constructor() {
        makeAutoObservable(this)
    }
}
Timer.prototype.increase = function() {
    this.secondsPassed += 1
}
Timer.prototype.reset = function(){
    this.secondsPassed = 0
}

这些是属于 JavaScript 的面向对象与继承部分的基础知识,这里不作过度深入说明。

通过上文我们知道在 Mobx 中实现数据响应式跟 Vue2 中的基本原理是一样的,也就是遍历要实现响应式的对象所有的属性,并使用 Object.defineProperty 把这些属性全部转为 getter/setter。但 通过 class 实例化的对象除了要获取自身的属性之外,还要获取原型对象上的属性,因为 class 中的方法是设置在原型上的。那么理解了这些之后我们就可以实现 makeAutoObservable API 了。

接下来我们实现一下:

function makeAutoObservable(target) {
    const adm = new ObservableObjectAdministration(target)
    target.__ob__ = adm
    // 获取实例的原型对象
    const proto = Object.getPrototypeOf(target)
    // 同时获取实例对象上的 key 和 原型对象上的 key,才能完整获取 class 中的属性和方法,同时通过 Set 进行去重
    const keys = new Set([...Reflect.ownKeys(target), ...Reflect.ownKeys(proto)])
    // 删除不需要监听的属性
    keys.delete("constructor")
    keys.delete('__ob__')
    // 遍历所有属性进行监听
    keys.forEach(key => {
        adm.defineObservableProperty_(key, target[key])
    })
    return target
}

我们可以看到有了之前实现 Mobx 的基础,再实现 makeAutoObservable 是非常容易的。相比较上面实现的 observablemakeAutoObservable 的实现最大的不同就是属性的获取,因为 makeAutoObservable 是应用在 class 类上的,所以除了获取对象自身上的属性之外,还要获取原型对象上的属性才能完整获取 class 中的属性和方法,同时还需要对所获取的属性和方法进行去重,最后去掉不需要监听的属性。

接下来我们就可以进行测试了:

class Timer {
    secondsPassed = 0
    constructor() {
        makeAutoObservable(this)
    }
    increase() {
        this.secondsPassed += 1
    }
    reset() {
        this.secondsPassed = 0
    }
}

const myTimer = new Timer()     
// 设置订阅者
const subscriber = function() {
    console.log(`现在的秒数:${myTimer.secondsPassed}`)
}

autorun(subscriber)

// 每秒更新一次
setInterval(() => {
    myTimer.increase()
}, 1000)

打印结果如下:

A04.png

可以看到我们实现的 makeAutoObservable 方法可以正确应用在 class 上了。

将手写的 Mobx 应用到 React 上

这小结对 React 不太熟悉的同学也没关太大关系,跟着敲就可以了。首先我们通过 create-react-app 这个脚手架快速创建一个 React 项目。

npx create-react-app react-app

我们把上面实现的 Mobx 功能内容设置到 ./src/mini-mobx.js 中,并且把使用到的函数进行导出。

接着我们把 App.js 文件的内容修改如下:

import { makeAutoObservable, observer } from "./mini-mobx"

class Timer {
  secondsPassed = 0

  constructor() {
    makeAutoObservable(this)
  }

  increaseTimer() {
    this.secondsPassed += 1
  }
}

const myTimer = new Timer()

const TimerView = observer(({ timer }) => <span>Seconds passed: {timer.secondsPassed}</span>)

function App() {
  return (
    <TimerView timer={myTimer}></TimerView>
  );
}

setInterval(() => {
    myTimer.increaseTimer()
}, 1000)

export default App;

我们看到上述的例子其实就是 Mobx 官网的例子,我们把 Mobx 官网的例子跑起来,就说明我们手写的 Mobx 功能是成功的了。上述例子中,我们还需要实现一个函数 observer,我们可以参考上面实现过的 autorun 函数。

我们可以看到 observer 接受的是一个函数组件,返回的也是一个函数组件,那么这就是一个典型的高阶组件,所谓高阶组件,也就是高阶函数,因为函数组件本质就是一个函数。那么我们根据这些特点,我们很容易就构造出 observer 函数基础架构。代码如下:

export function observer(baseComponent) {
    return (props) => {
        return baseComponent(props)
    }
}

页面正常渲染出来了,但还不能自动更新。

A05.png

从发布订阅的角度来说在 React 应用 Mobx 后,所写的函数组件就是一个订阅者,那么根据我们上面实现的 autorun 函数,我们先要实例化一个 Reaction 对象,而不管在 Vue 中还是 React 中函数组件在更新的时候,都是重新执行整个函数组件的,所以我们实例化的 Reaction 对象需要保存起来,那么在 React 里面有提供了一个 useRef 的 Hook,它可以创建一个 mutable ref 对象,在组件的整个生命周期内该对象保持不变。简单来说就是 useRef 可以创建一个可以保存状态的 Hook,即使组件重新渲染,其内部的值也不会变化。

import { useRef } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
    return (props) => {
        const admRef = useRef(null)
        if (!admRef.current) {
            // 实例化订阅者中介
            const reaction = new Reaction(
                () => {
                    // 回调函数中执行依赖收集函数
                    reaction.track(baseComponent)
                }
            )
            admRef.current = reaction
        }
        const reaction = admRef.current
        // 立即执行
        reaction.schedule_()
    }
}

页面显示如下:

A06.png

我们根据 autorun 的实现原理初步实现了上述功能,但报错了,原因是组件的 props 没有传进去,所以我们进行以下修改:

import { useRef } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
    return (props) => {
        const admRef = useRef(null)
        if (!admRef.current) {
            // 实例化订阅者中介
            const reaction = new Reaction(
                () => {
                    // 回调函数中执行依赖收集函数
+                    reaction.track(() => {
+                        baseComponent(props)
+                    })
                }
            )
            admRef.current = reaction
        }
        const reaction = admRef.current
        // 立即执行
        reaction.schedule_()
    }
}

页面显示如下:

A07.png

我们发现不报错了,但页面并没有渲染,没渲染的原因是我们并没有把函数组件执行的内容返回,所以我们继续进行以下修改:

import { useRef } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
    return (props) => {
+        let renderResult
        const admRef = useRef(null)
        if (!admRef.current) {
            // 实例化订阅者中介
            const reaction = new Reaction(
                () => {
                    // 回调函数中执行依赖收集函数
                    reaction.track(() => {
+                      renderResult = baseComponent(props)
                    })
                }
            )
            admRef.current = reaction
        }
        const reaction = admRef.current
        // 立即执行
        reaction.schedule_()
+        return renderResult
    }
}

修改后页面显示如下:

A08.png

经过上面修改,我们的页面可以渲染出来了,但又遇到新的问题了,页面并没有更新。按理来说,我们上面的 observer 是已经根据 autorun 的实现方式进行实现了。我们可以在 Reaction 的回调函数中进行打印,

export function observer(baseComponent) {
    return (props) => {

        if (!admRef.current) {
            const reaction = new Reaction(
                () => {
                    // 回调函数中执行依赖收集函数
                    reaction.track(() => {
                      renderResult = baseComponent(props)
+                      console.log('renderResult', renderResult)
                    })
                }
            )
            admRef.current = reaction
        }
+        console.log('outer')
    }
}

打印显示如下:

A09.png

我们发现其实我们的 Reaction 的回调函数已经重新执行了,但整个组件函数并没有重新执行,所以并没重新渲染内容。所以我们现在只要考虑把整个组件实现重新渲染就可以了。那么熟悉 React 的同学可能会知道在 React 函数组件中可以通过 useState 改变 state 值来触发组件的重新渲染。这个也是 Vue 和 React 区别非常大的一个地方。那么我们可以在 Reaction 的回调函数中执行更新函数,把依赖收集的相关代码放到外面执行。

代码修改如下:

import { useRef, useState } from "react"
import { Reaction } from "./mini-mobx"
export function observer(baseComponent) {
    return (props) => {
+        const [, setState] = useState()
        let renderResult
        const admRef = useRef(null)
        if (!admRef.current) {
            // 实例化订阅者中介
            const reaction = new Reaction(
                () => {
+                    // 执行更新
+                    setState(Symbol())
               }
            )
            admRef.current = reaction
        }
        const reaction = admRef.current
+        // 执行依赖收集函数
+        reaction.track(() => {
+          renderResult = baseComponent(props)
+        })
 
        return renderResult
    }
}

页面渲染如下:

01.gif

我们重新修改后,可以正常如期执行了。至此我们手写的 Mobx 也实现了在真实 React 环境中执行了。

总结

本文通过相对比较简洁的代码实现了 Mobx 的核心原理,同时对比了同时响应式的 Vue 和 Mobx 的最大设计区别,在 Vue 中创建的响应式数据,是可以随意在任何地方通过普通属性访问器进行修改的,但 Mobx 中则不提倡这种可以随意修改 state 的方式,在 Mobx 中希望开发者通过 actions 来改变 state,本质是像 React 那样通过一个函数来修改 state,或者说是遵循 Flux 和 Redux 的单向数据流思想。同时 Mobx 中的订阅者中介 Reaction 和 Vue 中的订阅者中介实现则有比较大的区别,主要是因为 Mobx 主要的设计受 React 的影响,在更新的时候需要特别的设置,而不像 Vue 那样直接重新运行副作用函数就可以了,这个说到底也是因为 React 不是靠依赖追踪来实现响应式的缘故。

那么具体 Mobx 的 Reaction 的要这样设计,而不能像 Vue 那样简洁呢,我们下一篇文章中继续探讨。

上述文章写于:2023 年,由于个人原因今年 2026 年发布。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

🔧 Rattail | 面向 Vite+ 和 AI Agent 的前端工具链

作者 耗子君QAQ
2026年4月17日 00:17

写在前面

掘金的同学们大家好呀,作者是 Varlet UI 的作者。掘金文章已经一年没更新了,去年跳槽到了一家创业公司负责前端架构工作,写文章这件事就一直搁置了。最近稍微缓过来了一点点(其实还是压力很大...),但不妨碍今天来给大家分享一下我们最新的开源项目 rattail

先聊聊 Vite Plus

上个月 VoidZero 正式以 MIT 协议开源了 Vite+,它把 ViteVitestOxlintOxfmtRolldowntsdown 统一收拢到了一个 vp 命令下面,一套工具链覆盖 devbuildtestlintfmtpack 等所有工程化环节。作者第一时间就把 varlet 周边的项目迁移到了 Vite+ 上面试试水,迁移下来发现效果特别好。以前那些散落在各处的 eslint 配置、prettier 配置、lint-staged 配置、commitlint 配置可以统一收拢到一个 vite.config.ts 里面,项目根目录一下子干净了不少(以前打开根目录看到十几个 .xxxrc 文件的日子终于结束了)。而且因为工具链统一了,AI Agent 在理解项目配置的时候幻觉也少了很多。

公司项目迁移

既然体验这么好,作者就决定把公司内部的前端项目也都迁移到 Vite+ 上。迁移的过程中也让作者重新审视了一下 rattail,我们在 varlet 生态里积累了大量的工具函数、请求库、校验规则工厂、CLI 工具链,之前一直是分散在各个包里的,正好借这次机会做一次大整合,于是就有了 rattail 2.0——一个面向 Vite+、对 AI Agent 非常友好的前端工具链。140+ 工具函数、渐进式请求库、链式校验规则工厂、CLI 工具链、类型安全枚举,pnpm add rattail 一条命令全部拉齐。

目前作者也在公司项目中全面使用了 Vite+ + rattail 这套技术栈,体验下来非常舒服。另外值得一提的是,这次 rattail 2.0 的迁移和开发过程中,作者大量使用了 AI 辅助编程,包括工具函数的编写、单元测试的补全、文档的生成等等,效率提升非常明显。配合 rattail 提供的 Agent Skills,AI Agent 能够很好的理解项目上下文并正确使用 rattail 的 API,整个工作流跑下来还是相当丝滑的。后续在业务开发中也明显感觉到,因为 rattail 把工具函数、请求库、校验规则这些东西 all in one 了,AI 在生成代码的时候幻觉变得特别少,而且很会按照规范做事。

相关链接

特性一览

  • ⚙️ 面向 Vite+ 的开箱即用配置预设
  • 🔧 CLI 工具链,支持发布、日志、Git Hooks、Commit Lint、API 生成
  • 🧰 140+ 工具函数,覆盖通用、字符串、数字、数组、对象、数学等场景
  • 🚀 基于 axios 的渐进式请求工具,支持 Vue 组合式 API
  • 📏 链式校验规则工厂,适配任意 UI 框架
  • 🏷️ 类型安全的枚举工具
  • 🤖 提供 Agent Skills,帮助 AI 编程助手理解和使用 Rattail
  • 🌲 可 Tree-shake,轻量,TypeScript 完整类型支持
  • 💪 90%+ 单元测试覆盖率

下面作者挑几个有意思的能力展开聊聊。

Vite+ 配置预设

做过前端工程化的同学应该都有体会,每次新项目光配 eslintprettier 这些东西就够喝一壶的了,配好了还得处理各种冲突。rattail 内置了面向 Vite+ 的开箱即用预设,一个 vite.config.ts 搞定 lintformatstagedgit hooks 等所有工程化配置。

import { lint, fmt, staged, clean, hook, defineConfig } from 'rattail/vite-plus'

export default defineConfig({
  lint: lint(),

  fmt: fmt(),

  staged: staged(),

  rattail: {
    clean: clean(),

    hook: hook(),
    
    api: {},

    release: {},

    changelog: {}
  },
})

之前作者为了配这些东西写了好几个配置文件,现在一个文件就够了(少写代码是第一生产力)。

CLI 工具链

安装 rattail 后会注册一个 rt 命令,覆盖了作者日常开发中最常用的几个场景。

# 清理产物
rt clean

# 安装 git hooks
rt hook

# 发布
rt release

# 生成 changelog
rt changelog

# 从 OpenAPI 生成 API 模块
rt api

这些命令都支持通过 vite.config.ts 中的 rattail 字段进行配置,也就是说项目根目录不需要再多出一堆 .xxxrc 文件了。这一点作者是比较在意的,毕竟谁也不想打开项目根目录看到十几个配置文件吧(有些项目根目录比 node_modules 还热闹)。

140+ 工具函数

lodash 大家都耳熟能详了,rattail 里的工具函数覆盖的场景和 lodash 类似,包括类型判断数组对象字符串数学函数集合文件等分类,用法就不逐个列举了。和 lodash 不同的是,这些函数从第一天就是用 TypeScript 写的,类型推导是第一优先级,全部可 Tree-shake。除了 lodash 风格的工具函数以外,rattail 还内置了一些前端项目中常用的实用工具,比如 sumHash 计算哈希、uuid 生成唯一 ID、mitt 事件总线、duration 时间格式化、storage / cookieStorage 存储封装、copyText 复制文本、download 文件下载等等,省得同学们每次都要单独装一堆小包。更多的可以去文档里查看完整的 API 列表。

类型安全的枚举工具

这个是作者个人比较喜欢的一个工具。前端项目里到处都是枚举值,比如订单状态、用户角色之类的。一般我们用 enum 或者常量对象来管理它们,但是 labeldescription 这些配套信息就只能另外维护了。enumOf 把值和它的元信息放在一起管理,并且类型推导是完备的。

import { enumOf } from 'rattail'

const Status = enumOf({
  Pending: { value: 0, label: '待处理' },
  Active: { value: 1, label: '进行中' },
  Done: { value: 2, label: '已完成' },
})

Status.Pending        // 0
Status.Active         // 1
Status.values()       // [0, 1, 2]
Status.labels()       // ['待处理', '进行中', '已完成']
Status.label(Status.Pending) // '待处理'
Status.options()      // [{ value: 0, label: '待处理' }, ...]

// 直接丢给 select 组件的 options,再也不用手动维护了

前端项目里到处都需要枚举值和它对应的文案,以前每次都要写个 map 或者 switch,现在一个 enumOf 就够了。另外 enumOflabeldescription 支持传入一个 getter 函数,配合 vue-i18n 之类的国际化方案可以很方便的实现多语言:

const Status = enumOf({
  Pending: { value: 0, label: () => t('status.pending') },
  Active: { value: 1, label: () => t('status.active') },
  Done: { value: 2, label: () => t('status.done') },
})

基于 axios 的渐进式请求工具

这个能力来自于作者之前开源的 @varlet/axle,现在通过 rattail/axle 直接引入。熟悉作者的同学可能看过之前介绍 axle 的文章,它在兼容 axios 的同时,天然支持 Vue3 Composition API

import { createAxle } from 'rattail/axle'
import { createUseAxle } from 'rattail/axle/use'

const axle = createAxle({ baseURL: '/api' })
const useAxle = createUseAxle({ axle })

const [users, getUsers, { loading, error }] = useAxle({
  method: 'get',
  url: '/user',
  params: { current: 1, pageSize: 10 },
})

作者一直觉得前端请求库和 Vue 的响应式系统应该有更好的结合方式,axle 就是在这个方向上的一个尝试。如果你不喜欢 axle 也完全没问题,rattail 的其他能力和请求库是解耦的,换成你喜欢的方案就好。

OpenAPI 生成 API 模块

rt api 可以直接解析后端提供的 OpenAPI / Swagger schema 文件,自动生成类型安全的 API 调用代码,这个在实际项目里把工作流做通之后体验可太好了。

vite.config.ts 里配置好 schema 路径和输出目录:

import { defineConfig } from 'rattail/vite-plus'

export default defineConfig({
  rattail: {
    api: {
      input: './openapi.json'
    },
  },
})

执行 rt api 后会自动生成这样的代码:

import { api } from '@/request'
import { type paths } from './_types'

export type ApiGetUsers = paths['/users']['get']
export type ApiCreateUser = paths['/users']['post']
export type ApiGetUser = paths['/users/{uuid}']['get']
export type ApiUpdateUser = paths['/users/{uuid}']['put']
export type ApiDeleteUser = paths['/users/{uuid}']['delete']

export type ApiGetUsersQuery = ApiGetUsers['parameters']['query']
export type ApiGetUsersRequestBody = undefined
export type ApiGetUsersResponseBody = ApiGetUsers['responses']['200']['content']['application/json']
// ... 其他类型同理

export const apiGetUsers = api<
  ApiGetUsersResponseBody, ApiGetUsersQuery, ApiGetUsersRequestBody>('/users', 'get')
export const apiCreateUser = api<
  ApiCreateUserResponseBody, ApiCreateUserQuery, ApiCreateUserRequestBody>('/users', 'post')
export const apiGetUser = api<
  ApiGetUserResponseBody, ApiGetUserQuery, ApiGetUserRequestBody>('/users/:uuid', 'get')
export const apiUpdateUser = api<
  ApiUpdateUserResponseBody, ApiUpdateUserQuery, ApiUpdateUserRequestBody>('/users/:uuid', 'put')
export const apiDeleteUser = api<
  ApiDeleteUserResponseBody, ApiDeleteUserQuery, ApiDeleteUserRequestBody>('/users/:uuid', 'delete')

请求类型、响应类型全部从 schema 里提取,不需要手写。后端接口变了,重新跑一遍 rt api 就行,前后端的类型始终保持同步。这个工作流对 AI Agent 也特别友好,AI 可以直接基于生成的类型去写业务代码,不会出现参数类型对不上的问题。甚至 AI Agent 可以通过 api 定义的变化,推测出你接下来要写什么业务。默认使用 axle,也支持 axios 的预设,同时支持 自定义输出

链式校验规则工厂

做表单的同学应该都写过类似 requiredminmax 这些校验规则。不同的 UI 框架校验规则的格式还不一样,每个项目都要适配一遍。rattail 提供了一个链式校验规则工厂,写起来很流畅,并且可以适配任意 UI 框架。这种内联的声明式写法和 TailwindCSS 的思路类似,可读性和可迁移性都非常好,对 AI 也特别友好,AI 可以直接从模板里读懂校验意图,生成和修改规则的准确度很高。

Naive UIElement Plus 为例:

<!-- Naive UI -->
<script setup lang="ts">
import type { FormItemRule } from 'naive-ui'
import { rulerFactory } from 'rattail/ruler'

const r = rulerFactory<FormItemRule>((validator, params = {}) => ({
  trigger: ['blur', 'change', 'input'],
  validator: (_, value) => validator(value),
  ...params,
}))
</script>

<template>
  <n-form :model>
    <n-form-item 
      path="name" 
      label="姓名"
      :rule="r().required('必填').min(2, '长度不正确').done()"
    >
      <n-input v-model:value="model.name" />
    </n-form-item>
  </n-form>
</template>
<!-- Element Plus -->
<script setup lang="ts">
import type { FormItemRule } from 'element-plus'
import { rulerFactory } from 'rattail/ruler'

const r = rulerFactory<FormItemRule>((validator, params) => ({
  validator(_, value, callback) {
    const e = validator(value)
    e ? callback(e) : callback()
  },
  trigger: ['blur', 'change', 'input'],
  ...params,
}))
</script>

<template>
  <el-form :model>
    <el-form-item 
      prop="email" 
      label="邮箱"
      :rules="r().email('必须是邮箱格式').done()"
    >
      <el-input v-model="model.email" />
    </el-form-item>
  </el-form>
</template>

AI Agent Skills

rattail 提供了一套 Agent Skills,说白了就是给 AI 写了一份"说明书",让 AI Agent 知道 rattail 有哪些能力、怎么用,不用你每次都手动告诉 AI。作者觉得未来的开源库都应该考虑对 AI Agent 的友好度。

写在最后

rattail 的工具函数和能力大多来自前端社区的通用实践。感谢同学们能看到这里,但是希望 rattail 能够帮助到大家。项目基于 MIT 协议。如果在使用的过程中遇到任何问题,欢迎在 issue 里反馈给我们,同时也十分欢迎对项目有兴趣的同学给我们发 pull request

支持我们的话留下一个 star 就好~

昨天 — 2026年4月16日首页

vite+vue2 动态路由加载方法实现

2026年4月16日 17:33

最近在改老项目,将webpack迁移到vite提高下速度 首先来看下默认静态加载路由,我们只需要在router/index.js直接配置好就可以了

dynamicRoutes_01.png

当然默认的情况 component: () => import('../views/HomeView.vue') 是这样的如果需要用@替代..需要在在vite.config.js中增加下配置

resolve: {
    alias: {
        '@': path.resolve(__dirname, 'src')
    }
},

dynamicRoutes_02.png

在webpack中动态加载使用如下,就可以了

export const loadView = (view) => {
  if (process.env.NODE_ENV === 'development') {
    return (resolve) => require([`@/views/${view}`], resolve)
  } else {
    // 使用 import 实现生产环境的路由懒加载
    return () => import(`@/views/${view}`)
  }
}

但是在vite中语法就变了,require() 是 CommonJS 语法,Vite 不支持,需要用import.meta.glob来实现,下面是相对路径使用,相对路径使用要注意当前方法引入对应根目录的层级

//代码文件顶部增加
const modules = import.meta.glob('../views/**/*')
//然后定义loadView 方法
export const loadView = (view) => {
    return modules[`../views/${viewPath}.vue`]  // 使用相对路径
}

在这里比较推荐使用相对路径来实现

//代码文件顶部增加
const modules = import.meta.glob('/src/views/**/*')
export const loadView = (view) => {
    return modules[`/src/views/${viewPath}.vue`]  // 使用绝对路径
}

说明一下如果从后端获取的动态组件路径是带.vue文件名字的可以忽略

最后又完善了一下增加了一些模糊匹配规则

/src/views/${viewName}.vue
/src/views/${viewName}/index.vue
/src/views/${viewName}/${viewName}.vue

完整的loadView 方法

export const loadView = (view) => {
    // 统一处理:无论后端是否带 .vue,都确保有后缀
    const viewName = view.replace(/\.vue$/, '')
    const possiblePaths = [
        `/src/views/${viewName}.vue`,
        `/src/views/${viewName}/index.vue`,
        `/src/views/${viewName}/${viewName}.vue`,
    ]

    for (const path of possiblePaths) {
        // const loader = modules[path]
        if (modules[path]) {
            console.log('✅ 匹配组件:', path)
            return modules[path]
        }
    }
    console.error(`未找到页面组件: ${viewName}`)
    console.log('可用页面组件:', Object.keys(modules))
    return null
}

演示demo

dynamicRoutes.gif

原文 www.liweiliang.com/1204.html

Vue<前端页面装修组件>

2026年4月16日 17:25

一个基于 Vue 2 和 Ant Design Vue 1.x 的可视化组件装修工具,支持拖拽排序、属性编辑和实时预览。

gif_1.gif

功能特性

  • 📱 移动端预览:支持自定义尺寸的移动端预览界面
  • 🎨 组件编辑:实时编辑组件属性,所见即所得
  • 📦 组件库:可扩展的组件库系统
  • 🎯 拖拽排序:支持组件的拖拽排序功能
  • 🔧 尺寸调整:可自定义预览区域尺寸,保持等比例缩放

技术栈

  • Vue 2
  • Ant Design Vue 1.x
  • vuedraggable

目录结构

src/
├── components/
│   └── DecorationBuilder/          # 装修工具主目录
│       ├── bases/                  # 基础组件
│       │   ├── Editor/             # 属性编辑器
│       │   ├── Preview/            # 移动端预览组件
│       │   │   ├── components/     # 预览组件的子组件
│       │   │   │   ├── BrowserToolbar/ # 浏览器工具栏
│       │   │   │   └── SizeEditor/     # 尺寸编辑器
│       │   └── Selector/           # 组件选择器
│       ├── config/                 # 配置文件
│       │   ├── componentTypes.js   # 组件类型定义
│       │   └── settings.js         # 全局设置
│       ├── widgets/                # 自定义组件
│       │   ├── Banner/             # 轮播图组件
│       │   ├── News/               # 新闻列表组件
│       │   └── index.js            # 组件注册表
│       └── index.vue               # 装修工具主入口
└── utils/
    ├── componentUtils.js           # 组件相关工具函数
    └── index.js                    # 通用工具函数
graph TD
    A[DecorationBuilder] --> B[bases]
    A --> C[config]
    A --> D[widgets]
    A --> E[index.vue]
    
    B --> F[Editor]
    B --> G[Preview]
    B --> H[Selector]
    
    G --> I[components]
    I --> J[BrowserToolbar]
    I --> K[SizeEditor]
    
    C --> L[componentTypes.js]
    C --> M[settings.js]
    
    D --> N[Banner]
    D --> O[News]
    D --> P[index.js]

核心组件说明

1. DecorationBuilder (主入口)

  • 文件:src/components/DecorationBuilder/index.vue
  • 功能:整合预览、编辑器和选择器组件,管理组件数据和交互逻辑

2. Preview (预览组件)

image.png

  • 文件:src/components/DecorationBuilder/bases/Preview/index.vue
  • 功能:展示移动端预览界面,支持组件拖拽排序
  • 子组件:
    • BrowserToolbar:浏览器工具栏,包含预览、添加组件、发布等功能
    • SizeEditor:尺寸编辑器,用于调整预览区域大小

3. Editor (属性编辑器)

image.png

  • 文件:src/components/DecorationBuilder/bases/Editor/index.vue
  • 功能:动态加载组件编辑器,允许编辑组件属性

4. Selector (组件选择器)

image.png

  • 文件:src/components/DecorationBuilder/bases/Selector/index.vue
  • 功能:展示所有可用组件,支持选择组件添加到预览区

配置文件说明

componentTypes.js

  • 定义组件类型枚举和元数据
  • 包含组件的显示名称、描述、图标等信息
export const COMPONENT_TYPES = {
  BANNER: 'banner',          // 轮播图
  NEWS_LIST: 'news-list'     // 新闻列表
}

export const COMPONENT_METADATA = {
  [COMPONENT_TYPES.BANNER]: {
    name: '轮播图',
    description: '支持多张图片轮播展示',
    icon: 'picture',
    category: '基础组件'
  }
  // ...
}

settings.js

  • 全局配置文件,包含预览设置等
export const PREVIEW_SETTINGS = {
  MOBILE_WIDTH: 375,         // 默认移动端宽度
  MOBILE_HEIGHT: 812         // 默认移动端高度
}

组件工具函数

文件:src/utils/componentUtils.js

主要功能:

  • getComponentMetadata():获取组件元数据
  • getAllComponentTypes():获取所有组件类型
  • getWidgetConfig():获取组件配置
  • getWidgetDefaultProps():获取组件默认属性
  • getWidgetPreview():获取预览组件
  • getWidgetEditor():获取编辑组件

组件映射关系

组件类型 组件名称 预览组件 编辑组件
banner 轮播图 BannerPreview BannerEditor
news-list 新闻列表 NewsPreview NewsEditor

数据格式说明

标准组件数据格式

[
  {
    "id": "1234567890",
    "type": "banner",
    "props": {
      "images": [
        { "url": "https://example.com/image1.jpg", "link": "" },
        { "url": "https://example.com/image2.jpg", "link": "" }
      ],
      "autoPlay": true,
      "interval": 3000,
      "dots": true,
      "arrows": false
    }
  },
  {
    "id": "0987654321",
    "type": "news-list",
    "props": {
      "title": "最新资讯",
      "news": [
        { "id": 1, "title": "新闻标题1", "date": "2026-04-16", "link": "" },
        { "id": 2, "title": "新闻标题2", "date": "2026-04-17", "link": "" }
      ],
      "showDate": true,
      "showArrow": true,
      "maxItems": 5
    }
  }
]

字段说明

  • id:组件唯一标识符,由系统自动生成
  • type:组件类型,对应 COMPONENT_TYPES 中的值
  • props:组件属性,包含所有可配置的参数

数据来源

  1. 默认数据:组件的初始默认属性来自各组件的 index.js 文件中的 defaultProps
  2. 用户配置:用户在编辑器中修改的属性会覆盖默认属性
  3. 保存/发布:最终的组件数据会以标准JSON格式保存或发布

使用方式

  • 前端渲染:通过组件类型动态加载对应的预览组件,并传入props进行渲染
  • 后端存储:可以将JSON数据存储到后端数据库中
  • 页面加载:从后端获取JSON数据后,可以直接传递给DecorationBuilder组件进行渲染

添加新组件指南

以添加一个"轮播的通知公告"组件为例:

1. 创建组件目录和文件

src/components/DecorationBuilder/widgets/ 下创建新组件目录:

NotificationBanner/
├── index.js           # 组件配置文件
├── preview.vue        # 预览组件
└── editor.vue         # 编辑组件

2. 编写组件配置文件 (index.js)

import NotificationBannerPreview from './preview.vue'
import NotificationBannerEditor from './editor.vue'
import { COMPONENT_TYPES } from '../../config/componentTypes'

export default {
  type: COMPONENT_TYPES.NOTIFICATION_BANNER,  // 需要在componentTypes.js中定义
  Preview: NotificationBannerPreview,
  Editor: NotificationBannerEditor,
  defaultProps: {
    // 组件默认属性
    notifications: [
      { id: 1, content: '通知内容1' },
      { id: 2, content: '通知内容2' }
    ],
    autoPlay: true,
    interval: 2000
  }
}

3. 编写预览组件 (preview.vue)

<template>
  <div class="notification-banner">
    <!-- 轮播的通知内容 -->
  </div>
</template>

<script>
export default {
  name: 'NotificationBannerPreview',
  props: {
    component: {
      type: Object,
      required: true
    }
  }
}
</script>

<style scoped>
/* 组件样式 */
</style>

4. 编写编辑组件 (editor.vue)

<template>
  <div class="notification-banner-editor">
    <!-- 属性编辑表单 -->
  </div>
</template>

<script>
export default {
  name: 'NotificationBannerEditor',
  props: {
    component: {
      type: Object,
      required: true
    }
  }
}
</script>

5. 注册组件类型

src/components/DecorationBuilder/config/componentTypes.js 中添加组件类型:

export const COMPONENT_TYPES = {
  // ... 现有类型
  NOTIFICATION_BANNER: 'notification-banner'  // 新增通知公告类型
}

export const COMPONENT_METADATA = {
  // ... 现有元数据
  [COMPONENT_TYPES.NOTIFICATION_BANNER]: {
    name: '通知公告',
    description: '轮播展示通知内容',
    icon: 'bell',
    category: '基础组件'
  }
}

6. 注册组件

src/components/DecorationBuilder/widgets/index.js 中导入并注册新组件:

import BannerComponent from './Banner'
import NewsComponent from './News'
import NotificationBannerComponent from './NotificationBanner'  // 导入新组件

export const widgets = [
  BannerComponent,
  NewsComponent,
  NotificationBannerComponent  // 注册新组件
]

注意事项

  1. 所有组件必须遵循相同的命名和目录结构
  2. 新组件必须在 componentTypes.js 中定义类型和元数据
  3. 预览组件和编辑组件必须正确导出
  4. 默认属性应该在组件的 index.js 中定义

Vue3 KeepAlive 深度揭秘:组件缓存的魔法是如何实现的?

2026年4月16日 11:29

Vue3 KeepAlive 深度揭秘:组件缓存的魔法是如何实现的?

本文将带你深入 Vue3 内核,从源码层面彻底搞懂 KeepAlive 组件的缓存机制、LRU 淘汰策略以及组件"失活"与"激活"的底层实现原理。

📋 文章导航


1. 为什么需要 KeepAlive?

1.1 实际业务场景

在开发后台管理系统或多标签页应用时,我们经常会遇到这样的需求:

  • 表单页面:用户填写了一半的表单,切换到其他页面查看资料,返回时期望表单数据还在
  • 列表页面:滚动到第 N 页,查看详情后返回,期望回到原来的滚动位置
  • 地图应用:地图已经缩放和平移到特定位置,切换页面后返回保持原状

1.2 没有 KeepAlive 的问题

<template>
  <button @click="currentView = 'A'">页面A</button>
  <button @click="currentView = 'B'">页面B</button>

  <!-- 普通动态组件切换 -->
  <component :is="currentView" />
</template>

<script setup>
import { ref } from "vue";
import ViewA from "./ViewA.vue";
import ViewB from "./ViewB.vue";

const currentView = ref("ViewA");
</script>

问题:当从 A 切换到 B 时,A 组件会被完全销毁(触发 onUnmounted),状态全部丢失。再切回 A 时,组件重新创建,所有数据重置。

1.3 KeepAlive 的解决方案

KeepAlive 通过组件级缓存完美解决这个问题:

  • 组件切换时不会销毁,而是进入"失活"状态
  • 组件实例、响应式数据、DOM 状态全部保留
  • 切换回来时"激活",瞬间恢复,无需重新渲染

2. KeepAlive 基础使用

2.1 基本用法

<template>
  <button
    v-for="tab in tabs"
    :key="tab"
    @click="currentTab = tab"
    :class="{ active: currentTab === tab }"
  >
    {{ tab }}
  </button>

  <!-- 使用 KeepAlive 包裹动态组件 -->
  <KeepAlive>
    <component :is="currentTab" />
  </KeepAlive>
</template>

<script setup>
import { ref } from "vue";
import Home from "./Home.vue";
import Posts from "./Posts.vue";
import Archive from "./Archive.vue";

const currentTab = ref("Home");
const tabs = ["Home", "Posts", "Archive"];
</script>

2.2 重要限制

⚠️ KeepAlive 只能缓存单个直接子节点

<!-- ❌ 错误:多个根节点 -->
<KeepAlive>
  <CompA />
  <CompB />
</KeepAlive>

<!-- ✅ 正确:使用动态组件包裹 -->
<KeepAlive>
  <component :is="activeComponent" />
</KeepAlive>

<!-- ✅ 正确:使用 v-if 切换单个组件 -->
<KeepAlive>
  <CompA v-if="showA" />
  <CompB v-else />
</KeepAlive>

3. 核心属性详解

3.1 属性一览表

属性 类型 说明
include string | RegExp | Array 只有名称匹配的组件会被缓存
exclude string | RegExp | Array 任何名称匹配的组件都不会被缓存
max number | string 最多可以缓存多少组件实例

3.2 include - 白名单缓存

<!-- 字符串形式(逗号分隔) -->
<KeepAlive include="Home,Posts">
  <component :is="currentTab" />
</KeepAlive>

<!-- 数组形式 -->
<KeepAlive :include="['Home', 'Posts']">
  <component :is="currentTab" />
</KeepAlive>

<!-- 正则表达式 -->
<KeepAlive :include="/^User/">
  <component :is="currentTab" />
</KeepAlive>

匹配规则:与组件的 name 选项进行匹配

<script>
export default {
  name: "Home", // 这个名字用于 include/exclude 匹配
  // ...
};
</script>

<!-- 或者使用 script setup -->
<script setup>
defineOptions({
  name: "Home",
});
</script>

3.3 exclude - 黑名单排除

<!-- 不缓存 Archive 组件 -->
<KeepAlive exclude="Archive">
  <component :is="currentTab" />
</KeepAlive>

<!-- 排除多个 -->
<KeepAlive :exclude="['Archive', 'Settings']">
  <component :is="currentTab" />
</KeepAlive>

3.4 max - LRU 缓存淘汰

<KeepAlive :max="5">
  <component :is="currentTab" />
</KeepAlive>

LRU (Least Recently Used) 算法

  1. 设置最大缓存数为 5
  2. 依次访问 A → B → C → D → E,全部缓存
  3. 访问 F 时,缓存已满,淘汰最久未使用的 A
  4. 访问 B,B 变为最近使用
  5. 访问 G,淘汰 C(现在 C 是最久未使用的)
缓存状态变化示意:

初始: []
访问A: [A]
访问B: [A, B]
访问C: [A, B, C]
访问D: [A, B, C, D]
访问E: [A, B, C, D, E]  ← 达到 max
访问F: [B, C, D, E, F]A 被淘汰
访问B: [C, D, E, F, B]B 移到最近使用
访问G: [D, E, F, B, G]C 被淘汰

4. 专属生命周期钩子

被 KeepAlive 缓存的组件会新增两个生命周期钩子:

4.1 生命周期对比

普通组件:          KeepAlive 缓存组件:
   onMounted           onMounted (首次)
       ↓                   ↓
   onUnmounted      onActivated (每次激活)
                          ↓
                     onDeactivated (失活)
                          ↓
                     onActivated (再次激活)
                          ↓
                     onDeactivated
                          ↓
                     onUnmounted (真正销毁时)

4.2 钩子函数详解

<script setup>
import { onMounted, onUnmounted, onActivated, onDeactivated } from "vue";

// 首次挂载时触发(仅一次)
onMounted(() => {
  console.log("组件首次挂载");
  // 适合执行一次性初始化:建立 WebSocket 连接、获取基础配置等
});

// 每次从缓存激活时触发
onActivated(() => {
  console.log("组件被激活");
  // 适合执行:恢复定时器、重新获取最新数据、恢复滚动位置等
});

// 组件被缓存时触发
onDeactivated(() => {
  console.log("组件被失活(进入缓存)");
  // 适合执行:暂停定时器、保存临时状态等
});

// 组件真正被销毁时触发(仅一次)
onUnmounted(() => {
  console.log("组件被销毁");
  // 清理工作:关闭 WebSocket、清除全局事件监听等
});
</script>

4.3 实际应用示例

<script setup>
import { ref, onActivated, onDeactivated } from "vue";

const scrollTop = ref(0);
const timer = ref(null);
const listData = ref([]);

// 激活时恢复状态
onActivated(() => {
  // 恢复滚动位置
  const container = document.querySelector(".list-container");
  if (container) {
    container.scrollTop = scrollTop.value;
  }

  // 重启定时刷新
  timer.value = setInterval(fetchLatestData, 5000);

  // 重新获取最新数据(可选)
  fetchLatestData();
});

// 失活时保存状态
onDeactivated(() => {
  // 保存滚动位置
  const container = document.querySelector(".list-container");
  if (container) {
    scrollTop.value = container.scrollTop;
  }

  // 暂停定时刷新
  if (timer.value) {
    clearInterval(timer.value);
    timer.value = null;
  }
});

async function fetchLatestData() {
  // 获取最新数据...
}
</script>

5. 底层实现原理

5.1 核心问题拆解

KeepAlive 要实现组件缓存,必须解决三个核心问题:

问题 解决方案
如何保存组件状态? 使用 Map 缓存组件的 VNode
如何识别缓存组件? 通过 shapeFlag 标记组件状态
如何让组件"隐藏"而不是销毁? 使用 move 函数将 DOM 移入隐藏容器

5.2 组件状态标记

Vue3 使用 shapeFlag 来标记 VNode 的类型和状态:

// 组件需要被缓存(进入缓存流程)
const COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8; // 256

// 组件已被缓存(从缓存恢复)
const COMPONENT_KEPT_ALIVE = 1 << 9; // 512

标记的作用

  1. COMPONENT_SHOULD_KEEP_ALIVE:告诉渲染器这个组件不应该被销毁,而是执行失活流程
  2. COMPONENT_KEPT_ALIVE:告诉渲染器这个组件来自缓存,不需要重新创建实例

5.3 缓存与隐藏机制

┌─────────────────────────────────────────────────────────────┐
│                      KeepAlive 组件                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────────────┐      ┌──────────────────────────┐    │
│   │   cache (Map)   │      │   storageContainer       │    │
│   │                 │      │   (隐藏的 div 容器)       │    │
│   │  key → VNode    │      │                          │    │
│   │  key → VNode    │      │  ┌──────────────────┐    │    │
│   │  key → VNode    │      │  │  被缓存的 DOM    │    │    │
│   │                 │      │  │  ┌──┐ ┌──┐ ┌──┐  │    │    │
│   └─────────────────┘      │  │  │A │ │B │ │C │  │    │    │
│                            │  │  └──┘ └──┘ └──┘  │    │    │
│   ┌─────────────────┐      │  └──────────────────┘    │    │
│   │   keys (Set)    │      │                          │    │
│   │                 │      └──────────────────────────┘    │
│   │  [A, B, C]      │                                      │
│   │  ↑  LRU 顺序    │                                      │
│   └─────────────────┘                                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

5.4 流程图解

首次渲染组件 A:
    │
    ▼
┌─────────────────┐
│  检查 cache     │
│  是否已有 A?   │
└────────┬────────┘
         │ 否
         ▼
┌─────────────────┐
│  正常创建组件 A  │
│  渲染 DOM        │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  存入 cache     │
│  A → VNode      │
│  keys.add(A)    │
└─────────────────┘

切换到组件 B:
    │
    ▼
┌─────────────────┐
│  组件 A 失活     │
│  (不是销毁!)    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  调用 _deActivate│
│  将 A 的 DOM    │
│  移入隐藏容器    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  渲染组件 B      │
└─────────────────┘

切回组件 A:
    │
    ▼
┌─────────────────┐
│  检查 cache     │
│  是否已有 A?   │
└────────┬────────┘
         │ 是
         ▼
┌─────────────────┐
│  命中缓存!      │
│  复用 VNode     │
│  复用组件实例    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  调用 _activate │
│  将 A 的 DOM    │
│  从隐藏容器移出  │
│  插入页面        │
└─────────────────┘
         │
         ▼
┌─────────────────┐
│  触发 onActivated│
└─────────────────┘

6. 源码深度解析

6.1 完整源码注释版

// packages/runtime-core/src/components/KeepAlive.ts

import {
  type VNode,
  type ComponentInternalInstance,
  type SetupContext,
  type RendererInternals,
  type RendererElement,
  type RendererNode,
  ShapeFlags,
  currentInstance,
  unmountComponent,
  callWithAsyncErrorHandling,
  onBeforeUnmount,
  type Slots,
  type FunctionalComponent,
  type Component,
  type ComponentOptions,
  type VNodeNormalizedChildren,
  type VNodeChild,
  setTransitionHooks,
  type TransitionHooks,
} from "@vue/runtime-core";

export interface KeepAliveProps {
  include?: MatchPattern;
  exclude?: MatchPattern;
  max?: number | string;
}

type MatchPattern = string | RegExp | (string | RegExp)[];

export const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,

  // 标记这是一个 KeepAlive 组件
  __isKeepAlive: true,

  props: {
    include: [String, RegExp, Array] as PropType<MatchPattern>,
    exclude: [String, RegExp, Array] as PropType<MatchPattern>,
    max: [String, Number],
  },

  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // ==================== 1. 获取组件实例和渲染器方法 ====================
    const instance = currentInstance!;

    // 从组件实例中获取渲染器注入的方法
    // move: 移动 DOM 节点
    // createElement: 创建 DOM 元素
    const { move, createElement } = instance.ctx.renderer as RendererInternals<
      RendererNode,
      RendererElement
    >;

    // ==================== 2. 创建存储容器 ====================
    // storageContainer 是一个普通的 div,用于存放被失活的组件 DOM
    const storageContainer = createElement("div");

    // ==================== 3. 定义激活/失活方法 ====================

    /**
     * 失活组件:将组件的 DOM 移动到隐藏容器
     * @param vnode 被失活的组件 VNode
     * @param container 当前容器(未使用,保持一致性)
     * @param anchor 锚点(未使用)
     */
    instance.ctx.deactivate = (vnode: VNode) => {
      move(vnode, storageContainer, null, MoveType.LEAVE);
    };

    /**
     * 激活组件:将组件的 DOM 从隐藏容器移回页面
     * @param vnode 被激活的组件 VNode
     * @param container 目标容器
     * @param anchor 锚点位置
     * @param isSVG 是否是 SVG
     * @param optimized 是否优化模式
     */
    instance.ctx.activate = (
      vnode: VNode,
      container: RendererElement,
      anchor: RendererNode | null,
      isSVG: boolean,
      optimized: boolean,
    ) => {
      const vnodeComponent = vnode.component!;

      // 将 DOM 移回页面
      move(vnode, container, anchor, MoveType.ENTER, isSVG);

      // 处理过渡动画
      if (vnodeComponent.da) {
        // 延迟激活(等待延迟显示动画完成)
        queuePostRenderEffect(() => {
          vnodeComponent.da!(vnodeComponent.vnode);
        }, instance.suspense);
      }
    };

    // ==================== 4. 缓存相关变量 ====================
    const cache: Map<string, VNode> = new Map(); // 缓存容器:key -> VNode
    const keys: Set<string> = new Set(); // 记录缓存顺序,用于 LRU
    let current: VNode | null = null; // 当前正在渲染的组件
    let pendingCacheKey: string | null = null; // 待缓存的 key

    // ==================== 5. 缓存清理函数 ====================

    /**
     * 根据 key 淘汰缓存条目
     * 当缓存超过 max 时,淘汰最久未使用的组件
     */
    function pruneCacheEntry(key: string) {
      const cached = cache.get(key);
      if (!cached) return;

      // 如果当前正在渲染的组件不是要淘汰的,触发 deactivated 钩子
      if (current !== cached) {
        const comp = cached.component!;
        if (!comp.isDeactivated) {
          // 调用 deactivated 生命周期钩子
          callWithAsyncErrorHandling(
            comp.type.deactivated,
            comp,
            ErrorCodes.COMPONENT_DEACTIVATED,
          );
          comp.isDeactivated = true;
        }
      }

      // 从缓存中移除
      cache.delete(key);
      keys.delete(key);
    }

    /**
     * 清空所有缓存
     */
    function pruneCache() {
      cache.forEach((cached, key) => {
        pruneCacheEntry(key);
      });
    }

    // ==================== 6. 监听 props 变化 ====================

    // 当 include/exclude 变化时,清理不再匹配的缓存
    watch(
      () => [props.include, props.exclude],
      ([include, exclude]) => {
        // 清理不再满足 include/exclude 条件的缓存
        cache.forEach((vnode, key) => {
          const name = getName(vnode);
          if (
            name &&
            (!include || !matches(include, name)) &&
            exclude &&
            matches(exclude, name)
          ) {
            pruneCacheEntry(key);
          }
        });
      },
      { flush: "post", deep: true },
    );

    // ==================== 7. 组件卸载时清理 ====================

    onBeforeUnmount(() => {
      cache.forEach((vnode) => {
        const { shapeFlag, component } = vnode;
        // 如果组件还在激活状态,需要手动卸载
        if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
          unmountComponent(component!);
        }
      });
    });

    // ==================== 8. 核心渲染逻辑 ====================

    return () => {
      // 获取默认插槽的第一个子节点
      const rawVNode = slots.default && slots.default();

      // 如果没有子节点,直接返回
      if (!rawVNode || rawVNode.length !== 1) {
        if (__DEV__ && rawVNode && rawVNode.length > 1) {
          warn(`KeepAlive should contain exactly one component child.`);
        }
        current = null;
        return rawVNode;
      }

      // 获取内部真实组件(处理 Teleport 等包裹情况)
      const vnode = getInnerChild(rawVNode[0]);
      const comp = vnode.type as Component;

      // 获取组件名称用于 include/exclude 匹配
      const name = getName(vnode);

      // 检查是否应该缓存
      const shouldCache = !(
        name &&
        ((props.include && !matches(props.include, name)) ||
          (props.exclude && matches(props.exclude, name)))
      );

      // 获取缓存 key
      const key = vnode.key == null ? comp : vnode.key;
      const cachedVNode = cache.get(key);

      // ==================== 8.1 命中缓存 ====================
      if (cachedVNode) {
        // 复用缓存的组件实例
        vnode.component = cachedVNode.component;
        vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE;

        // 更新 LRU 顺序:先删除再添加,确保在 Set 末尾(最近使用)
        keys.delete(key);
        keys.add(key);
      }
      // ==================== 8.2 未命中缓存 ====================
      else if (shouldCache) {
        // 存入新缓存
        cache.set(key, vnode);
        keys.add(key);

        // LRU 淘汰:如果超过 max,删除最久未使用的
        if (props.max && keys.size > parseInt(props.max as string, 10)) {
          pruneCacheEntry(keys.values().next().value);
        }
      }

      // 标记组件需要被缓存(影响卸载流程)
      vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE;
      current = vnode;

      return rawVNode;
    };
  },
};

// 辅助函数:获取组件名称
function getName(vnode: VNode): string | undefined {
  return (
    (vnode.type as ComponentOptions).name ||
    (vnode.type as ComponentOptions).__name ||
    (typeof vnode.type === "function" &&
      (vnode.type as FunctionalComponent).name)
  );
}

// 辅助函数:匹配模式
function matches(pattern: MatchPattern, name: string): boolean {
  if (isArray(pattern)) {
    return pattern.some((p) => matches(p, name));
  } else if (isString(pattern)) {
    return pattern.split(",").includes(name);
  } else if (isRegExp(pattern)) {
    return pattern.test(name);
  }
  return false;
}

6.2 关键逻辑解析

6.2.1 为什么使用 Map 和 Set?
const cache: Map<string, VNode> = new Map(); // 快速查找:O(1)
const keys: Set<string> = new Set(); // 保持插入顺序,支持 LRU
  • Map:提供 O(1) 的查找效率,适合频繁读取缓存
  • Set:保持插入顺序,且可以方便地获取"第一个"元素(最久未使用)
6.2.2 LRU 淘汰实现
// 更新 LRU 顺序
keys.delete(key); // 先删除旧位置
keys.add(key); // 再添加到末尾(最近使用)

// 淘汰最久未使用的
if (max && keys.size > max) {
  pruneCacheEntry(keys.values().next().value); // 获取并删除第一个
}
6.2.3 渲染器如何配合 KeepAlive?
// packages/runtime-core/src/renderer.ts

// 在组件卸载流程中
function unmountComponent(instance) {
  const { shapeFlag } = instance.vnode;

  // 检查是否是 KeepAlive 缓存的组件
  if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
    // 不销毁,而是调用 deactivate
    const { deactivate } = instance.parent?.ctx || {};
    if (deactivate) {
      deactivate(instance.vnode);
    }
    return;
  }

  // 普通组件:正常销毁流程
  // ...
}

// 在组件挂载流程中
function mountComponent(vnode, container, anchor) {
  // 检查是否来自缓存
  if (vnode.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
    // 复用已有实例,不需要重新创建
    const instance = vnode.component;

    // 调用 activate 将 DOM 移回页面
    const { activate } = instance.parent?.ctx || {};
    if (activate) {
      activate(vnode, container, anchor);
    }
    return;
  }

  // 普通组件:正常创建流程
  // ...
}

7. 实战应用场景

7.1 多标签页缓存

<template>
  <div class="tabs">
    <div
      v-for="tab in tabs"
      :key="tab.name"
      class="tab-item"
      :class="{ active: currentTab === tab.name }"
      @click="currentTab = tab.name"
    >
      {{ tab.label }}
      <span class="close" @click.stop="closeTab(tab.name)">×</span>
    </div>
  </div>

  <div class="tab-content">
    <KeepAlive :include="cachedTabs" :max="10">
      <component :is="currentTabComponent" :key="currentTab" />
    </KeepAlive>
  </div>
</template>

<script setup>
import { ref, computed, watch } from "vue";
import UserList from "./UserList.vue";
import OrderList from "./OrderList.vue";
import Settings from "./Settings.vue";

const tabs = ref([
  { name: "UserList", label: "用户管理", component: UserList },
  { name: "OrderList", label: "订单管理", component: OrderList },
  { name: "Settings", label: "系统设置", component: Settings },
]);

const currentTab = ref("UserList");
const cachedTabs = ref(["UserList", "OrderList"]); // 只缓存特定标签

const currentTabComponent = computed(() => {
  const tab = tabs.value.find((t) => t.name === currentTab.value);
  return tab?.component;
});

function closeTab(tabName) {
  // 关闭标签时从缓存列表移除
  const index = cachedTabs.value.indexOf(tabName);
  if (index > -1) {
    cachedTabs.value.splice(index, 1);
  }
  // 切换到其他标签...
}
</script>

7.2 表单数据保持

<template>
  <KeepAlive :include="['UserForm']">
    <UserForm v-if="showForm" @submit="handleSubmit" />
    <UserDetail v-else :user="currentUser" @edit="showForm = true" />
  </KeepAlive>
</template>

<script setup>
import { ref } from "vue";
import UserForm from "./UserForm.vue";
import UserDetail from "./UserDetail.vue";

const showForm = ref(true);
const currentUser = ref(null);

function handleSubmit(userData) {
  // 提交表单后切换到详情页
  currentUser.value = userData;
  showForm.value = false;
}
</script>

7.3 列表页状态保持

<!-- ListPage.vue -->
<template>
  <div class="list-page">
    <!-- 搜索条件 -->
    <SearchForm v-model="searchParams" @search="handleSearch" />

    <!-- 列表 -->
    <div class="list-container" ref="listRef">
      <div
        v-for="item in listData"
        :key="item.id"
        class="list-item"
        @click="goToDetail(item)"
      >
        {{ item.name }}
      </div>
    </div>

    <!-- 分页 -->
    <Pagination v-model:page="page" v-model:size="pageSize" :total="total" />
  </div>
</template>

<script setup>
import { ref, onActivated, onDeactivated } from "vue";
import { useRouter } from "vue-router";

const router = useRouter();
const listRef = ref(null);

// 状态数据
const searchParams = ref({});
const listData = ref([]);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const scrollTop = ref(0);

// 激活时恢复状态
onActivated(() => {
  // 恢复滚动位置
  if (listRef.value) {
    listRef.value.scrollTop = scrollTop.value;
  }

  // 可选:刷新数据(如果需要保持最新)
  // fetchData()
});

// 失活时保存状态
onDeactivated(() => {
  if (listRef.value) {
    scrollTop.value = listRef.value.scrollTop;
  }
});

function goToDetail(item) {
  router.push(`/detail/${item.id}`);
}

async function handleSearch() {
  // 搜索逻辑...
}
</script>

8. 性能优化建议

8.1 合理设置 max

<!-- ❌ 不设置 max,可能无限增长导致内存泄漏 -->
<KeepAlive>
  <component :is="currentTab" />
</KeepAlive>

<!-- ✅ 根据业务场景设置合理的 max -->
<KeepAlive :max="5">
  <component :is="currentTab" />
</KeepAlive>

8.2 使用 include/exclude 精确控制

<!-- 只缓存必要的组件,减少内存占用 -->
<KeepAlive :include="['UserList', 'OrderList']" :max="5">
  <component :is="currentTab" />
</KeepAlive>

8.3 避免缓存大型组件

<script setup>
// 对于包含大量数据或复杂图表的组件,考虑不缓存
defineOptions({
  name: "HeavyDataChart", // 在 exclude 中排除
});
</script>

8.4 及时清理缓存

<script setup>
import { ref, nextTick } from "vue";

const includeList = ref(["TabA", "TabB", "TabC"]);
const currentTab = ref("TabA");
const keepAliveRef = ref(null);

// 方法1:通过修改 include 排除特定组件
function clearCache(componentName) {
  const index = includeList.value.indexOf(componentName);
  if (index > -1) {
    includeList.value.splice(index, 1);
  }
}

// 方法2:使用 v-if 强制重新创建 KeepAlive(清空所有缓存)
async function clearAllCache() {
  keepAliveRef.value = false;
  await nextTick();
  keepAliveRef.value = true;
}
</script>

<template>
  <KeepAlive v-if="keepAliveRef" :include="includeList">
    <component :is="currentTab" />
  </KeepAlive>
</template>

9. 常见问题与避坑指南

9.1 组件 name 未设置导致缓存失效

<script setup>
// ❌ 错误:没有设置 name,include/exclude 无法匹配
// 组件会被缓存,但无法通过 include/exclude 控制

// ✅ 正确:显式设置 name
defineOptions({
  name: "MyComponent",
});
</script>

9.2 动态组件 key 问题

<template>
  <!-- ❌ 错误:key 变化会导致缓存失效 -->
  <KeepAlive>
    <component :is="currentTab" :key="Date.now()" />
  </KeepAlive>

  <!-- ✅ 正确:使用稳定的 key 或组件名作为 key -->
  <KeepAlive>
    <component :is="currentTab" :key="currentTab" />
  </KeepAlive>
</template>

9.3 异步组件的缓存

<script setup>
import { defineAsyncComponent } from "vue";

const AsyncComp = defineAsyncComponent(() => import("./AsyncComp.vue"));
</script>

<template>
  <!-- ✅ 异步组件也可以被缓存 -->
  <KeepAlive>
    <AsyncComp />
  </KeepAlive>
</template>

9.4 与 Transition 一起使用

<template>
  <!-- ✅ KeepAlive 应该包裹在 Transition 内部 -->
  <Transition name="fade" mode="out-in">
    <KeepAlive>
      <component :is="currentTab" />
    </KeepAlive>
  </Transition>

  <!-- ❌ 不要这样:KeepAlive 包裹 Transition -->
</template>

9.5 缓存后数据不更新问题

<script setup>
import { onActivated, ref } from "vue";

const data = ref([]);

// ✅ 在 onActivated 中刷新数据
onActivated(() => {
  // 组件从缓存激活时,重新获取最新数据
  fetchLatestData();
});

// 或者使用 watch 监听路由参数变化
import { watch } from "vue";
import { useRoute } from "vue-router";

const route = useRoute();

watch(
  () => route.params.id,
  (newId) => {
    if (newId) {
      fetchData(newId);
    }
  },
  { immediate: true },
);
</script>

10. 总结与思考

10.1 核心要点回顾

要点 说明
缓存机制 使用 Map 存储 VNode,Set 管理 LRU 顺序
状态标记 COMPONENT_SHOULD_KEEP_ALIVECOMPONENT_KEPT_ALIVE shapeFlag
隐藏实现 通过 move 函数将 DOM 移入隐藏的 div 容器
生命周期 onActivated / onDeactivated 用于状态恢复和保存
淘汰策略 LRU 算法,当缓存超过 max 时淘汰最久未使用的组件

10.2 设计思想

KeepAlive 的设计体现了 Vue3 的几个重要思想:

  1. 声明式编程:开发者只需声明要缓存的组件,无需关心实现细节
  2. 可组合性:与动态组件、Transition、异步组件无缝配合
  3. 性能优先:LRU 策略防止内存无限增长,DOM 移动而非重建保证性能
  4. 扩展性:通过 include / exclude 提供精细的控制能力

10.3 思考题

  1. 为什么 KeepAlive 使用 DOM 移动而不是 display: none

    • 提示:考虑 CSS 样式继承、布局计算、内存占用等因素
  2. 如何实现一个自定义的缓存策略(如 FIFO)?

    • 提示:研究 KeepAlive 的源码结构,尝试扩展
  3. KeepAlive 与 Pinia/Vuex 状态管理如何配合?

    • 思考:什么时候用 KeepAlive 缓存状态,什么时候用全局状态管理?
  4. 在 SSR 场景下,KeepAlive 会有什么问题?

    • 提示:服务端没有 DOM,组件如何"失活"?

📚 扩展阅读

  1. Vue3 官方文档 - KeepAlive
  2. Vue3 源码解读 - KeepAlive 实现
  3. LRU 缓存算法详解
  4. Vue3 渲染器原理

💡 如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。

AI 打字跟随优化

2026年4月16日 09:37

前文提到通过API去监听滚动容器或容器尺寸去触发打字跟随,监听用户的滚动去读取造成重排的属性来实现用户是否跟随打字实际会造成浏览器多次重排。

重排

浏览器重排(回流)是浏览器对DOM元素计算位置、尺寸、布局的过程。 浏览器解析HTML合成DOM树,解析CSS合成CSSOM树,它们会一步步合成渲染树,进行布局。页面、结构、尺寸发生变化就会再走一遍布局的计算流程,称为重排。

为什么重排会造成性能开销?

  • 当一个元素变化后,可能影响父元素、子元素、兄弟元素等,浏览器需要进行递归遍历。
  • 重排需要在浏览器主线程发生,阻塞JS、渲染。
  • 频繁重排会造成浏览器卡顿,浏览器的刷新率是60fps,每帧近16ms,一次重排就会占据一部分时间;多次重排会导致掉帧。

哪些操作会导致重排?

  • 元素几何属性发生变化。
  • 增删、移动DOM。
  • 窗口变化(resize、scroll页面等)
  • 获取布局相关属性:
    • offsetTop / offsetLeft / offsetWidth / offsetHeight

    • scrollTop / scrollHeight

    • clientTop / clientWidth

    • getComputedStyle()

    • getBoundingClientRect()

IntersectionObserver 哨兵模式

这里直接取消滚动事件的监听,在容器的最底部放一个哨兵容器,通过 IntersectionObserver 去监听哨兵在监听的父元素在可视区域的交叉值来判断用户是否滚动。让哨兵通过 scrollIntoView 直接暴露在可视区域,实现打字跟随。

<div class="chat-scroll-container" ref="scrollContainerRef">
    <div class="chat-container" id="messagesRef"></div>
    <div class="scroll-sentinel" ref="sentinelRef"></div> // 哨兵
</div>

threshold: 1 // 1 :表示全部进入,0 :露头就秒

onMounted(() => {
  const ro = new ResizeObserver(() => {
    if (enableAutoScroll.value) {
      scrollToBottom();
    }
  });
  ro.observe(chatMessagesRef.value);

  observer = new IntersectionObserver(
    (entries) => {
      const isIntersecting = entries[0].isIntersecting;
      enableAutoScroll.value = isIntersecting; 
    },
    {
      root: scrollContainerRef.value, // 监听父元素,默认为 root
      threshold: 1,       // 1表示全部进入,0 :露头就秒
      rootMargin: '10px', // 提前10px开始生效
    }
  );

  if (sentinelRef.value) observer.observe(sentinelRef.value);
});

  
  const scrollToBottom = () => {
  nextTick(() => {
    const el = sentinelRef.value;
    if (!el) return;
    if (enableAutoScroll.value) {
      sentinelRef.value.scrollIntoView({ behavior: 'instant' });
    }
  });
};

在实践过程中,如果使用 behavior: 'smooth' ,浏览器在触发 scrollToBottom 时发生的动画会频繁抖动,将哨兵挤到父容器的可视区域外,导致 IntersectionObserver 频繁触发,可能产生 bug,使用 instant 取消浏览器动画抖动,直接抵达底部,避免该情况产生。

Vue 3 defineOptions 宏,用 VuReact 编译成 React 长什么样?

作者 Ruihong
2026年4月16日 09:08

VuReact 是一个语义感知、约定驱动、支持渐进迁移的编译器,能把 Vue 3 代码一键转成标准可维护的 React 18+ 代码。

今天我们继续拆解核心 API:Vue 3 <script setup> 里的 defineOptions 宏,经过 VuReact 编译后在 React 中如何呈现?

前置约定

为了示例清爽、理解无歧义,先统一两个规则:

  1. 只保留核心逻辑,省略外层包裹与无关配置;
  2. 默认你已熟悉 Vue 3 defineOptions 的用法与语义。

编译对照:Vue defineOptions → React

1. Vue defineOptions({ name }) → React 组件命名

defineOptions 是 Vue 3 用于组件额外配置的宏,最常用就是指定组件 name。 在 React 中没有完全对应的宏,VuReact 会把 name 直接映射为组件函数名,保持语义一致。

Vue 代码

<script setup lang="ts">
  defineOptions({
    name: 'MyComponent'
  })
</script>

VuReact 编译后 React 代码

const MyComponent = () => {
  return <></>
}

export default MyComponent

defineOptions({ name }) 不会生成任何运行时 Hook,仅作为编译期信息,用来给 React 组件“起名字”,让 DevTools、调用栈保持和 Vue 一致。


2. Vue defineOptions 其他配置 → React 忽略/编译提示

defineOptions 还支持 inheritAttrscustomOptions 等配置。 由于 React 组件机制与 Vue 不同,无法直接映射,VuReact 会做保守处理:

  • inheritAttrs:React 无对应概念,直接忽略
  • customOptions:非标准配置,忽略并可在编译期提示
  • 其他扩展选项:统一忽略

Vue 代码

<script setup lang="ts">
  defineOptions({
    name: 'MyComponent',
    inheritAttrs: false
  })
</script>

VuReact 编译后 React 代码

const MyComponent = () => {
  return <></>
}

export default MyComponent
// inheritAttrs 在 React 中无直接对应,已忽略

这样处理的好处:不向 React 注入无用运行时代码,保持产物干净、符合 React 最佳实践。


3. 最佳实践:用 @vr-name 显式指定组件名

如果你希望100% 保留组件名语义,推荐使用 VuReact 官方推荐的注释约定:

<script setup lang="ts">
// @vr-name: MyComponent
</script>

编译后会稳定生成对应名称的 React 组件,比 defineOptions({ name }) 更可靠、更符合编译约定。

核心总结

  • defineOptions({ name }) → 编译为 React 组件名,无运行时开销
  • inheritAttrs 等 → React 无对应,直接安全忽略
  • 推荐用 // @vr-name: 组件名 替代,更稳定、更标准

VuReact 始终遵循:保留语义、不造多余运行时、符合 React 规范

相关资源


✨ 对你有帮助的话,欢迎 点赞 + 收藏 + 关注,持续更新 VuReact 编译原理实战~

你的 Vue 3 defineEmits(),VuReact 会编译成什么样的 React?

作者 Ruihong
2026年4月15日 18:50

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 defineEmits 宏经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中 defineEmits 的 API 用法与核心行为。

编译对照

Vue defineEmits → React props 事件回调映射

defineEmits 是 Vue 3 <script setup> 中用于声明组件自定义事件的宏,它会把事件名称和参数类型定义为函数签名。VuReact 会将它编译为 React props 的事件回调形式,并对事件名做驼峰映射。

  • Vue 代码:
<script setup lang="ts">
  defineProps<{ name?: string }>();

  const emit = defineEmits<{
    (e: 'save-item', payload: { id: string }): void;
    (e: 'update:name', value: string): void;
  }>();

  const submit = () => {
    emit('save-item', { id: '1' });
    emit('update:name', 'next');
  };
</script>
  • VuReact 编译后 React 代码:
type ICompProps = {
  name?: string;
  onSaveItem?: (payload: { id: string }) => void;
  onUpdateName?: (value: string) => void;
};

const submit = useCallback(() => {
  props.onSaveItem?.({ id: '1' });
  props.onUpdateName?.('next');
}, [props.onSaveItem, props.onUpdateName]);

从示例可以看到:Vue 的 defineEmits 不会直接编译为运行时 Hook,而是转换为 React 组件 props 中的回调函数。VuReact 会将事件名 save-item / update:name 映射为 onSaveItem / onUpdateName,并保留参数类型定义,实现了事件签名与 React props 回调的无缝对接


Vue v-model:xxx → React 双向绑定 props + 事件映射

此外,子组件中定义的 update:xxx 这类事件,通常用于实现 Vue 中父子组件的双向数据绑定,父组件会以 v-model:xxx="value" 的形式使用。VuReact 充分考虑了这种模式,能够精准地进行转换:

  • 父组件 Vue 代码:
<template>
  <Child v-model:name="current" />
</template>

<script setup>
  // @vr-name: Parent
  const current = ref('');
</script>
  • VuReact 编译后 React 代码:
const Parent = memo(() => {
  const current = useVRef('');
  return <Child name={current.value} onUpdateName={value => current.value = value} />
});

Vue emit 调用 → React props 回调调用

在 Vue 中,emit('event-name', payload) 触发组件自定义事件;在 React 中,VuReact 会把它编译为 props.onEventName?.(payload) 的调用形式。

  • Vue 代码:
<script setup lang="ts">
  const emit = defineEmits<{
    (e: 'submit', value: string): void;
  }>();

  const handleSubmit = () => {
    emit('submit', 'ok');
  };
</script>
  • VuReact 编译后 React 代码:
type ICompProps = {
  onSubmit?: (value: string) => void;
};

const handleSubmit = useCallback(() => {
  props.onSubmit?.('ok');
}, [props.onSubmit]);

VuReact 会对 emit 的事件名和参数进行类型映射,并在必要时自动为 useCallback 生成依赖数组,让 React 端的回调引用保持稳定,同时避免开发者手动维护依赖


Vue defineEmits 兼容事件名映射规则

VuReact 支持将 Vue 的短横线事件名、冒号事件名等映射为 React 的驼峰命名回调:

  • save-itemonSaveItem
  • update:nameonUpdateName
  • closeonClose

这种映射方式与 React 事件 props 习惯一致,也保持了 Vue 事件声明的语义。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

昨天以前首页

使用 IntersectionObserver + 哨兵元素实现长列表懒加载

作者 王小新_926
2026年4月15日 15:48

一、背景与痛点

在一个设备监控数据看板项目中,设备列表可能包含 300+ 个设备卡片。如果一次性渲染全部 DOM 节点,会带来明显的性能问题:

  • 首屏白屏时间长:300+ 卡片组件同时挂载,主线程阻塞
  • 内存占用高:大量 DOM 节点常驻内存
  • 交互卡顿:滚动、点击等操作响应延迟

为此,我们采用 IntersectionObserver + 哨兵元素 方案实现懒加载。

二、核心思路

整体思路可以概括为  "分页截取 + 哨兵触发"

全量数据(300+)  →  分页截取显示(每页15条)  →  哨兵进入视口时追加下一页

关键设计:

  1. 数据全量存储,视图分页截取deviceList 保存完整数据,displayDeviceList 通过 computed 计算 slice(0, end) 返回当前应显示的子集
  2. 哨兵元素:在列表末尾放置一个不可见的 DOM 元素,当它进入视口时触发加载
  3. IntersectionObserver:原生浏览器 API,高效监听元素与视口的交叉状态,零滚动事件开销

三、架构图示

┌─────────────────────────────────────────────┐
│            Vue Component (data)              │
│  deviceList: [...]         // 全量300+设备   │
│  devicePageSize: 15        // 每页条数       │
│  deviceCurrentPage: 0      // 当前页码       │
│  observer: null            // Observer实例    │
├─────────────────────────────────────────────┤
│            Computed Properties               │
│  displayDeviceList → slice(0, pageSize*page) │
│  hasMoreDevices → displayed < total          │
├─────────────────────────────────────────────┤
│            Template 渲染逻辑                 │
│  v-for="device in displayDeviceList"         │
│  ┌─── Card ───┐  ┌─── Card ───┐  ...        │
│  └────────────┘  └────────────┘              │
│  ┌─── Sentinel (ref="sentinel") ───┐         │
│  │  v-if="hasMoreDevices"          │         │
│  │  <加载更多设备...>               │         │
│  └─────────────────────────────────┘         │
└─────────────────────────────────────────────┘
         │                    ▲
         │ observe(sentinel)  │ isIntersecting
         ▼                    │
┌─────────────────────────────────────────────┐
│        IntersectionObserver                  │
│  rootMargin: '200px'   // 提前200px触发      │
│  threshold: 0.1                             │
│  → 触发 loadMoreDevices()                    │
│  → deviceCurrentPage++                       │
│  → displayDeviceList 自动更新 → DOM 更新     │
│  → $nextTick → 重新绑定哨兵                   │
└─────────────────────────────────────────────┘

四、核心代码实现

4.1 数据定义

data() {
  return {
    deviceList: [],        // 全量设备数据
    devicePageSize: 15,    // 每页条数
    deviceCurrentPage: 0,  // 当前已加载页数
    observer: null,        // IntersectionObserver 实例
  }
}

4.2 计算属性(视图截取 + 状态判断)

computed: {
  /** 当前已加载的设备列表(懒加载切片) */
  displayDeviceList() {
    const end = this.devicePageSize * this.deviceCurrentPage
    return this.deviceList.slice(0, end)
  },
  /** 是否还有更多设备可加载 */
  hasMoreDevices() {
    return this.displayDeviceList.length < this.deviceList.length
  }
}

关键点:使用 computed 而非手动维护一个 displayed 数组,确保数据源变化时自动响应更新。

4.3 哨兵元素(模板)

<!-- 设备网格容器 -->
<div class="dm-device-grid">
  <!-- 仅渲染 displayDeviceList 而非 deviceList -->
  <div v-for="device in displayDeviceList" :key="device.id" class="dm-device-card">
    <!-- 设备卡片内容 -->
  </div>
  <!-- 哨兵元素:仅在还有未加载数据时显示 -->
  <div v-if="hasMoreDevices" ref="sentinel" class="dm-lazy-sentinel">
    <i class="el-icon-loading" />
    <span>加载更多设备...</span>
  </div>
</div>

关键点v-if="hasMoreDevices" 确保数据全部加载后哨兵消失,Observer 自动停止触发。

4.4 IntersectionObserver 初始化

initObserver() {
  // 先断开旧观察器,防止重复绑定
  this.disconnectObserver()
  this.$nextTick(() => {
    const sentinel = this.$refs.sentinel
    if (!sentinel) return  // 哨兵不存在(数据已全部加载)

    this.observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          this.loadMoreDevices()
        }
      },
      {
        rootMargin: '200px',  // 提前200px触发,用户无感知
        threshold: 0.1
      }
    )
    this.observer.observe(sentinel)
  })
}

关键参数说明

  • rootMargin: '200px':哨兵距离视口还有 200px 时就触发回调,提前加载数据,实现 无感加载
  • threshold: 0.1:哨兵 10% 可见时即触发

4.5 加载更多 & 清理

/** 加载更多设备 */
loadMoreDevices() {
  if (!this.hasMoreDevices) return
  this.deviceCurrentPage++
  // 页码增加 → displayDeviceList 自动重新计算 → DOM 更新
  // Vue 响应式保证了这一链条无需手动操作
},

/** 断开观察器(组件销毁 / 切换组织时调用) */
disconnectObserver() {
  if (this.observer) {
    this.observer.disconnect()
    this.observer = null
  }
}

4.6 数据加载后重置

async loadDeviceList(organizationId) {
  this.deviceLoading = true
  try {
    const res = await fetchDeviceStatusList(params)
    this.deviceList = res.data.data || []
    // 重置懒加载分页
    this.deviceCurrentPage = 1  // 初始加载第一页
    this.$nextTick(() => {
      this.initObserver()  // 重新绑定观察器
    })
  } finally {
    this.deviceLoading = false
  }
}

4.7 生命周期钩子

mounted() {
  // ...其他初始化
  this.$nextTick(() => {
    this.initObserver()
  })
},
beforeDestroy() {
  // 清理 Observer,防止内存泄漏
  this.disconnectObserver()
}

五、数据流转全流程

用户滚动页面
    │
    ▼
IntersectionObserver 检测哨兵进入视口(提前200px)
    │
    ▼
回调触发 → loadMoreDevices()
    │
    ▼
deviceCurrentPage++ (1→2→3...)
    │
    ▼
displayDeviceList (computed) 自动重新计算
    slice(0, 15*2) → slice(0, 15*3) → ...
    │
    ▼
Vue 响应式更新 DOM(新增15个卡片)
    │
    ▼
哨兵元素被推到更下方
    │
    ▼
Observer 继续监听新位置的哨兵
    │
    ... 重复直到 hasMoreDevices === false
    │
    ▼
v-if="hasMoreDevices" = false → 哨兵从DOM移除
    │
    ▼
Observer 无目标 → 自动不再触发

六、方案优势总结

对比维度 传统 scroll 事件 本方案 (IntersectionObserver)
性能 滚动时高频触发,需 throttle/debounce 浏览器底层异步回调,零性能损耗
代码复杂度 需手动计算元素位置 getBoundingClientRect 声明式配置 rootMargin/threshold
兼容性 全兼容 IE 不支持,现代浏览器均支持
触发精度 节流后可能延迟或重复触发 精确触发一次,无重复

额外优点

  • 零依赖:纯浏览器原生 API,无需引入第三方库(如 vue-virtual-scroller)
  • 低侵入:仅需修改数据切片逻辑 + 添加哨兵元素,不改动现有卡片组件
  • 提前加载:通过 rootMargin 提前 200px 触发,用户几乎感知不到加载过程
  • 自动停止:数据全部加载后哨兵自动移除,Observer 不再触发

七、注意事项与踩坑

  1. $nextTick 必不可少initObserver 中获取 $refs.sentinel 必须在 DOM 更新后执行,所以需要 $nextTick 包裹
  2. 重置时机:切换组织 / 重新加载数据时,必须重置 deviceCurrentPage 并重新 initObserver
  3. 内存泄漏beforeDestroy 中务必调用 disconnectObserver() 清理
  4. Grid 布局兼容:哨兵元素需设置 grid-column: 1 / -1 确保占满整行,不会被挤到某一列
  5. v-if 而非 v-show:哨兵使用 v-if 控制而非 v-show,这样数据全部加载后哨兵完全从 DOM 移除,Observer 自然不再触发

「性能优化」虚拟列表极致优化实战:从原理到源码,打造丝滑滚动体验

2026年4月15日 14:35

前言

大家好,我是elk。

上篇文章我们聊了大文件的切片上传,这次再来看看另一个高频性能优化场景 —— 虚拟列表(Virtual List)

什么是虚拟列表?

虚拟列表「Virtual List」是一种前端性能优化技术,用于解决"长列表渲染"场景下,因DOM节点过多导致的页面卡顿,内存占用率高,首屏加载缓慢等问题。

核心思想是:只渲染当前视口可见的列表项,而非渲染全部列表数据。通过动态计算视口位置,复用DOM节点,实现"无限列表"的流畅渲染。

为什么需要虚拟列表?

在处理大数据量列表时,传统的渲染方式会面临两大瓶颈:

  1. DOM 节点过载:浏览器渲染 10,000 个复杂的 DOM 节点,内存消耗巨大。
  2. 布局与重绘:滚动时,大量的 DOM 节点重绘会导致帧率下降,产生明显的掉帧(Jank)。

适用业务场景

  • 大数据量列表渲染:后台管理系统的用户列表、日志列表、权限列表、数据报表等,数据量超1000条,全量渲染直接导致页面卡死、操作无响应。
  • 无限滚动场景:移动端信息流、商品列表、评论区、下拉选择器,用户持续下拉加载数据,DOM节点无限累加,最终引发页面崩溃。
  • 固定容器滚动列表:所有需要在固定高度容器内展示超长列表的业务场景。

核心原理

  • 视口计算:获取容器的可视高度,滚动距离,确定当前"可见区域"的范围
  • 数据截取:根据可见范围,计算需要渲染的列表项的起始索引和结束索引,从全部数据中截取范围内的数据,仅渲染截取后的可视数据
  • 偏移量计算:通过定位设置渲染区域的偏移量,让截取的数据精准的显现在视口内,模拟"滚动到指定位置的效果"
  • DOM复用:当滚动时,动态改变起始索引和结束索引,截取新的可视化数据,复用已渲染的DOM节点,减少DOM操作的开销

核心基础概念

  • 视口容器:用于展示列表的容器,用户的可见区域,通常设置为固定高度和overflow: auto
  • 列表项高度:单个列表项的高度,通常分为:"固定高度"和"动态高度"
  • 可见数量:可见区域中要展示的列表数量总个数,计算公式:Math.cell(视口高度 / 列表项高度)
  • 缓冲数量:在可见区域上下额外多渲染的数量,用于解决滚动时的"空白闪烁"问题。
  • 总高度:所有列表项的总高度,用于撑开容器,模拟长列表滚动(不设置,容器无法滚动)

核心知识点

主要是涉及到事件监听以及基础数据的计算和更新

基础知识点

滚动事件监听

通过监听容器的scoll事件,获取滚动距离(scrollTop),触发可见区域、起始索引、结束索引、可见列表、偏移量距离的计算

避免频繁触发滚动事件,需使用节流进行优化,避免过量计算损失性能

尺寸计算

  • 视口高度:可通过容器的「clientHeight」获得,一般定义固定高度
  • 滚动距离:通过容器滚动事件触发获得「scrollTop属性」
  • 固定高度:无需计算,自行设置的高度「itemHeight」
  • 动态高度:当容器滚动时,动态计算列表项的高度「clientHeight」,并列入缓存中

索引计算

起始索引「startIndex」

固定高度

index = Math.floor(scrollTop / ITEM_HEIGHT) 「滚动距离 / 固定单个项高度」

startIndex = Math.max(0, index - bufferCount) 「 减去缓冲个数获取真实起始索引 」

动态高度:需通过"累计高度"计算startIndex「遍历缓存的高度列表,通过二分法查找到大于等于scrollTop滚动距离的索引」

结束索引「endIndex」

index = startIndex + visibiliItemsCount + bufferCount 「起始索引 + 可见区域列表数量 + 缓冲量」

endIndex = Math.min( list.length, index )

偏移量计算

固定高度

    top = startIndex * ITEM_HEIGHT 「起始索引 * 单个项固定高度」

动态高度

top = prefixSumCache[startIndex] 「从高度缓存列表中获取当前起始索引的数据」

进阶知识点

在基础知识点上进行的优化措施,提升列表性能,优化用户体验

缓冲机制

当用户快速滚动时,如果是仅渲染可见区域内的数据,会出现"空白区域",数据未及时渲染

  • 缓存量设置1-5个,过多会增加DOM数量,削弱优化效果
  • 上方偏移量计算 startIndex + bufferCount , endIndex - bufferCount,就是确保上下都有缓冲

动态高度缓存与更新

在动态高度场景下,初始化时不知道每一项的真实高度,常见优化策略:

  • 先进行预估高度的渲染,渲染后通过nextTick获取真实高度
  • 将真实高度写入缓存,并重新计算前缀和
  • 后续滚动时,当实际高度和初始化缓存高度不匹配的时候才重新计算一次高度缓存

滚动事件节流

在滚动事件 handelScroll中使用了ticking锁和requestAnimationFrame

  • 滚动事件触发非常频繁,使用RAF可以确保浏览器在下一帧重绘前执行计算逻辑,避免掉帧,使滚动更平滑

二分查找优化索引定位

在动态高度场景下,需要根据 scrollTop 找到起始索引。如果每次都线性查找,时间复杂度 O(n)。利用 前缀和数组的单调递增特性,使用二分查找可将复杂度降至 O(log n)。

整体代码 —— 组件封装(Vue 3 + TypeScript)

以下是一个支持 动态高度缓冲区高度缓存二分查找 的完整虚拟列表组件。

<template>
  <div
    @scroll="handleScroll"
    ref="containerRef"
    :style="{ height: `${height}px` }"
    class="w-full position-relative top-0 left-0 overflow-auto"
  >
    <!-- 空状态 -->
    <div v-if="data.length === 0" class="w-full h-full flex items-center justify-center">
      <slot name="empty" />
    </div>
    <!-- 占位撑高容器 -->
    <template v-else>
      <div
        :style="{ height: `${containerHeight}px` }"
        class="w-full position-absolute top-0 left-0"
      ></div>
      <!-- 可视化容器 -->
      <div
        :style="{ transform: `translateY(${offset}px)` }"
        class="w-full position-absolute top-0 left-0"
      >
        <div
          v-for="(item, index) in visibleList"
          :key="item.id || index"
          ref="itemRef"
          :style="{ height: `${itemHeight}px` }"
          class="w-full flex items-center justify-center"
        >
          <slot name="default" :item="item" :index="index + startIndex" />
        </div>
      </div>
    </template>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watchEffect } from 'vue'
import type { PropType } from 'vue'

interface ListItem {
  id: number | string
  name: string
}

interface PropsParams {
  // 列表数据
  data: ListItem[]
  // 容器高度
  height: number
  // 项高度-预估高度
  itemHeight: number
  // 缓冲区数量
  bufferCount: number
}
const props: PropsParams = defineProps({
  data: {
    type: Array as PropType<ListItem[]>,
    default: () => [],
    required: true,
  },
  height: {
    type: Number,
    default: 250,
  },
  itemHeight: {
    type: Number,
    default: 50,
  },
  bufferCount: {
    type: Number,
    default: 5,
  },
})

// 容器ref
const containerRef = ref<HTMLDivElement>()
// 项ref
const itemRef = ref<HTMLDivElement[]>([])
// 滚动距离
const scrollTop = ref(0)

// 项高度-缓存集合
const itemHeightCache = ref<number[]>([])
// 前缀和-缓存集合
const prefixSumCache = ref<number[]>([])

// 可视化容器-开始索引
const startIndex = computed(() => {
  const index = getStartIndex(scrollTop.value)
  return Math.max(0, index - props.bufferCount)
})

// 可视化容器-结束索引
const endIndex = computed(() => {
  const index = startIndex.value + visibleCount.value + props.bufferCount * 2
  return Math.min(props.data.length, index)
})

// 撑开容器-高度
const containerHeight = computed(() => {
  return prefixSumCache.value[prefixSumCache.value.length - 1]
})
// 可视化容器-列表数量
const visibleCount = computed(() => {
  return Math.ceil(props.height / props.itemHeight)
})

// 可视化容器-渲染列表
const visibleList = computed(() => {
  return props.data.slice(startIndex.value, endIndex.value)
})

// 偏移量-计算
const offset = computed(() => {
  return prefixSumCache.value[startIndex.value]
})

/**
 * @description: 二分法-计算初始索引
 * @return {*}
 */
const getStartIndex = (scrollTop: number) => {
  let left = 0
  let right = prefixSumCache.value.length - 1
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    if (prefixSumCache.value[mid] === scrollTop) return mid
    if (prefixSumCache.value[mid] > scrollTop) {
      right = mid - 1
    } else {
      left = mid + 1
    }
  }
  return left
}

/**
 * @description: 初始化高度
 * @return {*}
 */
const initHeight = () => {
  try {
    // 初始化项高度缓存集合
    itemHeightCache.value = props.data.map(() => props.itemHeight)
    // 初始化前缀和缓存集合
    initPrefixSum()
  } catch (error) {
    console.error('初始化高度失败:', error)
  }
}

/**
 * @description: 初始化|修改 前缀和缓存集合
 * @return {*}
 */
const initPrefixSum = (index: number = 0) => {
  try {
    prefixSumCache.value = []
    let sum = 0
    // 计算前缀和缓存集合,从索引开始计算,直到列表结束
    itemHeightCache.value.forEach((item, i) => {
      if (i >= index) {
        prefixSumCache.value.push(sum)
        sum += item
      }
    })
  } catch (error) {
    console.error('初始化前缀和缓存集合失败:', error)
  }
}

/**
 * @description: 修改项的真实高度-当高度发生变化时才更新
 * @return {*}
 */
const updateItemHeight = async () => {
  try {
    await nextTick()
    const visibleItems = itemRef.value
    if (visibleItems.length === 0) return
    let hasHeightChanged = false
    visibleItems.forEach((el, index) => {
      if (el) {
        const itemIndex = index + startIndex.value
        const itemHeight = el.clientHeight
        // const itemHeight = el.getBoundingClientRect().height
        // 只有高度变化的时候才更新缓存
        if (itemHeight !== itemHeightCache.value[itemIndex]) {
          itemHeightCache.value[itemIndex] = itemHeight
          hasHeightChanged = true
        }
        if (hasHeightChanged) {
          initPrefixSum(itemIndex)
        }
      }
    })
  } catch (error) {
    console.error('更新项目高度失败:', error)
  }
}

/**
 * @description: 处理滚动事件
 * @return {*}
 */
let ticking = false
const handleScroll = () => {
  console.log('🚀 ~ handleScroll ~ containerRef: 触发了滚动事件')
  if (!ticking) {
    requestAnimationFrame(() => {
      if (containerRef.value) {
        scrollTop.value = containerRef.value?.scrollTop || 0
        updateItemHeight()
      }
      ticking = false
    })
    ticking = true
  }
}

// 监听数据变化-更新项高度
watchEffect(() => {
  if (props.data.length > 0) {
    initHeight()
    updateItemHeight()
  }
})

// 初始化-更新项高度
onMounted(() => {
  initHeight()
  updateItemHeight()
})
</script>

<style lang="css" scoped></style>

常见问题 & 最佳实践

Q1:为什么我的虚拟列表在快速滚动时还是会白屏?

  • 缓冲区太小:适当增加 bufferCount(比如从 2 提升到 5)。
  • 动态高度更新不及时:确保在 nextTick 后获取真实高度,并重新计算前缀和。
  • 未使用 requestAnimationFrame:滚动回调中的 DOM 操作可能被延迟,导致渲染跟不上。

Q2:动态高度组件中,prefixSum 的维护很容易出错,有什么建议?

推荐使用 长度 = n+1 的前缀和数组,其中 prefixSum[0] = 0prefixSum[i] 表示前 i 项的总高度。这样:

  • 第 i 项的偏移量 = prefixSum[i]
  • 总高度 = prefixSum[n]
  • 查找 scrollTop 对应索引时,二分查找第一个大于 scrollTop 的 prefixSum[i],然后 i-1 即为起始索引。

Q3:如何支持列表项内容动态变化(比如展开/收起)?

  • 监听内容变化,调用 updateRealHeights 重新测量受影响的项。
  • 如果是通过用户交互(如点击展开),可以手动触发更新并重新构建前缀和。

Q4:除了 transform 偏移,还有别的方案吗?

也可以使用 padding-top 偏移,但 transform 性能更好(不触发重排)。推荐使用 translateY

总结

虚拟列表是前端性能优化中 性价比极高 的一类技术 —— 实现成本可控,却能将万级列表的渲染性能从秒级降到毫秒级。本文从原理到代码,覆盖了固定高度、动态高度、缓冲区、二分查找、滚动节流等关键点。

优化永无止境,如果你还想更进一步,可以探索:

  • 使用 ResizeObserver 监听每一项的尺寸变化,自动更新高度缓存。
  • 结合 IntersectionObserver 实现可视区外图片懒加载。
  • 将虚拟列表与 分页 / 懒加载数据 结合,实现真正意义上的“无限滚动”。

希望这篇文章能帮你彻底掌握虚拟列表,写出更流畅的 Web 应用。如果觉得有帮助,欢迎点赞、评论、转发~

别再用 JSON.parse 深拷贝了,聊聊 StructuredClone

作者 ErpanOmer
2026年4月15日 09:58

临近下班,我们业务线出了一个极度无语的线上 Bug。

产品侧反馈,在一个非常核心的财务表单里,用户明明选择了 2026-04-14 作为结算日期,但点击提交后,整个页面直接白屏崩溃。

我打开错误监控看了一眼日志,立刻就把组里那个刚入职不久的小伙子叫了过来。 原因极其经典:他在把表单的原始状态同步给历史快照时,为了图省事,顺手写了一段几乎所有前端都写过的代码:

// 模拟用户表单数据
const formData = {
  amount: 1000,
  date: new Date("2026-04-14"), // 用户选的结算日期(Date对象)
};

// 深拷贝
const snapshot = JSON.parse(JSON.stringify(formData));

console.log("原始:", formData.date, typeof formData.date); 
// Date object

console.log("快照:", snapshot.date, typeof snapshot.date); 
// "2026-04-14T00:00:00.000Z" string

// 后续业务代码
function calcSettlementTime(data) {
  // 这里默认 date 是 Date 对象
  return data.date.getTime();
}

// 页面直接崩溃😢
try {
  const time = calcSettlementTime(snapshot);
  console.log("时间戳:", time);
} catch (err) {
  console.error("页面崩溃:", err);
}

他满脸委屈:老大,大家平时深拷贝不都是这么写的吗?🤷‍♂️

我让他自己把这段代码在控制台跑一遍。 当他看到表单里原本好好的 Date 对象,经过这一进一出,硬生生变成了一串 ISO 格式的字符串,导致后面调用 snapshot.date.getTime() 直接抛出 TypeError 时,他自己也沉默了。

作为前端老油条,这种因为 JSON.parse(JSON.stringify()) 引发的血案,我见过太多了。 它不仅会把 Date 变成字符串,还会把 MapSet 变成空对象 {},会把 undefinedSymbol 以及函数直接活生生抹除,更别提遇到循环引用时,它会当场抛出异常让你的主线程直接崩溃。

以前,我们为了解决这个破事,不得不在每个项目里老老实实 npm install lodash,然后引入那个笨重的 cloneDeep

但现在是 2026 年了。浏览器早就原生内置了完美的终极解药——structuredClone

今天咱们不聊虚的架构,就花三分钟,把这个原生 API 的底层逻辑讲清楚。


它是怎么解决历史遗留问题的?

structuredClone 不是什么语法糖,它是浏览器底层暴露出来的 结构化克隆算法(Structured Clone Algorithm)。这就意味着,它在 C++ 引擎层面的处理逻辑,远比 JS 业务层面的递归拷贝要深得多。

看一下原生 API 的用法:

const original = {
  date: new Date(),
  set: new Set([1, 2, 3]),
  map: new Map([['key', 'value']]),
  regex: /hello/i,
  buffer: new Uint8Array([1, 2, 3]).buffer,
};

// 制造一个循环引用
original.self = original;

// 一行代码,原生搞定
const cloned = structuredClone(original);

console.log(cloned.date instanceof Date); // true
console.log(cloned.set instanceof Set);   // true
console.log(cloned.self === cloned);      // true 完美处理循环引用!

发现没有?它不仅完美保留了所有的内置对象类型,连 JSON.parse 绝对搞不定的循环引用,它都处理得游刃有余。由于是在引擎底层运行,不需要像 Lodash 那样在 JS 运行时里疯狂压栈递归,它的执行效率在大部分复杂场景下都具有压倒性优势👍👍👍。


零拷贝转移 (Transferable Objects)

如果你以为 structuredClone 只是为了少引入一个 Lodash,那你就太小看浏览器的底层野心了。

它藏着一个 90% 的前端都不知道的极其硬核的功能:内存转移(Transfer)

在前端处理音视频、WebGL、或者读取几十 MB 的大文件时,我们经常会生成巨大的 ArrayBuffer。如果你用传统的深拷贝,内存瞬间翻倍,几十兆的内存分配极容易引起页面的掉帧卡顿。

structuredClone 提供了一个极其变态的第二个参数配置:{ transfer }

// 假设这是一个极大的 50MB 数据内存块
const u8Array = new Uint8Array(1024 * 1024 * 50);
const hugeBuffer = u8Array.buffer;

// 传统的深拷贝:内存翻倍,耗时极长
// const badCopy = lodash.cloneDeep(hugeBuffer); 

// 直接内存转移
const fastClone = structuredClone(hugeBuffer, { transfer: [hugeBuffer] });

console.log(fastClone.byteLength); // 52428800 (50MB 完美转移)
console.log(hugeBuffer.byteLength); // 0 (原对象的内存地址被转移)

这段代码的核心在于:它压根没有复制数据。 它直接在内存层面,把这块 50MB 数据的所有权,从 hugeBuffer 强行转移给了 fastClone。原对象被彻底掏空(变成了 detached 状态)。

这种零拷贝机制,在结合 Web Worker 处理复杂后台计算时,是打破性能瓶颈的绝对神器。这是任何第三方 JS 库都做不到的底层API。


一些坑要讲清楚🤔

既然这么牛,是不是以后项目里所有的拷贝闭着眼睛用它就行了? 作为一个踩过无数坑的老兵,我必须点出它的几个致命死角。如果你在真实的业务架构里滥用,下场比用 JSON.parse 还要惨。

对于函数和 DOM 节点的处理

JSON.parse 遇到函数,它会默默地忽略掉,至少不报错。 但 structuredClone 很直接。只要你的对象树里藏着一个方法,或者藏着一个 DOM 节点的引用,它会直接给你抛出 DataCloneError

const objWithFunc = {
  data: 123,
  onClick: () => console.log('click')
};

// 只要带有函数,直接抛同步错误
// DOMException: () => console.log('click') could not be cloned.
const copy = structuredClone(objWithFunc); 

这就意味着,如果你要拷贝的是一个 Vue/React 的响应式组件实例,或者是带有业务方法的数据模型,绝对不能用它👋。

原型链的断裂

不管你原本是一个通过 class 实例化的多么高级的业务对象,经过 structuredClone 的洗礼后,它都会变成一个普通的纯对象(Plain Object)。

class User {
  constructor(name) { this.name = name; }
  sayHi() { console.log('hi'); }
}

const user = new User('前端');
const cloneUser = structuredClone(user);

console.log(cloneUser instanceof User); // false 
cloneUser.sayHi(); // TypeError: cloneUser.sayHi is not a function

原型链上的所有方法全部丢失。它只关心纯粹的数据,不关心你的面向对象架构。‘


需要时收藏起来⭐⭐⭐

这几年,前端的工具链卷得飞起,大家的 package.json 越来越臃肿。遇到数组去重找库,遇到时间格式化找库,遇到深拷贝也要找库。

如果你只是单纯地处理一些后端传过来的嵌套数据,或者表单的复杂配置结构,完全可以直接把 structuredClone 敲在你的代码里。不用担心兼容性,目前主流浏览器(包括 Node.js)的支持率早就达到了工业级使用的标准了。

image.png

下次 Code Review 时,别再让我看到满屏的 JSON.parse 了 (玩笑😁😁😁)。

分享完毕,谢谢大家🙌

Suggestion.gif

你的 Vue 3 生命周期,VuReact 会编译成什么样的 React?

作者 Ruihong
2026年4月15日 09:22

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的生命周期钩子经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中生命周期钩子例如 onMounted、onBeforeMount、onUpdated、onBeforeUpdate、onBeforeUnmount、onUnmounted 的 API 用法与核心行为。

编译对照

Vue onMounted() → React useMounted()

onMounted 是 Vue 3 中用于组件首次挂载后执行逻辑的生命周期钩子,适合放初始化请求、订阅启动、DOM 相关准备等操作。VuReact 会将它编译为 useMounted,让 React 端也能在组件挂载后执行一次性副作用。

  • Vue 代码:
<script setup>
  import { onMounted } from 'vue';

  onMounted(() => {
    console.log('组件已挂载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useMounted } from '@vureact/runtime-core';

useMounted(() => {
  console.log('组件已挂载');
});

从示例可以看到:Vue 的 onMounted() 被翻译为 useMounted。VuReact 提供的 useMountedonMounted 的适配 API完全模拟 Vue onMounted 的首次挂载后执行时机

Vue onBeforeMount() → React useBeforeMount()

onBeforeMount 是 Vue 3 中用于组件挂载前执行逻辑的钩子,适合放需要在布局阶段之前准备的内容。VuReact 会将它编译为 useBeforeMount,基于 React 的布局效果在挂载前执行。

  • Vue 代码:
<script setup>
  import { onBeforeMount } from 'vue';

  onBeforeMount(() => {
    console.log('组件即将挂载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useBeforeMount } from '@vureact/runtime-core';

useBeforeMount(() => {
  console.log('组件即将挂载');
});

VuReact 提供的 useBeforeMountonBeforeMount 的适配 API完全模拟 Vue onBeforeMount 的首次挂载前时机

Vue onBeforeUpdate() → React useBeforeUpdate()

onBeforeUpdate 是 Vue 3 中用于跳过首次挂载,仅在组件更新前执行的钩子,适合放变更前校验、记录旧值、提前准备等逻辑。VuReact 会将它编译为 useBeforeUpdate,并支持依赖数组以控制触发时机。

  • Vue 代码:
<script setup>
  import { reactive, onBeforeUpdate } from 'vue';

  const state = reactive({ count: 0 });

  onBeforeUpdate(() => {
    console.log('更新前,当前 count:', state.count);
  });
</script>
  • VuReact 编译后 React 代码:
import { useReactive, useBeforeUpdate } from '@vureact/runtime-core';

const state = useReactive({ count: 0 });

useBeforeUpdate(
  () => {
    console.log('更新前,当前 count:', state.count);
  },
  [state.count],
);

从示例可以看到:Vue 的 onBeforeUpdate() 被翻译为 useBeforeUpdate。VuReact 提供的 useBeforeUpdateonBeforeUpdate 的适配 API完全模拟 Vue onBeforeUpdate 的更新前触发时机。当 React 对应 API 需要依赖数组时,deps 数组可用于只在指定值变化时触发,VuReact 会在编译阶段自动分析依赖并映射到对应依赖数组,避免开发者手动管理依赖

Vue onUpdated() → React useUpdated()

onUpdated 是 Vue 3 中用于组件更新后执行逻辑的钩子,适合放读取最新渲染结果、执行后续同步等操作。VuReact 会将它编译为 useUpdated,并支持可选依赖数组来精确控制触发条件。

  • Vue 代码:
<script setup>
  import { reactive, onUpdated } from 'vue';

  const state = reactive({ count: 0 });

  onUpdated(() => {
    console.log('组件更新后,count:', state.count);
  });
</script>
  • VuReact 编译后 React 代码:
import { useReactive, useUpdated } from '@vureact/runtime-core';

const state = useReactive({ count: 0 });

useUpdated(
  () => {
    console.log('组件更新后,count:', state.count);
  },
  [state.count],
);

VuReact 提供的 useUpdatedonUpdated 的适配 API完全模拟 Vue onUpdated 的更新后执行时机。如果 React API 使用 deps 数组,VuReact 会自动分析依赖并生成对应的数组,无需开发者手动维护依赖

Vue onBeforeUnmount() → React useBeforeUnMount()

onBeforeUnmount 是 Vue 3 中用于组件卸载前执行的钩子,适合放动画停止、资源解绑、日志上报等清理前逻辑。VuReact 会将它编译为 useBeforeUnMount,在卸载前执行。

  • Vue 代码:
<script setup>
  import { onBeforeUnmount } from 'vue';

  onBeforeUnmount(() => {
    console.log('组件即将卸载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useBeforeUnMount } from '@vureact/runtime-core';

useBeforeUnMount(() => {
  console.log('组件即将卸载');
});

VuReact 提供的 useBeforeUnMountonBeforeUnmount 的适配 API完全模拟 Vue onBeforeUnmount 的卸载前时机

Vue onUnmounted() → React useUnmounted()

onUnmounted 是 Vue 3 中用于组件卸载时执行逻辑的钩子,适合放最终资源释放、异步取消、上报日志等收尾逻辑。VuReact 会将它编译为 useUnmounted,在组件卸载时执行。

  • Vue 代码:
<script setup>
  import { onUnmounted } from 'vue';

  onUnmounted(() => {
    console.log('组件已卸载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useUnmounted } from '@vureact/runtime-core';

useUnmounted(() => {
  console.log('组件已卸载');
});

VuReact 提供的 useUnmountedonUnmounted 的适配 API完全模拟 Vue onUnmounted 的卸载时机

🔗 相关资源

✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

当 Vue 3 遇上桥接模式:手把手教你优雅剥离虚拟滚动的业务大泥球

作者 guojb824
2026年4月14日 22:20

当 Vue 3 遇上桥接模式:手把手教你优雅剥离虚拟滚动的业务大泥球

摘要:本文结合 Vue3 $attrs 特性与桥接模式,详细解析如何优雅解耦虚拟滚动容器与复杂业务组件。通过拆分抽象与实现,实现属性事件无缝透传,告别臃肿代码。

在企业级前端开发中,长列表渲染是一个永远绕不开的性能瓶颈。在 Vue 技术栈中,我们经常会使用 vue3-virtual-scroll-list 这样的第三方库来实现虚拟滚动,从而保证页面在面对万条甚至十万条数据时依然如丝般顺滑。

但是,随着业务复杂度的提升,一个棘手的设计问题往往会浮出水面:如何在使用第三方虚拟滚动库时,优雅地实现基础组件与业务组件的解耦与隔离?

今天,我们就来详细拆解这个场景,并探讨在 Vue 3 下利用 $attrs 透传机制实现完美隔离的设计思路。

一、 场景痛点与需求分析

想象一下这样一个典型的开发场景:

你正在负责一个大型后台管理系统。项目中有多处需要用到虚拟滚动列表:有的是简单的文本日志列表,有的是复杂的商品卡片列表,还有的是带有各种交互按钮(点赞、删除、编辑)的用户评论列表。

为了复用代码,你决定封装一个基础虚拟滚动组件(VirtualScrollerBasic),它负责引入第三方库,设定预估高度。同时,你还需要一个基础列表项组件(ItemBasic),它负责最基本的数据渲染和样式布局。

但是,业务部门的需求是千变万化的:

  • 场景 A 的商品列表需要传入一个特殊的业务参数 customText 来显示促销信息。
  • 场景 B 的评论列表需要在点击时触发一个专属的业务事件 @customEvent
  • 场景 C 的日志列表需要在每一项的底部插入一段自定义的 DOM 结构(使用插槽)。

如果直接在基础组件里把这些业务参数和事件全部写死,基础组件就会变得无比臃肿,甚至最终沦为一个不可维护的“大泥球”。

我们的核心诉求是:基础列表和基础 Item 只关心自己该关心的事情(比如基础的布局、基础的数据 source),而业务列表和业务 Item 可以自由地增加属性、监听事件、甚至传递插槽,且这一切对基础组件来说必须是“无感”的。

二、 方案设计思路:桥接模式与职责分离

为了解决上述痛点,我们需要引入**桥接模式(Bridge Pattern)**的思想。

桥接模式的核心是“将抽象部分与实现部分分离,使它们都可以独立地变化”。在虚拟滚动的场景中:

  • 抽象部分(Abstraction):是列表的容器(如 VirtualScrollerBasic),负责虚拟滚动的核心机制、数据调度和预估高度计算。
  • 实现部分(Implementor):是具体的列表项渲染器接口,负责单条数据的 UI 展示和交互。

这两部分通过一个“桥梁”(即动态传入的 listComponent 属性)连接起来。在此基础上,业务组件只需要处理自己的业务逻辑,剩下的不属于自己范围的基础属性和事件,通过 Vue 3 的 $attrs(在组合式 API 中通过 useAttrs() 获取)完美透传给基础组件。

Vue 3 的 $attrs 有一个非常棒的特性:它不仅包含了外部传入的非 Props 属性,还包含了绑定的事件(自动转化为 onXxx 形式)。这为我们实现属性和事件的跨层透传提供了天然的便利。

设计架构图

classDiagram
    class VirtualScrollerBasic {
        +items: Array
        +listComponent: Component
        +basicText: String
        +render()
    }

    class VirtualScrollerList {
        +customText: String
        +handleCustomEvent()
        +render()
    }

    class ItemBasic {
        +index: Number
        +basicText: String
        +render()
    }

    class Item {
        +customText: String
        +handleEvent()
        +render()
    }

    VirtualScrollerBasic o-- ItemBasic : Bridge (通过 listComponent 桥接)
    VirtualScrollerBasic <|-- VirtualScrollerList : 扩展 (组合包裹)
    ItemBasic <|-- Item : 扩展 (组合包裹)

三、 Vue 3 下的代码实现

让我们来看看这套设计模式在 Vue 3 中是如何落地的。

1. 基础列表组件 (VirtualScrollerBasic.vue)

基础列表组件的职责是封装第三方库 vue3-virtual-scroll-list,并且负责将外部传入的 $attrs 整合后向下透传。

<template>
  <div class="virtual-scroller-container">
    <virtual-list
      class="virtual-list"
      :data-key="'id'"
      :data-sources="items"
      :data-component="listComponent"
      :estimate-size="50"
      :extra-props="{
        // 关键点1:合并透传所有外部传入的业务属性和业务事件(onXxx)
        ...attrs,
        // 关键点2:基础层私有的参数和事件,互不干扰
        basicText: '这是基础层参数',
        onBasicEvent: handleBasicEvent,
      }"
    >
      <template #header>
        <slot name="header"></slot>
      </template>
    </virtual-list>
  </div>
</template>

<script setup>
import { ref, useAttrs } from "vue";
import VirtualList from "vue3-virtual-scroll-list";
import ItemBasic from "./ItemBasic.vue";

const attrs = useAttrs();

const props = defineProps({
  listComponent: {
    type: Object,
    default: () => ItemBasic,
  },
});

// 关键点3:阻止属性直接绑定到根节点 div 上,防止 DOM 污染和事件重复触发
defineOptions({
  inheritAttrs: false,
});

const items = ref([
  /* 模拟数据 */
]);
const handleBasicEvent = (source) => {
  console.log("基础事件触发");
};
</script>

2. 基础 Item 组件 (ItemBasic.vue)

基础 Item 组件负责渲染列表项的最基本信息。它只关心基础的 UI 和数据结构,不知道任何关于业务层的特殊参数。

<template>
  <div class="basic-item">
    <div class="basic-content">
      <span>#{{ index }} - ID: {{ source.id }}</span>
      <span v-if="basicText" class="basic-text">({{ basicText }})</span>
      <button @click="handleClick">触发基础事件</button>
    </div>
    <!-- 留出插槽供业务层扩展 -->
    <slot name="footer"></slot>
  </div>
</template>

<script setup>
const props = defineProps({
  source: {
    type: Object,
    required: true,
  },
  index: {
    type: Number,
    default: 0,
  },
  basicText: {
    type: String,
    default: "",
  },
});

const emit = defineEmits(["basicEvent"]);

const handleClick = () => {
  emit("basicEvent", props.source);
};
</script>

<style scoped>
.basic-item {
  padding: 10px;
  border-bottom: 1px solid #eee;
}
.basic-text {
  color: #888;
  margin-left: 10px;
}
</style>

3. 业务 Item 组件 (Item.vue)

业务 Item 组件的职责是拦截并消费属于业务层的属性(customText)和事件(customEvent),并将剩下的属性通过 v-bind 透传给基础 Item 组件。

<template>
  <div class="custom-item">
    <!-- 关键点1:v-bind="attrs" 将没被当前组件消费的属性和事件透传给基础组件 -->
    <item-basic v-bind="attrs" :source="source">
      <template #footer>
        <div class="custom-footer">
          自定义footer <span v-if="customText"> - {{ customText }}</span>
          <el-button type="primary" @click="handleEvent(source)">
            触发业务事件 customEvent
          </el-button>
        </div>
      </template>
    </item-basic>
  </div>
</template>

<script setup>
import { useAttrs } from "vue";
import ItemBasic from "./ItemBasic.vue";

const attrs = useAttrs();

// 关键点2:只声明业务层自己需要消费的属性。
// 如果业务层确实需要用到基础层的参数,就需要手动传给基础组件
const props = defineProps({
  source: { type: Object, required: true }, // 点击事件需使用,所以保留
  customText: { type: String, default: "" }, // 业务专属属性
});

const emit = defineEmits(["customEvent"]);

// 关键点3:同样需要阻止属性绑定到根节点
defineOptions({
  inheritAttrs: false,
});

const handleEvent = (source) => {
  // 触发业务层专属事件
  emit("customEvent", source);
};
</script>

4. 业务列表组件 (VirtualScrollerList.vue)

在最外层的业务列表中,我们就可以像使用普通组件一样,随心所欲地传递业务参数和监听业务事件了,底层的一切复杂透传对它来说都是透明的。

<template>
  <div class="virtual-scroller-list-wrapper">
    <virtual-scroller-basic
      :list-component="Item"
      :customText="'这是通过透传传入的业务参数 customText'"
      @customEvent="handleCustomEvent"
    >
      <template #header>
        <div class="custom-header">自定义业务 Header 内容</div>
      </template>
    </virtual-scroller-basic>
  </div>
</template>

<script setup>
import VirtualScrollerBasic from "./VirtualScrollerBasic.vue";
import Item from "./Item.vue";

const handleCustomEvent = (source) => {
  alert(`业务层成功拦截 customEvent,Item ID: ${source.id}`);
};
</script>

四、 Vue 3 与 Vue 2 的实现区别解析

如果你还在使用 Vue 2,或者刚从 Vue 2 迁移过来,可能会对上面的实现感到一些疑惑。这里有必要重点强调一下 Vue 3 和 Vue 2 在透传机制上的巨大差异。

Vue 2:属性与事件是分离的

在 Vue 2 中,组件的“属性”和“事件”是严格区分开的:

  • 传递的数据和非 Props 属性会被收集到 $attrs 中。
  • 通过 @v-on 绑定的事件会被收集到 $listeners 中。

所以在 Vue 2 中,如果你想把业务组件绑定的 @customEvent 透传给底层的 vue-virtual-scroll-listextra-props,你必须手动去遍历 $listeners,把它们转换成 onXxx 格式的函数,然后再和 $attrs 合并:

// Vue 2 下的 Hack 写法
computed: {
  mergedExtraProps() {
    const listenersAsProps = {};
    for (const eventName in this.$listeners) {
      const propName = 'on' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
      listenersAsProps[propName] = this.$listeners[eventName];
    }
    return { ...this.$attrs, ...listenersAsProps };
  }
}

不仅如此,在 Vue 2 中,由于底层组件接收到的 extra-props 只能以 Props 的形式被子组件接收,为了让子组件能像普通组件一样响应 @事件,我们往往还需要引入一个中间包装组件(Wrapper),利用函数式组件将 onXxx 的 Props 重新还原为真正的 $listeners 并绑定到实际渲染的组件上。

例如,我们需要定义一个 VirtualScrollerItemWrapper.vue

<script>
// Vue 2 函数式组件 Wrapper
export default {
  name: "VirtualScrollerItemWrapper",
  functional: true,
  render(h, context) {
    const { props, data } = context;
    const originalComponent = props.originalComponent; // 真实的业务组件
    const attrs = {};
    const on = {};

    // 遍历 props,将 onXxx 还原为事件监听器
    for (const key in props) {
      if (key === "originalComponent") continue;

      if (key.startsWith("on") && typeof props[key] === "function") {
        const eventName = key.charAt(2).toLowerCase() + key.slice(3);
        on[eventName] = props[key];
      } else {
        attrs[key] = props[key];
      }
    }

    return h(originalComponent, {
      attrs,
      on, // 重新绑定事件
      scopedSlots: data.scopedSlots,
    });
  },
};
</script>

然后在基础滚动组件中,我们不能直接渲染业务组件,而是必须把这个 Wrapper 传给 vue-virtual-scroll-listdata-component 属性,并将实际的业务组件通过 extra-props 传进去:

<template>
  <virtual-list
    :data-key="'id'"
    :data-sources="items"
    :data-component="VirtualScrollerItemWrapper" <!-- 使用包装组件 -->
    :estimate-size="50"
    :extra-props="{
      ...mergedExtraProps,
      originalComponent: listComponent // 将真实的渲染组件传给包装器
    }"
  />
</template>

可以看到,在 Vue 2 中为了实现这一套隔离与透传机制,代码非常冗长且绕脑。

Vue 3:大一统的 $attrs

Vue 3 进行了一次非常优雅的底层重构。它移除了 $listeners 对象,将所有通过 @event 绑定的事件,在编译时自动转换成了以 onXxx 开头的属性名(例如 @custom-event 变成了 onCustomEvent),并且统一收集到了 $attrs

正因为 Vue 3 的这个特性,我们在 VirtualScrollerBasic 中只需要写一句 ...attrs,就同时完成了属性和事件的透传!这与 vue3-virtual-scroll-list 要求的 extra-props 接收对象的 API 设计简直是天作之合。

五、 运行效果与总结

当代码运行起来后,你会看到:

  1. 列表顶部正确渲染了“自定义业务 Header 内容”。
  2. 每一项都正确渲染了基础数据(如 #0)和基础参数(如 基础参数)。
  3. 每一项的 Footer 都正确渲染了业务参数 这是通过透传传入的业务参数 customText

image.png

  1. 点击“触发业务事件 customEvent”按钮,外层的业务列表组件成功弹出了 Alert 提示框,拦截到了事件。

image-1.png

image-2.png

总结:用到的设计模式

通过这次重构,我们实际上是在前端工程中落地了以下几种经典的设计模式:

  1. 桥接模式 (Bridge Pattern):这是本文的核心架构。将“虚拟滚动容器(抽象层)”与“列表项渲染(实现层)”彻底解耦。通过 listComponent 这一桥梁连接,使得业务列表可以随意更换基础滚动机制,业务项也可以在不同的列表中复用,两者独立变化。
  2. 装饰器模式 (Decorator Pattern) / 高阶组件模式 (HOC):业务 Item 没有去修改基础 ItemBasic 的内部代码,而是通过包裹对其进行了增强(增加了业务插槽和事件),并通过 $attrs 将基础属性完美透传。
  3. 模板方法模式 (Template Method Pattern)VirtualScrollerBasic 定义了虚拟滚动的算法骨架(如何引入库、设定高度等),具体的 UI 表现通过插槽和动态组件延迟到了业务层去实现。

优雅的架构设计,往往不需要多么高深的语法,而是对框架特性(如 $attrs)的深刻理解,以及对“单一职责”原则的坚守。希望这篇文章能为你在处理复杂 Vue 组件封装时带来一些启发!

❌
❌