Vuejs设计与实现学习记录

57 阅读38分钟

前三章组织架构图: 1.png

第1章:权衡的艺术

书中剖析的三个关键权衡,每一个都重塑了我对 Vue 的理解:

  1. 命令式与声明式:此前我只知道 JQuery 是 “写步骤”、Vue 是 “写结果”,却从没想过二者并非对立 —— 原来 Vue 选择了 “声明式为上层,命令式为底层” 的架构。我一直以为 Vue 的 “简洁” 是天生的,却不知它背后是用命令式的底层逻辑支撑,既让我们开发者摆脱了繁琐的过程控制(兼顾可维护性),又悄悄守住了性能底线,这种 “藏巧于拙” 的设计让我十分震撼。
  2. 性能与可维护性:之前只模糊听说 “虚拟 DOM 能优化性能”,却不懂其核心逻辑。这一章让我明白,虚拟 DOM 的出现从不是为了追求 “极致性能”,而是为了在 “手动优化的极致性能” 和 “开发的可维护性” 之间找平衡。它通过对比新旧虚拟 DOM 的差异(Diff 算法)最小化真实 DOM 操作,虽然比不上手动写命令式代码的极致优化,却让我们在不用操心 DOM 操作的同时,获得了足够优秀的性能 —— 原来 Vue 的 “好⽤”,是用这种 “取舍智慧” 换回来的。
  3. 运行时与编译时:这两个概念我此前几乎毫无认知,默认框架要么 “灵活” 要么 “高效”,从未想过能二者兼顾。而 Vue 选择的 “运行时 + 编译时” 架构彻底打破了我的固有认知:编译器悄悄将我们写的模板编译为渲染函数,运行时再执行渲染函数、处理虚拟 DOM。原来我写的模板不是 “直接生效”,而是经过了这样一层 “翻译优化”,既保留了动态修改模板的灵活性,又拿到了编译优化带来的性能提升,这种兼顾的思路让我对框架架构有了全新的理解。

这一章让我深刻意识到,Vue 的 “易用” 从来不是偶然,而是无数次权衡后的最优解。它不仅解答了我 “Vue 为什么要这么设计” 的困惑,更让我以一种 “敬畏之心” 重新看待前端框架 —— 原来那些我们习以为常的便捷,背后都是设计者对 “取舍” 的深刻洞察,也让我对后续的底层原理学习充满了更强的探索欲。

第2章:框架设计的核心要素

这一章从开发者使用框架的视角,拆解了一个优秀框架必须考虑的细节 参考: 作为沉浸在 Vue 表层使用的初学者,带着对底层逻辑的敬畏继续研读《Vue.js 设计与实现》,第 2 章 “框架设计的核心要素” 再次颠覆了我的认知 —— 原来我一直觉得 “理所当然” 的框架体验,背后全是设计者对细节的极致打磨,那些我从未深究的 “便捷”,竟是优秀框架不可或缺的核心考量,彻底打破了我 “框架好用只是运气” 的浅薄认知。 这一章从开发者使用视角出发,拆解的每一个核心要素,都让我对 “优秀框架” 的定义有了全新理解:

  • 开发体验:以前写 Vue 时,遇到错误总会收到清晰的警告提示,我只当是 “框架该有的样子”,从未深思背后的设计逻辑。这一章让我恍然大悟,友好的警告信息从来不是 “附加功能”,而是框架专业性的直接体现 —— 它需要精准捕获开发者的常见错误场景,用易懂的语言指明问题所在,甚至给出解决方案。原来我每次避开的 “坑”,都是框架在背后悄悄 “保驾护航”,这种 “替用户着想” 的设计细节,让我对 Vue 的好感度再上一个台阶。
  • 控制框架体积:此前我只模糊知道 “Tree-Shaking 能减体积”,却不知道它和 Vue 的底层设计深度绑定 ——Vue 依赖 ESM 模块语法,让打包工具能精准识别并移除未使用的代码;更让我意外的是 “特性开关” 的存在,原来打包时可以关闭用不到的特性,进一步精简生产环境代码。我一直以为框架体积是 “固定的”,从未想过框架会主动提供 “瘦身方案”,这种 “不强迫用户为无用功能买单” 的设计,让我明白 “轻量” 从来不是偶然,而是主动设计的结果。
  • 输出多种构建产物:在接触这一章前,我只会根据教程复制粘贴引入 Vue 的代码,从未思考过 “为什么有不同的引入方式”。现在才知道,Vue 会专门提供适配不同环境的构建包 —— 浏览器直接引入的 UMD 格式、ESM 模块化格式、适配 Node.js 的版本等。原来我能在不同项目(网页、Node 服务)中顺畅使用 Vue,是框架提前做好了所有兼容工作,这种 “全方位适配” 的考量,让我意识到优秀框架的 “通用性” 背后,藏着多少不为人知的付出。
  • 错误处理:以前开发时遇到报错,只会自己排查调试,从未想过框架能在错误处理上提供支持。这一章让我了解到,Vue 会提供统一的错误处理接口,方便开发者收集和上报错误。我才明白,框架不仅要 “避免错误”,还要 “帮助用户处理错误”,这种 “从开发到上线全流程覆盖” 的设计,让我对 “框架的责任” 有了更深刻的认知 —— 它不只是开发工具,更是上线后的 “后盾”。
  • TypeScript 类型支持:作为经常依赖类型提示写代码的开发者,我一直觉得 Vue 的 TypeScript 支持 “很方便”,却没意识到这背后的深层意义。良好的类型声明不仅能在开发时减少错误、提供自动补全,更能侧面反映框架内部代码的质量和设计清晰度 —— 只有框架自身结构严谨、逻辑清晰,才能写出精准的类型定义。原来我享受的 “丝滑类型提示”,竟是框架内部高质量设计的 “外在体现”,这种 “内外兼修” 的设计理念,让我对 Vue 的敬佩又多了一分。

