阅读视图

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

代碼案例:CSS 屬性對照

CSS 屬性對照

內容: CSS 常用屬性與 Flutter Widget 屬性的逐一對照,每個部分都是可直接運行的 Widget demo。

涵蓋 10 個模塊:

模塊 CSS Flutter
盒模型 width/height/padding/margin/border/border-radius Container + BoxDecoration
文字樣式 font-size/color/font-weight/letter-spacing/line-height/text-decoration TextStyle
Flex 佈局 display:flex/flex-direction/justify-content/align-items/flex-wrap/gap/flex:1 Row/Column/Expanded/Wrap
定位 position:relative/absolute/fixed/top/left/z-index Stack + Positioned
尺寸約束 width:100%/max-width/min-width/fit-content SizedBox/ConstrainedBox/IntrinsicWidth
變換 transform:rotate/scale/translate Transform.rotate/scale/translate
顯示隱藏 display:none/visibility:hidden/opacity:0 Visibility/Opacity + 三元表達式
陰影 box-shadow/text-shadow BoxShadow/Shadow
漸變 linear-gradient/radial-gradient LinearGradient/RadialGradient
響應式 @media (max-width: 600px) MediaQuery.of(context).size.width

运行

在线效果可以复制到:dartpad.dev/ 查看,更推荐本地flutter build web调试学习

效果图

image.png

学习代码

import 'package:flutter/material.dart';

// ============================================================
// CSS → Flutter 完整对照手册
// ============================================================

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: Scaffold(body: CssToFlutter()));
  }
}

class CssToFlutter extends StatelessWidget {
  const CssToFlutter({super.key});

  @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: const [
          _BoxModel(),
          _TextStyle(),
          _FlexLayout(),
          _PositionLayout(),
          _SizeConstraint(),
          _Transform(),
          _Visibility(),
          _Shadow(),
          _Gradient(),
          _Responsive(),
        ],
      ),
    );
  }
}

// ============================================================
// 一、盒模型
// CSS: width / height / padding / margin / border / border-radius / background
// ============================================================
class _BoxModel extends StatelessWidget {
  const _BoxModel();

  @override
  Widget build(BuildContext context) {
    return Container(
      // CSS: width: 200px
      width: 200,
      // CSS: height: 200px
      height: 200,
      // CSS: margin: 20px
      margin: const EdgeInsets.all(20),
      // CSS: padding: 16px
      padding: const EdgeInsets.all(16),
      // CSS: padding: 10px 20px         → 上下10 左右20
      // padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20),
      // CSS: padding: 10px 20px 30px 40px → 上右下左
      // padding: const EdgeInsets.fromLTRB(40, 10, 20, 30),
      decoration: BoxDecoration(
        // CSS: background: blue
        color: Colors.blue,
        // CSS: border-radius: 20px
        borderRadius: BorderRadius.circular(20),
        // CSS: border-radius: 10px 20px 30px 40px
        // borderRadius: const BorderRadius.only(
        //   topLeft: Radius.circular(10),
        //   topRight: Radius.circular(20),
        //   bottomRight: Radius.circular(30),
        //   bottomLeft: Radius.circular(40),
        // ),
        // CSS: border: 3px solid yellow
        border: Border.all(width: 3, color: Colors.yellow),
        // CSS: border-top: 3px solid red  → 单边边框
        // border: const Border(top: BorderSide(width: 3, color: Colors.red)),
      ),
      child: const Text('盒模型'),
    );
  }
}

// ============================================================
// 二、文字样式
// CSS: font-size / color / font-weight / font-style / letter-spacing /
//      line-height / text-align / text-decoration / overflow
// ============================================================
class _TextStyle extends StatelessWidget {
  const _TextStyle();

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          '文字样式示例',
          style: const TextStyle(
            // CSS: font-size: 24px
            fontSize: 24,
            // CSS: color: red
            color: Colors.red,
            // CSS: font-weight: bold
            fontWeight: FontWeight.bold,
            // CSS: font-weight: 300
            // fontWeight: FontWeight.w300,
            // CSS: font-style: italic
            fontStyle: FontStyle.italic,
            // CSS: letter-spacing: 2px
            letterSpacing: 2,
            // CSS: line-height: 1.5
            height: 1.5,
            // CSS: text-decoration: underline
            decoration: TextDecoration.underline,
            // CSS: text-decoration: line-through
            // decoration: TextDecoration.lineThrough,
          ),
          // CSS: text-align: center
          textAlign: TextAlign.center,
          // CSS: overflow: hidden; white-space: nowrap; text-overflow: ellipsis
          overflow: TextOverflow.ellipsis,
          maxLines: 1,
        ),

        // CSS: text-transform: uppercase → Flutter 无内置,用 .toUpperCase()
        Text('hello'.toUpperCase()),
      ],
    );
  }
}

// ============================================================
// 三、Flex 布局
// CSS: display:flex / flex-direction / justify-content /
//      align-items / flex-wrap / gap / flex:1
// ============================================================
class _FlexLayout extends StatelessWidget {
  const _FlexLayout();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // CSS: display:flex; flex-direction: row; justify-content: space-between; align-items: center
        Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween, // justify-content
          crossAxisAlignment: CrossAxisAlignment.center, // align-items
          children: const [
            Text('左'),
            Text('中'),
            Text('右'),
          ],
        ),

        // CSS: display:flex; flex-direction: column; align-items: center
        Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: const [
            Text('上'),
            Text('中'),
            Text('下'),
          ],
        ),

        // CSS: flex: 1(占满剩余空间)
        Row(
          children: [
            Expanded(
                child: Container(color: Colors.red, height: 40)), // flex: 1
            Expanded(
                child: Container(color: Colors.blue, height: 40)), // flex: 1
          ],
        ),

        // CSS: flex: 2 / flex: 1(按比例)
        Row(
          children: [
            Expanded(
                flex: 2,
                child: Container(color: Colors.green, height: 40)), // flex: 2
            Expanded(
                flex: 1,
                child: Container(color: Colors.orange, height: 40)), // flex: 1
          ],
        ),

        // CSS: flex-wrap: wrap; gap: 8px
        Wrap(
          spacing: 8, // CSS: column-gap
          runSpacing: 8, // CSS: row-gap
          children: List.generate(
            6,
            (i) => Container(
              width: 80,
              height: 40,
              color: Colors.purple,
              child: Center(child: Text('item $i')),
            ),
          ),
        ),

        // justify-content 对照表:
        // flex-start       → MainAxisAlignment.start
        // flex-end         → MainAxisAlignment.end
        // center           → MainAxisAlignment.center
        // space-between    → MainAxisAlignment.spaceBetween
        // space-around     → MainAxisAlignment.spaceAround
        // space-evenly     → MainAxisAlignment.spaceEvenly

        // align-items 对照表:
        // flex-start       → CrossAxisAlignment.start
        // flex-end         → CrossAxisAlignment.end
        // center           → CrossAxisAlignment.center
        // stretch          → CrossAxisAlignment.stretch
        // baseline         → CrossAxisAlignment.baseline
      ],
    );
  }
}

// ============================================================
// 四、定位
// CSS: position: relative/absolute/fixed、top/left/right/bottom、z-index
// ============================================================
class _PositionLayout extends StatelessWidget {
  const _PositionLayout();

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 200,
      height: 200,
      // CSS: position: relative(父容器)
      child: Stack(
        children: [
          // 普通流中的元素
          Container(color: Colors.grey),

          // CSS: position: absolute; top: 10px; left: 10px; z-index: 1
          const Positioned(
            top: 10,
            left: 10,
            child: Text('左上角'),
          ),

          // CSS: position: absolute; bottom: 0; right: 0
          const Positioned(
            bottom: 0,
            right: 0,
            child: Text('右下角'),
          ),

          // CSS: position: absolute; top:50%; left:50%; transform: translate(-50%,-50%)
          const Positioned.fill(
            child: Align(
              alignment: Alignment.center,
              child: Text('居中'),
            ),
          ),

          // Stack 里越靠后的 child z-index 越高,对应 CSS z-index
        ],
      ),
    );
  }
}

// ============================================================
// 五、尺寸与约束
// CSS: width / height / max-width / min-width / max-height / min-height / box-sizing
// ============================================================
class _SizeConstraint extends StatelessWidget {
  const _SizeConstraint();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // CSS: width: 200px; height: 50px
        const SizedBox(width: 200, height: 50),

        // CSS: width: 100%(撑满父级宽度)
        const SizedBox(width: double.infinity, height: 50),

        // CSS: max-width: 300px; min-width: 100px
        ConstrainedBox(
          constraints: const BoxConstraints(
            minWidth: 100,
            maxWidth: 300,
            minHeight: 40,
            maxHeight: 100,
          ),
          child: Container(color: Colors.teal),
        ),

        // CSS: width: fit-content(包裹内容)
        IntrinsicWidth(
          child:
              Container(color: Colors.amber, child: const Text('fit-content')),
        ),
      ],
    );
  }
}

// ============================================================
// 六、变换
// CSS: transform: rotate / scale / translate
// ============================================================
class _Transform extends StatelessWidget {
  const _Transform();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // CSS: transform: rotate(0.05rad)
        Transform.rotate(
          angle: 0.05,
          child: Container(width: 100, height: 50, color: Colors.blue),
        ),

        // CSS: transform: scale(1.5)
        Transform.scale(
          scale: 1.5,
          child: Container(width: 100, height: 50, color: Colors.green),
        ),

        // CSS: transform: translate(20px, 10px)
        Transform.translate(
          offset: const Offset(20, 10),
          child: Container(width: 100, height: 50, color: Colors.red),
        ),
      ],
    );
  }
}

// ============================================================
// 七、显示与隐藏
// CSS: display: none / visibility: hidden / opacity: 0
// ============================================================
class _Visibility extends StatelessWidget {
  const _Visibility();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // CSS: display: none(不占位,从树中移除)
        Visibility(
          visible: false,
          child: Container(width: 100, height: 50, color: Colors.red),
        ),

        // CSS: visibility: hidden(占位,但不可见)
        Visibility(
          visible: false,
          maintainSize: true,
          maintainAnimation: true,
          maintainState: true,
          child: Container(width: 100, height: 50, color: Colors.red),
        ),

        // CSS: opacity: 0.5
        Opacity(
          opacity: 0.5,
          child: Container(width: 100, height: 50, color: Colors.blue),
        ),

        // 简单显隐:三元表达式(display:none 等价)
        // condition ? MyWidget() : const SizedBox.shrink(),
      ],
    );
  }
}

// ============================================================
// 八、阴影
// CSS: box-shadow / text-shadow
// ============================================================
class _Shadow extends StatelessWidget {
  const _Shadow();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // CSS: box-shadow: 4px 4px 8px rgba(0,0,0,0.3)
        Container(
          width: 100,
          height: 100,
          decoration: BoxDecoration(
            color: Colors.white,
            boxShadow: const [
              BoxShadow(
                color: Color(0x4D000000), // rgba(0,0,0,0.3)
                offset: Offset(4, 4), // x, y
                blurRadius: 8, // blur
                spreadRadius: 0, // spread
              ),
            ],
          ),
        ),

        // CSS: text-shadow: 2px 2px 4px rgba(0,0,0,0.5)
        const Text(
          '文字阴影',
          style: TextStyle(
            shadows: [
              Shadow(
                color: Color(0x80000000),
                offset: Offset(2, 2),
                blurRadius: 4,
              ),
            ],
          ),
        ),
      ],
    );
  }
}

// ============================================================
// 九、渐变
// CSS: background: linear-gradient / radial-gradient
// ============================================================
class _Gradient extends StatelessWidget {
  const _Gradient();

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // CSS: background: linear-gradient(to right, red, blue)
        Container(
          width: 200,
          height: 60,
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.centerLeft, // to left
              end: Alignment.centerRight, // to right
              colors: [Colors.red, Colors.blue],
            ),
          ),
        ),

        // CSS: background: linear-gradient(135deg, red, blue)
        Container(
          width: 200,
          height: 60,
          decoration: const BoxDecoration(
            gradient: LinearGradient(
              begin: Alignment.topLeft,
              end: Alignment.bottomRight,
              colors: [Colors.red, Colors.blue],
            ),
          ),
        ),

        // CSS: background: radial-gradient(circle, red, blue)
        Container(
          width: 200,
          height: 60,
          decoration: const BoxDecoration(
            gradient: RadialGradient(
              colors: [Colors.red, Colors.blue],
            ),
          ),
        ),
      ],
    );
  }
}

// ============================================================
// 十、响应式
// CSS: @media (max-width: 600px)
// ============================================================
class _Responsive extends StatelessWidget {
  const _Responsive();

  @override
  Widget build(BuildContext context) {
    // CSS: @media 等价于读取屏幕尺寸
    final screenWidth = MediaQuery.of(context).size.width;
    final screenHeight = MediaQuery.of(context).size.height;
    final isSmall = screenWidth < 600;

    return Column(
      children: [
        // CSS: @media (max-width: 600px) { font-size: 14px } else { font-size: 20px }
        Text(
          '响应式文字',
          style: TextStyle(fontSize: isSmall ? 14 : 20),
        ),

        // CSS: @media (max-width: 600px) { flex-direction: column }
        isSmall
            ? const Column(children: [Text('A'), Text('B')])
            : const Row(children: [Text('A'), Text('B')]),

        Text('屏幕宽: $screenWidth, 高: $screenHeight'),
      ],
    );
  }
}


pub.dev 常用包 vs npm 生态对照

pub.dev 常用包 vs npm 生态对照

Flutter 的 pub.dev 等价于 npm,pubspec.yaml 等价于 package.json


一、包管理命令对照

npm / yarn Flutter 说明
npm install flutter pub get 安装所有依赖
npm install axios flutter pub add dio 添加单个包
npm uninstall axios flutter pub remove dio 移除包
npm update flutter pub upgrade 升级所有包
npm outdated flutter pub outdated 查看过期包
package.json pubspec.yaml 依赖配置文件
package-lock.json pubspec.lock 锁定版本文件
node_modules/ .pub-cache/ 本地缓存目录
npm run build flutter build apk / ios / web 构建
npm start flutter run 开发运行
npx dart run 执行 Dart 脚本

二、网络请求

npm 包 pub.dev 包 说明
axios dio HTTP 客户端,拦截器、取消请求、上传进度
fetch(原生) http 官方轻量 HTTP 包
socket.io-client web_socket_channel WebSocket
graphql graphql_flutter GraphQL 客户端
dependencies:
  dio: ^5.4.0
  http: ^1.2.0
  web_socket_channel: ^2.4.0

三、状态管理

npm 包 pub.dev 包 说明
React Context(内置) provider 官方推荐,基于 InheritedWidget
zustand riverpod 现代状态管理,类型安全
redux flutter_bloc Redux 思想,Bloc 模式
mobx mobx + flutter_mobx 响应式状态管理
jotai riverpod(atom) 原子化状态
valtio get GetX,简单轻量
dependencies:
  provider: ^6.1.0         # React Context 等价
  flutter_riverpod: ^2.5.0  # Zustand 等价(推荐)
  flutter_bloc: ^8.1.0      # Redux 等价

四、路由导航

npm 包 pub.dev 包 说明
react-router-dom go_router 声明式路由(官方推荐)
react-router-dom(v5) auto_route 代码生成路由
Navigator 1.0 内置 Navigator 命令式路由(无需额外包)
dependencies:
  go_router: ^13.0.0

五、UI 组件库

npm 包 pub.dev 包 说明
antd / MUI Flutter 内置 Material 官方 Material Design
antd / MUI flutter_cupertino iOS 风格组件(内置)
第三方 UI 库 flutter_ui_kit 社区 UI 组件
图表:recharts fl_chart 折线图、柱状图、饼图
图表:echarts syncfusion_flutter_charts 功能更全的图表库
轮播:swiper carousel_slider 图片轮播
瀑布流:自定义 flutter_staggered_grid_view 瀑布流/不等高网格
下拉刷新 pull_to_refresh 下拉刷新 + 上拉加载
骨架屏 shimmer Loading 骨架屏效果
dependencies:
  fl_chart: ^0.66.0
  carousel_slider: ^4.2.1
  flutter_staggered_grid_view: ^0.7.0
  shimmer: ^3.0.0

六、本地存储

npm 包 pub.dev 包 说明
localStorage shared_preferences K-V 存储,等价于 localStorage
IndexedDB / localforage hive 轻量高性能本地数据库
sqlite sqflite SQLite 数据库
cookies flutter_secure_storage 安全存储(加密,存 Token)
redux-persist hydrated_bloc 状态持久化
dependencies:
  shared_preferences: ^2.2.0    # localStorage 等价
  flutter_secure_storage: ^9.0.0 # 安全存储 Token
  hive: ^2.2.3                   # 本地数据库
  hive_flutter: ^1.1.0
  sqflite: ^2.3.0                # SQLite

七、图片处理

npm 包 pub.dev 包 说明
react-lazyload cached_network_image 网络图片缓存 + 占位符
react-dropzone image_picker 选择图片/相机拍照
compressorjs flutter_image_compress 图片压缩
SVG(内置) flutter_svg SVG 图片支持
lottie-react lottie Lottie 动画
dependencies:
  cached_network_image: ^3.3.0
  image_picker: ^1.0.0
  flutter_svg: ^2.0.0
  lottie: ^3.0.0

八、工具类

npm 包 pub.dev 包 说明
dayjs / date-fns intl 日期格式化
uuid uuid 生成 UUID
lodash Dart 内置(collection 包) 数组/对象工具
classnames 无(直接条件判断) -
crypto-js crypto 加密(Dart 内置)
js-cookie flutter_secure_storage Cookie / Token 存储
i18next flutter_localizations + intl 国际化 i18n
react-i18next easy_localization i18n(更易用)
@sentry/react sentry_flutter 错误监控
react-query flutter_query / riverpod 服务端状态管理
dependencies:
  intl: ^0.19.0
  uuid: ^4.3.0
  easy_localization: ^3.0.0
  sentry_flutter: ^7.0.0

flutter:
  generate: true   # 开启 i18n 代码生成

九、权限与设备

场景 pub.dev 包 说明
权限请求(相机/位置/通知) permission_handler 统一权限管理
获取 GPS 位置 geolocator 地理位置
设备信息 device_info_plus 设备型号、系统版本
网络状态 connectivity_plus 检测网络连接
本地通知 flutter_local_notifications 本地推送
分享 share_plus 系统分享
打开链接 url_launcher 打开浏览器/拨号
剪贴板 内置 Clipboard 复制粘贴
dependencies:
  permission_handler: ^11.0.0
  geolocator: ^11.0.0
  connectivity_plus: ^6.0.0
  url_launcher: ^6.2.0
  share_plus: ^7.2.0

十、开发工具(devDependencies)

npm 包 pub.dev 包 说明
eslint flutter_lints 代码规范检查
prettier dart format(内置) 代码格式化
jest flutter_test(内置) 单元测试
@testing-library/react flutter_test(内置) Widget 测试
husky - Git hooks(需手动配置)
json-server json_serializable JSON 模型代码生成
typescript Dart(强类型内置) 类型系统(无需额外安装)
dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0
  json_serializable: ^6.7.0   # fromJson/toJson 代码自动生成
  build_runner: ^2.4.0        # 代码生成工具

十一、pubspec.yaml 完整示例

name: my_app
description: My Flutter App
version: 1.0.0+1

environment:
  sdk: ^3.0.0

dependencies:
  flutter:
    sdk: flutter

  # 网络
  dio: ^5.4.0
  cached_network_image: ^3.3.0

  # 状态管理
  provider: ^6.1.0

  # 路由
  go_router: ^13.0.0

  # 存储
  shared_preferences: ^2.2.0
  flutter_secure_storage: ^9.0.0

  # 工具
  intl: ^0.19.0
  uuid: ^4.3.0

  # UI
  fl_chart: ^0.66.0
  lottie: ^3.0.0
  flutter_svg: ^2.0.0

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0
  build_runner: ^2.4.0
  json_serializable: ^6.7.0

flutter:
  uses-material-design: true
  assets:
    - assets/images/
    - assets/animations/
  fonts:
    - family: PingFang
      fonts:
        - asset: assets/fonts/PingFang.ttf

十二、版本约束规则

# 等价于 npm 的 semver
dio: ^5.4.0   # ^:兼容版本(5.x.x),等价于 npm ^
dio: ~5.4.0   # ~:补丁版本(5.4.x),等价于 npm ~
dio: 5.4.0    # 精确版本,等价于 npm 5.4.0
dio: ">=5.0.0 <6.0.0"  # 范围
dio: any      # 任意版本(不推荐)

Flutter ThemeData 主题系统

Flutter ThemeData 主题系统

等价于 CSS Variables / Tailwind 主题 / styled-components ThemeProvider


一、核心对照

CSS / React Flutter 说明
--primary-color: #6200EE colorScheme.primary 主色
--background: #FFFFFF colorScheme.surface 背景色
--text-color: #000000 colorScheme.onSurface 文字色
--error-color: #B00020 colorScheme.error 错误色
CSS Variables 全局继承 Theme.of(context) 读取 任何子组件可读
:root { --color: red } MaterialApp(theme: ThemeData(...)) 全局定义
var(--primary-color) Theme.of(context).colorScheme.primary 使用主题色
@media (prefers-color-scheme: dark) darkTheme: ThemeData(...) 暗黑模式
styled-components ThemeProvider MaterialApp(theme: ...) 主题注入

二、定义全局主题

MaterialApp(
  theme: ThemeData(
    // Material 3 推荐:从种子色自动生成整套配色
    // 等价于 Tailwind 的 primary 色系自动生成 50-900
    colorScheme: ColorScheme.fromSeed(
      seedColor: const Color(0xFF6200EE),   // 主色种子
      brightness: Brightness.light,
    ),
    useMaterial3: true,

    // 也可以手动指定每个颜色角色
    // colorScheme: const ColorScheme.light(
    //   primary: Color(0xFF6200EE),        // 主色
    //   secondary: Color(0xFF03DAC6),      // 次要色
    //   surface: Color(0xFFFFFFFF),        // 背景
    //   error: Color(0xFFB00020),          // 错误色
    //   onPrimary: Colors.white,           // 主色上的文字
    //   onSurface: Color(0xFF000000),      // 背景上的文字
    // ),
  ),

  // 暗黑模式主题
  darkTheme: ThemeData(
    colorScheme: ColorScheme.fromSeed(
      seedColor: const Color(0xFF6200EE),
      brightness: Brightness.dark,         // 切换为深色
    ),
    useMaterial3: true,
  ),

  // 跟随系统 / 强制亮色 / 强制暗色
  themeMode: ThemeMode.system,   // ThemeMode.light / ThemeMode.dark
)

三、ColorScheme 颜色角色

Material 3 设计系统,每个颜色都有对应的「在其上的文字色」

