背景
此前在工作中突然下了多个H5工程的任务,通常在 H5 业务开发中,创建一个新项目往往不是简单执行一次 npm create vite 就结束。
一个真实可用的移动端 H5 项目通常还需要提前处理很多重复配置:
- 路由结构
- 状态管理
- 组件库
- TypeScript 支持
- 移动端适配
- vConsole 调试工具
- JSBridge
如果每次新建项目都手动配置,不仅浪费时间,还容易导致项目结构和工程规范不一致。
因此,我在业余时间搭建了一套统一技术规范的移动端 H5 脚手架 create-h5。
项目概览
当前项目的目录结构如下:
.
+-- index.ts
+-- tsdown.config.ts
+-- tsconfig.json
+-- package.json
+-- template/
| +-- base/
| +-- pinia/
| +-- typescript/
| `-- vant/
+-- utils/
| +-- deepMerge.ts
| +-- directoryTraverse.ts
| +-- renderTemplate.ts
| `-- tsHandler.ts
`-- README.md
几个核心文件和目录的职责如下:
| 路径 | 作用 |
|---|---|
index.ts | 脚手架源码入口,负责参数解析、命令行交互、模板渲染 |
tsdown.config.ts | CLI 构建配置 |
tsconfig.json | TypeScript 和 IDE 配置 |
template/base | 基础 Vue H5 项目模板 |
template/pinia | Pinia 增量模板 |
template/vant | Vant UI 增量模板 |
template/typescript | TypeScript 增量模板 |
utils/renderTemplate.ts | 递归复制模板,并处理 package.json 合并 |
utils/deepMerge.ts | 深度合并对象 |
utils/directoryTraverse.ts | 遍历生成后的项目目录 |
utils/tsHandler.ts | 处理 JS 转 TS、Vue SFC 转 TS |
整体流程可以概括为:
解析命令行参数
-> 进入命令行交互
-> 创建目标项目目录
-> 写入基础 package.json
-> 渲染 base 模板
-> 按需叠加 pinia / vant / typescript 模板
-> 合并 package.json
-> 处理 TypeScript 文件转换
-> 写入 px2rem 配置
CLI 入口
一个工程脚手架首先要解决的问题是:用户在命令行输入命令后,如何执行我们的代码。
当前项目的源码入口是 index.ts。文件顶部加入了 shebang:
#!/usr/bin/env node
本地开发时,通过 tsx 直接运行 TypeScript 源码:
{
"scripts": {
"dev": "tsx index.ts"
}
}
执行:
npm run dev
或者指定项目名称和功能参数:
npm run dev -- my-h5 --pinia --vantui --ts --rootValue 75
发布时不会直接执行 index.ts,而是先通过 tsdown 构建到 dist/index.js,再由 package.json 的 bin 字段指向构建产物:
{
"bin": {
"create-h5": "./dist/index.js"
}
}
因此用户安装后执行的是:
create-h5 my-h5
解析命令行参数
脚手架入口在 index.ts。
首先获取命令行参数:
const args = process.argv.slice(2)
process.argv 是 Node.js 提供的命令行参数数组。前两项通常是 Node 可执行文件路径和当前脚本路径,所以业务参数从第三项开始取。
当前项目使用 Node.js 内置的 parseArgs 解析参数:
const options = {
rootValue: { type: 'string' },
vantui: { type: 'boolean' },
pinia: { type: 'boolean' },
typescript: { type: 'boolean' },
ts: { type: 'boolean' },
} as const
const { values: argv } = parseArgs({
args,
options,
strict: false
})
这里支持的参数有:
| 参数 | 说明 |
|---|---|
my-h5 | 第一个位置参数,作为项目目录名 |
--pinia | 启用 Pinia |
--vantui | 启用 Vant UI |
--ts | 启用 TypeScript |
--typescript | 启用 TypeScript |
--rootValue | 配置 postcss-pxtorem 的根字号 |
例如:
create-h5 demo-h5 --pinia --vantui --ts --rootValue 75
这类参数适合在团队文档中固化成固定命令,减少人工选择。
命令行交互
如果用户没有通过命令行参数直接指定完整配置,脚手架会进入交互阶段。
当前项目使用 prompts 实现命令行交互:
result = await prompts([
{
name: 'projectName',
type: targetDir ? null : 'text',
message: '请输入工程名称:',
initial: defaultProjectName,
onState: (state) => targetDir = String(state.value).trim() || defaultProjectName
},
{
name: 'packageName',
type: 'text',
message: '请输入包名称:',
initial: defaultProjectName,
validate: (dir) => isValidPackageName(dir) || '无效的包名'
},
{
name: 'needsPinia',
type: argv.pinia ? null : 'toggle',
message: '是否引入 Pinia 用于状态管理?',
initial: false,
active: 'yes',
inactive: 'no'
},
{
name: 'needsVantUI',
type: argv.vantui ? null : 'toggle',
message: '是否引入 vant-ui 组件库?',
initial: true,
active: 'yes',
inactive: 'no'
},
{
name: 'rootValue',
type: argv.rootValue ? null : 'text',
message: '请输入根节点 font-size:',
initial: 75
},
{
name: 'needsTypeScript',
type: (argv.ts || argv.typescript) ? null : 'toggle',
message: '是否引入 TypeScript 语法?',
initial: false,
active: 'yes',
inactive: 'no'
}
])
这里有一个比较实用的设计:如果命令行中已经传入了某个参数,就跳过对应问题。
例如传入了 --pinia,则不会再询问是否引入 Pinia:
type: argv.pinia ? null : 'toggle'
这样就同时兼顾了两种使用方式:
- 手动创建项目时,可以通过交互一步步选择
- 固定项目模板时,可以通过参数直接跳过交互
交互结束后,脚手架会把用户输入和命令行参数合并成最终配置:
const {
projectName,
packageName = projectName ?? defaultProjectName,
needsPinia = argv.pinia,
needsVantUI = argv.vantui,
rootValue = argv.rootValue,
needsTypeScript = argv.typescript || argv.ts,
} = result
这里的 rootValue = argv.rootValue 是一个重要兜底。
当用户通过命令行传入 --rootValue 时,对应交互项会被跳过。如果最终解构时不从 argv.rootValue 取值,命令行传入的根字号就不会生效。
初始化项目目录
收集完配置后,脚手架会确定项目生成目录:
const cwd = process.cwd()
const root = path.join(cwd, targetDir)
如果目录不存在,就创建目录:
if (!fs.existsSync(root)) {
fs.mkdirSync(root)
}
然后先写入一个最小的 package.json:
const pkg = { name: packageName, version: '0.0.0' }
fs.writeFileSync(
path.resolve(root, 'package.json'),
JSON.stringify(pkg, null, 2)
)
这里没有直接复制完整模板中的 package.json,而是先写入项目名称和版本号。
后续渲染不同模板时,再把各个模板中的依赖合并进来。
这种方式更适合当前项目的模板设计,因为 create-h5 不是只选择一个模板,而是通过 base + 可选能力 的方式组合生成工程。
模板分层设计
当前模板目录分为四类:
template/
base/
pinia/
vant/
typescript/
base 模板
base 是所有项目都会渲染的基础模板。
它包含一个基本的 Vite + Vue 3 H5 项目:
- Vite
- Vue 3
- Vue Router
- Sass
- vConsole
amfe-flexiblepostcss-pxtoremautoprefixer- 基础页面
- 基础路由
- H5 常用 SDK 目录(根据实际项目实现JS桥)
入口文件 template/base/src/main.js:
import { createApp } from 'vue'
import router from './router'
import App from './App.vue'
// 测试环境打开控制台
import VConsole from 'vconsole';
new VConsole()
import 'amfe-flexible'
const app = createApp(App)
app.use(router)
app.mount('#app')
基础模板里已经注册了路由,并引入了移动端适配和 vConsole。
对于 H5 项目来说,这些基本都是高频配置。
pinia 模板
当用户选择引入 Pinia 时,会渲染 template/pinia。
这个模板主要做三件事。
第一,向 package.json 增加依赖:
{
"dependencies": {
"pinia": "^2.1.7"
}
}
第二,替换 src/main.js,注册 Pinia:
import { createPinia } from 'pinia'
const pinia = createPinia()
const app = createApp(App)
app.use(router)
app.use(pinia)
app.mount('#app')
第三,增加一个示例 store:
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})
这里采用的是组合式写法,和 Vue 3 的开发习惯比较一致。
vant 模板
当用户选择引入 Vant UI 时,会渲染 template/vant。
它会增加如下依赖:
{
"dependencies": {
"vant": "^4.6.6"
},
"devDependencies": {
"unplugin-vue-components": "^0.25.1"
}
}
同时替换 vite.config.js,加入 Vant 组件自动按需引入:
import Components from 'unplugin-vue-components/vite';
import { VantResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig({
plugins: [
vue(),
Components({
resolvers: [VantResolver()],
}),
]
})
这样在业务页面中使用 Vant 组件时,就不需要手动逐个引入组件。
typescript 模板
当用户选择 TypeScript 时,会渲染 template/typescript。
它主要提供:
tsconfig.jsontsconfig.node.json- TypeScript 版本的路由文件
同时会配合 utils/tsHandler.ts 对已有 JS 文件进行转换。
模板渲染
模板渲染时,需要读取自身包内的 template 目录。
早期本地调试时可以直接写:
const templateRoot = path.resolve(process.cwd(), 'template')
但发布后这个写法会有问题,用户会在任意目录执行命令:
create-h5 my-h5
这时 process.cwd() 是用户当前目录,而不是脚手架包目录。如果继续从 process.cwd() 找 template,脚手架会去用户目录下找模板,必然失败。
因此当前代码新增了 resolveTemplateRoot():
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
function resolveTemplateRoot() {
const candidates = [
path.resolve(__dirname, '../template'),
path.resolve(__dirname, 'template'),
]
const templateRoot = candidates.find((dir) => fs.existsSync(dir))
if (!templateRoot) {
throw new Error('未找到 template 模板目录')
}
return templateRoot
}
模板渲染逻辑在 utils/renderTemplate.ts。核心实现如下:
function renderTemplate(src, dest) {
const stats = fs.statSync(src)
if (stats.isDirectory()) {
if (path.basename(src) === 'node_modules') {
return
}
fs.mkdirSync(dest, { recursive: true })
for (const file of fs.readdirSync(src)) {
arguments.callee(path.resolve(src, file), path.resolve(dest, file))
}
return
}
fs.copyFileSync(src, dest)
}
逻辑比较直接:
- 如果当前路径是目录,则创建目标目录,并继续递归处理子文件
- 如果当前路径是普通文件,则复制到目标位置
- 如果遇到
node_modules,直接跳过
在 index.ts 中,通过一个内部函数封装模板渲染:
const templateRoot = resolveTemplateRoot()
function render(templateName) {
const templateDir = path.resolve(templateRoot, templateName)
renderTemplate(templateDir, root)
}
render('base')
if (needsPinia) {
render('pinia')
}
if (needsVantUI) {
render('vant')
}
基础模板一定会渲染,其他模板根据用户选择按需叠加。
package.json 合并
模板叠加时,最容易出问题的是 package.json。
例如:
base模板中有 Vue、Vite、Vue Router 等依赖pinia模板中有 Pinia 依赖vant模板中有 Vant 和自动导入插件依赖
如果直接复制文件,后渲染的模板会覆盖前面的 package.json。
所以 renderTemplate 对 package.json 做了特殊处理:
if (filename === 'package.json' && fs.existsSync(dest)) {
const existing = JSON.parse(fs.readFileSync(dest, 'utf8'))
const newPackage = JSON.parse(fs.readFileSync(src, 'utf8'))
const pkg = sortDependencies(deepMerge(existing, newPackage))
fs.writeFileSync(dest, JSON.stringify(pkg, null, 2) + '\n')
return
}
这里会读取目标项目中已有的 package.json,再读取当前模板中的 package.json,然后通过 deepMerge 合并。
深度合并函数在 utils/deepMerge.ts:
const isObject = (val) => val && typeof val === 'object'
const mergeArrayWithDedupe = (a, b) => Array.from(new Set([...a, ...b]))
function deepMerge(target, obj) {
for (const key of Object.keys(obj)) {
const oldVal = target[key]
const newVal = obj[key]
if (Array.isArray(oldVal) && Array.isArray(newVal)) {
target[key] = mergeArrayWithDedupe(oldVal, newVal)
} else if (isObject(oldVal) && isObject(newVal)) {
target[key] = arguments.callee(oldVal, newVal)
} else {
target[key] = newVal
}
}
return target
}
它支持三类合并:
- 数组去重合并
- 对象递归合并
- 普通字段直接覆盖
合并完成后,还会对依赖字段排序,让生成结果更加稳定。
TypeScript 转换
TypeScript 支持分成两部分:
- 对已经生成的 JS 文件做转换
- 渲染 TypeScript 增量模板
在 index.ts 中,相关逻辑如下:
if (needsTypeScript) {
directoryTraverse(root, convertFile)
directoryTraverse(root, vueTemplate)
render('typescript')
const indexHtmlPath = path.resolve(root, 'index.html')
const indexHtmlContent = fs.readFileSync(indexHtmlPath, 'utf8')
fs.writeFileSync(indexHtmlPath, indexHtmlContent.replace('src/main.js', 'src/main.ts'))
}
遍历项目目录
目录遍历工具在 utils/directoryTraverse.ts:
export default function directoryTraverse(dir: string, fileCallback: Function) {
for (const filename of fs.readdirSync(dir)) {
if (filename === '.git') {
continue
}
const fullpath = path.resolve(dir, filename)
const stats = fs.statSync(fullpath)
if (stats.isDirectory() && fs.existsSync(fullpath)) {
arguments.callee(fullpath, fileCallback)
continue
}
fileCallback(fullpath)
}
}
它会递归遍历目录中的文件,并对每个文件执行传入的回调函数。
JS 文件转换
JS 转 TS 的逻辑在 utils/tsHandler.ts:
export function convertFile(filepath) {
if (filepath.endsWith('.js')) {
if (path.basename(path.dirname(filepath)) === 'router') {
fs.unlinkSync(filepath)
return
}
const tsFilePath = filepath.replace(/.js$/, '.ts')
if (fs.existsSync(tsFilePath)) {
fs.unlinkSync(filepath)
} else {
fs.renameSync(filepath, tsFilePath)
}
}
if (path.basename(filepath) === 'jsconfig.json') {
fs.unlinkSync(filepath)
}
}
主要处理:
- 普通
.js文件重命名为.ts - 如果目标
.ts已存在,则删除原.js - 删除
jsconfig.json - 删除
router目录下原有 JS 文件,后续由 TypeScript 模板补充新的路由文件
Vue 文件转换
Vue 单文件组件的转换也比较简单:
export function vueTemplate(filepath) {
if (filepath.endsWith('.vue')) {
const templateContent = fs.readFileSync(filepath, 'utf-8')
fs.writeFileSync(
filepath,
templateContent.replace('<script setup>', `<script setup lang="ts">`)
)
}
}
它会把:
<script setup>
替换成:
<script setup lang="ts">
修改入口文件
文件转换后,还需要修改 index.html 中的入口文件:
const indexHtmlPath = path.resolve(root, 'index.html')
const indexHtmlContent = fs.readFileSync(indexHtmlPath, 'utf8')
fs.writeFileSync(
indexHtmlPath,
indexHtmlContent.replace('src/main.js', 'src/main.ts')
)
否则 Vite 仍然会加载 src/main.js。
移动端适配
H5 项目中,移动端适配是一个比较基础但又经常重复配置的能力。
基础模板的 vite.config.js 中已经引入了:
import autoprefixer from "autoprefixer"
import px2rem from "postcss-pxtorem"
PostCSS 配置如下:
css: {
postcss: {
plugins: [
autoprefixer(),
px2rem(),
]
}
}
脚手架生成项目后,会根据用户输入的 rootValue 替换 px2rem():
const px2remOption = {
rootValue,
unitPrecision: 5,
propList: ['*'],
exclude: /node_modules/i
}
const configPath = path.resolve(root, `vite.config.${needsTypeScript ? 'ts' : 'js'}`)
const configContent = fs.readFileSync(configPath, 'utf-8')
const newConfig = configContent.replace('px2rem()', `px2rem(${JSON.stringify(px2remOption)})`)
fs.writeFileSync(configPath, newConfig)
这里的思路是通过字符串替换,把默认的:
px2rem()
替换成带配置的:
px2rem({
rootValue: 75,
unitPrecision: 5,
propList: ['*'],
exclude: /node_modules/i
})
不过当前实现里有两个细节需要注意。
第一个细节是 JSON.stringify 不能正确保留正则表达式。
exclude: /node_modules/i 被序列化后会变成 {},所以最终写入配置文件时不够准确。
第二个细节是 Vant 模板中的 vite.config.js 已经写成了:
px2rem({ rootValue: 75, unitPrecision: 5, propList: ['*'], exclude: /node_modules/i })
如果用户选择了 Vant,vant 模板会覆盖基础模板中的 vite.config.js。这时最终替换逻辑再查找 px2rem() 就匹配不到了,因此交互输入的 rootValue 不会继续生效。
这块后续可以改成统一的占位符方案,例如模板里写:
px2rem(__PX2REM_OPTIONS__)
生成项目时再替换为配置文本。
也可以使用 AST 修改配置文件,避免纯字符串替换在复杂场景下失效。
生成后的项目能力
通过脚手架生成的基础项目,已经具备一个 H5 工程的基本能力。
基础依赖中包含:
{
"dependencies": {
"vconsole": "^3.15.1",
"vue": "^3.2.47",
"vue-router": "^4.2.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.1.0",
"amfe-flexible": "^2.2.1",
"autoprefixer": "^10.4.15",
"postcss-pxtorem": "^6.0.0",
"sass": "^1.66.1",
"typescript": "^5.0.2",
"vite": "^4.3.2",
"vue-tsc": "^1.4.2"
}
}
生成项目后,可以进入项目目录:
cd my-h5
安装依赖:
npm install
启动开发服务:
npm run dev
构建生产包:
npm run build
本地预览:
npm run preview
总结
create-h5 是一个典型的业务型前端脚手架。
它没有追求覆盖所有框架和所有场景,而是专注解决一个明确的问题:快速创建一个适合移动端 H5 业务开发的 Vue 3 工程。
从实现上看,它已经包含了脚手架的核心结构:
- Node CLI 入口
- 命令行参数解析
- 交互式配置
- 项目目录初始化
- 基础模板渲染
- 增量模板叠加
package.json依赖合并- TypeScript 文件转换
- 移动端适配配置
这类脚手架的价值不只是节省几分钟初始化时间,更重要的是把工程经验固化下来。
当团队的新项目都从同一个脚手架生成时,目录结构、基础依赖、移动端适配、组件库接入、调试工具和基础 SDK 都会保持一致。
项目越多,这种统一带来的收益就越明显。