vue2 面试题集锦

205 阅读20分钟

谈谈你对 MVVM 的理解

mvc

比较早期的是mvc针对后端来说的,大概流程如下。 用在前端比较典型的是 backbone,它有控制器的概念,配合上 underscore 的模板引擎,再加上 jquery 去做视图(view)和控制器(controller)的关联,其中 jq 是被用于 backbone 的 controller 层的(模板组装)。

缺点:如果使用传统的 mvc 大量的逻辑会耦合在c一层(不仅有数据处理,还要处理模板拼接,页面更新), 不仅有数据处理,还有(导致维护困难),所有就有了后面几种模式,比如 mvp, mv*,但是目前比较主流的就是 mvvm。

mvvm

mvvm 也分为三层,分别是 model,view 和 viewModal(viewModel 简化controller这一层),传统的 mvvm 要求不能手动操作视图更新(Vue 暴露了 ref 属性,这就不完全遵循 mvvm 了),可以说,mvvm 的核心就是为了隐藏 controller,因为这层太过复杂,通过封装的方法,使页面更新和数据自动更新(v-model)。 虽然没有完全遵循 MVVM 模型,但是 Vue 的设计也受到了它的启发。因此在文档中经常会使用 vm (ViewModel 的缩写) 这个变量名表示 Vue 实例。

mvvm 角度对比 vue 和 react

提到这里就不得不说一句,vue 和 react 的区别之一就是,react 专注 view 层,但是通过它们都能实现 mvvm,react 本身是库,vue 则是框架,库和框架的区别在于,我们使用库时,会主动调用库中的方法,比如 react 库想更新页面,需要调用它提供的 api(数据改变后我们需要手动调用 setState 更新页面),而框架会帮我们调用,只要我们按照它的套路来写代码~

请说一下 Vue2 和 Vue3 响应式数据的理解

响应式数据的核心就是,数据变化我能知道,而不是说数据驱动视图这一层。

对象在 vue2 中采用了 defineProperty(Vue.util.defineReactive)将数据定义成了响应式的数据(拦截所有的属性增加了 getter 和 setter),缺陷就是要递归构建,且每层递归都是一个闭包函数,缓存了当前属性的 val 和 watcher 收集器 dep,且不存在的属性无法被监控到。

Vue2 对数组并没有采用 defineProperty,因为数组中可能元素很多(劫持索引性能差),而且用户不会操作索引更改数据(劫持索引也无效),不过数组内元素会被递归检测,如果是对象,会被继续劫持对象的属性,所以数组不要钱陶过深,Vue2 通常采用以下方法进行优化~

  • Vue2中减少层级数据嵌套不要过深
  • 如果不需要响应式的数据就不要放在 data 中, 合理使用 Object.freeze
  • 我们尽量缓存使用过的变量,不要循环取值或 set 值。

vue3 采用了 proxy 直接可以对对象拦截,不用重写 get 和 set 方法,性能高,不需要直接递归(懒劫持)。

Vue2 中如何检测数组变化

vue2 中并没有使用 defineProperty 来检测我们的数组,因为数组中可能元素很多(劫持索引性能差),而且用户不会操作索引更改数据(劫持索引也无效),vue2 里面就采用重写数组的方法来实现 (7个变异的方法,能改变原数组的方法),通过 Object.create 一层对象做拦截的方式实现的,数组中的对象类型的元素也会被递归观测。缺陷是不能检测到索引更改和数组长度的更改

Vue中如何进行依赖收集?(观察者模式)

依赖收集的目的是,等会数据变化了可以更新视图。

如何收集的呢?每个属性都有一个 dep 属性、每个对象也都有一个 dep 属性(obj.ob.dep)。 每个组件在渲染的过程中都会创建一个渲染 watcher(watcher 有三种,渲染 watcher,计算属性 watcher,用户 watcher), 一个属性可能会有多个 watcher(多个组件中使用), 反过来一个 watcher 有多个 dep(一个组件使用了多个变量)。

当调用取值方法的时候(比如渲染模板中使用到的变量)如果有 watcher 就会将 watcher 收集到属性的 dep 上,等会数据变化后会通知自己对应的 dep 触发更新调用 watcher.update 方法,当然这里会异步触发更新(最后调用 watcher.get,也就是 vm.update(vm._render()) 去做页面更新,当然这里不用回答)。

如何理解 vue2 中的模板编译原理

模板编译的原理核心就是 模板 -> ast -> render 函数。

  1. 会将模板通过正则分词,变为 ast 语法树
  2. 对 ast 语法树进行优化,标记静态节点,静态节点 patch 时会跳过比对,(vue3 中模板编译的优化: patchFlag,blockTree,事件缓存,节点缓存等优化)
  3. 代码生成,拼接 render 函数内部字符串,比如 _c('div', ...)
  4. 通过 new Function(字符串变函数) + with(保证 this 指向 vm) 将其转为 render 函数
let template = `<div id="app">{{ msg }}</div>`;

// 模板 -> ast
const ast = parserHTML(template);

// ast -> render 函数内代码字符串,结果如下
const code = generate(ast); 

// @1 _c 是创建 dom 节点的方法
// @2 _v 是创建文本的方法
// @3 _s 是转字符串的方法,如果 msg 是个对象,则会转成字符串输出
code = ```_c('div', {
  attrs: {
    "id": "app"
  }
}, [_v(_s(msg))])```

// 生成 render 函数
render = new Function(`with(this){return ${code}}`);

Vue2 的生命周期是怎么实现的

  • 生命周期钩子在内部会被vue维护成一个数组(vue 内部有一个方法 mergeOptions)和全局的生命周期(比如 mixin 的生命周期会挂载全局 vue.options 上)合并最终转换成数组,当执行到具体流程时会通过 callHook 来实现调用钩子函数,其实就是一个发布订阅模式(先收集,再遍历调用)

Vue2 的生命周期方法有哪些?一般在哪一步发送请求及原因?

首先,神图奉上。

本题是个误导题目,其实在哪儿发请求都一样。

不过如果你想操作 dom,created 中必须异步的去获取 dom 才行,因为 ajax 请求本身是个异步,所以 created 中发送请求操作 dom 也没毛病!而 mounted 中 dom 才生成,自然也没毛病,至于有说法 created 中组件会更早的渲染更是无稽之谈,created 的请求会更早的发出,但是 ajax 回调是异步的,会等同步代码执行完毕后(mounted 也执行完毕)才能处理请求回调,所以一定不会影响到页面的初次渲染,然后执行 ajax 回调,请求回来再次渲染。

综上所述,created 和 mounted 中请求数据,都可以在回调中获取到 dom,且都不影响组件的初次渲染,只是可能 created 中发送请求,请求会提前被发送出去,请求结果可以更快的拿到,等初次渲染完成后可以更快的去更新。

不过,在服务端渲染的时候,我们无法使用浏览器的钩子(服务端渲染是把结果渲染成一个字符串,返回给浏览器,挂载过程是在服务器做的,那么在浏览器写的 mounted 方法自然是不能指向的),所以我们会把功能写在 created 方法中。

具体罗列如下:

  • beforeCreate 在实例初始化之后,数据观测(data observer) 和 event/watcher 事件配置之前被调用。
  • created 实例已经创建完成之后被调用。在这一步,实例已完成以下的配置:数据观测(data observer),属性和方法的运算,watch/event 事件回调。这里没有$el
  • beforeMount 在挂载开始之前被调用:相关的 render 函数首次被调用。
  • mounted el 被新创建的 vm.$el 替换,并挂载到实例上去之后调用该钩子。
  • beforeUpdate 数据更新时调用,发生在虚拟 DOM 重新渲染和打补丁之前。
  • updated 由于数据更改导致的虚拟 DOM 重新渲染和打补丁,在这之后会调用该钩子。
  • beforeDestroy 实例销毁之前调用。在这一步,实例仍然完全可用。
  • destroyed Vue 实例销毁后调用。调用后,Vue 实例指示的所有东西都会解绑定,所有的事件监听器会被移除,所有的子实例也会被销毁。 该钩子在服务器端渲染期间不被调用。

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

我们知道,Vue.component 注册一个组件,会调用 Vue.extend,并将 Vue.compoent 组件配置 options 传入,而 Vue.extend 返回一个子组件类的实例 Sub,如果实例化 2 次子组件时,data 是函数可以保证返回的不是同一个对象,防止 data 数据被多个子组件共享,否则,Vue 源码内部会抛出一个警告。

// 伪代码
Vue.extend = function(options) {
  function Sub(options) {
    // 合并初始化 options 中 data 和 实例化时传入的 options
    this.data = mergeOptions(this.constructor.options.data(), options.props);
  }

  // 子类记录 extend 传入的 options
  Sub.options = options;

  return Sub;
}

