~/static/img/default-avatar-0.jpeg

yinanchen

前端开发工程师

博客

教你设计一个vue3 响应系统(一)

yinanchen 2022-06-29

本文意在介绍vue3响应式的设计思路,没有过多对源码进行解读,主要从另一个角度来认识vue3的响应式的设计与实现,对于没有看过vue3源码的同学可以初步认识vue3的响应式原理,有利于对源码观看和解读起到更好的理解作用,对于已经观看过源码的同学,能让你对vue3有更深层次的一个理解

  1. 1. 副作用函数

正式介绍响应式设计之前,我们需要先了解什么是副作用函数。文本字义上看,副作用函数就是能产生副作用的函数,举个例子

fuction effect() {
    document.body.innerText = 'xiaohei'
}

effect函数的执行,会设置body的文本内容,但有可能有fn1,fn2等其他函数也会读取或者设置到body的文本内容。也就是,effect的执行直接或者间接影响其他函数的执行,这是就说effect产生了副作用

与响应式的联系

我们知道在vue中我么写在template里面的变量都是响应式的,当我们修改了这个变量,页面中的数据也就是template里面的数据也会跟着变,其实通过刚刚介绍的副作用就可以知道,数据改变我们要驱使template里面的数据也改变,这个过程就有点类似我们刚刚的effect函数,也就是我们修改了一个值,这个值的副作用函数要跟着执行

  1. 2. 响应式数据的基本实现

有过一定vue基础的人都知道,vue2是通过Object.defineProperty去实现,vue3则是使用了proxy;通过上文的讲述之后你可以猜想到其实就是利用了这2个api中的拦截读取和设置的属性

  • 当effect函数执行会触发读取操作
  • 当修改变量的时候,会触发设置操作 既然通过proxy可以拦截一个对象的读取和设置操作,那我们就可以在对于动作执行对于操作:

image.png

当然这里还有考虑一点,effect副作用函数不止一个的情况呢?有可能我们改一个变量值,会触发很多副作用的执行。这个时候我们就需要一个桶bucket,用这个桶在读取操作的时候来存放所有的副作用,当触发设置操作的时候,从桶中依次取出副作用执行即可,流程变化=>

image.png

根据上面的思路,我们还需要考虑一点细节问题,如何保证代码灵活性?这个时候我们的effect就需要变换一下,我们需要为effect提供一个注册副作用函数的函数,用来保证不管effect是什么函数,哪怕是一个匿名函数,都能被桶收集到,代码实现如下:

// 用一个全局变量来存放被注册的副作用函数
let activeEffect: Function | undefined = undefined;

function effect(fn: Function) {
  // fn就是我们上面讲到的effect副作用
  activeEffect = fn;
  // 执行副作用
  fn();
}

// 存放effect的桶
const bucket: Set<Function> = new Set();

const data = { text: "xiaohei" };

const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key) {
    if (activeEffect) {
      bucket.add(activeEffect);
    }
    return target[key];
  },
  // 拦截设置操作
  set(target, key, newVal) {
    target[key] = newVal;
    bucket.forEach((fn) => fn());
    return true;
  },
});
  1. 3. 一个完善的响应式系统

  2. 3.1 初步设计

从上面的代码执行来看,我们确实可以初步简单的实现修改data中text的数据,让对于effect执行,但是细心的人会发现,如果我们设置data的其他属性,比如data.xiaohei,其实也会触发所有effect执行

为什么会出现这种情况呢?其实不难发现,我们的桶是一个数组去存放副作用的,而对于data数据来说,不管你设置什么值,都对被proxy代理,也就是我们没有把副作用和data具体的key绑定上关系。所以我们需要重新设计一下这个桶bucket的数据结构

很多人来想到用map数据结构,即:key - set结构来设计桶;只能说对了一半,因为你只考虑了对单一个数据data的结构封装,而我们这一封装是针对于多个数据可使用的,所以应当采用map-map数据结构

这里桶的设计,vue3用了es6的weekmap来实现,为什么使用weakmap呢?原因是weakmap对key是弱引用,不影响垃圾回收器工作,一旦key被垃圾回收器回收,对应的值也就访问不到了

所以真正的桶的数据结构设计应该为:

  • weakMap由target => map构成
  • map由key => set构成

image.png

基于上面的分析,我们再做一成封装,将读取收集依赖的过程封装成track函数,将触发副作用函数封装到trigger里面,代码封装如下:

// 用一个全局变量来存放被注册的副作用函数
let activeEffect: Function | undefined = undefined;

function effect(fn: Function) {
  // fn就是我们上面讲到的effect副作用
  activeEffect = fn;
  // 执行副作用
  fn();
}

// 存放effect的桶
const bucket: WeakMap<Object, Map<string, Set<Function>>> = new WeakMap();

const data = { text: "xiaohei" };

