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:
-
第 2 行:在 san-native 中不支持行内样式 flex-basis
-
第 3 行:在 san-native 中不支持滚动事件 on-scroll
-
第 6 行:在 san-native 中文本节点 span 不允许嵌套 img
-
第 7 行:内置组件 lottie-view 必须要有 source 或者 src 属性
-
第 23 行:在 san-native 中不支持 display:inline
<template>
<div style="background-color:#fff; flex-basis:100px">
<div on-scroll="onScroll" class="{{$style['demolist-wrapper']}}">
内容
</div>
<span><img />san-native中span不允许嵌套img</span>
<lottie-view />
</div>
</template>
<script>
export default {
components: {},
onScroll() {
// do something
},
initData() {
return {};
}
}
</script>
<style lang="less" module>
.demolist-wrapper {
display: inline;
}
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 中,通过 type 标记规则的类型,docs 包含了规则的文档链接等信息,schema 则表示了配置规则应该遵守的约定,messages 包含了错误信息。
-
create 函数:
返回的对象中具有一个名为 ImportDeclaration 的方法,我们将该方法称之为 import 语法节点的访问器 (visitor),即在 ESlint 对整个 AST 遍历的过程中,访问到 import 语法节点的时候会调用所有名称为 ImportDeclaration 的 visitor。
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',
],
};
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',
}
},
// ...
}
};
-
rules:包含了该插件所有的规则的具体实现
-
processors:这里我们定义了专门用于处理.san 文件字符串源码以及检查信息的 processor
-
configs:包含了一些配置,可以看到与 @ecomfe/eslint-config 中的配置文件类似,具备 plugins 选项,以及对 rules 的配置。
需要说明的是:为了复用上面 configs.always
的配置,我们可以在项目的.eslintrc.*
文件中 extends
选项加上如下代码:
module.exports = {
extends: ['plugin:@baidu/san-native/always']
};
-
plugin 插件主要涉及自定义规则的具体实现,同时还能够提供配置
-
extend 配置主要涉及规则的具体配置
ESlint 的工作原理
接下来介绍 ESlint 是如何处理各种配置文件的,以及插件与配置文件中各字段在 ESlint 中的作用。ESlint 提供了命令行的方式来检测某个文件的代码,比如,我们想对.san 文件进行检查,那么可以通过下面的命令来实现:
eslint --ext .san src/app/component/animate/index.san
从上图当中我们可以看到,文件的字符串内容首先会被插件的 prepocess 处理,然后处理的结果被 parser 解析成对应的 AST,然后遍历 AST 的同时执行每个规则提供的方法,最后得到的检测结果会被 postprocess 处理。因此 ESlint 插件中的 processors 属性给开发者提供了操作字符串源码以及处理检测结果的能力。接下来分析 ESlint 配置中的一些字段在检测过程中的作用:
处理.eslintrc.*配置文件
-
extends:以【配置的复用方案】中的代码为例,其中 @ecomfe/eslint-config 以及 @ecomfe/eslint-config/san 会被合并,按照在数组中的顺序,相同的选项,后者的配置会覆盖前者。比如,前者的配置中 parser: @babel/eslint-parser 会被后者的 parser: 'san-eslint-parser' 替换,plugins,rules 都会被收集到一个对象当中,收集的规则为当前配置中的 rules 会被当前配置中 extends,plugins 的配置规则覆盖。
-
root:由于在读取配置文件的时候,会根据传入的 filePath 依次往上查找对应的文件,并将配置文件解析出来。当某个配置文件设置了 root 为 true 的时候,会停止继续向上搜索。
-
plugins:该字段决定了最终处理某类文件的 processor 是哪个插件提供的,举例来说,当 A 与 B 中都申明了对.san 文件处理的 processor,如果按照下面的配置,ESlint 最终会用 B 插件提供的 processor 处理.san 文件,如果将 plugins 字段中两个插件互换位置,则 ESlint 最终会用 A 插件提供的 processor。总的来说,ESlint 会从 plugins 字段申明的插件中从后向前找到第一个用于处理.san 文件的 processor。
module.exports = {
plugins: [
"A",
"B"
],
extends: [
"plugin:A/base",
"plugin:B/base"
]
};
将文件内容解析成 AST
-
parser 选项:当使用了自定义的 parser 之后,那么文件的内容将会被自定义 parser 解析成 AST,否则会使用默认的 parser,即 eslint 提供的 espree
-
parserOptions:该选项是当前 parser 的 option,每一个 rule 以及自定义 parser 都能够获取到该选项的值。
收集与执行 rule 生成的 visitor
当解析器将字符串解析成 AST 之后,在遍历 AST 的过程中会根据当前的节点类型执行对应的一些列提前注册好的 vistor。
-
调用每个 rule 的 create 函数收集 visitor:create 函数必须返回一个 visitor,即 key 为 AST 节点类型,value 为一个接收该节点的函数,ESlint 将 create 函数返回的 vistor 注册到内部维护的订阅发布器上。
-
遍历 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>
-
如何将.san 文件代码解析成 AST:显然无法直接使用 @babel/eslint-parser 或者 @typescript-eslint/parser 来进行解析,因此我们需要用自定义解析器来处理,这部分的工作 san-eslint-parser 已经帮我们处理了,后续在规则实现的时候进行分析。
-
如何处理 <style> 中的样式:由于 san-eslint-parser 不会处理.san 文件中 <style> 部分代码,因此我们需要单独处理,这里借助 postcss 对 <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])
};
}
};
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];
}
};
规则的实现
由于规则的实现依赖于自定义 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 中针对 templateBody 的 visitor:该方法接收的参数为 templateBodyVisitor,该对象存储了所有针对 templateBody 的 visitor,这些 visitor 会注册到自定义 parser 内部的发布订阅器上。
-
返回一个针对 script 对应的 AST 的 visitor:该 visitor 的 type 为 Program,根据上文中 ESlint 工作原理一节,我们可以知道,ESlint 在遍历 AST 的过程中,当遇到类型为 Program 的根节点时,会执行该 visitor,并且该 visitor 会调用自定义 parser 内部方法对 templateBody 存储的 AST 进行遍历。
因此,我们可以利用上述方法在每条 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": [] // 允许的子标签
}
在上文中已经介绍了如何在规则中通过编写相关的 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
});
}
}
});
}
};
在 vscode 中的检测效果
在每一条规则中,当发现不符合规则的代码时,我们可以通过 context.report 将对应的 ast 语法节点 / 位置信息以及错误信息提供给 ESlint
// 提供节点
context.report({
node: node,
messageId: "..."
});
// 提供位置信息:loc
context.report({
loc: node.loc,
message: "..."
});
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) => {};
};
"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;
构建的项目目录结构如下:
其中入口文件 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: {}
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语法节点上属性键值对解析成AST
const 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 项目中不符合规定的代码,并从底层原理的角度上介绍了插件里各个字段以及方法在检测过程中的作用,希望能对大家有所帮助。
文章看完,还不过瘾?
更多精彩内容欢迎关注百度开发者中心公众号