【实战指南】如何写一款小程序 Prettier 插件

Prettier 是一款开箱即用的代码格式化工具,主要特点是配置简单、便于集成、支持扩展。Prettier 原本聚焦于 Web 开发领域,由于表现优秀,社区也利用其扩展机制支持了 Java、PostgreSQL 等语言的格式化。

无需赘言,开发团队借助 Prettier 让团队成员保持一致的代码风格(不完全等同于代码规范)非常有必要,毕竟代码虽然是机器运行的,但主要是人在阅读;而且强迫人接受一种他可能不喜欢的风格,自然不如让工具自动统一来的容易。

其他内容大家可以查看官方文档了解更多,此处不过多介绍了。

认识插件

Prettier 主要聚焦于 Web 开发领域,因此 JavaScript、CSS 和 HTML 是默认支持的,甚至 JSX 和 Vue 也是内置支持的。

但是显然,假如你发明了一种全新的 DSL,Prettier 是不认的。那怎么办?写一款 插件

所以,插件就是让 Prettier 能够支持你自己的编程语言的一种方式。本质上,它就是一个 普通的 JavaScript Module,暴露以下 5 个模块:

诸位明鉴,下文代码是以 TypeScript 写就的。

// index.ts
import { SupportLanguage, Parser, Printer, SupportOption, ParserOptions } from 'prettier';
// 支持的语言列表
export const languages: Partial<SupportLanguage>[];
// 每个语言对应的 parser
export const parsers: Record<string, Parser>;
// 核心的格式化逻辑
export const printers: Record<string, Printer>;
// 可选,插件的自定义配置项,此处 PluginOptions 需自行定义
export const options: Record<keyof PluginOptions, SupportOption>;
// 可选,默认配置项
export const defaultOptions: Partial<ParserOptions>;
复制代码

写一个小程序 AXML 插件吧

阿里小程序(钉钉小程序、支付宝小程序等等)的上层 DSL 早已统一,但是一直都没有 AXML 自动格式化工具。

Prettier 对 JS/TS 是内置支持的,.acss 其实就是 CSS。

可能有人尝试过将 .axml 设置为 XML 文件类型来做格式化,但肯定效果不理想。因为无法格式化 AXML 文件中的 JS 表达式。

今天我们就写一个起码的 AXML 的 Prettier 插件吧。

languages

我们为小程序 AXML 这门语言命名为 axml,其 parser 列表是 ['axml'](a.1)。也就是说可以为它指定多个 parser,但通常一个就够了。我们就使用 axml 这个 parser(其定义见下文)。

parser 是将源代码解析为 AST 的工具。

// index.ts
import { SupportLanguage } from 'prettier';
export const languages: Partial<SupportLanguage>[] = [
  {
    name: 'axml',
    parsers: ['axml'], // (a.1)
    extensions: ['.axml'], // (a.2)
  },
];
复制代码

parsers

在 index.ts 中新增 export const parsers

// index.ts
import { SupportLanguage, Parser } from 'prettier';
import parse from './parse';
// prettier 指定 `node` 参数为 any,因为不同 parser 返回的 node 类型不尽相同
function locStart(node: any): number {
  return node.startIndex;
}
function locEnd(node: any): number {
  return node.endIndex;
}
export const languages: Partial<SupportLanguage>[] = [
  {
    name: 'axml',
    parsers: ['axml'],
    extensions: ['.axml'],
  },
];
export const parsers: Record<string, Parser> = {
  // 注意此处的 key 必须要与 languages 的 parsers 对应
  axml: {
    parse, // (b.1)
    locStart,
    locEnd,
    // 为 ast 格式命个名,后面会用到
    astFormat: 'axml-ast',
  },
};
复制代码

parse(b.1)是一个函数,在揭开它的面纱之前,我们先要确定解析 AXML 的 parser。

类 XML 的 DSL 市面上有很多 parser,我们就和小程序官方实现保持一致,使用 htmlparser2 来解析 AXML。所以 parse(b.1)的定义如下:

// parse.ts
import { parseDOM } from 'htmlparser2';
import { Node } from 'domhandler';
export default function parse(text: string): Node[] {
  const dom = parseDOM(text, {
    xmlMode: true,
    withStartIndices: true,
    withEndIndices: true,
  });
  return dom;
}
复制代码

