天天看點

一文徹底搞懂JavaScript前端5大子產品化規範及其差別

一文徹底搞懂JavaScript前端5大子產品化規範及其差別

在開發以及面試中,總是會遇到有關子產品化相關的問題,始終不是很明白,不得要領,例如以下問題,回答起來也是模棱兩可,希望通過這篇文章,能夠讓大家了解十之一二,首先抛出問題:

  • 導出子產品時使用module.exports/exports或者export/export default;
  • 有時加載一個子產品會使用require奇怪的是也可以使用import??它們之間有何差別呢?

于是有了菜鳥解惑的搜喽過程。。。。。。

子產品化規範:即為 JavaScript 提供一種子產品編寫、子產品依賴和子產品運作的方案。降低代碼複雜度,提高解耦性

Script 标簽

其實最原始的 JavaScript 檔案加載方式,就是Script 标簽,如果把每一個檔案看做是一個子產品,那麼他們的接口通常是暴露在全局作用域下,也就是定義在 window 對象中,不同子產品的接口調用都是一個作用域中,一些複雜的架構,會使用命名空間的概念來組織這些子產品的接口。

缺點:

  1. 污染全局作用域
  2. 開發人員必須主觀解決子產品和代碼庫的依賴關系
  3. 檔案隻能按照script标簽的書寫順序進行加載
  4. 在大型項目中各種資源難以管理,長期積累的問題導緻代碼庫混亂不堪

預設情況下,浏覽器是同步加載 JavaScript 腳本,即渲染引擎遇到<script>标簽就會停下來,等到執行完腳本,再繼續向下渲染。如果是外部腳本,還必須加入腳本下載下傳的時間。

如果腳本體積很大,下載下傳和執行的時間就會很長,是以造成浏覽器堵塞,使用者會感覺到浏覽器“卡死”了,沒有任何響應。

這顯然是很不好的體驗,是以浏覽器允許腳本異步加載。

<script src="path/to/myModule.js" defer></script>
<script src="path/to/myModule.js" async></script>      

<script>标簽添加defer或async屬性,腳本就會異步加載。渲染引擎遇到這一行指令,就會開始下載下傳外部腳本,但不會等它下載下傳和執行,而是直接執行後面的指令。

defer:要等到整個頁面在記憶體中正常渲染結束,才會執行;多個腳本時,按順序執行

async:一旦下載下傳完,渲染引擎就會中斷渲染,執行這個腳本再繼續渲染。多個腳本時,不能保證按執行順序

總結一句話:defer是“渲染完再執行”,async是“下載下傳完就執行”。

CommonJS規範(同步加載子產品)

  • 伺服器端實作:**Node.js **
  • 浏覽器端實作:**Browserify **,也稱為Commonjs的浏覽器的打包工具
  • 通過require方法同步加載所依賴的子產品,通過exports或module.exports導出需要暴露的資料。一個檔案就是一個子產品
  • CommonJS 規範包括了子產品(modules)、包(packages)、系統(system)、二進制(binary)、控制台(console)、編碼(encodings)、檔案系統(filesystems)、套接字(sockets)、單元測試(unit testing)等部分。

加載子產品

使用require函數 加載子產品(即被依賴子產品的 module.exports對象)。

  1. 按路徑加載子產品
  2. 通過查找 node_modules 目錄加載子產品
  3. 加載緩存:Node.js 是根據實際檔案名緩存,而不是 require() 提供的參數緩存的,如 require('express') 和 require('./node_modules/express')加載兩次,也不會重複加載,盡管兩次參數不同,解析到的檔案卻是同一個。
  4. 核心子產品擁有最高的加載優先級,換言之如果有子產品與其命名沖突,Node.js 總是會加載核心子產品。
  5. 更多關于require函數的用法和特點,部落客此前另外總結過一篇博文,NodeJs 入門到放棄 — 入門基本介紹(一)

導出子產品

exports.屬性 = 值

