阅读视图

发现新文章,点击刷新页面。

Modern.js 3.0 发布:聚焦 Web 框架,拥抱生态发展

前言

Modern.js 2.0 发布 至今,已过去三年时间,感谢社区开发者们对 Modern.js 的使用和信任。Modern.js 一直保持稳定的迭代,累计发布了超过 100 个版本。

在字节内部,Modern.js 已成为 Web 开发的核心框架。在全公司活跃的 Web 项目中,使用占比已从 2025 年初的 40% 增长至目前接近 70%。

这三年中,我们不断扩充新特性,持续进行代码重构与优化,也收到了非常多的反馈,这些经验成为了 3.0 版本改进的重要参考。经过慎重考虑,我们决定发布 Modern.js 3.0,对框架进行一次全面的升级。

Modern.js 2.0 到 3.0 的演变

从 Modern.js 2.0 到 3.0,有两个核心转变:

更聚焦,专注于 Web 框架

  • Modern.js 2.0:包含 Modern.js App、Modern.js Module、Modern.js Doc
  • Modern.js 3.0:只代表 Modern.js App,Modern.js Module 和 Modern.js Doc 已孵化为 RslibRspress

更开放,积极面向社区工具

  • Modern.js 2.0:内置各类工具、框架独特的 API 设计
  • Modern.js 3.0:强化插件体系,完善接入能力,推荐社区优质方案

Modern.js 3.0 新特性

React Server Component

TL;DR:Modern.js 3.0 集成 React Server Component,支持 CSR 和 SSR 项目,并支持渐进式迁移。

什么是 React Server Component

React Server Components(服务端组件)是一种新的组件类型,它允许组件逻辑完全在服务端执行,并直接将渲染后的 UI 流式传输到客户端。与传统的客户端组件相比,服务端组件带来了以下特性:

特性 说明
零客户端包体积 组件代码不包含在客户端 JS Bundle 中,仅在服务端执行,加快首屏加载与渲染速度
更高的内聚性 组件可直接连接数据库、调用内部 API、读取本地文件,提高开发效率
渐进增强 可与客户端组件无缝混合使用,按需下放交互逻辑到客户端,在保持高性能的同时,支持复杂交互体验

需要明确的是,RSC 和 SSR 是截然不同的概念

  • RSC:描述的是组件类型,即组件在哪里执行(服务端 vs 客户端)
  • SSR:描述的是渲染模式,即 HTML 在哪里生成(服务端 vs 客户端)

两者可以组合使用:Server Component 可以在 SSR 项目下使用,也可以在 CSR 项目下使用。在 Modern.js 3.0 中,我们同时支持这两种模式,开发者可以根据需求选择。

开箱即用

在 Modern.js 3.0 中,只需在配置中启用 RSC 能力:

modern.config.ts

export default defineConfig({
  server: {
    rsc: true,
  }
});

配置启用后,所有的路由组件都会默认成为 Server Component。项目中可能存在无法在服务端运行的组件,你可以先为这些组件添加 'use client' 标记,以保持原有行为,再逐步迁移。

RSC 效果演示视频:lf3-static.bytednsdoc.com/obj/eden-cn…

Modern.js 3.0 的 RSC 特性

Modern.js 一直选择 React Router 作为路由解决方案。去年,React Router v7 宣布支持 React Server Component,这为 Modern.js 提供了在 SPA 应用下实现 RSC 的基础。

相比于社区其他框架,Modern.js 对 RSC 做了几点优化:

  • 使用 Rspack 最新的 RSC 插件构建,显著提升 RSC 项目构建速度;并进一步优化了产物体积。
  • 不同于社区主流框架只支持 RSC + SSR,Modern.js 3.0 的 RSC 同样支持 CSR 项目
  • 在路由跳转时,框架会自动将多个 Data Loader 和 Server Component 的请求合并为单个请求,并流式返回,提升页面性能
  • 在嵌套路由场景下,路由组件类型不受父路由组件类型的影响,开发者可以从任意路由层级开始采用 Server Component

渐进式迁移

基于灵活的组件边界控制能力,Modern.js 3.0 提供了渐进式的迁移方式。Modern.js 3.0 允许基于路由组件维度的 Server Component 迁移,无需迁移整条组件树链路。

更多 React Server Component 的详细内容,可以参考:React Server Component


拥抱 Rspack

TL;DR:Modern.js 3.0 移除了对 webpack 的支持,全面拥抱 Rspack,并升级到最新的 Rspack & Rsbuild 2.0。

在 2023 年,我们开源了 Rspack,并在 Modern.js 中支持将 Rspack 作为可选的打包工具。在字节内部,超过 60% 的 Modern.js 项目已经切换到 Rspack 构建。

经过两年多发展,Rspack 在社区中的月下载量已超过 1000 万次,成长为行业内被广泛使用的打包工具;同时,Modern.js 的 Rspack 构建模式也得到持续完善。

Rspack 下载量

在 Modern.js 3.0 中,我们决定移除对 webpack 的支持,从而使 Modern.js 变得更加轻量和高效,并能更充分地利用 Rspack 的新特性。

更顺畅的开发体验

Modern.js 3.0 在移除 webpack 后,能够更好地遵循 Rspack 最佳实践,在构建性能、安装速度等方面均有提升:

底层依赖升级

Modern.js 3.0 将底层依赖的 Rspack 和 Rsbuild 升级至 2.0 版本,并基于新版本优化了默认构建配置,使整体行为更加一致。

参考以下文档了解底层行为变化:

更快的构建速度

Modern.js 通过 Rspack 的多项特性来减少构建耗时:

  • 默认启用 Barrel 文件优化:构建组件库速度提升 20%
  • 默认启用持久化缓存:非首次构建的速度提升 50%+

更快的安装速度

移除 webpack 相关依赖后,Modern.js 3.0 的构建依赖数量和体积均明显减少:

  • npm 依赖数量减少 40%
  • 安装体积减少 31 MB

更小的构建产物

Modern.js 现在默认启用 Rspack 的多项产物优化策略,能够比 webpack 生成更小的产物体积,例如:

增强 Tree shaking

增强了 tree shaking 分析能力,可以处理更多动态导入语法,例如解构赋值:

// 参数中的解构访问
import('./module').then(({ value }) => {
  console.log(value);
});

// 函数体内的解构访问
import('./module').then((mod) => {
  const { value } = mod;
  console.log(value);
});

常量内联

对常量进行跨模块内联,有助于压缩工具进行更准确的静态分析,从而消除无用的代码分支:

// constants.js
export const ENABLED = true;

// index.js
import { ENABLED } from './constants';
if (ENABLED) {
  doSomething();
} else {
  doSomethingElse();
}

// 构建产物 - 无用分支被消除
doSomething();

全链路可扩展

TL;DR:Modern.js 3.0 正式开放完整插件体系,提供运行时、服务端插件,同时支持灵活处理应用入口。

Modern.js 2.0 提供了 CLI 插件与内测版本的运行时插件,允许开发者对项目进行扩展。但在实践过程中,我们发现现有的能力不足以支撑复杂的业务场景。

Modern.js 3.0 提供了更灵活的定制能力,允许为应用编写全流程的插件,帮助团队统一业务逻辑、减少重复代码:

  • CLI 插件:在构建阶段扩展功能,如添加命令、修改配置
  • Runtime 插件:在渲染阶段扩展功能,如数据预取、组件封装
  • Server 插件:在服务端扩展功能,如添加中间件、修改请求响应

运行时插件

运行时插件在 CSR 与 SSR 过程中都会运行,新版本提供了两个核心钩子:

  • onBeforeRender:在渲染前执行逻辑,可用于数据预取、注入全局数据
  • wrapRoot:封装根组件,添加全局 Provider、布局组件等

你可以在 src/modern.runtime.ts 中注册插件,相比在入口手动引入高阶组件,运行时插件可插拔、易更新,在多入口场景下无需重复引入:

src/modern.runtime.tsx

import { defineRuntimeConfig } from "@modern-js/runtime";

export default defineRuntimeConfig({
  plugins: [
    {
      name: "my-runtime-plugin",
      setup: (api) => {
        api.onBeforeRender((context) => {
          context.globalData = { theme: "dark" };
        });
        api.wrapRoot((App) => (props) => <App {...props} />);
      },
    },
  ],
});

更多 Runtime 插件使用方式,请查看文档:Runtime 插件

服务端中间件

在实践过程中我们发现,部分项目需要扩展 Web Server,例如鉴权、数据预取、降级处理、动态 HTML 脚本注入等。

在 Modern.js 3.0 中,我们使用 Hono 重构了 Web Server,并正式开放了服务端中间件与插件的能力。开发者可以使用 Hono 的中间件来完成需求:

server/modern.server.ts

import { defineServerConfig, type MiddlewareHandler } from "@modern-js/server-runtime";

const timingMiddleware: MiddlewareHandler = async (c, next) => {
  const start = Date.now();
  await next();
  const duration = Date.now() - start;
  c.header('X-Response-Time', `${duration}ms`);
};

const htmlMiddleware: MiddlewareHandler = async (c, next) => {
  await next();
  const html = await c.res.text();
  const modified = html.replace(
    "<head>",
    '<head><meta name="generator" content="Modern.js">'
  );
  c.res = c.body(modified, { status: c.res.status, headers: c.res.headers });
};

export default defineServerConfig({
  middlewares: [timingMiddleware],
  renderMiddlewares: [htmlMiddleware],
});

更多服务端插件使用方式,可以查看文档:自定义 Web Server

自定义入口

在 Modern.js 3.0 中,我们重构了自定义入口,相比于旧版 API 更加清晰灵活:

src/entry.tsx

import { createRoot } from '@modern-js/runtime/react';
import { render } from '@modern-js/runtime/browser';

const ModernRoot = createRoot();

async function beforeRender() {
  // 渲染前的异步操作,如初始化 SDK、获取用户信息等
}

beforeRender().then(() => {
  render(<ModernRoot />);
});

更多入口使用方式,请查看文档:入口


路由优化

TL;DR:Modern.js 3.0 内置 React Router v7,提供配置式路由能力与 AI 友好的调试方式。

内置 React Router v7

在 Modern.js 3.0 中,我们统一升级到 React Router v7,并废弃了对 v5 和 v6 的内置支持。这一决策基于以下考虑:

版本演进与稳定性

React Router v6 是一个重要的过渡版本,它引入了许多新特性(如数据加载、错误边界等)。而 v7 在保持 v6 API 兼容性的基础上,进一步优化了性能、稳定性和开发体验。随着 React Router 团队将 Remix 定位为独立框架,React Router 核心库可能会在 v7 版本上长期维护,使其成为更可靠的选择。

升级路径

  • 从 v6 升级:React Router v7 对 v6 开发者来说是无破坏性变更的升级。在 Modern.js 2.0 中,我们已提供了 React Router v7 插件支持,你可以通过插件方式渐进式升级,验证兼容性后再迁移到 Modern.js 3.0。
  • 从 v5 升级:v5 到 v7 存在较大的 API 变化,建议参考 React Router 官方迁移指南 进行迁移。

配置式路由

在 Modern.js 中,我们推荐使用约定式路由来组织代码。但在实际业务中,开发者偶尔遇到以下场景:

  • 多路径指向同一组件
  • 灵活的路由控制
  • 条件性路由
  • 遗留项目迁移

因此,Modern.js 3.0 提供了完整的配置式路由支持,可以与约定式路由一起使用,或两者分别单独使用。

src/modern.routes.ts

import { defineRoutes } from "@modern-js/runtime/config-routes";

export default defineRoutes(({ route, layout, page }) => {
  return [
    route("home.tsx", "/"),
    route("about.tsx", "about"),
    route("blog.tsx", "blog/:id"),
  ];
});

更多配置式路由使用方式,请查看文档:配置式路由

路由调试

