普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月20日首页

Vite 和 Wepack 中如何处理环境变量

作者 乘方
2026年3月19日 22:15

环境变量文件

.env: 所有环境都会加载 .env.local: 所有环境都会加载,但被 git 忽略 .env.[mode]: 只在指定模式下加载(如 .env.development、.env.production) .env.[mode].local: 只在指定模式下加载,且被 git 忽略

备注:后续加载的文件变量会覆盖前面的

在 Webpack 工程中

步骤

  1. 通过cross env配置脚本指定运行的环境

    cross-env NODE_ENV=development

  2. 在node环境中使用process.env.NODE_ENV来获取环境参数mode.

  3. 使用dotenv读取项目根目录下的对应.env.[mode]文件,解析其中的键值对,并将其挂载到node环境下的process.env对象上,之后就可以通过process.env.VAR_NAME在node中访问它们。

  4. 将读取到的环境变量注入业务代码,作为全局变量

    1. 通过 new Webpack.DefinePlugin 直接定义
    2. 使用 dotenv 加载 .env 文件,再通过 dotenv-webpack 插件注入

在 Vite 工程中

步骤

  1. 默认运行vite是开发环境 --mode development,vite build是运行生产环境 --mode production. 如vite --mode test,指定测试环境,对应.env.test文件

    vite中的mode指的是环境参数,而webpack中的mode指的是打包方式

  2. 环境参数可以从defineConfig回调函数中的config参数获取

  3. 在配置文件中想要获取.env文件中的变量,需要使用vite自带的loadEnv来加载

    import { defineConfig, loadEnv } from "vite";
    
    export default defineConfig(({ command, mode }) => {
      // 加载环境变量
      const env = loadEnv(mode, process.cwd(), "");
      // 现在env中包含所有环境变量,包括没有前缀的
      // 如果需要只获取VITE_前缀的,可以省略第三个参数或指定'VITE_'
      // 如果希望所有变量都可用,第三个参数传''(空字符串)
    
      // 可以在配置中使用env
      return {
        // 比如设置base
        base: env.VITE_BASE_URL || "/",
        // 或者通过define注入更多变量
        define: {
          __APP_VERSION__: JSON.stringify(env.APP_VERSION),
        },
      };
    });
    
  4. 在任何客户端代码(.js、.jsx、.ts、.vue、.svelte 等)中,通过import.meta.env对象访问这些变量,无需手动添加。

    • Vite 默认只暴露VITE_开头的变量,这是一种安全机制。如果你确实需要将其他变量暴露给客户端,可以使用define插件手动注入
    • 还包含一些内置变量也会自动注入到客户端页面:

      MODE:当前运行模式(development / production 等) BASE_URL:应用部署的基础路径(由 base 配置项决定) PROD:是否为生产环境(布尔值) DEV:是否为开发环境(布尔值) SSR:是否为服务端渲染(布尔值)

import.meta[]

是一个给 JavaScript 模块暴露特定上下文元数据的全局对象,包含了当前模块的信息,比如模块的 URL 。它包含哪些具体属性,取决于代码运行的环境(如浏览器、Node.js、Bun 或 Nuxt 框架)。

1. import.meta.url:获取当前模块的URL, 定位模块本身的位置。

// 假设文件地址为:/projects/my-app/src/utils.js
console.log(import.meta.url);
// 浏览器环境输出: http://localhost:3000/src/utils.js
// Node.js 环境输出: file:///projects/my-app/src/utils.js

结合new URL加载资源:这是处理静态资源路径的推荐方式,能保证路径总是正确的

2. import.meta.resolve:解析相对路径,基于当前模块的URL来解析其他模块或文件的路径,特别适合在Node环境中替代__dirname使用。

3. import.meta.hot:实现热模块替换 (HMR),在开发模式下,可以利用它来实现模块热替换,提升开发效率。

// 使用 Pinia 状态管理库时的 HMR 示例
if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // 当模块更新时,执行一些操作,比如重新应用状态
    console.log("模块已热替换", newModule);
  });
}

4. import.meta.glob: 提供一个路径模式,构建工具(Vite)会在编译时静态分析,找到所有匹配的文件,并返回一个方便你操作的对象。

const modules = import.meta.glob('./dir/\*.js', { eager: true }) eager=true,返回模块为懒加载模式

4. import.meta.env: 可以方便地获取进程的环境变量。

备注import.meta.env import.meta.glob import.meta.hot,是客户端专属API,不支持在node环境下访问。

昨天 — 2026年3月19日首页

熬夜通宵读完 VitePlus 全部源码,我后悔没早点看

作者 sunny_
2026年3月19日 20:51

尤雨溪搞了个大的。我花一整夜把它拆了个底朝天,发现这东西远比你想的恐怖。

1. 为什么我要一夜读 VitePlus

3 月 13 日深夜,尤雨溪在 X 上发了一条推文,平静地宣布了一件大事:

image.png

Vite+ 以 MIT 协议全量开源,官网 viteplus.dev 同步上线。

如果说 Vite 8 的发布是"换了个引擎",那 Vite+ 的开源就是直接掀了桌子——它不是 Vite 的升级版,而是一个全新的物种。一个二进制文件,吃掉你整条前端工具链。

官方定位很直白:"The Unified Toolchain for the Web"。一个 vp 命令,把 Vite、Vitest、Oxlint、Oxfmt、Rolldown、tsdown、Vite Task 七个项目合并成了一个 CLI。管构建,管运行时,管包依赖,管代码检查,管格式化,管测试,管打包发布,甚至管 monorepo 的任务编排。以前你需要 npm、pnpm、Vite、ESLint、Prettier、Jest、nvm 各自配置、各自维护,现在一个 vp 全包了。

性能数字更是夸张:生产构建比 webpack 快 40 倍,Oxlint 比 ESLint 快 50 到 100 倍,Oxfmt 比 Prettier 快 30 倍。背后是 VoidZero 的豪华阵容——尤雨溪、Oxc 核心作者 LONG Yinan、Jest 创造者 Christoph Nakazawa。GitHub 仓库 62.9% Rust,33.4% TypeScript。

朋友圈、技术群都在转发。铺天盖地都是功能介绍,但我看了一圈,没有一篇文章认真读过它的源码。

所有人都在说"大一统",但没人说清楚:它到底是怎么做到的?Rust 和 Node.js 是怎么配合的?一个 CLI 怎么可能同时接管 Vite、Vitest、Oxlint 这些完全不同的工具?

我决定自己搞清楚。

当晚,我 clone 了 vite-plus 的仓库,泡了一壶咖啡,准备从源码层面彻底拆解这个"前端工具链终结者"。

git clone https://github.com/voidzero-dev/vite-plus.git

接下来几个小时发生的事,彻底刷新了我对前端工程的认知。


2. 自顶向下总览架构

在翻了 Cargo.tomlpackages/ 目录和 CLAUDE.md 之后,我脑子里逐渐浮现出整个 vite-plus 的架构全貌。

我画了一张文字架构图:

┌─────────────────────────────────────────────────────────────┐
│                      用户命令入口                            │
│                    $ vp dev / build / test / lint           │
└────────────────────────┬────────────────────────────────────┘
                         │
┌────────────────────────▼────────────────────────────────────┐
│               全局 CLI 层(Rust Binary: vp)                 │
│  crates/vite_global_cli  —  Clap 命令解析 + 命令路由         │
│  ├── A 类命令:包管理(install/add/remove)→ Rust 直接处理    │
│  ├── B 类命令:env/create/config → Rust 直接处理             │
│  └── C 类命令:dev/build/test/lint → 委托给 Node 层         │
└────────────────────────┬────────────────────────────────────┘
                         │ JsExecutor(spawn Node.js 进程)