颜色角色 用途 等价 CSS Variable
primary 主操作、重要按钮 --color-primary
onPrimary primary 上的文字/图标 --color-primary-text
secondary 次要操作 --color-secondary
surface 卡片、底部栏背景 --color-surface
onSurface surface 上的文字 --color-on-surface
background 页面背景(M3 已废弃,用 surface) --color-background
error 错误提示 --color-error
onError error 上的文字 --color-error-text
inversePrimary AppBar 上的背景色 -
outline 边框、分隔线 --color-border
// 在任意子组件中读取主题色
// 等价于 CSS: color: var(--color-primary)
final colorScheme = Theme.of(context).colorScheme;

Container(color: colorScheme.primary)
Text('标题', style: TextStyle(color: colorScheme.onSurface))

四、TextTheme 文字样式系统

等价于 Tailwind 的 text-xs / text-sm / text-base / text-lg ...

// 全局定义
ThemeData(
  textTheme: const TextTheme(
    displayLarge:   TextStyle(fontSize: 57, fontWeight: FontWeight.w400),
    displayMedium:  TextStyle(fontSize: 45),
    displaySmall:   TextStyle(fontSize: 36),
    headlineLarge:  TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
    headlineMedium: TextStyle(fontSize: 28),
    headlineSmall:  TextStyle(fontSize: 24),
    titleLarge:     TextStyle(fontSize: 22, fontWeight: FontWeight.w500),
    titleMedium:    TextStyle(fontSize: 16),
    titleSmall:     TextStyle(fontSize: 14),
    bodyLarge:      TextStyle(fontSize: 16),
    bodyMedium:     TextStyle(fontSize: 14),  // 默认正文
    bodySmall:      TextStyle(fontSize: 12),
    labelLarge:     TextStyle(fontSize: 14, fontWeight: FontWeight.bold),  // 按钮文字
    labelMedium:    TextStyle(fontSize: 12),
    labelSmall:     TextStyle(fontSize: 11),
  ),
)

// 使用
Text('标题', style: Theme.of(context).textTheme.headlineMedium)
Text('正文', style: Theme.of(context).textTheme.bodyMedium)

// 等价于:
// <h2 className="text-2xl font-bold">标题</h2>
// <p className="text-sm">正文</p>

五、组件主题定制

等价于 CSS 组件样式覆盖 / styled-components

ThemeData(
  // 按钮全局样式(等价于 .btn { border-radius: 8px })
  elevatedButtonTheme: ElevatedButtonThemeData(
    style: ElevatedButton.styleFrom(
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
      padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
    ),
  ),

  // 输入框全局样式
  inputDecorationTheme: const InputDecorationTheme(
    border: OutlineInputBorder(),
    contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  ),

  // AppBar 全局样式
  appBarTheme: const AppBarTheme(
    elevation: 0,
    centerTitle: true,
  ),

  // Card 全局样式
  cardTheme: CardTheme(
    elevation: 2,
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  ),

  // 全局字体
  fontFamily: 'Roboto',
)

六、局部覆盖主题

// 局部覆盖(等价于 CSS scope / styled-components 局部样式)
Theme(
  data: Theme.of(context).copyWith(
    colorScheme: Theme.of(context).colorScheme.copyWith(
      primary: Colors.red,   // 只改这个子树的主色
    ),
  ),
  child: ElevatedButton(
    onPressed: () {},
    child: const Text('红色按钮'),
  ),
)

七、暗黑模式适配

// 读取当前亮/暗模式
final isDark = Theme.of(context).brightness == Brightness.dark;

// 根据模式选色(等价于 CSS prefers-color-scheme)
Container(
  color: isDark ? Colors.grey[900] : Colors.white,
)

// 推荐:直接用 colorScheme,自动适配亮暗
// colorScheme.surface 在亮色主题是白色,暗色主题是深灰
Container(
  color: Theme.of(context).colorScheme.surface,
)

八、自定义颜色扩展

// 扩展 ColorScheme,添加自定义颜色(等价于扩展 CSS Variables)
@immutable
class AppColors extends ThemeExtension<AppColors> {
  final Color success;
  final Color warning;

  const AppColors({required this.success, required this.warning});

  @override
  AppColors copyWith({Color? success, Color? warning}) => AppColors(
        success: success ?? this.success,
        warning: warning ?? this.warning,
      );

  @override
  AppColors lerp(AppColors other, double t) => AppColors(
        success: Color.lerp(success, other.success, t)!,
        warning: Color.lerp(warning, other.warning, t)!,
      );
}

// 注册
ThemeData(
  extensions: const [
    AppColors(success: Color(0xFF4CAF50), warning: Color(0xFFFFC107)),
  ],
)

// 使用
final appColors = Theme.of(context).extension<AppColors>()!;
Icon(Icons.check, color: appColors.success)

九、完整主题配置示例

ThemeData get lightTheme => ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6200EE)),
  fontFamily: 'PingFang SC',
  textTheme: const TextTheme(
    titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
    bodyMedium: TextStyle(fontSize: 14, height: 1.5),
  ),
  elevatedButtonTheme: ElevatedButtonThemeData(
    style: ElevatedButton.styleFrom(
      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
    ),
  ),
  appBarTheme: const AppBarTheme(elevation: 0, centerTitle: true),
  cardTheme: CardTheme(
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
  ),
);

ThemeData get darkTheme => lightTheme.copyWith(
  colorScheme: ColorScheme.fromSeed(
    seedColor: const Color(0xFF6200EE),
    brightness: Brightness.dark,
  ),
);

十、主题速查

Theme.of(context).colorScheme.primaryvar(--color-primary)
Theme.of(context).colorScheme.surfacevar(--color-background)
Theme.of(context).colorScheme.errorvar(--color-error)
Theme.of(context).textTheme.headlineMedium.text-2xl.font-bold
Theme.of(context).textTheme.bodyMedium.text-sm(默认正文)
Theme.of(context).brightness                  → prefers-color-scheme

React Hooks → Flutter 等价写法

React Hooks → Flutter 等价写法

适合 React 开发者快速上手 Flutter,直接从你熟悉的 Hooks 映射


总览对照表

React Hook Flutter 等价 说明
useState State 字段 + setState 响应式状态
useEffect(() => {}, []) initState 挂载时执行一次
useEffect(() => {}, [dep]) didUpdateWidget 依赖变化时执行
useEffect(() => { return () => {} }) dispose 卸载时清理
useRef State 字段(不调用 setState) 不触发重建的引用
useMemo const Widget / 手动缓存字段 缓存计算结果
useCallback State 类方法(天然稳定) 缓存函数引用
useContext InheritedWidget / Provider.of 跨层级读取数据
useReducer Bloc / 手写 reducer 复杂状态管理
useLayoutEffect addPostFrameCallback 渲染后同步执行
forwardRef GlobalKey 父组件访问子组件
React.memo const Widget 避免不必要重建
lazy + Suspense FutureBuilder 异步加载
createPortal Overlay 渲染到根层

一、useState → State 字段 + setState

// React
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [user, setUser] = useState(null);

setCount(c => c + 1);  // 函数式更新
// Flutter
class _MyState extends State<MyWidget> {
  int count = 0;
  String name = '';
  User? user;          // null 用 ? 表示可空

  void increment() {
    setState(() {
      count += 1;      // 直接修改,无需函数式更新
    });
  }
}

差异:Flutter 直接修改字段值,不需要 prev => prev + 1 这种函数式更新。 setState 的作用只是通知框架重建,不负责计算新值。


二、useEffect → initState / didUpdateWidget / dispose

2.1 挂载时执行一次(依赖数组为空 []

// React
useEffect(() => {
  fetchData();
  startTimer();
}, []);
// Flutter
@override
void initState() {
  super.initState();
  fetchData();
  startTimer();
}

2.2 依赖变化时执行(依赖数组有值 [dep]

// React
useEffect(() => {
  fetchUser(userId);
}, [userId]);
// Flutter
@override
void didUpdateWidget(covariant MyWidget oldWidget) {
  super.didUpdateWidget(oldWidget);
  // 对比新旧 widget 属性,手动判断是否变化
  if (oldWidget.userId != widget.userId) {
    fetchUser(widget.userId);
  }
}

2.3 卸载时清理(return 清理函数)

// React
useEffect(() => {
  final sub = stream.listen(handler);
  return () => sub.cancel();  // 清理
}, []);
// Flutter
StreamSubscription? _sub;

@override
void initState() {
  super.initState();
  _sub = stream.listen(handler);
}

@override
void dispose() {
  _sub?.cancel();  // 清理
  super.dispose();
}

2.4 每次 render 后执行(无依赖数组)

// React
useEffect(() => {
  console.log('每次渲染后执行');
});
// Flutter —— 不推荐,但可以在 build 末尾用 addPostFrameCallback
@override
Widget build(BuildContext context) {
  WidgetsBinding.instance.addPostFrameCallback((_) {
    // 每次 build 完成后执行
  });
  return ...;
}

三、useRef → 普通字段(不触发重建)

// React
const timerRef = useRef(null);
const inputRef = useRef(null);

timerRef.current = setTimeout(...);
inputRef.current.focus();
// Flutter
// 普通字段就是 useRef,修改它不触发 setState 就不会重建
Timer? _timer;
final FocusNode _focusNode = FocusNode();

// 访问 Widget 实例(等价于 inputRef.current)
final GlobalKey _key = GlobalKey();

规律:React 需要 useRef 是因为函数组件每次 render 都会重新声明变量。 Flutter 的 State 类只创建一次,普通字段天然就是"ref"。


四、useMemo → const Widget / 缓存字段

// React
const expensiveValue = useMemo(() => {
  return heavyCalculation(data);
}, [data]);

const element = useMemo(() => <ExpensiveComponent />, []);
// Flutter

// 缓存计算结果:手动在 initState 或 didUpdateWidget 里计算并存字段
List<Item> _sorted = [];

@override
void initState() {
  super.initState();
  _sorted = [...widget.items]..sort();  // 只算一次
}

// 缓存 Widget:用 const,编译期复用,父 build 重建不影响它
const ExpensiveWidget()   // ✅ 等价于 useMemo(() => <Component />, [])

五、useCallback → State 类方法(天然稳定)

// React —— 每次 render 函数引用会变,需要 useCallback 稳定引用
const handleClick = useCallback(() => {
  setCount(c => c + 1);
}, []);
// Flutter —— 方法定义在 State 类里,引用天然稳定,不需要任何处理
void _handleClick() {
  setState(() => count++);
}

// 使用
TextButton(onPressed: _handleClick, child: Text('点击'))

差异:Flutter 完全不存在这个问题,State 类方法不会在每次 build 时重新创建。


六、useContext → Provider.of / context.read

// React
const theme = useContext(ThemeContext);
const { user, setUser } = useContext(UserContext);
// Flutter(以 Provider 包为例,对应 React Context)

// 读取并订阅(数据变化时重建,等价于 useContext)
final theme = context.watch<ThemeModel>();

// 只读取一次,不订阅(等价于 useRef 读 context,不触发重建)
final user = context.read<UserModel>();

// 调用方法
context.read<UserModel>().logout();

七、useReducer → Bloc / 手写 reducer

// React
const reducer = (state, action) => {
  switch (action.type) {
    case 'increment': return { ...state, count: state.count + 1 };
    case 'reset':     return { count: 0 };
  }
};
const [state, dispatch] = useReducer(reducer, { count: 0 });
dispatch({ type: 'increment' });
// Flutter —— 手写 reducer(轻量场景)
class CountState {
  final int count;
  const CountState(this.count);
}

CountState reducer(CountState state, String action) {
  switch (action) {
    case 'increment': return CountState(state.count + 1);
    case 'reset':     return const CountState(0);
    default:          return state;
  }
}

class _MyState extends State<MyWidget> {
  CountState _state = const CountState(0);

  void dispatch(String action) {
    setState(() => _state = reducer(_state, action));
  }
}

八、useLayoutEffect → addPostFrameCallback

// React —— DOM 更新后同步执行,获取元素尺寸
useLayoutEffect(() => {
  const rect = ref.current.getBoundingClientRect();
  setHeight(rect.height);
}, []);
// Flutter —— 帧渲染完成后执行,获取 Widget 尺寸
@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    final box = _key.currentContext?.findRenderObject() as RenderBox?;
    final size = box?.size;
    setState(() => _height = size?.height ?? 0);
  });
}

九、React.memo → const Widget

// React —— 父组件重渲染时,props 没变就跳过
const Child = React.memo(({ title }) => <div>{title}</div>);
// Flutter —— 方式1:const(最简单,适合静态内容)
const MyWidget()   // 编译期确定,父 build 重建时直接复用

// Flutter —— 方式2:不依赖外部变化的子 Widget 天然不重建
// Flutter 的 diff 算法:类型相同 + key 相同 = 复用 State,不重新创建

十、整体对比:组件生命周期

React 函数组件                    Flutter StatefulWidget
─────────────────────────────────────────────────────────
渲染阶段:
  function Component()         ←→   Widget build(context)

挂载:
  useEffect(() => {}, [])      ←→   initState()

依赖更新:
  useEffect(() => {}, [dep])   ←→   didUpdateWidget()

Context 变化:
  useContext 自动订阅           ←→   didChangeDependencies()

卸载:
  useEffect(() => () => {})    ←→   dispose()

每帧渲染后:
  useLayoutEffect              ←→   addPostFrameCallback

十一、常见陷阱对比

场景 React 陷阱 Flutter 等价陷阱
闭包捕获旧值 useEffect 依赖数组漏写 didUpdateWidget 忘记判断新旧值
内存泄漏 忘记清理订阅 dispose 里忘记 cancel/dispose
死循环 useEffectsetState 未加条件 build 里直接调用 setState
异步 setState 组件卸载后 setState dispose 后调用 setState(会报错)
不必要重建 未用 memo / useCallback 未用 const / RepaintBoundary

CSS → Flutter 对照手册

CSS → Flutter 对照手册

核心思维转变:CSS 是给元素加属性,Flutter 是用 Widget 嵌套实现布局


一、盒模型

CSS Flutter 说明
width / height Container(width, height) / SizedBox 固定尺寸
max-width / min-width ConstrainedBox(constraints) 尺寸约束
padding: 16px EdgeInsets.all(16) 四边相同
padding: 10px 20px EdgeInsets.symmetric(vertical:10, horizontal:20) 上下/左右
padding: 10px 20px 30px 40px EdgeInsets.fromLTRB(40, 10, 20, 30) 上右下左
margin Container(margin: ...) 外边距
background: blue Container(color: Colors.blue) 纯色背景
border-radius: 20px BorderRadius.circular(20) 圆角
border-radius: 10px 20px 30px 40px BorderRadius.only(topLeft: ..., ...) 单独圆角
border: 3px solid yellow Border.all(width:3, color:Colors.yellow) 边框
border-top: 3px solid red Border(top: BorderSide(width:3, color:Colors.red)) 单边边框
box-shadow BoxDecoration(boxShadow: [BoxShadow(...)]) 阴影
overflow: hidden ClipRRect / ClipOval 裁剪溢出
width: fit-content IntrinsicWidth 包裹内容宽度
width: 100% SizedBox(width: double.infinity) / Expanded 撑满父级

⚠️ Containercolordecoration 互斥,用了 decoration 就把颜色写进 BoxDecoration(color:...)


二、Flex 布局

CSS Flutter 说明
display: flex; flex-direction: row Row 横向排列
display: flex; flex-direction: column Column 纵向排列
justify-content mainAxisAlignment 主轴对齐
align-items crossAxisAlignment 交叉轴对齐
flex: 1 Expanded 占满剩余空间
flex: 2 (比例) Expanded(flex: 2) 按比例分配
flex-grow 不强制填满 Flexible 弹性但不强制
flex-wrap: wrap Wrap 自动换行
gap / column-gap Wrap(spacing: 8) 横向间距
row-gap Wrap(runSpacing: 8) 纵向间距
align-self Align 包裹单个子项 单独控制对齐

justify-content 对照

CSS Flutter
flex-start MainAxisAlignment.start
flex-end MainAxisAlignment.end
center MainAxisAlignment.center
space-between MainAxisAlignment.spaceBetween
space-around MainAxisAlignment.spaceAround
space-evenly MainAxisAlignment.spaceEvenly

align-items 对照

CSS Flutter
flex-start CrossAxisAlignment.start
flex-end CrossAxisAlignment.end
center CrossAxisAlignment.center
stretch CrossAxisAlignment.stretch
baseline CrossAxisAlignment.baseline

三、定位

CSS Flutter 说明
position: relative(父容器) Stack 绝对定位容器
position: absolute; top/left/right/bottom Positioned(top, left, right, bottom) 绝对定位子项
position: absolute; top:50%; transform:translate(-50%,-50%) Positioned.fill + Align(Alignment.center) 绝对居中
z-index Stack 中越靠后的 child 层级越高 层叠顺序
position: sticky SliverPersistentHeader(pinned: true) 滚动置顶
position: fixed ScaffoldfloatingActionButton / bottomNavigationBar 固定在屏幕

四、文字样式

CSS Flutter 说明
font-size: 20px TextStyle(fontSize: 20) 字号
color: red TextStyle(color: Colors.red) 颜色
font-weight: bold TextStyle(fontWeight: FontWeight.bold) 粗体
font-weight: 300 TextStyle(fontWeight: FontWeight.w300) 字重
font-style: italic TextStyle(fontStyle: FontStyle.italic) 斜体
letter-spacing: 2px TextStyle(letterSpacing: 2) 字间距
line-height: 1.5 TextStyle(height: 1.5) 行高
text-decoration: underline TextStyle(decoration: TextDecoration.underline) 下划线
text-decoration: line-through TextStyle(decoration: TextDecoration.lineThrough) 删除线
text-align: center Text(textAlign: TextAlign.center) 对齐
overflow: ellipsis Text(overflow: TextOverflow.ellipsis, maxLines: 1) 超出省略
text-transform: uppercase text.toUpperCase() 大写
text-shadow TextStyle(shadows: [Shadow(...)]) 文字阴影

五、背景与装饰

CSS Flutter 说明
background: blue BoxDecoration(color: Colors.blue) 纯色背景
background: linear-gradient(to right, red, blue) LinearGradient(begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [...]) 线性渐变
background: linear-gradient(135deg, red, blue) LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [...]) 对角渐变
background: radial-gradient(circle, red, blue) RadialGradient(colors: [...]) 径向渐变
background-image: url(...) DecorationImage(image: AssetImage(...)) 背景图
background-size: cover DecorationImage(fit: BoxFit.cover) 背景图填充

六、变换

CSS Flutter 说明
transform: rotate(0.05rad) Transform.rotate(angle: 0.05) 旋转(弧度)
transform: rotate(45deg) Transform.rotate(angle: 45 * pi / 180) 旋转(角度转弧度)
transform: scale(1.5) Transform.scale(scale: 1.5) 缩放
transform: translate(20px, 10px) Transform.translate(offset: Offset(20, 10)) 平移
transform-origin Transform(alignment: Alignment.topLeft, ...) 变换原点

七、显示与隐藏

CSS Flutter 说明
display: none(不占位) Visibility(visible: false) / 三元返回 SizedBox.shrink() 移除占位
visibility: hidden(占位) Visibility(visible: false, maintainSize: true, maintainState: true, maintainAnimation: true) 保留占位
opacity: 0.5 Opacity(opacity: 0.5) 透明度
pointer-events: none IgnorePointer / AbsorbPointer 禁用触摸

八、滚动

CSS Flutter 说明
overflow: scroll(单个子项) SingleChildScrollView 单子滚动
overflow: scroll(列表) ListView 列表滚动
overflow-x: scroll SingleChildScrollView(scrollDirection: Axis.horizontal) 横向滚动
scroll-snap PageView 分页滑动
虚拟列表 ListView.builder 懒加载长列表
网格滚动 GridView 网格列表
混合滚动 CustomScrollView + Sliver* 复杂滚动

九、响应式

CSS Flutter 说明
@media (max-width: 600px) MediaQuery.of(context).size.width < 600 屏幕宽度判断
vw / vh MediaQuery.of(context).size.width/height 视口尺寸
grid-template-columns: repeat(auto-fill, minmax(150px,1fr)) LayoutBuilder + 动态 crossAxisCount 自适应列数
父级约束响应 LayoutBuilder(builder: (context, constraints) {...}) 获取父级约束

十、经典布局速查

布局名 CSS 方案 Flutter 方案
圣杯布局 float / flex Column + Row + Expanded
双飞翼布局 margin Row + Expanded(主内容优先)
粘性底部 min-height: 100vh + margin-top: auto Column + Expanded
水平垂直居中 flex + justify/align: center Center / Align
瀑布流 column-count / JS 计算 双列 Column 交叉填充
底部导航 固定定位 BottomNavigationBar + IndexedStack
侧边抽屉 transform: translateX Drawer
折叠头部 position: sticky + JS SliverAppBar
主从布局 @media 切换 MediaQuery + 条件渲染
固定头尾中间滚动 flex-column + overflow: auto Column + Expanded + ListView

十一、最大的思维差异

CSS 思维 Flutter 思维
一个元素加多个属性 多个 Widget 嵌套组合
样式可以全局继承 样式靠 ThemeData 统一管理
父级影响子级布局 父 Widget 向子 Widget 传递约束(constraints)
z-index 控制层级 Stack 中越靠后的 child 层级越高
媒体查询响应式 MediaQuery.of(context).size 获取屏幕尺寸
display: none 隐藏元素 三元表达式直接移除 Widget
全局 class 复用样式 封装成独立 Widget 复用
CSS 变量 var(--color) Theme.of(context).colorScheme.primary
伪元素 ::before / ::after Stack + Positioned 叠加 Widget
transition 过渡动画 AnimatedContainer / AnimatedOpacity 等 Animated* 系列

十二、Widget 选择决策树

需要样式装饰(背景/圆角/边框/阴影)?
  └─ 是 → BoxDecoration(放在 Container 或 DecoratedBox 里)

需要布局子元素?
  ├─ 横向 → Row
  ├─ 纵向 → Column
  ├─ 叠加 → Stack
  └─ 网格 → GridView

需要滚动?
  ├─ 单个内容 → SingleChildScrollView
  ├─ 列表 → ListView.builder
  └─ 复杂滚动(折叠头/置顶) → CustomScrollView + Sliver*

只需固定尺寸,不需要装饰?
  └─ SizedBox(比 Container 更轻量)

只需内边距?
  └─ Padding(比 Container 更语义化)

只需对齐?
  └─ Align / Center

前端如何处理订单状态导航的数据竞态问题

业务场景

订单列表页面通常会有状态导航:

全部 / 待付款 / 待发货 / 已完成

用户每切换一次状态,前端就会请求一次订单列表:

axios.get("/api/orders", {
  params: { status: "pendingPay" }
});

如果用户快速切换状态,就可能同时存在多个还没完成的请求:

点击 全部       -> 请求 A
点击 待付款     -> 请求 B
点击 待发货     -> 请求 C

