1. 程式人生 > >ES6(十: import 模組載入)

ES6(十: import 模組載入)

(一)設計思想

ES6模組的設計思想,是儘量的靜態化,使得編譯時就能確定模組的依賴關係,以及輸入和輸出的變數。

CommonJS和AMD(現在基本快被放棄的載入方式)模組,都只能在執行時確定這些東西。比如, CommonJS模組就是物件,輸入時必須查詢物件屬性

// CommonJS模組
let { stat, exists, readFile } = require('fs');

// 等同於
let _fs = require('fs');
let stat = _fs.stat, exists = _fs.exists, readfile = _fs.readfile;

上面程式碼的實質是整體載入fs 模組(即載入fs 的所有方法),生成一個物件(_fs ),然後再從這個物件上面讀取3個方法。這種載入稱為“執行時載入”,因為只有執行時才能得到這個物件,導致完全沒辦法在編譯時做“靜態優化”。

ES6模組不是物件,而是通過export 命令顯式指定輸出的程式碼,輸入時也採用靜態命令的形式。

// ES6模組
import { stat, exists, readFile } from 'fs';

上面程式碼的實質是從fs 模組載入3個方法,其他方法不載入。這種載入稱為“編譯時載入”,即ES6可以在編譯時就完成模組編譯,效率要比CommonJS模組的載入方式高。
當然,這也導致了沒法引用ES6模組本身,因為它不是物件。

(二)export 命令

一個模組就是一個獨立的檔案。該檔案內部的所有變數,外部無法獲取。如果你希望外部能夠讀取模組內部的某個變數,就必須使用export 關鍵字輸出該變數。

下面是一個JS檔案,裡面使用export 命令輸出變數。

因為不推薦單獨每個都命令輸出,所以建議在底部一起命令輸出。

// profile.js
var firstName = 'Michael';
var lastName = 'Jackson';
var year = 1958;
export {firstName, lastName, year};

export命令除了輸出變數,還可以輸出函式或類(class)。

export function multiply (x, y) {
    return x * y;
};

上面程式碼對外輸出一個函式multiply 。

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

function v1() { ... }
function v2() { ... }
export {
    v1 as streamV1,
    v2 as streamV2,
    v2 as streamLatestVersion
};

上面程式碼使用as 關鍵字,重新命名了函式v1 和v2 的對外介面。重新命名後, v2 可以用不同的名字輸出兩次。

(三)import 命令

(1)import 命令接受一個物件(用大括號表示)

裡面指定要從其他模組匯入的變數名。大括號裡面的變數名,必須與被匯入模組(profile.js )對外介面的名稱相同。

// main.js
import {firstName, lastName, year} from './profile';

(2)為輸入的變數重新取一個名字

import命令要使用as 關鍵字,將輸入的變數重新命名。

import { lastName as surname } from './profile';

(3)import 命令具有提升效果

會提升到整個模組的頭部,首先執行。

foo();
import { foo } from 'my_module'; // 會提升到頂部

(4)import 語句會執行所載入的模組

載入所以模組,但是不會輸出任何值。

import 'lodash'
import 'style.scss'

(四)模組的整體載入

這種用法多用於常量定義方式(當然對函式,類都可以使用)。

// profile.js
const firstName = 'Michael';
const lastName = 'Jackson';
const year = 1958;
export {firstName, lastName, year};

分別用兩種載入方式舉例

// main.js (逐一載入)
import { firstName, lastName } from './circle';
console.log("姓名: " + firstName);

// main.js (整體載入)
import * as userName from './circle';
console.log("姓名: " + userName.firstName);

(五)export default命令

使用import 命令的時候,開發者需要知道所要載入的變數名或函式名,否則無法載入。

為了解決這種情況,就要用到export default 命令,為模組指定預設輸出。

當然,一個模組只能有一個預設輸出,因此export deault 命令只能使用一次。
所以, import 命令後面才不用加大括號,因為只可能對應一個方法。

export default 命令用在匿名函式前

// export-default.js
export default function () {
    console.log('foo');
}

// import-default.js
import customName from './export-default';
customName(); // 'foo

上面程式碼就是將一個函式定義成預設,然後輸出。不需要知道函式名,而且import後面不加大括號。

export default 命令用在非匿名函式前

// export-default.js
export default function foo() {
    console.log('foo');
}
// 或者寫成
function foo() {
    console.log('foo');
}
export default foo;

上面程式碼中, foo 函式的函式名foo ,在模組外部是無效的。載入的時候,視同匿名函式載入。

下面比較一下預設輸出和正常輸出

// 輸出
export default function crc32() {
    //TODO
}
// 輸入
import crc32 from 'crc32';
// 輸出
export function crc32() {
    //TODO
};
// 輸入
import { crc32 } from 'crc32';

在一條import語句中,同時輸入預設方法和其他變數

可以寫成下面這樣。

import customName, { otherMethod } from './export-default';

小結

本質上, export default 就是輸出一個叫做default 的變數或方法,然後系統允許你為它取任意名字。

(六)解析ES6模組載入實質

前言:

CommonJS模組輸出的是一個值的拷貝,而ES6模組輸出的是值的引用。

CommonJS模組輸出的是被輸出值的拷貝,也就是說,一旦輸出一個值,模組內部的變化就影響不到這個值。

// 01-require-lib.js

module.exports = {
    number: 3,
    incCounter: function () {
        this.number++;
    }
};
// 01-require.js
const lib = require('./01-require-lib');
const number = lib.number;
const incCounter = lib.incCounter;

console.log(number); // 3
incCounter();
console.log(number); // 3

上面程式碼說明, counter 輸出以後, 01-require-lib.js 模組內部的變化就影響不到number值。

模組載入命令import 時,不會去執行模組,而是隻生成一個動態的只讀引用。
等到真的需要用到時,再到模組裡面去取值,