┌────────────────────────▼────────────────────────────────────┐
│               本地 CLI 层(Node: vite-plus/dist/bin.js)     │
│  packages/cli/src/bin.ts  —  命令分发 + 工具解析             │
│  ├── 全局命令(create/migrate/config)→ JS 模块直接处理      │
│  └── 核心命令(dev/build/test/lint/fmt)→ NAPI 桥接到 Rust  │
└────────────────────────┬────────────────────────────────────┘
                         │ NAPI-RS 绑定
┌────────────────────────▼────────────────────────────────────┐
│            Rust 核心执行层(NAPI Binding)                    │
│  packages/cli/binding/src/  —  命令执行 + 任务调度           │
│  ├── cli.rs → vite_task Session API                         │
│  ├── exec/ → 工作区解析 + 参数处理                          │
│  └── 调用 JS 回调解析工具路径 → 启动子进程执行              │
└────────────────────────┬────────────────────────────────────┘
                         │
┌────────────────────────▼────────────────────────────────────┐
│                底层工具执行层                                 │
│  Vite (dev/build) │ Vitest (test) │ Oxlint (lint)           │
│  Oxfmt (fmt)      │ tsdown (pack) │ Rolldown (bundle)       │
└─────────────────────────────────────────────────────────────┘

我一开始以为 vite-plus 就是一个 CLI 壳子,封装了几个命令而已。但看到这个分层之后,我意识到它的架构远比我想象的复杂——它是一个双层混合架构:Rust 做入口和性能敏感的操作,Node.js 做生态桥接和配置解析,两者通过 NAPI-RS 和进程派生双通道通信。

这个设计其实很关键。让我一层一层拆。


3. CLI 入口拆解:Rust 是真正的门面

打开 crates/vite_global_cli/src/main.rs,这是用户输入 vp 时真正被执行的二进制文件的入口。

// crates/vite_global_cli/src/main.rs(关键逻辑,有删减)

#[tokio::main]
async fn main() -> ExitCode {
    vite_shared::init_tracing();

    let mut args: Vec<String> = std::env::args().collect();
    let argv0 = args.first().map(|s| s.as_str()).unwrap_or("vp");

    // 第一步:检测是否处于 shim 模式(被当作 node/npm/npx 调用)
    if let Some(tool) = shim::detect_shim_tool(argv0) {
        let exit_code = shim::dispatch(&tool, &args[1..]).await;
        return ExitCode::from(exit_code as u8);
    }

    // 第二步:如果没有子命令,弹出交互式选择器
    if args.len() == 1 {
        match command_picker::pick_top_level_command_if_interactive(&cwd) {
            Ok(TopLevelCommandPick::Selected(selection)) => {
                args.push(selection.command.to_string());
            }
            Ok(TopLevelCommandPick::Cancelled) => return ExitCode::SUCCESS,
            // ...
        }
    }

    // 第三步:标准化参数,然后解析并执行命令
    let normalized_args = normalize_args(args);
    match try_parse_args_from(normalized_args) {
        Ok(args) => match run_command(cwd, args).await { /* ... */ },
        Err(e) => { /* 错误处理 + 智能纠错 */ }
    }
}

设计解读:

这里有三个我觉得非常精巧的设计点。

第一,Shim 模式检测。 vp 不仅仅是 vp。当你运行 vp env on 后,系统的 nodenpmnpx 命令实际上会被重定向到 vp 这个二进制文件。vp 通过检查 argv[0](即进程被以什么名字调用)来判断自己是被当作 vp 还是 node 调用的。如果发现自己被当作 node 调用,就自动路由到 shim 逻辑,透明地使用它管理的 Node.js 版本来执行。这就是 vp env 能替代 nvm 的核心原理。

第二,交互式命令选择器。 当用户直接输入 vp 不带任何参数时,不是打印一堆 help 文字,而是弹出一个可交互的终端选择器(用 crossterm 实现),让用户用方向键选择想执行的命令。这个交互体验很 modern。

第三,智能命令纠错。 如果你输入了一个不存在的子命令,比如 vp fnt(想输 fmt),CLI 会用字符串相似度算法给出建议,并询问你是否要执行建议的命令。这种细节体验在纯 shell 脚本的 CLI 里是做不到的。

再看命令定义,在 cli.rs 里,命令被分成了三个清晰的类别:

// crates/vite_global_cli/src/cli.rs(有删减)

/// Available commands
#[derive(Subcommand, Debug)]
pub enum Commands {
    // =============================================
    // Category A: 包管理命令 — Rust 直接处理
    // =============================================
    Install { /* 大量参数... */ },
    Add { /* ... */ },
    Remove { /* ... */ },
    Update { /* ... */ },
    Dedupe { /* ... */ },
    Dlx { /* ... */ },
    // ...

    // =============================================
    // Category B: 全局/环境命令 — Rust 直接处理
    // =============================================
    Env { /* ... */ },
    Create { /* ... */ },
    Config { /* ... */ },
    // ...

    // =============================================
    // Category C: 开发命令 — 委托给 vite-plus Node 包
    // =============================================
    Dev { args: Vec<String> },
    Build { args: Vec<String> },
    Test { args: Vec<String> },
    Lint { args: Vec<String> },
    Fmt { args: Vec<String> },
    Check { args: Vec<String> },
    // ...
}

设计解读:

这个分类非常重要。A 类和 B 类命令,比如 installaddenv,整个流程都在 Rust 里完成,不需要启动 Node.js 进程。这意味着这些命令的启动速度极快——因为跳过了 Node.js 的冷启动开销。

而 C 类命令,比如 devbuildtestlint,则需要委托给 Node 层。原因很简单:这些命令本质上要运行的是 Vite、Vitest、Oxlint 这些 Node.js 生态的工具,它们的插件系统和配置加载都依赖 Node.js 运行时。

结论:CLI 不仅仅是入口,它是一个智能的工程调度中心。它根据命令类型决定走 Rust 快车道还是 Node.js 桥接通道,把"启动速度"和"生态兼容性"两个看似矛盾的目标统一了起来。


4. 配置系统:一个 defineConfig 统治所有

翻开 packages/cli/src/index.ts,这是 vite-plus 的 npm 包入口:

// packages/cli/src/index.ts

declare module '@voidzero-dev/vite-plus-core' {
  interface UserConfig {
    lint?: OxlintConfig;
    fmt?: FormatOptions;
    pack?: PackUserConfig | PackUserConfig[];
    run?: RunConfig;
    staged?: StagedConfig;
    lazy?: () => Promise<{ plugins?: VitestPlugin[] }>;
  }
}

export * from '@voidzero-dev/vite-plus-core';
export * from '@voidzero-dev/vite-plus-test/config';
export { defineConfig };

设计解读:

这里用了 TypeScript 的 declare module + interface 合并(declaration merging),在 Vite 原有的 UserConfig 上扩展了 lintfmtpackrunstaged 等字段。这意味着用户在 vite.config.ts 里通过 defineConfig 定义的配置,不仅包含 Vite 原有的配置(serverbuildplugins 等),还一并包含了 lint、格式化、测试、任务编排、库打包的配置。

一个文件管所有,不是口号,是真的在类型层面就统一了。

再看 defineConfig 的实现:

// packages/cli/src/define-config.ts(关键逻辑)

