普通视图
美联储4月维持利率不变的概率为99.5%
沪上阿姨:控股股东在内的全体IPO前股东自愿延长禁售期
外围扰动正逐步钝化,机构看好科技主线热度延续
喜宝婴幼儿食品疑遭鼠药污染,欧洲多国下架喜宝产品
国投智能:公司已构建全链条AI安全能力体系
曦智科技:拟全球发售1379.52万股H股,发售价不超183.20港元
国家发展改革委下达2026年第二批“两重”建设项目清单
迈威生物:拟全球发售4713.02万股H股,发售价不超30.71港元
2026中国互联网发展座谈会在京召开
美国能源部长赖特:汽油价格在明年之前,能否跌破每加仑 3 美元尚不确定
内存价格可能还要涨,HBM千亿美元大市场来了
美发射翻新“新格伦”运载火箭并成功回收一级箭体
行业首发!线控制动量产上车,奇瑞星途 EX7 上市售价 19.99 万元起
![]()
今晚,奇瑞星途品牌的全新车型——星途 EX7 正式上市。星途推出了纯电与增程两种动力共 6 款车型,官方指导价为 19.99 万至 26.39 万元。
![]()
从定价和车型布局来看,这台车同样是冲着当下的主流家用市场来的。
在现在的新能源市场,谈论「豪华」似乎成了一件很容易的事。冰箱、大屏、沙发几乎成了新车的标配,但在内卷的狂潮中,星途这次想讲的故事有些不太一样。
任何豪华品牌都必须以安全、性能、品质为根基,没有这个根基,再拼搏一辈子都是不能成功的。
![]()
奇瑞汽车股份有限公司常务副总裁张国忠在现场的这句话,奠定了整场发布会的基调。比起单纯的配置叠加,星途 EX7 更想强调一台车作为交通工具的基础素质。
首先是直观的尺寸数据。
星途 EX7 采用了「星际美学」设计语言,长宽高分别达到了 4988mm、1975mm 和 1710mm,轴距长达 3000mm。在现场,高管团队将这种车身比例简单概括为「532」。
![]()
作为一台主打「大五座」的 SUV,近 5 米的车长和 3 米的轴距,主要服务于舱内的两排乘客。
![]()
除了宽裕的第二排乘坐空间,放倒座椅后还能形成一个长达 2.2 米的平整空间。搭配 1800 升的后备箱容积和 60 升的前备箱,装载能力足够应对家庭远途出行。
![]()
回到车内,星途 EX7 也没有落下当下流行的智能化体验。
中控台配备了一块 30 英寸的 6K 一体屏,内置高通骁龙 8295P 芯片,以支撑 AI 灵犀智舱 2.0 的运行。
![]()
前排给到了零重力座椅,舱内还配备了一台 9 升容量的冰箱,支持 -18°C 到 50°C 的宽温域调节。
不过,真正让人觉得好用的,反而是那些不起眼的细节设计。
开发团队并没有把所有功能都塞进中控屏里。星途 EX7 将第二排座椅的通风、加热和按摩控制按键,直接做成了物理按键,安置在后排的门板侧边。
![]()
而在音响系统的硬件思路上,星途的做法也很典型。新车搭载了 23 个扬声器的音响系统,为了减少音频信号的传输损耗,工程师在金属插接线上使用了足金材质。
奇瑞汽车股份有限公司执行副总裁李学用在台上解释了原因:「我们为什么要用真黄金?是为了保证信号的稳定,让信号抗氧化、百分之百传递,让整车的音响更稳。」
![]()
这种死磕物理细节的做法,倒确实挺符合外界对奇瑞「理工男」的固有印象。
动力上,星途 EX7 提供了纯电和增程两套方案,以此来覆盖不同用户的出行半径。
![]()
增程版搭载了一台热效率接近 46% 的 1.5T 发动机作为增程器,配合 41.16 度的电池包,CLTC 纯电续航在 225 到 245 公里之间。如果是四驱版本,电机总功率可以达到 374kW。
纯电版全系基于 800V 高压平台打造。它提供了 70.01 度和 100.26 度两款宁德时代电池,对应的 CLTC 续航里程分别为 600、682 和 726 公里。
![]()
另外,李学用还提到了一个「期货」。
到今年四季度,我们就能够买到搭载半固态电池的 EX7,它的能量密度将由现在的 200Wh/kg 提升到 300Wh/kg。
在底盘硬件上,这台车也堆得足够满。
飞鱼底盘 3.0 系统的物理底子,是前全铝合金双叉臂加上后五连杆的独立悬架,另外空气弹簧和 CDC 电磁减振系统自然也没有缺席。不过,硬件堆料只是及格线,底盘的灵魂在于后期的调校。
![]()
星途这次找来了老牌工程公司西班牙 IDIADA(伊迪亚达)进行联合调校。谈到这背后的功夫,李学用在台上打了个很形象的比方。
底盘调校是一门很深刻的学科。它就像老中医一样,要有深厚的底蕴;又要像西医一样,有强大的数据库来支撑。
![]()
为了找准几块减振阀片在不同路况下的细微差别,底盘工程师需要常年在全球各地跑测试,最终呈现出来的数据确实直观。
在实测中,这台自重不轻的中大型 SUV,做出了 145km/h 的双移线成绩,麋鹿测试也达到了 76km/h。对于一台接近 5 米长的大车来说,这是一个相当不错的动态指标。
但这还不是今晚的技术重头戏。
![]()
整场发布会花去最多时间拆解的,是那套全球首搭的航空级 EMB 线控制动系统。简单来说,它彻底抛弃了汽车用了一百多年的液压刹车管路,直接用电信号控制电机来夹紧刹车盘。
这样做的好处立竿见影,没有了液压油的传递延迟,响应时间直接缩短到了 90 毫秒。体现在路面上,就是这台大车百公里刹停只需要 33 米级,并且连续高频重刹也不会出现明显的热衰减。
![]()
但纯电控刹车,大家最担心的必然是「万一断电了怎么办」。
工程师给出的答案是物理层面的「套娃」。系统内置了两路独立的电源、两颗独立的控制芯片,哪怕是遇到最极端的情况,它还留了最后一道「对角线交叉冗余」。
就算有一个对角坏了,另外一个对角可以同样制动。装 EMB 的车,只需要两个轮子(有刹车)就可以。
![]()
至于新能源车绕不开的辅助驾驶,星途 EX7 标配了基于地平线 HSD 打造的猎鹰 700 辅助驾驶系统。车顶的激光雷达和英伟达 Orin-Y 芯片,组成了核心的感知与计算中枢,支持高速和城市的领航辅助。
被动安全方面,车身使用了 88% 的高强度钢和铝合金,整车扭转刚度达到了 40000 Nm/deg。
![]()
在 20 万出头的新能源 SUV 市场,想卖好一辆车变得越来越复杂。
星途 EX7 身上保留着奇瑞一贯的固执,把大量的精力花在了底盘悬架和一套全新的电控刹车上。它其实代表了一部分传统车企的坚持,认定一台车终究还是要回归驾驶和安全本身。
#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。
2026 年前端工程师面试:一份来自面试官视角的真实复盘
前言:为什么我要写这篇文章
前两天和一个在高校和企业都面试过不少候选人的"面试官老炮"聊天,他听过太多候选人抱怨面试内容脱离实际、工作用不到。也听过面试官抱怨候选人只会背题、动手能力差。有意思的是,这两拨人的抱怨,往往都对。
今天我想换个视角——不站在候选人角度刷题,也不站在理论派角度讲八股文,而是站在有实际招聘需求、真正要带团队干活的面试官视角,聊聊 2026 年的前端工程师面试,到底在考什么、为什么这么考。
核心结论先行
2026 年的前端面试,考察维度已经发生了结构性变化:
| 维度 | 占比 | 变化趋势 |
|---|---|---|
| AI 工程能力 | 20% | 大幅上升,2024 年几乎不考 |
| 项目深度与结果 | 30% | 持续核心,但问法变了 |
| Coding 基本功 | 20% | 稳定,需要证明你能写 |
| 框架原理(React 为主) | 20% | 稳定,但要理解本质 |
| 系统设计 | 10% | 稳定,外企中大厂标配 |
一个重要变化:纯背题的通过率断崖式下跌。面试官开始问"这个方案你实际落地过吗""遇到什么问题""怎么取舍"。
一、AI 工程能力:这是 2026 年的新标配
为什么 AI 能力突然重要了?
原因很简单:团队里用 AI 的工程师,和不用的工程师,生产效率差 2-3 倍。不是 10-20%,是 2-3 倍。
任何一个正常的技术团队 Leader,只要用过了,都会想把 AI 用到团队里。所以面试 AI 能力,本质上是在判断:你能不能快速融入一个 AI-Augmented 的团队。
面试怎么考?
通常分三个层次:
层次一:工具使用(基础分)
- 你用哪些 AI 编程工具?
- 你的日常 AI 工作流是什么?
- 你如何保证 AI 生成代码的质量?
这三个问题几乎每场面试都会问到。如果你还在说"我就用 ChatGPT 写代码",那只能得基础分。
层次二:工程化落地(核心竞争力)
- 你在公司里推动过 AI 工作流落地吗?
- 团队如何统一 AI 工具配置?(Rule 文件、MCP 服务等)
- 怎么量化 AI 提效的价值?
- AI 生成的代码谁来 Review?流程是什么?
这部分是拉开差距的关键。很多候选人用 AI 用得很溜,但从来没想过如何让团队也用好。
层次三:边界认知(加分项)
- AI 能帮你做什么?不能帮你做什么?
- 什么时候你选择不用 AI?
- AI 生成的代码可能有哪些隐蔽的坑?
这部分考察的是你的判断力和工程素养。AI 不是万能的,知道它的边界在哪里,才是成熟工程师的标志。
我的 AI 工作流(可直接写在简历里的框架)
需求 → Context 构建 → AI 生成 → 质量保障 → 持续优化
1. Context 构建阶段
- 维护项目 Rule 文件(代码规范、架构约束)
- 配置 MCP 服务(提供项目特定上下文)
- 沉淀 Skills(常见任务的最佳实践)
- 持续更新 README 和 Onboarding 文档
2. AI 生成阶段
- UI to Code:设计稿直接生成组件代码
- 组件生成:可复用组件批量生产
- 逻辑实现:业务逻辑 + 状态管理
- 测试生成:单元测试 + 集成测试
3. 质量保障阶段
- 静态检查:ESLint + TypeScript + Prettier
- 自动测试:Jest + React Testing Library
- CI Pipeline:自动化流水线
- Code Review:AI 辅助 + 人工 Review 结合
4. 持续优化阶段
- 收集 AI 生成代码的运行时反馈
- 优化 Prompt 和上下文配置
- 沉淀最佳实践到知识库
高质量 Prompt 的五要素
面试时经常会被问到"怎么写 Prompt",可以参考这个框架:
| 要素 | 说明 | 示例 |
|---|---|---|
| 目标 | 要实现什么功能 | "帮我实现一个可复用的分页组件" |
| 约束 | 技术栈、代码规范 | "使用 React + TypeScript,遵循项目 ESLint 规则" |
| 上下文 | 相关代码、接口定义 | "已有基础的 Table 组件,路径在 src/components/Table" |
| 输出 | 期望的交付物 | "完整的 .tsx 组件 + 对应的单元测试" |
| 质量要求 | 类型安全、错误处理、可访问性 | "必须标注完整的 TypeScript 类型,处理 loading/error 状态" |
二、项目深度:这是永远的压轴戏
变了什么?问法升级了
以前的项目问题:
"请介绍一下你做过的最有挑战性的项目。"
现在的问题:
"你在那个项目里遇到的最大技术挑战是什么?你尝试了几种方案?为什么最终选择了这一种?"
以前是描述题,现在是决策题。
面试官不关心你做过什么,关心的是你怎么做决定。
推荐的回答框架:决策链法则
每个项目准备一套"决策链":
背景 → 问题 → 约束 → 方案对比 → 最终选择 → 结果 → 复盘
背景:项目的业务背景是什么?你负责什么? 问题:核心挑战是什么?量化指标是什么? 约束:时间、技术栈、团队能力等限制条件 方案对比:你考虑了哪几种方案?各自的优劣? 最终选择:为什么选了这个?trade-off 是什么? 结果:最终的成果,用数据说话 复盘:如果重来一次,你会怎么做?
三个必杀项目类型
无论你有多少项目,建议准备这三类:
1. 性能优化项目(技术深度证明)
这是面试官的最爱,因为数据清晰、过程明确。
参考回答模板:
"我们有一个管理后台,包含 10 万条数据的表格,用户反馈滚动卡顿。
我先用 React DevTools Profiler 定位到问题是每帧渲染的行数太多,不是分页能解决的。
调研了三个方案:虚拟滚动(react-window)、分片渲染、骨架屏。最终选了虚拟滚动,因为这是唯一能满足无限滚动 + 搜索 + 排序三个需求的方案。
实现的时候遇到两个坑:动态行高和滚动位置保持。行高问题通过预先测量+缓存解决,滚动位置用 key + scrollTop 记录。
结果:首屏从 3s 降到 0.3s,滚动帧率从 20fps 到 60fps,内存从 500MB 降到 50MB。"
2. 架构设计项目(系统思维证明)
可以是一次重构,也可以是新项目的架构选型。
关键不是选了什么框架,而是为什么这么选。
"我们系统从 jQuery 迁移到 React,我主导了技术方案选型。
调研了三个方案:渐进式迁移(逐页替换)、微前端隔离、独立重写。
最终选了渐进式迁移,原因:
- 独立重写风险太高,涉及 200+ 页面
- 微前端适合多团队独立部署,我们团队就 3 个人
- 渐进式迁移风险可控,同时能积累经验
迁移过程中设计了沙箱隔离层,让 React 和 jQuery 组件可以互相通信。
2 年时间完成了 100% 迁移,线上零事故。"
3. 失败/踩坑项目(工程成熟度证明)
面试官特别喜欢问:"你做过的项目里,有没有什么失败的经历?"
这不是送命题,这是送分题。关键是展示你如何从失败中学习。
"我曾经在一个项目里过度设计了状态管理。明明是一个简单的表单页面,我上了 Redux Toolkit。
结果:引入复杂度远超收益,团队其他成员维护成本很高。
我的复盘:状态管理方案应该由业务复杂度决定,不是技术炫技。
后来我总结了选型原则:能用 Context 就不用 Zustand,能用 Zustand 就不用 Redux。只有当团队超过 5 人、业务复杂度超过一定阈值时,才考虑引入全局状态管理库。
三、Coding 基本功:证明你能写代码
考什么?
2026 年的 Coding 环节大致分两类:
LeetCode 算法题(约 10%)
- 高频题型:数组/字符串操作、DFS/BFS、基础动态规划
- 难度:Medium 为主,偶尔 Easy 或 Hard
- 时间控制:15 分钟以内,超时基本挂
手写代码(约 10%)
- Promise 系列:Promise.all、Promise.race、Promise 并发控制
- 数组操作:拍平、深拷贝、防抖/节流
- 框架相关:简易版 useState、简易版 useEffect
为什么还要考算法?
这是面试官被"简历包装"坑怕了之后的保底手段。
你说你熟练使用 React,代码怎么写的?Promise 用得溜,实际能写一个 Promise.all 吗?
算法题的目的不是考你数据结构知识,而是看你在压力下思考和表达的能力。写不出来没关系,能讲清楚思路也算半个通过。
我的准备建议
- 刷题策略:刷 LeetCode Top 100 高频题足矣,不需要刷 500 道
- 手写题:一定要能讲清楚原理,不是背答案
- 善用 AI:用 AI 帮你理解算法思路,但一定要自己手写实现
- 时间管理:20 分钟内没思路,主动问面试官要提示,不丢人
四、框架原理:理解本质,而非背诵
React Fiber:必考,没有之一
关于 Fiber,我见过最离谱的候选人回答是:
"Fiber 是一个新的……React 版本。"
这是送命题。
Fiber 到底考什么?
| 问题层级 | 预期回答深度 |
|---|---|
| Fiber 是什么? | 一种链表结构的虚拟 DOM 描述对象 |
| 为什么要 Fiber? | 解决大型应用更新时的卡顿问题,让渲染可中断 |
| Fiber 的两个阶段? | render 阶段(可中断) + commit 阶段(不可中断) |
| render 阶段做了什么? | diff + 标记副作用(placement/update/deletion) |
| 时间切片怎么实现? | requestIdleCallback(现已被 MessageChannel 替代) |
| 优先级调度? | lanes 模型,不同更新有不同的优先级 |
最低要求:能说清楚 Fiber 解决了什么问题、两个阶段的区别。 加分项:能讲清楚 lanes/优先级调度的实现细节。
Hooks 原理:理解原理,而非 API
Hooks 的问题正在从"怎么用"升级到"为什么这么设计"。
| 问题 | 考察点 |
|---|---|
| useState 的实现原理? | 链表结构、current 指针、dispatch 闭包 |
| 为什么不能在条件语句中调用 Hook? | 链表顺序对应,每次调用对应链表的一个节点 |
| useEffect 的清理机制? | 函数返回值作为清理函数,下次执行前调用 |
| useMemo vs useCallback? | 前者缓存值,后者缓存函数引用 |
| 什么时候不用 useMemo? | 计算不耗时时、简单值,memo 本身的开销可能更大 |
性能优化:基于数据的优化
面试官最烦的答案是:
"用 React.memo 优化性能。"
面试官最喜欢的问题是:
"你在哪个场景下遇到了性能问题?怎么定位的?用的什么优化手段?效果如何?"
性能优化的正确打开方式:
Profile 定位瓶颈 → 假设原因 → 实施优化 → 数据验证效果
光优化没用,要能复现问题、定位原因、验证效果。
五、系统设计:考的是权衡能力
常见题目类型
- 设计一个支付页面
- 设计一个实时协作编辑器
- 设计一个图片上传和裁剪系统
- 设计一个新闻推荐系统
答题框架:先问后画
第一步:需求澄清(必做)
"我想确认几个问题:
- 预期的用户规模是多少?(1 万 vs 1000 万,方案差异很大)
- 重点关注哪个方面?(性能、安全、可扩展性)
- 是纯前端系统设计还是包含后端?"
这一步做不做,差距非常大。不问就开始画的,往往答不到点子上。
第二步:从高层到细节
整体架构
↓
数据模型
↓
核心模块
↓
关键决策点(trade-off 讨论)
第三步:讲清楚权衡
"方案 A 的优势是 XXX,劣势是 YYY。 方案 B 的优势是 XXX,劣势是 YYY。 考虑到我们的场景是……,所以最终选了方案 B。"
面试官想听的不是"最佳方案",而是你如何权衡取舍。
总结:面试的核心逻辑
2026 年的前端面试,核心考察的是四个能力层次:
| 层次 | 能力 | 对应的面试内容 |
|---|---|---|
| 能干活 | Coding 基本功 | LeetCode、手写代码 |
| 懂原理 | 框架深度理解 | React Fiber、Hooks、性能优化 |
| 能扛事 | 项目落地能力 | 决策链、问题解决、技术选型 |
| 会协作 | AI 工程能力 | 工作流、工具链、团队提效 |
这四个层次,层层递进。前两个是基础,后两个是拉开差距的关键。
一个忠告:不要把面试当成一场演技表演。真正的高手,面试时说的每一句话,都是自己真实做过的事情。与其花时间背题,不如花时间真正把项目做深、把问题想透。
面试只是开始,入职后的每一天才是真正的考验。
祝各位都能找到伯乐,也祝各位伯乐都能找到千里马。
构建无障碍组件之Window Splitter Pattern
Window Splitter Pattern 详解:构建可拖拽面板分割器
Window Splitter(窗口分割器,也称为 Resizable Splitter、Pane Resizer、Split Panel 或 Divider)是一种可移动的分隔组件,用于调整两个相邻面板(pane)的相对大小。本文基于 W3C WAI-ARIA Window Splitter Pattern 规范,详解如何构建无障碍的窗口分割器组件。
一、Window Splitter 的定义与核心概念
1.1 什么是 Window Splitter
Window Splitter 是一种可移动的分隔条,位于两个面板之间,允许用户调整面板的相对大小。它具有以下特征:
- 位于两个面板之间,作为可交互的分隔线
- 支持拖拽调整面板大小
- 可以是**可变(variable)或固定(fixed)**类型
- 可变分割器:可以在允许范围内调整到任意位置
- 固定分割器:在两个固定位置之间切换
- 具有表示**主面板(primary pane)**大小的数值
1.2 核心术语
| 术语 | 说明 |
|---|---|
| Primary Pane | 主面板,分割器的值表示该面板的大小 |
| Secondary Pane | 次面板,大小随主面板变化而调整 |
| Variable Splitter | 可变分割器,可在范围内任意调整 |
| Fixed Splitter | 固定分割器,只能在两个位置间切换 |
| Value | 分割器当前值,表示主面板的大小(通常为 0-100) |
┌─────────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────────────┬──────────────────────────────────────┐ │
│ │ │ │ │
│ │ Primary Pane │ Secondary Pane │ │
│ │ │ │ │
│ │ ┌────────────┐ │ ┌────────────────────────────────┐ │ │
│ │ │ │ │ │ │ │ │
│ │ │ Content │ │ │ Content │ │ │
│ │ │ │ │ │ │ │ │
│ │ └────────────┘ │ └────────────────────────────────┘ │ │
│ │ │ │ │
│ └──────────────────┼──────────────────────────────────────┘ │
│ │ │
│ ┌──────┴──────┐ │
│ │ Splitter │ <-- draggable separator │
│ │ (separator)│ role="separator" │
│ └─────────────┘ aria-valuenow │
│ │
│ Value = 30 (Primary: 30%, Secondary: 70%) │
│ │
└─────────────────────────────────────────────────────────────────┘
注意:"主面板"仅表示该面板的大小由分割器控制,不表示其内容更重要。
1.3 典型应用场景
- 代码编辑器:左侧文件树,右侧代码编辑区
- 阅读应用:左侧目录,右侧正文内容
- 邮件客户端:左侧邮件列表,右侧邮件详情
- 设计工具:左侧工具栏,右侧画布
二、WAI-ARIA 角色与属性
2.1 基本角色
Window Splitter 使用 role="separator" 标记。从 ARIA 1.1 开始,当 separator 元素可聚焦时,它被视为一个控件(widget)。
<div
role="separator"
aria-label="目录"
aria-valuenow="30"
aria-valuemin="0"
aria-valuemax="100"
aria-controls="primary-pane"
tabindex="0">
</div>
2.2 必需属性
| 属性 | 说明 | 示例值 |
|---|---|---|
role="separator" |
标记为分隔符角色 | - |
aria-valuenow |
当前值,表示主面板大小 | "30" |
aria-valuemin |
最小值,主面板最小时的位置 | "0" |
aria-valuemax |
最大值,主面板最大时的位置 | "100" |
aria-controls |
指向主面板元素 | "primary-pane" |
aria-label 或 aria-labelledby
|
可访问标签,应与主面板名称匹配 | "目录" |
2.3 属性详解
aria-valuenow
表示分割器的当前位置,通常映射为主面板的百分比大小:
-
0:主面板完全折叠(最小) -
100:主面板完全展开(最大) -
30:主面板占 30%,次面板占 70%
aria-controls
指向主面板元素,让辅助技术知道分割器控制哪个面板:
<div id="primary-pane" role="region" aria-label="目录">
<!-- 主面板内容 -->
</div>
<div
role="separator"
aria-controls="primary-pane"
...>
</div>
aria-label
标签应与主面板名称匹配,帮助用户理解分割器的作用:
<!-- 好的示例 -->
<div role="region" aria-label="目录" id="toc-pane">...</div>
<div role="separator" aria-label="目录" aria-controls="toc-pane">...</div>
<!-- 不好的示例 -->
<div role="separator" aria-label="分割器">...</div>
三、键盘交互规范
3.1 基本键盘交互
| 按键 | 功能 |
|---|---|
| ← Left Arrow | 垂直分割器向左移动 |
| → Right Arrow | 垂直分割器向右移动 |
| ↑ Up Arrow | 水平分割器向上移动 |
| ↓ Down Arrow | 水平分割器向下移动 |
| Enter | 切换主面板的展开/折叠状态 |
| Home(可选) | 将分割器移到最小位置(可能完全折叠主面板) |
| End(可选) | 将分割器移到最大位置(可能完全展开主面板) |
| F6(可选) | 在窗口面板之间循环切换焦点 |
3.2 Enter 键行为详解
Enter 键用于切换主面板的折叠状态:
- 如果主面板未折叠:折叠主面板(分割器移到最小值)
- 如果主面板已折叠:恢复分割器到之前的位置
function handleEnter(splitter) {
const currentValue = parseInt(splitter.getAttribute('aria-valuenow'));
const minValue = parseInt(splitter.getAttribute('aria-valuemin'));
if (currentValue > minValue) {
// 主面板未折叠,保存当前位置并折叠
splitter.dataset.previousValue = currentValue;
setSplitterValue(splitter, minValue);
} else {
// 主面板已折叠,恢复到之前的位置
const previousValue = parseInt(splitter.dataset.previousValue || '50');
setSplitterValue(splitter, previousValue);
}
}
3.3 固定分割器的键盘交互
固定分割器只支持 Enter 键,不支持方向键:
- 在两个固定位置之间切换
- 例如:折叠/展开侧边栏
四、鼠标交互规范
4.1 拖拽行为
- 鼠标按下:开始拖拽,记录起始位置
- 鼠标移动:实时更新分割器位置和面板大小
- 鼠标释放:结束拖拽,保存最终位置
4.2 视觉反馈
-
悬停状态:鼠标悬停时显示可拖拽的视觉提示(如改变光标为
col-resize或row-resize) - 拖拽状态:拖拽过程中显示视觉反馈(如半透明遮罩)
- 焦点状态:键盘聚焦时显示清晰的焦点指示器
[role="separator"] {
cursor: col-resize; /* 垂直分割器 */
}
[role="separator"][aria-orientation="horizontal"] {
cursor: row-resize; /* 水平分割器 */
}
[role="separator"]:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
五、实现方式
5.1 基础 Window Splitter 结构
<!-- 窗口容器 -->
<div class="window-container">
<!-- 主面板 -->
<div
id="primary-pane"
class="primary-pane"
role="region"
aria-label="目录">
<!-- 主面板内容 -->
<nav>
<h2>目录</h2>
<ul>
<li><a href="#ch1">第一章</a></li>
<li><a href="#ch2">第二章</a></li>
</ul>
</nav>
</div>
<!-- 分割器 -->
<div
role="separator"
class="splitter"
aria-label="目录"
aria-valuenow="30"
aria-valuemin="0"
aria-valuemax="100"
aria-controls="primary-pane"
tabindex="0">
</div>
<!-- 次面板 -->
<div
class="secondary-pane"
role="region"
aria-label="内容">
<!-- 次面板内容 -->
<article>
<h1>文章标题</h1>
<p>文章内容...</p>
</article>
</div>
</div>
5.2 CSS 样式
.window-container {
display: flex;
height: 100vh;
}
.primary-pane {
width: 30%; /* 初始宽度对应 aria-valuenow="30" */
min-width: 0;
overflow: auto;
}
.splitter {
width: 4px;
background-color: #e5e7eb;
cursor: col-resize;
transition: background-color 0.2s;
}
.splitter:hover,
.splitter:focus {
background-color: #3b82f6;
}
.splitter:focus {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.secondary-pane {
flex: 1;
overflow: auto;
}
5.3 JavaScript 实现
class WindowSplitter {
constructor(splitterElement) {
this.splitter = splitterElement;
this.primaryPane = document.getElementById(
splitterElement.getAttribute('aria-controls')
);
this.container = this.splitter.parentElement;
this.isDragging = false;
this.startX = 0;
this.startWidth = 0;
this.init();
}
init() {
// 鼠标事件
this.splitter.addEventListener('mousedown', this.handleMouseDown.bind(this));
document.addEventListener('mousemove', this.handleMouseMove.bind(this));
document.addEventListener('mouseup', this.handleMouseUp.bind(this));
// 键盘事件
this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
}
handleMouseDown(e) {
this.isDragging = true;
this.startX = e.clientX;
this.startWidth = this.primaryPane.offsetWidth;
this.container.style.userSelect = 'none';
}
handleMouseMove(e) {
if (!this.isDragging) return;
const delta = e.clientX - this.startX;
const newWidth = this.startWidth + delta;
const containerWidth = this.container.offsetWidth;
const percentage = Math.round((newWidth / containerWidth) * 100);
this.setValue(percentage);
}
handleMouseUp() {
this.isDragging = false;
this.container.style.userSelect = '';
}
handleKeyDown(e) {
const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
const step = 5; // 每次移动 5%
switch (e.key) {
case 'ArrowLeft':
e.preventDefault();
this.setValue(Math.max(minValue, currentValue - step));
break;
case 'ArrowRight':
e.preventDefault();
this.setValue(Math.min(maxValue, currentValue + step));
break;
case 'Home':
e.preventDefault();
this.setValue(minValue);
break;
case 'End':
e.preventDefault();
this.setValue(maxValue);
break;
case 'Enter':
e.preventDefault();
this.toggleCollapse();
break;
}
}
setValue(value) {
const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
// 限制在范围内
value = Math.max(minValue, Math.min(maxValue, value));
// 更新 ARIA 属性
this.splitter.setAttribute('aria-valuenow', value);
// 更新视觉
this.primaryPane.style.width = value + '%';
}
toggleCollapse() {
const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
if (currentValue > minValue) {
// 保存当前值并折叠
this.splitter.dataset.previousValue = currentValue;
this.setValue(minValue);
} else {
// 恢复之前的位置
const previousValue = parseInt(this.splitter.dataset.previousValue || '30');
this.setValue(previousValue);
}
}
}
// 初始化
const splitter = document.querySelector('[role="separator"]');
new WindowSplitter(splitter);
5.4 固定分割器实现
固定分割器只支持 Enter 键切换:
class FixedWindowSplitter {
constructor(splitterElement) {
this.splitter = splitterElement;
this.primaryPane = document.getElementById(
splitterElement.getAttribute('aria-controls')
);
this.positions = [0, 30]; // 两个固定位置:折叠、展开
this.currentIndex = 1; // 默认展开
this.init();
}
init() {
this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
}
handleKeyDown(e) {
if (e.key === 'Enter') {
e.preventDefault();
this.togglePosition();
}
}
togglePosition() {
this.currentIndex = (this.currentIndex + 1) % this.positions.length;
const value = this.positions[this.currentIndex];
this.splitter.setAttribute('aria-valuenow', value);
this.primaryPane.style.width = value + '%';
}
}
六、最佳实践
6.1 提供清晰的标签
分割器的标签应与主面板名称匹配:
<!-- 好的示例 -->
<div role="region" aria-label="文件树" id="file-tree">...</div>
<div role="separator" aria-label="文件树" aria-controls="file-tree">...</div>
<!-- 不好的示例 -->
<div role="separator" aria-label="拖拽调整">...</div>
6.2 确保键盘可访问
- 分割器必须可聚焦(
tabindex="0") - 支持方向键调整位置
- 支持 Enter 键折叠/展开
6.3 提供视觉反馈
- 悬停时改变光标样式
- 焦点状态清晰可见
- 拖拽过程中实时更新面板大小
6.4 限制调整范围
设置合理的 aria-valuemin 和 aria-valuemax,防止面板过小或过大:
<!-- 主面板最小 15%,最大 50% -->
<div
role="separator"
aria-valuemin="15"
aria-valuemax="50"
...>
</div>
6.5 保存用户偏好
记住用户调整后的面板大小,下次访问时恢复:
// 保存
localStorage.setItem('splitter-value', splitter.getAttribute('aria-valuenow'));
// 恢复
const savedValue = localStorage.getItem('splitter-value');
if (savedValue) {
splitter.setAttribute('aria-valuenow', savedValue);
primaryPane.style.width = savedValue + '%';
}
6.6 响应式设计考虑
在小屏幕上,考虑禁用分割器或提供替代方案:
@media (max-width: 768px) {
[role="separator"] {
display: none; /* 小屏幕隐藏分割器 */
}
.primary-pane {
width: 100% !important; /* 全宽显示 */
}
}
七、常见错误
7.1 忘记设置 aria-controls
<!-- 错误 -->
<div role="separator" aria-label="目录"></div>
<!-- 正确 -->
<div role="separator" aria-label="目录" aria-controls="primary-pane"></div>
7.2 标签与主面板不匹配
<!-- 错误 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="调整大小">...</div>
<!-- 正确 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="目录">...</div>
7.3 忽略键盘交互
只实现鼠标拖拽,不实现键盘支持,导致键盘用户无法调整面板大小。
八、总结
构建无障碍的 Window Splitter 组件需要关注:
-
正确的角色:使用
role="separator" -
必需的属性:
aria-valuenow、aria-valuemin、aria-valuemax、aria-controls、aria-label - 完整的键盘支持:方向键调整、Enter 键折叠、Home/End 快捷键
- 鼠标拖拽支持:mousedown/mousemove/mouseup 事件
- 清晰的标签:标签与主面板名称匹配
- 视觉反馈:悬停、焦点、拖拽状态的视觉提示
遵循 W3C Window Splitter Pattern 规范,我们能够创建既实用又无障碍的面板分割器,提升所有用户的操作体验。
文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。
PDF无限制预览!Jit-Viewer V1.5.0开源文档预览神器正式发布
下面和大家分享一下最近我们开源的文档预览SDK——Jit-Viewer,昨天刚发布 1.5.0 版本,和大家分享一下最新的功能更新。
![]()
如果你是开发文档预览功能的开发者,一定经历过这种崩溃:txt文档预览乱码、PDF只能看前5页、大文件加载卡顿,代码文件预览毫无章法。
为了帮大家解决这些真实的使用痛点,提升开发体验,我们这段时间优化了 Jit-Viewer 开源文档预览SDK。上周刚帮不少开发者解决了PDF预览受限的问题——终于能完整查看所有PDF文档了。
今天,Jit-Viewer V1.5.0 正式发布,4大核心更新,让文档预览开发更高效、更省心。
这次更新,我们重点带来了以下功能:
1. 支持txt多编码格式预览兼容
![]()
之前很多开发者反馈,txt文档预览经常出现乱码,尤其是非UTF-8编码的文件,调试起来特别麻烦,浪费大量时间。
这次更新,我们优化了txt文档解析逻辑,全面兼容ANSI、UTF-8、GBK等多种常见编码格式,不管你导入的txt文件是什么编码,都能正常显示,再也不用手动转换编码、反复调试,帮大家节省更多开发时间。
2. 支持PDF文件完整预览,告别5页限制
![]()
这是本次更新最受期待的功能!之前版本的Jit-Viewer,PDF文件只能预览前5页,对于需要完整预览长文档的开发者来说,实用性大打折扣,很多场景下根本无法满足需求。
![]()
这次我们彻底突破了这个限制,底层重构了PDF渲染能力,支持PDF文件全页完整预览,不管是几页的PDF,都能一次性加载完成,搭配原有缩放、翻页功能,完美适配各类PDF预览场景,再也不用为了查看完整PDF额外集成其他工具。
3. 优化SDK预览性能,搭载高性能文件预览引擎
我们知道,开发者在集成文档预览SDK时,最在意的就是性能——大文件加载慢、切换页面卡顿,都会影响产品体验。这次更新,我们重新设计了文件预览引擎,优化了文件加载、渲染的全流程,大幅提升了预览速度和稳定性,即使是大文档,也能快速加载、流畅切换,不会出现卡顿、崩溃的情况,同时降低了资源占用,让你的应用运行更流畅。
4. 支持代码文件高亮预览
针对开发类场景,我们新增了代码文件高亮预览功能。不管是Java、Python、JavaScript,还是HTML、CSS等常见编程语言,导入后都能自动识别语言类型,实现语法高亮,代码结构清晰可见,再也不用看着杂乱无章的纯文本代码发愁,尤其适合需要在应用中集成代码预览功能的开发者,大幅提升使用体验。
市面上很多商业文档预览SDK,只解决“能预览”的问题,而 Jit-Viewer 想解决的是“好用、省心、适配多场景”。
这次V1.5.0的更新,本质上是在“轻量高效”的核心定位上,进一步突破场景限制、优化使用体验——让复杂的文档预览开发,变得更简单,让不同需求的开发者,都能快速集成、高效使用,不用再为各类预览问题额外消耗精力。
简单来说,Jit-Viewer 是一个纯前端的文件预览引擎。不需要后端转换服务,不需要安装任何插件,几行代码就能让浏览器具备"专业软件"的预览能力。
目前 jit-viewer 已经支持了:
- docx / ppt / pdf / excel
- csv
- html
- markdown
- txt
- 代码文件(如js,css, java, go, c#, php, ts等)
- 音频 / 视频
- CAD
- 3D模型
- OFD(国产格式)
同时我们还在持续迭代优化,帮助大家仅通过几行代码,就能让自己的web系统轻松拥有多种文档预览的能力。
github:github.com/jitOffice/j…
《SwiftUI 高级特性第1章:自定义视图》
![]()
1.1 自定义视图概述
在 SwiftUI 中,自定义视图是构建复杂用户界面的基础。通过创建可重用的自定义视图组件,我们可以:
- 提高代码的可维护性和可重用性
- 封装复杂的 UI 逻辑
- 保持主视图代码的简洁性
- 实现统一的设计风格
1.2 自定义视图的创建方法
1.2.1 基本结构
创建自定义视图的基本步骤:
- 定义一个遵循
View协议的结构体 - 实现
body计算属性,返回一个视图 - 为视图添加必要的属性和初始化方法
1.2.2 示例代码结构
// 自定义视图结构体
struct CustomView: View {
// 属性定义
let title: String
let subtitle: String
// 初始化方法
init(title: String, subtitle: String) {
self.title = title
self.subtitle = subtitle
}
// 视图体
var body: some View {
VStack {
Text(title)
Text(subtitle)
}
}
}
1.3 自定义组件示例
1.3.1 自定义按钮
功能说明:创建一个具有不同样式的自定义按钮组件。
代码实现:
// 自定义按钮组件
struct CustomButton: View {
let title: String
let style: ButtonStyle
let action: () -> Void
// 按钮样式枚举
enum ButtonStyle {
case primary
case secondary
}
// 初始化方法
init(title: String, style: ButtonStyle = .primary, action: @escaping () -> Void) {
self.title = title
self.style = style
self.action = action
}
var body: some View {
Button(action: action) {
Text(title)
.padding()
.background(style == .primary ? .blue : .gray)
.foregroundColor(.white)
.cornerRadius(10)
.font(.headline)
}
}
}
使用示例:
CustomButton(title: "点击我") {
print("自定义按钮被点击")
}
CustomButton(title: "次要按钮", style: .secondary) {
print("次要按钮被点击")
}
1.3.2 自定义卡片
功能说明:创建一个带有标题、副标题和图标的卡片组件。
代码实现:
// 自定义卡片组件
struct CustomCard: View {
let title: String
let subtitle: String
let imageName: String
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Image(systemName: imageName)
.font(.largeTitle)
.foregroundColor(.blue)
Spacer()
}
Text(title)
.font(.headline)
.fontWeight(.bold)
Text(subtitle)
.font(.subheadline)
.foregroundColor(.gray)
}
.padding()
.background(.white)
.cornerRadius(10)
.shadow(radius: 5)
.padding(.horizontal)
}
}
使用示例:
CustomCard(
title: "欢迎使用 SwiftUI",
subtitle: "这是一个自定义卡片视图",
imageName: "star.fill"
)
CustomCard(
title: "学习 SwiftUI",
subtitle: "从基础到高级",
imageName: "book.fill"
)
1.3.3 自定义进度条
功能说明:创建一个可自定义颜色和进度的进度条组件。
代码实现:
// 自定义进度条组件
struct CustomProgressBar: View {
let progress: Double
let color: Color
init(progress: Double, color: Color = .red) {
self.progress = min(max(progress, 0), 1) // 确保进度在0-1之间
self.color = color
}
var body: some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
// 背景
Rectangle()
.fill(.gray.opacity(0.3))
.cornerRadius(5)
// 进度条
Rectangle()
.fill(color)
.frame(width: geometry.size.width * CGFloat(progress))
.cornerRadius(5)
}
.frame(height: 20)
}
.padding(.horizontal)
}
}
使用示例:
CustomProgressBar(progress: 0.3)
CustomProgressBar(progress: 0.7, color: .green)
CustomProgressBar(progress: 1.0, color: .blue)
1.3.4 自定义徽章
功能说明:创建一个可自定义颜色的徽章组件。
代码实现:
// 自定义徽章组件
struct CustomBadge: View {
let text: String
let color: Color
init(text: String, color: Color = .blue) {
self.text = text
self.color = color
}
var body: some View {
Text(text)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(color)
.foregroundColor(.white)
.cornerRadius(15)
.font(.subheadline)
.fontWeight(.bold)
}
}
使用示例:
CustomBadge(text: "新")
CustomBadge(text: "热门", color: .red)
CustomBadge(text: "优惠", color: .green)
1.3.5 自定义开关
功能说明:创建一个带有动画效果的自定义开关组件。
代码实现:
// 自定义开关组件
struct CustomToggle: View {
@Binding var isOn: Bool
var body: some View {
Button(action: {
isOn.toggle()
}) {
HStack {
Text(isOn ? "开启" : "关闭")
.font(.headline)
Spacer()
RoundedRectangle(cornerRadius: 20)
.fill(isOn ? .green : .gray)
.frame(width: 50, height: 30)
.overlay(
Circle()
.fill(.white)
.frame(width: 24, height: 24)
.offset(x: isOn ? 10 : -10)
.animation(.spring(), value: isOn)
)
}
.padding()
.background(.white)
.cornerRadius(10)
.shadow(radius: 2)
}
}
}
使用示例:
@State private var isEnabled = true
CustomToggle(isOn: $isEnabled)
1.3.6 自定义列表项
功能说明:创建一个带有图标、标题和副标题的列表项组件。
代码实现:
// 自定义列表项组件
struct CustomListItem: View {
let title: String
let subtitle: String
let imageName: String
var body: some View {
HStack(spacing: 15) {
Image(systemName: imageName)
.font(.title)
.foregroundColor(.blue)
VStack(alignment: .leading, spacing: 5) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.subheadline)
.foregroundColor(.gray)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundColor(.gray)
}
.padding()
.background(.white)
.cornerRadius(10)
.shadow(radius: 2)
}
}
使用示例:
CustomListItem(
title: "项目1",
subtitle: "这是第一个项目",
imageName: "circle.fill"
)
CustomListItem(
title: "项目2",
subtitle: "这是第二个项目",
imageName: "square.fill"
)
1.4 自定义视图的最佳实践
- 命名规范:使用清晰、描述性的名称
- 参数设计:合理设计初始化参数,提供默认值
- 布局考虑:使用适当的布局容器和间距
- 可访问性:确保视图对所有用户可访问
- 性能优化:避免不必要的计算和重绘
- 文档注释:为组件添加清晰的文档注释
1.5 综合示例
功能说明:展示所有自定义组件的综合示例。
代码实现:
import SwiftUI
struct CustomViewsDemo: View {
@State private var toggleState = false
var body: some View {
ScrollView {
VStack(spacing: 20) {
// 标题
Text("自定义视图")
.font(.largeTitle)
.fontWeight(.bold)
.foregroundColor(.blue)
// 1. 自定义按钮
VStack {
Text("1. 自定义按钮")
.font(.headline)
CustomButton(title: "主要按钮") {
print("主要按钮被点击")
}
CustomButton(title: "次要按钮", style: .secondary) {
print("次要按钮被点击")
}
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
// 2. 自定义卡片
VStack {
Text("2. 自定义卡片")
.font(.headline)
CustomCard(
title: "SwiftUI 教程",
subtitle: "学习现代 UI 开发",
imageName: "swift"
)
CustomCard(
title: "高级特性",
subtitle: "自定义视图与动画",
imageName: "star.fill"
)
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
// 3. 自定义进度条
VStack {
Text("3. 自定义进度条")
.font(.headline)
CustomProgressBar(progress: 0.3)
CustomProgressBar(progress: 0.7, color: .green)
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
// 4. 自定义徽章
VStack {
Text("4. 自定义徽章")
.font(.headline)
HStack {
CustomBadge(text: "新")
CustomBadge(text: "热门", color: .red)
CustomBadge(text: "推荐", color: .green)
}
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
// 5. 自定义开关
VStack {
Text("5. 自定义开关")
.font(.headline)
CustomToggle(isOn: $toggleState)
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
// 6. 自定义列表项
VStack {
Text("6. 自定义列表项")
.font(.headline)
CustomListItem(
title: "设置",
subtitle: "应用偏好设置",
imageName: "gear"
)
CustomListItem(
title: "个人资料",
subtitle: "查看和编辑个人信息",
imageName: "person"
)
}
.padding()
.background(Color.gray.opacity(0.1))
.cornerRadius(10)
}
.padding()
}
}
}
#Preview {
CustomViewsDemo()
}
1.6 总结
自定义视图是 SwiftUI 中非常强大的功能,通过创建可重用的组件,我们可以构建更加模块化、可维护的用户界面。在本章节中,我们学习了如何创建各种类型的自定义视图,包括按钮、卡片、进度条、徽章、开关和列表项等。
通过合理的设计和组织,自定义视图可以大大提高开发效率,同时保持代码的清晰性和可维护性。
参考资料
- SwiftUI 官方文档 - View
- Apple Developer Documentation: Building Custom Views
- SwiftUI by Example: Custom components
本内容为《SwiftUI 高级特性》第一章,欢迎关注后续更新。