这一章让我深刻体会到,优秀框架的 “易用” 从来不是凭空而来,而是由无数个 “站在开发者角度” 的细节堆砌而成。它让我从 “被动使用” 转变为 “主动思考”—— 原来那些被我忽略的细节,正是 Vue 之所以成为优秀框架的关键。也让我更加期待后续章节,想继续探索更多 Vue 设计背后的 “用心之处”。

第3章:Vue.js 3的设计思路

这一章揭开了 Vue 核心工作原理的面纱,让我明白那些我日常熟练使用的模板、组件、虚拟 DOM,并非孤立存在,而是一套环环相扣的有机体系。原来我一直 “知其然” 的 Vue 功能,背后藏着如此清晰的落地逻辑,每一个设计都在呼应 “声明式、高性能、可扩展” 的核心理念,彻底颠覆了我对 “Vue 如何工作” 的零散认知。

这一章将 Vue 的设计理念拆解为四个核心模块,每一个都让我对 “Vue 的实现逻辑” 有了全新的理解:

1. 声明式 UI:两种描述方式,一个底层核心

此前我以为 Vue 的模板和虚拟 DOM 是 “两条平行线”—— 模板是给新手用的直观写法,虚拟 DOM 是进阶开发者用的灵活方案,从未想过它们的最终归宿竟完全一致。这一章让我恍然大悟:无论是模板(HTML 标签 + 属性 + 事件)还是 JavaScript 对象(虚拟 DOM),本质上都是声明式描述 UI 的方式,最终都会转化为虚拟 DOM

原来我写的<div @click="handleClick">{ { message } }</div>模板,和用 h 函数写的h('div', { onClick: handleClick }, message),最终都会变成结构一致的虚拟 DOM 对象。h 函数的作用并非 “高级用法”,而是生成虚拟 DOM 的 “桥梁”;而 render 函数则是递归渲染这些虚拟 DOM(无论是模板转化来的,还是手动写的)的核心载体。这种设计,既满足了我这样的开发者对 “直观” 的需求,又兼顾了灵活扩展的场景,原来 Vue 的 “包容性”,从设计源头就已经注定。

2. 渲染器:虚拟 DOM 到真实 DOM 的 “翻译官”

它的工作逻辑简单:拿到虚拟 DOM 对象后,先取出tag(标签名)、data(属性 / 事件)、children(子节点),然后用document.createElement创建真实节点,给节点绑定事件、设置属性,再递归处理子虚拟 DOM,最后把真实节点挂载到容器中。原来那些看似 “神秘” 的 DOM 生成过程,本质上都是最基础的 DOM 操作 —— 虚拟 DOM 并没有替代原生 API,而是通过 “抽象描述” 让渲染逻辑更统一、更易优化。我一直以为的 “虚拟 DOM 优化”,从根源上是 “统一描述” 带来的可操作空间,而非凭空创造的高性能魔法。

3. 组件本质:返回虚拟 DOM 的 “特殊函数 / 对象”

对于组件,我此前的认知停留在 “可复用的代码块”—— 把 HTML、CSS、JS 封装起来,用的时候引入即可,从未想过它的底层实现竟如此简洁。这一章让我彻底明白:组件的本质,是一个能返回虚拟 DOM 的函数(或对象) ,这一下就统一了组件和普通元素的处理流程。当渲染器遍历虚拟 DOM 时,会先判断tag的类型:如果是'div'这类普通标签,就调用mountElement创建真实元素;如果是函数或对象(即组件),就调用mountComponent—— 要么执行函数组件拿到虚拟 DOM,要么调用对象组件的render方法获取虚拟 DOM,再继续递归渲染。更让我意外的是,有状态组件用对象结构表达,无状态组件用函数表达,这种设计既区分了场景,又没有打破 “组件→虚拟 DOM” 的核心逻辑。原来我写的export default { render() { return h('div', '组件内容') } },本质上就是给渲染器提供了一个 “虚拟 DOM 生成器”,组件的 “复用性”,正是源于这种统一的渲染逻辑。

4. 编译器与渲染器协作:高性能的 “双剑合璧”

