Vue设计与实现读后感-响应式系统实现-场景增强computed与watch(三)- 2

267 阅读15分钟

headerImg.png

这是项目的github地址:GoVue从0开始;这是我的b站直播间每天都会直播写代码:前端自习室,期待关注!!!

开发方式

我之前业务代码index.ts只是为了方便我在浏览器调试,并不能成为我代码健壮性的一部分。

备注

在源码中computed与watch,只有computed属于响应式的核心代码,而wacth是在runtime-core这部分代码里面。

单元测试

承接上文,随着场景的扩展,代码的修改,我已经不能保证我所写的代码对之前的业务是否产生影响,如果每次都跑一下之前的测试页面显然是不现实的。需要通过自动化手段保证代码后续修改的质量。我的场景还是比较简单的就是一个ts的单元测试场景,其实也不用太费劲毕竟尤大已经帮我们写好了单元测试了,最基本的方式就是把他的单元测试拷贝过来,其实大家只要熟悉一定单元测试的使用方式就可以了,可以查看jest官方文档

个人认为比较好的开发流程如下

功能需求->>代码优化-->>自动化测试

测试框架选择jest,下载相关依赖

  "devDependencies": {
    "jest": "^27.5.1",
    "@types/jest": "^27.4.1",
    "ts-jest": "^27.1.3",
    "typescript": "^4.6.2"
  }

设置jest配置在根目录下面新建jest.config.js

/*
 * @Description: jest.config.js 配置项
 * @Author: 吴文周
 * @Github: https://github.com/fodelf
 * @Date: 2022-03-16 11:37:12
 * @LastEditors: 吴文周
 * @LastEditTime: 2022-03-16 16:06:59
 */
module.exports = {
  testEnvironment: "jsdom",
  preset: "ts-jest",
  watchPathIgnorePatterns: ["/node_modules/", "/dist/", "/.git/"],
  moduleFileExtensions: ["ts", "tsx", "js", "json"],
  rootDir: __dirname,
  testMatch: ["<rootDir>/packages/**/__tests__/**/*spec.[jt]s?(x)"],
};

新建测试文件,复用了尤雨溪的单元测试,自己也添加删除了一些来匹配现在的api。

// effect.spec.ts 文件
describe("reactivity/effect", () => {
  it("should observe basic properties", () => {
    let dummy;
    const counter = reactive({ num: 0 });
    effect(() => (dummy = counter.num));

    expect(dummy).toBe(0);
    counter.num = 7;
    expect(dummy).toBe(7);
  });

  it("should observe multiple properties", () => {
    let dummy;
    const counter = reactive({ num1: 0, num2: 0 });
    effect(() => (dummy = counter.num1 + counter.num1 + counter.num2));

    expect(dummy).toBe(0);
    counter.num1 = counter.num2 = 7;
    expect(dummy).toBe(21);
  });

  it("should handle multiple effects", () => {
    let dummy1, dummy2;
    const counter = reactive({ num: 0 });
    effect(() => (dummy1 = counter.num));
    effect(() => (dummy2 = counter.num));

    expect(dummy1).toBe(0);
    expect(dummy2).toBe(0);
    counter.num++;
    expect(dummy1).toBe(1);
    expect(dummy2).toBe(1);
  });

  it("should handle ++", () => {
    let dummy1;
    const counter = reactive({ num: 0 });
    effect(() => {
      counter.num++;
      dummy1 = counter.num;
    });

    expect(dummy1).toBe(1);
    counter.num = 10;
    expect(dummy1).toBe(11);
  });
 }
)

单元测试建立完成,新建test脚本命令。

继续回归代码本身

调度执行

备注:源码里面响应式的代码库中并没有控制多次赋值的情况,这样实现有些硬写,有任务调度的设计,真正任务的调度的具体实现是在核心库有详细的实践,可以理解为下面是调度的实现,但是是无效的代码

调度执行可以是场景优化一种方式。正常的场景下面我们可以监听数据的变化,执行副作用函数,真正的业务场景上面可能需要我们做一些执行优化例如多次赋值的场景。

