系列文章链接:

多页面打包通用方案

多页面应用简介

多页面(MPA)和单页面(SPA)是对应用两种不同的处理方式,单页面应用一般是只有一个主页面,其他的页面切换都是靠路由和组件切换来实现,多页应用是每次跳转的时候服务端会返回一个新的 .html 页面,每一个页面是一个独立的应用,只是多个应用之间共用了同一个域名。

多页面的优势是页面与页面之间是相互解耦的,对 SEO 更加友好,缺点是每次新增或删除页面都需要更改构建的配置。

基础的多页面配置

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

module.exports = {
  entry: {
    admin: path.resolve(__dirname. 'src/pages/admin/index.js'),
    search: path.resolve(__dirname. 'src/pages/search/index.js')
  },
  output: {
    filename: '[name][chunkhash:8].js',
    path: './dist'
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: './src/index.html', // 模板文件路径
      filename: 'index.html', // 输出文件名称
      // ...
    }),
    new HtmlWebpackPlugin({
      template: './src/search.html',
      filename: 'search.html',
      // ...
    }),
  ]
  // ...
}

上面是一个基础的多页面配置,如果开发过程成页面的增加非常快速,且多人同时开发,这样每增加一个页面都需要在 entry 中增加一个入口,在 plugins 中增加一个 HtmlWebpackPlugin 插件的实例,这样的维护方式并不优雅。

我们更希望增加页面时不需要更改 Webpack 配置文件,而是可以动态的向 entryplugins 中添加配置。

动态的多页面配置

按照上面的优化思路,我们需要在 Webpack 配置中读取本地某一个固定目录的文件,以知道有哪些页面需要配置,当然我们可以使用 fs 模块自己实现,在这里更推荐使用 glob 模块,glob 模块可以通过通配符的方式按照定义的规则去匹配文件目录。

安装依赖:

$ npm install glob html-webpack-plugin -D

动态生成页面配置:

const path = require('path');
const glob = require('glob');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const setMPA = () => {
  const entry = {};
  const htmlPlugins = [];

  const pathMatch = path.resolve(__dirname, './src/pages/*/index.js')
  const entryFiles = glob.sync(pathMatch);

  entryFiles.map((pagePath) => {
    const pageName = pagePath.match(/.*\/(.*)\/index.js/)[1];
    entry[pageName] = pagePath;

    htmlPlugins.push(new HtmlWebpackPlugin({
      template: './src/pages/' + pageName + '/index.html', // 模板文件路径
      filename: pageName + '.html', // 输出文件名称
      chunks: [pageName], // 使用的 chunk 名称
      inject: true, // 将 js 资源放在 body 底部
      minify: {
        collapseWhitespace: true, // 是否删除空白符与换行符
        removeAttributeQuotes: true, // 是否移除引号
        minifyCSS: true, // 压缩 CSS
        minifyJS: true, // 压缩 JS
        removeComments: true // 是否移除 HTML 中的注释
      }
    }));
  });

  return {
    entry,
    htmlPlugins
  };
}

module.exports = setMPA();

首先我们创建一个模块,模块中创建 setMPA 函数专门用来对页面进行动态化处理,函数返回 entryHtmlWebpackPlugin 的实例,首先通过 globsync 同步读取本地目录 pages 下的文件,获取页面文件的绝对路径(数组),循环的过程中匹配页面名称,并根据页面名称动态的创建 entryHtmlWebpackPlugin

动态化配置示例:

const path = require('path');
const {entry, htmlPlugins} = require('./setMPA');

module.exports = {
  entry,
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name][chunkhash:8].js'
  },
  plugins: [
    ...htmlPlugins
  ]
}

Webpack 配置中只需要引入 setMPA 模块,解构出 entryHtmlWebpackPlugin 的集合,并写在对应的配置上,这样就化解了有人新增页面就要增加对应页面配置的尴尬。

集成 ESlint

ESlint 介绍

ESlint 的作用是对项目的 JS 代码进行规范检查和风格统一,可以减少代码中的隐患和潜在问题,团队越大开发人员越多体现越明显,团队也可以根据实际情况制定规范。

ESlint 可以与 lint-stagedhusky 等模块在代码提交阶段进行检测,可以与 GitlabGithub 等代码管理平台中的 CI/CD 进行集成,也可以在发布平台云构建过程中进行规范检查。

/* package.json */
{
  "script": {
    "precommit": "lint-staged"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "linters": {
      "*.{js,scss}": [
        "eslint --fix",
        "git add"
      ]
    }
  }
}

也有些优秀的 ESlint 规范模块可以使用,如腾讯的 eslint-config-airbnbeslint-config-airbnb-base 等。

