普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月2日掘金 前端
昨天 — 2025年11月1日掘金 前端

Postman 平替?这款轻量接口测试工具,本地运行 + 批量回归超实用!

作者 BestAns
2025年10月31日 22:06

Postman 平替?这款轻量接口测试工具,本地运行 + 批量回归超实用!

日常做接口测试时,你是不是也有过这样的困扰:用 Postman、Apifox 这类工具,功能太多反而找不到重点,想快速做迭代回归测试却步骤繁琐?今天给大家推荐一款「轻量化」的跨平台 RESTful 接口测试工具 ——restful-api-test,基于 Electron + Vue3 开发,聚焦核心需求,让我们的接口测试更高效!

为什么选它?4 大核心优势直击痛点

市面上接口测试工具不少,但 restful-api-test 偏偏走出了不一样的路线:它砍掉了冗余功能,只保留最实用的核心能力,尤其适合需要快速验证、本地隔离环境测试的场景。

  • 批量执行 + 结果汇总:一次跑多个接口,结果直观呈现,迭代回归测试效率直接拉满;
  • 结构化测试用例:把接口测试流程沉淀成用例,支持重复运行,还能做版本化管理;
  • 灵活断言表达式:支持自定义 JavaScript 布尔表达式,复杂校验需求也能满足;
  • 本地跨平台运行:无需依赖云端,边缘环境、隔离网络下也能正常使用,数据更安全。

界面总览:3 部分布局,上手无门槛

先看整体界面,restful-api-test 的布局非常简洁,主要分为 3 个部分,即使是新手也能快速熟悉:

  • 顶部:标题栏,清晰展示当前操作模块;
  • 左侧:测试用例列表,所有用例集中管理,一目了然;
  • 右侧:单接口测试视图 + 批量测试视图,按需切换,操作聚焦。

整体布局.png

核心功能实操:测试用例全流程管理

接下来带大家一步步看看,如何用 restful-api-test 管理测试用例,从导入到执行、导出,全流程都很丝滑~

1. 导入测试用例:JSON 格式,字段清晰

想添加新的测试用例,只需点击左侧列表上方的「导入测试用例」按钮即可。不过要注意,导入的用例必须是 JSON 格式,且包含 3 个必填字段:

  • name:测试用例名称,会显示在左侧列表中;
  • description:用例描述,说明用例的作用和覆盖场景;
  • apis:接口数组,每个接口包含name(接口名)、url(接口地址)、method(请求方法)等必填项,还有headers、params、body、expectedExpression(断言表达式)等可选字段。

导入测试用例.gif

给大家放一个完整的测试用例示例,直接复制修改就能用:

{
  "name": "帖子管理示例接口",
  "description": "通用资源的模拟接口,覆盖 Posts 常用操作。",
  "apis": [
    {
      "name": "获取所有帖子",
      "url": "https://jsonplaceholder.typicode.com/posts",
      "method": "GET",
      "headers": {
        "Accept": "application/json"
      },
      "expectedExpression": "($.status === 200) && Array.isArray($.data) && $.data.length > 0"
    },
    {
      "name": "获取指定帖子 (id=1)",
      "url": "https://jsonplaceholder.typicode.com/posts/1",
      "method": "GET",
      "headers": {
        "Accept": "application/json"
      },
      "expectedExpression": "($.status === 200) && $.data.id === 1"
    }
  ]
}

2. 编辑 / 删除:用例管理更灵活

导入的用例如果需要修改名称或描述,点击左侧列表的「编辑测试用例」按钮就能进入编辑模式;如果某个用例不再需要,点击删除按钮就能快速清理,操作很直观。

删除测试用例:

删除测试用例.gif 编辑测试用例:

编辑测试用例.gif

3. 批量执行:一次跑多个用例,结果秒出

做回归测试时,批量执行功能简直是刚需!两种方式可以触发批量测试:

  • 直接点击测试用例列表的「批量测试」按钮;
  • 选中用例后,在右侧批量测试视图点击「运行所有测试用例」按钮。

执行完成后,会清晰展示每个用例的测试结果,通过率一目了然,省去了逐个执行的麻烦。

批量执行测试用例.gif

4. 导出用例:版本管理 + 分享更方便

编辑后的测试用例,点击左侧列表的「导出测试用例」按钮,就能导出成 JSON 文件。这样一来,不仅方便做版本控制(比如用 Git 管理用例版本),还能轻松分享给团队成员,协作更高效。

导出测试用例.gif

单接口测试:细节拉满,满足复杂需求

一个测试用例通常包含多个接口,选中左侧列表中的具体接口,就能进入单接口测试视图。这个视图由 4 部分组成:顶部操作栏、请求详情、请求头、请求参数 / 请求体,每个部分都考虑到了实际测试场景的需求。

接口测试用例的管理.png

重点:预期断言表达式,精准校验结果

判断接口测试是否通过,关键就在「预期表达式」的配置。restful-api-test 支持标准 JavaScript 布尔表达式,运行时通过new Function('$', "use strict"; return !!(expr))执行,其中$是上下文对象,包含 5 个核心字段:

  • $.status:HTTP 状态码(如 200、404);
  • $.data:接口响应体(通常是 JSON 格式);
  • $.headers:响应头信息;
  • $.request:请求信息(含 url、method、headers、body);
  • $.response:完整响应对象,包含status、data、headers。

给大家举几个常用的表达式示例,方便参考:

  1. 校验响应结构和长度:
($.status === 200) && Array.isArray($.data.items) && $.data.items.length > 0
  1. 校验字段值和类型:
$.data.ok === true && typeof $.data.total === 'number' && $.data.total >= 10
  1. 校验响应头:
$.headers['content-type']?.includes('application/json')
  1. 校验回显参数:
Boolean($.response.data.args?.startTime && $.response.data.args?.endTime)

只要是 JavaScript 支持的判断逻辑和方法,都能写进表达式,比如Array.isArray()typeofincludes()等,灵活应对各种复杂的校验场景。

其他配置:URL、请求头、请求参数、请求方法、请求消息体配置

除了断言表达式,单接口视图还支持配置接口 URL、请求头(比如设置 Authorization、Content-Type)、URL 参数(GET 请求常用)、请求体(POST/PUT 请求传参,默认 JSON 格式),满足不同接口的请求需求。

资源获取:一键下载,快速上手

看完功能介绍,如果你想立刻体验这款工具,可以通过以下链接获取资源:

最后:期待你的支持!

这款工具目前处于持续迭代中,如果你在使用过程中觉得它帮你解决了实际问题,或者有新的功能需求,欢迎到 GitHub 仓库给项目点一个「Star」🌟!你的支持是我持续优化工具的最大动力,也能让更多有需要的人发现这款实用的接口测试工具~

如果遇到使用问题,也可以在仓库的 Issues 区留言,我会及时回复并修复,一起让这款工具变得更完善!

更多精彩文章,欢迎关注我的公众号:前端架构师笔记

Flutter疑难解决:单独让某个页面的电池栏标签颜色改变

作者 SoaringHeart
2025年10月31日 21:27

