阅读视图

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

“讯兔科技”正式完成近2亿元A轮融资

36氪获悉,“讯兔科技”正式完成近2亿元A轮融资。本轮融资由启明创投、红杉中国、高瓴创投共同领投,广发乾和、信宸资本(中信资本旗下私募股权投资业务)、清科控股跟投并赋能产业协同,老股东钟鼎资本和嘉程资本持续追加。华兴资本担任公司独家财务顾问。

一个普通Word文档,为什么99%的开源编辑器都"认怂"了?我们选择正面硬刚

先上一张图:

图片

这个是 Word 中我们高频使用的文档案例,在合同,公文,档案等各个场景中都能看见,但是我测试了市面上10多个主流开源的富文本/文档编辑器,没有一个能完整把上面的样式 1: 1 解析出来,99%解析的效果都是这样:

图片

其实在很多在线文档系统里,DOCX 导入后的效果之所以容易失真,是因为它们通常只保留了最表层的字号、颜色和段落,而丢失了真正决定版式的细节:

  • 分散对齐
  • 字符缩放
  • 字间距
  • 精确行距
  • 文档网格
  • 页面尺寸与页边距
  • 中西文混排规则

在 Web 编辑器领域,中文排版长期被忽视。大多数编辑器仅关注英文排版模型,导致中文文档出现标点溢出、行距不均、分散对齐缺失等问题。

为了解决这个痛点,我们花了半年时间做技术研究和验证,终于实现了一套高精度Docx解析算法,支持各种复杂的Word样式排版的解析渲染,并能在Web端实时编辑。

图片

没错,它就是 jitword,对标 Word 排版效果,原生支持中文排版规范,实现高保真文档导入导出。

老规矩,先上地址:

开源sdk: github.com/jitOffice/j…

JitWord 从底层重新设计了排版引擎,原生支持 GB/T 标点压缩、分散对齐、字符缩放、网格行距等专业排版特性,并实现了与 Word 格式的高保真双向互转。(虽然目前还达不到100%精度,但实测已经是业内top3的方案了)

下面是我们设计的高精度docx解析的技术架构:

图片大家可以参考一下,下面我会和大家详细分享一下我们实现的方案细节。

核心排版能力

一、分散对齐 — 像 Word 一样均匀分布每个字符

图片

传统 Web 编辑器只有左对齐、居中、右对齐、两端对齐四种模式。JitWord 额外实现了 分散对齐(Distribute) ,这是中文公文和正式文档中的必备排版方式。

实现原理:

  • 精确计算每行可用宽度与文本实际宽度的差值
  • 将差值均匀分配到每个字符间隙中:间距 = (行宽 - 文本宽) / (字符数 - 1)
  • 实时响应窗口缩放和字体变化,通过 ResizeObserver 动态重排
  • 三重 CSS 保障:text-align: justify + text-align-last: justify + text-justify: inter-character

效果:  每个字符等间距分布,行首行尾严格对齐,无论段落宽度如何变化都保持均匀美观。


二、字符缩放 — 灵活调整字符宽度比例

图片

支持 33% 到 200% 共 8 档水平缩放预设,可在不改变字号的前提下调整文本密度。

技术方案:

  • 使用 CSS transform: scaleX() 实现无损缩放
  • 自动补偿缩放后的布局宽度,确保分散对齐等特性不受影响
  • 导出 Word 时精确映射到 w:rPr > w:w 字符缩放属性

应用场景:  表格单元格内容过长时压缩显示、标题需要加宽强调效果、模拟 Word 中的字符缩放格式。


三、CJK 排版四件套 — 原生中文排版规范支持

JitWord 内置四项核心 CJK 排版特性,可从 Word 文档中自动识别并还原:

特性 作用 技术实现
严格折行 防止句号、逗号等标点出现在行首 line-break: strict + 东亚换行规则检测
标点压缩 连续标点(如 」、) 自动挤压间距 CSS text-spacing-trim: normal (渐进增强)
字距控制 保持 CJK 字符等宽边界 font-kerning: none 禁用西文字距调整
中英文自动间距 中文与英文/数字之间自动添加间距 CSS text-autospace: normal (渐进增强)

导入兼容性:  从 Word 文档的 <w:documentLayout> 配置中自动提取 characterSpacingControldoNotWrapTextWithPunctnoPunctuationKerningbalanceSingleByteDoubleByteWidth 等属性,精确映射到对应的 CSS 排版规则。


四、字间距精细调整

支持以 磅值(pt)  为单位的字间距调整,与 Word 完全一致:

  • 预设 9 档:从紧缩 -2pt 到加宽 5pt
  • 快捷键支持:每次增减 0.5pt,范围 -5pt ~ 10pt
  • 导出 Word 时精确转换为 twentieths of a point(Word 原生单位)

五、网格行距 — 公文排版标准

图片

支持 Word 文档网格(Document Grid)特性,段落基线自动对齐到文档网格,完美还原政府公文 "每页固定行数" 的排版要求。

高保真文档互转

DOCX 导入 — 五阶段 IR 管线

图片

JitWord 采用自研的中间表示(IR)架构,实现从 Word 到编辑器的高保真格式转换:

DOCX 文件 → XMLAST 解析 → DocIR 中间表示 → JitWord JSON 映射 → Schema 合规校验

关键能力:

  • 格式完整保留段落对齐、字间距、字符缩放、行高、缩进等属性逐一映射
  • CJK 属性提取自动识别文档级排版设置(标点压缩、折行规则、网格配置)
  • 图片异步持久化嵌入图片自动提取、上传到服务端,支持降级到 Base64
  • 智能降级docx4js 为主引擎,mammoth.js 作为兼容性备选
  • 诊断报告导入后生成详细报告,标注不支持的特性和有损转换项

DOCX 导出 — 精确格式输出

