Vue2源码解析 解析器

目录

1  解析器的作用

2  解析器内部运行原理

3  html解析器 

3.1  运行原理

3.2  截取开始标签

3.3  截取结束标签

3.4  截取注释

3.5  截取条件注释

3.6  截取DOCTYPE

3.7  截取文本

3.8  纯文本内容元素的处理

3.9  使用栈维护dom层级

3.10  整体逻辑

4  文本解析器

5  总结


1  解析器的作用

解析器要实现的功能是将模板解析成AST

案例:

<div>
  <p> {{ name }} </p>
</div>

转换后的AST: 

{
  tag:"div",
  type:1,
  staticRoot:false,
  static:false,
  plain:true,
  parent:undefined,
  attrsList:[],
  attrsMap:{},
  children:[
    {
      tag:"p",
      type:1,
      staticRoot:false,
      static:false,
      plain:true,
      parent:{
        tag:"div",
        // ...
      },
      attrsList:[],
      attrsMap:{},
      children:[{
        type:2,
        text:"{{name}}",
        static:false,
        expression:"_s(name)"
      }]
    }
  ]
}

2  解析器内部运行原理

解析器也分好几个子解析器,比如:html解析器、文本解析器、过滤解析器,主要是html解析器,html解析器的伪代码如下所示:

parseHTML(template,{
  start(tag,attrs,unary){
    // 解析到标签开始位置时,触发该函数
  },
  end(){
    // 解析到标签结束位置时,触发该函数
  },
  chars(text){
    // 解析到文本时,触发该函数
  },
  comment(text){
    // 解析到注释时,触发该函数
  }
})

比如:<div><p>我是lisa</p></div>当被html解析器解析时,所触发的钩子函数依次是:start、start、chars、end、end;

当html解析器不再触发钩子函数时,就说明所有模板都解析完毕,所有类型的节点都在钩子函数中构建完成,即AST构建完成。

在start钩子函数中,使用三个参数来创建一个元素类型的AST节点,例如:

function createASTElement(tag,attrs,parent){
  return {
    type:1,
    tag,
    attrsList:attrs,
    parent,
    children:[]
  }
}
parseHTML(template,{
  start(tag,attrs,unary){
    let element = createASTElement(tag,attrs,unary)
  },
  chars(text){
    let element = { type:3,text }
  },
  comment(text){
    let element = { type:3,text,isComment:true }
  }
})

构建AST层级关系其实非常简单,只需要维护一个栈(stack)即可,用栈来记录层级关系,这个层级关系也可以理解为dom的深度。

html解析器在解析html时,是从前往后解析。每当遇到开始标签,就触发钩子函数start,并把当前节点推入栈中;遇到结束标签,就触发end钩子函数,从栈中弹出一个节点。如下图所示:

 模板:

<div>
  <h1>我是Berwin</h1>
  <p>我今年23岁</p>
</div>

上面模板被解析成AST的过程如下图所示:

 

3  html解析器 

3.1  运行原理

解析html模板的过程就是循环的过程,简单来说就是用html模板字符串来循环,每轮循环从html模板中截取一小段字符串,然后重复以上过程,直到html模板被截成一个空字符串结束循环,解析完毕。

手动模拟html解析器的解析过程:

<div>
  <p>{{name}}</p>
</div>

最初始的html模板:

`<div>

        <p>{{ name }}</p>

</div>`

第一轮循环时,截取出一段字符串<div>,并触发钩子函数start,截图后的结果为:

`

        <p>{{ name }}</p>

</div>`

第二轮时,截取出一段字符串:

`

        `

并且触发钩子函数chars,截取后的结果为:

`<p>{{ name }}</p>

</div>`       

第三轮循环时,截取出一段字符串<p>,并触发钩子函数start,截取后的结果为:

`{{ name }}</p>

</div>`

第四轮循环时,截取出一段字符串{{ name }},并触发钩子函数chars,截取后的结果为:

`</p>
</div>`

第五轮循环时,截取出一段字符串</p>,并触发钩子函数end,截取结果为:

`

</div>`

第六轮循环时,截取出一段字符串:

`

`

并触发钩子函数chars,截取后的结果为:

`</div>`

第七轮循环时,截取出一段字符串</div>,并触发end钩子函数,截取后的结果为:

``

解析完毕。

