前端面试指南
基础知识
基础
语法
框架
工程
网络
性能
插件
操作系统
后端
业务思考
工程化
通用性
应用框架
业务
低代码
笔试实践
数据结构
模板解析
设计模式
正则表达式
本文档使用 MrDoc 发布
-
+
首页
基础
## 列举你所了解的计算机存储设备类型? 现代计算机以存储器为中心,主要由 CPU、I / O 设备以及主存储器三大部分组成。各个部分之间通过总线进行连接通信,具体如下图所示: ![image.png](/media/202203/2022-03-23_2331310.7915534737484923.png) 上图是一种多总线结构的示意图,CPU、主存以及 I / O 设备之间的所有数据都是通过总线进行并行传输,使用局部总线是为了提高 CPU 的吞吐量(CPU 不需要直接跟 I / O 设备通信),而使用高速总线(更贴近 CPU)和 DMA 总线则是为了提升高速 I / O 设备(外设存储器、局域网以及多媒体等)的执行效率。 主存包括随机存储器 RAM 和只读存储器 ROM,其中 ROM 又可以分为 MROM(一次性)、PROM、EPROM、EEPROM 。ROM 中存储的程序(例如启动程序、固化程序)和数据(例如常量数据)在断电后不会丢失。RAM 主要分为静态 RAM(SRAM) 和动态 RAM(DRAM) 两种类型(DRAM 种类很多,包括 SDRAM、RDRAM、CDRAM 等),断电后数据会丢失,主要用于存储临时程序或者临时变量数据。 DRAM 一般访问速度相对较慢。由于现代 CPU 读取速度要求相对较高,因此在 CPU 内核中都会设计 L1、L2 以及 L3 级别的多级高速缓存,这些缓存基本是由 SRAM 构成,一般访问速度较快。 ## 一般代码存储在计算机的哪个设备中?代码在 CPU 中是如何运行的? 高级程序设计语言不能直接被计算机理解并执行,需要通过翻译程序将其转换成特定处理器上可执行的指令,计算机 CPU 的简单工作原理如下所示: ![image.png](/media/202203/2022-03-23_2331310.6811934612262583.png) CPU 主要由控制单元、运算单元和存储单元组成(注意忽略了中断系统),各自的作用如下: * **控制单元** :在节拍脉冲的作用下,将程序计数器(Program Counter,PC)指向的主存或者多级高速缓存中的指令地址送到地址总线,接着获取指令地址所对应的指令并放入指令寄存器 (Instruction Register,IR)中,然后通过指令译码器(Instruction Decoder,ID)分析指令需要进行的操作,最后通过操作控制器(Operation Controller,OC)向其他设备发出微操作控制信号。 * **运算单元** :如果控制单元发出的控制信号存在算术运算(加、减、乘、除、增 1、减 1、取反等)或者逻辑运算(与、或、非、异或),那么需要通过运算单元获取存储单元的计算数据进行处理。 * **存储单元** :包括片内缓存和寄存器组,是 CPU 中临时数据的存储地方。CPU 直接访问主存数据大概需要花费数百个机器周期,而访问寄存器或者片内缓存只需要若干个或者几十个机器周期,因此会使用内部寄存器或缓存来存储和获取临时数据(即将被运算或者运算之后的数据),从而提高 CPU 的运行效率。 除此之外,计算机系统执行程序指令时需要花费时间,其中取出一条指令并执行这条指令的时间叫指令周期。指令周期可以分为若干个阶段(取指周期、间址周期、执行周期和中断周期),每个阶段主要完成一项基本操作,完成基本操作的时间叫机器周期。机器周期是时钟周期的分频,例如最经典的 8051 单片机的机器周期为 12 个时钟周期。时钟周期是 CPU 工作的基本时间单位,也可以称为节拍脉冲或 T 周期(CPU 主频的倒数) 。假设 CPU 的主频是 1 GHz(1 Hz 表示每秒运行 1 次),那么表示时钟周期为 1 / 109 s。理论上 CPU 的主频越高,程序指令执行的速度越快。 ## 什么是指令和指令集? 上图右侧主存中的指令是 CPU 可以支持的处理命令,一般包含算术指令(加和减)、逻辑指令(与、或和非)、数据指令(移动、输入、删除、加载和存储)、流程控制指令以及程序结束指令等,由于 CPU 只能识别二进制码,因此指令是由二进制码组成。除此之外,指令的集合称为指令集(例如汇编语言就是指令集的一种表现形式),常见的指令集有精简指令集(ARM)和复杂指令集(Inter X86)。一般指令集决定了 CPU 处理器的硬件架构,规定了处理器的相应操作。 ## 复杂指令集和精简指令集有什么区别? ## JavaScript 是如何运行的?解释型语言和编译型语言的差异是什么? 早期的计算机只有机器语言时,程序设计必须用二进制数(0 和 1)来编写程序,并且要求程序员对计算机硬件和指令集非常了解,编程的难度较大,操作极易出错。为了解决机器语言的编程问题,慢慢开始出现了符号式的汇编语言(采用 ADD、SUB、MUL、DIV 等符号代表加减乘除)。为了使得计算机可以识别汇编语言,需要将汇编语言翻译成机器能够识别的机器语言(处理器的指令集): ![image.png](/media/202203/2022-03-23_2331310.008427777312542628.png) 由于每一种机器的指令系统不同,需要不同的汇编语言程序与之匹配,因此程序员往往需要针对不同的机器了解其硬件结构和指令系统。为了可以抹平不同机器的指令系统,使得程序员可以更加关注程序设计本身,先后出现了各种面向问题的高级程序设计语言,例如 BASIC 和 C,具体过程如下图所示: ![image.png](/media/202203/2022-03-23_2331310.39230940569579775.png) 高级程序语言会先翻译成汇编语言或者其他中间语言,然后再根据不同的机器翻译成机器语言进行执行。除此之外,汇编语言虚拟机和机器语言机器之间还存在一层操作系统虚拟机,主要用于控制和管理操作系统的全部硬件和软件资源(随着超大规模集成电路技术的不断发展,一些操作系统的软件功能逐步由硬件来替换,例如目前的操作系统已经实现了部分程序的固化,简称固件,将程序永久性的存储在 ROM 中)。机器语言机器还可以继续分解成微程序机器,将每一条机器指令翻译成一组微指令(微程序)进行执行。 上述虚拟机所提供的语言转换程序被称为编译器,主要作用是将某种语言编写的源程序转换成一个等价的机器语言程序,编译器的作用如下图所示: ![image.png](/media/202203/2022-03-23_2331310.09379234819450544.png) 例如 C 语言,可以先通过 gcc 编译器生成 Linux 和 Windows 下的目标 .o 和 .obj 文件(object 文件,即目标文件),然后将目标文件与底层系统库文件、应用程序库文件以及启动文件链接成可执行文件在目标机器上执行。 > 温馨提示:感兴趣的同学可以了解一下 ARM 芯片的程序运行原理,包括使用 IDE 进行程序的编译(IDE 内置编译器,主流编译器包含 ARMCC、IAR 以及 GCC FOR ARM 等,其中一些编译器仅仅随着 IDE 进行捆绑发布,不提供独立使用的能力,而一些编译器则随着 IDE 进行发布的同时,还提供命令行接口的独立使用方式)、通过串口进行程序下载(下载到芯片的代码区初始启动地址映射的存储空间地址)、启动的存储空间地址映射(包括系统存储器、闪存 FLASH、内置 SRAM 等)、芯片的程序启动模式引脚 BOOT 的设置(例如调试代码时常常选择内置 SRAM、真正程序运行的时候选择闪存 FLASH)等。 如果某种高级语言或者应用语言(例如用于人工智能的计算机设计语言)转换的目标语言不是特定计算机的汇编语言,而是面向另一种高级程序语言(很多研究性的编译器将 C 作为目标语言),那么还需要将目标高级程序语言再进行一次额外的编译才能得到最终的目标程序,这种编译器可称为源到源的转换器。 除此之外,有些程序设计语言将编译的过程和最终转换成目标程序进行执行的过程混合在一起,这种语言转换程序通常被称为解释器,主要作用是将某种语言编写的源程序作为输入,将该源程序执行的结果作为输出,解释器的作用如下图所示: ![image.png](/media/202203/2022-03-23_2331310.8503760653880911.png) 解释器和编译器有很多相似之处,都需要对源程序进行分析,并转换成目标机器可识别的机器语言进行执行。只是解释器是在转换源程序的同时立马执行对应的机器语言(转换和执行的过程不分离),而编译器得先把源程序全部转换成机器语言并产生目标文件,然后将目标文件写入相应的程序存储器进行执行(转换和执行的过程分离)。例如 Perl、Scheme、APL 使用解释器进行转换, C、C++ 则使用编译器进行转换,而 Java 和 JavaScript 的转换既包含了编译过程,也包含了解释过程。 ## 简单描述一下 Babel 的编译过程? Babel 是一个源到源的转换编译器(Transpiler),它的主要作用是将 JavaScript 的高版本语法(例如 ES6)转换成低版本语法(例如 ES5),从而可以适配浏览器的兼容性。 > 温馨提示:如果某种高级语言或者应用语言(例如用于人工智能的计算机设计语言)转换的目标语言不是特定计算机的汇编语言,而是面向另一种高级程序语言(很多研究性的编译器将 C 作为目标语言),那么还需要将目标高级程序语言再进行一次额外的编译才能得到最终的目标程序,这种编译器可称为源到源的转换器。 ![image.png](/media/202203/2022-03-24_0025430.3565808261658516.png) 从上图可知,Babel 的编译过程主要可以分为三个阶段: * 解析(Parse):包括词法分析和语法分析。词法分析主要把字符流源代码(Char Stream)转换成令牌流( Token Stream),语法分析主要是将令牌流转换成抽象语法树(Abstract Syntax Tree,AST)。 * 转换(Transform):通过 Babel 的插件能力,将高版本语法的 AST 转换成支持低版本语法的 AST。当然在此过程中也可以对 AST 的 Node 节点进行优化操作,比如添加、更新以及移除节点等。 * 生成(Generate):将 AST 转换成字符串形式的低版本代码,同时也能创建 Source Map 映射。 具体的流程如下所示: ![image.png](/media/202203/2022-03-24_0025430.8965074220296042.png) 举个栗子,如果要将 TypeScript 语法转换成 ES5 语法: ```typescript // 源代码 let a: string = 1; // 目标代码 var a = 1; ``` ### 解析(Parser) Babel 的解析过程(源码到 AST 的转换)可以使用 [@babel/parser](https://link.juejin.cn?target=https%3A%2F%2Fbabeljs.io%2Fdocs%2Fen%2Fbabel-parser "https://babeljs.io/docs/en/babel-parser"),它的主要特点如下: * 支持解析最新的 ES2020 * 支持解析 JSX、Flow & TypeScript * 支持解析实验性的语法提案(支持任何 [Stage 0](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Ftc39%2Fproposals%2Fblob%2Fmaster%2Fstage-0-proposals.md "https://github.com/tc39/proposals/blob/master/stage-0-proposals.md") 的 PRS) @babel/parser 主要是基于输入的字符串流(源代码)进行解析,最后转换成[规范](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fbabel%2Fbabel%2Fblob%2Fmaster%2Fpackages%2Fbabel-parser%2Fast%2Fspec.md "https://github.com/babel/babel/blob/master/packages/babel-parser/ast/spec.md")(基于 [ESTree ](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Festree%2Festree "https://github.com/estree/estree")进行调整)的 AST,如下所示: ```typescript 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 阶段主要是进行词法和语法分析,如果词法或者语法分析错误,那么会在该阶段被检测出来。如果检测正确,则可以进入语法的转换阶段。 ### 转换(Transform) Babel 的转换过程(AST 到 AST 的转换)主要使用 [@babel/traverse](https://link.juejin.cn?target=https%3A%2F%2Fbabeljs.io%2Fdocs%2Fen%2Fbabel-traverse "https://babeljs.io/docs/en/babel-traverse"),该库包可以通过[访问者模式](https://link.juejin.cn?target=https%3A%2F%2Fzh.wikipedia.org%2Fwiki%2F%25E8%25AE%25BF%25E9%2597%25AE%25E8%2580%2585%25E6%25A8%25A1%25E5%25BC%258F "https://zh.wikipedia.org/wiki/%E8%AE%BF%E9%97%AE%E8%80%85%E6%A8%A1%E5%BC%8F")自动遍历并访问 AST 树的每一个 Node 节点信息,从而实现节点的替换、移除和添加操作,如下所示: ```typescript 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 插件手册](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fjamiebuilds%2Fbabel-handbook%2Fblob%2Fmaster%2Ftranslations%2Fzh-Hans%2Fplugin-handbook.md "https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md")。除此之外,你可能已经注意到这里的转换逻辑其实可以理解为实现一个简单的 Babel 插件,只是没有封装成 Npm 包。当然,在真正的插件开发开发中,还可以配合 [@babel/types](https://link.juejin.cn?target=https%3A%2F%2Fbabeljs.io%2Fdocs%2Fen%2Fbabel-types "https://babeljs.io/docs/en/babel-types") 工具包进行节点信息的判断处理。 > 温馨提示:这里只是简单的一个 Demo 示例,在真正转换 let、const 等变量声明的过程中,还会遇到处理暂时性死区(Temporal Dead Zone, TDZ)的情况,更多详细信息可以查看官方的插件 [babel-plugin-transform-block-scoping](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fbabel%2Fbabel%2Fblob%2Fmain%2Fpackages%2Fbabel-plugin-transform-block-scoping%2Fsrc%2Findex.js "https://github.com/babel/babel/blob/main/packages/babel-plugin-transform-block-scoping/src/index.js")。 ### 生成(Generate) Babel 的代码生成过程(AST 到目标代码的转换)主要使用 [@babel/generator](https://link.juejin.cn?target=https%3A%2F%2Fbabeljs.io%2Fdocs%2Fen%2Fbabel-generator "https://babeljs.io/docs/en/babel-generator"),如下所示: ```typescript 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](https://link.juejin.cn?target=https%3A%2F%2Fastexplorer.net%2F "https://astexplorer.net/") (也可以使用 Babel 官网自带的 [Try It Out](https://link.juejin.cn?target=https%3A%2F%2Fbabeljs.io%2Frepl "https://babeljs.io/repl") ),具体如下所示: ![image.png](/media/202203/2022-03-24_0025430.6227204486545951.png) > 温馨提示:上述第三个框是以插件的 API 形式进行调用,如果想了解 Babel 的插件开发,可以查看 [Babel 插件手册 / 编写你的第一个 Babel 插件](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fjamiebuilds%2Fbabel-handbook%2Fblob%2Fmaster%2Ftranslations%2Fzh-Hans%2Fplugin-handbook.md%23toc-writing-your-first-babel-plugin "https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md#toc-writing-your-first-babel-plugin")。 如果你觉得 Babel 的编译过程太过于简单,你可以尝试更高阶的玩法,比如自己设计词法和语法规则从而实现一个简单的编译器(Babel 内置了这些规则),你完全可以不只是做出一个源到源的转换编译器,而是实现一个真正的从 JavaScript (TypeScript) 到机器代码的完整编译器,包括实现中间代码 IR 以及提供机器的运行环境等,这里给出一个可以尝试这种高阶玩法的库包 [antlr4ts](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Ftunnelvisionlabs%2Fantlr4ts "https://github.com/tunnelvisionlabs/antlr4ts")(可以配合交叉编译工具链 [riscv-gnu-toolchain](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Friscv%2Friscv-gnu-toolchain "https://github.com/riscv/riscv-gnu-toolchain"),gcc 编译工具的制作还是非常耗时的)。 > 阅读链接:[ Babel 用户手册](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fjamiebuilds%2Fbabel-handbook%2Fblob%2Fmaster%2Ftranslations%2Fzh-Hans%2Fuser-handbook.md "https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/user-handbook.md")、[Babel 插件手册](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fjamiebuilds%2Fbabel-handbook%2Fblob%2Fmaster%2Ftranslations%2Fzh-Hans%2Fplugin-handbook.md "https://github.com/jamiebuilds/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md") ## JavaScript 中的数组和函数在内存中是如何存储的? JavaScript 中的数组存储大致需要分为两种情况: * 同种类型数据的数组分配连续的内存空间 * 存在非同种类型数据的数组使用哈希映射分配内存空间 > 温馨提示:可以想象一下连续的内存空间只需要根据索引(指针)直接计算存储位置即可。如果是哈希映射那么首先需要计算索引值,然后如果索引值有冲突的场景下还需要进行二次查找(需要知道哈希的存储方式)。 ## 浏览器和 Node.js 中的事件循环机制有什么区别? > 阅读链接:[面试分享:两年工作经验成功面试阿里 P6 总结](https://juejin.cn/post/6844903928442667015#heading-43 "https://juejin.cn/post/6844903928442667015#heading-43") - 了解 Event Loop 吗? ## ES6 Modules 相对于 CommonJS 的优势是什么? > 温馨提示:如果你只是想知道本题的答案,那么直接进入传送门 [16.8.2 Static module structure](https://link.juejin.cn/?target=https%3A%2F%2Fexploringjs.com%2Fes6%2Fch_modules.html%23static-module-structure "https://exploringjs.com/es6/ch_modules.html#static-module-structure") 。除此之外,以下 ES Module 的代码只在 Node.js 环境中进行了测试,感兴趣的同学可以使用浏览器进行再测试。对不同规范模块的代码编译选择了 Webpack,感兴趣的同学也可以采用 Rollup 进行编译测试。 关于 ES Module 和 CommonJS 的规范以及语法,这里不再详细叙述,如果你还不了解这两者的语法糖,可以查看 [ECMAScript 6 入门 / Module 语法](https://link.juejin.cn/?target=https%3A%2F%2Fes6.ruanyifeng.com%2F%23docs%2Fmodule "https://es6.ruanyifeng.com/#docs/module")、[ES Module 标准](https://link.juejin.cn/?target=https%3A%2F%2Ftc39.es%2Fecma262%2F%23sec-modules "https://tc39.es/ecma262/#sec-modules")以及 [Node.js 的 CommonJS 模块](https://link.juejin.cn/?target=http%3A%2F%2Fnodejs.cn%2Fapi%2Fmodules.html "http://nodejs.cn/api/modules.html"),两者的主要区别如下所示: | 类型 | ES Module | CommonJS | | ---------- | ----------- | -------------------------------- | | 加载方式 | 编译时 | 运行时 | | 引入性质 | 引用 / 只读 | 浅拷贝 / 可读写 | | 模块作用域 | this | this / __filename / __dirname... | ### 加载方式 加载方式是 ES Module 和 CommonJS 的最主要区别,这使得两者在**编译时**和**运行时**上各有优劣。首先来看一下 ES Module 在加载方式上的特性,如下所示: ```typescript // 编译时: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 在加载方式上的特性如下所示: ```typescript const a = 1; if(a === 1) { // VS Code 鼠标 hover 到 b 时,无法显示出 b 的类型信息 const b = require('./b'); } const c = 'b'; const d = require(`./${c}`); ``` 大家可能知道上述语法的差异性,接下来通过理论知识重点讲解一下两者产生差异的主要原因。在[前端知识点扫盲(一)/ 编译器原理](https://juejin.cn/post/6987549240436195364 "https://juejin.cn/post/6987549240436195364")中重点讲解了整个编译器的执行阶段,如下图所示: ![image.png](/media/202203/2022-03-24_0030560.5184211533165521.png) ES Module 是采用[静态](https://link.juejin.cn/?target=https%3A%2F%2Fexploringjs.com%2Fes6%2Fch_modules.html%23static-module-structure "https://exploringjs.com/es6/ch_modules.html#static-module-structure")的加载方式,也就是模块中导入导出的依赖关系可以在代码编译时就确定下来。如上图所示,代码在编译的过程中可以做的事情包含词法和语法分析、类型检查以及代码优化等等。因此采用 ES Module 进行代码设计时可以在编译时通过 ESLint 快速定位出模块的词法语法错误以及类型信息等。ES Module 中会产生一些错误的加载方式,是因为这些加载方式含有逻辑和变量的运行时判断,只有在代码的运行时阶段才能确定导入导出的依赖关系,这明显和 ES Module 的加载机制不相符。 CommonJS 相对于 ES Module 在加载模块的方式上存在明显差异,是因为 CommonJS 在运行时进行加载方式的动态解析,在运行时阶段才能确定的导入导出关系,因此无法进行静态编译优化和类型检查。 > 温馨提示:注意 import 语法和 import() 的区别,import() 是 [tc39 中的一种提案](https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Ftc39%2Fproposal-dynamic-import "https://github.com/tc39/proposal-dynamic-import"),该提案允许你可以使用类似于 import(`${path}/foo.js`) 的导入语句(估计是借鉴了 CommonJS 可以动态加载模块的特性),因此也允许你在运行时进行条件加载,也就是所谓的[懒加载](https://link.juejin.cn/?target=https%3A%2F%2Fwebpack.docschina.org%2Fguides%2Flazy-loading%2F "https://webpack.docschina.org/guides/lazy-loading/")。除此之外,import 和 import() 还存在其他一些重要的区别,大家还是自行谷歌一下。 ### 编译优化 由于 ES Module 是在编译时就能确定模块之间的依赖关系,因此可以在编译的过程中进行代码优化。例如: ```typescript // 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)进行代码编译,生成的编译产物如下所示: ```typescript (()=>{"use strict";console.log((function(){console.log("a")}))})(); ``` 可以发现编译生成的产物没有 function b 的代码,这是在编译阶段对代码进行了优化,移除了未使用的代码(Dead Code),这种优化的术语被叫做 [Tree Shaking](https://link.juejin.cn/?target=https%3A%2F%2Fwebpack.docschina.org%2Fguides%2Ftree-shaking%2F "https://webpack.docschina.org/guides/tree-shaking/")。 > 温馨提示:你可以将应用程序想象成一棵树。绿色表示实际用到的 Source Code(源码)和 Library(库),是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动这棵树,使它们落下。 > 温馨提示:在 ES Module 中可能会因为代码具有副作用(例如操作原型方法以及添加全局对象的属性等)导致优化失败,如果想深入了解 Tree Shaking 的更多优化注意事项,可以深入阅读[你的 Tree-Shaking 并没什么卵用](https://juejin.cn/post/6844903549290151949#heading-7 "https://juejin.cn/post/6844903549290151949#heading-7")。 为了对比 ES Module 的编译优化能力,同样采用 CommonJS 规范进行模块导入: ```typescript // 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 进行代码编译,生成的编译产物如下所示: ```typescript (() => { 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](https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fdherman%2Fdefense-of-dot-js%2Fblob%2Fmaster%2Fproposal.md%23typical-usage "https://github.com/dherman/defense-of-dot-js/blob/master/proposal.md#typical-usage")、 [Webpack / Final Steps](https://link.juejin.cn/?target=https%3A%2F%2Fwebpack.js.org%2Fguides%2Fauthor-libraries%2F%23final-steps "https://webpack.js.org/guides/author-libraries/#final-steps")、[pgk.module](https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Frollup%2Frollup%2Fwiki%2Fpkg.module "https://github.com/rollup/rollup/wiki/pkg.module") 以及 [rollup.js / Tree Shaki…](https://link.juejin.cn/?target=https%3A%2F%2Frollupjs.org%2Fguide%2Fen%2F%23tree-shaking "https://rollupjs.org/guide/en/#tree-shaking")。 > 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. ### 加载原理 & 引入性质 > 温馨提示:下述理论部分以及图片内容均出自于 2018 年的文章 [ ES modules: A cartoon deep-dive](https://link.juejin.cn/?target=https%3A%2F%2Fhacks.mozilla.org%2F2018%2F03%2Fes-modules-a-cartoon-deep-dive%2F "https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/"),如果想要了解更多原理信息可以查看 TC39 的 [16.2 Modules](https://link.juejin.cn/?target=https%3A%2F%2Ftc39.es%2Fecma262%2F%23sec-modules "https://tc39.es/ecma262/#sec-modules")。 在 ES Module 中使用模块进行开发,其实是在编译时构建模块之间的依赖关系图。在浏览器或者服务的文件系统中运行 ES6 代码时,需要解析所有的模块文件,然后将模块转换成 Module Record 数据结构,具体如下图所示: ![05_module_record-768x441.png](/media/202203/2022-03-24_0030560.26162274645988925.png) 事实上, ES Module 的加载过程主要分为如下三个阶段: * 构建(Construction):主要分为查找、加载(在浏览器中是下载文件,在本地文件系统中是加载文件)、然后把文件解析成 Module Record。 * 实例化(Instantiation):给所有的 Module Record 分配内存空间(此刻还没有填充值),并根据导入导出关系确定各自之间的引用关系,确定引用关系的过程称为链接(Linking)。 * 运行(Evaluation):运行代码,给内存地址填充运行时的模块数据。 > 温馨提示:import 的上述三个阶段其实在 import() 中体现的更加直观(尽管 import 已经被多数浏览器支持,但是我们在真正开发和运行的过程中仍然会使用编译后的代码运行,而不是采用浏览器 script 标签的远程地址的动态异步加载方式),而 import() 事实上如果要实现懒加载优化(例如 Vue 里的路由懒加载,更多的是在浏览器的宿主环境而不是 Node.js 环境,这里不展开更多编译后实现方式的细节问题),大概率要完整经历上述三个阶段的**异步**加载过程,具体再次查看 [ tc39 动态提案](https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Ftc39%2Fproposal-dynamic-import%23proposed-solution "https://github.com/tc39/proposal-dynamic-import#proposed-solution"):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](/media/202203/2022-03-24_0030560.3063392479607946.png) ES Module 模块加载的三个阶段分别需要在编译时和运行时进行(可能有的同学会像我一样好奇实例化阶段到底是在编译时还是运行时进行,根据 [tc39 动态加载提案](https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Ftc39%2Fproposal-dynamic-import "https://github.com/tc39/proposal-dynamic-import")里的描述可以得出你想要的答案: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](/media/202203/2022-03-24_0030560.887630089618049.png) 上图中 main.js 在运行加载 counter.js 时,会先等待 counter.js 运行完成后才能继续运行代码,因此在 CommonJS 中模块的加载是阻塞式的。CommonJS 采用同步阻塞式加载模块是因为它只需要从本地的文件系统中加载文件,耗费的性能和时间很少,而 ES Module 在浏览器(注意这里说的是浏览器)中运行的时候需要下载文件然后才能进行实例化和运行,如果这个过程是同步进行,那么会影响页面的加载性能。 从 ES Module 链接的过程可以发现模块之间的引用关系是内存的地址引用,如下所示: ```typescript // 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 规范的执行: ```typescript // 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](/media/202203/2022-03-24_0030560.39645313230938994.png) 在 ES Module 的导出中 Module Record 会实时跟踪(wire up 在这里理解为链接或者引用的意思)和绑定每一个导出变量对应的内存地址(从上图可以发现值还没有被填充,而 function 则可以在链接阶段进行初始化),导入同样对应的是导出所对应的同一个内存地址,因此对导入变量进行处理其实处理的是同一个引用地址的数据,如下图所示: ![1181374600-5acd91c0798bf_fix732.png](/media/202203/2022-03-24_0030560.950422194020698.png) CommonJS 规范在导出时事实上导出的是值拷贝,如下图所示: ![516296747-5acd92fbbb9e6_fix732.png](/media/202203/2022-03-24_0030560.5130451046224292.png) 在上述代码执行的过程中先对变量 a 进行值拷贝,因此尽管设置了定时器,变量 a 被引入后打印的信息仍然是 1。需要注意的是这种拷贝是浅拷贝,如下所示: ```typescript // 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),编译之后的代码如下所示: ```typescript (() => { 'use strict'; let e = 1; setTimeout(() => { e++; }, 1e3), setTimeout(() => { console.log(e); }, 2e3); })(); 复制代码 ``` 可以看出,将 ES Module 的代码进行编译后,使用的是同一个变量值,此时将 CommonJS 的代码进行编译: ```typescript (() => { 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 规范的缓存描述](https://link.juejin.cn/?target=http%3A%2F%2Fnodejs.cn%2Fapi%2Fmodules.html%23modules_caching "http://nodejs.cn/api/modules.html#modules_caching"),发现 Webpack 的编译完全符合 CommonJS 规范的缓存机制。了解了这个机制以后,你会发现多次使用 require 进行模块加载不会导致代码被执行多次,这是解决无限[循环依赖](https://link.juejin.cn/?target=http%3A%2F%2Fnodejs.cn%2Fapi%2Fmodules.html%23modules_cycles "http://nodejs.cn/api/modules.html#modules_cycles")的一个重要特征。 除了引入的方式可能会有区别之外,引入的代码可能还存在一些区别,比如在 ES Module 中: ```typescript // 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 中导入的变量则可读可写。当然除此之外,你也可以尝试更多的其他方面,比如: ```typescript // 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 代码,会直接抛出下述错误信息: ```typescript 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 编译的代码没有。 ### 模块作用域 大家会发现在 Node.js 的模块中设计代码时可以使用诸如 __dirname、__filename 之类的变量(需要注意在 Webpack 编译出的 CommonJS 前端产物中,并没有 __filename、__dirname 等变量信息,浏览器中并不需要这些文件系统的变量信息),是因为 Node.js 在加载模块时会对其进行如下包装: ```typescript // https://github.com/nodejs/node/blob/master/lib/internal/modules/cjs/loader.js#L206 const wrapper = [ '(function (exports, require, module, __filename, __dirname) { ', '\n});', ]; ``` 索性看到这个模块作用域的代码,我们就继续查看一下 require 的源码: ```typescript // 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](https://link.juejin.cn/?target=http%3A%2F%2Fnodejs.cn%2Fapi%2Fvm.html "http://nodejs.cn/api/vm.html")。除此之外,感兴趣的同学也了解一下 import 语法在 Node.js 中的底层实现,这里脑壳疼,就没有深入研究了。 > 温馨提示的温馨提示:比如你在源码中找不到上述代码的执行链路,那最简单的方式就是引入一个错误的模块,让错误信息将错误栈抛出来,比如如下所示,你会发现最底下执行了 wrapSafe,好了你又可以开始探索了,因为你对 safe 这样的字眼一定感到好奇,底下是不是执行的时候用了沙箱隔离呢? ```typescript 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 模块的代码: ```typescript // hello.js export function a() { console.log('this: ', this); // undefined } // index.js import { a } from './hello.js'; a(); ``` 我们接着执行 CommonJS 的代码: ```typescript // hello.js exports.a = function () { console.log('this: ', this); }; // index.js let { a } = require('./hello'); a(); ``` 你会发现 this 的上下文环境是有信息的,可能是当前模块的信息,具体没有深究: ![image.png](/media/202203/2022-03-24_0030560.3923783998929683.png) > 温馨提示:Node.js 的调试还能在浏览器进行?可以查看一下 [Node.js 调试](https://link.juejin.cn/?target=https%3A%2F%2Fnodejs.org%2Fzh-cn%2Fdocs%2Fguides%2Fdebugging-getting-started%2F "https://nodejs.org/zh-cn/docs/guides/debugging-getting-started/"),当然你也可以使用 VS Code 进行调试,需要进行一些额外的 launch 配置,当然如果你觉得 Node.js 自带的浏览器调试方式太难受了,也可以想想办法,如何通过 IP 端口在浏览器中进行调试,并且可以做到代码变动监听调试。 大家可以不用太纠结代码的细致实现,只需要大致可以了解到 CommonJS 中模块的导入过程即可,事实上 Webpack 编译的结果大致可以理解为该代码的浏览器简易版。那还记得我之前在面试分享中的题目:[两年工作经验成功面试阿里 P6 总结 / 如何在 Node 端配置路径别名(类似于 Webpack 中的 alias 配置)](https://juejin.cn/post/6844903928442667015#heading-37 "https://juejin.cn/post/6844903928442667015#heading-37"),如果你阅读了上述源码,基本上思路就是 HACK 原型链上的 require 方法: ```typescript const Module = require('module'); const originalRequire = Module.prototype.require; Module.prototype.require = function(id){ // 这里加入 path 的逻辑 return originalRequire.apply(this, id); }; ``` ## 高级程序设计语言是如何编译成机器语言的? ## 编译器一般由哪几个阶段组成?数据类型检查一般在什么阶段进行? ## 编译过程中虚拟机的作用是什么? ## 什么是中间代码(IR),它的作用是什么? ## 什么是交叉编译? 编译器的设计是一个非常庞大和复杂的软件系统设计,在真正设计的时候需要解决两个相对重要的问题: * 如何分析不同高级程序语言设计的源程序 * 如何将源程序的功能等价映射到不同指令系统的目标机器 为了解决上述两项问题,编译器的设计最终被分解成前端(注意这里所说的不是 Web 前端)和后端两个编译阶段,前端用于解决第一个问题,而后端用于解决第二个问题,具体如下图所示: ![image.png](/media/202203/2022-03-23_2331310.9940354997197123.png) 上图中的中间表示(Intermediate Representation,IR)是程序结构的一种表现方式,它会比 AST(后续讲解)更加接近汇编语言或者指令集,同时也会保留源程序中的一些高级信息,除此之外 ,它的种类很多,包括[三地址码(Three Address Code, TAC)](https://link.juejin.cn/?target=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FThree-address_code "https://en.wikipedia.org/wiki/Three-address_code")、[静态单赋值形式(Static Single Assignment Form, SSA)](https://link.juejin.cn/?target=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FStatic_single_assignment_form "https://en.wikipedia.org/wiki/Static_single_assignment_form")以及基于栈的 IR 等,具体作用包括: * 靠近前端部分主要适配不同的源程序,靠近后端部分主要适配不同的指令集,更易于编译器的错误调试,容易识别是 IR 之前还是之后出问题 * 如下左图所示,如果没有 IR,那么源程序到指令集之间需要进行一一适配,而有了中间表示,则可以使得编译器的职责更加分离,源程序的编译更多关注如何转换成 IR,而不是去适配不同的指令集 * IR 本身可以做到多趟迭代从而优化源程序,在每一趟迭代的过程中可以研究代码并记录优化的细节,方便后续的迭代查找并利用这些优化信息,最终可以高效输出更优的目标程序 ![image.png](/media/202203/2022-03-23_2331310.007671111314925483.png) 由于 IR 可以进行多趟迭代进行程序优化,因此在编译器中可插入一个新的优化阶段,如下图所示: ![image.png](/media/202203/2022-03-23_2331310.7515498285730373.png) 优化器可以对 IR 处理一遍或者多遍,从而生成更快执行速度(例如找到循环中不变的计算并对其进行优化从而减少运算次数)或者更小体积的目标程序,也可能用于产生更少异常或者更低功耗的目标程序。除此之外,前端和后端内部还可以细分为多个处理步骤,具体如下图所示: ![image.png](/media/202203/2022-03-23_2331310.6744715133410286.png) 优化器中的每一遍优化处理都可以使用一个或多个优化技术来改进代码,每一趟处理最终都是读写 IR 的操作,这样不仅仅可以使得优化可以更加高效,同时也可以降低优化的复杂度,还提高了优化的灵活性,可以使得编译器配置不同的优化选项,达到组合优化的效果。 ## 发布 / 订阅模式和观察者模式的区别是什么? > 阅读链接:[基于 Vue 实现一个简易 MVVM ](https://juejin.cn/post/6844904099704471559#heading-10 "https://juejin.cn/post/6844904099704471559#heading-10")- 观察者模式和发布/订阅模式 ## 装饰器模式一般会在什么场合使用? ## 谈谈你对大型项目的代码解耦设计理解?什么是 Ioc?一般 DI 采用什么设计模式实现? ## 列举你所了解的编程范式? 编程范式(Programming paradigm)是指计算机编程的基本风格或者典型模式,可以简单理解为编程学科中实践出来的具有哲学和理论依据的一些经典原型。常见的编程范式有: * 面向过程(Process Oriented Programming,POP) * 面向对象(Object Oriented Programming,OOP) * 面向接口(Interface Oriented Programming, IOP) * 面向切面(Aspect Oriented Programming,AOP) * 函数式(Funtional Programming,FP) * 响应式(Reactive Programming,RP) * 函数响应式(Functional Reactive Programming,FRP) > 阅读链接::如果你对于编程范式的定义相对模糊,可以继续阅读 [What is the precise definition of programming paradigm?](https://link.juejin.cn/?target=https%3A%2F%2Fsoftwareengineering.stackexchange.com%2Fquestions%2F166442%2Fwhat-is-the-precise-definition-of-programming-paradigm%23 "https://softwareengineering.stackexchange.com/questions/166442/what-is-the-precise-definition-of-programming-paradigm#") 了解更多。 不同的语言可以支持多种不同的编程范式,例如 C 语言支持 POP 范式,C++ 和 Java 语言支持 OOP 范式,Swift 语言则可以支持 FP 范式,而 Web 前端中的 JavaScript 可以支持上述列出的所有编程范式。 ## 什么是面向切面(AOP)的编程? ## 什么是函数式编程? 顾名思义,函数式编程是使用函数来进行高效处理数据或数据流的一种编程方式。在数学中,函数的三要素是定义域、值域和**对应关系。**假设 A、B 是非空数集,对于集合 A 中的任意一个数 x,在集合 B 中都有唯一确定的数 f(x) 和它对应,那么可以将 f 称为从 A 到 B 的一个函数,记作:y = f(x)。在函数式编程中函数的概念和数学函数的概念类似,主要是描述形参 x 和返回值 y 之间的**对应关系,**如下图所示: ![](/media/202203/2022-03-23_2331310.04198157112170442.png) > 温馨提示:图片来自于[简明 JavaScript 函数式编程——入门篇](https://juejin.cn/post/6844903936378273799 "https://juejin.cn/post/6844903936378273799")。 在实际的编程中,可以将各种明确**对应关系**的函数进行传递、组合从而达到处理数据的最终目的。在此过程中,我们的关注点不在于如何去实现**对应关系,**而在于如何将各种已有的对应关系进行高效联动,从而可快速进行数据转换,达到最终的数据处理目的,提供开发效率。 **简单示例** 尽管你对函数式编程的概念有所了解,但是你仍然不知道函数式编程到底有什么特点。这里我们仍然拿 OOP 编程范式来举例,假设希望通过 OOP 编程来解决数学的加减乘除问题: ```typescript class MathObject { constructor(private value: number) {} public add(num: number): MathObject { this.value += num; return this; } public multiply(num: number): MathObject { this.value *= num; return this; } public getValue(): number { return this.value; } } const a = new MathObject(1); a.add(1).multiply(2).add(a.multiply(2).getValue()); ``` 我们希望通过上述程序来解决 (1 + 2) * 2 + 1 * 2 的问题,但实际上计算出来的结果是 24,因为在代码内部有一个 `this.value` 的状态值需要跟踪,这会使得结果不符合预期。 接下来我们采用函数式编程的方式: ```typescript function add(a: number, b: number): number { return a + b; } function multiply(a: number, b: number): number { return a * b; } const a: number = 1; const b: number = 2; add(multiply(add(a, b), b), multiply(a, b)); ``` 以上程序计算的结果是 8,完全符合预期。我们知道了 `add` 和 `multiply` 两个函数的实际 **对应关系** ,通过将**对应关系**进行有效的组合和传递,达到了最终的计算结果。除此之外,这两个函数还可以根据数学定律得出更优雅的组合方式: ```typescript add(multiply(add(a, b), b), multiply(a, b)); // 根据数学定律分配律:a * b + a * c = a * (b + c),得出: // (a + b) * b + a * b = (2a + b) * b // 简化上述函数的组合方式 multiply(add(add(a, a), b), b); ``` 我们完全不需要追踪类似于 OOP 编程范式中可能存在的内部状态数据,事实上对于数学定律中的结合律、交换律、同一律以及分配律,上述的函数式编程代码足可以胜任。 **原则** 通过上述简单的例子可以发现,要实现高可复用的函数**(对应关系)**,一定要遵循某些特定的原则,否则在使用的时候可能无法进行高效的传递和组合,例如 * 高内聚低耦合 * 最小意外原则 * 单一职责原则 * ... 如果你之前经常进行无原则性的代码设计,那么在设计过程中可能会出现各种出乎意料的问题(这是为什么新手老是出现一些稀奇古怪问题的主要原因)。函数式编程可以有效的通过一些原则性的约束使你设计出更加健壮和优雅的代码,并且在不断的实践过程中进行经验式叠加,从而提高开发效率。 **特点** 虽然我们在使用函数的过程中更多的不再关注函数如何实现( **对应关系)** ,但是真正在使用和设计函数的时候需要注意以下一些特点: * 声明式(Declarative Programming) * 一等公民(First Class Function) * 纯函数(Pure Function) * 无状态和数据不可变(Statelessness and Immutable Data) * ... **声明式** 我们以前设计的代码通常是命令式编程方式,这种编程方式往往注重具体的实现的过程( **对应关系** ),而函数式编程则采用声明式的编程方式,往往注重如何去组合已有的**对应关系。**简单举个例子: ```typescript // 命令式 const array = [0.8, 1.7, 2.5, 3.4]; const filterArray = []; for (let i = 0; i < array.length; i++) { const integer = Math.floor(array[i]); if (integer < 2) { continue; } filterArray.push(integer); } // 声明式 // map 和 filter 不会修改原有数组,而是产生新的数组返回 [0.8, 1.7, 2.5, 3.4].map((item) => Math.floor(item)).filter((item) => item > 1); 复制代码 ``` 命令式代码一步一步的告诉计算机需要执行哪些语句,需要关心变量的实例化情况、循环的具体过程以及跟踪变量状态的变化过程。声明式代码更多的不再关心代码的具体执行过程,而是采用表达式的组合变换去处理问题,不再强调 **怎么做** ,而是指明**做什么。**声明式编程方式可以将我们设计代码的关注点彻底从过程式解放出来,从而提高开发效率。 **一等公民** 在 JavaScript 中,函数的使用非常灵活,例如可以对函数进行以下操作: ```typescript interface IHello { (name: string): string; key?: string; arr?: number[]; fn?(name: string): string; } // 函数声明提升 console.log(hello instanceof Object); // true // 函数声明提升 // hello 和其他引用类型的对象一样,都有属性和方法 hello.key = 'key'; hello.arr = [1, 2]; hello.fn = function (name: string) { return `hello.fn, ${name}`; }; // 函数声明提升 // 注意函数表达式不能在声明前执行,例如不能在这里使用 helloCopy('world') hello('world'); // 函数 // 创建新的函数对象,将函数的引用指向变量 hello // hello 仅仅是变量的名称 function hello(name: string): string { return `hello, ${name}`; } console.log(hello.key); // key console.log(hello.arr); // [1,2] console.log(hello.name); // hello // 函数表达式 const helloCopy: IHello = hello; helloCopy('world'); function transferHello(name: string, hello: Hello) { return hello('world'); } // 把函数对象当作实参传递 transferHello('world', helloCopy); // 把匿名函数当作实参传递 transferHello('world', function (name: string) { return `hello, ${name}`; }); ``` 通过以上示例可以看出,函数继承至对象并拥有对象的特性。在 JavaScript 中可以对函数进行参数传递、变量赋值或数组操作等等,因此把函数称为一等公民。函数式编程的核心就是对函数进行组合或传递,JavaScript 中函数这种灵活的特性是满足函数式编程的重要条件。 **纯函数** 纯函数是是指在相同的参数调用下,函数的返回值唯一不变。这跟数学中函数的映射关系类似,同样的 x 不可能映射多个不同的 y。使用函数式编程会使得函数的调用非常稳定,从而降低 Bug 产生的机率。当然要实现纯函数的这种特性,需要函数不能包含以下一些副作用: * 操作 Http 请求 * 可变数据(包括在函数内部改变输入参数) * DOM 操作 * 打印日志 * 访问系统状态 * 操作文件系统 * 操作数据库 * ... 从以上常见的一些副作用可以看出,纯函数的实现需要遵循最小意外原则,为了确保函数的稳定唯一的输入和输出,尽量应该避免与函数外部的环境进行任何交互行为,从而防止外部环境对函数内部产生无法预料的影响。纯函数的实现应该自给自足,举几个例子: ```typescript // 如果使用 const 声明 min 变量(基本数据类型),则可以保证以下函数的纯粹性 let min: number = 1; // 非纯函数 // 依赖外部环境变量 min,一旦 min 发生变化则输入和返回不唯一 function isEqual(num: number): boolean { return num === min; } // 纯函数 function isEqual(num: number): boolean { return num === 1; } // 非纯函数 function request<T, S>(url: string, params: T): Promise<S> { // 会产生请求成功和请求失败两种结果,返回的结果可能不唯一 return $.getJson(url, params); } // 纯函数 function request<T, S>(url: string, params: T) : () => Promise<S> { return function() { return $.getJson(url, params); } } ``` 纯函数的特性使得函数式编程具备以下特性: * 可缓存性(Cacheable) * 可移植性(Portable) * 可测试性(Testable) 可缓存性和可测试性基于纯函数输入输出唯一不变的特性,可移植性则主要基于纯函数不依赖外部环境的特性。这里举一个可缓存的例子: ```typescript interface ICache<T> { [arg: string]: T; } interface ISquare<T> { (x: T): T; } // 简单的缓存函数(忽略通用性和健壮性) function memoize<T>(fn: ISquare<T>): ISquare<T> { const cache: ICache<T> = {}; return function (x: T) { const arg: string = JSON.stringify(x); cache[arg] = cache[arg] || fn.call(fn, x); return cache[arg]; }; } // 纯函数 function square(x: number): number { return x * x; } const memoSquare = memoize<number>(square); memoSquare(4); // 不会再次调用纯函数 square,而是直接从缓存中获取值 // 由于输入和输出的唯一性,获取缓存结果可靠稳定 // 提升代码的运行效率 memoSquare(4); ``` **无状态和数据不可变** 在函数式编程的简单示例中已经可以清晰的感受到函数式编程绝对不能依赖内部状态,而在纯函数中则说明了函数式编程不能依赖外部的环境或状态,因为一旦依赖的状态变化,不能保证函数根据对应关系所计算的返回值因为状态的变化仍然保持不变。 这里单独讲解一下数据不可变,在 JavaScript 中有很多数组操作的方法,举个例子: ```typescript const arr = [1, 2, 3]; console.log(arr.slice(0, 2)); // [1, 2] console.log(arr); // [1, 2, 3] console.log(arr.slice(0, 2)); // [1, 2] console.log(arr); // [1, 2, 3] console.log(arr.splice(0, 1)); // [1] console.log(arr); // [2, 3] console.log(arr.splice(0, 1)); // [2] console.log(arr); // [3] ``` 这里的 `slice` 方法多次调用都不会改变原有数组,且会产生相同的输出。而 `splice` 每次调用都在修改原数组,且产生的输出也不相同。 在函数式编程中,这种会改变原有数据的函数已经不再是纯函数,应该尽量避免使用。 > 阅读链接:如果想要了解更深入的函数式编程知识点,可以额外阅读[函数式编程指北](https://link.juejin.cn/?target=https%3A%2F%2Fllh911001.gitbooks.io%2Fmostly-adequate-guide-chinese%2Fcontent%2F "https://llh911001.gitbooks.io/mostly-adequate-guide-chinese/content/")。 ## 响应式编程的使用场景有哪些? 响应式编程是一种基于**观察者(发布 / 订阅)模式**并且面向 **异步** (Asynchronous) **数据流** (Data Stream)和**变化传播**的声明式编程范式。响应式编程主要适用的场景包含: * 用户和系统发起的连续事件处理,例如鼠标的点击、键盘的按键或者通信设备发起的信号等 * 非可靠的网络或者通信处理(例如 HTTP 网络的请求重试) * 连续的异步 IO 处理 * 复杂的继发事务处理(例如一次事件涉及到多个继发的网络请求) * 高并发的消息处理(例如 IM 聊天) * ...
追风者
2022年3月24日 00:34
转发文档
收藏文档
上一篇
下一篇
手机扫码
复制链接
手机扫一扫转发分享
复制链接
关于 MrDoc
觅思文档MrDoc
是
州的先生
开发并开源的在线文档系统,其适合作为个人和小型团队的云笔记、文档和知识库管理工具。
如果觅思文档给你或你的团队带来了帮助,欢迎对作者进行一些打赏捐助,这将有力支持作者持续投入精力更新和维护觅思文档,感谢你的捐助!
>>>捐助鸣谢列表
微信
支付宝
QQ
PayPal
Markdown文件
分享
链接
类型
密码
更新密码