轉載:https://juejin.cn/post/6996815121855021087
前端面試知識點(二)
前言
本文是繼前端面試知識點(一)之后的第二篇面試知識點解析。在第一篇面試知識點解析中給出了 174 道面試題中的 19 道面試答案,本文將繼續給出部分答案解析。由於答案解析的篇幅越來越長,因此不得不將該面試題清單的答案解析做成前端面試知識點系列,從而可以幫助未閱讀過該系列文章的同學可以根據序號進行順序閱讀:
- 前端面試知識點(一): 1 ~ 5、7 ~ 8、10 ~ 15、18、20 ~ 21、24、29 ~ 30 等 19 道面試題的答案解析
- 前端面試知識點(二): 6、9 等 2 道面試題的答案解析
6、簡單描述一下 Babel 的編譯過程?
Babel 是一個源到源的轉換編譯器(Transpiler),它的主要作用是將 JavaScript 的高版本語法(例如 ES6)轉換成低版本語法(例如 ES5),從而可以適配瀏覽器的兼容性。
溫馨提示:如果某種高級語言或者應用語言(例如用於人工智能的計算機設計語言)轉換的目標語言不是特定計算機的匯編語言,而是面向另一種高級程序語言(很多研究性的編譯器將 C 作為目標語言),那么還需要將目標高級程序語言再進行一次額外的編譯才能得到最終的目標程序,這種編譯器可稱為源到源的轉換器。
從上圖可知,Babel 的編譯過程主要可以分為三個階段:
- 解析(Parse):包括詞法分析和語法分析。詞法分析主要把字符流源代碼(Char Stream)轉換成令牌流( Token Stream),語法分析主要是將令牌流轉換成抽象語法樹(Abstract Syntax Tree,AST)。
- 轉換(Transform):通過 Babel 的插件能力,將高版本語法的 AST 轉換成支持低版本語法的 AST。當然在此過程中也可以對 AST 的 Node 節點進行優化操作,比如添加、更新以及移除節點等。
- 生成(Generate):將 AST 轉換成字符串形式的低版本代碼,同時也能創建 Source Map 映射。
具體的流程如下所示:
舉個栗子,如果要將 TypeScript 語法轉換成 ES5 語法:
// 源代碼 let a: string = 1; // 目標代碼 var a = 1; 復制代碼
6.1 解析(Parser)
Babel 的解析過程(源碼到 AST 的轉換)可以使用 @babel/parser,它的主要特點如下:
- 支持解析最新的 ES2020
- 支持解析 JSX、Flow & TypeScript
- 支持解析實驗性的語法提案(支持任何 Stage 0 的 PRS)
@babel/parser 主要是基於輸入的字符串流(源代碼)進行解析,最后轉換成規范(基於 ESTree 進行調整)的 AST,如下所示:
import { parse } from '@babel/parser'; const source = `let a: string = 1;`; enum ParseSourceTypeEnum { Module = 'module', Script = 'script', Unambiguous = 'unambiguous', } enum ParsePluginEnum { Flow = 'flow', FlowComments = 'flowComments', TypeScript = 'typescript', Jsx = 'jsx', V8intrinsic = 'v8intrinsic', } // 解析(Parser)階段 const ast = parse(source, { // 嚴格模式下解析並且允許模塊定義 sourceType: ParseSourceTypeEnum.Module, // 支持解析 TypeScript 語法(注意,這里只是支持解析,並不是轉換 TypeScript) plugins: [ParsePluginEnum.TypeScript], }); 復制代碼
需要注意,在 Parser 階段主要是進行詞法和語法分析,如果詞法或者語法分析錯誤,那么會在該階段被檢測出來。如果檢測正確,則可以進入語法的轉換階段。
6.2 轉換(Transform)
Babel 的轉換過程(AST 到 AST 的轉換)主要使用 @babel/traverse,該庫包可以通過訪問者模式自動遍歷並訪問 AST 樹的每一個 Node 節點信息,從而實現節點的替換、移除和添加操作,如下所示:
import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; enum ParseSourceTypeEnum { Module = 'module', Script = 'script', Unambiguous = 'unambiguous', } enum ParsePluginEnum { Flow = 'flow', FlowComments = 'flowComments', TypeScript = 'typescript', Jsx = 'jsx', V8intrinsic = 'v8intrinsic', } const source = `let a: string = 1;`; // 解析(Parser)階段 const ast = parse(source, { // 嚴格模式下解析並且允許模塊定義 sourceType: ParseSourceTypeEnum.Module, // 支持解析 TypeScript 語法(注意,這里只是可以解析,並不是轉換 TypeScript) plugins: [ParsePluginEnum.TypeScript], }); // 轉換(Transform) 階段 traverse(ast, { // 訪問變量聲明標識符 VariableDeclaration(path) { // 將 const 和 let 轉換為 var path.node.kind = 'var'; }, // 訪問 TypeScript 類型聲明標識符 TSTypeAnnotation(path) { // 移除 TypeScript 的聲明類型 path.remove(); }, }); 復制代碼
關於 Babel 中的訪問器 API,這里不再過多說明,如果想了解更多信息,可以查看 Babel 插件手冊。除此之外,你可能已經注意到這里的轉換邏輯其實可以理解為實現一個簡單的 Babel 插件,只是沒有封裝成 Npm 包。當然,在真正的插件開發開發中,還可以配合 @babel/types 工具包進行節點信息的判斷處理。
溫馨提示:這里只是簡單的一個 Demo 示例,在真正轉換 let、const 等變量聲明的過程中,還會遇到處理暫時性死區(Temporal Dead Zone, TDZ)的情況,更多詳細信息可以查看官方的插件 babel-plugin-transform-block-scoping。
6.3 生成(Generate)
Babel 的代碼生成過程(AST 到目標代碼的轉換)主要使用 @babel/generator,如下所示:
import { parse } from '@babel/parser'; import traverse from '@babel/traverse'; import generate from '@babel/generator'; enum ParseSourceTypeEnum { Module = 'module', Script = 'script', Unambiguous = 'unambiguous', } enum ParsePluginEnum { Flow = 'flow', FlowComments = 'flowComments', TypeScript = 'typescript', Jsx = 'jsx', V8intrinsic = 'v8intrinsic', } const source = `let a: string = 1;`; // 解析(Parser)階段 const ast = parse(source, { // 嚴格模式下解析並且允許模塊定義 sourceType: ParseSourceTypeEnum.Module, // 支持解析 TypeScript 語法(注意,這里只是可以解析,並不是轉換 TypeScript) plugins: [ParsePluginEnum.TypeScript], }); // 轉換(Transform) 階段 traverse(ast, { // 訪問詞法規則 VariableDeclaration(path) { path.node.kind = 'var'; }, // 訪問詞法規則 TSTypeAnnotation(path) { // 移除 TypeScript 的聲明類型 path.remove(); }, }); // 生成(Generate)階段 const { code } = generate(ast); // code: var a = 1; console.log('code: ', code); 復制代碼
如果你想了解上述輸入源對應的 AST 數據或者嘗試自己編譯,可以使用工具 AST Explorer (也可以使用 Babel 官網自帶的 Try It Out ),具體如下所示:
溫馨提示:上述第三個框是以插件的 API 形式進行調用,如果想了解 Babel 的插件開發,可以查看 Babel 插件手冊 / 編寫你的第一個 Babel 插件。
如果你覺得 Babel 的編譯過程太過於簡單,你可以嘗試更高階的玩法,比如自己設計詞法和語法規則從而實現一個簡單的編譯器(Babel 內置了這些規則),你完全可以不只是做出一個源到源的轉換編譯器,而是實現一個真正的從 JavaScript (TypeScript) 到機器代碼的完整編譯器,包括實現中間代碼 IR 以及提供機器的運行環境等,這里給出一個可以嘗試這種高階玩法的庫包 antlr4ts(可以配合交叉編譯工具鏈 riscv-gnu-toolchain,gcc編譯工具的制作還是非常耗時的)。
閱讀鏈接: Babel 用戶手冊、Babel 插件手冊
9、ES6 Module 相對於 CommonJS 的優勢是什么?
溫馨提示:如果你只是想知道本題的答案,那么直接進入傳送門 16.8.2 Static module structure 。除此之外,以下 ES Module 的代碼只在 Node.js 環境中進行了測試,感興趣的同學可以使用瀏覽器進行再測試。對不同規范模塊的代碼編譯選擇了 Webpack,感興趣的同學也可以采用 Rollup 進行編譯測試。
關於 ES Module 和 CommonJS 的規范以及語法,這里不再詳細敘述,如果你還不了解這兩者的語法糖,可以查看 ECMAScript 6 入門 / Module 語法、ES Module 標准以及 Node.js 的 CommonJS 模塊,兩者的主要區別如下所示:
類型 | ES Module | CommonJS |
---|---|---|
加載方式 | 編譯時 | 運行時 |
引入性質 | 引用 / 只讀 | 淺拷貝 / 可讀寫 |
模塊作用域 | this | this / __filename / __dirname... |
9.1 加載方式
加載方式是 ES Module 和 CommonJS 的最主要區別,這使得兩者在編譯時和運行時上各有優劣。首先來看一下 ES Module 在加載方式上的特性,如下所示:
// 編譯時:VS Code 鼠標 hover 到 b 時可以顯示出 b 的類型信息 import { b } from './b'; const a = 1; // WARNING: 具有邏輯 if(a === 1) { // 編譯時:ESLint: Parsing error: 'import' and 'export' may only appear at the top level // 運行時:SyntaxError: Unexpected token '{' // TIPS: 這里可以使用 import() 進行動態導入 import { b } from './b'; } const c = 'b'; // WARNING: 含有變量 // 編譯時:ESLint:Parsing error: Unexpected token ` // 運行時:SyntaxError: Unexpected template string import { d } from `./${c}`; 復制代碼
CommonJS 相對於 ES Module 在加載方式上的特性如下所示:
const a = 1; if(a === 1) { // VS Code 鼠標 hover 到 b 時,無法顯示出 b 的類型信息 const b = require('./b'); } const c = 'b'; const d = require(`./${c}`); 復制代碼
大家可能知道上述語法的差異性,接下來通過理論知識重點講解一下兩者產生差異的主要原因。在前端知識點掃盲(一)/ 編譯器原理中重點講解了整個編譯器的執行階段,如下圖所示: ES Module 是采用靜態的加載方式,也就是模塊中導入導出的依賴關系可以在代碼編譯時就確定下來。如上圖所示,代碼在編譯的過程中可以做的事情包含詞法和語法分析、類型檢查以及代碼優化等等。因此采用 ES Module 進行代碼設計時可以在編譯時通過 ESLint 快速定位出模塊的詞法語法錯誤以及類型信息等。ES Module 中會產生一些錯誤的加載方式,是因為這些加載方式含有邏輯和變量的運行時判斷,只有在代碼的運行時階段才能確定導入導出的依賴關系,這明顯和 ES Module 的加載機制不相符。
CommonJS 相對於 ES Module 在加載模塊的方式上存在明顯差異,是因為 CommonJS 在運行時進行加載方式的動態解析,在運行時階段才能確定的導入導出關系,因此無法進行靜態編譯優化和類型檢查。
溫馨提示:注意 import 語法和 import() 的區別,import() 是 tc39 中的一種提案,該提案允許你可以使用類似於 import(`${path}/foo.js`) 的導入語句(估計是借鑒了 CommonJS 可以動態加載模塊的特性),因此也允許你在運行時進行條件加載,也就是所謂的懶加載。除此之外,import 和 import() 還存在其他一些重要的區別,大家還是自行谷歌一下。
9.2 編譯優化
由於 ES Module 是在編譯時就能確定模塊之間的依賴關系,因此可以在編譯的過程中進行代碼優化。例如:
// hello.js export function a() { console.log('a'); } export function b() { console.log('b'); } // index.js // TIPS: Webpack 編譯入口文件 // 這里不引入 function b import { a } from './hello'; console.log(a); 復制代碼
使用 Webpack 5.47.1 (Webpack Cli 4.7.2)進行代碼編譯,生成的編譯產物如下所示:
(()=>{"use strict";console.log((function(){console.log("a")}))})(); 復制代碼
可以發現編譯生成的產物沒有 function b 的代碼,這是在編譯階段對代碼進行了優化,移除了未使用的代碼(Dead Code),這種優化的術語被叫做 Tree Shaking。
溫馨提示:你可以將應用程序想象成一棵樹。綠色表示實際用到的 Source Code(源碼)和 Library(庫),是樹上活的樹葉。灰色表示未引用代碼,是秋天樹上枯萎的樹葉。為了除去死去的樹葉,你必須搖動這棵樹,使它們落下。
溫馨提示:在 ES Module 中可能會因為代碼具有副作用(例如操作原型方法以及添加全局對象的屬性等)導致優化失敗,如果想深入了解 Tree Shaking 的更多優化注意事項,可以深入閱讀你的 Tree-Shaking 並沒什么卵用。
為了對比 ES Module 的編譯優化能力,同樣采用 CommonJS 規范進行模塊導入:
// hello.js exports.a = function () { console.log('a'); }; exports.b = function () { console.log('b'); }; // index.js // TIPS: Webpack 編譯入口文件 const { a } = require('./hello'); console.log(a); 復制代碼
使用 Webpack 進行代碼編譯,生成的編譯產物如下所示:
(() => { var o = { 418: (o, n) => { (n.a = function () { console.log('a'); }), // function b 的代碼並沒有被去除 (n.b = function () { console.log('b'); }); }, }, n = {}; function r(t) { var e = n[t]; if (void 0 !== e) return e.exports; var s = (n[t] = { exports: {} }); return o[t](s, s.exports, r), s.exports; } (() => { const { a: o } = r(418); console.log(o); })(); })(); 復制代碼
可以發現在 CommonJS 模塊中,盡管沒有使用 function b,但是代碼仍然會被打包編譯,正是因為 CommonJS 模塊只有在運行時才能進行同步導入,因此無法在編譯時確定是否 function b 是一個 Dead Code。
溫馨提示:在 Node.js 環境中一般不需要編譯 CommonJS 模塊代碼,除非你使用了當前 Node 版本所不能兼容的一些新語法特性。
大家可能會注意到一個新的問題,當我們在制作工具庫或者組件庫的時候,通常會將庫包編譯成 ES5 語法,這樣盡管 Babel 以及 Webpack 默認會忽略 node_modules 里的模塊,我們的項目在編譯時引入的這些模塊仍然能夠做到兼容。在這個過程中,如果你制作的庫包體積非常大,你又不提供非常細粒度的按需引入的加載方式,那么你可以編譯你的源碼使得編譯產物可以支持 ES Module 的導入導出模式(注意只支持 ES6 中模塊的語法,其他的語法仍然需要被編譯成 ES5),當項目真正引入這些庫包時可以通過 Tree Shaking 的特性在編譯時去除未引入的代碼(Dead Code)。
溫馨提示:如果你想了解如何使發布的 Npm 庫包支持 Tree Shaking 特性,可以查看 defense-of-dot-js / Typical Usage、 Webpack / Final Steps、pgk.module 以及 rollup.js / Tree Shaki…。
Webpack 對於 module 字段的支持的描述提示:The module property should point to a script that utilizes ES2015 module syntax but no other syntax features that aren't yet supported by browsers or node. This enables webpack to parse the module syntax itself, allowing for lighter bundles via tree shaking if users are only consuming certain parts of the library.
9.3 加載原理 & 引入性質
溫馨提示:下述理論部分以及圖片內容均出自於 2018 年的文章 ES modules: A cartoon deep-dive,如果想要了解更多原理信息可以查看 TC39 的 16.2 Modules。
在 ES Module 中使用模塊進行開發,其實是在編譯時構建模塊之間的依賴關系圖。在瀏覽器或者服務的文件系統中運行 ES6 代碼時,需要解析所有的模塊文件,然后將模塊轉換成 Module Record 數據結構,具體如下圖所示:
事實上, ES Module 的加載過程主要分為如下三個階段:
- 構建(Construction):主要分為查找、加載(在瀏覽器中是下載文件,在本地文件系統中是加載文件)、然后把文件解析成 Module Record。
- 實例化(Instantiation):給所有的 Module Record 分配內存空間(此刻還沒有填充值),並根據導入導出關系確定各自之間的引用關系,確定引用關系的過程稱為鏈接(Linking)。
- 運行(Evaluation):運行代碼,給內存地址填充運行時的模塊數據。
溫馨提示:import 的上述三個階段其實在 import() 中體現的更加直觀(盡管 import 已經被多數瀏覽器支持,但是我們在真正開發和運行的過程中仍然會使用編譯后的代碼運行,而不是采用瀏覽器 script 標簽的遠程地址的動態異步加載方式),而 import() 事實上如果要實現懶加載優化(例如 Vue 里的路由懶加載,更多的是在瀏覽器的宿主環境而不是 Node.js 環境,這里不展開更多編譯后實現方式的細節問題),大概率要完整經歷上述三個階段的異步加載過程,具體再次查看 tc39 動態提案:This proposal adds an import(specifier) syntactic form, which acts in many ways like a function (but see below). It returns a promise for the module namespace object of the requested module, which is created after fetching, instantiating, and evaluating all of the module's dependencies, as well as the module itself.
ES Module 模塊加載的三個階段分別需要在編譯時和運行時進行(可能有的同學會像我一樣好奇實例化階段到底是在編譯時還是運行時進行,根據 tc39 動態加載提案里的描述可以得出你想要的答案:The existing syntactic forms for importing modules are static declarations. They accept a string literal as the module specifier, and introduce bindings into the local scope via a pre-runtime "linking" process.),而 CommonJS 規范中的模塊是在運行時同步順序執行,模塊在加載的過程中不會被中斷,具體如下圖所示:
上圖中 main.js 在運行加載 counter.js 時,會先等待 counter.js 運行完成后才能繼續運行代碼,因此在 CommonJS 中模塊的加載是阻塞式的。CommonJS 采用同步阻塞式加載模塊是因為它只需要從本地的文件系統中加載文件,耗費的性能和時間很少,而 ES Module 在瀏覽器(注意這里說的是瀏覽器)中運行的時候需要下載文件然后才能進行實例化和運行,如果這個過程是同步進行,那么會影響頁面的加載性能。
從 ES Module 鏈接的過程可以發現模塊之間的引用關系是內存的地址引用,如下所示:
// hello.js export let a = 1; setTimeout(() => { a++; }, 1000); // index.js import { a } from './hello.js'; setTimeout(() => { console.log(a); // 2 }, 2000); 復制代碼
在 Node (v14.15.4)環境中運行上述代碼得到的執行結果是 2,對比一下 CommonJS 規范的執行:
// hello.js exports.a = 1; setTimeout(() => { exports.a++; }, 1000); // index.js let { a } = require('./hello'); setTimeout(() => { console.log(a); // 1 }, 2000); 復制代碼
可以發現打印的結果信息和 ES Module 的結果不一樣,這里的執行結果為 1。產生上述差異的根本原因是實例化的方式不同,如下圖所示:
在 ES Module 的導出中 Module Record 會實時跟蹤(wire up 在這里理解為鏈接或者引用的意思)和綁定每一個導出變量對應的內存地址(從上圖可以發現值還沒有被填充,而 function 則可以在鏈接階段進行初始化),導入同樣對應的是導出所對應的同一個內存地址,因此對導入變量進行處理其實處理的是同一個引用地址的數據,如下圖所示:
CommonJS 規范在導出時事實上導出的是值拷貝,如下圖所示:
在上述代碼執行的過程中先對變量 a 進行值拷貝,因此盡管設置了定時器,變量 a 被引入后打印的信息仍然是 1。需要注意的是這種拷貝是淺拷貝,如下所示:
// hello.js exports.a = { value: 1, }; setTimeout(() => { exports.a.value++; }, 1000); // index.js let { a } = require('./hello'); setTimeout(() => { console.log(a.value); // 2 }, 2000); 復制代碼
接下來對比編譯后的差異,將 ES Module 的源碼進行編譯(仍然使用 Webpack),編譯之后的代碼如下所示:
(() => { 'use strict'; let e = 1; setTimeout(() => { e++; }, 1e3), setTimeout(() => { console.log(e); }, 2e3); })(); 復制代碼
可以看出,將 ES Module 的代碼進行編譯后,使用的是同一個變量值,此時將 CommonJS 的代碼進行編譯:
(() => { var e = { 418: (e, t) => { // hello.js 中的模塊代碼 (t.a = 1), setTimeout(() => { t.a++; }, 1e3); }, }, t = {}; function o(r) { // 開辟模塊的緩存空間 var s = t[r]; // 獲取緩存信息,每次返回相同的模塊對象信息 if (void 0 !== s) return s.exports; // 開辟模塊對象的內存空間 var a = (t[r] = { exports: {} }); // 逗號運算符,先運行模塊代碼,賦值模塊對象的值,然后返回模塊信息 // 由於緩存,模塊代碼只會被執行一次 return e[r](a, a.exports, o), a.exports; } (() => { // 淺拷貝 let { a: e } = o(418); setTimeout(() => { // 盡管 t.a ++,這里輸出的仍然是 1 console.log(e); }, 2e3); })(); })(); 復制代碼
可以發現 CommonJS 規范在編譯后會緩存模塊的信息,從而使得下一次將從緩存中直接獲取模塊數據。除此之外,緩存會使得模塊代碼只會被執行一次。查看 Node.js 官方文檔對於 CommonJS 規范的緩存描述,發現 Webpack 的編譯完全符合 CommonJS 規范的緩存機制。了解了這個機制以后,你會發現多次使用 require 進行模塊加載不會導致代碼被執行多次,這是解決無限循環依賴的一個重要特征。
除了引入的方式可能會有區別之外,引入的代碼可能還存在一些區別,比如在 ES Module 中:
// hello.js export function a() { console.log('a this: ', this); } // index.js import { a } from './hello.js'; // a = 1; ^ // TypeError: Assignment to constant variable. // ... // at ModuleJob.run (internal/modules/esm/module_job.js:152:23) // at async Loader.import (internal/modules/esm/loader.js:166:24) // at async Object.loadESM (internal/process/esm_loader.js:68:5) a = 1; 復制代碼
使用 Node.js 直接運行上述 ES Module 代碼,是會產生報錯的,因為導入的變量根據提示可以看出是只讀變量,而如果采用 Webpack 進行編譯后運行,則沒有上述問題,除此之外 CommonJS 中導入的變量則可讀可寫。當然除此之外,你也可以嘗試更多的其他方面,比如:
// hello.js // 非嚴格模式 b = 1; export function a() { console.log('a this: ', this); } // index.js import { a } from './hello.js'; console.log('a: ', a); 復制代碼
你會發現使用 Node.js 環境執行上述 ES Module 代碼,會直接拋出下述錯誤信息:
ReferenceError: b is not defined at file:///Users/ziyi/Desktop/Gitlab/Explore/module-example/esmodule/hello.js:1:3 at ModuleJob.run (internal/modules/esm/module_job.js:152:23) at async Loader.import (internal/modules/esm/loader.js:166:24) at async Object.loadESM (internal/process/esm_loader.js:68:5) 復制代碼
是因為 ES Module 的模塊需要運行在嚴格模式下, 而 CommonJS 規范則沒有這樣的要求,如果你在仔細一點觀察的話,會發現使用 Webpack 進行編譯的時候,ES Module 編譯的代碼會在前面加上 "use strict",而 CommonJS 編譯的代碼沒有。
9.4 模塊作用域
大家會發現在 Node.js 的模塊中設計代碼時可以使用諸如 __dirname、__filename 之類的變量(需要注意在 Webpack 編譯出的 CommonJS 前端產物中,並沒有 __filename、__dirname 等變量信息,瀏覽器中並不需要這些文件系統的變量信息),是因為 Node.js 在加載模塊時會對其進行如下包裝:
// https://github.com/nodejs/node/blob/master/lib/internal/modules/cjs/loader.js#L206 const wrapper = [ '(function (exports, require, module, __filename, __dirname) { ', '\n});', ]; 復制代碼
索性看到這個模塊作用域的代碼,我們就繼續查看一下 require 的源碼:
// https://github.com/nodejs/node/blob/3914354cd7ddc65774f13bbe435978217149793c/lib/internal/modules/cjs/loader.js#L997 Module.prototype.require = function(id) { validateString(id, 'id'); if (id === '') { throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string'); } requireDepth++; try { return Module._load(id, this, /* isMain */ false); } finally { requireDepth--; } }; // https://github.com/nodejs/node/blob/3914354cd7ddc65774f13bbe435978217149793c/lib/internal/modules/cjs/loader.js#L757 // Check the cache for the requested file. // 1. If a module already exists in the cache: return its exports object. // 2. If the module is native: call // `NativeModule.prototype.compileForPublicLoader()` and return the exports. // 3. Otherwise, create a new module for the file and save it to the cache. // Then have it load the file contents before returning its exports // object. Module._load = function(request, parent, isMain) { let relResolveCacheIdentifier; if (parent) { debug('Module._load REQUEST %s parent: %s', request, parent.id); // Fast path for (lazy loaded) modules in the same directory. The indirect // caching is required to allow cache invalidation without changing the old // cache key names. relResolveCacheIdentifier = `${parent.path}\x00${request}`; const filename = relativeResolveCache[relResolveCacheIdentifier]; // 有緩存,則走緩存 if (filename !== undefined) { const cachedModule = Module._cache[filename]; if (cachedModule !== undefined) { updateChildren(parent, cachedModule, true); if (!cachedModule.loaded) return getExportsForCircularRequire(cachedModule); return cachedModule.exports; } delete relativeResolveCache[relResolveCacheIdentifier]; } } // `node:` 用於檢測核心模塊,例如 fs、path 等 // Node.js 文檔:http://nodejs.cn/api/modules.html#modules_core_modules // 這里主要用於繞過 require 緩存 const filename = Module._resolveFilename(request, parent, isMain); if (StringPrototypeStartsWith(filename, 'node:')) { // Slice 'node:' prefix const id = StringPrototypeSlice(filename, 5); const module = loadNativeModule(id, request); if (!module?.canBeRequiredByUsers) { throw new ERR_UNKNOWN_BUILTIN_MODULE(filename); } return module.exports; } // 緩存處理 const cachedModule = Module._cache[filename]; if (cachedModule !== undefined) { updateChildren(parent, cachedModule, true); if (!cachedModule.loaded) { const parseCachedModule = cjsParseCache.get(cachedModule); if (!parseCachedModule || parseCachedModule.loaded) return getExportsForCircularRequire(cachedModule); parseCachedModule.loaded = true; } else { return cachedModule.exports; } } const mod = loadNativeModule(filename, request); if (mod?.canBeRequiredByUsers) return mod.exports; // Don't call updateChildren(), Module constructor already does. const module = cachedModule || new Module(filename, parent); if (isMain) { process.mainModule = module; module.id = '.'; } Module._cache[filename] = module; if (parent !== undefined) { relativeResolveCache[relResolveCacheIdentifier] = filename; } let threw = true; try { module.load(filename); threw = false; } finally { if (threw) { delete Module._cache[filename]; if (parent !== undefined) { delete relativeResolveCache[relResolveCacheIdentifier]; const children = parent?.children; if (ArrayIsArray(children)) { const index = ArrayPrototypeIndexOf(children, module); if (index !== -1) { ArrayPrototypeSplice(children, index, 1); } } } } else if (module.exports && !isProxy(module.exports) && ObjectGetPrototypeOf(module.exports) === CircularRequirePrototypeWarningProxy) { ObjectSetPrototypeOf(module.exports, ObjectPrototype); } } return module.exports; }; 復制代碼
溫馨提示:這里沒有將 wrapper 和 _load 的聯系說清楚(最后如何在 _load 中執行 wrapper),大家可以在 Node.js 源碼中跟蹤一下看一下上述代碼是怎么被執行的,是否是 eval 呢?不說了,腦殼疼,想要了解更多信息,可以查看 Node.js / vm。除此之外,感興趣的同學也了解一下 import 語法在 Node.js 中的底層實現,這里腦殼疼,就沒有深入研究了。
溫馨提示的溫馨提示:比如你在源碼中找不到上述代碼的執行鏈路,那最簡單的方式就是引入一個錯誤的模塊,讓錯誤信息將錯誤棧拋出來,比如如下所示,你會發現最底下執行了 wrapSafe,好了你又可以開始探索了,因為你對 safe 這樣的字眼一定感到好奇,底下是不是執行的時候用了沙箱隔離呢?
SyntaxError: Cannot use import statement outside a module at wrapSafe (internal/modules/cjs/loader.js:979:16) at Module._compile (internal/modules/cjs/loader.js:1027:27) at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10) at Module.load (internal/modules/cjs/loader.js:928:32) at Function.Module._load (internal/modules/cjs/loader.js:769:14) at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12) at internal/main/run_main_module.js:17:47 復制代碼
溫馨提示:是不是以前經常有面試官詢問 exports 和 module.exports 有什么關聯,其實根本不用糾結這個問題,因為兩者指向的是同一個引用地址,你如果對 exports 進行重新賦值,那么引用發生了改變,你新引用的部分當然就不會導出了,因為從源碼里可以看出,我們這里導出的是 module.exports。
接下來主要是重點看下 this 執行上下文的差異(注意這里只測試 Node.js 環境,編譯后的代碼可能會有差異),首先執行 ES Module 模塊的代碼:
// hello.js export function a() { console.log('this: ', this); // undefined } // index.js import { a } from './hello.js'; a(); 復制代碼
我們接着執行 CommonJS 的代碼:
// hello.js exports.a = function () { console.log('this: ', this); }; // index.js let { a } = require('./hello'); a(); 復制代碼
你會發現 this 的上下文環境是有信息的,可能是當前模塊的信息,具體沒有深究:
溫馨提示:Node.js 的調試還能在瀏覽器進行?可以查看一下 Node.js 調試,當然你也可以使用 VS Code 進行調試,需要進行一些額外的 launch 配置,當然如果你覺得 Node.js 自帶的瀏覽器調試方式太難受了,也可以想想辦法,如何通過 IP 端口在瀏覽器中進行調試,並且可以做到代碼變動監聽調試。
大家可以不用太糾結代碼的細致實現,只需要大致可以了解到 CommonJS 中模塊的導入過程即可,事實上 Webpack 編譯的結果大致可以理解為該代碼的瀏覽器簡易版。那還記得我之前在面試分享中的題目:兩年工作經驗成功面試阿里P6總結 / 如何在Node端配置路徑別名(類似於Webpack中的alias配置),如果你閱讀了上述源碼,基本上思路就是 HACK 原型鏈上的 require 方法:
const Module = require('module'); const originalRequire = Module.prototype.require; Module.prototype.require = function(id){ // 這里加入 path 的邏輯 return originalRequire.apply(this, id); }; 復制代碼
小結
目前的面試題答案系列稍微有些混亂,后續可能會根據類目對面試題進行簡單分類,從而整理出更加體系化的答案。本篇旨在希望大家可以對面試題進行舉一反三,從而加深理解(當我們問出一個問題的時候,可以衍生出 N 個問題)。