第1-3章总体概览

vue的3大核心模块

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

3.3关于组件

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

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

  1. const MyComponent = function(){
  2. return {
  3. tag: "div",
  4. props: {
  5. onClick: () => alert("component")
  6. },
  7. children: "click"
  8. }
  9. }

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

  1. function renderer(vnode, container){
  2. if(typeof vnode.tag === 'string'){
  3. // vnode描述的是标签元素
  4. mountElement(vnode, container)
  5. } else if(typeof vnode.tag === 'function'){
  6. // 说明 vnode此时是组件
  7. mountComponent(vnode, container)
  8. }
  9. }

mountElement创建元素

packages/runtime-core/src/renderer.ts

  1. const mountElement = (vnode, container, anchor = null) => {
  2. const { props, shapeFlag, type, children } = vnode;
  3. let el = (vnode.el = hostCreateElement(type));
  4. // 渲染元素属性
  5. if (props) {
  6. for (let prop in props) {
  7. hostPatchProp(el, prop, null, props[prop]);
  8. }
  9. }
  10. if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
  11. hostSetElementText(el, children); //文本节点的创建
  12. } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
  13. // 数组,需要进行对children创建挂载
  14. mountChildren(children, el);
  15. }
  16. hostInsert(el, container, anchor);
  17. };

mountElement语意化直白实现

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

mountComponent挂载组件

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

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

3.4编译器-处理模版

vue的一大核心就是可以编写template模版,便于开发。
编译器是处理模版,让模板编译成渲染函数。以.vue文件为例
读《vue3设计与实现》笔记 - 图2
编译器把template模版的内容编译出渲染函数,并添加到script标签块的组件对象上。

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

读《vue3设计与实现》笔记 - 图3

第4-6章响应式

4.响应式系统

4.1-4.3副作用effect

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

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

全局变量activeEffect

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

  1. // -----------定义effect-----------
  2. let activeEffect = undefined;
  3. function effect(fn){
  4. // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
  5. activeEffect = fn;
  6. // 执行副作用函数
  7. fn();
  8. }
  9. // -----------使用 effect函数-----------
  10. // 用一个匿名的副作用函数作为effect函数的参数
  11. effect(()=>{
  12. document.body.innerText = 'hello'
  13. })

案例参考

target/key/effect对应关系

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

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

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

对应关系为
读《vue3设计与实现》笔记 - 图4
根据上图对应关系,构建出数据结构,我们分别使用WeakMap存target,用Map存key,用Set存effect

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

读《vue3设计与实现》笔记 - 图5

  1. new Proxy(data, {
  2. get(target, key) {
  3. // 将 activeEffect 存储的副作用函数收集到deps中
  4. if (!activeEffect) return target[key];
  5. // 获取 target为索引的 depsMap,它是Map类型: key --> effects 结构
  6. let depsMap = targetMap.get(target);
  7. if (!depsMap) {
  8. targetMap.set(target, (depsMap = new Map()));
  9. }
  10. let deps = depsMap.get(key);
  11. if (!deps) {
  12. depsMap.set(key, (deps = new Set()));
  13. }
  14. deps.add(activeEffect);
  15. return target[key];
  16. },
  17. set(target, key, value) {
  18. target[key] = value;
  19. const depsMap = targetMap.get(target);
  20. if (!depsMap) return;
  21. const effects = depsMap.get(key);
  22. effects && effects.forEach((fn) => fn());
  23. return true;
  24. }
  25. });

案例代码

4.4分支切换和cleanup

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

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

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

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

读《vue3设计与实现》笔记 - 图6

  1. let activeEffect = undefined;
  2. function effect(fn) {
  3. const effectFn = () => {
  4. cleanup(effectFn)
  5. // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
  6. activeEffect = effectFn;
  7. // 执行副作用函数
  8. fn();
  9. };
  10. // 新增deps属性,用来存储所有包含当前副作用函数的依赖集合
  11. effectFn.deps = [];
  12. // 执行副作用函数
  13. effectFn();
  14. }
  15. // 清除副作用相关联的依赖集合deps
  16. function cleanup(effectFn) {
  17. // effectFn.deps是数组类型
  18. for (let i = 0; i < effectFn.deps.length; i++) {
  19. const deps = effectFn.deps[i]; // deps是集合Set类型
  20. deps.delete(effectFn); // 清除掉关联依赖集合内的所有副作用
  21. }
  22. // 然后才能设置deps为[]
  23. effectFn.deps.length = 0;
  24. }

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

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

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

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

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

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

4.5嵌套的effect【effect栈结构】

effect是可以嵌套的

  1. const data = {foo:true, bar: true, text: 'hello vue3'}
  2. effect(function effect1() {
  3. console.log("effect1 run");
  4. effect(function effect2() {
  5. console.log("effect2 run");
  6. temp2 = obj.bar;
  7. });
  8. temp1 = obj.foo;
  9. });

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

  1. "effect1 run"
  2. "effect2 run"
  3. "effect2 run"

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

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

  1. let activeEffect = undefined;
  2. // effect栈结构,存在effect
  3. let effectStack = []
  4. function effect(fn) {
  5. const effectFn = () => {
  6. // 调用cleanup函数 完成清除副作用相关联的依赖集合deps
  7. cleanup(effectFn);
  8. // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
  9. activeEffect = effectFn;
  10. effectStack.push(effectFn);
  11. // 执行副作用函数
  12. fn();
  13. effectStack.pop();
  14. activeEffect = effectStack[effectStack.length -1]
  15. };
  16. // 新增deps属性,用来存储所有包含当前副作用函数的依赖集合
  17. effectFn.deps = [];
  18. // 执行副作用函数
  19. effectFn();
  20. }

修正后代码

4.6避免无限递归循环

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

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

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

代码参考