// 原始数据
const obj = {
  count: 0,
};
// // 将原数据转换为代理数据使它具有响应式的特性
let objProxy = reactive(obj);
// // obj 触发count 副作用的收集依赖
effect(
  () => {
    document.body.innerHTML = objProxy.count;
  },
  { scheduler: true }
);
setTimeout(() => {
  // 连续赋值
  objProxy.count = 7;
  objProxy.count = 10;
  objProxy.count = 20;
  objProxy.count = 30;
  objProxy.count = 40;
  objProxy.count = 50;
  objProxy.count = 60;
  objProxy.count = 70;
}, 500);

其实在真正执行过程中,我们不希望都执行以下代码,document.body.innerHTML = 0, document.body.innerHTML = 7 ,document.body.innerHTML = 10...... 最后执行document.body.innerHTML = 70。这个显然是不符合预期的,我们想要优化的执行其实是只执行第一个和最后一个。

这个过程我们可以理解为合并丢弃的过程,这个在数据请求处理场景上面,其实有些异曲同工之妙,比如我向后端发送10个相同的请求,其实正在有意义的是最后一个请求。大家可以想一想如何实现?

当然这个场景相对较简单一点,因为不是异步的场景,因为不需要等待。实现的原理可以使用微任务的特性,这个是前端面试知识点,这是微任务和宏任务的博客,大家如果可以翻墙的话,有个视频介绍的也不错 youtobe地址,前端面试八股文了解了解。

技术一定要基于场景这个观点永远不会变。学了这个知识,在现实的开发中得以利用,如果大家开发一个任务调度相关的话,这个知识是有很大帮助的。

实现的原理就把执行的函数放到微任务中,改变函数执行的顺序。核心代码具体代码如下:

// 任务执行队列
export let jobQueue: Set<Function> = new Set();
// 微任务
export let tick = Promise.resolve();
/**
 * @name: clearQueue
 * @description: 清除任务队列
 */
export function clearQueue() {
  if (isFished) {
    return;
  }
  isFished = true;
  tick.then(() => {
    jobQueue.forEach((fn) => {
      fn();
    });
    jobQueue.clear();
    isFished = false;
  });
}

因为任务需要支持配置项,所以之前单一激活的副作用函数变量缓存不够用了,要支持配置的话,就需要对象了。核心代码如下:

/**
 * @name: ReactiveEffect
 * @description: 当前激活函数类
 */
class ReactiveEffect {
  // 收集函数
  public fu: Function;
  // 配置参数
  public option: Option | undefined;
  constructor(fu: Function, option?: Option) {
    this.fu = fu;
    if (option) {
      this.option = option;
    }
  }
  // 执行函数
  run(): void {
    this.fu();
  }
}

由于核心缓存对象的变化,依赖收集和触发函数也得有响应的变化了,代码如下:

/**
 * @name: effect
 * @description: 副作用封装
 * @param {Function} fun
 */
export function effect(fun: Function, option?: Option) {
  // 激活对象 新增改动
  const effectObject = new ReactiveEffect(fun, option);
  // 将当前收集函数放入栈中
  effectActiveFuList.push(effectObject);
  // 设置激活收集状态
  isTrackActive = true;
  function effectFu(effectObject: ReactiveEffect) {
    effectActive = effectObject;
    fun();
    // 嵌套收集的情况下数据是否清空
    if (effectActiveFuList.length == 0) {
      isTrackActive = false;
      effectActive = null;
    }
  }
  effectFu(effectObject);
}
/**
 * @name: track
 * @description: 收集依赖函数
 * @param {any} target 响应式的原始数据
 * @param {unknown} key 触发读取的key
 */
