vuejs设计与实现

框架设计概览

权衡的艺术

命令式和声明式

命令式 关注过程(jquery)

声明式 关注结果(vue)

Vue.js 帮我们封装了过程,内部实现是命令式的,而暴露给用户的却更加声明式

性能与可维护性的权衡

声明式代码的性能不优于命令式代码的性能

命令式代码的更新性能消耗 = 直接修改的性能消耗

声明式代码的更新性能消耗 = 直接修改的性能消耗 + 找出差异的性能消耗

但声明式代码的可维护性更强

设计框架时 要保证可维护性的同时让性能损失最小化

虚拟 DOM 的性能到底如何

虚拟 DOM 就是为了最小化找出差异这一步的性能消耗而出现的 (diff算法)

创建页面差异不大

更新页面性能差异大

更新页面的性能效果总结

运行时和编译时

纯运行时,没有编译的过程,没办法分析用户提供的内容

纯编译时,可以分析用户提供的内容,性能更好,但用户提供的内容必须编译后才能用,有损灵活性

Vue.js 是运行时 + 编译时的架构

框架设计的核心要素

提供用户的开发体验

更友好的警告

自定义输出形式

控制框架代码的体积

利用 Tree-shaking 机制,配合构建工具预定义常量的能力,如预定义 __DEV__常量,实现仅在开发环境中打印警告信息,从而控制线上代码体积

良好的 Tree-Shaking

利用 /*#__PURE__*/ 注释 来让 webpack 相信这段代码不会产生副作用,从而 tree-shaking 掉

副作用:当调用函数的时候会对外部产生影响(例如修改了全局变量)

Vue.js 3 源码中,基本都是在一些顶级调用的函数中使用 /*#__PURE__*/ 注释掉

框架应该输出怎样的构建产物

为了让用户能够通过 <script> 标签直接引用并使用,需要输出 IIFE 格式的资源

为了让用户能够通过 <script type=”module”> 引用并使用,需要输出 ESM 格式的资源

ESM格式的资源有两种:

esm-browser.js 用于浏览器 使用 __DEV__ true/false 来判断环境

esm-bundler.js 用于打包工具 使用 process.env.NODE_ENV !== ‘production’ 来判断

为了让用户能够通过在服务端通过

1
const Vue = require('vue')

使用,需要输出 CommonJS 格式的资源(一般用于服务端渲染)

特性开关

Vue.js 提供了很多功能,如果不需要可以通过特性开关关闭,这样打包时代码就不会出现在最终资源中,文件体积减小

比如 Vue3.js 中我们仍可使用 options API,可以关闭,通过 __VUE_OPTIONS_API__ 控制。

实现特性开关的方法和 __DEV__ 是一样的,本质上是通过 rollup.js(webpack) 的预定义常量插件来实现

可以使用 webpack.DefinePlugin 插件来控制是否开启

1
2
3
new webpack.DefinePlugin ({
__VUE_OPTIONS_API__: JSON.stringify(true)
})

错误处理

Vue3.js 中将错误处理程序封装为一个函数 callWithErrorHandling

还提供了 registerErrorHandler 函数,用户可以使用它注册错误处理程序

良好的 TS 支持

使用 TS 编写框架 和 框架对 TS 类型 支持友好是完全不同的

Vue3.js 的设计思路

声明式描述 UI

描述 UI 的两种方式:模板和虚拟 DOM

模板

1
2
3
4
<h1 @click="handler">
<span>
</span>
</h1>

虚拟DOM

1
2
3
4
5
6
7
8
9
const title = {
tag: 'h1',
props: {
onClick: handler
},
children: [
{tag: 'span'}
]
}

h 函数就是一个辅助创建虚拟 DOM 的工具函数

1
2
3
4
5
export default {
render() {
return h('h1', {onClick: handler})
}
}

等价于

1
2
3
4
5
6
7
8
export default {
render() {
return {
tag: 'h1',
props: { onClick: handler }
}
}
}