export function defineConfig(config: ViteUserConfigExport): ViteUserConfigExport {
  if (typeof config === 'object') {
    if (config instanceof Promise) {
      return config.then((config) => {
        if (config.lazy) {
          return config.lazy().then(({ plugins }) =>
            viteDefineConfig({
              ...config,
              plugins: [...(config.plugins || []), ...(plugins || [])],
            }),
          );
        }
        return viteDefineConfig(config);
      });
    } else if (config.lazy) {
      return config.lazy().then(({ plugins }) =>
        viteDefineConfig({
          ...config,
          plugins: [...(config.plugins || []), ...(plugins || [])],
        }),
      );
    }
  } else if (typeof config === 'function') {
    return viteDefineConfig((env) => {
      const c = config(env);
      // 处理异步 + lazy 加载...
    });
  }
  return viteDefineConfig(config);
}

设计解读:

这里有一个 lazy 字段的处理逻辑特别值得注意。它允许插件被懒加载——在配置解析阶段不立即加载插件模块,而是延迟到实际需要时才加载。这对大型项目的启动速度有直接帮助。代码注释里也写了:"temporary solution to load plugins lazily, we need to support this in the upstream vite"。说明这个特性后续会推到 Vite 上游。

而更让我惊讶的是 Rust 侧对配置的处理。打开 crates/vite_static_config/src/lib.rs,这个 crate 做了一件非常聪明的事情:

// crates/vite_static_config/src/lib.rs(关键逻辑,有删减)

/// 静态解析 vite.config.* 文件,不需要执行 JavaScript。
/// 使用 oxc_parser 解析 AST,提取纯 JSON 字面量字段。

pub fn resolve_static_config(dir: &AbsolutePath) -> FieldMap {
    let Some(config_path) = resolve_config_path(dir) else {
        return FieldMap::no_config();
    };
    let Ok(source) = std::fs::read_to_string(&config_path) else {
        return FieldMap::unanalyzable();
    };
    parse_js_ts_config(&source, extension)
}

fn parse_js_ts_config(source: &str, extension: &str) -> FieldMap {
    let allocator = Allocator::default();
    let source_type = match extension {
        "ts" | "mts" | "cts" => SourceType::ts(),
        _ => SourceType::mjs(),
    };
    let parser = Parser::new(&allocator, source, source_type);
    let result = parser.parse();
    extract_config_fields(&result.program)
}

/// 搜索模式(按优先级):
/// 1. export default defineConfig({ ... })
/// 2. export default { ... }
/// 3. module.exports = defineConfig({ ... })
/// 4. module.exports = { ... }
fn extract_config_fields(program: &Program<'_>) -> FieldMap {
    for stmt in &program.body {
        if let Statement::ExportDefaultDeclaration(decl) = stmt {
            if let Some(expr) = decl.declaration.as_expression() {
                return extract_config_from_expr(expr);
            }
        }
        // CJS: module.exports = ...
        if let Statement::ExpressionStatement(expr_stmt) = stmt
            && let Expression::AssignmentExpression(assign) = &expr_stmt.expression
            && assign.left.as_member_expression().is_some_and(|m| {
                m.object().is_specific_id("module")
                    && m.static_property_name() == Some("exports")
            })
        {
            return extract_config_from_expr(&assign.right);
        }
    }
    FieldMap::unanalyzable()
}

这段代码让我直接愣住了。

它用 Oxc 的 Rust 解析器在不启动 Node.js 的情况下,直接从 vite.config.ts 的源码 AST 中提取配置字段。如果某个字段的值是纯 JSON 字面量(字符串、数字、布尔、数组、对象),就直接提取出来用;如果包含函数调用、变量引用等动态内容,就标记为 NonStatic,后续再通过 Node.js 侧的完整配置解析来获取。

为什么要这么做? 因为像 vp run 这样的命令需要读取 vite.config.ts 中的 run 字段来构建任务图,但如果每次都要启动 Node.js 来解析配置,就会有几百毫秒的冷启动开销。通过 Rust 侧的静态分析,对于大多数场景(run 字段通常是纯 JSON),可以跳过 Node.js 直接读取。

结论:配置系统是双层的——Rust 侧做静态快速提取(零 Node.js 开销),Node 侧做完整解析(支持动态配置)。两层配合,既保证了速度,又保证了灵活性。这是一个典型的工程抽象:在性能和表达力之间找到了最优平衡点。


5. 插件系统与调度机制:控制,而不是使用

翻到 packages/cli/src/bin.ts,这是 Node 侧的命令入口。当 C 类命令(devbuild 等)被委托到 Node 层后,所有命令的执行都汇聚到这个文件:

// packages/cli/src/bin.ts(关键逻辑,有删减)

import { run } from '../binding/index.js';
import { lint } from './resolve-lint.js';
import { pack } from './resolve-pack.js';
import { test } from './resolve-test.js';
import { vite } from './resolve-vite.js';
import { fmt } from './resolve-fmt.js';
import { doc } from './resolve-doc.js';
import { resolveUniversalViteConfig } from './resolve-vite-config.js';

const command = args[0];

// 全局命令直接由 JS 处理
if (command === 'create') {
  await import('./global/create.js');
} else if (command === 'migrate') {
  await import('./global/migrate.js');
} else {
  // 核心命令 —— 委托给 Rust 核心
  const exitCode = await run({
    lint,    // JS 函数:解析 oxlint 的二进制路径
    pack,    // JS 函数:解析 tsdown 的二进制路径
    fmt,     // JS 函数:解析 oxfmt 的二进制路径
    vite,    // JS 函数:解析 vite 的二进制路径
    test,    // JS 函数:解析 vitest 的二进制路径
    doc,     // JS 函数:解析 vitepress 的二进制路径
    resolveUniversalViteConfig,
    args: process.argv.slice(2),
  });
  process.exit(exitCode);
}

这里的 run 不是一个普通函数——它是 NAPI-RS 绑定的 Rust 函数。

来看 Rust 侧怎么接收这些 JS 回调的:

// packages/cli/binding/src/lib.rs(关键逻辑,有删减)

#[napi(object, object_to_js = false)]
pub struct CliOptions {
    pub lint: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,
    pub fmt: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,
    pub vite: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,
    pub test: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,
    pub pack: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,
    pub doc: Arc<ThreadsafeFunction<(), Promise<JsCommandResolvedResult>>>,
    pub resolve_universal_vite_config: Arc<ThreadsafeFunction<String, Promise<String>>>,
}

#[napi]
pub async fn run(options: CliOptions) -> Result<i32> {
    let cwd = current_dir()?;
    let (tx, rx) = tokio::sync::oneshot::channel();

    // 在新线程中运行,避免阻塞 Node.js 事件循环
    std::thread::spawn(move || {
        let cli_options = ViteTaskCliOptions {
            lint: create_resolver(lint_tsf, "Failed to resolve lint command"),
            fmt: create_resolver(fmt_tsf, "Failed to resolve fmt command"),
            vite: create_resolver(vite_tsf, "Failed to resolve vite command"),
            test: create_resolver(test_tsf, "Failed to resolve test command"),
            pack: create_resolver(pack_tsf, "Failed to resolve pack command"),
            // ...
        };

        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all().build().unwrap();
        let local = tokio::task::LocalSet::new();
        let result = local.block_on(&rt, async {
            crate::cli::main(cwd, Some(cli_options), args).await
        });
        let _ = tx.send(result);
    });

    let result = rx.await?;
    // ...
}

设计解读:

这段代码揭示了 vite-plus 最精妙的架构设计之一——反向回调模式

