从工具函数开始学 Vue3 源码

442 阅读3分钟

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,然后按以下步骤修改配置文件开启测试

  1. 点击左侧带小虫子的 Debugger 按钮;
  2. 修改配置文件 launch.json,增加配置的时候选择 Jest:Default jest configuration,然后需要修改program,不然会报错。
  3. 打开需要调试的文件,如 normailzeProp.spec.ts 文件,点击开始调试按钮即可进行调试。有需要的时候也可以打断点进行单步调试。

WechatIMG986.png

// 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函数对应的单元测试方法,那么先分析这个方法。

  1. 从第一个断言expect(normalizeClass('foo')).toEqual('foo')可以看出,如果是前后没有空格的字符串,最后得到的结果还是原来输入的字符串;
  2. 从第二个断言 expect(normalizeClass(['foo', undefined, true, false, 'bar'])).toEqual('foo bar')可以看出,如果输入的值是数组,需要进行遍历操作,然后对每个值进行递归处理,由于该方法只支持字符串,数组,对象,因此这个例子的最后结果就是将数组中的字符串元素进行拼接;
  3. 从第三个断言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 &amp;&amp; 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 = '&quot;'
        break
      case 38: // &
        escaped = '&amp;'
        break
      case 39: // '
        escaped = '&#39;'
        break
      case 60: // <
        escaped = '&lt;'
        break
      case 62: // >
        escaped = '&gt;'
        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. 总结

  1. 掌握源码的调试方法很重要,这样可以很方便分析每段代码的运行逻辑。另外作者一般会写大量的单元测试用例,使用好这些用例对理解源码可以说是如虎添翼。
  2. 该篇文章用时超过8个小时,比较仓促,如有错误还望指正。