美文网首页
30.代码规范工具及背后技术设计(下)

30.代码规范工具及背后技术设计(下)

作者: ikonan | 来源:发表于2021-06-19 09:24 被阅读0次

上一节课,我们主要介绍了代码规范工具,了解了它们的配置、使用方式。这一节,我们将深入原理,并根据其实现和扩展能力,开发更加灵活的工具集。

在此之前,我们先回顾一下「代码规范」主题的知识点:


工具背后的技术原理和设计

这一小节,我们挑选实现更为复杂精妙的 ESLint 来分析。大家都清楚 ESLint 是基于静态语法分析(AST)进行工作的,AST 已经不是一个新鲜话题,我们在 webpack 章节就有介绍。ESLint 使用 Espree 来解析 JavaScript 语句,生成 AST。有了完整的解析树,我们就可以基于解析树对代码进行检测和修改。

ESLint 的灵魂是每一条 rule,每条规则都是独立且插件化的,我们挑一个比较简单的「禁止块级注释规则」源码来分析:

module.exports = {
 meta: {
   docs: {
     description: '禁止块级注释',
     category: 'Stylistic Issues',
     recommended: true    
   }
 },
 create (context) {
   const sourceCode = context.getSourceCode()
   return {
     Program () {
       const comments = sourceCode.getAllComments()
       const blockComments = comments.filter(({ type }) => type === 'Block')
       blockComments.length && context.report({
         message: 'No block comments'
       })
     }
   }
 }
}

从中我们看出,一条规则就是一个 node 模块,它由 meta 和 create 组成。meta 包含了该条规则的文档描述,相对简单。而 create 接受一个 context 参数,返回一个对象:

{
   meta: {
       docs: {
           description: '禁止块级注释',
           category: 'Stylistic Issues',
           recommended: true
       }
   },
   create (context) {
       // ...
       return {

       }
   }
}

从 context 对象上我们可以取得当前执行扫描到的代码,并通过选择器获取当前需要的内容。如上代码,我们获取代码的所有 comments(sourceCode.getAllComments()),如果 blockComments 长度大于 0,则 report No block comments 信息。

我们再来看一个 no-console rule 的实现:

"use strict";

module.exports = {
   meta: {
       type: "suggestion",

       docs: {
           description: "disallow the use of `console`",
           category: "Possible Errors",
           recommended: false,
           url: "https://eslint.org/docs/rules/no-console"
       },

       schema: [
           {
               type: "object",
               properties: {
                   allow: {
                       type: "array",
                       items: {
                           type: "string"
                       },
                       minItems: 1,
                       uniqueItems: true
                   }
               },
               additionalProperties: false
           }
       ],

       messages: {
           unexpected: "Unexpected console statement."
       }
   },

   create(context) {
       const options = context.options[0] || {};
       const allowed = options.allow || [];

       /**
        * Checks whether the given reference is 'console' or not.
        *
        * @param {eslint-scope.Reference} reference - The reference to check.
        * @returns {boolean} `true` if the reference is 'console'.
        */
       function isConsole(reference) {
           const id = reference.identifier;

           return id && id.name === "console";
       }

       /**
        * Checks whether the property name of the given MemberExpression node
        * is allowed by options or not.
        *
        * @param {ASTNode} node - The MemberExpression node to check.
        * @returns {boolean} `true` if the property name of the node is allowed.
        */
       function isAllowed(node) {
           const propertyName = astUtils.getStaticPropertyName(node);

           return propertyName && allowed.indexOf(propertyName) !== -1;
       }

       /**
        * Checks whether the given reference is a member access which is not
        * allowed by options or not.
        *
        * @param {eslint-scope.Reference} reference - The reference to check.
        * @returns {boolean} `true` if the reference is a member access which
        *      is not allowed by options.
        */
       function isMemberAccessExceptAllowed(reference) {
           const node = reference.identifier;
           const parent = node.parent;

           return (
               parent.type === "MemberExpression" &&
               parent.object === node &&
               !isAllowed(parent)
           );
       }

       /**
        * Reports the given reference as a violation.
        *
        * @param {eslint-scope.Reference} reference - The reference to report.
        * @returns {void}
        */
       function report(reference) {
           const node = reference.identifier.parent;

           context.report({
               node,
               loc: node.loc,
               messageId: "unexpected"
           });
       }

       return {
           "Program:exit"() {
               const scope = context.getScope();
               const consoleVar = astUtils.getVariableByName(scope, "console");
               const shadowed = consoleVar && consoleVar.defs.length > 0;

               /*
                * 'scope.through' includes all references to undefined
                * variables. If the variable 'console' is not defined, it uses
                * 'scope.through'.
                */
               const references = consoleVar
                   ? consoleVar.references
                   : scope.through.filter(isConsole);

               if (!shadowed) {
                   references
                       .filter(isMemberAccessExceptAllowed)
                       .forEach(report);
               }
           }
       };
   }
};

