站在巨人的肩膀上:你还不懂create-vite原理吗?来一起康康。

前言

最近在学习张鑫旭大佬的技术写作指南,有句话我看了很有感触,“一个知识点要想深入浅出,前提就是作者本人的理解足够深入,这样即便遇到问题,也不会束手无策。

公司最近想把项目的 webpack 替换成 vite,趁这个机会一次把 vite 给整明白了,争取面试时和面试官一起探讨一下 vite。

加了很久的源码共读群,一直没时间(懒的)学习,这次难得把摸鱼的时间抽出来,花了一周时间,认真读了一遍源码并写个文章总结一下。

moyu.gif

接下来,有请各位小伙伴同我一起敲开源码的大门吧 👏。

使用 vite 创建项目

几个月前,Vite 已经升级到 4.0 版本,此时距离 Vite 3.0 发布快一年了。4.0 版本完成 Rollup 2.0 到 3.0 的升级,同时增加了对 SWC 的支持,这是一个基于 Rust > 的打包器(bundler),声称比 Babel 有数量级的速度提升。

在开始源码之前,我们先用 vite 创建一个项目看看。这里我用的是 pnpm,对 pnpm 还不熟悉的同学,建议先看看官网的讲解。

pnpm create vite my-vue-app
image.png

我们也可以通过指定模板的方式一步到位

pnpm create vite my-vue-app --template vue-ts
image.png

vite 可以很快速的帮我们创建一个项目,那么这些选择提示以及模板中的文件都是怎么来的呢?带着问题我们来学习一波 vite 的源码,做到知其然,更知其所以然

项目克隆

create-vite 的源码地址在 vite 项目下:github.com/vitejs/vite…,我是把整个 vite clone 下来之后,再把 create-vite 的代码拆出来,上传到 github 上。

github 地址: https://github.com/wang1xiang/create-vite-analysis

确定入口

通过npm init 文档我们可以知道,create 就是 init 的别名。所以上面的命令pnpm create vite就相当于pnpm init vite

继续通过描述了解到 init 命令会转换为 npm exec,所以pnpm create vite最终被转换为pnpm exec create-vite,即npx create-vite

关于 npx 与 npm exec 的对比

然后通过 npm-exec 安装好 create-vite 包,接着执行其 package.json 文件中bin里的命令。

bin 里的命令对应的是一个可执行的文件,通过软链接或者符号链接到指定资源的映射,这些可执行文件必须以 #!/usr/bin/env node 开头,否则脚本将在没有 node 可执行文件的情况下启动。

我们看一下 create-vite 的 package.json 文件,参考npm 关于 package.json 中 bin 的解释

{
  ...
  "bin": {
    "create-vite": "index.js",
    "cva": "index.js"
  },
  ...
}

通过以上内容,我们可以知道最终执行的是当前目录下的index.js文件

#!/usr/bin/env node

import './dist/index.mjs'

这里的./dist/index.mjs是打包后的路径,实际执行的其实是./src/index.ts,即确定入口文件为./src/index.ts
下面让我们通过调试的方式来对 create-vite 做一个深入的了解,不懂如何调试的小伙伴,请参考新手向:前端程序员必学基本技能——调试 JS 代码

开始调试

首先我们先通过cmd + K cmd + 0收起所有函数,看下整体代码如下

create-vite-index.png

并不多哦,除去类型定义也就 400 行左右。

首先先看一下引入包的作用:

  • 几个 Node 里面常用模块:fs 文件模块、path 路径处理模块以及 fileURLToPath 转文件路径模块;

  • cross-spawn 自动根据运行平台(windows、mac、linux 等)生成 shell 命令,并执行;

  • minimist 解析命令行传入的参数;

    node example/parse.js test1 test2 -a beep -b boop
    # 输出 { _: [test1, test2], a: 'beep', b: 'boop' }
    
  • prompts 命令行交互提示;

  • kolorist 给输入输出上颜色;

在 index.ts 最后可以看到会执行 init 函数,那么我们就从 init 函数开始调试吧!

确定项目名称及预设模版

const defaultTargetDir = 'vite-project'

async const init = () => {
  // 获取传入的第一个参数 去除前后空格以及末尾的/
  const argTargetDir = formatTargetDir(argv._[0])
  // 获取传入的模板
  const argTemplate = argv.template || argv.t
  // 确定输出目录
  let targetDir = argTargetDir || defaultTargetDir
}
debugger-init.gif

