虚拟DOM更新
上一次,构建了基本的渲染函数,搭建了JSX开发环境,利用插件将JSX变成我们需要的代码,让我们可以用自定义的函数去做这些事情。页面展示没问题了
接下来我们还需要对页面进行更新
const rootDom = document.getElementById("root"); function tick() { const time = new Date().toLocaleTimeString(); // clockElement 会被编译成createElement(...) 通过上一节自定义的{pragma: 'createElement'} const clockElement = <h1>{time}</h1>; render(clockElement, rootDom); } tick(); setInterval(tick, 1000);
// createElement 等代码···· function render(element, parentDom) { // ... // 之前的代码 // ... if (!parentDom.lastChild) { // 如果父级为空,就直接加入 parentDom.appendChild(dom); } else { // 如果有了就执行替换 parentDom.replaceChild(dom, parentDom.lastChild); } }
对于这个小例子,这个解决方案运行良好,但对于更复杂的情况,重新创建所有子节点的性能成本是不可接受的。所以我们需要
一种方法来比较当前和前一次调用生成的元素树->render,并只更新差异。虚拟DOM对比(diff算法)
首先我们需要保留-先前渲染的树,以便我们可以将它与-新树-进行
比较。现在我们的虚拟DOM元素包含2个属性
element dom 现在需要把他自己加进去,形成一个新的树状结构 // 用TS来描述就是这样的 interface Instance { dome: HTMLElement; element: Element; childInstances: Instance[]; }
重构代码
重写我们的
render函数,保持同样的对比算法,并添加一个instantiate函数来创建一个给定元素的-实例(及其子元素):let rootInstance = null; function render(element, container) { const prevInstance = rootInstance; // 用来保存上一次的树 const nextInstance = reconcile(container, prevInstance, element); // 新的树 rootInstance = nextInstance; // 把旧树替换成新树,然后用变量rootInstance 保存下一次用 } function reconcile(parentDom, instance, element) { if (instance == null) { // 一开始的虚拟dom主树干- null // 那么就需要通过element生成我们的虚拟DOM树 const newInstance = instantiate(element); // 第一次,全新添加DOM元素 parentDom.appendChild(newInstance.dom); // 返回最新的树,以便保存下一次做对比 return newInstance; } else { // 旧树存在 const newInstance = instantiate(element); // 暂时先简单的替换 后面优化 parentDom.replaceChild(newInstance.dom, instance.dom); return newInstance; } } // 之前的render函数 => instantiate 传入element生成新的虚拟DOM树 function instantiate(element) { const { type, props } = element; const isTextElement = type === "TEXT ELEMENT"; const dom = isTextElement ? document.createTextNode("") : document.createElement(type); // 事件处理 const isListener = name => name.startsWith("on"); Object.keys(props).filter(isListener).forEach(name => { const eventType = name.toLowerCase().substring(2); dom.addEventListener(eventType, props[name]); }); // 属性设置 const isAttribute = name => !isListener(name) && name != "children"; Object.keys(props).filter(isAttribute).forEach(name => { dom[name] = props[name]; }); // dom 构造完成 // 处理子级 const childElements = props.children || []; // 递归调用instantiate 生成子级的虚拟DOM const childInstances = childElements.map(instantiate); // 获取 子级DOM数组 const childDoms = childInstances.map(childInstance => childInstance.dom); // 子级DOM数组添加到父级 childDoms.forEach(childDom => dom.appendChild(childDom)); // 生成当前的完整虚拟DOM 并返回 return { dom, element, childInstances }; }
为了重新使用DOM节点,我们需要一种方法来-更新DOM属性(className,style,onClick而无需创建一个
新的DOM节点等)。因此,让我们将-当前设置属性的代码部分-提取为-更新它们的通用函数updateDomProperties:function instantiate(element) { const { type, props } = element; const isTextElement = type === "TEXT ELEMENT"; const dom = isTextElement ? document.createTextNode("") : document.createElement(type); // 新建函数去更新属性 updateDomProperties(dom, [], props); const childElements = props.children || []; const childInstances = childElements.map(instantiate); const childDoms = childInstances.map(childInstance => childInstance.dom); childDoms.forEach(childDom => dom.appendChild(childDom)); const instance = { dom, element, childInstances }; return instance; } function updateDomProperties(dom, prevProps, nextProps) { const isEvent = name => name.startsWith("on"); const isAttribute = name => !isEvent(name) && name != "children"; // 移除上一次的事件 Object.keys(prevProps).filter(isEvent).forEach(name => { const eventType = name.toLowerCase().substring(2); dom.removeEventListener(eventType, prevProps[name]); }); // 移除属性 Object.keys(prevProps).filter(isAttribute).forEach(name => { dom[name] = null; }); // 设置最新属性 Object.keys(nextProps).filter(isAttribute).forEach(name => { dom[name] = nextProps[name]; }); // 设置新的事件 Object.keys(nextProps).filter(isEvent).forEach(name => { const eventType = name.toLowerCase().substring(2); dom.addEventListener(eventType, nextProps[name]); }); }
updateDomProperties从dom节点中删除所有旧属性,然后添加所有新属性。如果props发生了变化,它依然会改变,所以它会进行大量不必要的更新,但为了简单起见,现在就让它保持原样。
重用DOM节点
对比算法-需要尽可能多地重用-DOM节点。让我们为该·reconcile·函数添加一个验证,以检查之前渲染的元素
type是否与我们当前正在渲染的元素相同。如果type相同,我们将重新使用它(更新属性以匹配新的属性):function reconcile(parentDom, instance, element) { if (instance == null) { const newInstance = instantiate(element); parentDom.appendChild(newInstance.dom); return newInstance; } else if (instance.element.type === element.type) { // 接下来我们继续之前的优化,先判断一下标签类型是否相同 // 相同就直接属性 updateDomProperties(instance.dom, instance.element.props, element.props); // 把最新的element 放到目前的虚拟DOM上,然后直接返回 instance.element = element; return instance; } else { // 如果不同,元素已经变化了 就需要生成新的 然后返回 const newInstance = instantiate(element); // 然后把旧的元素直接替换掉 parentDom.replaceChild(newInstance.dom, instance.dom); return newInstance; } }
child对比
reconcile功能缺少一个关键步骤,它使children不受影响。child-对比是React的一个关键方面,它需要元素(key)中的额外属性来匹配-先前和当前树中的child。我们将使用这种算法的简易版本,它只比较-children-数组中相同位置的孩子。这种方法的成本是,我们失去了-重用DOM节点的机会,当他们改变渲染之间的子数组的顺序时。为了实现这一点,我们将先前的子实例instance.childInstances与子元素进行匹配element.props.children,然后reconcile逐个调用。我们还保留所有返回的实例,reconcile以便我们可以更新childInstances:
function reconcile(parentDom, instance, element) { if (instance == null) { const newInstance = instantiate(element); parentDom.appendChild(newInstance.dom); return newInstance; } else if (instance.element.type === element.type) { updateDomProperties(instance.dom, instance.element.props, element.props); // 需要 处理子级的虚拟DOM,看看是否有变化 instance.childInstances = reconcileChildren(instance, element); instance.element = element; return instance; } else { const newInstance = instantiate(element); parentDom.replaceChild(newInstance.dom, instance.dom); return newInstance; } } function reconcileChildren(instance, element) { // instance 旧 // element 新 const dom = instance.dom; const childInstances = instance.childInstances; const nextChildElements = element.props.children || []; // 新子级虚拟DOM数组 const newChildInstances = []; // 比较谁长度更长,找一个最大的长度 const count = Math.max(childInstances.length, nextChildElements.length); for (let i = 0; i < count; i++) { // 这里有2种情况 // 1,childInstance = null 表示 childElement 是新加到页面上 const childInstance = childInstances[i]; // 2,childElement = null, 表示需要从页面上删除? 所以等下我们需要考虑删除的情况 const childElement = nextChildElements[i]; // 递归处理 下级 reconcile const newChildInstance = reconcile(dom, childInstance, childElement); newChildInstances.push(newChildInstance); } return newChildInstances; }
删除DOM节点
如果
nextChildElements长于childInstances,reconcileChildren将为所有额外的子元素调用reconcile一个undefined实例。这不应该是一个问题,因为它if (instance == null)会照顾它并创建新的实例。但是反过来呢?当
childInstances它比nextChildElements传递undefined元素的时间长,reconcile并试图获取时抛出错误element.type。这是因为当我们需要从-DOM中删除元素时,我们没有考虑过这种情况。因此,我们需要做两件事情,检查 1.
element == null在-reconcile需要处理删除和 2. 过滤childInstances的-reconcileChildren功能:function reconcile(parentDom, instance, element) { if (instance == null) { const newInstance = instantiate(element); parentDom.appendChild(newInstance.dom); return newInstance; } else if (element == null) { // 如果element没有了,就说明页面上需要移除掉,因为更新,不止是增加,修改,也可以删除 parentDom.removeChild(instance.dom); return null; } else if (instance.element.type === element.type) { updateDomProperties(instance.dom, instance.element.props, element.props); instance.childInstances = reconcileChildren(instance, element); instance.element = element; return instance; } else { const newInstance = instantiate(element); parentDom.replaceChild(newInstance.dom, instance.dom); return newInstance; } } function reconcileChildren(instance, element) { const dom = instance.dom; const childInstances = instance.childInstances; const nextChildElements = element.props.children || []; const newChildInstances = []; const count = Math.max(childInstances.length, nextChildElements.length); for (let i = 0; i < count; i++) { const childInstance = childInstances[i]; const childElement = nextChildElements[i]; // 当旧大于新(删除的情况) childElement 就可能是空的,返回的虚拟DOM也是null const newChildInstance = reconcile(dom, childInstance, childElement); newChildInstances.push(newChildInstance); } // 这里我们就需要过滤一下,如果为null 就不用了 return newChildInstances.filter(instance => instance != null); }
好了,这一下我们一个基本的页面就可以完成了,包含增加,删除,修改元素的能力。
下一步,我们要考虑组件化实现组件与状态功能
class Component {}