拋開 Vue、React、JQuery 這類第三方js,我們該怎麼寫程式碼?

genetalks_大資料發表於2019-04-04

首先感謝React、Vue、Angular、Cycle、JQuery 等這些第三方js為開發帶來的便利。

以下將Vue、React這類常用的框架(庫)統稱為“第三方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%)。

類似下面這張圖:

拋開 Vue、React、JQuery 這類第三方js,我們該怎麼寫程式碼?

於是不得不開始優化,比如治標不治本的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可以通過瀏覽器的除錯工具進行檢視:

拋開 Vue、React、JQuery 這類第三方js,我們該怎麼寫程式碼?

簡單示例:

// 'open' 表示該shadow dom可以通過js 的函式進行訪問
const shadow = dom.attachShadow({mode: 'open'})
// 操作shadow dom
shadow.appendChild(h1);
複製程式碼

HTML templates(HTML模板)

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這種監聽屬性值變化的方式來實現。 同時需要注意的是,一個資料可以對應多個檢視,所以不能直接監聽,而是要建立一個佇列來處理。 整理一下實現思路:

  1. 通過選擇器找出帶有x-bind屬性的元素,以及該屬性的值,比如 <div x-bind="text"></div> 的屬性值是text
  2. 建立一個監聽佇列dispatcher儲存屬性值以及對應元素的處理函式。比如上面的元素監聽的是text屬性,處理函式是this.textContent = value;
  3. 建立一個資料模型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。 實現思路:

  1. 利用DOM選擇器找到帶有x-click屬性的元素。
  2. 讀取x-click屬性值,這時候我們需要對屬性值進行一下判斷,因為屬性值有可能是函式名比如x-click=fn,有可能是函式呼叫x-click=fn(a, true)
  3. 對於基礎資料型別進行判斷,比如布林值、字串,並加入到呼叫引數列表中。
  4. 為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的話那就只能利用已有的資料繫結機制了,將需要傳遞的屬性繫結到資料物件上。 梳理一下思路:

  1. 編寫子元件的時候建立props物件,並宣告需要被傳參的屬性, 比如this.props = {id: ''}
  2. 編寫子元件的時候不通過原生customElements.define,而是使用封裝過的函式,比如defineComponent來註冊,這樣可以記錄元件名和對應的props屬性。
  3. 父元件在使用子元件的時候進行遍歷,找出子元件和對應的props物件。
  4. 將子元件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物件屬性的宣告方式,用“&”符號來表示回撥函式。 理清一下思路:

  1. 子元件類中宣告props的屬性為回撥函式,如 this.props = {onClick:'&'}
  2. 父元件初始化時,在模板上傳遞對應屬性, 如<child-compoennt on-click="click"></child-component>
  3. 根據子元件屬性值找到對應的父元件函式,然後將父元件函式繫結作用域並傳入。如childComponent.props.onClick = this.click.bind(this)
  4. 子元件中呼叫父元件函式, 如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);
  }
}
複製程式碼

總結

關於基類的詳細程式碼可以參考文末的倉庫地址,目前專案遵循的是按需新增原則,只實現了一些基礎的操作,並沒有把所有可能用到的指令寫完。 所以還不足以稱之為“框架”,只是給大家提供實現思路以及編寫原生程式碼的信心。

具體示例:github.com/yalishizhud…


作者資訊:朱德龍,人和未來高階前端工程師。

相關文章