公司站点做前端架构改造,需要把历史代码中,所有用到的色值替换成变量,便于做主题化和样式迭代。
项目一期通过 nodejs 脚本,扫代码并人工做替换。
考虑到新代码的后期保障和后续其他改造工作,决定编写 Lint 并整合到项目的 CI 脚本中。
因为采用的是 react,涉及到的色值,一部分在 jsx 代码中,一部分在自定义的 css 中,所以需要分别开发eslint和stylelint插件。
开发 ESLint 插件
在开发 eslint 插件前,先简单理一下下面几个概念:
- eslint 规则
- eslint 解析器
- eslint 插件
eslint 规则
规则是 eslint 基础配置之一,每条规则都用来检测符合某种特征的代码。一条规则可以配置是否开启以及错误的级别,其中 0 或“off”代表关闭规则,1 或“warn”代表警告(warning),2 或“error“代表错误(error)。比如:
rules: {
"no-unused-vars": 1 // 当存在没有使用却声明的变量时,给出warning
}
有些规则还有 option,则可以通过数组进行配置,比如:
rules: {
"no-unused-vars": ["warn", { "ignoreRestSiblings": true }] // 形如var { type, ...coords } = data;type未使用的话会ignore
}
eslint 解析器
eslint 工作的原理,是利用解析器将 javascript 代码解析成 AST(抽象语法树),并对 AST 做从上至下和从下至上的两次遍历。同时,生效的规则会对 AST 中某些节点的选择器做监听,并触发回调。
所谓的 AST,其实就是一个树状的数据结构,每个节点都有对应的选择器。选择器很多,可以通过mdn或estree查看不同 js 版本的 AST 选择器。
这里推荐一个在线工具:astexplorer.net/ 可以对 js 代码片段在线解析,对后面开发插件带来很大的帮助。
eslint 官方默认的解析器是espree,其他用的比较多的还有babel-eslint,比官方支持更多最新的语法特性。
eslint 插件
官方提供的可配置的规则,都是内置在 eslint 包中的。如果想自定义规则,比如开始提到的查找色值这类特殊需求,就必须开发 eslint 插件。一个 eslint 插件,通常是若干规则和处理器的集合,比如写 react 项目,常常会用到的eslint-plugin-react。
下面就正式介绍,开发一个 eslint 插件的主要过程。
创建项目
安装官方推荐的脚手架工具Yeoman和对应的generator-eslint:
npm install -g yo generator-eslint
# 创建项目目录
mkdir eslint-plugin-console
cd eslint-plugin-console
# 生成项目
yo eslint:plugin
项目目录结构如下:
── eslint-plugin-console
│ ├── CHANGELOG.md
│ ├── README.md
│ ├── lib
│ │ ├── index.js // 入口
│ │ ├── processors // 存放处理器
│ │ └── rules // 存放规则
│ ├── package.json
│ └── yarn.lock
这里要注意两点:
- eslint 插件有固定的命名形式,以 eslint-plugin-开头,在配置时可以省略这个开头
- 注意脚手架工具创建的默认 eslint 版本可能较老,这里需要与所应用的项目的 eslint 版本保持一致,避免不适配
打开入口文件:
// import all rules in lib/rules
module.exports.rules = requireIndex(__dirname + "/rules");
// import processors
module.exports.processors = {
// add your processors here
};
可以看到,一个最基础的 eslint 插件其实就是一个包含 rules 和 processors 的对象。其他的配置具体可以参考官方文档
创建规则
可以通过脚手架工具执行命令来创建规则:
yo eslint:rule
当然也可以手动创建:由于入口文件通过requireindex引用了整个 rules 目录,所以可以直接在 rules 目录下以规则名为文件名创建一个规则文件:no-css-hard-code-color.js
这里需要注意,虽然官方没有限制规则的命名方式,但为了便于理解和维护,通常用于禁用某种形式的规则,可以以 no-开头,后面跟禁止的内容,并且单词之前以短横-分隔。
开发规则
module.exports = {
meta: {
type: "problem",
},
create: function(context) {
return {
// 返回AST选择器钩子
};
}
};
一个规则导出一个对象,对象中最核心的功能部分,主要在 create 当中,用来监听 AST 选择器。
另外,create 回调中还返回了一个 context 对象,用的最多的就是它的 report 方法,用来给出报错提示。具体 API 可以参考官方文档
合理选择 AST 选择器
接下来分析下需求,需要”检测所有 js 中写死的 css 色值“,那么先要总结出 css 色值的所有形式。
根据mdn查到,大致有四类:内置命名色、hex 色值、rgb 色值和 hsl 色值。于是,针对这四种,分别做匹配:内置色值采用枚举的方式检查,后三种使用正则校验:
/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/ //hex
/^rgba?\(.+\)$/ //rgb
/^hsla?\(.+\)$/ //hsl
然后就是调用 AST 选择器钩子做检测。首先能想到的是最简单粗暴的方式,对所有的字面量做检查:
TemplateElement(node) {
const { value } = node;
if (value) {
checkAndReport(value.raw, node);
}
},
然而经过测试,这种方式会存在大量的误检测,比如:
<a className="blue" href="/" />
这里的 blue 不是色值,但也会一概被误检出来。
调整策略,针对对象的 key 需要做一层白名单过滤。同时,通过分析可知,色值可能存在于以下几种情况中:
- 对象的值
- 变量声明的值
- 变量赋的值
- 三元表达式的值
- 模板字符串
于是修改代码如下:
// 对象的值
Property(node) {
const whiteList = ["className", "type", "warn"];
if (whiteList.indexOf(node.key.name) >= 0) return;
if (node.value.type === "Literal") {
checkAndReport(node.value.value, node.value);
}
},
// 变量定义
VariableDeclarator(node) {
if (!node.init) return;
if (node.init.type === "Literal") {
checkAndReport(node.init.value, node.init);
}
},
// 变量赋值
AssignmentExpression(node) {
if (node.right.type === "Literal") {
checkAndReport(node.right.value, node.right);
}
},
// 三元表达式
ConditionalExpression(node) {
if (node.consequent.type === "Literal") {
checkAndReport(node.consequent.value, node.consequent);
}
if (node.alternate.type === "Literal") {
checkAndReport(node.alternate.value, node.alternate);
}
},
// 模板字符串
TemplateElement(node) {
const { value } = node;
checkAndReport(value.raw, node);
},
这样基本能检测出所有硬编码的色值。
不过规则可能还是存在一些问题。一方面,一些特殊情况可能会误检,这时可以通过eslint 注释针对部分代码片段做过滤;另一方面,规则还是有漏洞的,比如如果通过模板字符串把内置色值名做了拆分、并赋值给新的变量,就检测不出来了。但这种情况一般不用考虑,如果为了绕过检测,直接用前面所述的 eslint 注释忽略掉就行了。
测试规则
测试插件规则的方式,我总结下来有三种:
- eslint 集成的自动化测试工具
- 安装到实际项目中运行
- 在线 AST 分析工具astexplorer
eslint 的测试工具依赖mocha,所以需要先安装 mocha(脚手架搭建的话可以忽略这步):
npm install mocha --dev
然后再 tests 目录下编写测试用例:
var rule = require("../../../lib/rules/no-css-hard-code-color"),
RuleTester = require("eslint").RuleTester;
var ruleTester = new RuleTester();
ruleTester.run("no-css-hard-code-color", rule, {
valid: [{ code: "var designToken = T_COLOR_DEFAULT" }],
invalid: [
{
code: "var designToken = '#ffffff'",
errors: [
{
message: "Please replace '#ffffff' with DesignToken. You can find in http://ui.components.frontend.ucloudadmin.com/#/Design%20Tokens?id=color",
},
],
},
],
});
添加 npm 脚本:
"scripts": {
"test": "mocha tests --recursive",
},
运行npm run test
,显示运行结果:

如果觉得编写测试用例太过麻烦,可以直接在真实项目中安装测试:
"dependencies": {
"eslint-plugin-console": "../eslint-plugin-console",
}
然后添加 .eslintrc 配置:
{
"parser": "babel-eslint",
"env": {
"browser": true,
"es6": true,
"node": true
},
"rules": {
"console/no-css-hard-code-color": 2
},
"plugins": [
"eslint-plugin-console"
]
}
或者使用在线工具:

发布
eslint 插件一般都是以 npm 包的形式发布和引用的,所以可以在 package.json 中添加发布脚本:
"scripts": {
"_publish": "npm publish",
"publish:patch": "standard-version --release-as patch --no-verify && npm run _publish",
"publish:minor": "standard-version --release-as minor --no-verify && npm run _publish"
},
这里引入standard-version,可以实现自动生成 CHANGELOG 文件。
开发 stylelint 插件
eslint 是用来解析 javascript 的,但项目中,还有部分硬编码的色值在.css 文件中,那么有没有办法检测这些文件呢?答案就是使用 stylelint。
与 eslint 的差异
stylelint 的设计大体上与 eslint 非常类似,所以这里重点只就它们的差异点做介绍。主要差异体现在以下几点:
- 解析器
- 插件入口
- 命名规则
stylelint 解析器
与 eslint 最核心的区别,无疑就是解析器。stylelint 所使用的解析器,是大名鼎鼎的postcss。如果开发过 postcss 插件就会发现,stylelint 的处理逻辑就类似于 postcss 插件。
具体实现上来说,stylelint 通过stylelint.createPlugin
方法,接收一个 rule 回调函数,并返回一个函数。函数中可以取到所检测 css 代码的 postcss 对象,该对象可以调用 postcss 的 api 对代码进行解析、遍历、修改等操作:
function rule(actual) {
return (root, result) => {
// root即为postcss对象
};
}
相比 eslint,css 的节点类型少很多,主要有rule
,比如#main { border: 1px solid black; }
、decl
,比如color: red
、atrule
,比如@media
、comment
等。
对于我们检测 css 属性值是否含有色值的需求,可以调用root.walkDecls
对所有 css 规则做遍历:
root.walkDecls((decl) => {
if (decl) { ... }
});
随后,再利用postcss-value-parser解析出规则中的值部分,通过枚举或正则,判断是否为色值:
const parsed = valueParser(decl.value);
parsed.walk((node) => {
const { type, value, sourceIndex } = node;
if (type === "word") {
if (
/^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/.test(value) ||
colorKeywords.includes(value)
) {
...
}
}
if (type === "function" && /^(rgba?|hsla?)$/.test(value)) {
...
}
});
最后,当检测到色值时,调用 stylelint 提供的report方法给出报错提示:
const messages = ruleMessages(ruleName, {
rejected: (color) =>
`Unexpected hard code color "${color}", please replace it with DesignToken.`,
});
report({
message: messages.rejected(valueParser.stringify(node)),
node: decl,
result,
ruleName,
});
插件入口
与 eslint 不同的是,stylelint 插件通过stylelint.createPlugin
创建。如果一个插件包含多个规则,则可以返回数组:
const requireIndex = require("requireindex");
const { createPlugin } = require("stylelint");
const namespace = require("./lib/utils/namespace");
const rules = requireIndex(__dirname + "/lib/rules");
const rulesPlugins = Object.keys(rules).map((ruleName) => {
return createPlugin(namespace(ruleName), rules[ruleName]);
});
module.exports = rulesPlugins;
这里参照了 eslint 插件类似的目录结构,通过 requireIndex 一起倒入进入口文件。
命名规则
相比 eslint,stylelint 官方对规则的命名做了建议,一般由两部分组成,即检测的对象+检测的内容,比如我们检测硬编码的色值,就可以命名为color-no-hard-code
。具体规则可见:stylelint.io/user-guide/…
总结
eslint 和 stylelint 可以帮助团队代码风格统一、减少 bug,而通过自定义插件和规则,可以根据业务和框架情况,定制化一些特性,这点在架构迭代中很有帮助,比如要下线某个组件或 组件的 api。但是 lint 终究是一种协助工具,实际开发中,测试还是必不可少的,有条件的话可以上自动化单元测试。