Skip to content

Latest commit

 

History

History
502 lines (389 loc) · 16.4 KB

vue2源码.md

File metadata and controls

502 lines (389 loc) · 16.4 KB

转Vue 3前再读一次Vue2源码

Vue3如火如荼,再不读估计以后就再也不会读了

找到vue代码入口

首先,从 GitHub - vuejs/vue 下载源码

直接看package.json文件,mainmodule字段代表都是已经构建好的最终代码

那就从npm scripts看起 dev -> rollup -w -c scripts/config.js —environment TARGET:web-full-dev -> scripts/config.js -> web-full-dev命令对应的文件 src/platforms/web/entry-runtime-with-compiler.js -> 一路向下找到入口文件src/core/instance/index.js

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

调试vue源码

在开始读源码之前,先把调试源码的工作准备好 首先,在dev命令中加一个配置项--sourcemap,完整命令如下

"dev": "rollup -w -c scripts/config.js --sourcemap --environment TARGET:web-full-dev",

然后npm run dev启动,rollup会在dist目录下生成一份带有映射关系的vue.js文件,并且会监听更改

利用这份dist/vue.js进行调试就可以了 在examples目录下建一份html文件,然后引入上面的dist/vue.js文件

WebStorm提供了一站式debug,非常方便 如下直接右键debug html文件,在源码需要的地方打断点即可开始 vscode也提供了类似的功能,自行摸索一下

Vue对象初始化

Vue原型挂载

通过这五个方法,可以看出instance/index.jsVue.prototype上挂载了一些方法

  • initMixin(Vue)
  • stateMixin(Vue)
  • eventsMixin(Vue)
  • lifecycleMixin(Vue)
  • renderMixin(Vue)

Vue属性挂载

回到core/index.js

import Vue from './instance/index'
import { initGlobalAPI } from './global-api/index'
import { isServerRendering } from 'core/util/env'
import { FunctionalRenderContext } from 'core/vdom/create-functional-component'

initGlobalAPI(Vue)

Object.defineProperty(Vue.prototype, '$isServer', {
  get: isServerRendering
})

Object.defineProperty(Vue.prototype, '$ssrContext', {
  get () {
    /* istanbul ignore next */
    return this.$vnode && this.$vnode.ssrContext
  }
})

// expose FunctionalRenderContext for ssr runtime helper installation
Object.defineProperty(Vue, 'FunctionalRenderContext', {
  value: FunctionalRenderContext
})

Vue.version = '__VERSION__'

export default Vue

主要通过initGlobalAPI(Vue)在Vue对象上添加了一波属性,主要是一些静态属性和方法

Vue完整初始化

再往下走,回到 src/platforms/web/runtime/index.js,主要是对web平台添加一下配置、组件、指令(只看大方向,不看具体内部实现和逻辑)

再往外走,到src/platforms/web/entry-runtime-with-compiler.js

  • 覆盖$mount方法
  • 挂载compile方法,提供了编译template的能力(完整版和运行时版本的区别)

做个小结

  • instance/index.jsVue.prototype进行属性和方法挂载
  • core/index.jsVue进行属性和方法挂载
  • runtime/index.js 对不同platform,进行配置、组件、指令的差异化挂载
  • entry-runtime-with-compiler.js$mount方法增加compile能力

再回src/core/runtime/index,jsthis._init(options),一切从这里开始 -> Vue.prototype._init -> 经过一系列的初始化以及合并配置工作(此处省略一大堆) -> 从src/core/instance/lifecycle.jsVue.prototype.$mount -> mountComponent

响应式系统

之前写的 Vue 响应式原理核心 · Issue #63 · amandakelake/blog · GitHub

本质上就是对Object.defineProperty() - JavaScript | MDN的理解和运用,再加上 Watcher、Dep组合的发布订阅模式,组成vue的核心原理

图片转载于 图解 Vue 响应式原理 - 掘金

核心流程

  • new Vue开始初始化,通过Object.defineProperty监听data里面的数据变化,创建Observer并遍历data创建Dep来收集使用当前dataWatcher,每个key都会new一个Dep
  • 编译模板时会每个组件都会创建一个Watcher,同时会将Dep.target标识为当前Watcher
  • 编译模板/mountComponent时,如果使用到了数据,会触发data.get -> Dep.addSub将相关的Watcher收集到Dep.subs
  • 数据更新触发data.set -> Dep.notify -> 通知相关的 Watcher调用vm._render更新DOM
  • 触发watcherupdate过程,利用队列做了优化,在nextTick后执行所有watcherrun方法,最后再执行它们的回调函数

