Vue响应式系统的核心依然是对数据进行劫持,只不过Vue3采样点是Proxy类,而Vue2采用的是Object.defineProperty()。Vue3之所以采用Proxy类主要有两个原因:
首先响应式的思路无外乎这样一个模型:
if (dataOptions) {
if (!isFunction(dataOptions)) {
warn(...);
}
const data = dataOptions.call(publicThis, publicThis);
if (isPromise(data)) { warn(); }
if (!isObject(data)) {
warn(`data() should return an object.`);
} else {
instance.data = reactive(data);
{
for (const key in data) {
checkDuplicateProperties("Data" /* DATA */, key);
// expose data on ctx during dev
if (key[0] !== '$' && key[0] !== '_') {
Object.defineProperty(ctx, key, {
configurable: true,
enumerable: true,
get: () => data[key],
set: NOOP
});
}
}
}
}
}
1、存储 total 的计算方式让 price 或 quantities 更新时,total 再计算一次
我们想保存 let toatl = price * quantity 这句代码,所以我们需要一个仓库,然后把代码存储进去, 以便于在第一次运行完代码之后,我们还可以再次调用该代码。
let effect = function(){
total = price * quantity
}
我们需要调用一个追踪函数 track 去存储我们的代码,然后调用 effect 来计算首次的 total。在之后的某个时刻,再次调用触发函数 trigger 来运行所有存储了的代码。
在我们代码中提到的track()、effect()、trigger() 你都可以在 Vue 3 响应性源码中看到同名的函数
为了存储我们的 effects,我们将使用 dep 变量,它代表依赖关系,用来储存 effects
let dep = new Set()
为了跟踪依赖,我们将 effect 添加到 Set 中。使用 Set 是因为它不允许拥有重复值。当我们尝试添加同样的effect时,它不会变成两个。
function track(){
dep.add(effect)
}
然后我们需要使用触发函数 trigger 遍历我们存储了的每一个 effect,然后运行它们。
function trigger(){ dep.forEach(effect=>effect()) }
通常,我们的对象会有多个属性,每个属性都需要自己的dep(依赖关系),或者说 effect 的 Set(集)。
现在问题我们要如何存储这些dep,或者说让每个属性拥有(自己的)依赖。
接下来我们要封装价格和数量到一个产品对象中。
let product = {price: 5, quantity: 2}
每一个属性都需要有自己的 dep,而 dep 其实就是一个effect 集。这个 effect 集应该在值发生改变时重新运行。这个 dep 的类型是Set,Set 中的每个值都只是一个我们需要执行的 effect,就像我们的这个计算总数的匿名函数。为了方便在 effect 执行完我们以后再找到它们,我们需要把这些 dep 储存起来,我们要创建一个 depsMap。
depsMap 是一张储存了每个属性 dep 对象的图(ES6Map),图里有一组键和值。我们将使用我们对象的属性作为键,比如数量或价格。在这种情况下,我们的值就是一个dep(effects集合)。
const depsMap = new Map()
function track(key) {
let dep = depsMap.get(key) // 拿到特定的 dep,这里的 dep 是 价格/数量
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
dep.add(effect) // 添加 effect
}
function trigger(key) {
let dep = depsMap.get(key) // 获取键的 dep
if (dep) {
// 如果存在 dep 运行每个 effect
dep.forEach(effect => {
effect()
})
}
}
let product = {
price: 5,
quantity: 2
}
let total = 0
let effect = () => {
total = product.price * product.quantity
}
track('quantity')
effect()
其实就是 该对象属性 都映射一个 effect ,effect 是保存关于该对象属性的计算或者处理
上文【 track】 是保存该对象属性 和 effect 之前的映射,depsMap是该对象。
目前为止,我们有一张 depsMap,它存储了每个属性自己的依赖对象(属性到自己依赖对象的映射)。然后每个属性都拥有它们自己的并可以重新运行 effect 的 dep。
//目标图存储着每个响应式对象的依赖
const targetMap = new WeakMap()
function track(target,key){
//获取目标的 deps 图,在我们的例子中是 product
let depsMap = targetMap.get(target)
//如果它还不存在,我们将为这个对象创建一个新的deps图
if(!depsMap){
targetMap.set(target,(depsMap = new Map()))
}
//获得这个属性的依赖对象(quantity)
let dep = depsMap.get(key)
//如果它不存在,我们将创建一个新的 Set
if(!dep){
depsMap.set(key,(dep = new Set()))
}
//把 effect 添加到依赖中
dep.add(effect)
}
function trigger(target,key){
//检查此对象是否拥有依赖的属性
const depsMap = targetMap.get(target)
// 没有则直接返回
if(!depsMap){return}
//否则,我们将检查此属性是否具有依赖
let dep = depsMap.get(key)
//dep 存在,遍历dep,运行每一个 effect
if(dep){
dep.forEach(effect => {effect()})
}
}
let product = {price:5,quantity:2}
let total = 0
let effect = () =>{
total = product.price * product.quantity
}
//传递产品和数量
track(product,'quantity')
effect()
由上可知 ,我们仍要手动调用 track 来保存 effect;调用 trigger 来触发 effect。我们想让我们的响应性引擎自动调用 track 和 trigger。那么问题就在于,什么才是调用它们的最好时机呢?
从逻辑上来说,在运行 effect 时,如果访问了产品的属性,或者说是使用了GET,就是我们想调用 track 去保存 effect 的时候 。
如果产品的属性改变了,或者说使用了SET,就是我们想调用 trigger 来运行那些保存了的 effect 的时候
Vue 3 中,我们将使用 ES6 Reflect和 ES6Proxy(代理)
这是我们的产品,这里有三种方法打印出对象的属性
let product = {price: 5, quantity: 2}
首先,我们可以使用经典的“点”表示法
console.log('quantity is ' + product.quantity) //Dot notation
我们也可以使用中括号表示法
console.log('quantity is ' + product['quantity']) //Bracket notation
我们还可以使用 ES6 Reflect
console.log('quantity is ' + Reflect.get(product,'quantity'))
Reflect是ES6为了操作对象而新增的API, 为什么要添加Reflect对象呢?它这样设计的目的是为了什么?
1)将Object对象的一些明显属于语言内部的方法(比如Object.defineProperty),放到Reflect对象上,那么以后我们就可以从Reflect对象上可以拿到语言内部的方法。
2)在使用对象的 Object.defineProperty(obj, name,{})时,如果出现异常的话,会抛出一个错误,需要使用try catch去捕获,但是使用
Reflect.defineProperty(obj, name, desc) 则会返回false。
let proxiedProduct = new Proxy(product,{}) //proxiedProduct
你看到代理中的第二个参数了吗?它叫 handler(处理程序,是一个对象),在 handler 中,你可以传递一个 trap(诱捕器)。trap 可以让我们拦截基本操作,如属性查找,枚举或函数调用。
但是当外界每次对obj进行操作时,就会执行handler对象上的一些方法。handler中常用的对象方法如下:
深入理解 ES6中的 Reflect
let product = {price: 5, quantity: 2}
let proxiedProduct = new Proxy(product,{
get(target,key){//声明两个参数
console.log('Get was call with key = ' + key)
return target[key] //Bracket notation
}
})
console.log(proxiedProduct.quantity) //(1)
当我们调用console.log()( 1),它会通过get(target)调用我们的 proxiedProduct。在这种情况下,这个目标就是我们传递的 product,它成了 target 的值。我们的 key 是 quantity,因为我们想得到 quantity,我们的代码就会输出“Get was call with key = quantity”(get 被调用了,它的key = 数量)。它返回该属性的值。
现在我们把代码中的中括号表示法变成 Reflect
在 Proxy 里使用 Reflect,我们会有一个附加参数,称为 receiver(接收器),它将传递到我们的 Reflect调用中。它保证了当我们的对象有继承自其它对象的值或函数时 this 指针能正确的指向使用(的对象),这将避免一些我们在 vue2 中有的响应式警告,打印结果和之前一样
let product = {price: 5, quantity: 2} //product
let proxiedProduct = new Proxy(product,{//proxiedProduct
get(target,key,receiver){
console.log('Get was call with key = ' + key)
return Reflect.get(target,key,receiver) // <-现在我们把代码中的中括号表示法变成 Reflect
}
// 我们的代理还需要拦截 set 方法
set(target,key,value,receiver){
console.log('Set was called with key = '+ key + ' and value ' + value)
return Reflect.set(target,key,value,receiver)
}
})
console.log(proxiedProduct.quantity)
// Get was call with key = quantity
// 2
用 handler 包装我们的 get 和 set方法 到常量处理程序中,最后我们将创建一个新的 Proxy,传递我们的 target 和我们的 handler
function reactive(target){
const handler = {
get(target,key,receiver){
console.log('Get was call with key = ' + key)
return Reflect.get(target,key,receiver)
},
set(target,key,value,receiver){
console.log('Set was called with key = '+ key + ' and value ' + value)
return Reflect.set(target,key,value,receiver)
}
}
return new Proxy(target,handler)
}
现在,我们声明产品时,我们只需传递一个对象到响应式函数中
let product = reactive({price: 5, quantity:2 })
它(响应性函数)将返回一个代理对象,我们会把这个代理对象当作原始对象来用。然后我们将更改产品的数量并将它输出到控制台。
let product = reactive({price: 5, quantity:2 })
product.quantity = 4
console.log(product.quantity)
// Set was called with key = quantity and value 4
// Get was call with key = quantity
// 4
结合以上所有知识
function reactive(target){
const handler = {
get(target,key,receiver){
let result = Reflect.get(target,key,receiver)
track(target,key) // 时机 -》触发数据变化
return result
},
set(target,key,value,receiver){
let oldValue = target[key]
let result = Reflect.set(target,key,value,receiver)
if(oldValue != result){
trigger(target,key)
}
return result
},
}
return new Proxy(target,handler)
}
let product = reactive({price: 5, quantity: 2 })
现在我们已经不再需要手动调用 track() 和 trigger() 了 撒花❀❀❀❀❀
简单来说ref就是:原始数据=>响应式数据 的过程
Ref 接受一个值,并返回一个响应的,可变的 Ref 对象,Ref 对象只有一个“.value”属性,它指向内部的值。它就相当于从一个文件复制粘贴过来的值。
示例代码1:
let origin = 0; //原始数据为原始值
let count = ref(origin);
function add() {
count.value++;
}
示例代码2:
let origin = { val: 0 };//原始数据为对象
let count = ref(origin);
function add() {
count.value.val++;
}
经测试,我们发现,传递的原始数据orgin可以是原始值也可以是引用值,但是需要注意,如果传递的是原始值,指向原始数据的那个值保存在返回的响应式数据的.value中,如上count.value;如果传递的一个对象,返回的响应式数据的.value中对应有指向原始数据的属性,如上count.value.val
不管传递数据类型的数据给ref,无论是原始值还是引用值,返回的响应式数据对象本质都是由RefImpl类构造出来的对象。但不同的是里头的value,一个是原始值,一个是Proxy对象(原始数据为对象),最终Vue会根据传入的数据是不是对象isObject(val),如果是对象本质调用的是reactive,否则返回原始数据
unction ref(raw){
const r = {
get value(){
//调用跟踪函数,追踪我们正在创建的对象r,键是"value",然后返回原始值(传入值)
track(r,'value')
return raw
},
//setter 接收一个新值,把新值赋值给原始值(raw)
set value(newVal){
raw = convert(newVal)
// convert有判断 数据是object 还是原始类型
//调用触发函数
trigger(r,'value',raw )
},
}
//返回对象
return r
}
const convert = <T extends unknown>(val: T): T =>
isObject(val) ? reactive(val) : val
}
计算属性两个最大的特点就是
getter 方法会在读取 computed 值的时候执行,而在 getter 方法中有一个叫 _dirty 的变量,它的意思是代表脏数据的开关,默认初始化时 _dirty 被设为 true ,在 getter 方法中表示开关打开,需要计算一遍computed 的值,然后关闭开关,之后再获取 computed 的值时由于 _dirty 是 false 就不会重新计算。这就是 computed 缓存值的实现原理
//
server.on('request', (req, res) => {
setTimeout(() => {
if (/\d.json/.test(req.url)) {
const data = {
content: '我是内容,来自' + req.url
}
res.end(JSON.stringify(data));
}
}, Math.random() * 2000);
});
属性内部会创建一个effect对象,只不过这个effect不是立即执行,而是等到取值的时候再执行,从之前computed的用法中,可以看到,computed()函数返回一个对象,并且这个对象中有一个value属性,可以进行get和set操作。
import {isFunction} from './shared/index';
import { effect, track, trigger } from './effect';
export function computed(getterOrOptions) {
let getter;
let setter;
if (isFunction(getterOrOptions)) {
getter = getterOrOptions;
setter = () => {};
} else {
getter = getterOrOptions.get;
setter = getterOrOptions.set;
}
let dirty = true; // 默认是脏的数据
let computed;
// 计算属性本质也是一个effect,其回调函数就是计算属性的getter
let runner = effect(getter, {
lazy: true, // 默认是非立即执行,等到取值的时候再执行
computed: true, // 标识这个effect是计算属性的effect
scheduler: () => {
// 在触发更新时 只是把dirty置为true
// 而不去立刻计算值 所以计算属性有lazy的特性
dirty = true
trigger(computed, "set", "value"); // 数据变化后,触发value依赖
}
});
let value;
computed = {
get value() {
if (dirty) {
value = runner(); // 等到取值的时候再执行计算属性内部创建的effect
dirty = false; // 取完值后数据就不是脏的了
track(computed, "get", "value"); // 对计算属性对象收集value属性
}
return value;
},
set value(newVal) {
setter(newVal);
}
}
return computed;
}
首先要知道,effect函数会立即开始执行,再执行之前,先把effect自身变成全局的activeEffect,以供响应式数据收集依赖。
并且activeEffect的记录是用栈的方式,随着函数的开始执行入栈,随着函数的执行结束出栈,这样就可以维护嵌套的effect关系。
export function trigger(target, type, key, value) {
const depsMap = targetMap.get(target); // 获取当前target对应的Map
if (!depsMap) { // 如果该对象没有收集依赖
console.log("该对象还未收集依赖"); // 比如修改值的时候,没有调用过effect
return;
}
const effects = new Set(); // 存储依赖的effect
const add = (effectsToAdd) => {
if (effectsToAdd) {
effectsToAdd.forEach(effect => {
effects.add(effect);
});
}
};
// const run = (effect) => {
// effect(); // 立即执行effect
// }
// 修改run方法,如果是计算属性的effect则执行其scheduler方法
+ const run = (effect) => {
+ if (effect.options.scheduler) { // 如果是计算属性的effect则执行其scheduler()方法
+ effect.options.scheduler(effect);
+ } else { // 如果是普通的effect则立即执行effect方法
+ effect();
+ }
+ }
/**
* 对于effect中使用到的数据,那肯定是响应式对象中已经存在的key,当数据变化后肯定能通过该key拿到对应的依赖,
* 对于新增的key,我们也不需要通知effect执行。
* 但是对于数组而言,如果给数组新增了一项,我们是需要通知的,如果我们仍然以key的方式去获取依赖那肯定是无法获取到的,
* 因为也是属于新增的一个索引,之前没有对其收集依赖,但是我们使用数组的时候会使用JSON.stringify(arr),此时会取length属性,
* 索引会收集length的依赖,数组新增元素后,其length会发生变化,我们可以通过length属性去获取依赖
*/
if (key !== null) {
add(depsMap.get(key)); // 对象新增一个属性,由于没有依赖故不会执行
}
if (type === "add") { // 处理数组元素的新增
add(depsMap.get(Array.isArray(target)? "length": ""));
}
// 遍历effects并执行
effects.forEach(run);
}
// 1. 响应式数据
const data = reactive({ count: 0 })
// 2. 计算属性
const plusOne = computed(() => data.count + 1)
// 3. 依赖收集
effect(() => console.log(plusOne.value))
// 4. 触发上面的effect重新执行
data.count ++
就这个例子来说,data是一个响应式数据。
effect传入的函数因为内部访问到它上面的属性count了,
所以形成了一个count -> effect的依赖 (看上面可知data 是响应的数据就会有 track / trigger 改变 count 触发 trigger 进而执行 effect )。
下次count改变了,这个effect就会重新执行,就这么简单。
先起几个别名便于讲解
// 计算effect
computed(() => data.count + 1)
// 日志effect
effect(() => console.log(plusOne.value))
从依赖关系来看,
计算effect的dirty置为true,标志着下次读取需要重新求值。
日志effect读取计算effect的value,获得最新的值并打印出来。
function watch(source, cb, options) {
if (!isFunction(cb)) { warn(...); }
return doWatch(source, cb, options);
}
// doWatch函数
if (isRef(source)) {
getter = () => source.value;
forceTrigger = !!source._shallow;
} else if (isReactive(isReactive)) {
getter = () => source;
deep = true;
} else if (isArray(source)) {
isMultiSource = true;
forceTrigger = source.some(isReactive);
getter = () => source.map(s => { ... });
} else if (isFunction(source)) {
getter = () => { ... };
} else {
getter = NOOP;
warnInvalidSource(source);
}
watch API支持不同方式的源定义,实际上就是不同类型定义相关的getter函数,其中对于监听源是被reactive处理的对象,需要调用traverse函数来处理
通过对属性获取就会执行其定义的get操作的函数,其中会触发track函数完成上面的目的。对于复杂结构的完全代理对象,这是一个需要关注的性能点。
watch API支持的选项有:
不同的flush值会设置不同的调度器逻辑
watchEffect是如何收集依赖呢?执行种通过effect函数创建的一个函数即activeEffect,该函数中会真正执行所谓的副作用逻辑。当执行runner函数时,就会执行watchEffect的source函数,这个过程中所有响应属性就会与当前activeEffect(即这里的runner)建立关联,这样就完成了所谓的依赖收集。当收集的属性被重新赋值,依据建立的联系就会触发副作用effect被执行,这就是watchEffect的功能了。
参考
Vue2响应系统中对象的每一个属性在get拦截中都会生成一个Dep对象,而Dep对象会与Watcher对象进行关联,通过发布订阅模式来实现整个响应系统;Vue3实际上原理类型,不同在于因为使用Proxy,实际上建立关联的是访问的属性主体与effect,effect可以将其看成Vue2中的Watcher对象,其创建的时机和功能都与Vue2中的Watcher高度相似
Vue3中effect只有通过effect函数来创建,Vue3将该函数也暴露出来了,实际上effect概念上来表示副作用(引申自函数式编程中纯函数等概念)本质上就是一个函数,这个函数内部会调用真正副作用逻辑的函数。effect函数存在很多属性,参入的相关逻辑比较复杂。而且effect函数调用只在三个地方computed相关、watch相关、mountComponent,与Vue2中Watcher实例创建的时机非常相似
Vue3中通过track来实现依赖收集即建立调用属性与effect联系,trigger来触发视图更新即遍历执行effect队列,执行effect自带的调度器scheduler或者执行自身(mount挂载阶段创建的一个effect实际上其调度器就是queueJob函数,该函数就是将job推入队列指定位置并遍历所有队列,所谓的job就是处理effect)
Vue3中深度代理以及浅层代理的改变都是判断相关返回值是否再次代理来实现的,而这个过程中Proxy代理相关的缓存机制会优化这个过程
Vue3支持对Map、Set等对象的代理,实际上都是只拦截get操作来实现的,在其自定义处理中会针对不同的实例方法调用分别处理,相关原理实际上涉及到ES规范中提及的内部方法和内部插糟等相关说明
参考