Webpack 中使用 ESlint

也可以在 Webpack 构建中集成 ESlint 规范检查,需要借助 eslint-loader,如果代码不符合 ESlint 规范,构建会中断。

安装依赖:

$ npm install babel-loader eslint-loader -D

Webpack 配置示例:

module.exports = {
  module: {
    rules: [
      // ...
      {
        test: /.js$/,
        exclude: /node_modules/, // 排除项
        use: [
          'babel-loader',
          'eslint-loader'
        ]
      }
      // ...
    ]
  }
}

ESlint 配置示例:

/* .eslint.js */
module.exports = {
  parser: 'babel-eslint', // 使用的 eslint 规范
  extends: ['airbnb'], // 继承的 eslint 规犯
  // 启用的环境
  env: {
    browser: true,
    node: true
  }
  // 自定义规则
  rules: {
    // 规则名称
    indent: [
      'error', // 错误级别
      2 // 配置项的值
    ]
  }
}

自定义 ESlint 规则或想要根据成熟的 ESlint 规则做定制化修改,可以在 .eslintrc.eslint.js.eslint.yml 文件中进行配置。

通过 Webpack 构建组件和基础库

构建描述

这里所说的打包组件和基础库其实就是 “造轮子” 时,对于自己封装的模块进行构建,在做这个事情的时候使用 rollup 其实更适合,因为 rollup 更纯粹,也更简单一些,Webpack 功能比较强大,除了对于平时开发的业务项目进行构建,对于打包组件和基础库也完全胜任。

开发组件或基础库通常需要满足下面两个要求:

  • 输出的文件要构建成压缩版本和非压缩版本,非压缩版本用于开发阶段,压缩版本用于线上;
  • 要支持多种模块化方式,如 AMDCommonJSES-Module 以及 script 标签引入。

各种引入方式:

/* AMD */
require(['large-number'], function (largeNumber) {
  largeNumber.add('999', '1');
})
/* CommonJS */
const largeNumber = require('large-number');

largeNumber.add('999', '1');
/* ES-Module */
import * as largeNumber from 'large-number';

largeNumber.add('999', '1');
<!-- script -->
<script src="//xxcnd/large-number.js"></script>
<script>
  largeNumber.add('999', '1');
</script>

构建一个基础库

目录结构

下面我们封装一个计算大数字加法的库,并把这个库作为第三方模块使用 Webpack 进行构建,项目目录结构如下:

  
    large-number
      |- dist
      | |- large-number.js
      | |- large-number.min.js
      |- src
      | |- index.js
      |- index.js
      |- package.json
      |- webpack.config.js
  
  • dist 是我们希望输出的目录,large-number.js 为非压缩版,large-number.min.js 为非压缩版;
  • src 是构建的目录,index.js 是大整数加法功能函数所在文件;
  • index.js:入口文件;
  • package.josn:依赖配置文件;
  • webpack.config.jsWebpack 配置文件。

功能函数

/* ~src/index.js */
export default function add(a, b) {
  // 相加两数的当前位的指针
  let i = a.length - 1;
  let j = b.length - 1;

  let carry = 0; // 是否进位
  let ret = ''; // 最后输出结果

  // 循环,个位个位相加,十位十位相加...
  while (i >= 0 || j >= 0) {
    let x = 0; // a 的当前位
    let y = 0; // b 的当前位
    let sum; // 当前位数的和

    // 如果存在当前位数将 a 的数字转化为数字,并将指针指向上一位
    if (i >= 0) {
      x = a[i] - '0';
      i--;
    }

    // 如果存在当前位数将 b 的数字转化为数字,并将指针指向上一位
    if (j >= 0) {
      y = b[j] - '0';
      j--;
    }

    sum = x + y + carry; // 求总和

    // 如果总和大于 10 进位,并修正当前位数
    if (sum >= 10) {
      carry = 1;
      sum -= 10;
    } else {
      carry = 0;
    }

    ret = sum + ret; // 将求和数子转换字符串
  }

  // 循环结束,如果仍然存在进位,则将进位的值与之前结果拼接
  if (carry) {
    ret = carry + ret;
  }

  return ret; // 返回最终结果
}

package.json

{
  "name": "large-number",
  "version": "1.0.0",
  "description": "大整数加法",
  "main": "index.js",
  "scripts": {
    "build": "webpack",
    "prepublish": "npm run build"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "terser-webpack-plugin": "^2.1.3",
    "webpack": "^4.41.2",
    "webpack-cli": "^3.3.9"
  }
}

其中 main 是模块指定执行的文件,指向根目录的主文件。

主文件

