实现mini-vue -- runtime-core模块(十五)实现vnode的更新逻辑,引出diff算法

152 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第21天,点击查看活动详情

不知不觉,runtime-core模块已经来到第15篇文章了,已经快接近尾声了

今天我们会来实现更新元素的逻辑,也就是对于一个vnode,它的chilren发生变化的时候,应当如何处理,才能使得数据children的变化导致视图也更新,这就要用到我们前面实现的reactivity模块的响应式功能

思路就是将渲染函数作为响应式数据的依赖,当响应式数据更新的时候重新执行渲染函数,使视图也更新

但是这会带来一个问题,如果一个vnode它的children只有一个,那么更新它很简单,但是如果children是一个数组,其中有很多个vnode,但是只有其中一个或几个vnode发生了变化,难道我们要把全部已经渲染的children数组卸载,然后再挂载吗?这样做无疑是很耗费性能的

所以这就引出了我们的diff算法,在mini-vue里我会使用双端diff算法处理数组children的更新,不过diff算法有点复杂,不能在一篇文章中讲完,我会放到下一篇去讲解 这篇文章我们首先来看看如何处理children的以下三种变化情况:

  1. array -> text
  2. text -> array
  3. text -> text

1. 案例环境

import { h, ref } from '../../lib/plasticine-mini-vue.esm.js';

export const App = {
  name: 'App',
  setup() {
    const count = ref(0);

    const onClick = () => {
      count.value++;
    };

    return {
      count,
      onClick,
    };
  },
  render() {
    return h('div', {}, [
      h('div', {}, `count: ${this.count}`),
      h('button', { onClick: this.onClick }, 'click'),
    ]);
  },
};

App组件中会渲染一个文本,文本中使用到了响应式变量count的值 并且有一个按钮,我们希望当点击这个按钮时,会让响应式变量count的值加1,并且会更新到视图上 先来看看目前这个案例的运行效果 image.png 可以看到,count的值并没有被显示出来,因为它现在是一个对象 image.png 理想的情况应当是在render函数中通过this访问setupState中的ref响应式变量时,获取到的是它的value属性的值,而不是RefImpl对象本身 接下来就要去处理一下这个问题


2. 访问响应式变量时自动返回value属性

从上面的分析就可以知道,我们应当去setupState相关的地方修改,找到返回setupState的地方,将ref中的value取出返回,这个可以用之前在reactivity模块实现的proxyRefs去处理 返回setupState的地方是src/runtime-core/component.ts中的handleSetupResult函数

function handleSetupResult(instance, setupResult: any) {
  // TODO 处理 setupResult 是 function 的情况
  if (typeof setupResult === 'object') {
    instance.setupState = setupResult;
  }

  finishComponentSetup(instance);
}

我们只要把这里赋值的setupResult通过proxyRefs包裹即可,proxyRefs会帮助我们把ref属性自动解构出value值的

function handleSetupResult(instance, setupResult: any) {
  // TODO 处理 setupResult 是 function 的情况
  if (typeof setupResult === 'object') {
-		instance.setupState = setupResult;
+   instance.setupState = proxyRefs(setupResult);
  }

  finishComponentSetup(instance);
}

现在就可以直接在render函数中访问ref响应式变量,并且不需要通过value属性去获取了


3. 依赖收集渲染函数

目前我们点击按钮时,响应式数据虽然更新了,但是相应的使用了响应式数据的渲染函数并不会更新,这是因为它虽然使用到了响应式数据,但是并没有被effect包裹,所以不会被作为响应式数据的依赖进行收集 既然如此,那么我们只需要用effect包裹一下就可以了,找到render函数调用的地方,用effect将它包裹起来

// src/runtime-core/renderer.ts
function setupRenderEffect(instance, container) {
  effect(() => {
    const { proxy, vnode } = instance;
    const subTree = instance.render.call(proxy);

    // subTree 可能是 Component 类型也可能是 Element 类型
    // 调用 patch 去处理 subTree
    // Element 类型则直接挂载
    patch(subTree, container, instance);

    // subTree vnode 经过 patch 后就变成了真实的 DOM 此时 subTree.el 指向了根 DOM 元素
    // 将 subTree.el 赋值给 vnode.el 就可以在组件实例上访问到挂载的根 DOM 元素对象了
    vnode.el = subTree.el;
  });
}

