读《vue3设计与实现》笔记1

85 阅读54分钟

第1-3章总体概览

vue的3大核心模块

3.3关于组件

组件的本质是一组DOM元素的封装

可以暂定一个函数代表组件,函数的返回值就是组件要渲染的内容,也是虚拟DOM

const MyComponent = function(){
  return {
    tag: "div",
    props: {
      onClick: () => alert("component")
    },
    children: "click"
  }
}

通过这样定义成函数,就可以在renderer渲染器中通过typeof进行判断,类型是组件还是元素。

function renderer(vnode, container){
  if(typeof vnode.tag === 'string'){
    // vnode描述的是标签元素
    mountElement(vnode, container)
  } else if(typeof vnode.tag === 'function'){
    // 说明 vnode此时是组件
    mountComponent(vnode, container)
  }
}

mountElement创建元素

packages/runtime-core/src/renderer.ts

  const mountElement = (vnode, container, anchor = null) => {
    const { props, shapeFlag, type, children } = vnode;
    let el = (vnode.el = hostCreateElement(type));
    // 渲染元素属性
    if (props) {
      for (let prop in props) {
        hostPatchProp(el, prop, null, props[prop]);
      }
    }
    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      hostSetElementText(el, children); //文本节点的创建
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 数组,需要进行对children创建挂载
      mountChildren(children, el);
    }
    hostInsert(el, container, anchor);
  };

mountElement语意化直白实现

function mountElement(vnode, container) {
  // 使用vnode.tag标签创建DOM元素
  const el = document.createElement(vnode.tag)
  // 遍历vnode.props,将事件和属性添加到DOM元素上
  for (const key in vnode.props) {
    if (/^on/.test(key)) {
      // 对以on开头的事件做处理,onClick --> click; vnode.props[key]是事件处理函数
      el.addEventListener(key.substr(2).toLowerCase(), vnode.props[key])
    } else{
      // 其他的属性,直接进行setAttribute设置
      el.setAttribute(key, vnode.props[key]);
    }
  }
  // 处理children
  if(typeof vnode.children === "string") {
    // 如果vnode.children是字符串,说明是文本节点
    el.appendChild(document.createTextNode(vnode.children))
  }else if(Array.isArray(vnode.children)){
    // 是数组,递归调用mountElement函数,渲染出子节点
    vnode.children.forEach(child => mountElement(child, el))
  }
  container.appendChild(el)
}

mountComponent挂载组件

由于vnode.tag是函数,返回值是虚拟DOM,首先获取到该函数的值const subTree = vnode.tag();这样subTree也是虚拟dom。再次递归调用renderer渲染器

function mountComponent(vnode, container){
  // 调用组件函数,获取到函数的返回值,即虚拟DOM
  const subTree = vnode.tag();
  // 递归调用renderer渲染 subTree
  renderer(subTree, container)
}

3.4编译器-处理模版

vue的一大核心就是可以编写template模版,便于开发。 编译器是处理模版,让模板编译成渲染函数。以.vue文件为例 编译器把template模版的内容编译出渲染函数,并添加到script标签块的组件对象上。

无论是模板还是渲染函数render,对于一个组件来说,渲染的内容都是通过渲染函数产生。然后渲染器把虚拟DOM渲染为真实DOM。

第4-6章响应式

4.响应式系统

4.1-4.3副作用effect

effect副作用函数,会直接或间接影响其他函数的执行

响应式数据,当更新该数据后,依赖该数据进行显示的都会同步更新。那么这个数据就是响应式的,在vue2中使用Object.defineProperty(只能代理对象上的属性)拦截get/set方法进行依赖的收集和派发。vue3中采用Proxy,可以代理整个对象。

全局变量activeEffect

为了解决副作用函数命名,定义了个全局变量activeEffect(初始值为undefined),作用是存储被注册的副作用函数。

// -----------定义effect-----------
let activeEffect = undefined;
function effect(fn){
  // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
  activeEffect = fn;
  // 执行副作用函数
  fn();
}
// -----------使用 effect函数-----------
// 用一个匿名的副作用函数作为effect函数的参数
effect(()=>{
  document.body.innerText = 'hello'
})

案例参考

target/key/effect对应关系

此时存在问题,如果更改了响应式对象obj.other属性,那么effect也会再次执行,显然不符合逻辑。

需要建立三个角色的对应关系

  • target:被代理的对象
  • key:被操作的属性
  • effect:要执行的副作用函数

对应关系为 根据上图对应关系,构建出数据结构,我们分别使用WeakMap存target,用Map存key,用Set存effect

  • WeakMap 由 target ---> Map 构成
  • Map 由 key ---> Set 构成

new Proxy(data, {
  get(target, key) {
    // 将 activeEffect 存储的副作用函数收集到deps中
    if (!activeEffect) return target[key];
    // 获取 target为索引的 depsMap,它是Map类型: key --> effects 结构
    let depsMap = targetMap.get(target);
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()));
    }
    let deps = depsMap.get(key);
    if (!deps) {
      depsMap.set(key, (deps = new Set()));
    }
    deps.add(activeEffect);

    return target[key];
  },
  set(target, key, value) {
    target[key] = value;
    const depsMap = targetMap.get(target);
    if (!depsMap) return;
    const effects = depsMap.get(key);
    effects && effects.forEach((fn) => fn());
    return true;
  }
});

案例代码

4.4分支切换和cleanup

const data = {ok: true, text: "hello vue3!"}
const obj = new Proxy(data, { ... })
effect(()=>{
  // 没有第9行的清理,effect run会被打印3次
  console.log("effect run");
  document.getElementById("app").innerText = obj.ok ? obj.text : "not";
})

代码链接 当effect函数内存在三元表达式,分支切换可能会遗留下副作用函数。

解决方案:在每次副作用函数执行时,先把它从所有的关联依赖集合中删除。

  • 要将副作用函数[activeEffect]从所有与之关联的依赖集合[deps]移除,需要知道哪些依赖集合[deps]包含它
  • 重新设计副作用函数,在副作用函数内部,添加deps属性[是数组]用来存储该副作用的相关联依赖集合

let activeEffect = undefined;
function effect(fn) {
  const effectFn = () => {
    cleanup(effectFn)
    // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
    activeEffect = effectFn;
    // 执行副作用函数
    fn();
  };
  // 新增deps属性,用来存储所有包含当前副作用函数的依赖集合
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}
// 清除副作用相关联的依赖集合deps
function cleanup(effectFn) {
  // effectFn.deps是数组类型
  for (let i = 0; i < effectFn.deps.length; i++) {
    const deps = effectFn.deps[i]; // deps是集合Set类型
    deps.delete(effectFn); // 清除掉关联依赖集合内的所有副作用
  }
  // 然后才能设置deps为[]
  effectFn.deps.length = 0;
}

cleanup函数接收副作用函数作为参数,遍历副作用函数的effect.deps数组,该数组的每项都是依赖集合deps。 然后将该副作用从依赖集合中移除。

处理trigger内部的无限循环执行

trigger函数内部,遍历effects集合,里面存放着副作用函数,当副作用函数执行时,会调用cleanup清除effects集合中的当前执行的副作用函数。但是副作用函数的执行会导致activeEffect重新被收集到集合中。

在调用forEach遍历Set集合时,如果一个值已经被访问过,但该值被删除并重新添加到集合,此时forEach遍历还没有结束,那么该值会被重新访问。forEach遍历会无限循环

const s = new Set([1]);
s.forEach(item=>{
  s.delete(1);
  s.add(1);
  console.log('run')
})
const s = new Set([1]);
const newS = new Set(s);
newS.forEach(item=>{
  s.delete(1);
  s.add(1);
  console.log('run')
})

所以就有了trigger函数中的72,73行代码 代码参考

4.5嵌套的effect【effect栈结构】

effect是可以嵌套的

const data = {foo:true, bar: true, text: 'hello vue3'}
effect(function effect1() {
  console.log("effect1 run");
  effect(function effect2() {
    console.log("effect2 run");
    temp2 = obj.bar;
  });
  temp1 = obj.foo;
});

当修改obj.foo的值时,会输出结果:

"effect1 run"
"effect2 run"
"effect2 run"

effect2被执行2次,显然不符合预期。 代码示例 问题出现在effect和activeEffect的关系上,使用activeEffect来存储effect函数注册的副作用函数,意味着同一个时刻只能有一个activeEffect,当副作用发生嵌套,内层副作用effect会覆盖activeEffect,并且不会恢复原值。即使再有响应式数据进行依赖收集,收集的副作用函数也是内层的副作用函数。

为了解决effect嵌套问题,需要建立个副作用函数栈effectStack,在副作用函数执行时,将前副作用函数压入栈中,副作用执行完毕将其从栈中弹出,并始终让activeEffect指向栈顶的副作用函数。

let activeEffect = undefined;
// effect栈结构,存在effect
let effectStack = []
function effect(fn) {
  const effectFn = () => {
    // 调用cleanup函数 完成清除副作用相关联的依赖集合deps
    cleanup(effectFn);
    // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
    activeEffect = effectFn;
    effectStack.push(effectFn);
    // 执行副作用函数
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length -1]
  };
  // 新增deps属性,用来存储所有包含当前副作用函数的依赖集合
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}

修正后代码

4.6避免无限递归循环

const data = {count:1}
const obj=new Proxy(data, {})
effect(() => obj.count++ ) // 副作用的自增操作,会引起栈溢出

问题出现:数据的读取和设置操作在同一个副作用函数内进行。此时无论是track收集的副作用函数,还是trigger是触发执行的副作用函数,都是activeEffect。 如果在trigger触发执行副作用函数与当前正在执行的副作用函数相同,则不触发执行。

// 更新依赖
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  //为了避免重复添加删除,造成死循环
  const effectsToRun = new Set();
  effects && effects.forEach(item=>{
    // trigger触发的副作用函数 与 当前正在执行的副作用函数相同,则不触发更新
    if(item !== activeEffect){
      effectsToRun.add(item)
    }
  })
  effectsToRun.forEach((fn) => fn());
}

代码参考

⭐️4.7scheduler 调度执行

  • 目前副作用的执行不受控制,现在会离开执行,并且会重复执行,为了解决这个问题,使用scheduler
  • 可调度性是响应式系统非常重要的特性。
  • vue中的computed和watch实现都依赖scheduler。
effect(() => {
  console.log("run effect", obj.count);
});
obj.count++;
console.log("over");
// 打印出
//run effect  1
//run effect  2
//over 

如果希望over打印显示在第二行,此时只能用户端调整打印顺序到effect上边。 代码演示

