【脚手架之旅】探究 create-vue 实现原理

本文参加了由 公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。

这是源码共读的第9期,链接:Vue 团队公开的全新脚手架工具

Vue 团队公开的全新脚手架工具,未来将替代 Vue-CLI

npm create vue 创建 Vue 工程

摘自GitHub:The recommended way to start a Vite-powered Vue project

bash 复制代码
npm create vue@latest
### Or, if you need to support IE11, you can create a Vue 2 project with
npm create vue@legacy

下图是一个使用npm create vue创建工程的完善示例图:

npm create

官网说明使用npm命令npm create vue创建工程

npm createnpm init的alias,详情可查阅npm文档

流程分析

解析命令行

使用命令npm create vue创建工程时,脚手架允许我们在命令后跟上项目所需依赖的参数。

通过 minimist 库解析命令行参数,获取创建工程所需的依赖。可选参数,详见下方注释:

typescript 复制代码
const cwd = process.cwd()
// possible options:
// --default
// --typescript / --ts
// --jsx
// --router / --vue-router
// --pinia
// --with-tests / --tests (equals to `--vitest --cypress`)
// --vitest
// --cypress
// --nightwatch
// --playwright
// --eslint
// --eslint-with-prettier (only support prettier through eslint for simplicity)
// --force (for force overwriting)
const argv = minimist(process.argv.slice(2), {
	alias: {
		typescript: ['ts'],
		'with-tests': ['tests'],
		router: ['vue-router']
	},
	string: ['_'],
	// all arguments are treated as booleans
	boolean: true
})

tips:近期发现,在24年2.20日项目更新了一个提交,内容为使用 node:utils 包下的 parseArgs 工具函数替换了 minimist 库

typescript 复制代码
const args = process.argv.slice(2)
const options = {
	typescript: { type: 'boolean' },
	ts: { type: 'boolean' },
	'with-tests': { type: 'boolean' },
	tests: { type: 'boolean' },
	'vue-router': { type: 'boolean' },
	router: { type: 'boolean' }
} as const

const { values: argv } = parseArgs({
	args,
	options,
	strict: false
})

对参数进行判断,如果设置了 feature flags (即已经在命令行输入了依赖参数) 跳过 prompts 依赖选择的询问

typescript 复制代码
// if any of the feature flags is set, we would skip the feature prompts
const isFeatureFlagsUsed =
	typeof (
		argv.default ??
		argv.ts ??
		argv.jsx ??
		argv.router ??
		argv.pinia ??
		argv.tests ??
		argv.vitest ??
		argv.cypress ??
		argv.nightwatch ??
		argv.playwright ??
		argv.eslint
	) === 'boolean'

let targetDir = argv._[0]
const defaultProjectName = targetDir || 'vue-project'

命令行交互获取项目依赖

若在敲入npm create命令时未设置 feature flags,则进入命令行交互阶段。

通过 prompts 库实现命令行交互,并获取结果。

typescript 复制代码
const language = getLanguage()
let result = {}
try {
  result = await prompts(
    [
      {
        name: 'projectName',
        type: targetDir ? null : 'text',
        message: language.projectName.message,
        initial: defaultProjectName,
        onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
      },
      // 省略若干配置
      {
        name: 'needsTypeScript',
        type: () => (isFeatureFlagsUsed ? null : 'toggle'),
        message: language.needsTypeScript.message,
        initial: false,
        active: language.defaultToggleOptions.active,
        inactive: language.defaultToggleOptions.inactive
      }
    ],
    {
      onCancel: () => {
        throw new Error(red('✖') + ` ${language.errors.operationCancelled}`)
      }
    }
  )
} catch (cancelled) {
  console.log(cancelled.message)
  process.exit(1)
}


const {
  projectName,
  needsTypeScript = argv.typescript
} = result
国际化

在上述代码中,我们发现第一行通过调用getLanguage()方法获取了一个东西。跟进查看utils/getLanguage.ts,发现这是一个处理国际化语言的工具函数。

