天天看點

前端子產品化知識梳理

前端子產品化知識梳理

一、背景

作為前端開發,子產品化我們已經耳熟能詳,我們平時接觸到的 ES6 的 import,nodejs中的require他們有啥差別?

我們也聽過CommonJS、CMD、AMD、ES6子產品系統,這些都有什麼聯系呢?

本文将對這些問題進行歸納總結,可以對子產品化有個清晰的認識。

二、為何需要子產品化?

1. 起源

最開始 js 是沒有子產品化的概念的,就是普通的腳本語言放到 script 标簽裡,做些簡單的校驗,代碼量比較少。

随着ajax的出現,前端可以請求資料了,做的事情更多了,邏輯越來越複雜,就會出現很多問題。

1.1 全局變量沖突

因為大家的代碼都在一個作用域,不同人定義的變量名可能重複,導緻覆寫。

var num = 1; // 一個人聲明了
...
var num = 2; // 其他人又聲明了      
1.2 依賴關系管理麻煩

比如我們引入了3個js檔案,他們直接互相依賴,我們需要按照依賴關系從上到下排序。

<script src='./one.js'></script>
<script src='./two.js'></script>
<script src='./three.js'></script>      

如果檔案有十多個,我們需要理清楚依賴關系再手動按順序引入,會導緻後續代碼更加難以維護。

2. 早期解決方案

針對前面說的問題,其實也有一些響應的解決方案。

2.1 命名空間

命名空間是将一組實體、變量、函數、對象封裝在一個空間的行為。這裡展現了子產品化思想雛形,通過簡單的命名空間進行「塊兒」的切分,展現了分離和内聚的思想。著名案例 「YUI2」。

// 示例:
const car = {
  name: '小汽車',
  start: () => {
    console.log('start')
  },
  stop: () => {
    console.log('stop')
  }
}      

上面示例可以發現可能存在問題,比如我們修改了car的name,會導緻原有的name被更改

car.name = '測試'
console.log(car) // {name: '111', start: ƒ, stop: ƒ}      
2.2 閉包

再次提升子產品化的解決方案,利用閉包使污染的問題得到解決,更加純粹的内聚

moduleA = function() {
   var name = '小汽車';
   return {
      start: function (c){
         return name + '啟動';
      };
   }
}()      

上面示例中function内部的變量就對全局隐藏了,達到了封裝的目的。但是子產品名稱暴露在全局,還是存在命名沖突的問題。

下面這個基于 IIFE 和閉包實作的效果:

// moduleA.js
(function(global) {
  var name = '小汽車';
  function start() {};
  global.moduleA = { name, start };
})(window)      

上面表達式中的變量 name 不能直接從外部通路。

綜上,是以子產品化解決的問題有哪些:

  • 解決命名污染,全局污染,變量沖突等問題
  • 内聚私有,變量不能被外面通路到
  • 怎麼引入其它子產品,怎樣暴露出接口給其它子產品
  • 引入其他子產品可能存在循環引用的問題

三、主流子產品化解決方案

1. CommonJS

可以點選 CommonJS規範檢視相關介紹。

1)每個檔案就是一個子產品,有自己的作用域。在一個檔案裡面定義的變量、函數、類,都是私有的,對其他檔案不可見。

2)CommonJS規範規定,每個子產品内部,module變量代表目前子產品。這個變量是一個對象,它的exports屬性(即module.exports)是對外的接口。加載某個子產品,其實是加載該子產品的module.exports屬性。

3)require方法用于加載子產品。

1.1 加載子產品
var example = require('./example.js');
var config = require('config.js');
var http = require('http');      
1.2 對外暴露子產品
module.exports.example = function () {
  ...
}
module.exports = function(x){  
    console.log(x)
}      
1.3 Node.js的子產品化

說到CommonJS 我們要提一下 Node.js,Node.js的出現讓我們可以用JavaScript來寫服務端代碼,而 Node 應用由子產品組成,采用的是 CommonJS 子產品規範,當然并非完全按照CommonJS來,它進行了取舍,增加了一些自身的特性。

1)Node内部提供一個Module建構函數。所有子產品都是Module的執行個體,每個子產品内部,都有一個module對象,代表目前子產品。包含以下屬性:

  • module.id 子產品的識别符,通常是帶有絕對路徑的子產品檔案名。
  • module.filename 子產品的檔案名,帶有絕對路徑。
  • module.loaded 傳回一個布爾值,表示子產品是否已經完成加載。
  • module.parent 傳回一個對象,表示調用該子產品的子產品。
  • module.children 傳回一個數組,表示該子產品要用到的其他子產品。
  • module.exports 表示子產品對外輸出的值。