请求 B 先返回   -> 页面显示待付款订单
请求 C 后返回   -> 页面显示待发货订单
请求 A 最后返回 -> 页面又被覆盖成全部订单

最终页面就会出现错误:导航高亮在“待发货”,列表内容却是“全部订单”。

这就是订单状态导航里的数据竞态问题。

竞态的本质

JS 是单线程的,但接口请求不是一个一个等着返回的。前端发起多个请求后,请求会交给浏览器或运行环境处理,哪个请求先完成,哪个请求的回调就先进入任务队列。

所以竞态的本质不是“多个 JS 线程同时修改数据”,而是:

多个异步结果都会更新同一份状态,但它们的完成顺序不可控,旧结果可能后返回并覆盖新结果。

在订单列表里,这份被竞争更新的状态通常是:

const state = {
  activeStatus: "pendingShip",
  orders: [],
  loading: false,
  error: null
};

要解决这个问题,目标很明确:

只有当前选中的订单状态对应的最新请求,才允许更新 ordersloadingerror

错误写法

下面这种写法很常见,但它有竞态风险:

async function loadOrders(status) {
  setActiveStatus(status);
  setLoading(true);
  setError(null);

  try {
    const response = await axios.get("/api/orders", {
      params: { status }
    });

    setOrders(response.data);
  } catch (error) {
    setError(error);
  } finally {
    setLoading(false);
  }
}

问题在于:只要请求返回,就直接更新列表,没有判断这次返回的数据是否还属于当前状态。

比如用户先点“全部”,马上又点“待发货”。如果“全部”的请求最后返回,它仍然会执行:

setOrders(response.data);

这样就会把“待发货”的列表覆盖成“全部订单”。

Axios 推荐处理方式

Axios 现在推荐使用 AbortControllersignal 取消请求。根据 Axios 官方文档,Axios 从 v0.22.0 开始支持 AbortController,旧的 CancelToken 已经不建议在新项目里使用。

完整写法如下:

let controller = null;
let requestSeq = 0;
let latestRequestSeq = 0;

async function loadOrders(status) {
  const requestId = ++requestSeq;
  latestRequestSeq = requestId;

  controller?.abort();
  controller = new AbortController();

  setActiveStatus(status);
  setLoading(true);
  setError(null);

  try {
    const response = await axios.get("/api/orders", {
      params: { status },
      signal: controller.signal
    });

    if (requestId !== latestRequestSeq) {
      return;
    }

    setOrders(response.data);
  } catch (error) {
    if (axios.isCancel(error) || error.name === "CanceledError") {
      return;
    }

    if (requestId === latestRequestSeq) {
      setError(error);
    }
  } finally {
    if (requestId === latestRequestSeq) {
      setLoading(false);
    }
  }
}

这段代码用了两层保护:

  1. AbortController:切换状态时取消上一次 Axios 请求。
  2. requestId:即使旧请求没有被真正取消,返回后也不能更新页面。

代码逐段解释

1. 保存当前请求控制器

let controller = null;

controller 用来保存上一次请求的 AbortController

每次切换订单状态时,先取消上一次请求:

controller?.abort();

然后为这一次新请求创建新的控制器:

controller = new AbortController();

再把它的 signal 传给 Axios:

axios.get("/api/orders", {
  params: { status },
  signal: controller.signal
});

这样当下一次状态切换发生时,就可以取消当前这次请求。

2. 给每次请求生成编号

let requestSeq = 0;
let latestRequestSeq = 0;

requestSeq 是全局递增的请求编号。

latestRequestSeq 记录当前最新请求的编号。

每次调用 loadOrders 时:

const requestId = ++requestSeq;
latestRequestSeq = requestId;

等价于:

requestSeq = requestSeq + 1;
const requestId = requestSeq;
latestRequestSeq = requestId;

假设用户快速点击三次:

第 1 次:全部   requestId = 1,latestRequestSeq = 1
第 2 次:待付款 requestId = 2,latestRequestSeq = 2
第 3 次:待发货 requestId = 3,latestRequestSeq = 3

如果第 1 次请求最后才返回,它自己的 requestId 还是 1,但最新请求已经是 3

if (requestId !== latestRequestSeq) {
  return;
}

判断不通过,说明这是旧请求,不能更新订单列表。

3. 只让最新请求更新列表

if (requestId !== latestRequestSeq) {
  return;
}

setOrders(response.data);

这段是防止竞态的核心。

它保证了:

旧请求返回 -> 直接 return
最新请求返回 -> setOrders

所以即使网络请求乱序返回,页面最终也只会展示当前订单状态对应的数据。

4. catch 里也要判断

catch 不只是接口 500 才会进入。下面这些情况都会进入 catch

  • 请求被 controller.abort() 取消。
  • 用户断网或网络异常。
  • 请求超时。
  • HTTP 状态码不是 2xx,例如 401、403、404、500。
  • Axios 拦截器主动 Promise.reject

取消请求不是业务错误,所以直接忽略:

if (axios.isCancel(error) || error.name === "CanceledError") {
  return;
}

真正的错误也要判断是不是最新请求:

if (requestId === latestRequestSeq) {
  setError(error);
}

否则旧请求失败了,可能会把当前页面误改成错误状态。

5. finally 里也要判断

很多人只保护 setOrders,但忘了保护 setLoading(false)

错误写法:

finally {
  setLoading(false);
}

如果旧请求先失败或先结束,它会提前把 loading 关掉。此时最新请求可能还在加载中,页面状态就不准确。

所以应该写成:

finally {
  if (requestId === latestRequestSeq) {
    setLoading(false);
  }
}

也就是说,orderserrorloading 都只能由最新请求更新。

为什么只用 AbortController 还不够

理论上,切换状态时取消上一个请求已经能解决大部分问题:

controller?.abort();

但实际项目里仍然建议保留 requestId 判断,原因有三点:

  1. 不是所有异步任务都能取消。
  2. 某些请求封装、缓存层、拦截器逻辑可能仍然会返回结果。
  3. 后续代码可能不只请求接口,还会包含异步格式化、延迟处理、数据合并等逻辑。

所以更稳的策略是:

能取消的,先取消。
取消不了的,返回后也不能更新 UI。

这就是 AbortController + requestId 组合使用的意义。

CancelToken 还要用吗

旧项目里可能会看到 Axios 的 CancelToken

const source = axios.CancelToken.source();

axios.get("/api/orders", {
  cancelToken: source.token
});

source.cancel();

这个写法现在不建议新项目优先使用。新项目更推荐:

const controller = new AbortController();

axios.get("/api/orders", {
  signal: controller.signal
});

controller.abort();

如果是维护老项目,可以先看项目里的 Axios 版本和封装方式。如果已经支持 signal,可以逐步迁移到 AbortController

小结

使用 Axios 时,推荐方案是:

AbortController 取消旧请求
+ requestId 判断最新请求
+ loading/error/orders 都做保护

这样才能保证:用户当前选中什么状态,页面最终就展示什么状态的订单数据。

鸿蒙 MVVM 实战:从 Demo 到工程化,聊聊登录、状态管理与埋点系统设计

鸿蒙 MVVM 实战:从 Demo 到工程化,聊聊登录、状态管理与埋点系统设计

本文基于 HarmonyOS Next(ArkTS / API 12+)实际开发经验总结,代码示例来自个人练习 Demo,架构思路来自真实项目沉淀。


前言

入职鸿蒙开发组已经一周多了,每天在看公司项目代码的同时,我会同步自己写一个 Demo 来验证理解。今天把这段时间的几个核心技术点整理成文章,主要涵盖三块:

  1. MVVM 分层架构:如何在鸿蒙里做好 Component / ViewModel / Controller / Biz / Imp 的职责划分
  2. 状态管理AppStorageV2PersistenceV2 的区别及正确使用姿势
  3. 埋点系统设计:如何封装一个可维护的 TrackingHelper,处理好生命周期和幂等上报

一、MVVM 分层架构实践

1.1 为什么需要分层?

鸿蒙里很容易把所有逻辑塞进 @ComponentV2 里,但这样做的问题是:组件变得又重又难测试,改一个业务逻辑要翻遍整个 UI 文件。

我的 Demo 里采用了和公司项目相似的五层结构:

Component(UI 展示 + 用户交互)
    ↓ 调用
Controller(流程编排:参数校验、状态流转、异常处理)
    ↓ 调用
Biz(业务逻辑:响应码判断、数据聚合)
    ↓ 调用
Imp(接口实现:网络请求、本地存储)
    ↓
外部系统(网络、数据库)

每一层只向下依赖,UI 不直接调 Biz,Controller 不直接写 UI

1.2 登录流程示例

以登录为例,看看每一层在做什么:

LoginPage.ets(Component 层)

@HMRouter({ pageUrl: 'pages/Login' })
@ComponentV2
export struct Login {
  @Local vm: AuthViewModel = new AuthViewModel()
  @Local controller: AuthController = new AuthController(this.vm)

  aboutToAppear(): void {
    // 路由守卫:已登录直接跳首页
    if (hasToken()) {
      HMUtil.replace({ navigationId: 'MainNavigation', pageUrl: 'pages/Home' })
    }
  }

  build() {
    Column({ space: 20 }) {
      TextInput({ placeholder: '请输入账户', text: this.vm.userName })
        .onChange((val) => { this.vm.userName = val })

      Button('登录').onClick(() => { this.controller.login() })
    }
  }
}

组件只做两件事:绑定状态、抛出事件vm.userName 的变化自动触发 UI 刷新,点击登录只是调 Controller 方法,不关心具体逻辑。

AuthController.ets(Controller 层)

export class AuthController {
  private biz: AuthBiz = new AuthBiz()
  private vm: AuthViewModel

  constructor(vm: AuthViewModel) {
    this.vm = vm
  }

  login(): void {
    if (!this.vm.userName || !this.vm.password) return

    this.vm.isLoading = true
    this.biz.login({ userName: this.vm.userName, password: this.vm.password })
      .then((result) => {
        if (!result) return
        saveAuth(result.token, result.userName, result.userId)
        HMUtil.replace({ navigationId: 'MainNavigation', pageUrl: 'pages/Home' })
      })
      .finally(() => { this.vm.isLoading = false })
  }
}

Controller 负责:参数校验 → 调 Biz → 处理结果 → 更新 ViewModel → 路由跳转。这些逻辑放在 Controller 里,UI 和 Biz 都不需要感知。

1.3 聊天发送流程

聊天消息发送的流程稍复杂,涉及 loading 气泡和打字机效果:

sendMessage(): void {
  const content = this.vm.inputContent.trim()
  if (!content) return

  // 1. 用户消息立即入队展示
  const userMsg = new ChatMessage()
  userMsg.role = 'user'
  userMsg.content = content
  this.vm.historyMessage.push(userMsg)
  this.vm.inputContent = ''

  // 2. 显示 AI loading 气泡
  this.vm.isLoading = true

  // 3. 调 Biz 获取响应
  this.biz.sendMessage(content, this.vm.sessionId)
    .then((result) => {
      if (!result) return
      this.vm.sessionId = result.sessionId
      this.vm.pendingResponse = result.content  // 组件消费后自动清空
    })
    .finally(() => { this.vm.isLoading = false })
}

pendingResponse 是一个"信箱"字段:Controller 写入完整回复文本,ChatTabComp 消费后启动打字机动画,消费完毕清空,防止重复播放。


二、状态管理:AppStorageV2 vs PersistenceV2

这两个 API 很像,但使用场景完全不同,我一开始也分不清楚。

2.1 AppStorageV2 —— 运行时全局共享

@ObservedV2
export class AppTabState {
  @Trace currentIndex: number = 0
}

// 全局单例:任何页面 connect 同一个 key,拿到的是同一个对象
export const tabState: AppTabState =
  AppStorageV2.connect(AppTabState, 'AppTabState', () => new AppTabState())!

特点:

  • 生命周期随应用进程,App 重启后重置为初始值
  • 适合:Tab 选中态、当前用户信息(已在内存中)、全局 UI 状态
  • 任何组件 AppStorageV2.connect 同一个 key,拿到的是同一个实例,修改会自动同步到所有订阅方

HomePage 里这样用:

@Local tabState: AppTabState =
  AppStorageV2.connect(AppTabState, 'AppTabState', () => new AppTabState())!

// Tabs onChange 时修改 @Trace 属性,所有订阅方自动刷新
.onChange((index) => { this.tabState.currentIndex = index })

2.2 PersistenceV2 —— 持久化存储

@ObservedV2
export class AuthPersist {
  @Trace token: string = ''
  @Trace userName: string = ''
  @Trace userId: string = ''
}

export function saveAuth(token: string, userName: string, userId: string): void {
  const persist = PersistenceV2.connect(AuthPersist, 'auth_persist', () => new AuthPersist())!
  persist.token = token
  persist.userName = userName
  persist.userId = userId
}

export function hasToken(): boolean {
  return PersistenceV2.connect(AuthPersist, 'auth_persist', () => new AuthPersist())!.token.length > 0
}

特点:

  • App 重启后数据依然存在,底层写入本地存储
  • 适合:登录 Token、用户偏好设置、离线缓存
  • 首次 connect 时使用工厂函数创建并持久化,后续直接读磁盘快照

选择原则:进程结束后还需要的数据用 PersistenceV2,仅运行时共享的数据用 AppStorageV2


三、埋点系统设计:封装 TrackingHelper

埋点是业务开发里经常被忽视但又很重要的一块。本节基于通用架构思路,分享如何设计一个可维护的埋点帮助类。

3.1 核心设计目标

  • 语义化接口:调用方只调 onSend()onReplyComplete() 这样的场景方法,不感知字段细节
  • 自包含状态:所有埋点状态(计数、计时、标志位)封装在 TrackingHelper 内部
  • 幂等上报:防止前后台切换、组件销毁、stop 按钮三条路径对同一次 Reply 重复上报
  • 可替换 sink:通过构造时传入的 sink 回调桥接到具体上报 SDK,解耦上报逻辑

3.2 结构概览

export type TrackSink = (event: TrackEvent) => void

export class TrackingHelper {
  // 计数(窗口内累计,前后台切换时重置)
  private sendCount: number = 0
  private replyCount: number = 0
  private normalSendCount: number = 0
  private normalReplyCount: number = 0

  // 会话计时
  private enterTime: number = 0
  private lastSendTime: number = 0

  // 幂等标志
  private replyReported: boolean = false
  private replyStarted: boolean = false  // 是否收到首字 delta

  private isBackground: boolean = false
  private sink: TrackSink

  constructor(sink: TrackSink) {
    this.sink = sink
  }

  // 场景方法:页面打开成功
  onOpenSuccess(pageSource: string): void { ... }

  // 场景方法:用户发送消息
  onSend(msg: string, isPreset: boolean, isVoice: boolean): void { ... }

  // 场景方法:收到首字 delta(标记 bot 已开始回答)
  onReplyStart(): void { this.replyStarted = true }

  // 场景方法:正常回复完成
  onReplyComplete(msg: string, tabName: string, firstDeltaTime: number): boolean { ... }

  // 场景方法:前台→后台
  onBackground(loading: boolean): void { ... }

  // 场景方法:组件销毁
  onDispose(loading: boolean): void { ... }
}

3.3 核心难点:pending reply 补报

最棘手的是这个场景:用户发送消息后(onSend 已上报),在 AI 回复过程中切后台或退出页面。此时 AiAgentReplyMsg 还没上报,如果直接上报 Close 就会有"发送有记录,回复没记录"的数据断层。

处理方式:

onBackground(loading: boolean): void {
  if (this.isBackground) return
  this.isBackground = true

  // 补报:loading 中 + 已收到首字 + 未上报 Reply → 补一条 EXCEPTION 类型
  this.flushPendingReplyIfNeeded(loading)
  this.reportClose()
  this.resetCounters()

  // 防止 in-flight 请求返回后再次计数
  this.replyReported = true
}

private flushPendingReplyIfNeeded(loading: boolean): void {
  if (loading && !this.replyReported && this.lastSendTime > 0 && this.replyStarted) {
    this.replyCount++
    this.normalSendCount++
    this.replyReported = true
    // 上报 EXCEPTION 类型的 AiAgentReplyMsg
    this.emit(this.buildReplyEvent(this.currentMessage, AgentReplyType.EXCEPTION))
  }
}

replyStarted 的作用是区分两种 loading 状态:

  • loading = truereplyStarted = false:请求已发出但 AI 还没开始回答 → 不补报(相当于发送失败,或 AI 尚未响应)
  • loading = truereplyStarted = true:AI 已经开始打字,回答被中断 → 补报 EXCEPTION

这样数据口径更准确:normalReplyCount 只统计完整正常完成的回答,中断的回答只计入 replyCount

3.4 发送类型区分

发送消息有三种入口,tabType 字段区分:

onSend(msg: string, isPreset: boolean, isVoice: boolean): void {
  this.sendCount++
  this.lastSendTime = Date.now()
  this.replyReported = false
  this.replyStarted = false

  const event = new TrackEvent('AiAgentSendMsg')
  if (isVoice) {
    event.tabType = 'VOICE'
  } else {
    event.tabType = isPreset ? 'CLICK_BUBBLE' : 'MANUAL'
  }
  event.searchKey = msg
  this.emit(event)
}

3.5 Close 事件携带统计数据

页面关闭时上报一次会话级统计:

private reportClose(): void {
  const event = new TrackEvent('AiAgentClose')
  event.duration = Date.now() - this.enterTime   // 页面停留时长(ms)
  event.sendCount = this.sendCount               // 本次窗口总发送次数
  event.replyCount = this.replyCount             // 总回复次数(含异常)
  event.normalSendCount = this.normalSendCount   // 有正常或中断回复的发送次数
  event.normalReplyCount = this.normalReplyCount // 完整正常回复次数
  this.emit(event)
}

四个计数字段的统计口径:

字段 含义
sendCount 用户发送的总次数
replyCount AI 有任何回复(含异常)的次数
normalSendCount AI 至少开始回答的发送次数
normalReplyCount AI 完整正常完成回答的次数

四、路由守卫:用 aboutToAppear 实现登录拦截

鸿蒙 HMRouter 没有全局路由守卫,但可以在 aboutToAppear 里做页面级拦截:

@HMRouter({ pageUrl: 'pages/Login' })
@ComponentV2
export struct Login {
  aboutToAppear(): void {
    if (hasToken()) {
      HMUtil.replace({
        navigationId: 'MainNavigation',
        pageUrl: 'pages/Home'
      })
    }
  }
}

replace 而不是 push,这样首页不会出现在路由栈里,用户无法从首页"返回"到登录页。

类似地,已登录用户直接访问登录页也会被自动跳转走。这是最简单的路由守卫实现,适合 Demo 级别的项目。生产项目通常会在路由拦截器层统一处理。


五、总结

今天练习 Demo 和完成埋点需求让我对几个知识点的理解更扎实了:

  1. MVVM 分层的价值不在于规范,而在于每层职责清晰后,改一处不会动到其他层,排查问题也只需要在对应层找。

  2. AppStorageV2 vs PersistenceV2 的选择标准很简单:App 重启后还需要的用 Persistence,不需要的用 AppStorage。

  3. 埋点系统容易被当成"随手上报"来处理,但一旦涉及生命周期(前后台切换、组件销毁)和幂等性,就需要专门设计。TrackingHelper 这种封装方式很值得借鉴:调用方只关心语义,所有状态管理在内部消化。

  4. 路由守卫在鸿蒙里依靠 aboutToAppear + replace 实现页面级拦截,简单有效。


如果你也在学鸿蒙开发,欢迎交流,一起填坑 🛠️

代码仓库:个人练习 Demo(MyApplication),基于 HarmonyOS Next API 12,DevEco Studio 5.x

AI生成代码快如闪电,但我修了三个小时——它到底帮了谁?

上周,老板丢过来一个内部后台页面的需求,说“不急,明天给就行”。我打开 v0,输入“帮我生成一个用户管理后台,包含表格、筛选、分页、编辑弹窗”。一分钟,页面出来了。表格、按钮、弹窗、样式,一应俱全。我复制代码到项目里,跑起来,看起来没问题。然后接下来三个小时,我都在改它的代码。

一、“神速”生成,然后呢?

v0 生成的速度确实快。但当我开始真正“使用”这份代码时,问题一个接一个冒出来:

  • 变量名随心所欲dataitemsitemDatafilteredData……一个表格数据,四五个名字来回用。改一个地方,全局搜索半天。
  • 样式写死:按钮宽度固定 120px,手机上看溢出;表格列宽固定,屏幕一缩就横向滚动。没有响应式,没有移动端适配。
  • 状态管理混乱:筛选条件、分页、弹窗开关全部挤在同一个 useState 里,改一个导致别的组件无辜重渲染。
  • 没有错误处理:接口返回 null,页面直接白屏。没有 loading,没有空状态。

这些不是 bug,是“能用但没法维护”。

二、修了三个小时,改了啥?

我花时间做的事,恰恰是 AI 最不擅长的事:

  1. 重命名所有变量,让代码能读。
  2. 把固定宽度改成 flex + 百分比,加媒体查询。
  3. 拆分状态,用 useReducer 管理筛选和分页。
  4. 补上 loading、错误提示、空数据占位。

说实话,到最后我有点怀疑:如果我自己从头写,可能也就三个半小时。AI 帮我省了半小时,但我额外付出了“读懂它逻辑”的成本。这笔账,算不过来。

三、AI 到底帮了谁?

我知道,会有人说“你不会写 prompt”“你不会调教”。也许更精确的 prompt 能生成更好的代码。但问题是,AI 生成的代码像一辆组装好的车,看起来能跑,但一上路就发现螺丝没拧紧,轮胎是歪的。它不是不能开,而是你需要先花时间检查所有零件。

对于不熟悉项目上下文、不知道团队规范、不了解业务细节的 AI 来说,生成“能跑”的代码已经是极限。而“可维护”的代码,恰恰需要这些信息。

所以,AI 到底帮了谁?帮我省了半小时打字时间?还是帮老板更快地看到“可视的进度”?对于开发者自己,短期“快”的背后,是长期的“改”。

四、我现在怎么用 AI?

我没有放弃 AI,而是调整了用法:

  • 不用 AI 写核心业务逻辑:自己设计状态和接口,用 AI 生成工具函数或数据 mock。
  • 用 AI 写文档和注释:生成 JSDoc、README、测试用例,这些不会坑人。
  • AI 生成代码后,强制做一轮重构:重命名、拆分、补异常,把“能跑”变成“能维护”。

这样一来,AI 节省的时间,不会在改代码时加倍吐回去。

五、最后

AI 写代码快,但不是免费的。你省下的打字时间,很可能变成修代码的时间。这笔账,建议每个团队都算清楚。

v8引擎和libuv的关系

简单来说,V8 负责“执行代码”,而 libuv 负责“调度任务”。在 Node.js 中,它们分工明确,通过精密的 C++ 接口协作,共同实现了 JavaScript 的非阻塞异步特性。