/* index.js */
if (process.env.NODE_ENV === 'production') {
  module.exports = require('./dist/large-number.min.js');
} else {
  module.exports = require('./dist/large-number.js');
}

主文件中根据当前的引用环境区分提供压缩版和非压缩文件版。

构建配置

下面是整个模块构建最重要的,就是 Webpack 的配置文件。

/* webpack.config.js */
const TerserWebpackPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'none',
  entry: {
    'large-number': './src/index.js',
    'large-number.min': './src/index.js'
  },
  output: {
    filename: '[name].js',
    library: 'largeNumber',
    libraryExport: 'default',
    libraryTarget: 'umd'
  },
  optimization: {
    minimize: true,
    minimizer: [
      // 压缩的同时转换 ES6 语法,基于 uglifyPlugin 改造
      new TerserWebpackPlugin({
        include: /\.min\.js$/
      })
    ]
  }
}

配置文件参数解析:

  • entry
    • large-number:压缩版入口文件路径;
    • large-number.min:非压缩版入口文件路径。
  • output
    • filename:出口文件名;
    • library:导出的文件所提供的全局变量名;
    • libraryExport:默认值为 default,如不配置访问导出对象的default 属性才可以获取对应的方法;
    • libraryTarget:打包的模块规则,如 CommonJSES-Module 等,详情见 Webpack 官网
  • TerserWebpackPlugin:基于 UglifyPlugin 插件实现的,相较于 UglifyPlugin,压缩的同时可以转换 ES6 语法。
    • include:属性的值为正则,默认匹配了 large-number.min.js 文件。

构建 SSR 应用

为什么要有 SSR 应用

通常的客户端渲染流程如下:

  • 开始加载(白屏);
  • HTML 加载成功(提供 loading);
  • 请求 CSSJS 等资源;
  • 解析 CSSJS 等资源;
  • 页面渲染样式、执行 JS 逻辑;
  • 如发送数据、图片请求;
  • 页面达到可交互状态。

从客户端的渲染流程看,我们可以发现从请求 HTML 到达到可交互状态中的请求是串行执行的,会导致白屏时间长,并且刚刚请求回的 .html 文件上的动态数据是空的,不利于搜索引擎的爬虫分析页面(不利于 SEO)。

什么是服务端渲染

SSRServer Side Rendering) 又称为服务端渲染,将渲染后的 .html 整个返回给客户端,可以让客户端在加载 .html 后直接看到页面。

服务端渲染流程:

  • 开始加载(白屏);
  • 服务端同构,将 HTMLDataCSS 等进行组合;
  • 返回给客户端解析并渲染;
  • 页面达到可交互状态。

跟客户端渲染的流程对比,可以发现服务端渲染的优势:

  • 串行的请求在服务端,内网拉取资源更快;
  • 服务端渲染把客户端渲染的多个串行的请求优化成了一个请求(减少请求数);
  • 返回页面就能直接渲染出内容,减少了白屏的时间;
  • 页面返回首屏所有数据,对 SEO 更友好。

客户端渲染和服务端渲染的差别对比:

客户端渲染服务端渲染
请求多个请求(HTML,数据等)1 个请求
加载过程HTML 与数据串行加载1 个请求返回 HTML 和数据
渲染前端渲染服务端渲染(如 Node.js)
可交互图片等静态资源加载完成,JS 逻辑执行完成可交互


构建服务端和客户端

假设负责服务端渲染的服务是由 Express 实现的,前端是使用 React 实现的,代码如下:

$ npm install express -D
/* ~server/index.js 服务端 */
const express = require('express');
const fs = require('fs');
const axios = require('axios');

// React 内部提供的方法,用于将 JSX 转换成 HTML 字符
const { renderToString } = require('react-dom/server');
// 引入需要转换的 JSX
const SSR = require('./dist/index-server');
// 引入构建后的模板
const html = fs.readFile('./dist/index.html', 'utf-8');

// 增加 hask,防止属于浏览器的对象在服务端报错
if (window === undefined) {
  global.window = {};
}

const server = (port) => {
  const app = express();

  app.use(express.static('dist'));

  app.get('/', (req, res) => {
    const html = renderMarkup(renderToString(SSR));
    res.status(200).send(html);
  });

  app.listen(port, () => {
    console.log(`server start ${port}`);
  });
}

const renderMarkup = async (str) => {
  const data = await axios.get('/xxx/xxx');
  return html.replace('<!-- HTML_PLACEHOLDER -->', str).replace(
    '<!-- INITAIL_DATA_PLACEHOLDER -->',
    '<script>window.__inital_data = ' + data + '</script>'
  );
}