⭐️4.7scheduler 调度执行

  • 目前副作用的执行不受控制,现在会离开执行,并且会重复执行,为了解决这个问题,使用scheduler
  • 可调度性是响应式系统非常重要的特性。
  • vue中的computed和watch实现都依赖scheduler。

    1. effect(() => {
    2. console.log("run effect", obj.count);
    3. });
    4. obj.count++;
    5. console.log("over");
    6. // 打印出
    7. //run effect 1
    8. //run effect 2
    9. //over

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

    控制执行时机

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

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

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

    1. function trigger(target, key) {
    2. const depsMap = targetMap.get(target);
    3. if (!depsMap) return;
    4. const effects = depsMap.get(key);
    5. //为了避免重复添加删除,造成死循环
    6. const effectsToRun = new Set();
    7. effects &&
    8. effects.forEach((effect) => {
    9. if (effect !== activeEffect) {
    10. effectsToRun.add(effect);
    11. }
    12. });
    13. effectsToRun.forEach((fn) => {
    14. // 如果用户使用的effect有 scheduler 配置,则走调度逻辑
    15. if (fn.options.scheduler) {
    16. fn.options.scheduler(fn);
    17. } else {
    18. fn();
    19. }
    20. });
    21. }

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

    1. effect(
    2. () => {
    3. console.log("run effect", obj.count);
    4. },
    5. {
    6. scheduler(fn) {
    7. setTimeout(fn);
    8. }
    9. }
    10. );
    11. obj.count++;
    12. console.log("over");

    代码参考

    控制执行次数

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

    1. effect(() => {
    2. console.log("run effect", obj.count);
    3. });
    4. obj.count++;
    5. obj.count++;
    6. obj.count++;

    创建一个任务执行队列

    1. const jobQueue = new Set();
    2. const p = Promise.resolve();
    3. let isFlushing = false;
    4. function flushJob(){
    5. if(isFlushing) return;
    6. isFlushing = true;
    7. p.then(()=>{
    8. jobQueue.forEach(job => job())
    9. }).finally(()=>{
    10. isFlushing = false;
    11. })
    12. }
    13. effect(
    14. () => {
    15. console.log("run effect", obj.count);
    16. },
    17. {
    18. scheduler(fn) {
    19. jobQueue.add(fn);
    20. flushJob();
    21. }
    22. }
    23. );

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

    ⭐️4.8计算属性computed

    处理立即执行问题

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

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

    修改effect的实现逻辑

    1. let activeEffect = undefined;
    2. // effect栈结构,存在effect
    3. let effectStack = [];
    4. function effect(fn, options = {}) {
    5. const effectFn = () => {
    6. // 调用cleanup函数 完成清除副作用相关联的依赖集合deps
    7. cleanup(effectFn);
    8. // 当调用effect注册副作用函数时,将副作用函数fn赋值给activeEffect
    9. activeEffect = effectFn;
    10. effectStack.push(effectFn);
    11. // 执行副作用函数
    12. fn();
    13. effectStack.pop();
    14. activeEffect = effectStack[effectStack.length - 1];
    15. };
    16. // 可以自定义执行规则,添加scheduler
    17. effectFn.options = options;
    18. // 新增deps属性,用来存储所有包含当前副作用函数的依赖集合
    19. effectFn.deps = [];
    20. /** 计算属性 🇭相关**/
    21. if (!options.lazy) {
    22. // 只有在非lazy属性时,才会执行effectFn
    23. effectFn();
    24. }
    25. // 默认情况下,只返回副作用函数,并不会执行
    26. return effectFn;
    27. }

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

  1. 使用computed
  2. ```typescript
  3. const data = { foo: 1, bar: 2 };
  4. const obj = new Proxy(data, {
  5. /** */
  6. })
  7. const sum = computed(() => obj.foo + obj.bar);
  8. console.log(sum, "sum");

代码演示

处理缓存问题

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

  1. // computed计算属性的定义
  2. function computed(getter) {
  3. // 用value缓存上次计算的值
  4. let value;
  5. // 判断是否需要重新计算,dirty为true才重新计算
  6. let dirty = true;
  7. // 把getter作为副作用函数,创建个lazy的effect
  8. const effectFn = effect(getter, {
  9. lazy: true,
  10. scheduler() {
  11. // 在调度器中将dirty 设置为true
  12. dirty = true;
  13. }
  14. });
  15. const obj = {
  16. // 当读取value 值时,才执行effectFn
  17. get value() {
  18. if (dirty) {
  19. value = effectFn();
  20. dirty = false;
  21. }
  22. return value;
  23. }
  24. };
  25. return obj;
  26. }

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

⭐️4.9watch属性

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

  • 利用effect和options.scheduler选项实现

    1. effect(()=>{
    2. console.log(obj.foo)
    3. },{
    4. scheduler(){
    5. // 当obj.foo发生变化,会执行这里的内容
    6. }
    7. })

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

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

    代码示例

    观察对象的属性

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

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

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

    获取新值newval和旧值oldval

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

    1. // watch函数接收2个参数,source是响应式数据,cb是数据变化执行的回调函数
    2. function watch(source, cb) {
    3. let getter;
    4. // 如果第一个参数是函数,直接执行函数,读取返回值
    5. if (typeof source === "function") {
    6. getter = source;
    7. } else {
    8. // 不是函数,则读取对象的多个属性
    9. getter = () => traverse(source);
    10. }
    11. let newVal, oldVal;
    12. // 通过traverse来读取source的值
    13. // 通过options的lazy属性,把返回值存储到effectFn中
    14. const effectFn = effect(() => getter(), {
    15. lazy: true,
    16. scheduler() {
    17. // scheduler重新执行副作用函数,得到最新值
    18. newVal = effectFn();
    19. // 将新值和旧值作为cb的参数
    20. cb(newVal, oldVal);
    21. // 更新下 旧值,否则下次得到的旧值会是错误的
    22. oldVal = newVal;
    23. }
    24. });
    25. // 手动调用副作用函数,得到旧值
    26. oldVal = effectFn();
    27. }
    28. function traverse(value, seen = new Set()) {
    29. // 如果是原始数据 或者已经读取过, 不进行处理
    30. if (typeof value !== "object" || value === null || seen.has(value)) return;
    31. // 将数据添加到seen中,表示已经读取过,避免循环引用,陷入死循环
    32. seen.add(value);
    33. // 假设观察的是对象,使用for in 读取数据的每各属性值,递归调用traverse
    34. for (let key in value) {
    35. traverse(value[key], seen);
    36. }
    37. return value;
    38. }

    代码示例

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

    4.10立即执行 watch

  • 立即执行回调函数

  • 回调函数的执行时机

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

  1. / watch函数接收3个参数,
  2. // source是响应式数据,cb是数据变化执行的回调函数,options设置执行时机
  3. function watch(source, cb, options = {}) {
  4. let getter;
  5. // 如果第一个参数是函数,直接执行函数,读取返回值
  6. if (typeof source === "function") {
  7. getter = source;
  8. } else {
  9. // 不是函数,则读取对象的多个属性
  10. getter = () => traverse(source);
  11. }
  12. let newVal, oldVal;
  13. // 将scheduler调度函数,封装成 job 函数
  14. const job = () => {
  15. newVal = effectFn();
  16. // 当数据变化时,执行cb
  17. cb(newVal, oldVal);
  18. oldVal = newVal;
  19. };
  20. // 通过traverse来读取source的值
  21. const effectFn = effect(() => getter(), {
  22. lazy: true,
  23. scheduler: job
  24. });
  25. if (options.immediate) {
  26. // immediate属性为真,自动执行下job任务
  27. job();
  28. } else {
  29. oldVal = effectFn();
  30. }
  31. }

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

  1. watch(
  2. () => obj.foo,
  3. (nv, ov) => {
  4. console.log("foo的数据变化了", nv, ov); // 2 1
  5. },
  6. {
  7. // immediate : true,回调函数会立即执行一次
  8. flush: 'post'
  9. }
  10. );
  11. obj.foo++;
  • post : 回调函数需要将副作用函数放到微任务队列中
  • sync:实现同步执行
  • pre: 组件更新前执行 ```typescript 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: () => {

    1. if (options.flush === "post") {
    2. const p = Promise.resolve();
    3. p.then(job);
    4. } else {
    5. job();
    6. }

    } });

    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 同步执行函数”);

  1. <a name="vmzwu"></a>
  2. ### 4.11过期的副作用,可以被取消
  3. 正在执行的副作用,要能够被取消,否则会发生[“竞态”问题](https://juejin.cn/post/6844903863749705741)。该问题可以在原始的xhr的abort中解决,也可在axios封装的 isCancel 中取消请求。<br />![](https://cdn.nlark.com/yuque/0/2022/jpeg/737887/1652515510903-5ad8aa27-559a-40ae-9659-4102504fe0cb.jpeg)<br />因此需要一种让副作用过期的技术。<br />watch的**回调函数**现在接收到newValue和oldvalue2个参数,通过**设置第3个参数 onInvalidate 函数**,这个函数类似事件监听器。使用onInvalidate注册回调函数,该回调函数在当前副作用函数过期时执行。
  4. ```typescript
  5. function delay(time) {
  6. return new Promise((resolve) => {
  7. setTimeout(() => {
  8. resolve(time);
  9. }, time);
  10. });
  11. }
  1. // watch函数接收3个参数,
  2. // source是响应式数据,cb是数据变化执行的回调函数,options设置执行时机
  3. function watch(source, cb, options = {}) {
  4. let getter;
  5. // 如果第一个参数是函数,直接执行函数,读取返回值
  6. if (typeof source === "function") {
  7. getter = source;
  8. } else {
  9. // 不是函数,则读取对象的多个属性
  10. getter = () => traverse(source);
  11. }
  12. let newVal, oldVal;
  13. // cleanup 存储用户注册的过期回调
  14. let cleanup;
  15. // onInvalidate 函数
  16. function onInvalidate(fn) {
  17. // 将过期回调 存储到cleanup
  18. cleanup = fn;
  19. }
  20. // 将scheduler调度函数,封装成 job 函数
  21. const job = () => {
  22. newVal = effectFn();
  23. if (cleanup) {
  24. cleanup();
  25. }
  26. // 将onInvalidate作为回调的第3个参数,以便用户使用
  27. cb(newVal, oldVal, onInvalidate);
  28. oldVal = newVal;
  29. };
  30. // 通过traverse来读取source的值
  31. const effectFn = effect(() => getter(), {
  32. lazy: true,
  33. scheduler: () => {
  34. if (options.flush === "post") {
  35. const p = Promise.resolve();
  36. p.then(job);
  37. } else {
  38. job();
  39. }
  40. }
  41. });
  42. if (options.immediate) {
  43. // immediate属性为真,自动执行下job任务
  44. job();
  45. } else {
  46. oldVal = effectFn();
  47. }
  48. }

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

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

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

  1. onInvalidate(() => {
  2. flag = true;
  3. });
  4. // 模拟后边发送接口请求,比上次的提前返回
  5. initTime = initTime - 1000;
  6. const res = await delay(initTime);
  7. if (!flag) {
  8. finalData = res;
  9. // onInvalidate生效,则会显示第二次请求的结果,不会被前一次结果覆盖
  10. // 如果注释掉 onInvalidate,则最终会显示 第一次发生的请求
  11. document.getElementById("app").innerHTML = finalData;
  12. }
  13. console.log("watch 内 finalData", finalData);

} ); obj.foo++; obj.foo++;

  1. [代码实例](https://codesandbox.io/s/vue-design-15bzo9?file=/4part/4.11.js)
  2. <a name="fr1eR"></a>
  3. ## 5.对象类型的响应式方案reactive/proxy
  4. <a name="MmNBi"></a>
  5. ### 5.1理解Proxy和Reflect对象
  6. ![](https://cdn.nlark.com/yuque/0/2022/jpeg/737887/1652668581615-0c23370c-5f76-4fac-9246-89eec8d8a486.jpeg)
  7. - Proxy只能代理对象类型
  8. - 代理是指,能够对对象的基本操作进行拦截,通过上面虚线定义的那些方法处理对象。
  9. - Proxy只能拦截对象的基本操作。复合操作处理不了,如obj.foo();
  10. ```typescript
  11. function fn(name) {
  12. console.log(`my name is ${name}, ${this.name}`);
  13. }
  14. const p = new Proxy(fn, {
  15. // 使用apply 拦截函数的调用
  16. apply(target, thisArg, argArray) {
  17. console.log(thisArg, argArray, "apply调用函数");
  18. target.call(thisArg, argArray);
  19. }
  20. });
  21. p.call({ name: "CallName" }, "北鸟南游"); // my name is 北鸟南游, CallName

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

Reflect对象中的receiver重要性

  1. const Obj = {
  2. get count() {
  3. return this.c;
  4. }
  5. };
  6. console.log(Reflect.get(Obj, "count", { c: 99 })); //99
  7. const po = new Proxy(Obj, {
  8. get(target, key, receiver) {
  9. if (key === "c") return 6;
  10. // return target[key]; //获取不到count的值,target找不到c
  11. return Reflect.get(target, key, receiver); //通过recevier可以改变属性访问器getter的this
  12. }
  13. });
  14. 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]]就有新定义,所以是异质对象。

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

  1. const obj = { foo: 1 };
  2. const po = new Proxy(obj, {
  3. deleteProperty(target, key) {
  4. return Reflect.deleteProperty(target, key);
  5. }
  6. });
  7. console.log(po.foo); // 1
  8. delete po.foo;
  9. 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

  1. function* enumerate(obj) {
  2. let visited=new Set;
  3. for (let key of Reflect.ownKeys(obj)) {
  4. if (typeof key === "string") {
  5. let desc = Reflect.getOwnPropertyDescriptor(obj,key);
  6. if (desc) {
  7. visited.add(key);
  8. if (desc.enumerable) yield key;
  9. }
  10. }
  11. }
  12. let proto = Reflect.getPrototypeOf(obj)
  13. if (proto === null) return;
  14. for (let protoName of Reflect.enumerate(proto)) {
  15. if (!visited.has(protoName)) yield protoName;
  16. }
  17. }

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

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

  1. const obj = { count: 1 };
  2. const po = new Proxy(obj, {
  3. // 拦截读取操作
  4. get(target, key, receiver) {
  5. // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
  6. track(target, key);
  7. // 返回属性值
  8. return Reflect.get(target, key, receiver);
  9. },
  10. // 拦截设置操作
  11. set(target, key, newVal) {
  12. // 设置属性值
  13. target[key] = newVal;
  14. trigger(target, key);
  15. },
  16. has(target, key) {
  17. track(target, key);
  18. return Reflect.has(target, key);
  19. },
  20. ownKeys(target) {
  21. //将副作用函数 与 ITERATE_KEY 关联
  22. track(target, ITERATE_KEY);
  23. return Reflect.ownKeys(target);
  24. },
  25. deleteProperty(target, key) {
  26. // 检查被删除的属性是否是对象自身的属性
  27. const hadKey = Object.prototype.hasOwnProperty.call(target, key);
  28. // 使用Reflect.deleteProperty 完成属性删除
  29. const res = Reflect.deleteProperty(target, key);
  30. if (res && hadKey) {
  31. trigger(target, key, "DELETE");
  32. }
  33. }
  34. });
  35. effect(() => {
  36. // console.log(po);
  37. for (const key in po) {
  38. console.log("key", key);
  39. }
  40. });
  41. po.bar = 2;

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

  1. function trigger(target, key) {
  2. const depsMap = targetMap.get(target);
  3. if (!depsMap) return;
  4. const effects = depsMap.get(key);
  5. //为了避免重复添加删除,造成死循环
  6. const effectsToRun = new Set();
  7. effects &&
  8. effects.forEach((effect) => {
  9. if (effect !== activeEffect) {
  10. effectsToRun.add(effect);
  11. }
  12. });
  13. // 删除操作会影响for...in循环次数
  14. // if (type === "ADD" || type === "DELETE") {
  15. // 取到与 ITERATE_KEY 相关的副作用函数
  16. const iterateEffects = depsMap.get(ITERATE_KEY);
  17. iterateEffects &&
  18. iterateEffects.forEach((effectFn) => {
  19. if (effectFn !== activeEffect) {
  20. effectsToRun.add(effectFn);
  21. }
  22. });
  23. // }
  24. effectsToRun.forEach((fn) => {
  25. // 如果用户使用的effect有 scheduler 配置,则走调度逻辑
  26. if (fn.options.scheduler) {
  27. fn.options.scheduler(fn);
  28. } else {
  29. fn();
  30. }
  31. });
  32. }

