本项目涉及代码:https://github.com/dangjingtao/vue3_reactivity_simple 推荐阅读:observer-util: https://github.com/nx-js/observer-util
defineProperty
的缺点和vue 2的hack 方案vue 2 的响应式已经很强大了。但是对于对象上新增的属性则有些吃力:
let vm = new Vue({
data() {
a: 1
},
watch: {
b() {
console.log('change !!')
}
}
})
// 没反应!
正常来说,被监听的数据在初始化时就已经被全部监听了。后续并不会再次这种时候,不得不通过vm.$set
(全局 Vue.set 的别名。)来处理新增的属性。
语法:this.$set( target, key, value )
。
target:要更改的数据源(可以是对象或者数组);key:要设置的数据名;value :赋值。
文档地址: https://cn.vuejs.org/v2/api/#Vue-set
此外对于数组也无法监听原地改动:
let obj = {}
Object.defineProperty(obj, 'a', {
configurable: true,
enumerable: true,
get: () => {
console.log('get value by defineProperty')
return val
},
set: (newVal) => {
console.log('set value by defineProperty')
val = newVal
}
})
obj.a = [] // set value by defineProperty
obj.a.push('1') // get value by defineProperty
obj.a[0] = 1 // get value by defineProperty
obj.a.pop(1) // get value by defineProperty
obj.a = [1, 2, 3] // set value by defineProperty
上述案例中,使用push、pop、直接通过索引为数组添加元素时会触发属性a的getter,是因为与这些操作的返回值有关,以push方法为例,使用push方法为数组添加元素时,push方法返回值是添加之后数组的新长度,当访问数组新长度时就会自然而然触发属性a的getter。
vue2 的做法是干脆把数组的原型方法都劫持了,从而达到监听数组的目的:
var arrayProto = Array.prototype
var arrayMethods = Object.create(arrayProto)
;[
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
].forEach(function(item){
Object.defineProperty(arrayMethods,item,{
value:function mutator(){
//缓存原生方法,之后调用
console.log('array被访问');
var original = arrayProto[item]
var args = Array.from(arguments)
original.apply(this,args)
// console.log(this);
},
})
较Proxy
来说,Object.defineProperty
是ie 8 支持的方法。对比几年前,兼容性的矛盾似乎不再那么突出(vue 3 最低支持ie 11)。所以在新一代的vue演进中,响应式机制的改革被提到了一个非常重要的位置。
在前面的文章中,我们了解过defineProperty和Proxy的用法。
我们写defineProperty的时候,总是会用一层对象循环来遍历对象的属性,一个个调整其中变化:
Object.keys(data).forEach(key => {
Object.defineProperty(data, key, {
get() {
return data[key];
},
set(nick) {
// 监听点
data[key] = nick;
}
})
})
而Proxy监听一个对象是这样的:
new Proxy(data, {
get(key) { },
set(key, value) { },
});
可以看到Proxy的语法非常简洁,根本不需要关心具体的 key,它去拦截的是 「修改 data 上的任意 key」 和 「读取 data 上的任意 key」。所以,不管是已有的 key 还是新增的 key,都逃不过它的魔爪。
Proxy 更加强大的地方还在于 Proxy 除了 get 和 set,还可以拦截更多的操作符。Proxy有多达13种拦截方法,不限于apply、ownKeys、deleteProperty、has等等是Object.defineProperty不具备的。
Proxy作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利。
所以说,前端响应式数据的新世代——Proxy,已经到来了。
可在此处克隆最新的仓库代码:https://github.com/vuejs/vue-next.git,下载下来之后运行dev命令打包:
npm run dev
即可阅读源码。
在vue 3中负责响应式部分的仓库名为 @vue/rectivity,它不涉及 Vue 的其他的任何部分,甚至可以轻松的集成进 React[注]。是非常非常“正交” 的实现方式。
[注] https://juejin.im/post/5e70970af265da576429aada
对于vue3 的effect API,如果你了解过 React 中的 useEffect,相信你会对这个概念秒懂,Vue3 的 effect 不过就是去掉了手动声明依赖的「进化版」的 useEffect。
在vue3 中,实现数据观察是这样的:
// 定义响应式数据
const data = reactive({
count: 1
});
// 观测变化,类似react中的useEffect
const effection = effect(() => console.log('count changed', data.count));
data.count = 2; // 'count changed 2'
如果想要监听单条数据,可以用ref
:
llet count = ref(0);
effect(()=>{
console.log(`count被变更为${count}`)
});
count.value += 1;
React 中手动声明 [data.count] 这个依赖的步骤被 Vue3 内部直接做掉了,在 effect 函数内部读取到 data.count 的时候,它就已经被收集作为依赖了。少了useState,setData,看起来比react更方便了。
因此实现响应式最重要的api是:
•reactive•effect•ref
接下来我们就尝试简单实现之。
先来实现ref(对单个数据的监听)。
新建一个Proxy.js:
let activeEffect;
class Dep{
constructor(){
this.subs = new Set();
}
depend(){
// 收集依赖
if(activeEffect){
this.subs.add(activeEffect);
}
}
notify(){
// 数据变化,通知effect执行。
this.subs.forEach(effect=>effect())
}
}
const ref = (initVal)=>{
const dep = new Dep();
let state = {
get value(){
// 收集依赖
dep.depend();
return initVal;
},
set value(newVal){
// 修改,通知dep执行有此依赖的effect
dep.notify();
return newVal;
}
}
return state;
}
let state = ref(0);
const effect = (fn)=>{
activeEffect = fn;
fn();
}
effect(()=>{
console.log(`state被变更为`,state.value)
});
state.value = 1;
在上面的代码中,state.value每次被设置,都会打印出变更提示。
在源码中找到packages/reactivity/src/refs.ts
,可以看到Ref方法是由createRef完成的。
// 源码 `packages/reactivity/src/refs.ts`
export function ref(value?: unknown) {
return createRef(value)
}
// ...
// 创建ref
function createRef(rawValue: unknown, shallow = false) {
if (isRef(rawValue)) {
return rawValue
}
let value = shallow ? rawValue : convert(rawValue)
const r = {
__v_isRef: true,
get value() {
track(r, TrackOpTypes.GET, 'value')
return value
},
set value(newVal) {
if (hasChanged(toRaw(newVal), rawValue)) {
rawValue = newVal
value = shallow ? newVal : convert(newVal)
trigger(
r,
TriggerOpTypes.SET,
'value',
__DEV__ ? { newValue: newVal } : void 0
)
}
}
}
return r
}
vue3 源码中,拦截各种取值、赋值操作,依托 track
和 trigger
两个函数进行依赖收集和派发更新。分别对应简化实现中的Dep.depend
和Dep.notify
。
•track 用来在读取时收集依赖。•trigger 用来在更新时触发依赖。
在vue3中,Dep不再是一个class类。而是一个非常大的Map对象。
在Proxy 第二个参数 handler 也就是陷阱操作符[注]中,拦截各种取值、赋值操作,依托 track 和 trigger 两个函数进行依赖收集和派发更新。
•track 用来在读取时收集依赖。•trigger 用来在更新时触发依赖。
[注] 陷阱操作符: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler
现在我们想模仿vue 3 的Composition API,实现一个这样的功能:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app"></div>
<button id="btn">click</button>
<script src="reactivity.js"></script>
<script>
const root = document.querySelector('#app');
const btn = document.querySelector('#btn');
let obj = reactive({
name: 'djtao',
age: 18
});
let double = computed(() => obj.age * 2);
effect(() => {
console.log(`数据变更为`, obj);
root.innerHTML = `<h3>Name(监听属性):${obj.name}</h3>
<p>Age(监听属性):${obj.age}</p><p>double(计算属性):${double.value}</p>`;
});
btn.addEventListener('click', e => {
obj.name = 'dangjingtao';
obj.age += 1;
})
</script>
</body>
</html>
要求点击按钮更新监听属性及计算属性。
结合tracker和trigger的角色,接下来就实现上面代码的用法。
我们用一个cerateBaseHandler来处理生成proxy的handler,然后通过track和trigger分辨收集依赖和派发更新。
// 依赖收集
const track = () => {
}
// 依赖派发
const trigger = () => {
}
// 计算属性
const computed = () => {
}
// 副作用
const effect = fn =>{
fn();
}
const createBaseHandler = (taget) => {
return {
get(target,key){
track(target,key);
return target[key]; // 此处源码用Reflect
},
set(target,key,newValue){
const info = {
oldValue:data[key],
newValue
};
data[key] = newValue;
trigger(target,key,info);
}
}
}
// 对象响应式
const reactive = (data) => {
const observer = new Proxy(data,createBaseHandler(data));
return observer;
}
那么响应式核心就写好了。
我们把所有的依赖放到一个类似栈的数组结构中。
在做之前,应该设想下“依赖”这个对象(不妨命名为targetMap
)的数据结构:首先它可能接收多个reactive的代理对象(命名为target
),而每个taget都对应各自的依赖(depMap
)——提示使用weakMap数据结构。这个depMap
是一个Map对象。可通过属性名拿到该属性名具体要收集的依赖集合dep(这是个Set对象)。当我们拿到effect之后,把它添加到dep中。依赖收集就完成了。
不妨试想收集依赖就是一个找自己位置的游戏,首先根据target在大对象中找到该数据的专属依赖。然后根据属性key,再找到这个数据,这个属性的一系列依赖,最后把副作用添加进去。
targetMap: {
{ name: 'djtao',age:18 }: depMap
}
depMap:{
'name':dep
}
dep:Set()
在收集依赖的过程中,一律遵循“找不到就创建”的原则。
// 它用来建立 监听对象 -> 依赖 的映射
let targetMap = new WeakMap();
const effectStack = [];
// 依赖收集
const track = (target,key) => {
// reactive可能有多个,一个可能又有多个key
const effect = effectStack[effectStack.length - 1];
if(effect){
// 尝试找到属于该数据的依赖对象(depMap)
let depMap = targetMap.get(target);
// 如果不存在,则把它定义为一个Map,并添加到targetMap中
if(!depMap){
depMap = new Map();
targetMap.set(target,depMap);
}
// 找到这个对象的这个属性,获取对这个对象,这个属性对依赖。
let dep = depMap.get(key);
// 如果不存在,则初始化一个set。
if(!dep){
dep = new Set();
depMap.set(key,dep);
}
// 核心逻辑:现在拿到dep了,把副作用添加进去。
dep.add(effect);
// 此处在deps也绑定上dep
effect.deps.push(dep);
}
}
trigger就好理解多了,对应的是简化实现一节代码的Dep.notify()
,当数据变化时,拿出依赖,遍历执行。
const trigger = (target, key, info) => {
// 简化来说 就是通过 key 找到所有更新函数 依次执行
const depMap = targetMap.get(target);
const deps = depMap.get(key)
deps.forEach(effect => effect());
}
实际上trigger需要进一步细化
接下来就是实现computed和effect副作用。
怎么理解二者的关系?简化来看,computed就是一个特殊的effect。因为它与原对象同时监听同一个或n个属性。需要一个option去配置它当它被标记为computed时,内容为true。
所以在trigger中,还需要更加健壮些:
// 依赖派发
const trigger = (target,key,info) => {
// 查找对象依赖
const depMap =targetMap.get(target);
// 如果没找到副作用即可结束
if(!depMap){
return
}
// effects 和 computedRunners 用于遍历执行副作用和computed的依赖
const effects = new Set();
const computedRunners = new Set();
if(key){
let deps = depMap.get(key);
deps.forEach(effect => {
// 计算flag为true时,添加到computedRunners
if(effect().computed){
computedRunners.add(effect);
}else{
effects.add(effect);
}
});
computedRunners.forEach(computed=>computed());
effects.forEach(effect=>effect());
}
}
这样trigger就相对完整了。
因为是一个特殊的effect,读取computed的value属性的时候,即可执行计算。
// 计算属性:特殊的effect,多了一个配置
const computed = (fn) => {
const runner = effect(fn,{computed:true,lazy:true});
return {
effect:runner,
get value(){
return runner();
}
}
}
再看effect:需要一个createReactiveEffect
方法处理一下:
// 副作用
const effect = (fn,options={}) =>{
let e = createReactiveEffect(fn,options);
// 惰性:首次不执行,后续更新才执行
if(!options.lazy){
e();
}
return e;
}
//
const createReactiveEffect = (fn,options={})=>{
const effect = (...args) =>{
return run(effect,fn,args);
}
// 单纯为了后续清理,以及缓存
effect.deps = [];
effect.computed = options.computed;
effect.lazy = options.lazy;
return effect;
}
// 辅助方法,执行前入栈,最后执行完之后出栈
// 以此保证最上面一个effect是最新的
const run =(effect,fn,args)=>{
if(effectStack.indexOf(effect)===-1){
try {
effectStack.push(effect);
return fn(...args);
} finally {
effectStack.pop();
}
}
}
run方法维护effectStack,并负责执行具体方法。
好了,需求就已经做完了。看看效果:
点击前:
点击后:
历次打印结果:
可以看到,响应式系统中,首先监听到初始值,点击按钮,先监听了name的变化,然后是age的变化。
自此,参照vue3源码的响应式系统完成。
代码在packages/src/reactivity/reactive.ts
export function reactive(target: object) {
// if trying to observe a readonly proxy, return the readonly version.
if (target && (target as Target)[ReactiveFlags.IS_READONLY]) {
return target
}
return createReactiveObject(
target,
false,
mutableHandlers,
mutableCollectionHandlers
)
}
createReactiveObject
的关键部分:
function createReactiveObject(
target: Target,
isReadonly: boolean,
baseHandlers: ProxyHandler<any>,
collectionHandlers: ProxyHandler<any>
) {
// ...
const observed = new Proxy(
target,
collectionTypes.has(target.constructor) ? collectionHandlers : baseHandlers
)
// ...
return observed
}
collectionHandlers对应的是Set,Map等数据类型,baseHandler对应普通对象。
baseHandler中也调用了trigger。
trigger和track的逻辑如下:
export function track(target: object, type: TrackOpTypes, key: unknown) {
// ...
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
if (!dep.has(activeEffect)) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
// ...
}
}
export function trigger(
target: object,
type: TriggerOpTypes,
key?: unknown,
newValue?: unknown,
oldValue?: unknown,
oldTarget?: Map<unknown, unknown> | Set<unknown>
) {
const depsMap = targetMap.get(target)
if (!depsMap) {
// never been tracked
return
}
const effects = new Set<ReactiveEffect>()
const add = (effectsToAdd: Set<ReactiveEffect> | undefined) => {
if (effectsToAdd) {
effectsToAdd.forEach(effect => {
if (effect !== activeEffect || !shouldTrack) {
effects.add(effect)
} else {
// the effect mutated its own dependency during its execution.
// this can be caused by operations like foo.value++
// do not trigger or we end in an infinite loop
}
})
}
}
if (type === TriggerOpTypes.CLEAR) {
// collection being cleared
// trigger all effects for target
depsMap.forEach(add)
} else if (key === 'length' && isArray(target)) {
depsMap.forEach((dep, key) => {
if (key === 'length' || key >= (newValue as number)) {
add(dep)
}
})
} else {
// schedule runs for SET | ADD | DELETE
//. ...
}
const run = (effect: ReactiveEffect) => {
if (__DEV__ && effect.options.onTrigger) {
effect.options.onTrigger({
effect,
target,
key,
type,
newValue,
oldValue,
oldTarget
})
}
if (effect.options.scheduler) {
effect.options.scheduler(effect)
} else {
effect()
}
}
effects.forEach(run)
}
在computed中,也把它作为一个特殊的effect存在。