编辑器内容反向导出为标准 Word 文档:

  • 对齐方式精确映射(含分散对齐 AlignmentType.DISTRIBUTE
  • 字间距从 pt 转换为 Word 的 twips 单位(ptValue × 20
  • 字符缩放转换为 Word 百分比(0-400%)
  • 支持浮动图片、复杂表格、有序/无序列表、代码块
  • 数学公式支持:LaTeX 自动转换为 Word OMML 格式

PDF 导出 — 像素级还原

自研的 PDF 导出引擎,确保所见即所得:

  • 逐元素分页精确计算每个元素的垂直空间占用,智能分页
  • 双渲染策略优先使用 SVG foreignObject(更好的字体支持),自动降级到 Canvas 渲染
  • 保真度校验导出后自动采样校验画布内容,检测空白或异常渲染并触发重试
  • 布局锁定导出时等待字体加载、图片加载、DOM 稳定后再截图
  • 图表/脑图静态化ECharts 图表和脑图自动转换为静态图片嵌入

单位体系统一

全链路采用 磅值(pt)  作为标准单位,与 Word 原生体系一致:

场景 单位 转换关系
编辑器内部 pt 基准单位
CSS 渲染 px 1pt = 1.333px
Word 文档 twips 1pt = 20 twips
导入兼容 half-points 1pt = 2 half-points

与其他 Web 编辑器的对比

能力 JitWord 通用富文本编辑器 在线协作文档
分散对齐 原生支持 不支持 部分支持
字符缩放 33%-200% 不支持 不支持
标点压缩 自动识别 不支持 不支持
严格折行 智能启用 不支持 基础支持
网格行距 完整支持 不支持 不支持
DOCX 高保真导入 五阶段 IR 管线 基础 HTML 转换 有损导入
DOCX 导出 精确格式映射 有限支持 有损导出
PDF 导出保真度 像素级 + 双渲染 浏览器打印 服务端渲染

最后总结一下

JitWord 从排版引擎层面解决了中文 Web 排版的核心痛点,通过自研的分散对齐算法、CJK 排版规范支持、五阶段 IR 导入管线和像素级 PDF 导出,实现了 Web 端对 Word 排版效果的真正对标

图片

无论是政府公文的严格格式要求,还是企业文档的专业排版需求,我们都能提供开箱即用的解决方案。

当然我们还在持续迭代优化,打造更高精度,更智能的AI协同文档系统,让个人和企业能更低成本将传统 Office “搬到”线上。

大家有好的建议随时交流反馈~

小小的但有硬派味,丰田酷路泽 FJ 正式发售,约 26.7 万元

兰德酷路泽家族在曼谷车展上迎来了新成员 Land Cruiser FJ。

这个以可靠性、耐久性和硬派越野闻名的经典车系,以更轻盈的方式向更多用户打开了大门。

和家族里那些更强调任务属性的车型相比,FJ 把「Freedom & Joy」放在了更靠前的位置。它依旧保留了酷路泽家族的硬派基因,但气质明显更轻快,也更愿意让更多人用自己的方式接近越野和出行本身的乐趣。

FJ 是目前兰德酷路泽家族里最小的一台,车身长宽高为 4575 / 1855 / 1960 mm,轴距为 2580 mm,整体体量与路虎卫士 90 接近。

虽然车的体量不大,但丰田依旧把家族一贯的硬派气质,塞进了当下很受欢迎的「方盒子」轮廓里,于是它看上去既像一台标准的酷路泽,又多了一点紧凑车型才有的灵巧和可爱。

FJ 的外观设计由来自中国的华人设计师易路主导,他毕业于清华大学,目前担任丰田设计中心外观负责人。

按照丰田的说法,FJ 的造型是一种「类似骰子的长方体」,车身整体是规整的矩形轮廓,但边角做了倒角处理,宽大的 C 柱、向外扩张的轮眉、黑色侧裙和黑化轮圈则充分体现出了它的越野气质。

FJ 提供了两种不同风格的前脸设计。一种采用复古圆灯,明显是在向经典车型致意;另一种则使用更现代的方形灯组,并配上 C 字形日行灯。两套方案都搭配印有「Toyota」字样的简洁格栅,下方前保险杠的造型也各有区分,所以同一台车可以呈现出两种完全不同的性格,一个偏怀旧,一个偏当代。

在结构层面,FJ 基于与 Hilux 和 Fortuner 同源的 IMV 梯形车架架构打造。

不过,丰田并没有简单照搬现有平台,而是针对这台更紧凑、更强调灵活性的酷路泽,额外增加了底盘加强件,以提升车身刚性和操控稳定性。

FJ 的整车离地间隙达到 245 mm,涉水深度为 700 mm,接近角和离地间隙也与 250 系列接近。再加上更短的前后悬和紧凑轴距,它在复杂路况下会有更好的通过性,也会给驾驶者更多脱困余量。

目前泰国市场采用单一高配版本发售,搭载 2.7 升 2TR-FE 直列四缸自然吸气汽油发动机,最大功率 164 马力,峰值扭矩 245 牛·米,匹配 6 速自动变速箱、分时四驱系统以及后差速锁。

配置上则标配 18 英寸合金轮毂、全 LED 灯组、双区独立空调、12.3 英寸中控触摸屏、7 英寸数字仪表盘、合成皮质内饰、7 个安全气囊,以及完整的 ADAS 主动安全辅助系统。

价格方面,FJ 在泰国的发布价格为 1,269,000 泰铢,约合人民币 26.7 万元。这个价格略高于当地顶配版卡罗拉 Cross GR Sport。

在泰国发布会上,丰田还同步带来了 4 款改装概念车,分别是 Meridian、Nature Explorer、Legendary 和 Street Cruiser。丰田尝试把 FJ 的不同使用场景直接摆到展台上,让人更容易理解这台车究竟可以怎么玩。

Meridian 是其中越野味最重的一台,采用水泥灰车漆,并搭配了黑色地形纹路涂装。

整车装上了全套 ARB 配件,包括护底板、涉水喉、兼具踏板功能的防刮侧踏、车顶行李架和 LED 灯条,再配上 17 英寸 Lenso MX Dinero 合金轮圈、全地形轮胎,以及由 Old Man Emu 弹簧和 Nitrocharger Plus 避震器组成的 20 mm 升高套件。

Nature Explorer 的方向则更偏向露营和长途户外。它采用烟蓝色车身搭配白色车顶,并且配备了车顶平台、车顶帐篷和侧方遮阳篷,夜间照明则由格栅上的 ARB 射灯和前挡风玻璃底部的 Nacho LED 灯负责。

Legendary 是 4 款概念车里最有情怀的一台,灵感直接来自经典的 FJ40。

它采用砂岩黄色车身,同色护板、细腻的镀铬装饰和黑化细节交织在一起,营造出很强的复古气息。它也是 4 台车里唯一使用圆形 LED 灯图案的版本。17 英寸 Lenso MX Duty 轮圈特意做出了类似镀铬钢圈的质感,翼子板上还印有经典的 Land Cruiser 徽标。

Street Cruiser 的路数则完全不同。它不再强调荒野,而是把重心放到城市驾驶和个性表达上。

20 英寸 Lenso Jager Astra 轮圈配公路胎,悬挂高度被进一步降低,制动系统升级为带红色 Brembo 卡钳的通风刹车盘。磨砂车身贴和赛车风格拉花让它明显更张扬,亮黑色车顶和尾翼也更有都市运动感。车尾原本放备胎的位置被换成储物盖板,腾出了安装摩托车架或自行车架的空间。

除了概念车,丰田还公布了面向泰国市场的 3 款官方改装套餐,同时也支持消费者按单件自由选购。Unbound Explorer 套装售价 32,450 泰铢,包含涉水喉、ARB 护底板和泥挡;Urban Unique 套装售价 32,700 泰铢,提供格栅装饰条、备胎盖、车门饰条、油箱盖、侧窗雨眉以及前后 2K 行车记录仪;Freedom Journey 套装售价 36,400 泰铢,则在前者基础上增加了 ARB 车顶平台。

和 FJ 一同出现的,还有一款名为 Land Hopper 的电动个人移动产品。丰田的设想是,它可以直接装进 FJ 的行李厢,在车辆抵达目的地之后,继续承担更深入的短途移动任务,比如穿行山林、非铺装小径,或者解决目的地周边的最后一段路程。

1951 年,在丰田皇冠和卡罗拉都还没有诞生的年代,兰德酷路泽以「丰田 BJ」之名出现,并成为首台登上富士山六合目的汽车。此后 70 多年,这个名字靠着可靠性、耐久性和硬派越野能力,进入全球 190 多个国家和地区,累计销量约 1215 万辆,逐渐从一台工具车,长成了一个极具辨识度的品牌符号。

而这辆 Land Cruiser FJ 则是丰田对这个经典名字做出的一次新解释。

过去,酷路泽更多时候代表的是远行、任务、耐久和征服复杂地形的能力;到了 FJ 这里,这些东西还在,只是表达方式变得更轻了一些,也更靠近日常生活。

它没有试图削弱酷路泽的传统,反而是用更友好的姿态,尝试将这个家族带到更多人的生活半径里。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

Flutter ListView Physics 滚动物理效果详解

前言

在 Flutter 开发中,ListView 是最常用的列表组件之一。大多数情况下,我们直接使用默认的滚动效果,但默认的 ScrollPhysics 在某些场景下体验并不理想。本文将详细介绍 ListView 的各种 physics 属性,以及如何实现类似 iOS 的流畅弹簧滚动效果。

一、ListView 常用属性一览

1.1 核心属性

属性 类型 说明
children List<Widget> 列表项组件(ListView children 构造)
itemBuilder Widget Function(BuildContext, int) 列表项构建器(ListView.builder 构造)
itemCount int? 列表项数量
scrollDirection Axis 滚动方向(horizontal/vertical)
reverse bool 是否反向滚动
controller ScrollController? 滚动控制器
physics ScrollPhysics? 滚动物理效果(本文重点)
padding EdgeInsetsGeometry? 内边距
itemExtent double? 固定 item 高度(提升性能)
cacheExtent double? 预渲染区域大小

1.2 构造方式对比

// 方式一:直接传入 children(适用于少量固定数据)
ListView(
  children: [
    ListTile(title: Text('Item 1')),
    ListTile(title: Text('Item 2')),
    ListTile(title: Text('Item 3')),
  ],
)

// 方式二:builder 构造(适用于大量/动态数据)
ListView.builder(
  itemCount: 1000,
  itemBuilder: (context, index) {
    return ListTile(title: Text('Item $index'));
  },
)

// 方式三:separated 构造(带分割线)
ListView.separated(
  itemCount: 100,
  separatorBuilder: (context, index) => Divider(),
  itemBuilder: (context, index) => ListTile(title: Text('Item $index')),
)

二、ScrollPhysics 详解

2.1 什么是 ScrollPhysics?

ScrollPhysics 是 Flutter 滚动系统的核心抽象类,它定义了滚动视图的物理行为,包括:

  • 滚动速度与阻尼:手指滑动后的减速效果
  • 边界回弹效果:滚动到边缘时的弹性动画
  • 吸附效果:滚动停止时的位置对齐
  • ** fling 手势**:快速滑动后的惯性滚动

2.2 Flutter 内置 Physics 方案

Physics 类 效果描述
ClampingScrollPhysics Android 默认效果,边界直接卡住,无回弹
BouncingScrollPhysics iOS 默认效果,边界有弹性回弹
FixedExtentScrollPhysics 固定高度列表专用(如 ListWheelScrollView)
NeverScrollableScrollPhysics 禁用滚动
PageScrollPhysics PageView 专用,页面吸附效果
RangeMaintainingScrollPhysics 保持内容范围的物理效果

2.3 各种 Physics 效果对比

┌─────────────────────────────────────────────────────────┐
│                    BouncingScrollPhysics (iOS 风格)     │
│                                                         │
│    ╭──────────────╮                                      │
│    │   列表项 1    │ ← 向上滚动到顶部时                    │
│    │   列表项 2    │   继续拖动会出现弹性回弹              │
│    │   列表项 3    │   ╭──────────────╮                  │
│    ╰──────────────╯   │   列表项 1    │ ← 回弹效果        │
│                       │   列表项 2    │                  │
│                       ╰──────────────╯                  │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│               ClampingScrollPhysics (Android 风格)      │
│                                                         │
│    ╭──────────────╮                                      │
│    │   列表项 1    │ ← 向上滚动到顶部时                    │
│    │   列表项 2    │   直接卡住,无回弹效果                │
│    │   列表项 3    │                                      │
│    ╰──────────────╯ ↓ 边界僵硬卡住                       │
└─────────────────────────────────────────────────────────┘

三、性能优化技巧

3.1 使用 itemExtent

如果列表项高度固定,使用 itemExtent 可以显著提升滚动性能:

ListView.builder(
  itemExtent: 60.0,  // 固定高度,减少测量计算
  itemBuilder: (context, index) => MyListTile(index: index),
)

3.2 合理设置 cacheExtent

ListView.builder(
  cacheExtent: 200.0,  // 预渲染区域,酌情调整
  itemBuilder: (context, index) => MyListTile(index: index),
)

3.3 使用 const 构造

physics: const BouncingScrollPhysics(),  // 尽可能使用 const

四、总结

核心要点:

  1. 默认效果不一定最优,需要根据场景选择
  2. BouncingScrollPhysics 是实现流畅体验的好选择
  3. 善用 const 构造和 itemExtent 优化性能

💡 小贴士:如果你发现滚动效果还是不够流畅,可以检查是否在 itemBuilder 中进行了不必要的重建操作

Flutter 实现点击任意位置收起键盘的最佳实践

痛点

在 Flutter 开发中,TextField 聚焦后会弹出键盘,关闭键盘通常需要:

  • 点击系统返回键
  • 点击输入框外的空白区域(但很多情况下点击空白区域也没反应)
  • 点击其他输入框(键盘会切换到另一个输入框,不会真正收起)

更麻烦的是,点击 AppBar 按钮、下拉菜单、列表项等非空白区域时,键盘往往纹丝不动,用户体验非常割裂。


核心方案:使用 Listener 监听 PointerDownEvent

Flutter 中,原始指针事件会先于手势事件分发到 widget 树。在 PointerDown 被子 widget 消费之前拦截它,就能实现"任何触摸都先收起键盘"的效果。

代码实现

Widget build(BuildContext context) {
  return Listener(
    behavior: HitTestBehavior.translucent,
    onPointerDown: (_) => FocusScope.of(context).unfocus(),
    child: Scaffold(
      // ... 原有内容
    ),
  );
}

三个关键点:

  1. Listener —— 直接监听底层指针事件,不依赖手势识别
  2. behavior: HitTestBehavior.translucent —— 让透明区域(空白区域)也能响应命中测试,确保整个屏幕都在监听范围内
  3. FocusScope.of(context).unfocus() —— 撤销当前焦点树中的焦点,Flutter 会自动触发键盘收起

完整示例

class MyPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Listener(
      behavior: HitTestBehavior.translucent,
      onPointerDown: (_) => FocusScope.of(context).unfocus(),
      child: Scaffold(
        appBar: AppBar(title: Text('示例页面')),
        body: Column(
          children: [
            TextField(
              controller: _searchController,
              decoration: InputDecoration(
                hintText: '搜索...',
                prefixIcon: Icon(Icons.search),
              ),
            ),
            Expanded(child: MyListView()),
            BottomInputBar(),
          ],
        ),
      ),
    );
  }
}