htmlparser2 解析出来的 AST 相对简单,可以查看 这里 感受一下。

这里实际上还有一个棘手的问题,AXML 中的“无值属性”(如:<view someAttr />)其实是模仿了 JSX 的语义,即”布尔属性“(<view someAttr /> 等价于 <view someAttr={true} />(JSX 语法)),但在 XML 以及 htmlparser2 这个 parser 中,它被解析为 <view someAttr="" />。这个需要我们特殊处理。

接下来是核心逻辑了。

printers

// index.ts
import { SupportLanguage, Parser, Printer } from 'prettier';
import parse from './parse';
import print from './print';
import embed from './embed';
// ... 省略
export const printers: Record<string, Printer> = {
  // 对应 parsers 中的 astFormat
  'axml-ast': {
    print, // (c.1)
    embed, // (c.2)
  },
};
复制代码

print(c.1)函数负责目标语言源代码本身的格式化逻辑,embed(c.2)函数则用来处理目标语言当中内嵌的其他语言的格式化。

对于小程序 AXML 来说,htmlparser2 解析出来的 AST 只有以下 3 种类型(node.type):

  • tag - 标签,<view></view> 等等
  • text - 标签内的文本
  • comment - 注释,<!-- -->,和 HTML 注释格式一致

print

print(c.1)中:

// print.ts
import { FastPath, Doc, ParserOptions, doc } from 'prettier';
const { concat } = doc.builders;
export default function print(
  path: FastPath,
  _options: ParserOptions,
  _print: (path: FastPath) => Doc // (c.3)
): Doc {
  // 获取 AST 中的 node
  const node = path.getValue();
  if (!node) return '';
  
  // htmlparser2 的 AST 是一个数组,因此我们需要调用 _print,它会递归调用我们自己定义的 print
  if (Array.isArray(node)) {
    return concat(path.map(_print));
  }
  
  // 继续判断 node.type,返回不同内容,限于篇幅,省略
}
复制代码

每一个格式化的代码片段,Prettier 将之称为 Doc(c.3)。

需要注意的是,AXML 中有两个地方会存在 JS 表达式(expression),标签(tag)的属性(attribute)和文本(text),它们存在于 {{}} 当中。这些表达式也需要格式化!

要处理 {{}} 中的 JS 表达式,则需要通过 embed(c.2),在 embed 函数中可以调用其他 parser 来处理目标文本(用法见下文)。因为是 JS 表达式,我们调用 Prettier 内置的 babel parser 来处理 JS 表达式就行了。

这就要求我们先解析 {{}}{{}} 格式是非常流行的所谓 mustache 风格,出于教学目的,我们直接用 mustache.js 来解析。

