普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月29日技术

从奶茶店彻底搞懂 SSR!从零到拿捏服务端渲染,看完面试吹牛逼不卡壳

2026年3月28日 23:45

本文适合人群:

  • 刚接触前端,听过 SSR 但完全摸不着头脑的新手
  • 只会写 CSR 单页应用,想入门 SSR 的前端 er
  • 马上要面试,怕被问到 SSR 的准打工人
  • 想做 SEO 优化,不知道怎么选技术方案的同学全文无废话,用奶茶店的故事带你从零搞懂 SSR,看完直接拿捏!

家人们谁懂啊!辛辛苦苦写了个 React 单页应用,交互丝滑到飞起,结果一上线傻眼了:用户打开网页先看 3 秒白屏,脾气爆的直接反手一个关闭;百度爬虫爬了半个月,连你页面的毛都没抓到,搜索排名直接沉到马里亚纳海沟;老板拍着桌子问你:为啥人家官网打开就有内容,咱们的得等半天?你挠破头加了路由懒加载、骨架屏,优化了半天,还是治标不治本。

今天咱就把这个救星「SSR 服务端渲染」给扒得明明白白,从最基础的概念讲起,结合大家天天写的业务场景,保证你看完不仅能懂,还能在面试官面前吹得头头是道!


一、先搞懂「反面教材」CSR:客户端渲染

在讲 SSR 之前,咱必须先把它的对照组掰扯清楚 —— 也就是现在 90% 的 React/Vue 项目都在用的CSR(Client Side Render 客户端渲染) 。不搞懂 CSR 的痛点,你根本不知道 SSR 到底解决了啥世纪难题。

CSR 到底是个啥?

用一句话翻译:把页面渲染的所有活儿,全甩给用户的浏览器(客户端)来干。咱用一个大家都懂的奶茶店类比,给它讲得透透的:

你去奶茶店点单:“老板,一杯全糖少冰加珍珠的波霸奶茶!”结果老板反手给你一个空杯子、一包奶茶粉、一包珍珠、一个迷你热水壶,跟你说:“原料都在这了,您自己回座位冲吧,冲完就能喝!”

你是不是当场想掀桌子?但咱前端天天在用的 CSR,本质上就是在干这事!

对应到我们的代码世界,完美匹配大家日常开发的逻辑:

  • 你的浏览器 = 这个冤大头顾客
  • 后端服务器 = 这个摆烂奶茶店
  • 空杯子 = 服务器返回的几乎空的 HTML 文件,里面就一行核心代码 <div id="root"></div>,啥正经内容都没有
  • 奶茶粉 + 珍珠 + 热水壶 = 我们打包出来的一坨坨 JS bundle 文件
  • 你自己冲奶茶 = 浏览器下载完所有 JS 文件,解析执行,再去请求后端 API 拿数据,最后把页面内容一点点渲染到 #root 节点上

整个渲染的核心逻辑,全在用户的浏览器里完成,所以叫客户端渲染。

CSR 的真香优点

能火这么多年,CSR 肯定不是一无是处,它的优点至今仍是很多业务场景的首选:

  1. 交互丝滑到飞起:毕竟是单页应用 SPA,页面跳转、内容更新都是局部刷新,不用整个页面重载,就像你冲好奶茶之后,想加糖加冰直接加就行,不用重新冲一杯,用起来跟原生 App 似的,体验拉满。
  2. 前后端分离 yyds:前端只管写页面交互,后端只管给接口,分工明明白白。本地开发用 mockjs 就能模拟接口数据,状态管理用 zustand 这类库就能轻松拿捏,开发效率直接拉满。

CSR 的致命缺点

但成也 SPA,败也 SPA,CSR 的两个核心痛点,直接戳中了前端人的命门:

  1. 首屏加载慢到抠脚:用户打开页面,得先下载 HTML→再下载 JS→再解析执行 JS→再请求 API 拿数据→最后才能渲染出内容,整个过程串行阻塞。网越差、JS 包越大,用户盯着白屏的时间就越长。哪怕你加了路由懒加载、骨架屏,也只是缓解用户焦虑,根本解决不了本质问题。
  2. SEO 烂到地心:这是 CSR 最致命的伤!搜索引擎的爬虫就像个赶时间的美食探店博主,进店一看杯子是空的,啥内容都没有,直接扭头就走,根本不会等你慢悠悠冲完奶茶。你页面里的文章、产品信息、关键词全在 JS 里,爬虫根本抓不到,你的网站自然在搜索结果里查无此人。

二、主角登场!SSR:服务端渲染

搞懂了 CSR,SSR 就一句话能说明白:把渲染页面的核心活儿,从用户的浏览器,挪到了服务器上干!

还是那个奶茶店,这次换成 SSR 模式,体验直接拉满:

你点单:“老板,一杯全糖少冰加珍珠的波霸奶茶!”老板直接在后台咔咔一顿操作,30 秒给你递过来一杯冲好、插好吸管、小料全加齐的成品奶茶,你拿到手开盖直接喝,一口都不用等!

对应到代码世界,就是 SSR 的核心定义:SSR 全称 Server Side Render,服务端渲染,就是让我们的 React 组件,先在 Node.js 服务端跑一遍,把组件 + 业务数据直接渲染成带完整内容的 HTML 字符串,再把这个完整的 HTML 返回给浏览器

你看,浏览器一拿到 HTML,瞬间就能渲染出完整的页面内容,用户直接就能看到内容,再也不用盯着白屏等 JS 加载了!

SSR 的灵魂:hydration 水合(注水)

这里必须给新手纠正一个致命误区:很多人以为 SSR 就是返回一个静态 HTML,页面点啥都没反应?大错特错!

SSR 可不是把 JS 扔了,人家既要让你先喝上奶茶,还要让你能自由 DIY!这里就不得不提 SSR 的灵魂操作 ——水合

用大白话翻译大家笔记里的核心逻辑:拿着服务器返回的已有 HTML,让 JS 在客户端重新跑一遍,把交互事件粘到对应的 DOM 节点上

整个水合过程分三步走,丝滑到用户根本感知不到:

  1. 先给你喝上奶茶:服务器先返回完整的 HTML 页面,浏览器瞬间渲染出静态内容,用户第一眼就看到完整页面,这一步直接解决了首屏和 SEO 的核心痛点。
  2. 偷偷给你递工具:浏览器在渲染静态页面的同时,会在后台默默下载对应的 JS bundle 文件,完全不影响用户看内容。
  3. 给奶茶激活 DIY 功能:JS 下载完成后,会给这个静态 HTML 页面做一次「水合激活」—— 把组件的状态、事件绑定、交互逻辑(比如按钮点击、轮播图、下拉刷新)全给你粘到对应的 DOM 节点上,让静态页面变成一个能交互、能玩的 SPA 应用。

这里还要补一个关键细节:React 组件的生命周期、state、副作用(比如 useEffect),在服务端只会跑渲染的部分,那些和浏览器、交互相关的逻辑,只会在客户端水合的时候才会执行,这也是新手最容易踩坑的地方!


三、SSR 的真香优点 & 坑爹踩坑点

真香优点,个个精准打击 CSR 的命门

  1. 首屏加载速度直接起飞:不用等 JS 下载、解析、执行、请求接口,浏览器拿到 HTML 直接渲染,开盖即食。网越差、用户设备越拉,这个优势越明显,用户留存率直接拉满。
  2. SEO 的亲爹级友好:爬虫过来直接拿到带完整内容的 HTML,你页面里的关键词、文章内容、产品信息明明白白,爬虫直接全抓走,收录和排名直接蹭蹭涨,做官网、内容站、电商站的必备神器。
  3. 低配设备的福音:渲染页面的重活全让高性能的服务器干了,用户的老破手机、旧电脑不用费劲跑大 JS 包,不仅流畅还省电,对低端机用户太友好了。

坑爹缺点,不是谁都能用得起

SSR 也不是万能神药,不然为啥不是所有项目都上 SSR?它的坑也不少:

  1. 服务器压力直接爆炸:以前是 1000 个用户,1000 个设备自己渲染页面,现在 1000 个用户的渲染活全让服务器干了,高峰期服务器直接忙到冒烟,服务器成本直接翻倍,小团队得掂量掂量自己的钱包够不够造。
  2. 开发门槛直接飙升:以前写 CSR,你只需要考虑浏览器环境就行;现在写 SSR,你得同时兼顾服务端环境和客户端环境。比如你天天用的 window、document 对象,在 Node 服务端根本不存在,一不小心就会报 undefined 的错;还有水合不匹配的 bug,HTML 内容和客户端渲染的内容对不上,能把新手搞到崩溃。
  3. 项目复杂度直线上升:SSR 项目要处理服务端的路由、数据预取、缓存、状态同步,比纯 CSR 的 SPA 项目复杂太多了,维护成本也跟着涨。

四、业务场景怎么选?别盲目炫技!

很多人学完 SSR,就想给所有项目都安排上,达咩!技术没有好坏,只有合不合适,咱直接给你划重点,闭着眼选都不会错。

闭眼选 CSR 的场景

  1. 后台管理系统、内部 OA 系统:这类系统都是自己人用,根本不需要 SEO,用户也都是固定的,哪怕首屏慢个 1 秒也无所谓,你非要上 SSR,就是脱裤子放屁 —— 多此一举,纯纯给自己找罪受。
  2. 强交互的应用:比如 canvas 画板、在线流程图、工作流编辑器这类,核心是交互,内容很少,SEO 完全没用,CSR 的丝滑交互才是王道。
  3. App 内的 WebView 页面:现在移动端流量入口早就不是百度搜索引擎了,大家都是直接打开 App。App 里的 WebView 页面,大多是内部功能页,根本不需要 SEO,而且原生已经处理了拍照、蓝牙、陀螺仪这类硬件相关的能力,WebView 只需要做交互页面,用 CSR 完全够用。

闭眼选 SSR 的场景

  1. 企业官网、品牌官网:官网的核心需求就是 SEO,要让用户能搜到,还要首屏打开快,给用户留个好印象,SSR 简直是量身定做。
  2. 内容站、资讯站、个人博客:这类网站全靠内容吃饭,SEO 就是生命线,而且用户大多是从搜索引擎进来的,首屏速度直接决定用户留不留,SSR 必选。
  3. 电商网站、营销活动页:电商站既要 SEO 引流,又要首屏快,毕竟用户多等 1 秒,转化率就掉一大截,SSR 能直接提升 GMV,香到不行。

五、懒人上手指南:不用自己造轮子

看到这,很多同学会说:SSR 听起来好复杂,我想上手试试,难道要自己从零搭一套?大可不必!咱前端人从来不重复造轮子,有现成的「全自动 SSR 奶茶机」给你用:

  • React 生态:Next.js,现在 SSR 界的顶流,React 官方都强推,把渲染、水合、路由、数据预取、打包这些脏活累活全给你封装好了,你只管写业务代码就行,新手友好度拉满。
  • Vue 生态:Nuxt.js,Vue 官方亲儿子,和 Next.js 师出同门,用法几乎一致,Vue 玩家闭眼冲。

这俩框架已经把 SSR 的坑都给你踩平了,不用你操心服务端和客户端的环境差异,不用你自己写水合逻辑,开箱即用,新手也能快速上手 SSR 项目。


最后:总结一下

其实 SSR 根本不是什么新东西,早年间 PHP、JSP 写的网站,全是服务端渲染 HTML,属于是前端界的「文艺复兴」了。只不过现在的 SSR,结合了 SPA 的丝滑交互,比老祖宗的版本强了不止一个档次。

用一句话记住 SSR 的核心:服务器提前把页面拼好,浏览器拿到手直接看,先看内容再水合绑交互,首屏快、SEO 好,就是费服务器、开发有门槛

技术从来不是为了炫技,而是为了解决问题。搞懂了 CSR 和 SSR 的核心差异,知道了它们各自的适用场景,你才能在项目里做出最合适的技术选型,再也不会被面试官问住,也不会被老板怼得哑口无言。

我把 Sentry 接进了 7 端小程序:从异常捕获、Breadcrumb 到 Source Map 定位

作者 志遥
2026年3月28日 23:39

微信、支付宝、字节、百度、QQ、钉钉、快手 7 端小程序,一套代码统一接入 Sentry,把异常捕获、用户路径、弱网兜底、Source Map 定位真正串成一套可落地的工程方案。

很多团队不是不想做小程序监控,而是试了一圈之后发现:

  • 官方能力能解决一部分问题,但很难覆盖多端统一治理
  • Web 监控 SDK 不能直接塞进小程序环境
  • 错误即使上报了,没有 Source Map 和上下文也依然难排查
  • 真正接入时,还会遇到域名白名单、弱网、堆栈路径不一致这些工程问题

这篇文章不讲抽象概念,直接回答一个更实际的问题:

如果你想把小程序真正接进 Sentry,这件事应该怎么落地?

一、为什么小程序线上问题比 Web 更难排查?

做过小程序开发的人,大概率都见过这些场景:

  • 用户说“页面打不开了”,但你在开发者工具里怎么都复现不出来
  • 某个 API 只在低版本基础库报错,测试同学根本测不到
  • 某个请求偶发超时,最终在业务里表现成“按钮点了没反应”
  • 某个版本上线后投诉变多,但你不知道到底是哪个页面、哪个操作、哪类设备在出问题

Web 端出了问题,大家已经习惯去看 Sentry、日志平台、APM。

但到了小程序,事情一下变复杂了:

  • 不是标准浏览器环境
  • 没有 DOM
  • 各平台运行时不一致
  • 上报链路受域名白名单和网络环境影响
  • 堆栈路径不是标准 URL,Source Map 解析也更麻烦

这就导致一个很现实的问题:

很多团队并不是没有监控意识,而是缺少一套真正适合小程序环境的工程化方案。

二、只靠微信官方能力,够不够?

先说结论:

  • 如果你只做微信单端项目,官方能力通常已经够用
  • 如果你做的是多端小程序,或者已经在用 Sentry 监控 Web / App / Backend,那么官方能力通常不够

微信官方已经提供了不少能力:

  • wx.getRealtimeLogManager() 实时日志
  • We 分析里的 JS 错误分析
  • Source Map 能力
  • 开发者工具的性能分析
  • 真机调试

这些能力对于单微信项目非常有价值。

但一旦进入下面这些场景,就会明显吃力:

  • 同时维护微信、支付宝、字节等多个小程序端
  • 希望把小程序纳入现有 Sentry 体系
  • 希望看到页面跳转、请求、用户动作形成的完整上下文
  • 希望弱网/断网时错误上报尽量不丢
  • 希望前后端链路能串起来

这时候问题就不再是“有没有监控”,而是:

能不能有一套跨平台、可统一治理、能真正接进研发流程的监控方案。

三、为什么不能直接把 Web SDK 搬进来?

这是很多人第一反应会踩的坑。

Sentry 官方 JavaScript SDK 很强,但小程序和 Web 有几个本质差异:

  • 小程序没有浏览器 DOM 与标准事件系统
  • 小程序网络请求不是 fetch / XMLHttpRequest
  • 各平台全局对象、生命周期、错误入口都不一样
  • 运行时堆栈路径往往是虚拟路径,不适合直接做 Source Map 匹配

所以如果要让 Sentry 真正在小程序里落地,需要额外做几件事:

  • 抹平多端运行时 API 差异
  • 接管小程序特有的错误与生命周期
  • 用小程序 request 定制 Transport
  • 处理弱网场景下的离线缓存与重试
  • 对堆栈路径做归一化,方便 Source Map 还原

这也是我做 sentry-miniapp 的原因。

四、sentry-miniapp 到底解决了什么问题?

sentry-miniapp 是一个基于 @sentry/core 构建的多端小程序监控 SDK。

它要解决的不是“监控平台从 0 到 1 重做一遍”,而是:

让小程序团队也能获得接近现代 Web 工程的错误监控与排查体验。

当前覆盖的平台包括:

  • 微信小程序
  • 支付宝小程序
  • 字节跳动 / 抖音小程序
  • 百度小程序
  • QQ 小程序
  • 钉钉小程序
  • 快手小程序

同时也兼容:

  • Taro
  • uni-app

换句话说,它补的是“小程序环境里的最后一公里适配”。

五、它具体能做什么?

核心能力包括:

  • 自动捕获未处理异常与 Promise rejection
  • 记录页面跳转、网络请求、用户动作等 Breadcrumb
  • 采集设备、系统、基础库等环境信息
  • 支持多端路径归一化,配合 Source Map 定位源码
  • 支持弱网 / 断网场景下的离线缓存
  • 支持把小程序纳入现有 Sentry 项目与 release 流程

如果你已经在用 Sentry,最直接的价值就是:

小程序终于不再是监控体系里的孤岛。

下面这张图可以直观看到,事件进入 Sentry 后,不再只是零散日志,而是形成了可检索、可聚合的问题列表:

Sentry 上报数据列表

六、5 分钟接入一个最小示例

1. 安装

npm install sentry-miniapp

2. 初始化

app.jsapp.ts 中,放在 App() 调用之前:

const Sentry = require('sentry-miniapp');

Sentry.init({
  dsn: 'https://your-key@sentry.io/your-project-id',
  release: 'my-miniapp@1.0.0',
  environment: 'production',
});

App({
  // 你的 App 配置
});

完成初始化后,SDK 会自动做几件事:

  • 捕获未处理异常
  • 记录基础上下文
  • 接入请求链路
  • 为后续排查保留设备与运行时信息

3. 给关键业务补上下文

真正有价值的监控,不是只有一条 error message,而是能把错误和业务动作关联起来。

try {
  riskyOperation();
} catch (error) {
  Sentry.captureException(error);
}

Sentry.captureMessage('用户完成首次支付', 'info');

Sentry.setUser({ id: 'user123', username: '张三' });
Sentry.setTag('page', 'payment');
Sentry.setContext('order', { orderId: '2024001', amount: 99.9 });

这样当线上真的出问题时,你看到的不只是“报错了”,而是:

  • 哪个用户
  • 在哪个页面
  • 做了什么操作
  • 哪个订单上下文下出的错

实际落到后台时,错误详情会把堆栈、上下文、标签、用户信息集中展示,排查效率会比“用户口头反馈 + 本地猜测”高很多:

Sentry 错误详情页示例一

七、真正决定排查体验的,是 Source Map

很多团队其实不是没有上报,而是上报之后看不懂。

原因通常有三个:

  • 线上代码经过压缩
  • 小程序运行时堆栈路径和源码路径不一致
  • release 与 sourcemap 没有严格对齐

如果不把这件事打通,监控平台的体验会大打折扣。

上传 sourcemap 的方式可以是:

sentry-cli releases files "my-miniapp@1.0.0" upload-sourcemaps ./dist \
  --url-prefix "app:///" \
  --ext js --ext map

这里最关键的,不只是命令本身,而是两件事:

  • 堆栈路径要能被统一映射
  • 构建产物必须和 release 版本严格对应

sentry-miniapp 做的一件重要事情,就是把不同平台的虚拟路径统一处理到 app:/// 语义下,降低小程序 Source Map 对齐的复杂度。

当 Source Map 对齐之后,后台里看到的异常详情就不再只是“压缩后看不懂的堆栈”,而是可以真正用于定位源码的问题信息:

Sentry 错误详情页示例二

八、这套方案真正带来的收益是什么?

如果接入完整,它带来的不只是“多一个报错工具”,而是四层收益。

1. 从“知道报错”到“知道用户怎么走到这里”

你能看到页面路径、用户动作、请求链路,而不是孤零零一条异常。

这也是为什么 Breadcrumb 对线上排查特别重要:错误并不是孤立发生的,它往往是前面一连串操作和请求共同导致的。

Sentry 错误详情页示例三

2. 从“单端排查”到“多端统一治理”

如果你的业务同时跑在多个小程序端,一个统一面板带来的收益非常大。

3. 从“只能复现”到“即使复现不了也能定位”

很多线上问题压根难以稳定复现,真正有价值的是:

基于上下文快速缩小排查范围。

4. 从“一个 SDK”到“纳入版本与研发流程”

一旦和 release、Source Map、CI/CD 结合起来,监控才会真正变成工程资产,而不是可有可无的埋点。

如果再把性能链路一起接进来,后台看到的就不只是“错误发生了”,还包括请求与资源加载的上下游关系,这对排查慢请求、偶发超时、页面卡顿尤其有帮助:

Sentry Waterfall 视图

九、什么团队最适合上这套方案?

更适合下面这些团队:

  • 多端小程序团队
  • 已经在用 Sentry 的团队
  • 对发布质量、错误治理有明确要求的团队
  • 线上问题定位成本高、业务链路复杂的团队

如果你只是一个微信单端、规模不大、诉求不复杂的小程序项目,优先把官方能力用扎实,通常会更划算。

十、更务实的接入建议:分三层做,不要一步到位

如果让我给建议,我会这么分层:

  • 第一层:先把微信官方能力用好
  • 第二层:出现多端、统一治理、跨系统追踪需求时,再补统一监控方案
  • 第三层:把 release、Source Map、告警、回归分析接进 CI/CD

这样不会一上来就引入太多复杂度,但每一步都能产生明确收益。

十一、项目地址

如果你正好在做下面这些事情,可以看看这个项目:

它适合的不是所有小程序项目,而是这些场景:

  • 多端统一监控
  • 接入现有 Sentry 体系
  • 处理小程序环境下的 Source Map、弱网缓存、跨端 API 差异

十二、结语

小程序监控这件事,真正难的不是“把错误发出去”,而是:

  • 能不能在小程序环境里稳定发出去
  • 能不能带着足够多的上下文发出去
  • 能不能最终定位到源码
  • 能不能接进团队已有的研发流程

如果这些都做到了,小程序监控就不再只是“补日志”,而是真正开始具备工程价值。

如果你已经在用 Sentry,或者正在考虑给多端小程序补一套统一监控链路,这个项目也许正好能帮你少踩很多坑:

用了大半年 Claude Code,我总结了 12 个真正改变工作流的配置技巧

2026年3月28日 22:50

用了大半年 Claude Code,我总结了 12 个真正改变工作流的配置技巧

你一定经历过这样的场景:

# 你兴冲冲地装好了 Claude Code
npm install -g @anthropic-ai/claude-code

# 然后对着终端敲下第一句话
claude "帮我重构这个项目"

# 接下来的 10 分钟,你看着它一顿操作猛如虎
# 改了 47 个文件,删了你精心设计的抽象层
# 把 TypeScript 类型全干成了 any
# 还顺手给你升了三个大版本的依赖