const obj = new Proxy(data, {
  // 拦截读取操作
  get(target, key: string) {
    // 将副作用函数添加到桶
    track(target, key)
    
    return target[key];
  },
  // 拦截设置操作
  set(target, key: string, newVal) {
    target[key] = newVal;
    // 触发副作用
    trigger(target, key)

    return true;
  },
});

function track(target: Object, key: string) {
    if (!activeEffect) return 
    let depsMap = bucket.get(target)
    if(!depsMap) {
        bucket.set(target,(depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if(!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    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())
}
  1. 3.2 响应式系统的特殊情况处理

    1. 3.2.1 三元运算符的影响

    有这么一种情况,我们在副作用中定义了一个三元运算符,分别读取某个对象的2个属性

    比如这样 obj.ok ? obj.text : 'last' ,可以先思考下会发生什么情况?

    根据上面的响应式系统,好像没什么区别,不管你读取那个属性,都会依赖收集,触发。原因正是如此,我们可以假设一下,如果我们把obj.ok 设置为false,那么这个三元就会一直读取到后面的last ,那么如果我此时读取obj.text 那是不是对应的这个副作用依赖不应该被收集呢?

    事实上根据我们上面的代码是会的,我可以解释一下执行的思路

    • 一开始obj.ok为true,text会被读取,所以当副作用执行的时候,会触发ok和text的读取,收集ok和text依赖
    • 我们设置obj.ok = false ,这会触发ok的trigger函数更新视图,执行副作用函数
    • 我们改变text,也会触发trigger函数更新视图

这时我们可以看到,中间貌似少了个步骤,没错就是对依赖的删除,所谓有增有减

那么怎么删除对应依赖呢?很简单,我们在track的时候不是已经收集了依赖了吗?我们现在需要的就是在副作用执行的时候先清楚依赖即可,这样我们就需要重新设计我们的effect函数了

let activeEffect: Function | undefined = undefined;

function effect(fn: Function) {
  // fn就是我们上面讲到的effect副作用
  const effectFn = () => {
      cleanup(effectFn) // 删除操作,只要遍历deps去删除即可,不贴代码了
      activeEffect = effectFn // 等于本身,以便可执行
      fn()
  }
  
  effectFn.deps = [] // 存放相关副作用的依赖
  effectFn()
  
}


function track(target: Object, key: string) {
    if (!activeEffect) return 
    let depsMap = bucket.get(target)
    if(!depsMap) {
        bucket.set(target,(depsMap = new Map()))
    }
    let deps = depsMap.get(key)
    if(!deps) {
        depsMap.set(key, (deps = new Set()))
    }
    deps.add(activeEffect)
    
    activeEffect.deps.push(deps) // 存放到上面effectFn的依赖容器中
}

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)
    
    const effectsToRun = new Set(effects) // 开辟一个新变量避免在遍历中删除或新增元素导致无限循环
    effectsToRun.forEach(item => item()) // 执行副作用重新读取,但是副作用的执行中又有删除
    // effects && effects.forEach(fn => fn())
}
  1. 3.2.2 深层嵌套effect

effect是可以深层嵌套的 !但是我们上面的逻辑并没有避免这种情况,原因就在activeEffect 中,因为这只是一个变量存放当前的活跃effect,但effect里面有effect,这个变量没法存储另一个深层的effect,所以导致代码错误

解决方法其实有点了解数据结构的人员就可以猜到,我们可以搞个栈来存放运行的effect,将当前运行的effect压入栈底,等待执行完毕再推出,保证activeEffect 始终指向当前栈的最顶上指针即可解决

  1. 3.2.3 魔性 obj.text++

有的时候副作用中可能会是这句魔性的自增语句,其实这个语句等同于obj.text = obj.text + 1 ,这个时候就有可能引起无线递归循环了,读取和触发一直循环

解决也很容易,在trigger函数中添加守卫判断,判断当前副作用是否正在执行即可

function trigger(target, key) {
    const depsMap = bucket.get(target)
    if(!depsMap) return
    const effects = depsMap.get(key)
    
    const effectsToRun = new Set() 
    effects && effects.forEach(effectFn => {
      if(activeEffect !== effectFn) {
        effectsToRun.add(effectFn)
      }
    })
    effectsToRun.forEach(item => item()) 
}
作者相关知识精选
  • chrome 106版本之后,支持对某个容器的媒体查询:

    .a {
       container-type: inline-size;
    }
    
    .b {
       color: yellow;
    }
    
    @container (min-width: 400px) {
      .b {
         color: blue;
      }
    }
    
    
    查看更多
  • 除了通过eval函数还有另一种方式可以将字符串当初js代码运行 new Function('data','return data')(data) ` 签名第一个是传参,后面的字符串是代码

    查看更多
  • 一个忽略的点

    svg文件不能以文件路径的形式放置在 img标签的src里面,这样是不会显示出来的

    因为svg的真正链接路径应该是svg里面的 xlink:href的路径,通常是以data:..., base64....开头的地址

    查看更多