这是最让我震撼的一点!此前我一直以为编译器的作用只是 “把模板转成 render 函数”,最多做些语法校验,从未想过它竟是 Vue 高性能的 “幕后功臣”。这一章让我明白:Vue 的高性能,核心是编译器与渲染器的协同优化—— 编译器不只是 “转译”,还会 “标记”;渲染器不只是 “渲染”,还会 “靶向更新”。 编译器在将模板编译为生成虚拟 DOM 的函数时,会主动分析模板中的静态内容(如<span>固定文本</span>)和动态内容(如<span>{ { message } }</span>),给虚拟 DOM 打上 “补丁标志(PatchFlag)”,明确标记出哪些部分是可能变化的。而渲染器在更新时,会直接跳过带有 “静态标记” 的虚拟 DOM 节点,只聚焦于带有 “动态标记” 的部分进行 Diff 和更新。这种 “编译器标记动态内容 + 渲染器精准更新” 的配合,让 Vue 避免了对整个 DOM 树的无差别遍历,实现了 “靶向更新”—— 原来我一直感受到的 “Vue 更新快”,不是单一模块的功劳,而是编译器和渲染器 “各司其职、互相配合” 的结果,彻底颠覆了我 “编译只是转译” 的浅层认知。

总结

这一章最核心的收获,是让我看清了 Vue 的 “有机整体” 属性:模板 / JS 对象是 UI 的 “描述层”,虚拟 DOM 是 “中间桥梁”,渲染器是 “落地执行层”,编译器是 “优化辅助层”。它们不是零散的功能模块,而是围绕 “声明式 UI” 这一核心,形成的闭环体系 —— 编译器为渲染器 “铺路”(标记动态内容),渲染器为声明式 UI “落地”(虚拟 DOM 转真实 DOM),组件则通过 “返回虚拟 DOM” 融入整个流程。

第4章:响应系统的作用与实现

第 4 章响应系统的学习 —— 这部分内容彻底击碎了我对 “Vue 响应式” 的浅层认知,原来我日常开发中随手写的obj.foo = 1就能触发视图更新,背后竟藏着一套如此精密、层层递进的逻辑体系;那些我曾以为 “理所当然” 的响应式效果,都是无数细节打磨和问题解决后的结果,每一个知识点都在重塑我对 Vue 的理解,让我真切体会到:原来我一直用的 Vue 响应式,竟是这样一步步被设计出来的。 这一章从最基础的概念入手,层层拆解响应系统的实现逻辑,每一步都让我对 “响应式” 的认知完成一次升级:

从 “知其然” 到 “知其所以然”:响应式数据与副作用函数

此前我只知道 “响应式数据变了视图会更”,却从没想过 “为什么变了就会更”。这一章开篇就点破核心:响应系统的根基是副作用函数—— 原来 “副作用” 不是负面词汇,而是指会影响外部的函数(比如修改 DOM、改变全局变量);而响应式数据的本质,是当数据变化时,自动重新执行依赖它的副作用函数。 当看到这段最基础的 Proxy 实现代码时,我第一次恍然大悟:

const obj = new Proxy(data, {
  get(target, key) { bucket.add(effect); return target[key] },
  set(target, key, newVal) { target[key] = newVal; bucket.forEach(fn => fn()) }
})

原来我们写的obj.text读取操作,会被 Proxy 拦截并把副作用函数 “存进桶里”;obj.text = '新值'的赋值操作,又会拦截并把桶里的函数取出来执行 —— 这就是 “拦截 - 收集 - 触发” 的核心逻辑!我一直以为的 “自动响应”,本质就是这两步简单的拦截操作,只是 Vue 把它做得更完善了。

从 “简陋实现” 到 “精准关联”:设计完善的响应系统

最初的 Set “桶” 让我觉得响应系统不过如此,但紧接着的问题就给了我一记 “暴击”:硬编码副作用函数名effect太不灵活,匿名函数根本无法收集;更关键的是,设置对象不存在的属性(如obj.notExist = 1)也会触发副作用函数执行 —— 原来我以为的 “收集依赖”,竟连 “哪个对象、哪个属性、哪个函数” 的对应关系都没理清。直到看到 WeakMap+Map+Set 的桶结构设计,我才有了深刻的理解:

  • WeakMap(target → Map):用弱引用存储原始对象,避免内存泄漏;
  • Map(key → Set):精准关联对象的某个属性与副作用函数;
  • Set:存储该属性对应的所有副作用函数。

原来响应系统不是 “无脑存函数”,而是要建立target → key → effectFn的精准关联!这让我明白,框架设计里哪怕是数据结构的选择,都藏着对 “性能”“内存” 的深度考量 —— 比如 WeakMap 的弱引用特性,就是为了让无用的 target 被垃圾回收,而不是一直占用内存,这是我此前从未考虑过的细节。

从 “忽略细节” 到 “直面边界”:分支切换、嵌套 effect 与无限递归

