前言
经常使用Vue2.0 + ElementUI进行开发,总是会遇到莫名其妙的问题,最常见的莫过于我的数据为啥没实时响应,不应该啊,当然不止如此。有了问题就找度娘,CV一下一般就解决掉了,总是这样不自行思考永远都是垃圾,对于问题一定要有自己的思考。阅读源码这个操作,总是提升最快的,可以学习别人的思想,可以深入原理,当然最厉害的是可以锻炼自己的毅力😂😂😂。
之前也有计划阅读源码,但一直都未开始,究其原因,不外乎:
- ①等我
js学好点再来; - ②等我设计模式都学好了再来;
- ③等我算法(
因为有diff算法)学好了再来; - ④等我
dom学好了再来......
现在我决定不等了,当然并不是因为等不到😇😇😇,而是有了一定的基础后,我决定换种思维方式,我为什么不能在阅读源码的过程中查漏补缺呢?像vue那么多的API,你常用的不也就那么几个,其他不常用的,还不是用到了才去翻翻看?只是你知道的多,心里有个印象,在遇到具体问题时,你的思路会更开阔。源码中包含很多知识点,一点一点探究,所以源码不只是源码这么简单,要多思考。
为什么都Vue3.0时代了,还要费劲去读Vue2.0的源码?
3.0并没有完全摒弃2.0,只是取代了一些东西- 学习作者的思想,巩固基础
- 学习了
2.0再去学3.0,更好的对比差异......
当然最重要的一点那就是,老子愿意😛😛😛。阅读源码一定要带着问题去读,不能盲目。本篇先看框架整体设计,也算作一个开篇,下篇进入正题,探究v2.0的响应式原理的具体实现。
我选择了读CDN这种集合式的,源码cdn地址:link,当下版本:v2.6.12,这应当是v2.0维护的最后一个版本了。当然你也可以分模块的阅读,Git地址:link,更清晰。
正文
整体
(function (global, factory) {
// ......
}(this, function () {
'use strict';
// 核心代码
}));
将一万多行的代码折叠起来,我们会看到,这就是一个LIFE函数,这是为避免污染全局变量常见的操作。将this这个顶级域变量赋值给global传入,不用想核心代码中肯定会用到,作用最其次也是用来在其上挂载属性,Vue对象就在上面挂载着。函数内部核心代码部分,采用严格模式,整体作为函数赋值给factory。
然后看......省略的部分:
typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() :
typeof define === 'function' && define.amd ? define(factory) :
(global = global || self, global.Vue = factory());
三目运算嵌套三目运算,牺牲可读性保持代码的简洁,我也经常写单行的if、for😂。第一层三目运算,判断exports、module是否存在,若存在就是NodeJS环境,执行factory核心代码并作为外部接口导出;否则,第二层三目运算,判断是否是AMD 异步模块,可以简单了解下requirejs,define用来定义一个模块,最后的判断就是浏览器环境了,在global(self即window对象)上挂载Vue属性,因为我们通过new Vue()使用,所以我们肯定factory()最终会返回一个函数。
于是查找代码的末尾,我们找到return Vue,全局搜索,不出所料,我们在第5073行找到了对于Vue的定义:
function Vue (options) {
if (!(this instanceof Vue)
) {
warn('Vue is a constructor and should be called with the `new` keyword');
}
this._init(options); // 实例化时的初始化操作
}
因为函数是可以被执行的特殊对象,所以我们在代码中会看到,在自身和原型上挂载属性、方法。至于哪些该挂载在自身,哪些该挂载在原型,这取决于当前属性是否需要实例化多份,这个问题与为什么 vue 的 data 要求返回一个函数?类似。可以共用的属性就放在原型上,相互影响的就放在对象自身,实例化时每个实例自行维护一份。
扩展:类型检测
上面在检测运行环境的时候使用的是typeof,我们知道js中类型检测一般有4种方式:
typeof
undefined:已在作用域中声明但还没有赋值的变量undeclared:还没有在作用域中声明过的变量可以看到var a; a; // undefined b; // ReferenceError: b is not defined typeof a; // "undefined" typeof b; // "undefined"typeof对undeclared的处理是一种特殊的安全防范机制,源码的环境检测就利用了这种机制,这也常被用来为某个缺失的功能写polyfill。
instanceof
[定义:检测构造函数的prototype属性是否出现在某个实例对象的原型链]
我深刻的记得,以前一提到instanceof,自己满脑子都是谁是谁的实例,这误解可能是以前学java时留下,所以纠正了。我可以不使用new操作符而直接改变对象的__proto__指向,也能达到使instanceof返回true的效果。手写一个instanceof:var myInstanceof = function(left, right){ // 接收的参数:left 实例对象,right 构造函数 if(left === null || right === null) return false; left = Object.getPrototypeOf(left); right = right.prototype; while(true){ // 遍历实例对象的原型链,判断是否出现过构造函数的 prototype 属性 if(left === right) return true; left = Object.getPrototypeOf(left); } }constructor
为什么constructor能够检测类型?先来看一张红宝书中的经典图:我们看到
constructor位于构造函数的prototype上,而constructor又默认指向构造函数,所以person1.constructor === Person可以做到检测类型。Object.prototype.toString.call()
这种检测是借用Object原型上的toString方法,它总是返回给定值的原始类型,可以用来弥补typeof的不足,即区分对象、数组、函数,它的返回结果总是"[object Null]"这样的。
工具函数
工具函数较多,下面挑重点探究
emptyObject
var emptyObject = Object.freeze({});
冻结一个对象,防止对象被修改。如果你有一个巨大的数组或对象,并且确信数据不会被修改,使用Object.freeze()可以让性能大幅提升,这是因为对于data或vuex里使用freeze冻结了的对象,vue不会做getter和setter的转换。并且,Object.freeze()冻结的是值,你仍然可以将变量的引用替换掉。
new Vue({
data: {
// vue不会对list里的object做getter、setter绑定
list: Object.freeze([
{ value: 1 },
{ value: 2 }
])
},
mounted () {
// 界面不会有响应
this.list[0].value = 100;
// 下面两种做法,界面都会响应
this.list = [
{ value: 100 },
{ value: 200 }
];
this.list = Object.freeze([
{ value: 100 },
{ value: 200 }
]);
}
})
再来复习一下三种保护对象的方式吧:
Object.preventExtensions()
字面意思,禁止扩展,即不可以添加新的属性。它仅阻止向自身添加属性,但属性仍然可以添加到对象原型。已有属性依旧可以修改和删除。检测是否可扩展使用Object.isExtensible()。Object.seal()
字面意思,密封,在不可扩展的基础上,将对象内部属性[[configurable]]特性将被设置成false,即不可删除属性。所以密封就是不能添加和删除属性,但可以修改。检测是否密封使用Object.isSealed()。Object.freeze()字面意思,冻结,在密封的基础上,将对象数据属性的[[writable]]设置为false,即不可修改。所以冻结就是不能增删改,但如果对象有定义[[Set]]函数,访问器属性仍然是可写的,又一个小细节。检测是否冻结使用Object.isFrozen()。
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; // 设置属性的值为 true
}
return expectsLowerCase // 如果第二可选参数为真
? function (val) { return map[val.toLowerCase()]; } // 将参数 val 转小写
: function (val) { return map[val]; } // 返回属性的值,存在返回true,否则返回undefined
}
// makeMap 的调用,每调用一次就会生成一个map对象
var isBuiltInTag = makeMap('slot,component', true); // 是否为内置标签
var isReservedAttribute = makeMap('key,ref,slot,slot-scope,is'); // 是否为保留属性
makeMap函数中使用了闭包,返回结果为一个函数可供调用。可以调用isBuiltInTag()传入字符串,用来判断是否为内置标签;调用isReservedAttribute()传入字符串,用来判断是否为保留属性。
cached
function cached (fn) { // 接收一个函数类型参数
var cache = Object.create(null); // 创建一个绝对安全的空对象
return (function cachedFn (str) { // 返回一个可供调用的函数,接收一个字符串类型参数
var hit = cache[str];
return hit || (cache[str] = fn(str)) // cache对象中存在str属性就返回对应值,否则添加str属性,其值为fn调用后的结果
})
}
cached直译缓存,缓存什么?这里是将参数fn的执行结果缓存在cache对象中,也同样使用了闭包。
var camelizeRE = /-(\w)/g; // 正则,匹配'-'加上任意一个字符
var camelize = cached(function (str) {
return str.replace(camelizeRE, function (_, c) { return c ? c.toUpperCase() : ''; })
});
camelize函数,用来驼峰化一个连字符连接的字符串,例camelize('my-component'),返回myComponent。
扩展1:replace
我们一般使用replace的时候,第二参数都传入的字符串,但第二参数支持函数类型。当传入函数类型值时是什么样子?
正则每次匹配到时都会调用该函数,这个函数的返回的字符串将作为替换文本使用。这个函数是自定义的替换规则。
这个函数的参数,分为以下两种情况:
- 当正则没有分组的时候,传进去的第一个实参是正则捕获到的内容,第二个参数是捕获到的内容在原字符串中的索引位置,第三个参数是原字符串(输入字符串)
- 当正则有分组的时候,第一个参数是总正则查找到的内容,后面依次是各个子正则查找到的内容。传完查找到的内容之后,再把总正则查找到的内容在原字符串中的索引传进(就是arguments[0]在str中的索引位置)。最后把输入字符串(就是原字符串)传进去
这里就是第二种情况,所以可以理解var camelizeRE = /-(\w)/g;为什么要加入()了,就是为了进行分组。
'my-component'.replace(/-(\w)/g, (arg1, arg2, arg3) => {
console.log(arg1, arg2, arg3)
})
// -c c 2
hyphenateRE()是上面camelize()的反操作,例camelize('myComponent'),返回my-component。
var hyphenateRE = /\B([A-Z])/g;
var hyphenate = cached(function (str) {
return str.replace(hyphenateRE, '-$1').toLowerCase()
});
提问:正则匹配中\b与\B的区别?
答:\b单词边界,如果字符的左右两边有空白字符则为单词边界;\B非单词边界,字符左右两边没有空白字符。
'that is a rabbit'.match(/\b(a)/g); // ["a"]
'that is a rabbit'.match(/\B(a)/g); // ["a", "a"]
关于-$1的疑惑,看下面扩展的第5条$n。
扩展2:replace
在replace的第二参数是字符串形式时,可以使用替换字符串插入特殊变量名。直接上MDN的内容:
| 变量名 | 代表的值 |
|---|---|
| $$ | 插入一个 "$" |
| $& | 插入匹配的子串 |
| $` | 插入当前匹配的子串左边的内容 |
| $' | 插入当前匹配的子串右边的内容 |
| $n | 假如第一个参数是 RegExp对象,并且 n 是个小于100的非负整数,那么插入第 n 个括号匹配的字符串。提示:索引是从1开始。如果不存在第 n个分组,那么将会把匹配到到内容替换为字面量。比如不存在第3个分组,就会用“$3”替换匹配到的内容 |
| $<Name> | 这里Name 是一个分组名称。如果在正则表达式中并不存在分组(或者没有匹配),这个变量将被处理为空字符串。只有在支持命名分组捕获的浏览器中才能使用 |
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; // 添加属性_length,应该会用到
return boundFn
}
这是一个bind的简单polyfill,应当是为了向下兼容。我之前也有写一些方法的模拟实现——Polyfill,里面就有bind,在call、apply、bind中难度较大的当属bind,因为bind需要考虑new操作符,被bind调用后生成的函数也是可以被new调用的。这里的polyfillBind算简单的了,直接接收函数作为第一参数,内部只简单判断参数的多少,来决定使用call或apply,当然你也可以直接使用其中一个,我猜想他这么做的原因可能是为了性能。call的性能是要优于apply的,因为apply接收的参数为类数组类型,在apply的内部应会有一个循环,你品品Math.max.apply(null, [1, 2, 3]),再品品ES5时生成安全数组(不包含空位)的方式Array.apply(null, {length: 3})。
looseEqual
function looseEqual (a, b) { // 接收两个进行比较的参数
if (a === b) { return true } // 首先判定是否严格相等,特殊情况:NaN
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) { // 二者都为Date实例
return a.getTime() === b.getTime() // 直接比较时间戳数值
} else if (!isArrayA && !isArrayB) { // 二者都不是数组,此时可能为function、{...}
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 { // 若有其他情况,返回false
/* istanbul ignore next */
return false
}
} catch (e) { // 防止意外情况发生(如对象循环引用)
/* istanbul ignore next */
return false
}
} else if (!isObjectA && !isObjectB) { // 二者都不为对象,此时为基本类型值
return String(a) === String(b) // 基本类型值采用字符串比较,将NaN也包含了进去
} else { // 若有其他情况,返回false
return false
}
}
额,这代码有点长😓😓😓。looseEqual直译宽松相等,该函数重在判断引用类型值只要内部属性相同就认定宽松相等。
repeat
var repeat = function (str, n) {
var res = '';
while (n) {
if (n % 2 === 1) { res += str; }
if (n > 1) { str += str; }
n >>= 1;
}
return res
};
这个函数可以说是ES6字符串新增的repeat方法的一个polyfill。你说说这个人你写就写呗,一个for就搞定了,你用while也没啥的,你整个>>=,你给我搞来个位运算符,增加我理解的难度,不过有一说一真的秀。
解释下这个操作符,右移指定位数后赋值,位运算符针对的是二进制,二进制右移一位对应十进制即除以2,二进制右移两位对应十进制即除以4,以此类推,2的指数次方增长。需要注意的点是,位运算符操作后的结果是整数,只取整数部分:
12.4 >> 1 // 6
12.8 >> 1 // 6
5 >> 1 // 2
所以有经验的人在截取数字整数部分的时候经常使用~~。位运算符由于是处理二进制,所以运算速度是很快的。此时来理解上述代码,传入奇数的情况下,初始就会比偶数多执行一次if (n % 2 === 1) { res += str; }会赋值一个res变量保存传入值str一份,这是结果里面奇数比偶数多一的由来。后面执行if (n > 1) { str += str; }将str以2的指数次方增长,所以后面就有了n >>= 1;,每次除以2,此长彼消,最后n为0时退出循环。
结语
慢慢来,会比较快!!!