Dep.target = Watcher的概念有点绕,可以多看几遍这里 Vue源码详细解析(一)—数据的响应化 · Issue #1 · Ma63d/vue-analysis · GitHub

下面是一份简单的响应式系统的实现

class Observer {
    constructor(data) {
        this.walk(data);
    }

    walk(data) {
        // 此处简化,只处理对象
        if (data instanceof Object) {
            for (let key in data) {
                if (data.hasOwnProperty(key)) {
                    this.defineReactive(data, key, data[key]);
                }
            }
        }
    }

    /**
     * getter中收集依赖,setter中触发依赖
     */
    defineReactive(data, key, val) {
        const _this = this;
        // 每个key实例一个dep
        const dep = new Dep();
        Object.defineProperty(data, key, {
            enumerable: true,
            configurable: true,
            get: function () {
                // 将当前的watcher实例收集到依赖中
                Dep.target && dep.addSub(Dep.target);
                return val;
            },
            set: function (newVal) {
                if (val === newVal) {
                    return;
                }
                val = newVal;
                // 递归遍历新值
                _this.walk(newVal);
                // 触发依赖
                dep.notify();
            },
        });
    }
}

class Dep {
    static target;

    constructor() {
        this.subs = [];
    }

    addSub(sub) {
        if (sub && sub.update) {
            this.subs.push(sub);
        }
    }

    notify() {
        this.subs.forEach(sub => sub.update());
    }
}

class Watcher {
    constructor(data, key, callback) {
        // getter收集依赖,getter不能传参,所以通过闭包传进去
        Dep.target = this;
        this.data = data;
        this.key = key;
        this.callback = callback;
        this.value = data[key];
        // 完成某个属性的依赖收集后,清空Dep.target
        // notify方法会重新调用getter(重新获取值,重新收集依赖)
        // 清空Dep.target,防止notify中不停绑定Watcher与Dep -> 代码死循环
        Dep.target = null;
    }

    /**
     * 更新视图 vm._render / 执行user逻辑
     */
    update() {
        this.value = this.data[this.key];
        this.callback(this.value);
    }
}

const data = {
    a: 1,
    b: 1,
};

new Observer(data);

new Watcher(data, 'a', (value) => {
    console.log('watcher update 新值 -> ' + value);
});

data.a = 2;
data.a = {
    c: 'c',
};

new Watcher(data.a, 'c', (value) => {
    console.log('watcher update 新值 -> ' + value);
});

data.a.c = 'hello new world';

上面代码直接跑的结果如图

依赖收集 | Vue.js 技术揭秘 黄老的课永远可以信赖

  • computed的本质是computed watcher
  • watch的本质是user watcher

Patch和Diff原理

DOM操作是昂贵的,尽量减少DOM操作 找出必须更新的节点,非必要不更新

数据发生变化时,会触发渲染Watcher的回调函数,执行组件更新

// src/core/instance/lifecycle.js

updateComponent = () => {
  vm._update(vm._render(), hydrating)
}

new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) {
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

组件更新调用了 vm._update

// src/core/instance/lifecycle.js
Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
  const vm: Component = this
  const prevEl = vm.$el
  const prevVnode = vm._vnode
  vm._vnode = vnode
  if (!prevVnode) {
    // initial render
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }
	// ...
}

深入 patch 代码细节前,先记住vue的diff算法特点:同层比较,不会跨层级不同节点直接删除

vue和react的diff算法大同小异,网上大部分文章都来源于这篇2013年的 React’s diff algorithm中文版本 包括这张传遍大街小巷的图

执行vm.__patch__的逻辑在src/core/vdom/patch.js中,patch主要处理新旧节点是否相同

  • 新旧节点不同:创建新节点 -> 更新父占位符节点 -> 删除旧节点
  • 新旧节点相同:获取children,diff child做不同更新逻辑 -> 比较核心的 updateChildren方法

先看patch的核心逻辑,以下代码对源码做了精简和注释,方便理解

function patch(oldVnode, vnode) {
    if (!oldVnode) {
        // empty mount (likely as component), create new root element
        createElm(vnode);
    } else {
        if (sameVnode(oldVnode, vnode)) {
            // 二、新旧节点相同, diff children
			  // patch existing root node
            patchVnode(oldVnode, vnode);
        } else {
            // 一、新旧节点不同的情况

            const oldElm = oldVnode.elm;
            const parentElm = nodeOps.parentNode(oldElm);

            // 1、创建新节点
            createElm(vnode);

            // 2、更新父的占位符节点
            if (parentElm) {
                // 更新逻辑
            }

            // 3、删除旧节点
            removeVnodes(oldVnode);
        }
    }

	  // 返回vnode,此时vnode.el已经对应了真实的dom
    return vnode;
}