控制执行时机

通过给effect副作用函数添加options,设置scheduler调度

function effect(fn, options = {}) {
  const effectFn = () => {
    // 调用cleanup函数 完成清除副作用相关联的依赖集合deps
    cleanup(effectFn);
    // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
    activeEffect = effectFn;
    effectStack.push(effectFn);
    // 执行副作用函数
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  // 可以自定义执行规则,添加scheduler
  effectFn.options = options;
  // 新增deps属性,用来存储所有包含当前副作用函数的依赖集合
  effectFn.deps = [];
  // 执行副作用函数
  effectFn();
}

然后在trigger触发更新时判断是否有调度规则,如果有,则执行调度函数,并把副作用effec作为参数传递

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);
  //为了避免重复添加删除,造成死循环
  const effectsToRun = new Set();
  effects &&
    effects.forEach((effect) => {
      if (effect !== activeEffect) {
        effectsToRun.add(effect);
      }
    });
  effectsToRun.forEach((fn) => {
    // 如果用户使用的effect有 scheduler 配置,则走调度逻辑
    if (fn.options.scheduler) {
      fn.options.scheduler(fn);
    } else {
      fn();
    }
  });
}

经过设置,再次调用effect,就可以随意控制后续effect的执行时机

effect(
  () => {
    console.log("run effect", obj.count);
  },
  {
    scheduler(fn) {
      setTimeout(fn);
    }
  }
);
obj.count++;
console.log("over");

代码参考

控制执行次数

下面的副作用执行,会重复执行4次,但是中间2次只是过度过程,用户并不关心。可以通过scheduler控制中间的过程不显示。

effect(() => {
  console.log("run effect", obj.count);
});
obj.count++;
obj.count++;
obj.count++;

创建一个任务执行队列

const jobQueue = new Set();
const p = Promise.resolve();
let isFlushing = false;
function flushJob(){
  if(isFlushing) return;
  isFlushing = true;
  p.then(()=>{
    jobQueue.forEach(job => job())
  }).finally(()=>{
    isFlushing = false;
  })
}
effect(
  () => {
    console.log("run effect", obj.count);
  },
  {
    scheduler(fn) {
      jobQueue.add(fn);
      flushJob();
    }
  }
);

通过定义jobQueue任务队列,将正在执行的副作用添加到任务队列中,利用Promise的微任务执行,可以等到所有的effect副作用都添加完毕后,在一次执行所有的副作用函数。由于jobQueue时Set数据结构,所以存储的只有一个effect,就是当前执行的副作用函数。这样就简单实现多个同步任务,只执行最后一次。

⭐️4.8计算属性computed

处理立即执行问题

目前创建的effect副作用都是立即执行的,如果有些场景不希望立即执行,而是在它需要的时候才执行。例如计算属性,只有被依赖的值发生变化,副作用才会执行。这时就可以通过effect的options中的lazy属性完成。

effect(()=>{
 console.log(obj.foo); 
}, {
  lazy: true // 通过指定lazy属性,设置effect不立即执行
})

修改effect的实现逻辑

let activeEffect = undefined;
// effect栈结构,存在effect
let effectStack = [];
function effect(fn, options = {}) {
  const effectFn = () => {
    // 调用cleanup函数 完成清除副作用相关联的依赖集合deps
    cleanup(effectFn);
    // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
    activeEffect = effectFn;
    effectStack.push(effectFn);
    // 执行副作用函数
    fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
  };
  // 可以自定义执行规则,添加scheduler
  effectFn.options = options;
  // 新增deps属性,用来存储所有包含当前副作用函数的依赖集合
  effectFn.deps = [];

  /** 计算属性 🇭相关**/
  if (!options.lazy) {
    // 只有在非lazy属性时,才会执行effectFn
    effectFn();
  }
  // 默认情况下,只返回副作用函数,并不会执行
  return effectFn;
}

修改effect后,如果传递的options中有参数lazy:true,则不立即执行。 代码演示 默认只有effect第一次执行,后边需要手动调用。 如果仅仅满足手动执行副作用,也没太大用途。可以把effect内的函数作为一个getter,这个getter函数可以返回任何值。 调整effect函数,通过对effectFn进行包装,effectFn是包装后的副作用,此包装副作用的返回值才是真正的副作用。代码第16行。如果是lazy的情况下,只是返回副作用的函数第29行,并不会执行第26行。只有在非lazy的情况下,才能返回包装副作用effectFn函数的执行结果,从而才能执行真正的副作用函数fn。

let activeEffect = undefined;
// effect栈结构,存在effect
let effectStack = [];
function effect(fn, options = {}) {
  // 通过effectFn进行了对fn的一层包装,可以处理不立即执行的情况。
  const effectFn = () => {
    // 调用cleanup函数 完成清除副作用相关联的依赖集合deps
    cleanup(effectFn);
    // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
    activeEffect = effectFn;
    effectStack.push(effectFn);
    // 将fn的执行结果存储到res中
    const res = fn();
    effectStack.pop();
    activeEffect = effectStack[effectStack.length - 1];
    // 将res作为effectFn的返回值。
    return res;
  };
  // 可以自定义执行规则,添加scheduler
  effectFn.options = options;
  // 新增deps属性,用来存储所有包含当前副作用函数的依赖集合
  effectFn.deps = [];

  /** 计算属性 🇭相关**/
  if (!options.lazy) {
    // 只有在非lazy属性时,才会执行effectFn
    effectFn();
  }
  // 默认情况下,只返回副作用函数,并不会执行
  return effectFn;
}

// computed计算属性的定义
function computed(getter) {
  // 把getter作为副作用函数,创建个lazy的effect
  const effectFn = effect(getter, { lazy: true });
  const obj = {
    // 当读取value 值时,才执行effectFn
    get value() {
      return effectFn();
    }
  };
  return obj;
}

使用computed

const data = { foo: 1, bar: 2 };
const obj = new Proxy(data, {
  /**  */
})
const sum = computed(() => obj.foo + obj.bar);
console.log(sum, "sum");

代码演示

处理缓存问题

如果多次访问sum.value的值,即使obj.foo和obj.bar没有变化,也会导致effectFn进行多次计算。为了解决这个问题,需要在computed函数添加对值的缓存功能。

// computed计算属性的定义
function computed(getter) {
  // 用value缓存上次计算的值
  let value;
  // 判断是否需要重新计算,dirty为true才重新计算
  let dirty = true;
  // 把getter作为副作用函数,创建个lazy的effect
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      // 在调度器中将dirty 设置为true
      dirty = true;
    }
  });
  const obj = {
    // 当读取value 值时,才执行effectFn
    get value() {
      if (dirty) {
        value = effectFn();
        dirty = false;
      }
      return value;
    }
  };
  return obj;
}

通过设置dirty,控制是否重新执行effectFn。然后又在effect的options中添加scheduler调度函数,该调度函数会在所依赖的响应式数据变化时执行,同时将dirty设置为true,下次进行计算就能获取到最新值。 代码示例

⭐️4.9watch属性

watch本质是观察一个响应式数据,当数据发生变化,执行对应的回调函数。

  • 利用effect和options.scheduler选项实现
effect(()=>{
  console.log(obj.foo)
},{
  scheduler(){
   // 当obj.foo发生变化,会执行这里的内容
  }
})

watch的实现就是依赖effect中的scheduler,当响应式数据发生变化,如果副作用函数存在scheduler选项,则触发scheduler函数执行,而不是直接触发副作用函数执行。 依据这一特性,简单实现watch

// watch函数接收2个参数,source是响应式数据,cb是数据变化执行的回调函数
function watch(source, cb){
  effect(
    ()=> source.foo,
    {
      scheduler(){
        cb()
      }
    }
  )
}
const data = {foo:1}
const obj = new Proxy(data, { /* */})
watch(obj, ()=>{
  console.log("foo的数据变化了")
})
obj.foo++;

代码示例

观察对象的属性

上面通过source.foo硬编码实现对对象foo的监测,为了让watch具有通用行,需要封装一个通用的读取操作:

// watch函数接收2个参数,source是响应式数据,cb是数据变化执行的回调函数
function watch(source, cb) {
  let getter;
  // 如果第一个参数是函数,直接执行函数,读取返回值
  if (typeof source === "function") {
    getter = source;
  } else {
    // 不是函数,则读取对象的多个属性
    getter = () => traverse(source);
  }
  // 通过traverse来读取source的值
  effect(() => getter(source), {
    scheduler() {
      // 当数据变化时,执行cb
      cb();
    }
  });
}
function traverse(value, seen = new Set()) {
  // 如果是原始数据 或者已经读取过, 不进行处理
  if (typeof value !== "object" || value === null || seen.has(value)) return;
  // 将数据添加到seen中,表示已经读取过,避免循环引用,陷入死循环
  seen.add(value);
  // 假设观察的是对象,使用for  in 读取数据的每各属性值,递归调用traverse
  for (let key in value) {
    traverse(value[key], seen);
  }
  return value;
}

通过traverse函数,对传入的第一个对象进行监听。如果第一个参数传入的是函数,只监听该函数返回值;如果传入的是对象,则监听对象上的所有属性,通过traverse递归操作。 代码实例

获取新值newval和旧值oldval

在使用watch时,经常使用newValue和oldValue值做对比,然后再进行下一步操作。但是上面的cb回调函数并没有传递任何参数。接下来就将newValue和oldValue通过cb传递给用户端使用。 修改watch的实现:在14行,将effect副作用函数保存为effectFn变量,第26行,手动执行effectFn函数得到的返回值就是oldval,即第一次执行的结果。

// watch函数接收2个参数,source是响应式数据,cb是数据变化执行的回调函数
function watch(source, cb) {
  let getter;
  // 如果第一个参数是函数,直接执行函数,读取返回值
  if (typeof source === "function") {
    getter = source;
  } else {
    // 不是函数,则读取对象的多个属性
    getter = () => traverse(source);
  }
  let newVal, oldVal;
  // 通过traverse来读取source的值
  // 通过options的lazy属性,把返回值存储到effectFn中
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler() {
      // scheduler重新执行副作用函数,得到最新值
      newVal = effectFn();
      // 将新值和旧值作为cb的参数
      cb(newVal, oldVal);
      // 更新下 旧值,否则下次得到的旧值会是错误的
      oldVal = newVal;
    }
  });
  // 手动调用副作用函数,得到旧值
  oldVal = effectFn();
}
function traverse(value, seen = new Set()) {
  // 如果是原始数据 或者已经读取过, 不进行处理
  if (typeof value !== "object" || value === null || seen.has(value)) return;
  // 将数据添加到seen中,表示已经读取过,避免循环引用,陷入死循环
  seen.add(value);
  // 假设观察的是对象,使用for  in 读取数据的每各属性值,递归调用traverse
  for (let key in value) {
    traverse(value[key], seen);
  }
  return value;
}