运行 npx modern routes 命令即可在 dist/routes-inspect.json 文件中生成完整的路由结构分析报告。

报告中会显示每个路由的路径、组件文件、数据加载器、错误边界、Loading 组件等完整信息,帮助开发者快速了解项目的路由配置,快速定位和排查路由相关问题。结构化的 JSON 格式也便于 AI agent 理解和分析路由结构,提升 AI 辅助开发的效率。

具体使用方式,请查看文档:路由调试


服务端渲染

TL;DR:Modern.js 3.0 重做了 SSG 能力,提供了灵活的缓存能力,对降级策略也进行了进一步的完善。

静态站点生成(SSG)

在 Modern.js 2.0 中,我们提供了静态站点生成的能力。这个能力非常适合用在可以静态渲染的页面中,能极大的提升页面首屏性能。

在新版本中,我们对 SSG 进行了重新设计:

  • 数据获取使用 Data Loader,与非 SSG 场景保持一致
  • 简化了 API,降低理解成本
  • 与约定式路由更好地结合

在新版本中,你可以通过 data loader 进行数据获取,与非 SSG 场景保持一致。然后在 ssg.routes 配置中即可直接指定要渲染的路由:

modern.config.ts

export default defineConfig({
  output: {
    ssg: {
      routes: ['/blog'],
    },
  },
});

routes/blog/page.data.ts

export const loader = async () => {
  const articles = await fetchArticles();
  return { articles };
};

更多 SSG 的使用方式,请查看文档:SSG

缓存机制

Modern.js 3.0 中提供了不同维度的缓存机制,帮助项目提升首屏性能。所有缓存均支持灵活配置,比如可以支持类似 HTTP 的 stale-while-revalidate 策略:

渲染缓存

支持将 SSR 结果进行整页的缓存,在 server/cache.ts 中配置:

server/cache.ts

import type { CacheOption } from '@modern-js/server-runtime';

export const cacheOption: CacheOption = {
  maxAge: 500, // ms
  staleWhileRevalidate: 1000, // ms
};

使用渲染缓存,请查看文档:渲染缓存

数据缓存

我们在新版本中提供了 cache 函数,相比渲染缓存它提供了更精细的数据粒度控制。当多个数据请求依赖同一份数据时,cache 可以避免重复请求:

server/loader.ts

import { cache } from "@modern-js/runtime/cache";
import { fetchUserData, fetchUserProjects, fetchUserTeam } from "./api";

// 缓存用户数据,避免重复请求
const getUser = cache(fetchUserData);

const getProjects = async () => {
  const user = await getUser("test-user");
  return fetchUserProjects(user.id);
};

const getTeam = async () => {
  const user = await getUser("test-user"); // 复用缓存,不会重复请求
  return fetchUserTeam(user.id);
};

export const loader = async () => {
  // getProjects 和 getTeam 都依赖 getUser,但 getUser 只会执行一次
  const [projects, team] = await Promise.all([getProjects(), getTeam()]);
  return { projects, team };
};

更多数据缓存的使用方式,请查看文档:数据缓存

灵活的降级策略

在实践过程中,我们沉淀了多维度的降级策略:

类型 触发方式 降级行为 使用场景
异常降级 Data Loader 执行报错 触发 ErrorBoundary 数据请求异常兜底
组件渲染报错 服务端渲染异常 降级到 CSR,复用已有数据渲染 服务端渲染异常兜底
业务降级 Loader 抛出 throw Response 触发 ErrorBoundary,返回对应 HTTP 状态码 404、权限校验等业务场景
配置 Client Loader 配置 Client Loader 绕过 SSR,直接请求数据源 需要在客户端直接获取数据的场景
强制降级 Query 参数 ?__csr=true 跳过 SSR,返回 CSR 页面 调试、临时降级
强制降级 请求头 x-modern-ssr-fallback 跳过 SSR,返回 CSR 页面 网关层控制降级

轻量 BFF

TL;DR:Modern.js 3.0 基于 Hono 重构了 Web Server,提供基于 Hono 的一体化函数,同时支持跨项目调用。

Hono 一体化函数

在 Modern.js 3.0 中,我们使用 Hono 作为 BFF 的运行时框架,开发者可以基于 Hono 生态扩展 BFF Server,享受 Hono 轻量、高性能的优势。

通过 useHonoContext 可以获取完整的 Hono 上下文,访问请求信息、设置响应头等:

api/lambda/user.ts

import { useHonoContext } from '@modern-js/server-runtime';

export const get = async () => {
  const c = useHonoContext();
  const token = c.req.header('Authorization');
  c.header('X-Custom-Header', 'modern-js');
  const id = c.req.query('id');

  return { userId: id, authenticated: !!token };
};

跨项目调用

在过去,Modern.js BFF 只能在当前项目中使用,而我们陆续收到开发者反馈,希望能够在不同项目中使用。这多数情况是由于开发者的迁移成本、运维成本造成的,相比于抽出原有代码再部署一个,显然复用已有服务更加合理。

为了保证开发者能得到与当前项目一体化调用类似的体验,我们提供了跨项目调用的能力。

更多 BFF 的使用方式,请查看文档:BFF


Module Federation 深度集成

TL;DR:Modern.js 3.0 与 Module Federation 2.0 深度集成,支持 MF SSR 和应用级别模块导出。

MF SSR

Modern.js 3.0 支持在 SSR 应用中使用 Module Federation,组合使用模块联邦和服务端渲染能力,为用户提供更好的首屏性能体验。

modern.config.ts

export default defineConfig({
  server: {
    ssr: {
      mode: 'stream',
    },
  },
});

配合 Module Federation 的数据获取能力,每个远程模块都可以定义自己的数据获取逻辑:

src/components/Button.data.ts

export const fetchData = async () => {
  return {
    data: `Server time: ${new Date().toISOString()}`,
  };
};

src/components/Button.tsx

export const Button = (props: { mfData: { data: string } }) => {
  return <button>{props.mfData?.data}</button>;
};

应用级别模块

不同于传统的组件级别共享,Modern.js 3.0 支持导出应用级别模块——具备完整路由能力、可以像独立应用一样运行的模块。这是微前端场景中的重要能力。

生产者导出应用

src/export-App.tsx

import '@modern-js/runtime/registry/index';
import { render } from '@modern-js/runtime/browser';
import { createRoot } from '@modern-js/runtime/react';
import { createBridgeComponent } from '@module-federation/modern-js/react';

const ModernRoot = createRoot();
export const provider = createBridgeComponent({
  rootComponent: ModernRoot,
  render: (Component, dom) => render(Component, dom),
});

export default provider;

消费者加载应用

src/routes/remote/$.tsx

import { createRemoteAppComponent } from '@module-federation/modern-js/react';
import { loadRemote } from '@module-federation/modern-js/runtime';

const RemoteApp = createRemoteAppComponent({
  loader: () => loadRemote('remote/app'),
  fallback: ({ error }) => <div>Error: {error.message}</div>,
  loading: <div>Loading...</div>,
});

export default RemoteApp;

