1-Vue源码之【解析】

1,301 阅读7分钟

前言

文章基于 Vue3 源码,版本为 3.3.0-alpha.4,由于本人能力所限,只是对源码的粗略解读,最终目标是实现一个【粗糙】的 Vue 框架。(属实是能力有限(lll ¬ ω ¬))

Parse

【注】Vue 源码里细节很多,对于很多情况,性能都考虑的很到位,比如该 parse.ts ,Vue 足足用了千多行代码书写,而我自己只是试着写了一些感兴趣的,仅仅只写了三百多行代码。

当然,代码嘛,以后有机会多读几遍,现在先浅尝。

在 Vue 源码中,我们可以在 vue/index.ts 文件下找到其导出了 compile 的方法,且 vue 是使用模板编译出代码的,所以我们先从 compile 开始,点进去会发现如下代码

export function compile(template: string, options: CompilerOptions = {}): CodegenResult {
  return baseCompile(
    template,
    extend({}, parserOptions, options, {
      // ...省略
    }),
  )
}

export function baseCompile(template: string | RootNode, options: CompilerOptions = {}): CodegenResult {
  // ...省略前后
  const ast = isString(template) ? baseParse(template, options) : template

  // ...省略
}

从上述代码我们可以看到,如果模板为字符串,那么就使用 baseParse 去解析,最终生成 抽象语法树AST)。

模板语法 --> AST -> h函数(createVnode 函数) --> 虚拟DOM --> diff 运算 --> 真实DOM

过程

我们的 baseParse 便是将模板语法解析成 AST 的方法

export function baseParse(content: string, options: ParserOptions = {}): RootNode {
  // 这里会创建一个解析器,里面保存了当前解析器解析到的位置,行数,源内容,当前内容等属性
  const context = createParserContext(content, options)

  // 该方法获取当前解析器的位置,内部实现仅仅只是返回 {line, offset, column}
  const start = getPosition(context)
  console.log({ context, start })

  // 主要先研究 parseChildren 这个方法
  return createRoot(parseChildren(context, TextModes.DATA, []), getSelection(context, start))
}
/**
 * 创建解析器上下文
 *
 * @param content 初始模板内容
 * @returns
 */
const createParseContext = (content: string): ParseContext => {
  return {
    line: 1, // 解析器解析到的行数
    offset: 0, // 解析器解析到的字数
    column: 0, // 源码里有,我自己写的后面没有做这个,不太好判断这是啥,列?但是为什么会有列的概念,期待后续研究
    originSource: content, // 模板源内容
    source: content, // 还待解析的模板内容
    // ...省略部分属性
  }
}
const MUSTACHE: ['{{', '}}'] = ['{{', '}}']

const startsWith = (s: string, c: string, pos?: number) => s.startsWith(c, pos)

const isEnd = (source: string) => {
  // 起初我这里只做了 !source 的判断,后来发现在解析DOM的时候,因为DOM可以嵌套子节点
  // 那么就需要递归 parseChildren 去处理子节点,那么这时候就需要多个 `source.startsWith('</')` 的处理,如果返回true,则表示遇到了闭合标签,需要退出循环,返回
  return !source || startsWith(source, '</')
}

const parseChildren = (context: ParseContext): NodeDataType[] => {
  const nodes: NodeDataType[] = []

  // isEnd判断是否跳出循环
  while (!isEnd(context.source)) {
    const source = context.source
    let node: NodeDataType

    // 这里源码做了很多精细的判断,我这里仅做了三种判断处理
    if (startsWith(source, '<')) {
      // 为 DOM  <
      node = parseElement(context)
    } else if (startsWith(source, MUSTACHE[0])) {
      // 为 MUSTACHE
      node = parseMustache(context)
    } else {
      // 为 text
      node = parseText(context)
    }

    if (node!) {
      // 源码里有node为数组的情况
      // if (isArray(node)) {
      //   for (let i = 0; i < node.length; i++) {
      //     pushNode(nodes, node[i])
      //   }
      // } else {
      pushNode(nodes, node)
      // }
    }
  }

  // 循环结束后,需要处理空白节点
  // 源码里做了更多判断,比如首元素,尾元素,两个元素之间的换行啥的
  // 我这里仅做了融合空白节点,且如果内容都是空白,那么则会被删除的操作。
  const _nodes = nodes
    .map((node) => {
      if (node.content && /[\t\r\n\f ]/.test(node.content)) {
        node.content = node.content.replace(/[\t\r\n\f ]+/g, ' ')

        if (/^\s+$/.test(node.content)) {
          return null as any
        }
      }

      return node
    })
    .filter(Boolean)

  return _nodes
}

ParseText

