五年入行三年摸鱼之大厂面试真题演练(卷三框架&构建工具篇)

446 阅读20分钟

大厂 框架 & 构建工具 相关面试题,均为简答,概括核心要点,方便面试短期记忆

目录

vue

webpack

待输出

vue
  • vue和react的区别
  • vue3 的 类似 hooks 的原理是怎么样的
  • vue3.0的新特性,了解 composition-api 和react hooks的区别
  • 写一个简单的diff
  • vue和react怎么实现模板解析,考察正则表达式
  • class 组件与函数式组件的区别(生命周期、设计理念,感觉这道题比较开发,可以看看 dan 的这篇:函数式组件与类组件有何不同?)
  • 假设团队内从 Vue 转为 React,你认为要做什么准备?
  • 为何在 Vue 和 Angular 和 React 中选了 Vue?
  • JavaScript 和 TypeScript 有什么区别?在技术选型的时候什么因素使你考虑用?
  • 关于移动端 UI 框架选型的思考:为何在 Vant 和 Cube 中选了 Vant?
  • 关于桌面端 UI 框架选型的思考:为何在 Element 和 iView 中选了 Element?
webpack
  • webpack怎么配置mock转发代理,mock的服务,怎么拦截转换的
  • babel 原理
  • babel的缓存是怎么实现的
  • 自己有没有写过ast, webpack通过什么把公共的部分抽出来的,属性配置是什么
  • 讲讲你写的 babel 插件
  • 写过 babel 插件吗?用来干啥的?怎么写的 babel 插件
  • 写过一些简单的 babel 插件,说了我们公司用来通过代码生成文档的 babel 插件是怎么做的。
  • 知道怎么转化成 AST 的吗?(我估计就是问词法分析和语法分析相关的)

vue和react的区别

new Vue做了什么

  • Vue 只能通过 new 关键字初始化,然后会调用 this._init 方法
  • Vue 初始化主要就干了几件事情,合并配置,初始化生命周期,初始化事件,初始化渲染,初始化 data、props、computed、watcher 等等
  • Vue 的初始化逻辑写的非常清楚,把不同的功能逻辑拆成一些单独的函数执行,让主线逻辑一目了然
  • 在初始化的最后,检测到如果有 el 属性,则调用 vm.$mount 方法挂载 vm,挂载的目标就是把模板渲染成最终的 DOM

vue组件通信方法

vue中8种组件通信方式

  • 父组件通过props的方式向子组件传递数据,而通过$emit 子组件可以向父组件通信
  • provideinject 是 vue2.2.0 新增的api, 简单来说就是父组件中通过provide来提供变量, 然后再子组件中通过inject来注入变量
  • ref:如果在普通的 DOM 元素上使用,引用指向的就是 DOM 元素;如果用在子组件上,引用就指向组件实例,可以通过实例直接调用组件的方法或访问数据
  • Vuex 是一个专为 Vue.js 应用程序开发的状态管理模式。它采用集中式存储管理应用的所有组件的状态,并以相应的规则保证状态以一种可预测的方式发生变化. Vuex 解决了多个视图依赖于同一状态和来自不同视图的行为需要变更同一状态的问题,将开发者的精力聚焦于数据的更新而不是数据在组件之间的传递上
  • 通过$parent$children就可以访问组件的实例,拿到实例代表什么?代表可以访问此组件的所有方法和data
  • eventBus 又称为事件总线,在vue中可以使用它来作为沟通桥梁的概念, 就像是所有组件共用相同的事件中心,可以向该中心注册发送事件或接收事件, 所以组件都可以通知其他组件
  • localStorage / sessionStorage
  • 在 vue2.4 中,为了解决该需求,引入了$attrs$listeners , 新增了inheritAttrs 选项。 在版本2.4以前,默认情况下,父作用域中不作为 prop 被识别 (且获取) 的特性绑定 (class 和 style 除外),将会“回退”且作为普通的HTML特性应用在子组件的根元素上。

双向绑定原理

  1. 初始化遍历属性,通过数据劫持 Object.defineProperty 来设置他们的get、set方法,具体是 Observer 这个类实现的
  2. 检测到有 el 属性,会调用 $mount 方法挂载vm,这一过程触发属性的 getter 方法,从而进行依赖收集,具体是通过Compiler类来实现,其中还涉及parse(通过正则解析生成ast抽象语法树)、optimize(标记静态节点,后面更新时直接跳过)、generate(生成render function需要的模板字符串)
  3. 数据更新时,触发 setter 方法,调用 dep.notify,遍历订阅者的 subs 数组,使他们中的 watcher 依次触发 update 方法,update之后,会触发 patch 过程,patch中具体的方法有 patchVnode 和 updateChildren,其中 updateChildren 中用到 diff 算法,进行新老子节点的比对,从而进行 dom 的增删改操作

Object.defineProperty进行数据劫持

import Dep from "./dep.js";

export default class Observer {
  constructor(data) {
    // 用来遍历 data
    this.walk(data);
  }
  // 遍历 data 转为响应式
  walk(data) {
    // 判断 data是否为空 和 对象
    if (!data || typeof data !== "object") return;
    // 遍历 data
    Object.keys(data).forEach((key) => {
      // 转为响应式
      this.defineReactive(data, key, data[key]);
    });
  }
  // 转为响应式
  // 要注意的 和vue.js 写的不同的是
  // vue.js中是将 属性给了 Vue 转为 getter setter
  // 这里是 将data中的属性转为getter setter
  defineReactive(obj, key, value) {
    // 如果是对象类型的 也调用walk 变成响应式,不是对象类型的直接在walk会被return
    this.walk(value);
    const _this = this;
    // 创建 Dep 对象
    let dep = new Dep();
    Object.defineProperty(obj, key, {
      // 设置可枚举
      enumerable: true,
      // 设置可配置
      configurable: true,
      // 获取值
      get() {
        // 在这里添加观察者对象 Dep.target 表示观察者
        console.log("observer get key => ", key);
        console.log("Dep.target ", Dep.target);
        console.log(dep.subs);
        Dep.target && dep.addSub(Dep.target);
        return value;
      },
      // 设置值
      set(newValue) {
        // 判断旧值和新值是否相等
        if (newValue === value) return;
        // 设置新值
        value = newValue;
        // 赋值的话如果是newValue是对象,对象里面的属性也应该设置为响应式的
        _this.walk(newValue);
        // 触发通知 更新视图
        dep.notify();
      },
    });
  }
}

发布订阅模式

/**
 * 订阅者
 */
export default class Dep {
  constructor() {
    // 存储观察者
    this.subs = [];
  }
  // 添加观察者
  addSub(sub) {
    console.log('~~~~~~sub ', sub)
    // 判断观察者是否存在和是否拥有update方法
    if (sub && sub.update) {
      this.subs.push(sub);
    }
  }
  // 通知方法
  notify() {
    /* 通知所有Watcher对象更新视图 */
    this.subs.forEach((sub) => {
      sub.update();
    });
  }
}

Vue2 数据劫持的缺陷

  • 无法监听数组变化
  • 只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历,如果属性值也是对象那么需要深度遍历,显然能劫持一个完整的对象是更好的选择。

Vue3 数据劫持的优势

  • proxy可以直接监听数组的变化
  • proxy可以监听对象而非属性.它在目标对象之前架设一层“拦截”,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写
  • Proxy直接可以劫持整个对象,并返回一个新对象。
  • Proxy返回的是一个新对象,我们可以只操作新的对象达到目的,而Object.defineProperty只能遍历对象属性直接修改;
  • Proxy作为新标准将受到浏览器厂商重点持续的性能优化,也就是传说中的新标准的性能红利
  • Proxy的劣势就是兼容性问题,而且无法用polyfill实现

image.png

