Vue3源码03: Vue3响应式核心原理

2022-09-27 14:22:57 浏览数 (1)

Vue3源码01 : 代码管理策略-monorepo

Vue3源码02: 项目构建流程和源码调试方法

“本文会先对子项目reactivity进行一个基本的介绍,随后会介绍Vue3中的响应式原理,最后会编写一个极简版的响应式系统。在下一篇文章中,将会详细讲解reactivity项目中具体源码的实现细节,敬请朋友们期待。 ”

前言

有可能朋友们会疑惑,源码分析为什么要从reactivity讲起,而不是从其他地方开始分析?请大家先看Vue3官方文档中的包依赖关系图:

代码语言:javascript复制
                                     --------------------- 
                                    |                     |
                                    |  @vue/compiler-sfc  |
                                    |                     |
                                     ----- -------- ------ 
                                          |        |
                                          v        v
                       ---------------------      ---------------------- 
                      |                     |    |                      |
         ------------>|  @vue/compiler-dom   --->|  @vue/compiler-core  |
        |             |                     |    |                      |
    ---- ----          ---------------------      ---------------------- 
   |         |
   |   vue   |
   |         |
    ---- ----          ---------------------      ----------------------      ------------------- 
        |             |                     |    |                      |    |                   |
         ------------>|  @vue/runtime-dom    --->|  @vue/runtime-core    --->|  @vue/reactivity  |
                      |                     |    |                      |    |                   |
                       ---------------------      ----------------------      ------------------- 

其实,我们在core/packages/目录下可以发现一共有16个文件夹,也就是说有16个子项目。但是最核心最重要的,就是图中涉及的7个项目。其中@vue/compiler-sfc、@vue/compiler-dom、 @vue/compiler-core跟编译相关。@vue/runtime-dom、@vue/runtime-core、@vue/reactivity跟运行时相关。而图中的子项目vue更像是一个家长,可以把其他子项目提供的能力聚合在一起,再统一对外提供能力。当vue将其聚合在一起的时候,其中一些子项目的能力只是供内部其他子项目调用,并不会对外暴露所有子项目完整的能力。

在本系列文章中会先讲运行时相关的子项目,再讲编译阶段相关的子项目,因为运行时跟我们实际开发更贴近,一开始就深入编译阶段容易让很多朋友打退堂鼓。而对于运行时相关的子项目,我们从依赖图的最末端讲起,再层层回到依赖项的最顶端,这样一开始涉及的内容会尽可能的少,然后逐渐丰富,符合认知规律。但凡事无绝对,由于@vue/runtime-dom内容相对较少且和实际开发联系比较紧密,因此会在讲解@vue/runtime-core之前进行分析。所以,关于运行时相关的源码分析,实际分析顺序如下:

  1. reactivity
  2. @vue/runtime-dom
  3. @vue/runtime-core

我们先介绍下这几个子项目各自的职责:

  • reactivity: 为数据提供响应式的能力,我们日常开发中出现的reactiveref等函数都出自该项目中;
  • @vue/runtime-dom: 针对浏览器的运行时,内部会涉及到DOM API,其依赖于@vue/runtime-core提供的能力;
  • @vue/runtime-core: 平台无关的运行时核心,内部依赖reactivity提供的数据响应式能力了。有了这个核心库,就可以针对特点平台自定义渲染器,@vue/runtime-dom就是案例。

“对于上文的介绍,大家可能会比较疑惑,比如到底什么是运行时?平台无关又是什么意思?什么是数据响应式?@vue/runtime-dom@vue/runtime-core到底是什么关系?请大家暂时先将这些疑问放下,在后续的文章内容中逐渐会解答大家的疑惑。 ”

reactivty

现在正式步入了reactivity的分析,下面首先会阐述Vue3中数据响应式的概念。接着以一个案例为起点,逐步实现一个极简版本的响应式系统。

Vue2的响应式原理

