脚手架create-vite源码学习及示例搭建

574 阅读7分钟

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

这是源码共读的第37期,链接:vite 3.0 都发布了,这次来手撕 create-vite 源码

一、学习目标

学习 vite 源码项目管理方式(monorepo),熟悉脚手架 create-vite 的实现,旨在了解成熟项目的项目管理(monorepo)且掌握脚手架搭建并能开发自定义脚手架,从而节省项目搭建时间提高开发效率。

二、准备工作

2.1 源码下载(github),了解目录结构,熟悉其项目管理方式

```
├── docs/ 		             // vite 手册
└── packages/
    ├── create-vite/                 // 脚手架
    ├── plugin-legacy/               // 插件: 为打包后的文件提供传统浏览器兼容性支持
    ├── plugin-react/                // 插件:提供完整的 React 支持
    ├── plugin-vue/                  // 插件:提供 Vue 3 单文件组件支持
    ├── plugin-vue-jsx/              // 插件:提供 Vue 3 JSX 支持
    ├── vite/                        // vite 前端构建工具
├── playground/                      // 组件操场
├── scripts
├── vite.config.ts                   // Vite 配置文件
└── package.json
```

在该源码中,整体使用了monorepo 的方式管理项目,单独维护的模块有脚手架( packages/create-vite)、官方提供的插件(packages/plugin-legacypackages/plugin-legacypackages/plugin-reactpackages/plugin-vuepackages/plugin-vue-jsx)和 packages/vite,其手册docs放在整体项目里维护。


monorepo 是项目管理的一种方式,其理念:在一个项目仓库(repo)中管理多个模块/包(package)。

其优点:统一了工作流,搭建一个脚手架就能管理(构建、测试、发布)多个package,统一测试,统一发版(当然,根据具体项目具体分析,确定如何统一维护);

其缺点:repo 体积会比较大,拥有自己 package.json 的 package,会安装自己的 node_modules,但大概率这些包会与其他 package 重复,这使原很大的 node_modules 变得更大;针对该缺点,目前常见的解决方案是 monorepo + yarn workspaces

2.2 脚手架包:packages/create-vite

  查看`packages.json` 文件,`bin` 字段设置了命令 `create-vite` 的入口文件 `index.js`,重置 `index.js` 文件内容
  ```
  #!/usr/bin/env node

  // import './dist/index.mjs'
  console.log("哈喽, 露水晰")
  ```
  

2.3 了解自定义命令的执行过程

