【脚手架之旅】从 0 到 1 实现一个 Vue H5 工程脚手架

5 阅读10分钟

背景

此前在工作中突然下了多个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.tsCLI 构建配置
tsconfig.jsonTypeScript 和 IDE 配置
template/base基础 Vue H5 项目模板
template/piniaPinia 增量模板
template/vantVant UI 增量模板
template/typescriptTypeScript 增量模板
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.jsonbin 字段指向构建产物:

{
  "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-flexible
  • postcss-pxtorem
  • autoprefixer
  • 基础页面
  • 基础路由
  • 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.json
  • tsconfig.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

所以 renderTemplatepackage.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 都会保持一致。

项目越多,这种统一带来的收益就越明显。