网络知识 娱乐 较容易理解的webpack性能优化

较容易理解的webpack性能优化

优化环境配置
1-webpack性能优化
开发环境性能优化
生产环境性能优化
开发环境性能优化
优化打包构建速度
HMR
优化代码调试
source-map
生产环境性能优化
优化打包构建速度
oneOf
babel缓存
多进程打包
externals
dll
优化代码运行的性能
缓存(hash-chunkhash-contenthash)
tree shaking
code split
懒加载/预加载
pwa
01-HMR 热模块替换
基于开发环境的devserver(开发环境没有hmr功能)

HMR: hot module replacement 热模块替换 / 模块热替换

作用:一个模块发生变化,只会重新打包这一个模块(而不是打包所有模块) 极大提升构建速度

样式文件:可以使用HMR功能:因为style-loader内部实现了~

js文件:默认不能使用HMR功能–>需要修改js代码,添加支持HMR功能的代码。

注意:HMR功能对js的处理,只能处理非入口js文件的其他文件。

html文件: 默认不能使用HMR功能.同时会导致问题:html文件不能热更新了~ (不用做HMR功能)

解决:修改entry入口,将html文件引入

entry: ['./src/js/index.js', './src/index.html'],
  ...  
  devServer: {
    static: resolve(__dirname, 'build'),
    compress: true,
    port: 3000,
    open: true,
    // 开启HMR功能
    // 当修改了webpack配置,新配置要想生效,必须重新webpack服务
    hot: true
  }
  
  
在需要热模块替换的非入口js文件中协商如下代码,可实现该方法的热模块替换
if (module.hot) {
  // 一旦 module.hot 为true,说明开启了HMR功能。 --> 让HMR功能代码生效
  module.hot.accept('./print.js', function() {
    // 方法会监听 print.js 文件的变化,一旦发生变化,其他模块不会重新打包构建。
    // 会执行后面的回调函数
    print();
  });
}

复制代码

02-source-map
(开发环境) source-map: 一种 提供源代码到构建后代码映射 技术 (如果构建后代码出错了,通过映射可以追踪源代码错误)

//在webpack.config.js中加
devtool: '...'

    <!--[inline-|hidden-|eval-][nosources-][cheap-[module-]]source-map-->

    <!--source-map:外部-->
    <!--  错误代码准确信息 和 源代码的错误位置-->
    <!--inline-source-map:内联-->
    <!--  只生成一个内联source-map-->
    <!--  错误代码准确信息 和 源代码的错误位置-->
    <!--hidden-source-map:外部-->
    <!--  错误代码错误原因,但是没有错误位置-->
    <!--  不能追踪源代码错误,只能提示到构建后代码的错误位置-->
    <!--eval-source-map:内联-->
    <!--  每一个文件都生成对应的source-map,都在eval-->
    <!--  错误代码准确信息 和 源代码的错误位置-->
    <!--nosources-source-map:外部-->
    <!--  错误代码准确信息, 但是没有任何源代码信息-->
    <!--cheap-source-map:外部-->
    <!--  错误代码准确信息 和 源代码的错误位置 -->
    <!--  只能精确的行-->
    <!--cheap-module-source-map:外部-->
    <!--  错误代码准确信息 和 源代码的错误位置 -->
    <!--  module会将loader的source map加入-->

    <!--内联 和 外部的区别:1. 外部生成了文件,内联没有 2. 内联构建速度更快-->

    <!--开发环境:速度快,调试更友好-->
    <!--  速度快(eval>inline>cheap>...)-->
    <!--    eval-cheap-souce-map-->
    <!--    eval-source-map-->
    <!--  调试更友好  -->
    <!--    souce-map-->
    <!--    cheap-module-souce-map-->
    <!--    cheap-souce-map-->

    <!--  --> eval-source-map  / eval-cheap-module-souce-map-->

    <!--生产环境:源代码要不要隐藏? 调试要不要更友好-->
    <!--  内联会让代码体积变大,所以在生产环境不用内联-->
    <!--  nosources-source-map 全部隐藏-->
    <!--  hidden-source-map 只隐藏源代码,会提示构建后代码错误信息-->

    <!--  --> source-map / cheap-module-souce-map-->
