阅读 366

Vue源码,你真的看懂了吗(五)

二月结束了~连续更了一个月了嘻嘻(虽然是一年最短的一个月,虽然划水了好多篇...
接下来应该会随缘更新了,有时间vue源码部分也会慢慢补充深一点。开始投简历了= =

Vue构造函数

在使用 Vue 的时候,要使用 new 操作符进行调用,这说明 Vue 是一个构造函数。

Vue构造函数原型

// 从五个文件导入五个方法(不包括 warn)
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'

// 定义 Vue 构造函数
// 提醒要使用new操作符来调用vue
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)
}

// 将 Vue 作为参数传递给导入的五个方法
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

// 导出 Vue
export default Vue
复制代码

如上面代码所示,首先分别从 ./init.js./state.js./render.js./events.js./lifecycle.js 这五个文件中导入五个方法,分别是:initMixinstateMixinrenderMixineventsMixin 以及 lifecycleMixin,然后定义了 Vue 构造函数,其中使用了安全模式来提醒你要使用 new 操作符来调用 Vue,接着将 Vue 构造函数作为参数,分别传递给了导入进来的这五个方法,最后导出 Vue。

下面介绍一下导进来的这几个方法的作用:

  • initMixin 方法

在 Vue.prototype 上定义了两个只读属性:$data $props和三个方法:$set$delete 以及 $watch

  • eventsMixin 方法

在 Vue.prototype 上添加了四个方法:$on$once$off$emit

  • lifecycleMixin 方法

在 Vue.prototype 上添加了四个方法:_update$forceUpdate$destroy

  • renderMixin 方法

最终经过 renderMixin 之后,Vue.prototype 又被添加了如下方法:

// installRenderHelpers 函数中
Vue.prototype._o = markOnce
Vue.prototype._n = toNumber
Vue.prototype._s = toString
Vue.prototype._l = renderList
Vue.prototype._t = renderSlot
Vue.prototype._q = looseEqual
Vue.prototype._i = looseIndexOf
Vue.prototype._m = renderStatic
Vue.prototype._f = resolveFilter
Vue.prototype._k = checkKeyCodes
Vue.prototype._b = bindObjectProps
Vue.prototype._v = createTextVNode
Vue.prototype._e = createEmptyVNode
Vue.prototype._u = resolveScopedSlots
Vue.prototype._g = bindObjectListeners

Vue.prototype.$nextTick = function (fn: Function) {}
Vue.prototype._render = function (): VNode {}
复制代码

由上面可知,每个 *Mixin 方法的作用其实就是包装 Vue.prototype,在其上挂载一些属性和方法。

Vue构造函数的静态属性和方法(全局API)

// 从 Vue 的出生文件导入 Vue
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'

// 将 Vue 构造函数作为参数,传递给 initGlobalAPI 方法,该方法来自 ./global-api/index.js 文件
initGlobalAPI(Vue)

// 在 Vue.prototype 上添加 $isServer 属性,该属性代理了来自 core/util/env.js 文件的 isServerRendering 方法
Object.defineProperty(Vue.prototype, '$isServer', {
  get: isServerRendering
})

// 在 Vue.prototype 上添加 $ssrContext 属性
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 存储了当前 Vue 的版本号
Vue.version = '__VERSION__'

// 导出 Vue
export default Vue
复制代码

首先调用了initGlobalAPI方法,然后在 Vue.prototype上分别添加了两个只读的属性:$isServer$ssrContext,在构造函数上定义了FunctionalRenderContext 静态属性,以便在ssr中使用它。最后在Vue构造函数上添加了一个静态属性version,存储当前vue的版本值。

