我將以前搭建的一個掘金:vue3+ts企業級開發環境寫成了一個腳手架,本來設想用這個腳手架快速搭建公司各個項目的開發環境,正在一點點集成。先來看下效果:javascript
【文章目標】:css
爲了更好的理解和更有效率的學習,建議先下載這個項目lu-cli。vue
如下是腳手架中一般會用到的一些工具包,在後面的文章內容中我會給你們講解一下這些包的主要用途,先讓咱們來看看有哪些包:java
commander
命令行界面完整解決方案。傳送門🚪inquirer
交互式命令界面集合。傳送門🚪globby
路徑匹配工具。傳送門🚪execa
子進程管理工具。傳送門🚪fs-extra
增強版文件系統。傳送門🚪download-git-repo
下載代碼倉庫工具。傳送門🚪ejs
模版渲染。傳送門🚪chalk
終端字符樣式。傳送門🚪boxen
終端「盒子」。傳送門🚪vue-codemod
將文件內容轉換成AST。傳送門🚪ora
終端loading效果。傳送門🚪figlet
打印終端logo。傳送門🚪open
跨平臺打開連接。傳送門🚪還有更多有趣好玩的包等你挖掘。node
其中關於包的細節應用我就不過多描述了,建議你們先npm init
初始化一個開發項目,能夠先調用一下這些包看看都是用來作什麼的,效果是什麼樣子,便於後面的理解,官網地址都已經在上面給你們列出來了。react
commander
包是一套完成的命令行解決方案,用來建立腳手架命令,如lucli create app
:git
// index.js
#!/usr/bin/env node
const program = require("commander");
program
.version("0.0.1", "-v, --version") // 定義版本
.command("create app") // 命令名稱
.description("create an application") // 描述
.action((source,destination)=>{ // 執行回調
console.log('1',source);
console.log('2',destination);
// 執行一些邏輯,例如如下的交互邏輯
})
//解析命令行
program.parse();
複製代碼
注意:必定要執行
program.parse();
解析命令,不然在你可能會直接傻掉,腳手架大業未始而崩殂。github
在開發環境下經過以下命令進行測試:正則表達式
node ./bin/index.js create app
複製代碼
當咱們的腳手架開發完成後,在package.json
的bin
字段中進行映射。例如:vue-router
// package.json
"bin":{
"lucli":"./index.js"
}
複製代碼
發佈在npm
,當用戶在全局安裝完咱們的腳手架工具以後,index.js
將會被映射到lucli
對應的全局 bins
中,這樣就能夠在命令行中執行了:
lucli create app
複製代碼
固然,在沒發佈以前,咱們還能夠經過npm link
命令手動進行映射。
注意: 命令行權限問題,windows用戶建議管理員模式運行。mac用戶建議用sudo執行
npm link
or
sudo npm install
複製代碼
執行完成後咱們也能夠執行lucli create app
命令了。
經過inquirer
工具進行與用戶交互。
const inquirer = require("inquirer")
inquirer.prompt([
{
name:"name",
message:"the name of project: ", // 項目名稱
type:"input",// 字符類型
default:"lucli-demo", // 默認名稱
validate: (name) => { // 驗證名稱是否正確
return validProjectName(name).errMessage || true;
}
},
{
name:"framework",
message:"project framework", // 項目框架
type:"list",
choices:[ // 選項
{
name: "vue + ts",
value: "vue"
},
{
name: "react",
value: "react"
}
]
}
])
.then((answers) => {
console.log('結果:',answers);
})
.catch((error) => {
if (error.isTtyError) {
// Prompt couldn't be rendered in the current environment
} else {
// Something else went wrong
}
});
複製代碼
咱們在以上代碼中獲取到了用戶建立的工程名稱,咱們經過path
肯定工程路徑。
const path = require("path");
// 記住這個targetDir
const targetDir = path.join(process.cwd(), '工程名稱');
複製代碼
targetDir是你目標工程在本地的絕對地址,例如:
/Users/lucas/repository/study/lu-cli
複製代碼
在腳手架中經過globby
用來讀取咱們的模版目錄。
const globby = require("globby");
const files = await globby(["**/*"], { cwd: './template', dot: true })
console.log(files)
複製代碼
結果是一個基於你指定目錄下全部文件的路徑數組。
例如是這樣一個目錄:
├─template
│ ├─src
│ │ ├─index.js
│ │ ├─router
│ │ │ ├─index.ts
結果以下:
files: [ "src/index.js" , "src/router/index.ts" ]
複製代碼
以文件爲最小單元。 這個工具的具體做用在實戰中比較明顯,接着日後看。
fs-extra
是fs
模塊的增強版,在原有功能的基礎上新增了一些api。
const fs = require("fs-extra")
// 讀取文件內容
const content = fs.readFileSync('文件路徑', 'utf-8');
// 寫成文件
fs.writeFileSync('文件路徑','文件內容')
複製代碼
一般用來渲染文件,除了以上方式,模版文件還能夠經過如下方式從代碼倉庫下載。
經過download-git-repo
從代碼倉庫git clone
代碼。
const download = require("download-git-repo");
download('https://www.xxx..git', 'test/tmp', function (err) {
console.log(err)
})
複製代碼
經過ejs
進行一般在模版中須要根據不一樣的條件進行渲染。例如:
package.json
中根據用戶是否須要安裝babel
的添加關於babel
的一些配置。main.js
的文件中根據功能,渲染不一樣代碼。xx.vue
的模版中動態設置css
預編譯。const ejs = require('ejs')
// demo-01
const template = (
`<%_ if (isTrue) { _%>`
+ '內容'
+ `<%_ } _%>`
)
const newContent = ejs.render(template, {
isTrue: true,
})
//demo-02
const template = (
`<style lang="<%= cssPre %>">`
+`.redColor{`
+`color:red`
+`}`
+ `</style>`
)
const newContent = ejs.render(template, {
cssPre: 'less',
})
複製代碼
經過execa
執行終端命令,好比npm install
等命令,這個工具還能夠設置安裝源。
const executeCommand = (command, args, cwd) => {
return new Promise((resolve, reject) => {
const child = execa(command, args, {
cwd,
stdio: ['inherit', 'pipe', 'inherit'],
})
child.stdout.on('data', buffer => {
const str = buffer.toString()
if (/warning/.test(str)) {
return
}
process.stdout.write(buffer)
})
child.on('close', code => {
if (code !== 0) {
reject(new Error(`command failed: ${command}`))
return
}
resolve()
})
})
}
// 這裏的targetDir是上面咱們的目標工程路徑,在這個路徑下執行npm install
await executeCommand('npm', ['install'], targetDir)
複製代碼
經過chalk
實如今終端中展現不一樣樣式的字符串。
const chalk = require('chalk');
console.log(chalk.blue('Hello world!'));
複製代碼
在vue-cli
中經過vue-codemod
將文件內容轉換成AST,從而實如今文件中注入代碼的功能,返回文件內容字符串。
const { runTransformation } = require("vue-codemod")
const fileInfo = {
path: "src/main.js",
source: "文件內容"
}
// 對代碼進行解析獲得 AST,再將參數 imports 中的語句插入
const injectImports = (fileInfo, api, { imports }) => {
const j = api.jscodeshift
const root = j(fileInfo.source)
const toImportAST = i => j(`${i}\n`).nodes()[0].program.body[0]
const toImportHash = node => JSON.stringify({
specifiers: node.specifiers.map(s => s.local.name),
source: node.source.raw,
})
const declarations = root.find(j.ImportDeclaration)
const importSet = new Set(declarations.nodes().map(toImportHash))
const nonDuplicates = node => !importSet.has(toImportHash(node))
const importASTNodes = imports.map(toImportAST).filter(nonDuplicates)
if (declarations.length) {
declarations
.at(-1)
// a tricky way to avoid blank line after the previous import
.forEach(({ node }) => delete node.loc)
.insertAfter(importASTNodes)
} else {
// no pre-existing import declarations
root.get().node.program.body.unshift(...importASTNodes)
}
return root.toSource()
}
const params = {
imports: [ "import { store, key } from './store';" ]
}
const newContent = runTransformation(fileInfo, transformation, params)
複製代碼
腳手架原理就是經過收集到用戶交互信息後,根據不一樣定製化的需求,去讀取咱們提早準備好的模版信息,而後對個別文件作一些差別化的更新,更新的核心就是如何修改文件的內容,有三種方式:
vue-codemod
這一塊會在後面詳細說明,最後將內容對應的都寫入到咱們的目標工程下面。在執行安裝命令。
讀取模版的方式有多種,你還能夠簡單粗暴的直接用download-git-repo
的方式從遠程倉庫代碼下載。這種方式看你怎麼利用了,若是下載完整的項目代碼都不用作配置,那要準備的模版可能比較多,相對死板,你也能夠將通用模版放在倉庫中經過這種方式下載,而不放在項目中從而減小項目包大小。甚至涉及到版本更新的問題,各有利弊。一般咱們將模版放在項目中,方便維護。
核心原理都同樣,更多須要你費心神多是想一想怎麼組織好你的代碼更合理了。
如下我更多經過代碼結構的方式幫助你們去梳理具體要怎麼開發腳手架的一個思路。
新建一個js
文件用於建立腳手架命令,能夠經過node
或者在package.json
中用npm link
的方式進行調試。
在命令執行完畢的回調中咱們建立用戶交互,等待用戶交互完成後,假設拿到了咱們這些信息:
{
name:"lucli-demo", // 項目名稱
framework:"vue", // vue框架
funcList:["router","vuex","less"] // 功能列表
}
複製代碼
如今咱們的目標就明確了,構建一個集成了router
,vuex
,less
的vue
開發環境。接下來咱們大體思路就是下載模版,而後在對應的文件中注入代碼,好比在package.json
中添加對應的依賴。
咱們將模版根據功能進行劃分,vue
框架模版目錄以下:
vue-plugins/default/ // 默認模版
vue-plugins/router/ // 路由模版
vue-plugins/vuex/ // vuex模版
複製代碼
下載模版其實就是讀取模版內容而後生成一個文件:
// 寫成文件
fs.writeFileSync('文件路徑','文件內容')
複製代碼
那咱們生成多個文件是否是能夠經過循環遍歷一個對象,那基於此咱們設想將咱們要構建的文件列表都放在一個對象裏面,這個數組的結構以下:
{
'src/main.js':'內容',
'src/App.vue':'內容',
'src/theme.less':'內容',
'package.json':'內容'
...
}
複製代碼
接下來要作的就是去生成這個對象,首先咱們建立一個Generator
類,咱們將最終要渲染的文件目錄放在裏面:
// src/Generator.js
class Generator{
constructor(){
this.files = {}; // 文件目錄
}
}
複製代碼
經過globby
去讀取咱們的模版目錄,而後遍歷這個對象,經過文件系統(fs-extra
)去讀取對應的文件內容,咱們這邊以router
模版爲例。
// Generator.js
class Generator{
constructor(){
this.files = {}; // 文件目錄
}
render(){
// 讀取router功能模板
const files = await globby(["**/*"], { cwd: './src/vue-plugins/router', dot: true })
for (const rawPath of files) {
// 讀取文件內容
const content = getFileContent(rawPath)
// 更新files對象
this.files[rawPath] = content;
}
}
}
複製代碼
咱們在每一個模版目錄下面都建立一個index.js
,經過調用這個render
方法將模版信息都保存在files
這個對象中。
// src/vue-plugins/router/index.js
module.exports = (generator) => {
// 渲染模版
generator.render("vue-plugins/router/template");
}
複製代碼
咱們在獲取到功能列表後,循環這個列表,依次去執行這個render
方法。
const generator = new Generator();
// 循環功能加載模版
_funcsList.forEach(funcName => {
require(`../src/${pluginName}/${funcName}/index.js`)(generator);
});
複製代碼
固然,咱們也別忘了加載咱們的默認模板,畢竟默認模板纔是項目架構的主體。
這樣咱們的files
對象就是要渲染成文件的目錄了。
剩下就是在特定的文件中作一些差別化的處理了,好比main.js
中引入router
等。
這就涉及到如何在一個文件中插入代碼了,有3種方式:
vue-cli
中利用了vue-codemod
這個包將文件內容轉換成AST,而後在AST對應的節點插入內容,再將AST轉換成文件內容。關於package.json
咱們單獨作處理,用簡單的對象合併作相關的差別化,若是有複雜的配置也能夠考慮模版渲染。
思想仍是同樣的,咱們在Generator
中建立一個codeInFiles
對象用來存放要插入代碼的文件以及要插入的內容,pkg
對象存放package.json
內容。
// src/Generator.js
class Generator{
constructor(){
this.pkg = {
name,
version: "1.0.0",
description: "",
scripts: {
dev: "vite --mode development",
build: "vue-tsc --noEmit && vite build",
prebuild: "vue-tsc --noEmit && vite build --mode staging",
serve: "vite preview",
},
}; // package.json
this.files = {}; // 文件目錄
this.codeInFiles = { // 要插入代碼的對象
'路徑':new Set()
};
}
// 更新要插入代碼的codeInFiles對象
injectImports(path, source) {
const _imports = this.codeInFiles[path] || (this.codeInFiles[path] = new Set());
(Array.isArray(source) ? source : [source]).forEach(item => {
_imports.add(item)
})
}
}
複製代碼
以router
功能爲例,在router
功能模版的index.js
中調用:
// src/vue-plugins/router/index.js
module.exports = (generator) => {
// 渲染模版
generator.render("vue-plugins/router/template");
// 添加依賴
generator.extendPackage({
"dependencies": {
"vue-router": "^4.0.10",
}
})
// 注入代碼
generator.injectImports("src/main.ts", "import router from './router';");
}
複製代碼
循環遍歷codeInFiles
這個對象來進行插入代碼,將新的文件內容用來更新files
對象,我這邊以vue-codemod
爲例子:
// Generator.js
// 處理package對象
extendPackage(obj) {
for (const key in obj) {
const value = obj[key];
if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) {
this.pkg[key] = Object.assign(this.pkg[key] || {}, value);
} else {
this.pkg[key] = value;
}
}
}
// 往files中插入代碼
injectImports(){
Object.keys(_codeInFiles).forEach(file => {
const imports = _codeInFiles[file] instanceof Set ? Array.from(_codeInFiles[file]) : [];
if (imports && imports.length) {
// 將新插入代碼後的文件內容更新files對象
_files[file] = runTransformation(
{ path: file, source: _files[file] },
injectImports,
{ imports },
)
}
})
}
複製代碼
而後根據文件目錄files
依次生成文件便可。
// 生成package,json文件
fs.writeFileSync('package.json', JSON.stringify(this.pkg, null, 2))
// 生成其餘文件
Object.keys(files).forEach((name) => {
fs.writeFileSync(filePath, files[name])
})
複製代碼
這樣咱們的項目架構就基本搭建好了。
最後經過execa
安裝依賴便可。
// 執行npm install安裝依賴包
await executeCommand('npm', ['install'], targetDir)
複製代碼
提醒:實戰過程當中,做者不會說的特別細,代碼貼的不會很完整,爲了更好的學習效率:
建議先你們下載這個項目lu-cli,在實戰的時候能夠作爲參考,以及查找代碼。
經過npm init
初始化咱們的腳手架項目,完善下目錄結構:
├─cli-project // 項目名稱
│ ├─bin
│ │ ├─index.js // 命令文件
│ ├─src // 源碼
│ ├─package.json // 配置文件
│ ├─README.md
複製代碼
// bin/index.js
#!/usr/bin/env node
const program = require("commander");
const handlePrompts = require("../src/create");
program
.version("0.0.1", "-v, --version")
.command("create app")
.description("create an application")
.action(() => {
// 處理交互
handlePrompts();
})
//解析命令行
program.parse();
複製代碼
運行調試
node ./bin/index.js create app
複製代碼
咱們在根目錄下建立src/create.js
用來處理交互邏輯,這一塊我就不過多贅述了,你能夠根據本身的想法設計交互方案,這塊的代碼我就不完整貼了:
// src/create.js
const inquirer = require("inquirer")
const boxen = require('boxen');
const chalk = require('chalk');
const path = require("path");
const { promptTypes } = require("./enum");
const getPromptsByType = require("./getPrompts");
const Generator = require("./Generator");
const {
executeCommand,
validProjectName
} = require("./utils/index");
module.exports = () => {
// 打印咱們的歡迎信息
console.log(chalk.green(boxen("歡迎使用 lucli ~", { borderStyle: 'classic', padding: 1, margin: 1 })));
inquirer.prompt([
{
name: "name",
message: "the name of project: ", // 項目名稱
type: "input",// 字符類型
default: "lucli-demo", // 默認名稱
validate: (name) => {
return validProjectName(name).errMessage || true;
}
},
{
name: "framework",
message: "project framework", // 項目框架
type: "list",
choices: [
{
name: "vue + ts",
value: promptTypes.VUE
},
{
name: "react",
value: promptTypes.REACT
}
]
}
]).then(answers=>{
// 根據框架選擇prompts
const prompts = getPromptsByType(answers.framework);
if (prompts.length) {
// 選擇功能
inquirer.prompt(prompts).then(async (funcs) => {
// 邏輯處理 will code
})
} else {
console.log('抱歉,正在開發中,敬請期待!');
}
})
}
複製代碼
處理完交互了,咱們拿到了對應的信息:
{
name:"lucli-demo", // 項目名稱
framework:"vue", // vue框架
funcList:["router","vuex","less"] // 功能列表
}
複製代碼
接下來就是生成files
文件了,首先建立Generator
類,初始化咱們的files
、codeInFiles
,pkg
等對象以及一些處理函數。
這個類實際上是在開發中一點點的完善起來的,做者爲了偷懶就直接貼出來了。大家也能夠本身嘗試去寫一下。
// src/Generator.js
const path = require("path");
const ejs = require('ejs');
const fs = require("fs-extra");
const { runTransformation } = require("vue-codemod")
const {
writeFileTree,
injectImports,
injectOptions,
isObject
} = require("../src/utils/index")
const {
isBinaryFileSync
} = require('isbinaryfile');
class Generator {
constructor({ name, targetDir }) {
this.targetDir = targetDir;
this.pkg = { // package.json
name,
version: "1.0.0",
description: "",
scripts: {
dev: "vite --mode development",
build: "vue-tsc --noEmit && vite build",
prebuild: "vue-tsc --noEmit && vite build --mode staging",
serve: "vite preview",
},
};
this.files = {}; // 文件目錄
this.codeInFiles = {}; // 要插入代碼的文件
this.optionInFiles = {}; // 注入項
this.middlewareFuns = []; // 處理文件目錄的函數列表
}
// 處理package對象
extendPackage(obj) {
for (const key in obj) {
const value = obj[key];
if (isObject(value) && (key === 'dependencies' || key === 'devDependencies' || key === 'scripts')) {
this.pkg[key] = Object.assign(this.pkg[key] || {}, value);
} else {
this.pkg[key] = value;
}
}
}
// 更新要插入代碼的對象
injectImports(path, source) {
const _imports = this.codeInFiles[path] || (this.codeInFiles[path] = new Set());
(Array.isArray(source) ? source : [source]).forEach(item => {
_imports.add(item)
})
}
// 更新要插入的選項的對象
injectOptions(path, source) {
const _options = this.optionInFiles[path] || (this.optionInFiles[path] = new Set());
(Array.isArray(source) ? source : [source]).forEach(item => {
_options.add(item)
})
}
// 解析文件內容
resolveFile(sourcePath) {
// 若是二進制文件則直接返回
if (isBinaryFileSync(sourcePath)) {
return fs.readFileSync(sourcePath);
}
const template = fs.readFileSync(sourcePath, 'utf-8');
// 這邊沒什麼必要,若是你有模版渲染才須要加
const content = ejs.render(template);
return content;
}
// 渲染方法
async render(source) {
this.middlewareFuns.push(async () => {
const relativePath = `./src/${source}`;
const globby = require("globby");
// 獲取文件目錄
const files = await globby(["**/*"], { cwd: relativePath, dot: true })
for (const rawPath of files) {
// 獲取絕對地址用於讀取文件
const sourcePath = path.resolve(relativePath, rawPath)
const content = this.resolveFile(sourcePath)
// 有文件內容
if (Buffer.isBuffer(content) || /[^\s]/.test(content)) {
this.files[rawPath] = content;
}
}
})
}
// 執行函數
async generator() {
// 設置files的值
for (const middleawre of this.middlewareFuns) {
await middleawre();
}
const _files = this.files;
const _codeInFiles = this.codeInFiles;
const _optionsInFiles = this.optionInFiles;
// 往files中插入代碼
Object.keys(_codeInFiles).forEach(file => {
const imports = _codeInFiles[file] instanceof Set ? Array.from(_codeInFiles[file]) : [];
if (imports && imports.length) {
_files[file] = runTransformation(
{ path: file, source: _files[file] },
injectImports,
{ imports },
)
}
})
// 往files中插入代碼
Object.keys(_optionsInFiles).forEach(file => {
const injections = _optionsInFiles[file] instanceof Set ? Array.from(_optionsInFiles[file]) : [];
if (injections && injections.length) {
_files[file] = injectOptions(_files[file], injections);
}
})
await writeFileTree(this.targetDir, this.files)
// 生成package.json文件
await writeFileTree(this.targetDir, {
"package.json": JSON.stringify(this.pkg, null, 2)
})
}
}
module.exports = Generator;
複製代碼
而後咱們肯定咱們的項目路徑,實例化一個Generator
類,循環遍歷咱們的功能列表,去加載模版文件,從而構建咱們的files
對象。具體的模版信息請參考項目中的模版。
// src/create.js
module.exports = () => {
...
inquirer.prompt(prompts).then(async (funcs) => {
// 邏輯處理 will code
// 項目路徑
const targetDir = path.join(process.cwd(), answers.name);
// 建立實例
const generator = new Generator({ name: answers.name, targetDir });
const _funcsList = funcs.funcList;
// 選擇css預編譯
if (_funcsList.includes("precompile")) {
const result = await inquirer.prompt([
{
name: "cssPrecle",
message: "less or sass ?",
type: "list",
choices: [
{
name: "less",
value: "less"
},
{
name: "sass",
value: "sass"
}
]
}
]);
_funcsList.pop();
// 添加預編譯依賴
generator.extendPackage({
"devDependencies": {
[result.cssPrecle]: result.cssPrecle === "less" ? "^4.1.1" : "^1.35.2"
}
})
}
let pluginName = '';
// 肯定框架模版
switch (answers.framework) {
case promptTypes.VUE:
pluginName = 'vue-plugins'
break;
case promptTypes.REACT:
pluginName = 'vue-plugins'
break;
};
// 加載默認模版
require(`../src/${pluginName}/default/index.js`)(generator);
// 加載功能模版
_funcsList.forEach(funcName => {
require(`../src/${pluginName}/${funcName}/index.js`)(generator);
});
...
})
複製代碼
// src/vue-plugins/vuex/index.js
module.exports = (generator) => {
// 添加依賴
generator.extendPackage({
"dependencies": {
"vuex": "^4.0.2"
}
})
// 注入代碼
generator.injectImports("src/main.ts", "import { store, key } from './store';");
// 注入選項
generator.injectOptions("src/main.ts", ".use(store, key)");
// 渲染模版
generator.render("vue-plugins/vuex/template");
}
複製代碼
在使用inquirer
的時候你們靈活的使用,好比關於css預編譯的選項,你也能夠經過type:expand
的方式去作,八仙過海,各顯神通。
而後執行generator
函數將files
對象寫成文件,最後安裝package.json
中的依賴包:
//src/create.js
const {
executeCommand,
validProjectName
} = require("./utils/index");
...
// 執行渲染生成文件
await generator.generator();
// 執行npm install安裝依賴包
await executeCommand('npm', ['install'], targetDir)
console.log(chalk.green(boxen("構建成功", { borderStyle: 'double', padding: 1 })));
複製代碼
這樣咱們一個基礎的腳手架就已經開發完畢了。
掌握了該項目基本上你不只能夠開發腳手架這樣的工具了,你還能夠去寫更多相似的工具去提升工做中的效率了。
這個項目其實還有許多能夠優化的地方,好比安裝依賴包的時候選擇安裝源,建立項目的時候檢測目錄下是否已經存在該文件夾了,還有關於一些開發規範的配置等等。有興趣的夥伴能夠去探索一下,以給我提交mr
。
有時間的話我後面也會接着更新這篇文章。
謝謝。