复制代码

03-oneof
优化生产环境打包构建速度的

以下loader只会匹配一个

注意:不能有两个配置处理同一种类型文件

oneof使一个文件只会匹配一个loader,否则会进行多次匹配。不能有两个配置处理同一种类型文件。因为eslint-loader和babel-loader都匹配js文件,所以要把一个loader放在oneof外面。

 module: {
    rules: [
      {
        // 在package.json中eslintConfig --> airbnb
        test: /.js$/,
        exclude: /node_modules/,
        // 优先执行
        enforce: 'pre',
        loader: 'eslint-loader',
        options: {
          fix: true
        }
      },
      {
        // 以下loader只会匹配一个
        // 注意:不能有两个配置处理同一种类型文件
        oneOf: [
          {
            test: /.css$/,
            use: [...commonCssLoader]
          },
          {
            test: /.less$/,
            use: [...commonCssLoader, 'less-loader']
          },
          /*
            正常来讲,一个文件只能被一个loader处理。
            当一个文件要被多个loader处理,那么一定要指定loader执行的先后顺序:
              先执行eslint 在执行babel
          */
          {
            test: /.js$/,
            exclude: /node_modules/,
            loader: 'babel-loader',
            options: {
              presets: [
                [
                  '@babel/preset-env',
                  {
                    useBuiltIns: 'usage',
                    corejs: {version: 3},
                    targets: {
                      chrome: '60',
                      firefox: '50'
                    }
                  }
                ]
              ]
            }
          },
          {
            exclude: /.(js|css|less|html|jpg|png|gif)/,
            loader: 'file-loader',
            options: {
              outputPath: 'media'
            }
          }
        ]
      }
    ]
  },
复制代码

04-缓存
类似开发环境的hmr 缓存:

babel缓存cacheDirectory: true–> 让第二次打包构建速度更快
文件资源缓存
hash: 每次wepack构建时会生成一个唯一的hash值。
问题: 因为js和css同时使用一个hash值。 如果重新打包,会导致所有缓存失效。(可能我却只改动一个文件)
chunkhash:根据chunk生成的hash值。如果打包来源于同一个chunk,那么hash值就一样
问题: js和css的hash值还是一样的 因为css是在js中被引入的,所以同属于一个chunk
contenthash:根据文件的内容生成hash值。不同文件hash值一定不一样 --> 让代码上线运行缓存更好使用。

//开启babel缓存
{
    test: /.js$/,
    exclude: /node_modules/,
    loader: 'babel-loader',
    options: {
      presets: [
        [
          '@babel/preset-env',
          {
            useBuiltIns: 'usage',
            corejs: { version: 3 },
            targets: {
              chrome: '60',
              firefox: '50'
            }
          }
        ]
      ],
      // 开启babel缓存
      // 第二次构建时,会读取之前的缓存
      cacheDirectory: true
      }
},

//文件资源缓存
  output: {
    filename: 'js/built.[contenthash:10].js',
    path: resolve(__dirname, 'build')
  },
复制代码

05-tree-shaking
tree shaking:去除无用代码

前提:1. 必须使用ES6模块化 2. 开启production环境
作用: 减少代码体积
在package.json中配置

 "sideEffects": false 所有代码都没有副作用(都可以进行tree shaking)
    问题:可能会把css / @babel/polyfill (副作用)文件干掉
  "sideEffects": ["*.css", "*.less"]
复制代码

06-代码分割
入口几个文件 出口就几个文件

 entry: {
    // 多入口:有一个入口,最终输出就有一个bundle
    index: './src/js/index.js',
    test: './src/js/test.js'
  },
 output: {
    // [name]:取文件名
    filename: 'js/[name].[contenthash:10].js',
    path: resolve(__dirname, 'build')
  },
复制代码

可以将node_modules中代码单独打包一个chunk最终输出

 /*
    1. 可以将node_modules中代码单独打包一个chunk最终输出
  */
  optimization: {
    splitChunks: {
      chunks: 'all'
    }
  },
