new Vue

91 阅读11分钟

参考链接:juejin.cn/post/684490…

new vue

vue究竟是什么?为什么就能实现这么多酷炫的功能,不知道大家有没有思考过这个问题。其实在每次初始化vue,使用new Vue({...})时,不难发现vue其实是一个类。不过即使在ES6已经如此普及的今天,vue的定义却是普通构造函数定义的,为什么没有采用ES6class呢?这个我们稍后回答,通过层层追踪终于找到了vue被定义的地方:

function Vue(options) {
  ...
  this._init(options)
}

因为是原理解析,flow的类型检测及一些边界情况,如使用方式不对或参数不对或不是主要逻辑的代码我们就省略掉吧。比如省略号这里边界情况是使用时必须是new Vue()的形式,否则会报错。

这就是vue最初始被定义的地方,你没看错,就是这么简单。当执行new Vue时,内部会执行一个方法 this._init(options),将初始化的参数传入。

这里需要说明一点,在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'

function Vue (options) {
  if (process.env.NODE_ENV !== 'production' &&
    !(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

现在可以回答之前的问题了,为什么不采用ES6class来定义,因为这样可以方便的把vue的功能拆分到不同的目录中去维护,将vue的构造函数传入到以下方法内:

  • initMixin(Vue):定义_init方法。
  • stateMixin(Vue):定义数据相关的方法$set,$delete,$watch方法。
  • eventsMixin(Vue):定义事件相关的方法$on$once$off$emit
  • lifecycleMixin(Vue):定义_update,及生命周期相关的$forceUpdate$destroy
  • renderMixin(Vue):定义$nextTick_render将render函数转为vnode。 这些方法都是在各自的文件内维护的,从而让代码结构更加清晰易懂可维护。

这些xxxMixin完成后,接着会定义一些全局的API

export function initGlobalAPI(Vue) {
  Vue.set方法
  Vue.delete方法
  Vue.nextTick方法
  
  ...
  
  内置组件:
  keep-alive
  transition
  transition-group
  
  ...
  
  initUse(Vue):Vue.use方法
  initMixin(Vue):Vue.mixin方法
  initExtend(Vue):Vue.extend方法
  initAssetRegisters(Vue):Vue.componentVue.directiveVue.filter方法
}

源码:

/* @flow */

import config from '../config'
import { initUse } from './use'
import { initMixin } from './mixin'
import { initExtend } from './extend'
import { initAssetRegisters } from './assets'
import { set, del } from '../observer/index'
import { ASSET_TYPES } from 'shared/constants'
import builtInComponents from '../components/index'
import { observe } from 'core/observer/index'

import {
  warn,
  extend,
  nextTick,
  mergeOptions,
  defineReactive
} from '../util/index'

export function initGlobalAPI (Vue: GlobalAPI) {
  // config
  const configDef = {}
  configDef.get = () => config
  if (process.env.NODE_ENV !== 'production') {
    configDef.set = () => {
      warn(
        'Do not replace the Vue.config object, set individual fields instead.'
      )
    }
  }
  Object.defineProperty(Vue, 'config', configDef)

  // exposed util methods.
  // NOTE: these are not considered part of the public API - avoid relying on
  // them unless you are aware of the risk.
  Vue.util = {
    warn,
    extend,
    mergeOptions,
    defineReactive
  }

  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)
  ASSET_TYPES.forEach(type => {
    Vue.options[type + 's'] = 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)
}

这里有部分APIxxxMixin定义的原型方法功能是类似或相同的,如this.$setVue.set他们都是使用set这样一个内部定义的方法。

这里需要提一下vue的架构设计,它的架构是分层式的。最底层是一个ES5的构造函数,再上层在原型上会定义一些_init$watch_render等这样的方法,再上层会在构造函数自身定义全局的一些API,如setnextTickuse等(以上这些是不区分平台的核心代码),接着是跨平台和服务端渲染(这些暂时不在讨论范围)及编译器。将这些属性方法都定义好了之后,最后会导出一个完整的构造函数给到用户使用,而new Vue就是启动的钥匙。

目录结构

刚才是从比较微观的角度近距离的观察了vue,现在我们从宏观角度来了解它内部的代码结构是如何组建起来的。 目录如下:

|-- dist  打包后的vue版本
|-- flow  类型检测,3.0换了typeScript
|-- script  构建不同版本vue的相关配置
|-- src  源码
    |-- compiler  编译器
    |-- core  不区分平台的核心代码
        |-- components  通用的抽象组件
        |-- global-api  全局API
        |-- instance  实例的构造函数和原型方法
        |-- observer  数据响应式
        |-- util  常用的工具方法
        |-- vdom  虚拟dom相关
    |-- platforms  不同平台不同实现
    |-- server  服务端渲染
    |-- sfc  .vue单文件组件解析
    |-- shared  全局通用工具方法
|-- test 测试
  • flow:javaScript是弱类型语言,使用flow以定义类型和检测类型,增加代码的健壮性。
  • src/compiler:将template模板编译为render函数。
  • src/core:与平台无关通用的逻辑,可以运行在任何javaScript环境下,如webNode.jsweex嵌入原生应用中。
  • src/platforms:针对web平台和weex平台分别的实现,并提供统一的API供调用。
  • src/observer:vue检测数据数据变化改变视图的代码实现。
  • src/vdom:将render函数转为vnode从而patch为真实dom以及diff算法的代码实现。
  • dist:存放着针对不同使用方式的不同的vue版本。

vue版本

vue使用的是rollup构建的,具体怎么构建的不重要,总之会构建出很多不同版本的vue。按照使用方式的不同,可以分为以下三类:

  • UMD:通过<script>标签直接在浏览器中使用。
  • CommonJS:使用比较旧的打包工具使用,如webpack1
  • ES Module:配合现代打包工具使用,如webpack2及以上。

而每个使用方式内又分为了完整版和运行时版本,这里主要以ES Module为例,有了官方脚手架其他两类应该没多少人用了。

vue的内部是只认render函数的,我们来自己定义一个render函数,也就是这么个东西:

new Vue({
  data: {
    msg: 'hello Vue!'
  },
  render(h) {
    return h('span', this.msg);
  }
}).$mount('#app');

可能有人会纳闷了,既然只认render函数,同时我们开发好像从来并没有写过render函数,而是使用的template模板。这是因为有vue-loader,它会将我们在template内定义的内容编译为render函数,而这个编译就是区分完整版和运行时版本的关键所在,完整版就自带这个编译器,而运行时版本就没有,如下面这段代码如果是在运行时版本环境下就会报错了:

new Vue({
  data: {
    msg: 'hello Vue!'  
  },
  template: `<div>{{msg}}</div>`
})

vue-cli默认是使用运行时版本的,更改或覆盖脚手架内的默认配置,将其更改为完整版即可通过编译:'vue$': 'vue/dist/vue.esm.js',推荐还是使用运行时版本。

runtimeruntime-only这两个版本的区别?

  1. 最明显的就是大小的区别,带编译器会比不带的版本大6kb
  2. 编译的时机不同,编译器是运行时编译,性能会有一定的损耗;运行时版本是借助loader做的离线编译,运行性能更高。

this._init()的初始化之旅

我们知道new Vue()时,内部会执行一个this._init()方法,这个方法是在initMixin(Vue)内定义的:

export function initMixin(Vue) {
  Vue.prototype._init = function(options) {
    ...
  }
}

当执行new Vue()执行后,触发的一系列初始化都在_init方法中启动,它的实现如下:

let uid = 0
Vue.prototype._init = function(options) {
  const vm = this
  vm._uid = uid++  // 唯一标识
  vm.$options = mergeOptions(  // 合并options
    resolveConstructorOptions(vm.constructor),
    options || {},
    vm
  )
  ...
  initLifecycle(vm) // 开始一系列的初始化
  initEvents(vm)
  initRender(vm)
  callHook(vm, 'beforeCreate')
  initInjections(vm)
  initState(vm)
  initProvide(vm)
  callHook(vm, 'created')
  ...
  if (vm.$options.el) {
    vm.$mount(vm.$options.el)
  }
}

每一个组件都是一个Vue构造函数的子类

会定义_uid属性,这是为每个组件每一次初始化时做的一个唯一的私有属性标识,有时候会有些作用。

合并options配置

参考链接:juejin.cn/post/684490…

面试题

请问可以在beforeCreate钩子内通过this访问到data中定义的变量么,为什么以及请问这个钩子可以做什么?

是不可以访问的,因为在vue初始化阶段,这个时候data中的变量还没有被挂载到this上,这个时候访问值会是undefinedbeforeCreate这个钩子在平时业务开发中用的比较少,而像插件内部的instanll方法通过Vue.use方法安装时一般会选在beforeCreate这个钩子内执行,vue-routervuex就是这么干的。

请问methods内的方法可以使用箭头函数么,会造成什么样的结果?

是不可以使用箭头函数的,因为箭头函数的this是定义时就绑定的。在vue的内部,methods内每个方法的上下文是当前的vm组件实例,methods[key].bind(vm),而如果使用使用箭头函数,函数的上下文就变成了父级的上下文,也就是undefined了,结果就是通过undefined访问任何变量都会报错。

请问vue@2为什么要引入虚拟Dom,谈谈对虚拟Dom的理解?

  • 随着现代应用对页面的功能要求越复杂,管理的状态越多,如果还是使用之前的JavaScript线程去频繁操作GUI线程的硕大Dom,对性能会有很大的损耗,而且也会造成状态难以管理,逻辑混乱等情况。引入虚拟Dom后,在框架的内部就将虚拟Dom树形结构与真实Dom做了映射,让我们不用在命令式的去操作Dom,可以将重心转为去维护这棵树形结构内的状态即可,状态的变化就会驱动Dom发生改变,具体的Dom操作vue帮我们完成,而且这些大部分可以在JavaScript线程完成,性能更高。

  • 虚拟Dom只是一种数据结构,可以让它不仅仅使用在浏览器环境,还可以用与SSR以及Weex等场景。

父子两个组件同时定义了beforeCreatecreatedbeforeMountemounted四个钩子,它们的执行顺序是怎么样的?

首先会执行父组件的初始化过程,所以会依次执行beforeCreatecreated、在执行挂载前又会执行beforeMount钩子,不过在生成真实dom__patch__过程中遇到嵌套子组件后又会转为去执行子组件的初始化钩子beforeCreatecreated,子组件在挂载前会执行beforeMounte,再完成子组件的Dom创建后执行mounted。这个父组件的__patch__过程才算完成,最后执行父组件的mounted钩子,这就是它们的执行顺序。执行顺序如下:

parent beforeCreate
parent created
parent beforeMounte
    child beforeCreate
    child created
    child beforeMounte
    child mounted
parent mounted

当前组件模板中用到的变量一定要定义在data里么?

data中的变量都会被代理到当前this下,所以我们也可以在this下挂载属性,只要不重名即可。而且定义在data中的变量在vue的内部会将它包装成响应式的数据,让它拥有变更即可驱动视图变化的能力。但是如果这个数据不需要驱动视图,定义在createdmounted钩子内也是可以的,因为不会执行响应式的包装方法,对性能也是一种提升

请简单描述下vue响应式系统?

简单来说就是使用Object.defineProperty这个API为数据设置getset。当读取到某个属性时,触发get将读取它的组件对应的render watcher收集起来;当重置赋值时,触发set通知组件重新渲染页面。如果数据的类型是数组的话,还做了单独的处理,对可以改变数组自身的方法进行重写,因为这些方法不是通过重新赋值改变的数组,不会触发set,所以要单独处理。响应系统也有自身的不足,所以官方给出了$set$delete来弥补。

为什么v-for里建议为每一项绑定key,而且最好具有唯一性,而不建议使用index

diff比对内部做更新子节点时,会根据oldVnode内没有处理的节点得到一个key值和下标对应的对象集合,为的就是当处理vnode每一个节点时,能快速查找该节点是否是已有的节点,从而提高整个diff比对的性能。如果是一个动态列表,key值最好能保持唯一性,但像轮播图那种不会变更的列表,使用index也是没问题的。

请问computed属性和watch属性分别什么场景使用?

当模板中的某个值需要通过一个或多个数据计算得到时,就可以使用计算属性,还有计算属性的函数不接受参数;监听属性主要是监听某个值发生变化后,对新值去进行逻辑处理。

说一下自定义事件机制

子组件使用this.emit触发事件时,会在当前实例的事件中心去查找对应的事件,然后执行它。不过这个事件回调是在父组件的作用域里定义的,所以emit触发事件时,会在当前实例的事件中心去查找对应的事件,然后执行它。不过这个事件回调是在父组件的作用域里定义的,所以emit里的参数会传递给父组件的回调函数,从而完成父子组件通信。

请说明下组件库中命令式弹窗组件的原理?

使用extend将组件转为构造函数,在实例化这个这个构造函数后,就会得到$el属性,也就是组件的真实Dom,这个时候我们就可以操作得到的真实的Dom去任意挂载,使用命令式也可以调用。

请说明下transition组件的实现原理?

transition组件是一个抽象组件,不会渲染出任何的Dom,它主要是帮助我们更加方便的写出动画。以插槽的形式对内部单一的子节点进行动画的管理,在渲染阶段就会往子节点的虚拟Dom上挂载一个transition属性,表示它的一个被transition组件包裹的节点,在path阶段就会执行transition组件内部钩子,钩子里分为enter和leave状态,在这个被包裹的子节点上使用v-if或v-show进行状态的切换。你可以使用Css也可以使用JavaScript钩子,使用Css方式时会在enter/leave状态内进行class类名的添加和删除,用户只需要写出对应类名的动画即可。如果使用JavaScript钩子,则也是按照顺序的执行指定的函数,而这些函数也是需要用户自己定义,组件只是控制这个的流程而已。