最近整理了下團隊新人文檔,對團隊內使用的框架 riot.js 這部分內容做了一些總結。本文主要在 riot.js 源碼 方面,分析一下 riot.js 的執行原理和使用優化。
Riot.js 簡介
Simple and elegant component-based UI library (Riot.js)
riot.js 是一個簡單優雅的 js UI框架。具有自定義標簽,簡單語法,API簡單,體積小, 學習成本低等特點。riot.js 使用Model-View-Presenter (MVP)設計模式來組織代碼, 這樣它能夠更模塊化、更具可測試性且易于理解。riot.js 僅僅提供了幫助UI渲染相關的基礎功能 ,并不具備其它復雜的功能,因此其體積很小,壓縮后僅有 10.39KB (react.min.js 大約 47.6KB ), 很適合組件類的業務開發。
Hello world
嘗試 Riot.js 最簡單的方法是使用 JSFiddle Hello Riot.js 例子。你可以在瀏覽器中打開它。 或者你也可以創建一個 .html 文件,然后通過如下方式引入Riot.js:
<script src="https://cdn.jsdelivr.net/npm/riot@3.7/riot+compiler.min.js"></scipt>
自定義標簽
Riot.js 采用自定義標簽的語法,每個自定義標簽都可以看做是一個組件(Riot.js Tag 對象),自定義標簽將相關的 HTML 和 JAVAScript 粘合在一起,成為一個可重用的組件。可以認為它同時具有 React 和 Polymer 的優點,但是語法更友好,學習成本更小。
<riot-demo> <span>{ title }</span> <script> this.title = "Hello World"; </script> </riot-demo>
在團隊中,我們會使用 webpack 來構建 riot 項目。每個組件都被寫成一個 *.tag 文件。
Riot.js 基本執行原理
一個riot自定義標簽在日常開發中從源碼到呈現在頁面上主要分為三步:編譯(一般利用官方自帶編譯工具)、注冊(riot.tag())和加載(riot.mount()),如下圖所示:
編譯
編譯階段的主要工作就是將riot語法寫的.tag文件轉換為可執行的.js文件,這部分主要靠編譯器來完成。例如:
riot.tag2('content-demo', '<h1>{message}</h1>', '', function (opts) { this.message = 'hello world'; });
riot.tag2 函數在 riot.js 源碼中的 core.js 文件中,代碼如下:
export function tag2(name, tmpl, css, attrs, fn) { if (css) styleManager.add(css, name) // tags implementation cache 標簽接口緩存 __TAG_IMPL[name] = { name, tmpl, attrs, fn } return name }
參數含義如下:
- name: riot 自定義標簽的名稱
- tmpl: 標簽的html內容
- css: 標簽中的內容
- attrs: riot 自定義標簽的屬性
- fn: 用戶自定義函數,即 標簽中的內容
riot.tag2() 函數將 riot tag 注冊到了 __TAG_IMP 對象中,方便之后的使用,css部分則被添加到了 byName 變量中,用于之后統一添加到頁面中。在源代碼中,還有一個 riot.tag()函數,這個函數用于直接直接創建一個 riot tag 實例的接口,而 riot.tag2() 是暴露給編輯器的接口,本質上功能是一樣的。
加載 riot.mount()
組件被注冊好以后,并沒有被渲染,直到我們調用 riot.mount() 函數后,相應的組件才會渲染到頁面上。源碼如下:
export function mount(selector, tagName, opts) { const tags = [] let elem, allTags // root {HTMLElement} riot-tag 標簽節點 function pushTagsTo(root) { if (root.tagName) { let riotTag = getAttr(root, IS_DIRECTIVE), // 要么 data-is 要么 root.tagName 本身 tag // ① 設置 data-is 屬性指向 if (tagName && riotTag !== tagName) { riotTag = tagName setAttr(root, IS_DIRECTIVE, tagName) } // ② mountTo 創建一個新的 riot tag 實例 tag = mountTo(root, riotTag || root.tagName.toLowerCase(), opts) if (tag) tags.push(tag) } else if (root.length) each(root, pushTagsTo) } // DOM 注入 style 標簽 styleManager.inject() if (isObject(tagName)) { opts = tagName tagName = 0 } if (isString(selector)) { selector = selector === '*' ? allTags = selectTags() : selector + selectTags(selector.split(/, */)) // ③ 利用 $$ 來判斷 這些 tag 是否已經掛載在 html 上面 elem = selector ? $$(selector) : [] } else elem = selector // 將所有元素掛載在根元素中 if (tagName === '*') { tagName = allTags || selectTags() if (elem.tagName) // 查找elem下的 tagName elem = $$(tagName, elem) else { // 將查找到的所有節點都 放入 nodeList中 var nodeList = [] each(elem, _el => nodeList.push($$(tagName, _el))) elem = nodeList } tagName = 0 } pushTagsTo(elem) return tags }
當調用 riot.mount 后,通過 selector 參數來查找 html 頁面上對應的節點。不在 html 上的節點是不會被渲染的。③處代碼為查找過程,其中$$為 document.querySelectAll。之后調用 pushTagsTo 函數來渲染 riot tag。
IS_DIRECTIVE = 'data-is' 渲染前,要檢查是否含有 tagName 參數,如果有的話即為 上述 riot.mount 的第三個用法。此時需要檢測 root 的 data-is 屬性值是否和 tagName 相等,如①處。不相等則將 root 設置其 data-is 為 tagName。
取消注冊 riot.unregister()
riot.unregister() 源碼十分簡單,如下:
export function unregister(name) { __TAG_IMPL[name] = null }
Riot.js 組件
在 Riot.js 中,每個自定義標簽都可以看成是一個組件,每個組件其實本質上都是一個 Tag 對象, 里面包含了對象的各種屬性和方法
Tag 類
Tag 類簡化源代碼如下:
// 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 組件狀態分為以下幾個部分:
- before-mount:標簽被加載之前
- mount:標簽實例被加載到頁面上以后
- update:允許在更新之前重新計算上下文數據
- updated:標簽模板更新后
- before-unmount:標簽實例被卸載之前
- unmount:標簽實例被從頁面上卸載后
riot.js 采用事件驅動的方式來進行通訊,我們可以采用如下函數來監聽上面的事件,例如處理 update 事件:
<riot-demo> <script> this.on('update', function() { // 標簽更新后的處理 }) </script> </riot-demo>
再談組件加載
當我們調用 riot.mount() 渲染指定組件的時候,riot 會從 __TAG_IMPL 中獲取相對應的已經注冊好的模板內容,并生成相應的 Tag 實例對象。并且觸發其上的 Tag.mount() 函數,最后將 Tag 對象緩存到 __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 }
組件加載階段,首先會整理標簽上所有的 attribute 的內容,區分普通屬性,和帶有表達式 expr 的屬性。
defineProperty(this, 'mount', function tagMount() { ... parseAttributes.apply(parent, [root, root.attributes, (attr, expr) => { // 檢測 expr 是否在 RefExpr 的原型鏈中 if (!isAnonymous && RefExpr.isPrototypeOf(expr)) expr.tag = this; // 掛載在 root.attributs 上面 root 為組件所在的 dom 對象 attr.expr = expr instAttrs.push(attr) }]) // impl 對象包含組件上的各種屬性,包括模板,邏輯等內容 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) //插入表達式 else setAttr(root, attr.name, attr.value) }]) ... }).bind(this)
初始化這些表達式內容,然后為組件添加全局注冊的mixin 內容。接下來,會執行我們為組件添加的函數內容,此時觸發 before-mount 事件。觸發完畢后,解析標簽上的表達式,比如 if each 等內容,然后執行組件的 update() 函數。
在 update() 函數中,首先會檢查用戶是否定義了組件的 shouldUpdate() 函數,如果有定義則傳入兩個參數,第一個是想要更新的內容(即調用this.update() 時傳入的參數)。第二個為接收的父組件更新的 opts 內容。若該函數返回值為 true 則更新渲染,否則放棄。 (這里需要注意,Tag.mount() 階段由于組件尚未處于記載完畢狀態,因此不會觸發 shouldUpdate() 函數)。
defineProperty(this, 'update', function tagUpdate(data) { ... // shouldUpdate 返回值檢測 if (canTrigger && this.isMounted && isFunction(this.shouldUpdate) && !this.shouldUpdate(data, nextOpts)) { return this } ... // 擴展opts extend(opts, nextOpts) if (canTrigger) this.trigger('update', data) update.call(this, expressions) if (canTrigger) this.trigger('updated') return this }).bind(this);
之后會觸發 update 事件,開始渲染新的組件。渲染完畢后觸發 updated 事件。
加載完畢后,修改組件狀態 defineProperty(this, 'isMounted', true)。如果渲染的組件不是作為子組件的話,我們就觸發自身的 mount 事件。否則的話,需要等到父組件加載完畢后,或者更新完畢后(已經加載過了),再觸發。
defineProperty(this, 'mount', function tagMount() { ... defineProperty(this, 'root', root) defineProperty(this, 'isMounted', true) if (skipAnonymous) return // 如果不是子組件則觸發 if (!this.parent) { this.trigger('mount') } // 否則需要等待父組件的狀態渲染狀態 else { const p = getImmediateCustomParentTag(this.parent) p.one(!p.isMounted ? 'mount' : 'updated', () => { this.trigger('mount') }) } return this }).bind(this)
當我們調用 tag.unmount 卸載組件的時候,首先會觸發 before-unmount 事件。再接下來清除所有的屬性和事件監聽等內容后,觸發 ‘unmount’ 事件。
組件更新原理
在 riot.js 中,想要更新組件我們必須手動調用 tag.update() 方法才可以或者通過綁定 dom 事件觸發(通過模板綁定的事件,會在回調執行完畢后自動觸發 tag.update ),并不能做到實時的更新處理。例如:
<riot-demo> <h1>{ title }</h1> <button click={ handleClick }>修改內容</button> <script> this.title = "標題" handleClick() { this.title = "新標題"; this.update(); // 調用 update 方法才能重新渲染組件 } </script> </riot-demo>
riot.js 并沒有提供 virtual dom 的功能,而是實現了一個粗粒度的 virtual dom。riot.js 為每個組件創建的 tag 對象中都保存一個 expressions 數組,更新的時候遍歷 expressions 數組,對比舊值,如果有變化就更新DOM。這種更新機制類似angular的臟檢查,但是僅有一輪檢查(單項數據流)。更新處理依照模板類型來處理:
- 文本內容的,直接: dom.nodeValue = value
- 值為空,而且關聯的 DOM 屬性是 checked/selected 等這種沒有屬性值的,移除對應的屬性
- 值為函數的,則進行事件綁定
- 屬性名為 if,則做條件判斷處理
- 做了 show/hide 的語法糖處理
export function toggleVisibility(dom, show) { dom.style.display = show ? '' : 'none' dom['hidden'] = show ? false : true }
- 普通屬性的,直接設置其值
riot.js 和 react 一樣也有 props(靜態,riot 中為 opts) 和本身數據(動態),具有和 react 一樣的輸入。但是輸出的時候,由于沒有 virtual dom UI的更新并沒有集中處理,是分散的。
riot.js 采用的這種方式,代碼量上大大的減少,但是也帶來了比較嚴重的性能問題。
更新性能問題
首先我們來看一段 vue 代碼:
<div id="demo"> <ul> <li v-for="item in items"> {{ item.name }} --- {{ item.age }} </li> </ul> <button v-on:click="handleClick">更新列表項</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); } })
代碼很簡單,單擊按鈕,為列表添加一條新數據。在組件掛載完畢后,為第一個 li 的 property 上面添加了 extraType 屬性。列表更新后,再去訪問這個 li 的 extraType 屬性。運行結果如下:
不出意料,可以正常訪問到 li 的type屬性。這說明了,在更新過程中,第一個 li 節點僅僅是 textContent 發生了改變而不是重新創建的。這樣的結果得益于 virtual dom 算法,保證更新最小變動。同樣的我們用 riot 來重寫上面的代碼。
<content-demo> <ul> <li each={ items }>{ name } -- { age }</li> </ul> <button class="btn" click={ handleClick }>訂閱內容</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>
查看運行結果:
extraType 找不到了,所有的 li 節點都被重新構建了。這里面發生了什么,查看源碼 /tag/each.js。渲染邏輯代碼如下:
export default function _each(dom, parent, expr) { ... expr.update = function updateEach() { ... each(items, function (item, i) { // 僅僅記錄 items 是對象的 var doReorder = mustReorder && typeof item === T_OBJECT && !hasKeys, // 舊數據 oldPos = oldItems.indexOf(item), // 是新的 isNew = oldPos === -1, pos = !isNew && doReorder ? oldPos : i, tag = tags[pos], // 必須追加 mustAppend = i >= oldItems.length, // 必須創建 isNew mustCreate = doReorder && isNew || !doReorder && !tag // 有key值得時候需要 mkitem item = !hasKeys && expr.key ? mkitem(expr, item, i) : item // 必須創建一個新 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 // 移動 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) } // 緩存原始數據到節點上 tag.__.item = item tag.__.index = i tag.__.parent = parent; // 如果不是創建的,我們需要更新節點內容。 if (!mustCreate) tag.update(item) }) // remove the redundant tags // 刪除多余的標簽 unmountRedundant(items, tags) // 記錄舊的數據 // clone the items array oldItems = items.slice() // dom 插入節點 root.insertBefore(frag, placeholder) } }
這段為列表渲染邏輯,遍歷新的數據items中的每一下 item。在原始數據 oldItems 中去查找(oldItems.indexOf(itemId)),是否存在 item 項。如果不存在,則標記 isNews 為 true。之后走到 if 的 mustCreaete 為 true 的分支,去創建一個新的 tag(將 li 節點看成是一個tag)。以此類推,當全部創建完畢后,刪除舊的節點(unmountRedundant(items, tags))。在斷點下,可以清楚看到節點的變化情況:
優化更新
綜上所述,riot.js 的更新邏輯僅僅是判斷新舊數據項是否為同一對象。為此,為了減少 DOM 的變動,降低渲染邏輯。我們修改handleClick函數:
handleClick() { this.items.push({"name": "hy", age: 22}) }
這樣輸出結果就會和 vue 的保持一致,并沒有創建新的 tag,而是利用了已經存在的內容。源碼中,這種情況下 isNews 為 false,從而避開了 創建標簽。而僅僅是通過 tags.splice(i, 0, tags.splice(pos, 1)[0]); 來移動位置,if (!mustCreate) { tag.update(item); } 更新節點內容。
保證數據項對象地址不變,僅僅是修改上面的不可變對象的值,將大大的提高 riot.js 的渲染效率。
// 更新第一個li內容 // 不推薦寫法,對象發生變化; this.items[0] = {"name": "hy", age: 23}; // 推薦寫法,僅僅是修改對象中的值 this.items[0].name = "hy"; this.items[0].age = 22;
保證源數據對象的不變,僅僅改變其上面的值,這樣就能減少 riot.js 渲染過程中,創建新的 tag 對象的開銷。
希望本文能幫助到您!
點贊+轉發,讓更多的人也能看到這篇內容(收藏不點贊,都是耍流氓-_-)
關注 {我},享受文章首發體驗!
每周重點攻克一個前端技術難點。更多精彩前端內容私信 我 回復“教程”
原文鏈接: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
作者:田光宇