被截取的片段分为很多种类型:

        1.开始标签;

        2.结束标签;

        3.html注释,<!-- 注释 -->;

        4.DOCTYPE;

        5.条件注释;

        6.文本;

3.2  截取开始标签

如何确定模板是不是开始标签开头(下面代码不包含标签属性的解析)?

// 正则表达式来匹配模板以开始标签开头
const ncname = '[a-zA-Z_][\\w\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)

// 以开始标签开始的模板
"<div></div>".match(startTagOpen) // ["<div","div",index:0,input:"<div></div>"]
// 以结束标签开始的模板
"</div>".match(startTagOpen) // null

当html标签解析到标签开始时,会触发钩子函数start,同时会给出三个参数,分别是标签名(tagName)、属性(attrs)和自闭合标识(unary)。

接下来就是解析标签名、属性、自闭合标识,解析后得到下面的数据结构:

const start = '<div></div>'.match(startTagOpen)
if (start) {
    const match = {
        tagName: start[1],
        attrs: [],
    }
}

1.解析标签属性

解析完开始标签开头之后,模板中伪代码的样例如下:'  class="box"></div>' 

每解析一个属性就截取一个属性。如果截取完后,剩下的html模板依然符合标签属性的正则表达式,那说明还有剩余的属性需要处理,此时需要重复执行前面的流程,直到剩余的模板不存在属性,也就是剩余的模板不存在符合正则表达式所预设的规则。

const startTagClose = /^\s*(\/?)>/
const attribute =
    /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>']+)))?/
let html = ' class="box" id="el"></div>'
let end, attr
const match = { tagName: 'div', attrs: [] }

while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
    html = html.substring(attr[0].length)
    match.attrs.push(attr)
}

解析后的结果为:

{
    tagName: 'div',
    attrs: [
        [' class="box"', 'class', '=', 'box', null, null],
        [' id="el"', 'id', '=', 'el', null, null],
    ],
}

2.解析自闭合标识

自闭合标签没有子节点的,所以我们提前构建AST层级时,需要维护一个栈,而一个节点是否需要推入到栈中,可以使用自闭合标识来判断。

代码如下:

function parseStartTagEnd(html) {
    const startTagClose = /^\s*(\/?)>/
    const end = html.match(startTagClose)
    const match = {}
    if (end) {
        match.unarySlash = end[1]
        html = html.substring(end[0].length)
        return match
    }
}
console.log(parseStartTagEnd('></div>')) // {unarySlash:""}
console.log(parseStartTagEnd('/><div></div>')) // {unarySlash:"/"}

3.实现源码

解析开始标签时,被拆分成三个部分,分别是标签名、属性、结尾。vue中的源码如下:

const ncname = '[a-zA-Z_][\\w-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
function advance(n) {
    html = html.substring(n)
}
function parseStartTag() {
    // 解析标签名,判断模板是否符合开始标签的特征
    const start = html.match(startTagOpen)
    if (start) {
        const match = {
             tagName: start[1],
             attrs: [],
         }
         advance(start[0].length)
         // 解析标签属性
         let end, attr
         while (
            !(end = html.match(startTagClose)) &&
            (attr = html.match(attribute))
         ) {
             advance(attr[0].length)
             match.attrs.push(attr)
         }
         // 判断该标签是否是自闭合标签
         if (end) {
            match.unarySlash = end[1]
            advance(end[0].length)
            return match
         }
    }
}

如果调用它后得到了解析结果,那么说明剩余模板得开始部分符合开始标签得规则,此时将解析出来得结果取出来并调用钩子函数start即可:

const startTagMatch = parseStartTag()
if(startTagMatch){
    handleStartTag(startTagMatch)
    continue
}

所有解析操作都运行在循环中,所以continue的意思是这一轮的解析工作已经完成,可以进行下一轮解析工作。

从代码中可以看出,如果调用parseStartTag之后有返回值,那么会进行开始标签的处理,其处理逻辑主要在handleStartTag中。这个函数的主要目的就是将tarName、attrs、unary等数据取出来,然后调用钩子函数将这些数据放到参数中。

3.3  截取结束标签

只有html模板的第一个字符是<时,我们才需要确认它到底是不是结束标签。