一、需求来源

最近项目适配研深色和浅色的功能,某些情况下整个页面顶部是深色图片背景,需要再浅色模式下,电池栏颜色 icon 颜色为白色(浅色模式下一般是黑色)。

跳转页面逻辑:A(黑)->B(白)->C(黑)。

实现思路:

1、AnnotatedRegion

存在问题:在demo项目里正常;但是进入有三百多个页面的项目,它改变的使整个app的电池栏icon 颜色。

2、No Choice(但最终完美实现需求):

SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);

二、使用示例

1、main.dart

navigatorObservers: [
  RouteManagerObserver(),
],

2、PageOne - B(白)

class _PageOneState extends State<PageOne> with CurrentOverlayStyleMixin {
  final scrollController = ScrollController();

  @override
  void initState() {
    super.initState();

    RouteManager().isDebug = false;
    WidgetsBinding.instance.addPostFrameCallback((_) {
      Future.delayed(Duration(milliseconds: 300), () {
        //在网络数据加载出来之后调用此方法
        currentOverlayStyleRoutePush();
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.green,
      body: buildBody(),
    );
  }

  Widget buildBody() {
    return Scrollbar(
      controller: scrollController,
      child: SingleChildScrollView(
        controller: scrollController,
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            SizedBox(height: 100),
            IconButton(onPressed: onBack, icon: Icon(Icons.arrow_back_ios_new)),
            OutlinedButton(onPressed: onNext, child: const Text("next")),
          ],
        ),
      ),
    );
  }

  Future<void> onBack() async {
    final result = AppNavigator.back();
  }

  Future<void> onNext() async {
    final result = await AppNavigator.toNamed(AppRouter.pageTwo);
    DLog.d(result);
  }

  @override
  SystemUiOverlayStyle get currentOverlayStyle => SystemUiOverlayStyle.dark;

  @override
  SystemUiOverlayStyle get otherOverlayStyle => SystemUiOverlayStyle.light;

  @override
  bool needOverlayStyleChanged({Route? from, Route? to}) {
    final fromName = from?.settings.name;
    final toName = to?.settings.name;
    DLog.d([fromName, toName].join(" >>> "));
    final result = (toName == AppRouter.pageOne);
    return result;
  }
}

三、源码

1、首先需要 RouteManager 路由管理类

/// 路由堆栈管理器
class RouteManager {
  static final RouteManager _instance = RouteManager._();
  RouteManager._();
  factory RouteManager() => _instance;
  static RouteManager get instance => _instance;

  /// 是否打印日志
  bool isDebug = false;

  /// 监听(跳转前)列表
  final List<void Function({Route? from, Route? to})> _beforelisteners = [];

  // 添加监听
  void addRouteBeforeListener(void Function({Route? from, Route? to}) cb) {
    if (_beforelisteners.contains(cb)) {
      return;
    }
    _beforelisteners.add(cb);
  }

  // 移除监听
  void removeRouteBeforeListener(void Function({Route? from, Route? to}) cb) {
    _beforelisteners.remove(cb);
  }

  /// 通知所有监听器
  void notifyRouteBeforeListeners({required Route? from, required Route? to}) {
    for (var ltr in _beforelisteners) {
      ltr(from: from, to: to);
    }
  }

  /// 监听列表
  final List<void Function({Route? from, Route? to})> _listeners = [];

  // 添加监听
  void addListener(void Function({Route? from, Route? to}) cb) {
    if (_listeners.contains(cb)) {
      return;
    }
    _listeners.add(cb);
  }

  // 移除监听
  void removeListener(void Function({Route? from, Route? to}) cb) {
    _listeners.remove(cb);
  }

  /// 通知所有监听器
  void notifyListeners({required Route? from, required Route? to}) {
    for (var ltr in _listeners) {
      ltr(from: from, to: to);
    }
  }

  /// 所有路由堆栈
  final List<Route<Object?>> _routes = [];

  /// 当前路由堆栈
  List<Route<Object?>> get routes => _routes;

  /// 当前 PageRoute 路由堆栈
  List<PageRoute<Object?>> get pageRoutes => _routes.whereType<PageRoute>().toList();

  /// 当前 DialogRoute 路由堆栈
  List<RawDialogRoute<Object?>> get dialogRoutes => _routes.whereType<RawDialogRoute>().toList();

  /// 当前 ModalBottomSheetRoute 路由堆栈
  List<ModalBottomSheetRoute<Object?>> get sheetRoutes => _routes.whereType<ModalBottomSheetRoute>().toList();

  /// 当前路由名堆栈
  List<String?> get routeNames => routes.map((e) => e.settings.name).toList();

  /// 之前路由
  Route<Object?>? get preRoute => _preRoute;

  /// 之前路由
  Route<Object?>? _preRoute;

  /// 之前路由 name
  String? get preRouteName => preRoute?.settings.name;

  /// 当前路由
  Route<Object?>? get currentRoute => routes.isEmpty ? null : routes.last;

  /// 当前路由 name
  String? get currentRouteName => currentRoute?.settings.name;

  /// 最近的 PopupRoute 类型路由
  PopupRoute? get popupRoute {
    for (int i = routes.length - 1; i >= 0; i--) {
      final e = routes[i];
      if (e is PopupRoute) {
        return e;
      }
    }
    return null;
  }

  /// 当前路由类型是 PopupRoute
  bool get isPopupOpen => popupRoute != null;

  /// 路由堆栈包含 DialogRoute 类型
  bool get isDialogOpen => popupRoute is DialogRoute;

  /// 路由堆栈包含 ModalBottomSheetRoute 类型
  bool get isSheetOpen => popupRoute is ModalBottomSheetRoute;

  /// 是否存在路由堆栈中
  bool contain(String routeName) {
    return routeNames.contains(routeName);
  }

  /// 路由对应的参数
  Object? getArguments(String routeName) {
    final index = pageRoutes.indexWhere((e) => e.settings.name == routeName);
    if (index == -1) {
      return null;
    }
    final route = pageRoutes[index];
    return route;
  }

  /// 入栈
  void push(Route<dynamic> route) {
    if (_routes.isEmpty || _routes.isNotEmpty && _routes.last != route) {
      _routes.add(route);
    }
  }

  /// 出栈
  void pop(Route<dynamic> route) {
    _routes.remove(route);
  }

  Map<String, dynamic> toJson() {
    final data = <String, dynamic>{};
    data['isDebug'] = isDebug;
    data['routes'] = routes.map((e) => e.toString()).toList();
    data['pageRoutes'] = pageRoutes.map((e) => e.toString()).toList();
    if (dialogRoutes.isNotEmpty) {
      data['dialogRoutes'] = dialogRoutes.map((e) => e.toString()).toList();
    }
    if (sheetRoutes.isNotEmpty) {
      data['sheetRoutes'] = sheetRoutes.map((e) => e.toString()).toList();
    }
    data['routeNames'] = routeNames;
    data['preRoute'] = preRoute.toString();
    data['preRouteName'] = preRouteName;
    data['currentRouteName'] = currentRouteName;
    data['popupRoute'] = popupRoute.toString();
    data['isPopupOpen'] = isPopupOpen;
    data['isDialogOpen'] = isDialogOpen;
    data['isSheetOpen'] = isSheetOpen;
    return data;
  }

