具备环境感知的JavaScript配置机制

前端项目里难免会需要用到一些全局配置(比如后端服务器的地址、RequireJS的配置等等),这些配置通常会以一个对象的形式存在,页面上的其他脚本会通过规定的方式来读取它(比如通过全局变量或者RequireJS)。
当端项目需要部署到多个环境时(比如典型的开发环境、测试环境和生产环境),每个环境的全局配置往往是不一样的,有可能是同名不同值,也有可能某些环境会多一些少一些配置项。

如果在每一处使用全局配置的地方都手工判断当前环境的话,则会产生不少冗余代码,并且容易出错。再者,对于全局配置的使用方而言,它在意的只是这一配置项的值,而非当前的环境。所以若能让使用方专心于获取值,而不用在意当前运行于什么环境的话,使用全局配置的体验就会更好了。

如果项目中已经使用了gulp之类的构建工具,就可以基于这些工具来为每个环境构建单独的全局配置,笔者较为推荐这种方式。而本文则要介绍一种不借助构建工具来让全局配置具备环境感知的机制,它能够根据当前页面的Url来自动生成最匹配当前环境的全局配置,使用方无需进行环境判断就可以直接使用。

首先对全局配置的格式提出一些要求:

var config = {
development: {},
staging: {},
production: {}
};

config对象包含的四个属性都可以叫做“环境配置”,用来存放与之对应的环境所适用的配置项。每个环境配置除了存放各自的配置项之外,还必须包含一个url属性,这是一个正则表达式类型的属性,用于测试当前页面地址,通过测试后,就表示当前环境配置适用于当前页面。比如:

var config = {
development: {
url: /\/dev($|\/)/i,
backend: "http://dev.backend.com"
},
staging: {
url: /\/staging($|\/)/i,
backend: "http://staging.backend.com"
},
production: {
url: /\/production($|\/)/i,
backend: "http://backend.com"
}
};

然后逐一测试这些环境配置是否适用于当前页面:

function getEnvironmentConfig () {
var envName,
env;

for (envName in config) {
if (config.hasOwnProperty(envName)) {
env = config[envName];
if (env.url instanceof RegExp === false) {
throw new Error("The type of url in config must be RegExp.");
} else if (env.url.test(window.location.href)) {
return env;
}
}
}
}
config = getEnvironmentConfig();

通过测试的环境配置被直接赋给了config对象,从而“屏蔽”了与当前环境不匹配的环境配置,也简化了使用方的访问方式。比如,假设当前页面地址是/staging/index.html,那么config.staging就会通过测试,接着config对象就会被config.staging覆盖。此时调用config.backend的话,得到的结果就会是:http://staging.backend.com

通常来说,每个环境都会有对应的后端服务器,但后端服务器所提供的API路径在所有环境上往往都是相同的,我们可以在每个环境配置中都添加一模一样的配置项,但这样做并不利于维护。我们可以把这类配置项单独存放在一个特殊的环境配置中,其余的环境配置会继承它所包含的所有配置项,比如:

var config = {
base: {
api: {
documents: "/docs",
users: "/users"
tags: "/tags"
}
},
development: {
url: /\/dev($|\/)/i,
backend: "http://dev.backend.com"
},
staging: {
url: /\/staging($|\/)/i,
backend: "http://staging.backend.com"
},
production: {
url: /\/production($|\/)/i,
backend: "http://backend.com"
}
};

base就是那个特殊的环境配置,其余环境配置会从base这里继承到其api属性。由于base只负责存放共享的配置,所以无需参与环境匹配测试,我们需要在之前的getEnvironmentConfig函数中跳过base,将for循环内的if语句修改为:

if (config.hasOwnProperty(envName) && envName !== "base") {
//not changed
}

接下来把通过测试的环境配置填充到base中,得到最终的全局配置:

config = fill(config.base, getEnvironmentConfig());

fill函数有两个参数,它会将第二个参数(source)中的所有属性填充到第一个参数(target)中。此例中target包含一个api属性,source则不包含此属性,完成填充后,target依然保留api属性,这就是我们想要实现的效果。此时调用config.api.documents就会得到:/docs

如果source和target都包含同样的属性,则应该使用source中的值来覆盖target中的值,比如开发环境的后端结构发生了变化,api.documents的路径变成了/docs/all,我们可以在development环境配置中添加和base同样结构的配置项来覆盖base中提供的值:

development: {
url: /\/dev($|\/)/i,
backend: "http://dev.backend.com",
api: {
documents: "/docs/all"
}
}

此时在开发环境中调用config.api.documents就会得到:/docs/all,需要注意的是base.api包含多个属性,而development.api只覆盖了documents一个属性。所以在完成填充之后,development.api应当能够继承base.api的其余几个未被覆盖的属性。也就是说,覆盖应该发生在source的叶子节点,从而实现覆盖某一个别属性而不影响其余同级别属性的继承。

这里又引入了另外一个需求:某一配置项在大多数环境中都需要,只有个别环境不需要。比如我们希望在测试环境和开发环境中暴露一个debug配置项来协助调试,而不允许该配置项出现在生产环境中,我们依然不希望在多个环境配置中重复定义该配置项,那么配置就可以这样来定义:

var config = {
base: {
api: {
documents: "/docs",
users: "/users",
tags: "/tags"
},
debug: {}
},
development: {
url: /\/dev($|\/)/i,
backend: "http://dev.backend.com",
api: {
documents: "/docs/all"
}
},
staging: {
url: /\/weibo.com($|\/)/i,
backend: "http://staging.backend.com"
},
production: {
url: /\/production($|\/)/i,
backend: "http://backend.com",
debug: undefined
}
};

可以看到base中定义了一个debug配置项,development和staging中均未定义同名配置项,所以会继承该配置项,而production中定义了该配置项,并为其赋值为undefined,这表示在production中不需要该配置项,所以fill函数应当在填充时忽略该配置项的继承,并且将该配置项从production中删去。完成填充后,在生产环境中无法访问config.debug,但在其余两个环境中则可以。

综上所述,fill函数所提供的功能就应该是:

  1. 完整遍历source对象树,并将其结构和值填充到target中;
  2. 在填充时,如果在target对象树中发现同级同名的叶节点,则考虑将其覆盖;
  3. 考虑覆盖时,如果source中的值为undefined,则将该叶节点从target中删除,并阻止填充。

下面是fill函数的简单实现:

function fill(target, source) {
var propertyName;

if (typeof target === "undefined") {
target = {};
}

if (source instanceof Date) {
target = new Date(source.getTime());
} else if (source instanceof RegExp) {
target = new RegExp(source);
} else if (source instanceof Array) {
target = source.map(function (item) {
return fill({}, item);
});
} else if (Object.prototype.toString.call(source) === "[object Object]") {
for (propertyName in source) {
if (source.hasOwnProperty(propertyName)) {
if (typeof source[propertyName] === "undefined" && target.hasOwnProperty(propertyName)) {
delete target[propertyName];
} else {
target[propertyName] = fill(target[propertyName], source[propertyName]);
}
}
}
} else {
target = source;
}

return target;
}

发表评论

电子邮件地址不会被公开。 必填项已用*标注