它们的关系可以这样理解:

  • V8 引擎:是 Google 开发的 JavaScript 执行引擎,负责将 JS 代码编译并执行起来。但它只管“执行”,并不提供文件操作、网络请求、定时器等能力。
  • libuv 库:是一个用 C 语言编写的异步 I/O 库,它为 Node.js 提供了事件循环和线程池,并封装了不同操作系统的底层 API。所有“耗时”的操作,如读取文件或发起网络请求,都由它来处理。

下面这张图清晰地展示了两者协作的完整流程:

flowchart TD
    A[User JavaScript Code] --> B[Node.js API]
    
    subgraph V8 [V8 Engine]
        direction LR
        V8_1[Execute JS Code]
        V8_2[Call Stack]
    end
    
    subgraph Libuv [libuv]
        direction LR
        L1[Event Loop]
        L2[Task Queues]
        L3[Thread Pool]
    end
    
    B -- "Delegate async operations (e.g., I/O, timers)" --> L1
    L1 -- "Wait for/Schedule work" --> L3
    L3 -- "Operation completes, callback ready" --> L2
    L1 -- "When stack is empty, pick callback" --> V8_2
    V8_2 -- "Execute callback" --> V8_1
    V8_1 -- "Continue..." --> B

具体来说,它们的分工可以拆解为以下三个步骤:

👑 角色扮演:V8 是“翻译官”,libuv 是“大管家”

  1. V8 引擎(翻译官):它的工作就是执行你写的 JavaScript 代码,比如计算 1+1,或者调用一个函数。当它遇到 setTimeoutfs.readFile 这样的异步操作时,它自己并不会做,而是会立即把这个任务交给 libuv,然后自己接着执行后面的代码。V8 通过一系列 C++ 接口(如 v8::Isolate, v8::Context, v8::Function::Call)与外界沟通,就像一个标准化的“翻译器”,把自己的内部状态暴露给外部程序。

  2. libuv 库(大管家):它接管了 V8 丢过来的所有异步任务。它负责管理一个事件循环,并根据任务的类型(是定时器、网络请求还是文件 I/O)进行调度:

    • 对于简单的任务:比如 setTimeout,libuv 会启动一个计时器,时间到了就将回调函数放入事件队列等待执行。
    • 对于耗时的任务:比如读取一个巨大的文件,libuv 会从自己的线程池(默认4个线程)中拿出一个线程去处理,避免主线程被阻塞。任务完成后,libuv 同样会把回调函数放入事件队列。

⚙️ 协同工作:事件循环如何运作?

V8 和 libuv 协同工作的核心,就是 Node.js 中广为人知的事件循环机制。它的工作流程像一个永不间断的流水线:

  1. 当 V8 执行代码时,如果遇到异步操作,就交给 libuv。
  2. libuv 接过任务去处理(比如等待定时器、读取文件)。
  3. 与此同时,V8 并不会停下来等待,它会继续执行代码中剩下的同步任务
  4. 当 libuv 处理完一个异步任务(比如文件读完了),它就会把相应的回调函数放到一个任务队列中。
  5. 一个叫做事件循环的机制会不断地、一轮又一轮地检查:当前 V8 的“调用栈”是否为空? 如果是空的,它就会从任务队列中取出一个回调函数,交还给 V8 去执行。
  6. V8 执行完这个回调函数后,调用栈再次清空,事件循环便会继续取出下一个回调,如此往复。

小提示:为了保证回调函数能在正确的全局环境下执行,Node.js 在调用回调前,会使用 V8 提供的 v8::Context::Enter 接口来设置正确的执行上下文。

💡 一个简单的例子

当你执行下面这段代码时:

const fs = require('fs');
console.log('1. 开始读取文件');
fs.readFile('./test.txt', (err, data) => {
  console.log('3. 文件读取完成');
});
console.log('2. 继续执行其他代码');
  1. V8 执行 console.log('1. ...'),打印 "1. 开始读取文件"。
  2. V8 执行 fs.readFile,识别出是异步 I/O,立即将其交给 libuv
  3. libuv 接手,从线程池中拿出一个线程去硬盘上读取 test.txt 文件。这个过程是并行的,不会阻塞主线程。
  4. 与此同时,V8 继续执行后面的 console.log('2. ...'),打印 "2. 继续执行其他代码"。
  5. 一段时间后,libuv 的线程完成了文件读取,它将回调函数 () => { console.log('3. ...') } 放入事件队列。
  6. 此时 V8 的调用栈已空,事件循环发现队列中有任务,便将这个回调函数交还给 V8 执行
  7. V8 执行该回调,最终打印出 "3. 文件读取完成"。

V8 引擎和 libuv 是 Node.js 的两个核心底层组件,分工明确、协同工作:V8 负责执行 JavaScript 代码,libuv 负责异步 I/O 和事件循环。

  • V8 引擎(由 Google 开发)是 JavaScript 引擎,将 JS 代码编译为机器码并执行,管理内存、垃圾回收、调用栈等,自身不处理文件/网络等系统 I/O。
  • libuv 是跨平台异步事件驱动库(用 C 写成),封装操作系统底层 I/O(如文件、网络、定时器),提供事件循环(event loop)和线程池,处理非阻塞操作并回调 JS。
  • 二者通过 Node.js 的绑定层(如 C++ Add-ons)连接:当 JS 调用 fs.readFile()http.createServer() 等 API 时,V8 执行 JS,但实际 I/O 交由 libuv 处理,完成后 libuv 将回调推回 V8 的事件循环执行。
  • V8 本身是单线程执行 JS,但可调用 libuv 的线程池(默认 4 线程)跑阻塞型操作(如 DNS、加密);libuv 不依赖 V8,也可被其他语言集成(如 Lua、Rust),但 Node.js 将两者深度绑定以实现“JS 做服务端开发”。

简言之:V8 让 JS 能跑,libuv 让 JS 能不阻塞地与系统交互;没有 libuv,V8 只能跑客户端脚本;没有 V8,libuv 只是个通用异步库。

用 wagmi v2 和 viem 手写 NFT 市场批量上架功能,我踩遍了所有异步坑

背景

上个月我接了个外包项目,做一个基于 ERC-721 的 NFT 交易市场前端。需求很简单:用户可以批量选择自己的 NFT,然后一次签名、批量上架到市场合约。项目用 Next.js 14 + wagmi v2 + viem,钱包连接用 RainbowKit。

我一开始觉得这有啥难的,不就是遍历数组调合约嘛。结果真正动手才发现,批量上架这件事在 Web3 前端里是个典型的“异步地狱”:你不仅要处理交易发送,还要等每个交易确认,同时还要处理用户拒绝签名、交易失败、Gas 不足等各种边界情况。

这篇文章就是我在解决“批量上架 NFT”这个具体问题时的完整踩坑记录。

问题分析

最初的思路:for 循环逐个发交易

我最初的想法很简单:用户选择 n 个 NFT,然后 for 循环里逐个调用合约的 listItem 方法,每次调用都等交易确认后再调用下一个。

// 最初的错误写法
async function batchList(tokenIds: number[]) {
  for (const id of tokenIds) {
    const { hash } = await writeContract({
      address: marketAddress,
      abi: marketABI,
      functionName: 'listItem',
      args: [nftAddress, id, price],
    })
    await waitForTransactionReceipt({ hash })
  }
}

为什么行不通

  1. 用户要签 n 次 MetaMask:每调用一次 writeContract,钱包弹一次签名,用户体验极其糟糕。
  2. 单点失败问题:如果第 3 个 NFT 上架失败,前面的已经上架了,后面的没法继续,状态不一致。
  3. Gas 浪费:每个交易都需要单独支付 Gas,对于批量上架来说成本太高。

后来我查了下 wagmi v2 的文档,发现 writeContract 其实支持批量操作,但需要合约层面支持。我合作的合约用的是 OpenSea 的 Seaport 协议风格,需要一个 bulkListItems 函数。但问题是合约没有这个函数,只能一个个调用。

真正的痛点

我的合约不支持批量函数,但用户又要求“一键批量上架”。这就意味着我必须在前端层面做两件事:

  • 用一个交易完成多个 NFT 的上架
  • 或者用一个签名授权多个操作

核心实现

方案选择:使用 multicall 模式

和合约开发者沟通后,我们决定在合约里加一个 multicall 函数(其实就是 OpenZeppelin 的 Multicall 扩展)。这样前端可以一次性打包多个 listItem 调用,用一个交易发送出去。

合约端的改动不归我管,我只需要前端构造好 calldata 数组传给 multicall 就行。

第一步:构造 multicall 的 calldata

这里有个坑:multicall 接收的参数是 bytes[],即每个子调用的编码数据。我必须用 viem 的 encodeFunctionData 来生成每个 listItem 调用的 calldata。

import { encodeFunctionData } from 'viem'
import { marketABI } from './abis'

// 构造 multicall 的 calldata
function buildMulticallData(tokenIds: number[], price: bigint) {
  return tokenIds.map((id) => {
    // 注意:这里用 encodeFunctionData 生成每个子调用的 calldata
    return encodeFunctionData({
      abi: marketABI,
      functionName: 'listItem',
      args: [nftAddress, id, price],
    })
  })
}

这里有个坑encodeFunctionDataargs 参数必须和合约函数的参数顺序完全一致。我当时把 nftAddressid 的顺序搞反了,结果合约一直报错,排查了半天才发现。

第二步:发送 multicall 交易

用 wagmi 的 useWriteContract hook 发送交易。

import { useWriteContract } from 'wagmi'

function BatchListButton({ tokenIds, price }: { tokenIds: number[], price: bigint }) {
  const { writeContract, isPending } = useWriteContract()

  const handleBatchList = async () => {
    const calldata = buildMulticallData(tokenIds, price)
    
    try {
      const hash = await writeContract({
        address: marketAddress,
        abi: marketABI,
        functionName: 'multicall',
        args: [calldata],
      })
      // hash 返回后,交易已经提交,等待确认
      console.log('交易已发送,hash:', hash)
    } catch (error) {
      console.error('发送失败:', error)
    }
  }

  return (
    <button onClick={handleBatchList} disabled={isPending}>
      {isPending ? '上架中...' : `批量上架 ${tokenIds.length} 个 NFT`}
    </button>
  )
}

注意这个细节writeContract 返回的是一个 Promise,resolve 时得到的是交易 hash,而不是交易确认。这意味着交易已经提交到链上,但还没被挖矿。如果用户此时关闭页面,交易可能失败。

第三步:等待交易确认并处理结果

为了给用户更好的反馈,我需要等待交易确认,然后检查每个子调用是否成功。

wagmi v2 提供了 useWaitForTransactionReceipt hook,但它是声明式的。我需要用命令式的方式等待,所以用了 viem 的 waitForTransactionReceipt

import { createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'

const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(),
})

// 等待交易确认
async function waitForTx(hash: `0x${string}`) {
  const receipt = await publicClient.waitForTransactionReceipt({ hash })
  return receipt
}

这里有个坑waitForTransactionReceipt 默认超时时间是 30 秒,如果网络拥堵,交易可能 30 秒内没确认,就会抛出超时异常。需要设置 timeout 参数。

const receipt = await publicClient.waitForTransactionReceipt({ 
  hash, 
  timeout: 120_000 // 延长到 2 分钟
})

第四步:解析 multicall 的返回值

multicall 函数返回一个 bytes[],每个元素对应子调用的返回值。我需要解析这些返回值来判断每个 NFT 是否上架成功。

import { decodeFunctionResult } from 'viem'

// 解析 multicall 返回值
function parseMulticallResult(results: `0x8`[], tokenIds: number[]) {
  return results.map((result, index) => {
    try {
      // 每个子调用的返回值类型是 bool
      const decoded = decodeFunctionResult({
        abi: marketABI,
        functionName: 'listItem',
        data: result,
      })
      return {
        tokenId: tokenIds[index],
        success: decoded as boolean,
      }
    } catch {
      return {
        tokenId: tokenIds[index],
        success: false,
        error: '解析失败',
      }
    }
  })
}

第五步:完整的批量上架流程

把上面所有步骤组合起来,加上错误处理和用户反馈。

import { useState } from 'react'
import { useWriteContract } from 'wagmi'
import { encodeFunctionData, decodeFunctionResult, createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { marketABI } from './abis'

const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(),
})

export function useBatchList() {
  const { writeContract } = useWriteContract()
  const [status, setStatus] = useState<'idle' | 'signing' | 'pending' | 'success' | 'error'>('idle')
  const [results, setResults] = useState<Array<{ tokenId: number; success: boolean; error?: string }>>([])

  const batchList = async (tokenIds: number[], price: bigint) => {
    setStatus('signing')
    setResults([])

    try {
      // 1. 构造 calldata
      const calldata = tokenIds.map((id) =>
        encodeFunctionData({
          abi: marketABI,
          functionName: 'listItem',
          args: [nftAddress, id, price],
        })
      )

      // 2. 发送交易
      setStatus('pending')
      const hash = await writeContract({
        address: marketAddress,
        abi: marketABI,
        functionName: 'multicall',
        args: [calldata],
      })

      // 3. 等待交易确认
      const receipt = await publicClient.waitForTransactionReceipt({
        hash,
        timeout: 120_000,
      })

      // 4. 解析返回值
      // 注意:multicall 的返回值在 receipt.logs 里,需要解析事件
      // 这里简化处理,实际需要根据合约事件解析
      const parsedResults = tokenIds.map((id) => ({
        tokenId: id,
        success: receipt.status === 'success',
      }))

      setResults(parsedResults)
      setStatus('success')
    } catch (error: any) {
      console.error('批量上架失败:', error)
      setStatus('error')
      // 如果用户拒绝了签名,error.message 包含 "User rejected"
      if (error.message?.includes('User rejected')) {
        alert('你取消了签名')
      } else {
        alert('上架失败,请重试')
      }
    }
  }

  return { batchList, status, results }
}

完整代码

以下是一个完整的 Next.js 页面组件,实现了批量上架 NFT 的功能。

// pages/batch-list.tsx
'use client'
import { useState } from 'react'
import { useAccount, useWriteContract } from 'wagmi'
import { encodeFunctionData, createPublicClient, http } from 'viem'
import { mainnet } from 'viem/chains'
import { marketABI, nftABI } from '@/abis'

const marketAddress = '0x...' // 替换为实际地址
const nftAddress = '0x...'    // 替换为实际地址
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http(),
})

export default function BatchListPage() {
  const { address, isConnected } = useAccount()
  const { writeContract } = useWriteContract()
  const [selectedIds, setSelectedIds] = useState<number[]>([])
  const [price, setPrice] = useState('')
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')

  const handleBatchList = async () => {
    if (!address || selectedIds.length === 0 || !price) return

    setStatus('loading')
    const priceBigInt = BigInt(price) // 注意:价格需要是 wei 单位

    try {
      // 构造 multicall calldata
      const calldata = selectedIds.map((id) =>
        encodeFunctionData({
          abi: marketABI,
          functionName: 'listItem',
          args: [nftAddress, id, priceBigInt],
        })
      )

      // 发送交易
      const hash = await writeContract({
        address: marketAddress,
        abi: marketABI,
        functionName: 'multicall',
        args: [calldata],
      })

      // 等待确认
      await publicClient.waitForTransactionReceipt({
        hash,
        timeout: 120_000,
      })

      setStatus('success')
      alert(`成功上架 ${selectedIds.length} 个 NFT`)
    } catch (error: any) {
      console.error(error)
      setStatus('error')
      if (error.message?.includes('User rejected')) {
        alert('已取消操作')
      } else {
        alert('上架失败,请检查网络和 Gas')
      }
    }
  }

  if (!isConnected) return <div>请先连接钱包</div>

  return (
    <div>
      <h1>批量上架 NFT</h1>
      <input
        type="text"
        placeholder="输入价格 (wei)"
        value={price}
        onChange={(e) => setPrice(e.target.value)}
      />
      <div>
        {/* 这里应该显示用户的 NFT 列表,允许选择 */}
        <p>已选择 {selectedIds.length} 个 NFT</p>
      </div>
      <button
        onClick={handleBatchList}
        disabled={status === 'loading'}
      >
        {status === 'loading' ? '上架中...' : '批量上架'}
      </button>
    </div>
  )
}

踩坑记录

  1. encodeFunctionData 参数顺序错误:我把 nftAddresstokenId 的顺序写反了,导致合约报错 revert。后来用 console.log 打印 calldata 对比合约 ABI 才找到问题。

  2. waitForTransactionReceipt 超时:默认 30 秒超时,在以太坊主网拥堵时经常超时。需要设置 timeout 参数,我改成了 120 秒。

  3. 用户拒绝签名的处理writeContract 如果用户取消签名,会抛出一个错误,但错误对象的格式在不同钱包不同。MetaMask 返回 { code: 4001, message: 'User rejected' },而 WalletConnect 返回的格式不一样。我用了 error.message?.includes('User rejected') 来兼容。

  4. Gas 估算失败:当批量上架的 NFT 数量太多时,Gas 估算可能失败。我加了 try-catch,如果估算失败就提示用户手动设置 Gas。

小结

核心收获:批量操作 Web3 交易时,优先考虑合约层面的 multicall 模式,前端只需要用 encodeFunctionData 构造 calldata 数组。如果合约不支持,可以和合约开发者沟通添加。另外,处理异步交易一定要考虑超时、用户拒绝、网络异常等边界情况。

可以继续深挖的方向:如何用 useSimulateContract 在发送前模拟交易,以及如何用 usePublicClient 替代手动创建 client。

数组扁平化

从入门到精通:JavaScript 数组扁平化的完整指南(含生产级手写实现)

前言

数组扁平化是前端开发中最常用的操作之一,无论是处理后端返回的嵌套数据、树形结构转换,还是进行数据预处理,你几乎每天都会用到它。

但你真的了解 flat() 方法吗?90% 的前端开发者都不知道它的这些细节:

  • 为什么 [1, , 2].flat() 会忽略空位,而 [1, undefined, 2].flat() 会保留 undefined
  • 为什么 flat(Infinity) 能完全展开数组,而 flat('Infinity') 却不行?
  • 为什么原生 flat() 能处理类数组对象,而很多手写实现却不行?

本文将从原生方法的使用讲起,一步步带你写出100% 符合现代 ECMAScript 规范的生产级 flatten 函数,覆盖所有边界情况和性能优化点。

一、原生 Array.prototype.flat 详解

ES2019 引入的 flat() 方法是数组扁平化的标准解决方案,但很多人只知道它的基本用法,却不了解它的完整行为。

1.1 基本用法

// 默认深度为 1,只展开一层
[1, [2, [3, 4], 5]].flat(); // [1, 2, [3, 4], 5]

// 指定深度为 2,展开两层
[1, [2, [3, 4], 5]].flat(2); // [1, 2, 3, 4, 5]

// 使用 Infinity 完全展开任意深度的数组
[1, [2, [3, [4, [5]]]]].flat(Infinity); // [1, 2, 3, 4, 5]

1.2 容易被忽略的重要特性

特性 1:自动忽略数组空位

这是最容易踩坑的点。flat() 会自动跳过数组中的空位(empty slot),但会保留显式赋值的 undefinednull

// 空位会被忽略
[1, , [2, , 3]].flat(); // [1, 2, empty, 3]
[1, , [2, , 3]].flat(2); // [1, 2, 3]

// 显式的 undefined 和 null 会被保留
[1, undefined, null, [2]].flat(); // [1, undefined, null, 2]
特性 2:支持任意类数组对象

flat() 是一个通用方法,它不要求 this 必须是真正的数组,只需要是一个具有 length 属性和整数键的对象:

// 处理 arguments
function test() {
  return Array.prototype.flat.call(arguments);
}
test(1, [2, 3], 4); // [1, 2, 3, 4]

// 处理自定义类数组对象
const arrayLike = {
  0: 1,
  1: [2, [3, 4]],
  2: 5,
  length: 3
};
Array.prototype.flat.call(arrayLike); // [1, 2, 3, 4, 5]

// 处理字符串
Array.prototype.flat.call('hello'); // ['h', 'e', 'l', 'l', 'o']
特性 3:depth 参数的转换规则

flat() 会将 depth 参数强制转换为整数,转换规则非常严格:

// 字符串数字会被转换为数字
[1, [2, [3]]].flat('2'); // [1, 2, 3]

// 小数会被截断
[1, [2, [3]]].flat(2.9); // [1, 2, 3]

// 所有负数和 NaN 都会被转换为 0
[1, [2, [3]]].flat(-1); // [1, [2, [3]]]
[1, [2, [3]]].flat(NaN); // [1, [2, [3]]]

// Infinity 会被保留
[1, [2, [3]]].flat(Infinity); // [1, 2, 3]

二、手写实现:从基础到生产级

了解了原生方法的行为后,我们来一步步实现一个完全符合规范的 flatten 函数。

2.1 基础递归实现(新手版)

这是最直观的实现方式,但存在很多问题:

// ❌ 问题很多的新手版本
function flatten(arr) {
  let result = [];
  for (const item of arr) {
    if (Array.isArray(item)) {
      result = result.concat(flatten(item));
    } else {
      result.push(item);
    }
  }
  return result;
}