const Sub = Vue.extend({
  data() {
    return { name: 'ys' }
  }
})

let childComp1 = new Sub({ props, slot });
let childComp2 = new Sub({ props, slot });

Vue.mixin 的使用场景和原理

Vue.mixin 的作用就是抽离公共的业务逻辑,原理类似“对象的继承”,当组件初始化时会调用 mergeOptions 方法进行合并,采用策略模式针对不同的属性进行合并。如果混入的数据和本身组件中的数据冲突,会采用“就近原则”以组件的数据为准。

mixin 优点:

  • 无侵入式增加组件的公共能力。

mixin 缺点:

  • 命名冲突,混入的属性或者方法名称可能被组件内的同名变量替换掉
  • 数据来源不清晰,莫名其妙应用 2 中多了一个 a 属性。

nextTick在哪里使用?原理是?

  • nextTick 功能是批处理,多次调用默认会将逻辑暂存到队列 callbacks 中,稍后同步代码执行完毕后会调用,依次执行队列任务
  • 内部实现原理,经典的异步更新流程,收集 -> 加锁 -> 异步更新 -> 继续执行同步代码收集任务 -> 同步执行完毕后开始整微任务队列 -> 微任务队列就一个任务!nextTick -> 同步方式清空收集到的任务 callbacks 中列表 -> 开锁,注意,不管 callbacks 收集了多少任务,也只是在外面包了一个异步任务,该任务执行后去同步的方式依次清空 callbacks。
  • 异步的实现原理 先采用promise.then 不行在采用 mutationObserver 不行在采用 setImmediate 不行在采用 setTimeout 优雅降级
let vm = new Vue({
  el: '#app',
  data() {
    a: 1
  },
  mounted() {
    Vue.nextTick(() => {
      console.log(app.innerHtml);
    });

    this.a = 100;
  }
});

// nextTick 输出的是 1,因为 this.a = 100,虽然看起来是同步的,实际上是往 nextTick 的 callbacks 中塞了一个更新任务 watcher.run,此时微队列为 [nextTick],同步代码执行完毕后,nextTick 执行,去同步清空 callbacks,也就是 [userNextTickCb, watcher.run],所以先打印,再更新~
// 如果二者反过来,则打印 100 哦
附 nextTick 源码
// ......

const callbacks = [];
let wating = false; // 防抖

// 依次执行 nextTick 队列中的 callback
function flushCallbacks() {
  callbacks.forEach(cb => cb());
  wating = false;
}

// 降级策略
function timer(cb) {
  let timerFn = () => {};

  if (Promise) {
    timerFn = () => {
      Promise.resolve().then(cb);
    };
  } else if (MutationObserver) { // 微任务 监听节点变化的 api
    let textNode = document.createTextNode(1); // 随便创建个文本节点来监听
    let observe = new MutationObserver(cb); // 注册个回调

    observe.observe(textNode, { // 监控文本节点变化 characterData 代表文本内容
      characterData: true
    });

    timerFn = () => {
      textNode.textContent = 2;
    }
  } else if (setImmediate) { // ie 才认的 api,性能略高于 setTimeout  
    timerFn = () => {
      setImmediate(cb);
    }
  } else {
    // 再不支持 只能延时器了
    timerFn = () => {
      setTimeout(cb);
    }
  }
  
  timerFn();
}

// 源码中的调度器会优先调用 nextTick 方法(批量更新就调用)
// 所以更新 dom 的操作会先入 callbacks 队列
export function nextTick(cb) {
  callbacks.push(cb);

  if (!wating) {
    // vue3 不考虑兼容,这里直接 Promise.resolve.then(flushCallbacks)
    // vue2 中考虑兼容性问题,有个降级策略
    timer(flushCallbacks);
    wating = true;
  }
}

多次修改一个属性,会多次更新页面么

this.a = 100;
this.a = 200;

不会重复渲染,因为每个组件对应一个 watcher,属性改变时去调用 dep.notify,对自己收集到的 watcher 依次执行 update 操作,而 update 时调用 queueWatcher 进行了 watcher 的去重,最后调用 nextTick 完成异步更新,所以两次数据的更改不会重复渲染。

computed 和 watcher 的区别

这两个东西内部都是基于 watcher 的,区别是 computed 数据可以用于页面渲染,watch 不行,computed 只有在取值时才会执行对应的回调(lazy 为 true 所以不会立即执行),watch 默认会执行一次(拿到老的值)。

computedWatcher 用了一个 dirty 属性实现了缓存机制,多次取值如果依赖的值不发生变化不会更改 dirty 的结果,拿到的就是老的值。

Vue.set 方法是如何实现的?

为了实现给以前不存在的对象添加属性可以实现更新页面,对象采用的是defineProperty不存在的属性检测不到,数组没有检测索引所以也监控不到,所以 Vue 提供了一个 set api。

  • 如果是数组,使用数组的 splice 方法触发更新
  • 如果是对象,调用 defineReactive 对该属性进行劫持,并调用对象 ob.dep.notify() 去通知更新
export function set (target: Array<any> | Object, key: any, val: any): any {
  // 是不是数组, 如果是数组而且是索引
  if (Array.isArray(target) && isValidArrayIndex(key)) {
    target.length = Math.max(target.length, key)
    target.splice(key, 1, val) // splice内部会触发 notify
    return val
  }
  // 如果对象本身就有这个属性 那就直接赋值就好了
  if (key in target && !(key in Object.prototype)) {
    target[key] = val
    return val
  }
  const ob = (target: any).__ob__

  // 不能把响应式属性添加到 vue 实例上
  if (target._isVue || (ob && ob.vmCount)) {
    process.env.NODE_ENV !== 'production' && warn(
      'Avoid adding reactive properties to a Vue instance or its root $data ' +
      'at runtime - declare it upfront in the data option.'
    )
    return val
  }
  if (!ob) { // 不是响应式的直接赋值
    target[key] = val
    return val
  }
  defineReactive(ob.value, key, val) // 把这个属性定义成响应式的
  ob.dep.notify() // 主动通知更新
  return val
}

Vue 为什么需要虚拟 DOM

  • 最核心的是跨端,不同的平台实现方案不同。 内部实现可以不局限于针对浏览器平台,比如在服务端渲染不会生成真实 dom,可能就是根据 vnode 生成一个字符串。
  • 如果开发者频繁操作 dom 可能会浪费性能,虚拟 dom 你可以认为增加了一层缓存,我们会先更新虚拟 dom,再更新到页面上。
  • 因为 dom diff 比较的是前后的虚拟 dom,比较差异更新页面(也可以真实 dom diff 性能差)。
  • 多次 dom 操作浏览器会进行合并的。
for (let i = 0; i < 10; i++) {
  document.body.appendChild(xxx); // 只会渲染一次
}

Vue中 diff 算法原理

  • diff 算法是 O(n) 级别的,采用的是同级比较,内部是深度优先遍历的方式遍历节点
  • 节点判断是否是同一个元素,如果是同一个元素,则比对属性比对孩子,如果不是则直接删除老的换成新的
  • Vue2 中采用了双指针对一些场景做了优化策略 (如果是静态节点可以跳过 diff 算法),头头,尾尾,尾头,头尾进行优化。
  • 最后乱序比较就是根据老节点创造一个映射表,用新的去里边找能复用的就复用节点,dom 前移,列表原位补 null,不存在的节点就创建 dom,最好删除老节点列表没使用到的节点。

可以看到,顺序固定的元素,复用的时候也会做一次移动,而在 Vue3 中,针对相对顺序相同的节点采用了 O(logn) 最长递增子序列算法(贪心 + 二分 + 前驱节点消除贪心影响),使新旧子节点列表中相对顺序相同的元素无需移动。

Vue3 中还有一个 blockTree 概念,如果是通过模板编译的,会根据节点是否稳定拆分 Block,它是一个拥有 dynmaicChildren 的对象,dynmaicChildren 是一个数组,会收集动态节点(根据 patchFlag),我们最后使用 BlockTree 收集到的数组去做更新,可以认为是从虚拟 dom 树的全量 diff 转为靶向 diff。

请说明 key 的作用和原理

  • Vue2 在 patch 过程中通过 vnode 的 tag 和 key 可以判断两个虚拟节点是否是相同节点(可以复用老节点)。
  • 无 key 会导致更新的时候出问题。
  • 动态列表中,不要使用索引作为 key,因为动态列表标签名是相同的,如果索引做 key,如果列表头新增数据,可能复用了不该复用的节点。