分别使用 Object.defineProperty 和 proxy 写一个简单的双向绑定

  • Object.defineProperty 劫持对象的属性
  • Proxy 需要使用new,返回一个实例,改变原对象需要通过 set 中设置
<!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>
    <input type="text" id="text" oninput="inputFn(this.value)" />
    <div id="div"></div>
    <script>
      const i = document.querySelector("#text");
      const div = document.querySelector("#div");
      const data = {
        text: "text",
        obj: {
          key: '999'
        },
        arr: [1, 2, 3]
      };
      const p = new Proxy(data, {
        set: (target, key, newValue) => {
          console.log("set", target, key, newValue);
          target[key] = newValue
          if (key === 'text') {
            div.innerHTML = newValue
            i.value = newValue
          }
        },
        get: (target, key) => {
          console.log("get", target, key);
          return target[key];
        },
      });
      function inputFn(value) {
        // 触发set
        p.text = value
      }
      p.text = 'aaaaaa'
      // 修改属性值为对象的key,或者数组,不会触发set?
      p.obj.key = '888'
      // p.obj.newKey = '666'
      // p.arr[1] = 0
      console.log('result ', data)

      // Object.defineProperty(data, "text", {
      //   enumerable: true,
      //   configurable: true,
      //   set: (newValue) => {
      //     console.log("set", newValue);
      //     div.innerHTML = newValue
      //     i.value = newValue
      //   },
      //   get: () => {
      //     console.log("get");
      //   },
      // });
      // // 触发get
      // console.log(data.text)
      // function inputFn(value) {
      //   // 触发set
      //   data.text = value;
      // }
      // setTimeout(() => {
      //   // 触发set
      //   data.text = new Date().getTime()
      // }, 2000);
    </script>
  </body>
</html>

proxy 除了拦截它的 getter 和 setter 外,还能做什么

get

  • 当通过proxy去读取对象里面的属性的时候,会进入到get钩子函数里面

set

  • 当通过proxy去为对象设置修改属性的时候,会进入到set钩子函数里面

has

  • 当使用in判断属性是否在proxy代理对象里面时,会触发has,比如
const obj = {
  name: '子君'
}
console.log('name' in obj)

deleteProperty

  • 当使用delete去删除对象里面的属性的时候,会进入deleteProperty钩子函数

apply

  • proxy监听的是一个函数的时候,当调用这个函数时,会进入apply钩子函数

ownKeys

  • 当通过Object.getOwnPropertyNames Object.getownPropertySymbols Object.keys Reflect.own>Keys 去获取对象的信息的时候,就会进入ownKeys这个钩子函数

construct

  • 当使用new操作符的时候,会进入construct这个钩子函数

defineProperty

  • 当使用Object.defineProperty去修改属性修饰符的时候,会进入这个钩子函数

getPrototypeOf

  • 当读取对象的原型的时候,会进入这个钩子函数

setPrototypeOf

  • 当设置对象的原型的时候,会进入这个钩子函数

isExtensible

  • 当通过Object.isExtensible去判断对象是否可以添加新的属性的时候,进入这个钩子函数

preventExtensions

  • 当通过Object.preventExtensions去设置对象不可以修改新属性时候,进入这个钩子函数

getOwnPropertyDescriptor

  • 在获取代理对象某个属性的属性描述时触发该操作,比如在执行 Object.getOwnPropertyDescriptor(proxy, "foo") 时会进入这个钩子函数

vue3新特性

composition api

image.png

  • setup 是 Vue3.x 新增的一个选项, 他是组件内使用 Composition API的入口,使用setup时,它接受两个参数:
export default defineComponent ({
   setup(props, context) {
       const { name } = props
       console.log(name)
   },
})
  • reactive:一般用来定义对象类型数据
  • ref:一般用来定义基础数据类型数据
  • toRefs:用于将一个 reactive 对象转化为属性全部为 ref 对象的普通对象
  • watchwatchEffect 的用法
const state = reactive({
  room: {
    id: 100,
    attrs: {
      size: "140平方米",
      type: "三室两厅",
    },
  },
});
watch(
  () => state.room,
  (newType, oldType) => {
    console.log("新值:", newType, "老值:", oldType);
  },
  { deep: true }
);

自定义hooks

<template>
  <p>count: {{ count }}</p>
  <p>倍数: {{ multiple }}</p>
  <div>
    <button @click="increase()">加1</button>
    <button @click="decrease()">减一</button>
  </div>
</template>

<script lang="ts">
import useCount from "../hooks/useCount";
 setup() {
    const { count, multiple, increase, decrease } = useCount(10);
        return {
            count,
            multiple,
            increase,
            decrease,
        };
    },
</script>

将分散在data,method,computed等中的同一业务逻辑的代码聚合在一起,看起来舒服多了,并且还可以扩展更多的功能

生命周期钩子

image.png

  • beforeCreatecreatedsetup替换了(但是 Vue3 中你仍然可以使用, 因为 Vue3 是向下兼容的, 也就是你实际使用的是 vue2 的)。其次,钩子命名都增加了on; Vue3.x 还新增用于调试的钩子函数onRenderTriggeredonRenderTricked

  • Vue3.x 中的钩子是需要从 vue 中导入

teleport传送

  • 即希望继续在子组件内部使用某某通用组件, 又希望渲染的 DOM 结构不嵌套在父组件的 DOM 中

  • 将通用组件DOM挂载到body下

suspense

suspen内部分为 defaultfallback

<Suspense>
    <template #default>
        <async-component></async-component>
    </template>
    <template #fallback>
        <div>
            Loading...
        </div>
    </template>
</Suspense>

fragment

在 Vue3.x 中,可以直接写多个根节点

tree-shaking

Vue3.x 在考虑到 tree-shaking的基础上重构了全局和内部 API, 表现结果就是现在的全局 API 需要通过 ES Module的引用方式进行具名引用

import { nextTick } from "vue"

nextTick(() =>{
    ...
})

受影响的 API

这是一个比较大的变化, 因为以前的全局 API 现在只能通过具名导入,这一更改会对以下 API 有影响:

  • Vue.nextTick
  • Vue.observable(用 Vue.reactive 替换)
  • Vue.version
  • Vue.compile(仅限完整版本时可用)
  • Vue.set(仅在 2.x 兼容版本中可用)
  • Vue.delete(与上同)

其他变更

slot 具名插槽语法

在 Vue2.x 中具名插槽和作用域插槽分别使用slotslot-scope来实现, 在 Vue3.0 中将slotslot-scope进行了合并同意使用。 Vue3.0 中v-slot

<!-- 父组件中使用 -->
 <template v-slot:content="scoped">
   <div v-for="item in scoped.data">{{item}}</div>
</template>
<!-- 也可以简写成: -->
<template #content="{data}">
    <div v-for="item in data">{{item}}</div>
</template>

自定义指令

在 Vue 3 中对自定义指令的 API 进行了更加语义化的修改, 就如组件生命周期变更一样, 都是为了更好的语义化, 变更如下:

image.png

v-model升级

那么在 Vue 3 中应该怎样实现的呢? 在 Vue3 中, 在自定义组件上使用v-model, 相当于传递一个modelValue 属性, 同时触发一个update:modelValue事件:

<modal v-model="isVisible"></modal>
<!-- 相当于 -->
<modal :modelValue="isVisible" @update:modelValue="isVisible = $event"></modal>
复制代码

如果要绑定属性名, 只需要给v-model传递一个参数就行, 同时可以绑定多个v-model

<modal v-model:visible="isVisible" v-model:content="content"></modal>

<!-- 相当于 -->
<modal
    :visible="isVisible"
    :content="content"
    @update:visible="isVisible"
    @update:content="content"
/>

异步组件