现在响应式数据更新后,render函数就会被重新执行了 响应式数据更新时执行render函数.gif 虽然是执行了渲染函数了,但是明显有问题,渲染函数并没有对数据进行更新,而是直接重新渲染了新的整个内容,这是因为我们还没有实现更新Element的逻辑,接下来就会带着大家去实现一下更新Element的逻辑


4. 区分组件是初始化还是更新状态

首先我们要能够区分组件的状态,这样才能判断是否需要对其进行更新渲染 当组件首次被渲染时,应当是一个初始化的状态,而当其已经被初始化过的时候,后续再次对其调用渲染函数时则应当走更新的逻辑 可以给组件实例添加一个isMounted属性,标记其是否被挂载过,被挂载过则执行更新逻辑,否则就是初始化渲染的逻辑

export function createComponentInstance(vnode, parent) {
  console.log('createComponentInstance -- parent: ', parent);

  const component = {
    vnode,
    type: vnode.type,
    setupState: {},
    props: {},
    emit: () => {},
    slots: {},
    provides: parent ? parent.provides : {},
    parent,
+   isMounted: false,
  };

  component.emit = emit as any;

  return component;
}

然后在调用渲染函数之前判断一下,首次挂载的时候才调用,更新时我们先控制台输出表示执行更新操作

  function setupRenderEffect(instance, container) {
    effect(() => {
      if (!instance.isMounted) {
        console.log('init');

        const { proxy, vnode } = instance;
        const subTree = instance.render.call(proxy);

        // subTree 可能是 Component 类型也可能是 Element 类型
        // 调用 patch 去处理 subTree
        // Element 类型则直接挂载
        patch(subTree, container, instance);

        // subTree vnode 经过 patch 后就变成了真实的 DOM 此时 subTree.el 指向了根 DOM 元素
        // 将 subTree.el 赋值给 vnode.el 就可以在组件实例上访问到挂载的根 DOM 元素对象了
        vnode.el = subTree.el;
        instance.isMounted = true; // 初始化后及时将其标记为已挂载
      } else {
        console.log('update');
      }
    });
  }

  return {
    createApp: createAppAPI(render),
  };

区分组件初始化和更新逻辑.gif


5. 重构渲染逻辑 -- 增加更新vnode的渲染逻辑

既然现在要处理更新逻辑,patch函数的参数就肯定要更改了,由原来只负责处理渲染新的vnode变成了现在需要对比新旧vnode进行更新,所以要对patch函数进行重构

5.1 更新的时候获取新旧vnode

首先我们要在该更新的时候,调用patch函数之前,先将新旧vnode获取出来,可以给组件实例再新增一个属性subTree,记录当前组件实例的render方法返回的vnode

export function createComponentInstance(vnode, parent) {
  console.log('createComponentInstance -- parent: ', parent);

  const component = {
    vnode,
    type: vnode.type,
    setupState: {},
    props: {},
    emit: () => {},
    slots: {},
    provides: parent ? parent.provides : {},
    parent,
    isMounted: false,
+   subTree: {},
  };

  component.emit = emit as any;

  return component;
}

然后就要处理subTree属性的更新逻辑,应当在初次挂载的时候赋值进行初始化,更新的时候赋值为新的vnode进行更新

  function setupRenderEffect(instance, container) {
    effect(() => {
      if (!instance.isMounted) {
        console.log('init');

        const { proxy, vnode } = instance;
-       const subTree = instance.render.call(proxy);
+       const subTree = (instance.subTree = instance.render.call(proxy));

        // subTree 可能是 Component 类型也可能是 Element 类型
        // 调用 patch 去处理 subTree
        // Element 类型则直接挂载
        patch(subTree, container, instance);

        // subTree vnode 经过 patch 后就变成了真实的 DOM 此时 subTree.el 指向了根 DOM 元素
        // 将 subTree.el 赋值给 vnode.el 就可以在组件实例上访问到挂载的根 DOM 元素对象了
        vnode.el = subTree.el;
        instance.isMounted = true; // 初始化后及时将其标记为已挂载
      } else {
        console.log('update');

+       const { proxy, vnode } = instance;
+       const subTree = instance.render.call(proxy); // 新 vnode
+       const prevSubTree = instance.subTree; // 旧 vnode
+       instance.subTree = subTree; // 新的 vnode 要更新到组件实例的 subTree 属性 作为下一更新的旧 vnode

+       console.log('old vnode', prevSubTree);
+       console.log('new vnode', subTree);

+       patch(subTree, container, instance);
      }
    });
  }

