Vue3框架原理实现(三)-patch

595 阅读7分钟

目的

这次主要是实现 vuereact 框架核心的patch概念, 为了防止重复渲染生成dom节点,提高效率。分析对比前后renderVNode 的差别来确定dom节点的更新、删除、添加。 这里我们可以参考react官方的对这个的说明。

react学习-协调

我们这里设计实现的会简单一些

image.png

patch流程

怎么patch?和之前实现的mount的关系是什么?这里我们可以画一个流程图来展示一下render的过程

patch.png patch就是对比新旧VNode的差异,最小限度的更新dom节点,我们也会根据这个流程来渲染我们的虚拟节点生成dom节点。

代码实现

render

这次我们重新实现render函数。实现的是下面的这部分流程。n1是上一次渲染的VNode,我们将渲染VNode挂载到container dom节点上,以便下一次render 时对比使用

image.png

//render.js
/**
 * 将虚拟dom节点挂载到真实的dom 节点上
 * @param {Object} vnode
 * @param {HTMLElement} container
 */
export function render(vnode, container) {
  const preVNode = container._vnode;
  if (!vnode) {
    if (preVNode) {
      unmount(preVNode);
    }
  } else {
    patch(preVNode, vnode, container);
  }
  container._vnode = vnode;
}

这里当n2 nextVNode不存在时,会直接卸载n1也就是preVNode

unmount

这我们来实现 unmount方法, 这里给vnode 添加了一个 el 属性, 存储该VNode对应的dom节点, 当创建dom节点时,el赋值为该节点

image.png

//render.js
function unmount(vnode) {
  // 提取出类型 和 对应的dom节点
  const { shapeFlag, el } = vnode;
  //是否是组件
  if (shapeFlag & ShapeFlags.COMPONENT) {
    unmountComponent(vnode);
  } else if (shapeFlag & ShapeFlags.FRAGMENT) {
  //Fragment类型的话
    unmountFragment(vnode);
  } else {
  //文本 或者Element 类型的话 直接remove
    el.parentNode.removeChild(el);
  }
}
//render.js
function mountElement(vnode, container) {
  const { type, props } = vnode;
  const el = document.createElement(type);
  mountProps(props, el);
  mountChildren(vnode, el);
  container.appendChild(el);
  //添加el属性
  vnode.el = el;
}

function mountTextNode(vnode, container) {
  const textNode = document.createTextNode(vnode.children);
  container.appendChild(textNode);
  //添加el属性
  vnode.el = textNode;
}
//vnode.js
/**
 * @param {string | Object | Text | Fragment} type ;
 * @param {Object | null} props
 * @param {string |number | Array | null} children
 * @returns VNode
 */
export function h(type, props, children) {
  //判断类型
  let shapeFlag = 0;
  if (isString(type)) {
    shapeFlag = ShapeFlags.ELEMENT;
  } else if (type === Text) {
    shapeFlag = ShapeFlags.TEXT;
  } else if (type === Fragment) {
    shapeFlag = ShapeFlags.FRAGMENT;
  } else {
    shapeFlag = ShapeFlags.COMPONENT;
  }
  //判断children
  if (isString(children) || isNumber(children)) {
    shapeFlag |= ShapeFlags.TEXT_CHILDREN;
    children = children.toString();
  } else if (isArray(children)) {
    shapeFlag |= ShapeFlags.ARRAY_CHILDREN;
  }
  //vnode 这里添加el 属性
  return {
    type,
    props,
    children,
    shapeFlag,
    el: null
  };
}

这里的unmountComponentunmountFragment 我们先定义好 之后再实现

patch

接下来就是patch 对比新旧VNode 比较之间的差异,来更新、删除、增加dom节点了

image.png

//render.js
function patch(n1, n2, container) {
  //n1存在 n1和n2不同的话 先unmount n1
  if (n1 && !isSameVNode(n1, n2)) {
    unmount(n1);
   //unmount 之后 将 n1 置为null
    n1 = null;
  }
  //判断n2 类型 COMPONENT、TEXT、FRAGMENT、ELEMENT
  const { shapeFlag } = n2;
  if (shapeFlag & ShapeFlags.COMPONENT) {
    processComponent(n1, n2, container);
  } else if (shapeFlag & ShapeFlags.TEXT) {
    processText(n1, n2, container);
  } else if (shapeFlag & ShapeFlags.FRAGMENT) {
    processFragment(n1, n2, container);
  } else {
    processElement(n1, n2, container);
  }
}

function isSameVNode(n1, n2) {
  return n1.type === n2.type;
}

这里的四个process方法是对比n1,n2的差别来更新container dom节点的, 这里我们先实现processText processFragment processElement 这三个方法

processText

这里我们先实现简单的processText方法

image.png 实现代码

//render.js
function processText(n1, n2, container) {
  if (n1) {
    //如果n1存在 因为两个都是文本节点 所以直接更新n1的dom节点 将n2 的el属性指向 n1的el
    n2.el = n1.el;
    n1.el.textContent = n2.children;
  } else {
    //不存在的话 直接挂载 文本节点
    mountTextNode(n2, container);
  }
}

function mountTextNode(vnode, container) {
  const textNode = document.createTextNode(vnode.children);
  container.appendChild(textNode);
  vnode.el = textNode;
}

processElement

image.png 首先同样是判断n1存不存在,不存在直接mountElement,存在的话得判断n1,n2的属性props、和children之间的差异来更新dom节点

//render.js
function processElement(n1, n2, container) {
 if (n1) {
   patchElement(n1, n2);
 } else {
   mountElement(n2, container);
 }
}
function patchElement(n1, n2) {
 n2.el = n1.el;
 // 对比更新 元素 的 props
 patchProps(n1.props, n2.props, n2.el);
 // 对比更新 children
 patchChildren(n1, n2, n2.el);
}

function mountElement(vnode, container) {
 const { type, props, shapeFlag, children } = vnode;
 const el = document.createElement(type);
 patchProps(null, props, el);
  //将判断挂载子节点的操作 放到了mountElement中
 if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
   mountTextNode(vnode, el);
 } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
   mountChildren(children, el);
 }
 container.appendChild(el);
 vnode.el = el;
}

function mountChildren(children, container) {
 children.forEach((child) => {
 //这里可以直接使用 patch 方法来挂载子节点 第一个参数为null表示 挂载新的节点
   patch(null, child, container);
 });
}

这里我们接着来实现 patchPropspatchChildren 这两个方法

patchProps

接收前后的VNode节点的props属性,根据n2props判断n1原来的props属性是否需要更新或者删除添加操作。

//patchProps.js
export function patchProps(oldProps, newProps, el) {
  if (oldProps === newProps) return;
  //防止 oldProps 或者 newProps 为null的时候 代码出错
  oldProps = oldProps || {};
  newProps = newProps || {};
  //迭代newProps 的属性值 判断oldProps 有没有对应的属性 有的话需不需要更新
  for (const key in newProps) {
    const next = newProps[key];
    const prev = oldProps[key];

    if (prev !== next) {
      patchDomProp(prev, next, key, el);
    }
  }
  //当oldProps 的属性不存在newProps中时,{class: 'a',style: {}} {style: {}}这种情况时,要在dom节点上删除没有在newProps中出现的属性值
  for (const key in oldProps) {
    if (newProps[key] == null) {
      patchDomProp(oldProps[key], null, key, el);
    }
  }
}

实现具体的patchDomProp,这个可以参考上次实现的mountProps方法,

//patchProps.js
const domPropsRE = /[A-Z]|^(value | checked | selected | muted | disabled)$/;
function patchDomProp(prev, next, key, el) {
  switch (key) {
    case 'class':
      //class直接更新为 next 的 class属性 为null时为空字符串'' 
      el.className = next || '';
      break;
    case 'style':
      //newProps 没有style属性时 dom节点直接移除style
      if (next == null) {
        el.removeAttribute('style');
      } else {
      //newProps 存在style属性时 遍历style 更新el对应的属性值
        for (const styleName in next) {
          el.style[styleName] = next[styleName];
        }
        //移除掉 在oldProps style中出现 没有在newProps style中出现的属性
        if (prev) {
          for (const styleName in prev) {
            if (next[styleName] == null) {
              el.style[styleName] = '';
            }
          }
        }
      }

      break;
    default:
    //这里以newProps里面的值为准
      if (/^on[^a-z]/.test(key)) {
        const eventName = key.slice(2).toLowerCase();
        if (prev) {
          el.removeEventListener(eventName, prev);
        }
        if (next) {
          el.addEventListener(eventName, next);
        }
      } else if (domPropsRE.test(key)) {
        //类似 <input type="checkbox" checked> checked 这种属性 特殊判断
        if (next === '' && isBoolean(el[key])) {
          next = true;
        }
        el[key] = next;
      } else {
        // 例如 自定义属性 {custom: ''} 应应用setAttribute设置为<input custom/>
        // 而 {custom: null} 应用removeAttribute 设置为 <input />
        if (next == null || next === false) {
          el.removeAttribute(key);
        } else {
          el.setAttribute(key, next);
        }
      }
      break;
  }
}

patchChildren

patchChild这里就要考虑n1,和n2的九种情况,如下图

patch_children.png 一种一种实现:

//render.js
function patchChildren(n1, n2, container) {
  //提取出n1,n2 的类型 以及children
  const { shapeFlag: prevShapeFlag, children: c1 } = n1;
  const { shapeFlag, children: c2 } = n2;
  //n2 的三种类型 TEXT Array NULL
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    //n2的为TEXT的三种情况 合并代码之后 简写
    if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      unmountChildren(c1);
    }
    //当文本不一样 也就是children 不同时才更新 dom 元素文本信息
    if (c1 !== c2) {
      container.textContent = c2;
    }
    //n2 类型为ARRAY_CHILDREN 时
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
      container.textContent = '';
      mountChildren(c2, container);
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      patchArrayChildren(c1, c2, container);
    } else {
      mountChildren(c2, container);
    }
  } else {
    if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
      container.textContent = '';
    } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      unmountChildren(c1);
    }
  }
}

这里我们又要实现unmountChildrenpatchArrayChildrenmountChildren方法

//render.js
//直接挨个调用unmount方法
function unmountChildren(children) {
  children.forEach((child) => {
    unmount(child);
  });
}
//直接使用patch挂载子节点
function mountChildren(children, container) {
  children.forEach((child) => {
    patch(null, child, container);
  });
}

patchArrayChildren方法就要考虑c1, 和 c2 长度问题

c1: a b c d
c2: a b c d e f g
或者
c1: a b c d e f g
c2: a b c d

所以要根据他们的公共长度来patch,c1多余unmoount,c2多余mount

//render.js
function patchArrayChildren(c1, c2, container) {
  const oldLength = c1.length;
  const newLength = c2.length;
  const commonLength = Math.min(oldLength, newLength);

  for (let i = 0; i < commonLength; i++) {
    patch(c1[i], c2[i], container);
  }

  if (oldLength > newLength) {
    unmountChildren(c1.slice(commonLength));
  } else if (oldLength < newLength) {
    mountChildren(c2.slice(commonLength), container);
  }
}

processFragment

image.png 代码实现

//render.js
function processFragment(n1, n2, container) {
  if (n1) {
    patchChildren(n1, n2, container);
  } else {
    mountChildren(n2.children, container);
  }
}

测试一下看看结果

//index.js
import { render, h, Fragment } from './runtime';

render(
  h('ul', null, [
    h('li', null, 'first'),
    h(Fragment, null, []),
    h('li', null, 'last'),
  ]),
  document.body
);

setTimeout(() => {
  render(
    h('ul', null, [
      h('li', null, 'first'),
      h(Fragment, null, [h('li', null, 'middle')]),
      h('li', null, 'last'),
    ]),
    document.body
  );
}, 2000);

第一次render时OK,2秒之后重新rendermiddle挂载到了最后面,有问题。

image.png 原因是第二次renderFragment子元素middle挂载时,新创建一个dom节点挂载到Fragment父节点ul上面了,因为用的是appendChild直接挂载到最后面了, 但是last节点没变所以还是没更新,还是在first后面,所以是last插入位置出现了问题

解决方法就是给Fragment元素添加anchor属性。processFragment的时候创建两个空文本节点,分别代表fragment插入的首尾位置。子元素根据anchor来确定在dom中的位置。使用insertBefore创建

VNode添加anchor

//vnode.js
/**
 * @param {string | Object | Text | Fragment} type ;
 * @param {Object | null} props
 * @param {string |number | Array | null} children
 * @returns VNode
 */
export function h(type, props, children) {
  //判断类型
  let shapeFlag = 0;
  if (isString(type)) {
    shapeFlag = ShapeFlags.ELEMENT;
  } else if (type === Text) {
    shapeFlag = ShapeFlags.TEXT;
  } else if (type === Fragment) {
    shapeFlag = ShapeFlags.FRAGMENT;
  } else {
    shapeFlag = ShapeFlags.COMPONENT;
  }
  //判断children
  if (isString(children) || isNumber(children)) {
    shapeFlag |= ShapeFlags.TEXT_CHILDREN;
    children = children.toString();
  } else if (isArray(children)) {
    shapeFlag |= ShapeFlags.ARRAY_CHILDREN;
  }
  return {
    type,
    props,
    children,
    shapeFlag,
    el: null,
    anchor: null
  };
}

改写processFragment方法

//render.js
function processFragment(n1, n2, container, anchor) {
//判断n1是否有anchor 有的话直接使用n1之前的 没有的话创建
  const fragmentStartAnchor = (n2.el = n1
    ? n1.el
    : document.createTextNode(''));
  const fragmentEndAnchor = (n2.anchor = n1
    ? n1.anchor
    : document.createTextNode(''));

  if (n1) {
    patchChildren(n1, n2, container, fragmentEndAnchor);
  } else {
    //插入标记
    container.insertBefore(fragmentStartAnchor, anchor);
    container.insertBefore(fragmentEndAnchor, anchor);
    //这里的fragmentEndAnchor代表的是挂载到他之前
    mountChildren(n2.children, container, fragmentEndAnchor);
  }
}

改写mountElment, mountTextNode方法

//render.js
function mountTextNode(vnode, container, anchor) {
  const textNode = document.createTextNode(vnode.children);
  container.insertBefore(textNode, anchor);
  vnode.el = textNode;
}

function mountElement(vnode, container, anchor) {
  const { type, props, shapeFlag, children } = vnode;
  const el = document.createElement(type);
  patchProps(null, props, el);

  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    mountTextNode(vnode, el);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    mountChildren(children, el);
  }

  //container.appendChild(el);
  container.insertBefore(el, anchor);
  vnode.el = el;
}

还要给相关方法全部加上anchor属性 processComponentprocessTextpatchprocessElementmountChildrenpatchChildrenpatchArrayChildren

改写unmountFragment方法

//render.js
function unmountFragment(vnode) {
  let { el: cur, anchor: end } = vnode;
  const { parentNode } = cur;
  //便利cur 到 end 之间的节点 并且删除
  while (cur !== end) {
    let next = cur.nextSibling;
    parentNode.removeChild(cur);
    cur = next;
  }
  //最后删除 end节点
  parentNode.removeChild(end);
}

还需要注意一下问题

image.png 改写patch

//render.js
function patch(n1, n2, container, anchor) {
  if (n1 && !isSameVNode(n1, n2)) {
   //如果是fragment anchor设置为n1的anchor的下一个dom节点 不然设置为n1 el的下一个节点 anchor为在这个元素之前插入
    anchor = (n1.anchor || n1.el).nextSibling;
    unmount(n1);
    n1 = null;
  }
  const { shapeFlag } = n2;
  if (shapeFlag & ShapeFlags.COMPONENT) {
    processComponent(n1, n2, container, anchor);
  } else if (shapeFlag & ShapeFlags.TEXT) {
    processText(n1, n2, container, anchor);
  } else if (shapeFlag & ShapeFlags.FRAGMENT) {
    processFragment(n1, n2, container, anchor);
  } else {
    processElement(n1, n2, container, anchor);
  }
}

OK 现在测试一下之前代码 成功

image.png