我正在参与掘金会员专属活动-源码共读第一期,点击参与。
前言
本文主要详细介绍 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工具函数命名很语义化,大部分情况下,看到这个函数名就知道它的功能是什么了。- 每个函数的代码其实并不复杂,只是应用场景需要反复斟酌。
- 函数符合单一原则,一个函数只做一件事。