最近整理了下團(tuán)隊(duì)新人文檔,對(duì)團(tuán)隊(duì)內(nèi)使用的框架 riot.js 這部分內(nèi)容做了一些總結(jié)。本文主要在 riot.js 源碼 方面,分析一下 riot.js 的執(zhí)行原理和使用優(yōu)化。
Riot.js 簡(jiǎn)介
Simple and elegant component-based UI library (Riot.js)
riot.js 是一個(gè)簡(jiǎn)單優(yōu)雅的 js UI框架。具有自定義標(biāo)簽,簡(jiǎn)單語(yǔ)法,API簡(jiǎn)單,體積小, 學(xué)習(xí)成本低等特點(diǎn)。riot.js 使用Model-View-Presenter (MVP)設(shè)計(jì)模式來(lái)組織代碼, 這樣它能夠更模塊化、更具可測(cè)試性且易于理解。riot.js 僅僅提供了幫助UI渲染相關(guān)的基礎(chǔ)功能 ,并不具備其它復(fù)雜的功能,因此其體積很小,壓縮后僅有 10.39KB (react.min.js 大約 47.6KB ), 很適合組件類的業(yè)務(wù)開(kāi)發(fā)。
Hello world
嘗試 Riot.js 最簡(jiǎn)單的方法是使用 JSFiddle Hello Riot.js 例子。你可以在瀏覽器中打開(kāi)它。 或者你也可以創(chuàng)建一個(gè) .html 文件,然后通過(guò)如下方式引入Riot.js:
<script src="https://cdn.jsdelivr.net/npm/[email protected]/riot+compiler.min.js"></scipt>
自定義標(biāo)簽
Riot.js 采用自定義標(biāo)簽的語(yǔ)法,每個(gè)自定義標(biāo)簽都可以看做是一個(gè)組件(Riot.js Tag 對(duì)象),自定義標(biāo)簽將相關(guān)的 HTML 和 JAVAScript 粘合在一起,成為一個(gè)可重用的組件。可以認(rèn)為它同時(shí)具有 React 和 Polymer 的優(yōu)點(diǎn),但是語(yǔ)法更友好,學(xué)習(xí)成本更小。
<riot-demo> <span>{ title }</span> <script> this.title = "Hello World"; </script> </riot-demo>
在團(tuán)隊(duì)中,我們會(huì)使用 webpack 來(lái)構(gòu)建 riot 項(xiàng)目。每個(gè)組件都被寫(xiě)成一個(gè) *.tag 文件。
Riot.js 基本執(zhí)行原理
一個(gè)riot自定義標(biāo)簽在日常開(kāi)發(fā)中從源碼到呈現(xiàn)在頁(yè)面上主要分為三步:編譯(一般利用官方自帶編譯工具)、注冊(cè)(riot.tag())和加載(riot.mount()),如下圖所示:

