作为《你不知道的Javascript》忠实读者,多次拜读该著作,本专栏用来分享我对该书的解读,适合希望深入了解这本书的读者阅读 电子书下载网址:zh.101-c.online
第三章——函数作用域和块作用域
一、全局命名空间与模块管理的区别
先看原文表述
我相信,对于全局命名空间大家都可以理解,所以我们把重点放在第二部分模块管理,其中有一些名词我们需要理解一下,什么是模块管理器,什么又是依赖管理器?
模块管理器(Module Manager)
模块管理器是一种用于组织和管理代码模块的工具或系统。它的主要功能包括:
- 模块定义与封装:允许开发者将代码划分为独立的模块,每个模块具有自己的作用域,避免全局作用域的污染。
- 模块加载:负责按需加载模块,可能是同步或异步的(如动态加载)。
- 依赖管理:确保模块之间的依赖关系被正确处理,按正确的顺序加载和执行模块。
- 作用域隔离:通过机制(如闭包、函数作用域或专用命名空间)确保模块的标识符(变量、函数等)不会泄漏到全局作用域,从而避免命名冲突。
常见的模块管理器:
- CommonJS:主要用于Node.js,通过
require和module.exports实现模块化。 - AMD(Asynchronous Module Definition):如RequireJS,支持异步加载模块。
- ES Modules(ESM):JavaScript的原生模块系统,通过
import和export语法实现。
例子:ES Modules(ESM)
// math.js(模块定义)
export function add(a, b) {
return a + b;
}
// app.js(模块导入)
import { add } from './math.js';
console.log(add(2, 3)); // 5
依赖管理器(Dependency Manager)
依赖管理器是一种专门用于管理代码库或模块之间依赖关系的工具。它的核心功能是:
- 依赖解析:根据模块的依赖声明(如“模块A需要模块B”),自动解析并获取所有依赖项。
- 依赖安装:通常与包管理器(如npm、yarn)结合,从远程仓库下载依赖的库。
- 版本控制:确保依赖的版本兼容性,避免冲突(如通过
package.json或锁文件)。 - 依赖注入:将依赖的模块显式导入到特定作用域中,而非全局作用域。
例子1:npm(Node.js 包管理器)
// package.json(声明依赖)
{
"dependencies": {
"lodash": "^4.17.21"
}
}
依赖管理器与模块管理器的关系:
- 依赖管理器更侧重于解决“如何获取和组合依赖”,而模块管理器更侧重于“如何定义和隔离模块”。
- 许多工具兼具两者功能(如Webpack、Rollup等打包工具,既管理模块也处理依赖)。
相信大家不难看出全局命名空间与模块管理的区别,全局命名空间的所有变量归根结底都在全局作用域之中,但是模块管理通过export,import等手段避免了变量在全局作用域中的暴露,只在使用的地方暴露,并不污染全局作用域,下面我们举一些例子来说明
🌰 示例分析
情况1:全局命名空间(冲突)
假设有两个文件:
lib.js
// 定义了一个全局变量 `utils`
var utils = { version: 1 };
app.js
// 不小心也定义了一个全局变量 `utils`
var utils = { version: 2 };
index.html
<script src="lib.js"></script>
<script src="app.js"></script>
运行结果:
lib.js先执行,window.utils = { version: 1 }。app.js后执行,window.utils = { version: 2 },覆盖了lib.js的utils。- 最终
utils的值是{ version: 2 },lib.js的变量被覆盖!
情况2:模块管理(无冲突)
lib.js(使用 ES Modules)
// 导出 utils,不会污染全局
export const utils = { version: 1 };
app.js(使用 ES Modules)
// 导入 lib.js 的 utils
import { utils } from './lib.js';
// 即使这里再定义一个 utils,也不会冲突
const utils = { version: 2 }; // 这是 app.js 的局部变量
console.log(utils); // { version: 2 }(优先用局部变量)
console.log(window.utils); // undefined(全局没有被污染)
运行结果:
lib.js的utils只在模块内部作用域。app.js的utils是局部变量,不会影响lib.js的utils。- 两个
utils互不干扰!
二、函数声明与函数表达式的区别
先看原文表述
毫无疑问,原文给出了一种区分函数声明和函数表达式的方法,但是其中有一句耐人寻味的话,(不仅仅是一行代码,而是整个声明中的位置)
这句话的核心意思是:
判断 function 是否为“第一个词”,不能只看它是不是在一行的开头,而要看它在整个语句的语法结构中的位置。因为代码的换行和格式化(如括号、缩进)可能会干扰直观判断。
下面我举个例子
假设你看到这样的代码:
function foo() {}
- 这里
function是一行的第一个词,显然是 函数声明。
但如果代码被换行或括号包裹:
const bar = function
foo() {};
- 如果只看第一行
const bar = function,可能误以为function是第一个词。 - 但实际上,
function属于赋值语句的一部分,所以 整体是一个函数表达式。
知道如何判断何为函数声明与函数表达式之后我们聚焦于函数声明与函数表达式的区别这里主要提两点
1. 作用域提升(Hoisting)
(1)函数声明
-
会被 JavaScript 引擎提升(Hoisting),可以在声明前调用。
console.log(greet("Alice")); // 正常执行(输出 "Hello, Alice!") function greet(name) { return `Hello, ${name}!`; }
(2)函数表达式
-
不会被提升,必须在定义后才能调用。
console.log(greet("Alice")); // 报错!greet is not a function const greet = function(name) { return `Hello, ${name}!`; };
2. 函数名的作用域
(1)函数声明
-
函数名在 当前作用域 中直接可用。
function foo() {} console.log(foo); // 正常输出函数
(2)函数表达式
-
匿名函数表达式:没有函数名,只能通过变量调用。
const bar = function() {}; console.log(bar); // 正常输出函数 -
具名函数表达式:函数名仅在函数内部可用,外部无法访问。
const baz = function qux() {}; console.log(baz); // 正常输出函数 console.log(qux); // 报错!qux is not defined
三、IIFE倒置代码运行顺序的意义
先看原文表述
相信看完这段表述之后,不止我在内的读者会思考一个问题,IIFE倒置代码运行顺序的意义在哪里,就简单的倒置吗?我相信意义不止于此
IIFE 倒置代码运行顺序的实际意义
这种 IIFE(立即执行函数表达式)参数化模式,确实可以通过“倒置代码顺序”改变执行逻辑。它的核心意义在于 控制作用域、依赖注入和模块化封装,尤其在 UMD(Universal Module Definition) 这类兼容性模块化方案中非常有用。
1. 代码示例分析
var a = 2;
(function IIFE(def) {
def(window); // 将 window 作为参数传递给 def
})(function def(global) {
var a = 3;
console.log(a); // 3(当前作用域的 a)
console.log(global.a); // 2(外部的 a)
});
执行顺序
-
声明全局变量
a = 2。 -
定义并立即执行
IIFE,传入函数def作为参数。 -
IIFE内部调用def(window),将window作为global参数传递。 -
执行
def函数:- 定义局部变量
a = 3(不影响外部的a)。 - 打印
a(局部)和global.a(全局)。
- 定义局部变量
2. 这种模式的实际意义
(1)作用域隔离
- 避免污染全局作用域:
通过 IIFE 包裹代码,所有内部变量(如a = 3)不会泄漏到全局,避免命名冲突。 - UMD 模块化的核心思想:
在兼容 CommonJS、AMD 和浏览器的模块化方案中,这种模式能确保模块代码在独立作用域中运行。
(2)依赖注入(Dependency Injection)
-
显式传递全局对象:
将window作为参数global传入,明确依赖关系,而非隐式依赖全局变量。
优点:- 代码更易测试(可模拟
global参数)。 - 兼容非浏览器环境(如 Node.js 的
global对象)。
- 代码更易测试(可模拟
(3)代码逻辑解耦
-
分离“定义”和“执行”:
函数def的定义和执行被拆分为两部分,便于动态调整依赖或配置。
应用场景:- 模块化库(如 jQuery、Lodash)的兼容性封装。
- 需要运行时注入依赖的插件系统。
(4)更清晰的执行流程
- 逻辑分层:
将核心逻辑(def)放在代码后半部分,而初始化逻辑(IIFE)在前,符合“先配置后执行”的设计模式。
3. 对比传统 IIFE
传统 IIFE(直接执行)
var a = 2;
(function() {
var a = 3;
console.log(a); // 3
console.log(window.a); // 2
})();
缺点:
- 隐式依赖
window,难以测试或替换依赖。 - 逻辑全部嵌套在 IIFE 内,可读性较差。
参数化 IIFE(倒置顺序)
var a = 2;
(function IIFE(def) {
def(window); // 显式传递依赖
})(function def(global) {
var a = 3;
console.log(a); // 3
console.log(global.a); // 2
});
优点:
- 依赖通过参数传递,灵活性高。
- 模块化代码更易维护和扩展。
4. 实际应用场景
(1)UMD 模块化方案
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD 环境(如 RequireJS)
define(['jquery'], factory);
} else if (typeof exports === 'object') {
// CommonJS 环境(如 Node.js)
module.exports = factory(require('jquery'));
} else {
// 浏览器全局环境
root.MyLib = factory(root.jQuery);
}
}(typeof self !== 'undefined' ? self : this, function ($) {
// 模块核心逻辑
return {
version: '1.0.0'
};
}));
说明:
- 通过 IIFE 的倒置顺序,动态适配不同模块化规范。
factory函数是核心逻辑,root和依赖(如jQuery)通过参数注入。
(2)依赖配置化
// 配置依赖后执行
(function init(appConfig) {
appConfig.start();
})({
debug: true,
start: function() {
console.log('App started with debug:', this.debug);
}
});
5. 总结
为什么这样设计?
- 明确依赖关系:通过参数传递依赖,而非隐式依赖全局变量。
- 作用域安全:避免变量污染,兼容多种环境。
- 代码分层:分离“初始化”和“逻辑”,提升可读性。
何时使用?
- 需要兼容多种模块化规范的库(如 UMD)。
- 需要注入依赖或动态配置的插件系统。
- 希望代码更易测试和维护的场景。
这种模式虽然略显冗长,但在复杂项目中能显著提升代码的健壮性和可维护性。
四、块级作用域的垃圾回收机制
先来看原文的表述
这里有两个问题需要思考
- 为何
someReallyBigData不会被回收 - 为何块级作用域可以让其回收
为何someReallyBigData不会被回收
原因:闭包的作用域链保留
在 JavaScript 中,闭包(Closure) 会让函数持有其定义时所处的作用域链。即使函数没有显式使用某些变量,引擎仍然会保留整个作用域链,因为:
- 词法作用域规则:函数在定义时就确定了它能访问哪些变量。
- 保守的垃圾回收机制:引擎无法静态分析函数是否会动态访问变量(例如通过
eval或with),因此会默认保留所有可能的引用。
代码示例分析
var someReallyBigData = { /* 大对象 */ }; // (1) 定义在全局作用域
process(someReallyBigData); // (2) 使用后理论上可回收
var btn = document.getElementById("my_button");
btn.addEventListener("click", function click() { // (3) 闭包形成
console.log("button clicked");
});
- 问题:
click回调函数的作用域链包含全局作用域(即someReallyBigData)。 - 结果:只要
click函数存在(按钮未移除),someReallyBigData就被视为“可达”,无法被回收。
1. 为什么 someReallyBigData 不会被回收?
原因:闭包的作用域链保留
在 JavaScript 中,闭包(Closure) 会让函数持有其定义时所处的作用域链。即使函数没有显式使用某些变量,引擎仍然会保留整个作用域链,因为:
- 词法作用域规则:函数在定义时就确定了它能访问哪些变量。
- 保守的垃圾回收机制:引擎无法静态分析函数是否会动态访问变量(例如通过
eval或with),因此会默认保留所有可能的引用。
代码示例分析
javascript
复制
var someReallyBigData = { /* 大对象 */ }; // (1) 定义在全局作用域
process(someReallyBigData); // (2) 使用后理论上可回收
var btn = document.getElementById("my_button");
btn.addEventListener("click", function click() { // (3) 闭包形成
console.log("button clicked");
});
- 问题:
click回调函数的作用域链包含全局作用域(即someReallyBigData)。 - 结果:只要
click函数存在(按钮未移除),someReallyBigData就被视为“可达”,无法被回收。
2. 为什么块级作用域可以让 someReallyBigData 被回收?
原因:作用域隔离
通过 let/const + {} 块级作用域,可以显式限制变量的生命周期:
- 块级作用域内的变量:仅在
{}内有效,外部无法访问。 - 闭包的作用域链不包含块级变量:如果回调函数定义在块外,就不会引用块内的变量。
优化后的代码
function process(data) { /* ... */ }
{ // (1) 使用块级作用域
let someReallyBigData = { /* 大对象 */ }; // 仅在块内有效
process(someReallyBigData);
} // (2) 块结束,someReallyBigData 超出作用域
var btn = document.getElementById("my_button");
btn.addEventListener("click", function click() { // (3) 闭包不引用块级变量
console.log("button clicked");
});
-
关键点:
someReallyBigData被限制在{}内,click函数未在块内定义,因此不引用它。- 块执行完毕后,
someReallyBigData不再被任何闭包引用,可安全回收。
五、结语
通过《你不知道的JavaScript》第三章的深度解析,我们系统性地梳理了函数作用域与块作用域的核心机制。从全局命名空间的污染问题到模块管理的封装艺术,从函数声明的提升特性到IIFE的依赖注入模式,再到块作用域对垃圾回收的精准控制——这些看似独立的特性,实则构成了JavaScript作用域体系的完整拼图。理解这些概念不仅能帮助我们写出更健壮的代码,更能培养对语言设计哲学的深刻认知。
正如Kyle Simpson在书中强调的,作用域的本质是代码可维护性与执行效率的平衡艺术。无论是模块化的依赖管理,还是闭包的内存优化,都需要开发者既掌握底层原理,又能灵活运用设计模式。建议读者将本文作为引子,结合书中示例代码进行实践,真正让这些知识融入日常开发思维。当你能清晰地预见每一行代码对作用域链和内存管理的影响时,就离JavaScript高手更近了一步。