Vue 源码初探
流程图
环境搭建
mkdir vue
cd vue
yarn init
复制代码
安装babel
- 运行以下命令安装所需的包(package):
yarn add @babel/core @babel/cli @babel/preset-env -D
复制代码
2.在项目的根目录下创建一个命名为 babel.config.json
的配置文件(需要 v7.8.0
或更高版本),并将以下内容复制到此文件中:
{
"presets": [
[
"@babel/env",
{
"targets": {
"edge": "17",
"firefox": "60",
"chrome": "67",
"safari": "11.1"
},
"useBuiltIns": "usage",
"corejs": "3.6.5"
}
]
]
}
复制代码
上述浏览器列表仅用于示例。请根据你所需要支持的浏览器进行调整。参见 此处 以了解
@babel/preset-env
可接受哪些参数。
上面内容来自于babel官网
- 运行此命令将
src
目录下的所有代码编译到lib
目录:
./node_modules/.bin/babel src --out-dir lib
复制代码
你可以利用 npm@5.2.0 所自带的 npm 包运行器将
./node_modules/.bin/babel
命令缩短为npx babel
现在我们执行以下操作
cd src
touch index.js
复制代码
//在index.js
function Vue() {
console.log('hiiiihi')
}
export default Vue
复制代码
执行
npx babel src --out-dir lib
Successfully compiled 1 file with Babel (493ms).
复制代码
结果如下
rollup 搭建
yarn add rollup -D
复制代码
添加配置文件rollup.config.js
import { babel } from '@rollup/plugin-babel';
export default {
input: 'src/index.js',
output: {
file: 'dist/vue.js',
//IIFE 自执行函数, UMD = AMD + CMD
format: 'umd',
name: 'Vue', //umd模块需要配置name,会将导出的模式放到window上
sourcemap: true
},
plugins: [
babel({
exclude: 'node_modules/**' //去掉node_modules下面的所有文件夹,不进行编译
})
]
}
复制代码
Vue 的初始化流程
下面我们正式开始编写,我们打开Vue.js的官网,把里面的内容抄过来,如下代码
<body>
<div id="app">
{{ message }}
</div>
<script src="./vue.js"></script>
<!--<script src="https://cdn.jsdelivr.net/npm/vue"></script>-->
<script>
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!'
}
})
</script>
</body>
复制代码
接着我们把引入cdn的vue类库换成我们自己编写的代码。
//vue.js
function Vue(options) {
console.log(options)
// this._init(options)
}
Vue.prototype._init = function(){
console.log('初始化Vue 流程入口')
}
export default Vue
复制代码
现在就以上代码,我们用到的js的 构造函数
原型
(prototype) new
。关于这些知识,在开始之前,我们先简单的瞄一眼 ^_*
- prototype 是构造函数的一个属性。
- 可以通过new 构造函数创建实例对象。
- 每个实例对象都可以调用构造函数上面prototype的方法或者属性。
//就上面代码来说。通过new Vue() 可以创建 app实例对象,
//在new Vue() 的时候可以通过this._init() 调用实例构造函数上面的方法
复制代码
new 的过程
我们先来讲讲new Vue()的过程
- 在内存中创建一个新对象。
- 这个新对象内部的[[Prototype]]特性被赋值为构造函数的 prototype 属性
- 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)
- 执行构造函数内部的代码(给新对象添加属性)
- 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象
function Fn(name, age) {
this.name = name
this.age = age
}
Fn.prototype.xxx = function(){}
function myNew(Obj) {
//创建一个新的对象
var a = {}
var arg = Array.from(arguments)
//取出第一个参数, 就是要实例化额对象的构造函数
var constructor = arg.shift()
//构造函数上面会有一个 prototype的属性存放着这类对象的共有属性和方法
//这句话相当于 a.__proto__ = constructor.prototype
Object.setPrototypeOf(a, constructor.prototype)
// 构造函数内部的 this 被赋值为这个新对象(即 this 指向新对象)
var result = constructor.apply(a, arg)
// 如果构造函数返回非空对象,则返回该对象;否则,返回刚创建的新对象
return typeof result === 'object' ? result : a
}
var t = myNew(Fn, '天明', 16)
console.log(t)
复制代码
查看控制台输出
好了,看到这里我们再,继续写下面的代码。嗨,既然已经写到这块了,不妨把下面这段代码的内存图也一并奉上叭
var app = new Vue()
复制代码
推荐阅读,大家可以点击去看一看,并亲手实验一下里面的代码,相信会有不少的收获,比如笔者在看着几篇文章的同时,可能还会不经意的看到其他的一些问题、比如
1. Function.__proto__ === Function.prototype // true
2. Math 没有prototype 属性
复制代码
JS 中 proto 和 prototype 存在的意义是什么?
en .看了以上的东西,我们来做一道题吧。
写一下Foo和obj的原型链向上查找直到结束的整个过程?当给x赋值后,分别值为多少?
function Foo() {};
let obj = new Foo();
// obj 原型链
obj.__proto__ === Foo.prototype
Foo.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null
// Foo 原型链
Foo.__proto__ === Function.prototype
Function.prototype.__proto__ === Object.prototype
Object.prototype.__proto__ === null
Function.prototype.x = 100;
Foo.x; // 100
obj.x; // undefined
//本题考查 实例的原型链和构造函数的原型链
//说明了构造函数的原型链和实例的原型链的开端是不一样的,虽然最后的结局是一样的。
复制代码
好。我们继续上面的代码,为了分离代码,我们开始这样构建代码, 我们提取一个单独的文件将Vue当做函数的参数传递到函数内部,在函数内部给其原型上面扩展方法。
//init.js
export function initMixin (Vue) {
Vue.prototype._init = function (options) {
console.log('初始化Vue 流程入口')
console.log(options)
}
}
复制代码
//index.js
import { initMixin } from "./init"
function Vue(options) {
this._init(options)
}
initMixin(Vue)
export default Vue
复制代码
Vue的对象劫持
上面我们写到给Vue原型添加_init方法,下面我们开始初始化Vue的状态 initState() 方法
import { initState } from "./state"
export function initMixin (Vue) {
Vue.prototype._init = function (options) {
//这里的 this 其实就是外面new 出来的实例
const vm = this
//把用户的选项放到vm 上,这样在其他的方法中都可以获取到了
vm.$options = options //为了后续方法,都可以获取到$options选项
//options中包含了很多的选项 el data props
initState(vm)
}
}
复制代码
//state.js
我们把当前实例vm 传递给initState() 从中就可以获取到用户传递的配置信息,想想大家开始写Vue的时候,在new Vue() 这个构造函数的时候传入的这个对象,我们现在要做的就是,拿到这个对象里面的不同的key 比如 data 、props 、computed、methods、等等,对这些参数做一些处理,让其在某些时刻发生作用。下面我们开始处理data属性。
import { observe } from "./observe"
import { isFunction } from "./utils"
export function initState(vm) {
const opts = vm.$options
//如果用户传递了data属性
if(opts.data) {
initData(vm)
}
}
function initData(vm) {
let data = vm.$options.data
//检测,用户传递的data的类型 如果是函数的话,需要取函数的返回值当做data
//用call 是为了获取vm 上面data
data = isFunction(data) ? data.call(vm) : data
//数据合法,开始监测
//需要将data 变成响应式的,用Object.defineProperty, 重写data中的所有属性
observe(data)
}
复制代码
现在我们已经确保data是一个对象了,{} 或者 [] ,现在我们姑且只认为data的类型只能是{},我们开始把data传入observe() 中让他具有响应式。所谓的响应式,目前我们先理解成,当我们写在data下面的属性在被获取、或者被设置的时候,可以告诉我们。用到的API,就是Object.defineProperty(),更多关于这个的讲解大家可以移步
import { isObject } from "../utils";
//观察者对象
class Observer {
constructor(data) {
this.walk(data)
}
walk(data) {
Object.keys(data).forEach((key)=>{
let value = data[key]
defineReactive(data, key, value)
})
}
}
/**
*
* @param {当前对象} data
* @param {要监测的key} key
* @param {当前对象key的value, 被监测之前的值} value
*/
function defineReactive(data, key, value){
//如果当前对象的key 再是一个对象就进行深层监测
observe(value)
Object.defineProperty(data, key,{
get() {
return value
},
set(newValue) {
//如果新值和老值相等 就不做处理
if(value === newValue) return
value = newValue
}
})
}
export function observe(data){
//判断data 必须是一个对象
if(!isObject(data)) {
return
}
//开始观测 ,这里目前return 或者不return 都可以
return new Observer(data)
}
复制代码
我们在使用的地方打印当前new 出来的实例, 可以看到他们的每个属性都拥有了一个get 和set方法。
性能优化
写到这块,我们是不是可以想一想一些关于性能优化的一些技巧。
- 不要把所有的数据都写在data里面,比如一些不会对ui页面产生影响或者说是不用进行监测的数据。
- 不要写数据的时候层次过深,尽量扁平化数据。
- 不要频繁的获取数据,会触发多次get方法,造成一定的性能损耗。
- 如果数据不需要响应式,可以使用Object.freeze进行属性的冻结。
这里有可能你会看见几个名词、可枚举、可配置、可修改、后两者很好理解其作用和用途、但是前者、不知道大家看到这个名词的时候,有没有想过问什么或有这个属性的出现呢?他有什么作用呢?如果想知道请观众老爷移步
数组的变化侦测
上面我们用defineReactive 对data下面的所有对象属性进行了观测,接下来我们给data里面写一个数组看看会发生什么?
var app = new Vue({
el: '#app',
data: {
message: 'Hello Vue!',
arr: [1, 2, 3]
}
})
console.log(app)
复制代码
可以看到数组的每一项都被进行了观测,我们写下以下代码,进行测试
//尝试调用app上面的$options 里面的data.arr[0]
console.log(app.$options.data.arr[0])
//同时我们在defineReactive get()里面打印一句话
function defineReactive(data, key, value){
//如果当前对象的key 再是一个对象就进行深层监测
observe(value)
Object.defineProperty(data, key,{
get() {
console.log('call get')
return value
},
set(newValue) {
//如果新值和老值相等 就不做处理
if(value === newValue) return
value = newValue
}
})
}
复制代码
我们可以看到,在我们读取属性的时候会进入get方法。
至于为什么Vue会选择重写数组方法,大家先没有必要较真,我在这边直接写我获取到的答案了,具体的大家有兴趣可以进入链接查看详细内容
主要有两点原因吧:
- Vue 的响应式是通过 Object.defineProperty() 实现的,这个 api 不能监听数组长度的变化,也就不能监听数组的新增。
- Vue 无法检测通过数组索引改变数组的操作,这不是 Object.defineProperty() api 的原因,而是 尤大认为性能消耗与带来的用户体验不成正比 ,对数组进行响应式检测会带来很大的性能消耗,因为数组项可能会大,比如1000条、10000条。
好,我们继续,开始数组的原数组的方法。
import { isArray, isObject } from "../utils";
import { arrayMethods } from "./array";
class Observer {
constructor(data) {
if(isArray(data)) {
//如果当前dada的属性的类型是数组,就重写当前数组的原型方法
data.__proto__ = arrayMethods
}else {
this.walk(data)
}
}
}
复制代码
array.js
//获取数组老的原型对象
let oldArrayProtoMethods = Array.prototype
//用数组原始的原型对象,创建一个新的对象
//作用就是 arrayMethods.__proto__ === oldArrayProtoMethods
export let arrayMethods = Object.create(oldArrayProtoMethods)
//把数组的可以改变原始数组的方法进行重写
let methods = [
'push',
'pop',
'splice',
'sort',
'shift',
'unshift',
'reverse'
]
methods.forEach((method)=>{
//给当前对象添加上要被重写的属性方法,对象查找属性是先从自身查找,然后再到原型链上去查找
//这样既可以保证改变原数组的方法被重写,又可以在原型上面查找到 concat、slice。等方法
//arrayMethods.push = function(){}
//arrayMethods.pop = function(){}
//arrayMethods.splice = function(){}
//...
arrayMethods[method] = function(){
console.log('调用了数组被重写的方法')
}
})
复制代码
测试
这样我们就又换了一种方式实现了,data里面数组的监听,是只能通过数组的这7个api调用的形式才可以触发监听。
Vue 实例取值的代理
//一开始vm实例和用户传递的data是没有关系的,为了外面的vm实例能获取到data 就给vm添加一个属性data,这样就可以用 vm._data进行数据的访问了,但是Vue的作者希望可用直接通过vm.message访问到传递给构造函数的参数里面的data的属性,我们就要再做一层代理
复制代码