传统的 Node.js 工具链是这样工作的:Node.js 是主控方,它调用各种工具。但在 vite-plus 里,Rust 是主控方。JS 侧传给 Rust 的不是数据,而是一组 resolver 函数——这些函数只负责一件事:告诉 Rust "这个工具的二进制路径在哪里"。

来看一个具体的 resolver 实现:

// packages/cli/src/resolve-lint.ts

export async function lint(): Promise<{
  binPath: string;
  envs: Record<string, string>;
}> {
  const oxlintMainPath = resolve('oxlint');
  const oxlintPackageRoot = dirname(dirname(oxlintMainPath));
  const binPath = join(oxlintPackageRoot, 'bin', 'oxlint');
  return {
    binPath,
    envs: {
      ...DEFAULT_ENVS,
      OXLINT_TSGOLINT_PATH: oxlintTsgolintPath,
    },
  };
}

JS 侧只做了"路径解析"——用 Node.js 的模块解析机制(require.resolve)来找到 oxlintvitestvite 等工具的真实路径。然后把路径和环境变量返回给 Rust 侧。

Rust 侧拿到路径后,才是真正的执行引擎。 它通过 vite_task crate 的 Session API 来:

  • 构建任务依赖图(Task Graph)
  • 按拓扑排序调度执行
  • 管理缓存和增量执行
  • fspy 追踪文件访问(用于智能缓存)

结论:vite-plus 不是在"使用"这些工具,它在"控制"这些工具。JS 侧只是一个"路径探测器",Rust 侧才是"调度中心"。这种反向控制的设计,让 Rust 能掌控整个执行流程的生命周期,包括并发调度、缓存决策、进程管理等——这些在纯 JS 实现里要么做不到,要么性能很差。


6. Rust 模块深度分析:不只是"写了个壳"

看完了架构全貌,让我钻进 Rust 代码的细节。先看 Cargo Workspace 的结构:

# Cargo.toml(根工作区)
[workspace]
resolver = "3"
members = ["bench", "crates/*", "packages/cli/binding"]

本地 crates 列表:

Crate 职责
vite_global_cli 全局 CLI 二进制(vp 命令)
vite_command 进程执行抽象 + fspy 文件追踪
vite_error 统一错误类型
vite_install 包管理逻辑(install/add/remove/update/dedupe)
vite_js_runtime Node.js 版本管理(下载/缓存/切换)
vite_migration 项目迁移逻辑
vite_shared 共享工具(输出格式、环境变量、tracing)
vite_static_config 静态配置解析(用 Oxc 解析 AST)
vite_trampoline Shim 二进制(用于 node/npm 命令代理)

外部 Git 依赖(来自 vite-task 仓库):

Crate 职责
vite_task 任务调度核心(Session API、任务图、调度器)
vite_workspace Monorepo 工作区解析
vite_path 类型安全的路径系统(AbsolutePath/RelativePath
vite_glob 文件 glob 匹配
fspy 文件系统访问追踪

还有 rolldownoxc 系列的几十个 crates 被作为依赖引入,用于构建和代码分析。

这个 crate 拓扑结构说明了什么?

Rust 在 vite-plus 中不是做某一个单一功能,而是覆盖了四大类职责:

6.1 命令解析与路由

vite_global_cli 用 Clap 框架实现了完整的 CLI 解析。所有命令、参数、别名、互斥选项都在 Rust 侧定义。这意味着 vp --help 的速度是原生的——不需要启动 Node.js。

6.2 包管理

vite_install 实现了跨包管理器的统一抽象。我看了它的依赖:它引入了 vite_workspace(工作区解析)、vite_command(进程执行)、vite_glob(glob 匹配)。它能识别当前项目用的是 npm、pnpm 还是 yarn,然后生成对应的命令来执行。

6.3 Node.js 版本管理

vite_js_runtime 是一个完整的 Node.js 版本管理器。它能:

  • 从官方源下载指定版本的 Node.js(支持 macOS/Linux/Windows)
  • 管理本地版本缓存
  • 根据项目配置(.node-versionengines.nodedevEngines.runtime)自动选择版本
  • 通过 shim 机制透明代理 node 命令

来看 JsExecutor 的版本解析逻辑:

// crates/vite_global_cli/src/js_executor.rs(关键逻辑,有删减)

pub struct JsExecutor {
    /// CLI 命令使用的运行时(A/B 类命令)
    cli_runtime: Option<JsRuntime>,
    /// 项目委托使用的运行时(C 类命令)
    project_runtime: Option<JsRuntime>,
    /// JS 脚本目录
    scripts_dir: Option<AbsolutePathBuf>,
}

/// 确保项目运行时已下载并缓存。
/// 解析顺序:
/// 1. 会话覆盖(vp env use 设置的环境变量)
/// 2. 会话覆盖(vp env use 写入的文件)
/// 3. 项目源(.node-version / engines.node / devEngines.runtime)
/// 4. 用户默认版本(config.json)
/// 5. 最新 LTS
pub async fn ensure_project_runtime(
    &mut self,
    project_path: &AbsolutePath,
) -> Result<&JsRuntime, Error> {
    // ...
}

设计解读:

注意这里有两个独立的运行时:cli_runtimeproject_runtime。CLI 自身的运行时版本是固定的(由 vite-plus 包的 devEngines.runtime 决定),而项目的运行时版本是动态的(由项目配置决定)。这种分离确保了 CLI 本身的稳定性不受项目配置影响。

6.4 静态配置解析

前面已经详细分析了 vite_static_config,它用 Oxc 解析器在 Rust 侧直接读取 vite.config.ts。这里补充一个设计细节:

// crates/vite_static_config/src/lib.rs

enum FieldMapInner {
    /// 对象没有展开运算符 → 闭合映射,缺失的键确定不存在
    Closed(FxHashMap<Box<str>, FieldValue>),
    /// 对象有展开运算符 → 开放映射,缺失的键可能存在于展开中
    Open(FxHashMap<Box<str>, serde_json::Value>),
}

它区分了"闭合映射"和"开放映射"两种状态。如果配置对象中没有 ...spread 语法,那么没出现在映射中的键就是确定不存在的;如果有 spread,那缺失的键可能在 spread 的源对象中,需要回退到 Node.js 侧解析。

这种精确的语义建模,不是"大概能用"的工程,是严谨的编译器级别的思维。

6.5 Rust 与 Node 的通信方式

通过源码分析,我确认了 Rust 和 Node 之间存在两种通信方式:

方式一:NAPI-RS(进程内调用)

packages/cli/binding/ 是一个 NAPI-RS 原生模块。它被编译为 .node 文件,由 Node.js 直接加载。JS 和 Rust 在同一个进程内通信,通过 ThreadsafeFunction 实现跨线程回调。

方式二:进程派生(跨进程调用)

全局 CLI(vp 二进制)通过 JsExecutor 派生 Node.js 子进程来运行 JS 脚本。Rust 管理 Node.js 的下载、版本选择和进程启动。

// crates/vite_global_cli/src/js_executor.rs

async fn run_js_entry(&self, project_path: &AbsolutePath,
    node_binary: &AbsolutePath, bin_prefix: &AbsolutePath,
    args: &[String]) -> Result<ExitStatus, Error>
{
    let entry_point = match Self::resolve_local_vite_plus(project_path) {
        Some(path) => path,        // 优先使用项目本地安装的 vite-plus
        None => {
            let scripts_dir = self.get_scripts_dir()?;
            scripts_dir.join("bin.js")  // 回退到全局安装
        }
    };
    let mut cmd = Self::create_js_command(node_binary, bin_prefix);
    cmd.arg(entry_point.as_path()).args(args)
       .current_dir(project_path.as_path());
    let status = cmd.status().await?;
    Ok(status)
}

这里还有一个细节让我印象深刻: 它用 oxc_resolver(Oxc 的模块解析器,Rust 实现)在 Rust 侧直接解析 vite-plus/package.json 的路径,来找到项目本地安装的 vite-plus。不需要启动 Node.js 就能完成模块解析。

结论:Rust 在 vite-plus 中的定位不是"性能加速层"这么简单。它是整个系统的控制平面(Control Plane),负责命令路由、版本管理、包管理、配置预读、任务调度、进程编排。Node.js 则是数据平面(Data Plane),负责具体工具的运行和生态桥接。这种"控制平面/数据平面"的分离,是企业级基础设施的典型设计模式。


7. 多工具整合机制:它不是在调用,是在接管

弄清楚了架构之后,我开始关注一个核心问题:vite-plus 是如何把 Vite、Vitest、Oxlint、Oxfmt、Rolldown、tsdown 这些工具整合到一起的?

7.1 工具路径解析:统一的 resolver 模式

每个工具都有一个对应的 resolver 文件:

packages/cli/src/
├── resolve-vite.ts      → Vite (dev/build/preview)
├── resolve-test.ts      → Vitest (test)
├── resolve-lint.ts      → Oxlint (lint/check)
├── resolve-fmt.ts       → Oxfmt (fmt/check)
├── resolve-pack.ts      → tsdown (pack)
├── resolve-doc.ts       → VitePress (doc)

每个 resolver 的接口完全一致:

interface ResolvedTool {
  binPath: string;                    // 工具二进制路径
  envs: Record<string, string>;       // 运行时环境变量
}

这个统一接口让 Rust 侧可以用完全相同的方式处理所有工具——解析路径、设置环境变量、启动子进程。

7.2 配置统一:从 vite.config.ts 到各工具

当用户在 vite.config.ts 里写:

import { defineConfig } from 'vite-plus'

export default defineConfig({
  server: { port: 3000 },           // → Vite
  lint: { options: { typeAware: true } },  // → Oxlint
  fmt: { /* ... */ },                // → Oxfmt
  test: { /* ... */ },               // → Vitest
  run: { tasks: { /* ... */ } },     // → vite_task
  staged: { '*.ts': 'vp check --fix' }, // → lint-staged 替代
  pack: { entry: ['src/index.ts'] }, // → tsdown
})

这个配置文件会被两条路径消费:

  1. Rust 侧的静态解析vite_static_config):提取 runlintfmt 等纯 JSON 字段
  2. Node 侧的完整解析resolve-vite-config.ts):通过 Vite 的 resolveConfig API 加载完整配置
