Vue源码分析 - 从入口到构造函数的整体流程

Vue 中的核心源码主要放在了 src/core 目录下,我们先来看下面这段代码

上面这段代码是我们平常创建一个 Vue 项目中 main.js 入口文件里的原始代码,这也是 Vue 创建应用的起点,接下来我们就来分析从 new Vue()$mount 这一过程 Vue 都做了哪些事,也就是做了哪些初始化。

首先是 src/core/index.ts 文件

我们看到它调用 initGlobalAPI 函数,并把 Vue 作为参数传进去,我们先不着急看 initGlobalAPI 内部逻辑,因为我们连它传入的这个参数 Vue 是啥都不知道,工欲善其事,必先利其器。我们看到文件最顶上 Vue 是从同目录下 instance/index 文件中导出的。 我们打开文件看一下:

哦,原来导出的 Vue 是一个函数,而且函数名称还是大写的,所以可以当作构造函数使用,除此之外,我们还看到在函数下方还有一系列以 xxxMixin 为首的函数调用,而且也把 Vue 这个函数当作参数传入,我们可以猜测这些函数调用就是执行各种初始化操作,而且在我们 new Vue 的时候,其实下面的那些 xxxMixin 都已经执行过了,new Vue 时调用的 _init 方法实际上是在 initMixin 中的,所以我们把它归类到 initMixin 中,可以初步得到以下初始化流程图:

具体每个函数都干了哪些事,我们接下来就按照上边流程图的顺序来逐个进行分析。

initMixin

_init 方法所在文件(src/core/instance/init.ts)

它是 Vue 原型上的一个方法,接收一个 options 参数,我们主要分析它的核心逻辑:

1、首先将 this 赋值给 vm

这里的 this 就是 new Vue 时构造出来的实例对象,所以 vm 特指 Vue 实例对象。

函数外部有一个 uid 变量,默认从 0 开始

接着往 Vue 实例对象上添加一个 _uid 属性作为唯一标识,值默认是 0,赋值完后 uid 自增(这样下一个 new Vue 构造的实例,它的 _uid 就是 1,以此类推)

vm 上添加 _isVue 属性,值设为 true,这个属性用来标记当前 vm 是一个 Vue 的实例(也就是通过 new Vue 构造出来的)

后边还设置了其他的一些属性,咱们先不用管,后边有出现会提到的。

然后我们来看关键方法 mergeOptions,从名字上我们就可以知道它是用于合并选项的,调用 mergeOptions 并将返回结果赋值给 vm.$options

我们先来分析它的传参,第一个参数是调用 resolveConstructorOptions 函数的返回值 那么调用 resolveConstructor 函数传了 vm.constructor(实例对象的 constructor),这里通过原型相关的知识可以得知,实例的 constructor 指向实例化它的构造函数,那这里就是 Vue 构造函数了,所以就是:

js 复制代码
vm.constructor === Vue // true

我们来看下 resolveConstructor 函数内部逻辑:

  • 接收一个参数 Ctor,我们刚才传了 vm.constructor(相当于 Vue 构造函数)
  • 取得 options 选项
  • 判断 Ctor.super 有没有(super 关键字会指向其父类的构造函数,即判断有无父类),这一般在子组件通过 extend 继承父组件时会存在这种情况,我们目前没有父子继承关系,所以不进入判断内部
  • 直接返回 options

mergeOptions 的第二个参数是 _init 接收的参数 options,也就是 new Vue 时传给构造函数中的对象,第三个参数是当前组件实例 vm。传给 mergeOptions 的三个参数都分析完后,来看下 mergeOptions 函数

通过官方注释得知,就是将两个选项合并成一个,parent 就是 Vue 构造函数的默认 options,child 是我们 new Vue 时传给构造函数的,vm 就是当前实例

  • 调用 checkComponent

checkComponent 用于检测组件的名称,前提是传入的 options 上的 components 不为空,枚举 options.components 上的组件名,调用 validateComponentName 函数校验

