Webpack构建多页应用心得体会

3,465 阅读5分钟

Webpack构建的基于zepto的多页应用脚手架,本文聊聊本次项目中Webpack构建多页应用的一些心得体会。

1.前言

由于公司旧版的脚手架是基于Gulp构建的zepto多页应用(有兴趣可以看看web-mobile-cli),有着不少的痛点。例如:

  1. 需要兼容低版本浏览器,只能采用promise,不能使用awaitgenerator等。(因为babel-runtime需要模块化);
  2. 浏览器缓存不友好(只能全缓存而不是使用资源文件的后缀哈希值来达到局部缓存的效果);
  3. 项目的结构不友好(可以更好的结构化);
  4. 开发环境下的构建速度(内存);
  5. Gulp插件相对Webpack少且久远,维护成本高等等。

这次升级有几个地方需要注意和改进:

  1. 项目旧代码尽量做到无缝转移;
  2. 资源文件的缓存;
  3. 组件式的组织目录结构。

Github仓库:

  1. Gulp构建的旧版多页应用web-mobile-cli
  2. Webpack构建的多页应用web-mobile-webpack-cli

2.多页

Webpack的多页应用通过多入口entry和多实例html-webpack-plugin配合来构建,html-webpack-pluginchunk属性传入对应entrykey就可以做到关联,例如:

module.exports = {
  entry: {
    pageOne: './src/pageOne/index.js',
    pageTwo: './src/pageTwo/index.js',
    pageThree: './src/pageThree/index.js'
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: `pageOne.html`,
      template: `./src/pageOne.html`,
      chunks: ['pageOne']
    }),
    new HtmlWebpackPlugin({
      filename: `pageTwo.html`,
      template: `./src/pageTwo.html`,
      chunks: ['pageTwo']
    }),
    new HtmlWebpackPlugin({
      filename: `pageTwo.html`,
      template: `./src/pageTwo.html`,
      chunks: ['pageTwo']
    })
  ]
}

那么问题来了,开发新的页面每次都得添加岂不是很麻烦。这里推荐神器glob根据正则规则匹配。

const glob = require('glob')

module.exports = {
  entry: glob.sync('./src/js/*.js').reduce((pre, filepath) => {
    const tempList = filepath.split('src/')[1].split(/js\//)
    const filename = `${tempList[0]}${tempList[1].replace(/\.js/g, '')}`
    
    return Object.assign(pre, {[filename]: filepath})
  }, {}),
  plugins: [
    ...glob.sync('./src/html/*.ejs').map((filepath, i) => {
      const tempList = filepath.split('src/')[1].split(/html\//)
      const fileName = tempList[1].split('.')[0].split(/[\/|\/\/|\\|\\\\]/g).pop()
      const fileChunk = `${tempList[0]}${fileName}`
      
      return new HtmlWebpackPlugin({
        filename: `${fileChunk}.html`,
        template: filepath,
        chunks: [fileChunk]
      })
    })
  ]
}

3.模板

项目没有直接使用html,而是使用了ejs作为模板,这里有至少两个好处:

  1. 把公共的代码抽离出来;
  2. 传入公共的变量。
// header.ejs
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0" />
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title><%= title %></title>
</head>

// index.ejs
<!DOCTYPE html>
<html lang="en">
<% include ./header.ejs %>
<body>
  <!-- page -->
</body>
<script src="<%= publicPath %>lib/zepto.js"></script>
</html>

<% include ./header.ejs %>就是引用了header.ejs文件,<%= title %><%= publicPath %>是我在配置文件定义的两个变量,publicPath是为了统一cdn缓存服务器的域名,非常有用。

4.垫片

项目中使用了zepto,所以需要垫片,所谓垫片就是shim 预置依赖,即全局依赖。

webpack compiler 能够识别遵循 ES2015 模块语法、CommonJS 或 AMD 规范编写的模块。然而,一些 third party(第三方库) 可能会引用一些全局依赖(例如 jQuery 中的 $)。因此这些 library 也可能会创建一些需要导出的全局变量。这些 "broken modules(不符合规范的模块)" 就是 shim(预置依赖) 发挥作用的地方。

垫片有两种方式:

  1. 传统方式的垫片就是在html文件中,所有引用的js文件的最前面引用的文件(例如zepto);
  2. Webpack配置shim预置依赖

最终我选择了Webpack配置shim预置依赖这种方式,因为:

  1. 传统的方式需要每个页面都手动引入(虽说搭配ejs可以抽离出来成为公共模块,但还是需要每个页面手动引入公共模块);
  2. 传统的方式需要多发一次请求去请求垫片;
  3. Webpack可以把所有第三方插件的代码都拆分打包成为一个独立的chunk,只需一个请求。
module.exports = {
  entry: {...},
  module: {
    rules: [
      {
        test: require.resolve('zepto'),
        use: 'imports-loader?this=>window'
      }
    ]
  },
  plugins: [
    new webpack.ProvidePlugin({$: 'zepto'})
  ]
}

5.拆分

一般来讲Webpack的配置entry中每个key就对应输出一个chunk,那么该项目中会提取这几类chunk

  1. 页面入口(entry)对应的chunk
  2. common:多次引用的公共文件;
  3. vender:第三方依赖;
  4. manifestWebpack运行时(runtime)代码,它存储着Webpackmodulechunk的信息。
module.exports = {
  entry: {...},
  module: {...},
  plugins: [],
  optimization: {
    runtimeChunk: {
      name: 'manifest'
    },
    splitChunks: {
      cacheGroups: {
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          chunks: 'all',
          name: 'vendors',
          filename: 'js/vendors.[contenthash:8].js',
          priority: 2,
          reuseExistingChunk: true
        },
        common: {
          test: /\.m?js$/,
          chunks: 'all',
          name: 'common',
          filename: 'js/common.[contenthash:8].js',
          minSize: 0,
          minChunks: 2,
          priority: 1,
          reuseExistingChunk: true
        }
      }
    }
  }
}

这里注意的有两点:

  1. 优先顺序:第三方插件的prioritycommon代码的priority大;
  2. 提取common代码:minChunks为引用次数,我设置为引用2次即提取为公共代码。minSize为最小字节,设置为0。

6.缓存

缓存的目的是为了提高加载速度,Webpack在缓存方面已经是老生常谈的了,每个文件赋予唯一的hash值,只有更新过的文件,hash值才改变,以达到整体项目最少文件改动。

6.1 hash值

Webpack中有三种hash值:

  1. hash:全部文件同一hash,一旦某个文件改变,全部文件的hash都将改变(同一hash不满足需求);
  2. chunkhash:根据不同的入口文件(Entry)进行依赖文件解析、构建对应的chunk,生成对应的哈希值(问题是css作为模块importJavaScript文件中的,它们的chunkhash是一致的,一旦改变js文件,即使importcss文件内容没有改变,其chunkhash值也会一同改变,不满足需求);
  3. contexthash:只有模块的内容变了,那么hash值才改变(采用)。
module.exports = {
  entry: {
    pageOne: './src/pageOne/index.js',
    pageTwo: './src/pageTwo/index.js',
    pageThree: './src/pageThree/index.js'
  },
  output: {
    path: 'src',
    chunkFilename: 'j[name].[contenthash:8].js',
    filename: '[name].[contenthash:8].js'
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: `pageOne.html`,
      template: `./src/pageOne.html`,
      chunks: ['pageOne']
    }),
    new HtmlWebpackPlugin({
      filename: `pageTwo.html`,
      template: `./src/pageTwo.html`,
      chunks: ['pageTwo']
    }),
    new HtmlWebpackPlugin({
      filename: `pageTwo.html`,
      template: `./src/pageTwo.html`,
      chunks: ['pageTwo']
    })
  ]
}

