大纲
了解源码的意义在哪里?
1. 学习编码规范和架构设计
在优秀的开源项目中,它们的编码规范和架构设计都是很棒的,另外在设计上也使用了大量的设计模式,阅读和学习源码能够快速提升我们的编码水平,让我们对设计模式有更深的理解。
并且我们阅读完一个源码后,可以触类旁通,能够快速地对其他框架的源码进行阅读和学习,减少时间成本。
2. 解决实际开发问题
在开发过程中,我们或多或少会遇到 bug ,比如:在有些循环里,执行异常了?Vue 改变值,但是 UI 不变?如果我们对相关源码有所涉猎,就可以快速定位到问题所在。
第一章:Vue3框架设计概览
编程范式
针对于目前的前端开发而言,主要存在两种编程范式:
- 命令式编程
- 声明式编程
命令式
详细描述做事过程 的方式就可以被叫做 命令式,命令式的核心在于:关注过程。
以下是一段命令式代码,它清楚的描述了:完成这件事情,所需要经历的过程。
// 1. 获取到指定的 div
const divEle = document.querySelector('#app')
// 2. 为该 div 设置 innerHTML 为 hello world
divEle.innerHTML = 'hello world'
声明式
声明式指的是:关注结果 的一种编程范式,他并不关心完成一个功能的详细逻辑与步骤。同样,如果我们通过代码来进行表示的话,以下例子:
<div>{{ msg }}</div>
对于 Vue 而言,它的内部实现一定是 命令式 的,而我们在使用 Vue 的时候,则是通过 声明式 来使用的。
也就是说: Vue 封装了命令式的过程,对外暴露出了声明式的结果。
性能与可维护性的权衡
接下来我们来看下从 性能 层面,Vue 所体现出来的一种权衡的方式。
对于 命令式 的代码而言,它直接通过 原生的 JavaScript 进行实现,这是最简单的代码我们把它的性能比作 1。
而声明式,无论内部做了什么,它想要实现同样的功能,内部必然要实现同样的命令式代码。所以它的性能消耗一定是 1 + N 的。
所以命令式的性能 > 声明式的性能,但声明式的可维护性,要远远大于命令式的可维护性。所以Vue暴露的是声明式的接口。
在前端领域,想要使用 JavaScript 修改 html 的方式,主要有三种:原生 JavaScript、innerHTML、虚拟 DOM。
简单来说,虚拟DOM是用vnode来代表节点,所谓 vnode 本身是 一个普通的 JavaScript 对象,代表了渲染的内容,里面有非常多的属性。例如对象中通过 type 表示渲染的 DOM。比如 type === div:则表示 div 标签、type === Framgnet 则表示渲染片段(vue 3 新增)、type === Text 则表示渲染文本节点等等。
很多人都会认为 虚拟 DOM 的性能是最高的,其实不是。
我们看看这个对比:
但是它的 心智负担(书写难度)最小, 从而带来了 可维护性最高。所以哪怕它的性能并不是最高的。Vue 依然选择了 虚拟 DOM 来进行了渲染层的构建。这个也是一种性能与可维护性的权衡。
运行时和编译时
它们两个都是框架设计的一种方式,可单独出现,也可组合使用。那么下面咱们就分别来介绍一下它们。
运行时:
它指的是:利用 render 函数直接把 虚拟 DOM 转化为 真实 DOM 元素 的一种方式。
在整个过程中,不包含编译的过程,所以无法分析用户提供的内容,要渲染只能传入一个复杂的 js 对象。
编译时:
它指的是:直接把 template 模板中的内容,转化为 真实 DOM 元素。
因为存在编译的过程,所以可以分析用户提供的内容,Svelte就是一个纯编译时的库。
但是这里要注意: Svelte的真实性能,没有办法达到理论数据。
运行时 + 编译时:
它的过程被分为两步:
- 先把 template 模板转化为 render 函数,也就是 编译时。
- 再利用 render 函数,把 虚拟 DOM 转化为 真实 DOM,也就是 运行时。
两者的结合,可以:
在编译时,分析用户提供的内容 。在运行时,提供足够的灵活性。
为什么 Vue 要设计成一个 运行时+编译时的框架呢?
那么想要理清楚这个问题,我们就需要知道 dom 渲染是如何进行的。
对于 dom 渲染而言,可以被分为两部分:
- 初次渲染 ,我们可以把它叫做 挂载
- 更新渲染 ,我们可以把它叫做 打补丁
初次渲染
当初始 div 的 innerHTML 为空时,
<div id="app"></div>
我们在该 div 中渲染如下节点:
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
</ul>
那么这样的一次渲染,就是 初始渲染。在这样的一次渲染中,我们会生成一个 ul 标签,同时生成三个 li 标签,并且把他们挂载到 div 中。
更新渲染
那么此时如果 ul 标签的内容发生了变化:
<ul>
<li>3</li>
<li>1</li>
<li>2</li>
</ul>
li - 3 上升到了第一位,浏览器更新这次渲染无非有两种方式:、
- 删除原有的所有节点,重新渲染新的节点
- 删除原位置的
li - 3,在新位置插入li - 3
首先对于第一种方式而言:它的好处在于不需要进行任何的比对,需要执行 6 次(删除 3 次,重新渲染 3 次)dom 处理即可。
对于第二种方式而言:在逻辑上相对比较复杂。他需要分成两步来做:
-
- 对比 旧节点 和 新节点 之间的差异
- 根据差异,删除一个 旧节点,增加一个 新节点
那么根据以上分析,我们知道了:
- 第一种方式:会涉及到更多的
dom操作 - 第二种方式:会涉及到
js计算 + 少量的dom操作
const length = 10000
// 增加一万个dom节点,耗时 3.992919921875 ms
console.time('element')
for (let i = 0; i < length; i++) {
const newEle = document.createElement('div')
document.body.appendChild(newEle)
}
console.timeEnd('element')
// 增加一万个 js 对象,耗时 0.402099609375 ms
console.time('js')
const divList = []
for (let i = 0; i < length; i++) {
const newEle = {
type: 'div'
}
divList.push(newEle)
}
console.timeEnd('js')
从结果可以看出,dom 的操作要比 js 的操作耗时多得多,即:dom 操作比 js 更加耗费性能。
那么得出这样的结论之后我们就有答案了:
- 针对于 纯运行时 而言:因为不存在编译器,所以我们只能够提供一个复杂的
JS对象。 - 针对于 纯编译时 而言:因为缺少运行时,所以它只能把分析差异的操作,放到 编译时 进行,但是这种方式这将损失灵活性。
- 运行时 + 编译时:比如
vue或react都是通过这种方式来进行构建的,使其可以在保持灵活性的基础上,尽量的进行性能的优化,从而达到一种平衡。
实现
Vue 中运行时和编译时的实现大致分为三个模块:
- 响应式模块:
reactivity - 运行时模块:
runtime - 编译器模块:
compiler
举个例子来描述一下三者之间的基本关系:
<template>
<div>{{ proxyTarget.name }}</div>
</template>
<script setup>
import { reactive } from 'vue'
const target = {
name: '张三'
}
const proxyTarget = reactive(target)
</script>
在以上代码中:
- 首先,我们通过
reactive方法,声明了一个响应式数据。
- 然后,我们在
tempalte标签中,写入了一个div。我们知道这里所写入的html并不是真实的html,我们可以把它叫做 模板,该模板的内容会被 编译器(compiler) 进行编译,从而生成一个render函数
- 最后,
vue会利用 运行时(runtime) 来执行render函数,从而渲染出真实dom
第二章:响应式系统
副作用函数与响应式数据
这一章节下面会围绕这两个问题展开:
Vue中这样的响应性数据是如何进行实现的呢?Vue2和Vue3之间响应性的设计有什么变化吗?为什么会产生这种变化呢?
咱们先来看基本概念 副作用函数 与 响应式数据。
所谓 副作用函数 指的是 会产生副作用的函数,这样的函数非常的多。比如
在这段代码中, effect 的触发会导致全局变化 val 发生变化,那么 effect 就可以被叫做副作用函数。而如果 val 这个数据的变化,导致了视图的变化,那么 val 就被叫做 响应式数据。
副作用指的是:对数据进行操作时,所产生的一系列后果。
副作用可能是会有多个的。
JS 的程序性
我们了解下什么叫做:JS 的程序性
<script>
// 定义一个商品对象,包含价格和数量
let product = {
price: 10,
quantity: 2
}
// 总价格
let total = product.price * product.quantity;
console.log(`总价格:${total}`);
// 修改了商品的数量
product.quantity = 5;
console.log(`总价格:${total}`);
</script>
这是一个非常简单的 JS 逻辑,两次打印的值应该都是一样的:总价格:20
那么我们现在有个想法,商品数量发生变化了,总价格能不能自己随之变化
但是 JS 本身具备 程序性,所谓程序性指的就是:一套固定的,不会发生变化的执行流程 ,我们是不可能拿到想要的 50 的。
那么如果我们想要拿到这个 50 就必须要让程序具备 响应性
那么怎么去做呢?你进行了一个这样的初步设想:
- 创建一个函数
effect,在其内部封装 计算总价格的表达式 - 在第一次打印总价格之前,执行
effect方法 - 在第二次打印总价格之前,执行
effect方法
但是这样存在一个明显的问题,那就是:必须主动在数量发生变化之后,重新主动执行 effect 才可以得到我们想要的结果。那么这样未免太麻烦了。有什么好的办法吗?
数据劫持
vue 2 的响应性核心 API:Object.defineProperty
vue2 以 Object.defineProperty 作为响应性的核心 API ,该 API 可以监听:指定对象的指定属性的 getter 和 setter,该 API 接收三个参数:指定对象、指定属性、属性描述符对象。
<script>
// 定义一个商品对象,包含价格和数量
let quantity = 2
let product = {
price: 10,
quantity: quantity
}
// 总价格
let total = 0;
// 计算总价格的匿名函数
let effect = () => {
total = product.price * product.quantity;
};
// 第一次打印
effect();
console.log(`总价格:${total}`); // 总价格:20
// 监听 product 的 quantity 的 setter
Object.defineProperty(product, 'quantity', {
// 监听 product.quantity = xx 的行为,在触发该行为时重新执行 effect
set(newVal) {
// 注意:这里不可以是 product.quantity = newVal,因为这样会重复触发 set 行为
quantity = newVal
// 重新触发 effect
effect()
},
// 监听 product.quantity,在触发该行为时,以 quantity 变量的值作为 product.quantity 的属性值
get() {
return quantity
}
});
</script>
这样可以完成我们刚刚所设想的响应式程序
Object.defineProperty 在设计层的缺陷
vue2 使用 Object.defineProperty 作为响应性的核心 API,但是在 vue3 的时候却放弃了这种方式,转而使用实现,为什么会这样呢?
这是因为Object.defineProperty存在缺陷:
心智负担
在 vue 官网中存在这样的一段描述 :
由于 JavaScript 的限制,Vue 不能检测数组和对象的变化
<template>
<div id="app">
<ul>
<li v-for="(val, key, index) in obj" :key="index">
{{ key }} - {{ val }}
</li>
</ul>
<button @click="addObjKey">为对象增加属性</button>
<hr />
<ul>
<li v-for="(item, index) in arr" :key="index">
{{ item }}
</li>
</ul>
<button @click="addArrItem">为数组添加元素</button>
</div>
</template>
<script>
export default {
name: 'App',
data() {
return {
obj: {
name: '张三',
age: 30
},
arr: ['张三', '李四']
}
},
methods: {
addObjKey() {
this.obj.gender = '男'
console.log(this.obj) // 通过打印可以发现,obj 中存在 gender 属性,但是视图中并没有体现
},
addArrItem() {
this.arr[2] = '王五'
console.log(this.arr) // 通过打印可以发现,arr 中存在 王五,但是视图中并没有体现
}
}
}
</script>
在上面的例子中,我们呈现了 vue2 中响应性的限制:
- 当为 对象 新增一个没有在
data中声明的属性时,新增的属性 不是响应性 的 - 当为 数组 通过下标的形式新增一个元素时,新增的元素 不是响应性 的
我们知道
vue 2是以Object.defineProperty作为核心API实现的响应性Object.defineProperty只可以监听 指定对象的指定属性的 getter 和 setter- 被监听了
getter和setter的属性,就被叫做 该属性具备了响应性
那么这就意味着:我们 必须要让指定对象中存在该属性,才可以为该属性指定响应性。
所以由于 JavaScript 的限制,新增的属性就没有办法通过 Object.defineProperty 来监听 ,将失去响应性。
Object.defineProperty没有办法监听到数组下标引起的变化。
那么如果想要增加具备响应性的新属性,那么可以通过 Vue.set 方法实现,相当于主动触发了一次 Object.defineProperty。
但是,这种方式其实并不方便,需要开发者主动触发,造成了一定的心智负担。
无脑递归
Object.defineProperty 是监听到具体的属性值,在属性的层面去做响应式的监听,并且是通过递归去处理的。
假设监听的对象是一个套了十几层的对象,每个属性不管有没有在组件内使用到,只要在Vue2 的data中定义了,那么就会执行一次Object.defineProperty来对这个属性进行监听,就很恶心也很耗性能。
const obj = {
obj1:{
obj2:{
obj3:{
name:'张三',
age:18
}
}
}
}
那么此时,我们已经知道了这些 vue2 中的 “缺陷”,那么 vue3 是如何解决这些缺陷的呢?
vue3的响应性核心 API:Proxy
因为 Object.defineProperty 存在的问题,所以 vue3 中修改了这个核心 API,改为使用 Proxy 进行实现。
proxy 顾名思义就是 代理 的意思。简单来说,代理可以看作是对目标对象的“包装” ,为目标对象架设一层拦截,外界对该对象的访问,都必须通过这层拦截。提供了像get,set,deleteProperty等13种捕捉器。
使用:
const p = new Proxy(target, handler)
参数:
13种捕获器:
我们来看如下代码:
<script>
// 定义一个商品对象,包含价格和数量
let product = {
price: 10,
quantity: 2
}
// 生成 proxy 代理对象实例,该实例拥有被代理对象的所有属性并且可以被监听 getter 和 setter
// product:被代理对象
// proxyProduct:代理对象
const proxyProduct = new Proxy(product, {
// 监听 proxyProduct 的 set 方法,在 proxyProduct.xx = xx 时,被触发
// 接收四个参数:被代理对象 target,指定的属性名 key,新值 newVal,最初被调用的对象 receiver
// 返回值为一个 boolean 类型,true 表示属性设置成功
set(target, key, newVal, receiver) {
// 为 target 附新值
target[key] = newVal
// 触发 effect
effect()
return true
},
// 监听 proxyProduct 的 get 方法,在 proxyProduct.xx 时,被触发
// 接收三个参数:被代理对象 tager,指定的属性名 key,最初被调用的对象 receiver
// 返回值为 proxyProduct.xx 的结果
get(target, key, receiver) {
return target[key]
}
})
// 总价格
let total = 0;
// 计算总价格的匿名函数
let effect = () => {
total = proxyProduct.price * proxyProduct.quantity;
};
// 第一次打印
effect();
console.log(`总价格:${total}`); // 总价格:20
</script>
在以上代码中,我们可以发现,Proxy 和 Object.defineProperty 存在一个非常大的区别,那就是:
Proxy:
-
Proxy将代理一个对象(被代理对象),得到一个新的对象(代理对象),同时拥有被代理对象中所有的属性。- 当想要修改对象的指定属性时,我们应该使用 代理对象 进行修改
- 代理对象 的任何一个属性都可以触发
handler的getter和setter
Object.defineProperty:
-
Object.defineProperty为 指定对象的指定属性 设置 属性描述符- 当想要修改对象的指定属性时,可以使用原对象进行修改
- 通过属性描述符,只有 被监听 的指定属性,才可以触发
getter和setter
所以当 vue3 通过 Proxy 实现响应性核心 API 之后,vue 将 不会 再存在新增属性时失去响应性的问题。
同时,关于刚刚说到的Vue2无脑遍历问题,Proxy 就没有这些限制,我管你几层,反正我就监听你这个对象,你只要发生变化无论是值变化还是属性新增,我都触发监听。
打个类比:原本Object.defineProperty的原理就好像村里跟你收保护费,来到你家看有几口人,从你爷爷开始,一直到你爸爸,你,每个人都要交钱,但是后面你儿子出生了,只要他们不来就不会被收人头钱,但是Proxy 就像栏在你家门口,每个人进出都会自动识别是否在交保护费的名单内。原本需要标记多少个人(属性)需要交保护费,现在只需要标记有多少个家族(对象)要交保护费,由属性层面上的监听变为对象层面上的监听,也就是说只有真正被用到的响应式数据才会被数据劫持,减少了性能消耗。
proxy的最佳拍档:Reflect
当我们了解了 Proxy 之后,那么接下来我们需要了解另外一个 Proxy 的 “伴生对象”:Reflect
Reflect 多数时候会与 Proxy 配合进行使用,在 MDN 文档 Proxy 的例子中Reflect 也有出现。
详细的介绍大家可以去查阅相关文档,我们可以这样简单理解,Reflect就是我们平时使用到的Object类的一个升级版。
我们来看一下代码:
<script>
const obj = {
name: '张三'
}
console.log(obj.name) // 张三
console.log(Reflect.get(obj, 'name')) // 张三
</script>
由以上代码可以发现,两次打印的结果是相同的。这其实也就说明了 Reflect.get(obj, 'name') 本质上和 obj.name 的作用相同
那么既然如此,我们为什么还需要 Reflect 呢?
根据官方文档可知,对于 Reflect.get 而言,它还存在第三个参数 receiver,那么这个参数的作用是什么呢?
根据官网的介绍为:
如果target对象中指定了getter,receiver则为getter调用时的this值。
什么意思呢?我们来看以下代码:
<script>
const p1 = {
lastName: '张',
firstName: '三',
// 通过 get 标识符标记,可以让方法的调用像属性的调用一样
get fullName() {
return this.lastName + this.firstName
}
}
const p2 = {
lastName: '李',
firstName: '四',
// 通过 get 标识符标记,可以让方法的调用像属性的调用一样
get fullName() {
return this.lastName + this.firstName
}
}
console.log(p1.fullName) // 张三
console.log(Reflect.get(p1, 'fullName')) // 张三
// 第三个参数 receiver 在对象指定了 getter 时表示为 this
console.log(Reflect.get(p1, 'fullName', p2)) // 李四
</script>
在以上代码中,我们可以利用 p2 作为第三个参数 receiver ,以此来修改 fullName 的打印结果。即:此时触发的 fullName 不是 p1 的 而是 p2 的。
我们再来看下面这个例子:
<script>
const p1 = {
lastName: '张',
firstName: '三',
// 通过 get 标识符标记,可以让方法的调用像属性的调用一样
get fullName() {
return this.lastName + this.firstName
}
}
const proxy = new Proxy(p1, {
// target:被代理对象
// receiver:代理对象
get(target, key, receiver) {
console.log('触发了 getter');
return target[key]
}
})
console.log(proxy.fullName);
</script>
此时我们触发了 prox.fullName,在这个 fullName 中又触发了 this.lastName + this.firstName 那么 getter 应该被触发几次?
在 this.lastName + this.firstName 这个代码中,我们的 this 是 p1 ,而非 Proxy 。所以 lastName 和 firstName 的触发,不会再次触发 getter 。
所以此时 getter 应该被触发 3 次 ,但是 实际只触发了 1 次。 这不是我们想看到的结果,我们希望所有属性的getter 操作都能被监听到。
解决这个问题就需要使用到 Reflect.get 了。
我们已知,Reflect.get 的第三个参数 receiver 可以修改 this 指向,那么我们可以 利用 Reflect.get 把 fullName 中的 this 指向修改为 Proxy,来达到触发三次的效果
我们修改以上代码:
const proxy = new Proxy(p1, {
// target:被代理对象
// receiver:代理对象
get(target, key, receiver) {
//这里的receiver === proxy receiver就是代理对象
console.log('触发了 getter');
// return target[key]
return Reflect.get(target, key, receiver)
}
})
修改代码之后 getter 得到了三次的触发
当我们期望监听代理对象的 getter 和 setter 时,不应该使用 target[key] ,因为它在某些时刻(比如 fullName)下是不可靠的。而 应该使用 Reflect ,借助它的 get 和 set 方法,使用 receiver(proxy 实例) 作为 this,已达到期望的结果(触发三次 getter)。
总结
我们有两种方式可以监听 target 的 getter 和 setter 分别是:
Object.defineProperty:这是vue2的响应式核心API,但是这个API存在一些缺陷,他只能监听 指定对象 的 指定属性 的getter和setter。所以在 “某些情况下”,vue2的对象或数组会失去响应性。Proxy:这是vue3的响应式核心API。该API表示代理某一个对象。代理对象将拥有被代理对象的所有属性和方法,并且可以通过操作代理对象来监听对应的getter和setter。
最后如果我们想要 “安全” 的使用 Proxy,还需要配合 Reflect 一起才可以,Reflect确保了Proxy捕获器(handler)中调用者的上下文访问(this)正确。
Vue3的响应式实现
我们知道在 vue 中想要实现响应式数据,拥有两种方式:
reactiveref
下面我将结合一个简单的案例来讲解:
<body>
<div id="app">{{obj.name}}</div>
</body>
<script>
const { reactive } = Vue
const obj = reactive({
name: '张三'
})
// Vue内部调用了这个 effect 方法
// effect(() => {
// document.querySelector('#app').innerText = obj.name
// })
</script>
reactive
vue3中的reactive做了什么
我们看看vue3中的reactive做了什么?
- 触发
reactive方法 - 创建
reactive对象:return createReactiveObject - 进入
new Proxy
-
- 第一个参数
target:为传入的对象 - 第二个参数
handler:TargetType.COLLECTION = 2,targetType = 1,所以handler为baseHandlers - 那这个
baseHandlers是什么呢?
- 第一个参数
- 在
reactive方法中可知,baseHandlers是触发createReactiveObject传递的第三个参数:mutableHandlers - 而
mutableHandlers则是packages/reactivity/src/baseHandlers.ts中导出的对象 - 所以我们到
packages/reactivity/src/baseHandlers.ts中,为它的get(createGetter)和set(createSetter)分别打入一个断点 - 我们知道
get和set会在 取值 和 赋值 时触发,所以此时这两个断点 不会执行 - 最后
reactive方法内执行了proxyMap.set(target, proxy)方法 - 最后返回了代理对象。
- 那么至此
reactive方法执行完成。
由以上执行逻辑可知,对于 reactive 方法而言,其实做的事情非常简单:
- 创建了
proxy - 把
proxy加到了proxyMap里面 - 最后返回了
proxy
const reactive = (target) => {
const handler = {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, key)
return result
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
if (oldValue != result) {
trigger(target, key)
}
return result
}
}
return new Proxy(target, handler)
}
什么是WeakMap
WeakMap 是一种键值对的集合,对比 WeakMap 和 Map 的文档可知,他们两个具备一个核心共同点,那就是:都是 {key, value} 的结构对象。
但是对于 WeakMap 而言,他却存在两个不同的地方:
key必须是对象key是弱引用的
这个 弱引用 指的是什么?
概念
弱引用:不会影响垃圾回收机制。即:WeakMap 的 key 不再存在任何引用时,会被直接回收。
强引用:会影响垃圾回收机制。存在强应用的对象永远 不会 被回收。
我们来看下面的例子:
// map
<script>
// target 对象
let obj = {
name: '张三'
}
// 声明 Map 对象
const map = new Map()
// 保存键值对
map.set(obj, 'value')
// 把 obj 置空
obj = null
</script>
在当前这段代码中,如果我们在浏览器控制台中,打印 map 那么打印结果如下:
即:虽然 obj 已经不存在任何引用了,但是它并没有被回收,依然存在于 Map 实例中。这就证明 Map 是强应用的,哪怕 obj 手动为 null,但是它依然存在于 Map 实例中。
接下来同样的代码,我们来看 WeakMap:
// target 对象
let obj = {
name: '张三'
}
// 声明 Map 对象
const wm = new WeakMap()
// 保存键值对
wm.set(obj, 'value')
// 把 obj 置空
obj = null
在当前这段代码中,如果我们在浏览器控制台中,打印 wm 那么打印结果如下:
Vue使用 WeakMap 结构的原因就是 WeakMap 对 key 是弱引用,不影响垃圾回收器的工作。根据这个特性可知,一旦 key 被垃圾回收器回收,那么对应的键和值就访问不到了。WeakMap 经常用于存储那些只有当 key 所引用的对象存在时 (没有被回收) 才有价值的信息。
如何进行依赖收集
Vue3的处理方式:
副作用订阅effects集合将被存储在一个的 WeakMap<target, Map<key, Set<effect>>> 结构中:
WeakMap:
key:响应性对象
value:Map 对象
key:响应性对象的指定属性(因为属性也可能是对象所以用Map来存储)
value:指定对象的指定属性的 执行函数
effect API
上面说过副作用函数可以有很多个,那么当一个副作用函数执行时,如何追踪到这个副作用并且正确的对它进行依赖收集呢?
事实上,副作用函数都会在Vue的内部被 effect API 进行一个包裹处理
Vue会用一个全局变量来保存当前正在执行的副作用函数
let activeEffect; // 当前正在执行的副作用函数,这是一个全局变量 这会在运行之前被初始化
effect API会创建一个ReactiveEffect类,它将原本的 effect 函数包装在了一个新的副作用函数中。在运行实际的更新之前,这个外部函数会将自己设为当前活跃的副作用 activeEffect。这使得在更新期间的 track() 调用都能捕获到这个当前活跃的副作用。
// 伪代码
const effect = (eff) => {
activeEffect = eff // 1. 将副作用赋值给 activeEffect
activeEffect() // 2. 执行 activeEffect
activeEffect = null // 3. 重置 activeEffect
}
此时,我们已经创建了一个能自动跟踪其依赖的副作用函数,它会在任意依赖被改动时重新运行。
Vue 还提供了一个 API 来让你创建响应式副作用 watchEffect(),原理和 ReactiveEffect比较类似。
由以上逻辑可知,整个 effect API 主要做了3 件事情:
- 生成
ReactiveEffect实例 - 触发
fn方法,从而激活getter - 建立了
targetMap和activeEffect之间的联系
-
dep.add(activeEffect)activeEffect.deps.push(dep)
tracker
来看看track() 函数做了什么
const track = (target, key) => {
// 判断当前是否有 activeEffect,如果没有则不执行了
if (activeEffect) {
// 尝试从 targetMap 中,根据 target 获取 map
let depsMap = targetMap.get(target)
// 如果获取到的 map 不存在,则生成新的 map 对象,并把该对象赋值给对应的 value
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
// 获取当前key的所有依赖函数
let dep = depsMap.get(key)
// 如果 dep 不存在,则生成一个新的 dep,并放入到 depsMap 中
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
dep.add(activeEffect) // 添加 activeEffect 依赖
}
}
在 track() 内部,会检查当前是否有正在运行的副作用。如果有,我们会查找到一个存储了所有追踪了该属性的订阅者的 Set,然后将当前这个副作用作为新订阅者添加到该 Set 中,如果在第一次追踪时没有找到对相应属性订阅的副作用集合,它将会在这里新建。
真实情况中,副作用函数都会被包裹成一个 响应式副作用函数 ReactiveEffect 存储进 set 结构的 effects 集合中。
trigger
说完了 get 中的 track(),我们继续看看 set 中的 trigger()干了什么.
当给对象赋值时,触发代理对象的 set 拦截,并执行 trigger()函数
const trigger = (target, key) => {
// 获取当前 target 的 depsMap
const depsMap = targetMap.get(target)
// 没有就返回
if (!depsMap) return
// 获取依赖容器dep
let dep = depsMap.get(key)
// 依次执行 dep 中的 依赖函数
if (dep) {
dep.forEach((effect) => effect())
}
}
在 trigger 之中,查找到该属性的所有订阅副作用,遍历 targetMap 中该数据对应的 dep 中的每个副作用函数执行。
梳理
回顾一下我们刚刚所提到的关键字,对整个响应式实现流程进行一个梳理:
- 副作用函数的全局变量
targetMap
响应式副作用函数集合 (WeakMap 结构)存储了 { target -> key -> dep } 的链接
activeEffect
标记了当前正在执行的副作用,或者也可以理解为副作用栈中的栈顶元素。当一个副作用被压入栈时,会将 这个副作用赋值给 activeEffect 变量,而当副作用中的函数执行完后该副作用会出栈,并将 activeEffect 赋值为栈的下一个元素。所以当栈中只有一个元素时,执行完出栈后,activeEffect 就会为 undefined。
- 响应式副作用的实现
effect API (对外暴露的一个API)
在收集依赖之前,第一次创建并入栈的副作用函数类型,同时会被设置成 activeEffect ,以便于在原始副作用函数时,在触发的 get() 拦截器中执行 track(),开始依赖收集。
- track 依赖收集
根据依赖收集副作用函数 储存进全局的 targetMap
- trigger 派发更新
set ( ) 时执行 track 到的集合, effects.forEach(run) 遍历集合内的所有副作用函数并执行
简而言之,Vue 通过一个函数包裹器effect API跟踪当前正在运行的函数,在函数被调用前就启动跟踪,而 Vue 在派发更新时,就能准确的找到这些被收集起来的副作用函数,存储在targetMap结构中,当数据发生更新时再次执行它,getter / setter 则是将ReactiveEffect, track 和 trigger 串在了一起。
reactive 函数的局限性
但是对于 reactive 而言,它其实是具备一些局限性的。
- 我们知道,对于
reactive函数而言,它会把传入的object作为proxy的target参数,而对于proxy而言,他只能代理 对象,而不能代理简单数据类型,所以说:我们不可以使用reactive函数,构建简单数据类型的响应性。 - 一个数据是否具备响应性的关键在于:是否可以监听它的
getter和setter****。而根据我们的代码可知,只有proxy类型的 代理对象 才可以被监听getter和setter,而一旦解构,对应的属性将不再是proxy类型的对象,所以:解构之后的属性,将不具备响应性。
那么到现在我们知道了,reactive 不可以对 简单数据类型使用,并且 不可以解构。那么如果我们期望 简单数据类型也具备响应性,那么我们又应该如何做呢?
此时我们可以使用 ref 函数来进行实现。
ref
我们知道了只靠 reactive 函数,vue 是没有办法构建出完善的响应式系统的。
所以我们还需要另外一个函数 ref。
ref如何处理复杂数据类型的响应性
我们先看一下源码做了什么:
ref函数中,直接触发createRef函数- 在
createRef中,进行了判断如果当前已经是一个ref类型数据则直接返回,否则 返回RefImpl类型的实例 - 那么这个
RefImpl是什么呢?
-
RefImpl是同样位于packages/reactivity/src/ref.ts之下的一个类- 该类的构造函数中,执行了一个
toReactive的方法,传入了value并把返回值赋值给了this._value,那么我们来看看toReactive的作用:
-
-
toReactive方法把数据分成了两种类型:
-
-
-
-
- 复杂数据类型:调用了
reactive函数,即把value变为响应性的。 - 简单数据类型:直接把
value原样返回
- 复杂数据类型:调用了
-
-
-
- 该类提供了一个分别被
get和set标记的函数value****
- 该类提供了一个分别被
-
-
- 当执行
xxx.value时,会触发get标记 - 当执行
xxx.value = xxx时,会触发set标记
- 当执行
-
- 至此
ref函数执行完成。
由以上逻辑可知:
- 对于
ref而言,主要生成了RefImpl的实例 - 在构造函数中对传入的数据进行了处理:
-
- 复杂数据类型:转为响应性的
proxy实例 - 简单数据类型:不去处理
- 复杂数据类型:转为响应性的
RefImpl分别提供了get value、set value以此来完成对getter和setter的监听,注意这里并没有使用proxy
const ref = raw => {
const r = {
get value(){
// 如果传入的是对象,则用reactive处理
if (typeof raw === 'object') {
return reactive(raw)
}
track(r, 'value');
return raw;
},
set value(newVal){
raw = newVal;
trigger(r, 'value');
}
}
return r;
}
get value()
在 get value 中会触发 track 方法 ,整个 get value 的处理逻辑还是比较简单的,主要还是通过之前的 track 来收集依赖。
再次触发 get value()
当我们现在修改一下数据:
obj.value.name = '李四'
大家想一下,此时会触发 get value 还是 set value ?
以上代码可以被拆解为:
const value = obj.value
value.name = '李四'
那么通过以上代码我们清晰可知,其实触发的应该是 get value 函数。
在 get value 函数中:
- 返回
this._value:
-
- 通过 构造函数,我们可知,此时的
this._value是经过Reactive函数过滤之后的数据,在当前实例中为proxy实例。
- 通过 构造函数,我们可知,此时的
get value执行完成
由以上逻辑可知:
const value是proxy类型的实例,即:代理对象,被代理对象为{name: '张三'}- 执行
value.name = '李四',本质上是触发了proxy的setter - 根据
reactive的执行逻辑可知,此时会触发trigger触发依赖。 - 至此,修改视图
ref如何处理简单数据类型的响应性
整个 ref 初始化的流程与复杂数据类型完全相同,但是有一个不同的地方,需要 特别注意:因为当前不是复杂数据类型,所以在 toReactive 函数中,不会通过 reactive 函数处理 value。所以 this._value 不是 一个 proxy。即:无法监听 setter 和 getter。
get value()
与处理复杂数据类型类似,使用track()对依赖做一个收集。
大不同:set value()
我们将要执行 obj.value = '李四' 的逻辑。我们知道在复杂数据类型下,这样的操作(obj.value.name = '李四'),其实是触发了 get value 行为。
但是,此时,在 简单数据类型之下,obj.value = '李四' 触发的将是 set value 形式,这里也是 ref 可以监听到简单数据类型响应性的关键。
跟踪代码,进入到 set value(newVal):
- 通过
hasChanged方法,对比数据是否发生变化
- 发生变化,则触发
trigger
- 执行
trigger触发依赖,完成响应性
由以上逻辑可知:
- 简单数据类型的响应性,不是基于
proxy或Object.defineProperty进行实现的,而是通过:set语法,将对象属性绑定到查询该属性时将被调用的函数 上,使其触发xxx.value = '李四'属性时,其实是调用了set value函数。 - 在
value函数中,触发依赖
所以,我们可以说:对于 ref 标记的简单数据类型而言,它其实 “并不具备响应性” ,所谓的响应性只不过是因为我们 主动触发了 value 方法 而已。
在这样的代码,我们需要知道的最重要的一点是:简单数据类型,不具备数据件监听的概念,即本身并不是响应性的。
只是因为 vue 通过了 set value() 的语法,把 函数调用变成了属性调用的形式,让我们通过主动调用该函数,来完成了一个 “类似于” 响应性的结果。
那么到这里整个响应式系统的大概流程,就已经描述完成了。
第三章:渲染器
渲染器的设计
在之前咱们说过 渲染器与渲染函数不是一个东西
- 渲染器 是
createRenderer的返回值,是一个对象。 - 渲染函数 是渲染器对象中的
render方法
在 vue 3.2.37 的源码内部,渲染器它的代码量非常庞大,涉及到了 2000 多行的代码。
代码量虽多,但是核心思路并不是特别复杂。总体可以被分为两部分:
- 在浏览器端渲染时,利用 DOM API 完成 DOM 操作:比如,如果渲染 DOM 那么就使用
createElement,如果要删除 DOM 那么就使用removeChild。 - 渲染器不能与宿主环境(浏览器)产生强耦合:因为
vue不光有浏览器渲染,还包括了 服务端 渲染,所以如果在渲染器中绑定了宿主环境,那么就不好实现服务端渲染了。
挂载与更新
对于渲染器而言,它做的最核心的事情就是 对节点进行挂载、更新的操作。
我们分两部分讲解一下:
- DOM 节点操作
- 属性节点操作
DOM节点操作
首先先来看 DOM 节点操作。DOM 节点的操作可以分为三部分:
- 挂载:所谓挂载表示节点的初次渲染。比如,可以直接通过 createElement 方法新建一个 DOM 节点,再利用 parentEl.insertBefore 方法插入节点。
- 更新:当响应性数据发生变化时,可能会涉及到 DOM 的更新。此时的更新本质上是属于 属性的更新。咱们等到属性节点操作那里再去说。
- 卸载:所谓卸载表示旧节点不在被需要了。那么此时就需要删除旧节点,比如可以通过 parentEl.removeChild 进行。
以上三种类型,是 vue 在进行 DOM 操作时的常见逻辑。基本上覆盖了 DOM 操作 90% 以上 的常见场景
属性节点操作
看完了 DOM 操作之后,接下来咱们来看属性节点操作。
针对于属性而言,大体可以分为两类:
- 属性:比如 class、id、value、src...
- 事件:比如 click、input....
那么咱们就先来看 非事件的属性部分。
想要了解 vue 中对于属性的处理,那么首先咱们需要先搞明白一个很重要的问题。那就是 浏览器中的属性分类。
在浏览器中 DOM 属性其实被分为了两类:
- 第一类叫做 HTML Attributes:直接定义在 HTML 标签 上的属性,都属于这一类。
- 第二类叫做 DOM Properties:它是拿到 DOM 对象后定义的属性。咱们接下来主要要说的就是它。
HTML Attributes 的定义相对而言比较简单和直观,但是问题在于 它只能在 html 中进行操作。
而如果想要在 JS 中操作 DOM 属性,就必须要通过 DOM Properties 来进行实现。但是因为 JS 本身特性的问题,会导致某些 DOM Properties 的设置存在特殊性。比如 class、type、value 这三个。
所以为了保证 DOM Properties 的成功设置,那么我们就必须要知道 不同属性的 DOM Properties 定义方式 。
下面咱们来看一下。
DOM Properties 的设置一共被分为两种:
- el.setAttribute('属性名', '属性值')
- el. 属性赋值 : el.属性名 = 属性值 或者 el[属性名] = 属性值 都属于 .属性赋值
在这段代码中,我们为 textarea 利用 DOM Properties 的方式设置了三个不同的属性:
- 首先是 class: class 在属性操作中是一个非常特殊的存在。它有两个名字 class 和 className。如果我们直接通过 el.setAttribute 的话,那么必须要用 class 才可以成功,而如果是通过 . 属性 的形式,那么必须要使用 className 才可以成功。
- 第二个是 type: type 仅支持 el.setAttribute 的方式,不支持 .属性的方式
- 第三个是 value:value 不支持直接使用 el.setAttribute 设置,但是支持 .属性 的设置方式
除了这三个属性之外,其实还有一些其他的属性也需要进行特殊处理,咱们这里就不再一一赘述了。
接下来,咱们来看 vue 对事件的处理操作。
事件的处理和属性、DOM 一样,也是分为 添加、删除、更新 三类。
- 添加:添加比较简单,主要利用 el.addEventListener 进行实现即可。
- 删除:主要利用 el.removeEventListener 进行处理。
- 更新:但是对于更新来说,就比较有意思了。下面咱们主要来看的就是这个更新操作。
通常情况下,我们所认知的事件更新应该是 删除旧事件、添加新事件 的过程。但是如果利用 el.addEventListener 和 el.removeEventListener 来完成这件事情,是一件非常消耗性能的事。
这时,vue 对事件的更新提出了一个叫做 vei 的概念,这个概念的意思是: 为 addEventListener 回调函数,设置了一个 value 的属性方法,在回调函数中触发这个方法。通过更新该属性方法的形式,达到更新事件的目的。
这个代码比较多,大家如果想要查看具体代码的话,可以看vue的源码。
Diff算法
目前针对于 vue 3.2.37 的版本来说,整个的 diff 算法被分为 5 步:
- sync from start:自前向后的对比
- sync from end:自后向前的对比
- common sequence + mount:新节点多于旧节点,需要挂载
- common sequence + unmount:旧节点多于新节点,需要卸载
- unknown sequence:乱序(最长有序子序列问题)
总结
这一章主要介绍渲染器与渲染函数的区别,HTML Attributes 和 DOM Properties 在行为上的差异性。
第四章:组件化
组件的实现原理
想要了解 vue 中组件的实现,那么首先我们需要知道什么是组件。
组件本质上就是一个 JavaScript 对象,比如,以下对象就是一个基本的组件
而对于组件而言,同样需要使用 vnode 来进行表示,当 vnode 的 type 属性是一个 自定义对象 时,那么这个 vnode 就表示组件的 vnode
而组件的渲染,本质上是 组件包含的 DOM 的渲染。 对于组件而言,必然会包含一个 render 渲染函数。如果没有 render 函数,那么 vue 会把 template 模板编译为 render 函数。而组件渲染的内容,其实就是 render 函数返回的 vnode。具体的渲染逻辑,全部都通过渲染器执行。
vue 3 之后提出了 composition API,composition API 包含一个入口函数,也就是 setup 函数。 setup 函数包含两种类型的返回值:
- 返回一个函数:当 setup 返回一个函数时,那么该函数会被作为 render 函数直接渲染。
- 返回一个对象:当 setup 返回一个对象时,那么 vue 会直接把该对象的属性,作为 render 渲染时的依赖数据
内建组件原理
keepAlive
首先第一个是 KeepAlive。
这是我们在日常开发中,非常常用的内置组件。它可以 缓存一个组件,避免该组件不断地销毁和创建。
看起来比较神奇,但是它的实现原理其实并不复杂,主要围绕着 组件卸载 和 组件挂载 两个方面:
- 组件卸载:当一个组件被卸载时,它并不被真正销毁,而是把组件保存在一个容器中
- 组件挂载:因为组件被保存了。所以当这个组件需要被挂载时,就不需要在重新创建,而是直接从容器中获取即可。
Teleport
Teleport 是 vue 3 新增的组件,作用是 将 Teleport 插槽的内容渲染到其他的位置。比如我们可以把 dialog 渲染到 body 根标签之下。
它的实现原理,主要也是分为两部分:
- 把 Teleport 组件的渲染逻辑,从渲染器中抽离
- 在指定的位置进行独立渲染
Transition
Transition 是咱们常用的动画组件,作用是 实现动画逻辑。
其核心原理同样被总结为两点:
- DOM 元素被挂载时,将动效附加到该 DOM 元素上
- DOM 元素被卸载时,等在 DOM 元素动效执行完成后,执行卸载 DOM 操作
第五章:编译器
编译器是一个非常复杂的环节。
编译器核心技术概述
在编译器核心技术概述,主要包含两个核心内容:
- 模板 DSL 的编译器
- Vue 编译流程三大步
模板 DSL 的编译器
在任何一个编程语言中,都存在编译器的概念。 vue 的编译器是在 一种领域下,特定语言的编译器 ,那么这种编译器被叫做 DSL 编译器。
而编译器的本质是 通过一段程序,可以把 A 语言翻译成 B 语言。在 vue 中的体现就是 把 tempalte 模板,编译成 render 渲染函数
一个完整的编译器,一个分为 两个阶段、六个流程:
- 编译前端:
-
- 词法分析
- 语法分析
- 语义分析
- 编译后端:
-
- 中间代码生成
- 优化
- 目标代码生成
而对于 vue 的编译器而言,因为它是一个特定领域下的编译器,所以流程会进行一些优化,一共分为三大步
- parse:通过 parse 函数,把模板编译成 AST 对象
- transform:通过 transform 函数,把 AST 转化为 JavaScript AST
- generate:通过 generate 函数,把 JavaScript AST 转化为 渲染函数(render)
这三大步中,每一步都包含非常复杂的逻辑实现。
编译器内部包含了特别多的代码实现,我也没办法给大家详细介绍,只能是把大致的核心流程为大家进行明确。
第六章:服务端渲染
最后来讲下 同构渲染。
想要了解同构渲染,那么需要先搞明白 CSR、SSR 的概念。
- CSR:所谓 CSR 指的是 客户端渲染。
-
- 浏览器向服务器发起请求
- 服务器查询数据库,返回数据
- 浏览器得到数据,进行页面构建
- SSR:表示 服务端渲染
-
- 览器向服务器发起请求
- 服务器查询数据库,根据数据,生成 HTML ,并进行返回
- 浏览器直接渲染 HTML
两种方式各有利弊,所以同构渲染,指的就是 把 CSR 和 SSR 进行合并。既可以单独 CSR ,也可以单独 SSR,同时还可以 结合两者,在首次渲染时,通过 SSR ,在非首次渲染时,通过 CSR。
以下是三者的对比图
而针对 vue 的服务端渲染来说,它是 将虚拟 DOM 渲染为 HTML 字符串,本质上是 解析的 vnode 对象,然后进行的 html 的字符串拼接
客户端激活的原理,大致分为两步:
- 为页面中的 DOM 元素与虚拟节点对象之间建立联系
- 为页面中的 DOM 元素添加事件绑定
这两步主要是通过 renderer.hydrate() 方法进行实现了。