<body>
  <div id="app">
    <li v-for="(item, index) of arr" :key="index">
      <input type="checkbox">
      {{ item }}
    </li>
    <button @click="addFruit">添加</button>
  </div>
  <script src="../node_modules/vue/dist/vue.js"></script>
  <script>
    new Vue({
      el: '#app',
      data: {
        arr: ['香蕉🍌', '苹果', '橘子🍊']
      },
      methods: {
        addFruit() {
          this.arr.unshift('柠檬🍋'); // 实际上 dom 是在尾部追加了一下
        }
      }
    });
  </script>
</body>

此时勾选香蕉,点添加后,发现元素复用错了,多选框被复用到了新插入的下标 0 元素上。

解决方法是,将 key 换成唯一值~

谈一谈你对 vue2 组件化的理解

组件化最早出现在 webComponent,浏览器通过它可以实现自定义标签,不过兼容性比较差。

组件化的好处有几点:

  • 组件级更新,特别是对于 Vue 组件,单独分一个 watcher 来处理组件更新,减小影响范围。
  • 合理规划、拆分代码。
  • 复用性强。
  • 单向数据流,子组件不能直接修改父组件的属性,不然数据混乱了。

组件的三大特性:

  • 属性,父组件可以给子组件传递属性。
  • 自定义事件,子组件可以通过自定义事件调用父组件的方法。
  • 插槽。

可以继续延伸一下,谈谈封装过什么组件,怎样封装组件的,如何基于原有组件进行二次开发。

Vue2 组件渲染流程

首先整体的页面模板(包含组件标签)会经历以下过程,页面 dom AST -> render -> vnode,在生成 vnode 前,我们在 createElment 方法中给组件的节点的 props 增加 props.hook.init 方法,并且执行 installComponentHooks 给组件创建 hooks(init,prepatch,insert,destroy),然后才生成组件的 vnode,在进行 patch 方法时,又会在创建真实节点的方法 createElm 中为组件节点的 vnode 调用其 vnode.props.hook.init,该方法会 new Sub 生成一个组件实例挂载到 vnode.componentInstance 上,实例化过程中调用了组件实例继承来的 _init 方法进行组件的数据劫持,生命周期,watcher 初始化等,最后调用组件继承来的 $mount 方法进行挂载(没有传递 el),返回组件实例上的真实 dom(vnode.componentInstance.el)。

上面标黑体的流程就是组件的渲染流程,vnode.componentInstance.mount()挂载方法会根据组件自己真实的template去生成render方法,然后生成自己的渲染watcher,渲染watcher实例化的时候会调用get方法,也就是vm.update(vm.render())方法,首先调用render方法生成组件templatevnode,然后调用patch方法去生成组件真实节点,patch中判断如果没有传递el,直接生成真实节点返回,此时将生成的组件真实节点挂载到组件实例上this.mount() 挂载方法会根据组件自己真实的 template 去生成 render 方法,然后生成自己的渲染 watcher,渲染 watcher 实例化的时候会调用 get 方法,也就是 vm._update(vm._render()) 方法,首先调用 render 方法生成组件 template 的 vnode,然后调用 patch 方法去生成组件真实节点,patch 中判断如果没有传递 el,直接生成真实节点返回,此时将生成的组件真实节点挂载到组件实例上 this.el = patch(this.$el, vnode),到这里组件初始化和挂载流程结束,组件实例的 el 属性上保存着组件真实 dom,这行代码执行完毕后,上述标红的地方就能拿到组件真实 dom 返回插入到页面。

网上找到的图,但还是觉得我画的更好 =。=

Vue2 组件更新流程

首先,触发组件更新的两个因素是:

  • 组件本身 data 发生改变,这个没什么要说的,触发组件自己的 watcher.update -> watcher.run -> watcher.get(compVm._patch(compVm.render()))
  • 组件接收的 props 发生了改变(父组件传入的值更新了),我们来重点说下这个
<my :a= "a"></my>

<script>
  Vue.component('my', {
    props: {
      a: {
        type: Number
      },
      render(h) {
        return h('div', this.a);
      }
    }
  });

  let vm = new Vue({
    el: '#app',
    data() {
      return { a: 1 }
    }
  });

  setTimeout(() => {
    vm.a = 100;
  }, 1000);
</script>

属性更新时会触发 patchVnode 方法 -> 组件虚拟节点会调用 prepatch 钩子 -> 更新属性 -> 组件更新,更具体流程如下。 至于为什么 vm.$options.propsData 是响应式数据,为什么它改变后组件会更新,我们接下来会说,往后看!

Vue 中异步组件原理

<my></my>

<script>
  Vue.component('my', function(resolve) {
    setTimeout(() => {
      resolve({ // 这个对象会进行 Vue.extend() 转成子类 Sub
        render(h) {
          return h('div', 'my')
        }
      });
    }, 1000)
  });

  let vm = new Vue({
    el: '#app'
  });
</script>

1s 后,页面会渲染出组件,异步组件的使用场景很多,比如路由异步加载组件(配合魔法注释使用更佳),loading 组件等~

实现原理:

可以说,异步组件的实现核心在于:

  • 注册组件时 options 作为一个函数传入,创建虚拟 dom 时,走到 createComponent 方法,会先去解析该函数,并对组件 options(函数) 状态进行判断,比如 error,resolved,loading 等(遇到这些,直接返回对应的组件),然后收集组件实例到 owners 中,最后调用异步函数,等待函数完成
  • 给页面塞一个空标签作为占位符。
  • 异步返回结果后,将返回结果对象使用 Vue.extend 生成子类 Sub,并挂在组件的 options.resolved 上,然后调用组件实例的 $forceUpdate 方法强行更新,再次走到 createComponent 方法,对 option(函数) 进行状态机校验,发现 options.resolved 后,取出其存储的中的子类 Sub,接下来走正常的组件创建流程,重新生成组件真实 dom 插入页面。

函数式组件的优势及原理

<my></my>

<script>
  Vue.component('my', { // 不用去调用组件的 _init,只需要渲染流程即可
    functional: true, // 函数式组件
    render(h) {
      return h('div', 'hello 函数式组件');
    }
  });

  let vm = new Vue({
    el: '#app'
  });

原理:createComponent 方法执行的时候,会判断组件选项中 functional 字段,如果为 true,跳过初始化组件,生成 Sub 类,installComponents 等操作,包括 _init(所以函数式组件没有 watcher),直接进行初始化上下文,生成 Vnode,patch 操作,所以性能很高。

函数式组件没有自己的状态和 watcher,所以他不会主动更新,除非父级更新,重新生成函数式组件,比如父级传来一个 props 列表数据,函数式组件的方式去渲染列表页面。

优点:

  • 性能好,没有自己的数据源,可以接收 props,无声明周期,无 this,无副作用,一般用于单纯的页面渲染(正常组件是一个类,但是函数组件就是一个普通的函数)。

缺点:

  • 无状态,没有自己的数据源

组件间的传值方式及之间的区别

  • props 和 emit父组件向子组件传递数据是通过prop传递的,子组件传递数据给父组件是通过emit 父组件向子组件传递数据是通过 prop 传递的,子组件传递数据给父组件是通过 emit 触发事件来做到的
  • parent,parent, children 获取当前组件的父组件和当前组件的子组件
  • attrsattrs 和 listeners,Vue 2.4 开始提供了 attrsattrs 和 listeners 来解决这个问题
  • 父组件中通过 provide 来提供变量,然后在子孙组件中通过 inject 来注入变量(会导致数据不明确,不建议使用,一般开发组件库才用)。
  • $refs 父拿子的实例
  • envetBus 平级组件数据传递,这种情况下可以使用中央事件总线的方式
  • vuex 状态管理

props 和 $emit 原理

<div id="app">
  <my a=1 b=2 c=3 @toChildFn="fn"></my>
</div>
<script src="../dist/vue.js"></script>
<script>
  Vue.component('my', { // 不用去调用组件的 _init,只需要渲染流程即可
    props: {
      a: {
        type: String
      },
      b: {
        type: String
      }
    },
    render(h) {
      return h('div', 'hello');
    }
  });

  let vm = new Vue({
    el: '#app',
    methods: {
      fn() {

      }
    },
  });
</script>

@1 创建组件 vnode 时,调用了 createComponent,会抽离 props,将组件传入的属性(a、b、c)和组件接收的属性(a 和 b 两个)进行比对,其中组件接收的属性挂到 props中,没接受的属性挂载到props 中,没接受的属性挂载到 attrs 上,然后把组件的属性和事件挂载到 vnode.componentOptions 上。

// 抽离出的组件 props
const propsData = extractPropsFromVNodeData(data, Ctor, tag);
// 给组件传递的事件 { 'on': { tochildfn: ƒ () }}
const listeners = data.on
...

