Vue3脚手架实现(四、模板渲染流程、渲染一个基础项目)

383 阅读9分钟

Vue3脚手架实现(一、整体介绍)

Vue3脚手架实现(二、项目环境搭建)

Vue3脚手架实现(三、命令行读取配置)

Vue3脚手架实现(四、模板渲染流程、渲染一个基础项目)

Vue3脚手架实现(五、渲染jsx和prettier配置)

Vue3脚手架实现(六、渲染router和pinia)

Vue3脚手架实现(七、渲染eslint配置)

Vue3脚手架实现(八、渲染vitest配置)

Vue3脚手架实现(九、渲染typescript配置)

Vue3脚手架实现(十、补之前配置)

Vue3脚手架实现(十一、打包项目)

这一章应该是比较主要的内容了,细节比之前要多一些,这一期直接根据代码架子,有个整体思路,然后一步一步补齐每个点

本章要点应该主要是:

渲染项目文件的流程

拆分模板文件

渲染一个基本的项目

预先说明

对于目录的一个拆分,主要是要根据文件的一个实际的情况。我们可以根据npm init vue@latest生成最简单配置的一个项目,如下图

image.png

我们可以用一个template目录,存放所有模板文件

我们对存放模板文件需要进行分类,这个分类也是我们初步拆分模板文件的一个依据。我们自己手动配置项目的时候,可能需要增加不同的配置,这些配置会对不同的文件造成不同的影响。通过分类拆分了模板文件之后,可以更方便的控制一个配置影响到的一类文件。

最简单的一个项目可以分为几类,我们可以根据分类:

  • 基础配置类(template/base)(这些配置,渲染基础项目是需要的,未来增加配置也都会在这些基础之上增添内容)
    • .vscode
      • extensions.json
      • settings.json
    • public
      • favicon.ico
    • src/assets/main.css(主要是针对demo代码的样式,每个配置都有这个文件)
    • .gitignore
    • index.html
    • jsconfig.json(ts的时候需要删除掉)
    • package.json
    • vite.config.js
  • 入口文件类(template/entry):方便统一管理入口文件的不同配置
    • 默认入口文件(template/entry/default),最基础的入口文件配置
      • main.js
  • 代码文件类(template/code):方便统一管理不同配置下的代码文件
    • 默认代码文件(template/code/default)
      • src下的相关的vue文件

代码整体流程架子

1. 预先生成项目的package.json

一个项目,当然就要有最基本的package.json文件,对应的name和version字段都要先填充上

在create函数中添加代码

const create = async (name: string, options: Options) => {
  // 项目目录预处理
  ...

  // 询问用户需要的配置
  ...

  // 创建一下目录
  ...

  // 生成基础的package.json文件
  const pkgJson = { name: name, version: '0.0.0' } // 写入package.json的name和版本
  fs.writeFileSync(path.resolve(root, 'package.json'), JSON.stringify(pkgJson, null, 2)) // 缩进为2
}

2. 渲染主流程代码

这里整体说明一下各个函数的作用

  • renderrenderTemplate 函数:render 函数的作用就是针对模板文件路径名称,渲染对应文件下面的所有文件,所有的模板文件都维护在template的文件夹中。renderTemplate 函数里面封装对不同文件的处理逻辑,见下文
  • callbacks:在renderTemplate的执行过程中,会将ejs数据的函数先存放成函数列表,用来后面统一执行收集ejs需要的所有数据,再注入到对应的ejs模板中渲染出最终的效果
  • render('base'):表明的是,渲染template/base路径下的文件,这里是项目基础文件,存放在用户不选择任何配置的时候的文件
  • render('entry/default'):渲染用户不选取任何配置的时候的基础入口文件
  • render('code/default'):渲染用户不选取任何配置的时候的基础代码文件
  • dataStore:这个就是用来存放最终的生成的ejs数据,dataStore的key就是ejs模板文件路径,value就是模板文件对应的数据
  • preOrderDirectoryTraverse:这个就是一个深度遍历文件执行操作的函数,第一个参数是路径,第二个参数是针对目录执行的函数,第三个参数就是对文件执行的函数。ejs模板的渲染,就是最终生成的目录中,针对xxx.ejs的文件,需要去掉.ejs后缀变成最终文件名字,用这个文件的路径当作key取到数据,用ejs渲染成最终需要的文件,然后需要删除原来的这个xxx.ejs的文件
