我正在参与掘金会员专属活动-源码共读第一期,点击参与。
前言
本文主要详细介绍 Vue2
源码中的基础工具函数,有的朋友可能会问,为什么是 Vue2
,而不是 Vue3
的?呃呃呃......主要原因是我所在公司还在用 Vue2
,并且 Vue2
跟 Vue3
的基础工具函数也是有很多相同的地方。
源码准备
点击 Vue2 仓库,克隆代码到你的本地仓库中,进入到 src/shared/
目录下,打开 util.ts
文件,这就是 Vue2
源码的基础工具函数所在的文件。
有些朋友可能不太熟悉源码当中的 TypeScript
语法,我们就需要把 TypeScript
编译成 JavaScript
:
- 打开 VS Code 的命令行终端,进入到项目的根目录,输入
pnpm i
(如果没有pnpm
,需要首先执行npm i pnpm -g
)。 - 接着输入
pnpm run build
,等待打包完成。 - 进入
dist
文件夹,打开vue.js
文件,12 行到 333 行就是我们要学习的源代码。
为了统一,我们就直接学习编译之后 JavaScript
版本的源代码。
工具函数
emptyObject
var emptyObject = Object.freeze({});
使用 Object.freeze
函数生成空对象,该对象被冻结,无法做出增加属性,删除属性,修改属性值的操作。
isArray
var isArray = Array.isArray;
用来判断某个变量是否为对象。
isUndef
function isUndef(v) {
return v === undefined || v === null;
}
判断变量是否是未定义,如果变量的值为 null
,也属于未定义。
isDef
function isDef(v) {
return v !== undefined && v !== null;
}
判断变量是否已经定义,跟 isUndef
相反。
isTrue
function isTrue(v) {
return v === true;
}
判断变量是否为真值(true
)。
isFalse
function isFalse(v) {
return v === false;
}
判断变量是否为假值(false
)。
在
JavaScript
中,假值其实有六个,false
、null
、undefined
、0、''
(空字符串),NaN
,所以需要在 Vue 中,需要isUndef
,isDef
,isTrue
,isFalse
来对真假值做精确的判断。
isPrimitive
function isPrimitive(value) {
return (typeof value === 'string' ||
typeof value === 'number' ||
// $flow-disable-line
typeof value === 'symbol' ||
typeof value === 'boolean');
}
判断变量是否是原始值,只对字符串,数字,布尔值,symbol
作为原始值,不包括 null
, undefined
和 bigint
。
isFunction
function isFunction(value) {
return typeof value === 'function';
}
判断变量是否是函数。
isObject
function isObject(obj) {
return obj !== null && typeof obj === 'object';
}
判断变量是否为对象。我们知道,typeof null
的结果也是 object
,所以该函数首先排除 null
,再判断 typeof
的值是否为 object
。当然了,在这个函数中,数组类型的变量也算对象。
为什么
typeof null
的值为object
呢?在
JavaScript
最初版本中,所有值都存储在 32 位字节的单元中,每个单元包含类型标签以及真实数据。类型标签存储在每个单元的低位中,占1-3个字节,共有五种数据类型:000 代表object
,1 代表int
,010 代表double
,100 代表string
,110 代表boolean
。
null
的值在第0位到第31位全是0,因此typeof
返回object
。
toRawType
var _toString = Object.prototype.toString;
function toRawType(value) {
return _toString.call(value).slice(8, -1);
}
获取原始数据类型,Object.prototype.toString()
方法返回表示该对象的字符串。
当时使用 call
函数改变 Object.prototype.toString()
方法的 this
指向时,会返回 [object xxx]
形式的字符串,其中 xxx
表示该变量的数据类型,比如:
Object.prototype.toString.call('') // [object String]
Object.prototype.toString.call(1) // [object Number]
Object.prototype.toString.call(true) // [object Boolean]
Object.prototype.toString.call([]) // [object Array]
Object.prototype.toString.call({}) // [object Object]
所以,.slice(8, -1)
表示取得该变量数据类型的字符串。toRawType
函数其实可以用来获取任何数据类型,但我们需要根据函数名来使用这个函数的功能。
数据类型的检测方式有四种:
typeof
,该运算符的返回值有number
、bigint
、boolean
、string
、object
(对象,null
,数组)、function
、undefined
、symbol
。instanceof
,用于检测构造函数的prototype
属性是否出现在某个实例对象的原型链上。只能正确判断引用数据类型,而不能判断基本数据类型。constructor
,该属性指向对象的构造函数,null
、undefined
不能使用此方法,因为他们不是由对象构建。基本数据类型是包装类对象,可以使用constructor
属性来判断。Object.prototype.toString.call()
,可以准确地区分任何数据类型。
isPlainObject
function isPlainObject(obj) {
return _toString.call(obj) === '[object Object]';
}
isPlainObject([]) // false
isPlainObject({}) // true
判断变量是否为纯对象。
isRegExp
function isRegExp(v) {
return _toString.call(v) === '[object RegExp]';
}
isRegExp(/vue/) // true
判断变量是否为正则表达式。
isValidArrayIndex
function isValidArrayIndex(val) {
var n = parseFloat(String(val));
return n >= 0 && Math.floor(n) === n && isFinite(val);
}
判断变量是否是有效的数组索引值,数组有效的索引值是0("0"),1("1"),2("2"),3("3")....
该函数首先将参数转换为浮点数,再判读这个浮点数是否大于0,是否是整数(Math.floor(n) === n
),是否是有限数值(isFinite(val)
)。
isFinite()
是一个全局函数,用来判断被传入的参数值是否为一个有限数值,若参数是非数值,则会尝试转换为数值。因此,
isFinite('1')
,isFinite(true)
,isFinite(false)
,isFinite([])
返回值都是true
。对于另一个判断是否为有限数值的函数
Number.isFinite()
来说,它不会对参数做自动转换,任何非数值的参数返回值都是false
。特殊地,参数如果是
NaN
,isFinite()
和Number.isFinite()
都返回false
。
isPromise
function isPromise(val) {
return (isDef(val) &&
typeof val.then === 'function' &&
typeof val.catch === 'function');
}
判断变量是否为 Promise
对象,首先使用 isDef
判断变量是否已经赋值,再判断它的 then
属性和 catch
属性是否是函数。
其实这里的 isDef(val)
语句替换为 val instanceof Promise
会不会更严谨一点?
toString
function toString(val) {
return val == null
? ''
: Array.isArray(val) || (isPlainObject(val) && val.toString === _toString)
? JSON.stringify(val, null, 2)
: String(val);
}
将变量转换为字符串,如果是 null
,则返回空字符串;如果是数组或对象并且对象的 toString
方法是 Object.prototype.toString
,则用 JSON.stringify()
函数进行转换;否则,直接调用 String()
函数进行转换。
JSON.stringify()
用到的第三个参数表示缩进用的空白字符串,用于美化输出。
在这个函数中,为什么数组或对象转换为字符串的时候要用
JSON.stringify()
函数,而不是直接使用String()
呢?因为在
JavaScript
当中,引用数据类型(函数,对象,数组)转换为字符串时,默认调用的是这个对象内部的toString()
方法,除了函数以外,数组和对象调用toString()
方法返回的值都不是它本身。数组的
toString()
方法返回的是:由逗号连接所有的字符串,如:
[1,2,3].toString() // "1,2,3"
对象的
toString()
方法返回的是:[object Object]
toNumber
function toNumber(val) {
var n = parseFloat(val);
return isNaN(n) ? val : n;
}
将变量转换成数值,如果转换失败则返回原始字符串。
makeMap
function makeMap(str, expectsLowerCase) {
var map = Object.create(null);
var list = str.split(',');
for (var i = 0; i < list.length; i++) {
map[list[i]] = true;
}
return expectsLowerCase
? function (val) { return map[val.toLowerCase()]; }
: function (val) { return map[val]; };
}
传入一个以逗号分隔的字符串,并将该字符串用逗号分隔符分割成子字符串数组,作为对象的健,相应的值为 true
,第二个参数表示获取键值时,键名是否需要小写。最终返回一个函数来检测某个键是否存在这个对象中。
isBuiltInTag
var isBuiltInTag = makeMap('slot,component', true);
isBuiltInTag('slot') // true
isBuiltInTag('component') // true
isBuiltInTag('Slot') // true
isBuiltInTag('Component') // true
判断变量是否是内置的标签。
isReservedAttribute
var isReservedAttribute = makeMap('key,ref,is');
isReservedAttribute('key') // true
isReservedAttribute('ref') // true
isReservedAttribute('is') // true
isReservedAttribute('Is') // false
判断变量是否是内置的属性,属性一般都需要严格区分大小写,所以第二个参数不传 true
。
remove$2
function remove$2(arr, item) {
var len = arr.length;
if (len) {
// fast path for the only / last item
if (item === arr[len - 1]) {
arr.length = len - 1;
return;
}
var index = arr.indexOf(item);
if (index > -1) {
return arr.splice(index, 1);
}
}
}
移除数组中的某一个元素。其中第二个参数为要移除的元素,如果该元素是数组的最后一个元素,那么数组直接将 length
属性减 1;否则需要通过 splice
方法来移除该元素。
splice
是一个很消耗性能的方法,增加或删除数组中的某一项时,其他元素都要移动位置。据了解,在 axios 源码中,移除数组的某个元素的操作是:将这个元素设置为
null
。当遍历数组,遇到null
的元素就不执行相关操作,因此也起到了移除元素的效果,并且性能更好。
hasOwn
var hasOwnProperty = Object.prototype.hasOwnProperty;
function hasOwn(obj, key) {
return hasOwnProperty.call(obj, key);
}
检测是否是自己的属性,主要通过 Object.prototype.hasOwnProperty()
方法来判断该属性是否对象的自身属性而不是原型链上的属性。
cached
function cached(fn) {
var cache = Object.create(null);
return function cachedFn(str) {
var hit = cache[str];
return hit || (cache[str] = fn(str));
};
}
利用闭包特性,缓存数据。fn
参数表示 cache
对象的属性值是如何得出的;cached
返回一个函数,该函数的功能是获取属性值,属性名为传入的参数 str
,如果属性值存在,则直接返回,否则调用 fn
函数获取属性值。
camelize
var camelizeRE = /-(\w)/g;
var camelize = cached(function (str) {
return str.replace(camelizeRE, function (_, c) { return (c ? c.toUpperCase() : ''); });
});
连字符转小驼峰,比如:on-click
转为 onClick
。
capitalize
var capitalize = cached(function (str) {
return str.charAt(0).toUpperCase() + str.slice(1);
});
首字母转大写。
hyphenate
var hyphenateRE = /\B([A-Z])/g;
var hyphenate = cached(function (str) {
return str.replace(hyphenateRE, '-$1').toLowerCase();
});
小驼峰转连字符,比如:onClick
转为 on-click
。
\B
是非单词边界,在理解\B
是什么匹配逻辑之前,首先需要理解\b
是什么。
\b
是单词边界,匹配逻辑是\w
与\W
之间的位置,也包括\w
与^
之间的位置,和\w
与$
之间的位置。比如:
const res = "[vue] util".replace(/\b/g, '#'); console.log(res) // "[#vue#] #util#"
我们分析一下,这四个
#
是怎么来的
- 第1个
#
,字符[
与v
,对应\W
与\w
之间的位置。- 第2个
#
,字符e
与]
,对应\w
与\W
之间的位置。- 第3个
#
,字符空格与u
,对应\W
与\w
之间的位置。- 第4个
#
,字符l
与结尾,对应\w
与$
之间的位置。所以
\B
就是\b
的反义词,在字符串的所有位置中,除去\b
的位置,剩下的就是\B
的。具体来说,匹配逻辑就是\w
与\w
,\W
与\W
,\W
与^
,\W
与$
之间的位置。
polyfillBind
function polyfillBind(fn, ctx) {
function boundFn(a) {
var 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, ctx) {
return fn.bind(ctx);
}
// @ts-expect-error bind cannot be `undefined`
var bind$1 = Function.prototype.bind ? nativeBind : polyfillBind;
bind 的 polyfill
,兼容低版本浏览器不支持原生的 bind
函数。polyfillBind
是通过 call
和 apply
来实现的,判断参数的个数是否大于 1 来决定是用 call
还是 apply
,单个参数调用 call
性能会更好。
更具体的 bind
函数实现原理,可以参考文章 —— 手撕 call、apply、bind 函数。
toArray
function toArray(list, start) {
start = start || 0;
var i = list.length - start;
var ret = new Array(i);
while (i--) {
ret[i] = list[i + start];
}
return ret;
}
把类数组转成真数组,参数 start
表示从哪个位置开始,默认从 0 开始。
大部分情况下,我们并不需要指定从哪个位置开始将类数组转换为数组,而是想要直接做转换。以下给大家介绍 5 种一行代码就能将类数组转换为数组的方法:
- 通过
call
调用数组的slice
方法来实现转换:Array.prototype.slice.call(arrayLike)
- 通过
call
调用数组的splice
方法来实现转换:Array.prototype.splice.call(arrayLike, 0)
- 通过
apply
调用数组的concat
方法来实现转换:Array.prototype.concat.apply([], arrayLike)
- 通过
Array.from
方法来实现转换:Array.from(arrayLike)
- 使用扩展运算符将类数组转化成数组:
[...arrayLike]
extend
function extend(to, _from) {
for (var key in _from) {
to[key] = _from[key];
}
return to;
}
const obj = { name: 'jack', age: 18 };
const extendObj = extend(data, { name: 'rose' });
console.log(obj); // { name: "rose", age: 18 }
console.log(extendObj); // { name: "rose", age: 18 }
console.log(obj === extendObj); // true
合并对象,将第二个参数对象的属性合并到第一个参数对象中。不过看起来跟 Object.assign()
方法的功能是一样的。
toObject
function toObject(arr) {
var res = {};
for (var i = 0; i < arr.length; i++) {
if (arr[i]) {
extend(res, arr[i]);
}
}
return res;
}
toObject(['vue', 'vuex'])
// {0: 'v', 1: 'u', 2: 'e', 3: 'x'}
将数组转换为对象来表示。
noop
function noop(a, b, c) { }
空函数
no
var no = function (a, b, c) { return false; };
始终返回 false
。
identity
var identity = function (_) { return _; };
返回参数本身。
genStaticKeys$1
function genStaticKeys$1(modules) {
return modules
.reduce(function (keys, m) {
return keys.concat(m.staticKeys || []);
}, [])
.join(',');
}
生成静态属性。传入一个对象数组,将数组中每个对象的 staticKeys
属性值进行合并,最后返回由逗号连接的所有 staticKeys
属性值。
looseEqual
function looseEqual(a, b) {
if (a === b)
return true;
var isObjectA = isObject(a);
var isObjectB = isObject(b);
if (isObjectA && isObjectB) {
try {
var isArrayA = Array.isArray(a);
var isArrayB = Array.isArray(b);
if (isArrayA && isArrayB) {
return (a.length === b.length &&
a.every(function (e, i) {
return looseEqual(e, b[i]);
}));
}
else if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
else if (!isArrayA && !isArrayB) {
var keysA = Object.keys(a);
var keysB = Object.keys(b);
return (keysA.length === keysB.length &&
keysA.every(function (key) {
return looseEqual(a[key], b[key]);
}));
}
else {
/* istanbul ignore next */
return false;
}
}
catch (e) {
/* istanbul ignore next */
return false;
}
}
else if (!isObjectA && !isObjectB) {
return String(a) === String(b);
}
else {
return false;
}
}
由于引用数据类型在用双等号(==
)或三等号(===
)进行比较时,比较的是指针地址,所以如果两个内容看起来完全一样的对象变量,都是不相等的。
const obj1 = {a: 1};
const obj2 = {a: 1};
obj1 == obj2 // false
obj1 === obj2 // false
而 looseEqual
函数内部对数组,日期,对象进行递归比较,如果内容上完全相等,那么两个变量就是相等的。
looseIndexOf
function looseIndexOf(arr, val) {
for (var i = 0; i < arr.length; i++) {
if (looseEqual(arr[i], val))
return i;
}
return -1;
}
返回 looseEqual
函数判断两个内容值相等时的索引值。用于对象数组需要找到某个对象的索引的场景。
once
function once(fn) {
var called = false;
return function () {
if (!called) {
called = true;
fn.apply(this, arguments);
}
};
}
const fn = once(function(){
console.log('只输出一次');
});
fn() // '只输出一次'
fn() // 什么都不输出
确保函数只执行一次。利用闭包特性,存储状态。
hasChanged
function hasChanged(x, y) {
if (x === y) {
return x === 0 && 1 / x !== 1 / y;
}
else {
return x === x || y === y;
}
}
Object.is()
方法的 polyfill
,兼容低版本浏览器不支持原生的 Object.is()
方法。
注意,hasChanged
函数内部的逻辑是和 Object.is()
相反的,即 hasChanged
函数返回 true
等于 Oeject.is()
方法返回 false
。
总结
Vue2
工具函数命名很语义化,大部分情况下,看到这个函数名就知道它的功能是什么了。- 每个函数的代码其实并不复杂,只是应用场景需要反复斟酌。
- 函数符合单一原则,一个函数只做一件事。