此工具逻辑为:

  • 通过当前环境获取设备区域的标识字符串,如:zh-Hans
  • 通过区域标识获取 locales 目录下预存放的语言文件,其中包含:
    • locales/en-US.json
    • locales/fr-FR.json
    • locales/tr-TR.json
    • locales/zh-Hans.json
    • locales/zh-Hant.json
typescript 复制代码
// 统一区域标识符
function linkLocale(locale: string) {
  let linkedLocale: string
  switch (locale) {
    case 'zh-TW':
    case 'zh-HK':
    case 'zh-MO':
      linkedLocale = 'zh-Hant'
      break
    case 'zh-CN':
    case 'zh-SG':
      linkedLocale = 'zh-Hans'
      break
    default:
      linkedLocale = locale
  }
  return linkedLocale
}

// 获取区域标识符
function getLocale() {
  const shellLocale =
    process.env.LC_ALL || // POSIX locale environment variables
    process.env.LC_MESSAGES ||
    process.env.LANG ||
    Intl.DateTimeFormat().resolvedOptions().locale || // Built-in ECMA-402 support
    'en-US' // Default fallback
  return linkLocale(shellLocale.split('.')[0].replace('_', '-'))
}
// 获取语言json文件
function getLanguage() {
  const locale = getLocale()
  const localesRoot = path.resolve(__dirname, 'locales')
  const languageFilePath = path.resolve(localesRoot, `${locale}.json`)
  const doesLanguageExist = fs.existsSync(languageFilePath)
  const lang: Language = doesLanguageExist
    ? require(languageFilePath)
    : require(path.resolve(localesRoot, 'en-US.json'))
  return lang
}

后续在交互中进行询问的信息便是 language 对象内的信息

初始化工程

创建项目工程目录 ,初始化 package.json

如果目录文件存在,则根据询问是否覆盖shouldOverwrite处理

typescript 复制代码
const root = path.join(cwd, targetDir)

if (fs.existsSync(root) && shouldOverwrite) {
  emptyDir(root)
} else if (!fs.existsSync(root)) {
  fs.mkdirSync(root)
}

console.log(`\n${language.infos.scaffolding} ${root}...`)

const pkg = { name: packageName, version: '0.0.0' }
fs.writeFileSync(path.resolve(root, 'package.json'), JSON.stringify(pkg, null, 2))

添加工程依赖

根据交互结果,渲染需要的文件以及配置。在 template 目录下存放了一些预定的项目所需文件。

引入工具函数renderTemplate,该函数将预定的配置等文件拷贝至目标工程里,特别对package.json文件中的依赖做了合并、排序操作。

typescript 复制代码
import renderTemplate from './utils/renderTemplate'

const templateRoot = path.resolve(__dirname, 'template')
const callbacks = []
function render(templateName) {
  const templateDir = path.resolve(templateRoot, templateName)
  renderTemplate(templateDir, root, callbacks)
}
// Render base template
render('base')
// Add configs.
if (needsRouter) {
  render('config/router')
}
if (needsPinia) {
  render('config/pinia')
}
// 省略若干配置

TS配置

如果项目需要引入TS,需要做两件事。

一、配置tsconfig.node.json

typescript 复制代码
if (needsTypeScript) {
  render('config/typescript')
  // Render tsconfigs
  render('tsconfig/base')
  
  const rootTsConfig = {
    // It doesn't target any specific files because they are all configured in the referenced ones.
    files: [],
    // All templates contain at least a `.node` and a `.app` tsconfig.
    references: [
      {
        path: './tsconfig.node.json'
      },
      {
        path: './tsconfig.app.json'
      }
    ]
  }
  // 省略若干配置
  if (needsVitest) {
    render('tsconfig/vitest')
    // Vitest needs a standalone tsconfig.
    rootTsConfig.references.push({
      path: './tsconfig.vitest.json'
    })
  }
  // 写入tsconfig.json
  fs.writeFileSync(
    path.resolve(root, 'tsconfig.json'),
    JSON.stringify(rootTsConfig, null, 2) + '\n',
    'utf-8'
  )
}

