Vue3源码学习4——渲染器(renderer函数)

219 阅读11分钟

在上一篇文章的h函数构建完成后,我们可以根据生成的VNode信息,来往页面上渲染节点,这就需要用到renderer函数

基本流程

按照DOM节点挂载的流程,一个节点要挂载到页面,应该要知道以下信息

  • 节点自身:包括节点类型节点属性子节点信息
  • 挂载到的父节点

对于Vue里面的虚拟DOM,还需要知道以下信息

  • 挂载方式:直接挂载/更新/卸载
  • 挂载方法

其中,节点自身的信息我们已经通过之前的h函数实现了,那么接下来就是实现以下几个部分:

  • 挂载方式的判断
  • 挂载方法的封装
  • 挂载性能的优化(减少挂载/卸载次数)

render函数框架搭建

阅读源码可以知道,renderer函数包括渲染器本身以及DOM操作两部分,DOM操作又包括了节点操作属性操作(例如classstyle设置)

渲染器本身

渲染器的核心就是createRenderer方法,里面的options就是DOM操作(应该理解成一些兼容性的方法,例如可能渲染到手机APP之类的)

这个方法最后返回一个render函数,包括了本次要操作的VNode信息容器信息

export function createRenderer(options: RendererOptions) {
  return baseCreateRenderer(options);
}

function baseCreateRenderer(options: RendererOptions): any {
  const render = (vnode, container) => {
    // 如果没有vnode,执行卸载操作
    if (vnode === null) {
      // TODO: 卸载
    }
    // 如果有vnode,挂载/更新(俗称“打补丁”)
    else {
      // 打补丁
      patch(container._vnode || null, vnode, container)
    }
    // 最后需要给容器的_vode赋值为本次操作的vnode
    container._vnode = vnode;
  };
  
  return {
    render,
  };
}

patch打补丁操作,包括了挂载新节点/更新旧节点这两种模式,所以入参包括了新旧节点容器以及锚点。具体处理根据节点自身的typeshapeFlag形状决定。

const patch = (oldVNode, newVNode, container, anchor = null) => {
  // 新旧节点一样,不用操作
  if (oldVNode === newVNode) {
    return;
  }

  const { type, shapeFlag } = newVNode;
  // 根据新节点的type判断
  switch (type) {
    // 一些简单的类型(纯文本、注释、框架)
    case Text:
      break;
    case Comment:
      break;
    case Fragment:
      break;
    default:
      // 其他一些复杂节点(element/组件)
      if (shapeFlag & ShapeFlags.ELEMENT) {
        // TODO: element处理
      } else if (shapeFlag & ShapeFlags.COMPONENT) {
        // TODO: 组件挂载
      }
  }
};

DOM操作

节点操作

Vue3源码里面的节点操作包括了以下:

  • 插入节点
  • 创建节点
  • 设置节点文字
  • 移除节点
  • 创建文本节点
  • 设置节点文本
  • 创建注释节点

实际上都是一些DOM操作,这里其实是做了封装

const doc = document;

const nodeOps = {
  insert: (child, parent, anchor) => {
    parent.insertBefore(child, anchor || null);
  },

  createElement: (tag): Element => {
    const el = doc.createElement(tag);
    return el;
  },

  setElementText: (el: Element, text) => {
    el.textContent = text;
  },

  remove: (child: Element) => {
    const parent = child.parentNode;
    if (parent) {
      parent.removeChild(child);
    }
  },

  createText: (text: string) => doc.createTextNode(text),

  setText: (node: Element, text: string) => (node.nodeValue = text),

  createComment: (text: string) => doc.createComment(text),
};

属性操作

属性操作包括了classstyle操作,以及Vue的on事件转换成DOM事件

const patchProp = (el: Element, key, prevValue, nextValue) => {
  if (key === "class") {
    // TODO: class操作
  } else if (key === "style") {
    // TODO: style操作
  } else if (isOn(key)) {
    // TODO: on事件操作
  }
};

不同节点的挂载和更新

节点的挂载核心一般包括四步:

  1. 创建节点
  2. 设置子节点
  3. 处理props
  4. 节点插入

节点的更新核心包括:

  1. 判断新旧节点是否一致
  2. 不一致,对比新旧节点的tag类型和children类型,执行不同的更新逻辑

Element节点

Element节点的挂载/更新,用的是processElement方法

const processElement = (oldVNode, newVNode, container, anchor) => {
  if (oldVNode == null) {
    // 挂载
    mountElement(newVNode, container, anchor);
  } else {
    // 更新
    patchElement(oldVNode, newVNode);
  }
};

Element节点挂载

当没有旧节点的时候,意味着直接挂载,在源码中调用的是mountElement方法

const mountElement = (vnode, container, anchor) => {
  const { type, props, shapeFlag } = vnode;
  // 1. 创建element
  const el = (vnode.el = hostCreateElement(type));
  
  // 根据shapeFlag判断具体的子节点类型
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    // 2. 设置文本
    hostSetElementText(el, vnode.children as string);
  } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
    // TODO: 子节点是数组的情况
  }

  // 3. 设置props
  if (props) {
    for (const key in props) {
      hostPatchProp(el, key, null, props[key]);
    }
  }
  // 4. 插入
  hostInsert(el, container, anchor);
};

具体到DOM操作,要从options中取值,即拿到那些封装好的DOM操作方法

const {
  insert: hostInsert,
  patchProp: hostPatchProp,
  setElementText: hostSetElementText,
  createComment: hostCreateComment,
} = options;

Element节点更新

当新旧节点都有的时候,意味着是节点的更新,在源码中调用的是patchElement方法

节点的更新首先要更新子节点,之后再考虑节点本身的属性更新

const patchElement = (oldVNode, newVNode) => {
  const el = (newVNode.el = oldVNode.el);

  const oldProps = oldVNode.props || EMPTY_OBJ;
  const newProps = newVNode.props || EMPTY_OBJ;

  patchChildren(oldVNode, newVNode, el, null);

  patchProps(el, newVNode, oldProps, newProps);
};
子节点更新

子节点的更新比较麻烦,因为节点的类型属性都可能有变化

从节点类型来看,核心的判断逻辑如下表所示

子节点更新逻辑.png

const patchChildren = (oldVNode, newVNode, container, anchor) => {
  const c1 = oldVNode && oldVNode.children;
  const c2 = newVNode && newVNode.children;

  const prevShapeFlag = oldVNode ? oldVNode.shapeFlag : 0;
  const { shapeFlag: newShapeFlag } = newVNode;

  // 新节点是文本的情况,直接更新成文本就好
  if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // TODO: 卸载旧子节点
    }
    // 比较新旧节点,只有在不一样的时候再去挂载新的子节点文本,提升性能
    if (c2 !== c1) {
      // 挂载新的子节点文本
      hostSetElementText(container, c2);
    }
  }
  // 新节点不是文本
  else {
    if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
      // 新旧节点都是数组
      if (newShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // TODO: diff计算
      }
      // 老节点数组,新节点不是数组,卸载老的
      else {
        // TODO: 卸载
      }
    }
    // 老节点不是数组(文本或者没有)
    else {
      // 老节点是文本
      if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
        // 删除旧节点text
        hostSetElementText(container, "");
      }
      // 新节点是数组
      if (newShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
        // TODO: 单独新子节点挂载
      }
    }
  }
};
节点属性更新

使用patchProps方法更新节点的属性,核心逻辑包括两点:

  1. 遍历老的属性,放最新的值
  2. 如果老的属性有值,新的没有,要删除属性
const patchProps = (el: Element, vnode, 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);
      }
    }
    
    // 遍历老的属性,更新上新的值/删除原来的值
    if (oldProps !== EMPTY_OBJ) {
      for (const key in oldProps) {
        // 对于老的props中有的,但是新的没有的,就不用留一个空值,而是删除
        if (!(key in newProps)) {
          hostPatchProp(el, key, oldProps[key], null);
        }
      }
    }
  }
};
更新不同标签的节点

如果说新的节点和老的节点的tag不一致,从Vue源码看来,需要做的是

  1. 删除老的节点
  2. 挂载新的节点

那么和之前的代码比起来,核心区别就是:判断是否是同一个标签节点,不是的话,先删除,后挂载

要判断是否是同一个标签节点,添加一个isSameVNodeType方法,核心依据type判断即可

function isSameVNodeType(oldVNode: VNode, newVNode: VNode) {
  return oldVNode.type === newVNode.type && oldVNode.key === newVNode.key;
}

最后,在render方法patch方法里添加一个新旧元素tag是否一致的判断,从而卸载tag不一致的老节点

const patch = (oldVNode, newVNode, container, anchor = null) => {
  ......
  // 判断新旧节点是否是同一元素
  if (oldVNode && !isSameVNodeType(oldVNode, newVNode)) {
    unmount(oldVNode);
    oldVNode = null;
  }
  ......
}

Text文本节点

文本节点的挂载和更新其实比较简单,就是在处理DOM元素的内部文字的添加/更新/删除

文本节点用到的是DOM原生操作中创建节点的文本内容(创建)以及对节点nodeValue的设置(更新)

const processText = (oldVNode, newVNode, container, anchor) => {
  if (oldVNode == null) {
    // 挂载
    newVNode.el = hostCreateText(newVNode.children);
    hostInsert(newVNode.el, container, anchor);
  } else {
    // 更新(只有新旧节点不一样的时候才触发)
    const el = (newVNode.el = oldVNode.el!);
    if (newVNode.children !== oldVNode.children) {
      hostSetText(el, newVNode.children);
    }
  }
};

const {
  createText: hostCreateText,
  setText: hostSetText,
} = options;

createText: (text: string) => doc.createTextNode(text),

setText: (node: Element, text: string) => (node.nodeValue = text),

Comment注释节点

注释节点和Text文本节点差不多,唯一不同的是注释节点没有响应式,所以只有挂载,没有更新

注释节点用到了DOM原生操作中的createComment方法

const processComment = (oldVNode, newVNode, container, anchor) => {
  if (oldVNode == null) {
    newVNode.el = hostCreateComment(newVNode.children);
    hostInsert(newVNode.el, container, anchor);
  } else {
    newVNode.el = oldVNode.el;
  }
};

const { createComment: hostCreateComment } = options;

createComment: (text: string) => doc.createComment(text)

Fragment片段节点

Fragment片段本质上是一个包裹性质的容器只会渲染其子节点

因此,processFragment这个方法应该是对children的渲染和更新

const processFragment = (oldVNode, newVNode, container, anchor) => {
  if (oldVNode == null) {
    mountChildren(newVNode.children, container, anchor);
  } else {
    patchChildren(oldVNode, newVNode, container, anchor);
  }
};

patchChildren之前实现了,现在唯一要补充的就是mountChildren,逻辑也不复杂,就是循环children然后挨个patch

const mountChildren = (children, container, anchor) => {
  for (let i = 0; i < children.length; i++) {
    // normalizeVNode是为了生成标准化的VNode节点
    const child = (children[i] = normalizeVNode(children[i]));
    patch(null, child, container, anchor);
  }
};

// 生成标准化vnode
function normalizeVNode(child) {
  if (typeof child === "object") {
    // child是对象意味着已经是VNode了,其实可以直接返回,这里是对标了源码
    return cloneIfMounted(child);
  } else {
    return createVNode(Text, null, String(child));
  }
}

function cloneIfMounted(child) {
  return child;
}

实现到这里可能会报一个错误,主要是因为children为字符串的时候,用下标可以读到单个字符串,但是这个属性不可编辑(只读)

为了处理这个bug,需要对字符串类型的内容做切割

const mountChildren = (children, container, anchor) => {
  if (isString(children)) {
    children = children.split("");
  }
  ......
};

节点删除

节点的删除逻辑统一:当新节点不存在的时候,就意味着删除

这里添加一个删除节点的DOM操作方法即可

export const nodeOps = {
  ......
  remove: (child: Element) => {
    const parent = child.parentNode;
    if (parent) {
      parent.removeChild(child);
    }
  }
}

删除节点直接用unmount方法处理即可

const { remove: hostRemove } = options;

const unmount = (vnode) => {
  hostRemove(vnode.el);
};

const render = (vnode, container) => {
  if (vnode === null) {
    // 卸载
    if (container._vnode) {
      unmount(container._vnode);
    }
  }
  .......
};

属性的挂载和更新

对于DOM元素来说,属性包括classstyle以及其他一些属性

class挂载和更新

class的挂载/更新其实比较简单,直接往class这个属性上赋值/移除属性即可

function patchClass(el: Element, value: string | null) {
  if (value === null) {
    el.removeAttribute("class");
  } else {
    el.className = value;
  }
}

style挂载和更新

style挂载/更新也比较简单,就是挂载新的style,同时删除旧的

function patchStyle(el: Element, prev, next) {
  const style = (el as HTMLElement).style;

  const isCssString = isString(style);

  if (next && !isCssString) {
    // 新样式挂载
    for (const key in next) {
      setStyle(style, key, next[key]);
    }

    // 旧样式处理
    if (prev && !isString(prev)) {
      for (const key in prev) {
        // 如果新的样式的key没有了,那就要删除旧的key
        if (next[key] == null) {
          setStyle(style, key, "");
        }
      }
    }
  }
}

function setStyle(
  style: CSSStyleDeclaration,
  name: string,
  value: string | string[]
) {
  style[name] = value;
}

事件的挂载和更新

事件的挂载和更新本质上调用了addEventListenerremoveEventListener这两个方法,唯一不一样的是Vue用了一个event invokers,避免了事件反复挂载和卸载

event invokers

如果常规写一个方法的绑定,2秒后重新绑定事件的话,可能会考虑这种先绑定再解绑重新绑定的写法

const buttonElement = document.querySelector('button')

const invoker = () => { console.log('hello') }

buttonElement.addEventListener('click', invoker)

setTimeout(() => {
  buttonElement.removeEventListener('click', invoker)
  buttonElement.addEventListener('click', () => { console.log('world') })
}, 2000)

但是这种写法会增大浏览器的负担,尤其是反复挂载/卸载

为了解决这样的问题,我们可以考虑不重复挂载/卸载,而是变更这个方法本身,所以就用到了invoker

const buttonElement = document.querySelector('button')

// 这个value很重要,等于每次修改事件只要修改value值,而且调用的时候也是调用value值
const invoker = () => {
  invoker.value()
}
invoker.value = () => {
  console.log('hello')
}

buttonElement.addEventListener('click', invoker)

setTimeout(() => {
  // 修改invoker的value就可以做到修改绑定事件了,不再需要重复卸载/绑定
  invoker.value = () => {
    console.log('world')
  }
}, 2000)

源码中的事件挂载更新处理

首先添加上针对事件的判断,即on开头,后面首字母大写

const onRE = /^on[^a-z]/;
export const isOn = (key: string) => onRE.test(key);

const patchProp = (el: Element, key, prevValue, nextValue) => {
  ......
  else if (isOn(key)) {
    patchEvent(el, key, prevValue, nextValue);
  }
  ......
};

patchEvent内部用了invokers,为了提升性能,用这种判断逻辑

  • 如果是新的监听事件,用addEventListener
  • 如果更新了监听事件回调方法,更新invokers
  • 只有当监听事件的回调方法为空(即不再监听),才使用removeEventListener
function patchEvent(
  el: Element & { _vei?: object },
  rawName: string,
  prevValue,
  nextValue
) {
  const invokers = el._vei || (el._vei = {});

  // 寻找缓存事件
  const existingInvoker = invokers[rawName];

  // 更新缓存事件
  if (nextValue && existingInvoker) {
    existingInvoker.value = nextValue;
  } else {
    // 这里需要先把onXXX去掉on,并首字母小写,变成DOM里的事件名称
    const name = parseName(rawName);
    if (nextValue) {
      const invoker = (invokers[rawName] = createInvoker(nextValue));
      el.addEventListener(name, invoker);
    } else if (existingInvoker) {
      el.removeEventListener(name, existingInvoker);
      invokers[rawName] = undefined;
    }
  }
}

// 把Vue里的事件名称变更成DOM里的
function parseName(name: string) {
  return name.slice(2).toLowerCase();
}

function createInvoker(initialValue) {
  const invoker = (e: Event) => {
    invoker.value && invoker.value();
  };

  invoker.value = initialValue;

  return invoker;
}

其他细节

render函数的导出

在实现renderer渲染器的过程中可以发现,render方法是baseCreateRenderer方法的一个返回值baseCreateRenderer方法又是createRenderer方法的返回值,而我们实际上要调用的只是render方法

所以源码做了一个事情,构建了一个render函数,但这个函数本质调用了createRenderer方法,将所需的参数传递过去(包含了节点操作属性操作

查看Vue3源码可以发现以下逻辑

const rendererOptions = extend({ patchProp }, nodeOps);

let renderer;

function ensureRenderer() {
  return renderer || (renderer = createRenderer(rendererOptions));
}

export const render = (...args) => {
  ensureRenderer().render(...args);
};

至于这里为什么要创建renderer变量ensureRenderer方法,而不是直接在render函数中调用createRenderer方法,目的有二:

  1. 避免重复创建
  2. 懒加载,只有在调用了render方法的时候才创建renderer渲染器实例

属性区分挂载

对于一个DOM节点的属性,有两种

  • HTML属性:定义在HTML标签
  • DOM属性:定义在DOM对象上,可以用.访问属性值

对于HTML属性,需要用setAttribute去操作,而DOM属性则可以直接用.赋值

为了区分应该用HTML属性设置还是用DOM属性设置,使用shouldSetAsProp方法去判断

function shouldSetAsProp(el: Element, key: string) {
  if (key === "form") return false;
  // input的list属性必须通过setAttribute设置
  if (key === "list" && el.tagName === "INPUT") return false;
  if (key === "type" && el.tagName === "TEXTAREA") return false;
  return key in el;
}

区分之后,如果可以用.设置的就直接设置,否则用setAttribute方法(class用.设置速度更快)

const patchProp = (el: Element, key, prevValue, nextValue) => {
  ......
  else if (shouldSetAsProp(el, key)) {
    patchDOMProp(el, key, nextValue);
  } else {
    patchAttr(el, key, nextValue);
  }
};

function patchDOMProp(el, key, value) {
  try {
    el[key] = value;
  } catch (err) {
    console.error(err);
  }
}

function patchAttr(el: Element, key, value) {
  if (value == null) {
    el.removeAttribute(key);
  } else {
    el.setAttribute(key, value);
  }
}