下一代的模板引擎:lit-html
前面的文章介紹了 Web Components 的基本用法,今天來看看基于這個(gè)原生技術(shù),Google 二次封存的框架 lit-html。
其實(shí)早在 Google 提出 Web Components 的時(shí)候,就在此基礎(chǔ)上發(fā)布了 Polymer 框架。只是這個(gè)框架一直雷聲大雨點(diǎn)小,內(nèi)部似乎也對這個(gè)項(xiàng)目不太滿意,然后他們團(tuán)隊(duì)又開發(fā)了兩個(gè)更加現(xiàn)代化的框架(或者說是庫?):lit-html、lit-element,今天的文章會(huì)重點(diǎn)介紹 ?lit-html 的用法以及優(yōu)勢。

發(fā)展歷程
在講到 lit-html 之前,我們先看看前端通過 JavaScript 操作頁面,經(jīng)歷過的幾個(gè)階段:
發(fā)展階段原生 DOM API
最早通過 DOM API 操作頁面元素,操作步驟較為繁瑣,而且 JS 引擎與瀏覽器 DOM 對象的通信相對耗時(shí),頻繁的 DOM 操作對瀏覽器性能影響較大。
var?$box?=?document.getElementById('box')
var?$head?=?document.createElement('h1')
var?$content?=?document.createElement('div')
$head.innerText?=?'關(guān)注我的公眾號'
$content.innerText?=?'打開微信搜索:『自然醒的筆記本』'
$box.append($head)
$box.append($content)

jQuery 操作 DOM
jQuery 的出現(xiàn),讓 DOM 操作更加便捷,內(nèi)部還做了很多跨瀏覽器的兼容性處理,極大的提升了開發(fā)體驗(yàn),并且還擁有豐富的插件體系和詳細(xì)的文檔。

var?$box?=?$('#box')
var?$head?=?$('<h1/>',?{?text:?'關(guān)注我的公眾號'?})
var?$content?=?$('<div/>',?{?text:?'打開微信搜索:『自然醒的筆記本』'?})
$box.append($head,?$content)

雖然提供了便捷的操作,由于其內(nèi)部有很多兼容性代碼,在性能上就大打折扣了。而且它的鏈?zhǔn)秸{(diào)用,讓開發(fā)者寫出的面條式代碼也經(jīng)常讓人詬?。≒S. 個(gè)人認(rèn)為這也不能算缺點(diǎn),只是有些人看不慣罷了)。
模板操作
『模板引擎』最早是后端 MVC 框架的 View 層,用來拼接生成 HTML 代碼用的。比如,mustache 是一個(gè)可以用于多個(gè)語言的一套模板引擎。
mustache后來前端框架也開始搗鼓 MVC 模式,漸漸的前端也開始引入了模板的概念,讓操作頁面元素變得更加順手。下面的案例,是 angluar.js 中通過指令來使用模板:
var?app?=?angular.module("box",?[]);
app.directive("myMessage",?function?(){
??return?{
????template?:?''?+
????'<h1>關(guān)注我的公眾號</h1>'?+
????'<div>打開微信搜索:『自然醒的筆記本』</div>'
??}
})

后來的 Vue 更是將模板與虛擬 DOM 進(jìn)行了結(jié)合,更進(jìn)一步的提升了 Vue 中模板的性能,但是模板也有其缺陷存在。
- 不管是什么模板引擎,在啟動(dòng)時(shí),解析模板是需要花時(shí)間,這是沒有辦法避免的;
- 連接模板與 JavaScript 的數(shù)據(jù)比較麻煩,而且在數(shù)據(jù)更新時(shí)還需進(jìn)行模板的更新;
- 各式各樣的模板創(chuàng)造了自己的語法結(jié)構(gòu),使用不同的模板引擎,就需要重新學(xué)習(xí)一遍其語法糖,這對開發(fā)體驗(yàn)不是很友好;
JSX
GitHub - OpenJSX/logo: Logo of JSX-IRReact 在官方文檔中這樣介紹 JSX:
“JSX,是一個(gè) JavaScript 的語法擴(kuò)展。我們建議在 React 中配合使用 JSX,JSX 可以很好地描述 UI 應(yīng)該呈現(xiàn)出它應(yīng)有交互的本質(zhì)形式。JSX 可能會(huì)使人聯(lián)想到模板語言,但它具有 JavaScript 的全部功能。
var?title?=?'關(guān)注我的公眾號'
var?content?=?'打開微信搜索:『自然醒的筆記本』'
const?element?=?<div>
??<h1>{title}</h1>
??<div>{content}</div>
</div>;
ReactDOM.render(
??element,
??document.getElementById('root')
)

JSX 的出現(xiàn),給前端的開發(fā)模式帶來更大的想象空間,更是引入了函數(shù)式編程的思想。
UI?=?fn(state)
但是這也帶來了一個(gè)問題,JSX 語法必須經(jīng)過轉(zhuǎn)義,將其處理成 React.createElement 的形式,這也提高了 React 的上手難度,很多新手望而卻步。
lit-html 介紹
lit-html 的出現(xiàn)就盡可能的規(guī)避了之前模板引擎的問題,通過現(xiàn)代瀏覽器原生的能力來構(gòu)建模板。
- ES6 提供的模板字面量;
- Web Components 提供的
<template>標(biāo)簽;
//?Import?lit-html
import?{html,?render}?from?'lit-html';
//?Define?a?template
const?template?=?(title,?content)?=>?html`
??<h1>${title}</h1>
??<div>${content}</div>
`;
//?Render?the?template?to?the?document
render(
??template('關(guān)注我的公眾號',?'打開微信搜索:『自然醒的筆記本』'),
??document.body
);

模板語法
由于使用了原生的模板字符,可以無需轉(zhuǎn)義,直接進(jìn)行使用,而且和 JSX 一樣也能使用 JavaScript 語法進(jìn)行遍歷和邏輯控制。
const?skillTpl?=?(title,?skills)?=>?html`
??<h2>${title?||?'技能列表'?}</h2>
??<ul>
????${skills.map(i?=>?html`<li>${i}</li>`)}
??</ul>
`;
render(
??skillTpl('我的技能',?['Vue',?'React',?'Angluar']),
??document.body
);

除了這種寫法上的便利,lit-html 內(nèi)部也提供了Vue 類似的事件綁定方式。
const?Input?=?(defaultValue)?=>?html`
??name:?<input?value=${defaultValue}?@input=${(evt)?=>?{
????console.log(evt.target.value)
??}}?/>
`;
render(
??Input('input?your?name'),
??document.body
);

樣式的綁定
除了使用原生模板字符串編寫模板外,lit-html 天生自帶的 CSS-in-JS 的能力。
import?{html,?render}?from?'lit-html';
import?{styleMap}?from?'lit-html/directives/style-map.js';
const?skillTpl?=?(title,?skills,?highlight)?=>?{
?const?styles?=?{
???backgroundColor:?highlight???'yellow'?:?'',
?};
?return?html`
???<h2>${title?||?'技能列表'?}</h2>
???<ul?style=${styleMap(styles)}>
?????${skills.map(i?=>?html`<li>${i}</li>`)}
???</ul>
?`
};
render(
?skillTpl('我的技能',?['Vue',?'React',?'Angluar'],?true),
?document.body
);

渲染流程
做為一個(gè)模板引擎,lit-html 的主要作用就是將模板渲染到頁面上,相比起 React、Vue 等框架,它更加專注于渲染,下面我們看看 lit-html 的基本工作流程。
//?Import?lit-html
import?{?html,?render?}?from?'lit-html';
//?Define?a?template
const?myTemplate?=?(name)?=>?html`<p>Hello?${name}</p>`;
//?Render?the?template?to?the?document
render(myTemplate('World'),?document.body);
通過前面的案例也能看出,lit-html 對外常用的兩個(gè) api 是 html 和 render。
構(gòu)造模板
html 是一個(gè)標(biāo)簽函數(shù),屬于 ES6 新增語法,如果不記得標(biāo)簽函數(shù)的用法,可以打開 Mozilla 的文檔(https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Template_literals)復(fù)習(xí)下。
export?const?html?=?(strings,?...values)?=>?{
??……
};
html 標(biāo)簽函數(shù)會(huì)接受多個(gè)參數(shù),第一個(gè)參數(shù)為靜態(tài)字符串組成的數(shù)組,后面的參數(shù)為動(dòng)態(tài)傳入的表達(dá)式。我們可以寫一個(gè)案例,看看傳入的 html 標(biāo)簽函數(shù)的參數(shù)到底長什么樣:
const?foo?=?'吳彥祖';
const?bar?=?'梁朝偉';
html`<p>Hello?${foo},?I'm?${bar}</p>`;

整個(gè)字符串會(huì)被動(dòng)態(tài)的表達(dá)式進(jìn)行切割成三部分,這個(gè)三個(gè)部分會(huì)組成一個(gè)數(shù)組,做為第一個(gè)參數(shù)傳入 html 標(biāo)簽函數(shù),而動(dòng)態(tài)的表達(dá)式經(jīng)過計(jì)算后得到的值會(huì)做為后面的參數(shù)一次傳入,我們可以將 strings 和 values 打印出來看看:
loglit-html 會(huì)將這兩個(gè)參數(shù)傳入 TemplateResult 中,進(jìn)行實(shí)例化操作。
export?const?html?=?(strings,?...values)?=>?{
??return?new?TemplateResult(strings,?values);
};
//?生成一個(gè)隨機(jī)字符
const?marker?=?`{{lit-${String(Math.random()).slice(2)}}}`;
const?nodeMarker?=?`<!--${marker}-->`;
export?class?TemplateResult?{
?constructor(strings,?values)?{
??this.strings?=?strings;
??this.values?=?values;
?}
?getHTML()?{
??const?l?=?this.strings.length?-?1;
??let?html?=?'';
??let?isCommentBinding?=?false;
??for?(let?i?=?0;?i?<?l;?i++)?{
???const?s?=?this.strings[i];
???html?+=?s?+?nodeMarker;
??}
??html?+=?this.strings[l];
??return?html;
?}
?getTemplateElement()?{
??const?template?=?document.createElement('template');
??let?value?=?this.getHTML();
??template.innerHTML?=?value;
??return?template;
?}
}
實(shí)例化的 ?TemplateResult 會(huì)提供一個(gè) getTemplateElement 方法,該方法會(huì)創(chuàng)建一個(gè) template 標(biāo)簽,然后會(huì)將 getHTML 的值傳入 template 標(biāo)簽的 innerHTML 中。而 getHTML 方法的作用,就是在之前傳入的靜態(tài)字符串中間插入 HTML 注釋。前面的案例中,如果調(diào)用 getHTML 得到的結(jié)果如下。

渲染到頁面
render 方法會(huì)接受兩個(gè)參數(shù),第一個(gè)參數(shù)為 html 標(biāo)簽函數(shù)返回的 TemplateResult,第二個(gè)參數(shù)為一個(gè)真實(shí)的 DOM 節(jié)點(diǎn)。
export?const?parts?=?new?WeakMap();
export?const?render?=?(result,?container)?=>?{
??//?先獲取DOM節(jié)點(diǎn)之前對應(yīng)的緩存
??let?part?=?parts.get(container);
??//?如果不存在緩存,則重新創(chuàng)建
??if?(part?===?undefined)?{
????part?=?new?NodePart()
????parts.set(container,?part);
????part.appendInto(container);
??}
??//?將?TemplateResult?設(shè)置到?part?中
??part.setValue(result);
??//?調(diào)用?commit?進(jìn)行節(jié)點(diǎn)的創(chuàng)建或更新
??part.commit();
};
render 階段會(huì)先到 parts 里面查找之前構(gòu)造過的 part 緩存??梢詫?part 理解為一個(gè)節(jié)點(diǎn)的構(gòu)造器,用來將 template 的內(nèi)容渲染到真實(shí)的 DOM 節(jié)點(diǎn)中。
如果 part 緩存不存在,會(huì)先構(gòu)造一個(gè),然后調(diào)用 appendInto 方法,該方法會(huì)在 DOM 節(jié)點(diǎn)的前后插入兩個(gè)注釋節(jié)點(diǎn),用于后續(xù)插入模板。
const?createMarker?=?()?=>?document.createComment('');
export?class?NodePart?{
??appendInto(container)?{
????this.startNode?=?container.appendChild(createMarker());
????this.endNode?=?container.appendChild(createMarker());
??}
}

