问题
最近需要给前端项目增加切换环境配置的功能,所有的配置都定义在 config.js
中,有大量的页面引用该文件,以下是示意代码:
// config.js
export let config = {
env: 'dev'
};
export default config;
// app.js
import config from './config.js';
const printConfig = () => {
console.log('config', config);
};
printConfig();
第一版实现
按直觉增加 changeConfig
函数修改配置:
// config.js
let config = {
env: 'dev'
};
export const changeConfig = nextConfig => {
config = nextConfig;
};
export default config;
// app.js
import config, { changeConfig } from './config.js';
const printConfig = label => {
console.log(label);
console.log('config', config);
console.log('config.env', config.env);
};
printConfig('before');
changeConfig({
env: 'prod'
});
printConfig('after');
结果并没有达到预期,虽然 config.js
中的 config
被修改,但 app.js
中 config
变量依然指向原始引用。因为 config
是 config.js
的默认导出(default export),访问默认导出时相当于访问模块的 default
属性,即:
Module {
changeConfig: Function,
default: config
}
如果 config
是有名导出(named export),则可以达到想要的效果,即:
// config.js
export let config = {
env: 'dev'
};
Module {
changeConfig: Function,
config: config
}
这时 app.js
和 config.js
中的 config
指向同一个引用。
第二版实现
改成有名导出可以完美解决配置切换的问题,但需要修改大量引用 config.js
的文件,工作量超出预期(实际上可以通过编写 codemod 来帮助我们安全修改文件)。
我们其实不需要真的修改 config
对象,只需要保证切换配置后能正确获取 config
中的属性,借助 Proxy
创建一个特别的默认导出,不用改动其他文件。
// config.js
let config = {
env: 'dev'
};
export default new Proxy(config, {
get: function (target, key) {
return config[key];
},
set: function (target, key, value) {
config[key] = value;
}
});
虽然其他页面访问的 default
属性指向 Proxy
实例,但 config.env
指向修改后的值。
老生常谈的问题
上面的模块引用问题抛去 ES 模块特性,本质上相当于如下代码:
let a = { foo: true };
let b = a;
a = { bar: true };
console.log(b); // { foo: true }
我们来复习一下三种常见的传值方式:
- pass by value 值传递,传递的是原始值的拷贝
- pass by pointer 指针传递
- pass by reference 引用传递,传递的是原始值的引用,也就是内存空间地址
对 JS 来说是值传递,但是值本身可能是引用类型。
let a = 1;
function fn(argv) {
argv = 2;
}
fn(a);
console.log(a); // 如果a的值变成2,则是引用传递,否则是值传递。
let a = { b: 1 };
function fn(argv) {
argv.b = 2;
}
fn(a);
console.log(a); // 虽然是值传递,但a的内容被修改。
ES 模块和普通场景的区别在于,它在输出值的同时会存在动态绑定(live bindings)。
// a.js
export let foo = 'foo';
export let update = () => foo = 'foooooo';
// b.js
import { foo, update } from './a.js';
console.log(foo); // 'foo'
update();
console.log(foo); // 'foooooo'
如果没有动态绑定,foo
会始终等于 foo
,而不会更新为 foooooo
。
如果想了解 ES 模块的动态绑定特性,可以阅读 Mozilla Hacks 上的文章 ES modules: A cartoon deep-dive。
CommonJS 又是什么样的?
// a.js
let foo = 'foo';
let update = () => foo = 'foooooo';
module.exports = { foo, update };
// b.js
let { foo, update } = require('./a.js');
console.log(foo); // 'foo'
update();
console.log(foo); // 'foo'
说明 CommonJS 模块没有动态绑定。
// a.js
let foo = { bar: 'foo' };
let printFoo = () => console.log(foo);
module.exports = { foo, printFoo };
// b.js
let { foo, printFoo } = require('./a.js');
console.log(foo); // { bar: 'foo' }
foo.bar = 'foooooo';
console.log(foo); // { bar: 'foooooo' }
printFoo(); // { bar: 'foooooo' }
如果修改 CommonJS 模块导出的对象,原模块中的对象也会被修改,这和 JS 本身的值传递特性一致。