文章較長,耐心觀看。
現在再開發一套UI框架似乎已經錯過了最佳創業時期,畢竟網上優秀的框架一大堆,輕量級的,重量級的,有依賴的,無依賴的,拿來即用的,需要配置的,應有盡有。但是老衲我找遍整個外網發現唯獨沒有利用Web Component標準庫實現的前端框架,要知道組件化可是Vue,React和Angular的招牌賣點之一,如今Web Component標準庫可以完美提供原生的組件化開發模式,這直接意味着前端框架市場仍然有風口,而我選擇使用Web組件標準庫來開發UI框架的最大賣點是:安全。
UI安全
上圖是Google工程師Eric Bidelman對UI安全的描述,他提到現今web app中成千上萬行的JavaScript代碼連接了各式各樣的html和css文件,但缺乏正規的組織形式,變得亂七八糟。類似的話在cn.Vuejs.org官網也說過。
我們常常重視數據安全而忽視了UI的安全。UI上的不安全主要來自2個方面,一個是各種框架同時使用造成的衝突:它們對全局變量的爭奪,對html結構和class選擇器的濫用經常會導致整個UI結構臃腫不堪,難以維護,很多前端框架都依賴於古老的html-css-js結構,這樣框架一多根本沒人敢接手,當然,模塊化開發就是爲了解決這個問題的。第二個不安全的因素是組件內部的過度暴露導致的系統紊亂:組件內部的邏輯結構可以被外界輕易修改是一個安全隱患,這也是Web Component要解決的問題。
不是,組件內部暴露出來不是可以提高自由度,可定製化嗎?
更多情況下,組件封裝是爲了防止你“不小心”篡改了內部信息,比如你能保證你自定義的outerHTML不會被別人的全局CSS作用到嗎?你不怕接手的一個項目中,原作者圖省事覆蓋了框架原來的一些屬性,然後你要排查半天嗎?以上這些都是過度自由的惡果,爲此,適當的封裝和隔離是必須的,組件對自身的保護是爲了規範用戶的操作。
然而,以Vue爲代表的前端框架在“組件保護”上做的一塌糊塗:用戶可以在不同的地方輕易修改別人的組件而沒有任何限制和約束,一旦出了bug就沒法精確定位責任人。
影子DOM:子樹隔離
影子DOM是Web組件的核心功能,便於理解可以叫它子DOM或者子樹。有了子樹就實現了一定程度的封裝,至少外面的CSS樣式進不來了,下圖是本文使用的例子。
但注意有一些默認樣式找不到的時候會繼承外界的樣式。此外,從外面用css選擇器也找不到:
document.querySelector("p") // null
HTMLElement.prototype.attachShadow這個方法有closed和open兩種模式,其實區別只有一個,就是open模式會在元素身上掛載一個shadowRoot的引用,方便隨時使用子樹,closed模式就不掛。。這種看似雞肋(因爲可以自己掛載)的模式區分其實暗示了2種不同的設計思想:組件的對外開放和閉關鎖國。爲了安全,給我老老實實的用closed模式。
因爲shadowRoot引用可以被組件外面的代碼調用,顯然是不安全的:
const $myWebComponent = document.querySelector("my-web-component");
$myWebComponent.shadowRoot.querySelector("p").innerText = "Modified!";
當然,所有安全都是相對的,在closed模式下掛載一個自定義的key來引用shadowRoot是一個稍微聰明點的實踐,像下面這樣在元素身上掛一個“_root”其他人應該猜不到(吧)。
class MyWebComponent extends HTMLElement {
constructor() {
super();
this._root = this.attachShadow({ mode: "closed" });
}
connectedCallback() {
this._root.innerHTML = `
<p>I'm in the closed Shadow Root!</p>
` ;
}
}
window.customElements.define("my-web-component", MyWebComponent);
但仍然不夠安全,因爲其他同事查查源碼就知道你藏在哪了,而且萬一“_root”衝突了同名屬性怎麼辦?
怎麼辦?當然用Symbol啊,Symbol就是專門解決key衝突問題的,可以隨時隨地的用Symbol()來創建一個全局唯一的uuid(Symbol函數本質是一個自加器)。這樣在自定義元素身上掛一個用symbol值來引用的shadowRoot,只要symbol值不要暴露,元素就沒辦法找到這個引用,就像一個人沒法伸手夠到自己的後背一樣難受(🤭)。
現在的問題是,symbol值藏在哪?由於這個uuid對於每個customElements是唯一的,放在構造函數身上不合適,因爲原型函數也需要使用,掛在任何一個原型函數上也不合適,掛在元素自身更不合適,咋整呢?老衲微微一笑:咋們有閉包啊。
閉包+Symbol:完美組合
我一直認爲秒殺面試官的訣竅是能夠用自己獨特的理解來定義任何一個名詞,比如我對js閉包的定義是:閉包是一個語法糖,在函數嵌套定義的語法環境下,父函數的環境對象(變量對象)會掛到子函數的作用域鏈上,這樣即使父函數消逝,只要子函數存在,作用域鏈和父級環境對象就不會被回收。
不過閉包還有一個更棒的好處:閉包函數的環境對象引用自自身的 [[Environment]]屬性下,這個對象從函數體外無法訪問。可以利用這種隔離來存放我們的symbol。
(()=>{
const shadowId = Symbol();
class MyWebComponent extends HTMLElement {
constructor() {
super();
this[shadowId] = this.attachShadow({ mode: "closed" });
}
connectedCallback() {
this[shadowId].innerHTML = `
<p>I'm in the closed Shadow Root!</p> `;
}
}
window.customElements.define("my-web-component", MyWebComponent);
})();
當然防不勝防,用戶甚至可以覆蓋Element.prototype.attachShadow函數(🙂),即使不能覆蓋也可以修改源碼(🙂🙂)。。不過有了closed模式結合閉包和Symbol足夠來打造屬於我自己的安全組件庫了!以上都是鋪墊,下面是精彩部分。
打造一套屬於自己的UI組件庫:UISec
這個項目名字還是比較隨意的(logo也是在藝術字庫中擼來的☺️),UISec(UI Security)模仿IPSec以及HTTPS的命名方式,所有的自定義元素就以“uis-”開頭,比如<uis-button>,準備主打一套以安全UI爲招牌而不是以美觀和易用爲噱頭的產品。項目還是以學習爲主,沒有任何商業的成分,地址暫時定在:https://github.com/JinHengyu/UISec。從長遠發展的眼光,爲了實現完美的UISec,需要制定一系列基本準則。
-
準則一:用戶與組件的責任分離
上述所有的安全措施都防止了外界對組件內部的入侵,但想要開發一套安全組件庫,還需要阻止內部對外部的惡意輸出,爲此我制定了一套用戶和組件的責任分割線:
對用戶來說,用戶可以修改組件(自定義element)在3維空間的座標(x,y,z),如果對應到css暴力定位中,x由left控制,y由top控制,z則由z-index控制,總之對於組件的位移不會影響組件的內容。此外,用戶還可以控制組件的容器大小,即控制組件的widht和height,容器大小決定了組件可以自由發揮的空間。用戶還決定了組件的生和死,即組件的創建和銷燬。
而組件自身能夠掌握的主動權力的只有修改自身內容,充其量包括自我銷燬的權利,不得干預自己在dom中的位置(x,y,z)和自身的尺寸(width,height)。
-
準則二:提供覆蓋內部CSS樣式的接口
除了主動權力,組件的被動權力則包括對外提供的接口,接口可以是setter和getter用來修改內部的數據,更多的時候用戶希望能夠定製內部的樣式,常見的UI插件喜歡提供格式各樣的樣式套餐,比如下圖是element-ui插件提供的各式各樣的按鈕:
但是無論你搭配多少套餐總是不可能滿足所有用戶的需求,萬一用戶想要一個會閃爍的按鈕怎麼辦?不如提供一個可以覆蓋內部css樣式的接口讓用戶可以完全定製,從根源上解決極端需求:
<!-- 放在其他style元素之後以達到覆蓋的目的 -->
<style id="customStyle"> </style>
get customStyle() {
return this[shadowId].querySelector('style#customStyle').innerHTML;
}
set customStyle(newStyle) {
this[shadowId].querySelector('style#customStyle').innerHTML = newStyle;
}
用戶只要稍微看一下shadowRoot內部的html大致結構就可以自由地覆蓋內部的css。但組件的設計仍然要以上手即用的樣式套餐爲主,以customStyle爲輔。
-
準則三:提供快捷方式
這樣一來,組件的權力似乎太小了,很多時候用戶希望組件可以和外部互動,比如對話框組件的按鈕希望能傳回調函數,將一個新Promise的resolve函數賦值給按鈕的oncilck以便封裝成一個異步模塊,然後由於對話框需要被append到body下面,fixed成窗口級的元素後才能正常使用。但是根據之前的2個準則,組件本身沒有這些操作的權限,只能用戶來操作,這樣不免有些繁瑣,不如我們在組件的構造函數上封裝一個這樣能夠快速生成對話框的工具類方法,提供一種快捷方式給用戶可以開箱即用:
await customElements.get('uis-modal').makeAlert({
title: '提示',
content: '雲端已更新',
});
console.log('alert對話框已關閉');
-
準則四:記得歸還其他線程上欠下的外債
組件的銷燬很簡單,但JS裏面沒有“銷燬對象”的說法,只有斷開對象的所有引用,所以銷燬一個組件通常只要斷開dom樹對該元素的引用即可,達到這個目的至少有remove,removeChild,replaceWith,replaceChild四個api可用。斷開的時候會觸發元素自身的disconnectedCallback回調。
組件銷燬有時候不夠乾淨,因爲組件有可能在使用期間留在其他線程上一些殘跡,這些殘跡並不會在組件銷燬後也隨之銷燬。要知道瀏覽器是多線程的,比如在計時器線程上你需要clearInterval或者clearTimeout掉組件觸發的計時任務,在事件監聽線程上你需要removeEventListener掉某個元素上的事件,避免資源的浪費,如果有這些外債,需要在這disconnectedCallback裏完成些操作。
-
準則五:將數據放在相關的組件下
我以前喜歡把數據(包括數據和函數)掛在相關的dom元素之下,而不是window對象,這樣子想要尋找和某個dom元素有關的數據非常方便。比如一個圖片輪播的組件就可以把所需的圖片列表掛到組件之下,不用掛到window對象之下了。
這種設計模式來源於Vue等框架,雖說數據與UI是分離的,但許多情況下對於一個功能,是可以將相關的數據和相關的UI組件放在一塊兒。比如“輪播”這個功能可以將輪播窗口和圖片列表綁定在一起,找起來也方便。
-
準則六:嵌入式wiki
有時候一個框架的官網打不開就會很着急,沒有文檔寸步難行。爲何不把一些簡短的文檔直接存在組件的構造函數中呢?通過wiki函數將一些關鍵信息打印在console中或者其他地方,比如下面這樣:
static get wiki() {
console.table({
version: { info: '0.1' },
description: { info: '這是一個modal組件' },
makeAlert: {
info: '創建alert的快捷方式',
params: 'title: string, content: string',
return: 'Promise'
},
'...': { info: '...' },
});
}
暫時想到這6個UISec的基本準則,以後有新的再補充,或許UISec在這個嚴格的基礎上會發展起來成爲小衆軟件,或許UISec項目永遠不會真正流行。那都無所謂,至少從使用框架到有勇氣設計框架,我們走出了一大步。
參考資料
-
https://developers.google.com/web/fundamentals/web-components/shadowdom
-
https://blog.revillweb.com/open-vs-closed-shadow-dom-9f3d7427d1af
-
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Closures
(完)
【日記】
分享一個好玩的“鍵盤字符畫”:
/***
* ┌───┐ ┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐ ┌───┬───┬───┐
* │Esc│ │ F1│ F2│ F3│ F4│ │ F5│ F6│ F7│ F8│ │ F9│F10│F11│F12│ │P/S│S L│P/B│ ┌┐ ┌┐ ┌┐
* └───┘ └───┴───┴───┴───┘ └───┴───┴───┴───┘ └───┴───┴───┴───┘ └───┴───┴───┘ └┘ └┘ └┘
* ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───────┐ ┌───┬───┬───┐ ┌───┬───┬───┬───┐
* │~ `│! 1│@ 2│# 3│$ 4│% 5│^ 6│& 7│* 8│( 9│) 0│_ -│+ =│ BacSp │ │Ins│Hom│PUp│ │N L│ / │ * │ - │
* ├───┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─────┤ ├───┼───┼───┤ ├───┼───┼───┼───┤
* │ Tab │ Q │ W │ E │ R │ T │ Y │ U │ I │ O │ P │{ [│} ]│ | \ │ │Del│End│PDn│ │ 7 │ 8 │ 9 │ │
* ├─────┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴┬──┴─────┤ └───┴───┴───┘ ├───┼───┼───┤ + │
* │ Caps │ A │ S │ D │ F │ G │ H │ J │ K │ L │: ;│" '│ Enter │ │ 4 │ 5 │ 6 │ │
* ├──────┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴─┬─┴────────┤ ┌───┐ ├───┼───┼───┼───┤
* │ Shift │ Z │ X │ C │ V │ B │ N │ M │< ,│> .│? /│ Shift │ │ ↑ │ │ 1 │ 2 │ 3 │ │
* ├─────┬──┴─┬─┴──┬┴───┴───┴───┴───┴───┴──┬┴───┼───┴┬────┬────┤ ┌───┼───┼───┐ ├───┴───┼───┤ E││
* │ Ctrl│ │Alt │ Space │ Alt│ │ │Ctrl│ │ ← │ ↓ │ → │ │ 0 │ . │←─┘│
* └─────┴────┴────┴───────────────────────┴────┴────┴────┴────┘ └───┴───┴───┘ └───────┴───┴───┘
*/