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