由于 DOM 解析会比较麻烦,且 DOM 解析也会涉及到文本解析,所以先从文本节点解析开始

/**
 * 解析文本内容
 *
 * @param context
 */
const parseText = (context: ParseContext): NodeDataType => {
  const start = getPosition(context)
  const source = context.source

  // 先获取最近的结束位置,(获取接下来文本内最近的 < 和 MUSTACHE 的位置,作为文本解析结束的位置)
  let endIndex = source.length
  for (let i = 0; i < ['<', MUSTACHE[0]].length; i++) {
    const chart = ['<', MUSTACHE[0]][i]
    const index = source.indexOf(chart)

    // 这里判断,离谁近用谁
    endIndex = endIndex > index && index !== -1 && index ? index : endIndex
  }

  // 把结束位置告诉真正解析文本内容的方法
  const content = parseTextData(context, endIndex!)

  // 返回的类型为文本类型,loc表示这段内容所处的位置信息
  return {
    type: NodeType.TEXT,
    content,
    loc: getLocation(context, start),
  }
}

/**
 * 取出文本 + 位移解析器。多个地方会用到,所以封装起来
 *
 * @param context
 * @param index
 */
const parseTextData = (context: ParseContext, index: number) => {
  // 取出文本
  const content = context.source.slice(0, index)

  // 位移解析器
  advanceBy(context, index)

  return content
}

1. 解析器位移 (set)

在最初我们创建了一个解析器,接下来我们解析各个语法的时候,都会用到下面这些方法,位移解析器,记录剩余需要解析的内容,当前的位置属性等。

// 位移解析器
const advanceBy = (context: ParseContext, len: number) => {
  const { source } = context

  // 位移方法
  advancePositionWithMutation(context, source, len)

  context.source = source.slice(len)
}

/**
 *
 * 位移空白
 */
const advanceSpace = (context: ParseContext) => {
  const { source } = context
  const preOffset = context.offset

  for (let i = 0; i < source.length; i++) {
    if (/^[\s\f\n\r\t\v]+$/.test(source[i])) {
      // 如果当前为换行符, 那么 line + 1
      if (source.charCodeAt(i) == 10) {
        context.line++
      }
      context.offset++
    } else {
      break
    }
  }

  context.source = source.slice(context.offset - preOffset)
  return context
}

/**
 * 根据source设置临时位置
 * 该功能为避免影响 源pos 的位置数据,而先去Object.assign({}, pos)生成一个临时的位置
 *
 * @param pos
 * @param source
 * @param offsetNum 位移数
 * @returns
 */
const advancePositionWithClone = (pos: Position, source: string, offsetNum: number = source.length) => {
  return advancePositionWithMutation(Object.assign({}, pos), source, offsetNum)
}

/**
 * 根据source设置实时位置
 *
 * @param pos
 * @param source
 * @param offsetNum 位移数
 * @returns
 */
const advancePositionWithMutation = (pos: Position, source: string, offsetNum: number = source.length) => {
  // 避免 offsetNum 为 -1 的情况
  offsetNum = offsetNum < 0 ? source.length : offsetNum
  let lineCount = 0

  for (let i = 0; i < offsetNum; i++) {
    // 如果当前为换行符, 那么 line + 1
    if (source.charCodeAt(i) == 10) {
      lineCount++
    }
  }

  pos.line += lineCount
  pos.offset += offsetNum

  return pos
}

2. 获取当前解析器位置 (get)

const getPosition = (context: ParseContext) => {
  const { line, offset } = context
  return { line, offset }
}

const getLocation = (context: ParseContext, start: Position, end?: Position) => {
  end = end || getPosition(context)
  return { start, end, source: context.originSource.slice(start.offset, end.offset) }
}

parseMustache

明白了文本解析,其实 mustache 解析也很简单,因为实际也是文本,所以内部用了 parseTextData 去获取 content,并且我们将 type 标记为 NodeType.STATE ,方便我们后续处理

/**
 * 解析Mustache
 *
 * @param context
 */
const parseMustache = (context: ParseContext): NodeDataType => {
  const start = getPosition(context)
  const [open, close] = MUSTACHE
  // 位移2步
  advanceBy(context, open.length)
  const closeIndex = context.source.indexOf(close)
  // 获取双花括号内的所有内容,这里用了 trim 去除左右空白
  const content = parseTextData(context, closeIndex).trim()

  // 因为parseTextData 里会位移到 close 之前,所以我们这里还需要再位移2步,让解析器停留在下一个需要解析的节点上
  advanceBy(context, close.length)

  return {
    type: NodeType.STATE,
    content,
    loc: getLocation(context, start),
  }
}

parseElement

