Vue2 源码学习

98 阅读11分钟

变化点

  • ts
  • pnpm
  • Composition api

文件结构

  • compiler-sfc

  • Src

    • compiler - 编译器

    • core - 通用运行时

    • platforms/web - web 平台运行时

    • v3 - vue3 相关

  • packages

    • compiler-sfc
    • server-renderer
    • template-compiler

源码调试

 npm i pnpm -g
 ​
 // package.json 增加源码映射,方便调试
 "dev": "rollup -w -c scripts/config.js --sourcemap ..."

基本原理

当一个Vue 实例创建时,Vue 会遍历data 中的属性,用Object.defineProperty 将它们转为getter/setter,并且在内部追踪相关依赖,在属性被访问和修改时通知变化。每个组件实例都有相应的watcher 程序实例,它会在组件渲染的过程中把属性记录为依赖,之后当依赖项的setter 被调用时,会通知watcher 重新计算,从而致使它关联的组件得以更新。

入口文件

配置文件scripts/config.js => full-dev => entry-runtime-with-compiler.ts

找到vue 构造函数:

=> src/platforms/web/entry-runtime-with-compiler.ts

=> src/platforms/web/runtime-with-compiler.ts

=> src/platforms/web/runtime/index.ts

  • 扩展浏览器平台特有的指令和组件
  • 增加一个原型方法patch
  • 实现了$mount

=> src/core/index.ts

  • 初始化全局API,如Vue.component/directive/filter/use/…

=> src/core/instance/index.ts

  • 声明Vue 构造函数
  • 混入,声明Vue 实例属性和方法

初始化流程

new Vue

function Vue –> this._init() =>

initMixin(Vue) =>

stateMixin(Vue) =>

eventsMixin(Vue) =>

lifecycleMixin(Vue) =>

renderMixin(Vue) =>

initMixin(Vue) –> Vue.prototype._init

  • merge options

  • 准备组件实例的属性和方法

    • initLifecycle - 初始化组件实例相关的属性,如$parent$root$children$refs
    • initEvents - 组件自定义事件的监听
    • initRender - 声明_c$createElement,会在render 函数中被调用
  • 派发beforeCreate - callHook(vm, ‘beforeCreate’)

  • 初始化组件的数据状态(祖辈注入、组件本身)

    • initInjections
    • initState - 组件本身状态:props methods data computed watch
    • initProvide
  • 派发created - callHook(vm, ‘created’)

$mount()

【目标】

  • 将组件数据和状态渲染到宿主元素上
  • 建立一个更新机制(如果响应式数据变化,组件会重新更新)

【过程】

$mount() –> mountComponent(会传入组件实例)

  • 派发beforeMount

  • updateComponent(第一次进来并不会执行,只是声明函数)

  • new Watcher(创建watcher 实例,构建更新机制)

    • new Watcher() 后updateComponent() 函数会执行

    • vm._render() =>

    • vm._update(vnode) –> vm.__patch__($el, vnode)

      • patch –> createElm –> createChildren –> createElm => createComponent

生命周期

callHook

createLifeCycle

多个钩子函数注册:

  • composition api 中常见
  • createLifeCycle() => injectHook()

挂载顺序

