普通视图

发现新文章,点击刷新页面。
今天 — 2025年6月27日技术

React 简洁代码 - 使用绝对路径导入

作者 Jimmy
2025年6月26日 23:38

译文原文链接 React Clean Code with Absolute Imports - 作者 Tasawar Hussain

React 是一个很受欢迎的 JavaScript 的库,用于构建用户界面。当我们使用 React 工作的时候,会发现我们需要从不同的文件中引入不同的模块。其中一个管理我们引入的方式是使用绝对路径引入。在本文中,我们将探讨在 React 中(无论你是用 Typescript 还是 JavaScript 组织代码)使用绝对路径导入的好处。

什么是绝对路径导入

JavaScript 中,当我们导入一个模块,我们需要指定需要导入的模块的文件路径。这个路径可以是相对路径或者绝对路径。相对路径是以一个点或者多个点开始,后面带个前斜线。

嗯,我们假设有下面一个 TODO 的应用,有下面的结构:

src/
  components/
    TodoList.tsx
    TodoItem.tsx
  screens/
    TodoScreen.tsx

为了在 TodoScreen 组件中引入 TodoList 组件,我们可以如下引入:

import TodoList from "../../components/TodoList";

上面的声明使用了相对的路径从 components 文件夹中导入了 TodoList 模块。使用相对路径很容易出错,特别是当我们编写一个大型的项目,该项目有很多层级的目录。

为了避免这个问题,我们可以使用绝对路径来导入模块。绝对路径是从我们项目根目录开始,并指定我们想要导入的模块路径。以下是个案例👇

import TodoList from "components/TodoList";

该声明使用绝对路径从 src/components 目录中导入 TodoList 模块。使用绝对可以让我们避免错误和使得代码更容易维护。

TypeScript 中怎么使用绝对路径导入

如果我们在 React 中使用的是 TypeScript,我们可以在文件 tsconfig.json 中使用 baseUrlpaths 选项来配置绝对路径导入。下面是个例子👇

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "src/*": ["src/*"]
    }
  }
}

JavaScript 中怎么使用绝对路径导入

如果我们在 React 项目中使用 JavaScript,我们可以在根目录下的文件 jsconfig.json 中添加下面的内容:

{
  "compilerOptions": {
    "baseUrl": "src"
  },
  "include": ["src"]
}

上面的配置设定 baseUrl 是你项目的根目录并且定义了一个 src/* 的路径映射。这意味着我们可以从 src 目录中使用绝对路径。

React 中使用绝对路径导入的好处

1. 简化导入

React 中使用绝对路径导入的最主要的好处就是简化我们的引入。相比于使用复杂的相对路径导入模块,我们可以使用简短的绝对路径导入。这让我们的代码更加容易读和维护。

比如,下面的相对路径:

import MyComponent from '../../../components/MyComponent';

上面使用了比较长和复杂的路径来引入 MyComponent 组件。相反的,使用绝对路径改写如下👇

import MyComponent from 'src/components/MyComponent';

上面这个声明使用了简洁的绝对路径导入 MyComponent 组件。

2. 避免文件路径错误

当我们使用相对路径时候,很容易错误导入文件。绝对路径通过指定我们想要导入的文件的精准位置来避免这个问题。这意味着我们可以避免写常见的错误,比如导入错误的文件或者文件丢失。

3. 重构更容易

使用绝对路径让我们重构代码更加容易。当我们在项目中移动文件或者目录,我们可以很简单地更新导入的路径。这为我们节约了不少时间并降低了引入错误的风险。

守护你的代码:JavaScript Obfuscator 实际操作指南

作者 烛阴
2025年6月26日 22:57

引言

在上篇文章中,我们深入探讨了 JavaScript 混淆的原理和意义。今天,我们将聚焦于一款在混淆领域备受推崇的利器——javascript-obfuscator!它以其强大的功能和丰富的配置选项,成为了许多开发者保护代码的得力助手。

本文将带你走进 javascript-obfuscator 的实际操作世界,从零开始,一步步学会如何使用它来提升你的 JavaScript 代码安全性,并给出一些实用的建议。准备好了吗?让我们开始吧!

准备工作:安装 javascript-obfuscator

首先,你需要将 javascript-obfuscator 安装到你的项目中。我们通常将其作为开发依赖安装。

使用 npm:

npm install javascript-obfuscator --save-dev

安装完成后,你就可以在你的项目中调用它了。

基础操作:命令行使用

javascript-obfuscator 最直接的使用方式是通过命令行。这对于构建脚本、自动化部署流程非常方便。

假设你有一个名为 my-script.js 的文件,内容如下:

// my-script.js
function sayHello(name) {
  const message = `Hello, ${name}! Welcome to my awesome app.`;
  console.log(message);
}

const userName = "Developer";
sayHello(userName);

// Some important logic here!
// var sensitiveData = 'this should be protected';

1. 基本混淆:生成混淆后的代码文件

最简单的用法就是将你的 .js 文件进行混淆,并输出到一个新的文件。

命令格式:

npx javascript-obfuscator <input_file> --output <output_file>

实操示例:

npx javascript-obfuscator my-script.js --output my-script.obfuscated.js

执行后,你会在当前目录下看到一个 my-script.obfuscated.js 文件。打开它,你会发现代码变得非常难以阅读,变量名被替换,字符串可能被加密,整体结构被重组。

输出示例:

const a0_0x4074ce = a0_0x4bc6; (function (_0x54b00d, _0x3b0184) { const _0x33237b = a0_0x4bc6, _0x3f3139 = _0x54b00d(); while (!![]) { try { const _0x103ed2 = parseInt(_0x33237b(0x1e1)) / 0x1 + parseInt(_0x33237b(0x1e4)) / 0x2 * (-parseInt(_0x33237b(0x1e8)) / 0x3) + -parseInt(_0x33237b(0x1ea)) / 0x4 + -parseInt(_0x33237b(0x1e3)) / 0x5 * (parseInt(_0x33237b(0x1eb)) / 0x6) + -parseInt(_0x33237b(0x1e7)) / 0x7 * (parseInt(_0x33237b(0x1e0)) / 0x8) + -parseInt(_0x33237b(0x1e9)) / 0x9 + parseInt(_0x33237b(0x1e5)) / 0xa * (parseInt(_0x33237b(0x1e2)) / 0xb); if (_0x103ed2 === _0x3b0184) break; else _0x3f3139['push'](_0x3f3139['shift']()); } catch (_0xc2b768) { _0x3f3139['push'](_0x3f3139['shift']()); } } }(a0_0x5c84, 0xe2898)); function a0_0x4bc6(_0x4b158a, _0x21504c) { const _0x5c84fa = a0_0x5c84(); return a0_0x4bc6 = function (_0x4bc667, _0xeef566) { _0x4bc667 = _0x4bc667 - 0x1e0; let _0x43b085 = _0x5c84fa[_0x4bc667]; return _0x43b085; }, a0_0x4bc6(_0x4b158a, _0x21504c); } function sayHello(_0xe54705) { const _0x202d82 = 'Hello,\x20' + _0xe54705 + '!\x20Welcome\x20to\x20my\x20awesome\x20app.'; console['log'](_0x202d82); } function a0_0x5c84() { const _0x3f2783 = ['14ExAAPK', '4920423yomcmx', '6376545hhdlhg', '1226148OmYpLz', '282558ycKIJX', '4454768rKIPel', '500372ZUSqtf', '8125084ygdeic', '25DvXNbT', '2enaKSl', '60hbcWcJ', 'Developer']; a0_0x5c84 = function () { return _0x3f2783; }; return a0_0x5c84(); } const userName = a0_0x4074ce(0x1e6); sayHello(userName);

关键点:

  • 默认情况下,javascript-obfuscator 会启用多种混淆技术,包括字符串数组、控制流平坦化、变量名混淆等,以提供良好的保护。

2. 控制混淆选项:定制化你的保护策略

javascript-obfuscator 提供了海量的配置选项,允许你精细控制混淆过程。你可以通过命令行参数来传递这些选项。

常用选项介绍与实操:

  • --compact: 压缩输出,移除空格和换行符。

    npx javascript-obfuscator my-script.js --output my-script.compact.js --compact true
    

    这会让输出代码更加紧凑,占用更少空间,并且也更难阅读。

  • --string-array: 将代码中的字符串收集到一个数组中,运行时再通过加密或编码的方式获取。这是非常有效的字符串保护技术。

    npx javascript-obfuscator my-script.js --output my-script.string-array.js --string-array true
    

    你会发现代码中多了一个类似 _0xXXXX 的数组,并且原始字符串不见了。

  • --string-array-encoding: 指定字符串数组的编码方式,如 base64, rc4, utf16le。RC4 通常提供更好的加密效果。

    npx javascript-obfuscator my-script.js --output my-script.rc4.js --string-array true --string-array-encoding rc4
    
  • --control-flow-flattening: 启用控制流平坦化,将代码逻辑转化为复杂的 switch 语句,极大增加理解难度。

    npx javascript-obfuscator my-script.js --output my-script.cf.js --control-flow-flattening true
    
  • --dead-code-injection: 注入无用的代码,增加代码量和阅读难度。

    npx javascript-obfuscator my-script.js --output my-script.deadcode.js --dead-code-injection true
    
  • --rename-globals: (谨慎使用!) 重命名全局变量。如果你的代码依赖于全局变量(例如被其他脚本调用),开启此选项可能导致错误。

    npx javascript-obfuscator my-script.js --output my-script.mangle-globals.js --rename-globals true
    
  • --reserved-strings: 指定一些不应被混淆的字符串。例如,把Developer列为不需要混淆的字符串。

    npx javascript-obfuscator my-script.js --output my-script.reserved.js --string-array true --reserved-strings Developer
    

3. 使用配置文件

当配置选项变得很多时,使用命令行参数会显得冗长。javascript-obfuscator 支持从配置文件读取配置,通常是一个 .json 文件。

首先,创建一个配置文件,例如 obfuscator-config.json:

{
  "compact": true,
  "controlFlowFlattening": true,
  "controlFlowFlatteningFactor": 0.75,
  "deadCodeInjection": true,
  "deadCodeInjectionPattern": "!![]",
  "stringArray": true,
  "stringArrayEncoding": ["rc4"],
  "stringArrayThreshold": 0.75,
  "renameGlobals": false,
  "splitStrings": true,
  "splitStringsChunkSize": 10,
  "unicodeEscapeSequence": true
}

然后,在命令行中使用 --config 参数:

npx javascript-obfuscator my-script.js --output my-script.config.js --config obfuscator-config.json

这样会使你的构建流程更加清晰和易于管理。

4. 使用 Node.js API

你也可以直接在你的 Node.js 脚本中调用 javascript-obfuscator 的 API。

const JavaScriptObfuscator = require('javascript-obfuscator');

const obfuscationResult = JavaScriptObfuscator.obfuscate(`
  function add(a, b) {
    return a + b;
  }

  console.log(add(5, 3));
`);

console.log(obfuscationResult.getObfuscatedCode());

结语

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript开发干货。

在 Flutter 中避免过度使用 StatefulWidget:常见错误及更好的替代方案

作者 JarvanMo
2025年6月26日 22:38

Flutter 是一个用于构建跨平台移动、Web 和桌面应用程序的出色工具包。其响应式框架和精美的 UI 组件使其成为全球开发者的首选。但就像任何强大的工具一样,如果我们不小心,很容易误用它。

Flutter 开发者(尤其是初学者)最常犯的错误之一,就是过度使用或误用StatefulWidget

让我们深入探讨为什么会出现这种情况,它会导致什么问题,以及如何通过更好的替代方案来避免它。

Flutter 中的 StatefulWidget 是什么?

在深入探讨陷阱之前,我们先快速回顾一下StatefulWidget的定义。

在 Flutter 中,Widget 是构建 UI 的基本单元。Widget 主要分为两种类型:

  • StatelessWidget:不可变,不维护自身状态。
  • StatefulWidget:维护可变状态,且状态可在 Widget 生命周期中变化。

StatefulWidget 的常见应用场景包括管理计数器、开关按钮,或响应用户交互的动态 UI 变化。

class MyCounter extends

很简单,对吧?但问题恰恰从这里开始……


❌ 常见错误:随处使用 StatefulWidget

在 Flutter 应用中,我们最常看到的反模式之一是将过多组件包裹在StatefulWidget中 —— 而这往往是不必要的。

为什么这样做不好?

现在让我们来深入分析。

1. 组件臃肿与紧密耦合

在 StatefulWidget 中塞入过多逻辑,会导致组件树臃肿,使 UI 与业务逻辑紧密耦合。这会让代码更难:

  • 维护:调试和重构变得困难。

  • 测试:紧耦合的代码难以进行单元测试。

  • 扩展:添加新功能时容易陷入混乱。

2. 不必要的重绘

调用setState()时,整个组件会被重绘。如果StatefulWidget包含复杂 UI 元素或嵌套组件,会导致性能低效,常见问题包括:

  • 动画卡顿
  • UI 响应缓慢
  • 移动设备耗电加剧

3.违背单一职责原则

优秀的软件架构强调职责分离。理想情况下,组件应仅负责渲染 UI,而非同时管理状态、网络请求和业务逻辑。
如果你的组件看起来像一个 “迷你后端 API 客户端”,是时候重构了!


💡 替代 “随处使用 StatefulWidget” 的更佳方案

现在我们来看看如何避免过度使用 StatefulWidget,并编写更简洁、易维护且可测试的 Flutter 代码。

✅ 1. 将 StatelessWidget 与状态管理结合使用

现代 Flutter 开发的最佳实践是将业务逻辑从组件中剥离,迁移至专门的状态管理方案中。
常用方案包括:

  • Provider
  • Riverpod
  • Bloc / Cubit
  • GetX
  • MobX

示例: 使用 Provider 代替 StatefulWidget

class Counter with ChangeNotifier {
  int _count = 0;

int get count => _count;
  void increment() {
    _count++;
    notifyListeners();
  }
}

将你的app裹在ChangeNotifierProvider中,你的组件会变得更加简洁:

class CounterText extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final counter = Provider.of<Counter>(context);
    return Text('Count: ${counter.count}');
  }
}

现在,你已经将用户界面和逻辑分离。你还能获得以下好处:

  • 可重用性
  • 更简洁的重建
  • 更轻松的测试

✅ 2. 利用Hooks(flutter_hooks)

flutter_hooks将React风格的钩子引入Flutter,让你能够在StatelessWidget中使用局部状态!


class HookCounter extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final count = useState(0);

    return Column(
      children: [
        Text('Count: ${count.value}'),
        ElevatedButton(
          onPressed: () => count.value++,
          child: Text('Increment'),
        ),
      ],
    );
  }
}

更简洁、更具函数式特性,且无需编写样板代码!

✅ 3. 为父级管理的状态使用回调参数

有时,你只需要让父级组件管理状态并将其作为属性传递下去。

class CustomSwitch extends StatelessWidget {
  final bool value;
  final ValueChanged<bool> onChanged;

  CustomSwitch({required this.value, required this.onChanged});
  @override
  Widget build(BuildContext context) {
    return Switch(value: value, onChanged: onChanged);
  }
}

现在状态在更高级别被控制,这提高了灵活性和可测试性。

✅ 4. 使用ValueNotifierValueListenableBuilder 对于轻量级的响应式更新,ValueNotifier是一个很棒的选择。

final counter = ValueNotifier<int>(0);

ValueListenableBuilder<int>(
  valueListenable: counter,
  builder: (context, value, child) {
    return Text('Count: $value');
  },
)

它效率很高,并且避免了StatefulWidget的样板代码。

🧠 你究竟何时该使用StatefulWidget?

需要明确的是,StatefulWidget并非“洪水猛兽”,它自有其适用场景。 当出现以下情况时可以使用它:

  • 你需要管理临时的局部状态(如动画、焦点或标签控制器)。
  • 该状态无需共享或持久化。
  • 你正在构建快速原型或进行实验。

但对于生产级应用,应避免将其作为状态管理策略。

👀 实际场景:重构一个混乱的StatefulWidget

假设你正在构建一个商品Card组件,而你最初的实现使用了StatefulWidget来管理:

  • 商品是否被点赞
  • 点赞/取消点赞的网络请求
  • 界面渲染
class ProductCard extends StatefulWidget {
  final Product product;
  ProductCard({required this.product});

  @override
  _ProductCardState createState() => _ProductCardState();
}
class _ProductCardState extends State<ProductCard> {
  bool isLiked = false;
  void toggleLike() {
    setState(() {
      isLiked = !isLiked;
      // Simulate API call
    });
  }
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text(widget.product.name),
        IconButton(
          icon: Icon(isLiked ? Icons.favorite : Icons.favorite_border),
          onPressed: toggleLike,
        ),
      ],
    );
  }
}

这种情况可以使用Riverpod等状态管理库来更好地处理:

final likeProvider = StateProvider.family<bool, String>((ref, productId) => false);

现在,你的ProductCard就只是一个展示型组件了,简洁得多。 怎么样学会了吗?最后欢迎大家关注我的公众号OpenFlutter

🚀🚀⚡️ Rspack 1.4 发布:性能再突破,生态更完善 ⚡️ 🚀🚀

2025年6月26日 21:57

前言

Rspack 1.4 正式发布!作为前端开发者,这是一个令人振奋的消息。Rspack 以其基于 Rust 的高性能和与 Webpack 的高度兼容性,成为现代 Web 开发中的重要工具。本次更新带来了显著的性能提升、生态系统扩展和新功能支持,让我们一起来探索这些激动人心的变化!

往期精彩推荐

正文

2025年6月26日,Rspack 团队发布了 1.4 版本,进一步提升了这一高性能 JavaScript 打包器的能力。以下是本次更新的核心亮点:

1. WebAssembly 支持:在线开发更便捷

Rspack 1.4 引入了对浏览器环境的 WebAssembly(Wasm)目标支持,特别适用于在线开发平台。

开发者现在可以直接在浏览器中构建和运行 Rspack 项目,极大地方便了在线开发和预览!

浏览器中构建和运行 Rspack

使用指南: rspack.dev/zh/guide/st…

2. 性能优化:更快、更小

Rspack 1.4 在性能方面取得了显著突破:

  • SWC 性能提升:与 SWC 团队合作,JavaScript 解析器速度提升了 30%~35%,压缩器速度提升了 10%。相比 Rspack 1.3 使用的 SWC 16,性能提升明显!

性能提升

  • 更小的构建产物:通过优化的死代码消除(DCE)和 tree shaking 技术,Rspack 生成的构建产物更精简。以 react-router 为例,Rspack(通过 Rsbuild)生成的压缩后大小为 36.35 kB(Gzip 后 13.26 kB),优于 Webpack(36.96 kB,13.37 kB)、Vite(42.67 kB,15.67 kB)等其他工具

更小的构建产物

3. 增量构建与 HMR 优化

Rspack 1.4 默认启用增量构建,通过 experiments.incremental: 'safe' 配置,仅重新构建发生变化的部分,显著减少构建时间。此外,热模块替换(HMR)性能提升了 30%~40%,让开发过程中的模块更新更加流畅。

 HMR 优化

4. CSS 代码分割

新引入的 CssChunkingPlugin 插件,支持 CSS 代码分割,优化了 CSS 资源的加载性能,特别适合大型项目。

import { rspack } from '@rspack/core';

export default {
  plugins: [
    new rspack.experiments.CssChunkingPlugin({
      // ...options
    }),
  ],
};

5. 懒编译与自定义文件系统

  • 懒编译:在 MultiCompiler 中支持懒编译,可针对每个编译器单独配置,优化大型项目的构建性能。
  • 自定义文件系统:通过 experiments.useInputFileSystem 支持自定义文件系统,例如 VirtualModulesPlugin,为开发者提供了更大的灵活性。

6. 性能追踪

Rspack 1.4 支持使用 Perfetto进行性能追踪。开发者可以通过设置环境变量 RSPACK_PROFILE=OVERVIEW 启用此功能,并在 Perfetto 平台上可视化性能数据!

性能追踪

7. 依赖升级

  • 升级 Zod 到 v4。
  • Biome v2 作为 create-rspack 的可选依赖,提升代码格式化和分析能力。

8. 生态系统扩展

Rspack 1.4 进一步扩展了其生态系统,与主流框架和工具的集成更加完善:

  • Rsbuild 1.4:支持 Chrome DevTools 集成,新增 .js?raw 查询以导入原始内容,并通过 SWC 支持 monorepo 编译范围,确保浏览器兼容性。

Chrome DevTools

  • Rslib 0.10:优化 ESM,支持 Vue 组件库 rsbuild-plugin-unplugin-vue。
  • Rspress 2.0 beta:引入 Shiki 代码高亮和新主题样式!
  • Rsdoctor MCP:通过 @rsdoctor/mcp-server 提供 AI 辅助的构建分析。
  • Rstest v0.0.3:Jest 兼容的测试框架,适用于 Node.js 和 UI 开发
  • next-rspack:测试覆盖率达 99.4%(生产环境)和 98.4%(开发环境)。
  • Kmi:结合 Umi 和 Rspack 的框架,提供性能提升!

9. 升级注意事项

  • SWC Wasm 插件:如 @swc/plugin-emotion 需要升级到 swc_core@29
  • 懒编译中间件:自动读取 lazyCompilation 配置,无需手动设置。

10. 未来计划

Rspack 1.4 的成功离不开社区的支持。自开源以来,项目已吸引 170 位贡献者,提交了超过 5000 个 Pull Request 和 2000 多个 Issue,npm 周下载量突破 10 万次。未来,Rspack 计划继续优化性能,支持更多现代 Web 标准,并完善与 Webpack 生态的兼容性。

如何开始?

最后

Rspack 1.4 通过性能优化、生态扩展和新功能支持,进一步巩固了其作为高性能 Web 打包器的地位。无论是更快的构建速度、更小的输出产物,还是与 Next.js、Vue 等框架的无缝集成,Rspack 都为开发者提供了更高效的工具链

更多详细更新看这里:rspack.dev/zh/blog/ann…

今天的分享就这些了,感谢大家的阅读,如果文章中存在错误的地方欢迎指正!

往期精彩推荐

Flutter,如何实现轮播图

作者 小old弟
2025年6月26日 18:59

说到这个 Flutter 项目配置,我发现 flutter_swiper_view 这个库用起来还挺顺手的。最近在做一个首页轮播图的功能,记录一下实现过程。

对了,先说说项目的基本配置吧。在 pubspec.yaml 里除了基础依赖,主要加了三个关键库:

  • flutter_swiper_view 负责轮播图
  • oktoast 用于提示消息
  • flutter_screenutil 做屏幕适配

关于 flutter_screenutil 的详细说明,flutter_screenutil 这个库真的太重要了!它帮我们解决了不同设备屏幕适配的大问题。说到屏幕适配,Android 和 iOS 设备那么多不同尺寸,要是手动适配简直要疯掉。

这个库主要提供了几个超好用的功能:

  1. .w - 宽度适配单位
  2. .h - 高度适配单位
  3. .r - 圆角/边距适配单位
  4. .sp - 字体大小适配单位

初始化的时候需要设置 designSize,这个要根据 UI 设计稿的尺寸来定。比如设计稿是 375x812(iPhone X 尺寸),那就这样设置:

主应用入口设置

主应用入口的代码挺有意思的。我注意到这里用 OKToast 包裹了整个应用,这样在任何地方都可以调用 toast 提示了。ScreenUtilInit 则是用来初始化屏幕适配的,designSize 需要提前定义好设计稿尺寸。

return OKToast(
  child: ScreenUtilInit(
    designSize: designSize,
    builder: (context, child) {
      return MaterialApp(
        title: 'Flutter Demo',
        theme: ThemeData(
          colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
          useMaterial3: true
        ),
        home: HomePage()
      );
    },
  )
);
void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ScreenUtilInit(
      designSize: Size(375, 812),
      minTextAdapt: true,
      splitScreenMode: true,
      builder: (context, child) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(),
          home: child,
        );
      },
      child: HomePage(),
    );
  }
}

使用起来特别简单,比如:

Container(
  width: 100.w,  // 相当于设计稿上的100px
  height: 200.h, // 相当于设计稿上的200px
  margin: EdgeInsets.all(10.r), // 圆角适配
  child: Text(
    'Hello', 
    style: TextStyle(fontSize: 16.sp) // 字体大小适配
  ),
)

首页轮播图实现

说到轮播图,在 HomePage 里我用 Swiper 组件实现了一个简单的版本。这里有几个关键点:

  1. 容器高度设为了 150.h - 这个 .hScreenUtil 提供的适配单位
  2. 宽度设为 double.infinity 让它撑满父容器
  3. 加了 15.r 的边距 - .r 也是适配单位
Container(
  height: 150.h,
  width: double.infinity,
  child: Swiper(
    indicatorLayout: PageIndicatorLayout.COLOR,
    autoplay: true,
    control: const SwiperControl(),
    itemCount: 3,
    itemBuilder: (context, index){
      return Container(
        width: double.infinity,
        margin: EdgeInsets.all(15.r),
        height: 150.h,
        color: Colors.lightBlue
      );
    }
  )
)

对了,Swiper 的配置项也挺丰富的:

  • autoplay: true 开启自动轮播
  • control 显示左右箭头控制器
  • indicatorLayout 设置指示器样式

遇到的坑

还有一个有意思的事,刚开始我没用 SafeArea,结果在 iPhone 上轮播图被刘海挡住了。后来加上了 SafeArea 才解决这个问题。所以现在代码是这样的:

Scaffold(
  body: SafeArea(child: Column(children: [
    // 轮播图和其他内容
  ]))
)

说到这个,Flutter 的布局系统有时候确实需要点时间适应,特别是从原生开发转过来的同学。不过一旦熟悉了,写起来还是挺爽的!

效果

1.gif

总结

总的来说,实现一个基础轮播图并不复杂,但要注意:

  • 屏幕适配问题
  • 不同设备的显示安全区域
  • 轮播图的各种配置选项

下次我打算给轮播图加上真实的图片和点击事件,到时候再分享更多心得!

【React专栏】一、React基础(一)

作者 engineer_why
2025年6月26日 18:50

React基础

1.1 React项目创建

npm install -g create-react-app

1.2 JSX语法

  • 使用成对的标签构成一个树状结构的数据

  • 当使用DOM类型标签时,标签的首字母必须小写

  • 当使用React组件类型标签时,标签的首字母必须大写

    • 因为React正是通过标签的首字母大小写,来判断当前是一个DOM类型标签,还是React组件类型标签。
  • 可以使用JavaScript表达式,因为JSX本质上仍是JavaScript

    • 在JSX中使用JavaScript表达式,需要用大括号“{}”包起来
    • 使用场景
      • 1️⃣通过表达式给标签属性赋值
      • 2️⃣通过表达式定义子组件
  • JSX中只能使用JavaScript表达式,不能使用多行JavaScript语句

  • 可以使用三元运算符或逻辑与(&&)运算符 代替 if 语句 的作用

  • JSX是DOM类型标签时,对应DOM标签支持的属性JSX也支持。

    • 但部分属性名称会改变,主要变化有:
      • class --> className(因为class时JavaScript的关键字)
      • onclick --> onClick(React对DOM标签支持的事件做了封装,封装时采用更常用的驼峰式命名法命名事件)
  • JSX标签是React组件类型时,可以任意自定义标签的属性名

  • JSX中的注释,需要用大括号“{}”将/* 注释 */包裹起来

const element = {
    <div>
        {/* 这是注释 */}
        <span>React</span>
   </div>
}

1.2.1 JSX 不是必需的

  • JSX语法只是 React.createElement(component, props, ...children)的语法糖,所有的JSX语法最终都会被转换成对这个方法的调用。
// JSX 语法
const element = <div className='foo'>Hello,React</div>

// 转换后
const element = React.createElement('div',{className: 'foo'},'Hello,React')

1.3 组件

React应用程序正是由一个个组件搭建而成。

1.3.1 组件定义

组件定义有两种方式: 1️⃣ES6 class(类组件) 2️⃣使用函数(函数组件)

1.3.1.1 类组件
  • 使用class定义组件,需要满足两个条件
    • class继承自React.Component
    • class内部必须定义render方法,render方法返回代表该组件UI的React元素
  • 创建好的React组件,需要使用ES6 export导出,方便其他文件引入
  • 渲染至页面,还需要使用到 react-dom 库中的 render函数(这个库会完成组件所代表的虚拟DOM节点到浏览器的DOM节点的转换)
import ReactaDOM from "react-dom";
import PostList from "./PostList";

ReactDOM.render(<PostList />, document.getElementById("root"));
1.3.1.2 函数组件

方便用于定义 无状态组件(即无需定义 state,仅接收props)

function Welcome(props) {
    return <h1>Hello,{props.name}</h1>
}

1.3.2 组件的 props

组件的props用于把 父组件中的数据或方法 传递给子组件,供子组件使用。

