Skip to content

基于Proxy的响应系统原理:100行代码实现一个响应系统 #25

@xwcoder

Description

@xwcoder

Vue.js的响应系统

类似Vue.js的响应式框架都会通过某种手段实现响应系统,从而实现当数据变更时自动更新试图: data => ui。

Vue.js 2.x的响应系统通过Object.defineProperty实现,所以后加的属性没有响应能力。Vue.js 3.x的响应系统使用Proxy实现,并发布可独立使用的包@vue/reactivity

-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
TypeScript                       7            154            115           1225
Qt Linguist                      1              0              0             52
-------------------------------------------------------------------------------
SUM:                             8            154            115           1277
-------------------------------------------------------------------------------

Vue.js 3.x的响应系统模块在仓库的packages/reactivity,包含7个主要文件,大概1200行代码。

这篇内容主要实现一个类似的简易响应系统,会对理解基于Proxy的响应系统和阅读@vue/reactivty有些帮助。

响应系统的主要功能

响应系统的主要功能很简单:当响应对象target的属性A变更时,依赖A的函数fn被自动调用。

使用示例

// source
import { reactive, effect } from '@vue/reactivity';

const user = {
  name: '张三',
  age: 10,
};

const userProxy = reactive(user);

// fn依赖name,city三个属性
effect(() => console.log(`${userProxy.name}: 居住在 ${userProxy.city}`));

// 修改属性name
userProxy.name = '李四';
## output
## 因为fn也需要响应初始数据, 所以通过effect()注册时会主动调用一次fn。
张三: 居住在 undefined
李四: 居住在 undefined
// 修改属性age
userProxy.age = 11;
# 无输出,响应函数fn未被调用,因为fn不依赖age属性。

简述下示例代码:

  1. 使用reactive()创建user的代理对象userProxy
  2. 使用effect()注册响应函数fn,fn依赖属性name, city
  3. 修改userProxyname属性,fn被自动调用。

实现

触发函数

响应系统核心功能是:当响应对象target的属性A变更时,依赖A的函数fn被自动调用。那么很自然可以想到以如下结构存储响应对象、属性和被调函数fn:

// WeakMap<object, Map<string, Set<Function>>>
const targetFnMap = new WeakMap();
  • targetFnMap是WeakMap,key是响应对象target本身,value是另一个map: depsMapdepsMap存储属性名和被调函数。
  • depsMap存储属性名和被调函数,key是属性名,value是依赖此属性的函数集合。

那么触发回调的操作就是:

  • 根据target和变更的属性名key,从targetFnMap中获取回调函数集合fns
  • 依次执行函数集合fns中的回调函数。
// reactive.js

function trigger(target, key) {
  const depsMap = targetFnMap.get(target);
  if (!depsMap) {
    return;
  }

  const fns = depsMap.get(key);
  if (!fns) {
    return;
  }

  fns.forEach(fn => fn());
}

target的属性变更时使用trigger()执行回调。

监控变更

接下来实现监测target的属性变更。这里使用Proxy实现。为防止重复生成proxy,使用WeakMap存储targetproxy的对应关系:

// WeakMap<target, proxy>
const reactiveMap = new WeakMap();

属性变更操作有两种:1、设置属性; 2、使用delete删除属性。所以需要实现set()deleteProperty()捕获器。

// reactive.js

export function reactive(target) {
  const existProxy = reactiveMap.get(target);

  if (existProxy) {
    return existProxy;
  }

  const proxy = new Proxy(target, handlers);
  reactiveMap.set(target, proxy);

  return proxy;
}
const handlers = {
  set(target, key, value, receiver) {
    const res = Reflect.set(target, key, value, receiver);
    // target不是receiver本身时不触发回调。
       if (reactiveMap.get(target) === receiver) {
      trigger(target, key);
    } 
    return res;
  },

  deleteProperty(target, key) {
    const hasOwn = Object.prototype.hasOwnProperty.call(target, key);
    const res = Reflect.deleteProperty(target, key);
    
    // 删除自身属性时触发回调
    if (hasOwn && res) {
      trigger(target, key);
    }

    return res;
  }
};
  • set()捕获器:target是原型链上的对象时不触发回调。
  • deleteProperty()捕获器:只成功删除target自身属性时触发回调。

构建依赖

还差最后一部分:构建依赖关系,即targetFnMap

通常回调函数fn通过以下方式依赖target的属性:

  • 读取属性:targetProxy.name
  • 使用in操作符判断是否包含属性:if ('name' in targetProxy)
  • 遍历属性:使用Object.keys()Object.getOwnPropertyNames()等遍历属性。

所以可以在fn执行时通过get()has()ownKeys()捕获器构建依赖关系。

因为fn也需要响应初始数据,所以通过effect()注册时会主动调用一次fn(),此时是构建依赖的时机。这里存在的问题是捕获器执行时并不知道fn,所以通过一个变量activeFn记录当前被调用的fn

let activeFn;

