下一代的模板引擎:lit-html
前面的文章介紹了 Web Components 的基本用法,今天來(lái)看看基于這個(gè)原生技術(shù),Google 二次封存的框架 lit-html。
其實(shí)早在 Google 提出 Web Components 的時(shí)候,就在此基礎(chǔ)上發(fā)布了 Polymer 框架。只是這個(gè)框架一直雷聲大雨點(diǎn)小,內(nèi)部似乎也對(duì)這個(gè)項(xiàng)目不太滿意,然后他們團(tuán)隊(duì)又開(kāi)發(fā)了兩個(gè)更加現(xiàn)代化的框架(或者說(shuō)是庫(kù)?):lit-html、lit-element,今天的文章會(huì)重點(diǎn)介紹 lit-html 的用法以及優(yōu)勢(shì)。
發(fā)展歷程
在講到 lit-html 之前,我們先看看前端通過(guò) JavaScript 操作頁(yè)面,經(jīng)歷過(guò)的幾個(gè)階段:
發(fā)展階段
原生 DOM API
最早通過(guò) DOM API 操作頁(yè)面元素,操作步驟較為繁瑣,而且 JS 引擎與瀏覽器 DOM 對(duì)象的通信相對(duì)耗時(shí),頻繁的 DOM 操作對(duì)瀏覽器性能影響較大。
- var $box = document.getElementById('box')
- var $head = document.createElement('h1')
- var $content = document.createElement('div')
- $head.innerText = '關(guān)注我的公眾號(hào)'
- $content.innerText = '打開(kāi)微信搜索:『自然醒的筆記本』'
- $box.append($head)
- $box.append($content)
jQuery 操作 DOM
jQuery 的出現(xiàn),讓 DOM 操作更加便捷,內(nèi)部還做了很多跨瀏覽器的兼容性處理,極大的提升了開(kāi)發(fā)體驗(yàn),并且還擁有豐富的插件體系和詳細(xì)的文檔。
- var $box = $('#box')
- var $head = $('<h1/>', { text: '關(guān)注我的公眾號(hào)' })
- var $content = $('<div/>', { text: '打開(kāi)微信搜索:『自然醒的筆記本』' })
- $box.append($head, $content)
雖然提供了便捷的操作,由于其內(nèi)部有很多兼容性代碼,在性能上就大打折扣了。而且它的鏈?zhǔn)秸{(diào)用,讓開(kāi)發(fā)者寫(xiě)出的面條式代碼也經(jīng)常讓人詬病(PS. 個(gè)人認(rèn)為這也不能算缺點(diǎn),只是有些人看不慣罷了)。
模板操作
『模板引擎』最早是后端 MVC 框架的 View 層,用來(lái)拼接生成 HTML 代碼用的。比如,mustache 是一個(gè)可以用于多個(gè)語(yǔ)言的一套模板引擎。
mustache
后來(lái)前端框架也開(kāi)始搗鼓 MVC 模式,漸漸的前端也開(kāi)始引入了模板的概念,讓操作頁(yè)面元素變得更加順手。下面的案例,是 angluar.js 中通過(guò)指令來(lái)使用模板:
- var app = angular.module("box", []);
- app.directive("myMessage", function (){
- return {
- template : '' +
- '<h1>關(guān)注我的公眾號(hào)</h1>' +
- '<div>打開(kāi)微信搜索:『自然醒的筆記本』</div>'
- }
- })
后來(lái)的 Vue 更是將模板與虛擬 DOM 進(jìn)行了結(jié)合,更進(jìn)一步的提升了 Vue 中模板的性能,但是模板也有其缺陷存在。
- 不管是什么模板引擎,在啟動(dòng)時(shí),解析模板是需要花時(shí)間,這是沒(méi)有辦法避免的;
- 連接模板與 JavaScript 的數(shù)據(jù)比較麻煩,而且在數(shù)據(jù)更新時(shí)還需進(jìn)行模板的更新;
- 各式各樣的模板創(chuàng)造了自己的語(yǔ)法結(jié)構(gòu),使用不同的模板引擎,就需要重新學(xué)習(xí)一遍其語(yǔ)法糖,這對(duì)開(kāi)發(fā)體驗(yàn)不是很友好;
JSX
GitHub - OpenJSX/logo: Logo of JSX-IR
React 在官方文檔中這樣介紹 JSX:
“JSX,是一個(gè) JavaScript 的語(yǔ)法擴(kuò)展。我們建議在 React 中配合使用 JSX,JSX 可以很好地描述 UI 應(yīng)該呈現(xiàn)出它應(yīng)有交互的本質(zhì)形式。JSX 可能會(huì)使人聯(lián)想到模板語(yǔ)言,但它具有 JavaScript 的全部功能。
- var title = '關(guān)注我的公眾號(hào)'
- var content = '打開(kāi)微信搜索:『自然醒的筆記本』'
- const element = <div>
- <h1>{title}</h1>
- <div>{content}</div>
- </div>;
- ReactDOM.render(
- element,
- document.getElementById('root')
- )
JSX 的出現(xiàn),給前端的開(kāi)發(fā)模式帶來(lái)更大的想象空間,更是引入了函數(shù)式編程的思想。
- UI = fn(state)
但是這也帶來(lái)了一個(gè)問(wèn)題,JSX 語(yǔ)法必須經(jīng)過(guò)轉(zhuǎn)義,將其處理成 React.createElement 的形式,這也提高了 React 的上手難度,很多新手望而卻步。
lit-html 介紹
lit-html 的出現(xiàn)就盡可能的規(guī)避了之前模板引擎的問(wèn)題,通過(guò)現(xiàn)代瀏覽器原生的能力來(lái)構(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)注我的公眾號(hào)', '打開(kāi)微信搜索:『自然醒的筆記本』'),
- document.body
- );
模板語(yǔ)法
由于使用了原生的模板字符,可以無(wú)需轉(zhuǎn)義,直接進(jìn)行使用,而且和 JSX 一樣也能使用 JavaScript 語(yǔ)法進(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
- );
除了這種寫(xiě)法上的便利,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
- );
樣式的綁定
除了使用原生模板字符串編寫(xiě)模板外,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 的主要作用就是將模板渲染到頁(yè)面上,相比起 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);
通過(guò)前面的案例也能看出,lit-html 對(duì)外常用的兩個(gè) api 是 html 和 render。
構(gòu)造模板
html 是一個(gè)標(biāo)簽函數(shù),屬于 ES6 新增語(yǔ)法,如果不記得標(biāo)簽函數(shù)的用法,可以打開(kāi) 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á)式。我們可以寫(xiě)一個(gè)案例,看看傳入的 html 標(biāo)簽函數(shù)的參數(shù)到底長(zhǎng)什么樣:
- 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)過(guò)計(jì)算后得到的值會(huì)做為后面的參數(shù)一次傳入,我們可以將 strings 和 values 打印出來(lái)看看:
log
lit-html 會(huì)將這兩個(gè)參數(shù)傳入 TemplateResult 中,進(jìn)行實(shí)例化操作。
- export const html = (strings, ...values) => {
- return new TemplateResult(strings, values);
- };
- 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é)果如下。
渲染到頁(yè)面
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)之前對(duì)應(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)造過(guò)的 part 緩存。可以將 part 理解為一個(gè)節(jié)點(diǎn)的構(gòu)造器,用來(lái)將 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());
- }
- }
然后通過(guò) 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) {
- // 通過(guò) 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)建過(guò)一次模板,則進(jìn)行更新
- if (this.value.template === template) {
- // console.log('更新DOM', value)
- this.value.update(value.values);
- } else {
- // 通過(guò)模板節(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),插入到頁(yè)面
- this.__commitNode(fragment);
- // 模板實(shí)例放入 value 屬性進(jìn)行緩存,用于后續(xù)判斷是否是更新操作
- this.value = instance;
- }
- }
- }
實(shí)例化之后的模板,首先會(huì)調(diào)用 instance._clone() 進(jìn)行一次拷貝操作,然后通過(guò) 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 并沒(méi)有類似 Vue、React 那種將模板或 JSX 構(gòu)造成虛擬 DOM 的流程,只提供了一個(gè)輕量的 html 標(biāo)簽方法,將模板字符轉(zhuǎn)化為 TemplateResult,然后用注釋節(jié)點(diǎn)去填充動(dòng)態(tài)的位置。TemplateResult 最終也是通過(guò)創(chuàng)建 標(biāo)簽,然后通過(guò)瀏覽器內(nèi)置的 innerHTML 進(jìn)行模板解析的,這個(gè)過(guò)程也是十分輕量,相當(dāng)于能交給瀏覽器的部分全部交給瀏覽器來(lái)完成,包括模板創(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è)高效的模板引擎,如果要用來(lái)編寫(xiě)業(yè)務(wù)代碼還缺少了類似 Vue、React 提供的生命周期、數(shù)據(jù)綁定等能力。為了完成這部分的能力,Polymer 項(xiàng)目組還提供了另一個(gè)框架:lit-element,可以用來(lái)創(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) 的框架,一共也就寫(xiě)了 70 行代碼,感興趣可以看看。