如果说基础实现让我入门,那各类边界问题的解决,则让我看到响应系统的 “严谨性”:

  • 分支切换与 cleanup:我从没想过三元表达式obj.ok ? obj.text : 'not'会有 “遗留副作用”—— 当obj.ok变为 false 后,修改obj.text本不该触发更新,却因为依赖没清除仍会执行。原来每次执行副作用函数前,需要先把它从所有关联的依赖集合中删除(cleanup),再重新收集依赖。更让我意外的是,清除依赖时还会触发 Set 遍历的无限循环问题,解决办法竟是 “复制一个新 Set 遍历”—— 原来看似简单的 forEach,都藏着容易踩的坑;
  • 嵌套的 effect 与 effect 栈:日常开发中组件嵌套是常态,我却从没想过这对应着 effect 的嵌套!原来全局变量activeEffect会被内层 effect 覆盖,导致外层 effect 的依赖收集错乱,而 Vue 用一个effect 栈来维护当前激活的副作用函数 —— 内层 effect 执行时压栈,执行完弹栈,让activeEffect始终指向栈顶,这才保证了 “父组件数据只触发父 effect,子组件数据只触发子 effect”;
  • 避免无限递归obj.foo++这个简单的自增操作,竟会导致栈溢出!原来自增同时包含 “读取(track)” 和 “设置(trigger)”,会让副作用函数在执行中又触发自身执行。而解决办法只是在 trigger 时加一个判断:如果要触发的函数和当前执行的函数是同一个,就跳过 —— 如此简单的守卫条件,却能解决致命的栈溢出问题,让我明白 “细节决定成败”。

从 “被动使用” 到 “主动掌控”:调度执行与计算属性

这部分内容让我从 “用 Vue” 的层面,跃升到 “懂 Vue” 的层面:

  • 调度执行:原来 “可调度性” 是响应系统的核心特性!我一直好奇 “为什么 Vue 多次修改数据只触发一次更新”“为什么 $nextTick 能拿到最新 DOM”,答案都在调度器里 —— 通过 Set 做任务队列去重、微任务队列执行副作用函数,就能实现 “多次修改只执行一次更新”;甚至可以通过调度器控制副作用函数的执行时机(比如放到宏任务),这解释了为什么 Vue 能灵活控制更新时机,而不是数据一变就立刻执行;

  • 计算属性 computed:我曾以为 computed 只是 “缓存结果的函数”,却不知道它本质是懒执行的 effect + 缓存机制 + dirty 标志:

    • lazy: true 让 computed 不会立即执行,只有读取value时才计算;
    • dirty 标志控制是否重新计算,避免多次访问重复计算;
    • 调度器在依赖变化时重置 dirty,保证数据更新后能重新计算;
    • 手动调用 track/trigger,让 computed 能被其他 effect 依赖(比如模板中的 computed 值变化触发组件重新渲染)。

当看到完整的 computed 实现代码时,我彻底明白:原来我日常用的const fullName = computed(() => obj.first + obj.last),背后是 lazy effect、缓存、依赖追踪的三重配合,每一行简洁的代码,都是底层逻辑层层封装的结果。

这一章的学习,让我彻底摆脱了 “只懂用 API” 的浅层认知 —— 原来 Vue 的响应系统不是 “天生如此”,而是从最简陋的 Proxy 拦截开始,一步步解决硬编码、依赖关联、边界问题、性能优化,最终形成的完善体系。它让我明白,框架的 “易用性”,是靠底层无数个 “解决问题的细节” 堆砌而成的;也让我带着更敬畏的心态看待每一行代码,因为那些看似简单的功能,背后都是设计者对 “场景”“性能”“边界” 的深度思考。

第五章:非原始值的响应式方案

这一章给我的冲击,其实比前面所有内容加起来都要大。 以前我只知道 Vue 能 “自动响应数据变化”,但在心里,它更像是一个黑盒:我写 data、写模板、写 effect,Vue 就帮我完成了剩下的 “魔法”。我对它的理解停留在 “用起来很方便”,至于它到底是怎么做到的,我既没有深入想过,也觉得那是 “框架内部的事情”,离我很远。 但当我真正坐下来,带着一点敬畏和好奇,去读这一章关于 “非原始值响应式方案” 的内容时,那种感觉就像是第一次真正走进了 Vue 的 “后台机房”。

原来,我一直以为很简单的 “响应式”,并不是我想象中那样,只是在 get/set 里做点手脚就完事了。

  • 我从来没想过,in 操作符、for...in 循环、delete,这些看似和 “响应式” 无关的语法,底层竟然都对应着对象的内部方法,而 Vue 为了让它们也能响应,需要一个个去拦截、去设计追踪逻辑。
  • 我也不知道,Proxy 只能代理 “基本语义”,像 obj.fn() 这样的方法调用,其实是由多个基本操作组合而成的,Vue 要在这些细碎的缝隙里,把依赖收集和触发更新精准地插进去。
  • 更让我震撼的是 Reflect 的作用。以前我只把它当成一个 “可有可无的工具对象”,但这一章让我明白,它不仅是为了保持默认行为,更是为了修复 this 指向,让访问器属性、原型链这些复杂场景也能正确响应。
  • 原来 reactive 不是简单地 “把对象变成响应式”,而是一个递归的、可配置深浅的代理工厂;
  • 原来 readonly 也不是 “加个锁” 那么简单,而是要在所有可能修改数据的拦截点上,温柔但坚定地 “说不”;
  • 原来 Vue 在设计这些 API 时,要同时考虑语言规范、性能、边界情况,还要为不同的使用场景(深响应 / 浅响应 / 只读)提供统一而灵活的底层架构。 读完这一章,我对 “Vue 是怎么实现的” 这个问题,有了一种全新的敬畏感。

以前我觉得自己已经 “会用 Vue 了”,但现在回头看,我只是站在它搭建好的舞台上,按部就班地写代码。真正厉害的,是舞台下面那一套我从未看见过的机制