代码中通过 astUtils.getVariableByName(scope, "console") 以及 isConsole 函数来判别 console 语句的出现,通过 allowed.indexOf(propertyName) !== -1 来过滤白名单。

实现非常简单,了解了这些,相信你也能写出 no-alert,no-debugger 的规则内容。

我们再来看一下 no-duplicate-case 规则,它监测 switch...case 中是否存在相同的 case 分支:

module.exports = {
   meta: {
       type: "problem",

       docs: {
           description: "disallow duplicate case labels",
           category: "Possible Errors",
           recommended: true,
           url: "https://eslint.org/docs/rules/no-duplicate-case"
       },

       schema: [],

       messages: {
           unexpected: "Duplicate case label."
       }
   },

   create(context) {
       const sourceCode = context.getSourceCode();

       return {
           SwitchStatement(node) {
               const mapping = {};

               node.cases.forEach(switchCase => {
                   const key = sourceCode.getText(switchCase.test);

                   if (mapping[key]) {
                       context.report({ node: switchCase, messageId: "unexpected" });
                   } else {
                       mapping[key] = switchCase;
                   }
               });
           }
       };
   }
};

代码非常简单,只是初始化时使用一个空的 mapping,每次添加 case 是进行对 mapping 的扩充,如果存在相同的 case 则 report。

虽然 ESLint 背后的技术内容比较复杂,但是基于 AST 技术,它已经给开发者提供了较为成熟的 APIs。写一条自己的规则并不是很难,只需要开发者找到相关的 AST 选择器,比如上面代码中的 getAllComments(),更多的选择器可以参考:Selectors - ESLint - Pluggable JavaScript linter。熟练掌握选择器,将是我们开发插件扩展的关键。

当然,更复杂的场景远不止这么简单,比如,多条规则是如何串联起来生效的?

多条规则串联生效

事实上, 规则可以从多个源来定义,比如代码的注释当中,或者配置文件当中。

ESLint 首先收集到所有规则配置源,将所有规则归并之后,进行多重遍历:遍历由源码生成的 AST,将语法节点传入队列当中;之后遍历所有应用规则,采用事件发布订阅模式(类似 webpack tapable),为所有规则的选择器添加监听事件;在触发事件时执行,如果发现有问题,会将 report message 记录下来。最终记录下来的问题信息将会被输出。

具体 ESLint 的源码如下:

function runRules(sourceCode, configuredRules, ruleMapper, parserOptions, parserName, settings, filename) {
   const emitter = createEmitter();
   const nodeQueue = [];
   let currentNode = sourceCode.ast;

   Traverser.traverse(sourceCode.ast, {
       enter(node, parent) {
           node.parent = parent;
           nodeQueue.push({ isEntering: true, node });
       },
       leave(node) {
           nodeQueue.push({ isEntering: false, node });
       },
       visitorKeys: sourceCode.visitorKeys
   });


   const lintingProblems = [];

   Object.keys(configuredRules).forEach(ruleId => {
       const severity = ConfigOps.getRuleSeverity(configuredRules[ruleId]);

       if (severity === 0) {
           return;
       }

       const rule = ruleMapper(ruleId);
       const messageIds = rule.meta && rule.meta.messages;
       let reportTranslator = null;
       const ruleContext = Object.freeze(
           Object.assign(
               Object.create(sharedTraversalContext),
               {
                   id: ruleId,
                   options: getRuleOptions(configuredRules[ruleId]),
                   report(...args) {

                       if (reportTranslator === null) {...}
                       const problem = reportTranslator(...args);
                       if (problem.fix && rule.meta && !rule.meta.fixable) {
                           throw new Error("Fixable rules should export a `meta.fixable` property.");
                       }
                       lintingProblems.push(problem);
                   }
               }
           )
       );

       const ruleListeners = createRuleListeners(rule, ruleContext);

       // add all the selectors from the rule as listeners
       Object.keys(ruleListeners).forEach(selector => {
           emitter.on();
       });
   });

   const eventGenerator = new CodePathAnalyzer(new NodeEventGenerator(emitter));

   nodeQueue.forEach(traversalInfo => {
       currentNode = traversalInfo.node;
       if (traversalInfo.isEntering) {
           eventGenerator.enterNode(currentNode);
       } else {
           eventGenerator.leaveNode(currentNode);
       }
   });

   return lintingProblems;
}

请再思考,我们的程序中免不了有各种条件语句、循环语句,因此 代码的执行是非顺序的。相关规则比如:「检测定义但未使用变量」,「switch-case 中避免执行多条 case 语句」,这些规则的实现,就涉及 ESLint 更高级的 code path analysis 概念等。ESLint 将 code path 抽象为 5 个事件。

  • onCodePathStart
  • onCodePathEnd
  • onCodePathSegmentStart
  • onCodePathSegmentEnd
  • onCodePathSegmentLoop

利用这 5 个事件,我们可以更加精确地控制检测范围和粒度。更多的 ESLint rule 实现,可以翻看源码进行学习,总之根据这 5 种事件,我们可以监测非顺序性代码,其核心原理还是事件机制。

我们通过 no-unreachable 规则来进行了解,该规则可以通过监测 return,throws,break,continue 的使用,识别出不会被执行的代码,并 report:

/**
* Checks whether or not a given variable declarator has the initializer.
* @param {ASTNode} node - A VariableDeclarator node to check.
* @returns {boolean} `true` if the node has the initializer.
*/
function isInitialized(node) {
   return Boolean(node.init);
}

/**
* Checks whether or not a given code path segment is unreachable.
* @param {CodePathSegment} segment - A CodePathSegment to check.
* @returns {boolean} `true` if the segment is unreachable.
*/
function isUnreachable(segment) {
   return !segment.reachable;
}

/**
* The class to distinguish consecutive unreachable statements.
*/
class ConsecutiveRange {
   constructor(sourceCode) {
       this.sourceCode = sourceCode;
       this.startNode = null;
       this.endNode = null;
   }

   /**
    * The location object of this range.
    * @type {Object}
    */
   get location() {
       return {
           start: this.startNode.loc.start,
           end: this.endNode.loc.end
       };
   }

   /**
    * `true` if this range is empty.
    * @type {boolean}
    */
   get isEmpty() {
       return !(this.startNode && this.endNode);
   }

   /**
    * Checks whether the given node is inside of this range.
    * @param {ASTNode|Token} node - The node to check.
    * @returns {boolean} `true` if the node is inside of this range.
    */
   contains(node) {
       return (
           node.range[0] >= this.startNode.range[0] &&
           node.range[1] <= this.endNode.range[1]
       );
   }

   /**
    * Checks whether the given node is consecutive to this range.
    * @param {ASTNode} node - The node to check.
    * @returns {boolean} `true` if the node is consecutive to this range.
    */
   isConsecutive(node) {
       return this.contains(this.sourceCode.getTokenBefore(node));
   }

   /**
    * Merges the given node to this range.
    * @param {ASTNode} node - The node to merge.
    * @returns {void}
    */
   merge(node) {
       this.endNode = node;
   }