下面介绍一下initGlobalAPI方法的作用:在Vue构造函数上添加全局API。

  • 在 Vue 构造函数上添加 config 只读属性

  • 在 Vue 上添加了 util 属性,util对象拥有四个属性,分别是:warn、extend、mergeOptions 以及 defineReactive。

    (Vue.util 以及 util 下的四个方法都不被认为是公共API的一部分,要避免依赖他们,但是你依然可以使用,只不过风险你要自己控制。)

  • 在 Vue 上添加了四个属性分别是 set、delete、nextTick 以及 options(通过 Object.create(null) 创建的空对象)

  • 接下来改造Vue.options

    Vue.options = {
      components: Object.create(null),
      directives: Object.create(null),
      filters: Object.create(null),
      _base: Vue
    }
    复制代码
  • 将 builtInComponents 的属性混合到 Vue.options.components 中。最终Vue.options 已经变成了这样:

    Vue.options = {
      components: {
          KeepAlive
      },
      directives: Object.create(null),
      filters: Object.create(null),
      _base: Vue
    }
    复制代码
  • initGlobalAPI 方法的最后部分,以 Vue 为参数调用了四个 init* 方法

    initUse(Vue)	// 添加Vue.use方法,用来安装插件
    initMixin(Vue)	// 在Vue上添加mixin全局API
    initExtend(Vue)		// 在 Vue 上添加了 Vue.cid 静态属性,和 Vue.extend 静态方法
    initAssetRegisters(Vue)	// 在 Vue 上添加了 Vue.component、Vue.directive、Vue.filter 三个静态方法,分别用来注册组件、指令、过滤器。
    复制代码

总结

在这个 core/index.js 文件里,它首先将核心的 Vue,也就是在 core/instance/index.js 文件中的 Vue,也可以说是原型被包装(添加属性和方法)后的 Vue 导入,然后使用 initGlobalAPI 方法给 Vue 添加静态方法和属性,除此之外,在这个文件里,也对原型进行了修改,为其添加了两个属性:$isServer$ssrContext,最后添加了 Vue.version 属性并导出了 Vue。

Vue平台化包装

platforms/web/runtime/index.js 文件文件的作用是对 Vue 进行平台化地包装:

  • 设置平台化的 Vue.config。
  • 在 Vue.options 上混合了两个指令(directives),分别是 model 和 show。
  • 在 Vue.options 上混合了两个组件(components),分别是 Transition 和 TransitionGroup。
  • 在 Vue.prototype 上添加了两个方法:__patch__$mount

添加compiler

entry-runtime-with-compiler.js文件不仅包括运行时版本的Vue构造函数,还包括compiler。

// ... 其他 import 语句

// 导入 运行时 的 Vue
import Vue from './runtime/index'

// ... 其他 import 语句

// 从 ./compiler/index.js 文件导入 compileToFunctions
import { compileToFunctions } from './compiler/index'

// 根据 id 获取元素的 innerHTML
const idToTemplate = cached(id => {
  const el = query(id)
  return el && el.innerHTML
})

// 使用 mount 变量缓存 Vue.prototype.$mount 方法
const mount = Vue.prototype.$mount
// 重写 Vue.prototype.$mount 方法
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // ... 函数体省略
}

/**
 * 获取元素的 outerHTML
 */
function getOuterHTML (el: Element): string {
  if (el.outerHTML) {
    return el.outerHTML
  } else {
    const container = document.createElement('div')
    container.appendChild(el.cloneNode(true))
    return container.innerHTML
  }
}

// 在 Vue 上添加一个全局API `Vue.compile` 其值为上面导入进来的 compileToFunctions
Vue.compile = compileToFunctions

// 导出 Vue
export default Vue
复制代码

这个文件运行下来,对Vue的影响有两个。

  • 重写了Vue.prototype.$mount 方法
  • 添加了Vue.compile全局API

Vue生命周期

  • 钩子函数在什么时候被调用

    • beforeCreate

      在实例初始化new Vue()之后,数据观测(data observer)响应式处理之前被调用

    • created

      实例已经创建完成之后被调用,实例已完成以下的配置:数据观测(data observer)、属性和方法的运算、watch/event事件回调。数据可以拿到,但是没有$el。

    • beforeMount

      在挂载开始之前被调用:相关的render函数首次被调用。

    • mounted

      el被新创建的vm.$el替换,并挂载到实例上去之后被调用。页面渲染完毕

    • beforeUpdate

      数据更新时调用,发生在虚拟DOM重新渲染和打补丁之前。

    • updated

      由于数据更改导致的虚拟DOM重新渲染和打补丁,在这之后会调用该钩子。

    • beforeDestroy

      实例销毁之前调用,在这一步,实例仍然完全可用。

    • destroyed

      Vue实例销毁后调用。调用后,Vue实例指示的所有东西都会解绑定,所有的事件监听器会被移除,所有的子实例也会被销毁。该钩子在服务器端渲染期间不被调用。

  • 生命钩子中可以做什么事

    • created

      实例已经创建完成,因为它时最早触发的,可以进行一些数据资源的请求。

    • mounted

      实例已经挂载完成,可以进行一些DOM操作。

    • beforeUpdate

      可以在这个钩子中进一步地更改状态,不会触发附加的重渲染过程。

    • updated

      可以执行依赖于DOM的操作,尽量避免在此期间更改状态,因为可能会导致更新无限循环。该钩子在服务器端渲染期间不被调用。

    • beforeDestory

      可以执行一些优化操作,如清空定时器、解绑事件的原生绑定。如果在当前实例上使用了$on方法,需要在组件销毁之前进行解绑。