// packages/cli/src/resolve-vite-config.ts

export async function resolveUniversalViteConfig(err: null | Error, viteConfigCwd: string) {
  const config = await resolveViteConfig(viteConfigCwd);
  return JSON.stringify({
    configFile: config.configFile,
    lint: config.lint,
    fmt: config.fmt,
    run: config.run,
    staged: config.staged,
  });
}

这个函数被 NAPI 侧的 resolve_universal_vite_config 回调所引用。当 Rust 侧的静态解析无法满足需求时(比如配置包含动态值),就会调用这个 JS 回调来获取完整配置。

7.3 "接管"而非"调用"

传统的前端工具链是这样的:你分别安装 Vite、ESLint、Prettier、Vitest,然后分别配置它们。每个工具是独立的——它们各自有入口、各自解析配置、各自输出结果。

vite-plus 的做法完全不同:

  1. 统一入口:所有命令都从 vp 进入,用户不直接调用 eslintprettiervitest
  2. 统一配置:所有工具的配置都在 vite.config.ts 中声明
  3. 统一调度:Rust 核心负责解析命令、加载配置、启动工具进程
  4. 统一输出:所有 CLI 输出都经过 vite_shared::output 格式化(Rust 侧)或 utils/terminal.ts 格式化(JS 侧)

看 CLAUDE.md 里的这段话:

## CLI Output
All user-facing output must go through shared output modules instead of raw print calls.
- Rust: Use `vite_shared::output` functions (info, warn, error, note, success)
- TypeScript: Use `packages/cli/src/utils/terminal.ts` functions

连输出格式都统一了。这不是"把几个工具串起来",这是在"接管整个开发体验"。

结论:vite-plus 做的不是工具的简单组合,而是工具的完全收编。它用统一的 resolver 模式抽象了工具路径发现,用 declaration merging 统一了配置类型,用 NAPI 双向回调统一了执行流程。它在做的事情是——"工程能力统一入口"。


8. 我的顿悟:它不是工具,而是体系

读到这里,大概凌晨三点。

我一开始是把 vite-plus 当作一个 CLI 工具来看的——就像 npm、turborepo、或者 nx 那样。但读完源码后我意识到,它的定位远不止如此。

让我梳理一下认知的升级路径:

阶段一:它是一个 CLI 工具。vp devvp buildvp test 这些命令统一了。

阶段二:它是一个工程平台。 它不仅统一了命令,还统一了配置(一个 vite.config.ts)、统一了包管理(自动检测 npm/pnpm/yarn)、统一了版本管理(内建 Node.js 版本管理)。

阶段三:它是一个工程体系。 从 Rust 到 Node.js 的双层架构、从静态解析到动态解析的双轨配置系统、从 Clap 到 NAPI 到子进程的多级调度、从 fspy 文件追踪到任务图缓存的智能构建系统——这些不是一个工具能做的事。

这是一个完整的前端工程体系。

它背后的方法论可以概括为三条:

  1. 性能敏感的部分用 Rust,生态敏感的部分用 Node.js。 不是全部重写,而是在正确的层放正确的语言。
  2. 控制平面和数据平面分离。 Rust 负责"做什么"(命令路由、任务调度、配置预读),Node.js 负责"怎么做"(工具执行、插件加载)。
  3. 统一抽象而非统一实现。 vite-plus 没有重新实现 Vite 或 Vitest,而是通过 resolver + NAPI + 配置合并的方式,把现有工具收编到统一框架下。

这第三点尤其重要。它意味着 vite-plus 不会和现有生态对抗——所有 Vite 插件、Vitest 扩展、Oxlint 规则都能继续使用。它做的是在上层加了一个编排层。


9. 优势与代价:必须客观

说了这么多优点,但作为一个认真读过源码的人,我也看到了一些需要正视的问题。

优势

工程一致性。 一个团队里,不管谁来建项目,用 vp create 出来的结构都是一样的。lint 规则一样、格式化风格一样、测试框架一样、构建配置一样。这对大团队的效率提升是巨大的。

可复用性。 一个 vite.config.ts 就是整个项目的工程规范。你甚至可以把它抽成一个 shared preset,在多个项目间复用。不再需要同步 .eslintrc + .prettierrc + vitest.config.ts 的组合。

启动速度。 Rust 二进制启动是毫秒级的。vp --help 不需要启动 Node.js,vp env current 不需要启动 Node.js,vp run(读取静态配置时)不需要启动 Node.js。这种"零冷启动"体验在 CI 环境里尤其重要。

智能缓存。 vite_task 通过 fspy 追踪每个任务的文件访问,实现精确的缓存失效。这不是简单的"输入文件 hash",而是在系统调用层面追踪了每个 readwrite 操作。

代价

