首先感謝React、Vue、Angular、Cycle、JQuery 等這些第三方js爲開發帶來的便利。css
如下將Vue、React這類經常使用的框架(庫)統稱爲「第三方js」。html
不管是新入行的小白仍是有經驗的開發者,前端圈裏的人必定聽過這類第三方js的大名。 一方面是由於它們實在太火了:前端
另外一方面是由於用它們開發很是方便:node
可是一則 GitHub 放棄使用 JQuery 的消息讓我開始思考:react
第三方js除了帶來便利以外還有哪些反作用?拋棄第三方js咱們還能寫出高效的代碼嗎?webpack
若是如今讓你開發一個項目,你會怎麼作? 假設你熟悉的是React,那麼用能夠用create-react-app
快速搭建一個項目。ios
要知道,這種「拿來主義」是會「上癮」的,因此第三方依賴就像一個滾動的雪球,隨着開發不斷增長,最後所佔體積愈來愈大。 若是用 webpack-bundle-analyzer
工具來分析項目的話,會發現項目代碼大部分體積都在node_modules
目錄中,也就意味着都是第三方js,典型的二八定律(80%的源代碼只佔了編譯後體積的20%)。git
相似下面這張圖:github
因而不得不開始優化,好比治標不治本的code split
(代碼體積並無減少,只是拆分了),好比萬試萬難靈的tree shaking
(你肯定shaking以後的代碼都只有你真正依賴的代碼?),優化效果有限不說,更糟糕的是依賴的捆綁。 好比ant-design
的模塊的日期組件依賴了moment
,那咱們在使用它的時候moment
就被引入了。 並且我即便發現體積更小的dayjs
能夠基本取代moment
的功能,也不敢引入,由於替換它日期組件會出問題,同時引入又增長了項目體積。web
有些第三方js被合稱之爲「全家桶」,這種叫法讓我想起了如今PC端的一些工具軟件,原本你只想裝一個電腦管家,結果它不斷彈窗提示你電腦不安全,建議你安裝一個殺毒軟件,又提示你軟件好久沒更新,提示你安裝某某軟件管家..... 原本只想裝一個,結果裝了全家。
若是你注意觀察,在這些第三方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),它由3個關鍵技術組成,咱們先來快速瞭解一下。
一組js API,容許自定義元素及其行爲,而後能夠在您的用戶界面中按照須要使用它們。 簡單示例:
// 定義組件類
class LoginForm extends HTMLElement {
constructor() {
super();
...
}
}
// 註冊組件
customElements.define('login-form', LoginForm);
<!-- 使用組件 -->
<login-form></login-form>
複製代碼
一組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模板技術包含兩個標籤:<template>
和 <slot>
。 當須要在頁面上重複使用同一個 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
。 實現思路:
x-click
屬性的元素。x-click
屬性值,這時候咱們須要對屬性值進行一下判斷,由於屬性值有多是函數名好比x-click=fn
,有多是函數調用x-click=fn(a, true)
。示例代碼:
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對象屬性的聲明方式,用「&」符號來表示回調函數。 理清一下思路:
this.props = {onClick:'&'}
。<child-compoennt on-click="click"></child-component>
。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);
}
}
複製代碼
關於基類的詳細代碼能夠參考文末的倉庫地址,目前項目遵循的是按需添加原則,只實現了一些基礎的操做,並無把全部可能用到的指令寫完。 因此還不足以稱之爲「框架」,只是給你們提供實現思路以及編寫原生代碼的信心。
原文連接:tech.gtxlab.com/web-compone…
做者信息:朱德龍,人和將來高級前端工程師。