Vue.js 会根据组件的 render 函数的返回值拿到虚拟 DOM,然后就可以把组件的内容渲染出来了

初始渲染器

渲染器的作用就是把 虚拟 DOM 转为 真实 DOM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function renderer(vnode, container) {
/*
vnode: 虚拟 DOM
container: 一个真实 DOM 元素,作为挂载点,渲染器会把虚拟 DOM 渲染到该挂载点下
*/
// 1.使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag)
// 2.遍历 vnode.props,将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
if (/^on/.test(key)) {
// 事件
el.addEventListener(
key.substring(2).toLowerCase(), // 事件名称 onClick ---> click
vnode.props[key] // 事件处理函数
)
}
}

// 3.处理 children
if (typeof vnode.children === 'string') {
// 如果 children 是字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
// 递归调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
vnode.children.forEach(child => render(child, el))
}

// 将元素添加到挂载点下
container.appendChild(el)
}

组件的本质

组件就是一组 DOM 元素的封装

可以定义一个函数来代表组件

1
2
3
4
5
6
7
8
9
const MyComponent = function() {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}

组件的返回值也是虚拟 DOM

定义虚拟 DOM 来描述组件

1
2
3
const vnode = {
tag: MyComponent
}

为了能够渲染组件,需要渲染器的支持,修改前面的 renderer 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function mountElement(vnode, container) {
/*
vnode: 虚拟 DOM
container: 一个真实 DOM 元素,作为挂载点,渲染器会把虚拟 DOM 渲染到该挂载点下
*/
// 1.使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag)
// 2.遍历 vnode.props,将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
if (/^on/.test(key)) {
// 事件
el.addEventListener(
key.substring(2).toLowerCase(), // 事件名称 onClick ---> click
vnode.props[key] // 事件处理函数
)
}
}

// 3.处理 children
if (typeof vnode.children === 'string') {
// 如果 children 是字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
// 递归调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
vnode.children.forEach(child => render(child, el))
}

// 将元素添加到挂载点下
container.appendChild(el)
}

mountComponent(vnode, container) {
// 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag()
// 递归调用 renderer 渲染 subtree
renderer(subtree, container)
}