2)Node使用CommonJS子產品規範,内置的require指令用于加載子產品檔案。

3)第一次加載某個子產品時,Node會緩存該子產品。以後再加載該子產品,就直接從緩存取出該子產品的module.exports屬性。所有緩存的子產品儲存在require.cache之中。

// a.js
var name = 'Lucy'
exports.name = name
// b.js
var a = require('a.js')
console.log(a.name) // "Lucy"
a.name = "hello";
var b = require('./a.js')
console.log(b.name) // "hello"      

上面第一次加載以後修改了name值,第二次加載的時候列印的name是上次修改的,證明是從緩存中讀取的。

想删除子產品的緩存可以這樣:

delete require.cache[moduleName];      

4)CommonJS子產品的加載機制是,輸入的是被輸出的值的拷貝。也就是說,一旦輸出一個值,子產品内部的變化就影響不到這個值。請看下面這個例子。

// a.js
var counter = 3
exports.counter = counter
exports.addCounter = function(a){
    counter++
}
// b.js
var a = require('a.js')
console.log(a.counter) // 3
a.addCounter()
console.log(a.age) // 3      

這個例子說明a.js子產品加載以後,子產品内部的變化就影響不到a.counter了。這是因為a.counter是一個原始類型的值,會被緩存。除非寫成一個函數,才能得到内部變動後的值。

2.前端子產品化

前面所說的CommonJS規範,都是基于node來說的,是以CommonJS都是針對服務端的實作。為什麼呢?

因為CommonJS規範加載子產品是同步的,也就是說,隻有加載完成,才能執行後面的操作。由于Node.js主要用于伺服器程式設計,子產品檔案一般都已經存在于本地硬碟,是以加載起來比較快,不用考慮非同步加載的方式,是以CommonJS規範比較适用。

如果是浏覽器環境,要從伺服器端加載子產品,用CommonJS需要等子產品下載下傳完并運作後才能使用,将阻塞後面代碼的執行,這時就必須采用非同步模式,是以浏覽器端一般采用AMD規範,解決異步加載的問題。

2.1 AMD(Asynchronous Module Definition)和 RequireJS

AMD是異步加載子產品規範。

RequireJS是一個工具庫。主要用于用戶端的子產品管理。它可以讓用戶端的代碼分成一個個子產品,實作異步或動态加載,進而提高代碼的性能和可維護性。它的子產品管理遵守AMD規範。

2.1.1 子產品定義

1)獨立子產品(不需要依賴任何其他子產品)​

//獨立子產品定義
define({
  method1: function() {}
  method2: function() {}
});  
//或者
define(function(){
  return {
    method1: function() {},
    method2: function() {},
  }
});      

2)非獨立子產品(需要依賴其他子產品)​

define(['module1', 'module2'], function(m1, m2){
    return {
        method: function() {
            m1.methodA();
            m2.methodB();
        }
    };
});      

define方法:

  • 第一個參數是一個數組,它的成員是目前子產品所依賴的子產品
  • 第二個參數是一個函數,目前面數組的所有成員加載成功後,它将被調用。它的參數與數組的成員一一對應,這個函數必須傳回一個對象,供其他子產品調用
2.1.2 子產品調用

require方法用于調用子產品。它的參數與define方法類似。

require(['a', 'b'], function ( a, b ) {
    a.doSomething();
});      

define和require這兩個定義子產品、調用子產品的方法,合稱為AMD模式。它的子產品定義的方法非常清晰,不會污染全局環境,能夠清楚地顯示依賴關系。

2.1.3 require.js的config方法

require方法本身也是一個對象,它帶有一個config方法,用來配置require.js運作參數。

require.config({
    paths: {
        jquery: [
            '//cdnjs.cloudflare.com/ajax/libs/jquery/2.0.0/jquery.min.js',
            'lib/jquery'
        ]
    }
});      

參數對象包含:

  • paths 指定各個子產品的位置
  • baseUrl 指定本地子產品位置的基準目
  • shim 用來幫助require.js加載非AMD規範的庫。
2.1.3 CommonJS 和AMD的對比
  • CommonJS一般用于服務端比如node,AMD一般用于浏覽器環境,并且允許非同步加載子產品,可以根據需要動态加載子產品
  • CommonJS和AMD都是運作時加載
2.1.4 運作時加載

簡單來講,就是CommonJS和AMD都隻能在運作時才能确定一些東西,是以是運作時加載。比如下面的例子:

// CommonJS子產品
let { stat, exists, readFile } = require('fs');
// 等同于
let _fs = require('fs');
let stat = _fs.stat;
let exists = _fs.exists;
let readfile = _fs.readfile;      

上面代碼其實是整體加載了fs子產品,生成了一個_fs 的對象,然後從這個對象上讀取三個方法。因為隻有運作時才能得到這個對象,是以成為運作時加載。

下面是AMD的例子:​

// AMD
define('a', function () {
  console.log('a 加載')
  return {
    run: function () { console.log('a 執行') }
  }
})
define('b', function () {
  console.log('b 加載')
  return {
    run: function () { console.log('b 執行') }
  }
})
//運作
require(['a', 'b'], function (a, b) {
  console.log('main 執行')  
  a.run()
  b.run()
})
// 運作結果:
// a 加載
// b 加載
// main 執行
// a 執行
// b 執行      

我們可以看到執行的時候,a和b先加載,後面才從main開始執行。是以require一個子產品的時候,子產品會先被加載,并傳回一個對象,并且這個對象是整體加載的,也就是常說的 依賴前置。

2.2 CMD(Common Module Definition) 和 SeaJS

在 Sea.js 中,所有 JavaScript 子產品都遵循 CMD(Common Module Definition) 子產品定義規範。

Sea.js和 RequireJS 差別在哪裡呢?這裡有個官方給出的差別。

RequireJS 遵循 AMD(異步子產品定義)規範,Sea.js 遵循 CMD (通用子產品定義)規範。規範的不同,導緻了兩者 api 不同。Sea.js 更貼近 CommonJS Modules/1.1 和 Node Modules 規範。

這裡對AMD和CMD做個簡單對比:

  1. AMD 定義子產品時,指定所有的依賴,依賴子產品加載後會執行回調并通過參數傳到這回調方法中:
define(['module1', 'module2'], function(m1, m2) {
       ...
    });      
  1. CMD規範中一個子產品就是一個檔案,子產品更接近于Node對CommonJS規範的定義:

define(factory); // factory 可以是一個函數,也可以是一個對象或字元串。

factory 為函數時,表示是子產品的構造方法。執行該構造方法,可以得到子產品向外     提供的接口。factory 方法在執行時,預設會傳入三個參數:require、exports 和     module:

define(function(require, exports, module) {
  // 子產品代碼
});      

其中,require 是一個方法,接受 子產品辨別 作為唯一參數,用來擷取其他子產品提供的接口。需要依賴子產品時,随時調用require( )引入即可。

define(function(require, exports) {
      // 擷取子產品 a 的接口
      var a = require('./a');
      // 調用子產品 a 的方法
      a.doSomething();
    });      

下面示範一下CMD的執行

define('a', function (require, exports, module) {
  console.log('a 加載')
  exports.run = function () { console.log('a 執行') }
})
define('b', function (require, exports, module) {
  console.log('b 加載')
  exports.run = function () { console.log('b 執行') }
})
define('main', function (require, exports, module) {
  console.log('main 執行')
  var a = require('a')
  a.run()
  var b = require('b')
  b.run()
})
// main 執行
// a 加載
// a 執行
// b 加載
// b 執行      

看到執行結果,會在真正需要使用(依賴)子產品時才執行該子產品,感覺這好像和我們認知的一樣,畢竟我也是這麼想的執行順序,但是看前面AMD的執行結果,是先把a和b都加載以後,才開始執行main的。

是以相較于AMD的依賴前置、提前執行,CMD則推崇依賴就近、延遲執行。

2.3 UMD(Universal Module Definition) 通用子產品規範

可以看到其實相容模式是将幾種常見子產品定義方式都做了相容處理。

(function (global, factory) {
  typeof exports === 'object' && typeof module !== 'undefined'  
  ? factory(require('lodash'))  // node, commonJS
  : typeof define === 'function' && define.amd  
      ? define(['lodash'], factory) // amd cmd
      : (
          global = typeof globalThis !== 'undefined'  
              ? globalThis  
              : global || self, factory(global.lodash)
          );
}(this, (function (lodash) { 'use strict';
    ...
})));      
2.4 ES6 子產品

子產品功能主要由兩個指令構成:export和import。export指令用于規定子產品的對外接口,import指令用于輸入其他子產品提供的功能。

2.4.1 子產品導出

一個子產品就是一個獨立的檔案。該檔案内部的所有變量,外部無法擷取。如果你希望外部能夠讀取子產品内部的某個變量(函數或類),就必須使用export關鍵字輸出該變量(函數或類)。

