Skip to content

《Vue设计与实现》读书笔记

最近在看霍春阳著作的《Vue设计与实现》一书,特此总结读书笔记

框架设计权衡

Vue框架中的TreeShaking

什么是TreeShaking

简单地说,Tree-Shaking 指的就是消除那些永远不会被执行的代码,也就是排除 dead code,现在无论是 rollup.js 还是 webpack,都支持Tree-Shaking

在 Rollup 和其他 JavaScript 构建工具中,/*#__PURE__*/ 注释用于标记函数调用为“纯函数”(pure function)。纯函数是指那些没有副作用且结果仅依赖于输入参数的函数。这种注释可以帮助构建工具和优化器更好地理解和优化代码。

rollup中的PURE注释主要作用

/*#__PURE__*/ 注释是一个有用的工具,可以帮助构建工具更好地优化你的代码。通过标记纯函数,你可以提高代码的可读性和性能,同时减少最终打包的代码体积。希望这些建议对你有所帮助!如果有任何问题或需要进一步的帮助,请随时告诉我。

  1. Tree Shaking

    • Tree shaking 是一种摇树优化技术,用于删除未使用的代码。标记为纯函数的代码更容易被识别为可删除的未使用代码。
    • 例如,如果一个纯函数的返回值没有被使用,构建工具可以安全地删除该函数调用及其依赖的代码。
  2. 内联优化

    • 构建工具可以更积极地内联标记为纯函数的代码,从而减少函数调用开销。
    • 内联优化可以提高性能,尤其是在函数调用频繁的情况下。
  3. 副作用分析

    • 构建工具可以更准确地分析代码的副作用。纯函数标记有助于构建工具识别哪些代码没有副作用,从而进行更有效的优化。
示例

假设你有一个纯函数 add,你可以使用 /*#__PURE__*/ 注释来标记它:

javascript
function add(a, b) {
  return a + b;
}

const result = /*#__PURE__*/ add(1, 2);
实际应用

在实际项目中,你可能会看到许多库和框架使用这种注释来帮助构建工具进行优化。例如,React 的 React.createElement 函数就经常被标记为纯函数:

javascript
const element = /*#__PURE__*/ React.createElement('div', null, 'Hello, World!');

构建产物

Vue借助Rollup打包工具,分别输出了IIFEESM(ECMAScript Modules)CJS(CommonJs)等多种类型的引用资源,关于前端模块化可以参考这篇博文

前端模块化

vue3的设计思路

渲染器的设计思路

渲染器的主要工作是将虚拟DOM(一个JavaScript对象)渲染成真实的DOM

假设我们有如下虚拟 DOM

js
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 对象,并没有特殊含义。

js
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 函数:

js
renderer(vnode, document.body) // body 作为挂载点

在浏览器中运行这段代码,会渲染出“click me”文本,点击该文本,会弹出 alert('hello'),如图 3-2 所示。

运行结果分析总结: 现在我们回过头来分析渲染器 renderer 的实现思路,总体来说分为三步:

  • 创建元素:把 vnode.tag 作为标签名称来创建 DOM 元素。

  • 为元素添加属性和事件:遍历 vnode.props 对象,如果 keyon 字符开头,说明它是一个事件,把字符 on 截取掉后再调用toLowerCase 函数将事件名称小写化,最终得到合法的事件名称,例如 onClick 会变成 click,最后调用addEventListener 绑定事件处理函数。

  • 处理 children:如果 children 是一个数组,就递归地调用renderer 继续渲染,注意,此时我们要把刚刚创建的元素作为挂载点(父节点);如果 children 是字符串,则使用createTextNode 函数创建一个文本节点,并将其添加到新创建的元素内。

渲染器的精髓都在更新节点的阶段,对于渲染器来说,它需要精确地找到 vnode 对象的变更点并且只更新变更的内容。就上例来说,渲染器应该只更新元素的文本内容,而不需要再走一遍完整的创建元素的流程。这些内容后文会重点讲解,但无论如何,希望大家明白,渲染器的工作原理其实很简单,归根结底,都是使用一些我们熟悉的 DOM 操作 API 来完成渲染工作。

组件的本质

其实虚拟 DOM 除了能够描述真实 DOM 之外,还能够描述组件。例如使用 { tag: 'div' } 来描述 <div> 标签,但是组件并不是真实的 DOM 元素,那么如何使用虚拟 DOM 来描述呢?想要弄明白这个问题,就需要先搞清楚组件的本质是什么。一句话总结:组件就是一组 DOM 元素的封装,这组 DOM 元素就是组件要渲染的内容,因此我们可以定义一个函数来代表组件,而函数的返回值就代表组件要渲染的内容:

js
const MyComponent = function () {
    return {
        tag: 'div',
        props: {
            onClick: () => alert('hello')
        },
        children: 'click me'
    }
}