原理深入

为什么不用 GestureDetector?

GestureDetector 只能检测"命中自己边界"的事件。如果某个按钮完全占用了自己的区域,GestureDetector.onTap 能捕获到,但如果你的按钮有自己的 onPressed 处理,指头点上去后:

PointerDown → GestureDetector 尝试命中 → 命中失败(被子 widget 吸收)
           → 子 widget 的 onPressed 响应

问题在于 GestureDetector.onTap 的执行顺序在子 widget 之后(或者说它自己根本收不到被消费的事件),如果你想"先收起键盘,再让按钮正常响应",GestureDetector 是做不到的

为什么 Listener 可以?

Listener 监听的是最原始的指针事件:

PointerDown → Listener.onPointerDown 触发(此时子 widget 还没处理)
           → 子 widget 接收并处理 onPressed
           → PointerUp → GestureDetector.onTap 触发

Listener.onPointerDown 在事件被消费之前就执行了。所以我们写的 unfocus() 会立刻触发键盘收起,然后子 widget 的正常点击逻辑继续执行,两者互不干扰。

HitTestBehavior.translucent 的作用

Flutter 的命中测试默认只检测不透明区域。空白区域(Container with no color、Expanded、SizedBox 等)默认不会被命中,导致 Listener 漏掉这片区域的触摸。

设置 behavior: HitTestBehavior.translucent 后,即使区域没有颜色,也会参与命中测试,确保整个屏幕都在监听范围内。


适用场景

场景 GestureDetector Listener
点击空白区域收起键盘
点击按钮收起键盘
点击 AppBar 收起键盘
点击下拉菜单收起键盘
滑动列表收起键盘 ✅(需要 onPanUpdate) ✅(PointerDown 已覆盖)
输入框聚焦后切换到另一个输入框 ⚠️ 键盘切换不消失 ✅ 键盘真正收起

进阶:封装为 Mixin

如果多个页面都需要这个行为,可以封装成 DismissibleKeyboard Mixin:

mixin DismissibleKeyboard<T extends StatefulWidget>
    on State<T> {
  @protected
  Widget buildWithKeyboardDismiss(BuildContext context, Widget child) {
    return Listener(
      behavior: HitTestBehavior.translucent,
      onPointerDown: (_) => FocusScope.of(context).unfocus(),
      child: child,
    );
  }
}

