首頁技術(shù)文章正文

排除第三方j(luò)s的使用者中如何寫高效代碼?

更新時間:2018-11-16 來源:黑馬程序員技術(shù)社區(qū) 瀏覽量:

js是前端很火的工作,可以對文章框架與源碼對比解析,各種針對框架層出不窮...
但是目前針對于js的各種文章對框架進(jìn)行對比、源碼解析以。GitHub 上 star 數(shù)量高速增長。各種針對框架的培訓(xùn)課程層出不窮?!?/p>

另一方面是因?yàn)橛盟鼈冮_發(fā)非常方便:

利用腳手架工具幾行命令就可以快速搭建項(xiàng)目。減少大量的重復(fù)代碼,結(jié)構(gòu)更加清晰,可讀性強(qiáng)。有豐富的UI庫和插件庫?!?br/>

但是一則 GitHub 放棄使用 JQuery 的消息讓我開始思考:

第三方j(luò)s除了帶來便利之外還有哪些副作用?拋棄第三方j(luò)s我們還能寫出高效的代碼嗎?

第三方j(luò)s的副作用雪球滾起來

如果現(xiàn)在讓你開發(fā)一個項(xiàng)目,你會怎么做?假設(shè)你熟悉的是React,那么用可以用create-react-app快速搭建一個項(xiàng)目。

很好,react、react-dom、react-router-dom 已經(jīng)寫入了package.json,不過事情還沒完。http請求怎么處理呢?引入axios吧。日期怎么處理?引入 moment 或 day 吧?!?br/>

要知道,這種“拿來主義”是會“上癮”的,所以第三方依賴就像一個滾動的雪球,隨著開發(fā)不斷增加,最后所占體積越來越大。如果用 webpack-bundle-analyzer 工具來分析項(xiàng)目的話,會發(fā)現(xiàn)項(xiàng)目代碼大部分體積都在node_modules目錄中,也就意味著都是第三方j(luò)s,典型的二八定律(80%的源代碼只占了編譯后體積的20%)。

類似下面這張圖:
1553761983277_1542355834060031155.png

于是不得不開始優(yōu)化,比如治標(biāo)不治本的code split(代碼體積并沒有減小,只是拆分了),比如萬試萬難靈的 tree shaking(你確定shaking之后的代碼都只有你真正依賴的代碼?),優(yōu)化效果有限不說,更糟糕的是依賴的捆綁。比如ant-design的模塊的日期組件依賴了 moment,那我們在使用它的時候moment就被引入了。而且我即使發(fā)現(xiàn)體積更小的 dayjs可以基本取代moment的功能,也不敢引入,因?yàn)樘鎿Q它日期組件會出問題,同時引入又增加了項(xiàng)目體積。

有些第三方j(luò)s被合稱之為“全家桶”,這種叫法讓我想起了現(xiàn)在PC端的一些工具軟件,本來你只想裝一個電腦管家,結(jié)果它不斷彈窗提示你電腦不安全,建議你安裝一個殺毒軟件,又提示你軟件很久沒更新,提示你安裝某某軟件管家…..本來只想裝一個,結(jié)果裝了全家。

工具馴化

如果你注意觀察,在這些第三方j(luò)s的使用者中,會看到這樣一些現(xiàn)象:

排他。一些使用 MV* 框架的開發(fā)者很喜歡站隊進(jìn)行討論,比如喜歡用 VueJS 的開發(fā)者很可能會吐槽 ReactJS,喜歡 Angular 的開發(fā)者會噴 VueJS。浮躁。一些經(jīng)驗(yàn)并不豐富的開發(fā)者會覺得:使用JavaScript操作DOM多么低效,直接來個第三方j(luò)s雙向數(shù)據(jù)綁定好了。自己寫XMLHTTPRequest發(fā)送請求多么麻煩,來第三方j(luò)s直接調(diào)用好了。局限。一些面試者以為自己熟悉某種第三方j(luò)s之后就覺得自己技術(shù)不錯(甚至很多時候這種“熟悉”還要打上引號),大有掌握了某種第三方j(luò)s就掌握了前端之意。

這些第三方j(luò)s本來是為了提升開發(fā)效率的工具,卻不知不覺地把開發(fā)者馴化了,讓其產(chǎn)生了依賴。如果每次讓你開發(fā)新項(xiàng)目,你不得不依賴第三方j(luò)s提供的腳手架來搭建項(xiàng)目,然后才能開始寫代碼。那么很可能你已經(jīng)形成工具思維,就像手里拿著錘子,是什么都是釘子,你處理問答的方式,看問題的角度很可能會受此局限。同時也意味著你正在離底層原生編碼越來越遠(yuǎn),越不熟悉原生API,你就越只能依賴第三方j(luò)s,如此循環(huán)往復(fù)。

