这个项目最开始只是一时兴起。我打开浏览器,看着十几个标签页堆在一起,突然想:要是能把它们折叠成一个桌面环境,窗口互相叠加、随意拖拽,平时工作资料按目录归档,浏览器本身就是一个操作系统,那该多过瘾。于是我用 HTML 加 CSS 加 JavaScript 手搓了一个运行在浏览器里的简约系统,代号就叫"64x"。
64x 不是真正的操作系统,也不是什么虚拟机,它是一整套模拟桌面环境的单页应用。整套代码就一个 HTML 入口,配几个 JS 模块和 CSS 文件,双击打开就能跑,所有"软件"都是前端组件。它解决的问题很纯粹:在一个网页里提供类似多窗口桌面系统的交互体验——窗口可以移动、缩放、最大化,有任务栏、文件管理器、终端、画板、文本编辑器,文件数据还能在浏览器刷新后保留下来。对于正在学习前端状态管理、事件机制和组件通信的同学来说,它是一份很完整的参考实现;对于纯粹想折腾点有趣项目的人,它也能给你带来不少思路。
我先把最核心的架构思路和几个关键模块的实现过程整理出来,包括一些踩坑记录。这篇文章适合有 HTML/CSS/JS 基础、想挑战一个完整项目的人,不需要框架基础,因为整个项目刻意没有使用任何前端框架,纯原生实现。
1. 从"网页"到"系统":第一版架构设计
1.1 我为什么要抛弃前端框架
项目立项之初,我其实犹豫过要不要上框架。后来仔细盘了一下需求,发现这个项目的核心不是数据流,也不是虚拟 DOM 性能,而是"窗口状态管理"和"DOM 结构的动态编排"。考虑到这类交互逻辑重度依赖原生 DOM 操作,最终决定纯手写,不上框架。
这个决定带来了两个直接好处:第一,项目零依赖,用户打开 HTML 就能跑,不需要 node_modules,不需要构建工具;第二,我能在代码里完整控制每个窗口的 DOM 生命周期,这对实现拖拽、缩放、焦点切换这类底层交互非常有利,不用绕框架的抽象层。
副作用也很明显。没有框架的响应式数据绑定,窗口状态一变,我需要手动同步修改 DOM 里的所有相关节点。一开始我踩了不少重复渲染的坑,后来我引入了两个"土办法"缓解:一是给每个窗口维护一个 states 对象,修改窗口属性前统一走一个 updateWindowState 函数;二是把"状态变更"和"DOM 渲染"拆成两个阶段,状态先落库、渲染后执行,杜绝了多次修改状态导致的反复渲染。
1.2 目录结构:一个小而完整的模块划分
64x 的代码结构按照操作系统模块的思维方式拆分:
text复制64x/
├── index.html # 入口,包含桌面容器、任务栏容器
├── css/
│ ├── base.css # 重置样式与基础变量
│ ├── desktop.css # 桌面布局与窗口样式
│ └── apps.css # 内置应用的独立样式
└── js/
├── core/
│ ├── window.js # 窗口管理
│ ├── taskbar.js # 任务栏
│ └── vfs.js # 虚拟文件系统
├── apps/
│ ├── textpad.js # 文本编辑器
│ ├── terminal.js # 终端模拟器
│ ├── paint.js # 画板
│ └── files.js # 文件管理器
└── main.js # 入口与事件总线
每个模块都对应一个独立的"系统服务",模块之间通过简单的事件总线通信,避免互相直接引用。这个粒度对个人项目来说刚刚好:不至于一个文件几千行,也不至于拆得太碎管不过来。
1.3 "64x"这个名字到底代表什么
好多人问我 64x 里的 64 是不是指 64 位。当时取名的时候我确实想到了"64 位"这个概念,但实际实现里最有价值的一个数字约束是:我可以同时打开的窗口数量上限被设置成了 64 个。换句话说,系统最多同时保留 64 个窗口实例,超出后新的窗口需要关掉旧的或最小化旧的,屏幕上才能真正展示出来。
这个上限不是随意定的。我实测下来,在 Chrome 里同时挂 64 个普通 DOM 窗口(每个窗口内部有基本布局和少量事件绑定)对帧率影响还处于可接受范围,但如果放开到 128 个,即使只是移动它们,肉眼也能感觉到明显卡顿。所以 64 是当前代码结构下一个相对稳妥的平衡点。项目代号就这样定了下来——64x,既蹭了"64 位系统"的意向,也暗示了并发窗口上限。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 窗口管理器的实现:系统的心脏
2.1 窗口对象的最小模型
每个窗口在代码里不是一个 DOM 节点,而是一个普通的 JavaScript 对象。DOM 只是这个对象在屏幕上的投影。定义如下:
javascript复制// 窗口状态模型
const defaultState = {
id: null, // 唯一标识
app: '', // 应用名
title: '', // 标题栏显示文本
x: 0, y: 0, // 窗口左上角位置
width: 620, // 窗口宽
height: 420, // 窗口高
zIndex: 1, // 层级
minimized: false, // 是否最小化
maximized: false, // 是否最大化
focused: false, // 是否获得焦点
content: null, // 应用内容渲染函数
state: {} // 应用私有状态
};
创建窗口时,窗口管理器会执行以下步骤:
- 调用 openWindow 分配 id,并把状态对象放入 windows 数组。
- 根据状态生成窗口 DOM,挂载到桌面容器。
- 执行应用提供的 init 函数,把应用内容区域渲染到窗口 content 容器。
- 触发窗口激活逻辑,把 zIndex 提到当前最大值之上。
这里的核心设计是"状态与 DOM 分离"。所有窗口的几何信息都在 windows 对象里保存着,后续做拖拽、缩放、记忆窗口位置(比如关闭后重开恢复到上次的位置)都很方便,只需要持久化这个对象数组。
2.2 Z 轴管理与焦点策略
窗口系统的体验很大程度上取决于 zIndex 的管理策略。最初我采用的是朴素方案:每次点击窗口,就把它的 zIndex 设为当前最大 zIndex 加一。这样实现简单,但有一个明显问题:层级数字会无限增长,而且无法处理"点击窗口内部某个 iframe 导致窗口失焦"的情况。
后来我调整成"层级分池"方案。整个桌面维护 5 个层级池,普通的用户窗口在 100-199 段内分配 zIndex,模态框/对话框在 900-999,右键菜单在 1000-1099,拖拽预览层使用 2000,全屏层使用 3000。窗口管理器持有 currentZ 变量,每次激活窗口时 currentZ 自增,但仅在当前池子范围内浮动,达到池子上限就统一归位重排一次。这样既避免了无限增长,也保证了特殊弹层始终压在普通窗口之上。
焦点策略上,我用了一个简单的规则:鼠标 pointerdown 落到某个窗口的 DOM 区域内,该窗口就被激活;点击桌面空白区域则取消所有窗口焦点,任务栏高亮同步消失。
2.3 拖拽、缩放、边缘吸附的硬核细节
窗口拖拽最核心的坑是"鼠标位移映射到窗口位移"的方式。很多人会写:
javascript复制window.addEventListener('mousemove', e => {
win.x = e.clientX;
win.y = e.clientY;
});
这样写会让窗口"跳"到鼠标位置,而不是继承按下瞬间的相对偏移。正确做法是记录按下时鼠标坐标与窗口坐标的差值,移动时用鼠标当前位置减去这个差值:
javascript复制let offsetX = e.clientX - win.x;
let offsetY = e.clientY - win.y;
function onMouseMove(e) {
const nx = e.clientX - offsetX;
const ny = e.clientY - offsetY;
updateWindow(win, { x: nx, y: ny });
}
缩放则是给窗口注册 8 个缩放手柄(四个角 + 四边中点)。每个手柄对应不同的缩放方向,比如"右上角"手柄要同时改变 x、y、width、height,而"右边中间"手柄只改变 width。这个逻辑不复杂,但要分清楚方向组合,否则会出现拖右边窗口却往左跑的问题。
边缘吸附我参考了现代操作系统的体验:拖动窗口靠近屏幕边缘时,如果距离小于 20px,就自动吸附到边缘。拖动过程中要实时检测,不能只在 mouseup 时检测一次。吸附被触发时,窗口位置会有一个平滑过渡的动画效果,这里我用的是 CSS transition 配合 transform 做微移动,体验上很自然。
为了保住拖拽的流畅度,我在 mousemove 回调里强制使用 requestAnimationFrame 做渲染节流,避免鼠标高频事件直接触发 DOM 样式的同步写入:
javascript复制let ticking = false;
function onMouseMove(e) {
if (!ticking) {
requestAnimationFrame(() => {
updateWindowPosition(win, e);
ticking = false;
});
ticking = true;
}
}
这个方法实测下来能明显降低高刷新率屏幕下的 CPU 占用,也让拖拽更跟手。
3. 虚拟文件系统:没有磁盘的存储
3.1 用一棵 JS 对象树模拟目录结构
一个系统没有文件系统说不过去。64x 的虚拟文件系统(VFS)没有依赖任何后端,数据全部存放在内存对象里,结构模仿 Linux 的目录树:
javascript复制const vfs = {
root: {
type: 'dir',
children: {
home: {
type: 'dir',
children: {
'桌面': {
type: 'dir',
children: {}
},
'文档': {
type: 'dir',
children: {
'hello.txt': {
type: 'file',
content: 'Hello, 64x!',
size: 12,
mtime: Date.now()
}
}
}
}
}
}
}
};
路径解析是整个 VFS 的核心函数。我实现了标准的 resolvePath 函数,支持绝对路径和相对路径,例如 /home/文档/hello.txt 或 ./桌面。路径通过 split 拆成段,逐级下钻,遇到不存在的节点就返回错误码。这套逻辑和真实文件系统的路径解析机制是对应的,所以代码写起来特别有代入感。
3.2 localStorage 还是 IndexedDB?我最终的选择
为了让刷新后数据不丢,我需要把 VFS 的数据持久化到浏览器本地。当时有两个选项:localStorage 和 IndexedDB。
localStorage 的优势是 API 极简,直接就支持 JSON.stringify 存字符串,但限制是单域 5MB 左右。IndexedDB 功能强大、容量大,但 API 复杂,读写都是异步事务,代码量要多不少。
64x 的文件主要是小文本和配置数据,项目定位也是"轻量演示",所以我选了 localStorage。每次 VFS 变更后,我会调用 saveVfs 方法把整个对象树序列化后存入 localStorage,同时记录一个版本号,防止将来索引结构变更导致解析失败:
javascript复制function saveVfs() {
const payload = {
version: 1,
data: vfs,
savedAt: Date.now()
};
localStorage.setItem('64x.vfs', JSON.stringify(payload));
}
加载时解析版本号,遇到不兼容版本就降级清空,避免白屏。
有一点需要提醒:localStorage 的写入是同步的,大文件内容如果塞进 VFS 会导致保存瞬间卡顿。我在文件管理器的"写入文件"环节做了大小限制,超过 1MB 的文件提示改用外部导入方案(比如直接拖入一个远程链接)。
3.3 应用间的"权限"伪实现
真实操作系统里,应用不能随随便便访问所有文件。我在 64x 里做了个轻量级的权限声明:每个应用在注册时可以声明它能访问的目录白名单。文件管理器能访问全部,终端模拟器只能访问 home 下的目录,文本编辑器通过"打开文件"对话框访问指定目录。这个白名单保存在应用注册表里,VFS 每次查询文件内容时都会校验请求来源应用是否有权限,用伪代码表示就是:
javascript复制function readFile(app, path) {
const appConfig = getAppConfig(app);
const allowedRoots = appConfig.allowedDirs;
if (!allowedRoots.some(prefix => path.startsWith(prefix))) {
return { error: 'EACCES', message: 'Permission denied' };
}
return resolvePath(path);
}
这个设计虽然简陋,但让整个系统的架构更接近真实操作系统的 App Sandbox 概念,对理解权限模型也是有帮助的。
4. 内置应用集:从"能用"到"像一个系统"
4.1 文本编辑器:从简单的 contenteditable 到光标同步
文本编辑器是 64x 的第一批应用。最初版本就用一个 contenteditable 区域解决,但很快发现问题:如果用 contenteditable,应用代码很难读取纯文本内容,而且粘贴富文本时格式会乱掉。
后来我换成了 textarea 方案,内容归属文本编辑器自身的状态管理。文件保存时,编辑器发出 save 事件并携带文本内容,VFS 根据当前打开的文件路径写入。为了做出"未保存状态"的提醒,我在编辑器里维护了一个 dirty 标记,任何输入事件都会置为脏,关闭窗口时如果脏标记为真,系统会弹出确认框。这个细节花了半天时间,但它让系统体验一下子从"玩具"升到了"工具"级别。
4.2 终端模拟器:一个不能执行 Shell 命令的"终端"
终端模拟器可能是 64x 里最受好奇的部分。我实现了一个类似终端 UI 的组件:支持命令输入、光标移动、历史记录、输出区域,内置一组自定的命令集,比如 help、ls、cat、echo、clear、open、date。这些命令在 JavaScript 里注册成命令表,解析输入后分发执行。
这里最有挑战的一个细节是滚动行为。终端输出内容超过可视区域时,要让滚动条自动滚到最底部,但不能在用户上翻历史时强行拉回底部。我的实现是:每次输出前记录 scrollTop 和 scrollHeight 的差值,如果距离小于 80 像素才自动滚动到底,否则保持用户当前阅读位置。这个规则兼顾了输出跟随和用户自主浏览。
终端里的 cat 命令能读取 VFS 文件内容,ls 能列出目录项,open 能启动应用。比如输入 open textpad /home/文档/hello.txt,编辑器就会打开指定路径的文件。这就是"终端"与"桌面"联动的一个典型场景,也是整个系统最吸引人的地方。
4.3 画板与图片预览
画板应用用的是 Canvas 2D API,支持画笔、矩形、圆形、橡皮擦、调色板和粗细调节。实现的关键在于绘制状态机的管理:按下鼠标时开始路径,移动时绘制,抬起时结束路径并记录到历史栈,才能实现撤销操作。
图片预览应用更简单,它本质上就是一个 <img> 标签的包装。通过 URL.createObjectURL 把本地选中的图片文件转成临时链接,预览完成后调用 revokeObjectURL 释放内存。这个细节我一开始漏了,结果连续预览多张图片后内存占用肉眼可见地上涨,后来补了一个"关闭预览窗口自动释放"的逻辑。
4.4 设置面板:换壁纸、调主题、记忆窗口位置
想让一个模拟系统有真实感,系统设置必须存在。设置面板提供三组功能:
- 壁纸切换:内置 6 张渐变/纯色背景,选择后写入系统状态,桌面背景实时更新。
- 主题切换:目前支持亮色和暗色两套主题,通过切换 html 根元素的 data-theme 属性实现 CSS 变量批量替换。
- 窗口行为设置:是否开启打开窗口记忆位置、是否开启关闭页面提示。
说到主题切换,用 CSS 变量实现确实是最优雅的方式。把窗口背景、文字色、边框色、按钮色都抽成变量,暗色主题只需要在根节点覆盖一组变量值,所有组件自动变色,不需要挨个改 CSS。这个方案强烈推荐给做前端项目的同学。
5. 从 60fps 到稳住帧率:性能优化的三次迭代
5.1 布局抖动:为什么移动窗口时其他窗口都在闪
第一版窗口拖拽上线后,我发现拖动一个窗口时,相邻窗口会有肉眼可见的抖动。用 DevTools 的 Performance 面板录制了一段操作,发现每个 mousemove 回调里都发生了强制同步布局(forced reflow),原因是我在更新当前窗口位置的同时,读取了另一个窗口的 offsetWidth 属性来计算对齐参考值。
这个问题最直接的解法是把对齐参考值缓存起来,只在窗口尺寸变化时更新,而不是在每次移动时读取。另一个更底层的优化是把窗口的 transform 属性作为位置载体,而不是同时修改 left 和 top。因为 transform 的改变不会触发 layout,只会触发 composite,性能开销小一个量级。我在所有窗口移动逻辑里把 left/top 全部改成了 translate3d 方式:
javascript复制winEl.style.transform = `translate3d(${win.x}px, ${win.y}px, 0)`;
这个改动让拖动帧率在复杂桌面场景下依然稳定在 60fps。
5.2 事件节流:高频 mousemove 与滚动事件的救赎
即使有了 transform,mousemove 的触发频率也可能达到每秒上百次。我在拖拽核心循环里加了 requestAnimationFrame 节流,前文提过。对滚动事件,场景不太一样:滚动时地图/长文档需要连续更新,但如果每次滚动都重新渲染全部内容就会卡。我的做法是对所有窗口内的滚动容器统一挂载滚动事件,利用 rAF 把滚动状态合并以后统一执行一次重绘逻辑。
除了 rAF 节流,我还对"非必须高频执行"的操作做了时间阈值节流。比如窗口 resize 过程中实时保存窗口位置的逻辑,我用了一个 200ms 的防抖版本,确保用户拖拽停止 200ms 后才会写入 localStorage。这对减少持久化写入次数效果显著。
5.3 内存泄漏排查:从闭包到全局监听器
项目做到中期,我连续切换多款应用、反复开关几十个窗口后,页面开始越来越卡。用 Chrome 任务管理器看到内存只增不减,典型的泄漏场景。
排查后定位到三个问题:
- 关闭窗口时移除了 DOM,但没有移除绑在 window 上的全局 keydown/mousedown 监听器。这个问题最隐蔽,因为监听器引用了窗口状态对象,导致整个窗口的 JS 对象无法被 GC 回收。修复方式是引入一个全局的 listenerRegistry,窗口关闭时统一执行 dispose 方法解绑监听。
- 终端模拟器因为要持续监听历史命令输入,用了 setInterval 做光标闪烁。如果窗口关闭后没有 clearInterval,定时器会持续运行并持有 DOM 引用。修复后,我在窗口关闭事件里调用应用实例的 destroy 钩子。
- Canvas 画板撤销栈保存了过大的 ImageData。原始图片如果太大,撤销栈不限制长度的话内存会爆。我限制了撤销步数为 30 步,超出后自动丢弃最老的记录。
这三类问题实际上也是很多前端项目都会遇到的内存泄漏来源,排查思路值得记在小本子上:先看全局监听器,再看定时器,最后看大数据结构。
6. 踩坑实录:三个让我调试到深夜的问题
6.1 中文输入法在终端里"输不进去"
终端模拟器上线后,有个朋友反馈:在输入框里打中文,候选字选完以后,内容没有出现在终端里。我在调试中发现,终端用的 keydown 事件无法正确捕获中文输入法产出的字符,因为中文输入合成过程触发的是 compositionstart / compositionupdate / compositionend 事件序列,而不是普通的 keypress。
解决方法是监听 textInput 事件,或者处理 compositionend 事件并把其 payload 添加到终端输入缓冲区中。我最后选的是 textInput 事件,因为它能直接拿到输入后的最终字符串,对中文、日文、韩文都非常友好。修正后,中文输入终于正常了,顺带也让终端支持了粘贴富文本内容时的纯文本提取。
6.2 拖拽窗口时鼠标"穿透"到 iframe
项目里有个应用会在窗口内部嵌入 iframe 展示网页。拖拽窗口经过 iframe 上方时,鼠标移动事件会被 iframe 吞掉,导致拖拽中途"卡住",鼠标好像钻进了另一个世界。
我在 iframe 的父级容器上添加了一层透明的覆盖层,拖拽过程中把覆盖层提高 zIndex 挡住 iframe 的交互,拖拽结束时隐藏覆盖层。这个办法简单有效,代价是拖拽期间 iframe 网页无法响应鼠标操作,但和卡顿问题相比这个代价完全可以接受。
顺便说一句,这也是很多桌面应用框架处理内嵌网页时的通用策略,并非我独创,但确实好用。
6.3 窗口数量到 64 之后页面开始掉帧
按设计,窗口数量上限是 64。但实际测试中,当第 60 多个窗口打开时,任务栏的缩略图渲染就开始出现明显延迟,而且切换窗口的时候动画掉帧。
任务栏缩略图的实现原来是为每个窗口创建一个独立的 Canvas,实时截取窗口内容区域。窗口多了以后 Canvas 数量激增,GPU 内存吃紧。优化思路是给任务栏缩略图做懒渲染:只有任务栏缩略图进入可视区域(任务栏本身是横向滚动)才创建 Canvas,离开可视区域就销毁。这个改动让 64 个窗口全开时的内存占用几乎降了一半,掉帧问题也消失了。
7. 上线部署与后续还能怎么玩
7.1 静态托管的零成本方案
因为 64x 是纯静态页面,我没有搭服务器,直接把整个目录推到了静态托管平台上。部署流程就是一行命令的简洁程度:把目录上传,配置首页为 index.html,完成。这个过程甚至不需要后端接口,对于一个纯前端模拟操作系统来说,部署成本几乎可以忽略。
如果后续要加"多用户文件系统",那就是另一个量级的事了,需要引入后端接口和数据库。我计划在下一个迭代里预留一个 WebDAV 适配层,让 VFS 可以切换成远端存储。
7.2 下一步:离线应用、PWA 和更多桌面生态
当前版本还有几个没来得及做的功能,列在项目 Roadmap 里:
- 离线支持:把 64x 包装成 PWA,使用 Service Worker 缓存静态资源,实现断网可用。
- 应用市场:做一个简单的应用列表页,让用户可以自由安装/卸载第三方窗口应用。
- 窗口动画:目前最小化/最大化过渡做得比较生硬,后续想加入弹簧动效。
- 多桌面空间:像真实系统那样支持多个虚拟桌面,快捷键切换。
对我自己来说,做完这个项目最大的收获不是"我写了一个网页操作系统"这个噱头,而是把很多前端零碎的知识点——事件机制、动画性能、内存管理、模块解耦——在一个大项目里真正串了起来。如果你也想练习前端综合能力,找这种"看似不正经"但牵涉面很广的项目来练手,其实是性价比极高的。64x 的窗口管理代码我后面还会继续重构,等应用市场做完以后,大概率会有第三篇文章出来。
