首先歡迎你們關注、點贊、收藏個人掘金帳號和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博客,關注、點贊、收藏 素質三連哦!但願這篇文章能對你有些許幫助,願共同窗習!