先检验一下是否能够在该更新的时候获取到新旧vnode image.png 确实能够获取,那么接下来就可以开始重构patch函数了


5.2 重构patch函数

原来的patch函数签名如下

function patch(vnode, container, parentComponent = null) {}

由于只能接收一个结点,不能够处理对比新旧vnode进行更新的逻辑,所以我们需要修改一下参数,用n1表示旧vnoden2表示新vnode

  function patch(n1, n2, container, parentComponent = null) {}

然后修改对patch的调用,初次挂载vnode的时候,n1null,表示没有旧的vnode 而更新的时候,prevSubTree是旧vnodesubTree是新vnode

function setupRenderEffect(instance, container) {
  effect(() => {
    if (!instance.isMounted) {
      const { proxy, vnode } = instance;
      const subTree = (instance.subTree = instance.render.call(proxy));

      // subTree 可能是 Component 类型也可能是 Element 类型
      // 调用 patch 去处理 subTree
      // Element 类型则直接挂载
      // 初次挂载 n1 不存在
-     patch(subTree, container, instance);
+     patch(null, subTree, container, instance);

      // subTree vnode 经过 patch 后就变成了真实的 DOM 此时 subTree.el 指向了根 DOM 元素
      // 将 subTree.el 赋值给 vnode.el 就可以在组件实例上访问到挂载的根 DOM 元素对象了
      vnode.el = subTree.el;
      instance.isMounted = true; // 初始化后及时将其标记为已挂载
    } else {
      const { proxy, vnode } = instance;
      const subTree = instance.render.call(proxy); // 新 vnode
      const prevSubTree = instance.subTree; // 旧 vnode
      instance.subTree = subTree; // 新的 vnode 要更新到组件实例的 subTree 属性 作为下一更新的旧 vnode

- 	  patch(subTree, container, instance);
+     patch(prevSubTree, subTree, container, instance);
    }
  });
}

6. patchElement

由于目前我们的案例场景就是对一个普通div元素的更新,是一个Element类型的vnode,所以我们先实现对Element的更新逻辑,实现一个patchElement函数

function processElement(n1, n2: any, container: any, parentComponent) {
  if (!n1) {
    // n1 不存在表示是首次挂载,应当执行初始化的逻辑
    mountElement(n2, container, parentComponent);
  } else {
    // n1 存在表示更新 调用 patchElement 执行更新的逻辑
    patchElement(n1, n2, container);
  }
}

/**
 * @description 对比 n1 和 n2 虚拟结点 找出不同的部分进行更新
 * @param n1 旧结点
 * @param n2 新结点
 * @param container 容器
 */
function patchElement(n1, n2, container) {
  console.log('n1', n1);
  console.log('n2', n2);
  
  // 找出 props 的不同
  // 找出 children 的不同
}

image.png 现在能够在patchElement中获取到新旧虚拟结点了,接下来要做的就是找出它们的不同的地方,然后作出更新即可


6.1 patchProps

首先来处理一下vnodeprops的更新逻辑,对比新旧vnode的每一个props,然后会有以下三种情况;

  1. prop和旧prop都存在,但是值不相同 -- 修改
  2. prop的值为null 或 undefined,但是旧的prop是存在的 -- 删除
  3. prop直接key都不存在了,说明该删除了 -- 删除

6.1.1 prop值发生改变

实现一个patchProps函数,接收n1n2,对比它们的props值,遇到不同的则进行更新 为了和之前自定义渲染器中用户传入的自定义的patchProp进行区分,这里先对自定义渲染器中的参数进行解构重命名