// 使用
class HomePage extends StatefulWidget {
  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> with DismissibleKeyboard {
  @override
  Widget build(BuildContext context) {
    return buildWithKeyboardDismiss(
      context,
      Scaffold(
        // ... 原有内容
      ),
    );
  }
}

总结

使用 Listener + onPointerDown + HitTestBehavior.translucent 组合,就能实现"任意触摸均收起键盘"的效果,比 GestureDetector 更早捕获事件,比手动给每个按钮绑 unfocus() 更优雅、更省心。这个方案几乎适用于所有需要键盘交互的 Flutter 页面。

Q2大客户拉货环比Q1翻倍?胜宏科技回应

3月26日,有消息称胜宏科技Q2大客户拉货环比Q1翻倍,未来业绩预期大幅改善。对此,记者以投资者身份致电胜宏科技,公司接线工作人员表示,不清楚本次股价异动原因,也不掌握市场传闻的具体订单信息。至于产能爬坡预期,胜宏科技前述工作人员介绍,“今年能够扩出来的产能,主要是去年就已经建好的厂房四、厂房九这两座,如果还有其他增量,就可能看我们在泰国的进展。泰国工厂的二期改造方面,我们设备都已经调试得差不多了,现在是在做客户的验证审厂以及试验板的导入工作”。(21财经)

拒绝 rem 计算!Vue3 大屏适配,我用 vfit 一行代码搞定

大家好,我是 RayChart,vfit.js、raychart.js 作者,8 年专注 Vue3 大屏适配、Web3D、数字孪生、数据可视化实战开发,长期分享可直接落地的前端效率工具与实战教程。

每次接到 1920×1080 标准大屏设计稿,最让人头疼的永远是适配
rem 要不停换算、百分比布局易乱、手动 scale 要写一堆监听与居中逻辑,坑多还容易出bug。

今天给大家带来我自研的 Vue3 轻量大屏适配库 —— vfit,真正做到:
不用计算、不用换算、不用调复杂布局,3 分钟接入,设计稿写多少 px,代码就写多少。


一、3 分钟极速接入(复制即用)

1. 安装依赖

npm install vfit

2. 全局配置(main.ts)

import { createApp } from 'vue'
import App from './App.vue'
import { createFitScale } from 'vfit'
import 'vfit/style.css' // 必须引入,否则组件失效

const app = createApp(App)

app.use(createFitScale({
  target'#app',
  designWidth1920,    // 设计稿宽度
  designHeight1080,   // 设计稿高度
  scaleMode'auto'     // 自动适配模式,直接用
}))

app.mount('#app')

配置完成,你的页面已经具备自动等比缩放 + 窗口居中能力,任意拖拽窗口都不会变形、不会错位。


二、核心神器:FitContainer 精准定位

做大屏最痛的不是缩放,而是组件坐标还原
vfit 提供的 <FitContainer> 组件,直接解决 90% 布局痛点:

设计稿 30px → 代码直接写 30,无需任何比例计算

<template>
  <div class="screen-wrapper">
    <!-- 标题:水平居中 -->
    <FitContainer :top="50" :left="0" :right="0">
      <h1 style="text-align: center">数据可视化大屏</h1>
    </FitContainer>

    <!-- 左侧图表:直接使用设计稿坐标 -->
    <FitContainer :top="100" :left="30">
      <ChartComponent />
    </FitContainer>

    <!-- 右侧列表:吸附边缘,自动适配 -->
    <FitContainer :top="100" :right="30">
      <ListComponent />
    </FitContainer>
  </div>
</template>

核心优势

  • 支持 top / left / right / bottom / z 五维定位
  • 自动按设计稿比例计算位置
  • 4K 屏、笔记本屏、拼接屏效果完全一致
  • 无需媒体查询、无需 rem、无需手写 CSS 计算

三、实战避坑指南(必看)

  1.  样式必须引入
    忘记引入 vfit/style.css 会导致 FitContainer 失效,布局直接混乱。
  2.  层级冲突处理
    FitContainer 默认有层级,弹窗被覆盖时可手动指定:
<FitContainer :z="999">
  1.  right / bottom 特殊逻辑
  • left:按设计稿比例自动缩放
  • right:不乘缩放,保持吸附屏幕边缘
    专为大屏展示优化,视觉更稳定。

四、适用场景