exports.方法 = 函數

  • Node.js 為每個子產品提供一個 exports 變量,指向 module.exports。相對于在每個子產品頭部,有一行這樣的指令:var exports = module.exports;
  • exports對象 和 module.exports對象,指同一個記憶體空間, module.exports對象才是真正的暴露對象
  • 而exports對象 是 module.exports對象的引用,不能改變指向,隻能添加屬性和方法,若直接改變exports 的指向,等于切斷了 exports 與 module.exports 的聯系,傳回空對象
  • console.log(module.exports === exports); // true

另外的用法:

// singleobjct.js


function Hello() {
    var name;
    this.setName = function (thyName) {
        name = thyName;
    };
    this.sayHello = function () {
        console.log('Hello ' + name);
    };
}


exports.Hello = Hello;      

此時擷取 Hello 對象require('./singleobject').Hello,略顯備援,可以用下面方法簡化。

// hello.js
function Hello() {
  var name;
  this.setName = function(thyName) {
    name = thyName;
  };
  this.sayHello = function() {
    console.log('Hello ' + name);
  };
}
module.exports = Hello;      

就可以直接獲得這個對象:

// gethello.js
var Hello = require('./hello');
hello = new Hello();
hello.setName('Yu');
hello.sayHello();      

​以下同樣一段代碼(圖為對應的目錄結構),分别運作在伺服器端和浏覽器端,看看有神馬差別?

一文徹底搞懂JavaScript前端5大子產品化規範及其差別
// module1.js
module.exports = {
  foo(){
    console.log('module1的foo()函數運作了');
  }
}


// module2.js
module.exports = function() {
    console.log('module2的foo()函數運作了');
}


// module3.js
exports.foo = function () {
  console.log('module3的foo()函數運作了');
}


exports.bar = function () {
  console.log('module3的bar()函數運作了');
}      
// main.js
let module1 = require('./module1')
let module2 = require('./module2')
let module3 = require('./module3') 


module1.foo()


module2()


module3.foo()
module3.bar()      

伺服器端實作 NodeJs

cd響應的目錄,直接指令行執行:node main.js

一文徹底搞懂JavaScript前端5大子產品化規範及其差別

浏覽器端實作 Browserify

Browserify 本身也是一個 NodeJS 子產品,npm安裝後可以使用 browserify 指令。

分析檔案中require 方法調用來遞歸查找所依賴的其他子產品。把輸入檔案所依賴的所有子產品檔案打包成單個檔案并輸出。

npm安裝指令 :npm install -g browserify

打包指令:browserify 入口檔案 -o 打封包件 如:browserify ./src/main.js -o ./dist/build.js

想要運作在浏覽器端,要有一個入口的hmtl檔案。建立index.html,并引入上述打包生成的build.js檔案 <script src="./dist/build.js"></script>。

一文徹底搞懂JavaScript前端5大子產品化規範及其差別

CommonJS 特點

  1. 同步加載方式,适用于服務端,因為子產品都放在伺服器端,對于服務端來說子產品加載較快,不适合在浏覽器環境中使用,因為同步意味着阻塞加載。
  2. 所有代碼都運作在子產品作用域,不會污染全局作用域。
  3. 子產品可以多次加載,但隻會在第一次加載時運作一次,然後運作結果就被緩存了,以後再加載,就直接讀取緩存結果。
  4. 子產品加載的順序,按照其在代碼中出現的順序。

AMD(Asynchronous Module Definition)

采用異步方式加載子產品,子產品的加載不影響它後面語句的運作。所有依賴這個子產品的語句,都定義在一個回調函數中,等到加載完成之後,這個回調函數才會運作。推崇依賴前置

require.js 是目前 AMD 規範最熱門的一個實作

AMD 也采用 require語句加載子產品,但是不同于 CommonJS,它要求兩個參數:require([module], callback);

  • [module]:是一個數組,成員就是要加載的子產品
  • callback:加載成功之後的回調函數;
require(['math'], function (math) {
  math.add(2, 3);
});      

建立子產品 及 規範子產品加載

