1. 类iOS系统不是iOS:先想清楚边界
这个项目最初的起因很朴素:要给客户演示一套智能终端的交互方案,真机来回刷机、装机、截图太折腾,需求最后变成了一个奇奇怪怪的问题——能不能在浏览器里直接打开一个看起来像iOS、用完还能关掉的操作界面?
我当时第一反应是,这不就是一个高仿H5页面吗?后来真正动手才发现,事情远没有“照着iPhone画一个桌面”那么简单。桌面图标、Dock、状态栏都好说,可一旦你要让这个界面真正“用起来”,后面跟着的是一整套状态管理:应用怎么打开、怎么关闭、退到桌面后台怎么恢复、下拉控制中心时正在跑的应用要不要响应手势、通知来了怎么弹、权限弹窗怎么拦截用户操作。这些链路全部串联起来,它才从一张“静态截图”变成一个“伪操作系统”。
这个项目做完之后,我对操作系统任务管理的理解,比啃十遍理论都深刻。现在写这篇东西,主要是把整个构建思路、关键代码逻辑和踩过的坑都沉淀下来。我默认你至少会HTML/CSS/JavaScript,了解Vue或React这类框架的其中一个会更轻松,但如果你只是刚入门前端,也能照着思路做出一个简化版本。
先泼一盆冷水:如果你想用这套东西去替代苹果真机做自动化测试,或者试图在里面运行真实的iOS App,那不可能。所谓“伪iOS系统”,仿的是界面交互和系统级体验,不是仿内核、仿硬件、仿App运行环境。它最常见的落地场景有三个:产品Demo、展厅触摸屏、前端教学项目。在这几个场景里,它比真机方便得多——不用越狱、不用装证书、代码改完浏览器刷新就能看效果,还能一键部署到任意终端。
我选择的技术栈是Vite + TypeScript,没有采用大型UI框架,App模块用原生DOM渲染。原因很简单:这个项目大量涉及触摸事件、动画曲线、窗口层级这些偏底层的操作,自己接管DOM反而更可控。如果你个人习惯用Vue或React,也没有问题,核心架构思路完全一致。
以下是整个系统需要模拟的功能清单:
| 模块 | 模拟程度 | 说明 |
|---|---|---|
| 桌面 | 图标网格、分页滑动、长按编辑模式 | 视觉和基础交互为主 |
| Dock | 固定应用、悬停放大动画 | 只做UI层,不承载真实任务 |
| 状态栏 | 时间、信号、电池、刘海屏适配 | 纯视觉+时间动态更新 |
| 控制中心 | 快捷开关、亮度/音量滑块 | 开关状态是本地模拟,不影响真实硬件 |
| 通知中心 | 卡片、下滑呼出、点击跳转 | 消息数据由内部协议生成 |
| 应用管理 | 打开、关闭、后台挂起、状态恢复 | 核心是伪墓碑机制 |
| 系统弹窗 | 隐私协议、权限请求、App内弹窗 | 拦截用户操作,保证合规流程 |
这个表直接决定了后面的开发顺序。我先从视觉层开始,逐步往交互层和管理层走,每一步都保证“能用”再继续加东西。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 主屏与Dock细节:把视觉基线立起来
很多人做仿iOS项目,第一步就栽在布局上:用了一堆绝对定位写死坐标,结果换个屏幕就乱套。正确做法是把主屏当成一个动态网格系统来算。
2.1 桌面图标网格怎么算
iOS主页的图标尺寸和间距在不同机型上不完全一致,但大致规律是“横向4~6列,纵向5~6行”。我采用动态计算方式:拿到当前窗口大小后,先扣除状态栏、Dock区域、安全区、边缘留白,再根据图标单元尺寸推算出行列数。
ts复制export function calcGridMetrics(screenW: number, screenH: number) {
const statusBar = 54;
const dockZone = 180;
const safeGap = 16;
const availableW = screenW - safeGap * 2;
const availableH = screenH - statusBar - dockZone;
const iconCellW = 82; // 图标 + 水平间距
const iconCellH = 96; // 图标 + 文字 + 垂直间距
const cols = Math.floor(availableW / iconCellW);
const rows = Math.floor(availableH / iconCellH);
return {
cols: Math.min(cols, 6),
rows: Math.min(rows, 5),
iconSize: 60,
fontSpacing: 8,
};
}
算好行列数之后,图标数据用一个二维数组维护,分页就对应数组切片。
ts复制interface Icon {
id: string;
name: string;
icon: string; // 图标URL或SVG
appId?: string; // 点击后启动的应用
}
const pages: Icon[][] = [];
// 第0页前4个应用,第1页往后放其余应用
渲染时只需要遍历当前页,配合transform: translateX(-pageIndex * 100%)实现左右滑动换页。这里要注意:切换页面用的是整屏位移,不要让浏览器对每一个图标单独做动画,否则低端机会卡到怀疑人生。对整页容器做GPU合成,一个transform就能搞定。
2.2 Dock的悬停放大动画
Dock是仿iOS系统里最“显眼”的交互记忆点。真实iOS的Dock有一个特性:图标会根据手指/鼠标位置动态放大,越靠近中心放大越明显,移动时还会带动相邻图标联动。这个效果用CSS过渡做不出来,必须手动算比例。
我的实现逻辑是:监听pointermove事件,拿到光标相对Dock的x坐标,对每个图标计算与光标之间的距离,再映射到缩放系数。
ts复制function handleDockHover(e: PointerEvent) {
const dockRect = dockRef.value.getBoundingClientRect();
const cursorX = e.clientX;
dockRef.value.querySelectorAll<HTMLElement>('.dock-item').forEach((item) => {
const