validateComponentName 接收参数 name(即组件名称),采用正则表达式校验 name 是否符合要求,不符合就调用 warn 函数,提示错误信息。 通过正则表达式校验,下边还需要校验是不是和 Vue 中一些内置名称冲突 调用 isBuiltInTag 和 isReservedAttribute 函数相当于是调用了 makeMap 返回的函数,我们看看 makeMap 函数

接收 str 字符串参数,expectsLowerCase 布尔值是否期望小写。makeMap 函数内部逻辑:

  • 构建一个空 map
  • 分割传入的 str 放到 list 数组
  • 遍历数组,将数组中的元素放到 map 中作为键,其值默认为 true
  • 返回值:如果 expectsLowerCase 为 true,返回一个函数,这个函数接口一个 val 参数,将 val 转为小写后去 map 中找到这个键对应的值,如果 expectsLowerCase 为 false,返回一个接收 val 参数的函数,函数直接返回 map 中 val 这个键对应的值。

总结:isBuiltInTag 函数对应的 makeMap 返回的函数内部的 map 为:

js 复制代码
map: {
    'slot': true,
    'component': true
}

这些是 Vue 中内置的标签

isReservedAttribute 函数对应 makeMap 内的 map 也是:

js 复制代码
map: {
    'key': true,
    'ref': true,
    'slot': true,
    'slot-scope': true,
    'is': true
}

这些是 Vue 中预定义的属性

默认调用 makeMap 时第二个参数是 true,也就是我们组件名称传进去会先转为小写,然后再去 map 中匹配,说白了,我们写的组件名称中不管大小写,统一转为小写后匹配上边的 map,只要匹配上键(属性名),它们的值刚好是 true,那就进入判断抛出错误提示信息。

  • 校验完组件名后,接着调用 normalizeProps 规范化 props 接收 options 选项以及 vm 实例对象

    • 获取 options 上的 props,为空直接返回

    • 情况一:props 如果是数组形式

遍历数组元素,进行类型判断,数组中的元素必须是字符串类型,否则抛出错误信息,随后调用 camelize 将元素 val 传进去做处理,camelize 就是将传入的字符串转为驼峰命名的形式并返回 camelizeRE 是要匹配的正则表达式,括号内是捕获组,(\W) 就是捕获一个英文单词,对传入的字符串使用 replace 方法,就是将正则表达式匹配的部分替换为第二个参数指定的部分。举个例子,假设我们传入的 str 如下:

js 复制代码
my-component

那么 camelizeRE 正则匹配到的就是 -c,捕获组捕获到的就是 c,replace 第二个参数是回调,回调第一个参数是匹配到的完整字符串,第二个参数是捕获到的字符,这里是小写 c,将其转为大写(调用 toUpperCase()),那这里就是将正则匹配到的 -c,替换成大写 C,替换后的字符如下:

js 复制代码
myComponent

这个 name 就是驼峰式的了,然后放到 res 对象中作为属性,值是一个对象,对象中默认有一个 type: null 的属性,放到 res 中就是

js 复制代码
res: {
    'xxx': {
        type: null
    }
}
  • 情况二:props 是对象形式 对象处理也很简单,枚举对象上的属性,拿到属性值 val,同样对属性名进行处理,转为驼峰命名形式,接着往 res 上放这个属性,如果 val 本身是一个对象,那就直接将这个对象作为属性值,反之就将 type 放到一个对象中,属性值设为这个 val。分别对应下边两种情况
js 复制代码
props: {
    name: String,
    age: {
        type: Number
    }
}

name 属性值不是一个对象,将 name 属性值(String)作为新对象中 type 的属性值,放到 res 中就是

js 复制代码
res: {
    name: {
        type: String
    }
}

age 属性值本身是一个对象,放到 res 中是

js 复制代码
res: {
    age: {
        type: Number
    }
}
  • props 不是数组也不是对象 抛出错误信息,提示 props 必须是一个数组或对象

  • 处理好的 res 覆盖 options.props

可以看到上边就是对用户传的 props 进行归一化,所谓归一化就是将不同的形式转为相同的形式,上边归一化后的形式就是:

js 复制代码
props: {
    key1: {
        type: String
    },
    key2: {
        type: Number
    },
    key3: {
        type: null
    }
}
  • 调用 normalizeInject 规范化 inject

