vue面试题详解(c的)

211 阅读39分钟

框架通识

vue-router 浅析原理

vue-router 是什么?

vue-router 是 Vue.js 官方的路由插件,它和 vue.js 是深度集成的,适合用于构建单页面应用。

那与传统的页面跳转有什么区别呢?

1.vue 的单页面应用是基于路由和组件的,路由用于设定访问路径,并将路径和组件映射起来。

2.传统的页面应用,是用一些超链接来实现页面切换和跳转的。

vue-router 实现原理

SPA(single page application):单一页面应用程序,有且只有一个完整的页面;当它在加载页面的时候,不会加载整个页面的内容,而只更新某个指定的容器中内容。

单页面应用(SPA)的核心之一是:

1.更新视图而不重新请求页面;

2.vue-router 在实现单页面前端路由时,提供了三种方式:Hash 模式、History 模式、abstract 模式,根据 mode 参数来决定采用哪一种方式。

路由模式

vue-router 提供了三种运行模式:

● hash: 使用 URL hash 值来作路由。默认模式。

● history: 依赖 HTML5 History API 和服务器配置。查看 HTML5 History 模式。

● abstract: 支持所有 JavaScript 运行环境,如 Node.js 服务器端。

Hash 模式

vue-router 默认模式是 hash 模式 —— 使用 URL 的 hash 来模拟一个完整的 URL,当 URL 改变时,页面不会去重新加载。