1.3.3 组件的 state

  • 组件的 state 是组件内部的状态,state 的变化最终将反应到组件的UI变化上。
  • 通过组件的构造方法 constructor 中,通过 this.state 定义组件的初始化。
  • 通过调用 this.setState 方法改变组件的状态(唯一改变组件状态的方式),组件UI会重新渲染

在组件的构造方法 constructor 内,首先要调用 super(props),这一步实际上是调用 React.Component 这个 class 的 constructor 方法,用来完成React组件的初始化工作。

小结: React 组件正是由 props 和 state 两种类型的数据,驱动渲染组件。props 是组件对外的接口,通过 props 接收外部传入的数据和方法; state 是组件内部的接口,组件内部的状态变化通过 state 反映。props 是只读的,不允许更改; state 是可变得,组件状态变化可通过改变 state 来实现。

1.3.4 有状态组件和无状态组件

React 应用组件设计的一般思路是,通过定义少数的有状态组件,管理整个应用的状态变化,并且将状态通过 props 传递给其余的无状态组件,由无状态组件完成页面的大部分 UI的渲染工作

  • 应尽可能多的使用无状态组件,无状态组件无需关心状态的变化,只聚焦UI的展示,更容易被复用。
  • 有状态组件主要处理状态变化的业务逻辑,无状态组件主要关注UI的渲染。

1.3.5 属性校验和默认属性

  • 属性校验
    • 基本的属性校验类型

      • String --> PropTypes.string
      • Number --> PropTypes.number
      • Boolean --> PropTypes.bool
      • Function --> PropTypes.func
      • Object --> PropTypes.object
      • Array --> PropTypes.array
      • Symbol --> PropTypes.symbol
      • Element --> PropTypes.element
      • Node --> PropTypes.node (可被渲染的节点:数字、字符串、React 元素或由这些类型的数据组成的数组)
    • 当知道要校验的属性是一个对象或数组时,通过使用PropTypes.shape(校验对象结构,校验内部属性类型)或PropTypes.arrayOf(校验数组内部元素类型)

style: PropTypes.shape({
    color: PropTypes.string;
    fontSize: PropTypes.number;
}),
sequence: PropTypes.arrayOf(PropTypes.number);

// 表示 style 是一个对象,对象内有 color 和 fontSize 两个属性, 
// color 是字符串类型,fontSize 是数字类型;sequence 是一个数组,数组的元素是数字类型。
  • 必填校验
    • 需要在PropTypes 的类型属性上,调用isRequired。
PostItem.propTypes = {
    post: PropTypes.shape({
        id: PropTypes.number
    }).isRequired,
    onVote: PropTypes.func.isRequired
}
  • 属性默认值
    • 通过组件的defaultProps 实现。当组件属性未被赋值时,组件会使用 defaultProps 定义的默认属性。
function Welcome(props) {
    return <h1 className='foo'>Hello, {props.name}</h1>
}

Welcome.defaultProps = {
    name: 'Stranger'
}

// 当props.name 未被赋值时,使用下方定义的默认属性值

【TypeScript专栏】二、TypeScript基础(一)

作者 engineer_why
2025年6月26日 18:41

2.1 JavaScript 基础知识补充

  • 数据类型

    • 7种原始类型

      • Boolean 布尔
      • Null 空
      • Undefined 未定义
      • Number 数字
      • BigInt 任意精度的整数

        它解决了传统 Number 类型因使用 64 位浮点数格式而无法安全表示超大整数(超过 ±(2^53 - 1))的问题。

      • String 字符串
      • Symbol 唯一
    • Object

      除 Object 以外的所有类型都是不可变的(值本身无法被改变)。称这些类型的值为“原始值”。

  • 类数组

    • 是普通对象,具有 length 属性和数字索引访问能力,但不继承 Array.prototype,因此无法直接调用数组方法。

    • 区别

      • 数组:是 JavaScript 的内置对象,用于存储有序的元素集合,继承自 Array.prototype,拥有丰富的数组方法(如 pushmapforEach 等)。
      • image.png
    • 转换为数组的方法

      // Array.from() (推荐)
      const argsArray = Array.from(arguments);
      
      // 扩展运算符 ...
      const nodesArray = [...doucment.querySelectorAll('div')];
      
      // Array.prototype.slice.call() (旧版本兼容)
      const argsArray = Array.prototype.slice.call(arguments);
      

2.2 原始数据类型

// 布尔类型
let isDone: boolean = false;

// 数字类型
let age: number = 12;

// 字符串类型
let firstName: string = 'zhangsan';
let message: string = `Hello,${firstName}`

// null 和 undifined
// 这两种类型是所有类型的子类型
// 也就是说这两种类型值可以赋值给其他类型
let u: undefined = undefined;
let n: null = null;

2.3 Any类型

  • 在无法判断所写数据的类型时,或临时完善ts校验时,可用。
  • 允许赋值给任意类型。
  • 一般情况下,要避免使用any类型,因为这个类型可以任意调用方法和属性,就会更容易出现错误,丧失类型检查的作用。
let notSure: any = 4;
notSure = 'maybe a string';
notSure = true;

notSure.myName;
notSure.getName(); 
// 调用属性和方法均不会报错,因为默认为any 任意类型

2.4 数组

// 声明 数字类型的数组
// 数组的元素,不允许出现 数字以外的类型
let arrOfNumber: number[] = [1,2,3];

// 在指定好类型后,在调用这个数组后
// 可以自动获得数组上所有的方法
// (输入 数组名.  后,存在的方法会自动弹出提示框)
arrOfNumber.push(5);

image.png

2.5 类数组

function test() {
    console.log(arguments);
    // arguments 就是类数组
    // 而类数组有已经内置的类型 IArguments 类型
    // 还有诸多已经存在的内置类型
}

2.6 元组

  • 合并不同类型的对象 将内部的数据类型进行更精细的确定
  • 起源于函数式编程
// 存储的数组元素,顺序和类型必须对应
// 因为仍旧是数组,所以可以使用数组的方法
// 如 push,但只可添加设定好的两种类型的值
let user: [string, number] = ['zhangsan', 20];

MCP+JS实现动态路线展示+视角跟随

作者 星使bling
2025年6月26日 18:27

MCP+JS实现动态路线展示+视角跟随

项目简介

本项目基于 React + @baidumap/mapv-three + Three.js 实现了《长安的荔枝》中广州至从化的荔枝运输路线3D可视化展示。项目支持在百度矢量地图和Bing卫星地图之间切换,并通过3D人物模型动态展示运输过程。

exp2.png

主要功能

  • 支持百度矢量地图和Bing卫星地图的切换显示
  • 使用3D人物模型沿路线动态移动
  • 智能相机跟随系统,提供最佳观察视角
  • 路线采用动态发光效果展示
  • Web Mercator (EPSG:3857) 投影支持,确保多地图源兼容性

依赖安装

npm install --save @baidumap/mapv-three three react react-dom
npm install --save-dev webpack webpack-cli copy-webpack-plugin html-webpack-plugin @babel/core @babel/preset-env @babel/preset-react babel-loader

构建与运行

npx webpack
# 生成 dist/ 目录,浏览器打开 dist/index.html 预览

路线说明

路线概述

  • 起点:广州市越秀区
  • 终点:从化区
  • 主要途经:广州北环高速 → 机场高速 → 大广高速
  • 路线特点:全程按照实际道路规划,主要通过高速公路网络连接

数据结构

项目主要数据存储在 data/lychee.geojson 文件中,包含:

  • 完整的路线坐标点序列
  • 详细的路段描述
  • 路线说明和数据来源信息

3D可视化效果

  • 地图默认以广州为中心
  • 采用 30° 倾斜角和 60° 俯仰角
  • 初始观察距离为 2km
  • 可视化元素:
    • 基础路线:浅蓝色发光效果
    • 动态飞线:红色动画效果
    • 3D人物模型:动态奔跑动画

目录结构

lychee/
├── data/
│   └── lychee.geojson     # 路线地理数据
├── public/
│   └── models/
│       └── running_man.glb # 3D人物模型
├── src/
│   ├── Demo.jsx           # 主要展示组件
│   └── index.js           # 入口文件
├── webpack.config.js      # 构建配置
├── package.json          # 项目依赖
└── README.md             # 项目说明

技术实现细节

地图系统

  • 支持在百度矢量地图和Bing卫星地图间无缝切换
  • 使用 EPSG:3857 (Web Mercator) 投影确保地图兼容性
  • 通过 projectArrayCoordinate 进行坐标投影转换

3D模型集成

  • 使用 GLTFLoader 加载 .glb 格式的3D人物模型
  • 实现模型动画混合器处理走路动画
  • 智能调整模型朝向,确保始终面向运动方向

相机系统

  • 实现智能相机跟随系统
  • 保持适当的观察距离和高度
  • 平滑的相机运动效果
  • 自动调整视角以获得最佳观察效果

配置说明

项目运行需要配置以下密钥:

  • 百度地图开发者密钥 (ak)
  • Cesium ion 访问令牌 (accessToken)

请在 Demo.jsx 中替换为你自己的密钥:

mapvthree.BaiduMapConfig.ak = '你的百度地图密钥';
mapvthree.CesiumConfig.accessToken = '你的Cesium ion密钥';

参考资料

复盘——微信小程序自定义tabbar结构设计优化导致的问题

2025年6月26日 18:18

要拿捏局部优化和系统优化

文章简单记录下,小程序自定义tabbar结构设计优化,导致的多页面代码变更的问题,和解决思维的变化

背景

全局layout组件有个error-page样式会覆盖住自定义tabbar,需要解决

image.png

自定义tabbar组件目前是在各个页面引用的,而不是统一在公共的layout组件使用,这个属于历史原因了,所以就有这个问题了

这里有个小点:微信小程序的app.jsontabbar可支持放入5个以上的页面,这些页面将会拥有switchTab的切换效果,但底部tabbar只能展示5个

原先代码case

<!-- index页面 --!>
<layout>
    <view>
        <!-- content --!>
    </view>
    <tabbar />
    <view style="height:50px"></view>
</layout>

<!-- layout组件代码 --!>
<view>
    <view class="content-page">
        <slot />
    </view>
    <view class="error-page" wx:if="{{ showErrorPage }}">
        接口报错啦
    </view>
</view>

<style>
    .content-page {
        z-index: 1;
        position:relative;
    }
    .error-page {
        z-index: 2;
        position: fixed;
        height: 100vh;
        background-color: #fff;
    }
</style>

其实主要本质上是因为tabbar位置放错了,应该得把tabbar组件放到layout组件,而不是放到页面里,这样就能和error-page保持同级,从而不会被盖住,如下:

<!-- layout组件代码 --!>
<view>
    <view class="content-page">
        <slot />
    </view>
    <view class="error-page" wx:if="{{ showErrorPage }}">
        接口报错啦
    </view>
    <tabbar />
    <view style="height:50px"></view>
</view>

为什么要说他呢,因为一开始我其实不敢把这个自定义tabbar放在layout里,我的想法是能简单就简单,能少改动就少改动,能不影响之前的就不影响之前的

也就是一开始想“局部修补”,但发现在复杂结构中,局部修补只会带来更多局部修补。

所以一开始我的解决的切入点就是error-pagecontent-page这俩部分,比方说

  • error-page判断有没有自定义tabbar,动态变更高度:calc(100vh - 50px)50px自定义tabbar高度,因为error-pagefixed+100vh,所以他就盖住了自定义tabbar

    • 这里有个点,虽然自定义tabbar层级可能很高,但error-page最终的层级比较还是content-page
  • 考虑到content-page设置了z-index,所以可以把content-pagez-index``设置为unset,这样error-page就能和自定义tabbar进行比较了,从而自定义tabbar不会被盖住

以上2点,就是我一开始解决时想到的点

这里说明下三个方案的优缺点,加上抽取自定义tabbar放到layout的方案

方案 改动成本 可维护性 健壮性 长期收益
❌ 高度调整 error-page ✅ 低 ❌ 差 ❌ 差 ❌ 差
❌ unset z-index content-page ✅ 低 ⚠️ 一般 ⚠️ 一般 ❌ 差
✅ 移动 tabbar 到 layout ❌ 高 ✅ 好 ✅ 好 ✅ 高

这里的主要问题就是:对原结构进行妥协,没有要做到系统性优化

只是把当前问题解决了,他带来的隐藏问题还需要一个一个页面进行查看,那这就和重新优化tabbar引入位置带来的问题一样,也需要一个个页面查看,既然投入的成本类似,那为什么不尝试用更好的办法呢,优化系统结构

不过呢,局部优化和整体优化有时候是有一定的取舍的,也就是不是完全反对局部优化,需要看情况,总结就是

  • 结构有误、问题普遍、未来可期 ⇒ 优化结构
    • 影响到了全部页面,每个页面都可能有类似问题等,后期也有类似问题等
  • 局部异常、代价可控、生命周期短 ⇒ 优化局部
    • 比方说,只有一个页面有问题,或者需要紧急上线

问题&设计

以上是背景,接下来就是一些问题&设计

iphone安全距离

项目 constant() env()
状态 已废弃 ✅ 推荐
兼容性 老版 iOS Safari(12 及以前) iOS 11.2+,现代浏览器支持
用法 constant(safe-area-inset-bottom) env(safe-area-inset-bottom)

其他可用安全距离

变量名 含义
safe-area-inset-top 顶部安全距离(刘海/状态栏)
safe-area-inset-bottom 底部安全距离(Home Indicator)
safe-area-inset-left 左边安全距离(圆角屏)
safe-area-inset-right 右边安全距离(圆角屏)
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);

bottom: constant(safe-area-inset-bottom);
bottom: env(safe-area-inset-bottom);

自定义tabbar引发的一系列高度问题

原先页面中都有一段这样的代码,height: 50px起一个占位作用,因为tabbarfixed布局

<tabbar />
<view style="height:50px"></view>

现在放到了layout组件里

<!-- layout组件代码 --!>
<view>
    <view class="content-page">
        <slot />
    </view>
    <view class="error-page" wx:if="{{ showErrorPage }}">
        接口报错啦
    </view>
    <tabbar />
    <view style="height:50px"></view>
</view>

为什么要说这个呢,因为页面里有些样式会这么写

.my-content-page {
    height: 100vh
}

此时把自定义tabbar组件移到layout组件里,和content-page保持同级,就会导致height: 100vh多了50px,会影响滚动或者其他的样式,所以原来的页面需要减掉50px

layout怎么判断是否展示自定义tabbar

这里推荐使用每个页面传递参数的方法进行标识

<layout is-tab-bar-page="{{ true }}"></layout>

这样layout可以更快的判别是否渲染自定义tabbar,核心原因是微信小程序的组件的生命周期原因

微信小程序的组件的生命周期:created -> attached -> ready

简单理解可以看成vue的生命周期,比如 beforeCreate -> created -> mounted

我在created环节就能拿到props数据了,从而更快的判别是否渲染自定义tabbar

还有个方法可以判别:

可以在mounted(ready)环节,获取页面实例,获取页面名、页面路径,判断是否在tabbar,从而判别是否渲染自定义tabbar

但他是在mounted环节判断的,速度要比beforeCreate(created)慢,所以推荐向子组件传参

我这里的欠缺点,没有考虑到生命周期的影响,我一开始采用的是后者,进行代码review后使用了前者,所以记录下

按钮点击没有反应

这算是个历史bug,刚好讲讲,个人的解决思路,顺便记录下,代码case

api.submit().then(res => { wx.navigate({ url: 'pages/index/index' }) })

手机上看到时,发现点击没反应,没有报错提示,也没有跳转,那么至少可以简单的分析出来,接口可能发生了点问题,从而没有走进跳转逻辑

至于报错提示没有,需要了解处理请求返回结果的逻辑怎么做报错提示的

我这里的代码是只对个别错误码进行了toast提示,个别没有,那接下来该怎么办,复现又不好复现

此时需要让后端人员,查一下接口日志,看看有没有发起请求、请求报错等

我这里得到的结果就是————没有登录。

考虑到,我页面初始化就会判断是否登录,没有登录就会发起登录,所以这里需要分析下,为什么会没有登录,token问题还是什么问题,这就需要让后端人员看了

我这里的欠缺点:KiBanba不熟悉、对没有登录不敏感(因为页面初始化就会发起登录,所以就得确认,是不是token过期了,或者后端逻辑问题等)

面对问题的解决思路

刚好在整理一下问题的解决思路

广泛来说:个人解决 -> AI解决 -> google、社区、issue解决

问题现象有很多:接口报错、没反应、页面加载速度慢、控制台报错、页面异常等

这里主要说明个人解决思路,以我的error-page覆盖问题来说明

  1. 问题是什么,描述问题现象
  2. 寻找触发问题的代码
  3. 判断这是前端问题还是后端问题
  4. 分析造成问题的核心原因,局部方面,系统方面,要带着全局观来看
  5. 提出、评估解决问题的手段,有时候还需要大胆猜测,小心求证
  6. 实践

如果是诡异的问题,可以多多比较,二分法等,比较可以比机型、环境、参数、版本等

如果有解决不了的问题,需要带着代码执行流程的进行模拟一遍,然后分析

总结

多多复盘,查漏补缺,沉着冷静,带着代码流程去分析,保持怀疑

如何在TypeScript里使用类封装枚举来实现Java的枚举形参倒置

作者 Hamm
2025年4月11日 15:43

一、前言

首先,枚举形参倒置 的意思是通过为枚举形参添加一些方法,来减少调用时候传入的形参个数。

🌰举个栗子

long timestamp = System.currentTimeMillis();

// before
DateTimeUtil.format(timestamp, DateTimeFormatter.FULL_DATE);

// after
DateTimeFormatter.FULL_DATE.format(timestamp);

如上示例代码,我们可以在调用时候减少传入一个枚举形参的传入,写出来的代码会更加简洁好看。

还有其他好处吗?

好像没有了...

当然,我们不讨论这两种写法的好处和坏处 (评论区可以讨论) ,我们只聊实现。

二、Java 的实现

众所周知,在 Java 中,枚举本身也是类的特殊封装,而枚举项可以认为是 Java 类的静态实例常量,所以,我们很轻易的就能实现:

2.1 封装枚举类

@Getter
@AllArgsConstructor
public enum DateTimeFormatter {
    /**
     * 年月日
     */
    FULL_DATE("yyyy-MM-dd"),

    /**
     * 时分秒
     */
    FULL_TIME("HH:mm:ss"),

    /**
     * 年月日时分秒
     */
    FULL_DATETIME("yyyy-MM-dd HH:mm:ss"),
    ;

    private final String value;
}

如上,我们声明这个枚举类之后,就可以为这个枚举添加一些我们需要的方法了:

2.2 添加方法

@Getter
@AllArgsConstructor
public enum DateTimeFormatter {
    // 省略定义的枚举项目
    ;

    // 枚举项封装的值
    private final String value;

    /**
     * 使用这个模板格式化毫秒时间戳
     *
     * @param milliSecond 毫秒时间戳
     * @return 格式化后的字符串
     */
    public final @NotNull String format(long milliSecond) {
        return DateTimeUtil.format(milliSecond, this);
    }

    /**
     * 使用这个模板格式化当前时间
     *
     * @return 格式化后的字符串
     */
    public final @NotNull String formatCurrent() {
        return format(System.currentTimeMillis());
    }
}

2.3 调用示例

所以封装完毕之后,我们就可以用这两个方法来实现本文提到的 枚举形参倒置 了:

QQ_1744356483132.png

三、TypeScript 的实现

可是在 TypeScript 中,枚举 并没有像 Java 那样,有 枚举封装 的特性,所以,我们只能通过 来实现这个功能了。可以参考我们这篇文章:TypeScript使用枚举封装和装饰器优雅的定义字典。今天我们就不赘述之前的设计了,可以先阅读之后继续下面的内容。

3.1 封装枚举类

export class DateTimeFormatter extends AirEnum {
  static readonly FULL_DATETIME = new DateTimeFormatter(
    'yyyy-MM-dd HH:mm:ss', '年-月-日 时:分:秒'
  )
  static readonly FULL_DATE = new DateTimeFormatter(
    'yyyy-MM-dd', '年-月-日'
  )
  static readonly FULL_TIME = new DateTimeFormatter(
    'HH:mm:ss', '时-分-秒'
  )
}

我们的枚举就声明好了,当然这里的 例子🌰 其实是多余的,因为 DateTimeFormatter 这种几乎好像大概应该也许是没有什么符合字典业务场景的。

当然,为了举例,你就假设它有。

3.2 添加方法

接下来,我们也同样实现这两个方法:

export class DateTimeFormatter extends AirEnum<string> {
  // 一些静态枚举项目

  /**
   * 格式化毫秒时间戳
   * @param timestamp 毫秒时间戳
   */
  format(timestamp: number) {
    return AirDateTime.formatFromMilliSecond(timestamp)
  }

  /**
   * 格式化当前时间
   */
  formatCurrent() {
    return this.format(Date.now().valueOf())
  }
}

如上,我们实现了和 Java 一样的方法,然后就可以在调用的时候使用这个方法了:

3.3 调用示例

QQ_1744357214249.png

四、总结

本文通过类的封装,来实现了枚举功能以及 枚举形参倒置 的功能,虽然大概可能没什么用。

但我还是建议你先读 TypeScript使用枚举封装和装饰器优雅的定义字典 这个文章。

今天周五,祝大家双休。

Bye。

昨天 — 2025年6月26日技术

前端多维数组扁平化常见的几种方法

作者 抱走白菜
2025年6月26日 18:08
在 JavaScript 中,数组扁平化是指将一个嵌套多层的数组转换为一个一维数组。
以下是几种实现数组扁平化的方法:
一:使用flat()方法:

const array = arr.flat([depth]);

参数 depth 参数可选,默认为1,代表要展开数组的深度级别.

flat()方法是ES2019中的方法

const arr = [1, 2, [3, 4], 5, 6];
const newArr = arr.flat();
console.log(newArr) // [1, 2, 3, 4, 5, 6]

const arr = [1, 2, [3, 4], [5, [6, 7], 8], 9];
const newArr = arr.flat();
console.log(newArr) // [1, 2, 3, 4, 5, [6, 7], 8, 9]

const newArr = arr.flat(2);
console.log(newArr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]

// 指定深度为 Infinity 可以完全扁平化
const arr = [1, 2, [3, 4], [5, [6, 7], 8], 9];
const newArr = arr.flat(Infinity);
console.log(newArr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]

当前方法会深度的递归展开数组,最终返回一个新的一维数组,但是原数组不会改变。

二:使用递归reduce

通过遍历每个元素,将数组元素展开并合并成一个新的的数组。

function flatten(arr){
    return arr.reduce((acc, val)=>{
        return acc.concat(Array.isArray(val) ? flatten(val): val);
    }, []);
};

// 使用:
const arr = [1, 2, [3, 4], [5, [6, 7], 8], 9];
const newArr = flatten(arr);
console.log(newArr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]
三:flatMap()方法

flatMap()方法适合一层嵌套的数组。

const arr = [1, 2, [3, 4], 5, 6];
const newArr = arr.flatMap(item=>item);
console.log(newArr) // [1, 2, 3, 4, 5, 6]

如果使用flatMap()方法处理多层数组,只会结构最外层的单层一层数组。

const arr = [1, 2, [3, 4], [5, [6, 7], 8], 9];
const newArr = arr.flatMap(item=>item);
console.log(newArr) // [1, 2, 3, 4, 5, [6, 7], 8, 9]
四:使用 toString() 和 split()

当前方法只适用于纯数字的数组,这种方法先将数组转换为字符串,然后再转成数组。

const arr = [1, 2, [3, 4], [5, [6, 7], 8], 9];
const newArr = arr.toString().split(',').map(Number);
console.log(newArr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]

arr.toString() 会将数组转换成字符串 1,2,3,4,5,6,7,8,9, 再通过split(',')后转化为['1', '2', '3', '4', '5', '6', '7', '8', '9'],然后遍历数组将string转换为number。

五:使用(非递归方法)
function flatten(arr){
    const result = [];
    const stack = [...arr];
    while(stack?.length > 0){
        // 获取数组的最后一个元素,生成新数组。
        const current = stack.pop();
        if(Array.isArray(current)){
            stack.push(...current);
        }else{
           result.unshift(current); 
        }
    };
    return result;
};

const arr = [1, 2, [3, 4], [5, [6, 7], 8], 9];
console.log(newArr) // [1, 2, 3, 4, 5, 6, 7, 8, 9]
总结:
  • flat()简洁且性能好,但需要 ES2019+ 支持。
  • 递归 + reduce() :灵活且兼容性好,适合处理任意深度的嵌套。
  • flatMap():适合一层嵌套的数组。
  • toString() :仅适用于纯数字数组,简单但有局限性。
  • 栈方法:避免了递归的潜在堆栈溢出问题,适合处理极深的嵌套数组。

Rspack 1.4 发布:支持在浏览器中运行

作者 WebInfra
2025年6月26日 18:02

Rspack 1.4

Rspack 1.4 已经正式发布!

值得关注的变更如下:

  • 新功能
    • 在浏览器中运行
    • 更快的 SWC
    • 更小的构建产物
    • 默认启用增量构建
    • 新增 CssChunkingPlugin
    • 增强 lazy compilation
    • 自定义文件系统
    • 性能分析工具
  • Rstack 进展
    • Rsbuild 1.4
    • Rslib 0.10
    • Rspress 2.0 beta
    • Rsdoctor MCP
    • Rstest 发布
  • 生态系统
    • next-rspack
    • Kmi
  • 升级指南

新功能

在浏览器中运行

从 Rspack 1.4 开始,我们正式引入了 Wasm target 支持,这意味着 Rspack 现在可以在浏览器环境中运行,包括 StackBlitzWebContainers)等在线平台。这使得开发者无需配置本地环境,即可快速创建原型、分享代码示例。

你可以直接体验我们提供的 在线示例,也可以在 这篇文档 中了解 StackBlitz 的使用指南。

ezgif-86ca2fa2b8caa5.gif

在后续版本中,我们将继续优化 Wasm 版本的使用流程和包体积。

同时我们也在开发 @rspack/browser 包,它是专为浏览器环境设计的版本,允许你直接在任何现代浏览器中使用 Rspack,而无需依赖 WebContainers 或是特定平台。

更快的 SWC

在过去几个月中,我们与 SWC 团队持续合作,共同优化 JavaScript 工具链的性能和可靠性。经过一段时间的优化,我们很高兴地看到,SWC 的性能取得了显著提升,使 Rspack 用户和所有基于 SWC 的工具都从中受益:

  • JavaScript parser(解析器)的性能提升了 30%~35%
  • JavaScript minifier(压缩器)的性能提升了 10%
SWC benchmark

以上数据来自:CodSpeed - SWC,对比的基准为 Rspack 1.3 所使用的 SWC 16。

更小的构建产物

在当前版本中,SWC 加强了死代码消除(DCE)能力,结合 Rspack 强大的 tree shaking 功能,使 Rspack 1.4 能够生成体积更小的构建产物。

我们以 react-router 为例进行测试:在源代码中仅引入它的一部分导出,然后对比不同打包工具的构建结果,可以看到 Rspack 生成的包体积最小。

import { BrowserRouter, Routes, Route } from 'react-router';

console.log(BrowserRouter, Routes, Route);

各个打包工具输出的包体积如下:

打包工具 压缩后体积 Gzipped 后体积
Rspack (Rsbuild) 36.35 kB 13.26 kB
webpack 36.96 kB 13.37 kB
Vite 42.67 kB 15.67 kB
Rolldown 42.74 kB 15.17 kB
Rolldown Vite 43.42 kB 15.46 kB
Farm 43.42 kB 15.63 kB
Parcel 44.62 kB 16.07 kB
esbuild 46.12 kB 16.63 kB
Bun 57.73 kB 20.8 kB

以上数据来自:react-router-tree-shaking-compare

默认启用增量构建

通过不断的优化迭代,Rspack 的增量构建功能已趋于稳定,在 Rspack 1.4 中,我们将所有阶段的增量优化设为默认开启,这能够显著加快重新构建的速度,HMR 性能通常可提升 30%-40%,具体提升幅度因项目而异。

下面是一位用户开启增量构建后的性能对比:

incremental benchmark

如果你需要降级到之前的行为,可以设置 experiments.incremental'safe' ,但我们推荐大部分项目直接使用新的默认配置,以获得最佳性能。

export default {
  experiments: {
    // 降级到之前的行为
    incremental: 'safe',
  },
};

新增 CssChunkingPlugin

Rspack 1.4 新增了实验性的 CssChunkingPlugin 插件,专门用于处理 CSS 代码分割。该插件能够确保样式的加载顺序与源代码中的导入顺序保持一致,避免因 CSS 加载顺序错误而导致的 UI 问题。

import { rspack } from '@rspack/core';

export default {
  plugins: [
    new rspack.experiments.CssChunkingPlugin({
      // ...options
    }),
  ],
};

启用 CssChunkingPlugin 后,CSS 模块的代码分割将完全由该插件处理,optimization.splitChunks 配置将不再对 CSS 模块生效,你可以查看 使用文档 了解更多。