server(process.env.PORT || 3000);
<!-- ~dist/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Search</title>
</head>
<body>
  <!-- 占位符,用于插入 HTML -->
  <div id="root"><!-- HTML_PLACEHOLDER --></div>
  <!-- 数据占位符,用于插入数据 -->
  <!-- INITAIL_DATA_PLACEHOLDER -->
</body>
</html>
/* ~dist/index-server.js 客户端 */
const React = require('react');

class App extends React.Component {
  render() {
    return <h1>Hello world!</h1>
  }
}

module.exports = <App />;

注意:由于 React 组件的 JSX 要通过服务端进行转换、渲染,所以不能使用 ReactDom.render 进行渲染,需要使用 require 引入, module.exports 导出。

构建配置

const path = require('path');

module.exports = {
  // ...
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name]-server.js'
    libararyTarget: 'umd'
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          'ignore-loader' // 使用构建后 `dist` 目录的 CSS
        ]
      }
    ]
  }
  // ...
}

Webpack SSR 注意的问题

  • 需要兼容浏览器的全局变量,如 windowdocument 等;
  • 组件适配:将不兼容的组件根据打包环境适配,如服务端为 CommonJS 模块化规范;
  • 请求适配:将 fetch 或者 ajax 请求的写法改写成 isomorphic-fetch 或者 axios(对于服务端做过适配);
  • 样式无法解析,服务端打包通过 ignore-loader 忽略掉 CSS 解析,或者将 style-loader 替换成 isomorphic-style-loader(使用 CSS-Module 的编码方式)。

定制构建命令行的显示日志

在每一次构建时,默认在命令行都会打印一堆的日志信息,但是对于一个关注业务的开发者来说,更希望在构建错误时才去关注日志,并且快速定位错误,在 Webpack 中提供了 stat 配置用来控制日志内容的显示。

生产环境配置示例:

module.exports = {
  // ...
  stat: 'errors-only'
  // ...
}

开发环境配置示例:

module.exports = {
  // ...
  devServer: {
    // ...
    stat: 'errors-only'
    // ...
  }
  // ...
}

生产环境控制执行构建命令时的日志显示,如 npm run build,而开发环境控制代码热更新重新构建时的日志显示。

stat 可选值如下:

可选值描述
errors-only只在发生错误时输出
errors-warnings只在发生错误或有新的编译时输出
minimal只在发生错误或有新的编译时输出
none没有输出
normal标准输出
verbose全部输出
detailed全部输出除了 chunkModules 和 chunkRootModules

目前存在一个问题是成功、警告以及失败的日志信息不够明显,使用 FriendlyErrorsWebpackPlugin 插件,可以通过颜色区分更明显的标注日志信息。

插件安装:

$ npm install friendly-errors-webpack-plugin -D

插件配置示例:

const FriendlyErrorsWebpackPlugin = require('friendly-errors-webpack-plugin');

module.exports = {
  // ...
  plugins: [
    new FriendlyErrorsWebpackPlugin()
  ]
  // ...
}

构建异常和中断处理

在执行构建时,如果构建成功,接下来可能会执行发布操作,如果构建失败,可能会做错误上报的操作,这就需要我们的 Webpack 配置中能构处理构建异常和中断,其实在 Webpack4 中每次进程执行构建后都会抛出结束的状态码,0 为构建成功,其他只为构建失败。

# 查看状态码
echo $?

但我们的目的并不是通过命令拿到错误码,而是在构建过程刚结束时,可以针对状态码去做不同的处理,其实根据 Webpack 的底层对象 Compiler 的特性去实现一个插件就可以实现构建异常和中断处理,在插件中通过 process.exit 抛出状态码。

process.exit 方法:

  • 状态码为 0,构建成功,回调函数中 err 参数为 null
  • 状态码为其他值,构建失败或中断,回调函数中 err 为错误对象,err.code 就是状态码。

插件简易实现和配置:

module.exports = {
  // ...
  plugins: [
    // ...
    function () {
      const interceptor = (stats) => {
        if (stats.complation.errors && process.argv.includes('--watch')) {
          // 处理错误,上报
          process.exit(1);
        }
      }

      if (this.hooks) {
        this.hooks.done.tap('done', interceptor); // Webpack4
      } else {
        this.plugin('done', interceptor); // Webpack3
      }
    }
    // ...
  ]
  // ...
}

plugins 不一定是类 new 出的插件实例对象,也可以是函数,上面的函数就可以作为插件被执行,在 Webpack 构建结束后会自动执行 done 事件,可以在 done 事件的回调函数中获取状态码和错误信息,做进一步的处理。

未完待续…