function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
mountElement(vnode, container)
} else if (typeof vnode.tag === 'function') {
// 说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}

模板的工作原理

编译器分析动态内容

代码中有静态内容(如写死的字符串),有动态内容(如双向绑定的变量)

动态内容改变时,渲染器会自动寻找变更点,找起来比较麻烦。

如果编译器有能力分析动态内容,获取更多信息,就会方便很多。

编译器能够识别出哪些是静态属性,哪些是动态属性(比如,模板上有冒号的属性就是动态的(双向绑定),没有的就是静态的),在生成虚拟 DOM 的时候就会携带额外的 patchFlags 信息。(patchFlags 应该是 vue3 新增的)

总结编译与渲染流程

响应系统

响应式系统的作用与实现

副作用函数

effect函数的执行会直接或间接影响其他函数的执行,这时我们说 effect 函数产生了副作用。

例如一个函数修改了全局变量,其实也是一个副作用。

响应式系统的工作流程

当读取操作发生时,将副作用函数收集到”桶“中

当设置操作发生时,从”桶“中去除副作用函数并执行

设计一个完善的响应系统

结构上来说,有三个层级。

代码中有很多对象,一个对象有很多属性,一个属性又有很多副作用函数。

当我们修改对象上的一个属性时,理应只能触发这个键上的副作用函数。

1
2
3
4
5
6
7
target
-- key1
-- effect1-1
-- effect1-2
-- key2
-- effect2-1
-- effect2-2

建立 weakmap - map - set 的数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const obj = new Proxy(data, {
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, 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
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)
}

// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(key)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}

分支切换与cleanup

数据变更后,一些副作用函数和key之间的关系应该被切断,但遗留了下来,导致了不必要的更新。

解决方案是,在每次副作用函数执行时,先把它从所有与之关联的依赖集合中删除。当副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用。

为了方便从所有依赖集合中删除副作用函数的操作,需要给每个副作用函数创建一个数组,在track函数中,将依赖放入相应副作用函数的数组。

然后建立一个 cleanup 函数,用于执行清除操作。

1
2
3
4
5
6
7
8
9
10
11
// 将副作用函数从所有相关依赖集合中移除
function cleanup(effectFn) {
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
}

响应的 trigger 函数需要做一些改动,否则会出现无限循环的场景。这和 forEach 的性质有关。

嵌套的 effect 和 effect 栈

effect 应设计为可嵌套的,因为存在组件嵌套的场景。

维持原先的写法会导致,内部的 effect 函数覆盖了外部的 effect 函数,因为同一时刻只能存在一个 activeEffect。

为了解决这个问题,需要一个副作用函数栈 effectStack。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 全局变量存储被注册的副作用函数
let activeEffect
// 副作用函数栈
const effectStack = []
// 注册副作用函数
function effect(fn) {
const effectFn = () => {
// 接收副作用函数作为参数,遍历副作用函数的effectFn.deps,每一项都是一个依赖集合,将该副作用函数从依赖集合中清除
cleanup(effectFn)
activeEffect = effectFn
// 执行副作用函数前,将当前副作用函数压栈
effectStack.push(effectFn)
// 执行副作用函数时,依赖集合会重新收集副作用函数
fn()
// 副作用函数执行完毕后,将当前副作用函数弹出栈,并将 activeEffect 还原成之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}

避免无限递归循环

1
2
3
effect(() => {
obj.foo = obj.foo + 1
})

这种自增的情况下,副作用函数执行,触发obj.foo的get行为,将当前副作用函数放入obj.foo的桶中,然后触发obj.foo的set行为,取出当前副作用函数执行,但由于当前副作用函数还在执行中,就要开始下一次的执行,就会无限递归调用自己。

解决方案:在 trigger 动作发生时增加守卫条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(key)
if (!depsMap) return
const effects = depsMap.get(key)

// 直接 effects.forEach 遍历会造成无限循环,因为执行 effectFn 会在 effects 中将 effectFn 清除,然后(可能重新建立联系)又会在 effects 中将 effectFn 加入,forEach的性质导致会将 effectFn 当作新元素再次执行
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。(避免无限递归)
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => effectFn())
}

调度执行

可调度,指的是当 trigger 动作出发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。

解决方案:为 effect 函数设计一个选项参数 options,允许用户指定调度器。trigger 函数中遍历执行副作用函数前做一个调度器是否存在的判断。

计算属性 computed 和 lazy

懒执行(lazy)

有些场景下,我们并不希望副作用函数立即执行,而是希望它在需要的时候才执行,例如计算属性。(依赖修改时不会立即执行,只有读取计算属性的值时才执行)

可以通过在 options 中添加 lazy 属性来达到目的

1
2
3
4
5
6
7
8
9
effect(
() => {
console.log(obj.foo)
},
// options
{
lazy: true
}
)

当 options.lazy 为 true 时,则不立即执行副作用函数,而是将封装的副作用函数 effectFn 作为返回值返回。

在 effectFn 中执行真正的副作用函数 fn 时,将其结果保存,并作为返回值返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 全局变量存储被注册的副作用函数
let activeEffect
// 副作用函数栈
const effectStack = []
// 注册副作用函数
function effect(fn) {
const effectFn = () => {
// 接收副作用函数作为参数,遍历副作用函数的effectFn.deps,每一项都是一个依赖集合,将该副作用函数从依赖集合中清除
cleanup(effectFn)
activeEffect = effectFn
// 执行副作用函数前,将当前副作用函数压栈
effectStack.push(effectFn)
// 执行副作用函数时,依赖集合会重新收集副作用函数
// 将 fn 的执行结果存到 res 中,目的是为了计算属性能够获取结果值
const res = fn()
// 副作用函数执行完毕后,将当前副作用函数弹出栈,并将 activeEffect 还原成之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return res
}
// 将 options 挂载到 effectFn 上
effectFn.options = options
// effectFn.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 非 lazy 的时候,才执行
if (!options.lazy) {
effectFn()
}
// 将副作用函数作为返回值返回
return effectFn()
}
缓存性(dirty)

读取计算属性值时,如果不是第一次获取且依赖的属性没有变化,则不应该再次执行副作用函数(effectFn),而是直接使用缓存。

增加 dirty 标志标识依赖是否有更新,副作用函数执行后,dirty 置为 false。利用 scheduler,在依赖改变时将 dirty 置 true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function computed(getter) {
// value 用来缓存上一次计算的值
let value
// dirty 用来标识是否需要重新计算值,为 true 则需要重新计算
let dirty = true

// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,getter 函数中的依赖变化时执行
scheduler() {
dirty = true
}
})

