模拟实现所有Vue3响应式API(上)

3,740 阅读9分钟

前言

本系列文章的目标是模拟实现所有Vue3响应式相关API

为了不混乱,我先将响应式相关API进行分类,如图所示

由于文章篇幅较长,为了避免大家疲劳,先作出两点改善:

  1. 分篇;将文章按照上述分类和内容量分为上、中、下或更多文章
  2. 插入图片;我将尽量多插入一些相关图片,一来缓解疲劳,二来帮助大家理解

此篇目标是深入了解9个响应式基础API中的reactive,并模拟实现我们自己的数据响应式系统

上篇3.png

思路

我的思路其实非常简单,首先去了解API的基本使用,然后试着去使用和理解它,然后按照它所实现的功能模拟实现我们自己的功能,如下

工作准备

在开始前,我们需要做一点准备工作

  1. 需要创建一个vue3项目,方便使用对应的响应式API 如果你不知道怎么创建,官网提供了多种创建方式:传送门

  2. 单独创建一个文件,用于模拟实现对应API

为了方便,我将上篇文章(从0开始手动实现Vue3初始化流程)所用的文件拿来继续使用,你也可以使用这个文件,简此文最下方附件 1

这个文件实现了Vue3的初始化流程相关的几个API,比如createAppmount方法,我们在这个文件的基础上进行模拟实现数据响应式API

有了以上的准备,下面开始深入理解reactive

reactive函数

我们分两部分来说:reactive的使用和模拟实现

image.png

reactive的使用

graph TD
从reactive的定义 --> 引出疑问 --> 解答

定义

我们先来看官方对于reactive的解释,官方的解释也非常简单

33.jpg

但从这句话我们可以得到以下信息

  1. reactive接受一个对象作为参数
  2. 其返回值是经reactive函数包装过后的数据对象,这个对象具有响应式

产生疑问

通过定义我们可能产生一些疑问

  1. 返回的响应式数据的本质是什么,为啥就能让数据变成响应式?

  2. "副本"是不是意味着响应式数据与原始数据没有关联?

  3. 返回的响应式副本里头的数据是深度响应式吗,即是否递归监听对象的所有属性?

  4. reactive的参数只能传递一个对象吗,如果传递其他值会怎么样?等

带着这些疑问我们一个一个来试验和解答

响应式数据的本质

首先,通过reactive创建一个响应数据,看看响应式数据具体是什么鬼

import { reactive } from "vue";
export default {
  setup() {  
    const state = reactive({
      count: 0,
    });
  },
};

如上代码就可以创建一个响应式数据state,我具体来看一下这个

console.log(state)

可以看见,返回的响应副本state其实就是Proxy对象。所以reactive实现响应式就是基于ES2015 Proxy的实现的。那我们知道Proxy有几个特点:

  1. 代理的对象是不等于原始数据对象
  2. 原始对象里头的数据和被Proxy包装的对象之间是有关联的。即当原始对象里头数据发生改变时,会影响代理对象;代理对象里头的数据发生变化对应的原始数据也会发生变化。

需要记住:是对象里头的数据变化,并不能将原始变量的重新赋值,那是大换血了

因此,既然reactive实现响应式是基于Proxy的实现的,那我们大胆猜测,原始数据与相应数据也是有关联的。

原始数据与响应式数据是否有关联

那我们来测试一下

<template>
  <button @click="change">
    {{ state.count }}
  </button>
</template>
<script>
import { reactive } from "vue";
export default {
  setup() {
    const obj = {
      count: 0,
    };
    const state = reactive(obj);
    function change(){
        ++state.count
        console.log(obj);
        console.log(state);
    }
    return { state,change};
  },
};
</script>

以上代码测试结果如下

验证,确实当响应式对象里头数据变化的时候原始对象的数据也会变化 如果反过来,结果也是一样

 // ++state.count
++obj.count;

当响应式对象里头数据变化的时候原始对象的数据也会变化

因此这里回答了第三个问题呢

那问题来了,我们操作数据的时候通过谁来操作呢? 官方的建议是

建议只使用响应式代理,避免依赖原始对象

再来解决另外一个问题看看reactive是否会深度监听每一层呢?

是否深度响应式

