首先欢迎你们关注、点赞、收藏个人掘金帐号和Github博客,也算是对个人一点鼓励,毕竟写东西无法得到变现,能坚持下去也是靠的是本身的热情和你们的鼓励。以前的文章咱们介绍了MV*框架的历史以及React引入Virtual DOM所带来的新的解决思路,俗话说,百闻不如一见,百见不如一干。这篇文章咱们将尝试使用去实现一个Virtual DOM的最小化实现方案,由于最近刚学了TypeScript,正好拿来练手。源码地址将在文章最后附录。
javascript
不管是MVC模式仍是后来改进的MVP模式以及目前更为常见的MVVM模式,其目的都是为了解决Model层和View如何链接,经过采用各类中间层(Controller、Presenter、View of Model)协调View与Model的关系。可是React所倡导的Virtual DOM方案却剑走偏锋,即每次Model层的变化都会从新渲染View层,那么做为开发者而言,只须要处理好数据和视图映射,从而将咱们的关注重点集中于数据和数据流的变化,从而极大的下降开发关注度。css
实际上咱们都知道浏览器对DOM的操做所带来的渲染重绘相比于JavaScript计算速度确定是慢上好几个数量级的。假设仅仅只是页面中一个数据的变化就重绘整个页面,那确定是咱们所不能接受的。借鉴计算机学科中最经常使用的Cache思想,咱们在低速的DOM操做和高速的JavaScript执行之间引入了Virtual DOM,经过对比两个Virtual DOM节点的变化,找出其中的变化,从而精准地修改DOM节点,在实现思路的同时尽量地下降操做代价,达到良好的性能体验。html
众所周知,把大象装到冰箱须要三步,那么实现一个Virtual DOM库须要几步呢?前端
上图就是咱们要实现Virtual DOM的基本流程:java
上面的四个步骤也就基本对应着咱们所要实现Virtual DOM的四个函数:node
createElement
render
diff
applyDiff
乍一看想要实现Virtual DOM库可能感受很有难度,可是通过仔细的分析,其实将问题转化成实现四个特定功能的函数。其实这种思惟方式在咱们软件开发中仍是很是的实用的,当目标过大而无从下手时,要学会将目标合理拆分。React所倡导的前端组件化其实就包含这个思想,组件化最重要的两个特色就是:复用和分治,咱们每每过于强调复用的特性。其实相比复用,分治才是组件化的精髓,咱们经过划分组件,每每使得特定组件仅具备相对较为简单的职责功能,而后经过组合简单的组件成为复杂的功能。相比而言,维护功能职责简单的组件更为容易,也不容易出错。接下来咱们要作的就是一步步实现各个函数功能,最终实现一个简单的Virtural DOM库。react
在此以前,咱们首先简要介绍JSX的做用,由React发扬光大的JSX语法使得咱们更为方便的在JavaScript中建立HTML,描述UI界面。JSX语法并非某个库所独有的,而是一种JavaScript函数调用的语法糖,JSX其实至关于JavaScript + HTML(也被称为hyperscript,即hyper + script,hyper是HyperText超文本的简写,而script是JavaScript的简写)。在React中,JSX语法都会转化成React.createElement
调用,而在Preact中,JSX语法则会被转成preact.h
函数调用。git
例如在React中:github
<ul> <li>列表1</li> <li>列表2</li> <li>列表3</li> </ul>
则会被转化为:算法
React.createElement( 'ul', null, React.createElement('li', null, '列表1'), React.createElement('li', null, '列表2'), React.createElement('li', null, '列表3') );
其中createElement
的参数依次是元素类型、属性、以及子元素。类型元素能够分为三种,依次是:字符串、函数、类,依次对应于HTML固有元素、无状态函数组件 (SFC)、类组件。本篇文章重点只在于阐释Virtual DOM基本原理,所以简单起见,咱们仅支持HTML固有元素,暂不支持无状态函数组件 (SFC)和类组件。
JSX能够根据使用的框架编译成不一样的函数调用,例如React的React.createElement
或者Preact的h
,咱们能够经过在JSX上添加编译注释(Pragma)来局部改变,例如:
/** @jsx h */ let dom = <div id="foo">Hello!</div>;
经过为JSX添加注释@jsx(这也被成为Pragma,即编译注释),可使得Babel在转化JSX代码时,将其装换成函数h的调用。固然,也能够在工程全局进行配置,好比咱们能够在Babel6中的.babelrc文件中设置:
{ "plugins": [ ["transform-react-jsx", { "pragma":"h" }] ] }
这样工程中全部用到JSX的地方都是被Babel转化成使用h函数的调用。在TypeScript中咱们能够经过更改配置文件tsconfig.json
中的jsxFactory
来控制JSX的编译,具体可参照TypeScript中关于JSX的文档,再也不此处赘述。
根据Virtual DOM节点的特色,咱们给出Virtual DOM节点类描述:
// 类型别名 type TagNameType = string; type KeyType = string | number | null; interface PropsType { key?: string | number; [prop: string]: any; } // 类 class VNode { // 节点类型 public tagName: TagNameType; // 属性 public props: PropsType; // key public key? : KeyType; // 子元素 public children: (VNode | string)[]; public constructor(tagName: TagNameType) { this.tagName = tagName; this.key = null; this.children = []; this.props = {}; } }
其中tagName
为元素类型,例如:div
、p
。由于咱们暂时仅支持HTML固有元素,所以TagNameType
是字符串类型。props
为元素属性,在接口PropsType
咱们规定属性中的key
值必须为number
或者string
或者null
(null
不传key
属性),若是对key
有不明白的同窗,欢迎你们阅读我以前的文章:React技术内幕:key带来了什么。
接下来让咱们看一下createElement
函数的定义:
function createElement(tagName: TagNameType, props: PropsType, ...children: any[]) { let key: KeyType = null; if (isNotNull(props)) { if (isKey(props.key)) { key = props.key!; delete props.key; } if (isNotNull(props.children)) { children.push(props.children); delete props.children; } } const node = new VNode(tagName); node.children = flatten(children); node.key = key; node.props = isNotNull(props) ? props : {}; return node; }
若是props
中含有key
属性,则会将其从props中删除,单独赋值给VNode的key属性,而处理props中的children
属性主要目的是为了处理如下状况经过props
中的children
属性直接传递子元素。而对children
调用flatten
主要是为了处理:
const dom = ( <ul> { Array.from({length: 3}).map((val, index)=>{ return (<li key={index}>列表</li>) }) } </ul> );
在这种状况下createElement
中的chilren[0]
是子元素数组,所以咱们使用flatten
函数将其处理普通的子元素数组。
经过createElement
函数咱们就能够将JSX转化成Virtual DOM节点,写个单元测试验证一下是否正确:
describe('createElement', () => { test('多个子元素-数组形式', () => { const dom = ( <ul> { Array.from({ length: 2 }).map((val, index) => { return <li key={index}></li>; }) } </ul> ); const ul = new VNode('ul'); ul.children = Array.from({ length: 2 }).map((val, index) => { const li = new VNode('li'); li.key = index; return li; }); expect(dom).toEqual(ul); }); });
运行一下,Bingo,测试经过。
将Virtual DOM树渲染成真实DOM函数也并不复杂:
const renderDOM = function (vnode: VNodeChildType) { if (isVNode(vnode)) { let node = document.createElement(vnode.tagName); // 设置元素属性 for (const prop of Object.keys(vnode.props)) { let value = vnode.props[prop]; if (prop === 'style') { value = transformStyleToString(value); } node.setAttribute(prop, value); } for (const child of vnode.children) { node.appendChild(renderDOM(child)); } return node; } if (typeof vnode === 'number') { return document.createTextNode(String(vnode)); } return document.createTextNode(vnode); }; const render = function (vnode: VNode, root: HTMLElement) { const dom = renderDOM(vnode); root.appendChild(dom); return dom; }
其中逻辑并不复杂,只须要特殊说起一点,元素中style
属性较为特殊,style
属性用来经过CSS为元素指定样式。在经过getAttribute()
访问时,返回的style
特性值中包含的是CSS文本,而经过属性来访问它则会返回一个对象。所以在这里咱们经过setAttribute
函数设置元素样式前,经过transformStyleToString
函数将样式从对象改变为字符串类型,而且将驼峰式的样式属性转化为普通的CSS样式属性,具体可见函数定义:
const transformStyleToString = function (style) { // 如果文本类型则直接返回 if (isString(style)) { return style; } // 如果对象类型则转为字符串 if (isObject(style)) { return Object.keys(style).reduce((acc, key) => { let cssKey = key.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`); return acc + `${cssKey}: ${style[key]};`; }, ''); } return ''; };
Diff算法多是Virtual DOM中相对较为复杂的部分,固然咱们只是为了实现一个简易的Virtual DOM系统,并不须要过于复杂的实现,下面只是我本身的一种实现策略,并不具备广泛性。Diff算法目的是为了比较两棵Virtual DOM树的差别,传统diff算法的复杂度为 O(n^3),实际上前端DOM树结构具备其自身的特定,所以衍生了各类各样的启发式算法,并将Diff算法的时间复杂度下降到O(n)。
所谓的启发式算法是指:在解决问题时所采起的一种根据经验规则进行发现的方法。其特色是在解决问题时,利用过去的经验,选择已经行之有效的方法,而不是系统地、以肯定的步骤去寻求答案。而在Diff中启发式算法主要是依赖于下列条件:
同级比较是指,咱们仅会对比同一层次的节点,而忽略跨层级的DOM移动操做。对于同一层次节点的增长和删除,咱们则不会进一步比较其子节点,这样只须要对树遍历一遍便可。
以上图为例,父节点从ul
变为p
节点,即便ul
大部分节点也能够重用,但咱们并不会跨层级比较,所以咱们会从新渲染div
及其子节点。
同元素比较是指,当遇到元素类型变化时,不会比较两个组件的不一样,直接建立新的元素。
以上图为例,父节点从ul
变为ol
节点,即便ul
子节点并未发生改变,但咱们认为元素类型从ul
改变为ol
,虽然子节点未发生改变,咱们并不会比较子节点,直接建立新的节点。
子元素比较是指,当节点处于同一层级时,咱们认为存在如下的节点操做:
INSERT_MARKUP
)REMOVE_NODE
)MOVE_EXISTING
)以上图为例,假设以前的Virtual DOM树为Old Tree。
当比较第一个子元素div
时,由于New Tree中的div
与同位置Old Tree中的div
节点类型一致,则咱们认为先后变化中对应位置的节点还是同一个,则咱们会继续比较节点属性及其及其子节点。
当比较第二个子元素时,由于p
节点含有key
属性,且key = 2
的节点也存在于Old Tree,而且先后两个key = 2
的节点类型是一致的,所以咱们认为New Tree中key = 2
的p
元素是由Old Tree中第三个子元素移动(MOVE_EXISTING
)而来。
当比较第三个子元素时,由于p
节点含有key = 3
且Old Tree中并不含有key = 3
的同类型节点,则咱们认为改节点属于插入节点(INSERT_MARKUP
)。
当咱们比较a
元素的子节点时,由于New Tree中已经不存在该位置的节点,所以咱们认为改节点属于删除节点(REMOVE_NODE
)。
当比较两棵Virtual DOM树时,咱们须要记录两棵Virtual DOM树的区别,咱们将其称为Patch
,由于咱们须要记录的是树节点的差别,所以咱们也能够将Patch
同类化一个树结构。根据Patch类特色,咱们给Patch类的定义:
class Patch { // 节点变化类型 public types: OPERATOR_TYPE[]; // 子元素Patch集合 public children: Patch[]; // 存储带渲染的新节点 public node: VNode | string | null; // 存储属性改变 public modifyProps: ModifyProps[]; // 文本改变 public modifyString: string; // 节点移动,搭配`MOVE_EXISTING`使用 public removeIndex: number; public constructor(types?: (OPERATOR_TYPE | OPERATOR_TYPE[])) { this.types = []; this.children = []; this.node = null; this.modifyProps = []; this.modifyString = ''; this.removeIndex = 0; if (types) { types instanceof Array ? this.types.push(...types) : this.types.push(types); } } // 省略类方法实现 // addType // addModifyProps // addChildPatch // setNode // setModifyString // setRemoveIndex }
其中types
属性用于存储变化类型,注意同一个Patch可能存在多种变化类型,所以咱们使用数组存储。Patch
存在如下几种类型:
export const enum OPERATOR_TYPE { INSERT_MARKUP, MOVE_EXISTING, REMOVE_NODE, PROPS_CHANG, TEXT_CHANGE }
其中INSERT_MARKUP
、MOVE_EXISTING
、REMOVE_NODE
咱们都已经介绍过,而PROPS_CHANG
表示节点属性发生改变,例如id
属性变化。而TEXT_CHANGE
适用于文本节点,表示文本节点内容发生改变。例如文本节点内容从Hello!
改变为Hello World
。
node
属性用于存储待渲染的节点类型类型,搭配INSERT_MARKUP
使用。removeIndex
属性表示当前节点是从同层序号节点位置移动而来,搭配MOVE_EXISTING
使用。modifyString
表示文本节点变化后的内容,搭配TEXT_CHANGE
使用。modifyProps
表示属性改变的数组,搭配PROPS_CHANGE
使用。其中ModifyProps
接口描述为:
const enum PROP_TYPE { ADD, // 新增属性 DELETE, // 删除属性 MODIFY // 属性改变 } interface ModifyProps { type: PROP_TYPE; key?: string; value?: any; }
完事具有,让咱们开始实现diff
函数
// 用于返回子元素数组NodeList中key-node集合(Map) function getChildrenKeyMap(children: VNodeChildType[]) { let map = new Map(); each(children, child => { if (isVNode(child) && isKey(child.key)) { map.set(child.key, child); } }); return map; } // 返回给定key值对应节点所在位置 function getChildIndexByKey(children: VNodeChildType[], key) { return findIndex(children, child => (isVNode(child) && child.key === key)); } function diffProps(preProps: PropsType, nextProps: PropsType) { // return [...addPropResult, ...deletePropResult, ...modifyPropResult]; // 返回Props比较数组结果,若是不存在Props变化则返回空数组。 }
function diff(preNode: VNode | null, nextNode: VNode) { const patch = new Patch(); // 若节点类型不一致或者以前的元素为空,则直接从新建立该节点及其子节点 if (preNode === null || preNode.tagName !== nextNode.tagName) { return patch.addType(OPERATOR_TYPE.INSERT_MARKUP).setNode(nextNode); } // 先后两个虚拟节点类型一致,则须要比较属性是否一致 const propsCompareResult = diffProps(preNode.props, nextNode.props); if (isNotEmptyArray(propsCompareResult)) { patch.addType(OPERATOR_TYPE.PROPS_CHANGE).addModifyProps(propsCompareResult); } // 若是上一个子元素不为空,且下一个子元素全为空,则须要清除全部子元素 if (isEmptyArray(nextNode.children) && isNotEmptyArray(preNode.children)) { return patch.addChildPatch(preNode.children.map(() => new Patch(OPERATOR_TYPE.REMOVE_NODE))); } const preChildrenKeyMap = getChildrenKeyMap(preNode.children); // 遍历处理子元素 each(nextNode.children, (child, index) => { const nextChild = child; const preChild = isNotNull(preNode.children[index]) ? preNode.children[index] : null; // 若是当前子节点是字符串类型 if (isString(nextChild)) { // 以前对应节点也是字符串 if (isString(preChild)) { if (nextChild === preChild) { return patch.addChildPatch(new Patch()); } else { return patch.addChildPatch((new Patch(OPERATOR_TYPE.TEXT_CHANGE).setModifyString(nextChild))); } } else { // 以前对应节点不是字符串,则须要建立新的节点 return patch.addChildPatch((new Patch(OPERATOR_TYPE.INSERT_MARKUP)).setNode(nextChild)); } } // 若当前的子节点中存在key属性 if (isVNode(nextChild) && isKey(nextChild.key)) { // 若是上一个同层虚拟DOM节点中存在相同key且元素类型相同的节点 if (preChildrenKeyMap.has(nextChild.key) && preChildrenKeyMap.get(nextChild.key).tagName === nextChild.tagName) { // 若是先后两个元素的key值和元素类型相等 const preSameKeyChild = preChildrenKeyMap.get(nextChild.key); const sameKeyIndex = getChildIndexByKey(preNode.children, nextChild.key); const childPatch = diff(preSameKeyChild, nextChild); if (sameKeyIndex !== index) { childPatch.addType(OPERATOR_TYPE.MOVE_EXISTING).setRemoveIndex(sameKeyIndex); } return patch.addChildPatch(childPatch); } else { // 直接建立新的元素 return patch.addChildPatch((new Patch(OPERATOR_TYPE.INSERT_MARKUP).setNode(nextChild))); } } // 子节点中不存在key属性 // 若先后相同位置的节点是 非VNode(字符串) 或者 存在key值( nextChild不含有key) 或者是 节点类型不一样,则直接建立新节点 if (!isVNode(preChild) || isKey(preChild.key) || preChild.tagName !== nextChild.tagName) { return patch.addChildPatch((new Patch(OPERATOR_TYPE.INSERT_MARKUP)).setNode(nextChild)); } return patch.addChildPatch(diff(preChild, nextChild)); }); // 若是存在nextChildren个数少于preChildren,则须要补充删除节点 if (preNode.children.length > nextNode.children.length) { patch.addChildPatch(Array.from({ length: preNode.children.length - nextNode.children.length }, () => new Patch(OPERATOR_TYPE.REMOVE_NODE))); } return patch; }
咱们简单举例下图场景,分别给new Tree
和old Tree
调用diff
算法,则会生成图中所示的Patch Tree
:
applyDiff
函数的实现则相对要简单的多,咱们只要对照Patch
Tree,对以前渲染的DOM树进行修改便可。
function applyChildDiff(actualDOM: HTMLElement, patch: Patch) { // 由于removeIndex是基于old Tree中的序号位置,所以咱们须要提早备份节点节点顺序关系 const childrenDOM = map(actualDOM.childNodes, child => child); const childrenPatch = patch.children; for (let index = 0; index < actualDOM.childNodes.length; index++) { const childPatch = childrenPatch[index]; let childDOM = childrenDOM[index]; if (contains(childPatch.types, OPERATOR_TYPE.MOVE_EXISTING)) { const insertDOM = childrenDOM[childPatch.removeIndex]; actualDOM.insertBefore(insertDOM, childDOM); childDOM = insertDOM; } innerApplyDiff(childDOM, childPatch, actualDOM); } } function innerApplyDiff(actualDOM: HTMLElement | Text, patch: Patch, parentDOM: HTMLElement) { // 处理INSERT_MARKUP,直接建立新节点替换以前节点 if (contains(patch.types, OPERATOR_TYPE.INSERT_MARKUP)) { const replaceDOM = renderDOM(patch.node!); parentDOM.replaceChild(replaceDOM, actualDOM); return replaceDOM; } // 处理REMOVE_NODE,直接删除当前节点 if (contains(patch.types, OPERATOR_TYPE.REMOVE_NODE)) { parentDOM.removeChild(actualDOM); return null; } // 处理TEXT_CHANGE if (contains(patch.types, OPERATOR_TYPE.TEXT_CHANGE)) { actualDOM.nodeValue = patch.modifyString; return actualDOM; } // 处理PROPS_CHANGE if (contains(patch.types, OPERATOR_TYPE.PROPS_CHANGE)) { each(patch.modifyProps, function (modifyProp) { let key = modifyProp.key; let value = modifyProp.value; switch (modifyProp.type) { case PROP_TYPE.ADD: case PROP_TYPE.MODIFY: if (key === 'style') { value = transformStyleToString(value); } actualDOM.setAttribute(key, value); break; case PROP_TYPE.DELETE: actualDOM.removeAttribute(key); break; } }); } if (isHTMLElement(actualDOM)) { applyChildDiff(actualDOM, patch); } return actualDOM; } function applyDiff (actualDOM: HTMLElement | Text, patch: Patch) { if (!(actualDOM.parentNode instanceof HTMLElement)) { throw Error('DOM元素未渲染'); } return applyDiff(actualDOM, patch, actualDOM.parentNode); }
如今咱们的Virtual DOM库已经基本完成,咱们起个名字就叫Vom,让咱们尝试使用一下:
import Vom from '../index'; function getRandomArray(length) { return Array.from(new Array(length).keys()).sort(() => Math.random() - 0.5); } function getRandomColor() { const colors = ['blue', 'red', 'green']; return colors[(new Date().getSeconds()) % colors.length]; } function getJSX() { return ( <div> <p>这是一个由Vom渲染的界面</p> <p> <span style={{ color: getRandomColor() }}>如今时间: { Date().toString() }</span> </p> <p>下面是一个顺序动态变化的有序列表:</p> <ul> { getRandomArray(10).map((key) => { return <li key={key}>列表序号: {key} </li>; }) } </ul> </div> ); } let preNode = getJSX(); let actualDom = Vom.render(preNode, document.body); setInterval(() => { const nextNode = getJSX(); const patch = Vom.diff(preNode, nextNode); actualDom = Vom.applyDiff(actualDom, patch)!; preNode = nextNode; }, 1000);
到目前为止咱们已经实现一个Virtual DOM的基本功能,本篇文章重点仍是在讲述Virtual DOM基本原理,实现方面相对比较简陋,若有不正确之处,望各位见谅。代码已经上传Github:Vom。写做不易,愿你们能多多支持,关注个人Github博客,关注、点赞、收藏 素质三连哦!但愿这篇文章能对你有些许帮助,愿共同窗习!