編譯
編譯階段的主要工作就是將riot語(yǔ)法寫(xiě)的.tag文件轉(zhuǎn)換為可執(zhí)行的.js文件,這部分主要靠編譯器來(lái)完成。例如:
riot.tag2('content-demo', '<h1>{message}</h1>', '', function (opts) { this.message = 'hello world'; });
riot.tag2 函數(shù)在 riot.js 源碼中的 core.js 文件中,代碼如下:
export function tag2(name, tmpl, css, attrs, fn) { if (css) styleManager.add(css, name) // tags implementation cache 標(biāo)簽接口緩存 __TAG_IMPL[name] = { name, tmpl, attrs, fn } return name }
參數(shù)含義如下:
- name: riot 自定義標(biāo)簽的名稱
- tmpl: 標(biāo)簽的html內(nèi)容
- css: 標(biāo)簽中的內(nèi)容
- attrs: riot 自定義標(biāo)簽的屬性
- fn: 用戶自定義函數(shù),即 標(biāo)簽中的內(nèi)容
riot.tag2() 函數(shù)將 riot tag 注冊(cè)到了 __TAG_IMP 對(duì)象中,方便之后的使用,css部分則被添加到了 byName 變量中,用于之后統(tǒng)一添加到頁(yè)面中。在源代碼中,還有一個(gè) riot.tag()函數(shù),這個(gè)函數(shù)用于直接直接創(chuàng)建一個(gè) riot tag 實(shí)例的接口,而 riot.tag2() 是暴露給編輯器的接口,本質(zhì)上功能是一樣的。
加載 riot.mount()
組件被注冊(cè)好以后,并沒(méi)有被渲染,直到我們調(diào)用 riot.mount() 函數(shù)后,相應(yīng)的組件才會(huì)渲染到頁(yè)面上。源碼如下:
export function mount(selector, tagName, opts) { const tags = [] let elem, allTags // root {HTMLElement} riot-tag 標(biāo)簽節(jié)點(diǎn) function pushTagsTo(root) { if (root.tagName) { let riotTag = getAttr(root, IS_DIRECTIVE), // 要么 data-is 要么 root.tagName 本身 tag // ① 設(shè)置 data-is 屬性指向 if (tagName && riotTag !== tagName) { riotTag = tagName setAttr(root, IS_DIRECTIVE, tagName) } // ② mountTo 創(chuàng)建一個(gè)新的 riot tag 實(shí)例 tag = mountTo(root, riotTag || root.tagName.toLowerCase(), opts) if (tag) tags.push(tag) } else if (root.length) each(root, pushTagsTo) } // DOM 注入 style 標(biāo)簽 styleManager.inject() if (isObject(tagName)) { opts = tagName tagName = 0 } if (isString(selector)) { selector = selector === '*' ? allTags = selectTags() : selector + selectTags(selector.split(/, */)) // ③ 利用 $$ 來(lái)判斷 這些 tag 是否已經(jīng)掛載在 html 上面 elem = selector ? $$(selector) : [] } else elem = selector // 將所有元素掛載在根元素中 if (tagName === '*') { tagName = allTags || selectTags() if (elem.tagName) // 查找elem下的 tagName elem = $$(tagName, elem) else { // 將查找到的所有節(jié)點(diǎn)都 放入 nodeList中 var nodeList = [] each(elem, _el => nodeList.push($$(tagName, _el))) elem = nodeList } tagName = 0 } pushTagsTo(elem) return tags }
當(dāng)調(diào)用 riot.mount 后,通過(guò) selector 參數(shù)來(lái)查找 html 頁(yè)面上對(duì)應(yīng)的節(jié)點(diǎn)。不在 html 上的節(jié)點(diǎn)是不會(huì)被渲染的。③處代碼為查找過(guò)程,其中$$為 document.querySelectAll。之后調(diào)用 pushTagsTo 函數(shù)來(lái)渲染 riot tag。
IS_DIRECTIVE = 'data-is' 渲染前,要檢查是否含有 tagName 參數(shù),如果有的話即為 上述 riot.mount 的第三個(gè)用法。此時(shí)需要檢測(cè) root 的 data-is 屬性值是否和 tagName 相等,如①處。不相等則將 root 設(shè)置其 data-is 為 tagName。
取消注冊(cè) riot.unregister()
riot.unregister() 源碼十分簡(jiǎn)單,如下:
export function unregister(name) { __TAG_IMPL[name] = null }
Riot.js 組件
在 Riot.js 中,每個(gè)自定義標(biāo)簽都可以看成是一個(gè)組件,每個(gè)組件其實(shí)本質(zhì)上都是一個(gè) Tag 對(duì)象, 里面包含了對(duì)象的各種屬性和方法
Tag 類
Tag 類簡(jiǎn)化源代碼如下:
// impl 包含組件的模板,邏輯等屬性 export default function Tag(impl = {}, conf = {}, innerHTML) { ...各種屬性初始化 defineProperty(this, '__', {...}) defineProperty(this, '_riot_id', ++__uid) defineProperty(this, 'refs', {}) ... // 定義組件更新方法 defineProperty(this, 'update', function tagUpdate(data){...}.bind(this)) // 定義組件 mixin 方法 defineProperty(this, 'update', function tagMixin(data){...}.bind(this)) // 定義組件加載方法 defineProperty(this, 'mount', function tagMount(data){...}.bind(this)) // 定義組件卸載方法 defineProperty(this, 'mount', function tagUnmount(data){...}.bind(this)) }
組件的生命周期
riot 組件狀態(tài)分為以下幾個(gè)部分:
- before-mount:標(biāo)簽被加載之前
- mount:標(biāo)簽實(shí)例被加載到頁(yè)面上以后
- update:允許在更新之前重新計(jì)算上下文數(shù)據(jù)
- updated:標(biāo)簽?zāi)0甯潞?/li>
- before-unmount:標(biāo)簽實(shí)例被卸載之前
- unmount:標(biāo)簽實(shí)例被從頁(yè)面上卸載后

riot.js 采用事件驅(qū)動(dòng)的方式來(lái)進(jìn)行通訊,我們可以采用如下函數(shù)來(lái)監(jiān)聽(tīng)上面的事件,例如處理 update 事件:
<riot-demo> <script> this.on('update', function() { // 標(biāo)簽更新后的處理 }) </script> </riot-demo>
再談組件加載
當(dāng)我們調(diào)用 riot.mount() 渲染指定組件的時(shí)候,riot 會(huì)從 __TAG_IMPL 中獲取相對(duì)應(yīng)的已經(jīng)注冊(cè)好的模板內(nèi)容,并生成相應(yīng)的 Tag 實(shí)例對(duì)象。并且觸發(fā)其上的 Tag.mount() 函數(shù),最后將 Tag 對(duì)象緩存到 __TAGS_CACHE 中。代碼如下:
export function mountTo(root, tagName, opts, ctx) { var impl = __TAG_IMPL[tagName], // 獲取 html 模板 implClass = __TAG_IMPL[tagName].class, // ? tag = ctx || (implClass ? Object.create(implClass.prototype) : {}), innerHTML = root._innerHTML = root._innerHTML || root.innerHTML var conf = extend({ root: root, opts: opts }, { parent: opts ? opts.parent : null }) if (impl && root) Tag.Apply(tag, [impl, conf, innerHTML]); if (tag && tag.mount) { tag.mount(true) // add this tag to the virtualDom variable if (!contains(__TAGS_CACHE, tag)) __TAGS_CACHE.push(tag) } return tag }
組件加載階段,首先會(huì)整理標(biāo)簽上所有的 attribute 的內(nèi)容,區(qū)分普通屬性,和帶有表達(dá)式 expr 的屬性。
defineProperty(this, 'mount', function tagMount() { ... parseAttributes.apply(parent, [root, root.attributes, (attr, expr) => { // 檢測(cè) expr 是否在 RefExpr 的原型鏈中 if (!isAnonymous && RefExpr.isPrototypeOf(expr)) expr.tag = this; // 掛載在 root.attributs 上面 root 為組件所在的 dom 對(duì)象 attr.expr = expr instAttrs.push(attr) }]) // impl 對(duì)象包含組件上的各種屬性,包括模板,邏輯等內(nèi)容 implAttrs = [] walkAttrs(impl.attrs, (k, v) => { implAttrs.push({ name: k, value: v }) }) // 檢查的是 implAttrs parseAttributes.apply(this, [root, implAttrs, (attr, expr) => { if (expr) expressions.push(expr) //插入表達(dá)式 else setAttr(root, attr.name, attr.value) }]) ... }).bind(this)
初始化這些表達(dá)式內(nèi)容,然后為組件添加全局注冊(cè)的mixin 內(nèi)容。接下來(lái),會(huì)執(zhí)行我們?yōu)榻M件添加的函數(shù)內(nèi)容,此時(shí)觸發(fā) before-mount 事件。觸發(fā)完畢后,解析標(biāo)簽上的表達(dá)式,比如 if each 等內(nèi)容,然后執(zhí)行組件的 update() 函數(shù)。
在 update() 函數(shù)中,首先會(huì)檢查用戶是否定義了組件的 shouldUpdate() 函數(shù),如果有定義則傳入兩個(gè)參數(shù),第一個(gè)是想要更新的內(nèi)容(即調(diào)用this.update() 時(shí)傳入的參數(shù))。第二個(gè)為接收的父組件更新的 opts 內(nèi)容。若該函數(shù)返回值為 true 則更新渲染,否則放棄。 (這里需要注意,Tag.mount() 階段由于組件尚未處于記載完畢狀態(tài),因此不會(huì)觸發(fā) shouldUpdate() 函數(shù))。
defineProperty(this, 'update', function tagUpdate(data) { ... // shouldUpdate 返回值檢測(cè) if (canTrigger && this.isMounted && isFunction(this.shouldUpdate) && !this.shouldUpdate(data, nextOpts)) { return this } ... // 擴(kuò)展opts extend(opts, nextOpts) if (canTrigger) this.trigger('update', data) update.call(this, expressions) if (canTrigger) this.trigger('updated') return this }).bind(this);
之后會(huì)觸發(fā) update 事件,開(kāi)始渲染新的組件。渲染完畢后觸發(fā) updated 事件。
加載完畢后,修改組件狀態(tài) defineProperty(this, 'isMounted', true)。如果渲染的組件不是作為子組件的話,我們就觸發(fā)自身的 mount 事件。否則的話,需要等到父組件加載完畢后,或者更新完畢后(已經(jīng)加載過(guò)了),再觸發(fā)。
defineProperty(this, 'mount', function tagMount() { ... defineProperty(this, 'root', root) defineProperty(this, 'isMounted', true) if (skipAnonymous) return // 如果不是子組件則觸發(fā) if (!this.parent) { this.trigger('mount') } // 否則需要等待父組件的狀態(tài)渲染狀態(tài) else { const p = getImmediateCustomParentTag(this.parent) p.one(!p.isMounted ? 'mount' : 'updated', () => { this.trigger('mount') }) } return this }).bind(this)
當(dāng)我們調(diào)用 tag.unmount 卸載組件的時(shí)候,首先會(huì)觸發(fā) before-unmount 事件。再接下來(lái)清除所有的屬性和事件監(jiān)聽(tīng)等內(nèi)容后,觸發(fā) ‘unmount’ 事件。
組件更新原理
在 riot.js 中,想要更新組件我們必須手動(dòng)調(diào)用 tag.update() 方法才可以或者通過(guò)綁定 dom 事件觸發(fā)(通過(guò)模板綁定的事件,會(huì)在回調(diào)執(zhí)行完畢后自動(dòng)觸發(fā) tag.update ),并不能做到實(shí)時(shí)的更新處理。例如:
<riot-demo> <h1>{ title }</h1> <button click={ handleClick }>修改內(nèi)容</button> <script> this.title = "標(biāo)題" handleClick() { this.title = "新標(biāo)題"; this.update(); // 調(diào)用 update 方法才能重新渲染組件 } </script> </riot-demo>
riot.js 并沒(méi)有提供 virtual dom 的功能,而是實(shí)現(xiàn)了一個(gè)粗粒度的 virtual dom。riot.js 為每個(gè)組件創(chuàng)建的 tag 對(duì)象中都保存一個(gè) expressions 數(shù)組,更新的時(shí)候遍歷 expressions 數(shù)組,對(duì)比舊值,如果有變化就更新DOM。這種更新機(jī)制類似angular的臟檢查,但是僅有一輪檢查(單項(xiàng)數(shù)據(jù)流)。更新處理依照模板類型來(lái)處理:
- 文本內(nèi)容的,直接: dom.nodeValue = value
- 值為空,而且關(guān)聯(lián)的 DOM 屬性是 checked/selected 等這種沒(méi)有屬性值的,移除對(duì)應(yīng)的屬性
- 值為函數(shù)的,則進(jìn)行事件綁定
- 屬性名為 if,則做條件判斷處理
- 做了 show/hide 的語(yǔ)法糖處理
export function toggleVisibility(dom, show) { dom.style.display = show ? '' : 'none' dom['hidden'] = show ? false : true }
- 普通屬性的,直接設(shè)置其值
riot.js 和 react 一樣也有 props(靜態(tài),riot 中為 opts) 和本身數(shù)據(jù)(動(dòng)態(tài)),具有和 react 一樣的輸入。但是輸出的時(shí)候,由于沒(méi)有 virtual dom UI的更新并沒(méi)有集中處理,是分散的。
riot.js 采用的這種方式,代碼量上大大的減少,但是也帶來(lái)了比較嚴(yán)重的性能問(wèn)題。
更新性能問(wèn)題
首先我們來(lái)看一段 vue 代碼:
<div id="demo"> <ul> <li v-for="item in items"> {{ item.name }} --- {{ item.age }} </li> </ul> <button v-on:click="handleClick">更新列表項(xiàng)</button> </div> var demo = new Vue({ el: '#demo', data: { items: [ { name: 'tgy', age: 23}, ] }, methods: { handleClick: function() { this.items = [ { name: 'tgy', age: 23}, { name: 'hy', age: 22}, ] } }, mounted: function() { console.log("組件掛載完畢"); document.querySelector("li").extraType = "origin"; }, updated: function() { console.log("組件更新完畢"); console.log(document.querySelector("li").extraType); } })
代碼很簡(jiǎn)單,單擊按鈕,為列表添加一條新數(shù)據(jù)。在組件掛載完畢后,為第一個(gè) li 的 property 上面添加了 extraType 屬性。列表更新后,再去訪問(wèn)這個(gè) li 的 extraType 屬性。運(yùn)行結(jié)果如下:

不出意料,可以正常訪問(wèn)到 li 的type屬性。這說(shuō)明了,在更新過(guò)程中,第一個(gè) li 節(jié)點(diǎn)僅僅是 textContent 發(fā)生了改變而不是重新創(chuàng)建的。這樣的結(jié)果得益于 virtual dom 算法,保證更新最小變動(dòng)。同樣的我們用 riot 來(lái)重寫(xiě)上面的代碼。
<content-demo> <ul> <li each={ items }>{ name } -- { age }</li> </ul> <button class="btn" click={ handleClick }>訂閱內(nèi)容</button> <script> let self = this; this.items = [ {"name": "tgy", age: 23} ]; handleClick() { this.items = [ {"name": "tgy", age: 23}, {"name": "hy", age: 22} ] } this.on('mount', function() { console.log("組件加載完畢"); document.querySelector("li").extraType = "origin"; }) this.on('updated', function() { console.log("組件更新完畢"); console.log(document.querySelector("li").extraType); }) </script> </content-demo>
查看運(yùn)行結(jié)果:

extraType 找不到了,所有的 li 節(jié)點(diǎn)都被重新構(gòu)建了。這里面發(fā)生了什么,查看源碼 /tag/each.js。渲染邏輯代碼如下:
export default function _each(dom, parent, expr) { ... expr.update = function updateEach() { ... each(items, function (item, i) { // 僅僅記錄 items 是對(duì)象的 var doReorder = mustReorder && typeof item === T_OBJECT && !hasKeys, // 舊數(shù)據(jù) oldPos = oldItems.indexOf(item), // 是新的 isNew = oldPos === -1, pos = !isNew && doReorder ? oldPos : i, tag = tags[pos], // 必須追加 mustAppend = i >= oldItems.length, // 必須創(chuàng)建 isNew mustCreate = doReorder && isNew || !doReorder && !tag // 有key值得時(shí)候需要 mkitem item = !hasKeys && expr.key ? mkitem(expr, item, i) : item // 必須創(chuàng)建一個(gè)新 tag if (mustCreate) { tag = new Tag(impl, { parent, isLoop, isAnonymous, tagName, root: dom.cloneNode(isAnonymous), item, index: i, }, dom.innerHTML) // mount the tag tag.mount() if (mustAppend) append.apply(tag, [frag || root, isVirtual]) else insert.apply(tag, [root, tags[i], isVirtual]) if (!mustAppend) oldItems.splice(i, 0, item) tags.splice(i, 0, tag) if (child) arrayishAdd(parent.tags, tagName, tag, true) } else if (pos !== i && doReorder) { // move // 移動(dòng) if (contains(items, oldItems[pos])) { move.apply(tag, [root, tags[i], isVirtual]) // move the old tag instance tags.splice(i, 0, tags.splice(pos, 1)[0]) // move the old item oldItems.splice(i, 0, oldItems.splice(pos, 1)[0]) } if (expr.pos) tag[expr.pos] = i if (!child && tag.tags) moveNestedTags.call(tag, i) } // 緩存原始數(shù)據(jù)到節(jié)點(diǎn)上 tag.__.item = item tag.__.index = i tag.__.parent = parent; // 如果不是創(chuàng)建的,我們需要更新節(jié)點(diǎn)內(nèi)容。 if (!mustCreate) tag.update(item) }) // remove the redundant tags // 刪除多余的標(biāo)簽 unmountRedundant(items, tags) // 記錄舊的數(shù)據(jù) // clone the items array oldItems = items.slice() // dom 插入節(jié)點(diǎn) root.insertBefore(frag, placeholder) } }
這段為列表渲染邏輯,遍歷新的數(shù)據(jù)items中的每一下 item。在原始數(shù)據(jù) oldItems 中去查找(oldItems.indexOf(itemId)),是否存在 item 項(xiàng)。如果不存在,則標(biāo)記 isNews 為 true。之后走到 if 的 mustCreaete 為 true 的分支,去創(chuàng)建一個(gè)新的 tag(將 li 節(jié)點(diǎn)看成是一個(gè)tag)。以此類推,當(dāng)全部創(chuàng)建完畢后,刪除舊的節(jié)點(diǎn)(unmountRedundant(items, tags))。在斷點(diǎn)下,可以清楚看到節(jié)點(diǎn)的變化情況:

優(yōu)化更新
綜上所述,riot.js 的更新邏輯僅僅是判斷新舊數(shù)據(jù)項(xiàng)是否為同一對(duì)象。為此,為了減少 DOM 的變動(dòng),降低渲染邏輯。我們修改handleClick函數(shù):
handleClick() { this.items.push({"name": "hy", age: 22}) }
這樣輸出結(jié)果就會(huì)和 vue 的保持一致,并沒(méi)有創(chuàng)建新的 tag,而是利用了已經(jīng)存在的內(nèi)容。源碼中,這種情況下 isNews 為 false,從而避開(kāi)了 創(chuàng)建標(biāo)簽。而僅僅是通過(guò) tags.splice(i, 0, tags.splice(pos, 1)[0]); 來(lái)移動(dòng)位置,if (!mustCreate) { tag.update(item); } 更新節(jié)點(diǎn)內(nèi)容。
保證數(shù)據(jù)項(xiàng)對(duì)象地址不變,僅僅是修改上面的不可變對(duì)象的值,將大大的提高 riot.js 的渲染效率。
// 更新第一個(gè)li內(nèi)容 // 不推薦寫(xiě)法,對(duì)象發(fā)生變化; this.items[0] = {"name": "hy", age: 23}; // 推薦寫(xiě)法,僅僅是修改對(duì)象中的值 this.items[0].name = "hy"; this.items[0].age = 22;
保證源數(shù)據(jù)對(duì)象的不變,僅僅改變其上面的值,這樣就能減少 riot.js 渲染過(guò)程中,創(chuàng)建新的 tag 對(duì)象的開(kāi)銷。
希望本文能幫助到您!
點(diǎn)贊+轉(zhuǎn)發(fā),讓更多的人也能看到這篇內(nèi)容(收藏不點(diǎn)贊,都是耍流氓-_-)
關(guān)注 {我},享受文章首發(fā)體驗(yàn)!
每周重點(diǎn)攻克一個(gè)前端技術(shù)難點(diǎn)。更多精彩前端內(nèi)容私信 我 回復(fù)“教程”
原文鏈接:http://eux.baidu.com/blog/fe/riot-js-%E6%A1%86%E6%9E%B6%E6%B7%B1%E5%85%A5%E8%A7%A3%E6%9E%90
作者:田光宇