改完之后你 git diff 一看,心态直接崩了。不是 Claude 不行,是你没告诉它"规矩"。

这篇文章不聊 Claude 有多智能、AGI 离我们有多近这些虚的。我只聊一件事:怎么配置 Claude Code,让它真正变成你的生产力工具,而不是一个需要你反复擦屁股的代码生成器(实际上没那么简单)。

基础配置:CLAUDE.md 才是灵魂

CLAUDE.md 是 Claude Code 的记忆文件——这个等下再说,放在项目根目录,它会在每次对话开始时被自动加载。这是你跟 Claude 建立"工作契约"的地方。

一个真正有用的 CLAUDE.md

# 项目概述

这是一个 B2B SaaS 的前端项目,技术栈:React 18 + TypeScript 5.3 + Vite 5 + TanStack Query v5。
状态管理用 Zustand,UI 组件库是内部封装的 @acme/ui(基于 Radix)。
后端 API 是 RESTful 风格,统一走 /api/v2 前缀,鉴权用 JWT + Refresh Token。

# 代码规范

- 组件文件用 PascalCase,工具函数用 camelCase
- 禁止使用 default export,全部用 named export
- 禁止使用 enum,用 as const 对象替代
- CSS 方案是 CSS Modules + PostCSS,不要引入 Tailwind 或 styled-components
- 类型定义统一放在同目录的 types.ts 中,不要用 interface,统一用 type

# 目录结构

src/
  features/       -- 按业务模块划分,每个模块包含 components/hooks/api/types
  shared/         -- 跨模块复用的工具、组件、hooks
  app/            -- 路由配置、全局 Provider、入口文件

# 绝对不要做的事

- 不要修改 src/shared/legacy/ 下的任何文件,这是历史遗留代码,正在逐步迁移
- 不要动 package.json 的依赖版本
- 不要创建新的全局状态 store,需要的话先跟我确认
- 不要使用 any 类型,实在推断不出来用 unknown + 类型守卫

看到区别了吗?好的 CLAUDE.md 要具体到能直接指导编码决策的程度。哦不,准确说是_INLINE_CODE_5__ 要具体到能直接指导编码决策的程度。"用 TypeScript"是废话,"禁止使用 enum,用 as const 替代"才是有效指令。

CLAUDE.md 的层级机制

这个很多人不知道——CLAUDE.md 支持多级放置:

项目根目录/CLAUDE.md          -- 全局生效
项目根目录/src/CLAUDE.md      -- 仅在操作 src 目录下文件时生效
项目根目录/tests/CLAUDE.md    -- 仅在操作 tests 目录下文件时生效
~/.claude/CLAUDE.md           -- 你所有项目都生效的个人偏好

我的实际用法是:根目录放项目级规范,src/features/ 下每个业务模块放一个,写清楚这个模块的业务上下文。比如:

# features/billing 模块

负责计费相关功能。核心概念:
- Plan: 套餐方案(free/pro/enterprise)
- Subscription: 用户的订阅实例,关联 Plan
- Invoice: 账单,由 Subscription 周期性生成

注意事项:
- 金额统一用"分"作为单位(integer),展示时再转换
- 所有金额计算必须用 Decimal.js,禁止浮点运算
- 退款逻辑非常复杂,修改前必须先读 src/features/billing/REFUND_LOGIC.md

环境区分:开发、CI、Code Review 三套配置

很多团队开始在 CI 流程里集成 Claude Code 做自动 Code Review 或者自动修复 lint 问题。这时候你需要区分环境。

本地开发配置

{
  "permissions": {
    "allow": [
      "Bash(npm test*)",
      "Bash(npm run dev*)",
      "Bash(npx tsc*)"
    ]
  },
  "model": "claude-sonnet-4-6"
}

CI 自动 Review 配置

# .github/workflows/claude-review.yml
- name: Claude Code Review
  run: |
    claude --print \
      --model claude-sonnet-4-6 \
      --permission-mode bypass \
      --max-turns 3 \
      "Review the changes in this PR. Focus on:
       1. Type safety issues
       2. Missing error handling at API boundaries
       3. Performance anti-patterns (unnecessary re-renders, missing memo)
       Do NOT suggest style changes, the linter handles that."
  env:
    ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

CI 环境里有几个关键点:

  • --print 模式,输出结果但不进入交互
  • --max-turns 限制它的操作轮次,避免跑飞
  • Prompt 里明确告诉它不要做什么,比如不要管代码风格,那是 ESLint 的活

深度分析配置

遇到复杂的架构决策或者疑难 bug,切到 Opus:

claude --model claude-opus-4-6 \
  "分析 src/features/billing/hooks/useSubscription.ts 的性能问题。
   这个 hook 在订阅列表页会导致每次筛选都触发全量重渲染。
   给出具体的优化方案,包括代码修改。"

Opus 的推理能力确实强一截,但速度慢、成本高。日常写 CRUD 没必要,遇到真正需要"想一想"的问题再拿出来。

提示词工程:跟 Claude Code 说话是门手艺

配置文件搞定了,日常交互的方式同样重要。同一个需求,不同的描述方式,得到的代码质量能差出两个档次。

别说"帮我写一个组件"

// 你说的
帮我写一个用户列表组件

// Claude 理解的
写一个能展示用户列表的 React 组件(具体怎么写我自由发挥)
// 你应该说的
在 src/features/user/components/ 下创建 UserList 组件:
- 用 TanStack Query 的 useQuery 调用 GET /api/v2/users
- 列表项用 @acme/ui 的 Card 组件
- 支持按 name 和 email 搜索,用 useDeferredValue 做防抖
- 空状态用 @acme/ui 的 EmptyState 组件
- 不需要分页,后端一次返回全量数据(最多 200 条)
- 参考 src/features/billing/components/InvoiceList.tsx 的写法

最后那句"参考 xxx 的写法"是点睛之笔,Claude Code 能直接读取那个文件,学习你项目里已有的模式。让它抄你的代码,比让它从零发挥靠谱得多。

用 /compact 控制上下文

长对话是 Claude Code 质量下降的头号杀手。聊到 20 轮以后,它会开始"忘记"前面的约定,代码风格也会漂移。

# 当你感觉它开始"犯迷糊"的时候
/compact

# 或者主动分阶段工作
# 第一阶段:讨论方案
claude "分析一下目前的路由架构,有哪些可以优化的点"

# 方案确定后开新对话执行
claude "按照以下方案重构路由:[具体方案]"

让 Claude 先读再写

先阅读以下文件,理解现有的错误处理模式:
- src/shared/lib/api-client.ts
- src/shared/hooks/useApiError.ts
- src/features/auth/api/login.ts

然后在 src/features/billing/api/createInvoice.ts 中实现创建账单的 API 调用,
错误处理方式必须与上述文件保持一致。

这个"先读后写"的模式我每天都在用。它解决的核心问题是:让 Claude 用你项目的方言写代码,而不是用它自己的"普通话"

你可能踩的坑(我已经替你踩过了)

CLAUDE.md 写太长反而有害

我试过写一个 500 行的 CLAUDE.md,把所有能想到的规范都塞进去。结果发现 Claude 反而开始选择性遗忘,有些规则它根本不遵守。经验值:根目录的 CLAUDE.md 控制在 100-150 行以内,更细的规范拆到子目录的 CLAUDE.md 里。Claude Code 只会在操作对应目录时加载子目录的配置,这样既不浪费上下文窗口,又能做到精准约束。

不要让 Claude 做它不擅长的事

Claude Code 写业务代码很强,但有几件事它做得不好:

  • 复杂的正则表达式:它生成的正则经常在边界情况翻车,自己写或用库更靠谱
  • 像素级还原设计稿:它能搭结构,但间距、颜色、动画的微调还是得人来
  • 性能优化的"最后一公里":它能帮你找到性能卡点,给出方向,但具体的优化参数(虚拟列表的 overscan、防抖的毫秒数)需要你实测调整
  • 涉及项目政治的决策:技术选型、要不要引入新依赖、要不要重构某个模块——这些是人的决策,不要甩给 AI

成本控制不是小事

Claude Code 按 token 计费,一个不小心就烧钱。几个省钱建议:

# 日常开发用 Sonnet,够用且便宜
claude --model claude-sonnet-4-6

# 只在需要深度推理时用 Opus
claude --model claude-opus-4-6

# 用 --max-turns 限制自动操作的轮次
claude --max-turns 10 "修复这个 bug"

根据 Anthropic 官方定价(2025 年数据),Opus 的 token 价格大约是 Sonnet 的 5 倍。如果你团队 5 个人全天用 Opus,一个月的 API 费用可能比一个初级工程师的工资还高。

context 溢出的信号

当你发现 Claude 开始出现以下症状,说明当前对话的上下文已经接近极限了:

  • 开始重复你之前已经否决的方案
  • 忘记了 CLAUDE.md 里写明的规范
  • 生成的代码风格突然变了
  • 回答变得越来越笼统,细节越来越少

这时候不要硬撑,直接开新对话。用一段简洁的背景描述代替之前 30 轮的聊天记录,效果反而更好。


Claude Code 不是魔法,它是一个能力极强但需要明确指令的工具,配置的本质是把你脑子里的"常识"翻译成机器能理解的规则。 你在配置上花的每一分钟,都会在后续的协作中以十倍的效率回报你。那些说"AI 写的代码不能用"的人,大概率是在用 2024 年的方式——裸奔、许愿式提示词、一句话需求——去驾驭 2026 年的工具。工具已经进化了,你的使用方式也该跟上。

LeetCode 215. 数组中的第K个最大元素:大根堆解法详解

作者 Wect
2026年3月28日 22:23

刷题路上,遇到“数组中的第K个最大元素”这类题目,很多人第一反应是“排序后直接取第k个”,但这样的时间复杂度是O(n log n),不符合题目要求的O(n)。今天就来拆解这道LeetCode中等题,用大根堆(最大堆)实现O(n)时间复杂度的解法。

一、题目解读:读懂需求,避开陷阱

题目很简洁:给定整数数组nums和整数k,返回数组中第k个最大的元素。

这里有两个关键注意点,也是容易踩坑的地方:

  • 不是“第k个不同的最大元素”:比如数组[3,2,3,1,2,4,5,5,6],k=4时,排序后是[1,2,2,3,3,4,5,5,6],第4个最大元素是3(而非去重后的4)。

  • 必须满足O(n)时间复杂度:常规的排序(快排、归并等)都是O(n log n),无法满足要求,所以需要用更高效的算法——大根堆(或快速选择),本文重点讲解大根堆解法。

二、核心思路:大根堆的“筛选”逻辑

大根堆的特性是:堆顶元素是整个堆中的最大值。利用这个特性,我们可以通过以下步骤找到第k个最大元素:

  1. 构建大根堆:将整个数组转换成大根堆,此时堆顶是数组的最大值(第1个最大元素)。

  2. 调整堆结构:将堆顶元素与堆的最后一个元素交换,然后缩小堆的范围(排除已经找到的最大值),再对堆顶进行调整,确保剩余元素仍为大根堆。

  3. 重复k-1次:经过k-1次上述交换和调整后,堆顶元素就是第k个最大元素(因为每次交换都能确定一个“当前最大值”,k-1次后,堆顶就是第k大)。

这里补充一个关键:构建大根堆的时间复杂度是O(n),每次调整堆的时间复杂度是O(log n),但我们只需要调整k-1次,当k较小时,整体时间复杂度接近O(n);即使k=n(找最小元素),时间复杂度也是O(n log n)?不对,其实大根堆解法的平均时间复杂度是O(n),完全满足题目要求,这也是题目允许的最优解法之一。

三、代码逐行解析:吃透每一个细节

先贴出你提供的完整代码(TypeScript版本),再逐行拆解,确保每个函数、每一步操作都能看懂:

function findKthLargest(nums: number[], k: number): number {
  let nL = nums.length;
  const swap = (a: number, b: number) => {
    const temp = nums[a];
    nums[a] = nums[b];
    nums[b] = temp;
  }

  const maxHeapify = (i: number, nL: number) => {
    let l = i * 2 + 1, r = i * 2 + 2, largest = i;
    if (l < nL && nums[l] > nums[largest]) {
      largest = l;
    }
    if (r < nL && nums[r] > nums[largest]) {
      largest = r;
    }
    if (largest != i) {
      swap(i, largest);
      maxHeapify(largest, nL);
    }
  }


  for (let i = Math.floor(nL / 2 - 1); i >= 0; --i) {
    maxHeapify(i, nL);
  }

  for (let i = nums.length - 1; i >= nums.length - k + 1; --i) {
    swap(0, i);
    --nL;
    maxHeapify(0, nL);
  }

  return nums[0];
};

1. 变量与辅助函数:swap交换函数

首先定义了nL变量,存储当前堆的长度(初始为数组长度),后续会随着堆的缩小而递减。

swap函数:接收两个索引a和b,交换nums数组中这两个索引对应的元素。这是堆调整中不可或缺的操作,用于交换堆顶和堆尾元素,以及调整堆时交换父节点和子节点。

2. 核心函数:maxHeapify(大根堆调整函数)

这个函数的作用是:给定一个节点索引i,确保以i为根节点的子树是大根堆(即根节点是该子树的最大值)。

  • l = i * 2 + 1:当前节点i的左子节点索引(堆的存储结构是数组,左子节点公式固定)。

  • r = i * 2 + 2:当前节点i的右子节点索引。

  • largest = i:初始化“最大值节点”为当前节点i。

  • 判断左子节点:如果左子节点存在(l < nL),且左子节点值大于当前最大值节点值,更新largest为l。

  • 判断右子节点:同理,判断右子节点是否存在,且值大于当前largest,更新largest为r。

  • 如果largest不等于i(说明当前节点不是子树的最大值):交换当前节点i和largest对应的元素,然后递归调整largest对应的子树(因为交换后,该子树可能不再是大根堆)。

3. 构建大根堆

循环语句:for (let i = Math.floor(nL / 2 - 1); i >= 0; --i) { maxHeapify(i, nL); }

这里的关键是起始索引i的计算:Math.floor(nL / 2 - 1)。因为堆的叶子节点不需要调整(叶子节点没有子节点,本身就是大根堆),而这个索引是最后一个非叶子节点的索引,从这个节点开始,从后往前依次调整每一个非叶子节点,就能构建出整个大根堆。

举个例子:如果数组长度是5,nL/2 -1 = 5/2 -1 = 1.5,向下取整为1,所以从索引1开始调整,依次调整1、0,就能完成大根堆构建。

4. 筛选第k个最大元素

循环语句:for (let i = nums.length - 1; i >= nums.length - k + 1; --i) { ... }

这个循环的目的是执行k-1次“交换+调整”操作,具体步骤:

  • swap(0, i):将堆顶(当前最大值)与当前堆的最后一个元素i交换,此时最大值就被“固定”在数组的末尾(不再参与后续堆调整)。

  • --nL:缩小堆的范围,排除刚才固定的最大值(堆的长度减1)。

  • maxHeapify(0, nL):对新的堆顶(交换后的元素)进行调整,确保剩余元素仍为大根堆。

循环的终止条件是i >= nums.length - k + 1,意味着我们只需要执行k-1次交换(比如k=3,就执行2次交换,固定前2个最大值),此时堆顶元素就是第k个最大元素,直接返回nums[0]即可。

四、关键注意点与避坑指南

  • nL的作用:nL是“当前堆的长度”,不是固定的数组长度。每次交换后,堆的范围缩小,nL递减,确保调整堆时只操作剩余的元素,避免重复处理已经固定的最大值。

  • 递归边界:maxHeapify函数中,l和r必须小于nL,否则会访问数组越界(比如当节点是叶子节点时,l和r会超出堆的范围,此时不进行判断)。

  • 时间复杂度验证:构建堆O(n),k-1次调整,每次调整O(log n),整体时间复杂度是O(n + k log n)。当k为常数时,就是O(n);即使k=n,也是O(n log n),但题目要求“设计并实现时间复杂度为O(n)的算法”,大根堆解法是符合要求的(平均时间复杂度O(n)),另一种更严格O(n)的解法是快速选择,但大根堆解法更易理解和实现。

  • 空间复杂度:O(log n),来自maxHeapify函数的递归调用栈(最坏情况下递归深度为log n);如果用迭代实现maxHeapify,空间复杂度可以优化到O(1)。

五、总结:大根堆解法的优势与适用场景

这道题的大根堆解法,核心是利用堆的特性,快速筛选出前k个最大值,最终定位到第k个。相比快速选择算法,大根堆解法更稳定,不易出现最坏情况(快速选择最坏时间复杂度O(n²)),且代码逻辑清晰,容易上手。

适用场景:当需要找到数组中前k个最大元素,或第k个最大元素时,大根堆是首选解法之一,尤其是在k较小的情况下,效率极高。

为什么我的代码在测试环境跑得好好的,一到用户电脑就崩?原来凶手躲在地址栏旁边

作者 kyriewen
2026年3月28日 21:23

引言

“Bug 无法复现,建议关闭。”

这是我上周在一个 issue 下面看到的回复。发 issue 的用户是个忠实用户,他说我们网站的某个按钮点完后页面就白屏了,但我们在测试环境、预发环境、甚至他的电脑上用无痕模式都试了一遍,愣是没复现。

就在我准备把这个 issue 标记为“无法复现,关闭”的时候,产品经理幽幽地说了一句:“要不你试试装几个浏览器插件?”

我当时心想:插件能影响我们代码?那不至于吧。

结果我装了 AdBlock、装了油猴脚本、装了某个购物比价插件,刷新页面,点击按钮——白屏了

那一刻我恍然大悟:原来我们的代码一直生活在“无菌实验室”里,而用户的浏览器,是一个充满了各种“妖魔鬼怪”的丛林。

今天,我们就来聊聊那些躲在地址栏旁边的“凶手”——浏览器扩展(Extensions),以及它们如何悄悄地破坏你的网页。

一、浏览器扩展:用户的朋友,开发者的噩梦

浏览器扩展(Chrome/Firefox/Edge 插件)本质上是在用户浏览器里运行的第三方代码。它们拥有各种权限:

  • 读取和修改当前页面的 DOM
  • 拦截和修改网络请求
  • 注入自己的 JS 和 CSS
  • 甚至操作本地存储、Cookie

这些权限对用户来说是“增强功能”,但对我们开发者来说,就是一颗不知道什么时候会炸的雷。

1.1 最常见的“作案手法”

手法一:往 DOM 里塞私货

很多广告拦截插件会扫描页面里的广告位,然后移除或隐藏它们。但如果你的代码恰好依赖某个被移除的 DOM 节点,就会报错。

// 你写的代码
const adBanner = document.getElementById('ad-banner');
adBanner.addEventListener('click', trackAdClick); // 如果 adBanner 被插件删了,这里就报错

手法二:修改全局变量

有些插件会往 window 对象上挂东西,比如 window.web3window.ethereum。如果插件代码有 bug,或者覆盖了你自己的变量,就会引发冲突。

手法三:拦截并修改网络请求

某些比价插件会在页面加载时修改 fetch 或 XMLHttpRequest,往请求里加参数、改返回值。如果你的代码对返回数据格式有严格校验,就可能崩。

手法四:注入大量 CSS 导致样式错乱

很多暗黑模式插件会强制给页面添加 filter: invert(1),然后你的精心设计的渐变、阴影、图片全部变成鬼片现场。

二、真实案例:一次被插件坑到怀疑人生的经历

去年有个用户反馈:我们网站的一个下拉菜单点不开。我们团队三台电脑都试了,没问题。后来让用户录屏,发现他的浏览器右上角有一排插件图标,大概七八个。

我让用户把插件一个个关掉试试。关到第三个——广告拦截器——菜单能点了。

后来排查发现,那个广告拦截器有一条规则,把我们的菜单按钮识别成了广告弹窗,给它加上了 display: none !important

解决方案?我们在 CSS 里给菜单按钮加了一个更高优先级的规则,并且改了 HTML 结构,避开了那个插件的检测规则。

从那以后,我养成了一个习惯:在调试“用户反馈但本地无法复现”的 bug 时,先问一句:“你装了哪些插件?”

三、常见的“凶手插件”类型

类型 典型代表 可能引发的问题
广告拦截器 AdBlock, uBlock Origin 移除 DOM 元素、阻止网络请求
安全/隐私插件 Privacy Badger, Ghostery 屏蔽第三方脚本、修改 Cookie
密码管理器 LastPass, 1Password 在密码框注入额外 UI,可能破坏表单提交逻辑
翻译插件 谷歌翻译、沙拉查词 修改 DOM 文本,可能破坏依赖文本内容的前端逻辑
暗黑模式插件 Dark Reader 注入全局 CSS,可能导致样式错乱
比价/购物助手 各种返利插件 修改商品价格、添加浮动按钮,可能遮挡你的 UI
油猴脚本 Tampermonkey 用户自定义脚本,什么都能干,什么都能坏

四、如何检测和防范“插件污染”?

4.1 开发阶段:用插件测试自己

在开发时,建议装几个常见的“破坏性”插件,时不时开着它们测试一下自己的页面。你会发现很多之前没想过的问题。

4.2 代码层面:防御性编程

  • 操作 DOM 前检查元素是否存在

    const el = document.getElementById('some-id');
    if (el) {
      el.addEventListener(...);
    }
    
  • 使用 !important 时要谨慎:插件经常用 !important 覆盖样式,如果你的样式也用 !important,可能会变成“谁的 !important 更厉害”的军备竞赛。

  • 避免依赖全局变量:如果一定要用,先检查是否存在冲突:

    if (typeof window.myGlobal !== 'undefined' && !window.myGlobal.__MY_APP__) {
      console.warn('全局变量 myGlobal 被第三方插件覆盖');
    }
    

4.3 异常捕获与上报

在代码里加上 try-catch,并上报错误信息。当用户反馈 bug 时,可以从错误日志里看出蛛丝马迹:

window.addEventListener('error', (event) => {
  // 上报错误,附带上用户安装了哪些插件(如果能检测到的话)
  reportError({
    message: event.message,
    filename: event.filename,
    // 可以尝试读取用户安装的插件,虽然不能完全读取,但部分插件会在 DOM 上留下痕迹
    extensions: detectExtensions()
  });
});

4.4 教用户“排除法”

当用户反馈 bug 时,可以提供一个标准操作:

  1. 打开无痕模式(默认禁用大部分插件)。
  2. 如果无痕模式正常,说明是插件的问题。
  3. 一个一个关掉插件,找出罪魁祸首。