export function effect(fn) {
  try {
    activeFn = fn;
    fn();
  } finally {
    activeFn = null;
  }
}

const handlers = {
  set(target, key, value, receiver) {
    // ...
  },

  deleteProperty(target, key) {
    // ...
  },

  has(target, key) {
    track(target, key);
    return Reflect.has(target, key);
  },

  get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver);
    track(target, key);
    return res;
  }
};
  • 使用effect()注册回调时将activeFn设置为当前fn,然后执行fn()
  • 如果fn中依赖了target的某些属性,则会执行相应的捕获器。
  • 在捕获器中记录依赖关系。

记录依赖关系的函数track()也比较简单:将依赖target key属性的函数fn记录到targetFnMap中。

function track(target, key) {
  if (!activeFn) {
    return;
  }

  let depsMap = targetFnMap.get(target);
  if (!depsMap) {
    depsMap = new Map();
    targetFnMap.set(target, depsMap);
  }

  let deps = depsMap.get(key);
  if (!deps) {
    deps = new Set();
    depsMap.set(key, deps);
  }

  if (!deps.has(activeFn)) {
    deps.add(activeFn);
  }
}

由于使用effect()注册时可能会有嵌套,如:

effect(() => {
  console.log(`${userProxy.name}: ${userProxy.age} 岁,居住在 ${userProxy.city}`);
  effect(() => {
    console.log(`at (${pointProxy.x}, ${pointProxy.y})`)
  });
  console.log(`you are, ${userProxy.age}`)
});

所以需要一个栈activeFnStack保存当前的fn,并通过压退栈操作保证activeFn的正确性,effect()修改如下:

// reactive.js

let activeFn;
let activeFnStack = [];

export function effect(fn) {
  try {
    activeFnStack.push(fn);
    activeFn = fn;
    fn();
  } finally {
    activeFnStack.pop()
    activeFn = activeFnStack[activeFnStack.length -1];
  }

  return effect;
}

最后实现ownKeys捕获器。遍历target属性的依赖比较特殊: fn依赖全部属性。可以使用一个特殊的key表示全部依赖的情况,为了避免名字冲突可以使用Symbol

// reactive.js

const ITERATE_KEY = Symbol('');

const handlers = {
  ownKeys(target) {
   track(target, ITERATE_KEY);
   return Reflect.ownKeys(target);
  }
};

trigger()也要执行ITERATE_KEY回调。

// reactive.js

function trigger(target, key) {
  const depsMap = targetFnMap.get(target);
  if (!depsMap) {
    return;
  }

  const fns = new Set();

  const normalFns = depsMap.get(key);
  const iterFns = depsMap.get(ITERATE_KEY);
  if (normalFns) {
    normalFns.forEach(fn => fns.add(fn));
  }

  if (iterFns) {
    iterFns.forEach(fn => fns.add(fn));
  }

  fns.forEach(fn => fn());
}

完整代码

使用示例:

import { reactive, effect, targetFnMap } from './reactive.js'

const user = {
  name: '张三',
  age: 10,
};

const userProxy = reactive(user);

effect(() => {
  const keys = Object.keys(userProxy);
  console.log(keys.map(key => (`${key} = ${userProxy[key]}`)).join(', '));
});

effect(() => console.log(`${userProxy.name}: ${ 'city' in userProxy ? '有常住地' : '无常住地'}`));

userProxy.name = '李四';
userProxy.age = 11;
userProxy.city = '北京';

output:

name = 张三, age = 10
张三: 无常住地
name = 李四, age = 10
李四: 无常住地
name = 李四, age = 11
李四: 有常住地
name = 李四, age = 11, city = 北京

深度响应

由于Proxy只代理直接属性,所以当前实现只是浅响应,对于如下示例是无效的,因为address不是响应对象。

// test.js
import { reactive, effect } from './reactive.js';
const user = {
  name: '张三',
  age: 10,
  address: {
    city: '北京',
  },
};

const userProxy = reactive(user);

effect(() => {
  console.log(userProxy.address.city);
});

userProxy.address.city = '上海';

回调函数执行console.log(userProxy.address.city)时会访问address属性,触发get()捕获器,所以可以在get()捕获器中判断属性address是否为object,如果是则将其变为响应对象,从而实现深度响应

const handlers = {
  get(target, key, receiver) {
    const res = Reflect.get(target, key, receiver);
    track(target, key);
    
    // 属性是object,将其变为响应对象 实现深度响应。
    if (res !== null && typeof res === 'object') {
      return reactive(res);
    }

    return res;
  }
};

@vue/reactivity实现了更丰富的功能,比如:

  • 实现了4种可响应对象:reactive, shallowReactive, readonly, shallowReadonly
  • Array以及集合Map, Set, WeakMap, WeakSet的支持。
  • 更健壮的边界和特殊值处理。
  • ...

本示例只是个简陋的demo,用于介绍基于Proxy的响应系统的基本原理。

-- EOF --

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions