Vue3响应式系统原理:trace和trigger函数

本文在Vue3响应式原理的简单实现的基础上,进一步探讨如何得到Vue3源码中的最基本的trace和trigger函数。

背景

为了说清楚Vue3的响应式系统原理,首先得搭建一个最简单的Vue3响应式原理模型,其中必须得有如下几个基本的元素:

  1. 原始对象
  2. 存放副作用函数的集合容器
  3. 响应式对象
  4. 副作用函数

简单实现

根据上一节提到的几个基本要素,我们有如下简单实现。

js 复制代码
// 原始对象
const data = { text: "hello world" };

// 存放副作用函数的集合容器。之所以是用Set数据结构,是为了防止相同的副作用函数重复收集
const bucket = new Set();

// 响应式对象。响应式对象为原始对象的Proxy代理
const obj = new Proxy(data, {
  get(target, key) {
    // 将2个副作用函数添加到容器中
    bucket.add(effect1);
    bucket.add(effect2);
    return target[key];
  },
  set(target, key, val) {
    target[key] = val;
    // 将容器中的副作用函数逐一执行
    bucket.forEach((fn) => fn());
    return true;
  },
});

// 定义第一个副作用函数
function effect1() {
  console.log("effect 1", obj.text);
}

// 定义第二个副作用函数
function effect2() {
  console.log("effect 2", obj.text);
}

代码的关键在于响应式对象obj的get和set的处理:

  1. 在对象属性的读操作中(get),将两个响应式函数effect1和effect2存放到容器bucket中;
  2. 在对象属性的写操作中(set),将容器bucket中所有的响应式函数逐一执行。

我们再添加一些初始化逻辑,我们就能运行上述demo了。

  1. 首先初始化运行所有的副作用函数,触发响应式对象的get方法;
  2. 其次模拟2s之后响应式对象属性值的更新,观察否会自动触发副作用函数的运行。
js 复制代码
// 省略之前的代码

// 初始化依次执行副作用函数,触发 get
effect1();
effect2();

// 模拟2s后修改数据
setTimeout(() => {
  obj.text = 'HELLO WORLD'
}, 2000)

// 初始化输出如下:
// effect 1 hello world
// effect 2 hello world
// 2s后输出如下:
// effect 1 HELLO WORLD
// effect 2 HELLO WORLD

可以看到2s之后,响应式对象属性赋值,能够自动触发相关的副作用函数执行,实现了响应式的基本效果。

移除硬编码

上面的代码中,写了两个副作用函数:effect1和effect2。但实际上的副作用函数个数是不确定的。因此有必要优化下上面的代码,避免出现obj的get方法中的硬编码。

  1. 首先将effect改造为一个用来执行副作用函数的函数(即函数运行的容器),具体的副作用函数作为一个参数传给effect。effect内部会具体执行传入的副作用函数。
js 复制代码
// fn为具体的副作用函数
function effect(fn) {
  fn()
}
  1. 其次,用一个全局变量activeEffect用来表示当前正在执行的副作用函数。
js 复制代码
let activeEffect = null
function effect(fn) {
  activeEffect = fn
  fn()
}
  1. 最后,当执行到具体副作用函数的时候,因为一定会进入到obj的get函数,因此可以在obj的get方法中,通过判断有无activeEffect来收集当前正在执行的副作用函数。
js 复制代码
// 省略其他代码
const obj = new Proxy(data, {
  get(target, key) {
    if(activeEffect) {
      bucket.set(activeEffect)
    }
    return target[key]
  },
  // 省略其他代码
})

// 省略其他代码

完整的代码如下:

js 复制代码
// 原始对象
const data = { text: "hello world" };

// 存放副作用函数的集合容器。之所以是用Set数据结构,是为了防止相同的副作用函数重复收集
const bucket = new Set();

// 表示当前正在运行的副作用函数
let activeEffect = null;

// 用于执行副作用函数的函数
function effect(fn) {
  activeEffect = fn
  fn() // 执行副作用函数
}

