将HTML字符串编译为虚拟DOM对象的基础实现
发布日期:2021-05-09 03:33:17 浏览次数:16 分类:博客文章

本文共 6448 字,大约阅读时间需要 21 分钟。

本文所有代码均保存在

虚拟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的结构参照

本文完整的代码实现可以查看

上一篇:全真教程:Windows环境Jupyter Notebook安装、运行和工作文件夹配置
下一篇:前端数据渲染及mustache模板引擎的简单实现

发表评论

最新留言

不错!
[***.144.177.141]2025年04月05日 21时45分53秒