手写50行代码实现vue中this是如何访问data和methods,并调试vue源码详细解剖原理

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

本文属于源码共读第23期 |为什么 Vue2 this 能够直接获取到 data 和 methods,点击了解本期详情一起参与

前言

1、通过本文可以了解到调试vue2.7.2+源码的两种方式,其他项目同样适用

2、了解vue2.7.2+初始化的流程

3、了解构造函数 new 操作符

4、了解 Object.defineProperty

5、了解 call、apply、bind 来改变 this 的指向

6this.data 和 this.methods 的访问原理解剖

7、手写 mini 版 this.data 和 this.methods

1、准备源码和测试代码

1.1、拉取代码

git clone [email protected]:vuejs/vue.git

1.2、安装依赖

查看根目录可以轻松的发现pnpm-workspace.yamlpnpm-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中的方法。

6.gif

简化一个小例子,主要简单来看看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后的目录文件

image.png

这样可以直接调试dist/vue.js文件,但调试不到源代码文件。 此时我们可以使用http-server

// 全局安装
npm i -g http-server

// 安装完成后命令行运行
http-server -p 8089

调试效果如下

5.gif

打开浏览器源代码标签,然后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文件才能在源代码中打断点调试

image.png

运行起来之后的调试方式,跟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的位置,我提前打好了断点。

image.png

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属性

getset字段的默认值为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

image.png

通过截图可以发现,当同时存在value和get属性的时候,会发生如图所示的错误。

所以可以总结为:在使用了get或者set属性之后,不允许再出现valuewritable中的任何一个属性,否则就会报错。

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()

image.png

通过运行后的截图可以发现,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访问

通过调试源码发现,只要仔细一点稍微花点时间,原来也能看懂尤大写的代码,没有想象中的那么难,而且感觉逻辑非常清晰,阅读起来很优雅。所以大家如果有想看源码,或者参加若川源码共读活动的,一定要大胆一些,不要怂,事情真的没有那么难。

有点目的性的阅读源码似乎更高效,这样针对性很强,不会大一统所有的源码都会过一下,时间一下子就过去了,每次带着一个小问题去看源码或许也是若川大佬的精髓所指。

通过阅读源码,就是把看不懂的函数方法关键字等,不断的查漏补缺。或者在这里的用法或者写法不一样,等等各种超乎你想象的用法、场景...,收获真的是非常大,尤其是看完后再写一篇小文总结出来,真的就比读一遍别人写的收获要多好几倍的感觉。

所以如果你还在犹豫自己看不懂,自己行不行等等借口,作为一个前端还不到两年经验的人告诉你,加加油相信自己,你完全可以的。最后一定要行动起来。

我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿

猜你喜欢

转载自juejin.im/post/7121512058725597191