#! /usr/bin/env node
import packageJson from './package.json'
import { program } from 'commander'
import fs from 'fs-extra'
import inquirer from 'inquirer'
import ora from 'ora'
import chalk from 'chalk'
import path, { dirname } from 'path'
import ejs from 'ejs'
import { renderTemplate } from './utils/renderTemplate'
import { preOrderDirectoryTraverse } from './utils/directoryTraverse'
import { fileURLToPath } from 'url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

...

const create = async (name: string, options: Options) => {
  // 项目目录预处理
  ...

  // 询问用户需要的配置
  ...

  // 创建一下目录
  ...

  // 生成基础的package.json文件
  ...

  // 模板文件位置
  const templateRoot = path.resolve(__dirname, 'template')
  const callbacks: Function[] = []
  const render = function (templateName) {
    const templateDir = path.resolve(templateRoot, templateName)
    renderTemplate(templateDir, root, callbacks)
  }

  // 渲染基础项目
  render('base')
  
  // 添加入口文件
  render('entry/default')
  
  // 添加项目code
  render('code/default')

  // 收集所有的ejs的数据
  const dataStore = {}
  for (const cb of callbacks) {
    await cb(dataStore)
  }
  // 根据ejs数据渲染对应的模板文件
  preOrderDirectoryTraverse(
    root,
    () => {},
    (filePath) => {
      if (filePath.endsWith('.ejs')) {
        const template = fs.readFileSync(filePath, { encoding: 'utf-8' })
        const dest = filePath.replace(/\.ejs$/, '')
        const content = ejs.render(template, dataStore[dest])
        fs.writeFileSync(dest, content)
        fs.unlinkSync(filePath)
      }
    },
  )
}

renderTemplate说明

这个就是各个类型的文件的详细渲染规则,主要规则如下

  • 目录
    • 忽略node_modules
    • 递归调用renderTemplate方法渲染子目录
  • 文件
    • package.json并且目录已经存在对应文件:合并json,排序依赖
    • extensions.json并且目录已经存在对应文件:直接合并json
    • settings.json并且目录已经存在对应文件:直接合并json
    • _xxx文件,更换文件名字为.xxx
    • _gitignore并且目录已经存在对应文件:就在文件后面追加内容
    • xxx.data.mjs:缓存对应的获取数据的方法。在所有文件render了之后,最后统一生成ejs数据注入到ejs模板中
    • 其他情况:直接复制文件到对应目录下,覆盖已有文件

其他可能需要注意的代码情况如下

  • 在合并json的时候,注意需要定义下数组的处理方法,见mergeJsonCustomize

详细见下面代码,代码中给了对应的注释内容

utils/renderTemplate.ts

import fs from 'fs-extra'
import path from 'path'
import { mergeWith } from 'lodash-es'
import { pathToFileURL } from 'url'
import { sortDependencies } from './sortDependencies'

// 注意在进行merge的时候,数组的merge需要自定义一下,否则数组会覆盖前面数组的内容
const mergeJsonCustomize = (objValue: any, srcValue: any) => {
  if (Array.isArray(objValue)) {
    return objValue.concat(srcValue)
  }
}

// 针对正常的json文件的处理,合并json对象然后进行写入文件即可
const writeMergeJson = (src: string, dest: string) => {
  const oldJson = JSON.parse(fs.readFileSync(dest, { encoding: 'utf-8' }))
  const newJson = JSON.parse(fs.readFileSync(src, { encoding: 'utf-8' }))
  const json = mergeWith({}, oldJson, newJson, mergeJsonCustomize)
  fs.writeFileSync(dest, JSON.stringify(json, null, 2) + '\n')
}

// 针对package.json文件的处理,合并需要对依赖字段进行排序处理
const writeMergePackageJson = (src: string, dest: string) => {
  const oldJson = JSON.parse(fs.readFileSync(dest, { encoding: 'utf-8' }))
  const newJson = JSON.parse(fs.readFileSync(src, { encoding: 'utf-8' }))
  const json = sortDependencies(mergeWith({}, oldJson, newJson, mergeJsonCustomize))
  fs.writeFileSync(dest, JSON.stringify(json, null, 2) + '\n')
}

