系列文章链接:
多页面打包通用方案
多页面应用简介
多页面(
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
配置文件,而是可以动态的向 entry
和 plugins
中添加配置。
动态的多页面配置
按照上面的优化思路,我们需要在 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
函数专门用来对页面进行动态化处理,函数返回 entry
和 HtmlWebpackPlugin
的实例,首先通过 glob
的 sync
同步读取本地目录 pages
下的文件,获取页面文件的绝对路径(数组),循环的过程中匹配页面名称,并根据页面名称动态的创建 entry
和 HtmlWebpackPlugin
。
动态化配置示例:
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
模块,解构出 entry
和 HtmlWebpackPlugin
的集合,并写在对应的配置上,这样就化解了有人新增页面就要增加对应页面配置的尴尬。
集成 ESlint
ESlint 介绍
ESlint
的作用是对项目的 JS
代码进行规范检查和风格统一,可以减少代码中的隐患和潜在问题,团队越大开发人员越多体现越明显,团队也可以根据实际情况制定规范。
ESlint
可以与 lint-staged
和 husky
等模块在代码提交阶段进行检测,可以与 Gitlab
和 Github
等代码管理平台中的 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-airbnb
、eslint-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
功能比较强大,除了对于平时开发的业务项目进行构建,对于打包组件和基础库也完全胜任。
开发组件或基础库通常需要满足下面两个要求:
- 输出的文件要构建成压缩版本和非压缩版本,非压缩版本用于开发阶段,压缩版本用于线上;
- 要支持多种模块化方式,如
AMD
、CommonJS
、ES-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.js
:Webpack
配置文件。
功能函数
/* ~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
:打包的模块规则,如CommonJS
,ES-Module
等,详情见 Webpack 官网。TerserWebpackPlugin
:基于UglifyPlugin
插件实现的,相较于UglifyPlugin
,压缩的同时可以转换ES6
语法。
include
:属性的值为正则,默认匹配了large-number.min.js
文件。
构建 SSR 应用
为什么要有 SSR 应用
通常的客户端渲染流程如下:
- 开始加载(白屏);
HTML
加载成功(提供loading
);- 请求
CSS
、JS
等资源;- 解析
CSS
、JS
等资源;- 页面渲染样式、执行
JS
逻辑;- 如发送数据、图片请求;
- 页面达到可交互状态。
从客户端的渲染流程看,我们可以发现从请求 HTML
到达到可交互状态中的请求是串行执行的,会导致白屏时间长,并且刚刚请求回的 .html
文件上的动态数据是空的,不利于搜索引擎的爬虫分析页面(不利于 SEO
)。
什么是服务端渲染
SSR
(Server Side Rendering
) 又称为服务端渲染,将渲染后的 .html
整个返回给客户端,可以让客户端在加载 .html
后直接看到页面。
服务端渲染流程:
- 开始加载(白屏);
- 服务端同构,将
HTML
、Data
、CSS
等进行组合;- 返回给客户端解析并渲染;
- 页面达到可交互状态。
跟客户端渲染的流程对比,可以发现服务端渲染的优势:
- 串行的请求在服务端,内网拉取资源更快;
- 服务端渲染把客户端渲染的多个串行的请求优化成了一个请求(减少请求数);
- 返回页面就能直接渲染出内容,减少了白屏的时间;
- 页面返回首屏所有数据,对
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 注意的问题
- 需要兼容浏览器的全局变量,如
window
、document
等;- 组件适配:将不兼容的组件根据打包环境适配,如服务端为
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
事件的回调函数中获取状态码和错误信息,做进一步的处理。
未完待续…