百度工程师手把手教你实现代码规范检测工具 -ag凯发k8国际
01 引言
代码规范是软件开发领域经久不衰的话题。在前端领域中,说到代码规范,我们会很容易想到检查代码缩进、尾逗号以及分号等等,除此之外,代码规范还包括了针对特殊场景定制化的检查。javascript 代码规范检查工具包括 jslint、jshint、eslint、fecs 等等,样式代码规范检查工具主要为 stylelint。
02 背景
san-native 是百度 app 内部的一套动态 na 视图框架,利用 js 引擎驱动 na 端渲染,使得 web 前端工程师可以十分方便的编写原生移动应用,一套代码多端运行。随着百度 app 中越来越多的业务开始接入 san-native,在此过程中,经常遇到 h5 中的一些样式属性以及事件在 san-native 中不支持,不按照 san-native 中内置组件嵌套规则的代码导致渲染结果不符合预期。比如下面一段.san 文件中的代码存在多处错误会导致端上渲染不正常甚至导致 crash:
因此,为了能够在编码阶段提前发现这些问题,我们需要对代码进行一些特殊的检测,包括样式,事件,以及嵌套规则。为了实现这样的功能,我们启动了 san-native-linter 项目,该项目中包含了两个相互独立的插件:@baidu/eslint-plugin-san-native 以及 @baidu/stylelint-plugin-san-native,我们将逐一介绍其实现原理。
03 抽象语法数(ast)
首先我们需要了解代码检测的主角 —— 抽象语法树 (abstract syntax tree)。在计算机科学中,抽象语法树简称 ast,它是源代码语法结构的一种抽象表示。它以树状的形式表现编程语言的语法结构,树上的每个节点都表示源代码中的一种结构。
将字符串源码转换成 ast 的工具称为解析器,常见的 javascript 解析器有 @babel/parser,espree,acorn 等,样式解析器有 postcss,csstree 等。ast 的生成有两个步骤:
- 词法分析(分词):将整个代码字符串分割成最小语法单元数组
- 语法分析:在分词基础上建立分析语法单元之间的关系
我们可以通过在线工具 [1] 查看一段代码的 ast,比如下图所示的 ast,图中用到的解析器为 @babel/eslint-parsre,右侧所示的对象为左侧代码对应的 ast,该 ast 的根节点 type 为 program,其 body 中有两个子节点,分别为 import 以及 export 对应的语法节点,其 type 分别为:importdeclaration 与 exportdefaultdeclaration。每个节点中 range 表示当前节点对应的代码在字符串源码中的开始与结束位置,loc 为开始与结束位置的行列信息。
04eslint-plugin-san-native
介绍 eslint-plugin-san-native 插件的实现之前,我们会先介绍 eslint 中的规则 (rule),eslint 配置与复用方案以及 eslint 的运行原理,最后介绍插件如何实现以及关键的技术点。
eslint 中的规则(rule)
上文中已经通过简单的例子介绍了抽象语法树的结构,并且在引言部分已经简述了 eslint 检测代码的核心思想,即对 ast 进行处理从而定位不符合规定的代码,在 eslint 中对 ast 进行处理的实体就是这里所说的规则(rule),下面给出了一个规则的示例代码:
module.exports = {meta: {type: "problem",docs: {...},schema: [],messages: {readonlymember: "the members of '{{name}}' are read-only."}},create(context) {return {importdeclaration(node) {context.report({node: node,messageid: "readonlymember",data: {name: 'xxx'}});}};} };在这样的一个规则中,我们需要导出一个对象,包括 meta 属性以及 create 方法,前者用于标记该 rule 的一些信息,后者则用于处理 ast 某个节点,并提供错误信息与出错的节点。
- meta 中,通过 type 标记规则的类型,docs 包含了规则的文档链接等信息,schema则表示了配置规则应该遵守的约定,messages 包含了错误信息。
- create 函数:
1.返回的对象中具有一个名为 importdeclaration 的方法,我们将该方法称之为 import 语法节点的访问器 (visitor),即在 eslint 对整个 ast 遍历的过程中,访问到 import 语法节点的时候会调用所有名称为 importdeclaration 的 visitor。
2.create 函数接收的参数为 context 对象,该对象上挂载了 eslint/ 自定义解析器为 rule 提供的方法以及用户配置文件中的自定义配置信息,更多的属性与方法见官方文档。这里我们调用 context.report 将错误信息以及对应的语法节点提供给 eslint。
eslint 配置的复用方案
上文介绍了 eslint 中的规则,在实际的工程应用中我们可以通过对规则进行定制化的配置来满足特定的需求,但是如果每启动一个项目,我们都需要进行相同的配置,势必会带来一定的时间成本。eslint 提供了全面、灵活的配置能力,可以配置解析器、规则、环境、全局变量等等;可以快速引入另一份配置,和当前配置层叠组合为新的配置;还可以将配置好的规则集发布为 npm 包,在工程内快速应用。接下来,我们以 @ecomfe/eslint-config 为例看看如何高效的实现配置的复用,下图为该代码库的目录结构:
在 @ecomfe/eslint-config 中,每个 js 文件都是一份 eslint 的配置,根目录下的入口文件 index.js 为基础配置 (base),其他文件夹可以看作是对基础配置的扩展,比如 san 文件夹下是关于 san 的一些规则的配置,在实际的项目中可以通过下面的方式引入:
module.exports = {extends: ['@ecomfe/eslint-config','@ecomfe/eslint-config/san', // 注意顺序// 或者选择严格模式// '@ecomfe/eslint-config/san/strict',], };eslint 会将 extends 字段中的所有配置文件合并起来,每个配置文件包含如下几个内容:
module.exports = {parser: 'xxx',parseroptions: {parser: 'yyy',sourcetype: 'module'},plugins: [],env: {},rules: {'indent-legacy': 'off',} };在这样一个配置文件中,各个字段的含义如下:
- parser:用于申明自定义 parser,该 parser 会将文件内容转换成 ast
- parseroptions:自定义 parser 的配置项
- plugins:申明使用的 eslint 插件,这部分会在后面 eslint 工作原理介绍
- env:申明检测所处的环境,该选项用于引入一组预定义的全局变量
- rules:对规则的配置
eslint 中插件与配置文件的区别
上文中依次介绍了 eslint 的规则的实现以及 eslint 配置的复用,本节我们说明插件与配置文件之间的区别,eslint 插件的入口文件示例代码如下:
module.exports = {rules: {'no-style-float': require('./rules/no-style-float'),// ...},processors: {'.san': require('./processor'),// ...},configs: {always: {plugins: ['@baidu/san-native'],rules: {'@baidu/san-native/no-style-float': 'error',}},// ...} };在这个入口文件中,我们向 eslint 提供了一个对象,该对象中包含的属性有:
- rules:包含了该插件所有的规则的具体实现
- processors:这里我们定义了专门用于处理.san 文件字符串源码以及检查信息的 processor
- configs:包含了一些配置,可以看到与 @ecomfe/eslint-config 中的配置文件类似,具备 plugins选项,以及对 rules 的配置。
需要说明的是:为了复用上面 configs.always 的配置,我们可以在项目的.eslintrc.* 文件中 extends 选项加上如下代码:
module.exports = {extends: ['plugin:@baidu/san-native/always'] };因此 eslint 插件以及配置文件的区别可以总结如下:
- plugin 插件主要涉及自定义规则的具体实现,同时还能够提供配置
- extend 配置主要涉及规则的具体配置
eslint 的工作原理
接下来介绍 eslint 是如何处理各种配置文件的,以及插件与配置文件中各字段在 eslint 中的作用。eslint 提供了命令行的方式来检测某个文件的代码,比如,我们想对.san 文件进行检查,那么可以通过下面的命令来实现:
eslint --ext .san src/app/component/animate/index.san当我们执行命令行的时候,eslint 的工作原理如下图所示:
从上图当中我们可以看到,文件的字符串内容首先会被插件的 prepocess 处理,然后处理的结果被 parser 解析成对应的 ast,然后遍历 ast 的同时执行每个规则提供的方法,最后得到的检测结果会被 postprocess 处理。因此 eslint 插件中的 processors 属性给开发者提供了操作字符串源码以及处理检测结果的能力。接下来分析 eslint 配置中的一些字段在检测过程中的作用:
*处理.eslintrc.配置文件
将文件内容解析成 ast
收集与执行 rule 生成的 visitor
当解析器将字符串解析成 ast 之后,在遍历 ast 的过程中会根据当前的节点类型执行对应的一些列提前注册好的 vistor。
eslint-plugin-san-native 的实现
经过上述 eslint 的工作原理的了解之后,我们开始介绍如何实现 eslint-plugin-san-native 来解决我们的问题,以下面的单文件组件为例:
<template><div></div> </template> <script>import test from './index.san';export default {} </script> <style lang="less">.a {} </style>有以下两点需要考虑的地方
项目构建
构建的项目目录结构如下
入口文件 eslint-plugin-san-native/lib/index.js 的代码如下:
module.exports = {rules: {'no-style-float': require('./rules/no-style-float'),// ...},processors: {'.san': require('./processor'),// ...},configs: {always: {plugins: ['@baidu/san-native'],rules: {'@baidu/san-native/no-style-float': 'error',}},// ...} };下面我们分别实现 processor 以及 rule。
processor 的实现
根据 eslint 工作原理可知,eslint 在获取到字符串源码的时候,会先利用插件提供的 preprocess 处理字符串源码,接着利用 parser 解析成 ast,然后将各个 ast 节点交给 rule 处理,接着处理后的检测结果交给 postprocess 处理,最后再执行 fix。因此,从 preprocess 到 postprocess 的过程中,处理的文件内容是不变的(ast 会被 object.freeze 处理),因此,我们可以在 preprocess 中将.san 中的 style 获取之后,利用 postcss 将其解析成 ast,并存储起来供后续所有 rule 共享。
获取 style对应的 ast
const postcss = require('postcss'); const syntaxs = {less: require('postcss-less'),sass: require('postcss-sass'),scss: require('postcss-scss') }; const processor = postcss([() => {}]); module.exports = {getast(syntax, content, plugins) {let ast = null;try {ast = syntax ? processor.process(content, {syntax}).sync(): processor.process(content).sync();} catch (error) {}return ast;},getstylecontentinfo(text) {const lines = text.split('\n');const content = /()([\s\s]*?)<\/style>/gi.exec(text);const langmatch = /\slang\s*=\s*("[^"]*"|'[^']*')/.exec(content[1]);const lang = langmatch[1].replace(/["'\s]/gi, '');const astfn = lang ? this.getast : this.getast.bind(null, syntaxs[lang]);return {startline: lines.indexof(content[1]),ast: astfn(content[3]),startoffset: text.indexof(content[3])};} };上面的代码根据 style中的 lang 字段,调用不同的 parser 对样式内容进行解析,并获取到代码块 style 所在的行数 startline 以及所处文件的位置 startoffset,这些数据都是用来修正样式 ast 节点位置的,这样 eslint 才能在输出错误信息的时候找到样式在文件中的真实位置。当然这里也可以直接利用 postcss-syntax 提供的 syntax 传入 postcss(defaultplugins).process 函数中,该 syntax 可以自动根据文件名称或者代码内容自动选择正确的语法解析器。
processor
const {styleastcache} = require('./utils/cache'); module.exports = {preprocess(code, filename) {// 所有.san 都会处理styleastcache.storeast(stylehelper.getstylecontentinfo(code));return [code];},postprocess(messages) {// 清除数据styleastcache.storeast(null);return messages[0];} };在各个规则中只需要引入 styleastcache,并调用 styleastcache.getast 即可获取到样式代码的 ast,styleastcache 在这里只是用于存储 ast 而已。
规则的实现
由于规则的实现依赖于自定义 parser 提供的 ast,因此我们需要先对 san-eslint-parser 的原理有一定的了解,那么我们将现分析其原理,然后介绍几类规则的实现方案。
san-eslint-parser
自定义 parser 需要提供 parseforeslint 方法,我们这里只关注该方法返回结果中的部分属性 (更多属性见官方):
- ast:ast 根节点
- services:自定义 parser 为 rule 提供的服务,每条规则可以通过 context.parserservices 访问到
ast
san-eslint-parser 会将我们.san 的文件内容利用分成三个 block,其中利用 parseroptions.parser 指定的解析器来处理 script 部分的内容,script 中如果是 javascript 代码则 parseroptions.parser 为 @babel/eslint-parser,如果是 typescript 代码则为 @typescript-eslint/parser。style 部分不会处理,template 部分当作 html 来解析。
上图所示为自定义 parser 生成的 ast,根节点的 type 为 program,根节点的 body 属性存储了 script 代码的 ast,根节点上的 templatebody 为 template 部分的 ast。由于 eslint 只会遍历根节点以及 body 上的节点,因此如果我们想为 templatebody 注册 visitor,那么可以通过 services 来实现。
services
san-eslint-parser 会在 services 属性上定义三个方法,我们只关注其中一个,简化后的代码如下:
let emitter = null; // 发布订阅器 function definetemplatebodyvisitor(templatebodyvisitor) {let scriptvisitor = {};if (!emitter) {emitter = new eventemitter();scriptvisitor["program"] = node => {traversenodes(rootast.templatebody)};}for (const selector of object.keys(templatebodyvisitor)) {emitter.on(selector, templatebodyvisitor[selector]);}return scriptvisitor; }该方法主要完成了两部分的工作:
因此,我们可以利用上述方法在每条 rule 中编写相关的 visitor 来处理 templatebody 中不同 type 类型语法节点,如下代码所示:
module.exports = {meta: {...},create(context) {return context.parserservices.definetemplatebodyvisitor(context, {'velement'(node) {// do something},'vtext'(node) {// do something}});} };属性规则
在 template 模板中,我们需要检测某个标签上的事件,内联样式,必选属性三种内容,为了避免重复代码,希望通过配置的方式实现规则。首先定义内置组件的描述信息,举例来说:
{"name": "lottie-view", // 标签名称"events": [ // 支持的事件"click","touchstart","touchmove","touchend","touchcancel","layout","longclick","pressin","pressout","firstmeaningfulpaint","animationfinish","downloadfinish"],"attributes": { "required": [],"oneof": [["src", "source"]], // 必须的可选属性"content": {}},"style": {"required": [],"notsurpport": []},"nestedtag": [] // 允许的子标签 }上面的描述信息中依次定义了标签名称,支持的事件,必选的属性,不支持 / 必须的内联样式,以及允许的子标签名称。描述信息的另一个优势是当组件库更新或者添加组件的时候,只需要在组件中维护这样的信息,则可以在不发布新版本 eslint 插件的时候应用到组件新的规则。
在上文中已经介绍了如何在规则中通过编写相关的 visitor 来处理 templatebody 中不同 type 类型语法节点,因此我们只需要对节点的相关数据进行一些判断,就可以实现代码检测。判断的逻辑这里不再介绍,只贴上一个 velement 中需要关注的节点属性:
上图中是下面标签对应的节点数据,为了获取标签的属性,我们可以从 starttag.attributes 中获取,可以看到其中属性名称为 style 的节点数据。
<div style="background-color:#fff; flex:1">...</div>样式规则
对于样式规则来说,我们需要同时检测 tempate 上的内联样式,也需要检测 style块中的样式代码,简化后的规则代码如下:
module.exports = {meta: {...},create(context) {return context.parserservices.definetemplatebodyvisitor(context, {'velement[name="template"]'(node) {const {ast: result, startline, startoffset} = styleastcache.getast();if (result && result.root) {result.root.walkdecls(decl => {// do something});}},vattribute(node) {const name = utils.getname(node);if (name == null) {return;}if (name === 'style' && node.value && node.value.value) {let stylevalarr = inlinestyleparser(node.value.value);stylevalarr.foreach(decl => {// do something});}}});} };可以看到我们对 template 对应的 ast 定义了两个 visitor,第一个 visitor 用于获取 velement 并且节点名称是 template 的语法节点,在该节点的 visitor 中,利用 postcss 提供的 api 遍历 style对应的 ast。第二个 visitor 用于检测 template 中每个标签上 style 属性中的内联样式。
在 vscode 中的检测效果
在每一条规则中,当发现不符合规则的代码时,我们可以通过 context.report 将对应的 ast 语法节点 / 位置信息以及错误信息提供给 eslint
// 提供节点 context.report({node: node,messageid: "..." }); // 提供位置信息:loc context.report({loc: node.loc,message: "..." });这样 eslint 能够通过 vscode 中的插件对错误代码进行高亮,从而实现在编译前提示代码中不支持的样式,事件,嵌套规则等等。
05stylelint-plugin-san-native
到此,我们介绍了如何开发 eslint 插件检测.san/.js/.ts 文件中的 san 组件,下面介绍如何开发 stylelint 插件来检测.less/.sass/.scss/.styl 文件中的样式代码。stylelint 提供了类似 eslint 的配置方式,可以在配置文件.stylelintrc.* 中 extends 多个配置文件,对单个 rule 进行配置,支持通过编写插件实现自定义的规则,支持使用 processor 在开始检测之前对源码字符串进行修改,并在结果输出之前对检测结果进行修改。
stylelint 工作原理
下图为 stylelint 的工作流程图,这里的 processor.code 相当于 eslint 中的 preprocess,而 processor.result 相当于 eslint 中的 postprocess。stylelint 与 eslint 的工作原理非常相似,从整体上来说,processor.code 与 processor.result 之间的过程与 eslint 有区别,stylelint 中会遍历所有 rules,然后将 ast 根节点交给每个 rule 进行遍历,而不像 eslint 中需要自己遍历 ast。
stylelint 规则
从上文 stylelint 的工作流程分析可以知道,stylelint 的规则接收一个 ast 根节点以及配置数据,因此其规则示例代码如下:
module.exports = function rule(primary, secondary, context) {return (root, result) => {}; };其中,primary 以及 secondary 为 rule 配置的时候填写的配置,举例来说:
"rules": {"block-no-empty": null, // primary 为 null"color-no-invalid-hex": true, // primary 为 true"comment-empty-line-before": [ "always", // primary 为 always{"ignore": ["stylelint-commands", "between-comments"]} // secondary] }stylelint 插件
只需要将 rule 利用 stylelint 提供的方法处理后即可生成一个插件,并且需要提供 rulename 以及 messages
const stylelint = require("stylelint"); const rulename = "plugin/xxx"; const messages = stylelint.utils.rulemessages(rulename, {expected: "expected ..." }); module.exports = stylelint.createplugin(rulename,function (primary, secondary, context) {return function (root, result) {// ...stylelint.utils.report({/* .o. */});};} ); module.exports.rulename = rulename; module.exports.messages = messages;stylelint-plugin-san-native 实现
构建的项目目录结构如下:
其中入口文件 index.js 的简化代码如下:
module.exports = [stylelint.createplugin(...),stylelint.createplugin(...),// ... ]同时提供了两份配置文件分别为:always.js 以及 temporary.js,下面为 always.js 的代码:
module.exports = {plugins: ['.'],rules: {'@baidu/stylelint-plugin-san-native/no-flex-basis': true,// ...} };在实际工程项目的.stylelintrc.js 中可以通过 extends 字段复用配置文件,比如:
module.exports = {extends: ['@baidu/stylelint-plugin-san-native/always','@baidu/stylelint-plugin-san-native/temporary'],rules: {}由于篇幅有限,我们只对一个具体的规则实现进行介绍,比如在 san-native 中并不支持样式属性 justify-content 的值被设置为 baseline,因此我们需要对该属性的值进行检测以及报错处理,规则的部分关键代码如下:
const {utils} = require('stylelint'); const getdeclarationvalue = require('stylelint/lib/utils/getdeclarationvalue'); const declarationvalueindex = require('stylelint/lib/utils/declarationvalueindex'); const valueparser = require('postcss-value-parser'); const meta = {stylename: 'justify-content',message: `only some values of '${stylename}' are supported in sna-native`,surrpportvalue: ['flex-start', 'flex-end', 'center', 'space-between', 'space-around'] }; const rulename = `stylelint-plugin-san-native/valid-justify-content`; const messages = utils.rulemessages(rulename, {expected: () => meta.message }); module.exports = function rule(primary) {return (root, result) => {const validoptions = utils.validateoptions(result, rulename, {primary});if (!validoptions || !primary) { return; }root.walkdecls(decl => {// 将declaration语法节点上属性键值对解析成astconst parsed = valueparser(getdeclarationvalue(decl));// 遍历每个属性值对应的节点parsed.walk(node => {if (meta.surrpportvalue.indexof(node.value) < 0) {utils.report({// 获取declaration语法节点中属性值部分在与declaration语法节点开始位置的偏移量index: declarationvalueindex(decl) node.sourceindex,message: messages.expected(),node: decl,rulename,result});}});});}; }; module.exports.rulename = rulename; module.exports.messages = messages;在对代码进行分析之前,我们需要了解 postcss 返回的 ast 的两个关键点:
1.属性声明与赋值会被解析成类型为 declaration 的语法节点,举例如下:
justify-content: baseline;2.可以通过 ast 上的 walkdecls 方法获取 ast 树中的每个类型为 declaration 的语法节点,该方法是由 postcss 提供,更多的方法可见 postcss 官方文档
上面代码中 rule 函数利用 root.walkdecls 遍历语法树中的 declaration 语法节点,并且每个 declaration 语法节点会被传入 root.walkdecls 接收的回调函数中,在该回调函数中如果发现属性值在 san-native 中不支持,则需要通过 stylelint.utils.report 将错误信息,发生错误的节点,以及属性值偏移量,当前规则名称传递给 stylelint,这样 stylelint 才能够定位到不规范代码的位置。同时借助编辑器插件将不符合代码规范的代码高亮出来,以 vscode 为例,进行如下的高亮提示:
06 总结
至此,我们介绍了如何实现 eslint 以及 stylelint 的插件来检测 san-native 项目中不符合规定的代码,并从底层原理的角度上介绍了插件里各个字段以及方法在检测过程中的作用,希望能对大家有所帮助。
文章看完,还不过瘾?
更多精彩内容欢迎关注百度开发者中心公众号
总结
以上是ag凯发k8国际为你收集整理的百度工程师手把手教你实现代码规范检测工具的全部内容,希望文章能够帮你解决所遇到的问题。
- 上一篇: 百度一款前端图片合成工具库mi开源啦!
- 下一篇: 工程师必知的代码重构指南