首先,向
vue.js
和霍春阳前辈
献上感谢。博客内容主要总结于“Vue.js设计与实现”,代码块实现来自我的仿写,可能存在bug。欢迎大家一起讨论学习,博客内容持续更新。
第1章 权衡的艺术
- 框架设计里到处都体现了权衡的艺术。
- 命令式框架关注
过程
(例如,jQuery )。 - 声明式框架关注
结果
(例如, Vue.js)。 - Vue.js对
过程
进行了封装,内部自身实现是命令式的
,外部用户使用是声明式的
。 - 声明式代码的性能不优于命令式代码的性能(即,声明式 <= 命令式)。
- 命令式代码的更新性能消耗 = 直接修改的性能消耗
- 声明式代码的更新性能消耗 = 直接修改的性能消耗 + 找出差异的性能消耗
- 要做出的关于
可维护性
与性能
之间的权衡,在保持可维护性的同时让性能损失最小化。 - 声明式代码的更新性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗,因此,如果我们能够
最小化找出差异的性能消耗
,就可以让声明式代码的性能无限接近命令式代码的性能。而所谓的虚拟DOM,就是为了最小化找出差异这一步的性能消耗而出现的。 - 我们很难写出绝对优化的命令式代码,尤其是当应用程序的规模很大的时候,即使你写出了极致优化的代码,也一定耗费了巨大的精力,这时的投入产出比其实并不高。
- 编译时,可以
分析用户提供的内容
,看看哪些内容未来可能会改变,哪些内容永远不会改变,这样我们就可以在编译的时候提取这些信息,然后将其传递给 Render 函数,Render 函数得到这些信息之后,就可以做进一步的优化了。 - 运行时,更加灵活。
- Vue.js 3 仍然保持了运行时 + 编译时的架构,在保持灵活性的基础上能够尽可能地去优化。
第2章 框架设计的核心要素
- 提供友好的警告信息至关重要。
- 开发工具插件支持。
- 控制框架代码的体积,有一些代码需要出现在开发阶段,不需要出现在生产阶段(例如,警告提示)。
- 框架要做到良好的 Tree-Shaking。Tree-Shaking 是一种排除 dead code 的机制,框架中会内建多种能力,例如 Vue.js 内建的组件等。对于用户可能用不到的能力,我们可以利用 Tree-Shaking 机制使最终打包的代码体积最小化。另外,Tree-Shaking 本身基于 ESM,并且 JavaScript 是一门动态语言,通过纯静态分析的手段进行 Tree-Shaking 难度较大,因此大部分工具能够识别
/*#__PURE__*/ 注释
,在编写框架代码时,我们可以利用/*#__PURE__*/
来辅助构建工具进行 Tree-Shaking。 - 针对不同的需求场景,提供不同的产物。
- 特性开关。有时用户明确知道自己仅会使用组合式 API,而不会使用选项对象式 API,这时用户可以通过特性开关关闭对应的特性,这样在打包的时候,用于实现关闭功能的代码将会被Tree-Shaking 机制排除。
- 用户自定义统一的异常处理。框架的错误处理做得好坏直接决定了用户应用程序的健壮性,同时还决定了用户开发应用时处理错误的心智负担。框架需要为用户提供统一的错误处理接口,这样用户可以通过注册自定义的错误处理函数来处理全部的框架异常。
- TypeScript的良好支持。
第3章 Vue.js 3 的设计思路
-
Vue.js 3 是一个
声明式
的 UI 框架,意思是说用户在使用 Vue.js 3 开发页面时是声明式地描述 UI 的。 -
现在我们已经了解了什么是
虚拟 DOM
,它其实就是用JavaScript 对象
来描述真实的DOM 结构。 -
渲染器的作用就是把
虚拟 DOM
渲染为真实 DOM
。 -
实现一个简单的渲染器:
// 函数声明 function render(vnode, root) { const el = document.createElement(vnode.tag) for (const key in vnode.props) { if (Object.hasOwnProperty.call(vnode.props, key)) { const element = vnode.props[key] if (key.startsWith("on")) { el.addEventListener(key.match(/^on([a-zA-Z]+)$/)[1].toLocaleLowerCase(), element) } el[key] = element } } if (Array.isArray(vnode.children)) { vnode.children.forEach(element => { render(element, el) }) } else { const textNode = document.createTextNode(vnode.children) el.appendChild(textNode) } root.appendChild(el) } function init() { const vnode = { tag: "div", props: { style: "color: green" }, children: [ { tag: "h1", children: "看下面" }, { tag: "span", props: { onClick: () => alert("出现弹框"), style: "cursor: pointer" }, children: "点我试试" } ] } const bodyDom = document.querySelector("body") render(vnode, bodyDom) } // 初始化应用 init()
-
一句话总结:组件就是一组 DOM 元素的封装。
-
组件要渲染的内容,就是虚拟 DOM。组件的返回值也是虚拟 DOM,它代表组件要渲染的内容。
-
我们可以使用一个
对象
来代表组件,该对象有一个函数,叫作 render,其返回值代表组件要渲染的内容
,也就是虚拟 DOM
。 -
我们可以使用一个
函数
来代表组件,函数返回值是虚拟 DOM
,即组件要渲染的内容
。 -
无论是手写虚拟 DOM(渲染函数)还是使用模板,都属于声明式地描述 UI,并且Vue.js 同时支持这两种描述 UI 的方式。
-
编译器的作用其实就是将模板编译为渲染函数。对于编译器来说,模板就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数。
-
所以,无论是使用
模板
还是直接手写渲染函数
,对于一个组件
来说,它要渲染的内容
最终都是通过渲染函数
产生的,然后渲染器
再把渲染函数
返回的虚拟 DOM
渲染为真实DOM,这就是模板的工作原理,也是 Vue.js 渲染页面的流程。 -
渲染函数
的返回值是虚拟 DOM
。渲染函数
的生成虚拟 DOM
。 -
我们了解到编译器和渲染器之间是存在信息交流的,它们互相配合使得性能进一步提升,而它们之间交流的媒介就是虚拟 DOM 对象。
-
Vue.js 采用模板的方式来描述 UI,但它同样支持使用虚拟 DOM (渲染函数)来描述 UI。虚拟 DOM 要比模板更加灵活,但模板要比虚拟 DOM 更加直观。
-
渲染器的作用是,把虚拟 DOM 对象渲染为真实 DOM 元素。它的工作原理是,递归地遍历虚拟 DOM 对象,并调用原生 DOM API 来完成真实 DOM 的创建。渲染器的精髓在于后续的更新,它会通过 Diff 算法找出变更点,并且只会更新需要更新的内容。
-
组件其实就是一组虚拟 DOM 元素的封装,它可以是一个返回虚拟 DOM 的函数,也可以是一个对象,但这个对象下必须要有一个函数用来产出组件要渲染的虚拟 DOM。
第4章 响应系统的作用与实现
- 副作用函数指的是会产生副作用的函数。副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用。
- effect 函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用。
-
观察上方代码块,我们可以发现两点线索:const obj = { text: 'hello world' } function effect() { document.body.innerText = obj.text }
(1)当副作用函数 effect 执行时,会触发字段 obj.text 的读取
操作。
(2)当修改 obj.text 的值时,会触发字段 obj.text 的设置
操作。 - 如果我们能拦截一个对象的
读取
和设置
操作,事情就变得简单了。当读取字段 obj.text 时,我们可以把副作用函数 effect 存储到一个“桶”里。当设置 obj.text 时,再把副作用函数 effect 从“桶”里取出并执行。 - 在 ES2015之前,只能通过 Object.defineProperty 函数实现,这也是 Vue.js 2 所采用的方式。在 ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是 Vue.js 3 所采用的方式。
- 一个响应系统的工作流程如下:
(1)当读取操作发生时,将副作用函数收集到“桶”中。
(2)当设置操作发生时,从“桶”中取出副作用函数并执行。 - “桶”的数据结构,使用了 WeakMap、Map 和 Set :
(1)WeakMap 由 target --> Map 构成。
(2)Map 由 key --> Set 构成。
其中 WeakMap 的键是原始对象 target,WeakMap 的值是一个 Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set。 - Set 数据结构所存储的副作用函数集合称为 key 的依赖集合。
- WeakMap 对 key 是
弱引用
,不影响
垃圾回收器的工作。所以 WeakMap 经常用于存储那些只有当 key 所引用的对象存在时(没有被回收)才有价值的信息,例如上面的场景中,如果 target 对象没有任何引用了,说明用户侧不再需要它了,这时垃圾回收器会完成回收任务。但如果使用 Map 来代替 WeakMap,那么即使用户侧的代码对 target 没有任何引用,这个 target 也不会被回收,最终可能导致内存溢出。 -
// 设计一个完善的响应系统 // 全局常量变量声明及初始化 let activeEffect = null const bucket = new WeakMap() // 函数声明 function track(target, key) { if (!activeEffect) return let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, (depsMap = new Map())) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } deps.add(activeEffect) } function trigger(target, key) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) effects && effects.forEach(fn => fn()) } function effect(fn) { activeEffect = fn fn() } function getProxy(data) { return new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newVal) { target[key] = newVal trigger(target, key) } }) } function init() { const proxyData = getProxy({ text: "hello world." }) effect(() => { document.body.innerText = proxyData.text }) setTimeout(() => { proxyData.text = "你好世界。" }, 3000) } // 初始化应用 init()
- 分支切换与 cleanup ,分支切换可能会产生遗留的副作用函数,遗留的副作用函数会导致不必要的更新。解决这个问题的思路很简单,每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除。
- 当副作用函数
执行完毕
后,会重新建立联系
,但在新的联系中不会包含
遗留的副作用函数。 - 语言规范:在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,如果此时 forEach 遍历没有结束,那么该值会重新被访问。
-
// 分支切换与cleanup // 全局常量变量声明及初始化 let activeEffect = null const bucket = new WeakMap() // 函数声明 function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] deps.delete(effectFn) } effectFn.deps.length = 0 } function track(target, key) { if (!activeEffect) return let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, (depsMap = new Map())) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } deps.add(activeEffect) activeEffect.deps.push(deps) } function trigger(target, key) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) const effectsToRun = new Set(effects) effectsToRun.forEach(effectFn => effectFn()) } function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn fn() } effectFn.deps = [] effectFn() } function getProxy(data) { return new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newVal) { target[key] = newVal trigger(target, key) } }) } function init() { const proxyData = getProxy({ text: "hello world." }) effect(() => { document.body.innerText = proxyData.text }) setTimeout(() => { proxyData.text = "你好世界。" }, 3000) } // 初始化应用 init()
-
// 嵌套的effect与effect栈,避免无限递归循环(例如,n++) // 全局常量变量声明及初始化 let activeEffect = null const effectStack = [] const bucket = new WeakMap() // 函数声明 function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] deps.delete(effectFn) } effectFn.deps.length = 0 } function track(target, key) { if (!activeEffect) return let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, (depsMap = new Map())) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } deps.add(activeEffect) activeEffect.deps.push(deps) } function trigger(target, key) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) const effectsToRun = new Set() effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) effectsToRun.forEach(effectFn => effectFn()) } function effect(fn) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } effectFn.deps = [] effectFn() } function getProxy(data) { return new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newVal) { target[key] = newVal trigger(target, key) } }) } function init() { const proxyData = getProxy({ foo: 1, bar: 1 }) effect(function effectFn1() { console.log('effectFn1 执行') effect(function effectFn2() { console.log('effectFn2 执行') proxyData.bar }) proxyData.foo }) setTimeout(() => { proxyData.foo++; }, 1000) setTimeout(() => { proxyData.bar++; }, 3000) } // 初始化应用 init()
- 所谓可调度,指的是当 trigger 动作
触发
副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式
。 -
// 005 第四章完结 // 全局常量变量声明及初始化 let activeEffect = null const effectStack = [] const bucket = new WeakMap() const jobQueue = new Set() const p1 = Promise.resolve() let isFlushing = false // 函数声明 function getProxy(data) { return new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newVal) { target[key] = newVal trigger(target, key) } }) } function effect(fn, options = {}) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) const res = fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] return res } effectFn.options = options effectFn.deps = [] if (!options.lazy) { effectFn() } return effectFn } function effectEliminateTransition(getter) { effect(() => { getter() }, { scheduler(fn) { jobQueue.add(fn) flushJob() } }) } function computed(getter) { let value let dirty = true const effectFn = effect(getter, { lazy: true, scheduler() { if (!dirty) { dirty = true trigger(obj, 'value') } } }) const obj = { get value() { if (dirty) { value = effectFn() dirty = false } track(obj, 'value') return value } } return obj } function watch(source, cb, options = {}) { let getter if (typeof source === 'function') { getter = source } else { getter = () => traverse(source) } let oldValue, newValue let cleanup function onInvalidate(fn) { cleanup = fn } const job = () => { newValue = effectFn() if (cleanup) { cleanup() } cb(newValue, oldValue, onInvalidate) oldValue = newValue } const effectFn = effect( () => getter(), { lazy: true, scheduler: () => { if (options.flush === 'post') { const p2 = Promise.resolve() p2.then(job) } else { job() } } } ) if (options.immediate) { job() } else { oldValue = effectFn() } } function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i] deps.delete(effectFn) } effectFn.deps.length = 0 } function track(target, key) { if (!activeEffect) return let depsMap = bucket.get(target) if (!depsMap) { bucket.set(target, (depsMap = new Map())) } let deps = depsMap.get(key) if (!deps) { depsMap.set(key, (deps = new Set())) } deps.add(activeEffect) activeEffect.deps.push(deps) } function trigger(target, key) { const depsMap = bucket.get(target) if (!depsMap) return const effects = depsMap.get(key) const effectsToRun = new Set() effects && effects.forEach(effectFn => { if (effectFn !== activeEffect) { effectsToRun.add(effectFn) } }) effectsToRun.forEach(effectFn => { if (effectFn.options.scheduler) { effectFn.options.scheduler(effectFn) } else { effectFn() } }) } function flushJob() { if (isFlushing) return isFlushing = true p1.then(() => { jobQueue.forEach(job => job()) }).finally(() => { isFlushing = false }) } function traverse(value, seen = new Set()) { if (typeof value !== 'object' || value === null || seen.has(value)) return seen.add(value) for (const k in value) { traverse(value[k], seen) } return value } function init() { const proxyData = getProxy({ foo: 1, bar: 2 }) watch(proxyData, () => { console.log("proxyData变化了") }, { immediate: true }) setTimeout(() => { proxyData.foo++ }, 2000); } // 初始化应用 init()