这比你在本地猜来猜去要高效得多。

五、检测用户装了哪些插件(有限但有用)

虽然你不能直接读取用户安装的所有插件(隐私原因),但你可以通过一些“痕迹”来推测:

function detectExtensions() {
  const detected = [];
  
  // AdBlock 检测
  if (document.querySelector('.adblock-warning') || 
      typeof window.adblockDetector !== 'undefined') {
    detected.push('AdBlock (可能)');
  }
  
  // 暗黑模式检测
  if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
    // 这不一定是插件,也可能是系统设置
    detected.push('暗黑模式');
  }
  
  // 某些插件会在 body 上加特定 class
  if (document.body.classList.contains('darkreader')) {
    detected.push('Dark Reader');
  }
  
  return detected;
}

六、总结:拥抱不确定性

浏览器插件是用户自主安装的,我们无法禁止,也不应该禁止。但我们可以通过防御性编程 + 异常监控 + 用户沟通,让页面在面对这些“不速之客”时更加健壮。

下次当你遇到“测试环境正常,用户环境报错”的 bug 时,别急着怀疑自己的代码,先看看用户的地址栏旁边——可能有个小小的图标,正在悄悄给你的页面使绊子。


每日一问:你遇到过最离谱的“插件导致 bug”是什么?是广告拦截器把你的登录按钮给拦了?还是翻译插件把你的代码注释翻译成了英文导致报错?评论区分享你的“受害者”经历!

面试官:说说事件冒泡与委托?这是我见过最透彻的回答

作者 ETA8
2026年3月28日 21:20

在JavaScript的世界里,事件监听是我们与用户交互的基础。但你是否遇到过这样的困惑:为什么点击了子元素,父元素的点击事件也跟着触发了?或者,当列表里有1000个按钮时,如何优雅地处理点击而不让页面卡死?

今天,我们就从你提供的两段代码出发,深入剖析事件流、事件委托、stopPropagation,最后带你看看React是如何利用这些原理“秀操作”的。


一、事件的“旅行”:事件流与冒泡

首先,我们要建立一个核心概念:事件不仅仅是“发生”在某个元素上,它是一场“旅行”。

当一个点击事件发生时,浏览器内部会经历三个阶段,这就是事件流

  1. 捕获阶段:事件从document根节点出发,像水流一样层层向下渗透,直到目标元素。
  2. 目标阶段:事件到达了实际被点击的元素(event.target)。
  3. 冒泡阶段:事件从目标元素出发,反向冒泡,一层层向上传播回document

看个例子(基于你的2.html):

想象一个红色的盒子(parent)里装着一个蓝色的盒子(child)。

document.getElementById('parent').addEventListener('click', function() {
  console.log('parent click');
}, false) // 默认false,代表在冒泡阶段执行

document.getElementById('child').addEventListener('click', function() {
  console.log('child click');
}, false)

当你点击蓝色的child时,控制台会依次输出:child click -> parent click

这就是事件冒泡。事件首先在child上触发,然后“冒泡”到父级parent,甚至继续冒泡到body(你的代码里body上还有个alert('橘子'),所以最后还会弹窗)。

为什么要了解这个? 因为绝大多数时候,我们利用的就是这个“冒泡”机制。


二、性能救星:事件委托

回到你的1.html,假设你有一个包含100个<li>的列表。

❌ 传统做法(笨重):

const lis = document.querySelectorAll('#list li');
for (let i = 0; i < lis.length; i++) {
  lis[i].addEventListener('click', function() { ... })
}

这种做法的问题在于内存开销。100个监听器就是100份内存消耗。如果列表是动态生成的,你还得不断地去绑定新元素的事件,非常麻烦。

✅ 事件委托(优雅):
利用冒泡原理,我们只需要在父元素<ul>上绑定一个监听器,就能管理所有子元素!

document.getElementById('list').addEventListener('click', function(event) {
  // event.target 指向实际被点击的那个 li
  console.log(event.target, event.target.innerHTML);
});

这就像什么?
就像小区的门卫。你不需要给每家每户(li)都配一个保安,只需要在小区大门口(ul)安排一个保安。谁进来了(事件冒泡上来了),保安看一眼event.target(身份证),就知道是谁。

这样做的好处:

  1. 减少内存消耗:不管有多少个li,只需要一个监听器。
  2. 自动支持动态元素:如果你后来用JS往列表里加了一个新的<li>,它不需要重新绑定事件,点击它依然会冒泡到ul被处理。

三、掌控雷电:stopPropagation

有时候,我们不希望事件冒泡。比如在做一个模态框,点击遮罩层关闭,但点击内容区不想关闭。

这时就需要用到e.stopPropagation()

document.getElementById('child').addEventListener('click', function(event) {
  event.stopPropagation(); // 关键代码:在这里“截断”事件
  console.log('child click');
}, false)

加上这行代码后,点击child,事件处理完就结束了,不会继续向上传递给parent,也就不会触发parent的点击事件,更不会出现body上的alert('橘子')

注意: 还有一种情况是useCapture(捕获)。addEventListener的第三个参数默认为false(冒泡)。如果设为true,事件就会在捕获阶段(从上往下)被触发。这在某些特殊场景(如想要最早拦截事件)非常有用。


四、最佳实践:就近原则

在使用事件委托时,有一个“就近原则”。

虽然我们可以把事件委托给document(在根节点监听所有点击),但不建议这么做。

为什么?
如果委托给document,每次点击页面任何地方,事件都要冒泡到最顶层,浏览器需要遍历的路径最长,增加了判断成本。

建议:
委托给距离目标元素最近的父级。比如在ul上代理li,而不是在document上代理li。这样既享受了委托的性能红利,又控制了事件传播的范围。


五、进阶引申:React的合成事件

如果你学过React,你会发现React的事件系统正是基于这些原理构建的。

React并没有给每个DOM节点绑定原生的addEventListener。相反,React实现了一套**合成事件(SyntheticEvent)**系统。

它的核心原理就是:

  1. 全局委托:React 17及以后,将所有事件统一委托到了挂载容器的根节点(React 16及以前是document)。
  2. 统一分发:当原生事件冒泡到根节点时,React会捕获它,然后根据组件树的结构,手动分发给对应的组件事件处理函数。

这样做的好处:

  • 性能极致:无论你的应用有多少个按钮,原生监听器只有一个。
  • 跨浏览器兼容:React抹平了不同浏览器(如Chrome和Firefox)对事件对象实现的差异,让你在任何浏览器拿到的e对象都是一样的。

总结

  • 事件流:捕获 -> 目标 -> 冒泡。理解它是理解一切的基础。
  • 事件委托:利用冒泡,将监听器绑定在父元素上,通过event.target识别目标。省内存、支持动态DOM。
  • stopPropagation:阻止事件继续冒泡,防止父级元素“误触”。
  • React启示:现代框架的高性能,往往就建立在这些基础原理的巧妙运用之上。

下次再写列表循环绑定时,记得停下来想一想:能不能用事件委托优化一下?

纯 Canvas 打造 Siri 同款边缘流动波浪光晕

作者 迷空
2026年3月28日 19:32

前言

最近在逛苹果官网时被 Siri 的波浪动画深深吸引,决定用纯 Canvas 实现一个类似的效果。经过多次迭代,终于完成了现在这个「边缘流动波浪光晕」效果,今天来分享一下实现思路。

效果展示

  • 粒子沿屏幕四边循环流动
  • 波浪效果通过粒子大小变化呈现
  • 多层发光营造柔和光晕
  • 渐变色沿路径流动
  • 支持鼠标交互和点击特效

核心原理

1. 路径设计

function getPosition(progress) {
    const w = canvas.width;
    const h = canvas.height;
    const pathLength = 2 * (w + h);  // 总
    路径长度
    
    const distance = progress * 
    pathLength;
    const top = w;
    const right = top + h;
    const bottom = right + w;
    
    // 根据距离判断当前在哪条边上
    if (distance < top) {
        return { x: distance, y: 0, edge: 
        0 };      // 顶边
    } else if (distance < right) {
        return { x: w, y: distance - top, 
        edge: 1 }; // 右边
    } else if (distance < bottom) {
        return { x: w - (distance - 
        right), y: h, edge: 2 }; // 底边
    } else {
        return { x: 0, y: h - (distance - 
        bottom), edge: 3 }; // 左边
    }
}

2. 波浪效果

波浪效果的核心是通过正弦函数控制粒子大小:

function getWaveRadius(progress, 
baseRadius) {
    const wave = Math.sin(
        progress * Math.PI * 2 * 
        waveFrequency - time * waveSpeed
    );
    // 将 -1~1 映射到 0~1
    const normalizedWave = (wave + 1) / 2;
    return baseRadius + normalizedWave * 
    (maxRadius - baseRadius);
}

3. 多层发光

为了营造柔和的光晕效果,我们使用多层径向渐变叠加:

function drawGlow(x, y, radius, 
colorProgress) {
    const layers = 5;
    
    for (let i = layers; i >= 0; i--) {
        const layerRadius = radius * (1 + 
        i * 1.2);
        const alpha = 0.2 * (1 - i / 
        (layers + 1));
        
        const gradient = ctx.
        createRadialGradient(x, y, 0, x, 
        y, layerRadius);
        gradient.addColorStop(0, getColor
        (colorProgress, alpha * 2));
        gradient.addColorStop(0.5, 
        getColor(colorProgress, alpha));
        gradient.addColorStop(1, getColor
        (colorProgress, 0));
        
        ctx.beginPath();
        ctx.arc(x, y, layerRadius, 0, 
        Math.PI * 2);
        ctx.fillStyle = gradient;
        ctx.fill();
    }
}

4. 渐变色方案

颜色沿着路径流动,这里使用了 6 个颜色节点的渐变:

const colorStops = [
    { r: 0, g: 220, b: 160 },    // 青色
    { r: 255, g: 180, b: 50 },   // 橙色
    { r: 255, g: 100, b: 80 },   // 珊瑚红
    { r: 80, g: 200, b: 255 },   // 天蓝
    { r: 160, g: 255, b: 100 },  // 嫩绿
    { r: 0, g: 220, b: 160 }     // 回到青
    色
];

5. 波浪连线

为了让波浪更具整体感,相邻粒子之间用贝塞尔曲线连接:

function drawWaveLine() {
    const edges = [[], [], [], []];
    
    particles.forEach(p => {
        const pos = getPosition(p.
        progress);
        const radius = getWaveRadius(p.
        progress, p.baseRadius);
        edges[pos.edge].push({ x: pos.x, 
        y: pos.y, radius, progress: p.
        progress });
    });
    
    edges.forEach((edge, edgeIndex) => {
        ctx.beginPath();
        for (let i = 0; i < edge.length; i
        ++) {
            const p = edge[i];
            const next = edge[(i + 1) % 
            edge.length];
            const offset = getEdgeOffset
            (edgeIndex, p.radius * 0.8);
            const nextOffset = 
            getEdgeOffset(edgeIndex, next.
            radius * 0.8);
            
            if (i === 0) {
                ctx.moveTo(p.x + offset.
                x, p.y + offset.y);
            }
            
            // 贝塞尔曲线连接
            const cpX = (p.x + next.x) / 
            2 + (offset.x + nextOffset.
            x) / 2;
            const cpY = (p.y + next.y) / 
            2 + (offset.y + nextOffset.
            y) / 2;
            ctx.quadraticCurveTo(cpX, 
            cpY, next.x + nextOffset.x, 
            next.y + nextOffset.y);
        }
        ctx.stroke();
    });
}

6. 鼠标交互

点击时产生额外的波峰效果:

canvas.addEventListener('click', function
(e) {
    const clickProgress = 
    getProgressFromPosition(e.clientX, e.
    clientY);
    
    particles.forEach(p => {
        const diff = Math.abs(p.progress 
        - clickProgress);
        const nearestDiff = Math.min
        (diff, 1 - diff);
        
        if (nearestDiff < 0.1) {
            // 增加附近粒子的大小
            p.baseRadius = baseRadius + 
            15 * (1 - nearestDiff / 0.1);
        }
    });
});

性能优化

  1. 粒子数量控制 :100 个粒子足够流畅
  2. 模糊叠加代替滤镜 :多层半透明叠加比 CSS filter 性能更好
  3. 增量渲染 :使用半透明 fillRect 产生拖尾效果,而非每帧重绘

配置参数

const config = {
    particleCount: 100,    // 粒子数量
    baseRadius: 5,          // 基础半径
    maxRadius: 10,          // 最大半径(波
    峰)
    flowSpeed: 0.0006,      // 流动速度
    waveSpeed: 1.8,         // 波浪速度
    waveFrequency: 4,       // 波浪频率
    glowLayers: 5           // 发光层数
};

完整代码

完整源码已放在文章开头,复制即可运行。

写在最后

这个效果看似复杂,但拆解后无非是:

  • 路径设计
  • 粒子系统
  • 渐变着色
  • 贝塞尔曲线 掌握这几个核心概念,你也能做出炫酷的动画效果。如果有任何问题,欢迎在评论区交流!

构建无障碍组件之Tabs Pattern

作者 anOnion
2026年3月28日 22:31

标签页(Tabs)是一种分层的内容展示组件,通过标签列表(Tab List)和对应的内容面板(Tab Panel)来组织和展示内容。本文基于 W3C WAI-ARIA Tabs Pattern 规范,详解如何构建无障碍的标签页组件。

一、Tabs 的定义与核心概念

1.1 什么是 Tabs

Tabs 是一种将内容分层展示的界面模式:

  • Tab List(标签列表):包含一组标签元素的容器
  • Tab(标签):作为对应内容面板的标签,激活后显示该面板
  • Tab Panel(标签面板):包含与标签关联的内容的元素
  • 通常一次只显示一个标签面板
  • 标签列表通常排列在当前显示面板的边缘(最常见的是顶部)

1.2 核心术语

术语 说明
Tab List 包含在 tablist 元素中的一组标签元素
Tab 标签列表中的元素,作为对应面板的标签
Tab Panel 包含与标签关联内容的元素
┌─────────────────────────────────────────┐
│  Tab List (role="tablist")              │
│  ┌─────────┬─────────┬─────────┐        │
│  │ Tab 1   │ Tab 2   │ Tab 3   │        │
│  │(active) │         │         │        │
│  └────┬────┴─────────┴─────────┘        │
│       │                                 │
│       ▼                                 │
│  ┌─────────────────────────────────┐    │
│  │  Tab Panel 1 (role="tabpanel")  │    │
│  │  ┌─────────────────────────┐    │    │
│  │  │                         │    │    │
│  │  │      Content Panel 1    │    │    │
│  │  │      (currently shown)  │    │    │
│  │  │                         │    │    │
│  │  └─────────────────────────┘    │    │
│  └─────────────────────────────────┘    │
│                                         │
│  ┌─────────────────────────────────┐    │
│  │  Tab Panel 2 (role="tabpanel")  │    │
│  │  ┌─────────────────────────┐    │    │
│  │  │      Content Panel 2    │    │    │
│  │  │      (hidden)           │    │    │
│  │  └─────────────────────────┘    │    │
│  └─────────────────────────────────┘    │
└─────────────────────────────────────────┘

1.3 自动激活 vs 手动激活

Tabs 有两种激活模式:

自动激活(Automatic Activation)

  • 标签获得焦点时自动激活并显示对应面板
  • 要求面板内容已预加载,避免延迟
  • 用户导航更高效

手动激活(Manual Activation)

  • 用户需要按 Space 或 Enter 激活标签
  • 适用于面板内容加载有延迟的场景
  • 避免焦点移动时的不必要加载

二、WAI-ARIA 角色与属性

2.1 基本角色

<div
  role="tablist"
  aria-label="产品信息">
  <button
    role="tab"
    aria-selected="true"
    id="tab-1">
    产品详情
  </button>
  <button
    role="tab"
    aria-selected="false"
    id="tab-2">
    规格参数
  </button>
  <button
    role="tab"
    aria-selected="false"
    id="tab-3">
    用户评价
  </button>
</div>

<div
  role="tabpanel"
  aria-labelledby="tab-1">
  <!-- 产品详情内容 -->
</div>

2.2 Tab List 属性

Tab List 容器具有 role="tablist"

<!-- 水平标签页(默认) -->
<div
  role="tablist"
  aria-label="产品信息">
  ...
</div>

<!-- 垂直标签页 -->
<div
  role="tablist"
  aria-label="设置选项"
  aria-orientation="vertical">
  ...
</div>

2.3 Tab 属性

每个标签具有 role="tab"

<button
  role="tab"
  id="tab-1"
  aria-selected="true"
  aria-controls="panel-1"
  tabindex="0">
  产品详情
</button>

<button
  role="tab"
  id="tab-2"
  aria-selected="false"
  aria-controls="panel-2"
  tabindex="-1">
  规格参数
</button>

2.4 Tab Panel 属性

每个面板具有 role="tabpanel"

  • aria-labelledby:引用对应标签的 ID
  • 未激活的面板通常使用 hidden 属性或 CSS 隐藏
<div
  role="tabpanel"
  id="panel-1"
  aria-labelledby="tab-1">
  <!-- 激活的面板内容 -->
</div>

<div
  role="tabpanel"
  id="panel-2"
  aria-labelledby="tab-2"
  hidden>
  <!-- 未激活的面板内容 -->
</div>

三、键盘交互规范

3.1 Tab 键导航

场景 行为
焦点进入 Tab List 焦点置于当前激活的标签上
焦点在 Tab List 中 焦点移动到 Tab List 外的下一个元素(通常是 Tab Panel 或其内部第一个可聚焦元素)

3.2 方向键导航(水平标签页)

按键 功能
左箭头 焦点移到上一个标签;如果在第一个标签,移到最后一个
右箭头 焦点移到下一个标签;如果在最后一个标签,移到第一个
Home(可选) 焦点移到第一个标签
End(可选) 焦点移到最后一个标签

3.3 方向键导航(垂直标签页)

按键 功能
上箭头 等同于水平标签页的左箭头
下箭头 等同于水平标签页的右箭头

3.4 激活操作

按键 功能
Space / Enter 激活当前聚焦的标签(手动激活模式下)
Shift + F10(Windows)
Control + 点击(Mac)
如果标签有关联的弹出菜单,打开菜单
Delete(可选) 如果允许删除,删除当前标签及其面板

3.5 自动激活说明

  • 推荐在面板内容已预加载时使用自动激活
  • 自动激活时,方向键移动焦点会立即激活对应标签
  • 如果面板加载有延迟,使用手动激活避免阻碍导航

四、实现方式与样式要点

4.1 基础 HTML 结构

<div class="tabs">
  <!-- Tab List -->
  <div
    role="tablist"
    aria-label="产品信息">
    <button
      role="tab"
      id="tab-1"
      aria-selected="true"
      aria-controls="panel-1"
      tabindex="0">
      产品详情
    </button>
    <button
      role="tab"
      id="tab-2"
      aria-selected="false"
      aria-controls="panel-2"
      tabindex="-1">
      规格参数
    </button>
    <button
      role="tab"
      id="tab-3"
      aria-selected="false"
      aria-controls="panel-3"
      tabindex="-1">
      用户评价
    </button>
  </div>

  <!-- Tab Panels -->
  <div
    role="tabpanel"
    id="panel-1"
    aria-labelledby="tab-1">
    <h2>产品详情</h2>
    <p>这是一款高性能的...</p>
  </div>

  <div
    role="tabpanel"
    id="panel-2"
    aria-labelledby="tab-2"
    hidden>
    <h2>规格参数</h2>
    <table>
      <tr>
        <th>尺寸</th>
        <td>100 x 50 x 20 mm</td>
      </tr>
      <tr>
        <th>重量</th>
        <td>200g</td>
      </tr>
    </table>
  </div>

  <div
    role="tabpanel"
    id="panel-3"
    aria-labelledby="tab-3"
    hidden>
    <h2>用户评价</h2>
    <p>"非常满意这款产品..."</p>
  </div>
</div>

4.2 样式实现注意事项

4.2.1 激活状态样式

激活的标签需要有明显的视觉区分:

  • 下划线/边框:使用边框颜色变化指示激活状态
  • 背景色:激活标签使用不同的背景色
  • 文字样式:加粗或改变颜色增强对比

4.2.2 焦点状态样式

确保键盘用户可以清楚看到当前焦点位置:

  • 使用 outlinebox-shadow 创建焦点环
  • 焦点环颜色与背景有足够对比度
  • 避免使用 outline: none 而不提供替代样式

4.2.3 面板显示/隐藏

  • 未激活的面板应使用 hidden 属性或 display: none 完全隐藏
  • 避免使用 visibility: hiddenopacity: 0,这会让内容仍可被屏幕阅读器访问

4.2.4 垂直标签页样式

垂直布局时需要注意:

  • 标签列表使用 flex-direction: column
  • 激活指示器从底部边框改为右侧边框
  • 确保足够的点击区域(最小 44x44px)

4.2.5 响应式设计

移动端适配建议:

  • 小屏幕下标签可以换行或使用水平滚动
  • 考虑将水平标签页切换为垂直布局
  • 调整标签内边距和字体大小

五、常见应用场景

5.1 产品详情页

<div
  role="tablist"
  aria-label="产品信息">
  <button
    role="tab"
    aria-selected="true"
    aria-controls="panel-overview">
    概览
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="panel-features">
    功能
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="panel-reviews">
    评价
  </button>
</div>

5.2 设置面板

<div
  role="tablist"
  aria-label="设置选项"
  aria-orientation="vertical">
  <button
    role="tab"
    aria-selected="true"
    aria-controls="panel-account">
    账户
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="panel-privacy">
    隐私
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="panel-notifications">
    通知
  </button>
</div>

5.3 代码示例展示

<div
  role="tablist"
  aria-label="代码示例">
  <button
    role="tab"
    aria-selected="true"
    aria-controls="panel-html">
    HTML
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="panel-css">
    CSS
  </button>
  <button
    role="tab"
    aria-selected="false"
    aria-controls="panel-js">
    JavaScript
  </button>
</div>

六、最佳实践

6.1 选择合适的激活模式

  • 自动激活:面板内容已预加载,无明显延迟
  • 手动激活:面板内容需要异步加载,或加载时间较长

6.2 确保键盘可访问

  • 所有标签都必须可以通过键盘聚焦
  • 方向键在标签之间循环导航
  • Tab 键从标签列表移动到面板内容