export function createRenderer(options) {
  const {
    createElement: hostCreateElement,
    patchProp: hostPatchProp,
    insert: hostInsert,
  } = options;
  
  // ...
}

接下来就去实现patchProps

  /**
   * @description 对比新旧结点的 props 进行更新
   * @param n1 旧结点
   * @param n2 新结点
   */
  function patchProps(oldProps, newProps) {
    for (const key in newProps) {
      const next = newProps[key];
      const prev = oldProps[key];

      if (next !== prev) {
        hostPatchProp(el, key, prev, next);
      }
    }
  }

遍历新props的每一个key,并且如果和旧的props中对应的值不相等时,就调用自定义渲染器的hostPatchProp方法去进行更新,由于hostPatchProp需要接收el参数,所以我们需要给patchProps新增一个el参数,并且还需要修改自定义渲染器的hostPatchProp,让它能够接收到新旧prop 那么这个el应该从哪里得到呢?el应当是从旧的vnode中获取的,并且要注意,新的vnode在更新之后也会变成下一次更新的旧vnode,因此获取el的同时还要给n2.el赋值

  function patchElement(n1, n2, container) {
    const el = (n2.el = n1.el);
    const oldProps = n1.props ?? {};
    const newProps = n2.props ?? {};

    // 找出 props 的不同
    patchProps(el, oldProps, newProps);

    // 找出 children 的不同
  }

  /**
   * @description 对比新旧结点的 props 进行更新
   * @param n1 旧结点
   * @param n2 新结点
   */
  function patchProps(el, oldProps, newProps) {
    for (const key in newProps) {
      const next = newProps[key];
      const prev = oldProps[key];

      if (next !== prev) {
        hostPatchProp(el, key, prev, next);
      }
    }
  }

由于修改了hostPatchProp,所以我们还需要去runtime-dom中修改以下它的实现

function patchProp(el, key, prevValue, nextValue) {
  const isOn = (key: string) => /^on[A-Z]/.test(key);
  // 处理事件监听
  if (isOn(key)) {
    el.addEventListener(key.slice(2).toLowerCase(), nextValue);
  } else {
    el.setAttribute(key, nextValue);
  }
}

最后别忘记了修改使用了hostPatchProp的代码,要给它们加上相应的参数 比如mountElement中就用到了这个函数,因此要修改

function mountElement(vnode: any, container: any, parentComponent) {
  // 将创建的元素挂载到 vnode 上 从而让组件实例能够访问到
  const el = (vnode.el = hostCreateElement(vnode.type));
  const { children, shapeFlag } = vnode;

  // children
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    el.textContent = children;
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    mountChildren(children, el, parentComponent);
  }

  // props
  const { props } = vnode;
  for (const [key, value] of Object.entries(props)) {
- 	hostPatchProp(el, key, value);
+   hostPatchProp(el, key, null, value);
  }

  hostInsert(el, container);
}

现在更新props的功能就算完成了,接下来就要去测试一下看看是否真的可以更新props


6.1.2 测试修改props值

App.js中定义一下props,并将它作为根组件的props,然后添加一个按钮,点击的时候会修改props的值

export const App = {
  name: 'App',
  setup() {
    const count = ref(0);
    const props = ref({
      name: 'foo',
      age: 20,
    });

    const onClick = () => {
      count.value++;
    };

    const changeProps = () => {
      props.value.name = props.value.name === 'foo' ? 'bar' : 'foo';
      props.value.age++;
    };

    return {
      count,
      onClick,
      changeProps,
      props,
    };
  },
  render() {
    return h('div', { name: this.props.name, age: this.props.age }, [
      h('div', {}, `count: ${this.count}`),
      h('button', { onClick: this.onClick }, 'click'),
      h(
        'button',
        {
          onClick: this.changeProps,
        },
        'changeProps'
      ),
    ]);
  },
};

现在每次点击changeProps按钮,都会让根组件propsnamefoobar之间反复横跳,而age值则加1,如果我们的更新props的功能实现了的话,应当能够修改相应DOM元素上的props更新props.gif 可以看到已经成功了!


