这一章应该是比较主要的内容了,细节比之前要多一些,这一期直接根据代码架子,有个整体思路,然后一步一步补齐每个点
本章要点应该主要是:
渲染项目文件的流程
拆分模板文件
渲染一个基本的项目
预先说明
对于目录的一个拆分,主要是要根据文件的一个实际的情况。我们可以根据npm init vue@latest生成最简单配置的一个项目,如下图
我们可以用一个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
- .vscode
- 入口文件类(template/entry):方便统一管理入口文件的不同配置
- 默认入口文件(template/entry/default),最基础的入口文件配置
- main.js
- 默认入口文件(template/entry/default),最基础的入口文件配置
- 代码文件类(template/code):方便统一管理不同配置下的代码文件
- 默认代码文件(template/code/default)
- src下的相关的vue文件
- 默认代码文件(template/code/default)
代码整体流程架子
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. 渲染主流程代码
这里整体说明一下各个函数的作用
render和renderTemplate函数: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并且目录已经存在对应文件:直接合并jsonsettings.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最终生成的内容。
文件结构为:
.vscode/extensions.jsonvue的推荐配置
{
"recommendations": ["Vue.volar"]
}
.vscode/settings.json单词拼写检查不提示配置。(备注:这个不需要,可以删除,因为最后生成项目名称是用户自己输入的,也可以改为根据用户输入默认添加一下这个拼写配置)
{
"cSpell.words": ["jqq"]
}
- public/favicon.ico,直接用的还是vue的图标
- 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
可以看到生成了一个基础的项目结构
cd .\jqq\
npm install
npm run dev
可以看到项目可以正常运行
本期最终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)
下期预告
- 模板拆分说明
- 按配置进行渲染