用虚拟DOM描述组件大概长这样:

js
const vnode = {
    tag: MyComponent
}

就像 tag: 'div' 用来描述 <div> 标签一样,tag:MyComponent 用来描述组件,只不过此时的 tag 属性不是标签名称,而是组件函数。为了能够渲染组件,需要渲染器的支持。修改前面提到的 renderer 函数,如下所示:

js
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函数:

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

可以看到,非常简单。首先调用 vnode.tag 函数,我们知道它其实就是组件函数本身,其返回值是虚拟 DOM,即组件要渲染的内容,这里我们称之为 subtree。既然 subtree 也是虚拟 DOM,那么直接调用 renderer 函数完成渲染即可。

组件一定得是函数吗?当然不是,我们完全可以使用一个 JavaScript 对象来表达组件,例如:

js
// MyComponent 是一个对象
const MyComponent = {
    render() {
        return {
            tag: 'div',
            props: {
                onClick: () => alert('hello')
            },
            children: 'click me'
        }
    }
}

这里我们使用一个对象来代表组件,该对象有一个函数,叫作 render,其返回值代表组件要渲染的内容。为了完成组件的渲染,我们需要修改 renderer 渲染器以及 mountComponent 函数。

js
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 函数:

js
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 框架中的另外一个重要组成部分:编译器

编译器和渲染器一样,只是一段程序而已,不过它们的工作内容不同。编译器的作用其实就是将模板编译为渲染函数,例如给出如下模板:

html
<div @click="handler">
    click me
</div>

对于编译器来说,模板就是一个普通的字符串,它会分析该字符串并生成一个功能与之相同的渲染函数:

js
render() {
    return h("div", { onClick: handler }, "click me");
}

以我们熟悉的 .vue 文件为例,一个 .vue 文件就是一个组件,如下所示

vue
 <template>
  <div @click="handler">click me</div>
</template>
    
     <script>
export default {
  data() {
    /* ... */
  },
  methods: {
    handler: () => {
      /* ... */
    },
  },
};
</script>

其中 <template> 标签里的内容就是模板内容,编译器会把模板内容编译成渲染函数并添加到 <script> 标签块的组件对象上,所以最终在浏览器里运行的代码就是:

js
export default {
    data() {/* ... */ },
    methods: {
        handler: () => {/* ... */ }
    },
    render() {
        return h('div', { onClick: handler }, 'click me')
    }
}

无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的,然后渲染器再把渲染函数返回的虚拟 DOM 渲染为真实 DOM,这就是模板的工作原理,也是 Vue.js 渲染页面的流程。编译器是一个比较大的话题,后面我们会着重讲解,这里大家只需要清楚编译器的作用及角色即可。

响应系统

Vue3的响应式数据基本实现

假设我们现在有一个数据obj,以及一个副作用函数effect

函数会影响一些全局数据就叫做副作用函数,与之相对的是纯函数 什么是纯函数

js
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实现这个需求:

html
<!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解决了硬编码的问题

html
<!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函数,这显然是不对的,这会浪费大量的性能

html
<!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 类型的数据作为“桶”了。

使用WeakMap重新定义桶结构

js
effect(function effectFn() {
    document.body.innerText = obj.text
})

以上代码中存在三个角色:

  • 被操作(读取)的目标对象obj

  • 被操作(读取)的字段text

  • 使用effect注册的副作用函数effectFn

如果用 target 来表示一个代理对象所代理的原始对象,用 key来表示被操作的字段名,用 effectFn 来表示被注册的副作用函数,那么可以为这三个角色建立如下关系:

js
 target
 └── key
    └── effectFn

这是一种树型结构,下面举几个例子来对其进行补充说明

如果有两个副作用函数同时读取同一个对象的属性值:

js
 effect(function effectFn1() {
    obj.text
 })
 effect(function effectFn2() {
    obj.text
 })

那么关系如下:

js
 target
 └── text
 └── effectFn1
 └── effectFn2

如果一个副作用函数中读取了同一个对象的两个不同属性:

js
 effect(function effectFn() {
    obj.text1
    obj.text2
 })

那么关系如下:

js
 target
 └── text1
 └── effectFn
 └── text2
 └── effectFn

如果在不同的副作用函数中读取了两个不同对象的不同属性:

js
 effect(function effectFn1() {
    obj1.text1
 })
 effect(function effectFn2() {
    obj2.text2
 })

那么关系如下:

js
 target1
 └── text1
 └── effectFn1
 target2
 └── text2
 └── effectFn2

总之,这其实就是一个树型数据结构。这个联系建立起来之后,就可以解决前文提到的问题了。拿上面的例子来说,如果我们设置了obj2.text2 的值,就只会导致 effectFn2 函数重新执行,并不会导致 effectFn1 函数重新执行。