hash(#)是 URL 的锚点,代表的是网页中的一个位置,单单改变#后的部分(/#/…),浏览器只会加载相应位置的内容,不会重新加载网页,也就是说 #是用来指导浏览器动作的,对服务器端完全无用,HTTP 请求中不包括#;同时每一次改变#后的部分,都会在浏览器的访问历史中增加一个记录,使用”后退”按钮,就可以回到上一个位置;所以说 Hash 模式通过锚点值的改变,根据不同的值,渲染指定 DOM 位置的不同数据

History 模式

HTML5 History API 提供了一种功能,能让开发人员在不刷新整个页面的情况下修改站点的 URL,就是利用 history.pushState API 来完成 URL 跳转而无须重新加载页面;

由于 hash 模式会在 url 中自带#,如果不想要很丑的 hash,我们可以用路由的 history 模式,只需要在配置路由规则时,加入"mode: ‘history’",这种模式充分利用 history.pushState API 来完成 URL 跳转而无须重新加载页面。

//main.js文件中
const router = new VueRouter({
  mode: 'history',
  routes: [...]
})

当使用 history 模式时,URL 就像正常的 url,例如 yoursite.com/user/id,比较好… 不过这种模式要玩好,还需要后台配置支持。所以呢,你要在服务端增加一个覆盖所有情况的候选资源:如果 URL 匹配不到任何静态资源,则应该返回同一个 index.html 页面,这个页面就是你 app 依赖的页面。

export const routes = [
  {path: "/", name: "homeLink", component:Home}
  {path: "/register", name: "registerLink", component: Register},
  {path: "/login", name: "loginLink", component: Login},
  {path: "*", redirect: "/"}]

此处就设置如果 URL 输入错误或者是 URL 匹配不到任何静态资源,就自动跳到到 Home 页面。

abstract 模式

abstract 模式是使用一个不依赖于浏览器的浏览历史虚拟管理后端。

根据平台差异可以看出,在 Weex 环境中只支持使用 abstract 模式。 不过,vue-router 自身会对环境做校验,如果发现没有浏览器的 API,vue-router 会自动强制进入 abstract 模式,所以 在使用 vue-router 时只要不写 mode 配置即可,默认会在浏览器环境中使用 hash 模式,在移动端原生环境中使用 abstract 模式。 (当然,你也可以明确指定在所有情况下都使用 abstract 模式)

vue-router 使用方式

1:下载 npm i vue-router -S

**2:在 main.js 中引入 ** import VueRouter from ‘vue-router’;

3:安装插件 Vue.use(VueRouter);

4:创建路由对象并配置路由规则

let router = new VueRouter({routes:[{path:’/home’,component:Home}]});

5:将其路由对象传递给 Vue 的实例,options 中加入 router:router

6:在 app.vue 中留坑

//main.js文件中引入
import Vue from "vue";
import VueRouter from "vue-router";
//主体
import App from "./components/app.vue";
import index from "./components/index.vue";
//安装插件
Vue.use(VueRouter); //挂载属性
//创建路由对象并配置路由规则
let router = new VueRouter({
  routes: [
    //一个个对象
    { path: "/index", component: index }
  ]
});
//new Vue 启动
new Vue({
  el: "#app",
  //让vue知道我们的路由规则
  router: router, //可以简写router
  render: c => c(App)
});

最后记得在在 app.vue 中“留坑”

//app.vue 中  
<template>

<router-view></router-view>

</template> <script> export default { data(){ return {} } } </script>

说一下虚拟 DOM?为什么要使用虚拟 DOM

什么是虚拟 DOM:  ⽤ JavaScript 对象表示 DOM 信息和结构,更新后使之与真实 dom 保持同步,同步过程就是协调,核心是 diff 算法。

为什么要用虚拟 DOM:  DOM 操作很慢,轻微的操作都可能导致⻚面重新排 版,⾮常耗性能。相对于 DOM 对象,js 对象处理起来更快, 而且更简单。通过 diff 算法对比新旧 vdom 之间的差异,可以 批量的、最⼩化的执行 dom 操作,从而提高性能。

why?  DOM 操作很慢,轻微的操作都可能导致页面重新排版,非常耗性能。相对于 DOM 对象,js 对象 处理起来更快,而且更简单。通过 diff 算法对比新旧 vdom 之间的差异,可以批量的、最小化的执行 dom 操作,从而提高性能。

大佬的详细分析 juejin.cn/post/684490…

map 和 v-for 中 key 的作用

虚拟 DOM 中 key 的作用

key 是虚拟 DOM 对象的标识 当状态中的数据发生改变时,Vue 会根据新数据生成新的虚拟 DOM,随后 Vue 进行新虚拟 DOM 和旧虚拟 DOM 的差异比较

对比规则

旧虚拟 DOM 中找到了与新虚拟 DOM 相同的 key:

  • 若虚拟 DOM 中内容没有变 直接使用之前的真实 DOM
  • 若虚拟 DOM 中内容变了 则生成新的真实 DOM 随后替换掉页面中之前的真实 DOM

旧虚拟 DOM 中未找到与新虚拟 DOM 相同的 key: 创建新的真实 DOM 随后渲染到页面

用 index 作为 key 可能会引发的问题

  1. 若对数据进行 :逆序添加 逆序删除等破坏顺序操作:

    会产生没有必要的真实 DOM 更新 界面效果没有问题 但效率低

  2. 如果结构中还包含输入类 DOM:

    会产生错误 DOM 更新 界面有问题

在这里插入图片描述

在这里插入图片描述

Vue 的 diff 算法

vue 中的 diff 算法涉及到操作 dom 的逻辑,所以用 html 来做演示

  • oldNodes 表示老的虚拟节点列表,el-真实 dom,tag-标签类型
  • domDiff 方法接收三个参数,el-要参与对比节点的父节点,oldChildren-老的虚拟 dom 列表,newChildren-新的虚拟 dom 列表
  • vue 中的节点对比采用双指针,从两端向中间遍历,当指针交叉的时候,就是对比完成了
  • 开始遍历时,首先依次进行头头、尾尾、头尾、尾头对比,这也是 vue 中 diff 算法 的一个优化点
  • 都对比完了,再对比其他没有移动规律的节点

image.png

image.png

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    <ul id="container">
      <li id="domA">A</li>
      <li id="domB">B</li>
      <li id="domC">C</li>
      <li id="domD">D</li>
    </ul>
    <script>
      const oldNodes = [
        { key: "A", el: domA, tag: "li" },
        { key: "B", el: domB, tag: "li" },
        { key: "C", el: domC, tag: "li" },
        { key: "D", el: domD, tag: "li" }
      ];

      const newNodes = [
        { key: "C", tag: "li" },
        { key: "A", tag: "li" },
        { key: "F", tag: "li" },
        { key: "G", tag: "li" },
        { key: "B", tag: "li" }
      ];

      /**
       * 看两个节点是否相同节点, 对比tag和key是否一样
       * @param {*} newVnode
       * @param {*} oldVnode
       */
      function isSameVnode(newVnode, oldVnode) {
        return newVnode.tag === oldVnode.tag && newVnode.key == oldVnode.key;
      }

      /**
       *
       * @param {*} el 真实dom节点
       * @param {*} oldChildren 老虚拟dom
       * @param {*} newChildren 新虚拟dom
       */
      function domDiff(el, oldChildren, newChildren) {
        // 老的开始索引
        let oldStartIndex = 0;
        // 老的开始节点
        let oldStartVnode = oldChildren[0];
        // 老的结束索引
        let oldEndIndex = oldChildren.length - 1;
        // 老的结束节点
        let oldEndVnode = oldChildren[oldEndIndex];

        // 新的开始索引
        let newStartIndex = 0;
        // 新的开始节点
        let newStartVnode = newChildren[0];
        // 新的结束索引
        let newEndIndex = newChildren.length - 1;
        // 新的结束节点
        let newEndVnode = newChildren[newEndIndex];

        // 根据老的节点,构造一个map
        let oldNodeMap = oldChildren.reduce((memo, item, index) => {
          // A: 0  记录位置
          memo[item.key] = index;
          return memo;
        }, {});

        // 双指针对比,从两端向中间遍历,当指针交叉的时候,就是对比完成了
        while (oldStartIndex <= oldEndIndex && newStartIndex <= newEndIndex) {
          // 指针移动的时候,可能元素已经被移走了,那就跳过这一项
          if (!oldStartVnode) {
            oldStartVnode = oldChildren[++oldStartIndex];
            console.log("1. oldStartVnode 为空");
          } else if (!oldEndVnode) {
            oldEndVnode = oldChildren[--oldEndIndex];
            console.log("2. oldEndVnode 为空");
          } else if (isSameVnode(oldStartVnode, newStartVnode)) {
            console.log("3. 头头相同", newStartVnode.key);
            // 头头比较,如果相同就移动头指针
            oldStartVnode = oldChildren[++oldStartIndex];
            newStartVnode = newChildren[++newStartIndex];
          } else if (isSameVnode(oldEndVnode, newEndVnode)) {
            console.log("4. 尾尾相同", newEndVnode.key);
            // 尾尾比较,如果相同,移动尾指针
            oldEndVnode = oldChildren[--oldEndIndex];
            newEndVnode = newChildren[--newEndIndex];
          } else if (isSameVnode(oldStartVnode, newEndVnode)) {
            console.log(
              `5. 头尾相同 移动 ${oldStartVnode.key} 到 ${oldEndVnode.key}的下一节点之前`
            );
            // 头尾比较
            // 将oldStartVnode.el 老节点的真实dom,移动到老的节点的最后
            el.insertBefore(oldStartVnode.el, oldEndVnode.el.nextSibling);
            oldStartVnode = oldChildren[++oldStartIndex];
            newEndVnode = newChildren[--newEndIndex];
          } else if (isSameVnode(oldEndVnode, newStartVnode)) {
            // 尾头比较
            console.log(
              `6. 尾头相同 移动${oldEndVnode.key} 到${oldStartVnode.key}之前`
            );
            el.insertBefore(oldEndVnode.el, oldStartVnode.el);
            oldEndVnode = oldChildren[--oldEndIndex];
            newStartVnode = newChildren[++newStartIndex];
          } else {
            // 上面都是特殊情况
            // 头头、尾尾、头尾、尾头都对比完了
            // 对比乱序的
            let moveIndex = oldNodeMap[newStartVnode.key];
            if (moveIndex === undefined) {
              // 找不到索引, 是新的节点,要创建一下
              el.insertBefore(createElm(newStartVnode), oldStartVnode.el);
              console.log(
                `7. 创建新节点${newStartVnode.key} 插入到 ${oldStartVnode.key}之前`
              );
            } else {
              // 找到了
              let moveVnode = oldChildren[moveIndex];
              el.insertBefore(moveVnode.el, oldStartVnode.el);
              // 将已经移动的节点标记为undefine
              oldChildren[moveIndex] = undefined;
              console.log(
                `8. 移动乱序节点${moveVnode.key} 到 ${oldStartVnode.key} 之前`
              );
            }
            newStartVnode = newChildren[++newStartIndex];
          }
        }

        // 新的多,那么就将多的插入进去即可
        if (newStartIndex <= newEndIndex) {
          // 参照物
          let anchor =
            newChildren[newEndIndex + 1] === null
              ? null
              : newChildren[newEndIndex + 1].el;
          for (let i = newStartIndex; i <= newEndIndex; i++) {
            console.log("插入", newChildren[i].key);
            el.insertBefore(createElm(newChildren[i]), anchor);
          }
        }

        // 老的多余,需要清理掉,删除即可
        if (oldStartIndex <= oldEndIndex) {
          for (let i = oldStartIndex; i <= oldEndIndex; i++) {
            let child = oldChildren[i];
            console.log("删除", child.key);
            child && el.removeChild(child.el);
          }
        }
      }

      /**
       * 根据虚拟dom创建真实dom
       * @param {*} vnode
       * @returns
       */
      function createElm(vnode) {
        let { tag, text, key } = vnode;

        if (typeof tag === "string") {
          vnode.el = document.createElement(tag);
          vnode.el.innerText = key;
        } else {
          vnode.el = document.createTextNode(text);
        }
        return vnode.el;
      }

      domDiff(container, oldNodes, newNodes);
    </script>
  </body>
</html>

区别

相同点

  • 都是两组虚拟 dom 的对比(react16.8 之后是 fiber 与虚拟 dom 的对比)
  • 只对同级节点进行对比,简化了算法复杂度
  • 都用 key 做为唯一标识,进行查找,只有 key 和标签类型相同时才会复用老节点
  • 遍历前都会根据老的节点构建一个 map,方便根据 key 快速查找

不同点

  • react 在 diff 遍历的时候,只对需要修改的节点进行了记录,形成 effect list,最后才会根据 effect list 进行真实 dom 的修改,修改时先删除,然后更新与移动,最后插入
  • vue 在遍历的时候就用真实 dominsertBefore方法,修改了真实 dom,最后做的删除操作
  • react 采用单指针从左向右进行遍历
  • vue 采用双指针,从两头向中间进行遍历
  • react 的虚拟 diff 比较简单,vue 中做了一些优化处理,相对复杂,但效率更高

组件通信的方式有哪些

vue2.x

1. props、emit(最常用的父子通讯方式)

父组件传入属性,子组件通过 props 接收,就可以在内部 this.XXX 的方式使用 子组件$emit(事件名,传递的参数)向外弹出一个自定义事件, 在父组件中的属性监听事件,同时也能获取子组件传出来的参数

//	父组件
<hello-world msg="hello world!" @confirm="handleConfirm"><hello-world>
//	子组件
 props: {
    msg: {
      type: String,
      default: ''
    }
  },
  methods:{
  	handleEmitParent(){
  		this.$emit('confirm', list)
  	}
  }

2. 事件总线 EventBus (常用任意两个组件之间通讯)

原理:注册的事件存起来,等触发事件时再调用。定义一个类去处理事件,并挂载到 Vue 实例的 this 上即可注册和触发事件,也可拓展一些事件管理

class Bus {
  constructor() {
    this.callbackList = {};
  }

  $on(name, callback) {
    // 注册事件
    this.callbackList[name]
      ? this.callbackList[name].push(callback)
      : (this.callbackList[name] = [callback]);
  }

  $emit(name, args) {
    // 触发事件
    if (this.callbackList[name]) {
      this.callbackList[name].forEach(cb => cb(args));
    }
  }
}

Vue.prototype.$bus = new Bus();

// 任意两个组件中
// 组件一:在组件的 mounted() 去注册事件
this.$bus.$on("confirm", handleConfirm);

// 组件二:触发事件(如:点击事件后执行触发事件即可)
this.$bus.$emit("confirm", list);

3. Vuex 状态管理

创建全局唯一的状态管理仓库(store),有同步(mutations)、异步(actions)的方式去管理数据,有缓存数据(getters),还能分成各个模块(modules)易于维护

juejin.cn/post/692846…

vuex 页面刷新数据丢失问题的多种解决方法

juejin.cn/post/706185…

4.ref

ref:如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子组件上,引用就指向组件实例

需要注意的是:这两种都是直接得到组件实例,使用后可以直接调用组件的方法或访问数据。我们先来看个用 ref来访问组件的例子:

// component-a 子组件
export default {
  data() {
    return {
      title: "Vue.js"
    };
  },
  methods: {
    sayHello() {
      window.alert("Hello");
    }
  }
};

// 父组件

5.provide/inject

简介

Vue2.2.0 新增 API,这对选项需要一起使用,以允许一个祖先组件向其所有子孙后代注入一个依赖,不论组件层次有多深,并在起上下游关系成立的时间里始终生效。一言而蔽之:祖先组件中通过 provider 来提供变量,然后在子孙组件中通过 inject 来注入变量。 provide / inject API 主要解决了跨级组件间的通信问题,不过它的使用场景,主要是子组件获取上级组件的状态,跨级组件间建立了一种主动提供与依赖注入的关系

举个例子

假设有两个组件: A.vue 和 B.vue,B 是 A 的子组件

// A.vue
export default {
  provide: {
    name: "浪里行舟"
  }
};
// B.vue
export default {
  inject: ["name"],
  mounted() {
    console.log(this.name); // 浪里行舟
  }
};

可以看到,在 A.vue 里,我们设置了一个 provide: name,值为 浪里行舟,它的作用就是将 name 这个变量提供给它的所有子组件。而在 B.vue 中,通过 inject 注入了从 A 组件中提供的 name 变量,那么在组件 B 中,就可以直接通过 this.name 访问这个变量了,它的值也是 浪里行舟。这就是 provide / inject API 最核心的用法。

需要注意的是:provide 和 inject 绑定并不是可响应的。这是刻意为之的。然而,如果你传入了一个可监听的对象,那么其对象的属性还是可响应的----vue 官方文档 所以,上面 A.vue 的 name 如果改变了,B.vue 的 this.name 是不会改变的,仍然是 浪里行舟。

provide 与 inject 怎么实现数据响应式

  • provide 祖先组件的实例,然后在子孙组件中注入依赖,这样就可以在子孙组件中直接修改祖先组件的实例的属性,不过这种方法有个缺点就是这个实例上挂载很多没有必要的东西比如 props,methods
  • 使用 2.6 最新 API Vue.observable 优化响应式 provide(推荐)

我们来看个例子:孙组件 D、E 和 F 获取 A 组件传递过来的 color 值,并能实现数据响应式变化,即 A 组件的 color 变化后,组件 D、E、F 会跟着变(核心代码如下:)

// A 组件
<div>
      <h1>A 组件</h1>
      <button @click="() => changeColor()">改变color</button>
      <ChildrenB />
      <ChildrenC />
</div>
......
  data() {
    return {
      color: "blue"
    };
  },
  // provide() {
  //   return {
  //     theme: {
  //       color: this.color //这种方式绑定的数据并不是可响应的
  //     } // 即A组件的color变化后,组件D、E、F不会跟着变
  //   };
  // },
  provide() {
    return {
      theme: this//方法一:提供祖先组件的实例
    };
  },
  methods: {
    changeColor(color) {
      if (color) {
        this.color = color;
      } else {
        this.color = this.color === "blue" ? "red" : "blue";
      }
    }
  }
  // 方法二:使用2.6最新API Vue.observable 优化响应式 provide
  // provide() {
  //   this.theme = Vue.observable({
  //     color: "blue"
  //   });
  //   return {
  //     theme: this.theme
  //   };
  // },
  // methods: {
  //   changeColor(color) {
  //     if (color) {
  //       this.theme.color = color;
  //     } else {
  //       this.theme.color = this.theme.color === "blue" ? "red" : "blue";
  //     }
  //   }
  // }
// F 组件
<template functional>
  <div class="border2">
    <h3 :style="{ color: injections.theme.color }">F 组件</h3>
  </div>
</template>
<script>
export default {
  inject: {
    theme: {
      //函数式组件取值不一样
      default: () => ({})
    }
  }
};
</script>

vue3

1. props

方法一,混合写法

// Parent.vue 传送
<child :msg1="msg1" :msg2="msg2"></child>
<script>
import child from "./child.vue"
import { ref, reactive } from "vue"
export default {
    data(){
        return {
            msg1:"这是传级子组件的信息1"
        }
    },
    setup(){
        // 创建一个响应式数据

        // 写法一 适用于基础类型  ref 还有其他用处,下面章节有介绍
        const msg2 = ref("这是传级子组件的信息2")

        // 写法二 适用于复杂类型,如数组、对象
        const msg2 = reactive(["这是传级子组件的信息2"])

        return {
            msg2
        }
    }
}
</script>

// Child.vue 接收
<script>
export default {
  props: ["msg1", "msg2"],// 如果这行不写,下面就接收不到
  setup(props) {
    console.log(props) // { msg1:"这是传给子组件的信息1", msg2:"这是传给子组件的信息2" }
  },
}
</script>

方法二,纯 Vue3 写法(语法糖)

// Parent.vue 传送
<child :msg2="msg2"></child>
<script setup>
    import child from "./child.vue"
    import { ref, reactive } from "vue"
    const msg2 = ref("这是传给子组件的信息2")
    // 或者复杂类型
    const msg2 = reactive(["这是传级子组件的信息2"])
</script>

// Child.vue 接收
<script setup>
    // 不需要引入 直接使用
    // import { defineProps } from "vue"
    const props = defineProps({
        // 写法一
        msg2: String
        // 写法二
        msg2:{
            type:String,
            default:""
        }
    })
    console.log(props) // { msg2:"这是传级子组件的信息2" }
</script>

2. $emit

// Child.vue 派发
<template>
    // 写法一
    <button @click="emit('myClick')">按钮</buttom>
    // 写法二
    <button @click="handleClick">按钮</buttom>
</template>
<script setup>

    // 方法一 适用于Vue3.2版本 不需要引入
    // import { defineEmits } from "vue"
    // 对应写法一
    const emit = defineEmits(["myClick","myClick2"])
    // 对应写法二
    const handleClick = ()=>{
        emit("myClick", "这是发送给父组件的信息")
    }

    // 方法二 不适用于 Vue3.2版本,该版本 useContext()已废弃
    import { useContext } from "vue"
    const { emit } = useContext()
    const handleClick = ()=>{
        emit("myClick", "这是发送给父组件的信息")
    }
</script>

// Parent.vue 响应
<template>
    <child @myClick="onMyClick"></child>
</template>
<script setup>
    import child from "./child.vue"
    const onMyClick = (msg) => {
        console.log(msg) // 这是父组件收到的信息
    }
</script>

3. expose / ref

父组件获取子组件的属性或者调用子组件方法

// Child.vue
<script setup>
    // 方法一 不适用于Vue3.2版本,该版本 useContext()已废弃
    import { useContext } from "vue"
    const ctx = useContext()
    // 对外暴露属性方法等都可以
    ctx.expose({
        childName: "这是子组件的属性",
        someMethod(){
            console.log("这是子组件的方法")
        }
    })

    // 方法二 适用于Vue3.2版本, 不需要引入
    // import { defineExpose } from "vue"
    defineExpose({
        childName: "这是子组件的属性",
        someMethod(){
            console.log("这是子组件的方法")
        }
    })
</script>

// Parent.vue  注意 ref="comp"
<template>
    <child ref="comp"></child>
    <button @click="handlerClick">按钮</button>
</template>
<script setup>
    import child from "./child.vue"
    import { ref } from "vue"
    const comp = ref(null)
    const handlerClick = () => {
        console.log(comp.value.childName) // 获取子组件对外暴露的属性
        comp.value.someMethod() // 调用子组件对外暴露的方法
    }
</script>

4. attrs

attrs:包含父作用域里除 class 和 style 除外的非 props 属性集合

// Parent.vue 传送
<child :msg1="msg1" :msg2="msg2" title="3333"></child>
<script setup>
    import child from "./child.vue"
    import { ref, reactive } from "vue"
    const msg1 = ref("1111")
    const msg2 = ref("2222")
</script>

// Child.vue 接收
<script setup>
    import { defineProps, useContext, useAttrs } from "vue"
    // 3.2版本不需要引入 defineProps,直接用
    const props = defineProps({
        msg1: String
    })
    // 方法一 不适用于 Vue3.2版本,该版本 useContext()已废弃
    const ctx = useContext()
    // 如果没有用 props 接收 msg1 的话就是 { msg1: "1111", msg2:"2222", title: "3333" }
    console.log(ctx.attrs) // { msg2:"2222", title: "3333" }

    // 方法二 适用于 Vue3.2版本
    const attrs = useAttrs()
    console.log(attrs) // { msg2:"2222", title: "3333" }
</script>

5. v-model

可以支持多个数据双向绑定

// Parent.vue
<child v-model:key="key" v-model:value="value"></child>
<script setup>
    import child from "./child.vue"
    import { ref, reactive } from "vue"
    const key = ref("1111")
    const value = ref("2222")
</script>

// Child.vue
<template>
    <button @click="handlerClick">按钮</button>
</template>
<script setup>

    // 方法一  不适用于 Vue3.2版本,该版本 useContext()已废弃
    import { useContext } from "vue"
    const { emit } = useContext()

    // 方法二 适用于 Vue3.2版本,不需要引入
    // import { defineEmits } from "vue"
    const emit = defineEmits(["key","value"])

    // 用法
    const handlerClick = () => {
        emit("update:key", "新的key")
        emit("update:value", "新的value")
    }
</script>

6. provide / inject

provide / inject 为依赖注入

provide:可以让我们指定想要提供给后代组件的数据

inject:在任何后代组件中接收想要添加在这个组件上的数据,不管组件嵌套多深都可以直接拿来用

// Parent.vue
<script setup>
    import { provide } from "vue"
    provide("name", "沐华")
</script>

// Child.vue
<script setup>
    import { inject } from "vue"
    const name = inject("name")
    console.log(name) // 沐华
</script>

7. Vuex

// store/index.js
import { createStore } from "vuex"
export default createStore({
    state:{ count: 1 },
    getters:{
        getCount: state => state.count
    },
    mutations:{
        add(state){
            state.count++
        }
    }
})

// main.js
import { createApp } from "vue"
import App from "./App.vue"
import store from "./store"
createApp(App).use(store).mount("#app")

// Page.vue
// 方法一 直接使用
<template>
    <div>{{ $store.state.count }}</div>
    <button @click="$store.commit('add')">按钮</button>
</template>

// 方法二 获取
<script setup>
    import { useStore, computed } from "vuex"
    const store = useStore()
    console.log(store.state.count) // 1

    const count = computed(()=>store.state.count) // 响应式,会随着vuex数据改变而改变
    console.log(count) // 1
</script>

8. mitt

Vue3 中没有了 EventBus 跨组件通信,但是现在有了一个替代的方案 mitt.js,原理还是 EventBus

先安装 npm i mitt -S

然后像以前封装 bus 一样,封装一下

mitt.js;
import mitt from "mitt";
const mitt = mitt();
export default mitt;

然后两个组件之间通信的使用

// 组件 A
<script setup>
import mitt from './mitt'
const handleClick = () => {
    mitt.emit('handleChange')
}
</script>

// 组件 B
<script setup>
import mitt from './mitt'
import { onUnmounted } from 'vue'
const someMethed = () => { ... }
mitt.on('handleChange',someMethed)
onUnmounted(()=>{
    mitt.off('handleChange',someMethed)
})
</script>

SPA 单页面应用和多页面应用有什么区别

SPA和MPA的对比.png

Vue

computed 和 watch 的区别

computed

computed 看上去是方法,但是实际上是计算属性,它会根据你所依赖的数据动态显示新的计算结果。计算结果会被缓存,computed 的值在 getter 执行后是会缓存的,只有在它依赖的属性值改变之后,下一次获取 computed 的值时才会重新调用对应的 getter 来计算

下面是一个比较经典简单的案例

<template>
  <div class="hello">
      {{fullName}}
  </div>
</template>

<script>
export default {
    data() {
        return {
            firstName: '飞',
            lastName: "旋"
        }
    },
    props: {
      msg: String
    },
    computed: {
        fullName() {
            return this.firstName + ' ' + this.lastName
        }
    }
}
</script>

注意

在 Vue 的 template 模板内({{}})是可以写一些简单的 js 表达式的很便利,如上直接计算 {{this.firstName + ’ ’ + this.lastName}},因为在模版中放入太多声明式的逻辑会让模板本身过重,尤其当在页面中使用大量复杂的逻辑表达式处理数据时,会对页面的可维护性造成很大的影响,而 computed 的设计初衷也正是用于解决此类问题。

应用场景

适用于重新计算比较费时不用重复数据计算的环境。所有 getter 和 setter 的 this 上下文自动地绑定为 Vue 实例。如果一个数据依赖于其他数据,那么把这个数据设计为 computed

watch

watcher 更像是一个 data 的数据监听回调,当依赖的 data 的数据变化,执行回调,在方法中会传入 newVal 和 oldVal。可以提供输入值无效,提供中间值 特场景。Vue 实例将会在实例化时调用 $watch(),遍历 watch 对象的每一个属性。如果你需要在某个数据变化时做一些事情,使用 watch。

<template>
  <div class="hello">
      {{fullName}}
      <button @click="setNameFun">click</button>
  </div>
</template>

<script>
export default {
    data() {
        return {
            firstName: '飞',
            lastName: "旋"
        }
    },
    props: {
      msg: String
    },
    methods: {
        setNameFun() {
            this.firstName = "大";
            this.lastName = "熊"
        }
    },
    computed: {
        fullName() {
            return this.firstName + ' ' + this.lastName
        }
    },
    watch: {
        firstName(newval,oldval) {
          console.log(newval)
          console.log(oldval)
        }
    }
}
</script>

总结:

1.如果一个数据依赖于其他数据,那么把这个数据设计为 computed 的

2.如果你需要在某个数据变化时做一些事情,使用 watch 来观察这个数据变化

data 为什么是个函数,而不是对象

  1. 根据实例对象 data 可以是函数,不会产生数据污染的情况
  2. 组件中的 data 写成一个函数,数据以函数返回值形式定义,这样每复用一次组件,就会返回一份新的 data,相似于给每一个组件实例建立一个私有的数据空间,让各个组件实例维护各自的数据。而单纯的写成对象形式,就使得全部组件实例共用了一份 data,就会形成一个变了全都会变的结果。

vue 的响应式原理

Vue2

大家都知道 Vue2 的响应式是基于Object.defineProperty的,那我就拿Object.defineProperty来举个例子

// 响应式函数
function reactive(obj, key, value) {
  Object.defineProperty(data, key, {
    get() {
      console.log(`访问了${key}属性`);
      return value;
    },
    set(val) {
      console.log(`将${key}由->${value}->设置成->${val}`);
      if (value !== val) {
        value = val;
      }
    }
  });
}

const data = {
  name: "林三心",
  age: 22
};
Object.keys(data).forEach(key => reactive(data, key, data[key]));
console.log(data.name);
// 访问了name属性
// 林三心
data.name = "sunshine_lin"; // 将name由->林三心->设置成->sunshine_lin
console.log(data.name);
// 访问了name属性
// sunshine_lin

通过上面的例子,我想大家都对Object.defineProperty有了一个了解,那问题来了?它到底有什么弊端呢?使得尤大大在 Vue3 中抛弃了它,咱们接着看:

// 接着上面代码

data.hobby = "打篮球";
console.log(data.hobby); // 打篮球
data.hobby = "打游戏";
console.log(data.hobby); // 打游戏

这下大家可以看出Object.defineProperty有什么弊端了吧?咱们可以看到,data 新增了hobby属性,进行访问和设值,但是都不会触发get和set,所以弊端就是:Object.defineProperty只对初始对象里的属性有监听作用,而对新增的属性无效。这也是为什么 Vue2 中对象新增属性的修改需要使用Vue.$set来设值的原因。

Vue3

从上面,咱们知道了Object.defineProperty的弊端,咱们接着讲 Vue3 中响应式原理的核心Proxy是怎么弥补这一缺陷的,老样子,咱们还是举例子(先粗略讲,具体参数下面会细讲):

const data = {
  name: "林三心",
  age: 22
};

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      console.log(`访问了${key}属性`);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      console.log(`将${key}由->${target[key]}->设置成->${value}`);
      Reflect.set(target, key, value, receiver);
    }
  };

  return new Proxy(target, handler);
}