该插件由 Next.js 的 CSS Chunking 功能启发而来,感谢 Next.js 团队在这一领域的创新。

增强 lazy compilation

Rspack 现已支持在 MultiCompiler 中启用 lazy compilation,这意味着当你在单次构建中使用多份 Rspack 配置时,可以为不同的 compiler 实例独立设置各自的 lazyCompilation 选项

export default [
  {
    target: 'web',
    experiments: {
      // enable lazy compilation for client
      lazyCompilation: true,
    },
  },
  {
    target: 'node',
    experiments: {
      // disable lazy compilation for server
      lazyCompilation: false,
    },
  },
];

自定义文件读取系统

Rspack 现在允许你自定义 compiler.inputFileSystem(编译器的文件读取系统),该功能可以通过配置 experiments.useInputFileSystem 开启,典型的使用场景包括:

import VirtualModulesPlugin from 'webpack-virtual-modules';

export default {
  entry: './virtualEntry.js',
  plugins: [
    new VirtualModulesPlugin({
      'virtualEntry.js': `console.log('virtual entry')`,
    }),
  ],
  experiments: {
    useInputFileSystem: [/virtualEntry\.js$/],
  },
};

由于自定义的 inputFileSystem 是通过 JavaScript 实现的,可能导致性能下降。为了缓解这个问题,useInputFileSystem 允许你传入一个正则表达式数组,过滤哪些文件需要从自定义的 inputFileSystem 读取,避免因替换原生文件系统而导致的性能开销。

未来我们还计划在 Rspack 中内置虚拟模块支持,从而提供更好的性能和使用体验。

详细用法请参考 文档

性能分析工具

Rspack 1.4 引入了更精确的 tracing 能力,它可以基于 perfetto 进行性能分析,用于快速定位构建性能的瓶颈。

你可以通过 RSPACK_PROFILE 环境变量开启 tracing:

RSPACK_PROFILE=OVERVIEW rspack build

生成的 rspack.pftrace 文件可在 ui.perfetto.dev 中进行可视化分析:

tracing

详细的用法请参考 Tracing 文档

依赖升级

在 Rspack 1.4 中,我们升级了一些主要依赖的版本,包括:

  • Rspack 现在使用 Zod v4 来校验配置的正确性。
  • create-rspack 现在提供 Biome v2 作为可选的代码校验和格式化的工具。

Rstack 进展

Rstack 是一个围绕 Rspack 打造的 JavaScript 统一工具链,具有优秀的性能和一致的架构。

Rsbuild 1.4

Rsbuild 1.4 已与 Rspack 1.4 同步发布,值得关注的特性有:

Chrome DevTools 集成

我们引入了全新的 rsbuild-plugin-devtools-json 插件,通过该插件,你可以无缝集成 Chrome DevTools 的 自动工作区文件夹 (Automatic Workspace Folders) 新特性。这意味着你可以在 DevTools 中直接修改和调试源代码,并将改动保存到本地文件系统。

rsbuild plugin devtools json

改进查询参数

Rsbuild 现在内置支持 .js?raw 查询参数,允许你将 JavaScript、TypeScript 和 JSX 文件的原始内容作为文本导入。这在需要将代码作为字符串进行处理的场景下非常有用(例如展示代码示例)。

import rawJs from './script1.js?raw';
import rawTs from './script2.ts?raw';
import rawJsx from './script3.jsx?raw';

console.log(rawJs); // JS 文件的原始内容
console.log(rawTs); // TS 文件的原始内容
console.log(rawJsx); // JSX 文件的原始内容

改进浏览器兼容性

当你在 monorepo 中引用其他包的 JS 文件时,Rsbuild 现在默认会使用 SWC 编译它们,这有助于避免外部依赖引入的浏览器兼容性问题。

以下图为例,假设 app 的构建目标为 ES2016,utils 的构建目标为 ES2021,当 app/src/index.js 引用 utils/dist/index.js 时,SWC 现在会将它降级到 ES2016。

rsbuild monorepo compile scope

Rslib 0.10

Rslib 0.10 版本已发布,主要新增了以下功能:

ESM 产物优化

Rslib 现在默认生成更简洁清晰、体积更小的 ESM 产物。

rslib esm

构建 Vue 组件库

通过引入 rsbuild-plugin-unplugin-vue 插件,你可以使用 Rslib 生成 Vue 组件库的 bundleless 产物。

import { defineConfig } from '@rslib/core';
import { pluginUnpluginVue } from 'rsbuild-plugin-unplugin-vue';

export default defineConfig({
  plugins: [pluginUnpluginVue()],
  lib: [
    {
      format: 'esm',
      bundle: false,
      output: {
        target: 'web',
      },
    },
  ],
});

输出 IIFE 格式

Rslib 现在可以生成 IIFE 格式 的产物,将代码包裹在函数表达式中。

rslib iife

阅读 博客 进一步了解 Rslib。

Rspress 2.0 beta

我们正在积极开发 Rspress 2.0,并发布了多个 beta 版本。目前,我们已完成大部分代码重构工作,并在 Rspress 2.0 中默认集成 Shiki 来提供更强大的代码高亮功能。

同时,我们正在开发全新的主题,预览效果如下:

rspress theme preview

Rsdoctor MCP

Rsdoctor 推出了 @rsdoctor/mcp-server,结合大模型来帮助你更好地分析构建数据。它能调用 Rsdoctor 的本地构建分析数据,提供智能的分析和优化建议。

Rsdoctor MCP 提供产物分析、依赖分析、产物优化建议和构建优化建议,能够分析产物的体积构成、依赖关系、重复依赖,并针对产物体积优化、代码分割以及构建性能提供相应的优化建议。

Rstest 发布

Rstest 是一个全新的基于 Rspack 的测试框架,它为 Rspack 生态提供了全面、一流的支持,能够轻松集成到现有的 Rspack 项目中,提供与 Jest 兼容的 API。

在这个月,我们发布了 Rstest 的 v0.0.3 版本,初步支持了 Node.js 和 UI 组件的测试,并在我们的 Rsbuild 等多个仓库中接入使用。

rstest

Rstest 目前仍处于早期阶段,我们建议你再关注一段时间,以确保它能够提供更完整的测试能力。

生态系统

next-rspack

自从 Rspack 加入 Next.js 生态 以来,我们的首要目标是提升 next-rspack 的稳定性和测试覆盖率。

在最新版本中,next-rspack 的功能已基本完善,测试覆盖率达到:

  • 生产构建 99.4%
  • 开发构建 98.4%

接下来,我们计划继续推进测试覆盖率至 100%,并进一步优化 next-rspack 的性能表现。

next-rspack

Kmi

Kmi 是一个基于 Umi 和 Rspack 的框架,通过集成 Rspack 作为打包工具,Kmi 带来了数倍于 webpack 版本的性能提升。

对于正在使用 Umi 框架的开发者而言,Kmi 提供了一种渐进式的迁移路径,让他们能够在保持项目稳定性的同时,享受 Rspack 带来的性能优势。

更多信息请参考 Kmi 仓库

升级指南

升级 SWC 插件

如果你的项目中使用了 SWC Wasm 插件(如 @swc/plugin-emotion 等),需要将插件升级至兼容 swc_core@29 的版本,否则可能因版本不兼容导致构建报错。

详情请查阅:常见问题 - SWC 插件版本不匹配

Lazy compilation 中间件

Lazy compilation 中间件的接入方式有所变化,该中间件现在可以从 compiler 实例中自动读取 lazyCompilation 选项,因此你不再需要手动传入 lazyCompilation 选项。

import { experiments, rspack } from '@rspack/core';
import { RspackDevServer } from '@rspack/dev-server';

const compiler = rspack([
  // ...multiple configs
]);

// no longer need to pass options to the middleware
const middleware = experiments.lazyCompilationMiddleware(compiler);

const server = new RspackDevServer(
  {
    setupMiddlewares: other => [middleware, ...other],
  },
  compiler,
);

鸿蒙手势识别:流畅的拖拽识别区域设置

作者 Geekwaner
2025年6月26日 18:01

1. 目标

我们要实现的是一个屏幕识别区域设置功能,用户可以通过拖拽手势动态调整识别区域的位置和大小。这种交互方式在答题类应用、OCR识别工具、截图标注等场景中都有广泛应用。

核心需求

  • 用户可以通过触摸拖拽调整识别区域
  • 实时显示调整过程中的区域变化
  • 提供视觉反馈增强用户体验
  • 智能边界控制防止区域超出有效范围

2. PanGesture手势识别核心实现

1. 手势基础配置

首先,我们来看手势的基础配置:

  • 主要代码
// 主要识别区域
Column() {
  // 上部分蒙层
  Row()
    .width('100%')
    .height(this.cropAreaY)

  // 识别区域
  Stack() {
    // 识别区域背景
    Column()
      .width('100%')
      .height('100%')
      .backgroundColor(this.isDragging ? '#fffc8d56' : '#FD7D3F')

    Image($r("app.media.ic_area"))
      .height(this.cropAreaHeight < 50 ? '100%' : 50)
      .objectFit(ImageFit.Contain)
  }
  .width('100%')
  .opacity(this.selectedImageUri ? 0.5 : 1)
  .height(this.cropAreaHeight)

  // 下部分蒙层
  Row()
    .width('100%')
    .layoutWeight(1)
}
.layoutWeight(1)
.gesture(
  PanGesture({ fingers: 1, distance: 1 })
    .onActionStart((event: GestureEvent) => {
      this.isDragging = true;
      this.startPoint = {
        x: event.fingerList[0].globalX,
        y: event.fingerList[0].globalY
      };
      console.log('开始拖拽识别');
    })
    .onActionUpdate((event: GestureEvent) => {
      this.handlePanGesture(event);
    })
    .onActionEnd((event: GestureEvent) => {
      this.isDragging = false;
      console.log('结束拖拽识别');
    })
)
  • 识别手势代码
.gesture(
  PanGesture({ fingers: 1, distance: 1 })
    .onActionStart((event: GestureEvent) => { ... })
    .onActionUpdate((event: GestureEvent) => { ... })
    .onActionEnd((event: GestureEvent) => { ... })
)

配置参数解析:

  • fingers: 1 - 指定需要1根手指触发手势,确保是单指操作
  • distance: 1 - 设置最小触发距离为1像素,让手势响应更加敏感

原理:通过设置顶部的高度来确定识别区域 cropAreaY为首次触碰的纵坐标

2. 手势生命周期详解

onActionStart - 手势开始阶段

.onActionStart((event: GestureEvent) => {
  this.isDragging = true;
  this.startPoint = {
    x: event.fingerList[0].globalX,
    y: event.fingerList[0].globalY
  };
  console.log('开始拖拽识别');
})

关键功能:

  • 状态标记:设置isDragging = true,标识进入拖拽状态
  • 起始点记录:保存手指触摸的全局坐标globalXglobalY
  • 状态初始化:为后续的区域计算提供基准点

技术要点:

  • 使用event.fingerList[0]获取第一个手指的信息
  • globalX/globalY提供相对于整个屏幕的绝对坐标
  • 状态管理确保只有在拖拽时才进行区域更新

onActionUpdate - 手势更新阶段

.onActionUpdate((event: GestureEvent) => {
  this.handlePanGesture(event);
})

这里调用了核心的手势处理函数handlePanGesture,我们来详细分析:

private handlePanGesture = (event: GestureEvent) => {
  if (!this.isDragging) {
    return; // 防护性检查
  }

  const currentY = event.fingerList[0].globalY;

  // 计算识别区域参数
  const minY = Math.min(this.startPoint.y, currentY);
  const maxY = Math.max(this.startPoint.y, currentY);

  // 定义底部操作区域高度
  const bottomOperationAreaHeight = 100;
  // 计算可用的最大高度
  const maxAvailableHeight = this.pageHeight - bottomOperationAreaHeight;

  // 应用边界限制
  this.cropAreaY = Math.max(0, minY);

  // 限制识别区域高度
  const calculatedHeight = maxY - minY;
  const maxAllowedHeight = maxAvailableHeight - this.cropAreaY;
  this.cropAreaHeight = Math.min(calculatedHeight, maxAllowedHeight);

  // 确保识别区域高度不为负数
  if (this.cropAreaHeight < 0) {
    this.cropAreaHeight = 0;
  }
}

实现亮点:

  1. 智能区域计算
    • 使用Math.min/Math.max自动确定矩形区域的上下边界
    • 支持从上往下拖拽和从下往上拖拽两种操作方式
  1. 动态边界控制
    • 上边界限制:Math.max(0, minY)确保不超出屏幕顶部
    • 下边界保护:预留100像素操作区域,防止覆盖按钮
    • 高度约束:动态计算最大允许高度
  1. 实时状态更新
    • 直接更新@State变量,触发UI自动重渲染
    • 无需手动调用刷新方法,响应速度快

onActionEnd - 手势结束阶段

.onActionEnd((event: GestureEvent) => {
  this.isDragging = false;
  console.log('结束拖拽识别');
})

清理工作:

  • 重置拖拽状态标记
  • 结束拖拽模式,恢复正常显示状态

3. 视觉反馈机制

在拖拽过程中,系统提供了丰富的视觉反馈:

// 识别区域背景色动态变化
.backgroundColor(this.isDragging ? '#fffc8d56' : '#FD7D3F')
  • 正常状态:橙色背景 #FD7D3F
  • 拖拽状态:半透明黄色 #fffc8d56
  • 实时响应:基于isDragging状态自动切换

3. 坐标系统与数据持久化

坐标转换机制

// 保存时:vp -> px -> 归一化
let region: image.Region = {
  x: 0,
  y: vp2px(this.cropAreaY) / screenHeight,
  size: {
    width: 0,
    height: vp2px(this.cropAreaHeight) / screenHeight
  }
};

// 加载时:归一化 -> px -> vp
this.cropAreaY = px2vp(region.y * screenHeight);
this.cropAreaHeight = px2vp(region.size.height * screenHeight);

设计优势:

  • 设备适配:使用归一化坐标适配不同屏幕尺寸
  • 精度保持:通过标准坐标转换保证显示一致性
  • 数据压缩:相对坐标占用更少存储空间

4. 实际应用效果

用户操作流程

  1. 触摸开始 → 记录起始坐标,进入拖拽模式
  2. 拖拽移动 → 实时计算区域范围,更新UI显示
  3. 松开手指 → 确定最终区域,退出拖拽模式
  4. 保存设置 → 归一化坐标并持久化存储

5. 完整代码

import photoAccessHelper from '@ohos.file.photoAccessHelper';
import { BusinessError } from '@ohos.base';
import image from '@ohos.multimedia.image';
import { promptAction } from "@kit.ArkUI";
import display from '@ohos.display';
import { PreferencesConstants, preferencesUtils } from 'utils';

const TAG = 'ScreenReadSetting';

@Component
export struct ScreenReadSetting {
  @Consume('pathStack') pathStack: NavPathStack;
  @State title: string = '读屏设置';
  @State selectedImageUri: string = '';
  // 识别相关状态
  @State startPoint: Position = { x: 0, y: 0 }; // 起始点
  @State cropAreaY: number = 60; // 识别区域Y坐标,默认60
  @State cropAreaHeight: number = 200; // 识别区域高度,默认200
  @State isDragging: boolean = false;
  @State pageHeight: number = 0; // 页面高度

  // 组件即将出现时的回调
  aboutToAppear() {
    this.loadSettings();
  }

  // 加载之前保存的设置
  private async loadSettings() {
    try {
      const savedSettings = preferencesUtils.getString(PreferencesConstants.SCREEN_READ_SETTING, '');
      let region: image.Region
      if (savedSettings) {
        region = JSON.parse(savedSettings);
      } else {
        region =
          { x: 0, y: 0.0721, size: { width: 0, height: 0.493 } }
        console.log(TAG, '没有找到保存的设置,使用默认值');
      }

      console.log(TAG, '加载保存的设置:', JSON.stringify(region));

      // 获取屏幕高度
      const displayInfo = display.getDefaultDisplaySync();
      const screenHeight = displayInfo.height;

      // 将归一化的坐标转换回像素值,再转换为vp
      this.cropAreaY = px2vp(region.y * screenHeight);
      this.cropAreaHeight = px2vp(region.size.height * screenHeight);

      console.log(TAG, '恢复的识别区域 - Y:', this.cropAreaY, 'Height:', this.cropAreaHeight);
    } catch (error) {
      console.error(TAG, '加载设置失败:', error);
      // 如果加载失败,保持默认值
    }
  }

  // 从相册选取
  private async selectFromGallery() {
    console.log('从相册选取');

    try {
      const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
      photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
      photoSelectOptions.maxSelectNumber = 1;

      const photoPicker = new photoAccessHelper.PhotoViewPicker();
      let photoSelectResult = await photoPicker.select(photoSelectOptions);

      if (photoSelectResult && photoSelectResult.photoUris && photoSelectResult.photoUris.length > 0) {
        this.selectedImageUri = photoSelectResult.photoUris[0];
        console.log('选择的图片URI:', this.selectedImageUri);
      } else {
        console.log('用户取消了图片选择');
      }
    } catch (error) {
      const err = error as BusinessError;
      console.error('选择图片失败:', err.code, err.message);
    }
  }

  // 保存设置
  private saveSettings() {
    console.log('保存设置');

    // 获取屏幕宽度
    const displayInfo = display.getDefaultDisplaySync();
    const screenHeight = displayInfo.height;

    console.log(TAG, 'screenHeight =' + JSON.stringify(screenHeight))


    // 输出符合image.Region格式的区域信息
    let region: image.Region = {
      x: 0,
      y: vp2px(this.cropAreaY) / screenHeight, // 区域左上角纵坐标
      size: {
        width: 0,
        height: vp2px(this.cropAreaHeight) / screenHeight
      }
    };

    // 存入首选项
    preferencesUtils.putString(PreferencesConstants.SCREEN_READ_SETTING, JSON.stringify(region))
    console.log(TAG, 'region =' + JSON.stringify(region))

    promptAction.showToast({
      message: '保存成功',
    })
    // 返回上一页
    this.pathStack.pop();
  }

  // 处理拖拽手势 - 自由拖拽调整识别区域
  private handlePanGesture = (event: GestureEvent) => {
    if (!this.isDragging) {
      return;
    }

    const currentY = event.fingerList[0].globalY;

    // 计算识别区域参数
    const minY = Math.min(this.startPoint.y, currentY);
    const maxY = Math.max(this.startPoint.y, currentY);

    // 定义底部操作区域高度
    const bottomOperationAreaHeight = 100;
    // 计算可用的最大高度(页面高度减去底部操作区域高度)
    const maxAvailableHeight = this.pageHeight - bottomOperationAreaHeight;

    // 应用边界限制
    this.cropAreaY = Math.max(0, minY);

    // 限制识别区域高度,确保不超过底部操作区域
    const calculatedHeight = maxY - minY;
    const maxAllowedHeight = maxAvailableHeight - this.cropAreaY;
    this.cropAreaHeight = Math.min(calculatedHeight, maxAllowedHeight);

    // 确保识别区域高度不为负数
    if (this.cropAreaHeight < 0) {
      this.cropAreaHeight = 0;
    }
    console.log(TAG, JSON.stringify(this.cropAreaY))
    console.log(TAG, JSON.stringify(this.cropAreaY))
  }

  build() {
    NavDestination() {
      Stack({ alignContent: Alignment.Top }) {
        Column() {
          // 主要识别区域
          Column() {
            // 上部分蒙层
            Row()
              .width('100%')
              .height(this.cropAreaY)

            // 识别区域
            Stack() {
              // 识别区域背景
              Column()
                .width('100%')
                .height('100%')
                .backgroundColor(this.isDragging ? '#fffc8d56' : '#FD7D3F')

              Image($r("app.media.ic_area"))
                .height(this.cropAreaHeight < 50 ? '100%' : 50)
                .objectFit(ImageFit.Contain)
            }
            .width('100%')
            .opacity(this.selectedImageUri ? 0.5 : 1)
            .height(this.cropAreaHeight)

            // 下部分蒙层
            Row()
              .width('100%')
              .layoutWeight(1)
          }
          .layoutWeight(1)
          .gesture(
            PanGesture({ fingers: 1, distance: 1 })
              .onActionStart((event: GestureEvent) => {
                this.isDragging = true;
                this.startPoint = {
                  x: event.fingerList[0].globalX,
                  y: event.fingerList[0].globalY
                };
                console.log('开始拖拽识别');
              })
              .onActionUpdate((event: GestureEvent) => {
                this.handlePanGesture(event);
              })
              .onActionEnd((event: GestureEvent) => {
                this.isDragging = false;
                console.log('结束拖拽识别');
              })
          )

          // 底部操作区域
          Column() {
            Row() {
              // 选取按钮
              Column() {
                Image($r('app.media.ic_photo'))
                  .width(22)
                  .height(22)
                  .fillColor('#333333')
                Text('选取')
                  .fontSize(14)
                  .fontColor('#333333')
                  .margin({ top: 4 })
              }
              .padding({ left: 20 })
              .onClick(() => {
                this.selectFromGallery();
              })

              Blank()

              // 取消按钮
              Button('取消')
                .backgroundColor(Color.White)
                .fontColor('#FF7F50')
                .fontSize(18)
                .borderWidth(2)
                .borderColor('#FF7F50')
                .borderRadius(25)
                .width(152)
                .height(50)
                .onClick(() => {
                  this.pathStack.pop();
                })

              // 保存按钮
              Button('保存')
                .backgroundColor('#FF7F50')
                .fontColor(Color.White)
                .fontSize(18)
                .borderRadius(25)
                .width(152)
                .height(50)
                .margin({ left: 12 })
                .onClick(() => {
                  this.saveSettings();
                })
            }
            .width('100%')
            .height(54)

            Text('提示:先在答题界面截图,选取图片设置题目区域更精确。')
              .fontSize(12)
              .fontWeight(FontWeight.Bold)
              .fontColor('#333333')
              .height(20)
              .textAlign(TextAlign.Center)
              .margin({ top: 8 })
          }
          .width('100%')
          .height(100)
          .backgroundColor(Color.White)
        }
        .width('100%')
        .height('100%')
      }
      .onAreaChange((oldValue: Area, newValue: Area) => {
        // 获取页面尺寸
        this.pageHeight = Number(newValue.height);

        console.log(TAG, '页面尺寸:', this.pageHeight)
      })
    }
    .hideTitleBar(true)
    .backgroundColor(this.selectedImageUri ? Color.Transparent : '')
    .backgroundImage(this.selectedImageUri)
    .backgroundImageSize(ImageSize.Cover)
  }
}

// 位置接口定义
interface Position {
  x: number;
  y: number;
}

探索 CSS 3D 世界:打造动态 3D 摆动画交互体验

2025年6月26日 18:01

引言:当物理规律遇见 CSS 魔法

在现代网页设计中,CSS 3D 变换与动画的结合正不断突破视觉表现的边界。本文将深入解析一个基于纯 CSS 实现的 3D 摆动画效果 —— 这个案例不仅展现了 CSS 3D 变换的强大能力,还通过物理运动规律与视觉设计的结合,创造出富有韵律感的交互体验。让我们一起拆解这个案例背后的技术原理与设计思路。


一、3D 摆动画的技术架构解析

1. CSS 变量构建可维护的设计系统

:root {
    --color-transparent: rgba(0,0,0,0);
    --color-black: #000;
    --color-white: #fff;
    --color-random-bg: rgba(128,128,128,0.33);
    --color-green: rgb(169, 197, 47);
    --color-blue: rgb(44, 93, 99);
    --color-dark: rgb(40, 55, 57);
    --color-light: rgb(247, 238, 187);

    /* 尺寸与字体 */
    --font-primary: 'Podkova', serif;
    --font-secondary: 'Trebuchet MS', Helvetica, sans-serif;
    --font-size: calc(1.1vw + 1.1vh - 0.6vmin);

    /* 动画时间*/
    --animation-time: 4s;
}

设计原则:

  • 颜色分层:区分背景色、主体色、交互色等不同层级
  • 动态单位:使用 vw/vh/vmin 等视口单位实现响应式布局
  • 时间统一:通过单一动画时间变量控制整体节奏

2. 3D 场景搭建:透视与变换的组合拳

3D 摆的视觉效果主要通过 CSS 3D 变换实现,核心在于 perspective 属性与 transform 属性的配合:

#sect {
    width: 100vw;
    height: 100vh;
    perspective: 600px; /* 定义视距,决定3D效果的强弱 */
    position: relative;
}

#sect ul {
    transform-style: preserve-3d; /* 关键属性,保持子元素的3D变换 */
    transform: translateZ(-70vmax) translateX(-50vw) rotateY(0deg);
    transition: all calc(var(--animation-time) / 3);
}

技术要点:

  • 透视原理perspective 值越小,3D 效果越强烈,600px 是兼顾真实感与可读性的平衡点
  • Z 轴定位:通过 translateZ 将整个场景推入 3D 空间,创造深度感
  • 变换原点:每个摆的 transform-origin: center -123vmax 设置了摆动的轴心点

二、物理运动的 CSS 动画实现

摆的运动效果是整个案例的核心,通过 CSS 关键帧动画模拟真实物理世界中的摆动规律:

@keyframes pendulum {
    from { transform: translateY(70vh) rotateX(-45deg); }
    to { transform: translateY(70vh) rotateX(45deg); }
}

#sect li {
    animation: pendulum ease-in-out infinite alternate var(--animation-time);
    /* 每个摆设置不同的动画延迟,形成错落有致的效果 */
    animation-delay: -0.1s; /* 第一个摆 */
}

动画设计精妙之处:

  • 运动轨迹:通过 rotateX 实现 X 轴旋转,模拟摆的左右摆动
  • 时间差设计:20 个摆分别设置 -0.1s 至 -2s 的动画延迟,形成类似"多米诺骨牌"的连锁反应
  • 缓动函数ease-in-out 实现自然的加速-减速运动,符合物理规律
  • 交替播放alternate 关键字让动画在到达终点后反向播放,形成连续摆动

三、金属质感球体的 CSS 设计艺术

每个摆的末端球体采用了复杂的 CSS 样式设计,通过多层渐变与阴影模拟金属质感:

.ball {
    width: 2.2em;
    height: 2.2em;
    border-radius: 50%;
    /* 径向渐变模拟球体表面光照 */
    background: radial-gradient(circle at 65% 35%, #f8fafd 0%, #bfc9d1 20%, #6b7a8f 60%, #222 100%);
    /* 多层阴影打造立体感 */
    box-shadow:
        0 0.3em 0.7em 0.1em rgba(0,0,0,0.5),
        0 0.1em 0.2em 0.05em rgba(0,0,0,0.3),
        inset 0.2em 0.2em 0.8em 0.1em rgba(255,255,255,0.7),
        inset -0.4em -0.4em 1.2em 0.1em rgba(0,0,0,0.5);
    /* 金属边框效果 */
    border: 0.18em solid #444c55;
}

/* 球体高光细节 */
.ball::before {
    content: '';
    position: absolute;
    left: 0.1em;
    top: 0.1em;
    width: 2em;
    height: 2em;
    border-radius: 50%;
    background: linear-gradient(90deg, rgba(60,60,60,0.25) 0%, rgba(255,255,255,0.12) 40%, rgba(60,60,60,0.25) 100%);
    z-index: 1;
    pointer-events: none;
    opacity: 0.7;
}

.ball::after {
    content: '';
    position: absolute;
    left: 1.1em;
    top: 0.5em;
    width: 0.4em;
    height: 0.4em;
    border-radius: 50%;
    background: rgba(255,255,255,0.95);
    filter: blur(1px); /* 模糊处理让高光更自然 */
    pointer-events: none;
    opacity: 0.85;
    z-index: 2;
}

金属质感实现要点:

  • 径向渐变:通过非中心定位的径向渐变模拟光源照射效果
  • 内外阴影结合:外部阴影模拟环境光,内部阴影塑造球体凹陷感
  • 边框处理:深灰色边框增加金属厚度感
  • 高光细节:通过伪元素添加局部高光点,增强镜面反射效果

四、交互设计:视图切换的 3D 变换逻辑

案例中的视图切换功能通过复选框与 CSS 选择器实现,展现了纯 CSS 交互的可能性:

<input type="checkbox" id="toggle" checked />
<label for="toggle" class="toggle">change view</label>
#toggle:checked + #sect ul {
    transform: translateZ(-50em) translateX(0vw) rotateY(90deg);
}

#toggle:checked + #sect li {
    /* 切换视图后调整各摆的透明度,形成深度感 */
    opacity: 0.5; /* 示例值,实际有20个不同的透明度设置 */
}

交互逻辑核心实现:

  • 纯 CSS 交互:利用 :checked 伪类选择器触发样式变化,无需 JavaScript
  • 3D 场景转换:通过 rotateY(90deg) 实现场景的旋转,配合 Z 轴位移创造深度变化
  • 分层设计:切换视图后通过透明度差异 (opacity) 强化 3D 层次感
  • 动画过渡:所有变换都添加了过渡效果,保证交互流畅性

五、响应式设计与性能优化