我之前对 Vue 的理解,只是停留在 “它能做什么”,而这一章开始,我第一次真正在思考 “它为什么能这样做”。

这种认知上的冲击,比学会几个新 API 要深刻得多。它让我明白,要真正理解一个框架,不能只停留在表面用法,而要愿意沉下心,去看它背后那些不那么 “光鲜” 的底层逻辑。 现在再写 const state = reactive({ ... }) 时,我心里不再只是一句 “这是响应式数据”,而是会下意识地想到:

  • 它背后是一个 Proxy
  • 每一次属性访问,可能都在触发依赖收集;
  • 每一次赋值、删除、新增属性,都可能在精准地触发某个副作用函数。

这一章没有教我写更多业务代码,却在悄悄改变我看代码的方式。 它让我从一个 “只会用 Vue 的开发者”,慢慢变成一个 “开始理解 Vue 内部逻辑的学习者”。这种视角的转变,是我读这一章最大的收获。 最近啃《Vue.js 设计与实现》里响应式系统的部分,从普通对象到数组,感觉已经摸透了点门道,结果学到 Set/Map 的代理实现,直接被各种细节按在地上摩擦。原来 reactive 能完美处理集合类型,背后藏了这么多我根本想不到的坑和解决方案,越学越觉得 Vue 的设计太严谨了,对这些底层知识也多了份敬畏之心。今天就以初学者的视角,把学到的 Set/Map 代理实现的知识点和踩坑感悟捋一捋,也算是自己的学习复盘。

为啥 Set/Map 不能直接用 Proxy 代理?

之前写普通对象的响应式,直接 new Proxy 套一层,重写 get/set 就能实现 track 和 trigger 了,我本来以为 Set/Map 也能照葫芦画瓢,结果随手写了段测试代码,直接报红:访问 proxy.size 的时候,提示 “在不兼容的 receiver 上调用了 Set.prototype.size”。 查了才知道,Set/Map 的 size 不是普通属性,而是访问器属性,它的 getter 执行时会检查 this 上的内部槽 [[SetData]]/[[MapData]],而我们通过代理对象访问 size 时,this 指向的是代理对象,不是原始的 Set/Map,代理对象根本没有这些内置的内部槽,自然就报错了。 解决方法也很巧妙,在 Proxy 的 get 拦截里,判断如果访问的是 size,就用 Reflect.get (target, key, target),手动把 getter 执行的 this 指向原始对象,这样就能正常读取 size 了。

const s = new Set([1,2,3])
const p = new Proxy(s, {
  get(target, key, receiver) {
    if (key === 'size') {
      return Reflect.get(target, key, target) // 改this指向原始对象
    }
    return Reflect.get(target, key, receiver)
  }
})
console.log(p.size) // 3 终于不报错了

说实话一开始看到这个报错我直接懵了,根本不知道 “内部槽” 是个啥东西,只知道 Proxy 能拦截操作,没想到内置对象的属性还依赖 this 上的特殊标识,改个 this 指向就解决问题,这波操作真的让我大开眼界,原来 Proxy 的细节这么多。

集合的方法执行也会踩坑,bind 绑定 this 是关键

解决了 size 的问题, p.delete (1),结果又报错了,和 size 的错误类似,提示 delete 方法的 receiver 不兼容。这时候我才发现,属性的 getter 和方法的执行,this 处理方式完全不一样

size 是访问器属性,一访问就执行 getter,我们能在 get 拦截里直接改 this;但 delete 是方法,访问 p.delete 的时候只是拿到方法引用,真正执行 delete (1) 的时候,this 还是指向代理对象 p,所以还是会检测不到内部槽。这次的解决方法是把集合的方法和原始对象绑定,在 get 拦截里,返回 target [key].bind (target),这样不管怎么调用代理对象的方法,方法内部的 this 永远指向原始的 Set/Map,所有内置方法就能正常执行了。

const p = new Proxy(s, {
  get(target, key, receiver) {
    if (key === 'size') {
      return Reflect.get(target, key, target)
    }
    return target[key].bind(target) // 方法绑定原始对象this
  }
})
p.delete(1) // 正常执行

原来属性和方法的处理还有这么大的区别,我一开始想都没想直接复用普通对象的逻辑,结果连续踩两个坑,才明白 Set/Map 作为内置集合,和普通纯对象的代理逻辑完全不是一个量级的,初学者真的要多敲代码试错,光看理论根本发现不了这些问题。

响应式的核心还是 track&trigger,只是要结合集合的方法重写

绕开了 Proxy 的坑,接下来就是给 Set/Map 加响应式能力了,这部分的核心其实还是和普通对象一样:读操作 track 依赖,写操作 trigger 更新,只是 Set/Map 的 “读” 和 “写” 不是通过属性访问,而是通过自身的方法,比如 get/has 是读,add/delete/set 是写。

Vue 的做法是创建一个instrumentations 工具对象,把 Set/Map 的所有方法重写一遍,在 Proxy 的 get 拦截里,不再返回原始方法,而是返回这个工具对象里的自定义方法。这样我们就能在自定义方法里,精准的加 track 和 trigger 了。