// 创建虚拟节点时,把组件属性挂到 vnode.componentOptions.propsData 上
// 组件接收的事件放到 vnode.componentOptions.listeners 上
const vnode = new VNode(
  `vue-component-${Ctor.cid}${name ? `-${name}` : ''}`,
  data, undefined, undefined, undefined, context,
  { Ctor, propsData, listeners, tag, children }, 
  asyncFactory
)

@2 组件调用 Vue.prototype._init 时,会走到 initInternalComponent 方法进行组件的属性,事件,插槽,render 等属性的收集,这里我们只关心属性和事件,会将组件的属性挂载到组件实例的 vm.options.propsData上,事件挂载到vm.options.propsData 上,事件挂载到 vm.options._parentListeners 上,跳出该函数。

export function initInternalComponent (vm: Component, options: InternalComponentOptions) {
  const opts = vm.$options = Object.create(vm.constructor.options)
  // doing this because it's faster than dynamic enumeration.
  const parentVnode = options._parentVnode
  opts.parent = options.parent
  opts._parentVnode = parentVnode

  const vnodeComponentOptions = parentVnode.componentOptions
  // 将组件属性挂载到组件实例 vm.$options.propsData 上
  // 这里其实就是 { a: 1, b: 2 }
  opts.propsData = vnodeComponentOptions.propsData 
  // 组件上绑定的事件,{ tochildfn: ƒ () }, 挂载到 m.$options._parentListeners 上
  opts._parentListeners = vnodeComponentOptions.listeners
  // 处理插槽
  opts._renderChildren = vnodeComponentOptions.children
  opts._componentTag = vnodeComponentOptions.tag

  if (options.render) {
    opts.render = options.render
    opts.staticRenderFns = options.staticRenderFns
  }
}

@3 开始执行 _init 中的 initEvents -> updateComponentListeners -> updateListeners,进行组件的事件绑定,会在子组件实例上收集一个 _events 事件列表(子组件的 _events 上收集的是父组件的方法),提供了 $emit 去触发组件上收集到的事件(触发父组件的方法执行),其实就是个发布订阅。

太长了被折起来了
export function initEvents (vm: Component) {
  vm._events = Object.create(null)
  vm._hasHookEvent = false
  // 我们所以的事件选项,本例中只有一个事件 { tochildfn: ƒ () }
  const listeners = vm.$options._parentListeners
  if (listeners) {
    updateComponentListeners(vm, listeners)
  }
}

// 介不是发布订阅么
function add (event, fn) {
  target.$on(event, fn)
}

// 介不是发布订阅么
function remove (event, fn) {
  target.$off(event, fn)
}

export function updateComponentListeners (
  vm: Component,
  listeners: Object,
  oldListeners: ?Object
) {
  target = vm
  // 把当前的事件挂载到组件实例上 vm._evnets = [tochildfn]
  updateListeners(listeners, oldListeners || {}, add, remove, createOnceHandler, vm)
  target = undefined
}

// 订阅
Vue.prototype.$on = function (event: string | Array<string>, fn: Function): Component {
  const vm: Component = this
  if (Array.isArray(event)) {
    for (let i = 0, l = event.length; i < l; i++) {
      vm.$on(event[i], fn)
    }
  } else {
    // vm._events 挂载着注册的事件
    (vm._events[event] || (vm._events[event] = [])).push(fn)
    // optimize hook:event cost by using a boolean flag marked at registration
    // instead of a hash lookup
    if (hookRE.test(event)) {
      vm._hasHookEvent = true
    }
  }
  return vm
}

// 发布
Vue.prototype.$emit = function (event: string): Component {
  const vm: Component = this
  if (process.env.NODE_ENV !== 'production') {
    const lowerCaseEvent = event.toLowerCase()
    if (lowerCaseEvent !== event && vm._events[lowerCaseEvent]) {
      tip(
        `Event "${lowerCaseEvent}" is emitted in component ` +
        `${formatComponentName(vm)} but the handler is registered for "${event}". ` +
        `Note that HTML attributes are case-insensitive and you cannot use ` +
        `v-on to listen to camelCase events when using in-DOM templates. ` +
        `You should probably use "${hyphenate(event)}" instead of "${event}".`
      )
    }
  }
  // $emit 触发事件执行
  let cbs = vm._events[event]
  if (cbs) {
    cbs = cbs.length > 1 ? toArray(cbs) : cbs
    const args = toArray(arguments, 1)
    const info = `event handler for "${event}"`
    for (let i = 0, l = cbs.length; i < l; i++) {
      invokeWithErrorHandling(cbs[i], vm, args, vm, info)
    }
  }
  return vm
}


@4 开始执行 _init 中的 initState,然后调用了 initProps,该方法会判断 vm.$options.propsData 组件接受到的属性的值是否合法,如果合法,就将值定义到 vm._props 上,最后对 vm 取值做了一层代理,取的是 vm._props 上的值哟。

太长了被折起来了
export function initState (vm: Component) {
  // 给实例上增加一个属性 _watchers 存放当前实例对应的 watcher 用于强制更新使用
  vm._watchers = []; 
  const opts = vm.$options
  // 初始化有顺序要求, 是vue自己定义的顺序
  if (opts.props) initProps(vm, opts.props)
  if (opts.methods) initMethods(vm, opts.methods)
  if (opts.data) {
    initData(vm); // 数据的初始化
  } else {
    observe(vm._data = {}, true /* asRootData */)
  }
  if (opts.computed) initComputed(vm, opts.computed)
  if (opts.watch && opts.watch !== nativeWatch) {
    initWatch(vm, opts.watch)
  }
}

function initProps (vm: Component, propsOptions: Object) {
  const propsData = vm.$options.propsData || {}
  // 最后 props 上的属性会被定义在 vm._props 中
  const props = vm._props = {}

  ...
  // propsOptions 就是 { a: { type: String }, b: { type: String } }
  for (const key in propsOptions) {
    // 这部分代码过长,限于篇幅这里简要描述下,这里主要拿了组件注册的属性(a 和 b)和传入的属性做了比对

    if (代码不符合规则) {
      // balabala
    } else {
      // 符合规则的属性被定义在 vm._props 上
      defineReactive(props, key, value)
    }

    if (!(key in vm)) {
      // 如果在 vm 上取值,取的是 vm._props,做了层代理
      proxy(vm, `_props`, key)
    }
  }
  toggleObserving(true)
}


parent & children

这个比较简单,就是说父亲记住儿子的实例,儿子记住父亲的实例而已,我们来看下组件初始化时候调用的 initLifecycle 方法。

export function initLifecycle (vm: Component) {
  const options = vm.$options

  // 组件初始化的时候会将父组件的实例传递过来,在 options 中哦
  let parent = options.parent // 获取父组件实例
  
  // 先不要在意抽象组件,后面 keep-alive 的时候再说~
  if (parent && !options.abstract) {
    while (parent.$options.abstract && parent.$parent) {
      parent = parent.$parent
    }
    parent.$children.push(vm) // 让父亲记录儿子
  }

  vm.$parent = parent // 让儿子记录父亲
}

inject provide 如何实现跨级通信

跨级通信指的是,父亲可以和孙子,曾孙,玄孙直接通信,其实 provide 就是把数据定义在一个变量上,子代实例可以一层层网上找.

Vue.component('my', { 
  inject: ['foo'],
  render(h) {
    return h('div', `hello 组件`);
  },
  created () {
    console.log(this.foo) // => "foo 的值"
  }
});

let vm = new Vue({
  provide: {
    foo: 'foo 的值'
  },
  el: '#app',
});

通过组件初始化中的 initProvide 方法,找到 inject.js 中的源码,可以看到,它的实现很简单:

  • provide 会在当前实例上保存一个 _provided 方法
  • 对组件进行 inject 时, 会调用 resolveInject 去解析值,解析的过程就是通过 vm.$parent 不停的往上层实例上寻找 _provide 属性且看寻找到的对象中有没有注入的属性,没有就一直往上找,找到之后,把 result 返回,子组件再将属性和值挂载到自己实例上。

注意,provide 提供的数据,默认不是响应式的,除非提供的就是响应式数据。

太长了被折起来了
export function initProvide (vm: Component) {
  const provide = vm.$options.provide 
  if (provide) {
    // 获取用户定义的 provide,provide 可以是函数或对象
    // 是函数会取返回值
    // 最好挂载到 vm._provided 上
    vm._provided = typeof provide === 'function'
      ? provide.call(vm)
      : provide
  }
}

