手寫簡易前端框架:patch 更新(1.0 完結篇)

語言: CN / TW / HK

前面兩篇文章,我們實現了 vdom 的渲染和 jsx 的編譯,實現了 function 和 class 元件,這篇來實現 patch 更新。

能夠做 vdom 的渲染和更新,支援元件(props、state),這就是一個比較完整的前端框架了。

首先,我們準備下測試程式碼:

測試程式碼

在上節的基礎上做下改造:

新增一個刪除按鈕,一個輸入框和新增按鈕,並且還要新增相應的事件監聽器:

這部分程式碼大家經常寫,就不過多解釋了:

function Item(props) {
return <li className="item" style={props.style}>{props.children} <a href="#" onClick={props.onRemoveItem}>X </a></li>;
}

class List extends Component {
constructor(props) {
super();
this.state = {
list: [
{
text: 'aaa',
color: 'pink'
},
{
text: 'bbb',
color: 'orange'
},
{
text: 'ccc',
color: 'yellow'
}
]
}
}

handleItemRemove(index) {
this.setState({
list: this.state.list.filter((item, i) => i !== index)
});
}

handleAdd() {
this.setState({
list: [
...this.state.list,
{
text: this.ref.value
}
]
});
}

render() {
return <div>
<ul className="list">
{this.state.list.map((item, index) => {
return <Item style={{ background: item.color, color: this.state.textColor}} onRemoveItem={() => this.handleItemRemove(index)}>{item.text}</Item>
})}
</ul>
<div>
<input ref={(ele) => {this.ref = ele}}/>
<button onClick={this.handleAdd.bind(this)}>add</button>
</div>
</div>
;
}
}

render(<List textColor={'#000'}/>, document.getElementById('root'));

前面我們已經實現了渲染,現在要實現更新,也就是 setState 之後更新頁面的流程。

實現 patch

其實最簡單的更新就是 setState 的時候重新渲染一次,整個替換掉之前的 dom:

setState(nextState) {
this.state = Object.assign(this.state, nextState);

const newDom = render(this.render());
this.dom.replaceWith(newDom);
this.dom = newDom;
}

測試下:

我們實現了更新功能!

開個玩笑。前端框架不會用這樣的方式更新的,多了很多沒必要的 dom 操作,效能太差。

所以還是要實現 patch,也就是:

setState(nextState) {
this.state = Object.assign(this.state, nextState);
if(this.dom) {
patch(this.dom, this.render());
}
}

「patch 功能是把要渲染的 vdom 和已有的 dom 做下 diff,只更新需要更新的 dom,也就是按需更新」。

是否要走 patch 邏輯,這裡可以加一個 shouldComponentUpdate 來控制,如果 props 和 state 都沒變就不用 patch 了。

setState(nextState) {
this.state = Object.assign(this.state, nextState);

if(this.dom && this.shouldComponentUpdate(this.props, nextState)) {
patch(this.dom, this.render());
}
}

shouldComponentUpdate(nextProps, nextState) {
return nextProps != this.props || nextState != this.state;
}

patch 怎麼實現呢?

渲染的時候我們是遞迴 vdom,對元素、文字、元件分別做不同的處理,包括建立節點和設定屬性。patch 更新的時候也是同樣的遞迴,但是對元素、文字、元件做的處理不同:

判斷 dom 節點是文字的話,要再看 vdom:

  • 如果 vdom 不是文字節點,直接替換

  • 如果 vdom 也是文字節點,那就對比下內容,內容不一樣就替換

if (dom instanceof Text) {
if (typeof vdom === 'object') {
return replace(render(vdom, parent));
} else {
return dom.textContent != vdom ? replace(render(vdom, parent)) : dom;
}
}

這裡的 replace 的實現是用 replaceChild:

const replace = parent ? el => {
parent.replaceChild(el, dom);
return el;
} : (el => el);

然後是元件的更新:

如果 vdom 是元件的話,對應的 dom 可能是同一個元件渲染的,也可能不是。

要判斷下 dom 是不是同一個元件渲染出來的,不是的話,直接替換,是的話更新子元素:

怎麼知道 dom 是什麼元件渲染出來的呢?

我們需要在 render 的時候在 dom 上加個屬性來記錄:

改下 render 部分的程式碼,加上 instance 屬性:

instance.dom.__instance = instance;

然後更新的時候就可以對比下 constructor 是否一樣,如果一樣說明是同一個元件,那 dom 是差不多的,再 patch 子元素:

if (dom.__instance && dom.__instance.constructor == vdom.type) {
dom.__instance.componentWillReceiveProps(props);

return patch(dom, dom.__instance.render(), parent);
}

否則,不是同一個元件的話,那就直接替換了:

class 元件的替換:

if (Component.isPrototypeOf(vdom.type)) {
const componentDom = renderComponent(vdom, parent);
if (parent){
parent.replaceChild(componentDom, dom);
return componentDom;
} else {
return componentDom
}
}

function 元件的替換:

if (!Component.isPrototypeOf(vdom.type)) {
return patch(dom, vdom.type(props), parent);
}

所以,元件更新邏輯就是這樣的:

function isComponentVdom(vdom) {
return typeof vdom.type == 'function';
}

if(isComponentVdom(vdom)) {
const props = Object.assign({}, vdom.props, {children: vdom.children});
if (dom.__instance && dom.__instance.constructor == vdom.type) {
dom.__instance.componentWillReceiveProps(props);
return patch(dom, dom.__instance.render(), parent);
} else if (Component.isPrototypeOf(vdom.type)) {
const componentDom = renderComponent(vdom, parent);
if (parent){
parent.replaceChild(componentDom, dom);
return componentDom;
} else {
return componentDom
}
} else if (!Component.isPrototypeOf(vdom.type)) {
return patch(dom, vdom.type(props), parent);
}
}

