1-Vue源码之【解析】
前言
文章基于 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
解析元素,需要要处理 标签 和 属性 ,包括其内部的子元素。
所以,需要有 parseTag
和 parseAttrs
两个方法,子元素的解析实际上跟普通解析元素一致,所以只需要递归调用 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
去除掉多余空格,这样就会生成好 属性数组,接着我在遍历数组挨个去从中抽出 name
和 value
。
但是实际上我看了源码之后,发现他用的方式和我想的方式不同,但是莫名其妙我就是觉得他的棒,总之就是那一刻……我看到了光(doge)
转载自:https://juejin.cn/post/7237514744478613559