create-vue 源码解析(二)

78 阅读6分钟

create-vue 源码解析(一)

一、目录结构:

  • index.ts 是整个 CLI 的打包入口,主文件。
  • utils 工具函数。
  • template Vue 项目模板文件。
  • playground 利用 create-vue 生成的项目的快照结果。
  • scripts 包含了一些脚本,如测试、快照、预发布、构建。

二、create-vue 执行流程

  • 使用 prompts 询问用户一系列 Yes/No 的问题,是否包含以下特性:TS, JSX, router, vuex, cypress。以及是否重写覆盖已有的文件夹。
  • 在目标目录写入包含包名和版本号的 package.json 文件。
  • 根据模板创建目标文件夹,调用 render 函数,先根据 base 模板创建一个基础的项目,再根据用户选择的特性,将特性模板与基础项目合并。
  • 支持 TS 特性的话,把所有 js 后缀改为 ts后缀。将 jsconfig.json 重命名为 tsconfig.json
  • 判断包管理器,生成 README.md,提示用户项目生成成功并展示提示消息

三、主要逻辑

主要分析的 index.ts 中的源码逻辑

3.1 支持命令行参数

支持类似 npm create vue@latest --vuex --ts --jsx 这种命令行参数。这里使用了 minimist 库来解析命令行参数。

  // 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
  })
// 如果所有的 flag 都设置了,直接跳过提问阶段
  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'

3.2 交互式提问

通过交互式提问的方式,配置生成项目所包含的特性,这里借助了 prompts 库来收集交互结果。

  try {
    // Prompts:
    // - Project name:
    //   - whether to overwrite the existing directory or not?
    //   - enter a valid package name for package.json
    // - Project language: JavaScript / TypeScript
    // - Add JSX Support?
    // - Install Vue Router for SPA development?
    // - Install Pinia for state management?
    // - Add Cypress for testing?
    // - Add Nightwatch for testing?
    // - Add Playwright for end-to-end testing?
    // - Add ESLint for code quality?
    // - Add Prettier for code formatting?
    result = await prompts(
      [
        {
          name: 'projectName',
          type: targetDir ? null : 'text',
          message: 'Project name:',
          initial: defaultProjectName,
          onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
        },
        {
          name: 'packageName',
          // isValidPackageName:判断包名合法性
          type: () => (isValidPackageName(targetDir) ? null : 'text'),
          message: 'Package name:',
          // toValidPackageName:合法化包名
          initial: () => toValidPackageName(targetDir),
          validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name'
        },
        {
          name: 'needsTypeScript',
          // 如果使用了 flag,直接 type 函数返回 null,就不会提问了
          type: () => (isFeatureFlagsUsed ? null : 'toggle'),
          message: 'Add TypeScript?',
          initial: false,
          active: 'Yes',
          inactive: 'No'
        },
        ...
      ],
      {
        onCancel: () => {
          throw new Error(red('✖') + ' Operation cancelled')
        }
      }
    )
  } catch (cancelled) {
    console.log(cancelled.message)
    process.exit(1)
  }

3.3 搭建项目文件夹

  • 创建文件夹
  • 写入package.json文件
  // 当前工作目录
  const cwd = process.cwd()
  // targetDir:项目文件夹名称
  const root = path.join(cwd, targetDir)

  // shouldOverwrite:是否重写文件夹
  if (fs.existsSync(root) && shouldOverwrite) {
  emptyDir(root)
  } else if (!fs.existsSync(root)) {
  fs.mkdirSync(root)
  }

  console.log(`\nScaffolding project in ${root}...`)

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

3.4 render 函数

  // 所有模板位于 template 文件夹
  const templateRoot = path.resolve(__dirname, 'template')
  const callbacks = []
  // 使用 renderTemplate 将 templateDir 下的内容尝试生成到 root 中。
  // const root = path.join(cwd, targetDir)
  const render = function render(templateName) {
    const templateDir = path.resolve(templateRoot, templateName)
    renderTemplate(templateDir, root, callbacks)
  }
  // Render base template
  // 写入基础模板
  render('base')

  // Add configs.
  // 写入其他配置模板
  if (needsJsx) {
    render('config/jsx')
  }
  ....