存在的问题:

  1. 不支持指定扁平化深度
  2. 错误处理稀疏数组(会将空位转为 undefined
  3. 不支持类数组对象
  4. concat 性能较差
  5. 没有正确处理 depth 参数

2.2 支持指定深度

// ✅ 支持指定深度
function flatten(arr, depth = 1) {
  if (depth <= 0) return arr.slice();
  
  return arr.reduce((prev, curr) => {
    return prev.concat(Array.isArray(curr) ? flatten(curr, depth - 1) : curr);
  }, []);
}

改进点:

  • 添加了 depth 参数,默认值为 1(与原生一致)
  • 使用 reduce 简化了代码

仍然存在的问题:

  • 不支持类数组对象
  • 错误处理稀疏数组
  • concat 性能较差

2.3 处理稀疏数组和类数组对象

这是实现的关键一步,也是最容易出错的地方:

// ✅ 支持类数组对象和稀疏数组
function flatten(input, depth = 1) {
  // 将输入转换为对象,支持类数组
  const O = Object(input);
  
  // 正确转换 depth 参数
  depth = Number(depth);
  if (isNaN(depth)) {
    depth = 0;
  } else if (isFinite(depth)) {
    depth = Math.trunc(depth);
  }
  
  if (depth <= 0) {
    return Array.prototype.slice.call(O);
  }
  
  // 获取有效的 length 属性
  const len = O.length >>> 0;
  const result = [];
  
  // 使用传统 for 循环,通过索引访问
  for (let i = 0; i < len; i++) {
    // 跳过数组空位
    if (!(i in O)) continue;
    
    const item = O[i];
    if (Array.isArray(item)) {
      result.push(...flatten(item, depth - 1));
    } else {
      result.push(item);
    }
  }
  
  return result;
}

关键改进:

  1. 使用 Object(input) 支持类数组对象
  2. 使用 O.length >>> 0 实现规范的 ToLength 操作
  3. 使用 i in O 判断是否为空位,自动跳过
  4. 使用 push + 展开运算符 代替性能较差的 concat

2.4 最终生产级实现

这是经过反复打磨的最终版本,99.9% 的场景下与原生 flat() 行为完全一致

/**
 * 数组扁平化纯函数(符合现代 ECMAScript 规范)
 * @param {any} input - 输入值(数组或任意类数组对象)
 * @param {number} [depth=1] - 扁平化深度
 * @returns {Array} 扁平化后的新数组
 * @throws {TypeError} 当 input 为 null 或 undefined 时抛出
 */
function flatten(input, depth = 1) {
  // 1. 执行规范的 ToObject 操作
  // null/undefined 会自然抛出 TypeError,错误信息与原生完全一致
  const O = Object(input);

  // 2. 严格实现规范的 ToIntegerOrInfinity 操作
  depth = Number(depth);
  
  if (isNaN(depth)) {
    depth = 0;
  } else if (isFinite(depth)) {
    depth = Math.trunc(depth); // 截断小数部分,保留符号
  }
  // Infinity 和 -Infinity 保持原值

  // 3. 深度 ≤ 0 时返回原对象的浅拷贝数组
  if (depth <= 0) {
    return Array.prototype.slice.call(O);
  }

  // 4. 执行规范的 ToLength 操作
  const len = O.length >>> 0;

  // 5. 创建结果数组
  // ES2024+ 规范:直接使用 Array 构造函数,不再使用已弃用的 Symbol.species
  const result = [];

  // 6. 按索引遍历,自动跳过空位
  for (let i = 0; i < len; i++) {
    if (!(i in O)) continue;

    const item = O[i];

    if (Array.isArray(item)) {
      const flattenedItem = flatten(item, depth - 1);
      result.push(...flattenedItem);
    } else {
      result.push(item);
    }
  }

  return result;
}

2.5 全面测试验证

// 核心功能测试
console.log(flatten([1, [2, [3]]])); // [1, 2, [3]] ✅
console.log(flatten([1, [2, [3]]], 2)); // [1, 2, 3] ✅
console.log(flatten([1, [2, [3]]], Infinity)); // [1, 2, 3] ✅
console.log(flatten([1, [2, [3]]], -Infinity)); // [1, [2, [3]]] ✅

// 边界情况测试
console.log(flatten([1, , [2, , 3]])); // [1, 2, empty, 3] ✅
console.log(flatten([1, undefined, null])); // [1, undefined, null] ✅
console.log(flatten([])); // [] ✅
console.log(flatten([[], [[]]])); // [[]] ✅

// 类数组对象测试
const arrayLike = { 0: 1, 1: [2, [3]], length: 2 };
console.log(flatten(arrayLike)); // [1, 2, [3]] ✅
console.log(flatten('hello')); // ['h', 'e', 'l', 'l', 'o'] ✅

三、进阶实现

3.1 迭代实现(避免栈溢出)

递归实现对于超过约 10000 层嵌套的极端数组会抛出栈溢出错误。如果需要处理这种情况,可以使用迭代实现:

/**
 * 迭代版数组扁平化(不会栈溢出)
 * @param {any} input - 输入值
 * @param {number} [depth=1] - 扁平化深度
 * @returns {Array} 扁平化后的新数组
 */
function flattenIterative(input, depth = 1) {
  const O = Object(input);
  depth = Number(depth);
  
  if (isNaN(depth)) {
    depth = 0;
  } else if (isFinite(depth)) {
    depth = Math.trunc(depth);
  }
  
  if (depth <= 0) {
    return Array.prototype.slice.call(O);
  }
  
  const len = O.length >>> 0;
  const stack = [];
  
  // 初始化栈,每个元素是 [value, currentDepth]
  for (let i = len - 1; i >= 0; i--) {
    if (i in O) {
      stack.push([O[i], depth]);
    }
  }
  
  const result = [];
  
  while (stack.length > 0) {
    const [item, currentDepth] = stack.pop();
    
    if (Array.isArray(item) && currentDepth > 0) {
      // 数组元素重新入栈,深度减 1
      for (let i = item.length - 1; i >= 0; i--) {
        if (i in item) {
          stack.push([item[i], currentDepth - 1]);
        }
      }
    } else {
      result.push(item);
    }
  }
  
  return result;
}

3.2 处理循环引用

原生 flat() 遇到循环引用会直接栈溢出。如果需要增强健壮性,可以添加循环引用检测:

/**
 * 支持循环引用检测的数组扁平化
 * @param {any} input - 输入值
 * @param {number} [depth=1] - 扁平化深度
 * @param {WeakSet} [seen] - 内部使用,用于记录已处理的对象
 * @returns {Array} 扁平化后的新数组
 */
function flattenSafe(input, depth = 1, seen = new WeakSet()) {
  const O = Object(input);
  depth = Number(depth);
  
  if (isNaN(depth)) {
    depth = 0;
  } else if (isFinite(depth)) {
    depth = Math.trunc(depth);
  }
  
  if (depth <= 0) {
    return Array.prototype.slice.call(O);
  }
  
  // 检测循环引用
  if (seen.has(O)) {
    return [];
  }
  seen.add(O);
  
  const len = O.length >>> 0;
  const result = [];
  
  for (let i = 0; i < len; i++) {
    if (!(i in O)) continue;
    
    const item = O[i];
    if (Array.isArray(item)) {
      result.push(...flattenSafe(item, depth - 1, seen));
    } else {
      result.push(item);
    }
  }
  
  return result;
}

// 测试循环引用
const a = [1];
a.push(a);
console.log(flattenSafe(a)); // [1]

四、实际应用场景

4.1 树形结构转一维数组

const categories = [
  {
    id: 1,
    name: '电子产品',
    children: [
      { id: 11, name: '手机', children: [{ id: 111, name: '苹果手机' }] },
      { id: 12, name: '电脑' }
    ]
  },
  { id: 2, name: '服装' }
];

// 将树形结构转换为一维数组
function flattenTree(tree) {
  return tree.reduce((prev, curr) => {
    prev.push(curr);
    if (curr.children) {
      prev.push(...flattenTree(curr.children));
    }
    return prev;
  }, []);
}

console.log(flattenTree(categories));
// [{id:1, name:'电子产品'}, {id:11, name:'手机'}, {id:111, name:'苹果手机'}, {id:12, name:'电脑'}, {id:2, name:'服装'}]

4.2 多维数组求和

function sumDeep(arr) {
  return flatten(arr, Infinity).reduce((a, b) => a + b, 0);
}

console.log(sumDeep([1, [2, [3, [4]]]])); // 10

4.3 数组深度去重

function uniqueDeep(arr) {
  return [...new Set(flatten(arr, Infinity))];
}

console.log(uniqueDeep([1, [2, [2, [3, 3]]]])); // [1, 2, 3]

五、性能对比

我们对不同实现方式进行了性能测试(测试环境:Node.js 20,100 万次调用):

实现方式 执行时间 相对性能
原生 flat 120ms 100%
最终递归版 180ms 67%
迭代版 250ms 48%
reduce + 递归 320ms 37%
concat 版 580ms 21%

结论:

  • 原生 flat() 性能最好,优先使用
  • 手写递归版性能接近原生,完全满足生产需求
  • 迭代版性能稍差,但不会栈溢出,适合处理极深嵌套的数组

六、总结

本文详细讲解了 JavaScript 数组扁平化的原理和实现,从原生方法的使用到生产级手写实现,覆盖了所有边界情况和性能优化点。

核心要点回顾:

  1. 原生 flat() 方法默认深度为 1,使用 Infinity 可以完全展开
  2. flat() 会自动忽略数组空位,但保留显式的 undefinednull
  3. flat() 是通用方法,支持任意类数组对象
  4. 手写实现时要注意 depth 参数的正确转换和稀疏数组的处理
  5. 现代 JavaScript 不再推荐使用 Symbol.species,直接返回普通数组即可

希望这篇文章能帮助你彻底掌握数组扁平化,写出更健壮、更高效的代码。如果你有任何问题或建议,欢迎在评论区留言讨论!

参考资料:

PSD2Code 近期更新与深度解析:从设计稿到生产级代码的完整技术栈

引言:为什么我们需要更好的 PSD 转代码工具?

在运营活动、H5页面、电商详情页等场景中,设计稿到前端代码的转换一直是一个痛点。传统工作流下,一个750×6778像素的活动页从PSD到可运行HTML平均需要4-6小时,而psd2code将这个时间压缩到了20秒

最近,我们完成了一系列重要的技术更新和bug修复,并建立了完善的文档系统。特别是针对PSD原生合成语义、光效穿透机制、空组白色污染等核心技术问题的深度解析,让工具更加稳定、智能。本文将带你深入了解psd2code的技术架构、核心算法,以及最新版本的关键改进。

一、完备的文档系统

psd2code拥有完善的文档系统,覆盖从架构概览到具体实现细节的全方位内容:

1.1 架构设计文档

  • 01-architecture/overview.md - 整体分层与模块职责
  • 01-architecture/data-flow.md - 从PSD到HTML的全链路数据流
  • 01-architecture/design-patterns.md - 使用的设计模式分析
  • 01-architecture/directory-layout.md - 目录→模块映射表

1.2 模块详解文档

  • 02-modules/core-*.md - 核心模块(IR/PSD解析/渲染/提取)
  • 02-modules/targets-*.md - 多端输出支持(HTML/React/Vue)
  • 02-modules/semantic.md - 语义化命名系统
  • 02-modules/framework.md - 框架层设计

1.3 关键主题深度解析

  • 03-topics/layout-optimizer.md - 布局优化器算法详解
  • 03-topics/bugfix-2026-06-01.md - 最新bug修复记录
  • 03-topics/empty-pt-group-white-pollution.md - 空PASS_THROUGH组白色污染问题
  • 03-topics/light-blend-penetrate.md - 光效穿透机制
  • 03-topics/group-rendering.md - 组合成策略
  • 03-topics/effects-rendering.md - 效果渲染流水线
  • 03-topics/ir-contract.md - IR作为契约的设计

1.4 扩展开发指南

  • 04-extending/add-a-target.md - 新增一个产物
  • 04-extending/add-a-stage.md - 新增一个处理阶段
  • 04-extending/add-a-layer-handler.md - 新增图层处理器
  • 04-extending/add-an-effect.md - 新增效果渲染器

1.5 开发约定

  • 05-conventions/coding-style.md - 代码风格规范
  • 05-conventions/known-pitfalls.md - 已知坑位与硬约束(必读)
  • 05-conventions/testing-and-validation.md - 测试验证标准流程
  • 05-conventions/ai-handoff.md - AI协作指南

1.6 待办事项

  • 06-todo/ir-typed-upgrade.md - IR类型系统升级计划

文档设计原则

  • 每份文档都有"本文解决什么/不讨论什么"头注
  • 面向"接手维护者 + 协作AI"的双重受众
  • 阅读顺序从上至下,由浅入深
  • 配合实际代码示例,避免理论空谈

二、架构回顾:编译器式分层设计

psd2code采用经典的编译器三段式架构:

PSD → 前端解析 → IR → 后端代码生成 → HTML/React/Vue

核心抽象:

  • IR (Intermediate Representation):基于pydantic的强类型中间表示,作为coretargets之间的严格契约
  • PipelineContext:贯穿所有Stage的全局上下文,承载状态和配置
  • Stage:单一职责的处理步骤,输入输出都是PipelineContext
  • Target:可插拔的产物生成器,通过装饰器注册到全局registry

这种设计让HTML target的能力升级自动惠及React/Vue target,因为它们都是在HTML产物之上的二次加工。

二、近期重要更新(2026年6月1日)

2.1 渐变叠加效果完整支持

问题:PSD中的渐变叠加效果(GradientOverlay)包含多个关键参数(缩放比例、对齐方式、偏移量),之前未完整解析导致效果与Photoshop不一致。

修复

  • 完整解析Scl(缩放比例)、Algn(与图层对齐)、Ofst(偏移)参数
  • 根据"与图层对齐"标志智能选择渐变范围:启用时基于图层尺寸,禁用时基于对角线长度
  • 加入中心点偏移修正,确保渐变位置准确

效果:窄长图层(如18×1864)上的近水平渐变或宽扁图层上的垂直渐变表现完全符合PS

2.2 光效穿透机制的完善

问题:光效穿透(Light Blend Penetrate)机制在处理全光效子组时存在逻辑漏洞,导致黑色污染。

修复

  • 新增_is_fully_suppressed_group()方法,递归判断PASS_THROUGH组内所有可见子层是否都是光效层
  • 优化目标选择逻辑,过滤无效的白色/高亮度目标
  • 新增降级路径:当所有穿透目标都被过滤时,改用CSS mix-blend-mode

效果:解决了web.psd组40/组40拷贝光晕边缘丢失的问题

2.3 调整层与渐变叠加的冲突解决

问题:含有调整层(如曲线、色相饱和度)的剪辑组在处理渐变叠加时,会使用有bug的composite()导致渐变丢失。

修复

  • 新增base_has_overlay检测,识别基础层是否含有渐变叠加
  • 改用ratio-transfer算法:分别获取"仅基础层"和"基础层+调整层"的结果,计算颜色比值
  • 将比值应用到已渲染的渐变效果上

验证:修复前后纵向颜色方差对比

  • 修复前:R=0.2, G=0.3, B=0.7(几乎无渐变)
  • 修复后:R=74.8, G=63.1, B=70.3(渐变完全恢复)

2.4 CSS类名数字开头问题

问题:PSD图层名"18th Anniversary"转换为".18th-anniversary__46",CSS规范不允许类名以数字开头,导致浏览器忽略整条规则。

修复:在_to_kebab()函数末尾增加检查,如果归一化结果以数字开头,自动添加"n"前缀。

2.5 PNG透明边裁剪与CSS同步

问题:PNG透明边裁剪后生成新文件名,但CSS引用未同步更新,导致图片加载404。

修复:将写回条件从pruned_n > 0扩展为pruned_n > 0 or trimmed_n > 0,确保trim操作也能触发CSS更新。

三、核心技术深度解析

3.1 布局优化器:从absolute到Flex的智能重构

LayoutOptimizer是psd2code最核心的功能,它将200+图层的绝对定位代码智能重构为Flex布局,同时保证视觉零偏移

七步流水线

  1. DOM重构:基于聚类算法识别行/列/堆叠结构
  2. 图层扁平化(默认关闭):多image子图层合成单张PNG
  3. 同质兄弟分组:识别平铺的同类卡片,包成v-list
  4. Flex推断:基于趋势检测的智能布局选择
  5. 单子wrapper折叠:消除算法产生的中间层
  6. CSS去冗余:精简z-index,合并等价规则
  7. CSS美化:DOM顺序排序,属性分段展示

聚类算法核心

  • 纵向重叠率≥0.5判定为同行
  • 背景层剥离:完全包含型、主轴覆盖型、双轴主导覆盖型
  • 伪多行装饰堆叠回退机制
  • 二维网格识别:列对齐+跨行对齐的智能处理

实战效果(南瓜大作战H5)

  • CSS行数:4805 → 1499(减少68.8%)
  • CSS块数:457 → 270(减少41.0%)
  • z-index字段:432 → 97(减少77.5%)
  • 6×4任务网格自动识别为v-col+v-row嵌套

3.2 PSD原生合成簇决策:R1-R5硬性规则

compose_cluster.py采用基于PSD硬性合成语义的决策系统,替代历史上的"三道闸门"启发式方法。核心算法通过R1-R5规则识别必须一起合成的图层簇:

规则 触发条件 含义
R1 剪贴蒙版 clipping == 1 剪贴层只能在base的alpha/bbox范围内显示 → 与下方最近的non-clipping base同簇
R2 非NORMAL混合 blend ∉ {NORMAL, DISSOLVE, PASS_THROUGH} OVERLAY / MULTIPLY / SCREEN / LINEAR_DODGE等通过公式修改下层像素 → 必须与下方一起合成
R3 PASS_THROUGH子组+上下文依赖 PT组内含调整层/非NORMAL blend/跨组剪贴 PT组不形成独立合成层,内部依赖会穿透组边界 → 与上下邻居同簇
R4 调整层 adjustment kind 曲线/色阶/曝光等修改下方所有像素 → 与下方一起合成
R5 浏览器不可还原 ≥1个cluster含≥2元素 浏览器alpha堆叠只能还原NORMAL → 被R1-R4粘连成≥2元素的cluster必须合成单张PNG

决策结果

  • merge_full:单cluster全非文本 → 全组合成单图
  • merge_with_text_kept:单cluster+≥1文本+≥1非文本 → 非文本合成为背景,文本独立导出
  • merge_partial:≥2 cluster且存在≥2元素glued cluster → 每个glued cluster单独合成
  • no_merge:全文本或全单元素 → 完全递归导出

设计哲学:将"是否合图"的判定从启发式猜测升级为基于PSD合成语义的硬性规则。

3.3 空PASS_THROUGH组白色污染问题

问题:递归为空的PASS_THROUGH组(组内所有子层展开后无可见像素)在独立cluster合成时,psd-tools的composite()会产生白色污染。

典型案例:"吧台"组中的空组导致PNG亮度从128.8变为164.2(偏差+35.4)。

根因:psd-tools的_apply_passthrough_source中,空组的shape_g = 0divide(x, 0)返回极大值→截断为白色→混合结果整体偏白。

解决方案:在detect_compose_clusters源头过滤递归为空的组:

  • 新增_is_group_recursively_empty()辅助函数
  • 过滤条件:不可见/opacity=0/调整层视为无贡献,子组递归检查
  • 效果:"吧台"组导出亮度从164.2降至123.6,与PSD全图参考值128.8高度吻合

核心原则:如果一个组递归展开后无可见像素内容,它就不应该成为cluster成员。

3.4 光效穿透机制

问题:PASS_THROUGH组中的光照类blend mode光效层(COLOR_DODGE/LINEAR_DODGE/SCREEN/LIGHTEN/LIGHTER_COLOR),其黑色像素在混合中是恒等色,单独导出会产生黑色像素块。

解决方案:五阶段光效穿透流程:

Phase 1:识别光效层

  • 遍历PSD,识别位于PASS_THROUGH组中的光照类混合模式图层
  • 构建LightEffectLayerInfo数据结构

Phase 2:组内向下查找

  • 计算光效层的有效作用区域(考虑Layer Mask/Vector Mask/Clipping)
  • 检查组内下方图层覆盖情况,覆盖率≥90%则不需要穿透

Phase 3:穿透到外组匹配

  • 向上追溯到外组,查找与光效层bbox有交集且有不透明像素的目标图层
  • 构建penetrate_map:目标图层ID → 需要叠加的光效层列表

Phase 4:导出时叠加光效

  • 路径1:叶图层 - 在_export_layer_image中,目标层渲染后叠加光效
  • 路径2:组目标 - 在_merge_group_as_single_image中,composite后叠加光效
  • 光效层只在有底色的区域起作用,输出alpha = 底层alpha

Phase 5:光效层导出抑制

  • 构建suppressed_light_layers集合
  • 独立叶导出路径:跳过被抑制的光效层
  • cluster合成路径:临时隐藏被抑制的光效层,避免黑底参与合成

边界案例:多层穿透、光效层被R2规则粘连、目标层可能是组、光效层被抑制导出等。

3.5 混合渲染策略:组级效果溢出的完美解决方案

挑战:PSD图层效果(描边、阴影、发光)在组级别会沿组边界裁切,psd-toolscomposite()无法处理溢出效果。

解决方案:手动栅格化 + composite混合

  1. 扩展画布手动逐层渲染 → 获取完整溢出像素
  2. group.composite(viewport=bbox) → 获取组内高质量像素
  3. composite结果覆盖到手动渲染的内部区域

效果:外部保留溢出效果,内部达到像素级匹配(Alpha差异max=0, mean=0.00)

3.3 语义化命名系统

三层置信度流水线

  1. Layer2角色推断:识别按钮、背景、卡片等语义角色
  2. Layer1清洗词典:基于common/cn_dict.json的中文关键词映射
  3. Fallback拼音:无匹配时使用拼音作为兜底

智能文件命名

  • 格式:images/<semantic-tag>-<md5前6位>.png
  • 示例:rounded-a3f012.png, btn-receive-279914.png
  • 优势:可读性 + 内容哈希确保git diff稳定

四、默认策略调整:更加保守的优化

4.1 ImageLayerFlatten默认关闭(2026-05-27起)

原因:图层扁平化会删除所有子DOM节点,过于激进,导致语义独立的栅格化元素(按钮、数字框、栅格化文字)被错误合并。

典型案例:抽奖活动"游泳圈"组内:

  • 游泳圈底图(pixel)
  • 数字框矩形(shape)
  • 礼盒文字(栅格化的TypeLayer)

这三个语义独立元素会被合并成单张flat-*.png,丧失独立改色、换文案、绑事件的能力。

新策略:通过--enable-image-layer-flatten显式启用,仅在确认整组是纯装饰时使用。

4.2 Smart Merge的精准控制

--no-smart-merge开关现在只控制LayoutOptimizer链路的两项优化:

  1. DOMRestructure多url背景内联合成
  2. background_flatten文本兜底

这两项优化不删除任何DOM子节点,副作用小,因此默认开启。仅在需要1:1对照PSD图层树或跑像素级回归基线时关闭。

五、多Target架构:一次转换,多端产出

5.1 核心优势

  • HTML target优化自动继承:布局优化、CSS去冗余、语义化命名等能力免费惠及React/Vue
  • 视觉一致性验证:开发者可直接对比html/index_optimized.htmlreact/src/App.jsx
  • 简单可靠的转换逻辑:DOM遍历 + class/style重映射 + 模板语法替换

5.2 产物结构

output/<psd_stem>/
├── html/           # 绝对定位版 + Flex优化版
├── react/          # Vite + React 18项目
└── vue/            # Vite + Vue 3 SFC项目

5.3 开箱即用

cd output/<psd_stem>/react  # 或vue
npm install && npm run dev  # http://localhost:5173

六、实战效果与数据

6.1 性能指标

  • 转换时间:750×6778活动页约20秒
  • 去重率:239个image图层 → 87张PNG(63.6%去重率)
  • CSS压缩:4805行 → 1499行(减少68.8%)
  • DOM节点:约500 → 280(ImageLayerFlatten启用时)

6.2 质量保证

  • 像素级还原:与PSD设计稿完全对齐
  • 浏览器兼容:支持现代浏览器,自动处理中文字宽差异
  • 语义化输出:可读的CSS类名,便于后期维护

七、质量保证与测试验证

7.1 Baseline Diff核心策略

psd2code采用严格的基线对比策略,确保代码变更不引入回归:

核心流程

# 1. 改动前:建立基线
cp -r .codebuddy/skills/psd2code/output /tmp/psd2code-baseline

# 2. 执行代码修改

# 3. 重新运行转换
python3 .codebuddy/skills/psd2code/psd_to_code.py sample.psd

# 4. 整树比对
diff -rq /tmp/psd2code-baseline .codebuddy/skills/psd2code/output
# 期望:零输出(零差异)

双基线验证

  1. 自己改动前的快照 - 验证"代码变更无副作用"
  2. 历史psd2html输出 - 验证"与历史版本保持兼容"

差异化策略

场景 允许差异 做法
故意重命名class/文件名 PR中说明并重建baseline
修复bug(原先就错) 准备before/after截图证明修复正确
新增效果/新字段 ✓(输出变更) 既有PSD不含新效果时应保持零差异
像素有任何变化 ✗ 默认不接受 若无明确理由,属于回归,需修复

多样本验证

  • 普通场景PSD
  • 带大量效果(外描边/投影/发光)的PSD
  • 带嵌套组/剪切蒙版/文字混排的PSD
  • 所有样本都diff零差异才算"通过"

Lint检查

# ruff检查
ruff check .codebuddy/skills/psd2code/scripts

# 提交前确保全项目lint零错误

手动烟测

  1. 打开index.htmlindex_optimized.html目视对比
  2. 浏览器打开查看正常渲染情况
  3. 检查DevTools Console有无JS错误

常见失败模式定位

症状 可能原因 下手点
图片md5变了但名字没变 渲染算法改变 比较images/下两个文件的PIL展示
图层位置偏移 bbox计算变化 检查image_ops._constrain_bbox_to_canvas
组渲染多/少内容 Handler决策改变 检查handlers.py顺序与can_handle
文字变图片 TextExtractor.has_transform判定不同 检查对应方法
Layout优化后HTML diff 优化规则改变 临时禁用LayoutOptimizeStage二分定位
IR校验失败 pydantic字段约束未满足 看报错信息的locmsg

7.2 PSD准备最佳实践

  1. 整理图层结构:按视觉版块分组(版块1-签到 / 版块2-道具 / 版块3-任务)
  2. 语义化命名:关键图层使用中文或kebab-case命名(bg-main / btn-领取 / 用户信息背景
  3. 避免默认命名:减少"图层12拷贝3"、"形状47"等无意义名称

7.3 使用技巧

  • 排查三件套_naming_report.md + layer_map.json + 对比index.htmlindex_optimized.html
  • CSS样式选择--css-style expanded查看PSD坐标溯源注释
  • 调试模式--no-smart-merge保持原始多url CSS,便于1:1诊断

八、未来展望

8.1 近期计划

  • 已完成:HTML/React/Vue三端支持
  • 🚧 进行中:小程序target(架构已预留扩展点)
  • 📅 规划中:Tailwind CSS输出、Figma文件支持

8.2 技术方向

  • 更智能的布局推断:基于机器学习识别设计模式
  • 更丰富的效果支持:PSD高级效果的完整还原
  • 更完善的开发工具:VS Code插件、设计稿对比工具

结语

psd2code不是一个"AI读图猜布局"的玩具,而是一个严格基于PSD结构信息的编译器。每一步决策都可解释、可调参、可单测,算法失败点都有明确的fallback路径。

通过最近的一系列更新,我们修复了多个长期存在的技术问题,优化了默认策略,让工具更加稳定可靠。如果你也在做活动页、长图详情页、运营H5,欢迎试用并提反馈。


参考资料

  • 项目入口:psd_to_code.py
  • 完整文档:doc/README.md
  • 已知坑位:doc/05-conventions/known-pitfalls.md(必读)
  • 测试验证:doc/05-conventions/testing-and-validation.md
  • 布局优化器深度解析:doc/03-topics/layout-optimizer.md
  • 最近Bug修复:doc/03-topics/bugfix-2026-06-01.md
  • 空组白色污染:doc/03-topics/empty-pt-group-white-pollution.md
  • 光效穿透机制:doc/03-topics/light-blend-penetrate.md
  • 组合成策略:doc/03-topics/group-rendering.md
  • 效果渲染流水线:doc/03-topics/effects-rendering.md
  • IR契约设计:doc/03-topics/ir-contract.md
  • CoreExtract模块:doc/02-modules/core-extract.md(含R1-R5合成簇规则)

项目地址:github.com/miaowmiaow/… 如有问题或建议,欢迎在GitHub提交Issue或PR。

多标签页并发请求导致 Token 刷新失败?只有 15行代码就能解决 !

多标签页并发请求导致 Token 刷新失败?只有 15行代码就能解决 !

最近我们客服群里的告警反馈就炸了。

不少核心 SaaS 用户在抱怨:你们系统怎么回事?我开着几个标签页在后台对账,突然所有页面全部掉线,提示登录过期,害得我刚录入的数据全没了!

我立刻把负责用户中心模块的小伙子叫过来,一块排查后端日志🫡。

原因极其典型:用户在浏览器里开着 5 个我们的后台标签页,半小时后,Token 过期了。 5 个标签页在同一瞬间检测到了过期,同时向后端发起 refreshToken 请求。

而我们的后端为了安全,设计了 单次刷新令牌即失效(One-Time Use Token) 的安全机制。 当这 5 个并发请求几乎同时到达服务器时:

ChatGPT Image 2026年6月1日 16_10_10.png

请求 A 先到达,后端刷新成功,返回了新的双 Token,并将旧的 Token 拉黑; 请求 B、C、D、E 紧随其后,拿着已经被拉黑的旧 Token 去刷新,后端判定为凭证被盗用,直接执行了安全熔断,把该用户名下的所有 Session 全部强制踢下线

小伙子一脸委屈:老大,这纯粹是网络临界区冲突,前端发请求又没有跨页面同步机制,我怎么控制谁先发,谁后发?🤷‍♂️

大部分中初级开发在面对这个痛点时,脑子里的常规套路是👇: 利用 localStorage 配合 storage 监听事件,或者手写一个基于 SharedWorker 的中转广播,在多个 Tab 之间实现一套复杂的同步锁逻辑。

代码动辄写上百行,不仅难以调试,还要处理页面卡死、Localstorage 写入延迟、Worker 线程挂掉等一大堆兼容性地雷。

但在这个圈子混了快十年,我一向提倡的原则是:凡是能用一行原生 API 降维打击的,绝对不要在 JS 业务层去折腾复杂的轮子。

其实,现代浏览器早就为我们内置了一款低调、极其强大、却被 90% 前端忽略的冷门大杀器——Web Locks API(Web 锁 API)

接下来直接上真家伙,看看它是怎么用最纯粹的原生语法,优雅解决这个多标签页死结的👋。


先讲清楚,什么是 Web Locks API?

很多前端知道线程锁、进程锁,但极少有人知道浏览器端也有 页面级互斥锁

navigator.locksW3C 正式通过的标准 API(早在 2022 年就已被所有主流浏览器原生支持)。 它允许同源(Same-Origin)下的多个浏览器上下文(无论是多个 Tab 标签页,还是多个 Web Worker 线程),去异步申请一个互斥的共享资源锁

在锁被持有期间,其他任何标签页都无法获取同名的锁,必须老老实实排队。只有当持有锁的那个异步函数执行完毕(ResolveReject),浏览器才会自动释放锁,并把控制权交给下一个排队的 Tab。


只有 15 行代码解决多标签页并发刷新

有了它,我们怎么去重构 Token 刷新逻辑?

不需要写任何跨页面通信,不需要写任何 storage 监听。直接看处理流程👇:

ChatGPT Image 2026年6月1日 16_30_32.png

核心伪源码👇:

// 纯原生 Web Locks 优雅解决多标签页并发刷新
import { http } from '@/utils/request';

async function getValidToken() {
  const localToken = localStorage.getItem('access_token');
  
  // 检查如果内存/本地的 Token 依然有效,直接返回,不需要抢锁
  if (isTokenValid(localToken)) {
    return localToken;
  }

  // 核心 API:向浏览器申请一个名为 'token_refresh_lock' 的互斥锁
  return navigator.locks.request('token_refresh_lock', async (lock) => {
    // 抢到锁后,第一件事:再次检查最新本地 Token(防止前一个拿到锁的 Tab 已经刷新好了)
    const latestToken = localStorage.getItem('access_token');
    if (isTokenValid(latestToken)) {
      return latestToken; // 如果前一个页面已经刷好了,直接复用,免去多余的网络请求
    }

    try {
      // 只有抢到锁的标签页,才会真正向后端发起刷新请求
      const { accessToken } = await http.post('/auth/refresh', {
        refreshToken: localStorage.getItem('refresh_token')
      });
      
      localStorage.setItem('access_token', accessToken);
      return accessToken;
    } catch (err) {
      // 如果刷新失败(比如: RefreshToken 真的过期了),清除状态并抛出
      handleLogout();
      throw err;
    }
  }); // 异步函数结束,浏览器自动在底层释放锁,排队中的下一个 Tab 拿到锁后直接触发 isTokenValid 退出
}

你根本不需要知道别的标签页现在是个什么状态,你只需要把最核心的临界代码用 navigator.locks.request 包起来。

多标签页之间的并发冲突、时序排队,全部交由浏览器内核的 C++ 引擎 在底层调度,既不会阻塞主线程,又绝对安全可靠。


也要警惕锁死与超时灾难

如果文章写到这里就结束,那就是纯粹的 API 爽文 了 😁。

在真实的工程环境里,只要涉及多线程/多端锁,就必然面临两个无法逃避的问题:死锁(Deadlock)与意外挂起。

如果持有锁的那个标签页,在执行异步请求时由于网络极其缓慢,卡了整整 30 秒,难道其他 4 个标签页要跟着卡死、拒绝响应用户 30 秒吗?😖

又或者,持有锁的标签页突然发生了崩溃,锁没有被正确释放怎么办?(这个不用担心,浏览器在标签页关闭或崩溃时,会在底层强行安全回收它持有的锁)。

为了防范 网络卡死 导致的所有页面陷入无尽等待,我们必须利用 AbortSignal 给锁加上一个 超时自动断开 的防御机制:

ChatGPT Image 2026年6月1日 17_15_10.png

// 带超时控制的 Web 锁
async function acquireLockWithTimeout() {
  // 创建一个 5 秒超时的控制器
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 5000);

  try {
    return await navigator.locks.request(
      'token_refresh_lock', 
      { signal: controller.signal }, // 注入超时信号
      async (lock) => {
        clearTimeout(timeoutId); // 成功拿到锁,清除超时器
        
        if (lock === null) {
          // 如果设置了 ifAvailable: true 且拿不到锁,lock 会返回 null
          throw new Error('当前系统繁忙,锁获取失败');
        }
        
        return await doHeavyTokenRefresh();
      }
    );
  } catch (err) {
    if (err.name === 'AbortError') {
      console.warn('锁获取超时,强行解除等待状态');
    }
    throw err;
  }
}

利用 controller.signal 这一行配置,我们就优雅地完成了对锁机制的安全兜底。一旦网络超时,等待队列中的其他页面会瞬间被唤醒并解绑,绝不会造成全站卡死的连带事故。


最后

如果再次遇到多页面、多端并发的数据同步问题,别再本能地去 npm 里搜那些笨重的轮子,也别在业务层写一堆难维护的 localStorage 定时器。

学会浏览器原生的底盘能力。用最克制、最优雅的一行 navigator.locks,去彻底终结困扰团队多时的工程死结。

把技术用在刀刃上, 你们觉得呢?😁

如何创建蛛网地图|气泡事件+全球发布+关联组合图表开发示例

完整HTML代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>挪威夏季奥运会奖牌分布地图 - Highcharts</title>
    <!-- 引入 Highcharts 核心库 + 地图库 -->
    <script src="https://cdn.jsdelivr.net/npm/highcharts@10.3.3/highcharts.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/highcharts@10.3.3/modules/map.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/highcharts@10.3.3/modules/data.js"></script>

    <style>
        #container {
            width: 100%;
            height: 85vh;
            margin: 0 auto;
        }
    </style>