可以查看笔者往期文章,简述:

  • 新建一个文件夹,创建一个 node 项目(终端输入命令:npm init,生成 package.json 文件)
  • package.json 文件,添加 bin 字段 { 'create-vite-lsx': 'index.js'}
  • 新建 index.js#!/usr/bin/env node 作用:说明该文件可当作脚本执行)
    #!/usr/bin/env node
          
    console.log("哈喽, 露水晰")
    
  • 打成全局包,须要打成全局包才可以使用命令(npm install .-gnpm link
  • 在终端输入 create-vite-lsx,得到如下结果说明该命令执行成功

image.png

三、create-vite 命令具体实现

src/index.js

// 1. 导入使用的模块
...

// 2. 主方法
async function init() {
    ...
}

// 3. 执行 init
init().catch((e) => {
  console.error(e)
})

3.1 模块

import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import spawn from 'cross-spawn'
import minimist from 'minimist'
import prompts from 'prompts'
import {
  blue,
  cyan,
  green,
  lightGreen,
  lightRed,
  magenta,
  red,
  reset,
  yellow
} from 'kolorist'
  • fs 文件操作(创建文件、删除文件、读某目录下的文件列表...)
  • path 路径操作
  • cross-spawn 运行批处理脚本
  • minimist 解析命令行参数
  • prompts 输入控件,用户交互
  • kolorist 用于将颜色放入标准输入/标准输出的库,即给 console.log 输出加颜色

3.2 init 方法

  • 解析命令行参数,得到 targetDir(项目被创建目录名称),template(模板类型)
  • 与用户交互
    1. targetDir 不存在,则等待用户输入,且赋值给 targetDir;
    2. targetDir 项目是否存在,若存在则给用户二次确认,若输入 y 则将原存在的 targetDir 目录下所有文件强制删除(删除是在用户交互结束后执行);否则,中断程序;
    3. 判断项目名称 packageName(默认值为 targetDir 路径的最后一级名称,与 targetDir 不同,该名称是写入到 package.json 的 name)是否有效,若无效则重新输入;
    4. 选择模板(提供了多种模板供用户选择),如果 template 空则给用户选择 framework
  • 通过与用户的交互,拿到项目路径 targetDir、包名称 packageName和使用的模板 template
  • 创建项目
    1. 若选择了自定义创建项目,则根据指引一步一步执行;
    2. 若根据模板创建,则一个文件一个文件的从源目录拷贝到目标目录(分两步:非 pcakge.json 文件和package.json 文件的创建),并修改文件中一些自定义的值(例:项目名称)

3.3 在 init 过程中使用到的一些工具类

文件拷贝、判断目录是否为空及删除目录下的所有文件(除了 .git 文件)

/**
 * 功能: 复制文件/目录到指定目录
 *
 * fs.copyFileSync(src, dest, mode) 将文件从源路径同步复制到目标路径
 *
 * @param {String} src 要复制的源文件名
 * @param {String} dest 复制操作将创建的目标文件名
 */
function copy(src, dest) {
  const stat = fs.statSync(src)
  if (stat.isDirectory()) {
    copyDir(src, dest)
  } else {
    fs.copyFileSync(src, dest)
  }
}

/**
 * 功能: 复制目录下的所有文件从源(srcDir)到目标目录(destDir)
 *
 * @param {string} srcDir
 * @param {string} destDir
 */
function copyDir(srcDir, destDir) {
  // 创建目标目录
  fs.mkdirSync(destDir, { recursive: true })
  // 获取源目录下的文件列表, 将其复制到目标目录
  for (const file of fs.readdirSync(srcDir)) {
    const srcFile = path.resolve(srcDir, file)
    const destFile = path.resolve(destDir, file)
    copy(srcFile, destFile)
  }
}

/**
 * 功能: 判断该文件路径是否为空
 *
 * @param {string} path
 */
function isEmpty(path) {
  const files = fs.readdirSync(path)
  return files.length === 0 || (files.length === 1 && files[0] === '.git')
}

/**
 * 功能: 强制删除目录下的文件(除了.git文件)
 *
 * fs.操作的文件/目录, 是以当前node执行环境目录为基础的
 * fs.existsSync(dir) 判断指定文件是否存在
 * fs.readdirSync(dir) 获取指定目录下的所有文件, 返回一个文件列表
 * fs.rmSync(file[, options]) 删除指定文件
 *
 * @param {string} dir
 */
function emptyDir(dir) {
  // 如果不存在
  if (!fs.existsSync(dir)) {
    return
  }
  // 如果存在, 则删除该目录下除了.git文件的其他文件
  for (const file of fs.readdirSync(dir)) {
    if (file === '.git') {
      continue
    }
    fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
  }
}

四、自己动手试试搭建一个脚手架

基本思路:暂时只有一个模板 template-html (静态网站),无需用户选择模板类型。输入搭建项目的命令create-project,根据指定的项目名称创建项目所在目录,再根据指定模板将模板下的所有文件拷贝到前面的项目目录下,并修改相应的名字配置。

// index.js

const fs = require('node:fs');
const path = require('node:path')
const minimist = require('minimist')
const prompts =  require('prompts')
const { reset, red } = require('kolorist')


// 解析命令行参数
const argv = minimist(process.argv.slice(2), { string: ['_']})
const cwd = process.cwd()

const renameFiles = {
    _gitignore: '.gitignore'
}

const defaultTargetDir = 'my-project'
const defaultTemplate = 'html'

async function init() {
    let targetDir = formatTargetDir(argv._[0])
    let template = argv.template || defaultTemplate

    const getProjectName = () =>
        targetDir === '.' ? path.basename(path.resolve()) : targetDir
    
    // 命令行交互
    let result = {}
    try {
        result = await prompts(
            [
                {
                    type: targetDir ? 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', // 二次确认: ? Target directory "packages" is not empty. Remove existing files and continue? » (y/N)
                    name: 'overwrite',
                    message: () =>
                      (targetDir === '.'
                        ? 'Current directory'
                        : `Target directory "${targetDir}:`) +
                      ` is not empty. Remove existing files and continue?`
                },
                {
                    type: (_, { overwrite } = {}) => {
                        if (overwrite === false) {
                          throw new Error(red('✖') + ' Operation cancelled')
                        }
                        return null
                      },
                      name: 'overwriteChecker'
                }
            ],
            {
                onCancel: () => {
                  throw new Error(red('✖') + ' Operation cancelled')
                }
            }
        )
    } catch (cancelled) {
        console.log(cancelled.message)
        return
    }

    const { overwrite, packageName } = result

    const root = path.join(cwd, targetDir)

    if (overwrite) {
        // 如果有二次确认过, 即输入的项目名称已存在,且已二次确认使用该目录则删除该目录下的所有文件(除了.git)
        emptyDir(root)
    } else if (!fs.existsSync(root)) {
        // 如果该目录不存在, 则创建目录
        fs.mkdirSync(root, { recursive: true })
    }

    const templateDir = path.resolve(cwd,`template-${template}`)
    
    const write = (file, content) => {
        const targetPath = renameFiles[file]
          ? path.join(root, renameFiles[file])
          : path.join(root, file)
        if (content) {
          fs.writeFileSync(targetPath, content)
        } else {
          copy(path.join(templateDir, file), targetPath)
        }
    }
    
    const files = fs.readdirSync(templateDir)
    for (const file of files.filter((f) => f !== 'package.json')) {
        write(file)
    }
    
    const pkg = JSON.parse(
        fs.readFileSync(path.join(templateDir, `package.json`), 'utf-8')
    )
    
    pkg.name = packageName || getProjectName()
    
    write('package.json', JSON.stringify(pkg, null, 2))
}

/**
 * 去掉字符串首尾空格且将末尾的"/"去掉
 * @param {string | undefined} targetDir 
 * @returns 
 */
function formatTargetDir(targetDir) {
    return targetDir?.trim().replace(/\/+$/g, '')
}


/**
 * 判断目录是否为空
 * @param {String} path 
 * @returns 
 */
function isEmpty(path) {
    const files = fs.readdirSync(path)
    return files.length == 0 || (files.length === 1 && files[0] === '.git')
}

/**
 * 强制删除目录下的文件(除了.git文件)
 * fs.操作的文件/目录, 是以当前node执行环境目录为基础的
 * fs.existsSync(dir) 判断指定文件是否存在
 * fs.readdirSync(dir) 获取指定目录下的所有文件, 返回一个文件列表
 * fs.rmSync(file[, options]) 删除指定文件
 *
 * @param {string} dir
 */
function emptyDir(dir) {
    // 如果不存在
    if (!fs.existsSync(dir)) {
        return
    }
    // 如果存在, 则删除该目录下除了.git文件的其他文件
    for (const file of fs.readdirSync(dir)) {
        if (file === '.git') {
            continue
        }
        fs.rmSync(path.resolve(dir, file), { recursive: true, force: true })
    }
}

/**
 * 拷贝文件
 * @param {*} src 
 * @param {*} dest 
 */
function copy(src, dest) {
    const stat = fs.statSync(src)
    if (stat.isDirectory()) {
      copyDir(src, dest)
    } else {
      fs.copyFileSync(src, dest)
    }
}

/**
 * 拷贝目录
 * @param {string} srcDir
 * @param {string} destDir
 */
function copyDir(srcDir, destDir) {
    // 创建目标目录
    fs.mkdirSync(destDir, { recursive: true })

    // 获取源目录下的文件列表, 将其复制到目标目录
    for (const file of fs.readdirSync(srcDir)) {
      const srcFile = path.resolve(srcDir, file)
      const destFile = path.resolve(destDir, file)
      copy(srcFile, destFile)
    }
}

init().catch(err=> {
    console.log("err", err)
})

终端输入命令 create-project, 输出为下

image.png

五、总结

首先,在学习 create-vite 源码过程中,收获了很多。例如:

(1)vite 项目管理模式(monorepo),尤其是 monrepo 项目管理理念:一个仓库(repo)管理多个模块/包(package),对比 create-react-appelement-plus 等项目,都使用了此理念;

(2)自定义脚手架搭建过程及使用的交互包 prompts 、五彩斑斓的打印包 kolorist 和 node 文件操作。