深入淺出ES6(十六):模塊 Modules

模塊基礎知識

每一個ES6模塊都是一個包含JS代碼的文件,模塊本質上就是一段腳本,而不是用module關鍵字定義一個模塊,但是模塊與腳本還是有兩點區別:

  • 在ES6模塊中,無論你是否加入“use strict;”語句,默認情況下模塊都是在嚴格模式下運行。
  • 在模塊中你可以使用importexport關鍵字。

我們先來討論export。默認情況下,你在模塊中的所有聲明相對於模塊而言都是寄存在本地的。如果你希望公開在模塊中聲明的內容,並讓其它模塊加以使用,你一定要導出這些功能。想要導出模塊的功能有很多方法,其中最簡單的方式是添加export關鍵字。

    // kittydar.js - 找到一幅圖像中所有貓的位置
    // (事實上是Heather Arthur寫的這個庫)
    // (但是她沒有使用ES6中新的模塊特性,因爲那時候是2013年)
    export function detectCats(canvas, options) {
      var kittydar = new Kittydar(options);
      return kittydar.detectCats(canvas);
    }
    export class Kittydar {
      ... 處理圖片的幾種方法 ...
    }
    // 這個helper函數沒有被export。
    function resizeCanvas() {
      ...
    }
    ...

你可以導出所有的最外層函數以及varletconst聲明的變量。

瞭解這些,你就可以編寫一個簡單的模塊。你不需要將所有代碼都放在一個IIFE或回調中,你只需要在模塊中解放手腳,聲明你需要的所有內容。代碼就是模塊,不是一段腳本,所以所有的聲明都被限定在模塊的作用域中,對所有腳本和模塊全局不可見。你需要做的是將組成模塊公共API的聲明全部導出。

在模塊中,除export之外的代碼無異於普通代碼,你可以訪問類似ObjectArray這樣的全局對象。如果你在web瀏覽器中運行模塊,你甚至可以使用document對象和XMLHttpRequest對象。

在一個獨立文件中,我們可以導入detectCats()函數然後用它來做點兒什麼:

    // demo.js - Kittydar的demo程序
    import {detectCats} from "kittydar.js";
    function go() {
        var canvas = document.getElementById("catpix");
        var cats = detectCats(canvas);
        drawRectangles(canvas, cats);
    }

如果想從一個模塊中導入多個名稱,你可以這樣寫:

    import {detectCats, Kittydar} from "kittydar.js";

當你運行的模塊中包含一條import聲明時,首先會加載被導入的模塊;然後依賴圖的深度優先遍歷按順序執行每一個模塊的主體代碼;爲了避免形成迴環,所有已執行的模塊都會被忽略。

這些就是模塊的基本知識了,相當簡單吧。;-)

Export列表

你不需要標記每一個被導出的特性,你只需要在花括號中按照列表的格式寫下你想導出的所有名稱:

    export {detectCats, Kittydar};
    // 此處不需要 `export`關鍵字
    function detectCats(canvas, options) { ... }
    class Kittydar { ... }

export列表可以在模塊文件最外層作用域的每一處聲明,不一定非要把它放在模塊文件的首行。你也可以聲明多個export列表,甚至通過其它的export聲明打造一個混合的export列表,只要保證每一個被導出的名稱是唯一的即可。

重命名import和export

恰恰有時候,導出的名稱會與你需要使用的其它名稱產生衝突,ES6爲你提供了重命名的方法解決這個問題,當你在導入名稱時可以這樣做:

    // suburbia.js
    // 這兩個模塊都會導出以`flip`命名的東西。
    // 要同時導入兩者,我們至少要將其中一個的名稱改掉。
    import {flip as flipOmelet} from "eggs.js";
    import {flip as flipHouse} from "real-estate.js";
    ...

同樣,當你在導出的時候也可以重命名。你可能會想用兩個不同的名稱導出相同的值,這樣的情況偶爾也會遇到:

    // unlicensed_nuclear_accelerator.js - 無DRM(數字版權管理)的媒體流
    // (這不是一個真實存在的庫,但是或許它應該被做成一個庫)

    function v1() { ... }
    function v2() { ... }

    export {
      v1 as streamV1,
      v2 as streamV2,
      v2 as streamLatestVersion
    };

Default exports

現在廣泛使用的模塊系統有CommonJS、AMD兩種,設計出來的新標準可以與這兩種模塊進行交互。所以假設你有一個Node項目,你已經執行了npm install lodash,你的ES6模塊可以從Lodash中導入獨立的函數:

    import {each, map} from "lodash";

    each([3, 2, 1], x => console.log(x));