通过通配路由 $.tsx,所有访问 /remote/* 的请求都会进入远程应用,远程应用内部的路由也能正常工作。

更多 Module Federation 的使用方式,请查看文档:Module Federation


技术栈更新

TL;DR:Modern.js 3.0 升级 React 19,最低支持 Node.js 20。

React 19

Modern.js 3.0 新项目默认使用 React 19,最低支持 React 18。

如果你的项目仍在使用 React 16 或 React 17,请先参考 React 19 官方升级指南 完成版本升级。

Node.js 20

随着 Node.js 不断推进版本演进,Node.js 18 已经 EOL。在 Modern.js 3.0 中,推荐使用 Node.js 22 LTS,不再保证对 Node.js 18 的支持。

Storybook Rsbuild

在 Modern.js 3.0 中,我们基于 Storybook Rsbuild 实现了使用 Storybook 构建 Modern.js 应用。

通过 Storybook Addon,我们将 Modern.js 配置转换合并为 Rsbuild 配置,并通过 Storybook Rsbuild 驱动构建,让 Storybook 调试与开发命令保持配置对齐。

更多 Storybook 使用方式,请查看文档:使用 Storybook

使用 Biome

随着社区技术不断发展,更快、更简洁的工具链已经成熟。在 Modern.js 3.0 中,新项目默认使用 Biome 作为代码检查和格式化工具。


从 Modern.js 2.0 升级到 3.0

主要变更

升级 Modern.js 3.0 意味着拥抱更轻量、更标准的现代化开发范式。通过全面对齐 Rspack 与 React 19 等主流生态,彻底解决历史包袱带来的维护痛点,显著提升构建与运行性能。

未来,我们也会基于 Modern.js 3.0 提供更多的 AI 集成与最佳实践,配合灵活的全栈插件系统,让开发者能以极低的学习成本复用社区经验,实现开发效率的质变与应用架构的现代化升级。

更多改进与变更,请查看文档:升级指南

反馈和社区

最后,再次感谢每一位给予我们反馈和支持的开发者,我们将继续与大家保持沟通,在相互支持中共同成长。

如果你在使用过程中遇到问题,欢迎通过以下方式反馈:

JS 大数值处理和金额格式化处理方案

点赞 + 关注 + 收藏 = 学会了

在做前端开发或者使用 n8n、dify 等工具时可能会跟数字打交道,可能会遇到下面这些需求:

  • 显示金额:1234567.89 → 1,234,567.89
  • 金额计算:0.1 + 0.2
  • 超大 ID:9007199254740993
  • 阿拉伯数字转中文金额:123456.78 → 壹拾贰万叁仟肆佰伍拾陆元柒角捌分

很多刚接触 JavaScript 的开发者会直接使用 Number 类型处理这些问题,但实际上这里面隐藏了不少

举个例子:

如果后端传给你的 JSON 里的长 ID 没加引号,前端在 JSON.parse 的一瞬间就已经把精度丢了。

const rawJson = '{"id": 9007199254740995}';
const parsed = JSON.parse(rawJson);
console.log(parsed.id.toString()); // "9007199254740996" 精度丢失了!!!

解决方案

  1. 后端改 String:最省心的办法。让后端把超长 ID 以字符串形式下发。
  2. 前端插件:如果后端不改,你可以使用 json-bigint 库来解析原始的 JSON 字符串。

数值是否安全?

在实际应用中,可以通过一些方法来胖段当前数值是否安全。

JS 提供的方法:

Number.isSafeInteger(9007199254740991) // true
Number.isSafeInteger(9007199254740992) // false

处理大数值的几种方案

在 JavaScript 里,普通数字类型是 Number(64位双精度浮点),它有一个安全整数范围:

  • 最大安全整数:Number.MAX_SAFE_INTEGER = 9007199254740991
  • 最小安全整数:Number.MIN_SAFE_INTEGER = -9007199254740991

超过这个范围就会出现 精度丢失问题,例如:

console.log(9007199254740991 + 1) // 9007199254740992
console.log(9007199254740991 + 2) // 9007199254740992  ❌ 精度丢失

如果你要处理 超过 Number 最大安全值的整数,有几种常见方案👇

方案1:BigInt

现代 JavaScript 提供了一种新的类型:BigInt

它可以表示任意大的整数。

const num = BigInt("9007199254740993")

console.log(num + 1n)
// 9007199254740994n

BigInt 的话,数字后面会跟着一个字母 n,看到它就能区分这个值和普通的 Number 类型不一样,这个需求可能会涉及很大的数值。

它还有一种简写方法,在赋值的时候不需要加引号括者数字,而是在数字后面加个 n

const num = 9007199254740993n

需要注意的是,BigInt 不能和 Number 混合运算!!!

1n + 1
// ❌ 报错

必须统一类型:

1n + BigInt(1)

金融计算为什么不能直接用 Number

一个经典问题:

0.1 + 0.2

# 结果是 0.30000000000000004

原因是浮点数精度问题。

金融系统一般有两种解决方案。

方案一:金额用“分”存储

例如:

123.45 元

存储为:12345

前端展示时再除以 100。

const amount = 12345 / 100

console.log(amount)
// 123.45

这种方式是比较老派的方法。

方案二:使用大数库

**MikeMcl 写了几个很出名的处理数字的JS库,比如:

我用 bignumber.js 演示一下。

安装 bignumber.js

npm install bignumber.js

在前端项目里引入:

import BigNumber from 'bignumber.js';

此时直接计算小数位的数值

const a = new BigNumber(0.1)
const b = new BigNumber(0.2)

a.plus(b).toString()
// 0.3

// 格式化,保留2位小数
a.plus(b).toFormat(2)
// 0.30

处理数值比较大的数据也没问题

// 1. 创建大数(建议始终传入字符串)
const x = new BigNumber('9007199254740995.123456789');
const y = new BigNumber('100');

// 2. 加减乘除
const res = x.plus(y);      // 加
const res2 = x.minus(y);    // 减
const res3 = x.times(y);    // 乘
const res4 = x.div(y);      // 除

console.log(res.toString()); // "9007199254741095.123456789" (精度完全保留)

格式化数值

在金融行业,金额的展示不仅关乎美观,更关乎准确性合规性。针对你提出的千分位转换、中文大写转换以及大数处理

千分位格式化

方案1:toLocaleString()

JavaScript 原生支持国际化格式化。

const amount = 123456789.56

amount.toLocaleString()
// 123,456,789.56

方案2:Intl.NumberFormat

金融项目比较推荐使用这个方案。

const formatter = new Intl.NumberFormat('zh-CN', {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2
})

console.log(formatter.format(1234567.8))
// 1,234,567.80

如果想在金额前面加一个“钱”的符号,比如人民币就加个 ¥,可以这么写:

const formatter = new Intl.NumberFormat('zh-CN', {
  style: 'currency',
  currency: 'CNY'
})

console.log(formatter.format(123456))
// ¥123,456.00

如果要使用美元符 $ 就这么写:

const formatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD'
})

console.log(formatter.format(123456))
// $123,456.00

方案3:正则实现千分位(不推荐)

function formatNumber(num) {
  return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")
}

console.log(formatNumber(123456789))
// 123,456,789

如果需要支持小数就这么写:

function formatMoney(num) {
  return num.toString().replace(/\d+/, function(n) {
    return n.replace(/(\d)(?=(\d{3})+$)/g, '$1,')
  })
}

金融系统一般不推荐这种方式,因为这种方式不支持国际化,不支持货币格式,容易出 bug。

数字转大写中文

在中文的金融系统中,经常需要展示:

123456.78
↓
壹拾贰万叁仟肆佰伍拾陆元柒角捌分

这个规则有点复杂,一般不建议自己实现。

我推荐使用开源库 nzhgithub.com/cnwhy/nzh)。

安装:

npm install nzh

使用:

import nzh from "nzh"

nzh.cn.encodeS(123456)
// 十二万三千四百五十六

nzh.cn.encodeB(123456.78)
// 壹拾贰万叁仟肆佰伍拾陆点柒捌

nzh.cn.toMoney(123456.78)
// 人民币壹拾贰万叁仟肆佰伍拾陆元柒角捌分

最后总结一下。

在 JavaScript 中处理金额和大数时,核心要记住三点:

1️⃣ 不要直接用 Number 做金融计算 2️⃣ 金额存储最好使用整数(分) 3️⃣ 展示时再进行格式化

只要遵循这三条原则,就可以避免绝大多数金额相关的 bug。

以上就是本文的全部内容了,还有疑问的话可以在评论区交流。

点赞 + 关注 + 收藏 = 学会了

Vue3 工程构建

Vue3 工程构建

概述

Vue项目在搭建初期应当设定好目标、规范以及结构,以便后期扩展,避免结构混乱,代码难读、难以修改。

文档以vue3和ant-design-vue组件库为例,从零搭建项目,项目依赖如下:

生产依赖

{
    "dependencies": {
        "@ant-design/icons-vue": "7.0.1",
        "ant-design-vue": "4.2.2",
        "autoprefixer": "10.4.20",
        "axios": "1.7.7",
        "less": "4.2.0",
        "mockjs": "1.1.0",
        "pinia": "2.3.1",
        "postcss": "8.5.3",
        "tailwindcss": "3.4.17",
        "vue": "3.5.27",
        "vue-router": "4.4.5"
    },
}

开发依赖

{
    "devDependencies": {
        "@commitlint/config-conventional": "19.7.1",
        "@eslint/js": "^9.39.2",
        "@types/node": "22.12.0",
        "@typescript-eslint/eslint-plugin": "8.26.1",
        "@typescript-eslint/parser": "8.26.1",
        "@vitejs/plugin-vue": "5.0.4",
        "@vue/test-utils": "2.4.6",
        "@vue/tsconfig": "^0.8.1",
        "commitlint": "19.7.1",
        "cssnano": "^7.1.2",
        "eslint": "9.17.0",
        "eslint-plugin-vue": "9.28.0",
        "globals": "^17.3.0",
        "happy-dom": "20.5.0",
        "husky": "9.1.7",
        "lint-staged": "15.2.10",
        "prettier": "3.5.3",
        "stylelint": "16.12.0",
        "stylelint-order": "^7.0.1",
        "typescript": "5.9.3",
        "unplugin-auto-import": "^21.0.0",
        "unplugin-vue-components": "^31.0.0",
        "vite": "5.4.21",
        "vitest": "2.1.8",
        "vue-eslint-parser": "^10.2.0",
        "vue-tsc": "2.2.8"
    }
}

pnpm安装依赖

首先项目建议使用pnpm进行包管理和安装,

  1. pnpm 的 node_modules 布局使用符号链接来创建依赖项的嵌套结构。node_modules 中每个包的每个文件都是来自内容可寻址存储的硬链接。(避免重复安装
  2. pnpm 是默认支持 monorepo 多项目管理(多项目管理
  3. pnpm 使用链接仅将项目的直接依赖项添加到模块目录的根目录中(幽灵依赖
npm i -g pnpm

项目生产依赖

在项目生产环境中的依赖,主要考虑项目性质以及UI设计:(由于vue3只能在现代浏览器下运行。所以应该从兼容现代浏览器的版本开始,不需要兼容ie版本)

  1. 项目使用vue3应该配套使用状态管理库pinia以及路由管理vue-router
  2. 项目组件库为ant-design-vue,需要安装icon图标**@ant-design/icons-vue**,以及统一使用less作为css预处理器
  3. css工程化统一使用tailwindcsspostcssautoprefixer自动补齐css前缀。
  4. 使用axios请求接口,mockjs可以模拟接口数据。方便前期没有接口条件下开发。

项目开发环境vite以及vite相关配置

项目直接使用vite构建vue3+typescript项目

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
import { resolve, extname } from 'path';
// https://vitejs.dev/config/
export default defineConfig({
    plugins: [
        vue(),
        AutoImport({
            // 自动导入常用 API(无需手写 import)
            imports: ['vue', 'vue-router', 'pinia'],
            resolvers: [
                AntDesignVueResolver({
                    importStyle: 'less',
                }),
            ],
            dts: 'src/auto-imports.d.ts', // 生成 `auto-imports.d.ts` 全局 API 类型声明,支持 IDE 代码提示
        }),
        Components({
            resolvers: [
                AntDesignVueResolver({
                    importStyle: 'less', // 这里设置为 'less',以便在使用组件时自动引入对应的 Less 样式文件
                }),
            ],
            dts: 'src/components.d.ts', // 生成 `components.d.ts` 全局组件类型声明,支持 IDE 代码提示
        }),
    ],
    resolve: {
        alias: {
            '@': resolve(__dirname, 'src'), // 设置 '@' 代表 'src' 目录,方便在项目中使用绝对路径导入模块
        },
    },
    css: {
        preprocessorOptions: {
            // 配置 Less 预处理器选项
            less: {
                javascriptEnabled: true, // 允许在 Less 文件中使用 JavaScript 表达式,这对于 Ant Design Vue 的样式定制非常重要
            },
        },
    },
    // 开发服务器配置
    server: {
        port: 3000,
        open: true,
        cors: true,
    },
    build: {
        target: 'es2020', // 设置构建目标为 ES2020,利用现代浏览器的特性提升性能
        outDir: 'dist',
        sourcemap: false,
        rollupOptions: {
            output: {
                // 按类型分类输出文件
                entryFileNames: 'assets/js/[name]-[hash].js',
                chunkFileNames: 'assets/js/[name]-[hash].js',
                assetFileNames: function (assetInfo) {
                    var _a;
                    var ext = extname((_a = assetInfo.name) !== null && _a !== void 0 ? _a : '');
                    if (ext === '.css') {
                        return 'assets/css/[name]-[hash][extname]';
                    }
                    return 'assets/[name]-[hash][extname]';
                },
                // 将核心依赖单独拆分成独立 chunk,方便 CDN 长效缓存
                manualChunks: {
                    vue: ['vue', 'vue-router', 'pinia'],
                    antd: ['ant-design-vue', '@ant-design/icons-vue'],
                    vendor: ['axios'],
                },
            },
        },
    },
});

代码规范eslint

ESLint 用于统一代码风格和查找潜在问题。本项目使用 JS/TS/Vue 分块配置,并根据不同文件类型启用对应规则。

//eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import vuePlugin from 'eslint-plugin-vue';
import vueParser from 'vue-eslint-parser';
import tseslint from '@typescript-eslint/eslint-plugin';
import tsparser from '@typescript-eslint/parser';

const isProd = process.env.NODE_ENV === 'production';

export default [
    // ==================== 基础配置 ====================
    {
        // 全局忽略的文件和目录
        ignores: [
            '**/node_modules/**',
            '**/dist/**',
            '**/build/**',
            '**/coverage/**',
            '**/public/**',
            '**/*.min.js',
            '**/*.d.ts',
            '**/package-lock.json',
            '**/pnpm-lock.yaml',
            '**/yarn.lock',
            '**/vite.config.d.ts',
            '**/vitest.config.d.ts',
        ],
    },

    // ==================== JavaScript 通用配置 ====================
    {
        files: ['**/*.{js,mjs,cjs}'],
        languageOptions: {
            ecmaVersion: 'latest',
            sourceType: 'module',
            globals: {
                ...globals.browser,
                ...globals.node,
                ...globals.es2020,
            },
        },
        rules: {
            ...js.configs.recommended.rules,

            // 自定义规则
            'no-console': isProd ? ['warn', { allow: ['warn', 'error'] }] : 'off',
            'no-debugger': isProd ? 'warn' : 'off',
            'no-alert': 'warn',
            'no-unused-vars': 'off', // 由 TypeScript 处理
            'prefer-const': 'error',
            eqeqeq: ['error', 'always'],
            curly: ['error', 'all'],
        },
    },

    // ==================== TypeScript 配置 ====================
    {
        files: ['**/*.{ts,tsx}'],
        languageOptions: {
            parser: tsparser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
                // 使用应用和 Node 两个 tsconfig,提升类型检查覆盖面
                project: ['./tsconfig.app.json', './tsconfig.node.json'],
            },
            globals: {
                ...globals.browser,
                ...globals.node,
                ...globals.es2020,
            },
        },
        plugins: {
            '@typescript-eslint': tseslint,
        },
        rules: {
            ...tseslint.configs.recommended.rules,

            // TypeScript 特定规则
            '@typescript-eslint/no-explicit-any': 'warn',
            '@typescript-eslint/no-unused-vars': [
                'error',
                {
                    argsIgnorePattern: '^_',
                    varsIgnorePattern: '^_',
                    caughtErrorsIgnorePattern: '^_',
                },
            ],
            '@typescript-eslint/ban-ts-comment': 'warn',
            '@typescript-eslint/no-empty-function': 'warn',
            '@typescript-eslint/no-non-null-assertion': 'warn',
            '@typescript-eslint/explicit-function-return-type': 'off',
            '@typescript-eslint/explicit-module-boundary-types': 'off',
            '@typescript-eslint/no-inferrable-types': 'warn',
            '@typescript-eslint/consistent-type-imports': [
                'warn',
                {
                    prefer: 'type-imports',
                    disallowTypeAnnotations: false,
                },
            ],
        },
    },

    // ==================== Vue 3 文件配置 ====================
    {
        files: ['**/*.vue'],
        plugins: {
            vue: vuePlugin,
        },
        languageOptions: {
            // 使用官方 Vue 解析器对象,支持 <template> + <script setup>
            parser: vueParser,
            parserOptions: {
                parser: tsparser,
                ecmaVersion: 'latest',
                sourceType: 'module',
                extraFileExtensions: ['.vue'],
                project: ['./tsconfig.app.json', './tsconfig.node.json'],
            },
            globals: {
                ...globals.browser,
            },
        },
        rules: {
            // 继承 Vue 3 推荐规则
            ...vuePlugin.configs['vue3-recommended'].rules,

            // ========== Vue 3 自定义规则 ==========
            // 1. 组件命名规则(针对 Vue 3 单文件组件)
            'vue/multi-word-component-names': [
                'error',
                {
                    ignores: [
                        'index', // index.vue
                        'App', // App.vue
                        '404', // 404.vue
                        '[id]', // 动态路由组件
                        '[...all]', // 动态路由组件
                        'Layout', // Layout.vue
                        'Default', // Default.vue
                        'Main', // Main.vue
                    ],
                },
            ],

            // 2. 组件属性换行规则(针对 Ant Design Vue 属性多的特点)
            'vue/max-attributes-per-line': [
                'error',
                {
                    singleline: 5, // Ant Design 组件通常属性较多,放宽到5个
                    multiline: {
                        max: 1,
                    },
                },
            ],

            // 3. Ant Design Vue 组件名特殊处理(关键配置)
            'vue/component-name-in-template-casing': [
                'error',
                'PascalCase',
                {
                    registeredComponentsOnly: false,
                    ignores: [
                        // Ant Design Vue 组件前缀 (a-)
                        '/^a-/', // a-button, a-input, a-modal
                        '/^A[A-Z]/', // AButton, AInput
                        // Vue 内置组件
                        'router-view',
                        'router-link',
                        'transition',
                        'transition-group',
                        'keep-alive',
                        'component',
                        'slot',
                        'template',
                        // 常见第三方组件
                        'icon',
                        'icons',
                    ],
                },
            ],

            // 4. 其他 Vue 规则调整
            'vue/require-default-prop': 'off', // 不要求必须默认值
            'vue/no-v-html': 'warn', // 警告使用 v-html
            'vue/prop-name-casing': ['error', 'camelCase'], // props 使用驼峰
            'vue/attribute-hyphenation': ['error', 'always'], // 属性使用连字符

            // 5. 模板内容换行
            'vue/html-closing-bracket-newline': [
                'error',
                {
                    singleline: 'never',
                    multiline: 'always',
                },
            ],

            // 7. 顺序规则(可选,使代码更整洁)
            'vue/attributes-order': [
                'error',
                {
                    order: [
                        'DEFINITION', // is, v-is
                        'LIST_RENDERING', // v-for
                        'CONDITIONALS', // v-if, v-else-if, v-else, v-show, v-cloak
                        'RENDER_MODIFIERS', // v-once, v-pre
                        'GLOBAL', // id
                        'UNIQUE', // ref, key, v-slot, v-model
                        'SLOT', // v-slot
                        'TWO_WAY_BINDING', // v-model
                        'OTHER_DIRECTIVES', // v-custom-directive
                        'OTHER_ATTR', // 其他属性
                        'EVENTS', // v-on
                        'CONTENT', // v-text, v-html
                    ],
                },
            ],
        },
    },

    // ==================== 测试文件特殊配置 ====================
    {
        files: ['**/__tests__/**/*.{js,ts,vue}', '**/*.test.{js,ts,vue}', '**/*.spec.{js,ts,vue}'],
        plugins: {
            '@typescript-eslint': tseslint,
        },
        languageOptions: {
            parser: tsparser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                // 使用 Vitest 全局变量
                ...globals.vitest,
            },
        },
        rules: {
            'no-console': 'off',
            'no-debugger': 'off',
            '@typescript-eslint/no-explicit-any': 'off',
            '@typescript-eslint/no-non-null-assertion': 'off',
        },
    },

    // ==================== 配置文件特殊处理 ====================
    {
        files: ['**/vite.config.{js,ts}', '**/vitest.config.{js,ts}', '**/eslint.config.{js,mjs}', '**/*.config.{js,ts}'],
        plugins: {
            '@typescript-eslint': tseslint,
        },
        languageOptions: {
            parser: tsparser,
            parserOptions: {
                ecmaVersion: 'latest',
                sourceType: 'module',
            },
            globals: {
                ...globals.node,
            },
        },
        rules: {
            'no-console': 'off',
            '@typescript-eslint/no-unused-vars': 'warn',
        },
    },
];