export function initInjections (vm: Component) {
  // 去当前实例上解析 inject,获取到父亲定义的数据
  const result = resolveInject(vm.$options.inject, vm)
  if (result) {
    toggleObserving(false) // 暂时关闭响应式
    Object.keys(result).forEach(key => {
      /* istanbul ignore else */
      if (process.env.NODE_ENV !== 'production') {
        defineReactive(vm, key, result[key], () => {
          warn(
            `Avoid mutating an injected value directly since the changes will be ` +
            `overwritten whenever the provided component re-renders. ` +
            `injection being mutated: "${key}"`,
            vm
          )
        })
      } else {
        // 定义到自己身上,这里不会去代理对象,因为开关关啦
        defineReactive(vm, key, result[key])
      }
    })
    toggleObserving(true)
  }
}

export function resolveInject (inject: any, vm: Component): ?Object {
  if (inject) {
    // inject is :any because flow is not smart enough to figure out cached
    const result = Object.create(null)
    const keys = hasSymbol
      ? Reflect.ownKeys(inject)
      : Object.keys(inject)

    for (let i = 0; i < keys.length; i++) {
      const key = keys[i]
      // #6574 in case the inject object is observed...
      if (key === '__ob__') continue
      const provideKey = inject[key].from
      let source = vm
      while (source) {
        // 去当前组件上不停的去找父亲有没有 _provide 且有没有我需要的属性,没有就一直往上找
        // 有点类似 instance 的实现哈哈
        if (source._provided && hasOwn(source._provided, provideKey)) {
          result[key] = source._provided[provideKey]
          break
        }
        source = source.$parent
      }
      // 没找到
      if (!source) {
        if ('default' in inject[key]) {
          const provideDefault = inject[key].default
          result[key] = typeof provideDefault === 'function'
            ? provideDefault.call(vm)
            : provideDefault
        } else if (process.env.NODE_ENV !== 'production') {
          warn(`Injection "${key}" not found`, vm)
        }
      }
    }
    return result
  }
}


attrsattrs 和 listeners

获取所有的事件和组件未接收的属性,直接定义在实例上即可,注意,它和 provide、inject 不能一概而论,attrsattrs 和 listeners 不能跨层通信。

组件 _init 时候调用了 initRender 方法,用来初始化 vm._c、attrsattrs 和 listeners。


export function initRender (vm: Component) {
  vm._vnode = null // the root of the child tree
  vm._staticTrees = null // v-once cached trees
  const options = vm.$options
  const parentVnode = vm.$vnode = options._parentVnode // the placeholder node in parent tree
  const renderContext = parentVnode && parentVnode.context
  vm.$slots = resolveSlots(options._renderChildren, renderContext)
  vm.$scopedSlots = emptyObject

  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

  const parentData = parentVnode && parentVnode.data

  if (process.env.NODE_ENV !== 'production') {
    // 定义 vm.$attrs
    defineReactive(
      vm, 
      '$attrs', 
      parentData && parentData.attrs || emptyObject, 
      () => { !isUpdatingChildComponent && warn(`$attrs is readonly.`, vm)}, 
      true
    )

    // 定义 vm.$listeners
    defineReactive(
      vm, 
      '$listeners', 
      options._parentListeners || emptyObject, 
      () => { !isUpdatingChildComponent && warn(`$listeners is readonly.`, vm)},
      true
    )
  } else {
    defineReactive(vm, '$attrs', parentData && parentData.attrs || emptyObject, null, true)
    defineReactive(vm, '$listeners', options._parentListeners || emptyObject, null, true)
  }
}

refs

给组件添加 refs 之后,可以获取组件实例,怎么实现的呢。

我们看下注册 ref 时做了什么,ref.js 中 registerRef 方法,可以看到,对于数组,refs 收集到的实际上是数组的实例。

export function registerRef (vnode: VNodeWithData, isRemoval: ?boolean) {
  const key = vnode.data.ref
  if (!isDef(key)) return

  const vm = vnode.context
  // 如果有实例就是实例,没有实例就是 dom 节点
  // 也就是说,如果为组件,refs 保存的 ref 就是组件的实例
  const ref = vnode.componentInstance || vnode.elm 
  const refs = vm.$refs
  if (isRemoval) {
    if (Array.isArray(refs[key])) {
      remove(refs[key], ref)
    } else if (refs[key] === ref) {
      refs[key] = undefined
    }
  } else {
    if (vnode.data.refInFor) { // v-for 中的 ref,循环 push
      if (!Array.isArray(refs[key])) {
        refs[key] = [ref]
      } else if (refs[key].indexOf(ref) < 0) {
        // $flow-disable-line
        refs[key].push(ref)
      }
    } else {
      // refs[key] = 组件实例
      refs[key] = ref
    }
  }
}

v-if 和 v-for 哪个优先级更高?

v-for 和 v-if 不要在同一个标签中使用(可以写但是有警告), 因为解析时先解析 v-for 再解析 v-if。 我们使用 vue-template-explore 来看下~

可以看出

<div>
  <li v-for="item in list" v-if="true"></li>
</div>

编译出来是这样的

function render() {
  with(this) {
    // _l 即为 v-for
    return _c('div', _l((list), function (item) {
      // 会拿 list 数据循环,最后渲染的时候判断是否要展示 li 标签,v-for 先执行
      return (true) ? _c('li') : _e()
    }), 0)
  }
}

所以,针对这样的场景,我们可以手动加一个 template 标签包裹

<div>
  <template v-if="true">
    <li v-for="item in list"></li>
  </template>  
</div>

源码在 compiler/codegen/index.js