断点进入:Vue.prototype.$mount –> mountComponent –> updateComponent(会向下递归找子组件,包括创建、挂载,因此在递归结束之前是看不到父组件callHook(‘vm’, ‘mounted’)

=> 父组件先创建,子组件创建,子组件挂载,父组件挂载

响应式 - 对象/数组

【整体流程 - initState

initProps =>

initSetup =>

initData =>

initMethods =>

initComputed =>

initWatch

initData - 数据响应式

1、获取data 选项

2、校验,避免和props 冲突

3、observe(data) => new Observer() => class Observer

  • 有一个对象(普通对象/数组)就对应一个Observer 实例

  • 观察当前对象是Obj 还是Array,做对应的响应式处理

  • 如果是普通对象,则获取所有key,然后进行遍历,在每一次遍历中调用defineReactive(obj, key, val, …) 为每一个key 做响应式处理:

    • 如果val 是对象的话,需递归处理 - observe()。

    • 该方法内部实现是用Object.defineProperty(obj, key, {}) 进行属性拦截:

      • get 函数若对象中嵌套数组,调用dependArray() - 将数组中所有元素,甚至内部嵌套数组都做一遍依赖收集
      • 若值有变化触发set 函数,会调用dep.notify() 通知更新,然后会调用watcher 的update 方法去更新
  • 如果是数组,会有一个扩展之后的数组方法 - arrayMethods

    • 扩展7 个变更方法,使它们具有变更通知能力
    • 覆盖数组实例原型:(value as any).__proto__ = arrayMethods
    • 如果是非浅层的数组,则需要递归进行响应式处理 - observeArray()

依赖收集

【响应式数据】— 【组件更新函数】建立依赖关系

【整体流程】

1、组件实例挂载时创建Watcher

2、Watcher 实例化过程中执行组件更新函数

3、设置Dep.target 为当前Watcher 实例

4、组件首次执行render 函数访问响应式数据

5、响应式数据get 函数拦截到访问行为,开始依赖收集的过程 => dep.depend()

6、depend 内部调用Watcher.addDep() 保存Watcher 管理的dep

7、在addDep 内部调用addSub 方法添加watcher 实例到当前dep 内部

8、响应式数据变化,set 函数拦截到变更行为,调用dep.notify() 通知相关Watcher 执行更新

【细节】

mountComponent =>

–> updateComponent - 组件更新函数

–> new Watcher(vm, updateComponent) - vm(组件实例),创建watcher 实例,构建更新机制,一个组件可以有多个watcher(如user watcher、component watcher/render watcher)

  • defineReactive =>

  • get =>

    • pushTarget(this) - 推送目标(watcher 实例)
    • value = this.getter.call(vm, vm) - 立刻执行一次getter 函数(组件更新函数 - updateComponent)

defineReactive =>

–> new Dep() - 每次执行defineReactive 就创建一个Dep 实例 => 【一个key 对应一个Dep 实例

–> observe() –> new Observer() –> class Observer

class Observer:

一个对象对应一个Observer 实例

它的构造函数中也有new Dep(),用于在用户动态新增或删除属性时,通知更新

–> Object.defineProperty()

get 方法】

【当前访问的对象的key 和某个组件的更新函数之间建立联系】

【Dep:依赖,视图中的动态项】

【Dep.target:一个watcher 实例(每个组件都有watcher)】

  • dep.depend() - 建立watcher 实例和dep 实例的关系(双方相互保存关系)

    • Dep.target.addDep(this) - 保存和watcher 相关的Dep 信息

      • dep.addSub(this) - 建立Dep和watcher 之间的关系 => 追加watcher 实例到subs 中
  • childOb.dep.depend() - 出现对象嵌套时,子ob 也要和组件的watcher 之间产生关系,便于该对象内部有属性新增或删除时通知组件更新

set 方法】

  • dep.notify()

    • subs 中存储的是管理Dep 的所有watcher,遍历它们,让它们执行更新
    • sub.update()

Object.defineProperty API 必须预先知道要拦截的key 是什么,所以并不能检测对象属性的添加和删除。尽管Vue.js 提供了set 和delete 实例函数,但对用户来说,还是增加了负担。

Vue.set/delete:动态添加和删除对象属性、数组项 =>

  • set()defineReactive(obj, key, val) => obj.__ob__.dep.notify()
  • del()delete obj[key] => obj.__ob__.dep.notify()

更新流程 - 异步更新

update() => (以下三选一)

  • lazy =>

  • sync => run()

  • queueWatcher - watcher 入队 –>

    • nextTick(flushSchedulerQueue) - 将传入的cb 放入callbacks 数组 –>
    • timerFunc() - 异步启动 –> Promise.resolve().then(flushCallbacks)
    • 进入异步环节:执行flushCallbacks() - 负责清空callbacks 数组,并调用数组中存在的flushSchedulerQueue 方法
    • flushSchedulerQueue 方法会遍历watcher queue,然后每个watcher 执行watcher.run() 方法
    • watcher.run() 中执行watcher.get() 方法,get 方法会立刻执行getter 方法,即组件更新函数 - updateComponent()

【nextTick】

nextTick 的核心是利用了Promie、MutationObserver、setImmediate、setTimeout 的原生JS 方法来模拟对应的微/宏任务的实现,本质是为了利用JS 的这些异步回调任务队列来实现Vue 框架中自己的异步回调队列。

虚拟DOM

【虚拟DOM 生成】

initRender() =>

该方法会实现两个实例方法:_c$createElement,它们会在render 函数中被调用,_c会在render 是由编译器生成的组件中被调用;如果是人为手写的render 函数,则调用$createElement,也就是传入render 的h

Vue.prototype._render =>

  • const { render, _parentVnode } = vm.$options - render 就是用户手写的render 函数 - render(h)
  • vnode = render.call(vm._renderProxy, vm. **createElement)vm.createElement**) - vm.createElement 会作为用户编写的render 函数的参数1