const proxyData = reactive(data);

console.log(proxyData.name);
// 访问了name属性
// 林三心
proxyData.name = "sunshine_lin";
// 将name由->林三心->设置成->sunshine_lin
console.log(proxyData.name);
// 访问了name属性
// sunshine_lin

可以看到,其实效果与上面的Object.defineProperty没什么差别,那为什么尤大大要抛弃它,选择Proxy呢?注意了,最最最关键的来了,那就是对象新增属性,来看看效果吧:

proxyData.hobby = "打篮球";
console.log(proxyData.hobby);
// 访问了hobby属性
// 打篮球
proxyData.hobby = "打游戏";
// 将hobby由->打篮球->设置成->打游戏
console.log(proxyData.hobby);
// 访问了hobby属性
// 打游戏

截屏2021-08-26 下午8.48.43.png

Vue 的钩子函数[路由导航守卫、keep-alive、生命周期钩子]

Vue-Router 导航守卫:

有的时候,我们需要通过路由来进行一些操作,比如最常见的登录权限验证,当用户满足条件时,才让其进入导航,否则就取消跳转,并跳到登录页面让其登录。

为此我们有很多种方法可以植入路由的导航过程:全局的, 单个路由独享的, 或者组件级的

全局守卫

vue-router 全局有三个守卫:

  1. router.beforeEach 全局前置守卫 进入路由之前
  2. router.beforeResolve 全局解析守卫(2.5.0+) 在 beforeRouteEnter 调用之后调用
  3. router.afterEach 全局后置钩子 进入路由之后
// main.js 入口文件
import router from "./router"; // 引入路由
router.beforeEach((to, from, next) => {
  next();
});
router.beforeResolve((to, from, next) => {
  next();
});
router.afterEach((to, from) => {
  console.log("afterEach 全局后置钩子");
});

to,from,next 这三个参数:

to 和 from 是将要进入和将要离开的路由对象,路由对象指的是平时通过 this.$route 获取到的路由对象。

