本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。
本文属于源码共读第23期 |为什么 Vue2 this 能够直接获取到 data 和 methods,点击了解本期详情一起参与。
前言
1、通过本文可以了解到调试vue2.7.2+源码的两种方式,其他项目同样适用
2、了解vue2.7.2+初始化的流程
3、了解构造函数 new 操作符
4、了解 Object.defineProperty
5、了解 call、apply、bind 来改变 this 的指向
6、this.data 和 this.methods 的访问原理解剖
7、手写 mini 版 this.data 和 this.methods
1、准备源码和测试代码
1.1、拉取代码
git clone [email protected]:vuejs/vue.git
1.2、安装依赖
查看根目录可以轻松的发现pnpm-workspace.yaml
和pnpm-lock.yaml
,那么就说明尤大大把vue2.7+也升级到pnpm。
如果你想去看历史版本,比如2.6版本可以,可以点击链接 github.com/vuejs/vue/t… ,本文就主要来看一下2.7+版本的。
pnpm i
1.3、准备测试代码
对于很多工作中正在使用vue2,甚至是vue3的大神们来说,下面这段代码再简单熟悉不过了。
<script src="../../dist/vue.js"></script>
<div id="demo">
<div>{{name}}</div>
<button @click="testThis">测试this</button>
</div>
<script>
const vm = new Vue({
data: {
name: 'aehyok',
},
methods: {
sayName(){
this.testThis();
},
testThis() {
this.name = 'update-aehyok';
console.log(this.name);
}
},
}).$mount('#demo');
</script>
其中有this.name可以直接访问data中的属性,然后通过this.testThis
可以直接访问methods中的方法。
简化一个小例子,主要简单来看看new操作符
<script>
function Vue() {
this.name ="aehyok"
}
let vue = new Vue();
console.log(vue); // Vue {name: 'aehyok'}
</script>
通过执行打印可以发现,new 通过构造函数
创建出来的实例可以访问到构造函数中的属性。
关于
new操作符
更详细的可以查看下面两位大神的文章,感觉这里知识点还是蛮多,等有空再进行总结实践一下。
一篇是若川大佬的:juejin.cn/post/684490…
另外一篇则是掘金七级大牛的精彩文章:juejin.cn/post/684490…
1.4、调试方式
- http-server
一种是通过pnpm build
指令进行直接编译,然后将vue打包生成到dist目录,执行完pnpm build
后的目录文件
这样可以直接调试dist/vue.js
文件,但调试不到源代码文件。 此时我们可以使用http-server
// 全局安装
npm i -g http-server
// 安装完成后命令行运行
http-server -p 8089
调试效果如下
打开浏览器源代码标签,然后ctrl + p
快捷键,输入state
,便能找到对应的源代码文件。 比如在53行
打上断点,刷新页面后,就会运行到断点位置。
- sourcemap
先修改package.json中的dev
指令
//未修改前
"dev": "rollup -w -c scripts/config.js --environment TARGET:full-dev",
// 修改后,主要添加 -m
"dev": "rollup -w -m -c scripts/config.js --environment TARGET:full-dev",
-m
就是要生成sourceMap
文件,生成sourceMap文件才能在源代码中打断点调试
运行起来之后的调试方式,跟http-server一样。
2、解析源码
2.1、入口文件
src/core/index.ts
,通过调试发现,这应该是vue的主入口文件。
import Vue from './instance/index'
import { initGlobalAPI } from './global-api/index'
//其他无关的代码暂时移除了
//......
initGlobalAPI(Vue)
export default Vue
通过字面意思就可以发现,初始化全局API,可以发现Vue
是通过模块import
引入的。我来看看是否有代码自动执行了?/src/core/instance/index
打开文件后发现果然有初始化的代码,这里我只挑主线的代码进行分析
2.2、Vue初始化
import { initMixin } from './init'
import { stateMixin } from './state'
import { renderMixin } from './render'
import { eventsMixin } from './events'
import { lifecycleMixin } from './lifecycle'
import { warn } from '../util/index'
import type { GlobalAPI } from 'types/global-api'
function Vue(options) {
if (__DEV__ && !(this instanceof Vue)) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)
export default Vue as unknown as GlobalAPI
初始化了Vue
函数,然后下面的函数都将Vue
作为参数进行传递,那我们就来看下面的第一个函数initMixin
函数,剩下的几个函数可以自行去详细查看阅读。
export function initMixin(Vue: typeof Component) {
Vue.prototype._init = function (options?: Record<string, any>) {
const vm: Component = this
vm._self = vm
initLifecycle(vm)
initEvents(vm)
initRender(vm)
callHook(vm, 'beforeCreate')
initInjections(vm) // resolve injections before data/props
initState(vm)
initProvide(vm) // resolve provide after data/props
callHook(vm, 'created')
/* istanbul ignore if */
if (__DEV__ && config.performance && mark) {
vm._name = formatComponentName(vm, false)
mark(endTag)
measure(`vue ${vm._name} init`, startTag, endTag)
}
if (vm.$options.el) {
vm.$mount(vm.$options.el)
}
}
}
Vue通过prototype为自身添加_init函数,这样当然只是初始化_init,并没有执行,等后面执行到这里我们再进行详细的解析。
2.3、初始化全局API
Vue简单初始化完毕,我们继续回到initGlobalAPI
函数。
export function initGlobalAPI(Vue: GlobalAPI) {
// 手动移除了很多无关紧要的代码,
Vue.set = set
Vue.delete = del
Vue.nextTick = nextTick
// 2.6 explicit observable API
Vue.observable = <T>(obj: T): T => {
observe(obj)
return obj
}
Vue.options = Object.create(null)
// this is used to identify the "base" constructor to extend all plain-object
// components with in Weex's multi-instance scenarios.
Vue.options._base = Vue
extend(Vue.options.components, builtInComponents)
initUse(Vue)
initMixin(Vue)
initExtend(Vue)
initAssetRegisters(Vue)
}
还是从字面意思我们就可以明白,在Vue函数对象上初始化全局函数,例如(set、delete、nextTick、options、Use、Mixin等等吧)。
2.4、调试 new Vue
接下来代码会运行到new Vue的位置,我提前打好了断点。
function Vue(options) {
if (__DEV__ && !(this instanceof Vue)) {
warn('Vue is a constructor and should be called with the `new` keyword')
}
this._init(options)
}
要开始执行_init函数,这个_init在上面我已经将其初始化,直接执行即可。其实这里开始初始化的是当前页面组件的所需的方法,这里我着重来看一下initState,其他的初始化是类似的,只是方法实现不太一样。
src/core/instance/state.ts
export function initState(vm: Component) {
const opts = vm.$options
if (opts.props) initProps(vm, opts.props)
// Composition API
initSetup(vm)
if (opts.methods) initMethods(vm, opts.methods)
if (opts.data) {
initData(vm)
} else {
const ob = observe((vm._data = {}))
ob && ob.vmCount++
}
if (opts.computed) initComputed(vm, opts.computed)
if (opts.watch && opts.watch !== nativeWatch) {
initWatch(vm, opts.watch)
}
}
-
initProps初始化Props
-
initSetup初始化组合式api的Setup生命周期钩子函数
-
initmethods初始化methods方法
-
initData初始化Data数据
-
initComputed 初始化computed
-
initwatch 初始化watch
从函数名字就可以非常清楚初始化的是什么,如果你使用过vue的话
2.5、initMethods 初始化方法
function initMethods(vm: Component, methods: Object) {
const props = vm.$options.props
for (const key in methods) {
vm[key] = typeof methods[key] !== 'function' ? noop : bind(methods[key], vm)
}
}
// 空函数
function noop(a?: any, b?: any, c?: any) {}
原来initMethods就是这么简单通过一个循环,将methods循环添加到vm对象上,就是这么的霸道。当然这里重点有一个bind
函数
function polyfillBind(fn: Function, ctx: Object): Function {
function boundFn(a: any) {
const 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: Function, ctx: Object): Function {
return fn.bind(ctx)
}
// @ts-expect-error bind cannot be `undefined`
export const bind = Function.prototype.bind ? nativeBind : polyfillBind
首先判断当前宿主下Function.prototype.bind
是否存在bind方法,有bind方法就直接调用bind,如果没有就采用call或者apply
来改变this指向。
- 就是使用 call apply bind,三种方式都可以实现相同的效果
- fun.apply(thisArgs, [arg1, arg2]) 参数通过数组的方式传递
- fun.call(thisArgs, arg1, arg2) 参数通过多个参数传递
- fun.bind(thisArgs, arg1, arg2)() bind 相当于创建一个新的函数,我们还需要手动调用
2.6、initData初始化data
function initData(vm: Component) {
// 中间省略或移除很多暂时不用的代码
let data: any = vm.$options.data
data = vm._data = isFunction(data) ? getData(data, vm) : data || {}
// proxy data on instance
const keys = Object.keys(data)
let i = keys.length
while (i--) {
//中间省略移除很多代码
const key = keys[i]
proxy(vm, `_data`, key)
}
// observe data
const ob = observe(data)
ob && ob.vmCount++
}
export function proxy(target: Object, sourceKey: string, key: string) {
sharedPropertyDefinition.get = function proxyGetter() {
return this[sourceKey][key]
}
sharedPropertyDefinition.set = function proxySetter(val) {
this[sourceKey][key] = val
}
Object.defineProperty(target, key, sharedPropertyDefinition)
}
先是对data属性通过isFunction进行判断是否为一个函数,如果为一个函数通过getData将函数转换为数据对象。 所以我们在定义data的时候其实有两种方式(这里以前还真的不知道)
// 第一种方式
data: {
name: 'aehyok',
},
// 第二种方式
data: () => {
return {
name: 'aehyok'
}
},
然后通过Object.keys
获取data中所有的keys,循环并通过proxy
中的Object.defineProperty
来实现对vm._data
中所有数据属性的监听,其实就相当于在this._data做了一层代理。
通过proxy代理后,可以vm[key]
直接来访问,同时外部便可以达到this[key]
,举例:这里的key也便是我们上面在data中定义的name
, this.name
便最终取值成功。
this.name = vm.name = vm._data.name
observe(data)
应该就是vue2的重点知识监听器,实现vue2双向绑定的代码应该都在这里面,这里简单看了一下没看明白,等有时间再来详细学习一下。
对于这里用到的Object.definePropery
之前也只是知道、看过、但从来没真正的学习一下,这里刚好遇到了,就来手动demo尝试一遍。
3、Object.definePropery的剖析
3.1、最最简单的demo
<script>
const obj = {}
Object.defineProperty(obj, 'name', {
})
console.log(obj.name) // undefined
</script>
可以发现打印出来的为undefined。这其中所使用的其实是descriptor
中的value
属性。 该属性默认值为undefined,同时该属性的值可以设置为任何有效的JavaScript值(基础类型、对象、函数等等)。
3.2、 descriptor的value属性
<script>
const obj = {}
Object.defineProperty(obj, 'name', {
value: 'aehyok'
})
console.log(obj.name) // aehyok
</script>
执行之后最终打印 aehyok。
3.3、 descriptor的writable属性
writable
默认值为false,设置为true后,下面属性的name值才能被修改
<script>
const obj = {}
Object.defineProperty(obj, 'name', {
value: 'aehyok',
writable: true,
})
obj.name = 'leo'
console.log(obj.name) // leo
</script>
可以发现打印出来的为leo。如果不设置writable
或者设置为false,则打印出来的为aehyok。
3.4、descriptor的configuable属性
configuable
默认值为false,设置为true后,下面的name属性,可以从obj上删除
<script>
const obj = {}
Object.defineProperty(obj, 'name', {
value: 'aehyok',
configurable: true,
})
console.log(obj) // {name: 'aehyok'}
delete obj.name
console.log(obj) // {}
</script>
第一个console打印出来aehyok,第二个console可以发现打印出来的为{} 空对象,obj中的name键通过delete被删除了。 如果不设置configuable
或设置为false,则第二个console打印出来的{name: 'aehyok'}
。
3.5、descriptor的enumerable属性
enumerable
默认值为false,设置为true后,该属性才会出现在对象的枚举属性中。
<script>
const obj = {}
Object.defineProperty(obj, 'name', {
value: 'aehyok',
enumerable: true,
})
for(let key in obj) {
console.log(key,obj[key])
}
</script>
可以发现打印出来的为name aehyok
。 如果不设置enumerable
或者设置为false,则什么都不会打印出来,因为刚好obj中没有一个可以枚举的属性。
3.6、descriptor的get和set属性
get
和set
字段的默认值为undefined。
<script>
const obj = {}
let tempValue = 'temp name'
Object.defineProperty(obj, 'name', {
get() {
return tempValue
},
set(value) {
tempValue = value
}
})
console.log(obj.name)
obj.name = 'Leo'
console.log(obj.name)
</script>
当我们通过obj.name
访问name属性的时候,会调用get
函数。 当我们通过obj.name = 'Leo'
进行赋值的时候,会调用set
函数。
3.7、小结
Object.defineProperty(obj, prop, descriptor)
-
obj :要定义的属性。
-
prop:要定义或修改的属性的名称或 [
Symbol
]。 -
descriptor:要定义或修改的属性描述符
-
value属性,默认值为undefined
-
get属性,默认值为undefined
-
set属性,默认值为undefined
-
writable属性,默认值为false
-
configuable属性,默认值为false
-
enumerable属性,默认值为false
-
通过截图可以发现,当同时存在value和get属性的时候,会发生如图所示的错误。
所以可以总结为:在使用了
get
或者set
属性之后,不允许再出现value
和writable
中的任何一个属性,否则就会报错。
4、 手写50行代码实现this访问data和methods
<script>
let descriptor = {
enumerable: true,
configuable: true,
}
function proxy(obj, sourceKey, key) {
descriptor.get = function getter() {
return this[sourceKey][key]
};
descriptor.set = function setter(val) {
this[sourceKey][key] = val
}
Object.defineProperty(obj,key, descriptor)
}
function Vue(options) {
let vm = this;
vm.$options = options
vm._init(vm)
}
Vue.prototype._init = function (vm) {
let opts = vm.$options
if(opts.data) {
initData(vm);
}
if(opts.methods) {
initMethods(vm, opts.methods);
}
}
function initData(vm) {
const data = vm._data = vm.$options.data;
const keys = Object.keys(data);
let i = keys.length;
while (i--) {
var key = keys[i];
proxy(vm, '_data', key)
}
}
function initMethods(vm, methods) {
for(let key in methods) {
vm[key] = typeof methods[key] !== 'function' ? {} : methods[key].bind(vm)
}
}
</script>
进行实例调用
const t_vue = new Vue({
data: {
name: 'aehyok',
},
methods: {
testThis() {
this.changeName()
console.log(this.name)
},
changeName() {
this.name = 'testThis';
}
}
})
console.log(t_vue.name)
console.log(t_vue)
t_vue.testThis()
通过运行后的截图可以发现,data中的name属性,以及methods中的testThis方法和changeName方法都已经被加载到了实例上了,再来简单的说明一下:
-
通过
Vue.prototype._init
初始化 -
initMethods
中直接通过bind进行生成新的函数,并直接通过vm[key]
赋值,达到this能够访问的目的 -
initData
中则是通过Object.definePropery
实现绑定到vm[key]
,从而达到this访问的目的
5、总结
-
1、熟悉了解 new 、bind 、call、apply简单用法
-
2、熟悉了解vue2中Object.defineProperty响应式原理
-
3、熟悉了解vue2中初始化代码的逻辑处理
-
4、手写实现mini版初始化来支持this访问
通过调试源码发现,只要仔细一点稍微花点时间,原来也能看懂尤大写的代码,没有想象中的那么难,而且感觉逻辑非常清晰,阅读起来很优雅。所以大家如果有想看源码,或者参加若川源码共读活动的,一定要大胆一些,不要怂,事情真的没有那么难。
有点目的性的阅读源码似乎更高效,这样针对性很强,不会大一统所有的源码都会过一下,时间一下子就过去了,每次带着一个小问题去看源码或许也是若川大佬的精髓所指。
通过阅读源码,就是把看不懂的函数方法关键字等,不断的查漏补缺。或者在这里的用法或者写法不一样,等等各种超乎你想象的用法、场景...,收获真的是非常大,尤其是看完后再写一篇小文总结出来,真的就比读一遍别人写的收获要多好几倍的感觉。
所以如果你还在犹豫自己看不懂,自己行不行等等借口,作为一个前端还不到两年经验的人告诉你,加加油相信自己,你完全可以的。最后一定要行动起来。
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。