likes
comments
collection
share

学习Vue2源码:解析实用的工具类函数

作者站长头像
站长
· 阅读数 11

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

这是源码共读的第24期,链接:【若川视野 x 源码共读】第24期 | vue2工具函数 - 掘金 (juejin.cn)

前言

终于是参与了这个关注了好久好久的读源码活动了,之前一直摆烂懒惰着,加之忙于找工作的事,也算是找到了并且已离职原公司,所以有了这么一个空挡去迈出这第一步。当然希望持之以恒,希望一年后的自己有所成长,希望能够达成去到一个优秀的外企工作的人生理想。

至于为什么是选择这一期起步,主要是因为个人此前的技术栈都是Vue2 + element-ui,再结合是第一篇,根据川哥的推荐就选了这一期难度偏小,曲线平缓的来作为迈出源码阅读的第一步了,那么废话到此,就开始揭开Vue源码的神秘面纱吧。

前期准备

直奔Vue2源码仓库的工具函数文件(TypeScript):vue/src/shared/util.ts at main · vuejs/vue (github.com)

或者通过github1s访问源码仓库(Flow ):util.js - vuejs/vue - GitHu…

若是认为TypeScriptFlow难读,可以阅读打包后的JS源码(JavaScript):vue/dist/vue.js at dev · vuejs/vue (github.com)

需要注意的是,TypeScript版本是在main分支上的,应该是Vue2.7用TypeScript重构了,而其他的版本都是在dev分支上,是2.6或以前的版本。

工具函数

本着能够学习TypeScript的目的,加上这些工具函数源码也并不难的因素,本文都以TypeScript(下文简称为TS)版本进行学习解读。

通用方法

因为本文不会把整个util.ts文件都解读完,一些比较简单重复的就略过,所以为了内容的完整性在此章节把源码中用到的一些通用方法先列出来。

// 判断是否为数组
export const isArray = Array.isArray

// Object原型上的toString方法,用于获取值的原始类型字符串,e.g., [object Object].
const _toString = Object.prototype.toString

emptyObject 绝对的空对象

export const emptyObject: Record<string, any> = Object.freeze({})

这个工具函数相当简单,就是冻结对象的第一层属性使得其不能修改,那么冻结一个空对象就必然能保证emptyObject这个引用一直是空对象啦。

值得注意的是,这里使用到了TS的Record<Keys, Type>工具类型,用于构造一个键类型到其他类型的映射。针对本例,在JS里面对象的键当然是string类型了,值是任意any类型。

isUndef 是否未定义

export function isUndef(v: any): v is undefined | null {
  return v === undefined || v === null
}

这个方法就是用于判断一个值是否为undefinednull,同样没什么难度。

不过有一个比较有趣的点是我注意到了它使用到了一个: v is undefined | null这个像是定义函数返回值的语句,却又不是。

一开始我还以为我看的是Flow版本的源码,就去Flow官网找函数相关的文档,没有找到。 后面才翻到在Type Guards这一篇当中,有其中一节Predicate type is consistent with the parameter type | Flow,大意应该是说规定的谓词类型起码得是参数类型的子集(那我就不懂为什么不直接限制参数类型了,理解有误的话欢迎指出)

学习Vue2源码:解析实用的工具类函数

后面发现我看的还是TS版本的源码啊!于是经过高人点拨才找到TS中对应的文档Narrowing,原来是TS的类型收窄,文档也说得相当清楚了,就是通过这个类型谓词来收窄传参的类型,在本例中就是一开始TS还认为a是any,一旦执行isUndef(a)true,那么a的类型就必然是undefined或者null了,看得出来是挺实用的。

学习Vue2源码:解析实用的工具类函数

toRawType 转成原始类型

export function toRawType(value: any): string {
  return _toString.call(value).slice(8, -1)
}

这个技巧还是有点意思的,首先_toString.call(value)这个语句会返回值的原始类型字符串,形如[object 原始类型]

