前言
2022年已經過了四分之一還多了,之前說好的每個月一片文章好像也沒有讓自己兌現。最近公司在做一些前端工程化相關的東西,雖然準備做元件庫的事情被領導給斃了,不過在這之前寫了一個腳手架的工具,畢竟現在這個環境下,腳手架工具氾濫,所以當然也要寫一寫玩玩。
最終效果
支援功能
- 自主選擇web端或移動端;
- 自主選擇專案框架react或vue;
- 自主選擇是否設定遠端git地址;
- 支援在專案模板中自定義變數替換;
- 自主選擇是否自動安裝依賴,可選擇npm、cnpm、yarn;
- 支援使用update命令線上更新;
- 自主選擇已存在檔案目錄是否覆蓋;
開發
初始化專案
那麼接下來就開始開發了,首先我們來新建一個專案資料夾就叫new-cli
吧,在專案資料夾中新建package.json
檔案,設定常用的欄位,設定完成後如下:
{
"name": "new-cli",
"version": "1.0.0",
"description": "a react project cli, help you create a react project quickly",
"bin": {
"new-cli": "bin/www.js"
},
"dependencies": {
"boxen": "^5.1.2",
"chalk": "^4.1.2",
"commander": "^9.1.0",
"consolidate": "^0.16.0",
"cross-spawn": "^7.0.3",
"download-git-repo": "^3.0.2",
"ejs": "^3.1.6",
"fs-extra": "^10.0.1",
"inquirer": "^8.2.1",
"metalsmith": "^2.4.2",
"ora": "^5.4.1",
"figlet": "^1.5.2",
"semver": "^7.3.5",
"shelljs": "^0.8.5"
},
"repository": {
"type": "git",
"url": "https://github.com/BoWang816/new-cli.git"
},
"keywords": [
"cli",
"react"
],
"author": "恪晨",
"publishConfig": {
"registry": "私有倉庫地址"
},
"engines": {
"node":"^12.20.0 || >=14"
}
}
通過以上設定以後,我們的腳手架名字就叫new-cli,也就是說到時候安裝的時候就是通過npm install -g new-cli
進行安裝。bin下面設定的名稱就是為了設定腳手架執行的命令,並且是從bin/www.js檔案作為了入口檔案;dependencies
中為我們需要的專案依賴,值得注意的是像boxen、chalk、figlet這一類的依賴包在最新版本中已經不支援requier方式引入了所以這裡我們需要安裝低版本的包;publishConfig
中可以設定到時候需要釋出的npm地址,如果你搭建了npm私服則通過設定registry就可以釋出到你的私服了。
設定專案入口
建好package.json
以後我們就開始建入口檔案,也就是bin下面的www.js,事實上你的入口檔案放置在根目錄也是可以的,可以根據自己的喜好,當然如果放置在了根目錄,則bin下面就要改為new-cli: './www.js'
。www.js中主要是引入commander、inquirer等工具包,進行腳手架工具的初始化。因為www.js將要作為一個node指令碼來執行,因此需要在最上方宣告環境:#! /usr/bin/env node
,我寫的這個腳手架中涉及到了init、update、help這三個命令,並且help是commander本身就支援的,這裡只是做了一點定製化。
初始化init命令、update命令、help命令
首先需要引入commander,使用它的program,
const {program} = require("commander");
,腳手架工具的主體就是它了,我們初始化相關的命令:#! /usr/bin/env node // 引入commander const {program} = require("commander"); // 初始化init命令, project-name就是你的專案名稱與專案資料夾名稱 program.command("init <project-name>") // init命令描述 .description("create a new project name is <project-name>") // init命令引數項,因為後續會設定支援覆蓋資料夾,所以這裡提供一個-f引數 .option("-f, --force", "overwrite target directory if it exists") // init命名執行後做的事情 .action(() => { console.log('doSomething'); }); program.command("update") .description("update the cli to latest version") // update命令執行後做的事情,自動檢測更新 .action(async () => { // await checkUpdate(); console.log('update'); }); program.on("--help", () => { // 監聽--help命令,輸出一個提示 console.log(figlet.textSync("new-cli", { font: "Standard", horizontalLayout: 'full', verticalLayout: 'fitted', width: 120, whitespaceBreak: true })); }); // 這個一定不能忘,且必須在最後!!! program.parse(process.argv);
通過設定以上內容,其實我們就可以使用基本的命令了。本地除錯的方式有兩種,一種是通過npm link
命令將我們寫的腳手架工具直接連結到本地的全域性npm中,一種則是直接通過node bin/www.js
直接執行這個js檔案,這裡我們使用後者就可以了。
擴充套件init命令
接下來我們就需要擴充套件init命名,也就是在action做一些事情了。首先,我們提供了-f的引數選項,目的是為了在初始化專案的時候檢測到有同名資料夾則進行覆蓋,因此在初始化專案的第一步我們就需要檢測當前路徑下是否存在同名的資料夾,並且在沒有設定-f的時候給出提示資訊,同時在設定了-f後給出二次提示,同意覆蓋則開始初始化專案。因此action函式中將要執行的以下內容,這裡我們就需要引入chalk,paht,fs-extray以及後續我們自己寫的create。
const chalk = require("chalk");
const path = require("path");
const fs = require('fs-extra');
const figlet = require('figlet');
const create = require('../utils/create');
program
.command("init <project-name>")
.description("create a new project name is <project-name>")
.option("-f, --force", "overwrite target directory if it exists")
.action(async (projectName, options) => {
const cwd = process.cwd();
// 拼接到目標資料夾
const targetDirectory = path.join(cwd, projectName);
// 如果目標資料夾已存在
if (fs.existsSync(targetDirectory)) {
if (!options.force) {
// 如果沒有設定-f則提示,並退出
console.error(chalk.red(`Project already exist! Please change your project name or use ${chalk.greenBright(`new-cli create ${projectName} -f`)} to create`))
return;
}
// 如果設定了-f則二次詢問是否覆蓋原資料夾
const {isOverWrite} = await inquirer.prompt([{
name: "isOverWrite",
type: "confirm",
message: "Target directory already exists, Would you like to overwrite it?",
choices: [
{name: "Yes", value: true},
{name: "No", value: false}
]
}]);
// 如需覆蓋則開始執行刪除原資料夾的操作
if (isOverWrite) {
const spinner = ora(chalk.blackBright('The project is Deleting, wait a moment...'));
spinner.start();
await fs.removeSync(targetDirectory);
spinner.succeed();
console.info(chalk.green("✨ Deleted Successfully, start init project..."));
console.log();
// 刪除成功後,開始初始化專案
// await create(projectName);
console.log('init project overwrite');
return;
}
console.error(chalk.green("You cancel to create project"));
return;
}
// 如果當前路徑中不存在同名資料夾,則直接初始化專案
// await create(projectName);
console.log('init project');
});
我們再來檢視現在的效果:
建立create方法
在上一步操作中,我們覆蓋同名檔案後,使用了
await create(projectName)
方法開始初始化專案,接下來我們開始開發create方法。在根目錄新建一個資料夾叫utils
,當然你可以隨意叫lib或者✨點贊
都行,在utils下面新建一個檔案叫create.js
,在這個檔案中,我們將設定下載初始化專案中一些問題詢問的執行。內容主要有以下:const inquirer = require("inquirer"); const chalk = require("chalk"); const path = require("path"); const fs = require("fs"); const boxen = require("boxen"); const renderTemplate = require("./renderTemplate"); const downloadTemplate = require('./download'); const install = require('./install'); const setRegistry = require('./setRegistry'); const {baseUrl, promptList} = require('./constants'); const go = (downloadPath, projectRoot) => { return downloadTemplate(downloadPath, projectRoot).then(target => { //下載模版 return { downloadTemp: target } }) } module.exports = async function create(projectName) { // 校驗專案名稱合法性,專案名稱僅支援字串、數字,因為後續這個名稱會用到專案中的package.json以及其他很多地方,所以不能存在特殊字元 const pattern = /^[a-zA-Z0-9]*$/; if (!pattern.test(projectName.trim())) { console.log(`\n${chalk.redBright('You need to provide a projectName, and projectName type must be string or number!\n')}`); return; } // 詢問 inquirer.prompt(promptList).then(async answers => { // 目標資料夾 const destDir = path.join(process.cwd(), projectName); // 下載地址 const downloadPath = `direct:${baseUrl}/${answers.type}-${answers.frame}-template.git#master` // 建立資料夾 fs.mkdir(destDir, {recursive: true}, (err) => { if (err) throw err; }); console.log(`\nYou select project template url is ${downloadPath} \n`); // 開始下載 const data = await go(downloadPath, destDir); // 開始渲染 await renderTemplate(data.downloadTemp, projectName); // 是否需要自動安裝依賴,預設否 const {isInstall, installTool} = await inquirer.prompt([ { name: "isInstall", type: "confirm", default: "No", message: "Would you like to help you install dependencies?", choices: [ {name: "Yes", value: true}, {name: "No", value: false} ] }, // 選擇了安裝依賴,則使用哪一個包管理工具 { name: "installTool", type: "list", default: "npm", message: 'Which package manager you want to use for the project?', choices: ["npm", "cnpm", "yarn"], when: function (answers) { return answers.isInstall; } } ]); // 開始安裝依賴 if (isInstall) { await install({projectName, installTool}); } // 是否設定了倉庫地址 if (answers.setRegistry) { setRegistry(projectName, answers.gitRemote); } // 專案下載成功 downloadSuccessfully(projectName); }); }
在
create.js
檔案中,我們首先判斷了初始化的專案名稱是否包含特殊字元,如果包含則給出錯誤提示,並終止專案初始化。如果專案名稱合法,則開始詢問使用者需要的專案模板:
我們將這些詢問的list抽離為常量,同時也將模板的地址抽離為常量,因此需要在utils資料夾下建立一個constants.js
的檔案,裡面的內容如下:/** * constants.js * @author kechen * @since 2022/3/25 */ const { version } = require('../package.json'); const baseUrl = 'https://github.com/BoWangBlog'; const promptList = [ { name: 'type', message: 'Which build tool to use for the project?', type: 'list', default: 'webpack', choices: ['webpack', 'vite'], }, { name: 'frame', message: 'Which framework to use for the project?', type: 'list', default: 'react', choices: ['react', 'vue'], }, { name: 'setRegistry', message: "Would you like to help you set registry remote?", type: 'confirm', default: false, choices: [ {name: "Yes", value: true}, {name: "No", value: false} ] }, { name: 'gitRemote', message: 'Input git registry for the project: ', type: 'input', when: (answers) => { return answers.setRegistry; }, validate: function (input) { const done = this.async(); setTimeout(function () { // 校驗是否為空,是否是字串 if (!input.trim()) { done('You should provide a git remote url'); return; } const pattern = /^(http(s)?:\/\/([^\/]+?\/){2}|git@[^:]+:[^\/]+?\/).*?.git$/; if (!pattern.test(input.trim())) { done( 'The git remote url is validate', ); return; } done(null, true); }, 500); }, } ]; module.exports = { version, baseUrl, promptList }
其中version為我們的腳手架版本號,baseUrl為專案模板下載的基礎地址,promptList為詢問使用者的問題列表,promptList的具體寫法是根據
inquirer.prompt()
方法來寫的,具體的怎麼寫後面我都會將官方文件地址附上,大家可以自己發揮。- 通過
inquirer.prompt()
獲取到使用者反饋的結果以後,我們會拿到相關的欄位值,然後去拼接出下載的專案模板地址,接下來就是開始下載專案模板了。這裡我們寫了go函式和renderTemplate倆個函式,一個用於下載專案模板一個用於渲染專案模板(因為涉及到變數的替換)。go函式中其實是使用了從外部引入的downloadTemplate方法,因此我們需要去關注downloadTemplate與renderTemplate方法,也就是接下來要講的重點了。
建立download方法
在
utils
資料夾下,新建一個名稱為download.js
的檔案,檔案內容如下:/** * 下載 * download.js * @author kechen * @since 2022/3/25 */ const download = require('download-git-repo') const path = require("path") const ora = require('ora') const chalk = require("chalk"); const fs = require("fs-extra"); module.exports = function (downloadPath, target) { target = path.join(target); return new Promise(function (resolve, reject) { const spinner = ora(chalk.greenBright('Downloading template, wait a moment...\r\n')); spinner.start(); download(downloadPath, target, {clone: true}, async function (err) { if (err) { spinner.fail(); reject(err); console.error(chalk.red(`${err}download template failed, please check your network connection and try again`)); await fs.removeSync(target); process.exit(1); } else { spinner.succeed(chalk.greenBright('✨ Download template successfully, start to config it: \n')); resolve(target); } }) }) }
該檔案中,我們使用了
download-git-repo
這個第三方的工具庫,用於下載專案模板,因為download-git-repo的返回結果是下載成功或者失敗,我們在使用非同步的方式的時候如果直接使用會存在問題,因此這裡封裝為promise,當err的時候給使用者丟擲異常提示,成功則將目標資料夾路徑返回用於後續使用。在create.js
中我們使用了go函式,在go函式執行成功後會返回一個data,裡面拿到了專案要下載到具體的資料夾的路徑,其實主要是為了獲取在download中的promise的resolve結果,拿到目標資料夾的路徑後,其實專案模板已經下載到了該資料夾中,就可以開始renderTemplate了。- 建立renderTemplate方法
在utils
資料夾下,新建一個檔案叫renderTemplate.js
,該函式的主要目的是為了將初始化的專案中設定的變數進行替換,主要使用了metalSmith
和consolidate
這兩個第三方的包,通過遍歷初始化專案中的檔案,將其轉換為ejs模板,並替換相關的變數。這個方法是參考了vww-cli的方式,通過讀取專案模板中的ask.ts
檔案,獲取專案模板中自定義的詢問列表,然後再進行檔案模板引擎渲染替換相關設定好的變數,主要內容如下:
/**
* 渲染模板
* renderTemplate.js
* @author kechen
* @since 2022/3/24
*/
const MetalSmith = require('metalsmith');
const {render} = require('consolidate').ejs;
const {promisify} = require('util');
const path = require("path");
const inquirer = require('inquirer');
const renderPro = promisify(render);
const fs = require('fs-extra');
module.exports = async function renderTemplate(result, projectName) {
if (!result) {
return Promise.reject(new Error(`無效的目錄:${result}`))
}
await new Promise((resolve, reject) => {
MetalSmith(__dirname)
.clean(false)
.source(result)
.destination(path.resolve(projectName))
.use(async (files, metal, done) => {
const a = require(path.join(result, 'ask.ts'));
// 讀取ask.ts檔案中設定好的詢問列表
let r = await inquirer.prompt(a);
Object.keys(r).forEach(key => {
// 將輸入內容前後空格清除,不然安裝依賴時package.json讀取會報錯
r[key] = r[key]?.trim() || '';
})
const m = metal.metadata();
const tmp = {
...r,
// 將使用到的name全部轉換為小寫字母
name: projectName.trim().toLocaleLowerCase()
}
Object.assign(m, tmp);
// 完成後刪除模板中的檔案
if (files['ask.ts']) {
delete files['ask.ts'];
await fs.removeSync(result);
}
done()
})
.use((files, metal, done) => {
const meta = metal.metadata();
// 需要替換的檔案的字尾名集合
const fileTypeList = ['.ts', '.json', '.conf', '.xml', 'Dockerfile', '.json'];
Object.keys(files).forEach(async (file) => {
let c = files[file].contents.toString();
// 找到專案模板中設定好的變數進行替換
for (const type of fileTypeList) {
if (file.includes(type) && c.includes('<%')) {
c = await renderPro(c, meta);
files[file].contents = Buffer.from(c);
}
}
});
done()
})
.build((err) => {
err ? reject(err) : resolve({resolve, projectName});
})
});
};
通過renderTemplate方法,我們基本就完成我們腳手架的主要功能了。我們就可以實現使用init命令建立專案了。這裡我遇到一個問題,就是在刪除ask.ts檔案的時候,如果後面不加await fs.removeSync(result);
這個檔案就無法刪除,但是加上按理說又不合理,具體原因沒有找到,有知道的朋友可以留言解釋一下,十分感謝。至此,我們初始化專案的功能已經完成,接下來就是一些擴充套件了。
建立setRegistry方法
在
utils
資料夾下,新建一個檔案叫setRegistry.js
,主要是為了幫助使用者初始化專案的git地址,在使用者建立是選擇是否需要自動設定專案倉庫地址,如果設定了專案地址,則這裡會自動初始化git,並設定專案地址,具體內容如下:/** * 設定倉庫地址 * setRegistry.js * @author kechen * @since 2022/3/28 */ const shell = require("shelljs"); const chalk = require("chalk"); module.exports = function setRegistry(projectName, gitRemote) { shell.cd(projectName); if (shell.exec('git init').code === 0) { if (shell.exec(`git remote add origin ${gitRemote}`).code === 0) { console.log(chalk.green(`✨ \n Set registry Successfully, now your local gitRemote is ${gitRemote} \n`)); return; } console.log(chalk.red('Failed to set.')); shell.exit(1); } };
建立install方法
在
utils
資料夾下,新建一個檔案叫install.js
,主要是為了幫助使用者自動安裝依賴,主要內容如下:/** * 安裝依賴 * install.js * @author kechen * @since 2022/3/22 */ const spawn = require("cross-spawn"); module.exports = function install(options) { const cwd = options.projectName || process.cwd(); return new Promise((resolve, reject) => { const command = options.installTool; const args = ["install", "--save", "--save-exact", "--loglevel", "error"]; const child = spawn(command, args, {cwd, stdio: ["pipe", process.stdout, process.stderr]}); child.once("close", code => { if (code !== 0) { reject({ command: `${command} ${args.join(" ")}` }); return; } resolve(); }); child.once("error", reject); }); };
建立checkUpdate方法
在
utils
資料夾下,新建一個檔案叫checkUpdate.js
,主要是為了幫助使用者自動檢測並進行腳手架更新,主要內容如下:/** * 檢查更新 * checkUpdate.js * @author kechen * @since 2022/3/23 */ const pkg = require('../package.json'); const shell = require('shelljs'); const semver = require('semver'); const chalk = require('chalk'); const inquirer = require("inquirer"); const ora = require("ora"); const updateNewVersion = (remoteVersionStr) => { const spinner = ora(chalk.blackBright('The cli is updating, wait a moment...')); spinner.start(); const shellScript = shell.exec("npm -g install new-cli"); if (!shellScript.code) { spinner.succeed(chalk.green(`Update Successfully, now your local version is latestVersion: ${remoteVersionStr}`)); return; } spinner.stop(); console.log(chalk.red('\n\r Failed to install the cli latest version, Please check your network or vpn')); }; module.exports = async function checkUpdate() { const localVersion = pkg.version; const pkgName = pkg.name; const remoteVersionStr = shell.exec( `npm info ${pkgName}@latest version`, { silent: true, } ).stdout; if (!remoteVersionStr) { console.log(chalk.red('Failed to get the cli version, Please check your network')); process.exit(1); } const remoteVersion = semver.clean(remoteVersionStr, null); if (remoteVersion !== localVersion) { // 檢測本地安裝版本是否是最新版本,如果不是則詢問是否自動更新 console.log(`Latest version is ${chalk.greenBright(remoteVersion)}, Local version is ${chalk.blackBright(localVersion)} \n\r`) const {isUpdate} = await inquirer.prompt([ { name: "isUpdate", type: "confirm", message: "Would you like to update it?", choices: [ {name: "Yes", value: true}, {name: "No", value: false} ] } ]); if (isUpdate) { updateNewVersion(remoteVersionStr); } else { console.log(); console.log(`Ok, you can run ${chalk.greenBright('wb-cli update')} command to update latest version in the feature`); } return; } console.info(chalk.green("Great! Your local version is latest!")); };
這裡需要注意的是,因為腳手架是全域性安裝的,涉及到許可權的問題,因此在mac下需要使用
sudo new-cli update
進行更新,而在windows中需要以管理員身份開啟命令列工具執行new-cli update
進行更新。到這裡,我們的腳手架基本就完成啦。
其他花裡胡哨的東東
主要功能基本就是上面這些啦,另外我們需要加一個專案建立成功之後的提示,在上文的create.js
中最後面有一個downloadSuccessfully的方法,其實就是建立成功後的提示,主要內容如下:
const downloadSuccessfully = (projectName) => {
const END_MSG = `${chalk.blue("? created project " + chalk.greenBright(projectName) + " Successfully")}\n\n ? Thanks for using wb-cli !`;
const BOXEN_CONFIG = {
padding: 1,
margin: {top: 1, bottom: 1},
borderColor: 'cyan',
align: 'center',
borderStyle: 'double',
title: '? Congratulations',
titleAlignment: 'center'
}
const showEndMessage = () => process.stdout.write(boxen(END_MSG, BOXEN_CONFIG))
showEndMessage();
console.log('? Get started with the following commands:');
console.log(`\n\r\r cd ${chalk.cyan(projectName)}`);
console.log("\r\r npm install");
console.log("\r\r npm run start \r\n");
}
具體的實現效果就是這樣的,這裡我是截了之前做好的圖。
專案模板
我們需要建立一個專案模板,裡面需要在根目錄下包含一個ask.ts
檔案,其他的就和正常專案一樣就好了,aks.ts的檔案內容示例如下,
/**
* demo
* aks.ts
* @author kechen
* @since 2022/3/24
*/
module.exports = [
{
name: 'description',
message: 'Please enter project description:',
},
{
name: 'author',
message: 'Please enter project author:',
},
{
name: 'apiPrefix',
message: 'Please enter project apiPrefix:',
default: 'api/1.0',
// @ts-ignore
validate: function (input) {
const done = this.async();
setTimeout(function () {
// 校驗是否為空,是否是字串
if (!input.trim()) {
done(
'You can provide a apiPrefix, or not it will be default【api/1.0】',
);
return;
}
const pattern = /[a-zA-Z0-9]$/;
if (!pattern.test(input.trim())) {
done(
'The apiPrefix is must end with letter or number, like default 【api/1.0】',
);
return;
}
done(null, true);
}, 300);
},
},
{
name: 'proxy',
message: 'Please enter project proxy:',
default: 'https://www.test.com',
// @ts-ignore
validate: function (input) {
const done = this.async();
setTimeout(function () {
// 校驗是否為空,是否是字串
if (!input.trim()) {
done(
'You can provide a proxy, or not it will be default【https://www.test.com】',
);
return;
}
const pattern =
/(http|https):\/\/[\w\-_]+(\.[\w\-_]+)+([\w\-.,@?^=%&:/~+#]*[\w\-@?^=%&/~+#])?/;
if (!pattern.test(input.trim())) {
done(
'The proxy is must end with letter or number, like default 【https://www.test.com】',
);
return;
}
done(null, true);
}, 300);
},
},
];
這裡我設定了四個變數分別是description、author、apiPrefix、proxy,在使用時只需要通過<%= var %>
這種方式就可以了,var可以是你在ask.ts中設定的任何變數,具體使用demo如下,當然要替換的檔案型別必須是在上面我們提到的renderTemplate函式中設定了字尾名的檔案才可以。使用這種方式,你就可以在專案模板中自由新增變數,且不需要更新腳手架工具。
{
"name": "xasrd-fe-mobile",
"description": "<%= description %>",
"private": true,
"author": "<%= author %>"
}
至此,我們的腳手架就全部開發完成啦,接下來就是怎麼釋出到npm或者npm私服了。
釋出
在上面我們講過,如果需要釋出的npm私服,則需要在package.json
中配置publishConfig並指向npm私服的地址,釋出的時候則需要通過以下命令進行釋出:
私服npm釋出
- 登陸私服
npm login --registry=http://xxxxx
xxxxx為你的私服地址 - 釋出
npm publish
- 登陸私服
官方npm釋出
- 直接
npm login
,再npm publish
- 前提是你的npm源指向的官方npm
- 直接
通過github action自動觸發npm釋出
- 具體請參考:詳細記錄開發一個npm包,封裝前端常用的工具函式
當然需要注意的是,釋出的時候,package.json中的version版本號不能重複哈!!!
總結
到這裡,我們就完整的開發了一個比較簡單前端腳手架工具,並可以釋出使用了。其實具體的做法並不是很難,有很多第三方的工具包可以用,當然因為這個工具的互動相對來說比較簡單,各位也可以自己奇思妙想,做一些更加花裡胡哨的功能進行擴充套件。示例的demo就不放啦,基本所有的內容都在上面提到了,大家可以自由發揮。當然基於這套我自己也寫了一個地址是https://www.npmjs.com/package/wb-fe-cli,不過因為最近實在沒時間,所以專案模板還沒有,暫時還不能完整的跑起來,後續會慢慢更新的。
參考
結語
最後希望看完本篇文章後,對你有所幫助,勤快的話可以自己動手手寫一寫啦。另外希望大家能夠關注一下我的Github,哈哈哈哈,帶你們看貪吃蛇!
也可以關注一下GridManager這個好用的表格外掛,支援React、Vue,非常好用哦!
下期,我將會給大家帶來一些我常用的Mac軟體的介紹,能夠幫助你在日常開發與工作中大大提升工作效率!!!可以先預覽一下 恪晨的Mac軟體推薦。