子產品必須采用 define() 函數來定義。

  1. 若一個子產品不依賴其他子產品。可以直接定義在 define() 函數中。
// math.js


define(function (){
 var add = function (x,y){
  return x+y;
 };
 return {
  add: add
 };
});      

若這個子產品還依賴其他子產品,那麼 define() 函數的第一個參數,必須是一個數組,指明該子產品的依賴性。當 require() 函數加載test子產品時,就會先加載 math.js 子產品。

// dataService.js


define(['math'], function (math) {
  function doSomething() {
    let result = math.add(2, 9);
    console.log(result);
  }
  return {
    doSomething
  };
});      

設定一個主子產品,統一排程目前項目中所有依賴子產品

// main.js


(function () {
  require.config ({
    // baseUrl:'',    
    paths:{   
      dataService:'./dataService',
      math:'./math'
    }
  })
  require(['dataService'], function (dataService) {
    dataService.doSomething()
  });


})();      

在index.html中引入require.js,并設定data-main入口主子產品

<!-- index/html -->


<script data-main="./js/main.js" src="./js/require.js"></script>      

來來來,浏覽器看看效果了:列印出了兩數字相加的結果

一文徹底搞懂JavaScript前端5大子產品化規範及其差別
  1. 本案例中所有源碼,目錄結構及每個子產品的作用,如下圖所示(源碼同上1234步驟):
一文徹底搞懂JavaScript前端5大子產品化規範及其差別

加載非規範的子產品

理論上require.js加載的子產品,必須是按照 AMD 規範用 define() 函數定義的子產品。

但實際上,雖然已經有一部分流行的函數庫(比如 jQuery )符合 AMD 規範,更多的庫并不符合。那麼require.js 如何能夠加載非規範的子產品呢?

這樣的子產品在用 require() 加載之前,要先用 require.config()方法,定義它們的一些特征。

例如,underscore 和 backbone 這兩個庫,都沒有采用 AMD 規範編寫。如果要加載的話,必須先定義它們的特征。

require.config({
 shim: {
  'underscore': {
   exports: '_'
  },
  'backbone': {
   deps: ['underscore', 'jquery'],
   exports: 'Backbone'
   }
 }
});      

require.config() 接受一個配置對象,這個對象有一個 shim 屬性,專門用來配置不相容的子產品。每個子產品要定義:

  • exports :輸出的變量名,表示這個子產品外部調用時的名稱;
  • deps:數組,表示該子產品的依賴性。

如jQuery 的插件還可以這樣定義:

shim: {
 'jquery.scroll': {
  deps: ['jquery'],
  exports: 'jQuery.fn.scroll'
 }
}      

AMD特點

  1. AMD允許輸出的子產品相容CommonJS
  2. 異步并行加載,不阻塞 DOM 渲染。
  3. 推崇依賴前置,也就是提前執行(預執行),在子產品使用之前就已經執行完畢。

CMD(Common Module Definition)

  • CMD 是通用子產品加載,要解決的問題與 AMD 一樣,隻不過是對依賴子產品的執行時機不同 ,推崇就近依賴。
  • sea.js 是 CMD 規範的一個實作代表庫
  • 定義子產品使用全局函數define,接收一個 factory 參數,可以是一個函數,也可以是一個對象或字元串;

1、factory是函數時,有三個參數,function(require, exports, module):

  • require:函數用來擷取其他子產品提供的接口require(子產品辨別ID)
  • exports:對象用來向外提供子產品接口
  • module :對象,存儲了與目前子產品相關聯的屬性和方法
// 定義 a.js 子產品,同時可引入其他依賴子產品,及導出本子產品
define(function(require, exports, module) {


  var $ = require('jquery.js')


  exports.price= 200;  
});      

2、factory為對象、字元串時,表示子產品的接口就是該對象、字元串。比如可以定義一個 JSON 資料子產品:

// 定義 foo.js
define({"foo": "bar"});


 // 導入使用
define(function(require, exports, module) {


  var obj = require('foo.js')


  console.log(obj)   // {foo: "bar"}
});      