  @override
  String toString() {
    const encoder = JsonEncoder.withIndent('  ');
    final descption = encoder.convert(toJson());
    return "$runtimeType: $descption";
  }

  void logRoutes() {
    if (!isDebug) {
      return;
    }

    developer.log(toString());
  }
}

/// 堆栈管理器路由监听器
class RouteManagerObserver extends RouteObserver<PageRoute<dynamic>> {
  @override
  void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
    RouteManager()._preRoute = previousRoute;
    RouteManager().notifyRouteBeforeListeners(from: previousRoute, to: route);

    super.didPush(route, previousRoute);
    RouteManager().push(route);

    RouteManager().notifyListeners(from: previousRoute, to: route);
    RouteManager().logRoutes();
  }

  @override
  void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
    RouteManager()._preRoute = route;
    RouteManager().notifyRouteBeforeListeners(from: route, to: previousRoute);

    super.didPop(route, previousRoute);
    RouteManager().pop(route);

    RouteManager().notifyListeners(from: route, to: previousRoute);
    RouteManager().logRoutes();
  }

  @override
  void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
    RouteManager()._preRoute = oldRoute;
    RouteManager().notifyRouteBeforeListeners(from: oldRoute, to: newRoute);

    super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
    if (oldRoute != null) RouteManager().pop(oldRoute);
    if (newRoute != null) RouteManager().push(newRoute);

    RouteManager().notifyListeners(from: oldRoute, to: newRoute);
    RouteManager().logRoutes();
  }

  @override
  void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
    RouteManager()._preRoute = route;
    RouteManager().notifyRouteBeforeListeners(from: previousRoute, to: route);

    super.didRemove(route, previousRoute);
    RouteManager().pop(route);

    RouteManager().notifyListeners(from: previousRoute, to: route);
    RouteManager().logRoutes();
  }
}

2、CurrentOverlayStyleMixin

/// 路由监听 mixin
mixin CurrentOverlayStyleMixin<T extends StatefulWidget> on State<T> {
  @protected
  SystemUiOverlayStyle get currentOverlayStyle;

  @protected
  SystemUiOverlayStyle get otherOverlayStyle;

  @protected
  bool needOverlayStyleChanged({Route? from, Route? to}) {
    throw UnimplementedError("❌$this Not implemented needOverlayStyleChanged");
  }

  @override
  void dispose() {
    RouteManager().removeListener(_onRouteListener);
    super.dispose();
  }

  @override
  void initState() {
    super.initState();
    RouteManager().addListener(_onRouteListener);
  }

  void _onRouteListener({Route? from, Route? to}) {
    final fromName = from?.settings.name;
    final toName = to?.settings.name;
    // DLog.d([fromName, toName].join(" >>> "));
    final needChange = needOverlayStyleChanged(from: from, to: to);
    // DLog.d([fromName, toName, needChange].join(" >>> "));
    if (needChange) {
      _onChange(style: currentOverlayStyle); //需要延迟,等UI走完,防止效果被覆盖
    } else {
      _onChange(style: otherOverlayStyle, duration: Duration.zero);
    }
  }

  Future<void> _onChange({
    Duration duration = const Duration(milliseconds: 300),
    required SystemUiOverlayStyle style,
  }) async {
    if (duration == Duration.zero) {
      SystemChrome.setSystemUIOverlayStyle(style);
    } else {
      Future.delayed(duration, () {
        SystemChrome.setSystemUIOverlayStyle(style);
      });
    }
    DLog.d("$this _onChange ${style.statusBarBrightness?.name}");
  }

  /// 电池栏状态修改(push 到新页面回调)
  void currentOverlayStyleRoutePush() {
    Route? from = RouteManager().pageRoutes[RouteManager().pageRoutes.length - 2];
    Route? to = RouteManager().pageRoutes.last;
    _onRouteListener(from: from, to: to);
  }
}

最后、总结

1、总体思路是在页面(请求网络数据成功)刷新出来之后,再改变当前 B 页面电池栏样式(注意别被样式覆盖)。当前页面 push 或者 pop 之后,恢复电池栏样式。所以需要自己实现路由监听。

2、谁能告诉我单个页面中的 AnnotatedRegion 为什么会影响到整个App的电池栏样式?不胜感激

“不卷 AI、不碰币、下班不收消息”——Android 知名技术大牛 Jake Wharton 的求职价值观

作者 张拭心
2025年10月31日 21:27

最近 Jake Wharton(Android 世界最知名的贡献者之一)在个人网站(jakewharton.com/hire/) 声明开始看工作机会:

"I'm currently looking for opportunities!"

他的求职价值观非常不一样,使得我忍不住转给大家。

Jake Wharton 是谁

如果你不知道 Jake Wharton 是谁,说明你可能是个 Android 开发小白或者路人hh。

他是 Android 圈非常知名的开源代码贡献者,创造了非常多广为使用的库。

image.png

他的经历:

  • 2012 年就加入 Square,主导 Retrofit、OkHttp、Picasso、Moshi 等底层库,国内几乎每款 App 都在间接调用他的代码
  • 2017 年转岗 Google,把 Kotlin 推向官方第一语言,并创建 KTX 系列扩展
  • 2020 年至今在 Cash App 设计跨平台方案,同时维护 Redwood、Zipline、Molecule 等开源项目

一句话:Android App 里的每一行网络请求、每一次 JSON 解析,背后都可能有他的影子。

完整内容截图

image.png

总结

对被迫卷 AI 代码率指标/下班后还得处理工作/失业后去做 Web 3 程序员们来说,Jake Wharton 的这份"反向招聘"是一声响亮的呐喊——先衡量公司,再衡量代码

愿我们都有这样的底气,在下一次求职时能把价值观说出口,把离线权握在手。


这就是流量的力量吗?用豆包 AI 编程做的小红书小组件帖子爆了

2025 上半年头部 AI 产品都有哪些?还有哪些新起之秀?

拭心 7 月日复盘|个体在 AI 时代的挑战

2025 年 AI 的落地程度远比我们想象的广

Lua世界的基石:变量、作用域与七大数据类型

作者 烛阴
2025年10月31日 21:26

一、变量与作用域:local 的重要性

在 Lua 中,变量不需要声明就可以直接赋值使用。但这样做会创建一个全局变量(Global Variable),这通常不是我们想要的结果。

全局变量的陷阱:

  • 命名冲突:在大型项目中,不同模块可能会无意中覆盖同名的全局变量,导致难以追踪的 Bug。
  • 性能问题:Lua 访问局部变量比访问全局变量更快。
  • 内存泄漏:全局变量除非被显式设为 nil,否则不会被垃圾回收器回收。

local 创建的变量是局部变量(Local Variable),其作用域(Scope)仅限于它所在的代码块内。

-- 这是全局变量,应避免
g_name = "Global Alice"