1) 導出變量 和 函數

// a.js
// 導出變量
export var name = 'Michael';
export var year = 2010;
// 或者  
// 也可以這樣導出
var name = 'Michael';
export { name, year };
複制代碼
// 導出函數
export function multiply(x, y) {
  return x * y;
};      

2) as的使用

通常情況下,export輸出的變量就是本來的名字,但是可以使用as關鍵字重命名。

function v1() { ... }
function v2() { ... }
export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};      
2.4.2 子產品引入

1) 使用export指令定義了子產品的對外接口以後,其他 JS 檔案就可以通過import指令加載這個子產品。

// 一般用法
import { name, year} from './a.js';
// as 用法
import { name as userName } from './a.js';      

注意:

import指令具有提升效果,會提升到整個子產品的頭部,首先執行。

下面的代碼不會報錯,因為import的執行早于foo的調用。這種行為的本質是,import指令是編譯階段執行的(後面對比CommonJs時會講到),在代碼運作之前。

foo();
import { foo } from 'my_module';      

2)整體子產品加載

//user.js
export name = 'lili';
export age = 18;
//逐一加載
import { age, name } from './user.js';
//整體加載
import * as user from './user.js';
console.log(user.name);
console.log(user.age);      

3)export default 指令

export default指令用于指定子產品的預設輸出。顯然,一個子產品隻能有一個預設輸出,是以export default指令隻能使用一次。是以,import指令後面才不用加大括号,因為隻可能唯一對應export default指令。

export default function foo() { // 輸出
  // ...
}
import foo from 'foo'; // 輸入      

注意:正是因為export default指令其實隻是輸出一個叫做default的變量,是以它後面不能跟變量聲明語句。

// 正确
var a = 1;
export default a;
// 錯誤
// `export default a`的含義是将變量`a`的值賦給變量`default`。
// 是以,這種寫法會報錯。
export default var a = 1;      
2.4.3 ES6子產品、CommonJS和AMD子產品差別

1) 編譯時加載 和 運作時加載

ES6 子產品的設計思想是盡量的靜态化,使得編譯時就能确定子產品的依賴關系,以及輸入和輸出的變量。是以ES6是編譯時加載。CommonJS 和 AMD 子產品,都隻能在運作時确定這些東西。

比如,CommonJS 子產品就是對象,輸入時必須查找對象屬性。

// CommonJS子產品  
let { stat, exists, readFile } = require('fs');  
// 等同于  
let _fs = require('fs');  
let stat = _fs.stat;  
let exists = _fs.exists;  
let readfile = _fs.readfile;
// -----------------
// ES6子產品
import { stat, exists, readFile } from 'fs';      

CommonJS和ES6子產品加載差別:

  • CommonJS 實質是整體加載fs子產品(即加載fs的所有方法),生成一個對象(_fs),然後再從這個對象上面讀取 3 個方法。這種加載稱為“運作時加載”,因為隻有運作時才能得到這個對象,導緻完全沒辦法在編譯時做“靜态優化”。
  • ES6子產品 實質是從fs子產品加載 3 個方法,其他方法不加載。這種加載稱為“編譯時加載 ”或者靜态加載,即 ES6 可以在編譯時就完成子產品加載,效率要比 CommonJS 子產品的加載方式高。

2) 值拷貝 和 引用拷貝

  1. 前面 1.3 Node.js子產品化提到了 CommonJS是值拷貝,子產品加載完并輸出一個值,子產品内部的變化就影響不到這個值。因為這個值是一個原始類型的值,會被緩存。
  2. ES6 子產品的運作機制與 CommonJS 不一樣。JS 引擎對腳本靜态分析的時候,遇到子產品加載指令import,就會生成一個隻讀引用。等到腳本真正執行時,再根據這個隻讀引用,到被加載的那個子產品裡面去取值。換句話說,ES6 的import有點像 Unix 系統的“符号連接配接”,原始值變了,import加載的值也會跟着變。是以,ES6 子產品是動态引用,并且不會緩存值,子產品裡面的變量綁定其所在的子產品。
// a.js
export let counter = 3;
export function addCounter() {
  counter++;
}
// b.js
import { counter, addCounter } from './a';
console.log(counter); // 3
addCounter();
console.log(counter); // 4      

ES6 子產品輸入的變量counter是活的,完全反應其所在子產品a.js内部的變化。

感謝你的閱讀,祝程式設計愉快!

學習更多技能請點選下方公衆号