createElement –> 实际调用 _createElement =>

_createElement 的作用是返回vnode(虚拟DOM) ,它会判断tag 类型,然后new VNode(config.parsePlatformTagName(tag), data, …)

image-20230303105159922

用户通过执行render 函数获得虚拟DOM 后传给update 函数 - Vue.prototype._update由update 函数转为dom(真实DOM)

Vue.prototype._update =>

  • 获取上次vnode

  • 如果结果不存在,说明是走初始化流程:

    • 调用vm.__patch__(vm.$el, vnode) 方法 –> Vue.prototype.__patch__ - src/platforms/web/runtime/index.ts#L33 –>

      • 里面执行的是patch 方法 - 通过工厂函数createPatchFunction 获得真正web 平台的patch
      • patch 该方法会区分更新流程和初始化流程 => createElm(node, …) - 创建新节点(在宿主元素的旁边插入新创建的树) => removeVnodes([oldVnode], …) - 删除宿主元素
  • 反之走更新流程,跟传入vnode 做对比。vm.__patch__(prevVnode, vnode) - 【此处涉及patch、diff 算法

createElm

在把虚拟DOM 变成真实DOM 过程中,一个元素节点会有样式、特性、属性等,这些也需要进行处理:

1、创建元素节点

2、递归处理子节点

3、如果节点有data 数据,则执行属性、特性、事件、样式等的处理流程 - invokeCreateHooks - 处理元素属性

cbs 是一个对象,形如{ create: [fn1, fn2], update: [...] } <= createPatchFunction cbs 由该函数创建而来

image-20230303144125632

patch、diff 算法

【更新机制建立】

mountComponent 函数内部声明一个updateComponent 组件更新函数,然后创建一个侦听器new Watcher(),这两者间建立了非常紧密的关系,这个内在关系是依赖收集建立的。new Watcher() 的时候,参数2 - updateComponent 传递进去,当前是函数则赋值给getter,由于Watcher 创建的时候会自动执行一次get,get 又会自动执行getter,即updateComponent(组件更新函数),而组件更新函数先执行渲染函数,渲染函数内部访问了诸多的响应式数据,这些响应式数据的get 函数会触发拦截,拦截的同时会做依赖收集,收集的目标就是pushTarget(this),刚好就是当前正在执行的Watcher。于是就建立了这些响应式数据和当前Watcher 之间的关系,也就建立了这个Watcher 和它相配对的组件更新函数的关系。

【diff 算法详解】

vm.__patch__() => patch –> patchVnode(oldVnode, vnode, …) –>

patchVnode 核心逻辑:根据children 和text 情况做对应操作

  • 新节点不是文本节点

    • 新旧节点都有孩子节点,深度优先对比两组子元素updateChildren(elm, oldCh, ch) - diff 算法
    • 只有新节点有孩子节点:清空旧节点文本,批量创建孩子节点
    • 只有旧节点有孩子节点:移除所有孩子节点
    • 旧节点是文本节点:清空旧节点文本
  • 新旧节点都是文本节点:更新文本