复制代码

单入口

//在某一js文件中写如下配置实现输出多文件
/*
  通过js代码,让某个文件被单独打包成一个chunk
  import动态导入语法:能将某个文件单独打包
*/
import(/* webpackChunkName: 'test' */'./test')
  .then(({ mul, count }) => {
    // 文件加载成功~
    // eslint-disable-next-line
    console.log(mul(2, 5));
  })
  .catch(() => {
    // eslint-disable-next-line
    console.log('文件加载失败~');
  });
    
复制代码

07-懒加载
懒加载:当文件需要使用时才加载 预加载 prefetch:会在使用之前,提前加载js文件 正常加载可以认为是并行加载(同一时间加载多个文件)
预加载 prefetch:等其他资源加载完毕,浏览器空闲了,再偷偷加载资源(预加载兼容问题有点严重)

console.log(‘index.js文件被加载了~’);

// import { mul } from './test';

document.getElementById('btn').onclick = function() {
  // 懒加载~:当文件需要使用时才加载~
  // 预加载 prefetch:会在使用之前,提前加载js文件 
  // 正常加载可以认为是并行加载(同一时间加载多个文件)  
  // 预加载 prefetch:等其他资源加载完毕,浏览器空闲了,再偷偷加载资源
  import(/* webpackChunkName: 'test', webpackPrefetch: true */'./test').then(({ mul }) => {
    console.log(mul(4, 5));
  });
};

复制代码

08-PWA渐进式网络开发应用程序 (离线可访问)
workbox ---->workbox-wepack-plugin

webpack.config.js

plugins: [
    new WorkboxWebpackPlugin.GenerateSW({
      /*
        1. 帮助serviceworker快速启动
        2. 删除旧的 serviceworker

        生成一个 serviceworker 配置文件~
      */
      clientsClaim: true,
      skipWaiting: true
    })
  ],

复制代码

入口js文件配置

/*
  1. eslint不认识 window、navigator全局变量
    解决:需要修改package.json中eslintConfig配置
      "env": {
        "browser": true // 支持浏览器端全局变量
      }
   2. sw代码必须运行在服务器上
      --> nodejs
      -->
        npm i serve -g
        serve -s build 启动服务器,将build目录下所有资源作为静态资源暴露出去
*/
// 注册serviceWorker
// 处理兼容性问题
if ('serviceWorker' in navigator) {
  window.addEventListener('load', () => {
    navigator.serviceWorker
      .register('/service-worker.js')
      .then(() => {
        console.log('sw注册成功了~');
      })
      .catch(() => {
        console.log('sw注册失败了~');
      });
  });
}
复制代码

09-多进程打包
下载thread-loader,对谁进行多进程打包就把thread-loader配置在它的后面

开启多进程打包。 (有利有弊) 进程启动大概为600ms,进程通信也有开销。 只有工作消耗时间比较长,才需要多进程打包

  {
    test: /.js$/,
    exclude: /node_modules/,   
    use: [
      /* 
        开启多进程打包。 
        进程启动大概为600ms,进程通信也有开销。
        只有工作消耗时间比较长,才需要多进程打包
      */
      {
        loader: 'thread-loader',
        options: {
          workers: 2 // 进程2个
        }
      },
      {
        loader: 'babel-loader',
        options: {
          presets: [
            [
              '@babel/preset-env',
              {
                useBuiltIns: 'usage',
                corejs: { version: 3 },
                targets: {
                  chrome: '60',
                  firefox: '50'
                }
              }
            ]
          ],
          // 开启babel缓存
          // 第二次构建时,会读取之前的缓存
          cacheDirectory: true
        }
      }
    ]
  },
复制代码

10-externals
有些包需要用cdn引入进来,防止将这些包打包进来,使用externals忽略这个包名。

  mode: 'production',
  externals: {
    // 拒绝jQuery被打包进来
    jquery: 'jQuery'
  }
复制代码
<script src="https://cdn.bootcss.com/jquery/1.12.4/jquery.min.js"></script>
复制代码

