写在前面
本文开始将开始 Farm 框架原理性解析的第一篇, 带领大家从 0 到 1 到最后打造出属于自己的编译器。
如果还有不知道 Farm 的同学,在这里再给大家简单的介绍一下 Farm 是一个基于 Rust 实现的极速构建引擎,帮助您更快地构建 Web 程序 和 JavaScript 库。
Farm
仓库相关链接, 希望大家可以多多支持, 如果大家对 Farm
感兴趣, 也非常希望大家可以参与进来, 一同打造下一代 Web
构建工具
Farm 相关链接, 希望大家多多鼓励和支持
Farm CLI
第一篇文章带大家创建基于 Farm api
来打造一套个性化的构建工具, 带大家先从 cli
命令行开始, 不仅仅可以像其他构建工具一样比如 vite
, 使用 vite
vite build
vite preview
命令来快速启动一个项目,我们也可以针对不同功能,业务来封装属于自己的命令
那么什么是 cli
呢 ?, 以及针对 web 项目的 cli 和传统意义上的 cli
有什么区别呢
CLI是Command Line Interface的缩写,即命令行界面。它是一种在计算机上与用户进行交互的方式,用户通过键入文本命令来执行各种操作,而不是通过图形用户界面(GUI)。
而针对 Web 构建工具的CLI通常是一组命令行工具,用于辅助开发人员在项目中执行各种任务。这些任务可以包括启动服务器运行项目、构建项目、预览项目, 分析等。
开发 Cli 使用的工具
开发cli
也有一些好用的工具,这里为大家介绍一些作者最常用的工具。
- commander: 用于解析命令和参数,可以给不同的命令配置不同的处理函数,函数会接收到这个伴随命令传入的参数,可以说是非常好用了。
- citty : 优雅的 CLI 构建器由 unjs 开发, 函数式编程,各个环节逻辑清晰
- Oclif:是一个用于构建CLI的框架,它提供了一组强大的工具和约定,可以帮助你快速构建出具有一致性和专业性的CLI工具。它支持命令的组合、自动生成帮助文档、命令插件化等功能。
- cac: 是一个用于构建 CLI 应用程序的 JavaScript 库。也是目前比较常用的一款
- ora: 控制台的loading效果
- minimist 解析命令行参数
- inquirer: 可以在控制台和用户一问一答,交互式的处理。
实现功能
本文会从头搭建项目, 带领大家一步一步完成整个 cli
项目的构建与发布,以下是我们要实现的几个通用命令, 因为本文只是 cli
的建设搭建所以不会对具体核心 api
以及功能实现展开讲解, 以后 Farm
团队都会一点一点以本文为起点解析所有功能, 请大家拭目以待。
以下是Farm
目前所有实现的一些主要命令, 本文带大家主要完成 start
和 build
命令
farm start
启动开发服务器farm build
项目打包编译farm watch
监听项目打包编译,修改代码后实时进行编译farm preview
启动预览服务器来预览生产环境产物farm clean
清除增量构建写入磁盘的缓存
项目初始化
接下来就正式开始创建我们的项目, 那么我们选择 citty
来开发脚手架,至于为什么选择 citty
因为不仅 citty
基于 mri (轻量 parser 解析器) mri 比其他以前常用的 这是 minimist
和 yargs-parser
的都要快很多, 并且使用了更加现代化的函数式编程思想,提供了defineCommand
、runMain
等函数 API
,语法更加简洁直观。而很多其他工具使用的是基于原型或对象的API方式.
- 初始化项目
ts
pnpm init
- 安装项目所需依赖, 尽量做到轻量, 功能全面, 代码清晰简洁
ts
pnpm add @farmfe/core citty -D
- 在
package.json
中设置bin
字段, 在package.json
的bin
字段指定一个可执行文件的路径,npm
会为该文件创建一个软链接,添加到系统环境变量PATH
中。这样在终端就可以直接执行该命令。
ts
{
"name": "personal-next-cli",
"version": "1.0.0",
"main": "./dist/index.cjs",
"types": "./dist/index.d.ts",
"bin": {
"next": "./bin/cli.js"
}
}
- 在项目中设置
bin
目录, 新增一个cli.js
文件使用shebang语法 , 在cli.js入口文件的第一行加上了#!/usr/bin/env node
。这是一种Unix风格的shebang语法,告诉系统使用env命令调用node解释器来执行该脚本文件。这使得cli.js可以像其他命令行程序一样被直接执行。
js
#!/usr/bin/env node
console.log("Im next personal cli");
然后终端中执行 pnpm install -g
或者 npm link
将包安装到全局环境后, bin
中配置的命令就被链接到了 系统的PATH变量
中。这样在任何目录下执行该命令都是可行的,方便使用。我们执行 next
之后, 就会运行当前 bin 链接的 js 文件
ts
PS D:\adny\personal-cli> next // 命令行输入
Im next personal cli // 输出
所以我们只需要把 console.log("Im next personal cli");
这句话换成我们需要运行的文件就可以完成我们的第一步了
- 那么先来介绍一下
citty
提供的主要方法及其作用:
- defineCommand
defineCommand
是citty中定义命令的核心API。它接收一个对象作为参数,该对象描述了命令的元数据、参数配置和执行逻辑。返回一个命令定义对象。
ts
const main = defineCommand({
meta: { /*...*/ }, // 命令元数据解析 例如 version
args: { /*...*/ }, // 命令参数配置
run({ args }) { /*...*/ } // 命令执行逻辑
})
- runMain
runMain
用于运行一个命令定义对象。它会根据用户输入的参数执行相应的命令逻辑,并自动生成帮助信息。这是运行CLI应用的入口点。
ts
const main = defineCommand({
meta: { /*...*/ }, // 命令元数据解析 例如 version
args: { /*...*/ }, // 命令参数配置
run({ args }) { /*...*/ } // 命令执行逻辑
})
runMain(main) // 运行命令
- showUsage
呈现使用方法并打印到控制台
ts
PS D:\adny\personal-cli> next --help
USAGE next-personal-cli [OPTIONS] start|build|watch|preview|clean
OPTIONS
-h, --help Show this help message
COMMANDS
start Compile the project in dev mode and serve it with dev server
build Compile the project in production mode
watch Watch file change and recompile
preview Compile the project in watch mode with preview server
clean Clean up the cache built incrementally
Use next-personal-cli <command> --help for more information about a command.
- parseArgs
parseArgs
用于解析命令行参数,并应用默认值。返回解析后的参数对象。
ts
const args = parseArgs(['--foo', 'bar'], { ... }) // { _: [], foo: 'bar' }
常用的命令我们只需要前两个 (defineCommand , runMain)就够了
然后 @farmfe/core
作为 farm 的核心包, 提供了很多的编译方面的 api
在后续文章我们都会为大家介绍, @farmfe/core
也内置了针对 cli
所需要的所有 api
包括 start
, build
, watch
, clean
, preview
和其他更加底层的 api
例如 compiler
, wathcer
, server
等等
编写代码
目录结构如下
ts
-- src
-- index.ts
-- command
-- build.ts
-- preview.ts
-- start.ts
-- watch.ts
-- clean.ts
-- package.json
-- bin
-- cli.js
先来看入口文件 src/index.ts
, 先定义所有命令 citty
使用 defineCommand
定义入口, meta
定义元数据, 比如我们可以传入脚手架的版本号或者描述,run
方法代表每次执行命令之后执行的函数, subCommands
定义子命令。
ts
import fs from 'node:fs'
import { defineCommand, runMain, showUsage } from 'citty'
import { args } from './args.js'
const packageJsonFile = fs.readFileSync('./package.json', 'utf8');
const { version, description } = packageJsonFile as any;
const main = defineCommand({
meta: {
name: 'next-personal-cli',
version,
description,
},
args,
async run(ctx) {
if (ctx.args.help) {
await showUsage(ctx.cmd)
return
}
},
subCommands: {
start: () => import("./commands/start.js").then((r) => r.default),
build: () => import("./commands/build.js").then((r) => r.default),
watch: () => import("./commands/watch.js").then((r) => r.default),
preview: () => import("./commands/preview.js").then((r) => r.default),
clean: () => import("./commands/clean.js").then((r) => r.default),
},
});
runMain(main);
arg
属性代表每一个命令传递的参数, 可以传递类型,别名,默认值,还有描述,比如第一个 config
参数, 那么比如在我们运行 build
的时候 就可以使用 next build --config next-cli.config.ts
来指定我们需要运行的配置文件, farm
默认接收一个 farm.config.ts
文件会把定义的配置文件和命令行参数进行合并, 然后解析出最终交给 compiler
的参数对象
ts
export const args: any = {
config: {
type: 'string',
alias: 'c',
description:
'Use this config file (if argument is used but value is unspecified, defaults to farm.config.ts)',
},
mode: {
type: 'string',
alias: 'm',
description: 'Set env mode',
default: 'development',
},
base: {
type: 'string',
description: 'Public base path',
},
clearScreen: {
type: 'boolean',
description: 'Allow/disable clear screen when logging',
default: true,
},
help: {
type: 'boolean',
alias: 'h',
description: 'Show this help message',
},
}
start
那么先来看一下 start
子命令, farm
提供了开箱即用的一些命令, 也包含了所有编译,开发服务器等 api
的使用方式, 在 start 方法中就可以使用 farm
提供的 start
方法, 传递不同的参数
ts
import { start } from '@farmfe/core'
import { startArgs as args } from "../args.js";
export default defineCommand({
meta: {
name: "start",
description: "Compile the project in dev mode and serve it with dev server",
},
args,
run({args}) {
const resolveOptions = resolveCommandOptions(args);
const configPath = getConfigPath(args.config as string);
const defaultOptions: any = {
compilation: {
lazyCompilation: args.lazy
},
server: resolveOptions,
clearScreen: args.clearScreen,
configPath,
mode: args.mode
};
start(defaultOptions as any)
},
});
先来设计一下 start
子命令所需要的参数
-
- base : 配置
dev server
的public path
- base : 配置
-
- port: 支持命令行传递不同端口号
-
- host : 支持命令行传递
host
主机
- host : 支持命令行传递
-
- open:支持默认自动打开浏览器功能
start
的命令行参数一般情况下, 我们会可能需要配置 base
相当于开发服务器 public path
, port
开发服务器端口号,host
开发服务器主机 ip
地址, open
是否默认启动浏览器。我们先简单的使用这几个
ts
export const startArgs: any = {
base: {
type: 'string',
description: 'Public base path',
}
host: {
type: "number",
description: "specify host",
},
port: {
type: "number",
description: "specify port",
},
open: {
type: "boolean",
description: "open browser after server started",
},
}
然后我们就可以开始测试 start
命令了, 我们简单的使用 tsc
进行打包, 然后 bin 文件链接打包之后的 dist
目录。
先定义 tsconfig.json
文件
json
{
"compilerOptions": {
"noImplicitAny": true,
"sourceMap": true,
"target": "esnext",
"inlineSources": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipDefaultLibCheck": true,
"skipLibCheck": true,
"rootDir": "src",
"outDir": "dist",
"declaration": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"resolveJsonModule": true,
"lib": ["ESNext", "DOM"],
"moduleResolution": "node",
"module": "esnext"
},
"exclude": [
"node_modules",
"src/templates",
],
"include": [
"src/**/*.ts"
],
}
然后运行 tsc
之后会生成 dist
目录, 我们在 bin/cli.mjs
中导入这个文件
shell
#!/usr/bin/env node
import '../dist/index.js'
package.json
中已经配置了 bin
属性的指向,所以当我们运行 next start
他就会执行这个文件, 接下来新建一个 playground
目录,新建一个 index.html
。
然后我们进入到
playground
目录运行 next start --port 6542
。

