前言
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 git@github.com: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访问
通过调试源码发现,只要仔细一点稍微花点时间,原来也能看懂尤大写的代码,没有想象中的那么难,而且感觉逻辑非常清晰,阅读起来很优雅。所以大家如果有想看源码,或者参加若川源码共读活动的,一定要大胆一些,不要怂,事情真的没有那么难。
有点目的性的阅读源码似乎更高效,这样针对性很强,不会大一统所有的源码都会过一下,时间一下子就过去了,每次带着一个小问题去看源码或许也是若川大佬的精髓所指。
通过阅读源码,就是把看不懂的函数方法关键字等,不断的查漏补缺。或者在这里的用法或者写法不一样,等等各种超乎你想象的用法、场景...,收获真的是非常大,尤其是看完后再写一篇小文总结出来,真的就比读一遍别人写的收获要多好几倍的感觉。
所以如果你还在犹豫自己看不懂,自己行不行等等借口,作为一个前端还不到两年经验的人告诉你,加加油相信自己,你完全可以的。最后一定要行动起来。