全局忽略配置

  • ignores:全局忽略的目录和文件模式,如:
    • **/node_modules/****/dist/****/build/**:依赖与构建输出目录。
    • **/coverage/**:测试覆盖率报告。
    • **/public/**:静态资源目录。
    • **/*.min.js**/*.d.ts:压缩 JS 与类型声明文件。
    • 各类锁文件和 *.config.d.ts 类型声明文件等。

JavaScript 通用配置

  • files:**/*.{js,mjs,cjs}
    • 对所有 JS 文件启用该配置。
  • languageOptions:
    • ecmaVersion:latest,使用最新 ECMAScript 语法。
    • sourceType:module,按 ES Module 解析。
    • globals:合并 browsernodees2020 全局变量,避免误报。
  • rules:
    • ...js.configs.recommended.rules:继承官方 ESLint 推荐规则。
    • no-console:生产环境下仅允许 console.warn / console.error,开发环境关闭。
    • no-debugger:生产环境警告,开发环境关闭。
    • no-alert:使用 alert 时给出警告。
    • no-unused-vars:关闭,由 TypeScript 规则接管。
    • prefer-const:推荐使用 const。
    • eqeqeq:强制使用 === / !==
    • curly:要求所有控制语句使用大括号。

TypeScript 配置

  • files:**/*.{ts,tsx}
  • languageOptions:
    • parser:@typescript-eslint/parser,支持 TS 语法。
    • parserOptions.project:./tsconfig.app.json, ./tsconfig.node.json,启用基于项目的类型信息检查。
    • globals:同样合并 browser/node/es2020 全局。
  • plugins:
    • @typescript-eslint:启用 TS 专用规则。
  • rules(节选):
    • 基于 @typescript-eslint 官方 recommended 规则。
    • @typescript-eslint/no-explicit-any:对 any 给出警告。
    • @typescript-eslint/no-unused-vars:检查未使用变量,可通过 _ 前缀忽略。
    • @typescript-eslint/ban-ts-comment:限制 // @ts-ignore 等用法。
    • @typescript-eslint/no-non-null-assertion:对 ! 非空断言警告。
    • @typescript-eslint/explicit-function-return-type:关闭强制显式返回类型。
    • @typescript-eslint/consistent-type-imports:推荐使用 type 导入形式。

Vue 3 文件配置

  • files:**/*.vue
  • plugins:
    • vue:Vue 官方 ESLint 插件。
  • languageOptions:
    • parser:vue-eslint-parser,支持 <template> + <script setup>
    • parserOptions.parser:内部再使用 TS 解析器,支持 TypeScript。
    • parserOptions.project:同样引用 tsconfig.app.jsontsconfig.node.json
  • rules(节选):
    • ...vuePlugin.configs['vue3-recommended'].rules:继承 Vue3 推荐规则集。
    • vue/multi-word-component-names:强制组件名多词,忽略特定名称(如 App、Layout、index 等)。
    • vue/max-attributes-per-line:单行最多 5 个属性,多行时每行 1 个,方便 Ant Design Vue 组件阅读。
    • vue/component-name-in-template-casing:模板中组件名强制 PascalCase,但对 a-button 等 Ant Design 组件和部分内置组件放宽。
    • vue/require-default-prop:关闭 props 强制默认值。
    • vue/no-v-html:对 v-html 给出警告。
    • vue/prop-name-casing:props 必须使用 camelCase。
    • vue/attribute-hyphenation:模板属性使用连字符形式。
    • vue/html-closing-bracket-newline:多行标签关闭时必须换行。
    • vue/attributes-order:规范属性书写顺序(如定义、条件、事件等)。

测试文件特殊配置

  • files:
    • **/__tests__/**/*.{js,ts,vue}
    • **/*.test.{js,ts,vue}
    • **/*.spec.{js,ts,vue}
  • languageOptions.globals:
    • 使用 globals.vitest,注入 Vitest 的全局(如 describeitexpect 等)。
  • rules:
    • no-console / no-debugger:在测试中关闭限制。
    • 放宽 TypeScript 关于 any 和非空断言的限制,方便编写测试用例。

配置文件自身的特殊处理

  • files:vite.config.{js,ts}vitest.config.{js,ts}eslint.config.{js,mjs} 以及 *.config.{js,ts}
  • languageOptions:仅使用 Node 环境的全局变量。
  • rules:
    • no-console:关闭,允许在配置文件中打印调试信息。
    • @typescript-eslint/no-unused-vars:降级为 warn,避免轻微未使用变量导致出错。

.prettierrc代码自动格式化

本项目使用 Prettier 统一代码格式,配置文件为 .prettierrc,并通过 npm script 与 lint-staged 集成,在保存/提交时代码会被自动格式化。

//.prettierrc
{
    "printWidth": 150,
    "tabWidth": 4,
    "useTabs": false,
    "semi": true,
    "singleQuote": true,
    "trailingComma": "es5",
    "bracketSpacing": true,
    "arrowParens": "avoid",
    "vueIndentScriptAndStyle": true,
    "htmlWhitespaceSensitivity": "css",
    "endOfLine": "lf"
}

通过vscode插件Prettier - Code formatter对代码进行自动格式化。能够更专注于代码逻辑书写。

.prettierrc 关键选项

  • printWidth:150
    • 每行最大字符数,超过会自动换行。
  • tabWidth:4
    • 一个缩进级别使用 4 个空格。
  • useTabs:false
    • 使用空格而不是制表符进行缩进。
  • semi:true
    • 语句末尾总是添加分号。
  • singleQuote:true
    • 使用单引号代替双引号。
  • trailingComma:"es5"
    • 在 ES5 允许的地方(对象、数组等)尽量保留尾随逗号。
  • bracketSpacing:true
    • 对象字面量的大括号两侧保留空格,例如 { foo: bar }
  • arrowParens:"avoid"
    • 能省略箭头函数参数括号时就省略,例如 x => x + 1
  • vueIndentScriptAndStyle:true
    • 在 .vue 文件中对 <script><style> 内容进行缩进。
  • htmlWhitespaceSensitivity:"css"
    • 按 CSS 的规则处理 HTML 空白字符,避免过度压缩影响布局。
  • endOfLine:"lf"
    • 统一使用 LF 换行符,有利于跨平台一致性。