</head>
<body>

<div id="container"></div>

<script>
(async () => {

    // 加载世界地图拓扑数据
    const topology = await fetch(
        'https://code.highcharts.com/mapdata/custom/world.topo.json'
    ).then(response => response.json());

    // 奥运会城市数据:城市、国家、年份、纬度、经度、总奖牌、金牌、银牌、铜牌
    const data = [
        ['亚特兰大', '美国', '1996', 33.75, -84.38, 7, 2, 2, 3],
        ['悉尼', '澳大利亚', '2000', -33.87, 151.20, 10, 4, 3, 3],
        ['雅典', '希腊', '2004', 38, 23.72, 6, 5, 0, 1],
        ['北京', '中国', '2008', 39.92, 116.38, 9, 3, 5, 1],
        ['伦敦', '英国', '2012', 51.5, -0.12, 4, 2, 1, 1],
        ['里约热内卢', '巴西', '2016', -22.91, -43.20, 4, 0, 0, 4],
        ['东京', '日本', '2020', 35.69, 139.69, 8, 4, 2, 2]
    ];

    Highcharts.mapChart('container', {

        chart: {
            map: topology
        },

        // 关闭图例
        legend: {
            enabled: false
        },

        // 地图导航(缩放按钮)
        mapNavigation: {
            enabled: true,
            buttonOptions: {
                verticalAlign: 'bottom'
            }
        },

        // 地图自适应显示范围
        mapView: {
            fitToGeometry: {
                type: 'MultiPoint',
                coordinates: [
                    [-164, 54],
                    [-35, 84],
                    [179, -38],
                    [-68, -55]
                ]
            }
        },

        // 标题(中文)
        title: {
            text: '挪威在夏季奥运会获得奖牌分布(1996-2020)',
            align: 'left'
        },

        // 副标题
        subtitle: {
            text: '数据来源:维基百科',
            align: 'left'
        },

        // 鼠标悬浮提示框(汉化)
        tooltip: {
            headerFormat: '',
            pointFormat: '{point.city}({point.country} {point.year})<br/>' +
                '总奖牌数:{point.z}<br/>' +
                '<span style="color: #ffd700;">●</span> 金牌:{point.gold}<br/>' +
                '<span style="color: #c0c0c0;">●</span> 银牌:{point.silver}<br/>' +
                '<span style="color: #cd7f32;">●</span> 铜牌:{point.bronze}<br/>'
        },

        series: [
            {
                name: '世界地图',
                nullColor: '#fad3cf' // 地图底色
            },
            {
                name: '奥运会举办城市',
                type: 'mapbubble', // 气泡地图类型
                color: '#fe5f55',
                lineWidth: 1,
                // 数据字段对应关系
                keys: [
                    'city', 'country', 'year', 'lat', 'lon', 'z', 'gold', 'silver', 'bronze'
                ],
                data: data,
                minSize: '5%',   // 气泡最小尺寸
                maxSize: '12.5%',// 气泡最大尺寸
            }
        ]

    });

})();
</script>

</body>
</html>

1. 图表类型

这是一个 世界地图 + 气泡图(Map Bubble) 组合图表

  • 底层:世界地图
  • 上层:气泡点 → 代表奥运会举办城市

2. 气泡代表什么?

每个气泡 = 一届夏季奥运会

  • 气泡位置 = 举办城市坐标
  • 气泡大小 = 挪威获得的总奖牌数
  • 气泡越大 → 奖牌越多

3. 数据包含哪些信息?

每一行数据对应:

['城市','国家','年份',纬度,经度,总奖牌,金牌,银牌,铜牌]

4. 鼠标悬浮提示(Tooltip)

悬浮在气泡上会显示:

  • 城市、国家、年份
  • 总奖牌数
  • 金牌、银牌、铜牌数量

5. 图表功能

  • ✅ 支持 鼠标缩放地图
  • ✅ 支持 拖拽移动
  • ✅ 自动适配世界范围
  • ✅ 美观配色 + 中文汉化
  • ✅ 可直接本地运行

6. 适用场景

  • 地理数据可视化
  • 赛事 / 事件全球分布
  • 带数值大小的点位展示
  • 大屏数据展示

你直接复制这段代码保存为 .html

双击打开就能看到完整地图图表!

需要我再给你做 动态数据版、可切换年份、可筛选奖牌 的增强版吗?

ECharts 区域地图可视化实战:以山东地图为例

引言

在数据可视化领域,地图可视化是展示地理相关数据的重要手段。本文将详细介绍如何使用 ECharts 结合 GeoJSON 数据实现区域地图的可视化,以山东省地图为例,带领大家从零开始构建一个精美的交互式地图应用。

效果预览

在这里插入图片描述

一、准备工作

1.1 环境要求

  • 浏览器:支持 HTML5 的现代浏览器(Chrome、Firefox、Edge 等)
  • 基础技术:HTML、JavaScript
  • 第三方库:ECharts 5.x

1.2 所需资源

  1. ECharts 库:用于地图渲染
  2. GeoJSON 数据:山东地图的地理边界数据
  3. 区域数据:各城市的统计数据

二、GeoJSON 数据解析

2.1 GeoJSON 结构说明

山东地图的 GeoJSON 文件结构如下:

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "properties": {
        "adcode": 370100,
        "name": "济南市",
        "center": [117.000923, 36.675807],
        "centroid": [117.221211, 36.3640013],
        "level": "city"
      },
      "geometry": {
        "type": "MultiPolygon",
        "coordinates": [...]
      }
    }
  ]
}

2.2 关键属性说明

属性 说明
name 城市名称,用于数据匹配
center 城市中心点坐标 [经度, 纬度]
centroid 城市几何中心坐标
adcode 行政区划代码

三、封装 RegionMap 类

为了提高代码复用性,我们封装了一个通用的 RegionMap 类。

3.1 类的核心结构

class RegionMap {
  constructor(options = {}) {
    this.container = options.container || 'container';  // 容器ID
    this.mapName = options.mapName || 'region';         // 地图名称
    this.geoJSON = options.geoJSON || null;             // GeoJSON数据
    this.data = options.data || [];                     // 区域数据
    this.tooltipData = options.tooltipData || [];       // 提示数据
    this.title = options.title || '分布图';              // 标题
    this.layoutSize = options.layoutSize || '180%';     // 布局大小
    this.zoom = options.zoom || 0.65;                   // 缩放比例
    this.chart = null;
    this.geoCoordMap = {};
    this.init();
  }
}

3.2 坐标解析方法

关键在于正确解析 GeoJSON 中的坐标信息:

updateGeoCoordFromMap() {
  if (this.geoJSON && this.geoJSON.features) {
    this.geoJSON.features.forEach((feature) => {
      const name = feature.properties?.name;
      let coord = null;
      // 优先级:center > centroid > cp
      if (feature.properties?.center) {
        coord = feature.properties.center;
      } else if (feature.properties?.centroid) {
        coord = feature.properties.centroid;
      } else if (feature.properties?.cp) {
        coord = feature.properties.cp;
      }
      if (name && coord) {
        this.geoCoordMap[name] = coord;
      }
    });
  }
}

3.3 数据转换方法

将区域数据转换为 ECharts 所需格式:

convertData(data) {
  const res = [];
  for (let i = 0; i < data.length; i++) {
    const geoCoord = this.geoCoordMap[data[i].name];
    if (geoCoord) {
      res.push({
        name: data[i].name,
        value: geoCoord.concat(data[i].value)
      });
    }
  }
  return res;
}

四、实现山东地图

4.1 创建 HTML 页面

<!DOCTYPE html>
<html lang="zh-CN" style="height: 100%">
<head>
  <meta charset="utf-8">
  <title>山东地图数据可视化</title>
</head>
<body style="height: 100%; margin: 0">
  <div id="container" style="margin: 0 auto;width: 50%;height: 55%;"></div>
  
  <!-- 引入 ECharts 和封装类 -->
  <script src="https://registry.npmmirror.com/echarts/5.4.3/files/dist/echarts.min.js"></script>
  <script src="./region-map.js"></script>
  
  <script type="text/javascript">
    // 地图逻辑代码
  </script>
</body>
</html>

4.2 准备山东数据

// 山东省各城市数据
var shandongData = [
  { name: '济南市', value: 100 },
  { name: '青岛市', value: 150 },
  { name: '淄博市', value: 80 },
  { name: '枣庄市', value: 60 },
  { name: '东营市', value: 70 },
  { name: '烟台市', value: 120 },
  { name: '潍坊市', value: 90 },
  { name: '济宁市', value: 85 },
  { name: '泰安市', value: 75 },
  { name: '威海市', value: 95 },
  { name: '日照市', value: 65 },
  { name: '临沂市', value: 110 },
  { name: '德州市', value: 70 },
  { name: '聊城市', value: 80 },
  { name: '滨州市', value: 55 },
  { name: '菏泽市', value: 90 },
  { name: '莱芜市', value: 45 }
];

// Tooltip 详细数据
var tooltipData = [
  { name: '济南市', value: 100, areas: ['历下区', '市中区', '槐荫区', '天桥区', '历城区'] },
  { name: '青岛市', value: 150, areas: ['市南区', '市北区', '黄岛区', '崂山区'] },
  { name: '烟台市', value: 120, areas: ['芝罘区', '福山区', '牟平区', '莱山区'] },
  { name: '临沂市', value: 110, areas: ['兰山区', '罗庄区', '河东区'] },
  { name: '潍坊市', value: 90, areas: ['潍城区', '寒亭区', '坊子区', '奎文区'] }
];

4.3 加载 GeoJSON 并渲染地图

// 异步加载山东 GeoJSON 数据
fetch('./shandong.json')
  .then(response => response.json())
  .then(geoJSON => {
    // 创建地图实例
    var shandongMap = new RegionMap({
      container: 'container',      // 容器ID
      mapName: 'shandong',        // 地图名称(用于ECharts注册)
      geoJSON: geoJSON,           // GeoJSON数据
      data: shandongData,         // 区域数据
      tooltipData: tooltipData,   // Tooltip数据
      title: '山东地图数据可视化', // 标题
      layoutSize: '180%',         // 布局大小
      zoom: 0.65                  // 缩放比例
    });
    
    // 渲染地图
    shandongMap.render();
  })
  .catch(error => {
    console.error('加载GeoJSON失败:', error);
  });

五、地图配置详解

5.1 视觉效果配置

RegionMap 类提供了精美的视觉效果:

itemStyle: {
  normal: {
    areaColor: {
      type: 'linear',
      x: 1200, y: 0, x2: 0, y2: 0,
      colorStops: [
        { offset: 0, color: 'rgba(3,27,78,0.75)' },
        { offset: 1, color: 'rgba(58,149,253,0.75)' }
      ],
      global: true
    },
    borderColor: '#c0f3fb',
    borderWidth: 1,
    shadowColor: '#8cd3ef',
    shadowOffsetY: 10,
    shadowBlur: 120
  },
  emphasis: {
    areaColor: 'rgba(0,254,233,0.6)'
  }
}

5.2 多层次阴影效果

为了增强立体感,采用了多层地图叠加技术:

geo: [
  { zlevel: 0, layoutCenter: ['50%', '50%'] },      // 主地图
  { zlevel: -1, layoutCenter: ['50%', '51%'] },     // 第一层阴影
  { zlevel: -2, layoutCenter: ['50%', '52%'] },     // 第二层阴影
  { zlevel: -3, layoutCenter: ['50%', '53%'] },     // 第三层阴影
  { zlevel: -4, layoutCenter: ['50%', '54%'] }      // 第四层阴影
]

5.3 动态效果配置

// 涟漪散点效果
{
  type: 'effectScatter',
  data: convertData(tooltipData),
  showEffectOn: 'render',
  rippleEffect: {
    scale: 5,
    brushType: 'stroke'
  },
  itemStyle: {
    color: '#00FFFF',
    shadowBlur: 10,
    shadowColor: '#16ffff'
  }
}

六、功能扩展

6.1 数据更新

// 更新区域数据
shandongMap.setData(newData);

// 更新 Tooltip 数据
shandongMap.setTooltipData(newTooltipData);

// 重新渲染
shandongMap.render();

6.2 销毁地图

// 页面销毁时释放资源
shandongMap.dispose();

6.3 响应式布局

// 监听窗口大小变化
window.addEventListener('resize', () => {
  if (shandongMap.chart) {
    shandongMap.chart.resize();
  }
});

七、常见问题

7.1 GeoJSON 加载失败

问题现象:地图无法显示,控制台报错

解决方案

  1. 检查文件路径是否正确
  2. 确保服务器支持跨域访问
  3. 验证 GeoJSON 格式是否正确

7.2 数据不显示

问题现象:地图显示正常,但数据点不显示

解决方案

  1. 检查数据中的 name 字段是否与 GeoJSON 中的名称一致
  2. 确认坐标解析逻辑是否正确
  3. 使用 console.log 打印 geoCoordMap 检查坐标是否正确解析

7.3 地图显示不全

问题现象:地图只显示部分区域

