Search K
Appearance
Appearance
最近在看霍春阳著作的《Vue设计与实现》一书,特此总结读书笔记
什么是TreeShaking
?
简单地说,Tree-Shaking
指的就是消除那些永远不会被执行的代码,也就是排除 dead code
,现在无论是 rollup.js
还是 webpack
,都支持Tree-Shaking
。
在 Rollup 和其他 JavaScript 构建工具中,/*#__PURE__*/
注释用于标记函数调用为“纯函数”(pure function)。纯函数是指那些没有副作用且结果仅依赖于输入参数的函数。这种注释可以帮助构建工具和优化器更好地理解和优化代码。
/*#__PURE__*/
注释是一个有用的工具,可以帮助构建工具更好地优化你的代码。通过标记纯函数,你可以提高代码的可读性和性能,同时减少最终打包的代码体积。希望这些建议对你有所帮助!如果有任何问题或需要进一步的帮助,请随时告诉我。
Tree Shaking:
内联优化:
副作用分析:
假设你有一个纯函数 add
,你可以使用 /*#__PURE__*/
注释来标记它:
function add(a, b) {
return a + b;
}
const result = /*#__PURE__*/ add(1, 2);
在实际项目中,你可能会看到许多库和框架使用这种注释来帮助构建工具进行优化。例如,React 的 React.createElement
函数就经常被标记为纯函数:
const element = /*#__PURE__*/ React.createElement('div', null, 'Hello, World!');
Vue
借助Rollup
打包工具,分别输出了IIFE
、ESM(ECMAScript Modules)
、CJS(CommonJs)
等多种类型的引用资源,关于前端模块化可以参考这篇博文
渲染器的主要工作是将虚拟DOM(一个JavaScript对象)
渲染成真实的DOM
假设我们有如下虚拟 DOM
:
const vnode = {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
首先简单解释一下上面这段代码。 tag
用来描述标签名称,所以 tag
: 'div'
描述的就是一个<div>
标签。
props
是一个对象,用来描述 <div>
标签的属性、事件等内容。可以看到,我们希望给 div
绑定一个点击事件。
children
用来描述标签的子节点。在上面的代码中,children
是一个字符串值,意思是 div
标签有一个文本子节点:<div>
click me</div>
实际上,你完全可以自己设计虚拟 DOM 的结构,例如可以使用tagName
代替 tag
,因为它本身就是一个 JavaScript
对象,并没有特殊含义。
function renderer(vnode, container) {
// 使用 vnode.tag 作为标签名称创建 DOM 元素
const el = document.createElement(vnode.tag)
// 遍历 vnode.props,将属性、事件添加到 DOM 元素
for (const key in vnode.props) {
if (/^on/.test(key)) {
// 如果 key 以 on 开头,说明它是事件
el.addEventListener(
key.substr(2).toLowerCase(), // 事件名称 onClick ---> click
vnode.props[key] // 事件处理函数
)
}
}
// 处理 children
if (typeof vnode.children === 'string') {
// 如果 children 是字符串,说明它是元素的文本子节点
el.appendChild(document.createTextNode(vnode.children))
} else if (Array.isArray(vnode.children)) {
// 递归地调用 renderer 函数渲染子节点,使用当前元素 el 作为挂载点
vnode.children.forEach(child => renderer(child, el))
}
// 将元素添加到挂载点下
container.appendChild(el)
}
这里的 renderer
函数接收如下两个参数。 vnode
:虚拟 DOM
对象。container
:一个真实 DOM
元素,作为挂载点,渲染器会把虚拟 DOM
渲染到该挂载点下。接下来,我们可以调用 renderer
函数:
renderer(vnode, document.body) // body 作为挂载点
在浏览器中运行这段代码,会渲染出“click me
”文本,点击该文本,会弹出 alert('hello')
,如图 3-2 所示。
运行结果分析总结: 现在我们回过头来分析渲染器 renderer
的实现思路,总体来说分为三步:
创建元素:把 vnode.tag
作为标签名称来创建 DOM
元素。
为元素添加属性和事件:遍历 vnode.props
对象,如果 key
以on
字符开头,说明它是一个事件,把字符 on
截取掉后再调用toLowerCase
函数将事件名称小写化,最终得到合法的事件名称,例如 onClick
会变成 click
,最后调用addEventListener
绑定事件处理函数。
处理 children
:如果 children
是一个数组,就递归地调用renderer
继续渲染,注意,此时我们要把刚刚创建的元素作为挂载点(父节点);如果 children
是字符串,则使用createTextNode
函数创建一个文本节点,并将其添加到新创建的元素内。
渲染器的精髓都在更新节点的阶段,对于渲染器来说,它需要精确地找到 vnode
对象的变更点并且只更新变更的内容。就上例来说,渲染器应该只更新元素的文本内容,而不需要再走一遍完整的创建元素的流程。这些内容后文会重点讲解,但无论如何,希望大家明白,渲染器的工作原理其实很简单,归根结底,都是使用一些我们熟悉的 DOM
操作 API
来完成渲染工作。
其实虚拟 DOM
除了能够描述真实 DOM
之外,还能够描述组件。例如使用 { tag: 'div' }
来描述 <div>
标签,但是组件并不是真实的 DOM
元素,那么如何使用虚拟 DOM
来描述呢?想要弄明白这个问题,就需要先搞清楚组件的本质是什么。一句话总结:组件就是一组 DOM
元素的封装,这组 DOM
元素就是组件要渲染的内容,因此我们可以定义一个函数来代表组件,而函数的返回值就代表组件要渲染的内容:
const MyComponent = function () {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
用虚拟DOM
描述组件大概长这样:
const vnode = {
tag: MyComponent
}
就像 tag
: 'div'
用来描述 <div>
标签一样,tag:MyComponent
用来描述组件,只不过此时的 tag
属性不是标签名称,而是组件函数。为了能够渲染组件,需要渲染器的支持。修改前面提到的 renderer
函数,如下所示:
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
// 说明 vnode 描述的是标签元素
mountElement(vnode, container)
} else if (typeof vnode.tag === 'function') {
// 说明 vnode 描述的是组件
mountComponent(vnode, container)
}
}
如果 vnode.tag
的类型是字符串,说明它描述的是普通标签元素,此时调用 mountElement
函数完成渲染;如果 vnode.tag
的类型是函数,则说明它描述的是组件,此时调用 mountComponent
函数完成渲染。其中 mountElement
函数与上文中 renderer
函数的内容一致:
mountComponent
函数:
function mountComponent(vnode, container) {
// 调用组件函数,获取组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}
可以看到,非常简单。首先调用 vnode.tag
函数,我们知道它其实就是组件函数本身,其返回值是虚拟 DOM
,即组件要渲染的内容,这里我们称之为 subtree
。既然 subtree
也是虚拟 DOM
,那么直接调用 renderer
函数完成渲染即可。
组件一定得是函数吗?当然不是,我们完全可以使用一个 JavaScript
对象来表达组件,例如:
// MyComponent 是一个对象
const MyComponent = {
render() {
return {
tag: 'div',
props: {
onClick: () => alert('hello')
},
children: 'click me'
}
}
}
这里我们使用一个对象来代表组件,该对象有一个函数,叫作 render
,其返回值代表组件要渲染的内容。为了完成组件的渲染,我们需要修改 renderer
渲染器以及 mountComponent
函数。
function renderer(vnode, container) {
if (typeof vnode.tag === 'string') {
mountElement(vnode, container)
} else if (typeof vnode.tag === 'object') { // 如果是对象,说明vnode 描述的是组件
mountComponent(vnode, container)
}
}
现在我们使用对象而不是函数来表达组件,因此要将 typeof vnode.tag === 'function'
修改为 typeof vnode.tag ==='object'
。接着,修改 mountComponent
函数:
function mountComponent(vnode, container) {
// vnode.tag 是组件对象,调用它的 render 函数得到组件要渲染的内容(虚拟 DOM)
const subtree = vnode.tag.render()
// 递归地调用 renderer 渲染 subtree
renderer(subtree, container)
}
其实 Vue.js 中的有状态组件就是使用对象结构来表达的。
无论是手写虚拟 DOM
(渲染函数)还是使用模板,都属于声明式地描述 UI
,并且 Vue.js
同时支持这两种描述 UI
的方式。上文中我们讲解了虚拟 DOM
是如何渲染成真实 DOM
的,那么模板是如何工作的呢?这就要提到 Vue.js
框架中的另外一个重要组成部分:编译器。
编译器和渲染器一样,只是一段程序而已,不过它们的工作内容不同。编译器的作用其实就是将模板编译为渲染函数,例如给出如下模板:
<div @click="handler">
click me
</div>
对于编译器来说,模板就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数:
render() {
return h("div", { onClick: handler }, "click me");
}
以我们熟悉的 .vue
文件为例,一个 .vue
文件就是一个组件,如下所示
<template>
<div @click="handler">click me</div>
</template>
<script>
export default {
data() {
/* ... */
},
methods: {
handler: () => {
/* ... */
},
},
};
</script>
其中 <template>
标签里的内容就是模板内容,编译器会把模板内容编译成渲染函数并添加到 <script>
标签块的组件对象上,所以最终在浏览器里运行的代码就是:
export default {
data() {/* ... */ },
methods: {
handler: () => {/* ... */ }
},
render() {
return h('div', { onClick: handler }, 'click me')
}
}
无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟 DOM
渲染为真实 DOM
,这就是模板的工作原理,也是 Vue.js
渲染页面的流程。编译器是一个比较大的话题,后面我们会着重讲解,这里大家只需要清楚编译器的作用及角色即可。
假设我们现在有一个数据obj
,以及一个副作用函数effect
函数会影响一些全局数据就叫做副作用函数,与之相对的是纯函数 什么是纯函数
const obj = { text: 'hello world' }
function effect() {
// effect 函数的执行会读取 obj.text
document.body.innerText = obj.text
}
setTimeout(()=>{
obj.text = 'hello vue3' //3s后我们改变数据,希望UI层自动更新,触发响应式
},3000)
如何才能让 obj
变成响应式数据呢?通过观察我们能发现两点线索:
当副作用函数 effect
执行时,会触发字段 obj.text
的读取操作
当修改 obj.text
的值时,会触发字段 obj.text
的设置操作。
如果我们能拦截一个对象的读取和设置操作,事情就变得简单了,当读取字段 obj.text
时,我们可以把副作用函数 effect 存储到一个“桶”里。接着,当设置 obj.text
时,再把副作用函数 effect
从“桶”里取出并执行即可
现在问题的关键变成了我们如何才能拦截一个对象属性的读取和设置操作。在 ES2015
之前,只能通过 Object.defineProperty
函数实现,这也是 Vue.js 2
所采用的方式。在 ES2015+
中,我们可以使用代理对象 Proxy
来实现,这也是 Vue.js 3
所采用的方式。
注意:Set
对象允许你存储任何类型(无论是原始值还是对象引用)的唯一值,集合(set
)中的元素只会出现一次,即集合中的元素是唯一的,即Set
中没有重复的元素,例如字符串、数字、函数、对象等。
接下来使用Proxy
实现这个需求:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="text/javascript">
// 存储副作用的桶
const bucket = new Set()
// 原始数据
const data = { text: "hello world" }
// 对原始数据进新代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
bucket.add(effect)
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 执行更新
target[key] = newVal
// 把副作用从桶里面取出来,并执行
bucket.forEach(fn => fn())
// 返回true代表执行成功
return true
}
})
// 定义副作用函数
function effect() {
// effect 函数的执行会读取 obj.text
document.body.innerText = obj.text
}
// 执行副作用函数,触发读取,将副作用函数添加到bucket中
effect()
// 3s后重新执行,测试响应式
setTimeout(() => {
obj.text = "hello vue3"
}, 3000)
</script>
</body>
</html>
这里发现定时器中的赋值代码成功触发了响应式
但是目前的实现还存在很多缺陷,例如我们直接通过名字(effect
)来获取副作用函数,这种硬编码的方式很不灵活。副作用函数的名字可以任意取,我们完全可以把副作用函数命名为myEffect
,甚至是一个匿名函数,因此我们要想办法去掉这种硬编码的机制。
上面实现了一个基础的响应式系统,但是有个缺陷,就是调用effect
函数属于硬编码,接下来解决这个问题:
我们可以封装一个effect
函数来注册副作用函数到bucket
中,利用一个全局变量activeEffect
解决了硬编码的问题
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="text/javascript">
// 定义一个全局变量保存被注册的副作用函数
let activeEffect = null;
// 存储副作用的桶
const bucket = new Set()
// 原始数据
const data = { text: "hello world" }
// 对原始数据进新代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
bucket.add(activeEffect)
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 执行更新
target[key] = newVal
// 把副作用从桶里面取出来,并执行
bucket.forEach(fn => fn())
// 返回true代表执行成功
return true
}
})
// 封装副作用注册函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
// 执行副作用函数,触发读取
effect(() => {
document.body.innerText = obj.text
})
// 3s后重新执行,测试响应式
setTimeout(() => {
obj.text = "hello vue3"
}, 3000)
</script>
</body>
</html>
上面的代码也还存在问题,当我们在响应式数据 obj
上改变一个不存在的属性时,例如notExist
,会发现即使我们改变的不是obj.text
,也会重新触发effect
函数,这显然是不对的,这会浪费大量的性能
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="text/javascript">
// 定义一个全局变量保存被注册的副作用函数
let activeEffect = null;
// 存储副作用的桶
const bucket = new Set()
// 原始数据
const data = { text: "hello world" }
// 对原始数据进新代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
bucket.add(activeEffect)
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 执行更新
target[key] = newVal
// 把副作用从桶里面取出来,并执行
bucket.forEach(fn => fn())
// 返回true代表执行成功
return true
}
})
// 封装副作用注册函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
// 执行副作用函数,触发读取
effect(() => {
console.log("effect run"); // 会发现effect run一共会打印两次
document.body.innerText = obj.text
})
// 3s后重新执行,测试响应式
setTimeout(() => {
// 副作用函数中并没有读取 notExist 属性的值
obj.notExist = "hello vue3"
}, 3000)
</script>
</body>
</html>
出现这个原因是因为我们没有在副作用函数与被操作的目标字段之间建立明确的联系。例如当读取属性时,无论读取的是哪一个属性,其实都一样,都会把副作用函数收集到“桶”里;当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。解决方法很简单,只需要在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计“桶”的数据结构,而不能简单地使用一个Set
类型的数据作为“桶”了。
effect(function effectFn() {
document.body.innerText = obj.text
})
以上代码中存在三个角色:
被操作(读取)的目标对象obj
被操作(读取)的字段text
使用effect
注册的副作用函数effectFn
如果用 target
来表示一个代理对象所代理的原始对象,用 key
来表示被操作的字段名,用 effectFn
来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:
target
└── key
└── effectFn
这是一种树型结构,下面举几个例子来对其进行补充说明
如果有两个副作用函数同时读取同一个对象的属性值:
effect(function effectFn1() {
obj.text
})
effect(function effectFn2() {
obj.text
})
那么关系如下:
target
└── text
└── effectFn1
└── effectFn2
如果一个副作用函数中读取了同一个对象的两个不同属性:
effect(function effectFn() {
obj.text1
obj.text2
})
那么关系如下:
target
└── text1
└── effectFn
└── text2
└── effectFn
如果在不同的副作用函数中读取了两个不同对象的不同属性:
effect(function effectFn1() {
obj1.text1
})
effect(function effectFn2() {
obj2.text2
})
那么关系如下:
target1
└── text1
└── effectFn1
target2
└── text2
└── effectFn2
总之,这其实就是一个树型数据结构。这个联系建立起来之后,就可以解决前文提到的问题了。拿上面的例子来说,如果我们设置了obj2.text2
的值,就只会导致 effectFn2
函数重新执行,并不会导致 effectFn1
函数重新执行。
接下来使用这个树形数据结构进行代码优化,顺便封装通用逻辑,优化后的代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="text/javascript">
// 定义一个全局变量保存被注册的副作用函数
let activeEffect = null;
// 原始数据
const data = { text: "hello world" }
// 存储副作用函数的桶
const bucket = new WeakMap()
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return
// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key -->effects
let depsMap = bucket.get(target)
// 如果不存在 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()))
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target)
if (!depsMap) return
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}
// 代理对象
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)
}
})
// 封装副作用注册函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
// 执行副作用函数,触发读取
effect(() => {
console.log("effect run");
document.body.innerText = obj.text
})
// 可以注册多个副作用函数
effect(() => {
let a = obj.text
console.log("effect2 run");
})
// 3s后重新执行,测试响应式
setTimeout(() => {
// 副作用函数中并没有读取 text 属性的值
obj.text = "hello vue3"
}, 3000)
</script>
</body>
</html>
现在的代码还有一个问题,effect
函数中当obj.ok
为false
的时候,并不需要获取obj.text
,也就是说当obj.ok
为false
的时候,不需要收集obj.text
的依赖,也不需要重新触发effect
,但是现在obj.text = 'hello vue3'
赋值的时候,还是会触发effect
,原因是最开始当obj.text = true
的时候,收集了obj.text
的依赖,但是obj.ok = false
的时候并没有清除旧的依赖,导致obj.text
更新的时候依然触发了trigger
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="text/javascript">
// 定义一个全局变量保存被注册的副作用函数
let activeEffect = null;
// 原始数据
const data = { text: "hello world", ok: true }
// 存储副作用函数的桶
const bucket = new WeakMap()
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
console.log("track run:key",key);
// 没有 activeEffect,直接 return
if (!activeEffect) return
// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key -->effects
let depsMap = bucket.get(target)
// 如果不存在 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()))
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
console.log("trigger run:key",key);
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target)
if (!depsMap) return
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}
// 代理对象
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)
}
})
// 封装副作用注册函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
// 执行副作用函数,触发读取
effect(() => {
console.log("effect run");
document.body.innerText = obj.ok ? obj.text : 'not'
})
// 3s后重新执行,测试响应式
setTimeout(() => {
// 副作用函数中并没有读取 text 属性的值
obj.ok = false
console.log("text 改变,此时的bucket:{}",bucket);
obj.text = 'hello vue3'
}, 3000)
</script>
</body>
</html>
控制台输出结果如下,可以发现当obj.ok = false
的时候,obj.text
的更新还是触发了effect
函数
effect run
index06.html:21 track run:key ok
index06.html:21 track run:key text
index06.html:45 trigger run:key ok
index06.html:83 effect run
index06.html:21 track run:key ok
index06.html:91 text 改变,此时的bucket:{} WeakMap {{…} => Map(2)}[[Entries]]0: {Object => Map(2)}key: {text: 'hello vue3', ok: false}value: Map(2)[[Entries]]0: {"ok" => Set(1)}key: "ok"value: Set(1)[[Entries]]0: () => {
console.log("effect run");
document.body.innerText = obj.ok ? obj.text : 'not'
}size: 1[[Prototype]]: Set1: {"text" => Set(1)}key: "text"value: Set(1)[[Entries]]0: () => {
console.log("effect run");
document.body.innerText = obj.ok ? obj.text : 'not'
}size: 1[[Prototype]]: Setsize: 2[[Prototype]]: Map[[Prototype]]: WeakMap
index06.html:45 trigger run:key text
index06.html:83 effect run
index06.html:21 track run:key ok
解决这个问题的思路很简单,每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除,当副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用函数。所以,如果我们能做到每次副作用函数执行前,将其从相关联的依赖集合中移除,那么问题就迎刃而解了。大概原理如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="text/javascript">
let activeEffect = null;
const data = { text: "hello world", ok: true }
const bucket = new WeakMap()
function track(target, key) {
...
}
function trigger(target, key) {
...
}
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
}
})
function effect(fn) {
// 在这里清除fn 函数 关联的所有依赖集合
cleanup()
activeEffect = fn
fn()
}
effect(() => {
console.log("effect run");
document.body.innerText = obj.ok ? obj.text : 'not'
})
setTimeout(() => {
// obj.ok 改变会触发 effect,清除了关联的依赖集合并 执行回调函数fn ,然后会重新建立依赖,
// 此时发现 effect 中没用到 obj.text,那么就不会建立 fn 函数和 obj.text的依赖,obj.text数据变化时也不会重新触发 effect 了
obj.ok = false
obj.text = 'hello vue3'
}, 3000)
</script>
</body>
</html>
控制台输出结果如下,可以发现obj.text
改变已经不会再次引起effect
重新执行了
effect run
index07.html:21 track run:key ok
index07.html:21 track run:key text
index07.html:46 trigger run:key ok
index07.html:103 effect run
index07.html:21 track run:key ok
index07.html:111 text 改变,此时的bucket:{} WeakMap {{…} => Map(2)}[[Entries]]0: {Object => Map(2)}key: ok: falsetext: "hello vue3"[[Prototype]]: Objectvalue: Map(2)[[Entries]]0: {"ok" => Set(1)}1: {"text" => Set(0)}size: 2[[Prototype]]: Map[[Prototype]]: WeakMap
index07.html:46 trigger run:key text
利用调用栈解决effect
无限递归嵌套的问题:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<script type="text/javascript">
// 全局变量
let temp1, temp2
// 定义一个全局变量保存被注册的副作用函数
let activeEffect = null;
// 原始数据
const data = { foo: true, bar: true }
// 存储副作用函数的桶
const bucket = new WeakMap()
// effect 栈
const effectStack = [] // 新增
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
console.log("track run:key", key);
// 没有 activeEffect,直接 return
if (!activeEffect) return
// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key -->effects
let depsMap = bucket.get(target)
// 如果不存在 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()))
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect)
// 将其添加到 activeEffect.deps 数组中, 目的是获取到这个副作用函数关联的依赖集合
activeEffect.deps.push(deps)
}
// 在 set 拦截函数内调用 trigger 函数触发变化
function trigger(target, key) {
console.log("trigger run:key", key);
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects) // 新增
effectsToRun.forEach(effectFn => effectFn()) // 新增
// effects && effects.forEach(effectFn => 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)
}
})
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffectactiveEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn) // 新增
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把activeEffect 还原为之前的值
effectStack.pop() // 新增
activeEffect = effectStack[effectStack.length - 1] // 新增
}
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
// 清除指定函数的依赖
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
// deps是bucket中传入下来的依赖集合引用
const deps = effectFn.deps[i]
// 将 effectFn 从依赖集合中移除
deps.delete(effectFn)
}
// 最后重置 effectFn.deps 数组
effectFn.deps.length = 0
}
// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function effectFn2() {
console.log('effectFn2 执行')
// 在 effectFn2 中读取 obj.bar 属性
temp2 = obj.bar
})
// 在 effectFn1 中读取 obj.foo 属性
temp1 = obj.foo
})
// 3s后重新执行,测试响应式
setTimeout(() => {
obj.foo = false
}, 3000)
</script>
</body>
</html>
有了调度函数,我们在 trigger
函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,从而把控制权交给用户:
前文介绍了 effect
函数,它用来注册副作用函数,同时它也允许指定一些选项参数 options
,例如指定 scheduler
调度器来控制副作用函数的执行时机和方式;也介绍了用来追踪和收集依赖的track
函数,以及用来触发副作用函数重新执行的 trigger
函数。实际上,综合这些内容,我们就可以实现 Vue.js
中一个非常重要并且非常有特色的能力——计算属性。
计算属性 = effect
+ options(扩展了dirty字段)
+ scheduler
现在所实现的 effect
函数会立即执行传递给它的副作用函数但在有些场景下,我们并不希望它立即执行,而是希望它在需要的时候才执行,例如计算属性,我们希望用到的时候再执行 effect
传递的方法
lazy
选项和之前介绍的 scheduler
一样,它通过 options
选项对象指定。有了它,我们就可以修改 effect
函数的实现逻辑了,当 options.lazy
为 true
时,则不立即执行副作用函数
function effect(fn, options = {}) {
const 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 = []
if (!options.lazy) {
effectFn()
}
return effectFn
}
现在我们已经能够实现懒执行的副作用函数,并且能够拿到副作用函数的执行结果了,接下来就可以实现计算属性了
function computed(getter) {
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true
})
const obj = {
// 当读取 value 时才执行 effectFn
get value() {
return effectFn()
}
}
return obj
}
接下来可以进行使用
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, { /* ... */ })
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value) // 3
可以看到它能够正确地工作。不过现在我们实现的计算属性只做到了懒计算,也就是说,只有当你真正读取 sumRes.value
的值时,它才会进行计算并得到值。但是还做不到对值进行缓存,即假如我们多次访问 sumRes.value
的值,会导致 effectFn
进行多次计算
console.log(sumRes.value) // 3
console.log(sumRes.value) // 3
console.log(sumRes.value) // 3
上面的代码多次访问 sumRes.value
的值,每次访问都会调用 effectFn
重新计算
为了解决这个问题,就需要我们在实现 computed
函数时,添加对值进行缓存的功能,如以下代码所示:
function computed(getter) {
// value 用来缓存上一次计算的值
let value
// dirty 标志,用来标识是否需要重新计算值,为 true 则意味着“脏”,需要计算
let dirty = true
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
// 只有“脏”时才计算值,并将得到的值缓存到 value 中
if (dirty) {
value = effectFn()
// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
dirty = false
}
return value
}
}
return obj
}
上面的代码依然有个问题,当第一次调用计算属性后,dirty
就一直为false
了,后面再调用计算属性就永远不会重新触发了
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, { /* ... */ })
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value) // 3
console.log(sumRes.value) // 3
// 修改 obj.foo
obj.foo++
// 再次访问,得到的仍然是 3,但预期结果应该是 4
console.log(sumRes.value) // 3
可以利用scheduler
解决这个问题,我们为 effect
添加了 scheduler
调度器函数,它会在 getter
函数中所依赖的响应式数据变化时执行,这样我们在 scheduler
函数内将 dirty
重置为 true
,当下一次访问 sumRes.value
时,就会重新调用 effectFn
计算值,这样就能够得到预期的结果了。
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将 dirty 重置为 true
scheduler() {
dirty = true
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
return obj
}
实际上, watch
的实现本质上就是利用了 effect
以及 options.scheduler
选项,当effect
中收集的数据变化时,如果用户传入了scheduler
则执行用户传入的函数,此时scheduler
相当于watch
,如以下代码所示:
effect(() => {
console.log(obj.foo)
}, {
scheduler() {
// 当 obj.foo 的值变化时,会执行 scheduler 调度函数
}
})
最简单的watch
实现
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
effect(
// 触发读取操作,从而建立联系
() => source.foo,
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb()
}
}
)
}
调用方式
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
watch(obj, () => {
console.log('数据变化了')
})
obj.foo++
上面的代码对source.foo
进行了硬编码,最好的方式是编写一个函数遍历目标对象递归进行读取操作,这样就能读取一个对象上的任意属性,从而当任意属性发生变化时都能够触发回调函数执行。
function watch(source, cb) {
effect(
// 调用 traverse 递归地读取
() => traverse(source),
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb()
}
}
)
}
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)
}
return value
}
watch
还支持传入一个getter
函数,在 getter
函数内部,用户可以指定该 watch
依赖哪些响应式数据,只有当这些数据变化时,才会触发回调函数执行。如下代码实现了这一功能:
watch(
// getter 函数
() => obj.foo,
// 回调函数
() => {
console.log('obj.foo 的值变了')
}
)
function watch(source, cb) {
// 定义 getter
let getter
// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用 traverse 递归地读取
getter = () => traverse(source)
}
effect(
// 执行 getter
() => getter(),
{
scheduler() {
cb()
}
}
)
}
现在watch
还缺少一个东西,即在回调函数中拿不到旧值与新值。通常我们在使用 Vue.js
中的 watch
函数时,能够在回调函数中得到变化前后的值:
watch(
() => obj.foo,
(newValue, oldValue) => {
console.log(newValue, oldValue) // 2, 1
}
)
obj.foo++
那么如何获得新值与旧值呢?这需要充分利用 effect
函数的 lazy
选项,如以下代码所示:
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到effectFn 中以便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler() {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 将旧值和新值作为回调函数的参数
cb(newValue, oldValue)
// 更新旧值,不然下一次会得到错误的旧值
oldValue = newValue
}
}
)
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
watch
的立即执行功能
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// 提取 scheduler 调度函数为一个独立的 job 函数
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
// 使用 job 函数作为调度器函数
scheduler: job
}
)
if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job()
} else {
oldValue = effectFn()
}
}