第一章 权衡的艺术
命令式与声明式:
命令式: JQuery框架, 命令式框架的一大特点就是关注过程. 代码本身就是做事的过程
声明式: Vue 框架, 声明式框架的一大特点是关注结果
Vue 的 内部实现一定是命令式的, 而暴漏给用户的却更加声明式.
性能与可维护性的权衡
结论: 声明式代码的性能不优于命令式代码的性能.
命令式代码的更新性能消耗 = 直接修改的性能消耗
声明式代码的更新性能消耗 = 直接修改的性能消耗 + 找出差异的性能消耗
框架设计者要做的是: 在保持可维护性的同时让性能损失最小化.
虚拟DOM的性能到底如何?
虚拟DOM 就是为了最小化的找出差异而出现的.
创建页面的性能比较
虚拟 DOM | innerHTML | 计算量 | |
---|---|---|---|
纯 JS 运算 | 创建 JS 对象(VNode) | 渲染 HTML 字符串 | 差距不大,一个数量级 |
DOM 运算 | 新建所有 DOM 元素 | 新建所有 DOM 元素 | 差距不大,一个数量级 |
更新页面的性能比较
虚拟 DOM | innerHTML | 计算量 | |
---|---|---|---|
纯 JS 运算 | 创建新的 JS 对象 + Diff | 渲染 HTML 字符串 | 虚拟 DOM 多一个 diff,但不会产生数量级的差异 |
DOM 运算 性能因素 | 必要的 DOM 元素 与数据变化量相关 | 销毁所有旧 DOM 新建所有新 DOM 与模版大小相关 | 虚拟 DOM 很有优势 |
innerHTML(模版) | < | 虚拟 DOM | < | 原生 JS |
---|---|---|---|---|
心智负担中等 性能差 | 心智负担小 可维护性强 性能不错 | 心智负担大 可维护性差 性能高 |
运行时和编译时
设计一个框架时,有三种选择:纯运行时、运行时 + 编译时、纯编译时。Vue.js 是一个运行时 + 编译时的框架。
React 的核心是在运行时工作的,但它的生态系统包含了许多编译时的工具和流程(例如jsx),使其不是一个纯粹的运行时框架。
过程 | 优缺点 | |
---|---|---|
纯运行时 | 只提供一个Render 函数, Render函数会根据对象递归地将数据渲染成DOM元素. | 由于没有编译的过程,没办法分析用户提供的内容 |
运行时 + 编译时 | 先调用Compiler编译器 把HTML字符串编译成树形结构的数据对象, 再使用Render函数. 它既支持运行时, 用户可以直接提供数据从而无需编译, 又支持编译时,用户可以提供HTML字符串,我们将其编译为数据对象后再交给运行时处理. 因为在运行时才开始编译会产生一定的性能开销,所在在构建时就执行Compiler将用户提供的内容编译好. | 在保持灵活性的基础上能够尽可能的去优化 |
纯编译时 | 将HTML标签直接编译成命令式代码, 只需要Compiler, 连Render都不需要了. | 由于不需要任何运行时,性能可能会更好, 但是这种做法有损灵活性. |
第 2 章 框架设计的核心要素
提升用户的开发体验
提供友好的告警信息,帮助用户快速地定位问题。例如创建一个 Vue.js 应用并试图将其挂载到一个不存在的 DOM 节点时,就会收到一条告警信息。
控制框架代码的体积
用一个变量区分开发环境和生成环境, 它打包时不会出现在最终产物中.
在开发环境中为用户提供良好的警告消息的同时,不会增加生产环境代码的体积.
框架要做到良好的Tree-Shaking
tree-shaking指的是哪些永远不会被执行的代码. 想要实现tree-shaking模块必须是ESM (ES Module) , 因为tree-shaking依赖ESM的静态结构.
框架应该输出怎样的构建产物
Vue.js 的构建产物除了有环境上的区别之外,还会根据使用场景的不同而输出其他形式的产物。
特性开关
在设计框架时,框架会给用户提供诸多特性,可以通过设置某个特性开关为 true 或 false,来开启或关闭对应的特性。好处有下面几点:
-
对于用户关闭的特性,可以利用 Tree-Shaking 机制使其不包含在最终的资源中。
-
可以通过特性开关任意为框架添加新的特性,而不用担心资源体积变大。当框架升级时,也可以通过特性开关来支持遗留 API,这样新用户可以选择不使用遗留 API,从而使最终打包的资源体积最小化。
实现特性开关原理与 DEV 常量一样,本质上利用 rollup.js 的预定义常量插件来实现。
错误处理
框架错误处理机制的好坏直接决定了用户应用程序的健壮性。
若框架只暴露函数,为了应对函数调用出错的情况,用户需要在每个引用的函数中写 try...catch 代码块,因此需要为用户提供统一的错误处理接口。
第 3 章 Vue.js 3 的设计思路
声明式地描述UI
<h1 @click='handler' ><span></span> </h1>
除了使用模版声明式地描述UI外,还可以用JaveScript对象来描述UI. (使用JaveScript对象描述UI更加灵活)
const title = {
tag: 'h1', // 标签名称
props: { // 标签属性
onClick: handler
},
children: [ //子节点
{ tag:'span' }
]
}
对应上面vue的声明式描述
上述只是创建节点,其实渲染器的精髓在更新节
而使用JaveScript对象来描述UI的方式, 其实就是所谓的虚拟DOM.
Vue中h函数的返回值就是一个对象. 作用是让我们编写虚拟DOM更加轻松, h函数就是一个辅助创建虚拟DOM的工具函数.
初识渲染器
渲染器的作用就是把虚拟DOM渲染为真实的DOM
渲染器的总体实现思路大概三步:
1.创建元素 2.为元素添加属性和事件 3.处理 children
// 虚拟 DOM
const vnode = {
tag: 'div', // 标签名称
props: { // 标签的属性、事件等
onClick: () => alert('hello')
},
children: 'click me' // 标签的子节点
}
// 一个简单的渲染器,把上面的虚拟 DOM 渲染成真实的 DOM
/**
vnode:虚拟 DOM 对象
container:一个真实的 DOM 元素,作为挂载点,渲染器会把虚拟 DOM 渲染到该挂载点下
*/
function renderer (vnode, container) {
// 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag)
// 遍历 vnode.props,将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
// 如果 key 以 on 开头,说明它是事件
if (/^on/.test(key)) {
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick 变成 click
vode.props[key] // 事件处理函数
)
}
}
// 处理 children
if (type of vnode.children === 'string') {
// 字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
// 递归地调用 render 函数渲染子节点,使用当前元素 el 作为挂载点(父节点)
vnode.children.forEach(child => render(child, el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}
// 调用渲染器函数
renderer(vnode, document.body) // body 作为挂载点
上述只是创建节点,其实渲染器的精髓在更新节点的阶段(通过 diff 算法寻找并只更新变化的内容)。
组件的本质
组件就是一组DOM元素的封装. 这组DOM元素就是组件要渲染的内容, 组件的返回值也是虚拟DOM
1.定义函数来表达组件
// 描述组件
// 定义一个函数来代表组件, 而函数的返回值就代表组件要渲染的内容
const MyConponents = function () {
return {
tag: 'div', // 标签名称
props: { // 标签属性
onClick: () => alert('hello')
},
children: 'click me' //子节点
}
}
为了能够渲染组件需要渲染器 都支持. 修改renderer函数, 渲染组件和渲染普通标签只多了一步调用函数,返回虚拟DOM
function renderer(vnode, container) {
if(typeof vnode.tag === 'string'){
// 说明vnode描述的是标签元素
mountElement(vnode,container)
} else if(typeof vnode === 'function'){
// 说明vnode描述的是组件
mountComponent(vnode,container)
}
}
mountElement函数与渲染器中renderer函数的内容一致.
function mountComponent(vnode,container) {
// 调用组件函数, 获取组件要渲染的内容(虚拟DOM)
const subtree = vnode.tag()
// 递归地调用renderer 渲染 subtree
renderer(subtree,container)
}
2.也可以使用JavaScript对象来表达组件
// 描述组件
const MyConponents = {
render() {
return {
tag: 'div', // 标签名称
props: { // 标签属性
onClick: () => alert('hello')
},
children: 'click me' //子节点
}
}
}
function renderer(vnode, container) {
if(typeof vnode.tag === 'string'){
mountElement(vnode,container)
} else if(typeof vnode === 'object'){ // 其余步骤同上, 如果是对象,说明vnode描述的是组件
mountComponent(vnode,container)
}
}
function mountComponent(vnode,container) {
const subtree = vnode.tag.render() // vnode 是组件对象, 调用它的render函数得到组件要渲染的内容(虚拟DOM)
renderer(subtree,container)
}
模版的工作原理
编译器: 作用就是将模板编译为渲染函数. (一句话: 就是将template模板转成虚拟DOM)
例如:
<div @click='handler'>
click me
</div>
// 对于编译器来说, 模板(template就是模板)就是一个普通的字符串, 它会分析该字符串并生成一个功能与之相同的渲染函数.
render() {
return h('div',{ onClick:handler }, 'click me')
}
对于一个组件来说,它要渲染的内容最终都会通过渲染函数产生的, 然后渲染器再把渲染函数返回的虚拟DOM渲染为真是的DOM, 这就是模板的工作原理, 也Vue.js渲染页面的过程.
Vue.js 是各个模块组成的有机整体
组件的实现依赖于渲染器,模版的编译依赖于编译器。
**渲染器的作用之一就是寻找并只更新变化的内容,哪些内容可能会发生变化可以由编译器告诉,**因为编译器可以一眼看出模版中哪些内容是不变的,哪些内容是可能发生变化的。
<!-- 模版内容 -->
<!-- 可以直观的知道 id 值是不变的,class 值是根据变量 cls 的值变化的 -->
<div id="foo" :class="cls"></div>
// 编译器将模版编译成渲染函数时会附带信息:哪些是静态属性、哪些是动态属性
render() {
return {
tag: 'div',
props: {
id: 'foo',
class: cls
},
patchFlags: 1 // 假设 1 代表 class 是动态的 对于渲染器来说, 相等与省去了寻找变更点的工作量, 性能自然就提升了.
}
}
-
理解声明式UI为什么引入?有什么好处?
只关注结果, 性能好, 降低心智
-
知道Vuejs中渲染器的作用是什么?
渲染器的作用就是把虚拟DOM渲染为真实的DOM 渲染器的总体实现思路大概三步: 1.创建元素 2.为元素添加属性和事件 3.处理 children
-
了解在Vuejs中是如何理解组件的?
组件就是一组DOM元素的封装. 这组DOM元素就是组件要渲染的内容, 组件的返回值也是虚拟DOM
第 4 章 响应式数据与副作用函数
响应式数据与副作用函数
副作用函数: 指的是会产生副作用的函数.
🌰: effect函数的执行会直接或间接影响其他函数的执行, 这时我们就说effect函数产生否副作用
function effect() {
document.doby.innerText = 'hello Vue3'
}
// 当effect函数执行时, 它回设置文本内容, 但除了effect函数之外的任何函数都可以读取或设置body的内容.
// 副作用函数很容易产生, 例如修改了全局变量
响应式数据:
const obj = { text: 'hello world' }
function effect() {
// effect函数的执行会读取 ojb.text
document.dody.innerText = obj.text
}
obj.text = 'hello Vue3'
// 我们希望当obj.text的值变化后, 副作用函数自动执行,如果实现这个目标 那么对象就是响应式数据.
// 显然还做不到这一点, 因为obj是一个普通对象, 修改它的值除了本事发生变化,不会有任何其他反应
响应式系统的基本实现
如何才能让obj变成响应式数据:
当副作用函数effect执行时, 会触发obj.text的读取操作. 当修改obj.text 的值时, 会触发字段obj.text的设置操作.
如果我们能拦截一个对象的读取和设置操作, 事情就变得简单了. 当读取obj.text的时, 把副作用effect函数存储到一个'桶'里 , 当设置obj.text时, 再把副作用effect函数从'桶'里取出来执行既可.
如何拦截一个对象的读取和设置操作. 在ES2015之前通过Object.defineProperty 函数实现. 也就是Vue2的实现, 在ES2015+使用代理对象Proxy来实现.
// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = { text: 'hello world' }
// 对原始数据代理
const ojb = new Proxy(data,{
// 拦截读取操作
get(target,key){
// 将副作用函数effect添加到存储副作用函数的桶中. effect是写死的
bucket.push(effect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target,key,newValue){
// 设置新的属性值
terget[key] = newValue
// 把副作用函数从桶中取出来并执行
bucket.forEach(fn => fn())
// 返回 true 代表执行成功
return true
}
})
// 副作用函数
function effect() {
document.body.innerText = obj.text
}
// 执行副作用函数, 触发读取
effect()
// 1秒后修改响应式数据
setTimeout(() => {
obj.text = 'hello vue3'
},1000)
设计一个完善的响应系统
从上一个例子中不难看出, 一个响应系统的工作流程如下:
当读取操作发生时, 将副作用函数收集到'桶'中, 当设操作发生时, 从'桶' 中取出副作用函数并执行.
WeakMap的键是原始对象target, WeakMap 的值是一个Map实例, 而Map的键是原始对象target的key, Map的值是由一个副作用函数组成的Set
// 解决两个问题 1. 用来注册副作用函数effect是硬编码 2.给对象添加新的属性,也会触发副作用(没有在副作用函数与被操作的目标字段之间建立明确的联系)
// 提供一个用来注册副作用函数的机制, 哪怕副作用函数是匿名函数.
// 用一个全局变量存储被注册的副作用函数
let activeEffect ;
// effect 函数用于注册副作用函数
function effect(fn){
// 当调用 effect 注册副作用函数时, 将副作用函数 fn 赋值给 activeEffect (主要是记录当前正在执行的副作用函数)
activeEffect = fn
// 执行副作用函数
fn()
}
// 存储副作用函数的桶, 这里为什么要使用WeakMap() 简单来说: WeakMap对key是弱引用, 不影响垃圾回收器的工作.
const bucket = new WeakMap()
const obj = new Proxy(data,{
// 拦截读取操作
get(target,key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(terget, key)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target,key,newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶中取出来并执行
trigger(target,key)
}
})
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target,key){
// 没有activeEffect , 直接return
if(!activeEffect) return
// 根据 target 从'桶'中取得 depsMap , 它是一个Map类型, key -> effects
let depsMap = bucket.get(terget)
// 如果 depsMap 不存在, 那么新建一个 Map 并于 target 关联
if(!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据key 从 depsMap 中取得 deps , 它是 Set 类型, 里边存储着所有与当前 key 相关联的副作用函数 effects
let deps = depsMap.get(key)
// 如果 deps 不存在, 同样新建一个 Set 并于 key 关联
if(!deps){
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到'桶'中 (正在执行的副作用函数)
bucket.add(activeEffect)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target,key) {
// 根据 target 从'桶'中取得 depsMap , 它是一个Map类型, key -> effects
const depsMap = bucket.get(taeget)
if(!depsMap) return
// 根据 key 取得所有副作用函数effects
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}
// WeakMap 与 Map 和 Set 关系图
const WeakMap = {
// key是target, Map 类型
target1:{
// key是各种属性, age, name等
name: new Set([fn1,fn2]), // 各种使用到name的副作用函数
age: new Set([fn3,fn4]) // 各种使用到age的副作用函数
},
target2:{
// key是各种属性, age, name等
name: new Set([fn1,fn2]), // 各种使用到name的副作用函数
age: new Set([fn3,fn4]) // 各种使用到age的副作用函数
},
}
分支切换与cleanup清除
obj.ok ? obj.text : 'not' 当字段 obj.ok 的值发生变化,代码的分支会跟着变化,这就是分支切换。
分支切换指的是在副作用函数执行过程中,由于条件渲染等情况的改变,某些依赖可能不再被执行到。因此,需要从它们所依赖的响应式数据的依赖列表中将它们移除,这个过程就是清理(cleanup)。
-
避免产生遗留的副作用函数:每次副作用函数执行时,先把它从所有与之关联的依赖集合中移除(需要明确哪些依赖集合中包含该副作用函数)。
-
在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,若此时 forEach 遍历没有结束,那么该值会重新被访问,从而导致无限循环。解决办法是构造新的 Set,代替原 Set 来进行遍历操作。
/**
重新设计副作用函数:明确哪些依赖集合中包含该副作用函数
*/
//
let activeEffect
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn)
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
// activeEffect = effectFn:将当前执行的副作用函数设置为activeEffect,这样在fn()执行过程中,如果有响应式数据被访问,track函数可以将activeEffect收集到相应的依赖集合中。
activeEffect = effectFn
fn()
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}function cleanup(effectFn) {
// 遍历 effectFn.deps 数组,其每一项都是一个依赖集合
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i]
// 将 副作用函数 effectFn 从依赖集中移除
deps.delete(effectFn)
}
// 最后需要重制 effectFn.deps 数组
effectFn.deps.length = 0
}// 在 track 函数收集 effectFn.deps 数组中的依赖集合
function track(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
depsMap.set(target, {deps = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
bucket.set(target, {depsMap = new Set()))
}
// 把当前激活的副作用函数添加到依赖集合 deps(deps 就是一个与当前副作用函数存在联系的依赖集合) 中
deps.add(activeEffect)
// 将 deps 添加到 activeEffect.deps 数组中,就完成了对依赖集合的收集
activeEffect.deps.push(deps)
}function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)// 新构造一个 set 集合来遍历:避免无限循环 const effectsToRun = new Set(effects) effectsToRun.forEach(effectFn => effectFn()) /** 在调用 forEach 遍历 Set 集合时,如果一个值已经被访问过了,但该值被删除并重新添加到集合,若此时 forEach 遍历没有结束,那么该值会重新被访问,从而导致无限循环。 */ // 弃用(会导致无限循环:在副作用函数执行前调用 cleanup 将其从相关的依赖(effects)集合中清除;在副作用函数执行时会重新将其收集到集合中,而此时对于 effects 集合的遍历仍在进行) // effects && effects.forEach(fn => fn())
}
嵌套的effect与effect栈
effect可以发生嵌套, 在vue中, 父子组件就是嵌套的effect.
我们用activeEffect来存储effect函数注册的副作用函数,这意味这同一时刻activeEffect所存储的副作用函数只能有一个当副作用函数发生嵌套时, 内层副作用函数的执行会覆盖activeEffect的值, 并且不会恢复到原来的值.
为了解决这个问题, 需要一个副作用函数栈effectStack,在副作用函数执行时,将当前副作用函数, 压入栈中,待副作用函数执行完毕后将其从栈中弹出, 并始终让activeEffect指向栈顶的副作用函数.
/* 嵌套effect
effect(function effectFn1(){
effect(function effectFn2() {})
})
在vue中, 父子组件就是嵌套的effect
effect(() => {
Foo.render()
effect(() => {
Bar.render()
})
})
当修改obj.foo只会触发fn2的执行.
我们用activeEffect来存储effect函数注册的副作用函数,这意味这同一时刻activeEffect所存储的副作用函数只能有一个
当副作用函数发生嵌套时, 内层副作用函数的执行会覆盖activeEffect的值, 并且不会恢复到原来的值.
为了解决这个问题, 需要一个副作用函数栈effectStack,在副作用函数执行时,将当前副作用函数
压入栈中,待副作用函数执行完毕后将其从栈中弹出, 并始终让activeEffect指向栈顶的副作用函数.
*/
let activeEffect;
// effect 栈
const effectStack = [] // 新增
function effect(fn){
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn) // 新增
fn()
// 在当前副作用函数执行完后,将当前副作用函数弹出栈
effectStack.pop() // 新增
// 将activeEffect还原为之前的值 // 新增
activeEffect = effectStack[effectStack.length -1]
}
effectFn.deps = []
effectFn()
}
function cleanup (effectFn){
for (let i = 0; i < effectFn.deps.length; i++) {
const depsSet = effectFn.deps[i]
depsSet.delete(effectFn)
}
effectFn.deps.length = 0
}
const bucket = new WeakMap()
const data = {bar: true, foo: true}
const obj = new Proxy(data,{
// 拦截读取操作
get(target,key) {
track(target,key)
return target[key]
},
// 拦截设置操作
set(target,key,value) {
target[key] = value
trigger(target,key)
}
})
//在 get 拦截函数内调用 track 函数追踪变化
function track(target,key){
if(!activeEffect) return target[key]
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)
}
//在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target,key){
const depsMap = bucket.get(target)
if(!depsMap) return
const effectFns = depsMap.get(key)
const effectsToRun = new Set(effectFns)
effectsToRun.forEach(fn => fn());
}
// 以下新增
let temp1, temp2;
effect(function effectFn1(){
console.log('effectFn1执行了');
effect(function effectFn2(){
console.log('effectFn2执行了');
// 在fn2中读取了bar的值
temp1 = obj.bar
})
// 在fn1中读取foo的值
temp1 = obj.foo
})
避免无限递归循环
effect(() => obj.foo++)
在这个语句中,既会读取foo的值,又会设置foo的值,这是导致问题的根本原因
首先读取foo的值,这会触发track操作,将当前副作用函数收集到桶中, 接着+1再赋值给foo,这时会触发trigger操作,既把桶中的副作用函数取出并执行.
问题是该副作用函数正在执行中,还没有执行完,就要开始下一次的执行,这样就会导致无线递归调用自己,于是导致栈溢出.
解决:
读取和设置是在同一个副作用函数内进行的.此时无论是track收集的副作用函数还是trigger触发的副作用函数都是activeEffect
只需要在trigger函数触发执行方副作用函数与当前正在执行的副作用函数对比,如果一样则不触发执行
let activeEffect;
const effectStack = []
function effect(fn){
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length -1]
}
effectFn.deps = []
effectFn()
}
function cleanup (effectFn){
for (let i = 0; i < effectFn.deps.length; i++) {
const depsSet = effectFn.deps[i]
depsSet.delete(effectFn)
}
effectFn.deps.length = 0
}
const bucket = new WeakMap()
const data = { foo:1 }
const obj = new Proxy(data,{
// 拦截读取操作
get(target,key) {
track(target,key)
return target[key]
},
// 拦截设置操作
set(target,key,value) {
target[key] = value
trigger(target,key)
}
})
//在 get 拦截函数内调用 track 函数追踪变化
function track(target,key){
if(!activeEffect) return target[key]
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)
}
//在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target,key){
const depsMap = bucket.get(target)
if(!depsMap) return
const effectFns = depsMap.get(key)
const effectsToRun = new Set(effectFns)
effectsToRun.forEach(fn => {
if(fn !== activeEffect){ //新增
fn()
}
});
}
effect(() => {
//obj.foo++ //这样写会导致栈溢出 Maximum call stack size exceeded
obj.foo = obj.foo + 1
})
/*
为什么会这样呢?
在这个语句中,既会读取foo的值,又会设置foo的值,这是导致问题的根本原因
首先读取foo的值,这会触发track操作,将当前副作用函数收集到桶中.
接着+1再赋值给foo,这时会触发trigger操作,既把桶中的副作用函数取出并执行
问题是该副作用函数正在执行中,还没有执行完,就要开始下一次的执行,这样就会导致无线递归调用自己,于是导致栈溢出.
解决:
读取和设置是在同一个副作用函数内进行的.此时无论是track收集的副作用函数还是trigger触发的副作用函数都是activeEffect
只需要在trigger函数触发执行方副作用函数与当前正在执行的副作用函数对比,如果一样则不触发执行
*/
调度执行
可调度:当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。
解决调度性:为 effect 函数设计第二个参数 options对象,允许用户指定调度器。调度器的优先级高
let activeEffect;
const effectStack = []
function effect(fn,options = {}){
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length -1]
}
//将options挂载到effectFn上
effectFn.options = options // 新增
effectFn.deps = []
effectFn()
}
function cleanup (effectFn){
for (let i = 0; i < effectFn.deps.length; i++) {
const depsSet = effectFn.deps[i]
depsSet.delete(effectFn)
}
effectFn.deps.length = 0
}
const bucket = new WeakMap()
const data = { foo:1 }
const obj = new Proxy(data,{
// 拦截读取操作
get(target,key) {
track(target,key)
return target[key]
},
// 拦截设置操作
set(target,key,value) {
target[key] = value
trigger(target,key)
}
})
//在 get 拦截函数内调用 track 函数追踪变化
function track(target,key){
if(!activeEffect) return target[key]
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)
}
//在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target,key){
const depsMap = bucket.get(target)
if(!depsMap) return
const effectFns = depsMap.get(key)
const effectsToRun = new Set(effectFns)
effectsToRun.forEach(fn => {
// 如果一个副作用函数存在调度器,则调用调度器,并将副作用函数传递过去
if(fn.options.scheduler){ // 新增
fn.options.scheduler(fn) // 新增
} else {
if(fn !== activeEffect){
fn()
}
}
});
}
// 以下新增
// 用户在调用effect函数时,可以传递第二个参数options, 是一个对象.
effect(() => {
console.log(obj.foo);
},{
// 调度器scheduler
scheduler(fn){
setTimeout(fn)
}
})
obj.foo++
console.log('结束了');
/*
effect(() => {
console.log(obj.foo);
})
obj.foo++
console.log('结束了');
现在打印的是1,2,结束了
假设需求有变,输出顺序调整为1,结束了,2
effect(() => {
console.log(obj.foo);
},{
// 调度器scheduler
scheduler(fn){
setTimeout(fn)
}
})
obj.foo++
console.log('结束了');
现在打印的是1,结束了,2
*/
计算属性computed与lazy
/*
fn才是真正的副作用函数,effectFn是我们包装后的副作用函数.
为了通过effectFn得到真正的副作用函数fn的执行结果,
需要将其保存到res,然后将其作为effectFn函数的返回值
*/
let activeEffect;
const effectStack = []
function effect(fn,options = {}){
const effectFn = () => {
console.log('effectFn执行了');
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
// 将fn函数的执行结果存储到res中
const res = fn() //新增
effectStack.pop()
activeEffect = effectStack[effectStack.length -1]
// 将res作为effectFn的返回值
return res //新增
}
effectFn.options = options
effectFn.deps = []
// 只有非lazy时 才执行
if(!options.lazy){ //新增
effectFn()
}
// 将副作用函数作为返回值返回
return effectFn //新增
}
function cleanup (effectFn){
for (let i = 0; i < effectFn.deps.length; i++) {
const depsSet = effectFn.deps[i]
depsSet.delete(effectFn)
}
effectFn.deps.length = 0
}
const bucket = new WeakMap()
const data = { foo:1, bar:2 }
const obj = new Proxy(data,{
// 拦截读取操作
get(target,key) {
track(target,key)
return target[key]
},
// 拦截设置操作
set(target,key,value) {
target[key] = value
trigger(target,key)
}
})
//在 get 拦截函数内调用 track 函数追踪变化
function track(target,key){
if(!activeEffect) return target[key]
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)
}
//在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target,key){
const depsMap = bucket.get(target)
if(!depsMap) return
const effectFns = depsMap.get(key)
const effectsToRun = new Set(effectFns)
effectsToRun.forEach(fn => {
if(fn.options.scheduler){
fn.options.scheduler(fn)
} else {
if(fn !== activeEffect){
fn()
}
}
});
}
// 定义一个computed函数 //以下新增
function computed(getter){
// value 用来缓存上一次计算的值
let value;
// dirty 标志, 用来标识是否需要重新计算值, 为true需要重新计算
let dirty = true
// 把 getter 作为副作用函数, 创建一个 lazy 的effect
const effectFn = effect(getter,{
lazy:true,
// 添加调度器, 在调度器中将dirty置为true
scheduler() { // 该调度器会在getter函数中所有依赖的响应式数据变化时执行
if(!dirty){
dirty = true
// 当计算属性依赖的响应式数据变化时,手动调用trigger函数触发相应
trigger(obj,'value')
}
}
})
const obj = {
// 当调用value时, 才读取effectFn
get value (){
// 只有为true时,才重新计算,并将得到的值进行缓存
if(dirty){
value = effectFn()
// 将dirty设置为false,下次访问直接使用value缓存中的值
dirty = false
}
// 当读取value时, 手动调用track函数进行追踪
track(obj,'value')
return value
}
}
return obj
}
const sumRes = computed(() => obj.foo + obj.bar)
// 只有读取value时,才会执行 effectFn 并将其结果作为返回值返回
// console.log(sumRes.value);
// console.log(sumRes.value);
// console.log(sumRes.value);
// obj.foo++
// console.log(sumRes.value);
effect(() => {
console.log(sumRes.value);
})
obj.foo++
/*
解决三个问题;
1.计算属性没有缓存
2.计算属性的依赖项发生变化后没有从新触发副作用函数
3.effect函数中调用计算属性
*/
-
什么是响应式数据?什么是副作用函数?
副作用函数: 副作用函数是指是会产生副作用的函数,即函数的执行会直接或间接影响其他函数的执行;
响应式数据: 数据在某个地方被使用,当数据的值发生变化时,使用到该数据的地方能够自动更新数据值,那么该数据就被称为响应式数据
-
响应式数据是怎么实现的?使用weakMap结构是为了解决什么问题?
拦截一个对象的读取和设置操作;当读取操作发生时, 将副作用函数收集到'桶'中, 当设操作发生时, 从'桶' 中取出副作用函数并执行.
WeakMap对key是弱引用, 不影响垃圾回收器的工作.
-
对比proxy与defineProperty有什么优缺点?
defineProperty: 缺点: 数组的这些方法无法触发set: push, pop, shift, unshift,splice, sort, reverse,数组变化时不能直接监听到. 只能对单个属性进行拦截,无法对整个对象进行拦截
proxy: 优点: 可以直接监听整个对象,不用遍历对象属性, 可以直接监听数组的变化, 包含get和set在内有13种拦截方法
对于单个属性的监听,Object.defineProperty可以提供较好的性能.
处理复杂的情况,Object.defineProperty可能需要更多的代码和递归操作来实现同样的功能。
-
为什么响应式数据中会引入分支切换的概念?
根据不同条件执行不同的动作
-
cleanup函数的作用是什么?为什么会需要cleanup函数?
每次副作用函数执行时,先把它从所有与之关联的依赖集合中移除(需要明确哪些依赖集合中包含该副作用函数)
如果没有cleanup函数,会出现不需要的副作用函数仍然保留的情况,执行不必要的副作用函数
-
什么情况下会出现嵌套的effect?effect栈的出现是为了解决什么问题?
当组件发生嵌套时,就会发生了effect 嵌套;
effect栈的出现是为了在出现嵌套effect的场景下,保证一个响应式数据只会收集直接读取其值的副作用函数,避免出现错乱;
-
为什么会出现无限递归循环?无限递归循环是怎么解决的?
effect(() => obj.foo++)
在副作用函数中,如果先读取一个变量,再将这个变量赋值给自身的操作,读取的时候会导致副作用函数被收集,在赋值的时候副作用函数又会被执行,而副作用函数本身也没执行完,就会导致无限递归循环
增加一个判断,如果副作用函数与当前正在执行的副作用函数一样,则不执行这个函数
-
响应式系统中可调度性是指什么?调度器scheduler是怎么实现的呢?
当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。
为 effect 函数设计第二个参数 options对象,允许用户指定调度器。调度器的优先级高
-
什么叫计算属性?计算属性是如何实现的?
计算属性就是当其依赖属性的值发生变化时,这个属性的值会自动变化,与之相关的DOM部分也会同步自动更新;
添加一个lazy选项进行标记,只有lazy不为true的时候执行
-
watch的使用场景时什么?实现原理是什么?watch的第3个参数都支持哪些配置?分别是怎么实现的?
watch的使用场景:观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数
实现原理:利用 effect 以及 options.scheduler;
第3个参数支持的配置
-
立即执行:添加一个immediate字段,判断是否为true,如果为true,立即执行回调函数;否则懒执行回调函数
-
执行时机:flush字段,判断如果为post则放入微任务队列中延迟执行;否则同步执行
-
过期回调:onInvalidate函数,在执行回调函数之前,先执行传入的onInvalidate函数注册过期回调
-
第 5章 非原始值的响应式方案
5.1理解Proxy和Reflect
代理
-
定义:对一个对象基本语义 的代理,允许我们拦截 并重新定义 对一个对象的基本操作(限于此)。
-
基本操作:读取、设置属性值的操作,调用函数。
-
非基本操作/复合操作:调用对象的方法。
Proxy
-
定义/作用:使用 Proxy 可以创建一个代理对象,能够实现对**其他对象(只能代理对象)**的代理。
-
参数:Proxy 构造函数接收两个参数。第一个参数是被代理的对象,第二个参数也是一个对象(一组夹子,其中 get 函数用来拦截读取操作,set 函数用来拦截设置操作)。
-
一个函数也是一个对象, 所以调用函数也是对一个对象的基本操作。
Reflect
- 定义:Reflect 是一个全局对象,其下有许多方法 -> Reflect.get()、Reflect.set()、Reflect.apply()等。
-
联系:任何在 Proxy 的拦截器中能够找到的方法,都能够在 Reflect 中找到同名函数。
-
作用:Reflect 函数可以接收额外参数,即指定接收者 receiver(相当于绑定函数调用时指定的 this),这在响应式系统中很重要。
5.2JaveScript 对象及Proxy的工作原理
-
在 JS 中,对象的实际语义是由对象的内部方法指定的,所谓内部方法,指的是我们对一个对象进行操作时在引擎内部调用的方法,这些方法对于 JS 使用者来说是不可见的。在 ECMAScript 规范中使用[[xxx]]来代表内部方法或内部插槽。
-
内部方法的多态性:不同类型的对象可能部署了相同的内部方法,却具有不同的逻辑。
-
Proxy 创建代理对象时指定的拦截函数,实际上是用来自定义代理对象本身的内部方法和行为的,而不是用来指定被代理对象的内部方法和行为的。
-
如果一个对象需要作为函数调用,这个对象就必须部署内部方法[[Call]],以此来区分普通对象和函数。
-
Proxy 是一个异质对象。
Proxy 对象部署的所有内部方法
内部方法 | 处理器函数 | 描述 |
---|---|---|
[[GetPrototypeOf]] | getPrototypeOf | 查明为该对象提供继承属性的对象, null表示没有继承属性. |
[[SetPrototypeOf]] | setPrototypeOf | 将该对象与提供继承属性的另一个对象相关联. 传递null表示没有继承属性, true成功, false失败. |
[[IsExtensible]] | isExtensible | 是否允许向该对象添加其他属性. |
[[PreventExtensions]] | preventExtensions | 控制能否向该对象添加新属性, 成功true, 失败false. |
[[GetOwnProperty]] | getOwnPropertyDescriptor | 返回该对象自身属性的描述符,键为propertyKey, 不存在则返回undefined. |
[[DefineOwnProperty]] | defineProperty | 创建或更改自己的属性, 键为propertyKey , 如果已经被创建或更新返回true, 无法创建或更新返回false. |
[[HasProperty]] | has | 返回布尔值,表示该对象是否已经拥有键为propertyKey的自己或继承的属性. |
[[Get]] | get | 返回该对象中propertyKey对应的值. 如果必须运行ECMAScript代码来检索属性值,则在代码运行时使用Receiver作为this值. |
[[Set]] | set | 将键值为propertyKey的属性的值设置为value. 如果必须运行ECMAScript代码来检索属性值,则在代码运行时使用Receiver作为this值. 设置成功返回true, 设置失败返回false. |
[[Delete]] | deleteProperty | 从该属性删除属于自身的键为propertyKey的属性. 未被删除返回false, 已被删除或不存在返回true. |
[[OwnPropertyKeys]] | ownKeys | 返回一个list, 其元素都是对象自身的属性键. |
[[Call]] | apply | 将运行的代码与this对象关联. 由函数调用触发. 该内部方法的参数是一个this值和参数列表. |
[[Construct]] | construct | 创建一个对象. 通过new运算符或者super调用触发. 该内部方法的第一个参数是一个list, 该list的原色是构造函数调用或super调用的参数, 第二个参数是最初应用new运算符的对象. 实现该内部方法的对象称为构造函数. |
5.3如何代理Object
响应系统应该拦截一切读取操作, 以便在数据变化时时能够正确地触发响应.
-
代理访问属性 obj.foo
const obj = { foo:1 } const p = new Proxy(obj,{ // 拦截读取操作 get(target,key,receiver) { // 建立联系 track(target,key) // 返回属性值 return Reflect(target,key,receiver) }, })
-
判断对象或原型上是否存在给定的key key in obj
const obj = { foo:1 } const p = new Proxy(obj,{ // 拦截对in操作符的代理 has(target,key){ track(target,key) return Reflect.has(target,key) } }) effect(() => { console.log('foo' in p); // 将会建立依赖关系, 返回true })
-
使用for...in循环遍历对象 for(const key in obj){}
const obj = { foo: 1 } // ITERATE_KEY 是 track 函数追踪的 key(ownKeys拦截时不像 set/get拦截,其没有明确的属性名来拦截) const ITERATE_KEY = Symbol() const p = new Proxy(obj, { ownKeys(target) { // 将副作用函数与 ITERATE_KEY 关联 track(target, ITERATE_KEY) // 使用 Reflect.ownKeys(obj)来获取只属于对象自身拥有的键 return Reflect.ownKeys(target) }, }) effect(() => { // for...in 循环 for (const key in p) { console.log(key) // foo } })
新增、修改、删除属性时(影响 for...in 的循环次数 ),分别需要、不需要(减少性能损耗)、需要触发与 ITERATE_KEY 相关联的副作用函数重新执行,因此需要完善对 object 的代理 和 trigger 函数:
const obj = { foo:1 } // 重复key const ITERATE_KEY = Symbol() const TriggerType = { SET: "SET", ADD: "ADD", DELETE:'DELETE', } const p = new Proxy(obj,{ // 拦截设置操作 set(target,key,newValue,receiver) { // 如果属性不存在, 说明是新增属性, 否则是设置已有属性 const type = Object.prototype.hasOwnProperty.call(target,key) ? TriggerType.SET : TriggerType.ADD // 设置属性值 const res = Reflect.set(target,key,newValue,receiver) // 将Type作为第三个属性传递给trigger函数 trigger(target,key,type) return res }, ownKeys(target) { // 将副作用函数与 ITERATE_KEY关联 track(target,ITERATE_KEY) // ownKeys 返回一个list, 其元素都是对象自身的属性键, 这个操作不与任何具体的键进行绑定, 因此我们构造唯一的key作为标识. return Reflect.ownKeys(target) }, deleteProperty(target,key){ // 先检测被操作的属性是否是对象自己的属性 const hadKey = Object.prototype.hasOwnProperty.call(target,key) // 使用Reflect.deleteProperty 完成属性的删除 const res = Reflect.deleteProperty(target,key) // 只有被删除的属性是自己的属性且成功删除时,才触发更新 if(res && hadKey){ trigger(target,key,TriggerType.DELETE) } return res } }) //在 set 拦截函数内调用 trigger 函数触发变化 function trigger(target,key,type){ const depsMap = bucket.get(target) if(!depsMap) return const effect = depsMap.get(key) const effectsToRun = new Set() // 将与key相关联的副作用函数添加到effectsToRun effect && effect.forEach(fn => { if(fn !== activeEffect){ effectsToRun.add(fn) } }) // 只有当操作类型为ADD或DELETE时, 才触发与ITERATE_KEY相关联的副作用函数重新执行 if(type === TriggerType.ADD || type === TriggerType.DELETE){ // 取得与ITERATE_KEY相关联的副作用函数 const iterateEffects = depsMap.get(ITERATE_KEY) // 将与ITERATE_KEY相关联的副作用函数添加到effectsToRun iterateEffects && iterateEffects.forEach(fn => { if(fn !== activeEffect){ effectsToRun.add(fn) } }) } effectsToRun.forEach(fn => { if(fn.options.scheduler){ fn.options.scheduler(fn) } else { fn() } }); }
5.4合理的触发响应
设置的值没有发生变化时,不需要触发响应 -> 完善 Proxy 构造函数中的 set 函数 判断新值和旧值是否相等,且都不是NaN.
封装 Proxy 构造函数 -> 得到组合式 API 中熟悉的 reactive 方法
屏蔽由原型引起的更新(设置子元素不存在的属性时,会向上找其原型的属性,导致副作用函数执行两次,具体见104~106页) -> 完善 Proxy 构造函数中的 get、set 函数
function reactive(obj) {
return new Proxy(obj,{
// 拦截读取操作
get(target,key,receiver) {
// child 的 set 拦截函数 target是原始对象obj receiver是child
// parent 的 set 拦截函数 target是原始对象proto receiver仍然是是child
// 根据这个区别, 我们只需要判断receiver是否是target的代理对象即可.
// 如果传入的key是raw,就返回原始数据,在set中使用
if(key === 'raw'){
return target
}
// 建立联系
track(target,key)
// 返回属性值
return Reflect.get(target,key,receiver)
},
// 拦截设置操作
set(target,key,newValue,receiver) {
// 拿到旧值
const oldValue = target[key]
// 如果属性不存在, 说明是新增属性, 否则是设置已有属性
const type = Object.prototype.hasOwnProperty.call(target,key) ? TriggerType.SET : TriggerType.ADD
// 设置属性值
const res = Reflect.set(target,key,newValue,receiver)
// target === receiver.raw 说明receiver就是target的代理对象
// if(target === receiver.raw){
// 当前值发生变化,且旧值和新值都不是NaN才更新
if(oldValue !== newValue && ( oldValue === oldValue || newValue === newValue )){
// 将Type作为第三个属性传递给trigger函数
trigger(target,key,type)
}
// }
return res
},
})
}
const obj = {}
const proto = { bar:1 }
const child = reactive(obj)
const parent = reactive(proto)
// 将parent设置为child的原型
Object.setPrototypeOf(child,parent)
effect(() => {
console.log('effect执行了');
/*
child代理的obj中没有bar属性, 会往原型上找
虽然我们操作的是child.bar,但这也会导致parent代理对象的Set拦截函数被执行
*/
console.log(child.bar);
})
// 当读取child.bar的值时,副作用函数不就会被child.bar收集也会被parent.bar收集.
// 两次更新是由于set拦截函数被触发两次导致的,所有我们能够在set拦截函数中区分这两个更新就可以.
child.bar = 3
5.5浅响应和深响应
目前所实现的对 object 的响应是浅响应。
实现深响应?-> 当Reflect.get 返回的结果是对象,则递归地调用 reactive 函数将其包装成响应式对象,再返回
// 实现深响应式
new Proxy(obj,{
get(target,key,receiver) {
if(key === 'raw'){
return target
}
track(target,key)
// 得到原始结果,以const obj = reactive({ foo:{bar:1} })为例得到的是普通对象{bar:1}
const res = Reflect.get(target,key,receiver)
// 判断是否是对象, 如果是对象则递归的调用reactive将其包装成响应式对象并返回
if(typeof res === 'object' || typeof res !== 'null'){
receiver(res)
}
return res
},
},
兼顾深响应和浅响应?-> reactive 函数摇身一变成 createReactive 函数,并完善 Proxy 构造函数中的 get 函数(添加 isShallow 属性),实现深响应(reactive)和浅响应(shallowReactive)时分别调用 createReactive 函数,赋予不同的 isShallow 参数:
// 封装createReactive函数, 接收一个参数isShallow,代表是否为浅响应, 默认为false,即非浅响应
function createReactive(obj,isShallow = false) {
return new Proxy(obj,{
// 拦截读取操作
get(target,key,receiver) {
if(key === 'raw'){
return target
}
track(target,key)
// 得到原始结果,以const obj = reactive({ foo:{bar:1} })为例得到的是普通对象{bar:1}
const res = Reflect.get(target,key,receiver)
// 如果是浅响应直接返回原始值
if(isShallow){
return res
}
// 判断是否是对象, 如果是对象则递归的调用reactive将其包装成响应式对象并返回
if(typeof res === 'object' || typeof res !== 'null'){
receiver(res)
}
return res
},
})
}
// 深响应式
function reactive(obj){
return createReactive(obj)
}
// 浅响应式
function shallowReactive(obj){
return createReactive(obj,true)
}
const obj = reactive({ foo:{bar:1} })
effect(() => {
console.log('effect执行了');
console.log(obj.foo.bar);
})
obj.foo.bar = 99
5.6只读和浅只读
我们希望一些数据是只读的,当用户尝试修改只读数据时,会收到一条警告信息, 这样就是实现了对数据的保护.
实现只读 -> 为 createReactive 函数添加 第三个参数 isReadonly 属性 -> 完善 Proxy 构造函数中的 set 函数(不允许修改)和 deleteProperty(不允许删除):
// 增加第三个参数isReadonly 代表是否只读, 默认为false, 即非只读
function createReactive(obj,isShallow = false, isReadonly = false) {
return new Proxy(obj,{
// 拦截读取操作
get(target,key,receiver) {
if(key === 'raw'){
return target
}
// 非只读才需要建立响应式联系: 如果一个数据是只读的,那就意味着任何方式都无法修改它,因此没必要建立响应式联系
if(!isReadonly){
track(target,key)
}
const res = Reflect.get(target,key,receiver)
// 如果是浅响应直接返回原始值
if(isShallow){
return res
}
if(typeof res === 'object' || typeof res !== null){
// 如果数据是只读是, 则调用readonly对值进行包装
return isReadonly ? readonly(res) : reactive(res)
}
return res
},
// 拦截设置操作
set(target,key,newValue,receiver) {
const oldValue = target[key]
const type = Object.prototype.hasOwnProperty.call(target,key) ? TriggerType.SET : TriggerType.ADD
const res = Reflect.set(target,key,newValue,receiver)
// 如果是只读的, 则打印警告信息并返回
if(isReadonly){
console.warn(`属性${key}是只读的`)
return true
}
if(target === receiver.raw){
if(oldValue !== newValue && ( oldValue === oldValue || newValue === newValue )){
trigger(target,key,type)
}
}
return res
},
deleteProperty(target,key){
// 如果是只读的, 则打印警告信息并返回
if(isReadonly){
console.warn(`属性{key}是只读的`)
return true
}
const hadKey = Object.prototype.hasOwnProperty.call(target,key)
const res = Reflect.deleteProperty(target,key)
if(res && hadKey){
trigger(target,key,TriggerType.DELETE)
}
return res
}
})
}
// // 深响应式
function reactive(obj){
return createReactive(obj)
}
// 浅响应式
function shallowReactive(obj){
return createReactive(obj,true)
}
// 浅只读, 没有做到深只读
function shallowReadonly(obj) {
return createReactive(obj,true,true)
}
// 深只读, 为了实现深只读,我们还应该在get拦截函数内递归地调用readonly将数据包转成只读的代理对象
function readonly(obj) {
console.log('谢谢');
return createReactive(obj,false,true)
}
//
const obj = readonly({ foo: {bar:1} })
effect(() => {
console.log('effect执行了');
console.log(obj.foo = 33);
})
5.7代理数组
数组的索引与length
数组就是一个变异对象, [[ DefineOwnProperty ]] 内部方法与常规对象不同. 其他内部方法与常规对象相同. 因此,当实现对数组的代理时, 用于代理普通对象的大部分代码可以继续使用.
设置元素值时,也可能会隐式地修改length的属性值, 因此在触发响应时,也应该触发length属性相关联的副作用函数执行
const arr = reactive(['foo'])
effect(() => {
console.log('effect执行了');
console.log(arr.length);
})
// 设置索引 1 的值, 会导致数组的长度为 2, 但是现在做不到这一点, 需要修改set拦截函数
arr[1] = 'bar'
// 拦截设置操作
set(target,key,newValue,receiver) {
const oldValue = target[key]
// 这时候key是length属性
// 如果代理目标是数组,则检测被设置的索引值是否小于数组长度,小于是set, 否则是add
const type = Array.isArray(target)
? Number(key) < target.length ? TriggerType.SET : TriggerType.ADD
: Object.prototype.hasOwnProperty.call(target,key) ? TriggerType.SET : TriggerType.ADD
const res = Reflect.set(target,key,newValue,receiver)
...省略
return res
},
// 然后再trigger函数中正确触发与数组对象相关的length属性相关联的副作用函数重新执行
//在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target,key,type){
const depsMap = bucket.get(target)
...省略
// 操作类型是add,且目标对象是数组时, 应该取出并执行那些与length属性相关联的副作用函数
if(type === TriggerType.ADD && Array.isArray(target)){
// 取出 length属性相关联的副作用函数
const lengthEffects = depsMap.get('length')
// 将这些副作用函数添加到effectToRun 中, 待执行
lengthEffects && lengthEffects.forEach((fn) => {
if(fn !== activeEffect){
effectsToRun.add(fn)
}
})
}
...省略
}
反过来思考, 修改length 也会隐式地影响数组元素. 当修改length属性值时,只有那些索引值大于或等于新的length属性值的元素才需要触发响应.
effect(() => {
console.log(arr[0]);
})
// 将数组的长度修改为0, 导致第0个元素被删除, 因此应该触发响应.
// 如果我们将arr.length设置为100, 这并不会影响第0个元素,所有不需要触发副作用函数重新执行
arr.length = 0
// 修改
set(target,key,newValue,receiver) {
...省略
if(target === receiver.raw){
if(oldValue !== newValue && ( oldValue === oldValue || newValue === newValue )){
// 增加第四个参数, 即触发响应的新值
trigger(target,key,type,newValue)
}
}
return res
},
//在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target,key,type){
const depsMap = bucket.get(target)
...省略
// 如果操作的是数组,并且修改了数组的length属性
if(Array.isArray(target) && key === 'length'){
// 对于索引大于或等于新的length值的元素(比如newValue是0, 则把大于0的元素全部重新执行)
// 需要把所有相关联的副作用函数取出来并添加到effectToRun中待执行
depsMap.forEach((effects,key) => {
if(key >= newValue){
console.log(key, newValue);
effects.forEach(fn => {
if(fn !== activeEffect){
effectsToRun.add(fn)
}
})
}
})
}
...省略
}
for...in 循环遍历数组
const arr = reactive(['foo'])
effect(() => {
for (const key in arr) {
console.log(key);//0
// 无论是为数组添加新元素还是修改数组的长度, 本质上都是修改了length属性.
// 可以在ownKeys拦截函数内, 判断当前操作target是否是数组,是则使用length作为key去建立响应联系
}
})
arr[1] = 'bar' // 能够触发副作用函数重新执行
arr.length = 0
// 拦截for...in
ownKeys(target) {
// 如果拦截target的是数组,则使用length属性作为key并建立响应联系
track(target, Array.isArray(target) ? 'length' : ITERATE_KEY)
return Reflect.ownKeys(target)
},
数组的查找方法
-
什么是Proxy?Proxy具有什么特性?
-
Proxy的语法描述?Proxy的作用?
-
什么是Reflect?Reflect具有什么特性?
-
Reflect的语法描述?Reflect的作用?
-
对象读取的拦截方式,
-
访问属性:obj.foo。
-
遍历对象key:key in obj。
-
遍历对象:for in(const key in obj{}。
-
-
Set怎么实现添加新属性,修改已有的属性值区分的?
-
如何代理 delete 操作符
-
合理地触发响应中几种场景分别是这么处理的?
-
值没有发生变化,不需要触发响应怎么实现
-
NaN 的处理
-
为什么可以监听到原型上属性。应该怎么处理
-
-
说说自己对浅响应与深响应是怎么理解的?
-
shallowReactive浅响应怎么实现的?
-
reactive深响应怎么实现的?
-
说说自己对只读与浅只读是怎么理解的?
-
shallowReadonly浅只读怎么实现的?
-
readonly只读怎么实现的?
第 12章 组件的实现原理
-
-
为什么需要组件化?
有了组件,我们就可以将一个大的页面拆成多个部分,每一个部分都可以作为单独的组件, 这些组件共同组成完成的页面.
-
渲染组件的结构是什么?
一个组件必须包含一个渲染函数, 即render函数, 并且渲染函数的返回值应该是虚拟DOM
const MyComponent = { name: 'MyComponent',// 组件名称, 可选 render() { // 组件的渲染函数, 返回值必须是虚拟DOM return { type: 'div', children: '文本内容' } } }
-
渲染器通过mountComponent如何完成组件渲染任务?
function mountComponent(vnode,container,anchor){ // 1.通过vnode获取组件的选项对象(即 vnode.tyepe) const componentOptions = vnode.type // 2.获取组件的渲染函数 const { render } = componentOpetions // 3.执行渲染函数, 获取组件要渲染的内容, 即render函数返回的虚拟DOM const subTree = render() // 4. 最后调用patch函数来挂载组件所描述的内容(即subTree) patch(null,subTree,container,anchor) }
-
-
组件状态与自更新
-
组件自身的状态是如何维护的?
使用data函数来定义组件自身的状态, 通过this访问由data函数返回的状态数据.
-
组件自身状态是如何完成初始化的?
function mountComponent(vnode, container, anchor){ const componentOptions = vnode.type const { render, data } = componentOpetions // 1. 调用 data 函数得到原始数据, 并调用 reactive 函数将其包装成响应式数据 const state = reactive(data()) // 2. 调用 render 函数时, 将 this 设置为 响应式数据state, 从而 render 函数内部可以通过 this 访问组件自身状态数据 const subTree = render.call(state, state) patch(null,subTree,container,anchor) }
-
组件的自更新指的是什么?怎么完成组件的自更新?
当组件自身状态发生变化时, 要触发组件更新. 即组件的自更新.
// 将render 函数调用包装到effect内,即可完成组件的自更新. effect(() => { const subTree = render.call(state,state) patch(null, subTree, contaioer, anchor) })
-
为什么需要调度器?调度器是如何实现的?
因为effect是同步执行的,如果多次修改响应式数据的值,将会导致渲染函数执行多次,这实际上是没有必要的. 避免多次执行副作用函数带来的性能开销
所有需要一个调度器, 当副作用函数需要重新执行时, 我们不会立即执行它, 而是将它缓存到一个微任务队列,等到执行栈清空后,再从微任务取出并执行.
// 任务缓存队列,用一个 Set 数据结构来表示,这样就可以自动对任务进行去重 const queue = new Set(); // 一个标志,代表是否正在刷新任务队列 let isFlushing = false; // 创建一个立即 resolve 的 Promise 实例 const p = Promise.resolve(); // 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列 function queueJob(job) { // 将 job 添加到任务队列 queue 中 queue.add(job); // 如果还没有开始刷新队列,则刷新之 if (!isFlushing) { // 将该标志设置为 true 以避免重复刷新 isFlushing = true; // 在微任务中刷新缓冲队列 p.then(() => { try { // 执行任务队列中的任务 queue.forEach((job) => job()); } finally { // 重置状态 isFlushing = false; queue.clear = 0; } }); } } effect(() => { const subTree = render.call(state,state) patch(null, subTree, contaioer, anchor) },{ // 指定该副作用函数的调度器为 queueJob 即可 scheduler: queueJob })
-
-
组件实例与组件的生命周期
-
组件实例是什么?
组件实例本质上就是一个状态集合(或一个对象), 它维护着组件运行过程中的所有信息, 例如组件的生命周期函数,组件渲染的子树,组件是否已经挂载,组件自身状态等.
const instance = { state, // 组件自身状态, 即data isMounted: false, // 表示组件是否挂载, subTree: null // 组件所渲染的内容, 即字数, 初始时是null,以后每次渲染都会记录 }
-
组件生命周期钩子是怎么注册并执行的?
function mountComponent(vnode, container, anchor){ const componentOptions = vnode.type // 从组件选项对象中取得组件的生命周期函数 const { render, data, beforeCreate, created, beforeMount, mounted, beforeUpdata, updated } = componentOptions // 在这里调用 beforeCreate 钩子 beforeCreate && beforeCreate() const state = reactive(data()) const instance = { state, isMounted: false, subTree: null } vnode.component = instance // 在这里调用 created 钩子 created && created.call(state) effect(() => { const subTree = render.call(state,state) if(!instance.isMounted){ // 在这里调用 beforeMount 钩子 beforeMount && beforeMounted.call(state) pathc(null, subTree, container, anchor) instance.isMounted = true // 在这里调用 mounted 钩子 mounted && moueted(state) } else { // 在这里调用 beforeUpdate 钩子 beforeUpdate && beforeUpdate.call(state) patch(instance.subTree, subTree, container, anchor) // 在这里调用 updated 钩子 updated && updated.call(state) } instance.subTree = subTree },{ scheduler: queueJob }) }
-
-
props与组件的被动更新
-
如何解析得到组件渲染时的props数据?
将组件选项中定义的 MyComponent.props 对象和为组件传递的 vnode.props 对象相结合, 最终解析出组件在渲染时,需要使用的 props 和 attrs 数据.
// 关于props的内容需要关心: 1.为组件传递的props数据, 2.组件选项对象中定义的props选项 function mountComponent(vnode, container, anchor){ const componentOptions = vnode.type // 从组件选项对象中取得 props const { render, data, props: propsOption /* 其他省略 */ } = componentOptions beforeCreate && beforeCreate() const state = reactive(data()) // 调用 resolveProps 函数解析出最终的 props 数据与 attrs 数据 const [ props, attrs ] = resolveProps(propsOption,vnode.props) const instance = { state, // 将解出的 props 数据包装成 shallowReactive 并定义到组件实例上 props: shallowReactive(props), } vnode.component = instance // 省略部分代码 } // resolveProps 函数用于解析组件 props 和 attrs数据 function resolveProps(options, propsData){ const props = {} const attrs = {} // 遍历为组件传递的 props 数据 for(const key in propsData){ if(key in options){ // 如果为组件传递的 props 数据在组件自身的props 选项中有定义,则是合法的 props props[key] = propsData[key] } else { attrs[key] = propsData[key] } } // 最后返回 props 和 attrs 数据 return [ props, attrs ] }
-
什么叫做子组件的被动更新?
由父组件自更新所引起的子组件更新叫做子组件的被动更新.
1.检测子组件是否真的需要更新, 因为子组件的 props 可能是不变的. 2 如果需要更新, 则更新子组件的 props,slots 等内容
/* 下面是组件被动更新的最小实现: 1.需要将组件实例添加到新的组件vnode对象上, 即 n2.component = n1.component 否则下次更新时将无法取得组件实例 2.instance.props 对象本身是浅响应的, 因此,在更新组件的 props 时, 只需要设置 instance.props对象下的属性既可触发组件重新渲染 */ function patchComponent(n1, n2, anchor) { // 获取组件实例,即 n1.component,同时让新的组件虚拟节点 n2.component 也指向组件实例(连续赋值), 用处是复用旧的组件实例 const instance = (n2.component = n1.component) // 获取当前的 props 数据 const { props } = instance // 调用 hasPropsChanged 检测为子组件传递的 props 是否发生变化,如果没有变化,则不需要更新 if (hasPropsChanged(n1.props, n2.props)) { // 调用 resolveProps 函数重新获取 props 数据 const [ nextProps ] = resolveProps(n2.type.props, n2.props) // 更新 props for (const k in nextProps){ props[k] = nextProps[k] } // 删除不存在的 props for (const k in props){ if ( !(k in nextProps) delete props[k]) } } } // 判断子组件传递的 props 是否发生变化 function hasPropsChanged(prevProps,nextProps){ const nextKeys = Object.keys(nextProps) // 如果新旧 props 的数量变了,则说明有变化 if (nextKeys.length !== Object.keys(prevProps).length) { return true } for (let i = 0; i < nextKeys.length; i++){ const key = nextKey[i] // 如果上一个props 和 下一个 props, 有不相等的 props 说明有变化 if(nextProps[key] !== prevProps[key]) return true } return false }
-
子组件的被动更新是如何实现的?
// 由于 props 数据与组件自身的状态数据都需要暴露到渲染函数 中,并使得渲染函数能够通过 this 访问它们,因此我们需要封装一 个渲染上下文对象 function mountComponent(vnode, container, anchor) { // 省略部分代码 const instance = { state, props: shallowReactive(props), isMounted: false, subTree: null } vnode.component = instance // 创建渲染上下文对象,本质上是组件实例的代理 const renderContext = new Proxy(instance, { get(t, k, r){ // 取得组件自身状态与 props 数据 const { state, props } = t // 先尝试读取自身状态数据 if (state && k in state) { return state[k] } else if (k in props) { // 如果组件自身没有该数据,则尝试从 props 中读取 return props[k] } else { console.log('不存在') } }, set(t,k, v,r){ const { state, props } = t if(state && k in state){ state[k] = v } else if(k in props){ console.warn(`Attempting to mutate prop "${k}". Props are readonly`) } else { console.log('不存在') } } }) // 生命周期函数调用时要绑定渲染上下文对象 created && created.call(renderContext) // 省略部分代码 } /* 在上面这段代码中,我们为组件实例创建了一个代理对象,该对 象即渲染上下文对象。它的意义在于拦截数据状态的读取和设置操作, 每当在渲染函数或生命周期钩子中通过 this 来读取数据时,都会优先从组件的自身状态中读取, 如果组件本身并没有对应的数据, 则再从 props 数据中读取。最后我们将渲染上下文作为渲染函数以及 生命周期钩子的 this 值即可。 */
-
-
setup函数的作用与实现
-
在vue3中setup函数的作用是什么?
主要用于配合组合式API, 为用户提供一个地方, 用于建立组合逻辑, 创建响应式数据,创建通用函数, 注册生命周期钩子等能力.
setup函数只会在挂载时执行一次,返回值有两种: 1.函数, 该函数作为组件的render函数. 2. 对象, 该对象的数据将暴漏给模板使用.
-
setup函数的相关使用了解
// setup 函数接收两个参数。第一个参数是 props 数据对象,第二个参数也是一个对象,通常称为 setupContext setup(props, setupContext) { // 没有显式地声明为 props 的属性会存储到 attrs 对象中 // expose 显式地对外暴露组件数据 const { slots, emit, attrs, expose } = setupContext }
-
setup函数是怎么实现的?
function mountComponent(vnode, container, anchor) { const componentOptions = vnode.type let { render, data, setup, /* 省略其他选项 */ } = componentOptions // 取出setup beforeCreate && beforeCreate() const state = data ? reactive(data()) : null const [props, attrs] = resolveProps(propsOption, vnode.props) const instance = { state, props: shallowReactive(props), isMounted: false, subTree: null } // 新增行↓ // setup的第二个参数 const setupContext = { attrs } // 调用setup函数, 将只读的 props 作为第一个参数传递, 避免用户意外的修改 props 的值, 将setupContext作为第二个参数传递 const setupResult = setup(shallowReadonly(instance.props), setupContext) // 用来存储 setup 返回的结果 let setupState = null if(typeof setupResult === 'function'){ // 报告冲突 if(render){ console.log('setup 函数返回渲染函数,render 选项将被忽略') } // 将 setupResult 作为渲染函数 render = setupResult } else { // 如果 setup 的返回值不是函数, 则作为数据状态赋值给 setupState setupState = setupResult } // 新增行↑ vnode.component = instance // 创建渲染上下文对象,本质上是组件实例的代理 const renderContext = new Proxy(instance, { get(t, k, r){ // 取得组件自身状态与 props 数据 const { state, props } = t // 先尝试读取自身状态数据 if (state && k in state) { return state[k] } else if (k in props) { // 如果组件自身没有该数据,则尝试从 props 中读取 return props[k] } else if (setupState && k in setupState) { // 新增行 // 渲染上下文需要增加对 setupState 的支持 return setupState[k] } else { console.log('不存在') } }, set(t,k, v,r){ const { state, props } = t if(state && k in state){ state[k] = v } else if(k in props){ console.warn(`Attempting to mutate prop "${k}". Props are readonly`) } else if (setupState && k in setupState) { // 新增行 // 渲染上下文需要增加对 setupState 的支持 setupState[k] = v } else { console.log('不存在') } } }) // 生命周期函数调用时要绑定渲染上下文对象 created && created.call(renderContext) // 省略部分代码 }
-
-
组件事件与emit的实现
-
emit的作用是什么?
emit 用来发射组件的自定义事件
自定义事件的本质就是根据事件名称去props 数据对象中寻找对应的事件处理函数并执行
-
emit是怎么实现的?
// 整个实现并不复杂, 只需要实现一个 emit 函数并将其添加到 setupContext 对象中, 这样就可以通过 setupContext 取得 emit 函数了. function mountComponent(vnode, container, anchor) { // 省略部分代码 // 定义 emit 函数, 它接收两个参数: event: 事件名称, payload: 传递给事件处理函数的参数 function emit (event, ...payload) { // 根据约定对事件名称进行处理, 例如change => onChange const eventName = `on${evnet[0].toUpperCase() + event.slice(1)}` // 根据处理后的事件名称去 props 中寻找对应的事件处理函数 const handler = instance.props[eventName] if(handler){ // 调用事件处理函数并传递参数 handler(...payload) } else { console.log('事件不存在') } } // 将 emit 函数添加到 setupContext 中, 用户可以同 setupContext 取得 emit 函数 const setupContext = { attrs, emit } // 省略部分代码 } // 通过检测 propsData 的 key 值来判断它是否 以字符串 'on' 开头,如果是,则认为该属性是组件的自定义事件。 // 这时,即使组件没有显式地将其声明为 props,我们也将它添加到最 终解析的 props 数据对象中 function resolveProps(options, propsData){ const props = {} const attrs = {} for (const key in propsData) { // 以字符串 on 开头的 props , 无论是否显式地声明,都将其添加到 props 数据中, 而不是添加到 attrs 中 if(key in options || key.startsWith('on)){ props[key] = propsData[key] } else { attrs[key] = propsData[key] } } return [ props, attrs ] }
-
-
插槽的工作原理与实现
-
什么是插槽?插槽的作用是什么?
组件的插槽指组件会预留一个槽位, 该槽位具体要渲染的内容有用户插入.
组件模板中的插槽内容会被编译为插槽函数, 而插槽函数的返回值就是具体的插槽内容.
-
插槽是怎么实现的?
// 最基本的 slots 的实现非常简单, 只需要将编译好的 vnode.children 作为 slots 对象,然后将 slots 对象添加到 setupContext 对象中. function mountComponent(vnode, container, anchor) { // 省略部分代码 // 直接使用编译好的 vnode.children 对象作为 slots 对象即可 const slots = vnode.children || {} // 将 slots 对象添加到 setupContext 中 const setupContext = { attrs, emit, slots } } // 为了在 render 函数内和生命周期钩子函数内通过 this.$slots来访问插槽内容,需要在renderContext中特殊对待 $slots 属性. function mountComponent(vnode, container, anchor) { // 省略部分代码 const slots = vnode.children || {} const instance = { slots, // 将插槽添加到组件实例上 } const renderContext = new Proxy(instance, { get(t, k, r){ const { state, slots, props } = t // 当 k 的值为 $slots 时, 直接返回组件实例上的 slots if(k === '$slots'){ return slots } } }) }
-
-
注册生命周期
第 13章 内建组件和模块
-
异步组件要解决的问题
-
什么是异步组件?
以异步的方式加载并渲染一个组件. (组件的代码可以在需要时才加载,而不是在应用启动时一次性加载所有组件。)// /
// 异步渲染 <template> <CompA /> <component :is="asyncComp" /> </template> <script setup> import { shallowRef } from 'vue' import CompA from 'CompA.vue' const asyncComp = shallowRef(null) // 异步加载 CompB 组件 import('CompB.vue').then(CompB => asyncComp.value = CompB) </script> // 使用vue中的异步组件 import { defineAsyncComponent } from 'vue'; const AsyncComp = defineAsyncComponent(() => import('./CompA'));
-
异步组件要解决什么问题 ?
1.懒加载: 异步组件的主要目的是允许将组件的代码分割出来,并在需要时才加载,而不是在应用启动时就加载所有的代码。
2.提高性能: 将应用拆分成多个小块并按需加载,可以显著提高页面加载速度,减少首屏加载时间,从而提升用户体验。
3.减少打包体积: 异步组件可以帮助减少单个文件的大小,因为它们允许将大的应用拆分成小的代码块。这些小的代码块作为单独的文件被加载,可以有效地降低初始加载所需的带宽。
-
-
异步组件的实现原理
-
封装defineAsyncComponent函数
-
为什么要引入defineAsyncComponent?
为了实现一个完善的异步组件, 为异步组件提供更好的封装支持,提供以下能力
1.允许用户指定加载出错时要渲染的组件
2.允许用户指定 Loading 组件, 以及展示该组件的延迟时间
3.允许用户设置加载组件的超时时长.
4.组件加载失败时候, 为用户提供重试的能力
-
defineAsyncComponent是怎么实现的?
// 定义异步组件 <template> <AsyncComp /> </template> <script setup> import { defineAsyncComponent } from 'vue' // 使用 defineAsyncComponent 定义一个异步组件,它接收一个加载器作为参数 const AsyncComp = defineAsyncComponent(() => import('CompA')) </script> // defineAsyncComponent 函数用于定义一个异步组件, 接收一个异步组件加载器作为参数 ※1. 本质上是一个高阶函数, 返回一个包装组件 function defineAsyncComponent(loader){ // 一个变量, 存储异步加载的组件,所有它是大写开头 let InnerComp = null // return { name: 'AsyncComponentWrapper', setup() { // 异步组件是否加载成功 const loaded = ref(false) // 执行加载器函数, 返回一个 Promise 实例,加载成功后,将成功的组件赋值给 InnerComp ,并将 loaded 设置为true,表示加载成功 loader().then( c => { InnerComp = c loaded.value = true }) return () => { // ※2.如果异步组件加载成功, 则渲染该函数, 否则渲染一个占位内容 return loaded.value ? { type: InnerComp } : { type: Text, children: '' } } } } }
-
-
超时与Error组件
-
为什么需要 Error 组件?
在加载异步组件时,可能会因为网络问题或其他原因导致组件加载失败。Error 组件提供一个友好的用户提示界面。
-
Error 组件是怎么实现的?
渲染 Error 组件
// 首先重新设计接口, 让用户指定超时时长, defineAsyncComponent 函数需要接收一个配置对象作为参数 const AsyncComp = defineAsyncComponent({ //新增 loader: () => import('CompA.vue'),// 加载器 timeout: 2000, // 超时时长, 单位是 ms errorComponent: MyErrorComp // 指定一个 Error 组件, 错误发生时候会渲染它 }) // defineAsyncComponent 的具体实现 function defineAsyncComponent(option){ // options 可以是配置项, 也可以是加载器 新增 if( typeof options === 'function' ){ options= { // 如果 options 是加载器, 将其赋值 loader , 作为配置项的一个属性 loader: options } } const { loader } = options let InnerComp = null return { name: 'AsyncComponentWrapper', setup() { const loaded = ref(false) // 1. 表示是否超时, false 即没有超时 const timeout = ref(false) loader.then((c) => { InnerComp = c loaded.value = true }) let timer = null // 新增 if( options.timeout ){ // 2. 如果传递了超时时长, 则开启一个定时器 timer = setTimeout(() => { // 超出时间后, 将 timeout 设置为 true timeout.value = true }, options.timeout) } // 卸载时, 清除定时器 onUmouted(() => clearTimeout(timer)) const placeholder = { type: Text, children: '' } return () => { if(loaded.value){ // 如果组件异步加载成功, 则渲染加载的组件 return { type: InnerComp } } else if( timeout.value ){ // 3. 如果加载超时, 且指定了 Error 组件, 则渲染该组件 新增 return options.errorComponent ? { type: options.errorComponet } : placeholder } return placeholder } } } }
除渲染 Error 组件, 还需要将 poprs 传递出去和处理其他加载错误
function defineAsyncComponent(option){ if( typeof option === 'function' ){ option = { loader: options } } const { loader } = options let InnerComp = null return { name: 'AsyncComponentWrapper', setup() { const loaded = ref(false) // 定义 error , 当错误发生时, 存储错误对象 新增 const error = shallowRef(null) const timeout = ref(false) loader.then((c) => { InnerComp = c loaded.value = true }) // 添加 catch 捕获加载过程中的错误 新增 .catch((err) => error.value =err) let timer = null if( options.timeout ){ timer = setTimeout(() => { // 超时后,创建一个错误对象, 并赋值给 error 新增 const err = new Error(`事件解释异步组件超时${options.timeout}ms.`) error.value = err }, options.timeout) } onUmouted(() => clearTimeout(timer)) const placeholder = { type: Text, children: '' } return () => { if( loaded.value ){ return { type: InnerComp } } else if( error.value && options.errorComponent ){ //新增 // 当存在错误且配置了 errorComponent 才展示 Error 组件, 同时将 error 作为 props 传递 return { type: options.errorComponent, props: { error: error.value } } } return placeholder } } } }
-
-
延迟与Loading组件
-
为什么需要Loading组件?
异步加载组件可能很慢, 通过展示 Loading 组件来提供更好的用户体验。
-
Loading组件是怎么实现的?
通常,我们会从加载开始的那一刻起就展示 Loading 组件。但在网络状况良好的情况下,异步组件的加载速度会非常快,这会导致 Loading 组件刚完成渲染就立即进入卸载阶段,于是出现闪烁的情况。对于用户来说这是非常不好的体验。因此,我们需要为 Loading组件设置一个延迟展示的时间。例如,当超过 200ms 没有完成加载,才展示 Loading 组件。这样,对于在 200ms 内能够完成加载的情况来说,就避免了闪烁问题的出现。
// 首先设计接口 defineAsyncComponent({ loader: () => new Promise(r => { /*.......*/ }), delay: 200, // 指定延迟时长, 200ms 后展示 Loading 组件 loadingComponent: { // 类似 Error 组件, 用来配置 Loading 组件 setup() { return () => { return { tyoe: 'h2', children: 'Loading' } } } } }) // defineAsyncComponent 的具体实现有 Loading 功能 function defineAsyncComponent(options){ if( typeof options === 'function' ){ options = { loader: options } } const { loader } = options let InnerComp = null return { name: 'AsyncComponentWrapper', setup() { const loaded = ref(false) const error = shallowRef(null) // 1. 是否正在加载 新增↓ const loading = ref(false) let loadingTimer = null // 2. 如果配置项存在 delay , 开启定时器,当延迟到时后将 loading 设置为 true if( options.delay ){ loadingTimer = setTimeout(() => { loading.value = true }, options.delay) } else { // 如果配置项没有 delay, 直接标记加载中 loading.value = true } // 新增↑ loader.then((c) => { InnerComp = c loaded.value = true }) .catch((err) => error.value =err) .finally(() => { // 新增 // 3. 加载完毕后, 无论成功还是失败都要关闭 loading , 并清除 延迟定时器 loading.value = false clearTimeout(loadingTimer) }) let timer = null if( options.timeout ){ timer = setTimeout(() => { const err = new Error(`事件解释异步组件超时${options.timeout}ms.`) error.value = err }, options.timeout) } onUmouted(() => clearTimeout( timer )) const placeholder = { type: Text, children: '' } return () => { if( loaded.value ){ return { type: InnerComp } } else if( error.value && options.errorComponent ){ return { type: options.errorComponent, props: { error: error.value } } } else if( loading.value && options.loadingComponent ){ // 新增 // 4. 如果异步组件正在加载, 并且用户指定了 loading 组件, 则渲染 loading 组件 return { type: options.loadingComponent } } else { return placeholder } } } } } // 当异步组件加载成功后,需要卸载 loading 组件并渲染异步组件. 为了支持 loading 组件的卸载, 需要修改 unmount 函数 functin unmount(vnode){ if( vnode.type === Fragment ){ vnode.children.forEach(c => unmount(c)) } else if( typeof vnode.type === 'object' ){ // 对于组件的卸载,本质上是要卸载组件所渲染的内容, 即 subTree umount( vnode.component.subTree ) reutrn } const parent = vnode.el.parentNode if( parent ){ parent.removeChild(vnode.el) } } /* 卸载虚拟节点对应的真实 DOM 节点的功能。 对于 Fragment 类型,递归卸载其所有子节点; 对于组件类型,卸载其渲染的子树; 对于普通元素节点,则直接从父节点中移除对应的 DOM 节点。 */
-
-
重试机制
-
重试是指什么?
重试指的是当加载出错时,有能力重新发起加载组件的请求。
-
为什么需要重试机制?
提升用户的开发体验
-
重试机制是如何实现的?
// 封装一个 fetch 函数, 模拟接口请求 function fetch () { return new Promise((resolve, reject) => { setTimeout(() => { // 请求在 1 秒后失败 reject('err') }, 1000) }) } // 为了实现失败后的重试, 封装一个 load 函数 // 接收一个 onError 回调函数 function load(onError){ // 请求接口, 得到 Promise 实例 const p = fetch() // 捕获错误 return p.catch(err => { // 当错误发生时, 返回一个新的 Promise 实例, 并调用 onError 回调 // 同时将 retry 函数作为 onError 回调的参数. return new Promise((resolve,reject) => { // retry 函数用来执行重试的函数, 执行该函数会重新调用 load 函数并发生请求 const retry = () => resolve(load(onError)) const fail = () => reject(err) onError(retry, fail) }) }) } fa // 调用 load 函数加载资源 load(() => { retry() }).then(res => { console.log(res) }) // defineAsyncComponent 的具体实现有 重试 功能 function defineAsyncComponent(options){ if(typeof options === 'function'){ options = { loader: options } } const { loader } = options let InnerComp = null // 记录重试次数 新增↓ let retries = 0 // 封装 load 函数用来加载异步组件 function load() { return loader() // 捕获加载器的错误 .catch(err => { // 如果用户指定了 onError 回调, 则将控制权交给用户 if(options.onError){ return new Promise((resolve,reject) => { // 重试 const retry = () => { resolve(load()) retries++ } // 失败 const fail = () => reject(err) // 作为 onError 回调函数的参数, 让用户觉得下一步怎么做 options.onError(retry, fail, retries) }) } else { throw error } }) } // 新增↑ return { name: 'AsyncComponentWrapper', setup() { const loaded = ref(false) const error = shallowRef(null) const loading = ref(false) let loadingTimer = null if(options.delay){ loadingTimer = setTimeout(() => { loading.value = true }, options.delay) } else { loading.value = true } // 调用 load 加载组件 load.then((c) => { InnerComp = c loaded.value = true }) .catch((err) => error.value =err) .finally(() => { loading.value = false alearTimeout(loadingTimer) }) let timer = null if(options.timeout){ timer = setTimeout(() => { const err = new Error(`事件解释异步组件超时${options.timeout}ms.`) error.value = err }, options.timeout) } onUmouted(() => clearTimeout(timer)) const placeholder = { type: Text, children: '' } return () => { if(loaded.value){ return { type: InnerComp } } else if(error.value && options.errorComponent){ return { type: options.errorComponent, props: { error: error.value } } } else if(loading.value && options.loadingComponent){ return { type: options.loadingComponent} } else { return placeholder } } } } }
-
-
-
函数式组件
-
什么是函数式组件?
以普通函数的方式加载并渲染一个组件, 返回值是虚拟DOM.
特点: 无状态, 编写简单且直观
在 Vue2 中相比有状态组件来说, 函数式组件具有明显的性能优势. 但是 Vue3 中,性能差距不大.
function MyFuncComp(props){ return { type: 'h1', children: props.title } } // 函数式组件没有自身状态, 但它仍然可以接收由外部传入的 props . 为了给函数式组件定义 props , 需要添加静态的 props MyFuncComp.props = { title: String } vue2中函数式组件为什么比有状态组件性能好?
-
函数式组件是如何实现的?
/* 在有状态组件的基础上, 实现函数式组件非常简单, 因为挂载组件的相关逻辑可以复用 mountComponent 函数, 因此在 patch 函数内需要支持函数类型的 vnode.type */ function patch(n1, n2, container, anchor) { if( n1 && n1.type !== n2.type ){ unmount(n1) n1 = null } const { type } = n2 if(typeof type === 'string'){ } else if(type === Text){ } else if(type === Fragment){ } else if (typeof type === 'object' || typeof type === 'function'){ // 新增 // type 是对象为有状态组件, Type 是函数为函数式组件 if(!n1){ mountComponent(n2, container, anchor) } else { patchComponent(n1, n2, anchor) } } }
-
第 14章 内建组件和模块
KeepAlive 组件的实现原理
1.1 组件的激活与失活
Vue.js 内建的 KeepAlive 组件可以避免一个组件被频繁地销毁/重建
//根据变量 currentTab 值的不同,会渲染不同的 <Tab> 组件。当用户频繁地切换 Tab 时,会导致不停地卸载并重建对 应的 <Tab> 组件。
<template>
<Tab v-if="currentTab === 1">...</Tab>
<Tab v-if="currentTab === 2">...</Tab>
<Tab v-if="currentTab === 3">...</Tab>
</template>
//无论用户怎样切换 <Tab> 组件,都不会发生频繁的创建和 销毁,因而会极大地优化对用户操作的响应,尤其是在大组件场景下,优势会更加明显。
<template>
<!-- 使用 KeepAlive 组件包裹 -->
<KeepAlive>
<Tab v-if="currentTab === 1">...</Tab>
<Tab v-if="currentTab === 2">...</Tab>
<Tab v-if="currentTab === 3">...</Tab>
</KeepAlive>
</template>
KeepAlive 的组件从原容器搬运到另外一个隐藏的容器中,实现"假卸载"。当被搬运到隐藏容器中的组件需要再次被"挂载"时,我们也不能执行真正的挂载逻辑,而应该把该组件从隐藏容器中再搬运到原容器。这个过程对应到组件的生命周期,其实就是 activated 和 deactivated。

如图所示,"卸载"一个被 KeepAlive 的组件时,它并不会真的被卸载,而会被移动到一个隐藏容器中。当重新"挂载"该组件时,它也不会被真的挂载,而会被从隐藏容器中取出,再"放回"原来的容器中,即页面中。
一个最基本的 KeepAlive 组件实现
const KeepAlive = {
// KeepAlive 组件独有的属性,用作标识
_isKeepAlive: true,
setup( props, { slots } ){
// 创建一个缓存对象 { key: 组件(vnode.type), value: vnode } 为什么是 Map , 因为 map 的值可以是任意类型
const cache = new Map()
// 当前 KeepAlive 组件的实例
const instance = currentInstance
// 对于 KeepAlive 组件来说, 它的实例存在特殊的 KeepAliveCtx 对象, 该对象有渲染器注入,
// 该对象暴漏渲染器的内部方法, move 函数用来将一段 DOM移动到另一个容器中
const { move, createElement } = instance.keepAliveCtx
// 创建隐藏容器
const storageContainer = createElement('div)
// 失活的本质就是将组件所渲染的内容移动到隐藏容器中,而激活的本质是将组件所渲染的内容从隐藏容器中搬运回原来的 容器。
// 失活
instance._deActivate = ( vnode ) => {
move( vnode, storageContainer )
}
// 激活
instance._activeate = ( vnode, container, anchor) => {
move( vnode, container, anchor )
}
return () => {
// KeepAlive 的默认插槽就是要被 KeepAlive 的组件
let rawVNode = slots.default()
// 如果不是组件,直接渲染即可,因为非组件的虚拟节点无法被 KeepAlive
if( typeof rawVNode.type !== 'Object' ) {
return rawVNode
}
// 挂载时先获取缓存组件的 vnode
const cachedVNode = cache.get( rawVNode.type ) // rawVNode.type 是每个组件
if( cachedVNode ) {
// 如果有缓存的内容, 说明不应该挂载, 而应该执行激活
// 继承组件实例
rawVNode.component = cachedVNode.component
// 在 vnode 上添加 kepVNode 属性, 标记 true, 避免渲染器重新挂载它
rawVNode.keptAlive = true
} else {
// 如果没有缓存, 则添加到缓存中,下次激活时候就不会执行挂载了
cache.set( rawVNode.type, rawVNode )
}
// 在 vnode 上添加 shouldKeepAlive 属性, 标记 true, 避免渲染器卸载它
rawVNode.shouldKeepAlive = true
// 将 KeepAlive 组件的实例添加到 vnode 上, 以便在渲染器中( unmount 函数中)访问
rawVNode.keepAliveInstance = instance
return rawVnode
}
}
}
/*
省流版:
1.挂载时先看该组件有没有缓存, 有缓存说明不应该挂载,而是应该激活( kepVNode 字段避免重新挂载)
2.如果没有缓存, 则添加到缓存中, 下次激活的时候就不用挂载了
3.添加 shouldKeepAlive 属性, 避免渲染器真的卸载
4.记录 KeepAlive 组件的实例
*/
KeepAlive组件会对"内部组件"进行操作,主要是在"内部组件"的 vnode 对象上添加一些标记属性,以便渲染器能够据此执行特定的逻辑。
shouldKeepAlive:
功能: 避免渲染器卸载
省流: 判断是否为true, 是的话就执行 _deActivate 使其失活, 为 false 才真正执行卸载
keepAliveInstance: 主要是通过 vnode.keepAliveInstance 操作 _deActivateb 进行失活 和操作 _activate 进行激活
// 卸载操作
function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c))
return
} else if (typeof vnode.type === 'object') {
// vnode.shouldKeepAlive 是一个布尔值,用来标识该组件是否应该 被 KeepAlive
if (vnode.shouldKeepAlive) {
// 对于需要被 KeepAlive 的组件,我们不应该真的卸载它,而应调用该组件的父组件,
// 即 KeepAlive 组件的 _deActivate 函数使其失活
vnode.keepAliveInstance._deActivate(vnode)
} else {
unmount( vnode.component.subTree )
}
return
}
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild( vnode.el )
}
}
keptAlive:
功能: 避免渲染器重新挂载
省流: 判断是否为true, 是的话就执行 _activate 使其失活, 为 false 才真正执行挂载
function patch(n1, n2, container, anchor) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 省略部分代码
} else if (typeof type === 'object' || typeof type === 'function') {
// component
if (!n1) {
// 如果该组件已经被 KeepAlive,则不会重新挂载它,而是会调用 _activeate 来激活
if( n2.keptAlive ){
n2.keepAliveInstance._activate( n2, container, anchor )
} else {
mountComponent( n2, container, anchor )
}
} else {
patchComponent(n1, n2, anchor)
}
}
}
上面这段代码中涉及的 move 函数是由渲染器注入的,如下面 mountComponent 函数的代码所示:
function mountComponent(vnode, container, anchor) {
// 省略部分代码
const instance = {
state,
props: shallowReactive(props),
isMounted: false,
subTree: null,
slots,
mounted: [],
// 只有 KeepAlive 组件的实例下会有 keepAliveCtx 属性
keepAliveCtx: null
}
// 检查当前要挂载的组件是否是 KeepAlive 组件
const isKeepAlive = vnode.type._isKeepAlive
if( isKeepAlive ){
// 是 KeepAlive 组件, 就添加 keepAliveCtx 对象
instance.keepAliveCtx = {
// move 函数用来移动一段 vnode
move( vnode, container, anchor ) {
// 本质上是将组件渲染的内容移动到隐藏容器中
insert( vnode.component.sutTree.el, container, anchor )
},
createElement
}
}
// 省略部分代码
}
1.2 include 和 exclude
为了使用户能够自定义缓存规则,我们需要让 KeepAlive 组件支持两个 props,分别是 include 和exclude。其中,include 用来显式地配置应该被缓存组件,而exclude 用来显式地配置不应该被缓存组件。
const KeepAlive = {
__isKeepAlive: true,
// 定义 include 和 exclude props
props: {
include: RegExp,
exclude: RegExp
},
setup(props, { slots }) {
// 省略部分代码
}
}
const KeepAlive = {
setup( props, { slots } ){
return () => {
let rawVNode = slots.default()
if( typeof rawVNode.type !== 'Object' ) {
return rawVNode
}
// 这里的 name 是使用 include 和 exclude 传进来的值
const name = rawVNode.type.name
// 对 name 进行匹配, 如果 name 无法被 include 匹配,或者被 exclude 匹配
if( name && ( ( props.include && !props.include(name) ) || ( props.exclude && !props.exclude(name) ) ){
// 则直接渲染内部组件, 不对其进行后续的缓存操作
return rawVNode
}
}
}
}
问题: 这部分代码应该写在上面 KeepAlive 中的哪里?
在vue中, 被keepAlive包裹的组件如果有a,b,c三个组件, 其中a被缓存,b不被缓存. 那c会怎么样?
组件 a 会被缓存,因为它被明确包含在 include 属性中。组件 b 不会被缓存,因为它被明确排除在 exclude 属性中。
keepAlive 如果没有使用 include则 默认会缓存组件, 使用了 include 则缓存 include 中的组件
1.3 缓存管理
缓存逻辑:
如果缓存存在,则继承组件实例,并将用于描述组件的 vnode 对象标记为 keptAlive,这样渲染器就不会重新创建新的组件实例;
如果缓存不存在,则设置缓存。
这里的问题在于,当缓存不存在的时候,总是会设置新的缓存。这会导致缓存不断增加,极端情况下会占用大量内存。为了解决这个
问题,我们必须设置一个缓存阈值,当缓存数量超过指定阈值时对缓存进行修剪。
Vue.js 当前所采用的修剪策略叫作"最新一次访问"。
"最新一次访问"的缓存修剪策略的核心在于,需要把当前访问(或渲染)的组件作为最新一次渲染的组件,并且该组件在缓存修剪过程中始终是安全的,即不会被修剪。
<KeepAlive :max="2">
<component :is="dynamicComp"/>
</KeepAlive>
/*
例子1:
1.初始渲染1并缓存, 缓存队列为 [ Comp1 ]
2.切换到2并缓存, 缓存队列为 [ Comp1, Comp2 ]
3.切换到3并缓存, 缓存队列为 [ Comp2, Comp3 ].
由于缓存已满, 最新一次访问的是 Comp2, 所有它的安全的, 因此被修剪掉的应该是 Comp1.
例子2:
1.初始渲染1并缓存, 缓存队列为 [ Comp1 ]
2.切换到2并缓存, 缓存队列为 [ Comp1, Comp2 ]
3.再切换到1, 缓存队列为 [ Comp2, Comp1 ] 1已经在缓存了,不需要修剪, 但是要将最新一次渲染的组件设置为1
4.切换到3, 此时缓存已满, 由于最新一次访问的是 Comp1, 所有它的安全的, 因此被修剪掉的应该是 Comp2. 结果是[ Comp1, Comp3 ].
*/
<KeepAlive :cache="cache">
<Comp />
</KeepAlive>
// 自定义实现
const _cache = new Map()
const cache = {
get(key) {
return _cache.get(key)
},
set(key, value) {
_cache.set(key, value)
},
delete(key) {
_cache.delete(key)
},
forEach(fn) {
_cache.forEach(fn)
}
}
keepAlive 总结
KeepAlive 组件可以避免组件实例不断地被销毁和重建。它的基本实现并不复杂。当被KeepAlive 的组件"卸载"时,渲染器并不会真的将其卸载掉,而是会将该组件搬运到一个隐藏容器中,从而使得组件可以维持当前状态。当被 KeepAlive 的组件"挂载"时,渲染器也不会真的挂载它,而是将它从隐藏容器搬运到原容器。
我们还讨论了 KeepAlive 的其他能力,如匹配策略和缓存策略。include 和 exclude 这两个选项用来指定哪些组件需要被KeepAlive,哪些组件不需要被 KeepAlive。默认情况下,include 和exclude 会匹配组件的 name 选项。但是在具体实现中,我们可以扩
展匹配能力。对于缓存策略,Vue.js 默认采用"最新一次访问"。
2. Teleport 组件的实现原理
将指定内容渲染到特定容器中. ( 使用: 模态框, 通知和弹出窗口 等) (特性: 极大的灵活性, 解决样式冲突,)
2.1Teleport 要解决的问题
<template>
<div id="box" style="z-index: -1;">
<Overlay />
</div>
</template>
Teleport 要解决的问题?
在上面这段模板中, Overlay 组件会被渲染到 id 为 box 的 div 标签下. 然而有时这并不是我们所期望的.如果 Overlay 是一个蒙层组件, 并要求蒙层能遮盖页面上的任何元素. 就是要求 Overlay 组件的 z-index层级最高. 但是id为box的div标签有一段内联样式, 无论怎么设置 Oberlay 的 z-index 也无法实现遮挡功能.
Vue2 中只能通过原生 DOM API 手动搬运 DOM 元素实现需求. 缺点是 手动操作 DOM 会使元素的渲染与 Vue 的渲染机制脱节, 导致各种可预见或不可预见的事情.
<template>
<Teleport to="body">
<div class="overlay"></div>
</Teleport>
</template>
<style scoped>
.overlay {
z-index: 9999;
}
</style>
/*
Overlay 要渲染的内容包含在 Teleport 组件内, 即作为 Teleport 组件的插槽, 通过为 Teleport 组件指定渲染目标 body, 即 to 属性的值
该组件就会直接把它的插槽内容渲染到 body 下, 而不是按照模板的 DOM 层级来渲染, 于是就实现了跨 DOM 层级的渲染
*/
2.2 实现 Teleport 组件
首先将 Teleport 组件的渲染逻辑从渲染器中分离出来
-
避免渲染器代码膨胀
-
没有使用 Teleport 时, 可以利用 TreeShaking 在打包中删除 Teleport 的代码, 减少打包体积
怎么实现 Teleport 组件
省流版:
-
首先修改 patch 函数, 增加 _isTeleport 判断是不是 Teleport 组件,是的话调用组件定义的 process 函数, 将渲染控制权交出去.
-
Teleport 组件会直接将其子节点编译为一个数组.
-
挂载和更新:
挂载: 旧的 VNode 不存在直接获取传进来的 to , 即挂载点, 通过 patch 将 n2.children 渲染到指定挂载点
更新: 只需要调用 patchChildren 完成更新即可, 如果新旧 to 值不同, 需要通过 move 对内容进行移动
// 首先修改 patch 函数
function patch(n1, n2, container, anchor) {
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
const { type } = n2
if (typeof type === 'string') { // 省略部分代码
} else if (type === Text) { // 省略部分代码
} else if (type === Fragment) {// 省略部分代码
} else if (typeof type === 'object' && type._isTeleport) {
// 根据 _isTeleport 判断是不是 Teleport 组件
type.process( n1, n2, container, anchor, {
patch,
patchChildren,
unmount,
// 用来移动被 Teleport 的内容
move( vnode, container, anchor ) {
// 移动一个组件, 移动普通元素
insert( vnode.component ? vnode.component.subTree.el : vnode.el, container, anchor )
}
})
} else if (typeof type === 'object' || typeof type === 'function') { // 省略部分代码
}
}
// 设计虚拟 DOM 的结构
// 假设用户编写的模板如下:
<Teleport to='body'>
<h1>Title</h1>
<p>content</p>
</Teleport>
// 通常一个组件的子节点会被编译为插槽内容, 对于 Teleport 来说, 直接将其子节点编译为一个数组即可
function render() {
return {
type: Teleport,
children: [ // 以普通的 children 形式代表被 Teleport 的内容
{ type: 'h1', children: 'Title' },
{ type: 'p', children: 'content' }
]
}
}
// 实现 Teleport 组件
const Teleport = {
_isTeleport: true,
process( n1, n2, container, cachor, internals ){
// 通过 internals 参数获取渲染器的内部方法
const { patch, patchChildren } = interals
// 如果旧 VNode n1 不存在, 则是全新的挂载, 否则执行更新
if( !n1 ){
// 获取容器, 即挂载点
const target = typeof n2.props.to === 'string' ? document.querySelector(n2.props.to) : n2.props.to
// 将 n2.children 渲染到指定挂载点即可
n2.children.forEach(c => patch( null, c, target, anchor ))
} else {
// 更新
patchChildren( n1, n2, container )
if( n2.props.to !== n1.props.to ){
// 如果新旧 to 的参数不同, 则需要对内容进行移动
const newTarget = typeof n2.props.to === 'string' ? document.querySelector(n2.props.to) : n2.props.to
// 移动到新的容器
n2.children.forEach(c => move(c, newTarget))
}
}
}
}
3 . Transition 组件的实现原理
原理:
当 DOM 元素被挂载时,将动效附加到该 DOM 元素上;
当 DOM 元素被卸载时,不要立即卸载 DOM 元素,而是等到附加到该 DOM 元素上的动效执行完成后再卸载它。
3.1 原生 DOM 的过渡
<div class="box"></div>
.box {
width: 100px;
height: 100px;
background-color: red;
}
.enter-from { // 开始
transform: translateX(200px);
}
.enter-to { // 结束
transform: translateX(0);
}
.enter-active { // 运动过程
transition: transform 1s ease-in-out;
}
// 进场效果
// 创建 class 为 box 的 DOM 元素
const el = document.createElement('div')
el.classList.add('box')
// 在 DOM 元素被添加到页面之前, 将初始状态和运动过程定义到元素上
el.classList.add('enter-from') // 初始状态
el.classList.add('enter-active') // 运动状态
document.body.appendChild(el) // 将元素添加到页面, 即挂载
// 嵌套调用 requestAnimationFrame 兼容 Chrome 和 Safari
requestAnimationFrame(() => {
requestAnimationFrame(() => {
el.classList.remove('enter-from') // 移除 enter-from
el.classList.add('enter-to') // 添加 enter-to
// 当过渡完成后,将 enter-to 和 enter- active 这两个类从 DOM 元素上移除
// 监听 transitionend 事件完成收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('enter-to')
el.classList.remove('enter-active')
})
})
})
/*
beforeEnter 阶段: 添加 enter-from 和 enter-active 类。
enter 阶段: 在下一帧中移除 enter-from 类,添加 enter-to。
进场动效结束: 移除 enter-to 和 enter-active 类。
*/
// 初始状态
.leave-from {
transform: translateX(0);
}
// 结束状态
.leave-to {
transform: translateX(200px);
}
// 过渡过程
.leave-active {
transition: transform 2s ease-out;
}
// 离场过渡的初始状态与结束状态正好对应进场过渡的结束状态与初始状态。
el.addEventListener('click', () => {
// 将卸载动作封装到 performRemove 函数中
const performRemove = () => el.parentNode.removeChild(el)
})
// 具体的离场动效的实现如下:
el.addEventListener('click', () => {
// 将卸载动作封装到 performRemove 函数中
const performRemove = () => el.parentNode.removeChild(el)
// 设置初始状态: 添加 leave-form 和 leave-active
el.classList.add('leave-from')
el.classList.add('leave-active')
// 强制 reflow 使初始值生效
document.body.offsetHeight
// 下一帧切换状态
requestAnimationFrams(() => {
requestAnimationFrams(() => {
// 切换到结束状态
el.classList.remove('leave-from')
el.classList.remove('leave-to')
// 监听 transitionend 事件, 做收尾工作
el.addEventListener('transitionend', () => {
el.classList.remove('leave-to')
el.classList.remove('leave-active')
// 当过渡完成后, 调用 performRemove 将 DOM 元素移除
performRemove()
})
})
})
})
3.2实现 Transition 组件
Transition 组件是基于虚拟 DOM 实现的。
<template>
<Transition>
<div>我是需要过渡的元素</div>
</Transition>
</template>
// 编译后的虚拟 DOM
function render() {
return {
type: Transition,
children: {
default() {
return { type: 'div', children: '我是需要过渡的元素' };
}
}
};
}
// Transition 组件本身不会渲染任何额外的内容,它只是通过默认插槽读取过渡元素,并渲染需要过渡的元素;
// Transition 组件的作用,就是在过渡元素的虚拟节点上添加 transition 相关的钩子函数。
const Transition = {
name: 'Transition',
setup( props, { slots } ){
return () => {
const innerVNodFrame = slots.default()
innerVNode.transition = {
befoerEneter(el) {
// 初始状态, 添加 enter-from 和 enter-active
el.classList.add('enter-from')
el.classList.add('enter-active')
},
enter(el) {
// 下一帧切到结束状态
nextFrame(() => {
// 移除 enter-from 类,添加 enter-to 类
el.classList.remove('enter-from')
el.classList.add('enter-to')
// 监听 transitionend 事件完成收尾 移除 enter 所有类
el.addEventListiner('transitionend', () => {
el.classList.remove('enter-to')
el.classList.remove('enter-active')
})
})
},
leave( el, performRemove ) {
// 设置离场过渡的初始状态:添加 leave-from 和 leave-active
el.classList.add('leave-from')
el.classList.add('leave-active')
// 强制 reflow 让初始状态生效
document.body.offsetHeight
// 下一侦修改状态
nextFrame(() => {
// 移除 leave-from 类,添加 leave-to 类
el.classList.remove('leave-from')
el.classList.add('leave-to')
// 监听 transitionend 事件完成收尾工作 移除 leave 所有类
el.addEventListiner('transitionend', () => {
el.classList.remove('leave-to')
el.classList.remove('leave-active')
// 完成 DOM 元素的卸载, 真正卸载 DOM 的地方
performRemove()
})
})
}
}
return innerVNode
}
}
}
function mountElement( vnode, container, anchor ) {
const el = vnode.el = createElement( vnode.type )
// 判断一个 VNode 是否需要过渡
const needTransition = vnode.transition
if( needTransition ) {
// 挂载 DOM 元素之前, 调用 transition.beforeEnter 钩子,并将 DOM 元素作为参数传递
vnode.transiton.beforeEnter(el)
}
insert( el, container, anchor )
if( needTransition ) {
// 在挂载元素之后, 调用 transition.enter 钩子,并将 DOM 元素作为参数传递
vnode.transition.enter( el )
}
}
function unmount( vnode ) {
// ......
const needTransition = vnode.transition
const parent = vnode.el.parentNode
if ( parent ) {
// 卸载动作
const performRemove = () => parent.removeChild( vnode.el )
if( needTransition ){
// 如果需要过渡, 调用 leave 钩子, 同时将 DOM 元素和 performRemove 函数传递出去
vnode.transition.leave( vnode.el, performRemove)
} else {
// 如果不需要过渡处理,则直接执行卸载操作
performRemove()
}
}
}
第 15章 1 编译器核心技术概览
编译器其实只是一段程序,它用来将"一种语言 A"翻译成"另外一种语言 B"。其中,语言 A 通常叫作 (source code),语言 B 通常叫作 (object code 或 target code)
完整的编译过程通常包含词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成等。

编译前端 - 仅负责分析源代码。
编译后端则通常与目标平台有关,编译后端涉及中间代码生成和优化以及目标代码生成。
Vue.js 模板编译器的目标代码其实就是渲染函数。
Vue.js 模板编译器会首先对模板进行词法分析和语法分析,得到模板 AST。接着,将模板 AST (transform)成 JavaScript AST。
最后,根据 JavaScript AST 生成 JavaScript 代码,即渲染函数代码。

AST 是 abstract syntax tree 的首字母缩写,即抽象语法树。所谓模板 AST,其实就是用来描述模板的抽象语法树。
parse 函数完成模板的词法分析和语法分析,得到模板 AST
const templateAST = parse(template)

transform 函数来完成模板 AST 到 JavaScript AST的转换工作
const jsAST = transform(templateAST)

generate 函数会将渲染函数的代码以字符串的形式返回


工作流程:
-
用来将模板字符串解析为模板 AST 的解析器 (parser);
-
用来将模板 AST 转换为 JavaScript AST 的转换器 (transformer);
-
用来根据 JavaScript AST 生成渲染函数代码的生成器 (generator)。
15.2 parser 的实现原理与状态机
<p>Vue</p>
解析器会把这段字符串模板切割为三个 Token。
开始标签:<p>。 文本节点:Vue。 结束标签:</p>。

-
状态机始于"初始状态 1"。
-
在"初始状态 1"下,读取模板的第一个字符 <,状态机会进入下一个状态,即"标签开始状态 2"。
-
在"标签开始状态 2"下,读取下一个字符 p。由于字符 p 是字母,所以状态机会进入"标签名称状态 3"。
-
在"标签名称状态 3"下,读取下一个字符 >,此时状态机会从"标签名称状态 3"迁移回"初始状态 1",并记录在"标签名称状态"下产生的标签名称 p。
-
在"初始状态 1"下,读取下一个字符 V,此时状态机会进入"文本状态 4"。
-
在"文本状态 4"下,继续读取后续字符,直到遇到字符 < 时,状态机会再次进入"标签开始状态 2",并记录在"文本状态 4"下产生的文本内容,即字符串"Vue"。
-
在"标签开始状态 2"下,读取下一个字符 /,状态机会进入"结束标签状态 5"。
-
在"结束标签状态 5"下,读取下一个字符 p,状态机会进入"结束标签名称状态 6"。
-
在"结束标签名称状态 6"下,读取最后一个字符 >,它是结束标签的闭合字符,于是状态机迁移回"初始状态 1",并记录在"结束标签名称状态 6"下生成的结束标签名称。
// 定义状态机的状态
const State = {
initial: 1, // 初始状态
tagOpen: 2, // 标签开始状态
tagName: 3, // 标签名称状态
text: 4, // 文本状态
tagEnd: 5, // 结束标签状态
tagEndName: 6 // 结束标签名称状态
}// 一个辅助函数,用于判断是否是字母
function isAlpha(char) {
return char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z'
}// 接收模板字符串作为参数,并将模板切割为 Token 返回
function tokenize(str) {
// 状态机的当前状态:初始状态
let currentState = State.initial
// 用于缓存字符
const chars = []
// 生成的 Token 会存储到 tokens 数组中,并作为函数的返回值返回
const tokens = []
// 使用 while 循环开启自动机,只要模板字符串没有被消费尽,自动机就会一直
while(str) {
// 查看第一个字符,注意,这里只是查看,没有消费该字符
const char = str[0]
// switch 语句匹配当前状态
switch (currentState) {
// 状态机当前处于初始状态
case State.initial:
// 遇到字符 <
if (char === '<') {
// 1. 状态机切换到标签开始状态
currentState = State.tagOpen
// 2. 消费字符 <
str = str.slice(1)
} else if (isAlpha(char)) {
// 1. 遇到字母,切换到文本状态
currentState = State.text
// 2. 将当前字母缓存到 chars 数组
chars.push(char)
// 3. 消费当前字符
str = str.slice(1)
}
break
// 状态机当前处于标签开始状态
case State.tagOpen:
if (isAlpha(char)) {
// 1. 遇到字母,切换到标签名称状态
currentState = State.tagName
// 2. 将当前字符缓存到 chars 数组
chars.push(char)
// 3. 消费当前字符
str = str.slice(1)
} else if (char === '/') {
// 1. 遇到字符 /,切换到结束标签状态
currentState = State.tagEnd
// 2. 消费字符 /
str = str.slice(1)
}
break
// 状态机当前处于标签名称状态
case State.tagName:
if (isAlpha(char)) {
// 1. 遇到字母,由于当前处于标签名称状态,所以不需要切换状态,
// 但需要将当前字符缓存到 chars 数组
chars.push(char)
// 2. 消费当前字符
str = str.slice(1)
} else if (char === '>') {
// 1.遇到字符 >,切换到初始状态
currentState = State.initial
// 2. 同时创建一个标签 Token,并添加到 tokens 数组中
// 注意,此时 chars 数组中缓存的字符就是标签名称
tokens.push({
type: 'tag',
name: chars.join('')
})
// 3. chars 数组的内容已经被消费,清空它
chars.length = 0
// 4. 同时消费当前字符 >
str = str.slice(1)
}
break
// 状态机当前处于文本状态
case State.text:
if (isAlpha(char)) {
// 1. 遇到字母,保持状态不变,但应该将当前字符缓存到 chars 数组
chars.push(char)
// 2. 消费当前字符
str = str.slice(1)
} else if (char === '<') {
// 1. 遇到字符 <,切换到标签开始状态
currentState = State.tagOpen
// 2. 从 文本状态 --> 标签开始状态,此时应该创建文本 Token,并添加到 tokens 数组
// 注意,此时 chars 数组中的字符就是文本内容
tokens.push({
type: 'text',
content: chars.join('')
})
// 3. chars 数组的内容已经被消费,清空它
chars.length = 0
// 4. 消费当前字符
str = str.slice(1)
}
break
// 状态机当前处于标签结束状态
case State.tagEnd:
if (isAlpha(char)) {
// 1. 遇到字母,切换到结束标签名称状态
currentState = State.tagEndName
// 2. 将当前字符缓存到 chars 数组
chars.push(char)
// 3. 消费当前字符
str = str.slice(1)
}
break
// 状态机当前处于结束标签名称状态
case State.tagEndName:
if (isAlpha(char)) {
// 1. 遇到字母,不需要切换状态,但需要将当前字符缓存到 chars 数组
chars.push(char)
// 2. 消费当前字符
str = str.slice(1)
} else if (char === '>') {
// 1. 遇到字符 >,切换到初始状态
currentState = State.initial
// 2. 从 结束标签名称状态 --> 初始状态,应该保存结束标签名称 Token
// 注意,此时 chars 数组中缓存的内容就是标签名称
tokens.push({
type: 'tagEnd',
name: chars.join('')
})
// 3. chars 数组的内容已经被消费,清空它
chars.length = 0
// 4. 消费当前字符
str = str.slice(1)
}
break
}
}
// 最后,返回 tokens
return tokens
}const tokens = tokenize(
<p>Vue</p>
)
[
{ type: 'tag', name: 'p' }, // 开始标签
{ type: 'text', content: 'Vue' }, // 文本节点
{ type: 'tagEnd', name: 'p' } // 结束标签
]
15.3 构造 AST
AST 在结构上与模板是"同构"的,它们都具有树型结构

使用程序根据模板解析后生成的 Token 构造出这样一棵 AST。

根据 Token 列表构建 AST 的过程,其实就是对 Token 列表进行扫描的过程。
从第一个 Token 开始,顺序地扫描整个 Token 列表,直到列表中的所有 Token 处理完毕。
开始时:

结束时:

// parse 函数接收模板作为参数
function parse(str) {
// 首先对模板进行标记化,得到 tokens
const tokens = tokenize(str);
// 创建 Root 根节点
const root = {
type: 'Root',
children: []
};
// 创建 elementStack 栈,起初只有 Root 根节点
const elementStack = [root];
// 开启一个 while 循环扫描 tokens,直到所有 Token 都被扫描完毕为止
while (tokens.length) {
// 获取当前栈顶节点作为父节点
const parent = elementStack[elementStack.length - 1];
// 当前扫描的 Token
const t = tokens[0];
switch (t.type) {
case 'tag':
// 如果当前 Token 是开始标签,则创建 Element 类型的 AST 节点
const elementNode = {
type: 'Element',
tag: t.name,
children: []
};
// 将其添加到父级节点的 children 中
parent.children.push(elementNode);
// 将当前节点压入栈
elementStack.push(elementNode);
break;
case 'text':
// 如果当前 Token 是文本,则创建 Text 类型的 AST 节点
const textNode = {
type: 'Text',
content: t.content
};
// 将其添加到父节点的 children 中
parent.children.push(textNode);
break;
case 'tagEnd':
elementStack.pop()
break
}
// 消费已经扫描过的 token
tokens.shift();
}
// 最后返回 AST
return root;
}
15.4 AST 的转换与插件化架构

其中 transform 函数就是用来完成 AST 转换工作的
15.4.1 节点的访问
为了对 AST 进行转换,我们需要能访问 AST 的每一个节点,这样才有机会对特定节点进行修改、替换、删除等操作
function dump(node, indent = 0) {
// 节点的类型
const type = node.type
// 节点的描述,如果是根节点,则没有描述
// 如果是 Element 类型的节点,则使用 node.tag 作为节点的描述
// 如果是 Text 类型的节点,则使用 node.content 作为节点的描述
const desc = node.type === 'Root' ? '' : node.type === 'Element' ? node.tag : node.content
// 打印节点的类型和描述信息
console.log(`${'-'.repeat(indent)}${type}: ${desc}`)
// 递归地打印子节点
if( node.children ){
node.children.forEach(n => dump(n, indent + 2))
}
}
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
console.log(dump(ast))
// 运行上面这段代码,将得到如下输出:
Root:
--Element: div
----Element: p
------Text: Vue
----Element: p
------Text: Template
实现对 AST 中节点的访问。访问节点的方式是,从 AST 根节点开始,进行深度优先遍历
function traverseNode(ast) {
// 当前节点,ast 本身就是 Root 节点
const currentNode = ast
// 对当前节点进行操作
if(currentNode.type === 'Element' && currentNode.tag === 'p'){
// 将所有 p 标签转换为 h1 标签
currentNode.tag = 'h1'
}
//如果节点的类型为 Text
if(currentNode.type === 'Text'){
// 重复其内容两次,这里我们使用了字符串的 repeat() 方法
currentNode.content = currentNode.content.repeat(2)
}
// 如果有子节点,则递归地调用 traverseNode 函数进行遍历
const children = currentNode.children
if( children ){
for (let i = 0; i < children.length; i++) {
traverseNode(children[i])
}
}
}
// 封装 transform 函数,用来对 AST 进行转换
function transform(ast) {
// 调用 traverseNode 完成转换
traverseNode(ast)
//打印AST信息
console.log(dump(ast))
}
const ast = parse(`<div><p>Vue</p><p>Template</p></div>`)
transform(ast)
}
// 运行上面这段代码,我们将得到如下输出:
Root:
--Element: div
----Element: h1
------Text: Vue
----Element: h1
------Text: TemplateTemplate
避免 traverseNode 函数变得越来越臃肿, 我们对节点的操作和访问进行解耦
// 接收第二个参数 context
function traverseNode(ast, context) {
const currentNode = ast
// context.nodeTransforms 是一个数组,其中每一个元素都是一个函数
const transforms = context.nodeTransforms
for (let i = 0; i < transforms.length; i++) {
// 将当前节点 currentNode 和 context 都传递给 nodeTransforms 中注册的回调函数
transforms[i](currentNode, context)
}
const children = currentNode.children
if( children ){
for (let i = 0; i < children.length; i++) {
traverseNode(children[i], context)
}
}
}
// 把回调函数存储到 transforms 数组中,然后遍历该数组,并逐 个调用注册在其中的回调函数。
// 最后,我们将当前节点 currentNode 和 context 对象分别作为参数传递给回调函数。
function transform(ast) {
// 在 transform 函数内创建 context 对象
const context = {
nodeTransforms: [
transformElement, // 转换标签节点
transformText // 转换文本节点
]
}
// 调用 traverseNode 完成转换
traverseNode(ast, context)
// 打印 AST 信息
console.log(dump(ast))
}
function transformElement(node) {
if (node.type === 'Element' && node.tag === 'p') {
node.tag = 'h1'
}
}
function transformText(node) {
if (node.type === 'Text') {
node.content = node.content.repeat(2)
}
}
// 解耦之后,节点操作封装到了 transformElement 和 transformText 这样的独立函数中。
15.4.2 转换上下文与节点操作
上下文对象其实就是程序在某个范围内的"全局变量"。
function transform(ast) {
const context = {
// 增加 currentNode,用来存储当前正在转换的节点
currentNode: null,
// 增加 childIndex,用来存储当前节点在父节点的 children 中的位置索引
childIndex: 0,
// 增加 parent,用来存储当前转换节点的父节点
parent: null,
// 用于替换节点的函数,接收新节点作为参数
replaceNode(node) {
// 为了替换节点,我们需要修改 AST
// 找到当前节点在父节点的 children 中的位置:context.childIndex
// 然后使用新节点替换即可
context.parent.children[context.childIndex] = node
// 由于当前节点已经被新节点替换掉了,因此我们需要将 currentNode 更新为新节点
context.currentNode = node
},
// 用于删除当前节点。
removeNode() {
if (context.parent){
// 调用数组的 splice 方法,根据当前节点的索引删除当前节点
context.parent.children.splice(context.childIndex, 1)
// 将 context.currentNode 置空
context.currentNode = null
}
},
nodeTransforms: [
transformElement, // 转换标签节点
transformText // 转换文本节点
]
}
// 调用 traverseNode 完成转换
traverseNode(ast, context)
// 打印 AST 信息
console.log(dump(ast))
}
function traverseNode(ast, context) {
const currentNode = ast
const transforms = context.nodeTransforms
for (let i = 0; i < transforms.length; i++) {
// 将当前节点 currentNode 和 context 都传递给 nodeTransforms 中注册的回调函数
transforms[i](currentNode, context)
// 由于任何转换函数都可能移除当前节点,因此每个转换函数执行完毕后,
// 都应该检查当前节点是否已经被移除,如果被移除了,直接返回即可
if (!context.currentNode) return
}
const children = currentNode.children
if( children ){
for (let i = 0; i < children.length; i++)
// 递归地调用 traverseNode 转换子节点之前,将当前节点设置为父节点
context.parent = context.currentNode
// 设置位置索引
context.childIndex = i
// 递归地调用时,将 context 透传
traverseNode(children[i], context)
}
}
}
// 转换函数的第二个参数就是 context 对象
function transformText(node, context) {
if (node.type === 'Text') {
// 如果当前转换的节点是文本节点,则调用 context.replaceNode 函数将 其替换为元素节点
context.replaceNode({
type: 'Element',
tag: 'span'
})
// 如果是文本节点,直接调用 context.removeNode 函数将其移除即可
// 测试删除
context.removeNode()
}
}
15.4.3 进入与退出
当转换函数处于进入阶段时,它会先进入父节点,再进入子节点。而当转换函数处于退出阶段时,则会先退出子节点,再退出父节点。
这样,只要我们在退出节点阶段对当前访问的节点进行处理,就一定能够保证其子节点全部处理完毕。

需要重新设计转换函数的能力
function traverseNode(ast, context) {
const currentNode = ast
// 1. 增加退出阶段的回调函数数组
const exitFns = []
const transforms = context.nodeTransforms
for (let i = 0; i < transforms.length; i++) {
// 2. 转换函数可以返回另外一个函数,该函数即作为退出阶段的回调函数
const onExit = transforms[i](context.currentNode, context)
if (onExit) {
// 将退出阶段的回调函数添加到 exitFns 数组中
exitFns.push(onExit)
}
if (!context.currentNode) return
}
const children = currentNode.children
if( children ){
for (let i = 0; i < children.length; i++)
// 递归地调用 traverseNode 转换子节点之前,将当前节点设置为父节点
context.parent = context.currentNode
// 设置位置索引
context.childIndex = i
// 递归地调用时,将 context 透传
traverseNode(children[i], context)
}
}
// 在节点处理的最后阶段执行缓存到 exitFns 中的回调函数
// 注意,这里我们要反序执行
let i = exitFns.length
while (i--) {
exitFns[i]()
}
}
15.5将模板AST转为JavaScript AST
一个基本的数据结构来描述函数声明语句
const FunctionDeclNode = {
type: 'FunctionDecl', // 代表该节点是函数声明
// 函数的名称是一个标识符,标识符本身也是一个节点
id: {
type: 'Identifier',
name: 'render', // name 用来存储标识符的名称,在这里它就是渲染函数的
},
params: [], // 参数,目前渲染函数还不需要参数,所以这里是一个空数组
// 渲染函数的函数体只有一个语句,即 return 语句
body: [
{
type: 'ReturnStatement',
return: {// 最外层的 h 函数调用
type: 'CallExpression',
callee: { type: 'Identifier', name: 'h' },
arguments: [
// 第一个参数是字符串字面量 'div'
{
type: 'StringLiteral',
value: 'div'
},
// 第二个参数是一个数组
{
type: 'ArrayExpression',
elements: [
// 数组的第一个元素是 h 函数的调用
{
type: 'CallExpression',
callee: { type: 'Identifier', name: 'h' },
arguments: [
// 该 h 函数调用的第一个参数是字符串字面量
{ type: 'StringLiteral', value: 'p' },
// 第二个参数也是一个字符串字面量
{ type: 'StringLiteral', value: 'Vue' },
]
},
// 数组的第二个元素是 h 函数的调用
{
type: 'CallExpression',
callee: { type: 'Identifier', name: 'h' },
arguments: [
// 该 h 函数调用的第一个参数是字符串字面量
{ type: 'StringLiteral', value: 'p' },
// 第二个参数也是一个字符串字面量
{ type: 'StringLiteral', value: 'Template' },
]
},
]
}
}
},
],
};
// 描述函数调用语句
const CallExp = {
type: 'CallExpression',
// 被调用函数的名称,它是一个标识符
callee: {
type: 'Identifier',
name: 'h'
},
// 参数
arguments: []
}
// 渲染函数的返回值
function render() {
// h 函数的第一个参数是一个字符串字面量, 第二个参数是一个数组
return h('div', [/*...*/])
}
// 为 StringLiteral 的节点来描述第一个参数
const Str = {
type: 'StringLiteral',
value: 'div'
}
// 为 ArrayExpression 的节点来描述第二个参数
const Arr = {
type: 'ArrayExpression',
value: []
}
-
将模板AST转为JavaScript AST
-
为什么需要将模板 AST 转换为 JavaScript AST?
我们需要将模板编译为渲染函数。而渲染函数是由 JavaScript代码来描述的,因此,我们需要将模板 AST 转换为用于描述渲染函数的 JavaScript AST。
-
了解渲染函数的 JavaScript 代码所对应的 JavaScript AST的结构描述
-
实现模板 AST 转换为 JavaScript AST
// 用来创建 StringLiteral 节点 function createStringLiteral(value) { return { type: 'StringLiteral', value } } // 用来创建 Identifier 节点 function createIdentifier(name) { return { type: 'Identifier', name } } // 用来创建 ArrayExpression 节点 function createArrayExpression(elements) { return { type: 'ArrayExpression', elements } } // 用来创建 CallExpression 节点 调用 function createStringLiteral(callee, elements) { return { type: 'CallExpression', callee: createIdentifier(callee), elements } } // 转换文本节点 function transformText(node) { // 如果不是文本节点,则什么都不做 if (node.type !== 'Text') { return } // 文本节点对应的 JavaScript AST 节点其实就是一个字符串字面量, // 因此只需要使用 node.content 创建一个 StringLiteral 类型的节点即可 // 最后将文本节点对应的 JavaScript AST 节点添加到 node.jsNode 属性下 node.jsNode = createStringLiteral(node.content) } // 转换标签节点 function transformElement(node) { // 将转换代码编写在退出阶段的回调函数中, // 这样可以保证该标签节点的子节点全部被处理完毕 return () => { // 如果被转换的节点不是元素节点,则什么都不做 if (node.type !== 'Element') { return } // 1. 创建 h 函数调用语句, // h 函数调用的第一个参数是标签名称,因此我们以 node.tag 来创建一个字符串字面量节点 // 作为第一个参数 const callExp = createCallExpression('h', [ createStringLiteral(node.tag) ]) // 2. 处理 h 函数调用的参数 // 如果当前标签节点只有一个子节点,则直接使用子节点的 jsNode 作为参数, 如果当前标签节点有多个子节点,则创建一个 ArrayExpression 节点作为参数 node.children.length === 1 ? callExp.arguments.push(node.children[0].jsNode) : callExp.arguments.push(createArrayExpression(node.children.map(c => c.jsNode))) // 退出阶段 // 3. 将当前标签节点对应的 JavaScript AST 添加到 jsNode 属性下 node.jsNode = callExp }) } // 在转换标签节点时,我们需要将转换逻辑编写在退出阶段的回调 函数内,这样才能保证其子节点全部被处理完毕; // 无论是文本节点还是标签节点,它们转换后的 JavaScript AST 节点 都存储在节点的 node.jsNode 属性下 // 转换 Root 根节点 function transformRoot(node) { // 将逻辑编写在退出阶段的回调函数中,保证子节点全部被处理完毕 return () => { // 如果不是根节点,则什么都不做 if (node.type !== 'Root') { return } // node 是根节点,根节点的第一个子节点就是模板的根节点, // 当然,这里我们暂时不考虑模板存在多个根节点的情况 const vnodeJSAST = node.children[0].jsNode // 创建 render 函数的声明语句节点,将 vnodeJSAST 作为 render 函数体的返回语句 node.jsNode = { type: 'FunctionDecl', id: { type: 'Identifier', name: 'render' }, params: [], body: [ { type: 'ReturnStatement', return: vnodeJSAST } ] } } }
-
-
代码生成
-
如何根据 JavaScript AST 生成渲染函数的代码
function generate(node) { // 上下文通用方法 const context = { code: '', push(code) { context.code += code }, currentIndent: 0, newLine() { context.push('\n' + ' '.repeat(context.currentIndent)) }, indent() { context.currentIndent++ context.newLine() }, deIndent() { context.currentIndent-- context.newLine() } } // 实际生成代码 genNode(node, context) return context.code } function genNode(node, context) { switch (node.type) { case 'FunctionDecl': genFunctionDecl(node, context) break; case 'ReturnStatement': genReturnStatement(node, context) break; case 'CallExpression': genCallExpression(node, context) break; case 'StringLiteral': genStringLiteral(node, context) break; case 'ArrayExpression': genArrayExpression(node, context) break; default: break; } }
-
如何对生成的代码进行缩进和换行
context.indent() 函数用来完成代码缩进它的原理很简单,即让缩进级别 context.currentIndent 进行自增,再调用context.newline() 函数
与之对应的 context.deIndent() 函数则用来取消缩进,即让缩进级别 context.currentIndent 进行自减,再调用 context.newline() 函数。
-
第 16章 解析器
-
文本模式及其对解析器的影响
-
文本模式是指什么?
文本模式指的是解析器在工作时所进入的一些特殊状态,在不同的特殊状态下,解析器对文本的解析行为会有所不同。具体来说,当解析器遇到一些特殊标签时,会切换模式,从而影响其对文本的解析行为。
这些特殊标签是:
<title> 标签、<textarea> 标签,当解析器遇到这两个标签时,会切换到 RCDATA 模式;
<style>、<xmp>、<iframe>、<noembed>、<noframes>、<noscript> 等标签,当解析器遇到这些标签时,会切换到RAWTEXT 模式;
当解析器遇到 <![CDATA[ 字符串时,会进入 CDATA 模式。
-
文本模式对解析器有什么影响 ?
-
-
递归下降算法构造模板AST
-
如何使用递归下降算法构造模板 AST
parseChildren 函数是解析器的核心.
parseChildren 函数在解析模板过程中的状态迁移过程。
1.当遇到字符 < 时,进入临时状态。
1.1如果下一个字符匹配正则 /a-z/i,则认为这是一个标签节点,于是调用 parseElement 函数完成标签的解析。注意正
则表达式 /a-z/i 中的 i,意思是忽略大小写(case-insensitive)。
1.2如果字符串以 <!-- 开头,则认为这是一个注释节点,于是调用 parseComment 函数完成注释节点的解析。
1.3如果字符串以 <![CDATA[ 开头,则认为这是一个 CDATA 节点,于是调用 parseCDATA 函数完成 CDATA 节点的解析。
2.如果字符串以 {{ 开头,则认为这是一个插值节点,于是调用parseInterpolation 函数完成插值节点的解析。
3.其他情况,都作为普通文本,调用 parseText 函数完成文本节点的解析。
// 定义文本模式,作为一个状态表 const TextModes = { DATA: 'DATA', RCDATA: 'RCDATA', RAWTEXT: 'RAWTEXT', CDATA: 'CDATA' } // 解析器函数,接收模板作为参数 function parse(str) { // 定义上下文对象 const context = { // source 是模板内容,用于在解析过程中进行消费 source: str, // 解析器当前处于文本模式,初始模式为 DATA mode: TextModes.DATA } // 调用 parseChildren 函数开始进行解析,它返回解析后得到的子节点 // parseChildren 函数接收两个参数: // 第一个参数是上下文对象 context // 第二个参数是由父代节点构成的节点栈,初始时栈为空 const nodes = parseChildren(context, []) // 解析器返回 Root 根节点 return { type: 'Root', // 使用 nodes 作为根节点的 children children: nodes } } function parseChildren(context, ancestors) { // 定义 nodes 数组存储子节点,它将作为最终的返回值 let nodes = []; // 从上下文对象中取得当前状态,包括模式 mode 和模板内容 source const { mode, source } = context; // 开启 while 循,只要满足条件就会一直对字符串进行解析 // 关于 isEnd() 后文会详细讲解 while(!isEnd(context, ancestors)) { let node; // 只有 DATA 模式和 RCDATA 模式才支持插值节点的解析 if (mode === TextModes.DATA || mode === TextModes.RCDATA) { // 只有 DATA 模式才支持标签节点的解析 if (mode === TextModes.DATA && source[0] === '<') { if (source[1] === '!') { if (source.startsWith('<!--')) { // 注释 node = parseComment(context); } else if (source.startsWith('<![CDATA[')) { // CDATA node = parseCDATA(context, ancestors) } } else if (source[1] === '/') { // 结束标签,这里需要抛出错误,后文会详细解释原因 } else if (/[a-z]/i.test(source[1])) { node = parseElement(context, ancestors); } } else if (source.startsWith('{{')) { // 解析插值 node = parseInterpolation(context); } } // node 不存在,说明处于其他模式,即非 DATA 模式且非 RCDATA 模式 // 这时一切内容都作为文本处理 if (!node) { // 解析文本节点 node = parseText(context); } // 将节点添加到 nodes 数组中 nodes.push(node); } // 当 while 循环停止后,说明子节点解析完毕,返回子节点 return nodes; }
parseElement 函数会做三件事:解析开始标签,解析子节点,解析结束标签。
function parseElement() { // 解析开始标签 const element = parseTag() // 这里递归地调用 parseChildren 函数进行 <div> 标签子节点的解析 element.children = parseChildren() // 解析结束标签 parseEndTag() return element }
isEnd 函数的逻辑
function isEnd(context, ancestors) { // 当模板内容解析完毕后,停止 if (!context.source) return true // 获取父级标签节点 const parent = ancestors[ancestors.length - 1] // 如果遇到结束标签,并且该标签与父级标签节点同名,则停止 if (parent && context.source.startsWith(`</${parent.tag}`)) { return true } }
-
-
状态机的开启与停止
-
状态机的开启时机是?
当解析器遇到开始标签时, 会将该标签压入父级节点栈,同时开启新的状态机.
-
状态机的结束时机是?
当解析器遇到结束标签时, 会将父级节点栈存在与该标签同名的开始标签,会停止当前正在运行的状态机.
-
-
解析标签节点
-
如何解析标签节点?
// 解析器函数,接收模板作为参数 function parse(str) { const context = { source: str, mode: TextModes.DATA, // advanceBy 函数用来消费指定数量的字符,它接收一个数字作为参数 advanceBy(num) { // 根据给定字符数 num,截取位置 num 后的模板内容,并替换当前模板内容 context.source = context.source.slice(num) }, // 无论是开始标签还是结束标签,都可能存在无用的空白字符,例如 <div> advanceSpaces() { // 匹配空白字符 const match = /^[\t\r\n\f ]+/.exec(context.source) if (match) { // 调用 advanceBy 函数消费空白字符 context.advanceBy(match[0].length) } } } const nodes = parseChildren(context, []) return { type: 'Root', children: nodes } }
parseTag函数
// 由于 parseTag 既用来处理开始标签,也用来处理结束标签,因此我们设计第二个参数 type, // 用来代表当前处理的是开始标签还是结束标签,type 的默认值为 'start',即默认作为开始标签处理 function parseTag(context, type = 'start') { // 从上下文对象中拿到 advanceBy 函数 const { advanceBy, advanceSpaces } = context; // 处理开始标签和结束标签的正则不同 const match = type === 'start' ? /^<([a-z][^\t\r\n\f />]*)/i.exec(context.source) : /^<\([a-z][^\t\r\n\f />]*)/i.exec(context.source) // 匹配成功后,正则表达式的第一个捕获组的值就是标签名称 const tag = match[1] // 清空匹配的标签开头部分(例如 `<div` 这段内容, 会将保留除了 <div 的其他内容 advanceBy(match[0].length); // 清空标签中无用的空白字符 advanceSpaces(); // 在消费匹配的内容后,如果字符串以 '/>' 开头,则说明这是一个自闭合标签 const isSelfClosing = context.source.startsWith('/>'); // 如果是自闭合标签,则清空 '/>',否则清空 '>' advanceBy(isSelfClosing ? 2 : 1); // 返回标签节点 return { type: 'Element', // 标签名称 tag, // 标签的属性暂时留空 props: [], // 子节点留空 children: [], // 是否自闭合 isSelfClosing }; } /* 对于字符串 '<div>',会匹配出字符串 '<div',剩余 '>'。 对于字符串 '<div/>',会匹配出字符串 '<div',剩余 '/>'。 对于字符串 '<div---->',其中减号(-)代表空白符,会匹配 出字符串 '<div',剩余 '---->'。 */
parseElement 函数在得到由 parseTag 函数产生的标签节点后,需要根据节点的类型完成文本模式的切换
function parseElement(context, ancestors) { const element = parseTag(context) if (element.isSelfClosing) return element // 切换到正确的文本模式 if (element.tag === 'textarea' || element.tag === 'title') { // 如果由 parseTag 解析得到的标签是 <textarea> 或 <title>,则切换到 RCDATA 模式 context.mode = TextModes.RCDATA } else if(/style|xmp|iframe|noembed|noframes|noscript/.test(element.tag)){ // 如果由 parseTag 解析得到的标签是: // <style>、<xmp>、<iframe>、<noembed>、<noframes>、<noscript> // 则切换到 RAWTEXT 模式 context.mode = TextModes.RAWTEXT } else { // 否则切换到 DATA 模式 context.mode = TextModes.DATA } ancestors.push(element) element.children = parseChildren(context, ancestors) ancestors.pop() if( context.source.startsWith(`</${element.tag}>` )){ parseTag(context, 'end') } else { console.log(`${lement.tag}标签缺少闭合标签`) }
-
标签内的无用的空白字符是怎么处理的?
会清空掉
advanceSpaces() { // 匹配空白字符 const match = /^[\t\r\n\f ]+/.exec(context.source) if (match) { // 调用 advanceBy 函数消费空白字符 context.advanceBy(match[0].length) } }
-
如果标签为一个自闭合标签是怎么处理的?
如果是自闭合标签,则清空 '/>',否则清空 '>'
-
-
解析属性指令
-
如何解析标签属性?
// 例如 id="foo"
1.首先,解析出第一个属性的名称 id,并消费字符串 'id'
2.除了要消费属性名称之外,还要消费属性名称后面可能存在的空白字符
3.接着,我们需要消费等于号字符
4.接下来处理属性值
既然属性值被引号引用了,就意味着在剩余模板内容中,下一个 引号之前的内容都应该被解析为属性值。
-
如何解析标签指令?
只需重新执行上述步骤
实际上,parseAttributes 函数消费模板内容的过程,就是不断地解析属性名称、等于号、属性值的过程
<div id="foo" v-show="display"/> function parseTag(context, type = 'start') { // 调用 parseAttributes 函数完成属性与指令的解析,并得到 props 数组, // props 数组是由指令节点与属性节点共同组成的数组 const props = parseAttributes(context) //...省略其他 return { props, } } function parseAttributes(context) { const { advanceBy, advanceSpaces } = context const props = [] while( !context.source.startsWith('>') && !context.source.startsWith('/>') ){ // 匹配属性名称 const match = /^[^\t\r\n\f />][^\t\r\n\f />=]*/.exex(context.source) // 得到属性名称 const name = match[0] // 清除属性名称 advanceBy(name.length) // 清除空白字符 advanceSpaces() // 清除 等于号 advanceBy(1) // 继续清除空白字符 advanceSpaces() // 记录属性值 let value = '' // 剩余模板的第一个字符 const quote = context.source[0] // 判断是否被引号包裹 const isQuoted = quote === '""' || quote === "''" if(isQuoted){ // 清除引号 advanceBy(1) // 找到下一个引号的索引 const endQuoteIndex = context.source.indexOf(quote) if (endQuoteIndex > -1) { // 获取下一个引号之前的内容作为属性值 value = context.source.slice(0, endQuoteIndex) // 清除属性值 advanceBy(value.length) // 清除引号 advanceBy(1) } else { console.error('缺少引号') } } else { // 说明属性值没有被引号包裹, 将下一个空白字符之前的内容全部作为属性值 const match = /^[^\t\r\n\f >]+/.exec(context.source) // 获取属性值 value = match[0] // 清除属性值 advanceBy(value.length) } // 清除空白字符 advanceSpaces() // 使用属性名称 + 属性值创建一个属性节点,添加到 props 数组中 props.push({ type: 'Attribute', name, value }) } // 最后把解析的 props 返回 return props }
-
-
解析文本
-
如何解析文本节点?
会走到 7 然后调用parseText
parseText函数实现如下
function parseText(context){ // 剩余内容长度 let endIndex = context.source.length // 获取 < 的位置所索引 const ltIndex = context.source.indexOf('<') // 获取 {{ 的位置索引 const delimiterIndex = context.dource.indexOf('{{') // < 存在且小于剩余内容长度, 则 < 的位置是结束索引 if(ltIndex > -1 && ltIndex < endIndex){ endIndex = ltIndex } // {{ 存在且小于剩余内容长度, 则 {{ 的位置是结束索引 if(delimiterIndex > -1 && delimiterIndex < endIndex){ endIndex = delimiterIndex } // 截取文本内容 const content = context.source.slice(0, endIndex) // 清除文本内容 context.advanceBy(content.length) return { type: 'Text', content } } const ast = parse(`<div>Text</div>`) // 根据 ast 得到 AST const ast = { type: 'Root', children: [ { type: 'Element', tag: 'div', props: [], isSelfClosing: false, children: [ { type: 'Text', context: 'Text' } ] } ] }
-
文本的结尾索引如何选取?
判断呗. 看 < 的位置和 {{ 的位置, 谁的位置更小, 就是结尾索引
-
-
解码命名字符引用
-
什么是命名字符引用?
以字符 & 开头,以字符 ; 结尾的特殊文本内容 例如 <
-
Vue.js 模板的解析器为什么要对命名字符引用进行解码?
前提: 在 Vue.js 模板中,文本节点所包含的 HTML 实体不会被浏览器解析。这是因为模板中的文本节点最终将通过如el.textContent 等文本操作方法设置到页面,而通过el.textContent 设置的文本内容是不会经过HTML 实体解码的
如果用户在 Vue.js 模板中编写了 HTML实体,而模板解析器不对其进行解码,那么最终渲染到页面的内容将不符合用户的预期。
-
如何解码命名字符引用?
假设文本为 a<b, 则会被解析成 a<b
1.首先,当解析器遇到字符 & 时,会进入字符引用状态。接着,解析下一个字符 l,这会使得解析器进入命名字符引用状态,并在命名字符引用表(后文简称"引用表")中查找以字符 l 开头的项。
由于引用表中存在诸多以字符 l 开头的项,例如 lt、lg、le等,因此解析器认为此时是"匹配"的。
2.于是开始解析下一个字符 t,并尝试去引用表中查找以 lt 开头的项。由于引用表中也存在多个以 lt 开头的项,例如 lt、ltcc;、ltri; 等,因此解析器认为此时也是"匹配"的。
- 于是又开始解析下一个字符 b,并尝试去引用表中查找以 ltb 开头的项,结果发现引用表中不存在符合条件的项,至此匹配结束。
注意: 需要区分是否是 ; 结尾, 如果不是 ; 结尾,则按照 '最短原则' 就是长度-1进行匹配
decodeHtml 函数的具体实现
// 第一个参数是要被解码的文本内容, 第二个参数是布尔值,表示文本内容是否作为属性值 function decodeHtml(rawText, asAttr = false){ let offset = 0 const end = rawText.length // 解码后的文本 let decodedText = '' // 引用表中名称的最大长度 let maxCRNameLength = 0 // 清除指定长度的文本 function advance(length){ offset += length rawText = rawText.slice(length) } while( offset < end ){ /* 用于匹配字符引用的开始部分,如果匹配成功,那么 head[0] 的值将有三种 1.head[0] === '&',这说明该字符引用是命名字符引用 2.head[0] === '&#',这说明该字符引用是用十进制表示的数字字符引用 3.head[0] === '&#x',这说明该字符引用是用十六进制表示的数字字符引用 */ const head = /$(?:#x?)?/i.exec(rawText) // 如果没有匹配,说明已经没有需要解码的内容了 if(!head){ // 计算剩余长度, 将剩余长度加到 decodedText 上, 然后清除剩余内容 const remaining = end - offset decodedText += rawText.slice(0, remaining) advance(remaining) break } // head.index 为匹配的字符 & 在 rawText 中的位置索引 // 截取字符 & 之前的内容加到 decodedText 上 decodedText += rawText.slice(0, head.index) // 清除字符 & 之前的内容 advance(head.index) // 如果满足条件,则说明是命名字符引用,否则为数字字符引用 if (head[0] === '&') { let name = '' let value; // 字符 & 的下一个字符必须是 ASCII 字母或数字,这样才是合法的命名字符引用 if(/[0-9a-z]/i.test(rawText[1])){ // 根据引用表计算实体名称的最大长度, if (!maxCRNameLength) { maxCRNameLength = Object.keys(namedCharacterReferences).reduce( (max, name) => Math.max(max, name.length), 0 ) } // 从最大长度开始对文本进行截取,并试图去引用表中找到对应的项 for (let length = maxCRNameLength; !value && length > 0; --length) { // 截取字符 & 到最大长度之间的字符作为实体名称 name = rawText.substr(1, length) // 使用实体名称去索引表中查找对应项的值 value = (namedCharacterReferences)[name] } // 如果找到了对应项的值,说明解码成功 if( value ){ // 检查实体名称的最后一个匹配字符是否是分号 const semi = name.endsWith(';') // 如果解码的文本作为属性值,最后一个匹配的字符不是分号, // 并且最后一个匹配字符的下一个字符是等于号(=)、ASCII 字母或数字,将字符 & 和实体名称 name 作为普通文本 if ( asAttr && !semi && /[=a-z0-9]/i.test(rawText[name.length + 1] || '') ){ decodedText += '&' + name advance(1 + name.length) } else { // 其他情况下,正常使用解码后的内容拼接到 decodedTex decodedText += value advance(1 + name.length) } } else { // 如果没有找到对应的值,说明解码失败 decodedText += '&' + name advance(1 + name.length) } } else { // 如果字符 & 的下一个字符不是 ASCII 字母或数字,则将字符 & 作为普通文本 decodedText += '&' advance(1) } } } return decodedText } // 解析文本节点时通过 decodeHtml 对文本内容进行解码: function parseText(context) { // 省略部分代码 return { type: 'Text', content: decodeHtml(content) // 调用 decodeHtml 函数解码内容 } }
-
-
解码数字字符引用
-
什么是数字字符引用?
前缀 + Unicode 码点
-
如何解码数字字符引用?
function decodeHtml(rawText, asAttr = false){ // 省略部分代码 while( offse < end ){ if(head[0] === '&'){ } else { // 判断是十进制表示还是十六进制表示 const hex = head[0] === '&#x' // 根据不同进制表示法,选用不同的正则 const pattern = hex ? /^&#x([0-9a-f]+);?/i : /^&#([0-0]+);?/ // 最终,body[1] 的值就是 Unicode 码点 const body = pattern.exec(rawText) // 如果匹配成功,则调用 String.fromCodePoint 函数进行解码 if (body) { // 根据对应的进制,将码点字符串转换为数字 const cp = Number.parseInt(body[1], hex ? 16 : 10) // 码点的合法性检查 if (cp === 0) { // 如果码点值为 0x00,替换为 0xfffd cp = 0xfffd } else if (cp > 0x10ffff) { // 如果码点值超过 Unicode 的最大值,替换为 0xfffd cp = 0xfffd } else if (cp >= 0xd800 && cp <= 0xdfff) { // 如果码点值处于 surrogate pair 范围内,替换为 0xfffd cp = 0xfffd } else if ((cp >= 0xfdd0 && cp <= 0xfdef) || (cp & 0xfffe) === 0xfffe) { // 如果码点值处于 noncharacter 范围内,则什么都不做,交给平台 处理 } else if ( (cp >= 0x01 && cp <= 0x08) || cp === 0x0b || (cp >= 0x0d && cp <= 0x1f) || (cp >= 0x7f && cp <= 0x9f) ) { // 在 CCR_REPLACEMENTS 表中查找替换码点,如果找不到,则使用原码点 cp = CCR_REPLACEMENTS[cp] || cp } // 解码后追加到 decodedText 上 decodedText += String.fromCodePoint(cp) // 消费整个数字字符引用的内容 advance(body[0].length) } else { // 如果没有匹配,则不进行解码操作,只是把 head[0] 追加到 decodedText 上并消费 decodedText += head[0] advance(head[0].length) } } } return decodedText }
-
-
解析插值与注释
-
什么是文本插值?
{{ a }}
-
文本插值是如何解析的?
解析器在遇到文本插值的起始定界符({{)时,会进入文本"插值状态 6",并调用 parseInterpolation 函数来解析插值内容
parseInterpolation函数: 一句话: 找到插值的开始和结束位置,然后提取表达式内容,消费这些内容
function parseInterpolation(context) { // 消费开始定界符 context.advanceBy('{{'.length); // 找到结束定界符的位置索引 const closeIndex = context.source.indexOf('}}'); if (closeIndex < 0) { console.error('插值缺少结束定界符'); } // 截取开始定界符与结束定界符之间的内容作为插值表达式 const content = context.source.slice(0, closeIndex); // 消费表达式的内容 context.advanceBy(content.length); // 消费结束定界符 context.advanceBy('}}'.length); // 返回类型为 Interpolation 的节点,代表插值节点 return { type: 'Interpolation', // 插值节点的 content 是一个类型为 Expression 的表达式节点 content: { type: 'Expression', // 表达式节点的内容则是经过 HTML 解码后的插值表达式 content: decodeHtml(content) } }; } // 配合上面的 parseInterpolation 函数,解析如下模板内容: const ast = parse(`<div>foo {{ bar }} baz</div>`) // 得到如下 AST: const ast = { type: 'Root', children: [ { type: 'Element', tag: 'div', isSelfClosing: false, props: [], children: [ { type: 'Text', content: 'foo ' }, // 插值节点 { type: 'Interpolation', content: [ type: 'Expression', content: ' bar ' ] }, { type: 'Text', content: ' baz' } ] } ] }
-
第 17章 编译优化
编译优化指的是编译器将模板编译为渲染函数的过程中,尽可能多地提取关键信息,并以此指导生成最优代码的过程。
17.1 动态节点收集与补丁标志
17.1.1 传统 Diff 的问题
传统 DOM 的 Diff 算法中, 当它在对比新旧两颗虚拟 DOM 数的时候,总是要按照虚拟 DOM 的层级结构一层一层地遍历.
<div id="foo">
<p class="bar">{{ text }}</p>
</div>
// 上面模板中唯一可能变化的就是 text 的值, 最高效的更新方式就是直接设置 p 标签的文本内容.
/*
对比 div 节点,以及该节点的属性和子节点。
对比 p 节点,以及该节点的属性和子节点。
对比 p 节点的文本子节点,如果文本子节点的内容变了,则更新 之,否则什么都不做。
传统 Diff 存在很多无意义的对比操作.
如果能够跳过这些无意义的操作,性能将 会大幅升。而这就是 Vue.js 3 编译优化的思路来源
传统 Diff 算法无法利用编译时提取到的任何关键信息,导致渲染器运行时不能去做相关的优化
而 Vue3 的编译器会将编译时得到的关键信息附着在他生成的虚拟 DOM 上,然后通过虚拟 DOM 传给渲染器
*/
17.1.2 Block PatchFlags
只要运行时能够区分动态内容和静态内容,即可实现极致的优化策略
<div>
<div>foo</div>
<p>{{ bar }}</p>
</div>
// 在上面这段模板中,只有 {{ bar }} 是动态的内容
// 传统虚拟 DOM 是这样描述的
const vnode = {
tag: "div",
children: [
{ tag: 'div', children: 'foo' },
{ tag: 'p', children: ctx.bar },
]
}
// 编译优化之后,编译器会将它取到的关键信息"附着"到虚拟 DOM 节点上
const vnode = {
tag: "div",
children: [
{ tag: 'div', children: 'foo' },
{ tag: 'p', children: ctx.bar, patchFlag: 1 }, // 动态节点
]
}
/*
数字 1:代表节点有动态的 textContent(例如上面模板中的 p 标签)。
数字 2:代表元素有动态的 class 绑定。
数字 3:代表元素有动态的 style 绑定。
数字 4:其他......。
*/
const PatchFlags = {
TEXT: 1, // 代表节点有动态的 textContent
CLASS: 2, // 代表元素有动态的 class 绑定
STYLE: 3
}
// 在虚拟节点的创建阶段,把它的动态子节点提取出来,并将其存储到该虚拟节点的 dynamicChildren 数组内:
const vnode = {
tag: "div",
children: [
{ tag: 'div', children: 'foo' },
{ tag: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT },
],
// 将 children 中的动态节点提取到 dynamicChildren 数组中
dynamicChildren: [
// p 标签具有 patchFlag 属性,因此它是动态节点
{ tag: 'p', children: ctx.bar, patchFlag: PatchFlags.TEXT }
// 不仅能够收集它的直接动态子节点,还能够收集所有动态节点(如孙节点)
]
}
/*
当渲染器在更新一个 Block 时,会忽略虚拟节点的 children 数组,
而是直接找到该虚拟节点的 dynamicChildren 数组,并只更新该数组中的动态节点。
这样,在更新时就实现了跳过静态内容,只更新动态内容。
当一个动态节点的 patchFlag 值为数字 1 时,我们知道它只存在动态的文本节点,所以只需要更新它的文本内容即可。
*/
17.1.3 收集动态节点
// 动态节点栈
const dynamicChildrenStack = []
// 当前动态节点集合
let currentDynamicChildren = null
// openBlock 用来创建一个新的动态节点集合,并将该集合压入栈中
function openBlock() {
dynamicChildrenStack.push((currentDynamicChildren = []))
}
// closeBlock 用来将通过 openBlock 创建的动态节点集合从栈中弹出
function closeBlock() {
currentDynamicChildren = dynamicChildrenStack.pop()
}
// 第四个参数是补丁标志,它代表当前虚拟 DOM 节点是一个动态节点
function createVNode(tag, props, children, flags) {
const key = props && props.key;
props && delete props.key;
const vnode = {
tag,
props,
children,
key,
patchFlags: flags
};
if (typeof flags !== 'undefined' && currentDynamicChildren) {
// 动态节点,将其添加到当前动态节点集合中
currentDynamicChildren.push(vnode);
}
return vnode;
}
render() {
// 1. 使用 createBlock 代替 createVNode 来创建 block
// 2. 每当调用 createBlock 之前,先调用 openBlock 创建一个新的动态节点集合
return (openBlock(), createBlock('div', null, [
createVNode('p', { class: 'foo' }, null, 1 ),
createVNode('p', { class: 'bar' }, null)
]))
}
function createBlock(tag, props, children) {
// block 本质上也是一个 vnode
const block = createVNode(tag, props, children)
// 将当前动态节点集合作为 block.dynamicChildren
block.dynamicChildren = currentDynamicChildren
// 关闭 block
closeBlock()
// 返回
return block
}
17.1.4 渲染器的运行时支持
// 只更新 dynamicChildren 中的动态节点
function patchElement(n1, n2) {
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
// 省略部分代码
if (n2.dynamicChildren){
// 调用 patchBlockChildren 函数,这样只会更新动态节点
patchBlockChildren(n1, n2)
} else {
patchChildren(n1, n2, el)
}
}
function patchBlockChildren(n1, n2){
// 只更新动态节点即可
for(let i = 0; i < n2.dynamicChildren.length; i++){
patchElement(n1.dynamicChildren[i], n2.dynamicChildren[i])
}
}
// 针对性地完成靶向更新
function patchElement(n1,n2){
const el = n2.el = n1.el
const oldProps = n1.props
const newProps = n2.props
if (n2.patchFlags) {
// 靶向更新
if (n2.patchFlags == 1) {
// 只需要更新 class
}else if (n2.patchFlags == 2) {
// 只需要更新 style
}else if (...) {
// ...
}
}else{
// 全量更新
for(const key in newProps){
if(newProps[key] != oldProps[key]){
patchProps(el, key, oldProps[key], newProps[key])
}
}
for(const key in oldProps){
if(!(key in newProps)){
patchProps(el,key,oldProps[key],null)
}
}
}
// 在处理 children 时,调用 patchChildren 函数
patchChildren(n1, n2,el)
}
17.2 block树
17.2.1 v-if 指令的节点
<div>
<section v-if="foo">
<p>{ a }</p>
</section>
<section v-else> <!-- 即使这里是 section -->
<div> <!-- 这个 div 标签在 Diff 过程中被忽略 -->
<p>{ a }</p>
</div>
</section>
</div>
// 将两个 <section> 标签都作为 Block 角色,构成一棵 Block 树
/*
Block(Div)
- Block(Section v-if)
- Block(Section v-else)
*/
// 父级 Block 除了会收集动态子代节点之外,也会收集子 Block。
// 因此,两个子 Block(section) 将作为父级 Block(div) 的动态节点被收集到父级 Block(div) 的 dynamicChildren 数组中
const block = {
tag: 'div',
dynamicChildren: [
{/* Block(Section v-if) 或者 Block(Section v-else) */}
{ tag: 'section', key: 0 /* key 值会根据不同 的 Block 而发生变化 */, dynamicChildren: [...] },
]
}
/*
当 v-if 条件为真时,父级 Block 的 dynamicChildren 数组中包含的是 Block(section v-if)
当 v-if 的条件为假时, 父级 Block 的 dynamicChildren 数组中包含的将是 Block(section v-else)
根据 Block 的 key 值区分出更新前后的两个 Block 是不同的,并使用新的 Block 替换旧的 Block
*/
17.2.2 带有 v-for 指令的节点
// v-for 指令的节点也会让虚拟 DOM 树变得不稳定
// 解决方法很简单,我们只需要让带有 v-for 指令的标签 也作为 Block 角色即可。
const block = {
tag: 'div',
dynamicChildren: [
// 这是一个 Block,它有 dynamicChildren
{ tag: Fragment, dynamicChildren: [/* v-for 的节点 */] },
{ tag: 'i', children: ctx.foo, 1 /* TEXT */ },
{ tag: 'i', children: ctx.bar, 1 /* TEXT */ },
]
}
17.2.3 Fragment 的稳定性
放弃根据dynamicChildren 数组中的动态节点进行靶向更新的思路,并回退到传统虚拟 DOM 的 Diff 手段,即直接使用 Fragment 的 children而非 dynamicChildren 来进行 Diff 操作
当 Fragment 的子节点进行更新时,就可以恢复优化模式。
Fragment 是稳定的( v-for 指令的表达式是常量时 / 模板中有多个根节点)
17.3 静态提升
静态提升: 即把纯静态的节点提升到渲染函数之外
能够减少更新时创建虚拟 DOM 带来的性能开销和内存占用。
这个例子展示了如何静态提升:
<div>
<p>static text</p>
<p>{{ title }}</p>
</div>
//在没有静态提升
function render() {
return (openBlock(), createBlock('div', null, [
createVNode('p', null, 'static text'),
createVNode('p', null, ctx.title, 1),
]))
}
// 当响应式数据变化,并使得渲染函数重新执行时,并不会重新创建静态的虚拟节点,从而避免了额外的性能开销。
// 把静态节点提升到渲染函数之外
const hoist1 = createVNode('p', null, 'static text'),
// 使用静态提升
function render() {
return (openBlock(), createBlock('div', null, [
hoist1, // 静态节点引用
createVNode('p', null, ctx.title, 1),
]))
}
// 静态提升是以树为单位的, 下面模板整个元素及其子节点都会被提升
<div>
<div>
<p>static text</p>
<div>
<p>static text</p>
</div>
</div>
</div>
// 另一个例子
<div>
<p foo="bar" a=b>{{ text }}</p>
</div>
// 静态提升的 props 对象 将纯静态的 props 升到渲染函数之 外
const hoistProp = { foo: 'bar', a: 'b' }
function render(ctx) {
return (openBlock(), createBlock('div', null, [
createVNode('p', hoistProp, ctx.text)
]))
}
17.4 预字符串化
预字符串化是基于静态提升的一种优化策略 .
<div>
<p></p>
//...20 个 p 标签
<p></p>
</div>
// 采用静态提升优化策略时编译后的代码
cosnt hoist1 = createVNode('p', null, null, PatchFlags.HOISTED)
// ....
cosnt hoist20 = createVNode('p', null, null, PatchFlags.HOISTED)
render() {
return (openBlock(), createBlock('div', null, [ hoist1, /*...*/ hoist20]))
}
// 预字符串化能够将这些静态节点序列化为字符串,并生成一个 Static 类型的 VNode:
const hoistStatic = createStaticVNode('<p></p>...20个...<p></p>')
render() {
return (openBlock(), createBlock('div', null, [hoistStatic]))
}
/*
大块的静态内容可以通过 innerHTML 进行设置,在性能上具有一定优势。
减少创建虚拟节点产生的性能开销。
减少内存占用。
*/
17.5 缓存内联事件处理函数
缓存内联事件处理函数可以避免不必要的更新
function render(ctx) {
return h(Comp, {
// 内联事件处理函数, 每次重新渲染都会创建一个新的 props 对象. 造成性能开销,需要对内联事件进行缓存
onChange: () => (ctx.a + ctx.b)
})
}
function render(ctx,cache) {
return h(Comp, {
// 将内联事件处理函数缓存到 cache 数组中
onChange: cache[0] || (cache[0] = ($event) => (ctx.a + ctx.b))
})
}
/*
渲染函数的第二个参数是一个数组 cache,该数组来自组件实例,
我们可以把内联事件处理函数添加到 cache 数组中。
这样,当渲染函数重新执行并创建新的虚拟 DOM 树时,会优先读取缓存中的事件处理函数。
这样,无论执行多少次渲染函数,props 对象中 onChange 属性的值始终不变,
于是就不会触发 Comp 组件更新了
*/
17.6 v-once
Vue.js 3 不仅会缓存内联事件处理函数,配合 v-once 还可实现对虚拟 DOM 的缓存。
<section>
<div v-once>{{ foo }}</div>
</section>
// 被编译为
function render(ctx, cache) {
return (openBlock(), createBlock('div', null, [
cache[1] || (cache[1] = createVNode("div", null, ctx.foo, 1 /* TEXT */))
]))
}