local function my_function()
    -- 这是局部变量,作用域仅在此函数内
    local l_name = "Local Bob"
    print(g_name) -- 可以访问外部的全局变量

    if true then
        -- 这是一个新的代码块,'l_name' 依然可见
        local block_var = "I'm in a block"
        print(l_name)
    end
  
    -- print(block_var) -- 错误!'block_var' 在这里已经超出了作用域
end

my_function()
-- print(l_name) -- 错误!'l_name' 在函数外部不可见

多重赋值(Multiple Assignment) Lua 还支持一种非常方便的语法,可以同时给多个变量赋值:

local x, y = 10, 20
print(x, y) -- 输出: 10   20

-- 常用于交换变量值
x, y = y, x
print(x, y) -- 输出: 20   10

二、Lua 的七大数据类型

Lua 的所有值(Value)都带有类型。它内置了 8 种基础类型,你可以使用 type() 函数来查看任何一个值的类型。

1. nil(空)

nil 是一个非常特殊的类型,它只有一个值,就是 nil

  • 一个未赋值的全局变量,其默认值就是 nil
  • 将一个全局变量赋值为 nil 是删除它的唯一方法,这会触发垃圾回收。
local a
print(type(a)) -- 输出: nil

local t = { key = "value" }
t.key = nil -- 从 table 中移除了这个键值对

2. boolean(布尔)

布尔类型只有两个值:truefalse

重要:在 Lua 的条件判断中,只有 falsenil被视为“假”,其他所有值(包括数字 0 和空字符串 "")都视为“真”

if 0 then print("0 is true") end -- 会被打印
if "" then print("Empty string is true") end -- 会被打印

3. number(数字)

在 Lua 5.2 及之前的版本中,number 类型统一表示为双精度浮点数。从 Lua 5.3 开始,引入了对整数(integer)和浮点数(float)的区分,但这在后台自动处理,对开发者来说通常是透明的。

local num1 = 10     -- 整数
local num2 = 3.14   -- 浮点数
local num3 = 1e3    -- 科学记数法, 等于 1000
print(type(num1)) -- 输出: number

4. string(字符串)

Lua 的字符串是不可变的(Immutable)字节序列。你可以用单引号 ' 或双引号 " 来创建。

  • 它们之间没有区别。
  • 字符串一旦创建,就不能修改其中的某个字符,任何操作(如拼接 ..)都会返回一个新的字符串。
local greeting = "Hello"
local name = 'Lua'
local message = greeting .. ", " .. name .. "!" -- 拼接
print(message) -- 输出: Hello, Lua!

-- 长字符串/多行字符串
local html = [[
<html>
    <head><title>Lua</title></head>
    <body>Hello World!</body>
</html>
]]

5. table(表)

这是 Lua 最核心、最强大的数据类型。table 是一种关联数组,它可以用来实现普通数组、字典(哈希映射)、集合、记录、命名空间,甚至是面向对象中的“对象”。

-- 作为数组 (索引从 1 开始)
local arr = { "apple", "banana", "orange" }
print(arr[1]) -- 输出: apple

-- 作为字典 (key-value pairs)
local person = {
    name = "David",
    age = 30,
    ["is_student"] = false -- key 也可以是字符串
}
print(person.name)     -- 点号语法糖
print(person["age"])   -- 方括号语法

-- 混合使用
local mixed = { 10, 20, x = "hello", y = "world" }
print(mixed[1], mixed.x) -- 输出: 10   hello

6. function(函数)

在 Lua 中,函数是“一等公民”,这意味着函数本身也是一种数据类型。你可以像操作其他值一样操作它们:存入变量、放入表中、作为参数传递或作为返回值。

local function say(msg)
    print(msg)
end

local greet = say -- 将函数赋值给变量
greet("Hi there!") -- 调用它

7. userdata(用户数据)

userdata 类型允许 C 代码将自定义的、任意的 C 数据存储在 Lua 变量中。这通常用于将 Lua 与 C/C++ 库进行绑定,在纯 Lua 代码中我们很少直接创建它。

结语

点个赞,关注我获取更多实用 Lua 技术干货!如果觉得有用,记得收藏本文!

昨天以前掘金 前端

前端小彩蛋:纯 CSS 实现情侣小球互动,甜到齁的代码我扒下来了

2025年10月31日 17:56

用纯 CSS 实现超甜互动动画:两个小球的 "亲吻" 瞬间

大家好,今天给大家分享一个用纯 CSS 实现的趣味互动动画 —— 两个卡通小球从对视到亲吻的甜蜜过程。整个动画不需要一行 JavaScript,仅通过 CSS 关键帧和变换属性就能实现流畅自然的互动效果。话不多说,先看看最终效果(建议配合代码食用):

效果预览

两个白色小球(我们暂且叫它们 "女主" 和 "男主")会周期性地完成这样一套动作:

  • 女主先羞涩地向男主靠近一点
  • 男主主动前倾,做出亲吻动作
  • 亲吻瞬间会出现可爱的亲吻符号
  • 之后两者恢复原位,等待下一次互动

image.png

实现思路拆解

这个动画的核心是通过CSS @keyframes定义多组动画,再通过时间线协调让不同元素的动作配合起来。我们可以分三个部分来理解实现过程:

1. 基础布局与角色设计

首先需要搭建基础结构,用两个div.ball作为两个小球的主体,再通过子元素实现面部特征(眼睛、嘴巴、腮红)。

html

预览

<div class="container">
  <!-- 女主 -->
  <div class="ball" id="l-ball">
    <div class="face face-l">
      <div class="eye eye-l"></div>
      <div class="eye eye-r"></div>
      <div class="mouth"></div>
    </div>
  </div>
  <!-- 男主 -->
  <div class="ball" id="r-ball">
    <div class="face face-r">
      <!-- 面部特征 -->
    </div>
  </div>
</div>

CSS 中通过border-radius: 50%实现圆形小球,用定位属性调整面部元素的位置:

css

.ball {
  background-color: white;
  border: 8px solid;
  width: 100px;
  height: 100px;
  border-radius: 50%;
  display: inline-block;
  position: relative; /* 让子元素可以绝对定位 */
}

.face {
  width: 70px;
  height: 30px;
  position: absolute; /* 相对于父元素定位 */
  right: 0;
  top: 30px;
}

2. 面部细节:用伪元素和简单形状构建表情

面部特征的实现很巧妙,尤其是腮红和眼睛:

  • 腮红:用::before::after伪元素实现,避免增加多余 HTML 标签

    css

    .face::after, .face::before {
      content: ""; /* 伪元素必须有content */
      position: absolute;
      width: 18px;
      height: 8px;
      background-color: #badc58; /* 可爱的粉色腮红 */
      top: 20px;
      border-radius: 50%;
    }
    
  • 眼睛:通过边框实现简单的眼部形状,男主和女主的眼睛有细微区别(男主是上边框,女主是下边框)

    css

    .eye {
      width: 15px;
      height: 14px;
      border-radius: 50%;
      border-bottom: 5px solid; /* 女主眼睛是下边框 */
      position: absolute;
    }
    .eye-r-p {
      border-top: 5px solid; /* 男主眼睛是上边框 */
      border-bottom: 0;
    }
    
  • 嘴巴:用圆角边框实现微笑形状,亲吻时会隐藏

    css

    .mouth {
      width: 30px;
      height: 14px;
      border-radius: 50%;
      border-bottom: 5px solid;
      position: absolute;
      bottom: -5px;
      margin: auto; /* 水平居中 */
    }
    