normalizeInject 内部处理逻辑和 normalizeProps 很相似,都是分为数组和对象两种情况处理,如果两者都不是就抛出错误信息。只是 props 中是处理 type,injects 是 from。这里对象的处理中,如果枚举的属性其属性值是一个对象,会调用 extend 进行处理,传入两个参数,第一个是一个对象,有 from 属性,属性值是这个枚举属性,第二个参数是枚举属性对应的值,这个值是个对象,看看 extend 函数。

就是往第一个参数也就是目标对象上混入属性,枚举第二个参数传入的 val 对象,往第一个参数({from: key})混入第二个参数中的属性(如果第二个参数 val 对象上也存在 from 属性,则 val 对象上的 from 属性值覆盖源对象上的 from 属性值,混入后,源对象上就不仅只有 from 属性了,还可能有其他的一些属性)。

最后归一化后的格式就是:

js 复制代码
injects: {
    key1: {
        from: xxx,
        ..., // 其他一些属性
    }
}
  • 调用 normalizeDirectives 规范化 directives

    • 获取 directives 选项
    • 枚举选项上的属性,获取属性值
    • 判断属性值是不是一个函数,是的话就将选项上当前属性的属性值重新赋值为一个对象,对象上的 bind 和 update 属性为这个源函数
  • 枚举 parent(Vue 构造函数的 options 选项),调用 mergeField 将属性作为实参传入

  • 枚举 child(用户传入的 options 选项),如果属性在 parent(Vue 构造函数中的 options)中不存在,调用 mergeField 函数,将 key 属性作为实参传入

接着看看这个核心的 mergeField 函数

strat 是一个函数,会先从 strats 中取,strats 在文件顶部声明

默认值为 config.optionMergeStrategies, 这个 config 在(src/core/config.ts)文件中,默认是个空对象

从上边的接口类型声明来看,这个对象上的属性是字符串类型,属性值是个函数

那么如果 strats 上没有这个属性对应值,就会使用默认的策略 defaultStrat

默认策略接收父属性值和子属性值作为参数,如果子属性值不为 undefined 的话优先返回它,否则再取父属性值,这个返回值就作为最后合并好的 options 上该属性对应的值。

那么上边先枚举 Vue 构造函数的 options 上的属性,如果用户传的 options 也存在该属性,优先使用用户传的,这样后边枚举用户传的 options 时就仅需要处理 Vue 构造函数 options 上没有的属性了。

  • 最后返回合并好的终极 options

至此,mergeOptions 函数就分析完了,主要流程就是组件名校验归一化 propsinjectsdirectives,然后将 Vue 构造函数及用户的 options 进行一个合并,最后返回。

继续往下看:

  • initProxy 所在文件(src/core/instance/proxy.ts),在文件顶部声明了一个 initProxy 变量,然后赋值为一个函数: 接收 vm 实例作为参数,首先判断 hasProxy 值,hasProxy 判断浏览器是否支持 Proxy 这个 API,即不为 undefined,且调用 isNative 要返回 true,isNative 会判断传入参数是一个函数,且是 JavaScript 内置的函数(内置函数调用 toString 方法会返回包含 [native code] 的字符串)

进入判断后先获取 vm 上的 options,然后定义 handlers 配置项,然后创建一个代理实例赋值给 vm 上的 _renderProxy 属性,如果 hasProxy 为 false, _renderProxy 属性就赋值为 vm 实例本身,接着我们看下代理配置项 handlers 取值,有 getHandler 和 hasHandler 两种:

getHandler 中定义了一个 get 函数,参数是 target(这里是 vm 实例) 和 key(属性)

也就是我们读取 vm 上的某个属性时,会触发 get 函数拦截,首先判断这个 key 属性是字符串且不在 target(vm 实例)上,接着继续判断 key 属性在不在 vm 实例的 $data(也就是我们组件中写的 data 对象里)上,如果在就调用 warnReservedPrefix 函数抛出错误提示,如果不在 vm 实例上也不在 vm.$data 属性上,调用 warnNonPresent 函数抛出另一个错误信息

因为属性 key 它不在 vm 上,但在 vm.$data 上,所以这里提示信息意思就是这个 key 属性必须访问 $data.key 上的