const obj = {
// 当读取 value 时才执行 effectFn
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}

return obj
}
计算属性太懒了
1
2
3
4
5
6
7
const sumRes = computed(() => obj.foo + obj.bar)

effect(() => {
console.log(sumRes.value)
})

obj.foo++

执行上述代码,发现并不会打印任何东西

因为虽然 obj.foo 改变了,但计算属性懒,并没有重新求值,所以 sumRes 没变,所以没有执行副作用函数(打印sumRes)。

解决方案:

读取计算属性时,手动调用 track 函数进行追踪

计算属性依赖数据变化时,手动调用 trigger 函数触发响应

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function computed(getter) {
// value 用来缓存上一次计算的值
let value
// dirty 用来标识是否需要重新计算值,为 true 则需要重新计算
let dirty = true

// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,getter 函数中的依赖变化时执行
scheduler() {
dirty = true
// 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
trigger(obj, 'value')
}
})

const obj = {
// 当读取 value 时才执行 effectFn
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
// 当读取 value 时,手动调用 track 函数进行追踪
track(obj, 'value')
return value
}
}

return obj
}

watch

本质上就是利用了 effect 以及 options.scheduler 选项

watch的使用:

1
2
3
4
5
6
7
8
9
10
11
12
// 1.传一个对象
watch(obj, () => {
console.log("数据变了")
})

// 2.传一个 getter
watch(
() => obj.foo,
() => {
console.log("obj.foo 的值改变了");
}
)

watch的实现:

1.处理source是对象还是getter的能力(将对象也包装成getter)

2.回调中拿到oldValue和newValue的能力(利用effect函数的lazy配置项)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function watch(source, cb) {
// source 可能是对象 也可能是 getter,需要判断
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}

// 定义旧值与新值
let oldValue, newValue
// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用

const effectFn = effect(
// 调用 traverse 递归读取
() =>getter(),
{
lazy: true,
scheduler() {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 当数据变化时,调用回调函数 cb
cb(newValue, oldValue)
// 更新旧值
oldValue = newValue
}
}
)
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}

立即执行及回调执行时机