但是也許你已經習慣看到_.each的書寫方式而不想直接用each函數呢?或者你就真的想導入整個_函數呢,畢竟_對於Lodash而言至關重要

針對這種情況,你可以換用一種稍微不太一樣的方法:不用花括號來導入模塊。

    import _ from "lodash";

這種簡略的表達方法等價於import {default as _} from "lodash";。在ES6的模塊中導入的CommonJS模塊和AMD模塊都有一個默認的導出,如果你用require()加載這些模塊也會得到相同的結果——exports對象。

ES6模塊不只導出CommonJS模塊,它的設計邏輯爲你提供導出不同內容的多種方法,默認導出的是你得到的所有內容。舉個例子,在用這種寫法的時候,據我所知,著名的colors包就沒有任何針對ES6的支持。像大多數npm上的包一樣,它是諸多CommonJS模塊的集合,但是你可以正確地將它導入到你的ES6代碼中。

    // `var colors = require("colors/safe");`的ES6等效代碼
    import colors from "colors/safe";

如果你想讓自己的ES6模塊有一個默認的導出,實現的方法很簡單,默認導出與其它類型的導出相似,沒有什麼技巧可言,唯一的不同之處是它被命名爲“default”。你可以用我們剛纔討論的重命名語法來實現:

    let myObject = {
      field1: value1,
      field2: value2
    };
    export {myObject as default};

這種簡略的表達方法看起來更清爽:

    export default {
      field1: value1,
      field2: value2
    };

關鍵字export default後可跟隨任何值:一個函數、一個類、一個對象字面量,只要你能想到的都可以。

模塊對象

很抱歉新特性有點兒多,但JavaScript不是唯一這樣做的語言:出於某些原因,每一種語言中的模塊系統都有這麼一堆又獨立又小,雖然無聊但是很方便的特性。不過還好,我們只剩一樣東西沒講了。好吧,是兩樣。

    import * as cows from "cows";

當你import *時,導入的其實是一個模塊命名空間對象,模塊將它的所有屬性都導出了。所以如果“cows”模塊導出一個名爲moon()的函數,然後用上面這種方法“cows”將其全部導入後,你就可以這樣調用函數了:cows.moo()

聚合模塊

有時一個程序包中主模塊的代碼比較多,爲了簡化這樣的代碼,可以用一種統一的方式將其它模塊中的內容聚合在一起導出,可以通過這種簡單的方式將所有所需內容導入再導出:

    // world-foods.js - 來自世界各地的好東西

    // 導入"sri-lanka"並將它導出的內容的一部分重新導出
    export {Tea, Cinnamon} from "sri-lanka";

    // 導入"equatorial-guinea"並將它導出的內容的一部分重新導出
    export {Coffee, Cocoa} from "equatorial-guinea";

    // 導入"singapore"並將它導出的內容全部導出
    export * from "singapore";

這些export-from語句每一個都好比是在一條import-from語句後伴隨着一個export。與真正的導入內容的方法不同的是,這些導入內容再重新導出的方法不會在作用域中綁定你導入的內容。如果你打算用world-foods.js中的Tea來寫一些代碼,可別用這種方法導入模塊,你會發現當前模塊作用域中根本找不到Tea

如果從“singapore”導出的任何名稱碰巧與其它的導出衝突了,可能會觸發一個錯誤,所以使用export *語句的時候要格外小心。

呼!終於講完了所有的語法!現在來講一些有趣的內容。

import實際都做了些什麼?

如果我說它什麼都沒做,你敢信?

哦,看來你沒那麼容易上當啊。好吧,你相信標準裏面通常都不會規定import的行爲麼?如果真是這樣,那這是件好事兒麼?

ES6將模塊加載過程的細節完全交由最終的實現來定義,模塊執行的其它部分倒是在規範中有詳細定義

粗略地講,當你通知JS引擎運行一個模塊時,它一定會按照以下四個步驟執行下去:

  1. 語法解析:閱讀模塊源代碼,檢查語法錯誤。
  2. 加載:遞歸地加載所有被導入的模塊。這也正是沒被標準化的部分。
  3. 連接:每遇到一個新加載的模塊,爲其創建作用域並將模塊內聲明的所有綁定填充到該作用域中,其中包括由其它模塊導入的內容。
  4. 如果你的代碼中有import {cake} from "paleo"這樣的語句,而此時“paleo”模塊並沒有導出任何“cake”,你就會觸發一個錯誤。這實在是太糟糕了,你都快要運行模塊中的代碼了,都是cake惹的禍!
  5. 運行時:最終,在每一個新加載的模塊體內執行所有語句。此時,導入的過程就已經結束了,所以當執行到達有一行import聲明的代碼的時候……什麼都沒發生!

