前言
本文是继前端面试知识点(一)之后的第二篇面试知识点解析。在第一篇面试知识点解析中给出了 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),从而可以适配浏览器的兼容性。
从上图可知,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 工具包进行节点信息的判断处理。
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 ),具体如下所示:
如果你觉得 Babel 的编译过程太过于简单,你可以尝试更高阶的玩法,比如自己设计词法和语法规则从而实现一个简单的编译器(Babel 内置了这些规则),你完全可以不只是做出一个源到源的转换编译器,而是实现一个真正的从 JavaScript (TypeScript) 到机器代码的完整编译器,包括实现中间代码 IR 以及提供机器的运行环境等,这里给出一个可以尝试这种高阶玩法的库包 antlr4ts(可以配合交叉编译工具链 riscv-gnu-toolchain,gcc编译工具的制作还是非常耗时的)。
9、ES6 Module 相对于 CommonJS 的优势是什么?
关于 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 在运行时进行加载方式的动态解析,在运行时阶段才能确定的导入导出关系,因此无法进行静态编译优化和类型检查。
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。
为了对比 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。
大家可能会注意到一个新的问题,当我们在制作工具库或者组件库的时候,通常会将库包编译成 ES5 语法,这样尽管 Babel 以及 Webpack 默认会忽略 node_modules 里的模块,我们的项目在编译时引入的这些模块仍然能够做到兼容。在这个过程中,如果你制作的库包体积非常大,你又不提供非常细粒度的按需引入的加载方式,那么你可以编译你的源码使得编译产物可以支持 ES Module 的导入导出模式(注意只支持 ES6 中模块的语法,其他的语法仍然需要被编译成 ES5),当项目真正引入这些库包时可以通过 Tree Shaking 的特性在编译时去除未引入的代码(Dead Code)。
9.3 加载原理 & 引入性质
在 ES Module 中使用模块进行开发,其实是在编译时构建模块之间的依赖关系图。在浏览器或者服务的文件系统中运行 ES6 代码时,需要解析所有的模块文件,然后将模块转换成 Module Record 数据结构,具体如下图所示:
事实上, ES Module 的加载过程主要分为如下三个阶段:
- 构建(Construction):主要分为查找、加载(在浏览器中是下载文件,在本地文件系统中是加载文件)、然后把文件解析成 Module Record。
- 实例化(Instantiation):给所有的 Module Record 分配内存空间(此刻还没有填充值),并根据导入导出关系确定各自之间的引用关系,确定引用关系的过程称为链接(Linking)。
- 运行(Evaluation):运行代码,给内存地址填充运行时的模块数据。
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;
};
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
接下来主要是重点看下 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 的上下文环境是有信息的,可能是当前模块的信息,具体没有深究:
大家可以不用太纠结代码的细致实现,只需要大致可以了解到 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 个问题)。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!