elpis-core 前端 Webpack 工程化实践

一、工程目标:用统一构建链连接页面与服务

elpis-core 前端工程化要解决的核心问题,是让业务页面拥有统一的入口、依赖处理、开发反馈和交付方式。开发者编写 Vue 组件,Webpack 将模块及资源转换为浏览器可执行的产物,Koa 再通过模板引擎把页面交付给用户。

当前项目采用多页应用结构:page1、page2 各有一个入口文件,各自产生一个页面模板。页面之间共享 Vue、Element Plus 等基础依赖,以及统一的启动函数。页面内部还预留了 Vue Router 的接入方式,可以在一个服务端页面内组织局部前端路由。

graph TD P1[&#34;page1 页面入口<br/>entry.page1.js&#34;] --> W[&#34;Webpack 构建<br/>Loader 转换 · Plugin 编排&#34;] P2[&#34;page2 页面入口<br/>entry.page2.js&#34;] --> W W --> T[&#34;页面模板 .tpl&#34;] W --> A[&#34;JS / CSS / 图片资源&#34;] T --> K[&#34;Koa + Nunjucks<br/>渲染请求期数据&#34;] A --> D[&#34;开发:9090 内存服务<br/>生产:静态资源服务&#34;] K --> B[&#34;浏览器页面&#34;] D --> B B --> V[&#34;boot 初始化 Vue<br/>挂载到 #root&#34;] V --> API[&#34;/api 业务请求&#34;]

这种结构把三个阶段分开:构建期负责依赖分析与资源地址注入,请求期负责模板数据渲染,浏览器运行期负责组件交互。当前 Vue 组件通过 createApp() 在浏览器挂载,服务端渲染的是 HTML 页面壳及配置数据,并没有执行 Vue 组件的 SSR 渲染。

工程相关目录如下:

text 复制代码
app/
├── pages/
│   ├── boot.js                 Vue 应用统一初始化
│   ├── page1/
│   │   ├── entry.page1.js       page1 构建入口
│   │   └── page1.vue           页面组件
│   ├── page2/
│   │   ├── entry.page2.js
│   │   └── page2.vue
│   ├── common/curl.js          请求封装
│   ├── store/index.js          Pinia 实例
│   └── assets/cusiom.css       公共样式,沿用源码拼写
├── view/entry.tpl              Webpack 使用的模板源文件
├── webpack/
│   ├── dev.js                  Express 开发构建服务
│   ├── prod.js                 一次性生产构建脚本
│   └── config/
│       ├── webpack.base.js     多页入口与公共构建规则
│       ├── webpack.dev.js      开发配置与 HMR
│       └── webpack.prod.js     生产资源与优化配置
├── public/
│   ├── static/                直接提供给浏览器的公共资源
│   └── dist/                  构建生成的模板及资源
├── controller/view.js         调用 ctx.render
└── router/view.js             声明 /view/:page

二、多页入口与模板:把目录约定变成构建规则

2.1 自动发现页面入口

app/webpack/config/webpack.base.js 使用 glob 扫描 app/pages/**/entry.*.js,以去掉扩展名后的文件名作为入口名称。每个入口同时创建一个 HtmlWebpackPlugin 实例。以下为源码逻辑的等价简化:

javascript 复制代码
const glob = require('glob');
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');

const entries = {};
const htmlPlugins = [];
const pattern = path.resolve(process.cwd(), 'app/pages/**/entry.*.js');

for (const file of glob.sync(pattern)) {
  const entryName = path.basename(file, '.js');
  entries[entryName] = file;
  htmlPlugins.push(new HtmlWebpackPlugin({
    filename: path.resolve(
      process.cwd(), 'app/public/dist', `${entryName}.tpl`
    ),
    template: path.resolve(process.cwd(), 'app/view/entry.tpl'),
    chunks: [entryName]
  }));
}

这段代码建立了一组稳定的映射关系:

入口文件 Webpack 入口名 生成模板 页面地址
pages/page1/entry.page1.js entry.page1 dist/entry.page1.tpl /view/page1
pages/page2/entry.page2.js entry.page2 dist/entry.page2.tpl /view/page2

表中 pages、dist 分别位于 app 和 app/public 下。入口名称取自文件名,不包含父目录;不同目录中的入口文件仍应保持名称唯一。由于 glob 扫描在配置加载时执行,新增入口文件后需要重启构建进程,让新入口进入配置。

chunks: [entryName] 的含义是为模板选择这个入口对应的资源组。入口依赖的 runtime 和共享 chunk 仍会一起注入,不代表最终 HTML 中只有一个 script 标签。本次开发配置隔离编译生成的 page1 模板就包含 runtime、vendors、entry.page1 三个脚本,不包含 entry.page2 的入口脚本。

2.2 同一个模板经过两次处理

模板源文件 app/view/entry.tpl 提供页面骨架,核心内容可简化为:

html 复制代码
<!DOCTYPE html>
<html>
  <head>
    <title>{{name}}</title>
    <link href="/static/normalize.css" rel="stylesheet">
  </head>
  <body>
    <div id="root"></div>
    <input id="env" value="{{env}}" style="display:none">
    <input id="options" value="{{options}}" style="display:none">
  </body>
</html>

构建期,HtmlWebpackPlugin 注入带哈希的资源地址,并生成 .tpl。其中 {{name}}{{env}}{{options}} 保留为模板占位符。请求期,Koa 的 ViewController 再执行:

javascript 复制代码
// app/controller/view.js:当前分支源码节选
await ctx.render(`./dist/entry.${ctx.params.page}`, {
  name: app.options?.name,
  env: app.env.get(),
  options: JSON.stringify(app.options)
});

Nunjucks 的根目录是 app/public,扩展名是 tpl,所以 /view/page1 最终读取 app/public/dist/entry.page1.tpl。这个分支已经把模板渲染路径接到了 Webpack 的 dist 产物。

text 复制代码
app/view/entry.tpl
        │  构建期:注入 JS / CSS 地址
        ▼
app/public/dist/entry.page1.tpl
        │  请求期:填充 name / env / options
        ▼
浏览器收到 HTML
        │  运行期:加载脚本、读取配置、挂载 Vue
        ▼
可交互页面

原始模板还把隐藏输入框中的值赋给 window.envwindow.options。这是一条从服务端配置到浏览器的传递通道,页面可读取应用级选项;进入 HTML 的配置应仅包含允许在浏览器中访问的字段。

三、基础配置:模块转换、路径解析与依赖注入

3.1 Loader 负责把资源转换成模块

公共规则集中在 webpack.base.js。Loader 数组通常从右向左执行,因此 Less 的转换顺序是先编译为 CSS,再处理 CSS 依赖,最后注入页面。

输入类型 当前基础处理链 作用
.vue vue-loader 拆解单文件组件的模板、脚本与样式
app/pages 内的 .js babel-loader 为业务脚本提供 Babel 转换入口
.css css-loader → style-loader 处理样式依赖并注入 style 标签
.less less-loader → css-loader → style-loader 编译 Less 并注入样式
png、jpg、gif、svg 等匹配图片 url-loader 小资源内联,其余交由文件输出
eot、ttf、woff、woff2 file-loader 输出字体资源并返回访问地址

这里箭头表示转换发生的先后顺序;实际配置中 CSS 写为 use: ['style-loader', 'css-loader']

VueLoaderPlugin 配合 vue-loader,使基础规则也能应用于 .vue 文件拆出的脚本及样式。例如页面中的 <style lang="less" scoped> 会参与 Less 处理,scoped 则用于生成组件范围内的样式选择器。

图片规则设置 limit: 300,单位是字节,达到内联条件的小图片会转换为数据 URL。这个阈值很小,不能把它理解成 300 KB。模板里直接引用的 /static/logo.png 没有通过业务模块 import,因此不会自动进入这条 url-loader 处理链。

基础配置只为业务 JS 指定 babel-loader;Babel 实际做哪些转换还取决于 presets、plugins 和目标配置。当前生产 HappyPack 的 JS 配置显式指定 @babel/preset-env@babel/plugin-transform-runtime,不能仅凭"使用了 babel-loader"就断言开发和生产具有完全一致的转译行为。

3.2 别名让业务代码围绕模块职责组织

javascript 复制代码
// webpack.base.js:源码节选
resolve: {
  extensions: ['.js', '.vue', '.less', '.css'],
  alias: {
    $pages: path.join(process.cwd(), 'app/pages'),
    $common: path.join(process.cwd(), 'app/pages/common'),
    $widgets: path.join(process.cwd(), 'app/pages/widgets'),
    $store: path.join(process.cwd(), 'app/pages/store'),
    $assets: path.join(process.cwd(), 'app/pages/assets'),
    $utils: path.join(process.cwd(), 'app/utils')
  }
}

因此入口可以写 import boot from '$pages/boot.js',组件可以写 import $curl from '$common/curl'。这里的别名在构建时解析,浏览器收到的是已编译模块,不需要认识 $pages。别名也不会创建目录,使用 $widgets$utils 前仍要准备对应模块。

3.3 ProvidePlugin 与 DefinePlugin 的职责

基础配置通过 ProvidePlugin 配置 Vue、axios 和 lodash:

javascript 复制代码
new webpack.ProvidePlugin({
  Vue: 'vue',
  axios: 'axios',
  _: 'lodash'
});

它会在模块使用相应自由标识符时注入模块依赖,不会因为这份配置就自动创建 window.Vuewindow.axios。当前 curl.js 本身显式引入 axios,模板又通过 CDN 引入 axios 和 js-md5;构建没有配置 externals,因此这些 CDN 标签并不会把 bundle 中对应的依赖排除出去。

DefinePlugin 负责构建期常量替换,例如 Vue 的功能开关。当前配置还将外部 process.env.NODE_ENV 写入 DefinePlugin,而开发和生产配置又分别指定 mode。若外部环境变量未设置或与 mode 不一致,可能出现定义冲突;本次未设置 NODE_ENV 的开发隔离编译就出现了该警告。执行构建时显式设置与 mode 相同的值,可以使两处定义一致。

四、统一 boot:让页面入口保持简单

当前每个页面入口只负责找到页面组件,并把它交给公共启动函数:

javascript 复制代码
// app/pages/page1/entry.page1.js:现有实现
import boot from '$pages/boot.js';
import Page1 from './page1.vue';

boot(Page1);

app/pages/boot.js 集中引入 Element Plus、组件库 CSS、公共样式和 Pinia,并通过 createApp() 创建 Vue 应用。其主路径可概括为:

javascript 复制代码
// 等价简化:展示当前无页面内路由时的启动路径
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import '$assets/cusiom.css';
import pinia from '$store';

export default (Page, { libs = [] } = {}) => {
  const app = createApp(Page);
  app.use(ElementPlus);
  app.use(pinia);
  libs.forEach(lib => app.use(lib));
  app.mount('#root');
};

公共初始化的价值是让不同页面使用相同的 UI、状态管理和插件安装方式。app/pages/store/index.js 当前创建并导出 Pinia 实例,业务 Store 可以在此基础上扩展。不同 HTML 页面有各自的浏览器运行上下文,安装同一套 Pinia 初始化代码不会自动让它们共享运行时状态。

4.1 多页地址与页面内路由

/view/page1/view/page2 由服务端路由控制,切换页面意味着请求另一个 HTML。boot 另外接收 routers 选项,计划使用 createWebHashHistory() 支持页面内部路由,例如 /view/page1#/detail。URL 的 hash 部分由浏览器处理,不会作为服务端的页面参数。

这个可选分支在当前源码里读取了 routers,却把未定义的 routes 直接传给 createRouter。实际 page1、page2 都只调用 boot(Page),没有进入该分支。接入页面内路由时,应使用下面的修正片段:

javascript 复制代码
// 接入页面内路由时的修正示例
if (routers && routers.length) {
  const router = createRouter({
    history: createWebHashHistory(),
    routes: routers
  });
  app.use(router);
  router.isReady().then(() => app.mount('#root'));
} else {
  app.mount('#root');
}

4.2 请求封装承接前后端协议

page1 在 onMounted 中调用 $common/curl,将 query 映射到 Axios 的 params,并把响应 data 赋给表格:

javascript 复制代码
// app/pages/page1/page1.vue:源码节选
onMounted(async () => {
  const res = await $curl({
    url: '/api/project/list',
    method: 'GET',
    query: { proj_key: 'test' }
  });
  tableData.value = res.data;
});

curl.js 集中处理请求头、超时和 Element Plus 错误提示,并追加服务端所需的签名请求头。文章不展开其中的密钥值。当前成功结果按 { success, data, metaData } 返回;后端成功响应则使用 metadata,需要额外数据时应统一字段大小写。另外,函数参数中的 baseURL、responseType 目前没有写入最终 Axios 配置,不能把它们视为已生效的选项。

这层封装让页面关注数据如何展示,但调用方仍需判断结果。当前封装会把部分错误转换为已兑现的 Promise,不能只依靠 catch 处理所有业务失败。

五、开发环境:双服务协作与热更新

5.1 8080 提供页面,9090 提供编译资源

app/webpack/dev.js 使用 Express 承载 webpack-dev-middleware 和 webpack-hot-middleware。它与 Koa 业务服务分别运行:

text 复制代码
浏览器打开 http://127.0.0.1:8080/view/page1
                      │
                      ▼
             Koa 渲染 dist/*.tpl
                      │
          HTML 中注入 9090 资源地址
                      │
                      ▼
http://127.0.0.1:9090/public/dist/dev/js/*.bundle.js
                      │
                      ▼
                Vue 页面运行
                  │       │
     /api 请求 ───┘       └── HMR EventSource
         ▼                          ▼
      Koa 8080              Express 9090
                           /__webpack_hmr

脚本来自 9090,不会把页面的 origin 改成 9090。页面仍由 8080 的文档地址决定来源,所以相对地址 /api/project/list 会请求 Koa 8080。当前开发服务没有配置 API proxy,这条链路通过"业务页面来源与 API 同源"来连接。

在项目根目录中,可以分别启动:

bash 复制代码
# 终端一:前端编译与开发资源服务
NODE_ENV=development npm run build:dev

# 终端二:Koa 业务服务
__ENV=local node index.js

以上是 macOS / Linux 写法。build:dev 实际执行 node --max-old-space-size=8192 ./app/webpack/dev.js;这里给 Node 设置的是堆内存上限,不表示构建一定占用 8 GB。后端直接设置 __ENV 是为了对齐当前 env.js 的实际读取名称。等待前端编译成功后访问 /view/page1,而不是把 9090 的根路径当作业务首页。

5.2 output.path 与 publicPath 分别解决什么

开发配置的关键字段如下:

javascript 复制代码
// app/webpack/config/webpack.dev.js:源码节选
output: {
  filename: 'js/[name]_[chunkhash:8].bundle.js',
  path: path.resolve(process.cwd(), 'app/public/dist/'),
  publicPath: 'http://127.0.0.1:9090/public/dist/dev/',
  globalObject: 'this'
}

output.path 是编译输出文件系统中的根目录;publicPath 是浏览器访问这些资源的 URL 前缀。开发 URL 中出现 /public/dist/dev/,并不意味着磁盘必须存在完全对应的 app/public/dist/dev 目录。dev middleware 会按照 publicPath 将请求映射到它管理的编译结果。

javascript 复制代码
// app/webpack/dev.js:源码节选
app.use(devMiddleware(compiler, {
  writeToDisk: filePath => filePath.endsWith('.tpl'),
  publicPath: devConfig.output.publicPath,
  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 }
}));

这里最关键的是 writeToDisk:模板需要写到磁盘,因为 Koa 的 Nunjucks 会从磁盘读取;JS 等编译资源继续由开发中间件的内存文件系统提供。这样可以同时满足业务模板渲染和开发资源快速更新。代码最后的 express.static 提供磁盘静态文件兜底,正常编译资源主要由前面的 dev middleware 处理。

开发配置还使用 eval-cheap-module-source-map,方便定位到源模块;这类开发产物的大小不能直接当作生产下载体积。

5.3 HMR 的四个参与者

HMR 需要入口客户端、Webpack 热替换插件、服务端事件流及能够接受更新的模块共同配合:

text 复制代码
修改 .vue / .js / .less
          │
          ▼
Webpack 监听依赖变更并重新编译
          │
          ▼
webpack-hot-middleware 发送构建事件
          │  EventSource / SSE
          ▼
浏览器内的 HMR client 收到通知
          │
          ▼
Webpack runtime 获取并应用更新
          │
          ├── 可接受更新:替换受影响模块
          └── 无法接受且开启 reload:尝试整页刷新

开发配置为每个页面入口追加了客户端:

javascript 复制代码
// webpack.dev.js:等价简化
const origin = 'http://127.0.0.1:9090';
const query = new URLSearchParams({
  path: origin + '/__webpack_hmr',
  timeout: 20000,
  reload: true,
  quiet: true
});

baseConfig.entry[entryName] = [
  baseConfig.entry[entryName],
  'webpack-hot-middleware/client?' + query.toString()
];

这里使用完整的 HMR URL 很关键:页面来源是 8080,如果仅传 /__webpack_hmr,EventSource 会默认连接 8080,而实际事件流服务位于 9090。服务端通过 hotMiddleware(compiler, { path: '/__webpack_hmr', heartbeat: 2000 }) 与客户端对应。

HotModuleReplacementPlugin 负责生成更新所需能力,Vue Loader 提供组件层面的更新支持。具体能否保留页面状态取决于变更类型与模块是否接受更新,不能把所有修改都描述成"无刷新且状态完全保留"。此外,配置中的 127.0.0.1 指向浏览器所在设备,当前默认地址适合本机开发;跨设备访问时要同步调整资源与 HMR 地址。

六、生产构建:分包、样式提取与资源交付

生产构建脚本 app/webpack/prod.js 通过 Node API 调用 webpack(ProdConfig, callback),打印编译统计。配置由 merge.smart(baseConfig, productionConfig) 合并而来,复用入口、模板、别名和基础分包规则。

配置维度 开发 生产配置
mode development production
运行方式 Express 常驻编译服务 一次性调用 Webpack
浏览器资源前缀 http://127.0.0.1:9090/public/dist/dev/ /dist/prod
资源输出目录 app/public/dist,开发资源主要在内存 app/public/dist/prod
模板输出目录 app/public/dist app/public/dist
JS 文件名 js/name_chunkhash:8.bundle.js 相同命名规则
Source Map 显式启用开发 Source Map 未启用 devtool,Terser sourceMap 为 false
样式策略 style-loader 注入页面 配置 MiniCssExtractPlugin 提取 CSS
代码压缩 开发调试用途 Terser 与 CSSMinimizer

生产模板仍输出到 dist 根目录,是因为 HtmlWebpackPlugin 使用了绝对 filename,不随 output.path 自动移动到 prod。Koa 的静态根目录是 app/public,因此 /dist/prod/js/... 对应磁盘上的 app/public/dist/prod/js/...。部署时应同时交付 dist 根目录下的模板与 prod 资源,而不是只上传 JS 文件。

6.1 按变化频率组织资源

基础配置设置 runtimeChunk: { name: 'runtime' },把 Webpack 的模块加载运行时代码独立出来,并通过 splitChunks 建立缓存组:

缓存组 当前匹配规则 设计作用
vendor → vendors node_modules,priority 20,enforce true 聚合第三方依赖
common 路径包含 src,至少 3 个 chunk 引用、达到 30000 字节 提取匹配目录内的共享模块
page app/pages,至少 2 个 chunk 引用、达到 30000 字节 提取满足条件的页面共享代码

较大的 priority 优先,而不是源码注释所写的"值越小越优先"。当前业务源码在 app/pages,common 组的 src 条件不能直接代表业务公共目录;page 组名也不表示"每一个页面都会独立生成一个 page chunk"。分组能否输出文件取决于模块匹配、复用关系、体积和最终优化结果。

本次开发配置隔离编译中,两页实际共享 vendors 和 runtime,各有 entry.page1、entry.page2,没有产生名为 common 或 page 的共享包。这个结果有助于理解:splitChunks 描述提取规则,不是提前保证固定数量的文件。

text 复制代码
entry.page1.tpl ──▶ runtime + vendors + entry.page1
entry.page2.tpl ──▶ runtime + vendors + entry.page2
                         ▲
                  同一构建内可复用资源

哈希文件名让资源内容变化时有机会得到新 URL,配合静态服务器的缓存策略,可以减少重复下载。配置使用 JS 的 chunkhash;CSS 插件只显式设置了 chunkFilename: 'css/[name]_[contenthash:8].css',这主要控制非入口 CSS chunk。若希望入口 CSS 也统一采用内容哈希,应另行显式设置 filename,不能把这一条 chunkFilename 当作所有 CSS 文件的命名规则。

6.2 生产优化的作用范围

生产配置包含 MiniCssExtractPlugin、CSSMinimizerPlugin、HappyPack 和 TerserWebpackPlugin。其意图分别是将样式提取为文件、压缩 CSS、并行执行指定 Loader、压缩 JS。Terser 开启 parallel,并设置 drop_console、drop_debugger。HappyPack 的线程数来自 CPU 数量;这属于并行处理策略,实际是否更快还取决于任务大小、进程通信和机器资源。

当前 performance.hints 为 false,所以配置中的入口 5 MB、资源 3 MB 阈值没有形成构建告警或失败门禁。生产脚本也只是打印 stats,没有显式用 stats.hasErrors() 设置非零退出码;因此不能只凭终端出现 building 或进程正常退出,就判断产物可交付。

6.3 合并配置必须看最终规则

当前生产配置展示了一个很典型的工程问题:追加生产 Loader,并不等于删除开发 Loader。实际加载合并配置后,JS 的 use 同时保留了 babel-loader 与 happypack/loader;基础 .css.less 规则也仍然存在,同时又追加了一条 /\.(less|css)$/ 的提取规则。一个样式文件可能匹配多条规则,不能把配置意图直接描述成已经完成了干净的生产样式切换。

更容易控制的写法,是在统一的规则工厂里按环境选择 style-loader 或提取 Loader。下面是说明这一思路的替代设计示例,并非当前分支已经应用的配置:

javascript 复制代码
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

function createStyleRules(isProd) {
  const firstLoader = isProd
    ? MiniCssExtractPlugin.loader
    : 'style-loader';

  return [
    {
      test: /\.css$/,
      use: [firstLoader, 'css-loader']
    },
    {
      test: /\.less$/,
      use: [firstLoader, 'css-loader', 'less-loader']
    }
  ];
}

该设计应替换原有样式规则,而不是再追加一套;生产环境仍需保留 MiniCssExtractPlugin 实例。是否继续采用 HappyPack,也要结合最终 Loader 链与依赖兼容性决定。

相关推荐
2601_953988071 小时前
Ricon组态实时监控 - 毫秒级数据可视化
前端·物联网·数学建模·信息可视化·架构·前端框架
V158897262012 小时前
从滴滴模式看机器人租赁:撮合型租赁平台开发源码的调度系统设计思路
linux·前端·机器人
szarron2 小时前
国产手持式频谱分析仪选型攻略:TFN RC系列 vs HTOOL SA8T频谱分析仪 专业参数对比(军工/路测/调试全覆盖)
开发语言·前端·状态模式
开开心心就好2 小时前
免费桌签打印工具,支持批量导入名字
前端·javascript·人工智能·docker·jupyter·智能手机·语音识别
涛涛ing2 小时前
2026 年 9 月,整个 npm 生态的「心脏」都被 Rust 换了
前端
IT_陈寒2 小时前
明明设了默认值,为什么我的JavaScript函数参数还是undefined?
前端·人工智能·后端
CappuccinoRose2 小时前
Web Components 基础
前端·javascript·交互·web component·shadow dom
晴天162 小时前
前端 Monorepo 入门分享:从概念到实践
前端
lytao1232 小时前
90% 覆盖率不等于没 Bug:用风险配置测试组合
前端·javascript·bug·软件工程