1. 可拖动侧边栏到底解决什么问题,适合谁用
侧边栏宽度固定这件事,平时不觉得难受,一旦遇到长文档、代码编辑器、后台管理面板就会暴露出来。左边目录太窄,长标题被截断;拉宽一点,右侧正文又被挤得只剩一条缝。用户每次刷新页面,宽度又回到默认值,刚调好的布局白调了。
可拖动侧边栏要解决的就是这个:让用户用鼠标按住一条分隔条左右拖动,实时改变侧边栏宽度,松手后把宽度写进 localStorage,下次刷新页面自动还原到上次的位置。它不依赖任何框架,原生 JS 就能实现,核心 API 只有三个鼠标事件加一个本地存储。
适合谁看:正在写后台管理系统、文档站、在线 IDE、笔记类应用的前端同学;刚学完 DOM 事件想找一个完整小项目练手的新手;以及被「侧边栏宽度写死」折磨过的产品开发。你不需要 React/Vue 基础,只要会querySelector和addEventListener就能跟下来。
我试过把这个逻辑塞进一个后台项目,原本产品经理要求「侧边栏支持拖拽」,评估下来以为要引第三方库,结果原生实现不到 60 行,还没有额外依赖。下面从零开始,把 HTML 结构、CSS 布局、JS 拖拽逻辑、localStorage 持久化、刷新还原、清空回退全部走一遍,每一步都给可复制的代码和验证动作。
核心检索词先明确:原生 JS 可拖动侧边栏 + localStorage 记住拖动位置。读完你能得到一个刷新不丢宽度、带最小宽度保护、拖动时不选中文本的侧边栏组件。
2. 前置准备:TaoToken 接入与本地环境说明
这一节不是必须的,但如果你打算把侧边栏组件接到一个真实项目里,或者用 AI 辅助生成/补全这段拖拽逻辑,可以先把模型调用通道准备好。TaoToken 是一个模型 API 聚合入口,官网地址是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 基址是 https://taotoken.net/api 。它本身不替代你的编辑器,只是给代码补全、对话调试提供一个统一的调用地址。
为什么写侧边栏要提这个?因为拖拽逻辑里最容易出错的地方是「移动时重复读取宽度导致抖动」,这种问题用模型对话把代码贴进去问,比翻文档快。你可以把下面这段拖拽代码丢进模型对话里,让它帮你检查事件解绑是否完整。
接入需要三件套:Base URL、API Key、Model ID。Base URL 填https://taotoken.net/api,API Key 在控制台的 API Keys 页面创建,Model ID 按你实际使用的模型填写。这三样缺一不可,尤其是 Model ID,写错会直接报模型不存在。
如果你用的是 Claude Code 这类命令行编码工具,配置方式是在项目里设置环境变量或配置文件,把 Base URL 指向上面这个地址,Key 填控制台生成的,Model ID 填对应模型。Cline、Codex 的 auth.json 也是同样的三件套思路:地址、密钥、模型名。配置完先发一条最简单的请求验证通道是否通,再开始写业务代码。
本地环境方面,这个侧边栏 demo 只需要一个浏览器和一个能打开 HTML 文件的方式。你可以直接新建index.html,把后面的代码粘进去双击打开;也可以用 VS Code 的 Live Server 插件起一个本地服务,改完自动刷新,调试拖拽更顺手。不需要 Node、不需要构建工具。
有一点要提醒:localStorage 是按域名隔离的。你用file://打开和用http://localhost打开,存的是两份数据。调试时如果发现「明明存了却没还原」,先确认打开方式是否一致。这个坑后面排错章节还会细说。
3. 可复制配置:HTML/CSS/JS 完整片段与关键参数
这一节给完整可运行代码,路径和原文一致,直接复制即可。先看 HTML 结构,左侧栏.scalable里放内容和一个拖动条.drag,右侧.content放正文。
<body> <!-- 左侧栏 --> <div class="scalable"> <div class="left-content"> <img src="xxx" alt=""> </div> <!-- 拖动条 --> <div class="drag"> <i></i> <i></i> </div> </div> <!-- 内容栏 --> <div class="content"> <div class="right-content">holle word</div> </div> </body>CSS 部分,body 用 flex 横向排列,左侧栏设position: relative让拖动条能绝对定位到右边缘。拖动条right: -15px挂在侧边栏外侧,cursor: col-resize给出左右拖动的鼠标样式,user-select的禁用交给 JS 在拖动时动态加。
* { padding: 0; margin: 0; } body { display: flex; min-height: 100vh; } /* 左侧栏 */ .scalable { position: relative; width: 300px; min-width: 100px; /* 设置最小宽度 */ height: 100vh; background-color: beige; } /* 拖动条 */ .drag { position: absolute; top: 0; right: -15px; width: 15px; height: 100vh; box-shadow: 0 0 5px rgba(0, 0, 0, 0.5); cursor: col-resize; display: flex; justify-content: center; align-items: center; } /* 拖动条中的2条线 */ .drag i { display: inline-block; width: 1px; height: 20px; background-color: #3e3e3e; } .drag i:nth-child(1) { margin-right: 5px; } .left-content { padding: 20px; } .left-content img { width: 100%; } .right-content { padding: 20px 20px 20px 40px; }JS 是核心。四个关键点:mousedown 记录起始坐标和当前宽度、mousemove 计算新宽度、mouseup 解绑并写入 localStorage、页面加载时读取还原。
// 获取 body,拖动条 和 左侧栏 let drag = document.querySelector('.drag'); let body = document.querySelector('body'); let scalable = document.querySelector('.scalable'); // 定义变量,记录点击时的X let startX, scalableWidth = 0; // 获取本地存储中的左侧栏宽度,有值就还原 let localWidth = localStorage.getItem('scalableWidth'); if (localWidth !== null) { scalable.style.width = localWidth + 'px'; } // 给 drag 绑定鼠标点击事件 drag.addEventListener('mousedown', startDrag); // 点击的事件 function startDrag(e) { startX = e.clientX; // 宽度必须在点击时获取,不能在移动时获取 scalableWidth = getScalable(); // 点击时给 body 添加不可选中样式 body.style.userSelect = 'none'; body.addEventListener('mousemove', moveDrag); body.addEventListener('mouseup', remove); } // 移动的事件 function moveDrag(e) { let newWidth = scalableWidth + e.clientX - startX; scalable.style.width = newWidth + 'px'; } // 解绑的事件 function remove() { body.style.userSelect = 'auto'; localStorage.setItem('scalableWidth', getScalable()); body.removeEventListener('mousemove', moveDrag); body.removeEventListener('mouseup', remove); } // 获取左侧栏宽度函数 function getScalable() { return parseInt(window.getComputedStyle(scalable).width, 10); }关键参数说明:startX是按下瞬间的鼠标横坐标,scalableWidth是按下瞬间侧边栏的像素宽度,两者都在 mousedown 里固定。移动时用scalableWidth + e.clientX - startX算新宽度,鼠标右移e.clientX - startX为正,侧边栏变宽;左移为负,变窄。这个差值逻辑是整个拖拽不抖动的关键。
getScalable()用getComputedStyle拿到的是带px的字符串,parseInt转成数字。存进 localStorage 的也是数字,还原时拼上px。min-width: 100px在 CSS 里兜底,防止拖到负宽度。
4. 验证请求与成功结果:拖动刷新与清空回退
代码写完要验证两件事:拖动后刷新位置是否保持,清空 localStorage 是否回退默认值。按下面步骤走。
第一步,打开页面,默认侧边栏 300px。把鼠标移到侧边栏右边缘的拖动条上,光标变成左右箭头。按住左键向右拖,侧边栏实时变宽,右侧内容同步被挤压。拖动过程中试着选中文字,会发现选不中,因为body.style.userSelect = 'none'生效了。
第二步,松手。打开浏览器开发者工具,切到 Application 面板,左侧找到 Local Storage,展开当前域名,能看到一条scalableWidth记录,值是你松手时的宽度数字,比如420。这一步确认写入成功。
第三步,按 F5 刷新页面。侧边栏应该保持 420px,而不是回到 300px。如果保持住了,说明读取还原逻辑正常。再拖一次到别的宽度,刷新,确认跟着变。
第四步,验证回退。在 Application 面板里右键删掉scalableWidth这条记录,或者直接在 Console 里执行:
localStorage.removeItem('scalableWidth'); location.reload();刷新后侧边栏回到 CSS 里写的 300px 默认值。这一步证明「没有存储值时走默认」的分支是对的。
第五步,边界测试。把侧边栏往左拖到很窄,观察是否停在 100px 不再继续缩小,这是min-width在起作用。往右拖到很宽,右侧内容区会被压缩,如果内容区有min-width或flex-shrink设置,表现会不同,可以按需调整。
成功结果的标准:拖动流畅无抖动、松手后 localStorage 有值、刷新后宽度还原、清空后回默认、拖到极限有最小宽度保护。五条都过,这个组件就算可用了。
如果你在验证过程中用模型对话辅助排查,可以把 Console 报错和当前代码一起贴进去,让它定位是事件绑定问题还是取值问题。通道配置见上一节的 Base URL、Key、Model ID 三件套。
5. 本篇常见错误排查:401、local proxy failed、reading choices、OAuth
这一节把拖拽侧边栏和接入模型通道时容易撞上的报错集中过一遍。有些是前端逻辑问题,有些是 API 调用问题,分开看。
报错一:拖动时侧边栏疯狂抖动、宽度乱跳。原因通常是宽度在 mousemove 里重复读取。如果你把scalableWidth = getScalable()写进了 moveDrag,每次移动都拿当前宽度再加差值,宽度会指数级放大。正确做法是只在 mousedown 里读一次。对照第 3 节代码,确认getScalable()只出现在 startDrag 和 remove 里。
报错二:松手后还在拖动,鼠标移出窗口也停不下来。这是 mouseup 没绑对或者没解绑。mouseup 要绑在body上而不是拖动条上,因为拖动时鼠标可能已经离开拖动条区域。解绑时removeEventListener的第二个参数必须是同一个函数引用,不能写成匿名函数。检查body.addEventListener('mouseup', remove)和body.removeEventListener('mouseup', remove)是否成对。
报错三:刷新后宽度没还原。先看 localStorage 里有没有值,再看读取代码是否在 DOM 加载后执行。如果 script 写在 head 里且没加 defer,document.querySelector('.scalable')可能拿到 null。把 script 放到 body 末尾,或者用DOMContentLoaded包一层。另外确认打开方式一致,file://和localhost的存储不互通。
报错四:401 Unauthorized。这是调用模型 API 时的鉴权失败,和侧边栏无关。检查 API Key 是否填对、是否过期、请求头里Authorization: Bearer <key>格式是否正确。Base URL 用https://taotoken.net/api,不要多加斜杠或路径。
报错五:local proxy failed。本地代理配置问题,通常是环境变量里残留了代理设置,或者请求地址被错误改写。检查你的终端/编辑器环境变量,确认请求直接指向 Base URL,没有经过额外的本地转发层。
报错六:reading choices 相关报错。这类报错一般出现在解析模型返回结构时,返回体里没有预期的choices字段。先打印完整响应体看结构,确认 Model ID 是否正确、请求体格式是否符合该模型要求。不同模型的返回字段名可能不同,别硬套。
报错七:OAuth 相关报错。如果你用的是需要 OAuth 授权的编码工具,报错通常出在 token 过期或回调地址不匹配。重新走一遍授权流程,确认回调地址和配置里写的一致。用 API Key 方式接入的话一般不涉及 OAuth。
排查顺序建议:先确认是前端逻辑问题还是 API 问题,前端问题看 Console,API 问题看 Network 面板的请求和响应。把报错原文和上下文一起看,比猜快。
6. 语义一致的收尾:把侧边栏组件用起来
到这里,一个刷新不丢宽度的可拖动侧边栏就完成了。核心就三件事:mousedown 固定起点、mousemove 算差值改宽度、mouseup 存 localStorage。加上页面加载时的读取还原和 CSS 的 min-width 兜底,就是一个能直接放进项目的组件。
实际用的时候可以再补两个小细节。一是给拖动加个节流,虽然原生 mousemove 频率下这个逻辑已经够流畅,但在低端设备上可以用requestAnimationFrame包一层。二是把宽度存成对象,比如{ width: 420, collapsed: false },方便以后扩展折叠状态。
如果你要把这个组件接到真实项目,或者用 AI 帮你继续扩展功能,模型调用通道可以走 TaoToken:API 基址 https://taotoken.net/api ,控制台创建 Key,模型对话入口在 https://taotoken.net/api ,接入文档在 https://taotoken.net/api 。长期做编码和 Agent 类任务的话,Coding Plan 会更合适,地址是 https://taotoken.net/api 。三件套 Base URL、Key、Model ID 配齐就能用。
最后留一个实用技巧:调试 localStorage 时,别每次都手动去 Application 面板删。在 Console 里写个小函数clearSidebar = () => { localStorage.removeItem('scalableWidth'); location.reload(); },需要回退默认值时敲一下就行,比翻面板快得多。