工作中遇到的一个 JavaScript 模块引用问题

问题

最近需要给前端项目增加切换环境配置的功能,所有的配置都定义在 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.jsconfig 变量依然指向原始引用。因为 configconfig.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.jsconfig.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 模块和普通场景的区别在于,它在输出值的同时会存在动态绑定。

// 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

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 本身的值传递特性一致。