将HTML字符串编译为虚拟DOM对象的基础实现

2021-05-08

本文所有代码均保存在HouyunCheng / mini-2vdom

虚拟DOM只是实现MVVM的一种方案,或者说是视图更新的一种策略,是实现最小化更新的diff算法的操作对象。

创建扫描器

所有编译行为的第一步都是遍历整个字符串,于是我们创建Scanner类,专门用于扫描整个字符串。

class Scanner {
  constructor(text) {
    this.text  = text;
    // 指针
    this.pos = 0;
    // 尾巴  剩余字符
    this.tail = text;
  }

  /**
   * 路过指定内容
   *
   * @memberof Scanner
   */
  scan(tag) {
    if (this.tail.indexOf(tag) === 0) {
      // 直接跳过指定内容的长度
      this.pos += tag.length;
      // 更新tail
      this.tail = this.text.substring(this.pos);
    }
  }

  /**
   * 让指针进行扫描,直到遇见指定内容,返回路过的文字
   *
   * @memberof Scanner
   * @return str 收集到的字符串
   */
   scanUntil(stopTag) {
    // 记录开始扫描时的初始值
    const startPos = this.pos;
    // 当尾巴的开头不是stopTg的时候,说明还没有扫描到stopTag
    while (!this.eos() && this.tail.indexOf(stopTag) !== 0 ) {
      // 改变尾巴为当前指针这个字符到最后的所有字符
      this.tail = this.text.substring(++this.pos);
    }

    // 返回经过的文本数据
    return this.text.substring(startPos, this.pos).trim();
  }

  /**
   * 判断指针是否到达文本末尾(end of string)
   *
   * @memberof Scanner
   */
  eos() {
    return this.pos >= this.text.length;
  }
}

scanUntil方法用于扫描字符串,并将扫描过的内容返回,用于收集为token。整个扫描会分段进行,直到字符串的结尾。

转换为没有嵌套结构的tokens

先看代码,我们先实例化Scanner用于扫描整个传入字符串,同时初始化一个tokens数组用于保存token和一个word用于保存sanner收集到的字符串。

整个转化行为会持续到字符串的末尾,而scanscanUntil交替进行,不断获取<>之间的内容(即标签和属性)或者><之间的内容(即标签内的内容,包括文本和子标签)。

为了区分开始标签和结束标签,我们在生成的token数组中的第一项添加#/作为开始或结束的标记,第二项为标签名,第三项,我们放入开始标签中收集到的属性,而不是将属性单独放在一个token中,这样做是为了简化后边将tokens转化为嵌套结构的操作。

于是,我们得到了由形如[类型标记, 标签名, 数据, 文本]组成的二维数组。

这里对是一个标签否有属性这一点使用了非常简单粗暴的实现,即看<>中收集到的字符串中是否有空格,有空格则判断为有属性,没空格则判断为没有属性。

在收集标签属性的时候,顺便使用propsParser对标签属性进行了简单解析。

/**
 * 将html字符串转为无嵌套结构的token,返回tokens数组
 *
 * @param {string} html
 * @return {array} 
 */
function collectTokens(html) {
  const scanner = new Scanner(html);
  const tokens = [];

  let word = '';
  while (!scanner.eos()) {
    // 扫描文本
    const text = scanner.scanUntil('<');
    scanner.scan('<');
    tokens[tokens.length - 1] && tokens[tokens.length - 1].push(text);
    // 扫描标签<>中的内容
    word = scanner.scanUntil('>');
    scanner.scan('>');
    // 如果没有扫描到值,就跳过本次进行下一次扫描
    if (!word) continue;
    // 区分开始标签 # 和结束标签 /
    if (word.startsWith('/')) {
      tokens.push(['/', word.slice(1)]);
    } else {
      // 如果有属性存在,则解析属性
      const firstSpaceIdx = word.indexOf(' ');
      if (firstSpaceIdx === -1) {
        tokens.push(['#', word, {}]);
      } else {
        // 解析属性
        const data = propsParser(word.slice(firstSpaceIdx))
        tokens.push(['#', word.slice(0, firstSpaceIdx), data]);
      }
    }
  }

  return tokens;
}

使用propsParser简单解析标签属性

propsParser中,我们同样使用Scanner进行扫描,用=进行分割,分别得到keyvalue

由于某些属性是单属性的,比如字符串<button loading disabled class="btn">中的loading,以=分割的话会得到loading disabled class作为key,这显然是错误的。于是我们同样使用简单粗暴的方式,用是否有空格来判断是否有单属性,同时将单属性的值设置为true

由于这里直接使用了"="进行扫描,所以当前的程序不支持单引号,同时="之间不能有空格。

同时,这里只是对标签属性进行了简单的拆分,并没有对classstyle内的属性进行拆分。那是之后的步骤。当然,也可以放在这里进行。