代码实例

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

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

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

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

  1. // 更新依赖
  2. function trigger(target, key, type) {
  3. const depsMap = targetMap.get(target);
  4. if (!depsMap) return;
  5. const effects = depsMap.get(key);
  6. //为了避免重复添加删除,造成死循环
  7. const effectsToRun = new Set();
  8. effects &&
  9. effects.forEach((effect) => {
  10. if (effect !== activeEffect) {
  11. effectsToRun.add(effect);
  12. }
  13. });
  14. // 删除操作会影响for...in循环次数
  15. if (type === "ADD") {
  16. // 取到与 ITERATE_KEY 相关的副作用函数
  17. const iterateEffects = depsMap.get(ITERATE_KEY);
  18. iterateEffects &&
  19. iterateEffects.forEach((effectFn) => {
  20. if (effectFn !== activeEffect) {
  21. effectsToRun.add(effectFn);
  22. }
  23. });
  24. }
  25. effectsToRun.forEach((fn) => {
  26. // 如果用户使用的effect有 scheduler 配置,则走调度逻辑
  27. if (fn.options.scheduler) {
  28. fn.options.scheduler(fn);
  29. } else {
  30. fn();
  31. }
  32. });
  33. }

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

代理对象的删除操作

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

  1. const po = new Proxy(obj, {
  2. deleteProperty(target, key) {
  3. // 检查被删除的属性是否是对象自身的属性
  4. const hadKey = Object.prototype.hasOwnProperty.call(target, key);
  5. // 使用Reflect.deleteProperty 完成属性删除
  6. const res = Reflect.deleteProperty(target, key);
  7. if (res && hadKey) {
  8. trigger(target, key, "DELETE");
  9. }
  10. return res;
  11. }
  12. });

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

  1. function trigger(target, key, type) {
  2. const depsMap = targetMap.get(target);
  3. if (!depsMap) return;
  4. const effects = depsMap.get(key);
  5. //为了避免重复添加删除,造成死循环
  6. const effectsToRun = new Set();
  7. effects &&
  8. effects.forEach((effect) => {
  9. if (effect !== activeEffect) {
  10. effectsToRun.add(effect);
  11. }
  12. });
  13. console.log(type, key);
  14. // 删除操作会影响for...in循环次数
  15. if (type === "ADD" || type === "DELETE") {
  16. // 取到与 ITERATE_KEY 相关的副作用函数
  17. const iterateEffects = depsMap.get(ITERATE_KEY);
  18. iterateEffects &&
  19. iterateEffects.forEach((effectFn) => {
  20. if (effectFn !== activeEffect) {
  21. effectsToRun.add(effectFn);
  22. }
  23. });
  24. }
  25. effectsToRun.forEach((fn) => {
  26. // 如果用户使用的effect有 scheduler 配置,则走调度逻辑
  27. if (fn.options.scheduler) {
  28. fn.options.scheduler(fn);
  29. } else {
  30. fn();
  31. }
  32. });
  33. }

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