这种情况就是 key 属性即不在 vm 上也不在 vm.$data 上,抛出错误信息表示属性或方法在实例上未定义但是存在引用

再看下 hasHandler 函数的 has 函数,has 代理方法是针对 in 操作符的,比如我们这里判断一个属性:

js 复制代码
name in obj

这就会触发代理对象 obj 的 has 拦截方法,同理上边代理对象是 vm,使用 in 操作符判断某个属性是否在 vm 上时就会触发 has 函数拦截。 has 常量取值取决于 key 属性在不在 target(vm)上

isAllowed 常量取值满足以下其中一种就是 true,都不满足就为 false:

js 复制代码
allowedGlobals(key) ||
typeof key === 'string' &&
          key.charAt(0) === '_' &&
          !(key in target.$data)

allowGlobals 函数也很简单,有我们熟悉的 makeMap 函数,第二个参数不传,就是内部匹配名称时不会转为小写去匹配,内部 map 的组成由每个逗号分割的关键字作为属性,其属性值默认为 true,调用这个函数时,如果当前 key 和 map 中某个属性名称匹配,那么取值就是 true 下面就是判断 has 取值为 false(即属性不在 vm 上) 且 isAllowed 也为 false,内部逻辑和 getHandler 中的 get 函数相似。最后返回 has || !isAllowed 逻辑或的取值。

在 initLifecycle 调用前,还有一句赋值语句,将自身引用保持在了自身的 _self 属性上

  • initLifecycle

initLifecycle 的初始化,就是往 vm 实例上添加一些属性,并赋予默认值,比如我们熟悉且常用的 $refs$parent$root$children 对象,

  • initEvents 事件初始化

核心就是 updateComponentListeners 方法,这个方法内部又调用了 updateListeners 方法

updateListeners 方法内部就是对新老事件进行处理(更新事件 on 监听,包括 add 新增事件和 remove 移除事件)

  • initRender

内部定义了 $slots$createElement$attrs$listeners

  • beforeCreate

调用了 beforeCreate 生命周期钩子

  • initInjections

处理 injects 信息,将 injects 对象中的每个属性转为响应式的,这样就能和在 data 中声明的属性一样使用了,这里的关键点就是 injects 比 data 和 props 先初始化。

  • initState

初始化 props、setup(vue3 语法糖)、methods、data、computed、watch(这边的初始化逻辑留到响应式系统篇章再来分析)

  • initProvide

处理 provide 信息,将 provide 对象内的每个属性转为响应式,provide 的初始化在 data、methods 初始化之后

  • created

调用 created 生命周期钩子

  • $mount

判断选项中如果有 el 节点,那就作为实参传入 vm.$mount 函数中

这里的 el 就是我们常说的挂载的根容器 app

stateMixin

所在文件:src/core/instance/state.ts,内部逻辑也很简单

  • 拦截 Vue 原型上的 $data$props
  • Vue 原型上添加 $set 方法
  • Vue 原型上添加 $delete 方法
  • Vue 原型上添加 $watch 方法

看看是怎么拦截的

访问 Vue.prototype.$data 时实际上是这样访问 vm._data(当前 vm 实例上的 _data 对象)

访问 Vue.prototype.$props 时实际上是这样访问 vm._props(当前 vm 实例上的 _props 对象)

如果是修改 Vue.prototype.$data 或者 Vue.prototype.$props,会走 set 拦截方法抛出错误信息

eventsMixin

eventsMixin 函数接收 Vue 构造函数作为参数,往构造函数原型上添加四个方法:

  • $on
  • $once
  • $off
  • $emit

lifecycleMixin

lifecycleMixin 函数接收 Vue 构造函数作为参数,往 Vue 原型添加三个方法:

  • _update
  • $forceUpdate
  • $destroy

renderMixin

renderMixin 函数接收 Vue 构造函数作为参数,往 Vue 原型添加两个方法:

  • $nextTick
  • _render

调用 installRenderHelpers 函数时将 Vue.prototype 作为实参传入

target 就是 Vue.prototype,往 Vue 原型上添加各种以 _ 开头的方法