灵活性下降。 当你需要对某个工具做非常规的定制时,vite-plus 的抽象层可能会挡在中间。比如你想用 oxlint 的某个实验性 flag,需要确认 vite-plus 是否透传了这个 flag。

学习成本。 虽然 vite-plus 简化了日常使用,但当出了问题需要 debug 时,你面对的是一个 Rust + Node.js + NAPI 的混合架构。排查问题的路径比纯 Node.js 工具链要长。

版本耦合。 Vite、Vitest、Oxlint 的版本由 vite-plus 统一管理。如果你需要某个工具的特定版本(比如 Vitest 的 nightly),可能需要等 vite-plus 更新。

Alpha 阶段风险。 目前是 v0.1.x,API 可能随时变化。vp migrate 之后大多数项目还需要手动调整。在生产环境使用需要谨慎评估。


10. 总结:前端工程正在发生什么变化

读完整个源码库,我对前端工程的趋势有了更清晰的认知。

Node + Rust 混合架构正在成为主流

vite-plus 不是第一个走这条路的项目。Turbopack(Rust)、SWC(Rust)、Biome(Rust)、Bun(Zig)……用系统级语言重写前端工具链的性能关键路径,已经是不可逆的趋势。

但 vite-plus 的做法更加务实。它没有选择用 Rust 重写一切(像 Bun 那样),而是在 Rust 和 Node.js 之间找到了一条清晰的分界线:Rust 做基础设施(CLI、进程管理、版本管理、配置预读、任务调度),Node.js 做生态桥接(插件系统、工具执行、配置解析)。这种"各取所长"的混合架构,可能是当前最现实的路线。

前端工程正在体系化

过去十年,前端工程经历了从"手动配置"到"脚手架生成"到"框架约定"的演进。vite-plus 代表了下一步——"工具链统一"。它把开发服务器、构建、测试、Lint、格式化、包管理、版本管理、任务编排这些散落的能力,收拢到一个统一的体系里。

这和后端世界的 cargo(Rust)、go(Go)的设计理念是一致的——一个工具管一切。前端终于也开始走这条路了。

VitePlus 的行业意义

站在 Vite 78.7K Star 和每周 6900 万次 npm 下载的用户基数上,vite-plus 的迁移成本几乎是所有同类方案中最低的。它不需要你切换框架、不需要你重写配置、不需要你学习全新的 API——你的 Vite 插件还能用,你的 vite.config.ts 只需要改一下 import 路径。

从这个角度看,vite-plus 不只是一个工具的升级,它可能是整个前端工程体系演进的一个拐点。


凌晨五点,咖啡见底。合上 IDE,我觉得这一夜没白熬。

如果你也对前端工程体系化感兴趣,建议 clone 一份 vite-plus 的源码自己翻翻。从 crates/vite_global_cli/src/main.rs 开始,顺着调用链走一遍——你会对"现代前端工具链应该长什么样"有全新的理解。

git clone https://github.com/voidzero-dev/vite-plus.git
cd vite-plus
# 先看全局 CLI 入口
cat crates/vite_global_cli/src/main.rs
# 再看 NAPI 绑定层
cat packages/cli/binding/src/lib.rs
# 最后看 Node 侧入口
cat packages/cli/src/bin.ts

三个文件,就能看懂整条链路。


参考资料:

踩坑记录:Mac M系列芯片下 pnpm dlx 触发的 esbuild 架构不匹配错误

作者 eason_fan
2026年3月19日 19:32

踩坑记录:Mac M系列芯片下 pnpm dlx 触发的 esbuild 架构不匹配错误

背景

在日常开发中,克隆一个前端项目后,我们习惯性地执行 pnpm installpnpm dev。但最近在搭载 Apple Silicon (M系列芯片) 的 Mac 上,项目启动时却抛出了一个极其刺眼的致命错误,甚至重新 git clone 项目也无法解决

错误现象

执行命令后,终端抛出如下错误堆栈,直接导致进程退出 (exit code 1):

