构建Svelte template 的语法树

185 阅读3分钟

Svelte的响应式相对Vue来说还是比较简单的,其难点在于根据我们写的代码,编译生成正确的代码,因此通过手写一些可以运行的代码,而不是像前一篇文章只是复制源码,来加深对Svelte的理解。

ast语法树

<script>
let name = 'world'
</script>
<h1>Hello {name} </h1>

上面的代码会被编译成如下图所示的ast。 svelt-html.jpeg 我们先关注上面代码中的html部分,这个组件中的html部分会被编译4种ast节点。

  • Fragment:所有节点的祖先节点。
  • Element: html节点,上面的h1就会对应一个element。
  • Text: 文本节点,上面script和h1之间的换行符(\n),Hello 文本和{name}后面的空格都会编译成一个Text节点。
  • MustacheTag:{}之间的内容

每个节点都是用object来表示的,其中的属性意义如下表所示。

属性意义
start字符串开始位置
end字符串结束位置
type节点类型有四种(Fragement,text,Element,MustacheTag)
raw文本内容,text节点独有
chidlren子节点,Fragement和Element独有
expression{}中的表达式,MstacheTag独有
attributeshtml属性,elemnt独有

构建语法树

<h1>hello {name}</h1>解析这样子的字符串,(状态机)我们的思路是用四个函数来分别表示四种节点,然后再一个循环依次读取每一个字符,遇到字符'<'就调用解析element的函数,遇到'{'就调用解析MstacheTag函数。

let state = fragment
class Parser{
  index = 0 // 代码字符串的下标
  constructor(template) {
    this.template = template

    while (this.index < this.template.length) {
      state = state(this) || fragment
    }
  }
  match(c) {
    return this.template[this.index] === c
  }
}

function fragment(parser) {} 
function element(parser) {}
function text(parser) {}
function mstancheTag(parser) {}

现在我们来解释一下这一段代码的具体意思,Parser接受代码字符串输入,每一个字符都调用对应的函数进行处理,每个函数都要消耗一个字符,并且根据是否要把state设置成为其他状态,还是保持在本状态里面。 我们先来实现fragment函数。

function fragment(parser) {
  if (parser.match('<')) { 
    return element // 如果是字符'<',通过返回element函数,使得全局变量的值变为element,从而进入解析element。
  }

  if (parser.match('{')) {
    return mstancheTag // 使状态进入mstancheTag
  }

  return text // 使状态进入text
}

接下来我们来实现一下text函数。进入text函数只要不是字符'<'和字符字符'{'我们都保持在text函数里面。

function text(parser) {
  const start = parser.index // text节点开始的位置.

  let data = '' // 文本的内容,初始化为空字符.
  while (
    parser.index < parser.template.length && // 不能超过输入代码字符的长度.
    !parser.match('<') && // 预读一个字符,不能为'<', 注意此时不能消耗字符.
    !parser.match('{') //  
    ) { 
      data += parser.template[parser.index] // 读取文本
      parser.index++ // 注意此时一定要消耗一个字符,否则进入死循环中去。 
  }

  // 此时我们已经得到文本节点的全部内容了,可以构建文本节点了。
  const node = {
    type: 'text',
    start,
    end: start + data.length - 1,
    raw: data
  }
  // 不返回任何状态,使得进入默认的fragment进行重新判断。
}

text函数做的事情上面的注释已经解释的很清楚了,接下我们来看一下element函数的实现。 elment函数实现起来就没有那么简单了,我们要解析的要attribute,Svelte自定义的tag,还有是否是注释节点等等,因此目前我们只读取tag的名字即可,往后的内容在慢慢补充。

function element(parser) {
  // fragment函数没有消耗<,因此此时我们要消耗掉字符<
  const start = parser.index++

  const tagName = readTagName(parser)

  // 构建element元素
  const element = {
    type: 'Element',
    start,
    name: tagName,
    end: null, // 以后再填充
    children: [],
    attribute: []
  }
}
const regex_whitespace_or_slash_or_closing_tag = /(\s|\/|>)/
function readTagName(parser) {
  /**
   * 读取名字中断在三种情况。
   * 1. 碰到空格,这种情况有可能name后面有attribute,或者直接结束。
   * 2. 名字后面直接是字符'/',是自闭标签。
   * 3. 名字后面直接跟字符'>',说明标签的开始部分已经结束了。
   */
  const name = parser.read_util(regex_whitespace_or_slash_or_closing_tag)
  return name
}

Parser的成员函数实现如下。

class Parser {
  // 其他的代码,如上。
  read_util(pattern) {
    if (this.index > this.template.index) {
      throw Error('超出了template的长度了')
    }
    const start = this.index
    const match = pattern.exec(this.template.slice(start))
    if (match) {
      // 消耗的字符为匹配的长度加上初始长度
      this.index = start + match.index
      return this.template.slice(start, this.index)
    }
    // 不匹配情况,直接消耗完整个字符串,提前退出循环
    this.index = this.template.length
    return this.template.slice(start)
  }
}

接下来我们来实现mstancheTag,在此之前我们往Parser添加一个新的成员函数skip_white_space

