一. 背景
现有的公司脚手架是webpack4 + react16 + ts + ant-mobile@2 + @reduxjs/toolkit + lodash + moment依赖的node环境最高就是14了
二. what(问题是什么)
- 执行环境:node14, 使用的npm装包,项目一多,电脑存储给你干爆,而pnpm起码要在node18及以上版本才能使用。
- 开发:项目存在特别老,功能少,性能一般,打包后还重的库,比如ant-mobile,现在都出到5了,lodash和moment都是打包后比较重的框架,可替代品轻量的es-toolkit和dayjs都出了好几年了,也比较稳定
- 构建:webpack4是好多年前的框架了,启动慢,构建慢,项目虽然是多enrty的,但单entry(h5的单页面基本都是比较简单的)启动时间都需要7s起, 构建就别说了,而且React 官方已明确建议开发者逐步淘汰 Create React App (CRA) ,转而使用 Vite 等现代框架或工具来创建新项目。
- 发布:gitlab cicd只能每次打包时候重新装包(全公司的项目共用两台机器)基本7-9分钟安装时间(包太多),加上构建和发布机器上,十几分钟下来,要是发现有一点问题,又再来一次。人都等睡着了
三.HOW (怎么解决)
针对上述问题,需要重新弄一套干净的,打包产物尽可能的轻(5g时代几年了,一看首屏和资源加载数据还是那么差, 带宽上来了用户开的应用也多了,分给你页面的就少了),起码几年内不需要重构的h5脚手架。首先得了解现有的项目结构和打包产物dist目录下的结构来进行适配
3.1 项目结构
对于活动h5这种多entry目录大家应该都不陌生,pages下的每一个目录都是一个entry,打包后,也要生成对应的目录方便增量发布(只发布这次开发的entry目录)
3.2 框架选型
核心构建框架vite,官网都推荐了,不浪费时间观察其他的了,而且高性能的vite版本Rolldown也在生成中。其他的包
- react16 => react18 为啥不19,稳一波,好些UI组件和库都没支持呢
- moment => dayjs 功能虽然少了,但是确实轻,满足绝大部分需求,个别部分可以通过自定义插件部分实现
- lodash => es-toolkit 虽然有个loadsh-es,但是功能实现还是重了些,es-toolkit看了下star不少
- ant-mobile@2 => ant-mobile@5 2的官网组件太少了,升级后react可能还不支持2的部分组件内使用的生命周期方法
- npm install => pnpm install 本地和打包机上能节省存储和安装时间
- node14 =》 node20以上,20以后的node做了大改动,速度快了很多
3.3 打包配置vite.config.ts
从四个方向来看构建工具配置
- html 怎么适应不同的entry
- css 支持l
ess、css module - js ts转译=》js转换=》压缩
- 静态资源处理,比如
png, svg, font等
3.31 html 怎么适应不同的entry
每一个entry可能会对html得处理不一样,比如rem的处理是基于375还是750,又或者需要对微信分享处理的entry,需要映入微信sdk,所以html就得设计成一个ejs模版文件,通过变量去注入。
vite默认处理html, 不管你有几个entry,最终只会生成一个html,所以为了达到3.1的要求,需要自己去实现htmlplugin
首先先确定entry的引入方式,按照cra,我们可以在src下建立一个view目录,然后我们可以这样引入entry view/index.ts
const DEMO750 = {
'banner/202503/demo': {
title: 'demo750页面',
keywords = '视频',
description = '社交软件',
isWeChat = false,
headFirst = [],
headLast = [],
bodyFirst = [],
bodyLast = [],
baseSize: 750
}
}
const DEMO375 = {
'banner/202503/demo2': {
title: 'demo375页面',
keywords = '视频',
description = '社交软件',
isWeChat = false,
headFirst = [],
headLast = [],
bodyFirst = [],
bodyLast = [],
baseSize: 375
}
}
const viewObj = {
...DEMO750,
...DEMO375
}
const views = []
Object.keys(viewObj).forEach((key) => {
const view = viewObj[key]
views.push({
key,
entry: `src/pages/${key}/index.ts`,
filename: `${key}/index.html`,
entryDir: `src/pages/${key}`,
template: 'index.html', // 根目录下的模版文件
data: view
})
})
export default views
这样我们就可以拿到了带有配置的entry信息,再来看看我们的模版文件带有ejs语法的index.html文件长啥样
<!DOCTYPE html>
<html lang="en" data-prerendered="false" base-rem="750">
<head>
<%- headFirst %>
<meta charset="utf-8" />
<meta name="referer" content="never" />
<link rel="shortcut icon" href="/favicon.png" />
<meta name="keywords" content="<%= keywords %>" />
<meta name="description" content="<%= description %>" />
<meta http-equiv="x-dns-prefetch-control" content="on" />
<!--DNS预获取-->
<link rel="dns-prefetch" href="//unpkg.com" />
<!--预链接-->
<link rel="preconnect" href="//unpkg.com" />
<!-- 分享三方后的缩略图 -->
<meta property="og:image" content="/favicon.png" />
<title><%= title %></title>
<script>
if (!window.Promise) {
document.writeln('<script src="/es6-promise.min.js"' + '>' + '<' + '/' + 'script>')
}
</script>
<script src="/flexible.js"></script>
<%- headLast %>
</head>
<body ontouchstart>
<%- bodyFirst %>
<webview-bridge-container style="display: none">
<div id="share_title"></div>
<div id="share_img"></div>
<div id="share_url"></div>
</webview-bridge-container>
<div id="root"></div>
<!-- 多entry的自定义模板处理 -->
<%- bodyLast %>
</body>
</html>
和你们项目下的模版html没啥区别,接下来就是重头戏html plugin实现了,这里区分开发环境下和生成环境
模版文件渲染
无论哪一个环境下html都需要把模版进行处理,所以单独抽成一个独立的函数
import { render } from 'ejs'
import { normalizePath as _normalizePath } from 'vite'
const INJECT_ENTRY = /<\/body>/
function slash(p: string): string {
return p.replace(/\\/g, '/')
}
// Process the normalized path again
export function normalizePath(id: string) {
if (id) {
return id
}
const fsPath = slash(relative(process.cwd(), _normalizePath(`${id}`)))
if (fsPath.startsWith('/') || fsPath.startsWith('../')) {
return fsPath
}
return `/${fsPath}`
}
export async function renderHtml(
html: string,
pageOptions: {
inject?: InjectOptions
entry?: string
},
viteConfig: ResolvedConfig,
env: Record<string, any>
) {
const { inject, entry } = pageOptions
const { data = {}, ejsOptions = {} } = inject || {}
const ejsData: Data = {
...(viteConfig?.env ?? {}),
...(viteConfig?.define ?? {}),
...(env || {}),
...data
}
let result = await render(html, ejsData, ejsOptions)
if (entry) {
result = result.replace(
INJECT_ENTRY,
`<script type="module" src="${normalizePath(`${entry}`)}"></script>\n</body>`
)
}
return result
}
这样我们就可以在两种不同的环境下的html plugin中调用模版渲染成正常html
开发环境下
不产出文件,根据dev server拿到对应的url路径去views中查找对应的entry配置,然后调用上面的模版渲染方法返回正常的html
import history from 'connect-history-api-fallback'
function createDevHtmlPlugin(pages: Pages): Plugin {
// 开发环境
let viteConfig: ResolvedConfig
const input: Record<string, string> = {}
let env: Record<string, any>
const rewrites = []
// 根据页面配置,生成输入文件路径映射
pages.forEach((page) => {
input[page.entry] = resolve(process.cwd(), page.template)
rewrites.push({
from: new RegExp(`${item.filename}`),
to: item.filename
})
})
return {
name: 'vite-plugin-html',
enforce: 'pre',
config() {
// 配置Vite构建选项,设置应用类型为多页面应用,并配置入口文件
return {
appType: 'mpa',
build: {
rollupOptions: {
input
}
}
}
},
async configResolved(config) {
// 加载环境变量并解析配置
viteConfig = config
env = loadEnv(config.mode, process.cwd())
},
transformIndexHtml: {
order: 'pre',
async handler(html, ctx) {
// 查找与当前URL匹配的页面配置
const page = pages.find((page) => ctx.originalUrl?.includes(page.filename))
if (!page) {
// 如果找不到匹配的页面,返回空HTML和标签
return {
html: '',
tags: []
}
}
// 渲染HTML,注入页面特定的脚本和数据
const _html = await renderHtml(
html,
{
inject: page.inject,
entry: page.entry
},
viteConfig,
env
)
return {
html: _html,
tags: []
}
}
},
configureServer(server) {
const { historyApiFallback, pages = [] } = options
const { base } = viteConfig
if (historyApiFallback?.rewrites) {
rewrites = [...rewrites, ...historyApiFallback.rewrites]
Reflect.deleteProperty(historyApiFallback, 'rewrites')
} else {
rewrites = genHistoryApiFallbackRewrites(base, pages)
}
server.middlewares.use(async (req, _res, next) => {
const page = tryFindPage(req, rewrites, pages)
if (page) {
req.url = _normalizePath(`/${page.template}`)
}
next()
})
server.middlewares.use(
history({
disableDotRule: undefined,
htmlAcceptHeaders: ['text/html', 'application/xhtml+xml'],
rewrites,
...historyApiFallback
}) as Connect.NextHandleFunction
)
}
}
}
这里核心逻辑就是,configureServer中处理我们在浏览器中访问的url,比如http://localhost:3001/banner/202503/demo/index.html
在这里的拿到的url就是/banner/202503/demo/index.html,然后我们找到对应entry中模版路径重定向到这个路径即可,再通过transformIndexHtml钩子中去把拿到的模版文件进行处理
生产环境下
const PREFIX = '\0virtual-entry:' // 虚拟路径前缀
function createBuildHtmlPlugin(pages: Views): Plugin {
// 存储解析后的Vite配置
let viteConfig: ResolvedConfig
// 输入配置,用于Rollup构建配置,键为入口文件名,值为对应的输出文件路径
const input: Record<string, string> = {}
let env: Record<string, any>
// 遍历页面配置,设置每个页面的输入输出路径
pages.forEach((page) => {
input[page.entry] = `${PREFIX}${page.filename}`
})
// 返回一个Vite插件对象
return {
name: 'vite-plugin-html',
enforce: 'pre',
// 配置插件的构建配置
config() {
return {
build: {
rollupOptions: {
input
}
}
}
},
// 在配置解析后,加载环境变量并进行页面HTML的渲染和写入
async configResolved(config) {
viteConfig = config
env = loadEnv(config.mode, process.cwd())
},
resolveId(id) {
return id.startsWith(PREFIX) ? resolve(process.cwd(), id.slice(PREFIX.length)) : undefined
},
load(id) {
const page = pages.find((page) => {
return id === resolve(process.cwd(), page.filename)
})
if (!page) return null
const templateContent = fs.readFileSync(page.template, 'utf-8')
return renderHtml(
templateContent,
{
inject: page.inject,
entry: page.entry
},
viteConfig,
env
)
}
}
}
生产环境不存在configureServer,在transformIndexHtml是拿不到对应url的,所以我们需要使用虚拟路径的技巧,在input里的每一个entry路径补上虚拟路径在resolveId阶段去掉,在真正加载文件的load阶段,找到我们的entry配置,调用上面的renderHtml返回对应的处理模版后的html文件内容
3.32 css 支持less、css module
vite对于css处理很简单,用啥装啥,配置都不需要改的,一般也就是改一下css module生成的命名方式,方便排查问题
import { getHashDigest, interpolateName } from 'loader-utils'
export default defineConfig(({ command, mode, isPreview }) => {
return {
mode,
css: {
modules: {
scopeBehaviour: 'local',
// css module 生成类名
generateScopedName: (name, filename) => {
const fileNameOrFolder = filename.match(/index\.module\.(css|scss|less)$/)
? '[folder]'
: '[name]'
const str: any = filename.replace(__dirname, '') + name
// Create a hash based on a the file location and class name. Will be unique across a project, and close to globally unique.
const hash = getHashDigest(str, 'md5', 'base32', 5)
// Use loaderUtils to find the file or folder name
const className = interpolateName(
{ resourcePath: filename, resourceQuery: '' } as any,
fileNameOrFolder + '_' + name + '__' + hash
)
// // Remove the .module that appears in every classname when based on the file and replace all "." with "_".
return className.replace('.module_', '_').replace(/\./g, '_')
}
}
}
}
})
3.33 js ts转译=》js转换=》压缩
import react from '@vitejs/plugin-react'
export default defineConfig(({ command, mode, isPreview }) => {
return {
mode,
plugins: [
react({
babel: {
plugins: [
'@babel/plugin-transform-react-jsx',
['@babel/plugin-proposal-decorators', { legacy: true }],
['@babel/plugin-transform-class-properties', { loose: true }]
]
}
}),
htmlPlugin(views)
]
}
})
主要是引入babel包进行处理装饰器、jsx
3.34 静态资源处理
export default defineConfig(({ command, mode, isPreview }) => {
return {
mode,
build: {
// target: ['es2015', 'edge88', 'firefox78', 'chrome87', 'safari14'],
assetsDir: '',
rollupOptions: {
output: {
experimentalMinChunkSize: 10 * 1024, // 单位b 没有副作用,合并较小的模块
entryFileNames: (entryItem) => {
const name = entryItem.name || ''
const pathName = name
.replace('/index.html', '')
.replace('src/pages/', '')
.replace('.ts', '')
.replace('.js', '')
.replace(/\//g, '-')
const newEntryFileName = `${name.replace(
/.*index.(t|j)s$/,
`static/js/runtime/${pathName}-[hash].js`
)}`
return newEntryFileName
},
manualChunks: (id) => {
if (/(axios|redux|redux-thunk|react-redux|react-router|react-router-dom)/.test(id)) {
return 'libs'
}
},
assetFileNames: (assetItem) => {
if (assetItem.names.join().includes('.css')) {
if (assetItem.names.includes('index.css') && assetItem.originalFileNames.length) {
const map = new Map<string, number>()
let entryName = ''
assetItem.originalFileNames.forEach((moduleId) => {
const key = moduleId.split('/').at(-2)
if (key && !map.has(moduleId)) {
map.set(key, 1)
}
if (!entryName && moduleId.includes('src/pages')) {
entryName += moduleId
.replace(/.*src\/pages\//, '')
.replace(new RegExp(`components.*|common.*|${key}.*`), '')
}
})
const path: string = `static/css/${entryName}${[...map].reduce(
(pre, cur) => `${pre}${pre ? '-' : ''}${cur[0].toLowerCase()}`,
''
)}-[hash][extname]`
return path
}
return 'static/css/[name].[hash][extname]'
}
return 'static/media/[name].[hash][extname]'
},
chunkFileNames(chunkItem) {
if (chunkItem.name === 'index' && chunkItem.moduleIds.length) {
const map = new Map<string, number>()
let entryName = ''
chunkItem.moduleIds.forEach((moduleId) => {
if (!moduleId.includes('src/pages')) {
return
}
const key = moduleId.split('/').at(-2)
if (key && !map.has(moduleId)) {
map.set(key, 1)
}
if (!entryName && moduleId.includes('src/pages')) {
entryName += moduleId
.replace(/.*src\/pages\//, '')
.replace(new RegExp(`components.*|common.*|${key}.*`), '')
}
})
const fileName = [...map].reduce(
(pre, cur) => `${pre}${pre ? '-' : ''}${cur[0].toLowerCase()}`,
''
)
const path: string = `static/js/${entryName}${fileName || 'common'}-[hash].js`
return path
}
return 'static/js/[name]-[hash].js'
}
}
}
},
}
})
取消默认的assetsDir静态资源路径,通过entryFileNames, assetFileNames,chunkFileNames去重新生成,不然它会给你来个大杂烩,不屈分entry去对产物目录分类
至于manualChunks分包策略,各有所爱
结语
这样一个基本的vite h5脚手架就基本完成了,接下来的这几篇文章会继续加强这个脚手架,已经写完的文章会把文字换成链接,方便大家点开
- 开发动态代理插件,即每一个entry都有自己的代理文件
- 开发动态mock插件,即每一个entry都有自己的mock文件, 通过server.middlewares拦截req.url实现
- 开发全局引入三分库插件,比如cdn上的react
- 开发预渲染插件
- 开发支持rem vw并存转换插件