「Vue.js 设计与实现」笔记¶
框架设计概览¶
第1章 权衡的艺术¶
-
1.1 命令式和声明式
- 命令式:关注过程,例如 jQuery
- 声明式:关注结果,如 Vue// jQuery $('#app') .text('hello world') .on('click', function(){alert('hello world')}); // 或 JavaScript const app = doucment.querySelect('#app'); app.innerText = 'hello world'; app.addEventLister('click', function(){alert('hello world')})
- Vue 封装「命令式过程」,暴露「声明式结果」 ```js <div id="app" @click="()=> alert('hello world')">hello world</div> ```
-
1.2 性能与可维护性
-
命令式更新性能 = 直接修改的性能消耗
- 声明式更新性能 = 找出差异的性能消耗 + 直接修改的性能消耗
- 更新页面时的性能:innerHTML < 虚拟DOM < 原生JavaScript
-
权衡
- 命令式性能 > 声明式性能
- 命令式可维护性 < 声明式可维护性(降低心智负担)
-
1.3 运行时和编译时
-
纯运行时 runtime
- 利用 render 函数,直接把 Virtual DOM 转化为真实 DOM
- 没有模板编译过程
-
纯编译时 compile
- 直接把 template 模板内容,转化为真实 DOM
- 没有运行时,性能更好,如 Svelte
-
运行时 + 编译时
- 1) 把 template 模板转化为 render 函数
- 2) 再利用 render 函数,把 Virtual DOM 转化为真实 DOM
- 编译时:分析用户提供的模板内容;运行时:提供足够的灵活性。
-
第2章 框架设计的核心要素¶
- 2.1 提升用户的开发体验
- 2.2 控制框架代码的体积
-
2.3 框架要做到良好的 Tree-Shaking
- 无论是 rollup.js 还是webpack,都支持Tree-Shaking
- 模块必须是ESM (ES Module),Tree-Shaking 依赖 ESM 的静态结构
-
2.4 错误处理
- 统一的错误处理接口:
callWithErrorHandling
- 统一的错误处理接口:
-
2.5 良好的 TypeScript 类型支持
- 便于调试,提升开发体验
- 良好的可维护性
第3章 Vue.js3的设计思路¶
-
3.1 描述 UI 的形式
- 声明式的「模板」描述 UI
- 命令式的 「render 函数」(h函数)描述 UI
- 3.2 初识渲染器import { h } from 'vue' export default{ render(){ return h('h1', { onClick:handler })//虚拟DOM } }
-
渲染器的作用就是把「虚拟DOM」渲染为「真实DOM」
- ● 创建元素
- ● 为元素添加属性和事件
- ● 处理 children
-
vnode
- rendererconst vnode = { tag: 'div', props: { onClick: () => alert('hello') }, children: 'click me' }
- renderer 是 createRenderer 的返回值,是一个对象,包含渲染函数。 - 如下 ```js function renderer(vnode, container) { // 使用 vnode.tag 作为标签名称创建 DOM 元素 const el = document.createElement(vnode.tag); // 遍历 vnode.props,将属性、事件添加到 DOM 元素 for (const key in vnode.props) { if (/^on/.test(key)) { // 如果 key 以 on 开头,说明是事件 el.addEventListener( key.substr(2).toLowerCase(), // onClick ---> click vnode.props[key] // 事件处理函数 ) } } // 处理 children if (typeof vnode.children === 'string') { // 文本节点 const text = document.createTextNode(vnode.children); el.appendChild(text); } else if (Array.isArray(vnode.children)) { // 递归遍历子节点 vnode.children.forEach(child => renderer(child, el)); } // 将元素添加到挂载点下 container.appendChild(el); } ```
- 渲染器的精髓在于更新节点的阶段。对于渲染器来说,它需要精确地找到 vnode 对象的变更点并且只更新变更的内容。
-
3.3 组件的本质
- 「组件」就是一组 DOM 元素的封装
- 一个 Javascript 对象(即 vnode)
- 函数式组件:可以定义一个函数来代表组件,返回值代表组件要渲染的内容
- 有状态组件:使用对象结构来表达
-
3.4 模板的工作原理
- 编译器的作用其实就是将「模板」编译为「渲染函数」。
- 无论是使用模版还是手写,对于组件来说,它渲染的内容都是通过渲染函数产生的,然后渲染器把渲染函数返回的虚拟DOM渲染为真实DOM,这就是模版的工作原理
- 渲染器、编译器都是Vue.js核心组成成分,构成整体,模块互相配合提升框架性能。
响应式系统¶
第4章 响应系统的作用与实现¶
-
4.1 响应式数据与副作用函数
-
副作用函数
- 副作用函数指的是会产生副作用的函数。
- effect 函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用。
-
响应式数据
- 会导致视图变化的数据
- 当 obj.text 的值发生变化时,我们希望副作用函数 effect 会重新执行,如果能实现这个目标,那么对象 obj 就是响应式数据。
-
-
4.2 响应式数据的基本实现
-
核心逻辑
- 数据读取 getter 行为:
document.body.innerText = obj.text
- 数据修改 setter 行为:
obj.text = 'hello vue3'
- 数据读取 getter 行为:
-
核心 API
- vue2:Object.defineProperty
- vue3: Proxy
-
vue3 具体实现
-
getter 行为
- 1、当副作用函数 effect 执行时,会触发字段
obj.text
的「读取」操作
- 1、当副作用函数 effect 执行时,会触发字段
-
setter 行为
- 2、当修改
obj.text
的值时,会触发字段obj.text
的「设置」操作
- 2、当修改
-
-
-
4.3 设计一个完善的响应式系统
-
解决副作用函数名称硬编码的缺陷
- 解决方法:提供一个用来注册副作用函数的机制
- 实现:用一个全局变量 activeEffect 存储被注册的副作用函数
- // effect 函数用来注册副作用函数
- // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
-
解决没有在副作用函数与被操作的目标字段之间建立明确联系的缺陷
- 解决方法:在副作用与被操作的字段之间建立联系,使用 「树型结构」重新设计 「桶」 的数据结构
- 代码实现:使用 WeakMap 代替桶,用 Map 来存储 key,用 Set 来存储副作用函数effectFn。
- 如图
-
为什么要使用WeakMap?
- 简单地说,WeakMap 对 key 是弱引用,不影响垃圾回收器的工作。
- 根据这个特性可知,一旦 key 被垃圾回收器回收,那么对应的键和值就访问不到了。
-
-
4.4 嵌套的 effect 与 effect 栈
-
嵌套的 effect
-
Vue.js 渲染函数的执行
- 实际上 Vue.js 的渲染函数就是在一个 effect 中执行的
-
嵌套渲染组件
- effect 是可以发生嵌套的,比如我们嵌套渲染组件。
-
effect 不支持嵌套会发生什么
- 当副作用函数发生嵌套时,内层的副作用函数的执行会覆盖 activeEffect 的值,并且永远不会恢复到原来的值。
- 这时如果再有响应式数据进行依赖收集,即使这个响应式数据是在外层的副作用函数中读取的,它们收集到的副作用函数也会是内层的副作用函数。
-
-
副作用函数栈 effectStack
- 解决: activeEffect 被覆盖,副作用函数收集错误的问题。
- 在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况。
-
-
4.5 避免无限递归循环
-
导致原因
effect(() => obj.foo++) // 会导致无限循环
- 首先读取 obj.foo 的值,这会触发 track 操作,将当前副作用函数收集到 “桶” 中;接着将其加 1 后再赋值给 obj.foo ,此时会触发 trigger 操作。
- 问题是该副作用函数还没有执行完成,又要开始下一次的执行。所以导致了无限递归地调用自己,产生了栈溢出。
-
解决方法
- 在 trigger 动作发生时增加守卫条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。
-
-
4.6 调度系统
-
响应式的可调度性
- 所谓可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定:副作用函数(effect)执行的时机、次数以及方式。
-
解决方案
- 为 effect 函数设计一个选项参数 options,允许用户指定调度器。
-
实现原理
- 异步:Promise
- 队列:jobQueue
- 基于 Set 构建队列 jobQueue,利用 Promise 的异步特性,控制执行顺序
-
控制副作用函数的执行顺序
- 在 trigger 中触发副作用函数重新执行时,可以直接调用用户传递的「调度器函数」,把控制权交给用户
-
控制副作用函数执行的次数
- 有时候我们只关心结果而不关心过程,那么我们可以控制副作用函数执行的次数
-
-
4.7 计算属性 computed
- 概念:一个属性值,当依赖的响应式数据变化时,重新计算。
- 实现原理:利用「调度系统」。
-
具体思路
- 1、computed 做到了「懒计算」。解决:只有在读取 computedResult.value 的值时,它才会进行计算并得到值
- 2、对值进行「缓存」。解决:多次访问 computedResult.value 的值,会导致effectFn 进行多次计算
- 3、在 effect 中添加「调度器」,当值发生变化时,将 dirty 重置为 true。解决:在 getter 函数中所依赖的响应式数据变化时执行,这样我们在 scheduler 函数内将 dirty 重置为 true,当下一次访问 computedResult.value 时,就会重新调用 effectFn 计算值。
- 4、懒执行机制导致 effect 嵌套时,外层的 effect 不会被内层 effect 中的响应式数据收集。解决:读取计算属性的值时,手动调用 track 函数进行追踪;当计算属性依赖的响应式数据发生变化时,手动调用 trigger 函数触发响应
-
4.8 惰性执行(lazy)
- 懒执行的 effect:当 options.lazy 为 true 时,则不立即执行副作用函数,并且通过 effect 函数的返回值拿到对应的副作用函数(真正的副作用函数 fn )执行的结果。
-
4.9 watch 的实现原理
-
watch 的本质
- watch 的本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。
- watch 的实现本质就是利用了 effect 和 options.scheduler 选项。
-
watch 的实现
- 如果副作用函数存在 scheduler 选项,当响应式数据发生变化时,会触发 scheduler 函数执行,而不是直接触发副作用函数执行。
- Subtopic 4
-
获取新值与旧值
- 实现:利用 effect 函数的lazy 选项
- 手动调用 effectFn 函数获得的返回值就是 旧值,即第一次执行得到的值。
- 当变化发生并触发 scheduler 调度函数执行时,会重新调用 effectFn 并得到新值
-
-
4.10 立即执行的 watch 与回调执行时机
-
立即执行的回调函数
- options 选项的 immediate 为 true,就会立即执行watch的回调函数。
- 实现方法:把 scheduler 调度函数封装为一个通用函数,分别在初始化和变更时执行它。
-
回调函数的执行时机
- 通过 options 的 flush 选项来指定回调函数的执行时机
- flush: 'pre' // 还可以指定为 'post' | 'sync'
- pre:watch 创建时立即执行一次
- post:将 job 函数放到微任务队列中,并等待 DOM 更新结束后执行,从而实现异步延迟执行
- sync:否则直接执行 job 函数,即同步执行
-
-
4.11 过期的副作用
-
竞态问题
- 描述一个系统或进程的输出,依赖于不受控制的事件出现顺序或者出现的时机。
- 代码
- 非预期结果:请求A返回过期了。
-
解决方式
- onInvalidate:该回调函数会在副作用下一次重新执行前调用,可以用来清除无效的副作用,例如等待中的异步请求。
- 使用方式
-
onInvalidate 原理
- 关联的响应式数据「首次被修」改时,不会触发onInvalidate函数!
- 副作用函数「下一次重新」执行前,先触发 onInvalidate。
-
第5章 非原始值的响应式方案¶
-
5.1、理解 Proxy 和 Reflect
- Proxy:代理一个对象(被代理对象)的 getter和setter行为,得到一个proxy实例(代理对象)
- Reflect:在Proxy中使用this时,保证this指向 proxy,从而正确执行次数的副作用
-
5.2、JavaScript 对象及Proxy 的工作原理
- 5.3、如何代理 Object
- 5.4、合理地触发响应
- 5.5、浅响应与深响应
- 5.6、只读和浅只读
- 5.7、代理数组
- 5.8、代理 Set 和 Map
第6章 原始值的响应式方案¶
-
6.1、引入 ref 的概念
- 通过 get、set 函数标记符,让函数以属性调用的形式被触发
- 访问 ref.value 即触发 value() 函数
-
6.2、响应丢失问题
- 6.3、自动脱 ref
渲染器¶
第7章 渲染器的设计¶
-
7.1、渲染器与响应系统结合
- 渲染器不仅能够渲染真实DOM元素,它还是框架跨平台能力的关键。
- 利用响应系统的能力,自动调用渲染器完成页面的渲染和更新。
-
7.2、渲染器的基本概念
-
渲染器 vs 渲染函数
- 渲染器 renderer, createRenderer 的返回值
- 渲染函数 render,renderer 对象中的 render 方法。
- 代码
- vnodefunction createRenderer() { function render(vnode, container) {} function hydrate(vnode, container) {} return { render, hydrate, } }
- 一个 Javascript 对象,描述渲染的内容。
- type 字段:"div"、Text、“MyComponent”、
-
挂载 mount
- 渲染器把虚拟DOM节点渲染为真实DOM节点的过程叫做挂载。
- 容器 container :“挂载点” 其实就是一个DOM元素。
-
执行渲染任务
- 调用 renderer.render(vnode, container) 函数
- 1、在首次渲染时,渲染器会将 vnode1 渲染为真实DOM。渲染完成后,vnode1 会存储到容器元素的
container._vnode
属性中,它会在后续渲染中作为 旧vnode 使用。 - 2、在第二次渲染时,旧vnode 存在,此时渲染器会把 vnode2 作为新vnode,并将新旧vnode 一同传递给 path函数进行打补丁(patch)。
-
-
7.3、自定义渲染器
-
为什么需要自定义 createRenderer 函数?
- 渲染器不仅可以用来渲染,还可以用来激活已有的DOM元素,这个过程通常发生在「同构渲染」的情况下。
- 当调用 createRenderer 函数创建渲染器时,渲染器不仅包含 render 函数,还包含 hydrate 函数。
- hydrate 用来处理同构渲染。
-
添加 patch 函数:patch 函数是整个渲染器的核心,它承载了最重要的渲染逻辑。
- mountElement 函数:mountElement 函数内调用了大量依赖于浏览器的 API ,我们需要将这些浏览器特有的 API 抽离,作为配置项,然后作为 createRenderer 的参数。
-
第8章 挂载与更新¶
-
DOM 节点操作
-
挂载
- DOM 的初次渲染
- 通过 createElement 新建
- 通过 parentEl.insertBefore 插入
-
更新
- 当响应式数据发生变化时,会涉及到 DOM 更新
- 属性更新
-
卸载
- 节点不需要了。
- 通过 parentEl.removeChild 完成
-
-
属性节点操作
-
属性
- HTML Attributes
- DOM Poperties
-
事件
- 添加:el.addEventListener
- 删除:el.removeEventListener
-
更新:vei(vue event invokers)
- 用来更新事件,vue 通过这种方式而不是调用 addEventListener 和 removeEventListener 解决了「频繁的删除、新增事件」时,非常消耗性能的问题
packages/runtime-dom/src/modules/events.ts
中 增加patchEvent
、parseName
、createInvoker
方法:- // patchEvent existingInvoker.value = nextValue
- 实现思路:为 addEventListener 的回调函数,设置一个 value 属性方法,在回调函数中触发这个方法。通过更新该属性方法的形式,达到更新事件的目的。(妙啊!)
-
第9、10、11章 Diff 算法¶
-
简单Diff算法
- 1、减少DOM操作的性能开销
- 2、DOM 复用与 key 的作用
-
3、找到需要移动的元素
- 拿新的一组子节点中的节点去旧的一组子节点中寻找可复用的节点。
- 如果找到了,则记录该节点在旧的一组子节点中位置索引,我们把这个位置索引称为最大索引。
- 在整个更新过程中,如果一个节点的索引值小于最大索引,则说明该节点对应的真实DOM元素需要移动。
-
双端 Diff 算法
-
10.1、双端比较的原理
- 双端 Diff 算法指的是,在新旧两组子节点的四个端点之间分别进行比较,并试图找到可复用的节点。
-
10.2、双端比较优势
- 对于同样的更新场景,执行的 DOM 移动操作次数更少。
-
10.3、添加新元素
- 10.4、移除不存在的元素
-
-
快速 Diff 算法
-
快速 Diff 原理
- 它借鉴了文本 Diff 中的预处理思路,先处理新旧两组子节点中相同的前置节点和相同的后置节点。
- 当前置节点和后置节点全部处理完毕后,如果无法简单地通过挂载新节点或者卸载已经不存在的节点来完成更新,则需要根据节点的索引关系,构造出一个「最长递增子序列」。
- 「最长递增子序列」所指向的节点即为不需要移动的节点。
- 「最长递增子序列」算法实现时间复杂度:动态规划 O(n^2)、贪心+二分查找 O(nlogn)
-
源码执行顺序
- 1) sync from start:自前向后的对比
- 2) sync from end:自后向前的对比
- 3) common sequence+mount:新节点多于旧节点,需要挂载
- 4) common sequence+unmount:l旧节点多于新节点,需要卸载
- 5) unknown sequence:乱序
-
组件化¶
第12章 组件的实现原理¶
-
概念
- 组件对象
- 组件的 vnodeconst MyComponent = { name: 'MyMyComponent', data() { return { foo: 1 } } }
- 二者关系:组件对象会包含一个 render 函数,render 函数的执行完后会返回一个 vnodeconst vnode = { type: MyComponent, // ... };
-
12.1、渲染组件
- 渲染器会使用 mountComponent 和 patchComponent 来完成组件的挂载和更新。
-
12.2、组件状态与自更新
-
实现组件自身状态的初始化
- 1、通过组件的选项对象取得 data 函数并执行,然后调用 reactive 函数将 data 函数返回的状态包装为响应式数据
- 2、在调用 render 函数时,将其 this 的指向设置为响应式数据 state,同时将 state 作为 render 函的第一个参数传递
-
实现组件自更新
- 将整个渲染任务包装到一个 effect 中
- 当组件自身的响应式数据发生变化时,组件就会自动重新执行渲染函数,从而完成更新。
-
副作用函数的缓冲
- 如果多次修改响应式数据的值,将会导致渲染函数同步执行多次,这实际上是没有必要的。因此,我们需要实现一个调度器,当副作用函数需要重新执行时,我们不会立即执行它,而是将它缓存到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行。
- 调度器本质上利用了微任务的异步执行机制,实现对副作用函数的缓冲。
-
-
12.3、组件实例与组件的生命周期
- 组件实例(instance)本质上就是一个状态集合 (或一个对象),它维护着组件运行过程中的所有信息,例如注册到组件的生命周期函数、组件渲染的子树 (subTree)、组件是否已经被挂载、组件自身的状态 (data) 等等。
-
12.4、props 与组件的被动更新
- 12.4.1 解析 props 数据
-
12.4.2 子组件被动更新
- props 本质上是父组件的数据,当 props 发生变化时,会触发父组件重新渲染。
- 由父组件自更新所引起的子组件更新叫作子组件的「被动更新」。
-
12.4.3 封装渲染上下文对象
- 由于 props 数据与组件自身的状态数据都需要暴露到渲染函中,并使得渲染函数能够通过 this 访问它们,因此我们需要封装一个渲染上下文对象
- 创建 renderContext「渲染上下文对象」,本质上是「组件实例的代理」
-
12.5、setup 函数的作用与实现
- 通过检测 setup 函数的返回值类型来决定如何处理它,如果它的返回值为「函数」,则直接将其作为组件的渲染函数;
- 如果不是,则作为数据状态赋值给 setupState; setup 函数返回的数据状态会暴露到渲染环境 renderContext。
-
12.6、组件事件与 emit 的实现
-
12.7、插槽的工作原理与实现
- 插槽:组件的插槽指的是组件会预留一个槽位,该槽位具体要渲染的内容由用户插入。
- 本质:组件中 innerHTML 的内容,在 vnode 中以 children 的属性呈现
- 原理:针对 children 渲染即可
-
12.8、注册生命周期
第13章 异步组件与函数式组件¶
-
13.1、异步组件要解决的问题
- 「异步组件」在页面性能、拆包以及服务端下发组件等场景中尤为重要。
- 允许用户指定加载出错时要渲染的组件
- 允许用户指定 Loading 组件,以及展示该组件的延迟时间
- 允许用户设置加载组件的超时时长
- 组件加载失败时,为用户提供重试的能力
-
13.2、异步组件的实现原理
-
13.3、函数式组件
- 函数式组件没有自身状态,也没有生命周期的概念。
- 函数式组件本质上就是一个普通函数,其返回值是虚拟 DOM。
-
实现对函数式组件的兼容
- 选择性的复用有状态组件的初始化逻辑:
- 首先,在 mountComponent 函数内检查组件的类型,如果是「函数式组件」,则直接将组件函数作为组件选项对象的 render 选项,并将组件函数的静态 props 属性作为组件的 props 选项即可,其它逻辑保持不变。
第14章 内建组件和模块¶
-
14.1、KeepAlive 组件的实现原理
- 内建的 KeepAlive 组件可以避免一个组件被频繁的销毁/重建。
- KeepAlive 的本质是缓存管理,再加上特殊的挂载/卸载逻辑。
- 原理:“卸载” 一个被 KeepAlive 的组件时,它并不会真的被卸载,而会被移动一个隐藏容器中。当重新 “挂载” 该组件时,它也不会真的被挂载,而会被从隐藏容器中取出,再 “放回” 原来的容器中,即页面中。
-
14.2、Teleport 组件的实现原理
-
Teleport 组件要解决的问题
- 内建 Teleport 组件,该组件可以将指定内容渲染到特定容器中,而不受DOM层级的限制。
-
实现 Teleport 组件
-
实现渲染逻辑分离
- 将 Teleport 组件的渲染逻辑从渲染器中分离出来,两点好处:
- 1)可以避免渲染器逻辑代码 “膨胀”;
- 2)可以利用 Tree-Shaking 机制在最终的 bundle 中删除 Teleport 相关的代码,使得最终构建包的体积变小。
-
组件定义
- Teleport 组件有特殊的选项 __isTeleprot 和 process。
-
实现挂载
- 如果要执行挂载,则需要根据 props.to 属性的值来取得真正的挂载点。
-
实现更新
- 只需要调用 patchChildren 函数完成更新操作即可
-
-
-
14.3、Transition 组件的实现原理
- 作用:实现动画逻辑。
- 原理:DOM 挂载时,将动效附加到该DOM上;当DOM卸载时,等到动效执行完,再执行卸载操作。
编译器¶
第15章 编译器核心技术概览¶
-
模板DSL编译器
- DSL:一个领域中,特定语言的编译器。
-
编译流程
- 完整编译流程
- Vue 的编译流程
-
Vue 编译流程三大步
- parse:通过 parse 函数,把模板编译成 AST 对象
- transform:把 AST 编译成 javascript AST
- generate:把 javascript AST 转化为渲染函数
第16章 解析器 Parser¶
第17章 编译优化¶
- 概念:通过比编译手段提取关键信息,比以此知道生产最优代码的过程。
- 核心:根据是否 受数据变化影响,区分「动态节点」和「静态节点」。
-
Block 树
- 本质:虚拟节点树对象
- 核心:dynamicChildren 收集所有动态子节点
-
其他优化手段
- 静态提升
- 预字符串化
- 缓存内联事件处理函数
- v-once 指令
服务端渲染¶
第18章 同构渲染¶
-
CSR
- 将组件渲染为HTML字符串
-
SSR
- 将 vnode 渲染为 HTML 字符串
-
同构渲染
- CSR + SSR: 首次渲染 SSR,二次渲染 CSR
- 对比图
-
客户端激活原理
renderer.hydrate()
- 1) 为页面中的 DOM 元素与虚拟节点对象之间建立联系
- 2) 为页面 DOM 元素添加事件绑定