next:Function 这个参数是个函数,且必须调用,否则不能进入路由(页面空白)。

  • next() 进入该路由。
  • next(false): 取消进入路由,url 地址重置为 from 路由地址(也就是将要离开的路由地址)。
  • next 跳转新路由,当前的导航被中断,重新开始一个新的导航。
  我们可以这样跳转:next('path地址')或者next({path:''})或者next({name:''})
  且允许设置诸如 replace: true、name: 'home' 之类的选项
  以及你用在router-link或router.push的对象选项。

路由独享守卫

如果你不想全局配置守卫的话,你可以为某些路由单独配置守卫:

const router = new VueRouter({
  routes: [
    {
      path: "/foo",
      component: Foo,
      beforeEnter: (to, from, next) => {
        // 参数用法什么的都一样,调用顺序在全局前置守卫后面,所以不会被全局守卫覆盖
        // ...
      }
    }
  ]
});

路由组件内的守卫

  1. beforeRouteEnter 进入路由前
  2. beforeRouteUpdate (2.2) 路由复用同一个组件时
  3. beforeRouteLeave 离开当前路由时

文档中的介绍:

  beforeRouteEnter (to, from, next) {
    // 在路由独享守卫后调用 不!能!获取组件实例 `this`,组件实例还没被创建
  },
  beforeRouteUpdate (to, from, next) {
    // 在当前路由改变,但是该组件被复用时调用 可以访问组件实例 `this`
    // 举例来说,对于一个带有动态参数的路径 /foo/:id,在 /foo/1 和 /foo/2 之间跳转的时候,
    // 由于会渲染同样的 Foo 组件,因此组件实例会被复用。而这个钩子就会在这个情况下被调用。
  },
  beforeRouteLeave (to, from, next) {
    // 导航离开该组件的对应路由时调用,可以访问组件实例 `this`
  }

beforeRouteEnter 访问 this

因为钩子在组件实例还没被创建的时候调用,所以不能获取组件实例 this,可以通过传一个回调给next来访问组件实例 。

但是回调的执行时机在 mounted 后面,所以在我看来这里对 this 的访问意义不太大,可以放在created或者mounted里面。

    beforeRouteEnter (to, from, next) {
    console.log('在路由独享守卫后调用');
      next(vm => {
        // 通过 `vm` 访问组件实例`this` 执行回调的时机在mounted后面,
      })
    }

beforeRouteLeave

导航离开该组件的对应路由时调用,我们用它来禁止用户离开,比如还未保存草稿,或者在用户离开前,将setInterval销毁,防止离开之后,定时器还在调用

    beforeRouteLeave (to, from , next) {
      if (文章保存) {
        next(); // 允许离开或者可以跳到别的路由 上面讲过了
      } else {
        next(false); // 取消离开
      }
    }

关于钩子的一些知识

路由钩子函数的错误捕获

如果我们在全局守卫/路由独享守卫/组件路由守卫的钩子函数中有错误,可以这样捕获:

router.onError(callback => {
  // 2.4.0新增 并不常用,了解一下就可以了
  console.log(callback, "callback");
});

跳转死循环,页面永远空白

我了解到的,很多人会碰到这个问题,来看一下这段伪代码:

router.beforeEach((to, from, next) => {
  if (登录) {
    next();
  } else {
    next({ name: "login" });
  }
});

看逻辑貌似是对的,但是当我们跳转到login之后,因为此时还是未登录状态,所以会一直跳转到login然后死循环,页面一直是空白的,所以:我们需要把判断条件稍微改一下。

if (登录 || to.name === "login") {
  next();
} // 登录,或者将要前往login页面的时候,就允许进入路由
全局后置钩子的跳转

文档中提到因为 router.afterEach 不接受next函数所以也不会改变导航本身,意思就是只能当成一个钩子来使用,但是我自己在试的时候发现,我们可以通过这种形式来实现跳转:

// main.js 入口文件
import router from "./router"; // 引入路由
router.afterEach((to, from) => {
  if (未登录 && to.name !== "login") {
    router.push({ name: "login" }); // 跳转login
  }
});
完整的路由导航解析流程(不包括其他生命周期)
  1. 触发进入其他路由。
  2. 调用要离开路由的组件守卫beforeRouteLeave
  3. 调用局前置守卫:beforeEach
  4. 在重用的组件里调用 beforeRouteUpdate
  5. 调用路由独享守卫 beforeEnter
  6. 解析异步路由组件。
  7. 在将要进入的路由组件中调用beforeRouteEnter
  8. 调用全局解析守卫 beforeResolve
  9. 导航被确认。
  10. 调用全局后置钩子的 afterEach 钩子。
  11. 触发 DOM 更新(mounted)。
  12. 执行beforeRouteEnter 守卫中传给 next 的回调函数

你不知道的 keep-alive[我猜你不知道]

在开发 Vue 项目的时候,大部分组件是没必要多次渲染的,所以 Vue 提供了一个内置组件keep-alive缓存组件内部状态,避免重新渲染文档在这里

用法:

缓存动态组件:

<keep-alive>包裹动态组件时,会缓存不活动的组件实例,而不是销毁它们,此种方式并无太大的实用意义。

    <!-- 基本 -->
    <keep-alive>
      <component :is="view"></component>
    </keep-alive>

    <!-- 多个条件判断的子组件 -->
    <keep-alive>
      <comp-a v-if="a > 1"></comp-a>
      <comp-b v-else></comp-b>
    </keep-alive>

缓存路由组件:

使用keep-alive可以将所有路径匹配到的路由组件都缓存起来,包括路由组件里面的组件,keep-alive大多数使用场景就是这种。

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

生命周期钩子:

在被keep-alive包含的组件/路由中,会多出两个生命周期的钩子:activated 与 deactivated

activated 在组件第一次渲染时会被调用,之后在每次缓存组件被激活时调用

activated 调用时机:

第一次进入缓存路由/组件,在mounted后面,beforeRouteEnter守卫传给 next 的回调函数之前调用:

    beforeMount=> 如果你是从别的路由/组件进来(组件销毁destroyed/或离开缓存deactivated)=>
    mounted=> activated 进入缓存组件 => 执行 beforeRouteEnter回调

因为组件被缓存了,再次进入缓存路由/组件时,不会触发这些钩子

    // beforeCreate created beforeMount mounted 都不会触发。

所以之后的调用时机是:

    组件销毁destroyed/或离开缓存deactivated => activated 进入当前缓存组件
    => 执行 beforeRouteEnter回调
    // 组件缓存或销毁,嵌套组件的销毁和缓存也在这里触发

deactivated:组件被停用(离开路由)时调用

使用了 keep-alive 就不会调用 beforeDestroy(组件销毁前钩子)和 destroyed(组件销毁),因为组件没被销毁,被缓存起来了

这个钩子可以看作beforeDestroy的替代,如果你缓存了组件,要在组件销毁的的时候做一些事情,你可以放在这个钩子里。

如果你离开了路由,会依次触发:

    组件内的离开当前路由钩子beforeRouteLeave =>  路由前置守卫 beforeEach =>
    全局后置钩子afterEach => deactivated 离开缓存组件 => activated 进入缓存组件(如果你进入的也是缓存路由)
    // 如果离开的组件没有缓存的话 beforeDestroy会替换deactivated
    // 如果进入的路由也没有缓存的话  全局后置钩子afterEach=>销毁 destroyed=> beforeCreate等

那么,如果我只是想缓存其中几个路由/组件,那该怎么做?

缓存你想缓存的路由:

Vue2.1.0 之前
  1. 配置一下路由元信息
  2. 创建两个keep-alive标签
  3. 使用v-if通过路由元信息判断缓存哪些路由。
 <keep-alive>
     <router-view v-if="$route.meta.keepAlive">
         <!--这里是会被缓存的路由-->
     </router-view>
 </keep-alive>
 <router-view v-if="!$route.meta.keepAlive">
     <!--因为用的是v-if 所以下面还要创建一个未缓存的路由视图出口-->
 </router-view>
 //router配置
 new Router({
   routes: [
     {
       path: '/',
       name: 'home',
       component: Home,
       meta: {
         keepAlive: true // 需要被缓存
       }
     },
     {
       path: '/:id',
       name: 'edit',
       component: Edit,
       meta: {
         keepAlive: false // 不需要被缓存
       }
     }
   ]
 });
Vue2.1.0 版本之后

使用路由元信息的方式,要多创建一个router-view标签,并且每个路由都要配置一个元信息,是可以实现我们想要的效果,但是过于繁琐了点。

幸运的是在 Vue2.1.0 之后,Vue 新增了两个属性配合keep-alive来有条件地缓存 路由/组件。

新增属性:

  • include:匹配的 路由/组件 会被缓存
  • exclude:匹配的 路由/组件 不会被缓存

includeexclude支持三种方式来有条件的缓存路由:采用逗号分隔的字符串形式,正则形式,数组形式。

正则和数组形式,必须采用v-bind形式来使用。

缓存组件的使用方式

    <!-- 逗号分隔字符串 -->
    <keep-alive include="a,b">
      <component :is="view"></component>
    </keep-alive>

    <!-- 正则表达式 (使用 `v-bind`) -->
    <keep-alive :include="/a|b/">
      <component :is="view"></component>
    </keep-alive>

    <!-- 数组 (使用 `v-bind`) -->
    <keep-alive :include="['a', 'b']">
      <component :is="view"></component>
    </keep-alive>

但更多场景中,我们会使用 keep-alive 来缓存路由

<keep-alive include="a">
  <router-view></router-view>
</keep-alive>

匹配规则:

  1. 首先匹配组件的 name 选项,如果name选项不可用。
  2. 则匹配它的局部注册名称。 (父组件 components 选项的键值)
  3. 匿名组件,不可匹配。(比如路由组件没有name选项,并且没有注册的组件名。)
  4. 只能匹配当前被包裹的组件,不能匹配更下面嵌套的子组件。(比如用在路由上,只能匹配路由组件的name选项,不能匹配路由组件里面的嵌套组件的name选项。)
  5. 文档:<keep-alive>不会在函数式组件中正常工作,因为它们没有缓存实例。
  6. exclude 的优先级大于 include
  <keep-alive include="a,b" exclude="a">
    <!--只有a不被缓存-->
    <router-view></router-view>
  </keep-alive>

当组件被 exclude 匹配,该组件将不会被缓存,不会调用 activated 和 deactivated

组件生命周期钩子

关于组件的生命周期,是时候放出这张图片了:

img

这张图片已经讲得很清楚了,很多人这部分也很清楚了,大部分生命周期并不会用到,这里提一下几点:

  1. ajax 请求最好放在 created 里面,因为此时已经可以访问this了,请求到数据就可以直接放在data里面。
  2. 关于 dom 的操作要放在 mounted 里面,在mounted前面访问 dom 会是undefined
  3. 每次进入/离开组件都要做一些事情,用什么钩子:
  • 不缓存:

进入的时候可以用created和mounted钩子,离开的时候用beforeDestory和destroyed钩子,beforeDestory可以访问this,destroyed不可以访问this。

  • 缓存了组件:

缓存了组件之后,再次进入组件不会触发beforeCreate、created 、beforeMount、 mounted,如果你想每次进入组件都做一些事情的话,你可以放在activated进入缓存组件的钩子中。

同理:离开缓存组件的时候,beforeDestroy和destroyed并不会触发,可以使用deactivated离开缓存组件的钩子来代替。

触发钩子的完整顺序:

将路由导航、keep-alive、和组件生命周期钩子结合起来的,触发顺序,假设是从 a 组件离开,第一次进入 b 组件:

  1. beforeRouteLeave:路由组件的组件离开路由前钩子,可取消路由离开。
  2. beforeEach: 路由全局前置守卫,可用于登录验证、全局路由 loading 等。
  3. beforeEnter: 路由独享守卫
  4. beforeRouteEnter: 路由组件的组件进入路由前钩子。
  5. beforeResolve:路由全局解析守卫
  6. afterEach:路由全局后置钩子
  7. beforeCreate:组件生命周期,不能访问this
  8. created:组件生命周期,可以访问this,不能访问 dom。
  9. beforeMount:组件生命周期
  10. deactivated: 离开缓存组件 a,或者触发 a 的beforeDestroydestroyed组件销毁钩子。
  11. mounted:访问/操作 dom。
  12. activated:进入缓存组件,进入 a 的嵌套子组件(如果有的话)。
  13. 执行 beforeRouteEnter 回调函数 next。

nextTick 原理

为什么会有 nextTick 这个东西的存在?

因为 vue 采用的异步更新策略,当监听到数据发生变化的时候不会立即去更新 DOM, 而是开启一个任务队列,并缓存在同一事件循环中发生的所有数据变更;这种做法带来的好处就是可以将多次数据更新合并成一次,减少操作 DOM 的次数,如果不采用这种方法,假设数据改变 100 次就要去更新 100 次 DOM,而频繁的 DOM 更新是很耗性能的;

nextTick 的作用?

nextTick 接收一个回调函数作为参数,并将这个回调函数延迟到 DOM 更新后才执行;
使用场景:想要操作 基于最新数据生成的 DOM 时,就将这个操作放在 nextTick 的回调中;

nextTick 实现原理

将传入的回调函数包装成异步任务,异步任务又分微任务和宏任务,为了尽快执行所以优先选择微任务;

nextTick 提供了四种异步方法 Promise.then、MutationObserver、setImmediate、setTimeout(fn,0)

vue 模板(template)里为什么不能使用多个头结点?

首先 template 标签的特性是:隐藏性,任意性,无效性。意思就是我们在实际的.vue 文件中,template 只是一个标志,他不被渲染,可以写在任何地方,我们也无法获取到它。对于一个.vue 文件来说,template 中的 HTML 元素会作为虚拟 dom 被渲染,而 template 中的最外层 div 就是 vue 处理和识别的直接 dom,如果有两个 div,vue 就不知道该识别哪一个。

vue 挂载和卸载父子组件生命周期钩子执行顺序

父子组件生命周期执行顺序

加载渲染过程

父beforeCreate->父created->父beforeMount->子beforeCreate->子created->子beforeMount->子mounted->父mounted

子组件挂载完成后,父组件还未挂载。所以组件数据回显的时候,在父组件 mounted 中获取 api 的数据,子组件的 mounted 是拿不到的。

仔细看看父子组件生命周期钩子的执行顺序,会发现 created 这个钩子是按照从外内顺序执行,所以父子组件传递接口数据的解决方案是:

  • 在 created 中发起请求获取数据,依次在子组件的 created 或者 mounted 中会接收到这个数据。

更新过程

父beforeUpdate->子beforeUpdate->子updated->父updated

销毁过程

父beforeDestroy->子beforeDestroy->子destroyed->父destroyed

补充单一组件钩子执行顺序

activated, deactivated 是组件 keep-alive 时独有的钩子

  1. beforeCreate
  2. created
  3. beforeMount
  4. mounted
  5. beforeUpdate
  6. updated
  7. activated
  8. deactivated
  9. beforeDestroy
  10. destroyed
  11. errorCaptured

总结

  • beforeCreate 执行时:data 和 el 均未初始化,值为 undefined
  • created 执行时:Vue 实例观察的数据对象 data 已经配置好,已经可以得到 data 的值,但 Vue 实例使用的根 DOM 元素 el 还未初始化
  • beforeMount 执行时:data 和 el 均已经初始化,但此时 el 并没有渲染进数据,el 的值为“虚拟”的元素节点
  • mounted 执行时:此时 el 已经渲染完成并挂载到实例上
  • beforeUpdate 和 updated 触发时,el 中的数据都已经渲染完成,但只有 updated 钩子被调用时候,组件 dom 才被更新。
  • 在 created 钩子中可以对 data 数据进行操作,这个时候可以进行数据请求将返回的数据赋给 data
  • 在 mounted 钩子对挂载的 dom 进行操作,此时,DOM 已经被渲染到页面上。
  • 虽然 updated 函数会在数据变化时被触发,但却不能准确的判断是那个属性值被改变,所以在实际情况中用computedwatch函数来监听属性的变化,并做一些其他的操作。
  • 所有的生命周期钩子自动绑定 this 上下文到实例中,所以不能使用箭头函数来定义一个生命周期方法 (例如 created: () => this.fetchTodos()),会导致 this 指向父级
  • 在使用 vue-router 时有时需要使用来缓存组件状态,这个时候 created 钩子就不会被重复调用了,如果我们的子组件需要在每次加载或切换状态的时候进行某些操作,可以使用 activated 钩子触发。
  • 父子组件的钩子并不会等待请求返回,请求是异步的,VUE 设计也不能因为请求没有响应而不执行后面的钩子。所以,我们必须通过 v-if 来控制子组件钩子的执行时机

注意 在父组件传递接口的数据给子组件时,一定要在子组件标签上加上 v-if="传递的接口数据"

在父组件的 created 中发请求获取数据,通过 prop 传递给子组件。子组件在 created 或者 mounted 中拿父组件传递过来的数据 这样处理是有问题的。

在父组件调用接口传递数据给子组件时,接口响应显然是异步的。这会导致无论你在父组件哪个钩子发请求,在子组件哪个钩子接收数据。都是取不到的。当子组件的 mounted 都执行完之后,此时可能父组件的请求才返回数据。会导致,从父组件传递给子组件的数据是 undefined。

解决方法 1:

在渲染子组件的时候加上一个条件,data1 是父组件调用接口返回的数据。当有数据的时候在去渲染子组件。这样就会形成天然的阻塞。在父组件的 created 中的请求返回数据后,才会执行子组件的 created,mounted。最后执行父组件的 mounted。

<div class="test">
    <children v-if="data1" :data="data1" ></children>
</div>

解决方法 2:

在子组件中 watch 监听,父组件获取到值,这个值就会变化,自然是可以监听到的

watch:{
    data:{
      deep:true,
      handler:function(newVal,oldVal) {
        this.$nextTick(() => {
          this.data = newVal
          this.data = newVal.url ? newVal.url : ''
        })
      }
    },
}

从父组件点击调用接口并显示子组件,子组件拿到数据并监听在 watch 中调用方法并显示

以下为子组件,data1 是从子组件传递过来的数据。在 created,mounted 中都拿不到父组件调用接口返回的 data1。 只能 watch 监听 data1。并调用方法渲染子组件。

props:['data1'],
watch:{
    data1:{
      deep:true,
      handler:function(newVal,oldVal) {
        this.$nextTick(() => {
          this.data1 = newVal
          this.showData1(this.data1)
        })
      }
    },
}

vue 的优化方案

一、代码层面的优化

1.1、v-if 和 v-show 区分使用场景

v-if 是 真正 的条件渲染,因为它会确保在切换过程中条件块内的事件监听器和子组件适当地被销毁和重建;也是惰性的:如果在初始渲染时条件为假,则什么也不做——直到条件第一次变为真时,才会开始渲染条件块。

v-show 就简单得多, 不管初始条件是什么,元素总是会被渲染,并且只是简单地基于 CSS 的 display 属性进行切换。

所以,v-if 适用于在运行时很少改变条件,不需要频繁切换条件的场景;v-show 则适用于需要非常频繁切换条件的场景。

1.2、computed 和 watch 区分使用场景

computed:  是计算属性,依赖其它属性值,并且 computed 的值有缓存,只有它依赖的属性值发生改变,下一次获取 computed 的值时才会重新计算 computed 的值;

watch:  更多的是「观察」的作用,类似于某些数据的监听回调 ,每当监听的数据变化时都会执行回调进行后续操作;

运用场景:

  • 当我们需要进行数值计算,并且依赖于其它数据时,应该使用 computed,因为可以利用 computed 的缓存特性,避免每次获取值时,都要重新计算;
  • 当我们需要在数据变化时执行异步或开销较大的操作时,应该使用 watch,使用 watch 选项允许我们执行异步操作 ( 访问一个 API ),限制我们执行该操作的频率,并在我们得到最终结果前,设置中间状态。这些都是计算属性无法做到的。

1.3、v-for 遍历必须为 item 添加 key,且避免同时使用 v-if

(1)v-for 遍历必须为 item 添加 key

在列表数据进行遍历渲染时,需要为每一项 item 设置唯一 key 值,方便 Vue.js 内部机制精准找到该条列表数据。当 state 更新时,新的状态值和旧的状态值对比,较快地定位到 diff 。

(2)v-for 遍历避免同时使用 v-if

v-for 比 v-if 优先级高,如果每一次都需要遍历整个数组,将会影响速度,尤其是当之需要渲染很小一部分的时候,必要情况下应该替换成 computed 属性。

推荐:

<ul>
  <li
    v-for="user in activeUsers"
    :key="user.id">
    {{ user.name }}
  </li>
</ul>
computed: {
  activeUsers: function () {
    return this.users.filter(function (user) {
	 return user.isActive
    })
  }
}

不推荐:

<ul>
  <li
    v-for="user in users"
    v-if="user.isActive"
    :key="user.id">
    {{ user.name }}
  </li>
</ul>

1.4、长列表性能优化

Vue 会通过 Object.defineProperty 对数据进行劫持,来实现视图响应数据的变化,然而有些时候我们的组件就是纯粹的数据展示,不会有任何改变,我们就不需要 Vue 来劫持我们的数据,在大量数据展示的情况下,这能够很明显的减少组件初始化的时间,那如何禁止 Vue 劫持我们的数据呢?可以通过 Object.freeze 方法来冻结一个对象,一旦被冻结的对象就再也不能被修改了。

export default {
  data: () => ({
    users: {}
  }),
  async created() {
    const users = await axios.get("/api/users");
    this.users = Object.freeze(users);
  }
};

1.5、事件的销毁

Vue 组件销毁时,会自动清理它与其它实例的连接,解绑它的全部指令及事件监听器,但是仅限于组件本身的事件。 如果在 js 内使用 addEventListene 等方式是不会自动销毁的,我们需要在组件销毁时手动移除这些事件的监听,以免造成内存泄露,如:

created() {
  addEventListener('click', this.click, false)
},
beforeDestroy() {
  removeEventListener('click', this.click, false)
}

1.6、图片资源懒加载

对于图片过多的页面,为了加速页面加载速度,所以很多时候我们需要将页面内未出现在可视区域内的图片先不做加载, 等到滚动到可视区域后再去加载。这样对于页面加载性能上会有很大的提升,也提高了用户体验。我们在项目中使用 Vue 的 vue-lazyload 插件:

(1)安装插件

npm install vue-lazyload --save-dev

(2)在入口文件 man.js 中引入并使用

import VueLazyload from "vue-lazyload";

然后再 vue 中直接使用

Vue.use(VueLazyload);

或者添加自定义选项

Vue.use(VueLazyload, {
  preLoad: 1.3,
  error: "dist/error.png",
  loading: "dist/loading.gif",
  attempt: 1
});

(3)在 vue 文件中将 img 标签的 src 属性直接改为 v-lazy ,从而将图片显示方式更改为懒加载显示:

<img v-lazy="/static/img/1.png">

1.7、路由懒加载