11-dll
对代码单独打包

新建webpack.dll.js

/*
  使用dll技术,对某些库(第三方库:jquery、react、vue...)进行单独打包
    当你运行 webpack 时,默认查找 webpack.config.js 配置文件
    需求:需要运行 webpack.dll.js 文件
      --> webpack --config webpack.dll.js
*/

const { resolve } = require('path');
const webpack = require('webpack');

module.exports = {
  entry: {
    // 最终打包生成的[name] --> jquery
    // ['jquery'] --> 要打包的库是jquery
    jquery: ['jquery'],
  },
  output: {
    filename: '[name].js',
    path: resolve(__dirname, 'dll'),
    library: '[name]_[hash]' // 打包的库里面向外暴露出去的内容叫什么名字
  },
  plugins: [
    // 打包生成一个 manifest.json --> 提供和jquery映射
    new webpack.DllPlugin({
      name: '[name]_[hash]', // 映射库的暴露的内容名称
      path: resolve(__dirname, 'dll/manifest.json') // 输出文件路径
    })
  ],
  mode: 'production'
};

复制代码

webpack.config.js

const { resolve } = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');
const AddAssetHtmlWebpackPlugin = require('add-asset-html-webpack-plugin');

module.exports = {
  entry: './src/index.js',
  output: {
    filename: 'built.js',
    path: resolve(__dirname, 'build')
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html'
    }),
    // 告诉webpack哪些库不参与打包,同时使用时的名称也得变~
    new webpack.DllReferencePlugin({
      manifest: resolve(__dirname, 'dll/manifest.json')
    }),
    // 将某个文件打包输出去,并在html中自动引入该资源
    new AddAssetHtmlWebpackPlugin({
      filepath: resolve(__dirname, 'dll/jquery.js')
    })
  ],
  mode: 'production'
};

我们首先封装一个响应式处理的方法 defineReactive,通过 defineProperty 这个方法重新定义对象属性的 get 和 set 描述符,来实现对数据的劫持,每次 读取数据 的时候都会触发 get ,每次 更新数据 的时候都会触发 set ,所以我们可以在 set 中触发更新视图的方法 update 来实现一个基本的响应式处理。

/**
 * @param {*} obj  目标对象
 * @param {*} key  目标对象的一个属性
 * @param {*} val  目标对象的一个属性的初始值
 */
function defineReactive(obj, key, val) {
  // 通过该方法拦截数据
  Object.defineProperty(obj, key, {
    // 读取数据的时候会走这里
    get() {
      console.log('🚀🚀~ get:', key);
      return val
    },
    // 更新数据的时候会走这里
    set(newVal) {
      // 只有当新值和旧值不同的时候 才会触发重新赋值操作
      if (newVal !== val) {
        console.log('🚀🚀~ set:', key);
        val = newVal
        // 这里是触发视图更新的地方
        update()
      }
    }
  })
}
复制代码

我们写点代码来测试一下,每 1s 修改一次 obj.foo 的值 , 并定义一个 update 方法来修改 app 节点的内容。

// html
<div id='app'>123</div>

// js
// 劫持 obj.foo 属性
const obj = {}
defineReactive(obj, 'foo', '')

// 给 obj.foo 一个初始值
obj.foo = new Date().toLocaleTimeString()

// 定时器修改 obj.foo
setInterval(() => {
  obj.foo = new Date().toLocaleTimeString()
}, 1000)

// 更新视图
function update() {
  app.innerHTML = obj.foo
}
复制代码

可以看到,每次修改 obj.foo 的时候,都会触发我们定义的 get 和 set ,并调用 update 方法更新了视图,到这里,一个最简单的响应式处理就完成了。

处理深层次的嵌套
一个对象通常情况下不止一个属性,所以当我们要给每个属性添加响应式的时候,就需要遍历这个对象的所有属性,给每个 key 调用 defineReactive 进行处理。

/**
 * @param {*} obj  目标对象
 */
