阅读 15228

前端面试知识点(二)

前言

本文是继前端面试知识点(一)之后的第二篇面试知识点解析。在第一篇面试知识点解析中给出了 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 作为目标语言),那么还需要将目标高级程序语言再进行一次额外的编译才能得到最终的目标程序,这种编译器可称为源到源的转换器。

image.png

从上图可知,Babel 的编译过程主要可以分为三个阶段:

  • 解析(Parse):包括词法分析和语法分析。词法分析主要把字符流源代码(Char Stream)转换成令牌流( Token Stream),语法分析主要是将令牌流转换成抽象语法树(Abstract Syntax Tree,AST)。
  • 转换(Transform):通过 Babel 的插件能力,将高版本语法的 AST 转换成支持低版本语法的 AST。当然在此过程中也可以对 AST 的 Node 节点进行优化操作,比如添加、更新以及移除节点等。
  • 生成(Generate):将 AST 转换成字符串形式的低版本代码,同时也能创建 Source Map 映射。

具体的流程如下所示: image.png

举个栗子,如果要将 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 ),具体如下所示:

image.png

温馨提示:上述第三个框是以插件的 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 ModuleCommonJS
加载方式编译时运行时
引入性质引用 / 只读浅拷贝 / 可读写
模块作用域thisthis / __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}`);

复制代码

大家可能知道上述语法的差异性,接下来通过理论知识重点讲解一下两者产生差异的主要原因。在前端知识点扫盲(一)/ 编译器原理中重点讲解了整个编译器的执行阶段,如下图所示: image.png 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 UsageWebpack / Final Stepspgk.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 数据结构,具体如下图所示: 05_module_record-768x441.png

事实上, 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.

07_3_phases.png 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 规范中的模块是在运行时同步顺序执行,模块在加载的过程中不会被中断,具体如下图所示: 43_cjs_cycle.png 上图中 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。产生上述差异的根本原因是实例化的方式不同,如下图所示:1665647773-5acd908e6e76f_fix732.png

在 ES Module 的导出中 Module Record 会实时跟踪(wire up 在这里理解为链接或者引用的意思)和绑定每一个导出变量对应的内存地址(从上图可以发现值还没有被填充,而 function 则可以在链接阶段进行初始化),导入同样对应的是导出所对应的同一个内存地址,因此对导入变量进行处理其实处理的是同一个引用地址的数据,如下图所示: ​

1181374600-5acd91c0798bf_fix732.png

CommonJS 规范在导出时事实上导出的是值拷贝,如下图所示: ​

516296747-5acd92fbbb9e6_fix732.png

在上述代码执行的过程中先对变量 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 的上下文环境是有信息的,可能是当前模块的信息,具体没有深究: ​

image.png

温馨提示: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 个问题)。

文章分类
前端
文章标签