本文地址:www.kilig.xyz/blog?id=687…
之前一直想梳理梳理Vue的源码(之所以梳理Vue源码而不是其他的,一是开发主要用的还是Vue,对内部的一些原理也接触过一些,但都是碎片化的,不够系统,另外还是因为Vue的源码阅读难度相对来说还是低一点,也有很多可以参考的文章文档),迟迟未动笔,趁着最近感觉学习有点无从下手,干脆把这件事做了,毕竟,Vue3都出了
正文
如何阅读Vue源码,网上相关的文章和建议很多,但不一定适合自己,打算将这个系列以通过阅读源码实现一个简单版Vue2的方式长期写下去,后续如果有什么更好的方式,再去变通。
为什么直接从响应式部分的代码入手,一方面Vue的响应式已经老生常谈,相关的文章很多,但很多细节问题有很多分歧,譬如为何 数组通过索引更新数据视图不更新等等,有些东西自己不去实践,谈起来总会底气不足,另一方面的,自己确实也没有什么阅读的思路,从最熟悉的入手,效率也会高一点。
项目环境
打包器,Vue使用的是 rollup ,具体的用法配置之类的就不在这里说了,网上也有很多介绍的文章,总之它就是一个JS 的模块打包器,但更适合应用于类库这种场景。
类型检查,Vue采用了facebook的 Flow 来做静态类型检查。之所以采用 Flow 而非 TypeScript,大概Flow更符合Vue2轻便的特性,同时Flow 也更容易迁移。类型检查这部分内容在阅读过程中不去考虑,尽量去关注一些核心的代码逻辑。
初始化
Vue初始化很简单,就用官网入门教程里的例子 ,简洁明了。
用法如下,传入参数,实例化一个Vue
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
<body>
<div id="app">
{{ message }}
</div>
</body>
<script>
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
</script>
</html>
控制台打印一下上述代码初始化的全局的Vue
这里主要关注一下Vue.$options,这个就是我们实例化Vue时候的传参
入口文件 src/core/instance/index.js
import { initMixin } from './init'
// 声明 构造函数Vue
function Vue (options) {
// 需通过new 来实例化,否则抛出错误
if ( !(this instanceof Vue )) {
throw('Vue is a constructor and should be called with the `new` keyword')
}
/*
初始化
Vue.prototype._init 方法定义在 initMixin中
*/
this._init(options)
}
initMixin(Vue)
export default Vue
这里的代码没有什么好说的,但是在看这部分内容的代码的时候因为这个 _init 方法对实例化对象的流程产生了一些疑问,做了一个思考,可以稍记录一下
这里的 this 是new Vue后的实例本身,那就意味着构造函数在执行时,new的实例已经声明并且分配空间了,而构造函数只是负责为该实例属性做一些赋值操作。
这个属于基础知识,但是之前没有想过这个问题,想当然觉得实例化的流程应该是
调用执行构造函数 =>声明及初始化=>生成实例
回到正题
响应式实现
数据劫持
开始进入数据劫持监听实现
// src/core/instance/init.js
import { initState } from './state'
export function initMixin( Vue ) {
Vue.prototype._init = function (options) {
const vm = this
vm.$options = options
// 初始化数据
initState(vm)
}
}
下面来完成 初始化data 这一步
初始化data,初始化了什么
- 获取 data
获取我们传给构造函数的data
- 将data 转化成对象
一般options中 data定义有两种方式:对象字面量和函数(使用组件时,data必须是函数)
var app = new Vue({ el: '#app', //对象字面量 data: { message: 'Hello Vue!' } }) var app = new Vue({ el: '#app', // 函数 data (){ return { message: 'Hello Vue!' } } })至于这里使用组件时,data为什么必须是函数,先不讨论,等后面涉及到的时候再去讨论
- 劫持data,并对data进行监听
这个过程的代码如下
// src/core/instance/state.js
import { observe } from '../observe/index'
export function initState(vm) {
const opts = vm.$options
if (opts.data) {
// 初始化 data
initData(vm)
}
}
function initData (vm) {
// 将 data 添加到 vm 上,可通过 vm._data 修改 data
let data = vm.$options.data
// 将 data 存储为对象
data = vm._data = typeof data === 'function'
? data.call(vm, vm)
: data || {}
// 劫持监听 data 对象 (Vue的响应式原理)
observe(data)
}
实现 observe(使用 Object.defineProperty)
// src/core/observe/index.js
export function observe (){
return new Observe()
}
class Observe{
constructor(value){
// 遍历所有属性
this.walk(value)
}
// 遍历所有属性,并为这些属性添加 getter 和 setter
walk(obj){
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
// 将该对象的属性定义为响应式数据 (getter,setter)
defineReactive(obj,keys[i])
}
}
}
function defineReactive(obj,key) {
let val = obj[key]
// 多层嵌套对象默认深层监听(即递归劫持嵌套对象的所有属性)
observe(val)
Object.defineProperty(obj,key,{
enumerable: true,
configurable: true,
get(){
console.log(`获取了数据${key}:`,val)
return val
},
set(newVal){
console.log(`更改了数据${key}:`,val)
if (newVal === val) {
return
}else{
val = newVal
}
// 继续劫持数据(当改变该属性的类型,赋值为对象时)
observe(newVal)
}
})
}
以上代码,完成了简单的数据类型的劫持。
对数组类型的特殊处理
为什么是简单的数据类型的劫持呢,这里有一个问题(因为刚开始看源码时没有注意到这个细节,所以只简化出了一个这样简单的数据劫持的逻辑),在Vue开发过程中,经常会涉及到属性类型为 数组 的响应式应用场景。Vue监测不到通过数组索引的方式改变数据的行为,而我们上述实现数据劫持的代码,是可以劫持数组类型的,我们可以写个例子测试一下
function observeArr(obj,key,value) {
let val = value
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
console.log('读取了值:', value)
return value
},
set(newVal) {
if (newVal === value || (newVal !== newVal && value !== value)) {
return
} else {
console.log("更改了值:", newVal)
value = newVal
}
}
})
}
observeArr(testArr, '2', 9)
所以,Object.defineProperty 是可以劫持类型为 Array 的数据
很明显,Vue对 数组 类型的数据做了特殊处理,并且并未对其设置 setter/getter。为什么做这种处理?
假设我们根据业务需求,定义了这样的 data 类型
data: {
table1: [
[ 1,
[ [2,3,4,5,6],[ [8,9,10], [10,11,12] ] ],
13,
14
],
[15,16]
]}
数组嵌套数组嵌套数组,无限套娃。或者数组的长度很大(这种场景在开发中会遇到较多),又或者无限套娃的数组的长度很大,多重爆炸。所以通过索引劫持数组是极其极其极其耗费性能(不但要递归遍历数组,还要开辟空间进行劫持)的一件事。所以Vue对数组做了特殊处理,官方说明只能通过以下方法触发响应式
[ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
为什么这些方法可以触发更新,因为Vue内部重写了这些方法,当调用数组的该方法时,会调用一些方法来实现响应式
但是,并不是只有这些方法会触发响应式(视图更新),以下结构的数组,可以通过直接改变值触发更新
data() {
return {
test: [
{a:1111}
]
}
}
// 可以通过 test[0].a = newVal 更新视图
之前开发可能没有注意到这个问题。
理清了逻辑,接下来,开始处理属性类型为数组时的数据的劫持的流程
先实现 当 数组 的项为object 时的数据劫持。
遍历数组,若数组的某项值为类型为object,添加至响应式数据
这个类型的判断 其实是在 Observe 这个类的 walk 方法中完成的。基本类型 调用Object.keys(),返回空数组,自然不会执行 defineReactive方法将数据添加至响应式数据
另外,在observe函数中进行了一层 类型为 undefinded 和 null 的判断,因为 Object.keys() 遇到 value值为 undefined 和 null 时会报错
在实例化Observe时,判断数据类型,并调用对应的处理方法
// src/core/observe/index.js
import { isObject } from './../../share/util.js'
export function observe (value){
// 需判断 value 是否为undefined或者null
if (!isObject(value)) {
return
}
return new Observe(value)
}
class Observe{
constructor(value){
// 判断数据是否为数组
if (Array.isArray(value)) {
// 调用数据类型为数组时的处理方法
this.observeArray(value)
} else {
// 遍历所有属性
this.walk(value)
}
}
// 遍历所有属性,并为这些属性添加 getter 和 setter
walk(obj){
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
// 将该对象的属性定义为响应式数据 (getter,setter)
defineReactive(obj,keys[i])
}
}
observeArray (items) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
}
function defineReactive(obj,key) {
let val = obj[key]
// 多层嵌套对象默认深层监听(即递归劫持嵌套对象的所有属性)
observe(val)
Object.defineProperty(obj,key,{
enumerable: true,
configurable: true,
get(){
console.log(`获取了数据${key}:`,val)
return val
},
set(newVal){
console.log(`更改了数据${key}:`,val)
if (newVal === val) {
return
}else{
val = newVal
}
// 继续劫持数据(当改变该属性的类型,赋值为对象时)
observe(newVal)
}
})
}
//src/share/util.js
export function isObject (obj){
return obj !== null && typeof obj === 'object'
}
下面实现重写数组方法(当调用数组的需要重写的方法时也通知数据变更)
- 当类型为数组时,重写该对象的 __proto__ ,将我们上文提及的需重写的改变数组本身的七个方法添加至 __proto__
- 重写的这个__proto__ 属性为一个对象,该对象的__proto__ 需继续指向 Array,继承数组的其他方法
- 对于向数组添加元素的方法,需做把添加的数据继续添加至响应式数据的处理
这里要特别说明一下,重写 __proto__属性需要通过 Object.defineProperty() 将 __proto__ 设置为不可枚举 属性,如果直接通过 . 的方式,会在进入后面的循环部分时,无限递归
(是的,刚开始当我试着用 . 简化代码来重写 __proto__ 属性时,发生了空间溢出,当时脑子短路,想了很久为什么会这样,并且也没有在网上看到相关的说明)
// src/core/observe/index.js
import { isObject } from './../../share/util.js'
import { def } from '../util/lang.js'
import { arrayMethods } from './array'
export function observe (value){
// 需判断 value 是否为undefined或者null
if (!isObject(value)) {
return
}
return new Observe(value)
}
class Observe{
constructor(value){
// 将ob_属性添加至实例
def(value, '__ob__', this)
// 判断数据是否为数组
if (Array.isArray(value)) {
value.__proto__ = arrayMethods
// 调用数据类型为数组时的处理方法
this.observeArray(value)
} else {
// 遍历所有属性
this.walk(value)
}
}
// 遍历所有属性,并为这些属性添加 getter 和 setter
walk(obj){
/*Object.keys()的传参为 undefined或者null 时会报错
在observe函数中已判断过,所以不用再去判断
*/
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
// 将该对象的属性定义为响应式数据 (getter,setter)
defineReactive(obj,keys[i])
}
}
observeArray (items) {
for (let i = 0, l = items.length; i < l; i++) {
observe(items[i])
}
}
}
function defineReactive(obj,key) {
let val = obj[key]
// 多层嵌套对象默认深层监听(即递归劫持嵌套对象的所有属性)
observe(val)
Object.defineProperty(obj,key,{
enumerable: true,
configurable: true,
get(){
console.log(`获取了数据${key}:`,val)
return val
},
set(newVal){
console.log(`更改了数据${key}:`,val)
if (newVal === val) {
return
}else{
val = newVal
}
// 继续劫持数据(当改变该属性的类型,赋值为对象时)
observe(newVal)
}
})
}
// src/core/observe/array.js
export const arrayMethods = Object.create(Array.prototype)
// 重写以下方法 (这些方法均会改变数组本身)
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
// 将methodsToPatch里的方法依次添加至 arrayMethods 这个对象
methodsToPatch.forEach(function (method) {
// 获取数组原来的该方法的实现
const original = Array.prototype[method]
arrayMethods[method] = function (...args) {
console.log(ob)
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
//slice方法第二个参数为添加的元素
inserted = args.slice(2)
break
} //以上方法均会向数组添加元素,新添加的元素需添加至响应式数据
// 当向数组插入新值时,继续将数据添加至响应式数据
if (inserted) ob.observeArray(inserted)
return result
}
})
响应式部分中,数据的劫持的代码基本就是这些,后面还是先看一看视图层部分。内容太多,下篇再写吧