updateChildren(elm, oldCh, ch, …)

  • 双端查找相同节点

  • 正常查找相同节点

    • 没在老节点中找到:创建新节点

    • 在老节点中找到:

      • 是相同节点:更新
      • 不是相同节点:创建新节点
  • 查找结束:

    • 新子节点数组未处理完:剩下的节点批量创建
    • 老子节点数组未处理完:剩下的节点批量删除

【整体流程】

  • 创建首尾4 个游标和相对应的4 个节点

  • 循环条件:起始游标不能超过结束游标

    • 前两个条件:游标调整,确保游标对应的节点不为空

    • 开始双端查找:首首、尾尾、旧首新尾(存在移动节点的情况 - 右移)、旧尾新首(存在移动节点的情况 - 左移)

    • 正常查找:从新数组排头拿出节点去老数组中查找相同节点 =>

      • 新的在老的中没找到 - 创建
      • 找到了,如果是相同节点则 - 更新,不同节点则 - 创建
  • 循环结束,后续处理:

    • 老数组结束,新数组多余元素批量创建
    • 新数组子元素结束,老数组剩余的元素批量删除

编译器

Vue 中有个独特的编译器模块,称为compiler,它的作用是将用户编写的template 编译为JS 中可执行的render 函数。

之所以需要这个编译过程是为了便于前端开发能高效的编写视图模板。 前端开发更愿意用HTML 来编写视图,直观且高效。手写render 函数不仅效率低下,而且失去了编译器的优化能力。

编译器不是必须的,vue 中引入编译器主要是为了:

  • 提高易用性
  • 开发效率

【编译时刻】

根据引入vue 的版本不同,编译器执行的时机不甚相同:

  • vue.js:包含编译器,运行时编译
  • vue-runtime.js:不包含编译器,打包工具预编译(如webapck 的vue-loader 插件来预编译.vue 的文件),不支持字符串模板

字符串模板 => 在vue 中的template 中定义的模板,如.vue 的单文件组件模板和定义组件时template 属性值的模板。字符串模板不会在页面初始化参与页面的渲染,会被vue进行解析编译之后再被浏览器渲染。

HTML模板(dom 模板) => 写在html文件中,一打开就会被浏览器进行解析渲染的,所以要遵循html 结构和标签的命名,否则浏览器不解析也就不能获取内容。

Vue 的编译过程就是将template 转化为render 函数的过程:【vue template => render

1、调用parse 方法解析模板,生成AST(JS 对象)。使用大量的正则表达式对模板进行解析,遇到开始标签、闭合标签、文本的时候都会执行对应的钩子(回调函数)进行相关处理。

2、optimize - AST 深加工。Vue 的数据是响应式的,但其实模板中并不是所有的数据都是响应式的。有一些数据首次渲染后就不会再变化,对应的DOM 也不会变化。所以会调用optimize 方法对静态节点做优化,优化过程就是深度遍历AST树,按照相关条件对树节点进行标记。这些被标记的静态节点就可以跳过对它们的比对,对运行时的模板起到很大的优化作用。

3、Generate - 将优化后的AST 树生成为render 函数。

【编译流程】

解析用于将template 转换成抽象语法树AST。

createCompilerCreator

parse –> parseHTML - 有限状态机 =>

  • advance(start[0].length) 通过此函数不断完后移动游标

optimize(标记静态节点) =>

generate –> genElement()

【V2 中transform 这一步在parse 中完成。】

【何时停止状态机】

  • 遇到开始标签时,标签入栈,开启状态机
  • 遇到结束标签,且栈中存在同名开始标签时停止状态机
  • 模板内容被解析完毕时停止状态机

【V3 - 源码】

github1s.com/vuejs/core/…

组件机制

1、如何声明组件

2、_c 如何处理组件

3、patch 过程

【细节】

initAssetRegisters - 组件声明做了什么