成功运行,说明我们 start
方法就已经运行成功了, 那么我们 start
命令就完成了, 大家根据公司业务或者其他想法, 也可以添加其他的命令行参数。
Build
接下来开始 build
命令, 其实功能也是一样, 浅浅设计一下 build
命令
-
- outDir: 支持编译打包之后的输出目录
-
- entry: 支持编译的入口文件
-
- format : 如果是
node
环境需要打包出不同的输出格式比如esm
,cjs
- format : 如果是
-
- targetEnv : 要同时支持
web
环境 和node
环境的打包
- targetEnv : 要同时支持
-
- minify : 是否需要压缩
minify
- minify : 是否需要压缩
-
- sourcemap : 是否输出生成
sourcemap
- sourcemap : 是否输出生成
-
- treeShaking : 以及是否需要
treeShaking
- treeShaking : 以及是否需要
那么需要传递的命令行参数就是
ts
export const buildArgs: any = {
// 输出目录
outDir: {
type: 'string',
alias: 'o',
description: 'Output directory',
},
// 入口文件
input: {
type: 'string',
alias: 'i',
description: 'Input file',
},
// 构建环境
target: {
type: 'string',
alias: 't',
description: 'transpile targetEnv node, browser',
},
// 格式化输出 esm / cjs
format: {
type: 'string',
alias: 'f',
description: 'Output format esm, cjs',
},
// 是否开启压缩
minify: {
type: 'boolean',
description: 'code compression at build time',
},
// 是否开始 sourcemap
sourcemap: {
type: 'boolean',
description: 'output source maps for build'
},
// 是否开启树摇
treeShaking: {
type: 'boolean',
description: 'Eliminate useless code without side effects',
},
}
然后开始编写 build
的 subCommands
, 把格式化后的参数传递给 farm
提供的 build
方法
javascript
import { defineCommand } from "citty";
import { build } from '@farmfe/core'
export default defineCommand({
meta: {
name: "build",
description: "Compile the project in production mode",
},
args,
run({ args }) {
const configPath = getConfigPath(args.config);
const defaultOptions = {
compilation: {
watch: args.watch,
output: {
path: args?.outDir,
targetEnv: args?.target,
format: args?.format
},
input: {
index: args?.input
},
sourcemap: args.sourcemap,
minify: args.minify,
treeShaking: args.treeShaking
},
mode: args.mode,
configPath
};
build(defaultOptions)
},
});
这样我们在执行 next build
之后他就会自动执行这个命令

