『手写Vue3』更新

122 阅读5分钟

createRenderer

自上一期完成provide/inject后,我们还实现了createRenderer。

  • 目的:支持多平台渲染,之前只支持dom,但是还能支持canvas等,不同平台结点的生成、props的更新,以及结点的插入方式的api不一样,不同平台对此应该传入不同的接口。
  • 实现:把render.ts下的所有方法放进createRenderer函数,构成闭包。createRenderer接受options参数,从中可以取出三个函数,对应上面的三个接口。之后实现这三个函数,并传入options对象。由于render函数不再直接暴露出去,createApp函数不能直接调用render,所以createApp外面又套了一层createAppAPI,用于拿到render。createRenderer的返回值是一个对象,调用createAppAPI并传入render。
import { createRenderer } from '../runtime-core';

// 抽出来的逻辑
function createElement(type) {
  return document.createElement(type);
}

function patchProp(el, key, value) {
  const isOn = (key) => /^on[A-Z]/.test(key);

  if (isOn(key)) {
    const event = key.slice(2).toLocaleLowerCase();
    el.addEventListener(event, value);
  } else {
    el.setAttribute(key, value);
  }
}

function insert(el, parent) {
  parent.append(el);
}

// renderer对象,含有createApp方法
export const renderer: any = createRenderer({
  createElement,
  patchProp,
  insert
});

// 暴露给用户的接口
export function createApp(...args) {
  return renderer.createApp(...args);
}

export * from '../runtime-core';

render.ts:


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

  // 构成闭包
  function render(){}
  function patch(){}
  // ...

  return {
    createApp: createAppAPI(render)
  };
}

更新 element 流程

先提供组件:

import { h, ref } from '../../lib/my-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',
      {
        id: 'root'
      },
      [
        h('div', {}, 'count:' + this.count), // 依赖收集
        h(
          'button',
          {
            onClick: this.onClick
          },
          'click'
        )
      ]
    );
  }
};

想要实现的效果,是点击按钮count值自增。

image.png

从组件我们可以发现,count是ref,但render函数中访问时却没用this.count.value,根据组件代理的知识,instance.setupState里保存了setup的返回值,而且它也是一个objectWithRefs,使用proxyRefs,返回值无需.value的方式访问。

function handleSetupResult(instance, setupResult: any) {
  if (typeof setupResult === 'object') {
    // proxyRefs
    instance.setupState = proxyRefs(setupResult);
  }

  finishComponentSetup(instance);
}

只有修改了这一步,上面组件的count才能渲染为0,否则是[object Object]。

然后来思考更新的实现,当组件内ref的值改变了,毫无疑问需要更新,更新就需要调用render生成新的vnode,而render是在setupRenderEffect中调用的。

该函数内裹一层effect,这样该effect就会被添加到ref的dep集合中,当触发ref的value setter,会重新render生成新的vnode。

给instance添加isMounted属性,初始化为false,表示组件还未挂载。初始化的时候会挂载,之后依赖触发时,由于已经挂载,会进入更新的逻辑。

还需要给instance添加subTree属性,表示旧的vnode,然后把新旧的vnode都传入patch。

  function setupRenderEffect(instance: any, initialVNode, container) {
    effect(() => {
      const { proxy, isMounted } = instance;

      if (!isMounted) {
        const subTree = (instance.subTree = instance.render.call(proxy));
        patch(null, subTree, container, instance);

        initialVNode.el = subTree.el;
        instance.isMounted = true;
      } else {
        const subTree = instance.render.call(proxy); // 新的vnode
        patch(instance.subTree, subTree, container, instance);
        instance.subTree = subTree;
      }
    });
  }

显然,patch新增了一个参数,现在第一个参数n1表示旧vnode,第二个参数n2表示新vnode,这一函数签名的变化将引起一连串的修改。如果我们判断某处的patch是初始化操作,就给n1传null,此处不过多分析。

给processElement函数也添加参数n1,该函数根据n1是否为null,判断此时进行的是mount操作还是更新操作。

  function processElement(n1, n2, container, parentComponent) {
    if (!n1) {
      mountElement(n2, container, parentComponent);
    } else {
      patchElement(n1, n2, container);
    }
  }

更新流程搭建完毕,之后更新的具体操作只需要在函数patchElement中实现即可。

更新 props