立即执行
1
2
3
4
5
watch(obj, () => {
console.log("数据变了")
}, {
immediate: true
})
回调执行时机
1
2
3
4
5
6
7
8
watch(obj, () => {
console.log("数据变了")
}, {
// pre (组件更新前)涉及组件更新时机,暂时无法模拟
// post (组件更新后)代表调度函数需要将副作用函数放到一个微任务队列,并等待 DOM 更新结束后再执行
// sync (同步执行)
flush: 'pre'
})
watch改良
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
function watch(source, cb) {
// source 可能是对象 也可能是 getter,需要判断
let getter
if (typeof source === 'function') {
getter = source
} else {
// 调用 traverse 递归读取
getter = () => traverse(source)
}

// 定义旧值与新值
let oldValue, newValue

// 调度器函数
const job = () => {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 当数据变化时,调用回调函数 cb
cb(newValue, oldValue)
// 更新旧值
oldValue = newValue
}

// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
const effectFn = effect(
// 执行 getter
() =>getter(),
{
lazy: true,
scheduler: () => {
// else 里面相当于 'sync' ; 'pre' 因为涉及组件更新时机,所以暂时不能模拟
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)

if (options.immediate) {
// 当 immediate 为 true 时,立即执行 job,从而触发回调执行
job()
} else {
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
}

过期的副作用

假设多次改变obj,那么会发送多个请求并赋给res,因为请求是异步的,不确定哪个请求先完成,所以 res 的值可能和理想中的不一致。

1
2
3
4
5
watch(obj, async () => {
const res = await fetch('/path/to/request')
// 将请求结果赋给 data
finalData = res
})

我们需要想办法,让之前没用的副作用过期。

解决方案:watch 的回调函数接收第三个参数 onInvalidate,我们可以使用 onInvalidate 函数注册一个回调,这个回调会在当前副作用过期时执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let finalData
watch(obj, async (newValue, oldValue, onInvalidate) => {
// 定义一个标志,代表当前副作用函数是否过期,默认为 false
let expired = false
onInvalidate(() => {
expired = true
})

const res = await fetch('/path/to/request')

if (!expired) {
finalData = res
}
})

onInvalidate的实现

在 watch 内部每次检测到变更后,在副作用函数重新执行前,先调用我们通过 onInvalidate 函数注册的过期回调。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
function watch(source, cb) {
// source 可能是对象 也可能是 getter,需要判断
let getter
if (typeof source === 'function') {
getter = source
} else {
// 调用 traverse 递归读取
getter = () => traverse(source)
}

// 定义旧值与新值
let oldValue, newValue

// cleanup 用来存储用户注册的过期回调
let cleanup
// 定义 onInvalidate 函数
function onInvalidate(fn) {
// 将过期回调存到 cleanup 中
cleanup = fn
}

// 调度器函数
const job = () => {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 在调用回调函数之前,先调用过期回调
if (cleanup) {
cleanup()
}
// 当数据变化时,调用回调函数 cb
cb(newValue, oldValue, onInvalidate)
// 更新旧值
oldValue = newValue
}

// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
const effectFn = effect(
// 执行 getter
() =>getter(),
{
lazy: true,
scheduler: () => {
// else 里面相当于 'sync' ; 'pre' 因为涉及组件更新时机,所以暂时不能模拟
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)

if (options.immediate) {
// 当 immediate 为 true 时,立即执行 job,从而触发回调执行
job()
} else {
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
}

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
// 全局变量存储被注册的副作用函数
let activeEffect
// 副作用函数栈
const effectStack = []
// 注册副作用函数
function effect(fn) {
const effectFn = () => {
// 接收副作用函数作为参数,遍历副作用函数的effectFn.deps,每一项都是一个依赖集合,将该副作用函数从依赖集合中清除
cleanup(effectFn)
activeEffect = effectFn
// 执行副作用函数前,将当前副作用函数压栈
effectStack.push(effectFn)
// 执行副作用函数时,依赖集合会重新收集副作用函数
// 将 fn 的执行结果存到 res 中,目的是为了计算属性能够获取结果值
const res = fn()
// 副作用函数执行完毕后,将当前副作用函数弹出栈,并将 activeEffect 还原成之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return res
}
// 将 options 挂载到 effectFn 上
effectFn.options = options
// effectFn.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 非 lazy 的时候,才执行
if (!options.lazy) {
effectFn()
}
// 将副作用函数作为返回值返回
return effectFn()
}

// 数据拦截
const obj = new Proxy(data, {
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, 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
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)
// 将依赖集合放入副作用函数的依赖收集数组中
// 目的是为了在每次副作用函数执行时,把副作用函数从所有与之关联的以来集合中删除(cleanup),避免产生多余的不存在的依赖
activeEffect.deps.push(deps)
}

// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(key)
if (!depsMap) return
const effects = depsMap.get(key)

// 直接 effects.forEach 遍历会造成无限循环,因为执行 effectFn 会在 effects 中将 effectFn 清除,然后(可能重新建立联系)又会在 effects 中将 effectFn 加入,forEach的性质导致会将 effectFn 当作新元素再次执行
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。(避免无限递归)
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
// 否则直接执行副作用函数
effectFn()
}
})
}

// 将副作用函数从所有相关依赖集合中移除
function cleanup(effectFn) {
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
}

function computed(getter) {
// value 用来缓存上一次计算的值
let value
// dirty 用来标识是否需要重新计算值,为 true 则需要重新计算
let dirty = true

// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,getter 函数中的依赖变化时执行
scheduler() {
dirty = true
// 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
trigger(obj, 'value')
}
})

const obj = {
// 当读取 value 时才执行 effectFn
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
// 当读取 value 时,手动调用 track 函数进行追踪
track(obj, 'value')
return value
}
}

