題圖:Vincent Guthhtml
注:本文全部代碼都可在本人的我的項目colon中找到,本文也同步到了知乎專欄vue
可能你已經體會到了 Vue
所帶來的便捷了,相信有一部分緣由也是由於其基於 DOM 的語法簡潔的模板渲染引擎。這篇文章將會介紹如何實現一個基於 DOM 的模板引擎(就像 Vue
的模板引擎同樣)。node
開始以前,咱們先來看一下最終的效果:git
const compiled = Compile(`<h1>Hey ?, {{ greeting }}</h1>`, { greeting: `Hello World`, }); compiled.view // => `<h1>Hey ?, Hello World</h1>`
實現一個模板引擎實際上就是實現一個編譯器,就像這樣:github
const compiled = Compile(template: String|Node, data: Object); compiled.view // => compiled template
首先,讓咱們來看下 Compile
內部是如何實現的:正則表達式
// compile.js /** * template compiler * * @param {String|Node} template * @param {Object} data */ function Compile(template, data) { if (!(this instanceof Compile)) return new Compile(template, data); this.options = {}; this.data = data; if (template instanceof Node) { this.options.template = template; } else if (typeof template === 'string') { this.options.template = domify(template); } else { console.error(`"template" only accept DOM node or string template`); } template = this.options.template; walk(template, (node, next) => { if (node.nodeType === 1) { // compile element node this.compile.elementNodes.call(this, node); return next(); } else if (node.nodeType === 3) { // compile text node this.compile.textNodes.call(this, node); } next(); }); this.view = template; template = null; } Compile.compile = {};
經過上面的代碼,能夠看到 Compile
的構造函數主要就是作了一件事 ———— 遍歷 template
,而後經過判斷節點類型的不一樣來作不一樣的編譯操做,這裏就不介紹如何遍歷 template
了,不明白的話能夠直接看 walk
函數的源碼,咱們着重來看下如何編譯這些不一樣類型的節點,以編譯 node.nodeType === 1
的元素節點爲例:express
/** * compile element node * * @param {Node} node */ Compile.compile.elementNodes = function (node) { const bindSymbol = `:`; let attributes = [].slice.call(node.attributes), attrName = ``, attrValue = ``, directiveName = ``; attributes.map(attribute => { attrName = attribute.name; attrValue = attribute.value.trim(); if (attrName.indexOf(bindSymbol) === 0 && attrValue !== '') { directiveName = attrName.slice(bindSymbol.length); this.bindDirective({ node, expression: attrValue, name: directiveName, }); node.removeAttribute(attrName); } else { this.bindAttribute(node, attribute); } }); };
噢忘記說了,這裏我參考了 Vue
的指令語法,就是在帶有冒號 :
的屬性名中(固然這裏也能夠是任何其餘你所喜歡的符號),能夠直接寫 JavaScript 的表達式,而後也會提供幾個特殊的指令,例如 :text
, :show
等等來對元素作一些不一樣的操做。瀏覽器
其實該函數只作了兩件事:app
遍歷該節點的全部屬性,經過判斷屬性類型的不一樣來作不一樣的操做,判斷的標準就是屬性名是不是冒號 :
開頭而且屬性的值不爲空;dom
綁定相應的指令去更新屬性。
其次,再看一下 Directive
內部是如何實現的:
import directives from './directives'; import { generate } from './compile/generate'; export default function Directive(options = {}) { Object.assign(this, options); Object.assign(this, directives[this.name]); this.beforeUpdate && this.beforeUpdate(); this.update && this.update(generate(this.expression)(this.compile.options.data)); }
Directive
作了三件事:
註冊指令(Object.assign(this, directives[this.name])
);
計算指令表達式的實際值(generate(this.expression)(this.compile.options.data)
);
把計算出來的實際值更新到 DOM 上面(this.update()
)。
在介紹指令以前,先看一下它的用法:
Compile.prototype.bindDirective = function (options) { new Directive({ ...options, compile: this, }); }; Compile.prototype.bindAttribute = function (node, attribute) { if (!hasInterpolation(attribute.value) || attribute.value.trim() == '') return false; this.bindDirective({ node, name: 'attribute', expression: parse.text(attribute.value), attrName: attribute.name, }); };
bindDirective
對 Directive
作了一個很是簡單的封裝,接受三個必填屬性:
node
: 當前所編譯的節點,在 Directive
的 update
方法中用來更新當前節點;
name
: 當前所綁定的指令名稱,用來區分具體使用哪一個指令更新器來更新視圖;
expression
: parse 以後的 JavaScript 的表達式。
在 Directive
內部咱們經過 Object.assign(this, directives[this.name]);
來註冊不一樣的指令,因此變量 directives
的值多是這樣的:
// directives export default { // directive `:show` show: { beforeUpdate() {}, update(show) { this.node.style.display = show ? `block` : `none`; }, }, // directive `:text` text: { beforeUpdate() {}, update(value) { // ... }, }, };
因此假設某個指令的名字是 show
的話,那麼 Object.assign(this, directives[this.name]);
就等同於:
Object.assign(this, { beforeUpdate() {}, update(show) { this.node.style.display = show ? `block` : `none`; }, });
表示對於指令 show
,指令更新器會改變該元素 style
的 display
值,從而實現對應的功能。因此你會發現,整個編譯器結構設計好後,若是咱們要拓展功能的話,只需簡單地編寫指令的更新器便可,這裏再以指令 text
舉個例子:
// directives export default { // directive `:show` // show: { ... }, // directive `:text` text: { update(value) { this.node.textContent = value; }, }, };
有沒有發現編寫一個指令其實很是的簡單,而後咱們就能夠這麼使用咱們的 text
指令了:
const compiled = Compile(`<h1 :text="'Hey ?, ' + greeting"></h1>`, { greeting: `Hello World`, }); compiled.view // => `<h1>Hey ?, Hello World</h1>`
講到這裏,其實還有一個很是重要的點沒有提到,就是咱們如何把 data
真實數據渲染到模板中,好比 <h1>Hey ?, {{ greeting }}</h1>
如何渲染成 <h1>Hey ?, Hello World</h1>
,經過下面三個步驟便可計算出表達式的真實數據:
把 <h1>Hey ?, {{ greeting }}</h1>
解析成 'Hey ?, ' + greeting
這樣的 JavaScript 表達式;
提取其中的依賴變量並取得所在 data
中的對應值;
利用 new Function()
來建立一個匿名函數來返回這個表達式;
最後經過調用這個匿名函數來返回最終計算出來的數據並經過指令的 update
方法更新到視圖中。
// reference: https://github.com/vuejs/vue/blob/dev/src/compiler/parser/text-parser.js#L15-L41 const tagRE = /\{\{((?:.|\n)+?)\}\}/g; function parse(text) { if (!tagRE.test(text)) return JSON.stringify(text); const tokens = []; let lastIndex = tagRE.lastIndex = 0; let index, matched; while (matched = tagRE.exec(text)) { index = matched.index; if (index > lastIndex) { tokens.push(JSON.stringify(text.slice(lastIndex, index))); } tokens.push(matched[1].trim()); lastIndex = index + matched[0].length; } if (lastIndex < text.length) tokens.push(JSON.stringify(text.slice(lastIndex))); return tokens.join('+'); }
該函數我是直接參考 Vue
的實現,它會把含有雙花括號的字符串解析成標準的 JavaScript 表達式,例如:
parse(`Hi {{ user.name }}, {{ colon }} is awesome.`); // => 'Hi ' + user.name + ', ' + colon + ' is awesome.'
咱們會經過下面這個函數來提取出一個表達式中可能存在的變量:
const dependencyRE = /"[^"]*"|'[^']*'|\.\w*[a-zA-Z$_]\w*|\w*[a-zA-Z$_]\w*:|(\w*[a-zA-Z$_]\w*)/g; const globals = [ 'true', 'false', 'undefined', 'null', 'NaN', 'isNaN', 'typeof', 'in', 'decodeURI', 'decodeURIComponent', 'encodeURI', 'encodeURIComponent', 'unescape', 'escape', 'eval', 'isFinite', 'Number', 'String', 'parseFloat', 'parseInt', ]; function extractDependencies(expression) { const dependencies = []; expression.replace(dependencyRE, (match, dependency) => { if ( dependency !== undefined && dependencies.indexOf(dependency) === -1 && globals.indexOf(dependency) === -1 ) { dependencies.push(dependency); } }); return dependencies; }
經過正則表達式 dependencyRE
匹配出可能的變量依賴後,還要進行一些對比,好比是不是全局變量等等。效果以下:
extractDependencies(`typeof String(name) === 'string' && 'Hello ' + world + '! ' + hello.split('').join('') + '.'`); // => ["name", "world", "hello"]
這正是咱們須要的結果,typeof
, String
, split
和 join
並非 data
中所依賴的變量,因此不須要被提取出來。
export function generate(expression) { const dependencies = extractDependencies(expression); let dependenciesCode = ''; dependencies.map(dependency => dependenciesCode += `var ${dependency} = this.get("${dependency}"); `); return new Function(`data`, `${dependenciesCode}return ${expression};`); }
咱們提取變量的目的就是爲了在 generate
函數中生成相應的變量賦值的字符串便於在 generate
函數中使用,例如:
new Function(`data`, ` var name = data["name"]; var world = data["world"]; var hello = data["hello"]; return typeof String(name) === 'string' && 'Hello ' + world + '! ' + hello.split('').join('') + '.'; `); // will generated: function anonymous(data) { var name = data["name"]; var world = data["world"]; var hello = data["hello"]; return typeof String(name) === 'string' && 'Hello ' + world + '! ' + hello.split('').join('') + '.'; }
這樣的話,只須要在調用這個匿名函數的時候傳入對應的 data
便可得到咱們想要的結果了。如今回過頭來看以前的 Directive
部分代碼應該就一目瞭然了:
export default class Directive { constructor(options = {}) { // ... this.beforeUpdate && this.beforeUpdate(); this.update && this.update(generate(this.expression)(this.compile.data)); } }
generate(this.expression)(this.compile.data)
就是表達式通過 this.compile.data
計算後咱們所須要的值。
咱們前面只講瞭如何編譯 node.nodeType === 1
的元素節點,那麼文字節點如何編譯呢,其實理解了前面所講的內容話,文字節點的編譯就簡單得不能再簡單了:
/** * compile text node * * @param {Node} node */ Compile.compile.textNodes = function (node) { if (node.textContent.trim() === '') return false; this.bindDirective({ node, name: 'text', expression: parse.text(node.textContent), }); };
經過綁定 text
指令,並傳入解析後的 JavaScript 表達式,在 Directive
內部就會計算出表達式實際的值並調用 text
的 update
函數更新視圖完成渲染。
:each
指令到目前爲止,該模板引擎只實現了比較基本的功能,而最多見且重要的列表渲染功能尚未實現,因此咱們如今要實現一個 :each
指令來渲染一個列表,這裏可能要注意一下,不能按照前面兩個指令的思路來實現,應該換一個角度來思考,列表渲染其實至關於一個「子模板」,裏面的變量存在於 :each
指令所接收的 data
這個「局部做用域」中,這麼說可能抽象,直接上代碼:
// :each updater import Compile from 'path/to/compile.js'; export default { beforeUpdate() { this.placeholder = document.createComment(`:each`); this.node.parentNode.replaceChild(this.placeholder, this.node); }, update() { if (data && !Array.isArray(data)) return; const fragment = document.createDocumentFragment(); data.map((item, index) => { const compiled = Compile(this.node.cloneNode(true), { item, index, }); fragment.appendChild(compiled.view); }); this.placeholder.parentNode.replaceChild(fragment, this.placeholder); }, };
在 update
以前,咱們先把 :each
所在節點從 DOM 結構中去掉,可是要注意的是並不能直接去掉,而是要在去掉的位置插入一個 comment
類型的節點做爲佔位符,目的是爲了在咱們把列表數據渲染出來後,能找回原來的位置並把它插入到 DOM 中。
那具體如何編譯這個所謂的「子模板」呢,首先,咱們須要遍歷 :each
指令所接收的 Array
類型的數據(目前只支持該類型,固然你也能夠增長對 Object
類型的支持,原理是同樣的);其次,咱們針對該列表的每一項數據進行一次模板的編譯並把渲染後的模板插入到建立的 document fragment
中,當全部整個列表編譯完後再把剛剛建立的 comment
類型的佔位符替換爲 document fragment
以完成列表的渲染。
此時,咱們能夠這麼使用 :each
指令:
Compile(`<li :each="comments" data-index="{{ index }}">{{ item.content }}</li>`, { comments: [{ content: `Hello World.`, }, { content: `Just Awesome.`, }, { content: `WOW, Just WOW!`, }], });
會渲染成:
<li data-index="0">Hello World.</li> <li data-index="1">Just Awesome.</li> <li data-index="2">WOW, Just WOW!</li>
其實細心的話你會發現,模板中使用的 item
和 index
變量其實就是 :each
更新函數中 Compile(template, data)
編譯器裏的 data
值的兩個 key
值。因此要自定義這兩個變量也是很是簡單的:
// :each updater import Compile from 'path/to/compile.js'; export default { beforeUpdate() { this.placeholder = document.createComment(`:each`); this.node.parentNode.replaceChild(this.placeholder, this.node); // parse alias this.itemName = `item`; this.indexName = `index`; this.dataName = this.expression; if (this.expression.indexOf(' in ') != -1) { const bracketRE = /\(((?:.|\n)+?)\)/g; const [item, data] = this.expression.split(' in '); let matched = null; if (matched = bracketRE.exec(item)) { const [item, index] = matched[1].split(','); index ? this.indexName = index.trim() : ''; this.itemName = item.trim(); } else { this.itemName = item.trim(); } this.dataName = data.trim(); } this.expression = this.dataName; }, update() { if (data && !Array.isArray(data)) return; const fragment = document.createDocumentFragment(); data.map((item, index) => { const compiled = Compile(this.node.cloneNode(true), { [this.itemName]: item, [this.indexName]: index, }); fragment.appendChild(compiled.view); }); this.placeholder.parentNode.replaceChild(fragment, this.placeholder); }, };
這樣一來咱們就能夠經過 (aliasItem, aliasIndex) in items
來自定義 :each
指令的 item
和 index
變量了,原理就是在 beforeUpdate
的時候去解析 :each
指令的表達式,提取相關的變量名,而後上面的例子就能夠寫成這樣了:
Compile(`<li :each="(comment, index) in comments" data-index="{{ index }}">{{ comment.content }}</li>`, { comments: [{ content: `Hello World.`, }, { content: `Just Awesome.`, }, { content: `WOW, Just WOW!`, }], });
到這裏,其實一個比較簡單的模板引擎算是實現了,固然還有不少地方能夠完善的,好比能夠增長 :class
, :style
, :if
或 :src
等等你能夠想到的指令功能,添加這些功能都是很是的簡單的。
全篇介紹下來,整個核心無非就是遍歷整個模板的節點樹,其次針對每個節點的字符串值來解析成對應的表達式,而後經過 new Function()
這個構造函數來計算成實際的值,最終經過指令的 update
函數來更新到視圖上。
若是仍是不清楚這些指令如何編寫的話,能夠參考我這個項目 colon 的相關源碼(部分代碼可能會有不影響理解的細微差異,可忽略),有任何問題均可以在 issue 上提。
目前有一個侷限就是 DOM-based 的模板引擎只適用於瀏覽器端,目前筆者也正在實現兼容 Node 端的版本,思路是把字符串模板解析成 AST,而後把更新數據到 AST 上,最後再把 AST 轉成字符串模板,實現出來後有空的話再來介紹一下 Node 端的實現。
最後,若是上面有說得不對或者有更好的實現方式的話,歡迎指出討論。