解析元素,需要要处理 标签属性 ,包括其内部的子元素

所以,需要有 parseTagparseAttrs 两个方法,子元素的解析实际上跟普通解析元素一致,所以只需要递归调用 parseChildren 即可

/**
 * 解析元素
 *
 * 这里先处理开始标签,接着去处理子元素,最终会碰到 闭合标签
 * @param context
 */
const parseElement = (context: ParseContext): NodeDataType => {
  // 解析标签,内部也会调用解析属性的方法
  const element = parseTag(context)

  // 处理子元素
  const children = parseChildren(context)
  element.children = children

  // 处理闭合标签
  if (!element.isSelfEndTag) {
    const closeTag = `</${element?.tag}>`

    if (startsWith(context.source, closeTag)) {
      advanceBy(context, closeTag.length)
    }
  }

  // 经历了一轮 children 的递归,需要重新计算该元素真正的结束位置
  element.loc = getLocation(context, element.loc.start)

  return element
}

1. parseTag

const parseTag = (context: ParseContext): NodeDataType => {
  const start = getPosition(context)
  const source = context.source
  // 处理标签名
  const match = /^\<([a-zA-Z]\w*)/.exec(source)

  if (!match?.[1]) {
    throw new Error('标签名称不正确')
  }

  // 位移解析器
  advanceBy(context, match?.[0].length)

  // 处理属性名
  const props = parseAttrs(context)

  // 清除空白
  advanceSpace(context)

  // 判断是否自闭合标签
  let isSelfEndTag = false
  if (startsWith(context.source, '/>')) {
    isSelfEndTag = true
  }

  advanceBy(context, isSelfEndTag ? 2 : 1)

  return {
    type: NodeType.ELEMENT,
    tag: match[1]!,
    loc: getLocation(context, start),
    content: null,
    props,
    isSelfEndTag,
  }
}

2. parseAttrs

属性的解析,是通过循环,一段一段解析的,首先要清除属性前的空白,然后用 nameReg 判断,接着用 nameRegAccurate 判断属性是什么类型的(有指令也有普通属性等,都需要在这里事先标记好)

当 name 处理完,接着便是处理 value,我们需要判断 value 前面是否有等号。

如果有,那么就需要判断引号正确与否。且在解析阶段,不需要去判断 value 是否正确,只需要找到下一个同样的引号,引号之间的内容作为字符串去处理即可。

如果引号乱用(比如一边用双引号,一边用单引号),那么在后续其他地方调用 value 时,就会报错,在解析属性阶段,无需去考虑复杂的东西,把一切当成字符串即可

/**
 * 解析属性
 *
 * @param context
 */
