1. elpis 为什么需要工程化?
elpis致力于打造企业级应用框架,支持多系统切换。elpis 的渲染模式:每个独立站点的入口由 SSR 渲染分发,而每个入口渲染的页面又是一个独立的 SPA 应用。
工程化在 elpis 中的首要作用就是:根据不同的入口文件,生成不同的页面模板,并指定页面模板需要注入的代码块。
其次,我们想要将代码提交到生成环境中运行,还需要代码进行兼容性处理、分包、压缩、打包分析等等,以保证生产环境代码的正常运行。再者,对于前端的代码的修改,每次修改后都需要重启服务器过于麻烦,因此对于开发环境,我们还需要代码的热更新功能,实现代码修改后的实时热更新,提升本地开发效率和体验。
2. elpis 如何做工程化?
2.1 SSR 入口文件的处理
对于SSR的入口文件,如果我们使用的是 Vue 框架,则我们需要创建一个 Vue-APP;如果我们使用的是 React,那么我们需要创建一个 React-APP;但是我们的目的打造的是一个多入口的应用,如果每个入口都重复的写一遍创建 APP 应用的代码,就显得很繁琐。
于是,我们可以对于 Vue 框架设计一个 boot-vue.js 的公共方法,用于创建 Vue 的 APP应用(React同理)。再通过配置项的形式给到不同的 APP 可以创建不同的 APP 实例,它们可以拥有不同的路由、不同的三方库等等。
我们以创建 Vue 的 APP 为例,创建一个 boot.js 方法用于创建 Vue-APP:
js
import { createApp } from 'vue';
import ElementPlus from 'element-plus';
import 'element-plus/theme-chalk/index.css';
import 'element-plus/theme-chalk/dark/css-vars.css';
import pinia from '@store';
import { createRouter, createWebHistory } from 'vue-router';
/**
* 应用主入口 启动多个入口
* @param {Object} pageComponent 页面入口组件
* @param {Object} options 配置项
* - {Array} routes 路由配置
* - {Array} libs 需要加载的库
*/
export default (pageComponent, options) => {
const { routes, libs } = options || {};
const app = createApp(pageComponent);
app.use(ElementPlus); // 注册 UI 组件库
// 加载第三方库
if (libs && libs?.length > 0) {
try {
for (let i = 0; i < libs.length; i++) {
app.use(libs[i]);
}
} catch (error) {
console.error(`加载第三方库失败: ${error}`);
}
}
app.use(pinia); // 注册 pinia
// 页面路由
if (routes && routes?.length > 0) {
const router = createRouter({
history: createWebHistory(), // 统一使用 history 模式
routes,
});
app.use(router);
router.isReady().then(() => {
app.mount('#root');
})
} else {
app.mount('#root');
}
};
不同的应用页面入口,直接调用 boot.js 即可创建 APP 应用。
2.2 Vue单文件的处理
因为使用到了 Vue 框架单文件,因此我们需要给工程加上处理 Vue 单文件的能力。
首先,以面向对象的编程思维,我们新建一个 webpack.common.js 文件,将开发环境和生产环境都需要的配置配置在此,而开发环境的入口文件 webpack.dev.js、生产环境的入口文件 webpack.prod.js 会先继承 webpack.common.js 文件配置,在新增各种环境所需的配置即可(这里使用的是 webpack,当然使用 vite 等其他构建工具也行)。
对于 Vue,我们需要使用解析 Vue 对应的 vue-loader 和 插件 VueLoaderPlugin。
js
const { VueLoaderPlugin } = require('vue-loader');
module.exports = {
module: {
rules: [
{
test: /\.vue$/,
use: [ 'vue-loader' ]
},
// ...
]
},
plugins: [
new VueLoaderPlugin(),
new webpack.DefinePlugin({
__VUE_OPTIONS_API__: 'true', // 支持 vue 解析 optionApi
__VUE_PROD_DEVTOOLS__: 'false', // 禁用 Vue 调试工具
__VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false' // 禁用 水合 信息
}),
]
}
2.3 入口文件的处理
约定:我们的入口文件以 entry.xxx.js 的命名形式存在的。
经过 vue 相关的 loader 和 插件的处理后,可以将 vue 文件打包成 js 文件,我们还需要根据不同的入口页面模板,给它们注入不同的代码块(我们的应用可能有很多很多的入口文件,如果每个入口文件都在 webpack.common.js 文件中单独声明且指定它们的代码块,那么这个配置文件就会显得特别臃肿。从工程化的角度,这里我们采用统一处理)。
js
// 所有页面的入口
const pageEntries = {};
// 所有页面的 html 插件配置
const htmlWebpackPluginList = [];
// 获取 app/pages 目录下所有的入口文件(entry.xxx.js)
const entryFileList = path.resolve(process.cwd(), './app/pages/**/entry.*.js');
glob.sync(entryFileList).forEach(file => {
const entryFileName = path.basename(file, '.js');
pageEntries[entryFileName] = file;
htmlWebpackPluginList.push(new HtmlWebpackPlugin({
template: path.resolve(process.cwd(), './app/views/entry.tpl'),
filename: path.resolve(process.cwd(), './app/public/dist/', `${entryFileName}.tpl`),
chunks: [ entryFileName ]
}))
})
module.exports ={
plugins: [
...htmlWebpackPluginList
]
}
2.4 样式文件的处理
处理 less、css 等样式文件,需要使用到对应的 loader(注意:loader 的执行顺序是从右到左)。
js
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.less$/,
use: ['style-loader', 'css-loader', 'less-loader'],
},
2.5 处理图片等其他资源
webpack5 以及内置处理图片、字体文件等资源的功能,这里就不再赘述。
2.6 生产环境处理
考虑到项目庞大之后,打包的速度会很慢,打包后的代码体积会很大。所有我们针对这些问题需要做出对应的配置。
优化打包速度,以使用 thread-loader 为示例:
js
const ThreadLoaderConfig = {
workers: OS.cpus().length,
workerNodeArgs: ['--max-old-space-size=1024'],
workerParallelJobs: 50,
}
{
loader: 'thread-loader', // 小项目没有必要使用,启动进程也需要时间的
options: {
...ThreadLoaderConfig
}
},
优化单个文件打包体积过大,在 vue-router 上,我们使用动态导入的策略,加上 webpack 的分包策略,对 js 进行分包:
js
// 动态导入
component: () => import('./complex-view/schema-view/schema-view.vue')
// 分包
splitChunks: {
chunks: 'all',
maxAsyncRequests: 10,
maxInitialRequests: 10,
cacheGroups: {
vendor: { // 第三方模块
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 20,
enforce: true,
reuseExistingChunk: true,
},
common: {
test: /[\\/]widgets|common[\\/]/,
name: 'common',
minChunks: 2,
minSize: 1,
priority: 10,
reuseExistingChunk: true,
},
}
},
实现 js 文件的动态导入,减小首屏加载的资源体积。
2.7 开发环境搭建
使用 express 搭建本地服务器,结合 webpack 的中间件,监听文件的改动,当文件改动时,通知浏览器更新页面局部内容,达到实时热更新的效果。使用到的 webpack 中间件有 webpack-dev-middleware、webpack-hot-middleware,以及 HMR 插件 HotModuleReplacementPlugin。具体配置细节需要查看 webpack 官方文档。
js
// webpack.dev.js
const path = require('path');
const webpack = require('webpack');
const merge = require('webpack-merge');
const webpackCommonConfig = require('./webpack.common');
const DEV_SERVER_CONFIG = {
HOST: '127.0.0.1',
PORT: 2024,
HMR_PATH: '__webpack_hmr',
TIMEOUT: 20000
}
Object.keys(webpackCommonConfig.entry).forEach(v => {
if (v !== 'vendor') {
const { HOST, PORT, HMR_PATH, TIMEOUT } = DEV_SERVER_CONFIG;
webpackCommonConfig.entry[v] = [
webpackCommonConfig.entry[v],
`webpack-hot-middleware/client?path=http://${HOST}:${PORT}/${HMR_PATH}?timeout=${TIMEOUT}&reload=true`
]
}
})
const webpackDevConfig = merge.smart(webpackCommonConfig, {
mode: 'development',
// 开发环境打包产物输出
output: {
path: path.resolve(process.cwd(), './app/public/dist/dev/'), // dev 输出文件路径
publicPath: `http://${DEV_SERVER_CONFIG.HOST}:${DEV_SERVER_CONFIG.PORT}/public/dist/dev/`, // 服务器-外部资源的路径
filename: 'js/[name]_[chunkhash:8].bundle.js',
globalObject: 'this',
},
plugins: [
// 应用运行时,可以热模块替换
new webpack.HotModuleReplacementPlugin({
multiStep: false,
}),
],
devtool: 'cheap-module-source-map'
})
module.exports = {
webpackDevConfig,
DEV_SERVER_CONFIG
};
本地开发环境启动入口文件,如下:
js
// dev.js 本地开发环境启动文件
const path = require('path');
const express = require('express');
const webpack = require('webpack');
const WebpackDevMiddleware = require('webpack-dev-middleware');
const WebpackHotMiddleware = require('webpack-hot-middleware');
const { webpackDevConfig, DEV_SERVER_CONFIG } = require('./config/webpack.dev');
const app = express(); // 初始化 express 服务
const compiler = webpack(webpackDevConfig);
// 指定 静态文件目录
app.use(express.static(path.join(__dirname, '../public/dist')))
// 监控文件改动
app.use(WebpackDevMiddleware(compiler, {
writeToDisk: (filePath) => filePath.endsWith('.tpl'),
publicPath: webpackDevConfig.output.publicPath,
// headers
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, HEAD, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'X-Request-With, Content-Type, Authorization',
},
stats: {
colors: true,
}
}))
// 热更新,通知浏览器
app.use(WebpackHotMiddleware(compiler, {
path: `/${DEV_SERVER_CONFIG.HMR_PATH}`,
log: () => {}
}))
console.log('=== 等待 webpack 初次构建完成... ===');
const { PORT } = DEV_SERVER_CONFIG;
app.listen(PORT, () => {
console.log(`dev server listening on port: ${PORT}`);
})
2.8 增加启动脚本
区分本地开发环境启动脚本、生产环境打包脚本
json
"build:dev": "node --max_old_space_size=4096 ./app/webpack/dev.js",
"build:prod": "node ./app/webpack/prod.js"
3. elpis工程化总结
为了能够集成【node服务 => SSR => SPA】的能力我们处理了这些问题:
- 应用程序的创建方法封装
- Vue 文件的处理
- 页面模板与代码块的绑定
- 样式处理
- 资源处理
- 生产环境处理
- 开发环境搭建
这些处理只是冰山一角,还有很多需要处理的问题,如:代码兼容性处理(css兼容性处理、js兼容性处理等等),这里就不再赘述。
阶段二完结,阶段三的文字飞奔中...(欢迎各位佬交流)