来写一个 wxml formatter

·  阅读 282
来写一个 wxml formatter

做过小程序(不限于微信小程序)开发的应该都知道,每个公司的小程序都会采用一套自己的 xxml 语法,例如微信小程序的 wxml、头条小程序的 ttml 等。这些小程序 DSL 基本上都有着同样的模板语法("继承自" vue 的),所以用同一套 XML 的解析规则就可成功解析,在此基础上便可进行格式化

本次开发的 formatter 基于开源库 htmlparser2 并在此基础上进行修改。

本文使用 XXML 来统称 wxml, ttml, swan, axml 等名字差几个字符但含义相同的类 XML 小程序 DSL。

htmlparser2

该库是一个非常轻量且简单的 html 解析器,同时它做的工作也十分简单:遍历传入的字符串,当遇到 opentag 时,调用我们传入的 onopentag 钩子函数;当遇到 closetag 时,调用我们传入的 onclosetag 钩子函数;当遇到普通的文本时,调用我们传入的 ontext 钩子函数...

下面正式开始:

实现基本的 formatter

  • 确定数据结构

XML 节点数据结构没什么好说的,标签名+属性+子节点 三件套完事

type Attrs = {
  [attrName: string]: any;
};

type Comment = {
  type: string;
  value: string;
};

type Node = {
  name: string;
  attrs: Attrs;
  children: Array<Node | string | Comment>;
  indent: number;
};
复制代码

由于我们需要做格式化,所以给 Node 额外加了一个 indent 属性来保存该节点的缩进信息

  • 初始化 parser
import { Parser } from 'htmlparser2';

this.parser = new Parser(
  {
    onopentag: this.onopentag,
    onclosetag: this.onclosetag,
    ontext: this.ontext,
    oncomment: this.oncomment,
  }, { xmlMode: true }
);
复制代码

xmlMode: true - 开启后不会对 html 的特殊标签做特殊处理

由于 parser 本身做的事情非常简单且无状态,不会记录各个标签之间的关系,所以我们需要一个 stack 来记录这些关系

  • 初始化 stack
type Stack = Node[] & {
  getLast: () => Node | void;
};

private initStack() {
  this.stack = new Array<Node>() as Stack;
  this.stack.getLast = () => {
    return this.stack[this.stack.length - 1];
  };
}
复制代码

为了方便操作,给 stack 在数组的基础上增加了一个 getLast 方法,返回最后一个元素。

首先我们先考虑如何得到一个 JSON 对象,该对象可以描述传入的 XXML 代码字符串。

例如下面 XXML 代码:

<view class="container">
  <text style="color:skyblue;">Hello World</text>
</view>
复制代码

其映射为一个 JSON:

[
  {
    "name": "view",
    "attrs": {
      "class": "container"
    },
    "children": [
      {
        "name": "text",
        "attrs": {
          "style": "color:skyblue;"
        },
        "children": ["Hello World"]
      }
    ]
  }
]
复制代码

我们在主干代码里只实现这个 JSON 的生成过程,而将字符串级的格式化相关逻辑放在 handle 开头的方法中,使得代码解耦,逻辑清晰

  • onopentag 钩子
  private onopentag = (name: string, attrs: Attrs) => {
    const stack = this.stack;
    const { tabSize } = this.opt;

    const indent = stack.length * tabSize;
    const newNode: Node = { name, attrs, children: [], indent };

    this.handleOpentag(newNode);

    stack.push(newNode);
  };
复制代码

opentag 触发时,说明 parser 遇到了一个新标签,于是将该标签生成一个 node,进栈

  • onclosetag 钩子
  private onclosetag = (name: string) => {
    const { result } = this;
    const stack = this.stack;
    const node = stack.pop();

    if (!node) {
      throw `Parse error: no open tag for close tag ${name}`;
    } else {
      if (node.name !== name) {
        throw `Parse error: close tag does not match open tag: ${name}`;
      } else {
        const lastNode = stack.getLast();

        if (lastNode) {
          // 找到了 node 的 父 node
          lastNode.children.push(node);
        } else {
          // node 为顶层 node
          result.push(node);
        }

        this.handleClosetag(node);
      }
    }
  };
复制代码

closetag 触发时,说明 parser 遇到了一个标签闭合。此时先出栈一个 node,为当前 node。然后取出 stack 中的最后一个元素 lastNode。如果 lastNode 存在,那么 lastNode 为当前 node 的父元素,将当前 node 插入到 lastNodechildren 中。如果 lastNode 不存在,说明当前 node 为顶层元素,直接插入到 result 中。

  • ontext 钩子
  const IGNORE_TAGS = ['text', 'inline-text'];

  private ontext = (text: string) => {
    const { stack } = this;
    const lastNode = stack.getLast();

    if (lastNode) {
      // 文本的父 node
      if (IGNORE_TAGS.includes(lastNode.name)) {
        lastNode.children.push(text); // 忽略标签内的文本不做 trim 处理

        this.handleText(text);
      } else {
        const trimedText = text.trim();
        if (trimedText.length > 0) {
          lastNode.children.push(trimedText);
          this.handleText(trimedText);
        }
      }
    }
  };