const parseAttrs = (context: ParseContext): NodePropType[] => {
  const props: NodePropType[] = []
  // 存放属性名称
  const nameSet = new Set<string>([])

  // 1. 清除空白
  advanceSpace(context)

  // 一般会遇到 v-bind:xx="yy" @click="zz" aa="bb" :cc="dd" ee
  // 如果是 非空非`/`和`>`且最后不能是`=`号的,那说明是属性 name。类似于匹配了上述等号前面的情况
  const nameReg = /^[^\t\r\n\f />][^\t\r\n\f />=]*/
  const nameRegAccurate = /(?:^v-([a-z0-9-]+))?(?:(?::|^\.|^@|^#)(\[[^\]]+\]|[^\.]+))?(.+)?$/i

  // 循环解析,依次取出
  while (context.source.length > 0 && !startsWith(context.source, '>') && !startsWith(context.source, '/>')) {
    const start = getPosition(context)
    // =================
    // 处理 Name
    // =================
    const name = nameReg.exec(context.source)?.[0] ?? ''
    if (!name) {
      console.error('名称不合法')
      break
    }

    if (nameSet.has(name)) {
      console.error('存在相同的属性,自动覆盖')
    }
    nameSet.add(name)

    // 名字处理完,前进解析器并清除空白,并清除
    advanceBy(context, name.length)

    // =================
    // 处理 Value
    // =================
    let value = ''
    if (/^[\t\r\n\f ]*=/.test(context.source)) {
      advanceSpace(context)
      advanceBy(context, 1) // 前进去除等于号
      advanceSpace(context)

      // 1. 先初步解析一边value
      value = parseAttributeValue(context)
      if (value === undefined) {
        return []
      }
    }

    // 清除空白
    advanceSpace(context)

    // 2. 接着判断属性是指令,还是普通字符串
    if (/^(v-[A-Za-z0-9-]|:|\.|@|#)/.test(name)) {
      // 进入则说明是指令
      const exec = nameRegAccurate.exec(name ?? '')
      // 指令名(我这里不处理插槽,所以我去掉slot)
      const dirName = exec?.[1] || startsWith(name, ':') ? 'bind' : startsWith(name, '@') ? 'on' : ''

      // 由于正则的判断,只有 v-bind:name v-on:click , @click :name 这种才有 exec?.[2]值
      // 类似于 v-if v-for 这种, exec[2] 为 undefined
      // 所以能进入,表示该指令有对应的 keyName
      if (exec?.[2]) {
        const keyName = exec?.[2]
        const keyIndex = name.lastIndexOf(exec[2])
        console.log({ source: context.source, dirName, keyName })
        console.log({ name, keyIndex, value, start })

        /**
         * 以 v-bind:age 为例子。我们的 start 是 字符v 所在的位置,
         * 而我们现在要求的是 age 的位置,且我们这里为了不影响源数据,需要用到 WithClone 方法,
         * 那么我们 age 的位置计算方式就是:当前位置 + 实际走的位置 keyIndex,
         * 当然计算位置,我们都需要判断空行或者其他,所以我们需要把这一小段文本 v-bind:age 带给 WithClone 方法。
         */
        // 获取 content 的位置
        const argStart = advancePositionWithClone(start, name, keyIndex)
        const argEnd = advancePositionWithClone(argStart, name, keyName.length)

        /**
         * const createSimpleExpression = (content: string, loc: Location): SimpleExpressionNode => {
              return {
                type: NodeType.SIMPLE_EXPRESSION,
                content,
                loc,
              }
           }
         * 
         */
        // 属性内容
        const arg = createSimpleExpression(keyName, getLocation(context, argStart, argEnd))
      }

      // 如果value表达式有左右引号,那么需要去掉引号,且位置要移动
      // 这里如果不去掉,那么举例 @click="onClick" 中的 exp 就变成了 "onClick"
      // 但我们实际需要的表达式是 onClick, 虽然你可以在后续的解析中在去掉,但先处理好最好
      if (value && value.isQuoted) {
        const valueLoc = value.loc!
        valueLoc.start.offset++
        valueLoc.end = advancePositionWithClone(valueLoc.start, value.content!)
        valueLoc.source = valueLoc.source.slice(1, -1) // 给 source 去掉左右引号
      }

      props.push({
        type: NodeType.DIRECTIVE,
        name: dirName, // 根据type来区分是 指令名 or 属性名
        // 属性 内容
        arg,
        // value 内容
        exp: createSimpleExpression(value?.content!, value?.loc!),
        loc: getLocation(context, start),
      })
    } else {
      // 3. 普通字符串处理
      props.push({
        type: NodeType.ATTRIBUTE,
        name,
        value,
        loc: getLocation(context, start),
      })
    }
  }

  return props
}

/**
 * 初步解析Value
 *
 * @param context
 * @returns
 */
const parseAttributeValue = (context: ParseContext): AttributeValue => {
  const start = getPosition(context)

  const quota = context.source[0]
  const isQuoted = quota == `"` || quota == `'`
  if (!isQuoted) {
    console.error('属性value不合法')
    return
  }

  advanceBy(context, 1) // 前进去除第一个引号
  const index = context.source.indexOf(quota)
  const content = parseTextData(context, index == -1 ? context.source.length : index)
  if (context.source[0] == quota) {
    advanceBy(context, 1) // 前进去除第二个引号
  }

  return {
    loc: getLocation(context, start),
    isQuoted,
    content,
  }
}

总结

第一次这样一点一滴翻看源码,并且自己试着码了一遍,收获很大,这种生成一种解析器的方式,让我感觉像是 C 语言的指针解析文本时候一样,一点一点前进。

里面涉及到的细节,远不是当初背面试题的那一句 用正则判断,解析元素,文本和 mustache 语法 来的那么简单。想想就可笑,之前背八股文,也背过一些源码题,确实只是应付面试罢了。当然你又不能不背,囧……

这篇文章属于是看了下源码之后,自己试着边撸边看的,自己后来测试了,大致上一样,当然细节捉襟见肘,毕竟人家一千多行代码,里面对很多文本的判断,不是我这三百多行可以比拟的,感兴趣的朋友,还是建议直接瞅源码。

看源码,我感觉还是更多锻炼思维这种东西,且大家瞅的时候,可以试着提前想想,自己的话,要如何去整。

比如在解析属性的时候,我一开始以为他会:把 <> /> 之前的文本,用 split 去除掉多余空格,这样就会生成好 属性数组,接着我在遍历数组挨个去从中抽出 namevalue

但是实际上我看了源码之后,发现他用的方式和我想的方式不同,但是莫名其妙我就是觉得他的棒,总之就是那一刻……我看到了光(doge)