6.1.3 props值不存在

在这个场景中,我们会将props的值置为undefined,看看能否顺利将props删除掉,由于真正的更新props的逻辑是交给自定义渲染器的hostPatchProp实现的,所以我们要修改runtime-dom中的patchProp函数

function patchProp(el, key, prevValue, nextValue) {
  const isOn = (key: string) => /^on[A-Z]/.test(key);
  // 处理事件监听
  if (isOn(key)) {
    el.addEventListener(key.slice(2).toLowerCase(), nextValue);
  } else {
+   if (nextValue === undefined || nextValue === null) {
+     el.removeAttribute(key);
    } else {
      el.setAttribute(key, nextValue);
    }
  }
}

App.js中新增一个按钮,点击后会将props中的name置为undefined

// setup
const removeProps = () => {
  props.value.name = undefined;
};

return {
  count,
  onClick,
  props,
  changeProps,
  removeProps,
};

// render
h('button', { onClick: this.removeProps }, 'removeProps')

删除props.gif


6.1.4 props key 不存在

如果是新的vnode中,对应的propskey不存在了,说明也是要执行删除逻辑的,这时候就不是在hostPatchProp中处理了,因为即便是propskey不存在,其判断结果也是undefined,也依然会删除,逻辑是正确的,不需要更改 我们这次需要更改的是patchProps中对oldProps中的key进行遍历,如果遍历出在newProps中不存在的key,则需要进行删除

  function patchProps(el, oldProps, newProps) {
    for (const key in newProps) {
      const next = newProps[key];
      const prev = oldProps[key];

      if (next !== prev) {
        hostPatchProp(el, key, prev, next);
      }
    }

+   // 遍历 oldProps 找出不存在于 newProps 中的 key 进行删除
+   for (const key in oldProps) {
+     if (!(key in newProps)) {
+       hostPatchProp(el, key, oldProps[key], null);
+     }
+   }
  }

现在在App.js中再添加一个按钮,点击之后会将props赋值为一个新的对象,这个对象中没有了age属性

// setup
const removeProps2 = () => {
  // 移除掉 age 属性
  props.value = {
    name: 'foo',
  };
};

// render
h('button', { onClick: this.removeProps2 }, 'removeProps2')

删除props key.gif


6.1.5 提高代码健壮性

  1. 实际上当新旧props对象是同一个对象的时候,没必要执行遍历它们的key的操作,这纯粹在浪费时间
function patchProps(el, oldProps, newProps) {
  if (oldProps !== newProps) {
    for (const key in newProps) {
      const next = newProps[key];
      const prev = oldProps[key];

      if (next !== prev) {
        hostPatchProp(el, key, prev, next);
      }
    }

    // 遍历 oldProps 找出不存在于 newProps 中的 key 进行删除
    for (const key in oldProps) {
      if (!(key in newProps)) {
        hostPatchProp(el, key, oldProps[key], null);
      }
    }
  }
}
  1. props对象是一个空对象的时候没必要进行遍历

注意,js中两个空对象字面量进行逻辑判断的时候是不相等的,虽然都是空对象,但是是两个指向不同内存地址的引用,要进行空对象判断,应当创建一个空对象,然后在oldPropsnewProps赋值的时候赋值为这个空对象,判断的时候也是使用这个空对象而不是使用字面量的方式 在shared模块下导出一个空对象

// src/shared/index.ts
export const EMPTY_OBJ = {};

然后修改oldPropsnewProps初始化的逻辑

function patchElement(n1, n2, container) {
  const el = (n2.el = n1.el);
- const oldProps = n1.props || {};
- const newProps = n2.props || {};
+ const oldProps = n1.props || EMPTY_OBJ;
+ const newProps = n2.props || EMPTY_OBJ;

  // 找出 props 的不同
  patchProps(el, oldProps, newProps);

  // 找出 children 的不同
}

再添加oldProps为空对象的判断逻辑