代码示例

watch(
  () => obj.foo,
  (nv, ov) => {
    console.log("foo的数据变化了", nv, ov); // 2 1
  }
);
obj.foo++;

4.10立即执行 watch

  • 立即执行回调函数
  • 回调函数的执行时机

watch的实现,使用了options的lazy属性,所以不会立即执行。为了能够让watch的回调函数在创建时立刻执行一次,可以给watch添加第三个参数 immediate: true;

/ watch函数接收3个参数,
// source是响应式数据,cb是数据变化执行的回调函数,options设置执行时机
function watch(source, cb, options = {}) {
  let getter;
  // 如果第一个参数是函数,直接执行函数,读取返回值
  if (typeof source === "function") {
    getter = source;
  } else {
    // 不是函数,则读取对象的多个属性
    getter = () => traverse(source);
  }
  let newVal, oldVal;
  // 将scheduler调度函数,封装成 job 函数
  const job = () => {
    newVal = effectFn();
    // 当数据变化时,执行cb
    cb(newVal, oldVal);
    oldVal = newVal;
  };

  // 通过traverse来读取source的值
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: job
  });

  if (options.immediate) {
    // immediate属性为真,自动执行下job任务
    job();
  } else {
    oldVal = effectFn();
  }
}

除了给watch的第三个参数options设置immediate还可设置flush来控制回调函数的执行时机。

watch(
  () => obj.foo,
  (nv, ov) => {
    console.log("foo的数据变化了", nv, ov); // 2 1
  },
  {
    // immediate : true,回调函数会立即执行一次
    flush: 'post' 
  }
);
obj.foo++;
  • post : 回调函数需要将副作用函数放到微任务队列中
  • sync:实现同步执行
  • pre: 组件更新前执行
function watch(source, cb, options = {}) {
  let getter;
  // 如果第一个参数是函数,直接执行函数,读取返回值
  if (typeof source === "function") {
    getter = source;
  } else {
    // 不是函数,则读取对象的多个属性
    getter = () => traverse(source);
  }
  let newVal, oldVal;
  // 将scheduler调度函数,封装成 job 函数
  const job = () => {
    newVal = effectFn();
    // 当数据变化时,执行cb
    cb(newVal, oldVal);
    oldVal = newVal;
  };

  // 通过traverse来读取source的值
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      if (options.flush === "post") {
        const p = Promise.resolve();
        p.then(job);
      } else {
        job();
      }
    }
  });

  if (options.immediate) {
    // immediate属性为真,自动执行下job任务
    job();
  } else {
    oldVal = effectFn();
  }
}

/** 使用watch **/
watch(
  () => obj.foo,
  (nv, ov) => {
    console.log("foo的数据变化了", nv, ov);
  },
  {
    flush: "post" //添加上,则在out 之后打印
  }
);
obj.foo++;
console.log("out 同步执行函数");

4.11过期的副作用,可以被取消

正在执行的副作用,要能够被取消,否则会发生“竞态”问题。该问题可以在原始的xhr的abort中解决,也可在axios封装的 isCancel 中取消请求。 因此需要一种让副作用过期的技术。 watch的回调函数现在接收到newValue和oldvalue2个参数,通过设置第3个参数 onInvalidate 函数,这个函数类似事件监听器。使用onInvalidate注册回调函数,该回调函数在当前副作用函数过期时执行。

function delay(time) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(time);
    }, time);
  });
}
// watch函数接收3个参数,
// source是响应式数据,cb是数据变化执行的回调函数,options设置执行时机
function watch(source, cb, options = {}) {
  let getter;
  // 如果第一个参数是函数,直接执行函数,读取返回值
  if (typeof source === "function") {
    getter = source;
  } else {
    // 不是函数,则读取对象的多个属性
    getter = () => traverse(source);
  }
  let newVal, oldVal;
  // cleanup 存储用户注册的过期回调
  let cleanup;
  // onInvalidate 函数
  function onInvalidate(fn) {
    // 将过期回调 存储到cleanup
    cleanup = fn;
  }

  // 将scheduler调度函数,封装成 job 函数
  const job = () => {
    newVal = effectFn();
    if (cleanup) {
      cleanup();
    }
    // 将onInvalidate作为回调的第3个参数,以便用户使用
    cb(newVal, oldVal, onInvalidate);
    oldVal = newVal;
  };

  // 通过traverse来读取source的值
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: () => {
      if (options.flush === "post") {
        const p = Promise.resolve();
        p.then(job);
      } else {
        job();
      }
    }
  });

  if (options.immediate) {
    // immediate属性为真,自动执行下job任务
    job();
  } else {
    oldVal = effectFn();
  }
}

在watch中首先定义cleanup变量,用来存储用户通过onInvalidate函数注册的回调。 在job函数内,每次执行回调函数cb之前,先检查是否存在过期的回调,如果存在,则执行过期回调函数cleanup 最后把onInvalidate回调函数作为第三个参数传递给cb。 测试onInvalidate,

  • 通过模拟发生接口请求,后边的接口请求返回速度快,
  • 如果没有onInvalidate函数,则结果会显示为前一次接口返回的结果。这是错误的
  • 通过设置onInvalidate函数,把上次的副作用函数给取消掉,就不会发生前次接口值覆盖最新接口值的情况。

let finalData;
let initTime = 2200;
watch(
  () => obj.foo,
  async (newVal, oldVal, onInvalidate) => {
    console.log("foo的数据变化了");
    let flag = false;

    onInvalidate(() => {
      flag = true;
    });

    // 模拟后边发送接口请求,比上次的提前返回
    initTime = initTime - 1000;
    const res = await delay(initTime);
    if (!flag) {
      finalData = res;
      // onInvalidate生效,则会显示第二次请求的结果,不会被前一次结果覆盖
      // 如果注释掉 onInvalidate,则最终会显示 第一次发生的请求
      document.getElementById("app").innerHTML = finalData;
    }
    console.log("watch 内 finalData", finalData);
  }
);
obj.foo++;
obj.foo++;

代码实例

5.对象类型的响应式方案reactive/proxy

5.1理解Proxy和Reflect对象

  • Proxy只能代理对象类型
  • 代理是指,能够对对象的基本操作进行拦截,通过上面虚线定义的那些方法处理对象。
  • Proxy只能拦截对象的基本操作。复合操作处理不了,如obj.foo();
function fn(name) {
  console.log(`my name is ${name}, ${this.name}`);
}
const p = new Proxy(fn, {
  // 使用apply 拦截函数的调用
  apply(target, thisArg, argArray) {
    console.log(thisArg, argArray, "apply调用函数");
    target.call(thisArg, argArray);
  }
});
p.call({ name: "CallName" }, "北鸟南游"); // my name is 北鸟南游, CallName 

Reflect下的方法和Proxy的拦截器方法名称相同,任何通过Proxy拦截的方法都能在Reflect中找到。Reflect的重要意义在于receiver参数,可以理解为函数调用过程中的this。通过改变receiver,可以调整getter中的this。

Reflect对象中的receiver重要性

const Obj = {
  get count() {
    return this.c;
  }
};
console.log(Reflect.get(Obj, "count", { c: 99 })); //99

const po = new Proxy(Obj, {
  get(target, key, receiver) {
    if (key === "c") return 6;
    // return target[key]; //获取不到count的值,target找不到c
    return Reflect.get(target, key, receiver); //通过recevier可以改变属性访问器getter的this
  }
});
console.log(po.count, "count");  // 6

在getter属性访问器内,通过target[key]返回属性值,此时target是原始对象Obj,key是count,第11行相当于获取Obj.count。当打印po.count即访问count属性时,getter内的this指向原来的Obj对象,此时Obj下不存在属性c。所以用第11行,结果返回的是undefined。 当使用Reflect,并且要传递第三个参数receiver。那么此时的po.count,访问po代理对象的count属性时,recever就是po,访问器属性count的getter函数内的this就是代理对象po。当key为c时结果就会返回 6

5.2js对象及Proxy工作原理

js对象分为:常规对象(ordinary object)和异质对象(exotic object); 在js中对象的实际语意是由对象的内部方法(internalmethod)指定的,内部方法是当对一个对象进行操作时,在引擎内部调用的方法,这些方法对于我们使用者不可见。 image.png

image.png 以上2个表中定义了14个内部方法,ECMAScript 定义的内部方法。 在js中,一个对象必须包括table5中的12个必要的内部方法。table6中的 [[Call]] 和 [[Construct]]是对象作为函数调用必须包含的内部方法。

  • 常规对象是内部方法必须是9.1表中定义实现。
  • 对象的内部方法有重新改写定义9.2-9.5定义的对象,则是异质对象。
  • Proxy对象的内部方法[[Get]]就有新定义,所以是异质对象。

创建代理对象时的拦截方法,实质上是自定义代理对象本身的内部方法和行为。

const obj = { foo: 1 };
const po = new Proxy(obj, {
  deleteProperty(target, key) {
    return Reflect.deleteProperty(target, key);
  }
});
console.log(po.foo); // 1
delete po.foo;
console.log(po.foo); // undefined

5.3如何代理对象

前面一直使用get拦截对象属性的读取,但在响应系统中,读取是一个很宽泛概念,使用in操作符检查对象上是否具有给定的key也是读取操作。一个普通对象的所有读取操作可能有:

  • 访问属性:obj.foo
  • 判断对象或原型上是否存在给定的key: key in obj
  • 使用for... in 遍历对象: for(const key in obj) {}

第一种情况,可以直接使用get进行拦截。如果使用了in操作符,就需要查看对应的拦截函数image.png 可以看到in操作符运算结果是通过HasProperty的抽象方法得到。关于HasProperty 抽象方法可以看到内部对应的拦截函数是has。 image.png in操作符使用has进行拦截。 通过查找for... in的规范,可以看到是通过ownKeys进行拦截。 image.png

function* enumerate(obj) {
  let visited=new Set;
  for (let key of Reflect.ownKeys(obj)) {
      if (typeof key === "string") {
          let desc = Reflect.getOwnPropertyDescriptor(obj,key);
          if (desc) {
              visited.add(key);
              if (desc.enumerable) yield key;
          }
      }
  }
  let proto = Reflect.getPrototypeOf(obj)
  if (proto === null) return;
  for (let protoName of Reflect.enumerate(proto)) {
      if (!visited.has(protoName)) yield protoName;
  }
}