換句話說, ES6的輸入有點像Unix系統的”符號連線“,原始值變了,輸入值也會跟著變。因此, ES6模組是動態引用,並且不會快取值,模組裡面的變數繫結其所在的模組。

// lib.js
export let number= 3;
export function incCounter() {
    number++;
}
// main.js
import { counter, incCounter } from './lib';
console.log(counter); // 3
incCounter();
console.log(counter); // 4

上面程式碼說明, ES6模組輸入的變數counter 是活的,完全反應其所在模組lib.js 內部的變化。

再舉一個出現在export 一節中的例子。

// m1.js
export var foo = 'bar';
setTimeout(() => foo = 'baz', 500);

// m2.js
import {foo} from './m1.js';
console.log(foo); // bar
setTimeout(() => console.log(foo), 500);

上面程式碼中, m1.js 的變數foo ,在剛載入時等於bar ,過了500毫秒,又變為等於baz 。

(七)迴圈載入

CommonJS模組的載入原理

CommonJS的一個模組,就是一個指令碼檔案。 require 命令第一次載入該指令碼,就會執行整個指令碼,然後在記憶體生成一個物件。

{
  id: '...',
  exports: { ... },
  loaded: true,
  ...
}

上面程式碼中,該物件的id 屬性是模組名, exports 屬性是模組輸出的各個介面, loaded 屬性是一個布林值,表示該模組的指令碼是否執行完畢。其他還有很多屬性,這裡都省略了。

以後需要用到這個模組的時候,就會到exports 屬性上面取值。即使再次執行require 命令,也不會再次執行該模組,而是到快取之中取值。

也就是說, CommonJS模組無論載入多少次,都只會在第一次載入時執行一
次,以後再載入,就返回第一次執行的結果,除非手動清除系統快取。

CommonJS模組的迴圈載入

CommonJS模組的重要特性是載入時執行,即指令碼程式碼在require 的時候,就會全部執行。

一旦出現某個模組被”迴圈載入”,就只輸出已經執行的部分,還未執行的部分不會輸出。

讓我們來看, Node官方文件裡面的例子。

指令碼檔案a.js 程式碼如下。

exports.done = false;
const b = require('./b.js');
console.log('在 a.js 之中, b.done = %j', b.done);
exports.done = true;
console.log('a.js 執行完畢');

上面程式碼之中, a.js 指令碼先輸出一個done 變數,然後載入另一個指令碼檔案b.js 。注意,此時a.js 程式碼就停在這裡,等待b.js 執行完畢,再往下執行。

再看b.js 的程式碼。

exports.done = false;
const a = require('./a.js');
console.log('在 b.js 之中, a.done = %j', a.done);
exports.done = true;
console.log('b.js 執行完畢')

上面程式碼之中, b.js 執行到第二行,就會去載入a.js ,這時,就發生了“迴圈載入”。系統會去a.js 模組對應物件的exports 屬性取值,可是因為a.js 還沒有執行完,從exports 屬性只能取回已經執行的部分,而不是最後的值。

a.js 已經執行的部分,只有一行。

exports.done = false;

因此,對於b.js 來說,它從a.js 只輸入一個變數done ,值為false 。然後, b.js 接著往下執行,等到全部執行完畢,再把執行權交還給a.js 。於是, a.js 接著往下執行,直到執行完畢。

我們寫一個指令碼main.js ,驗證這個過程。

const a = require('./a.js');
const b = require('./b.js');
console.log('在 main.js 之中, a.done=%j, b.done=%j', a.done, b.done);

執行main.js ,執行結果如下。

$ node main.js
在 b.js 之中, a.done = false
b.js 執行完畢
在 a.js 之中, b.done = true
a.js 執行完畢
在 main.js 之中, a.done=true, b.done=true

上面的程式碼證明了兩件事。一是,在b.js 之中, a.js 沒有執行完畢,只執行了第一行。二是, main.js 執行到第二行時,不會再次執行b.js ,而是輸出快取的b.js 的執行結果,即它的第四行。

exports.done = true;

總之, CommonJS輸入的是被輸出值的拷貝,不是引用。

另外,由於CommonJS模組遇到迴圈載入時,返回的是當前已經執行的部分的值,而不是程式碼全部執行後的值,兩者可能會有差異。所以,輸入變數的時候,必須非常小心。

var a = require('a'); // 安全的寫法
var foo = require('a').foo; // 危險的寫法
exports.good = function (arg) {
    return a.foo('good', arg); // 使用的是 a.foo 的最新值
};
exports.bad = function (arg) {
    return foo('bad', arg); // 使用的是一個部分載入時的值
};

上面程式碼中,如果發生迴圈載入, require(‘a’).foo 的值很可能後面會被改寫,改用require(‘a’) 會更保險一點。

ES6模組的迴圈載入

ES6模組是動態引用,遇到模組載入命令import 時,不會去執行模組,只是生成一個指向被載入模組的引用,需要開發者自己保證,真正取值的時候能夠取到值。

// a.js
import {bar} from './b.js';
export function foo() {
    bar();
    console.log('執行完畢');
}
foo();
// b.js
import {foo} from './a.js';
export function bar() {
    if (Math.random() > 0.5) {
        foo();
    }
}

按照CommonJS規範,上面的程式碼是沒法執行的。 a 先載入b ,然後b 又載入a ,這時a 還沒有任何執行結果,所以輸出結果為null ,即對於b.js 來說,變數foo 的值等於null ,後面的foo() 就會報錯。
但是, ES6可以執行上面的程式碼。

$ babel-node a.js
執行完畢

a.js 之所以能夠執行,原因就在於ES6載入的變數,都是動態引用其所在的模組。只要引用是存在的,程式碼就能執行。