Vue 是单页面应用,可能会有很多的路由引入 ,这样使用 webpcak 打包后的文件很大,当进入首页时,加载的资源过多,页面会出现白屏的情况,不利于用户体验。如果我们能把不同路由对应的组件分割成不同的代码块,然后当路由被访问的时候才加载对应的组件,这样就更加高效了。这样会大大提高首屏显示的速度,但是可能其他的页面的速度就会降下来。

路由懒加载:

const Foo = () => import("./Foo.vue");
const router = new VueRouter({
  routes: [{ path: "/foo", component: Foo }]
});

1.8、第三方插件的按需引入

我们在项目中经常会需要引入第三方插件,如果我们直接引入整个插件,会导致项目的体积太大,我们可以借助 babel-plugin-component ,然后可以只引入需要的组件,以达到减小项目体积的目的。以下为项目中引入 element-ui 组件库为例:

(1)首先,安装 babel-plugin-component :

npm install babel-plugin-component -D

(2)然后,将 .babelrc 修改为:

{
  "presets": [["es2015", { "modules": false }]],
  "plugins": [
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "theme-chalk"
      }
    ]
  ]
}

(3)在 main.js 中引入部分组件:

import Vue from "vue";
import { Button, Select } from "element-ui";

Vue.use(Button);
Vue.use(Select);

二、Webpack 层面的优化

2.1、Webpack 对图片进行压缩

在 vue 项目中除了可以在 webpack.base.conf.js 中 url-loader 中设置 limit 大小来对图片处理,对小于 limit 的图片转化为 base64 格式,其余的不做操作。所以对有些较大的图片资源,在请求资源的时候,加载会很慢,我们可以用 image-webpack-loader来压缩图片:

(1)首先,安装 image-webpack-loader :

npm install image-webpack-loader --save-dev

(2)然后,在 webpack.base.conf.js 中进行配置:

{
  test: /.(png|jpe?g|gif|svg)(?.*)?$/,
  use:[
    {
    loader: 'url-loader',
    options: {
      limit: 10000,
      name: utils.assetsPath('img/[name].[hash:7].[ext]')
      }
    },
    {
      loader: 'image-webpack-loader',
      options: {
        bypassOnDebug: true,
      }
    }
  ]
}

2.2、减少 ES6 转为 ES5 的冗余代码

Babel 插件会在将 ES6 代码转换成 ES5 代码时会注入一些辅助函数,例如下面的 ES6 代码:

class HelloWebpack extends Component{...}

这段代码再被转换成能正常运行的 ES5 代码时需要以下两个辅助函数:

babel - runtime / helpers / createClass; // 用于实现 class 语法
babel - runtime / helpers / inherits; // 用于实现 extends 语法

在默认情况下, Babel 会在每个输出文件中内嵌这些依赖的辅助函数代码,如果多个源代码文件都依赖这些辅助函数,那么这些辅助函数的代码将会出现很多次,造成代码冗余。为了不让这些辅助函数的代码重复出现,可以在依赖它们时通过 require('babel-runtime/helpers/createClass') 的方式导入,这样就能做到只让它们出现一次。babel-plugin-transform-runtime 插件就是用来实现这个作用的,将相关辅助函数进行替换成导入语句,从而减小 babel 编译出来的代码的文件大小。

(1)首先,安装 babel-plugin-transform-runtime :

npm install babel-plugin-transform-runtime --save-dev

(2)然后,修改 .babelrc 配置文件为:

"plugins": [
    "transform-runtime"
]

2.3、构建结果输出分析

Webpack 输出的代码可读性非常差而且文件非常大,让我们非常头疼。为了更简单、直观地分析输出结果,社区中出现了许多可视化分析工具。这些工具以图形的方式将结果更直观地展示出来,让我们快速了解问题所在。接下来讲解我们在 Vue 项目中用到的分析工具:webpack-bundle-analyzer 。

我们在项目中 webpack.prod.conf.js 进行配置:

if (config.build.bundleAnalyzerReport) {
  var BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
    .BundleAnalyzerPlugin;
  webpackConfig.plugins.push(new BundleAnalyzerPlugin());
}

执行 $ npm run build --report 后生成分析报告如下:

1.png

三、基础的 Web 技术优化

3.1、开启 gzip 压缩

gzip 是 GNUzip 的缩写,最早用于 UNIX 系统的文件压缩。HTTP 协议上的 gzip 编码是一种用来改进 web 应用程序性能的技术,web 服务器和客户端(浏览器)必须共同支持 gzip。目前主流的浏览器,Chrome,firefox,IE 等都支持该协议。常见的服务器如 Apache,Nginx,IIS 同样支持,gzip 压缩效率非常高,通常可以达到 70% 的压缩率,也就是说,如果你的网页有 30K,压缩之后就变成了 9K 左右

以下我们以服务端使用我们熟悉的 express 为例,开启 gzip 非常简单,相关步骤如下:

  • 安装:
npm install compression --save
  • 添加代码逻辑:
var compression = require("compression");
var app = express();
app.use(compression());
  • 重启服务,观察网络面板里面的 response header,如果看到如下红圈里的字段则表明 gzip 开启成功

1.png

3.2、浏览器缓存

为了提高用户加载页面的速度,对静态资源进行缓存是非常必要的,根据是否需要重新向服务器发起请求来分类,将 HTTP 缓存规则分为两大类(强制缓存,对比缓存),如果对缓存机制还不是了解很清楚的,可以参考作者写的关于 HTTP 缓存的文章《深入理解 HTTP 缓存机制及原理》,这里不再赘述。

Webpack

Webpack 优化

1. 构建打点

要做优化,我们肯定得知道要从哪里做优化对吧。那在我们的一次构建流程中,是什么拉低了我们的构建效率呢?我们有什么方法可以将它们测量出来呢?

要解决这两个问题,我们需要用到一款工具:speed-measure-webpack-plugin,它能够测量出在你的构建过程中,每一个 Loader 和 Plugin 的执行时长,官方给出的效果图是下面这样:

img

而它的使用方法也同样简单,如下方示例代码所示,只需要在你导出 Webpack 配置时,为你的原始配置包一层 smp.wrap 就可以了,接下来执行构建,你就能在 console 面板看到如它 demo 所示的各类型的模块的执行时长。

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");

const smp = new SpeedMeasurePlugin();

module.exports = smp.wrap(YourWebpackConfig);

小贴士:由于 speed-measure-webpack-plugin 对于 webpack 的升级还不够完善,目前(就笔者书写本文的时候)还存在一个 BUG,就是无法与你自己编写的挂载在 html-webpack-plugin 提供的 hooks 上的自定义 Plugin (add-asset-html-webpack-plugin 就是此类)共存,因此,在你需要打点之前,如果存在这类 Plugin,请先移除,否则会产生如我这篇 issue 所提到的问题。

可以断言的是,大部分的执行时长应该都是消耗在编译 JS、CSS 的 Loader 以及对这两类代码执行压缩操作的 Plugin 上,如果你的执行结果和我所说的一样,请不要吝啬你的手指,为我的文章点个赞吧 😁。

为什么会这样呢?因为在对我们的代码进行编译或者压缩的过程中,都需要执行这样的一个流程:编译器(这里可以指 webpack)需要将我们写下的字符串代码转化成 AST(语法分析树),就是如下图所示的一个树形对象:

img

显而易见,编译器肯定不能用正则去显式替换字符串来实现这样一个复杂的编译流程,而编译器需要做的就是遍历这棵树,找到正确的节点并替换成编译后的值,过程就像下图这样:

img

这部分知识我在之前的一篇文章 Webpack 揭秘——走向高阶前端的必经之路 中曾详细介绍过,如果你有兴趣了解,可以翻阅噢~

大家一定还记得曾经在学习《数据结构与算法》或者是面试时候,被树形结构的各种算法虐待千百遍的日子吧,你一定也还记得深度优先遍历和广度优先遍历的实现思路对吧。可想而知,之所以构建时长会集中消耗在代码的编译或压缩过程中,正是因为它们需要去遍历树以替换字符或者说转换语法,因此都需要经历"转化 AST -> 遍历树 -> 转化回代码"这样一个过程,你说,它的时长能不长嘛。

2. 优化策略

既然我们已经找到了拉低我们构建速率的“罪魁祸首”,接下来我们就点对点逐个击破了!这里,我就直接开门见山了,既然我们都知道构建耗时的原因,自然就能得出针对性的方略。所以我们会从四个大方向入手:缓存、多核、抽离以及拆分,你现在看到这四个词或许脑海里又能浮现出了一些熟悉的思路,这很棒,这样的话你对我接下来将介绍的手段一定就能更快理解。

2.1. 缓存

我们每次的项目变更,肯定不会把所有文件都重写一遍,但是每次执行构建却会把所有的文件都重复编译一遍,这样的重复工作是否可以被缓存下来呢,就像浏览器加载资源一样?答案肯定是可以的,其实大部分 Loader 都提供了 cache 配置项,比如在 babel-loader 中,可以通过设置 cacheDirectory 来开启缓存,这样,babel-loader 就会将每次的编译结果写进硬盘文件(默认是在项目根目录下的node_modules/.cache/babel-loader目录内,当然你也可以自定义)。

但如果 loader 不支持缓存呢?我们也有方法。接下来介绍一款神器:cache-loader ,它所做的事情很简单,就是 babel-loader 开启 cache 后做的事情,将 loader 的编译结果写入硬盘缓存,再次构建如果文件没有发生变化则会直接拉取缓存。而使用它的方法很简单,正如官方 demo 所示,只需要把它卸载在代价高昂的 loader 的最前面即可:

module.exports = {
  module: {
    rules: [
      {
        test: /.ext$/,
        use: ["cache-loader", ...loaders],
        include: path.resolve("src")
      }
    ]
  }
};

小贴士cache-loader 默认将缓存存放的路径是项目根目录下的 .cache-loader 目录内,我们习惯将它配置到项目根目录下的 node_modules/.cache 目录下,与 babel-loader 等其他 Plugin 或者 Loader 缓存存放在一块

同理,同样对于构建流程造成效率瓶颈的代码压缩阶段,也可以通过缓存解决大部分问题,以 uglifyjs-webpack-plugin 这款对于我们最常用的 Plugin 为例,它就提供了如下配置:

module.exports = {
  optimization: {
    minimizer: [
      new UglifyJsPlugin({
        cache: true,
        parallel: true
      })
    ]
  }
};

我们可以通过开启 cache 配置开启我们的缓存功能,也可以通过开启 parallel 开启多核编译功能,这也是我们下一章节马上就会讲到的知识。而另一款我们比较常用于压缩 CSS 的插件—— optimize-css-assets-webpack-plugin,目前我还未找到有对缓存和多核编译的相关支持,如果读者在这块领域有自己的沉淀,欢迎在评论区提出批正。

小贴士:目前而言笔者暂不建议将缓存逻辑集成到 CI 流程中,因为目前还仍会出现更新依赖后依旧命中缓存的情况,这显然是个 BUG,在开发机上我们可以手动删除缓存解决问题,但在编译机上过程就要麻烦的多。为了保证每次 CI 结果的纯净度,这里建议在 CI 过程中还是不要开启缓存功能。

2.2. 多核

这里的优化手段大家肯定已经想到了,自然是我们的 happypack。这似乎已经是一个老生常谈的话题了,从 3 时代开始,happypack 就已经成为了众多 webpack 工程项目接入多核编译的不二选择,几乎所有的人,在提到 webpack 效率优化时,怎么样也会说出 happypack 这个词语。所以,在前端社区繁荣的今天,从 happypack 出现的那时候起,就有许多优秀的质量文如雨后春笋般层出不穷。所以今天在这里,对于 happypack 我就不做过多细节上的介绍了,想必大家对它也再熟悉不过了,我就带着大家简单回顾一下它的使用方法吧。

