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