node:internal/modules/run_main:123
    triggerUncaughtException(
    ^
Error:
You installed esbuild for another platform than the one you're currently using.
This won't work because esbuild is written with native code and needs to
install a platform-specific binary executable.
Specifically the "@esbuild/darwin-x64" package is present but this platform
needs the "@esbuild/darwin-arm64" package instead. People often get into this
situation by installing esbuild with npm running inside of Rosetta 2 and then
trying to use it with node running outside of Rosetta 2, or vice versa (Rosetta
2 is Apple's on-the-fly x86_64-to-arm64 translation service).
...
    at generateBinPath (/Users/xxx/Library/Caches/pnpm/dlx/fa19b49eb7fa...)
    at esbuildCommandAndArgs (/Users/xxx/Library/Caches/pnpm/dlx/fa19b...)
...

问题分析

错误信息其实已经说得很清楚了:架构不匹配 (Architecture Mismatch)

esbuild 是一个使用 Go 语言编写的高性能构建工具,它在安装时会根据当前的操作系统和 CPU 架构下载对应的底层二进制文件。

在我们的场景中:

  • 期望环境:Mac M系列芯片,原生架构是 arm64 (darwin-arm64)。
  • 实际加载的包:系统却发现本地存在的是为 Intel 芯片编译的包 darwin-x64

为什么重新 clone 项目也没用?

这就是这个 Bug 最搞人心态的地方。如果你仔细观察报错堆栈,会发现错误并不是从项目本地的 node_modules 抛出的,而是来自: /Users/xxx/Library/Caches/pnpm/dlx/...

这说明问题出在执行 pnpm dlx 命令时。pnpm dlx 类似于 npx,用于临时下载并执行一个包。pnpm 把之前(可能是在旧的 Intel Mac 上,或者是误用 Rosetta 终端时)下载的 darwin-x64 版本的包缓存在了全局的 dlx 目录中

当你再次运行项目时,哪怕项目是全新 clone 的,pnpm dlx 依然会去读取这个全局的、架构错误的缓存,从而导致崩溃。

解决方案

明确了是全局缓存作祟,解决起来就非常简单粗暴了:进行深度清理。

第一步:核实 Node.js 架构

首先,必须确保你当前运行的 Node.js 本身是原生的 arm64 版本,而不是通过 Rosetta 2 翻译运行的 Intel 版本。

在终端输入:

node -p "process.arch"
  • 如果输出是 arm64,说明环境正确,请进行下一步。
  • 如果输出是 x64,说明你的 Node.js 版本不对。你需要卸载当前的 Node.js,并重新安装原生版本(例如使用 nvm install <version>)。同时检查你的终端软件(Terminal/iTerm2)是否在“显示简介”中勾选了“使用 Rosetta 打开”,如果有,请取消勾选。

第二步:彻底清空 pnpm 的全局 DLX 缓存

既然缓存污染了,我们就手动将其根除。在终端执行以下命令:

# 强制删除 pnpm 的全局 dlx 缓存目录(将 /Users/xxx 替换为你报错信息中的实际路径,通常是 ~/.local/share/pnpm 或 ~/Library/Caches/pnpm)
rm -rf ~/Library/Caches/pnpm/dlx

# 清理 pnpm 的全局 store 缓存
pnpm store prune

第三步:重新安装依赖

回到你的项目根目录,为了保险起见,清空本地的 node_modules,然后重新安装:

# 删除本地 node_modules
rm -rf node_modules

# 重新安装依赖,此时 pnpm dlx 会重新拉取正确的 arm64 版本
pnpm install

# 启动项目
pnpm dev

总结

当我们在 Mac M 系列芯片上遇到类似 @esbuild/darwin-x64@esbuild/darwin-arm64 的冲突,且重装项目无效时,一定要优先排查全局缓存(如 pnpm dlx 缓存目录)以及 Node.js 自身的运行架构。暴力清理特定的全局缓存目录,往往是解决此类“幽灵报错”的最快途径。

昨天以前首页

我花了三天用AI写了个上一代前端构建工具

作者 达拉
2026年3月16日 18:15

前端工程化实践:从复制粘贴到一键生成,xcli 解决了什么问题,又是如何设计的。

以前:2小时的痛苦

# 第1步:创建目录
mkdir my-project && cd my-project

# 第2步:初始化 package.json
npm init -y

# 第3步:安装 TypeScript
npm install -D typescript
npx tsc --init
# 然后手动改 tsconfig.json ...

# 第4步:安装 ESLint
npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin
# 然后创建 .eslintrc.json,配置规则 ...

# 第5步:安装 Prettier
npm install -D prettier eslint-config-prettier
# 然后创建 .prettierrc ...

# 第6步:安装 Vite
npm install -D vite @vitejs/plugin-react
# 然后创建 vite.config.ts ...

# 第7步:配置 browserslist
# 创建 .browserslistrc,写兼容配置 ...

# 第8步:配置 PostCSS
npm install -D postcss autoprefixer
# 创建 postcss.config.js ...

# ... 此处省略 20 步

# 第 N 步:终于跑起来了
npm run dev

耗时:约 2 小时

现在:3分钟的优雅

npx @jserxiao/xcli init my-project -t react -d
cd my-project
pnpm dev

耗时:约 3 分钟


xcli 是什么?

xcli 是一个可插拔的 TypeScript 项目脚手架 CLI 工具。

它的核心理念:配置标准化、可复用、开箱即用

# 全局安装
npm install -g @jserxiao/xcli

# 或者直接用 npx
npx @jserxiao/xcli init my-project

核心功能详解

1. 三种项目模板

根据实际业务场景,我设计了三种模板:

📦 Library 模板

适合开发 npm 包、工具函数库。

my-lib/
├── src/
│   └── index.ts          # 入口文件
├── dist/                 # 编译输出
├── package.json
├── tsconfig.json
└── README.md

特性

  • TypeScript 5 严格模式
  • 同时输出 ESM + CJS 格式
  • 自动生成类型声明

⚛️ React 模板

基于 pnpm monorepo 的企业级前端项目。

my-app/
├── src/                    # 主应用源码
│   ├── main.tsx
│   ├── App.tsx
│   ├── pages/              # 页面
│   ├── components/         # 组件
│   ├── router/             # 路由
│   ├── api/                # HTTP 请求
│   │   └── request.ts      # Axios/Fetch 封装
│   └── store/              # 状态管理
│       ├── index.ts
│       ├── counterSlice.ts
│       └── middleware/
├── packages/               # pnpm workspace
│   ├── shared/             # 共享工具库
│   └── ui/                 # UI 组件库
├── vite.config.ts          # Vite 配置
├── eslint.config.js        # ESLint Flat Config
├── tsconfig.json
├── postcss.config.js
├── .browserslistrc         # 浏览器兼容
└── pnpm-workspace.yaml

状态管理可选

  • Redux Toolkit(推荐)
  • MobX

HTTP 请求可选

  • Axios(带完整封装)
  • Fetch(原生 API 封装)

💚 Vue 模板

同样是 pnpm monorepo 结构,默认集成 Pinia。

my-vue-app/
├── src/
│   ├── main.ts
│   ├── App.vue
│   ├── pages/
│   ├── components/
│   ├── router/
│   ├── api/
│   └── store/              # Pinia 状态管理
├── packages/
│   ├── shared/
│   └── ui/
└── ...配置文件

2. 丰富的插件系统

插件系统
├── 代码规范
│   ├── ESLint 9 (Flat Config) ⭐ 最新格式
│   ├── Prettier
│   └── Stylelint (支持 CSS/Less/SCSS)
│
├── 构建工具
│   ├── Vite 5 ⭐ 默认推荐
│   ├── Webpack 5
│   └── Rollup
│
├── 测试工具
│   ├── Vitest ⭐ Vite 原生
│   └── Jest
│
└── Git 工具
    ├── Husky (Git Hooks)
    └── Commitlint (提交规范)

每个插件都是独立、可插拔的。你可以选择需要的,跳过不需要的。


3. 浏览器兼容性:一处配置,处处生效

这是我最想重点介绍的功能。

以前的问题

CSS 前缀和 JS Polyfill 分开配置,经常对不上:

// postcss.config.js
module.exports = {
  plugins: {
    autoprefixer: {
      overrideBrowserslist: ['last 2 versions']  // 这里
    }
  }
}

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      targets: { browsers: ['> 1%'] }  // 和这里不一致!
    }]
  ]
}

结果:CSS 兼容 Chrome 70,JS 兼容 Chrome 60,乱套了。

xcli 的解决方案

统一使用 .browserslistrc

# .browserslistrc
[production]
> 0.5%
last 2 versions
not dead
not IE 11
Chrome >= 86    # 明确指定 Chrome 86+

[development]
last 1 chrome version
last 1 firefox version
last 1 safari version

然后所有工具自动读取:

工具 作用 配置方式
Autoprefixer 添加 CSS 前缀 自动读取 .browserslistrc
Babel preset-env JS Polyfill 自动读取 .browserslistrc
Vite Legacy 旧浏览器兼容 自动读取 .browserslistrc

一处配置,处处生效。再也不用操心兼容性问题。


4. ESLint 9 Flat Config

很多脚手架还在用 ESLint 8 的 .eslintrc.json 格式,xcli 直接上了 ESLint 9+ Flat Config

旧格式(ESLint 8)

// .eslintrc.json
{
  "root": true,
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "extends": [
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended"
  ],
  "env": {
    "node": true
  }
}