把自己定义的组件配置对象扩展成为VueComponent 类

initRender() –> vm._c =>

createElement –> _createElement –> createComponent =>

  • installComponentHooks(data) –> hooksToMerge –> componentVNodeHooks

    • init:createComponentInstanceForVnode => child.$mount(),在这个过程中进行组件实例创建,并将vdom 变成真实dom。
    • prepatch
    • insert
    • destroy
  • new VNode() - 创建组件vdom

patch –> createElm –> createComponent - 执行vnode 的hooks,如init

事件实现机制

【事件处理整体流程】

1、编译阶段:处理为data 中的on

<div id="demo">
    <h1>事件处理机制</h1>
    <!-- 普通事件 -->
    <p @click="onClick">this is p</p>
    <!-- 自定义事件 -->
    <comp @myclick="onMyClick"></comp>
</div>


Vue.component('comp', {
	template: `<div @click="$emit('myclick')">this is comp</div>`
})
const app = new Vue({
	el: '#demo',
	methods: {
		onClick() {console.log('普通事件')},
		onMyClick() {console.log('自定义事件')}
	}
})
f anonymous(
) {
with(this){return _c('div', {attrs: {"id": "demo"}}, [
    _c('h1',[_v("事件处理机制")]),_v(" "),
    _c('p',{on:{"click":onClick}},[_v("this is p")]),_v(" "),
    _c('comp', {on: {"myclick": onMyClick}})
],1)}
}

2、 初始化阶段

在Vue 当中,hooks 可以作为一种event,在Vue 的源码中,称之为hookEvent。

<Table @hook:updated="handleTableUpdate"></Table>

场景:有一个来自第三方的复杂表格组件,表格进行数据更新的时候渲染时间需要1s,由于渲染时间较长,为了更好的用户体验,希望在表格进行更新时显示一个loading 动画。修改源码这个方案可行,但是不优雅。

callHook src/core/instance/lifecycle.js - 调用生命周期钩子时若发现标记则额外派发hook 事件

$on src/core/instance/event.js - 监听时若发现是hook 事件则做一个标记

插槽

<div>
    <comp1>
        <span>abc</span>
    </comp1>
    <comp2>
        <template v-slot:default>abc</template>
        <template v-slot:foo="ctx">
        	{{ ctx.abc }}
        </template>
    </comp2>
</div>

Vue.component('comp1', { template: '<div><slot></slot></div>' })
Vue.component('comp2', {
	data() {
		return { abc: 'abc from comp2' }
	},
	template: '<div><slot></slot><slot name="foo" :abc="abc"></slot></div>'
})
(function anonymous(
) {
with(this) {return
    _c('div',{attrs: {"id": "demo"}},[
        _c('h1',[_v("插槽处理机制")]),_v(" "),
        _c('comp1',[_c('span',[_v("abc")])]),_v(" "),
        _c('comp2',{
        	scopedSlots:_u([
                {key:"default",fn:function(){return [_v("abc")]},proxy:true},
                {key:"foo",fn:function(ctx){return [_v(_s(ctx.abc))]}}
            ])
    	})
    ],1)}
})
// src/core/instance/render.js
vm.$slots = resolveSlots(options._renderChildren, renderContext)

resolveSlots() 函数从父组件中获取渲染结果VNode,它们被存入default,这就是默认插槽的内容。这就是能在render 函数中访问this.$slots.default 获取默认插槽内容的原因。这里renderContext 就是父组件实例,显然如果有动态内容要从它里面获取。

comp1 组件的渲染函数在使用$slots 中的内容。

(function anonymous(
) {
    with(this){return _c('div',[_t("default"),_v(" "),_t("foo",null,{"abc":"abc from comp"})],2)}
})

这里_t 就是renderSlot() 的别名,它会用到slots 或scopedSlots 的内容。

// src/core/instance/render-helpers/render-slot.js
const scopendSlotFn = this.$scopedSlots[name]
let nodes
if (scopedSlotFn) {
    // ...
} else {
    nodes = this.$slots[name] || fallback
}