你的前端项目是如何被创建的

你的前端项目是如何被创建的

在使用vite创建项目的时候,我们通常会使用到pnpm create vite命令来初始化一个vite项目

但却一直没有去了解这个命令具体是执行什么了?

pnpm create干了些什么?

我们来研究一下,首先我们使用到了pnpm,这个怎么安装我就不多说了,如果您的设备上还没有安装pnpm,可以移步如何安装pnpm

接下来,是create命令,这个是pnpm中的一个脚本命令

从文档中的描述,我们可以得知pnpm create是用于从提前准备好的 create-*@foo/create-* 起始套件创建项目的命令

也就是说,vite项目中包含了这一部分内容,于是我们可以到vite仓库查看一下,这里的create是做了些什么操作

vite仓库中,我们得知这是一个monorepo,查看packages文件夹后发现create-vite这个库 (也就对应上所述的@vite/create-*规则)

展开该项目中的src/index.ts文件,可以发现这就是我们输入pnpm create vite所调用的文件

我们来看看这个文件做了些什么操作

首先,当你运行了该文件后,它调用了init函数

typescript 复制代码
init().catch((e) => {
  console.error(e)
})

init函数

我们先看看这个文件引入了些什么库

typescript 复制代码
// 用于处理不同平台上指令兼容
import spawn from 'cross-spawn'
// 用于解析命令行参数,方便获取,还可以添加默认值等功能
import minimist from 'minimist'
// 用于实现交互式命令行的库,像我们看到的一样
import prompts from 'prompts'
// 用于在终端上渲染五颜六色的文字,提高字体美感
import {
  blue,
  cyan,
  green,
  lightBlue,
  lightGreen,
  lightRed,
  magenta,
  red,
  reset,
  yellow,
} from 'kolorist'

在介绍该文件所引入的第三方库之后,我们来看看这个文件主要的函数init

typescript 复制代码
async function init() {
  // 获取命令行传递的参数内容
  const argTargetDir = formatTargetDir(argv._[0])
  // 获取命令行传递的模板参数,决定拉去某一个模板内容
  const argTemplate = argv.template || argv.t

  // 项目目录,默认为vite-project
  let targetDir = argTargetDir || defaultTargetDir

  // 获取目录名称
  const getProjectName = () =>
    targetDir === '.' ? path.basename(path.resolve()) : targetDir

  // 交互式命令行包含哪一些值,当用户回答完问题之后,会被赋值到result中
  let result: prompts.Answers<
    'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'
  >
}

接下来,我们来看看当我们输入pnpm create vite之后,所产生的交互式命令在create-vite这个包里是怎么实现的

typescript 复制代码
result = await prompts(
      [
        // 获取项目名称
        {
          type: argTargetDir ? null : 'text',
          name: 'projectName',
          message: reset('Project name:'),
          initial: defaultTargetDir,
          onState: (state) => {
            targetDir = formatTargetDir(state.value) || defaultTargetDir
          },
        },
        // 当目录存在时,由用户决定是否重写该目录
        {
          type: () =>
            !fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'confirm',
          name: 'overwrite',
          message: () =>
            (targetDir === '.'
              ? 'Current directory'
              : `Target directory "${targetDir}"`) +
            ` is not empty. Remove existing files and continue?`,
        },
        // 检查是否操作了覆盖
        {
          type: (_, { overwrite }: { overwrite?: boolean }) => {
            if (overwrite === false) {
              throw new Error(red('✖') + ' Operation cancelled')
            }
            return null
          },
          name: 'overwriteChecker',
        },
        {
          // 验证包名是否符合条件
          // 正则表达式规则如下(isValidPackageName function)
          // /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test(projectName)
          // toValidPackageName方法则是替换一些特殊符号,例如空格替换为`-`,处理trim等
          type: () => (isValidPackageName(getProjectName()) ? null : 'text'),
          name: 'packageName',
          message: reset('Package name:'),
          initial: () => toValidPackageName(getProjectName()),
          validate: (dir) =>
            isValidPackageName(dir) || 'Invalid package.json name',
        },
        // 选择框架模板,默认为下标0的模板,也就是vanilla(原生)
        // 具体看FRAMEWORKS这个常量的模板内容
        {
          type:
            argTemplate && TEMPLATES.includes(argTemplate) ? null : 'select',
          name: 'framework',
          message:
            typeof argTemplate === 'string' && !TEMPLATES.includes(argTemplate)
              ? reset(
                  `"${argTemplate}" isn't a valid template. Please choose from below: `,
                )
              : reset('Select a framework:'),
          initial: 0,
          choices: FRAMEWORKS.map((framework) => {
            const frameworkColor = framework.color
            return {
              title: frameworkColor(framework.display || framework.name),
              value: framework,
            }
          }),
        },
        // 基于上面选择的框架模板,选择具体模板内容,例如ts模板,js模板
        {
          type: (framework: Framework) =>
            framework && framework.variants ? 'select' : null,
          name: 'variant',
          message: reset('Select a variant:'),
          choices: (framework: Framework) =>
            framework.variants.map((variant) => {
              const variantColor = variant.color
              return {
                title: variantColor(variant.display || variant.name),
                value: variant.name,
              }
            }),
        },
      ],
      {
        // 当用户取消选择时触发
        onCancel: () => {
          throw new Error(red('✖') + ' Operation cancelled')
        },
      },
    )