问题:

  • 多个配置文件(.eslintrc + .eslintignore
  • 配置格式不统一
  • 插件加载顺序容易出问题

新格式(ESLint 9 Flat Config)

// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
import prettierConfig from 'eslint-config-prettier';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';

export default tseslint.config(
  { ignores: ['dist'] },
  {
    extends: [js.configs.recommended, ...tseslint.configs.recommended],
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
    plugins: {
      'react-hooks': reactHooks,
      'react-refresh': reactRefresh,
    },
    rules: {
      ...reactHooks.configs.recommended.rules,
      'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
      '@typescript-eslint/no-explicit-any': 'warn',
      '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
    },
  },
  prettierConfig,
);

优势:

  • ✅ 单一配置文件
  • ✅ JavaScript 原生数组操作,可扩展性更强
  • ✅ 显式导入,没有隐式依赖
  • ✅ 性能更好

实战演示

场景 1:快速启动一个 React 项目

# 交互式创建
npx @jserxiao/xcli init my-react-app

# 然后根据提示选择:
# ? 项目类型: React
# ? 样式预处理器: Less
# ? 状态管理: Redux Toolkit
# ? HTTP 请求库: Axios
# ? 打包工具: Vite
# ? 创建 VSCode 配置: Yes

或者直接命令行一把梭:

npx @jserxiao/xcli init my-react-app \
  -t react \
  -s less \
  -m redux \
  -h axios \
  -b vite \
  -d

生成的项目结构:

my-react-app/
├── src/
│   ├── main.tsx              # React 18 入口
│   ├── App.tsx               # 根组件
│   ├── pages/
│   │   ├── Home.tsx          # 首页(带 Redux 示例)
│   │   └── About.tsx         # 关于页
│   ├── components/
│   │   └── Layout.tsx        # 布局组件
│   ├── router/
│   │   └── index.tsx         # React Router 6
│   ├── api/
│   │   └── request.ts        # Axios 封装(含拦截器)
│   ├── store/
│   │   ├── index.ts          # Store 配置
│   │   ├── counterSlice.ts   # Counter 示例
│   │   ├── apiSlice.ts       # RTK Query
│   │   └── middleware/
│   │       └── logger.ts     # 日志中间件
│   └── assets/
├── packages/
│   ├── shared/               # 共享工具函数
│   │   └── src/
│   │       └── index.ts
│   └── ui/                   # UI 组件库
│       └── src/
│           └── index.ts
├── public/
├── vite.config.ts            # Vite 5 配置
├── eslint.config.js          # ESLint 9 Flat Config
├── tsconfig.json             # TypeScript 5
├── postcss.config.js         # PostCSS + Autoprefixer
├── .browserslistrc           # 浏览器兼容
├── .prettierrc
├── pnpm-workspace.yaml
└── package.json

直接运行:

cd my-react-app
pnpm install
pnpm dev

打开浏览器,一个完整的 React 项目已经跑起来了:

  • ✅ React 18 + TypeScript 5
  • ✅ React Router 6
  • ✅ Redux Toolkit(含 RTK Query)
  • ✅ Axios 封装
  • ✅ Vite 5 + HMR
  • ✅ ESLint 9 + Prettier
  • ✅ pnpm monorepo

总耗时:3 分钟


场景 2:企业级 Vue 项目

npx @jserxiao/xcli init my-vue-app \
  -t vue \
  -s scss \
  -b webpack \
  -d

注意这里用了 Webpack 而不是 Vite。

为什么?因为有些企业项目需要:

  • 更细粒度的构建控制
  • 复杂的 loader 配置
  • 特定的优化策略

xcli 的 Webpack 配置包含:

// webpack.config.cjs 节选
module.exports = (env, argv) => {
  return {
    // ...
    module: {
      rules: [
        // Babel:自动读取 .browserslistrc
        {
          test: /\.[jt]sx?$/,
          use: {
            loader: 'babel-loader',
            options: {
              presets: [
                ['@babel/preset-env', {
                  useBuiltIns: 'usage',  // 按需 Polyfill
                  corejs: 3,
                }],
                '@babel/preset-typescript',
              ],
            },
          },
        },
        // CSS + PostCSS
        {
          test: /\.css$/,
          use: [
            MiniCssExtractPlugin.loader,
            'css-loader',
            'postcss-loader',  // Autoprefixer
          ],
        },
      ],
    },
    // 代码分割
    optimization: {
      splitChunks: {
        chunks: 'all',
        cacheGroups: {
          vendor: {
            test: /[\\/]node_modules[\\/]/,
            name: 'vendors',
          },
        },
      },
    },
  };
};

场景 3:开发一个 npm 工具库

npx @jserxiao/xcli init my-utils -t library -d

生成的 Library 项目:

my-utils/
├── src/
│   └── index.ts          # 入口文件
├── dist/                 # ESM + CJS 输出
├── package.json
├── tsconfig.json
├── rollup.config.ts      # Rollup 配置
└── README.md

package.json 自动配置:

{
  "name": "my-utils",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.cjs",
  "module": "dist/index.js",
  "types": "dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "rollup -c",
    "dev": "rollup -c -w"
  }
}

直接发布到 npm:

pnpm build
npm publish

技术细节:xcli 是如何设计的?

插件架构

每个插件都是一个独立的对象:

export const vitePlugin: Plugin = {
  name: 'vite',
  displayName: 'Vite',
  description: '下一代前端构建工具',
  category: 'bundler',
  defaultEnabled: true,
  devDependencies: {
    vite: '^5.0.0',
    '@vitejs/plugin-react': '^4.0.0',
    '@vitejs/plugin-legacy': '^5.0.0',
  },
  scripts: {
    dev: 'vite',
    build: 'vite build',
    preview: 'vite preview',
  },
  files: [
    {
      path: 'vite.config.ts',
      content: (context) => getViteConfig(context),
    },
  ],
};

好处

  • 插件之间完全解耦
  • 可以独立更新某个插件
  • 社区可以自定义插件

模板系统

模板负责生成项目结构:

export const reactTemplate = {
  type: 'react',
  displayName: 'React',
  description: 'React 前端项目 (pnpm monorepo)',

  createStructure: async (projectPath, context) => {
    // 1. 创建目录结构
    // 2. 生成配置文件
    // 3. 生成源代码
    // 4. 根据选项调整(Redux/MobX、Axios/Fetch、Vite/Webpack)
  },

  getDependencies: (styleType, stateManager, httpClient, bundler) => {
    // 根据选择返回对应的依赖
    return {
      dependencies: { ... },
      devDependencies: { ... },
    };
  },
};

版本管理

所有依赖版本统一在 versions.ts 中管理:

export const BUNDLER_VERSIONS = {
  vite: '^5.0.12',
  webpack: '^5.98.0',
  // ...
};

export const FRAMEWORK_VERSIONS = {
  react: '^18.2.0',
  vue: '^3.4.15',
  // ...
};

好处

  • 版本升级只需改一处
  • 确保所有项目使用相同版本
  • 避免版本冲突

对比:xcli vs 其他脚手架

特性 xcli create-react-app Vite 官方模板
TypeScript ✅ 原生支持 ⚠️ 需要 eject ✅ 支持
Monorepo ✅ pnpm workspace ❌ 不支持 ❌ 不支持
状态管理 ✅ 可选 Redux/MobX/Pinia ❌ 无 ❌ 无
HTTP 封装 ✅ 可选 Axios/Fetch ❌ 无 ❌ 无
ESLint 9 ✅ Flat Config ❌ 旧格式 ⚠️ 需手动配
浏览器兼容 ✅ 统一配置 ⚠️ 需手动配 ⚠️ 需手动配
构建工具 ✅ Vite/Webpack ❌ 仅 Webpack ✅ Vite
插件系统 ✅ 可插拔 ❌ 无 ❌ 无

使用建议

个人项目

# 快速启动,默认配置够用
xcli init my-app -t react -d

团队项目

# 明确指定每个选项,确保一致性
xcli init team-project \
  -t react \
  -s scss \
  -m redux \
  -h axios \
  -b vite \
  -d

建议团队制定一份 xcli 使用规范,确保所有项目配置统一。

开源库

xcli init my-lib -t library -d

写在最后

xcli 解决的是一个"小"问题——省去配置的时间。

但它带来的价值是"大"的:

  • 时间节省:从 2 小时到 3 分钟
  • 配置标准化:团队所有项目配置统一
  • 技术债减少:不再需要维护多份配置
  • 新人友好:降低项目启动门槛

如果你也受够了重复配置,不妨试试:

npx @jserxiao/xcli init my-project

附录:常用命令速查

# 创建项目
xcli init my-project
xcli init my-project -t react -d
xcli init my-project -t vue -d
xcli init my-lib -t library -d

# 插件管理
xcli plugin list
xcli plugin add vitest
xcli plugin remove jest

# 升级 CLI
xcli upgrade --check
xcli upgrade

# 查看版本
xcli version

这个工具是我用AI花了三四天写的;没错,文章也是AI写的;有问题或建议,欢迎在评论区交流 💬

❌
❌