export function track(target: object, key: unknown) {
  // 从数组中去除最后面那一个 或者 是否是依赖收集函数触发的 新增改动
  effectActive = effectActiveFuList.pop() || effectActive;
  // 是否存在激活对象
  if (effectActive && isTrackActive) {
    // 当前对象的缓存map
    let effectCacheMap: Map<unknown, Set<ReactiveEffect>> | undefined =
      targetsMap.get(target);
    // 判断当前对象是否存在缓存
    if (!effectCacheMap) {
      effectCacheMap = new Map();
      targetsMap.set(target, effectCacheMap);
    }
    // 当前对象当前key的依赖列表
    // let deps: Set<Function> = new Set();
    let deps: Set<ReactiveEffect> | undefined = effectCacheMap.get(key);
    // 判读是否存在相同key的缓存列表
    if (!deps) {
      deps = new Set();
      effectCacheMap.set(key, deps);
    }
    // 添加到依赖列表
    deps.add(effectActive);
  }
}
/**
 * @name: trigger
 * @description: 触发函数
 * @param {type} {*}
 * @return {type} {*}
 * @param {object} target
 * @param {unknown} key
 */
export function trigger(target: Target, key: unknown) {
  // 如果对象是否在对象WeakMap的缓存池中存在
  const effectCacheMap = targetsMap.get(target);
  if (effectCacheMap) {
    //判断当前key是否存在依赖列表
    let deps = effectCacheMap.get(key);
    if (deps) {
      // const effects = new Set(deps);
      deps.forEach((item) => {
        // 当前get和set 触发同一个收集函数不执行解决oject.count++ 的这样的问题
        if (effectActive?.fu != item.fu) {
          effectActive = item;
          //判断是否需要修改执行逻辑   新增改动
          if (item?.option?.scheduler) {
            jobQueue.add(item.fu);
            clearQueue();
          } else {
            item.run();
          }
          effectActive = null;
        }
      });
    }
  }
}

强调:实现是这样实现的,在当前版本的vue3响应式中并没有这个场景了,在之后我的代码和单元测试中会删除这段

计算属性与lazy

基本实现

我们先看一段vue3的api具体demo,具体代码如下:

const count = ref(1)
const plusOne = computed(() => count.value + 1)
console.log(plusOne.value)

一个段代码的阅读最好的方式,就是先明白输入是什么,输出是什么?我们可以看到computed 输入的是一个副作用函数,输出是一个值,这个值有个特性就是当count变化时plusOne的值也是发生变化。

//伪代码

interface ComputedObject {
  value: any;
}
/**
 * @name: computed
 * @description: 计算属性函数
 * @return {ComputedObject} ComputedObject
 * @param {function} getter
 */
export function computed(getter: () => void): ComputedObject {
  let obj = {
    value:change()
  };
  return obj;
}

这是一个由果到因的一个过程,逻辑并不合理。因为正在的开发是有这样的场景,需要我这么设计代码,而不是因为代码长这样,我得这么设计。

我们怎么实现当前开发的述求呢?其实是不是只要访问plusOne.value的时候,再把() => count.value + 1 这个方法执行的返回值加以返回就可以实现了。利用之前effect,只要控制这个effect里面副作用函数执行的时机就可以实现我们当前的述求。我们需要一个lazy的这样的option,这个配置项需要我们控制实现的时机,第一次不执行,而是在返回函数,只有我们获取value值时,才调用执行返回。

// effect.ts 文件
export function effect(fun: Function, option?: Option): Function {
  // 激活对象 新增改动
  const effectObject = new ReactiveEffect(fun, option);
  // 将当前收集函数放入栈中
  effectActiveFuList.push(effectObject);
  // 设置激活收集状态
  isTrackActive = true;
  function effectFu(effectObject: ReactiveEffect) {
    effectActive = effectObject;
    if (!option?.lazy) {
      fun();
    } else {
      // 将执行结果返回作为值 新增
      return fun();
    }
    // 嵌套收集的情况下数据是否清空
    if (effectActiveFuList.length == 0) {
      isTrackActive = false;
      effectActive = null;
    }
  }
  // 如果不立即执行则返回当前的执行函数 新增
  if (option?.lazy) {
    return effectFu;
  } else {
    effectFu(effectObject);
    return () => {};
  }
}


//-----------------------我是分割线-----------------------


//computed.ts文件
/**
 * @name: computed
 * @description: 计算属性函数
 * @return {ComputedObject} ComputedObject
 * @param {function} getter
 */