還有元素的更新:

元素

如果 dom 是元素的話,要看下是否是同一型別的:

  • 不同型別的元素,直接替換

if (dom.nodeName !== vdom.type.toUpperCase() && typeof vdom === 'object') {
return replace(render(vdom, parent));
}
  • 同一型別的元素,更新子節點和屬性

更新子節點我們希望能重用的就重用,所以在 render 的時候給每個元素加上一個標識 key:

instance.dom.__key = vdom.props.key;

更新的時候如果找到 key 就重用,沒找到就 render 一個新的。

首先我們把所有的子節點的 dom 放到一個物件裡:

const oldDoms = {};
[].concat(...dom.childNodes).map((child, index) => {
const key = child.__key || `__index_${index}`;
oldDoms[key] = child;
});

[].concat 是為了拍平陣列,因為陣列的元素也是陣列。

預設 key 設定為 index。

然後迴圈渲染 vdom 的 children,如果找到對應的 key 就直接複用,然後繼續 patch 它的子元素。如果沒找到,就 render 一個新的:

[].concat(...vdom.children).map((child, index) => {
const key = child.props && child.props.key || `__index_${index}`;
dom.appendChild(oldDoms[key] ? patch(oldDoms[key], child) : render(child, dom));
delete oldDoms[key];
});

把新的 dom 從 oldDoms 裡去掉。剩下的就是不再需要的 dom,直接刪掉即可:

for (const key in oldDoms) {
oldDoms[key].remove();
}

刪掉之前還可以執行下元件的 willUnmount 的生命週期函式:

for (const key in oldDoms) {
const instance = oldDoms[key].__instance;
if (instance) instance.componentWillUnmount();

oldDoms[key].remove();
}

子節點處理完了,再處理下屬性:

這個就是把舊的屬性刪掉,設定新的 props 即可:

for (const attr of dom.attributes) dom.removeAttribute(attr.name);
for (const prop in vdom.props) setAttribute(dom, prop, vdom.props[prop]);

setAttribute 之前我們只做了 style、event listener 和普通屬性的處理,還需要再完善下:

每次 event listener 都要 remove 再 add,這樣 render 多次也始終只有一個:

function isEventListenerAttr(key, value) {
return typeof value == 'function' && key.startsWith('on');
}

if (isEventListenerAttr(key, value)) {
const eventType = key.slice(2).toLowerCase();

dom.__handlers = dom.__handlers || {};
dom.removeEventListener(eventType, dom.__handlers[eventType]);

dom.__handlers[eventType] = value;
dom.addEventListener(eventType, dom.__handlers[eventType]);
}

把各種事件的 listener 放到 dom 的 __handlers 屬性上,每次刪掉之前的,換成新的。

然後再支援下 ref 屬性:

function isRefAttr(key, value) {
return key === 'ref' && typeof value === 'function';
}

if(isRefAttr(key, value)) {
value(dom);
}

也就是這樣的功能:

<input ref={(ele) => {this.ref = ele}}/>

再支援下 key 的設定:

if (key == 'key') {
dom.__key = value;
}

還有一些特殊屬性的設定,包括 checked、value、className:

if (key == 'checked' || key == 'value' || key == 'className') {
dom[key] = value;
}

其餘的就都是 setAttribute 設定了:

function isPlainAttr(key, value) {
return typeof value != 'object' && typeof value != 'function';
}

if (isPlainAttr(key, value)) {
dom.setAttribute(key, value);
}

所以現在的 setAttribute 是這樣的:

const setAttribute = (dom, key, value) => {
if (isEventListenerAttr(key, value)) {
const eventType = key.slice(2).toLowerCase();
dom.__handlers = dom.__handlers || {};
dom.removeEventListener(eventType, dom.__handlers[eventType]);
dom.__handlers[eventType] = value;
dom.addEventListener(eventType, dom.__handlers[eventType]);
} else if (key == 'checked' || key == 'value' || key == 'className') {
dom[key] = value;
} else if(isRefAttr(key, value)) {
value(dom);
} else if (isStyleAttr(key, value)) {
Object.assign(dom.style, value);
} else if (key == 'key') {
dom.__key = value;
} else if (isPlainAttr(key, value)) {
dom.setAttribute(key, value);
}
}

文字、元件、元素的更新邏輯都寫完了,我們來測試下吧:

大功告成!

我們實現了 patch 的功能,也就是細粒度的按需更新。

程式碼上傳到了 github:https://github.com/QuarkGluonPlasma/frontend-framework-exercize

總結

patch 和 render 一樣,也是遞迴的處理元素、元件、文字。

patch 時要對比下 dom 中的和要渲染的 vdom 的一些資訊,然後決定渲染新的 dom,還是複用已有 dom,所以 render 的時候要在 dom 上記錄 instance、key 等資訊。

元素的子元素更新要支援 key做標識,這樣可以複用之前的元素,減少 dom 的建立。屬性設定的時候 event listener 要每次刪掉已有的再新增一個新的,保證只會有一個。

實現了 vdom 的渲染和更新,實現了元件和生命週期,這已經是一個完整的前端框架了。

這是我們實現的前端框架的第一個版本,叫做 Dong 1.0。

但是,現在的前端框架是遞迴的 render 和 patch 的,如果 vdom 樹太大,會計算量很大,效能不會很好,後面的 Dong 2.0 我們再把 vdom 改造成 fiber,然後實現下 hooks 的功能。