拦截ownKeys操作即可间接拦截for...in循环。

由于ownKeys,只能获取到目标对象target,没有传入key参数。 在track函数中需要key值,通过 const ITERATE_KEY = Symbol(); 作为key值。

const obj = { count: 1 };
const po = new Proxy(obj, {
  // 拦截读取操作
  get(target, key, receiver) {
    // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
    track(target, key);
    // 返回属性值
    return Reflect.get(target, key, receiver);
  },
  // 拦截设置操作
  set(target, key, newVal) {
    // 设置属性值
    target[key] = newVal;
    trigger(target, key);
  },
  has(target, key) {
    track(target, key);
    return Reflect.has(target, key);
  },
  ownKeys(target) {
    //将副作用函数 与  ITERATE_KEY 关联
    track(target, ITERATE_KEY);
    return Reflect.ownKeys(target);
  },
  deleteProperty(target, key) {
    // 检查被删除的属性是否是对象自身的属性
    const hadKey = Object.prototype.hasOwnProperty.call(target, key);
    // 使用Reflect.deleteProperty 完成属性删除
    const res = Reflect.deleteProperty(target, key);

    if (res && hadKey) {
      trigger(target, key, "DELETE");
    }
  }
});
effect(() => {
  // console.log(po);
  for (const key in po) {
    console.log("key", key);
  }
});
po.bar = 2;

po原来只有count属性,因此for...in循环一次,第42行给它添加了新属性bar,所以for...in循环就会由执行1次变成2次。也就是说当为对象添加属性时,需要触发ITERATE_KEY相关联的副作用重新执行。 给trigger方法添加 ITERATE_KEY相关的副作用函数

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

  //为了避免重复添加删除,造成死循环
  const effectsToRun = new Set();
  effects &&
    effects.forEach((effect) => {
      if (effect !== activeEffect) {
        effectsToRun.add(effect);
      }
    });
  //  删除操作会影响for...in循环次数
  // if (type === "ADD" || type === "DELETE") {
  // 取到与 ITERATE_KEY 相关的副作用函数
  const iterateEffects = depsMap.get(ITERATE_KEY);
  iterateEffects &&
    iterateEffects.forEach((effectFn) => {
      if (effectFn !== activeEffect) {
        effectsToRun.add(effectFn);
      }
    });
  // }

  effectsToRun.forEach((fn) => {
    // 如果用户使用的effect有 scheduler 配置,则走调度逻辑
    if (fn.options.scheduler) {
      fn.options.scheduler(fn);
    } else {
      fn();
    }
  });
}

代码实例

区分是新增属性还是更新设置属性?

按照上面给po新增了bar属性,effect副作用内的for...in会重新执行。但是更新po.count =2时,for...in也会重新执行。这样违背了修改属性不会对for...in循环产生影响。 在更新属性时,不需要多for...in产生影响,应该在Proxy的set方法中进行判断,是新增属性还是设置属性。

const type = Object.prototype.hasOwnProperty.call(target, key)
      ? "SET"
      : "ADD";

检查当前操作属性key是否存在目标对象上,如果存在,则是“SET”修改属性,否则是新增属性。可以把该参数传递给trigger。

// 更新依赖
function trigger(target, key, type) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);

  //为了避免重复添加删除,造成死循环
  const effectsToRun = new Set();
  effects &&
    effects.forEach((effect) => {
      if (effect !== activeEffect) {
        effectsToRun.add(effect);
      }
    });
  //  删除操作会影响for...in循环次数
  if (type === "ADD") {
    // 取到与 ITERATE_KEY 相关的副作用函数
    const iterateEffects = depsMap.get(ITERATE_KEY);
    iterateEffects &&
      iterateEffects.forEach((effectFn) => {
        if (effectFn !== activeEffect) {
          effectsToRun.add(effectFn);
        }
      });
  }

  effectsToRun.forEach((fn) => {
    // 如果用户使用的effect有 scheduler 配置,则走调度逻辑
    if (fn.options.scheduler) {
      fn.options.scheduler(fn);
    } else {
      fn();
    }
  });
}

只有在“ADD”时,才触发与 ITERATE_KEY 相关的副作用函数重新执行。代码实例

代理对象的删除操作

删除对象自身的属性,如果删除成功,则会影响for...in的遍历,也会触发effect副作用。 因此需要检查被删除的属性是否属于自身const hadKey=Object.prototype.hasOwnProperty.call(target, key);,然后调用Reflect.deleteProperty(target, key);完成属性的删除。


const po = new Proxy(obj, {
  deleteProperty(target, key) {
    // 检查被删除的属性是否是对象自身的属性
    const hadKey = Object.prototype.hasOwnProperty.call(target, key);
    // 使用Reflect.deleteProperty 完成属性删除
    const res = Reflect.deleteProperty(target, key);

    if (res && hadKey) {
      trigger(target, key, "DELETE");
    }
    return res;
  }
});

操作类型type为“DELETE”也应该触发与 ITERATE_KEY 相关联的副作用函数重新执行。

function trigger(target, key, type) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  const effects = depsMap.get(key);

  //为了避免重复添加删除,造成死循环
  const effectsToRun = new Set();
  effects &&
    effects.forEach((effect) => {
      if (effect !== activeEffect) {
        effectsToRun.add(effect);
      }
    });
  console.log(type, key);
  //  删除操作会影响for...in循环次数
  if (type === "ADD" || type === "DELETE") {
    // 取到与 ITERATE_KEY 相关的副作用函数
    const iterateEffects = depsMap.get(ITERATE_KEY);
    iterateEffects &&
      iterateEffects.forEach((effectFn) => {
        if (effectFn !== activeEffect) {
          effectsToRun.add(effectFn);
        }
      });
  }

  effectsToRun.forEach((fn) => {
    // 如果用户使用的effect有 scheduler 配置,则走调度逻辑
    if (fn.options.scheduler) {
      fn.options.scheduler(fn);
    } else {
      fn();
    }
  });
}

最后可以测试,删除自身属性foo及非自身属性bar的区别。删除bar不会再次触发effect副作用函数执行。 代码实例

5.4合理的触发响应

NaN引起的不必要更新

为了监听更新而触发副作用,以上解决方法面临第一个问题,设置的值没有变化,也触发副作用

const obj = {foo:1};
const p = new Proxy(obj, { //... 
})
effect(()=>{
  console.log(p.foo)
})
p.foo = 1; //设置值,但是没更新,仍然触发了effect副作用执行。

代码示例 为了解决这个问题,可以修改set拦截函数的代码,在调用trigger函数触发响应前,判断值是否发生变化。

const p = new Proxy(obj, {
  //...
  set(target, key, newVal, receiver) {
    // 先存储下旧值
    const oldVal = target[key];

    const res = Reflect.set(target, key, newVal, receiver);
    // 比较新值和旧值,只有在不相等的时候才触发响应
    if (oldVal !== newVal) {
      trigger(target, key);
    }
    return res;
  },
  //...
})

代码示例, 经过改造后,设置的值没有变化,就不触发effect更新。 上面使用了全等进行对比,在处理NaN时会有bug,因为NaN永远不等NaN,那么也会进行更新。所以还需要排除掉NaN数据。

NaN === NaN;  // false
NaN !== NaN;  // true

继续修改setter操作函数。

const p = new Proxy(obj, {
  //...
  set(target, key, newVal, receiver) {
    // 先存储下旧值
    const oldVal = target[key];

    const res = Reflect.set(target, key, newVal, receiver);
    // 比较新值和旧值,只有在不相等的时候才触发响应;并且不是NaN
    if (oldVal !== newVal && (oldVal ===oldVal || newVal === newVal) ) {
      trigger(target, key);
    }
    return res;
  },
  //...
})

代码示例

屏蔽原型链引起副作用更新

先把创建代理对象封装成通用的方法 reactive。这样可以方便创建多个代理对象。

const obj = { foo: 1 };
const child = reactive(obj);
const parent = reactive({ bar: 2 });
//设置parent 为child的原型
Object.setPrototypeOf(child, parent);

console.log("判断obj的原型是不是parent", Object.getPrototypeOf(obj) === parent);

effect(() => {
  console.log(child.bar);
});

child.bar = 3; //这里的修改,会触发2次effect的执行
  • 给child设置了parent作为原型。
  • child和parent都是响应式对象
  • 修改child.bar属性,由于child自身上没有bar属性,会找到原型对象parent上。parent也是响应式对象,从而就触发了2次effect。

代码示例 解决办法:既然是执行2次,那么只要屏蔽掉一次就可以。两次更新都是在set拦截函数中触发,因此需要在拦截函数set中设置触发更新的条件。

// child 的set拦截函数
set(target, key, newVal, receiver){
  // target是原始对象 obj
  // receiver是代理对象 child
}

// parent 的set拦截函数
set(target, key, newVal, receiver){
  // target是原始对象 原型proto 即parent
  // receiver是代理对象 child
}

可以发现,target在两次代理过程中是发生变化的,receiver是不变的。可以通过给receiver设置一个"raw"属性让它为原来的对象obj;

child.raw === obj; //true
parent.raw === obj; // false

修改reactive的getter和setter拦截函数

