普通视图
基于Vue的数字输入框指令
vue2 模版编译原理
解决前端项目中大数据复杂列表场景的完美方案
【react】 useReducer 集中式管理组件的状态
【react】useState是什么,怎么用
前端合并的表格排序功能
从天气查询到低代码 AI 应用:Coze 平台让人人都能开发智能体
老板:就是你小子删光了try-catch?
Canvas 如何渲染富文本、图片、SVG 及其 Path 路径?
Postman 平替?这款轻量接口测试工具,本地运行 + 批量回归超实用!
Postman 平替?这款轻量接口测试工具,本地运行 + 批量回归超实用!
日常做接口测试时,你是不是也有过这样的困扰:用 Postman、Apifox 这类工具,功能太多反而找不到重点,想快速做迭代回归测试却步骤繁琐?今天给大家推荐一款「轻量化」的跨平台 RESTful 接口测试工具 ——restful-api-test,基于 Electron + Vue3 开发,聚焦核心需求,让我们的接口测试更高效!
为什么选它?4 大核心优势直击痛点
市面上接口测试工具不少,但 restful-api-test 偏偏走出了不一样的路线:它砍掉了冗余功能,只保留最实用的核心能力,尤其适合需要快速验证、本地隔离环境测试的场景。
- 批量执行 + 结果汇总:一次跑多个接口,结果直观呈现,迭代回归测试效率直接拉满;
- 结构化测试用例:把接口测试流程沉淀成用例,支持重复运行,还能做版本化管理;
- 灵活断言表达式:支持自定义 JavaScript 布尔表达式,复杂校验需求也能满足;
- 本地跨平台运行:无需依赖云端,边缘环境、隔离网络下也能正常使用,数据更安全。
界面总览:3 部分布局,上手无门槛
先看整体界面,restful-api-test 的布局非常简洁,主要分为 3 个部分,即使是新手也能快速熟悉:
- 顶部:标题栏,清晰展示当前操作模块;
- 左侧:测试用例列表,所有用例集中管理,一目了然;
- 右侧:单接口测试视图 + 批量测试视图,按需切换,操作聚焦。
![]()
核心功能实操:测试用例全流程管理
接下来带大家一步步看看,如何用 restful-api-test 管理测试用例,从导入到执行、导出,全流程都很丝滑~
1. 导入测试用例:JSON 格式,字段清晰
想添加新的测试用例,只需点击左侧列表上方的「导入测试用例」按钮即可。不过要注意,导入的用例必须是 JSON 格式,且包含 3 个必填字段:
- name:测试用例名称,会显示在左侧列表中;
- description:用例描述,说明用例的作用和覆盖场景;
- apis:接口数组,每个接口包含name(接口名)、url(接口地址)、method(请求方法)等必填项,还有headers、params、body、expectedExpression(断言表达式)等可选字段。
![]()
给大家放一个完整的测试用例示例,直接复制修改就能用:
{
"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. 编辑 / 删除:用例管理更灵活
导入的用例如果需要修改名称或描述,点击左侧列表的「编辑测试用例」按钮就能进入编辑模式;如果某个用例不再需要,点击删除按钮就能快速清理,操作很直观。
删除测试用例:
编辑测试用例:
![]()
3. 批量执行:一次跑多个用例,结果秒出
做回归测试时,批量执行功能简直是刚需!两种方式可以触发批量测试:
- 直接点击测试用例列表的「批量测试」按钮;
- 选中用例后,在右侧批量测试视图点击「运行所有测试用例」按钮。
执行完成后,会清晰展示每个用例的测试结果,通过率一目了然,省去了逐个执行的麻烦。
![]()
4. 导出用例:版本管理 + 分享更方便
编辑后的测试用例,点击左侧列表的「导出测试用例」按钮,就能导出成 JSON 文件。这样一来,不仅方便做版本控制(比如用 Git 管理用例版本),还能轻松分享给团队成员,协作更高效。
![]()
单接口测试:细节拉满,满足复杂需求
一个测试用例通常包含多个接口,选中左侧列表中的具体接口,就能进入单接口测试视图。这个视图由 4 部分组成:顶部操作栏、请求详情、请求头、请求参数 / 请求体,每个部分都考虑到了实际测试场景的需求。
![]()
重点:预期断言表达式,精准校验结果
判断接口测试是否通过,关键就在「预期表达式」的配置。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。
给大家举几个常用的表达式示例,方便参考:
- 校验响应结构和长度:
($.status === 200) && Array.isArray($.data.items) && $.data.items.length > 0
- 校验字段值和类型:
$.data.ok === true && typeof $.data.total === 'number' && $.data.total >= 10
- 校验响应头:
$.headers['content-type']?.includes('application/json')
- 校验回显参数:
Boolean($.response.data.args?.startTime && $.response.data.args?.endTime)
只要是 JavaScript 支持的判断逻辑和方法,都能写进表达式,比如Array.isArray()、typeof、includes()等,灵活应对各种复杂的校验场景。
其他配置:URL、请求头、请求参数、请求方法、请求消息体配置
除了断言表达式,单接口视图还支持配置接口 URL、请求头(比如设置 Authorization、Content-Type)、URL 参数(GET 请求常用)、请求体(POST/PUT 请求传参,默认 JSON 格式),满足不同接口的请求需求。
资源获取:一键下载,快速上手
看完功能介绍,如果你想立刻体验这款工具,可以通过以下链接获取资源:
- 项目仓库地址:github.com/astonishqft…(可查看源码、提交 Issues、了解更新日志)
- Windows 版本下载:github.com/astonishqft…
- Mac(x86 架构)版本下载:github.com/astonishqft…
- Mac(ARM 架构)版本下载:github.com/astonishqft…
最后:期待你的支持!
这款工具目前处于持续迭代中,如果你在使用过程中觉得它帮你解决了实际问题,或者有新的功能需求,欢迎到 GitHub 仓库给项目点一个「Star」🌟!你的支持是我持续优化工具的最大动力,也能让更多有需要的人发现这款实用的接口测试工具~
如果遇到使用问题,也可以在仓库的 Issues 区留言,我会及时回复并修复,一起让这款工具变得更完善!
更多精彩文章,欢迎关注我的公众号:前端架构师笔记
Flutter疑难解决:单独让某个页面的电池栏标签颜色改变
一、需求来源
最近项目适配研深色和浅色的功能,某些情况下整个页面顶部是深色图片背景,需要再浅色模式下,电池栏颜色 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 的求职价值观
最近 Jake Wharton(Android 世界最知名的贡献者之一)在个人网站(jakewharton.com/hire/) 声明开始看工作机会:
"I'm currently looking for opportunities!"
他的求职价值观非常不一样,使得我忍不住转给大家。
Jake Wharton 是谁
如果你不知道 Jake Wharton 是谁,说明你可能是个 Android 开发小白或者路人hh。
他是 Android 圈非常知名的开源代码贡献者,创造了非常多广为使用的库。
![]()
他的经历:
- 2012 年就加入 Square,主导 Retrofit、OkHttp、Picasso、Moshi 等底层库,国内几乎每款 App 都在间接调用他的代码
- 2017 年转岗 Google,把 Kotlin 推向官方第一语言,并创建 KTX 系列扩展
- 2020 年至今在 Cash App 设计跨平台方案,同时维护 Redwood、Zipline、Molecule 等开源项目
一句话:Android App 里的每一行网络请求、每一次 JSON 解析,背后都可能有他的影子。
完整内容截图
![]()
总结
对被迫卷 AI 代码率指标/下班后还得处理工作/失业后去做 Web 3 程序员们来说,Jake Wharton 的这份"反向招聘"是一声响亮的呐喊——先衡量公司,再衡量代码。
愿我们都有这样的底气,在下一次求职时能把价值观说出口,把离线权握在手。
这就是流量的力量吗?用豆包 AI 编程做的小红书小组件帖子爆了
Lua世界的基石:变量、作用域与七大数据类型
一、变量与作用域: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(布尔)
布尔类型只有两个值:true 和 false。
重要:在 Lua 的条件判断中,只有 false 和 nil被视为“假”,其他所有值(包括数字 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 技术干货!如果觉得有用,记得收藏本文!
这个Vue3旋转菜单组件让项目颜值提升200%!支持多种主题,拿来即用
【独家实测】Cursor 2.0 发布,花一分钟看看都更新了啥
前端小彩蛋:纯 CSS 实现情侣小球互动,甜到齁的代码我扒下来了
用纯 CSS 实现超甜互动动画:两个小球的 "亲吻" 瞬间
大家好,今天给大家分享一个用纯 CSS 实现的趣味互动动画 —— 两个卡通小球从对视到亲吻的甜蜜过程。整个动画不需要一行 JavaScript,仅通过 CSS 关键帧和变换属性就能实现流畅自然的互动效果。话不多说,先看看最终效果(建议配合代码食用):
效果预览
两个白色小球(我们暂且叫它们 "女主" 和 "男主")会周期性地完成这样一套动作:
- 女主先羞涩地向男主靠近一点
- 男主主动前倾,做出亲吻动作
- 亲吻瞬间会出现可爱的亲吻符号
- 之后两者恢复原位,等待下一次互动
![]()
实现思路拆解
这个动画的核心是通过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); } /* 回到原位 */
}
亲吻瞬间的细节处理
为了让亲吻更真实,我们做了两个细节:
- 男主嘴巴隐藏(
opacity: 0) - 出现亲吻符号(
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; } /* 快速消失 */
}
动画设计小贴士
- 时间线同步:所有动画都设置为 4 秒周期,通过调整关键帧的百分比位置实现动作配合
-
动作缓冲:使用
easetiming-function 让动作更自然,避免生硬的启停 - 细节丰富:添加面部转动、符号闪现等小细节,让动画更生动
- 复用原则:用伪元素减少 HTML 标签,用 class 复用样式
总结
这个小动画虽然简单,但包含了 CSS 动画的核心知识点:关键帧定义、transform 变换、动画时间控制和多元素协同。通过调整颜色、尺寸和关键帧参数,你可以轻松修改出属于自己的互动动画 —— 比如改成两个星星眨眼、小猫打招呼等。
完整代码已经放在上面了,感兴趣的同学可以复制下来试试,也可以在此基础上扩展更多互动效果。如果觉得有用,欢迎点赞收藏~ 有任何问题或创意,欢迎在评论区交流!
Apache Doris 数据导入原理与性能优化 | Deep Dive
概述
对于 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 数据
不同攒批时间和不同并发下的导入性能,测试结果如下:
![]()
不同 bucket 数对导入性能的影响,测试结果如下:
![]()
Group Commit 测试
性能测试数据参考:doris.apache.org/zh-CN/docs/…
总结
Apache Doris 的数据导入优化并非单一参数的调整,而是一个涉及表结构设计、写入策略、资源配置与业务场景的系统性工程。 数据导入机制依托 FE 和 BE 的分布式协作,结合事务管理和轻量 ETL 功能,来确保高效、可靠的数据写入。频繁小批量导入会增加事务开销、存储碎片和 Compaction 压力,可以通过以下优化策略来有效缓解:
- 表结构设计:合理分区和明细模型减少扫描和计算开销,精简索引降低写入负担。
- 攒批优化:客户端和服务端攒批减少事务和 flush 频率,生成大文件,优化存储和查询。
- 分桶数优化:适量分桶平衡负载,避免热点或管理开销。
- 内存优化:控制 MemTable 大小、按分区导入。
- 并发优化:适度并发提升吞吐量,结合分批和资源监控控制延迟。
用户可根据业务场景(如实时日志、批量 ETL)结合这些策略,优化表设计、参数配置和资源分配,可以显著提升导入性能。
Webpack系列-SourceMap
在上一篇文章中,我们深入探讨了Webpack Plugin的工作原理和开发实践。今天,我们将继续Webpack系列,聚焦于一个同样重要的主题——SourceMap。作为现代前端开发中不可或缺的调试工具,SourceMap能够显著提升开发效率和调试体验。让我们一起来揭开SourceMap的神秘面纱。
什么是SourceMap❓
SourceMap是一种映射关系文件,它将编译、压缩的代码映射原代码。在开发过程中,我们经常遇到如下场景:
- 使用TS等预编译语言
- 使用ES6高级语法需通过
Babel转译 - 对代码进行压缩、混淆
- 将多个文件打包合并
以上处理后生成的运行代码与原始代码差异巨大,给调试代码来了巨大的困难。SourceMap正是解决这一问题的关键技术。
SourceMap配置
在Webpack里可以通过devtool配置eval、source-map、cheap、module、inline这些关键词相互组合的值,达到不同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的灵魂,以最少得字符表示最多的映射信息。编码规则如下:
-
按行分组:
mapping字符串首先以;分隔,每个分号代表转换后代码的一行。如AAAA,IAAIC,IAAI;;AACRD,IAAIE,IAAI;;AACRF,IAAIG,IAAI代码转换后的代码有5行 -
按段分隔:每一行用
,号分隔成多个映射段。每段代表该行的一个位置(通常是某个词法标记的开始) - 相对位置:每段是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编码的字符表。
![]()
有可能有第五个数字,但不是必需的,如果有的话,表示属于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在前端开发中的重要作用:
- 调试利器:SourceMap解决了编译后代码难以调试的问题,让我们能够在浏览器中直接调试原始源代码
- 灵活配置:Webpack提供了多种devtool配置选项,我们可以根据开发和生产环境的不同需求选择合适的SourceMap策略
- 底层原理:SourceMap通过VLQ编码和映射关系,实现了编译后代码与源代码之间的精确定位
掌握SourceMap的工作原理和配置技巧,能够显著提升我们的开发效率和调试体验。希望本文能帮助大家更好地理解和使用这一重要工具!