5.4合理的触发响应

NaN引起的不必要更新

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

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

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

  1. const p = new Proxy(obj, {
  2. //...
  3. set(target, key, newVal, receiver) {
  4. // 先存储下旧值
  5. const oldVal = target[key];
  6. const res = Reflect.set(target, key, newVal, receiver);
  7. // 比较新值和旧值,只有在不相等的时候才触发响应
  8. if (oldVal !== newVal) {
  9. trigger(target, key);
  10. }
  11. return res;
  12. },
  13. //...
  14. })

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

  1. NaN === NaN; // false
  2. NaN !== NaN; // true

继续修改setter操作函数。

  1. const p = new Proxy(obj, {
  2. //...
  3. set(target, key, newVal, receiver) {
  4. // 先存储下旧值
  5. const oldVal = target[key];
  6. const res = Reflect.set(target, key, newVal, receiver);
  7. // 比较新值和旧值,只有在不相等的时候才触发响应;并且不是NaN
  8. if (oldVal !== newVal && (oldVal ===oldVal || newVal === newVal) ) {
  9. trigger(target, key);
  10. }
  11. return res;
  12. },
  13. //...
  14. })

代码示例

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

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

  1. const obj = { foo: 1 };
  2. const child = reactive(obj);
  3. const parent = reactive({ bar: 2 });
  4. //设置parent 为child的原型
  5. Object.setPrototypeOf(child, parent);
  6. console.log("判断obj的原型是不是parent", Object.getPrototypeOf(obj) === parent);
  7. effect(() => {
  8. console.log(child.bar);
  9. });
  10. child.bar = 3; //这里的修改,会触发2次effect的执行
  • 给child设置了parent作为原型。
  • child和parent都是响应式对象
  • 修改child.bar属性,由于child自身上没有bar属性,会找到原型对象parent上。parent也是响应式对象,从而就触发了2次effect。

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

  1. // child 的set拦截函数
  2. set(target, key, newVal, receiver){
  3. // target是原始对象 obj
  4. // receiver是代理对象 child
  5. }
  6. // parent 的set拦截函数
  7. set(target, key, newVal, receiver){
  8. // target是原始对象 原型proto 即parent
  9. // receiver是代理对象 child
  10. }

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

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