function reactive(obj) {
  return new Proxy(obj, {
    // 拦截读取操作
    get(target, key, receiver) {
      if (key === "raw") { // 设置raw属性,访问该属性时,获取到被代理的原始值
        return target;
      }
      // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
      track(target, key);
      // 返回属性值
      return Reflect.get(target, key, receiver);
    },
    // 拦截设置操作
    set(target, key, newVal, receiver) {
      const oldVal = target[key];
      // 如果属性不存在,则说明是在添加新的属性,否则是设置已存在的属性
      const type = Object.prototype.hasOwnProperty.call(target, key)
        ? "SET"
        : "ADD";

      // 设置属性值
      const res = Reflect.set(target, key, newVal, receiver);
      console.log(target === receiver.raw);
      if (target === receiver.raw) { // 排除掉原型链上属性的更新
        if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
          trigger(target, key, type);
        }
      }

      return res;
    }
    // ...
  }

代码示例

5.5浅响应和深响应

以前创建的代理,只能代理对象的一层。

const obj = reactive({ foo : { bar: 0}});
effect(()=>{
  console.log(obj.foo.bar)
});
// 修改obj.foo.bar的值,不能触发effect
obj.foo.bar = 2;

由于在get拦截函数中,Reflect.get函数返回的是obj.foo的结果 {bar: 0}。这是一个普通对象,并不是响应式对象,所以不能建立响应。改造get拦截函数

function reactive(obj){
  return new Proxy(obj, {
    get(target, key, receiver){
      if(key === "raw"){
        return target
      }
      track(target, key);
      // 得到返回结果
      const res = Reflect.get(target, key, receiver);
      if(typeof res === "object" && res !== null){
        //如果是对象类型,并且不是null,继续调用reactive
        return reavtive(res)
      }
      return res;
    }
  })
}

这样就可实现对象的深层次代理。修改obj.foo.bar的值,也能触发effect的更新。 代码实例 但是并不是所有情况都希望深度代理,这就产生了shallowReactive浅响应。

const obj = shallowReactive({foo: {bar: 1}})
effect(()=>{
  console.log(obj.foo.bar)
})
// obj.foo是响应的,可以触发effect执行
obj.foo = {bar: 32}
// obj.foo.bar不是响应的,不能触发effect函数重新执行
obj.foo.bar = 2

使用函数柯里化,继续封装一层createReactive函数,将创建不同类型的响应式数据通过参数创建。

function createReactive(obj, isShallow = false){
  return new Proxy(obj, {
    // 拦截get
    get(target, key, receiver){
      if(key === "raw"){
        return target
      }
      const res = Reflect.get(target, key, receiver);
      track(target, key);
      // 如果isShallow为真, 浅代理,直接返回res对象
      if(isShallow){
        return res
      }
      if(typeof res === "object" && res !== null){
        return reactive(res)
      }
      return res;
    }
  })
}
function reactive(obj){
  return createReactive(obj); //深代理
}
function shallowReactive(obj){
  return createReactive(obj, true); //浅代理
}

代码示例

5.6只读和浅只读

有时希望对数据进行保护,给数据设置为只读。当用户修改值或删除值时都发出警告。

const obj = readOnly({foo:1});
// 当修改数据,会弹出警告
obj.foo = 2

可以看出只读也是对数据的代理操作,在setter拦截函数中进行设置。给createReactive传递第3个参数

function createReactive(obj, isShallow = false, isReadonly = false){
  return new Proxy(obj, {
    // 设置的拦截
    set(target, key, newVal, receiver){
      // 如果是只读, isReadonly为真
      if(isReadonly){
        console.warn(`${key} 是只读的,不能修改`)
        return true;
      }
      const oldVal = target[key];
      // 如果属性不存在,则说明是在添加新的属性,否则是设置已存在的属性
      const type = Object.prototype.hasOwnProperty.call(target, key)
        ? "SET"
        : "ADD";
      // 设置属性值
      const res = Reflect.set(target, key, newVal, receiver);
      if (target === receiver.raw) {
        if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
          trigger(target, key, type);
        }
      }

      return res;
    },
    deleteProperty(target, key) {
      if (isReadonly) {
        console.warn(`属性 ${key} 是只读的`);
        return true;
      }
      const hadKey = Object.prototype.hasOwnProperty.call(target, key);
      const res = Reflect.deleteProperty(target, key);

      if (res && hadKey) {
        trigger(target, key, "DELETE");
      }

      return res;
    }
    }
  })
}

设置和删除属性时,都会有警告提示。 如果一个数据是只读,那么就无法修改它,也就没必要建立响应联系。修改getter拦截函数,只有非只读情况下才建立响应式track。