案例中包含的响应式设计细节:

@media screen and (max-width: 600px) {
    body > * {
        font-size: 1.5em;
    }
}

优化建议:

  • 基于视口的单位:使用 vw/vh/vmin 等单位替代固定像素值
  • 动态字体计算:通过 clamp() 函数实现更灵活的字体大小适配
  • 3D 性能优化
    • 减少过度复杂的 3D 变换
    • 利用 will-change 属性提前告知浏览器动画变化
    • 对非关键元素使用 transform-style: flat 提升性能

六、扩展可能性:从摆动画到物理模拟系统

这个基础案例可以从多个维度进行扩展:

  1. 物理模拟增强
    • 添加重力参数可调的物理引擎
    • 实现摆之间的相互作用力模拟
    • 增加空气阻力等环境因素
  2. 交互功能扩展
    • 鼠标拖拽控制摆的初始位置
    • 触摸屏支持手势交互
    • 声音反馈与摆动节奏同步
  3. 视觉效果升级
    • 添加光影追踪效果
    • 实现材质动态变化
    • 结合 CSS 变量实现主题切换功能

源代码 index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>3D Pendulum Animation</title>
    <style>
        /* =====================
           变量定义与基础重置
        ===================== */
        :root {
            /* 颜色变量 */
            --color-transparent: rgba(0,0,0,0);
            --color-black: #000;
            --color-white: #fff;
            --color-random-bg: rgba(128,128,128,0.33); /* 简化随机背景色 */
            --color-green: rgb(169, 197, 47);
            --color-blue: rgb(44, 93, 99);
            --color-dark: rgb(40, 55, 57);
            --color-light: rgb(247, 238, 187);

            /* 字体和尺寸 */
            --font-primary: 'Podkova', serif;
            --font-secondary: 'Trebuchet MS', Helvetica, sans-serif;
            --font-size: calc(1.1vw + 1.1vh - 0.6vmin);

            /* 动画时间 */
            --animation-time: 4s;
        }

        * {
            outline: none;
            box-sizing: border-box;
        }

        html {
            background-color: var(--color-black);
            width: 100vw;
            height: 100vh;
            overflow: hidden;
        }

        body {
            font-family: var(--font-primary);
            font-size: var(--font-size);
            color: var(--color-white);
            background-color: var(--color-random-bg);
            margin: 0;
            width: 100%;
            height: 100%;
            overflow: hidden;
        }

        /* =====================
           响应式字体适配
        ===================== */
        @media screen and (max-width: 600px) {
            body > * {
                font-size: 1.5em;
            }
        }

        /* =====================
           交互控件样式
        ===================== */
        #toggle {
            display: none;
        }

        .toggle {
            position: fixed;
            z-index: 10;
            left: 1em;
            top: 1em;
            display: inline-block;
            padding: 0.4em 0.5em 0.5em;
            cursor: pointer;
            text-indent: 1.7em;
            color: var(--color-green);
            border-radius: 0.25em;
            transition: all calc(var(--animation-time) / 5);
            background-color: var(--color-dark);
        }
        .toggle::before {
            content: '';
            position: absolute;
            z-index: 20;
            left: 0.5em;
            top: 0.4em;
            width: 1em;
            height: 1em;
            display: inline-block;
            border: 2px solid var(--color-blue);
            vertical-align: middle;
            border-radius: 3px;
        }
        .toggle::after {
            content: '';
            position: absolute;
            width: 0;
            height: 0;
            z-index: 21;
            display: inline-block;
            border: 2px solid var(--color-light);
            border-width: 0 4px 4px 0;
            left: 0.75em;
            top: 0.75em;
            opacity: 0;
            transition: all calc(var(--animation-time) / 5);
            transform: rotate(45deg);
        }
        #toggle:checked + #sect .toggle {
            color: var(--color-light);
        }
        #toggle:checked + #sect .toggle::after {
            width: 0.5em;
            top: 0.25em;
            height: 1em;
            opacity: 1;
        }

        /* =====================
           3D 场景容器样式
        ===================== */
        #sect {
            width: 100vw;
            height: 100vh;
            padding: 1em;
            text-align: center;
            display: block;
            position: relative;
            perspective: 600px; /* 3D 透视效果 */
        }

        /* =====================
           摆动画主结构样式
        ===================== */
        #sect ul {
            display: block;
            position: absolute;
            top: 0;
            left: 0;
            right: 0;
            bottom: 0;
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
            transition: all calc(var(--animation-time) / 3);
            transform-style: preserve-3d; /* 保持子元素3D变换 */
            transform: translateZ(-70vmax) translateX(-50vw) rotateY(0deg);
        }
        #toggle:checked + #sect ul {
            /* 切换视图时的3D旋转 */
            transform: translateZ(-50em) translateX(0vw) rotateY(90deg);
        }

        /* =====================
           摆锤单元样式
        ===================== */
        #sect li {
            display: inline-block;
            position: absolute;
            font-size: 3em;
            margin-left: -5em;
            transition: all calc(var(--animation-time) / 5);
            opacity: 1;
            color: var(--color-light);
            transform-origin: center -123vmax; /* 摆动轴心点 */
            animation: pendulum ease-in-out infinite alternate var(--animation-time);
            list-style-type: none;
        }
        #sect li::before {
            content: '';
            position: absolute;
            bottom: 100%;
            width: 1px;
            box-shadow: 0 0 0 0.01em var(--color-green);
            height: 25em;
            left: 50%;
            background-color: var(--color-green);
        }

        /* ===============
           摆锤分布与延迟
        =============== */
        /* 每个li的水平位置和动画延迟,形成连锁反应 */
        #sect li:nth-of-type(1) { left: 2.5em; animation-delay: -0.1s; }
        #sect li:nth-of-type(2) { left: 5em; animation-delay: -0.2s; }
        #sect li:nth-of-type(3) { left: 7.5em; animation-delay: -0.3s; }
        #sect li:nth-of-type(4) { left: 10em; animation-delay: -0.4s; }
        #sect li:nth-of-type(5) { left: 12.5em; animation-delay: -0.5s; }
        #sect li:nth-of-type(6) { left: 15em; animation-delay: -0.6s; }
        #sect li:nth-of-type(7) { left: 17.5em; animation-delay: -0.7s; }
        #sect li:nth-of-type(8) { left: 20em; animation-delay: -0.8s; }
        #sect li:nth-of-type(9) { left: 22.5em; animation-delay: -0.9s; }
        #sect li:nth-of-type(10) { left: 25em; animation-delay: -1s; }
        #sect li:nth-of-type(11) { left: 27.5em; animation-delay: -1.1s; }
        #sect li:nth-of-type(12) { left: 30em; animation-delay: -1.2s; }
        #sect li:nth-of-type(13) { left: 32.5em; animation-delay: -1.3s; }
        #sect li:nth-of-type(14) { left: 35em; animation-delay: -1.4s; }
        #sect li:nth-of-type(15) { left: 37.5em; animation-delay: -1.5s; }
        #sect li:nth-of-type(16) { left: 40em; animation-delay: -1.6s; }
        #sect li:nth-of-type(17) { left: 42.5em; animation-delay: -1.7s; }
        #sect li:nth-of-type(18) { left: 45em; animation-delay: -1.8s; }
        #sect li:nth-of-type(19) { left: 47.5em; animation-delay: -1.9s; }
        #sect li:nth-of-type(20) { left: 50em; animation-delay: -2s; }

        /* ===============
           视图切换时的透明度变化,增强3D层次感
        =============== */
        #toggle:checked + #sect li:nth-of-type(1) { opacity: 1; }
        #toggle:checked + #sect li:nth-of-type(2) { opacity: 0.95; }
        #toggle:checked + #sect li:nth-of-type(3) { opacity: 0.9; }
        #toggle:checked + #sect li:nth-of-type(4) { opacity: 0.85; }
        #toggle:checked + #sect li:nth-of-type(5) { opacity: 0.8; }
        #toggle:checked + #sect li:nth-of-type(6) { opacity: 0.75; }
        #toggle:checked + #sect li:nth-of-type(7) { opacity: 0.7; }
        #toggle:checked + #sect li:nth-of-type(8) { opacity: 0.65; }
        #toggle:checked + #sect li:nth-of-type(9) { opacity: 0.6; }
        #toggle:checked + #sect li:nth-of-type(10) { opacity: 0.55; }
        #toggle:checked + #sect li:nth-of-type(11) { opacity: 0.5; }
        #toggle:checked + #sect li:nth-of-type(12) { opacity: 0.45; }
        #toggle:checked + #sect li:nth-of-type(13) { opacity: 0.4; }
        #toggle:checked + #sect li:nth-of-type(14) { opacity: 0.35; }
        #toggle:checked + #sect li:nth-of-type(15) { opacity: 0.3; }
        #toggle:checked + #sect li:nth-of-type(16) { opacity: 0.25; }
        #toggle:checked + #sect li:nth-of-type(17) { opacity: 0.2; }
        #toggle:checked + #sect li:nth-of-type(18) { opacity: 0.15; }
        #toggle:checked + #sect li:nth-of-type(19) { opacity: 0.1; }
        #toggle:checked + #sect li:nth-of-type(20) { opacity: 0.05; }

        /* ===============
           摆动动画关键帧
        =============== */
        @keyframes pendulum {
            from { transform: translateY(70vh) rotateX(-45deg); }
            to   { transform: translateY(70vh) rotateX(45deg); }
        }

        /* ===============
           3D小钢珠样式
        =============== */
        .ball {
            display: block;
            width: 2.2em;
            height: 2.2em;
            border-radius: 50%;
            background: radial-gradient(circle at 65% 35%, #f8fafd 0%, #bfc9d1 20%, #6b7a8f 60%, #222 100%);
            box-shadow:
                0 0.3em 0.7em 0.1em rgba(0,0,0,0.5),
                0 0.1em 0.2em 0.05em rgba(0,0,0,0.3),
                inset 0.2em 0.2em 0.8em 0.1em rgba(255,255,255,0.7),
                inset -0.4em -0.4em 1.2em 0.1em rgba(0,0,0,0.5);
            position: relative;
            border: 0.18em solid #444c55; /* 金属边 */
        }
        .ball::before {
            content: '';
            position: absolute;
            left: 0.1em;
            top: 0.1em;
            width: 2em;
            height: 2em;
            border-radius: 50%;
            background: linear-gradient(90deg, rgba(60,60,60,0.25) 0%, rgba(255,255,255,0.12) 40%, rgba(60,60,60,0.25) 100%);
            z-index: 1;
            pointer-events: none;
            opacity: 0.7;
        }
        .ball::after {
            content: '';
            position: absolute;
            left: 1.1em;
            top: 0.5em;
            width: 0.4em;
            height: 0.4em;
            border-radius: 50%;
            background: rgba(255,255,255,0.95);
            filter: blur(1px);
            pointer-events: none;
            opacity: 0.85;
            z-index: 2;
        }
    </style>
</head>
<body>
<!-- 视图切换开关 -->
<input type="checkbox" id="toggle" checked />

<!-- 3D 摆动画主容器 -->
<section id="sect">
    <!-- 视图切换按钮 -->
    <label for="toggle" class="toggle">change view</label>
    <!-- 摆锤列表 -->
    <ul>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
        <li><span class="ball"></span></li>
    </ul>
</section>
</body>
</html>

结语:CSS 3D 的无限可能

这个 3D 摆动画案例展示了 CSS 在实现复杂 3D 交互效果上的潜力。从物理运动模拟到金属质感设计,再到纯 CSS 交互逻辑,每一个细节都体现了现代前端设计的精巧构思。随着浏览器对 CSS 特性的支持不断深入,我们有理由相信,未来会有更多令人惊叹的 3D 交互效果通过纯 CSS 实现。

对于开发者而言,这个案例的启示在于:不必局限于传统的网页设计思维,通过深入理解 CSS 3D 变换、动画系统与交互逻辑,我们能够创造出兼具视觉美感与交互体验的创新作品。而对于设计者来说,这则是一个将物理规律、视觉设计与用户体验完美结合的典范。

无论是用于数据可视化、艺术展示还是交互组件,这种基于 CSS 的 3D 实现方式都为我们打开了一扇通往无限可能的大门。

Vulkan Tutorial 教程翻译(八)顶点缓冲区

作者 siwangqishiq2
2025年6月26日 17:38

顶点缓冲

顶点输入描述

介绍

在后面的几个章节中,我们准备使用内存中的顶点缓冲去替换硬编码在顶点着色器中的顶点数据。首先我们使用最简单的,从CPU可见内存中利用 memcpy 直接拷贝数据的方式,之后我们会使用一个暂存缓冲区拷贝数据到高性能内存的方式。

顶点着色器

首先修改顶点着色器让它不再包含顶点数据,使用 in 关键字去接收顶点缓存数据.

#version 450

layout(location = 0) in vec2 inPosition;
layout(location = 1) in vec3 inColor;

layout(location = 0) out vec3 fragColor;

void main(){
    gl_Position = vec4(inPosition, 0.0 , 1.0);
    fragColor = inColor;
}

inPosition 和 inColor 变量是顶点属性,它们是在顶点缓冲区中逐顶点设置的属性,就像我们手动通过两个数组指定每个顶点的颜色和位置一样,确保重新编译了这个顶点着色器.

就像 fragColor,layout(location = x) 注解标注了出的索引,之后可以引用它们,有一点很重要,它们中的一些类型,例如 dvec3 代表64位的向量类型,会占用多个槽位,这意味着,这之后的索引必须至少递增2以上。

layout(location = 0) in dvec3 inPosition;
layout(location = 2) in vec3 inColor;

你可以在布局说明的文档中找到更多的信息 OpenGL wiki.

顶点数据

我们将着色器代码中的顶点数据移动到C++代码中,首先引入 GLM 库头文件,这会为我们提供与线性代数有关的向量和矩阵数据结构,我们会使用这些类型去实现位置和颜色向量.

#include <glm/glm.hpp>

创建一个 Vertex 结构体,包含两个我们在顶点着色器中使用的属性.

struct Vertex {
    glm::vec2 pos;
    glm::vec3 color;
};

GLM 可以为我们提供在着色器中数据类型在C++层的对应类型。

const std::vector<Vertex> vertices = {
    {{0.0f, -0.5f}, {1.0f, 0.0f, 0.0f}},
    {{0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}},
    {{-0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}
};

现在使用 Vertex 结构体去指定顶点数据,我们使用与之前shader中一样的位置和颜色数据,不过这次我们将其打包进了一个顶点数组中,这种方式称之为顶点数据的交错存储。

绑定描述

下一步,是告诉Vulkan,在数据被传输到GPU的显存后如何按需要的格式传递给顶点着色器。有两种结构体用来传达这些内容。

首先是 VkVertexInputBindingDescription ,我们为 Vertex 结构体添加一个成员函数,返回 VkVertexInputBindingDescription结构体

struct Vertex {
    glm::vec2 pos;
    glm::vec3 color;

    static VkVertexInputBindingDescription getBindingDescription() {
        VkVertexInputBindingDescription bindingDescription{};

        return bindingDescription;
    }
};

顶点绑定描述代表着在顶点着色阶段数据如何从内存中载入到顶点中,指定了数据之间的字节大小,以及是否可以每个顶点数据结束时自动移动到下一个数据。

VkVertexInputBindingDescription bindingDescription{};
bindingDescription.binding = 0;
bindingDescription.stride = sizeof(Vertex);
bindingDescription.inputRate = VK_VERTEX_INPUT_RATE_VERTEX;

我们所有的逐顶点数据都被打包在一个数组中,所以我们只准备使用一个绑定。binding参数指定了绑定数据的索引,stride 指定了从一个实体数据跳转到下一条实体数据的字节偏移, inputRate 代表着顶点的步进方式,可取以下的两个值.

  • VK_VERTEX_INPUT_RATE_VERTEX : 逐顶点步进
  • VK_VERTEX_INPUT_RATE_INSTANCE : 逐实例步进

我们并不使用实例渲染,所以这里选择 VK_VERTEX_INPUT_RATE_VERTEX 逐顶点数据.

属性描述

第二个用于描述如何处理顶点输入的结构体是 VkVertexInputAttributeDescription , 我们向 Vertex 结构体中添加一个帮助函数去填写这个结构体.

#include <array>
...
static std::array<VkVertexInputAttributeDescription, 2> getAttributeDescriptions() {
    std::array<VkVertexInputAttributeDescription, 2> attributeDescriptions{};

    return attributeDescriptions;
}

如定义的函数原型所示,我们准备返回两个结构体构成的数组.一个属性描述结构体代表着如何从原始的顶点数据中提取出顶点属性,我们现在有两个属性,位置和颜色,所以这里需要返回两个顶点描述结构体。

attributeDescriptions[0].binding = 0;
attributeDescriptions[0].location = 0;
attributeDescriptions[0].format = VK_FORMAT_R32G32_SFLOAT;
attributeDescriptions[0].offset = offsetof(Vertex, pos);

binding 参数告诉 Vulkan 使用哪个顶点数据的绑定.location 参数直接对应着着色器中的 location 数据位置,输入的顶点着色器 location = 0,代表位置数据,是两个32位的浮点数.

format 描述了数据的类型,一个可能会让人困惑的点是,类型的指定可能用的是颜色的格式.通常使用以下的类型对应关系:

  • float : VK_FORMAT_R32_SFLOAT
  • vec2 : VK_FORMAT_R32G32_SFLOAT
  • vec3 : VK_FORMAT_R32G32B32_SFLOAT
  • vec4 : VK_FORMAT_R32G32B32A32_SFLOAT

应该选择与颜色通道相匹配的格式类型,允许使用比着色器中更多的通道数量,这些会被直接丢弃,如果通道的数量低于组件的数量,BGA通道将会使用默认的值(0, 0, 1),颜色的值类型(SFLOAT,UINT,SINT)和二进制比特的位数,也需要匹配着色器中的类型,以下是一些示例:

  • ivec2 : VK_FORMAT_R32G32_SINT , 二维向量,元素是32位的有符号整型
  • uvec4 : VK_FORMAT_R32G32B32A32_UINT , 四维向量,元素是32位的无符号整型
  • double : VK_FORMAT_R64_SFLOAT,一个双精度的浮点型

format 参数隐式地定义了数据的比特位数,offset 指定了每个顶点此段数据的偏移量,我们使用 offsetof 这个宏定义来自动计算.

attributeDescriptions[1].binding = 0;
attributeDescriptions[1].location = 1;
attributeDescriptions[1].format = VK_FORMAT_R32G32B32_SFLOAT;
attributeDescriptions[1].offset = offsetof(Vertex, color);

颜色属性也是相似的设置。

管线的顶点输入

现在需要在 createGraphicsPipeline 函数中,安装图形管线,按我们的期望去接收顶点数据,找到 vertexInputInfo 结构体,修改其中的两个描述字段:

auto bindingDescription = Vertex::getBindingDescription();
auto attributeDescriptions = Vertex::getAttributeDescriptions();

vertexInputInfo.vertexBindingDescriptionCount = 1;
vertexInputInfo.vertexAttributeDescriptionCount = static_cast<uint32_t>(attributeDescriptions.size());
vertexInputInfo.pVertexBindingDescriptions = &bindingDescription;
vertexInputInfo.pVertexAttributeDescriptions = attributeDescriptions.data();

管线以及准备好按照顶点容器的格式去接收顶点数据并将它们传输到顶点着色器中了。如果现在运行程序,验证层将会报错:未绑定顶点缓冲,下一步我们就要去创建顶点缓冲,讲数据传输到顶点缓冲区,让GPU可以访问到它们。

创建顶点缓冲区

概述

Vulkan 中的缓冲(Buffer) 指的是一块可以被显卡读取的用于存储各种数据的内存区域,它可以被用于存储顶点数据,我们本章便会演示,也可以用来存储其他类型的数据,后面也会演示.与Vulkan对象不同,缓冲区并不会自动从内存中分配,前几章已经表明了,Vulkan会让程序员控制所有的东西,内存的管理便是其中之一。

创建缓存

新建函数 createVertexBuffer ,在initVulkan 中调用

void initVulkan() {
    createInstance();
    setupDebugMessenger();
    createSurface();
    pickPhysicalDevice();
    createLogicalDevice();
    createSwapChain();
    createImageViews();
    createRenderPass();
    createGraphicsPipeline();
    createFramebuffers();
    createCommandPool();
    createVertexBuffer();
    createCommandBuffers();
    createSyncObjects();
}

...

void createVertexBuffer() {

}

创建缓存需要我们去填写 VkBufferCreateInfo 结构体.

VkBufferCreateInfo bufferInfo{};
bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
bufferInfo.size = sizeof(vertices[0]) * vertices.size();

size 代表缓存的字节数量,计算字节数量很简单直接使用 sizeof,

bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;

第二个字段 usage , 代表缓存中的数据将被用来做什么,可以通过位运算 | 来指定多个用途.我们目前是用于顶点缓冲区的。

bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

与交换链中的图像一样,缓冲区也可能在同一时间被其他的队列簇所共享,这个缓冲区仅被用于图形队列,所以这里坚持使用排他模式。

flags 参数可以被用于配置稀疏类型的缓存内存,目前还用不上所以直接设置为0.

现在可以通过 vkCreateBuffer 创建缓冲区了,定义一个名为 vertexBuffer 的类成员去存储缓冲的句柄。

VkBuffer vertexBuffer;

...

void createVertexBuffer() {
    VkBufferCreateInfo bufferInfo{};
    bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size = sizeof(vertices[0]) * vertices.size();
    bufferInfo.usage = VK_BUFFER_USAGE_VERTEX_BUFFER_BIT;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

    if (vkCreateBuffer(device, &bufferInfo, nullptr, &vertexBuffer) != VK_SUCCESS) {
        throw std::runtime_error("failed to create vertex buffer!");
    }
}

直到程序结束,缓冲区应该一直都可被渲染命令所使用,并且它并不依赖于交换链,所以在原始的 cleanup 函数中清理它.

void cleanup() {
    cleanupSwapChain();

    vkDestroyBuffer(device, vertexBuffer, nullptr);

    ...
}

内存需求

缓冲区已经被创建出来了,但是还没有任何的内存被赋值给它,为缓冲区分配内存的第一步是去查询内存的需求,我们这里使用函数 vkGetBufferMemoryRequirements。

VkMemoryRequirements memRequirements;
vkGetBufferMemoryRequirements(device, vertexBuffer , &memRequirements);

VkMemoryRequirements 结构体有三个成员

  • size : 内存的数量(字节为单位),也许会与bufferInfo.size不一样
  • alignment : 缓冲区开始的内存范围的偏移,依赖于 bufferInfo.usgae 和 bufferInfo.flags.
  • memoryTypeBits : 比特标志位,对这个缓冲区合适的内存类型

显卡可以提供并分配不同类型的内存,不同的内存有着不同的操作和效率上的特点,我们需要结合缓冲区的需求和应用自身的需求选择正确的内存类型.让我们创建一个新函数 findMemoryType

uint32_t findMemoryType(uint32_t typeFilter,VkMemoryPropertyFlags properties){

}

首先需要使用 vkGetPhysicalDeviceMemoryProperties 查询可用的内存类型,

VkPhysicalDeviceMemoryProperties memProperties;
vkGetPhysicalDeviceMemoryProperties(physicalDevice, &memProperties);

VkPhysicalDeviceMemoryProperties 有两个数组成员变量 memoryTypes 和 memoryHeaps ,堆内存是不同的内存资源,如独立的显存资源,以及在显存资源耗尽时可用的交换空间等。这些堆上存在着不同的内存类型,现在我们仅聚焦于自己的内存,而不是堆内存,但是你要对这些影响性能的内存留下一个印象.

现在找到一个适合缓冲区的内存类型:

for(uint32_t i = 0 ; i < memProperties.memoryTypeCount ; i++){
    if(typeFilter & (1 << i) ){
        return i;
    }
}

throw std::runtime_error("failed to find suitable memory type!");

typeFilter 参数会使用二进制运算组合需要请求的内存类型,我们可以通过简单的与运算找到合适的内存类型。 然而,我们并不仅仅对适用于顶点缓冲区的内存类型感兴趣,也需要这些内存可以写入我们提供的顶点数据,memoryTypes类型由 VkMemoryType的数组构成,它代表着堆和每一种内存的类型。定义了特定用途的内存类型,例如可以将它映射到CPU操作的内存空间,其代表常量是 VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT,我们也需要 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT 这个属性,会看到为何需要这个内存属性。

现在可以修改循环中的检查条件

for (uint32_t i = 0; i < memProperties.memoryTypeCount; i++) {
    if ((typeFilter & (1 << i)) 
        && (memProperties.memoryTypes[i].propertyFlags & properties) == properties) {
        return i;
    }
}

我们也许会请求多个需要支持的内存属性,所以需要通过 & 操作符检查结果,如果这种内存类型满足所有我们期望的操作就返回在memoryTypes结构中的索引,否则抛出异常。

内存分配

现在已经有方法去找到正确的内存类型了,可以通过 VkMemoryAllocateInfo 来实际分配内存了.

VkMemoryAllocateInfo allocInfo{};
allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
allocInfo.allocationSize = memRequirements.size;
allocInfo.memoryTypeIndex = 
    findMemoryType(memRequirements.memoryTypeBits, 
        VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT);

内存的分配现在只需要指定大小和类型了,它们都是从内存的类型查询和期望使用的内存属性中得到的。创建类成员vkAllocateMemory去存储分配的内存。

VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;

...

if (vkAllocateMemory(device, &allocInfo, nullptr, &vertexBufferMemory) != VK_SUCCESS) {
    throw std::runtime_error("failed to allocate vertex buffer memory!");
}

内存分配成功后,就可以使用 vkBindBufferMemory 将其与缓冲区关联在一起了.

vkBindBufferMemory(device, vertexBuffer , vertexBufferMemory, 0);

前三个参数意义很明显,第四个参数是内存的偏移,由于这块内存是专门为了顶点缓冲分配的,所以 offset值直接设置成了0,如果offset 是非零值,那么它需要根据 memRequirements.alignment 参数进行切割处理.

当然,就像C++的内存动态分配一样,这里的内存也需要在合适的时机被释放掉,绑定到缓冲区的内存在缓冲区不再被使用时就需要被释放掉了,所以这里我们在缓冲区被销毁后,也清理掉关联的内存。

void cleanup() {
    cleanupSwapChain();

    vkDestroyBuffer(device, vertexBuffer, nullptr);
    vkFreeMemory(device, vertexBufferMemory, nullptr);

    ... 
}

填充顶点缓冲

现在可以将顶点的数据拷贝到缓冲区了,通过使用内存映射技术,将显卡内存映射为CPU可访问的内存。以上通过 vkMapMemory 函数来实现。

void* data;
vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0, &data);

此函数允许我们通过提供大小和偏移来访问一块指定的内存区域。偏移和大小在这里设置为 0 和 bufferInfo.size。也可以设置 VK_WHOLE_SIZE 特殊值来映射整段的内存区域。倒数第二个参数被用来指定flags,但是目前的API版本还不起作用,所以一定是0.最后一个参数则指定了映射内存的输出指针.

void* data;
vkMapMemory(device, vertexBufferMemory, 0, bufferInfo.size, 0, &data);
    memcpy(data, vertices.data() , (size_t)bufferInfo.size);
vkUnMapMemory(device, vertexBufferMemory);

现在可以使用 memcpy 函数拷贝顶点数据到映射的内存中,最后使用 vkUnmapMemory 解绑映射关系.不幸的是,驱动可能并不会立刻将数据拷贝到映射的缓冲区中,例如由于缓存原因,也有可能写入的内存对于映射的内存来说,是不可见的。有两种方式去处理这个问题:

  • 使用与主机一致的内存类型,标志位为 VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
  • 在做完内存映射后立刻调用 vkFlushMappedMemoryRanges ,然后在读取映射内存前调用 vkInvalidateMappedMemoryRanges 。

我们这里选择第一种方案,它会确保映射的内存总是与缓冲区中的内容是一致的。需要重视的一点是,与显式刷新相比,这个方案的性能更差。

刷新内存或者使用一致性内存意味着驱动可以感知到我们给缓冲区写入了内容,但这并不意味着它们是在GPU上实际可见的,把数据传输给GPU是一个发生在后台的操作,规范仅仅是限制了,在下一次的 vkQueueSubmit 操作前数据的传输一定会完成。

绑定顶点缓冲区

最后,需要在渲染操作的时候绑定顶点缓冲区,现在在 recordCommandBuffer 中完成这一步操作

vkCmdBindPipeline(commandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, graphicsPipeline);

VkBuffer vertexBuffers[] = {vertexBuffer};
VkDeviceSize offsets[] = {0};
vkCmdBindVertexBuffers(commandBuffer, 0, 1, vertexBuffers, offsets);

vkCmdDraw(commandBuffer, static_cast<uint32_t>(vertices.size()), 1, 0, 0);

vkCmdBindVertexBuffers 函数用于绑定顶点缓冲,commandBuffer后面的两个参数,指定了偏移和绑定的数量,后两个参数指定了顶点缓冲区的数组和从这个数组偏移多少取值。vkCmdDraw 的调用也从之前的硬编码3改为了实际的顶点数量.

现在运行程序会看到与之前一样的三角形:

triangle.png

试着在 vertices 变量中修改顶部顶点的颜色为白色

const std::vector<Vertex> vertices = {
    {{0.0f, -0.5f}, {1.0f, 1.0f, 1.0f}},
    {{0.5f, 0.5f}, {0.0f, 1.0f, 0.0f}},
    {{-0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}}
};

再次运行程序,会看到如下结果:

triangle_white.png

在下一节,我们会采用另外一种性能更好但是更加麻烦的方式来拷贝顶点数据送入顶点缓冲区.

暂存缓冲

介绍

我们已经让顶点缓冲正确工作了,但是这个允许我们从CPU访问的内存类型也许对GPU的读取并不是最优的。最优内存的标记位是 VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT ,这通常不能被CPU直接访问到,因为此内存存在于GPU上.本章,我们会创建两个顶点缓冲区,一个是CPU可访问的暂存缓冲区,用于从顶点数组中上传数据,另外一个顶点缓冲是设备内存上的缓冲区,我们会使用一个缓冲拷贝命令来将数据从暂存区域移动到实际的顶点缓冲区.

传输队列

拷贝命令需要一个支持传输操作的队列簇,标志位为 VK_QUEUE_TRANSFER_BIT . 好消息是任何支持 VK_QUEUE_GRAPHICS_BIT 或 VK_QUEUE_COMPUTE_BIT 的队列簇都是隐式支持 VK_QUEUE_TRANSFER_BIT 的,并不需要在 queueFlags 标志位上将其显式地标明出来.

如果你喜欢挑战,也可以试着去使用不同的队列簇去完成传输操作,你的程序需要做如下的修改:

  • 修改 QueueFamilyIndices 和 findQueueFamilies 去显式地找到拥有 VK_QUEUE_TRANSFER_BIT 标志位的队列簇.而不是之前的 VK_QUEUE_GRAPHICS_BIT.
  • 修改 createLogicalDevice 创建逻辑设备时请求传输队列簇
  • 创建第二个命令缓冲池,用来提交传输命令
  • 修改资源的 sharingMode 为 VK_SHARING_MODE_CONCURRENT ,让资源可以被图形和传输队列簇共享
  • 提交传输命令例如 vkCmdCopyBuffer (本章会用到)给传输队列而不是图形队列.

有很多工作要做,但这会教会你如何让资源在多个队列簇中被共享。

抽象缓冲区的创建

因为我们要在本章创建多个缓冲区,将创建逻辑提成一个函数是个好主意,创建新函数 createBuffer ,将 createVertexBuffer 代码移到里面

void createBuffer(VkDeviceSize size, 
    VkBufferUsageFlags usage, 
    VkMemoryPropertyFlags properties, 
    VkBuffer& buffer, 
    VkDeviceMemory& bufferMemory) {
    
    VkBufferCreateInfo bufferInfo{};
    bufferInfo.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO;
    bufferInfo.size = size;
    bufferInfo.usage = usage;
    bufferInfo.sharingMode = VK_SHARING_MODE_EXCLUSIVE;

    if (vkCreateBuffer(device, &bufferInfo, nullptr, &buffer) != VK_SUCCESS) {
        throw std::runtime_error("failed to create buffer!");
    }

    VkMemoryRequirements memRequirements;
    vkGetBufferMemoryRequirements(device, buffer, &memRequirements);

    VkMemoryAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO;
    allocInfo.allocationSize = memRequirements.size;
    allocInfo.memoryTypeIndex = findMemoryType(memRequirements.memoryTypeBits, properties);

    if (vkAllocateMemory(device, &allocInfo, nullptr, &bufferMemory) != VK_SUCCESS) {
        throw std::runtime_error("failed to allocate buffer memory!");
    }

    vkBindBufferMemory(device, buffer, bufferMemory, 0);
}

此函数传入三个参数,内存大小,内存属性以及用途标志,以便于我们可以创建不同类型的缓冲对象,最后两个参数是输出的句柄变量

现在可以从 createVertexBuffer 函数中移除代码,仅使用 createBuffer来代替了

void createVertexBuffer() {
    VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();
    createBuffer(bufferSize, 
        VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, 
        VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, 
        vertexBuffer, 
        vertexBufferMemory);

    void* data;
    vkMapMemory(device, vertexBufferMemory, 0, bufferSize, 0, &data);
        memcpy(data, vertices.data(), (size_t) bufferSize);
    vkUnmapMemory(device, vertexBufferMemory);
}

运行程序确保顶点缓冲工作正常.

使用暂存缓冲区

现在继续修改 createVertexBuffer ,使用一个CPU可见的内存作为临时缓存,再创建一个设备本地内存作为实际的顶点缓冲区.

void createVertexBuffer() {
    VkDeviceSize bufferSize = sizeof(vertices[0]) * vertices.size();

    VkBuffer stagingBuffer;
    VkDeviceMemory stagingBufferMemory;
    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer, stagingBufferMemory);

    void* data;
    vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
        memcpy(data, vertices.data(), (size_t) bufferSize);
    vkUnmapMemory(device, stagingBufferMemory);

    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, vertexBuffer, vertexBufferMemory);
}