正则匹配代码如下:

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:)?${ncname})`
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)
const endTagMatch = '</div>'.match(endTag) // ["</div>","div",index:0,input:"</div>"]
const endTagMatch2 = '<div>'.match(endTag) // null

分辨出剩余模板是否是结束标签后,还需要截取模板,和触发钩子函数。vue源码精简后:

const endTagMatch = html.match(endTag)
if (endTagMatch) {
    html = html.substring(endTagMatch[0].length)
    urlToHttpOptions.end(endTagMatch[1])
    continue
}

3.4  截取注释

const comment = /^<!--/
if(comment.test(html)){
  const commentEnd = html.indexOf("-->")
  if(commentEnd >= 0){
    if(options.shouldKeepComment){
      options.comment(html.substring(4,commentEnd))
    }
    html = html.substring(commentEnd + 3)
    continue
  }
}

3.5  截取条件注释

const conditionalComment = /^<!\[/
if(conditionalComment.test(html)){
  const conditionalEnd = html.indexOf("]>")
  if(conditionalEnd){
    html = html.substring(conditionalEnd + 2)
    continue
  }
}

3.6  截取DOCTYPE

const doctype = /^<!DOCTYPE [^>]+>/i
const doctypeMatch = html.match(doctype)
if(doctypeMatch){
  html = html.substring(doctypeMatch[0].length)
  continue
}

3.7  截取文本

如果html模板的第一个字符不是<,那么它一定是文本了。

while (html) {
  let text
  let textEnd = html.indexOf("<")
  // 截取文本
  if(textEnd >= 0){
    rest = html.slice(textEnd)
    /**
     * while用于解决文本中存在<的问题,如果剩余的模板不符合任何被解析的类型,
     * 那么重复解析文本,知道剩余模板符号被解析的类型为止
     */
    while (
      !endTag.test(rest) && // 结束标签
      !startTagOpen.test(rest) && // 开始标签
      !comment.test(rest) && // 注释
      !conditionalComment.test(rest) // 条件注释
    ) {
      // 如果<在纯文本中,将它视为纯文本对待
      next = rest.indexOf("<",1)
      if(next < 0) break
      textEnd += next
      rest = html.slice(0,textEnd)
    }
    text = html.substring(0,textEnd)
    html = html.substring(textEnd)
  }
  // 如果模板中找不到<,就说明整个模板都是文本
  if(textEnd < 0){
    text = html
    html = ""
  }
  // 触发钩子函数
  if(options.chars && text){
    options.chars(text)
  }
}

3.8  纯文本内容元素的处理

什么是纯文本内容元素呢?script、style、textarea这三种元素叫做纯文本内容元素。

while (html) {
    if (!lastTag || !isPlainTextElement(lastTag)) {
        // 父元素为正常元素的处理逻辑
    } else {
        // 父元素为script、style、textarea的处理逻辑
        const stackedTag = lastTag.toLowerCase()
        const reStackedTag =
            reCache[stackedTag] ||
            (reCache[stackedTag] = new RegExp(
                '([\\s\\S]*?)(</' + stackedTag + '[^>]*>)',
                'i'
            ))
        // 把文本截取出来并触发钩子函数chars
        const rest = html.replace(reStackedTag, function (all, text) {
            if (options.chars) {
                options.chars(text)
            }
            return ''
        })
        html = rest
        options.end(stackedTag)
    }
}

3.9  使用栈维护dom层级

每解析到开始标签,就向栈中推进去一个;每解析到标签结束,就弹出一个。同时,html解析器中的栈还有另一个作用,可以检测html标签是否正确闭合。

3.10  整体逻辑

如果通过<分辨出即将解析的这一小部分字符不是文本而是标签类,那么标签类有那么多类型,需要进一步分辨是哪种类型:

function parseHTML(html, options) {
    while (html) {
        if (!lastTag || !isPlainTextElement(lastTag)) {
            let textEnd = html.indexOf('<')
            if (textEnd === 0) {
                if (comment.test(html)) {
                    // 注释的逻辑处理
                    continue
                }
                if (conditionalComment.test(html)) {
                    // 条件注释的逻辑处理
                    continue
                }
                const doctypeMatch = html.match(doctype)
                if (doctypeMatch) {
                    // doctype的逻辑处理
                    continue
                }
                const endTagMatch = html.match(endTag)
                if (endTagMatch) {
                    // 结束标签的逻辑处理
                    continue
                }
                const startTagMatch = parseStartTag()
                if (startTagMatch) {
                    // 开始标签的处理逻辑
                    continue
                }
            }
            let text, rest, next
            if (textEnd >= 0) {
                // 解析文本
            }
            if (textEnd < 0) {
                text = html
                html = ''
            }
            if (options.chars && text) {
                options.chars(text)
            }
        } else {
            // 父元素为script、style、textarea的处理逻辑
        }
    }
}

4  文本解析器

文本解析器得作用是解析文本。准确得说是对html解析器解析出来得文本进行二次加工

文本其实分两种类型,一种是纯文本(不需要任何处理),另一种是带变量得文本(需要解析器进一步解析)。

每当html解析器解析到文本时,都会触发chars函数,并且从参数中得到解析出来的文本。在chars函数中,需要构建文本类型的AST,并将它添加到父节点的children属性中。

如果遇到带变量的文本,需要进行二次加工,代码如下所示:

parseHTML(template, {
    // ...
    chars(text) {
        text = text.trim()
        if (text) {
            const children = currentParent.children
            let expression
            /**
             * 执行parseText后又返回值,说明是带变量的文本,加工后添加到children中;
             * 否则说明是普通文本,直接添加到children中
             */
            if ((expression = parseText(text))) {
                children.push({
                    type: 2,
                    expression,
                    text,
                })
            } else {
                children.push({
                    type: 3,
                    text,
                })
            }
        }
    },
    // ...
})

例如:"Hello  {{ name }}"解析后:"Hello_" + _s(name)

上面代码中_s就是下面这个toString函数的别名:

function toString(val) {
    return val == null
        ? ''
        : typeof val === 'object'
            ? JSON.stringify(val, null, 2)
            : String(val)
}

案例:

var obj = { name: "Berwin"}
with(obj){
    function toString(val) {
        return val == null
            ? ''
            : typeof val === 'object'
            ? JSON.stringify(val, null, 2)
            : String(val)
    }
    console.log("Hello " + toString(name)) // "Hello  Berwin"
}

事实上,最终AST会转换成代码字符串放在with中执行。

在文本解析器中,先使用正则表达式来匹配是否为带变量的文本,纯文本返回undefined:

function parseText(text) {
  const tagRE = /\{\{((?:.|\n)+?)\}\}/g
  if (!tagRE(text)) {
      return
  }
  const tokens = []
  let lastIndex = tagRE.lastIndex = 0
  let match,index
  while (match = tagRE.exec(text)) {
    index = match.index
    // 先把 {{ 前边的文本添加到token中
    if(index > lastIndex){
      tokens.push(JSON.stringify(text.slice(lastIndex,index)))
    }
    // 把变量改成_s(x)这样的形成也添加到数组中
    tokens.push(`_s(${match[1].trim()})`)
    // 设置lastIndex来保证下一轮循环时,正则表达式不再重复匹配已经解析过的文本
    lastIndex = index + match[0].length
  }
  // 当所有变量都处理完毕后,如果最后一个变量右边还有文本,就将文本添加到数组中
  if(lastIndex < text.length){
    tokens.push(JSON.stringify(text.slice(lastIndex)))
  }
  return tokens.join("+")
}

这段代码有个关键的地方在lastIndex:每处理完一个变量后,会重新设置lastIndex的位置,这样可以保证如果后面还有其他变量,那么在下一轮循环时可以从lastIndex的位置开始向后匹配,而lastIndex之前的文本将文本将不再被匹配。 

5  总结

解析器的作用是通过模板得到AST。

生成AST的过程需要借助html解析器,当html解析器触发不同的钩子函数时,我们可以构建出不同的节点。

随后,我们通过栈来得到当前正在构建的节点的父节点,然后将构建出的节点添加到父节点的下面。

最终,当html解析器运行完毕后,可以得到一个完整的带dom层级关系的AST。

html解析器的内部原理:一小段一小段地截取模板字符串,每截取一小段字符串,就会根据截取出来的字符串类型触发不同的钩子函数,直到模板字符串截空停止运行。

文本分两种类型,不带变量的纯文本和带变量的文本,带变量的文本需要使用文本解析器进行二次加工。

注:本文章来自于《深入浅出vue.js》(人民邮电出版社)阅读后的笔记整理

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值