里程碑2 - 基于webpack5 完成工程化建设

4 阅读5分钟

一、目标

完成webpack搭建,通过解析引擎,将业务文件打包成koa可以解析出来的产物文件,解析后输出页面;在打包过程中,根据环境区分具体配置

二、基础配置项

1. entry

告知webpack从哪个文件开始构建

  • 单页面:

    只有一个入口文件,输出一个chunk,路由由前端控制,具体配置:

    {
        entry: './src/main.js'
    }
    
  • 多页面:

    多页面就是具备多个入口,输出多个chunk,每个页面独立运行、单独部署,由后端模板渲染,具体配置:

    {
        entry: {
            index: './src/pages/index.js',
            admin: './src/pages/admin.js',
        }
    }
    

    多页面需要多个html文件,通常使用HtmlWebpackPlugin来进行对应,chunks决定了注入哪个入口的bundle,需要和entry中设定的入口相对应

        new HtmlWebpackPlugin({
          filename: 'index.tpl',
          template: path.resolve(process.cwd(), "./app/view/entry.tpl"),
          chunks: ["index"]
        }),
    

2. module

模块解析,决定了要对哪些模块要使用哪些loader进行解析。

如果配置了多个loader,他的执行顺序是从右往左执行、从下往上执行;因为webpack认为loader是函数组合,例:use: ["style-loader", "css-loader"]的执行顺序类似于style(css(source))

  module: {
    rules: [
      {
        test: /\.vue$/,
        use: {
          // vue-loader必须配合 vueLoaderPlugin使用,因为vue-loader需要插件配合处理SFC(单文件组件)的拆分逻辑  
          loader: "vue-loader"
        }
      }
    ]
  },

loader是如何进行解析的,又是从哪个文件获取到的?

webpack解析loader的逻辑和node解析模块的逻辑一样(loader的解析逻辑 ≈ node.js的require解析逻辑)

默认从 node_modules/目录逐级向上查找。如果在loader中填写绝对路径,会直接用指定的路径,不会去node_modules/中查找

3. output

output的基础配置:

  output: {
    filename: "/js/main.js",
    path: "",
    publicPath: "",
  },

在filename中可以使用chunkhash,来实现代码改了就自动更新、没改就不重新下载,因为浏览器的缓存策略是文件名变了就重新下载、文件名没变就走缓存;而chunkhash是只要内容变了hash就变

  • 开发环境

在开发环境的配置中,由于使用的'webpack-dev-middleware',资源是存在内存里面,而不是从磁盘读取的,所以使用的是完整Http地址;

且为了解决 "window is not defined",要添加globalObject: "this"配置;

不同环境的全局对象不一样,webpack默认用window作为全局对象,node.js使用global作为全局对象,添加配置改成this可以兼容多环境,使在不同环境下,this可以准确访问到全局对象

  • 生产环境

由于生产环境通常由Nginx、CDN等统一托管,所以在output的publicPath中使用的是相对路径;

且生产环境里面添加crossOriginLoading: "anonymous"配置解决跨域问题

crossOriginLoading: "anonymous"在解决什么跨域问题?如果不配置会发生什么?为什么只在生产环境中配置?

这里说的跨域不是ajax跨域而是浏览器使用< script >标签加载跨域JS资源;我们生产环境可能会出现主站和CDN使用不同域名、script跨域加载,例:

资源类型来源
index.htmlwww.xxxxx.com
JS静态资源cdn.xxxxx.com

在开发环境中,使用完整http地址访问内存,是同源的不需要;而生产环境通常会静态资源分域名、上CDN、多子域名部署,所以要在生产环境配置跨域

4. resolve

用于配置模块解析的具体行为,比如webpack如何把import里的字符串变为文件路径的,即把'./app'解析成'/src/app'的

常用的是:

extensions:会自动补全文件后缀,按数组里面的顺序查找,找到第一个存在的文件就停止;数组定义的顺序很重要、越常用的放前面

alias:给路径起别名,在解析的时候、会先查找是否有匹配的alias,有的话替换为真实路径,再进行解析

5. plugins