6.3 提供清晰的视觉反馈

  • 激活的标签使用不同的样式(颜色、边框)
  • 焦点状态清晰可见
  • 未激活的面板完全隐藏

6.4 避免嵌套 Tabs

不要在 Tab Panel 内部嵌套另一个 Tabs,这会造成:

  • 键盘导航复杂且容易迷失
  • 屏幕阅读器用户难以理解层级关系
  • 视觉上的混乱

6.5 处理大量标签

如果标签数量过多:

  • 考虑使用垂直方向节省水平空间
  • 或者重新组织内容结构
  • 避免标签需要水平滚动

6.6 移动端适配

@media (max-width: 640px) {
  [role='tablist'] {
    flex-wrap: wrap;
  }

  [role='tab'] {
    flex: 1;
    min-width: 80px;
    padding: 10px;
    font-size: 14px;
  }
}

七、Tabs 与其他组件的选择

7.1 Tabs 与 Accordion 的选择

场景 Tabs Accordion
需要同时对比多个内容 ❌ 一次只能看一个 ✅ 可同时展开多个
水平空间有限 ❌ 需要水平排列标签 ✅ 垂直展开更省空间
内容有明确顺序 ✅ 标签顺序暗示优先级 ❌ 各面板相对独立
需要频繁切换内容 ✅ 点击标签即可切换 ❌ 需要展开/折叠操作
移动端优先 ❌ 水平空间受限 ✅ 垂直空间更充裕
键盘导航效率 ✅ 方向键快速循环 ⚠️ Tab 键逐个遍历
内容关联性 ✅ 同一主题不同方面 ✅ 相对独立的内容块
典型应用场景 产品详情页、设置面板 FAQ、内容列表、过滤器

选择建议:

  • 用 Tabs:内容关联紧密、空间充足、需要快速切换
  • 用 Accordion:需要对比多个内容、移动端、垂直空间充裕

7.2 Tabs 与 Menu 的选择

场景 Tabs Menu
页面内内容切换 ✅ 显示对应面板,不跳转 ❌ 预期会跳转页面
导航到不同页面 ❌ 不适合页面导航 ✅ 专为导航设计
执行操作(如下载) ❌ 不适用于操作 ✅ 可触发各种操作
内容关联性 ✅ 同一上下文相关内容 ❌ 通常相互独立
视觉反馈 ✅ 始终显示激活状态 ✅ 显示当前页面
键盘导航 ✅ 方向键循环,Tab 离开 ✅ 方向键导航,Enter 激活
ARIA 角色 tablist + tab + tabpanel menubar + menuitemnavigation + link
典型应用场景 产品详情、代码示例展示 主导航、用户操作菜单

选择建议:

  • 用 Tabs:同一页面内切换相关内容
  • 用 Menu:页面导航或执行操作