const state = reactive({
    a:{
        b:{
            c:{name:'c'}
        }
    }
});    
console.log(state);  
console.log(state.a);
console.log(state.a.b);  
console.log(state.a.b.c); 

可以看到结果reactive是递归会将每一层包装成Proxy对象的,深度监听每一层的property

是否可以传递原始值

最后测试一下如果reactive传递是非对象而是原始值会怎么样

const state = reactive(0);  
console.log(state)

结果是,原始值并不会被包装,所以也没有响应式特点

到这我们已经了解了reactive,下面进行简单总结:

  1. reactive的参数可以传递对象也可以传递原始值。但是原始值并不会包装成响应式数据
  2. 返回的响应式数据的本质Proxy对象
  3. 返回的响应式"副本"与原始数据有关联,当原始对象里头的数据或者响应式对象里头的数据发生,会彼此相互影响。两种都可以触发界面更新,操作时建议只使用响应式代理对象
  4. 返回的响应式对象里头时深度递归监听每一层的,每一层都会被包装成Proxy对象

有了这些知识,我们下面开始模拟实现reactive函数

模拟实现reactive核心功能

image.png

修改测试用例

const { createApp } = Vue
const app = createApp({
    setup() {
        const state = reactive({
            count: 0
        })
        setInterval(() => {
            console.log(state.count)
            state.count++
        }, 2000);
        return state
    }
});
app.mount('#app');

如上代码,我希望实现一个reactive函数,它接受一个对象,返回一个包装后的响应式对象,当响应式数据发生变化时,页面能及时跟新。

创建reactive函数

我们知道Vue3是基于Proxy实现响应式。作用是所以当数据发生变化时,我们可以拦截到并作出一些操作,比如更新UI视图。因此我们定义reactive接受一个对象obj,通过new Proxy返回包装后的响应式数据

function reactive(obj) {
    return new Proxy(obj, {
        get(target, key) {
            return target[key]
        },
        set(target, key, val) {
            target[key] = val
            // 这里当数据变化时,更新界面,于是我们考虑到这里需要update方法用户更新
            // updata待实现...
        }
    })
}

上述代码中,我们需要封装一个update方法,当数据变化时调用,即用于更新和初始化,于是我们回到mount函数中实现封装

封装update

封装update.jpg

所以可以看到,update函数做了三件事:

  1. 得到最新的元素el
  2. 清空宿主元素parent的内容
  3. 追加el

另外我们还需要在初始化时执行一次

this.update()

下面我们希望当render函数的内部用到了响应式数据,并当数据发生变化时,再次执行update函数

因此我们回到reactive中,当执行set函数时,说明数据有变化,这是我们需要做更新,但是我们怎么调用update呢?使用app.update()吗?

虽然使用app.update()可以实现,但是耦合了app,失去了复用性。所以我们得想其他办法来解耦合

解耦合

image.png

首先我们希望当一个数据发生变化,一定要知道更新的是哪个对应的函数。因此我们需要一个依赖收集的过程,也叫添加副作用,于是我们可以创建一个effect函数,该函数接受一个函数fn作为参数,如果fn使用到了一些响应式数据,当数据发生变化,这个副作用函数fn将再次执行,同时返回副作用函数,如下

const effectStack = [];
function effect(fn) {
    const eff = function () {
        try {
            effectStack.push(eff)
            fn()
        } finally {
            effectStack.pop();
        }
    }
    eff();// 执行一次,触发依赖收集
    return eff
}

effectStack做了以下几个事,用于临时存储fn,将来在做依赖收集的时候把它拿出来,拿出来跟它相关的数据相映射

graph TD
effectStack --> 临时存储fn
effectStack --> 收集依赖时拿出来

接着我们需要写一个依赖收集的函数tracktrack的作用是接受targetkey,让traget key和副作用函数eff建立一个映射关系。兵器我们需要建立一个数据结构,来存储这个映射关系,于是实现如下:

function track(target, key) {
    // 获取副作用函数
    const effect = effectStack[effectStack.length - 1]
    if (effect) {
        console.log(targetMap)
        let map = targetMap[target]
        if (!map) {
            map = targetMap[target] = {}
        }
        let deps = map[key]
        if (!deps) {
            deps = map[key] = []
        }
        // 将副作用函数放入deps
        if (deps.indexOf(effect) === -1) {
            deps.push(effect)
        }
    }
}