对webpack生命周期的扩展,常用的插件:HtmlWebpackPlugin(自动生产HTML并注入打包后的js)、CleanWebpackPlugin(清理之前的构建结果)、MiniCssExtractPlugin(提取css公共部分)

6. optimization

控制webpack如何优化输出结果,包含代码分割、模块合并、缓存、treeShaking、压缩等优化策略

  • splitChunks:

    可以把公共文件抽离成单独文件,即把多个chunk之间"共享的模块"抽成新的chunk

    核心目的不是为了生成更多的包,而是为了让缓存命中率最大化,而是减少打包重复率、提高缓存利用率

    splitChunks会根据四个维度来决定拆谁:

    1. priority优先级高的先处理
    2. minChunks复用次数多的优先拆
    3. minSize满足最小模块体积的,但是小于模块体积的不考虑拆分、拆了反而会增加请求成本
    4. 剩余并行请求数限制,如果拆分太多导致请求超限,会放弃拆分

    所以我们可以根据这四个维度来配置cacheGroups自定义拆包规则:

    1. framework(1和2可以拆成一个包)

      管理库等可以从三方组件库中单独拆分出来,这些稳定且复用率高,通常只包含核心运行时和框架内部依赖,不会包含业务代码或通用工具库;

      配置信息:test: /[\\/]node_modules[\\/](vue|vue-router|vuex)/,

    2. rendor

      node_modules 第三方依赖库,必须拆分出来,因为更新频率低、体积大、缓存价值高

      配置信息:test: /[\\/]node_modules[\\/]/,

    3. common

      公共组件,多个页面共同使用的某些utils或者组件

    4. 业务组件代码

  • runtimeChunk:

    用于把webpack运行时代码单独抽离、避免业务代码变动导致缓存失效

