likes
comments
collection
share

【源码阅读】create-vue 实现原理

作者站长头像
站长
· 阅读数 61

介绍

  1. 源码库地址

  2. 功能

    通过 npm init vue@3 或者 npm init vue@2 初始化一个对应版本的 vue 项目。

    【源码阅读】create-vue 实现原理 知道了它是干啥的,接下来就去看一下它是怎么实现的。

源码

既然是通过 npm init 来执行的 vue@x 这个命令,那么 npm init 都干了什么,它是怎么知道有 vue@x 这个命令呢?

这是一个阻塞性的问题,先来解决它。

1. npm init vue@x

第一个问题,npm init 都干了什么,通过 npm 官网npm init 的描述可以知道,执行 npm init 的时候会对这个命令进行转换

npm init [--force|-f|--yes|-y|--scope]
npm init <@scope> (same as `npx <@scope>/create`)
npm init [<@scope>/]<name> (same as `npx [<@scope>/]create-<name>`)

也就是说

  • npm init foo -> npx create-foo
  • npm init @usr/foo -> npx @usr/create-foo
  • npm init @usr -> npx @usr/create

那么结论就是,npm init vue@x 其实是执行了 npx create-vue@x,而 @ 后面的参数就是 vue 对应的版本,核心就是 npx create-vuecreate-vue 才是真正被执行的命令。

知道了 create-vue 是真正的命令,那么第二个问题就变成了,npx 怎么知道有 create-vue 这个命令呢?

先说结论: npx 在执行的时候会到 node_modules/.bin/ 目录下和环境变量 $PATH 中检查命令,如果找不到该命令就会远端下载同名依赖,并通过 node 执行。

这个结论是看了 阮一峰大佬的 npx 使用教程 后得出的,本文在后面的【扩展】中也有引用。

对应这里就是 node_modules/.bin/create-vue

看到这里就想到了之前 这篇文章 中学到了 package.jsonbin 字段定义的命令会被注册到全局或本地的 node_modules/.bin/ 目录下。

在该项目的 package.jsonbin 字段确实定义了 create-vue 这个命令。

到这里 npm init 所做的事情就全部都明白了,可以继续往下看了。

在这里其实还有一个问题,@ 后边的版本号是怎么生效的呢?

执行 npx create-vue@2 的时候,npm 就会认为下载大版本是 2 的最新版本,@3 就是大版本是 3 的最新版本

2. 命令的入口文件

前边知道了 create-vue 命令的定义

// package.json
"bin": {
  "create-vue": "outfile.cjs"
}

但是在项目中却没有找到 outfile.cjs 这个文件,然后就想到了这会不会是打包后的文件,就全局搜了一下,果然在 scripts/build.mjs 文件中有定义输出文件是 outfile.cjs ,对应的入口文件是 index.ts

await esbuild.build({
  bundle: true,
  entryPoints: ['index.ts'],
  outfile: 'outfile.cjs',
  format: 'cjs',
  platform: 'node',
  target: 'node14',
  ...
})

产生的问题: cjs 是什么文件?

cjs 代表 commonJs,同时还有 mjs 代表了 ModuleJs。

参考文件: .js 和 .mjs 文件有什么区别?

知道了命令的入口文件,接下来就看一下这个命令都干了什么,是怎么实现的。

3. 命令的实现

index.ts 文件中定义了 5 个函数,核心实现在 init() 函数中,那么就以 init() 函数为切入点,其他函数的具体实现在后边用到的时候再来看。

