1. 程式人生 > >ECMA Script 6_模組載入方案 ES6 Module 模組語法_import_export

ECMA Script 6_模組載入方案 ES6 Module 模組語法_import_export

1. 模組載入方案 commonJS

背景:

歷史上,JavaScript 一直沒有模組(module)體系,

無法將一個大程式拆分成互相依賴的小檔案,再用簡單的方法拼裝起來。

其他語言都有這項功能: 

Ruby 的require

Python 的import

甚至就連 CSS 都有@import

但是 JavaScript 任何這方面的支援都沒有,這對開發大型的、複雜的專案形成了巨大障礙

在 ES6 之前,社群制定了一些模組載入方案,最主要的有:

CommonJS     用於伺服器

AMD    

用於瀏覽器

ES6 在語言標準的層面上,實現了模組功能,而且實現得相當簡單,完全可以取代 CommonJS 和 AMD 規

範,成為瀏覽器和伺服器通用的模組解決方案

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

CommonJS 和 AMD 模組,都只能在執行時確定這些東西。

比如,CommonJS 模組就是物件,輸入時必須查詢物件屬性。

執行時載入:實質是整體載入fs模組(即載入fs的所有方法),生成一個物件(_fs),然後再從這個物件上面讀取 3 個方法

  • let { stat, exists, readFile } = require('fs');    //
    CommonJS模組 // 等同於 let _fs = require('fs'); let stat = _fs.stat; let exists = _fs.exists; let readfile = _fs.readfile;

ES6 模組 不是物件,而是通過 export 命令顯式指定 輸出的程式碼,再通過 import 命令輸入

編譯時載入: 實質是從fs模組載入 3 個方法,其他方法不載入。

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

ES6 可以在編譯時就完成模組載入,效率要比 CommonJS 模組的載入方式高。

當然,這也導致了沒法引用 ES6 模組本身,因為它不是物件

  • ES6 的模組自動採用嚴格模式,不管你有沒有在模組頭部加上"use strict";
  • 限制
  • 變數必須聲明後再使用
    函式的引數不能有同名屬性,否則報錯
    不能使用 with 語句
    不能對只讀屬性賦值,否則報錯
    不能使用字首 0 表示八進位制數,否則報錯
    不能刪除不可刪除的屬性,否則報錯
    不能刪除變數 delete prop,會報錯,只能刪除屬性 delete global[prop]
    eval 不會在它的外層作用域引入變數
    eval 和 arguments 不能被重新賦值
    arguments不會自動反映函式引數的變化
    不能使用 arguments.callee
    不能使用 arguments.caller
    禁止 this 指向全域性物件
    不能使用 fn.caller 和 fn.arguments 獲取函式呼叫的堆疊
    增加了保留字(比如 protected、static 和 interface)

2. 模組功能主要由兩個命令構成:export 和 import

export、import 命令 可以出現在模組的任何位置,

只要處於模組頂層就可以,

不能處於塊級作用域內,否則就會報錯

export

用於輸出模組的對外介面

一個模組就是一個獨立的檔案。

 

注意1. export語句輸出的介面,與其對應的值是動態繫結關係,

即通過該介面,可以取到模組內部實時的值

  • export var foo = 'bar';
    setTimeout(() => foo = 'baz', 500);
    // 輸出變數 foo,值為bar,500 毫秒之後變成baz

不同於CommonJS 模組輸出的是值的快取,不存在動態更新

注意2. export命令規定的是對外的介面,必須與模組內部的變數建立一一對應關係。

  • // 報錯
    export 1;
    
    // 報錯
    var m = 1;
    export m;

      // 報錯
      function f() {}
      export f;

    /**** 正確寫法 ****/
    // 寫法一
    export var m = 1;
    
    // 寫法二
    var m = 1;
    export {m};
    
    // 寫法三
    var n = 1;
    export {n as m};

      // 正確
      export function f() {};

     
       

      // 正確
      function f() {}
      export {f};

  • export 命令輸出變數

模組檔案內部的所有變數,外部無法獲取。

如果你希望外部能夠讀取模組內部的某個變數,就必須使用 export 關鍵字輸出該變數

  • // profile.js
    export var firstName = 'Michael';
    export var lastName = 'Jackson';
    export var year = 1958;

優先考慮以下寫法。因為這樣就可以在指令碼尾部,一眼看清楚輸出了哪些變數。

  • // 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;
    };
  • 可以使用 export { ...as...} 關鍵字重新命名
  • function v1() { ... }
    function v2() { ... }
    
    export {
        v1 as streamV1,
        v2 as streamV2,
        v2 as streamLatestVersion    // v2 可以用不同的名字輸出兩次。
    }; 

 

import

用於輸入其他模組提供的功能

其他 JS 檔案就可以通過 import 命令載入這個模組

  • // main.js
    import {firstName, lastName, year} from './profile.js';
    
    function setName(element) {
        element.textContent = firstName + ' ' + lastName;
    }
  • import 命令要使用 as 關鍵字,將輸入的變數重新命名
  • import { lastName as surname } from './profile.js';
  • import 命令輸入的變數都是隻讀的