Vue2中,所谓响应式,我们可以粗略的这样理解,就是利用Object.defineProperty方法,为某个对象reactiveObj属性key设置getset属性,当某个地方X调用了reactiveObj.key则会触发get方法,此时在get方法中做一条记录:X使用了reactiveObj对象的key属性。当为对象reactiveObjkey属性赋值的时候,会触发reactiveObjset方法,此时在set方法中,通知X将自己负责的地方执行一些更新逻辑。如果这个更新逻辑是操作DOM显示新的内容,对于用户来讲直接的感受就是没操作DOM的情况下,只是修改了自己定义的一个普通对象上的一个属性的值,但是DOM上的内容却自己发生了变化。事实上,在Vue2中,通常情况下,定义的所有数据都默认是响应式的,也就是说会默认为每个数据对象的每个属性调用Object.defineProperty方法,让其数据默认具备响应式的能力。

Vue2和Vue3关于响应式的最重要的区别

从本质上讲Vue3的响应式原理和Vue2的响应式原理没有根本的不同。都可以简单的理解为,使用一个对象的属性的时候,记录下是谁在使用,当对象的属性值发生变化时再通知那些使用过该属性值的地方做相应的处理。当然,虽然本质上没有太大的不同,但在实现响应式的方案却又有很大的差别。主要有两个核心的差异:

  1. 利用的基础能力不同,Vue2利用了Object.definePropertyVue3利用了Proxy的相关API
  2. Vue2是默认会让所有的数据具备响应式的能力,Vue3需要手动调用函数让特定数据具备响应式的能力;

当然Vue2Vue3还有很多不同,比如因为采用底层能力的不同导致的兼容性不同、Object.defineProperty有新增属性或数组的响应式丢失问题等等还有很多其他的不同。但我认为核心的不同就是上面的两点:一个代表了实现的基本原理不同,一个代表了响应式相关的应用实践的差异。

手写极简版Vue3响应式系统

“在Vue的世界,不管是Vue2还是Vue3,我们无论在template中写了什么内容,都会在程序内部转化成虚拟DOM,然后再将虚拟DOM转化成真实DOM,最后再将真实DOM在合适的时机挂载到document上某个具体的地方。Vue3有两个render函数,第一个render函数是对模版进行编译的函数compile执行完的返回值,执行该函数可以获得虚拟DOM对象;另一个render函数是将虚拟DOM转化成真实DOM,并将真实DOM挂载到document上。一定要分清这两个render函数的不同,这两个函数可以说是整个Vue3的灵魂。对于响应式原理来讲,我们关心的是第二个render函数,如果对于刚才对render函数的描述还比较模糊也没关系在这里,现在只需要将这句话刻在脑海里:执行完一个函数后,document上的内容就发生了变化。 ”

假如有一个场景,初始代码如下,直接让浏览器页面上某个地方显示字符串yangyitao

代码语言:javascript复制
// 代码片段1
let dataObj = {name:'yangyitao'};
document.getElementById('anyRealId').innerText = dataObj.name // Id为`anyRealId`的元素真实存在
dataObj.name = "杨艺韬";

但是,我们希望在改变了dataObj.name的值后,元素anyRealId中的内容也发生变化,也就是显示的内容由yangyitao变为杨艺韬。可能你会进行下面的改造:

代码语言:javascript复制
// 代码片段2
let dataObj = {name:'yangyitao'};
const functionA = ()=>{
    document.getElementById('anyRealId').innerText = dataObj.name // Id为`anyRealId`的元素真实存在 
}
functionA();
dataObj.name = "杨艺韬";
functionA();

没错,这既保证了数据能够正常初始化,又保证了数据更新后,触发页面内容的变化。但是这样存在几个问题:

  • 手动调用函数,看起来比较繁琐
  • 目前的案例只用到了对象dataObjname属性,所以我们知道在name的值发生了变化手动调用函数,但是如果runctionA中使用了dataObj的100个属性,而且这100个属性可以在任何地方发生改变。程序会显得极其臃肿,也考验我们的记忆力,可操作性极低。
  • 在有些情况下,数据变化后,我们并不需要更新页面上显示的内容,怎么进行区分呢?