function observe(obj) {
  // 先判断类型, 响应式处理的目标一定要是个对象类型
  if (typeof obj !== 'object' || obj === null) {
    return
  }
  // 遍历 obj, 对 obj 的每个属性进行响应式处理
  Object.keys(obj).forEach(key => {
    defineReactive(obj, key, obj[key])
  })
}
// 定义对象 obj
const obj = {
  foo: 'foo',
  bar: 'bar',
  friend: {
    name: 'aa'
  }
}

// 访问 obj 的属性 , foo 和 bar 都被劫持到,就不在浏览器演示了。
obj.bar = 'barrrrrrrr' // => 🚀🚀~ set: bar
obj.foo = 'fooooooooo' // => 🚀🚀~ set: foo

// 访问 obj 的属性 obj.friend.name 
obj.friend.name = 'bb' // => 🚀🚀~ get: friend
复制代码

当我们访问 obj.friend.name 的时候,也只是打印出来 get: friend ,而不是 friend.name , 所以我们还要进行个 递归,把 深层次的属性 同样也做响应式处理。

function defineReactive(obj, key, val) {
  // 递归
  observe(val)
  
  // 继续执行 Object.defineProperty...
  Object.defineProperty(obj, key, {
    ... ...
  })
}

// 再次访问 obj.friend.name
obj.friend.name = 'bb' // => 🚀🚀~ set: name
复制代码

递归的时机在 defineReactive 这个方法中,如果 value 是对象就进行递归,如果不是对象直接返回,继续执行下面的代码,保证 obj 中嵌套的属性都进行响应式的处理,所以当我们再次访问 obj.friend.name 的时候,就打印出了 set: name 。

处理直接赋值一个对象
上面已经实现了对深层属性的响应式处理,那么如果我直接给属性赋值一个对象呢?

const obj = {
  friend: {
    name: 'aa'
  }
}
obj.friend = {           // => 🚀🚀~ set: friend
  name: 'bb'
}
obj.friend.name = 'cc'   // => 🚀🚀~ get: friend
复制代码

这种赋值方式还是只打印出了 get: friend ,并没有劫持到 obj.friend.name ,那怎么办呢?我们只需要在 触发 set 的时候,判断一下 value 的类型,如果它是个对象类型,我们就对他执行 observe 方法。

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    ... ...
    set(newVal) {
      // 只有当新值和旧值不同的时候 才会触发重新赋值操作
      if (newVal !== val) {
        console.log('🚀🚀~ set:', key);
        // 如果 newVal 是个对象类型,再次做响应式处理。
        if (typeof obj === 'object' && obj !== null) {
          observe(newVal)
        }
        val = newVal
      }
    }
  })
}
// 再次给 obj.friend 赋值一个对象
obj.friend = {
  name: 'bb'
}
// 再次访问 obj.friend.name , 这个时候就成功的劫持到了 name 属性
obj.friend.name = 'cc'  //=> 🚀~ set: name
复制代码

处理新添加一个属性
上面的例子都是操作 已经存在 的属性,那么如果我们 新添加 一个属性呢?

const obj = {}
obj.age = 18
obj.age = 20
复制代码

当我们试图修改 obj.age 的时候,什么都没有打印出来,说明并没有对 obj.age 进行响应式处理。这里也非常好理解,因为新增加的属性并没有经过 defineReactive 的处理,所以我们就需要一个方法来手动处理新添加属性这种情况。

/**
 * @param {*} obj  目标对象
 * @param {*} key  目标对象的一个属性
 * @param {*} val  目标对象的一个属性的初始值
 */
function $set(obj, key, val) {
  // vue 中在这进行了很多判断,val 是对象还是数组等等,我们就从简了
  defineReactive(obj, key, val)
}

// 调用 $set 方法给 obj 添加新的属性
$set(obj, 'age', 18)

// 再次访问 obj.age 
obj.age = 20 //=> 🚀🚀~ set: age
复制代码

新定义的 $set 方法,内部也是把目标属性进行了 defineReactive 处理,这时我们再次更新 obj.age 的时候,就打印出了 set: age , 也就实现了一个响应式的处理。