3.1. 初始化变量

  1. 首先通过 process.cwd() 获取了当前工作目录,然后又通过 minimist 来获取命令行参数,类似于执行 npm init vue@3 --ts

    // possible options:
    // --default
    // --typescript / --ts
    // --jsx
    // --router / --vue-router
    // --pinia
    // --with-tests / --tests (equals to `--vitest --cypress`)
    // --vitest
    // --cypress
    // --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']
      },
      // all arguments are treated as booleans
      boolean: true
    })
    

    process.argv 获取到的是一个数组,第一个元素是启动 Node.js 进程的可执行文件所在的绝对路径;第二个元素是当前所执行 js 文件的绝对路径;第三个及后边元素都是参数。

    通过 demo 来看一下 minimist 的参数和返回值

    $ node example/minimist.js -a beep -b boop
    { _: [], a: 'beep', b: 'boop' }
    
    $ node example/minimist.js -x 3 -y 4 -n5 -abc --beep=boop foo bar baz
    { _: [ 'foo', 'bar', 'baz' ],
      x: 3,
      y: 4,
      n: 5,
      a: true,
      b: true,
      c: true,
      beep: 'boop'
    }
    
  1. 定义了 isFeatureFlagsUsed 变量,作用是如果命令行中已经配置了,那么后面就将跳过后面用户选择的步骤(prompts 的功能)。

    const isFeatureFlagsUsed =
      typeof (
        argv.default ??
        argv.ts ??
        argv.jsx ??
        argv.router ??
        argv.pinia ??
        argv.tests ??
        argv.vitest ??
        argv.cypress ??
        argv.eslint
      ) === 'boolean'
    

    ?? 的作用: 左侧操作数为 nullundefined 时,其返回右侧的操作数,否则返回左侧的操作数。

  1. 从命令行中尝试获取项目名称,不存在的话则采用默认项目名称。

    let targetDir = argv._[0]
    const defaultProjectName = !targetDir ? 'vue-project' : targetDir
    

    这里需要注意的是,默认情况下 argv._process.argv 前两个元素是一致的,但是这里在定义 argv 的时候是通过 process.argv.slice(2) 获取到的,得到的结果也就不一样的。

  1. 最后定义了 forceOverwrite 变量来决定是否强制重写,清空目录。

    const forceOverwrite = argv.force // npm init vue@3 --force
    

3.2. 获取用户配置

  1. 首先定义了变量 result 同时声明了其类型,其次就是通过 prompts 来和用户进行交互。

    result = await prompts(
      [
        {
          name: 'projectName',
          type: targetDir ? null : 'text',
          message: 'Project name:',
          initial: defaultProjectName,
          onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
        },
        {
          name: 'shouldOverwrite',
          type: () => (canSkipEmptying(targetDir) || forceOverwrite ? null : 'confirm'),
          message: () => {
            const dirForPrompt =
                  targetDir === '.' ? 'Current directory' : `Target directory "${targetDir}"`
    ​
            return `${dirForPrompt} is not empty. Remove existing files and continue?`
          }
        }
        ...
      ]
    )
    

    这里定义了一些执行 npm init vue@x 命令后对用户的询问,其中包括项目名称、是否要重写目录、是否引入 vue-router/jsx... 等,具体内容在文章开头的图片中有描述。

    这里产生了一个疑问,就是在 prompts 的配置中为什么会有两个 projectName 的配置,因为这个是非阻塞性的,放到后面来解决。

  1. 最后将上面命令行中获取的参数和用户选择的参数相结合存到对应的变量中。

    const {
        projectName,
        packageName = projectName ?? defaultProjectName,
        shouldOverwrite = argv.force,
        needsJsx = argv.jsx,
        needsTypeScript = argv.typescript,
        needsRouter = argv.router,
        needsPinia = argv.pinia,
        needsCypress = argv.cypress || argv.tests,
        needsVitest = argv.vitest || argv.tests,
        needsEslint = argv.eslint || argv['eslint-with-prettier'],
        needsPrettier = argv['eslint-with-prettier']
      } = result
      const needsCypressCT = needsCypress && !needsVitest
    

3.2. 创建项目目录或清空已有目录创建项目

首先是将当前工作目录加项目名称组合出项目的根目录。

后面是如果目录存在并且需要重写则进行重写,反之如果目录不存在则创建。

主要来看一下是如何清空目录的,入口是 emptyDir 函数。

function emptyDir(dir) {
  // 不存在则 return
  if (!fs.existsSync(dir)) {
    return
  }
​
  postOrderDirectoryTraverse(
    dir,
    // fs.rmdirSync() 只能删除一个空文件夹
    (dir) => fs.rmdirSync(dir),
    // fs.unlinkSync() 只能删除文件或者符号链接
    (file) => fs.unlinkSync(file)
  )
}

这里又调用了 postOrderDirectoryTraverse 函数并且定义了删除文件夹和删除文件的回调函数作为参数。函数的具体实现如下

export function postOrderDirectoryTraverse(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(): 返回文件或者目录的信息
    // fs.lstatSync().isDirectory(): 判断是否是目录,是则 true, false
    if (fs.lstatSync(fullpath).isDirectory()) {
      // 对于目录的操作: 递归调用
      postOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
​
      // 删除该目录,fs.rmdirSync() 只能删除一个空文件夹,所以上边先递归,删除最底层的文件夹
      dirCallback(fullpath)
      continue
    }
    // 是文件则删除文件
    fileCallback(fullpath)
  }
}
  1. 通过 fs.readdirSync() 遍历目录下的所有文件,所有操作都在循环中进行。
  2. 然后判断如果是 git 文件则跳过本次处理。
  3. 然后获取遍历项的绝对目录,判断该项是文件还是目录,是文件则删除文件,如果是目录则采用深度优先将逐级删除。