const HappyPack = require("happypack");
const os = require("os");
// 开辟一个线程池
// 拿到系统CPU的最大核数,happypack 将编译工作灌满所有线程
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length });

module.exports = {
  module: {
    rules: [
      {
        test: /.(js|jsx)$/,
        exclude: /node_modules/,
        use: "happypack/loader?id=js"
      }
    ]
  },
  plugins: [
    new HappyPack({
      id: "js",
      threadPool: happyThreadPool,
      loaders: [
        {
          loader: "babel-loader"
        }
      ]
    })
  ]
};

所以配置起来逻辑其实很简单,就是用 happypack 提供的 Plugin 为你的 Loaders 做一层包装就好了,向外暴露一个 id ,而在你的 module.rules 里,就不需要写 loader 了,直接引用这个 id 即可,所以赶紧用 happypack 对那些你测出来的代价比较昂贵的 loaders 们做一层多核编译的包装吧。而对于一些编译代价昂贵的 webpack 插件,一般都会提供 parallel 这样的配置项供你开启多核编译,因此,只要你善于去它的官网发现,一定会有意想不到的收获噢~

所以最后,在 production 模式下的 CSS Rule 配置就变成了下面这样:

module.exports = {
    ...,
    module: {
        rules: [
            ...,
            {
                test: /.css$/
                exclude: /node_modules/,
                use: [
                    _mode === 'development' ? 'style-loader' : MiniCssExtractPlugin.loader,
                    'happypack/loader?id=css'
                ]
            }
        ]
    },
    plugins: [
        new HappyPack({
          id: 'css',
          threadPool: happyThreadPool,
          loaders: [
            'cache-loader',
            'css-loader',
            'postcss-loader',
          ],
        }),
    ],
}

2.3. 抽离

对于一些不常变更的静态依赖,比如我们项目中常见的 React 全家桶,亦或是用到的一些工具库,比如 lodash 等等,我们不希望这些依赖被集成进每一次构建逻辑中,因为它们真的太少时候会被变更了,所以每次的构建的输入输出都应该是相同的。因此,我们会设法将这些静态依赖从每一次的构建逻辑中抽离出去,以提升我们每次构建的构建效率。常见的方案有两种,一种是使用 webpack-dll-plugin 的方式,在首次构建时候就将这些静态依赖单独打包,后续只需要引用这个早就被打好的静态依赖包即可,有点类似“预编译”的概念;另一种,也是业内常见的 Externals的方式,我们将这些不需要打包的静态资源从构建逻辑中剔除出去,而使用 CDN 的方式,去引用它们。

3. 提升体验

这里主要是介绍几款 webpack 插件来帮助大家提升构建体验,虽然说它们在提升构建效率上对你没有什么太大的帮助,但能让你在等待构建完成的过程中更加舒服。

3.1. progress-bar-webpack-plugin

这是一款能为你展示构建进度的 Plugin,它的使用方法和普通 Plugin 一样,也不需要传入什么配置。下图就是你加上它之后,在你的终端面板上的效果,在你的终端底部,将会有一个构建的进度条,可以让你清晰的看见构建的执行进度:

img

3.2. webpack-build-notifier

这是一款在你构建完成时,能够像微信、Lark 这样的 APP 弹出消息的方式,提示你构建已经完成了。也就是说,当你启动构建时,就可以隐藏控制台面板,专心去做其他事情啦,到“点”了自然会来叫你,它的效果就是下面这样,同时还有提示音噢~

img

3.3. webpack-dashboard

当然,如果你对 webpack 原始的构建输出不满意的话,也可以使用这样一款 Plugin 来优化你的输出界面,它的效果就是下面这样,这里我就直接上官图啦:

img

4. 总结

综上所述,其实本质上,我们对与 webpack 构建效率的优化措施也就两个大方向:缓存和多核。缓存是为了让二次构建时,不需要再去做重复的工作;而多核,更是充分利用了硬件本身的优势(我相信现如今大家的电脑肯定都是双核以上了吧,我自己这台公司发的低配 MAC 都有双核),让我们的复杂工作都能充分利用我们的 CPU。而将这两个方向化为实践的主角,也是我们前面介绍过的两大王牌,就是:cache-loader 和 happypack,所以你只要知道它并用好它,那你就能做到更好的构建优化实践。所以,别光看看,快拿着你的项目动手实践下,让你优化后的团队项目在你的 leader 面前眼前一亮吧!

但是,大家一定要记着,这些东西并不是说用了效果就一定会是最好的,我们一定要切记把它们用在刀刃上,就是那些在第一阶段我们通过打点得出的构建代价高昂的 Loader 或者 Plugin,因为我们知道,像本地缓存就需要读写硬盘文件,系统 IO 需要时间,像启动多核也需要 IPC 通信时间,也就是说,如果本来构建时长就不长的模块,有可能因为添加了缓存或者多核会有得不偿失的结果,因此这些优化手段也需要合理的分配和使用。

如今,webpack 自身也在不断的迭代与优化,它早就已经不是两三年前那个一直让我们吐槽构建慢、包袱重的构建新星了,之所以会成为主流,也正是因为 webpack 团队已经在效率及体验上为我们做出很多了,而我们需要做的,已经很少了,而且我坚信,将来还会更少。

说说 webpack 编译打包的流程

Webpack 的运行流程是一个串行的过程,从启动到结束依次执行以下流程:

  1. 初始化:启动构建,读取与合并配置参数,加载 Plugin,实例化 Compiler。
  2. 编译:从 Entry 发出,针对每个 Module 串行调用对应的 Loader 去翻译文件内容,再找到该 Module 依赖的 Module,递归地进行编译处理。
  3. 输出:对编译后的 Module 组合成 Chunk,把 Chunk 转换成文件,输出到文件系统。

如果只执行一次构建,以上阶段将会按照顺序各执行一次。但在开启监听模式下,流程将变为如下:

webpack-flow

初始化阶段

初始化阶段大致分为:

  • 合并shell配置文件文件的参数并且实例化 Complier 对象
  • 加载插件
  • 处理入口
事件名解释
初始化参数从配置文件和 Shell 语句中读取与合并参数,得出最终的参数。 这个过程中还会执行配置文件中的插件实例化语句 new Plugin()。
实例化 Compiler用上一步得到的参数初始化 Compiler 实例,Compiler 负责文件监听和启动编译。Compiler 实例中包含了完整的 Webpack 配置,全局只有一个 Compiler 实例。
加载插件依次调用插件的 apply 方法,让插件可以监听后续的所有事件节点。同时给插件传入 compiler 实例的引用,以方便插件通过 compiler 调用 Webpack 提供的 API
environment开始应用 Node.js 风格的文件系统到 compiler 对象,以方便后续的文件寻找和读取。
entry-option读取配置的 Entrys,为每个 Entry 实例化一个对应的 EntryPlugin,为后面该 Entry 的递归解析工作做准备。
after-plugins调用完所有内置的和配置的插件的 apply 方法。
after-resolvers根据配置初始化完 resolverresolver 负责在文件系统中寻找指定路径的文件。

编译阶段

事件名解释
before-run清除缓存
run启动一次新的编译。
watch-run和 run 类似,区别在于它是在监听模式下启动的编译,在这个事件中可以获取到是哪些文件发生了变化导致重新启动一次新的编译。
compile该事件是为了告诉插件一次新的编译将要启动,同时会给插件带上 compiler 对象。
compilation当 Webpack 以开发模式运行时,每当检测到文件变化,一次新的 Compilation 将被创建。一个 Compilation 对象包含了当前的模块资源、编译生成资源、变化的文件等。Compilation 对象也提供了很多事件回调供插件做扩展。
make一个新的 Compilation 创建完毕,即将从 Entry 开始读取文件,根据文件类型和配置的 Loader 对文件进行编译,编译完后再找出该文件依赖的文件,递归的编译和解析。
after-compile一次 Compilation 执行完成。这里会根据编译结果 合并出我们最终生成的文件名和文件内容。
invalid当遇到文件不存在、文件编译错误等异常时会触发该事件,该事件不会导致 Webpack 退出。

这里主要最重要的就是compilation过程,compilation 实际上就是调用相应的 loader 处理文件生成 chunks并对这些 chunks 做优化的过程。几个关键的事件(Compilation对象this.hooks中):

事件名解释
build-module使用对应的 Loader 去转换一个模块。
normal-module-loader在用 Loader 对一个模块转换完后,使用 acorn 解析转换后的内容,输出对应的抽象语法树(AST),以方便 Webpack 后面对代码的分析。
program从配置的入口模块开始,分析其 AST,当遇到 require 等导入其它模块语句时,便将其加入到依赖的模块列表,同时对新找出的依赖模块递归分析,最终搞清所有模块的依赖关系
seal所有模块及其依赖的模块都通过 Loader 转换完成后,根据依赖关系开始生成 Chunk

输出阶段

事件名解释
should-emit所有需要输出的文件已经生成好,询问插件哪些文件需要输出,哪些不需要。
emit确定好要输出哪些文件后,执行文件输出,可以在这里获取和修改输出内容。
after-emit文件输出完毕。
done成功完成一次完成的编译和输出流程。
failed如果在编译和输出流程中遇到异常导致 Webpack 退出时,就会直接跳转到本步骤,插件可以在本事件中获取到具体的错误原因。

总结

Webpack 的运行流程是一个串行的过程,从启动到结束会依次执行以下流程:

  1. 初始化参数:从配置文件和 Shell 语句中读取与合并参数,得出最终的参数
  2. 开始编译:用上一步得到的参数初始化 Compiler 对象,加载所有配置的插件,执行对象的 run 方法开始执行编译
  3. 确定入口:根据配置中的 entry 找出所有的入口文件
  4. 编译模块:从入口文件出发,调用所有配置的 Loader 对模块进行翻译,再找出该模块依赖的模块,再递归本步骤直到所有入口依赖的文件都经过了本步骤的处理
  5. 完成模块编译:在经过第 4 步使用 Loader 翻译完所有模块后,得到了每个模块被翻译后的最终内容以及它们之间的依赖关系
  6. 输出资源:根据入口和模块之间的依赖关系,组装成一个个包含多个模块的 Chunk,再把每个 Chunk 转换成一个单独的文件加入到输出列表,这步是可以修改输出内容的最后机会
  7. 输出完成:在确定好输出内容后,根据配置确定输出的路径和文件名,把文件内容写入到文件系统

Webpack loader 和 plugin 的区别

  1. webpack loader 是用来加载文件的,webpack plugin 是用来扩展功能的。
  2. loader 主要是用来加载一个个文件的,比如它可以加载 js 文件并把 js 文件转译成低版本浏览器可以支持的 js 文件;也可以用来加载 css 文件,可以把 css 文件变成页面上的 style 标签;还可以加载图片文件,可以对文件进行优化。
  3. plugin 是用来加强 webpack 功能的,比如HTML webpack plugin是用来生成一个 html 文件的;再比如mini css extract plugin是用来抽取 css 代码并把它变成一个 css 文件的。

常用的 plugin 和 loader

Loader

简介

webpack 中提供了一种处理多种文件格式的机制,这便是 Loader,我们可以把 Loader 当成一个转换器,它可以将某种格式的文件转换成 Wwebpack 支持打包的模块。

在 Webpack 中,一切皆模块,我们常见的 Javascript、CSS、Less、Typescript、Jsx、图片等文件都是模块,不同模块的加载是通过模块加载器来统一管理的,当我们需要使用不同的 Loader 来解析不同类型的文件时,我们可以在 module.rules 字段下配置相关规则。