// 响应式对象。响应式对象为原始对象的Proxy代理
const obj = new Proxy(data, {
  get(target, key) {
    if(activeEffect) {
      bucket.add(activeEffect)
    }
    return target[key];
  },
  set(target, key, val) {
    target[key] = val;
    // 将容器中的副作用函数逐一执行
    bucket.forEach((fn) => fn());
    return true;
  },
});

////////////////////////////////////
// 定义第一个副作用函数
function effect1() {
  console.log("effect 1", obj.text);
}

// 定义第二个副作用函数
function effect2() {
  console.log("effect 2", obj.text);
}

// 初始化依次执行副作用函数,触发 get
effect(effect1);
effect(effect2);

// 模拟2s后修改数据
setTimeout(() => {
  obj.text = 'HELLO WORLD'
}, 2000)

通过上面的改造,我们引入了一个全局变量activeEffect,从而移除了之前obj的get方法中对于副作用函数的硬编码,降低了耦合性。

副作用函数的隔离

上一节的代码中,原始对象data只有一个属性text。如果现在原始对象data有两个属性text1和text2,同时也有两个副作用函数,情况是否有不同呢?

js 复制代码
// 原始对象,包含两个属性
const data = { 
    text1: "hello world",
    text2: "你好世界",
};

// 其他代码

// 副作用函数1
function effectText1 {
    console.log("effect text 1", obj.text1)
}

// 副作用函数2
function effectText2 {
    console.log("effect text 2", obj.text2)
}

我们希望的是,当修改obj.text1,触发effectText1的执行,不触发effectText2;当修改obj.text2,触发effectText2的执行,不触发effecctText1。即对应属性的修改,只会触发对应包含该属性的副作用函数,而不会相互干扰。完整代码如下:

js 复制代码
// 原始对象,包含两个属性
const data = {
  text1: "hello world 1",
  text2: "hello world 2",
};

// 存放副作用函数的集合容器。之所以是用Set数据结构,是为了防止相同的副作用函数重复收集
const bucket = new Set();

// 表示当前正在运行的副作用函数
let activeEffect = null;

// 用于执行副作用函数的函数
function effect(fn) {
  activeEffect = fn;
  fn(); // 执行副作用函数
}

// 响应式对象。响应式对象为原始对象的Proxy代理
const obj = new Proxy(data, {
  get(target, key) {
    if (activeEffect) {
      bucket.add(activeEffect);
    }
    return target[key];
  },
  set(target, key, val) {
    target[key] = val;
    // 将容器中的副作用函数逐一执行
    bucket.forEach((fn) => fn());
    return true;
  },
});

////////////////////////////////////
// 定义第一个副作用函数
function effectText1() {
  console.log("effect text 1", obj.text1);
}

// 定义第二个副作用函数
function effectText2() {
  console.log("effect text 2", obj.text2);
}

// 初始化依次执行副作用函数,触发 get
effect(effectText1);
effect(effectText2);

// 模拟2s后修改数据
setTimeout(() => {
  obj.text2 = "HELLO WORLD 2";
}, 2000);


// 初始化输出如下:
// effect text 1 hello world 1
// effect text 2 hello world 2
// 2s后输出如下:
// effect text 1 hello world 1
// effect text 2 HELLO WORLD 2

可以看到,2s之后修改了text2属性,不但触发了effectText2的执行,也触发了effectText1的执行。原因在于我们每次触发属性的更新,都会将bucket的所有副作用函数都会执行一遍,且在执行所有的副作用函数的过程中,没有区分哪些副作用函数该执行,哪些不该执行。因此我们需要改造下容纳副作用函数的容器bucket。 改造的思路如下:

将bucket用Map对象实现,其中Map的key表示对象的属性,Map的value为一个Set对象,Set用于存储对应key相关联的副作用函数。

根据上面的思路,我们可以跟进一步思考。上面只考虑了一个响应式对象的情况,实际情况中可能存在多个响应式对象存在,因此可以进一步升级上面的思路。

将bucket用WeakMap表示,WeakMap中的每个key表示一个响应式对象,WeakMap的每个value用一个Map表示。其中Map的每个key表示该对象的每个属性,Map的每个value用Set表示,用于存储属性相关联的响应式函数。