接下来在测试一下我们定义的 build
命令行参数
shell
next build --entry index.js --outDir build --target node-next --sourcemap

然后目录就会生成 build
文件夹,并且打包的是 node
环境下的产物

Farm 一致性
Farm
提供的这些开箱即用的方法,我们拿start
和build
举例,其他命令原理也是如此, 他们都是传递一个配置参数对象, 这两个方法除了start
是需要启动一个开发服务器, 其他基本没有区别, 除了一些默认的生产环境以及开发环境的插件 比如默认开发环境不开启压缩以及树摇,所以你甚至可以在开发环境中开启treeShaking
和minify
。Farm
的这一举动是为了表达出开发环境和生产环境永远的一致性
扩展 create 命令
其他命令原理也是一样的,这样就完成了一个构建工具基本的 cli
启动和构建的能力。 为了扩展 cli
的能力,在带领大家写一个创建模板的子命令, 首先在 index.ts
中添加create
子命令
ts
subCommands: {
create: () => import("./commands/create.js").then((r) => r.default),
},
commands
目录下新建 create.ts
文件整个功能的实现也非常简单, 我们提供好已知的项目模板, 只要根据不同的命令行参数就可以解析到不同的 template
最后把模板复制到我们传递的目录中就可以了, 我们使用 prompts
这个包来进行交互不仅提供命令行参数的能力, 也提供模板交互来让我们手动进行选择, 我们为了简化操作就只提供一种选择框架的交互 整个代码逻辑也非常简单,
我们需要先安装 prompts
交互和 picocolors
颜色包
ts
pnpm add prompts picocolors -D
代码逻辑如下,createArgs
我们可以传递 template 参数来指定使用 vue
或者 react
等其他框架或者根据 prompts
来进行手动选择,先输入项目名称, 然后选择需要使用的框架, 然后就成功啦。
ts
import { defineCommand } from "citty";
import fs from 'node:fs'
import prompts from 'prompts';
import colors from 'picocolors';
import { createArgs as args } from "../args.js";
import { copyTemplate, formatTargetDir, getConfigPath, isEmpty, pkgFromUserAgent, resolveCommandOptions } from "../utils.js";
export default defineCommand({
meta: {
name: "create template",
description: "create a new project from a template with farm",
},
args,
run({ args }) {
createFarm(args)
},
});
async function createFarm(args: any) {
const cwd = process.cwd();
const DEFAULT_TARGET_NAME = 'farm-project';
const argProjectName = formatTargetDir(args._[0]);
const argFramework = args.template || args.t;
let targetDir = argProjectName || DEFAULT_TARGET_NAME;
let result: IResultType = {};
const skipInstall = args['skip-install'] ?? args.skipInstall ?? true;
try {
result = await prompts(
[
{
type: argProjectName ? null : 'text',
name: 'projectName',
message: 'Project name:',
initial: DEFAULT_TARGET_NAME,
onState: (state) => {
targetDir = formatTargetDir(state.value) || DEFAULT_TARGET_NAME;
}
},
{
type: () =>
!fs.existsSync(targetDir) || isEmpty(targetDir) ? null : 'confirm',
name: 'overwrite',
message: () =>
(targetDir === '.'
? '🚨 Current directory'
: `🚨 Target directory "${targetDir}"`) +
` is not empty. Overwrite existing files and continue?`
},
{
type: (_, { overwrite }: { overwrite?: boolean }) => {
if (overwrite === false) {
throw new Error(colors.red('❌') + ' Operation cancelled');
}
return null;
},
name: 'overwriteChecker'
},
{
type: argFramework ? null : 'select',
name: 'framework',
message: 'Select a framework:',
initial: 0,
choices: [
{
title: colors.cyan('React'),
value: 'react'
},
{ title: colors.green('Vue'), value: 'vue' },
]
},
],
{
onCancel: () => {
throw new Error(colors.red('❌') + ' Operation cancelled');
}
}
);
} catch (cancelled) {
console.log(cancelled.message);
return;
}
const { framework = argFramework, packageManager } = result;
await copyTemplate(targetDir, {
framework,
projectName: targetDir,
packageManager
});
console.log(colors.green('📦 Template copied Successfully!'));
}
接下来我们试一下效果, 要提前把 template
文件夹放到目录中, 发布的时候可以在 package.json
中放到 files
里

