旧项目维护:从全局脚本、构造函数到模块边界
旧项目的问题通常不在某个关键字过时,而在状态、依赖与执行环境藏得太深。本篇把前面已经学过的闭包、原型和模块放回旧程序:先解释它为什么能运行,再在原有写法里修好问题,最后判断迁移是否保留了外部行为。
需要第二章的函数作用域与 this、第四章的原型与属性描述符、第七章的模块和脚本加载。var、构造函数和回调仍然是 JavaScript 的合法能力;它们不是一套已经不能运行的语言。
先确定文件由谁执行
浏览器经典脚本、Node CommonJS、ESM 都能包含普通函数,但顶层含义不同。经典脚本可能通过全局对象互相传递值;CommonJS 文件包在模块函数里;ESM 有独立模块作用域并默认严格。把扩展名从 .cjs 改成 .mjs 不只是整理文件名。
下面使用 Node 内置的 vm 建立一个独立的经典脚本上下文。它用来观察脚本作用域,不模拟 DOM、网络或 HTML 加载,也不能当作执行不可信代码的安全边界。保存为 script-scope.mjs,运行 node script-scope.mjs:
import vm from 'node:vm'
const context = vm.createContext({ console })
vm.runInContext(`
var publicCount = 1;
let privateCount = 2;
console.log(this === globalThis);
console.log(globalThis.publicCount, globalThis.privateCount);
`, context)
vm.runInContext(`console.log(publicCount, privateCount);`, context)
// true
// 1 undefined
// 1 2privateCount 没有成为全局对象属性,后一个脚本却仍能按名称读取它:全局词法绑定与全局对象属性不是一回事。它也不因此成为只有第一个文件可见的私有变量。迁移到 ESM 后,跨文件依赖应当显式导出与导入。
如果旧代码给未声明名称赋值,先补明确声明并验证调用者,再考虑严格模式。还要检查普通函数的 this、arguments 与动态 eval;把所有函数换成箭头函数会改变接收者和参数模型,不能作为通用迁移规则。
命名空间把名称聚在一起,IIFE 把状态藏起来
旧脚本常只公开一个名字,再把功能放进它的属性:
var Reader = {
count: 0,
next: function () { return ++this.count; }
};
Reader.count = 100;
console.log(Reader.next()); // 101这个对象减少了顶层名字,却没有控制谁能修改 count。属性可见,外部代码就能绕过 next。若需要保护状态,可以使用立即调用函数表达式(IIFE):函数现在执行一次,返回允许调用的方法,闭包保留内部变量。
以下例子保存为独立 .mjs 运行;虽然保留旧式函数写法,但并不依赖宽松模式:
var Reader = (function () {
var count = 0;
return {
next: function () { return ++count; },
current: function () { return count; }
};
})();
console.log(Reader.next(), Reader.current()); // 1 1
Reader.count = 100;
console.log(Reader.next(), Reader.current()); // 2 2赋值 Reader.count 只增加了公开对象的同名属性,没有改闭包中的变量。区别来自两个存储位置,不是属性名具有特殊保护能力。公开对象仍可被改写,返回方法也能被替换;闭包私有状态不等于整个 API 自动不可变。
现在要求两处阅读器独立计数,不能简单让两个变量都指向 Reader。移除“立即调用”,保留工厂函数;每次调用建立新作用域:
function createReader(start) {
var count = start;
return {
next: function () { return ++count; },
current: function () { return count; }
};
}
var first = createReader(0);
var second = createReader(10);
console.log(first.next(), second.current()); // 1 10若迁移为 ESM,导出这个工厂即可继续保持每次调用独立。若改成模块顶层的一个 count 并导出 next,则变成共享一份状态。两者都能写成模块,模块格式本身不会替你决定实例数量。
构造继承要分别处理实例状态和方法查找
旧程序经常通过父构造函数初始化字段,通过原型共享方法。这是两个步骤:只调用父函数不会连接方法;只连接原型不会替新实例初始化字段。
保存为 legacy-inheritance.mjs:
function Folder(name) {
this.name = name;
this.items = [];
}
Folder.prototype.add = function (item) { this.items.push(item); };
function SharedFolder(name) {
Folder.call(this, name);
}
SharedFolder.prototype = Object.create(Folder.prototype);
Object.defineProperty(SharedFolder.prototype, 'constructor', {
value: SharedFolder, writable: true, configurable: true
});
var left = new SharedFolder('左');
var right = new SharedFolder('右');
left.add('笔记');
console.log(left.items, right.items); // ['笔记'] []
console.log(left.add === right.add); // true
console.log(left instanceof Folder); // trueFolder.call(this, name) 在当前实例上写入独立数组;Object.create 创建一个没有执行 Folder 的对象,用来连接方法查找链。constructor 属性是对工具和调用者有用的约定,但不会补执行初始化。描述符没写 enumerable,默认 false,避免它进入普通可枚举字段列表。
错误版本常写 SharedFolder.prototype = new Folder('共享'),子构造器又不再调用父函数。此时 items 在共享原型上;left.items.push 会修改那一个数组,right 也能看到。修复应该把可变字段初始化放回每次构造,而不是只给 constructor 改名。若保留 new Folder 同时调用父构造器,又会在建立原型时多做一次初始化,父函数有外部副作用时尤其明显。
换成 class SharedFolder extends Folder 时,派生构造器应调用 super(name) 再使用 this。但 class 构造器不能作为普通函数调用,类方法也有不同的属性描述符;把旧插件依赖的所有枚举行为一并保留,不能只凭输出两份数组就下结论。先用本例确认状态隔离与方法共享,再逐项检查实际调用者依赖。
CommonJS 缓存的是导出对象,解构不会持续读取属性
在独立目录保存两个文件,运行 node inspect.cjs:
// counter.cjs
var count = 0;
exports.read = function () { return count; };
exports.next = function () { return ++count; };
exports.label = '初始';
exports.rename = function (text) { exports.label = text; };// inspect.cjs
var first = require('./counter.cjs');
var second = require('./counter.cjs');
var label = first.label;
first.next();
first.rename('修改后');
console.log(first === second); // true
console.log(second.read()); // 1
console.log(label, second.label); // 初始 修改后两个 require 在同一进程解析到同一缓存模块,取得同一份导出对象。read 方法通过闭包读取最新 count;label 变量保存的则是当时的字符串。解构 var { label } = first 也只是取值,不是 ESM 的活动绑定。
模块函数开始执行时,exports 是 module.exports 的一个局部别名。下面是独立的错误实验,替换 counter.cjs 后重新启动进程:
// counter.cjs:错误对照
exports = { read: function () { return 7; } };require('./counter.cjs') 得到的仍是原来的空对象。重新给局部变量 exports 赋值,没有改 module.exports。若要替换整份接口,用 module.exports = { ... };若只追加属性,则修改原对象。不要在异步回调里才更换整份导出,调用方可能早已拿到旧对象。
迁移时先明确消费者需要“当前值”还是“取得时的快照”。ESM 导出工厂可以保留独立实例,导出同一个实例可以保留共享状态;命名导出的活动绑定又与对象属性快照不同。循环模块还涉及初始化顺序,应继续读第七章的循环依赖实验,不能把缓存理解为自动解决所有循环。
把维护变成可以比较的改动
先保留原入口,记录哪些对象必须共享、哪些必须独立、错误何时传播和副作用由谁结束。完成原格式中的修复,再换声明、模块格式或类语法,每次只改变一个维度。回调转 Promise 的错误与时机问题在第五章已有完整实验,维护时应回到那个契约,而不是看到 callback 就全部替换。
练习
- 为 createReader 增加 reset(),恢复到本次创建时的 start,且返回恢复后的值。start 限定为 0~1000 的整数,非法值抛 RangeError。分别创建起点 2 和 10 的对象,修改、重置第一份,证明第二份不变;公开对象上新增 count 属性不得影响内部状态。
- 复制构造继承例子,改成“子构造器为空,原型由 new Folder 创建”的错误版本,证明两实例共享数组。修回正文的两步骤方案,再证明数组独立、add 是同一函数、instanceof Folder 为真。
- 在原 CommonJS 形式中实现 createCounter(start) 工厂并用 module.exports 暴露。require 两次仍应得到同一工厂,但调用两次必须得到独立状态;写断言证明这一点。再将同一工厂迁移为 ESM,保持创建和 next/read 行为。