一步一步帶你實現virtual dom(一)
一步一步帶你實現virtual dom(二)--Props和事件
很高興我們可以繼續分享編寫虛擬DOM的知識。這次我們要講解的是產品級的內容,其中包括:設置和DOM一致性、以及事件的處理。
使用Babel
在繼續之前,我們需要彌補前一篇文章中沒有詳細講解的內容。假設有一個沒有任何屬性(props)的節點:
<div></div>
Babel,在處理這個節點的時候會把節點的props屬性設置為“null”,因為它沒有任何的屬性。因此我們會得到這樣的結果:
function h(type, props, ...children) {
return {type, props: props || {}, children};
}
設置props
設置props非常簡單,記得DOM顯示嗎?我們把props作為簡單的js對象來存儲,所以這樣的標簽:
<ul className="list", style="list-style: none;"></ul>
內存里就會有這樣的對象:
{
type: 'ul',
props: {className: 'list', style: 'list-style:none;'}
}
因此每一個props的字段就是一個屬性名,這個字段的值就是屬性值。所以,我們只要把這些值給真正的DOM節點設置了就可以了。我們寫一個方法包裝一個setAttribute()方法:
function setProp($target, name, value) {
$target.setAttribute(name, value);
}
那么現在我們知道如何設置屬性了(prop)--我們之后可以全部都設置上,只要遍歷prop對象的屬性就可以:
function setProps($target, props) {
Object.keys(props).forEach(name => {
setProp($target, name, props[name]);
})
}
還記得createElement()方法么?我們只需要在真正的DOM節點創建之后調用setProp方法給它設置即可:
function createElement(node) {
if(typeof node === 'string) {
return document.createTextNode(node);
}
const $el = document.createElement(node.type);
setProps($el, node.props);
node.children
.map(createElement)
.forEach($el.appendChild.bind($el));
return $el;
}
但是,這還沒有完。我們忘記了一些小細節。首先,‘class’是js的保留字。所以不能把它用作屬性名稱。我們會使用‘className’:
<nav className="navbar light">
<ul></ul>
</nav>
但是在真正的DOM里並沒有‘className’,所以我們應該在setProp方法里處理這個問題。
另外一個事情是,設置布爾型的屬性的時候最好使用布爾值:
<input type="checkbox" checked={false} />
在這個例子里,我並不希望這個'checked'屬性值設置在真正的DOM節點上。但是事實上這個值足夠設置DOM節點了,當然這同時還需要給對應的虛擬DOM節點也設置這個值:
function setBooleanProp($target, name, value) {
if(value) {
$target.setAttribute(name, value);
$target.[name] = true;
} else {
$target[name] = false;
}
}
現在我們就來看看如何自定義屬性。這次完全是我們自己的實現,因此后面我們會有不同作用的屬性,並且不是全都要在DOM節點上顯示的。所以要寫一個方法來檢查這個屬性是不是自定義的。現在它是空的,所以我們還沒有任何的自定義屬性:
function isCustomProp(name) {
return false;
}
下面就是我們完整的setProp()
方法,把所有的問題都處理了:
function setProp($target, name, value) {
if(isCustomProp(name)) {
return;
} else if(name === 'className') {
$target.setAttribute('class', value);
} else if(typeof value === 'boolean') {
setBooleanProp($target, name, value);
} else {
$target.setAttribute(name, value);
}
}
現在在JSFiddle里面試試吧.
屬性區分(Diff Props)
現在我們已經可以使用prop來創建元素了,現在要處理的就是如何區分元素的props了。最終要么是設置屬性,要么是刪除它。我們已經有方法可以設置屬性了,現在來寫一個方法來刪除它們吧。事實上這非常簡單:
function removeBooleanProp($target, name) {
$target.removeAttribute(name);
$target[name] = false;
}
function removeProp($target, name, value) {
if(isCustomProp(name)) {
return;
} else if(name === 'className') {
$target.removeAttribute('class');
} else if(typeof === 'boolean') {
removeBooleanProp($target, name);
} else {
$target.removeAttribute(name);
}
}
我們再來寫一個updateProp()
方法來比較兩個屬性--就的和新的,並根據比較的結果來更新DOM元素的屬性:
- 在DOM里沒有這個屬性的話,就刪除掉
new old
<nav></nav> <nav className='navbar'></nav>
- 在新的節點里包含了某個屬性,那么就需要在DOM上設置這個屬性
new old
<nav style='background: blue'></nav> <nav></nav>
- 某個屬性在新的和舊的節點里都存在,那么我們就需要比較他們的值。如果他們不相等我們就需要根據結果給新的節點設置屬性值了。
new old
<nav className='navbar default'></nav> <nav className='navbar'></nav>
- 在其他情況下,屬性並沒有改變我們什么都不需要做。
下面這個方法就是專門處理prop的:
function updateProp($target, naem, newVal, oldVal) {
if(!newVal) {
removeProp($target, name, oldVal);
} else if(!oldVal || newVal != oldVal) {
setProp($target, name, newVal);
}
}
是不是很簡單?但是一個節點會有不止一個屬性--所以我們要寫一個方法可以遍歷全部的屬性,然后調用updateProp()
方法來一對一對的處理:
function updateProps($target, newProps, oldProps = {}) {
const props = Object.assign({}, newProps, oldProps);
Object.leys(props).forEach(name => {
updateProp($target, name, newProps[name], oldProps[name]);
});
}
這里需要注意我們創建的組合對象。它包含了新、舊節點的屬性。因此,在遍歷的時候我們會遇到undefined
,不過這沒有關系,我們的方法可以處理這個問題。
最后一件事就是把這個方法放到我們的updateElement()
方法里。我們應該放在哪里呢?如果節點本身沒有改變,那么它的子節點呢?這個問題我們也需要處理。所以我們把那個方法放在最后一個if
語句塊里。
function updateElement($parent, newNode, oldNode, index=0) {
if() {
...
} else if(newNode.type) {
updateProps(
$parent.childNodes[index],
newNode.props,
oldNode.props,
);
...
}
}
接着在這里測試一下吧。
事件
當然一個動態的應用是免不了會有事件的。我們可以使用querySelector()
來處理節點,然后用addEventListener()
來給節點添加事件的listener。但是,這樣沒啥意思。我們要像React一樣來處理事件。
<button onClick={() => alert('hi')}></button>
這樣看起來就像那么回事兒了。你看到了,我們是用了props
來聲明一個事件監聽器的。我們的屬性名都是on
開頭的。
function isEventProp(name) {
return /^on/.test(name);
}
我們來寫一個方法,從屬性里獲取事件名稱。記住事件的名稱都是以on
為前綴的。
function extractEventName(name) {
return name.slice(2).toLowerCase();
}
看起來,如果我們在屬性里聲明了事件,那么我們就需要在setProps()
或者updateProps()
方法里處理。但是如何處理方法的不同呢?
你不能用相等操作符來比較兩個方法。當然你可以用toString()
方法,然后比較兩個方法。但是有個問題,方法里可能會包含native code,這就給比較帶來了問題。
"function () { [native code] }"
當然我們可以使用時間冒泡的方式來處理。我們可以寫我們自己的事件處理管理器,這個管理器會附加到body
或者繪制我們節點的容器節點上。因此,我們可以在每次更新的時候添加一次事件處理器,這樣也不會造成多大的資源浪費。
但是,我們不會這么做。因為這樣會增加很多的問題,而且事實上我們的時間處理器不會頻繁的改變。所以,我們只要在創建我們的節點的時候添加一次事件監聽器就可以。那么不會在setProps
方法里設置事件屬性。我們自己處理添加事件的問題。怎么實現呢?記得我們的方法可以檢測自定義的屬性嗎?現在它不會是空的了:
function isCustomProp(name) {
return isEventProp(name);
}
當我們知道了一個真的DOM節點的時候添加事件監聽器,這時屬性對象也非常清晰的。
function addEventListeners($target, props) {
Object.keys(props).forEach(name => {
if(isEventProp(name)){
$target.addEventListener(
exteactEventName(name),
props[name]
);
}
});
}
把上面的代碼加入到createElement
方法里:
function createElement(node) {
if(typeof node === 'string') {
return document.createTextNode('node');
}
const $el = document.createElement(node.type);
setProps($el, node.props);
addEventListeners($el, node.props);
node.children
.map(createElement)
.forEach($el.appendChild.bind($el));
return $el;
}
再次添加事件
如果你必須要再次添加事件監聽器呢?我們來簡單理解處理一下這個問題。只是這樣的話性能會受到印象。我們會引入一個自定義屬性:forceUpdate
。記住,我們怎么檢查節點的更改的:
function changed(node1, node2) {
return typeof node1 ~== typeof node2 ||
typeof node1 === 'string' && node1 !== node2 ||
node1.type !== node2.type ||
node.props.forceUpdate;
}
如果forceUpdate
為true的話,節點就會整個的重新創建並且新的事件監聽器也會被添加進去。整個屬性也不是不應該加到實際的DOM節點的,所以需要處理一下:
function isCustomProp(name) {
return isEventProp(name) || name === 'forceUpdate';
}
這基本就是全部了。是的,整個解決的方法會影響性能,但是很簡單。
結語
這就基本是全部了。希望你覺得有趣。如果你知道更簡單的解決方法處理事件處理器的不同的方法的話,能分享到評論里就太感謝了。
原文地址:https://medium.com/@deathmood/write-your-virtual-dom-2-props-events-a957608f5c76