3.3. 创建 package.json

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

生成一个基础的 package.json 文件。

3.4. 定义模板文件渲染函数并生成项目基础文件

const render = function render(templateName) {
  const templateDir = path.resolve(templateRoot, templateName)
  renderTemplate(templateDir, root)
}
  1. 拿到模板文件目录
  2. 调用 renderTemplate 进行核心操作。这里只需要知道对模板进行渲染就可以了,具体实现放到后面来看。
  3. 根据用户配置生成基础文件
// Render base template
render('base')
​
// Add configs.
if (needsJsx) {
  render('config/jsx')
}
if (needsRouter) {
  render('config/router')
}
if (needsPinia) {
  render('config/pinia')
}
if (needsVitest) {
  render('config/vitest')
}
if (needsCypress) {
  render('config/cypress')
}
if (needsCypressCT) {
  render('config/cypress-ct')
}
if (needsTypeScript) {
  render('config/typescript')
​
  // Render tsconfigs
  render('tsconfig/base')
  if (needsCypress) {
    render('tsconfig/cypress')
  }
  if (needsCypressCT) {
    render('tsconfig/cypress-ct')
  }
  if (needsVitest) {
    render('tsconfig/vitest')
  }
}

3.5. 渲染生成 EsLint 配置

if (needsEslint) {
  renderEslint(root, { needsTypeScript, needsCypress, needsCypressCT, needsPrettier })
}

renderEslint 函数的实现流程和 renderTemplate 函数的实现流程类似。

3.6. 渲染生成代码模板

const codeTemplate =
    (needsTypeScript ? 'typescript-' : '') +
    (needsRouter ? 'router' : 'default')
render(`code/${codeTemplate}`)
​
// Render entry file (main.js/ts).
if (needsPinia && needsRouter) {
  render('entry/router-and-pinia')
} else if (needsPinia) {
  render('entry/pinia')
} else if (needsRouter) {
  render('entry/router')
} else {
  render('entry/default')
}

3.7. 如果需要 TS

调用 preOrderDirectoryTraverse 函数来进行文件转换。

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)
    }
  }
)

这里在调用的时候最后一个回调函数有具体的操作:

  1. 如果是 js 文件则将 js 文件删除并更名为 ts 文件
  2. 如果有 jsconfig.json 文件则将其删除

preOrderDirectoryTraverse 函数具体实现如下

export function preOrderDirectoryTraverse(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()) {
      dirCallback(fullpath)
      // in case the dirCallback removes the directory entirely
      if (fs.existsSync(fullpath)) {
        preOrderDirectoryTraverse(fullpath, dirCallback, fileCallback)
      }
      continue
    }
    fileCallback(fullpath)
  }
}
​
// Rename entry in `index.html`
const indexHtmlPath = path.resolve(root, 'index.html')
// 读取项目中的 `index.html`
const indexHtmlContent = fs.readFileSync(indexHtmlPath, 'utf8')
// 将 index.html 文件中的 js 引入改为 ts
fs.writeFileSync(indexHtmlPath, indexHtmlContent.replace('src/main.js', 'src/main.ts'))
  1. 通过 fs.readdirSync() 遍历目录下的所有文件,所有操作都在循环中进行。
  2. 然后判断如果是 git 文件则跳过本次处理。
  3. 然后获取遍历项的绝对目录,判断该项是文件还是目录,是文件则更改为 ts 文件,如果是目录则递归调用 preOrderDirectoryTraverse 函数处理子目录下的文件。
  4. index.html 文件中的 js 引入改为 ts

3.8. 不需要 TS

调用 preOrderDirectoryTraverse 函数删除所有 ts 文件。

3.9. 根据包管理工具生成 README 文件,并给出提示