   /**
    * Resets this range by the given node or null.
    * @param {ASTNode|null} node - The node to reset, or null.
    * @returns {void}
    */
   reset(node) {
       this.startNode = this.endNode = node;
   }
}

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------

module.exports = {
   meta: {
       type: "problem",

       docs: {
           description: "disallow unreachable code after `return`, `throw`, `continue`, and `break` statements",
           category: "Possible Errors",
           recommended: true,
           url: "https://eslint.org/docs/rules/no-unreachable"
       },

       schema: []
   },

   create(context) {
       let currentCodePath = null;

       const range = new ConsecutiveRange(context.getSourceCode());

       /**
        * Reports a given node if it's unreachable.
        * @param {ASTNode} node - A statement node to report.
        * @returns {void}
        */
       function reportIfUnreachable(node) {
           let nextNode = null;

           if (node && currentCodePath.currentSegments.every(isUnreachable)) {

               // Store this statement to distinguish consecutive statements.
               if (range.isEmpty) {
                   range.reset(node);
                   return;
               }

               // Skip if this statement is inside of the current range.
               if (range.contains(node)) {
                   return;
               }

               // Merge if this statement is consecutive to the current range.
               if (range.isConsecutive(node)) {
                   range.merge(node);
                   return;
               }

               nextNode = node;
           }

           /*
            * Report the current range since this statement is reachable or is
            * not consecutive to the current range.
            */
           if (!range.isEmpty) {
               context.report({
                   message: "Unreachable code.",
                   loc: range.location,
                   node: range.startNode
               });
           }

           // Update the current range.
           range.reset(nextNode);
       }

       return {

           // Manages the current code path.
           onCodePathStart(codePath) {
               currentCodePath = codePath;
           },

           onCodePathEnd() {
               currentCodePath = currentCodePath.upper;
           },

           // Registers for all statement nodes (excludes FunctionDeclaration).
           BlockStatement: reportIfUnreachable,
           BreakStatement: reportIfUnreachable,
           ClassDeclaration: reportIfUnreachable,
           ContinueStatement: reportIfUnreachable,
           DebuggerStatement: reportIfUnreachable,
           DoWhileStatement: reportIfUnreachable,
           ExpressionStatement: reportIfUnreachable,
           ForInStatement: reportIfUnreachable,
           ForOfStatement: reportIfUnreachable,
           ForStatement: reportIfUnreachable,
           IfStatement: reportIfUnreachable,
           ImportDeclaration: reportIfUnreachable,
           LabeledStatement: reportIfUnreachable,
           ReturnStatement: reportIfUnreachable,
           SwitchStatement: reportIfUnreachable,
           ThrowStatement: reportIfUnreachable,
           TryStatement: reportIfUnreachable,

           VariableDeclaration(node) {
               if (node.kind !== "var" || node.declarations.some(isInitialized)) {
                   reportIfUnreachable(node);
               }
           },

           WhileStatement: reportIfUnreachable,
           WithStatement: reportIfUnreachable,
           ExportNamedDeclaration: reportIfUnreachable,
           ExportDefaultDeclaration: reportIfUnreachable,
           ExportAllDeclaration: reportIfUnreachable,

           "Program:exit"() {
               reportIfUnreachable();
           }
       };
   }
};

实现中,通过 isUnreachable 函数来判别一个 code path 是否无法触及,我提供一些返例帮助大家理解:

function foo() {
   return true;
   console.log("done");
}

function bar() {
   throw new Error("Oops!");
   console.log("done");
}

while(value) {
   break;
   console.log("done");
}

throw new Error("Oops!");
console.log("done");

function baz() {
   if (Math.random() < 0.5) {
       return;
   } else {
       throw new Error();
   }
   console.log("done");
}

因为 unreachable 的代码需要放在一个区块当中去理解,单条语句无法去进行判别,因此使用 ConsecutiveRange 类来保留连续代码信息。

最后,这种优秀的插件扩展机制对于设计一个库,尤其是设计一个规范工具来说,是非常值得借鉴的模式。事实上,prettier 也会在新的版本中引入插件机制,目前已经在 beta 版,感兴趣的读者可以尝鲜