修改reactive的getter和setter拦截函数

  1. function reactive(obj) {
  2. return new Proxy(obj, {
  3. // 拦截读取操作
  4. get(target, key, receiver) {
  5. if (key === "raw") { // 设置raw属性,访问该属性时,获取到被代理的原始值
  6. return target;
  7. }
  8. // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
  9. track(target, key);
  10. // 返回属性值
  11. return Reflect.get(target, key, receiver);
  12. },
  13. // 拦截设置操作
  14. set(target, key, newVal, receiver) {
  15. const oldVal = target[key];
  16. // 如果属性不存在,则说明是在添加新的属性,否则是设置已存在的属性
  17. const type = Object.prototype.hasOwnProperty.call(target, key)
  18. ? "SET"
  19. : "ADD";
  20. // 设置属性值
  21. const res = Reflect.set(target, key, newVal, receiver);
  22. console.log(target === receiver.raw);
  23. if (target === receiver.raw) { // 排除掉原型链上属性的更新
  24. if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
  25. trigger(target, key, type);
  26. }
  27. }
  28. return res;
  29. }
  30. // ...
  31. }

代码示例

5.5浅响应和深响应

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

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

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

  1. function reactive(obj){
  2. return new Proxy(obj, {
  3. get(target, key, receiver){
  4. if(key === "raw"){
  5. return target
  6. }
  7. track(target, key);
  8. // 得到返回结果
  9. const res = Reflect.get(target, key, receiver);
  10. if(typeof res === "object" && res !== null){
  11. //如果是对象类型,并且不是null,继续调用reactive
  12. return reavtive(res)
  13. }
  14. return res;
  15. }
  16. })
  17. }

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

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

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

  1. function createReactive(obj, isShallow = false){
  2. return new Proxy(obj, {
  3. // 拦截get
  4. get(target, key, receiver){
  5. if(key === "raw"){
  6. return target
  7. }
  8. const res = Reflect.get(target, key, receiver);
  9. track(target, key);
  10. // 如果isShallow为真, 浅代理,直接返回res对象
  11. if(isShallow){
  12. return res
  13. }
  14. if(typeof res === "object" && res !== null){
  15. return reactive(res)
  16. }
  17. return res;
  18. }
  19. })
  20. }
  21. function reactive(obj){
  22. return createReactive(obj); //深代理
  23. }
  24. function shallowReactive(obj){
  25. return createReactive(obj, true); //浅代理
  26. }

代码示例

5.6只读和浅只读

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

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

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

  1. function createReactive(obj, isShallow = false, isReadonly = false){
  2. return new Proxy(obj, {
  3. // 设置的拦截
  4. set(target, key, newVal, receiver){
  5. // 如果是只读, isReadonly为真
  6. if(isReadonly){
  7. console.warn(`${key} 是只读的,不能修改`)
  8. return true;
  9. }
  10. const oldVal = target[key];
  11. // 如果属性不存在,则说明是在添加新的属性,否则是设置已存在的属性
  12. const type = Object.prototype.hasOwnProperty.call(target, key)
  13. ? "SET"
  14. : "ADD";
  15. // 设置属性值
  16. const res = Reflect.set(target, key, newVal, receiver);
  17. if (target === receiver.raw) {
  18. if (oldVal !== newVal && (oldVal === oldVal || newVal === newVal)) {
  19. trigger(target, key, type);
  20. }
  21. }
  22. return res;
  23. },
  24. deleteProperty(target, key) {
  25. if (isReadonly) {
  26. console.warn(`属性 ${key} 是只读的`);
  27. return true;
  28. }
  29. const hadKey = Object.prototype.hasOwnProperty.call(target, key);
  30. const res = Reflect.deleteProperty(target, key);
  31. if (res && hadKey) {
  32. trigger(target, key, "DELETE");
  33. }
  34. return res;
  35. }
  36. }
  37. })
  38. }

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

  1. function createReactive(obj, isShallow = false, isReadonly = false){
  2. return new Proxy(obj, {
  3. // 拦截读取操作
  4. get(target, key, receiver) {
  5. if (key === "raw") {
  6. return target;
  7. }
  8. // 非只读的时候才需要建立响应联系
  9. if (!isReadonly) {
  10. track(target, key);
  11. }
  12. const res = Reflect.get(target, key, receiver);
  13. if (isShallow) {
  14. return res;
  15. }
  16. if (typeof res === "object" && res !== null) {
  17. // 深响应
  18. return reactive(res);
  19. }
  20. return res;
  21. }
  22. }
  23. }

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

  1. function createReactive(obj, isShallow = false, isReadonly = false){
  2. return new Proxy(obj, {
  3. // 拦截读取操作
  4. get(target, key, receiver) {
  5. if (key === "raw") {
  6. return target;
  7. }
  8. // 非只读的时候才需要建立响应联系
  9. if (!isReadonly) {
  10. track(target, key);
  11. }
  12. const res = Reflect.get(target, key, receiver);
  13. if (isShallow) {
  14. return res;
  15. }
  16. if (typeof res === "object" && res !== null) {
  17. // 深只读和深响应
  18. return isReadonly ? readonly(res) : reactive(res);
  19. }
  20. return res;
  21. }
  22. }
  23. }
  24. function readonly(obj){
  25. return createReactive(obj, false, true)
  26. }
  27. // 只需要修改第二个参数即可,浅响应,并且做了只读处理
  28. function shallowReadonly{
  29. return createReactive(obj, true, true)
  30. }

代码示例

5.7数组 5.8Map和Set

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

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

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

引入ref概念

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

  1. // 封装ref函数
  2. function ref(val){
  3. //在ref内创建包裹对象
  4. const wrapper={
  5. value: val
  6. }
  7. // 将包裹对象变成响应式
  8. return reactive(wrapper)
  9. }

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

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

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

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

代码示例

转换ref的方法toRef和toRefs

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

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

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

  1. const obj = reactive({foo:1, bar:2});
  2. // newObj对象下具有obj对象的同名属性,每个属性值都是对象
  3. const newObj = {
  4. foo: {
  5. get value(){
  6. return obj.foo
  7. }
  8. },
  9. bar: {
  10. get value(){
  11. return obj.bar
  12. }
  13. }
  14. }
  15. effect(()=>{
  16. console.log(newObj.foo)
  17. })
  18. obj.foo = 3

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

  1. function toRef(obj, key){
  2. const wrapper={
  3. get value(){
  4. return obj[key]
  5. }
  6. }
  7. //使用Object.defineProperty在wrapper对象上定义一个不可枚举的属性__is_Ref
  8. Object.defineProperty(wrapper, "__is_Ref", {
  9. value: true
  10. })
  11. return wrapper
  12. }

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

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

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

  1. function toRef(obj, key){
  2. const wrapper={
  3. get value(){
  4. return obj[key]
  5. },
  6. // 可以设置值
  7. set value(val){
  8. obj[key] = val;
  9. }
  10. }
  11. //使用Object.defineProperty在wrapper对象上定义一个不可枚举的属性__is_Ref
  12. Object.defineProperty(wrapper, "__is_Ref", {
  13. value: true
  14. })
  15. return wrapper
  16. }

代码示例

自动脱ref方法proxyRefs

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

  1. function proxyRefs(target){
  2. return new Proxy(target, {
  3. get(target, key, receiver){
  4. const value = Reflect.get(target, key, receiver);
  5. //如果是Ref,则获取的是 value 值
  6. return value.__v_isRef ? value.value : value;
  7. },
  8. set(target, key, newVal, receiver){
  9. const value = target[key];
  10. // 如果是Ref,则设置其对应的 value 属性值
  11. if(value.__v_isRef){
  12. value.value = newValue;
  13. return true
  14. }
  15. return Reflect.set(target, key, newVal, receiver)
  16. }
  17. })
  18. }
  • 第6行,设置getter的去value属性
  • 第13行,设置setter的去value属性

代码示例

第7-11章 渲染器

7实现自定义渲染器

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

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

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

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

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

在vue中结合响应式数据。

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

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

  1. <script src="https://unpkg.com/@vue/reactivity@3.2.35/dist/reactivity.global.js"></script>
  2. <script>
  3. const {effect, ref} = VueReactivity;
  4. function renderer(domString, container){
  5. container.innerHTML = domString;
  6. }
  7. let count = ref(1);
  8. // 使用方法
  9. effect(() => {
  10. renderer(
  11. `<h1>vue3 renderer, ${count.value}</h1>`,
  12. document.getElementById("app")
  13. );
  14. });
  15. setTimeout(() => {
  16. count.value++;
  17. }, 400);
  18. </script>

代码示例

7.2渲染器基本概念

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

  1. function createRenderer(){
  2. function render(vnode, container){
  3. }
  4. function hydrate(vnode, container){
  5. }
  6. return { render, hydrate }
  7. }

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

用渲染器执行任务

  1. const renderer = createRenderer();
  2. // 渲染任务
  3. renderer.render(vnode, container)
  4. // 第二次渲染
  5. renderer.render(newVnode, container)
  • 首先用createRenderer创建一个渲染器renderer,接着调用render函数进行渲染工作。
  • 渲染器除了挂载节点外,还有多次渲染的更新动作。更新节点即patch的过程

    1. function createRenderer(){
    2. function render(vnode, container){
    3. if(vnode){ //vnode存在,进行挂载动作
    4. // vnode:新节点, container._vnode:旧节点,使用patch函数打补丁
    5. patch(container._vnode, vnode, container)
    6. } else {//vnode不存在
    7. if(container._vnode){ //container._vnode存在,说明是卸载过程
    8. //需要将container内的DOM清空
    9. container.innerHTML = "";
    10. }
    11. }
    12. container._vnode = vnode;
    13. }
    14. function patch(n1, n2, container){}
    15. return { render }
    16. }

    patch函数的三个参数

  • n1:旧vnode

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

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

  1. const renderer = createRenderer();
  2. // first
  3. renderer.render(vnode1, container);
  4. // second
  5. renderer.render(vnode2, container);
  6. // third
  7. renderer.render(null, container);

7.3自定义渲染器

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

  1. const vnode = {
  2. type: "h1",
  3. children: "hello"
  4. }

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

  1. const vnode = {
  2. type: "h1",
  3. children: "hello"
  4. }
  5. const renderer = createRenderer();
  6. renderer.render(vnode, container);
  7. function createRenderer(){
  8. function patch(n1, n2, container){
  9. if(!n1){
  10. mountElement(n2, container)
  11. } else {
  12. // n1存在,进行更新操作
  13. }
  14. }
  15. funtion mountElement(vnode, container){
  16. // 创建DOM元素
  17. let el = document.createElement(vnode.type);
  18. // 处理子节点,如果子节点是字符串,代表元素具有文本节点
  19. if(typeof vnode.children === "string"){
  20. //设置元素的textContent属性即可
  21. el.textContent = vnode.children;
  22. }
  23. //将元素添加到容器中
  24. container.appendChild(el)
  25. }
  26. function render(vnode, container){
  27. if(vnode){ //vnode存在,进行挂载动作
  28. // vnode:新节点, container._vnode:旧节点,使用patch函数打补丁
  29. patch(container._vnode, vnode, container)
  30. } else {//vnode不存在
  31. if(container._vnode){ //container._vnode存在,说明是卸载过程
  32. //需要将container内的DOM清空
  33. container.innerHTML = "";
  34. }
  35. }
  36. container._vnode = vnode;
  37. }
  38. return {
  39. render
  40. }
  41. }

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

  1. function createRenderer(options){
  2. //通过options传入特定API
  3. const {createElement, insert, setElementText} = options;
  4. //在mountElement函数中,使用特定API
  5. function mountElement(vnode, container){
  6. // 调用createElement函数创建元素
  7. const el = createElement(vnode.type)
  8. if(typeof vnode.children === "string"){
  9. //调用setElementText设置元素的文本节点
  10. setElementText(el, vnode.children)
  11. }
  12. //调用insert函数将元素插入到容器
  13. insert(el, container)
  14. }
  15. }
  16. // 自定义传入打印流程API
  17. const renderer = createRenerer({
  18. createElement(tag){
  19. console.log("创建元素",tag)
  20. return {tag}
  21. },
  22. setElementText(el, text){
  23. console.log(`设置${JSON.stringify(el)} 的文本内容: ${text}`)
  24. el.text = text;
  25. },
  26. insert(el, parent, anchor=null){
  27. console.log(`将 ${JSON.stringify(el)} 添加到 ${JSON.stringify(parent)} 下`)
  28. parent.children = el
  29. }
  30. })

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

8.挂载和更新

8.1处理子节点和元素属性

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

  1. const vnode = {
  2. type: 'div',
  3. children: [{},{}]
  4. }

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

  1. function mountedElement(vnode, container){
  2. const el= createElement(vnode.type);
  3. // 处理vnode 的children属性
  4. if (typeof vnode.children === "string") {
  5. setElementText(el, vnode.children);
  6. } else if (Array.isArray(vnode.children)) {
  7. + vnode.children.forEach((child) => {
  8. + patch(null, child, el);
  9. + });
  10. }
  11. }

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

  1. function mountedElement(vnode, container){
  2. const el= createElement(vnode.type);
  3. // 处理vnode 的children属性
  4. if (typeof vnode.children === "string") {
  5. setElementText(el, vnode.children);
  6. } else if (Array.isArray(vnode.children)) {
  7. vnode.children.forEach((child) => {
  8. patch(null, child, el);
  9. });
  10. }
  11. // 处理vnode 的props属性
  12. + if (vnode.props) {
  13. + for (let key in vnode.props) {
  14. + el.setAttribute(key, vnode.props[key]);
  15. + }
  16. + }
  17. }

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

挂载元素的流程

读《vue3设计与实现》笔记 - 图12
代码实例

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;
读《vue3设计与实现》笔记 - 图14
vnode的props.disabled值为空字符串,如果在渲染器中调用setAttribute函数设置属性:
el.setAttribute("disabled", ""),这样可以给按钮设置禁用状态。
但是当用户设置<button :disabled="false">不禁用按钮</button>时,经过转换为vnode后

  1. const button = {
  2. type: "button",
  3. props: {
  4. disabled: false // 不禁用按钮
  5. }
  6. }

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

  • 优先设置元素DOM Properties
  • 当值为空字符串时,要手动改正为true。 ```javascript function mountElement(vnode, container) { const el = createElement(vnode.type); // 处理vnode 的children属性 if (typeof vnode.children === “string”) {
    1. setElementText(el, vnode.children);
    } else if (Array.isArray(vnode.children)) {
    1. console.log("child", vnode.children);
    2. vnode.children.forEach((child) => {
    3. patch(null, child, el);
    4. });
    } // 处理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); }
    1. [代码示例](https://codesandbox.io/s/vue-design-15bzo9?file=/8part/8.3.js)
    2. <a name="Mbi4J"></a>
    3. #### 处理特殊属性,只能用setAttribute
    4. 但是这样处理还是有问题,有一些DOM Properties属性是只读的。 `<input form="form1" />`,input标签的form属性(HTML Attributes),它对应的DOM Propertiesel.form,但是el.form是只读属性,那么就只能通过setAttribute函数来设置它。
    5. ```javascript
    6. function shouldSetProps(el, key, value) {
    7. // 特殊处理只能通过setAttribute函数设置的属性
    8. if (key === "form" && el.tagName === "INPUT") return false;
    9. return key in el;
    10. }
    11. function mountedElement(vnode, container){
    12. // ...
    13. // 处理vnode 的props属性
    14. if (vnode.props) {
    15. for (let key in vnode.props) {
    16. const value = vnode.props[key];
    17. // 先设置 properties属性
    18. // 通过shouldSetProps方法进行判断,排除掉一些只能用setAttribute设置的属性
    19. if (shouldSetProps(el, key, value)) {
    20. const type = typeof el[key];
    21. //如果是boolean类型,且值为空,手动修复为 true
    22. if (type === "boolean" && value === "") {
    23. el[key] = true;
    24. } else {
    25. el[key] = value;
    26. }
    27. } else {
    28. // 如果没有对应的dom properties,则使用setAttribute函数设置属性
    29. el.setAttributes(key, vnode.props[key]);
    30. }
    31. }
    32. }
    33. //...
    34. }
    代码示例

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

    将属性的设置操作提取到渲染器选项中,通过创建renderer实例的options进行设置处理。增加了灵活性。
    1. const renderer = createRenderer({
    2. createElement(tag) {
    3. return document.createElement(tag)
    4. },
    5. setElementText(el, text) {
    6. el.textContent = text
    7. },
    8. insert(el, parent, anchor = null) {
    9. parent.insertBefore(el, anchor)
    10. },
    11. patchProps(el, key, preValue, nextValue) {
    12. if (shouldSetAsProps(el, key, nextValue)) {
    13. const type = typeof el[key]
    14. if (type === 'boolean' && nextValue === '') {
    15. el[key] = true
    16. } else {
    17. el[key] = nextValue
    18. }
    19. } else {
    20. el.setAttribute(key, nextValue)
    21. }
    22. }
    23. })
    代码示例

    8.4class属性设置

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

    序列化处理class

    方式1:指定class为字符串值
    读《vue3设计与实现》笔记 - 图15
    方式2:指定class为对象
    读《vue3设计与实现》笔记 - 图16
    方式3:class可以包含上面2中类型的数组
    读《vue3设计与实现》笔记 - 图17
    class可以包含多种类型值,需要使用normalizeClass函数将不同类型的class值转为正常的字符串。
    通过normalizeClass转换vnode的class值
    读《vue3设计与实现》笔记 - 图18

    设置class属性

    作者对比3种设置class方式【el.className, el.setAttributes, classList】的性能,发现el.className性能最佳。
    调整patchProps函数 ```javascript 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); } } })
    1. [完整代码示例](https://codesandbox.io/s/vue-design-15bzo9?file=/8part/8.4.js)<br />其实处理class需要特殊格式化处理,还有style也需要类似的处理,详情可以查看[vue源码](https://github.com/shenshuai89/core/blob/main/packages/shared/src/normalizeProp.ts#L6)
    2. <a name="WMtUp"></a>
    3. ### 8.5卸载操作
    4. 前面4节介绍了挂载操作,这节介绍卸载操作。<br />卸载发生在更新阶段,更新指的是在初次挂载完成后,后续渲染触发的属性或值的变化。
    5. ```javascript
    6. // 初次挂载
    7. renderer.render(vnode, document.querySelector("#app"));
    8. // 更新
    9. renderer.render(newVnode, document.querySelector("#app"))
    10. // 卸载
    11. renderer.render(null, document.querySelector("#app"));
    当给render的第一个参数设置为null,就是执行的卸载。
    在前面mountElement函数中的render方法,如果container._vnode不存在,则直接container.innerHTML = “”;
    1. function render(vnode, container){
    2. if(vnode){ //vnode存在,进行挂载动作
    3. // vnode:新节点, container._vnode:旧节点,使用patch函数打补丁
    4. patch(container._vnode, vnode, container)
    5. } else {//vnode不存在
    6. if(container._vnode){ //container._vnode存在,说明是卸载过程
    7. //需要将container内的DOM清空
    8. container.innerHTML = "";
    9. }
    10. }
    11. container._vnode = vnode;
    12. }
    这么做是不严谨的,主要原因有:
  • 容器的内容可能有某个或多个组件渲染的,当卸载操作发生时,应当正确的调用这些组件的beforeUnmount、unmounted等生命周期的函数
  • 还有些元素存在自定义指令,应该在卸载的时候正确执行对应的指令钩子。
  • 使用innerHTML清空容器元素,不会移除绑定在DOM元素上的事件处理函数。

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

  1. function mountElement(vnode, container){
  2. //...
  3. function render(vnode, container) {
  4. console.log("render", vnode, container);
  5. // vnode存在,说明是挂在创建阶段
  6. if (vnode) {
  7. patch(container._vnode, vnode, container);
  8. } else {
  9. // 新vnode节点不存在,并且判断下旧的_vnode存在,说明是卸载阶段
  10. if (container._vnode) {
  11. // 重新调整卸载操作,根据vnode.el值 移除真实DOM内容
  12. const el = container._vnode.el;
  13. // 获取el的父元素
  14. const parent = el.parentNode;
  15. if (parent) parent.removeChild(el);
  16. }
  17. }
  18. // 把 vnode 存储到 container._vnode 下,作为后续渲染中的旧 vnode节点存在
  19. container._vnode = vnode;
  20. }
  21. //...
  22. }

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

  1. function unmount(vnode){
  2. const parent = vnode.el.parentNode;
  3. if(parent){
  4. parent.removeChild(vnode.el);
  5. }
  6. }

代码示例

8.6区分vnode类型

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

  1. function patch(n1, n2, container){
  2. if(!n1){
  3. mountElement(n2, container);
  4. } else {
  5. // update
  6. }
  7. }

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

  1. function patch(n1, n2, container){
  2. if(n1 && n1.type !== n2.type){
  3. // 新旧节点的类型不同,直接将旧的vnode节点n1卸载
  4. unmount(n1);
  5. n1 = null;
  6. }
  7. if(!n1){
  8. mountElement(n2, container);
  9. }else {
  10. // update
  11. }
  12. }

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

  1. function mountElement(vnode, container){
  2. //...
  3. function patch(n1, n2, container) {
  4. if (n1 && n1.type !== n2.type) {
  5. unmount(n1);
  6. n1 = null;
  7. }
  8. const { type } = n2;
  9. // 根据不同type类型,分情况处理,如果是string,直接更新element,如果是对象,则更新组件
  10. if (typeof type === "string") {
  11. if (!n1) {
  12. mountElement(n2, container);
  13. } else {
  14. patchElement(n1, n2);
  15. }
  16. } else if (typeof type === "object") {
  17. //如果n2.type的值的类型是对象,表示的是组件
  18. } else if (type === "xxx") {
  19. // 处理其它类型的值
  20. }
  21. }
  22. // ...
  23. }

代码示例

8.7事件处理

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

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

  1. const vnode = {
  2. type: "p",
  3. props: {
  4. onClick: ()=>{
  5. alert("clicked");
  6. }
  7. },
  8. children: 'text'
  9. }

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

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

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

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

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

处理特殊事件属性

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

  1. patchProps(el, key, prevValue, nextValue) {
  2. if (/^on/.test(key)) {
  3. const invokers = el._vei || (el._vei = {})
  4. let invoker = invokers[key]
  5. const name = key.slice(2).toLowerCase()
  6. if (nextValue) {
  7. if (!invoker) {
  8. // el._evi设置成对象
  9. invoker = el._vei[key] = (e) => {
  10. // 一个事件类型还可以绑定多个事件处理函数。因此在vnode的props中存在数组情况
  11. if (Array.isArray(invoker.value)) {
  12. invoker.value.forEach(fn => fn(e))
  13. } else {
  14. invoker.value(e)
  15. }
  16. }
  17. invoker.value = nextValue
  18. el.addEventListener(name, invoker)
  19. } else {
  20. invoker.value = nextValue
  21. }
  22. } else if (invoker) {
  23. el.removeEventListener(name, invoker)
  24. }
  25. } else if (key === 'class') {
  26. el.className = nextValue || ''
  27. } else if (shouldSetAsProps(el, key, nextValue)) {
  28. const type = typeof el[key]
  29. if (type === 'boolean' && nextValue === '') {
  30. el[key] = true
  31. } else {
  32. el[key] = nextValue
  33. }
  34. } else {
  35. el.setAttribute(key, nextValue)
  36. }
  37. }

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

  1. const vnode = {
  2. type: "p",
  3. props: {
  4. onClick:[
  5. ()=>{
  6. alert("111")
  7. },
  8. ()=>{
  9. alert("222")
  10. }
  11. ]
  12. },
  13. children: "text"
  14. }

代码示例

8.8事件冒泡和更新时机

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

更新patchProps方法

  1. patchProps(el, key, prevValue, nextValue) {
  2. if (/^on/.test(key)) {
  3. const invokers = el._vei || (el._vei = {})
  4. let invoker = invokers[key]
  5. const name = key.slice(2).toLowerCase()
  6. if (nextValue) {
  7. if (!invoker) {
  8. invoker = el._vei[key] = (e) => {
  9. + console.log(e.timeStamp) // 事件触发时间
  10. + console.log(invoker.attached) //事件绑定时间
  11. + if (e.timeStamp < invoker.attached) return
  12. if (Array.isArray(invoker.value)) {
  13. invoker.value.forEach(fn => fn(e))
  14. } else {
  15. invoker.value(e)
  16. }
  17. }
  18. invoker.value = nextValue
  19. + invoker.attached = performance.now()
  20. el.addEventListener(name, invoker)
  21. } else {
  22. invoker.value = nextValue
  23. }
  24. } else if (invoker) {
  25. el.removeEventListener(name, invoker)
  26. }
  27. } else if (key === 'class') {
  28. el.className = nextValue || ''
  29. } else if (shouldSetAsProps(el, key, nextValue)) {
  30. const type = typeof el[key]
  31. if (type === 'boolean' && nextValue === '') {
  32. el[key] = true
  33. } else {
  34. el[key] = nextValue
  35. }
  36. } else {
  37. el.setAttribute(key, nextValue)
  38. }
  39. }

代码示例

8.9 更新子节点

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

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

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

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

一个vnode的子节点有3种可能,那么当渲染器更新时,新旧子节点都分别是3种可能。
读《vue3设计与实现》笔记 - 图19
用代码实现更新的过程

  1. function patchElement(n1, n2) {
  2. const el = n2.el = n1.el
  3. const oldProps = n1.props
  4. const newProps = n2.props
  5. // 更新props
  6. for (const key in newProps) {
  7. if (newProps[key] !== oldProps[key]) {
  8. patchProps(el, key, oldProps[key], newProps[key])
  9. }
  10. }
  11. for (const key in oldProps) {
  12. if (!(key in newProps)) {
  13. patchProps(el, key, oldProps[key], null)
  14. }
  15. }
  16. // 更新children,是对一个元素进行patch打补丁的最后一步操作
  17. patchChildren(n1, n2, el)
  18. }

接下来实现patchChildren函数。

新的children类型是字符串

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

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

新的子节点类型是数组

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

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

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

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

    ```javascript 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属性值:

  1. // 文本节点的type标识
  2. const Text = Symbol();
  3. const TextVnode = {
  4. type: Text;
  5. children: "text text"
  6. }
  7. // 注释节点的type标识
  8. const Comment = Symbol();
  9. const commentVnode = {
  10. type: Comment,
  11. children: "commentVnode"
  12. }

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

  1. function patch(n1, n2, container) {
  2. if (n1 && n1.type !== n2.type) {
  3. unmount(n1)
  4. n1 = null
  5. }
  6. const { type } = n2
  7. if (typeof type === 'string') {
  8. if (!n1) {
  9. mountElement(n2, container)
  10. } else {
  11. patchElement(n1, n2)
  12. }
  13. } else if (type === Text) {
  14. if (!n1) {
  15. // 创建文本节点
  16. const el = n2.el = document.createTextNode(n2.children)
  17. // 将文本节点插入到容器中
  18. insert(el, container)
  19. } else {
  20. // 如果旧vnode存在,只需要更新旧节点的内容
  21. const el = n2.el = n1.el
  22. if (n2.children !== n1.children) {
  23. el.nodeValue = n2.children;
  24. }
  25. }
  26. }
  27. }

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

  1. const renderer = createRenderer({
  2. //...
  3. createTextNode(text){
  4. return document.createTextNode(text)
  5. },
  6. setText(){
  7. el.nodeValue = text;
  8. }
  9. //...
  10. })

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

  1. function patch(n1, n2, container) {
  2. if (n1 && n1.type !== n2.type) {
  3. unmount(n1)
  4. n1 = null
  5. }
  6. const { type } = n2
  7. if (typeof type === 'string') {
  8. if (!n1) {
  9. mountElement(n2, container)
  10. } else {
  11. patchElement(n1, n2)
  12. }
  13. } else if (type === Text) {
  14. if (!n1) {
  15. const el = n2.el = createText(n2.children)
  16. insert(el, container)
  17. } else {
  18. const el = n2.el = n1.el
  19. if (n2.children !== n1.children) {
  20. setText(el, n2.children)
  21. }
  22. }
  23. }
  24. }

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

8.11 Fragment多根节点标签

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

  1. <template>
  2. <li>1</li>
  3. <li>1</li>
  4. <li>1</li>
  5. </template>
  6. // 对应的虚拟节点 vnode
  7. const vnode = {
  8. type: Fragment,
  9. children: [
  10. {type: "li", children: "1"},
  11. {type: "li", children: "2"},
  12. {type: "li", children: "3"},
  13. ]
  14. }

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

  1. function patch(n1, n2, container) {
  2. if (n1 && n1.type !== n2.type) {
  3. unmount(n1)
  4. n1 = null
  5. }
  6. const { type } = n2
  7. if (typeof type === 'string') {
  8. if (!n1) {
  9. mountElement(n2, container)
  10. } else {
  11. patchElement(n1, n2)
  12. }
  13. } else if (type === Text) {
  14. if (!n1) {
  15. const el = n2.el = createText(n2.children)
  16. insert(el, container)
  17. } else {
  18. const el = n2.el = n1.el
  19. if (n2.children !== n1.children) {
  20. setText(el, n2.children)
  21. }
  22. }
  23. + } else if (type === Fragment) {
  24. + if (!n1) {
  25. + n2.children.forEach(c => patch(null, c, container))
  26. + } else {
  27. + patchChildren(n1, n2, container)
  28. + }
  29. + }
  30. }

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

  1. function unmount(vnode) {
  2. if (vnode.type === Fragment) {
  3. vnode.children.forEach(c => unmount(c))
  4. return
  5. }
  6. const parent = vnode.el.parentNode
  7. if (parent) {
  8. parent.removeChild(vnode.el)
  9. }
  10. }

代码示例链接