3. 动画核心:多组关键帧的时间线配合

这是整个效果的灵魂 —— 让两个角色的动作 "有来有回",形成互动感。我们定义了 4 组关键帧动画,通过控制不同时间段的动作来实现协调效果:

女主的动画(l-ball)

css

@keyframes close {
  0% { transform: translate(0); }
  20% { transform: translate(20px); } /* 向男主靠近 */
  35% { transform: translate(20px); } /* 停留一会儿 */
  55% { transform: translate(0px); } /* 回到原位 */
  100% { transform: translate(0px); }
}

同时配合面部的轻微转动,增加羞涩感:

css

@keyframes face {
  20% { transform: translate(5px) rotate(-2deg); } /* 微微转头 */
  35% { transform: translate(5px) rotate(-2deg); }
  /* 其他时间恢复原位 */
}
男主的动画(r-ball)

男主的动作更主动,包含移动和旋转:

css

@keyframes kiss {
  40% { transform: translate(0); }
  50% { transform: translate(30px) rotate(20deg); } /* 前倾靠近 */
  60% { transform: translate(-33px); } /* 靠近亲吻 */
  67% { transform: translate(-33px); } /* 停留 */
  77% { transform: translate(0px); } /* 回到原位 */
}
亲吻瞬间的细节处理