return obj
}

function watch(source, cb) {
// source 可能是对象 也可能是 getter,需要判断
let getter
if (typeof source === 'function') {
getter = source
} else {
// 调用 traverse 递归读取
getter = () => traverse(source)
}

// 定义旧值与新值
let oldValue, newValue

// cleanup 用来存储用户注册的过期回调
let cleanup
// 定义 onInvalidate 函数
function onInvalidate(fn) {
// 将过期回调存到 cleanup 中
cleanup = fn
}

// 调度器函数
const job = () => {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 在调用回调函数之前,先调用过期回调
if (cleanup) {
cleanup()
}
// 当数据变化时,调用回调函数 cb
cb(newValue, oldValue, onInvalidate)
// 更新旧值
oldValue = newValue
}

// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
const effectFn = effect(
// 执行 getter
() =>getter(),
{
lazy: true,
scheduler: () => {
// else 里面相当于 'sync' ; 'pre' 因为涉及组件更新时机,所以暂时不能模拟
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)

if (options.immediate) {
// 当 immediate 为 true 时,立即执行 job,从而触发回调执行
job()
} else {
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
}

function traverse(value, seen = new Set()) {
// 如果要读取的数据时原始值,或者已经被读取过,那什么都不用做
if (typeof value !== 'object' || value === null || seen.has(value)) return
// 将数据添加到 seen 中,代表遍历读取过了,避免循环引用引起的死循环
seen.add(value)
// 暂时不考虑数组等其他结构
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归调用 traverse 处理
for (const k in value) {
traverse(value[k], seen)
}
}

非原始值的响应式方案

理解 proxy 和 reflect

代理,指的是对一个对象基本语义的代理,允许我们拦截并重新定义对一个对象的基本操作。

基本语义:读取属性值、设置属性值、调用函数

reflect 在数据响应式中的作用

reflect.get() 有第三个参数 receiver,可以理解为函数调用过程中的 this。

reflect.get(target, key, receive) 替代 target[key],可以避免 this 错误问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const obj = {
foo: 1,
get bar() {
return this.foo
}
}

const p = new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
// 当读取 p.bar 时, receiver 就是 p
// 如果是 target[key], this 会被认为是 obj, 因为 target 是 obj
return Reflect.get(target, key, receiver)
}
})

JS对象及Proxy 的工作原理

内部方法

对象的实际语义是由对象的内部方法指定的。

内部方法指的是当我们对一个对象进行操作时在引擎内部调用的方法,这些方法对于 JS 使用者来说是不可见的。

例如 访问 obj.foo 时,引擎内部会调用 [[GET]] 这个内部方法来读取属性值。

ES2022规范要求的所有必要的内部方法:

还有两个额外的必要内部方法:

如果一个对象需要作为函数调用,那么这个对象就必须部署内部方法[[Call]]。

内部方法具有多态性:

不同类型的对象可能部署了相同的内部方法,却具有不同的逻辑。

例如:普通对象和 Proxy 对象,都部署了 [[Get]] 这个内部方法,但它们的逻辑不同

常规对象与异质对象

满足以下三点要求的对象就是常规对象:

1.对于前11个内部方法,必须使用 ECMA 规范 10.1.x 节给出的定义实现

2.对于[[Call]],必须使用 ECMA 规范 10.2.1 节给出的定义实现