比如重写 Set 的 add 方法,要先判断元素是否已经存在,不存在才执行 trigger(避免无效更新),而且要指定操作类型为 ADD,这样后续才能根据类型触发对应的依赖;delete 方法则是存在元素才触发 trigger,操作类型为 DELETE。

const mutableInstrumentations = {
  add(key) {
    const target = this.raw // raw是挂载在代理对象上的原始对象
    const hadKey = target.has(key)
    const res = target.add(key)
    if (!hadKey) { // 新增才触发更新
      trigger(target, key, 'ADD')
    }
    return res
  },
  delete(key) {
    const target = this.raw
    const hadKey = target.has(key)
    const res = target.delete(key)
    if (hadKey) { // 删除存在的元素才触发更新
      trigger(target, key, 'DELETE')
    }
    return res
  }
}

这里才回过神来,不管是什么数据类型,响应式的核心逻辑从来没变过,只是不同数据类型的 “读 / 写” 操作形式不同,普通对象是属性的get/set,数组是索引和push/pop等方法,而Set/Map是自身的内置方法。抓住track和trigger这个核心,再去适配不同数据类型,思路就清晰多了。

一个超容易忽略的点:避免污染原始数据

学到 Map 的 set 方法时,又遇到了一个我完全没考虑过的问题 ——数据污染。简单说,就是如果我们把一个响应式对象作为值,通过代理对象的 set 方法存到原始 Map 里,那原始 Map 就会被响应式对象污染,后续操作原始 Map 也能触发响应,这显然不符合预期,因为原始数据本不该有响应式能力。 比如这段代码,本来只想让 p1 是响应式的,结果操作原始对象 m 也能触发 effect,就是因为 p2 这个响应式对象被直接存到了 m 里:

const m = new Map()
const p1 = reactive(m)
const p2 = reactive(new Map())
p1.set('p2', p2) // 直接把响应式对象p2存到原始m里,造成污染

effect(() => {
  console.log(m.get('p2').size) // 操作原始对象
})
m.get('p2').set('foo', 1) // 居然能触发effect更新

解决方法也很简单,在重写 set 方法时,先判断要设置的值是不是响应式对象,如果是,就通过value.raw取出它的原始对象,再把原始对象存到原始 Map 里,这样原始数据里永远只存原始值,就不会被污染了。

mutableInstrumentations.set = function(key, value) {
  const target = this.raw
  const hadKey = target.has(key)
  const oldValue = target.get(key)
  const rawValue = value.raw || value // 取原始值,避免污染
  target.set(key, rawValue)
  
  if (!hadKey) {
    trigger(target, key, 'ADD')
  } else if (oldValue !== value) {
    trigger(target, key, 'SET')
  }
}

这个点真的让我对 Vue 的设计佩服得五体投地,我作为初学者,只想着实现功能就行,根本不会考虑到 “原始数据不能被污染” 这种边界情况,而 Vue 的源码却把这些细节都考虑到了。这也让我明白,写框架和写业务代码的区别,框架要考虑所有可能的使用场景,把各种边界情况都封死,让用户用得放心。

forEach 和迭代器的处理,细节拉满

Set/Map 的遍历操作也是个大头,比如 forEach、for...of,还有 entries/keys/values 这些迭代器方法,不仅要让遍历操作能 track 依赖,还要保证遍历出来的参数是响应式的,不然嵌套的集合就没响应了。

比如 forEach 方法,不仅要在执行时 track (ITERATE_KEY)(因为遍历和集合的元素数量相关),还要把传给回调的 value 和 key 包装成响应式对象,这样回调里访问嵌套集合的属性,才能触发依赖追踪。而且还要考虑到 forEach 的第二个参数,用来指定回调的 this 指向,细节真的太到位了。

mutableInstrumentations.forEach = function(callback, thisArg) {
  const wrap = (val) => typeof val === 'object' ? reactive(val) : val
  const target = this.raw
  track(target, ITERATE_KEY) // 遍历关联ITERATE_KEY
  target.forEach((v, k) => {
    // 包装参数为响应式,指定thisArg
    callback.call(thisArg, wrap(v), wrap(k), this)
  })
}

还有 for...of 遍历依赖的迭代器方法(Symbol.iterator/entries/keys/values),不仅要自定义迭代器,把返回值包装成响应式,还要让迭代器对象实现可迭代协议(即实现Symbol.iterator返回自身),不然会报错 “不是可迭代对象”。更细节的是,keys 方法还要单独用一个新的 Symbol 键(比如 MAP_KEY_ITERATE_KEY)来 track,因为 keys 只关心集合的键,不关心值,Map 的 SET 操作(修改值)不应该触发 keys 遍历的更新,这样能避免无效的重渲染。

迭代器这块真的把我绕晕了,又是可迭代协议又是迭代器协议,一开始写的时候少了Symbol.iterator返回自身,直接报红,调了半天才发现问题。而且 keys 方法的这个优化,让我感受到了框架设计的极致,不仅要实现功能,还要追求性能,避免不必要的更新,这一点真的值得我们初学者好好学习。

写在最后:越学越敬畏,越学越清晰