  • Vue3 数据可视化大屏
  • 数字孪生项目
  • 监控中心、控制台页面
  • 多端自适应、拼接屏项目
  • 不想写复杂适配逻辑的前端项目

vfit 不是功能最繁杂的,但最简单、最稳定、最适合生产环境,让你把时间花在 ECharts、3D 渲染、业务逻辑上,而不是算像素。


五、项目资源

GitHub:github.com/v-plugin/vf…
官方文档:vfit.raychart.cn


🎁 粉丝专属福利

关注我的微信公众号 RayChart
后台回复关键词:vfit
立即免费领取:
✅ vfit 完整可运行项目模板
✅ 10 套大厂可视化大屏源码
✅ 数字孪生项目素材包
✅ 一对一技术问题答疑

公众号持续更新:Vue3 大屏适配、Web3D、3D 模型压缩、全景预览、自研效率工具、数字孪生实战干货,所有内容均可直接复制到项目使用。

32.29 万元起,华为乾崑加持的奥迪 A6L,开始淡化「行政」标签

去年 12 月,搭载华为乾崑智驾的一汽奥迪 A5L,单月全系销量达到 8057 辆,刷新了车型销量纪录。

这个数字在一定程度上说明了一条路径的可行性,即在豪华车市场整体承压的背景下,引入表现突出的本土供应商来补齐短板,确实有机会抬升产品的市场表现。

A6L 的换代思路,正是奥迪在这样的背景下展开的。

它选择了双线并行,让燃油版 A6L 继续强化机械质感这项传统优势,同时借助华为补足智能化短板;而基于 PPE 豪华纯电平台打造的首款轿车奥迪 A6L e-tron,则试图兼顾智能体验与驾控质感,在纯电市场寻找突破口。

目前率先上市的是燃油版 A6L,共推出 4 个版本,起售价为 32.29 万元,相比 25 款指导价降低了约 10 万元。

纯电版 A6L e-tron 则开启了预售,起售价为 31.3 万元。

淡化「行政感」

燃油版 A6L 是一汽奥迪销量体系里最关键的一款车。

在中国市场销售已接近 30 年,积累下来的不只是知名度,更像一种长期形成的市场认知。提到公务接待、商务出行,很多人第一时间想到的,大概率是 A6L。

但「行政」这个标签,一方面稳住了它的一批核心用户,另一方面也在无形中拉远了另一部分消费者,尤其是那些更在意个性、科技感和新鲜感的人。

所以,无论是发布会上请来韩寒、马龙站台,还是这次设计风格的调整,都能看出奥迪正在有意识地淡化过去的「行政感」。

上一代车型那种稳妥、端正、不容易出错,但也偏保守的设计语言,在新车上已经被明显削弱。

新车前脸的六边形格栅开口更大,整体姿态更低,压迫感更强。内部的 Y 型填充结构,无论采用镀铬还是熏黑处理,都让整车气质更鲜明。前包围两侧的纵向通风口也进一步强化了切割感,视觉重心明显前移。

好在,奥迪也为偏好传统豪华风格的用户推出了更强调稳重气质的雅致型前脸。

「灯厂」新车的矩阵式 LED 大灯单侧集成 48 个发光单元,支持自适应远近光,灯腔内部加入了更精致的金属饰条,并提供 8 种可切换的灯光签名。尾灯组则采用第二代 OLED 分体式尾灯,上下分区设计更强调层次感,底部灯带横向贯穿,单侧包含 198 个 OLED 发光单元,并支持 Car-to-X 通信,可在特定场景下向后车传递制动信息。

车漆方面,除了传奇黑和茉莉白,新车还新增了香槟黑、深洋蓝和曼达洛银 3 款专为中国市场定制的配色,本土化取向十分明确。

空间一直是 A6L 的核心优势,这一代车型又把这一点进一步放大了。

新车长宽高分别为 5142 mm、1874 mm、1450 mm,轴距达到 3066 mm,较上一代增加了 42 mm,与标轴版奥迪 A8 的轴距持平。

增加出来的空间主要体现在后排。腿部余量更充裕,地板中央凸起也更低,长途乘坐的舒适性会得到比较直接的提升。

进入车内,奥迪这次的思路也很明确,就是把舒适性、数字化和豪华感同时往上推。

车内采用五屏联动布局,包括 14.5 寸中控屏、11.9 寸虚拟座舱仪表、13.1 寸 W-HUD 3D 抬头显示,以及带电子防窥功能的 10.9 寸副驾娱乐屏。

主驾座椅支持 18 向电动调节,配备加热、通风和按摩功能;后排靠背最大可调至 35 度,同样具备加热、通风和按摩功能,四驱版后排还支持电动调节。

车内顶棚和上门柱护板采用仿麂皮材质,地毯使用法国簇绒工艺,配合 16 扬声器 B&O 音响和三区独立空调,整体氛围明显朝着更强的感官豪华感推进。

1.96 平方米的全景天幕支持 6 个区块独立控制,夜间还能配合由 112 颗 RGB LED 构成的星空顶和 30 色氛围灯,进一步强化座舱的高级感。

动力部分则是这辆车最能体现奥迪德系底色的地方。

全新 A6L 提供 3 套动力系统,全系匹配 7 速湿式双离合变速箱。2.0T 低功率版最大功率为 150 kW,采用改良版米勒循环和 VTG 可变截面涡轮,整体取向更偏向燃油经济性。

2.0T 高功率版最大功率为 200 kW,基于第五代 EA888 发动机打造,压缩比达到 12.5:1,热效率为 39.4%,燃油喷射压力提升至 500 bar,同样配备 VTG 可变截面涡轮和 48 V 轻混系统。这套系统还引入了 HDI 双电机全域智混架构,采用 P0 + P3 并联混动结构。

集成于变速箱输出轴的 P3 电机可在 0 至 140 km/h 范围内随时介入,额外提供 18 kW 动力输出和 25 kW 能量回收能力,峰值扭矩达到 230 N・m。在低速和泊车工况下,车辆还能实现电机直驱,从而改善平顺性与静谧性。

更值得关注的还是 3.0T V6 版本。它搭载新一代 EA839 发动机,最大功率 270 kW,0 至 100 km/h 加速时间为 4.6 秒,并配备 48 V 轻混系统、quattro 全时四驱、自适应空气悬架,以及同级少见的全轮转向系统,后轮最大转角达到 5 度。

在当前越来越强调混动和纯电的市场环境里,奥迪依然保留这台 V6,去服务那些对六缸发动机仍有执念、同时对新能源路线并不热情的用户。

稳住长处,补齐短板

新车的辅助驾驶系统,值得单独展开讲讲。

硬件层面,新车配备双激光雷达,支持城区 NOA 和高速 NOA,可覆盖自动上下匝道、无保护左转、跨层记忆泊车、循迹倒车、车位到车位等场景,整体能力达到 L2 + 级别。

软件层面,华为乾崑智驾负责感知与决策,而在具体执行端,转向、制动和悬架响应仍由奥迪调校的 VMM 车辆运动管理模块来完成。

也就是说,这套方案是借助华为的感知和算法能力提升智能驾驶水平,同时把车辆最终呈现出的动态质感继续交给奥迪把控。

它想做的,其实是把华为的智能化能力和奥迪多年积累下来的底盘调校经验融合在一起。理想状态下,用户获得的是接近新势力水准的智驾体验,同时还能保留传统德系豪华车应有的行驶质感。

对华为来说,这个项目同样有特殊意义。此前,乾崑智驾主要落地于问界、智界这类与华为绑定更深的品牌,而 A6L 面对的是完全不同的用户群体和产品语境。燃油车的存量市场依然庞大。这样的合作案例,无论未来继续在国内复制,还是进一步向海外市场输出技术方案,都会更有说服力。

A6L e-tron

如果说燃油版 A6L 这次换代的重点,是补上智能化短板,稳住传统豪华轿车的基本盘,那么纯电版 A6L e-tron 就是在用一套更彻底的电动化底层,参与新能源市场的竞争。

这辆车最核心的看点,首先还是智能化。奥迪与华为深度合作,为 A6L e-tron 引入了深度定制的乾崑智驾系统,支持高速 NOA、城区 NOA,以及跨层记忆泊车、循迹倒车、遥控泊车等一整套高级泊车辅助功能。

它的双激光雷达采用嵌入式设计,并加入恒温自清洁能力,再配合摄像头和传感器共 9 处清洗装置,可以缓解雨雪、泥泞等复杂环境下感知能力衰减的问题。

座舱部分,A6L e-tron 也明显在向当下主流高端电动车靠拢。

奥迪与思必驰联合开发了全场景语音助手,支持「可见即可说」、连续多指令识别和方言交互,还加入了眼神控制车窗和 Avatar 语音形象。再配合六屏联动座舱、AR-HUD、电子外后视镜、环抱式交互灯带和九分区可调光全景天幕,整套车内氛围的科技感很强,

A6L e-tron 基于 PPE 豪华纯电平台和 E³ 1.2 电子电气架构打造,配备前后五连杆、FSD 频率选择减震器和自适应空气悬架。

全域 800 V 架构、107 kWh 电池、最长 815 km 续航、270 kW 直流快充,以及 quattro 电动四驱系统,也让它在续航、补能和动态表现上进入了当前豪华纯电市场的主流竞争区间。

这次换代,燃油版 A6L 的重点,在于守住传统豪华轿车在空间、底盘和机械素质上的优势区间,以及品牌长期积累下来的信任感;A6L e-tron 则代表了奥迪在纯电赛道上的另一种回答,它试图通过智能化、电动化和更彻底的本土定制,重新参与市场竞争。

这其实也是 BBA 在新能源冲击下普遍采取的办法:不过度押注单一路线,而是在燃油和纯电两条赛道上同时补齐短板,尽可能为自己争取更多转身时间。

先把现有阵地稳住,再寻找下一阶段的主动权。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

A股三大指数集体收跌,全市场成交不足2万亿

36氪获悉,A股三大指数集体收跌,沪指跌1.09%,深成指跌1.41%,创业板指跌1.34%;保险、培育钻石、存储器概念领跌,黄河旋风跌超6%,佰维存储跌超5%,中国人寿跌超4%;锂电电解液、火电、动力电池概念涨幅居前,石大胜华、晋控电力涨停,多氟多涨超3%;全市场成交不足2万亿。

宁德时代在成都成立科技新公司,注册资本1000万元

36氪获悉,爱企查App显示,近日,成都时代电服科技有限公司成立,法定代表人为张永灿,注册资本1000万元人民币,经营范围包括软件开发、新能源汽车换电设施销售、电动汽车充电基础设施运营等。股东信息显示,该公司由宁德时代旗下时代电服科技有限公司全资持股。

使用 Hooks 构建无障碍 React 组件

无障碍不是上线前才需要检查的清单,而是从第一行代码开始就需要贯彻的设计约束。谈到 React 中的无障碍,大多数开发者会想到 ARIA 属性、语义化 HTML 和屏幕阅读器支持。这些确实重要。但还有一个完整的无障碍类别很少受到关注:尊重用户在操作系统层面已经设置好的偏好。

每个主流操作系统都允许用户配置减少动画、高对比度、深色模式和文本方向等偏好。这些不是装饰性的选择。启用”减少动画”的用户可能患有前庭功能障碍,动画过渡会让他们感到身体不适。启用高对比度的用户可能视力低下。当你的 React 应用忽略这些信号时,这不仅仅是功能缺失——而是一道屏障。

本文将向你展示如何使用 ReactUse 的 hooks 在 React 中检测和响应这些操作系统级别的偏好。我们将覆盖减少动画、对比度偏好、颜色方案检测、焦点管理和文本方向——然后将所有内容整合到一个实际的组件中。

手动监听媒体查询的问题

浏览器通过 CSS 媒体查询(如 prefers-reduced-motionprefers-contrast 和 prefers-color-scheme)暴露操作系统级别的偏好。你可以在 JavaScript 中使用 window.matchMedia 来读取这些值。手动实现的方式如下:

import { useState, useEffect } from "react";

function useManualReducedMotion(): boolean {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  useEffect(() => {
    const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
    setPrefersReducedMotion(mediaQuery.matches);

    const handler = (event: MediaQueryListEvent) => {
      setPrefersReducedMotion(event.matches);
    };

    mediaQuery.addEventListener("change", handler);
    return () => mediaQuery.removeEventListener("change", handler);
  }, []);

  return prefersReducedMotion;
}

这段代码能工作,但存在问题。你需要处理 SSR(window 不存在的情况)、管理事件监听器的清理,并且需要为每个想要跟踪的媒体查询重复这个模式。将这个模式乘以减少动画、对比度、颜色方案和其他查询,你最终会得到大量容易出错的样板代码。

ReactUse 提供的 hooks 封装了这个模式,包含正确的 SSR 处理、适当的清理逻辑,以及当用户更改系统偏好时的实时更新。

useReducedMotion:尊重动画偏好

useReducedMotion hook 检测用户是否在设备上启用了”减少动画”设置。这是你能使用的最具影响力的无障碍 hooks 之一,因为动画可能会给前庭功能障碍的用户带来真实的身体不适。

import { useReducedMotion } from "@reactuses/core";

function AnimatedCard({ children }: { children: React.ReactNode }) {
  const prefersReducedMotion = useReducedMotion();

  return (
    <div
      style={{
        transition: prefersReducedMotion
          ? "none"
          : "transform 0.3s ease, opacity 0.3s ease",
        animation: prefersReducedMotion ? "none" : "fadeIn 0.5s ease-in",
      }}
    >
      {children}
    </div>
  );
}

这里的关键不是简单地禁用动画——而是在没有动画的情况下提供等价的体验。对于大多数用户需要 500ms 淡入的卡片,对于偏好减少动画的用户应该立即显示。内容相同,只是呈现方式不同。

你还可以使用这个 hook 在不同的动画策略之间切换:

import { useReducedMotion } from "@reactuses/core";

function PageTransition({ children }: { children: React.ReactNode }) {
  const prefersReducedMotion = useReducedMotion();

  if (prefersReducedMotion) {
    // 即时过渡——没有动画,但仍然有视觉变化
    return <div style={{ opacity: 1 }}>{children}</div>;
  }

  // 为未选择减少动画的用户提供完整的滑入动画
  return (
    <div
      style={{
        animation: "slideInFromRight 0.4s ease-out",
      }}
    >
      {children}
    </div>
  );
}

usePreferredContrast:适应对比度需求

usePreferredContrast hook 读取 prefers-contrast 媒体查询,告诉你用户想要更多对比度、更少对比度,还是没有偏好。这对视力低下的用户至关重要。

import { usePreferredContrast } from "@reactuses/core";

function ThemedButton({ children, onClick }: {
  children: React.ReactNode;
  onClick: () => void;
}) {
  const contrast = usePreferredContrast();

  const getButtonStyles = () => {
    switch (contrast) {
      case "more":
        return {
          backgroundColor: "#000000",
          color: "#FFFFFF",
          border: "3px solid #FFFFFF",
          fontWeight: 700 as const,
        };
      case "less":
        return {
          backgroundColor: "#E8E8E8",
          color: "#333333",
          border: "1px solid #CCCCCC",
          fontWeight: 400 as const,
        };
      default:
        return {
          backgroundColor: "#3B82F6",
          color: "#FFFFFF",
          border: "2px solid transparent",
          fontWeight: 500 as const,
        };
    }
  };

  return (
    <button onClick={onClick} style={getButtonStyles()}>
      {children}
    </button>
  );
}

当用户请求更高对比度时,你应该增大前景和背景颜色之间的差异、使用更粗的字体粗细、让边框更明显。当他们请求更低对比度时,柔化视觉强度。默认分支处理未设置偏好的用户。

usePreferredColorScheme:系统主题检测

usePreferredColorScheme hook 告诉你用户的操作系统是设置为浅色模式、深色模式,还是没有偏好。这是构建主题感知组件的基础。

import { usePreferredColorScheme } from "@reactuses/core";

function AdaptiveCard({ title, body }: { title: string; body: string }) {
  const colorScheme = usePreferredColorScheme();

  const isDark = colorScheme === "dark";

  return (
    <div
      style={{
        backgroundColor: isDark ? "#1E293B" : "#FFFFFF",
        color: isDark ? "#E2E8F0" : "#1E293B",
        border: `1px solid ${isDark ? "#334155" : "#E2E8F0"}`,
        borderRadius: "8px",
        padding: "24px",
      }}
    >
      <h3 style={{ marginTop: 0 }}>{title}</h3>
      <p>{body}</p>
    </div>
  );
}

如果你只需要一个简单的布尔值判断,ReactUse 还提供了 usePreferredDark,当用户偏好深色方案时返回 true。如果你需要一个完整的深色模式切换并持久化用户的选择,useDarkMode 可以开箱即用。

对于更细粒度的媒体查询控制,useMediaQuery 让你订阅任何 CSS 媒体查询字符串并获得实时更新。

useFocus:键盘导航和焦点管理

键盘导航是核心无障碍要求。无法使用鼠标的用户依赖 Tab 键在交互元素之间移动。useFocus hook 提供了对焦点的编程控制,这对于模态对话框、下拉菜单和动态内容至关重要。

import { useRef } from "react";
import { useFocus } from "@reactuses/core";

function SearchBar() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [focused, setFocused] = useFocus(inputRef);