其他常用配置(可选)

  • singleAttributePerLine
    • 默认:false
    • 作用:在 HTML / Vue / JSX 标签中,每个属性是否独占一行。属性较多的组件,设为 true 可读性更好,但文件会更长。
  • jsxSingleQuote
    • 默认:false
    • 作用:控制 JSX/TSX 内是否也使用单引号。若项目希望“所有地方都统一用单引号”,可以设为 true
  • quoteProps
    • 默认:"as-needed"
    • 常用值:"as-needed"(默认,仅在需要时加引号)、"consistent"(同一对象内保持一致)、"preserve"(保留原样)。
    • 作用:控制对象属性名(key)是否加引号,可根据团队对 JSON/对象风格的偏好统一约定。
  • bracketSameLine
    • 默认:false
    • 作用:控制多行 JSX/HTML 标签的闭合 > 是否与最后一行内容在同一行。不同团队习惯不同,可按团队偏好统一。
  • proseWrap
    • 默认:"preserve"
    • 常用值:"always""never""preserve"
    • 作用:控制 Markdown 文本是否在 printWidth 处自动换行。文档较多的项目,若希望 diff 更细致、行宽统一,可考虑设为 "always"
  • embeddedLanguageFormatting
    • 默认:"auto"
    • 常用值:"auto""off"
    • 作用:是否格式化字符串模板或文件中嵌入的代码块(如 Markdown 里的代码块、内联脚本等)。若不希望被自动改动,可设为 "off"
  • requirePragma
    • 默认:false
    • 作用:只有在文件头部包含特定注释(例如 @format)时才会被 Prettier 格式化。通常用于大型旧项目的“渐进式接入”。
  • insertPragma
    • 默认:false
    • 作用:在被 Prettier 格式化过的文件头部自动插入 @format 注释,常配合 requirePragma 使用。
  • rangeStart / rangeEnd
    • 默认:0 / Infinity
    • 作用:仅格式化文件的某一段范围,一般通过 CLI 或编辑器集成设置,适用于“只格式化选中区域”的场景。
  • overrides
    • 类型:数组
    • 作用:按文件匹配规则(files / excludeFiles)为不同类型文件指定不同的 Prettier 配置,例如:
      • *.md 使用不同的 printWidthproseWrap
      • *.json 关闭某些影响可读性的规则等。
  • plugins
    • 类型:数组
    • 作用:引入第三方 Prettier 插件,例如:
      • 排序 import、属性或 Tailwind 类名;
      • 支持额外的语法/语言。
    • 仅在确有需求时再引入,避免增加不必要的依赖和格式化开销。

与脚本命令的关系

  • format 脚本:prettier --write .
    • 手动运行时,会对整个项目文件执行一次格式化。

与 ESLint / Stylelint / lint-staged 的协同

  • .lintstagedrc 中:
    • *.{js,ts,vue} 文件:先用 eslint --cache --fix --max-warnings=0 再用 prettier --write,先修复语法/风格问题,再统一格式。
    • *.{css,less,scss}:先用 stylelint --cache --fix 检查样式规范,再用 Prettier 格式化。
    • *.{json,md,html,yml,yaml}:直接使用 prettier --write 进行格式化。
  • 这样可以保证:
    • ESLint/Stylelint 负责“代码/样式是否合理、有没有问题”;
    • Prettier 负责“长什么样、缩进和空格如何对齐”。

typescript 配置

本项目使用多层 tsconfig 管理不同环境和用途的 TypeScript 配置:

  • tsconfig.json:顶层工程引用文件。
  • tsconfig.app.json:应用源码相关配置。
  • tsconfig.node.json:Node 环境下的 TS 配置(主要用于 vite.config.ts、vitest.config.ts 等)。

tsconfig.json

{
    "files": [],
    "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }],
}
  • files:空数组
    • 顶层不直接编译任何文件,仅作为引用入口。
  • references:
    • 引用 tsconfig.app.jsontsconfig.node.json,组成 TS 的多工程(project references)结构,便于增量构建和工具支持。

tsconfig.app.json

{
    "extends": "@vue/tsconfig/tsconfig.dom.json",
    "compilerOptions": {
        "target": "ES2020",
        "useDefineForClassFields": true,
        "lib": ["ES2020", "DOM", "DOM.Iterable"],
        "module": "ESNext",
        "skipLibCheck": true,
        "moduleResolution": "bundler",
        "allowImportingTsExtensions": true,
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "preserve",
        "strict": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "noFallthroughCasesInSwitch": true,
        "baseUrl": ".",
        "paths": {
            "@/*": ["src/*"]
        }
    },
    "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
    "references": [
        {
            "path": "./tsconfig.node.json"
        }
    ]
}
extends
  • "@vue/tsconfig/tsconfig.dom.json"
    • 基于 Vue 官方推荐的 DOM 环境配置,自动包含适合 Vue 3 Web 应用的编译选项和 lib 设置。
compilerOptions(节选)
  • target:"ES2020"
    • 输出目标为 ES2020,支持较新的 JS 特性。
  • useDefineForClassFields:true
    • 使用符合 TC39 标准的类字段语义。
  • lib:["ES2020", "DOM", "DOM.Iterable"]
    • 包含 ES2020 和 DOM 相关的类型声明。
  • module:"ESNext"
    • 使用 ESNext 模块系统,交给打包工具处理。
  • skipLibCheck:true
    • 跳过库声明文件的类型检查,提高编译速度。
  • moduleResolution:"bundler"
    • 使用适合打包工具(如 Vite)的模块解析策略。
  • allowImportingTsExtensions:true
    • 允许显式导入 .ts 扩展名文件。
  • resolveJsonModule:true
    • 允许导入 JSON 文件,并生成对应的类型。
  • isolatedModules:true
    • 强制每个文件都可单独编译,有利于配合 Babel/Vite 使用。
  • noEmit:true
    • 不输出编译结果文件,仅做类型检查。
  • jsx:"preserve"
    • 保留 JSX,交给后续工具处理(如果使用 JSX/TSX)。
  • strict:true
    • 开启严格模式,包含多项严格类型检查选项。
  • noUnusedLocals / noUnusedParameters:true
    • 禁止未使用的本地变量和参数。
  • noFallthroughCasesInSwitch:true
    • 阻止 switch 语句的 case 贯穿错误。
  • baseUrl:"."
    • 以项目根目录为基础路径。
  • paths:
    • "@/*": ["src/*"]
    • 对应 Vite 中的 @ 别名,使 TS 能理解 @/xxx 导入路径。
include
  • "src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"
    • 指明应用代码中需要被 TS 类型系统分析的文件范围。
references
  • 引用 ./tsconfig.node.json
    • 让应用配置依赖 Node 配置,便于统一工程结构和增量编译。

tsconfig.node.json

{
    "compilerOptions": {
        "composite": true,
        "skipLibCheck": true,
        "module": "ESNext",
        "moduleResolution": "bundler",
        "allowSyntheticDefaultImports": true,
        "strict": true
    },
    "include": ["vite.config.ts", "vitest.config.ts"]
}
compilerOptions
  • composite:true
    • 表明该配置参与 TS 的工程引用(project references),允许生成增量信息。
  • skipLibCheck:true
    • 跳过声明文件检查,加快编译。
  • module:"ESNext"
    • 使用 ESNext 模块系统。
  • moduleResolution:"bundler"
    • 适配 Vite 等打包工具的解析方式。
  • allowSyntheticDefaultImports:true
    • 允许对仅有 export = 的模块使用默认导入,兼容 CommonJS 包。
  • strict:true
    • 同样开启严格类型检查。
include
  • ["vite.config.ts", "vitest.config.ts"]
    • 仅对这两个 Node 环境运行的配置文件进行类型检查,保证其类型安全和智能提示。

TypeScript 工具链与脚本(补充)

vue-tsc

- 专门针对 Vue 3 项目(包括 `.vue` 文件)进行类型检查的工具。
- 在 package.json 中通过脚本:
    - `type-check`: `vue-tsc -b`
    - `build`: `vue-tsc -b && vite build`
- 先基于 tsconfig 工程配置做一遍完整类型检查,再进入 Vite 构建流程,避免类型错误进入打包阶段。

ESLint + @typescript-eslint/*

- ESLint 使用 `@typescript-eslint/parser``@typescript-eslint/eslint-plugin` 读取 tsconfig 中的编译选项和类型信息,对 TS/TSX 代码做更精细的规则检查。
- 通过 parserOptions.project 指向 `tsconfig.app.json` / `tsconfig.node.json`,确保类型感知规则(如 no-unused-vars、no-explicit-any 等)能够发挥作用。

Vitest 与 TS: - vitest.config.ts 通过 mergeConfig 复用 Vite 配置,使测试文件也能使用同样的别名与 TS 配置。 - 测试代码本身的类型检查依托于上述 tsconfig 和 vue-tsc/TypeScript 工具链。

Git Hooks & 提交规范配置说明

本项目通过 Husky、lint-staged 和 Commitlint 组成一套 Git 提交前检查与提交消息规范校验流程。

Husky 目录结构

  • .husky/pre-commit
    • 提交前(pre-commit hook)执行。
  • .husky/commit-msg
    • 输入提交信息后、真正写入提交之前执行。

package.json 中:

  • "prepare": "husky"
    • 安装依赖后会自动初始化 Husky(创建 .husky 目录),确保 Git Hooks 生效。

pre-commit 钩子:代码质量检查

文件:.husky/pre-commit

内容:

# 严格模式:提交前必须通过 lint-staged 检查
pnpm lint-staged

含义:

  • 在执行 git commit 时,只对暂存区(staged)的文件运行 lint-staged。
  • 如果 lint-staged 中配置的命令有失败,则本次提交会被中断,强制开发者先修复问题。

lint-staged 配置:只检查改动文件

文件:.lintstagedrc

核心规则:

{
    "*.{js,ts}": ["eslint --cache --fix --max-warnings=0", "prettier --write"],
    "*.vue": ["eslint --cache --fix --max-warnings=0", "prettier --write"],
    "*.{css,less,scss}": ["stylelint --cache --fix", "prettier --write"],
    "*.{json,md,html,yml,yaml}": "prettier --write",
    "src/**/components/**/*.{vue,js,ts}": ["eslint --cache --fix --max-warnings=5"],
    "src/**/antd/**/*.{vue,js,ts}": ["eslint --cache --fix --max-warnings=5"],
}

说明:

  • *.{js,ts} / *.vue
    • 先使用 ESLint(带缓存、自动修复、并将允许的 warning 数量控制为 0)。
    • 再使用 Prettier 统一代码格式。
  • *.{css,less,scss}
    • 使用 Stylelint 进行样式规范检查并自动修复,然后交给 Prettier 统一格式。
  • *.{json,md,html,yml,yaml}
    • 仅用 Prettier 进行格式化,保证缩进与风格统一。
  • src/**/components/**/*.{vue,js,ts}src/**/antd/**/*.{vue,js,ts}
    • 对关键目录(组件、Ant Design 相关目录)额外运行一次 ESLint,并允许少量 warning(max-warnings=5),强调这里的代码质量。

commit-msg 钩子:提交信息规范

文件:.husky/commit-msg

内容:

# 严格模式:提交信息必须符合规范,否则中断提交
pnpm commitlint --edit "$1"

含义:

  • 在编写完 commit message 后,使用 Commitlint 对提交信息进行校验。
  • 如果不符合规范(例如类型不合法、描述太短等),提交会被中止,需要修改提交说明后重试。

Commitlint 规则

文件:.commitlintrc.cjs

核心规则:

// .commitlintrc.cjs
module.exports = {
    extends: ['@commitlint/config-conventional'],

    rules: {
        // 1. 提交类型(必需)
        'type-enum': [
            2,
            'always',
            [
                'feat', // 新功能
                'fix', // Bug修复
                'docs', // 文档更新
                'style', // 代码格式(空格、分号等,不影响功能)
                'refactor', // 重构(既不是新功能也不是bug修复)
                'test', // 测试相关
                'chore', // 构建过程或辅助工具变动
                'perf', // 性能优化
                'build', // 构建系统或外部依赖变更
                'ci', // CI配置变更
                'revert', // 回滚提交
                'other', // 其他类型
            ],
        ],

        // 2. 类型必须小写
        'type-case': [2, 'always', 'lower-case'],

        // 3. 类型不能为空
        'type-empty': [2, 'never'],

        // 4. 主题(描述)不能为空
        'subject-empty': [2, 'never'],

        // 5. 主题不以句号结尾
        'subject-full-stop': [2, 'never', '.'],

        // 6. 主题最少3个字符
        'subject-min-length': [2, 'always', 3],

        // 7. 主题最多100个字符(建议一行能显示完整)
        'subject-max-length': [2, 'always', 100],

        // 8. 作用域(可选)
        'scope-enum': [
            2,
            'always',
            [
                'component', // 组件
                'page', // 页面
                'layout', // 布局
                'router', // 路由
                'store', // 状态管理(Pinia)
                'api', // API接口
                'utils', // 工具函数
                'styles', // 样式
                'types', // TypeScript类型
                'config', // 配置
                'deps', // 依赖更新
                'other', // 其他(不属于以上分类的提交)
            ],
        ],
    },
};

关键点:

  • extends:['@commitlint/config-conventional']

    • 基于社区常用的 Conventional Commits 规范。
  • type-enum

    • 限制可用的提交类型,例如:featfixdocsstylerefactortestchoreperfbuildcirevertother 等。
  • type-case / type-empty

    • 类型必须小写,且不能为空。
  • subject-empty / subject-min-length / subject-max-length

    • 提交描述必填,长度在 3~100 字符之间,且不允许以句号结尾(subject-full-stop 规则)。
  • scope-enum

    • 可选的作用域列表,如 componentpagelayoutrouterstoreapiutilsstylestypesconfigdepsother 等,帮助约束“这个提交主要改了哪一类东西”。

日常使用建议

  • 开发过程中:
    • 经常本地执行 pnpm lintpnpm format 保持代码整洁。
  • 提交代码时:
    • 按照约定的格式书写提交信息,例如:
      • feat(component): 新增用户列表组件
      • fix(api): 修复登录接口返回值解析错误
    • pre-commit 和 commit-msg 钩子会自动帮你做最后一层把关。

postcss.config.cjs 配置说明

PostCSS 用于在构建过程中对 CSS 进行各种自动化处理。本配置主要启用了 Tailwind CSS、Autoprefixer 以及在生产环境使用的 cssnano 压缩。

plugins 插件配置

// postcss.config.cjs
module.exports = {
    plugins: {
        // Tailwind CSS 插件
        tailwindcss: {
            config: './tailwind.config.js', // 指定 Tailwind 配置文件路径
        },

        // Autoprefixer 自动添加浏览器前缀
        autoprefixer: {
            overrideBrowserslist: [
                'last 2 versions', // 支持最近2个版本的浏览器
                '> 1%', // 全球使用率 > 1% 的浏览器
                'ios >= 8', // iOS 8+
                'android >= 4.4', // Android 4.4+
                'not ie <= 11', // 不支持 IE 11 及以下
                'not dead', // 不包含已死亡的浏览器
            ],
            grid: true, // 为 IE 启用 CSS Grid 前缀
            flexbox: true, // 为旧版浏览器添加 Flexbox 前缀
            remove: false, // 不删除过时的前缀
        },

        // 可选:CSS 压缩(生产环境)
        ...(process.env.NODE_ENV === 'production'
            ? {
                  cssnano: {
                      preset: [
                          'default',
                          {
                              discardComments: { removeAll: true }, // 删除所有注释
                              normalizeWhitespace: false, // 不压缩空格(由构建工具处理)
                          },
                      ],
                  },
              }
            : {}),
    },
};
tailwindcss
  • 作用:启用 Tailwind CSS,按需生成原子化工具类样式。
  • config:'./tailwind.config.js'
    • 指定 Tailwind 的配置文件路径,统一管理扫描范围、主题颜色等。
autoprefixer
  • 作用:自动为 CSS 添加浏览器前缀,提升兼容性。
  • overrideBrowserslist:浏览器兼容策略列表:
    • last 2 versions:支持最近 2 个版本的各主流浏览器。
    • > 1%:全球使用率大于 1% 的浏览器。
    • ios >= 8:支持 iOS 8 及以上版本。
    • android >= 4.4:支持 Android 4.4 及以上版本。
    • not ie <= 11:排除 IE11 及以下版本。
    • not dead:排除已经停止维护的“死亡”浏览器。
  • grid:true
    • 为 IE 等浏览器添加 CSS Grid 前缀。
  • flexbox:true
    • 为旧版浏览器添加 Flexbox 前缀。
  • remove:false
    • 不删除已有的旧前缀,避免影响兼容性。
cssnano(仅生产环境)
  • 作用:在生产环境中对 CSS 进行压缩和优化,减小包体积。
  • 条件启用:process.env.NODE_ENV === 'production' 时才添加该插件。
  • preset:
    • 'default':使用 cssnano 默认优化策略。
    • 配置对象:
      • discardComments.removeAll:true,删除所有 CSS 注释。
      • normalizeWhitespace:false,不在此处压缩空白字符(交由构建工具处理)。

总结

  • 开发环境:Tailwind + Autoprefixer,便于快速开发与兼容性处理。
  • 生产环境:在上述基础上额外启用 cssnano,进一步压缩 CSS 提高加载性能。

tailwind.config.js 配置说明

该文件定义了 Tailwind CSS 的扫描范围、主题扩展以及与 Ant Design Vue 的配合策略。

// tailwind.config.js(精简版)
module.exports = {
    content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],

    theme: {
        extend: {
            // Ant Design 主题颜色
            colors: {
                primary: '#1890ff',
                success: '#52c41a',
                warning: '#faad14',
                error: '#f5222d',
                info: '#13c2c2',
            },

            // 字体
            fontFamily: {
                sans: ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif'],
            },

            // 圆角
            borderRadius: {
                ant: '6px',
            },

            // 阴影
            boxShadow: {
                ant: '0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05)',
            },
        },

        // 响应式断点
        screens: {
            xs: '480px',
            sm: '640px',
            md: '768px',
            lg: '1024px',
            xl: '1280px',
            '2xl': '1536px',
        },
    },

    // 关键:禁用 preflight 避免与 Ant Design 冲突
    corePlugins: {
        preflight: false,
    },

    plugins: [],
};

content 扫描范围

  • ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}']
    • Tailwind 会在这些文件中扫描类名,只生成实际用到的工具类,减少最终 CSS 体积。

theme 主题配置

extend 扩展主题
  • colors:

    • primary:#1890ff
    • success:#52c41a
    • warning:#faad14
    • error:#f5222d
    • info:#13c2c2
    • 这些颜色与 Ant Design 的主题色保持一致,方便统一 UI 风格。
  • fontFamily.sans:

    • ['-apple-system', 'BlinkMacSystemFont', '"Segoe UI"', 'Roboto', '"Helvetica Neue"', 'Arial', 'sans-serif']
    • 定义无衬线字体的优先级列表,提升跨平台字体一致性。
  • borderRadius.ant:

    • 6px,用于配合 Ant Design 的默认圆角风格,可在项目中通过自定义类统一使用。
  • boxShadow.ant:

    • 一组与 Ant Design 近似的阴影配置,使自定义元素与 AntD 组件视觉统一。
screens 断点定义
  • xs:480px
  • sm:640px
  • md:768px
  • lg:1024px
  • xl:1280px
  • 2xl:1536px

这些断点用于响应式布局,如 md:w-1/2 表示在 md 及以上宽度时占一半宽度。

corePlugins 内置插件控制

  • preflight:false
    • 关闭 Tailwind 的预设 CSS 重置(preflight),以避免与 Ant Design 自身的样式重置产生冲突。
    • 通过此设置,可以让 Ant Design 的默认样式在项目中保持预期行为。

plugins 自定义插件

  • 当前为 [](空数组)。
    • 需要时可以在此添加社区或自定义的 Tailwind 插件,例如表单、美化滚动条等。

vitest.config.ts 配置说明

Vitest 是与 Vite 深度集成的测试框架。本配置基于 Vite 配置进行扩展,使测试环境与实际构建环境保持一致。

import { mergeConfig } from 'vitest/config';
import viteConfig from './vite.config';

export default mergeConfig(viteConfig, {
    test: {
        globals: true,
        environment: 'happy-dom',
        include: ['src/**/*.{test,spec}.{js,ts,vue}'],
    },
});

mergeConfig 与基础配置

  • import { mergeConfig } from 'vitest/config'
    • 用于在 Vitest 配置中复用并扩展 Vite 的配置。
  • import viteConfig from './vite.config'
    • 引入项目的 Vite 配置,保证测试环境的别名、插件等与开发/构建保持一致。
  • export default mergeConfig(viteConfig, { ... })
    • 使用 mergeConfig 将 Vitest 相关配置与 Vite 基础配置合并。

test 选项

  • globals:true
    • 启用全局测试 API,如 describeitexpect 等,无需手动 import。
  • environment:'happy-dom'
    • 使用 happy-dom 提供类似浏览器的 DOM 环境,适合测试 Vue 组件与涉及 DOM 操作的逻辑。
  • include:['src/**/*.{test,spec}.{js,ts,vue}']
    • 指定测试文件匹配模式:
      • 位于 src 目录及其子目录中。
      • 文件名包含 .test..spec.
      • 支持 js/ts/vue 等扩展名。

与 ESLint 中测试配置的关系

  • ESLint 的测试规则块会为这些测试文件注入 Vitest 的全局变量,防止 describe 等被报未定义。
  • Vitest 配置中的 include 对测试执行范围负责,两者配合保证测试既能正确运行又能通过 lint 检查。

项目目录结构说明

本文档说明本项目主要目录的作用和推荐使用方式,便于团队成员快速理解和扩展。

目录结构总览

├─ src/
│  ├─ assets/
│  │  ├─ images/
│  │  └─ styles/
│  ├─ components/
│  │  ├─ common/
│  │  └─ business/
│  ├─ layouts/
│  ├─ router/
│  │  └─ modules/
│  ├─ services/
│  │  └─ modules/
│  ├─ stores/
│  │  └─ modules/
│  ├─ hooks/
│  ├─ utils/
│  ├─ types/
│  ├─ constants/
│  ├─ tests/
│  │  ├─ unit/
│  │  └─ components/
│  ├─ App.vue
│  └─ main.ts
├─ public/
├─ doc/
│  ├─ project-structure.md
│  └─ ...(其他配置/规范文档)
├─ package.json
├─ vite.config.ts / vite.config.js
├─ vitest.config.ts / vitest.config.js
├─ tsconfig*.json
├─ eslint.config.js
├─ tailwind.config.js
└─ .prettierrc

根目录

  • src/
    • 前端应用的主要源码目录。
  • public/
    • 静态公共资源目录,打包时会原样拷贝到构建结果中。
  • doc/
    • 项目文档目录(工程规范、配置说明、目录结构等)。