更新props的要求如下:

  • 之前的值和现在不一样 -> 修改
  • 新值为undefined / null -> 删除
  • key在新的props中不存在 -> 删除

组件:

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

export const App = {
  name: 'App',

  setup() {
    const count = ref(0);

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

    const props = ref({
      foo: 'foo',
      bar: 'bar'
    });
    const onChangePropsDemo1 = () => {
      props.value.foo = 'new-foo';
    };

    const onChangePropsDemo2 = () => {
      props.value.foo = undefined;
    };

    const onChangePropsDemo3 = () => {
      props.value = {
        foo: 'foo'
      };
    };

    return {
      count,
      onClick,
      onChangePropsDemo1,
      onChangePropsDemo2,
      onChangePropsDemo3,
      props
    };
  },
  render() {
    return h(
      'div',
      {
        id: 'root',
        ...this.props
      },
      [
        h('div', {}, 'count:' + this.count),
        h(
          'button',
          {
            onClick: this.onClick
          },
          'click'
        ),
        h(
          'button',
          {
            onClick: this.onChangePropsDemo1
          },
          'changeProps - 值改变了 - 修改'
        ),

        h(
          'button',
          {
            onClick: this.onChangePropsDemo2
          },
          'changeProps - 值变成了 undefined - 删除'
        ),

        h(
          'button',
          {
            onClick: this.onChangePropsDemo3
          },
          'changeProps - key 在新的里面没有了 - 删除'
        )
      ]
    );
  }
};

image.png

实现patchProps函数:

  function patchElement(n1, n2, container) {
    const prevProps = n1.props || EMPTY_OBJ;
    const nextProps = n2.props || EMPTY_OBJ;

    // 细节,n2是新的vnode,需要保存.el
    // 下次更新的时候,之前的n2就会变为n1
    const el = (n2.el = n1.el);

    patchProps(el, prevProps, nextProps);
  }

  function patchProps(el, oldProps, newProps) {
    if (oldProps !== newProps) {
      for (const key in newProps) {
        hostPatchProp(el, key, oldProps[key], newProps[key]);
      }
    }
    if (oldProps !== EMPTY_OBJ) {
      for (const key in oldProps) {
        if (!(key in newProps)) {
          hostPatchProp(el, key, oldProps[key], null);
        }
      }
    }
  }

hostPatchProp是createRender的options中拿到的函数之一,用于props操作。之前只接收三个参数,现在新增参数oldValue。

// hostPatchProp是解构赋值后的名称,其实就是patchProp函数
function patchProp(el, key, oldValue, newValue) {
  const isOn = (key) => /^on[A-Z]/.test(key);

  if (isOn(key)) {
    const event = key.slice(2).toLocaleLowerCase();
    el.addEventListener(event, newValue);
  } else {
    // 新值是undefined或null,就删除
    if (newValue === undefined || newValue === null) {
      el.removeAttribute(key);
    } else {
      el.setAttribute(key, newValue);
    }
  }
}

在mount中调用hostPatchProp时,oldValue传null即可。

更新children

概述

更新children共有四种情况:

  1. oldChildren是string,newChildren是array。
  2. oldChildren是string,newChildren是string。
  3. oldChildren是array,newChildren是string。
  4. oldChildren是array,newChildren是array。

最后一种情况最复杂,这一小节处理前三种情况。

实现patchChildren,并且在patchElement中调用:

  function patchChildren(n1, n2, container, parentComponent) {
    const { shapeFlag: prevShapeFlag, children } = n1;
    const { shapeFlag, children: c2 } = n2;

    if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
      if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // 情况三
        unmountChildren(children);
      }
      // 情况二
      hostSetElementText(container, c2);
    } else {
      if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        // 情况一
        hostSetElementText(container, '');
        // 第一个参数从vnode改为children
        mountChildren(c2, container, parentComponent);
      } else {
        // TODO
      }
    }
  }
  
  function unmountChildren(children) {
    for (let i = 0; i < children.length; i++) {
      hostRemove(children[i].el);
    }
  }
  • 情况一:将elementText设置为空,mount新children
  • 情况二:将elementText设置为新的
  • 情况三:将旧children的结点卸载,将elementText设置为新的

createRenderer的options中新增两个接口,分别是remove和setElementText:

function remove(child) {
  const parent = child.parentNode;
  if (parent) {
    parent.removeChild(child);
  }
}

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