常见误区:

  • ❌ 使用 Tabs 作为主导航菜单(应使用 <nav> + <a>
  • ❌ 使用 Menu 展示同一页面内容(应使用 Tabs)
  • ❌ Tabs 标签使用 <a> 链接(应使用 <button>
  • ❌ 在移动端使用过多 Tabs(应考虑 Accordion)

八、总结

构建无障碍的 Tabs 组件需要关注:

  1. 正确的 ARIA 角色tablisttabtabpanel
  2. 完整的状态管理aria-selectedaria-controlstabindex
  3. 键盘导航支持:方向键循环、Home/End 快捷键
  4. 合适的激活模式:根据内容加载情况选择自动或手动激活
  5. 清晰的视觉反馈:激活状态、焦点状态明确可辨

遵循 W3C Tabs Pattern 规范,我们能够创建既美观又包容的标签页组件,为所有用户提供一致的体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

昨天 — 2026年3月28日技术

fsck Cheatsheet

Basic Usage

Common fsck command forms.

Command Description
fsck /dev/sdb1 Check a filesystem
fsck -n /dev/sdb1 Check only, do not write changes
fsck -f /dev/sdb1 Force a check even if the filesystem looks clean
fsck -t ext4 /dev/sdb1 Check only filesystems of the given type
fsck -A Check filesystems listed in /etc/fstab

Safe Repair Workflow

Use this sequence before making repairs.

Command Description
lsblk -f Identify the device, mount point, and filesystem type
mount | grep /dev/sdb1 Confirm whether the device is mounted
sudo umount /dev/sdb1 Unmount the filesystem before repair
sudo fsck -n /dev/sdb1 Run a read-only check first
sudo fsck -p /dev/sdb1 Auto-fix safe problems without prompts

Repair Modes

Choose how interactive the repair should be.

Option Description
-n Do not make changes, useful for a safe first pass
-p Automatically repair safe problems
-y Answer yes to all prompts
-r Prompt before each repair
-f Force checking even if the filesystem appears clean

Filesystem Selection

Target one filesystem, one type, or all entries in fstab.

Command Description
sudo fsck /dev/nvme0n1p2 Check one device directly
sudo fsck -t ext4 /dev/nvme0n1p2 Check one device as ext4
sudo fsck -A Check all eligible filesystems in /etc/fstab
sudo fsck -AR Check all except the root filesystem
sudo fsck.ext4 /dev/nvme0n1p2 Run the filesystem-specific checker directly

Root Filesystem Recovery

fsck cannot repair the mounted root filesystem on a running system.

Method Description
Recovery mode Boot into recovery and choose the filesystem check option
Live USB Boot a live system, identify the root partition, then run fsck there
fsck.mode=force Kernel parameter to force a check during boot on systemd systems
fsck.repair=yes Kernel parameter to approve repairs during boot
sudo touch /forcefsck Older non-systemd pattern to force a boot-time check

tune2fs Scheduling

Control when ext filesystems are checked automatically.

Command Description
sudo tune2fs -l /dev/sdb1 | grep -i 'last checked|mount count' Show last check time and mount counters
sudo tune2fs -c 25 /dev/sdb1 Run a check after every 25 mounts
sudo tune2fs -c -1 /dev/sdb1 Disable mount-count-based checks
sudo tune2fs -i 1m /dev/sdb1 Run a check at most once per month
sudo tune2fs -i 0 /dev/sdb1 Disable time-based checks

fstab Pass Values

The sixth /etc/fstab column controls boot-time check order.

Value Description
0 Do not check this filesystem at boot
1 Check first, usually the root filesystem
2 Check after root, for other local filesystems

Example: /dev/sda2 /home ext4 defaults 0 2

Exit Codes

Use exit codes to understand what fsck found.

Code Description
0 No errors
1 Filesystem errors corrected
2 System should be rebooted
4 Filesystem errors left uncorrected
8 Operational error
16 Usage or syntax error
32 Checking canceled by user
128 Shared-library error

Other Filesystems

Some filesystems use tools other than fsck.

Filesystem Tool
XFS xfs_repair
Btrfs btrfs check or btrfs scrub
NTFS ntfsfix
FAT/VFAT fsck.vfat
Ext2/3/4 fsck.ext2, fsck.ext3, fsck.ext4

Related Guides

Use these articles for the full workflow around filesystem repair.

Guide Description
Fsck Command in Linux (Repair Filesystem) Full fsck guide with examples
How to Check Disk Space in Linux Using the df Command Check mounted filesystems and free space
How to Mount and Unmount File Systems in Linux Unmount a filesystem before repair
Sudo Command in Linux: Run Commands as Root Run fsck with the required privileges

为什么很多复杂跳转,最后都得先回首页?

2026年3月28日 18:15

首页真正开始变重,往往不是 tab 能不能动态,而是它一边要处理远端配置,一边要处理登录态差异和多入口跳转。用户从不同地方进来,默认先到哪一页、旧状态要不要保、资源位和不同内容往哪儿走,都会一起变。走到这一步,首页处理的就不只是页面显示,而是整套跳转和页面安排。

我这次重新看首页相关代码,最先让我停住的,不是动态 tab,也不是某个配置字段,而是一段跳转逻辑。

很多入口并不是直接去目标页,而是要先退回首页,再继续往下走:

if (currentRoute != AppRoutes.initial && currentRoute != AppRoutes.splash) {
  Get.until((route) => route.settings.name == AppRoutes.initial);
}
Get.toNamed(AppRoutes.gameDetail, arguments: args);

如果首页真的只是一个普通页面,这种写法其实很奇怪。

你明明可以直接跳去详情页,为什么还要先回首页?

只有一种解释说得通:

首页在这个项目里,早就不只是首页了。

它已经成了很多复杂跳转最后都要借一下力的固定落点。

顺着这段逻辑再往下看,后面很多原本零散的问题就会重新连起来:

  • 为什么同样是首页,从不同地方进来体验不一样
  • 为什么某些 tab 一改,旧状态就开始乱
  • 为什么资源位、Deep Link、小程序和首页会互相牵扯
  • 为什么启动阶段很多初始化最后也会绕回首页这套逻辑里

所以这篇文章真正想讨论的,不是“首页怎么做成配置化”,而是首页什么时候开始从一个页面,慢慢变成一个要负责承接跳转、安排入口、重新组织内容的总入口。

1. 首页一旦成了很多跳转共同的落脚点,角色就已经变了

项目继续长一段时间以后,首页很容易悄悄长出一种新角色:

  • 不是用户点一下就进来的首页
  • 而是别的流程收不住时,要先退回来的那个位置

这件事比“tab 能不能动态”更值得注意。

因为它说明系统已经默认把首页当成:

  • 一个稳定基点
  • 一个统一入口
  • 一个可以继续展开下一步跳转的地方

这类角色一旦成立,首页真正开始关心的就不再只是页面怎么画、tab 怎么排,而会变成:

  • 哪些跳转应该先回首页
  • 哪些目标页可以直接进
  • 哪些入口需要先做登录校验
  • 哪些内容得先回到首页这一层再继续往下分

也就是说,首页最先变复杂的地方,往往不是页面显示,而是很多流程最后都要借它走一遍。

2. 真正把首页拖复杂的,不是配置字段本身,而是不同地方进来后要不要当成同一个首页处理

首页一旦开始接很多不同入口,页面本身就会变得没那么“固定”。

项目里 fromTaskPage 这种开关很小,但代表性特别强。它说明同样都是回首页,系统还得继续区分:

  • 你是正常点进来的
  • 还是从任务页跳回来的
  • 是从资源位带进来的
  • 还是从 Deep Link 直达后又退回来的
  • 甚至是不是从 WebView、小程序退出以后再回到首页这套逻辑里

一旦这些路径同时存在,“首页默认长什么样”这个问题就已经不够用了。

更接近真实的问题反而是:

  • 这次默认应该落在哪一页
  • 之前的状态还要不要保
  • 这次回来的用户,应该看到原来的首页,还是改过组织后的首页

所以首页复杂度真正开始长出来,不是因为页面本身变花了,而是因为同样叫首页,从不同地方进来的其实已经不是同一种体验。

3. 配置化后面最重的,不是 tab 显示,而是老页面、旧状态和默认先到哪一页怎么一起调整

不少人会先把首页配置化理解成:

  • tab 从写死改成接口返回
  • 某些频道能开关
  • 用户 A 和用户 B 看到的顺序不一样

这些当然都算配置化,但还不是最重的地方。

真正把事情变复杂的,是首页调整以后,老页面和旧状态还在不在。

DynamicTabController 这段逻辑就很能说明问题:

final Map<String, Widget> existingViews = {};
final oldTabs = _previousTabs.toList();

for (int i = 0; i < oldTabs.length && i < tabViews.length; i++) {
  existingViews[_normalizeTabName(oldTabs[i])] = tabViews[i];
}

for (String tab in tabs) {
  final normalizedTab = _normalizeTabName(tab);

  if (existingViews.containsKey(normalizedTab)) {
    tabViews.add(existingViews[normalizedTab]!);
  } else {
    ...
  }
}

这段代码真正在回答的,不是“tab 对不对”,而是:

  • 老页面要不要继续沿用
  • 老状态要不要保
  • 页面切回来时该不该刷新

DynamicPageConfig 这一层又把另一个问题抬了出来。

它已经不只是在说“显示不显示”,而是在决定:

  • 谁能看到什么
  • 某个页面什么时候出现
  • 默认先落在哪个 tab
  • 从不同入口回来时,首页要不要换一种页面安排

比如这段默认配置:

static const List<TabConfig> defaultTabs = [
  TabConfig(
    name: '论坛',
    key: 'forum',
    requireAuth: false,
    requireConfig: false,
    order: 0,
  ),
  TabConfig(
    name: '关注',
    key: 'follow',
    requireAuth: true,
    requireConfig: false,
    order: 1,
  ),
  ...
];

static const String defaultSelectedTab = 'discover';
static bool fromTaskPage = false;

这些字段一旦组合在一起,首页真正要处理的就不再只是“显示哪些 tab”,而是:

  • 入口筛选
  • 默认先到哪一页
  • 登录态差异
  • 配置差异
  • 老状态怎么保留下来

所以首页配置化真正变重的地方,常常不是 UI,而是页面重新调整以后,旧东西和新入口怎么一起处理。

4. 当资源位、H5、小程序都接进来以后,首页已经在替整套页面关系决定去向了

首页会继续变重,还有一个经常被低估的原因:

它后面接的内容类型越来越杂了。

项目里这几层一接进来,首页就已经很难只被当成一个普通页面看了:

  • 资源位服务会主动预加载
  • 小程序控制器会在启动阶段预热默认小程序
  • 本地 LocalServer 会被拉起来组织小程序内容
  • 远端配置 game_suport_show 还会影响小程序页要不要显示

这说明首页最后要处理的,已经不只是几个原生模块,而是一整套内容能力:

  • 原生页
  • H5 页面
  • 小程序页
  • 资源位驱动内容

这时候,很多看起来像“首页问题”的事情,最后都会被拉成另一类问题:

  • 内容该落在哪一页
  • 不同内容进来以后首页怎么接
  • 跳转规则怎么统一
  • 初始化时机怎么安排

走到这里,首页其实已经在替整套页面关系决定去向了。

所以我现在再看“配置化首页”这类需求,已经不会先去想“tab 怎么写”,而是:

  • 这个首页最后要处理多少种内容
  • 它是不是已经成了很多跳转共同的落脚点
  • 它是不是已经在替整套页面关系决定入口怎么走

只要这些问题开始出现,首页就已经不只是首页了。

JavaScript 严格模式下 arguments 的区别

2026年3月28日 17:37

标签:#前端 #JavaScript #严格模式 #arguments #学习笔记

一、arguments 是什么?

arguments 是函数内部的类数组对象(Array-like),包含了函数调用时传入的所有实参。

function foo() {
  console.log(arguments);       // [1, 2, 3]
  console.log(arguments[0]);    // 1
  console.log(arguments.length); // 3
}
foo(1, 2, 3);

⚠️ 它不是真正的数组,没有 pushmapforEach 等方法(除非用 Array.from() 转换)。

二、如何开启严格模式

// 全局严格模式
'use strict';

// 函数级严格模式
function bar() {
  'use strict';
  // 此函数内启用严格模式
}

三、严格模式 vs 非严格模式的核心区别

🔴 区别一:arguments 不可被修改来影响命名参数(最重要)

非严格模式(二者联动)

function foo(a, b) {
  console.log(a, b);        // 1, 2
  arguments[0] = 100;
  console.log(a, b);        // 100, 2  ← a 被改变了!
}
foo(1, 2);

非严格模式下,修改 arguments[n] 会同步修改对应的命名参数。

严格模式(二者独立)

'use strict';
function foo(a, b) {
  console.log(a, b);        // 1, 2
  arguments[0] = 100;
  console.log(a, b);        // 1, 2  ← a 没变!
}
foo(1, 2);

严格模式下,arguments 和命名参数完全独立,互不影响。

🔴 区别二:arguments.callee 被禁用

非严格模式(可用)

function factorial(n) {
  if (n <= 1) return 1;
  return n * arguments.callee(n - 1);  // ✅ 正常执行
}
console.log(factorial(5)); // 120

arguments.callee 指向当前正在执行的函数本身,常用于匿名函数递归。

严格模式(报错)

'use strict';
function factorial(n) {
  if (n <= 1) return 1;
  return n * arguments.callee(n - 1);  // ❌ TypeError!
}

严格模式下访问 arguments.callee 会直接抛出 TypeError

✅ 替代方案

// 方案1:命名函数直接调用自身
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
}

// 方案2:使用箭头函数 + 函数名(尾递归友好)
const factorial = (n) => {
  if (n <= 1) return 1;
  return n * factorial(n - 1);
};

// 方案3:用函数表达式赋给变量
const factorial = function f(n) {
  if (n <= 1) return 1;
  return n * f(n - 1);
};

🔴 区别三:arguments.caller 被禁用

非严格模式

function inner() {
  console.log(arguments.caller);  // 返回调用 inner 的外部函数
}
function outer() {
  inner();
}
outer();

严格模式

'use strict';
function inner() {
  console.log(arguments.caller);  // ❌ TypeError!
}

arguments.callerarguments.callee 在严格模式下都被禁止,原因是它们存在安全隐患(可以访问调用栈)。

🟡 区别四:arguments 不会追踪剩余参数(Spread Rest)

严格模式引入了 ...rest 语法,它与 arguments 的行为完全不同:

'use strict';

function foo(a, ...rest) {
  console.log(arguments.length); // 实参个数
  console.log(rest.length);      // 剩余参数个数
}

foo(1, 2, 3, 4);
// arguments.length → 4(所有实参)
// rest.length → 3(去掉 a 之后的部分:[2, 3, 4])

关键区别

  • arguments:类数组,包含所有实参
  • ...rest真正的数组,只包含未匹配命名参数的部分

四、完整对比表

特性

非严格模式

严格模式

修改 arguments[n] 影响命名参数

✅ 会影响

❌ 不影响

arguments.callee

✅ 可用

❌ TypeError

arguments.caller

✅ 可用

❌ TypeError

arguments 与 rest 参数共存

✅ 可共存

✅ 可共存(但行为独立)

五、为什么严格模式要限制 arguments?

1. 性能优化

非严格模式下,JS 引擎必须维护 arguments 和参数之间的双向绑定关系,这导致无法对函数参数进行某些优化。严格模式下二者独立,引擎可以更高效地处理参数。

2. 安全性

arguments.calleearguments.caller 允许访问调用栈,存在被利用来进行安全攻击的风险。

3. 代码可读性

现代 JS 推荐使用命名函数rest 参数替代 arguments 的各种黑魔法,代码更清晰。

六、现代推荐写法

'use strict';

// ❌ 旧写法:依赖 arguments
function sum() {
  let total = 0;
  for (let i = 0; i < arguments.length; i++) {
    total += arguments[i];
  }
  return total;
}

// ✅ 新写法:使用 rest 参数
function sum(...nums) {
  return nums.reduce((acc, n) => acc + n, 0);
}

// ✅ 新写法:rest + 命名参数结合
function log(prefix, ...messages) {
  messages.forEach(msg => console.log(prefix, msg));
}
log('[INFO]', 'hello', 'world');

总结

严格模式的核心改变:将 arguments 从一个"神奇的对象"变成了一个普通的类数组,切断了它与命名参数的隐式绑定,并禁用了不安全的 callee/caller 属性。在现代 JS 开发中,推荐使用 ...rest 参数替代 arguments

如何借助Github pages部署React+vite静态前端项目

作者 苍舒墨
2026年3月27日 14:46

这里只针对静态页面啊,需要向后端服务器访问数据的不行,所以还是有点受限了。

在部署时遇到了许多问题,所以想和大家分享一下我的解决方案。

我自己也去找了许多教程,下面贴出一些个人觉得有帮助的:

来认真学一下,项目部署到 github pages-腾讯云开发者社区-腾讯云

在 GitHub Pages 上部署 React 应用|极客教程

【‌轻松上手:React Vite 应用快速部署至 Github Pages】www.bilibili.com/video/BV1ch…

【免费部署一个静态网站!在GitHub上部署静态网站教程】www.bilibili.com/video/BV1D2…

虽然啊,这些都没有解决我的问题,但是讲的还是不错的。其中有些是使用git命令推到Github的仓库上,但是不知道为什么输命令的话连不上github

报错如下:

fatal: unable to access 'https://github.com/仓库名/项目名.git/': Failed to connect to github.com port 443 after 21143 ms: Couldn't connect to server

遇到过404的问题即访问不到资源,也遇到过不报错但全白屏的情况。其实都是路径不对的原因,接下来进行分析。

GitHub Pages 的两种部署方式

  1. 用户/组织站点

    • 仓库名:username.github.io
    • 访问地址:https://username.github.io/
    • 部署在根路径,base 配置为 /
  2. 项目站点

    • 仓库名:任意名称(如 myWeb
    • 访问地址:https://username.github.io/myWeb/
    • 部署在子路径,需要特殊配置

关键配置项

  • base:Vite 配置,控制静态资源(JS/CSS/图片)的引用路径
  • basename:React Router 配置,控制路由的基础路径

失败原因分析

问题 1:文件结构错误

现象: 404 错误,资源找不到

原因: 上传时把 dist 文件夹本身拖到 GitHub,导致结构变成:

仓库根目录/
  └── dist/
      ├── index.html
      └── assets/

实际访问路径变成 https://xxx.github.io/myWeb/index.html,而 HTML 里的资源路径是 /myWeb/assets/...,路径不匹配。

解决方案:

  • 进入 dist 文件夹内部
  • 全选里面的文件和文件夹(不选 dist 本身)
  • 拖到 GitHub 仓库根目录

正确结构:

仓库根目录/
  ├── index.html
  ├── assets/
  └── vite.svg

问题 2:base 配置错误

现象: 资源 404,或者图标能显示但 JS/CSS 加载失败

原因: base 配置不正确导致资源路径错误

尝试过的配置:

  1. base: '/' (默认)

    <script src="/assets/index.js"></script>
    

    实际访问:https://xxx.github.io/assets/index.js

    正确路径:https://xxx.github.io/myWeb/assets/index.js

  2. base: '/visual-editor/' (绝对路径)

    <script src="/myWeb/assets/index.js"></script>
    

    生产环境:✅ 正确

    本地开发:❌ npm run devnpm run preview 都会失败

  3. base: './' (相对路径)

    <script src="./assets/index.js"></script>
    

    看似完美,但遇到了问题 3...

问题 3:React Router 路由匹配失败

现象:

  • 资源加载成功(304 /200 状态码)
  • 控制台无报错
  • 页面白屏
  • DOM 中只有 <div id="root"><div id="app"></div></div>,app 内部为空

原因: BrowserRouter 在子路径下路由匹配失败

技术细节:

  • GitHub Pages 访问地址:https://xxx.github.io/myWeb/
  • BrowserRouter 默认 basename 是 /
  • 当前实际路径是 /myWeb/
  • 路由配置的 path="/" 匹配不上实际路径 /myWeb/
  • React 渲染了 <div id="app"> 但 Routes 内没有匹配的组件

为什么本地 preview 也白屏?

  • npm run preview 运行在 http://localhost:4173/
  • 如果 base 是 /myWeb/,资源路径变成 http://localhost:4173/myWeb/assets/...
  • 但实际文件在 http://localhost:4173/assets/...
  • 路径不匹配导致加载失败或路由失败

问题 4:开发/生产环境冲突

矛盾点:

  • 开发环境(npm run dev)需要 base 为 /
  • 生产环境(GitHub Pages)需要 base 为 /myWeb/
  • 写死任何一个值都会导致另一个环境失败

最终解决方案

1. 动态配置 base(vite.config.ts)

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig(({ mode }) => ({
  plugins: [react()],
  resolve: {
    alias: {
      '@': '/src',
    },
  },
  server: {
    port: 3000,
  },
  base: mode === 'production' ? '/myWeb/' : '/',
}));

原理:

  • mode 参数由 Vite 自动传入
  • npm run dev:mode = 'development',base = '/'
  • npm run build:mode = 'production',base = '/visual-editor/'

2. 动态配置 basename(src/main.tsx)

import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App';
import './styles/base.css';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <BrowserRouter basename={import.meta.env.BASE_URL}>
      <App />
    </BrowserRouter>
  </React.StrictMode>
);

原理:

  • import.meta.env.BASE_URL 是 Vite 内置环境变量
  • 自动读取 vite.config.ts 中的 base 值
  • 开发环境:basename = '/'
  • 生产环境:basename = '/myWeb/'

3. 部署流程

1. 构建项目
npm run build

2. 上传文件
打开本地 dist 文件夹
全选里面的所有内容(index.html、assets 文件夹等)
拖到 GitHub 仓库根目录上传

3. 配置 GitHub Pages
仓库 Settings → Pages
Source: Deploy from a branch
Branch: main
Folder: / (root)
Save

4. 等待 1-2 分钟后访问
https://username.github.io/myWeb/

常见问题排查

白屏无报错

  1. 检查 DOM 结构,看 <div id="root"> 里有没有内容
  2. 如果只有空的 <div id="app">,是路由问题
  3. 确认 BrowserRouter 的 basename 配置

资源 404

  1. F12 → Network 面板查看失败的资源路径
  2. 对比实际文件在 GitHub 上的位置
  3. 检查 vite.config.ts 的 base 配置

缓存问题

  1. 强制刷新:Ctrl + Shift + R(Windows)
  2. 无痕模式访问
  3. 清除浏览器缓存
  4. URL 加随机参数:?v=123(没试过有没有用)

本地预览失败

npm run preview

如果白屏,说明配置有问题,不要急着部署,先在本地解决。

总结

部署 Vite + React Router 到 GitHub Pages 的核心是:

  1. 理解路径差异:开发环境根路径 vs 生产环境子路径
  2. 两处配置:静态资源路径(base)+ 路由基础路径(basename)
  3. 环境区分:使用 Vite 的 mode 参数动态配置
  4. 正确上传:上传 dist 内容,不是 dist 文件夹

配置正确后,开发和部署都能正常工作,无需手动切换配置。

如果网络上实在找不到解决方法,可以去问ai的,现在大模型越来越强,将问题和目标清楚地描述给ai,大概率可以解决问题。

🔥 前端人必看:浏览器安全核心知识点全解析(XSS/CSRF/DDoS)

作者 小金鱼Y
2026年3月28日 17:03

作为前端开发者,我们每天都在和浏览器打交道,但你真的了解浏览器背后的安全隐患吗?从 XSS 脚本注入到 CSRF 跨站请求伪造,再到 DDoS 流量攻击,这些威胁时刻潜伏在我们的代码里。

本文将用最通俗的语言,拆解浏览器安全的核心知识点,让你轻松掌握防御手段,写出更安全的 Web 应用。

一、同源策略:浏览器安全的第一道防线

同源策略是浏览器最核心的安全基石,它规定了:协议、域名、端口必须完全一致,两个页面才属于 “同源”,才能自由读取对方的资源。

  • 举个例子:

    • https://juejin.cnhttps://juejin.cn:8080 → 端口不同,不同源
    • https://juejin.cnhttps://m.juejin.cn → 子域名不同,不同源
    • http://juejin.cnhttps://juejin.cn → 协议不同,不同源
  • 作用:同源策略限制了不同源之间的脚本、Cookie、DOM 等资源的访问,从根源上防止恶意网站窃取用户数据。如果没有同源策略,恶意网站可以轻易读取你在其他网站的登录状态、个人信息,后果不堪设想。

二、XSS 攻击:藏在网页里的 “隐形脚本”

1. 什么是 XSS?

XSS(Cross-Site Scripting,跨站脚本攻击)是指黑客在目标网站的网页中嵌入恶意脚本,当用户访问该网站时,浏览器会执行这些脚本,导致用户信息泄露、网站被劫持等严重后果。

2. XSS 的三种注入方式

  • 反射型 XSS:用户在不知情的情况下,将恶意脚本作为参数传递给网站请求,服务器会把参数原封不动返回给用户,浏览器会解析执行这段脚本。

    • 示例:https://example.com/search?keyword=<script>stealCookie()</script>
  • 存储型 XSS:黑客将恶意脚本直接存储在目标网站的数据库中,当其他用户访问相关页面时,脚本会被加载执行。

    • 示例:在评论区、留言板提交包含恶意脚本的内容
  • DOM 型 XSS:黑客通过篡改页面 DOM 结构,劫持 Web 资源,直接在前端执行恶意代码,不需要服务器参与。

3. 如何防御 XSS?

  1. 关键字符过滤:对用户输入的内容进行严格过滤,转义掉 <>&"' 等特殊字符,防止浏览器将其解析为 HTML 标签。

  2. CSP(内容安全策略) :在响应头中添加 Content-Security-Policy 字段,限制浏览器只能加载指定来源的资源,防止 XHR 请求加载恶意脚本。

    • 示例:Content-Security-Policy: default-src 'self'
  3. HttpOnly Cookie:在设置 Cookie 时添加 HttpOnly 属性,禁止 JavaScript 访问 Cookie,即使被 XSS 攻击也无法窃取用户登录凭证。

    • 示例:Set-Cookie: sessionId=xxx; HttpOnly

三、CSRF 攻击:披着 “合法外衣” 的恶意请求

1. 什么是 CSRF?

CSRF(Cross-Site Request Forgery,跨站请求伪造)是指黑客在恶意网站中嵌入表单或脚本,当用户登录过目标网站并访问恶意网站时,浏览器会自动携带目标网站的 Cookie,发起伪造的请求(比如转账、修改密码)。

2. CSRRF 的攻击方式

  • 自动发起 GET 请求:通过 <img><link> 等标签,在页面加载时自动发起请求。
  • 自动发起 POST 请求:通过隐藏表单,在页面加载时自动提交表单。

3. 如何防御 CSRF?

  1. 验证码:在关键操作(如转账、修改密码)前添加验证码,要求用户手动输入,防止机器人自动提交。

  2. CSRF 令牌:在表单中添加随机生成的令牌,服务器验证令牌有效性后才处理请求,确保请求来自合法页面。

  3. SameSite Cookie:在设置 Cookie 时添加 SameSite 属性,限制 Cookie 只能在同站请求中携带,防止跨站请求携带 Cookie。

    • Strict:完全禁止跨站携带 Cookie
    • Lax:允许部分安全的跨站请求携带 Cookie(如 GET 请求)

四、DDoS 攻击:压垮服务器的 “流量洪水”

1. 什么是 DDoS?

DDoS(Distributed Denial of Service,分布式拒绝服务攻击)是指黑客通过控制大量被感染的设备,向目标网站发送海量请求,耗尽服务器的带宽、CPU、内存等资源,导致网站无法正常响应合法用户的请求。

2. 如何防御 DDoS?

  1. 防火墙:在服务器端部署防火墙,对进入的流量进行过滤,只允许合法流量通过,拦截恶意请求。
  2. 负载均衡:通过负载均衡器将流量分发到多台服务器,避免单台服务器被流量压垮,提升系统的抗攻击能力。
  3. CDN 加速:将静态资源缓存到全球各地的 CDN 节点,分散流量压力,同时隐藏源服务器的真实 IP。
  4. 流量清洗:使用专业的 DDoS 防护服务,对流量进行清洗,过滤掉恶意请求,只将合法流量转发给源服务器。

五、浏览器系统安全:从底层筑牢防线

除了上述攻击类型,浏览器本身也提供了一系列安全机制,帮助我们保护用户数据:

  • 沙箱机制:将浏览器的不同进程(如渲染进程、插件进程)隔离在沙箱中,防止恶意代码突破进程边界,影响系统安全。
  • 自动更新:浏览器会定期更新安全补丁,修复已知的漏洞,建议始终保持浏览器为最新版本。
  • 安全浏览:主流浏览器会内置安全浏览功能,拦截已知的恶意网站、钓鱼网站,提醒用户注意风险。
  • 权限控制:浏览器会对网站的权限(如摄像头、麦克风、地理位置)进行严格控制,用户可以手动授权或拒绝。

六、总结

浏览器安全是 Web 开发中不可忽视的一环,从同源策略到 XSS、CSRF、DDoS 攻击,每一个知识点都关系到用户数据的安全。作为前端开发者,我们不仅要写出功能完善的代码,更要具备安全意识,在开发过程中主动采取防御措施,为用户打造更安全的上网环境。

React如何远程加载组件

作者 前端fun
2026年3月28日 16:13

前言

做过低代码开发的应该都遇到过组件加载的问题,一开始我们是把组件和整个项目放在同一个仓库中,打包的时候每个组件都是一个单独的chunk,这样加载的时候按需加载。但是随着项目越来越大,组件越来越多,甚至一些自定义的等等,这一套逻辑就不能支持下去。

而远程组件的好处就是,每个开发都能编写组件直接提交到一个公共的地方,那么我们的项目在获取组件列表的时候就能自动拿到以及使用。

构想

既然我们需要加载远程组件,就需要实现一个模块,需要处理从远端加载我们的组件然后进行渲染。

1.加载远端js
2.缓存组件(避免多次加载)
3.获取组件
4. 渲染

低代码通常会返回一个配置项,描述了内部有哪些组件以及他们的层级关系,这些我们不需要关注,默认我们现在有这么一个配置项。

首先要加载远程组件,然后把他放到react中渲染,类似下面的结构:

  const json = {
  components: [
  {
"id": "remote-button-a",
"url": "https://cdn.jsdelivr.net/npm/@luke358/remote-component@latest/dist/umd/button.umd.js",
"globalName": "RemoteUI",
"exportName": "Button",
"componentProps": {
"children": "远程按钮 A"
},
"loadingText": "远程按钮 A 加载中",
"clickMessage": "Remote UMD button A is working."
},
  ]
  }

这里我们定义了一些简单的结构。

  • import 远程的js
  • name 我们最终把这个组件挂载到window上,用于存取组件

实现

远程组件的构建和打包

这里主要关注组件的实现、以及通过配置远程加载组件,这里我们需要把远程组件打包为 umd 或者 iife。

我们新建一个项目来存放我们的组件。直接使用vite+react+ts脚手架搭建即可。

我们把所有的组件都放在 src/components 下。

主要的打包配置如下

import { existsSync } from "node:fs";
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
import { basename, resolve } from "node:path";
import { build } from "vite";
import react from "@vitejs/plugin-react";

const rootDir = process.cwd();
const srcComponentsDir = resolve(rootDir, "src/components");
const outDir = resolve(rootDir, "dist/umd");
const entryExtensions = [".tsx", ".ts", ".jsx", ".js"];
const globalNamespace = "RemoteUI";

function toKebabCase(value) {
  return value
    .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
    .replace(/[_\s]+/g, "-")
    .toLowerCase();
}

async function getComponentEntries() {
  const dirents = await readdir(srcComponentsDir, { withFileTypes: true });
  const entries = [];

  for (const dirent of dirents) {
    if (!dirent.isDirectory()) {
      continue;
    }

    const componentName = basename(dirent.name);
    const entry = entryExtensions
      .map((extension) => resolve(srcComponentsDir, componentName, `index${extension}`))
      .find((entryPath) => existsSync(entryPath));

    if (entry) {
      entries.push({
        componentName,
        entry,
        fileName: `${toKebabCase(componentName)}.umd.js`,
        globalName: globalNamespace
      });
    }
  }

  if (entries.length === 0) {
    throw new Error("No component entry files were found under src/components.");
  }

  return entries;
}

async function buildComponent(entryConfig) {
  await build({
    configFile: false,
    plugins: [
      react({
        jsxRuntime: "classic"
      })
    ],
    resolve: {
      alias: {
        "@": resolve(rootDir, "src")
      }
    },
    build: {
      outDir,
      emptyOutDir: false,
      sourcemap: true,
      lib: {
        entry: entryConfig.entry,
        name: entryConfig.globalName,
        formats: ["umd"],
        fileName: () => entryConfig.fileName
      },
      rollupOptions: {
        external: ["react", "react-dom"],
        output: {
          extend: true,
          globals: {
            react: "React",
            "react-dom": "ReactDOM"
          }
        }
      }
    }
  });
}

async function main() {
  const entries = await getComponentEntries();

  await rm(outDir, { recursive: true, force: true });
  await mkdir(outDir, { recursive: true });

  for (const entry of entries) {
    await buildComponent(entry);
  }

  await writeFile(
    resolve(outDir, "manifest.json"),
    JSON.stringify(
      entries.map(({ componentName, fileName, globalName }) => ({
        componentName,
        fileName,
        globalName,
        accessPath: `${globalName}.${componentName}`
      })),
      null,
      2
    )
  );

  console.log(`Built ${entries.length} UMD bundle(s) into ${outDir}`);
}

main().catch((error) => {
  console.error(error);
  process.exit(1);
});

主要关注的就是 formats, globalNameexternal.

我们不需要把 React 和 ReactDOM 打包进来,到时候全部都适用主应用的,所以后续需要在主应用把 React 和 ReactDOM 挂载到 window 上。

然后编写一个简单的 button 组件

import React from "react";

export interface ButtonProps {
  children: React.ReactNode;
  type?: "button" | "submit" | "reset";
  disabled?: boolean;
  onClick?: () => void;
}

const buttonStyle: React.CSSProperties = {
  display: "inline-flex",
  alignItems: "center",
  justifyContent: "center",
  padding: "12px 18px",
  border: "none",
  borderRadius: "999px",
  background:
    "linear-gradient(135deg, rgba(15, 23, 42, 1) 0%, rgba(37, 99, 235, 1) 100%)",
  color: "#ffffff",
  fontSize: "14px",
  fontWeight: 600,
  cursor: "pointer",
  boxShadow: "0 12px 30px rgba(37, 99, 235, 0.24)"
};

const disabledStyle: React.CSSProperties = {
  opacity: 0.45,
  cursor: "not-allowed",
  boxShadow: "none"
};

export function Button({
  children,
  type = "button",
  disabled = false,
  onClick
}: ButtonProps) {
  return (
    <button
      type={type}
      disabled={disabled}
      onClick={onClick}
      style={{
        ...buttonStyle,
        ...(disabled ? disabledStyle : undefined)
      }}
    >
      {children}
    </button>
  );
}

执行打包之后 "build:umd": "node ./scripts/build-umd.mjs" 就会在dist/umd 下看到一个 button.umd.js 文件。

之后把这个文件上传到 oss 或者其他地方就能进行远程加载。

远程组件的加载使用

添加React到全局对象

// main.ts
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";

declare global {
  interface Window {
    React?: typeof React;
    ReactDOM?: typeof ReactDOM;
  }
}

window.React = React;
window.ReactDOM = ReactDOM;

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

这样在渲染远程组件的时候就能确保 React 能正确的使用

RemoteComponentLoader 实现


function loadRemoteScript(url: string) {
  const cachedPromise = scriptPromiseCache.get(url);

  if (cachedPromise) {
    return cachedPromise;
  }

  const scriptPromise = new Promise<void>((resolve, reject) => {
    const existingScript = document.querySelector<HTMLScriptElement>(`script[data-remote-src="${url}"]`);

    if (existingScript) {
      if (existingScript.dataset.loaded === "true") {
        resolve();
        return;
      }

      existingScript.addEventListener("load", () => resolve(), { once: true });
      existingScript.addEventListener("error", () => reject(new Error(`Load failed: ${url}`)), {
        once: true
      });
      return;
    }

    const script = document.createElement("script");
    script.src = url;
    script.async = true;
    script.dataset.remoteSrc = url;
    script.onload = () => {
      script.dataset.loaded = "true";
      resolve();
    };
    script.onerror = () => reject(new Error(`Load failed: ${url}`));
    document.head.appendChild(script);
  }).catch((error) => {
    scriptPromiseCache.delete(url);
    removeRemoteScript(url);
    throw error;
  });

  scriptPromiseCache.set(url, scriptPromise);
  return scriptPromise;
}

export function loadRemoteComponent<TProps extends Record<string, unknown>>(
  config: RemoteComponentConfig
) {
  const cacheKey = getCacheKey(config);
  const resolvedComponent = resolvedComponentCache.get(cacheKey);

  if (resolvedComponent) {
    return Promise.resolve(resolvedComponent as RemoteComponentType<TProps>);
  }

  const pendingComponent = componentPromiseCache.get(cacheKey);

  if (pendingComponent) {
    return pendingComponent as Promise<RemoteComponentType<TProps>>;
  }

  const componentPromise = loadRemoteScript(config.url)
    .then(() => {
      const remoteComponent = resolveRemoteExport(config);
      resolvedComponentCache.set(cacheKey, remoteComponent);
      return remoteComponent;
    })
    .catch((error) => {
      componentPromiseCache.delete(cacheKey);
      resolvedComponentCache.delete(cacheKey);
      throw error;
    });

  componentPromiseCache.set(cacheKey, componentPromise);
  return componentPromise as Promise<RemoteComponentType<TProps>>;
}

这里主要是展示怎么通过配置加载我们远端的组件。

后续使用只需要:


const [ResolvedComponent, setResolvedComponent] = React.useState<RemoteComponentType<TProps> | null>(

useEffect(() => {
loadRemoteComponent<TProps>(config)
      .then((component) => {
        if (!disposed) {
          setResolvedComponent(() => component);
        }
      })
      .catch((error: Error) => {
        if (!disposed) {
          setLoadError(error);
        }
      });

    return () => {
      disposed = true;
    };
}, [])

return <div> {ResolvedComponent ? 'loading' : <ResolvedComponent />} </div>

这里只是给了个简单的例子。

正常情况我们需要考虑组件的并发加载处理,缓存,错误捕获避免导致应用全部崩溃等一些细节问题。

具体实现代码(主要由AI生成):
github.com/luke358/tes…

安装依赖后,执行 npm run dev 可以直接看到效果

解决大数据渲染卡顿:Vue3 虚拟列表组件的完整实现方案

作者 起风了___
2026年3月28日 15:54

文章简介

本文介绍的是 vue3 中虚表组件的实现方式。当需要展示的数据量达到几百上千条时就需要使用虚表,否则大量组件的渲染会导致页面卡顿甚至卡死。 备注:本文介绍的虚表只支持固定且高度相同的数据元素。

实现原理

滚动容器
┌─────────────────────────────┐
│                             │
│    ~~~~~~~~~~~~~~~~~~~      │
│    ~  未渲染的虚拟行  ~       │
│    ~~~~~~~~~~~~~~~~~~~      │
│  ┌─────────────────────┐    │
│  │  实际渲染区域     │    │
│  │  (visibleRows)      │    │
│  └─────────────────────┘    │
│    ~~~~~~~~~~~~~~~~~~~      │
│    ~  未渲染的虚拟行  ~       │
│    ~~~~~~~~~~~~~~~~~~~      │
│                             │
└─────────────────────────────┘
  • 虚表由 3 个元素组成,分别为有固定高度的根元素(滚动容器)提供数据滚动能力、用于撑开根容器的占位元素、用于展示信息的区域渲染元素。
  • 渲染区域在根元素内部使用绝对定位 position: absolute; 脱离文档流。
    • 实时计算需要渲染的元素行。
    • 备注:当需要渲染的元素发生变化时,通过 transform: translateY(100px); 属性对渲染区域进行偏移,确保渲染连续。
  • 根元素使用相对定位 position: relative; 使渲染元素在根元素内部定位、滚动。
  • 占位元素只用来撑开根元素内部空间,让根元素提供滚动能力。
    • 备注:占位元素的高度计算方式:数据量 * 数据展示元素高度。

外部属性定义

  • items: 使用虚表的父组件传入的所有要展示数据源。
  • itemHeight:每个数据元素的展示行高
  • width、height:可由父组件传入固定数值,默认撑满父组件。
  • space:展示元素之间的间距
  • bufferSize:渲染区域上下缓冲区大小
const props = defineProps({
  // 数据源 (必须)
  items: {
    type: Array,
    required: true,
  },
  // 行高 (必须,单位px)
  itemHeight: {
    type: Number,
    required: true,
  },
  // 容器宽度 (可选,未指定则撑满父元素)
  width: {
    type: [String, Number],
    default: "100%",
  },
  // 容器高度 (可选,未指定则撑满父元素)
  height: {
    type: [String, Number],
    default: "100%",
  },
  // item 间距 (可选,默认5px)
  space: {
    type: Number,
    default: 8,
  },
  // 上下缓冲区行数,避免快速滚动白屏 (默认5)
  bufferSize: {
    type: Number,
    default: 5,
  },
});

插槽定义

主要用于定义数据展示元素插槽的数据类型,否则使用虚表的父组件在定义数据展示元素时会飘红

defineSlots<{
  item(props: { item: any; index: number }): void;
}>();

html 部分

  • viewportRef 绑定根元素对象,用于获取实际视口高度,视口高度会用来计算可展示元素数量
  • containerStyle: 用于设置父组件传递的根容器宽高,或设置默认值
  • virtual-viewport:根元素 css 属性
  • virtual-phantom:占位块 css 属性
  • totalHeight:虚表需要展示的总数据占位高度
  • virtual-content:渲染区 css 属性
  • offsetY:渲染区偏移量
  • visibleRows:实际渲染元素
  • itemHeight:插槽定义的数据展示元素高度,由使用虚表的父组件通过属性传入
  • itemActualHeight:渲染元素实际高度 = 插槽定义的数据展示元素高度(itemHeight) + 元素间隔(space)
<template>
  <!-- 虚拟滚动视口 -->
  <div
    ref="viewportRef"
    class="virtual-viewport"
    :style="containerStyle"
    @scroll="onScroll"
  >
    <!-- 占位块,撑开滚动空间 -->
    <div class="virtual-phantom" :style="{ height: totalHeight + 'px' }" />

    <!-- 渲染内容区,绝对定位跟随滚动 -->
    <div
      class="virtual-content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="row in visibleRows"
        :key="row.index"
        :style="{ height: itemActualHeight + 'px' }"
      >
        <!-- 通过插槽让外部自定义每一项的渲染内容 -->
        <slot
          name="item"
          :item="row.data"
          :index="row.index"
          :style="{ height: itemHeight + 'px' }"
        >
          <!-- 默认渲染,当外部没有提供自定义插槽时使用 -->
          <div>
            <span>#{{ row.index + 1 }}</span>
            <span>{{ row.data }}</span>
          </div>
        </slot>
      </div>
    </div>
  </div>
</template>

css 部分

<style scoped>
.virtual-viewport {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}

.virtual-phantom {
  width: 100%;
  pointer-events: none;
}

.virtual-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  will-change: transform;
}
</style>

实时计算变量实现

核心逻辑

  1. 实时计算父组件设置的根元素宽高
  2. 虚表组件挂载后获得视口高度,并订阅根元素的大小变化。
  3. 监听展示数据的变化,超出滚动范围时修正滚动范围。
  4. 实时计算每项元素实际高度
  5. 实时计算占位元素总高度
  6. 实时计算起始结束索引
  7. 实时计算实际渲染的数据行
  8. 实时计算偏移量
  • 实时计算使用 vue3 的 computed() 方法

// 引用
const viewportRef = ref<any>(null);
// 实际容器高度 (动态计算)
const viewportHeight = ref(0);
// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
  (e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
  scrollTop.value = e.target.scrollTop;
  emit("scroll", scrollTop.value);
};

// 容器样式
const containerStyle = computed(() => {
  const style: any = {};
  style.width =
    typeof props.width === "number" ? `${props.width}px` : props.width;
  style.height =
    typeof props.height === "number" ? `${props.height}px` : props.height;
  return style;
});
// 更新容器高度
const updateViewportHeight = () => {
  if (viewportRef.value) {
    const height = viewportRef.value.clientHeight;
    if (viewportHeight.value !== height) {
      viewportHeight.value = height;
    }
  }
};
// 使用ResizeObserver监听视口尺寸变化
let resizeObserver: any = null;
onMounted(() => {
  // 初始化容器高度
  updateViewportHeight();
  // 监听容器大小变化
  resizeObserver = new ResizeObserver(() => {
    updateViewportHeight();
  });
  if (viewportRef.value) {
    resizeObserver.observe(viewportRef.value);
  }
});
onBeforeUnmount(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});
// 监听数据变化,确保滚动位置有效
watch(
  () => props.items,
  () => {
    // 如果数据变化后总高度变小,且当前滚动位置超出范围,修正滚动
    nextTick(() => {
      if (viewportRef.value) {
        const maxScroll = Math.max(
          0,
          totalHeight.value - viewportRef.value.clientHeight,
        );
        if (viewportRef.value.scrollTop > maxScroll) {
          viewportRef.value.scrollTop = maxScroll;
        }
      }
    });
  },
);