我们现在使用一个新的 stagingBuffer 和 stagingBufferMemory 映射和拷贝进来的顶点数据,在本章我们准备使用两个新的内存使用标记位.

  • VK_BUFFER_USAGE_TRANSFER_SRC_BIT : 缓冲区可以被用作内存传输的源
  • VK_BUFFER_USAGE_TRANSFER_DST_BIT : 缓冲区可被用作内存传输的目的地

vertexBuffer 现在分配的内存类型是本地设备类型,这种类型意味着它不能被用作 vkMapMemory 进行映射,但是我们可以从 stageBuffer拷贝数据到 vertexBuffer.我们需要在 usgae 为stagingBuffer标识成源,为 vertexBuffer 标识为目的地.

现在去实现一个函数 copyBuffer ,将缓冲区对象从一个缓冲区移动到另一个.

void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {

}

内存传输操作使用命令缓冲运行,因此首先需要分配一个临时的命令缓冲,希望能创建一个单独的命令缓冲池,专门用来分配这种短时的命令缓冲,这样的实现可以让内存的分配得到更好的优化,应该在命令池创建时使用 VK_COMMAND_POOL_CREATE_TRANSIENT_BIT 标志位.

void copyBuffer(VkBuffer srcBuffer, VkBuffer dstBuffer, VkDeviceSize size) {
    VkCommandBufferAllocateInfo allocInfo{};
    allocInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO;
    allocInfo.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY;
    allocInfo.commandPool = commandPool;
    allocInfo.commandBufferCount = 1;

    VkCommandBuffer commandBuffer;
    vkAllocateCommandBuffers(device, &allocInfo, &commandBuffer);
}

然后立刻开始记录命令缓冲

VkCommandBufferBeginInfo beginInfo{};
beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO;
beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT;

vkBeginCommandBuffer(commandBuffer, &beginInfo);

我们准备只使用这个命令缓冲一次,结束操作时从函数中返回,使用 VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT 标志告诉驱动我们的意图。

VkBufferCopy copyRegion{};
copyRegion.srcOffset = 0; // Optional
copyRegion.dstOffset = 0; // Optional
copyRegion.size = size;
vkCmdCopyBuffer(commandBuffer, srcBuffer, dstBuffer, 1, &copyRegion);

vkCmdCopyBuffer 会操作缓冲区的内容进行移动,它接收缓冲的源和目的,以及一个范围的数组参数.范围是定义为 VkBufferCopy 的结构体数组,由 srcOffset,dstOffset ,size 组成,在这里不能像vkMapMemory一样指定 VK_WHOLE_SIZE .

vkEndCommandBuffer(commandBuffer);

这个命令缓冲区只包含拷贝命令,所以在那之后我们可以停止记录,现在可以运行命令缓冲完成数据的传输了:

VkSubmitInfo submitInfo{};
submitInfo.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO;
submitInfo.commandBufferCount = 1;
submitInfo.pCommandBuffers = &commandBuffer;

vkQueueSubmit(graphicsQueue, 1, &submitInfo, VK_NULL_HANDLE);
vkQueueWaitIdle(graphicsQueue);

与绘制命令不同,这里没有我们需要等待的事件,要做的仅仅是立刻开始运行传输指令.有两个方法去等待传输完成,我们可以使用栅栏然后通过 vkWaitForFences ,或者更简单的通过 vkQueueWaitIdle 等待传输队列空闲,栅栏的方式允许你同事调度多个传输任务,然后一次性的等待它们完成,这会提供给驱动更多的优化空间。

vkFreeCommandBuffers(device, commandPool, 1, &commandBuffer);

不要忘记去清理掉传输指令运行的命令缓冲(这里应该不是必须的,因为程序结束会清理CommandPool,仅仅是尽早释放内存)

现在在 createVertexBuffer 中调用 copyBuffer 将顶点数据移动到设备本地内存:

createBuffer(bufferSize, 
VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, vertexBuffer, vertexBufferMemory);
copyBuffer(stagingBuffer, vertexBuffer, bufferSize);

在从暂存区拷贝完数据后需要清理掉暂存区

    ...

    copyBuffer(stagingBuffer, vertexBuffer, bufferSize);

    vkDestroyBuffer(device, stagingBuffer, nullptr);
    vkFreeMemory(device, stagingBufferMemory, nullptr);
}

运行程序会看到与之前一样的三角形,现在做的性能提供也许并不明显,不过顶点缓冲区的确被加载到了性能更高的内存区域,当我们渲染更加复杂的几何体时,将会受到影响.

结论

应该注意到,在实际生产的环境中,并不会每一次都为单独的内存调用 vkAllocateMemory 进行分配,内存分配的最大并行数量是被 maxMemoryAllocationCount 物理参数限制的,至在一些性能很高的硬件 如 NVIDIA GTX 1080 上,这个值也许会低于 4096 ,正确的使用内存分配的方式是,一次分配一个较大数量的内存,再创建一个自定义的二次分配器,结合offset参数完成内存的实际分配.

你既可以自行实现这个内存分配器,也可以使用 VulkanMemoryAllocator 库,本教程为了简便依然是为每一个资源都使用一个单独的分配器.

下一章,我们会学习索引缓冲区的使用。

索引缓冲区

介绍

在实际的应用程序中,你渲染的3D网格通常会在多个三角形之间共享顶点.甚至在仅绘制一个简单的矩形时,这种情况也会发生:

vertex_vs_index.svg

绘制一个矩形需要输入两个三角形,这意味着我们需要6个顶点,问题在于这两个矩形的顶点是重复的,有50%的重复,对于更加复杂的网格,情况会变得更差。问题的解决方案是使用索引缓冲区.

顶点缓冲区本质上是指向顶点缓冲数据的一组指针,这允许你重新排序顶点数据,重用已经存在的多个顶点.上面的图表展示了我们如何利用一个含有四个顶点的顶点缓冲区结合索引缓冲拼接出一个矩形,前三个索引定义了右上角的三角形,后三个索引定义了左下角的三角形。

索引缓冲区的创建

本章我们准备修改顶点数据,并增加索引数据以绘制出一个矩形,修改的顶点数据代表四个角上的点

const std::vector<Vertex> vertices = {
    {{-0.5f, -0.5f}, {1.0f, 0.0f, 0.0f}},
    {{0.5f, -0.5f}, {0.0f, 1.0f, 0.0f}},
    {{0.5f, 0.5f}, {0.0f, 0.0f, 1.0f}},
    {{-0.5f, 0.5f}, {1.0f, 1.0f, 1.0f}}
};

左上角红色,右上角绿色,左下角蓝色,右下角白色,新增一个 indices 代表索引缓冲区的内容,它匹配了图中展示的索引值.

const std::vector<uint16_t> indices = {
    0, 1, 2, 2, 3, 0
};

既可以使用 uint16_t 也可以使用 uint32_t ,取决于顶点的数量.因为这里我们的顶点数量少于 65535 个,所以坚持使用 uint16_t.

与顶点数据一样 ,索引数据也需要打包上传到VkBuffer ,让GPU可以访问到.定义两个新的类成员去存储索引缓冲的资源句柄。

VkBuffer vertexBuffer;
VkDeviceMemory vertexBufferMemory;
VkBuffer indexBuffer;
VkDeviceMemory indexBufferMemory;

createIndexBuffer 函数与 createVertexBuffer 很相似:

void initVulkan() {
    ...
    createVertexBuffer();
    createIndexBuffer();
    ...
}

void createIndexBuffer() {
    VkDeviceSize bufferSize = sizeof(indices[0]) * indices.size();
    VkBuffer stagingBuffer;
    VkDeviceMemory stagingBufferMemory;
    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, stagingBuffer, stagingBufferMemory);

    void* data;
    vkMapMemory(device, stagingBufferMemory, 0, bufferSize, 0, &data);
    memcpy(data, indices.data(), (size_t) bufferSize);
    vkUnmapMemory(device, stagingBufferMemory);

    createBuffer(bufferSize, VK_BUFFER_USAGE_TRANSFER_DST_BIT | VK_BUFFER_USAGE_INDEX_BUFFER_BIT, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, indexBuffer, indexBufferMemory);

    copyBuffer(stagingBuffer, indexBuffer, bufferSize);

    vkDestroyBuffer(device, stagingBuffer, nullptr);
    vkFreeMemory(device, stagingBufferMemory, nullptr);
}

有两个显著的不同,bufferSize 现在是索引的类型大小乘以索引的数量,usage参数使用 VK_BUFFER_USAGE_INDEX_BUFFER_BIT 代替 VK_BUFFER_USGAE_INDEX_BUFFER_BIT,除了这两点,其它几乎一样,我们创建了一个临时缓冲区拷贝了索引的内容到设备本地索引缓冲区。

索引缓冲区也需要在程序结束时被清理。

void cleanup() {
    cleanupSwapChain();

    vkDestroyBuffer(device, indexBuffer, nullptr);
    vkFreeMemory(device, indexBufferMemory, nullptr);

    vkDestroyBuffer(device, vertexBuffer, nullptr);
    vkFreeMemory(device, vertexBufferMemory, nullptr);

    ...
}

使用索引缓冲

使用索引缓冲区,涉及到对 recordCommandBuffer 函数的两处修改,首先需要绑定索引缓冲,就像顶点缓冲那样,不同点是,你只可以有一个索引缓冲,不可能为每一个顶点属性使用不同的顶点缓冲,所以仍然会有重复的顶点属性存在.

vkCmdBindVertexBuffers(commandBuffer, 0, 1, vertexBuffers, offsets);

vkCmdBindIndexBuffer(commandBuffer, indexBuffer, 0, VK_INDEX_TYPE_UINT16);

索引缓冲通过函数 vkCmdBindIndexBuffer 完成绑定,它接收索引缓冲 ,字节为单位的偏移量,以及一个索引数据的类型,如前面提到的,这个值可能是 VK_INDEX_TYPE_UINT16 或者 VK_INDEX_TYPE_UINT32.

绑定了索引缓冲并不会改变任何东西 ,我们还需要修改绘制指令告诉Vulkan使用索引缓冲完成绘制, 使用 vkCmdDrawIndexed 替代原来的 vkCmdDraw

vkCmdDrawIndexed(commandBuffer, static_cast<uint32_t>(indices.size()), 1, 0, 0, 0);

这个函数的调用与 vkCmdDraw 很像,前两个参数指定了索引和实例的数量,我们并不使用实例,所以这里设置为1,索引的数据实际代表着传递给顶点着色器的数量,接下来的参数代表着索引缓冲区的偏移,倒数第二个参数代表在索引缓冲区进入顶点着色器之前顶点的索引值 (gl_VertexIndex),最后一个参数代表实例的偏移,这里不使用设置为0.

现在运行程序可以看到如下结果:

indexed_rectangle.png

现在已经了解了如何通过索引缓冲区来节省内存,这将在未来的章节我们导入复杂的3D模型时,很重要.

前面的章节已经提到你可以从单个的内存分配器中分配多个资源,例如缓冲区,但事实上,可以更进一步,驱动的开发者建议你在使用多个缓冲区的时候,例如顶点缓冲和索引缓冲,将多个缓冲区放入一个 VkBuffer 中,在提交命令时使用 偏移量(offset)来进行区分.好处是你的数据在这种场景下更加的缓存友好,因为它们排列的更加紧密,甚至可以针对没有使用相同渲染操作的数据,重用一些块的资源,这被称之为混叠,在一些Vulkan函数中,你需要显式地指定。

下一章,我们会学习如何传递一些频繁改变的参数给GPU。

C++代码 / 顶点着色器 / 片段着色器

记一个自定义下拉框组件,实现了单选、多选、过滤、树形结构等功能

作者 LayneTu
2025年6月26日 17:32

不知各位使用 elementPlus 组件库中的 el-select 时有没有发现,每添加一个 el-select 组件,就意味着页面中同时会生成一个 el-popover 的下拉框组件,这样就会导致某些表单页面或者列表嵌套表单的页面大量的使用 el-select 组件时,页面中会生成大量的 el-popover 组件,导致页面卡顿,性能下降,那么有没有什么办法可以解决这个问题呢?

image.png

既然如此那就手动造一个全局下拉框组件,同时满足 单选,多选,树形下拉框,添加新item,搜索等功能,下面就开始我们的造轮子之旅吧!

ps: 此组件是基于 elementPlus 组件开发的,且项目中用到了 tailwindcssscss ,所以需要先安装这几个依赖。

目录结构如下,总共三个文件,两个组件和一个页面,其中 popover.vue 是弹出框组件,在一个页面中只需引入一个

├── demo
│   ├── components
│   │   ├── popover.vue
│   │   └── select.vue
│   └── index.vue
  1. popover.vue 负责弹出框组件
<template>
    <el-tooltip ref="tooltipRef" :visible="visible" effect="light" :placement="initPlacement" :virtual-ref="virtualRef" virtual-triggering transition="el-zoom-in-top" :gpu-acceleration="false" :show-arrow="config.showArrow" :popper-options="{ modifiers: [{ name: 'computeStyles', options: { adaptive: false, enabled: false } }] }" popper-class="!p-0" >
        <template #content>
            <div @click.prevent.stop class="box-border py-2" :style="popoverWidth">
                <div v-if="!options?.length" class="text-colors-3 text-center">
                    No Data
                </div>

                <el-scrollbar v-else max-height="336px">
                    <ul v-if="!config.isTree">
                        <li v-for="(item, index) in options" :key="`listItem-${index}`" @click="handleSelectPopoverItem($event, item)" class="flex justify-between items-center py-[5px] px-5 hover:bg-[#F5F6F7] cursor-pointer" :class="isChooseItem(item) && ['text-colors-primary', 'font-bold']">
                            <span class="whitespace-nowrap overflow-hidden text-ellipsis">{{ item[config.dataProps.label] }}</span>
                            <el-icon v-if="config.multiple && isChooseItem(item)" :size="12" class="flex-none"><Check /></el-icon>
                        </li>
                    </ul>
                    <div v-else>
                        <el-tree-v2 ref="treeRef" :data="options" :highlight-current="false" :props="config.dataProps" :height="200" :item-size="28" :expand-on-click-node="false" :filter-method="filterMethod" @node-click="handleNodeClick" class="px-4">
                            <template #default="{ node }">
                                <div class="w-full flex items-center justify-between pr-1 overflow-hidden">
                                    <p class="w-full overflow-hidden text-ellipsis whitespace-nowrap" :class="[ chooseData[config.dataProps.value] == node.data[config.dataProps.value] && '!text-colors-primary']">{{ node.data[config.dataProps.label] }}</p>
                                </div>
                            </template>
                            <template #empty>
                                <div class="h-[200px] leading-[200px] text-colors-3">No Data</div>
                            </template>
                        </el-tree-v2>
                    </div>
                </el-scrollbar>
            </div>
        </template>
    </el-tooltip>
</template>

<script setup>
import { Check } from '@element-plus/icons-vue';
const props = defineProps({
    virtualRef: { type: Object, default: () => ({}) },      // 虚拟触发元素【如果是ref,则传递 ref.value.$el】
    visible: { type: Boolean, default: false },             // 是否显示
})

const emit = defineEmits(['resetSelectPopoverConfig']);

onMounted(() => {
    document.addEventListener('click', handleResetSelectPopoverConfig);
})

const treeRef = ref(null);
const chooseData = ref(''); // 选择的数据
const config = ref({}); // 配置
// 非请求来的数据在弹窗显示时即可初始化数据
watch(() => props.visible, visible => visible && handleInitData());

// 计算popover宽度
const popoverWidth = computed(() => {
    return (config.value.fitInputWidth && props.virtualRef.offsetWidth) ? `width: ${props.virtualRef.offsetWidth}px;` : `width: ${config.value.popoverWidth || 400}px;`;
})

const tooltipRef = ref(null);
// 初始化数据
const initPlacement = ref('bottom');
const options = ref([]);
const handleInitData = () => {
    if (!props.visible) {
        config.value = {};
        return;
    } else {
        config.value = props.virtualRef.popoverConfig;
        nextTick(() => initPlacement.value = config.value.initPlacement || tooltipRef.value.popperRef.popperInstanceRef.state.placement);
        options.value = config.value.options ? JSON.parse(JSON.stringify(config.value.options)) : [];
        if (config.value.data) {
            // 赋值选中的数据
            chooseData.value = JSON.parse(JSON.stringify(config.value.data));

            // 树形结构的初始化展开
            if (config.value.isTree && config.value.data[config.value.dataProps.value]) {
                setTimeout(() => handleTreeExpand(config.value.data[config.value.dataProps.value]), 0);
            }
        } else {
            // 重置选中的数据
            chooseData.value = config.value.multiple ? [] : '';
        }
    }
}

// 树形结构展开
const handleTreeExpand = (key) => {
    const currentNode = treeRef.value && treeRef.value.getNode(key);
    if (!currentNode) return;
    const parantNode = currentNode.parent && treeRef.value.getNode(currentNode.parent.data[config.value.dataProps.value]);
    parantNode && treeRef.value.expandNode(parantNode, true);
    parantNode?.parent && handleTreeExpand(parantNode[config.value.dataProps.value]);
}

// 是否选中
const isChooseItem = computed(() => {
    return item => {
        if (config.value.multiple) {
            return chooseData.value.find(i => i[config.value.dataProps.value] === item[config.value.dataProps.value])
        } else {
            return chooseData.value[config.value.dataProps.value] === item[config.value.dataProps.value];
        }
    };
})

// 更新tooltip的显示,防止tooltip错位
const handleUpdatePopover = () => {
    setTimeout(() => tooltipRef.value.updatePopper(), 1);
}

// 选中项
const handleSelectPopoverItem = (e, item) => {
    if (config.value.multiple) {
        const index = chooseData.value.findIndex(i => i[config.value.dataProps.value] === item[config.value.dataProps.value]);
        index > -1 ? chooseData.value.splice(index, 1) : chooseData.value.push(item);
        config.value.onSelect(JSON.parse(JSON.stringify(chooseData.value)));
        handleUpdatePopover();
    } else {
        handleResetSelectPopoverConfig();
        if (item && chooseData.value[config.value.dataProps.value] === item[config.value.dataProps.value]) return;
        chooseData.value = item;
        config.value.onSelect(JSON.parse(JSON.stringify(item)));
    }
}
// 点击node节点
const handleNodeClick = (data, node, e) => {
    handleSelectPopoverItem(e, data);
}

// 过滤指定节点
const filterMethod = (query, node) => {
    // 当query为空时且非子节点时,折叠所有节点
    if (query === '' && node.children) {
        const trueNode = treeRef.value.getNode(node[config.value.dataProps.value]);
        trueNode && nextTick(() => treeRef.value.collapseNode(trueNode));
    }
    return node[config.value.dataProps.label].includes(query);
}

// 搜索
const handleSearch = (query) => {
    if (config.value.isTree) {
        treeRef.value && treeRef.value.filter(query);
        return;
    }
    if (query) {
        const regex = new RegExp(query, 'gi');
        options.value = config.value.options.filter(item => regex.test(item[config.value.dataProps.label]));
    } else {
        options.value = JSON.parse(JSON.stringify(config.value.options));
    }
    
    handleUpdatePopover();
}

// 重置配置
function handleResetSelectPopoverConfig() {
    emit('resetSelectPopoverConfig');
}

// 单独更新选中的值
const handleUpdateChooseData = (val) => {
    chooseData.value = JSON.parse(JSON.stringify(val));
}

onBeforeUnmount(() => {
    document.removeEventListener('click', handleResetSelectPopoverConfig);
})

defineExpose({
    handleInitData,
    handleUpdateChooseData,
    handleSearch,
    handleUpdatePopover,
    getVisibleStatus: () => props.visible
})
</script>

<style lang="scss" scoped>
:deep(.el-virtual-scrollbar) {
    right: -6px!important;
}

:deep(.el-tree) {
    .el-tree-node__content {
        gap: 4px;
        
        &>.el-tree-node__expand-icon {
            padding: 2px;
        }
    }

    .el-tree--highlight-current .el-tree-node.is-current>.el-tree-node__content {
        background-color: transparent;
    }
}
</style>
  1. select.vue 负责下拉选择组件
<template>
    <div ref="selectRef" @click.prevent.stop="handleTrigger" class="el-select">
        <div @mouseenter="handleHover($event, true)" @mouseleave="handleHover($event, false)" class="el-select__wrapper" :class="[currentPopoverTarget === selectRef && 'is-focused', isHovering && 'is-hovering', disabled && 'is-disabled']">
            <div class="el-select__selection">
                <div class="el-select__selected-item el-select__input-wrapper w-full flex gap-1">

                    <template v-if="filterable">
                        <input ref="filterInputRef" v-model="filterInputValue" :disabled="disabled" type="text" autocomplete="off" spellcheck="false" @input="handleFilterInput" @blur="handleFilterInputBlur" class="el-select__input flex-none" style="width: 11px;">
                        <span ref="filterInputSpanRef" aria-hidden="true" class="el-select__input-calculator">{{ filterInputValue }}</span>
                    </template>
                    
                    <template v-if="multiple">
                        <el-tag v-for="(tag, tagIndex) in data" :key="`tag-item-${tagIndex}`" disable-transitions :closable="!disabled" type="info" @close="handleCloseTag(tagIndex)" >{{ tag?.[dataProps?.label] }}</el-tag>
                    </template>
                    <span v-else-if="!filterInputValue" class="w-fit el-select__selected-item el-select__placeholder text-colors-1 break-all text-ellipsis line-clamp-1" :class="[disabled && 'text-colors-3', (!data?.[dataProps?.label] || filterInputIsFocus) && 'is-transparent']">{{ data?.[dataProps?.label] || placeholder }}</span>

                    <template v-if="addItems && multiple && !filterable">
                        <input ref="addItemsInputRef" v-model="addItemsInputValue" :disabled="disabled" type="text" autocomplete="off" spellcheck="false" @input="handleAddItemsInput" @blur="handleAddItemsInputBlur" class="el-select__input flex-none" style="width: 11px;">
                        <span ref="addItemsInputSpanRef" aria-hidden="true" class="el-select__input-calculator">{{ addItemsInputValue }}</span>
                    </template>
                </div>
            </div>
            <div class="el-select__suffix">
                <i v-if="clearable && isHovering && ((!multiple && data?.[dataProps?.label]) || (multiple && data.length))" @click.prevent.stop="handleClear" class="el-icon el-select__caret el-input__icon el-select__clear">
                    <el-icon><CircleClose /></el-icon>
                </i>
                <i v-else class="el-icon el-select__caret el-input__icon" :class="currentPopoverTarget === selectRef && 'is-reverse'">
                    <el-icon><ArrowDown /></el-icon>
                </i>
            </div>
        </div>
    </div>
</template>

<script setup>
import { CircleClose, ArrowDown } from '@element-plus/icons-vue';

const props = defineProps({
    modelValue: '',     // 双向绑定的值
    selectPopoverRef: null,     // select的popover [多选时需传递]
    options: null,            // 数据
    disabled: { type: Boolean, default: false },            // 是否禁用
    currentPopoverTarget: null,                           // 当前激活popover的target
    dataProps: { type: Object, default: () => ({ value: 'value', label: 'label', children: 'children' }) },      // 结构配置
    placeholder: { type: String, default: 'pleaseSelect' },
    fitInputWidth: { type: Boolean, default: true },      // 是否适应输入框宽度
    popoverWidth: { type: Number, default: 400 },      // popover宽度
    filterable: { type: Boolean, default: false },    // 是否可搜索
    clearable: { type: Boolean, default: false },    // 是否显示清除按钮
    multiple: { type: Boolean, default: false },    // 是否多选
    showArrow: { type: Boolean, default: true },     // 是否显示箭头
    addItems: { type: Boolean, default: false },      // 是否可以添加内容 [与filterable不可同时使用,优先filterable]
    initPlacement: { type: String, default: '' },      // 初始位置
    isTree: { type: Boolean, default: false },      // 是否为树形下拉框
});
const emit = defineEmits(['update:modelValue', 'click', 'change']);