  return (
    <div>
      <input
        ref={inputRef}
        type="search"
        placeholder="Search..."
        style={{
          outline: focused ? "2px solid #3B82F6" : "1px solid #D1D5DB",
          padding: "8px 12px",
          borderRadius: "6px",
          width: "100%",
        }}
      />
      <button onClick={() => setFocused(true)}>
        Focus Search (Ctrl+K)
      </button>
    </div>
  );
}

这个 hook 同时返回当前焦点状态和一个设置函数。你可以使用焦点状态来应用视觉指示器(超出浏览器默认样式),并使用设置函数来编程式地移动焦点——例如,当模态框打开时或当触发键盘快捷键时。

将此与 useActiveElement 配合使用,可以跟踪整个应用中当前拥有焦点的元素,这对于构建焦点陷阱和跳过导航链接非常有用。

useTextDirection:RTL 和 LTR 支持

国际化和无障碍有很大的重叠。useTextDirection hook 检测和管理文档的文本方向,支持从左到右(LTR)和从右到左(RTL)布局。

import { useTextDirection } from "@reactuses/core";

function NavigationMenu() {
  const [dir, setDir] = useTextDirection();

  return (
    <nav
      style={{
        display: "flex",
        flexDirection: dir === "rtl" ? "row-reverse" : "row",
        gap: "16px",
        padding: "12px 24px",
      }}
    >
      <a href="/">Home</a>
      <a href="/about">About</a>
      <a href="/contact">Contact</a>
      <button onClick={() => setDir(dir === "rtl" ? "ltr" : "rtl")}>
        Toggle Direction
      </button>
    </nav>
  );
}

RTL 支持影响的不仅仅是文本对齐。导航顺序、图标位置和 margin/padding 方向都需要翻转。通过使用 useTextDirection 作为唯一数据源,你可以构建自动适应的布局逻辑。

综合示例:无障碍通知组件

下面是一个将多个无障碍 hooks 整合到单个组件中的实际示例——一个尊重动画偏好、适应对比度设置、跟随系统颜色方案并正确管理焦点的通知提示:

import { useRef, useEffect } from "react";
import {
  useReducedMotion,
  usePreferredContrast,
  usePreferredColorScheme,
  useFocus,
} from "@reactuses/core";

interface NotificationProps {
  message: string;
  type: "success" | "error" | "info";
  visible: boolean;
  onDismiss: () => void;
}

function AccessibleNotification({
  message,
  type,
  visible,
  onDismiss,
}: NotificationProps) {
  const prefersReducedMotion = useReducedMotion();
  const contrast = usePreferredContrast();
  const colorScheme = usePreferredColorScheme();
  const dismissRef = useRef<HTMLButtonElement>(null);
  const [, setFocused] = useFocus(dismissRef);

  const isDark = colorScheme === "dark";
  const isHighContrast = contrast === "more";

  // 通知出现时将焦点移至关闭按钮
  useEffect(() => {
    if (visible) {
      setFocused(true);
    }
  }, [visible, setFocused]);

  if (!visible) return null;

  const colors = {
    success: {
      bg: isDark ? "#064E3B" : "#ECFDF5",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#10B981" : "#6EE7B7",
      text: isDark ? "#A7F3D0" : "#065F46",
    },
    error: {
      bg: isDark ? "#7F1D1D" : "#FEF2F2",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#EF4444" : "#FCA5A5",
      text: isDark ? "#FECACA" : "#991B1B",
    },
    info: {
      bg: isDark ? "#1E3A5F" : "#EFF6FF",
      border: isHighContrast ? "#FFFFFF" : isDark ? "#3B82F6" : "#93C5FD",
      text: isDark ? "#BFDBFE" : "#1E40AF",
    },
  };

  const scheme = colors[type];

  return (
    <div
      role="alert"
      aria-live="assertive"
      style={{
        position: "fixed",
        top: "16px",
        right: "16px",
        backgroundColor: scheme.bg,
        color: scheme.text,
        border: `${isHighContrast ? "3px" : "1px"} solid ${scheme.border}`,
        borderRadius: "8px",
        padding: "16px 20px",
        maxWidth: "400px",
        display: "flex",
        alignItems: "center",
        gap: "12px",
        fontWeight: isHighContrast ? 700 : 400,
        // 尊重动画偏好
        animation: prefersReducedMotion ? "none" : "slideIn 0.3s ease-out",
        transition: prefersReducedMotion ? "none" : "opacity 0.2s ease",
      }}
    >
      <span style={{ flex: 1 }}>{message}</span>
      <button
        ref={dismissRef}
        onClick={onDismiss}
        aria-label="关闭通知"
        style={{
          background: "none",
          border: `1px solid ${scheme.text}`,
          color: scheme.text,
          cursor: "pointer",
          borderRadius: "4px",
          padding: "4px 8px",
          fontWeight: isHighContrast ? 700 : 500,
        }}
      >
        关闭
      </button>
    </div>
  );
}