/**
 * Instructions:
 * Supported package managers: pnpm > yarn > 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,
    needsCypressCT,
    needsEslint
  })
)
​
console.log(`\nDone. Now run:\n`)
if (root !== cwd) {
  console.log(`  ${bold(green(`cd ${path.relative(cwd, root)}`))}`)
}
console.log(`  ${bold(green(getCommand(packageManager, 'install')))}`)
if (needsPrettier) {
  console.log(`  ${bold(green(getCommand(packageManager, 'lint')))}`)
}
console.log(`  ${bold(green(getCommand(packageManager, 'dev')))}`)
console.log()

上来就碰到了一个问题 process.env.npm_config_user_agent 的作用是什么呢?查了资料之后知道它可以获取到使用的是什么包管理工具。

后面就是根据包管理工具通过 generateReadme 函数生成对应的 README 文件。

再往下就是根据包管理工具进行提示,包括安装依赖、运行 lint、运行项目的提示,这里调用了 getCommand 函数,实现也很简单。

export default function getCommand(packageManager, scriptName) {
  if (scriptName === 'install') {
    return packageManager === 'yarn' ? 'yarn' : `${packageManager} install`
  }
​
  return packageManager === 'npm' ? `npm run ${scriptName}` : `${packageManager} ${scriptName}`
}

到这里整体的流程就看完了,接下来可以解决之前遇到的那些问题了。

疑问

1. prompts 的配置中为什么会有两个 projectName 的配置,作用是什么

prompts 的参数中有这么一段配置

[{
  name: 'projectName',
  type: targetDir ? null : 'text',
  message: 'Project name:',
  initial: defaultProjectName,
  // 将用户输入的内容赋值给 targetDir ,这个时候也就不再是 undefined 了
  onState: (state) => (targetDir = String(state.value).trim() || defaultProjectName)
},
// ...
{
  name: 'packageName',
  type: () => (isValidPackageName(targetDir) ? null : 'text'),
  message: 'Package name:',
  initial: () => toValidPackageName(targetDir),
  validate: (dir) => isValidPackageName(dir) || 'Invalid package.json name'
}
// ...
]

第一个的作用是当执行了 npm init vue@x 之后,提示给用户的一个默认名称

【源码阅读】create-vue 实现原理

第二个则是当用户输入项目名称之后触发的逻辑,会进行名称校验。

2. 模板渲染(templateRender)函数的具体实现

function renderTemplate(src, dest) {
  const stats = fs.statSync(src)
​
  if (stats.isDirectory()) {
    // skip node_module
    if (path.basename(src) === 'node_modules') {
      return
    }
    // if it's a directory, render its subdirectories and files recursively
    fs.mkdirSync(dest, { recursive: true })
​
    for (const file of fs.readdirSync(src)) {
      renderTemplate(path.resolve(src, file), path.resolve(dest, file))
    }
    return
  }
​
  const filename = path.basename(src)
​
  if (filename === 'package.json' && fs.existsSync(dest)) {
    // merge instead of overwriting
    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(/^_/, '.'))
  }
  fs.copyFileSync(src, dest)
}

扩展: npx

引用自 阮一峰大佬的博客

npx 是 5.2 版本被引入的,引入的目的就是为了解决在命令行中调用依赖项。

例如: npm install -D mocha

正常情况下要想调用 mocha 只能在项目脚本文件中或者 package.json 的 scripts 字段中,如果想通过命令行的方式只能这样: 根目录下执行 node-modules/.bin/mocha --version 也就是 node node-modules/.bin/mocha --version,有了 npx 只需要 npx mocha --version 就可以了。

并且 npx 调用一个需要全局安装的依赖时,它并不会进行全局安装,而是将其下载到一个临时目录下,使用完毕之后再将其删除。后续再次执行命令时会再次进行下载。

只要 npx 后面的命令在本地找不到的时候,就会下载同名依赖。

# ls node_modules/.bin/
  _mocha  flat    he      js-yaml mocha   nanoid
  
# npx create-vue 依旧可以执行
# Vue.js - The Progressive JavaScript Framework
# ...

使用场景

  1. 临时使用不同版本的 node

    // -p 安装
    npx -p node@10.16.0 npm run dev // 安装指定版本并运行
    
  2. 执行拥有 package.json 和入口脚本的开源项目

    // 执行 Gist 代码
    npx https://gist.github.com/zkat/4bc19503fe9e9309e2bfaa2c58074d32
    ​
    // 执行仓库代码
    npx github:piuccio/cowsay hello
    

总结

  1. 通过 minimist 可以获取命令行中的参数

  2. 通过prompts 可以和用户进行交互

  3. 在查资料的过程中,也感觉到了项目的依赖的很少,很多都是自己来实现,这应该也是 create-vue 很快的原因之一吧。

    如:校验名称一般是用官方的 npmvalidate-npm-package-name,删除文件夹一般都是使用 rimraf;而 create-vue 是自己实现 emptyDirisValidPackageName

参考资料