Vue3 中 使用 defineAsyncComponent 定义异步组件,配置选项 component 替换为 loader,Loader 函数本身不再接收 resolve 和 reject 参数,且必须返回一个 Promise

<template>
  <!-- 异步组件的使用 -->
  <AsyncPage />
</tempate>
<script>
import { defineAsyncComponent } from "vue";
export default {
  components: {
    // 无配置项异步组件
    AsyncPage: defineAsyncComponent(() => import("./NextPage.vue")),
    // 有配置项异步组件
    AsyncPageWithOptions: defineAsyncComponent({
      loader: () => import(".NextPage.vue"),
      delay: 200,
      timeout: 3000,
      errorComponent: () => import("./ErrorComponent.vue"),
      loadingComponent: () => import("./LoadingComponent.vue"),
    })
  },
}
</script>

vue3相对于vue2有哪些优化

尤大公布的数据就是 update 性能提升了 1.3~2 倍ssr 性能提升了 2~3 倍,来看看都有哪些优化

  • 事件缓存:将事件缓存,可以理解为变成静态的了
  • 静态标记:Vue2 是全量 Diff,Vue3 是静态标记 + 非全量 Diff
  • 静态提升:创建静态节点时保存,后续直接复用
  • 使用最长递增子序列优化了对比流程:Vue2 里在 updateChildren() 函数里对比变更,在 Vue3 里这一块的逻辑主要在 patchKeyedChildren() 函数里,具体看下面

事件缓存

比如这样一个有点击事件的按钮

<button @click="handleClick">按钮</button>

来看下在 Vue3 被编译后的结果

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("button", {
    onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.handleClick && _ctx.handleClick(...args)))
  }, "按钮"))
}

注意看,onClick 会先读取缓存,如果缓存没有的话,就把传入的事件存到缓存里,都可以理解为变成静态节点了,优秀吧,而在 Vue2 中就没有缓存,就是动态的

静态标记

为了提高diff算法性能

看一下静态标记是啥?

export const enum PatchFlags {
  TEXT = 1 ,  // 动态文本节点
  CLASS = 1 << 1,  // 2   动态class
  STYLE = 1 << 2,  // 4   动态style
  PROPS = 1 << 3,  // 8   除去class/style以外的动态属性
  FULL_PROPS = 1 << 4,       // 16  有动态key属性的节点,当key改变时,需进行完整的diff比较
  HYDRATE_EVENTS = 1 << 5,   // 32  有监听事件的节点
  STABLE_FRAGMENT = 1 << 6,  // 64  一个不会改变子节点顺序的fragment (一个组件内多个根元素就会用fragment包裹)
  KEYED_FRAGMENT = 1 << 7,   // 128 带有key属性的fragment或部分子节点有key
  UNKEYEN_FRAGMENT = 1 << 8, // 256  子节点没有key的fragment
  NEED_PATCH = 1 << 9,       // 512  一个节点只会进行非props比较
  DYNAMIC_SLOTS = 1 << 10,   // 1024   动态slot
  HOISTED = -1,  // 静态节点 
  BAIL = -2      // 表示 Diff 过程中不需要优化
}

在 Vue3 中编译的结果是这样的

const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "沐华", -1 /* HOISTED */)

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _hoisted_2,
    _createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */)
  ]))
}
复制代码

看到上面编译结果中的 -11 了吗,这就是静态标记,这是在 Vue2 中没有的,patch 过程中就会判断这个标记来 Diff 优化流程,跳过一些静态节点对比

静态提升

其实还是拿上面 Vue2 和 Vue3 静态标记的例子,在 Vue2 里每当触发更新的时候,不管元素是否参与更新,每次都会全部重新创建,就是下面这一堆(调用render)

with(this){
    return _c(
      'div',
      {attrs:{"id":"app"}},
      [ 
        _c('div',[_v("沐华")]),
        _c('p',[_v(_s(age))])
      ]
    )
}

而在 Vue3 中会把这个不参与更新的元素保存起来,只创建一次,之后在每次渲染的时候不停地复用,比如上面例子中的这个,静态的创建一次保存起来

const _hoisted_1 = { id: "app" }
const _hoisted_2 = /*#__PURE__*/_createElementVNode("div", null, "沐华", -1 /* HOISTED */)
复制代码

然后每次更新 age 的时候,就只创建这个动态的内容,复用上面保存的静态内容

export function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_openBlock(), _createElementBlock("div", _hoisted_1, [
    _hoisted_2,
    _createElementVNode("p", null, _toDisplayString(_ctx.age), 1 /* TEXT */)
  ]))
}

patchKeyedChildren

在 Vue2 里 updateChildren 会进行

  • 头和头比
  • 尾和尾比
  • 头和尾比
  • 尾和头比
  • 都没有命中的对比 在 Vue3 里 patchKeyedChildren
  • 头和头比
  • 尾和尾比
  • 基于最长递增子序列进行移动/添加/删除

vue ssr实践

  • csr or ssr?为什么要ssr?
  • nuxt.js
  • 同构
  • 性能优化
  • 容灾、降级
  • 监控、告警
  • 日志

csr or ssr

  • 客户端渲染(CSR)

服务端只返回一个基本的html模板,浏览器根据html内容去加载js,获取数据,渲染出页面内容;

  • 服务端渲染(SSR)

页面的内容是在服务端渲染完成,返回到浏览器直接展示

为什么要ssr

与传统 SPA (单页应用程序 (Single-Page Application)) 相比,SSR的优势主要在于

  • 更好的搜索引擎优化(SEO) ,SPA应用程序初始展示loading菊花图,然后通过Ajax获取内容,搜索引擎并不会等待异步完成后再行抓取页面内容;
  • 更快的内容到达时间 (time-to-content) ,特别是对于缓慢的网络情况或运行缓慢的设备,无需等待所有的JavaScript都完成下载并执行,才显示服务器渲染的标记,用户能够更快速地看到完整渲染的页面,提升用户体验。下图能够更直观的反应加载时效果。

image.png

nuxt.js实现ssr

同构

所谓同构就是采用一套代码,构建双端(server 和 client)逻辑,最大限度的重用代码,不用维护两套代码。而传统的服务端渲染是无法做到的

性能优化

虽然Vue SSR渲染速度已经很快,但是由于创建组件实例和虚拟DOM节点的开销,与基于字符串拼接的模板引擎的性能相差很大,在高并发情况下,服务器响应会变慢,极大的影响用户体验,因此必须进行性能优化

启用缓存

  • a、页面缓存:  在创建render实例时利用 LRU-Cache 来缓存渲染好的html,当再有请求访问该页面时,直接将缓存中的html字符串返回。
  • b、组件缓存:  将渲染后的组件DOM存入缓存,定时刷新,有效期内取缓存中DOM。主要适用于重复使用的组件,多用于列表,例如商品列表。
  • c、API缓存: Node服务器需要先调用后台接口,获取到数据,然后才能进行渲染,获取接口速度的快慢,直接影响到渲染的时间,对接口的缓存可以加快每个请求的处理速度,更快地释放掉请求,从而提高性能。API缓存主要适用于数据基本保持不变,变更不是很频繁,与用户个人数据无关的接口。

接口并发请求 Promise.all

同一个页面,在Node层可能会同时调用多个接口,如果是串行调用,需要等待的时间会比较长,如果是并发请求,会缩小等待时间。 例如:

let data1 = await $axios.get('接口1')
let data2 = await $axios.get('接口2')
let data3 = await $axios.get('接口3')

可以改成:

let {data1,data2,data3} = await Promise.all([
   $axios.get('接口1'),
   $axios.get('接口2'),
   $axios.get('接口3')
])

首屏最小化

影响用户体验主要是首屏的白屏时间,而第二屏、第三屏...,并不需要立即显示。以商品详情页为例,

部分页面采用CSR