为了让亲吻更真实,我们做了两个细节:

  1. 男主嘴巴隐藏(opacity: 0
  2. 出现亲吻符号(kiss-m元素显示)

css

/* 嘴巴隐藏动画 */
@keyframes mouth-m {
  55% { opacity: 0; } /* 亲吻时隐藏 */
  66% { opacity: 0; }
  /* 其他时间显示 */
}

/* 亲吻符号显示动画 */
@keyframes kiss-m {
  55% { opacity: 0; }
  66% { opacity: 1; } /* 亲吻瞬间显示 */
  66.1% { opacity: 0; } /* 快速消失 */
}

动画设计小贴士

  1. 时间线同步:所有动画都设置为 4 秒周期,通过调整关键帧的百分比位置实现动作配合
  2. 动作缓冲:使用ease timing-function 让动作更自然,避免生硬的启停
  3. 细节丰富:添加面部转动、符号闪现等小细节,让动画更生动
  4. 复用原则:用伪元素减少 HTML 标签,用 class 复用样式

总结

这个小动画虽然简单,但包含了 CSS 动画的核心知识点:关键帧定义、transform 变换、动画时间控制和多元素协同。通过调整颜色、尺寸和关键帧参数,你可以轻松修改出属于自己的互动动画 —— 比如改成两个星星眨眼、小猫打招呼等。

完整代码已经放在上面了,感兴趣的同学可以复制下来试试,也可以在此基础上扩展更多互动效果。如果觉得有用,欢迎点赞收藏~ 有任何问题或创意,欢迎在评论区交流!

Apache Doris 数据导入原理与性能优化 | Deep Dive

作者 SelectDB
2025年10月31日 17:53

概述

对于 Apache Doris 这样的高性能分析型数据库而言,高效、稳定的数据导入是保障实时分析能力的生命线。然而,在海量数据持续写入的场景下,如何平衡导入延迟与吞吐、如何避免性能瓶颈,是开发者面临的核心挑战。本文将深入剖析 Doris 数据导入的核心原理,涵盖关键流程、组件、事务管理等,探讨影响导入性能的因素,并提供实用的优化方法和最佳实践,有助于用户选择合适的导入策略,优化导入性能。

数据导入原理

导入原理概述

Doris 的数据导入原理建立在其分布式架构之上,主要涉及前端节点(Frontend, FE)和后端节点(Backend, BE)。FE 负责元数据管理、查询解析、任务调度和事务协调,而 BE 则处理实际的数据存储、计算和写入操作。Doris 的数据导入设计旨在满足多样化的业务需求,包括实时写入、流式同步、批量加载和外部数据源集成。其核心理念包括:

  • 一致性与原子性:每个导入任务作为一个事务,确保数据原子写入,避免部分写入。通过 Label 机制保证导入数据的不丢不重。
  • 灵活性:支持多种数据源(如本地文件、HDFS、S3、Kafka 等)和格式(如 CSV、JSON、Parquet、ORC 等),能满足不同场景。
  • 高效性:利用分布式架构并行处理数据,多 BE 节点并行处理数据,提高吞吐量。
  • 简易性:提供轻量级 ETL 功能,用户可在导入时直接进行数据清洗和转换,减少外部工具依赖。
  • 灵活建模:支持明细模型(Duplicate Key)、主键模型(Unique Key)和聚合模型(Aggregate Key),允许在导入时进行数据聚合或去重。

导入通用流程

Doris 的数据导入遵循一个标准化的核心流程,主要包括以下几个阶段:

1、提交导入任务

  • 用户通过客户端(如 HTTP、JDBC、MySQL 客户端)提交导入请求,指定数据源(如本地文件、Kafka Topic、HDFS 文件路径)、目标表、文件格式和导入参数(如分隔符、错误容忍度)。
  • 每个任务可以指定一个唯一的 Label,用于标识任务并支持幂等性(防止重复导入)。例如,用户在 Stream Load 中通过 HTTP header 指定 Label。
  • Doris 的前端节点(FE)接收请求,验证权限、检查目标表是否存在,并解析导入参数。

2、任务分配与协调

  • FE 分析数据分布(基于表的分区和桶分规则),生成导入计划,并选择一个后端节点(BE)作为 Coordinator,负责协调整个任务。
  • 如果用户直接向 BE 提交(如 Stream Load),BE 可直接担任 Coordinator,但仍需从 FE 获取元数据(如表 Schema)。
  • 导入计划会将数据分配到多个 BE 节点,确保并行处理以提高效率。

3、数据读取与分发

  • Coordinator BE 从数据源读取数据(例如,从 Kafka 拉取消息、从 S3 读取文件,或直接接收 HTTP 数据流)。
  • Doris 解析数据格式(如对 CSV 分割、JSON 解析),并支持用户定义的 轻量 ETL 操作,包括:
    • 前置过滤:对原始数据进行过滤,减少处理开销。
    • 列映射:调整数据列与目标表列的对应关系。
    • 数据转换:通过表达式处理数据。
    • 后置过滤:对转换后的数据进行过滤。
  • Coordinator BE 解析完数据后按分区和桶分规则分发到多个下游的 Executor BE。

4、数据写入

  • Doris 的高吞吐写入得益于其独特的数据模型与 LSM Tree(Log-Structured Merge-Tree)存储结构的结合。LSM Tree 是一种高效的磁盘写入优化结构,通过将写操作分为内存和磁盘两个阶段,显著提升了写入性能。其核心思想是将随机写转换为顺序写,减少磁盘 I/O 开销,同时通过多级合并(Compaction)维护数据的有序性和查询效率。

  • 数据首先分发到多个 BE(Backend)节点,写入内存表(MemTable),并按 Key 列进行排序。对于 Aggregate 或 Unique Key 数据模型,Doris 会根据 Key 执行聚合或去重操作(如 SUM、REPLACE),减少数据冗余,提升查询性能。

  • 当 MemTable 写满(默认 200MB)或任务结束时,数据会异步写入磁盘,形成列式存储的 Segment 文件,并组成 Rowset。LSM Tree 的内存写入和异步刷盘机制确保了高吞吐量,同时通过后台的 Compaction 过程定期合并 Segment 文件,优化存储结构和查询效率。

  • 每个 BE 节点独立处理分配的数据,写入完成后向 Coordinator 报告状态,确保分布式环境下写入操作的可靠性和一致性。

5、事务提交与发布

  • Coordinator 向 FE 发起事务提交(Commit)。FE 确保多数副本成功写入后,并通知 BE 发布数据版本(Publish Version),待 BE Publish 成功后,FE 标记事务为 VISIBLE,此时数据可以查询。
  • 如果失败,FE 触发回滚(Rollback),则删除临时数据,以确保数据一致性。

6、结果返回

  • 同步方式(如 Stream Load、Insert Into)直接返回导入结果,包含成功/失败状态和错误详情(如 ErrorURL)。
  • 异步方式(如 Broker Load)提供任务 ID 和 Label,用户可通过 SHOW LOAD 查看进度、错误行数和详细信息。
  • 操作记录到审计日志,支持后续追溯。

导入冲突解决

在冲突解决方面, 经典的写写冲突会导致写入无法并行,从而显著降低写入吞吐量。Doris 提供了基于业务语义的冲突机制,可很好避免该问题(参考文档)。而 Redshift、Snowflake、Iceberg 和 Hudi 等则采用了文件级别的冲突处理,因而不具备实时更新的能力。

MemTable 前移

MemTable 前移是 Apache Doris 2.1.0 版本引入的优化机制,针对 INSERT INTO…SELECT 导入方式显著提升性能,官方测试显示该优化使得单副本导入耗时缩短约 64%(为原先的 36%),三副本导入耗时缩短约 46%(为原先的 54%),传统流程中,Sink 节点需将数据编码为 Block 格式,通过 Ping-pong RPC 传输到下游节点,涉及多次编码和解码,增加开销。Memtable 前移优化了这一过程:Sink 节点直接处理 MemTable,生成 Segment 数据后通过 Streaming RPC 传输,减少编码解码和传输等待,同时提供更准确的内存反压。目前该功能只支持存算一体部署模式。

存算分离导入

在存算分离架构下,导入优化聚焦数据存储和事务管理解耦:

  • 数据存储:BE 不持久化数据,MemTable flush 后生成 Segment 文件直接上传至共享存储(如 S3、HDFS),利用对象存储的高可用性和低成本支持弹性扩展。BE 本地 File Cache 异步缓存热点数据,通过 TTL 和 Warmup 策略提升查询命中率。元数据(如 Tablet、Rowset 元数据)由 Meta Service 存储于 FoundationDB,而非 BE 本地 RocksDB。
  • 事务处理:事务管理从 FE 迁移至 Meta Service,消除了 FE Edit Log 写入瓶颈。Meta Service 通过标准接口(beginTransaction、commitTransaction)管理事务,依赖 FoundationDB 的全局事务能力确保一致性。BE 协调者直接与 Meta Service 交互,记录事务状态,通过原子操作处理冲突和超时回收,简化同步逻辑,提升高并发小批量导入吞吐量。

导入方式

Doris 提供多种导入方式,共享上述原理,但针对不同场景优化。用户可根据数据源和业务需求选择:

  • Stream Load: 通过 HTTP 导入本地文件或数据流,同步返回结果,适合实时写入(如应用程序推送数据)。
  • Broker Load: 通过 SQL 导入 HDFS、S3 等外部存储,异步执行,适合大规模批量导入。
  • Routine Load: 从 Kafka 持续消费数据,异步流式导入,支持 Exactly-Once,适合实时同步消息队列数据。
  • Insert Into/Select: 通过 SQL 从 Doris 表或外部源(如 Hive、MySQL、S3 TVF)导入,适合 ETL 作业、外部数据集成。
  • MySQL Load: 兼容 MySQL LOAD DATA 语法,导入本地 CSV 文件,数据经 FE 转发为 Stream Load,适合小规模测试或 MySQL 用户迁移。

如何提升 Doris 的导入性能

Doris 的导入性能受其分布式架构与存储机制影响,核心涉及 FE 元数据管理、BE 并行处理、MemTable 缓存刷盘及事务管理等环节。以下从表结构设计、攒批策略、分桶配置、内存管理和并发控制等维度,结合导入原理说明优化策略及其有效性。

表结构设计优化:降低分发开销与内存压力

Doris 的导入流程中,数据需经 FE 解析后,按表的分区和分桶规则分发至 BE 节点的 Tablet(数据分片),并在 BE 内存中通过 MemTable 缓存、排序后刷盘生成 Segment 文件。表结构(分区、模型、索引)直接影响数据分发效率、计算负载和存储碎片。

  • 分区设计:隔离数据范围,减少分发与内存压力

通过按业务查询模式(如时间、区域)划分分区,导入时数据仅分发至目标分区,避免处理无关分区的元数据和文件。同时写入多个分区会导致大量 Tablet 活跃,每个 Tablet 占用独立的 MemTable,显著增加 BE 内存压力,可能触发提前 Flush,生成大量小 Segment 文件。这不仅增加磁盘或对象存储的 I/O 开销,还因小文件引发频繁 Compaction 和写放大,降低性能。通过限制活跃分区数量(如逐天导入),可减少同时活跃的 Tablet 数,缓解内存紧张,生成更大的 Segment 文件,降低 Compaction 负担,从而提升并行写入效率和后续查询性能。

  • 模型选择:减少计算负载,加速写入

明细模型(Duplicate Key)仅存储原始数据,无需聚合或去重计算;而 Aggregate 模型需按 Key 列聚合,Unique Key 模型需去重,均会增加 CPU 和内存消耗。对于无需去重或聚合的场景,优先使用明细模型,可避免 BE 节点在 MemTable 阶段的额外计算(如排序、去重),降低内存占用和 CPU 压力,进而加速数据写入流程。

  • 索引控制:平衡查询与写入开销

索引(如位图索引、倒排索引)需在导入时同步更新,否则会增加写入时的维护成本。仅为高频查询字段创建索引,避免冗余索引,可减少 BE 写入时的索引更新操作(如索引构建、校验),降低 CPU 和内存占用,来提升导入吞吐量。

攒批优化:减少事务与存储碎片

Doris 的每个导入任务为独立事务,涉及 FE 的 Edit Log 写入(记录元数据变更)和 BE 的 MemTable 刷盘(生成 Segment 文件)。高频小批量导入(如 KB 级别)会导致 Edit Log 频繁写入(增加 FE 磁盘 I/O)、MemTable 频繁刷盘(生成大量小 Segment 文件,触发 Compaction 写放大),显著降低性能。

  • 客户端攒批:减少事务次数,降低元数据开销

客户端将数据攒至数百 MB 到数 GB 后一次性导入,减少事务次数。单次大事务替代多次小事务,可降低 FE 的 Edit Log 写入频率(减少元数据操作)及 BE 的 MemTable 刷盘次数(减少小文件生成),避免存储碎片和后续 Compaction 的资源消耗。

  • 服务端攒批(Group Commit):合并小事务,优化存储效率

开启 Group Commit 后,服务端将短时间内的多个小批量导入合并为单一事务,减少 Edit Log 写入次数和 MemTable 刷盘频率。合并后的大事务生成更大的 Segment 文件(减少小文件),减轻后台 Compaction 压力,特别适用于高频小批量场景(如日志、IoT 数据写入)。

分桶数优化:平衡负载与分发效率

分桶数决定 Tablet 数量(每个桶对应一个 Tablet),直接影响数据在 BE 节点的分布。过少分桶易导致数据倾斜(单 BE 负载过高),过多分桶会增加元数据管理和分发开销(BE 需处理更多 Tablet 的 MemTable 和 Segment 文件)。

  • 合理配置分桶数:确保 Tablet 大小均衡

分桶数需根据 BE 节点数量和数据量设置,推荐单 Tablet 压缩后的数据大小为 1-10GB(计算公式:分桶数=总数据量/(1-10GB))。同时,调整分桶键(如随机数列)避免数据倾斜。合理分桶可平衡 BE 节点负载,避免单节点过载或多节点资源浪费,提升并行写入效率。

  • 随机分桶优化:减少 RPC 开销与 Compaction 压力

在随机分桶场景中,启用load_to_single_tablet=true,可将数据直接写入单一 Tablet,绕过分发到多个 Tablet 的过程。这消除了计算 Tablet 分布的 CPU 开销和 BE 间的 RPC 传输开销,显著提升写入速度。同时,集中写入单一 Tablet 减少了小 Segment 文件的生成,避免频繁 Compaction 带来的写放大,降低减少 BE 的资源消耗和存储碎片,提升导入和查询效率。

内存优化:减少刷盘与资源冲击

数据导入时,BE 先将数据写入内存的 MemTable(默认 200MB),写满后异步刷盘生成 Segment 文件(触发磁盘 I/O)。高频刷盘会增加磁盘或对象存储(存算分离场景)的 I/O 压力;内存不足则导致 MemTable 分散(多分区/分桶时),易触发频繁刷盘或 OOM。

  • 按分区顺序导入:集中内存使用

按分区顺序(如逐天)导入,集中数据写入单一分区,减少 MemTable 分散(多分区需为每个分区分配 MemTable)和刷盘次数,降低内存碎片和 I/O 压力。

  • 大规模数据分批导入:降低资源冲击

对大文件或多文件导入(如 Broker Load),建议分批(每批≤100GB),避免导入出错后带来过大的重试代价过大,同时减少对 BE 内存和磁盘的集中占用。本地大文件可使用streamloader工具自动分批导入。

并发优化:平衡吞吐量与资源竞争

Doris 的分布式架构支持多 BE 并行写入,增加并发可提升吞吐量,但过高并发会导致 CPU、内存或对象存储 QPS 争抢(存算分离场景需考虑 S3 等 API 的 QPS 限制),会增加事务冲突和延迟。

  • 合理控制并发:匹配硬件资源

结合 BE 节点数和硬件资源(CPU、内存、磁盘 I/O)设置并发线程。适度并发可充分利用 BE 并行处理能力,提升吞吐量;过高并发则因资源争抢降低效率。

  • 低时延场景:降低并发与异步提交

对低时延要求场景(如实时监控),需降低并发数(避免资源竞争),并结合 Group Commit 的异步模式(async_mode)合并小事务,减少事务提交延迟。

Doris 数据导入的延迟与吞吐取舍

在使用 Apache Doris 时,数据导入的 延迟(Latency)吞吐量(Throughput) 往往需要在实际业务场景中进行平衡:

  • 更低延迟:意味着用户能更快看到最新数据,但写入批次更小,写入频率更高,会导致后台 Compaction 更频繁,占用更多 CPU、IO 和内存资源,同时增加元数据管理的压力。
  • 更高吞吐:则通过增大单次导入数据量来减少导入次数,可以显著降低元数据压力和后台 Compaction 开销,从而提升系统整体性能。但数据写入到可见之间的延迟会有所增加。

因此,建议用户在满足业务时延要求的前提下,尽量增大单次导入写入的数据量,以提升吞吐并减少系统开销。

测试数据

Flink 端到端时延

采用 Flink Connector 使用攒批模式进行写入,主要关注数据端到端的时延和导入吞吐。攒批时间通过 flink Connector 的 sink.buffer-flush.interval 参数来控制的,Flink Connector 的详细使用参考:doris.apache.org/docs/3.0/ec…

机器配置:

  • 1 台 FE: 8 核 CPU、16GB 内存
  • 3 台 BE:16 核 CPU、64GB 内存

数据集:

  • TPCH lineitem 数据

不同攒批时间和不同并发下的导入性能,测试结果如下:

TPCH lineitem 测试数据-1.jpg

不同 bucket 数对导入性能的影响,测试结果如下:

TPCH lineitem 数据-2.jpg

Group Commit 测试

性能测试数据参考:doris.apache.org/zh-CN/docs/…

总结

Apache Doris 的数据导入优化并非单一参数的调整,而是一个涉及表结构设计、写入策略、资源配置与业务场景的系统性工程。 数据导入机制依托 FE 和 BE 的分布式协作,结合事务管理和轻量 ETL 功能,来确保高效、可靠的数据写入。频繁小批量导入会增加事务开销、存储碎片和 Compaction 压力,可以通过以下优化策略来有效缓解:

  • 表结构设计:合理分区和明细模型减少扫描和计算开销,精简索引降低写入负担。
  • 攒批优化:客户端和服务端攒批减少事务和 flush 频率,生成大文件,优化存储和查询。
  • 分桶数优化:适量分桶平衡负载,避免热点或管理开销。
  • 内存优化:控制 MemTable 大小、按分区导入。
  • 并发优化:适度并发提升吞吐量,结合分批和资源监控控制延迟。

用户可根据业务场景(如实时日志、批量 ETL)结合这些策略,优化表设计、参数配置和资源分配,可以显著提升导入性能。

Webpack系列-SourceMap

作者 云枫晖
2025年10月31日 17:47

在上一篇文章中,我们深入探讨了Webpack Plugin的工作原理和开发实践。今天,我们将继续Webpack系列,聚焦于一个同样重要的主题——SourceMap。作为现代前端开发中不可或缺的调试工具,SourceMap能够显著提升开发效率和调试体验。让我们一起来揭开SourceMap的神秘面纱。

什么是SourceMap❓

SourceMap是一种映射关系文件,它将编译、压缩的代码映射原代码。在开发过程中,我们经常遇到如下场景:

  • 使用TS等预编译语言
  • 使用ES6高级语法需通过Babel转译
  • 对代码进行压缩、混淆
  • 将多个文件打包合并

以上处理后生成的运行代码与原始代码差异巨大,给调试代码来了巨大的困难。SourceMap正是解决这一问题的关键技术。

SourceMap配置

在Webpack里可以通过devtool配置evalsource-mapcheapmoduleinline这些关键词相互组合的值,达到不同SourceMap的效果。

module.exports = {
  devtool: 'eval-source-map'
}

每个关键词的作用

关键词 作用 特点 使用场景
eval 通过eval函数执行模块代码 构建和重构速度最快 开发环境、需要快速的构建速度
source-map 生成独立的.map文件 映射质量高 生产环境、高质量错误跟踪
cheap 减少VLQ编码的计算量,减少source-map的体积 只映射行号,不映射列号,提升性能 开发环境、减少source-map的体积
module 包含loader的sourcemap信息 对于使用babel、ts的项目方便定位 开发环境、以便使用loader的文件定位问题
inline 将sourcemap作为DataURL嵌入到bundle中 不需要额外的.map文件,但增加了bundle的体积 开发环境

SourceMap的推荐配置

开发环境 - eval-cheap-module-source-map

module.exports = {
  mode: 'development',
  devtool: 'eval-cheap-module-source-map'
}

生产环境 - source-map

module.exports = {
  mode: 'production',
  devtool: 'source-map'
}

如果不想让用户看到.map文件,可以配置hidden-source-map。配置后生成的.map文件不包含引用注释,需要手动关联。

SoureMap的底层原理

生成SourceMap的方法

生成SourceMap的方法很多,我比较喜欢uglify-js的API生成SourceMap文件

安装uglify-js

npm i uglify-js

源文件内容

let a = 1;
let b = 2;
let c = 3;

生成SourceMap

const UglifyJS = require("uglify-js");
const fs = require("fs");
const path = require("path");
const result = UglifyJS.minify(
  {
    "index.js": fs.readFileSync(path.join(__dirname, "./src/index.js"), "utf8"), // 读取生成source map的源文件
  },
  {
    compress: false, // 代码不进行压缩
    output: {
      beautify: true,
      indent_level: 2,
    },
    sourceMap: {
      filename: "index.min.js",
      url: "index.min.js.map",
    },
  }
);
fs.writeFileSync("index.min.js", result.code);
fs.writeFileSync("index.min.js.map", result.map);

处理后的源代码

let a = 1;

let b = 2;

let c = 3;
// 此行浏览器会解析此行注释 获取.map文件通过VLQ编码获取源文件精准定位
//# sourceMappingURL=index.min.js.map 

SourceMap文件格式

{
  "version": 3,
  "file": "index.min.js",
  "sources": [
    "index.js"
  ],
  "names": [
    "let",
    "a",
    "b",
    "c"
  ],
  "mappings": "AAAAA,IAAIC,IAAI;;AACRD,IAAIE,IAAI;;AACRF,IAAIG,IAAI"
}

整个文件其实就是一个JS对象,可以被解释器读取。主要有以下几个属性:

  • version Source Map的版本 目前为3
  • file 转换后的文件名
  • sourceRoot 转换前的文件所在的目录。如果与转换前的文件在同一目录,该项为空
  • sources 转换前的文件。值为数组类型,表示可以存在多个文件合并
  • names 转换前的所有变量名和属性名
  • mappings 记录位置信息和字符串,后续详解

mapping属性

mapping属性的字符串值是SourceMap的灵魂,以最少得字符表示最多的映射信息。编码规则如下:

  1. 按行分组mapping字符串首先以;分隔,每个分号代表转换后代码的一行。如AAAA,IAAIC,IAAI;;AACRD,IAAIE,IAAI;;AACRF,IAAIG,IAAI 代码转换后的代码有5
  2. 按段分隔:每一行用,号分隔成多个映射段。每段代表该行的一个位置(通常是某个词法标记的开始)
  3. 相对位置:每段是VLQ编码的字符串,通常包含1、4或5个字段(不是"变量"),分别表示:
  • 生成代码的列位置
  • 源文件索引
  • 源文件行位置
  • 源文件列位置
  • (可选)names数组中的变量索引

一个典型的 4 段 VLQ 编码 AAAA 解码后可能代表 [0, 0, 0, 0],它的含义是:

  • 生成的列(Generated Column)0
  • 源文件索引(Source Index)0 (对应 sources 数组中的第一个文件)
  • 原始行(Original Line)0 (第 1 行)
  • 原始列(Original Column)0

💡 解释
VLQ编码最早用于MIDI文件,后来被多种格式采用。它的特点就是可以非常精简地表示很大的数值。
VLQ编码是变长的。如果(整)数值在-15到+15之间(含两个端点),用一个字符表示;超出这个范围,就需要用多个字符表示。它规定,每个字符使用6个两进制位,正好可以借用Base 64编码的字符表。

image.png

有可能有第五个数字,但不是必需的,如果有的话,表示属于names中的哪个变量。再看一个例子:

// 源码
let a = 1;

通过uglify-js处理后的mapping值为:

{
  "version": 3,
  "file": "index.min.js",
  "sources": [
    "index.js"
  ],
  "names": [
    "let",
    "a"
  ],
  "mappings": "AAAAA,IAAIC,EAAI"
}

通过VLQ编码转换后得出映射信息

[0,0,0,0,0], [4,0,0,4,1], [2,0,0,4]
  • [0,0,0,0,0] 对应源文件的标识符let
  • [4,0,0,4,1] 对应源文件的变量名a
  • [2,0,0,4] 对应源文件的标识符;

当浏览器加载包含SourceMap注释的JS文件时,会在开发者工具打开时自动下载并解析对应的.map文件。即使没有显式打开开发者工具,现代浏览器也会在控制台报错时使用SourceMap信息。然后通过VLQ编码解析得到一串数组如[0,0,0,0,0], [4,0,0,4,1], [2,0,0,4],当浏览器遇到断点或者错误时,根据一串数组找到源文件定位到具体的行和列,然后高亮或者报错。

小结

通过本文的学习,我们深入了解了SourceMap在前端开发中的重要作用:

  1. 调试利器:SourceMap解决了编译后代码难以调试的问题,让我们能够在浏览器中直接调试原始源代码
  2. 灵活配置:Webpack提供了多种devtool配置选项,我们可以根据开发和生产环境的不同需求选择合适的SourceMap策略
  3. 底层原理:SourceMap通过VLQ编码和映射关系,实现了编译后代码与源代码之间的精确定位

掌握SourceMap的工作原理和配置技巧,能够显著提升我们的开发效率和调试体验。希望本文能帮助大家更好地理解和使用这一重要工具!

❌
❌