记住,track的目的就是建立target和key和副作用eff之间关系

graph TD
target --> key --> eff

接着,我们再reactiveget函数中,做依赖收集

track(target,key)

已经上面步骤,我们已将tragetkey、和副作用函数建立一个映射关系,于是我们可以在用户改变值的时候去触发依赖。因此下面我们封装一个trigger方法来触发依赖

function trigger(target, key) {
    const map = targetMap[target]
    if (map) {
        const deps = map[key]
        if (deps) {
            deps.forEach(dep => dep());
        }
    }
}

接着,我们reactiveset中调用trigger,触发依赖

function reactive(obj) {
    return new Proxy(obj, {
        get(target, key) {
            // 可以做依赖收集
            track(target, key)
            return target[key]
        },
        set(target, key, val) {
            target[key] = val
            // 触发依赖
            trigger(target, key)
        }
    })
}

最后要将update函数作为副作用函数,修改如下:

this.update = effect(() => {
    const el = ops.render.call(this.proxy)
    parent.innerHTML = ''
    insert(el, parent)
})

最终,我们成功实现了reactive,完成了数据响应式

测试代码运行成功,如下 动画11111111.gif

最终代码见文章底部 附件2

总结

end.jpg

reactive的作用其实就是将接收到的对象,通过Proxy打包成响应式对象,当响应式对象的数据发生变化时,页面视图可以对应进行更新。

整个的实现过程从创建reactive开始,里头通过Proxy拦截到对象的相关操作,当代理对象数据发生变化时,我们可以同时在set内部通知更新,于是这里封装了update方法,但是为了解决耦合问题,我们分别实现了添加副作用函数effect、依赖收集的函数track以及触发依赖的trigger方法等

END

附件 1

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>mini-vue3</title>
</head>

<body>
    <div id="app"></div>
    <script>
        const Vue = {
            createApp(ops) {
                const renderer = Vue.createRenderer({
                    querySelector(selector) {
                        return document.querySelector(selector)
                    },
                    insert(child, parent, anchor) {
                        parent.insertBefore(child, anchor || null)
                    }
                })
                return renderer.createApp(ops)
            },
            createRenderer({ querySelector, insert }) {
                return {
                    createApp(ops) {
                        return {
                            mount(selector) {
                                const parent = querySelector(selector)

                                if (!ops.render) {

                                    ops.render = this.compile(parent.innerHTML)
                                }

                                if (ops.setup) {
                                    this.setupState = ops.setup()
                                } else {
                                    this.data = ops.data();
                                }
                                this.proxy = new Proxy(this, {
                                    get(target, key) {
                                        if (key in target.setupState) {
                                            return target.setupState[key]
                                        } else {
                                            return target.data[key]
                                        }
                                    },
                                    set(target, key, val) {
                                        if (key in target.setupState) {
                                            target.setupState[k] = val
                                        } else {
                                            target.data[key] = val
                                        }
                                    }
                                })
                                const el = ops.render.call(this.proxy)
                                parent.innerHTML = ''
                                insert(el, parent)
                            },
                            compile(template) {
                                return function render() {
                                    const h1 = document.createElement('h1')
                                    h1.textContent = this.count
                                    return h1;
                                }
                            }
                        }
                    }
                }
            }
        }
    </script>
    <script>
        // 测试用例
        const { createApp } = Vue
        const app = createApp({
            setup() {
                let count = 1
                return { count }
            }
        });
        app.mount('#app');
    </script>
</body>

</html>

附件 2

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>mini-vue3</title>
</head>