src 目录

  • src/main.ts
    • 应用入口文件,创建 Vue 应用实例,注册路由、状态管理、全局组件等。
  • src/App.vue
    • 根组件,一般只负责基本布局容器和路由出口等。
  • src/assets/
    • 静态资源:图片、图标、全局样式等。
    • 建议按类型或业务拆分子目录,例如:images/styles/ 等。
  • src/components/
    • 可复用的通用组件。
    • 可再划分:common/(基础通用)、business/(跨页面业务组件)等。
  • src/views/
    • 页面级组件(通常与路由一一对应)。
    • 每个页面一个目录,内部放该页面的子组件,例如:views/user/List.vueviews/user/components/UserTable.vue
  • src/layouts/
    • 布局组件:如后台管理系统的主框架(侧边栏 + 顶栏 + 内容区)、登录页布局等。
    • 常见约定:DefaultLayout.vueAuthLayout.vue 等。
  • src/router/
    • 路由相关配置。
    • 一般包含:index.ts(创建 router 实例)、按模块拆分的路由配置文件(如 modules/user.ts)。
  • src/stores/
    • 状态管理(例如 Pinia)相关的 store 定义。
    • 每个业务领域一个 store 文件,如:userStore.tsappStore.ts 等。
  • src/services/
    • 与后端交互的服务层代码(API 请求封装)。
    • 推荐:
      • request.ts:封装 Axios/Fetch,统一处理请求、响应、错误。
      • 按业务模块拆分 API 文件,如:user.tsauth.tssystem.ts
  • src/utils/
    • 工具函数库,纯函数、与业务相对无关的通用逻辑。
    • 可按功能再拆分:date.tsformat.tsvalidator.ts 等。
  • src/hooks/
    • 组合式函数(Composition API Hooks),抽离可复用的状态逻辑。
    • 命名建议以 use 开头,例如:useRequestusePaginationuseDialog 等。
  • src/types/
    • TypeScript 类型定义,接口类型、全局类型、枚举等。
    • 可以按模块管理:user.d.tsauth.d.tsapi.d.ts 等。
  • src/constants/
    • 项目中用到的常量定义,如枚举值、字典、配置项、路由名称常量等。
    • 例如:route-names.tsstorage-keys.tsbusiness.ts
  • src/tests/
    • 单元测试与组件测试目录,使用 Vitest 运行。
    • 推荐按类型再分:tests/unit/(工具函数、逻辑单元)和 tests/components/(组件相关测试)。
    • 测试文件命名建议:*.test.ts*.spec.ts,Vitest 已在 vitest.config.ts 中配置 include: ['src/**/*.{test,spec}.{js,ts,vue}'],会自动匹配。

常见子目录约定

以下为项目中推荐使用的一些二级子目录,实际可根据业务扩展或精简:

  • src/assets/images/
    • 存放图片、图标等静态资源,可按业务或功能再拆子目录。
  • src/assets/styles/
    • 全局样式、Tailwind 扩展、主题相关样式等。
  • src/components/common/
    • 基础通用组件,如按钮封装、表格封装、对话框封装等,可在多个业务模块中复用。
  • src/components/business/
    • 跨页面的业务组件,例如“用户选择器”、“部门树选择”等。
  • src/views/dashboard/
    • 仪表盘 / 概览页相关的页面组件。
  • src/router/modules/
    • 路由模块配置文件,按业务模块拆分,例如:system.tsuser.tsdashboard.ts
  • src/stores/modules/
    • 按业务模块拆分的 store 文件,例如:system.tsuser.tsapp.ts 等。
  • src/services/modules/
    • 按业务模块拆分的 API 服务文件,例如:system.tsuser.tsauth.ts 等。

约定与建议

  • 页面(views)和业务组件的目录结构,尽量与路由、业务模块保持一致,便于查找和重构。
  • 通用组件、hooks、utils、services 等尽量保持“可复用、低耦合”,避免将具体页面逻辑写进去。
  • 新增目录或模块时,优先考虑是否属于现有模块,尽量保持目录层级清晰、简洁。

别再手写 MethodChannel 了:Flutter Pigeon 工程级实践与架构设计

123.png

一、为什么 MethodChannel 在中大型项目里会失控?

每一个从 Native 转 Flutter 的开发者,大概都经历过这样的“至暗时刻”:

1.1 字符串 API 的不可维护性

你小心翼翼地在 Dart 端写下 invokeMethod("getUserInfo"),但 Android 同学在实现时写成了 getUserInfo (多了一个空格),或者 iOS 同学随手改成了 fetchUserInfo

  • 结果:编译期一片祥和,运行期直接 MissingPluginException 崩溃。
  • 本质:MethodChannel 是基于“字符串契约”的弱类型通信,它把风险全部推迟到了运行时。

1.2 多人协作时的“数据猜谜”

// Native 返回的数据
{
  "userId": 1001, // Android 传的是 Long
  "userId": "1001", // iOS 传的是 String
  "isActive": 0 // 到底是 bool 还是 int?
}

Flutter 端的解析代码充斥着大量的 dynamic 转换和防御性编程。一旦原生同学修改了某个字段名,Flutter 端没有任何感知,直到线上用户反馈 Bug。

1.3 Add-to-App 场景下的复杂度翻倍

当你进入混合开发(Add-to-App)深水区,面对多 FlutterEngine生命周期分离以及原生/Flutter 页面频繁跳转时,MethodChannel 这种“广播式”或“散乱式”的注册方式,会让代码逻辑像线团一样纠缠不清。

在 Demo 期,MethodChannel 是灵活的;在工程期,它是不可靠的。我们需要一种强契约方案。

二、Pigeon 是什么?它解决的不是“简化代码”,而是“契约问题”

Pigeon 是 Flutter 官方推出的代码生成工具,它的核心理念是 IDL(接口定义语言)

2.1 核心理念:契约驱动开

你不再需要手写 Dart 的 invokeMethod 和原生的 onMethodCall。你只需要写一个 Dart 抽象类(契约),Pigeon 就会为你生成:

  1. Dart 端 的调用代码。
  2. Android (Kotlin/Java) 的接口代码。
  3. iOS (Swift/ObjC) 的协议代码。
  4. C++ (Windows) 的头文件。

2.2 本质差异对比

维度 MethodChannel (手写) Pigeon (自动生成)
类型安全 ❌ 弱类型 (Map<String, dynamic>) 强类型 (Class/Enum)
编译期校验 ❌ 无,拼错字照样跑 ,参数不对直接报错
通信效率 ⚠️ 手动序列化可能有误 ✅ 使用 StandardMessageCodec 二进制传输
线程模型 ⚠️ 默认主线程 ✅ 支持 @TaskQueue 后台执行

注意:Pigeon 生成的通信代码属于内部实现细节,各平台必须使用同版本源码生成代码,否则可能出现运行时错误或数据序列化异常。

2.3 不仅仅是 RPC:拥抱类型安全的 Event Channel

很多人对 Pigeon 的印象还停留在“单次请求-响应(MethodChannel 替代品)”的阶段。但在较新的版本中,Pigeon 已经正式将版图扩张到了 Event Channel (流式通信)

在过去,当原生端需要向 Flutter 高频、持续地推送事件(例如:蓝牙状态监听、大文件下载进度、传感器数据)时,我们只能乖乖回去手写 EventChannel,并在 Dart 端痛苦地处理 Stream<dynamic>,强类型防线在此彻底崩溃。

现在,通过 Pigeon 的 @EventChannelApi() 注解或配合强类型回调,你可以直接生成带有类型签名的 Stream 接口。这意味着:原生端主动推送事件,也终于被纳入了编译期校验的保护伞下。

三、入门示例:3分钟完成一次重构

3.1 定义接口文件 (pigeons/device_api.dart)

import 'package:pigeon/pigeon.dart';

// 定义数据模型(DTO)
class DeviceInfo {
  String? systemVersion;
  String manufacturer;
  bool isTablet;
}

// 定义 Flutter 调用原生的接口
@HostApi()
abstract class DeviceHostApi {
  DeviceInfo getDeviceInfo();
  void vibrate(int durationMs);
}

// 定义 原生调用 Flutter 的接口
@FlutterApi()
abstract class DeviceFlutterApi {
  void onBatteryLow(int level);
}

3.2 生成代码

在终端运行(建议封装进 Makefile 或脚本):

dart run pigeon \
  --input pigeons/device_api.dart \
  --dart_out lib/api/device_api.g.dart \
  --kotlin_out android/app/src/main/kotlin/com/example/app/DeviceApi.g.kt \
  --kotlin_package "com.example.app" \
  --swift_out ios/Runner/DeviceApi.g.swift

3.3 接入(以 Kotlin 为例)

原生端不再需要处理 MethodCall 的 switch-case,而是直接实现接口:

// Android
class DeviceApiImpl : DeviceHostApi {
    override fun getDeviceInfo(): DeviceInfo {
        return DeviceInfo(manufacturer = "Samsung", isTablet = false)
    }
    override fun vibrate(durationMs: Long) {
        // 实现震动逻辑
    }
}

// 注册
DeviceHostApi.setUp(flutterEngine.dartExecutor.binaryMessenger, DeviceApiImpl())

四、工程级接口设计规范(核心价值)

如果你把 Pigeon 当作 MethodChannel 的语法糖,那你就低估了它。使用Pigeon 会迫使你进行架构思考。

4.1 Feature 分层设计:拒绝上帝类

错误做法:创建一个 AppApi,里面塞满了登录、支付、埋点、蓝牙等几十个方法。

推荐做法:按业务领域拆分文件和接口。

pigeons/
  ├── auth_api.dart    // 登录、Token管理
  ├── payment_api.dart // 支付、内购
  ├── trace_api.dart   // 埋点、日志
  └── system_api.dart  // 设备信息、权限

Pigeon 支持多输入文件,生成的代码也会自然解耦。这使得不同业务线的开发同事(如支付组 vs 基础组)可以并行开发,互不冲突。

4.2 DTO 设计原则:协议即文档

  • 严禁使用 Map:在 Pigeon 定义中,不要出现 Map<String, Object>。必须定义具体的 class
  • 善用 Enum:Pigeon 完美支持枚举。将状态码定义为 Enum,Android/iOS 端会自动生成对应的枚举类,彻底告别魔术数字(Magic Number)。(Pigeon 针对复杂泛型、递归数据结构支持有限,若 API 返回过于复杂结构,可以考虑在 DTO 层先做扁平化封装。)
  • 空安全(Null Safety)String?String 在生成的 Native 代码中会被严格区分(如 Kotlin 的 String? vs String,Swift 的 String? vs String)。这强制原生开发者处理空指针问题。

4.3 接口版本演进策略

中大型项目必然面临原生版本滞后于 Flutter 版本的情况(热更新场景)。

  • 原则只增不减

  • 策略

    1. 新增字段必须是 nullable 的。
    2. 废弃字段不要直接删除,而是标记注释,并在 Native 端做兼容处理。
    3. 如果改动极大,建议新建 ApiV2 接口,而不是修改 ApiV1

五、Pigeon 在 Add-to-App 架构中的最佳实践

5.1 多 FlutterEngine 场景

在混合开发中,你可能同时启动了两个 FlutterEngine(一个用于主页,一个用于详情页)。如果直接使用静态注册,会导致消息发错引擎。

关键解法:Scope to BinaryMessenger

Pigeon 生成的 setUp 方法第一个参数就是 BinaryMessenger

// Android: 为每个引擎单独注册实例
class MyActivity : FlutterActivity() {
    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        
        // 绑定当前引擎的 Messenger
        val apiImpl = MyFeatureApiImpl(context) 
        MyFeatureApi.setUp(flutterEngine.dartExecutor.binaryMessenger, apiImpl)
    }
}

通过这种方式,API 的实现实例与 Engine 的生命周期严格绑定,互不干扰。

5.2 避免内存泄漏

ActivityViewController 销毁时,切记要解绑:

override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
    // 传入 null 即可解绑,防止持有 Context 导致泄漏
    MyFeatureApi.setUp(flutterEngine.dartExecutor.binaryMessenger, null)
}

5.3 模块化项目结构建议

建议将 Pigeon 定义和生成代码单独抽取为一个 Package(例如 my_app_bridge)。

  • 好处:Native 工程和 Flutter 工程可以依赖同一个 Git Submodule 或私有 Pub 库,确保双方拿到的协议文件永远是一致的。

六、异常处理与错误模型设计

不要只返回 false,要抛出异常。

6.1 Pigeon 的 Error 机制

Pigeon 允许在 Native 端抛出特定的 Error,Flutter 端捕获为 PlatformException

Kotlin 端:

throw FlutterError("AUTH_ERROR", "Token expired", "Details...")

Dart 端:

try {
  await api.login();
} catch (e) {
  if (e is PlatformException && e.code == 'AUTH_ERROR') {
    // 处理 Token 过期
  }
}

6.2 统一错误模型

为了统一三端认知,建议在 Pigeon 里定义通用的 ErrorResult 包装类:

class ApiResult<T> {
  bool success;
  T? data;
  String? errorCode;
  String? errorMessage;
}

虽然这看起来稍微繁琐,但在大型 App 中,这能让原生和 Dart 拥有一套完全一致的错误码字典。


七、性能对比与关键优化

7.1 性能真相

很多开发者问:Pigeon 比 MethodChannel 快吗?

  • 传输层面两者一样快。底层都使用 StandardMessageCodec 进行二进制序列化。
  • 执行层面:Pigeon 省去了手动解析 Map 和类型转换的开销,这部分微小的 CPU 收益在数据量巨大时才明显。

7.2 杀手级特性:@TaskQueue (解决 UI 卡顿)

默认情况下,MethodChannel 的原生方法在 主线程 (Main Thread) 执行。如果你的 Native 方法涉及繁重的 I/O 或计算,会卡住 Flutter 的 UI 渲染。

Pigeon 支持 @TaskQueue 注解(Flutter 3.3+):

@HostApi()
abstract class HeavyWorkApi {
  @TaskQueue(type: TaskQueueType.serialBackgroundThread)
  String calculateHash(String heavyData);
}

加了这一行,原生代码会自动在后台线程执行,计算完后再回调主线程。这在图像处理、文件加密场景下是质的飞跃

要注意的是:该注解受底层平台实现影响,在一些旧版本平台接口或不支持背景线程执行(默认还是 MainThread),因此建议提前验证目标设备支持情况。

八、CI 与自动化生成策略

为了防止“接口漂移”(即 Dart改了,Native 没重新生成):

  1. Do check in:建议将生成的 .g.dart.kt.swift 文件提交到 Git 仓库。

    • 理由:原生开发人员可能没装 Flutter 环境,他们需要直接能跑的代码。
  2. CI 校验:在 CI 流水线中增加一步检查:

    # 重新生成一遍
    dart run pigeon ...
    # 检查是否有文件变动
    git diff --exit-code
    

    如果有变动,说明开发者提交了 Pigeon 定义但没运行生成命令,CI 直接报错。

  3. 团队协作的死穴:严格锁定生成器版本: 你的 CI 跑得很完美,直到有一天发生了这样的灾难:A 同学在本地用 Pigeon v20 生成了代码,B 同学拉取分支后,因为本地环境是 v21 并重新运行了生成命令,导致满屏的 Git 冲突和不可预期的 API 漂移。

    **防坑策略**:绝不能仅仅把 `pigeon` 写进 `pubspec.yaml``dev_dependencies` 就万事大吉。你       必须在团队的构建脚本(如 `Makefile`)或 CI 配置中,**强制锁定 Pigeon 的执行版本**

九、什么时候不该用 Pigeon?

Pigeon 虽好,但不是银弹。以下场景建议保留 MethodChannel:

  1. 非结构化的动态数据:例如透传一段任意结构的 JSON 给前端展示,强类型反而是一种束缚。
  2. 极简单的临时通信:比如这就只是想弹一个 Toast,写个 Pigeon 接口略显“杀鸡用牛刀”。
  3. 插件内部通信:如果你在写一个极简的插件,不想引入 Pigeon 依赖增加包体积(虽然 Pigeon 主要是 dev_dependency,但生成的代码会增加少量体积)。
  4. 复杂插件/SDK 封装(深层多态与自定义 Codec) Pigeon 的本质是基于 IDL(接口定义语言)的生成器,而 IDL 天生对“类继承(Inheritance)”和“多态(Polymorphism)”支持极弱。

如果你在封装一个重型的底层 SDK,通常会遇到两个死穴:

  • 类层次结构复杂:需要传递极度复杂的深层嵌套对象,且高度依赖多态行为。
  • 特殊的异步控制:无法用简单的 callback 处理,需要接管底层的 async token。

建议:在这种极高复杂度的场景下,不要强迫 Pigeon 做它不擅长的事。真正的工程级解法是“混合双打”——对于标准的 CRUD 指令和配置同步,使用 Pigeon 保障开发效率与类型安全;对于极其复杂的对象传输或需要自定义编解码(Codec)的链路,果断退回到手动配置 StandardMessageCodec 甚至 BasicMessageChannel

十、总结:这是架构升级的必经之路

Pigeon 对于 Flutter 项目的意义,不亚于 TypeScript 对于 JavaScript。

  • 小项目用 MethodChannel 是灵活,大项目用它是隐患。
  • Pigeon 将通信模式从 “口头约定” 升级为 “代码契约”
  • 它是 Add-to-App 混合开发中,连接原生与 Flutter 最稳固的桥梁。

如果大家的项目中有超过 5 个 MethodChannel 调用,可以尝试选取其中一个,按照本文的流程进行 Pigeon 化改造。你会发现,那种“编译通过即运行正常”的安全感,是 MethodChannel 永远给不了的。

Elpis 动态组件扩展设计:配置驱动的边界与突破

配置驱动的边界问题

Elpis 通过配置驱动解决了 80% 的中后台 CRUD 场景,但总会遇到内置组件无法覆盖的情况:

  • 需要省市区三级联动选择器
  • 需要带千分位格式化的金额输入框
  • 需要集成公司自研的图片裁剪上传组件
  • 需要富文本编辑器、图表组件等第三方库

这时候有三个选择:

方案 A:放弃配置驱动,回到手写代码

方案 B:等框架作者更新内置组件

方案 C:自己扩展组件,像内置组件一样使用

Elpis 选择了方案 C,通过动态组件扩展机制,让框架既保持标准化,又具备灵活性。

核心设计:一个"字符串"的魔法

Elpis 的扩展机制说穿了就一个核心思想:配置里写的是字符串,渲染时才决定用哪个组件

看这段配置:

product_name: {
  createFormOption: {
    comType: 'input',  // 这只是个字符串
  }
}

这个 'input' 不是直接对应某个组件,而是一个"代号"。真正的组件在哪?在一个叫"注册中心"的地方:

// form-item-config.js
const FormItemConfig = {
  'input': { component: InputComponent },
  'select': { component: SelectComponent },
  'richEditor': { component: RichEditorComponent }
};

渲染时,Elpis 做的事情很简单:

<component :is="FormItemConfig[配置里的comType].component" />

就这样,配置和组件解耦了。你想加新组件?往注册中心加一行,配置里就能用。

这个设计妙在哪?

1. 配置稳定: 即使你把 InputComponent 整个重写了,配置文件一个字都不用改。因为配置里只是写了个 'input' 字符串。

2. 场景隔离: 搜索栏有自己的注册中心,表单有自己的注册中心。同样是 'input',在搜索栏可能是个简单输入框,在表单里可能是个带校验的复杂组件。

3. 扩展简单: 不需要改框架代码,不需要发 PR,不需要等更新。自己加一行注册,立刻就能用。

实战:扩展一个富文本编辑器组件

通过实际案例演示如何扩展组件。假设需要添加富文本编辑器支持。

第一步:实现组件

创建文件 app/pages/widgets/schema-form/complex-view/rich-editor/rich-editor.vue

<template>
  <div class="form-item">
    <div class="item-label">
      <span>{{ schema.label }}</span>
      <span v-if="schema.option?.required" class="required">*</span>
    </div>
    <div class="item-value">
      <QuillEditor v-model:content="value" />
      <div v-if="!isValid" class="valid-tips">{{ validMessage }}</div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { QuillEditor } from '@vueup/vue-quill';

const props = defineProps({
  schemaKey: String,    // 字段名
  schema: Object,       // 字段配置
  model: String         // 初始值
});

const value = ref(props.model || '');
const isValid = ref(true);
const validMessage = ref('');

// 必须实现的接口方法
const validate = () => {
  if (props.schema.option?.required && !value.value) {
    isValid.value = false;
    validMessage.value = '这个字段必填';
    return false;
  }
  isValid.value = true;
  return true;
};

const getValue = () => {
  return { [props.schemaKey]: value.value };
};

defineExpose({ validate, getValue });
</script>

关键约定

  • Props 必须包含 schemaKeyschemamodel
  • 必须暴露 validate()getValue() 方法
  • 其他实现细节可自由发挥

第二步:注册组件

app/pages/widgets/schema-form/form-item-config.js 中注册:

import richEditor from "./complex-view/rich-editor/rich-editor.vue";

const FormItemConfig = {
  input: { component: input },
  select: { component: select },
  richEditor: { component: richEditor }  // 新增注册
};

第三步:配置使用

在业务模型中使用新组件:

product_description: {
  type: 'string',
  label: '商品描述',
  createFormOption: {
    comType: 'richEditor',  // 使用扩展组件
    required: true
  }
}

完成。刷新页面,富文本编辑器自动渲染,校验、提交等功能自动生效。

背后的技术:Vue 3 的动态组件

你可能好奇 Elpis 是怎么做到"运行时决定渲染哪个组件"的。答案是 Vue 3 的 <component :is>

看 Elpis 的核心渲染代码:

<template>
  <template v-for="(itemSchema, key) in schema.properties">
    <component
      :is="FormItemConfig[itemSchema.option?.comType]?.component"
      :schemaKey="key"
      :schema="itemSchema"
      :model="model[key]"
    />
  </template>
</template>

这段代码在做什么?

  1. 遍历配置里的每个字段
  2. 读取字段的 comType(比如 'input'
  3. 从注册中心找到对应的组件(FormItemConfig['input'].component
  4. :is 动态渲染这个组件

关键点:is 后面可以是一个变量,这个变量的值是什么组件,就渲染什么组件。

这就是为什么你改配置文件就能换组件——因为组件是运行时决定的,不是编译时写死的。

一个容易忽略的细节:统一接口

注意到没有,所有组件都接收同样的 props:

:schemaKey="key"
:schema="itemSchema"
:model="model[key]"

这是 Elpis 的"约定"。只要你的组件遵守这个约定,就能被动态渲染。

这就像 USB 接口,不管你是键盘、鼠标还是 U 盘,只要接口对得上,就能插上用。

所以写扩展组件时,记住三件事:

  1. Props 要有 schemaKeyschemamodel
  2. 要暴露 validate()getValue() 方法
  3. 其他的随便你发挥

对比:Elpis vs 其他方案

vs Element Plus / Ant Design(组件库)

组件库:给你一堆组件,你自己拼。

<el-form>
  <el-form-item label="商品名称">
    <el-input v-model="form.name" />
  </el-form-item>
  <el-form-item label="价格">
    <el-input-number v-model="form.price" />
  </el-form-item>
  <!-- 每个字段都要写 -->
</el-form>

Elpis:写个配置,自动生成。

{
  product_name: { createFormOption: { comType: 'input' } },
  price: { createFormOption: { comType: 'inputNumber' } }
}

结论:组件库灵活但重复劳动多,Elpis 标准化但省事。适用场景不同,不是替代关系。

vs Formily / React JSON Schema Form(表单方案)

JSON Schema 表单:只管表单,其他的你自己搞。

Elpis:搜索 + 表格 + 表单 + 详情,一套配置全搞定。

结论:Elpis 是 JSON Schema 思想在整个中后台系统的延伸。

写在最后

Elpis 的动态组件扩展机制核心就三件事:

  1. 配置里写字符串标识,不直接引用组件
  2. 用注册中心做类型映射,字符串对应具体组件
  3. 用 Vue 的 :is 实现运行时动态渲染

这套设计让框架在标准化和灵活性之间找到了平衡:

  • 80% 的场景用内置组件,配置驱动,快速开发
  • 20% 的场景扩展组件,一次封装,到处复用

扩展组件的成本是一次性的,但收益是长期的。当你的组件库逐渐丰富,配置驱动的威力就会越来越明显。

框架的价值不在于限制开发者,而在于提供清晰的扩展路径,让开发者在需要时能够突破标准化的边界。

引用: 抖音“哲玄前端”《大前端全栈实践》

❌