手寫(xiě)簡(jiǎn)易前端框架:Patch 更新(1.0 完結(jié)篇)
前面兩篇文章,我們實(shí)現(xiàn)了 vdom 的渲染和 jsx 的編譯,實(shí)現(xiàn)了 function 和 class 組件,這篇來(lái)實(shí)現(xiàn) patch 更新。
能夠做 vdom 的渲染和更新,支持組件(props、state),這就是一個(gè)比較完整的前端框架了。
首先,我們準(zhǔn)備下測(cè)試代碼:
測(cè)試代碼
在上節(jié)的基礎(chǔ)上做下改造:
添加一個(gè)刪除按鈕,一個(gè)輸入框和添加按鈕,并且還要添加相應(yīng)的事件監(jiān)聽(tīng)器:
這部分代碼大家經(jīng)常寫(xiě),就不過(guò)多解釋了:
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'));
前面我們已經(jīng)實(shí)現(xiàn)了渲染,現(xiàn)在要實(shí)現(xiàn)更新,也就是 setState 之后更新頁(yè)面的流程。
實(shí)現(xiàn) patch
其實(shí)最簡(jiǎn)單的更新就是 setState 的時(shí)候重新渲染一次,整個(gè)替換掉之前的 dom:
setState(nextState) {
this.state = Object.assign(this.state, nextState);
const newDom = render(this.render());
this.dom.replaceWith(newDom);
this.dom = newDom;
}
測(cè)試下:
我們實(shí)現(xiàn)了更新功能!
開(kāi)個(gè)玩笑。前端框架不會(huì)用這樣的方式更新的,多了很多沒(méi)必要的 dom 操作,性能太差。
所以還是要實(shí)現(xiàn) patch,也就是:
setState(nextState) {
this.state = Object.assign(this.state, nextState);
if(this.dom) {
patch(this.dom, this.render());
}
}
「patch 功能是把要渲染的 vdom 和已有的 dom 做下 diff,只更新需要更新的 dom,也就是按需更新」。
是否要走 patch 邏輯,這里可以加一個(gè) shouldComponentUpdate 來(lái)控制,如果 props 和 state 都沒(méi)變就不用 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 怎么實(shí)現(xiàn)呢?
渲染的時(shí)候我們是遞歸 vdom,對(duì)元素、文本、組件分別做不同的處理,包括創(chuàng)建節(jié)點(diǎn)和設(shè)置屬性。patch 更新的時(shí)候也是同樣的遞歸,但是對(duì)元素、文本、組件做的處理不同:
文本
判斷 dom 節(jié)點(diǎn)是文本的話,要再看 vdom:
- 如果 vdom 不是文本節(jié)點(diǎn),直接替換
- 如果 vdom 也是文本節(jié)點(diǎn),那就對(duì)比下內(nèi)容,內(nèi)容不一樣就替換
if (dom instanceof Text) {
if (typeof vdom === 'object') {
return replace(render(vdom, parent));
} else {
return dom.textContent != vdom ? replace(render(vdom, parent)) : dom;
}
}
這里的 replace 的實(shí)現(xiàn)是用 replaceChild:
const replace = parent ? el => {
parent.replaceChild(el, dom);
return el;
} : (el => el);
然后是組件的更新:
組件
如果 vdom 是組件的話,對(duì)應(yīng)的 dom 可能是同一個(gè)組件渲染的,也可能不是。
要判斷下 dom 是不是同一個(gè)組件渲染出來(lái)的,不是的話,直接替換,是的話更新子元素:
怎么知道 dom 是什么組件渲染出來(lái)的呢?
我們需要在 render 的時(shí)候在 dom 上加個(gè)屬性來(lái)記錄:
改下 render 部分的代碼,加上 instance 屬性:
instance.dom.__instance = instance;
然后更新的時(shí)候就可以對(duì)比下 constructor 是否一樣,如果一樣說(shuō)明是同一個(gè)組件,那 dom 是差不多的,再 patch 子元素:
if (dom.__instance && dom.__instance.constructor == vdom.type) {
dom.__instance.componentWillReceiveProps(props);
return patch(dom, dom.__instance.render(), parent);
}
否則,不是同一個(gè)組件的話,那就直接替換了:
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);
}
所以,組件更新邏輯就是這樣的:
元素如果 dom 是元素的話,要看下是否是同一類(lèi)型的:
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 是元素的話,要看下是否是同一類(lèi)型的:
不同類(lèi)型的元素,直接替換
if (dom.nodeName !== vdom.type.toUpperCase() && typeof vdom === 'object') {
return replace(render(vdom, parent));
}
同一類(lèi)型的元素,更新子節(jié)點(diǎn)和屬性
更新子節(jié)點(diǎn)我們希望能重用的就重用,所以在 render 的時(shí)候給每個(gè)元素加上一個(gè)標(biāo)識(shí) key:
instance.dom.__key = vdom.props.key;
更新的時(shí)候如果找到 key 就重用,沒(méi)找到就 render 一個(gè)新的。
首先我們把所有的子節(jié)點(diǎn)的 dom 放到一個(gè)對(duì)象里:
const oldDoms = {};
[].concat(...dom.childNodes).map((child, index) => {
const key = child.__key || `__index_${index}`;
oldDoms[key] = child;
});
[].concat 是為了拍平數(shù)組,因?yàn)閿?shù)組的元素也是數(shù)組。
默認(rèn) key 設(shè)置為 index。
然后循環(huán)渲染 vdom 的 children,如果找到對(duì)應(yīng)的 key 就直接復(fù)用,然后繼續(xù) patch 它的子元素。如果沒(méi)找到,就 render 一個(gè)新的:
[].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();
}
刪掉之前還可以執(zhí)行下組件的 willUnmount 的生命周期函數(shù):
for (const key in oldDoms) {
const instance = oldDoms[key].__instance;
if (instance) instance.componentWillUnmount();
oldDoms[key].remove();
}
子節(jié)點(diǎn)處理完了,再處理下屬性:
這個(gè)就是把舊的屬性刪掉,設(shè)置新的 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 多次也始終只有一個(gè):
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 的設(shè)置:
if (key == 'key') {
dom.__key = value;
}
還有一些特殊屬性的設(shè)置,包括 checked、value、className:
if (key == 'checked' || key == 'value' || key == 'className') {
dom[key] = value;
}
其余的就都是 setAttribute 設(shè)置了:
function isPlainAttr(key, value) {
return typeof value != 'object' && typeof value != 'function';
}
if (isPlainAttr(key, value)) {
dom.setAttribute(key, value);
}
所以現(xiàn)在的 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);
}
}
文本、組件、元素的更新邏輯都寫(xiě)完了,我們來(lái)測(cè)試下吧:
大功告成!
我們實(shí)現(xiàn)了 patch 的功能,也就是細(xì)粒度的按需更新。
代碼上傳到了 github:https://github.com/QuarkGluonPlasma/frontend-framework-exercize
總結(jié)
patch 和 render 一樣,也是遞歸的處理元素、組件、文本。
patch 時(shí)要對(duì)比下 dom 中的和要渲染的 vdom 的一些信息,然后決定渲染新的 dom,還是復(fù)用已有 dom,所以 render 的時(shí)候要在 dom 上記錄 instance、key 等信息。
元素的子元素更新要支持 key做標(biāo)識(shí),這樣可以復(fù)用之前的元素,減少 dom 的創(chuàng)建。屬性設(shè)置的時(shí)候 event listener 要每次刪掉已有的再添加一個(gè)新的,保證只會(huì)有一個(gè)。
實(shí)現(xiàn)了 vdom 的渲染和更新,實(shí)現(xiàn)了組件和生命周期,這已經(jīng)是一個(gè)完整的前端框架了。
這是我們實(shí)現(xiàn)的前端框架的第一個(gè)版本,叫做 Dong 1.0。
但是,現(xiàn)在的前端框架是遞歸的 render 和 patch 的,如果 vdom 樹(shù)太大,會(huì)計(jì)算量很大,性能不會(huì)很好,后面的 Dong 2.0 我們?cè)侔? vdom 改造成 fiber,然后實(shí)現(xiàn)下 hooks 的功能。