template/base 是一个基础模板,它包括了 .vscodeindex.htmlvite.config.js 等这些前端项目基础文件。文件目录结构如下:

base-dir.png

在模板文件夹里存在 _ 前缀的文件,是为了避免影响一些 CLI 工具和编辑器的行为,这些文件在 render 的过程中会被重命名成 . 前缀。

3.5 对TS支持的特殊处理

主要做了两个工作:

  • 检查现有的 js 文件,如果已经有 ts 文件,就删除 js 文件;否则,将 .js 文件后缀改为 .ts 后缀
  • 移除 jsconfig.json,使用 tsconfig.json
  if (needsTypeScript) {
    // Convert the JavaScript template to the TypeScript
    // Check all the remaining `.js` files:
    //   - If the corresponding TypeScript version already exists, remove the `.js` version.
    //   - Otherwise, rename the `.js` file to `.ts`
    // Remove `jsconfig.json`, because we already have tsconfig.json
    // `jsconfig.json` is not reused, because we use solution-style `tsconfig`s, which are much more complicated.

    // 从根目录开始先序遍历目录文件
    preOrderDirectoryTraverse(
      root,
      () => {},
      (filepath) => {
        if (filepath.endsWith('.js') && !FILES_TO_FILTER.includes(path.basename(filepath))) {
          const tsFilePath = filepath.replace(/\.js$/, '.ts')
          // 如果存在同名 ts 文件,就删除原来的 js 文件,否则重命名后缀为 ts
          if (fs.existsSync(tsFilePath)) {
            fs.unlinkSync(filepath)
          } else {
            fs.renameSync(filepath, tsFilePath)
          }
          // 如果文件名是 jsconfig.json,就删除
        } else if (path.basename(filepath) === 'jsconfig.json') {
          fs.unlinkSync(filepath)
        }
      }
    )

    // Rename entry in `index.html`
    // index.html 入口文件中引用了 main.js 文件, 也需要改成 ts 格式
    const indexHtmlPath = path.resolve(root, 'index.html')
    const indexHtmlContent = fs.readFileSync(indexHtmlPath, 'utf8')
    fs.writeFileSync(indexHtmlPath, indexHtmlContent.replace('src/main.js', 'src/main.ts'))
  } else {
    // Remove all the remaining `.ts` files
    // 如果不需要 ts 支持,就删掉所有 ts 文件
    preOrderDirectoryTraverse(
      root,
      () => {},
      (filepath) => {
        if (filepath.endsWith('.ts')) {
          fs.unlinkSync(filepath)
        }
      }
    )
  }

3.6 根据包管理器生成 Readme 文件和命令行提示

  // 查询本地用户npm包管理器
  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(`\nDone. Now run:\n`)
  if (root !== cwd) {
    // 从 cwd 到 root 的相对路径
    const cdProjectName = path.relative(cwd, root)
    // 使用 kolorist 库美化命令行输出
    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')))}`)
  console.log()

四、主要工具函数

在了解了 index.ts 中的主要代码逻辑之后,已经可以对整个 create-vue 脚手架的工作原理有一定了解。接下来,对一些主要的工具函数进行分析。

directoryTraverse

在源码中使用两种文件目录递归方式,一种是 preOrderDirectoryTraverse,一种是 postOrderDirectoryTraverse。这两个函数都位于util/directoryTraverse.ts中,两者是类似,唯一的区别是在当前路径是文件夹时,先进入下一层文件夹,还是先调用文件夹处理函数 dirCallback,本质上就是多叉树的先序遍历和后序遍历。

// directoryTraverse.ts

// 先序遍历
export function preOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
  // fs.readdirSync() 返回一个包含目录中的所有文件名的数组
  for (const filename of fs.readdirSync(dir)) {
    // 跳过 .git 文件
    if (filename === '.git') {
      continue
    }
    const fullpath = path.resolve(dir, filename)

    // fs.lstatSync(fullpath)返回一个stats对象 描述文件或设备信息
    // 如果是文件夹就进行递归,否则直接调用fileCallback
    if (fs.lstatSync(fullpath).isDirectory()) {
      dirCallback(fullpath)
      // dirCallback操作可能会删除目录,所以需要判断目录是否存在
      if (fs.existsSync(fullpath)) {
        preOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
      }
      continue
    }
    fileCallback(fullpath)
  }
}