这个组件展示了几个无障碍原则的协同工作:

  1. role="alert" 和 aria-live="assertive"  确保屏幕阅读器立即播报通知。
  2. useReducedMotion 为偏好减少动画的用户禁用滑入动画。
  3. usePreferredContrast 为需要更高对比度的用户增加边框宽度和字体粗细。
  4. usePreferredColorScheme 根据用户的浅色或深色主题适配所有颜色。
  5. useFocus 将键盘焦点移至关闭按钮,使用户无需使用鼠标就能操作通知。

为什么 Hooks 是无障碍的正确抽象

Hooks 具有可组合性。每个无障碍关注点都封装在自己的 hook 中,你可以按需组合它们。一个简单的按钮可能只使用 usePreferredContrast。一个复杂的模态框可能使用我们介绍的全部五个 hooks。这些 hooks 互相独立,这意味着你可以逐步采用它们,无需重构现有代码。

Hooks 还能实时响应变化。如果用户在你的应用打开时从浅色切换到深色模式,hooks 会更新,你的组件会使用新的偏好重新渲染。这是仅使用 CSS 的方案(依赖静态类名)难以实现的。

安装

通过包管理器安装 ReactUse:

npm install @reactuses/core

然后导入你需要的 hooks:

import {
  useReducedMotion,
  usePreferredContrast,
  usePreferredColorScheme,
  useFocus,
  useTextDirection,
} from "@reactuses/core";

相关 Hooks

ReactUse 提供了 100 多个 React hooks。探索全部 →

欧洲央行管委内格尔:4月加息只是一个选项

欧洲央行管委内格尔:随着时间一天天推移,通胀风险正在不断上升。到4月份我们将掌握足够数据,以决定是需要采取行动,还是可以再等等再看。4月加息当然是一个选项,但只是一个选项。(新浪财经)

别再混淆了!JS类型转换底层:valueOf vs toString vs Symbol.toPrimitive 详解

js获取对象值的方法有三种valueOf()toString()symbol.toPrimitive这些其实是类型转换的问题;三种方式本质上略微不同;

我们知道在js中,'一切皆为对象'。每个对象都有一个toString()方法和valueOf方法,其中toString()方法返回一个表示该对象的字符串,valueOf 方法返回该对象的原始值。

一、valueOf() 与 toString()

基本类型的情况下:

const str = "hello",n = 123,bool = true;
console.log(typeof(str.toString()) + "_" + str.toString())        //string_hello
console.log(typeof(n.toString()) + "_" + n.toString()  )            //string_123
console.log(typeof(bool.toString()) + "_" + bool.toString())        //string_true

console.log(typeof(str.valueOf()) + "_" + str.valueOf())            //string_hello
console.log(typeof(n.valueOf()) + "_" + n.valueOf())                //number_123
console.log(typeof(bool.valueOf()) + "_" + bool.valueOf())          //boolean_true

// valueOf
console.log(str.valueOf() === str)  // true
console.log(n.valueOf() === n) // true
console.log(bool.valueOf() === bool) // true
// toString
console.log(str.toString() === str) // true
console.log(n.toString() === n)     // false
console.log(bool.toString() === bool) // false

toString 方法对于值类型数据使用而言,其效果相当于类型转换,将原类型转为字符串。

valueOf 方法对于值类型数据使用而言,其效果将相当于返回原数据。 引用类型的情况下:

var obj = {};

console.log(obj.toString());    //[object Object] 返回对象类型
console.log(obj.valueOf());     //{} 返回对象本身

综合例子:

let test = { 
    i: 10, 
    toString: function() {
       console.log('toString');
       return this.i; 
    }, 
    valueOf: function() { 
       console.log('valueOf');
       return this.i; 
    }
} 
console.log(test);          // { I:10, toString: f, valueOf: f }
console.log(+test);         // 10 valueOf
console.log('' + test);       // 10 valueOf
console.log(String(test));  // 10 toString
console.log(Number(test));  // 10 valueOf
console.log(test == '10');  // true valueOf
console.log(test == '10');  // true valueOf
console.log(test === '10'); // false

个人理解:

带有运算符的获取值的方式都会走valueOf()方法;强转字符串的时候走toString()方法;

二、toString() 和 String()

  • toString()
    • toString()可以将所有的数据都转换为字符串,但是要排除nullundefined
    • nullundefined不能转换为字符串,nullundefined调用toString()方法会报错
    • 如果当前数据为数字类型,则toString()括号中的可以写一个数字,代表进制,可以将数字转化为对应进制字符串。
var num = 123;
console.log(num.toString()+'_'+ typeof(num.toString()));    //123_string
console.log(num.toString(2)+'_'+typeof(num.toString()));    //1111011_string
console.log(num.toString(8)+'_'+typeof(num.toString()));    //173_string
console.log(num.toString(16)+'_'+typeof(num.toString()));   //7b_string
  • String()
    String()可以将nullundefined转换为字符串,但是没法转进制字符串。

三、Symbol.toPrimitive

对象的Symbol.toPrimitive属性。指向一个方法。该对象被转化为原始类型的值时,会调用这个办法,返回该对象对应的原始类型值。 Symbol.toPrimitive被调用时,会接受一个字符串参数,表示当前运算的模式,一个有三种模式。

  • Number: 该场合需要转成数值
  • String: 该场合需要转成字符串
  • Default: 该场合可以转成数值,也可以转成字符串。

Symbol.toPrimitive在类型转换方面,优先级是最高的

const test = { 
i: 10, 
toString: function() {
   console.log('toString');
  return this.i; 
}, 
valueOf: function() { 
   console.log('valueOf');
   return this.i; 
},
    [Symbol.toPrimitive](hint) {
        if(hint === 'number'){
          console.log('Number场景');
          return 123;
        }
        if(hint === 'string'){
          console.log('String场景');
          return 'str';
        }
        if(hint === 'default'){
          console.log('Default 场景');
          return 'default';
        }
    }
}

console.log(test);          // { i:10, toString: f, valueOf: f, Symbol(Symbol.toPrimitive): f }
console.log(+test);         // 123 Number场景
console.log(''+test);       // default Default 场景
console.log(String(test));  // str String场景
console.log(Number(test));  // 123 Number场景
console.log(test == '10');  // false default场景
console.log(test === '10'); // false

上面代码中、+test中的加号命名为一元加号+test本质就是转成数值的意思;

Tips

console.log(3 + test);  // 3default Default 场景
console.log(3 - test);  // -120 Number场景
console.log(3 * test);  // 369 Number场景
console.log(3 / test);  // 0.0243902 Number场景

以上的代码中,加减乘除都算运算符,本应都应该走Number场景,但是唯独+号走了Default场景

四、一元加号

一元加号运算符 + 在其操作数之前,并计算其操作数;但如果尚未将其转换为数字,则尝试将其转换为数字

console.log(+'')  // 0
console.log(+true)  // 1
console.log(+false)  // 0
console.log(+'hello')  // NaN

console.log(1 + +"2" + "2")  // 32

一元加法是将某事物转换为数字的最快和首选方法,因为它不对数字执行任何其他操作。

如果它无法解析特定值,它将输出为NaN

感谢您抽出宝贵的时间观看本文;本文是JavaScript系列的第 6 篇,后续会持续更新,欢迎关注~

斯柯达将退出中国,大众中国回应:销售到年中,持续提供售后

据媒体报道,针对斯柯达品牌推出中国市场传闻,大众中国方面回应称:斯柯达汽车在中国的销售将持续到2026年年中,此后仍将为车主持续提供得全面的保修和售后服务支持。大众中国方面表示,斯柯达汽车对全球战略进行了调整,将重点聚焦印度、东盟等高增长市场。同时,大众中国再次强调,中国始终是大众汽车集团全球战略的核心。集团在华建有近40座工厂,服务超5000万名客户,同时设立了德国总部外规模最大的研发中心——大众汽车(中国)科技有限公司(VCTC),以持续推进智能网联汽车技术的开发。(财联社)

