手把手教你寫一個前端腳手架
腳手架是什麼,相信各位已經熟悉得不能再熟悉了,畢竟無論是 vue 開發者(vue-cli)還是 react(create-react-app)開發者,他們都有各自的腳手架,個人雖是用 react 更多,但不得不說是更喜歡 vue-cli 的,它的插件機制
非常有意思,雖不如 webpack 的 plugin 那麼方便,但也很強大。不過再講這強大的功能之前,原諒我先水一篇腳手架的基礎。
腳手架會分兩篇
來講,本篇爲基礎篇
,講一講最簡單的腳手架如何搭建,入個門。
正文
概念與優點
相信很多開發者都有這麼一段經歷,那就是在開始新項目之前,先把舊項目拉下來,刪刪減減,只留下初始化項目時的配置,一切業務代碼都刪了,然後再開始新項目的開發。一次兩次如此做還好,但再多了就很厭煩,特別是刪代碼還很難保證項目的純淨,會出現漏刪或者刪多了的問題。而這時候,你就需要一個腳手架。
腳手架是什麼,他就是一個純淨的項目,可以完全不包含業務代碼,每次開始新項目之前,跑一下腳手架的命令,那麼一個純淨的項目就初始化出來了,可以直接在這之上進行開發。
無論是公司還是個人私底下做項目練手,都極其建議寫一個腳手架,就算是像本文這樣做一個最簡單的也是好的。
那麼,腳手架該如何做搭建呢,請移步到下文~
實現
前提:所使用到的第三方庫
-
Commander[1] 完整的 node 命令行解決方。當然也可以使用 yargs[2],yargs 功能更多一些。
-
Chalk[3] 能給 shell 命令行的文字添加樣式,簡單來說就是拿來畫畫的,可要可不要。
-
fs-extra[4] 操作文件的,比之 node 自帶的 fs,這個會更加強大與完善些。
-
inquirer[5] 在 shell 命令行中提供交互的庫,具體效果看下文的演示。
-
ora[6] 在 shell 命令行中展示 loading 效果
-
download-git-repo[7] 下載 git 倉庫。
步驟一:指定執行的文件
-
先創建一個項目
執行npm init -y
-
創建一個 bin 文件夾,添加 index.js 文件,在這個文件中寫下
#! /usr/bin/env node
此時目錄結構如下:
- 在 package.json 中指定執行命令和執行的文件
- 執行
npm link
命令,鏈接到本地環境中 npm link (只有本地開發需要執行這一步,正常腳手架全局安裝無需執行此步驟)Link 相當於將當前本地模塊鏈接到 npm 目錄下,這個目錄可以直接訪問,所以當前包就能直接訪問了。默認 package.json 的 name 爲基準,也可以通過 bin 配置別名。link 完後,npm 會自動幫忙生成命令,之後可以直接執行 cli xxx。
步驟二:配置可執行命令
- 直接在 bin/index.js 下配置 create 命令。直接貼代碼了,裏面涉及到的都是第三方庫的 api,不瞭解的先查下文檔較好。
ps:以下代碼都是 mjs,所以需要在 package.json 中添加一行 "type": "module"
// 1 配置可執行的命令 commander
import { Command } from 'commander';
import chalk from 'chalk';
import config from '../package.json' assert { type: 'json' };
const program = new Command();
program
.command('create <app-name>') // 創建命令
.description('create a new project') // 命令描述
.action((name, options, cmd) => {
console.log('執行 create 命令');
});
program.on('--help', () => {
console.log();
console.log(`Run ${chalk.cyan('rippi <command> --help')} to show detail of this command`);
console.log();
});
program
// 說明版本
.version(`rippi-cli@${config.version}`)
// 說明使用方式
.usage('<command [option]');
// 解析用戶執行命令傳入的參數
program.parse(process.argv);
將上面提到的第三方庫都安裝一下,然後隨便打開一個 cmd,執行 cli create project
。
步驟三:完善核心命令 ---create 命令
上面的步驟都只是一個腳手架最基本的鋪墊,而 create 命令纔是最關鍵的,而這最核心的 create 命令都應該做些什麼事情呢?
這裏就要聊聊腳手架的本質了,腳手架的本質無非就是我們先在一個倉庫裏寫好一個模板項目,然後腳手架每次運行的時候都把這個模板項目拉到目標項目中,腳手架不過是省去了我們拉代碼,初始化項目的操作而已。那麼現在,create 命令的基本流程就是這樣了。
ps: 如果要使用gitee的話,就不能使用download-git-repo這個庫了,這個庫只支持下載github,要另外找一個支持下載gitee的庫
- 創建一個 lib 文件夾,任何工具方法或者抽象類都放到這個文件夾中。以下是代碼,註釋解釋的都比較清楚了。
// lib/creator.js 編寫一個creator類,整個找模板到下載模板的主要邏輯都抽象到了這個類中。
import { fetchRepoList } from './request.js';
import { loading } from './utils.js';
import downloadGitRepo from 'download-git-repo';
import inquirer from 'inquirer';
import chalk from 'chalk';
import util from 'util';
class Creator {
constructor(projectName, targetDir) {
this.name = projectName;
this.dir = targetDir;
// 將downloadGitRepo轉成promise
this.downloadGitRepo = util.promisify(downloadGitRepo);
}
fetchRepo = async () => {
const branches = await loading(fetchRepoList, 'waiting for fetch resources');
return branches;
}
fetchTag = () => {}
download = async (branch) => {
// 1 拼接下載路徑 這裏放自己的模板倉庫url
const requestUrl = `rippi-cli-template/react/#${branch}`;
// 2 把資源下載到某個路徑上
await this.downloadGitRepo(requestUrl, this.dir);
console.log(chalk.green('done!'));
}
create = async () => {
// 1 先去拉取當前倉庫下的所有分支
const branches = await this.fetchRepo();
// 這裏會在shell命令行彈出選擇項,選項爲choices中的內容
const { curBranch } = await inquirer.prompt([
{
name: 'curBranch',
type: 'list',
// 提示信息
message: 'please choose current version:',
// 選項
choices: branches
.filter((branch) => branch.name !== 'main')
.map((branch) => ({
name: branch.name,
value: branch.name,
})),
},
]);
// 2 下載
await this.download(curBranch);
}
};
export default Creator;
// lib/utils.js 給異步方法加loading效果,只是一個好看點的交互效果
import ora from 'ora';
export const loading = async (fn, msg, ...args) => {
// 計數器,失敗自動重試最大次數爲3,超過3次就直接返回失敗
let counter = 0;
const run = async () => {
const spinner = ora(msg);
spinner.start();
try {
const result = await fn(...args);
spinner.succeed();
return result;
} catch (error) {
spinner.fail('something go wrong, refetching...');
if (++counter < 3) {
return run();
} else {
return Promise.reject();
}
}
};
return run();
};
// lib/request.js 下載倉庫
import axios from 'axios';
axios.interceptors.response.use((res) => {
return res.data;
});
// 這裏是獲取模板倉庫的所有分支,url寫自己的模板倉庫url
export const fetchRepoList = () => {
return axios.get('https://api.github.com/repos/rippi-cli-template/react/branches');
};
寫完上述代碼,接下來我們實例化下 creator,然後調用它的 create 方法就好了。
// lib/create.js
import path from 'path';
import Creator from './creator.js';
/**
* 執行create時的處理
* @param {any} name // 創建的項目名
* @param {any} options // 配置項 必須是上面option配置的選項之一,否則就報錯 這裏取的起始就是cmd裏面的options的各個option的long屬性
* @param {any} cmd // 執行的命令本身 一個大對象,裏面很多屬性
*/
const create = async (projectName, options, cmd) => {
// 獲取工作目錄
const cwd = process.cwd();
// 目標目錄也就是要創建的目錄
const targetDir = path.join(cwd, projectName);
// 創建項目
const creator = new Creator(projectName, targetDir);
creator.create();
};
export default create;
// bin/index.js 將上文中的action改掉
program
.command('create <app-name>') // 創建命令
.description('create a new project') // 命令描述
.action((name, options, cmd) => {
console.log('執行 create 命令');
});
那麼好,完成上述動作,我們來看看效果。
在一個空文件夾中打開 shell 命令行,然後執行cli create project
project 是項目名,隨便改。
效果已經出來了,我的這個倉庫有兩個分支,分別是 react 和 react+ts 的模板分支,這裏任意選一個。
選擇完畢之後,就會開始下載,看到 done 就說明下載完了。
此時我們的文件夾中多了這麼一個文件夾,打開進去看。
就是我們模板倉庫裏面的那些文件內容。
其實到這裏,最基本的一個腳手架就寫完了,不過對於嘗試了多次的朋友來說會發現一個問題,那就是噹噹前文件夾中存在相同名稱的文件時,文件就直接被覆蓋,而很多時候這個行爲是不好的,會導致用戶丟失不想丟失的內容,爲了優化這個體驗我們加個 --force 的配置。
優化:增加 --force 配置
force,就當遇到同名文件,直接覆蓋繼續我們的創建項目的流程。
// bin/index.js 新增一個option
program
.command('create <app-name>') // 創建命令
.description('create a new project') // 命令描述
.option('-f, --force', 'overwrite target directory if it is existed') // 命令選項(選項名,描述) 這裏就是解決下重名的情況
.action((name, options, cmd) => {
import('../lib/create.js').then(({ default: create }) => {
create(name, options, cmd);
});
});
在 create 方法中,我們接受的第二參數就會包含這個 option。
// lib/create.js
import path from 'path';
import fs from 'fs-extra';
import inquirer from 'inquirer';
import Creator from './creator.js';
/**
* 執行create時的處理
* @param {any} name // 創建的項目名
* @param {any} options // 配置項 必須是上面option配置的選項之一,否則就報錯 這裏取的起始就是cmd裏面的options的各個option的long屬性
* @param {any} cmd // 執行的命令本身 一個大對象,裏面很多屬性
*/
const create = async (projectName, options, cmd) => {
// 先判斷是否重名,如果重名,若選擇了force則直接覆蓋之前的目錄,否則報錯
// 獲取工作目錄
const cwd = process.cwd();
// 目標目錄也就是要創建的目錄
const targetDir = path.join(cwd, projectName);
if (fs.existsSync(targetDir)) {
// 選擇了強制創建,先刪除舊的目錄,然後創建新的目錄
if (options.force) {
await fs.remove(targetDir);
} else {
const { action } = await inquirer.prompt([
{
name: 'action',
type: 'list',
// 提示信息
message: `${projectName} is existed, are you want to overwrite this directory`,
// 選項
choices: [
{ name: 'overwrite', value: true },
{ name: 'cancel', value: false },
],
},
]);
if (!action) {
return;
} else {
console.log('\r\noverwriting...');
await fs.remove(targetDir);
console.log('overwrite done');
}
}
}
// 創建項目
const creator = new Creator(projectName, targetDir);
creator.create();
};
export default create;
整個 create 方法增加多了一個判斷是否存在同名文件的情況。
ps:node其實已經不推薦使用exists相關的方法了,但爲了好理解這裏仍然使用這個方法。node更推薦的是access方法,想了解更多可以查閱node官方文檔。
增加完這段邏輯之後,我們這個腳手架的完整流程如下:
結尾
本文是腳手架搭建的一個入門,這個腳手架只擁有最簡單的功能,而下一篇腳手架的搭建將會是複雜版的,擁有者插件機制,能通過配置插件動態生成項目,比如是初始化各種 lint、是否使用 mobx/redux,亦或者是是否初始化路由等,這都能通過配置插件完成,敬請期待吧😀。
那麼好,本文到此就結束了,希望沒接觸過腳手架的朋友能通過這篇文章瞭解到腳手架並且實現自己的腳手架。
本文簡單腳手架的完整代碼:點這裏 [8]
原文鏈接:https://juejin.cn/post/7260893255189758010
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/LL5eOwKVAUgR9srtAAkIvA