js 复制代码
// 原始对象,包含两个属性
const data = {
  text1: "hello world 1",
  text2: "hello world 2",
};

// 存放副作用函数的集合容器
const bucket = new WeakMap();

// 表示当前正在运行的副作用函数
let activeEffect = null;

// 用于执行副作用函数的函数
function effect(fn) {
  activeEffect = fn;
  fn(); // 执行副作用函数
}

// 响应式对象。响应式对象为原始对象的Proxy代理
const obj = new Proxy(data, {
  get(target, key) {
    if(!activeEffect) return target[key]

    let depsMap = bucket.get(target)
    if(!depsMap) {
      depsMap = new Map()
      bucket.set(target, depsMap)
    }
    let deps = depsMap.get(key)
    if(!deps) {
      deps = new Set()
      depsMap.set(key, deps)
    }
    deps.add(activeEffect)

    return target[key];
  },
  set(target, key, val) {
    target[key] = val;
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)
    effects && effects.forEach(fn => fn())
    return true;
  },
});

////////////////////////////////////
// 定义第一个副作用函数
function effectText1() {
  console.log("effect text 1", obj.text1);
}

function effectText2() {
  console.log("effect text 2", obj.text2)
}

// 初始化依次执行副作用函数,触发 get
effect(effectText1);
effect(effectText2);

// 模拟2s后修改数据
setTimeout(() => {
  obj.text2 = "HELLO WORLD 2";
}, 2000);


// 初始化输出如下:
// effect text 1 hello world 1
// effect text 2 hello world 2
// 2s后输出如下:
// effect text 2 HELLO WORLD 2

提取trace和trigger函数

我们将上一节中get和set方法的逻辑提取出来,就变成了triace和trigger函数。

js 复制代码
// 省略其他代码
let activeEffect = null
const bucket = new WeakMap()

function track(target, key){
  if(!activeEffect) return
  let depsMap = bucket.get(target)
  if(!depsMap) {
    depsMap = new Map()
    bucket.set(target, depsMap)
  }
  let deps = depsMap.get(key)
  if(!deps) {
    deps = new Set()
    depsMap.set(key, deps)
  }
  deps.add(activeEffect)
}

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if(!depsMap) return
  const effects = depsMap.get(key)
  effects && effects.forEach(fn => fn())
}

其中trace表示副作用函数的收集,trigger表示副作用函数的触发执行。同理,响应式对象的定义可以简化为如下形式:

js 复制代码
// 响应式对象。响应式对象为原始对象的Proxy代理
const obj = new Proxy(data, {
  get(target, key) {
    if(!activeEffect) return target[key]
    track(target, key);
    return target[key];
  },
  set(target, key, val) {
    target[key] = val;
    trigger(target, key);
    return true;
  },
});

结论

本文我们通过直观的方式实现了一个最简单的响应式系统。然后通过逐步优化和抽象,提取出了trace和trigger函数。

代码

github.com/wdskuki/js-...

相关推荐
M_emory_16 分钟前
解决 git clone 出现:Failed to connect to 127.0.0.1 port 1080: Connection refused 错误
前端·vue.js·git
Ciito19 分钟前
vue项目使用eslint+prettier管理项目格式化
前端·javascript·vue.js
成都被卷死的程序员1 小时前
响应式网页设计--html
前端·html
mon_star°1 小时前
将答题成绩排行榜数据通过前端生成excel的方式实现导出下载功能
前端·excel
Zrf21913184551 小时前
前端笔试中oj算法题的解法模版
前端·readline·oj算法
文军的烹饪实验室2 小时前
ValueError: Circular reference detected
开发语言·前端·javascript
Martin -Tang3 小时前
vite和webpack的区别
前端·webpack·node.js·vite
迷途小码农零零发3 小时前
解锁微前端的优秀库
前端
王解4 小时前
webpack loader全解析,从入门到精通(10)
前端·webpack·node.js
我不当帕鲁谁当帕鲁4 小时前
arcgis for js实现FeatureLayer图层弹窗展示所有field字段
前端·javascript·arcgis