接下来就是对这个字符串进行切割,毕竟[object 这部分总是一致的,这里有趣的是用到了平常较少会用到的slice函数的负数索引。众所周知,slice函数截取的是[start, end)这个范围的字符串,那么当传参是负数时,则意味着从数组末位开始计算,那么-1就是数组的最后一位了。

如此,在此例中就实现了从形如[object 原始类型]的字符串中抽取出原始类型的这一操作了。

isValidArrayIndex 是否合法的数组索引

/**
 * Check if val is a valid array index.
 */
export function isValidArrayIndex(val: any): boolean {
  const n = parseFloat(String(val))
  return n >= 0 && Math.floor(n) === n && isFinite(val)
}

这个函数就是用于判断val是否是一个合法的数组索引,那么什么是一个合法的数组索引了,查找规范找到了这里 ECMAScript® 2024 Language Specification (tc39.es)

An array index is an integer index n such that CanonicalNumericIndexString(n) returns an integral Number in the inclusive interval from +0𝔽 to 𝔽(232 - 2).

说实话,我还是有点搞不清楚到底什么是一个合法的数组索引,理解到的大概意思应该是一个介于[0, 253-1]这个区间的整数。

那么我们再来看这个函数是否满足这个需求,首先把val转成字符串再利用parseFloat将其转成浮点数,没问题。接下来就是利用n>=0 && isFinite(val)判断是值否在刚才提到的区间,利用Math.floor(n) === n判断是否为整数,一切看上去都没问题。

那么问题来了,也是困扰了我一段时间的问题:为什么第一行要使用parseFloat,而不是+Number()parseInt等等呢?反正到了isFinite(val)这一步也是要在必要时转Number的,既然能早知道是错的为何不提前结束判断呢,为什么非得是那么宽松的parseFloat呢?

不使用+Number()我理解了,因为这种强转不可靠,会把truefalsenull等转成相应的数字,而parse系列函数得到的是NaN。但不使用parseInt的原因还是不得而知1

makeMap 生成一个判断值是否在给定list里的map

/**
 * Make a map and return a function for checking if a key
 * is in that map.
 */
export function makeMap(
  str: string,
  expectsLowerCase?: boolean
): (key: string) => true | undefined {
  const map = Object.create(null)
  const list: Array<string> = str.split(',')
  for (let i = 0; i < list.length; i++) {
    map[list[i]] = true
  }
  return expectsLowerCase ? val => map[val.toLowerCase()] : val => map[val]
}

这个函数利用到了闭包来实现传入一个以,分隔的字符串,返回一个校验传参是否属于这个字符串中的其中一项的函数的功能,支持传入第二个参数规定是否期望字符串里的每一项都为小写格式(且传参大小写不敏感)。这个函数的功能类似于['a', 'b'].includes(val)

举个例子:

// 检查是否为Vue的内置标签
const isBuiltInTag = makeMap('slot,component', true)

isBuiltInTag('slot') // true
isBuiltInTag('c') // false
isBuiltInTag('Component') // true

cached 缓存函数

/**
 * Create a cached version of a pure function.
 */
export function cached<R>(fn: (str: string) => R): (sr: string) => R {
  const cache: Record<string, R> = Object.create(null)
  return function cachedFn(str: string) {
    const hit = cache[str]
    return hit || (cache[str] = fn(str))
  }
}

这个函数同样是利用到了闭包,实现的功能是传入一个普通函数,使之转化成带有缓存的函数,就避免了重复计算,可见Vue底层对于性能的极致优化。

同样的其实还有一个在本文中没提及的remove函数,这个函数竟然出乎意料地对数组移除一个元素做了一个特殊的优化2

那么话归正题,让我们看一下这个函数是如何实现的,首先在函数内声明一个cache对象,用于存放函数调用的结果,其中键是传参,值是函数调用的返回值。如此,在下次函数调用时,如果命中缓存则不进行函数调用,如果没有命中缓存则调用函数并缓存返回值。

所以这个函数最终返回了一个供开发者调用的经过"cached"处理的函数,内部实现就是上面提到的一个逻辑。

camelize

/**
 * Camelize a hyphen-delimited string.
 */
const camelizeRE = /-(\w)/g
export const camelize = cached((str: string): string => {
  return str.replace(camelizeRE, (_, c) => (c ? c.toUpperCase() : ''))
})

这个函数的作用就是把kebab-case的字符串转成camelCase的(且有缓存)。

命名规范

那么在这里先延伸讲讲什么是kebab-casecamelCase

众所周知,在编程届的一大难题就是命名。

那么除了命名本身语义带来的问题以外,还有一个问题就是命名规范(naming conventions),我们该在何时使用合适的casing,以及为什么我们程序员命名需要那么多命名规范。

其实原因就是空格 在绝大多数编程语言中都属于保留字,你无法像这样命名

let length of list = 1;

如果你这样进行命名了,程序会以空格作为分隔符对每一个部分进行tokenize,这时候 let length of list = 1 都会被程序单独处理。如此,自然也就不能如预期地运行程序了,这时候就需要通过某种方式来将单词各部分组合起来,这个某种方式就是各种命名规范了。

我们前端开发者常用到的命名规范一般有三种,除了上面提到的两种以外还有一种叫PascalCase,那么在何种场景下运用何种规范又是一个问题了。

kebab-case

kebab-case就是一个都是小写的字符串,中间的空格用-来替代,如:on-click

运用场景多是HTML模版,因为HTML模版大小写不敏感。或者你还能在URL上看到它的身影。

camelCase

camelCase就是首字母小写,随后的所有单词的首字母都大写的一种格式,如:isDisabledhandleClose

运用场景就是我们在写JS的时候普通变量或是方法的命名。

PascalCase

PascalCase就是所有单词的首字母都大写的一种格式,如:CascaderPanelAppComponent

运用场景就是像Vue的单文件组件,或者是Class的命名。

正则表达式

接下来进行这段代码实现的解析,首先定义了一个匹配kebab-case的每个单词开头的字母的正则/-(\w)/g,这里涉及到正则的点有三个。

其一是,\w匹配一个单字字符(字母、数字或者下划线)。等价于 [A-Za-z0-9_]

其二是,(x)这个是捕获括号3,用于标记记住一个捕获组(capturing group),可供正则表达式的匹配方法使用。

其三是,g这个想必是所有程序员都有所了解,就是全局搜索的标识。

replace的用法

这里用到了字符串的replace方法,其中第二个参数传入的是函数,属于比较少用到的特性。

我们就来看这一句str.replace(/-(\w)/g, (_, c) => (c ? c.toUpperCase() : '')),以str为a-bc-d为例。

replace的用法不赘述,第一个参数就是正则表达式,找到匹配的字符串进行替换。

第二个参数传入了一个函数,其中函数的第一个参数是匹配的子串,这里也就是-b-d了;

这个函数的第二个参数就是刚才提到的捕获组,这里就是bd了,函数的返回值就是要替换成的字符串,于是就实现了这么一个把kebab-case的字符串转成camelCase的需求了。

其实后面的hyphenate函数也同样以这些知识为基底,都不难的。就是实现了把camelCase字符串转成kebab-case字符串的需求。

/**
 * Hyphenate a camelCase string.
 */
const hyphenateRE = /\B([A-Z])/g
export const hyphenate = cached((str: string): string => {
  return str.replace(hyphenateRE, '-$1').toLowerCase()
})

bind 的 polyfill

/**
 * Simple bind polyfill for environments that do not support it,
 * e.g., PhantomJS 1.x. Technically, we don't need this anymore
 * since native bind is now performant enough in most browsers.
 * But removing it would mean breaking code that was able to run in
 * PhantomJS 1.x, so this must be kept for backward compatibility.
 */

/* istanbul ignore next */
function polyfillBind(fn: Function, ctx: Object): Function {
  function boundFn(a: any) {
    const l = arguments.length
    return l
      ? l > 1
        ? fn.apply(ctx, arguments)
        : fn.call(ctx, a)
      : fn.call(ctx)
  }

  boundFn._length = fn.length
  return boundFn
}

function nativeBind(fn: Function, ctx: Object): Function {
  return fn.bind(ctx)
}

// @ts-expect-error bind cannot be `undefined`
export const bind = Function.prototype.bind ? nativeBind : polyfillBind

这段代码实现上也比较简单,就是导出了一个bind函数,如果原生的可用就用原生的,否则就使用polyfill的bind

这个polyfill的bind也是用很简单的方式来实现了

  1. 定义了boundFn作为返回出去的绑定好上下文的函数
  2. 这个函数接收一系列参数,定义l来存函数调用时传入参数的个数
  3. 返回函数调用的结果,这里一大串三目运算的目的反正就是根据传入的参数个数来进行不同方式的调用
  4. 用一个内部变量_length来记录传入的原始函数的参数列表长度
  5. 返回boundFn

toArray 把类数组转成真正的数组

/**
 * Convert an Array-like object to a real Array.
 */
export function toArray(list: any, start?: number): Array<any> {
  start = start || 0
  let i = list.length - start
  const ret: Array<any> = new Array(i)
  while (i--) {
    ret[i] = list[i + start]
  }
  return ret
}

首先我们得先了解类数组(array-like)是什么,类数组就是一个有索引和length属性的对象,所以形式上很像数组,却又不是数组。

它是不可迭代的,且没有数组原型上的一些如push等方法,那么为了方便使用,一般都需要把类数组转成数组去使用。

像我们最熟知的函数的arguments就是一个典型的类数组。

在这个函数实现中我觉得最奇妙的一点是利用i这个变量来做一个反向填充,毕竟如果是一般思维的话就是直接从start开始,一个for循环遍历list进行正向填充了。

再次惊叹于Vue源码设计疯狂挤性能的操作,毕竟这样的反向填充可以省下每次循环去判断数组长度,或者就算缓存数组长度也需要多一个变量,空间依然会有一个浪费。

基于现在ES的新特性,我们转类数组可以简单方便地直接使用Array.from。一些思考,可否把这个函数是实现成下面的样子。

/**
 * Convert an Array-like object to a real Array.
 */
export function toArray(list: any, start?: number): Array<any> {
  start = start || 0
  return Array.from(list).slice(start)
}

extend 合并源对象的属性到目标对象

/**
 * Mix properties into target object.
 */
export function extend(
  to: Record<PropertyKey, any>,
  _from?: Record<PropertyKey, any>
): Record<PropertyKey, any> {
  for (const key in _from) {
    to[key] = _from[key]
  }
  return to
}

这里面的PropertyKey是真的找不到相关地方,代表什么样的类型,欢迎评论区指出。

这个函数实现很单纯,就是把源对象中所有的属性都遍历并浅拷贝到目标对象,最终返回目标对象。

Object.assign的用法相当相似,不过还是有那么点不同

  • Object.assign并不会复制源对象原型上的属性,而for in循环会
  • Object.assign会复制源对象的Symbol类型属性,而for in循环不会
  • 两者都不能复制不可枚举的属性

toObject 将对象数组合并成一个对象

/**
 * Merge an Array of Objects into a single Object.
 */
export function toObject(arr: Array<any>): object {
  const res = {}
  for (let i = 0; i < arr.length; i++) {
    if (arr[i]) {
      extend(res, arr[i])
    }
  }
  return res
}

迷之三兄弟

/**
 * Perform no operation.
 * Stubbing args to make Flow happy without leaving useless transpiled code
 * with ...rest (https://flow.org/blog/2017/05/07/Strict-Function-Call-Arity/).
 */
export function noop(a?: any, b?: any, c?: any) {}

/**
 * Always return false.
 */
export const no = (a?: any, b?: any, c?: any) => false

/* eslint-enable no-unused-vars */

/**
 * Return the same value.
 */
export const identity = (_: any) => _

希望有读完Vue源码的朋友或者大佬能指导一下这三兄弟是干嘛的,尤其是前两个函数为何需要定义这三个可选变量呢?

looseEqual 宽松相等

/**
 * Check if two values are loosely equal - that is,
 * if they are plain objects, do they have the same shape?
 */
export function looseEqual(a: any, b: any): boolean {
  if (a === b) return true
  const isObjectA = isObject(a)
  const isObjectB = isObject(b)
  if (isObjectA && isObjectB) {
    try {
      const isArrayA = Array.isArray(a)
      const isArrayB = Array.isArray(b)
      if (isArrayA && isArrayB) {
        return (
          a.length === b.length &&
          a.every((e: any, i: any) => {
            return looseEqual(e, b[i])
          })
        )
      } else if (a instanceof Date && b instanceof Date) {
        return a.getTime() === b.getTime()
      } else if (!isArrayA && !isArrayB) {
        const keysA = Object.keys(a)
        const keysB = Object.keys(b)
        return (
          keysA.length === keysB.length &&
          keysA.every(key => {
            return looseEqual(a[key], b[key])
          })
        )
      } else {
        /* istanbul ignore next */
        return false
      }
    } catch (e: any) {
      /* istanbul ignore next */
      return false
    }
  } else if (!isObjectA && !isObjectB) {
    return String(a) === String(b)
  } else {
    return false
  }
}

宽松相当的应用场景就是需要比对数组或对象他们里面的内容是否全等,由于他们都属于引用类型,比对的是地址,所以无法直接使用==关键字来进行对比。所以需要这么一个函数来对里面的内容进行递归对比来判断他们是否宽松相等。

这个函数的逻辑拆解大致如下:

  1. 如果他们本身是严格相等的,那么必然宽松相等,返回true
  2. 如果他们都是引用类型,则需要进一步比对
    1. 如果都是数组,则递归比对里面每一项是否都宽松相等,返回比对结果
    2. 如果两者都是日期类型,则利用getTime来比对时间戳是否一致,返回比对结果
    3. 如果两者都不是数组(在使用上就意味着是普通对象,当然还是会有正则这种对象),依然是比对每一个键值是否宽松相等,返回比对结果
    4. 若一个是数组,另一个不是,则必然不相等,返回false
  3. 如果都不是引用类型的,也就是说两者都是基本类型的,直接转成String来比对,返回结果
  4. 如果一个是引用类型,一个不是,那必然是false

looseIndexOf 宽松版的indexOf

/**
 * Return the first index at which a loosely equal value can be
 * found in the array (if value is a plain object, the array must
 * contain an object of the same shape), or -1 if it is not present.
 */
export function looseIndexOf(arr: Array<unknown>, val: unknown): number {
  for (let i = 0; i < arr.length; i++) {
    if (looseEqual(arr[i], val)) return i
  }
  return -1
}

这个方法就是找到传入值在数组中的哪个位置,只不过使用的是宽松相等,而原生indexOf使用的是严格相等。

hasChanged Object.is的polyfill

// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is#polyfill
export function hasChanged(x: unknown, y: unknown): boolean {
  if (x === y) {
    return x === 0 && 1 / x !== 1 / (y as number)
  } else {
    return x === x || y === y
  }
}

初看还觉得这代码很诡异,才发现它说是Object.is的polyfill。

这里就先说说Object.is===的区别,就是===会将NaNNaN视为不相等,会将+0-0视为相等。而Object.is就解决了这些问题。

但我发现Vue实现的这个polyfill比对NaN还是不相等啊,实物与卖家秀不符🤨

而在+0-0的处理上使用到了1/x这样的操作,如此,1除以+0得到的是Infinity,而1除以-0得到的是-Infinity,从而实现了+0-0的比较不相等。

总结

通过这次的阅读源码,主要是再次巩固了JS基础,再者是学习到了一点TS的东西,自己也动手查了不少文档才完成了本文的编辑。

也是因为这一篇难度不大,所以很多时间都花在如何写好一篇博客上,总共花费了整整周末两天,也算是把第一步迈出去了,道阻且长。

另外,其实本文也有提到一些尚且不了解的点,希望评论区大佬指导。如果内容有纰漏也欢迎指出。

参考文章

源码学习原文:初学者也能看懂的 Vue2 源码中那些实用的基础工具函数 - 掘金 (juejin.cn)

Footnotes

  1. 看看Vue2中"判断一个值是否为有效的数组索引"是怎么实现的-开发网 (guijs.cn)

  2. vue/src/shared/util.ts at main · vuejs/vue (github.com)

  3. 正则表达式 - JavaScript | MDN (mozilla.org)