本文内容学习自 哲玄前端 《大前端全栈实践》课程
前端工程化的由来
在互联网早期,前端开发主要是编写简单的 HTML、CSS 和 JavaScript,页面逻辑简单,代码量少。随着 Web 应用复杂度的提升(从内容展示到复杂交互的单页应用 SPA),以及 React、Vue 等框架的兴起,前端开发发生了根本性变化。
- 项目从几个文件变为成千上万个模块
- 用户对加载速度和交互体验的要求越来越高
- 团队开发中,如何保持代码风格、模块依赖、项目结构的一致性
Node.js的出现是前端工程化的关键转折点。它让JavaScript具备了文件操作和网络服务能力,催生了Webpack、Babel、npm等强大的工具链生态,为前端工程化提供了坚实的技术基础。
前端工程化的目标
- 通过自动化构建工具(如Webpack、Vite)处理编译、打包等重复劳动,让开发者能专注于业务逻辑。
- 通过模块化(将代码拆分为独立功能单元)和组件化(构建可复用的UI部件)来组织代码,使得代码结构清晰、易于复用和维护。同时,利用ESLint、Prettier等工具强制统一代码风格,并结合自动化测试,确保代码的健壮性和可读性。
- 通过代码分割、懒加载、Tree Shaking、压缩资源等优化手段,显著减少资源体积,提升应用加载速度和运行时性能。
该项目中工程化的一些配置
多页面的构建
为每个页面配置独立的入口和应用实例
使用 glob 工具动态扫描约定目录 app/pages/**/entry.*.js,为每个找到的入口文件生成一个配置项。通过 webpack 的 entry 和 HtmlWebpackPlugin,为每个入口生成对应的模板(.tpl)文件并自动注入对应的 chunks。这使得增加新页面只需添加符合约定的文件,无需修改构建配置,实现了“约定大于配置”。
- 采用 tpl 的原因:1. 有动态数据,而且动态数据是由服务端注入进去的。 2. html 往往是静态数据。直接返回 html 无法利用框架的数据绑定能力
// app/webpack/config/webpack.base.js
/**
* 动态构造 entry 入口配置和最终渲染的页面模板 HtmlWebpackPlugin
* entry: {
* 'entry.xxx': './app/pages/entry.xxx.js',
* }
*
* new HtmlWebpackPlugin({
* template: path.resolve(process.cwd(), 'app/view/entry.tpl'),
* filename: path.resolve(process.cwd(), 'app/public/dist', 'entry.xxx.tpl')
* chunks: ['entry.xxx'],
* })
*/
const entryFileList = glob.sync(path.resolve(process.cwd(), 'app/pages/**/entry.*.js'))
const entry = {}
const HtmlWebpackPluginList = []
entryFileList.forEach(entryFilePath => {
const entryFileName = path.basename(entryFilePath, '.js')
entry[entryFileName] = entryFilePath
HtmlWebpackPluginList.push(
new HtmlWebpackPlugin({
// 指定要使用的模板文件
template: path.resolve(process.cwd(), 'app/view/entry.tpl'),
// 产物(最终模板)输出路径
filename: path.resolve(process.cwd(), 'app/public/dist', `${entryFileName}.tpl`),
// 要注入的代码块, 对应 entry 中的 key
chunks: [entryFileName],
})
)
})
- Koa 静态服务挂载 app/public ,模板引擎(Nunjucks)负责渲染 .tpl 模板文件, controller 选择注入的入口对应模板,形成完整页面。
分包策略
分包的目的是对代码进行拆分,优化缓存和加载性能。需要综合考虑模块的变更频率、依赖关系和缓存效果。 这里把 js 文件打包成 3 类
- vendor: 将 node_modules 中的第三方库单独打包。这些代码基本不改动, 除非依赖版本升级。可以充分利用浏览器长效缓存。
- common: 业务组件代码的公共部分抽取出来, 改动较少
- entry.{page}: 不同页面 entry 里的业务组件代码的差异部分, 会经常改动
// app/webpack/config/webpack.base.js
module.exports = {
...
optimization: {
splitChunks: {
chunks: 'all', // 对同步和异步模块都进行分割
maxAsyncRequests: 10, // 每次异步加载的最大并行请求数
maxInitialRequests: 10, // 入口点的最大并行请求数
cacheGroups: {
vendor: {
// 第三方依赖库
test: /[\\/]node_modules[\\/]/, // 把 node_modules 下的代码抽离出来
name: 'vendor', // 模块名称
priority: 20, // 数字越大,优先级越高。默认是 0
enforce: true, // 强制执行。忽略 minSize、minChunks、maxAsyncRequests、maxInitialRequests 选项
reuseExistingChunk: true, // 复用已有的公共 chunk
},
common: {
// 公共模块
test:/[\\/]common|widgets[\\/]/,
name: 'common',
priority: 10,
reuseExistingChunk: true,
minSize: 1, // 最小分割文件大小(字节)
minChunks: 2, // 被两处引用即归为公共模块
},
},
},
// 将 webpack 运行时生成的代码打包到 runtime.js 中
runtimeChunk: true,
},
...
}
- optimization.splitChunks: 当 webpack 处理文件路径时,它们始终包含 Unix 系统中的
/和 Windows 系统中的\。这就是为什么在 {cacheGroup}.test 字段中使用[\\/]来表示路径分隔符的原因。{cacheGroup}.test 中的/或\会在跨平台使用时产生问题。 - runtimeChunk: true :将 Webpack 运行时代码抽到独立的 runtime.js ,减少入口包的无效改动,提升缓存命中。
开发环境与生产环境的区别
开发环境
- 目标:快速反馈、热更新、较快构建、详细错误报告。
产物经 devServer 内存提供,模板 .tpl 写至 app/public/dist/dev/, 浏览器通过 publicPath 从 devServer 获取资源并进行热更新。(仅 .tpl 输出到磁盘)
- 修改代码并保存后,几乎能立即在浏览器中看到变化,无需手动刷新页面
关键步骤:devServer、source-map、热更新、 内存系统。
// app/webpack/config/webpack.dev.js
const path = require('path')
const merge = require('webpack-merge')
// 引入基类配置
const webpackBaseConfig = require('./webpack.base')
const HOST = '127.0.0.1'
const PORT = 9002
// 开发环境配置
const webpackConfig = merge.smart(webpackBaseConfig, {
mode: 'development',
// 使浏览器可以重构原始源并在调试器中显示重建的原始源
devtool: 'eval-cheap-module-source-map',
output: {
path: path.resolve(process.cwd(), 'app/public/dist/dev'), // 输出文件存储路径
publicPath: `http://${HOST}:${PORT}/public/dist/dev/`, // 外部资源公共路径
filename: 'js/[name]-[contenthash:8].bundle.js',
crossOriginLoading: 'anonymous',
},
// 启用 devServer 进行 HMR
devServer: {
host: HOST,
port: PORT,
static: {
directory: path.resolve(process.cwd(), 'app/public/dist'),
},
devMiddleware: {
// 需要落地的文件
writeToDisk: filePath => filePath.endsWith('.tpl'),
},
compress: true, // 启用 gzip compression 压缩文件
hot: true,
allowedHosts: [HOST],
},
})
module.exports = webpackConfig
// package.json
{
"scripts": {
"build:dev": "webpack serve --config ./app/webpack/config/webpack.dev.js",
},
}
- 如果命令中没有
--config ./app/webpack/config/webpack.dev.js,Webpack 会忽略你的配置,使用内置默认配置(默认入口为./src),从而导致报错。
手动搭建devServer
在技术选型中使用的是 webpack-dev-middleware 而非 webpack-dev-server,使用 webpack-hot-middleware 依赖以在自定义服务器或应用程序上启用 HMR。
// app/webpack/config/webpack.dev.js
const path = require('path')
const merge = require('webpack-merge')
const webpack = require('webpack')
// 引入基类配置
const webpackBaseConfig = require('./webpack.base.js')
// devServer配置
const HOST = '127.0.0.1'
const PORT = 9002
const HRM_PATH = '__webpack_hmr'
const TIMEOUT = 1000 * 10
const DEV_SERVER_CONFIG = { HOST, PORT, HRM_PATH, TIMEOUT }
// 开发阶段的 entry 配置需要加入 hrm
Object.keys(webpackBaseConfig.entry).forEach(key => {
webpackBaseConfig.entry[key] = [
// 主入口文件
webpackBaseConfig.entry[key],
// hrm 更新入口,官方指定的 hmr 路径
`webpack-hot-middleware/client?path=http://${HOST}:${PORT}/${HRM_PATH}&timeout=${TIMEOUT}&reload=true`,
]
})
const webpackConfig = merge(webpackBaseConfig, {
// 开发环境配置
mode: 'development',
// 使浏览器可以重构原始源并在调试器中显示重建的原始源
devtool: 'eval-cheap-module-source-map',
output: {
filename: 'js/[name]_[chunkhash:8].bundle.js',
path: path.resolve(process.cwd(), './app/public/dist/dev'), // 输出文件存储路径
publicPath: `http://${HOST}:${PORT}/public/dist/dev/`, // 外部资源公共路径
globalObject: 'this',
},
plugins: [
// 热更新插件。允许在应用程式运行时替换模块
new webpack.HotModuleReplacementPlugin({
multiStep: false,
}),
],
})
module.exports = {
webpackConfig,
DEV_SERVER_CONFIG,
}
// app/webpack/dev.js
// 本地开发启动 devServer
const express = require('express')
const path = require('path')
const consolere = require('consoler')
const webpack = require('webpack')
const webpackDevMiddleware = require('webpack-dev-middleware')
const webpackHotMiddleware = require('webpack-hot-middleware')
const { webpackConfig, DEV_SERVER_CONFIG } = require('./config/webpack.dev.js')
const app = express()
const compiler = webpack(webpackConfig)
// 指定静态文件目录
app.use(express.static(path.resolve(process.cwd(), './app/public/dist')))
// 引用 webpack-dev-middleware 中间件(监控文件改动)
app.use(
webpackDevMiddleware(compiler, {
// 需要落地的文件
writeToDisk: filePath => filePath.endsWith('.tpl'),
// 资源路径
publicPath: webpackConfig.output.publicPath,
// headers配置
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'X-Requested-With, content-type, Authorization',
},
stats: {
colors: true,
},
})
)
// 引用 webpack-hot-middleware 中间件,用于实现热更新功能
app.use(
webpackHotMiddleware(compiler, {
path: `/${DEV_SERVER_CONFIG.HRM_PATH}`,
log: () => {},
})
)
consolere.info('请等待 webpack 初次构建完成提示...')
const port = DEV_SERVER_CONFIG.PORT
app.listen(port, () => {
console.log(`app listening on port ${port}\n`)
})
- 引入了 devMiddleware, 实质资源地址已经被 devMiddleware 这个覆盖
- publicPath: webpackConfig.output.publicPath
- devServer 的静态目录实质并没有具体能应用到的场景
- devServer.static.directory: path.resolve(process.cwd(), 'app/public/dist')
- app.use(express.static(path.resolve(process.cwd(), './app/public/dist')))
生产环境
- 目标:性能优先、缓存友好。
产物写至 app/public/dist/prod/;浏览器通过 '/dist/prod/' 前缀加载静态资源。
关键步骤:压缩、混淆,拆分代码
多进程打包
Thread-loader
使用时,需将此 loader 放置在其他 loader 之前。放置在此 loader 之后的 loader 会在一个独立的 worker 池中运行。
MiniCssExtractPlugin.loader需要在加载器链的最前面,它的作用是将 CSS 提取到单独的文件中,而不是嵌入到 JavaScript 文件中,需要直接与css-loader配合,因此应该放在加载器链的最前面。
每个 worker 都是一个独立的 node.js 进程,其开销大约为 600ms 左右。同时会限制跨进程的数据交换。
// app/webpack/config/webpack.prod.js
const path = require('path')
const merge = require('webpack-merge')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CSSMinimizerWebpackPlugin = require('css-minimizer-webpack-plugin')
const HtmlWebpackInjectAttributesPlugin = require('html-webpack-inject-attributes-plugin')
const TerserWebpackPlugin = require('terser-webpack-plugin')
// 引入基类配置
const webpackBaseConfig = require('./webpack.base')
// 生产环境配置
const webpackConfig = merge.smart(webpackBaseConfig, {
mode: 'production',
output: {
path: path.resolve(process.cwd(), 'app/public/dist/prod'),
publicPath: '/dist/prod/',
filename: 'js/[name]-[contenthash:8].bundle.js',
crossOriginLoading: 'anonymous',
},
module: {
rules: [
{
// 多进程打包 js
test: /\.js$/,
use: ['thread-loader', 'babel-loader'],
include: [path.resolve(process.cwd(), 'app/pages')],
},
{
// 多进程打包 css
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'thread-loader', 'css-loader'],
},
],
},
plugins: [
// 提取 css 的公共部分,有效利用缓存
new MiniCssExtractPlugin({
filename: 'css/[name]-[contenthash:8].bundle.css',
}),
// 优化并压缩 css
new CSSMinimizerWebpackPlugin(),
// 浏览器在请求资源时不发送用户的身份凭证
new HtmlWebpackInjectAttributesPlugin({
crossorigin: 'anonymous',
}),
],
optimization: {
// 使用 TerserWebpackPlugin 的并发和缓存,提升压缩阶段的性能
minimize: true,
minimizer: [
new TerserWebpackPlugin({
cache: true, // 启用缓存来加速构建过程
parallel: true, // 启用多核 CPU 的优势加快压缩速度
terserOptions: {
compress: {
drop_console: true, // 清除console.log
},
},
}),
],
},
performance: {
hints: 'error',
},
})
module.exports = webpackConfig
HappyPack
// app/webpack/config/webpack.prod.js
const path = require('path')
const os = require('os')
const merge = require('webpack-merge')
const HappyPack = require('happypack')
const happypackCommonConfig = {
debug: false,
threadPool: HappyPack.ThreadPool({ size: os.cpus().length }),
}
// 引入基类配置
const webpackBaseConfig = require('./webpack.base.js')
const webpackConfig = merge.smart(webpackBaseConfig, {
...
module: {
rules: [
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'happypack/loader?id=css'],
},
{
test: /\.js$/,
include: [
// 只对业务代码进行 babel
path.resolve(process.cwd(), './app/pages'),
],
use: 'happypack/loader?id=js',
},
],
},
plugins: [
...
// 多线程打包 js,加快打包速度
new HappyPack({
...happypackCommonConfig,
id: 'js',
loaders: [
`babel-loader?${JSON.stringify({
presets: ['@babel/preset-env'],
plugins: ['@babel/plugin-transform-runtime'],
})}`,
],
}),
// 多线程打包 css,加快打包速度
new HappyPack({
...happypackCommonConfig,
id: 'css',
loaders: [
{
path: 'css-loader',
options: { importLoaders: 1 },
},
],
}),
],
...
})
module.exports = webpackConfig