拋開 Vue、React、JQuery 這類第三方js,我們該怎么寫代碼?
第三方js的現狀
無論是新入行的小白還是有經驗的開發者,前端圈里的人一定聽過這類第三方js的大名。
一方面是因為它們實在太火了:
- 各種文章對框架進行對比、源碼解析以。
- GitHub 上 star 數量高速增長。
- 各種針對框架的培訓課程層出不窮。
- ……
另一方面是因為用它們開發非常方便:
- 利用腳手架工具幾行命令就可以快速搭建項目。
- 減少大量的重復代碼,結構更加清晰,可讀性強。
- 有豐富的UI庫和插件庫。
- ……
但是一則 GitHub 放棄使用 JQuery 的消息讓我開始思考:
第三方js除了帶來便利之外還有哪些副作用?
拋棄第三方js我們還能寫出高效的代碼嗎?
第三方js的副作用
雪球滾起來
如果現在讓你開發一個項目,你會怎么做?
假設你熟悉的是React,那么用可以用create-react-app快速搭建一個項目。
- 很好,react、react-dom、react-router-dom 已經寫入了package.json,不過事情還沒完。
- http請求怎么處理呢?引入axios吧。
- 日期怎么處理?引入 moment 或 day 吧。
- ……
要知道,這種“拿來主義”是會“上癮”的,所以第三方依賴就像一個滾動的雪球,隨著開發不斷增加,***所占體積越來越大。
如果用 webpack-bundle-analyzer 工具來分析項目的話,會發現項目代碼大部分體積都在node_modules目錄中,也就意味著都是第三方js,典型的二八定律(80%的源代碼只占了編譯后體積的20%)。
類似下面這張圖:
于是不得不開始優化,比如治標不治本的code split(代碼體積并沒有減小,只是拆分了),比如萬試萬難靈的tree shaking(你確定shaking之后的代碼都只有你真正依賴的代碼?),優化效果有限不說,更糟糕的是依賴的捆綁。
比如ant-design的模塊的日期組件依賴了moment,那我們在使用它的時候moment就被引入了。
而且我即使發現體積更小的dayjs可以基本取代moment的功能,也不敢引入,因為替換它日期組件會出問題,同時引入又增加了項目體積。
有些第三方js被合稱之為“全家桶”,這種叫法讓我想起了現在PC端的一些工具軟件,本來你只想裝一個電腦管家,結果它不斷彈窗提示你電腦不安全,建議你安裝一個殺毒軟件,又提示你軟件很久沒更新,提示你安裝某某軟件管家…..
本來只想裝一個,結果裝了全家。
工具馴化
如果你注意觀察,在這些第三方js的使用者中,會看到這樣一些現象:
- 排他。一些使用 MV* 框架的開發者很喜歡站隊進行討論,比如喜歡用 VueJS 的開發者很可能會吐槽 ReactJS,喜歡 Angular 的開發者會噴 VueJS。
- 浮躁。一些經驗并不豐富的開發者會覺得:使用JavaScript操作DOM多么低效,直接來個第三方js雙向數據綁定好了。自己寫XMLHTTPRequest發送請求多么麻煩,來第三方js直接調用好了。
- 局限。一些面試者以為自己熟悉某種第三方js之后就覺得自己技術不錯(甚至很多時候這種“熟悉”還要打上引號),大有掌握了某種第三方js就掌握了前端之意。
這些第三方js本來是為了提升開發效率的工具,卻不知不覺地把開發者馴化了,讓其產生了依賴。
如果每次讓你開發新項目,你不得不依賴第三方js提供的腳手架來搭建項目,然后才能開始寫代碼。
那么很可能你已經形成工具思維,就像手里拿著錘子,是什么都是釘子,你處理問答的方式,看問題的角度很可能會受此局限。
同時也意味著你正在離底層原生編碼越來越遠,越不熟悉原生API,你就越只能依賴第三方js,如此循環往復。
怎么打破這種狀況?
先推薦張鑫旭的一篇文章《不破不立的哲學與個人成長》,當然就是放棄它們。
這里需要注意的是,我所說的放棄并不是所有項目都自己寫框架,這樣在效率上而言是做不到的。
更推薦的而是在一些時間相對充裕、影響(規模)不大的項目中進行嘗試。
比如開發某個公司內部使用的小工具,或者頁面數量不多的時間不緊張(看個人開發速度)的小項目。
用原生API進行開發的時候我們可以參考下面兩條建議。
理解精髓
雖然我們不使用任何第三方js,但是其原理及實現我們是可以學習,比如你知道實現數據綁定的方式有臟值檢測、以及Object.defineProperty,那么你在寫代碼的時候就可以使用它們,你會發現懂這些原理和真正使用起來還有不小的距離。
換個角度而言,這也可以進一步加深我們對第三方js的理解。
當然我們的目的并不是為了再造一個山寨版的js,而是適當地結合、刪減和優化已有的技術和思想,為業務定制最合適的代碼。
文中提到的第三方js受歡迎很重要的一個原因是因為對DOM操作進行了優化甚至是隱藏。
JQuery號稱是DOM操作的利器,將DOM封裝成JQ對象并擴展了API,而MV框架取代JQuery的原因是因為在DOM操作這條路上做得更絕,直接屏蔽了底層操作,將數據映射到模板上。
如果這些MV的思考方式還只是停留在DOM的層次上的話估計也無法發展到今天的規模。
因為屏蔽DOM只是簡化了代碼而已,要搭建大型項目還要考慮代碼組織的問題,就是抽象和復用。
這些第三方js選擇的方式就是“組件化”,把HTML、js和CSS封裝在一個具有獨立作用域的組件中,形成可復用的代碼單元。
下面我們通過不引入任何第三方js的情況下來進行實現。
無依賴實踐
web components
先來考慮組件化。
其實瀏覽器原生就支持組件化(web components),它由3個關鍵技術組成,我們先來快速了解一下。
Custom elements(自定義元素)
一組js API,允許自定義元素及其行為,然后可以在您的用戶界面中按照需要使用它們。
簡單示例:
- // 定義組件類
- class LoginForm extends HTMLElement {
- constructor() {
- super();
- ...
- }
- }
- // 注冊組件
- customElements.define('login-form', LoginForm);
- <!-- 使用組件 -->
- <login-form></login-form>
Shadow DOM(影子DOM)
一組js API,創建一顆可見的DOM樹,這棵樹會附著到某個DOM元素上。
這棵樹的根節點稱之為shadow root,只有通過shadow root 才可以訪問內部的shadow dom,并且外部的css樣式也不會影響到shadow dom上。
相當于創建了一個獨立的作用域。
常見的shadow root可以通過瀏覽器的調試工具進行查看:
簡單示例:
- // 'open' 表示該shadow dom可以通過js 的函數進行訪問
- const shadow = dom.attachShadow({mode: 'open'})
- // 操作shadow dom
- shadow.appendChild(h1);
HTML templates(HTML模板)
HTML模板技術包含兩個標簽:和 。
當需要在頁面上重復使用同一個 DOM結構時,可以用 template 標簽來包裹它們,然后進行復用。
slot標簽讓模板更加靈活,使得用戶可以自定義模板中的某些內容。
簡單示例如下:
- <!-- template的定義 -->
- <template id="my-paragraph">
- <p><slot>My paragraph</slot></p>
- </template>
- // template的使用
- let template = document.getElementById('my-paragraph');
- let templateContent = template.content;
- document.body.appendChild(templateContent);
- <!-- 使用slot -->
- <my-paragraph>
- <span slot="my-text">Let's have some different text!</span>
- </my-paragraph>
- <!-- 渲染結果 -->
- <p>
- <span slot="my-text">Let's have some different text!</span>
- </p>
MDN上還提供了一些簡單的例子。這里來一個完整的例子:
- const str = `
- <style>
- p {
- color: white;
- background-color: #666;
- padding: 5px;
- }
- </style>
- <p><slot name="my-text">My default text</slot></p>
- `
- class MyParagraph extends HTMLElement {
- constructor() {
- super();
- const template = document.createElement('template');
- template.innerHTML = str;
- const templateContent = template.content;
- this.attachShadow({mode: 'open'}).appendChild(
- templateContent.cloneNode(true)
- );
- }
- }
- customElements.define('my-paragraph', MyParagraph);
完整的組件
不過這樣的組件功能還太弱了,因為很多時候組件之間是需要有交互的,比如父組件向子組件傳遞參數,子組件調用父組件回調函數。
因為它是HTML標簽,所以很自然地想到通過屬性來傳遞。而恰好組件也有生命周期函數來監聽屬性的變化,看似***!
不過問題又來了,首先是性能問題,這樣會增加對dom的讀寫操作。其次是數據類型問題,HTML標簽上只能傳遞字符串這類簡單的數據,而對于對象、數組、函數等這類復雜的數據就無能為力了。
你很可能想到對它們進行序列化和反序列化來實現,一來是弄得頁面很不美觀(想象一個長度為100的數組參數被序列化后的樣子)。二來是操作復雜,不停地序列化和反序列化既容易出錯也增加性能消耗。三來是一些數據無法被序列化,比如正則表達式、日期對象等。
好在我們可以通過選擇器獲取DOM實例來傳遞參數。但是這樣的話就不可避免地操作DOM,這可不是個好的處理方式。
另一方面,就組件內部而言,如果我們需要動態地將一些數據顯示到頁面上也需要操作DOM。
組件內部視圖與數據地通信
將數據映射到視圖我們可以采用數據綁定的形式來實現,而視圖的變化影響到數據可以采用事件的綁定的形式。
數據綁定
怎么楊將視圖和數據建立綁定關系,通常的做法是通過特定的模板語法來實現,比如說使用指令。
例如用x-bind指令來將數據體蟲到視圖的文本內容中。
臟值檢測的機制在性能上有損耗我們不考慮,那么剩下的就是利用Object.defineProperty這種監聽屬性值變化的方式來實現。
同時需要注意的是,一個數據可以對應多個視圖,所以不能直接監聽,而是要建立一個隊列來處理。
整理一下實現思路:
- 通過選擇器找出帶有x-bind屬性的元素,以及該屬性的值,比如<div x-bind="text"></div>的屬性值是text。
- 建立一個監聽隊列dispatcher保存屬性值以及對應元素的處理函數。比如上面的元素監聽的是text屬性,處理函數是this.textContent = value;
- 建立一個數據模型state,編寫對應屬性的set函數,當值發生變化時執行dispatcher中的函數。
示例代碼:
- // 指令選擇器以及對應處理函數
- const map = {
- 'x-bind'(value) {
- this.textContent = undefined === value ? '' : value;
- }
- };
- // 建立監聽隊列,監聽數據對象屬性值得變動,然后遍歷執行函數
- for (const p in map) {
- forEach(this.qsa(`[${p}]`), dom => {
- const property = attr(dom, p).split('.').shift();
- this.dispatcher[property] = this.dispatcher[property] || [];
- const fn = map[p].bind(dom);
- fn(this.state[property]);
- this.dispatcher[property].push(fn);
- });
- }
- for (const property in this.dispatcher) {
- defineProperty(property);
- }
- // 監聽數據對象屬性
- const defineProperty = p => {
- const prefix = '_s_';
- Object.defineProperty(this.state, p, {
- get: () => {
- return this[prefix + p];
- },
- set: value => {
- if(this[prefix + p] !== value) {
- this.dispatcher[p].forEach(fun => fun(value, this[prefix + p]));
- this[prefix + p] = value;
- }
- }
- });
- };
這里不是操作了DOM了嗎?
沒關系,我們可以把DOM操作放入基類中,那么對于業務組件就不再需要接觸DOM了。
小結:
這里使用VueJS同樣的數據綁定方式,但是由于數據對象屬性只能有一個 set 函數,所以建立了一個監聽隊列來進行處理不同元素的數據綁定,這種隊列遍歷的方式和AngularJS臟值檢測的機制有些類似,但是觸發機制不同、數組長度更小。
事件綁定
事件的綁定思路比數據綁定更簡單,直接在DOM元素上進行監聽即可。
我們以click事件為例進行綁定,創建一個事件綁定的指令,比如x-click。
實現思路:
- 利用DOM選擇器找到帶有x-click屬性的元素。
- 讀取x-click屬性值,這時候我們需要對屬性值進行一下判斷,因為屬性值有可能是函數名比如x-click=fn,有可能是函數調用x-click=fn(a, true)。
- 對于基礎數據類型進行判斷,比如布爾值、字符串,并加入到調用參數列表中。
- 為DOM元素添加事件監聽,當事件觸發時調用對應函數,傳入參數。
示例代碼:
- const map = ['x-click'];
- map.forEach(event => {
- forEach(this.qsa(`[${event}]`), dom => {
- // 獲取屬性值
- const property = attr(dom, event);
- // 獲取函數名
- const fnName = property.split('(')[0];
- // 獲取函數參數
- const params = property.indexOf('(') > 0 ? property.replace(/.*\((.*)\)/, '$1').split(',') : [];
- let args = [];
- // 解析函數參數
- params.forEach(param => {
- const p = param.trim();
- const str = p.replace(/^'(.*)'$/, '$1').replace(/^"(.*)"$/, '$1');
- if (str !== p) { // string
- args.push(str);
- } else if (p === 'true' || p === 'false') { // boolean
- args.push(p === 'true');
- } else if (!isNaN(p)) {
- args.push(p * 1);
- } else {
- args.push(this.state[p]);
- }
- });
- // 監聽事件
- on(event.replace('x-', ''), dom, e => {
- // 調用函數并傳入參數
- this[fnName](...params, e);
- });
- });
- });
對于表單控件的雙向數據綁定也很容易,即在建立數據綁定修改value,然后建立事件綁定監聽input事件即可。
組件與組件之間的通信
解決完組件內部的視圖與數據的映射問題我們來著手解決組件之間的通信問題。
組件需要提供一個屬性對象來接收參數,我們設定為props。
父=>子,數據傳遞
父組件要將值傳入子組件的props屬性,需要獲取子組件的實例,然后修改props屬性。
這樣的話就不可避免的操作DOM,那么我們考慮將DOM操作法放在基類中進行。
那么問題來了,怎么找到哪些標簽是子組件,子組件有哪些屬性是需要綁定的?
可以通過命名規范和選擇其來獲取嗎?比如組件名稱都以cmp-開頭,選擇器支不支持暫且不說,這種要求既約束編碼命名,同時有沒有規范保證。
簡單地說就是沒有靜態檢測機制,如果有開發者寫的組件不是以cmp-開頭,運行時發現數據傳遞失敗檢查起來會比較麻煩。
所以可以在另一個地方對組件名稱進行采集,那就是注冊組件函數。
我們通過customElements.define函數來注冊組件,一種方式是直接對該函數進行重載,在注冊組件的時候記錄組件名稱,但是實現有些難度,而且對原生API函數修改難以保證不會對其它代碼產生影響。
所以折中的方式是對齊封裝,然后利用封裝的函數進行組件注冊。
這樣我們就可以記錄所有注冊的組件名了,然后創建實例來獲取對應props我們就解決了上面提出的問題。
同時在props對象的屬性上編寫set函數進行監聽。
到了這一步還只完成了一半,因為我們還沒有把數據傳遞給子組件。
我們不要操作DOM的話那就只能利用已有的數據綁定機制了,將需要傳遞的屬性綁定到數據對象上。
梳理一下思路:
- 編寫子組件的時候建立props對象,并聲明需要被傳參的屬性, 比如this.props = {id: ''}。
- 編寫子組件的時候不通過原生customElements.define,而是使用封裝過的函數,比如defineComponent來注冊,這樣可以記錄組件名和對應的props屬性。
- 父組件在使用子組件的時候進行遍歷,找出子組件和對應的props對象。
- 將子組件props對象的屬性綁定到父組件的數據對象state屬性上,這樣當父組件state屬性值發生變化時,會自動修改子組件props屬性值。
示例代碼:
- const components = {};
- /**
- * 注冊組件函數
- * @param {string} 組件(標簽)名
- * @param {class} 組件實現類
- */
- export const defineComponent = (name, componentClass) => {
- // 注冊組件
- customElements.define(name, componentClass);
- // 創建組件實例
- const cmp = document.createElement(name);
- // 存儲組件名以及對應的props屬性
- components[name] = Object.getOwnPropertyNames(cmp.props) || [];
- };
- // 注冊子組件
- class ChildComponent extends Component {
- constructor() {
- // 通過基類來創建模板
- // 通過基類來監聽props
- super(template, {
- id: value => {
- // ...
- }
- });
- }
- }
- defineComponent('child-component', ChildComponent);
- <!-- 使用子組件 -->
- <child-component id="myId"></child-component>
- // 注冊父組件
- class ParentComponent extends Component {
- constructor() {
- super(template);
- this.state.myId = 'xxx';
- }
- }
上面的代碼中有很多地方可以繼續優化,具體查看文末示例代碼。
子=>父,回調函數
子組件的參數要傳回給父組件,可以采用回調函數的形式。
比較麻煩的時候調用函數時需要用到父組件的作用域。
可以將父組件的函數進行作用域綁定然后傳入子組件props對象屬性,這樣子組件就可以正常調用和傳參了。
因為回調函數操作方式和參數不一樣,參數是被動接收,回調函數是主動調用,所以需要在聲明時進行標注,比如參考AngularJS指令的scope對象屬性的聲明方式,用“&”符號來表示回調函數。
理清一下思路:
- 子組件類中聲明props的屬性為回調函數,如 this.props = {onClick:'&'}。
- 父組件初始化時,在模板上傳遞對應屬性, 如。
- 根據子組件屬性值找到對應的父組件函數,然后將父組件函數綁定作用域并傳入。如childComponent.props.onClick = this.click.bind(this)。
- 子組件中調用父組件函數, 如this.props.onClick(...)。
示例代碼:
- // 注冊子組件
- class ChildComponent extends Component {
- constructor() {
- // 通過基類來聲明回調函數屬性
- super(template, {
- onClick: '&'
- });
- ...
- this.props.onClick(...);
- }
- }
- defineComponent('child-component', ChildComponent);
- <!-- 父組件中使用子組件 -->
- <child-component on-click="click"></child-component>
- // 注冊父組件
- class ParentComponent extends Component {
- constructor() {
- super(template);
- }
- // 事件傳遞放在基類中操作
- click(data) {
- ...
- }
- }
穿越組件層級的通信
有些組件需要子孫組件進行通信,層層傳遞會編寫很多額外的代碼,所以我們可以通過總線模式來進行操作。
即建立一個全局模塊,數據發送者發送消息和數據,數據接收者進行監聽。
示例代碼
- // bus.js
- // 監聽隊列
- const dispatcher = {};
- /**
- * 接收消息
- * name
- */
- export const on = (name, cb) => {
- dispatcher[name] = dispatcher[name] || [];
- const key = Math.random().toString(26).substring(2, 10);
- // 將監聽函數放入隊列并生成唯一key
- dispatcher[name].push({
- key,
- fn: cb
- });
- return key;
- };
- // 發送消息
- export const emit = function(name, data) {
- const dispatchers = dispatcher[name] || [];
- // 輪詢監聽隊列并調用函數
- dispatchers.forEach(dp => {
- dp.fn(data, this);
- });
- };
- // 取消監聽
- export const un = (name, key) => {
- const list = dispatcher[name] || [];
- const index = list.findIndex(item => item.key === key);
- // 從監聽隊列中刪除監聽函數
- if(index > -1) {
- list.splice(index, 1);
- return true;
- } else {
- return false;
- }
- };
- // ancestor.js
- import {on} from './bus.js';
- class AncestorComponent extends Component {
- constructor() {
- super();
- on('finish', data => {
- //...
- })
- }
- }
- // child.js
- class ChildComponent extends Component {
- constructor() {
- super();
- emit('finish', data);
- }
- }
總結
關于基類的詳細代碼可以參考文末的倉庫地址,目前項目遵循的是按需添加原則,只實現了一些基礎的操作,并沒有把所有可能用到的指令寫完。
所以還不足以稱之為“框架”,只是給大家提供實現思路以及編寫原生代碼的信心。