// 后序遍历
export function postOrderDirectoryTraverse(dir, dirCallback, fileCallback) {
  for (const filename of fs.readdirSync(dir)) {
    if (filename === '.git') {
      continue
    }
    const fullpath = path.resolve(dir, filename)

    if (fs.lstatSync(fullpath).isDirectory()) {
      postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
      dirCallback(fullpath)
      continue
    }
    // 做文件进行操作
    fileCallback(fullpath)
  }
}

renderTemplate

create-vue render 模板的时候,本质上是调用了一个 renderTemplate 函数。

renderTemplate 主要做了以下工作:

  • 将 src 的目录或文件递归地拷贝到 dest 下
  • 以 _ 命名的文件会替换为以 . 命名
  • package.json 如果已存在 dest 中,则对其内容进行合并
// renderTemplate.ts
function renderTemplate(src, dest, callbacks) {
  // 获取src的文件信息
  const stats = fs.statSync(src)

  // src 是文件夹时,递归渲染子目录
  if (stats.isDirectory()) {
    // path.basename() 从路径中解析出文件名
    if (path.basename(src) === 'node_modules') {
      return
    }

    // fs.mkdirSync(dest, { recursive: true }) 可以创建深层文件夹,例如 /a/b/c, 即使不存在/a 或 /a/b
    fs.mkdirSync(dest, { recursive: true })
    // 递归子目录
    for (const file of fs.readdirSync(src)) {
      renderTemplate(path.resolve(src, file), path.resolve(dest, file), callbacks)
    }
    return
  }

  // src 是一个单文件
  const filename = path.basename(src)
  if (filename === 'package.json' && fs.existsSync(dest)) {
    // 合并 package.json,并对依赖进行排序
    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
  }
  ...

  // 将文件名开头为_的文件,重命名为.开头
  if (filename.startsWith('_')) {
    // rename `_file` to `.file`
    dest = path.resolve(path.dirname(dest), filename.replace(/^_/, '.'))
  }
  ...

  // 拷贝src文件到dest
  fs.copyFileSync(src, dest)
}

deepMerge 和 sortDependencies

在合并 package.json 时,使用了两个函数 deepMergesortDependencies

deepMerge 递归合并新的 json 文件到已有文件中。主要思路就是两个值都是对象时继续递归,碰到两个数组的时候就进行一个浅拷贝合并,否则就直接赋值。

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)
    // 当两个val都是对象时,进行递归处理
    } else if (isObject(oldVal) && isObject(newVal)) {
      target[key] = deepMerge(oldVal, newVal)
    } else {
      target[key] = newVal
    }
  }

  return target
}

deepMerge 之后还需要对 package.json 中的依赖字段按照插入序排列

export default function sortDependencies(packageJson) {
  const sorted = {}
  // 需要排序的字段
  const depTypes = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']

  for (const depType of depTypes) {
    if (packageJson[depType]) {
      sorted[depType] = {}

      Object.keys(packageJson[depType])
        .sort()
        .forEach((name) => {
          // 按插入序排序
          sorted[depType][name] = packageJson[depType][name]
        })
    }
  }

  return {
    ...packageJson,
    ...sorted
  }
}

总结

create-vue 代码简洁,抛弃 Webpack 全面拥抱 vite 之后轻松了许多,开发体验提升了不少。通过学习 create-vue 源码,可以了解脚手架是如何工作的,对前端工程化也有更深的理解,在不了解脚手架之前完全不知道脚手架是如何运作的,像是一个未知的黑盒,恐惧它不如打开看看,没什么大不了的。

参考