思考

ajax请求放在哪个生命周期中?

在created的时候,视图中的DOM并没有渲染出来,此时直接去操作DOM节点,无法找到相关元素。 在mounted中,此时DOM已经渲染出来,可以直接操作DOM节点。 一般情况下都放到mounted中,保证逻辑的统一性,因为生命周期是同步执行的,ajax是异步执行的。 服务器端渲染因为没有DOM,不支持mounted方法,所以在服务器端渲染的情况下统一放到created中

vue父子组件生命周期调用顺序

加载渲染过程

父beforeCreate ==> 父created ==> 父beforeMount ==> 子beforeCreat ==>子created ==> 子beforeMount ==> 子mounted ==> 父mounted

子组件更新过程

父beforeUpdate ==> 子beforeUpdate ==> 子updated ==> 父updated

父组件更新过程

父beforeUpdate ==> 父updated

销毁过程

父beforeDestroy ==> 子beforeDestroy ==> 子destroyed ==> 父destroyed

理解

组件的调用顺序都是先父后子,渲染完成的顺序是先子后父

组件的销毁操作是先父后子,销毁完成的顺序是先子后父

原理图

谈谈 Vue 事件机制,手写$on,$off,$emit,$once

Vue 事件机制 本质上就是 一个 发布-订阅 模式的实现。

class Vue {
  constructor() {
    //  事件通道调度中心
    this._events = Object.create(null);
  }
  $on(event, fn) {
    if (Array.isArray(event)) {
      event.map(item => {
        this.$on(item, fn);
      });
    } else {
      (this._events[event] || (this._events[event] = [])).push(fn);
    }
    return this;
  }
  $once(event, fn) {
    function on() {
      this.$off(event, on);
      fn.apply(this, arguments);
    }
    on.fn = fn;
    this.$on(event, on);
    return this;
  }
  $off(event, fn) {
    if (!arguments.length) {
      this._events = Object.create(null);
      return this;
    }
    if (Array.isArray(event)) {
      event.map(item => {
        this.$off(item, fn);
      });
      return this;
    }
    const cbs = this._events[event];
    if (!cbs) {
      return this;
    }
    if (!fn) {
      this._events[event] = null;
      return this;
    }
    let cb;
    let i = cbs.length;
    while (i--) {
      cb = cbs[i];
      if (cb === fn || cb.fn === fn) {
        cbs.splice(i, 1);
        break;
      }
    }
    return this;
  }
  $emit(event) {
    let cbs = this._events[event];
    if (cbs) {
      const args = [].slice.call(arguments, 1);
      cbs.map(item => {
        args ? item.apply(this, args) : item.call(this);
      });
    }
    return this;
  }
}
复制代码

Vue 组件 data 为什么必须是函数?

问:new Vue()实例中,data 可以直接是一个对象,为什么在 vue 组件中,data 必须是一个函数呢?

因为组件可以复用,如果data是一个对象,对象属于引用类型,会导致子组件中的data属性值会相互污染。如果是一个函数,每个实例就可以维护一份被返回对象的独立拷贝。

new Vue()的实例并不会被复用,所以data可以是一个对象。

keep-alive 的实现原理

<keep-alive>是一个抽象组件,自身不会渲染DOM元素,也不会出现在父组件链中。包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们。作用是保存组件的渲染状态。

源码