看到了嘛?我可告訴過你結果是“啥都沒有”哦。事關編程語言我絕不撒謊!

但是現在我們真的要深入瞭解這個系統最有趣的部分了!有一個很酷的小技巧我可以教給你。系統不指定加載過程的實現方式,你也可以通過在源代碼中查找import聲明提前計算出所有依賴,你可以將ES6系統實現爲:在編譯時計算所有依賴並將所有模塊打包成一個文件,通過網絡一次傳輸所有模塊!像webpack這樣的工具就實現了這個功能。

這種做法的意義非常深遠,因爲通過網絡加載腳本需要花費時間,每當你請求到一個模塊,你可能發現它裏面也包含着import聲 明,這就需要你再花費一些時間加載更多的腳本。基於如此天真的思想實現的加載器需要消耗更多的網絡往返時間。但是webpack就不一樣啦,它所用的加載 器是經過精心設計的,吸收了軟件工程領域的精華,所以你不僅可以立即開始使用ES6模塊系統,還不會損耗運行時的性能。

最初的時候,標準委員會已經制定並實現了詳細的ES6模塊加載標準,它未成爲最終的標準的原因是成員們沒有就代碼封包(bundle)功能的實現方式達成一 致意見。我希望有人能搞定這個問題,正如我們所見,模塊加載的過程亟待被標準化;最關鍵的是,封包的功能實在是太好,就這樣放棄對其進行標準化有些可惜 啊。

靜態vs動態:論規則及破例之法

JavaScript作爲一門動態語言已經得到了一個令人驚訝的靜態模塊系統。

  • 你只可以在模塊的最外層作用域使用importexport,不可在條件語句中使用,也不能在函數作用域中使用import
  • 所有導出的標識符一定要在源代碼中明確地導出它們的名稱,你不能通過編寫代碼遍歷一個數組然後用數據驅動的方式導出一堆名稱。
  • 模塊對象被凍結了,所以你無法hack模塊對象併爲其添加polyfill風格的新特性。
  • 一個模塊的所有依賴必須在模塊代碼運行前完全加載、解析並且及早連接,不存在一種通過import來按需懶加載的語法。
  • import模塊產生的錯誤沒有錯誤恢復機制。一個app可能囊括了上百個模塊,一旦有一個模塊無法加載或連接,所有的模塊都不會運行,而且你不能在try/catch代碼塊中捕捉import的錯誤信息。(上面這些描述的本意是說:系統是靜態的,webpack可在編譯時爲你檢測那些錯誤。)
  • 不支持在模塊加載依賴前運行其它代碼的鉤子,這也意味着無法控制模塊的依賴加載過程。

只要你的需求是靜態的,系統就會運行良好,但是你有時可以設想下需要一點兒hack,對麼?

這也就是無論你用什麼模塊加載系統,你都將有一個編程API來支持ES6的靜態import/export語法。舉個例子,webpack中引入了一個“代碼分割”API,從而可以按需懶加載一些模塊的多個封包。相同的API可以幫你打破上面列舉的絕大多數其它的規則。

ES6模塊語法非常靜態,這是很好的——它通過強有力的編譯時工具的形式進行彌補。但是設計靜態語法的初衷是要與豐富的動態編程加載器API一起增強ES6的模塊系統。

我什麼時候可以使用ES6模塊?

如果你現在就想在項目中加入新的模塊語法,你需要使用BabelTraceur這樣的轉譯器。在系列之前的文章中,Gastón I. Silva展示瞭如何使用Babel和Broccoli來爲web平臺編譯ES6代碼;在那篇文章的基礎上,Gastón準備了一個支持ES6模塊的工作示例Axel Rauschmayer寫的這篇文章給出了一個用Babel和webpack構建項目的示例。

ES6模塊系統主要由Dave Herman和Sam Tobin-Hochstadt進行設計,在近幾年的爭論中,他們與所有參與者(包括我)爲新模塊系統的靜態部分進行辯護。Jon Coppeard負責在Firefox中實現這些模塊的特性。JavaScript加載器標準也在制定當中,接下來標準委員會可能會爲HTML添加一些類似<script type=module>特性。

然後這就是ES6的全部啦。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章