// 每项实际高度
const itemActualHeight = computed(() => {
  return props.itemHeight + props.space;
});

// 计算总数据量
const totalItems = computed(() => props.items.length);

// 总高度
const totalHeight = computed(() => totalItems.value * itemActualHeight.value);

// 计算起始索引 (基于scrollTop)
const startIndex = computed(() => {
  const rawStart = Math.floor(scrollTop.value / itemActualHeight.value);
  return Math.max(0, rawStart - props.bufferSize);
});

// 计算结束索引
const endIndex = computed(() => {
  const rawEnd = Math.ceil(
    (scrollTop.value + viewportHeight.value) / itemActualHeight.value,
  );
  return Math.min(totalItems.value - 1, rawEnd + props.bufferSize);
});

// 实际需要渲染的行数据
const visibleRows = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return props.items
    .map((item, index) => ({
      index: start + index,
      data: item,
    }))
    .slice(start, end + 1);
});
// 偏移量
const offsetY = computed(() => startIndex.value * itemActualHeight.value);

对外暴露滚动事件、滚动距离

// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
  (e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
  scrollTop.value = e.target.scrollTop;
  emit("scroll", scrollTop.value);
};

对外暴露根容器

defineExpose({
  $el: viewportRef,
});

使用虚表的父组件可以通过 ref 绑定虚表的根元素。

假设父组件通过 ref="parent" 绑定虚表根元素,通过父组件控制虚表滚动的方法为

parent.value.$el.scrollTop = 100;

父组件使用

<virtual-table
  ref="parent"
  :items="data"
  :space="8"
  :itemHeight="150"
  @scroll="(value: number) => (scrollTop = value)"
>
  <template #item="{ item, index }">
    <div>序号:{{ index }}</div>
    <div>内容:{{ item }}</div>
  </template>
</virtual-table>

附源码

<script setup lang="ts">
import {
  ref,
  computed,
  onMounted,
  onBeforeUnmount,
  watch,
  nextTick,
} from "vue";

const props = defineProps({
  // 数据源 (必须)
  items: {
    type: Array,
    required: true,
  },
  // 行高 (必须,单位px)
  itemHeight: {
    type: Number,
    required: true,
  },
  // 容器宽度 (可选,未指定则撑满父元素)
  width: {
    type: [String, Number],
    default: "100%",
  },
  // 容器高度 (可选,未指定则撑满父元素)
  height: {
    type: [String, Number],
    default: "100%",
  },
  // item 间距 (可选,默认8px)
  space: {
    type: Number,
    default: 8,
  },
  // 上下缓冲区行数,避免快速滚动白屏 (默认5)
  bufferSize: {
    type: Number,
    default: 5,
  },
});
defineSlots<{
  item(props: { item: any; index: number }): void;
}>();

// 引用
const viewportRef = ref<any>(null);
// 实际容器高度 (动态计算)
const viewportHeight = ref(0);
// 滚动位置
const scrollTop = ref(0);
// 滚动事件
const emit = defineEmits<{
  (e: "scroll", scrollTop: number): void;
}>();
const onScroll = (e: any) => {
  scrollTop.value = e.target.scrollTop;
  emit("scroll", scrollTop.value);
};

// 容器样式
const containerStyle = computed(() => {
  const style: any = {};
  style.width =
    typeof props.width === "number" ? `${props.width}px` : props.width;
  style.height =
    typeof props.height === "number" ? `${props.height}px` : props.height;
  return style;
});
// 更新容器高度
const updateViewportHeight = () => {
  if (viewportRef.value) {
    const height = viewportRef.value.clientHeight;
    if (viewportHeight.value !== height) {
      viewportHeight.value = height;
    }
  }
};
// 使用ResizeObserver监听视口尺寸变化
let resizeObserver: any = null;
onMounted(() => {
  // 初始化容器高度
  updateViewportHeight();
  // 监听容器大小变化
  resizeObserver = new ResizeObserver(() => {
    updateViewportHeight();
  });
  if (viewportRef.value) {
    resizeObserver.observe(viewportRef.value);
  }
});
onBeforeUnmount(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});
// 监听数据变化,确保滚动位置有效
watch(
  () => props.items,
  () => {
    // 如果数据变化后总高度变小,且当前滚动位置超出范围,修正滚动
    nextTick(() => {
      if (viewportRef.value) {
        const maxScroll = Math.max(
          0,
          totalHeight.value - viewportRef.value.clientHeight,
        );
        if (viewportRef.value.scrollTop > maxScroll) {
          viewportRef.value.scrollTop = maxScroll;
        }
      }
    });
  },
);

// 每项实际高度
const itemActualHeight = computed(() => {
  return props.itemHeight + props.space;
});

// 计算总数据量
const totalItems = computed(() => props.items.length);

// 总高度
const totalHeight = computed(() => totalItems.value * itemActualHeight.value);

// 计算起始索引 (基于scrollTop)
const startIndex = computed(() => {
  const rawStart = Math.floor(scrollTop.value / itemActualHeight.value);
  return Math.max(0, rawStart - props.bufferSize);
});

// 计算结束索引
const endIndex = computed(() => {
  const rawEnd = Math.ceil(
    (scrollTop.value + viewportHeight.value) / itemActualHeight.value,
  );
  return Math.min(totalItems.value - 1, rawEnd + props.bufferSize);
});

// 实际需要渲染的行数据
const visibleRows = computed(() => {
  const start = startIndex.value;
  const end = endIndex.value;
  return props.items
    .map((item, index) => ({
      index: start + index,
      data: item,
    }))
    .slice(start, end + 1);
});
// 偏移量
const offsetY = computed(() => startIndex.value * itemActualHeight.value);

defineExpose({
  $el: viewportRef,
});
</script>

<template>
  <!-- 虚拟滚动视口 -->
  <div
    ref="viewportRef"
    class="virtual-viewport"
    :style="containerStyle"
    @scroll="onScroll"
  >
    <!-- 占位块,撑开滚动空间 -->
    <div class="virtual-phantom" :style="{ height: totalHeight + 'px' }" />

    <!-- 渲染内容区,绝对定位跟随滚动 -->
    <div
      class="virtual-content"
      :style="{ transform: `translateY(${offsetY}px)` }"
    >
      <div
        v-for="row in visibleRows"
        :key="row.index"
        :style="{ height: itemActualHeight + 'px' }"
      >
        <!-- 通过插槽让外部自定义每一项的渲染内容 -->
        <slot
          name="item"
          :item="row.data"
          :index="row.index"
          :style="{ height: itemHeight + 'px' }"
        >
          <!-- 默认渲染,当外部没有提供自定义插槽时使用 -->
          <div>
            <span>#{{ row.index + 1 }}</span>
            <span>{{ row.data }}</span>
          </div>
        </slot>
      </div>
    </div>
  </div>
</template>

<style scoped>
.virtual-viewport {
  position: relative;
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}

.virtual-phantom {
  width: 100%;
  pointer-events: none;
}

.virtual-content {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  will-change: transform;
}
</style>

聊聊我逃离前端开发前的思考

作者 华洛
2026年3月28日 15:50

我在22年底chatGPT出现后的第一时间选择了从前端转型,并精准预测了25年AI产品、agent工程师岗位的诞生,以及26年将会是AI代替人类岗位的元年。

回头想一下,我能做出这些预测,并及时调整我的人生轨迹,全因为我的思考方式:像规划企业一样规划我的人生。

这个思考方式确实让我少走了非常多弯路,早在23年4月份,我写下图中的思考,而这份思考也是我放弃前端选择转型的基础逻辑。

cc99ba4232d28d3ff3b2196882a3d28.jpg

像规划企业一样规划我们的人生

如何像规划企业一样规划我们的人生?

首先大家要对我们的参与社会工作的人生阶段有一个概念:

从24岁大学毕业开始工作到65岁退休,足足有41年。

要知道2026年我们建国才77年;

中华老字号(创立50年以上)认证的企业也只有1455家;

倒闭了多少家企业才有了这1455的老字号。

所以,各位认为选择一个行业之后,能干满40年概率有多大?

干满40年一个行业,需要极大的运气与实力才可以的。

所以,今天我们所面对的,本就是这个世界应该发生的事情,大可不必过于担心焦虑。

比尔·盖茨强调企业需保持"离破产仅18个月"的危机意识。

保持这个意识的企业为了活下去, 都在不停地想办法赚钱、扩展业务:

  • 要不停地迭代产品功能、服务,建立企业护城河;
  • 要不停地找新到新的业务方向、新的客户、新的合作者;
  • 要不停审视市场环境、政策变化、竞争对手,决定进入\离开某个市场。
  • 等等.....

企业面临着市场缩小、政策变化、竞争变多、扩张业务等因素,都在不断研究方向,研究战略,生怕走错一步被彻底淘汰。

但是很多人却从不给自己做未来规划,直到事情发生才后知后觉,然后开始怨天尤人。

殊不知,个人面临着年龄变大、精力衰退、技能落后、新人顶替等等因素,被淘汰的风险一点都不比企业小。

所以个人也应该随时保持距离被辞退仅18个月的风险意识,尤其是现在身处AI的年代,这个时间被压缩的更少了;

我们要不断地审视自己:

  • 是否处于同行业较高水平?
  • 是否存在被淘汰的风险?风险在哪?
  • 是否要选择进入\退出某个岗位\行业?
  • 等等......

试一下吧,现在开始,审视一下你自己,规划一下你自己,像规划一家企业一样。

结语

最后送给读者一句话:

when the facts change, I change my mind ——凯恩斯

这也正应了咱们那句老话:君子审时度势,顺势而为。

我是华洛,关注我学习更多AI落地的实战经验与技巧。

加油,共勉。

☺️你好,我是华洛,All in AI多年,专注于AI在产品侧的应用以及企业AI员工的设计。

关注我:华洛AI转型纪实

专栏文章

# 多写点skill吧,写的越多这行业死的越快。

# 聊聊我们公司的AI应用工程师每天都干啥?

# SEO还没死,GEO之战已经开始

# 从0到1打造企业级AI售前机器人——实战指南二:RAG工程落地之数据处理篇🧐

# 从0到1打造企业级AI售前机器人——实战指南一:根据产品需求和定位进行agent流程设计🧐

# 聊一下MCP,希望能让各位清醒一点吧🧐

# 实战派!百万PV的AI产品如何搭建RAG系统?

# 团队落地AI产品的全流程

# 5000字长文,AI时代下程序员的巨大优势!

Lexical依赖版本冲突与标题渲染

作者 im_AMBER
2026年3月28日 13:52

一、概述

昨天主要修复了两个bug:一是 PDF/Doc 导出时的依赖冲突导致的崩溃问题,二是文档标题更新后的 UI 同步问题。本文记录问题现象、排查过程、根因分析及解决方案,并对过程中的技术选型与排查方法进行复盘总结。

二、文件导出

导出文件PDF Doc 格式的时候存在id冲突,文件导出失败。

是因为Lexical编辑器基于节点框架设计,存在依赖冲突或多次初始化导致的问题。

依赖库版本不一致,后续也证实了是这个原因,BlockNote官方开源库已经明确说明,依赖版本要保持唯一、保持一致。 多节点重复使用选择 JSON ID · 问题 #1718 · TypeCellOS/BlockNote --- Duplicate use of selection JSON ID multiple-node · Issue #1718 · TypeCellOS/BlockNote 在这里插入图片描述

关于这个问题的bug追踪,我学会了通过F12控制台查看调用堆栈,分析错误具体出现在哪几层,事件是如何冒泡的。 在这里插入图片描述从下往上看(按时间顺序):

  • 用户点击:在菜单上点了一下(onInternalClick -> onMenuClick)。
  • 进入代码:程序运行到了 useFileExport.ts 的第 43 行,进入了 exportFile 函数。
  • 触发报错:在执行导出逻辑时,代码内部调用了 Lexical 的选择器逻辑(MultipleNodeSelection.ts)。
  • 报错根源:Selection.jsonID 发现 multiple-node 这个 ID 已经被注册过了。

项目可能同时加载了两个不同版本的 @lexical/selection 或相关插件包。当它们各自尝试注册 MultipleNodeSelection 时,ID 就会发生冲突。

Ctrl+P 查找相关文件,查看编译产物是否存在多份。

如果存在多份,可能是依赖版本冲突,也可能是模块解析路径不一致、代码分割策略或构建配置导致的重复打包。需要进一步查看产物中的版本号或模块路径来确认根本原因。

BlockNote 是基于 Lexical 构建的开源富文本编辑器框架,在 Lexical 基础上做了业务层封装。其底层的Lexical编辑器版本可能存在细微差异,在文档导出时,节点对应的ID发生了冲突。各自注册multiple selection相关包时,ID出现冲突,导致无法正确匹配对应实例。

我排查后发现,是之前引入AI库时做了全局配置分发,没有校验BlockNote开源库的版本一致性,这里涉及到依赖配置问题。 在这里插入图片描述 同时pnpm在打包编译和预加载时,也必须保证依赖版本统一。直接采用了pnpm overrides。

因为BlockNote是多层封装的开源库,所以在package.json里无法直接找到对应的Lexical编辑器依赖,需要通过命令行做深度检索和依赖输出。

根本原因:

  • 依赖树分析: 由于 pnpm 的依赖提升(hoisting)机制,以及部分包通过动态 import 懒加载, 导致 @lexical/selection 在不同模块中被解析到了不同的版本路径。 虽然 package.json 中版本范围一致,但实际安装时存在版本漂移或重复实例。

  • 打包逻辑分析: 在 Vite 构建时,由于模块解析(module resolution)的路径规则, 主包和 exporter 模块中的 Lexical 依赖被解析到了不同的 node_modules 路径, 导致运行时加载了多个 Lexical 实例。

  • 运行时分析: 导出操作触发了动态加载 -> 加载了第二个 Lexical 实例 -> 第二个实例尝试在全局注册已存在的 ID -> 崩溃

这次的收获主要是学会看堆栈信息,也考虑一下报错的更多原因。判断编译文件是否存在多个版本,从而快速定位错误。也思考了为什么出现报错的,除了排查代码逻辑,还要思考一下版本冲突等问题。