class Parser {
  // 其他代码如上
  skip_white_space() {
    while (
      this.index < this.template.length &&
      regex_whitespace.test(this.template[this.index])
    ) {
      this.index++
    }
  }
}

现在我们来实现mstancheTag

function mstancheTag(parser) {
  // 同理先消耗字符'{'
  const start = parser.index++
  parser.skip_white_space()
  const expression = readExpression(parser)
  parser.skip_white_space()
  parser.expect('}')
  const node = {
    type: 'MstancheTag',
    start,
    end: parser.index,
    expression
  }
}
// 暂时先{}中的内容当做字符串来处理,实际中间的内容是表达式
function readExpression(parser) {
  let data = ''
  while (
    parser.index < parser.template &&
    parser.template[parser.index] !== '}'
  ) {
    data += parser.template[parser.index++]
  }
  return data
}

至此构建ast节点的内容我们已经完成了,接下我们要把这些节点连接成为一颗ast树。

首先我们在Parser的construct中新建一个节点代表所有节点的祖先节点。又因为html子节点有可能嵌套多层,因此我们在Parser声明一个栈,栈顶的元素永远是当前解析节点的父节点。

class Parser {
  stack = []
  construnctor(tempalte) {
    // 其他代码如上
    this.html = { // 代表ast节点的根节点
      type: 'Fragement',
      start: 0,
      end: template.length,
      children: []
    }
    
    this.stack.push(this.html)
    while (this.index < this.template.length) {
      state = state(this) || fragment
    }
  }
  // 获取栈顶元素
  current () {
    return this.stack[this.stack.length - 1]
  }
}

接下来我们把text节点添加到ast树中去。

function text(parser) {
  // 省略其他代码
  parser.current().children.push(node)
}

mstancheTag节点也是同理用一句简单的parser.current().children.push(node)即可把mstancheTag添加到Ast树当中去。

element节点我们有一些特殊的情况要处理一下,除了用成员函数current()获取父元素,从而添加到父元素的children数组中去。我们还要当前新创建的元素压入stack中去,这是因为当前元素可能还是有子元素,而且关闭标签的时候如,我们要栈顶元素弹出,并且检查站定元素的name是否与我们是否与关闭标签的名字一样,如果不一样的话说明标签是没有关闭的我们要报错。

通过以上的分析我们就可以写下以下代码。

function element(parser) {
  const start = parser.index++
  const parent = parser.current() // 拿到栈顶元素
  
  // <字符后面跟着是字符/情况下,j解析到了标签的关闭部分,比如 </button>
  const is_closing_tag = parser.expect('/') // 
  const tagName = readTagName(parser)

  // 构建element元素
  const element = {
    type: 'Element',
    start,
    name: tagName,
    end: null, // 以后再填充
    children: [],
    attribute: []
  }
  
  if (is_closing_tag) {
    // 关闭标签,解析完名字之后就剩字符'>'
    // 例如</button>名字button解析之后,后面应该跟>,否则就报错。
    parser.expect('>', true)
    if (
      parent.type !== element.type ||
      parent.name !== element.name
    ) {
      throw Error('标签没有正确关闭')
    }
    parent.end = parser.index
    parser.stack.pop() // 此时一对标签已经正确解析了
    return
  }
  
  parser.current().children.push(element)
  // 如果不是关闭标签,解析完名字之后,我们要看一下是否是自关闭标签。
  parser.skip_white_space()
  const self_close = parser.expect('/')
  parser.expect('>', true)
  if (self_close) {
    element.end = parser.index
  } else {
    parser.stack.push(element)
  }
}

同时我们在向Parser添加新的成员函数expect。

class Parser {
  // 省略其他代码
  expect(c, require) {
    if (
      this.index < this.template.length &&
      c === this.template[this.index]
    ) {
      this.index++
      return true
    }
    if (require) {
      throw Error(`期待字符${c},但是却是字符${this.template[this.index]}`
    }
    return false
  }
}

现在我们来写一些测试用例,来看一下我们的实现是否正确。

console.info('test unit 1')
const test1 = '<button>hello {name}</button>'
const parser = new Parser(test1)
console.info(parser.html.type === 'Fragment')
console.info(parser.html.children.length === 1)
console.info(parser.html.children[0].type === 'Element')
console.info(parser.html.children[0].name === 'button')
const children = parser.html.children[0].children
console.info(children.length === 2)
console.info(children[0].type === 'Text')
console.info(children[0].raw === 'hello ')
console.info(children[1].type === 'MstancheTag')
console.info(children[1].expression === 'name')

console.info('test unit 2')
const test2 = '<p><button>test</button></p>'
const parser2 = new Parser(test2)
console.info(parser2.html.children.length === 1)
console.info(parser2.html.children[0].type === 'Element')
console.info(parser2.html.children[0].name === 'p')
console.info(parser2.html.children[0].children[0].type === 'Element')
console.info(parser2.html.children[0].children[0].name === 'button')
console.info(parser2.html.children[0].children[0].children[0].type === 'Text')
console.info(parser2.html.children[0].children[0].children[0].raw === 'test')

我们暂时不用专门的测试框架,先用console肉眼看一下是否正确,如果你运行以上代码的话,你会看见控制台输出的全部是true。

完整的代码点击这里

未完待续!