export function genElement (el: ASTElement, state: CodegenState): string {
  if (el.parent) {
    el.pre = el.pre || el.parent.pre
  }

  if (el.staticRoot && !el.staticProcessed) {
    return genStatic(el, state)
  } else if (el.once && !el.onceProcessed) {
    return genOnce(el, state)
  } else if (el.for && !el.forProcessed) { // 先处理的是 v-for
    return genFor(el, state)
  } else if (el.if && !el.ifProcessed) { // 再处理的是 v-if
    return genIf(el, state)
  } else if (el.tag === 'template' && !el.slotTarget && !state.pre) {
    return genChildren(el, state) || 'void 0'
  } else if (el.tag === 'slot') {
    return genSlot(el, state)
  } else {
    // ...
  }

v-for,v-if,v-model 的实现原理

其实,v-medel 算是指令,而 v-for 和 v-if,并不会编译出 directive 来,在生成代码的时候就将这两个东西进行了转义。

v-for

v-for 实现原理其实就是拼接一个循环函数,内部用了一个方法 _l

src/compiler/codegen/index.js:187

export function genFor(
    el: any,
    state: CodegenState,
    altGen ? : Function,
    altHelper ? : string
): string {
    const exp = el.for // 拿到表达式arr
    const alias = el.alias
    const iterator1 = el.iterator1 ? `,${el.iterator1}` : ''
    const iterator2 = el.iterator2 ? `,${el.iterator2}` : ''

    if (process.env.NODE_ENV !== 'production' &&
        state.maybeComponent(el) && // slot 和 template不能进行v-for操作
        el.tag !== 'slot' &&
        el.tag !== 'template' &&
        !el.key
    ) {
        state.warn(
            `<${el.tag} v-for="${alias} in ${exp}">: component lists rendered with ` +
            `v-for should have explicit keys. ` +
            `See https://vuejs.org/guide/list.html#key for more info.`,
            el.rawAttrsMap['v-for'],
            true /* tip */
        )
    }
    el.forProcessed = true // avoid recursion 生成循环函数
    const r = `${altHelper || '_l'}((${exp}),` +
        `function(${alias}${iterator1}${iterator2}){` +
        `return ${(altGen || genElement)(el, state)}` +
        '})'

    return r;
}

v-if

v-if 自动会被转义成三元表达式。

function genIfConditions(
    conditions: ASTIfConditions,
    state: CodegenState,
    altGen ? : Function,
    altEmpty ? : string
): string {
    if (!conditions.length) {
        return altEmpty || '_e()'
    }
    const condition = conditions.shift()
    if (condition.exp) { // 如果有表达式
        return `(${condition.exp})?${ // 将表达式拼接起来
      genTernaryExp(condition.block)
    }:${ // v-else-if
      genIfConditions(conditions, state, altGen, altEmpty) 
    }`
    } else {
        return `${genTernaryExp(condition.block)}` // 没有表达式直接生成元素 像v-else
    }

    // v-if with v-once should generate code like (a)?_m(0):_m(1)
    function genTernaryExp(el) {
        return altGen ?
            altGen(el, state) :
            el.once ?
            genOnce(el, state) :
            genElement(el, state)
    }
}

v-model

组件上的 v-model

在以往,我们通常传给子组件一个数据,子组件如果想修改,还需要 $emit 调用父组件传递过来的方法修改数据,这样很麻烦。

<body>
    <div id="app">
      <my :name="name" @input="changeName"></my>
    </div>
    <script src="../dist/vue.js"></script>
    <script>
      Vue.component('my',{
          props: {
              name: String
          },
          template:`<div><button @click="$emit('input', 'ys222')">{{ name }}</button></div> ` 
      })
      new Vue({
          el: '#app',
          data:{
              name: 'ys'
          },
          methods:{
              changeName(newVal){
                  this.name = newVal
              }
          }
      })
    </script>
</body>

我们可以使用 v-model 来改写它

<body>
  <div id="app">
    <my v-model="name"></my>
    {{ name }}
  </div>
  <script src="../dist/vue.js"></script>
  <script>
    Vue.component('my',{
      props: {
        value: String // 默认传递的属性名为 value
      },
      template:`<div>{{value}}<button @click="$emit('input', 'ys222')">更改姓名</button></div> ` 
    })

    new Vue({
      el: '#app',
      data:{
        name: 'ys'
      }
    })
  </script>
</body>

可以看出,实际上 v-model 只是一个 :value + @input 的语法糖,所以我们需要 $emit('input', 'xxx') 来去触发,真正的实现是这样的:

<my type="text" :value="name"  @input="name=$event.target.value"></my>

可以通过 model 选项更改子组件接收的属性名和传递的事件名~

<body>
  <div id="app">
    <my v-model="name"></my>
    {{ name }}
  </div>
  <script src="../dist/vue.js"></script>
  <script>
    Vue.component('my', {
      model: {
        prop: 'aaa', // 接收的属性名更改
        event: 'myEvent' // 接收的事件名
      },
      props: {
        aaa: String // 这里也要改哦
      },
      template: `<div>{{ aaa }}<button @click="$emit('myEvent', 'ys222')">更改姓名</button></div> `
    })

    new Vue({
      el: '#app',
      data: {
        name: 'ys'
      }
    })
  </script>
</body>

当模板解析到 v-model 时,会执行 transformModel 方法

function transformModel (options, data: any) {
  // 是否子组件配置了 model.props 选项,默认接收属性名是 value
  const prop = (options.model && options.model.prop) || 'value'
  // 是否子组件配置了 model.event 选项,默认接收属性名是 input
  const event = (options.model && options.model.event) || 'input'
  ;(data.attrs || (data.attrs = {}))[prop] = data.model.value
  const on = data.on || (data.on = {})
  const existing = on[event]
  const callback = data.model.callback
  if (isDef(existing)) {
    if (
      Array.isArray(existing)
        ? existing.indexOf(callback) === -1
        : existing !== callback
    ) {
      on[event] = [callback].concat(existing)
    }
  } else {
    on[event] = callback // 给元素绑定了个事件,事件名默认是 input
  }
}

表单元素上的 v-model

表单元素上的 v-model 和组件是有一些差异的,如下所示,这是 v-model 在输入框绑定的效果

{{ name }}
<input type="text" v-model="name">

而对于 :value + @input 而言

{{ name }}
<input :value="name" @input="e => name=e.target.value" />
所以,可以看出,v-model 用在表单元素和用在组件上是不同的,一定是做过特殊处理的~

我们看下 v-model 编译后的结果

function render() {
  with(this) {
    return _c('input', {
      directives: [{ // 这里多了个指令属性
        name: "model",
        rawName: "v-model",
        value: (name),
        expression: "name"
      }],
      attrs: {
        "type": "text"
      },
      domProps: {
        "value": (name)
      },
      on: {
        "input": function ($event) {
          if ($event.target.composing) return;
          name = $event.target.value
        }
      }
    })
  }
}

所以 v-model 用在表单元素上,会被解析成一个指令(在编译的时候会将 v-model 解析成一个指令),默认会给 input 事件拼接一个处理中文输入法的问题,在运行的时候需要调用指令(会对不同的类型做不同的处理),指令执行的时候还会去处理修饰符 v-model.xxxx。


const directive = { // 运行时 会调用此方法
  inserted (el, binding, vnode, oldVnode) {
    if (vnode.tag === 'select') {
      // #6903
      if (oldVnode.elm && !oldVnode.elm._vOptions) {
        mergeVNodeHook(vnode, 'postpatch', () => {
          directive.componentUpdated(el, binding, vnode)
        })
      } else {
        setSelected(el, binding, vnode.context)
      }
      el._vOptions = [].map.call(el.options, getValue)
    } else if (vnode.tag === 'textarea' || isTextInputType(el.type)) {
      el._vModifiers = binding.modifiers
      if (!binding.modifiers.lazy) {
        el.addEventListener('compositionstart', onCompositionStart)
        el.addEventListener('compositionend', onCompositionEnd)
        // Safari < 10.2 & UIWebView doesn't fire compositionend when
        // switching focus before confirming composition choice
        // this also fixes the issue where some browsers e.g. iOS Chrome
        // fires "change" instead of "input" on autocomplete.
        el.addEventListener('change', onCompositionEnd)
        /* istanbul ignore if */
        if (isIE9) {
          el.vmodel = true
        }
      }
    }
  }

Vue中slot是如何实现的?什么时候使用它?

slot 分为三种:

  • 具名插槽
  • 普通插槽(在父组件中渲染 vnode,只能用父组件的数据,渲染后传递给儿子)
  • 作用域插槽(在子组件中渲染 vnode,可以使用子组件的数据来进行渲染,比如表格组件,slot-scope={ row })

具名插槽

// base-layout 组件
<div class="container">
  <header>
    <!-- 我这里接收 v-slot:header 的模板 -->
    <slot name="header"></slot>
  </header>
  <main>
     <!-- 我这里接收没有具名的模板 -->
    <slot></slot>
  </main>
  <footer>
    <!-- 我这里接收 v-slot:footer 的模板 -->
    <slot name="footer"></slot>
  </footer>
</div>

使用组件和插槽

<base-layout>
  <template v-slot:header>
    <h1>Here might be a page title</h1>
  </template>

  <template v-slot:default>
    <p>A paragraph for the main content.</p>
    <p>And another one.</p>
  </template>

  <template v-slot:footer>
    <p>Here's some contact info</p>
  </template>
</base-layout>

普通插槽

<body>
  <div id="app">
    <my>
      <div>{{ name }}</div>
      <div slot="title">{{ name }}</div>
      <div slot="content">{{ name }}</div>
      <!-- 这里整个会被解析为插槽的属性 slots = { default: vNode1, title: vNode2, content: vNode3 } -->
    </my>
  </div>
  <script src="../dist/vue.js"></script>
  <script>
    Vue.component('my', {
      template: `<div><slot></slot></div>`
      // _c('div', [_t("default"), _t("title"), _t("content")], 2)
      // 可以看到,依次去渲染子节点
    })

    new Vue({
      el: '#app',
      data: {
        name: 'ys'
      }
    })
  </script>
</body>

core/instance/init.js

export function initInternalComponent (vm: Component, options) {
  const opts = vm.$options = Object.create(vm.constructor.options)
  const parentVnode = options._parentVnode
  opts.parent = options.parent
  opts._parentVnode = parentVnode
  const vnodeComponentOptions = parentVnode.componentOptions
  opts.propsData = vnodeComponentOptions.propsData 
  opts._parentListeners = vnodeComponentOptions.listeners 
  opts._renderChildren = vnodeComponentOptions.children // 插槽
  opts._componentTag = vnodeComponentOptions.tag

  if (options.render) {
    opts.render = options.render
    opts.staticRenderFns = options.staticRenderFns
  }
}

继续执行 core/instance/render.js 中的 initRender 方法

// 解析插槽
vm.$slots = resolveSlots(options._renderChildren, renderContext)

看下 core/instance/render-helpers/resolve-slots.js 中的 resolveSlots 方法

// 东西很多,关注这一句即可,如果有 child,就给插槽的 default 属性构建为一个数组
// 比如 slots = { default: [子标签虚拟节点] }
(slots.default || (slots.default = [])).push(child)

作用域插槽

渲染的作用域取自组件内部属性~

<body>
  <div id="app">
    <my>
      <div slot-scope="{ a }">{{ a }}</div>
      <!--解析后为  function ({ a }) { return _c('div', {}, [_v(_s(a))])} -->
      <!-- 没有去渲染,而是返回了一个函数,接收一个参数 a,return 一个 _c 函数 -->
    </my>
  </div>
  <script src="../dist/vue.js"></script>
  <script>
    Vue.component('my', {
      template: `<div><slot a=1></slot></div> `
      // _c('div', [_t("default", null, {
      //   "a": "1"
      // })], 2)
      // 可以看到,在子组件中渲染的时候调用了上面那个函数
      // 并将数据传递给刚才解析后的函数进行渲染
    })

    new Vue({
      el: '#app',
      data: {
        name: 'ys'
      }
    })
  </script>
</body>

Vue.use 是干什么的?原理是什么?

  • Vue.use是用来使用插件的,我们可以在插件中扩展全局组件、指令、原型方法等。
  • 维护了 installedPlugins 缓存加载过的插件列表,如果加载过,直接返回 Vue
  • 会调用插件的 install 方法,将 Vue 的构造函数跟添加到 install 原有参数列表传入,并执行 install 方法。
  • 如果 install 不是一个函数,看插件本身是不是一个函数,是的话,直接执行插件~

src/core/global-api/use.js

// 接收函数或者对象
Vue.use = function (plugin: Function | Object) {
  // 插件缓存
  const installedPlugins = (this._installedPlugins || (this._installedPlugins = []))

  if (installedPlugins.indexOf(plugin) > -1) { // 如果已经安装过插件,直接返回
    return this
  }

  const args = toArray(arguments, 1) // 除了第一项其他的参数整合成数组

  args.unshift(this) // 将 Vue 放入参数 arguments 首位
  if (typeof plugin.install === 'function') {
    // 如果插件的 install 为函数,调用 install 方法
    // 传入 plugin 和 args「args第一项是 Vue」
    plugin.install.apply(plugin, args)
  } else if (typeof plugin === 'function') { 
    // 插件就是函数,调用方法,传入 args「args第一项是 Vue」
    plugin.apply(null, args)
  }
  installedPlugins.push(plugin) // 缓存插件

  return this
}

那么为什么需要有 install 方法呢,提供插件直接用不爽么?其实这是为了解除插件本身和 Vue 版本的强依赖,插件不去规定死 Vue 版本,而采用用户的 Vue,去进行插件的初始化,挂载,防止了版本不同所造成的麻烦。

const customPlugin = {
  install(this, a, b, c) {
    // this 需要你传给我,我不约束 Vue 版本
  }
}

Vue.use(customPlugin) => customPlugin.install(Vue, a, b, c)

组件中写name选项有哪些好处及作用?

  • 增加 name 选项会在 components 属性中增加组件本身,实现组件的递归调用(递归组件,比如多层树型组件,核心思想是递归调用某个组件,而这个组件的作用就是解析出此层的数据。)。
  • 可以标识组件的具体名称方便调试和查找对应组件,比如 keep-alive 可搭配组件 name 进行缓存过滤。

src/core/global-api/extend.js:67

Sub.options.components[name] = Sub;  // 组件配置生成的组件构造函数是挂载 components.name 上的

Vue 事件修饰符有哪些?其实现原理是什么?

.stop、.prevent、.capture!、.self、.once~、.passive&

  • 组件在编译的时候会对一些修饰符做处理(根据不同的修饰符,生成不同的代码)
  • 真正运行的时候,也需要去处理一些修饰符

src\compiler\helpers.js:69

code
export function addHandler ( // 针对 AST 解析的时候,会调用此方法
  el: ASTElement,
  name: string,
  value: string,
  modifiers: ?ASTModifiers,
  important?: boolean,
  warn?: ?Function,
  range?: Range,
  dynamic?: boolean
) {
  modifiers = modifiers || emptyObject
  // warn prevent and passive modifier
  /* istanbul ignore if */
  if (
    process.env.NODE_ENV !== 'production' && warn &&
    modifiers.prevent && modifiers.passive
  ) {
    warn(
      'passive and prevent can\'t be used together. ' +
      'Passive handler can\'t prevent default event.',
      range
    )
  }
  if (modifiers.right) { // 鼠标右键 
    if (dynamic) {
      name = `(${name})==='click'?'contextmenu':(${name})`
    } else if (name === 'click') {
      name = 'contextmenu'
      delete modifiers.right
    }
  } else if (modifiers.middle) { // 鼠标中间
    if (dynamic) {
      name = `(${name})==='click'?'mouseup':(${name})`
    } else if (name === 'click') {
      name = 'mouseup'
    }
  }

  // 精彩来啦
  if (modifiers.capture) { // 如果 capture 用 ! 标记 比如,@click.capture 解析为 !click
    delete modifiers.capture  
    name = prependModifierMarker('!', name, dynamic)
  }
  if (modifiers.once) { // 如果是once 用~ 标记
    delete modifiers.once
    name = prependModifierMarker('~', name, dynamic)
  }
  /* istanbul ignore if */
  if (modifiers.passive) { // 如果是passive 用 &标记
    delete modifiers.passive
    name = prependModifierMarker('&', name, dynamic)
  }

  let events
  if (modifiers.native) {
    delete modifiers.native
    events = el.nativeEvents || (el.nativeEvents = {})
  } else {
    events = el.events || (el.events = {})
  }

  const newHandler: any = rangeSetItem({ value: value.trim(), dynamic }, range)
  if (modifiers !== emptyObject) {
    newHandler.modifiers = modifiers
  }

  const handlers = events[name]
  /* istanbul ignore if */
  if (Array.isArray(handlers)) {
    important ? handlers.unshift(newHandler) : handlers.push(newHandler)
  } else if (handlers) {
    events[name] = important ? [newHandler, handlers] : [handlers, newHandler]
  } else {
    events[name] = newHandler
  }

  el.plain = false
}



src\compiler\codegen\events.js:42

code
function genHandler (handler: ASTElementHandler | Array<ASTElementHandler>): string {  
    let code = ''
    let genModifierCode = ''
    const keys = []
    for (const key in handler.modifiers) {
      if (modifierCode[key]) {
        genModifierCode += modifierCode[key]
        // left/right
        if (keyCodes[key]) {
          keys.push(key)
        }
      } else if (key === 'exact') {
        const modifiers: ASTModifiers = (handler.modifiers: any)
        genModifierCode += genGuard(
          ['ctrl', 'shift', 'alt', 'meta']
            .filter(keyModifier => !modifiers[keyModifier])
            .map(keyModifier => `$event.${keyModifier}Key`)
            .join('||')
        )
      } else {
        keys.push(key) // modifiers中表达式存起来 
      }
    }
    if (keys.length) {
      code += genKeyFilter(keys)
    }
    // Make sure modifiers like prevent and stop get executed after key filtering
    if (genModifierCode) {
      code += genModifierCode
    }
    const handlerCode = isMethodPath
      ? `return ${handler.value}.apply(null, arguments)`
      : isFunctionExpression
        ? `return (${handler.value}).apply(null, arguments)`
        : isFunctionInvocation
          ? `return ${handler.value}`
          : handler.value
    /* istanbul ignore if */
    if (__WEEX__ && handler.params) {
      return genWeexHandler(handler.params, code + handlerCode)
    }
    return `function($event){${code}${handlerCode}}`
}


vue-dev\src\platforms\web\runtime\modules\events.js:105

code
export function updateListeners (
  on: Object,
  oldOn: Object,
  add: Function,
  remove: Function,
  createOnceHandler: Function,
  vm: Component
) {
  let name, def, cur, old, event
  for (name in on) { // 循环on中的 即事件
    def = cur = on[name]
    old = oldOn[name]
    event = normalizeEvent(name) // 事件修饰符
    /* istanbul ignore if */
    if (__WEEX__ && isPlainObject(def)) {
      cur = def.handler
      event.params = def.params
    }
    if (isUndef(cur)) {
      process.env.NODE_ENV !== 'production' && warn(
        `Invalid handler for event "${event.name}": got ` + String(cur),
        vm
      )
    } else if (isUndef(old)) {
      if (isUndef(cur.fns)) {
        cur = on[name] = createFnInvoker(cur, vm)
      }
      if (isTrue(event.once)) {
        cur = on[name] = createOnceHandler(event.name, cur, event.capture)
      }
      add(event.name, cur, event.capture, event.passive, event.params)
    } else if (cur !== old) {
      old.fns = cur
      on[name] = old
    }
  }
  for (name in oldOn) {
    if (isUndef(on[name])) {
      event = normalizeEvent(name)
      remove(event.name, oldOn[name], event.capture)
    }
  }
}


Vue 中 .sync 修饰符的作用,用法

.sync 在 vue3 中被删除掉了,一个类似于 v-model 的用于组件传递 prop 的语法糖,可以实现"双向绑定",它比之组件 v-model 有以下优点:

  • v-model 默认传递的属性名和事件名叫 value 和 input,除非用户改写,.sync 没有这个限制
  • v-model 不能传递和更新多个属性,.sync 可以
<!-- 旧写法 -->
<text-document
  v-bind:title="a"
  v-on:update:title="a = $event"
></text-document>

<!-- .sync 方便写法  .sync 修饰符的 v-bind 不能和表达式一起使用 -->
<text-document v-bind:title.sync="a" v-bind:name.sync="b"></text-document>

<!-- 组件内触发更新 -->
this.$emit('update:title', newTitle)
this.$emit('update:name', newTitle)

那么为什么要废弃掉它呢?因为 vue3 中 v-model 也提供了同样的写法:

<text-document v-model:title="a" v-model:name="b"></text-document>

如何理解自定义指令

  • 1.在生成 ast 语法树时,遇到指令会给当前元素添加 directives 属性 "{ directive: "v-for", name: for}"
  • 2.通过 genDirectives 生成指令代码
directives: [{
    name: "model",
    rawName: "v-model",
    value: (xxx),
    expression: "xxx"
    }]
  • 3.在 patch 前将指令的钩子提取到 cbs 中,在 patch 过程中调用对应的钩子
  • 4.当执行 cbs 对应的钩子时,调用对应指令定义的方法 (create/update/destroy) , 调用用户自定义指令的钩子函数 (inserted,bind,unbind, componentUpdate)

最后实际调用的方法,需要仔细调试下~

src/core/vdom/modules/directives.js

function _update (oldVnode, vnode) {
  const isCreate = oldVnode === emptyNode
  const isDestroy = vnode === emptyNode
  // 拿到老的指令和新的指令
  const oldDirs = normalizeDirectives(oldVnode.data.directives, oldVnode.context)
  const newDirs = normalizeDirectives(vnode.data.directives, vnode.context)

  const dirsWithInsert = []
  const dirsWithPostpatch = []

  let key, oldDir, dir
  for (key in newDirs) {
    oldDir = oldDirs[key]
    dir = newDirs[key]
    if (!oldDir) { // 如果没有老的指令 调用bind方法 Vue.directive({bind,inserte,compontentUpdate})
      // new directive, bind
      callHook(dir, 'bind', vnode, oldVnode)
      if (dir.def && dir.def.inserted) { // 先存起来inserted 要等待组件被插入到页面后再 调用方法
        dirsWithInsert.push(dir)
      }
    } else {
      // existing directive, update
      dir.oldValue = oldDir.value
      dir.oldArg = oldDir.arg // 如果老的新的都有就调用更新的钩子
      callHook(dir, 'update', vnode, oldVnode)
      if (dir.def && dir.def.componentUpdated) {
        dirsWithPostpatch.push(dir) // 将更新流程放到队列中
      }
    }
  }

  if (dirsWithInsert.length) {
    const callInsert = () => {
      for (let i = 0; i < dirsWithInsert.length; i++) {
        callHook(dirsWithInsert[i], 'inserted', vnode, oldVnode)
      }
    }
    if (isCreate) { // 将vnode插入的操作 和 insert操作合并在一起
      mergeVNodeHook(vnode, 'insert', callInsert)
    } else {
      callInsert()
    }
  }

  if (dirsWithPostpatch.length) {
    mergeVNodeHook(vnode, 'postpatch', () => {
      for (let i = 0; i < dirsWithPostpatch.length; i++) {
        callHook(dirsWithPostpatch[i], 'componentUpdated', vnode, oldVnode)
      }
    })
  }

  if (!isCreate) {
    for (key in oldDirs) {
      if (!newDirs[key]) {
        // no longer present, unbind
        callHook(oldDirs[key], 'unbind', oldVnode, oldVnode, isDestroy)
      }
    }
  }
}

keep-alive平时在哪里使用?原理是?

<keep-alive :include="whiteList" :exclude="blackList" :max="count">
    <router-view></router-view>
</keep-alive>

keep-alive 缓存的是什么呢?它缓存了组件的实例,而组件的实例上就有组件的 dom(vm.el),所以缓存了实例也就是缓存了dom元素。组件在切换的时候,如果有缓存,直接可以复用上次渲染出的vm.el),所以缓存了实例也就是缓存了 dom 元素。组件在切换的时候,如果有缓存,直接可以复用上次渲染出的 vm.el 结果~

keep-alive 不用做任何渲染操作,内部使用了一个 LRU 算法来管理缓存(抽象组件,不会产生父子关系,但是有缓存)。

LRU:最近最久未使用法(最近使用的要提前,最久未使用的优先丢弃)

let cachedList = [a, b, c];
maxCache = 3;

// 最久未使用丢弃
arr.push(d); // a 移除,d 插入

// 如果插入元素在缓存中存在,则把缓存中该元素移动到最新的位置
arr.push(b) // 第二项的 b,移动到数组末尾

keep-alive 实现

src/core/components/keep-alive.js

export default {
  name: 'keep-alive',
  abstract: true, // 说明是一个抽象组件,不会被记录在 $parent $children 中

  props: {
    include: patternTypes, // 白名单
    exclude: patternTypes, // 黑名单 动态操作黑白名单可以切换缓存 v-if 来切换
    max: [String, Number] // 最大的缓存个数
  },

  methods: {
    cacheVNode() {
      const { cache, keys, vnodeToCache, keyToCache } = this // 取出要缓存的节点
      if (vnodeToCache) {
        const { tag, componentInstance, componentOptions } = vnodeToCache
        cache[keyToCache] = { // 缓存的就是实例
          name: getComponentName(componentOptions),
          tag,
          componentInstance, 
        }
        keys.push(keyToCache) // [1]
        // prune oldest entry
        if (this.max && keys.length > parseInt(this.max)) {
          // 超过限制,把第 0 个删掉,最新的放进来,最近最久未使用法的思想
          pruneCacheEntry(cache, keys[0], keys, this._vnode)
        }
        this.vnodeToCache = null 
      }
    }
  },

  created () {
    this.cache = Object.create(null) // 记录缓存的列表
    this.keys = [] // 记录所有要缓存组件的名字
  },

  destroyed () {
    for (const key in this.cache) {
      pruneCacheEntry(this.cache, key, this.keys)
    }
  },

  mounted () { // mounted要等待页面渲染完毕后调用,先往下看 render!!
    this.cacheVNode()
    this.$watch('include', val => {
      pruneCache(this, name => matches(val, name))
    })
    this.$watch('exclude', val => {
      pruneCache(this, name => !matches(val, name))
    })
  },

  updated () {
    this.cacheVNode()
  },

  render () {
    const slot = this.$slots.default // 拿到默认插槽
    const vnode: VNode = getFirstComponentChild(slot) // 获取第一个组件的虚拟节点 keep-alive只能缓存一个组件
    const componentOptions: ?VNodeComponentOptions = vnode && vnode.componentOptions
    if (componentOptions) { // Ctor,props,children,slos,name 都在里面哦
      // check pattern
      const name: ?string = getComponentName(componentOptions) // componentOptions.name
      const { include, exclude } = this
      if ( // 获取组件名 看是否需要缓存,不需要缓存则直接返回
        // not included
        (include && (!name || !matches(include, name))) ||
        // excluded
        (exclude && name && matches(exclude, name))
      ) {
        // 不缓存 直接返回 vnode,原样返回
        return vnode 
      }

      const { cache, keys } = this // cache 缓存列表,keys 缓存的 key 的数组
      const key: ?string = vnode.key == null
        ? componentOptions.Ctor.cid + (componentOptions.tag ? `::${componentOptions.tag}` : '')
        : vnode.key // 计算一个缓存的key值,组件有 key 直接用,组件没 key,使用 cid + 标签名作为 key
      if (cache[key]) { // 缓存过,复用组件实例
        vnode.componentInstance = cache[key].componentInstance
        // make current key freshest

        remove(keys, key) // 先把自己删掉
        keys.push(key) // 插到最后面
      } else { // 没有缓存过
        // delay setting the cache until update
        this.vnodeToCache = vnode // 记录需要缓存的组件 vnode
        this.keyToCache = key // 记录需要缓存的 key 
      }

      // 为了后续在组件创造的过程中 init 方法判断是不是 keep-alive 缓存过的组件
      // 如果缓存过就不再次 new Ctor
      vnode.data.keepAlive = true 
    }
    return vnode || (slot && slot[0]) // 返回虚拟节点

    // a b页面都缓存了
    // a->b 页面他是如何更新的

    // <keep-alive><router-view></router-view></keep-alive>
  }
}

切换路由,keep-alive 缓存的页面怎么更新

比如 a b 页面都被缓存了,那么当我从 a 切换到 b 页面时,为什么组件的更换会更新页面呢?页面明明都是一层 keep-alive 套着 router-view,原因是 router-view 里面有个 slot 标签。

keep-alive 中组件切换的时候,(router-view 内部的)插槽会触发更新,如果插槽的内容变化了,会强制重新渲染 $forceupdate, 重新进行渲染。

activated 和 deactiveted

还有个点就是,当 keep-alive 的组件被激活,会调用 activated 钩子,同样的还有 keep-alive 的组件被销毁时会调用 deactiveted 钩子。