前端进阶系列 · JavaScript 核心机制深度解析
20 篇文章,从编译原理到引擎内部,覆盖作用域与闭包、this 与原型链、类型与强制转换、异步编程模型、ES6+ 进阶特性五大模块。
写在前面
做前端开发快八年了。说实话,有很长一段时间,我觉得自己什么都会一点——React 能写,Vue 能写,Node 也能写。但真要让我说清楚"闭包到底是什么"或者"Event Loop 完整执行顺序",话到嘴边又觉得讲不透。
我相信很多人跟我有同样的感觉:瓶颈期。什么都能干,但深入一步就有点虚。
这个系列就是我对这些年积累的系统复盘。从 JavaScript 最底层的概念出发——作用域、原型、类型、异步——把那些"似懂非懂"的东西彻底掰开讲清楚。先做 JavaScript 系列,后面还会有 CSS 和浏览器相关的内容。
希望这个系列的总结也能帮你突破那个瓶颈。
JavaScript 是一门编译型语言
你有没有遇到过这种情况——变量明明写在代码里,运行时报了 ReferenceError?或者 console.log 出来的值跟你预期的是两回事?这些令人抓狂的 bug,根源往往只有一个:你对作用域的理解还停留在表面。要真正理解作用域,得从 JS 代码执行之前说起。
你可能听过这句话:"JavaScript 是解释型语言"。这其实不准确。所有现代 JavaScript 引擎(V8、SpiderMonkey、JavaScriptCore)都采用了 JIT(Just-In-Time)编译,整个流程如下:
graph LR
A[源代码] --> B[词法分析 Parsing]
B --> C[AST 抽象语法树]
C --> D[字节码 Bytecode]
D --> E[JIT 编译]
E --> F[优化后的机器码]
F --> G[执行]
- 词法分析(Parsing) :引擎将字符串源码分解为有意义的 token(
var、a、=、2、;)。 - 语法分析:将 token 流转换为 AST(Abstract Syntax Tree),一棵描述程序结构的树。
- 生成字节码:编译器将 AST 转为中间表示——字节码。
- JIT 编译:在运行时,引擎会识别"热点代码"(hot code),将其编译为高度优化的机器码。
JavaScript 实际上是先编译,再执行的。搞懂了编译阶段,作用域也就清楚了。
编译阶段发生了什么
当引擎执行 var a = 2; 这条语句时,实际上会发生两次处理:
// 你看到的是一行代码,但引擎处理了两个步骤:
var a = 2;
// 步骤1(编译阶段):编译器在当前作用域中声明变量 a
// 步骤2(执行阶段):引擎在当前作用域中查找变量 a,并赋值为 2
这个过程里,三个角色分工明确:
| 角色 | 职责 |
|---|---|
| 引擎(Engine) | 负责整个程序的编译和执行 |
| 编译器(Compiler) | 负责语法分析和代码生成 |
| 作用域(Scope) | 负责维护变量查找规则,管理标识符的可访问性 |
编译器负责声明,引擎负责执行,作用域负责管理这些声明的关系。
词法作用域:在编译时就已确定
JavaScript 采用的是 词法作用域(Lexical Scope) ,这意味着作用域在编译阶段就已经被确定,与运行时无关。作用域是由你写代码时变量和函数声明的位置决定的。
var globalVar = 'I am global';
function outer() {
var outerVar = 'I am from outer';
function inner() {
var innerVar = 'I am from inner';
console.log(innerVar); // 'I am from inner'
console.log(outerVar); // 'I am from outer'
console.log(globalVar); // 'I am global'
}
inner();
}
outer();
与之相对的是 动态作用域(Dynamic Scope) ,它取决于函数在哪里被调用,而不是在哪里被定义。某些语言(如早期的 Perl)使用动态作用域,但 JavaScript 始终使用词法作用域。记住这一点就够了:只看函数定义的位置,就能确定它的作用域。
作用域链:从内向外逐级查找
当引擎需要查找一个变量时,它会沿着 作用域链(Scope Chain) 从内向外逐级搜索:
graph TD
G["全局作用域 globalVar"] --> O["outer 函数作用域 outerVar"]
O --> I["inner 函数作用域 innerVar"]
style G fill:#e1f5fe,stroke:#01579b
style O fill:#fff3e0,stroke:#e65100
style I fill:#e8f5e9,stroke:#1b5e20
查找规则很简单:
- 先在当前作用域找
- 找不到就去外层作用域找
- 一直找到全局作用域
- 如果全局都没找到,非严格模式下会创建一个全局变量(严格模式报
ReferenceError)
var name = 'Global';
function showName() {
var name = 'Local';
console.log(name); // 'Local',先在当前作用域找到就停止
}
showName();
console.log(name); // 'Global',不受内部影响
这就是"遮蔽效应(Shadowing)":内部作用域的变量"遮蔽"了外部同名的变量。
两个不该碰的黑魔法:eval() 和 with()
JavaScript 里有两个"后门"可以在运行时篡改词法作用域,但你不该用:
eval() :将字符串当作代码执行,可以动态声明变量:
function badEval(str) {
eval(str);
console.log(b); // 42,eval 在运行时在当前作用域创建了变量 b
}
badEval('var b = 42;');
// console.log(b); // 报错,b 在 badEval 的作用域内
with() :将一个对象处理为一个独立的作用域:
var obj = { a: 1, b: 2 };
with (obj) {
a = 3; // 修改 obj.a
b = 4; // 修改 obj.b
c = 5; // 小心!obj 没有 c,所以 c 泄漏到了全局!
}
console.log(obj.c); // undefined
console.log(c); // 5,泄漏到全局作用域!
为什么不该碰?
第一,引擎在编译阶段做的优化全废掉了;
第二,性能断崖式下降;
第三,严格模式下 with 直接禁掉,eval 也没法动外部作用域。
函数作用域 vs 全局作用域
在 ES6 之前,JavaScript 只有两种作用域:
// 全局作用域:在任何函数外声明的变量
var global = 'I live everywhere';
function myFunc() {
// 函数作用域:函数内部声明的变量只在此函数内可访问
var local = 'I live only here';
if (true) {
var stillLocal = 'I am also local to the function'; // 注意:不是块级作用域!
}
console.log(stillLocal); // 可以访问,var 会忽略 {} 块
console.log(global); // 可以访问,作用域链向上查找
}
myFunc();
// console.log(local); // ReferenceError
这就是 var 的"函数级作用域"特性——只有函数能创建新的作用域,if、for、while 等代码块对 var 无效。这一特性引发了很多令人困惑的 bug,也直接催生了 ES6 的 let 和 const。
总结
JavaScript 是先编译后执行的,作用域在编译阶段就已确定——词法作用域。引擎、编译器、作用域三者协作完成变量的声明和赋值,变量查找沿着作用域链从内向外进行。eval() 和 with() 是两个不该碰的后门,代价远高于便利。而 var 只有函数作用域、没有块级作用域,这正是下一篇要解决的问题。
本文原创首发于公众号【我做开发那些年】,现同步转载至本平台。
声明:如需转载本文至其他平台,请注明文章来源及公众号信息,感谢您对原创内容的尊重与支持!