当我们选择完模板后,接下来就是拉取该模板了

我们继续看prompt之后的代码

typescript 复制代码
async function init() {
  // 忽略上面已经讲过的代码
  // 获取用户选择的答案
  const { framework, overwrite, packageName, variant } = result

  // 完整的目录路径
  const root = path.join(cwd, targetDir)

  // 如果选择的重写,则清空文件夹内容
  if (overwrite) {
    emptyDir(root)
  } else if (!fs.existsSync(root)) {
    fs.mkdirSync(root, { recursive: true })
  }

  // 模板名称
  let template: string = variant || framework?.name || argTemplate
  let isReactSwc = false
  // 如果是react-swc,设置isReactSwc为true,后续作为判断
  if (template.includes('-swc')) {
    isReactSwc = true
    template = template.replace('-swc', '')
  }

  // 获取包管理信息
  const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
  const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
  // 判断是否为yarn管理器,处理@latest在yarn1.x中无法使用
  const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')
  // 有些模板含customCommand字段,会另外调用对应框架的pnpm create目录
  // 例如custom-vue中的customCommand字段为npm create vue@latest TARGET_DIR
  const { customCommand } =
    FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}

  if (customCommand) {
    const fullCustomCommand = customCommand
      .replace(/^npm create /, () => {
        // `bun create` uses it's own set of templates,
        // the closest alternative is using `bun x` directly on the package
        if (pkgManager === 'bun') {
          return 'bun x create-'
        }
        return `${pkgManager} create `
      })
      // Only Yarn 1.x doesn't support `@version` in the `create` command
      .replace('@latest', () => (isYarn1 ? '' : '@latest'))
      .replace(/^npm exec/, () => {
        // Prefer `pnpm dlx`, `yarn dlx`, or `bun x`
        if (pkgManager === 'pnpm') {
          return 'pnpm dlx'
        }
        if (pkgManager === 'yarn' && !isYarn1) {
          return 'yarn dlx'
        }
        if (pkgManager === 'bun') {
          return 'bun x'
        }
        // Use `npm exec` in all other cases,
        // including Yarn 1.x and other custom npm clients.
        return 'npm exec'
      })

    const [command, ...args] = fullCustomCommand.split(' ')
    // we replace TARGET_DIR here because targetDir may include a space
    const replacedArgs = args.map((arg) => arg.replace('TARGET_DIR', targetDir))
    const { status } = spawn.sync(command, replacedArgs, {
      stdio: 'inherit',
    })
    process.exit(status ?? 0)
  }

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

  const templateDir = path.resolve(
    // fileURLToPath('file://nas/foo.txt');       // Correct:   \\nas\foo.txt (Windows)
    // 用于将file协议路径转化为文件路径
    fileURLToPath(import.meta.url),
    '../..',
    `template-${template}`,
  )

  // 写入模板
  const write = (file: string, content?: string) => {
    const targetPath = path.join(root, renameFiles[file] ?? file)
    // 如果有传递内容,按内容写入
    if (content) {
      fs.writeFileSync(targetPath, content)
    } else {
    // 拷贝目录下的模板到当前目录
      copy(path.join(templateDir, file), targetPath)
    }
  }

  // 读取模板文件,除package.jso外的文件写入到
  const files = fs.readdirSync(templateDir)
  for (const file of files.filter((f) => f !== 'package.json')) {
    write(file)
  }

  // 读取package.json
  const pkg = JSON.parse(
    fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8'),
  )

  // 修改name属性,改为项目名称
  pkg.name = packageName || getProjectName()

  write('package.json', JSON.stringify(pkg, null, 2) + '\n')

  if (isReactSwc) {
    // 如果是swc模板,则将plugin-react替换为plugin-react-swc
    setupReactSwc(root, template.endsWith('-ts'))
  }

  // 最后显示指引文字,也就是我们看到的
  // cd xx
  // pnpm install
  // pnpm dev
  const cdProjectName = path.relative(cwd, root)
  console.log(`\nDone. Now run:\n`)
  if (root !== cwd) {
    console.log(
      `  cd ${
        cdProjectName.includes(' ') ? `"${cdProjectName}"` : cdProjectName
      }`,
    )
  }
  switch (pkgManager) {
    case 'yarn':
      console.log('  yarn')
      console.log('  yarn dev')
      break
    default:
      console.log(`  ${pkgManager} install`)
      console.log(`  ${pkgManager} run dev`)
      break
  }
  console.log()
}

