目的
这次主要是实现 vue 和 react 框架核心的patch概念, 为了防止重复渲染生成dom节点,提高效率。分析对比前后render的VNode 的差别来确定dom节点的更新、删除、添加。
这里我们可以参考react官方的对这个的说明。
我们这里设计实现的会简单一些
patch流程
怎么patch?和之前实现的mount的关系是什么?这里我们可以画一个流程图来展示一下render的过程
patch就是对比新旧VNode的差异,最小限度的更新dom节点,我们也会根据这个流程来渲染我们的虚拟节点生成dom节点。
代码实现
render
这次我们重新实现render函数。实现的是下面的这部分流程。n1是上一次渲染的VNode,我们将渲染VNode挂载到container dom节点上,以便下一次render 时对比使用
//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赋值为该节点
//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
};
}
这里的unmountComponent 和 unmountFragment 我们先定义好 之后再实现
patch
接下来就是patch 对比新旧VNode 比较之间的差异,来更新、删除、增加dom节点了
//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方法
实现代码
//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
首先同样是判断
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);
});
}
这里我们接着来实现 patchProps 和 patchChildren 这两个方法
patchProps
接收前后的VNode节点的props属性,根据n2的props判断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的九种情况,如下图
一种一种实现:
//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);
}
}
}
这里我们又要实现unmountChildren 和 patchArrayChildren、mountChildren方法
//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
代码实现
//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秒之后重新render时middle挂载到了最后面,有问题。
原因是第二次
render时 Fragment子元素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属性
processComponent,processText,patch,processElement,mountChildren,patchChildren,patchArrayChildren
改写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);
}
还需要注意一下问题
改写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 现在测试一下之前代码 成功