开发ESLint & Stylelint插件实践

3,785 阅读5分钟

公司站点做前端架构改造,需要把历史代码中,所有用到的色值替换成变量,便于做主题化和样式迭代。
项目一期通过 nodejs 脚本,扫代码并人工做替换。
考虑到新代码的后期保障和后续其他改造工作,决定编写 Lint 并整合到项目的 CI 脚本中。
因为采用的是 react,涉及到的色值,一部分在 jsx 代码中,一部分在自定义的 css 中,所以需要分别开发eslintstylelint插件。

开发 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,其实就是一个树状的数据结构,每个节点都有对应的选择器。选择器很多,可以通过mdnestree查看不同 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 的测试工具依赖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,显示运行结果:

示例1

如果觉得编写测试用例太过麻烦,可以直接在真实项目中安装测试:

"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"
  ]
}

或者使用在线工具:

示例2

发布

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: redatrule,比如@mediacomment等。

对于我们检测 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 终究是一种协助工具,实际开发中,测试还是必不可少的,有条件的话可以上自动化单元测试。