// 直接追加到文件末尾
const writeAppendFile = (src: string, dest: string) => {
  const oldFile = fs.readFileSync(dest, { encoding: 'utf-8' })
  const newFile = fs.readFileSync(src, { encoding: 'utf-8' })
  fs.writeFileSync(dest, oldFile + '\n' + newFile)
}

export const renderTemplate = (src: string, dest: string, callbacks) => {
  const stats = fs.statSync(src)

  /**
   * 对目录进行操作
   */
  if (stats.isDirectory()) {
    // 忽略node_modules
    if (path.basename(src) === 'node_modules') {
      return
    }
    // 如果目录已经存在,默认会抛出错误(除非设置了 { recursive: true })
    fs.mkdirSync(dest, { recursive: true })
    // 渲染子目录和文件
    for (const file of fs.readdirSync(src)) {
      renderTemplate(path.resolve(src, file), path.resolve(dest, file), callbacks)
    }
    return
  }

  /**
   * 后面就是针对文件的操作
   */

  const filename = path.basename(src) // 文件名
  const isExistFile = fs.existsSync(dest) // 是否存在文件

  // package.json
  if (filename === 'package.json' && isExistFile) {
    writeMergePackageJson(src, dest)
    return
  }

  // extensions.json
  if (filename === 'extensions.json' && isExistFile) {
    writeMergeJson(src, dest)
    return
  }

  // settings.json
  if (filename === 'settings.json' && isExistFile) {
    writeMergeJson(src, dest)
    return
  }

  // _开头的文件名字,复制到目录视为.开头的文件名字,因为很多.开头的配置文件会影响到其他文件,所以模板中使用_开头的规范
  if (filename.startsWith('_')) {
    dest = path.resolve(path.dirname(dest), filename.replace(/^_/, '.'))
  }

  // 处理.gitignore文件,追加内容到文件末尾
  if (filename === '_gitignore' && isExistFile) {
    writeAppendFile(src, dest)
    return
  }

  // 针对xxx.ejs,对应模板数据是xxx.data.mjs
  // xxx.data.mjs导出getData方法来生成对应的模板数据
  // 针对xxx.ejs可以有多个getData方法调用,后面的getData方法会得到之前的getData的数据传参
  if (filename.endsWith('.data.mjs')) {
    // 去除后缀就表示是对应的dest
    dest = dest.replace(/\.data\.mjs$/, '')
    // 记录函数数组,后续使用
    callbacks.push(async (dataStore) => {
      const getData = (await import(pathToFileURL(src).toString())).default
      dataStore[dest] = await getData({
        oldData: dataStore[dest] || {},
      })
    })
    return
  }

  // 其他情况,直接复制文件,用后面的文件进行覆盖前面的文件内容
  fs.copyFileSync(src, dest)
}

utils/sortDependencies.ts

// 对package.json的依赖进行排序操作
// 因为正常npm install一个包的时候,这些依赖项都是有序的
export const 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,
  }
}

preOrderDirectoryTraverse

/utils/directoryTraverse.ts:递归遍历文件夹和文件,对文件夹执行回调dirCallback,对文件执行回调fileCallback

import fs from 'fs-extra'
import path from 'path'

export const preOrderDirectoryTraverse = (
  dir: string,
  dirCallback: (fullPath: string) => void,
  fileCallback: (fullPath: string) => void,
) => {
  for (const filename of fs.readdirSync(dir)) {
    // 忽略.git
    if (filename === '.git') {
      continue
    }
    const fullPath = path.resolve(dir, filename)
    if (fs.lstatSync(fullPath).isDirectory()) {
      dirCallback(fullPath)
      if (fs.existsSync(fullPath)) {
        preOrderDirectoryTraverse(fullPath, dirCallback, fileCallback)
      }
      continue
    }
    fileCallback(fullPath)
  }
}

模板template

template/base

模板的话,我这边直接从create-vue的源码中粘贴出来自定义改了下,我这里和create-vue中有些许不同

主要的话,其实就是一个支持vue的最小配置的一个项目,但是做了如下改动

  • 剔除了入口文件(剔除入口文件是因为需要单独根据配置情况渲染入口文件)
  • 按照渲染规范更改了.gitignore文件的名字为_gitignore(主要是这个配置文件如果用.gitignore命名,会影响这个目录下的git行为)
  • vite.config.js文件用了vite.config.js.data.mjs和vite.config.js.ejs替代,这样方便后续增加配置,动态改变vite.config.js最终生成的内容。