三、环境配置

  • 开发环境

    开发环境配置的核心目标是启动快、热更新快、调试体验好、不污染生产产物

    配置中使用热更新(在有修改的时候,不刷新整个页面,只替换修改的模块)。热更新的配置分为以下几个部分:

    1. devServer配置

      给HMR客户端用的连接信息,因为热更新底层靠的是浏览器和webpack-dev-server之间的websocket长连接

      把打包结果放在内存中,好处是不走磁盘IO、编译更快、热更新更快

      下列代码是我们自己定义的一个常量,使用的是webpack-hot-middleware,用于给HMR客户端拼接连接地址使用:

          const DEV_SERVER_CONFIG = {
            HOST: "127.0.0.1",
            PORT: 9002,
            HMR_PATH: "__webpack_hmr",
            TIMEOUT: 20000
          };
      

      对于HMR_PATH这个路径的设定不是必须是这个,但是hotMiddleware会默认监听'__webpack_hmr',浏览器也会自动连接到这个路径;可以做修改修改的同时要注意下面注入到HMR客户端的路径,必须要匹配上,否则会链接不上。

      还有一种方法是使用webpack-dev-server设定,webpack自动开启websocket、注入HMR

          devserver: {
              hot:true
          }
      
    2. HMR(热更新)

      正常情况下webpack打包之后,浏览器只执行entry中的入口文件;下面这段代码就是手动往每个入口文件中注入HMR客户端代码(核心代码

      Object.keys(baseConfig.entry).forEach(v => {
        if (v !== "vendor") {
          baseConfig.entry[v] = [
            baseConfig.entry[v],
            `webpack-hot-middleware/client?path=http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/${DEV_SERVER_CONFIG.HMR_PATH}&timeout=${DEV_SERVER_CONFIG.TIMEOUT}`
          ];
        }
      });
      

      之所以在注入的时候排除掉rendor,是因为在分包的时候rendor中都是第三方库,这些第三方库基本不会变;如果给rendor添加HMR客户端会增加bundle体积、可能会影响缓存、没有意义

      在向入口文件注入HMR客户端代码的同时,要添加HotModuleReplacementPlugin,告知webpack开启模块级别的替换能力,如果没加这个plugin,即使客户端连上了websocket也只会整页刷新不会进行模块替换

      为了实现热更新,浏览器要经历哪几个步骤?

      1. 和dev-server建立websocket
      2. 监听代码改动
      3. 下载更新模块
      4. 替换旧模块
    3. devtool

      控制source-map生成方式、生成时机、映射精度和输出位置

      devtool值的核心组合规则是 [inline-][eval-][hidden-][nosources-][cheap-][module-]source-map;除了eval和false以外必须以source-map结尾,以下是几种常见的devtool的比对:

      devtool构建速度调试精度适用环境评价
      false最快没有正式生产环境推荐用于性能最大化的生产版本
      eval一般开发推荐用于开发版本以最高性能进行
      eval-cheap-module-source-map一般精确到行开发开发构建的权衡选择
      cheap-module-source-map精确到行开发
      source-map最慢行+列生产环境排错
      hidden-source-map最慢行+列生产环境线上监控
  • 生产环境

    生产环境追求的是性能、缓存和安全;本次构建中使用了thread-loader/happypack来做多线程编译,使用TerserPlugin来做并发压缩和缓存。

    1. thread-loader

      作用:把后续loader放到worker线程池里面执行,适合比较耗时的loader,比如:babel-loader;

      需要注意的是thread-loader必须放在babel-loader前面,如果并发执行的loader项目很小(例:css-loader)反而会因为进程创建有开销导致变慢

          use: [
            {
              loader: "thread-loader",
              options: {
                workers: os.cpus().length,
                // 每个线程并发处理的工作数,默认为20
                workerParallelJobs: 30,
                // 闲置时定时删除 worker 进程、默认为500(毫秒)
                // 以设置为无穷大, 这样在监视模式(--watch)下可以保持 worker 持续存在
                poolTimeout: 2000,
                // 工作池分配给worker的工作数量。默认为200.
                // 降低这个数值会降低总体的效率,但是会提升工作分布更均一
                poolParallelJobs: 50
              }
            },
            {
              loader: "babel-loader",
              options: {
                presets: ["@babel/preset-env"],
                plugins: [["@babel/plugin-transform-runtime"]]
              }
            }
          ]
      
    2. happypack

      happypack在使用时,需要在plugin和loader中同时添加

          use: ["happypack/loader?id=js"]
          ...
          ...
          plugin: [
              // 多线程打包js,加快打包速度
              new HappyPack({
                debug: false,
                threadPool: HappyPack.ThreadPool({ size: os.cpus().length }),
                id: "js",
                loaders: [
                  `babel-loader?${JSON.stringify({
                    presets: ["@babel/preset-env"],
                    plugins: ["@babel/plugin-transform-runtime"]
                  })}`
                ]
              }),
          ]
          
      
    3. TerserPlugin

      作用是压缩JS、删除注释和console;可以多进程并发压缩、缩短压缩阶段耗时;TerserPlugin支持缓存,对没变化的文件会跳过压缩

          new TerserPlugin({
              // 启用缓存来加速构建过程
              cache: true,
              // 利用多核 CPU 的优势来加快压缩速度
              parallel: true,
              terserOptions: {
                compress: {
                  // 去掉 console.log 内容
                  drop_console: true
                },
                format: {
                  // 移除注释
                  comments: true
                }
              },
      
              // 是否将注释提取到单独文件中
              extractComments: false
          })
      

四、webpack启动

  • 开发环境

    本次我们开发环境的启动构建不是直接使用webpack-dev-server,而是基于webpack-dev-server的底层实现完成一个属于我们自己的 'webpack-dev-server';以下是具体步骤:

    1. 创建Express实例

      const app = express();

      本质上是创建一个HTTP服务器,负责监听端口、处理请求、返回静态资源和建立HMR通讯

    2. 创建compiler

      const compiler = webpack(webpackConfig);

      获取之前在开发环境webpack中导出的{ webpackConfig, DEV_SERVER_CONFIG },并创建一个compiler实例(此时仍处于准备阶段,还没开始构建)

    3. 指定静态文件目录

      app.use(express.static(path.join(__dirname, "../public/dist")))

      用于访问磁盘文件;但是我们使用的'webpack-dev-middleware',默认使用内存文件系统,所以这个目录大多数时候是空的,不会写到磁盘中

      注:HMR只是浏览器和服务器之间的通讯机制,是否写入磁盘和HMR没有关系

    4. 引入 devMiddleware 中间件(核心)

      writeToDisk: filePath => filePath.endsWith(".tpl"), devMiddleware中的这个配置会将tpl文件写入到真实文件中

      为什么不全内存,而是要将tpl写入到真实文件中?

      因为某些后端模板需要真实文件。。。。用来干嘛?有哪些模板需要?为什么需要

           headers: {
            "Access-Control-Allow-Origin": "*",
            "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, PATCH, OPTIONS",
            "Access-Control-Allow-Headers":
              "X-Request-With, content-type, Authorization"
          },
      

      为什么要加这些header?

      1. 这部分header属于响应头,作用是解决浏览器跨域问题;因为浏览器的同源策略机制,使得协议+域名+端口 必须完全一致,否则请求会被浏览器拦截
      2. 前端和后端不处于同一个端口下,属于跨域,加上这些响应头开发时方便调试
      3. 配置了这个header所有由webpack-dev-middleware返回的资源都会自动带上这些

      这些header都是用来干嘛的?

      1. Access-Control-Allow-Origin:

        · 告知浏览器允许哪些源访问我的资源,设置为*就是允许任何域名访问这个资源,设置特定域名就是只允许这个域名访问

        · 是CORS(跨域资源共享)响应头,加在返回JS资源的服务器响应头里面,只在跨域请求时才检查

        · 如果有跨域获取资源,但是不需要读取内容,即使没有配置Access-Control-Allow-Origin也能加载执行,只是在报错的时候拿不到真实的报错信息

        · 如果有跨域、设置了crossorigin、但没有配置Access-Control-Allow-Origin,浏览器会直接拦截加载、script加载会失败、控制台报CORS错误

      2. Access-Control-Allow-Methods:

        · 允许跨域请求使用哪些HTTP方法

      3. Access-Control-Allow-Headers:

        · 允许客户端发送哪些自定义请求头

    5. 引入 hotMiddleware 中间件

      负责浏览器和服务器之间的通讯,采用SSE(Server Sent Events)机制

        hotMiddleware(compiler, {
          path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
          log: () => {}
        })
      

      path是我们在webpack中定义的HMR通讯路径,浏览器会请求这个路径

    6. 启动 devserver

      开始监听端口

          const port = DEV_SERVER_CONFIG.PORT;
          app.listen(port, () => {
            console.log(`app listening on port ${port}`);
          });
      

      为什么在启动devserver的时候只写了监听端口的代码?

      1. 在devMiddleware内部已经调用compiler.watch(),也就意味着webpack已经开始监听文件变化了
      2. express只提供Http能力,职责只有监听端口、接收请求、返回资源

      所以监听端口只是为了打开网络入口,真正的构建启动时间点实在devMiddleware

  • 生产环境

    相比于开发环境在启动构建的时候配置的监听文件改动、热更新之类的,生产环境比较简洁明了的多;只是启动一次构建、然后执行callback

    webpack(webpackProdConfig, (err, stats) => {
      if (err) {
        console.log(err);
        return;
      }
      process.stdout.write(
        `${stats.toString({
          // 在控制台输出色彩信息
          colors: true,
          // 不显示每个模块的打包信息
          modules: false,
          // 不显示子编译任务的信息
          children: false,
          // 不显示每个代码块的信息
          chunks: false,
          // 显示代码块中模块的信息
          chunkModules: true
        })}\n`
      );
    });
    

    这个callback在构建完成之后进行回调,在callback捕获的err中只包含致命错误,例如语法错误、插件崩溃等;stats中记录的则是模板编译错误,stats包含了整个构建结果的统计信息,例如模块数量、chunk信息等;

    使用stats.toString()可以把stats对象格式化为可读的文本,也就是我们在控制台看到的打包结果。

五、开发环境和生产环境的差异

  1. 构建位置

    开发环境是在内存中,生产环境是写入磁盘

  2. 是否监听

    开发环境监听改变,生产环境执行完就结束

  3. 是否压缩

    开发环境通常不需要进行压缩,生产环境进行压缩

  4. 启用HMR

    开发环境为了更好的调试,启用HMR;生产环境不需要