如上图所示,在 init 这里打个断点,使用esno来运行 ts 代码。

可以看到当我们有传入项目名称以及模板时,通过 minimist 拿到了传入的参数,如果输入合法的情况下,就会跳过 prompts 询问环节,继续下一步

输入合法指的是:当前目录不存在输入的项目名称的文件、输入的模板合法;如果输入不合法的情况下还是会进入 prompts 询问环节

平时初始化项目,都是使用--template来创建指定模板,通过源码看到也可以通过--t来创建模板,学到了。

这里根据传入的参数得到项目名称以及预设模版,如果未传入时通过 prompts 来确定项目名称和预设模板。

// 如果用户传入参数并且符合规则时 就不会进入询问
try {
  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?`,
      },
      // 二次确认是否输入的y 如果输入N(false)时直接退出
      {
        type: (_, { overwrite }: { overwrite?: boolean }) => {
          if (overwrite === false) {
            throw new Error(red('✖') + ' Operation cancelled')
          }
          return null
        },
        name: 'overwriteChecker',
        message: '',
      },
      // 判断输入的项目名是否符合 package.json 的命名规范
      {
        type: () => (isValidPackageName(getProjectName()) ? null : 'text'),
        name: 'packageName',
        message: reset('Package name:'),
        initial: () => toValidPackageName(getProjectName()),
        validate: (dir) =>
          isValidPackageName(dir) || 'Invalid package.json name',
      },
      // 通过--template传入模板存在时 不询问 否则让用户选择
      {
        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,
          }
        }),
      },
      // 判断框架是否有其他类型 如果存在时让用户选择
      {
        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')
      },
    }
  )
} catch (cancelled: any) {
  console.log(cancelled.message)
  return
}
debugger-init-noargs.gif

这一步流程如下:

  1. 如果传入项目名称和模板并输入合法时,不进行询问,直接到下一步

  2. 未传入时,首先获取项目名;

  3. 判断项目名是否存在,存在提示用户是否清空;

  4. 不存在时,判断输入项目名是否符合 package.json name 的命名规范,不符合要求用户重新输入;

  5. 符合时,选择预设模板以及模板对应的变体,并将用户输入的结果保存到result中,进入下一步

    const { framework, overwrite, packageName, variant } = result
    

覆盖已有目录/创建不存在的项目目录

// 目录是否为空 文件数为0 或者只存在.git
function isEmpty(path: string) {
  const files = fs.readdirSync(path)
  return files.length === 0 || (files.length === 1 && files[0] === '.git')
}
// 清空目录 如果是.git时 不做处理
function emptyDir(dir: string) {
  if (!fs.existsSync(dir)) {
    return
  }
  for (const file of fs.readdirSync(dir)) {
    if (file === '.git') {
      continue
    }
    fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
  }
}
const root = path.join(cwd, targetDir)

// 覆盖已有非空目录 或 创建空白目录
if (overwrite) {
  emptyDir(root)
} else if (!fs.existsSync(root)) {
  fs.mkdirSync(root, { recursive: true })
}
mkdirproject.gif

这一步完成后,可以看到在当前目录已经创建了vite-project空目录。

确定模板 template

// 确定模板
let template: string = variant || framework?.name || argTemplate
let isReactSwc = false
// 是否包含swc https://cn.vitejs.dev/plugins/#vitejsplugin-react-swc
if (template.includes('-swc')) {
  isReactSwc = true
  template = template.replace('-swc', '')
}
entertemplate.gif

这一步确定了最终选择的模板,并且判断了是否是 SWC 类型的模板。文章开头我们提高过 vite4.0 支持SWC,类似于的 Babel 代码处理插件,基于 Rust 开发,速度上比 Babel 快了很多倍,目前官方的模板仅支持在react中使用。

确定包管理器 pkgManager

// 通过 process.env.npm_config_user_agent 获取到当前运行脚本的包管理器和版本号
function pkgFromUserAgent(userAgent: string | undefined) {
  if (!userAgent) return undefined
  const pkgSpec = userAgent.split(' ')[0]
  const pkgSpecArr = pkgSpec.split('/')
  return {
    name: pkgSpecArr[0],
    version: pkgSpecArr[1],
  }
}
// process.env.npm_config_user_agent = npm/8.19.3 node/v16.19.1 darwin arm64 workspaces/false
const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent)
// 当前的包管理器
const pkgManager = pkgInfo ? pkgInfo.name : 'npm'
// 是否是yarn 1.x版本
const isYarn1 = pkgManager === 'yarn' && pkgInfo?.version.startsWith('1.')
nodepackage.gif

通过调试,可以知道 process.env.npm_config_user_agentnpm/8.19.3 node/v16.19.1 darwin arm64 workspaces/false这样的格式,通过 pkgFromUserAgent 可以获取到当前运行脚本的包管理器(npm/yarn/pnpm)和版本号。

customCommand 自定义命令

这里我们重新选 vue 模板的变种:create-vue 进行调试

const { customCommand } =
  FRAMEWORKS.flatMap((f) => f.variants).find((v) => v.name === template) ?? {}

// 拿到自定义命令 如:create-vue npm create vue@latest TARGET_DIR
if (customCommand) {
  const fullCustomCommand = customCommand...

  const [command, ...args] = fullCustomCommand.split(' ')
  // 替换TARGET_DIR为真实路径
  const replacedArgs = args.map((arg) => arg.replace('TARGET_DIR', targetDir))
  // 通过spawn.sync执行此命令
  const { status } = spawn.sync(command, replacedArgs, {
    // stdio: 'inherit'意思是继承:子进程继承当前进程的输入输出 并将输出信息同步输出在当前进程上
    stdio: 'inherit',
  })
  // 退出
  process.exit(status ?? 0)
}
templtae-create-vue.gif

可以看到,此时拿到对应的 customCommand 自定义命令npm create vue@latest TARGET_DIR,然后通过spawn.sync启动一个子进程来执行这个命令,完成后退出进程。

输出模板内容到目录

到最后一步了,坚持 ✊

// 确认模板路径
const templateDir = path.resolve(
  fileURLToPath(import.meta.url), // file:///xxx/create-vite-analysis/src/index.ts
  '../..',
  `template-${template}`
)

// 写入文件 package.json要修改name字段使用writeFileSync 其他直接copy
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.json的文件全部复制到输出目录中
const files = fs.readdirSync(templateDir)
for (const file of files.filter((f) => f !== 'package.json')) {
  write(file)
}

// 通过readFileSync拿到package.json文件内容 并通过JSON.parse处理
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')

// 选择的是react swc模板时 替换插件
if (isReactSwc) {
  setupReactSwc(root, template.endsWith('-ts'))
}

// 输出
// cd ...
// npm install
// npm run dev
generateTemplate.gif

大概流程为:

  1. 通过最终模板 template 确认模板对应的目录,也就是这些文件夹;
    viteTemplateList.png

此处import.meta可以获取这个模块的元数据信息,import.meta.url相当于file:///Users/xxx/create-vite-analysis/src/index.ts

  1. 获取模板内的文件/文件夹,通过write函数将除了 package.json 的文件全部复制到输出目录targetPath中;
  2. 将 package.json 文件的name字段替换为项目名packageName || getProjectName(),并写入输出目录中;
  3. 此时还需要判断是否为 react SWC 模式,如果是的话替换相应的插件为 SWC 的插件。

最后,输出cd ...这些提示命令。

总结

create-vite 的源码我们已经调试完了,是不是有这种感觉:原来离我们遥不可及的源码,并没那么高深莫测,如果自己动手调试一下的话,会发现并没有那么难。

说下我自己的收获:

  1. 知道了 vite4.0 的新功能,了解了一下 SWC 与 Babel 的对比;
  2. 学到了 npm init/create 命令执行的过程以及 npx 与 npm exec 的区别;
  3. 学会了 kolorist、prompts、minimist、cross-spawn 这几个工具的使用;
  4. 学会了 vscode 如何调试 ts 代码;
  5. 学会了 脚手架工具 创建项目的流程,有机会的话为公司弄一套自己的脚手架 create-xxx。

我发现有很多人是会被自己劝退,"我这种水平怎么读得了源码"、"我还没到读源码的年限"诸如此类的话,不妨动手从简单的源码试试,你并没有自己想的那么糟糕(哈哈,我就是个 🌰),

本文项目地址,git clone https://github.com/wang1xiang/create-vite-analysis.git。欢迎 star。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,033评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,725评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,473评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,846评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,848评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,691评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,053评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,700评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,856评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,676评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,787评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,430评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,034评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,990评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,218评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,174评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,526评论 2 343

推荐阅读更多精彩内容