function createReactive(obj, isShallow = false, isReadonly = false){
  return new Proxy(obj, {
    // 拦截读取操作
    get(target, key, receiver) {
      if (key === "raw") {
        return target;
      }
      // 非只读的时候才需要建立响应联系
      if (!isReadonly) {
        track(target, key);
      }

      const res = Reflect.get(target, key, receiver);

      if (isShallow) {
        return res;
      }

      if (typeof res === "object" && res !== null) {
        // 深响应
        return reactive(res);
      }

      return res;
    }
  }
}

此时实现的readonly只读函数,只是浅只读shallowReadonly,还没有做深度处理。 如果要对数据做深度的只读处理,通过给createReactive传递第3个参数,设置为真。

function createReactive(obj, isShallow = false, isReadonly = false){
  return new Proxy(obj, {
    // 拦截读取操作
    get(target, key, receiver) {
      if (key === "raw") {
        return target;
      }
      // 非只读的时候才需要建立响应联系
      if (!isReadonly) {
        track(target, key);
      }

      const res = Reflect.get(target, key, receiver);

      if (isShallow) {
        return res;
      }

      if (typeof res === "object" && res !== null) {
        // 深只读和深响应
        return isReadonly ? readonly(res) : reactive(res);
      }

      return res;
    }
  }
}

function readonly(obj){
  return createReactive(obj, false, true)  
}
  // 只需要修改第二个参数即可,浅响应,并且做了只读处理
function shallowReadonly{
  return createReactive(obj, true, true)
}
                   

代码示例

5.7数组 5.8Map和Set

这2种对象处理的边界情况太多太复杂,还是要作者原书的描述。

6.原始值类型响应式方案ref的实现,getter/setter

第5章实现的响应式方案是建立在非原始值的对象上。如果是原始值基本类型:Boolean、Number、String、null、undefined、BigInt、Symbol类型的值。原始值是按值传递,而非引用传递,如果函数接收原始值作为参数,那么形参和实参直接没有关系,代理也就没意义。 JavaScript中的Proxy无法对原始值进行代理。

引入ref概念

原始值无法响应代理,通过包裹一层属性,变成对象类型。

// 封装ref函数
function ref(val){
  //在ref内创建包裹对象
  const wrapper={
    value: val
  }
  // 将包裹对象变成响应式
  return reactive(wrapper)
}

现在通过ref就可以给原始值创建响应式数据

const refVal = ref(1);
effect(()=>{
  //在副作用内通过value属性读原始值
  console.log(refVal.value);
})
// 修改值能触发副作用effect函数重新执行
refVal.value = 2

为了区分ref创建的响应式数据还是reactive创建的,需要在创建ref是添加__v_isRef属性

// 封装ref函数
function ref(val){
  //在ref内创建包裹对象
  const wrapper={
    value: val
  }
  //使用Object.defineProperty在wrapper对象上定义一个不可枚举的属性__is_Ref
  Object.defineProperty(wrapper, "__is_Ref", {
    value: true
  })
  // 将包裹对象变成响应式
  return reactive(wrapper)
}

代码示例

转换ref的方法toRef和toRefs

使用上面方法创建的响应式数据,无法进行展开,展开后响应式就会丢失。

export defalut{
  setup(){
    const obj = reactive({foo:1, bar:2});
    return { ...obj }
  }
}
// 使用展开运算符(...)导致响应丢失,相当于导出的是
return {
  foo:1,
  bar:2
}

为了解决响应式丢失问题,可以创建个newObj对象,在该对象下具有与obj的同名属性。每个属性值又是对象

const obj = reactive({foo:1, bar:2});
// newObj对象下具有obj对象的同名属性,每个属性值都是对象
const newObj = {
 foo: {
  get value(){
    return obj.foo
  }
 },
 bar: {
  get value(){
   return obj.bar
  }
 }
}
effect(()=>{
 console.log(newObj.foo)
})
obj.foo = 3

从newObj对象可以看出,结构存在相似。因此可以抽象出来,封装成函数toRef。

function toRef(obj, key){
 const wrapper={
  get value(){
   return obj[key]
  }
 }
 //使用Object.defineProperty在wrapper对象上定义一个不可枚举的属性__is_Ref
  Object.defineProperty(wrapper, "__is_Ref", {
    value: true
  })
 return wrapper
}

toRef函数接收2个参数,第1个参数obj是响应数据,第2个是obj对象的一个键。该函数会返回类似ref结构的wrapper对象。 toRef只能一次解决对象的一个key,可以在做一次封装,将所有key都做代理,封装成toRefs函数

function toRefs(obj){
  const ret = {};
  // for in循环遍历
  for(const key in obj){
    // 循环调用 toRef 完成转换
    ret[key]=toRef(obj, key)
  }
  return ret
}
// 这样只需一步操作,可完成整个对象的响应式转换
const newObj = {...toRefs(obj)}

现在通过toRef和toRefs方法,实现了将基本类型转成响应式。 此时toRef只实现了value属性的getter,还需要实现setter,增加设置时触发effect响应

function toRef(obj, key){
 const wrapper={
  get value(){
   return obj[key]
  },
  // 可以设置值
  set value(val){
   obj[key] = val;
  }
 }
 //使用Object.defineProperty在wrapper对象上定义一个不可枚举的属性__is_Ref
  Object.defineProperty(wrapper, "__is_Ref", {
    value: true
  })
 return wrapper
}

代码示例

自动脱ref方法proxyRefs

toRef函数转化解决响应丢失问题,但是带来新的问题,使用时必须通过value属性访问值,增加使用麻烦。 因此对包含有__v_isRef属性的数据做特殊处理,使用时自动去掉value属性

function proxyRefs(target){
  return new Proxy(target, {
    get(target, key, receiver){
      const value = Reflect.get(target, key, receiver);
      //如果是Ref,则获取的是 value 值
      return value.__v_isRef ? value.value : value;
    },
    set(target, key, newVal, receiver){
      const value = target[key];
      
      // 如果是Ref,则设置其对应的 value 属性值
      if(value.__v_isRef){
        value.value = newValue;
        return true
      }
      return Reflect.set(target, key, newVal, receiver)
    }
  })
}
  • 第6行,设置getter的去value属性
  • 第13行,设置setter的去value属性

代码示例

第7-11章 渲染器

7实现自定义渲染器

渲染器是执行渲染任务。vue3渲染器不仅包括Diff算法,还包含特有的快捷路径更新策略,充分结合编译器实现性能优化。

7.1渲染器与响应式数据结合

最基本的渲染器,就是一个函数

function renderer(domString, container){
  container.innerHTML = domString;
}
// 使用方法
renderer("<h1>vue3 renderer</h1>", document.getElementById("app"))

以上就实现了一个渲染器,并将h1标签的内容,插入到页面id为app内。

在vue中结合响应式数据。

function renderer(domString, container){
  container.innerHTML = domString;
}
let count = ref(1);
// 使用方法
effect(()=>{
  renderer(`<h1>vue3 renderer, ${count}</h1>`, document.getElementById("app"))
})
count.value++;
  • 定义响应式数据count
  • 在副作用函数effect中调用渲染器renderer函数执行
  • count数据发生变化,渲染器重新执行,更新页面内容。

可以使用vue的reactive.global.js模拟上述过程

<script src="https://unpkg.com/@vue/reactivity@3.2.35/dist/reactivity.global.js"></script>
<script>
  const {effect, ref} = VueReactivity;
  function renderer(domString, container){
    container.innerHTML = domString;
  }
  let count = ref(1);
  // 使用方法
  effect(() => {
    renderer(
      `<h1>vue3 renderer, ${count.value}</h1>`,
      document.getElementById("app")
    );
  });
  setTimeout(() => {
    count.value++;
  }, 400);
</script>

代码示例

7.2渲染器基本概念

renderer是渲染器,名词。render是渲染,动词。渲染器把虚拟DOM渲染成真实DOM元素,这个过程叫挂载。 渲染器要接收一个挂载点作为参数,用来指定挂载的位置。 使用一个函数createRenderer来创建渲染器

function createRenderer(){
  function render(vnode, container){
  }
  function hydrate(vnode, container){
  }
  return { render, hydrate }
}

渲染器不仅包含render函数,还包含hydrate函数(和服务端渲染相关)。

用渲染器执行任务

const renderer = createRenderer();
// 渲染任务
renderer.render(vnode, container)
// 第二次渲染
renderer.render(newVnode, container)
  • 首先用createRenderer创建一个渲染器renderer,接着调用render函数进行渲染工作。
  • 渲染器除了挂载节点外,还有多次渲染的更新动作。更新节点即patch的过程
function createRenderer(){
  function render(vnode, container){
    if(vnode){ //vnode存在,进行挂载动作
      // vnode:新节点, container._vnode:旧节点,使用patch函数打补丁
      patch(container._vnode, vnode, container)
    } else {//vnode不存在
      if(container._vnode){ //container._vnode存在,说明是卸载过程
        //需要将container内的DOM清空
        container.innerHTML = "";
      }
    }
    container._vnode = vnode;
  }
  function patch(n1, n2, container){}
  return { render }
}

patch函数的三个参数

  • n1:旧vnode
  • n2:新vnode
  • 第三个参数container:挂载容器

在首次渲染时,容器元素container._vnode属性不存在,为undefined。意味着首次渲染传递给patch函数的第一个参数n1是undefined。 演示连续调用3次的过程

const renderer = createRenderer();
// first
renderer.render(vnode1, container);
// second
renderer.render(vnode2, container);
// third
renderer.render(null, container);

7.3自定义渲染器

渲染器可以通过配置特定API,可实现渲染到任意平台的目标。 创建一个以浏览器为渲染目标平台的渲染器,然后可以将浏览器API进行抽象,即可转换为通用渲染器。 定义一个h1的vnode对象

const vnode = {
  type: "h1",
  children: "hello"
}

用type属性来描述vnode类型,当type是字符串,可认为是普通标签,并将type作为标签名。 使用renderer渲染vnode

const vnode = {
  type: "h1",
  children: "hello"
}
const renderer = createRenderer();
renderer.render(vnode, container);
function createRenderer(){
  function patch(n1, n2, container){
    if(!n1){
      mountElement(n2, container)
    } else {
      // n1存在,进行更新操作
    }
  }
  funtion mountElement(vnode, container){
    // 创建DOM元素
    let el = document.createElement(vnode.type);
    // 处理子节点,如果子节点是字符串,代表元素具有文本节点
    if(typeof vnode.children === "string"){
      //设置元素的textContent属性即可
      el.textContent = vnode.children;
    }
    //将元素添加到容器中
    container.appendChild(el)
  }
  function render(vnode, container){
     if(vnode){ //vnode存在,进行挂载动作
        // vnode:新节点, container._vnode:旧节点,使用patch函数打补丁
        patch(container._vnode, vnode, container)
      } else {//vnode不存在
        if(container._vnode){ //container._vnode存在,说明是卸载过程
          //需要将container内的DOM清空
          container.innerHTML = "";
        }
      }
      container._vnode = vnode;
  }
  return {
    render
  }
}

以上过程先调用document.createElement函数,用vnode.type作为标签名创建新DOM元素,接着处理vnode.children.如果是字符串,则将内容设置为元素的textContent属性,最后完成appendChild操作。 这是挂载一个普通标签元素的流程。我们的目标是设计一个不依赖浏览器平台的通用渲染器。只需将mountElement函数依赖的浏览器特有API进行抽离。

function createRenderer(options){
  //通过options传入特定API
  const {createElement, insert, setElementText} = options;
  //在mountElement函数中,使用特定API
  function mountElement(vnode, container){
    // 调用createElement函数创建元素
    const el = createElement(vnode.type)
    if(typeof vnode.children === "string"){
      //调用setElementText设置元素的文本节点
      setElementText(el, vnode.children)
    }
    //调用insert函数将元素插入到容器
    insert(el, container)
  }
}
// 自定义传入打印流程API
const renderer = createRenerer({
 createElement(tag){
   console.log("创建元素",tag)
   return {tag}
 },
 setElementText(el, text){
   console.log(`设置${JSON.stringify(el)} 的文本内容: ${text}`)
   el.text = text;
 },
 insert(el, parent, anchor=null){
   console.log(`将 ${JSON.stringify(el)} 添加到 ${JSON.stringify(parent)} 下`)
   parent.children = el
 }
})

通过给createRenderer传入不同的配置项,这样就可以实现自定义的渲染器。 代码示例 自定义渲染器案例项目

8.挂载和更新

8.1处理子节点和元素属性

子节点可能包含多个,所以需要设置成数组类型;即将children设置成数组

const vnode = {
  type: 'div',
  children: [{},{}]
}

定义成数组类型,然后就需要修改mountElement方法,增加对数组类型处理。

function mountedElement(vnode, container){
  const el= createElement(vnode.type);
  // 处理vnode 的children属性
  if (typeof vnode.children === "string") {
    setElementText(el, vnode.children);
  } else if (Array.isArray(vnode.children)) {
+    vnode.children.forEach((child) => {
+      patch(null, child, el);
+    });
  }
}

vnode.children是数组类型,则进行循环遍历操作。执行patch函数,在patch函数内部,挂载阶段会递归调用mountedElement方法。 处理过子节点后,开始处理props属性。

function mountedElement(vnode, container){
  const el= createElement(vnode.type);
  // 处理vnode 的children属性
  if (typeof vnode.children === "string") {
    setElementText(el, vnode.children);
  } else if (Array.isArray(vnode.children)) {
    vnode.children.forEach((child) => {
      patch(null, child, el);
    });
  }
  // 处理vnode 的props属性
+  if (vnode.props) {
+    for (let key in vnode.props) {
+     el.setAttribute(key, vnode.props[key]);
+    }
+  }
}

这里简单的用setAttribute进行元素属性的设置。 为元素设置属性需要处理很多边界条件,在后边会单独分析。

挂载元素的流程

代码实例

8.2HTML Attributs 和DOM Properties

理解HTML Attributes和DOM Properties差异,能正确的设计虚拟节点的结构,正确的为元素设置属性。 <input id="my-input" type="text" value="foo"/> 以上这段html代码,其中标签上的属性 id="my-input"、 type="text"、value="foo"就是HTML Attributes。 当用js获取这段html代码时,得到的对象就是**DOM对象,**dom对象的属性就是 Properties。 const el = document.querySelector("my-input") image.png

  • DOM Properties 和HTML Attributes的名称不是一一对应,比如样式class在html中是class,在dom中用className表示。
  • 不是所有的DOM Properties都有对应的HTML Attributes。比如可以使用el.textContent给元素设置文本内容,但是HTML Attributes没有对应的属性。

关于值的变化

在input标签中,如果用户没有修改文本框的内容,那么通过el.value和el.getAttributes都是获取的foo。 如果用户修改了文本框的内容为bar。 console.log(el.value); // "bar" console.log(el.getAttributes); // 仍是 "foo"

文本框内容的修改不会影响el.getAttributes的返回值,该值表示HTML Attributes的意义。 DOM Properties始终存储的是当前最新值。

仍然可以通过defaultValue获取到默认值, console.log(el.defaultValue); ⭐️⭐️⭐️⭐️核心关系:HTML Attributes的作用是设置DOM Properties的初始值。

8.3正确的设置元素属性

默认情况下浏览器会自动分析html attributes并设置合适的dom properties,但是在使用vue模版时,就不能被浏览器解析,所以这部分设置属性工作需要vue框架来完成。 以设置按钮禁用属性为例<button disabled>button</button>, 浏览器解析html时会设置一个disabled的属性给html attributes。并将el.disabled的DOM Properties值设置为true。 同样代码在vue模版中会被编译成vnode; vnode的props.disabled值为空字符串,如果在渲染器中调用setAttribute函数设置属性: el.setAttribute("disabled", ""),这样可以给按钮设置禁用状态。 但是当用户设置<button :disabled="false">不禁用按钮</button>时,经过转换为vnode后

const button = {
  type: "button",
  props: {
    disabled: false // 不禁用按钮
  }
}

渲染器使用el.setAttribute函数设置属性,那么按钮就被禁用了 ,因为使用el.setAttribute函数时,总是会被字符串化,结果为el.setAttribute("disabled", "false"); 只要disabled属性存在,按钮就会被禁用; 为了解决这个问题,需要在vue框架中特殊处理

  • 优先设置元素DOM Properties
  • 当值为空字符串时,要手动改正为true。
function mountElement(vnode, container) {
    const el = createElement(vnode.type);
    // 处理vnode 的children属性
    if (typeof vnode.children === "string") {
      setElementText(el, vnode.children);
    } else if (Array.isArray(vnode.children)) {
      console.log("child", vnode.children);
      vnode.children.forEach((child) => {
        patch(null, child, el);
      });
    }
    // 处理vnode 的props属性
    if (vnode.props) {
+      for (let key in vnode.props) {
+        // 先设置 properties属性
+        if (key in el) {
+          const type = typeof el[key];
+          const value = vnode.props[key];
+          //如果是boolean类型,且值为空,手动修复为 true
+          if (type === "boolean" && value === "") {
+            el[key] = true;
+          } else {
+            el[key] = value;
+          }
+        } else {
+          // 如果没有对应的dom properties,则使用setAttribute函数设置属性
+          el.setAttributes(key, vnode.props[key]);
+        }
+      }
    }
    // 将生成的el元素插入到container中
    insert(el, container);
  }

代码示例

处理特殊属性,只能用setAttribute

但是这样处理还是有问题,有一些DOM Properties属性是只读的。 <input form="form1" />,input标签的form属性(HTML Attributes),它对应的DOM Properties是el.form,但是el.form是只读属性,那么就只能通过setAttribute函数来设置它。

function shouldSetProps(el, key, value) {
  // 特殊处理只能通过setAttribute函数设置的属性
  if (key === "form" && el.tagName === "INPUT") return false;
  return key in el;
}
function mountedElement(vnode, container){
  // ...
  // 处理vnode 的props属性
    if (vnode.props) {
      for (let key in vnode.props) {
        const value = vnode.props[key];
        // 先设置 properties属性
        // 通过shouldSetProps方法进行判断,排除掉一些只能用setAttribute设置的属性
        if (shouldSetProps(el, key, value)) {
          const type = typeof el[key];
          //如果是boolean类型,且值为空,手动修复为 true
          if (type === "boolean" && value === "") {
            el[key] = true;
          } else {
            el[key] = value;
          }
        } else {
          // 如果没有对应的dom properties,则使用setAttribute函数设置属性
          el.setAttributes(key, vnode.props[key]);
        }
      }
    }
  //...
}

代码示例

将属性处理方法抽离为与平台无关

将属性的设置操作提取到渲染器选项中,通过创建renderer实例的options进行设置处理。增加了灵活性。

const renderer = createRenderer({
  createElement(tag) {
    return document.createElement(tag)
  },
  setElementText(el, text) {
    el.textContent = text
  },
  insert(el, parent, anchor = null) {
    parent.insertBefore(el, anchor)
  },
  patchProps(el, key, preValue, nextValue) {
    if (shouldSetAsProps(el, key, nextValue)) {
      const type = typeof el[key]
      if (type === 'boolean' && nextValue === '') {
        el[key] = true
      } else {
        el[key] = nextValue
      }
    } else {
      el.setAttribute(key, nextValue)
    }
  }
})

代码示例

8.4class属性设置

在vue框架中对class属性做了增强。

序列化处理class

方式1:指定class为字符串值 方式2:指定class为对象 方式3:class可以包含上面2中类型的数组 class可以包含多种类型值,需要使用normalizeClass函数将不同类型的class值转为正常的字符串。 通过normalizeClass转换vnode的class值

设置class属性

作者对比3种设置class方式【el.className, el.setAttributes, classList】的性能,发现el.className性能最佳。 调整patchProps函数

const renderer = createRenderer({
  //...
  patchProps(el, key, prevValue, nextValue){
+    if (key === "class") {
+      // 对class属性进行处理
+      el.className = nextValue || "";
    } else if (shouldSetProps(el, key, nextValue)) {
      const type = typeof el[key];
      if (type === "boolean" && nextValue === "") {
        el[key] = true;
      } else {
        el[key] = nextValue;
      }
    } else {
      el.setAttribute(key, nextValue);
    }
  }
})

完整代码示例 其实处理class需要特殊格式化处理,还有style也需要类似的处理,详情可以查看vue源码

8.5卸载操作

前面4节介绍了挂载操作,这节介绍卸载操作。 卸载发生在更新阶段,更新指的是在初次挂载完成后,后续渲染触发的属性或值的变化。

// 初次挂载
renderer.render(vnode, document.querySelector("#app"));
// 更新
renderer.render(newVnode, document.querySelector("#app"))
// 卸载
renderer.render(null, document.querySelector("#app"));

当给render的第一个参数设置为null,就是执行的卸载。 在前面mountElement函数中的render方法,如果container._vnode不存在,则直接container.innerHTML = "";

function render(vnode, container){
  if(vnode){ //vnode存在,进行挂载动作
    // vnode:新节点, container._vnode:旧节点,使用patch函数打补丁
    patch(container._vnode, vnode, container)
  } else {//vnode不存在
    if(container._vnode){ //container._vnode存在,说明是卸载过程
      //需要将container内的DOM清空
      container.innerHTML = "";
    }
  }
  container._vnode = vnode;
}

这么做是不严谨的,主要原因有:

  • 容器的内容可能有某个或多个组件渲染的,当卸载操作发生时,应当正确的调用这些组件的beforeUnmount、unmounted等生命周期的函数
  • 还有些元素存在自定义指令,应该在卸载的时候正确执行对应的指令钩子。
  • 使用innerHTML清空容器元素,不会移除绑定在DOM元素上的事件处理函数。

正确的卸载办法: 根据vnode对象获取与之相关联的真实DOM元素,然后使用DOM操作方法,将该DOM移除。 因此需要建立vnode和真实DOM元素之间的关系。 const el = vnode.el = createElement(vnode.type)

function mountElement(vnode, container){
  //...
  function render(vnode, container) {
    console.log("render", vnode, container);
    // vnode存在,说明是挂在创建阶段
    if (vnode) {
      patch(container._vnode, vnode, container);
    } else {
      // 新vnode节点不存在,并且判断下旧的_vnode存在,说明是卸载阶段
      if (container._vnode) {
        // 重新调整卸载操作,根据vnode.el值 移除真实DOM内容
        const el = container._vnode.el;
        // 获取el的父元素
        const parent = el.parentNode;
        if (parent) parent.removeChild(el);
      }
    }
    // 把 vnode 存储到 container._vnode 下,作为后续渲染中的旧 vnode节点存在
    container._vnode = vnode;
  }
  //...
}

container._vnode代表旧vnode,要被卸载的vnode,然后通过container._vnode.el取得真实DOM元素,并调用removeChild函数将其从父元素中移除。 由于卸载操作是比较常见的基本操作,可以单独封装到unmount函数中。

function unmount(vnode){
  const parent = vnode.el.parentNode;
  if(parent){
    parent.removeChild(vnode.el);
  }
}

代码示例

8.6区分vnode类型

在patch函数中,对比n1和n2元素进入打补丁操作。

function patch(n1, n2, container){
  if(!n1){
    mountElement(n2, container);
  } else {
   // update
  }
}

在更新操作时,先对比n1和n2 的type是否相同。如果不同,就没有patch的意义,可以直接将n1卸载。

function patch(n1, n2, container){
  if(n1 && n1.type !== n2.type){
    // 新旧节点的类型不同,直接将旧的vnode节点n1卸载
    unmount(n1);
    n1 = null;
  }
  if(!n1){
    mountElement(n2, container);
  }else {
    // update
  }
}

vnode.type的类型不同,需要进行的操作处理不同,因此需要调整patch进行不同类型的处理

function mountElement(vnode, container){
  //...
  function patch(n1, n2, container) {
    if (n1 && n1.type !== n2.type) {
      unmount(n1);
      n1 = null;
    }
    const { type } = n2;
    // 根据不同type类型,分情况处理,如果是string,直接更新element,如果是对象,则更新组件
    if (typeof type === "string") {
      if (!n1) {
        mountElement(n2, container);
      } else {
        patchElement(n1, n2);
      }
    } else if (typeof type === "object") {
      //如果n2.type的值的类型是对象,表示的是组件
    } else if (type === "xxx") {
      // 处理其它类型的值
    }
  }
  // ...
}

代码示例

8.7事件处理

像处理普通属性一样处理事件

把事件当作一种特殊的属性,可以按照约定,在vnode.props对象中,凡是以字符串on开头的属性都是事件。

const vnode = {
  type: "p",
  props: {
    onClick: ()=>{
      alert("clicked");
    }
  },
  children: 'text'
}

解决了事件在虚拟节点层面的问题,接下来处理如何将事件添加到DOM元素上,调整patchProps,增加addEventListener函数绑定事件。

function patchProps(el, key, prevValue, nextValue){
  // 匹配以on开头的属性
  if(/^on/.test(key)){
    const eventName = key.slice(2).toLowerCase();
    el.addEventListener(eventName, nextValue);
  }else if(key === "class"){
    // ...
  }
  //...
}

那么更新事件呢,按照处理props属性的方式,先移除之前的,再添加新的。

function patchProps(el, key, prevValue, nextValue){
  // 匹配以on开头的属性
  if(/^on/.test(key)){
    const eventName = key.slice(2).toLowerCase();
    // 移除之前的事件函数
    prevValue && el.removeEventListener(eventName, prevValue);
    // 设置最新的事件函数
    el.addEventListener(eventName, nextValue);
  }else if(key === "class"){
    // ...
  }
  //...
}

这种方式能够达到目的,但是操作起来性能不佳。

处理特殊事件属性

可以伪造一个绑定事件处理函数invoker,然后把真正的事件处理函数设置为invoker.value属性的值。这样当更新事件的时候,将不再需要调用removeEventListener函数来移除上次绑定的事件。

patchProps(el, key, prevValue, nextValue) {
    if (/^on/.test(key)) {
      const invokers = el._vei || (el._vei = {})
      let invoker = invokers[key]
      const name = key.slice(2).toLowerCase()
      if (nextValue) {
        if (!invoker) {
          // el._evi设置成对象
          invoker = el._vei[key] = (e) => {
            // 一个事件类型还可以绑定多个事件处理函数。因此在vnode的props中存在数组情况
            if (Array.isArray(invoker.value)) {
              invoker.value.forEach(fn => fn(e))
            } else {
              invoker.value(e)
            }
          }
          invoker.value = nextValue
          el.addEventListener(name, invoker)
        } else {
          invoker.value = nextValue
        }
      } else if (invoker) {
        el.removeEventListener(name, invoker)
      }
    } else if (key === 'class') {
      el.className = nextValue || ''
    } else if (shouldSetAsProps(el, key, nextValue)) {
      const type = typeof el[key]
      if (type === 'boolean' && nextValue === '') {
        el[key] = true
      } else {
        el[key] = nextValue
      }
    } else {
      el.setAttribute(key, nextValue)
    }
  }

由于一个元素上可以绑定多个事件,为了避免事件覆盖,需要将el._evi的数据结构设置为对象,它的键是事件名称,它的值是对应的事件处理函数。 同一个类型的事件,还可以绑定多个事件处理函数。

const vnode = {
  type: "p",
  props: {
    onClick:[
      ()=>{
        alert("111")
      },
      ()=>{
        alert("222")
      }
    ]
  },
  children: "text"
}

代码示例

8.8事件冒泡和更新时机

主要目的是:屏蔽到所有绑定时间【attached】晚于事件触发时间【timeStamp】的所有事件执行。 原因很简单,点击时事件还没进行绑定的事件,一律不执行。否则会引发错误。

更新patchProps方法

patchProps(el, key, prevValue, nextValue) {
    if (/^on/.test(key)) {
      const invokers = el._vei || (el._vei = {})
      let invoker = invokers[key]
      const name = key.slice(2).toLowerCase()
      if (nextValue) {
        if (!invoker) {
          invoker = el._vei[key] = (e) => {
+            console.log(e.timeStamp) // 事件触发时间
+            console.log(invoker.attached) //事件绑定时间
+            if (e.timeStamp < invoker.attached) return
            if (Array.isArray(invoker.value)) {
              invoker.value.forEach(fn => fn(e))
            } else {
              invoker.value(e)
            }
          }
          invoker.value = nextValue
+          invoker.attached = performance.now()
          el.addEventListener(name, invoker)
        } else {
          invoker.value = nextValue
        }
      } else if (invoker) {
        el.removeEventListener(name, invoker)
      }
    } else if (key === 'class') {
      el.className = nextValue || ''
    } else if (shouldSetAsProps(el, key, nextValue)) {
      const type = typeof el[key]
      if (type === 'boolean' && nextValue === '') {
        el[key] = true
      } else {
        el[key] = nextValue
      }
    } else {
      el.setAttribute(key, nextValue)
    }
  }

代码示例

8.9 更新子节点

前面所有示例都只是实现挂载操作,并没进行更新处理。在挂载子节点时,首先区分其类型。

  • 如果vnode.children是字符串,说明元素是文本子节点
  • 如果vnode.children是数组,说明元素具有多个子节点

子节点类型的规范化,有利于处理更新逻辑。 对于元素的更新,主要有以下3种情况

<!--没有子节点-->
<div></div>
<!--文本子节点-->
<div>123</div>
<!--多个子节点-->
<div>
  <p></p>
  <h1></h1>
</div>
  • 没有子节点,vnode.children的值是null
  • 具有文本子节点,vnode.children的值是字符串,代表文本内容
  • 其他情况,无论是单个元素子节点,还是多个子节点,都可以用数组来表示

一个vnode的子节点有3种可能,那么当渲染器更新时,新旧子节点都分别是3种可能。 用代码实现更新的过程

function patchElement(n1, n2) {
  const el = n2.el = n1.el
  const oldProps = n1.props
  const newProps = n2.props
  // 更新props
  for (const key in newProps) {
    if (newProps[key] !== oldProps[key]) {
      patchProps(el, key, oldProps[key], newProps[key])
    }
  }
  for (const key in oldProps) {
    if (!(key in newProps)) {
      patchProps(el, key, oldProps[key], null)
    }
  }
  // 更新children,是对一个元素进行patch打补丁的最后一步操作
  patchChildren(n1, n2, el)
}

接下来实现patchChildren函数。

新的children类型是字符串

function patchChildren(n1, n2, container){
  // 判断新子节点的类型是否是文本节点
  if(typeof n2.children === "string"){
    // 旧的子节点有三种类型可能:只有当是一组节点时才需要逐个卸载
    if(Array.isArray(n1.children)){
      n1.children.forEach((c) => unmount(c))
    }
    setElementText(container, n2.children)
  }
}

以上代码表示,首先检测新节点类型是否是文本节点,如果是则要检查旧子节点的类型。旧子节点类型有三种可能,只有旧子节点是一组子节点时,需要循环遍历他们,并逐个调用unmount函数进行卸载。其他2种情况不需要任何操作处理。

新的子节点类型是数组

如果新子节点不是文本,再增加新的处理逻辑分支

function patchChildren(n1, n2, container){
  // 判断新子节点的类型是否是文本节点
  if(typeof n2.children === "string"){
    // 旧的子节点有三种类型可能:只有当是一组节点时才需要逐个卸载
    if(Array.isArray(n1.children)){
      n1.children.forEach((c) => unmount(c))
    }
    setElementText(container, n2.children)
  }// 以下为新增
  else if(Array.isArray(n2.children)){// 新元素子节点类型是数组
    //判断旧子节点n1的children是否也是一组子节点
    if(Array.isArray(n1.children)){
      // 新旧子节点都是一组子节点,这里涉及到了核心的Diff算法,后续进行处理
      // todo
    }else{
      // 旧的子节点要么是文本子节点,要么不存在
      // 无论哪种情况,都只需要将容器清空,然后将新的一组子节点逐个挂载
      setElementText(container, '')
      n2.children.forEach(c => patch(null, c, container))
    }
  }
}

以上代码新增了对n2.children类型判断,检测它是否为一组子节点,如果是则接着判断旧子节点的类型。

  • 旧子节点是一组子节点,涉及到新旧两组子节点对比,就是vue的diff算法。后续进行详细分析,这里可以采用简单的处理方式:把旧节点全部卸载,再将新的一组子节点进行挂载。
  • 如果旧子节点是没有子节点或只是文本节点,只需要将容器元素清空,然后再逐个将新的一组子节点挂载到容器中即可。
function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    if (Array.isArray(n1.children)) {
      n1.children.forEach((c) => unmount(c))
    }
    setElementText(container, n2.children)
  } else if (Array.isArray(n2.children)) {
    if (Array.isArray(n1.children)) {
+      n1.children.forEach(c => unmount(c))
+      n2.children.forEach(c => patch(null, c, container))
    } else {
      setElementText(container, '')
      n2.children.forEach(c => patch(null, c, container))
    }
  }
}