因為它的本質是輸入介面。

也就是說,不允許在載入模組的腳本里面,改寫介面

  • import {a} from './xxx.js'
    
    a = {};     // Syntax Error : 'a' is read-only;

     

     // 如果a是一個物件,改寫a的屬性是允許的
     a.foo = 'hello'; // 合法操作

  • import 命令具有提升效果,會提升到整個模組的頭部,首先執行

本質是,import命令是編譯階段執行的,在程式碼執行之前就輸入完成了。

  • 由於import是靜態執行,所以不能使用表示式和變數,這些只有在執行時才能得到結果的語法結構
  • 僅僅執行模組,但是不輸入任何值
  • import 'lodash';
    import 'lodash';    // 多次重複執行同一句 import 語句,那麼只會執行一次,而不會執行多次
  • CommonJS 模組的require命令  和  ES6 模組的import命令,可以寫在同一個模組裡面,但是最好不要這樣做
  • 因為import在靜態解析階段執行,所以它是一個模組之中最早執行的。下面的程式碼可能不會得到預期結果。
  • require('core-js/modules/es6.symbol');
    require('core-js/modules/es6.promise');
    import React from 'React';

模組的整體載入

  • 現有模組 circle.js
  • // circle.js
    export function area(radius) { return Math.PI * radius * radius; }; export function circumference(radius) { return 2 * Math.PI * radius; };
  • index.js 整體載入
  • // index.js
    import * as circle from './circle';
    
    console.log('圓面積:' + circle.area(4));
    console.log('圓周長:' + circle.circumference(14));

export default 模組指定預設輸出

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

但是,使用者肯定希望快速上手,未必願意閱讀文件,去了解模組有哪些屬性和方法

為了給使用者提供方便,讓他們不用閱讀文件就能載入模組,就要用到 export default 命令,為模組指定預設輸出

一個模組只能有一個預設輸出, 因此 export default 命令只能使用一次

使用 export default 時,對應的 import 語句不需要使用大括號

  • // export-default.js
    export default function foo() {
        console.log('foo');
    };
    
    // 或者寫成
    function foo() {
        console.log('foo');
    };
    
    export default foo;
  • 如果想在一條 import 語句中,同時輸入預設方法和其他介面,可以寫成下面這樣
  • export default function (obj) {
      // ···
    }
    
    export function each(obj, iterator, context) {
      // ···
    }
    
    export { each as forEach };
    
    
    /**** 匯入 ****/
    import _, { each, forEach } from 'lodash';

跨模組常量 const

引入import()函式,完成動態載入

import函式的引數specifier,指定所要載入的模組的位置。

import 命令能夠接受什麼引數,import()函式就能接受什麼引數,兩者區別主要是後者為動態載入。

import()類似於 Node 的require方法,區別主要是前者是非同步載入,後者是同步載入

import()返回一個 Promise 物件

  • const main = document.querySelector('main');
    
    import(`./section-modules/${someVariable}.js`)
        .then(module => {
            module.loadPageInto(main);
        })
        .catch(err => {
            main.textContent = err.message;
        });

 

3. 瀏覽器載入

預設情況下,瀏覽器是同步載入 JavaScript 指令碼,即渲染引擎遇到<script>標籤就會停下

來,等到執行完指令碼,再繼續向下渲染。如果是外部指令碼,還必須加入指令碼下載的時間

 

如果指令碼體積很大,下載和執行的時間就會很長,因此造成瀏覽器堵塞,使用者會感覺到瀏覽器“卡死”

了,沒有任何響應。這顯然是很不好的體驗,所以瀏覽器允許指令碼非同步載入,

下面就是兩種非同步載入的語法

  • <script src="path/to/myModule.js" defer></script>
    <script src="path/to/myModule.js" async></script>
  • <script> 標籤開啟 defer 或 async 屬性,指令碼就會非同步載入。

渲染引擎遇到這一行命令,就會開始下載外部指令碼,但不會等它下載和執行,而是直接執行後面的命令。

defer 與 async 的區別是:

defer 要等到整個頁面在記憶體中正常渲染結束

(DOM 結構完全生成,以及其他指令碼執行完成),才會執行

async 一旦下載完,渲染引擎就會中斷渲染,

執行這個指令碼以後,再繼續渲染

  • 瀏覽器載入 ES6 模組,也使用<script>標籤,但是要加入type="module"屬性
  • <script type="module" src="./foo.js"></script>

瀏覽器對於帶有 type="module"的 <script>,都是非同步載入,不會造成堵塞瀏覽器,

即等到整個頁面渲染完,再執行模組指令碼,等同於打開了 <script> 標籤的 defer 屬性。

ES6 模組也允許內嵌在網頁中,語法行為與載入外部指令碼完全一致

  • <script type="module">
        import utils from "./utils.js";
    
        // other code
    </script>