function patchProps(el, oldProps, newProps) {
  if (oldProps !== newProps) {
    for (const key in newProps) {
      const next = newProps[key];
      const prev = oldProps[key];

      if (next !== prev) {
        hostPatchProp(el, key, prev, next);
      }
    }

    // 遍历 oldProps 找出不存在于 newProps 中的 key 进行删除
+   if (oldProps !== EMPTY_OBJ) {
      for (const key in oldProps) {
        if (!(key in newProps)) {
          hostPatchProp(el, key, oldProps[key], null);
        }
      }
+   }
  }
}

6.2 patchChildren

props的更新逻辑处理完毕之后就要来实现一下children的更新逻辑了,对于children的更新,需要先清除children有哪些类型 前面我们通过ShapeFlags区分不同vnode的类型,并且还能够区分它们的children的类型

// src/shared/shapeFlags.ts
export const enum ShapeFlags {
  ELEMENT = 1,
  STATEFUL_COMPONENT = 1 << 1,
  TEXT_CHILDREN = 1 << 2,
  ARRAY_CHILDREN = 1 << 3,
  SLOT_CHILDREN = 1 << 4,
}

主要考虑text类型和array类型的子节点的更新


6.2.1 讨论更新的情况

主要有以下四种更新情况:

  1. childrenarray,新childrentext
  2. childrentext,新children也是text
  3. childrentext,新childrenarray
  4. childrenarray,新children也是array

其中前三种的处理比较容易

  • 第一种情况,只需要将旧的children数组中的内容清空,然后修改childrentext类型,并赋值对应的text内容即可
  • 第二种情况则直接修改文本内容即可
  • 第三种情况需要先清空文本的内容,再将array挂载上去

6.2.2 array变成text

1. 案例场景

首先看看array类型的children变成text类型的children的处理逻辑,先搭建一个案例场景

import { h, ref } from '../../lib/plasticine-mini-vue.esm.js';

const prevChildren = [h('p', {}, 'foo'), h('p', {}, 'bar')];
const nextChildren = 'text children';

export const ArrayToText = {
  name: 'ArrayToText',
  setup() {
    const toggleChildren = ref(true);
    window.toggleChildren = toggleChildren;

    return {
      toggleChildren,
    };
  },
  render() {
    return this.toggleChildren
      ? h('div', {}, prevChildren)
      : h('p', {}, nextChildren);
  },
};

子组件中根据toggleChilren这一响应式变量的值去渲染不同的vnode,为了方便测试,在组件内部将toggleChildren挂载到window中,然后通过控制台中修改响应式数据的值的方式触发更新逻辑 父组件中直接渲染子组件

export const App = {
  name: 'App',
  setup() {},
  render() {
    return h('div', { id: 'root' }, [h(ArrayToText)]);
  },
};

image.png 可以看到输出的新旧vnodeshapeFlag是有变化的,array类型的shapeFlag8text类型的shapeFlag4,再加上元素自身是Element类型的,shapeFlag1,因此最终的shapeFlag为所有shapeFlag之和


2. patchChildren

创建一个patchChilren函数,先处理从arraytext的转换

function patchChildren(n1, n2) {
  // n2 的 children 是 text 类型
  const prevShapeFlag = n1.shapeFlag;
  const { shapeFlag } = n2;
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // n1 的 children 是 array 类型
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // TODO 1. 将 array 的内容卸载 2. 将 text 的内容挂载
    }
  }
}

目前要做的就是两件事:

  1. 将 array 的内容卸载
  2. 将 text 的内容挂载
移除array的内容

卸载array的内容可以创建一个unmountChildren函数,传入要卸载的children

function unmountChildren(children) {
  for (let i = 0; i < children.length; i++) {
    // 获取到 vnode 中的 el
    const el = children[i].el;
    // 调用自定义渲染器中的 remove 逻辑
    hostRemove(el);
  }
}

由于移除元素会和DOM操作有关,因此可以归于自定义渲染器的功能,所以改为调用自定义渲染器的remove方法,然后我们再去实现runtime-dom中相应的渲染器的remove方法

+ /**
+  * @description 移除子元素
+  * @param child 子元素
+  */
+ function remove(child) {
+   const parent = child.parentNode;
+   if (parent) {
+     parent.removeChild(child);
+   }
+ }

