# 深入理解vue响应式原理
# 写在前面
TIP
一切黑魔法的背后都是1+1
众所周知, 目前每个前端开发都会接触过一到两个框架. 或许是 react
, 或许是 vue
, 又或许是 angular
.我们在使用这些框架的同时又有没有想过, 在这些看似很魔幻的功能背后到底隐藏着什么东西, 它又是如何运作的? 今天我们就针对 vue
最核心的响应式原理做一次深入的剖析.
那么什么是响应式呢? 我们知道在 react
中, 如果你想更新你的视图, 那么你需要在改变状态之后手动去调用 react
提供的 setState
方法. 而在 vue
里面, 我们修改 data
之后并不需要我们手动去调用什么方法通知 vue
去更新视图, 因为 vue
能够感知到某个状态被改变了, 从而主动的去更新视图, 对于开发者而言, 这可能更能让人接受, 而主动感知并更新视图的这个过程, 其实就是响应式.
继续用大白话来解释就是下面这个过程:
vue
在初始化的过程中会对某些特定的数据进行劫持. 什么叫做劫持呢, 就是你对这个数据的任何读取, 赋值操作都会经过vue
才能成功. 例如我们声明的data
, 当我们读取data
里面的数据时, 必须经过vue
, 然后vue
再给我们返回想要的数据, 去给data
里面的数据赋值时, 也需要经过vue
, 让vue
去帮助我们赋值. 整个劫持操作在vue3
里面通过Proxy
去实现, 我们后面会介绍到它, 现在大家只需要知道vue
会这么干就行了.那我们什么时候需要更新数据? 我们在
template
里面用到数据的时候, 一定会去读取data
里面的值, 这个时候这个信息就被vue
捕获到了, 同时, 这时候vue
还能知道你是在哪个template
里面用的这个值, 并将其一并记录下来.当我们去改变
data
里面的值时,vue
又知道了, 同时他还记录了这个值对应的template
, 所以自然就会去主动更新你的视图, 完成整个更新流程.
例如下面这段代码:
<template>
<div>{{msg}}</div>
<button @click="change">change</button>
<template/>
<script>
export default {
data() {
return {
msg: 'hello world'
}
},
methods: {
change() {
this.data.msg = 'hello world!!!!'
}
}
}
</script>
当这段代码被解析并初次渲染时, vue
会经过下面这几步:
当我们更新 data
的时候, 是下面的流程:

这就是 vue
的响应式过程. vue3.0
已经把响应式相关的函数都抽到了 @vue/reactivity
中, 官网文档 (opens new window)在此. vue
里面所有和响应式相关的操作都通过这些函数来完成, 下面我们将顺着官网的介绍, 一个个对他们进行更加详细的介绍, 大家可以将该文档作为官网的补充文档进行阅读. 同时也推荐大家对于 vue3
的 Reactivity API
有一定了解之后再来看这篇文章, 收获会更多.
# 响应式基础 API
# reactive
该方法就是返回一个对象的响应式副本, 他的响应式转换是深层的——它影响所有嵌套 property
.在基于 ES2015 Proxy
的实现中, 返回的 proxy
是不等于原始对象的.
这个就是我们之前所说的 vue
劫持 data
的方法. 它的内部是通过 Proxy
实现的, 具体用法可以参考MDN (opens new window). 在这里我们可以简单写一个demo
.
const obj = {name: 'klx'};
const proxyObj = new Proxy(obj, {
get(target, key, receiver) {
console.log("get: ", target, key, receiver);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
console.log("set: ", target, key, value, receiver);
return Reflect.set(target, key, value, receiver);
}
})
proxyObj.name = 'hello world';
// set: { name: 'klx' } name hello world { name: 'klx' }
console.log(proxyObj.name);
// get: { name: 'hello world' } name { name: 'hello world' }
// hello world
通过传递 get
、set
方法给 Proxy
, 我们就能劫持到 obj
对象的读取和赋值操作, 进而能够在这些阶段做一些我们想做的事情.
我们可以看到这里面还使用了一个 Reflect
, 这又是个啥玩意? 我们还是来看看MDN (opens new window). 在 proxy
中使用它来操作 target
主要是为了获取正确的上下文引用. 举个例子:
const parent = {
_name: "parent name",
get name() {
return this._name;
}
};
const parentProxy = new Proxy(parent, {
get(target, prop, receiver) {
// 正确写法
//return Reflect.get(target, prop, receiver);
// 错误写法
return Reflect.get(target, prop);
}
});
const child = {
_name: "child name"
};
Object.setPrototypeOf(child, parentProxy);
console.log(child.name);
// parent name
// 想要拿到 child name, 当时却拿到了 parent name
在这个例子中, 我们的本意是想拿到 child name
, 但是最后却拿到了 parent name
, 这是因为 this
对象指向了 parent
, 但是我们调用的时候明明是 child
. 这个时候就出现了 this
指向错误的问题, 好在 Reflect.get
方法接受一个 receiver
参数用来修改 getter
调用时候的 this
指向, 所以这样我们才能拿到我们真正想要的值.
除此之外, 还有几个问题需要大家注意一下:
1. proxy 只能代理对象
Proxy
能够代理的对象仅限于 Object
(Array
, Map
, Set
, WeakMap
, WeakMap
等), 所以对于 Number
、String
等类型, 它就无能为力了. 比如下面这段代码就会报错:
new Proxy(1, {});
// TypeError: Cannot create proxy with a non-object as target or handler
而我们在使用 vue
的过程中肯定会出现一些非对象类型, 那么这个时候会怎么处理? 凭直觉来说, 肯定是再做一层包装, 将其包装成对象再进行代理. 实际上 vue
也是这么干的, 这一块内容我们会在介绍Ref
的时候提到.
2. 解构会使该 proxy
失去响应性
我们看这个例子:
const obj = new Proxy({name: 'klx'}, {
get(target, key) {
console.log("get", target, key);
return target[key];
}
});
obj.name;
// get { name: 'klx' } name
const { name } = obj;
// get { name: 'klx' } name
name
// nothing
现在我们再使用 name
是不会触发 get
函数的. 当然, 如果 name
本身就是一个具有响应性的对象, 那么它本身仍然具有响应性, 但是读取 name
的时候不会触发初始对象的响应性. 比如下面这个例子:
const message = new Proxy({name: 'klx'}, {
get(target, key) {
console.log("message get: ", target, key);
return target[key];
}
});
const obj = new Proxy({proxyObj: message}, {
get(target, key) {
console.log("obj get: ", target, key);
return target[key];
}
});
obj.proxyObj;
// obj get: { proxyObj: { name: 'klx' } } proxyObj
const { proxyObj } = obj;
// obj get: { proxyObj: { name: 'klx' } } proxyObj
proxyObj;
// nothing
proxyObj.name;
// message get: { name: 'klx' } name
我们把 proxyObj
对象解构出来之后, 读取它本身的name
属性仍然会触发它自己的 get
函数, 但是我们再读取 proxyObj
的时候已经无法触发 obj
对象的 get
函数了.
3. proxy 本身不会进行递归代理
这句话是什么意思呢, 也就是说如果我们有一个对象, 它只会进行一层浅代理, 并不会继续对对象中的对象进行代理, 正如我们上一个例子中, 如果想让 proxyObj
也是一个代理的话, 那么我们需要进行额外的操作, 也就是传给proxyObj
的对象就是一个 proxy
对象.
而从官网的介绍我们明显可以看到, reactive
是会对进行一个深层次的转换, 具体怎么实现也非常简单.
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
if(isObject(res)) {
return reactive(res);
}else {
return res;
}
}
})
}
这样就能保证对 obj
里面所有的对象都进行代理.
至此, 对于这个 api
我们就介绍的差不多了, 可以看到我们大部分的篇幅都在介绍 proxy
, 因为 reactive
其实就是 proxy
, 所以现在我们知道, 通过这个api
返回后的对象就是一个经过了 proxy
代理的对象.
# effect
接受一个传入的函数, 并响应式追踪其依赖, 在依赖发生变更的时候会重新运行该函数. 官网上并没有提及这个函数, 因为这是比较底层的一个函数, 我们日常使用可以用watchEffect
来代替, 我们后面在说watch
、computed
的时候会提到.
单独使用一个reactive
其实并没有什么意义, 因为拿到这个proxy
对象之后, 我们并不能干什么, 重要的是在proxy
的set
/get
函数内部它干了什么. 我们在之前的介绍中说到, vue
获取到读取data
的事件时, 会将data.msg
和模版进行绑定, 这个过程其实就是在get
中做的. 而这个模版其实就可以理解为传给effect
的函数. 话不多说, 我们先来一个简单的例子看看:
const { effect, reactive } = Vue;
const obj = reactive({ msg: 'hello world' });
effect(() => {
console.log(obj.msg);
// 首次运行会输出 hello world
});
setTimeout(() => {
obj.msg = 'hello world!!!'
// obj的值发生改变之后会触发 effect 的函数再次运行, 再次输出 hello world!!!
}, 1000);
可以看到, 首次运行输出了hello world
, 当我们改变obj.msg
的时候, 再次触发了该函数, 输出了hello world!!!
. 所以这是不是和data.msg
的值改变之后, vue
能够重新更新视图很像? 那么这个 effect
函数是如何实现的呢? 我们可以将其拆分一下, 当我们做到下面这几个步骤的时候, 就能实现这个effect
了.
- 获取到某个响应式数据被读取了
- 知道此时这个响应式数据和什么东西要进行绑定
- 响应式数据在发生某些更改的时候重新触发绑定的函数
那么在这里第一步我们已经知道怎么做了, 我们在get
函数中就能知道某个响应式数据被改变了. 我们如何知道此时应该将它和什么东西绑定在一起? 其实在这里我们要做的就是要将其和传给effect
的函数绑定在一起. 我们将这个函数称为副作用, 也就是某个值修改后应该运行的副作用函数. 我们可以用一个activeEffect
来记录下当前的副作用函数, 这样在触发get
的时候我们就能知道该和什么东西绑定了, 下面我们简单实现一下effect
函数.
let activeEffect = null;
const effectMap = new Map();
function effect(f) {
activeEffect = f;
f();
activeEffect = null;
}
// _type 在我们这里是无用参数
function track(obj, _type, key) {
// 收集副作用, 以 obj 为对象
let depsMap = effectMap.get(obj);
if(!depsMap) {
effectMap.set(obj, depsMap = new Map());
}
// 副作用属于该对象的哪个属性
let dep = depsMap.get(key);
if(!dep) {
depsMap.set(key, dep = []);
}
// 如果当前有活跃的副作用, 将其收集起来
if(activeEffect) {
dep.push(activeEffect);
}
}
// _type 在我们这里是无用参数
function trigger(obj, _type, key) {
// 触发副作用
const depsMap = effectMap.get(obj);
if(!depsMap) {
return;
}
// 触发哪个 key 的副作用
const dep = depsMap.get(key);
if(!dep) {
return;
}
// 运行 key 里面的副作用
dep.forEach(effect => {
effect();
});
}
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, null, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver);
trigger(target, null, key);
return res;
}
})
}
const obj = reactive({msg: 'hello world'});
effect(() => {
console.log("effect1", obj.msg);
// effect1 hello world
})
effect(() => {
console.log("effect2", obj.msg);
// effect2 hello world
})
obj.msg = 'hello world!!!!'
// effect1 hello world!!!!
// effect2 hello world!!!!
注意, 我们在get
和set
中干了什么呢, 我们维护了这样的一个列表:
+---------------------------+
| |
| target -> key -> effects |
| |
+---------------------------+
当触发了get
函数的时候我们就能知道这个key
和什么副作用挂钩, 当前活跃的副作用是在effect
内部去赋值的.
当某个数据的set
函数被触发的时候, 我们就能通过target
和key
找到应该触发的副作用, 再依次运行就好了.
但是值得注意的是这样的实现只是一个简单的demo
, 有很多东西都没有考虑到, 比如我们不能简单的通过key
来对应这个列表, 因为对于Array
、Map
这些对象来说, key
并不难一一对应. 比如说我在effect
里面读取了一个arr[2]
, 然后我运行了arr.clear()
, 此时有可能并没有2
的key
, 但是显然此时应该触发副作用. 所以在vue
真正实现中, 有两个对象
export const enum TrackOpTypes {
GET = 'get',
HAS = 'has',
ITERATE = 'iterate'
}
export const enum TriggerOpTypes {
SET = 'set',
ADD = 'add',
DELETE = 'delete',
CLEAR = 'clear'
}
其中TrackOpTypes
标示在使用数据的时候可能触发的操作, TriggerOpTypes
标示在修改数据时可能触发的操作, vue
的内部实现会根据 key
+type
来进行匹配, 判断执行相应的副作用.
实际上是这样的映射关系:
+--------------------------------------+
| |
| target -> OpTypes + key -> effects |
| |
+--------------------------------------+
具体的逻辑大家可以参看源码, 但是我们只要知道有一个这样的映射关系就行了. 同时, 在生产环境中, 还需要考虑递归、调度的问题, 这里我们就不过多展开了, 有兴趣的同学可以和我私聊或者自己参看源码.
这样, 我们就实现了一个简单的effect
函数, 在我们改变某个响应式数据的时候, 能够触发对应的副作用函数. 实际上我们可以看到最核心的就是track
和trigger
函数(这两个函数非常重要, 后面会一直提到), vue
把他们封装在了reactive
内部, 我们是无感知的调用, 那么如果我们不使用响应式对象, 直接手动触发这两个函数能否触发effect
的副作用函数呢?话不多说, 我们直接来实战演练一下:
<script setup>
import { effect, trigger, track } from "@vue/reactivity";
const TrackOpTypes = {
GET: "get",
HAS: "has",
ITERATE: "iterate",
};
const TriggerOpTypes = {
SET: "set",
ADD: "add",
DELETE: "delete",
CLEAR: "clear",
};
// 触发trigger函数, 用 type + key 找副作用
const triggerEvent = () => trigger(obj, TriggerOpTypes.SET, "msg");
const obj = { msg: "hello world" };
effect(() => {
// 捕获当前副作用函数, 用 type + key 映射
track(obj, TrackOpTypes.GET, "msg");
alert(obj.msg)
});
obj.msg = "hello world!!!!!";
triggerEvent();
</script>
<template>
<button @click="triggerEvent">
点我触发副作用
</button>
</template>
可以看到, 我们根本没有使用响应式对象, 但是也能触发副作用. 这就是因为响应式对象的内部其实也是通过track
和trigger
来触发, 我们只是把这个工作给提到外面来进行了而已.
# readonly
接受一个对象 (响应式或纯对象) 或 ref 并返回原始对象的只读代理.只读代理是深层的: 任何被访问的嵌套property
也是只读的.
一个只读的对象, 那不就是一个只有 get
的代理吗? 所以我们内部只要这样处理一下就好了:
function readonly(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
return isObject(res) ? readonly(res) : res;
},
set(target, key) {
console.warn(
`Set operation on key "${String(key)}" failed: target is readonly.`,
target
)
return true
},
})
}
这样, 当我们调用 set
方法的时候, 就会抛出一个错误提示用户这是一个只读属性.
# isReadonly
检查对象是否是由readonly
创建的只读代理.
这个函数的用法很简单明了, 我也不用过多介绍什么了. 那么具体如何实现这个函数呢, 我最直观的想法就是在使用readonly
函数的时候在目标对象上挂一个属性用来标识这是一个readonly
对象, vue
内部并没有这么做. 在内部实现中, 有一个enum
表示标示了各种类型
export const enum ReactiveFlags {
SKIP = '__v_skip',
IS_REACTIVE = '__v_isReactive',
IS_READONLY = '__v_isReadonly',
IS_SHALLOW = '__v_isShallow',
RAW = '__v_raw'
}
其中IS_READONLY
就表示只读类型, 那么怎么用呢. 不管是在用reactive
还是readonly
创建副本的时候, get
函数中都会有一个这样的操作:
function readonly() {
return new Proxy(obj, {
get(target, key, receiver) {
if(key === ReactiveFlags.IS_READONLY) {
return true;
}
return Reflect.get(target, key, receiver);
}
})
}
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
if(key === ReactiveFlags.IS_READONLY) {
return false;
}
return Reflect.get(target, key, receiver);
}
})
}
当我们用ReactiveFlags.IS_READONLY
的key
去访问object
的时候, 会返回true
或者false
表示这是一个什么变量, 那么很显然这两个函数中有很多重合的部分, 我们可以优化一下写成下面这样:
function createGetter(isReadonly = false) {
return function get(target, key, receiver) {
if(key === ReactiveFlags.IS_READONLY) {
return isReadonly;
}
return Reflect.get(target, key, receiver);
}
}
function readonly() {
return new Proxy(obj, {
get: createGetter(true),
})
}
function reactive(obj) {
return new Proxy(obj, {
get: createGetter(false),
})
}
那么readonly
的实现就非常简单了:
function readonly(obj) {
return obj[ReactiveFlags.IS_READONLY];
}
我们可以直接实际演练一下, 不使用isReadonly
函数, 直接使用ReactiveFlags
来获取相应的值:
<script setup>
import { readonly } from 'vue';
const ReactiveFlags = {
SKIP: '__v_skip',
IS_REACTIVE: '__v_isReactive',
IS_READONLY: '__v_isReadonly',
IS_SHALLOW: '__v_isShallow',
RAW: '__v_raw'
}
const msg = readonly({msg: 'hello world'});
const isReadonly = msg[ReactiveFlags.IS_READONLY];
</script>
<template>
<div>
isReadonly: {{isReadonly}}
</div>
</template>
这里isReadonly
会返回一个true
, 也可以直接点击链接 (opens new window)自己尝试一下.
# toRaw
返回reactive
或readonly
代理的原始对象.这是一个“逃生舱”, 可用于临时读取数据而无需承担代理访问/跟踪的开销, 也可用于写入数据而避免触发更改.不建议保留对原始对象的持久引用.请谨慎使用.
我们可以看到之前的Flags
里面有一个值就是RAW
, 而我们在get
函数中拿到的target
其实就是原始对象, 所以怎么做大家也非常清楚了:
function createGetter(isReadonly = false) {
return function get(target, key, receiver) {
if(key === IS_READONLY) {
return isReadonly;
}else if(key === ReactiveFlags.RAW) {
return target;
}
return Reflect.get(target, key, receiver);
}
}
# isReactive
检查对象是否是由reactive
创建的响应式代理. 如果该代理是readonly
创建的, 但包裹了由reactive
创建的另一个代理, 它也会返回true
. 同样的, 它的实现也非常简单.
export function isReactive(obj) {
if (isReadonly(obj)) {
return isReactive(obj[ReactiveFlags.RAW])
}
return obj[ReactiveFlags.IS_REACTIVE];
}
# isProxy
检查对象是否是由reactive
或readonly
创建的 proxy.
我们之前已经介绍过了isReactive
和isReadonly
, 那么显然:
function isProxy(obj) {
return isReactive(obj) || isReadonly(obj);
}
# markRaw
标记一个对象, 使其永远不会转换为proxy
.返回对象本身.
在之前的FLAGS
中有一个属性叫SKIP
, 这个属性就是标示某个对象跳过代理. 所以我们只需要为对象加上这个属性就行了.
function markRaw(obj) {
Object.defineProperty(obj, ReactiveFlags.SKIP, {
configurable: true,
enumerable: false,
value: true,
})
return obj;
}
function reactive(obj) {
if(obj[ReactiveFlags.SKIP])) {
return obj;
}
return new Proxy(obj, {
get(target, key, receiver) {
if(key === IS_READONLY) {
return false;
}
return Reflect.get(target, key, receiver);
}
})
}
那么很显然, 我们可以直接试试不使用markRaw方法, 直接给对象加一个skip
属性会怎么样:
<script setup>
import { reactive, isProxy } from 'vue';
const ReactiveFlags = {
SKIP: '__v_skip',
IS_REACTIVE: '__v_isReactive',
IS_READONLY: '__v_isReadonly',
IS_SHALLOW: '__v_isShallow',
RAW: '__v_raw'
}
const obj1 = reactive({msg: 'hello world'});
const isObj1Proxy = isProxy(obj1);
const obj2 = reactive((() => {
const obj = {msg: 'hello world'};
obj[ReactiveFlags.SKIP] = true;
return obj;
})());
const isObj2Proxy = isProxy(obj2);
</script>
<template>
<div>
obj1 is proxy: {{isObj1Proxy}}
</div>
<div>
obj2 is proxy: {{isObj2Proxy}}
</div>
</template>
isObj1Proxy
是true
, isObj2Proxy
是false
, 具体链接 (opens new window)在此
# shallowReactive
创建一个响应式代理, 它跟踪其自身property
的响应性, 但不执行嵌套对象的深层响应式转换 (暴露原始值).
我们最开始就说过, proxy
本身是不会对深层对象进行代理的, 而我们去实现对深层对象的代理是通过递归实现的. 那么很显然我们只需要在递归的时候加一个判断条件就能实现这个api
.
function createGetter(isReadonly = false, shallow = false) {
return function get(target, key, receiver) {
if(key === IS_READONLY) {
return isReadonly;
}else if(key === ReactiveFlags.RAW) {
return target;
}
const res = Reflect.get(target, key, receiver);
if(shallow) {
return res;
}
return isReadonly ? readonly(res) : reactive(res);
}
}
function shallowReactive(obj) {
return new Proxy(obj, {
get: createGetter(false, true),
});
}
这里我们是把get
函数的创建给单独抽了出来, 像之前那样, 通过传参数的方式去控制创建各种get
.
# shallowReadonly
创建一个proxy
, 使其自身的property
为只读, 但不执行嵌套对象的深度只读转换 (暴露原始值).
同理, 我们可以和shallowReactive
公用一个createGetter
函数:
function shallowReadonly(obj) {
return new Proxy(obj, {
get: createGetter(true, true),
});
}
只需要将传给createGetter
的第一个参数从false
改为true
就行了.
# Refs
# ref
接受一个内部值并返回一个响应式且可变的ref
对象. ref
对象仅有一个.value property
, 指向该内部值. 如果将对象分配为ref
值, 则它将被reactive
函数处理为深层的响应式对象.
我们在介绍reactive
的时候就说过, proxy
只能代理对象, 那么对于 String
、Number
这些类型改如何处理呢? vue
提供了ref api
可以用来处理这些值, 从介绍来看, 我们可以用.value
来获取对应的值, 从这个行为也能看出内部一定是一个对象, 所以就相当于vue
帮助我们包装了一个对象.
下面的问题就是如何让这个对象具有响应性. 不知道大家是否还记得我们之前在介绍effect
的时候提到了两个函数track
、trigger
, 同时, 我们还通过这两个函数让一个非响应式对象具有了响应性, 忘了的同学可以点击链接 (opens new window)回顾一下.
所以接下来的问题就简单了, 我们就借助这两个函数让我们封装的对象具有响应性.
const TrackOpTypes = {
GET: 'get',
HAS: 'has',
ITERATE: 'iterate'
}
const TriggerOpTypes = {
SET: 'set',
ADD: 'add',
DELETE: 'delete',
CLEAR: 'clear'
}
class Ref {
constructor(value) {
this._value = value;
this.__v_isRef = true;
}
get value() {
track(this, TrackOpTypes.GET, 'value');
return this._value;
}
set value(newValue) {
if(newValue != value) {
value = newValue;
trigger(this, TriggerOpTypes.SET, 'value');
}
}
}
function ref(value) {
return new Ref(value);
}
我们用之前实现过的 effect
来验证一下, 看看是否具有响应性:
const TrackOpTypes = {
GET: 'get',
HAS: 'has',
ITERATE: 'iterate'
}
const TriggerOpTypes = {
SET: 'set',
ADD: 'add',
DELETE: 'delete',
CLEAR: 'clear'
}
let activeEffect = null;
const effectMap = new Map();
function effect(f) {
activeEffect = f;
f();
activeEffect = null;
}
// _type 在我们这里是无用参数
function track(obj, _type, key) {
// 收集副作用, 以 obj 为对象
let depsMap = effectMap.get(obj);
if(!depsMap) {
effectMap.set(obj, depsMap = new Map());
}
// 副作用属于该对象的哪个属性
let dep = depsMap.get(key);
if(!dep) {
depsMap.set(key, dep = []);
}
// 如果当前有活跃的副作用, 将其收集起来
if(activeEffect) {
dep.push(activeEffect);
}
}
// _type 在我们这里是无用参数
function trigger(obj, _type, key) {
// 触发副作用
const depsMap = effectMap.get(obj);
if(!depsMap) {
return;
}
// 触发哪个 key 的副作用
const dep = depsMap.get(key);
if(!dep) {
return;
}
// 运行 key 里面的副作用
dep.forEach(effect => {
effect();
});
}
class Ref {
constructor(value) {
this.__v_isRef = true;
this._value = value;
}
get value() {
track(this, TrackOpTypes.GET, 'value');
return this._value;
}
set value(newValue) {
if(newValue != this._value) {
this._value = newValue;
trigger(this, TriggerOpTypes.SET, 'value');
}
}
}
function ref(value) {
return new Ref(value);
}
const refObj = ref(1);
effect(() => {
console.log(refObj.value);
// 1
})
refObj.value++;
// 2
当 effect
首次运行的时候会输出 1
, 当ref
的value
被改变的时候会再次触发副作用函数, 输出2
, 这样我们就让一个普通对象具有了响应性. 至于第二句话: 如果将对象分配为ref
值, 则它将被reactive
函数处理为深层的响应式对象.
那么我们只要判断一下这个值是否时对象, 再调用reactive
处理一下是不是就大功告成了.
# isRef
检查值是否为一个ref
对象
注意我们之前ref
的实现中, 里面有一个__v_isRef
, 这个值就代表了这是个ref
.
function isRef(ref) {
return !!ref.__v_isRef;
}
# unref
如果参数是一个ref
, 则返回内部值, 否则返回参数本身.这是 val = isRef(val) ? val.value : val
的语法糖函数.
这句话本身就说的非常清楚了, 这是为了方便我们使用value
值, 毕竟如果是ref
, 我们必须使用.value
才能拿到真正的值.
# toRefs
将响应式对象转换为普通对象, 其中结果对象的每个property
都是指向原始对象相应property
的ref
.
我们之前说过, proxy
如果被解构之后会失去响应性, 那么如果我们想在解构之后还能保持响应性就必须做一些额外的处理. 我们仔细想想, 失去响应性的主要原因是这个变量被赋值给了另一个普通变量, 如果我们每次拿值的时候都从原始的响应式对象上拿, 这样我们就能始终保持响应性了.
class ObjectRefImpl {
constructor(obj, key) {
this._obj = obj;
this._key = key;
}
get value() {
const val = this._obj[this._key];
return val;
}
set value(val) {
this._obj[this._key] = val;
}
}
function toRefs(obj) {
const res = {};
for(const key in obj) {
res[key] = new ObjectRefImpl(obj, key);
}
return res;
}
这样, 我们每次使用解构后的值时, 都会从原始proxy
对象上取值, 这样就会触发原始proxy
的get
、set
, 从而保持响应性. 同样, 我们来测试一下:
let activeEffect = null;
const effectMap = new Map();
function effect(f) {
activeEffect = f;
f();
activeEffect = null;
}
// _type 在我们这里是无用参数
function track(obj, _type, key) {
// 收集副作用, 以 obj 为对象
let depsMap = effectMap.get(obj);
if(!depsMap) {
effectMap.set(obj, depsMap = new Map());
}
// 副作用属于该对象的哪个属性
let dep = depsMap.get(key);
if(!dep) {
depsMap.set(key, dep = []);
}
// 如果当前有活跃的副作用, 将其收集起来
if(activeEffect) {
dep.push(activeEffect);
}
}
// _type 在我们这里是无用参数
function trigger(obj, _type, key) {
// 触发副作用
const depsMap = effectMap.get(obj);
if(!depsMap) {
return;
}
// 触发哪个 key 的副作用
const dep = depsMap.get(key);
if(!dep) {
return;
}
// 运行 key 里面的副作用
dep.forEach(effect => {
effect();
});
}
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, null, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver);
trigger(target, null, key);
return res;
}
})
}
class ObjectRefImpl {
constructor(obj, key) {
this._obj = obj;
this._key = key;
this.__v_isRef = true;
}
get value() {
const val = this._obj[this._key];
return val;
}
set value(val) {
this._obj[this._key] = val;
}
}
function toRefs(obj) {
const res = {};
for(const key in obj) {
res[key] = new ObjectRefImpl(obj, key);
}
return res;
}
const obj = reactive({name: 'klx', age: 10});
const { name, age } = toRefs(obj);
effect(() => {
console.log(name.value, age.value);
// klx 10
})
age.value++;
// klx 11
可以看到, 当我们修改age
的值时, 是能够触发副作用函数的, 大家也可以试试直接从obj
里面解构, 再看看能不能触发effect
的副作用.
# toRef
可以用来为源响应式对象上的某个property
新创建一个ref
. 然后, ref
可以被传递, 它会保持对其源property
的响应式连接.
可以看到, toRef
和toRefs
非常像, 只是toRef
是针对单个props
. 而还有一个显著区别是, 如果obj
上不存在某个props
, 那么toRefs
是不会进行处理的, 但是toRef
还是会返回一个可用的ref
. 因为我们之前是通过遍历obj
的key
来进行处理的, 如果某个可选props
没有, 那自然不会进行处理.
class ObjectRefImpl {
constructor(obj, key) {
this._obj = obj;
this._key = key;
this.__v_isRef = true;
}
get value() {
const val = this._obj[this._key];
return val;
}
set value(val) {
this._obj[this._key] = val;
}
}
function toRef(obj, key) {
return new ObjectRefImpl(obj, key);
}
实现上也非常简单, 和toRefs
基本上一样.
# customRef
创建一个自定义的ref
, 并对其依赖项跟踪和更新触发进行显式控制.它需要一个工厂函数, 该函数接收track
和trigger
函数作为参数, 并且应该返回一个带有get
和set
的对象.
customRef
主要是让开发者自己控制什么时候触发依赖跟踪和更新, 其实经过上面这么多的介绍, 我们不使用customRef
也能用它提供的track
和trigger
函数来实现一个自己的customRef
. 这个函数主要是把内部的函数通过传参的形式暴露给了开发者, 让大家少一点心智负担. 其实工厂函数接受的track
和trigger
就是我们之前一直在说的两个函数.
class CustomRef {
constructor(factory) {
const {get, set} = factory(
() => track(this, TrackOpTypes.GET, 'value'), // 传递给开发者的 track 函数
() => trigger(this, TriggerOpTypes.SET, 'value'), // 传递给开发者的 trigger 函数
)
this._get = get;
this._set = set;
}
get value() {
return this._get();
}
set value(val) {
this._set(val);
}
}
function customRef(factory) {
return new CustomRef(factory);
}
这样, 开发者在调用track
、trigger
的时候, 实际上还是通过OpTypes
+key
建立映射关系, 实现响应性. 但是现在就把调用的权利交给了开发者, 让他自己控制.
# shallowRef
创建一个跟踪自身.value
变化的ref
, 但不会使其值也变成响应式的.
其实我们上面实现的ref
本身就没有对value
再进行处理, vue
本身会将其处理为响应式对象, 那么, 如果我们不想讲其变成响应式对象, 只要加一个判断条件是不是就行了?
isObject && isShallow ? reactive(val) : val;
就类似这样, 是不是很简单.
# triggerRef
手动执行与shallowRef
关联的任何作用 (effect
).
到这里我们已经非常熟悉track
和trigger
函数了. 我们知道, 我们如果想触发副作用就必须触发trigger
. 但是如果我们使用了shallowRef
, 其下面的value
是不会具有响应性的. 所以自然也不会触发trigger
, 不会触发effect
.
所以我们就可以手动去触发trigger
, 让它触发副作用.
function triggerRef(ref) {
trigger(ref, TriggerOpTypes.SET, 'value');
}
const ref = shallowRef({ age: 1 });
effect(() => {
console.log(ref.value.age)
})
// 并不会触发响应式
ref.value.age++;
// 强制触发 effect 副作用
triggerRef(ref);
这样, 我们就能触发这个 Optypes
+key
所绑定的副作用.
# Computed 与 watch
# computed
接受一个getter
函数, 并根据getter
的返回值返回一个不可变的响应式ref
对象. 或者, 接受一个具有get
和set
函数的对象, 用来创建可写的ref
对象.
这个 computed
就是我们常说的计算属性, 他有一个非常重要的特点就是会缓存之前的结果. 只有当它所依赖的值发生改变的时候才会重新计算结果, 否则不会再执行之前的函数而是返回之前的结果. 同时它的计算是惰性的, 也就是说只有在你真正使用这个值的时候才会去计算. 如果你只声明但是没有使用, 这个getter
函数是永远不会执行的. 我们一步步来实现一下 computed
, 先不用考虑缓存、惰性求值之类的东西.
function computed(getter) {
const refObj = ref(null);
effect(() => {
refObj.value = getter();
});
return refObj;
}
其实只有这么简单几行代码我们就能实现一个超级简单的computed
. 在getter
运行的时候内部使用到的响应式变量都会触发track
, 这样就把effect
副作用收集了起来. 当后面这些值的setter
被触发之后, 就会重新触发副作用, 进而重新对refObj.value
进行赋值, 所以我们就能拿到最新的值了. 我们来简单试一试
const TrackOpTypes = {
GET: 'get',
HAS: 'has',
ITERATE: 'iterate'
}
const TriggerOpTypes = {
SET: 'set',
ADD: 'add',
DELETE: 'delete',
CLEAR: 'clear'
}
let activeEffect = null;
const effectMap = new Map();
function effect(f) {
activeEffect = f;
f();
activeEffect = null;
}
// _type 在我们这里是无用参数
function track(obj, _type, key) {
// 收集副作用, 以 obj 为对象
let depsMap = effectMap.get(obj);
if(!depsMap) {
effectMap.set(obj, depsMap = new Map());
}
// 副作用属于该对象的哪个属性
let dep = depsMap.get(key);
if(!dep) {
depsMap.set(key, dep = []);
}
// 如果当前有活跃的副作用, 将其收集起来
if(activeEffect) {
dep.push(activeEffect);
}
}
// _type 在我们这里是无用参数
function trigger(obj, _type, key) {
// 触发副作用
const depsMap = effectMap.get(obj);
if(!depsMap) {
return;
}
// 触发哪个 key 的副作用
const dep = depsMap.get(key);
if(!dep) {
return;
}
// 运行 key 里面的副作用
dep.forEach(effect => {
effect();
});
}
class Ref {
constructor(value) {
this.__v_isRef = true;
this._value = value;
}
get value() {
track(this, TrackOpTypes.GET, 'value');
return this._value;
}
set value(newValue) {
if(newValue != this._value) {
this._value = newValue;
trigger(this, TriggerOpTypes.SET, 'value');
}
}
}
function ref(value) {
return new Ref(value);
}
function computed(f) {
const refObj = ref(null);
effect(() => {
refObj.value = f();
});
return refObj;
}
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, null, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver);
trigger(target, null, key);
return res;
}
})
}
const obj = reactive({name: 'klx', age: 20});
const age = computed(() => {
return obj.age * 2;
});
console.log(age.value);
// 40
obj.age++;
console.log(age.value);
// 42
当我们改变obj.age
的值之后, age
的值也随之改变了, 符合预期. 那么接下来我们来看看如何避免重复计算以及惰性计算.
其实真正的effect
函数有一个lazy
的变量可以传递, 这样effect
就不会立即执行. 执行完effect
会返回一个run
函数, 调用run
函数的时候才会真正执行. 同时effect
还能接受一个scheduler
(调度器)的参数, effect
里面的变量被改变触发副作用时, 并不运行传给effect
的回调函数, 而是运行用户自定义的调度器, 也就是scheduler
函数. 所以我们可以把effect
进行一些改造:
function effect(f, options) {
const { lazy = false, scheduler} = options;
const run = () => {
try {
activeEffect = scheduler ? scheduler : f;
return f();
}finally {
activeEffect = null;
}
};
if(!lazy) {
run();
}
return run;
}
那么显然, computed
其实就是一个lazy
的effect
:
function computed(f) {
const run = effect(() => {
return f();
}, {
lazy: true,
});
const refObj = {
get value() {
return run();
}
};
return refObj;
}
这样, 只有在我们真的使用了refObj.value
的时候, 才会执行到get
函数, 从而触发effect
的执行. 大家可以自己修改一下代码尝试一下. 那么第二个问题就是如何避免多次重复执行. 可以看到, 现在的实现中, 无论我们的依赖有没有改变, run
函数每次必定都会执行, 那么什么时候该执行什么时候不该执行? 首先第一次肯定需要跑一遍run
函数我们才能拿到真正的结果. 但是在后面的过程中, 如果effect
依赖的那些值都没有变化的话, 那我们就没有必要再次运行run
函数了, 因为依赖的值不变得到的结果肯定还是一样的.
所以我们弄一个dirty
的变量来判断effect
所依赖的值是否发生了变化, 这个时候就用到了scheduler
函数. 那么dirty
什么情况下才应该是true
?
- 首次默认值为
true
computed
内部依赖的值发生了改变
而在依赖值改变的时候, 会触发effect
的副作用函数, 所以我们在这个时机把dirty
的值修改为true
就好了.
function computed(f) {
let dirty = true;
let res = null;
const run = effect(() => {
return f();
}, {
lazy: true,
scheduler: () => {
dirty = true;
}
});
const refObj = {
get value() {
if(dirty) {
res = run();
dirty = false;
}
return res;
}
};
return refObj;
}
我们把结果保存起来, 在dirty
为false
的时候不再运行run
函数, 这样就能实现值的缓存. 我们来完整跑一跑:
const TrackOpTypes = {
GET: 'get',
HAS: 'has',
ITERATE: 'iterate'
}
const TriggerOpTypes = {
SET: 'set',
ADD: 'add',
DELETE: 'delete',
CLEAR: 'clear'
}
let activeEffect = null;
const effectMap = new Map();
function effect(f, options) {
const { lazy = false, scheduler} = options;
const run = () => {
try {
activeEffect = scheduler ? scheduler : f;
return f();
}finally {
activeEffect = null;
}
};
if(!lazy) {
run();
}
return run;
}
// _type 在我们这里是无用参数
function track(obj, _type, key) {
// 收集副作用, 以 obj 为对象
let depsMap = effectMap.get(obj);
if(!depsMap) {
effectMap.set(obj, depsMap = new Map());
}
// 副作用属于该对象的哪个属性
let dep = depsMap.get(key);
if(!dep) {
depsMap.set(key, dep = []);
}
// 如果当前有活跃的副作用, 将其收集起来
if(activeEffect) {
dep.push(activeEffect);
}
}
// _type 在我们这里是无用参数
function trigger(obj, _type, key) {
// 触发副作用
const depsMap = effectMap.get(obj);
if(!depsMap) {
return;
}
// 触发哪个 key 的副作用
const dep = depsMap.get(key);
if(!dep) {
return;
}
// 运行 key 里面的副作用
dep.forEach(effect => {
effect();
});
}
function computed(f) {
let dirty = true;
let res = null;
const run = effect(() => {
return f();
}, {
lazy: true,
scheduler: () => {
dirty = true;
}
});
const refObj = {
get value() {
if(dirty) {
res = run();
dirty = false;
}
return res;
}
};
return refObj;
}
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, null, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver);
trigger(target, null, key);
return res;
}
})
}
const obj = reactive({name: 'klx', age: 20});
const age = computed(() => {
console.log("computed 执行了")
return obj.age * 2;
});
console.log(age.value);
// computed 执行了
// 40
console.log(age.value);
// 40
可以看到, 第二次使用age.value
的时候已经不会执行computed
的函数了. 其实还有一个问题就是, 我们现在这样写, 返回的对象是没有响应性的, computed
实际上会返回一个ref
, 是具有响应性的. 那么这个要怎么写了, 我已经不会写了, 大家帮我实现一下(是不是用track
、trigger
就行了?).
# watchEffect
立即执行传入的一个函数, 同时响应式追踪其依赖, 并在其依赖变更时重新运行该函数.
从描述来看这个和我们之前介绍的effect
函数非常像, 其实watchEffect
底层依赖的就是effect
, 但是它比effect
做了更多的事情, 例如:
- 会根据实际情况自动
stop
. 例如组件被卸载的时候 - 会通过队列去调用副作用, 避免多次重复调用, 例如多次触发同一个变量的
set
, 最终只会执行一次副作用. - 接受一个
flush
参数, 支持自定义副作用调用时机.
个人认为, 也正因为如此, 它并不是一个纯粹的响应式api, 所以它不随着@vue/reactivity
一起暴露出来, 而是和watch
函数一起放在了@vue/runtime-core
中. 我们主要看看它的多次调用触发一次副作用如何实现? 还记得之前说过effect
支持传入一个调度器的参数吗, 其实我们借助这个参数就能实现这个功能.
function watchEffect(f) {
const queue = [];
const run = effect(() => {
return f();
}, {
scheduler: () => {
if(!queue.includes(run)) {
queue.push(run);
Promise.resolve().then(() => {
while(queue.length) {
const f = queue.shift();
f();
}
})
}
}
})
}
我们维护一个队列, 将要执行的副作用函数放入其中, 每次执行之前先判断这个副作用函数是否在队列中, 再决定是否入栈. 最后利用js
的事件循环将真正的执行放到微任务队列中去执行. 而要想实现flush
参数, 其实也是在调度器里面做文章, 例如在flush
等于sync
的情况下就直接运行副作用函数, 从而让用户去控制副作用函数执行时机. 我们可以看看效果:
const TrackOpTypes = {
GET: 'get',
HAS: 'has',
ITERATE: 'iterate'
}
const TriggerOpTypes = {
SET: 'set',
ADD: 'add',
DELETE: 'delete',
CLEAR: 'clear'
}
let activeEffect = null;
const effectMap = new Map();
function effect(f, options) {
const { lazy = false, scheduler} = options;
const run = () => {
try {
activeEffect = scheduler ? scheduler : f;
return f();
}finally {
activeEffect = null;
}
};
if(!lazy) {
run();
}
return run;
}
// _type 在我们这里是无用参数
function track(obj, _type, key) {
// 收集副作用, 以 obj 为对象
let depsMap = effectMap.get(obj);
if(!depsMap) {
effectMap.set(obj, depsMap = new Map());
}
// 副作用属于该对象的哪个属性
let dep = depsMap.get(key);
if(!dep) {
depsMap.set(key, dep = []);
}
// 如果当前有活跃的副作用, 将其收集起来
if(activeEffect) {
dep.push(activeEffect);
}
}
// _type 在我们这里是无用参数
function trigger(obj, _type, key) {
// 触发副作用
const depsMap = effectMap.get(obj);
if(!depsMap) {
return;
}
// 触发哪个 key 的副作用
const dep = depsMap.get(key);
if(!dep) {
return;
}
// 运行 key 里面的副作用
dep.forEach(effect => {
effect();
});
}
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, null, key);
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const res = Reflect.set(target, key, value, receiver);
trigger(target, null, key);
return res;
}
})
}
function watchEffect(f) {
const queue = [];
const run = effect(() => {
return f();
}, {
scheduler: () => {
if(!queue.includes(run)) {
queue.push(run);
Promise.resolve().then(() => {
while(queue.length) {
const f = queue.shift();
f();
}
})
}
}
})
}
const obj = reactive({age: 20});
watchEffect(() => {
console.log(obj.age);
})
// 首次执行输出 20
obj.age++;
obj.age++;
obj.age++;
// 全部加完之后输出 23
这样就避免了无用的副作用运行. 所以我们在日常开发中使用watchEffect
就行了, effect
只是它底层依赖的api
而已(毕竟官网都没说明的api
).
# watchPostEffect
watchEffect
的别名, 带有 flush: 'post' 选项, 等效于:
watchEffect(() => {}, {
flush: 'post'
});
# watchSyncEffect
watchEffect
的别名, 带有 flush: 'sync' 选项, 等效于:
watchEffect(() => {}, {
flush: 'sync'
});
# watch
watch API
与选项式 API this.$watch
(以及相应的 watch
选项) 完全等效.watch
需要侦听特定的数据源, 并在单独的回调函数中执行副作用.默认情况下, 它也是惰性的——即回调仅在侦听源发生变化时被调用.
这个函数支持监听一个ref
对象, 监听reactive
对象, 也支持传入一个函数, 监听内部依赖的变量, 同时也支持传递数组. 这个方法实现的思路其实也很简单, 将传入的监听内容封装为一个 getter
函数, 将其放到effect
中去, 在副作用触发时通过调度器执行对应回调函数就行了. 例如这样:
function watch(reactiveObj, cb) {
const get = () => {
return reactiveObj
}
effect(() => {
return get();
}, {
scheduler: () => {
cb();
}
})
}
但是有一个地方需要注意, 这个函数有一个deep
参数, 是用来控制是否监听对象内部值的变化. 因为当你传入一个reactive
对象的时候, getter
函数并没有使用到reactive
内部的值, 自然不会track
副作用. 那么你再修改reactive
内部值的时候, 自然也不会被监听到.
const { reactive, watch } = Vue;
const obj = reactive({ name: 'klx', age: 10 });
watch(() => obj, () => {
console.log("obj 被修改", obj);
});
// 正确写法
// watch(() => obj, () => {
// console.log("obj 被修改", obj);
// }, {
// deep: true,
// });
obj.age++;
// 并不会触发 watch
我们可能注意到这里是使用函数去返回了一个reactive
, 如果你直接给watch
传一个reactive
会发现它是能够成功的监听到内部值的改变的, 这是因为当你单独传入reactive
时, vue
内部帮你把deep
置为true
了, 其他情况下默认值为false
.
// ...
} else if (isReactive(source)) {
getter = () => source
deep = true
} else if (isArray(source))
// ...
而当你传入deep: true
时, vue
会自动通过traverse
函数帮你遍历对象下的所有属性:
export function traverse(value: unknown, seen?: Set<unknown>) {
if (!isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
return value
}
seen = seen || new Set()
if (seen.has(value)) {
return value
}
seen.add(value)
if (isRef(value)) {
traverse(value.value, seen)
} else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
traverse(value[i], seen)
}
} else if (isSet(value) || isMap(value)) {
value.forEach((v: any) => {
traverse(v, seen)
})
} else if (isPlainObject(value)) {
for (const key in value) {
traverse((value as any)[key], seen)
}
}
return value
}
这样, 就会触发所有props
的get
, 从而track
到副作用, 实现深层监听.
# 结语
至此, 终于把官网上介绍的响应性 API 介绍完了, 其实还有一个 Effect 作用域 API
, 这个API
的源码其实也非常简单, 大家感兴趣的自己去看就好了.
这一系列文章只是为了让大家更加深入的了解vue
实现响应式背后的原理, vue
的所有响应式操作都依赖这些基础api
实现. 而文中所列出的代码也不是vue
的真正实现, 都是一个最基础的能跑的demo
, 非常多的情况是没有考虑进去的. 但是已经足够让大家对于它背后的原理有一定的了解. 如果想真正看看vue
到底是如何处理各种递归、调度、优化依赖收集的部分, 还是建议大家自己去阅读一下源码.
# 参考资料
- github源码 (opens new window)
- 官网文档 (opens new window)
- vue core team 成员 HcySunYang 博客 (opens new window)
← vue工程化