1. 前言
「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战」
刚开始学习 [Vue3](https://github.com/vuejs/core) 源码的时候发现很难找到切入点,于是到网上找了很多文章和视频。比较通用的调试方法是:在目录packages/vue/dist中生成文件,然后打开packages/vue/examples中的html文件,通过在浏览器中打断点进行单步调试。顿时感觉很神奇,于是参照这种方法进行调试,不过很快就遇到难题了,通过这种方法还是很难理解代码的关联逻辑,而且不方便进行数据修改再调试。终于,最后找到了通过Jest进行Debug的方式。再看看源码,发现每个比较复杂的方法,都有通过 Jest 编写的单元测试的例子,顿时觉得这种学习源码的方式可行。由于工具函数一般依赖其他文件较少,所以先从工具函数开始学习Vue3源码。
2. 环境准备
2.1 获取项目源代码
使用命令从 Github 上获取源码,选择工具函数对应的文件目录 packages/shared
git clone https://github.com/vuejs/core.git
2.2 安装插件,修改配置
首先在 VSCode 上安装插件 Jest,然后按以下步骤修改配置文件开启测试
- 点击左侧带小虫子的
Debugger按钮; - 修改配置文件
launch.json,增加配置的时候选择Jest:Default jest configuration,然后需要修改program,不然会报错。 - 打开需要调试的文件,如
normailzeProp.spec.ts文件,点击开始调试按钮即可进行调试。有需要的时候也可以打断点进行单步调试。
// launch.json 配置文件中需要修改的内容
"program": "${workspaceFolder}/node_modules/jest/bin/jest",
3. 源码分析
进入到shared目录之后,需要重点关注src目录和__tests__目录,其中src目录是项目源码,而__tests__目录中都是对应的单元测试代码。当然,并不是所有的工具函数都有对应的单元测试代码,而是比较复杂的工具函数才有对应的单元测试代码。在__test__目录中有5个测试文件,接下来的源码分析就是选取了这5个测试文件的对应源码文件,然后进行逐一分析。
3.1 normalizeProp.ts
目前看到的源码,normalizeProp.spec.ts文件只有normalizeClass函数对应的单元测试方法,那么先分析这个方法。
- 从第一个断言
expect(normalizeClass('foo')).toEqual('foo')可以看出,如果是前后没有空格的字符串,最后得到的结果还是原来输入的字符串; - 从第二个断言
expect(normalizeClass(['foo', undefined, true, false, 'bar'])).toEqual('foo bar')可以看出,如果输入的值是数组,需要进行遍历操作,然后对每个值进行递归处理,由于该方法只支持字符串,数组,对象,因此这个例子的最后结果就是将数组中的字符串元素进行拼接; - 从第三个断言
expect(normalizeClass({ foo: true, bar: false, baz: true })).toEqual('foo baz')可以看出,如果输入的值是对象,则会进行遍历操作,当key对应的value存在的时候就会将key拼接起来并返回。
// 源码
export function normalizeClass(value: unknown): string {
let res = ''
if (isString(value)) {
res = value
} else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
const normalized = normalizeClass(value[i])
if (normalized) {
res += normalized + ' '
}
}
} else if (isObject(value)) {
for (const name in value) {
if (value[name]) {
res += name + ' '
}
}
}
return res.trim()
}
normalizeStyle函数没有对应的单元测试方法,那就自己动手写一个。expect(normalizeStyle(['color:red;fontSize:15px', { display: 'flex' }])).toEqual({ color: 'red', fontSize: '15px', display: 'flex' }),这个方法已测试断言正确。从normalizeStyle函数可以看出,如果输入的值是数组的话,就会遍历每一个值。当这个值是字符串的时候,就会调用parseStringStyle方法,按照正则表达式listDelimiterRE拆分成数组,再遍历该数组的每个元素,通过正则表达式propertyDelimiterRE进行拆分,得到结果的长度大于1就会按照 key-value 的形式返回一个对象;当这个值不是字符串的时候,就会递归调用normalizeStyle函数。最后的结果就是返回一个对象。
// 源码
export function normalizeStyle(
value: unknown
): NormalizedStyle | string | undefined {
if (isArray(value)) {
const res: NormalizedStyle = {}
for (let i = 0; i < value.length; i++) {
const item = value[i]
const normalized = isString(item)
? parseStringStyle(item)
: (normalizeStyle(item) as NormalizedStyle)
if (normalized) {
for (const key in normalized) {
res[key] = normalized[key]
}
}
}
return res
} else if (isString(value)) {
return value
} else if (isObject(value)) {
return value
}
}
const listDelimiterRE = /;(?![^(]*\))/g
const propertyDelimiterRE = /:(.+)/
export function parseStringStyle(cssText: string): NormalizedStyle {
const ret: NormalizedStyle = {}
cssText.split(listDelimiterRE).forEach(item => {
if (item) {
const tmp = item.split(propertyDelimiterRE)
tmp.length > 1 && (ret[tmp[0].trim()] = tmp[1].trim())
}
})
return ret
}
3.2 escapeHtml.ts
这个文件中最重要的就是escapeHtml函数,我们可以找到对应的单元测试文件escapeHtml.spec.ts,分析一下其中一个比较具有代表性的断言结果expect(escapeHtml("a && b")).toBe("a && b")。首先将输入的值转化成字符串,然后看正则表达式escapeRE匹配字符串的结果,匹配内容为空则返回字符串,不为空则对字符串的每个元素进行遍历,通过charCodeAt方法将其中出现的5种字符进行转义,最后得出转义后的结果。
// 源码
const escapeRE = /["'&<>]/
export function escapeHtml(string: unknown) {
const str = '' + string
const match = escapeRE.exec(str)
if (!match) {
return str
}
let html = ''
let escaped: string
let index: number
let lastIndex = 0
for (index = match.index; index < str.length; index++) {
switch (str.charCodeAt(index)) {
case 34: // "
escaped = '"'
break
case 38: // &
escaped = '&'
break
case 39: // '
escaped = '''
break
case 60: // <
escaped = '<'
break
case 62: // >
escaped = '>'
break
default:
continue
}
if (lastIndex !== index) {
html += str.slice(lastIndex, index)
}
lastIndex = index + 1
html += escaped
}
return lastIndex !== index ? html + str.slice(lastIndex, index) : html
}
3.3 codeframe.ts
该文件只有一个函数generateCodeFrame,先找到对应的单元测试文件codeframe.spec.ts。这个函数的功能比较复杂,不过通过单步调试可以大概理解它的作用。找到 source 中 start 到 end 这个范围的内容并通过下面一行的 ^ 符号指出具体的代码范围。然后按行打印出目标代码上下部分的内容。比较特别的是,在测试用例line near top中使用了方法toMatchSnapshot来生成快照,初次生成以后作为标准,后续的结果会和初次生成的快照进行对比,从而判断是否达到预期结果。
const range: number = 2
export function generateCodeFrame(
source: string,
start = 0,
end = source.length
): string {
// Split the content into individual lines but capture the newline sequence
// that separated each line. This is important because the actual sequence is
// needed to properly take into account the full line length for offset
// comparison
let lines = source.split(/(\r?\n)/)
// Separate the lines and newline sequences into separate arrays for easier referencing
const newlineSequences = lines.filter((_, idx) => idx % 2 === 1)
lines = lines.filter((_, idx) => idx % 2 === 0)
let count = 0
const res: string[] = []
for (let i = 0; i < lines.length; i++) {
count +=
lines[i].length +
((newlineSequences[i] && newlineSequences[i].length) || 0)
if (count >= start) {
for (let j = i - range; j <= i + range || end > count; j++) {
if (j < 0 || j >= lines.length) continue
const line = j + 1
res.push(
`${line}${' '.repeat(Math.max(3 - String(line).length, 0))}| ${
lines[j]
}`
)
const lineLength = lines[j].length
const newLineSeqLength =
(newlineSequences[j] && newlineSequences[j].length) || 0
if (j === i) {
// push underline
const pad = start - (count - (lineLength + newLineSeqLength))
const length = Math.max(
1,
end > count ? lineLength - pad : end - start
)
res.push(` | ` + ' '.repeat(pad) + '^'.repeat(length))
} else if (j > i) {
if (end > count) {
const length = Math.max(Math.min(end - count, lineLength), 1)
res.push(` | ` + '^'.repeat(length))
}
count += lineLength + newLineSeqLength
}
}
break
}
}
return res.join('\n')
}
3.4 looseEqual.ts
该文件最主要的就是 looseEqual 方法,对应的测试文件 looseEqual.spec.ts 中写了大量的测试用例,但代码逻辑并不复杂。首先通过三等运算符进行判断;如果是日期使用 getTime() 方法进行判断;如果是数组进行遍历,对每个元素递归调用进行判断;如果是对象,判断是否为空,键值长度是否相等,每个键及对应的值是否相等,最后将两个数变成字符串来判断是否相等。
function looseCompareArrays(a: any[], b: any[]) {
if (a.length !== b.length) return false
let equal = true
for (let i = 0; equal && i < a.length; i++) {
equal = looseEqual(a[i], b[i])
}
return equal
}
export function looseEqual(a: any, b: any): boolean {
if (a === b) return true
let aValidType = isDate(a)
let bValidType = isDate(b)
if (aValidType || bValidType) {
return aValidType && bValidType ? a.getTime() === b.getTime() : false
}
aValidType = isArray(a)
bValidType = isArray(b)
if (aValidType || bValidType) {
return aValidType && bValidType ? looseCompareArrays(a, b) : false
}
aValidType = isObject(a)
bValidType = isObject(b)
if (aValidType || bValidType) {
/* istanbul ignore if: this if will probably never be called */
if (!aValidType || !bValidType) {
return false
}
const aKeysCount = Object.keys(a).length
const bKeysCount = Object.keys(b).length
if (aKeysCount !== bKeysCount) {
return false
}
for (const key in a) {
const aHasKey = a.hasOwnProperty(key)
const bHasKey = b.hasOwnProperty(key)
if (
(aHasKey && !bHasKey) ||
(!aHasKey && bHasKey) ||
!looseEqual(a[key], b[key])
) {
return false
}
}
}
return String(a) === String(b)
}
3.5 toDisplayString.ts
该文件代码量比较少,格式化文件只有45行代码,可是对应的单元测试文件toDisplayString.spect.ts中,有20个断言的例子,对理解源码的逻辑非常有帮助。
这个函数使用到的核心方法是 JSON.stringify(),它的功能是将 Javascript 对象转成 JSON 字符串。可以传入3个参数,其中第一个参数是目标对象,第二,三个参数可选。第二个参数在转化的过程中用来处理目标对象,如果是数组,则目标对象只有存在于数组属性中的值才会被序列化成 JSON 字符串,如果是函数,则会对目标对象的每个属性进行处理。第三个参数主要是美化格式的作用,如果是数字,最大值是10,小于1则没有空格,如果是字符串,则用字符串来代替空格(字符串长度大于10则截取10个),如果为空则没有空格。
// 源码
export const toDisplayString = (val: unknown): string => {
return val == null
? ''
: isArray(val) ||
(isObject(val) &&
(val.toString === objectToString || !isFunction(val.toString)))
? JSON.stringify(val, replacer, 2)
: String(val)
}
const replacer = (_key: string, val: any): any => {
// can't use isRef here since @vue/shared has no deps
if (val && val.__v_isRef) {
return replacer(_key, val.value)
} else if (isMap(val)) {
return {
[`Map(${val.size})`]: [...val.entries()].reduce((entries, [key, val]) => {
;(entries as any)[`${key} =>`] = val
return entries
}, {})
}
} else if (isSet(val)) {
return {
[`Set(${val.size})`]: [...val.values()]
}
} else if (isObject(val) && !isArray(val) && !isPlainObject(val)) {
return String(val)
}
return val
}
下面的代码选取了官方测试用例中比较典型的一个。首先,这个二维数组构建的 map 相当于let myMap = new Map(); myMap.set(1, "foo"); myMap.set({ baz: 1 }, { foo: "bar", qux: 2 });,然后符合 isObject 并且 toString 方法没有改写,则进入到 JSON.stringify(val,replacer,2) 中,在 replacer 函数中,符合 isMap 方法,返回的结果就是最后的断言结果,当然这里面的每个属性还是会调用 replacer 函数,但不影响最后的结果。
// 单元测试代码
const m = new Map<any, any>([
[1, 'foo'],
[{ baz: 1 }, { foo: 'bar', qux: 2 }]
])
expect(toDisplayString(m)).toMatchInlineSnapshot(`
"{
\\"Map(2)\\": {
\\"1 =>\\": \\"foo\\",
\\"[object Object] =>\\": {
\\"foo\\": \\"bar\\",
\\"qux\\": 2
}
}
}"
`)
4. 总结
- 掌握源码的调试方法很重要,这样可以很方便分析每段代码的运行逻辑。另外作者一般会写大量的单元测试用例,使用好这些用例对理解源码可以说是如虎添翼。
- 该篇文章用时超过8个小时,比较仓促,如有错误还望指正。