手写vue2源码系列之将html转换成AST语法树(三)

122 阅读3分钟

文章来源:手写Vue2.0源码(二)-模板编译原理|技术点评 - 掘金 (juejin.cn) 本文无商业价值,如侵联删。

Vue进行模板渲染的步骤是:

1)首先拿到template中的html,利用正则匹配将html转换成ast语法树,这棵树描述了我们当前的DOM结构;

2)然后再将AST语法树转行成虚拟DOM节点,最后再将虚拟DOM节点转换成真实DOM节点。

那么什么是ast语法树呢?

废话不多说,我们用一个小案例来搞明白

如果此时你的Vue中的el模板是:

<div id="app">
    <p>hello{{name}}</p>
</div>

那么最后所变成的ast语法树应该大致为这样

let root = {
  tag: "div", //标签名称
  attrs: [{ name: "id", value: "app" }], //标签属性
  parent: null, //该标签的父亲
  type: 1, //比如元素的类型是1
  children: [
    {
      tag: "p",
      attrs: [],
      parent: root,
      type: 1, //比如元素的类型是1
      children: [
        {
          text: "hello{{name}}",
          type: 3, //比如文本的类型是3
        },
      ],
    },
  ],
};

1)首先将template转换成AST语法树

import { initState } from "./initState";
import compileToFunctions from './compiler/index.js'
function initMixin(Vue) {
  Vue.prototype._init = function (options) {
    const vm = this; //声明一个vm=this,这样方便后续拿值,且由于this是实例对象,根据地址引用,操作vm就相当于操作this
    vm.$options = options; //把new Vue传进来的options配置对象挂载在vue的实例身上
    initState(vm); //初始化状态,这里进行data初始化等
    if (vm.$options.el) {
      //开始进行模板渲染
      vm.$mount(vm.$options.el);
    }
  };
  Vue.prototype.$mount = function (el) {
    const vm = this;
    const options = vm.$options;
    el = document.querySelector(el);
    // 进行el挂载时的顺序为:
    //如果options中有render函数,则使用render;没有则采用template;否则就使用el中的内容
    if (!options.render) {
      let template = options.template;
      if (!template && el) {
        template = el.outerHTML; //outerHTML内容包含描述元素及其后代的序列化 HTML 片段,就是字符串
      }
      // 拿到template之后,我们就要把template先变成AST语法树,再变成虚拟DOM节点
      if (template) {
        const render = compileToFunctions(template);
        options.render = render;
      }
    }
  };
}
export { initMixin }


src/compiler/index.js
import parse from "./parse.js";
import generate from "./generate.js";
export default function compileToFunctions(template) {
  // 1)首先将template生成ast语法树
  let ast = parse(template);

  // 2)需要把ast转化成类似_c('div',{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))))这样的字符串

  // 3)然后把上面的字符串生成一个render函数
  let code = generate(ast);
  //with语法可以改变作用域
  let renderFn = new Function(`with(this){return ${code}}`);
  return renderFn;
}

src/compiler/parse.js
export default function prase(html) {
  //用来捕获template的正则表达式,?:是匹配但不捕获的意思

  const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; //匹配标签名 形如 abc-123
  const qnameCapture = `((?:${ncname}\\:)?${ncname})`; //匹配特殊标签 形如 abc:234 前面的abc:可有可无
  const startTagOpen = new RegExp(`^<${qnameCapture}`); // 匹配标签开始 形如 <abc-123 捕获里面的标签名
  const startTagClose = /^\s*(\/?)>/; // 匹配标签结束  >
  const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾 如 </abc-123> 捕获里面的标签名
  const attribute =
    /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性  形如 id="app"
  let root, currentParent; //首先定义一个根节点和当前父节点
  let stack = []; //栈结构,把匹配到的标签都放入栈中,当匹配到相同的标签,证明就是闭合的标签,需要从栈的最后边拿到这个标签
  const ELEMENT_TYPE = 1; //元素类型为1
  const TEXT_TYPE = 3; //文本的类型为3
  //首先定义一个生成AST语法树结构的方法
  function createASTElement(tagName, attrs) {
    return {
      tag: tagName,
      type: ELEMENT_TYPE,
      children: [],
      attrs,
      parent: null,
    };
  }
  // 对开始标签进行处理
  function handleStartTag({ tagName, attrs }) {
    let element = createASTElement(tagName, attrs);
    if (!root) {
      root = element;
    }
    currentParent = element;
    stack.push(element);
  }

  // 对结束标签进行处理
  function handleEndTag(tagName) {
    // 栈结构 []
    // 比如 <div><span></span></div> 当遇到第一个结束标签</span>时 会匹配到栈顶<span>元素对应的ast 并取出来
    let element = stack.pop();
    // 当前父元素就是栈顶的上一个元素 在这里就类似div
    currentParent = stack[stack.length - 1];
    // 建立parent和children关系
    if (currentParent) {
      element.parent = currentParent;
      currentParent.children.push(element);
    }
  }

  // 对文本进行处理
  function handleChars(text) {
    // 去掉空格
    text = text.replace(/\s/g, "");
    if (text) {
      currentParent.children.push({
        type: TEXT_TYPE,
        text,
      });
    }
  }

  // 解析标签生成ast核心
  while (html) {
    // 查找<
    let textEnd = html.indexOf("<");
    // 如果<在第一个 那么证明接下来就是一个标签 不管是开始还是结束标签
    if (textEnd === 0) {
      // 如果开始标签解析有结果
      const startTagMatch = parseStartTag();
      if (startTagMatch) {
        // 把解析好的标签名和属性解析生成ast
        handleStartTag(startTagMatch);
        continue;
      }

      // 匹配结束标签</
      const endTagMatch = html.match(endTag);
      if (endTagMatch) {
        advance(endTagMatch[0].length);
        handleEndTag(endTagMatch[1]);
        continue;
      }
    }

    let text;
    // 形如 hello<div></div>
    if (textEnd >= 0) {
      // 获取文本
      text = html.substring(0, textEnd);
    }
    if (text) {
      advance(text.length);
      handleChars(text);
    }
  }

  // 匹配开始标签
  function parseStartTag() {
    const start = html.match(startTagOpen);

    if (start) {
      const match = {
        tagName: start[1],
        attrs: [],
      };
      //匹配到了开始标签 就截取掉
      advance(start[0].length);

      // 开始匹配属性
      // end代表结束符号>  如果不是匹配到了结束标签
      // attr 表示匹配的属性
      let end, attr;
      while (
        !(end = html.match(startTagClose)) &&
        (attr = html.match(attribute))
      ) {
        advance(attr[0].length);
        attr = {
          name: attr[1],
          value: attr[3] || attr[4] || attr[5], //这里是因为正则捕获支持双引号 单引号 和无引号的属性值
        };
        match.attrs.push(attr);
      }
      if (end) {
        //   代表一个标签匹配到结束的>了 代表开始标签解析完毕
        advance(1);
        return match;
      }
    }
  }
  //截取html字符串 每次匹配到了就把已经匹配的删除掉,往后吧继续匹配
  function advance(n) {
    html = html.substring(n);
  }
  //   返回生成的ast
  return root;
}