export function computed(getter: () => void): ComputedObject {
  const effectFu = effect(getter, { lazy: true });
  let obj = {
    get value() {
      const effectObject = new ReactiveEffect(getter);
      return effectFu(effectObject);
    },
  };
  return obj;
}

封装优化

数据缓存优化例如我们多次访问的场景以及计算属性嵌套的场景。其实多次访问计算属性的时候不需要重复执行。嵌套计算属性的依赖需要正确的执行,以达到计算属性值变化,依赖的计算属性也会发生变化。

import { reactive, computed } from './index'
// 原始数据
const obj = {
  count: 0
}
// 将原数据转换为代理数据使它具有响应式的特性
let objProxy = reactive(obj)
const plusOne = computed(() => objProxy.count + 1)
// 嵌套依赖计算属性
const plusOne1 = computed(() => plusOne.value + 1)

setTimeout(() => {
  // 多次访问相同值
  console.log(plusOne.value)
  console.log(plusOne.value)
  console.log(plusOne.value)
  // 修改赋值
  objProxy.count = 7
  // objProxy.count++
  // 打印计算变化
  console.log(plusOne1.value)
  console.log(plusOne.value)
}, 500)

避免多次重复计算,无非最简单的方式缓存之前的结果,光缓存还不够,什么时候更新呢?发生新的值变更时就触发更新缓存。

实现方式比如加个缓存的标志位,如果标志位没有变就用缓存的值,如果标志位变了,就使用新的计算结果。

计算属性的特性需要获取值时返回最新的计算结果,就需要将执行函数保留,方便get value 的时候随时调用。

具体实现如下

// computed.ts文件
/**
 * @name: ComputedRefImpl
 * @description: compute 实体类
 * @param {type} {*}
 * @return {type} {*}
 */
class ComputedRefImpl {
  // getter 函数
  private getter: () => void
  // 副作用函数执行
  private effectFu: Function
  // 缓存数据标志位
  private isDirty: boolean = true
  // 缓存数据标志位
  private cacheData: any = undefined
  private effectObject: ReactiveEffect
  constructor(getter: () => void) {
    this.getter = getter
    // 新增调度任务,当值变化是改变标志位
    this.effectObject = new ReactiveEffect(this.getter, () => {
      this.isDirty = true
    })
    this.effectFu = effect(getter, {
      lazy: true,
      reactiveEffect: this.effectObject
    })
  }
  // value 引用
  get value() {
    // 标志位没有变化使用缓存数据
    if (this.isDirty) {
      this.cacheData = this.effectFu()
      // 重置标志位
      this.isDirty = false
    }
    return this.cacheData
  }
}
/**
 * @name: computed
 * @description: 计算属性函数
 * @return {ComputedObject} ComputedObject
 * @param {function} getter
 */
export function computed(getter: () => void): ComputedObject {
  return new ComputedRefImpl(getter)
}

//-----------------------我是分割线-----------------------

/**
 * @name: ReactiveEffect
 * @description: 当前激活函数类
 */
export class ReactiveEffect {
  // 收集函数
  public fu: Function
  // 调入任务
  public scheduler: (() => void) | undefined
  constructor(fu: Function, scheduler?: () => void) {
    this.fu = fu
    // 调度函数
    if (scheduler) {
      this.scheduler = scheduler
    }
  }
  // 执行函数
  run(): void {
    effectActive = this
    return this.fu()
  }
}

/**
 * @name: effect
 * @description: 副作用封装
 * @param {Function} fun
 */
export function effect(fun: Function, option?: Option): Function {
  // // 激活对象 新增改动
  const effectObject = option?.reactiveEffect
    ? option.reactiveEffect
    : new ReactiveEffect(fun)
  effectActiveFuList.push(effectObject)
  isTrackActive = true
  fun()
  // 嵌套收集的情况下数据是否清空
  if (effectActiveFuList.length == 0) {
    isTrackActive = false
    effectActive = null
  }
  // 将runner返回 自定义执行时间
  const runner = effectObject.run.bind(effectObject)
  return runner
}