watch([() => props.modelValue, () => props.options], () => {
    handleInitData();
});

onMounted(() => {
    // 绑定下拉框配置项
    handleInitConfig();
})

// 初始化配置
const selectRef = ref(null);
const handleInitConfig = () => {
    nextTick(() => {
        selectRef.value.popoverConfig = {};
        const keyList = ['isTree', 'dataProps', 'options', 'fitInputWidth', 'filterable', 'showArrow', 'multiple', 'popoverWidth', 'initPlacement'];
        keyList.forEach(key => selectRef.value.popoverConfig[key] = props[key]);
        selectRef.value.popoverConfig['onSelect'] = handleSelect;
        handleInitData();
    })
}
// 初始化选中数据
const data = ref(null);
const handleInitData = () => {
    if (!props.modelValue || (props.multiple && !props.modelValue?.length) || !props.options || !props.options.length) {
        if (props.multiple) {
            data.value = [];
            props.modelValue?.forEach(item => {
                const obj = {};
                obj[props.dataProps.label] = item;
                data.value.push(obj);
            })
        } else {
            data.value = {};
            data.value[props.dataProps.label] = props.modelValue;
        }
        return;
    }
    if (props.isTree) {
        let findItem = handleFindTreeItem(props.options, props.modelValue);
        if (findItem) {
            data.value = findItem;
        } else {
            data.value = {};
            data.value[props.dataProps.label] = props.modelValue;
        }
    } else {
        if (props.multiple) {
            const labelArr = [];
            props.modelValue.forEach(item => {
                const findItem = props.options.find(option => option[props.dataProps.value] === item);
                findItem ? labelArr.push(findItem) : labelArr.push({ [props.dataProps.value]: item, [props.dataProps.label]: item });
            })
            data.value = labelArr.length ? labelArr : props.modelValue;
        } else {
            const findItem = props.options.find(item => item[props.dataProps.value] === props.modelValue);
            if (findItem) {
                data.value = findItem;
            } else {
                data.value = {};
                data.value[props.dataProps.label] = props.modelValue;
            }
        }
    }
    selectRef.value.popoverConfig.data = data.value;
    selectRef.value.popoverConfig.options = props.options;
}
// 树形结构查找选中项
const handleFindTreeItem = (treeData, value) => {
    for (let item of treeData) {
        if (item[props.dataProps.value] === value) return item;

        if (item.children && item.children.length > 0) {
            let result = handleFindTreeItem(item.children, value);
            if (result) return result;
        }
    }
    return null;
}
// 下拉框选中时触发
const handleSelect = (config) => {
    data.value = config;
    selectRef.value.popoverConfig.data = data;
    if (props.multiple) {
        emit('update:modelValue', config.map(i => i[props.dataProps.value]));
        emit('change', config);
    } else {
        emit('update:modelValue', config[props.dataProps.value]);
        emit('change', config);
    }
}

// 多选框中删除其中某项
const handleCloseTag = (index) => {
    data.value.splice(index, 1);
    selectRef.value.popoverConfig.data = data.value;
    if (props.selectPopoverRef) {
        props.selectPopoverRef.handleInitData();
        props.selectPopoverRef.handleUpdatePopover();
    }
    emit('update:modelValue', data.value.map(i => i[props.dataProps.value]));
}

// 点击下拉框时传递ref
const handleTrigger = () => {
    if (props.disabled) return;
    if (filterInputRef.value) {
        filterInputRef.value.focus();
        filterInputIsFocus.value = true;
    }
    addItemsInputRef.value && addItemsInputRef.value.focus();
    selectRef.value.popoverConfig.data = data.value;
    selectRef.value.popoverConfig.options = props.options;
    emit('click', selectRef.value);
}

// filter输入框
const filterInputRef = ref(null);
const filterInputSpanRef = ref(null);
const filterInputValue = ref('');
const filterInputIsFocus = ref(false);
let filterInputTimer = null;
const handleFilterInput = () => {
    const maxWidth = selectRef.value.offsetWidth - 24 - 20;
    nextTick(() => filterInputRef.value.style.width = filterInputSpanRef.value.offsetWidth > maxWidth ? `${maxWidth}px` : `${filterInputSpanRef.value.offsetWidth || 1}px`);
    if (props.selectPopoverRef) {
        // 如果当前是关闭状态则手动触发开启下拉框
        !props.selectPopoverRef.getVisibleStatus() && emit('click', selectRef.value);
        filterInputTimer && clearTimeout(filterInputTimer);
        filterInputTimer = setTimeout(() => props.selectPopoverRef.handleSearch(filterInputValue.value), 0);
    }
}
const handleFilterInputBlur = () => {
    filterInputIsFocus.value = false;
    filterInputValue.value = '';
    props.selectPopoverRef && props.selectPopoverRef.handleUpdatePopover();
}

// addItems输入框
const addItemsInputRef = ref(null);
const addItemsInputSpanRef = ref(null);
const addItemsInputValue = ref('');
const handleAddItemsInput = () => {
    const maxWidth = selectRef.value.offsetWidth - 24 - 20;
    nextTick(() => addItemsInputRef.value.style.width = addItemsInputSpanRef.value.offsetWidth > maxWidth ? `${maxWidth}px` : `${addItemsInputSpanRef.value.offsetWidth || 1}px`);
}
const handleAddItemsInputBlur = () => {
    if (!addItemsInputValue.value) return;
    // 判断是否为options中的一项
    const hasItem = props.options.find(i => i[props.dataProps.label] === addItemsInputValue.value);
    // 获取原本选中的项
    let oldValue = data.value.map(i => i[props.dataProps.value]);
    if (hasItem) {
        // 替换掉输入值为options的key
        addItemsInputValue.value = hasItem[props.dataProps.value];
        // 去除inputValue中含有的
        oldValue = oldValue.filter(i => i !== hasItem[props.dataProps.value]);
    }
    emit('update:modelValue', [...oldValue, addItemsInputValue.value]);
    // 更新下拉框的选中项
    nextTick(() => props.selectPopoverRef && props.selectPopoverRef.handleUpdateChooseData(data.value));
    addItemsInputValue.value = '';
    addItemsInputRef.value.style.width = '1px';
}

// 清空
const handleClear = () => {
    data.value = props.multiple ? [] : '';
    selectRef.value.popoverConfig.data = data.value;
    props.selectPopoverRef && props.selectPopoverRef.handleInitData()
    emit('update:modelValue', data.value);
    emit('click');
}

// 移入移出
const isHovering = ref(false);
const handleHover = (e, enterFlag) => {
    isHovering.value = enterFlag;
}
</script>

<style lang="scss" scoped>
:deep(.el-tag__content) {
    max-width: 100px!important;
    overflow: hidden;
    text-overflow: ellipsis;
}
</style>

  1. index.vue 实例页面
  • 最基础的单选下拉框,包含搜索、清除等功能
<template>
    <div style="padding: 100px;background-color: #fff;">
        <SelectContainer v-model="value" :options="options" clearable :currentPopoverTarget="selectPopoverConfig.virtualRef" :selectPopoverRef="selectPopoverRef" @click="handleShowSelectPopover" style="width: 250px" />

        <SelectPopover ref="selectPopoverRef" :virtualRef="selectPopoverConfig.virtualRef" :visible="selectPopoverConfig.visible" @resetSelectPopoverConfig="handleResetSelectPopoverConfig" />
    </div>
</template>

<script setup>
import SelectContainer from './components/select.vue';
import SelectPopover from './components/popover.vue';
const value = ref('');
const options = ref([
    {
        value: '1',
        label: 'Option 1',
    },
    {
        value: '2',
        label: 'Option 2',
    },
    {
        value: '3',
        label: 'Option 3'
    }
]);

// 下拉框
const selectPopoverRef = ref(null);
const selectPopoverConfig = ref({
    virtualRef: {},
    visible: false
});
// 打开下拉框
const handleShowSelectPopover = (target) => {
    if (!target) return;
    if (selectPopoverConfig.value.virtualRef !== target) {
        handleResetSelectPopoverConfig();     // 先将之前的虚拟节点重置
        // 延迟设置新的虚拟节点,出现新的下拉动画
        setTimeout(() => {
            selectPopoverConfig.value.virtualRef = target;
            selectPopoverConfig.value.visible = true;
        }, 0);
        return;
    }
    selectPopoverConfig.value.visible = !selectPopoverConfig.value.visible;
    !selectPopoverConfig.value.visible && (selectPopoverConfig.value.virtualRef = {});
}
// 重置下拉框
const handleResetSelectPopoverConfig = () => {
    selectPopoverConfig.value.virtualRef = {};
    selectPopoverConfig.value.visible = false;
}
</script>

screen_recording_2025-06-26_17-16-04.gif

  • 多选下拉框,包含添加items、清除等功能,注意添加items和搜索功能不能同时使用
<template>
    <div style="padding: 100px;background-color: #fff;">
        <SelectContainer v-model="value" :options="options" clearable multiple addItems :currentPopoverTarget="selectPopoverConfig.virtualRef" :selectPopoverRef="selectPopoverRef" @click="handleShowSelectPopover" style="width: 250px" />
        
        <SelectPopover ref="selectPopoverRef" :virtualRef="selectPopoverConfig.virtualRef" :visible="selectPopoverConfig.visible" @resetSelectPopoverConfig="handleResetSelectPopoverConfig" />
    </div>
</template>
<script setup>
import SelectContainer from './components/select.vue';
import SelectPopover from './components/popover.vue';

const value = ref('');
const options = ref([
    {
        value: '1',
        label: 'Option 1',
    },
    {
        value: '2',
        label: 'Option 2',
    },
    {
        value: '3',
        label: 'Option 3',
    },
]);

// 下拉框
const selectPopoverRef = ref(null);
const selectPopoverConfig = ref({
    virtualRef: {},
    visible: false
});
// 打开下拉框
const handleShowSelectPopover = (target) => {
    if (!target) return;
    if (selectPopoverConfig.value.virtualRef !== target) {
        handleResetSelectPopoverConfig();     // 先将之前的虚拟节点重置
        // 延迟设置新的虚拟节点,出现新的下拉动画
        setTimeout(() => {
            selectPopoverConfig.value.virtualRef = target;
            selectPopoverConfig.value.visible = true;
        }, 0);
        return;
    }
    selectPopoverConfig.value.visible = !selectPopoverConfig.value.visible;
    !selectPopoverConfig.value.visible && (selectPopoverConfig.value.virtualRef = {});
}
// 重置下拉框
const handleResetSelectPopoverConfig = () => {
    selectPopoverConfig.value.virtualRef = {};
    selectPopoverConfig.value.visible = false;
}
</script>

screen_recording_2025-06-26_17-14-03.gif

  • 树形下拉框,包含搜索、清除等功能
<template>
    <div style="padding: 100px;background-color: #fff;">
        <SelectContainer v-model="value2" :options="options2" clearable filterable isTree :currentPopoverTarget="selectPopoverConfig.virtualRef" :selectPopoverRef="selectPopoverRef" @click="handleShowSelectPopover" style="width: 250px" />
        
        <SelectPopover ref="selectPopoverRef" :virtualRef="selectPopoverConfig.virtualRef" :visible="selectPopoverConfig.visible" @resetSelectPopoverConfig="handleResetSelectPopoverConfig" />
    </div>
</template>
<script setup>
import SelectContainer from './components/select.vue';
import SelectPopover from './components/popover.vue';
const value2 = ref('');
const options2 = ref([
    {
        value: '1',
        label: 'Option 1',
        children: [
            {
                value: '1-1',
                label: 'Option 1-1',
            },
            {
                value: '1-2',
                label: 'Option 1-2',
            },
        ]
    },
    {
        value: '2',
        label: 'Option 2',
        children: [
            {
                value: '2-1',
                label: 'Option 2-1',
            },
            {
                value: '2-2',
                label: 'Option 2-2',
            }
        ]
    },
    {
        value: '3',
        label: 'Option 3',
    },
]);

// 下拉框
const selectPopoverRef = ref(null);
const selectPopoverConfig = ref({
    virtualRef: {},
    visible: false
});
// 打开下拉框
const handleShowSelectPopover = (target) => {
    if (!target) return;
    if (selectPopoverConfig.value.virtualRef !== target) {
        handleResetSelectPopoverConfig();     // 先将之前的虚拟节点重置
        // 延迟设置新的虚拟节点,出现新的下拉动画
        setTimeout(() => {
            selectPopoverConfig.value.virtualRef = target;
            selectPopoverConfig.value.visible = true;
        }, 0);
        return;
    }
    selectPopoverConfig.value.visible = !selectPopoverConfig.value.visible;
    !selectPopoverConfig.value.visible && (selectPopoverConfig.value.virtualRef = {});
}
// 重置下拉框
const handleResetSelectPopoverConfig = () => {
    selectPopoverConfig.value.virtualRef = {};
    selectPopoverConfig.value.visible = false;
}
</script>

screen_recording_2025-06-26_17-20-05.gif

写到最后,还有好多功能没有展示,都在 select.vue 中备注着,可以根据需要自行添加,还可以自己DIY更多功能,比如加虚拟滚动组件实现大数据量的下拉选择等等,没有做不到,只有想不到✌️

bignumber.js 中文文档

2025年6月26日 17:30

概述

bignumber.js 是一个用于任意精度算术运算的 JavaScript 库,托管在 GitHub 上。该库已集成到当前页面,因此现在可以在控制台中使用。

构造函数

BigNumber(n [, base]) ⇒ BigNumber

  • n:可以是数字、字符串或 BigNumber 实例

  • base:数字,整数,范围在 2 到 36 之间(可通过配置 ALPHABET 扩展此范围)

返回一个新的 BigNumber 对象实例,其值为 nn 是指定基数的数值,若省略或基数为 null/undefined,则为十进制。 **示例:**

javascript

x = new BigNumber(123.4567); // '123.4567'
// 'new' 是可选的
y = BigNumber(x); // '123.4567'

如果 n 是十进制值,可以是普通(定点)或指数表示法。其他基数的值必须是普通表示法。任何基数的值都可以有小数位,即小数点后的数字。

javascript

new BigNumber(43210); // '43210'
new BigNumber('4.321e+4'); // '43210'
new BigNumber('-735.0918e-430'); // '-7.350918e-428'
new BigNumber('123412421.234324', 5); // '607236.557696'

支持带符号的 0、带符号的 Infinity 和 NaN。

javascript

new BigNumber('-Infinity'); // '-Infinity'
new BigNumber(NaN); // 'NaN'
new BigNumber(-0); // '0'
new BigNumber('.5'); // '0.5'
new BigNumber('+2'); // '2'

十六进制字面值形式的字符串值,例如 '0xff' 或 '0xFF'(但不是 '0xfF')是有效的,八进制和二进制前缀 '0o' 和 '0b' 的字符串值也是有效的。没有前缀的八进制字面值形式的字符串值将被解释为十进制,例如 '011' 被解释为 11,而不是 9。

javascript

new BigNumber(-10110100.1, 2); // '-180.5'
new BigNumber('-0b10110100.1'); // '-180.5'
new BigNumber('ff.8', 16); // '255.5'
new BigNumber('0xff.8'); // '255.5'

如果指定了基数,n 将根据当前的 DECIMAL_PLACES 和 ROUNDING_MODE 设置进行舍入。这包括十进制,因此除非需要此行为,否则不要为十进制值包含基数参数。

javascript

BigNumber.config({ DECIMAL_PLACES: 5 });
new BigNumber(1.23456789); // '1.23456789'
new BigNumber(1.23456789, 10); // '1.23457'

如果基数无效,则抛出错误。有关错误信息,请参阅 “错误” 部分。

对于字符串类型的值,其位数没有限制(除了 JavaScript 的最大数组大小)。有关设置 BigNumber 的最大和最小可能指数值,请参阅 RANGE。

javascript

new BigNumber('5032485723458348569331745.33434346346912144534543');
new BigNumber('4.321e10000000');

如果 n 无效,则返回 BigNumber NaN(除非 BigNumber.DEBUG 为 true,见下文)。

javascript

new BigNumber('.1*'); // 'NaN'
new BigNumber('blurgh'); // 'NaN'
new BigNumber(9, 2); // 'NaN'

为了帮助调试,如果 BigNumber.DEBUG 为 true,则在 n 无效时将抛出错误。如果 n 是类型为数字且有效数字超过 15 位,也会抛出错误,因为对这些数字调用 toString 或 valueOf 可能不会产生预期的值。

javascript

console.log(823456789123456.3); //  823456789123456.2
new BigNumber(823456789123456.3); // '823456789123456.2'
BigNumber.DEBUG = true;
// '[BigNumber Error] Number primitive has more than 15 significant digits'
new BigNumber(823456789123456.3);
// '[BigNumber Error] Not a base 2 number'
new BigNumber(9, 2);

BigNumber 也可以从对象字面量创建。使用 isBigNumber 检查其是否格式正确。

javascript

new BigNumber({ s: 1, e: 2, c: [ 777, 12300000000000 ], _isBigNumber: true }); // '777.123'

方法

静态方法

clone .clone ([object]) ⇒ BigNumber 构造函数

  • object:对象

返回一个新的独立 BigNumber 构造函数,其配置如 object 所述(请参阅 config),如果 object 为 null 或 undefined,则使用默认配置。

如果 object 不是对象,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

BigNumber.config({ DECIMAL_PLACES: 5 });
BN = BigNumber.clone({ DECIMAL_PLACES: 9 });
x = new BigNumber(1);
y = new BN(1);
x.div(3); // 0.33333
y.div(3); // 0.333333333
// BN = BigNumber.clone({ DECIMAL_PLACES: 9 }) 等效于:
BN = BigNumber.clone();
BN.config({ DECIMAL_PLACES: 9 });

configset([object]) ⇒ object

  • object:包含以下部分或全部属性的对象

为这个特定的 BigNumber 构造函数配置设置。

DECIMAL_PLACES
  • 数字:整数,范围在 0 到 1e+9 之间

  • 默认值:20

涉及除法的操作结果的最大小数位数,即除法、平方根和基数转换操作,以及负指数的幂运算。

javascript

BigNumber.config({ DECIMAL_PLACES: 5 });
BigNumber.set({ DECIMAL_PLACES: 5 }); // 等效
ROUNDING_MODE
  • 数字:整数,范围在 0 到 8 之间

  • 默认值:4(ROUND_HALF_UP)

上述操作中使用的舍入模式,以及 decimalPlaces、precision、toExponential、toFixed、toFormat 和 toPrecision 的默认舍入模式。

这些模式可作为 BigNumber 构造函数的枚举属性使用。

javascript

BigNumber.config({ ROUNDING_MODE: 0 });
BigNumber.set({ ROUNDING_MODE: BigNumber.ROUND_UP }); // 等效
EXPONENTIAL_AT
  • 数字:整数,绝对值范围在 0 到 1e+9 之间,或

  • 数字数组:[整数 -1e+9 到 0 之间,整数 0 到 1e+9 之间]

  • 默认值:[-7, 20]

toString 返回指数表示法时的指数值。

如果分配单个数字,则该值为指数绝对值。

如果分配一个包含两个数字的数组,则第一个数字是使用指数表示法的负指数值及以下,第二个数字是使用指数表示法的正指数值及以上。

例如,要模拟 JavaScript 数字开始使用指数表示法的指数值,请使用 [-7, 20]。

javascript

BigNumber.config({ EXPONENTIAL_AT: 2 });
new BigNumber(12.3); // '12.3'        e 仅为 1
new BigNumber(123); // '1.23e+2'
new BigNumber(0.123); // '0.123'       e 仅为 -1
new BigNumber(0.0123); // '1.23e-2'
BigNumber.config({ EXPONENTIAL_AT: [-7, 20] });
new BigNumber(123456789); // '123456789'   e 仅为 8
new BigNumber(0.000000123); // '1.23e-7'
// 几乎从不返回指数表示法:
BigNumber.config({ EXPONENTIAL_AT: 1e+9 });
// 始终返回指数表示法:
BigNumber.config({ EXPONENTIAL_AT: 0 });

无论 EXPONENTIAL_AT 的值如何,toFixed 方法始终返回普通表示法,toExponential 方法始终返回指数形式。

使用基数参数调用 toString,例如 toString (10),也将始终返回普通表示法。

RANGE
  • 数字:整数,绝对值范围在 1 到 1e+9 之间,或

  • 数字数组:[整数 -1e+9 到 -1 之间,整数 1 到 1e+9 之间]

  • 默认值:[-1e+9, 1e+9]

超出此范围的指数值将溢出为 Infinity 或下溢为零。

如果分配单个数字,则为最大指数绝对值:正指数大于该绝对值的值变为 Infinity,负指数大于该绝对值的值变为零。

如果分配一个包含两个数字的数组,则第一个数字是负指数限制,第二个数字是正指数限制。

例如,要模拟 JavaScript 数字变为零和 Infinity 的指数值,请使用 [-324, 308]。

javascript

BigNumber.config({ RANGE: 500 });
BigNumber.config().RANGE; // [ -500, 500 ]
new BigNumber('9.999e499'); // '9.999e+499'
new BigNumber('1e500'); // 'Infinity'
new BigNumber('1e-499'); // '1e-499'
new BigNumber('1e-500'); // '0'
BigNumber.config({ RANGE: [-3, 4] });
new BigNumber(99999); // '99999'      e 仅为 4
new BigNumber(100000); // 'Infinity'   e 为 5
new BigNumber(0.001); // '0.01'       e 仅为 -3
new BigNumber(0.0001); // '0'          e 为 -4

有限 BigNumber 的最大可能绝对值为 9.999...e+1000000000。

非零 BigNumber 的最小可能绝对值为 1e-1000000000。

CRYPTO
  • 布尔值:true 或 false

  • 默认值:false

确定是否使用加密安全的伪随机数生成的值。

如果 CRYPTO 设置为 true,则 random 方法将在支持的浏览器中使用 crypto.getRandomValues 生成随机数字,或在使用 Node.js 时使用 crypto.randomBytes。

如果宿主环境不支持这两个函数,则尝试将 CRYPTO 设置为 true 将失败并抛出异常。

如果 CRYPTO 为 false,则使用的随机源将是 Math.random(假定其生成至少 30 位的随机性)。

javascript

// Node.js
global.crypto = require('crypto');
BigNumber.config({ CRYPTO: true });
BigNumber.config().CRYPTO; // true
BigNumber.random(); // 0.54340758610486147524
MODULO_MODE
  • 数字:整数,范围在 0 到 9 之间

  • 默认值:1(ROUND_DOWN)

计算模数 a mod n 时使用的模数模式。

商 q = a /n 根据与所选 MODULO_MODE 对应的 ROUNDING_MODE 计算。

余数 r 计算为:r = a - n * q。

下表显示了模数 / 余数操作中最常用的模式。尽管可以使用其他舍入模式,但它们可能不会产生有用的结果。

属性 描述
ROUND_UP 0 如果被除数为负,余数为正,否则为负
ROUND_DOWN 1 余数与被除数符号相同
ROUND_FLOOR 3 余数与除数符号相同
ROUND_HALF_EVEN 6 IEEE 754 余数函数
EUCLID 9 余数始终为正

舍入 / 模数模式可作为 BigNumber 构造函数的枚举属性使用。

javascript

BigNumber.config({ MODULO_MODE: BigNumber.EUCLID });
BigNumber.config({ MODULO_MODE: 9 }); // 等效
POW_PRECISION
  • 数字:整数,范围在 0 到 1e+9 之间

  • 默认值:0

幂运算结果的最大精度,即有效数字位数(除非指定模数)。

如果设置为 0,则有效数字位数不受限制。

javascript

BigNumber.config({ POW_PRECISION: 100 });
FORMAT
  • 对象

FORMAT 对象配置 toFormat 方法返回的字符串格式。

下面的示例显示了识别的 FORMAT 对象的属性及其默认值。

与其他配置属性不同,FORMAT 对象的属性值不会检查有效性。现有的 FORMAT 对象将简单地被传入的对象替换。该对象可以包含下面显示的任何数量的属性。

javascript

BigNumber.config({
  FORMAT: {
    // 要前置的字符串
    prefix: '',
    // 小数分隔符
    decimalSeparator: '.',
    // 整数部分的分组分隔符
    groupSeparator: ',',
    // 整数部分的主要分组大小
    groupSize: 3,
    // 整数部分的次要分组大小
    secondaryGroupSize: 0,
    // 小数部分的分组分隔符
    fractionGroupSeparator: ' ',
    // 小数部分的分组大小
    fractionGroupSize: 0,
    // 要附加的字符串
    suffix: ''
  }
});
ALPHABET
  • 字符串

  • 默认值:'0123456789abcdefghijklmnopqrstuvwxyz'

用于基数转换的字母表。字母表的长度对应于可以传递给 BigNumber 构造函数或 toString 的基数参数的最大值。

字母表没有最大长度,但必须至少包含 2 个字符,并且不能包含空格或重复字符,也不能包含符号指示符 '+' 和 '-',或小数分隔符 '.'。

javascript

// 十二进制(基数 12)
BigNumber.config({ ALPHABET: '0123456789TE' });
x = new BigNumber('T', 12);
x.toString(); // '10'
x.toString(12); // 'T'

返回包含上述属性及其当前值的对象。

如果 object 不是对象,或者为上述属性之一分配了无效值,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

BigNumber.config({
  DECIMAL_PLACES: 40,
  ROUNDING_MODE: BigNumber.ROUND_HALF_CEIL,
  EXPONENTIAL_AT: [-10, 20],
  RANGE: [-500, 500],
  CRYPTO: true,
  MODULO_MODE: BigNumber.ROUND_FLOOR,
  POW_PRECISION: 80,
  FORMAT: {
    groupSize: 3,
    groupSeparator: ' ',
    decimalSeparator: ','
  },
  ALPHABET: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ$_'
});
obj = BigNumber.config();
obj.DECIMAL_PLACES; // 40
obj.RANGE; // [-500, 500]

isBigNumber.isBigNumber(value) ⇒ boolean

  • value:任何值

如果 value 是 BigNumber 实例,则返回 true,否则返回 false。

javascript

x = 42;
y = new BigNumber(x);
BigNumber.isBigNumber(x); // false
y instanceof BigNumber; // true
BigNumber.isBigNumber(y); // true
BN = BigNumber.clone();
z = new BN(x);
z instanceof BigNumber; // false
BigNumber.isBigNumber(z); // true

如果 value 是 BigNumber 实例且 BigNumber.DEBUG 为 true,则此方法还将检查 value 是否格式正确,如果不正确则抛出错误。有关错误信息,请参阅 “错误” 部分。

此检查在从对象字面量创建 BigNumber 时很有用。

javascript

x = new BigNumber(10);
// 更改 x.c 为非法值
x.c = NaN;
BigNumber.DEBUG = false;
// 无错误
BigNumber.isBigNumber(x); // true
BigNumber.DEBUG = true;
// 错误
BigNumber.isBigNumber(x); // '[BigNumber Error] Invalid BigNumber'

maximum.max(n...) ⇒ BigNumber

  • n:数字、字符串或 BigNumber

返回值为参数中最大值的 BigNumber。

返回值始终精确且未舍入。

javascript

x = new BigNumber('3257869345.0378653');
BigNumber.maximum(4e9, x, '123456789.9'); // '4000000000'
arr = [12, '13', new BigNumber(14)];
BigNumber.max.apply(null, arr); // '14'

minimum.min(n...) ⇒ BigNumber

  • n:数字、字符串或 BigNumber

返回值为参数中最小值的 BigNumber。

返回值始终精确且未舍入。

javascript

x = new BigNumber('3257869345.0378653');
BigNumber.minimum(4e9, x, '123456789.9'); // '123456789.9'
arr = [2, new BigNumber(-14), '-15.9999', -12];
BigNumber.min.apply(null, arr); // '-15.9999'

random.random([dp]) ⇒ BigNumber

  • dp:数字,整数,范围在 0 到 1e+9 之间

返回一个新的 BigNumber,其伪随机值等于或大于 0 且小于 1。

返回值将有 dp 位小数(如果产生 trailing zeros,则可能更少)。

如果省略 dp,则小数位数默认为当前 DECIMAL_PLACES 设置。

根据此 BigNumber 构造函数的 CRYPTO 设置和宿主环境对 crypto 对象的支持,返回值的随机数字由 Math.random(最快)、crypto.getRandomValues(最新浏览器中的 Web Cryptography API)或 crypto.randomBytes(Node.js)生成。

在 Node.js 中使用时,要能够将 CRYPTO 设置为 true,必须全局可用 crypto 对象:

javascript

global.crypto = require('crypto');

如果 CRYPTO 为 true,即使用其中一种 crypto 方法,返回的 BigNumber 的值应是加密安全的,并且在统计上与随机值无法区分。

如果 dp 无效,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