学完 Set/Map 的代理实现,我最大的感受就是:原来我们平时用起来无比丝滑的reactive,背后藏了这么多细节和巧思。从普通对象到数组,再到 Set/Map,Vue 的响应式系统不是简单的用 Proxy 套一层就完事了,而是针对不同数据类型的特性,做了大量的适配和优化,解决了无数的边界问题。 作为一个初学者,一开始啃这些底层知识的时候,经常会因为看不懂报错、搞不懂细节而烦躁,但慢慢啃下来之后,不仅搞懂了响应式的实现逻辑,更培养了自己的细节思维边界思维。以前写代码只想着 “能跑就行”,现在会不自觉的思考:这个方法有没有边界情况?这么写会不会有隐藏的问题? 而且越学越觉得,Vue 的源码设计是有迹可循的,不管是什么数据类型,响应式的核心永远是 track 和 trigger,所有的适配都是围绕 “如何在正确的时机执行 track 和 trigger” 展开的。抓住这个核心,再去拆解不同数据类型的处理逻辑,就不会觉得乱了。 当然,这只是 Vue 响应式系统的一小部分,后面还有 WeakSet/WeakMap、只读响应式、浅响应式等内容要啃,路还很长。但这次的学习让我明白,学习源码不用急于求成,沉下心来抠细节,多敲代码试错,慢慢积累,总会有收获的。对 Vue 的这些底层知识,也始终保持一份敬畏之心,因为每一个看似简单的 API,背后都是开发者的心血和智慧。

第6章:原始值响应式

第6章「原始值的响应式方案」,这一章彻底解决了我长期以来的一个困惑:为什么用ref包裹原始值(比如数字、字符串)才能实现响应式,而reactive却不行?在学习这一章之前,我只是机械记住“原始值用ref,引用值用reactive”,却从没想过背后的原因,甚至偶尔会疑惑,为什么访问ref的值必须加.value,这些看似基础的问题,直到学完这一章才真正豁然开朗。

这一章最核心的知识点,就是ref的设计逻辑。之前学第5章非原始值的响应式方案时,知道reactive依赖Proxy实现,但Proxy无法代理原始值——因为原始值是按值传递,而非按引用传递,根本没有可以拦截的引用对象。为了解决这个问题,Vue的思路很巧妙:用一个非原始值(对象)包裹原始值,再对这个包裹对象进行Proxy代理,这就是ref的本质。而这也解释了为什么ref需要通过.value访问值,因为我们操作的,其实是包裹对象的value属性,而非原始值本身。

除此之外,章节中关于“区分ref与普通响应式对象”的设计,也让我感受到了Vue的严谨。刚开始实现的ref,本质上就是一个被reactive代理的包裹对象,和我们自己用reactive创建的包裹对象没有任何区别,这就会导致框架无法区分一个数据到底是不是ref,进而影响后续的自动脱ref功能。为此,Vue给ref的包裹对象添加了一个不可枚举的__v_isRef属性,用这个属性作为ref的标识,简单又高效,这种“细节处的兜底设计”,看似微小,却能避免很多潜在问题,也让我明白,好的底层设计,往往藏在这些不起眼的细节里。

ref的作用不仅限于实现原始值的响应式,还能解决“响应丢失问题”,这是我学习这一章时最意外的收获。之前写Vue组件时,偶尔会遇到这样的情况:把reactive创建的响应式对象用展开运算符暴露到模板中,修改数据后页面不更新,当时只知道换一种暴露方式就能解决,却不知道背后是响应丢失的问题。学完才懂,展开运算符会把响应式对象的属性拆解成普通值,返回的是一个不具备响应式能力的普通对象,自然无法触发页面更新。

而解决响应丢失问题的关键,就是toRef和toRefs函数。它们的核心思路,就是把响应式对象的每个属性,都转换成类似ref的包裹对象——这样一来,即使我们用展开运算符拆解,得到的依然是具备响应能力的ref,而非普通值。toRef针对单个属性,toRefs则批量处理所有属性,两者搭配使用,就能彻底解决响应丢失问题。同时,章节中还完善了toRef的设计,给它添加了setter方法,让toRef创建的ref不仅能读取值,还能修改值,保证了功能的完整性,这种“逐步完善、兼顾实用性”的设计思路,也给我平时写代码提供了启发。

自动脱ref的知识点,更是解决了我使用ref时的“心智负担”。刚开始用toRefs解决响应丢失问题后,又出现了新的问题:每个属性都是ref,访问时必须加.value,这在模板中使用会非常繁琐。而自动脱ref的作用,就是让我们在访问ref时,无需手动加.value——框架会自动判断,如果访问的属性是ref,就直接返回它的value值。这种设计,本质上是通过Proxy拦截属性访问,利用之前定义的__v_isRef标识判断是否为ref,进而自动处理.value的访问逻辑,既减轻了用户的使用成本,也保证了API的易用性。

学完第6章,我最大的感受是,Vue的API设计从来都不是“凭空出现”的,每一个API的存在,都有其必然的原因,每一个细节的设计,都是为了解决具体的问题。之前用ref、toRefs时,只觉得是“约定俗成”,现在才明白,背后都是底层逻辑的支撑——从Proxy无法代理原始值,到用ref包裹对象,再到__v_isRef标识、自动脱ref,整个逻辑链条环环相扣,每一步都在解决前一步出现的问题,这种“层层递进、闭环设计”的思路,让我对Vue的底层架构有了更深刻的认知。