关于版本管理的问题,比较好的做法我认为应该在CI流程中加入脚本,检查所有 @blocknote/* 包的版本号是否一致。但是现在我还没有着手做这个。目前的package.json也比较少,现在看来自己维护也不是很麻烦。

三、文档标题提取

我一开始认为应该创建一个hook,然后在对应的wikiList(UI层的渲染)里调用这个hook。

但其实不是所有逻辑都适合用hook,写一个纯工具函数也可以。我当时也没太分清hook和utils的区别,自定义hook和utils呢,我个人感觉是有没有涉及到状态变更,像utils其实是没有动态变化的状态要管理的,更多是直接处理数据层。

React 官方文档在 Building Your Own Hooks 中强调:

Custom Hooks let you share stateful logic, not state itself. 自定义 Hook 让你共享有状态的逻辑,而不是状态本身。

关键在"有状态的逻辑"——这意味着自定义 Hook 的本质是封装那些涉及状态管理和副作用的行为。

核心还是要做到UI和逻辑解耦。

然后wiki list要怎么获取当前动态更新的数据呢?

 useEffect(() => {
    const handleUpdate = (e: any) => {
      const { id, title } = e.detail;
      setDocs((prevDocs) =>
        prevDocs.map((doc) =>
          doc._id === id ? { ...doc, title: title } : doc,
        ),
      );
    };

    window.addEventListener("WIKI_TITLE_UPDATED", handleUpdate);
    return () => window.removeEventListener("WIKI_TITLE_UPDATED", handleUpdate);
  }, []);

这里用了事件广播,把事件监听挂载到window对象上。这个做法因为直接挂载在浏览器web上,其实不是太优雅,但是胜在简单。

我觉得需要注意的一点是前后端与数据库的交互逻辑:新建了一个标题title之后,title是怎么存入数据库的,页面刷新之后也要保证逻辑正常。

 updateDocument(currentDocId, {
          title: note.title,
          content: note.content,
        }).catch(() => {});

新增了title字段。这里bug的验证是打印控制台,看看是否存入后端了,当然应该也可以直接看database的GUI界面。

四、小结

本次修复的两个问题虽然规模不大,但涉及依赖管理、架构设计、技术选型等多个维度。日常开发中,应保持对报错信息的敏感度,深入排查根因而非仅修复表象,同时注重代码的可维护性与可扩展性。

特别声明:本次代码实现仅仅是能跑通功能,并不是优雅的做法,存在设计等层面的缺陷,还请见谅。限于个人经验,文中若有疏漏,还请不吝赐教。

【节点】[Texture3DAsset节点]原理解析与实际应用

作者 SmalBox
2026年3月28日 13:40

【Unity Shader Graph 使用与特效实现】专栏-直达

在 Unity URP Shader Graph 中,Texture 3D Asset 节点是一个基础且重要的资源定义节点,它专门用于在着色器程序中定义和引用三维纹理资源。与传统的 2D 纹理不同,3D 纹理在三个维度上都具有纹理数据,这使得它在处理体积数据、复杂材质过渡和动态效果方面具有独特优势。该节点本身并不直接执行纹理采样操作,而是作为纹理资源的声明和引用点,为后续的 Sample Texture 3D 节点提供必要的纹理数据源。

三维纹理在计算机图形学中通常被称为体积纹理,它将纹理数据组织在三维空间中的规则网格上,每个纹素都有对应的(x, y, z)坐标。这种结构使得 3D 纹理特别适合表示体积数据,如医学成像中的 CT 扫描数据、云层和烟雾的密度场、金属材质的微观结构,或者是复杂材质的内部变化。在实时渲染中,3D 纹理的使用能够创造出更加丰富和真实的视觉效果,特别是在需要表现材质内部结构或复杂过渡效果的场景中。

Texture 3D Asset 节点的核心作用是建立 Shader Graph 与项目中 3D 纹理资源之间的连接桥梁。通过这个节点,开发者可以将项目中导入或创建的 3D 纹理资源引入到着色器图中,并使其可用于后续的纹理采样和处理操作。这种设计符合现代着色器开发中的资源与逻辑分离原则,使得同一个 3D 纹理资源可以在不同的采样节点中以不同的参数重复使用,提高了资源的利用率和着色器开发的灵活性。

在 Unity 的渲染管线中,3D 纹理的处理方式与 2D 纹理有所不同。由于包含额外的维度,3D 纹理通常需要更多的内存和计算资源,因此在性能优化方面需要特别关注。Texture 3D Asset 节点通过统一的接口抽象了这些底层细节,使得开发者可以专注于材质的外观和效果,而不必过多担心底层的实现复杂性。

描述

Texture 3D Asset 节点的主要功能是定义在着色器中使用的常量 3D 纹理资源。这里的"常量"意味着在着色器执行过程中,该纹理资源本身不会发生变化,尽管通过不同的采样参数可以从同一纹理中提取不同的信息。这种设计模式符合现代图形编程的最佳实践,即尽可能将资源和计算逻辑分离,从而提高代码的可维护性和执行效率。

在 Shader Graph 的工作流程中,Texture 3D Asset 节点承担着资源绑定的关键角色。当在项目中创建或导入 3D 纹理后,需要通过此节点将其引入到着色器图中。节点本身不包含任何采样逻辑,它仅仅是对纹理资源的引用声明。这种设计使得同一个纹理资源可以在着色器图中的多个位置被重复使用,每个使用位置都可以应用不同的采样参数和变换操作,而无需在内存中创建多个纹理副本。

3D 纹理与 2D 纹理在内部结构上存在显著差异。2D 纹理可以看作是平面上的像素网格,而 3D 纹理则是三维空间中的体素网格。每个体素不仅包含颜色信息,还可以存储其他数据,如密度、材质属性或其他自定义信息。这种结构使得 3D 纹理特别适合用于表示体积效果和复杂材质的内部结构变化。例如,在模拟大理石材质时,可以使用 3D 纹理来存储石材内部的矿物分布和脉络结构,通过适当的采样方式,可以渲染出具有真实内部结构的大理石表面。

要对 3D 纹理资源进行实际采样,必须将其与 Sample Texture 3D 节点结合使用。这两个节点的分工明确:Texture 3D Asset 节点负责声明纹理资源,而 Sample Texture 3D 节点负责执行具体的采样操作。这种分离设计的优势在于提高了资源的可复用性——使用单个 Texture 3D Asset 节点时,可以使用不同的参数对 3D 纹理进行多次采样,无需对 3D 纹理本身进行多次定义。这不仅减少了内存占用,也使得着色器结构更加清晰和易于维护。

在实际应用场景中,3D 纹理的用途十分广泛。它们可以用于创建复杂的材质效果,如木材的年轮、石材的纹理、金属的晶粒结构等。在特效制作中,3D 纹理常用于模拟体积效果,如烟雾、云层、火焰等。此外,在科技可视化领域,3D 纹理也常用于显示医学影像数据、地质勘探数据、流体模拟结果等科学数据。

Unity 对 3D 纹理的支持包括了一系列优化特性,如 mipmapping、纹理压缩和流式加载等。这些特性通过 Texture 3D Asset 节点对开发者透明地提供,使得开发者可以专注于材质效果的实现,而不必过多关注底层的纹理管理和优化细节。同时,Unity 还提供了完善的工具链支持,包括 3D 纹理的导入设置、预览和调试工具,进一步简化了 3D 纹理的使用流程。

端口

输出端口

Texture 3D Asset 节点仅包含一个输出端口,这个端口的设计反映了节点的单一职责原则——专注于提供 3D 纹理资源的引用。

Out 输出端口是该节点的唯一输出接口,其数据类型被明确指定为"3D 纹理"。这一数据类型不仅包含了纹理本身的图像数据引用,还封装了与纹理相关的元数据信息,如纹理尺寸、格式、mipmap 级别等。在 Shader Graph 的内部数据流中,这个输出端口传递的实际上是一个纹理对象的句柄或引用,而不是纹理数据本身。这种设计确保了数据传输的高效性,避免了不必要的数据复制操作。

输出端口的连接规则需要特别注意。Out 端口只能连接到接受 3D 纹理类型输入的节点,最典型的就是 Sample Texture 3D 节点。如果尝试将其连接到不兼容的节点类型,Shader Graph 会显示连接错误,防止不合理的节点连接组合。这种类型安全检查机制确保了着色器图的正确性和稳定性。

从数据流的角度来看,Out 端口输出的 3D 纹理引用在着色器执行过程中保持不变。这意味着在渲染一帧的过程中,纹理资源是恒定的,不会发生改变。这种不变性使得 Unity 的渲染管线能够进行各种优化,如纹理数据的预取、缓存策略的优化等。同时,这也符合现代图形 API 的设计原则,即在绘制调用之间尽可能保持资源状态的稳定。

在实际使用中,Out 端口的连接方式直接影响着着色器的性能和效果。如果同一个 Texture 3D Asset 节点的 Out 端口被连接到多个 Sample Texture 3D 节点,这意味着同一纹理资源将被多次采样。这种情况下,Unity 的渲染后端通常会识别出这种模式并进行优化,比如通过纹理绑定点的复用减少 API 调用开销。但是,开发者仍需注意这种用法可能带来的性能影响,特别是在性能敏感的移动平台上。

输出端口的特性还包括其对动态分支的支持。在包含动态分支的着色器中,即使某个分支路径上的 Sample Texture 3D 节点实际上不会被执行,但只要它连接到了 Texture 3D Asset 节点的 Out 端口,对应的纹理资源仍然会被绑定到着色器上。这种行为确保了着色器执行的一致性,但同时也意味着需要注意纹理资源的绑定数量,避免超出目标平台的限制。

控件

对象字段控件

Texture 3D Asset 节点的核心控件是一个 3D 纹理对象字段,这个控件提供了与 Unity 项目资源系统的直接交互接口。对象字段的设计遵循了 Unity 编辑器的通用模式,使得熟悉 Unity 的开发者能够快速上手使用。

对象字段控件在节点界面中显示为一个资源选择区域,通常包含纹理预览、资源名称和选择按钮等元素。开发者可以通过多种方式为此字段指定 3D 纹理资源:直接拖拽项目窗口中的 3D 纹理资源到字段区域、点击字段右侧的选择按钮从资源选择窗口中选取,或者通过字段的上下文菜单进行操作。这种灵活的资源指定方式适应了不同的工作流程和习惯。

对象字段的验证机制确保了只有合适的资源类型可以被指定。当尝试分配非 3D 纹理资源时,系统会拒绝该操作并给出相应的错误提示。这种类型安全机制防止了错误的资源分配,减少了调试时间。此外,字段还会对纹理资源的导入设置进行检查,确保其符合 3D 纹理的使用要求,如正确的纹理尺寸、格式和 mipmap 设置等。

在资源管理方面,对象字段维护着对项目中 3D 纹理资源的引用。这种引用关系在 Shader Graph 序列化时会被保存,确保了着色器材质在重新加载时能够正确恢复与纹理资源的关联。同时,这种引用机制也使得资源的重命名、移动等操作能够被正确跟踪,减少了资源丢失的风险。

对象字段还提供了快速的资源访问和导航功能。通过字段的上下文菜单,开发者可以快速在项目窗口中定位当前指定的纹理资源、重新导入纹理、或者打开纹理导入设置界面。这些便捷功能大大提高了着色器开发的工作效率,特别是在需要频繁调整纹理设置的工作流程中。

对于团队协作和资源管理,对象字段的引用机制确保了 Shader Graph 与纹理资源之间的依赖关系能够被 Unity 的资产数据库正确跟踪。这使得资源打包、依赖分析和内存管理等功能能够正常工作。当构建项目时,所有通过 Texture 3D Asset 节点引用的 3D 纹理资源都会被自动包含在构建中,无需手动管理这些依赖关系。

对象字段的另一个重要特性是它对默认资源的支持。在创建新的 Texture 3D Asset 节点时,字段初始状态为空,此时节点不会输出有效的纹理引用。开发者必须显式地指定一个 3D 纹理资源,否则在着色器编译时会生成错误。这种显式的要求确保了着色器行为的明确性,避免了因缺少资源而导致的意外行为。

生成的代码示例

当 Shader Graph 编译为实际的着色器代码时,Texture 3D Asset 节点会生成对应的 HLSL 代码。理解这些生成的代码对于深入掌握节点的工作原理和进行高级着色器开发具有重要意义。

基础代码结构

典型的 Texture 3D Asset 节点会生成如下形式的 HLSL 代码:

HLSL

TEXTURE3D(_Texture3DAsset);
SAMPLER(sampler_Texture3DAsset);

这段代码包含两个关键部分:纹理声明和采样器声明。TEXTURE3D(_Texture3DAsset) 宏声明了一个 3D 纹理对象,其中的 _Texture3DAsset 是纹理的标识符名称。这个标识符在默认情况下由系统自动生成,但也可以根据命名规则进行预测。在实际编译过程中,Unity 会根据 Shader Graph 的整体结构和设置来确定最终的标识符命名。

SAMPLER(sampler_Texture3DAsset) 声明了与纹理关联的采样器状态。采样器定义了纹理采样时的各种参数,如过滤模式、寻址模式等。在现代图形 API 中,纹理和采样器通常是分离的,这种设计允许多个纹理共享同一个采样器状态,提高了资源的灵活性。

编译时处理

在着色器编译过程中,Unity 会对 Texture 3D Asset 节点进行一系列处理和优化。首先,系统会检查指定的 3D 纹理资源是否存在且有效。如果资源丢失或类型不正确,编译器会生成错误并停止编译。这种严格的验证确保了最终着色器的可靠性。

对于纹理的导入设置,如 mipmap、压缩格式、各向异性过滤等,Unity 会在编译时考虑这些设置并生成相应的采样代码。例如,如果纹理启用了 mipmap,生成的采样代码会自动包含 mipmap 级别的计算和选择逻辑。这些细节对 Shader Graph 用户是透明的,但了解其背后的机制有助于更好地优化纹理设置。

运行时行为

在运行时,生成的着色器代码通过 Unity 的材质系统与实际的纹理资源进行绑定。当材质被渲染时,Unity 的渲染管线会确保在绘制调用之前,所有引用的纹理资源都被正确设置到对应的纹理单元中。这个过程是自动管理的,开发者通常不需要关心具体的实现细节。

对于不同的渲染管线和平台,Unity 可能会生成不同的底层代码。例如,在支持 Bindless Texture 的平台上,可能会使用更高效的纹理绑定方式。这些平台特定的优化由 Unity 自动处理,确保了着色器在不同环境下都能获得最佳性能。

高级用法代码模式

在复杂的着色器中,可能会遇到多个 Texture 3D Asset 节点协同工作的情况。这种情况下生成的代码会包含多个纹理和采样器声明:

HLSL

// 第一个3D纹理
TEXTURE3D(_Texture3DAsset);
SAMPLER(sampler_Texture3DAsset);

// 第二个3D纹理
TEXTURE3D(_SecondaryVolumeTexture);
SAMPLER(sampler_SecondaryVolumeTexture);

这种模式允许在同一个着色器中使用多个 3D 纹理,比如一个用于基础颜色,另一个用于法线或高度信息。在性能方面需要注意,同时使用多个 3D 纹理可能会增加内存带宽需求,特别是在移动设备上需要谨慎使用。

与 Properties 的关联

虽然 Texture 3D Asset 节点在 Shader Graph 中表现为常量资源引用,但在某些情况下,开发者可能希望将 3D 纹理暴露为材质的可配置属性。这种情况下,需要使用 Texture 3D 类型的 Property 节点,而不是 Texture 3D Asset 节点。两者的代码生成有所不同:

HLSL

// 通过Property暴露的3D纹理
TEXTURE3D(_CustomVolumeTexture);
SAMPLER(sampler_CustomVolumeTexture);
float4 _CustomVolumeTexture_ST; // 自动生成的缩放偏移参数

Property 节点会额外生成纹理的缩放偏移参数(_ST),这使得材质可以在运行时动态调整纹理的变换参数。理解这种区别对于选择正确的节点类型非常重要。

优化考虑

从生成的代码角度来看,有几个性能优化的考虑点。首先,尽可能复用同一个 Texture 3D Asset 节点,而不是创建多个引用相同资源的节点。这减少了着色器中纹理声明的数量,可能会带来微小的性能提升。

其次,注意纹理的采样器设置。通过 Unity 的采样器状态管理,可以确保相似的采样设置共享采样器状态,减少状态切换的开销。在 Shader Graph 中,这通常通过采样器节点的设置来控制。

最后,考虑到不同平台的特性,生成的代码可能会有所差异。在编写跨平台着色器时,应该测试在不同设备上的表现,确保性能特征符合预期。Unity 提供的帧调试器和渲染诊断工具可以帮助分析纹理相关的性能问题。

实际应用示例

基础体积材质创建

创建一个简单的体积材质是理解 Texture 3D Asset 节点用法的良好起点。假设我们需要创建一个具有内部结构的大理石材质,可以使用 3D 纹理来模拟石材内部的矿物分布。

首先在项目中准备或创建一个合适的 3D 纹理资源。这个纹理应该包含大理石内部结构的体积数据,通常可以通过程序化生成或从真实数据扫描获得。在 Unity 中导入这个纹理时,需要确保纹理类型设置为"3D Texture",并根据需要配置 mipmap、压缩格式等导入设置。

在 Shader Graph 中创建新的着色器图,添加 Texture 3D Asset 节点。通过节点的对象字段控件指定刚才导入的 3D 纹理资源。此时节点的 Out 端口已经可以输出对该纹理的引用。接下来添加 Sample Texture 3D 节点,将其 Texture 输入端口连接到 Texture 3D Asset 节点的输出。Sample Texture 3D 节点的 UVW 输入需要提供三维纹理坐标,这通常来自物体的世界位置或物体空间位置经过适当变换后的结果。

将 Sample Texture 3D 节点的 RGB 输出连接到主着色器的 Base Color 输入,就可以看到 3D 纹理对物体着色的基础效果。通过调整 Sample Texture 3D 节点的采样参数和 UVW 输入的变换方式,可以控制纹理在物体表面的表现方式和密度。

复杂效果组合

3D 纹理的真正威力在于与其他着色器功能的组合使用。例如,可以结合使用多个 Texture 3D Asset 节点来创建复杂的多层体积效果。

考虑一个高级的云层渲染示例。首先需要两个 3D 纹理:一个用于云层的基本密度分布,另一个用于云层的细节扰动。创建两个 Texture 3D Asset 节点分别引用这两个纹理资源。对基础密度纹理进行采样得到基本的云层形状,对细节纹理进行采样并使用时间变量进行动画化,然后将两者按照适当的比例混合。

这种技术的核心在于理解 3D 纹理采样坐标的变换和组合。基础纹理可以使用较大尺度的世界坐标,而细节纹理使用较小尺度的坐标并加上时间变量。通过适当的混合函数(如加法、乘法或屏幕混合)组合两个采样结果,可以创建出动态且富有细节的云层效果。

进一步地,可以将结果与光照计算结合。使用 3D 纹理采样结果作为体积散射计算的输入,结合方向光信息计算光照衰减和散射效果。这种技术能够产生非常逼真的体积光照,适用于烟雾、云层和其他参与介质效果的渲染。

性能优化实践

在使用 Texture 3D Asset 节点时,性能优化是一个重要的考虑因素。3D 纹理由于包含更多的数据量,通常比同等分辨率的 2D 纹理需要更多的内存和采样开销。

一个关键的优化策略是合理选择 3D 纹理的分辨率。对于不需要高频细节的体积数据,可以使用较低的分辨率。例如,64×64×64 的 3D 纹理在大多数情况下已经能够提供不错的质量,而内存占用只有同等 2D 纹理的 1/64(相对于 4096×4096 的 2D 纹理)。

另一个重要的优化是 mipmap 的使用。对于在深度方向有较大变化的体积效果,启用 mipmap 可以显著提高采样的缓存效率。但是需要注意 mipmap 会增加约 33% 的内存占用,需要在质量和性能之间做出权衡。

在 Shader Graph 中,可以通过适当组织节点结构来优化性能。例如,如果多个 Sample Texture 3D 节点使用相同的纹理但不同的采样参数,应该让它们共享同一个 Texture 3D Asset 节点,而不是每个采样节点都连接自己独立的纹理资源节点。这种共享减少了着色器中纹理绑定的数量,可能带来性能提升。

对于移动平台,还需要特别注意纹理压缩和格式选择。ASTC 压缩格式对 3D 纹理通常有较好的支持,可以在保持可接受质量的同时显著减少内存占用。同时,应该避免在片段着色器中进行过于复杂的 3D 纹理采样操作,特别是在低端移动设备上。

调试和问题排查

在使用 Texture 3D Asset 节点时可能会遇到各种问题,掌握有效的调试方法非常重要。一个常见的问题是纹理显示为粉色,这通常表示纹理资源丢失或类型不匹配。检查 Texture 3D Asset 节点的对象字段是否正确指定了 3D 纹理资源,并确认该资源在项目中确实存在且导入设置正确。

另一个常见问题是纹理采样结果不符合预期。这可能是由于采样坐标不正确造成的。可以通过可视化采样坐标来调试这个问题——将 UVW 坐标的各个分量分别输出为颜色,检查坐标范围是否合理。正常情况下,采样坐标应该在[0,1]范围内,超出这个范围的行为取决于纹理的 Wrap Mode 设置。

性能问题也是需要关注的重点。如果发现使用 3D 纹理后帧率显著下降,可以使用 Unity 的 Profiler 工具分析渲染耗时。特别关注纹理采样指令的数量和耗时,以及纹理内存的占用情况。如果发现问题,可以考虑降低纹理分辨率、优化采样次数或使用更高效的纹理格式。

对于高级用户,还可以使用 RenderDoc 等图形调试工具深入分析着色器的执行情况。这些工具可以显示每个绘制调用中纹理的实际绑定状态和采样结果,帮助定位复杂问题的根本原因。

最佳实践和高级技巧

资源管理策略

有效的资源管理是成功使用 Texture 3D Asset 节点的关键。首先,建立统一的 3D 纹理命名和组织规范。由于 3D 纹理在项目中可能不像 2D 纹理那样常见,清晰的组织结构可以避免混淆和提高工作效率。

在内存管理方面,注意 3D 纹理的加载和卸载时机。大型 3D 纹理可能会占用显著的内存空间,应该通过 Unity 的资源管理系统确保它们只在需要时加载。可以使用 Addressables 系统或传统的 Resources 文件夹来管理 3D 纹理的加载生命周期。

对于需要动态生成的 3D 纹理,Unity 提供了 Texture3D.Create 方法和支持 Compute Shader 的更新方式。这些高级用法允许在运行时生成或修改 3D 纹理。


【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Vue3 路由实战 | Vue Router 从 0 到 1 搭建权限管理系统

作者 代码煮茶
2026年3月28日 13:23

Vue3 路由实战 | Vue Router 从 0 到 1 搭建权限管理系统

零、为什么路由权限是企业级项目的“灵魂”?

你有没有遇到过这样的场景:

// 用户A登录后,看到了“用户管理”菜单
// 用户B登录后,菜单栏里没有“用户管理”

// 更离谱的是:用户B虽然看不到菜单,但直接输入URL:
// /user/manage
// 页面居然能打开!——这是巨大的安全漏洞!

企业级项目的核心诉求:用户能看到什么,取决于他有什么权限。这不只是UI层面的隐藏,更是路由层面的拦截。

今天,我们就来搭建一个完整的权限路由系统,包含:

  • 登录拦截
  • 动态路由生成
  • 菜单权限控制
  • 按钮级权限

一、路由基础:从0到1的快速回顾

1.1 安装与基础配置

npm install vue-router@4
// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'

// 静态路由(任何人都能访问)
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { title: '登录', requiresAuth: false }
  },
  {
    path: '/404',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue'),
    meta: { title: '404', requiresAuth: false }
  },
  {
    path: '/',
    redirect: '/dashboard',
    meta: { requiresAuth: true }
  },
  {
    path: '/dashboard',
    name: 'Dashboard',
    component: () => import('@/views/Dashboard.vue'),
    meta: { 
      title: '仪表盘', 
      icon: 'dashboard',
      requiresAuth: true,
      permissions: ['dashboard:view']  // 需要的权限
    }
  }
]

// 动态路由(根据权限动态添加)
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    name: 'User',
    component: () => import('@/layout/index.vue'),
    meta: { title: '用户管理', icon: 'user', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'UserList',
        component: () => import('@/views/user/List.vue'),
        meta: { 
          title: '用户列表', 
          permissions: ['user:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'role',
        name: 'RoleList',
        component: () => import('@/views/user/Role.vue'),
        meta: { 
          title: '角色管理', 
          permissions: ['role:list'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/product',
    name: 'Product',
    component: () => import('@/layout/index.vue'),
    meta: { title: '商品管理', icon: 'product', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'ProductList',
        component: () => import('@/views/product/List.vue'),
        meta: { 
          title: '商品列表', 
          permissions: ['product:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'category',
        name: 'CategoryList',
        component: () => import('@/views/product/Category.vue'),
        meta: { 
          title: '分类管理', 
          permissions: ['category:list'],
          requiresAuth: true 
        }
      }
    ]
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

export default router

1.2 路由元信息(meta)的妙用

// 定义路由元信息类型
declare module 'vue-router' {
  interface RouteMeta {
    title?: string          // 页面标题
    icon?: string           // 菜单图标
    requiresAuth?: boolean  // 是否需要登录
    permissions?: string[]  // 需要的权限列表
    hidden?: boolean        // 是否在菜单中隐藏
    keepAlive?: boolean     // 是否缓存
    breadcrumb?: boolean    // 是否显示面包屑
    activeMenu?: string     // 高亮的菜单(用于详情页)
  }
}

二、路由守卫:权限控制的守门员

2.1 全局前置守卫

// src/router/index.ts
import { useUserStore } from '@/stores/modules/user'
import { ElMessage } from 'element-plus'

// 白名单:不需要登录就能访问的页面
const whiteList = ['/login', '/404', '/register', '/forget-password']

router.beforeEach(async (to, from, next) => {
  // 设置页面标题
  document.title = to.meta.title ? `${to.meta.title} - 后台管理系统` : '后台管理系统'
  
  const userStore = useUserStore()
  const hasToken = userStore.token
  
  // 1. 如果有 token
  if (hasToken) {
    if (to.path === '/login') {
      // 已登录,访问登录页 → 重定向到首页
      next({ path: '/' })
    } else {
      // 检查是否已经获取过用户信息
      if (userStore.userInfo === null) {
        try {
          // 获取用户信息
          await userStore.fetchUserInfo()
          
          // 根据权限生成动态路由
          const accessRoutes = await generateRoutes(userStore.permissions)
          accessRoutes.forEach(route => {
            router.addRoute(route)
          })
          
          // 解决动态路由刷新后404问题
          next({ ...to, replace: true })
        } catch (error) {
          // token 无效,清除并跳转登录
          await userStore.logout()
          ElMessage.error('登录已过期,请重新登录')
          next(`/login?redirect=${to.path}`)
        }
      } else {
        // 已有用户信息,直接放行
        next()
      }
    }
  } 
  // 2. 没有 token
  else {
    if (whiteList.includes(to.path)) {
      // 在白名单中,直接放行
      next()
    } else {
      // 不在白名单,跳转登录页
      next(`/login?redirect=${to.path}`)
    }
  }
})

2.2 全局后置守卫

// 路由跳转完成后
router.afterEach((to, from) => {
  // 关闭页面加载动画
  // 上报页面访问数据
  // 等等...
  
  // 滚动到顶部(除了需要保持滚动位置的情况)
  if (to.hash) {
    const element = document.querySelector(to.hash)
    if (element) element.scrollIntoView()
  } else {
    window.scrollTo(0, 0)
  }
})

2.3 路由独享守卫

// 在路由配置中单独配置
{
  path: '/settings',
  component: () => import('@/views/Settings.vue'),
  beforeEnter: (to, from, next) => {
    // 检查用户是否有权限访问设置页面
    const userStore = useUserStore()
    if (userStore.userRole === 'admin') {
      next()
    } else {
      next('/403')
    }
  }
}

三、动态路由:根据权限生成菜单

3.1 生成动态路由的核心逻辑

// src/router/utils/dynamicRoutes.ts
import type { RouteRecordRaw } from 'vue-router'
import { asyncRoutes } from '@/router'

/**
 * 根据权限过滤路由
 * @param routes 路由列表
 * @param permissions 用户权限列表
 */