3.对于[[Construct]],必须使用 ECMA 规范 10.2.2 节给出的定义实现

如果有一点不满足就是异质对象。

Proxy 对象的 [[Get]] 没有使用 ECMA 规范的 10.1.8 节给出的定义实现,所以是一个异质对象。

代理透明性质

如果在创建代理对象时没有指定对应的拦截函数,例如没有指定 get() 拦截函数,那么当我们通过代理对象访问属性值时,代理对象的内部方法 [[Get]] 会调用原始对象的内部方法 [[Get]] 来获取属性值。

创建代理对象时指定的拦截函数,实际上是用来自定义代理对象本身的内部方法和行为的,而不是用来指定被代理对象的内部方法和行为的。

Proxy 对象部署的所有内部方法

[[Call]] 和 [[Construct]] 只有当被代理对象是函数和构造函数时才会部署

举例:

1
2
3
4
5
6
// deleteProperty 实现的是代理对象p的内部方法和行为,所以为了删除被代理对象上的属性,需要使用Reflect.deleteProperty
const p = new Proxy(obj, {
deleteProperty(target, key) {
return Reflect.deleteProperty(target, key)
}
})

如何代理Object

读取

1.访问属性:obj.foo

2.判断对象或原型上是否存在给定的 key:key in obj

3.使用 for…in 循环遍历对象:for (const key in obj){}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const p = new Proxy(obj, {
// 访问属性:obj.foo
get(target, key, receiver) {
// 建立联系
track(target, key)
// 返回属性值
return Reflect.get(target, key, receiver)
},

// 判断对象或原型上是否存在给定的 key:key in obj
has(target, key) {
track(target, key)
return Reflect.has(target, key)
},

// 使用 for ... in 循环遍历对象 for (const key in obj) {}
ownKeys(target) {
// 将副作用函数与 ITERATE_KEY 关联
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
}
})
设置

1.新增属性

2.修改属性值

新增属性会对 for…in 循环产生影响(增加循环次数),所以需要触发与 ITERATE_KEY 关联的 副作用函数

修改属性值不需要触发与 ITERATE_KEY 关联的 副作用函数

我们可以使用 Object.prototype.hasOwnProperty 来判断当前操作属性是否已经存在于目标对象上,从而区分新增和修改

1
2
3
4
5
6
7
8
9
10
11
12
13
const p = new Proxy(obj, {
set(target, key, newVal, receiver) {
// 确定是新增还是修改属性
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'

// 设置属性值
const res = Reflect.set(target, key, newVal, receiver)

// 将 type 作为第三个参数传给 trigger
trigger(target, key, type)
return res
}
})
删除
1
2
3
4
5
6
7
8
9
10
11
12
13
14
const p = new Proxy(obj, {
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, 'DELETE')
}

return res
}
})

设置和删除都会向 trigger 传第三个参数 type,所以 trigger 函数也要做修改

如果 type 是 ADD/DELETE,则需要触发与 ITERATE_KEY 相关联的副作用函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
const depsMap = bucket.get(key)
if (!depsMap) return
const effects = depsMap.get(key)

// 直接 effects.forEach 遍历会造成无限循环,因为执行 effectFn 会在 effects 中将 effectFn 清除,然后(可能重新建立联系)又会在 effects 中将 effectFn 加入,forEach的性质导致会将 effectFn 当作新元素再次执行
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。(避免无限递归)
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})

// 只有当操作类型为 'ADD' 或 'DELETE' 时,触发与 ITERATE_KEY 相关联的副作用函数
if (type === 'ADD' || type === 'DELETE') {
const iterateEffects = depsMap.get(ITERATE_KEY)
iterateEffects && iterateEffects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
}

effectsToRun.forEach(effectFn => {
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
// 否则直接执行副作用函数
effectFn()
}
})
}

vuejs设计与实现
http://example.com/2023/02/19/vuejs设计与实现/
Author
John Doe
Posted on
February 19, 2023
Licensed under