复制代码

formatter 最复杂的地方在于,text 标签内的文本解析规则不确定,所以要保证源代码中的 text 标签内部长什么样,格式化之后也要长什么样。

当遇到文本 text 时,取 stack 最后一个 node。如果该 node 存在,那么该 nodetext 的父节点。如果 node 为需要忽略处理内部元素的节点,那么直接将 text 插入 node.children。否则,需要将 text trim 处理,来过滤掉源代码中的换行、空格等符号,将 trim 之后的 text 插入到 node.children

  • oncomment 钩子
  private oncomment = (comment: string) => {
    const { stack } = this;
    const lastNode = stack.getLast();

    if (lastNode) {
      // 注释的父 node
      lastNode.children.push({
        type: 'comment',
        value: comment,
      });
    }

    this.handleComment(lastNode, comment);
  };
复制代码

注释的处理非常简单,直接判断是否有父元素,如果有则插入父元素的 children 即可。

  • handleOpentag 方法
  private handleOpentag(newNode: Node) {
    const {
      stack,
      opt: { tabSize, maxLength },
    } = this;

    let opentagStr = '';

    const attrsTextWithoutBreak = generateAttrsText(newNode, false, tabSize);
    const { name, attrs, indent } = newNode;
    const lastNode = stack.getLast();
    if (lastNode && IGNORE_TAGS.includes(lastNode.name)) {
      opentagStr += `<${name}${attrsTextWithoutBreak}>`;
    } else {
      opentagStr = generateBlankSpace(indent);

      const opentagLength = getOpentagLength(
        name,
        attrsTextWithoutBreak,
        indent
      );
      if (opentagLength > maxLength) {
        // 超过最大长度限制,那么每个 attr 都要换行
        if (Object.keys(attrs).length === 0) {
          opentagStr += `<${name}>`;
        } else {
          opentagStr += `<${name}`;
          opentagStr += generateAttrsText(newNode, true, tabSize);
          opentagStr += `${BR}${generateBlankSpace(indent)}>`;
        }
      } else {
        opentagStr += `<${name}${attrsTextWithoutBreak}>`;
      }
      // 非忽略元素要换行
      if (this.resultStr.length > 0) {
        opentagStr = `${BR}${opentagStr}`;
      }
    }

    this.resultStr += opentagStr;
  }
复制代码

opentag< + 属性字符串 + > 组成。使用 prettier 的思想,当写成一行时长度(包括缩进) > maxLength 时,就将每个属性字符串换行写。 获得 lastNode,即 newNode 的父节点。如果 lastNode 为忽略元素,说明不需要对 newNode 做任何特殊处理,直接按基本规则拼接为一行即可。否则,先按照 indent 生成对应数量的空白字符,作为缩进。然后判断将其写成一行的长度是否 > maxLength。如果大于,那么就将每个每个属性换行写(generateAttrsText 的第二个参数为属性是否换行)。如果不大于,就直接一行写完。对于非忽略元素,自身需要换行,所以在前面加一个换行符(需要判断一下是否为第一行,如果 resultStr 为空,说明当前是在第一行,就不加换行符了)

  • handleClosetag 方法
  private handleClosetag(node: Node) {
    const { name, indent } = node;
    let closetagStr = '';

    if (IGNORE_TAGS.includes(name)) {
      // 忽略元素直接原地结束
      closetagStr = `</${name}>`;
    } else {
      if (node.children.length === 0) {
        // 无子元素,不换行
        closetagStr = `</${name}>`;
      } else {
        // 有子元素,要换行
        closetagStr = `${BR}${generateBlankSpace(indent)}</${name}>`;
      }
    }

    this.resultStr += closetagStr;
  }
复制代码

如果 node 为忽略元素,那么直接结束;否则判断其是否有子元素,有子元素自身才换行。

  • handleText 方法
  private handleText(text: string) {
    // text 统一不换行
    this.resultStr += text;
  }
复制代码

因为 text 只会存在于忽略标签内(写在其他地方无法渲染),所以不需要做任何处理,直接添加到 resultStr 上。

  • handleComment 方法
  private handleComment(parentNode: Node | void, comment: string) {
    if (parentNode && IGNORE_TAGS.includes(parentNode.name)) {
      this.resultStr += `<!--${comment}-->`;
    } else {
      // comment 独占一行
      const indent = this.stack.length * this.opt.tabSize;
      let shouldBreak = this.resultStr.length > 0;
      if (shouldBreak) {
        this.resultStr += `${BR}${generateBlankSpace(indent)}<!--${comment}-->`;
      } else {
        this.resultStr += `${generateBlankSpace(indent)}<!--${comment}-->`;
      }
    }
  }