为了实现这些目标,我们可能对程序进一步进行优化:

代码语言:javascript复制
<!--代码片段3-->
<html>
    <head></head>
    <body>
        <div id="app"></div>
    </body>
    <script>
        const objMap = new Map()
        const reactive = (obj) => {
            return new Proxy(obj, {
                get: function (target, property) {
                    let propertyMap = objMap.get(target) || new Map()
                    let effectArr = propertyMap.get(property) || []
                    if (effectArr.indexOf(functionA) === -1 && !!functionA) { 
                        effectArr.push(functionA)
                        propertyMap.set(property, effectArr)
                        objMap.set(target, propertyMap)
                    }
                    return target[property] 
                },
                set: function (target, property, val) {
                    target[property] = val
                    let propertyMap = objMap.get(target) || new Map()
                    let effectArr = propertyMap.get(property) || []
                    effectArr.forEach(item => {
                        item()  
                    })
                }
            })
        }

        let dataObj = {name: 'yangyitao'}
        let reactiveDataObj = reactive(dataObj)

        const functionA = () => {
            document.getElementById('app').innerText = reactiveDataObj.name // Id为`anyRealId`的元素真实存在 
        }
        functionA()

        setTimeout(() => {
            reactiveDataObj.name = '杨艺韬'
        }, 3000)
    </script>
</html>

在浏览器中打开页面,会发现页面中的yangyitao会在3秒钟后自动更换为杨艺韬。这样对于函数functionA而言,我们完成了简单的数据变化触发页面变化的功能。为了具备一定的通用性,我们将代码改造成下面这个样子:

代码语言:javascript复制
<!--代码片段4-->
<html>
    <head></head>
    <body>
        <div id="app"></div>
    </body>
    <script>
        const objMap = new Map()
        let activeEffect = null
        const reactive = (obj) => {
            return new Proxy(obj, {
                get: function (target, property) {
                    let propertyMap = objMap.get(target) || new Map()
                    let effectArr = propertyMap.get(property) || []
                    if (effectArr.indexOf(activeEffect) === -1 && !!activeEffect) { 
                        effectArr.push(activeEffect)
                        propertyMap.set(property, effectArr)
                        objMap.set(target, propertyMap)
                    }
                    return target[property] 
                },
                set: function (target, property, val) {
                    target[property] = val
                    let propertyMap = objMap.get(target) || new Map()
                    let effectArr = propertyMap.get(property) || []
                    effectArr.forEach(item => {
                        item()  
                    })
                }
            })
        }
        const effect = (fn) => {
            activeEffect = fn
            fn()
        }

        let dataObj = {name: 'yangyitao'}
        let reactiveDataObj = reactive(dataObj)

        const functionA = () => {
            document.getElementById('app').innerText = reactiveDataObj.name // Id为`anyRealId`的元素真实存在 
        }
        effect(functionA)

        setTimeout(() => {
            reactiveDataObj.name = '杨艺韬'
        }, 3000)
    </script>
</html>

这样,程序多了一点通用性,我们可以把functionA换成functionB或者其他。上面的代码逻辑并不严密,很多边界条件都没处理,但这并不影响我们达到目标。在后续只要更改了reactiveDataObjname属性的值,页面就会自动发生变化,而不需要上文中手动调用一个函数来触发变化。

代码片段4中有几个关键点:

  1. 调用reactive函数传入数据对象,该函数会返回一个代理对象reactiveDataObj,在后续functionA中使用对象reactiveDataObj而非dataObj
  2. 先执行一遍传入effect函数中的functionA,并用一个全局变量activeEffect记录该函数。

其实到了这里,我们可以认为自己已经理解了Vue3最核心的原理。在下一篇文章中,将会详细讲解reactivity项目中具体源码的实现细节,敬请朋友们期待。

0 人点赞