怎么打破這種狀況?先推薦張鑫旭的一篇文章《不破不立的哲學(xué)與個人成長》,當(dāng)然就是放棄它們。這里需要注意的是,我所說的放棄并不是所有項(xiàng)目都自己寫框架,這樣在效率上而言是做不到的。更推薦的而是在一些時間相對充裕、影響(規(guī)模)不大的項(xiàng)目中進(jìn)行嘗試。比如開發(fā)某個公司內(nèi)部使用的小工具,或者頁面數(shù)量不多的時間不緊張(看個人開發(fā)速度)的小項(xiàng)目。

用原生API進(jìn)行開發(fā)的時候我們可以參考下面兩條建議。

理解精髓

雖然我們不使用任何第三方j(luò)s,但是其原理及實(shí)現(xiàn)我們是可以學(xué)習(xí),比如你知道實(shí)現(xiàn)數(shù)據(jù)綁定的方式有臟值檢測、以及Object.defineProperty,那么你在寫代碼的時候就可以使用它們,你會發(fā)現(xiàn)懂這些原理和真正使用起來還有不小的距離。換個角度而言,這也可以進(jìn)一步加深我們對第三方j(luò)s的理解。

當(dāng)然我們的目的并不是為了再造一個山寨版的js,而是適當(dāng)?shù)亟Y(jié)合、刪減和優(yōu)化已有的技術(shù)和思想,為業(yè)務(wù)定制最合適的代碼。

文中提到的第三方j(luò)s受歡迎很重要的一個原因是因?yàn)閷OM操作進(jìn)行了優(yōu)化甚至是隱藏。JQuery號稱是DOM操作的利器,將DOM封裝成JQ對象并擴(kuò)展了API,而MV框架取代JQuery的原因是因?yàn)樵贒OM操作這條路上做得更絕,直接屏蔽了底層操作,將數(shù)據(jù)映射到模板上。如果這些MV的思考方式還只是停留在DOM的層次上的話估計也無法發(fā)展到今天的規(guī)模。因?yàn)槠帘蜠OM只是簡化了代碼而已,要搭建大型項(xiàng)目還要考慮代碼組織的問題,就是抽象和復(fù)用。這些第三方j(luò)s選擇的方式就是“組件化”,把HTML、js和CSS封裝在一個具有獨(dú)立作用域的組件中,形成可復(fù)用的代碼單元。

下面我們通過不引入任何第三方j(luò)s的情況下來進(jìn)行實(shí)現(xiàn)。

無依賴實(shí)踐web components

先來考慮組件化。其實(shí)瀏覽器原生就支持組件化(web components),它由3個關(guān)鍵技術(shù)組成,我們先來快速了解一下。

Custom elements(自定義元素)

一組js API,允許自定義元素及其行為,然后可以在您的用戶界面中按照需要使用它們。簡單示例:

// 定義組件類class LoginForm extends HTMLElement {  constructor() {    super();    ...  }}// 注冊組件customElements.define('login-form', LoginForm);<!-- 使用組件 --><login-form></login-form>Shadow DOM(影子DOM)

一組js API,創(chuàng)建一顆可見的DOM樹,這棵樹會附著到某個DOM元素上。這棵樹的根節(jié)點(diǎn)稱之為shadow root,只有通過shadow root 才可以訪問內(nèi)部的shadow dom,并且外部的css樣式也不會影響到shadow dom上。相當(dāng)于創(chuàng)建了一個獨(dú)立的作用域。

常見的shadow root可以通過瀏覽器的調(diào)試工具進(jìn)行查看:

1553761995011_1542355870574008809.png

簡單示例:

// 'open' 表示該shadow dom可以通過js 的函數(shù)進(jìn)行訪問const shadow = dom.attachShadow({mode: 'open'})// 操作shadow domshadow.appendChild(h1);HTML templates(HTML模板)

HTML模板技術(shù)包含兩個標(biāo)簽:<template>和 <slot>。當(dāng)需要在頁面上重復(fù)使用同一個 DOM結(jié)構(gòu)時,可以用 template 標(biāo)簽來包裹它們,然后進(jìn)行復(fù)用。slot標(biāo)簽讓模板更加靈活,使得用戶可以自定義模板中的某些內(nèi)容。簡單示例如下:

<!-- 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><!-- 渲染結(jié)果 --><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);完整的組件

不過這樣的組件功能還太弱了,因?yàn)楹芏鄷r候組件之間是需要有交互的,比如父組件向子組件傳遞參數(shù),子組件調(diào)用父組件回調(diào)函數(shù)。因?yàn)樗荋TML標(biāo)簽,所以很自然地想到通過屬性來傳遞。而恰好組件也有生命周期函數(shù)來監(jiān)聽屬性的變化,看似完美!不過問題又來了,首先是性能問題,這樣會增加對dom的讀寫操作。其次是數(shù)據(jù)類型問題,HTML標(biāo)簽上只能傳遞字符串這類簡單的數(shù)據(jù),而對于對象、數(shù)組、函數(shù)等這類復(fù)雜的數(shù)據(jù)就無能為力了。你很可能想到對它們進(jìn)行序列化和反序列化來實(shí)現(xiàn),一來是弄得頁面很不美觀(想象一個長度為100的數(shù)組參數(shù)被序列化后的樣子)。二來是操作復(fù)雜,不停地序列化和反序列化既容易出錯也增加性能消耗。三來是一些數(shù)據(jù)無法被序列化,比如正則表達(dá)式、日期對象等。好在我們可以通過選擇器獲取DOM實(shí)例來傳遞參數(shù)。但是這樣的話就不可避免地操作DOM,這可不是個好的處理方式。另一方面,就組件內(nèi)部而言,如果我們需要動態(tài)地將一些數(shù)據(jù)顯示到頁面上也需要操作DOM。

組件內(nèi)部視圖與數(shù)據(jù)地通信

將數(shù)據(jù)映射到視圖我們可以采用數(shù)據(jù)綁定的形式來實(shí)現(xiàn),而視圖的變化影響到數(shù)據(jù)可以采用事件的綁定的形式。

數(shù)據(jù)綁定

怎么楊將視圖和數(shù)據(jù)建立綁定關(guān)系,通常的做法是通過特定的模板語法來實(shí)現(xiàn),比如說使用指令。例如用x-bind指令來將數(shù)據(jù)體蟲到視圖的文本內(nèi)容中。臟值檢測的機(jī)制在性能上有損耗我們不考慮,那么剩下的就是利用 Object.defineProperty這種監(jiān)聽屬性值變化的方式來實(shí)現(xiàn)。同時需要注意的是,一個數(shù)據(jù)可以對應(yīng)多個視圖,所以不能直接監(jiān)聽,而是要建立一個隊列來處理。整理一下實(shí)現(xiàn)思路:

通過選擇器找出帶有x-bind屬性的元素,以及該屬性的值,比如 <div x-bind="text"></div>的屬性值是text。建立一個監(jiān)聽隊列dispatcher保存屬性值以及對應(yīng)元素的處理函數(shù)。比如上面的元素監(jiān)聽的是 text屬性,處理函數(shù)是this.textContent = value;建立一個數(shù)據(jù)模型state,編寫對應(yīng)屬性的set函數(shù),當(dāng)值發(fā)生變化時執(zhí)行 dispatcher中的函數(shù)。

示例代碼:

// 指令選擇器以及對應(yīng)處理函數(shù)const map = {  'x-bind'(value) {    this.textContent = undefined === value ? '' : value;  }};// 建立監(jiān)聽隊列,監(jiān)聽數(shù)據(jù)對象屬性值得變動,然后遍歷執(zhí)行函數(shù)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);}// 監(jiān)聽數(shù)據(jù)對象屬性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了嗎?沒關(guān)系,我們可以把DOM操作放入基類中,那么對于業(yè)務(wù)組件就不再需要接觸DOM了。

小結(jié):這里使用VueJS同樣的數(shù)據(jù)綁定方式,但是由于數(shù)據(jù)對象屬性只能有一個 set 函數(shù),所以建立了一個監(jiān)聽隊列來進(jìn)行處理不同元素的數(shù)據(jù)綁定,這種隊列遍歷的方式和AngularJS臟值檢測的機(jī)制有些類似,但是觸發(fā)機(jī)制不同、數(shù)組長度更小。

事件綁定

事件的綁定思路比數(shù)據(jù)綁定更簡單,直接在DOM元素上進(jìn)行監(jiān)聽即可。我們以click事件為例進(jìn)行綁定,創(chuàng)建一個事件綁定的指令,比如 x-click。實(shí)現(xiàn)思路:

利用DOM選擇器找到帶有x-click屬性的元素。讀取x-click屬性值,這時候我們需要對屬性值進(jìn)行一下判斷,因?yàn)閷傩灾涤锌赡苁呛瘮?shù)名比如 x-click=fn,有可能是函數(shù)調(diào)用x-click=fn(a, true)。對于基礎(chǔ)數(shù)據(jù)類型進(jìn)行判斷,比如布爾值、字符串,并加入到調(diào)用參數(shù)列表中。為DOM元素添加事件監(jiān)聽,當(dāng)事件觸發(fā)時調(diào)用對應(yīng)函數(shù),傳入?yún)?shù)。

示例代碼:

const map = ['x-click'];map.forEach(event => {  forEach(this.qsa(`[${event}]`), dom => {    // 獲取屬性值    const property = attr(dom, event);    // 獲取函數(shù)名    const fnName = property.split('(')[0];    // 獲取函數(shù)參數(shù)    const params = property.indexOf('(') > 0 ? property.replace(/.*\((.*)\)/, '$1').split(',') : [];    let args = [];    // 解析函數(shù)參數(shù)    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]);      }    });    // 監(jiān)聽事件    on(event.replace('x-', ''), dom, e => {      // 調(diào)用函數(shù)并傳入?yún)?shù)      this[fnName](...params, e);    });  });});

對于表單控件的雙向數(shù)據(jù)綁定也很容易,即在建立數(shù)據(jù)綁定修改value,然后建立事件綁定監(jiān)聽input事件即可。

組件與組件之間的通信

解決完組件內(nèi)部的視圖與數(shù)據(jù)的映射問題我們來著手解決組件之間的通信問題。組件需要提供一個屬性對象來接收參數(shù),我們設(shè)定為props。

父=>子,數(shù)據(jù)傳遞

父組件要將值傳入子組件的props屬性,需要獲取子組件的實(shí)例,然后修改 props屬性。這樣的話就不可避免的操作DOM,那么我們考慮將DOM操作法放在基類中進(jìn)行。那么問題來了,怎么找到哪些標(biāo)簽是子組件,子組件有哪些屬性是需要綁定的?可以通過命名規(guī)范和選擇其來獲取嗎?比如組件名稱都以cmp-開頭,選擇器支不支持暫且不說,這種要求既約束編碼命名,同時有沒有規(guī)范保證。簡單地說就是沒有靜態(tài)檢測機(jī)制,如果有開發(fā)者寫的組件不是以 cmp-開頭,運(yùn)行時發(fā)現(xiàn)數(shù)據(jù)傳遞失敗檢查起來會比較麻煩。所以可以在另一個地方對組件名稱進(jìn)行采集,那就是注冊組件函數(shù)。我們通過customElements.define函數(shù)來注冊組件,一種方式是直接對該函數(shù)進(jìn)行重載,在注冊組件的時候記錄組件名稱,但是實(shí)現(xiàn)有些難度,而且對原生API函數(shù)修改難以保證不會對其它代碼產(chǎn)生影響。所以折中的方式是對齊封裝,然后利用封裝的函數(shù)進(jìn)行組件注冊。這樣我們就可以記錄所有注冊的組件名了,然后創(chuàng)建實(shí)例來獲取對應(yīng) props我們就解決了上面提出的問題。同時在props對象的屬性上編寫 set函數(shù)進(jìn)行監(jiān)聽。到了這一步還只完成了一半,因?yàn)槲覀冞€沒有把數(shù)據(jù)傳遞給子組件。我們不要操作DOM的話那就只能利用已有的數(shù)據(jù)綁定機(jī)制了,將需要傳遞的屬性綁定到數(shù)據(jù)對象上。梳理一下思路:

編寫子組件的時候建立props對象,并聲明需要被傳參的屬性, 比如this.props = {id: ''}。編寫子組件的時候不通過原生customElements.define,而是使用封裝過的函數(shù),比如defineComponent來注冊,這樣可以記錄組件名和對應(yīng)的props屬性。父組件在使用子組件的時候進(jìn)行遍歷,找出子組件和對應(yīng)的props對象。將子組件props對象的屬性綁定到父組件的數(shù)據(jù)對象 state屬性上,這樣當(dāng)父組件state屬性值發(fā)生變化時,會自動修改子組件 props屬性值。

示例代碼:

const components = {};/** * 注冊組件函數(shù) * @param {string} 組件(標(biāo)簽)名 * @param {class} 組件實(shí)現(xiàn)類 */export const defineComponent = (name, componentClass) => {  // 注冊組件  customElements.define(name, componentClass);  // 創(chuàng)建組件實(shí)例  const cmp = document.createElement(name);  // 存儲組件名以及對應(yīng)的props屬性  components[name] = Object.getOwnPropertyNames(cmp.props) || [];};// 注冊子組件class ChildComponent extends Component {  constructor() {    // 通過基類來創(chuàng)建模板    // 通過基類來監(jiān)聽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';  }}

上面的代碼中有很多地方可以繼續(xù)優(yōu)化,具體查看文末示例代碼。

子=>父,回調(diào)函數(shù)

子組件的參數(shù)要傳回給父組件,可以采用回調(diào)函數(shù)的形式。比較麻煩的時候調(diào)用函數(shù)時需要用到父組件的作用域。可以將父組件的函數(shù)進(jìn)行作用域綁定然后傳入子組件props對象屬性,這樣子組件就可以正常調(diào)用和傳參了。因?yàn)榛卣{(diào)函數(shù)操作方式和參數(shù)不一樣,參數(shù)是被動接收,回調(diào)函數(shù)是主動調(diào)用,所以需要在聲明時進(jìn)行標(biāo)注,比如參考AngularJS指令的scope對象屬性的聲明方式,用“&”符號來表示回調(diào)函數(shù)。理清一下思路:

子組件類中聲明props的屬性為回調(diào)函數(shù),如 this.props = {onClick:'&'}。父組件初始化時,在模板上傳遞對應(yīng)屬性, 如<child-compoennt on-click="click"></child-component>。根據(jù)子組件屬性值找到對應(yīng)的父組件函數(shù),然后將父組件函數(shù)綁定作用域并傳入。如childComponent.props.onClick = this.click.bind(this)。子組件中調(diào)用父組件函數(shù), 如this.props.onClick(...)。

示例代碼:

// 注冊子組件class ChildComponent extends Component {  constructor() {    // 通過基類來聲明回調(diào)函數(shù)屬性    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) {    ...  }}穿越組件層級的通信

有些組件需要子孫組件進(jìn)行通信,層層傳遞會編寫很多額外的代碼,所以我們可以通過總線模式來進(jìn)行操作。即建立一個全局模塊,數(shù)據(jù)發(fā)送者發(fā)送消息和數(shù)據(jù),數(shù)據(jù)接收者進(jìn)行監(jiān)聽。

示例代碼

// bus.js// 監(jiān)聽隊列const dispatcher = {};/**  * 接收消息 * name  */export const on = (name, cb) => {  dispatcher[name] = dispatcher[name] || [];  const key = Math.random().toString(26).substring(2, 10);  // 將監(jiān)聽函數(shù)放入隊列并生成唯一key  dispatcher[name].push({    key,    fn: cb  });  return key;};// 發(fā)送消息export const emit = function(name, data) {  const dispatchers = dispatcher[name] || [];  // 輪詢監(jiān)聽隊列并調(diào)用函數(shù)  dispatchers.forEach(dp => {    dp.fn(data, this);  });};// 取消監(jiān)聽export const un = (name, key) => {  const list = dispatcher[name] || [];  const index = list.findIndex(item => item.key === key);  // 從監(jiān)聽隊列中刪除監(jiān)聽函數(shù)  if(index > -1) {    list.splice(index, 1);    return true;  } else {    return false;  }};// ancestor.jsimport {on} from './bus.js';class AncestorComponent extends Component {  constructor() {    super();    on('finish', data => {      //...    })      }}// child.jsclass ChildComponent extends Component {  constructor() {    super();    emit('finish', data);  }}總結(jié)

關(guān)于基類的詳細(xì)代碼可以參考文末的倉庫地址,目前項(xiàng)目遵循的是按需添加原則,只實(shí)現(xiàn)了一些基礎(chǔ)的操作,并沒有把所有可能用到的指令寫完。所以還不足以稱之為“框架”,只是給大家提供實(shí)現(xiàn)思路以及編寫原生代碼的信心。

具體示例:https://github.com/yalishizhude/web-component


作者:黑馬程序員前端與移動開發(fā)培訓(xùn)學(xué)院
首發(fā):http://web.itheima.com/

分享到:
在線咨詢 我要報名
和我們在線交談!