复制代码

如果父节点为忽略元素,那么直接拼到 resultStr上;否则独占一行,需要注意点和 opentag 一样,如果本身就在第一行就不需要换行。

处理 mustache 模板

到此为止,这个 formatter 已经可以应付大部分 case 了。但是由于 xxml 有解析模板的能力,当考虑 mustache {{}} 时就会有 bad case:

<text>{{ a<1 ? 1 : 0 }}</text>
复制代码

htmlparser2 解析到 a<1 中的 < 时,会以为发现了一个 opentag, 标签名为 1。此时自然不可能有一个标签名为 1 的 opentag,所以会出现 bug。同样,当 {{}} 里出现 < 时,也可能有 bug。

  • 修改 htmlparser2

htmlparser2 维护了自身的一个状态。当解析完一个 closetag 时,它会处于 Text 状态,也就是说此时遇到的字符串都会当做 text 来处理。Text 状态的 parser 遇到 < 时,就会进入 BeforeTagName 状态,等待接收一个 tagName 作为 openTag。所以我们要做的就是当 parser 过完 {{ 时,状态不为 Text,而是另一个自定义的状态 InExpr。而在 InExpr 下的 parser 遇到 < > 不会做任何处理。直到遇到 }} 时,将状态还原。

  _stateText(c: string) {
    if (c === '<') {
      if (this._index > this._sectionStart) {
        this._cbs.ontext(this._getSection());
      }
      this._state = State.BeforeTagName;
      this._sectionStart = this._index;
    } else if (
      this._decodeEntities &&
      this._special === Special.None &&
      c === '&'
    ) {
      if (this._index > this._sectionStart) {
        this._cbs.ontext(this._getSection());
      }
      this._baseState = State.Text;
      this._state = State.BeforeEntity;
      this._sectionStart = this._index;
    } else if (c === '{' && this._buffer.charAt(this._index + 1) === '{') {
      this._stateBeforeEnterExpr = this._state;
      this._state = State.InExpr;
    }
  } 

  _stateInExpr(c: string) {
    if (c === '}' && this._buffer.charAt(this._index + 1) === '}') {
      this._state = this._stateBeforeEnterExpr;
    }
  }
复制代码

最后一步可选的改造

到此为止,这个 formatter 已经能应付几乎所有的场景了。不过有经验的开发者看完上面对于 {{}} 的处理,就能马上意识到一个问题:如果源代码长这样

<text>{{'}}<a>'}}</text> 
复制代码

预期是在页面上渲染 } } < a > 这5个字符,但是由于 parser 经过了 }} 后,状态回到了 Text,然后将<a>当成了一个 opentag ,再次引发 bug。

在这种情况下,最佳实践自然是将 }}\<a> 这个字符串用一个变量保存起来,在{{}}里放这个变量。但是 formatter 也可以强行兼容一下这种场景。

{{}}内是一个合法的 JavaScript 表达式,所以我们保存 {{}} 中间的字符串,使用一个例如 acorn 的 JavaScript parser 去解析这个字符串,解析成功则说明这是一个完整的模板表达式,才将状态还原为 Text

_stateBeforeEnterExpr: State = State.Text; 
_exprStartIndex: number = 0; 
_exprEndIndex: number = 0;

_getExpr() {
  return this._buffer.substring(this._exprStartIndex, this._exprEndIndex); 
}

static checkExpr(expr: string) { 
  try { 
    parse(expr); 
    return true; 
  } catch (err) { 
    return false; 
  } 
}

_stateInExpr(c: string) {
  if (c === '}' && this._buffer.charAt(this._index + 1) === '}') {
    this._exprEndIndex = this._index;
    const expr = this._getExpr();
    if (Tokenizer.checkExpr(expr)) { 
      this._state = this._stateBeforeEnterExpr; 
    } 
  } 
}
复制代码

由于 JavaScript parser 的性能消耗,这种做法并不推荐。所以这种情况还是应该使用最佳实践,将字符串用变量保存起来然后渲染。

总结

实现一个 xxml formatter,基本思路:

  1. 解析 xxml,保存每个节点的节点名属性缩进,以及节点之间的父子元素关系
  2. 对于 opentag ,拼接<节点名属性>,根据情况判断属性的拼接方式,即是否需要换行
  3. 对于 closetag,拼接 <节点名/>,根据情况判断是否要换行
  4. 对于 text,不需要做处理,直接拼接
  5. 对于 comment,根据情况判断是否要换行

需要注意的点:

  1. textinline-text 等特殊标签内部元素不需要任何处理
  2. {{}} 内部的表达式可能存在 < > 等特殊字符,需要特殊处理
  3. {{}} 本身也可能出现在 {{}} 内部的表达式中,这时应该使用字符串变量的方法,优于改造 formatter 兼容
分类:
前端
收藏成功!
已添加到「」, 点击更改