export default {
  // 组件名
  name: "keep-alive",
  // 判断当前组件是否渲染成真实DOM的关键
  abstract: true, // 抽象组件属性 ,它在组件实例建立父子关系的时候会被忽略,发生在 initLifecycle 的过程中
  props: {
    include: patternTypes, // 被缓存组件
    exclude: patternTypes, // 不被缓存组件
    max: [String, Number] // 指定缓存大小
  },

  created() {
    this.cache = Object.create(null); // 缓存的虚拟DOM
    this.keys = []; // 缓存的VNode的键
  },

  destroyed() {
    for (const key in this.cache) {
      // 删除所有缓存
      pruneCacheEntry(this.cache, key, this.keys);
    }
  },

  mounted() {
    // 监听缓存/不缓存组件
    this.$watch("include", val => {
      pruneCache(this, name => matches(val, name));
    });
    this.$watch("exclude", val => {
      pruneCache(this, name => !matches(val, name));
    });
  },

  render() {
    // 获取第一个子元素的 vnode
    const slot = this.$slots.default;
    const vnode: VNode = getFirstComponentChild(slot);
    const componentOptions: ?VNodeComponentOptions =
      vnode && vnode.componentOptions;
    if (componentOptions) {
      // name不在inlcude中或者在exlude中 直接返回vnode
      // check pattern
      const name: ?string = getComponentName(componentOptions);
      const { include, exclude } = this;
      if (
        // not included
        (include && (!name || !matches(include, name))) ||
        // excluded
        (exclude && name && matches(exclude, name))
      ) {
        return vnode;
      }

      const { cache, keys } = this;
      // 获取键,优先获取组件的name字段,否则是组件的tag
      const key: ?string =
        vnode.key == null
          ? // same constructor may get registered as different local components
            // so cid alone is not enough (#3269)
            componentOptions.Ctor.cid +
            (componentOptions.tag ? `::${componentOptions.tag}` : "")
          : vnode.key;
      // 命中缓存,直接从缓存拿vnode 的组件实例,并且重新调整了 key 的顺序放在了最后一个
      if (cache[key]) {
        vnode.componentInstance = cache[key].componentInstance;
        // make current key freshest
        remove(keys, key);
        keys.push(key);
      }
      // 不命中缓存,把 vnode 设置进缓存
      else {
        cache[key] = vnode;
        keys.push(key);
        // prune oldest entry
        // 如果配置了 max 并且缓存的长度超过了 this.max,还要从缓存中删除第一个
        if (this.max && keys.length > parseInt(this.max)) {
          pruneCacheEntry(cache, keys[0], keys, this._vnode);
        }
      }
      // keepAlive标记位
      vnode.data.keepAlive = true;
    }
    return vnode || (slot && slot[0]);
  }
};


// src/core/components/keep-alive.js
// 删除缓存VNode还要对应执行组件实例的destory钩子函数。
function pruneCacheEntry (
  cache: VNodeCache,
  key: string,
  keys: Array<string>,
  current?: VNode
) {
  const cached = cache[key]
  if (cached && (!current || cached.tag !== current.tag)) {
    cached.componentInstance.$destroy() // 执行组件的destory钩子函数
  }
  cache[key] = null
  remove(keys, key)
}
复制代码

实现原理

  • 获取 keep-alive 包裹着的第一个子组件对象及其组件名。
  • 根据设定的 include/exclude(如果有)进行条件匹配,决定是否缓存。不匹配,直接返回组件实例。
  • 根据组件 ID 和 tag 生成缓存 Key,并在缓存对象中查找是否已缓存过该组件实例。如果存在,直接取出缓存值并更新该 key 在 this.keys 中的位置(更新 key 的位置是实现 LRU 置换策略的关键)
  • 在 this.cache 对象中存储该组件实例并保存 key 值,之后检查缓存的实例数量是否超过 max 的设置值,超过则根据 LRU 置换策略删除最近最久未使用的实例(即是下标为 0 的那个 key)
  • 最后组件实例的 keepAlive 属性设置为 true,这个在渲染和执行被包裹组件的钩子函数会用到,主要作用是使组件实例不再进入$mount过程,那mounted之前的所有钩子函数(beforeCreate、created、mounted)都不再执行。

LRU 缓存淘汰算法

LRU(Least recently used)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。所以当需要存储的数量超过可以存储的最大值时,会将最久没被访问过的给剔除,再增加新数据。 keep-alive 的实现正是用到了 LRU 策略,将最近访问的组件 push 到 this.keys 最后面,this.keys[0]也就是最久没被访问的组件,当缓存实例超过 max 设置值,删除 this.keys[0]

组件之间是如何进行通信的

  • 父子通信

    • 父向子传递数据是通过 props

    • 子向父是通过 events($emit)v-on:event

    • 通过父链 / 子链也可以通信($parent / $children)

    • ref 也可以访问组件实例;

    • provide / inject API;

    • $attrs/$listeners

  • 兄弟通信

    • Bus;
    • Vuex
  • 跨级通信

    • Bus;
    • Vuex;
    • provide / inject API、
    • $attrs/$listeners