解决方案

  1. 调整 layoutSize 参数(如 '200%'
  2. 修改 zoom 参数调整缩放比例
  3. 检查容器尺寸是否足够

three-instance-batch 开发笔记

为什么要做这个

写 Three.js 项目时被 InstancedMesh 折腾。改 transform 的时候需要重复 setMatrixAtneedsUpdate = true,删实例的时候需要手动标记尾部搬过来补坑。写三个场景就得写三套差不多的管理代码。

所以抽了这一层:上面像操作普通 Mesh 一样改 transform,下面自动追踪增量刷 GPU

逐个说

1. 怎么追踪变更

最初想过 Proxy 整包 Vector3,但 Three 内部大量直接 vec.x = 5,Proxy 会打断引擎自己的路径,而且每个实例多一层代理对象,内存翻倍。

最后用了最笨的办法——一个方法一个方法地换。

function wrapMethod(obj, method, onChange) {
  const orig = obj[method]
  obj[method] = function (...args) {
    const r = orig.apply(this, args)
    onChange()
    return r
  }
}

36 个方法分了四组——position/scale 7 个、rotation 7 个、quaternion 8 个、color 6 个——各自挂 onChange。构造一个 Instance 大约 35 次闭包调用,比 Proxy 方案多 1KB 左右的函数开销。

代价是属性赋值不管用。 position.x = 5 不触发任何东西。x/y/z 是普通数字属性,要想追踪得整成 getter/setter,会和 Three 的内部冲突。所以文档里说清楚,单轴改动用 setX()

2. 分组依据

InstancedMesh 一个 Mesh 只能绑一组 geometry + material。所以不同 geometry 铁定分开,但不同 material 什么时候能共用要考虑:

  • color 不同 → 可以共用,instanceColor 缓冲区搞定
  • map 不同 → 必须分,shader uniform 绑的纹理不一样
  • transparent 不同 → 必须分,影响渲染队列

最后筛了 13 个属性进签名。目前 MeshStandardMaterial + MeshPhongMaterial 常用组合没出过问题。目前的键的格式是:

geo.uuid | map=tex:xxx;transparent=false;... | 00
                                              ↑ castShadow receiveShadow

shadow 单独抠出来是因为运行时改 shadow 开关要换 Mesh——InstancedMesh 的 castShadow 是整个 Mesh 级别设的,不能逐实例控制。

改 shadow → key 变了 → 塞进 _needsGroupRebuild → 下一个 update 在旧组删掉、在新组重建。

3. 只刷新脏的数据

每一帧假设 5000 个实例里 50 个在动。全量刷 5000 行矩阵就是 5000 × 64B = 320KB 往 GPU 塞。

脏标记分两层:

Instance._localDirty / _worldDirty / _dirtyColor
    ↓ (通过 subscriber 推)
BatchGroup.dirtyIndices: Set<number>
BatchGroup.dirtyColors: Set<number>

update 时只遍历这两个 Set,哪行脏刷哪行。

颜色脏和矩阵脏分开记,因为动 transform 不代表变色了。以前试过统一一个 dirty flag,结果每帧多写 5KB 没变的颜色数据。

4. 父子层级的去重

parent.position 一动,子的 worldMatrix 全脏。这个是基于setter 自动往下传播 _worldDirty = true

但计算时:A → B → C 三层,C 计算 world 时递归进 B,B 又递归进 A。如果还有 D → B → C,A 被算两次。层级深了就是指数级重复。

let frameVersion = 0  // 每帧 ++

recomputeWorldMatrix() {
  if (this._frameVersion === frameVersion) return  // 本帧算过了
  this._frameVersion = frameVersion
  // ...
}

5. 删除怎么处理

数组中间掏个洞,我的做法是:把最后一个搬到空位,count--

idx=2,末位 lastIdx=5:
  [A B C D E F][A B F D E _]  count=65

F 的 indexMap 改 2,idx=2 推进 dirtyIndices。一轮 update 就同步到 GPU 了。

空出来的 lastIdx 推进 emptySlots 栈,下次 add 优先弹。这个栈有个小 bug——连续删多个时同一个索引会重复进栈——但正常使用场景下加删混合跑,很难堆起来。真要修的话弹栈前查个 Set 就行。

6. 扩容

InstancedMesh 容量是死的。超了就新 new 一个更大的,旧的数据拷过去,旧的 dispose。

over-allocation 默认 0.2,也就是说容量 16 → 扩容到 16 + ceil(4) = 20,不是 32。试过 2x,1000 实例浪费 1000 × 64B = 64KB 的 GPU Buffer 空槽。

7. Instance 跟 Batcher 的通信

Instance 不该知道 Batcher 的存在,反过来 Batcher 又要感知 Instance 变更。就一个接口:

interface BatcherSubscriber {
  markDirty(inst, type)   // matrix 还是 color
  markShadowChange(inst)  // 换组
  removeInstance(inst)    // 清理
}

Batcher 实现它,addInstance 时 inst._subscribe(this)。这样以后有其他"观察者"(比如统计面板想监听变更频率)不用改 Instance 的代码。

测试

56 条 vitest。写的时候反复纠结要不要 mock Three.js——最后决定不 mock,因为整个库的价值就在跟 InstancedMesh 的交互,mock 了等于没测。

最值钱的 test case:

  • 空槽复用:add 4 → remove 2 → add 2,断言 count 和 groupCount 不变,新实例在旧槽位上
  • swap-and-pop 索引正确:remove 非末位后,末位实例的 getMatrixAt 位置对
  • 扩容保留自定义材质:capacity=2,塞 3 个,检查 depthMaterial 还在
  • shadow 迁移:改 castShadow 后 update,groupCount 从 1 变 2,count 不变

量化交易 PostgreSQL 建表指南:K 线数据 + 信号记录 + 性能优化

量化系统跑到一定阶段,CSV 一定会成为瓶颈。本文给出一个生产级的 PostgreSQL 建表方案。

为什么 CSV 不够用

场景 CSV 耗时 PostgreSQL 耗时
查茅台近 20 天收盘价 ~30s <0.1s
对比 50 只股一年涨幅 ~5min <1s
多策略并行读写 文件锁冲突 ACID 保障
重复数据去重 手动 UNIQUE INDEX

1. K 线数据表

CREATE TABLE daily_prices (
    id            SERIAL PRIMARY KEY,
    symbol        VARCHAR(10) NOT NULL,
    trade_date    DATE NOT NULL,
    open          NUMERIC(12,4),
    high          NUMERIC(12,4),
    low           NUMERIC(12,4),
    close         NUMERIC(12,4),
    volume        BIGINT,
    amount        NUMERIC(16,2),
    created_at    TIMESTAMP DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_symbol_date 
  ON daily_prices(symbol, trade_date);

CREATE INDEX idx_date ON daily_prices(trade_date);

类型选择

类型 为什么
open/high/low/close NUMERIC(12,4) 定点数,无浮点误差。4 位兼容港股美股
volume BIGINT 日成交可达数亿股,INTEGER 不够
amount NUMERIC(16,2) 成交额可达数千亿,预留空间
trade_date DATE 不带时区,A 股统一北京时间

为什么不用 FLOAT?

>>> 0.1 + 0.2 == 0.3
False

金融场景不允许这种误差。NUMERIC 是定点数,牺牲一点速度换绝对精度。


2. 信号表

CREATE TABLE signals (
    id            SERIAL PRIMARY KEY,
    symbol        VARCHAR(10) NOT NULL,
    strategy      VARCHAR(50) NOT NULL,
    signal_type   VARCHAR(10) NOT NULL CHECK (signal_type IN ('BUY','SELL','HOLD')),
    price         NUMERIC(12,4),
    reason        TEXT,
    created_at    TIMESTAMP DEFAULT NOW()
);

CREATE INDEX idx_signals_time ON signals(created_at DESC);
CREATE INDEX idx_signals_symbol ON signals(symbol);

CHECK 约束防止脏数据(如 signal_type = 'buy' 大小写不一致)。


3. Upsert:避免重复拉取产生脏数据

from sqlalchemy.dialects.postgresql import insert

def upsert_kline(engine, df):
    """插入或更新 K 线数据"""
    rows = df.to_dict('records')
    stmt = insert(DailyPrice).values(rows)
    stmt = stmt.on_conflict_do_update(
        constraint='idx_symbol_date',
        set_={
            'open': stmt.excluded.open,
            'high': stmt.excluded.high,
            'low': stmt.excluded.low,
            'close': stmt.excluded.close,
            'volume': stmt.excluded.volume,
            'amount': stmt.excluded.amount,
        }
    )
    engine.execute(stmt)

定时任务多次拉同一天数据 → ON CONFLICT DO UPDATE 只更新不复写。


4. Python 批量入库

import pandas as pd
from sqlalchemy import create_engine

engine = create_engine(
    'postgresql://user:pass@localhost:5432/quantdb'
)

df = pd.read_csv('daily_data.csv')
df.to_sql('daily_prices', engine,
          if_exists='append',
          index=False,
          method='multi',       # 批量 INSERT
          chunksize=1000)       # 分批提交

method='multi' 把多行合并成一条 INSERT,比单条插入快 10 倍以上。


5. 常用查询

-- 最近 N 天 K 线
SELECT trade_date, open, high, low, close, volume
FROM daily_prices
WHERE symbol = '600519'
ORDER BY trade_date DESC
LIMIT 20;

-- 今日涨幅 TOP 10
SELECT symbol,
       ROUND((close - prev_close) / prev_close * 100, 2) AS pct_change
FROM (
    SELECT symbol, close,
           LAG(close) OVER (PARTITION BY symbol ORDER BY trade_date) AS prev_close
    FROM daily_prices
    WHERE trade_date >= CURRENT_DATE - INTERVAL '2 days'
) t
WHERE trade_date = (
    SELECT MAX(trade_date) FROM daily_prices WHERE symbol = t.symbol
)
ORDER BY pct_change DESC
LIMIT 10;

-- 连续上涨天数
SELECT symbol, COUNT(*) AS up_days
FROM (
    SELECT symbol, trade_date,
           close > LAG(close) OVER (PARTITION BY symbol ORDER BY trade_date) AS is_up
    FROM daily_prices
    WHERE trade_date > CURRENT_DATE - INTERVAL '10 days'
) t
WHERE is_up
GROUP BY symbol
HAVING COUNT(*) >= 3;

6. 数据量大了怎么办

数据量 方案 效果
<100 万条 默认配置 无需优化
100 万 ~ 1000 万 加索引 + 调 work_mem 查询毫秒级
>1000 万 (分钟线) TimescaleDB 插件 自动分区 + 10:1 压缩
-- TimescaleDB 一键转时序表
SELECT create_hypertable('daily_prices', 'trade_date');

-- 7 天后自动压缩
SELECT add_compression_policy('daily_prices', 
    INTERVAL '7 days');

选型速查

方案 适用
CSV 0-1 股回测,快速原型
SQLite 10-50 股,本地单机
PostgreSQL 全市场 5000+,生产环境
+TimescaleDB 分钟级 × 全市场,高频

早点切到 PG,省下的排查时间比写建表语句多得多。

TypeScript 进阶知识总结:从 extends、泛型到 infer,一篇打通 TS 类型系统

前言

TypeScript 的核心价值不是“给 JavaScript 加几个类型注释”,而是用类型系统提前描述数据结构、函数契约和业务状态。

这篇文章会从基础类型讲起,重点展开几个 TS 进阶高频点:

  • 索引签名 [key: string]: unknown
  • in 类型收窄
  • extends 的多种用法
  • value is Xxx 类型守卫
  • objectObjectunknownany 的区别
  • 联合类型和交叉类型
  • 函数重载
  • 泛型
  • infer
  • 常用工具类型源码实现和使用场景

1. TypeScript 到底解决什么问题?

TypeScript = JavaScript + 静态类型检查。

它最终还是会编译成 JavaScript,类型只存在于编译期。

function add(a: number, b: number): number {
  return a + b;
}

add(1, 2);
add("1", 2); // 报错

TS 的作用是:在代码运行前,提前发现类型错误。

2. 基础类型

常见基础类型:

let name: string = "Tom";
let age: number = 18;
let isAdmin: boolean = false;
let n: null = null;
let u: undefined = undefined;
let big: bigint = 100n;
let sym: symbol = Symbol("id");

数组:

const nums: number[] = [1, 2, 3];
const names: Array<string> = ["Tom", "Jerry"];

元组:

const user: [string, number] = ["Tom", 18];

对象:

const user: { id: string; name: string } = {
  id: "001",
  name: "Tom",
};

3. anyunknownnevervoid

3.1 any

any 表示任意类型,但会关闭类型检查。

let value: any = "hello";

value.toFixed(); // TS 不报错,但运行可能报错

3.2 unknown

unknown 也可以接收任意类型,但使用前必须判断。

let value: unknown = "hello";

if (typeof value === "string") {
  value.toUpperCase();
}

所以:

unknown = 类型安全版 any

3.3 void

void 常用于表示函数没有返回值。

function log(message: string): void {
  console.log(message);
}

3.4 never

never 表示永远不会出现的值。

function fail(message: string): never {
  throw new Error(message);
}

也常用于穷尽检查:

function assertNever(value: never): never {
  throw new Error(`Unexpected value: ${value}`);
}

4. 索引签名:[key: string]: any

你可能见过这样的写法:

type Obj = {
  [props: string]: any;
};

它表示:

这个对象可以有任意字符串 key,并且 value 是 any 类型。

props 只是名字,可以换成 key

type Obj = {
  [key: string]: any;
};

但是项目里更推荐:

type Obj = {
  [key: string]: unknown;
};

因为 any 会放弃类型检查,而 unknown 更安全。

const obj: Obj = {
  name: "Tom",
  age: 18,
};

const name = obj.name;

if (typeof name === "string") {
  name.toUpperCase();
}

注意,一旦写了索引签名,明确属性也要兼容它:

type User = {
  name: string;
  age: number;
  [key: string]: string | number;
};

5. in 的三种用法

5.1 判断属性是否存在

const user = {
  name: "Tom",
  age: 18,
};

console.log("name" in user); // true
console.log("email" in user); // false

5.2 联合类型收窄

type Cat = {
  meow: () => void;
};

type Dog = {
  bark: () => void;
};

function speak(animal: Cat | Dog) {
  if ("meow" in animal) {
    animal.meow();
  } else {
    animal.bark();
  }
}

"meow" in animal 会让 TS 知道当前分支里 animalCat

5.3 映射类型中遍历 key

type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

这里的 in 表示遍历 keyof T 中的每一个 key。

6. extends:不只是继承

extends 是 TS 中非常重要的关键字。核心可以理解成:

A extends B

意思是:

A 必须满足 B。

6.1 interface 继承

interface Person {
  name: string;
}

interface Student extends Person {
  school: string;
}

Student 同时拥有 nameschool

const s: Student = {
  name: "Tom",
  school: "No.1 School",
};

6.2 class 继承

class Animal {
  move() {
    console.log("move");
  }
}

class Dog extends Animal {
  bark() {
    console.log("bark");
  }
}

6.3 泛型约束

function printName<T extends { name: string }>(obj: T) {
  console.log(obj.name);
}

意思是:

T 必须至少有 name: string

printName({ name: "Tom", age: 18 }); // OK
printName({ age: 18 }); // 报错

你也可以写得更具体:

function handle<T extends { id: string; name: string }>(item: T): T {
  return item;
}

这表示 T 至少要有 idname

const result = handle({
  id: "001",
  name: "Tom",
  age: 18,
});

result.age; // OK

泛型约束的价值是:

既限制结构,又保留传入对象的完整类型。

6.4 条件类型

type IsString<T> = T extends string ? true : false;

使用:

type A = IsString<string>; // true
type B = IsString<number>; // false

这里的 extends 更准确地说是:

T 是否可以赋值给 string。

例如:

type A = "hello" extends string ? true : false;
// true

type B = string extends "hello" ? true : false;
// false

7. T extends object

function fn<T extends object>(value: T) {
  return value;
}

T extends object 表示 T 必须是非原始类型。

可以:

fn({});
fn([]);
fn(() => {});
fn(new Date());

不可以:

fn("hello");
fn(123);
fn(true);

注意:数组和函数也属于 object

如果你想表达普通键值对象,可以考虑:

Record<string, unknown>

但它和普通对象也不是完全等价,实际项目里要看具体场景。

8. objectObject 的区别

8.1 object

object 表示非原始类型。

let a: object;

a = {};
a = [];
a = () => {};

a = "hello"; // 报错
a = 123; // 报错

8.2 Object

Object 范围更宽,几乎表示所有非 null、非 undefined 的值。

let b: Object;

b = {};
b = [];
b = "hello";
b = 123;
b = true;

所以真实项目里不建议用大写 Object 表示普通对象。

更推荐:

unknown // 任意类型,安全
object // 非原始类型
Record<string, unknown> // 普通键值对象

9. 不用 any / unknown,如何表示所有 JS 值?

可以用联合类型:

type Primitive =
  | string
  | number
  | boolean
  | bigint
  | symbol
  | null
  | undefined;

type AnyValue = Primitive | object;

因为 object 包含普通对象、数组、函数、Date、Map、Set 等非原始值。

如果你要表示 JSON 值,可以这样写:

type JsonValue =
  | string
  | number
  | boolean
  | null
  | JsonValue[]
  | { [key: string]: JsonValue };

JSON 不包含:

undefined
function
symbol
bigint
Date
Map
Set

10. 类型守卫:value is Xxx

类型守卫用于告诉 TS:

如果这个函数返回 true,那么参数就是某个类型。

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

使用:

function handle(value: unknown) {
  if (isPlainObject(value)) {
    value.name; // OK,类型是 unknown
  }
}

属性值仍然是 unknown,所以要继续判断:

function handle(value: unknown) {
  if (isPlainObject(value) && typeof value.name === "string") {
    value.name.toUpperCase();
  }
}

类型守卫不必须和 unknown 配合,也可以用于联合类型:

function isString(value: string | number): value is string {
  return typeof value === "string";
}

11. 联合类型和交叉类型

11.1 联合类型 |

联合类型表示“或”。

type ID = string | number;

使用时需要收窄:

function printId(id: string | number) {
  if (typeof id === "string") {
    id.toUpperCase();
  } else {
    id.toFixed(2);
  }
}

适合表达多种可能:

type Status = "loading" | "success" | "error";

11.2 交叉类型 &

交叉类型表示“且”。

type User = { name: string } & { age: number };

const user: User = {
  name: "Tom",
  age: 18,
};

交叉类型常用于合并对象结构:

type Base = {
  id: string;
  createdAt: string;
};

type User = Base & {
  name: string;
};

注意基础类型交叉通常会得到 never

type A = string & number;
// never

对象属性冲突也会导致不可能类型:

type A = { id: string } & { id: number };
// id: never

12. 函数重载

函数重载用于表达:

同一个函数,不同参数类型或参数数量,对应不同返回类型。

function fn(x: string): string;
function fn(x: number): number;
function fn(x: string | number): string | number {
  return x;
}

前两行是重载签名,最后一行是实现签名。

调用时:

const a = fn("hello"); // string
const b = fn(123); // number

参数数量不同也可以重载:

function makeDate(timestamp: number): Date;
function makeDate(year: number, month: number, day: number): Date;

function makeDate(
  yearOrTimestamp: number,
  month?: number,
  day?: number
): Date {
  if (month !== undefined && day !== undefined) {
    return new Date(yearOrTimestamp, month, day);
  }

  return new Date(yearOrTimestamp);
}

不允许两个参数调用:

makeDate(2026, 6); // 报错

如果返回值不随参数类型变化,优先用联合类型。

如果输入和输出保持同一种类型关系,优先用泛型。

13. 泛型

泛型可以理解成:

把类型当成参数传进去。

function identity<T>(value: T): T {
  return value;
}

调用:

const a = identity("hello"); // string
const b = identity(123); // number

泛型的价值是:

既能复用逻辑,又能保留具体类型。

13.1 数组例子

function first<T>(arr: T[]): T | undefined {
  return arr[0];
}
const a = first([1, 2, 3]); // number | undefined
const b = first(["a", "b"]); // string | undefined

13.2 多个泛型参数

function pair<T, U>(a: T, b: U): [T, U] {
  return [a, b];
}

const result = pair("age", 18);
// [string, number]

13.3 泛型接口

type ApiResponse<T> = {
  code: number;
  message: string;
  data: T;
};

使用:

type User = {
  id: string;
  name: string;
};

const res: ApiResponse<User> = {
  code: 0,
  message: "ok",
  data: {
    id: "001",
    name: "Tom",
  },
};

13.4 泛型默认值

type ApiResponse<T = unknown> = {
  code: number;
  data: T;
};

14. 泛型和 keyof

这个模板非常重要:

function getValue<T, K extends keyof T>(obj: T, key: K): T[K] {
  return obj[key];
}

使用:

const user = {
  id: 1,
  name: "Tom",
};

const id = getValue(user, "id"); // number
const name = getValue(user, "name"); // string

getValue(user, "age"); // 报错

这里同时用到了:

T
K extends keyof T
T[K]

它表达的是:

key 必须是 obj 的 key,返回值类型就是这个 key 对应的 value 类型。

15. infer

infer 用于条件类型中,作用是:

从一个类型结构中提取某一部分类型。

15.1 提取数组元素类型

type Item<T> = T extends Array<infer U> ? U : never;

使用:

type A = Item<string[]>; // string
type B = Item<number[]>; // number

15.2 提取 Promise 结果

type UnwrapPromise<T> = T extends Promise<infer R> ? R : T;
type A = UnwrapPromise<Promise<string>>;
// string

递归拆 Promise:

type DeepUnwrapPromise<T> = T extends Promise<infer R>
  ? DeepUnwrapPromise<R>
  : T;

15.3 提取函数返回值

type MyReturnType<T> =
  T extends (...args: any[]) => infer R ? R : never;

使用:

function getUser() {
  return {
    id: 1,
    name: "Tom",
  };
}

type User = MyReturnType<typeof getUser>;
// { id: number; name: string }

15.4 提取函数参数

type MyParameters<T> =
  T extends (...args: infer P) => any ? P : never;
function createUser(id: string, age: number) {}

type Params = MyParameters<typeof createUser>;
// [id: string, age: number]

15.5 提取对象字段

type GetData<T> = T extends { data: infer D } ? D : never;
type Response = {
  code: number;
  data: {
    id: string;
    name: string;
  };
};

type Data = GetData<Response>;
// { id: string; name: string }

16. TS 里的常用工具类型:源码实现 + 使用场景

TypeScript 内置了很多工具类型,它们本质上都是基于这些能力组合出来的:

  • 泛型
  • keyof
  • 映射类型 [K in keyof T]
  • 条件类型 T extends U ? X : Y
  • infer
  • 联合类型分发

下面逐个看常用工具类型。

16.1 Partial<T>

Partial<T> 会把对象类型的所有属性变成可选。

简化源码:

type MyPartial<T> = {
  [K in keyof T]?: T[K];
};

使用示例:

type User = {
  id: string;
  name: string;
  age: number;
};

type PartialUser = Partial<User>;

等价于:

type PartialUser = {
  id?: string;
  name?: string;
  age?: number;
};

使用场景:更新部分字段。

function updateUser(id: string, patch: Partial<User>) {
  // 只更新传入的字段
}

updateUser("001", {
  name: "Jerry",
});

16.2 Required<T>

Required<T> 会把对象类型的所有属性变成必选。

简化源码:

type MyRequired<T> = {
  [K in keyof T]-?: T[K];
};

这里的 -? 表示移除可选标记。

使用示例:

type User = {
  id?: string;
  name?: string;
};

type RequiredUser = Required<User>;

等价于:

type RequiredUser = {
  id: string;
  name: string;
};

使用场景:配置合并后,内部使用完整配置。

type Config = {
  host?: string;
  port?: number;
};

const defaultConfig: Required<Config> = {
  host: "localhost",
  port: 3000,
};

function createServer(config: Config) {
  const finalConfig: Required<Config> = {
    ...defaultConfig,
    ...config,
  };

  finalConfig.host;
  finalConfig.port;
}

16.3 Readonly<T>

Readonly<T> 会把对象属性变成只读。

简化源码:

type MyReadonly<T> = {
  readonly [K in keyof T]: T[K];
};

使用示例:

type User = {
  id: string;
  name: string;
};

type ReadonlyUser = Readonly<User>;

等价于:

type ReadonlyUser = {
  readonly id: string;
  readonly name: string;
};

使用场景:防止函数内部修改传入对象。

function printUser(user: Readonly<User>) {
  console.log(user.name);

  user.name = "Jerry"; // 报错
}

注意:Readonly<T> 默认是浅只读。

type User = {
  profile: {
    age: number;
  };
};

const user: Readonly<User> = {
  profile: {
    age: 18,
  },
};

user.profile.age = 20; // 可以,因为 profile 内部不是 readonly

16.4 Pick<T, K>

Pick<T, K> 从对象类型中挑选部分属性。

简化源码:

type MyPick<T, K extends keyof T> = {
  [P in K]: T[P];
};

使用示例:

type User = {
  id: string;
  name: string;
  age: number;
  password: string;
};

type UserBaseInfo = Pick<User, "id" | "name">;

等价于:

type UserBaseInfo = {
  id: string;
  name: string;
};

使用场景:接口返回、组件 props、表格字段。

type UserCardProps = Pick<User, "id" | "name">;

function renderUserCard(user: UserCardProps) {
  return `${user.id} - ${user.name}`;
}

16.5 Omit<T, K>

Omit<T, K> 从对象类型中排除部分属性。

简化源码:

type MyOmit<T, K extends keyof any> = Pick<T, Exclude<keyof T, K>>;

拆开看:

Exclude<keyof T, K>

先从所有 key 里排除 K,再用 Pick 取剩下的字段。

使用示例:

type User = {
  id: string;
  name: string;
  password: string;
};

type PublicUser = Omit<User, "password">;

等价于:

type PublicUser = {
  id: string;
  name: string;
};

使用场景:隐藏敏感字段。

function toPublicUser(user: User): Omit<User, "password"> {
  const { password, ...rest } = user;
  return rest;
}

也常用于创建参数:

type CreateUserDto = Omit<User, "id">;

16.6 Record<K, T>

Record<K, T> 用来创建一个 key/value 对象类型。

简化源码:

type MyRecord<K extends keyof any, T> = {
  [P in K]: T;
};

keyof any 等价于:

string | number | symbol

使用示例:

type Role = "admin" | "user" | "guest";

type RoleMap = Record<Role, string>;

等价于:

type RoleMap = {
  admin: string;
  user: string;
  guest: string;
};

使用场景:枚举映射、状态映射、字典对象。

type Status = "pending" | "success" | "error";

const statusText: Record<Status, string> = {
  pending: "处理中",
  success: "成功",
  error: "失败",
};

这样如果少写一个 key,TS 会报错:

const statusText: Record<Status, string> = {
  pending: "处理中",
  success: "成功",
  // error 缺失,报错
};

16.7 Exclude<T, U>

Exclude<T, U> 从联合类型 T 中排除可以赋值给 U 的类型。

简化源码:

type MyExclude<T, U> = T extends U ? never : T;

使用示例:

type Status = "pending" | "success" | "error";

type NonErrorStatus = Exclude<Status, "error">;

结果:

type NonErrorStatus = "pending" | "success";

使用场景:从联合类型里排除某些值。

type EventName = "click" | "hover" | "focus";

type MouseEventName = Exclude<EventName, "focus">;
// "click" | "hover"

理解重点:

type MyExclude<T, U> = T extends U ? never : T;

T 是联合类型时,条件类型会自动分发:

Exclude<"a" | "b" | "c", "a">

相当于:

("a" extends "a" ? never : "a")
| ("b" extends "a" ? never : "b")
| ("c" extends "a" ? never : "c")

结果:

"b" | "c"

16.8 Extract<T, U>

Extract<T, U> 从联合类型 T 中提取可以赋值给 U 的类型。

简化源码:

type MyExtract<T, U> = T extends U ? T : never;

使用示例:

type Status = "pending" | "success" | "error";

type SuccessStatus = Extract<Status, "success" | "done">;

结果:

type SuccessStatus = "success";

使用场景:从联合类型中取交集。

type FrontendEvent = "click" | "hover" | "focus";
type SupportedEvent = "click" | "focus";

type AvailableEvent = Extract<FrontendEvent, SupportedEvent>;
// "click" | "focus"

16.9 NonNullable<T>

NonNullable<T> 从类型中排除 nullundefined

简化源码:

type MyNonNullable<T> = T extends null | undefined ? never : T;

使用示例:

type Value = string | number | null | undefined;

type SafeValue = NonNullable<Value>;

结果:

type SafeValue = string | number;

使用场景:处理已经判空后的类型。

function assertValue<T>(value: T): NonNullable<T> {
  if (value === null || value === undefined) {
    throw new Error("value is empty");
  }

  return value;
}

16.10 ReturnType<T>

ReturnType<T> 用来获取函数返回值类型。

简化源码:

type MyReturnType<T extends (...args: any[]) => any> =
  T extends (...args: any[]) => infer R ? R : never;

使用示例:

function getUser() {
  return {
    id: "001",
    name: "Tom",
  };
}

type User = ReturnType<typeof getUser>;

结果:

type User = {
  id: string;
  name: string;
};

使用场景:复用函数返回值类型,避免重复声明。

function createState() {
  return {
    count: 0,
    user: null as null | { id: string; name: string },
  };
}

type State = ReturnType<typeof createState>;

16.11 Parameters<T>

Parameters<T> 用来获取函数参数类型,结果是一个元组。

简化源码:

type MyParameters<T extends (...args: any[]) => any> =
  T extends (...args: infer P) => any ? P : never;

使用示例:

function createUser(id: string, age: number) {
  return { id, age };
}

type CreateUserParams = Parameters<typeof createUser>;

结果:

type CreateUserParams = [id: string, age: number];

使用场景:封装函数、转发参数。

function createUser(id: string, age: number) {
  return { id, age };
}

function wrapper(...args: Parameters<typeof createUser>) {
  return createUser(...args);
}

16.12 ConstructorParameters<T>

ConstructorParameters<T> 用来获取构造函数参数类型。

简化源码:

type MyConstructorParameters<T extends abstract new (...args: any[]) => any> =
  T extends abstract new (...args: infer P) => any ? P : never;

使用示例:

class User {
  constructor(public id: string, public name: string) {}
}

type UserConstructorParams = ConstructorParameters<typeof User>;

结果:

type UserConstructorParams = [id: string, name: string];

使用场景:工厂函数。

function createInstance<T extends abstract new (...args: any[]) => any>(
  Ctor: T,
  ...args: ConstructorParameters<T>
): InstanceType<T> {
  return new Ctor(...args);
}

如果遇到 abstract new 不好理解,可以先记:

new (...args: any[]) => any

表示构造函数类型。

16.13 InstanceType<T>

InstanceType<T> 用来获取构造函数创建出来的实例类型。

简化源码:

type MyInstanceType<T extends abstract new (...args: any[]) => any> =
  T extends abstract new (...args: any[]) => infer R ? R : never;

使用示例:

class User {
  id = "001";
  name = "Tom";
}

type UserInstance = InstanceType<typeof User>;

结果:

type UserInstance = User;

使用场景:根据 class 自动得到实例类型。

class Service {
  request() {}
}

type ServiceInstance = InstanceType<typeof Service>;

const service: ServiceInstance = new Service();

16.14 Awaited<T>

Awaited<T> 用来获取 Promise 最终 resolve 出来的类型。

真实源码更复杂,这里写一个简化版本:

type MyAwaited<T> = T extends Promise<infer R>
  ? MyAwaited<R>
  : T;

使用示例:

type A = Awaited<Promise<string>>;
// string

type B = Awaited<Promise<Promise<number>>>;
// number

使用场景:提取异步函数返回数据类型。

async function fetchUser() {
  return {
    id: "001",
    name: "Tom",
  };
}

type User = Awaited<ReturnType<typeof fetchUser>>;

拆开看:

ReturnType<typeof fetchUser>

得到:

Promise<{ id: string; name: string }>

再用:

Awaited<...>

得到:

{ id: string; name: string }

16.15 ReadonlyArray<T>

ReadonlyArray<T> 表示只读数组。

使用示例:

const nums: ReadonlyArray<number> = [1, 2, 3];

nums.push(4); // 报错
nums[0] = 10; // 报错

也可以写成:

const nums: readonly number[] = [1, 2, 3];

使用场景:函数不应该修改传入数组。

function sum(nums: readonly number[]) {
  return nums.reduce((total, item) => total + item, 0);
}

16.16 ThisParameterType<T>

ThisParameterType<T> 用来提取函数里的 this 参数类型。

简化源码:

type MyThisParameterType<T> =
  T extends (this: infer U, ...args: any[]) => any ? U : unknown;

使用示例:

function fn(this: { name: string }, age: number) {
  console.log(this.name, age);
}

type ThisTypeOfFn = ThisParameterType<typeof fn>;

结果:

type ThisTypeOfFn = {
  name: string;
};

使用场景:处理依赖 this 的老代码或库封装。

16.17 OmitThisParameter<T>

OmitThisParameter<T> 用来移除函数里的 this 参数。

function fn(this: { name: string }, age: number) {
  console.log(this.name, age);
}

type FnWithoutThis = OmitThisParameter<typeof fn>;

结果类似:

type FnWithoutThis = (age: number) => void;

使用场景:把依赖 this 的函数 bind 之后再使用。

const bound = fn.bind({ name: "Tom" });

const run: OmitThisParameter<typeof fn> = bound;

16.18 工具类型组合使用

工具类型真正强大的地方在于组合。

示例 1:提取异步函数返回数据。

async function getUser() {
  return {
    id: "001",
    name: "Tom",
  };
}

type User = Awaited<ReturnType<typeof getUser>>;

示例 2:创建接口入参类型。

type User = {
  id: string;
  name: string;
  password: string;
  createdAt: string;
};

type CreateUserDto = Omit<User, "id" | "createdAt">;

结果:

type CreateUserDto = {
  name: string;
  password: string;
};

示例 3:更新接口入参。

type UpdateUserDto = Partial<Omit<User, "id" | "createdAt">>;

结果:

type UpdateUserDto = {
  name?: string;
  password?: string;
};

示例 4:状态映射。

type Status = "pending" | "success" | "error";

const statusText: Record<Status, string> = {
  pending: "处理中",
  success: "成功",
  error: "失败",
};

示例 5:从联合类型中排除某些状态。

type Status = "pending" | "success" | "error" | "cancelled";

type ActiveStatus = Exclude<Status, "cancelled">;

16.19 小结

常用工具类型可以按用途分类记忆。

对象属性处理:

Partial<T>
Required<T>
Readonly<T>
Pick<T, K>
Omit<T, K>
Record<K, T>

联合类型处理:

Exclude<T, U>
Extract<T, U>
NonNullable<T>

函数类型处理:

ReturnType<T>
Parameters<T>
ThisParameterType<T>
OmitThisParameter<T>

构造函数处理:

ConstructorParameters<T>
InstanceType<T>

异步类型处理:

Awaited<T>

它们背后的核心能力其实就几个:

keyof
in
extends
infer
映射类型
条件类型
联合类型分发

掌握这些底层能力后,工具类型就不是黑盒了。

17. 实战建议

写 TS 时可以记住这些习惯:

  • 不确定类型时,优先用 unknown,少用 any
  • 对外部数据,比如接口返回值,不要盲信类型。
  • 描述动态对象时,优先考虑 [key: string]: unknown
  • 对普通对象可以用 Record<string, unknown>,但要理解它不是所有对象。
  • 表达多种状态时,用联合类型。
  • 表达对象合并时,用交叉类型。
  • 参数和返回值有类型关系时,用泛型。
  • 不同参数组合对应不同返回类型时,用函数重载。
  • 从复杂类型里提取子类型时,用 infer
  • extends 不只表示继承,更常用于泛型约束和条件类型判断。

18. 总结

这篇文章里,我们围绕 TS 类型系统梳理了这些重点:

[key: string]: unknown

用于描述动态属性。

"name" in obj

用于属性判断和类型收窄。

T extends SomeType

用于泛型约束。

T extends U ? X : Y

用于条件类型判断。

value is SomeType

用于自定义类型守卫。

A | B

表示联合类型,满足其中一种。

A & B

表示交叉类型,同时满足多种。

function identity<T>(value: T): T

表示泛型函数。

T extends Array<infer U> ? U : never

表示从数组中提取元素类型。

TypeScript 的难点不在于语法多,而在于要理解这些语法背后的共同目标:

用类型系统描述真实业务里的数据关系,让错误尽量在编译期暴露出来。

当你能熟练使用 extendskeyof、泛型、类型守卫、infer 这些工具时,就已经进入 TS 类型系统的核心区域了。

vxe-table 实现数据分组统计与表尾合计

在数据报表或管理系统中,经常需要按某个字段对数据进行分组,并计算每组的统计值(如求和、平均值),同时在表格底部显示所有数据的合计。vxe-table 提供了灵活的聚合配置 aggregateConfig 和自定义计算函数 calcValuesMethod,配合手动表尾 footerData,可以轻松实现这类需求。

本文将通过一个完整的示例,演示如何按角色(role)对数据进行分组,统计每个角色的 num 字段总和,并在表格尾部显示所有数据的 num 总计。

属性说明

配置项 / 属性 所在位置 作用
aggregateConfig.groupFields 表格配置(gridOptions) 指定分组字段,例如 ['role'],表格会按该字段对数据进行分组,并在每个分组后插入“分组合计行”
aggregateConfig.calcValuesMethod 表格配置 自定义分组统计值计算函数,接收当前分组的子数据(children)和列信息(column),返回统计值
aggFunc 列配置(columns) 标记该列需要参与聚合计算,配合分组统计使用
footerData 表格配置 手动指定表尾合计行的数据,适用于简单的总计、平均值等,更复杂时可配合 updateFooter 方法动态计算
rowGroupNode 列配置 标识该列作为分组的显示节点,通常设置为 true,使分组后的行可以展开/折叠(示例中使用)

分组聚合需要同时设置 aggregateConfig.groupFields 和至少一列的 rowGroupNode: true,同时要聚合的列必须设置 aggFunc: true(或自定义聚合函数)。

代码

image

<template>
  <div>
    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

// 表尾合计行数据(手动维护总计)
const footerSummaryRow = reactive({
  seq: '总计',
  num: 0
})

const gridOptions = reactive({
  border: true,
  showOverflow: true,       // 超出显示省略号
  showFooter: true,         // 开启表尾
  height: 500,
  loading: false,

  // 分组聚合配置
  aggregateConfig: {
    groupFields: ['role'],   // 按角色字段分组
    // 自定义分组统计值的计算方法
    calcValuesMethod({ column, children }) {
      // 只对 num 列进行求和
      if (column.field === 'num') {
        let sum = 0
        children.forEach(item => {
          sum += item.num
        })
        return sum
      }
      // 其他列不统计,返回 0 或空字符串
      return 0
    }
  },

  // 列定义
  columns: [
    { type: 'seq', width: 70, title: '序号' },
    { field: 'name', title: '姓名', minWidth: 150, rowGroupNode: true },  // 分组显示节点
    { field: 'role', title: '角色', width: 120 },
    { field: 'num', title: '数量', width: 100, aggFunc: true },           // 参与聚合
    { field: 'age', title: '年龄', width: 80 },
    { field: 'address', title: '地址', minWidth: 200 }
  ],

  data: [],               // 动态加载的数据
  footerData: [footerSummaryRow]   // 表尾数据
})

// 模拟异步加载数据,并计算表尾总计
const loadData = () => {
  gridOptions.loading = true
  setTimeout(() => {
    const rawData = [
      { id: 10001, name: 'Test1', role: 'Develop', sex: 'Woman', age: 28, num: 63, date: '2025-02-01', address: 'test abc' },
      { id: 10002, name: 'Test2', role: 'Test', sex: 'Man', age: 22, num: 63, date: '2025-01-01', address: 'Guangzhou' },
      { id: 10003, name: 'Test3', role: 'PM', sex: 'Woman', age: 32, num: 10, date: '2025-05-01', address: 'Shanghai' },
      { id: 10004, name: 'Test4', role: 'Designer', sex: 'Man', age: 32, num: 24, date: '2025-01-01', address: 'test abc' },
      { id: 10005, name: 'Test5', role: 'Develop', sex: 'Man', age: 30, num: 98, date: '2025-01-01', address: 'Shanghai' },
      { id: 10006, name: 'Test6', role: 'Designer', sex: 'Man', age: 30, num: 46, date: '2025-03-01', address: 'test abc' },
      { id: 10007, name: 'Test7', role: 'Test', sex: 'Woman', age: 29, num: 35, date: '2025-05-01', address: 'test abc' },
      { id: 10008, name: 'Test8', role: 'PM', sex: 'Woman', age: 35, num: 84, date: '2025-11-01', address: 'test abc' },
      { id: 10009, name: 'Test9', role: 'Test', sex: 'Man', age: 21, num: 25, date: '2025-05-01', address: 'test abc' },
      { id: 10010, name: 'Test10', role: 'PM', sex: 'Woman', age: 28, num: 32, date: '2025-03-01', address: 'test abc' },
      { id: 10011, name: 'Test11', role: 'Test', sex: 'Woman', age: 29, num: 24, date: '2025-03-01', address: 'test abc' },
      { id: 10012, name: 'Test12', role: 'Develop', sex: 'Man', age: 37, num: 28, date: '2025-10-01', address: 'test abc' },
      { id: 10013, name: 'Test13', role: 'Test', sex: 'Woman', age: 24, num: 38, date: '2025-02-01', address: 'test abc' },
      { id: 10014, name: 'Test14', role: 'Develop', sex: 'Man', age: 34, num: 46, date: '2025-08-01', address: 'test abc' },
      { id: 10015, name: 'Test15', role: 'Designer', sex: 'Man', age: 21, num: 48, date: '2025-05-01', address: 'test abc' },
      { id: 10016, name: 'Test16', role: 'Designer', sex: 'Woman', age: 21, num: 59, date: '2025-10-01', address: 'test abc' },
      { id: 10017, name: 'Test17', role: 'Test', sex: 'Man', age: 31, num: 81, date: '2025-12-01', address: 'test abc' },
      { id: 10018, name: 'Test18', role: 'Develop', sex: 'Woman', age: 32, num: 75, date: '2025-10-01', address: 'test abc' },
      { id: 10019, name: 'Test19', role: 'Test', sex: 'Man', age: 37, num: 80, date: '2025-02-01', address: 'test abc' },
      { id: 10020, name: 'Test20', role: 'Develop', sex: 'Man', age: 41, num: 60, date: '2025-03-01', address: 'test abc' }
    ]

    // 计算总计(num 总和)
    let totalNum = 0
    rawData.forEach(row => {
      totalNum += row.num
    })
    footerSummaryRow.num = totalNum

    gridOptions.data = rawData
    gridOptions.loading = false
  }, 200)
}

// 调用加载
loadData()
</script>

实现思路

  • 分组显示:表格会按 role 字段将数据分组,同一角色的数据连续排列,并在每个分组前显示一个可展开/折叠的分组行(分组标题行)。
  • 分组统计行:在每个分组的末尾,自动插入一行“分组合计”,其中 num 列显示该组内所有 num 值的总和(由 * calcValuesMethod 自定义计算)。 表尾总计:表格底部固定一行(footerData),显示所有数据的 num 总计。该总计需要手动从数据源计算并赋值。

vxe-table 通过 aggregateConfig 和 aggFunc 提供了强大的分组聚合能力,结合自定义计算函数 calcValuesMethod 可以满足任意复杂的统计需求。而表尾合计通过 footerData 手动维护,灵活直观。两者配合使用,能够快速搭建具有分组统计和总计功能的专业数据表格。

vxetable.cn

❌