文件结构为: image.png

  • .vscode/extensions.json vue的推荐配置
{
  "recommendations": ["Vue.volar"]
}

  • .vscode/settings.json 单词拼写检查不提示配置。(备注:这个不需要,可以删除,因为最后生成项目名称是用户自己输入的,也可以改为根据用户输入默认添加一下这个拼写配置)
{
  "cSpell.words": ["jqq"]
}

  • public/favicon.ico,直接用的还是vue的图标 image.png
  • src/assets/main.css:里面无内容
  • _gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*

node_modules
.DS_Store
dist
dist-ssr
coverage
*.local

/cypress/videos/
/cypress/screenshots/

# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

*.tsbuildinfo

  • index.html
<!doctype html>
<html lang="">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" href="/favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Jqq App</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

  • jsconfig.json
{
  "compilerOptions": {
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "exclude": ["node_modules", "dist"]
}

  • package.json
{
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.5.13"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.2.1",
    "vite": "^6.2.1",
    "vite-plugin-vue-devtools": "^7.7.2"
  }
}

  • vite.config.js.data.mjs
export default function getData() {
  return {
    plugins: [
      {
        id: 'vue',
        importer: "import vue from '@vitejs/plugin-vue'",
        initializer: 'vue()',
      },
      {
        id: 'vite-plugin-vue-devtools',
        importer: "import vueDevTools from 'vite-plugin-vue-devtools'",
        initializer: 'vueDevTools()',
      },
    ],
  }
}

  • vite.config.js.ejs:这里其实就是针对getData中返回的数据进行渲染的模板代码,具体可以参考ejs文档
import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
<%_ for (const { importer } of plugins) { _%>
<%- importer %>
<%_ } _%>

// https://vite.dev/config/
export default defineConfig({
  plugins: [
  <%_ for (const { initializer } of plugins) { _%>
    <%- initializer _%>,
  <%_ } _%>
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url)),
    },
  },
})

template/entry/default

create-vue的源码中应该是针对不同的情况给了对应的入口模板,我这里将对应的情况进行抽离,用ejs的方式进行渲染。主要是实际项目中可能需要自定义引入一些内容,方便大家可以在此基础上进行拓展入口配置。例如我们自己的项目中其实有配置是否引入团队自己的ui组件库之类的,就需要在选中配置后,在入口文件中动态添加配置。

后续我们的pinia和store之类的配置这里也要动态添加配置的,我们后续再做介绍

  • src/main.js.data.mjs
export default function getData() {
  return {
    importerList: [],
    useList: [],
    operationList: [],
  }
}

  • src/main.js.ejs
import '@/assets/main.css'

import App from './App.vue'
import { createApp } from 'vue'
<%_ for (const { importer } of importerList) { _%>
<%- importer %>
<%_ } _%>

const app = createApp(App)

<%_ for (const { use } of useList) { _%>
<%- use %>

<%_ } _%>
<%_ for (const { operation } of operationList) { _%>
<%- operation %>

<%_ } _%>
app.mount('#app')

template/code/default

  • src/components/HelloWorld.vue
<template>
  <div>hello world, jqq project is created</div>
</template>

<script setup></script>

<style scoped></style>

  • src/App.vue
<template>
  <HelloWorld></HelloWorld>
</template>

<script setup>
import HelloWorld from '@/components/HelloWorld.vue'
</script>

<style scoped></style>

验证下效果

我们执行npm run create

image.png

可以看到生成了一个基础的项目结构

image.png

cd .\jqq\
npm install
npm run dev

可以看到项目可以正常运行

image.png

本期最终index.ts

#! /usr/bin/env node
import packageJson from './package.json'
import { program } from 'commander'
import fs from 'fs-extra'
import inquirer from 'inquirer'
import ora from 'ora'
import chalk from 'chalk'
import path, { dirname } from 'path'
import ejs from 'ejs'
import { renderTemplate } from './utils/renderTemplate'
import { preOrderDirectoryTraverse } from './utils/directoryTraverse'
import { fileURLToPath } from 'url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

interface Options {
  force?: boolean
}

