前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >vue 随记(4):响应式的进化

vue 随记(4):响应式的进化

作者头像
一粒小麦
发布2020-07-23 15:29:08
6480
发布2020-07-23 15:29:08
举报
文章被收录于专栏:一Li小麦一Li小麦

响应式的进化

本项目涉及代码:https://github.com/dangjingtao/vue3_reactivity_simple 推荐阅读:observer-util: https://github.com/nx-js/observer-util

1. defineProperty的缺点和vue 2的hack 方案

1.1 新属性设置不上

vue 2 的响应式已经很强大了。但是对于对象上新增的属性则有些吃力:

代码语言:javascript
复制
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

1.2 数组监听不上

此外对于数组也无法监听原地改动:

代码语言:javascript
复制
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 的做法是干脆把数组的原型方法都劫持了,从而达到监听数组的目的:

代码语言:javascript
复制

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);
        },
    })

2. Proxy——新时代的响应式

Proxy来说,Object.defineProperty 是ie 8 支持的方法。对比几年前,兼容性的矛盾似乎不再那么突出(vue 3 最低支持ie 11)。所以在新一代的vue演进中,响应式机制的改革被提到了一个非常重要的位置。

在前面的文章中,我们了解过defineProperty和Proxy的用法。

我们写defineProperty的时候,总是会用一层对象循环来遍历对象的属性,一个个调整其中变化:

代码语言:javascript
复制
Object.keys(data).forEach(key => {
  Object.defineProperty(data, key, {
    get() {
      return data[key];
    },

    set(nick) {
      // 监听点
      data[key] = nick;
    }
  })
})

而Proxy监听一个对象是这样的:

代码语言:javascript
复制
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,已经到来了。

3. vue3 中的响应式

可在此处克隆最新的仓库代码:https://github.com/vuejs/vue-next.git,下载下来之后运行dev命令打包:

代码语言:javascript
复制
 npm run dev

即可阅读源码。

在vue 3中负责响应式部分的仓库名为 @vue/rectivity,它不涉及 Vue 的其他的任何部分,甚至可以轻松的集成进 React[注]。是非常非常“正交” 的实现方式。

[注] https://juejin.im/post/5e70970af265da576429aada

对于vue3 的effect API,如果你了解过 React 中的 useEffect,相信你会对这个概念秒懂,Vue3 的 effect 不过就是去掉了手动声明依赖的「进化版」的 useEffect。

在vue3 中,实现数据观察是这样的:

代码语言:javascript
复制
// 定义响应式数据
const data = reactive({ 
  count: 1
});

// 观测变化,类似react中的useEffect
const effection = effect(() => console.log('count changed', data.count));

data.count = 2; // 'count changed 2'

如果想要监听单条数据,可以用ref:

代码语言:javascript
复制
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

接下来我们就尝试简单实现之。

3.1 ref:监听单个变量

3.1.1 简化实现

先来实现ref(对单个数据的监听)。

新建一个Proxy.js:

代码语言:javascript
复制
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每次被设置,都会打印出变更提示。

3.1.2 源码解读

在源码中找到packages/reactivity/src/refs.ts,可以看到Ref方法是由createRef完成的。

代码语言:javascript
复制
// 源码 `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 源码中,拦截各种取值、赋值操作,依托 tracktrigger 两个函数进行依赖收集和派发更新。分别对应简化实现中的Dep.dependDep.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

3.2 reactive:监听对象

现在我们想模仿vue 3 的Composition API,实现一个这样的功能:

代码语言:javascript
复制
<!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的角色,接下来就实现上面代码的用法。

3.2.1 监听(响应式核心)

我们用一个cerateBaseHandler来处理生成proxy的handler,然后通过track和trigger分辨收集依赖和派发更新。

代码语言:javascript
复制
// 依赖收集
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;
}

那么响应式核心就写好了。

3.2.2 track和trigger

我们把所有的依赖放到一个类似栈的数组结构中。

在做之前,应该设想下“依赖”这个对象(不妨命名为targetMap)的数据结构:首先它可能接收多个reactive的代理对象(命名为target),而每个taget都对应各自的依赖(depMap)——提示使用weakMap数据结构。这个depMap是一个Map对象。可通过属性名拿到该属性名具体要收集的依赖集合dep(这是个Set对象)。当我们拿到effect之后,把它添加到dep中。依赖收集就完成了。

不妨试想收集依赖就是一个找自己位置的游戏,首先根据target在大对象中找到该数据的专属依赖。然后根据属性key,再找到这个数据,这个属性的一系列依赖,最后把副作用添加进去。

代码语言:javascript
复制
targetMap: {
    { name: 'djtao',age:18 }: depMap   
}  

depMap:{
    'name':dep
}

dep:Set()

在收集依赖的过程中,一律遵循“找不到就创建”的原则。

代码语言:javascript
复制
// 它用来建立 监听对象 -> 依赖 的映射
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(),当数据变化时,拿出依赖,遍历执行。

代码语言:javascript
复制
const trigger = (target, key, info) => {
    // 简化来说 就是通过 key 找到所有更新函数 依次执行
    const depMap = targetMap.get(target);
      const deps = depMap.get(key)
    deps.forEach(effect => effect());
}

实际上trigger需要进一步细化

3.2.3 computed和effect

接下来就是实现computed和effect副作用。

怎么理解二者的关系?简化来看,computed就是一个特殊的effect。因为它与原对象同时监听同一个或n个属性。需要一个option去配置它当它被标记为computed时,内容为true。

所以在trigger中,还需要更加健壮些:

代码语言:javascript
复制
// 依赖派发
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属性的时候,即可执行计算。

代码语言:javascript
复制
// 计算属性:特殊的effect,多了一个配置
const computed = (fn) => {
  const runner = effect(fn,{computed:true,lazy:true});

  return {
    effect:runner,
    get value(){
      return runner();
    }
  }
}

再看effect:需要一个createReactiveEffect方法处理一下:

代码语言:javascript
复制
// 副作用
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源码的响应式系统完成。

3.3 源码导读

代码在packages/src/reactivity/reactive.ts

代码语言:javascript
复制
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的关键部分:

代码语言:javascript
复制
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的逻辑如下:

代码语言:javascript
复制
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存在。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-07-17,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 一Li小麦 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 响应式的进化
    • 1. defineProperty的缺点和vue 2的hack 方案
      • 1.1 新属性设置不上
      • 1.2 数组监听不上
    • 2. Proxy——新时代的响应式
      • 3. vue3 中的响应式
        • 3.1 ref:监听单个变量
        • 3.2 reactive:监听对象
        • 3.3 源码导读
    领券
    问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档