以上可以看出,打补丁做的事情就算给vnode.el对应到真实的dom上面

看下辅助方法sameVNode,它的目的就是判断两个节点是否值得比较 比较逻辑很简单,就是先看 keykey不同则是不同组件,再开始判断 tag、isComment、data、input等类型

function sameVnode (a, b) {
  return (
    a.key === b.key &&
    a.asyncFactory === b.asyncFactory && (
      (
        a.tag === b.tag &&
        a.isComment === b.isComment &&
        isDef(a.data) === isDef(b.data) &&
        sameInputType(a, b)
      ) || (
        isTrue(a.isAsyncPlaceholder) &&
        isUndef(b.asyncFactory.error)
      )
    )
  )
}

只有当两个节点值得比较时,才会进入patchVnode(oldVnode, vnode)的流程

function patchVnode (oldVnode, vnode) {

    const elm = vnode.elm = oldVnode.elm
    
    // 如果新旧节点相同,return
    if (oldVnode === vnode) {
        return
    }

    // 如果新旧节点都是文本节点,return
    if (vnode.isStatic && oldVnode.isStatic && vnode.key === oldVnode.key) {
        vnode.componentInstance = oldVnode.componentInstance
        return
    }

    // 1、prepatch -> updateChildComponent, 更新vnode对应实例的属性,如$vnode、slot、listeners、props等
    prepatch(oldVnode, vnode)

    // 2、执行update钩子以及用户自定义的update方法
    callUpdateHook(vnode)


    // 3、patch
    const oldCh = oldVnode.children
    const ch = vnode.children

    // 不是文本节点
    if (!vnode.text) {
        if (oldCh && ch) {
            if (oldCh !== ch) {
                updateChildren(elm, oldCh, ch)
            }
        } else if (ch) {
            if (oldVnode.text) {
                nodeOps.setTextContent(elm, '')
            }
            addVnodes(elm, ch)
        } else if (oldCh) {
            removeVnodes(oldCh)
        } else if (oldVnode.text) {
            nodeOps.setTextContent(elm, '')
        }
    //  是文本节点,且新旧文本不同
    } else if (oldVnode.text !== vnode.text) {
        nodeOps.setTextContent(elm, vnode.text)
    }

    postpatch(oldVnode, vnode)
}

以上伪代码都不难读,配合下面流程图会更清晰

还剩下一个updateChildren(vnode, oldVnode)流程,既是diff里的重点也是难点,该方法的核心规律是通过while进行遍历收缩循环

  • 从头尾开始移动,当交叉则停止
    • oldStartIdx -> newStartIdx
    • oldEndIdx -> newEndIdx
    • oldStartIdx -> newEndIdx
    • oldEndIdx -> newStartIdx
  • 如果以上情况都不符合,则通过key进行判断

文字读起来比较繁杂,最快的方式是看一下前人的视频或者动画,再来理解会事半功倍,推荐 diff算法之理解updateChildren函数

其他

NextTick

for (macroTask of macroTaskQueue) {
    // 1. Handle current MACRO-TASK
    handleMacroTask();
      
    // 2. Handle all MICRO-TASK
    for (microTask of microTaskQueue) {
        handleMicroTask(microTask);
    }
}

在浏览器环境中

  • 常见的 macro task 有 setTimeoutMessageChannelpostMessagesetImmediate
  • 常见的 micro task 有 MutationObseverPromise.then

nextTick的降级策略,先微任务,再降级到宏任务

  • promise
  • MutationObserver
  • setImmediate
  • setTimeout(0)

Vue源码详解之nextTick:MutationObserver只是浮云,microtask才是核心! · Issue #6 · Ma63d/vue-analysis · GitHub

Vue响应式对数组的处理

为什么vue2中监听不到数组的变化?

并不是因为 Object.defineProperty的问题,它本身对数组的表现跟对象是一致的,数组的索引就可以看做key来使用,它本身有监控数组下标变化的能力

其实是vue2中放弃了这个特性,在Observer中不会对数组进行walk处理去遍历所有属性,而是进行了特殊处理

按照祖师爷的说法是:性能问题,性能代价和获得的用户体验收益不成正比 具体可参考为什么vue没有提供对数组属性的监听

实际处理

改写数组的push、pop等8个方法,让他们在执行之后通知数组更新了,缺点: 参见官网

  • 不能直接修改数组的长度 this.list.length = 0
  • 通过下标去修改数组 this.list[1] = 'a'

改写步骤

  • 继承原生Array的原型方法
  • 对继承后的对象使用Object.defineProperty进行拦截
  • 把被拦截后的响应式原型,赋值到数组数据的原型上