/**
 * @name: trigger
 * @description: 触发函数
 * @param {type} {*}
 * @return {type} {*}
 * @param {object} target
 * @param {unknown} key
 */
export function trigger(target: Target, key: unknown) {
  // 如果对象是否在对象WeakMap的缓存池中存在
  const effectCacheMap = targetsMap.get(target)
  if (effectCacheMap) {
    //判断当前key是否存在依赖列表
    let deps = effectCacheMap.get(key)
    if (deps) {
      // const effects = new Set(deps);
      deps.forEach(item => {
        // 当前get和set 触发同一个收集函数不执行解决oject.count++ 的这样的问题
        if (effectActive?.fu != item.fu) {
          effectActive = item
          // 如果存在调度任务执行调度任务
          if (effectActive?.scheduler) {
            effectActive.scheduler()
          } else {
            // 将执行副作用函数
            item.run()
          }
          effectActive = null
        }
      })
    }
  }
}

因为我没有采取真正的lazy方式,直接执行收集了依赖,当computed的时候,我不需要使用嵌套的方式来达到computed的实现。这个部分我表述的不清晰,是因为这边我的实现也不优雅,没关系,下次优化吧,继续进行下面的代码阅读不能阻塞,毕竟我先实现了,单元测试也过了哈哈。太过纠结于细节,这本书一年都搞不完。

watch的实现原理

先看一下官方的watch的api使用形式,反推实现,我们需要实现一个响应式的数据,并监听数据的变化,执行相关的回调,返回新旧值。

// 侦听一个 getter
const state = reactive({ count: 0 })
watch(
  () => state.count,
  (count, prevCount) => {
    /* ... */
  }
)

// 直接侦听一个 ref
const count = ref(0)
    watch(count, (count, prevCount) => {
  /* ... */
})

有了前面computed实现铺垫,我们再去实现这个wacth就简单多了,我们其实只需要关注如何实现新值和旧值的回调就可以了。

场景代码如下

import { reactive, watch } from './index'
// 原始数据
const obj = {
  count: 0
}
// 将原数据转换为代理数据使它具有响应式的特性
let objProxy = reactive(obj)
// 监听函数
watch(
  () => objProxy.count,
  (count, prevCount) => {
    console.log(count, prevCount)
  }
)
objProxy.count = 1

我们去实现一个wacth代码如下

/**
 * @name: watch
 * @description: 监听函数
 * @param {Function} getter
 * @param {Function} callback
 * @param {Option} option
 */
interface Option {}
export function watch(getter: Function, callback: Function, option?: Option) {
  let oldValue = getter()
  let newValue: any
  let effectFn: Function
  const reactiveEffect = new ReactiveEffect(getter, () => {
    // 获取新值
    newValue = effectFn()
    // 执行回调
    callback(newValue, oldValue)
    // 旧值缓存
    oldValue = newValue
  })
  // 粗发依赖收集
  effectFn = effect(getter, {
    lazy: true,
    reactiveEffect: reactiveEffect
  })
}

立即执行的wacth 和 回调执行的时机

参数调整和代码优化:我们知道vue3官方的wacth的api支持多种参数,例如immediate和flush这样的参数都是对回调执行时机进行控制的。

简单实现一下支持立即执行回调和异步执行回调的场景。

/**
 * @name: watch
 * @description: 监听函数
 * @param {Function} getter
 * @param {Function} callback
 * @param {Option} option
 */
export function watch(getter: Function, callback: Function, option?: Option) {
   // 旧值
  let oldValue: any = undefined
  // 新值
  let effectFn: Function
  // 是否立即执行
  if (option?.immediate) {
    newValue = getter()
    callback(newValue, oldValue)
    oldValue = newValue
  } else {
    oldValue = getter()
  }
  const reactiveEffect = new ReactiveEffect(getter, () => {
    // 获取新值
    newValue = effectFn()
    // 异步执行
    if (option?.flush == 'post') {
      let tick = Promise.resolve()
      tick.then(() => {
        callback(newValue, oldValue)
      })
    } else {
      // 执行回调
      callback(newValue, oldValue)
    }
    // 旧值缓存
    oldValue = newValue
  })
  // 触发依赖收集
  effectFn = effect(getter, {
    lazy: true,
    reactiveEffect: reactiveEffect
  })
}