BigNumber.config({ DECIMAL_PLACES: 10 });
BigNumber.random(); // '0.4117936847'
BigNumber.random(20); // '0.78193327636914089009'

sum.sum(n...) ⇒ BigNumber

  • n:数字、字符串或 BigNumber

返回值为参数之和的 BigNumber。

返回值始终精确且未舍入。

javascript

x = new BigNumber('3257869345.0378653');
BigNumber.sum(4e9, x, '123456789.9'); // '7381326134.9378653'
arr = [2, new BigNumber(14), '15.9999', 12];
BigNumber.sum.apply(null, arr); // '43.9999'

枚举属性

库的枚举舍入模式存储为构造函数的属性。

(它们本身不被库内部引用。)

舍入模式 0 到 6(含)与 Java 的 BigDecimal 类的舍入模式相同。

属性 描述
ROUND_UP 0 远离零舍入
ROUND_DOWN 1 向零舍入
ROUND_CEIL 2 向 Infinity 舍入
ROUND_FLOOR 3 向 -Infinity 舍入
ROUND_HALF_UP 4 向最近的邻居舍入。如果等距,远离零舍入
ROUND_HALF_DOWN 5 向最近的邻居舍入。如果等距,向零舍入
ROUND_HALF_EVEN 6 向最近的邻居舍入。如果等距,向偶数邻居舍入
ROUND_HALF_CEIL 7 向最近的邻居舍入。如果等距,向 Infinity 舍入
ROUND_HALF_FLOOR 8 向最近的邻居舍入。如果等距,向 -Infinity 舍入

javascript

BigNumber.config({ ROUNDING_MODE: BigNumber.ROUND_CEIL });
BigNumber.config({ ROUNDING_MODE: 2 }); // 等效

DEBUG

  • undefined、false 或 true

如果 BigNumber.DEBUG 设置为 true,则当此 BigNumber 构造函数接收到无效值时将抛出错误,例如有效数字超过 15 位的数字类型值。

当 isBigNumber 方法接收到格式不正确的 BigNumber 时,也将抛出错误。

javascript

BigNumber.DEBUG = true;

实例方法

BigNumber 实例从其构造函数的原型对象继承的方法。

BigNumber 是不可变的,即其方法不会更改它。

对 ±0、±Infinity 和 NaN 的处理与 JavaScript 对这些值的处理一致。

许多方法名有较短的别名。

absoluteValue.abs() ⇒ BigNumber

返回值为该 BigNumber 值的绝对值(即大小)的 BigNumber。

返回值始终精确且未舍入。

javascript

x = new BigNumber(-0.8);
y = x.absoluteValue(); // '0.8'
z = y.abs(); // '0.8'

comparedTo.comparedTo(n [, base]) ⇒ number

  • n:数字、字符串或 BigNumber

  • base:数字

返回:

  • 1:如果此 BigNumber 的值大于 n 的值

  • -1:如果此 BigNumber 的值小于 n 的值

  • 0:如果此 BigNumber 和 n 的值相同

  • null:如果此 BigNumber 或 n 的值为 NaN

javascript

x = new BigNumber(Infinity);
y = new BigNumber(5);
x.comparedTo(y); // 1
x.comparedTo(x.minus(1)); // 0
y.comparedTo(NaN); // null
y.comparedTo('110', 2); // -1

decimalPlaces.dp([dp [, rm]]) ⇒ BigNumber|number

  • dp:数字,整数,范围在 0 到 1e+9 之间

  • rm:数字,整数,范围在 0 到 8 之间

如果 dp 是数字,返回值为该 BigNumber 的值使用舍入模式 rm 舍入到最多 dp 位小数的 BigNumber。

如果省略 dp,或 dp 为 null 或 undefined,返回值为该 BigNumber 值的小数位数,或者如果该 BigNumber 的值为 ±Infinity 或 NaN,则为 null。

如果省略 rm,或 rm 为 null 或 undefined,使用 ROUNDING_MODE。

如果 dp 或 rm 无效,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

x = new BigNumber(1234.56);
x.decimalPlaces(1); // '1234.6'
x.dp(); // 2
x.decimalPlaces(2); // '1234.56'
x.dp(10); // '1234.56'
x.decimalPlaces(0, 1); // '1234'
x.dp(0, 6); // '1235'
x.decimalPlaces(1, 1); // '1234.5'
x.dp(1, BigNumber.ROUND_HALF_EVEN); // '1234.6'
x; // '1234.56'
y = new BigNumber('9.9e-101');
y.dp(); // 102

dividedBy.div(n [, base]) ⇒ BigNumber

  • n:数字、字符串或 BigNumber

  • base:数字

返回值为该 BigNumber 的值除以 n 的 BigNumber,根据当前 DECIMAL_PLACES 和 ROUNDING_MODE 设置进行舍入。

javascript

x = new BigNumber(355);
y = new BigNumber(113);
x.dividedBy(y); // '3.14159292035398230088'
x.div(5); // '71'
x.div(47, 16); // '5'

dividedToIntegerBy.idiv(n [, base]) ⇒ BigNumber

  • n:数字、字符串或 BigNumber

  • base:数字

返回值为该 BigNumber 的值除以 n 的整数部分的 BigNumber。

javascript

x = new BigNumber(5);
y = new BigNumber(3);
x.dividedToIntegerBy(y); // '1'
x.idiv(0.7); // '7'
x.idiv('0.f', 16); // '5'

exponentiatedBy.pow(n [, m]) ⇒ BigNumber

  • n:数字、字符串或 BigNumber,整数

  • m:数字、字符串或 BigNumber

返回值为该 BigNumber 的值的 n 次幂的 BigNumber,即 x^n,并可选模 m。

如果 n 不是整数,则抛出错误。有关错误信息,请参阅 “错误” 部分。

如果 n 为负,结果根据当前 DECIMAL_PLACES 和 ROUNDING_MODE 设置进行舍入。

由于幂运算结果的位数可能快速增长,例如 123.456^10000 有超过 50000 位,因此计算的有效数字位数受 POW_PRECISION 设置的限制(除非指定模数 m)。

默认情况下,POW_PRECISION 设置为 0。这意味着将计算无限数量的有效数字,并且对于较大的指数,该方法的性能将急剧下降。

如果指定了 m,并且 m、n 和此 BigNumber 的值都是整数,且 n 为正,则使用快速模幂算法,否则操作将按 x.exponentiatedBy (n).modulo (m) 执行,POW_PRECISION 为 0。

javascript

Math.pow(0.7, 2); // 0.48999999999999994
x = new BigNumber(0.7);
x.exponentiatedBy(2); // '0.49'
BigNumber(3).pow(-2); // '0.11111111111111111111'

integerValue.integerValue([rm]) ⇒ BigNumber

  • rm:数字,整数,范围在 0 到 8 之间

返回值为该 BigNumber 的值使用舍入模式 rm 舍入为整数的 BigNumber。

如果省略 rm,或 rm 为 null 或 undefined,使用 ROUNDING_MODE。

如果 rm 无效,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

x = new BigNumber(123.456);
x.integerValue(); // '123'
x.integerValue(BigNumber.ROUND_CEIL); // '124'
y = new BigNumber(-12.7);
y.integerValue(); // '-13'
y.integerValue(BigNumber.ROUND_DOWN); // '-12'

以下是如何添加一个模拟 JavaScript 的 Math.round 函数的原型方法的示例。Math.ceil、Math.floor 和 Math.trunc 可以用 BigNumber.ROUND_CEIL、BigNumber.ROUND_FLOOR 和 BigNumber.ROUND_DOWN 以相同方式模拟。

javascript

BigNumber.prototype.round = function () {
  return this.integerValue(BigNumber.ROUND_HALF_CEIL);
};
x.round(); // '123'

isEqualTo.eq(n [, base]) ⇒ boolean

  • n:数字、字符串或 BigNumber

  • base:数字

如果此 BigNumber 的值等于 n 的值,则返回 true,否则返回 false。

与 JavaScript 一样,NaN 不等于 NaN。

注意:此方法内部使用 comparedTo 方法。

javascript

0 === 1e-324; // true
x = new BigNumber(0);
x.isEqualTo('1e-324'); // false
BigNumber(-0).eq(x); // true  ( -0 === 0 )
BigNumber(255).eq('ff', 16); // true
y = new BigNumber(NaN);
y.isEqualTo(NaN); // false

isFinite.isFinite() ⇒ boolean

如果该 BigNumber 的值是有限数,则返回 true,否则返回 false。

BigNumber 的唯一可能的非有限值是 NaN、Infinity 和 -Infinity。

javascript

x = new BigNumber(1);
x.isFinite(); // true
y = new BigNumber(Infinity);
y.isFinite(); // false

注意:如果 n <= Number.MAX_VALUE,可以使用原生方法 isFinite ()。

isGreaterThan.gt(n [, base]) ⇒ boolean

  • n:数字、字符串或 BigNumber

  • base:数字

如果此 BigNumber 的值大于 n 的值,则返回 true,否则返回 false。

注意:此方法内部使用 comparedTo 方法。

javascript

0.1 > (0.3 - 0.2); // true
x = new BigNumber(0.1);
x.isGreaterThan(BigNumber(0.3).minus(0.2)); // false
BigNumber(0).gt(x); // false
BigNumber(11, 3).gt(11.1, 2); // true

isGreaterThanOrEqualTo.gte(n [, base]) ⇒ boolean

  • n:数字、字符串或 BigNumber

  • base:数字

如果此 BigNumber 的值大于或等于 n 的值,则返回 true,否则返回 false。

注意:此方法内部使用 comparedTo 方法。

javascript

(0.3 - 0.2) >= 0.1; // false
x = new BigNumber(0.3).minus(0.2);
x.isGreaterThanOrEqualTo(0.1); // true
BigNumber(1).gte(x); // true
BigNumber(10, 18).gte('i', 36); // true

isInteger.isInteger() ⇒ boolean

如果该 BigNumber 的值是整数,则返回 true,否则返回 false。

javascript

x = new BigNumber(1);
x.isInteger(); // true
y = new BigNumber(123.456);
y.isInteger(); // false

isLessThan.lt(n [, base]) ⇒ boolean

  • n:数字、字符串或 BigNumber

  • base:数字

如果此 BigNumber 的值小于 n 的值,则返回 true,否则返回 false。

注意:此方法内部使用 comparedTo 方法。

javascript

(0.3 - 0.2) < 0.1; // true
x = new BigNumber(0.3).minus(0.2);
x.isLessThan(0.1); // false
BigNumber(0).lt(x); // true
BigNumber(11.1, 2).lt(11, 3); // true

isLessThanOrEqualTo.lte(n [, base]) ⇒ boolean

  • n:数字、字符串或 BigNumber

  • base:数字

如果此 BigNumber 的值小于或等于 n 的值,则返回 true,否则返回 false。

注意:此方法内部使用 comparedTo 方法。

javascript

0.1 <= (0.3 - 0.2); // false
x = new BigNumber(0.1);
x.isLessThanOrEqualTo(BigNumber(0.3).minus(0.2)); // true
BigNumber(-1).lte(x); // true
BigNumber(10, 18).lte('i', 36); // true

isNaN.isNaN() ⇒ boolean

如果该 BigNumber 的值是 NaN,则返回 true,否则返回 false。

javascript

x = new BigNumber(NaN);
x.isNaN(); // true
y = new BigNumber('Infinity');
y.isNaN(); // false

注意:也可以使用原生方法 isNaN ()。

isNegative.isNegative() ⇒ boolean

如果该 BigNumber 的符号为负,则返回 true,否则返回 false。

javascript

x = new BigNumber(-0);
x.isNegative(); // true
y = new BigNumber(2);
y.isNegative(); // false

注意:如果 n <= -Number.MIN_VALUE,可以使用 n < 0。

isPositive.isPositive() ⇒ boolean

如果该 BigNumber 的符号为正,则返回 true,否则返回 false。

javascript

x = new BigNumber(-0);
x.isPositive(); // false
y = new BigNumber(2);
y.isPositive(); // true

isZero.isZero() ⇒ boolean

如果该 BigNumber 的值为零或负零,则返回 true,否则返回 false。

javascript

x = new BigNumber(-0);
x.isZero() && x.isNegative(); // true
y = new BigNumber(Infinity);
y.isZero(); // false

注意:如果 n >= Number.MIN_VALUE,可以使用 n == 0。

minus.minus(n [, base]) ⇒ BigNumber

  • n:数字、字符串或 BigNumber

  • base:数字

返回值为该 BigNumber 的值减去 n 的 BigNumber。

返回值始终精确且未舍入。

javascript

0.3 - 0.1; // 0.19999999999999998
x = new BigNumber(0.3);
x.minus(0.1); // '0.2'
x.minus(0.6, 20); // '0'

modulo.mod(n [, base]) ⇒ BigNumber

  • n:数字、字符串或 BigNumber

  • base:数字

返回值为该 BigNumber 的值模 n 的 BigNumber,即该 BigNumber 除以 n 的整数余数。

返回值的符号取决于此 BigNumber 构造函数的 MODULO_MODE 设置。如果为 1(默认值),结果将与该 BigNumber 具有相同的符号,并且将与 Javascript 的 % 运算符(在双精度限制内)和 BigDecimal 的 remainder 方法匹配。

返回值始终精确且未舍入。

javascript

1 % 0.9; // 0.09999999999999998
x = new BigNumber(1);
x.modulo(0.9); // '0.1'
y = new BigNumber(33);
y.mod('a', 33); // '3'

multipliedBy.times(n [, base]) ⇒ BigNumber

  • n:数字、字符串或 BigNumber

  • base:数字

返回值为该 BigNumber 的值乘以 n 的 BigNumber。

返回值始终精确且未舍入。

javascript

0.6 * 3; // 1.7999999999999998
x = new BigNumber(0.6);
y = x.multipliedBy(3); // '1.8'
BigNumber('7e+500').times(y); // '1.26e+501'
x.multipliedBy('-a', 16); // '-6'

negated.negated() ⇒ BigNumber

返回值为该 BigNumber 的值取反的 BigNumber,即乘以 -1。

javascript

x = new BigNumber(1.8);
x.negated(); // '-1.8'
y = new BigNumber(-1.3);
y.negated(); // '1.3'

plus.plus(n [, base]) ⇒ BigNumber

  • n:数字、字符串或 BigNumber

  • base:数字

返回值为该 BigNumber 的值加上 n 的 BigNumber。

返回值始终精确且未舍入。

javascript

0.1 + 0.2; // 0.30000000000000004
x = new BigNumber(0.1);
y = x.plus(0.2); // '0.3'
BigNumber(0.7).plus(x).plus(y); // '1.1'
x.plus('0.1', 8); // '0.225'

precision.sd([d [, rm]]) ⇒ BigNumber|number

  • d:数字或布尔值,整数,范围在 1 到 1e+9 之间,或 true 或 false

  • rm:数字,整数,范围在 0 到 8 之间

如果 d 是数字,返回值为该 BigNumber 的值使用舍入模式 rm 舍入到 d 位有效数字的 BigNumber。

如果省略 d 或 d 为 null 或 undefined,返回值为该 BigNumber 值的有效数字位数,或者如果该 BigNumber 的值为 ±Infinity 或 NaN,则为 null。

如果 d 为 true,则数字整数部分的任何 trailing zeros 都计为有效数字,否则不计。

如果省略 rm 或 rm 为 null 或 undefined,使用 ROUNDING_MODE。

如果 d 或 rm 无效,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

x = new BigNumber(9876.54321);
x.precision(6); // '9876.54'
x.sd(); // 9
x.precision(6, BigNumber.ROUND_UP); // '9876.55'
x.sd(2); // '9900'
x.precision(2, 1); // '9800'
x; // '9876.54321'
y = new BigNumber(987000);
y.precision(); // 3
y.sd(true); // 6

shiftedBy.shiftedBy(n) ⇒ BigNumber

  • n:数字,整数,范围在 -9007199254740991 到 9007199254740991 之间

返回值为该 BigNumber 的值移动 n 位的 BigNumber。

移动的是小数点,即 10 的幂,如果 n 为负则向左移动,如果 n 为正则向右移动。

返回值始终精确且未舍入。

如果 n 无效,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

x = new BigNumber(1.23);
x.shiftedBy(3); // '1230'
x.shiftedBy(-3); // '0.00123'

squareRoot.sqrt() ⇒ BigNumber

返回值为该 BigNumber 的值的平方根的 BigNumber,根据当前 DECIMAL_PLACES 和 ROUNDING_MODE 设置进行舍入。

返回值将正确舍入,即好像结果首先计算到无限数量的正确数字,然后再舍入。

javascript

x = new BigNumber(16);
x.squareRoot(); // '4'
y = new BigNumber(3);
y.sqrt(); // '1.73205080756887729353'

toExponential.toExponential([dp [, rm]]) ⇒ string

  • dp:数字,整数,范围在 0 到 1e+9 之间

  • rm:数字,整数,范围在 0 到 8 之间

返回表示该 BigNumber 的值的字符串,采用指数表示法,使用舍入模式 rm 舍入到 dp 位小数,即小数点前有一位数字,后面有 dp 位数字。

如果该 BigNumber 的指数表示法的值的小数位数少于 dp,则返回值将相应地附加零。

如果省略 dp 或 dp 为 null 或 undefined,小数点后的位数默认为精确表示该值所需的最小位数。

如果省略 rm 或 rm 为 null 或 undefined,使用 ROUNDING_MODE。

如果 dp 或 rm 无效,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

x = 45.6;
y = new BigNumber(x);
x.toExponential(); // '4.56e+1'
y.toExponential(); // '4.56e+1'
x.toExponential(0); // '5e+1'
y.toExponential(0); // '5e+1'
x.toExponential(1); // '4.6e+1'
y.toExponential(1); // '4.6e+1'
y.toExponential(1, 1); // '4.5e+1'  (ROUND_DOWN)
x.toExponential(3); // '4.560e+1'
y.toExponential(3); // '4.560e+1'

toFixed.toFixed([dp [, rm]]) ⇒ string

  • dp:数字,整数,范围在 0 到 1e+9 之间

  • rm:数字,整数,范围在 0 到 8 之间

返回表示该 BigNumber 的值的字符串,采用普通(定点)表示法,使用舍入模式 rm 舍入到 dp 位小数。

如果该 BigNumber 的普通表示法的值的小数位数少于 dp,则返回值将相应地附加零。

与 Number.prototype.toFixed 不同,Number.prototype.toFixed 在数字大于或等于 10^21 时返回指数表示法,而此方法始终返回普通表示法。

如果省略 dp 或 dp 为 null 或 undefined,返回值将未舍入并采用普通表示法。这也与 Number.prototype.toFixed 不同,Number.prototype.toFixed 返回值到零位小数。

当需要定点表示法且当前 EXPONENTIAL_AT 设置导致 toString 返回指数表示法时,此方法很有用。

如果省略 rm 或 rm 为 null 或 undefined,使用 ROUNDING_MODE。

如果 dp 或 rm 无效,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

x = 3.456;
y = new BigNumber(x);
x.toFixed(); // '3'
y.toFixed(); // '3.456'
y.toFixed(0); // '3'
x.toFixed(2); // '3.46'
y.toFixed(2); // '3.46'
y.toFixed(2, 1); // '3.45'  (ROUND_DOWN)
x.toFixed(5); // '3.45600'
y.toFixed(5); // '3.45600'

toFormat.toFormat([dp [, rm[, format]]]) ⇒ string

  • dp:数字,整数,范围在 0 到 1e+9 之间

  • rm:数字,整数,范围在 0 到 8 之间

  • format:对象,见 FORMAT

返回表示该 BigNumber 的值的字符串,采用普通(定点)表示法,使用舍入模式 rm 舍入到 dp 位小数,并根据 format 对象的属性进行格式化。

有关 format 对象的属性、类型和用法,请参阅 FORMAT 和下面的示例。格式化对象可以包含一些或所有识别的属性。

如果省略 dp 或 dp 为 null 或 undefined,则返回值不会舍入到固定的小数位数。

如果省略 rm 或 rm 为 null 或 undefined,使用 ROUNDING_MODE。

如果省略 format 或 format 为 null 或 undefined,使用 FORMAT 对象。

如果 dp、rm 或 format 无效,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

fmt = {
  prefix: '',
  decimalSeparator: '.',
  groupSeparator: ',',
  groupSize: 3,
  secondaryGroupSize: 0,
  fractionGroupSeparator: ' ',
  fractionGroupSize: 0,
  suffix: ''
};
x = new BigNumber('123456789.123456789');
// 设置全局格式化选项
BigNumber.config({ FORMAT: fmt });
x.toFormat(); // '123,456,789.123456789'
x.toFormat(3); // '123,456,789.123'
// 如果保留了分配给 FORMAT 的对象的引用,
// 可以直接更改格式属性
fmt.groupSeparator = ' ';
fmt.fractionGroupSize = 5;
x.toFormat(); // '123 456 789.12345 6789'
// 或者,将格式化选项作为参数传递
fmt = {
  prefix: '=> ',
  decimalSeparator: ',',
  groupSeparator: '.',
  groupSize: 3,
  secondaryGroupSize: 2
};
x.toFormat(); // '123 456 789.12345 6789'
x.toFormat(fmt); // '=> 12.34.56.789,123456789'
x.toFormat(2, fmt); // '=> 12.34.56.789,12'
x.toFormat(3, BigNumber.ROUND_UP, fmt); // '=> 12.34.56.789,124'

toFraction.toFraction([maximum_denominator]) ⇒ [BigNumber, BigNumber]

  • maximum_denominator:数字、字符串或 BigNumber,整数 >= 1 且 <= Infinity

返回一个包含两个 BigNumber 的数组,表示该 BigNumber 的值为一个简单分数,具有整数分子和整数分母。分母将是一个正的非零值,小于或等于 maximum_denominator。

如果未指定 maximum_denominator,或为 null 或 undefined,分母将是精确表示该数字所需的最小值。

如果 maximum_denominator 无效,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

x = new BigNumber(1.75);
x.toFraction(); // '7, 4'
pi = new BigNumber('3.14159265358');
pi.toFraction(); // '157079632679,50000000000'
pi.toFraction(100000); // '312689, 99532'
pi.toFraction(10000); // '355, 113'
pi.toFraction(100); // '311, 99'
pi.toFraction(10); // '22, 7'
pi.toFraction(1); // '3, 1'

toJSON.toJSON() ⇒ string

与 valueOf 相同。

javascript

x = new BigNumber('177.7e+457');
y = new BigNumber(235.4325);
z = new BigNumber('0.0098074');
// 序列化包含三个 BigNumber 的数组
str = JSON.stringify( [x, y, z] );
// "["1.777e+459","235.4325","0.0098074"]"
// 返回包含三个 BigNumber 的数组
JSON.parse(str, function (key, val) {
    return key === '' ? val : new BigNumber(val)
});

toNumber.toNumber() ⇒ number

返回该 BigNumber 的值作为 JavaScript 数字原语。

此方法与使用一元加号运算符进行类型转换相同。

javascript

x = new BigNumber(456.789);
x.toNumber(); // 456.789
+x; // 456.789
y = new BigNumber('45987349857634085409857349856430985');
y.toNumber(); // 4.598734985763409e+34
z = new BigNumber(-0);
1 / z.toNumber(); // -Infinity
1 / +z; // -Infinity

toPrecision.toPrecision([sd [, rm]]) ⇒ string

  • sd:数字,整数,范围在 1 到 1e+9 之间

  • rm:数字,整数,范围在 0 到 8 之间

返回表示该 BigNumber 的值的字符串,使用舍入模式 rm 舍入到 sd 位有效数字。

如果 sd 小于以普通(定点)表示法表示该值的整数部分所需的位数,则使用指数表示法。

如果省略 sd 或 sd 为 null 或 undefined,返回值与 n.toString () 相同。

如果省略 rm 或 rm 为 null 或 undefined,使用 ROUNDING_MODE。

如果 sd 或 rm 无效,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

x = 45.6;
y = new BigNumber(x);
x.toPrecision(); // '45.6'
y.toPrecision(); // '45.6'
x.toPrecision(1); // '5e+1'
y.toPrecision(1); // '5e+1'
y.toPrecision(2, 0); // '4.6e+1'  (ROUND_UP)
y.toPrecision(2, 1); // '4.5e+1'  (ROUND_DOWN)
x.toPrecision(5); // '45.600'
y.toPrecision(5); // '45.600'

toString.toString([base]) ⇒ string

  • base:数字,整数,范围在 2 到 ALPHABET.length 之间(见 ALPHABET)

返回表示该 BigNumber 的值的字符串,采用指定的基数,或者如果省略 base 或 base 为 null 或 undefined,则为十进制。

对于基数大于 10 的情况,使用默认的基数转换字母表(见 ALPHABET),值 10 到 35 用 a-z 表示(与 Number.prototype.toString 相同)。

如果指定了基数,值将根据当前的 DECIMAL_PLACES 和 ROUNDING_MODE 设置进行舍入。

如果未指定基数,并且此 BigNumber 的正指数等于或大于当前 EXPONENTIAL_AT 设置的正分量,或者负指数等于或小于设置的负分量,则返回指数表示法。

如果 base 为 null 或 undefined,则忽略它。

如果基数无效,则抛出错误。有关错误信息,请参阅 “错误” 部分。

javascript

x = new BigNumber(750000);
x.toString(); // '750000'
BigNumber.config({ EXPONENTIAL_AT: 5 });
x.toString(); // '7.5e+5'
y = new BigNumber(362.875);
y.toString(2); // '101101010.111'
y.toString(9); // '442.77777777777777777778'
y.toString(32); // 'ba.s'
BigNumber.config({ DECIMAL_PLACES: 4 });
z = new BigNumber('1.23456789');
z.toString(); // '1.23456789'
z.toString(10); // '1.2346'

valueOf.valueOf() ⇒ string

与 toString 相同,但不接受基数参数,并且为负零包含负号。

javascript

x = new BigNumber('-0');
x.toString(); // '0'
x.valueOf(); // '-0'
y = new BigNumber('1.777e+457');
y.valueOf(); // '1.777e+457'

属性

BigNumber 实例的属性

属性 描述 类型
c 系数 * number[] 基数 1e14 的数字数组
e 指数 number 整数,范围在 - 1000000000 到 1000000000 之间
s 符号 number -1 或 1
  • 有效数字

c、e 和 s 属性中的任何一个的值也可能为 null。

最好将上述属性视为只读属性。在该库的早期版本中,通过直接写入指数属性来更改 BigNumber 的指数是可以的,但现在这不再可靠,因为系数数组的第一个元素的值现在取决于指数。

请注意,与 JavaScript 数字一样,原始指数和小数 trailing zeros 不一定保留。

javascript

x = new BigNumber(0.123); // '0.123'
x.toExponential(); // '1.23e-1'
x.c; // '1,2,3'
x.e; // -1
x.s; // 1
y = new Number(-123.4567000e+2); // '-12345.67'
y.toExponential(); // '-1.234567e+4'
z = new BigNumber('-123.4567000e+2'); // '-12345.67'
z.toExponential(); // '-1.234567e+4'
z.c; // '1,2,3,4,5,6,7'
z.e; // 4
z.s; // -1

零、NaN 和 Infinity

下表显示了 ±0、NaN 和 ±Infinity 的存储方式。

c e s
±0 [0] 0 ±1
NaN null null null
±Infinity null null ±1

javascript

x = new Number(-0); // 0
1 / x == -Infinity; // true
y = new BigNumber(-0); // '0'
y.c; // '0' ( [0].toString() )
y.e; // 0
y.s; // -1

错误

下表显示了抛出的错误。

这些错误是通用的 Error 对象,其消息以 “[BigNumber Error]” 开头。

方法 抛出错误
BigNumber comparedTo dividedBy dividedToIntegerBy isEqualTo isGreaterThan isGreaterThanOrEqualTo isLessThan isLessThanOrEqualTo minus modulo plus multipliedBy 基数不是原始数字 基数不是整数 基数超出范围 数字原语有效数字超过 15 位 * 不是基数... 数字 * 不是数字 *
clone 需要对象
config 需要对象 DECIMAL_PLACES 不是原始数字 DECIMAL_PLACES 不是整数 DECIMAL_PLACES 超出范围 ROUNDING_MODE 不是原始数字 ROUNDING_MODE 不是整数 ROUNDING_MODE 超出范围 EXPONENTIAL_AT 不是原始数字 EXPONENTIAL_AT 不是整数 EXPONENTIAL_AT 超出范围 RANGE 不是原始数字 RANGE 不是整数 RANGE 不能为零 RANGE 不能为零 CRYPTO 不是 true 或 false crypto 不可用 MODULO_MODE 不是原始数字 MODULO_MODE 不是整数 MODULO_MODE 超出范围 POW_PRECISION 不是原始数字 POW_PRECISION 不是整数 POW_PRECISION 超出范围 FORMAT 不是对象 ALPHABET 无效
decimalPlaces precision random shiftedBy toExponential toFixed toFormat toPrecision 参数不是原始数字 参数不是整数 参数超出范围
decimalPlaces precision 参数不是 true 或 false
exponentiatedBy 参数不是整数
isBigNumber 无效的 BigNumber*
minimum maximum 不是数字 *
random crypto 不可用
toFormat 参数不是对象
toFraction 参数不是整数 参数超出范围
toString 基数不是原始数字 基数不是整数 基数超出范围
  • 仅在 BigNumber.DEBUG 为 true 时抛出。