二、js文件转换为ts文件,删除jsconfig.json,同时修改文件内容中的带有.js的后缀

TypeScript 复制代码
if (needsTypeScript) {
  preOrderDirectoryTraverse(
    root,
    () => {},
    (filepath) => {
      if (filepath.endsWith('.js')) {
        const tsFilePath = filepath.replace(/.js$/, '.ts')
        if (fs.existsSync(tsFilePath)) {
          fs.unlinkSync(filepath)
        } else {
          fs.renameSync(filepath, tsFilePath)
        }
      } else if (path.basename(filepath) === 'jsconfig.json') {
        fs.unlinkSync(filepath)
      }
    }
  )
  // Rename entry in `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'))
}

生成 README 文件,给出运行项目的提示

获取用户的包管理器,生成README文件对项目进行一些说明

打印工程创建成功日志以及执行提示

typescript 复制代码
// 生成Readme文件工具函数
import generateReadme from './utils/generateReadme'
// 命令行颜色优化库
import { red, green, bold } from 'kolorist'

const userAgent = process.env.npm_config_user_agent ?? ''
const packageManager = /pnpm/.test(userAgent) ? 'pnpm' : /yarn/.test(userAgent) ? 'yarn' : 'npm'

// README generation
fs.writeFileSync(
  path.resolve(root, 'README.md'),
  generateReadme({
    projectName: result.projectName ?? result.packageName ?? defaultProjectName,
    packageManager,
    needsTypeScript,
    needsVitest,
    needsCypress,
    needsNightwatch,
    needsPlaywright,
    needsNightwatchCT,
    needsCypressCT,
    needsEslint
  })
)

// 打印日志
console.log(`\n${language.infos.done}\n`)
if (root !== cwd) {
  const cdProjectName = path.relative(cwd, root)
  console.log(
    `  ${bold(green(`cd ${cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName}`))}`
  )
}
console.log(`  ${bold(green(getCommand(packageManager, 'install')))}`)
if (needsPrettier) {
  console.log(`  ${bold(green(getCommand(packageManager, 'format')))}`)
}
console.log(`  ${bold(green(getCommand(packageManager, 'dev')))}`)

总结

create-vue 专注于 vue.js 框架的工程脚手架,对生态的预设及配置更加完善。从体验来说,相对于 create-vite, create-vue 能够更完整的搭建vue工程。

该项目对专注前端工程化的同学一定会有帮助,其中不少工具函数的源码也值得学习。

最后,捞一下😁:【脚手架之旅】探究 create-vite 实现原理

相关推荐
一只欢喜25 分钟前
uniapp使用uview2上传图片功能
前端·uni-app
程序员大金36 分钟前
基于SpringBoot+Vue+MySQL的养老院管理系统
java·vue.js·spring boot·vscode·后端·mysql·vim
尸僵打怪兽39 分钟前
后台数据管理系统 - 项目架构设计-Vue3+axios+Element-plus(0920)
前端·javascript·vue.js·elementui·axios·博客·后台管理系统
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS网上购物商城(JAVA毕业设计)
java·vue.js·spring boot·后端·开源
ggome1 小时前
Uniapp低版本的安卓不能用解决办法
前端·javascript·uni-app
Ylucius1 小时前
JavaScript 与 Java 的继承有何区别?-----原型继承,单继承有何联系?
java·开发语言·前端·javascript·后端·学习
前端初见1 小时前
双token无感刷新
前端·javascript
、昔年1 小时前
前端univer创建、编辑excel
前端·excel·univer
emmm4591 小时前
前端中常见的三种存储方式Cookie、localStorage 和 sessionStorage。
前端
Q186000000001 小时前
在HTML中添加视频
前端·html·音视频