寫一個(gè)更好的Javascript DOM庫(kù)
目前,jQuery是事實(shí)上的操作文檔對(duì)象模型(DOM)的庫(kù)。它可以與流行的客戶端MV*框架結(jié)合使用,并且擁有大量的插件與大型的社區(qū)。開發(fā)者 對(duì)于Javascript的興趣與日俱增的同時(shí),很多人開始好奇,原生的API是如何工作的,以及我們何時(shí)應(yīng)該直接使用它們而不是引用一個(gè)額外的庫(kù)。
最近,我開始發(fā)現(xiàn)越來(lái)越多的jQuery的問題,至少是在我的使用中是這樣的。其中的絕大多數(shù)涉及到j(luò)Query的核心,在不取消向后兼容的情況下無(wú)法解決——而向后兼容又非常重要。與很多人一樣,我繼續(xù)使用了它一段時(shí)間,每天瀏覽所有討厭的瀏覽器怪異模式。
后來(lái), Daniel Buchner創(chuàng)造了SelectorListener,于是有了“live擴(kuò)展(live extensions)”的概念。我開始考慮創(chuàng)造一系列的函數(shù),使得我們可以使用比迄今為止用過(guò)的方法都更好的方式來(lái)創(chuàng)建非干擾性的DOM組件。目標(biāo)是回顧已有的API與解決方案,并創(chuàng)造一個(gè)更干凈、可測(cè)試且輕量級(jí)的庫(kù)。
向庫(kù)添加有用的特性
是live擴(kuò)展的想法鼓勵(lì)我開發(fā)了better-dom項(xiàng)目,不過(guò),還有一些其他的有趣的特性使得它成為一個(gè)獨(dú)特的庫(kù)。我們快速地看一下:
- live擴(kuò)展
- 原生的動(dòng)畫
- 內(nèi)置的微模板
- 國(guó)際化的支持
live擴(kuò)展
jQuery有一個(gè)叫做“live事件(live events)”的概念。借助事件代理,它使得開發(fā)者可以處理現(xiàn)有的以及未來(lái)的元素。但是許多情況會(huì)要求更大的靈活度。比如為了初始化一個(gè)部件而需要對(duì)DOM進(jìn)行轉(zhuǎn)換,事件代理就會(huì)力不從心。故而,live擴(kuò)展。
目標(biāo)是,只需定義一次擴(kuò)展并使得所有未來(lái)的元素快速略過(guò)初始化函數(shù),而無(wú)論部件的復(fù)雜度。這個(gè)很重要,因?yàn)樗沟梦覀兛梢月暶魇降亻_發(fā)web頁(yè)面,從而在AJAX應(yīng)用中表現(xiàn)優(yōu)異。
Live擴(kuò)展使得你無(wú)需調(diào)用初始化方法就可以操作未來(lái)的元素
我們來(lái)看一個(gè)簡(jiǎn)單的例子。假設(shè)我們的任務(wù)是實(shí)現(xiàn)一個(gè)完全自定義的提示框。:hover 偽類選擇器并無(wú)幫助,因?yàn)樘崾究虻奈恢秒S著鼠標(biāo)移動(dòng)而變化。事件代理也不是很合適;監(jiān)聽文檔樹中所有元素的mouseover 及mouseleave 事件代價(jià)很大。live擴(kuò)展將拯救你!
- DOM.extend("[title]", {
- constructor: function() {
- var tooltip = DOM.create("span.custom-title");
- // set the title's textContent and hide it initially
- tooltip.set("textContent", this.get("title")).hide();
- this
- // remove legacy title
- .set("title", null)
- // store reference for quicker access
- .data("tooltip", tooltip)
- // register event handlers
- .on("mouseenter", this.onMouseEnter, ["clientX", "clientY"])
- .on("mouseleave", this.onMouseLeave)
- // insert the title element into DOM
- .append(tooltip);
- },
- onMouseEnter: function(x, y) {
- this.data("tooltip").style({left: x, top: y}).show();
- },
- onMouseLeave: function() {
- this.data("tooltip").hide();
- }
- });
我們可以在CSS中定義 .custom-title 元素的樣式:
- .custom-title {
- position: fixed; /* required */
- border: 1px solid #faebcc;
- background: #faf8f0;
- }
當(dāng)你向頁(yè)面中插入一個(gè)帶title 屬性的新元素時(shí),最有趣的部分發(fā)生了。自定義的提示框無(wú)需調(diào)用任何初始化方法即可生效。
live擴(kuò)展是獨(dú)立的;這樣它們并不需要為了使得未來(lái)的內(nèi)容生效去調(diào)用一個(gè)初始化方法。因此它們可以與任何DOM庫(kù)結(jié)合使用,將UI代碼分割成許多小的獨(dú)立的塊,從而簡(jiǎn)化應(yīng)用的邏輯。
***,同樣很重要的,一些關(guān)于Web組件的內(nèi)容。規(guī)范的一部分,“裝飾器” ,意在解決類似的問題。目前,它使用了一種基于標(biāo)記的實(shí)現(xiàn),通過(guò)特殊的語(yǔ)法,將事件監(jiān)聽者綁定到子元素上。但它仍只是早期的草案:
“裝飾器,與Web組件的其它部分不同的是,它還沒有一個(gè)規(guī)范。”
#p#
原生動(dòng)畫
多虧了 Apple, CSS現(xiàn)在擁有了對(duì)動(dòng)畫的良好支持。過(guò)去動(dòng)畫通常使用Javascript的setInterval 及setTimeout實(shí)現(xiàn)。這曾經(jīng)是很酷的特性——但是現(xiàn)在看來(lái),它更像是壞的實(shí)踐。原生的動(dòng)畫總是更平滑:常常更快,開銷更小,并且在瀏覽器不支持的情況下可以很好地降級(jí)。
better-dom中,沒有animate方法:只有show, hide 以及toggle。庫(kù)使用基于標(biāo)準(zhǔn)的aria-hidden屬性來(lái)在CSS中獲取一個(gè)隱藏元素的狀態(tài)。
為了說(shuō)明它是如何工作的,我們來(lái)為先前介紹的提示框添加一個(gè)簡(jiǎn)單的動(dòng)畫效果:
- .custom-title {
- position: fixed; /* required */
- border: 1px solid #faebcc;
- background: #faf8f0;
- /* animation code */
- opacity: 1;
- -webkit-transition: opacity 0.5s;
- transition: opacity 0.5s;
- }
- .custom-title[aria-hidden=true] {
- opacity: 0;
- }
show() 以及hide() 在內(nèi)部將 aria-hidden 屬性值設(shè)置為false或true。這使得CSS可以處理動(dòng)畫與轉(zhuǎn)換。
你可以在這個(gè)demo中看到更多使用了better-dom的動(dòng)畫。
內(nèi)置的微模板
HTML字符串冗長(zhǎng)而繁瑣。尋找替代的過(guò)程中我發(fā)現(xiàn)了超棒的Emmet。如今Emmet已經(jīng)是一個(gè)非常流行的文本編輯器插件,它擁有漂亮而簡(jiǎn)潔的語(yǔ)法。比如這段HTML:
- body.append("<ul><li class='list-item'></li><li class='list-item'></li><li class='list-item'></li></ul>");
與對(duì)應(yīng)的微模板比較:
- body.append("ul>li.list-item*3");
在better-dom中,任何接受HTML的方法同樣接受Emmet表達(dá)式??s寫解析器很快,所以不用擔(dān)心付出性能代價(jià)。如果需要,還有一個(gè)模板預(yù)編譯函數(shù)可用。
國(guó)際化支持
開發(fā)一個(gè)UI組件經(jīng)常會(huì)需要本地化——這并不輕松。多年來(lái),很多人使用不同的方法解決這個(gè)問題。在better-dom中,我相信改變CSS選擇器的狀態(tài),就如同轉(zhuǎn)換語(yǔ)言。
從概念上說(shuō),轉(zhuǎn)換語(yǔ)言正是改變內(nèi)容的“表現(xiàn)”。在CSS2中,有幾個(gè)偽類選擇器可用于描述這樣一個(gè)模型::lang 以及:before。我們來(lái)看下邊的代碼:
- [data-i18n="hello"]:before {
- content: "Hello Maksim!";
- }
- [data-i18n="hello"]:lang(ru):before {
- content: "Привет Максим!";
- }
這是個(gè)很簡(jiǎn)單的把戲:html 元素的lang 屬性控制當(dāng)前語(yǔ)言,而content 屬性值根據(jù)當(dāng)前的語(yǔ)言變化。通過(guò)使用如data-i18n這樣的屬性,我們可以在HTML中維護(hù)文本內(nèi)容。
- [data-i18n]:before {
- content: attr(data-i18n);
- }
- [data-i18n="Hello Maksim!"]:lang(ru):before {
- content: "Привет Максим!";
- }
當(dāng)然,這樣的CSS并不吸引人,所以better-com提供了兩個(gè)幫助方法:i18n 及DOM.importStrings。前者用于更新 data-i18n 屬性為合適的值,后者為特定的語(yǔ)言本地化字符串。
- label.i18n("Hello Maksim!");
- // the label displays "Hello Maksim!"
- DOM.importStrings("ru", "Hello Maksim!", "Привет Максим!");
- // now if the page is set to ru language,
- // the label will display "Привет Максим!"
- label.set("lang", "ru");
- // now the label will display "Привет Максим!"
- // despite the web page's language
還可以使用參數(shù)化的字符串。直接向關(guān)鍵字符串中添加${param} 變量:
- label.i18n("Hello ${user}!", {user: "Maksim"});
- // the label will display "Hello Maksim!"
讓原生的APIs 更加優(yōu)雅
通常我們都希望遵從標(biāo)準(zhǔn)。但是有時(shí)候標(biāo)準(zhǔn)對(duì)用戶并不友好。DOM就是一團(tuán)糟 ,為了將其變得好用,我們不得不把它包裝到一個(gè)方便的API中。盡管開源的庫(kù)已經(jīng)作了很多改進(jìn),仍有一些部分可以做得更好:
- getter 及setter
- 事件處理
- 功能性的方法支持
GETTER 及SETTER
原生的 DOM 元素有attributes 及properties的概念,但他們的行為并不完全一致。假設(shè)我們?cè)谝粋€(gè)web頁(yè)面中有如下的標(biāo)記:
- <a href="/chemerisuk/better-dom" id="foo" data-test="test">better-dom</a>
為了解釋為什么“DOM就是一團(tuán)糟”,我們來(lái)看這:
- var link = document.getElementById("foo");
- link.href; // => "https://github.com/chemerisuk/better-dom"
- link.getAttribute("href"); // => "/chemerisuk/better-dom"
- link["data-test"]; // => undefined
- link.getAttribute("data-test"); // => "test"
- link.href = "abc";
- link.href; // => "https://github.com/abc"
- link.getAttribute("href"); // => "abc"
一個(gè)attribute與其在HTML中對(duì)應(yīng)的字符串相等,但元素的同名property可能會(huì)有一些奇怪的行為,比如在上邊列出來(lái)的,生成完全合格的URL。這些區(qū)別有時(shí)會(huì)導(dǎo)致混淆。
在實(shí)際使用中,很難想像一個(gè)這樣的區(qū)別有用的場(chǎng)景。除此之外,開發(fā)者必須總是牢記哪些值(attribute 或property)被使用了,這會(huì)引入沒必要的復(fù)雜度。
在better-dom中,事情要清楚得多。每個(gè)元素都只有智能的getter與setter。
- var link = DOM.find("#foo");
- link.get("href"); // => "https://github.com/chemerisuk/better-dom"
- link.set("href", "abc");
- link.get("href"); // => "https://github.com/abc"
- link.get("data-attr"); // => "test"
首先,它做一次屬性(property)查找,如果是已定義的,則返回供操作。不然,getter 及setter 作用于對(duì)應(yīng)的元素屬性(attribute)。對(duì)于boolean值(checked, selected, 這些), 可以直接使用 true 或 false 來(lái)更新值:改變?cè)氐脑搶傩裕╬roperty)將觸發(fā)對(duì)應(yīng)的attibute(原生行為)的更新。
#p#
改良的事件處理
事件處理是DOM中很重要的一部分,然而,我發(fā)現(xiàn)一個(gè)根本性的問題:將event對(duì)象傳入元素監(jiān)聽者的行為導(dǎo)致關(guān)心可測(cè)試性的開發(fā)者不得不偽造***個(gè)參數(shù)(事件對(duì)象),或是創(chuàng)建一個(gè)額外的函數(shù)來(lái)傳入事件處理函數(shù)僅需的事件屬性。
- var button = document.getElementById("foo");
- button.addEventListener("click", function(e) {
- handleButtonClick(e.button);
- }, false);
這很煩人。不過(guò)如果我們將可變部分抽象為一個(gè)參數(shù),我們就可以擺脫額外的函數(shù):
- var button = DOM.find("#foo");
- button.on("click", handleButtonClick, ["button"]);
默認(rèn)地,事件處理函數(shù)會(huì)被傳入["target", "defaultPrevented"] 數(shù)組,所以不用為了獲得這些屬性添加***一個(gè)參數(shù)。
- button.on("click", function(target, canceled) {
- // handle button click here
- });
延時(shí)綁定也得到了支持(我建議讀一下Peter Michaux關(guān)于這個(gè)主題的回顧)。它是W3C的標(biāo)準(zhǔn)中常規(guī)事件綁定的更加靈活的替換物。它在你需要頻繁進(jìn)行啟用和關(guān)閉方法調(diào)用時(shí)非常有用。
- button._handleButtonClick = function() { alert("click!"); };
- button.on("click", "_handleButtonClick");
- button.fire("click"); // shows "clicked" message
- button._handleButtonClick = null;
- button.fire("click"); // shows nothing
***,同樣很重要的,better-dom不提供任何對(duì)于遺留的或不同瀏覽器中表現(xiàn)不一致的API的調(diào)用,比如click(), focus() 和submit()。 調(diào)用他們的唯一方式是使用fire 方法,它在沒有監(jiān)聽者返回false的情況下執(zhí)行默認(rèn)行為:
- link.fire("click"); // clicks on the link
- link.on("click", function() { return false; });
- link.fire("click"); // triggers the handler above but doesn't do a click
功能性方法的支持
ES5規(guī)范了一些的有用的數(shù)組方法,包括 map, filter 以及some。它們?cè)试S我們以符合標(biāo)準(zhǔn)的方式使用通用的集合操作。因此現(xiàn)在我們有了諸如Underscore 和Lo-Dash這樣的項(xiàng)目,它們?cè)诶系臑g覽器上實(shí)現(xiàn)這些方法。
better-dom中的每個(gè)元素(或集合)都內(nèi)置了如下的方法:
each (它與 forEach 的區(qū)別在于返回this 而不是 undefined)
some
every
map
filter
reduce[Right]
- var urls, activeLi, linkText;
- urls = menu.findAll("a").map(function(el) {
- return el.get("href");
- });
- activeLi = menu.children().filter(function(el) {
- return el.hasClass("active");
- });
- linkText = menu.children().reduce(function(memo, el) {
- return memo || el.hasClass("active") && el.find("a").get()
- }, false);
避免jQuery的問題
在不放棄向后兼容的情況下,以下的絕大多數(shù)問題無(wú)法在jQuery中得到解決。這是為什么創(chuàng)造一個(gè)新的庫(kù)看起來(lái)是合乎邏輯的解決途徑。
- “神奇的” $ 函數(shù)
- [] 操作符的值
- 關(guān)于 return false的問題
- find 以及findAll
“神奇的” $ 函數(shù)
每個(gè)人都或多或少聽說(shuō)過(guò)$ (美元) 函數(shù)的神奇。一個(gè)單字符的名字并不具有描述性,所以它看起來(lái)像是一個(gè)內(nèi)置的語(yǔ)言操作符。這也正是缺乏經(jīng)驗(yàn)的開發(fā)者的代碼中$的調(diào)用隨處可見的原因。
在背后的實(shí)現(xiàn)中,$是個(gè)極其復(fù)雜的函數(shù)。經(jīng)常地執(zhí)行,尤其是 mousemove 、scroll這樣的頻繁事件中,會(huì)導(dǎo)致較差的UI性能。
盡管有很多文章建議將jQuery對(duì)象緩存下來(lái),開發(fā)者依舊在將$大量嵌入在代碼中,因?yàn)閖Query庫(kù)的語(yǔ)法鼓勵(lì)了這樣的代碼風(fēng)格。
$函數(shù)的另一個(gè)問題是,它可以被用來(lái)做完全不同的兩件事。人們已經(jīng)喜歡了這樣的語(yǔ)法,但通常來(lái)說(shuō),這是一個(gè)失敗的函數(shù)設(shè)計(jì):
- $("a"); // => searches all elements that match “a” selector
- $("<a>"); // => creates a <a> element with jQuery wrapper
better-dom 使用了幾個(gè)函數(shù)來(lái)承擔(dān)jQuery中$函數(shù)的職責(zé):find[All] 以及DOM.create。find[All] 被用來(lái)依據(jù)CSS選擇器來(lái)獲取元素。 DOM.create 在內(nèi)存中創(chuàng)建一個(gè)新的節(jié)點(diǎn)樹。它們的名字就可以清晰地表明它們的職責(zé)。
#p#
[]操作符的值
導(dǎo)致$函數(shù)被頻繁調(diào)用的另一個(gè)原因正是方括號(hào)操作符。當(dāng)一個(gè)新的jQuery對(duì)象被創(chuàng)建的時(shí)候,所有相關(guān)的節(jié)點(diǎn)都被存儲(chǔ)在數(shù)值型屬性中。但是請(qǐng)注意,這樣一個(gè)數(shù)值屬性的值包含了一個(gè)原生的元素實(shí)例(而非經(jīng)jQuery包裝過(guò)的對(duì)象):
- var links = $("a");
- links[0].on("click", function() { ... }); // throws an error
- $(links[0]).on("click", function() { ... }); // works fine
正因?yàn)檫@樣的特性,jQuery或是其它庫(kù)(比如Underscore)中的每一個(gè)功能方法都要求當(dāng)前元素在回調(diào)函數(shù)中使用$() 包起來(lái)。因此,開發(fā)者必須時(shí)刻牢記他們正在操作的對(duì)象類型——一個(gè)原生元素或是一個(gè)包裝過(guò)的對(duì)象——盡管事實(shí)上他們正在使用一個(gè)操作DOM的庫(kù)。
在better-dom中,方括號(hào)操作符返回一個(gè)庫(kù)對(duì)象,所以開發(fā)者可以忘記原生元素。只有一種可接受的方式來(lái)獲取原生元素:使用一個(gè)特別的 legacy方法。
- var foo = DOM.find("#foo");
- foo.legacy(function(node) {
- // use Hammer library to bind a swipe listener
- Hammer(node).on("swipe", function(e) {
- // handle swipe gesture here
- });
- });
事實(shí)上,只有非常少見的情況會(huì)需要這個(gè)方法,比如兼容一個(gè)原生的方法,或是另一個(gè)DOM庫(kù)(比如上邊例子中的Hammer)。
關(guān)于RETURN FALSE的問題
jQuery事件處理函數(shù)中返回false后的奇怪的攔截行為讓我一直很糾結(jié)。依據(jù)W3C的標(biāo)準(zhǔn),它應(yīng)該在大多數(shù)情況下取消默認(rèn)行為。在jQuery中,return false 還會(huì)阻止事件代理。
這樣的捕獲會(huì)導(dǎo)致問題:
1 自行調(diào)用stopPropagation() 可能導(dǎo)致兼容性問題,因?yàn)樗柚沽似渌蝿?wù)相關(guān)的監(jiān)聽者的執(zhí)行。
2 大部分開發(fā)者(即使是一些有經(jīng)驗(yàn)的)并沒有意識(shí)到這樣的行為
尚不清楚為什么jQuery社區(qū)決定不遵循標(biāo)準(zhǔn)。但better-dom并不會(huì)重蹈覆轍。 所以,正如每個(gè)人所預(yù)期的,事件句柄中的return false 只會(huì)阻止瀏覽器默認(rèn)行為,而不會(huì)干擾事件冒泡。
FIND 以及FINDALL
元素查找是在瀏覽器中代價(jià)***的操作之一。兩個(gè)原生的方法可以用來(lái)實(shí)現(xiàn)它:querySelector以及querySelectorAll。區(qū)別在于前者在匹配到***個(gè)結(jié)果后即停止查找。
這個(gè)特性使得我們可以顯著減少特定情形下的迭代次數(shù)。在我的測(cè)試中,速度提升到了二十倍!而且,可以預(yù)見,依據(jù)文檔樹的規(guī)模,提升可能達(dá)到更多。
jQuery提供了一個(gè)find 方法,使用querySelectorAll ,用于一般的情形。目前還沒有函數(shù)使用querySelector 來(lái)只獲取***個(gè)匹配的元素。
better-dom 庫(kù)有兩個(gè)單獨(dú)的方法:find 及findAll。它們?cè)试S我們使用querySelector 優(yōu)化。為了評(píng)估潛在的性能提升,我在我上一個(gè)商業(yè)項(xiàng)目的所有源代碼中搜索了這些方法的使用:
find
在11個(gè)文件中匹配103次
findAll
在4個(gè)文件中匹配14次
很明顯find 方法要受歡迎得多。這說(shuō)明querySelector 優(yōu)化在大多數(shù)情況下是有意義的,并能推動(dòng)相當(dāng)?shù)男阅芴嵘?/p>
結(jié)論
live擴(kuò)展確實(shí)使得解決前端問題簡(jiǎn)單不少。將UI分割為許多小塊可以帶來(lái)更加獨(dú)立、可維護(hù)的解決方案。不過(guò)正如我們所展示的,一個(gè)框架不僅僅是關(guān)于這些(盡管這是主要目標(biāo))。
我在開發(fā)過(guò)程中學(xué)習(xí)到的一件事是,如果你不喜歡某個(gè)標(biāo)準(zhǔn),或者你對(duì)該如何做某件事情有自己不同看法,那么就去實(shí)現(xiàn)它,證明你的方法可行。這也很有趣!
更多關(guān)于better-dom 項(xiàng)目的信息可以在GitHub找到。
原文鏈接:http://coding.smashingmagazine.com/2014/01/13/better-javascript-library-for-the-dom/