浏览器窗口最小化的时候,setInterval 执行变慢,解决方案

方法一:使用 Web Worker 保持精确计时

1. 创建 Worker 文件(timer-worker.js)
// timer-worker.js
let intervalId = null;

self.addEventListener('message', (e) => {
  const { type, interval } = e.data;
  
  if (type === 'start') {
    // 停止已有的定时器
    if (intervalId) clearInterval(intervalId);
    // 启动新的定时器
    intervalId = setInterval(() => {
      self.postMessage('tick');
    }, interval);
  } else if (type === 'stop') {
    if (intervalId) {
      clearInterval(intervalId);
      intervalId = null;
    }
  }
});
2. 在主线程中使用 Worker
// 主线程代码
const worker = new Worker('timer-worker.js');

// 监听 Worker 发来的消息
worker.addEventListener('message', (e) => {
  if (e.data === 'tick') {
    // 这里执行原本需要定时执行的任务
    console.log('定时任务执行', new Date());
  }
});

// 启动定时器,间隔 1000ms
worker.postMessage({ type: 'start', interval: 1000 });

// 停止定时器
// worker.postMessage({ type: 'stop' });

优点:即使页面最小化或切换到后台,Worker 中的 setInterval 依然保持设定的频率。 注意:Worker 中不能直接访问 DOM,需要通过 postMessage 与主线程通信,因此适合执行不直接操作页面的逻辑(如数据轮询、计时更新等)。

方法二:结合 Page Visibility API 动态调整策略

如果无法使用 Worker(例如需要频繁操作 DOM),可以监听页面的可见性变化,当页面变为不可见时,改用更宽松的策略,但无法彻底避免频率限制。

let intervalId = null;
let isPageVisible = true;

// 监听页面可见性变化
document.addEventListener('visibilitychange', () => {
  isPageVisible = !document.hidden;
  
  if (isPageVisible) {
    // 页面可见时恢复原有频率
    startTimer(1000);
  } else {
    // 页面不可见时,可以延长间隔或停止某些非关键任务
    // 但无法强制浏览器按原频率执行
  }
});

function startTimer(interval) {
  if (intervalId) clearInterval(intervalId);
  intervalId = setInterval(() => {
    console.log('任务执行', new Date());
  }, interval);
}

startTimer(1000);

局限:浏览器仍会限制后台页面的计时器频率,因此无法真正“解决”变慢问题,只能根据场景适配。

方法三:使用 setTimeout 递归 + 时间补偿

通过记录实际执行时间与预期时间的偏差,动态调整下一次 setTimeout 的延迟,可以在一定程度上缓解频率降低带来的累积误差,但依然无法绕过浏览器的底层限制。

let expectedTime = 0;
let timeoutId = null;

function scheduleTask(interval) {
  if (timeoutId) clearTimeout(timeoutId);
  
  const now = Date.now();
  if (expectedTime === 0) {
    expectedTime = now + interval;
  } else {
    expectedTime += interval;
  }
  
  const delay = Math.max(0, expectedTime - now);
  timeoutId = setTimeout(() => {
    // 执行实际任务
    console.log('任务执行', new Date());
    scheduleTask(interval);
  }, delay);
}

scheduleTask(1000);

说明:这种方法可以确保任务在后台仍按设定的间隔执行,但 setTimeout 同样受浏览器限制(最小间隔通常为 1 秒),所以实际效果有限。

总结

  • 如果定时任务不涉及 DOM 操作(如轮询数据、发送请求、计时更新),Web Worker 是最佳选择,能完美解决后台频率限制问题。

  • 如果必须操作 DOM,则只能接受浏览器对后台页面的优化,并结合可见性 API 调整业务逻辑。

选择哪种方案取决于你的具体需求。

在 vue3中如何使用

1. 创建 Worker 文件

在 src/workers 目录下创建 timer.worker.js:

// src/workers/timer.worker.js
let intervalId = null

self.addEventListener('message', (e) => {
  const { type, interval } = e.data

  if (type === 'start') {
    if (intervalId) clearInterval(intervalId)
    intervalId = setInterval(() => {
      self.postMessage('tick')
    }, interval)
  } else if (type === 'stop') {
    if (intervalId) {
      clearInterval(intervalId)
      intervalId = null
    }
  }
})

注意:如果使用 Vite,可以直接用 ?worker 后缀导入,也可以使用 new Worker(new URL(...)) 方式(推荐)。

2. 封装一个组合式函数(Composable)

创建一个 useWorkerTimer.ts(或 .js):

// composables/useWorkerTimer.js
import { ref, onMounted, onUnmounted } from 'vue'

export function useWorkerTimer(interval = 1000, autoStart = true) {
  const worker = ref(null)
  const tick = ref(0)          // 计数,可用来触发响应式更新
  const isRunning = ref(false)

  // 初始化 Worker
  const initWorker = () => {
    // 兼容 Vite 的导入方式(推荐)
    worker.value = new Worker(new URL('../workers/timer.worker.js', import.meta.url))

    worker.value.addEventListener('message', (e) => {
      if (e.data === 'tick') {
        tick.value++          // 每次触发都会更新,可驱动视图
      }
    })
  }

  // 启动定时器
  const start = () => {
    if (!worker.value) initWorker()
    worker.value?.postMessage({ type: 'start', interval })
    isRunning.value = true
  }

  // 停止定时器
  const stop = () => {
    worker.value?.postMessage({ type: 'stop' })
    isRunning.value = false
  }

  // 清理 Worker
  const terminate = () => {
    stop()
    if (worker.value) {
      worker.value.terminate()
      worker.value = null
    }
  }

  // 自动管理生命周期
  onMounted(() => {
    if (autoStart) start()
  })

  onUnmounted(() => {
    terminate()
  })

  return {
    tick,          // 响应式计数,可在模板中显示
    isRunning,     // 运行状态
    start,
    stop,
    terminate
  }
}
3. 在 Vue 组件中使用
<template>
  <div>
    <p>Worker 定时器已运行:{{ tick }} 次</p>
    <button @click="start" :disabled="isRunning">启动</button>
    <button @click="stop" :disabled="!isRunning">停止</button>
  </div>
</template>

<script setup>
import { useWorkerTimer } from '@/composables/useWorkerTimer'

// 间隔 1000ms,自动启动
const { tick, isRunning, start, stop } = useWorkerTimer(1000, true)
</script>
4. 进阶:传递数据与主线程交互

如果需要在 Worker 中执行更复杂的任务(例如发起网络请求),可以通过 postMessage 传递数据。 Worker 端接收数据

// timer.worker.js
self.addEventListener('message', async (e) => {
  const { type, payload } = e.data
  if (type === 'fetch') {
    const res = await fetch(payload.url)
    const data = await res.json()
    self.postMessage({ type: 'fetchResult', data })
  }
})
主线程发送并接收结果
// 在组件中
worker.value?.postMessage({
  type: 'fetch',
  payload: { url: 'https://api.example.com/data' }
})

worker.value?.addEventListener('message', (e) => {
  if (e.data.type === 'fetchResult') {
    console.log('获取到数据:', e.data.data)
  }
})
5. 注意事项
  1. Worker 文件路径 在 Vite 中,使用 new URL('../workers/timer.worker.js', import.meta.url) 可以保证开发和生产环境路径正确。 如果使用 Vue CLI,可以简单用 new Worker('@/workers/timer.worker.js'),但需要确保 Webpack 正确处理。

  2. 响应式数据更新 通过 tick 的更新可以驱动视图重新渲染,这是通过 Vue 的响应式系统自动完成的。

  3. 生命周期清理 在组件卸载时,务必调用 worker.terminate() 避免内存泄漏。上面封装的 useWorkerTimer 已处理。

  4. 兼容性 Web Worker 支持现代浏览器及移动端,如果需要兼容非常古老的浏览器,可使用降级方案(如 fallback 到 setInterval)。

总结

在 Vue 3 中使用 Web Worker 保持精确计时,只需三步:

  • 创建独立的 Worker 文件,内部使用 setInterval 并 postMessage 通知主线程。

  • 封装组合式函数管理 Worker 生命周期(创建、启动、停止、销毁)。

  • 在组件中调用该函数,即可享受不受页面可见性影响的稳定定时器。

这种方式非常适合轮询、实时数据更新、倒计时等需要精确计时的业务场景。

❌