function propsParser(propsStr) {
  propsStr = propsStr.trim();
  const scanner = new Scanner(propsStr);
  const props = {};

  while(!scanner.eos()) {
    let key = scanner.scanUntil('=');

    // 对单属性的处理
    const spaceIdx = key.indexOf(' ');
    if (spaceIdx !== -1) {
      const keys = key.replace(/\s+/g, ' ').split(' ');

      const len = keys.length;
      for (let i = 0; i < len - 1; i++) {
        props[keys[i]] = true;
      }
      key = keys[len - 1].trim();
    }
    scanner.scan('="');

    const val = scanner.scanUntil('"');
    props[key] = val || true;
    scanner.scan('"');
  }

  return props;
}

生成有嵌套结构的tokens

在之前生成的tokens是没有嵌套结构的,是一个简单的二维数组。在这里,我们要将其转换有嵌套结构的tokens

对于嵌套结构,通常使用来生成,遇到开始标签(这里为#)则压栈,遇到结束标签(这里为/)则出栈。

在这里,我们使用stack来保存栈状态,用collector来收集嵌套的内容,在压栈和出栈的同时也修改collector的指向,以保证嵌套层次的准确性。

同时,我们将嵌套结构放在token的第三个元素的位置。得到形如[类型标记, 标签名, 子节点, 数据, 文本]tokens

function nestTokens(tokens) {
  const nestedTokens = [];
  const stack = [];
  let collector = nestedTokens;

  for (let i = 0, len = tokens.length; i < len; i++) {
    const token = tokens[i];

    switch (token[0]) {
      case '#':
        // 收集当前token
        collector.push(token);
        // 压入栈中
        stack.push(token);
        // 由于进入了新的嵌套结构,新建一个数组保存嵌套结构
        // 并修改collector的指向
          token.splice(2, 0, []);
          collector = token[2];
        break;
      case '/':
        // 出栈
        stack.pop();
        // 将收集器指向上一层作用域中用于存放嵌套结构的数组
        collector = stack.length > 0
          ? stack[stack.length - 1][2]
          : nestedTokens;
        break;
      default:
        collector.push(token);
    }
  }

  return nestedTokens;
}

整合tokenizer函数

有了以上两个函数函数之后,我们可以将其整合为一个函数,方便之后调用。

function tokenizer(html) {
  return nestTokens(collectTokens(html));
}

将tokens转换为虚拟DOM

这一步相对来说就简单很多,只需要安装tokens的结构把相应的数据取出即可。

同时,在这里我们对classstyle属性进行解析,将形如{class: "item active"}class属性转换为

{
    class: {
        item: true,
        active: true
    }
}

的形式。

将形如{style: "border: 1px solid red; height: 300px"}转换为

{
    style: {
        border: "border: 1px solid red",
        height: "300px"
    }
}

的形式。

同时将在data中的属性key提取出来。由于当前的虚拟DOM还没有上树,所有elm属性为undefined。对于子节点,我们使用递归将子节点追加到children数组中。

于是最终我们得到形如

{
    sel: "div",
    children: [{
        sel: "p",
            data: {},
            elm: undefined,
            text: "文本",
            key: "1",
        }
    }],
    data: {class: {container: true}, id: "main"},
    elm: undefined,
    text: undefined,
    key: undefined,
}

的虚拟DOM结构。

以下是tokens2vdom的代码实现。

function tokens2vdom(tokens) {
  const vdom = {};

  for (let i = 0, len = tokens.length; i < len; i++) {
    const token = tokens[i];
    vdom['sel'] = token[1];
    vdom['data'] = token[3];

    // 解析类名
    if (vdom['data']['class']) {
      vdom['data']['class'] = classParser(vdom['data']['class']);
    }

    // 解析行类样式
    if (vdom['data']['style']) {
      vdom['data']['style'] = styleParser(vdom['data']['style']);
    }

    // 添加key
    if (vdom['data']['key']) {
      vdom['key'] = vdom['data']['key'];
      delete vdom['data']['key'];
    } else  {
      vdom['key'] = undefined;
    }

    if (token[4]) {
      vdom['text'] = token[token.length - 1];
    } else {
      vdom['text'] = undefined;
    }

    vdom['elm'] = undefined;
    
    const children = token[2];
    if (children.length === 0) {
      vdom['children'] = undefined;
      continue;
    };

    vdom['children'] = [];

    for (let j = 0; j < children.length; j++) {
      vdom['children'].push(tokens2vdom([children[j]]));
    }

    if (vdom['children'].length === 0) {
      delete vdom['children'];
    }
  }

  return vdom;
}

整合toVDOM函数

到这里我们的需求就基本实现了,我们将之前的函数整合为一个函数即可。

function toVDOM (html) {

  const tokens = tokenizer(html);
  const vdom = tokens2vdom(tokens);

  return vdom;
}

虚拟DOM的结构参照 snabbdom/snabbdom

本文完整的代码实现可以查看 HouyunCheng / mini-2vdom