第7章 渲染器的设计

作为刚入门Vue源码的新人,啃完第7章「渲染器的设计」,终于打破了我对“渲染”的片面认知——原来我们平时用Vue时,数据变化后页面自动更新的背后,除了响应系统,还有渲染器在默默发力。这一章没有过于晦涩的底层逻辑堆砌,更多是从基础概念入手,逐步拆解渲染器的核心作用、工作流程,以及跨平台能力的实现思路,全程跟着学下来,既有收获的踏实感,也有对Vue设计严谨性的敬畏。

在学习这一章之前,我对渲染器的理解只停留在“把写的模板变成页面”的表面,甚至分不清renderer(渲染器)和render(渲染动作)的区别,总觉得这是个很“高深”的模块,遥不可及。直到学完才明白,渲染器的核心作用其实很明确:把虚拟DOM(vnode)渲染成特定平台的真实元素,在浏览器里就是渲染成真实DOM,而它也是Vue跨平台能力的关键所在——这一点彻底打破了我“Vue只能用于浏览器”的固有认知。

这一章最让我理清思路的,是渲染器与响应系统的配合关系。之前学响应系统时,只知道effect能追踪依赖、ref/reactive能创建响应式数据,却不知道这些数据变化后,是谁来触发页面更新。学完才懂,响应系统负责“感知”数据变化,而渲染器负责“执行”渲染操作,两者结合,才能实现“数据变、页面更”的自动化流程。哪怕是一个极简的渲染器,只要配合响应系统的effect,就能完成基础的自动更新,这种“分工明确、协同工作”的设计,让我觉得很精妙,也终于打通了之前的知识断层。

除此之外,章节里的基础概念讲解,帮我扫清了很多认知盲区。比如虚拟DOM(vnode),它和真实DOM结构相似,是描述页面结构的树型节点,之前总觉得它很抽象,其实本质就是用对象来描述元素;还有挂载(mount)、打补丁(patch)、容器(container)这些术语,之前在写Vue组件时偶尔会看到(比如mounted钩子),却不知道具体含义,学完后终于明白:挂载是首次将vnode渲染成真实DOM并插入容器的过程,打补丁是后续更新时,对比新旧vnode、只更新变化部分的过程,而容器就是指定渲染位置的DOM元素。尤其是patch函数,它既负责挂载(无旧vnode时),又负责打补丁(有新旧vnode时),这种“一物两用”的设计,既简洁又高效,也让我意识到,好的底层设计往往是“简洁且通用”的。

自定义渲染器的部分,是我这一章最意外的收获。之前一直好奇,Vue为什么能实现跨平台(比如浏览器、Node.js),难道是为每个平台都写了一套渲染逻辑?其实不然,核心是“抽象与配置”。渲染器的核心逻辑本身不依赖具体平台,而是把浏览器特有的DOM操作(比如创建元素、设置文本)抽离成可配置项,用户创建渲染器时,只需传入对应平台的配置,就能实现自定义渲染。比如章节里举例的“打印渲染流程”的自定义渲染器,既能在浏览器运行,也能在Node.js运行,这让我明白,跨平台不是“黑魔法”,而是通过合理的抽象,让核心代码脱离平台依赖,这种设计思路,也给我平时写代码提供了新的启发——不要写死具体逻辑,多做抽象、预留配置,才能提升代码的可扩展性。

作为新人,学习过程中也有过困惑。比如刚开始区分“渲染器”和“虚拟DOM”时,总觉得两者是一回事,慢慢梳理才分清:虚拟DOM是“描述页面的模板”,渲染器是“把模板变成真实元素的工具”;还有patch函数的工作流程,刚开始不太理解“为什么挂载也属于特殊的打补丁”,直到反复梳理渲染流程才明白,首次渲染时没有旧vnode,patch函数就会执行挂载操作,后续有新旧vnode对比时,才执行打补丁操作,本质上都是“将vnode渲染到容器”的过程,只是场景不同而已。

学完这一章,最大的感受不是掌握了多少复杂的API,而是对Vue的底层设计多了一份敬畏,也慢慢养成了“追本溯源”的思维。以前用Vue,只知道“这样写能实现效果”,现在会不自觉地思考“背后是怎么实现的”——数据变化后,effect触发重新执行,渲染器调用patch函数,对比新旧vnode,更新变化部分,整个流程环环相扣,每一步都有其存在的意义。同时也意识到,底层知识的学习,不在于“记住多少概念”,而在于“理解背后的设计逻辑”,比如渲染器的抽象设计、patch函数的通用设计,这些思路不仅适用于框架开发,也适用于日常的业务代码编写。

当然,这一章只是渲染器设计的入门,后续还有patch函数的具体实现、复杂vnode的处理等内容要啃,但这一章的学习,已经为我打下了坚实的基础。作为新人,我知道自己还有很多不懂的地方,比如自定义渲染器的具体配置细节、跨平台渲染的深层逻辑,还需要慢慢琢磨、反复梳理,但至少,我已经走出了“对底层一无所知”的误区,开始真正理解Vue的强大之处。

阅读至173页