前言
讲述了如何通过编译时拦截来解决小程序项目中由于修改 dayjs 本地化配置而导致的全局配置污染问题。今日前端早读课文章由 @苏梓铭分享,公号:Goodme 前端团队授权分享。
正文从这开始~~
万恶之源
事情的起因,还要从年初的一个线上问题说起,我依稀记得那是一个悠闲的周五午后,产品在群里的一声惊呼,打破了这难得的片刻安宁。
“不好了小苏!快看看线上这是什么问题,怎么日期错位了”
我定睛一看,2 月 22 号,不是周四吗?怎么成周三了🤔时间倒流了?物理学不存在了?打开自己手机上的小程序看了看,还真有问题。
但是我转念一想,待办模块的代码已经半年多没有发布了,所以这一定不是我的问题,但是毕竟是自己负责的业务线,还是老老实实的打开项目开始排查。
问题背景
首先需要明确,在项目工程中,我们统一使用 dayjs 库来处理时间和日期。dayjs 的默认本地化配置是 en,这里给不清楚的同学科普一下本地化配置:
本地化配置是实现国际化(internationalization,i18n)的一种方式,通常是指在使用三方库(如 UI 组件库、数据处理库等)时,为了适应不同地区或语言的需求,对这些库进行的配置和调整。一般来说,本地化配置的目的包括:
语言支持:将库中的文本、提示信息等翻译成用户所需的语言。
格式调整:根据地区的习惯调整日期、时间、数字、货币等格式。例如,某些地区使用 “日 / 月 / 年” 的日期格式,而另一些地区则使用 “月 / 日 / 年”。
文化适应:根据不同文化背景调整内容的展示方式,比如颜色、图标等。
通过本地化配置,可以提高用户体验,使得应用程序在全球范围内更具可用性和友好性。
以大家熟知的 <font style="color:rgb(38, 38, 38);">Ant Design</font>
组件库为例,就可以通过指定不同的本地化配置,来让组件的 <font style="color:rgb(38, 38, 38);">placeholder</font>
等内容变为不同语言,从而实现国际化。
我们可以通过调用 locale 方法来指定 dayjs 的全局本地化语言配置:
国际化 (i18n)・Day.js:https://day.js.org/docs/zh-CN/i18n/i18n
如果我们将本地化配置设置成 <font style="color:rgb(38, 38, 38);">zh-cn</font>
,其实例的格式化、取值等多个操作方法的表现都会与默认配置有所不同,这一点会在文档的标题后带上 “Locale Aware” 来进行说明
Day of Week (Locale Aware) · Day.js:https://day.js.org/docs/zh-CN/get-set/weekday
在日历组件中,我们并没有修改过 dayjs 的本地化配置,而是使用的默认配置(en),其对应的一周的第一天是周日,因此日历的表头写死了了第一天是周日。
代码的实现逻辑也很简单,就是通过调用 dayjs().startOf('week')
获取本周的第一天,然后循环六次 add(1, "day")
来生成一周七天每天对应的日期;高亮当天就是判断列表的 item 是否和当前是同一天。
回到问题本身,这里我很快就找到原因所在了:打印出的日期数组,拿到的第一天返回的是周一!
这显然是本地化配置被修改后的表现。
谁修改了本地化配置?
此时此刻,我突然眼前一黑 —— 坏了,前段时间另一条业务线的一个需求,自己可不就是修改了 dayjs 的本地化配置嘛。但我记得这是在子包内的应用吧,我都没有点进去子包,为什么还是执行了代码?
小程序分包逻辑
做过小程序开发的同学可能都知道,为了减小小程序的包体积,通常会将小程序进行分包,即将非首屏的模块和页面放在子包 subPackages 中,这样就可以实现进入时按需加载。
使用分包 | 微信开放文档:https://developers.weixin.qq.com/miniprogram/dev/framework/subpackages/basic.html
意外卷入的子包代码
日历组件是在主包中的,而修改的逻辑是在分包内的,既然是按需加载,理论上在我没有进入分包时,就算里头的代码有问题,也不应该在首页就执行才对。全局搜索了一番,也不只是这一个模块调用了 dayjs.loacle 方法,为什么其他的模块就不会造成影响呢?
抱着这样的疑问,我继续看代码,原来,在首页的金刚区引入了这个模块内的一个文件中的配置信息,导致整个文件都被打包到主包里了,而其中就包括了 dayjs 的相关代码。
问题定位完成,赶紧止血,最直接的修复方式就是把配置信息单独拆一个文件出来,就可以防止无关代码也被打进主包了。
一通操作,发布上线,总算是把问题给修好了。
问题真的解决了吗?
解决了,但是没完全解决。如果用户点进入子包后再进入其它子包模块,同样可能出现类似的问题(如果涉及到日期处理)。
同样的,工程内还有其它模块其实也存在相同的问题,由于复现方式较为苛刻,且通常只影响展示,因此才一直没有客诉。
出于时间考虑,这些问题我们没有立刻解决,而是准备排期到后面一起做规范治理。
卷土重来,痛定思痛
时光飞逝,这个小问题由于没有及时跟进治理,就仿佛一颗石头扔到了大海中,激起的波浪很快就被淹没在更多的需求和线上问题处理中。虽然此后再也没有出现过,但是这是一颗深埋在地底的炸弹,不知道什么时候就会被人触发。
墨菲定律告诉我们,任何可能出错的事情最终都会出错,果不其然,在六个月后,熟悉的截图和熟悉的问题再次出现在了业务反馈群。这一次,我看都没看,直接在工程里全局搜索 locale,果不其然,发现了上周的一次发布,有别的同学也用相同的方式修改了 dayjs 全局的本地化配置,随后就是将问题反馈给对应的同学,修复止血。
此时此刻,我意识到,这已经不是一个小问题了,如果继续放任不管,一定还会对其他同学造成误导,规范统一已经迫在眉睫!于是我们正式启动了技改计划。
需求分析
需求目标
首先我们需要明确技改的目标,即团队内需要确保项目工程内部 dayjs 本地化配置的全局唯一性,以防止在后续的开发过程中继续污染全局本地化配置。
方案思路
基于上述目标,我们可以将需求进一步拆解为存量代码的治理,和增量代码的管控。
工程治理
治理的目标是确保项目工程内不再出现 dayjs.locale()
的调用,从而保证 dayjs 本地化配置的全局唯一。
我们可以通过全局搜索来梳理出所有调用过此方法的模块,并且让对应业务域的开发和测试来进行影响面评估。
这里会涉及到一个权衡点,即我们是否应当将 dayjs 的全局本地化配置统一为 zh-cn?我们作为一家国内的茶饮企业 IT 团队,产品和业务都遵循着周一~周日的偏好,例如按周查看流水数据等,我们不可能按照周日是第一天的逻辑去计算。但是由于工程已经太过庞大,所有的日期逻辑基本上都是基于默认配置来编写的,如果要统一成 zh-cn 偏好,影响面和工作量就太过庞大了,与之对比的收益也并不会高出很多。
因此我们只能选择 “将错就错”,将工程内的日期本地化配置统一为默认的 en
规范管控
解决了工程内存量代码的规范不统一,如何保证后续开发依然能够维持这个统一规范呢?只依靠开发的自觉遵守无疑是不可靠的,我们还需要一个更加强硬的手段来从根本上杜绝全局本地化配置被误修改的可能性,即通过代码的编译时拦截,对不符合规范的编码进行报错提示。
这里除了需要校验工程内的代码,我们还需要检测 node_modules 中二方包的代码,因为二方 npm 包中也可能误修改 dayjs 配置,从而对主工程造成影响。
代码设计与实现
治理的部分比较偏业务,本文这里不做赘述,主要讲讲如何实现编译时拦截。
技术选型
静态分析原理
实现编译时拦截,实际上就是要对代码做静态分析。
静态分析是在不需要执行代码的前提下对代码进行分析的处理过程 (执行代码的同时进行代码分析即是动态分析)。 静态分析的目的是多种多样的, 它可用于语法检查,编译,代码高亮,代码转换,优化,压缩等等场景。
实现静态分析工具有很多,例如我们经常使用的 ESLint,Webpack 以及 babel 等,都可以做到这一点。
按照分析方式的不同,通常情况下我们可以将其分为字符串匹配和 AST(Abstract Syntax Tree,抽象语法树) 分析两种方式,字符串匹配通常情况下只能做较为简单的校验,基于 AST 我们则可以对代码做更加精准的分析。
简单来说就是 AST 会将代码按照不同的关键字和符号拆分为一个一个节点。
本文主要针对三方库 dayjs 的方法做调用拦截,以下面的代码为例:
import dayjs from 'dayjs'
dayjs.locale('zh-cn')
其对应生成的 AST 长这样:
{
"type": "Program",
"body": [
{
"type": "ImportDeclaration",
"specifiers": [
{
"type": "ImportDefaultSpecifier",
"local": {
"type": "Identifier",
"name": "dayjs"
}
}
],
"source": {
"type": "Literal",
"value": "dayjs",
"raw": "'dayjs'"
}
},
{
"type": "ExpressionStatement",
"expression": {
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "Identifier",
"name": "dayjs"
},
"property": {
"type": "Identifier",
"name": "locale"
},
"computed": false,
"optional": false
},
"arguments": [
{
"type": "Literal",
"value": "zh-cn",
"raw": "'zh-cn'"
}
],
"optional": false
}
}
],
"sourceType": "module"
}
可以在这个网站在线查看:AST explorer:https://astexplorer.net/,为了减少代码量这里只展示了部分属性。
不难看出其中有 ImportDeclaration 导入声明、MemberExpression 成员表达式,CallExpression 调用表达式等节点。我们可以利用 ImportDeclaration 来追踪 dayjs 的导入,使用 MemberExpression 来追踪成员访问方法,使用 CallExpression 来追踪方法调用,大体流程如下:
在 AST 遍历的过程中,通常会用到访问者模式(Visitor Pattern)。简单来说就是我们可以通过为每个节点指定处理函数,当遍历 AST 到指定节点时,就会执行这个函数来对当前节点进行处理,这有助于我们把遍历过程和处理过程进行解耦。ESLint 和 Babel 的 AST 遍历都基于访问者模式实现。
接下来我们具体分析一下各个分析方式的优劣。
ESLint
首先,静态分析最最最简单的方式也是最常见的一种方式就是使用 ESLint。ESLint 是一个基于 AST 的代码检查工具,通过配置其自带的 no-restricted-syntax 规则,我们就能实现一个简单的方法调用校验:
module.exports = {
rules: {
'no-restricted-syntax': [
'error',
{
selector: "CallExpression[callee.object.name='dayjs'][callee.property.name='locale']",
message: '不允许调用 dayjs.locale 方法',
},
],
},
};
其通过处理 CallExpression 方法调用节点,拿到调用者和调用属性进行字符串匹配,不难看出,这样只能拦截到固定名称的调用,如果导入 dayjs 时指定了别的名称,则无法拦截。因此我们需要通过编写 ESLint 自定义插件,利用其提供的 AST 能力来实现这一点:
module.exports = {
rules: {
'no-dayjs-locale': {
create(context) {
// 用于存储所有与 dayjs 相关的变量名
const dayjsIdentifiers = new Set();
return {
// 处理 import 语句
ImportDeclaration(node) {
if (node.source.value === 'dayjs') {
// 记录所有从 'dayjs' 导入的变量名
node.specifiers.forEach((specifier) => {
dayjsIdentifiers.add(specifier.local.name);
});
}
},
// 监听 MemberExpression 节点
MemberExpression(node) {
if (
node.object &&
dayjsIdentifiers.has(node.object.name) && // 检查是否是 dayjs 变量
node.property &&
node.property.name === 'locale' // 检查是否调用了 locale
) {
context.report({
node,
messageId: 'noDayjsLocale',
data: {
identifier: node.object.name,
},
});
}
},
};
},
},
},
};
和上述的 AST 静态分析流程一样,这里我们使用了 ImportDeclaration 和 MemberExpression 节点,来记录 dayjs 的引入别名,并在成员方法调用时做判断,可以很好地满足我们的需求。
但是考虑到 ESLint 在我们的工程体系中是增量校验的,即只校验提交时改动的文件。此外我们需要扫描项目的 node_modules 下的二方包,这并不在 ESLint 的功能边界内(不同的二方包内可能包含不同的 lint 规则),因此 ESLint 只能作为一个辅助的手段。
既然 ESLint 行不通,我们继续尝试在构建时做文章。
Webpack Loader
我们团队的小程序技术栈使用的是 Taro,其使用 Webpack 进行构建。在 Webpack 体系中,Webpack Loader 用于对模块的源代码进行转换,它会遍历工程中所有引入的模块进行处理,我们同样可以使用它来做代码的静态分析。
我们可以通过编写自定义 loader,来对代码进行分析,如下所示:
module.exports = function (source) {
// 检查是否有 dayjs.locale 的调用
if (source.includes('dayjs.locale(')) {
// 抛出编译错误
this.emitError(new Error(`禁止调用 dayjs.locale() at ${this.resourcePath}`));
}
return source;
};
Taro 使用了 webpack-chain 来管理 webpack loader 和 plugin,我们需要使用 webpackChain 配置来添加我们的自定义 loader
const path = require('path');
const config = {
mini: {
webpackChain(chain) {
// 添加自定义 loader
chain.module
.rule('check-dayjs-locale')
.test(/\.[tj]sx?$/i)
.include.add(/@guming[\\/].+/) // 包含 node_modules 下的指定文件夹
.add(path.resolve(process.cwd(), 'src'))
.end()
.use('check-dayjs-locale')
.loader(path.resolve(__dirname, './loaders/check-dayjs-locale.js')); // 引入自定义 loader
},
},
};
分别修改项目内和 node_modules 中的代码,可以看到已经成功地检测到了污染代码并且抛出了错误
但是不难看出这样的方式和 eslint 自定义规则一样,都只是字符串级的匹配,如果用户引入 dayjs 时修改了命名同样无法检出,我们同样需要使用 AST 能力来实现,通过引入 acorn、estraverse 等库,我们可以对代码进行 AST 解析和遍历,同样可以满足需求:
const acorn = require('acorn');
const estraverse = require('estraverse');
const escodegen = require('escodegen');
module.exports = function (source) {
// 使用 Acorn 解析源代码为 AST
const ast = acorn.parse(source, {
ecmaVersion: 2020, // 设置 ECMAScript 版本
sourceType: 'module', // 支持 ES 模块
});
// 遍历 AST,查找 dayjs.locale 调用
estraverse.replace(ast, {
enter(node) {
// 判断是否是 dayjs.locale() 调用
if (
node.type === 'CallExpression' &&
node.callee.type === 'MemberExpression' &&
node.callee.object.name === 'dayjs' &&
node.callee.property.name === 'locale'
) {
const argument = node.arguments[0];
if (argument && argument.type === 'Literal') {
const locale = argument.value;
console.log(`Intercepted dayjs.locale call with argument: ${locale}`);
// 比如,阻止某些语言的切换
if (locale === 'fr') {
// 修改参数为默认语言 'en'
node.arguments[0].value = 'en';
}
}
}
},
});
// 将修改后的 AST 重新生成代码
const output = escodegen.generate(ast);
// 返回修改后的代码
return output;
};
实际上,在前端工程中我们通常都会使用 babel 来进行代码转译,而 babel 自身就提供了一套 AST 的解析和分析工具,我们没必要为了这些功能单独引入其他的包。编写 Babel 插件,或许是一个更好的选择。
Babel 插件
Babel 是一个通用的多功能的 JavaScript 编译器,通常用于将高版本的 ES 语法转译成低版本的 ES 语法,来让开发者使用最新语法进行开发,而不需要考虑浏览器的兼容性问题。
除了转译 JS 代码外,我们同样可以通过编写 Babel 插件来支持不同形式的静态分析,大体思路和 ESLint 一致,通过 ImportDeclaration 和 MemberExpression 追踪 dayjs.locale 的调用,从而进行代码分析:
module.exports = function ({ types: t }) {
// 用于记录 dayjs 的别名
let dayjsIdentifiers = null;
return {
visitor: {
// 处理 import 语句:import dayjs from 'dayjs'
ImportDeclaration(path) {
if (path.node.source.value === 'dayjs') {
const specifier = path.node.specifiers[0];
if (t.isImportDefaultSpecifier(specifier)) {
dayjsIdentifiers = specifier.local.name;
}
}
},
// 处理 .locale() 调用
MemberExpression(path) {
if (dayjsIdentifiers && t.isIdentifier(path.node.object, { name: dayjsIdentifiers }) && t.isIdentifier(path.node.property, { name: 'locale' })) {
const parentPath = path.parentPath;
if (parentPath.isCallExpression()) {
const args = parentPath.node.arguments;
// 打印参数
console.log(`${dayjsIdentifiers}.locale called with arguments:`, args.map(arg => arg.value));
// 可以在这里修改参数,例如强制设置为 'en'
if (args.length > 0 && t.isStringLiteral(args[0])) {
parentPath.node.arguments[0] = t.stringLiteral('en');
}
}
}
}
}
};
};
测试一下,完美实现了我们的需求,重命名的方法调用也成功地拦截了。
由于 AST 的静态代码分析限制,我们无法禁用诸如
dayjs[method]('zh-cn')
这样的动态调用,实际上也实在是不可能有人这么去写代码,除非是恶意投毒…… 因此我们只限制了最常见的一些可能的误修改场景。正所谓防君子不防小人,这种程度的编译时拦截已经为我们最大程度上杜绝了开发误修改的可能性。
代码实现
综合团队技术栈以及工具的能力边界考虑,我们最终敲定了使用 Webpack 使用 Babel Loader 注册 Babel Plugin 的形式实现功能。
本次改造的是 Taro 小程序工程,Taro 使用 webpack-chain 来管理 webpack 配置。我们需要通过配置 webpackChain 字段来注册 babel-loader,指定 include 属性来添加 src 目录和 node_modules 下的二方包目录:
const path = require('path');
const config = {
mini: {
webpackChain(chain) {
// 添加自定义 loader
chain.module
.rule('check-dayjs-locale')
.test(/\.[tj]sx?$/i)
.include.add(/@guming[\\/].+/)
.add(path.resolve(process.cwd(), 'src'))
.end()
.use('babel') // 使用 Babel 进行处理
.loader('babel-loader') // 使用 babel-loader
.options({
plugins: [path.resolve(__dirname, './babel-plugins/check-dayjs-locale.js')],
});
},
},
};
配置化的通用校验能力
就本次需求的编译时校验场景而言,只拦截 dayjs.locale
是否可以满足需求了呢?答案是肯定的。但对于团队而言,我们如何保证未来不会出现其他类似的问题呢?今天是 dayjs,明天会不会是 moment?难道出了问题我们还要继续迭代插件吗?
在编码时我们总是要考虑未来可能发生的变化。实际上,架构组的同学最终为我们提供了一个通用的规则禁用插件,其支持通过如下方式禁用方法的调用以及模块的引入:
module.exports = {
plugins: [
["@guming/babel-plugin-ban-usage", {
rules: [
{
source: "dayjs",
ban: ["default.extend", "default.locale"] // 方法禁用
},
{
source: "dayjs/plugin/xxx", // 导入禁用
import: false
}
]
}]
]
}
在代码实现上,新增了 Program 节点的 Visitor,在其中获取 rules 配置;并且在遍历 ImportDeclaration 节点时按照所配置的规则进行了匹配校验。
import { NodePath, PluginObj, types as t } from '@babel/core';
import { stringifyMemberExpression } from './utils';
export default function BabelPluginBanUsage(): PluginObj {
const rulesMap = new Map<string, { import?: boolean; ban: string[] }>();
// 抛出错误
function throwPathError(path: NodePath, msg: string): never {
if (path?.buildCodeFrameError) {
throw path.buildCodeFrameError(`[BanUsage] ${msg}`);
}
throw new Error('未知异常');
}
return {
name: 'BabelPluginBanUsage',
visitor: {
Program: {
enter(path, state) {
const { rules } = (state.opts || {}) as any;
for (const rule of rules || []) {
if (!rule.source) {
throw new Error('配置规则 source 不能为空');
}
let target = rulesMap.get(rule.source);
if (!target) {
target = { import: undefined, ban: [] };
rulesMap.set(rule.source, target);
}
if (rule.import === false) {
target.import = false;
}
if (rule.ban) {
target.ban.push(...rule.ban);
}
}
},
},
ImportDeclaration(path) {
if (path.node.importKind === 'type') return;
const source = path.node.source.value;
const target = rulesMap.get(source);
if (!target) return;
if (target.import === false) {
throwPathError(path, `已禁止引入: ${source}`);
return;
}
if (target.ban.length > 0) {
for (let i = 0; i < path.node.specifiers.length; i++) {
const specifier = path.node.specifiers[i];
const isSpecifier = specifier.type === 'ImportSpecifier';
const isDefault =
specifier.type === 'ImportDefaultSpecifier' ||
specifier.type === 'ImportNamespaceSpecifier' ||
(isSpecifier && t.isIdentifier(specifier.imported, { name: 'default' }));
// prettier-ignore
const targetPath = (isDefault ? path.get(`specifiers.${i}.local`) : path.get(`specifiers.${i}.imported`)) as NodePath<t.Identifier>;
const localName = specifier.local.name;
const binding = targetPath.scope.bindings[localName];
for (const banItem of target.ban) {
if (isDefault && banItem === 'default') {
throwPathError(targetPath, `已禁止使用: ${banItem} (from "${source}")`);
} else {
for (const referencePath of binding.referencePaths) {
if (
t.isIdentifier(referencePath.node) &&
t.isMemberExpression(referencePath.parentPath?.node) &&
referencePath.parentPath!.node.object === referencePath.node
) {
let members = stringifyMemberExpression(referencePath);
if (isDefault) {
const index = members.indexOf('.');
if (index >= 0) {
members = `default${members.slice(index)}`;
}
}
if (
members === banItem ||
(members.startsWith(banItem) && members[banItem.length] === '.')
) {
throwPathError(
targetPath,
`已禁止使用: ${banItem} (from "${source}")`
);
}
}
}
}
}
}
}
},
},
};
}
如此一来,我们便可以同时支持多种形式的禁用策略了。后续如果需要禁用其他方式或是资源引入,我们也可以很轻松地通过修改配置文件来支持。
总结
本文主要介绍了团队如何使用 Webpack Loader 和 Babel 插件来防止开发者在项目中误修改 dayjs 的本地化配置,从而导致全局配置被污染的问题。
在开发工作中,我们经常会遇到各种各样的问题和挑战,而这些问题往往不是孤立存在的,它们可能是由一系列复杂的因素相互作用的结果。正如本文所描述的 dayjs 本地化配置问题,它不仅仅是一个简单的编码错误,而是涉及到了协作规范以及工程架构等多个层面。
一个专业且可信赖的前端工程师需要具备的不仅仅是编码能力,更重要的是要有预见性的思维和系统性的解决问题的能力。需要能够从宏观的角度去审视问题,预见可能的风险,并采取有效的措施来预防和解决这些问题。
关于本文
作者:@苏梓铭
原文:https://mp.weixin.qq.com/s/XZ1iwSztcD6ALinBeLpoPA
这期前端早读课
对你有帮助,帮” 赞 “一下,
期待下一期,帮” 在看” 一下 。