接下来使用这个树形数据结构进行代码优化,顺便封装通用逻辑,优化后的代码:

html
<!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>

WeakMap、Map 和 Set 之间的关系

清除遗留副作用函数

现在的代码还有一个问题,effect函数中当obj.okfalse的时候,并不需要获取obj.text,也就是说当obj.okfalse的时候,不需要收集obj.text的依赖,也不需要重新触发effect,但是现在obj.text = 'hello vue3'赋值的时候,还是会触发effect,原因是最开始当obj.text = true的时候,收集了obj.text的依赖,但是obj.ok = false的时候并没有清除旧的依赖,导致obj.text更新的时候依然触发了trigger

html
<!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函数

js
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

解决这个问题的思路很简单,每次副作用函数执行时,我们可以先把它从所有与之关联的依赖集合中删除,当副作用函数执行完毕后,会重新建立联系,但在新的联系中不会包含遗留的副作用函数。所以,如果我们能做到每次副作用函数执行前,将其从相关联的依赖集合中移除,那么问题就迎刃而解了。大概原理如下:

html
<!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重新执行了

js
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和effect栈

利用调用栈解决effect无限递归嵌套的问题:

html
<!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>

Scheduler调度函数

有了调度函数,我们在 trigger 函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,从而把控制权交给用户:

Computed的实现原理

前文介绍了 effect 函数,它用来注册副作用函数,同时它也允许指定一些选项参数 options,例如指定 scheduler 调度器来控制副作用函数的执行时机和方式;也介绍了用来追踪和收集依赖的track 函数,以及用来触发副作用函数重新执行的 trigger 函数。实际上,综合这些内容,我们就可以实现 Vue.js 中一个非常重要并且非常有特色的能力——计算属性。

计算属性 = effect + options(扩展了dirty字段) + scheduler

现在所实现的 effect 函数会立即执行传递给它的副作用函数但在有些场景下,我们并不希望它立即执行,而是希望它在需要的时候才执行,例如计算属性,我们希望用到的时候再执行 effect 传递的方法

lazy 选项和之前介绍的 scheduler 一样,它通过 options 选项对象指定。有了它,我们就可以修改 effect 函数的实现逻辑了,当 options.lazytrue 时,则不立即执行副作用函数

js
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
}

现在我们已经能够实现懒执行的副作用函数,并且能够拿到副作用函数的执行结果了,接下来就可以实现计算属性了

js
function computed(getter) {
    // 把 getter 作为副作用函数,创建一个 lazy 的 effect
    const effectFn = effect(getter, {
        lazy: true
    })

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

    return obj
}

接下来可以进行使用

js
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 进行多次计算

js
console.log(sumRes.value) // 3
console.log(sumRes.value) // 3
console.log(sumRes.value) // 3

上面的代码多次访问 sumRes.value 的值,每次访问都会调用 effectFn 重新计算

为了解决这个问题,就需要我们在实现 computed 函数时,添加对值进行缓存的功能,如以下代码所示:

js
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了,后面再调用计算属性就永远不会重新触发了

js
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 计算值,这样就能够得到预期的结果了。

js
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的实现原理

实际上, watch 的实现本质上就是利用了 effect 以及 options.scheduler 选项,当effect中收集的数据变化时,如果用户传入了scheduler则执行用户传入的函数,此时scheduler相当于watch ,如以下代码所示:

js
effect(() => {
    console.log(obj.foo)
}, {
    scheduler() {
        // 当 obj.foo 的值变化时,会执行 scheduler 调度函数
    }
})

最简单的watch实现

js
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
    effect(
        // 触发读取操作,从而建立联系
        () => source.foo,
        {
            scheduler() {
                // 当数据变化时,调用回调函数 cb
                cb()
            }
        }
    )
}

调用方式

js
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })

watch(obj, () => {
    console.log('数据变化了')
})

obj.foo++

上面的代码对source.foo进行了硬编码,最好的方式是编写一个函数遍历目标对象递归进行读取操作,这样就能读取一个对象上的任意属性,从而当任意属性发生变化时都能够触发回调函数执行。

js
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 依赖哪些响应式数据,只有当这些数据变化时,才会触发回调函数执行。如下代码实现了这一功能:

js
watch(
    // getter 函数
    () => obj.foo,
    // 回调函数
    () => {
        console.log('obj.foo 的值变了')
    }
)
js
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 函数时,能够在回调函数中得到变化前后的值:

js
watch(
    () => obj.foo,
    (newValue, oldValue) => {
        console.log(newValue, oldValue) // 2, 1
    }
)

obj.foo++

那么如何获得新值与旧值呢?这需要充分利用 effect 函数的 lazy 选项,如以下代码所示:

js
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的立即执行功能

js
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()
    }
}

上次更新于: