在上一篇文章的h函数构建完成后,我们可以根据生成的VNode信息
,来往页面上渲染节点,这就需要用到renderer函数
基本流程
按照DOM节点挂载的流程,一个节点要挂载到页面,应该要知道以下信息
- 节点自身:包括节点类型、节点属性、子节点信息
- 挂载到的父节点
对于Vue里面的虚拟DOM,还需要知道以下信息
- 挂载方式:直接挂载/更新/卸载
- 挂载方法
其中,节点自身的信息我们已经通过之前的h函数
实现了,那么接下来就是实现以下几个部分:
- 挂载方式的判断
- 挂载方法的封装
- 挂载性能的优化(减少挂载/卸载次数)
render函数框架搭建
阅读源码可以知道,renderer函数
包括渲染器本身以及DOM操作
两部分,DOM操作又包括了节点操作和属性操作(例如class
、style
设置)
渲染器本身
渲染器的核心就是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
打补丁操作,包括了挂载新节点/更新旧节点这两种模式,所以入参包括了新旧节点、容器以及锚点。具体处理根据节点自身的type
和shapeFlag形状
决定。
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),
};
属性操作
属性操作包括了class
、style
操作,以及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事件操作
}
};
不同节点的挂载和更新
节点的挂载核心一般包括四步:
- 创建节点
- 设置子节点
- 处理props
- 节点插入
节点的更新核心包括:
- 判断新旧节点是否一致
- 不一致,对比新旧节点的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);
};
子节点更新
子节点的更新比较麻烦,因为节点的类型和属性都可能有变化
从节点类型来看,核心的判断逻辑如下表所示
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方法
更新节点的属性,核心逻辑包括两点:
- 遍历老的属性,放最新的值
- 如果老的属性有值,新的没有,要删除属性
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源码看来,需要做的是
- 删除老的节点
- 挂载新的节点
那么和之前的代码比起来,核心区别就是:判断是否是同一个标签节点,不是的话,先删除,后挂载
要判断是否是同一个标签节点,添加一个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元素来说,属性包括class
、style
以及其他一些属性
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;
}
事件的挂载和更新
事件的挂载和更新本质上调用了addEventListener
和removeEventListener
这两个方法,唯一不一样的是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方法
,目的有二:
- 避免重复创建
- 懒加载,只有在调用了
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);
}
}