# 框架设计概览
# 权衡的艺术
命令式和声明式:
命令式关注过程,声明式关注结果。Vue的内部实现是命令式,暴露给用户是声明式。
性能和可维护性:
性能上命令式更优,声明式多了查找变更的性能消耗。但是声明式可维护性高。
虚拟DOM的性能:增量更新
虚拟DOM为了最小化找出差异的性能消耗。
与innerHTML对比:
- 创建页面差距不大
- 更新页面时差异较大,innerHTML需要销毁旧DOM元素,全量创建新的DOM元素。
运行时和编译时:
- 纯运行时:灵活性高,性能和安全性低
- 运行时+编译时:保留灵活性,尽可能优化性能
- 纯编译时:性能高,但灵活性较低
# 框架设计的额核心要素
- 提升用户的开发体验:控制台信息
- 控制框架代码的体积:__DEV__区分开发环境
- 做到良好的Tree-Shaking
/*#__PURE__*/ //告诉rollup.js没有副作用,可以放心删除
- 框架的构建产物:IIFE、ESM
- 特性开关:使用rollup.js的预定义常量插件实现
- 错误处理:统一错误处理接口
# vue3的设计思路
- 虚拟DOM就是用JS对象来描述DOM,渲染函数返回的结果就是虚拟DOM
- 渲染器的作用是将虚拟DOM渲染成真实DOM
- 组件的本质:组件就是一组虚拟DOM元素的封装,可以是函数或者JS对象
- 编译器:作用是将template模板编译为渲染函数,并添加在script标签块的组件对象上
# 响应系统
# 响应式系统的作用与实现
副作用函数effect,执行会直接或间接影响其他函数的执行。√
通过拦截字段的读取和设置操作,读取时,将effect放置在桶中,设置时,拿出effect并执行。需要将一个effrct函数绑定在一个某个对象的某个属性上。√
数据结构使用了weakMap、Map和Set。weakMap的键为对象,值为对一个map,存储该对象所有属性的依赖;Map中的key为对象的属性,值为key相关的响应函数集合set。把副作用函数收集到桶里的逻辑封装为track,把触发副作用函数执行的逻辑封装到trigger中。变量activeEffect至关重要√
分支切换带来的effect函数遗留问题:为effect添加依赖集合deps,每次执行时先把所有的相关依赖删除,这部分逻辑封装在cleanup里面,trigger中用一个新的set防止一直循环√
嵌套的effect和effect栈,避免相互影响√
避免无限递归循环,在同一个effect中读取和设置时会触发,trigger前添加守卫。√
调度执行,让用户控制trigger的执行方式、执行次数、执行时机√
计算属性computed和lazy,添加值缓存、懒执行的副作用函数√
watch的实现原理,基于调度器实现,递归地watch对象的所有属性,获取旧值和新值√
立即执行的watch和回调执行时机,immediate指定回调是否立即执行,flush指定同步和异步执行√
副作用过期,OnInvalidate注册过期回调,通过闭包exppired实现的
# 非原始值的响应式方案
# 基本原理
- Proxy能够拦截对象的基本操作,例如读取、复制、函数调用。
- Reflect.get(),可以绕过对象的getter函数获取属性值。不改变语言原本行为的情况下进行扩展和定制,是一种元编程
Reflect.get(target, propertyKey [, receiver])
target为对象,propertyKey为属性名,receiver表示 getter 函数的执行上下文对象
常规对象和异质对象。函数对象会部署[[call]]内部方法,普通对象则不会。
常规对象:内部方法用ECMA规范10.1给出的定义实现、[[call]]通过10.2.1实现、[[construct]]10.2.2实现;其他的都是异质对象,如Proxy
# 代理对象
要重点处理for...in的读取、添加、更新、删除操作。
读取操作:不同的读取,根据ECMA规范中使用的基本方法区分
- 访问属性:obj.foo,用拦截get
- 判断是否存在指定key:key in obj,has拦截函数
- for...in循环,ownkeys拦截
设置/添加操作:
- set拦截函数,区分添加和更新
删除操作:
- delete p.foo
合理的触发响应:
- 值是否真的发生了变化
- 屏蔽原型带来的更新
**深响应与浅响应:**是否递归地把所有子属性也设置为可响应
**只读与浅只读:**递归实现,拦截设置和删除操作
# 代理数组(异质对象)
读取操作:
- 通过索引访问:arr[0]
- 访问数组长度: arr.length
- for...in
- for...of
- 原型方法,concat/join/every/some/find/findIndex/includes
设置操作:
- 索引修改:arr[0]=0
- 修改长度:arr.length = 0
- 栈方法
- 原型方法:split/fill/sort
数组的索引与length
- 设置索引时可能会修改length,设置length时可能会影响数组元素
遍历数组
- for...in和遍历对象时相同,使用length属性作为追踪的key
- for...of遍历可迭代对象的,数组内建了Symbol.iterator方法的实现
数组的查找方法,incodes/indexOf/lastIndexOf,通过apply实现的,将this指向代理对象。实现了对原始对象和代理对象的查找。
栈方法通过设置是否追踪,实现对track的屏蔽
# 代理Set和Map(有空了再看)
# 原始值的响应式方案
- 使用一个非原始值包裹原始值,
// 封装一个ref函数
fuction ref(val){
// 内部创建一个包裹对象
const wrapper = {
value:val
}
// 将包裹对象变成响应式数据
return reactive(wrapper)
}
区分包裹对象和非原始值的响应式对象
- 响应丢失问题:...展开运算符,将响应式数据转换成类似于ref结构的数据
- 自动脱ref:去掉.value,通过Proxy代理实现,如果访问的属性是ref,直接返回它的value
# 渲染器renderer
# 渲染器的设计
利用响应系统,自动调用渲染器,实现页面的渲染和更新。
渲染器吧虚拟DOM节点渲染成真实DOM节点的过程叫做挂载。
自定义渲染器,将依赖于浏览器的API进行抽离。这样可以在NodeJs中执行。
# 挂载与更新
挂载子节点和元素的属性,setAttribute
HTML attributes作用是DOM properties的初始值
正确地设置元素属性:优先使用DOM properties,封装为一个pathProps函数
class的特殊处理,结构转换,el.className/el.classList/setAttribute性能更好
卸载操作:渲染null空内容时发生
innerHTML清空的缺点:
- 不能正确的调用子组件的生命周期钩子函数
- 不会触发自定义指令的钩子函数
- 不会移除DOM元素上的事件处理函数
让vnode.el引用真实的DOM元素,并封装为unmount函数
- 区分vnode的类型
- 事件的处理:约定以on开头的属性是事件;伪造一个事件处理函数invoker,把它的值指向具体的事件,避免更新事件时移除上一个事件,单一事件可以有多个处理函数,使用数组
- 事件的冒泡和更新时机,需要屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行
- 更新子节点,子节点的类型有三种:文本/空/其他,将逻辑封装为patchChildren,理清判断逻辑,总共有9种情况
- 文本节点和注释节点
- Fragment 本身不会渲染任何DOM元素,只需要渲染Fragment的所有子节点
# 简单diff算法(从头到尾)
- 减少DOM操作的性能开销,遍历较短的一组,如果新的长,说明需要挂载,旧的长,说明需要卸载
- DOM的复用和Key的作用,引入额外的key作为vnode的标识,key相同进行patch
- 找到需要移动的元素
- 如何移动元素,遍历新的子节点,根据在旧子节点中的引用,移动真实DOM,记录最大索引,核心是移动到前一个对应的真实DOM后面,
- 添加新元素,在旧的中找不到时find=false
- 移除不存在的元素。在新的中遍历完旧的之后,需要反过来遍历一次,查找需要删除的元素。
# 双端diff算法(从头尾到中间)
同时对新旧子节点的两个端点进行扫描,需要四个索引值
每一轮步骤:以新的数组为移动依据
- 比较旧的起始与新的起始,如果key匹配,patch后,newstart++,oldstart++
- 比较旧的结束语新的结束,如果key匹配,patch后,newend--,oldend--
- 比较旧的开始与新的结束,如果key匹配,patch后,oldstart的真实DOM移动到oldend真实DOM之后,,newstart++,oldend--
- 比较旧的结束与新的开始,如果key匹配,patch后,oldend的真实DOM移动到oldstart真实DOM之前,,oldstart++,newend--
每一轮移动两个指针,直至新旧两组指针相遇
非理想情况:
- 找到newstart在old中的索引,并移动到最前面,old中的原来的位置变为undefined
- old中遇到undefined,跳过
添加新元素:diff完之后,再遍历一次new节点,每次都插在oldstart之前
移除不存在的元素:遍历old,依次卸载剩余的。
# 快速diff算法
类似于文本diff,先对前缀,后缀进行预处理
预处理流程:
- 判断前缀,指针j
- 判断后缀,newend,oldend
非理想情况:预处理结束后,new、old都有剩余,用source记录new的剩余,source存储new中的节点在old中的索引。建立索引表,时间复杂度降为O(n)
- 在source中寻找最长递增子序列(可以不连续),从后向前遍历source[i]及其最长递增子序列seq[s]。
- source[i]==-1,则挂载该节点
- i!=seq[s],需要移动节点
最长递增子序列中的节点位置是不需要移动的
# 组件化
# 组件的实现原理
渲染组件用vnode.type存储组件对象
组件状态与自更新
- 执行data()函数,并用reactive()包装为响应式对象
- 调用render函数,this指向state
- 将渲染任务包装在effect中
- 通过调度器,将任务缓冲到微任务队列中,这样就有机会对任务进行去重
组件实例与组件的生命周期
组件实例是一个状态合集,维护者组件运行过程中的所有信息
- state:组件自身状态数据
- isMounted:表示是否被挂载
- subTree:渲染函数返回的虚拟DOM
props与组件的被动更新
- 将MyComponent.props和vnode.props结合,解析出props和attrs,其中attrs是MyComponent中没有定义的props
- 被动更新就是父组件更新引起的子组件更新
- 封装一个渲染上下文对象renderContext,并优先从上下文中读取,实质上是组件实例的代理对象
setup函数的作用与实现(只会在被挂载时执行一次)
- 配合组合式API,建立组合逻辑、创建响应式数据、创建函数、注册生命周期钩子等等
- 接受两个参数props,setupContext,后者存储着slots、emit、attrs、expose
- 如果返回数据对象,则需要暴露在渲染上下文中。
注册事件和emit的实现
通过v-on指令为组件绑定的事件,经过编译后,会以OnXXX形式存储在props属性中。
触发自定义事件的本质就是,根据事件名称去props数据对象中寻找相应的事件处理函数并执行。
插槽的工作原理和实现
组件模板的插槽内容会被编译为插槽函数,插槽函数执行的返回值就是具体的插槽内容
注册生命周期
维护一个当前组件实例currentInstance和mount数组
# 异步组件与函数式组件
# 异步组件的实现原理
- 封装defineAsyncComponent函数,判断是否加载成功
- 加载器、超时时间、并可以指定出错是渲染的模块。把错误对象传递到Error组件中
- loading组件,delay后加载loading
- 重试机制,为用户提供重试的能力
# 函数式组件
函数式组件本质就是一个普通函数,返回值是一个虚拟DOM
# 内建组件和模块
# KeepAlive组件
本质是缓存管理,加上特殊的挂载、卸载逻辑。
在内部组件的vnode对象上添加一些属性,以便渲染器能够据此执行特定的逻辑。
- shouldKeepAlive:渲染之卸载内部组件时可以判断是否需要被keepalive
- keepAliveInstance:便于卸载时访问失活函数
- KeptAlive:标记已经被缓存
include与exclude
缓存管理:Map
- map的键是组件选项对象,vnode.type,值是vnode对象,虚拟DOM
- 设置缓存的阈值,超出时修剪,策略最新一次访问
# Teleport组件
将执行内容渲染到特定容器中,不受DOM层级的限制。
const Teleportt = {
__isTeleport: true,
process(n1,n2,container,anchor){
// 渲染逻辑
}
}
# Transition组件
帮助制作基于状态变化的过渡和动画
核心原理:
- 当DOM元素被挂载时,将动效附加在该DOM上
- 当DOM元素被卸载时,不立即卸载,而是等到附加在DOM上的动效执行完成后在卸载
# 编译器
# 编译器核心技术概览(3步)
完整的编译:词法分析、语法分析、语义分析、中间代码生成、优化、目标代码生成
vue的编译是DSL,将vue编译为渲染函数
vue的编译:模板、词法分析、语法分析、模板AST、Transformer、JS AST、代码生成、渲染函数
模板AST:
- type区分不同节点
- 标签节点的子节点在children中
- 属性和指令在props数组
- 不同节点的对象属性不同
语义分析:
- 检查v-else是否存在对应的v-if
- 分析属性值是否是静态的
const templateAST = parse(template)
const jsAST = transform(templateAST)
const code = generate(jsAST)
# parser的实现原理与状态机
解析器逐个读取字符串模板的字符,根据一定规则,切割成一个个token
有限状态自动机,随着字符的输入,解析器会在不同状态间迁移,将模板解析为一个一个的token
# 构造模板AST
递归下降算法,
栈+token列表可以实现
# AST的转换
- 深度优先遍历
- 对节点的操作和访问解耦,存放在上下文context中
- currentNode: 当前正转换的节点
- childIndex:当前节点在父节点的children中的位置索引
- parent:用来存储当前转换的父节点
- 元素的进入与退出
# 生成JS AST
函数声明语句:
- id 函数名称
- params:函数的参数
- body:函数体