const renderer: any = createRenderer({
  createElement,
  patchProp,
  insert,
+ remove,
});

设置text的内容

需要将什么东西设置为text呢? image.png 实际上应当是将这里的整个div元素内的子结点改成text结点,因此我们需要获取到这个div元素,给patchChildren函数加上一个container参数,存放整个children的根元素

- function patchChildren(n1, n2) {}
+ function patchChildren(n1, n2, container) {}

container从哪里来呢?container实际上就是旧的vnode中的el,也就是n1.el

- patchChildren(n1, n2);
+ patchChildren(n1, n2, el);

由于也是涉及到DOM操作,所以可以把它抽象成自定义渲染器的接口函数,现在再给自定义渲染器增加一个setElementText函数,用于修改元素的文本内容,由于新的文本内容来自n2.children,所以该函数要接收的text参数就是n2.children,而Element则是patchChildrencontainer

const c2 = n2.children;
hostSetElementText(container, c2);
export function createRenderer(options) {
  const {
    createElement: hostCreateElement,
    patchProp: hostPatchProp,
    insert: hostInsert,
    remove: hostRemove,
+   setElementText: hostSetElementText,
  } = options;
  
  // ...
}

然后在runtime-dom中实现这个新的接口函数

+ function setElementText(el, text) {
+   el.textContent = text;
+ }

const renderer: any = createRenderer({
  createElement,
  patchProp,
  insert,
  remove,
+ setElementText,
});

从array变成text.gif


6.2.3 text变成text

还是先搭建一下案例场景

export const TextToText = {
  name: 'TextToText',
  setup() {
    const toggleTextToText = ref(true);
    window.toggleTextToText = toggleTextToText;

    return {
      toggleTextToText,
    };
  },
  render() {
    return this.toggleTextToText
      ? h('p', {}, 'old text')
      : h('p', {}, 'new text');
  },
};

修改toggleTextToText变量的值会导致渲染函数重新执行,切换不同的文本 实现思路很简单,在原来的基础上,添加一个else分支调用hostSetElementText即可,因为原先的if分支已经判断了旧结点为array,那么else就意味着就结点不是array,只能是text了,对于texttext的转换,直接修改文本内容即可

function patchChildren(n1, n2, container) {
  // n2 的 children 是 text 类型
  const prevShapeFlag = n1.shapeFlag;
  const { shapeFlag } = n2;
  const c2 = n2.children;

  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 新 children 是 text 类型
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 旧 children 是 array 类型 -- 从 array 变为 text

      // 卸载 array 的内容
      unmountChildren(n1.children);

      // 挂载 text 的内容
      hostSetElementText(container, c2);
+   } else {
+     // 旧 children 是 text 类型 -- 从 text 变为 text
+     hostSetElementText(container, c2); // 直接修改文本内容即可
+   }
  }
}

从text变为text.gif


6.2.4 从text变成array

text变成array我们需要先清空text中的内容,然后再将array挂载上去

- function patchChildren(n1, n2, container) {
+ function patchChildren(n1, n2, container, parentComponent) {
  // n2 的 children 是 text 类型
  const prevShapeFlag = n1.shapeFlag;
  const { shapeFlag } = n2;
  const c2 = n2.children;

  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 新 children 是 text 类型
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 旧 children 是 array 类型 -- 从 array 变为 text

      // 卸载 array 的内容
      unmountChildren(n1.children);

      // 挂载 text 的内容
      hostSetElementText(container, c2);
    } else {
      // 旧 children 是 text 类型 -- 从 text 变为 text
      hostSetElementText(container, c2); // 直接修改文本内容即可
    }
+  } else {
+    // 新 children 是 array 类型
+    if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
+      // 旧 children 是 text 类型 -- 从 text 变为 array
+
+      // 清空旧结点中的文本内容
+      hostSetElementText(container, '');
+
+      // 挂载新结点中 array 的内容
+      mountChildren(c2, container, parentComponent);
+    }
+  }
}

mountChildren需要一个parentComponent参数,我们给patchChildren加上这个参数后传给mountChildren即可,然后再沿着调用链往上添加parentComponent参数,直到能够获取到该参数为止