自动化规范与团队建设

自动化规范还有其他一些细节,比如使用 EditorConfig 来保证编辑器的设置统一,确定在制表符空格或换行方面的一致性,又如使用 commitlint 并配合 husky,来保证 commit message 的规范:

安装 commitlint cli 和 conventional config

npm install --save-dev @commitlint/{config-conventional,cli}

配置 commitlint

echo "module.exports = {extends: ['@commitlint/config-conventional']}" > commitlint.config.js

并在 commit-msg 的 git hook 阶段进行检查,在 package.json 中添加:

{
   "husky": {
       "hooks": {
           "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
       }  
   }
}

我们也可以根据团队需求做更多定制化的尝试,比如自动规范化或生产 commit message,有了规范的 commit message 之后,就可以提取关键内容,规范化生产 changelog 等。

其他方向上,还可以从团队文档的生产来考虑。举个例子,如果使用 React 开发项目,那么 React 组件文档如何规范化生成?如何提高组件使用的效率,减少学习成本?我在掘金 AMA 上做客时,有人便提出了这样的问题。

我们组内面临着最古老的 React 管理平台重构任务,这次我们想生成关于管理平台的阅读文档(包括常用的样式命名、工具方法、全局组件、复杂 API 交互流程等)。

所以我想提出的问题是:面向 React 代码的可维护性和可持续发展(不要单个功能每个团队成员都实现一遍,当新成员加入的时候知道有哪些功能能从现在代码中复用, 也知道有哪些功能还没有,他可以添加实现进去),业内有哪些工具或 npm 库或开发模式是可以确切能够帮助解决痛点或者改善现状的呢?

确实,随着项目复杂度的提升,各种组件也「爆炸式」增长。如何让这些组件方便易用,能快速上手,同时不成为负担,又避免重复造轮子现象,良好的组件管理在团队中非常重要。

关于「React 组件管理文档」,简单梳理一下:总得来说,社区在这方面的探索很多,相关方案也各有特色。

确实,随着项目复杂度的提升,各种组件也「爆炸式」增长。如何让这些组件方便易用,能快速上手,同时不成为负担,又避免重复造轮子现象,良好的组件管理在团队中非常重要。

  • 最知名的一定是 storybook,它会生成一个静态页面,专门用来展示组件的实际效果以及用法;缺点是业务侵入性较强,且 story 编写成本较高。
  • 我个人很喜欢的是 react-docgen,比较极客风格,它能够分析并提取 React 组件信息。原理是使用了 recast 和 @babel/parser AST 分析,最终产出一个 JSON 文档。 https://github.com/reactjs/react-docgen 是它的网页链接,缺点是它较为轻量,缺乏有效的可视化能力。
  • 那么在 react-docgen 之上,我们可以考虑 React Styleguidist,这款 React 组件文档生成器,支持丰富的 demo,可能会更符合需求。
  • 一些小而美的解决方案:比如 react-doc、react-doc-generator、cherrypdoc,都可以考虑尝试。

「自己动手、丰衣足食」,其实开发一个类似的工具并不会太复杂。如果有时间和精力,你可以根据自己的需求,实现一个完全匹配自己团队的 React 组件管理文档,或者其他框架相关、业务相关的文档,这非常有意义。

总结

在规范化的道路上,只有你想不到,没有你做不到。

简单的规范化工具用起来非常清爽,但是背后的实现却蕴含了很深的设计与技术细节,值得我们深入学习。

作为前端工程师,我们应该从平时开发的痛点和效率瓶颈入手,敢于尝试,不断探索。保证团队开发的自动化程度,就能减少不必要的麻烦。

除了「偏硬」的强制规范手段,一些「软方向」,比如团队氛围、code review/analyse 等,也直接决定着团队的代码质量。进阶的工程师不仅需要在技术上成长,在团队建设上更需要主动交流。

相关文章

网友评论

      本文标题:30.代码规范工具及背后技术设计(下)

      本文链接:https://www.haomeiwen.com/subject/ccpxyltx.html