3、下面通過一個案例分析,深入了解一下CMD子產品化規範,具體的用法:

  • 定義1,2,3,4,四個簡單子產品,定義主子產品main.js, 以及一個index.html
  • cmd從文法上分析,結合了AMD子產品定義的特點,同時又沿用了CommonJs 子產品導入和導出的特點
  • 由于代碼比較雜,是以還是看圖了解一下吧,圖上均有标注每個檔案的用途,圖二為浏覽器執行效果:
一文徹底搞懂JavaScript前端5大子產品化規範及其差別
一文徹底搞懂JavaScript前端5大子產品化規範及其差別

AMD 與 CMD 的差別

  1. AMD 是提前執行,CMD 是延遲執行。
  2. AMD 是依賴前置,CMD 是依賴就近。
// AMD 
define(['./a', './b'], function(a, b) {  // 在定義子產品時 就要聲明其依賴的子產品
    a.doSomething()
    // ....
    b.doSomething()
    // ....
})


// CMD
define(function(require, exports, module) {
   var a = require('./a')
   a.doSomething()
   // ... 


   var b = require('./b') // 可以在用到某個子產品時 再去require
   b.doSomething()
   // ... 
})      

UMD(Universal Module Definition)

  • UMD是AMD和CommonJS的糅合
  • UMD的實作很簡單:
  1. 先判斷是否支援Node.js子產品(exports是否存在),存在則使用Node.js子產品模式。
  2. 再判斷是否支援AMD(define是否存在),存在則使用AMD方式加載子產品。
  3. 前兩個都不存在,則将子產品公開到全局(window或global)。
(function (window, factory) {
    if (typeof exports === 'object') {


        module.exports = factory();
    } else if (typeof define === 'function' && define.amd) {


        define([],factory);
    } else {


        window.eventUtil = factory();
    }
})(this, function () {
  return {};
});      

ES6子產品化

ES6 子產品的設計思想,是盡量的靜态化,使得編譯時就能确定子產品的依賴關系,以及輸入和輸出的變量。

ES6 中,import引用子產品,使用export導出子產品。預設情況下,Node.js預設是不支援import文法的,通過babel項目将 ES6 子產品 編譯為 ES5 的 CommonJS。

是以Babel實際上是将import/export翻譯成Node.js支援的require/exports。

// 導入
import Vue from 'vue'import App from './App'
// 導出
function v1() { ... }function v2() { ... }export {
  v1 as streamV1,
  v2 as streamV2,
  v2 as streamLatestVersion
};export function multiply() {...};export var year = 2018;export default ...      

剛剛講到使用babel将import編譯為nodejs支援的require,即可使用node指令執行,而浏覽器預設是不支援import和require的,此時還需要借助另一個工具,即上文中,在講述CommonJs時,提到的,browserify,下面請看完整的案例分析:

1、安裝必要包,babel,及browserify

  • npm install babel-cli -g
  • npm install babel-preset-es2015 --save-dev
  • npm install browserify -g

2、建立.babelrc檔案,并設定編譯格式為es2015

3、自定義一個子產品,導出資料,并在主子產品中加載執行

4、babel ./src -d ./build 指令将import編譯為require

5、browserify ./build/main.js -o ./dist/main.js 編譯為浏覽器識别文法,最終引入index.html檔案中。

一文徹底搞懂JavaScript前端5大子產品化規範及其差別

6、編譯指令及浏覽器運作效果:

一文徹底搞懂JavaScript前端5大子產品化規範及其差別

子產品化規範大總結

一文徹底搞懂JavaScript前端5大子產品化規範及其差別

問題回歸:"require"與"import"的差別

說了這麼多,還是要回到文章一開始提到的問題,"require"與"import"兩種引入子產品方式,到底有神馬差別,大緻可以分為以下幾個方面(可能總結的也不是很全面):

寫法上的差別

require/exports 的用法隻有以下三種簡單的寫法:

const fs = require('fs')
exports.fs = fs
module.exports = fs      

import/export 的寫法就多種多樣:

import fs from 'fs'
import {default as fs} from 'fs'
import * as fs from 'fs'
import {readFile} from 'fs'
import {readFile as read} from 'fs'
import fs, {readFile} from 'fs'


export default fs
export const fs
export function readFile
export {readFile, read}
export * from 'fs'      

輸入值的差別

require輸入的變量,基本類型資料是指派,引用類型為淺拷貝,可修改

import輸入的變量都是隻讀的,如果輸入 a 是一個對象,允許改寫對象屬性。

import {a} from './xxx.js'


a = {}; // Syntax Error : 'a' is read-only;


a.foo = 'hello'; // 合法操作      

執行順序

require:不具有提升效果,到底加載哪一個子產品,隻有運作時才知道。

const path = './' + fileName;
const myModual = require(path);      

import:具有提升效果,會提升到整個子產品的頭部,首先執行。import的執行早于foo的調用。本質就是import指令是編譯階段執行的,在代碼運作之前。

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

import()函數:ES2020提案引入,支援動态加載子產品。import()函數接受一個參數,指定所要加載的子產品的位置,參數格式同import指令,兩者差別主要是import()為動态加載。

可用于按需加載、條件加載、動态的子產品路徑等。

它是運作時執行,也就是說,什麼時候運作到這一句,就會加載指定的子產品,傳回一個 Promise 對象。

import()加載子產品成功以後,該子產品會作為一個對象,當作then方法的參數。可以使用對象解構指派,擷取輸出接口。

// 按需加載
button.addEventListener('click', event => {
  import('./dialogBox.js')
  .then({export1, export2} => {   // export1和export2都是dialogBox.js的輸出接口,解構獲得
    // do something...
  })
  .catch(error => {})
});


// 條件加載
if (condition) {
  import('moduleA').then(...);
} else {
  import('moduleB').then(...);
}




// 動态的子產品路徑
import(f()).then(...);    // 根據函數f的傳回結果,加載不同的子產品。      

使用表達式和變量

require:很顯然是可以使用表達式和變量的

let a = require('./a.js')
a.add()


let b = require('./b.js')
b.getSum()      

import靜态執行,不能使用表達式和變量,因為這些都是隻有在運作時才能得到結果的文法結構。

// 報錯
import { 'f' + 'oo' } from 'my_module';


// 報錯
let module = 'my_module';
import { foo } from module;


// 報錯
if (x === 1) {
  import { foo } from 'module1';
} else {
  import { foo } from 'module2';
}      

而require/exports 和 import/export 本質上的差別,實際上也就是CommonJS規範與ES6子產品化的差別

1、浏覽器在不做任何處理時,預設是不支援import和require

2、babel會将ES6子產品規範轉化成Commonjs規範

3、webpack、gulp以及其他建構工具會對Commonjs進行處理,使之支援浏覽器環境

它們有三個重大差異。

  1. CommonJS 子產品輸出的是一個值的拷貝,ES6 子產品輸出的是值的引用。
  2. CommonJS 子產品是運作時加載,ES6 子產品是編譯時輸出接口。
  3. CommonJS 子產品的​

    ​require()​

    ​是​

    ​同步​

    ​加載子產品,ES6 子產品的​

    ​import​

    ​指令是​

    ​異步​

    ​加載,有一個獨立的子產品依賴的解析階段。

導緻第二個差異是因為 CommonJS 加載的是一個對象(即module.exports屬性),該對象隻有在腳本運作完才會生成。

而 ES6 子產品不是對象,它的對外接口隻是一種靜态定義,在代碼靜态解析階段就會生成

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 子產品不是對象,而是通過export指令顯式指定輸出的代碼,再通過import指令輸入。
  • 可以在編譯時就完成子產品加載,引用時隻加載需要的方法,其他方法不加載。效率要比 CommonJS 子產品的加載方式高。
import { stat, exists, readFile } from 'fs';      

繼續閱讀