// 目标目录可能存在,就要提醒用户是否进行删除
// 同时要配合用户的强制删除options,用户的配置优先级更高
const processTargetDirectory = async (name: string, options: Options) => {
  return new Promise(async (resolve) => {
    const cwd = process.cwd()
    const target = path.join(cwd, name)
    let force = options.force
    const isExistTarget = fs.existsSync(target)
    // 有目录,无force配置时候才询问用户
    if (isExistTarget && typeof options.force === 'undefined') {
      // 询问用户
      const answer = await inquirer.prompt<{ force: boolean }>([
        {
          type: 'confirm',
          name: 'force',
          message: `Do you want to overwrite directory ${name}`,
          default: false,
        },
      ])
      force = answer.force
    }
    // 有目录,不能强制覆盖
    if (isExistTarget && !force) {
      console.log(chalk.red('✖') + ' ' + `The directory ${name} already exists`)
      process.exit(0)
    }
    // 有目录,可以强制覆盖
    if (isExistTarget && force) {
      const spinner = ora(`${name} is deleting`)
      spinner.start()
      fs.remove(target, (err) => {
        if (err) {
          console.error(err)
          process.exit(0)
        }
        spinner.succeed(`delete ${name} success`)
        return resolve(true)
      })
    } else {
      return resolve(true)
    }
  })
}

const FEATURE_OPTIONS = [
  {
    value: 'typescript',
    name: 'TypeScript',
  },
  {
    value: 'jsx',
    name: 'JSX 支持',
  },
  {
    value: 'router',
    name: 'Router(单页面应用开发)',
  },
  {
    value: 'pinia',
    name: 'Pinia(状态管理)',
  },
  {
    value: 'vitest',
    name: 'Vitest(单元测试)',
  },
  {
    value: 'eslint',
    name: 'ESLint(错误预防)',
  },
  {
    value: 'prettier',
    name: 'Prettier(代码格式化)',
  },
] as const
type Feature = (typeof FEATURE_OPTIONS)[number]['value']

const inquireConfig = async () => {
  const answer = await inquirer.prompt<{ features: Feature[] }>([
    {
      type: 'checkbox',
      name: 'features',
      message: 'Please select the features',
      choices: FEATURE_OPTIONS,
    },
  ])
  return answer
}

const create = async (name: string, options: Options) => {
  // 项目目录预处理
  await processTargetDirectory(name, options)

  // 询问用户需要的配置
  const { features } = await inquireConfig()
  const needsTypeScript = features.includes('typescript')
  const needsJsx = features.includes('jsx')
  const needsPrettier = features.includes('prettier')
  const needsRouter = features.includes('router')
  const needsPinia = features.includes('pinia')
  const needsVitest = features.includes('vitest')
  const needsEslint = features.includes('eslint')

  // 创建一下目录
  const targetDir = name
  const cwd = process.cwd()
  const root = path.join(cwd, targetDir)
  fs.mkdirSync(root)

  // 生成基础的package.json文件
  const projectPackageJson = { name: name, version: '0.0.0' } // 写入package.json的name和版本
  fs.writeFileSync(path.resolve(root, 'package.json'), JSON.stringify(projectPackageJson, null, 2)) // 缩进为2

  // 模板文件位置
  const templateRoot = path.resolve(__dirname, 'template')
  const callbacks: Function[] = []
  const render = function (templateName) {
    const templateDir = path.resolve(templateRoot, templateName)
    renderTemplate(templateDir, root, callbacks)
  }

  // 渲染基础项目
  render('base')

  // 添加入口文件
  render('entry/default')

  // 添加项目code
  render('code/default')

  // 收集所有的ejs的数据
  const dataStore = {}
  for (const cb of callbacks) {
    await cb(dataStore)
  }
  // 根据ejs数据渲染对应的模板文件
  preOrderDirectoryTraverse(
    root,
    () => {},
    (filePath) => {
      if (filePath.endsWith('.ejs')) {
        const template = fs.readFileSync(filePath, { encoding: 'utf-8' })
        const dest = filePath.replace(/\.ejs$/, '')
        const content = ejs.render(template, dataStore[dest])
        fs.writeFileSync(dest, content)
        fs.unlinkSync(filePath)
      }
    },
  )
}

program
  .name(packageJson.name)
  .description('cli to create a project of vue3')
  .version(packageJson.version)
program
  .command('create')
  .description('create a new project of vue3')
  .argument('<string>', 'project name')
  .option('-f, --force', 'overwirte target directory if it already exists')
  .action((name: string, options: Options) => {
    create(name, options)
  })
program.parse(process.argv)

下期预告

  • 模板拆分说明
  • 按配置进行渲染