要确定异常是否为 BigNumber 错误:

javascript

try {
  // ...
} catch (e) {
  if (e instanceof Error && e.message.indexOf('[BigNumber Error]') === 0) {
      // ...
  }
}

类型转换

为了防止在原始数字操作中意外使用 BigNumber,或意外将 BigNumber 添加到字符串,valueOf 方法可以安全地覆盖,如下所示。

valueOf 方法与 toJSON 方法相同,两者都与 toString 方法相同,除了它们不接受基数参数,并且为负零包含负号。

javascript

BigNumber.prototype.valueOf = function () {
  throw Error('valueOf called!');
};
x = new BigNumber(1);
x / 2; // '[BigNumber Error] valueOf called!'
x + 'abc'; // '[BigNumber Error] valueOf called!'

常见问题

为什么 BigNumbers 会删除小数 trailing zeros?

一些任意精度库保留小数 trailing zeros,因为它们可以指示值的精度。这可能有用,但算术运算的结果可能会产生误导。

javascript

x = new BigDecimal("1.0");
y = new BigDecimal("1.1000");
z = x.add(y); // 2.1000
x = new BigDecimal("1.20");
y = new BigDecimal("3.45000");
z = x.multiply(y); // 4.1400000

指定值的精度是指指定该值位于某个范围内。

在第一个示例中,x 的值为 1.0。trailing zero 显示了值的精度,暗示它在 0.95 到 1.05 的范围内。同样,y 的 trailing zeros 指示的值的精度表明该值在 1.09995 到 1.10005 的范围内。

如果我们将范围内的两个最小值相加,0.95 + 1.09995 = 2.04995,如果我们将两个最大值相加,1.05 + 1.10005 = 2.15005,因此加法结果的范围(由操作数的精度暗示)为 2.04995 到 2.15005。

然而,BigDecimal 给出的结果 2.1000 表明该值在 2.09995 到 2.10005 的范围内,因此其 trailing zeros 暗示的精度可能会产生误导。

在第二个示例中,真实范围是 4.122744 到 4.157256,而 BigDecimal 的答案 4.1400000 表明范围是 4.13999995 到 4.14000005。同样,trailing zeros 暗示的精度可能会产生误导。

该库与二进制浮点数和大多数计算器一样,不保留小数 trailing zeros。相反,toExponential、toFixed 和 toPrecision 方法允许在需要时添加 trailing zeros。

性能注意事项

在处理极大指数或高精度计算时,BigNumber 的性能可能会受到影响:

  • 幂运算(exponentiatedBy) :当指数较大时,计算量会呈指数级增长。例如,123.456^10000 可能产生超过 50000 位的结果,此时建议通过 POW_PRECISION 限制有效数字位数。
  • 大量小数位运算:设置过高的 DECIMAL_PLACES 会增加计算开销,建议根据实际需求调整(默认值为 20)。
  • 连续链式操作:虽然 BigNumber 是不可变对象,但频繁创建新实例可能影响性能,建议复用变量。

与其他库的兼容性

  • JSON 序列化:通过重写 toJSON 方法,BigNumber 可直接被 JSON.stringify 序列化,反序列化时需手动转换(如示例所示)。
  • 原生数字转换:使用 toNumber() 可将 BigNumber 转换为 JavaScript 原生数字,但需注意精度丢失(超过 15 位有效数字时)。
  • 浏览器与 Node.js 环境:在 Node.js 中需手动引入 crypto 模块以启用加密安全随机数(CRYPTO: true)。

最佳实践

  1. 初始化优化

    • 优先使用字符串初始化大数字(如 new BigNumber('12345678901234567890')),避免数字原语精度丢失。
    • 对于固定精度场景,提前通过 config 设置 DECIMAL_PLACES 和 ROUNDING_MODE
  2. 舍入模式选择

    • 金融计算推荐使用 ROUND_HALF_EVEN(IEEE 754 标准),避免累计误差。
    • 科学计算可根据需求选择 ROUND_UP 或 ROUND_FLOOR
  3. 性能优化技巧

    • 使用 clone() 创建独立配置的构造函数,避免全局配置污染。
    • 对重复计算的中间结果进行缓存(如 const half = new BigNumber(0.5))。
  4. 错误处理

    • 生产环境建议关闭 DEBUG 模式以提高性能,开发阶段可启用以捕获潜在问题。
    • 通过 try-catch 捕获 [BigNumber Error] 类型错误,进行友好提示。

常见场景示例

金融计算(精确除法)

javascript

// 计算 1/3 并保留 10 位小数
const config = { DECIMAL_PLACES: 10, ROUNDING_MODE: BigNumber.ROUND_HALF_EVEN };
const oneThird = new BigNumber(1).dividedBy(3, 10).set(config);
console.log(oneThird.toFixed()); // '0.3333333333'

加密安全随机数

javascript

// Node.js 环境
global.crypto = require('crypto');
BigNumber.config({ CRYPTO: true, DECIMAL_PLACES: 16 });
const randomNum = BigNumber.random();
console.log(randomNum); // 如 '0.7819332763691409'(加密安全)

大数比较与排序

javascript

const numbers = [
  '9999999999999999999',
  new BigNumber('1e20'),
  99999999999999999999
];
const sorted = numbers.sort((a, b) => 
  new BigNumber(a).comparedTo(new BigNumber(b))
);
console.log(sorted); // ['9999999999999999999', 99999999999999999999, '1e20']

货币格式化

javascript

BigNumber.config({
  FORMAT: {
    prefix: '¥',
    decimalSeparator: '.',
    groupSeparator: ',',
    groupSize: 3,
    suffix: '元'
  }
});
const price = new BigNumber('1234567.89');
console.log(price.toFormat()); // '¥1,234,567.89元'

版本兼容性

  • ES5 与 ES6+ :库本身不依赖 ES6 特性,可在传统浏览器中使用。
  • 模块化支持:支持 CommonJS(Node.js)和 AMD 规范,也可通过全局变量 BigNumber 访问。

扩展与自定义

  1. 自定义基数字母表

    javascript

    // 自定义十六进制字母表(含大写字母)
    BigNumber.config({ ALPHABET: '0123456789ABCDEF' });
    const hexNum = new BigNumber('FF', 16);
    console.log(hexNum.toString()); // '255'
    
  2. 添加自定义方法

    javascript

    // 扩展原型添加百分比计算方法
    BigNumber.prototype.percentOf = function(percent) {
      return this.multipliedBy(percent).dividedBy(100);
    };
    const total = new BigNumber(200);
    const discount = total.percentOf(15); // 计算 15% 折扣
    console.log(discount); // '30'
    

疑难解答

  1. 为什么 0.1 + 0.2 不等于 0.3?

    • 答:JavaScript 原生数字使用双精度浮点,存在精度误差。使用 BigNumber 可解决:

      javascript

      const sum = new BigNumber(0.1).plus(0.2);
      console.log(sum.equals(0.3)); // true
      
  2. 如何处理超出 JavaScript 数字范围的数?

    • 答:直接使用字符串初始化,如 new BigNumber('1e1000'),BigNumber 会自动处理指数范围(受 RANGE 配置限制)。
  3. 基数转换时如何处理大数?

    • 答:BigNumber 支持最大 36 进制(默认字母表),如需更高基数可扩展 ALPHABET,如:

      javascript

      BigNumber.config({ ALPHABET: '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' });
      const base62Num = new BigNumber('123456', 62);
      console.log(base62Num.toString()); // '279838'
      

贡献与反馈

  • 问题报告:请在 GitHub 仓库 提交 Issue,附详细复现步骤。
  • 功能请求:通过 Pull Request 贡献代码,需遵循项目测试规范。
  • 社区支持:可通过 Stack Overflow 标签 #bignumber.js 获得帮助。

许可证

bignumber.js 遵循 MIT 许可证,允许自由使用、修改和分发。

从零打造一个开源的 AI 组件生成平台 😡

作者 imoo
2025年6月26日 17:28

前言

这两年 ai 也是跑的飞快,相信大部分兄弟已经投入 cursor trae 这些编辑器的怀抱了。效率属实提升了太多,即使没有的兄弟,估计也会使用 chatgpt 豆包 这些 ai 辅助自己进行开发。

但当问题比较复杂的时候,生成的代码往往不尽如人意。这时候就要自己来思考处理了。兄弟们有没有想过,为什么我们可以通过自己思考来处理复杂问题?ai 差在哪里?

要想明白这个问题,需要我们简单分析一下自己的行为,假设我们要做一个表单页,其中有定制的 Radio进度条等,我们的思路基本如下:

  1. 实现 Radio 组件,进行测试
  2. 实现进度条组件,进行测试
  3. 使用这些组件组装出完整的页面

是的,我们会将复杂问题,拆分为小块的问题,逐步进行验证和使用

如果按这个思路,让 ai 来处理小块的问题,生成结果会比之前好非常多。这种小块问题,也即基础单元,粒度选为组件太合适了

众所周知,组件可以独立为一个文件进行展示和测试,像积木一样,能很方便的进行组合。

image.png

另外,将其作为上下文注入对话时,也仅需要提供 用途+Props,大模型并不需要知道其内部完整实现,所以能节省很多上下文。

所以本文将聚焦于生成组件这一环,先贴一下我们的目标:

image.png

需要实现的基本功能为:

  1. 能跟 ai 讨论需求
  2. ai 能生成组件代码
  3. 平台需要实时渲染组件代码

这里需要解决的关键问题有:

  1. 如何定制 ai,使其能从对话中分辨出,应该进行需求讨论 or 生成组件
  2. 如何让组件代码能够实时渲染

定制 ai

定制 ai 这一步我选用了 dify,原因有下:

  1. 提供了基本的 ai 能力,支持使用自己的 key
  2. 能够编排模型思路,做出部分逻辑判断
  3. 简单、免费、热门

将花一些篇幅介绍 Dify 的使用,如果想跳过这一步,可以直接拿 DSL 文件,导入 Dify 即可 github.com/imoo666/ai-…

Dify 的基本使用

我们可以新建一个空白的 demo 应用(注意应用类型需要选择 Chatflow),然后简单做个测试

image.png

但是我们需要的是 api,而不是一个聊天的平台,所以看一下怎么用 api 来调用它。

先找 ai 帮我们写个 node 脚本:

const API_KEY = "your-demo-api-key";
const API_URL = "https://api.dify.ai/v1/chat-messages";

async function askDify(prompt) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      inputs: {},
      query: prompt,
      response_mode: "blocking", // 阻塞式,一次性返回完整结果
      user: "demo-user", // 可选:用于标识请求用户
    }),
  });

  const data = await res.json();
  console.log("🤖 Dify AI Response:", data.answer);
}

// 修改这里的 prompt 内容来测试
askDify("你好,能介绍一下你自己吗?");

dify 的 key 我们可以在这里找到

image.png

接着就可以进行 key 的替换和测试

image.png

不过大家可能在测试的过程中就会发现,openai 的免费额度用的飞快,所以我们需要去整点自己的模型放进 dify 中。

本次流程中,我使用了两个模型:

硅基流动:完全免费的 qwen-8b(在 dify 使用时要选择关闭思考模式),用于简单的对话、判断逻辑,节省成本

google:几乎免费的 gemini2.5 flash,用于生成高质量组件内容

这两个平台的注册和使用都非常简单,直接上官网就行了。

拿到 key 以后,直接上设置中激活对应模型,随之就能在其他地方使用了

image.png

Dify 的逻辑编排

我们增加一个分类器用于对问题的分类

  • 已经整理好了全部需求,可以准备生成组件
  • 还没讨论或是还没讨论完,需要和用户讨论需求

并给定两种输出来验证分类的正确性

image.png

分类是正确的,那么我们就可以写后续的逻辑了,对于不同的分类,我们给予不同的提示词

你是一个引导机器人,你有丰富的前端开发经验,你需要一步步引导用户进行 react 组件生成。
- 你只需要和用户详细的讨论需求,不需要进行代码实现
- 如果用户不想考虑的很详细,则用你的经验来为用户选择一个最简单、最合理的需求列表
- 代码实现由另一个 api 来做,它将使用 react + ts,类组件,且只支持一个文件
- 每次和用户讨论需求后,将所有需求要点都重新罗列出来,按 12345... 进行输出,直到用户觉得满意,可以生成组件为止

你是一个前端组件代码生成器,你需要根据用户的需求,将其实现为可运行的 React 函数组件代码(TSX)。
要求:
- 使用函数组件,且只能生成一个文件
- 这个文件需要包含两个部分 1. 导出的组件,注意需要用局部导出 2. 导出的 使用demo,使用默认导出
- 需要使用行内 style 来写样式,组件的 ui 要现代化和美观
- 代码中严禁出现反引号`,代码中严禁出现反引号`
- 只需要返回组件代码,不需要用 markdown 格式输出,不需要其他介绍,只需要代码!

image.png

这样逻辑就编排完毕了,也成功实现了该讨论讨论,该生成生成。

现在我们只需要考虑如何将这一大串组件代码进行实时渲染即可。

React 实时渲染

主流的解决方案有以下三种

image.png

首先由于 react-live 不支持 import,导致很多包用不了,假如我们是基于其他组件来做二次封装会相当麻烦,直接 pass。

Livecode 体验了一下,感觉很重,而且 ui 比较古老,遂放弃。

Sandpack 则是功能最完整,体感最好的,也是 storybook 等工具的同款。

使用起来也非常容易,只需要接入一个组件即可,demo 如下

import { Sandpack } from "@codesandbox/sandpack-react";

const App = () => {
  const files = {}
  
  return (
    <Sandpack
      files={files} 
      theme="light" 
      template="react"
    />
  )  
}

随后左侧的框就可以写入 react 代码,并将其渲染到右侧

image.png

至此,我们掌握的这些就可以串起来了,先用 ai 区别出需求讨论或是组件生成,若是需求讨论,则沟通细节。若是组件生成,则将代码置入我们的 Sandpack 组件中,进行实时预览,若是不满意则再进行调整。

不过在此过程中还有个问题,前端接收到 api 返回后,如何判断此次请求应该渲染为正常对话还是组件预览

如果能直接拿到整条消息,那倒是可以尝试去解析格式之类的,但要注意,对话是流式输出,我们不能在最后才进行判断。

这里我的解法是在 dify 平台返回值时,在最前端增加了一个 @component 的标识,如果 steam 读取到的第一个 token@component,则走生成组件的路径。

这样一来,整体的逻辑就完全打通了。

平台实现

平台的技术栈选用了 nextjs,因为必须要请求 dify 的 api,我们需要一个后端来做转发工作,否则会触发跨域,所以 nextjs 这种全栈框架就非常合适了。

简单跟 v0 配合,平台整体做了个七七八八,这种前端活就不赘述了,有兴趣的同学可以查看源码。

image.png

我用 vercel 搭建了一个站点,给大家体验一下。因为使用了我的 api,额度不多,随时可能会挂

image.png

体验地址:v0-online-component-builder.vercel.app/

源码:github.com/imoo666/ai-…

有用就给个 star 吧~

未完成部分

这个平台目前还只是个 demo,有很多东西没有填充。未完项还有:

  • 网页卡顿的厉害,需要优化,因为是直接让 ai 生成了,没仔细看源码
  • 链接知识库,读取私有组件库的代码,这一块实际上在另一个项目里跑通了,但是还没接入此项目中
  • 完善保存功能

总之起个抛砖引玉的作用,诸君共勉

Node.js 原生扩展技术深度解析:C++ Addons 与 FFI 完全指南

作者 bhwa2333
2025年6月26日 17:27

1. Node.js 原生扩展概述

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境,它允许你在服务器端运行 JavaScript 代码。虽然 Node.js 自身提供了丰富的模块和功能,但在某些场景下,我们需要更高的性能或使用一些 Node.js 原生不支持的功能。

1.1 为什么需要原生扩展

  • 性能优化:将计算密集型任务(如图像处理、加密算法)转移到原生代码层
  • 系统交互:直接调用操作系统 API(如文件系统、网络套接字、硬件设备)
  • 集成现有库:复用已有的 C/C++ 库(如 OpenCV、FFmpeg、数学计算库)
  • 底层访问:操作内存、指针等 JavaScript 无法直接处理的资源

1.2 技术架构与开发方式

技术架构层次:

应用层:JavaScript 业务代码
    ↓
工具层:FFI 工具 (Koffi、node-ffi 等)
    ↓
基础层:C++ Addons 技术框架
    ↓
系统层:操作系统 API、动态链接库

开发方式选择:

所有原生扩展的底层都基于 C++ Addons 技术,但开发者有两种不同的实现路径:

  1. 直接开发 C++ Addons

    • 开发者自己编写 C++ 代码,使用 N-API 或 V8 API
    • 适合:复杂业务逻辑、性能极致优化、深度定制需求
  2. 使用 FFI 工具

    • 使用现成的 FFI 库(这些库本身就是用 C++ Addons 开发的)
    • 通过 FFI 工具调用现有的动态链接库
    • 代表工具:Koffi、node-ffi
    • 适合:调用现有库、快速原型开发、简单的原生函数调用

2. C++ Addons 基础技术

2.1 核心概念

C++ Addons 是 Node.js 提供的底层技术能力,允许开发者使用 C++ 编写高性能模块,并通过 JavaScript 调用。它本质上是编译后的动态链接库(.node 文件),通过 Node.js 的 require() 直接加载。

2.2 技术基础

  • N-API:Node.js 提供的稳定原生接口,不依赖特定 V8 版本
  • V8 API:直接使用 V8 引擎接口(较老的方式,版本兼容性差)
  • 编译工具node-gyp 跨平台编译工具

2.3 开发流程

  1. 编写 C++ 代码

    • 定义将在 Node.js 中被调用的函数
    • 实现具体的业务逻辑
    • 处理 JavaScript 与 C++ 之间的类型转换
  2. 配置编译

    • 创建 binding.gyp 配置文件
    • 配置源文件、依赖库、编译选项
  3. 编译模块

    • 使用 node-gyp 工具编译代码
    • 生成 .node 文件
  4. 加载使用

    • 使用 require() 函数加载编译后的模块

2.4 实际应用示例

示例:数学计算模块

// math_addon.cc
#include <napi.h>

// 高性能的矩阵乘法函数
Napi::Value MatrixMultiply(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();
    
    // 参数验证和类型转换
    if (info.Length() < 2) {
        Napi::TypeError::New(env, "Expected two arguments").ThrowAsJavaScriptException();
        return env.Null();
    }
    
    // 实现复杂的矩阵运算逻辑
    // ... 高性能的 C++ 计算代码 ...
    
    // 返回结果
    return Napi::Number::New(env, result);
}

// 模块初始化
Napi::Object Init(Napi::Env env, Napi::Object exports) {
    exports.Set(Napi::String::New(env, "matrixMultiply"), 
                Napi::Function::New(env, MatrixMultiply));
    return exports;
}

NODE_API_MODULE(math_addon, Init)

在 Node.js 中使用:

const mathAddon = require('./build/Release/math_addon');

const result = mathAddon.matrixMultiply(matrix1, matrix2);
console.log('计算结果:', result);

2.5 优势与挑战

优势:

  • 性能最高:完全原生代码执行,无额外开销
  • 功能无限制:可编写任意复杂的 C++ 逻辑
  • 深度集成:与 Node.js 运行时深度集成
  • 内存控制:精确控制内存分配和释放

挑战:

  • 开发门槛高:需要掌握 C++ 和 Node.js 原生 API
  • 编译复杂:需要配置编译环境,处理平台差异
  • 维护成本高:需要适配不同 Node.js 版本和操作系统
  • 开发周期长:相比纯 JavaScript 开发周期更长

3. FFI 工具层

3.1 FFI 概念与原理

FFI(Foreign Function Interface)是一种编程模式,允许在一种编程语言中调用另一种语言编写的函数。在 Node.js 中,FFI 特指从 JavaScript 代码调用 C/C++ 动态链接库的技术。

FFI 基本原理:

  • 动态库加载:运行时加载 .dll.so.dylib 文件
  • 函数符号解析:通过函数名查找库中的函数地址
  • 类型转换:JavaScript 与 C 类型之间的自动转换
  • 调用封装:将原生函数调用封装为 JavaScript 函数

3.2 主要 FFI 工具对比

特性 node-ffi Koffi
开发状态 维护较少 活跃开发
性能 中等 优秀
API 设计 较复杂 简洁现代
类型系统 基础 完善
Node.js 兼容 版本兼容问题 广泛兼容
社区支持 逐渐减少 快速增长

3.3 Koffi 详解

3.3.1 基本概念

Koffi 是一个现代化的 Node.js FFI 工具。重要概念澄清

  • Koffi 本身就是一个用 C++ Addons 技术开发的原生模块
  • 它为开发者提供了简洁易用的 API 来调用其他动态链接库
  • 使用 Koffi 的开发者不需要直接编写 C++ 代码,但底层仍然依赖 C++ Addons 技术

Koffi 的本质:它是一个"FFI 引擎",把复杂的 C++ Addon 开发工作完成了一遍,然后为 JavaScript 开发者提供简单易用的接口来调用任意的 C/C++ 动态库。

3.3.2 Koffi 实现的核心功能

1. 动态库加载与管理
// 跨平台动态库加载
const libm = koffi.load('libm.so.6');    // Linux
const msvcrt = koffi.load('msvcrt.dll');  // Windows
const libSystem = koffi.load('libSystem.dylib'); // macOS

实现功能:

  • 统一的跨平台动态库加载接口
  • 自动处理不同操作系统的库文件格式差异
  • 库符号管理和函数地址解析
2. 类型系统与自动转换
// 基本类型映射
const sin = lib.func('double', 'sin', ['double']);  // 返回double,参数double

// 复杂类型支持
const strcpy = lib.func('char*', 'strcpy', ['char*', 'char*']);

实现功能:

  • JavaScript ↔ C 类型的双向自动转换
  • 支持基本类型:int, double, char*, void*
  • 支持复杂类型:数组、指针、结构体
  • 自动类型验证和错误提示
3. 结构体(Struct)支持
// 定义和使用 C 结构体
const Point = koffi.struct('Point', {
    x: 'double',
    y: 'double',
    name: 'char*'
});

// 创建和使用结构体实例
const point = new Point({ x: 10.5, y: 20.3, name: 'origin' });
console.log(point.x, point.y); // 访问结构体成员

实现功能:

  • 动态创建 C 结构体的 JavaScript 包装
  • 自动处理内存布局和字节对齐
  • 支持嵌套结构体和复杂数据结构
4. 回调函数桥接
// JavaScript 函数作为 C 回调
const CallbackType = koffi.pointer('void', ['int']);

// 注册 JavaScript 回调函数
const callback = koffi.register(CallbackType, (value) => {
    console.log('C 代码调用了 JS 函数:', value);
});

// 将 JS 回调传递给 C 函数
nativeFunction(callback);

实现功能:

  • JavaScript 函数 → C 函数指针的转换
  • 回调函数的生命周期管理
  • 支持异步回调和多线程回调
  • 自动处理回调函数的内存管理
5. 内存管理
// 自动内存管理(大多数情况)
const result = someFunction(data); // Koffi 自动处理内存

// 手动内存管理(高级用法)
const buffer = koffi.malloc(1024);  // 分配 1KB 内存
// ... 使用内存进行操作
koffi.free(buffer);  // 手动释放内存

实现功能:

  • 自动内存分配和释放(临时参数转换)
  • 手动内存控制接口(高级场景)
  • 引用计数防止内存泄漏
  • 垃圾回收集成
6. 函数调用优化

Koffi 在函数调用流程上实现了高效的转换机制:

JavaScript 调用 someFunc(arg1, arg2)
    ↓
参数类型转换 (JS Number → C double)
    ↓
调用原生函数 (通过动态获取的函数指针)
    ↓
返回值转换 (C double → JS Number)
    ↓
返回给 JavaScript

实现功能:

  • 最小化函数调用开销
  • 智能参数类型推断
  • 错误处理和异常传播
  • 调用栈优化

3.3.3 Koffi 解决的核心问题

1. 复杂性封装
  • 问题:直接写 C++ Addon 需要处理 V8/N-API 的复杂接口
  • 解决:提供简洁的 JavaScript API,隐藏底层复杂性
2. 类型安全
  • 问题:JavaScript 与 C 类型系统不匹配,容易出错
  • 解决:自动类型转换和验证,减少类型相关的错误
3. 跨平台兼容
  • 问题:不同操作系统的动态库格式和调用约定不同
  • 解决:统一的跨平台接口,自动处理平台差异
4. 开发效率
  • 问题:传统 C++ Addon 开发周期长、调试困难、需要编译环境
  • 解决:即时调用,无需编译步骤,快速原型开发
5. 库集成便利性
  • 问题:集成现有 C/C++ 库需要编写大量胶水代码
  • 解决:直接调用现有动态库,无需修改原有代码

3.3.4 实际应用示例

基于 Koffi 的功能,它特别适合以下场景:

// 1. 调用系统 API
const kernel32 = koffi.load('kernel32.dll');
const GetCurrentProcessId = kernel32.func('uint32', 'GetCurrentProcessId', []);
const pid = GetCurrentProcessId();
console.log('当前进程 ID:', pid);

// 2. 使用数学库进行高精度计算
const libm = koffi.load('libm.so.6');
const pow = libm.func('double', 'pow', ['double', 'double']);
const result = pow(2.5, 3.2); // 比 JavaScript 的 Math.pow 更精确

// 3. 图像处理库集成
const opencv = koffi.load('libopencv.so');
const cvtColor = opencv.func('void', 'cvtColor', ['void*', 'void*', 'int']);
// 直接调用 OpenCV 的图像转换函数

// 4. 硬件设备 SDK 调用
const deviceSDK = koffi.load('device_driver.dll');
const readSensorData = deviceSDK.func('int', 'ReadSensorData', ['void*', 'int']);

**3.3.5 基本使用

const koffi = require('koffi');

// 加载系统数学库
const libm = koffi.load('libm.so.6');  // Linux
// const libm = koffi.load('msvcrt.dll');  // Windows

// 定义函数签名
const sin = libm.func('double', 'sin', ['double']);
const pow = libm.func('double', 'pow', ['double', 'double']);

// 调用原生函数
console.log('sin(π/2) =', sin(Math.PI / 2));  // 输出: 1
console.log('2^3 =', pow(2, 3));              // 输出: 8

3.3.6 高级功能

结构体支持:

// 定义 C 结构体
const Point = koffi.struct('Point', {
    x: 'double',
    y: 'double'
});

// 使用结构体
const point = new Point({ x: 10.5, y: 20.3 

3.3.7 实现原理深入解析

Koffi 是基于 Node.js N-API 构建的 C++ Addon,其核心实现包括:

1. 动态库加载机制

  • Windows: 使用 LoadLibraryGetProcAddress
  • Linux/macOS: 使用 dlopendlsym
  • 跨平台抽象: 统一的库加载接口

2. 类型系统

  • 类型映射: JavaScript ↔ C 类型的双向转换
  • 内存管理: 自动分配临时内存,引用计数防止泄漏
  • 结构体支持: 动态创建 C 结构体的 JavaScript 包装

3. 函数调用流程

JavaScript 调用
    ↓
参数类型转换 (JS → C)
    ↓
调用原生函数 (通过函数指针)
    ↓
返回值转换 (C → JS)
    ↓
返回给 JavaScript

3.3.8 与直接开发 C++ Addon 的对比

维度 直接开发 C++ Addon 使用 Koffi
开发复杂度
性能 最优 优秀
开发速度
适用场景 复杂逻辑、定制需求 调用现有库
维护成本
调试难度 中等

4. 技术选型指南

4.1 选择直接开发 C++ Addon 的场景

推荐使用的情况:

  • 复杂业务逻辑:需要实现复杂的算法或数据结构
  • 性能极致优化:对性能有极高要求,需要最小化开销
  • 深度定制:需要与 Node.js 运行时深度集成
  • 内存精确控制:需要精确管理内存分配和释放
  • 错误处理:需要复杂的错误处理和异常管理

示例场景:

  • 图像/视频处理引擎
  • 机器学习推理引擎
  • 加密算法实现
  • 实时音频处理

4.2 选择 FFI 工具(如 Koffi)的场景

推荐使用的情况:

  • 调用现有库:需要使用已有的 C/C++ 动态库
  • 快速原型:需要快速验证想法或构建原型
  • 简单函数调用:只需要调用几个简单的原生函数
  • 第三方库集成:集成系统库或第三方库

示例场景:

  • 调用系统 API
  • 使用图像处理库(如 OpenCV)
  • 调用数据库驱动
  • 集成硬件 SDK
❌
❌