vuex原理 vuex实现了一个单向数据流,在全局拥有一个state存放数据,当组件要更改state中的数据时,必须通过Mutation进行,Mutation同时提供了订阅者模式供外部插件调用获取state数据的更新。当所有异步操作(常见于调用后端接口获取数据)或批量的同步任务(耗时长)就需要在Action中定义方法,但Action中的方法是无法修改state的,还是需要通过Mutation来修改state中的数据。最后,根据state中数据的变化,更新渲染到视图上。

参考阅读: segmentfault.com/a/119000001…

slot是什么?有什么作用?原理是什么?

插槽slot可以理解为在组件模板中占好了位置,当使用该组件标签时候,组件标签里面的内容就会自动填充(替换组件模板中slot位置),作为承载分发内容的出口,而这一个标签元素是否显示,以及怎么显示是由父组件决定的。

通过插槽可以让用户可以拓展组件,去更好地复用组件和对其做定制化处理。

slot又分三类,默认插槽,具名插槽和作用域插槽。

实现原理:当子组件vm实例化时,获取到父组件传入的slot标签的内容,存放在vm.$slot中,默认插槽为vm.$slot.default,具名插槽为vm.$slot.xxx,xxx 为插槽名,当组件执行渲染函数时候,遇到slot标签,使用$slot中的内容进行替换,此时可以为插槽传递数据,若存在数据,则可称该插槽为作用域插槽。

template和jsx的有什么分别?

template和jsx的都是render函数的一种表现形式,不同的是:

JSX相对于template而言,具有更高的灵活性,在复杂的组件中,更具有优势,而 template 虽然显得有些呆滞。但是 template 在代码结构上更符合视图与逻辑分离的习惯,更简单、更直观、更好维护。

SSR是什么?有什么好处?

在客户端请求服务器的时候,服务器到数据库中获取到相关的数据,并且在服务器内部将Vue组件渲染成HTML,并且将数据、HTML一并返回给客户端,这个在服务器将数据和组件转化为HTML的过程,叫做服务端渲染SSR

而当客户端拿到服务器渲染的HTML和数据之后,由于数据已经有了,客户端不需要再一次请求数据,而只需要将数据同步到组件或者Vuex内部即可。除了数据以外,HTML也结构已经有了,客户端在渲染组件的时候,只需要将HTML的DOM节点映射到Virtual DOM即可,不需要重新创建DOM节点,这个将数据和HTML同步的过程,又叫做客户端激活

使用SSR的好处:

  • 有利于SEO

    其实就是有利于爬虫来爬你的页面,因为部分页面爬虫是不支持执行JavaScript的,这种不支持执行JavaScript的爬虫抓取到的非SSR的页面会是一个空的HTML页面,而有了SSR以后,这些爬虫就可以获取到完整的HTML结构的数据,进而收录到搜索引擎中。

  • 白屏时间更短

    相对于客户端渲染,服务端渲染在浏览器请求URL之后已经得到了一个带有数据的HTML文本,浏览器只需要解析HTML,直接构建DOM树就可以。而客户端渲染,需要先得到一个空的HTML页面,这个时候页面已经进入白屏,之后还需要经过加载并执行 JavaScript、请求后端服务器获取数据、JavaScript 渲染页面几个过程才可以看到最后的页面。特别是在复杂应用中,由于需要加载 JavaScript 脚本,越是复杂的应用,需要加载的 JavaScript 脚本就越多、越大,这会导致应用的首屏加载时间非常长,进而降低了体验感。

使用js实现一个只读属性

我们可以参照vue中的实现方式。

$data$props是两个vue中十分重要的属性,以下代码把它们设置为只读。

  // $data 属性实际上代理的是 _data 这个实例属性
 const dataDef = {}
  dataDef.get = function () { return this._data }
  // $props 代理的是 _props 这个实例属性。
  const propsDef = {}
  propsDef.get = function () { return this._props }
  // 在生产环境下,为 $data 和 $props 这两个属性设置一下 set,当触发set时抛出警告,说明这两个属性只读
  if (process.env.NODE_ENV !== 'production') {
    dataDef.set = function (newData: Object) {
      warn(
        'Avoid replacing instance root $data. ' +
        'Use nested data properties instead.',
        this
      )
    }
    propsDef.set = function () {
      warn(`$props is readonly.`, this)
    }
  }
复制代码
文章分类
前端
文章标签