VUE中的数据响应式
实现简易的Vue
这是 Vue 中最基本的使用方式,创建一个 Vue 的实例,然后就可以在模板中使用 data 中定义的响应式数据了,今天我们就来完成一个简易版的 Vue 。

<div id='app'>
  <p>{{counter}}</p>
  <p>{{counter}}</p>
  <p>{{counter}}</p>
  <p my-text='counter'></p>
  <p my-html='desc'></p>
  <button @click='add'>点击增加</button>
  <p>{{name}}</p>
  <input type="text" my-model='name'>
</div>

<script>
  const app = new MyVue({
    el: "#app",
    data: {
      counter: 1,
      desc: `<span style='color:red' >一尾流莺</span>`
    },
    methods: {
      add() {
        this.counter++
      }
    }
  })
</script>
复制代码

原理

在这里插入图片描述

设计类型介绍
MyVue: 框架构造函数
Observer:执行数据响应化(区分数据是对象还是数组)
Compile:编译模板,初始化视图,收集依赖(更新函数,创建 watcher)
Watcher:执行更新函数(更新 dom )
Dep:管理多个 Watcher 批量更新
流程解析
初始化时通过 Observer 对数据进行响应式处理,在 Observer 的 get 的时候创建一个 Dep 的实例,用来通知更新。
初始化时通过 Compile 进行编译,解析模板语法,找到其中动态绑定的数据,从 data 中获取数据并初始化视图,把模板语法替换成数据。
同时进行一次订阅,创建一个 Watcher ,定义一个更新函数 ,将来数据发生变化时,Watcher 会调用更新函数 把 Watcher 添加到 dep 中 。
Watcher 是一对一的负责某个具体的元素,data 中的某个属性在一个视图中可能会出现多次,也就是会创建多个 Watcher,所以一个 Dep 中会管理多个 Watcher。
当 Observer 监听到数据发生变化时,Dep 通知所有的 Watcher 进行视图更新。
代码实现 - 第一回合 数据响应式
observe
observe 方法相对于上面,做了一小点的改动,不是直接遍历调用 defineReactive 了,而是创建一个 Observer 类的实例 。

// 遍历obj 对其每个属性进行响应式处理
function observe(obj) {
  // 先判断类型, 响应式处理的目标一定要是个对象类型
  if (typeof obj !== 'object' || obj === null) {
    return
  }
  new Observer(obj)
}
复制代码

Observer类
Observer 类之前有解释过,它就是用来 做数据响应式 的,在它内部区分了数据是 对象 还是 数组 ,然后执行不同的响应式方案。

// 根据传入value的类型做响应的响应式处理
class Observer {
  constructor(value) {
    this.value = value
    if (Array.isArray(value)) {
      // todo  这个分支是数组的响应式处理方式 不是本文重点 暂时忽略
    } else {
      // 这个分支是对象的响应式处理方式
      this.walk(value)
    }
  }

  // 对象的响应式处理 跟前面讲到过的一样,再封装一层函数而已
  walk(obj) {
    // 遍历 obj, 对 obj 的每个属性进行响应式处理
    Object.keys(obj).forEach(key => {
      defineReactive(obj, key, obj[key])
    })
  }
}
复制代码

MVVM类(MyVue)
这一回合我们就先在实例初始化的时候,对 data 进行响应式处理,为了能用 this.key 的方式访问this.$data.key,我们需要做一层代理。

class MyVue {
  constructor(options) {
    // 把数据存一下
    this.$options = options
    this.$data = options.data

    // data响应式处理
    observe(this.$data)

    // 代理 把 this.$data 上的属性 全部挂载到 vue实例上 可以通过 this.key 访问 this.$data.key
    proxy(this)
  }
}
复制代码

proxy 代理也非常容易理解,就是通过 Object.defineProperty 改变一下引用。

/**
 * 代理 把 this.$data 上的属性 全部挂载到 vue实例上 可以通过 this.key 访问 this.$data.key
 * @param {*} vm vue 实例
 */
function proxy(vm) {
  Object.keys(vm