loader 特点

  • loader 本质上是一个函数,output=loader(input) // input 可为工程源文件的字符串,也可是上一个 loader 转化后的结果;
  • 第一个 loader 的传入参数只有一个:资源文件(resource file)的内容;
  • loader 支持链式调用,webpack 打包时是按照数组从后往前的顺序将资源交给 loader 处理的。
  • 支持同步或异步函数。

常用的 Loader

1. babel-loader

babel-loader 基于 babel,用于解析 JavaScript 文件。babel 有丰富的预设和插件,babel 的配置可以直接写到 options 里或者单独写道配置文件里。

Babel 是一个 Javscript 编译器,可以将高级语法(主要是 ECMAScript 2015+ )编译成浏览器支持的低版本语法,它可以帮助你用最新版本的 Javascript 写代码,提高开发效率。

用法

# 环境要求:
webpack 4.x || 5.x | babel-loader 8.x | babel 7.x

# 安装依赖包:
npm install -D babel-loader @babel/core @babel/preset-env webpack

然后,我们需要建立一个 Babel 配置文件来指定编译的规则。

Babel 配置里的两大核心:插件数组(plugins) 和 预设数组(presets)。

Babel 的预设(preset)可以被看作是一组 Babel 插件的集合,由一系列插件组成。

常用预设:

  • @babel/preset-env ES2015+ 语法
  • @babel/preset-typescript TypeScript
  • @babel/preset-react React
  • @babel/preset-flow Flow

插件和预设的执行顺序:

  • 插件比预设先执行
  • 插件执行顺序是插件数组从前向后执行
  • 预设执行顺序是预设数组从后向前执行

webpack 配置代码:

// webpack.config.js
module: {
  rules: [
    {
      test: /.m?js$/,
      exclude: /node_modules/,
      use: {
        loader: "babel-loader",
        options: {
          presets: [["@babel/preset-env", { targets: "defaults" }]],
          plugins: ["@babel/plugin-proposal-class-properties"], // 缓存 loader 的执行结果到指定目录,默认为node_modules/.cache/babel-loader,之后的 webpack 构建,将会尝试读取缓存
          cacheDirectory: true
        }
      }
    }
  ];
}

以上 options 参数也可单独写到配置文件里,许多其他工具都有类似的配置文件:ESLint (.eslintrc)、Prettier (.prettierrc)。

配置文件我们一般只需要配置 presets(预设数组) 和 plugins(插件数组) ,其他一般也用不到,代码示例如下:

// babel.config.js
module.exports = api => {
  return {
    presets: [
      "@babel/preset-react",
      [
        "@babel/preset-env",
        {
          useBuiltIns: "usage",
          corejs: "2",
          targets: {
            chrome: "58",
            ie: "10"
          }
        }
      ]
    ],
    plugins: [
      "@babel/plugin-transform-react-jsx",
      "@babel/plugin-proposal-class-properties"
    ]
  };
};
2. ts-loader

为 webpack 提供的 TypeScript loader,打包编译 Typescript

安装依赖:

npm install ts-loader --save-dev
npm install typescript --dev

webpack 配置如下:

// webpack.config.json
module.exports = {
  mode: "development",
  devtool: "inline-source-map",
  entry: "./app.ts",
  output: {
    filename: "bundle.js"
  },
  resolve: {
    // Add `.ts` and `.tsx` as a resolvable extension.
    extensions: [".ts", ".tsx", ".js"]
  },
  module: {
    rules: [
      // all files with a `.ts` or `.tsx` extension will be handled by `ts-loader`
      { test: /.tsx?$/, loader: "ts-loader" }
    ]
  }
};

还需要 typescript 编译器的配置文件tsconfig.json

{
  "compilerOptions": {
    // 目标语言的版本
    "target": "esnext", // 生成代码的模板标准
    "module": "esnext",
    "moduleResolution": "node", // 允许编译器编译JS,JSX文件
    "allowJS": true, // 允许在JS文件中报错,通常与allowJS一起使用
    "checkJs": true,
    "noEmit": true, // 是否生成source map文件
    "sourceMap": true, // 指定jsx模式
    "jsx": "react"
  }, // 编译需要编译的文件或目录
  "include": ["src", "test"], // 编译器需要排除的文件或文件夹
  "exclude": ["node_modules", "**/*.spec.ts"]
}
3. markdown-loader

markdown 编译器和解析器

用法:

只需将 loader 添加到您的配置中,并设置 options。

js 代码里引入 markdown 文件:

// file.js

import md from "markdown-file.md";

console.log(md);

webpack 配置:

// wenpack.config.js
const marked = require("marked");
const renderer = new marked.Renderer();

module.exports = {
  // ...
  module: {
    rules: [
      {
        test: /.md$/,
        use: [
          {
            loader: "html-loader"
          },
          {
            loader: "markdown-loader",
            options: {
              pedantic: true,
              renderer
            }
          }
        ]
      }
    ]
  }
};
4. raw-loader

可将文件作为字符串导入

// app.js
import txt from "./file.txt";
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /.txt$/,
        use: "raw-loader"
      }
    ]
  }
};
5. file-loader

用于处理文件类型资源,如 jpg,png 等图片。返回值为 publicPath 为准

// file.js
import img from "./webpack.png";
console.log(img); // 编译后:https://www.tencent.com/webpack_605dc7bf.png
// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /.(png|jpe?g|gif)$/i,
        loader: "file-loader",
        options: {
          name: "[name]_[hash:8].[ext]",
          publicPath: "https://www.tencent.com"
        }
      }
    ]
  }
};

css 文件里的图片路径变成如下:

/* index.less */
.tag {
   background-color: red;
   background-image: url(./webpack.png);
}
/* 编译后:*/
background-image: url(https://www.tencent.com/webpack_605dc7bf.png);
6. url-loader:

它与 file-loader 作用相似,也是处理图片的,只不过 url-loader 可以设置一个根据图片大小进行不同的操作,如果该图片大小大于指定的大小,则将图片进行打包资源,否则将图片转换为 base64 字符串合并到 js 文件里。

module.exports = {
  module: {
    rules: [
      {
        test: /.(png|jpg|jpeg)$/,
        use: [
          {
            loader: "url-loader",
            options: {
              name: "[name]_[hash:8].[ext]", // 这里单位为(b) 10240 => 10kb // 这里如果小于10kb则转换为base64打包进js文件,如果大于10kb则打包到对应目录
              limit: 10240
            }
          }
        ]
      }
    ]
  }
};
7. postcss-loader

PostCSS 是一个允许使用 JS 插件转换样式的工具。 这些插件可以检查(lint)你的 CSS,支持 CSS Variables 和 Mixins, 编译尚未被浏览器广泛支持的先进的 CSS 语法,内联图片,以及其它很多优秀的功能。

PostCSS 在业界被广泛地应用。PostCSS 的 autoprefixer 插件是最流行的 CSS 处理工具之一。

autoprefixer 添加了浏览器前缀,它使用 Can I Use 上面的数据。

安装

npm install postcss-loader autoprefixer --save-dev

代码示例:

// webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const isDev = process.NODE_ENV === "development";
module.exports = {
  module: {
    rules: [
      {
        test: /.(css|less)$/,
        exclude: /node_modules/,
        use: [
          isDev ? "style-loader" : MiniCssExtractPlugin.loader,
          {
            loader: "css-loader",
            options: {
              importLoaders: 1
            }
          },
          {
            loader: "postcss-loader"
          },
          {
            loader: "less-loader",
            options: {
              lessOptions: {
                javascriptEnabled: true
              }
            }
          }
        ]
      }
    ]
  }
};

然后在项目根目录创建 postcss.config.js,并且设置支持哪些浏览器,必须设置支持的浏览器才会自动添加添加浏览器兼容

module.exports = {
  plugins: [
    require("precss"),
    require("autoprefixer")({
      browsers: [
        "defaults",
        "not ie < 11",
        "last 2 versions",
        "> 1%",
        "iOS 7",
        "last 3 iOS versions"
      ]
    })
  ]
};

Plugin

Plugin 简介

Webpack 就像一条生产线,要经过一系列处理流程后才能将源文件转换成输出结果。 这条生产线上的每个处理流程的职责都是单一的,多个流程之间有存在依赖关系,只有完成当前处理后才能交给下一个流程去处理。 插件就像是一个插入到生产线中的一个功能,在特定的时机对生产线上的资源做处理。

Webpack 通过 Tapable 来组织这条复杂的生产线。 Webpack 在运行过程中会广播事件,插件只需要监听它所关心的事件,就能加入到这条生产线中,去改变生产线的运作。 Webpack 的事件流机制保证了插件的有序性,使得整个系统扩展性很好。

常用 Plugin

1. copy-webpack-plugin

将已经存在的单个文件或整个目录复制到构建目录。

const CopyPlugin = require("copy-webpack-plugin");

module.exports = {
  plugins: [
    new CopyPlugin({
      patterns: [
        {
          from: "./template/page.html",
          to: `${__dirname}/output/cp/page.html`
        }
      ]
    })
  ]
};
2. html-webpack-plugin

基本作用是生成 html 文件

  • 单页应用可以生成一个 html 入口,多页应用可以配置多个 html-webpack-plugin 实例来生成多个页面入口
  • 为 html 引入外部资源如 script、link,将 entry 配置的相关入口 chunk 以及 mini-css-extract-plugin 抽取的 css 文件插入到基于该插件设置的 template 文件生成的 html 文件里面,具体的方式是 link 插入到 head 中,script 插入到 head 或 body 中。
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  entry: {
    news: [path.resolve(__dirname, "../src/news/index.js")],
    video: path.resolve(__dirname, "../src/video/index.js")
  },
  plugins: [
    new HtmlWebpackPlugin({
      title: "news page", // 生成的文件名称 相对于webpackConfig.output.path路径而言
      filename: "pages/news.html", // 生成filename的文件模板
      template: path.resolve(__dirname, "../template/news/index.html"),
      chunks: ["news"]
    }),
    new HtmlWebpackPlugin({
      title: "video page", // 生成的文件名称
      filename: "pages/video.html", // 生成filename的文件模板
      template: path.resolve(__dirname, "../template/video/index.html"),
      chunks: ["video"]
    })
  ]
};
3. clean-webpack-plugin

默认情况下,这个插件会删除 webpack 的 output.path 中的所有文件,以及每次成功重新构建后所有未使用的资源。

这个插件在生产环境用的频率非常高,因为生产环境经常会通过 hash 生成很多 bundle 文件,如果不进行清理的话每次都会生成新的,导致文件夹非常庞大。

const { CleanWebpackPlugin } = require("clean-webpack-plugin");
module.exports = {
  plugins: [new CleanWebpackPlugin()]
};
4. mini-css-extract-plugin

本插件会将 CSS 提取到单独的文件中,为每个包含 CSS 的 JS 文件创建一个 CSS 文件。

// 建议 mini-css-extract-plugin 与 css-loader 一起使用
// 将 loader 与 plugin 添加到 webpack 配置文件中
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = {
  plugins: [new MiniCssExtractPlugin()],
  module: {
    rules: [
      {
        test: /.css$/i,
        use: [MiniCssExtractPlugin.loader, "css-loader"]
      }
    ]
  }
};
5. webpack-bundle-analyzer

可以看到项目各模块的大小,可以按需优化.一个 webpack 的 bundle 文件分析工具,将 bundle 文件以可交互缩放的 treemap 的形式展示。

const BundleAnalyzerPlugin = require("webpack-bundle-analyzer")
  .BundleAnalyzerPlugin;

module.exports = {
  plugins: [new BundleAnalyzerPlugin()]
};

启动服务:

  • 生产环境查看:NODE_ENV=production npm run build
  • 开发环境查看:NODE_ENV=development npm run start

analyzer.gif