最后一个情况,新的子节点为null

function patchChildren(n1, n2, container) {
  if (typeof n2.children === 'string') {
    if (Array.isArray(n1.children)) {
      n1.children.forEach((c) => unmount(c))
    }
    setElementText(container, n2.children)
  } else if (Array.isArray(n2.children)) {
    if (Array.isArray(n1.children)) {
      n1.children.forEach(c => unmount(c))
      n2.children.forEach(c => patch(null, c, container))
    } else {
      setElementText(container, '')
      n2.children.forEach(c => patch(null, c, container))
    }
  } else { // 新的子节点不存在
+    if (Array.isArray(n1.children)) { 
       // 旧的子节点是一组,需要逐个卸载
+      n1.children.forEach(c => unmount(c))
+    } else if (typeof n1.children === 'string') { // 旧的子节点是文本,直接清空
+      setElementText(container, '')
+    }
+  }
}

最后走到else分支,说明新的子节点不存在。这是仍需要判断旧的子节点类型;

  • 如果旧子节点不存在,什么都不需要做
  • 旧的子节点是文本节点,则清空文本内容
  • 旧的子节点是一组节点,则逐个卸载。

代码示例

8.10文本节点和注释节点

使用虚拟DOM描述多种类型的真实DOM,最常见的两种节点类型是文本节点和注释节点。 vnode.type属性代表一个vnode的类型,如果vnode.type的值是字符串,则表示描述的是普通标签,并且该值就是标签的名称,如div,p; 但是注射节点和文本解读不同于普通标签节点,它没有标签,因此需要创造出唯一的标识,来表示注释节点和文本节点的type属性值:

// 文本节点的type标识
const Text = Symbol();
const TextVnode = {
  type: Text;
  children: "text text"
}
// 注释节点的type标识
const Comment = Symbol();
const commentVnode = {
  type: Comment,
  children: "commentVnode"
}

有了文本节点和注释节点的vnode对象后,就可以使用渲染器来渲染他们。

function patch(n1, n2, container) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }

  const { type } = n2

  if (typeof type === 'string') {
    if (!n1) {
      mountElement(n2, container)
    } else {
      patchElement(n1, n2)
    }
  } else if (type === Text) {
    if (!n1) {
      // 创建文本节点
      const el = n2.el = document.createTextNode(n2.children)
      // 将文本节点插入到容器中
      insert(el, container)
    } else {
      // 如果旧vnode存在,只需要更新旧节点的内容
      const el = n2.el = n1.el
      if (n2.children !== n1.children) {
        el.nodeValue = n2.children;
      }
    }
  }
}

patch函数依赖平台特有API,可以通过createTextNode和setText方式实现更新。 在创建renderer实例时,给options新增createTextNode和setText方法

const renderer = createRenderer({
  //...
  createTextNode(text){
    return document.createTextNode(text)
  },
  setText(){
    el.nodeValue = text;
  }
  //...
})

修改patch中的操作,使用特定的平台API;

function patch(n1, n2, container) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }

  const { type } = n2

  if (typeof type === 'string') {
    if (!n1) {
      mountElement(n2, container)
    } else {
      patchElement(n1, n2)
    }
  } else if (type === Text) {
    if (!n1) {
      const el = n2.el = createText(n2.children)
      insert(el, container)
    } else {
      const el = n2.el = n1.el
      if (n2.children !== n1.children) {
        setText(el, n2.children)
      }
    }
  }
}

注释节点的处理和文本节点处理方式类似,只需使用document.createComment函数创建注释节点元素 代码示例:

8.11 Fragment多根节点标签

Fragment是vue3新增的节点标签,也需要创建单独的type类型。Fragment主要是为了解决多根元素节点的标签。

<template>
  <li>1</li>
  <li>1</li>
  <li>1</li>
</template>
// 对应的虚拟节点 vnode
const vnode = {
  type: Fragment,
  children: [
    {type: "li", children: "1"},
    {type: "li", children: "2"},
    {type: "li", children: "3"},
  ]
}

增加了Fragment标签,调整渲染器的渲染逻辑处理,渲染Fragment标签本身不会渲染任何内容,所以只会渲染Fragment子节点内容。

function patch(n1, n2, container) {
  if (n1 && n1.type !== n2.type) {
    unmount(n1)
    n1 = null
  }

  const { type } = n2

  if (typeof type === 'string') {
    if (!n1) {
      mountElement(n2, container)
    } else {
      patchElement(n1, n2)
    }
  } else if (type === Text) {
    if (!n1) {
      const el = n2.el = createText(n2.children)
      insert(el, container)
    } else {
      const el = n2.el = n1.el
      if (n2.children !== n1.children) {
        setText(el, n2.children)
      }
    }
+  } else if (type === Fragment) {
+    if (!n1) {
+      n2.children.forEach(c => patch(null, c, container))
+    } else {
+      patchChildren(n1, n2, container)
+    }
+  }
}

在patch函数中增加了Fragment类型虚拟节点的处理,在卸载时也需要支持Fragment类型的卸载

function unmount(vnode) {
  if (vnode.type === Fragment) {
    vnode.children.forEach(c => unmount(c))
    return
  }
  const parent = vnode.el.parentNode
  if (parent) {
    parent.removeChild(vnode.el)
  }
}

代码示例链接

vue设计与实现是本对技术讲解非常细致的书,小伙伴们可以支持下创作者。文章内容基本是书中内容,记录的没有书中详细。更详细的了解请阅读原书