过期的副作用

onInvalidate 这个过期副作用还是一个很有用的场景,例如我监听一个输入框的变化,向服务端发送请求,但是请求本身是个异步行为,不同的请求响应时间不同,可能第一个请求,10s之后回来,第二个请求1s之后回来,明显在业务上面我们只需要处理第二个请求。

这样的业务常规的处理方式有两种,第一种就是队列数据,第一个请求处理完成再处理第二个,依次处理下面的请求,第二种就是只要发送新的请求,前面的请求就取消,abort这样的api使用,可能不同的请求库有不同的请求方式,具体场景是由业务决定的。

这样闭包变量的方式也是我们处理异步丢弃的一种实现方案,而不是在请求库,请求方式的层面解决这个问题。

数据变化   请求时间  响应数据

数据变化1 --10s--> 响应数据1
数据变化2 --20s--> 响应数据2
数据变化3 --30s--> 响应数据3
数据变化4 --40s--> 响应数据4
数据变化5 --1s-->  响应数据5

现在的业务上面其实我们是希望最后一个请求数据生效,其他的不处理。

// 业务代码

const obj = {
  count: 0
}
// 将原数据转换为代理数据使它具有响应式的特性
let objProxy = reactive(obj)
// 实现一个异步等待的函数
function sleep(interval: number) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(interval)
    }, interval * 1000)
  })
}
// 副作用清除
watch(
  () => objProxy.count,
  async (count, prevCount) => {
    let flag = false
    // 清除之前的副作用
    onInvalidate(() => {
      flag = true
    })
    const res = await sleep(count)
    // 标记最后一个生效
    if (!flag) {
      console.log(res)
    }
  }
)
// 使用不同状态
objProxy.count = 10
objProxy.count = 20
objProxy.count = 30
objProxy.count = 40
objProxy.count = 1

实现方式如下

// 清除函数
let cleanup: Function
/**
 * @name: watch
 * @description: 监听函数
 * @param {Function} getter
 * @param {Function} callback
 * @param {Option} option
 */
export function watch(getter: Function, callback: Function, option?: Option) {
  // 旧值
  let oldValue: any = undefined
  // 新值
  let newValue: any
  let effectFn: Function
  // 是否立即执行
  if (option?.immediate) {
    newValue = getter()
    callback(newValue, oldValue)
    oldValue = newValue
  } else {
    oldValue = getter()
  }
  const reactiveEffect = new ReactiveEffect(getter, () => {
    if (cleanup) {
      cleanup()
    }
    // 获取新值
    newValue = effectFn()
    // 异步执行
    if (option?.flush == 'post') {
      let tick = Promise.resolve()
      tick.then(() => {
        callback(newValue, oldValue)
      })
    } else {
      // 执行回调
      callback(newValue, oldValue)
    }
    // 旧值缓存
    oldValue = newValue
  })
  // 触发依赖收集
  effectFn = effect(getter, {
    lazy: true,
    reactiveEffect: reactiveEffect
  })
}
/**
 * @name: onInvalidate
 * @description: 清除副作用
 * @param {type} {*}
 * @return {type} {*}
 */
export function onInvalidate(fu: Function) {
  cleanup = fu
}

总结

  1. computed和watch 是vue的核心api,我的整个实现过程其实是倒置的,不是为了解决什么问题,才设计什么api,而是因为api是那样,所以我这么去这么实现。这是跟我阅读场景相关的,并不是好的开发业务形态,这是本末倒置的。
  2. 单元测试对于基础库而言非常重要,不断的修改api,怎么还能保证以前的代码,以前的功能还能跑通?
  3. 异步处理场景有很多小细节,具体的业务如何就需要我们设计不同的实现方案。

dianzang.png