6.2 module id

仅仅使用contexthash还不足够,每当import的资源文件顺序改变时,chunk依然会改变,目的没有达成。要解决这个问题首先要理解modulechunk分别是什么,简单理解:

  1. module:一个import对应一个module(例如:import zepto from 'zepto'中的zepto就是一个module);
  2. chunk:根据配置文件打包出来的包,就是chunk。(例如多页应用中每个entrykey值对应的文件)。

因为Webpack内部维护了一个自增的id,依照顺序赋予给每个module,每当新增或者删减导致module的顺序改变时,受影响的chunkhash值也会改变。解决办法就是使用唯一的hash值替代自增的id

module.exports = {
  entry: {...},
  module: {...},
  plugins: [],
  optimization: {
    moduleIds: 'hashed'
  }
}

7.优化

优化的目的是提高执行和打包的速度。

7.1 查找路径

告诉Webpack解析模块时应该搜索的目录,缩小编译范围,减少不必要的编译工作。

const {resolve} = require('path')

module.exports = {
  entry: {...},
  module: {...},
  plugins: [],
  optimization: {...},
  resolve: {
    alias: {
      '@': resolve(__dirname, '../src'),
    },
    modules: [
      resolve('src'),
      resolve('node_modules'),
    ]
  }
}

7.2 指定目录

指定loaderinclude目录,作用是缩小编译范围。

const {resolve} = require('path')

module.exports = {
  entry: {...},
  module: {
    rules: [
      {
        test: /\.css$/,
        include: [
          resolve("src"),
        ],
        use: ['style-loader', 'css-loader']
      }
    ]
  },
  plugins: [],
  optimization: {...},
  resolve: {...}
}

7.3 babel缓存目录

babel-loader开始缓存目录cacheDirectory

const {resolve} = require('path')

module.exports = {
  entry: {...},
  module: {
    rules: [
      {
        test: /\.m?js$/,
        exclude: /(node_modules|bower_components)/,
        include: [
          resolve("src"),
        ],
        use: {
          loader: 'babel-loader',
          options: {
            cacheDirectory: true,
            presets: ['@babel/preset-env'],
            plugins: ['@babel/plugin-transform-runtime']
          }
        }
      }
    ]
  },
  plugins: [],
  optimization: {...},
  resolve: {...}
}

7.4 插件TerserJSPlugin

TerserJSPlugin插件的作用是压缩JavaScript,优化的地方是开启缓存目录和开启多线程。

const {resolve} = require('path')

module.exports = {
  entry: {...},
  module: {...},
  plugins: [],
  optimization: {
    minimizer: [
      new TerserJSPlugin({
        parallel: true,
        cache: true,
      })
    ]
  },
  resolve: {...}
}

8.总结

通过这次学习Webpack到升级脚手架,对前端工程化有了进一步的了解,也感受到了Webpack4带来的开箱即用,挺方便的。

参考文章:
Webpack官方文档
【实战】webpack4 + ejs + express 带你撸一个多页应用项目架构
基于 webpack 的持久化缓存方案