initGlobalAPI

接收 Vue 构造函数作为形参

先代理 Vue 上的 config 属性(是个对象),属性描述符项是 configDef,提供 get 和 set 函数,当你尝试修改 Vue.config 时会被 configDef 的 set 函数拦截,抛出错误信息,意思就是不能替换 Vue.config 对象。如果是读取 Vue.config,就被 configDef 的 get 函数拦截,直接返回了 config,这个 config 是个对象(所在文件:core/config.ts)中,就是暴露了一堆全局属性(比如 async、devtools 等)

接着往 Vue.util 对象上添加一些方法 Vue 官方也提供了注释,意思就是这些不被认为是公共的 API,虽然暴露出去你能用,但是不建议用,因为这几个 API 其实是 Vue 内部自己在用的。

再下边就往 Vue 上添加了几个方法,在之后文章会详细介绍

下边初始化 Vue.options 为一个空对象,遍历 ASSETS_TYPE 数组中的元素然后追加到 options 上

ASSETS_TYPE 所在文件(src/shared/constants.ts 数组中每个元素就是遍历时的 type,是字符串类型,往 type 对应元素名称后边拼上 s 后作为属性名,属性值默认是空对象,遍历追加完后 Vue.options 上就有如下属性:

js 复制代码
Vue.options = {
    components: {},
    directives: {},
    filters: {}
}

接着往 Vue.options 追加 _base 属性,属性值是 Vue 本身 现在 Vue.options 就多了一个属性

js 复制代码
Vue.options = {
    _base: Vue,
    components: {},
    directives: {},
    filters: {}
}

然后是 extend 函数,将 Vue.options.components 作为第一个参数(是一个对象),builtInComponent 作为第二个参数

builtInComponent(所在文件:src/core/components/index.ts),其实就是暴露了一个 KeepAlive 组件

那看下 extend 函数(所在文件:src/shared/utils.ts

其实很简单,就是往目标对象混合属性,目标对象就是传的第一个参数(Vue.options.components),混合的属性在第二个参数里,刚才看了是一个对象,对象里边有 KeepAlive 属性(组件)

混合后就是

js 复制代码
Vue.options = {
    _base: Vue,
    components: {
        KeepAlive
    },
    directives: {},
    filters: {}
}

接着下边又往 Vue 上添加了一系列方法:

initUse,往 Vue 上添加了 use 方法

initMixin,往 Vue 上添加 mixin 方法

initExtend,往 Vue 上 添加 extend 方法

initAssetRegisters,往 Vue 上添加 componentdirectivefilter 方法,ASSET_TYPES 刚才看过了是个数组,数组中每个元素作为函数名,在 Vue 上注册成了函数。

至此,从 new Vue() 到 $mount 期间的一系列初始化操作我们就看完了,上边为了先分析传给 initGlobalAPI 的参数 Vue,所以就先分析了 initMixin 等函数,实际上 initGlobalAPI 作为核心入口文件是最先执行的。下边再来看看流程图,这会就清晰多了

Vue 初始化流程,每一步都做了哪些初始化,现在看就一目了然了,下篇文章我们就进击 Vue 的响应式系统~

相关推荐
低代码布道师8 小时前
互联网医院18:前端进阶——CSS“父相子绝”打造专业级卡片交互
前端·css·低代码·小程序·云开发
听风说图8 小时前
AI设计类产品分析:Lovart
前端
北辰alk8 小时前
Vue 父子组件双向绑定的终极指南:告别数据同步烦恼!
vue.js
luffy54599 小时前
css实现五星好评样式
前端·css·html
晓风残月淡9 小时前
专业Web打印控件Lodop使用教程
前端
非凡ghost9 小时前
遥控精灵APP(手机家电遥控器)
前端·windows·智能手机·firefox·软件需求
ohyeah9 小时前
React 自定义 Hook 实战:从鼠标追踪到待办事项管理
前端·react.js
松涛和鸣10 小时前
DAY43 HTML Basics
linux·前端·网络·网络协议·tcp/ip·html
前端 贾公子10 小时前
剖析源码Vue项目结构 (一)
前端·javascript·vue.js