我们定义好的 templates
目录

然后在任意目录下输入
ts
next create

选择完之后就能看到项目被正确的创建了

总结
本文从头到尾详细介绍了如何使用 Farm
构建一个个性化的 CLI
工具,旨在帮助开发者快速构建适用于特定项目或场景的命令行界面工具。通过本文的学习,读者不仅可以了解到 CLI
工具的重要性和特点,还能够掌握使用 citty
构建 CLI
工具的方法。
在项目初始化和结构部分,我们学习了如何使用 citty 初始化项目,配置 package.json
的 bin
字段,并创建了项目的目录结构,为后续各个子命令的实现做好了准备。接着,我们逐步实现了 start
、build
等子命令,展示了如何利用 Farm
提供的 API 来完成具体功能。整个脚手架的命令都已经完成本文只带大家体验了 start
和 build
命令。其他的 watch
,preview
和 clean
完整的功能可以看github next-personal-cli 仓库代码
在最后,我们还介绍了如何添加交互式命令 create,并使用 prompts 包来与用户进行交互,以便根据用户的选择创建新项目。这一部分为 CLI 工具增添了更多的交互性和可定制性,使其更加灵活和易用。
我们甚至可以基于 cli
扩展各种各样的插件命令来快速创建插件,Farm
框架也提供了更多核心、功能颗粒度更加细致的 API
,使得开发者可以根据自身需求定制更加完善的功能。不仅仅是 cli
, 无论是扩展现有功能、引入新的特性,还是开发更专业化的工具,都可以在Farm
的基础上灵活实现。这种灵活性和可扩展性为开发者提供了丰富的选择,使得他们可以根据项目需求和个人偏好,打造出更符合实际场景的定制化工具。因此,无论是构建简单的 CLI
工具还是复杂的 Web
应用,Farm
都能够提供强大支持,并为开发者带来更大的创造空间。
本文实现代码功能逻辑并不复杂, 适合大家在步入前端工程化开头的一课,这是我们实现 Farm
的第一课,后面的代码会循序渐进。请大家拭目以待,毫无保留的教给大家
欢迎加入 Farm 团队
欢迎加入大家加入 Farm 团队来一起共同建设。欢迎大家 Star🌟
Farm 官网:Farm
Farm 代码仓库:Farm Code
Farm 交流群, 欢迎大家加入我们的交流群共同学习,共同进步