<body>
    <div id="app"></div>
    <script>
        // `reactive`接受一个对象`obj`,返回包装后的响应式数据
        function reactive(obj) {
            // Vue3中基于Proxy实现响应式。作用是所以当数据发生变化时,我们可以拦截到并作出一些操作,比如更新UI视图,即数据响应式
            return new Proxy(obj, {
                get(target, key) {
                    // 可以做依赖收集
                    track(target, key)
                    return target[key]
                },
                set(target, key, val) {
                    target[key] = val
                    // 这里当数据变化时,更新界面,于是我们可以创建一个update方法,并在这里调用
                    // updata()
                    // app.update()
                    //这有个问题,app耦合了,没有通用性
                    // 为了解决这个问题
                    // 我们希望有一条神秘的线,当一个数据发生变化,我一定要知道更新的是哪个对应的函数。
                    // 因此,我们需要一个依赖收集的过程,或者叫添加副作用,即数据发生改变,产生一个副作用

                    // 触发依赖
                    trigger(target, key)
                }
            })
        }
        //effectStack用于临时存储fn,将来在做依赖收集的时候把它拿出来,拿出来跟它相关的数据相映射
        const effectStack = [];
        // 添加副作用函数fn
        function effect(fn) {
            // effect的作用是将传入的fn作为副作用函数,如果fn使用到了一些响应式数据,当数据发生变化,这个副作用函数fn将再次执行
            const eff = function () {
                try {
                    effectStack.push(eff)
                    fn()
                } finally {
                    effectStack.pop();
                }
            }//eff的作用是处理错误,入栈,执行函数,出栈
            // 执行一次,触发依赖收集
            eff();
            return eff
        }

        // 依赖收集函数,希望在副作用函数执行时,去触发track
        // track的作用是接受target、key,让traget[key]和副作用函数eff建立一个映射关系
        // 所以,我建立一个数据结构,来存储这个映射关系
        const targetMap = {}//大概结构是这样的{target: {key:[eff]}}
        function track(target, key) {
            // 获取副作用函数
            const effect = effectStack[effectStack.length - 1]
            // 建立target和key和eff关系
            if (effect) {
                console.log(targetMap)
                let map = targetMap[target]
                if (!map) {
                    map = targetMap[target] = {}
                }
                let deps = map[key]
                if (!deps) {
                    deps = map[key] = []
                }
                // 将副作用函数放入deps
                if (deps.indexOf(effect) === -1) {
                    deps.push(effect)
                }
            }
        }

        function trigger(target, key) {
            const map = targetMap[target]
            if (map) {
                const deps = map[key]
                if (deps) {
                    deps.forEach(dep => dep());
                }
            }
        }

        const Vue = {
            createApp(ops) {
                const renderer = Vue.createRenderer({
                    querySelector(selector) {
                        return document.querySelector(selector)
                    },
                    insert(child, parent, anchor) {
                        parent.insertBefore(child, anchor || null)
                    }
                })
                return renderer.createApp(ops)
            },
            createRenderer({ querySelector, insert }) {
                return {
                    createApp(ops) {
                        return {
                            mount(selector) {
                                const parent = querySelector(selector)

                                if (!ops.render) {

                                    ops.render = this.compile(parent.innerHTML)
                                }

                                if (ops.setup) {
                                    // 经过上面修改,this.setupState已经是响应式对象
                                    this.setupState = ops.setup()
                                } else {
                                    this.data = ops.data();
                                }
                                this.proxy = new Proxy(this, {
                                    get(target, key) {
                                        if (key in target.setupState) {
                                            return target.setupState[key]
                                        } else {
                                            return target.data[key]
                                        }
                                    },
                                    set(target, key, val) {
                                        if (key in target.setupState) {
                                            target.setupState[k] = val
                                        } else {
                                            target.data[key] = val
                                        }
                                    }
                                })
                                // 封装一个update方法,当数据变化时调用,即用于更新和初始化
                                this.update = effect(() => {
                                    // 得到最新的元素、清空、追加
                                    const el = ops.render.call(this.proxy)
                                    parent.innerHTML = ''
                                    insert(el, parent)
                                })
                                // 在初始化是需要先执行一次
                                this.update()

                            },
                            compile(template) {
                                return function render() {
                                    const h1 = document.createElement('h1')
                                    h1.textContent = this.count
                                    return h1;
                                }
                            }
                        }
                    }
                }
            }
        }
    </script>
    <script>
        // 测试用例
        const { createApp } = Vue
        const app = createApp({
            setup() {
                const state = reactive({
                    count: 0
                })
                setInterval(() => {
                    console.log(state.count)
                    state.count++
                }, 2000);
                return state
            }
        });
        app.mount('#app');
    </script>
</body>

</html>