本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
对于工具函数来说,我觉得一般来说有以下几个特点:
- 复用性高
- 适用场景广
- 纯函数
- 可读性好
- 不轻易改动
在之前的 Vue2.x 中有看过一部分的源码,其中就包含了工具函数的部分,这次参加若川大神的源码共读,再一次重温了一边 Vue3 中的工具函数。这里为什么要说重温呢?因为发现其实在 Vue2.x 和 Vue3 中的工具函数,其实都大同小异。不仅如此,我们在平时项目中,或者使用一些其他的第三方组件或者插件的时候,也会经常碰到类似的一些工具函数,这也是文章开头我觉得会有那几个特点的原因之一。
这些小而精的基础函数,在通过拼装组合之后,会产生一些用来解决复杂应用场景的“神兵利器”,而这无论在 React 里还是在 Vue 里,都很符合它们组件化的编程思想。
这些个工具函数中,我们可以将其划分为三大类:
-
判断类:有大多数的函数都是以
is开头,这样的命名方式也让我们更加清楚的知道这种形式的函数,一般情况下都是用来做判断的,它的返回值不是true就是false,当然has形式的函数也是; -
值操作
- 转换数据类型:比如
toNumber - 删除操作:比如删除数组项的
remove - 合并操作:比如合并对象的
extends - ......
- 转换数据类型:比如
-
其他
- 定义初始值:比如空函数、空数组等
- 可跨端获取全局对象
- 这些基础方法,在平常中很不起眼,但是不可否认的是它们是一些强大的 API 的基石,如同房子的地基一般,毫不起眼但又必不可少。
延伸一下,我们还可以在上述基础上去总结一些经常会用到的校验类工具(比如表单校验)、高阶函数、组合和管道等等。
那么有了这么多好的方法,我们也应该发挥出我们最常用的 CV 大法,创建出自己的工具库,在平时的工作中或者学习中,慢慢积累,先临摹再创新,不但可以提供开发效率,还可以提升自己的专业水平,一举两得。
开读
开源项目一般都能在 README.md 或者 .github/contributing.md 找到贡献指南。
而贡献指南写了很多关于参与项目开发的信息。比如怎么跑起来,项目目录结构是怎样的。怎么投入到开发,需要哪些知识储备等。
一般我们可以在项目目录结构描述中,可以找到对应的模块。这次我们需要学习的是 shared 模块中的源码,对应的文件路径是:vue-next/packages/shared/src/index.ts 。
工具函数
在 vue-next/packages/shared/src/index.ts 中,可以查看使用函数的位置。
1. babelParserDefaultPlugins babel 解析默认插件
/**
* List of @babel/parser plugins that are used for template expression
* transforms and SFC script transforms. By default we enable proposals slated
* for ES2020. This will need to be updated as the spec moves forward.
* Full list at https://babeljs.io/docs/en/next/babel-parser#plugins
* 用于模板表达式转换和SFC脚本转换的@babel/parser插件列表。默认情况下,我们启用计划用于ES2020的提案。这将需要随着等级库的前进而更新。
*/
const babelParserdefaultPlugins = [
'bigInt',
'optionalChaining',
'nullishCoalescingOperator'
]
这里就是几个默认的解析插件,根据注释我们可以知道,它们的作用是用来解析模版和SFC脚本。
那么对于模版我们是很熟悉的了,那 SFC 脚本是什么呢?
其实 SFC 就是我们经常说的「单文件组件」,具体可以看 Vue loader 的官方文档Vue 单文件组件(SFC)规范。
2. EMPTY_OBJ 空对象
const EMPTY_OBJ = (process.env.NODE_ENV !== 'production')
? Object.freeze({})
: {};
这个方法我们从名称就可以看出来,是用来创建空对象的。
这里的 process.env.NODE_ENV 是 node 项目中的一个全局的环境变量,一般会有:development 和 production 两种,当然也可以自定义其他的环境变量(在项目根目录下使用 .env 文件),比如测试环境 .env.test 等等。
表达式中当当前的环境不是生产环境时,我们使用 Object.freeze() API 来创建一个空对象,反之使用面向字面量的形式创建一个空对象。
那这两者的区别也很明显,当我们使用 Object.freeze() 创建对象时,创建的是冻结对象,而冻结对象的最外层是无法进行修改的。这就导致了如果我们使用这个 API 创建了一个空对象,那就无法再对这个空对象进行属性的添加和修改;而如果不是一个空对象,那么可以对里层的数据进行修改,但是无法修改最外层的。如下:
const EMPTY_OBJ_1 = Object.freeze({});
EMPTY_OBJ_1.name = '小道士';
console.log(EMPTY_OBJ_1.name); // undefined
const EMPTY_OBJ_2 = Object.freeze({ props: {title: 'Coder杂谈' }})
EMPTY_OBJ_2.props.name = '小道士';
EMPTY_OBJ_2.props2 = 'props2';
console.log(EMPTY_OBJ_2.props.name); // '小道士'
console.log(EMPTY_OBJ_2.props2); // undefined
console.log(EMPTY_OBJ_2);
/**
* {
* props: {
* title: 'Coder杂谈',
* name: '小道士'
* }
* }
*/
3. EMPTY_ARR 空数组
const EMPTY_ARR = (process.env.NODE_ENV !== 'production')
? Object.freeze([]) : []
跟上一个工具函数是一样的,在生产环境以外的环境,我们不能对这个空数组进行添加数据项和更改 length 的操作;
const EMPTY_ARR = Object.freeze([]);
EMPTY_ARR.push(1); // 报错
EMPTY_ARR.length = 3;
console.log(EMPTY_ARR.length); // 0
4. NOOP 空函数
const NOOP = () => { };
其实刚开始看很多人是很懵逼的,不知道为啥要定义一个空函数出来,其实很多库的源码中都有这样的定义函数,比如 jQuery、underscore、lodash 等,使用的场景有:
- 方便判断
- 方便压缩
const instance = {
render: NOOP
}
// 条件
const dev = true;
if (dev) {
instance.render = function () {
console.log('render')
}
}
// 可以用作判断
if (instance.render === NOOP) {
console.log('i');
}
// 方便压缩
// 如果是 function () {},不方便压缩代码
5. NO 永远返回 false 的函数
/**
* Always return false
*/
const NO = () => false;
// 除了压缩代码的好除外,一直返回 false
6. isOn 判断字符串是不是 on 开发,并且 on 后首字母不是小写字母
这个方法的作用可想而知是很大的,因为我们可能需要它来完成一些是否是自定义方法的校验,代码如下:
const onRE = /^on[^a-z]/;
const isOn = (key) => onRE.test(key);
isOn('onChange'); // true
isOn('onchange'); // false
isOn('on3Change'); // true
onRE 是一个正则表达式,这里 ^ 表示以 on 开头,而在中括号中,^ 表示非的意思,所以中括号里的内容表示不能是小写字母。
7. isModelListener 监听器
判断字符串是不是以 onUpdate: 开头
const isModelListener = (key) => key.startsWith('onUpdate:');
// exp
isModelListener('onUpdate:change'); // true
isModelListener('1onUpdate:change'); // false
startsWith 方法是 ES6 中新增的字符串方法,可以查看阮一峰老师的《ES6 入门》。
8. extend 合并
这个方法其实就是 Object.assign :
const extend = Object.assign;
// exp
const data = { name: 'cecil' };
const dataExtend = extend(data, { title: 'Coder杂谈' });
console.log(data); // { name: 'cecil', title: 'Coder杂谈' };
console.log(dataExtend); // { name: 'cecil', title: 'Coder杂谈' };
console.log(data === dataExtend); // true
这里给大家解释一下,因为我们知道 Object.assign() 方法呢,是会把所有可枚举属性的值从一个或者多个源对象分配到目标对象。返回合并后的目标对象。
const target = { a: 1, b: 2 }; // 目标对象
const source = { b: 4, c: 5 };
// 返回合并后的目标对象
const returnedTarget = Object.assign(target, source);
console.log(target);
// expected output: Object { a: 1, b: 4, c: 5 }
console.log(returnedTarget);
// expected output: Object { a: 1, b: 4, c: 5 }
9. remove 移除数组的一项
const remove = (arr, el) => {
const i = arr.indexOf(el);
if (i > -1) {
arr.splice(i, 1);
}
}
这个就很好理解,当数组中确实拥有某一项值的时候,我们获取到对应的下标,然后使用数组方法 splice 方法将其删除掉即可。但是 splice 其实是一个很耗费性能的方法。因为删除掉数组中的某一项时,后面的元素都要移动位置。
10. hasOwn 是不是自己本身所拥有的属性
这个方法其实还是 Object 的内置方法,只不过这里我们将其赋值给一个变量而已。
const hasOwnProperty = Object.prototype.hasOwnProperty;
const hasOwn = (val, key) => hasOwnProperty.call(val, key);
这里我们需要注意一点:这个 API 只找自身属性,而不是通过原型链向上查找。我们来看几个例子:
hasOwn({__proto__: { a: 1 }}, 'a') // false
hasOwn({ a: undefined }, 'a') // true
hasOwn({}, 'a') // false
hasOwn({}, 'hasOwnProperty') // false
hasOwn({}, 'toString') // false
11. isArray 判断数组
这个方法顾名思义,判断是不是数组,其实除了在 vue3.x 源码之外,我们也可以在 vue2.x、react、jquery、lodash源码中看到类似的方法封装。
const isArray = Array.isArray;
// exp
isArray([]); // true
// 使用 instanceof 会在一些特殊情况下不准确
const fakeArr = { __proto__: Array.prototype, length: 0 };
isArray(fakeArr); // false
fakeArr instanceof Array; // true
因为 instanceof 这个方法实际上是根据原型属性来检查的,如果构造函数的原型属性出现在某个实例对象的原型链上,那么就返回 true。那上述例子就很一目了然了,我们定义的 fakeArr 对象里重新覆盖了默认的 __proto__ 对象,将它的值赋值为 Array.prototype 。这刚好就符合 instanceof 的判断逻辑,所以最终返回了 true。
12. isMap 判断是不是 Map 对象
这个怎么实现呢?我记得我之前有过一篇博客是讲怎么实现一个功能完整的 typeof,在这篇博客里最终的产物其实就可以运用到这里,博客地址。
const isMap = (val) => toTypeString(val) === '[object Map]';
// exp
const map = new Map();
const p = { p: 'Hello World' };
map.set(o, 'content');
map.get(o); // content
isMap(map); // true
isMap(p); // false
Map 是 ES6 新增的数据结构。它和 Object 对象可以说是孪生兄弟,都是键值对的集合,那既然都是这种集合,它与 Object 又有何不同呢?首先 Map 中“键”的范围不再仅限于字符串,各种类型的值包括对象都是可以作为键的。对于 Map 结构的数据我们会另开一章进行学习。
13. isSet 判断是不是 Set 对象
const isSet = (val) => toTypeString(val) === '[object Set]';
Set 数据结构也是 ES6 新增的,我们可以理解它为一个类数组结构的值,只不过里面的成员是唯一的。在平常我们可以使用它和 Array.from 来配合使用做数组去重的操作。
14. isDate 判断是不是 Date 对象
const isDate = (val) => val instanceof Date;
// 其实这里这种方法来判断是不太准的,比如我们之前看到的 isArray 里的例子。
// 但是一般也是够用的。
15. isFunction 判断是不是函数
const isFunction = val => typeof val === 'function';
// 这种判断函数的方式是最简单粗暴,兼容性也是比较好的,当然我们也可以使用 toTypeString 方法来进行判断,这个方法会在后续有说明
16. isString 判断是不是字符串
const isString = val => typeof val === 'string';
17. isSymbol 判断是不是 symbol
const isSymbol = val => typeof val === 'symbol';
Symbol 类型也是 ES6 新增的,它属于原始数据类型,表示独一无二的值。
18. isObject 判断是不是对象
const isObject = val => val !== null && typeof val === 'object';
这里为什么我们会先定义 val !== null 呢?因为 typeof null 得到的值其实就是 object,具体原因也可以到我的博客中去查看,链接地址。
19. isPromise 判断是不是 Promise
const isPromise = (val) => {
return isObject(val) && isFunction(val.then) && isFunction(val.catch);
}
其实我们是不是也可以使用之前使用的 toTypeString 呢?
const isPromise = val => toTypeString(val) === '[object Promise]';
20. objectToString 对象转字符串
const objectToString = Object.prototype.toString;
21. toTypeString 判断数据类型
这个函数的作用是结合上一个函数对数据类型做一个判断和输出,最终得到一个准确的数据类型字符串。
const toTypeString = value => objectToString.call(value);
22. toRawType 获取最终的数据类型值
const toRawType = (value) => toTypeString(value).slice(8, -1)
这个函数我之前也写过,但是我是直接将以上三个方法都链接在一起写的。使用它我们能得到类似 Array String 的字符串,从而得知参数的数据类型究竟是什么。
为啥我们会写这样一个方法呢?之前我们有说过,typeof null 的值为 object ,这显然是不准确的,当然,这也是其中一个较为典型的不准确的例子,其他的就不一一赘述了。所以我们使用这样一个方法来准确的获取某个值的数据类型。
23. isPlainObject 判断是不是纯粹的对象
const isPlainObject = (value) => toTypeString(value) === '[object Object]'
24. isIntegerKey 判断是不是数字型的字符串 key
const isIntegerKey = key => isString(key) &&
key !== 'NaN' &&
key[0] !== '-' &&
'' + parseInt(key, 10) === key;
25. makeMap
看到这个函数名的时候我们会天真的认为,这可能是一个生成 Map 类型的方法。其实不是,这其实就是一个生成对象的方法。来看下代码:
function makeMap(str,expectsLowerCase) {
const map = Object.create(null);
const list = str.split(',');
for (let i = 0; i < list.length; i++) {
map[list[i]] = true
}
return expectsLowerCase ? val => !!map[val.toLowerCase()] : val => !!map[val];
}
const isReservedProp = makeMap(
',key,ref,' +
'onVnodeBeforeMount,onVnodeMounted,' +
'onVnodeBeforeUpdate,onVnodeUpdated,' +
'onVnodeBeforeUnmount,onVnodeUnmounted'
)
// 保留的属性
isReservedProp('key'); // true
isReservedProp('ref'); // true
isReservedProp('onVnodeBeforeMount'); // true
isReservedProp('onVnodeMounted'); // true
isReservedProp('onVnodeBeforeUpdate'); // true
isReservedProp('onVnodeUpdated'); // true
isReservedProp('onVnodeBeforeUnmount'); // true
isReservedProp('onVnodeUnmounted'); // true
根据函数体我们可以知道,我们需要传进来一个以逗号为分隔符的字符串进来,这是第一个参数,第二个参数表示我们是否要在后面的返回函数中使用大小写。
然后这个函数最终的返回值是一个函数,而这个函数的作用是判断之后传进来的值,是否在 map 对象中。
26. cacheStringFunction 缓存
这个函数相对比较复杂一点
const cacheStringFunction = fn => {
const cache = Object.create(null);
return ((str) => {
const hit = cache[str];
return hit || (cache[str] = fn(str));
})
}
27. hasChanged 判断是不是有变化
const hasChanged = (val, oldVal) => !Object.is(val, oldVal);
这里需要注意两个点:
- Obect.is 认为 +0 和 -0 不是同一个值
- Object.is 认为 NaN 和 本身 相比 是同一个值
28. invokeArrayFns 执行数组中的函数
const invokeArrayFns = (fns, arg) => {
for (let i = 0; i < fns.length; i++) {
fns[i](arg)
}
}
29. def 定义对象属性
const def = (obj, key, value) => {
Object.defineProperty(obj, val, {
configurable: true,
enumerable: false,
value
})
}
用过 vue2.x 版本的同学都知道,vue2.x 中的响应式原理底层就是使用这个 API 来实现的。而要详细了解这个 API 的同学,可以移步到这片文章中去:Vue2.x 响应式原理
30. toNumber 转数字
const toNumber = val => {
const n = parseFloat(val);
return isNaN(n) ? val : n
}
31. getGlobalThis 全局对象
let _globalThis;
const getGlobalThis = () => {
return (_globalThis ||
(_global =
typeof globalThis !== undefined
? globalThis
: typeof self !== undefined
? self
: typeof window !== undefined
? window
: typeof global !== undefined
? global
: {}))
}
我们来看看这个函数怎么用。
首先,_globalThis 肯定是没有的,所以会执行到下面的三元表达式中去。
先判断 globalThis 是否存在,如果不存在我们就去检测 self。
globaThis 提供了一个标准的方式来获取不同环境下的全局 this 对象。
self 是在 Web Worker 中的全局对象,而如果这个对象也不存在,那么我们继续检测 window 对象。若 window 也不存在,那继续检测查找 Node 环境的全局对象 global,如果都不存在,那么就返回一个空对象。
再次执行时,因为 _globalThis 已经被赋值了,所以就直接返回了这个值,下面的逻辑就不会再执行了。