到此,src/index.ts文件已经被读完了,这就是我们项目在创建时的整个过程,整体来说这个文件阅读起来没有什么难度吧

当然,除了pnpm create vite之外,像pnpm create vue也是一样的,我们可以从create-vue项目中找到执行的脚本文件,这些就不再叙述了,感兴趣的同学可以去看看

依赖如何自动更新

不过,我还是有一个疑问,我看到每个template模板里的package.json的依赖版本是直接写上去,为什么不设置为@latest,或者是自动获取当前最新版本替换进package.json中,是因为担心出现各依赖版本之间兼容性问题吗?

带着这个疑问,我继续寻找答案,在一位大佬的提示下,我从vite的发布脚本中发现了一个名为updateTemplateVersions的函数

从这个函数名称,我们大概可以猜到它是用于更新模板版本

查看该函数,它里面执行了一下几步操作:

typescript 复制代码
export async function updateTemplateVersions(): Promise<void> {
  const viteVersion = fs.readJSONSync('packages/vite/package.json').version
  if (/beta|alpha|rc/.test(viteVersion)) return

  const dir = 'packages/create-vite'
  // 读取packages/create-vite文件夹中以template-开头的文件夹
  const templates = readdirSync(dir).filter((dir) =>
    dir.startsWith('template-'),
  )
  for (const template of templates) {
    const pkgPath = path.join(dir, template, `package.json`)
    const pkg = fs.readJSONSync(pkgPath)
    // 通过遍历,将每个模板中的vite版本替换为当前要发布的vite版本
    pkg.devDependencies.vite = `^` + viteVersion
    writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n')
  }
}

Renovate

这个时候又会有个新的疑问?

那除了vite版本更新之外,像VueReact这些模板中的框架版本又是怎么保证是最新的呢

我从模板中的package.jsoncommit开始入手

从这个文件的commit中,除了发布vite时的commit,还有renovate[bot]提交的记录,我们点开看看

这是一个Github AppRenovate提供了一种简单又强大的方式来检测并更新项目的依赖项

它会检查仓库中的依赖项,并拉起一条Pull Request来更新依赖项到最新版本或指定范围内的版本

保持项目依赖项的处于最新状态,提供更好的安全性和稳定性

感兴趣的同学,可以看看Renovate documentation

继续查阅相关配置文件(.github/renovate.json5),这里我也给这个配置文件写上注释,方便大家不熟悉renovate的同学阅读它

json5 复制代码
{
  // JSON 文件中的一个特殊字段
  // 指定该模式遵循的JSON模式标准的哪个草案
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  // 继承
  "extends": ["config:base", "schedule:weekly", "group:allNonMajor"],
  // PR中的标签
  "labels": ["dependencies"],
  // 忽略路径
  "ignorePaths": ["**/__tests__/**"],
  // 自动升级到符合范围要求的最新版本
  "rangeStrategy": "bump",
  // 不想要更新的依赖
  "packageRules": [
    {
      "depTypeList": ["peerDependencies"],
      "enabled": false,
    },
  ],
  // 不自动更新的依赖
  "ignoreDeps": [
    // manually bumping
    "esbuild",
    "rollup",
    "node",
    "typescript",

    // breaking changes
    "kill-port", // `kill-port:^2.0.0 has perf issues (#8392)
  ],
}

这也算是个额外所获了个知识点,本想了解一下项目是如何被创建的⭐⭐

相关推荐
m0_7482552642 分钟前
前端安全——敏感信息泄露
前端·安全
鑫~阳2 小时前
html + css 淘宝网实战
前端·css·html
Catherinemin2 小时前
CSS|14 z-index
前端·css
心软小念3 小时前
外包干了27天,技术退步明显。。。。。
软件测试·面试
2401_882727574 小时前
低代码配置式组态软件-BY组态
前端·后端·物联网·低代码·前端框架
NoneCoder4 小时前
CSS系列(36)-- Containment详解
前端·css
anyup_前端梦工厂4 小时前
初始 ShellJS:一个 Node.js 命令行工具集合
前端·javascript·node.js
5hand4 小时前
Element-ui的使用教程 基于HBuilder X
前端·javascript·vue.js·elementui
GDAL4 小时前
vue3入门教程:ref能否完全替代reactive?
前端·javascript·vue.js
六卿4 小时前
react防止页面崩溃
前端·react.js·前端框架