//比如这个案例经过AST语法树转换后是变成如下的样子
 <div id="app">
      <h1>hello{{name}}</h1>
    </div>

image.png

2)把AST语法树变成JS语法形式的字符串

我们要把AST语法树转换成形如_c('div',{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))))的函数式字符串

src/compiler/generate.js
export default function generate(el) {
  let children = getChildren(el);
  let code = `_c('${el.tag}',${
    el.attrs.length ? `${genProps(el.attrs)}` : "undefined"
  }${children ? `,${children}` : ""})`;
  return code;
}
const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g; //匹配花括号 {{  }} 捕获花括号里面的内容

function gen(node) {
  // 判断节点类型
  // 主要包含处理文本核心
  // 源码这块包含了复杂的处理  比如 v-once v-for v-if 自定义指令 slot等等  咱们这里只考虑普通文本和变量表达式{{}}的处理

  // 如果是元素类型
  if (node.type == 1) {
    //   递归创建
    return generate(node);
  } else {
    //   如果是文本节点
    let text = node.text;
    // 不存在花括号变量表达式
    if (!defaultTagRE.test(text)) {
      return `_v(${JSON.stringify(text)})`;
    }
    // 正则是全局模式 每次需要重置正则的lastIndex属性  不然会引发匹配bug
    let lastIndex = (defaultTagRE.lastIndex = 0);
    let tokens = [];
    let match, index;

    while ((match = defaultTagRE.exec(text))) {
      // index代表匹配到的位置
      index = match.index;
      if (index > lastIndex) {
        //   匹配到的{{位置  在tokens里面放入普通文本
        tokens.push(JSON.stringify(text.slice(lastIndex, index)));
      }
      //   放入捕获到的变量内容
      tokens.push(`_s(${match[1].trim()})`);
      //   匹配指针后移
      lastIndex = index + match[0].length;
    }
    // 如果匹配完了花括号  text里面还有剩余的普通文本 那么继续push
    if (lastIndex < text.length) {
      tokens.push(JSON.stringify(text.slice(lastIndex)));
    }
    // _v表示创建文本
    return `_v(${tokens.join("+")})`;
  }
}

// 处理attrs属性
function genProps(attrs) {
  let str = "";
  for (let i = 0; i < attrs.length; i++) {
    let attr = attrs[i];
    // 对attrs属性里面的style做特殊处理
    if (attr.name === "style") {
      let obj = {};
      attr.value.split(";").forEach((item) => {
        let [key, value] = item.split(":");
        obj[key] = value;
      });
      attr.value = obj;
    }
    str += `${attr.name}:${JSON.stringify(attr.value)},`;
  }
  return `{${str.slice(0, -1)}}`;
}

// 生成子节点 调用gen函数进行递归创建
function getChildren(el) {
  const children = el.children;
  if (children) {
    return `${children.map((c) => gen(c)).join(",")}`;
  }
}

image.png

3)把JS语法形式的字符串变成render渲染函数

src/compiler/index.js
import parse from "./parse.js";
import generate from "./generate.js";
export default function compileToFunctions(template) {
  // 1)首先将template生成ast语法树
  let ast = parse(template);

  // 2)需要把ast转化成类似_c('div',{id:"app"},_c('div',undefined,_v("hello"+_s(name)),_c('span',undefined,_v("world"))))这样的字符串

  // 3)然后把上面的字符串生成一个render函数
  let code = generate(ast);
  //with语法可以改变作用域
  let renderFn = new Function(`with(this){return ${code}}`);
  return renderFn;
}

image.png

经过上面的一系列转化,这就是我们最终所要的渲染函数了,其中_c代表的create,_v代表的是文本,接下来我们就要实现如何把这个渲染函数渲染成虚拟节点。