并不是所有页面对体验、SEO要求都很高,像商城这样的业务,可以只对首页、商品详情页等核心页面做SSR,这样可以大大减少服务端的压力

优化前后性能压测对比

优化前: 优化后: 从上图可以看出,未经优化前QPS只有125,经过一系列优化QPS达到了6000,提升了接近 50倍。

降级策略

这里的降级是指将SSR降级为CSR,使用Node做SSR,瓶颈在于CPU和内存,在高并发情况下,很容易导致CPU飙升,用户访问页面时间变长,如果Node服务器挂了,直接会导致页面访问不了。所以为了保证项目上线之后平稳运行,需要提供容灾、降级方案。

Nuxt.js可以同时支持CSR和SSR,我们在打包时,既生成SSR的包,同时生成CSR的包,分别进行部署。

项目中采用了以下几种降级方案:

监控系统降级

Node服务器上启动一个服务,用来监测Node进程的CPU和内存使用率,设定一个阈值,当达到这个阈值时,停止SSR,直接将CSR的入口文件index.html返回,实现降级。

Nginx降级策略

全平台降级

例如618,双11等大促期间,我们事先知道流量会很大,可以提前通过修改Nginx配置,将请求转发到静态服务器,返回index.html,切换到CSR。

单次访问降级

当偶发性的Node服务器返回5xx错误码,或者Node服务器直接挂了,我们可以通过如下Nginx配置,做到自动切换到CSR,保证用户能正常访问。

Nginx配置如下:

location / {
  proxy_pass Node服务器地址;
  proxy_intercept_errors on;
  error_page 408 500 501 502 503 504 =200 @spa_page;  
}
location @spa_page {
  rewrite ^/*  /spa/200.html break;
  proxy_pass  静态服务器;
}

指定渲染方式

在url中增加参数isCsr=true,Nginx层对参数isCsr进行拦截,如果带上该参数,指向CSR,否则指向SSR;这样就可以通过url参数配置来进行页面分流,减轻Node服务器压力。

CI/CD 自动化部署

基于公司的CI/CD,我们实现了Docker部署和Shell脚本部署两种自动化部署方案。

  • Shell脚本构建、部署
1、安装nvm, nvm 是Node.js 的版本管理器(version manager)
2、通过nvm安装或者切换成对于的Node版本

# 定义安装nvm的方法
install_nvm() {
  echo "env $app_env install nvm ..."
  wget --header='Authorization:Basic dml2b2Rldm9wczp4TFFidmtMbW9ZKn4x' -nv -P .nvm http://xxx/download/nvm-master.zip

  unzip -qo .nvm/nvm-master.zip
  mv nvm-master/* $NVM_DIR
  rm -rf .nvm
  rm -rf nvm-master

  . "$NVM_DIR/nvm.sh"
  if [[ $? = 1 ]];
  then
    echo "install nvm fail"
  else
    echo "install nvm success"
  fi
}

# 定义安装Node的方法
install_node() {
   # command_args为用户自定义的Node版本号
  local USE_NODEVER=$command_args

  echo "will install NodeJs $USE_NODEVER"

  nvm install $USE_NODEVER >/dev/null

  echo "success change to NodeJs version" $(node -v)
}

# Node环境安装
prepare() {
   if [[ -s "$NVM_DIR/nvm.sh" ]];
  then
    . "$NVM_DIR/nvm.sh"
  else
    install_nvm
  fi
  echo "nvm version $(nvm --version)"

  install_node
}
  • Docker构建、部署

Docker是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的镜像中,然后发布到任何流行的 Linux或Windows 机器上,也可以实现虚拟化。容器是完全使用沙箱机制,相互之间不会有任何接口。

# 基础镜像
FROM node:12.16.0

# 创建文件存放目录
RUN mkdir -p /home/docker-demo
WORKDIR /home/docker-demo
COPY . /home/docker-demo

# 安装依赖
RUN yarn install

# 打包,并把静态资源进行md5压缩
RUN yarn prod

# 静态资源部署CDN
RUN yarn deploy

# 端口号
EXPOSE 3000

# 项目启动命令
CMD npm start

相比较而言,Docker部署具有很大优势:

  • 构建、部署更加方便
  • 一致的运行环境「这段代码在我机器上没问题啊」
  • 弹性伸缩
  • 更高效的利用系统资源
  • 快 - 管理操作(启动,停止,开始,重启等)都是以秒或毫秒为单位

监控、告警

监控是整个产品生命周期中非常重要的一环,事前及时预警发现故障,事后提供详实的数据用于追查定位问题。

在应用出现故障时,需要有合适的工具链来支撑问题的定位修复,我们引入了开源的企业级 Node.js 应用性能监控与线上故障定位解决方案Easy-Monitor,可以更好地监控 Node.js 应用状态,来面对性能和稳定性方面的挑战。我们在内网部署了这套系统,并进行了二次开发,集成了内网域登录,并可以通过内部聊天工具推送告警信息

image.png

日志

应用上线后,一旦发生异常,第一件事情就是要弄清当时发生了什么,比如用户当时如何操作、数据如何响应等,此时日志信息就给我们提供了第一手资料。因此我们需要接入公司的日志系统

虚拟 DOM 的理解?

  • 从本质上来说,Virtual Dom是一个JS对象,通过对象的方式来表示DOM结构
  • 将页面的状态抽象为JS对象的形式,配合不同的渲染工具,使跨平台渲染成为可能
  • 通过事务处理机制,将多次DOM修改的结果一次性的更新到页面上,从而有效的减少页面渲染的次数,减少修改DOM的重绘重排次数,提高渲染性能
let oldVDOM = { // 旧虚拟DOM
        tagName: 'ul', // 标签名
        props: { // 标签属性
            id: 'list'
        },
        children: [ // 标签子节点
            {
                tagName: 'li', props: { class: 'item' }, children: ['哈哈']
            },
            {
                tagName: 'li', props: { class: 'item' }, children: ['呵呵']
            },
            {
                tagName: 'li', props: { class: 'item' }, children: ['嘿嘿']
            },
        ]
    }

diff算法如何运作?底层如何比较key?如何进行替换?如何进行移动

核心方法

  • 适配层,跨平台操作dom
  • sameVnode
  • patch
  • patchVnode
  • updateChildren

sameVnode

判断是否为同一个虚拟dom

function sameVnode (a, b) {
  return (
    a.key === b.key &&  // key值
    a.tag === b.tag &&  // 标签名
    a.isComment === b.isComment &&  // 是否为注释节点
    // 是否都定义了data,data包含一些具体信息,例如onclick , style
    isDef(a.data) === isDef(b.data) &&  
    sameInputType(a, b) // 当标签是<input>的时候,type必须相同
  )
}

patch

  • patch方法入口,比对oldVnode、newVnode,并返回 newVnode
  • 是否是虚拟dom节点,不是的话转成虚拟dom
  • 判断是否是sameVnode,是的话进入patchVnode
  • 不是删除旧节点,插入新节点

patchVnode

  • 只有是sameVnode,才会进入该方法
  • 如果新节点是文本节点,通过适配层,修改元素的text
  • 如果不是文本节点,且oldVnode有children,进入updateChildren,比对新旧节点的children
  • 如果不是文本节点,且旧节点没有children,直接将newVnode中的children插入到真实dom中

updateChildren

  • diff算法的核心方法,比对两个children
  • while循环,两组对撞指针,oldStartVnode、oldEndVnode、newStartVnode、newEndVnode
  • 两两比对,如果头头、尾尾是sameVnode,继续updateChildren(old, new)比对,
  • 如果 oldStartVnode/newEndVnode 是 sameVnode,将 oldStartVnode 移动到 oldEndVnode 之后
  • 如果 newStartVnode/oldEndVnode 是 sameVnode,将 oldEndVnode 移动到 oldStartVnode 之前
  • 如果都没有命中,循环 old children,建立一个key和索引的映射关系的map,从里头找是否存在老节点的key等于 newStartVnode,如果有,继续updateChildren,并将找到的这个节点的真实dom,插入到 oldStartVnode 之前,且设置 oldChildren 的这一项为 undefined
  • 如果没找到,说明 newStartVnode 是一个新的节点,调用createElement的方法,将dom插入到 oldStartVnode 之前
  • while循环结束,如果oldChildren还没被遍历完,全部删除
  • 如果newChildren还没被遍历完,将其全部依次插入到 newEndVnode 之前

为什么使用v-for时必须添加唯一的key?为什么不推荐使用数组索引做为key?

为什么必须添加key

  • key 的作用主要是为了更高效的更新虚拟 DOM,根据sameVnode方法,因为它可以非常精确的找到相同节点,因此 patch 过程会非常高效
  • Vue 在 patch 过程中会判断两个节点是不是相同节点时,key 是一个必要条件。比如渲染列表时,如果不写 key,Vue 在比较的时候,就可能会导致频繁更新元素,使整个 patch 过程比较低效,影响性能

为什么不推荐使用数组索引做为key?

  • 如果在数组前面或者中间插入元素,索引变了,根据sameVnode方法,原本一个不用处理的节点,全部都会执行更新操作,起不到优化的作用

Vue2 VS Vue3 Diff算法的比较

vue diff 策略

  • 传统的计算两颗树的差异时间复杂度为O(n^3),显然成本比较高(老树的每一个节点都去遍历新树的节点,直到找到新树对应的节点。那么这个流程就是 O(n^2),再紧接着找到不同之后,再计算最短修改距离然后修改节点,这里是 O(n^3)。)
  • Vue采用对树的节点进行同层比较,所以时间复杂度是O(n),比较高效

Vue Diff算法的基于什么策略

  • Web UI 中 DOM 节点跨层级的移动操作特别少,可以忽略不计 (tree-diff)
  • 拥有相同类的两个组件将会生成相似的树形结构,拥有不同类的两个组件将会生成不同的树形结(component diff)
  • 对于同一层级的一组子节点,它们可以通过唯一 id 进行区分(element-diff)

Vue Diff算法的原因以及目的

  • Vue diff算法是vue2中引入虚拟DOM的产物,它的出现是为了通过对比新旧节点计算出需要改动的最小变化。 核心思想:尽可能的复用老节点

vue3:使用最长递增子序列可以最大程度的减少 DOM 的移动,达到最少的 DOM 操作

webpack常用配置

mode

entry

output

resolve

devServer

externals

devtool

module

plugins

loader/plugin区别?以及常用loader/plugin

区别

  • 1.作用不同:

    • Loader直译为"加载器"。Webpack将一切文件视为模块,但是webpack原生是只能解析js文件,如果想将其他文件也打包的话,就会用到loader。 所以Loader的作用是让webpack拥有了加载和解析非JavaScript文件的能力。
    • Plugin直译为"插件"。Plugin可以扩展webpack的功能,让webpack具有更多的灵活性。 在 Webpack 运行的生命周期中会广播出许多事件,Plugin 可以监听这些事件,在合适的时机通过 Webpack 提供的 API 改变输出结果。
  • 2.用法不同:

    • Loader在module.rules中配置,也就是说作为模块的解析规则而存在。 类型为数组,每一项都是一个Object,里面描述了对于什么类型的文件(test),使用什么加载(loader)和使用的参数(options)
    • Plugin在plugins中单独配置。 类型为数组,每一项是一个plugin的实例,参数都通过构造函数传入。

常用loader

  • raw-loader:加载文件原始内容(utf-8)
  • file-loader:把文件输出到一个文件夹中,在代码中通过相对URL去引用输出的文件
  • url-loader:和file-loader类似,但是能在文件很小的情况下以base64的方式把文件内容注入到代码中
  • source-map-loader:加载额外的Source Map文件,以方便断点调试
  • svg-inline-loader:将压缩后的 SVG 内容注入代码中
  • image-loader:加载并且压缩图片文件
  • json-loader 加载 JSON 文件(默认包含)
  • handlebars-loader: 将 Handlebars 模版编译成函数并返回
  • babel-loader:把ES6转化成ES5
  • ts-loader: 将 TypeScript 转换成 JavaScript
  • awesome-typescript-loader:将 TypeScript 转换成 JavaScript,性能优于 ts-loader
  • css-loader:加载css,支持模块化、压缩、文件导入等特性
  • style-loader:把css代码注入到js中,通过DOM操作去加载css
  • eslint-loader:通过ESLint检查JS代码
  • tslint-loader:通过 TSLint检查 TypeScript 代码
  • postcss-loader:扩展 CSS 语法,使用下一代 CSS,可以配合 autoprefixer 插件自动补齐 CSS3 前缀
  • vue-loader:加载 Vue.js 单文件组件
  • cache-loader: 可以在一些性能开销较大的 Loader 之前添加,目的是将结果缓存到磁盘里

常用plugin

  • define-plugin:定义环境变量(Webpack4 之后指定 mode 会自动配置)
  • ignore-plugin:忽略部分文件
  • commons-chunk-plugin:提取公共代码
  • html-webpack-plugin:简化 HTML 文件创建 (依赖于 html-loader)
  • web-webpack-plugin:可方便地为单页应用输出 HTML,比 html-webpack-plugin 好用
  • uglifyjs-webpack-plugin:不支持 ES6 压缩 (Webpack4 以前)
  • terser-webpack-plugin: 支持压缩 ES6 (Webpack4)
  • mini-css-extract-plugin: 分离样式文件,CSS 提取为独立文件,支持按需加载 (替代extract-text-webpack-plugin)
  • webpack-parallel-uglify-plugin: 多进程执行代码压缩,提升构建速度
  • serviceworker-webpack-plugin:为网页应用增加离线缓存功能
  • clean-webpack-plugin: 目录清理
  • ModuleConcatenationPlugin: 开启 Scope Hoisting
  • speed-measure-webpack-plugin: 可以看到每个 Loader 和 Plugin 执行耗时 (整个打包耗时、每个 Plugin 和 Loader 耗时)
  • webpack-bundle-analyzer: 可视化 Webpack 输出文件的体积 (业务组件、依赖第三方模块)

webpack的构建流程是什么

webpack是一个打包模块化javascript的工具,它将一切文件都看作是模块。通过loader编译转换文件,通过plugin注入钩子,最后输出的资源模块组合成文件。

基本概念

  1. compiler:webpack的运行入口,实例化时定义webpack构建主要流程,同时创建时使用的核心对象compilation;

  2. compilation:由compiler实例化,存储构建过程中使用的数据,用户监控这些数据的变化,每次构建创建一个compilation实例;

  3. chunk:一般一个入口对应一个chunk;

  4. Module:用于表示代码模块的类型,有很多子类用于处理不同情况的模块,模块相关信息都可以从Module实例中获取,例如dependiencies记录模块的依赖信息;

  5. Parser:基于acorn来分析AST语法树,解析出代码模块的依赖;

  6. Dependency:解析用于保存代码块对应的依赖使用对象

  7. template:生成最终代码要用到的代码模块

基本流程

  1. 创建complier实例,用于控制构建流程,complier实例包含了webpack基本环境信息

  2. 根据配置项转换成对应的内部插件,并初始化options配置项

  3. 执行compiler.run

  4. 创建complitation实例,每次构建都会创建一个compilation实例,包含了这次构建的基本信息;

  5. 从entery开始递归分析依赖,对每个模块进行buildmodule,通过loader将不同的类型的模块转化成webpack模块

  6. 调用parser.parse将上面的结构转化成AST树,

  7. 遍历整个AST树,搜集依赖dependency,并保存在compilation的实例中

  8. 生成chunks,不同的entry,生成不同的chunks,动态导入也会生成自己的chunks,待到生成chunks后再继续优化

  9. 使用template基于compilation的数据生成结果代码

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

  1. 初始化参数,通过args将webpack.config.js和shell脚本的配置信息合并,并且初始化。

  2. 利用初始化的参数创建complier对象,complier可以视作为一个webpack的实例。存在于webpack从启动到结束的整个过程,它包含了webpack的module,plugin等参数信息,然后调用complier.run方法开始编译。

  3. 根据entry配置信息找到入口文件,创建compilation对象,可以理解为webpac的一次构建编译过程。包含了当前编译环境的所有资源。包括编译后的资源。

  4. 第四步通过配置信息,调用loader进行模块编译使用acorn将模块转化为AST树,当遇到require模块依赖时,创建依赖并加入到依赖数组,再找出依赖的依赖,递归异步的处理所有的依赖项。

  5. 得到所有模块的依赖关系和模块翻译之后的文件后,然后调用compilation.seal方法,对这些模块和根据模块依赖关系创建chunks进行整理,将所有资源进行合并拆分等操作,最后一次性修改输出内容的地方。

  6. 根据配置信息中的output配置进行最后模块文件的输出,指定输出文件名和文件路径。

原理

webpack打包输出后的文件其实就是一个闭包,传入的参数是一个对象,键值为所有输出文件的路径,内容为eval包裹的文件内容。闭包内重写了模块的加载方式,自己定义了webpack_require方法,来实现commonjs规范模块的加载机制。

webpack实际上是基于事件流,通过一系列的插件来运行,webpack利用taptable库提供的各种钩子来实现对于整个构建流程各个步骤的控制。

es module 和 commonjs 的区别

CommonJs

  • CommonJs可以动态加载语句,代码发生在运行时
  • CommonJs混合导出,还是一种语法,只不过不用声明前面对象而已,当我导出引用对象时之前的导出就被覆盖了
  • CommonJs导出值是拷贝,可以修改导出的值,这在代码出错时,不好排查引起变量污染

Es Module

  • Es Module是静态的,不可以动态加载语句,只能声明在该文件的最顶部,代码发生在编译时
  • Es Module混合导出,单个导出,默认导出,完全互不影响
  • Es Module导出是引用值之前都存在映射关系,并且值都是可读的,不能修改

动态加载的原理是啥,就是 webpack 编译出来的代码

在单页应用中,经常使用 webpack 的 动态导入 功能来异步加载模块,从而减少部分文件的体积。我们可以通过webpack 提供的 import() 和 require.ensure 两个 API 来使用该功能,两个方法根本实现都是相同的

  • 动态的创建 script 标签,以及通过 jsonp 去请求 chunk

webpack热更新怎么配置?原理?

  • 浏览器是如何更新的
  • 如何做到页面不刷新也就就自动更新的

webpack-dev-server 作用

webpack-dev-server,实际上是一个小型Express服务器,它是用webpack-dev-middleware来处理webpack编译后的输出。它是一个静态资源服务器,只用于开发环境;webpack-dev-server会把编译后的静态文件全部保存在内存里

webapck-dev-middleware 作用

是一个处理静态资源的middleware

webpack-hot-middleware 作用

是一个结合webpack-dev-middleware使用的middleware,它可以实现浏览器的无刷新更新(hot reload),这也是webpack文档里常说的HMR(Hot Module Replacement)

基础概念

  1. webpack compiler:将js编译成Bundle
  2. Bundle Server:提供文件在浏览器的访问,实际上就是一个服务器
  3. HMR Server:将热更新的文件输出给HMR Runtime
  4. HMR Runtime:会注入到bundle.js中,与HMR Server通过webSocket链接,接收文件变化,并更新对应文件
  5. bundle.js:构建输出的文件

配置

// webpack.config.js
module.exports = {
  //...
  devServer: {
    hot: true,
  },
};

原理

启动阶段

  • webpack Compiler将对应文件打包成bundle.js(包含注入的HMR Server),发送给Bundler Server
  • 浏览器即可访问服务器的方式去获取bundle.js

更新阶段(文件发生变化)

  • webpack compiler重新编译,发送给HMR Server
  • HMR Server可以知道有哪些资源、哪些模块发生了变化,通知HMR Runtime
  • HMR Runtime更新代码

HMR原理详解

使用webpack-dev-server去启动本地服务,内部实现使用了webpack、express、websocket

  • 使用express启动本地服务,当浏览器访问资源时对此响应

  • 服务端和客户端使用websocket实现长连接

  • webpack监听源文件的变化,即当开发者保存文件时触发webpack的重新编译

    • 每次编译都会生成hash值,已改动模块的json文件、已改动模块代码的js文件
    • 编译完成后通过socket向客户端推送当前编译的hash戳
  • 客户端的websocket监听到有文件改动推送过来的hash戳,会和上一次对比

    • 一致就走缓存
    • 不一致就通过ajax和jsonp向服务端获取最新资源
  • 使用内存文件系统去替换有修改的内容实现局部刷新

server端

  • 启动webpack-dev-server服务器
  • 创建webpack实例
  • 创建server服务器
  • 添加webpack的done事件回调
  • 编译完成向客户端发送消息
  • 创建express应用app
  • 设置文件系统为内存文件系
  • 添加webpack-dev-middleware中间件,中间件负责返回生成的文件
  • 启动webpack编译
  • 创建http服务器并启动服务
  • 使用sockjs在浏览器端和服务端之间建立一个websocket长连接
  • 创建socket服务器

client端

  • webpack-dev-server/client端会监听到此hash消息
  • 客户端收到ok消息后会执行reloadApp方法进行更新
  • 在reloadApp中会进行判断,是否支持热更新,如果支持的话发生 webpackHotUpdate 事件,如果不支持就直接刷新浏览器
  • 在webpack/hot/dev-server.js会监听webpackHotUpdate事件
  • 在check方法里会调用module.hot.check方法
  • HotModuleReplacement.runtime 请求 Manifest
  • 通过调用 JsonpMainTemplate.runtime 的 hotDownloadManifest 方法
  • 调用 JsonpMainTemplate.runtime 的 hotDownloadUpdateChunk 方法通过JSONP请求获取最新的模块代码
  • 补丁js取回来或会调用 JsonpMainTemplate.runtime.js 的 webpackHotUpdate 方法
  • 然后会调用 HotModuleReplacement.runtime.js 的 hotAddUpdateChunk 方法动态更新模块代码
  • 然后调用 hotApply 方法进行热更新

说一下关于tree-shaking的原理

当前端项目到达一定的规模后,我们一般会采用按模块方式组织代码,这样可以方便代码的组织及维护。但会存在一个问题,比如我们有一个utils工具类,在另一个模块中导入它。这会在打包的时候将utils中不必要的代码也打包,从而使得打包体积变大,这时候就需要用到Tree shaking技术了

tree-shaking 是一种通过清除多余代码方式来优化项目打包体积的技术

原理

  • 利用ES6模块的特点
    • 只能作为模块顶层的语句出现
    • import的模块名只能是字符串常量,不能动态引入模块
    • import 引入的模块不能再进行修改的 虽然tree-shaking的概念在1990年就提出来了,但是直到ES6的ES6-style模块出现后才真正被利用起来。这是因为tree-shaking只能在静态模块下工作。ES6模块加载是静态的,因此在ES6种使用tree-shaking是非常容易地。而且,tree-shaking不仅支持import/export级别,而且也支持声明级别

在ES6以前,我们可以使用CommonJS引入模块:require(),这种引入是动态地,也意味着我们可以基于条件来导入需要的代码:

let mainModule;
//动态导入
if(condition){
    mainModule=require('dog')
}else{
    mainModule=require('cat')
}

CommonJS的动态特性意味着tree-shaking不适用。因为它是不可能确定哪些模块实际运行之前是需要的或者是不需要的。在ES6中,进入了完全静态的导入语法:import。

//不可行
if(condition){
    mainModule=require('dog')
}else{
    mainModule=require('cat')
}

只能通过导入所有的包后再进行条件获取

import dog from 'dog';
import cat from 'cat';
if(condition){
//dog.xxx
}else{
//cat.xxx
}

ES6的import语法可以使用tree-shaking,因为可以在代码不运行的情况下就能分析出不需要的代码。

如何使用?

从webpack2开始支持实现了tree-shaking特性,webpack2正式版本内置支持ES6的模块(也叫harmony模块)和未引用模块检测能力。webpack4正式版本扩展了这个检测能力,通过package.jsonsideEffects属性作为标记,向complier提供提示,表明项目中哪些文件是ES6模块,由此可以安全地删除文件中未使用地部分 如果使用的是webpack4,只需要将mode设置为production,就可以开启tree-shaking

entry:'./src/index.js',
mode:'production',
output:{
    path:path.resolve(__dirname,'dist'),
    filename:'bundle.js'
},

如果使用webpack2,可能你会发现tree-shaking不起作用。因为babel会将代码编译成CommonJS模块,而tree-shaking不支持CommonJS,所以需要配置不转义

options:{presets:[['es2015',{modules:false}]]}

关于副作用

副作用是指那些当import的时候会执行一些动作,但是不一定会有任何export。比如ployfill,ployfills不对外暴露方法给主程序使用

tree-shaking不能自动识别哪些代码属于副作用,因此手动指定这些代码显得非常重要,如果不指定可能会出现一些意想不到的问题

在webpack中,是通过package.json的sideEffects属性来实现的

"name":"tree-shaking",
"sideEffects":false

如果所有的代码都不包含副作用,我们就可以简单地将该属性标记为false来告知webpack,它可以安全地删除未用到的export导出。

如果你的代码确实有一些副作用,那么可以改为提供一个数组:

"name":"tree-shaking",
"sideEffects":[
    "./src/public/polyfill.js"
]

总结

  • tree-shaking不会支持动态导入(如CommonJS的require()语法),只纯静态的导入(ES6的import/export)
  • webpack中可以在项目package.json文件中,添加一个"sideEffects"属性,手动指定副作用的脚本

说一下webpack的一些plugin,怎么使用webpack对项目进行优化

构建优化

  1. 减少编译体积 ContextReplacementPugin、IgnorePlugin、babel-plugin-import、babel-plugin-transform-runtime。
  2. 并行编译 happypack、thread-loader、uglifyjsWebpackPlugin开启并行
  3. 缓存 cache-loader、hard-source-webpack-plugin、uglifyjsWebpackPlugin开启缓存、babel-loader开启缓存
  4. 预编译 dllWebpackPlugin && DllReferencePlugin、auto-dll-webapck-plugin

性能优化

  1. 减少编译体积 Tree-shaking、Scope Hositing。
  2. hash缓存 webpack-md5-plugin
  3. 拆包 splitChunksPlugin、import()、require.ensure

如何利用webpack来优化前端性能 #

压缩js

压缩css

压缩图片

清除无用的CSS

Tree Shaking

Scope Hoisting

  • Scope Hoisting 可以让 Webpack 打包出来的代码文件更小、运行的更快, 它又译作 "作用域提升",是在 Webpack3 中新推出的功能。
  • scope hoisting的原理是将所有的模块按照引用顺序放在一个函数作用域里,然后适当地重命名一些变量以防止命名冲突
  • 这个功能在mode为production下默认开启,开发环境要用 webpack.optimize.ModuleConcatenationPlugin插件

代码分割

CDN

如何提高webpack的构建速度

费时分析

const SpeedMeasureWebpackPlugin = require('speed-measure-webpack-plugin');
const smw = new SpeedMeasureWebpackPlugin();
module.exports =smw.wrap({});

缩小范围

  • extensions:指定extension之后可以不用在require或是import的时候加文件扩展名,会依次尝试添加扩展名进行匹配
  • alias:配置别名可以加快webpack查找模块的速度
  • modules
  • mainFields`:默认情况下package.json 文件则按照文件中 main 字段的文件名来查找文件
  • mainFiles:当目录下没有 package.json 文件时,我们说会默认使用目录下的 index.js 这个文件,其实这个也是可以配置的
  • resolveLoaderresolve.resolveLoader用于配置解析 loader 时的 resolve 配置,默认的配置

noParse

  • module.noParse 字段,可以用于配置哪些模块文件的内容不需要进行解析
  • 不需要解析依赖(即无依赖) 的第三方大型类库等,可以通过这个字段来配置,以提高整体的构建速度

IgnorePlugin

  • IgnorePlugin用于忽略某些特定的模块,让 webpack 不把这些指定的模块打包进去
import moment from  'moment';
console.log(moment);
new webpack.IgnorePlugin(/^./locale/,/moment$/)
  • 第一个是匹配引入模块路径的正则表达式
  • 第二个是匹配模块的对应上下文,即所在目录名

日志优化

DLL

当需要导入的模块在动态连接库里的时候,模块不能再次被打包,而是去动态连接库里获取

利用缓存

  • babel-loader开启缓存:Babel在转义js文件过程中消耗性能较高,将babel-loader执行的结果缓存起来,当重新打包构建时会尝试读取缓存,从而提高打包构建速度、降低消耗
  • 使用cache-loader:在一些性能开销较大的 loader 之前添加此 loader,以将结果缓存到磁盘,存和读取这些缓存文件会有一些时间开销,所以请只对性能开销较大的 loader 使用此 loader
  • 使用hard-source-webpack-plugin:为模块提供了中间缓存,缓存默认的存放路径是 node_modules/.cache/hard-source,配置 hard-source-webpack-plugin 后,首次构建时间并不会有太大的变化,但是从第二次开始,构建时间大约可以减少 80%左右,webpack5中会内置hard-source-webpack-plugin
  • oneOf:每个文件对于rules中的所有规则都会遍历一遍,如果使用oneOf就可以解决该问题,只要能匹配一个即可退出。(注意:在oneOf中不能两个配置处理同一种类型文件)

多进程

  • thread-loader:把这个 loader 放置在其他 loader 之前, 放置在这个 loader 之后的 loader 就会在一个单独的 worker 池(worker pool)中运行
  • terser-webpack-plugin 开启多进程

webpack打包出来的体积太大,如何优化体积?

如何对bundle体积进行监控和分析?
const {BundleAnalyzerPlugin} = require('webpack-bundle-analyzer')
module.exports={
  plugins: [
    new BundleAnalyzerPlugin()  // 使用默认配置
    // 默认配置的具体配置项
    // new BundleAnalyzerPlugin({
    //   analyzerMode: 'server',
    //   analyzerHost: '127.0.0.1',
    //   analyzerPort: '8888',
    //   reportFilename: 'report.html',
    //   defaultSizes: 'parsed',
    //   openAnalyzer: true,
    //   generateStatsFile: false,
    //   statsFilename: 'stats.json',
    //   statsOptions: null,
    //   excludeAssets: null,
    //   logLevel: info
    // })
  ]
}
{
 "scripts": {
    "dev": "webpack --config webpack.dev.js --progress"
  }
}

压缩代码 & 多进程并行压缩

  • webpack-paralle-uglify-plugin
  • uglifyjs-webpack-plugin 开启parallel参数(不支持ES6)
  • terser-webpack-plugin 开启paraller参数(推荐使用这个,支持 ES6 语法压缩)
  • 通过 mini-css-extract-plugin 提取Chunk中的CSS代码到单独文件
  • 通过 optimize-css-assets-webpack-plugin 插件,开启cssnano 压缩css
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
    optimization: {
        minimizer: [
            new TerserPlugin({
                parallel: true,
            }),
        ],
    },
};

提取页面公共资源

  • 使用 html-webpack-externals-plugin,将基础包通过CDN引入,不打入bundle中
  • 使用SplitChunksPlugin进行(公共脚本、基础包、页面公共文件)分离(webpack4内置),替代了 CommonsChunkPlugin 插件
  • 基础包分离:将一些基础库放到cdn,比如vue,webpack配置 external 是 vue 的不打入 bundle

Tree shaking

  • purgecss-webpack-plugin 和 mini-css-extract-plugin 配合使用(仅仅是建议)
  • 打包过程中检测工程中没有引用过的模块并进行标记,在资源压缩时将它们从最终的bundle中去掉(只能对ES6 Module生效)开发中尽可能使用 ES6 Module 的模块,提高tree shaking的效率
  • 禁用babel-loader的模块依赖解析,否则 webpack 接收到的就是转换过的commonJS形式的模块,无法进行tree shaking
  • 使用 PurifyCSS(不在维护)或者uncss去除无用css代码

Scope hoisting

  • 构建后的代码会存在大量闭包,造成体积增大,运行代码时创建的函数作用域变多,内存开销变大。Scope hoisting 将所有模块的代码按照引用顺序放在一个函数作用域里,然后适当的重命名一些变量以防止变量名冲突

  • 必须是ES6的语法,因为有很多第三方库仍采用CommonJS 语法,为了充分发挥Scope hoisting 的作用,需要配置 mainFields 对第三方模块优先采用 jsnext:main 中指向的ES6模块化语法

图片压缩

  • 使用基于node库的imagemin(很多定制选项、可以处理多种图片格式)
  • 配置 image-webpack-loader

动态Ployfill

动态 polyfill 指的是根据不同的浏览器,动态载入需要的 polyfillPolyfill.io 通过尝试使用 polyfill 重新创建缺少的功能,可以更轻松地支持不同的浏览器,并且可以大幅度的减少构建体积。

  • 建议采用 polyfill-service 只给用户返回需要的polyfill,社区维护
  • @babel-preset-env中通过useBuiltIns:"usage" 参数来动态加载polyfill

借助工具分析性能瓶颈

  • speed-measure-webpack-plugin,简称SMP,分析出 webpack 打包过程中Loader和Plugin的耗时,有助于找到构建过程中的性能瓶颈。

source map是什么?生产环境怎么用?

  • sourcemap是为了解决开发代码与实际运行代码不一致时帮助我们debug到原始开发代码的技术
  • webpack通过配置可以自动给我们source maps文件,map文件是一种对应编译文件和源文件的方法

source map 的类型

  • source-map:原始代码 最好的sourcemap质量有完整的结果,但是会很慢
  • eval-source-map:原始代码 同样道理,但是最高的质量和最低的性能
  • cheap-module-eval-source-map:原始代码(只有行内) 同样道理,但是更高的质量和更低的性能
  • cheap-eval-source-map:转换代码(行内)每个模块被eval执行,并且sourcemap作为eval的一个dataurl
  • eval:生成代码 每个模块都被eval执行,并且存在@sourceURL,带eval的构建模式能cache SourceMap
  • cheap-source-map:转换代码(行内)生成的sourcemap没有列映射,从loaders生成的sourcemap没有被使用

看似配置项很多, 其实只是五个关键字eval、source-map、cheap、module和inline的任意组合

关键字含义
eval使用eval包裹模块代码
source-map产生.map文件
cheap不包含列信息(关于列信息的解释下面会有详细介绍)也不包含loader的sourcemap
module包含loader的sourcemap(比如jsx to js ,babel的sourcemap),否则无法定义源文件
inline将.map作为DataURI嵌入,不单独生成.map文件
  • eval eval执行
  • eval-source-map 生成sourcemap
  • cheap-module-eval-source-map 不包含列
  • cheap-eval-source-map 无法看到真正的源码

如何选择source map的类型

  • 首先在源代码的列信息是没有意义的,只要有行信息就能完整的建立打包前后代码之间的依赖关系。因此,不管是开发还是生产环境都会增加cheap属性来忽略模块打包后的列信息关联
  • 不管是生产环境还是开发环境,我们都需要定位debug到最原始的资源,比如定位错误到jsx,ts的原始代码,而不是经编译后的js代码。所以不可以忽略掉module属性
  • 需要生成.map文件,所以得有source-map属性
  • 开发环境使用:cheap-module-eval-source-map
  • 生产环境使用:cheap-module-source-map

手写plugin、手写loader、plugin 和 loader 的顺序

插件实现

  • webpack本质是一个事件流机制,核心模块:tapable(Sync + Async)Hooks 构造出 === Compiler(编译) + Compilation(创建bundles)
  • compiler对象代表了完整的webpack环境配置。这个对象在启动webpack时被一次性建立,并配置好所有可操作的设置,包括options、loader和plugin。当在webpack环境中应用一个插件时,插件将收到此compiler对象的引用。可以使用它来访问webpack的主环境
  • compilation对象代表了一次资源版本构建。当运行webpack开发环境中间件时,每当检测到一个文件变化,就会创建一个新的compilation,从而生成一个新的编译资源。一个compilation对象表现了当前的模块资源、编译生成资源、变化的文件、以及被跟踪依赖的状态的信息。compilation对象也提供了很多关键时机的回调,以供插件做自定义处理时选择使用
  • 创建一个插件函数,在其prototype上定义apply方法,指定一个webpack自身的事件钩子
  • 函数内部处理webpack内部实例的特定数据
  • 处理完成后,调用webpack提供的回调函数
function MyWebpackPlugin(){
    //
};

// prototype上定义apply方法
MyWebpackPlugin.prototype.apply = function(){
    // 指定一个事件函数挂载到webpack
    compiler.plugin("webpacksEventHook",funcion (compiler){
        console.log("这是一个插件");
        //功能完成调用后webpack提供的回调函数
        callback()
    })
}

DLLPlugin原理,为什么不直接使用压缩版本的js

通常来说,我们的代码都可以至少简单区分成业务代码第三方库。如果不做处理,每次构建时都需要把所有的代码重新构建一次,耗费大量的时间。然后大部分情况下,很多第三方库的代码并不会发生变更(除非是版本升级),这时就可以用到dll:把复用性较高的第三方模块打包到动态链接库中,在不升级这些库的情况下,动态库不需要重新打包,每次构建只重新打包业务代码

  1. 使用DLLPlugin打包需要分离到动态库的模块
  2. 在主构建配置文件使用动态库文件
  3. 在入口文件引入dll文件
<body>
  <div id="app"></div>
  <!--引用dll文件-->
  <script src="../../dist/dll/react.dll.js" ></script>
</body>

作用

首先从前面的介绍,至少可以看出dll的两个作用

  • 分离代码,业务代码和第三方模块可以被打包到不同的文件里,这个有几个好处:
    • 避免打包出单个文件的大小太大,不利于调试
    • 将单个大文件拆成多个小文件之后,一定情况下有利于加载(不超出浏览器一次性请求的文件数情况下,并行下载肯定比串行快)
  • 提升构建速度。第三方库没有变更时,由于我们只构建业务相关代码,相比全部重新构建自然要快的多。

原理

  • xx.manifest.json
  • name指的是对应的dll库名字
  • 描述了哪些模块被打进来dll了, 用模块名当id标识出来 大概是一个模块清单
  • xxx.dll.js 就是模块的源码了
  • xxx.dll.js 是各个模块的源码集合 通过key(模块id)–> value查询出来

ast

babel & babel插件