详解 Vue2 源码的基础工具函数

424 阅读5分钟
Vue

我正在参与掘金会员专属活动-源码共读第一期,点击参与

前言

本文主要详细介绍 Vue2 源码中的基础工具函数,有的朋友可能会问,为什么是 Vue2,而不是 Vue3 的?呃呃呃......主要原因是我所在公司还在用 Vue2,并且 Vue2Vue3 的基础工具函数也是有很多相同的地方。

源码准备

点击 Vue2 仓库,克隆代码到你的本地仓库中,进入到 src/shared/ 目录下,打开 util.ts 文件,这就是 Vue2 源码的基础工具函数所在的文件。

有些朋友可能不太熟悉源码当中的 TypeScript 语法,我们就需要把 TypeScript 编译成 JavaScript

  1. 打开 VS Code 的命令行终端,进入到项目的根目录,输入 pnpm i(如果没有 pnpm,需要首先执行 npm i pnpm -g)。
  2. 接着输入 pnpm run build,等待打包完成。
  3. 进入 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 中,假值其实有六个,falsenullundefined、0、''(空字符串),NaN,所以需要在 Vue 中,需要 isUndefisDefisTrueisFalse 来对真假值做精确的判断。

isPrimitive

function isPrimitive(value) {
  return (typeof value === 'string' ||
    typeof value === 'number' ||
    // $flow-disable-line
    typeof value === 'symbol' ||
    typeof value === 'boolean');
}

判断变量是否是原始值,只对字符串,数字,布尔值,symbol 作为原始值,不包括 nullundefinedbigint

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 函数其实可以用来获取任何数据类型,但我们需要根据函数名来使用这个函数的功能。

数据类型的检测方式有四种:

  1. typeof,该运算符的返回值有 numberbigintbooleanstringobject(对象,null,数组)、functionundefinedsymbol
  2. instanceof,用于检测构造函数的 prototype 属性是否出现在某个实例对象的原型链上。只能正确判断引用数据类型,而不能判断基本数据类型。
  3. constructor,该属性指向对象的构造函数,nullundefined 不能使用此方法,因为他们不是由对象构建。基本数据类型是包装类对象,可以使用 constructor 属性来判断。
  4. 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

特殊地,参数如果是 NaNisFinite()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. 第1个 #,字符 [v,对应 \W\w 之间的位置。
  2. 第2个 #,字符 e],对应 \w\W 之间的位置。
  3. 第3个 #,字符空格与 u,对应 \W\w 之间的位置。
  4. 第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 是通过 callapply 来实现的,判断参数的个数是否大于 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 种一行代码就能将类数组转换为数组的方法:

  1. 通过 call 调用数组的 slice 方法来实现转换:Array.prototype.slice.call(arrayLike)
  2. 通过 call 调用数组的 splice 方法来实现转换:Array.prototype.splice.call(arrayLike, 0)
  3. 通过 apply 调用数组的 concat 方法来实现转换:Array.prototype.concat.apply([], arrayLike)
  4. 通过 Array.from 方法来实现转换:Array.from(arrayLike)
  5. 使用扩展运算符将类数组转化成数组:[...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

总结

  1. Vue2 工具函数命名很语义化,大部分情况下,看到这个函数名就知道它的功能是什么了。
  2. 每个函数的代码其实并不复杂,只是应用场景需要反复斟酌。
  3. 函数符合单一原则,一个函数只做一件事。