实际上简单地用 mustache.js 会有问题,因为类似 {{!a && b}} 这样的片段在 mustache.js 是有语义的({{! 表示注释);但在 AXML 里,它仅表示 !a && b 表达式。这里我们就不展开了。 另,小程序框架是自行实现了一个 {{}} 的解析器。

embed

Prettier 在执行时,embed(c.2)会优先print(c.1)执行:如果 embed 返回了非 null 的值,则结束格式化;反之,继续执行 print 中的逻辑。

embed(c.2)中:

// embed.ts
import { FastPath, Doc, ParserOptions, Options, doc } from 'prettier';
import { DataNode, Element } from 'domhandler';
import { parse } from 'mustache';
const {
  group,  // (d.1) Prettier 最基本的方法,会根据 printWidth 等配置项自动换行(或不换行)
  concat, // 拼接 Doc 的方法,类似 Array.prototype.concat
  line,   // 一个换行,如果父级 group(d.1) 后不需换行,则将其转换为一个空格
  indent, // 一个缩进,如果父级 group(d.1) 后不需换行,则忽略
  softline, // 一个换行,如果父级 group(d.1) 后不需换行,则忽略
} = doc.builders;
export default function embed(
  path: FastPath,
  print: (path: FastPath) => Doc,
  textToDoc: (text: string, options: Options) => Doc, // (d.2)
  options: ParserOptions // (d.3)
): Doc | null {
  const node = path.getValue();
  // 返回 null,则交给 print(c.1) 继续执行
  if (!node || !node.type) return null;
  switch (node.type) {
    // 文本类型
    case 'text':
      const text = (node as DataNode).data;
      // 1. 调用 mustache.parse 解析文本
      // 2. 调用 textToDoc(d.2) 格式化 JS 表达式(如有)
      // 3. 拼接 `{{`、格式化好的表达式、`}}`(如有)
      // 4. 调用 group(d.1) 方法包裹前面拼接好的内容
    
    // 标签类型
    case 'tag':
      // 1. 如果有 children,递归调用
      // 2. 提取 attribute,调用 mustache.parse 解析文本
      // 3. 调用 textToDoc(d.2) 格式化 JS 表达式(如有)
      // 4. 拼接 `{{`、格式化好的表达式、`}}`(如有)
      // 5. 调用 group(d.1) 方法包裹前面拼接好的内容
    default:
      // 返回 null,则交给 print(c.1) 继续执行
      return null;
  }
}
复制代码

特别说明一下 textToDoc(d.2)方法,要解析 JS 表达式,按如下方式使用即可:

// embed.ts
// ...
const doc: Doc = textToDoc(expressionExtractedByMustache, {
  parser: 'babel',
  semi: false,
  singleQuote: true,
});
return indent(concat([softline, doc]));
复制代码

options(d.3)参数就是我们指定的一些配置项了,也包含自定义的配置项(见下文)。

此外,关于 groupindent 等方法,建议大家 查阅文档

当然还有一些需要特别注意的地方,比如 style 属性可以直接这样写 style="{{height: '100%', width: '100%'}}"(实际上所有的对象型属性都可以简化写成这样),大括号里提取出来的文本并不是合法的 JS 表达式,需要我们特殊处理。此种细节都要考虑到。

options

index.ts 中的 export const options 用于指定插件所支持的自定义配置项。

假如我们希望小程序 AXML 插件支持一个 axmlBracketSameLine 的配置项,其作用类似 jsxBracketSameLine

那么可以这样定义:

// index.ts
import { SupportLanguage, Parser, Printer, SupportOption, ParserOptions } from 'prettier';
// ... 省略
interface PluginOptions {
  axmlBracketSameLine: boolean;
}
// 插件自定义的配置项
export const options: Record<keyof PluginOptions, SupportOption> = {
  axmlBracketSameLine: {
    name: 'axmlBracketSameLine',
    category: 'Global',
    type: 'boolean',
    default: false,
    description: 'Put the `>` of a multiline AXML element on a new line',
  },
};
复制代码

这样,上文的 options(d.3)参数中就可以读到 options.axmlBracketSameLine,以此决定是否要将开标签的结束字符 > 放置在同一行。

defaultOptions

插件的默认配置项,会覆盖 Prettier 的同名默认配置项,可以指定内置配置项和插件自定义配置项。

例如:

// index.ts
import { SupportLanguage, Parser, Printer, SupportOption, ParserOptions } from 'prettier';
// ... 省略
export const defaultOptions: Partial<ParserOptions> = {
  tabWidth: 2, // 2 个空格缩进
  printWidth: 80, // 打印宽度 80
};
复制代码

到这里,我们的 AXML 插件就开发完成了。

使用插件

插件使用起来非常简单,只需将我们的插件发布到 npm(或 yarn,或私有化的 npm 服务如 tnpm),且其 package 名称以下述字符开头,Prettier 执行时就会自动加载插件、自动识别文件类型并调用对应插件

  • @prettier/plugin-
  • prettier-plugin-
  • @<scope>/prettier-plugin-

假设我们将小程序 AXML 插件发布到 npm 上,并命名为 prettier-plugin-axml,那么只需要在你的项目中安装:

npm i --save-dev prettier prettier-plugin-axml
复制代码

然后执行:

./node_modules/.bin/prettier --write "src/**/*.axml"
复制代码

就大功告成了。

因为我们已经在 extensions 中(a.2)指定了文件后缀为 .axml,所以 prettier 会自动为此类文件匹配我们的插件,因此不用显式指定 plugin

总结

概括来说,要开发一个 Prettier 插件,总共分三步:

  1. 用一个或多个 parser 把源代码解析为 AST;
  2. 调用 Prettier 的 API 按需加入换行、空格、缩进等;
  3. 没了。

是不是很简单呢?

参考链接


作者:钉钉前端团队

上一篇:VMware中出现物理内存不足,无法使用配置的设置开启虚拟机解决方案


下一篇:一篇文章教会你如何将DOM转换为virtual DOM