注意:

            • 程式碼是在模組作用域之中執行,而不是在全域性作用域執行。模組內部的頂層變數,外部不可見。
            • 模組指令碼自動採用嚴格模式,不管有沒有宣告 use strict
            • 模組之中,可以使用import命令載入其他模組(.js字尾不可省略,需要提供絕對 URL 或相對 URL),也可以使用export命令輸出對外介面。
            • 模組之中,頂層的this關鍵字返回undefined,而不是指向window。也就是說,在模組頂層使用this關鍵字,是無意義的。
            • 同一個模組如果載入多次,將只執行一次。
  • 利用頂層的 this 等於 undefined 這個語法點,可以偵測當前程式碼是否在 ES6 模組之中。
  • const isNotModuleScript = this !== undefined

4. ES6 模組與 CommonJS 模組完全不同。

  • CommonJS 模組輸出的是值的拷貝            ES6 模組輸出的是值的引用。
  • CommonJS 模組是執行時載入            ES6 模組是編譯時輸出介面。

 CommonJS 載入的是一個物件(即module.exports屬性),該物件只有在指令碼執行完才會生成

 ES6 模組不是物件,它的對外介面只是一種靜態定義,在程式碼靜態解析階段就會生成

  • CommonJS 模組輸出的是值的拷貝,也就是說,一旦輸出一個值,模組內部的變化就影響不到這個值
  • /**** 定義介面 lib.js ****/
    var counter = 3;
    function incCounter() {
        counter++;
    };
    
    module.exports = {
        counter: counter,
        incCounter: incCounter,
    };
    
    
    /**** 匯入 main.js ****/
    var mod = require('./lib');
    
    console.log(mod.counter);  // 3
    mod.incCounter();    // 改變的是模組檔案中的值,而當前檔案的值不受影響
    console.log(mod.counter); // 3
  • ES6 模組的執行機制與 CommonJS 不一樣。

JS 引擎對指令碼靜態分析的時候,遇到模組載入命令import,就會生成一個只讀引用。

等到指令碼真正執行時,再根據這個只讀引用,到被載入的那個模組裡面去取值

  • // lib.js
    export let counter = 3;
    export function incCounter() {
      counter++;
    }
    
    // main.js
    import { counter, incCounter } from './lib';
    console.log(counter);    // 3    第一次取值
    incCounter(); 
    console.log(counter);    // 4    再取值,發現值變了
  • 唯一要注意的是: ES6 輸入的模組變數,只是一個“符號連線”,所以這個變數是隻讀的,對它進行重新賦值會報錯

5. Node 對 ES6 模組的處理比較麻煩,因為它有自己的 CommonJS 模組格式,與 ES6 模組格式是不相容的。

目前的解決方案是,將兩者分開,ES6 模組 和 CommonJS 採用各自的載入方案

  • 為了與瀏覽器的 import 載入規則相同,Node 的.mjs檔案支援 URL 路徑。
  • import './foo?query=1';    // 載入 ./foo 傳入引數 ?query=1
  • 只要檔名中含有:%#?等特殊字元,最好對這些字元進行轉義。

因為 Node 會按 URL 規則解讀

  • Node 的 import 命令只支援載入本地模組(file:協議),不支援載入遠端模組
  • 如果模組名不含路徑,那麼 import 命令會去 node_modules 目錄尋找這個模組
  • 如果指令碼檔案省略了字尾名,

比如import './foo',Node 會依次嘗試四個字尾名

./foo.mjs

./foo.js

./foo.json

./foo.node

如果這些指令碼檔案都不存在,Node 就會去載入 ./foo/package.json 的 main 欄位指定的指令碼。

如果 ./foo/package.json 不存在 或者 沒有 main 欄位,那麼就會丟擲錯誤。

6. ES6 模組載入 CommonJS 模組 

CommonJS 模組的輸出 都定義在 module.exports 這個屬性上面

  • // a.js
    module.exports = {
        foo: 'hello',
        bar: 'world'
    };
    
    
    // 等同於
    export default {
        foo: 'hello',
        bar: 'world'
    };
    
    /**** 
        export 指向 modeule.exports,
        即 exports 變數 是對 module 的 exports 屬性的引用
        因此
     ****/
    module.exports = func;    // 正確
    export = func;    // 錯誤

    module.exports會被視為預設輸出,即import命令實際上輸入的是這樣一個物件{ default: module.exports }

  • 通過 import 一共有三種寫法,可以拿到 CommonJS 模組的module.exports
  • // 寫法一
    import baz from './a';
    // baz = {foo: 'hello', bar: 'world'};
    
    // 寫法二
    import {default as baz} from './a';
    // baz = {foo: 'hello', bar: 'world'};
    
    // 寫法三
    import * as baz from './a';
    // baz = {
    //   get default() {return module.exports;},
    //   get foo() {return this.default.foo}.bind(baz),
    //   get bar() {return this.default.bar}.bind(baz)
    // }
  • CommonJS 的一個模組,就是一個指令碼檔案。
  • 可以通過將變數和函式設定為  module.exports / exports 的屬性來暴露變數和函式
  • require 命令第一次載入該指令碼,就會執行整個指令碼,然後在記憶體生成一個物件