export function filterRoutesByPermissions(
  routes: RouteRecordRaw[],
  permissions: string[]
): RouteRecordRaw[] {
  return routes.filter(route => {
    // 检查当前路由是否需要权限
    if (route.meta?.permissions) {
      // 判断用户是否有任一所需权限
      const hasPermission = route.meta.permissions.some(perm => 
        permissions.includes(perm)
      )
      if (!hasPermission) return false
    }
    
    // 递归过滤子路由
    if (route.children) {
      route.children = filterRoutesByPermissions(route.children, permissions)
      // 如果子路由全部被过滤掉,则当前路由也不显示
      if (route.children.length === 0 && route.meta?.permissions) {
        return false
      }
    }
    
    return true
  })
}

/**
 * 将后端返回的权限树转换为路由
 * @param menus 后端返回的菜单树
 */
export function convertMenusToRoutes(menus: any[]): RouteRecordRaw[] {
  return menus.map(menu => {
    const route: RouteRecordRaw = {
      path: menu.path,
      name: menu.name,
      component: loadComponent(menu.component),
      meta: {
        title: menu.title,
        icon: menu.icon,
        permissions: menu.permissions,
        hidden: menu.hidden
      }
    }
    
    if (menu.children && menu.children.length > 0) {
      route.children = convertMenusToRoutes(menu.children)
    }
    
    return route
  })
}

/**
 * 懒加载组件
 */
function loadComponent(componentPath: string) {
  // 返回一个函数,Vue Router 会异步加载
  return () => import(`@/views/${componentPath}.vue`)
}

3.2 在路由守卫中生成动态路由

// src/router/index.ts
let hasAddedDynamicRoutes = false

router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore()
  const hasToken = userStore.token
  
  if (hasToken) {
    if (to.path === '/login') {
      next({ path: '/' })
    } else {
      if (!hasAddedDynamicRoutes && userStore.userInfo) {
        try {
          // 方式一:前端定义路由,根据权限过滤
          const accessRoutes = filterRoutesByPermissions(
            asyncRoutes, 
            userStore.permissions
          )
          
          // 方式二:后端返回路由,动态添加
          // const accessRoutes = convertMenusToRoutes(userStore.menus)
          
          // 添加动态路由
          accessRoutes.forEach(route => {
            router.addRoute(route)
          })
          
          // 添加404路由(必须放在最后)
          router.addRoute({
            path: '/:pathMatch(.*)*',
            name: 'NotFound',
            component: () => import('@/views/error/404.vue')
          })
          
          hasAddedDynamicRoutes = true
          
          // 重新跳转,确保路由已添加
          next({ ...to, replace: true })
        } catch (error) {
          console.error('生成动态路由失败:', error)
          await userStore.logout()
          next(`/login?redirect=${to.path}`)
        }
      } else {
        next()
      }
    }
  } else {
    // 没有 token 的处理...
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})

3.3 根据路由生成菜单

<!-- components/SidebarMenu.vue -->
<template>
  <el-menu
    :default-active="activeMenu"
    :collapse="isCollapse"
    :unique-opened="true"
    background-color="#304156"
    text-color="#bfcbd9"
    active-text-color="#409eff"
    router
  >
    <template v-for="route in menuRoutes" :key="route.path">
      <!-- 单级菜单 -->
      <el-menu-item 
        v-if="!route.children || route.children.length === 0"
        :index="route.path"
      >
        <el-icon><component :is="route.meta?.icon" /></el-icon>
        <template #title>
          <span>{{ route.meta?.title }}</span>
        </template>
      </el-menu-item>
      
      <!-- 多级菜单(递归) -->
      <el-sub-menu 
        v-else
        :index="route.path"
      >
        <template #title>
          <el-icon><component :is="route.meta?.icon" /></el-icon>
          <span>{{ route.meta?.title }}</span>
        </template>
        <sidebar-menu-item 
          v-for="child in route.children"
          :key="child.path"
          :route="child"
        />
      </el-sub-menu>
    </template>
  </el-menu>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { useAppStore } from '@/stores/modules/app'
import { useUserStore } from '@/stores/modules/user'
import type { RouteRecordRaw } from 'vue-router'

const route = useRoute()
const appStore = useAppStore()
const userStore = useUserStore()

const isCollapse = computed(() => appStore.sidebarCollapsed)
const activeMenu = computed(() => {
  const { path, meta } = route
  // 如果路由有 activeMenu 配置,则高亮指定菜单
  if (meta.activeMenu) {
    return meta.activeMenu
  }
  return path
})

// 获取需要显示的菜单路由
const menuRoutes = computed(() => {
  // 从 router 中获取动态添加的路由
  const routes = router.getRoutes()
  
  // 过滤掉不需要在菜单中显示的路由
  return routes.filter(route => {
    return route.meta?.title && !route.meta?.hidden
  })
})
</script>

四、路由懒加载:让首屏飞起来

4.1 基础懒加载

// 标准写法
const UserList = () => import('@/views/user/List.vue')

// 带 loading 的写法
const UserList = () => ({
  component: import('@/views/user/List.vue'),
  loading: LoadingComponent,
  error: ErrorComponent,
  delay: 200,
  timeout: 3000
})

4.2 路由分组(chunk)

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          // 将 Vue 相关打包在一起
          'vendor-vue': ['vue', 'vue-router', 'pinia'],
          // 将 UI 库单独打包
          'vendor-element': ['element-plus'],
          // 将工具库打包
          'vendor-utils': ['axios', 'dayjs', 'lodash-es'],
          // 将路由页面按模块分组
          'routes-user': [
            './src/views/user/List.vue',
            './src/views/user/Role.vue'
          ],
          'routes-product': [
            './src/views/product/List.vue',
            './src/views/product/Category.vue'
          ]
        }
      }
    }
  }
})

4.3 预加载策略

<!-- index.html 中添加预加载链接 -->
<link rel="prefetch" href="/assets/js/dashboard.xxx.js">
// 使用 webpack/vite 的魔法注释
const UserList = () => import(
  /* webpackChunkName: "user-list" */
  /* webpackPrefetch: true */
  '@/views/user/List.vue'
)

五、实战:后台管理系统完整路由模块

5.1 项目结构

src/
├── router/
│   ├── index.ts                 # 路由主文件
│   ├── modules/                 # 路由模块
│   │   ├── user.ts              # 用户模块路由
│   │   ├── product.ts           # 商品模块路由
│   │   └── order.ts             # 订单模块路由
│   ├── guards/                  # 路由守卫
│   │   ├── auth.ts              # 认证守卫
│   │   ├── permission.ts        # 权限守卫
│   │   └── progress.ts          # 进度条守卫
│   └── utils/                   # 路由工具
│       ├── dynamicRoutes.ts     # 动态路由生成
│       └── permissions.ts       # 权限过滤
├── layout/
│   ├── index.vue                # 主布局
│   ├── Sidebar.vue              # 侧边栏
│   └── Header.vue               # 头部
└── views/
    ├── login/
    │   └── index.vue
    ├── dashboard/
    │   └── index.vue
    ├── user/
    │   ├── List.vue
    │   └── Role.vue
    └── error/
        ├── 401.vue
        ├── 403.vue
        └── 404.vue

5.2 完整路由配置

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import type { Router, RouteRecordRaw } from 'vue-router'
import { useUserStore } from '@/stores/modules/user'
import { useAppStore } from '@/stores/modules/app'
import { ElMessage } from 'element-plus'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

// 配置进度条
NProgress.configure({ showSpinner: false })

// 静态路由
export const constantRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/login/index.vue'),
    meta: { title: '登录', requiresAuth: false }
  },
  {
    path: '/401',
    name: 'Unauthorized',
    component: () => import('@/views/error/401.vue'),
    meta: { title: '未授权', requiresAuth: false }
  },
  {
    path: '/403',
    name: 'Forbidden',
    component: () => import('@/views/error/403.vue'),
    meta: { title: '无权限', requiresAuth: false }
  },
  {
    path: '/404',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue'),
    meta: { title: '页面不存在', requiresAuth: false }
  },
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/dashboard',
    meta: { requiresAuth: true },
    children: [
      {
        path: 'dashboard',
        name: 'Dashboard',
        component: () => import('@/views/dashboard/index.vue'),
        meta: { 
          title: '仪表盘', 
          icon: 'Odometer',
          affix: true,
          requiresAuth: true 
        }
      }
    ]
  }
]

// 动态路由(需要权限)
export const asyncRoutes: RouteRecordRaw[] = [
  {
    path: '/user',
    component: () => import('@/layout/index.vue'),
    meta: { title: '用户管理', icon: 'User', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'UserList',
        component: () => import('@/views/user/List.vue'),
        meta: { 
          title: '用户列表', 
          permissions: ['user:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'role',
        name: 'RoleList',
        component: () => import('@/views/user/Role.vue'),
        meta: { 
          title: '角色管理', 
          permissions: ['role:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'permission',
        name: 'PermissionList',
        component: () => import('@/views/user/Permission.vue'),
        meta: { 
          title: '权限管理', 
          permissions: ['permission:list'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/product',
    component: () => import('@/layout/index.vue'),
    meta: { title: '商品管理', icon: 'Goods', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'ProductList',
        component: () => import('@/views/product/List.vue'),
        meta: { 
          title: '商品列表', 
          permissions: ['product:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'category',
        name: 'CategoryList',
        component: () => import('@/views/product/Category.vue'),
        meta: { 
          title: '分类管理', 
          permissions: ['category:list'],
          requiresAuth: true 
        }
      },
      {
        path: 'detail/:id',
        name: 'ProductDetail',
        component: () => import('@/views/product/Detail.vue'),
        meta: { 
          title: '商品详情', 
          hidden: true,  // 不在菜单中显示
          activeMenu: '/product/list', // 高亮商品列表菜单
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/order',
    component: () => import('@/layout/index.vue'),
    meta: { title: '订单管理', icon: 'Document', requiresAuth: true },
    children: [
      {
        path: 'list',
        name: 'OrderList',
        component: () => import('@/views/order/List.vue'),
        meta: { 
          title: '订单列表', 
          permissions: ['order:list'],
          keepAlive: true,
          requiresAuth: true 
        }
      },
      {
        path: 'refund',
        name: 'RefundList',
        component: () => import('@/views/order/Refund.vue'),
        meta: { 
          title: '退款管理', 
          permissions: ['order:refund'],
          requiresAuth: true 
        }
      }
    ]
  },
  {
    path: '/settings',
    component: () => import('@/layout/index.vue'),
    meta: { title: '系统设置', icon: 'Setting', requiresAuth: true, roles: ['admin'] },
    children: [
      {
        path: 'profile',
        name: 'Profile',
        component: () => import('@/views/settings/Profile.vue'),
        meta: { title: '个人设置', requiresAuth: true }
      },
      {
        path: 'account',
        name: 'Account',
        component: () => import('@/views/settings/Account.vue'),
        meta: { title: '账号管理', roles: ['admin'], requiresAuth: true }
      }
    ]
  }
]

// 创建路由实例
const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes,
  scrollBehavior(to, from, savedPosition) {
    if (savedPosition) {
      return savedPosition
    } else {
      return { top: 0 }
    }
  }
})

// 标记是否已添加动态路由
let hasAddedRoutes = false

// 生成动态路由
async function generateDynamicRoutes(permissions: string[], roles: string[]) {
  // 根据权限过滤路由
  const filterRoutes = (routes: RouteRecordRaw[]): RouteRecordRaw[] => {
    return routes.filter(route => {
      // 检查角色权限
      if (route.meta?.roles && !route.meta.roles.some((role: string) => roles.includes(role))) {
        return false
      }
      
      // 检查按钮权限
      if (route.meta?.permissions) {
        const hasPermission = route.meta.permissions.some((perm: string) => 
          permissions.includes(perm)
        )
        if (!hasPermission) return false
      }
      
      // 递归过滤子路由
      if (route.children) {
        route.children = filterRoutes(route.children)
        if (route.children.length === 0 && route.meta?.permissions) {
          return false
        }
      }
      
      return true
    })
  }
  
  const accessibleRoutes = filterRoutes(asyncRoutes)
  
  // 动态添加路由
  accessibleRoutes.forEach(route => {
    router.addRoute(route)
  })
  
  // 添加404兜底路由
  router.addRoute({
    path: '/:pathMatch(.*)*',
    name: 'NotFound',
    component: () => import('@/views/error/404.vue')
  })
  
  return accessibleRoutes
}

// 全局前置守卫
router.beforeEach(async (to, from, next) => {
  // 开始进度条
  NProgress.start()
  
  const userStore = useUserStore()
  const appStore = useAppStore()
  const hasToken = userStore.token
  
  // 设置页面标题
  if (to.meta.title) {
    document.title = `${to.meta.title} - ${appStore.siteTitle}`
  }
  
  if (hasToken) {
    // 已登录
    if (to.path === '/login') {
      // 跳转到首页
      next({ path: '/' })
      NProgress.done()
    } else {
      // 检查是否已获取用户信息
      if (userStore.userInfo === null) {
        try {
          // 获取用户信息
          await userStore.fetchUserInfo()
          
          // 生成动态路由
          const routes = await generateDynamicRoutes(
            userStore.permissions,
            userStore.roles
          )
          
          // 保存路由到 store(用于生成菜单)
          userStore.setRoutes(routes)
          
          // 解决动态路由刷新后404问题
          next({ ...to, replace: true })
        } catch (error) {
          console.error('路由初始化失败:', error)
          await userStore.logout()
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      } else {
        // 检查路由权限
        if (to.meta.requiresAuth) {
          // 检查角色权限
          if (to.meta.roles && !to.meta.roles.some(role => userStore.roles.includes(role))) {
            next('/403')
            NProgress.done()
            return
          }
          
          // 检查按钮权限
          if (to.meta.permissions) {
            const hasPermission = to.meta.permissions.some(perm => 
              userStore.permissions.includes(perm)
            )
            if (!hasPermission) {
              next('/403')
              NProgress.done()
              return
            }
          }
        }
        next()
      }
    }
  } else {
    // 未登录
    if (to.meta.requiresAuth) {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    } else {
      next()
    }
  }
})

// 全局后置守卫
router.afterEach(() => {
  // 结束进度条
  NProgress.done()
})

// 重置路由(用于退出登录)
export function resetRouter() {
  // 获取所有动态添加的路由
  const routes = router.getRoutes()
  routes.forEach(route => {
    const name = route.name as string
    // 排除静态路由
    if (!constantRoutes.some(r => r.name === name)) {
      router.removeRoute(name)
    }
  })
  hasAddedRoutes = false
}

export default router

5.3 登录页面实现

<!-- views/login/index.vue -->
<template>
  <div class="login-container">
    <el-form
      ref="loginFormRef"
      :model="loginForm"
      :rules="loginRules"
      class="login-form"
    >
      <h3 class="title">后台管理系统</h3>
      
      <el-form-item prop="username">
        <el-input
          v-model="loginForm.username"
          placeholder="用户名"
          :prefix-icon="User"
          size="large"
        />
      </el-form-item>
      
      <el-form-item prop="password">
        <el-input
          v-model="loginForm.password"
          type="password"
          placeholder="密码"
          :prefix-icon="Lock"
          size="large"
          show-password
          @keyup.enter="handleLogin"
        />
      </el-form-item>
      
      <el-form-item>
        <el-button
          :loading="loading"
          type="primary"
          size="large"
          class="login-btn"
          @click="handleLogin"
        >
          登录
        </el-button>
      </el-form-item>
      
      <div class="tips">
        <span>测试账号:admin / 123456</span>
        <span class="ml-10">普通账号:user / 123456</span>
      </div>
    </el-form>
  </div>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { User, Lock } from '@element-plus/icons-vue'
import { useUserStore } from '@/stores/modules/user'

const router = useRouter()
const route = useRoute()
const userStore = useUserStore()

const loginForm = reactive({
  username: 'admin',
  password: '123456'
})

const loginRules = {
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
  ]
}

const loginFormRef = ref()
const loading = ref(false)

const handleLogin = async () => {
  if (!loginFormRef.value) return
  
  await loginFormRef.value.validate(async (valid: boolean) => {
    if (!valid) return
    
    loading.value = true
    try {
      const success = await userStore.login(loginForm)
      if (success) {
        const redirect = route.query.redirect as string || '/'
        router.push(redirect)
        ElMessage.success('登录成功')
      }
    } catch (error) {
      console.error('登录失败:', error)
    } finally {
      loading.value = false
    }
  })
}
</script>

<style scoped lang="scss">
.login-container {
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  
  .login-form {
    width: 400px;
    padding: 40px;
    background: #fff;
    border-radius: 8px;
    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
    
    .title {
      text-align: center;
      margin-bottom: 30px;
      color: #333;
    }
    
    .login-btn {
      width: 100%;
    }
    
    .tips {
      text-align: center;
      color: #999;
      font-size: 12px;
      
      span {
        display: inline-block;
      }
      
      .ml-10 {
        margin-left: 10px;
      }
    }
  }
}
</style>

5.4 按钮级权限指令

// src/directives/permission.ts
import type { App, Directive } from 'vue'
import { useUserStore } from '@/stores/modules/user'

// 权限指令 v-permission="['user:add']"
const permissionDirective: Directive = {
  mounted(el, binding) {
    const { value } = binding
    const userStore = useUserStore()
    
    if (value && Array.isArray(value) && value.length > 0) {
      const hasPermission = value.some(perm => 
        userStore.permissions.includes(perm)
      )
      
      if (!hasPermission) {
        el.parentNode?.removeChild(el)
      }
    }
  }
}

export function setupPermissionDirective(app: App) {
  app.directive('permission', permissionDirective)
}
<!-- 在组件中使用 -->
<template>
  <div>
    <!-- 只有拥有 user:add 权限才能看到添加按钮 -->
    <el-button v-permission="['user:add']" type="primary">
      添加用户
    </el-button>
    
    <!-- 拥有任一权限即可 -->
    <el-button v-permission="['user:edit', 'user:delete']">
      操作
    </el-button>
  </div>
</template>

六、进阶:路由缓存与标签页

6.1 多标签页功能

// stores/modules/tabs.ts
import { defineStore } from 'pinia'
import type { RouteLocationNormalized } from 'vue-router'

interface TabItem {
  name: string
  title: string
  path: string
  query?: Record<string, any>
  params?: Record<string, any>
}

export const useTabsStore = defineStore('tabs', {
  state: () => ({
    visitedTabs: [] as TabItem[],
    activeTab: ''
  }),
  
  actions: {
    addTab(route: RouteLocationNormalized) {
      // 过滤掉不需要缓存的路由
      if (route.meta?.hidden || route.meta?.noCache) return
      
      const tab: TabItem = {
        name: route.name as string,
        title: route.meta?.title as string,
        path: route.path,
        query: route.query,
        params: route.params
      }
      
      const exists = this.visitedTabs.some(item => item.path === tab.path)
      if (!exists) {
        this.visitedTabs.push(tab)
      }
      
      this.activeTab = tab.path
    },
    
    removeTab(path: string) {
      const index = this.visitedTabs.findIndex(tab => tab.path === path)
      if (index > -1) {
        this.visitedTabs.splice(index, 1)
      }
      
      // 如果删除的是当前激活的标签,跳转到上一个标签
      if (this.activeTab === path) {
        const lastTab = this.visitedTabs[index - 1] || this.visitedTabs[0]
        if (lastTab) {
          this.activeTab = lastTab.path
          return lastTab
        }
      }
      return null
    },
    
    closeOtherTabs(path: string) {
      this.visitedTabs = this.visitedTabs.filter(tab => tab.path === path)
      this.activeTab = path
    },
    
    closeAllTabs() {
      this.visitedTabs = []
      this.activeTab = ''
    }
  }
})

七、常见问题与解决方案

7.1 动态路由刷新后404

// 问题:刷新页面后,动态添加的路由丢失
// 解决:在路由守卫中重新添加

router.beforeEach(async (to, from, next) => {
  // ... 省略其他代码
  
  if (!hasAddedRoutes && userStore.userInfo) {
    // 重新添加动态路由
    await generateDynamicRoutes(userStore.permissions, userStore.roles)
    // 关键:replace 当前路由,重新触发守卫
    next({ ...to, replace: true })
    return
  }
  
  next()
})

7.2 路由权限缓存

// 使用 sessionStorage 缓存用户路由
const cacheKey = `user-routes-${userStore.userId}`

// 保存
sessionStorage.setItem(cacheKey, JSON.stringify(accessibleRoutes))

// 恢复
const cachedRoutes = sessionStorage.getItem(cacheKey)
if (cachedRoutes) {
  const routes = JSON.parse(cachedRoutes)
  routes.forEach(route => router.addRoute(route))
}

八、总结

一个完整的权限路由系统包含:

  1. 静态路由:登录页、404页等公共页面
  2. 动态路由:根据权限动态添加
  3. 路由守卫:登录拦截、权限校验
  4. 菜单生成:根据路由自动生成侧边栏
  5. 权限指令:按钮级权限控制
  6. 路由缓存:标签页、keep-alive

核心代码量统计

  • 路由配置文件:~200行
  • 动态路由逻辑:~100行
  • 路由守卫:~150行
  • 菜单组件
❌
❌