然后通過 commit 方法創(chuàng)建真實(shí)的節(jié)點(diǎn),并插入到兩個(gè)注釋節(jié)點(diǎn)中。下面我們看看 commit 方法的具體操作:
export?class?NodePart?{
??setValue(result)?{
????//?將?templateResult?放入?__pendingValue?屬性中
????this.__pendingValue?=?result;
??}
??commit()?{
????const?value?=?this.__pendingValue;
????//?依據(jù)?value?的不同類型進(jìn)行不同的操作
????if?(value?instanceof?TemplateResult)?{
??????//?通過?html?標(biāo)簽方法得到的?value
??????//?肯定是?TemplateResult?類型的
??????this.__commitTemplateResult(value);
????}?else?{
??????this.__commitText(value);
????}
??}
??__commitTemplateResult(value)?{
????//?調(diào)用?templateFactory?構(gòu)造模板節(jié)點(diǎn)
????const?template?=?templateFactory(value);
????//?如果之前已經(jīng)構(gòu)建過一次模板,則進(jìn)行更新
????if?(this.value.template?===?template)?{
??????//?console.log('更新DOM',?value)
??????this.value.update(value.values);
????}?else?{
??????//?通過模板節(jié)點(diǎn)構(gòu)造模板實(shí)例
??????const?instance?=?new?TemplateInstance(template);
??????//?將?templateResult?中的?values?更新到模板實(shí)例中
???const?fragment?=?instance._clone();
??????instance.update(value.values);
??????//?拷貝模板中的?DOM?節(jié)點(diǎn),插入到頁面
??????this.__commitNode(fragment);
??????//?模板實(shí)例放入?value?屬性進(jìn)行緩存,用于后續(xù)判斷是否是更新操作
??????this.value?=?instance;
????}
??}
}
實(shí)例化之后的模板,首先會(huì)調(diào)用 instance._clone() 進(jìn)行一次拷貝操作,然后通過 instance.update(value.values) 將計(jì)算后的動(dòng)態(tài)表達(dá)式插入其中。

最后調(diào)用 __commitNode 將拷貝模板得到的節(jié)點(diǎn)插入真實(shí)的 DOM 中。
export?class?NodePart?{
??__insert(node)?{
????this.endNode.parentNode.insertBefore(node,?this.endNode);
??}
??__commitNode(value)?{
????this.__insert(value);
????this.value?=?value;
??}
}

可以看到 lit-html 并沒有類似 Vue、React 那種將模板或 JSX 構(gòu)造成虛擬 DOM 的流程,只提供了一個(gè)輕量的 html 標(biāo)簽方法,將模板字符轉(zhuǎn)化為 TemplateResult,然后用注釋節(jié)點(diǎn)去填充動(dòng)態(tài)的位置。TemplateResult 最終也是通過創(chuàng)建 <template> 標(biāo)簽,然后通過瀏覽器內(nèi)置的 innerHTML 進(jìn)行模板解析的,這個(gè)過程也是十分輕量,相當(dāng)于能交給瀏覽器的部分全部交給瀏覽器來完成,包括模板創(chuàng)建完后的節(jié)點(diǎn)拷貝操作。
export?class?TemplateInstance?{
??_clone()?{
????const?{?element?}?=?this.template;
????const?fragment?=?document.importNode(element.content,?true);
????//?省略部分操作……
????return?fragment;
??}
}
其他
lit-html 只是一個(gè)高效的模板引擎,如果要用來編寫業(yè)務(wù)代碼還缺少了類似 Vue、React 提供的生命周期、數(shù)據(jù)綁定等能力。為了完成這部分的能力,Polymer 項(xiàng)目組還提供了另一個(gè)框架:lit-element,可以用來創(chuàng)建 WebComponents。
除了官方的 lit-element 框架,Vue 的作者還將 Vue 的響應(yīng)式部分剝離,與 lit-html 進(jìn)行了結(jié)合,創(chuàng)建了一個(gè) vue-lit(https://github.com/yyx990803/vue-lit) 的框架,一共也就寫了 70 行代碼,感興趣可以看看。

