阅读视图

发现新文章,点击刷新页面。

“破案”笔记:iframe动态加载内容后,打印功能为何失灵?

“破案”笔记:iframe动态加载内容后,打印功能为何失灵?

案件概述

异常现象:当我用 iframe.srcdoc动态生成一个报告页面,并想自动调起打印时,打印窗口死活不弹出来,打印完成的回调函数也永远不会执行。代码看起来没问题,但就是无效。

初步怀疑:是不是 srcdoc把我刚绑定的事件监听器给“冲走了”?


第一现场:重现“案发”过程

这是当时“案发”的代码片段:

// 1. 给 iframe 灌入新内容
let frame = document.getElementById('myFrame');
frame.srcdoc = `<h1>我的报告</h1><p>请打印我</p>`;

// 2. 立刻绑定打印完成后的回调
frame.contentWindow.addEventListener('afterprint', function() {
  console.log('打印完成!'); // 🚨 这条日志从未出现!
});

// 3. 立刻下令打印
frame.contentWindow.print(); // 🚨 打印窗口毫无反应!

直观感受:代码执行了,但像石沉大海,没有任何效果和报错。


侦查实验:逐一排除嫌疑

我们做了几个关键实验来排查。

实验一:事件监听器真的被“冲走了”吗?

我们在设置新内容前后,绑定一个自己能控制的“信号弹”(自定义事件)。

frame.addEventListener('信号弹', () => console.log('监听器A在'));
frame.srcdoc = `<h1>新内容</h1>`;
frame.addEventListener('信号弹', () => console.log('监听器B也在'));

// 发射信号弹
frame.dispatchEvent(new Event('信号弹'));
// 控制台输出:监听器A在 | 监听器B也在

✅ 结论:监听器没有消失。两个都还在正常工作。所以“冲走监听器”的嫌疑被排除了。

实验二:如果等一会儿再打印呢?

我们怀疑是不是命令下得太急了。

frame.srcdoc = `<h1>新内容</h1>`;
setTimeout(() => {
  frame.contentWindow.print(); // 🕐 延迟1秒后:打印窗口弹出了!
  console.log('打印调用成功,但 afterprint 仍不触发');
}, 1000);

⚠️ 新发现等待足够时间后,打印命令能执行了,但 afterprint事件依然不触发。 这说明事件绑定的时机可能也有问题。

实验三:找到那个“正确时机”

我们尝试在 iframe 自己宣布“我准备好了”的时候再行动。

frame.srcdoc = `<h1>新内容</h1>`;

// 监听 iframe 的“准备好”信号
frame.onload = function() {
  // 等它喊“准备好”了,我们再绑定和打印
  frame.contentWindow.addEventListener('afterprint', function() {
    console.log('✅✅✅ 打印完成!'); // 这次成功了!
  });
  frame.contentWindow.print(); // 打印窗口正常弹出
};

✅ 决定性证据:在 onload事件里操作,一切完全正常


案情复盘:到底发生了什么?

我们可以把 iframe.srcdoc = ‘...’这个过程,想象成给一个房间(iframe)进行彻底的重装修

  1. 拆旧:浏览器先把房间里(iframe 内)所有旧的家具、管道(旧的文档、窗口)全清空。

  2. 异步装修:然后开始根据你给的新图纸(HTML字符串)异步施工。这需要时间,水电、墙面、家具都在同步安排。

  3. 施工中:在装修队喊“完工啦!”(触发 load事件)之前,这个房间处于施工状态

    • 你对着一个还在铺水泥的墙面(不稳定的内部窗口)喊“打印!”(print()),工人会无视你。
    • 你告诉一面还没砌好的墙“打印完喊我一声”(绑 afterprint),这个请求可能会丢失。
  4. 竣工:只有等 onload事件触发,才代表房间完全装修好,水电全通,可以正式投入使用。这时你的所有指令都能被正确接收和执行。

所以,核心不是监听器被“删除”,而是你对着一个“半成品”发出了指令。


解决方案:两个可靠的行动指南

方案一:等待“竣工典礼”(最推荐)

做法:用 srcdoc设置内容,但所有操作都放到 iframe.onload回调函数里。

优点:逻辑清晰,是现代 API 的标准用法。

iframe.srcdoc = ‘你的HTML内容’;
iframe.onload = function() {
  // 在这里进行所有“室内操作”
  iframe.contentWindow.addEventListener(‘afterprint’, 你的回调);
  iframe.contentWindow.print();
};

方案二:使用“魔法瞬间重建”

做法:不用 srcdoc,改用传统的 document.write()来同步写入内容。

原理document.write()会在你写下内容的同一时刻,同步、立即地重建整个文档,没有“施工中”的等待期。写完后立即可用。

优点:无需等待 onload,立即生效。

let doc = iframe.contentWindow.document;
doc.open();
doc.write(‘你的完整HTML内容’); // 魔法发生,内容瞬间被替换
doc.close();
// 紧接着就可以操作,因为文档已经就绪
iframe.contentWindow.print();

构建无障碍组件之Alert Dialog Pattern

Alert Dialog Pattern 详解:构建无障碍中断式对话框

Alert Dialog 是 Web 无障碍交互的重要组件。本文详解其 WAI-ARIA 实现要点,涵盖角色声明、键盘交互、最佳实践,助你打造中断式对话框,让关键信息触达每位用户。

一、Alert Dialog 的定义与核心功能

Alert Dialog(警告对话框)是一种模态对话框,它会中断用户的工作流程以传达重要信息并获取响应。与普通的 Alert 通知不同,Alert Dialog 需要用户明确与之交互后才能继续其他操作。这种设计适用于需要用户立即关注和做出决定的场景。

在实际应用中,Alert Dialog 广泛应用于各种需要用户确认或紧急通知的场景。例如,删除操作前的确认提示、表单提交失败的错误确认、离开页面时的未保存更改提醒等。这些场景都需要用户明确响应才能继续操作,因此 Alert Dialog 成为最佳选择。

二、Alert Dialog 的特性与注意事项

Alert Dialog 组件具有几个重要的特性,这些特性决定了它的适用场景和实现方式。首先,Alert Dialog 会获取键盘焦点,确保用户的注意力集中在对话框上。其次,Alert Dialog 通常会阻止用户与页面的其他部分交互,直到用户关闭对话框。这种模态特性确保了用户必须处理重要信息才能继续操作。

Alert Dialog 组件的设计还需要考虑几个关键因素。首先,Alert Dialog 应该始终包含一个明确的关闭方式,如确认按钮或取消按钮。其次,对话框应该有一个清晰的标题,通过 aria-labelledbyaria-label 关联。另外,对话框的内容应该通过 aria-describedby 关联,以便屏幕阅读器能够正确读取完整信息。这些属性的正确使用对于无障碍体验至关重要。

三、WAI-ARIA 角色、状态和属性

正确使用 WAI-ARIA 属性是构建无障碍 Alert Dialog 组件的技术基础。Alert Dialog 组件的 ARIA 要求包含多个属性的配合使用。

role="alertdialog" 是 Alert Dialog 组件的必需属性,它向辅助技术表明这个元素是一个警告对话框。这个属性使浏览器和辅助技术能够将 Alert Dialog 与其他类型的对话框区分开来,从而提供特殊的处理方式,如播放系统提示音。

aria-labelledbyaria-label 用于标识对话框的标题。如果对话框有可见的标题标签,应该使用 aria-labelledby 引用该标题元素;如果没有可见标题,则使用 aria-label 提供标签。

aria-describedby 用于引用包含警告消息的元素。这确保屏幕阅读器能够朗读完整的对话框内容,包括详细的说明和操作提示。

<!-- Alert Dialog 基本结构 -->
<dialog
  id="confirm-dialog"
  role="alertdialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <form method="dialog">
    <h2 id="dialog-title">确认删除</h2>
    <p id="dialog-desc">您确定要删除这个文件吗?此操作无法撤销。</p>
    <div class="actions">
      <button value="confirm">确认删除</button>
      <button value="cancel">取消</button>
    </div>
  </form>
</dialog>

值得注意的是,Alert Dialog 与普通 Dialog 的主要区别在于 Alert Dialog 用于紧急或重要信息,并且通常包含确认/取消按钮。用户无法忽略 Alert Dialog,必须做出响应才能继续操作。

四、键盘交互规范

Alert Dialog 的键盘交互遵循模态对话框的交互模式。用户可以通过多种方式与 Alert Dialog 进行交互。

  • EnterSpace 用于激活默认按钮,通常是对话框中的主要操作按钮。
  • Tab 键用于在对话框内的焦点元素之间切换,焦点会循环停留 在对话框内部。
  • Escape 键通常用于关闭对话框,相当于点击取消按钮。
// ESC 键关闭对话框示例
document.addEventListener('keydown', function (e) {
  if (e.key === 'Escape' && dialog.open) {
    dialog.close();
  }
});

焦点管理是 Alert Dialog 的关键部分。当对话框打开时,焦点应该立即移动到对话框内部或默认按钮上。当对话框关闭时,焦点应该返回到打开对话框的元素。这种焦点管理确保了键盘用户能够保持其工作上下文。

五、完整示例

以下是一个完整的 Alert Dialog 实现示例,展示了正确的 HTML 结构、ARIA 属性和焦点管理。

<dialog
  id="confirm-dialog"
  role="alertdialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <form method="dialog">
    <h2 id="dialog-title">确认删除</h2>
    <p id="dialog-desc">您确定要删除这个文件吗?此操作无法撤销。</p>
    <div class="dialog-actions">
      <button
        class="btn btn-ghost"
        value="cancel">
        取消
      </button>
      <button
        class="btn btn-error"
        value="confirm">
        删除
      </button>
    </div>
  </form>
</dialog>

<button
  id="delete-btn"
  class="btn btn-error">
  删除文件
</button>

<script>
  const dialog = document.getElementById('confirm-dialog');
  const deleteBtn = document.getElementById('delete-btn');
  let previousActiveElement;

  deleteBtn.addEventListener('click', function () {
    previousActiveElement = document.activeElement;
    dialog.showModal();
  });

  dialog.addEventListener('close', function () {
    if (dialog.returnValue === 'confirm') {
      console.log('文件已删除');
    }
    previousActiveElement.focus();
  });
</script>

六、最佳实践

6.1 实现方式对比

Alert Dialog 可以通过两种方式实现:使用 div 配合 ARIA 属性,或使用原生 <dialog> 元素。

传统方式(div + ARIA)
<div
  role="alertdialog"
  aria-modal="true"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <h2 id="dialog-title">确认删除</h2>
  <p id="dialog-desc">您确定要删除这个文件吗?</p>
  <button>确认</button>
  <button>取消</button>
</div>

这种方式需要开发者手动处理焦点管理、ESC 键关闭、背景锁定等逻辑。

推荐方式(原生 dialog)
<dialog
  role="alertdialog"
  aria-labelledby="dialog-title"
  aria-describedby="dialog-desc">
  <form method="dialog">
    <h2 id="dialog-title">确认删除</h2>
    <p id="dialog-desc">您确定要删除这个文件吗?</p>
    <button value="confirm">确认</button>
    <button value="cancel">取消</button>
  </form>
</dialog>

HTML 原生 <dialog> 元素简化了实现,它提供了:

  • 自动焦点管理
  • 内置 ESC 键支持
  • 自动模态背景
  • 内置 ARIA 属性

<dialog> 元素的默认 roledialog,表示普通对话框。对于 Alert Dialog,需要显式设置 role="alertdialog" 来告诉辅助技术这是一个需要紧急处理的对话框,从而获得系统提示音等特殊处理。

6.2 焦点管理

正确的焦点管理对于键盘用户和无障碍体验至关重要。打开对话框时,焦点应该移动到对话框内部或默认按钮。关闭对话框时,焦点应该返回到触发对话框的元素。

// 焦点管理最佳实践
function openDialog(dialog) {
  const previousFocus = document.activeElement;
  dialog.showModal();

  // 移动焦点到对话框内
  const focusableElements = dialog.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
  );
  if (focusableElements.length > 0) {
    focusableElements[0].focus();
  }

  // 保存关闭时的焦点元素
  dialog.dataset.previousFocus = previousFocus;
}

function closeDialog(dialog) {
  dialog.close();
  const previousFocus = document.querySelector(
    `[data-focus-id="${dialog.dataset.focusId}"]`,
  );
  if (previousFocus) {
    previousFocus.focus();
  }
  dialog.remove();
}

6.3 避免过度使用

Alert Dialog 会中断用户的工作流程,因此应该谨慎使用。只有在真正需要用户立即响应的情况下才使用 Alert Dialog。对于非紧急信息,应该考虑使用普通的 Alert 或 Toast 通知。

<!-- 不推荐:过度使用 Alert Dialog -->
<dialog
  open
  role="alertdialog">
  <h2>提示</h2>
  <p>您的设置已保存。</p>
  <button onclick="this.closest('dialog').close()">确定</button>
</dialog>

<!-- 推荐:使用普通 Alert -->
<div role="alert">您的设置已保存。</div>

6.4 屏幕阅读器兼容性

确保 <dialog> 对屏幕阅读器用户友好。<dialog> 元素内置了无障碍支持,但仍然建议对 Alert Dialog 设置 role="alertdialog" 来区分紧急对话框。

<!-- 屏幕阅读器友好的 dialog -->
<dialog
  id="session-dialog"
  role="alertdialog">
  <form method="dialog">
    <h2>重要提醒</h2>
    <p>您的会话将在 5 分钟后过期。请尽快保存您的工作。</p>
    <div class="actions">
      <button value="continue">继续使用</button>
      <button value="exit">退出</button>
    </div>
  </form>
</dialog>

七、Alert 与 Alert Dialog 的区别

理解 AlertAlert Dialog 的区别对于正确选择通知组件至关重要。虽然两者都是用于传达重要信息,但它们服务于不同的目的和使用场景。

Alert 是一种被动通知组件,它不需要用户进行任何交互操作。Alert 会在不被中断用户工作流程的前提下自动通知用户重要信息。用户可以继续当前的工作,Alert 只是在视觉和听觉上提供通知。这种设计适用于不紧急、不需要用户立即响应的信息,例如操作成功确认、后台处理完成通知等。

Alert Dialog 则是一种需要用户主动响应的对话框组件。当用户需要做出决定或者提供确认时,应该使用 Alert Dialog。Alert Dialog 会中断用户的工作流程,获取键盘焦点,要求用户必须与之交互才能继续其他操作。这种设计适用于紧急警告、确认删除操作、放弃更改确认等需要用户明确响应的场景。

选择建议:如果信息需要用户立即响应并做出决定,使用 Alert Dialog;如果只是被动通知信息,使用 Alert。

八、总结

构建无障碍的对话框组件需要关注元素选择、焦点管理、键盘交互三个层面的细节。从元素选择角度,推荐优先使用原生 <dialog> 元素,它内置了无障碍支持和焦点管理。从焦点管理角度,需要确保打开和关闭时焦点的正确移动。从用户体验角度,应该避免过度使用对话框,只在真正需要用户响应时使用。

WAI-ARIA Alert Dialog Pattern 为我们提供了清晰的指导方针,遵循这些规范能够帮助我们创建更加包容和易用的 Web 应用。每一个正确实现的对话框,都是提升用户体验和确保重要信息有效传达的重要一步。

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

Koa.js 教程 | 一份不可多得的 Node.js 的 Web 框架 Koa.js 教程

第一章 安装和配置 koa

Koa 是一个轻量级、现代化的框架, 由 Express 原班人马开发

初始化配置文件 package.json

npm init -y

配置 package.json (ESM规范)

{
     "type": "module",
     "name": "demo",
     "version": "1.0.0",
     "main": "index.js",
     "scripts": {
          "dev":"nodemon index.js",
           "test": "echo \"Error: no test specified\" && exit 1"
     },
     "keywords": [],
     "author": "",
     "license": "ISC",
     "description": ""
}

npm 官网

     www.npmjs.com

安装koa      

npm i koa

     全局安装 nodemon

  .  npm i nodemon -g

     当 nodemon 检测到监视的文件发生更改时, 会自动重新启动应用

第二章 创建并启动 http 服务器

中间件

中间件是处理 HTTP 请求和响应的函数,它们可以做以下操作:

  • 处理请求(例如解析请求体、验证用户身份等)
  • 修改响应(例如设置响应头、发送响应体等)
  • 执行后续中间件

中间件 - 很重要的概念 !!!!!!!

注意 : app.use() 方法用于注册 中间件

中间件 是处理 http 请求和响应的函数 , 当一个请求到达服务器时, 会从第一个中间件开始执行, 直到最后一个中间件

上下文对象 ctx

在 Koa 中,ctx(上下文)对象是每个中间件函数的核心,它包含了请求和响应的所有信息。所有的 HTTP 请求和响应都通过 ctx 进行处理。

上下文对象 ctx ( context ) 包含了与当前 http 请求相关的所有信息

如: http方法、url、请求头、请求体、查询参数等

import Koa from 'koa'

const hostname = "127.0.0.1" //服务器监听的ip地址
const port = 8008 //服务器监听的端口号

/*
    实例化一个 Koa 对象
    实例化是指根据一个类创建具体对象的过程
*/
const app = new Koa()

app.use(async ctx => {
    ctx.body = "juejin.cn" // 使用 ctx.body 设置响应体的内容
})

//启动 http 服务器, 并在指定的ip地址(127.0.0.1)和端口(8008)上监听连接请求
app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第三章 洋葱模型

洋葱模型

当你处理一个请求时,

可以想象成是在 "剥洋葱" ,从外向内一层一层地往里剥,直到剥到中心部分

这个过程涉及对 请求 的多个层面进行解析、验证、处理

在处理完洋葱(请求)后,

构建 响应 的过程就像是从精心准备的食材 ( 处理请求 后得到的数据) 开始,

从内向外逐层添加调料(格式化、封装等),最终形成一道色香味俱佳的菜肴(响应)

image.png

import Koa from 'koa'

const hostname = "127.0.0.1" //服务器监听的ip地址
const port = 8008 //服务器监听的端口号

/*
    实例化一个 Koa 对象
    实例化是指根据一个类创建具体对象的过程
*/
const app = new Koa()

/*
    app.use() 方法用于注册中间件
    中间件是处理 http 请求和响应的函数
    当一个请求到达服务器时, 会从第一个中间件开始执行, 直到最后一个中间件
    
    上下文对象 ctx(context) 包含了与当前 http 请求相关的所有信息
    如: http方法、url、请求头、请求体、查询参数等
*/
app.use(async (ctx,next) => {
    console.log(1)
    await next() //若中间件调用了next(),会暂停当前中间件的执行,将控制权传递给下一个中间件
    console.log(2)
})

app.use(async (ctx,next) => { 
    console.log(3)
    await next()
    console.log(4)
})

//当中间件没有再调用next(),则不需要再将控制权传递给下一个中间件,控制权会按照相反的顺序执行
app.use(async (ctx,next) => {
    console.log(5)
    ctx.body = "dengruicode.com" // 使用 ctx.body 设置响应体的内容
})

//启动 http 服务器, 并在指定的ip地址(127.0.0.1)和端口(8008)上监听连接请求
app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第四章 安装和配置路由 - get请求

在 Koa 中,koa-router 是一个轻量级的路由中间件,它可以帮助你定义路由、处理 HTTP 请求并解析请求参数。通过使用 koa-router,你可以创建一个灵活的路由系统,轻松地组织和管理 Koa 应用的各个部分。

安装 koa-router

首先,你需要安装 koa-router

npm install @koa/router       # 注意:新版 koa-router 包名是 @koa/router
import Koa from 'koa'
import Router from '@koa/router'

const hostname = "127.0.0.1"
const port = 8008

const app = new Koa()
const router = new Router() //实例化一个 Router 对象

//------ get请求
//路由是根据客户端发送的请求(包括请求的路径、方法等)调用与之匹配的处理函数
//根路由 http://127.0.0.1:8008/
router.get('/', async ctx => { //get请求
    ctx.body = "dengruicode.com"
})

//查询参数 http://127.0.0.1:8008/test?id=001&web=dengruicode.com
router.get('/test', async ctx => { //get请求
    let id = ctx.query.id
    let web = ctx.query.web
    ctx.body = id + " : " + web
})

//路径参数 http://127.0.0.1:8008/test2/id/002/web/www.dengruicode.com
router.get('/test2/id/:id/web/:web', async ctx => {
    let id = ctx.params.id
    let web = ctx.params.web
    ctx.body = id + " : " + web
})

//重定向路由 http://127.0.0.1:8008/test3
router.redirect('/test3', 'https://www.baidu.com')

app.use(router.routes()) //将定义在 router 对象中的路由规则添加到 app 实例中

//------ 路由分组
//http://127.0.0.1:8008/user/add
//http://127.0.0.1:8008/user/del

const userRouter = new Router({ prefix: '/user' })
userRouter.get('/add', async ctx => {
    ctx.body = "添加用户"
})
userRouter.get('/del', async ctx => {
    ctx.body = "删除用户"
})
app.use(userRouter.routes())

// 在所有路由之后添加404处理函数
app.use(async ctx => {
    if (!ctx.body) { //若没有设置 ctx.body, 则说明没有到匹配任何路由
        ctx.status = 404
        ctx.body = '404 Not Found'
    }
})

app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第五章 post请求

安装 koa-body

Koa 原生不支持解析 POST 请求体,需安装 koa-body 中间件:

npm install koa-body

POST 请求处理示例

修改 src/index.js,新增 POST 路由:

import Koa from 'koa';
import Router from '@koa/router';
import { koaBody } from 'koa-body';

const app = new Koa();
const router = new Router();
const port = 8008;

// 注册 koa-body 中间件:解析 JSON、表单、文件类型的 POST 数据
app.use(koaBody({
  multipart: true, // 支持文件上传(后续第八章用)
  json: true, // 解析 JSON 格式
  urlencoded: true // 解析表单格式(application/x-www-form-urlencoded)
}));

// 1. 处理 JSON 格式 POST 请求
router.post('/api/json', async (ctx) => {
  const { name, age } = ctx.request.body;
  ctx.body = {       // ctx.request.body 是 koa-body 解析后的 POST 数据
    code: 200,
    msg: "JSON 数据接收成功",
    data: { name, age }
  };
});

// 2. 处理表单格式 POST 请求
router.post('/api/form', async (ctx) => {
  const { username, password } = ctx.request.body;
  ctx.body = {
    code: 200,
    msg: "表单数据接收成功",
    data: { username, password }
  };
});

app.use(router.routes());

// 404 处理
app.use(async (ctx) => {
  ctx.status = 404;
  ctx.body = '404 Not Found';
});

app.listen(port, () => {
  console.log(`POST 服务器启动:http://localhost:${port}`);
});

测试 POST 请求(两种方式)

方式 1:Postman 测试

  • 请求地址:http://localhost:8008/api/json

  • 请求方法:POST

  • 请求体:选择 raw > JSON,输入:

    { "name": "张三", "age": 20 }
    
  • 响应:{"code":200,"msg":"JSON 数据接收成功","data":{"name":"张三","age":20}}

方式 2:curl 命令测试

# 测试 JSON 格式
curl -X POST -H "Content-Type: application/json" -d '{"name":"张三","age":20}' http://localhost:8008/api/json

# 测试表单格式
curl -X POST -d "username=admin&password=123456" http://localhost:8008/api/form

第六章 错误处理

import Koa from 'koa'
import Router from '@koa/router'

const hostname = "127.0.0.1"
const port = 8008

const app = new Koa()
const router = new Router()

//http://127.0.0.1:8008/
router.get('/', async ctx => {
    throw new Error("测试")
})

/*
    将 '错误处理中间件' 放在 '路由处理中间件' 之前, 当一个请求到达时,
    会先经过 '错误处理中间件', 然后才会进入 '路由处理中间件',
    是为了确保可以捕获错误
*/
app.use(async (ctx, next) => {  // 错误处理中间件
    try {
        await next()
    } catch (err) {
        //console.log('err:', err)
        ctx.status = 500
        ctx.body = 'err: ' + err.message
    }
})

app.use(router.routes())   // 路由处理中间件

app.listen(port, hostname, () => {
    console.log(`服务器已启动: http://${hostname}:${port}`)
})

第七章 允许跨域请求

安装跨域中间件

npm install @koa/cors

跨域配置示例

import Koa from 'koa';
import Router from '@koa/router';
import Cors from '@koa/cors';

const app = new Koa();
const router = new Router();
const port = 8008;

app.use(Cors()) //允许跨域请求

// 测试跨域路由
router.get('/api/cors', async (ctx) => {
  ctx.body = {
    code: 200,
    msg: "跨域请求成功"
  };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`跨域服务器启动:http://localhost:${port}`);
});

测试跨域

在任意前端项目(如 Vue / React / HTML 文件)中发送请求:

// 前端代码示例
fetch('http://localhost:8008/api/cors')
  .then(res => res.json())
  .then(data => console.log(data)) // 输出 {code:200, msg:"跨域请求成功"}
  .catch(err => console.error(err));

无跨域报错即配置成功。

第八章 上传图片

依赖准备(复用 koa-body)

koa-body 已支持文件上传,无需额外安装依赖,只需确保配置 multipart: true

图片上传示例

import Koa from 'koa';
import Router from '@koa/router';
import { koaBody } from 'koa-body';
import fs from 'fs';
import path from 'path';

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 创建上传目录(不存在则创建)
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) {
  fs.mkdirSync(uploadDir, { recursive: true });
}

// 2. 配置 koa-body 支持文件上传
app.use(koaBody({
  multipart: true, // 开启文件上传
  formidable: {
    uploadDir: uploadDir, // 临时存储目录
    keepExtensions: true, // 保留文件扩展名(如 .png/.jpg)
    maxFieldsSize: 2 * 1024 * 1024, // 限制文件大小 2MB
    filename: (name, ext, part, form) => {
      // 自定义文件名:时间戳 + 原扩展名,避免重复
      return Date.now() + ext;
    }
  }
}));

// 3. 图片上传接口
router.post('/api/upload', async (ctx) => {
  // ctx.request.files 是上传的文件对象
  const file = ctx.request.files.file; // 前端上传的文件字段名需为 file
  if (!file) {
    ctx.status = 400;
    ctx.body = { code: 400, msg: "请选择上传的图片" };
    return;
  }

  // 返回文件信息
  ctx.body = {
    code: 200,
    msg: "图片上传成功",
    data: {
      filename: file.newFilename, // 自定义后的文件名
      path: `/uploads/${file.newFilename}`, // 访问路径
      size: file.size // 文件大小(字节)
    }
  };
});

// 4. 静态文件访问:让上传的图片可通过 URL 访问
app.use(async (ctx, next) => {
  if (ctx.path.startsWith('/uploads/')) {
    const filePath = path.join(uploadDir, ctx.path.replace('/uploads/', ''));
    if (fs.existsSync(filePath)) {
      ctx.type = path.extname(filePath).slice(1); // 设置响应类型(如 png/jpg)
      ctx.body = fs.createReadStream(filePath); // 读取文件并返回
      return;
    }
    ctx.status = 404;
    ctx.body = "文件不存在";
    return;
  }
  await next();
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`图片上传服务器启动:http://localhost:${port}`);
});

测试图片上传

方式 1:Postman 测试

  • 请求地址:http://localhost:8008/api/upload
  • 请求方法:POST
  • 请求体:选择 form-data,Key 为 file,Type 选 File,上传一张图片。
  • 响应:返回文件路径,如 http://localhost:8008/uploads/1738987654321.png,访问该 URL 可查看图片。

方式 2:curl 命令测试

终端输入 bash 命令

curl -X POST -F "file=@/你的图片路径/xxx.png" http://localhost:8008/api/upload

第九章 cookie

Cookie 是存储在客户端浏览器的小型文本数据,Koa 内置 ctx.cookies API 可以操作 Cookie。

Cookie 操作示例

import Koa from 'koa'
import Router from '@koa/router'
 
const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 设置 Cookie
router.get('/cookie/set', async (ctx) => {
  // ctx.cookies.set(名称, 值, 配置)
  ctx.cookies.set(
    'username', 
    encodeURIComponent('张三'), 
    {
      maxAge: 24 * 60 * 60 * 1000, // 过期时间 1 天(毫秒)
      httpOnly: true, // 仅允许服务端访问,防止 XSS 攻击
      secure: false, // 开发环境设为 false(HTTPS 环境设为 true)
      path: '/', // 生效路径(/ 表示全站)
      sameSite: 'lax' // 防止 CSRF 攻击
    }
  );
  ctx.body = { code: 200, msg: "Cookie 设置成功" };
});

// 2. 获取 Cookie
router.get('/cookie/get', async (ctx) => {
  const username = ctx.cookies.get('username');
  ctx.body = {
    code: 200,
    msg: "Cookie 获取成功",
    data: { username }
  };
});

// 3. 删除 Cookie
router.get('/cookie/delete', async (ctx) => {
  ctx.cookies.set('username', '', { maxAge: 0 }); // 设置 maxAge 为 0 即删除
  ctx.body = { code: 200, msg: "Cookie 删除成功" };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`Cookie 服务器启动:http://localhost:${port}`);
});

测试 Cookie

  1. 访问 http://localhost:8008/cookie/set → 设置 Cookie;
  2. 访问 http://localhost:8008/cookie/get → 获取 Cookie,输出 {username: "张三"}
  3. 访问 http://localhost:8008/cookie/delete → 删除 Cookie,再次获取则为 undefined

第十章 session

安装 Session 中间件

Koa 原生不支持 Session,需安装 koa-session

npm install koa-session

Session 配置示例

import Koa from 'koa'
import Router from '@koa/router'
import session  from 'koa-session'

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. 配置 Session 密钥(生产环境需改为随机字符串)
app.keys = ['dengruicode_secret_key'];

// 2. Session 配置
const CONFIG = {
  key: 'koa:sess', // Session Cookie 名称
  maxAge: 24 * 60 * 60 * 1000, // 过期时间 1 天
  autoCommit: true,
  overwrite: true,
  httpOnly: true, // 仅服务端访问
  signed: true, // 签名 Cookie,防止篡改
  rolling: false, // 不刷新过期时间
  renew: false, // 快过期时自动续期
  secure: false, // 开发环境 false
  sameSite: 'lax'
};

// 3. 注册 Session 中间件
app.use(session(CONFIG, app));

// 4. Session 操作
// 设置 Session
router.get('/session/set', async (ctx) => {
  ctx.session.user = {
    id: 1,
    name: "张三",
    age: 20
  };
  ctx.body = { code: 200, msg: "Session 设置成功" };
});

// 获取 Session
router.get('/session/get', async (ctx) => {
  const user = ctx.session.user;
  ctx.body = {
    code: 200,
    msg: "Session 获取成功",
    data: { user }
  };
});

// 删除 Session
router.get('/session/delete', async (ctx) => {
  ctx.session = null; // 清空 Session
  ctx.body = { code: 200, msg: "Session 删除成功" };
});

app.use(router.routes());

app.listen(port, () => {
  console.log(`Session 服务器启动:http://localhost:${port}`);
});

测试 Session

  1. 访问 http://localhost:8008/session/set → 设置 Session;
  2. 访问 http://localhost:8008/session/get → 获取 Session,输出用户信息;
  3. 访问 http://localhost:8008/session/delete → 清空 Session,再次获取则为 undefined

注意:koa-session 是基于 Cookie 的内存 Session,生产环境建议使用 koa-redis 将 Session 存储到 Redis,避免服务重启丢失数据。

第十一章 jwt

安装 JWT 依赖

npm install jsonwebtoken koa-jwt
  • jsonwebtoken:生成 / 解析 JWT 令牌;
  • koa-jwt:验证 JWT 令牌的中间件。

JWT 完整示例

import Koa from 'koa'
import Router from '@koa/router'
import jwt  from 'jsonwebtoken'
import koaJwt  from 'koa-jwt'

const app = new Koa();
const router = new Router();
const port = 8008;

// 1. JWT 密钥(生产环境需加密存储)
const JWT_SECRET = 'dengruicode_jwt_secret';
// JWT 过期时间:1 小时(秒)
const JWT_EXPIRES_IN = 3600;

// 2. 登录接口:生成 JWT 令牌
router.post('/api/login', async (ctx) => {
  // 模拟验证用户名密码(生产环境需查数据库)
  const { username, password } = ctx.request.body;
  if (username === 'admin' && password === '123456') {
    // 生成 JWT 令牌
    const token = jwt.sign(
      { id: 1, username }, // 载荷:存储用户信息(不要存敏感数据)
      JWT_SECRET,
      { expiresIn: JWT_EXPIRES_IN }
    );
    ctx.body = {
      code: 200,
      msg: "登录成功",
      data: { token }
    };
  } else {
    ctx.status = 401;
    ctx.body = { code: 401, msg: "用户名或密码错误" };
  }
});

// 3. 受保护的接口:需要 JWT 验证
// koa-jwt 中间件会自动解析 Authorization 头中的 token
app.use(koaJwt({ secret: JWT_SECRET }).unless({
  path: [/^/api/login/] // 排除登录接口,无需验证
}));

// 4. 获取用户信息接口(需验证 JWT)
router.get('/api/user/info', async (ctx) => {
  // ctx.state.user 是 koa-jwt 解析后的 JWT 载荷
  const { id, username } = ctx.state.user;
  ctx.body = {
    code: 200,
    msg: "获取用户信息成功",
    data: { id, username }
  };
});

app.use(router.routes());

// 5. JWT 错误处理
app.use(async (ctx, next) => {
  try {
    await next();
  } catch (err) {
    if (err.status === 401) {
      ctx.status = 401;
      ctx.body = { code: 401, msg: "token 无效或过期" };
    } else {
      throw err;
    }
  }
});

app.listen(port, () => {
  console.log(`JWT 服务器启动:http://localhost:${port}`);
});

测试 JWT

步骤 1:登录获取 token

curl -X POST -d "username=admin&password=123456" http://localhost:8008/api/login
# 响应:{"code":200,"msg":"登录成功","data":{"token":"xxx.xxx.xxx"}}

步骤 2:携带 token 访问受保护接口

curl -H "Authorization: Bearer 你的token" http://localhost:8008/api/user/info
# 响应:{"code":200,"msg":"获取用户信息成功","data":{"id":1,"username":"admin"}}

步骤 3:token 无效 / 过期测试

携带错误 token 或过期 token 访问,会返回 {"code":401,"msg":"token 无效或过期"}

总结

  1. 核心流程:Koa 开发的核心是「中间件 + 路由」,所有功能(跨域、上传、JWT)都通过中间件扩展;

  2. 关键依赖@koa/router(路由)、koa-body(POST / 上传)、@koa/cors(跨域)、koa-session(Session)、jsonwebtoken/koa-jwt(JWT);

  3. 生产建议

    • Session/JWT 密钥需随机生成并加密存储;

    • 文件上传需限制大小和类型,防止恶意上传;

    • 跨域需指定具体域名,而非 *

    • JWT 载荷不要存敏感数据,过期时间不宜过长。

pnpm-workspace.yaml

pnpm-workspace.yamlpnpm 的“项目组织与调度中枢”,告诉 pnpm:哪些目录是同一个 workspace,以及这些包之间如何协同工作。

定义哪些包属于同一个仓库

packages:
  - packages/*
  - apps/*
  • packages/* 下面每个有 package.json 的目录,都是一个包
  • apps/* 下面每个 app 也是一个包

Workspace 内包本地互相引用

packages/
  utils/
  ui/
apps/
  admin/

apps/admin/package.json 里:

{
  "dependencies": {
    "@my/utils": "workspace:*"
  }
}

效果是:

  • 不去 npm 下载
  • 直接 软链接到本地 packages/utils
  • 改代码立刻生效

这是 monorepo 的灵魂能力。

依赖统一安装、统一锁定

在根目录执行pnpm install
pnpm 会:

  • 扫描 pnpm-workspace.yaml 里的所有包
  • 统一生成 一份 pnpm-lock.yaml
  • 所有包共享同一个依赖树

支持 catalog

pnpm-workspace.yaml 里可以这样写:

catalog:
  vite: ^5.1.0
  vue: ^3.4.0
  typescript: ^5.3.3

子包中:

"devDependencies": {
  "vite": "catalog:",
  "vue": "catalog:"
}

版本集中管理,企业级工程标配.

支持 workspace 协议(workspace:*)

"@my/ui": "workspace:*"     // 任意版本
"@my/ui": "workspace:^"     // 遵循 semver
"@my/ui": "workspace:~"

批量执行命令

pnpm -r build
pnpm -r test
pnpm -r lint
  • -r = recursive
  • 对 workspace 里的 所有包 执行

corepack 作用

corepack 可以把它理解成 Node.js 自带的“包管理器管理器”
corepack 用来管理和锁定项目使用的包管理器(比如 pnpm / yarn),而不是管理依赖本身。

为什么会有 corepack

以前的情况很乱:

  • 有的人用 npm
  • 有的人用 yarn
  • 有的人用 pnpm
  • 同一个项目里,不同人用的 包管理器版本还不一样

结果就是:

“我这能跑,你那为啥装不起来?”

corepack 的出现,就是为了解决 “到底用哪个包管理器、用哪个版本” 这个问题。

corepack 能干什么

1️⃣ 统一项目使用的包管理器

package.json 里可以写:

{
  "packageManager": "pnpm@8.15.4"
}

含义是:

这个项目 必须pnpm,而且版本是 8.15.4

这时候:

  • pnpm install
  • 同事 npm install
  • CI 里跑 pnpm install

👉 corepack 会自动帮你下载并使用正确版本的 pnpm

不用大家手动装。


2️⃣ 自动安装 & 切换 yarn / pnpm

你甚至不需要提前全局装 pnpm:

corepack enable
pnpm install

如果项目声明的是:

"packageManager": "yarn@3.6.1"

corepack 会:

  • 自动下载 yarn 3.6.1
  • 用它来执行命令

你本地有没有 yarn 👉 不重要


3️⃣ 防止“包管理器版本不一致”的坑

比如:

  • A 用 pnpm 7
  • B 用 pnpm 8
  • lock 文件结构都不一样

corepack 可以 强制版本一致,从源头避免:

  • lockfile 被反复改
  • CI 跑不过
  • “我这没问题啊”的玄学 bug

corepack 和 npm / yarn / pnpm 的关系

可以这么理解👇

corepack
  ├── 管理 pnpm
  ├── 管理 yarn
  └── 管理 npm(间接)
  • npm / yarn / pnpm:真正干活的
  • corepack:负责“发工具、管版本、做协调”

常用命令速览 🧠

# 启用 corepack(Node 16+ 自带)
corepack enable

# 查看当前 corepack 版本
corepack --version

# 指定并激活某个包管理器版本
corepack prepare pnpm@8.15.4 --activate

什么时候一定要用 corepack

非常推荐用在这些场景👇

  • 团队协作项目
  • monorepo(pnpm / yarn workspace)
  • CI / Docker / 线上构建
  • 你已经被 “lockfile 一直变” 折磨过 😅

一句话总结

corepack 不是用来装依赖的,是用来“管包管理器的版本和使用权”的。
它让“这个项目该用哪个包管理器、哪个版本”变成一件确定的事。

CSS盒模型实战:用代码透视 `border-box`与 `content-box`的天壤之别

CSS盒模型实战:用代码透视 border-boxcontent-box的天壤之别

理解CSS盒模型是前端布局的必修课,而 box-sizing属性则是掌控盒模型计算规则的钥匙。本文将通过您文档中生动的代码示例,直观展示其核心区别。

场景一:标准盒模型的“扩张”困扰(content-box

在默认的 content-box模型下,您为元素设置的 widthheight仅作用于其内容区域。让我们看一个例子:

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        .box.content-box {
            width: 200px;       /* 仅指内容的宽度 */
            height: 100px;      /* 仅指内容的高度 */
            padding: 20px;      /* 内边距 */
            border: 5px solid black; /* 边框 */
            margin: 20px;       /* 外边距 */
            box-sizing: content-box; /* 这是默认值,也可不写 */
            background-color: lightgreen;
        }
    </style>
</head>
<body>
    <div class="box content-box">Box with content-box</div>
</body>
</html>

关键代码分析

  • width: 200px; height: 100px;:这里定义的仅仅是绿色内容区域的尺寸。
  • 添加的 paddingborder向外扩张盒子的总尺寸。

计算结果

  • 盒子的总宽度 = 200(width) + 20 * 2(padding) + 5 * 2(border) = 250px
  • 盒子的总高度 = 100(height) + 20 * 2(padding) + 5 * 2(border) = 150px

此时,盒子在页面上的实际占位是 250px * 150px,远大于你直觉上认为的 200px * 100px。这在多列布局时极易导致意外换行或溢出。

场景二:怪异盒模型的“收缩”智慧(border-box

为了解决上述问题,border-box模型采用了更直观的计算方式:你设定的 widthheight直接定义了这个盒子的总边框盒尺寸。对比示例:

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        .box.border-box {
            width: 200px;       /* 指整个盒子的总宽度! */
            height: 100px;      /* 指整个盒子的总高度! */
            padding: 20px;
            border: 5px solid black;
            margin: 20px;
            box-sizing: border-box; /* 核心:切换为 border-box */
            background-color: lightblue;
        }
    </style>
</head>
<body>
    <div class="box border-box">Box with border-box</div>
</body>
</html>

关键代码分析

  • 同样的 width: 200px; height: 100px;声明,但因为 box-sizing: border-box;的存在,这里的 200px 和 100px 被解释为包含内容、内边距和边框的总尺寸
  • 添加的 paddingborder向内挤压内容区域的空间。

计算结果

  • 盒子的总宽度 = 200px(由 width直接定义)
  • 盒子的总高度 = 100px(由 height直接定义)
  • 内容区域的实际宽度 = 200 - 20 * 2 - 5 * 2 = 150px
  • 内容区域的实际高度 = 100 - 20 * 2 - 5 * 2 = 50px

无论你如何调整 paddingborder,这个浅蓝色盒子的外轮廓都严格保持为你设定的 200px * 100px,这使得精确控制布局变得轻而易举。

实战应用:为什么 border-box是布局神器

让我们看一个经典应用场景——创建两个等宽并列的盒子:

<!DOCTYPE html>
<html lang="en">
<head>
    <style>
        .container {
            width: 1200px;
            margin: 0 auto;
        }
        .box {
            box-sizing: border-box; /* 使用 border-box 模型 */
            width: 580px; /* 总宽580px */
            height: 100px;
            margin: 0 10px; /* 左右外边距各10px */
            border: 1px solid #000; /* 边框 */
            padding: 5px; /* 内边距 */
            display: inline-block;
            background-color: green;
        }
        .box:nth-child(2) {
            background-color: yellow;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="box">1</div><div class="box">2</div>
    </div>
</body>
</html>

核心优势解析

  1. 尺寸可预测:每个 .box的总宽度是明确的 580px,无论其 borderpadding如何变化。

  2. 布局计算简单

    • 单个盒子占位:580px(width) + 10 * 2(margin) = 600px
    • 两个盒子总占位:600px + 600px = 1200px
    • 容器宽度为 1200px,完美容纳。

如果此处使用 content-box,会发生什么?

每个盒子的实际总宽度会变成:580(width) + 5 * 2(padding) + 1 * 2(border) = 592px,再加上左右 margin各10px,单个盒子就占用了 612px,两个盒子就需要 1224px,会立即撑破 1200px的容器,导致第二个盒子掉到下一行。border-box彻底避免了这种烦人的计算。

总结与最佳实践

通过以上代码的对比演示,可以清晰地看到:

  • **content-box** 是“加法模型”(实际尺寸 = 设定尺寸 + padding + border),易导致布局失控。
  • **border-box** 是“减法模型”(内容尺寸 = 设定尺寸 - padding - border),让元素的占位尺寸完全可预测。

因此,在现代前端开发中,一个公认的最佳实践是在CSS起始位置就全局应用 border-box模型:

*,
*::before,
*::after {
  box-sizing: border-box;
}

这条简单的规则,能让你在后续的整个开发过程中,彻底告别因 paddingborder导致的布局尺寸计算烦恼,将更多精力投入到创意和逻辑的实现中。

去到比北方更北的地方—2025年终总结

想写年终总结的心情,是在看到车窗外的雪之后,突然有的。

2025年的最后一天,我们坐上了从北京朝阳开往延吉西的高铁。

车厢内的暖气开得很足,我靠在椅背上,觉得码字是打发这6小时车程再好不过的事。

古典主义认为灵感是神的旨意,于是我便开始等待,等待着神对我下达祂的旨意。

没等多久我就发现了比码字还要好的事情,那就是睡觉。

于是这一篇在离开北京时就想写的文章,最终还是在我回北京之后,才被写出来。


时代的关键词

如果让大家用一个词语形容2025年,大家会选择什么词语呢?

我选的词语是“AI”

从chatGPT一鸣惊人的“AI元年”来算,这波浪潮已经汹涌了3年。

大家都说要拥抱变化,拥抱变化 。

个人感觉变化已经不够形容了

这是 冲击

Cursor为首的AI IDE工具正在冲击我们这个职业

技术社区的博客里涌现出越来越多的名词,LLM、Agent、MCP、CSR、Rules、Skills、AGUI、A2UI、A2A等等

集团在年初邀请梁宁做了一次分享,我当时没来得及去线下,后来用几个午休的时间,断断续续看完了录播回放

在视频播放完毕,进度条已经重置的时候,脑海里还是存在着她频频提到的6个字

“时代的关键词”

  • 蒸汽时代的关键词是效率不对称

  • 互联网时代的关键词是信息不对称

  • AI时代的关键词是经验不对称

从梁宁的视角看互联网时代,对我来说是很新奇的体验:

“互联网解决的是什么问题,解决的就是信息不对称这个问题

所以我们有时候也会用‘信息时代’来称呼‘互联网时代’

那么什么是‘信息’?

我们在网上看到的图片也好,视频也好,文字也好,认为自己看到了一个商品也好、人物也好。

我们在互联网上看到的一切,都是信息。

再书面化一些,引用《信息系统项目管理师教程》这本书里对于信息的定义

信息是物质、能量及其属性的标示的集合

谈到互联网时代,谈到互联网,肯定绕不开BAT,

为什么BAT会产生、会崛起?

先说B,百度是搜索引擎,他天然收割了web上的所有信息

互联网解决的是信息不对称的问题,搜索引擎又天然站在了这种生态位的顶端,他能拿到全量的信息,作为收割者

因此这也是为什么最初B是BAT之首的原因。

再说T,腾讯的核心信息是什么?是人,

“人是一切社会关系的总和” —马克思

在互联网如何用信息表达一个人?

id和关系链

没人能撼动他的核心,碰他的人

而且他跳脱在了百度的收割之外

因为他是客户端

最后来说说A,阿里

为什么百度也收割不了阿里?

最早的时候淘宝主动屏蔽百度的搜索,淘宝通过构建自己的商品信息生态,他自己打造的信息当然是他自己内部收割,因此百度收割不了他的信息

人的信息、交易的信息、其他的信息(比如携程)构成了信息的全部

现在则是每个企业都在构建自己的信息生态,携程是酒旅,京东是供应链

而到了移动互联网时代,

小红书、抖音又有自己的信息生态”

那么AI时代的经验不对称又该从何说起?

如何区分“知识”和“经验”?

我很好奇梁宁又会怎么看待现在的时代,在《真需求》这本书写完后,外边的世界发生了天翻地覆的变化…

与其感慨路难行,不如马上出发

打完这个标题,我抬头看了一眼电脑旁的饮料,写文字的时候需要喝点小甜水,这是我两年前养成的习惯

后来我发现很多作家也有这样的习惯,王小波需要一直喝茶、吸烟,来让自己保持一个亢奋的状态,用于写作

  • 2024年,我写了将近20万字,输出了34篇技术博客,掘金的热榜挤进去过几次,还“混“上过第一,至于喝掉了的饮料数...大概也能在余杭区拔得头筹吧😄

  • 2025年,我只在掘金发了3篇文章,正文加起来估计1万字左右

同比下降2000%,我还想不想在掘金混了?

非也非也,倒不是我也感慨路难行了所以不去行了,而是转战场了,主要的技术沉淀与分享都在公司内部社区。

本来这一章节是想顺着这个标题做一些技术写作的心得分享,但是随着OpenClaw的出现,我对技术写作产生了完全不同于以往的想法

  • 就像OpenClaw出现之后,程序员应该去写让大模型更方便阅读和理解的cli层面的东西一样。

  • 以后所谓的技术博客,是不是也就是写成让大模型更能读懂、最佳读懂代码的提示词而已

形态上的变化

比如我写一篇源码分析,代码层面的查看基本就是靠大模型通过csr去全面解读,然后我再基于个人经验和理解完成汇总,顺便补上知识点的官方文档出处,确保不会误人子弟、减少大模型的幻觉

但是其实很多时候也没必要花力气打开博客论坛去看别人的这种文章

作为开发者我期望我能在AI IDE里完成一切,我希望它在代码解读时是站在高质量技术博主的视角上的。

很多时候同一个知识点,不同水平的人看到的门道是不一样的。

说个八股文,js的闭包是千人千面,各种说法都有,

背后的根因我想就是每个人的理解有深有浅。

大家用了这么久的技术论坛,应该看过不少技术大佬、技术团队的文章

有些技术大佬看问题的角度特别刁钻、特别深入,看完往往会让人拍大腿,原来还能这么理解吗?

有些技术团队看问题直抓本质,解决方案一出就是最佳实践

如果大模型能够具备和这些技术大佬、技术团队一样对问题/现象/知识点 的思考与解读能力

我们是否还有必要访问技术论坛、关注这些技术大佬、技术团队呢?

或许,在未来,技术博客只有帮助作者本人沉淀知识、掌握知识这一个作用了?

(费曼学习法:教授他人,对学习的吸收率是90%)

“前端已死”

  • Claude模型更新了,甩开别的一大截,前端已死

  • GPT 5更新了,CodeMax模式,甩开别的一大截,前端已死

  • Gemini更新了,甩开别的一大截,前端已死

前端又双叒叕死了😭

Vibe Coding确实给我们这个行业带来了巨大的影响

skills出来前,依托mcp和rules,再加上规约编程,大模型就能写出很不错的、符合研发规范的代码了

再加上一些d2c的工具,从0到1的需求开发,效率确实得到了显著的提升

但是目前的模式对于增量开发(基于工程现有功能完成需求迭代)的提效其实没那么显著

再者,对于程序员来说,写代码需要的时间很多时候并不是大头,更多时间被花在了prd评审,上下游对齐,协作沟通,开会等非编码性质的事上

我们可能不应该再继续想着提升5个流程节点中某个节点的几个子节点的效率

(比如研发节点中的设计稿还原、code reciew这些子节点)

而是能不能把5个流程节点直接砍成3个,这种层面的提效

Vibe Coding什么时候可以不仅仅是Coding?

去到比北方更北的地方

前端不仅仅只是前端

Web 安全与反编译源码下的权限设计:构建前后端一体的信任防线

引言

在现代 Web 应用中,安全与权限设计 是架构中最容易被忽视、却最容易出问题的部分。
随着前端应用的复杂度不断提高(Vue、React、Flutter Web 等),越来越多逻辑被放在客户端执行;
与此同时,打包后的前端代码可被轻易“反编译”、“逆向分析”或直接在浏览器中篡改。

于是,开发者常面临一个棘手问题:

「当用户可以直接查看、修改、甚至注入前端逻辑时,我们该如何保障权限体系的安全性?」

本文将系统分析 反编译环境下权限设计的风险与防护机制,通过架构分层思路,
构建一个前后端统一、安全可控的权限防护体系,并提供实践代码与工程建议。


image.png

一、问题定义与背景

1. 前端反编译:安全的假象

前端编译产物(HTML、JS、CSS)最终都需运行在用户端。
即使使用了 Webpack、Vite、Uglify 进行混淆打包,攻击者仍可通过如下方式分析源码:

  • 打开 浏览器开发者工具 查看逻辑;
  • 使用 反混淆工具 还原函数与模块;
  • 直接 修改全局变量绕过条件判断
  • 使用 抓包工具(如 Burp、Postman) 模拟接口调用。

这意味着:
前端的权限判断、令牌验证或角色限制——如果不由后端复核
都可以被通过篡改脚本的方式绕过。

2. 案例:被篡改的前端权限

错误示例(前端判断管理员身份):

if (user.role === 'admin') {
  showAdminPanel();
}

攻击者在浏览器控制台直接执行:

user.role = 'admin';
showAdminPanel();

即可解锁「管理员面板」。
但如果后端接口没有二次验证,那么真正的危险在于:他能调用后台管理 API 删除数据。


二、安全权限设计的核心原则

  1. 前端展示,后端决策

    • 前端只能控制 UI 是否显示某个按钮,不应决定「是否允许执行动作」。
    • 所有与安全相关的逻辑(增删改、数据查询)必须由后端验证。
  2. 服务端必须验证权限 + 签名

    • 后端是「唯一可信环境」,应验证请求来源、签名、角色、Token。
  3. 权限是「被动判定」,不是「主动记忆」

    • 不依赖前端本地状态(如 localStorage);
    • 每次请求都在后端重新验证身份。

三、安全权限防护的分层架构

为了实现安全的分布式权限体系,我们可以将系统划分为六层:

层级 描述 核心防护策略
① 前端展示层 Vue / React 应用 仅展示功能,不存储逻辑;限制 Token 暴露
② 接入与网关层 Nginx / Kong / API Gateway 限流、防爬;验证 Token 签名;请求日志
③ 鉴权服务层 OAuth2 / SSO Server 登录态验证;角色与租户判断;颁发 JWT
④ 资源服务层 各业务模块服务 核心逻辑校验:RBAC / ABAC 权限匹配
⑤ 数据与审计层 Database、Redis、ELK 脱敏、最小访问策略、操作留痕
⑥ 安全监控层 SIEM、Prometheus 风控检测、告警策略、异常分析

架构图

下图展示了完整防护分层结构(数据流由上至下):

┌──────────────────────────────────────┐
│          安全监控层(SIEM/风控)     │
│  • 登录异常检测  • 攻击告警分析     │
└──────────────────────────────────────┘
                 ▲
┌──────────────────────────────────────┐
│          数据与审计层               │
│  • 数据最小权限访问                │
│  • 审计日志与安全追踪              │
└──────────────────────────────────────┘
                 ▲
┌──────────────────────────────────────┐
│          资源服务层(业务逻辑)      │
│  • 接口级权限控制(@RoleBasedAccess)│
│  • 防越权、操作审计                │
└──────────────────────────────────────┘
                 ▲
┌──────────────────────────────────────┐
│          鉴权服务层(SSO)           │
│  • Token验证、角色发放              │
│  • 动态授权、租户隔离               │
└──────────────────────────────────────┘
                 ▲
┌──────────────────────────────────────┐
│          接入网关层(API Gateway)   │
│  • 限流、防爬、防刷                │
│  • HMAC签名验证                    │
└──────────────────────────────────────┘
                 ▲
┌──────────────────────────────────────┐
│          前端展示层(非信任区)      │
│  • 仅展示UI、读取Token提醒用户登录   │
│  • 禁止业务逻辑在本地执行           │
└──────────────────────────────────────┘

四、技术实现

1. 后端角色权限注解示例

// 自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RoleCheck {
    String[] value();
}
// 拦截器实现
@Component
public class RoleInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        String token = req.getHeader("Authorization");
        User user = TokenService.verify(token);
        RoleCheck check = ((HandlerMethod) handler).getMethodAnnotation(RoleCheck.class);
        if (check != null && !user.hasAnyRole(check.value())) {
            res.setStatus(HttpServletResponse.SC_FORBIDDEN);
            return false;
        }
        return true;
    }
}

🔐 即便攻击者模拟请求或反编译前端,也无法绕过后端角色认证。


2. 前端:基于权限的显示控制(非逻辑控制)

// 假设后端返回的角色为 ['user']
const userRoles = ['user'];

const routes = [
  { name: 'Dashboard', meta: { role: ['user', 'admin'] } },
  { name: 'SystemConfig', meta: { role: ['admin'] } }
];

// 仅前端渲染控制
const visibleRoutes = routes.filter(route =>
  route.meta.role.some(role => userRoles.includes(role))
);

仅影响 UI 展示,不影响接口可访问性。


3. 防反编译与攻击加固

攻击方式 防护措施 实践工具
打包JS被逆向 混淆与代码压缩 terser, webpack-obfuscator
Token篡改 服务签名验证、短时效JWT Redis / JWT RFC7519
模拟接口 请求签名(HMAC / Nonce) Nginx + Auth Filter
调试注入 Content-Security-Policy (CSP) HTTP 安全策略头
重放攻击 时间戳 + 随机Nonce验证 Redis缓存校验

签名验证示例(Node.js HMAC)

import crypto from 'crypto';

function signRequest(payload, secret, timestamp) {
  const base = JSON.stringify(payload) + timestamp;
  return crypto.createHmac('sha256', secret).update(base).digest('hex');
}

五、设计优缺点分析

模型 优点 缺点 适合场景
前端判断权限 简单、体验好 易被绕过、不安全 仅用于 UI 控制
后端校验权限 安全、集中管理 开销稍高、响应滞后 核心业务接口
分层架构权限体系 安全与效率平衡 架构复杂、需治理 企业级中大型系统

✅ 推荐混合架构:前端保障体验,后端保障安全。


六、结论

在 Web 反编译几乎无法避免的时代,安全是策略,不是幻觉

权限控制要从「信任前端」转变为「前后端协同」。
只要保持以下三点,你的权限体系就能在复杂的安全形势下立于不败之地:

  1. 一切授权最终落地后端;
  2. 所有敏感逻辑皆可审计;
  3. 前后端之间的信任关系可验证、可撤销。

未来,伴随 零信任架构(Zero Trust)动态策略授权(Policy-based Access Control, PBAC) 的兴起,
权限安全将更加智能与分布化。安全从此不是附加,而将成为业务本身的一部分。


七、参考资料

  1. [OWASP Top 10 2021: Broken Access Control]
  2. [Spring Security Reference Documentation]
  3. [MDN Web Docs: Content Security Policy (CSP)]
  4. [RFC 7519 – JSON Web Token (JWT)]
  5. [Zero Trust Architecture – NIST SP 800-207]

前端HTML转PDF的两种主流方案深度解析

引言

在现代Web开发中,将网页内容导出为PDF格式的需求越来越普遍。无论是生成电子发票、导出数据报表、制作可打印的文档,还是为用户提供离线阅读的材料,HTML到PDF的转换都是前端开发者必须掌握的技能。本文将深入剖析两种主流的前端PDF生成方案,从原理、实现到最佳实践,帮助你根据实际场景选择最合适的技术路线。


方案一:浏览器原生打印API

核心原理

浏览器原生打印方案利用了window.print()这一内置API。通过动态创建一个新的浏览器窗口,将需要打印的HTML内容写入该窗口,然后触发浏览器的打印对话框,让用户可以选择"另存为PDF"。这种方法的本质是依赖浏览器自身的渲染引擎和打印能力。

完整实现代码

/**
 * 使用浏览器原生API生成PDF
 * @param {string} title - 打印页面的标题
 * @param {string} style - 需要打印的CSS样式
 * @param {string} content - 需要打印的HTML内容
 */
function printToPDF(title, style, content) {
    // 构建完整的HTML文档结构
    const html = `
        <!DOCTYPE html>
        <html>
        <head>
            <meta charset="UTF-8">
            <meta name="viewport" content="width=device-width, initial-scale=1.0">
            <title>${title}</title>
            <style>
                /* 基础重置样式 */
                * {
                    margin: 0;
                    padding: 0;
                    box-sizing: border-box;
                }
                
                /* 打印优化样式 */
                @media print {
                    body {
                        -webkit-print-color-adjust: exact;
                        print-color-adjust: exact;
                    }
                    
                    /* 避免表格被截断 */
                    table {
                        page-break-inside: avoid;
                    }
                    
                    /* 避免图片被截断 */
                    img {
                        page-break-inside: avoid;
                        max-width: 100%;
                    }
                }
                
                ${style}
            </style>
        </head>
        <body>
            ${content}
        </body>
        </html>
    `;
    
    // 创建新窗口
    const printWindow = window.open('', '_blank');
    
    if (!printWindow) {
        console.error('弹窗被浏览器拦截,请检查弹窗设置');
        return;
    }
    
    // 写入HTML内容
    printWindow.document.write(html);
    printWindow.document.close();
    
    // 等待资源加载完成后触发打印
    printWindow.onload = function() {
        setTimeout(() => {
            printWindow.print();
            // 打印完成后可选择关闭窗口
            // printWindow.close();
        }, 500);
    };
}

// 使用示例
const title = '月度销售报表';
const style = `
    .report-header { text-align: center; margin-bottom: 20px; }
    .report-table { width: 100%; border-collapse: collapse; }
    .report-table th, .report-table td { border: 1px solid #ddd; padding: 8px; }
`;
const content = document.getElementById('report-container').innerHTML;

printToPDF(title, style, content);

关键配置说明

配置项 说明 建议值
-webkit-print-color-adjust 确保打印时保留背景色和颜色 exact
page-break-inside: avoid 防止元素在分页处被截断 应用于表格、图片
page-break-before/after 控制强制分页位置 根据内容结构设置

方案一优缺点分析

优点:

  • 零依赖:无需引入任何第三方库,减少项目体积
  • 浏览器兼容性好:所有现代浏览器都支持
  • 用户可控:用户可以在打印对话框中选择纸张大小、方向、边距等
  • 样式灵活:可以使用@media print媒体查询专门优化打印样式

缺点:

  • 交互依赖:必须弹出打印对话框,无法静默生成PDF
  • 样式一致性差:不同浏览器的打印效果可能存在差异
  • 无法自动下载:需要用户手动选择"另存为PDF"
  • 分页控制有限:复杂的分页逻辑难以精确控制

方案二:html2pdf.js库方案

核心原理

html2pdf.js是一个基于html2canvasjsPDF的封装库。其工作流程分为三步:

  1. DOM转Canvas:使用html2canvas将HTML元素渲染为Canvas图像
  2. Canvas转图像:将Canvas转换为JPEG/PNG图像数据
  3. 图像转PDF:使用jsPDF将图像数据插入PDF文档

完整实现代码

import html2pdf from 'html2pdf.js';

/**
 * 使用html2pdf.js生成PDF
 * @param {HTMLElement} element - 需要转换的DOM元素
 * @param {Object} options - 配置选项
 * @returns {Promise} - 返回Promise对象
 */
function generatePDF(element, options = {}) {
    // 默认配置
    const defaultOptions = {
        // PDF基础设置
        margin: [10, 10, 10, 10],           // 上右下左边距(单位:mm)
        filename: 'document.pdf',            // 默认文件名
        
        // 图像质量设置
        image: {
            type: 'jpeg',                    // 图像格式:jpeg/png
            quality: 0.98                    // 图像质量:0-1
        },
        
        // html2canvas配置
        html2canvas: {
            scale: 2,                        // 缩放倍数,影响清晰度
            useCORS: true,                   // 允许加载跨域图片
            allowTaint: true,                // 允许污染画布(用于跨域图片)
            logging: false,                  // 关闭日志输出
            letterRendering: true,           // 改善文字渲染
            dpi: 192                         // 图像DPI
        },
        
        // jsPDF配置
        jsPDF: {
            unit: 'mm',                      // 单位:mm/pt/px/in
            format: 'a4',                    // 页面格式:a4/letter/legal等
            orientation: 'portrait'          // 方向:portrait(纵向)/landscape(横向)
        },
        
        // 分页控制
        pagebreak: {
            mode: ['avoid-all', 'css', 'legacy'],
            before: '.page-break-before',    // 在这些元素前强制分页
            after: '.page-break-after',      // 在这些元素后强制分页
            avoid: 'img, table, .no-break'   // 避免这些元素被分页截断
        }
    };
    
    // 合并配置
    const mergedOptions = deepMerge(defaultOptions, options);
    
    // 执行转换
    return html2pdf()
        .set(mergedOptions)
        .from(element)
        .save();
}

/**
 * 获取PDF的Base64数据(用于上传或预览)
 * @param {HTMLElement} element - 需要转换的DOM元素
 * @param {Object} options - 配置选项
 * @returns {Promise<string>} - 返回Base64编码的PDF数据
 */
async function getPDFBase64(element, options = {}) {
    const pdf = await html2pdf()
        .set(options)
        .from(element)
        .outputPdf('datauristring');
    
    return pdf;
}

/**
 * 获取PDF的Blob对象(用于自定义下载逻辑)
 * @param {HTMLElement} element - 需要转换的DOM元素
 * @param {Object} options - 配置选项
 * @returns {Promise<Blob>} - 返回PDF的Blob对象
 */
async function getPDFBlob(element, options = {}) {
    const pdf = await html2pdf()
        .set(options)
        .from(element)
        .outputPdf('blob');
    
    return pdf;
}

// 使用示例
const element = document.getElementById('invoice-container');

// 基础使用 - 直接下载
generatePDF(element, {
    filename: '发票-2024001.pdf',
    margin: [15, 15, 15, 15]
});

// 高级使用 - 获取数据后上传
getPDFBase64(element, {
    filename: 'report.pdf',
    html2canvas: { scale: 3 },  // 更高清晰度
    jsPDF: { orientation: 'landscape' }  // 横向布局
}).then(base64Data => {
    // 上传到服务器
    uploadToServer(base64Data);
});

配置项深度解析

1. 清晰度优化

{
    html2canvas: {
        scale: 3,           // 推荐值:2-4,值越大越清晰但性能越差
        dpi: 300,           // 打印级清晰度
        letterRendering: true  // 改善小字体渲染
    }
}

2. 分页控制策略

/* CSS方式控制分页 */
.page-break-before {
    page-break-before: always;
}

.page-break-after {
    page-break-after: always;
}

.no-break {
    page-break-inside: avoid;
}
{
    pagebreak: {
        mode: ['avoid-all', 'css', 'legacy'],
        // avoid-all: 尽可能避免元素被截断
        // css: 尊重CSS的page-break属性
        // legacy: 使用旧版分页算法
    }
}

3. 跨域图片处理

{
    html2canvas: {
        useCORS: true,       // 尝试使用CORS加载跨域图片
        allowTaint: true,    // 允许污染画布(如果CORS失败)
        proxy: '/api/proxy'  // 图片代理服务地址
    }
}

方案二优缺点分析

优点:

  • 静默生成:无需用户交互,可自动下载或上传
  • 效果一致:不受浏览器打印设置影响,输出稳定
  • 程序化控制:可通过代码精确控制生成过程
  • 支持异步:可集成到自动化流程中

缺点:

  • 体积较大:需要引入第三方库(约200KB+)
  • 性能开销:大页面转换可能较慢,会阻塞主线程
  • 文字可选性:生成的PDF中文字是图像,无法选择复制
  • 复杂样式限制:某些CSS特性(如flexbox、grid)可能渲染不准确

方案对比与选型指南

对比维度 浏览器原生打印 html2pdf.js
依赖体积 0KB ~200KB+
用户交互 需要 不需要
生成速度 较慢(取决于内容大小)
输出一致性 浏览器依赖 高度一致
文字可选性 支持 不支持(文字为图像)
分页控制 有限 灵活
跨域图片 支持 需特殊配置
自动化集成 困难 容易
浏览器兼容性 优秀 良好

选型建议

选择浏览器原生打印的场景:

  • 需要用户自定义打印设置(纸张、边距等)
  • 对PDF文件大小敏感
  • 需要生成的PDF中文字可选择、可复制
  • 项目对第三方依赖有严格限制

选择html2pdf.js的场景:

  • 需要静默生成PDF,不打扰用户
  • 需要自动上传PDF到服务器
  • 对输出效果的一致性要求高
  • 需要集成到自动化工作流中

最佳实践与常见问题

1. 打印样式优化

/* 打印专用样式表 */
@media print {
    /* 隐藏不需要打印的元素 */
    .no-print,
    .navbar,
    .sidebar,
    .actions {
        display: none !important;
    }
    
    /* 确保背景色打印 */
    * {
        -webkit-print-color-adjust: exact !important;
        print-color-adjust: exact !important;
    }
    
    /* 链接显示URL */
    a[href]:after {
        content: " (" attr(href) ")";
    }
    
    /* 表格优化 */
    table {
        page-break-inside: avoid;
        font-size: 12pt;
    }
    
    /* 分页控制 */
    .page-break {
        page-break-after: always;
    }
}

2. 大页面性能优化

// 分块处理大页面
async function generateLargePDF(container) {
    const pages = container.querySelectorAll('.page');
    const pdf = new jsPDF('p', 'mm', 'a4');
    
    for (let i = 0; i < pages.length; i++) {
        // 使用requestIdleCallback避免阻塞UI
        await new Promise(resolve => {
            requestIdleCallback(async () => {
                const canvas = await html2canvas(pages[i], { scale: 2 });
                const imgData = canvas.toDataURL('image/jpeg', 0.95);
                
                if (i > 0) pdf.addPage();
                pdf.addImage(imgData, 'JPEG', 0, 0, 210, 297);
                
                resolve();
            });
        });
    }
    
    pdf.save('large-document.pdf');
}

3. 常见问题解决方案

Q: 生成的PDF中文字模糊?

// 提高scale值和DPI
html2canvas: {
    scale: 3,
    dpi: 300,
    letterRendering: true
}

Q: 跨域图片无法显示?

// 方案1:配置CORS
html2canvas: {
    useCORS: true,
    allowTaint: true
}

// 方案2:使用图片代理
html2canvas: {
    proxy: 'https://your-domain.com/image-proxy'
}

// 方案3:将图片转为Base64
const img = document.querySelector('img');
fetch(img.src)
    .then(res => res.blob())
    .then(blob => {
        const reader = new FileReader();
        reader.onloadend = () => {
            img.src = reader.result;
        };
        reader.readAsDataURL(blob);
    });

Q: 表格被分页截断?

/* 为表格容器添加保护 */
.table-wrapper {
    page-break-inside: avoid;
}

/* 或使用html2pdf的分页配置 */
pagebreak: {
    avoid: 'table, tr'
}

总结

前端HTML转PDF的两种主流方案各有优劣:

  • 浏览器原生打印适合需要用户参与、对文件大小敏感、需要文字可选的场景
  • html2pdf.js适合需要自动化、对输出一致性要求高的场景

在实际项目中,可以根据具体需求选择单一方案或组合使用。例如,可以提供"打印"按钮使用原生方案,同时提供"下载PDF"按钮使用html2pdf.js方案,让用户自主选择。

随着Web技术的发展,新的方案如Chrome的Headless打印、Puppeteer等服务端方案也在兴起。但对于纯前端场景,本文介绍的两种方案仍然是最实用、最成熟的选择。

Web 安全与反编译源码下的权限设计:构筑前后端一致的防护体系

引言

在 Web 应用的安全体系中,权限控制 是最核心的防线之一。无论是企业后台、数据门户还是 SaaS 平台,一旦权限设计出现漏洞,就可能导致数据泄漏、越权操作甚至后门利用。

然而,许多开发者只在前端配置权限逻辑,忽略了 反编译与前端源码暴露的安全风险。通过 F12 调试、打包反编译或网络抓包等手段,攻击者可以轻易绕过前端判断直接访问后端接口。

问题: 如何在「源码可见」「反编译可行」的环境下,设计一套真正安全且可扩展的权限体系?

本文将从 Web 安全视角 分析权限设计的底层风险,讲解 从前端到后端的统一权限防护策略,并通过示例代码展示如何有效抵御反编译带来的安全威胁。


一、问题定义与背景

1. 前端可逆向的现实

现代 Web 前端(如 React、Vue、Angular)使用 打包编译代码混淆 技术,但本质上仍是 运行在用户端的可执行脚本。攻击者可通过:

  • 浏览器开发者工具查看源码(即使混淆);
  • 抓包工具(如 Burp、Postman)直接模拟请求;
  • 反编译 WebAssembly / 前端加密逻辑;
  • 修改本地存储信息(token、role、flag)。

这意味着 任何前端层的权限校验都是不可信的

2. 常见错误示例

// ❌ 前端中错误的“权限控制”
if (userRole === 'admin') {
  showAdminPanel();
} else {
  hideAdminPanel();
}

攻击者只需在浏览器控制台输入:

userRole = 'admin'; showAdminPanel();

即可伪装为管理员。


二、解决方案与技术设计

🔒 权限控制的本质:可信判定必须在服务端

安全设计原则:

任何安全相关的判断都应在服务端完成,前端仅作展示或引导。

1. 后端统一鉴权与授权

后端需要构建一套集中式的 鉴权(Authentication)授权(Authorization) 模型:

  • 鉴权: 当前请求是谁?(JWT / Session / OAuth2)
  • 授权: 当前用户能做什么?(RBAC / ABAC / PBAC)

示例:基于 RBAC 的 Spring Boot 后端实现

// RoleBasedAccess.java(自定义注解)
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RoleBasedAccess {
    String[] value();
}
// SecurityInterceptor.java
@Component
public class SecurityInterceptor implements HandlerInterceptor {
    @Override
    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
        String token = req.getHeader("Authorization");
        User user = TokenUtils.verify(token);
        RoleBasedAccess access = ((HandlerMethod) handler).getMethodAnnotation(RoleBasedAccess.class);
        if (access != null && !user.hasAnyRole(access.value())) {
            res.setStatus(HttpServletResponse.SC_FORBIDDEN);
            return false;
        }
        return true;
    }
}
// 控制器示例
@GetMapping("/admin/list")
@RoleBasedAccess({"ADMIN"})
public ResponseEntity<?> listAdmins() {
    return ResponseEntity.ok(adminService.findAll());
}

✅ 即使攻击者篡改前端变量或修改 UI,仍无法访问未经授权的接口。


2. 前端仅作“权限可视化”,非“权限控制”

前端可以按服务器返回的权限标识动态渲染按钮、菜单,但不能依赖这些字段做业务安全判断。

前端示例(Vue + Axios)

// app.js
axios.interceptors.request.use(config => {
  const token = localStorage.getItem('token');
  config.headers.Authorization = token;
  return config;
});

// 动态菜单渲染
const user = getUserInfo(); // 后端返回的角色信息
const routes = allRoutes.filter(route => user.roles.includes(route.meta.role));
  • 菜单与按钮的展示只影响“视觉体验”;
  • 实际的访问结果由后端决定(即使前端按钮被伪造也无效)。

3. 防反编译与数据暴露的加固措施

威胁类型 防护策略 技术点
源码反编译 构建时混淆与 Tree Shaking Webpack terser / esbuild
关键逻辑逆向 将核心算法迁移至后端或 WebAssembly 例如签名、密钥生成
API 被直接调用 使用 Token + HMAC 签名 + 限流 JWT + Redis + Nginx
调试注入 CSP(Content Security Policy)限制 JS 执行环境 HTTP 安全头
数据泄漏 后端白名单过滤、敏感字段脱敏 DTO 防止 DB 字段透传

例如,通过 请求签名机制(HMAC) 阻止伪造请求:

// 前端发起请求前生成签名(所有参数 + 时间戳)
import crypto from "crypto";

const secret = "server-shared-key";
const timestamp = Date.now().toString();
const sign = crypto.createHmac('sha256', secret)
                   .update(userId + timestamp)
                   .digest('hex');

axios.post('/api/secure', { data, timestamp, sign });

后端验证签名合法性:

# Flask 示例
import hmac, hashlib

def verify_sign(userId, ts, sign):
    secret = "server-shared-key"
    expected = hmac.new(secret.encode(), f"{userId}{ts}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, sign)

三、优缺点分析与实践建议

模型 优点 缺点 适用场景
前端权限(展示级) 响应快、易实现 简单易被绕过 UI控制、低安全页面
后端权限(验证级) 安全可靠、可审计 开发略复杂 所有敏感接口必须
混合策略 安全+体验平衡 需前后端版本统一 企业后台中大型系统

实践建议:

  1. 前端负责体验,但不要承担安全职责;
  2. 后端负责权限核心,所有请求均二次校验;
  3. 静态资源与接口同时限流、校验、审计;
  4. 每个权限点应有日志可追溯、有策略可管理

四、结论

在反编译与源码暴露成为常态的 Web 环境中,前端安全只是幻觉,后端的权限才是真相

高质量的权限设计,不是让前端更“聪明”,而是让后端更“可信”;不是靠混淆隐藏逻辑,而是靠架构建立信任边界。

未来,随着 零信任架构(Zero Trust)前后端一体化加密通信 的普及,
权限设计将更智能、更去中心化,基于 身份、上下文与行为检测 的动态授权机制将逐渐取代传统 RBAC。


五、参考资料

  1. OWASP Top 10 2021 – A01: Broken Access Control
  2. Spring Security 官方文档
  3. MDN – Content Security Policy (CSP) 指南
  4. [JWT.io – JSON Web Token 官方标准]
  5. OWASP – Authorization Cheat Sheet

用 React 手搓一个 3D 翻页书籍组件,呼吸海浪式翻页,交互体验带感!

用 React 手搓一个 3D 翻页书籍组件,页角还能卷起来!从零到踩坑全记录

前端开发中,你是否也想过把枯燥的内容展示做得像翻书一样?本文记录了我从零开发一个 3D 交互式书籍组件 的完整过程——包括 CSS 3D 翻页、拖拽手势、页角海浪卷起效果,以及中间踩过的坑和最终的解决方案。

一、为什么要做这个组件?

在做一个 AI 知识库产品时,产品经理提了一个需求:

「能不能把教程做成一本可以翻页的书?用户点击或拖拽就能翻页,体验要像真书。」

市面上的轮播图、Tab 切换都太「平」了,我希望做一个有纵深感的 3D 翻书交互。翻遍了 npm,要么功能太简陋,要么依赖 Canvas 体积太大,最终决定——自己写一个

目标很明确:

  • 🎨 CSS 3D 实现真实翻页效果,不用 Canvas
  • ✋ 支持拖拽翻页、点击翻页、键盘翻页
  • 🌊 鼠标悬停页角时有「海浪卷起」的视觉提示
  • 📱 移动端触摸支持
  • 🧱 纯 React 组件,零外部翻书依赖

二、架构设计:一本书的 DOM 结构

先想清楚一本书的物理结构:

┌─────────────────────────────────┐
│           Container             │  ← perspective: 2000px 提供 3D 视角
│  ┌───────────────────────────┐  │
│  │       BookWrapper         │  │  ← 打开时 translateX(50%) 居中
│  │  ┌─────────────────────┐  │  │
│  │  │      Cover          │  │  │  ← rotateY(-180deg) 翻开
│  │  │  ┌ front ┐┌ back ─┐ │  │  │
│  │  │  │封面图片││内封页  │ │  │  │
│  │  │  └───────┘└───────┘ │  │  │
│  │  ├─────────────────────┤  │  │
│  │  │      Pages          │  │  │  ← 所有页面叠在一起
│  │  │  ┌ Page 1 ────────┐ │  │  │
│  │  │  │ front │ back   │ │  │  │  ← 每页双面
│  │  │  └────────────────┘ │  │  │
│  │  │  ┌ Page 2 ────────┐ │  │  │
│  │  │  │ front │ back   │ │  │  │
│  │  │  └────────────────┘ │  │  │
│  │  │  ┌ BackCover ─────┐ │  │  │
│  │  │  │   The End      │ │  │  │
│  │  │  └────────────────┘ │  │  │
│  │  └─────────────────────┘  │  │
│  └───────────────────────────┘  │
│        Navigation Bar           │
└─────────────────────────────────┘

核心思路:

  • 每一页都是绝对定位叠在一起,transform-origin: left center,翻页就是绕左边缘旋转 -180°
  • backface-visibility: hidden + 前后两个 div 模拟正反面
  • 通过 zIndex 控制翻过的页和未翻的页的层叠关系

三、核心实现

3.1 CSS 3D 翻页

关键 CSS:

.container {
  perspective: 2000px;  // 3D 视角距离
}

.page {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform-origin: left;  // 绕左边轴翻转
}

.pageFront, .pageBack {
  backface-visibility: hidden;  // 只显示朝向用户的面
}

.pageBack {
  transform: rotateY(180deg) translateZ(0.5px);  // 背面翻转 180°
}

用 Framer Motion 的 variants 控制翻转动画:

const variants = {
  flipped: {
    rotateY: -180,
    zIndex: isBuriedLeft ? index + 1 : pages.length + 10,
    transition: {
      rotateY: { duration: 0.6, ease: [0.645, 0.045, 0.355, 1] },
      zIndex: { delay: 0.6 },
    },
  },
  unflipped: {
    rotateY: 0,
    zIndex: pages.length - index,
    transition: {
      rotateY: { duration: 0.6, ease: [0.645, 0.045, 0.355, 1] },
      zIndex: { delay: 0.6 },
    },
  },
}

这里的贝塞尔曲线 [0.645, 0.045, 0.355, 1] 是精心调的,模拟纸张翻页时先快后慢的物理感。

3.2 拖拽翻页

参考电子书阅读器的拖拽逻辑:

// mousedown → 记录起点
// mousemove → 计算偏移,用 rAF 优化性能
// mouseup → 偏移超过阈值(80px)则触发翻页

const handleMouseMove = useCallback((e: MouseEvent) => {
  if (!isDragging) return
  currentDragXRef.current = e.clientX
  if (rafIdRef.current) cancelAnimationFrame(rafIdRef.current)
  rafIdRef.current = requestAnimationFrame(() => {
    setDragOffset(currentDragXRef.current - dragStartXRef.current)
  })
}, [isDragging])

拖拽过程中,当前页面会有一个「弓起」效果:

const curlAngle = isActiveDragPage
  ? Math.min(Math.abs(dragOffset) * 0.25, 45) * (dragOffset < 0 ? -1 : 1)
  : 0
const curlZ = isActiveDragPage
  ? Math.min(Math.abs(dragOffset) * 0.15, 30)
  : 0

根据拖拽偏移量,页面最多弓起 45°,同时沿 Z 轴抬升 30px,配合 box-shadow 产生投影,效果非常逼真。

3.3 页角海浪卷起效果 🌊

这是整个组件最有趣的交互细节:鼠标悬停在页角时,纸张会像海浪一样卷起来,提示用户「这里可以翻页」。

实现原理:在页面的右下角/左下角放置 80×80 的热区,hover 时用 border-radius: 100% + 渐变背景模拟卷角,配合 CSS @keyframes 实现呼吸式波浪动画。

.cornerZone {
  position: absolute;
  width: 80px;
  height: 80px;
  cursor: pointer;
}

.curlEffect {
  width: 0;
  height: 0;
  transition: width 0.35s cubic-bezier(0.34, 1.56, 0.64, 1),
              height 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}

// hover 时展开卷角
.cornerActive .curlEffect {
  width: 55px;
  height: 55px;
}

卷角的渐变模拟了纸张翻起时的明暗变化:

.cornerBottomRight .curlEffect {
  background: linear-gradient(
    225deg,
    rgba(253, 251, 247, 0.95) 0%,    // 翻起的纸面(亮)      
    rgba(253, 251, 247, 0.9) 35%,
    rgba(230, 225, 215, 0.85) 50%,   // 折痕处(暗)
    rgba(200, 195, 185, 0.4) 70%,
    transparent 100%                  // 渐隐到背景
  );
  border-top-left-radius: 100%;      // 关键!圆弧形卷角
}

海浪动画通过 @keyframes 让卷角大小在 50px - 70px 之间波动:

@keyframes curlWaveRight {
  0%   { width: 55px; height: 55px; }
  30%  { width: 70px; height: 70px; }  // 浪涌
  60%  { width: 50px; height: 50px; }  // 回落
  100% { width: 55px; height: 55px; }  // 归位
}

弹性过渡的贝塞尔曲线 cubic-bezier(0.34, 1.56, 0.64, 1) 让展开有一个「弹一下」的效果,像纸张被风吹起。

四、踩坑实录:那些让我抓狂的 Bug

坑 1:页角点击不触发翻页

现象:鼠标在页角卷起后点击,但页面没有翻动。

原因mousedown 事件冒泡到了父容器 .pages,触发了拖拽逻辑(isDragging = true)。由于 React 的条件渲染逻辑写了 !isDragging,页角区域立刻被卸载,onClick 根本来不及触发。

解决:在页角热区上阻止 mousedown 冒泡:

<div
  className={styles.cornerZone}
  onMouseDown={(e) => e.stopPropagation()}  // 关键!
  onTouchStart={(e) => e.stopPropagation()}
  onClick={(e) => {
    e.stopPropagation()
    setCornerHover('none')
    nextPage(e)
  }}
>

坑 2:翻到下一页时左侧短暂闪烁

现象:翻页时左侧会短暂显示封面内容,然后才变成当前页的背面。

第一次尝试(失败):用 Framer Motion 的 opacity 动画延迟隐藏已翻过的页面。设置了 delay: 0.65s,等翻转动画完成后再隐藏。

结果:时序不可靠。opacity 依赖 Framer Motion 的 variant 重算,isBuriedLeft 变化时 variant 值立刻更新,无论 delay 多少都可能出现竞态。

最终方案:彻底放弃 opacity 动画,改用 CSS visibility 隐藏深层页面:

// 只隐藏 "深层" 掩埋的页面(index < currentPageIndex - 1)
// 保留紧邻的前一页可见,确保左侧始终有背面内容
const isDeeplyBuried = isFlipped && index < currentPageIndex - 1

<motion.div style={{
  visibility: isDeeplyBuried ? 'hidden' : 'visible',
}}>

visibility: hidden即时的、无动画的、确定性的——完美解决闪烁问题。

坑 3:翻回上一页时又闪了

现象:修好了向后翻页,但翻回上一页时又出现闪烁。

原因unflipped variant 的 zIndex transition 的 delay 设为了 0,导致页面还在翻转动画过程中,zIndex 就提前降低了,被其他页面遮挡。

解决:双向翻页的 zIndex 都延迟到动画结束后再更新:

unflipped: {
  rotateY: 0,
  zIndex: pages.length - index,
  transition: {
    rotateY: { duration: 0.6, ease: [0.645, 0.045, 0.355, 1] },
    zIndex: { delay: 0.6 },  // 和翻页动画时长一致!
  },
},

坑 4:最后一页拖不动但光标还是「抓手」

现象:翻到最后一页(The End),虽然结束页已经阻止了事件冒泡,但在页面空白区域鼠标仍然显示 grab 光标。

解决:检测最后一页状态,同时禁用拖拽逻辑和光标样式:

const isLastPage = currentPageIndex >= pages.length - 1

// 禁用 mousedown
const handleMouseDown = useCallback((e) => {
  if (!isOpen || isLastPage) return  // 最后一页不触发拖拽
  // ...
}, [isOpen, isLastPage])

// 光标
cursor: isOpen
  ? (isLastPage ? 'default' : isDragging ? 'grabbing' : 'grab')
  : 'default'

五、最终效果

组件支持的交互方式一览:

交互方式 说明
🖱️ 拖拽翻页 按住页面左右拖拽,超过 80px 阈值松手翻页
🌊 页角点击 悬停右下角/左下角出现卷起效果,点击翻页
🔘 导航栏 底部导航栏前后翻页按钮
⌨️ 键盘 ← → 翻页 / Escape 关闭 / Home End 跳转
📱 触摸 移动端触摸滑动翻页
📕 封面 点击或向左拖拽打开书籍

使用方式非常简单:

import InteractiveBook from '@stateless/InteractiveBook'

<InteractiveBook
  coverImage="/cover.jpg"
  bookTitle="AI Agent 完全指南"
  bookAuthor="AI 专家"
  pages={[
    {
      pageNumber: 1,
      title: '第一章',
      content: <div>正面内容</div>,
      backContent: <div>背面内容</div>,
    },
    // ...
  ]}
  onPageChange={(index) => console.log('当前页:', index)}
  enableKeyboard
/>

六、技术栈总结

技术 用途
React + TypeScript 组件逻辑
Framer Motion 翻页动画、封面动画、导航栏动画
CSS 3D Transform perspectiverotateYpreserve-3dbackface-visibility
CSS Modules (Less) 样式隔离
requestAnimationFrame 拖拽性能优化
lucide-react 图标

七、写在最后

一个看似简单的翻书组件,涉及了 CSS 3D 变换、事件冒泡机制、Framer Motion variant 生命周期、zIndex 时序控制 等多个知识点。最大的教训是:

不要用动画属性(opacity/transform)去做「显示/隐藏」这种二元状态控制。visibility 或条件渲染——确定性比优雅更重要。

完整代码已开源,欢迎 Star ⭐


GitHub: Pro React Admin

预览地址: Interactive Book

image.png

image.png

如果这篇文章对你有帮助,别忘了点个赞 👍 收藏一下 📌

【ThreeJS实战】从86MB到4MB:复杂模型加载优化黑魔法

前言:正当我沉浸在将draw call从52000优化到1的喜悦中无法自拔时,产品经理这时候又杀过来了:"客户说模型加载要30秒,还没进去就关页面了,你优化一下?"我打开Network面板一看,卧槽,86MB的GLB文件!这谁顶得住啊...

如果你也遇到过这种情况:精心打磨的3D场景,本地运行丝滑流畅,一上线用户骂娘——"破网站卡死了"、"怎么还在转圈"、"手机直接闪退"。别急着怪用户网速慢,先看看你的模型是不是太胖了

我这有个复杂模型,几何体+贴图一共86MB,在4G网络下加载需要30秒(Chrome模拟Slow 4G(3mb/s)一直加载...)。今天咱们不讲Blender操作模型(之前用Blender是因为没招,现在有更狠的),直接用命令行黑魔法把它压到4MB!!,加载时间从30秒干到1.5秒

以下是优化前的绝望现场整整加载了30多秒...

image.png

一、优化思路

既然知道了加载为什么那么慢的原因,那我们就可以开始想想该怎么优化了

我目前的思路就是用gltf-transform 先把模型体积压下来,要不然渲染的时候再流畅,客户等到第二十秒的时候关闭浏览器,也没有意义了。。

二、DRACOLoader

ThreeJS DRACOLoader直接无缝解压缩被压缩的模型

安装压缩模型工具(不用Blender,命令行搞定)

# 安装gltf-transform(一行命令搞定Draco压缩+WebP+KTX2)
npm install -g @gltf-transform/cli

至于我为什么选择gltf-transform而不是gltf-pipeline,以下是它们的对比:

特性 gltf-pipeline gltf-transform
Draco压缩 ✅ 支持 ✅ 支持(更快)
WebP纹理 ❌ 不支持 ✅ 支持(关键!)
KTX2/Basis ❌ 不支持 ✅ 支持
安装体积 大(依赖多) 小(WASM核心)
推荐度 ⭐⭐⭐ ⭐⭐⭐⭐⭐

压缩你的GLB(80MB → 4MB)

gltf-transform optimize input.glb output.glb \
  --compress draco \
  --texture-compress webp \
  --texture-size 2048

以下是我压缩之后的体积:

image.png

可以看到,模型的体积得到了巨大的缩减,从原来的86mb到现在的4mb左右!

参数说明

参数 说明 建议值
--texture-compress webp 贴图转WebP格式 必加,体积减半
--texture-compress ktx2 贴图转KTX2(GPU直读) 如果目标设备支持,比WebP更好
--texture-size 2048 限制最大贴图尺寸 必加,4096→2048省4倍显存
--compress draco 启用Draco几何压缩 必加,默认就是sequential模式
--compress-method sequential Draco编码模式 sequential(默认,小体积)或 edgeloop(快解码)
--compress-level 10 Draco压缩级别 0-10,10压最狠但解压慢,建议7-10
--flatten 打平节点层级 如果模型层级太深,加这个减少DrawCall(但会丢失动画)

以下是优化之后的加载时间,就问你快不快!

image.png

Three.js加载代码(关键!)

/**
 * 优化后的 GLB 加载步骤(Draco / gltf-transform)
 *
 * 依赖:Three.js、GLTFLoader、DRACOLoader
 * 解码器:把 three 的 examples/jsm/libs/draco/gltf/ 放到站点 /draco/ 下,或使用 CDN 路径
 */

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';

// ————— 步骤 1:创建 Draco 解码器并指定路径 —————
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/');
// 或用 CDN(与项目 three 版本一致):'https://cdn.jsdelivr.net/npm/three@0.182.0/examples/jsm/libs/draco/gltf/'

// ————— 步骤 2:把 DRACOLoader 挂到 GLTFLoader 上 —————
const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);

// ————— 步骤 3:正常 load,普通 GLB 与 Draco 压缩的 GLB 都能加载 —————
loader.load(
  'https://your-cdn.com/model-optimized.glb',
  (gltf) => {
    scene.add(gltf.scene);
  },
  undefined,
  (err) => console.error(err),
);

// Promise 写法(可选):
export function loadOptimizedGLB(url) {
  return new Promise((resolve, reject) => {
    loader.load(url, resolve, undefined, reject);
  });
}
// 使用方式:const gltf = await loadOptimizedGLB(url);

注意setDecoderPath 指向的是 Draco 的 WASM 解码文件,需要从 Three.js 的 examples/jsm/libs/draco/ 目录复制到你的 public 文件夹,或者用 CDN(上面示例用的是从threejs复制的本地解码文件)。

image.png

image.png

避坑指南

  1. 别重复压缩:Draco是有损压缩,压一次损失一点精度,别压两遍!先备份原文件。
  2. WebP兼容性:虽然现代浏览器都支持WebP,但如果你要兼容IE11(虽然不应该),只能用PNG/JPG。
  3. KTX2谨慎用:KTX2(Basis Universal)压缩率最高,但需要 GPU 支持,老旧手机可能解码失败,建议 WebP 更稳妥。
  4. 量化精度:如果你发现压缩后的模型出现裂缝(顶点没对齐),把 --quantization-position 从 10 调到 14。

还有一件事:Draco是有损压缩,但视觉上几乎看不出差别(工业模型顶点精度够高),解压是在Web Worker里进行的,不会卡主线程。

三、又到了喜闻乐见的前后对比(刺激!)

指标 原始模型 Draco压缩
文件体积 86MB 4MB
4G加载时间 30秒 1.5秒

可以看到加载时间跨度很大,从30秒到1.5秒,足足提升了20倍,客户本来都要睡着了,但现在客户眨了一下眼睛,就发现眼前屏幕里的世界都不一样了~

总结

优化路径:86MB(原始)→ 4MB(Draco+WebP)→ 1.5秒加载完成

核心认知

  • gltf-transform:一站式解决几何体+贴图压缩,不用Blender,一行命令搞定
  • Draco:解决"下载慢"(几何体从18MB压到2MB)
  • WebP:解决"贴图肥"(68MB压到2MB,兼容性最好)

没用到的手段(进阶可选)

  • KTX2:比WebP体积更小且GPU直读,但需要设备支持,老旧手机可能解码失败
  • 分块加载:如果4MB还是大,可以拆成"外壳1MB+细节3MB",首屏秒开

不用Blender,全程命令行+代码搞定,这才是工程师的浪漫。

下篇预告:【ThreeJS实战】GPU还是100%?LOD策略:让远处模型自动"减肥"

互动:你用gltf-transform压了多少倍?我20倍算不算狠?评论区报出你的原始体积vs优化后体积,看看谁是真正的"压王"😏

Nginx 路径映射深度解析:从本地开发到生产交付的底层哲学

Nginx 静态资源映射:从原理到生产环境的最佳实践

摘要:在现代前后端分离架构中,Nginx 不仅是高性能的静态资源服务器,更是不可或缺的反向代理枢纽。然而,由于对资源映射(root/alias)及请求转发(proxy_pass)逻辑的理解偏差,往往会导致从 Windows 开发环境迁移至 Linux 生产环境时出现 404 或转发异常。本文将从 HTTP 协议视角出发,深度剖析“路径映射三剑客”的底层逻辑,并提供一套可落地的工程化配置规范与避坑指南。


1. 业务场景与工程痛点

在实际的工程链路中,我们经常遇到这样的场景: 前端同学在 Windows 本地使用 Nginx 调试 SPA(单页应用)或静态站点,一切运行正常。但当 CI/CD 流水线将代码部署到 Linux 生产服务器后,访问特定资源(如图片、次级路由)却频频出现 404 错误。

这并非玄学,而是由于对 Nginx 路径解析机制操作系统文件系统差异 理解不足导致的。要解决这个问题,我们需要先建立正确的路径映射心智模型。

2. 核心模型解析:URL 与文件系统的映射

Nginx 的核心职责之一,就是将抽象的 HTTP URI 映射到具体的 服务器文件系统路径

2.1 URI 的语义差异

在配置之前,必须明确 URL 尾部斜杠的协议语义:

  • /images:客户端请求名为 images资源实体(可能是文件,也可能是目录)。
  • /images/:客户端明确请求名为 images目录容器

工程细节: 当用户访问 /images(不带斜杠)且服务器上存在同名目录时,Nginx 默认会返回 301 Moved Permanently,自动重定向到 /images/。这是为了确保相对路径资源(如 ./logo.png)能基于正确的 Base URL 加载。


3. 资源映射三剑客:Root、Alias 与 Proxy_Pass

rootaliasproxy_pass 是 Nginx 流量分发的核心指令。前两者解决的是如何将 URI 映射到 本地文件系统,而后者解决的是如何将请求转发到 网络服务接口

3.1 Root:追加逻辑 (Append)

root 指令采用追加策略。它将请求的 URI 完整拼接到 root 指定的路径之后。

  • 计算公式最终物理路径 = root路径 + 完整URI
  • 配置示例
    location /static/ {
        root /var/www/app;
    }
    
  • 解析过程:请求 GET /static/css/style.css -> 物理路径:/var/www/app/static/css/style.css

3.2 Alias:替换逻辑 (Replace)

alias 指令采用替换策略。它用 alias 指定的路径替换掉 location 匹配到的部分。

  • 计算公式最终物理路径 = alias路径 + (完整URI - location匹配部分)
  • 配置示例
    location /static/ {
        alias /var/www/app/public/;
    }
    
  • 解析过程:请求 GET /static/css/style.css -> 匹配 /static/ -> 剩余 css/style.css -> 最终访问:/var/www/app/public/css/style.css

3.3 Proxy_Pass:请求转发逻辑 (Forward)

与处理本地文件的指令不同,proxy_pass 处理的是网络协议栈的转发。其路径处理逻辑遵循相似的“追加”与“替换”哲学,由目标 URL 结尾是否有 / 决定。

场景 A:不带斜杠(透明转发,对应 Root 逻辑)

proxy_pass 的目标 URL 不带路径(即没有结尾的 /)时,Nginx 会将原始请求的 URI 完整地传递给后端服务。

  • 配置示例
    location /api/ {
        proxy_pass http://127.0.0.1:3000; 
    }
    
  • 路径解析:请求 GET /api/user -> 转发到 http://127.0.0.1:3000/api/user
  • 工程特征location 匹配路径被完整保留。适用于后端服务本身就包含 /api 前缀的场景。
场景 B:带斜杠(路径重写,对应 Alias 逻辑)

proxy_pass 的目标 URL 包含路径(即使只有一个结尾的 /)时,Nginx 会将 URI 中匹配 location 的部分替换为该路径。

  • 配置示例
    location /api/ {
        proxy_pass http://127.0.0.1:3000/; 
    }
    
  • 路径解析:请求 GET /api/user -> 转发到 http://127.0.0.1:3000/user
  • 工程特征location 匹配路径被“剥离”。适用于后端服务是纯净接口,仅通过 Nginx 统一前缀入口的场景。

3.4 资源映射三剑客对比表

假设统一配置 location /api/,观察不同指令下的映射结果:

指令 映射目标 URI 处理方式 示例配置 实际请求 -> 结果映射 典型场景
Root 本地磁盘 追加 (Append) root /data; /api/user -> /data/api/user 静态站点默认部署
Alias 本地磁盘 替换 (Replace) alias /data/v1/; /api/user -> /data/v1/user 虚拟路径、资源别名
Proxy_Pass (无/) 远程服务 透明转发 proxy_pass http://node:3000; /api/user -> node:3000/api/user 后端服务自带前缀
Proxy_Pass (带/) 远程服务 路径重写 proxy_pass http://node:3000/; /api/user -> node:3000/user 统一入口,后端无前缀

4. 工程化落地:跨平台环境差异处理

在团队协作中,统一开发环境(Windows/Mac)与生产环境(Linux)的配置规范至关重要。

4.1 Windows 开发环境的陷阱

Windows 文件系统有“盘符”概念,且对路径分隔符不敏感。

  • 绝对路径问题: 在 Windows 下配置 root /html;,Nginx 会将其解析为当前盘符的根目录(如 D:\html),而非 Nginx 安装目录。
  • 最佳实践使用相对路径
    # 推荐:相对于 Nginx 安装目录 (prefix)
    location / {
        root html; 
        index index.html;
    }
    

4.2 Linux 生产环境的规范

Linux 环境强调权限控制与路径的确定性。

  • 绝对路径强制: 生产配置必须使用绝对路径,避免因启动方式不同导致的工作目录漂移。

    root /usr/share/nginx/html;
    
  • 权限隔离 (Permission): 常见的 403 Forbidden 错误通常并非配置错误,而是权限问题。

    • 要求:Nginx 运行用户(通常是 nginxwww-data)必须拥有从根目录到目标文件全路径的 x (执行/搜索) 权限,以及目标文件的 r (读取) 权限。
    • 排查命令
      namei -om /var/www/project/static/image.png
      
  • Alias 的斜杠对称性: 这是一个容易被忽视的 Bug 源。在 Linux 下使用 alias 时,如果 location 只有尾部斜杠,建议 alias 也加上尾部斜杠,保持对称,避免路径拼接错位。

    # Good
    location /img/ {
        alias /var/www/images/;
    }
    

5. 调试与排错指南

当出现 404 或 403 时,不要盲目猜测,请遵循以下排查路径:

  1. Check Error Log: 这是最直接的证据。Nginx 的 error.log 会明确打印出它试图访问的完整物理路径。

    open() "/var/www/app/static/css/style.css" failed (2: No such file or directory)
    

    对比日志中的路径与你预期的路径,通常能立刻发现 rootalias 的误用。

  2. 验证文件存在性: 直接复制日志中的路径,在服务器上执行 ls -l <path>,确认文件是否存在以及权限是否正确。


总结: Nginx 的路径映射与转发逻辑虽然细碎,但其背后遵循着高度一致的“追加”与“替换”哲学。掌握 rootaliasproxy_pass 的底层差异,不仅能解决 404/403 等表象问题,更能帮助开发者构建出优雅、可维护的配置体系。在工程实践中,建议通过规范化路径命名(如统一使用 /api/ 前缀)与环境感知配置(如 Linux 绝对路径强制化)来降低运维复杂度,确保从本地开发到生产交付的丝滑顺畅。

主管:”人家 Node 框架都用 Nest.js 了 , 你怎么还在用 Express ?“

我反驳主管道 : “我自己做项目做着玩 ! 你管我用哪一个 !”

回家之后 , 我开始好奇那么多 Node 框架 , 到底有什么区别啊?

Node.js Web 框架各式各样 , 下面简单的介绍一下这些 Node.js Web 框架 !

一、分类

Node.js Web 框架主要分 3 类:

分类 核心特点 代表框架 适用场景
极简核心框架 仅封装 HTTP 基础能力,无冗余功能 Express、Koa 中小型 API、自定义业务系统
全栈 / 企业级框架 内置路由、ORM、验证、鉴权等全套能力 NestJS、AdonisJS 大型企业应用、团队协作项目
高性能框架 基于异步 I/O/ 编译优化,极致性能 Fastify、Hapi 高并发 API、微服务
特殊场景框架 针对特定场景优化(如 SSR、低代码) Next.js、Nuxt.js(Node 端)、Sails.js 前端 SSR、低代码平台

二、主流框架详细介绍

⚠️ : 排名不分先后顺序

1. Express(最经典的极简框架)

  • 核心定位:Node.js Web 框架的 “鼻祖”,极简、灵活,无内置冗余功能,生态最丰富。

  • 核心特性

    • 中间件机制(线性中间件,req -> 中间件1 -> 中间件2 -> res);
    • 简洁的路由系统;
    • 无内置 ORM / 验证,需手动集成第三方库(如 mongooseexpress-validator)。
  • 示例代码

    const express = require('express');
    const app = express();
    
    // 中间件
    app.use(express.json());
    
    // 路由
    app.get('/api/user', (req, res) => {
      res.json({ name: '张三', age: 20 });
    });
    
    app.listen(3000, () => console.log('Express 启动在 3000 端口'));
    
  • 优点:生态极全(几乎所有 Node 库都兼容)、学习成本低、灵活度高;

  • 缺点:回调嵌套(易出现 “回调地狱”)、无内置类型支持(TS 需手动配置)、无统一规范(团队协作易混乱);

  • 适用场景:中小型 API 服务、快速原型开发、个人项目。

2. Koa(Express 团队升级版)

  • 核心定位:Express 原团队开发,解决 Express 回调地狱问题,基于 async/await 重构。

  • 核心特性

    • 洋葱模型中间件(中间件可双向执行,如 “请求进来执行 -> 响应出去再执行”);
    • 原生支持 async/await,无回调地狱;
    • 比 Express 更精简(甚至没有内置路由,需装 koa-router)。
  • 示例代码

    const Koa = require('koa');
    const koaRouter = require('koa-router');
    const koaBody = require('koa-body');
    
    const app = new Koa();
    const router = new koaRouter();
    
    // 洋葱模型中间件
    app.use(async (ctx, next) => {
      console.log('请求开始');
      await next(); // 执行下一个中间件
      console.log('请求结束'); // 响应时执行
    });
    
    app.use(koaBody());
    router.get('/api/user', async (ctx) => {
      ctx.body = { name: '张三', age: 20 };
    });
    
    app.use(router.routes());
    app.listen(3000, () => console.log('Koa 启动在 3000 端口'));
    
  • 优点:异步体验好、洋葱模型灵活(适合日志 / 鉴权 / 异常捕获)、轻量;

  • 缺点:生态比 Express 略少、需手动集成更多第三方库;

  • 适用场景:中小型 API 服务、需要灵活中间件的场景、嫌弃 Express 回调的项目。

3. NestJS(企业级 TypeScript 框架)

  • 核心定位:对标 Spring Boot,基于 TypeScript,强调模块化、依赖注入,适合大型团队协作。

  • 核心特性

    • 强制 TypeScript 开发,类型安全;
    • 模块化架构(Module + Controller + Service);
    • 内置依赖注入、拦截器、管道、守卫(鉴权)、过滤器;
    • 兼容 Express/Koa,可无缝集成第三方库;
    • 支持微服务、GraphQL、WebSocket。
  • 示例代码

    // user.controller.ts
    import { Controller, Get } from '@nestjs/common';
    import { UserService } from './user.service';
    
    @Controller('api/user')
    export class UserController {
      constructor(private readonly userService: UserService) {}
    
      @Get()
      getUser() {
        return this.userService.getUser();
      }
    }
    
    // user.service.ts
    import { Injectable } from '@nestjs/common';
    
    @Injectable()
    export class UserService {
      getUser() {
        return { name: '张三', age: 20 };
      }
    }
    
  • 优点:规范统一、类型安全、适合大型项目 / 团队、生态完善(官方封装了大量企业级能力);

  • 缺点:学习成本高、入门门槛高、小型项目用着 “重”;

  • 适用场景:大型企业应用、微服务、团队协作项目、需要强类型的项目。

4. Fastify(高性能极简框架)

  • 核心定位:极致性能,比 Express 快 2-3 倍,专为高并发 API 设计。

  • 核心特性

    • 基于 JSON Schema 验证请求参数,性能优于传统验证库;
    • 内置日志、压缩、路由缓存,无需额外配置;
    • 兼容 Express 中间件;
    • 支持 TypeScript。
  • 示例代码

    const fastify = require('fastify')({ logger: true });
    
    // 路由 + 参数验证
    fastify.get('/api/user', {
      schema: {
        querystring: {
          age: { type: 'number' }
        }
      }
    }, async (request, reply) => {
      return { name: '张三', age: request.query.age || 20 };
    });
    
    fastify.listen({ port: 3000 }, (err) => {
      if (err) throw err;
      console.log('Fastify 启动在 3000 端口');
    });
    
  • 优点:性能极高、内置功能丰富(无需装大量中间件)、轻量;

  • 缺点:生态比 Express 小、部分特性(如 Schema 验证)有学习成本;

  • 适用场景:高并发 API、微服务、对性能要求高的项目。

5. Hapi(稳定的企业级框架)

“还记得当初在 沃尔玛 买了虾 , 自己回家自己做 鸡油炒河虾仁 , 艾玛 , 老香了!!! ”

  • 核心定位:由 Walmart ( 沃尔玛 ) 开发,强调配置优于编码,适合稳定的企业级服务。

  • 核心特性

    • 内置路由、验证、缓存、日志,无需第三方库;
    • 插件化架构,扩展能力强;
    • 稳定性极高(适合金融 / 电商等核心系统)。
  • 优点:稳定、内置功能全、安全性高;

  • 缺点:学习成本高、灵活性低、性能不如 Fastify;

  • 适用场景:金融 / 电商等核心系统、对稳定性要求极高的项目。

6. Next.js(前端 SSR/SSG 框架,Node 端核心)

  • 核心定位:React 生态的全栈框架,Node 端负责服务端渲染(SSR)、静态生成(SSG)。

  • 核心特性

    • 服务端渲染(提升 SEO、首屏加载速度);
    • 自动路由(基于文件系统);
    • 内置 API 路由(无需额外搭后端);
    • 支持静态生成、增量静态再生。
  • 适用场景:React 前端项目、需要 SEO 的网站(如博客、电商)、全栈 React 应用。

7. Sails.js(低代码 / 快速开发框架)

  • 核心定位:对标 Ruby on Rails,内置 ORM、蓝图 API、实时通信,适合快速开发全栈应用。

  • 核心特性

    • 自动生成 CRUD API(蓝图路由);
    • 内置 Waterline ORM(支持多数据库);
    • 支持 WebSocket 实时通信;
  • 优点:开发速度极快、低代码;

  • 缺点:灵活性低、定制化成本高;

  • 适用场景:快速原型开发、低代码平台、小型全栈应用。

8. AdonisJS(Node.js 版的 Laravel,全栈企业级框架)

  • 核心定位:对标 PHP 界的 Laravel,是 Node.js 生态中 “开箱即用” 的全栈框架,内置全套企业级能力,强调 “约定优于配置”。

  • 核心特性

    • 内置 ORM(Lucid ORM):支持 MySQL、PostgreSQL 等,无需手动集成第三方 ORM;
    • 内置身份验证(用户注册 / 登录 / 权限)、表单验证、CSRF 保护;
    • 支持 MVC 架构、路由分组、中间件、任务调度;
    • 原生支持 TypeScript,类型安全;
    • 内置模板引擎(Edge),也支持前后端分离;
  • 示例代码(核心路由 + ORM)

    // start/routes.ts
    import Route from '@ioc:Adonis/Core/Route'
    import User from 'App/Models/User'
    
    // 路由 + 数据库查询
    Route.get('/api/user', async () => {
      const user = await User.find(1) // Lucid ORM 查用户
      return { name: user?.name, age: user?.age }
    })
    
    // 表单验证
    Route.post('/api/user', async ({ request }) => {
      const data = request.validate({
        schema: {
          name: schema.string(),
          age: schema.number()
        }
      })
      await User.create(data) // 新增用户
      return { success: true }
    })
    
  • 优点:开箱即用(无需装大量依赖)、Laravel 开发者易上手、规范统一、内置安全特性;

  • 缺点:生态比 Express/NestJS 小、灵活性略低、国内使用较少(中文文档有限);

  • 适用场景:全栈 Node.js 应用、Laravel 转 Node 开发的团队、中小型企业应用、需要快速搭建带数据库的业务系统。

9. Nuxt.js(Vue 生态全栈框架,Node 端负责 SSR/SSG)

  • 核心定位:Vue 生态的官方全栈框架,基于 Vue + Node.js 实现服务端渲染(SSR)、静态站点生成(SSG),解决 Vue 单页应用 SEO 差的问题。

  • 核心特性

    • 自动路由(基于 pages 目录,无需手动配置路由);
    • 服务端渲染(SSR)、静态生成(SSG)、增量静态再生(ISR);
    • 内置 API 路由(server/api 目录,无需额外搭后端服务);
    • 支持 Vue3 + TypeScript、自动代码分割、缓存优化;
    • 集成 Pinia(状态管理)、Nuxt Modules(生态插件);
  • 示例代码(核心用法)

    <!-- pages/api/user.vue (页面路由) -->
    <template>
      <div>{{ user.name }}</div>
    </template>
    
    <script setup lang="ts">
    // 服务端获取数据(SSR)
    const { data: user } = await useAsyncData('user', () => 
      $fetch('/api/user') // 调用内置 API 路由
    )
    </script>
    
    // server/api/user.ts (内置 API 路由)
    export default defineEventHandler(() => {
      return { name: '张三', age: 20 }
    })
    
  • 优点:Vue 开发者无缝上手、解决 SEO 问题、全栈一体化(前端 + Node 端)、生态完善;

  • 缺点:仅适配 Vue 技术栈、Node 端逻辑定制化能力有限、大型项目需深入理解其生命周期;

  • 适用场景:Vue 全栈应用、需要 SEO 的网站(博客 / 电商 / 官网)、静态站点生成、中小型 Vue 项目。

10. Egg.js(阿里开源,企业级 Node.js 框架)

  • 核心定位:阿里开源的企业级框架,基于 Express/Koa 封装,强调 “约定优于配置”,适合中大型团队协作。

  • 核心特性

    • 基于 Koa2(洋葱模型),兼容 Koa/Express 中间件;
    • 内置多进程模型(Master + Worker),自动利用多核 CPU;
    • 插件化架构(如 egg-mysql、egg-redis),生态丰富(阿里官方维护);
    • 支持 TypeScript、单元测试、日志、监控;
    • 规范的目录结构(controller/service/middleware/config),团队协作友好;
  • 示例代码

    // app/controller/user.js
    const { Controller } = require('egg');
    
    class UserController extends Controller {
      async index() {
        const { ctx } = this;
        // 调用 service 层
        const user = await ctx.service.user.getUser();
        ctx.body = user;
      }
    }
    
    module.exports = UserController;
    
    // app/service/user.js
    const { Service } = require('egg');
    
    class UserService extends Service {
      async getUser() {
        // 用 egg-mysql 查数据库
        return await this.app.mysql.get('user', { id: 1 });
      }
    }
    
    module.exports = UserService;
    
  • 优点:阿里背书、规范统一、多进程性能优、国内生态完善(中文文档 / 社区)、适合团队协作;

  • 缺点:灵活性低于 Express/Koa、学习成本中等、小型项目用着 “重”;

  • 适用场景:中大型企业应用、阿里系技术栈项目、国内团队协作项目、需要多进程优化的 Node 服务。

三、 对比

框架 学习成本 性能 生态 类型支持 适用规模 核心优势 技术栈 / 定位
Express 中等 极丰富 需手动配 小 / 中 灵活、生态全、入门快 极简核心框架
Koa 中等 丰富 需手动配 小 / 中 洋葱模型、async/await 极简核心框架(Express 升级版)
NestJS 中等 丰富 原生 TS 中 / 大 规范、企业级、团队协作 企业级 TS 框架
Fastify 极高 中等 原生 TS 小 / 中 / 大 极致性能、内置功能全 高性能极简框架
Hapi 中高 中等 需手动配 中 / 大 稳定、安全、企业级 企业级配置优先框架
Next.js 中等 极丰富 原生 TS 小 / 中 / 大 React SSR、全栈一体化 React 全栈框架
Sails.js 中等 中等 需手动配 低代码、开发速度快 低代码全栈框架
AdonisJS 中等 中等 原生 TS 小 / 中 Laravel 风格、开箱即用 全栈企业级框架(Node 版 Laravel)
Nuxt.js 中等 极丰富 原生 TS 小 / 中 / 大 Vue SSR、全栈一体化、SEO 优 Vue 全栈框架
Egg.js 中高 丰富 需手动配 中 / 大 阿里背书、多进程、国内生态好 企业级框架(基于 Koa)

四、选型建议

  1. 个人 / 小型项目、快速开发:选 Express(生态全)或 Koa(异步体验好);
  2. 高并发 API、微服务:选 Fastify(性能第一);
  3. 大型企业应用、团队协作:选 NestJS(TS + 规范)或 Hapi(稳定);
  4. React 全栈、需要 SEO:选 Next.js;
  5. 低代码、快速原型:选 Sails.js。

总结

  1. 核心维度:选型优先看「项目规模 + 团队技术栈 + 性能要求」,小型项目别用重框架(如 NestJS),大型项目别用太灵活的框架(如 Express);
  2. 生态优先级:如果需要集成大量第三方库,Express / Koa / Next.js 是首选;
  3. 性能优先级:高并发场景直接选 Fastify;
  4. 团队协作:大型团队优先 NestJS(强规范),避免 Express 因灵活导致的代码混乱。

okokok , 这个文章到这里就结束了 , 我们有缘再会 😁😁😁 !!!

深入理解 Vue.js 渲染机制:从声明式到虚拟 DOM 的完整实现

相关概念:

命令式 VS 声明式

从范式上来看,视图层框架通常分为:

  • 命令式框架
    • 更加关注过程,代码本身描述的是“做事的过程”,符合逻辑直觉
    •   // 自然语言描述能够与代码产生一一对应的关系
        // 示例:
        const div = document.querySelector('#app'// 获取div
        div.innerText = 'hello world'// 设置文本内容
        div.addEventListener('click', () => { alert('ok') }) // 绑定点击事件
      
  • 声明式框架
    • 更加关注结果,主要是提升代码的可维护性
    •   // 用户提供一个“预期的结果”,中间的过程由vue.js实现
        // 示例
        <div @click="()  => alert('ok')">hello world</div>
      
    • 更新时性能消耗 = 找出差异的性能消耗 + 直接修改的性能消耗

因为声明式框架在更新时比命令式框架多了“找出差异”的过程,所以声明式代码的性能不会优于命令式代码的性能。而对比命令式代码,声明式代码又具有更强的可维护性,更加的直观。所以框架要做的就是:在保持可维护性的同时让性能损失最小化

在开发过程中,原生JS操作DOM,虚拟DOM和innerHTML三者操作页面的性能都与创建页面、更新页面,页面大小、变更部分的大小有关系,选择哪种更新策略,需要结合心智负担、可维护性等因素综合考虑。

性能对比

更新策略 心智负担 可维护性 性能 适用场景
原生JS 最高 简单页面
虚拟DOM 复杂应用
innerHTML 静态内容

运行时 VS 编译时

以上文中声明式框架示例代码为例,简单描述vue.js的渲染过程:

1、通过编译器【compile】 解析模版字符串识别到需要创建一个DOM元素,设置内容为hello world,并为其绑定一个点击事件,完成后输出一个虚拟DOM【即一个描述真实DOM的js对象】

2、通过渲染函数【render】 将虚拟DOM渲染成真实的DOM树挂载到指定元素上,完成渲染

当设计一个框架的时候,有三种选择

  • 纯运行时
    • 上面提到的如果只用渲染函数,由用户直接提供虚拟DOM作为入参,就是所谓的纯运行时框架
    • 没有编译过程,也就无法添加相关的优化手段,比如tree-shaking
  • 运行时 + 编译时
    • 代码运行时由编译器将语义化代码编译成目标数据并作为渲染函数的入参,这种操作就是 运行时编译框架。它既支持运行时【即用户直接提供数据对象】,又支持编译时【即将用户语义化代码编译为目标数据】
    • 由于代码运行时才开始编译会产生一定的性能开销,因此可以在构建时就执行编译操作,以提升性能。【在 Vue 3.5.22 中,运行时编译通过 @vue/compiler-dom 实现,构建时编译通过 @vitejs/plugin-vue 实现】
  • 纯编译时
    • 如果省略上面的渲染函数,直接将用户代码通过编译器完成真实DOM的渲染,就是一个纯编译时框架。即不支持任何运行时内容。
    • 由于不需要任何运行时,而是直接将代码编译成可执行的js代码,因为性能可能会更好,但是有损灵活性。

Vue.js就是内部封装了命令式代码从而实现的面向用户的声明式框架;是运行时+编译时架构,目的在于保持灵活性的基础上尽可能的优化性能

其中组件的实现依赖于渲染器,组件中模板的编译依赖于编译器虚拟DOM作为媒介在整个渲染过程中作为组件真实DOM的载体协助实现内容渲染和更新。

虚拟DOM【vnode

虚拟DOM 是一个用来描述真实DOM的js对象。

使用虚拟DOM的好处是可以将不同类型的标签、属性及子节点抽象成一个对象,这样描述UI可以更加灵活。

// 上文中的代码可以用以下形式表示
const vnode= {
    // 标签名称
    tag'div',
    // 标签属性
    props: {
        onClick: () =>alert('ok')
    },
    // 子节点
    children'hello world'
}

vue中的h函数就是一个辅助创建虚拟DOM的工具函数

import { h } from 'vue'

export default {
    render() {
        return h('div', { onClick: () => alert('ok') }, 'hello world')
    }
}

// 等价于
export default {
    render() {
        return {
            tag: 'div',
            props: {
                onClick: () => alert('ok')
            },
            children: 'hello world'
        }
    }
}

// 等价于
<div @click="() => alert('ok')">hello world</div>

虚拟DOM的性能优势:

  • 批量更新:可以将多次DOM操作合并为一次
  • 跨平台:同一套代码可以渲染到不同平台
  • 优化策略:通过diff算法最小化DOM操作

组件

组件就是一组DOM元素的封装,它可以是一个返回虚拟DOM的函数,也可以是一个对象。组件的返回值也是虚拟DOM,它代表组件要渲染的内容。

编译器【compile】

编译器的作用是将组件模板【<template>】编译为渲染函数并添加到<script>标签块的组件对象上

// demo.vue
<template>
<div@click="handler">
        hello world
    </div>
</template>

<script>
exportdefault {
        data() { }
        methods: {
            handler: () =>alert('ok')
        }
    }
</script>

组件编译后结果:

exportdefault {
    data() {},
    methods: {
        handler: () =>alert('ok')
    },
    render() {
        return _createElementVNode('div', { onClick: handler }, 'hello world', -1/* HOISTED */)
    }
}

无论是使用模板还是直接手写渲染函数,对于一个组件来说,它要渲染的内容最终都是通过渲染函数产生的。然后再将渲染函数返回的虚拟DOM作为渲染器的入参,进行真实DOM的渲染

Vue3的编译优化:

  • 静态提升:将静态内容提升到渲染函数外部
  • 补丁标记:为动态内容添加标记,优化diff过程【通过在虚拟DOM中添加标记实现】
  • tree-shaking:移除未使用代码

渲染器【renderer】

渲染器的作用就是递归遍历虚拟DOM对象,并调用原生DOM API来完成真实DOM的创建。

渲染器的精髓在于后续的更新,它会通过Diff算法寻找并且只更新变化内容。

大致实现思路如下:

  • 如果不是内容变更:
    • 根据vnode.tag创建对应DOM元素
    • 遍历vnode.props对象,如果keyon字符开头,说明它是一个事件,调用addEventListener绑定事件处理函数;否则作为属性添加到DOM元素上
    • 处理children,如果是字符串,就创建文本节点;如果是数组就递归调用render继续渲染,最后把创建的元素挂载到新创建的元素内
  • 否则先找出vnode对象的变更点,并且只更新变更的内容

组件渲染过程详解:

vite@vitejs/plugin-vuevue-core的关系
  • vite中使用了@vitejs/plugin-vue来处理vue组件

  • @vitejs/plugin-vue中集成了vue-core中的compiler-sfc用于解析编译Vue组件

  • compiler-sfc中调用了compiler-core中的基础逻辑进行组件的编译和渲染

当我们新建并启动vue项目后,内容是如何渲染的,又是如何实时更新的?

创建并启动一个Vue应用 

// 创建新项目
npm create vue@latest
// 进入项目后安装依赖
npm install
// 启动,实际执行的是vite命令
npm run dev

当项目运行npm run dev命令时执行内容如下:

编译阶段:

启动一个vite开发服务器,浏览器会通过这个服务器来访问此项目的网页和代码

vite是一个通用的构建工具,vite本身并不直接处理.vue文件,而是通过插件系统来处理各种类型文件,其中@vitejs/plugin-vue就是用来处理vue单文件组件的

图片

构建时阶段

Vite接收到组件请求,会执行插件【@vitejs/plugin-vue】的load钩子函数,再执行Transform钩子函数

图片

在上图钩子函数执行过程中触发了compiler-sfc相关方法的执行

图片图片

监听组件变化

@Vitejs/plugin-vue插件的核心入口文件【packages/plugin-vue/src/index.ts】中定义了Vite插件的所有钩子函数,其中handleHotUpdate钩子是Vite提供的热更新处理函数,当Vue文件发生变化时,Vite会自动调用这个钩子,此时插件会检查变化的文件是否为Vue组件,如果是则调用专门的handleHotUpdate函数packages/plugin-vue/src/handleHotUpdate.ts

图片

最终将返回

SFCTemplateCompileResults : {
    code: string, // 渲染函数代码
    ast?: RootNode, // 抽象语法树
    preamble?: string// 预处理代码
    source: string// 输入源
    tips: string[], // 提示
    errors: (string | CompilerError)[], // 错误
    map?: RawSourceMap, // 源映射
}

这个阶段会将.vue文件转换为js代码,生成的是渲染函数的字符串

运行时阶段

当浏览器加载并执行这些js代码时,就会发生真正的渲染过程

应用启动 -> createApp() -> app.mount() -> render() -> patch() -> mountElement() -> 真实DOM

图片

到此就完成了vue中基本的渲染过程。

「web extensions🛠️」有关浏览器扩展,开发前你需要知道一些......

Hi,这里是JustHappy,上手直接开始开发插件?我想你会没有头绪,如果你从未开发过浏览器插件,我想这篇一定对你有帮助,哈哈至少在 vibe coding 的时候可以更好的指挥 AI 去帮助你,放心,我“碎片式写作”篇幅不长,希望给你带来愉快的阅读体验

image.png

浏览器扩展是什么?

浏览器扩展是由 Web 技术构建的小程序(HTML、CSS 和 JavaScript),扩展程序可通过自定义界面、监控浏览器事件和修改网页来提升浏览体验。

自 web 技术诞生初始不久,浏览器插件、扩展技术并应运而生,而后 FireFox、Chrome 等浏览器大厂也纷纷下场,并在 2015 年确定了统一的 WebExtensions 标准

在我眼中,浏览器扩展是 web 世界的“外挂”,你几乎可以通过它去操纵一切网页;同时它也是良好的应用内容载体,其独特的交互模式在一些场景实有奇效

统一的标准:WebExtensions(2015+)

Mozilla、Google、Microsoft 达成共识,共同确立了 WebExtensions API ,我们之后的开发之路也围绕这个标准,这里先做个“路由”,这样以后找文档不至于迷路哈哈

WebExtensions 标准适用的浏览器:

  • Mozilla Firefox
  • Google Chrome
  • Microsoft Edge
  • Apple Safari(部分)

比较详尽的内容参照 MDN / chrome 文档 : MDN web Extensions / Chrome web Extension

它是一个完整的应用

虽然我们通过 Web 技术来开发浏览器扩展,但它的形态绝对不是一个单纯的网页中的网页,而更像是一个完整的应用,有自己独立的一套运作体系,这个“应用”大致的构成成分如下

插件是如何操作页面的?

我们往往通过一下两种方式操作页面(其余不推荐),受限于篇幅,这里只简单介绍

1. Content Script:扩展派驻在页面里的“手和眼”

Content Script 就是注入到匹配页面里的脚本,它能:

  • 读/改 DOM:document.querySelector(...)element.textContent = ...
  • 监听事件:点击、输入、滚动
  • 插入 UI:按钮、浮层、标记、高亮
  • 观察变化:MutationObserver

关键点:它和网页 JS 共享同一个 DOM,但不共享同一个 JS 作用域(隔离世界)。

2. chrome.scripting:把脚本“打到页面里”

chrome.scripting可以把代码“注入到页面”执行,但它并不等同于传统意义上“把脚本塞进网页里”

chrome.scripting 是由扩展后台控制的“按需代码注入机制”,注入点默认是 Content Script 的隔离世界,而不是网页的 JS 主世界。

典型流程是:

  • UI(popup/sidepanel)→ 发消息给 Background
  • Background 找到当前 tabId
  • Background 用 scripting 把脚本/样式注入到该 tab

ok,就到这,且听后续我们展开细说

如果你有兴趣,可以直接尝试我的插件开发模板,持续迭代中.....

使用文档:simonmie.github.io/vue-chrome-…

github : github.com/Simonmie/vu…

也欢迎各位大佬参与

多模型支持的架构设计:如何集成 10+ AI 模型

Blade 多模型 AI 系统架构:从统一接口到智能路由

blade-code 系列第 3 篇。本文基于 Blade 源码(2026年2月)探讨多模型 AI 系统的架构设计。

为什么要支持多模型?

构建 AI 应用时,第一个问题往往是:"用哪个模型?"

  • OpenAI GPT-4.5?强大但贵
  • Claude Opus 4?代码能力强但有速率限制
  • DeepSeek V3?便宜但稳定
  • 智谱 GLM-4.7?需配置

我的答案是:全都要

blade-code 从第一天就设计为多模型架构,基于 Vercel AI SDK 构建统一接口,支持 80+ 主流模型无缝切换。

单一模型的问题

问题 影响
成本高 GPT-4.5 输入 $15/M tokens
速率限制 Claude 每分钟请求数有限
服务中断 OpenAI 宕机时干瞪眼
能力差异 不同任务需要不同模型

多模型架构能解决这些问题:

成本优化 — 简单任务用便宜模型,复杂任务用强大模型:

// 简单任务
const summary = await model.generate('总结这段文字', {
  model: 'deepseek-chat' // $0.28/M input
});

// 复杂任务
const architecture = await model.generate('设计系统架构', {
  model: 'claude-opus-4' // $15/M input
});

高可用 — 主模型挂了自动切换:

const response = await model.generate(prompt, {
  model: 'gpt-4.5',
  fallback: ['claude-sonnet-4', 'deepseek-chat']
});

统一接口设计

架构概览

graph TB
    subgraph "Blade ModelManager"
        MM[ModelManager]
        MM --> |管理| P1[OpenAI Provider]
        MM --> |管理| P2[Anthropic Provider]
        MM --> |管理| P3[Google/Gemini Provider]
        MM --> |管理| P4[DeepSeek Provider]
        MM --> |管理| P5[Azure Provider]
    end
    
    subgraph "Vercel AI SDK"
        AI[AI SDK Core]
        AI --> |抽象| CP[createOpenAICompatible]
        AI --> |抽象| CA[createAnthropic]
        AI --> |抽象| CD[createDeepSeek]
        AI --> |抽象| CG[createGoogleGenerativeAI]
    end
    
    subgraph "统一接口"
        PI[IChatService Interface]
        PI --> |chat| GEN[生成响应]
        PI --> |streamChat| STR[流式响应]
    end
    
    P1 -.-> |实现| PI
    P2 -.-> |实现| PI
    P3 -.-> |实现| PI
    P4 -.-> |实现| PI

IChatService 统一接口

所有模型提供商都实现统一的 IChatService 接口:

// packages/agent-sdk/src/services/ChatServiceInterface.ts

export interface IChatService {
  chat(messages: Message[], tools?: Tool[], signal?: AbortSignal): Promise<ChatResponse>;
  streamChat(messages: Message[], tools?: Tool[], signal?: AbortSignal): AsyncGenerator<StreamChunk>;
  getConfig(): ChatConfig;
  updateConfig(newConfig: Partial<ChatConfig>): void;
}

export interface ChatResponse {
  content: string;
  reasoningContent?: string;  // Thinking 模型的推理过程
  toolCalls?: ChatCompletionMessageToolCall[];
  usage?: UsageInfo;
}

export interface UsageInfo {
  promptTokens: number;
  completionTokens: number;
  totalTokens: number;
  reasoningTokens?: number;
  cacheCreationInputTokens?: number;  // Anthropic: 缓存创建
  cacheReadInputTokens?: number;      // Anthropic: 缓存读取
}

Provider 工厂

基于 Vercel AI SDK 的 Provider 工厂:

// packages/agent-sdk/src/services/VercelAIChatService.ts

private createModel(config: ChatConfig): LanguageModel {
  const { provider, apiKey, baseUrl, model } = config;

  switch (provider) {
    case 'anthropic':
      return createAnthropic({ apiKey, baseURL: baseUrl })(model);
    case 'gemini':
      return createGoogleGenerativeAI({ apiKey, baseURL: baseUrl })(model);
    case 'deepseek':
      return createDeepSeek({ apiKey, baseURL: baseUrl })(model);
    default:
      // OpenAI 兼容接口(覆盖 100+ 模型)
      return createOpenAICompatible({ name: 'custom', apiKey, baseURL: baseUrl })(model);
  }
}

模型管理

ModelManager

// packages/agent-sdk/src/agent/ModelManager.ts

export class ModelManager {
  private chatService!: IChatService;
  private currentModelId?: string;

  async initialize(modelId?: string): Promise<IChatService> {
    const modelConfig = this.resolveModelConfig(modelId);
    await this.applyModelConfig(modelConfig);
    return this.chatService;
  }

  async switchModelIfNeeded(modelId: string): Promise<boolean> {
    if (modelId === this.currentModelId) return false;
    
    const modelConfig = this.config.models?.find((m) => m.id === modelId);
    if (!modelConfig) return false;

    await this.applyModelConfig(modelConfig);
    return true;
  }
}

运行时切换

# 启动时指定模型
blade --model=claude-opus-4 "优化代码"

# 运行时切换
> /model claude-sonnet-4
✅ 已切换到 claude-sonnet-4

# 仅当前轮使用指定模型
> /model once deepseek-chat 总结这段代码

成本优化

Token 追踪

Blade 实时追踪每次请求的 Token 使用:

// packages/agent-sdk/src/services/ChatServiceInterface.ts

export interface UsageInfo {
  promptTokens: number;
  completionTokens: number;
  totalTokens: number;
  reasoningTokens?: number;
  cacheCreationInputTokens?: number;
  cacheReadInputTokens?: number;
}

模型成本对比

模型 输入 ($/M) 输出 ($/M) 特点
gpt-4.5 $15.00 $75.00 旗舰
gpt-4o $2.50 $10.00 均衡
gpt-4o-mini $0.15 $0.60 轻量
claude-opus-4 $15.00 $75.00 代码最强
claude-sonnet-4 $3.00 $15.00 性价比
deepseek-chat $0.28 $1.10 性价比王
deepseek-reasoner $0.55 $2.19 推理
groq-llama-4 $0.20 $0.20 高速

Prompt Caching

Anthropic/DeepSeek 支持 prompt caching,缓存命中时成本降低 90%:

提供商 正常输入 缓存命中 节省
Anthropic $5/M $0.50/M 90%
OpenAI $1.75/M $0.175/M 90%
DeepSeek $0.28/M $0.028/M 90%

核心设计原则

  1. 抽象优于具体 — Vercel AI SDK 隔离具体实现
  2. 组合优于继承 — 通过组合不同 Provider 实现多模型支持
  3. 配置优于硬编码 — 模型选择、降级策略都可配置
  4. 监控优于盲目 — 实时追踪 Token 使用和成本

参考资源


本文由 青雲 (echoVic) 撰写,基于 blade-code 的实践经验。 如有问题或建议,欢迎在 GitHub Issues 讨论。

深入浏览器指纹:Canvas、WebGL、Audio是如何暴露你的身份的?

你以为清除了Cookie就安全了?2025年约翰霍普金斯大学的研究首次证实:浏览器指纹追踪比你想象的更普遍,而且你几乎无法阻止它。

📋 目录


背景:Cookie时代的终结

还记得那些年困扰我们的Cookie弹窗吗?

"本网站使用Cookie改善您的体验"——然后给你两个选项:一个巨大的"接受所有Cookie"按钮,和一个藏在角落里的"拒绝"链接。这就是所谓的"暗模式"(Dark Pattern),专门用来诱导用户同意追踪。

好消息是,这个时代正在落幕。Chrome、Firefox、Safari都在逐步默认阻止第三方Cookie。但坏消息是——广告商们找到了更隐蔽的武器:浏览器指纹

浏览器指纹最大的特点是:你无法删除它,甚至无法感知它。它就像你在互联网上留下的无形签名,无论你如何清理浏览数据,它都能把你认出来。

2025年2月,约翰霍普金斯大学和德州农工大学的研究团队发布了论文《The First Early Evidence of the Use of Browser Fingerprinting for Online Tracking》,首次实证证实了浏览器指纹被广泛用于广告追踪。研究团队通过FPTrace框架发现,改变指纹后广告竞价出现了显著差异,直接证明了指纹与广告定向的关联。

更讽刺的是,2025年3月,Google修改了隐私政策,允许在Privacy Sandbox中使用浏览器指纹技术。这意味着连倡导"隐私保护"的科技巨头,也在拥抱这种技术。


什么是浏览器指纹?

简单来说,浏览器指纹就是通过收集浏览器和设备的多种特征信息,生成一个几乎唯一的标识符。这些特征包括但不限于:

特征类别 具体信息 熵值贡献
User Agent 浏览器版本、操作系统 中等
屏幕信息 分辨率、颜色深度、可用分辨率
时区语言 时区偏移、首选语言
字体列表 已安装字体 极高
插件信息 浏览器插件列表 中等
Canvas 2D渲染像素差异 极高
WebGL GPU型号、渲染器信息 极高
Audio 音频处理特征
Hardware 内存、CPU核心数 中等

根据EFF的Panopticlick研究,在100万个样本中,94.2%的浏览器指纹都是唯一的。

打个比方:如果把User Agent比作你的名字,Canvas指纹就是你的笔迹,WebGL指纹是你的DNA——前者很容易伪造,后者几乎无法复制。


Canvas指纹:像素的秘密

原理剖析

Canvas指纹是浏览器指纹中最成熟、最稳定的技术之一。它的核心思想非常简单:让浏览器在Canvas上绘制特定内容,然后读取像素数据,不同浏览器/设备产生的像素差异就是指纹

为什么会产生差异?主要原因包括:

  1. 显卡驱动差异:不同GPU渲染相同的图形会有细微差异
  2. 操作系统差异:Windows、macOS、Linux的字体渲染引擎不同
  3. 浏览器差异:Chrome、Firefox、Safari的Canvas实现有差异
  4. 抗锯齿算法:不同浏览器使用不同的抗锯齿策略

实战代码

function getCanvasFingerprint() {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  
  // 设置画布大小
  canvas.width = 200;
  canvas.height = 50;
  
  // 绘制背景
  ctx.fillStyle = '#f60';
  ctx.fillRect(0, 0, 200, 50);
  
  // 绘制文字 - 关键!字体和抗锯齿会产生差异
  ctx.textBaseline = 'alphabetic';
  ctx.fillStyle = '#069';
  ctx.font = '16px "Times New Roman"';
  ctx.fillText('FingerprintJS 🤓', 10, 30);
  
  // 绘制复杂图形 - 增加熵值
  ctx.strokeStyle = '#06f';
  ctx.arc(150, 25, 15, 0, Math.PI * 2);
  ctx.stroke();
  
  // 获取像素数据并哈希
  const data = canvas.toDataURL('image/png');
  return hashString(data); // 生成哈希值作为指纹
}

真实案例

fingerprintjs(GitHub 26.4k stars)的Canvas实现更加复杂:

// 来自 fingerprintjs/src/sources/canvas.ts
function renderTextImage(canvas, context) {
  // 绘制多行文字,使用多种字体和emoji
  const text = 'Cwm fjordbank glyphs vext quiz 😃';
  context.font = '14px Arial';
  context.fillText(text, 2, 20);
  
  // 绘制几何图形
  context.beginPath();
  context.moveTo(100, 5);
  context.lineTo(120, 35);
  context.stroke();
}

// 关键:检测Canvas Farbling(噪声注入)
function isCanvasStable(canvas) {
  const img1 = canvas.toDataURL();
  const img2 = canvas.toDataURL();
  return img1 === img2; // Brave等浏览器会注入噪声,两次读取结果不同
}

为什么难以防御?

Canvas指纹的可怕之处在于它利用了合法的Web API。网站可以说"我只是想画个图表",实际上却在偷取你的指纹。你无法完全禁用Canvas,否则大量网站(包括图表库、游戏、视频编辑)都会失效。


WebGL指纹:GPU的指纹

如果说Canvas指纹是"笔迹",那WebGL指纹就是"DNA检测"——它直接读取你的显卡型号和驱动信息。

原理剖析

WebGL(Web Graphics Library)是浏览器中的3D图形API。它的指纹信息主要来源:

  1. GPU型号:通过WEBGL_debug_renderer_info扩展获取真实的显卡型号
  2. 渲染管道差异:不同GPU执行相同的着色器程序会产生细微差异
  3. 扩展支持:不同的GPU支持不同的WebGL扩展
  4. 参数限制MAX_TEXTURE_SIZEMAX_VIEWPORT_DIMS等参数

实战代码

function getWebGLFingerprint() {
  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
  
  if (!gl) return null;
  
  const result = [];
  
  // 基础参数
  result.push('vendor:' + gl.getParameter(gl.VENDOR));
  result.push('renderer:' + gl.getParameter(gl.RENDERER));
  result.push('version:' + gl.getParameter(gl.VERSION));
  result.push('shadingLanguageVersion:' + gl.getParameter(gl.SHADING_LANGUAGE_VERSION));
  
  // 关键:获取真实的GPU信息(如果扩展可用)
  const debugInfo = gl.getExtension('WEBGL_debug_renderer_info');
  if (debugInfo) {
    result.push('unmaskedVendor:' + gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL));
    result.push('unmaskedRenderer:' + gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL));
  }
  
  // 能力参数 - 这些因GPU而异
  result.push('maxTextureSize:' + gl.getParameter(gl.MAX_TEXTURE_SIZE));
  result.push('maxViewportDims:' + gl.getParameter(gl.MAX_VIEWPORT_DIMS));
  result.push('maxVertexAttribs:' + gl.getParameter(gl.MAX_VERTEX_ATTRIBS));
  
  return result.join('|');
}

高级技术:WebGL渲染指纹

除了基础参数,还可以通过实际渲染来生成指纹:

// 来自beefproject/beef的WebGL指纹实现
function getAdvancedWebGLFingerprint() {
  const canvas = document.createElement('canvas');
  const gl = canvas.getContext('webgl');
  
  // 创建着色器程序
  const vShader = gl.createShader(gl.VERTEX_SHADER);
  gl.shaderSource(vShader, `
    attribute vec2 attrVertex;
    void main() {
      gl_Position = vec4(attrVertex, 0.0, 1.0);
    }
  `);
  gl.compileShader(vShader);
  
  const fShader = gl.createShader(gl.FRAGMENT_SHADER);
  gl.shaderSource(fShader, `
    precision mediump float;
    void main() {
      gl_FragColor = vec4(0.5, 0.5, 0.5, 1.0);
    }
  `);
  gl.compileShader(fShader);
  
  // 链接着色器并绘制
  const program = gl.createProgram();
  gl.attachShader(program, vShader);
  gl.attachShader(program, fShader);
  gl.linkProgram(program);
  gl.useProgram(program);
  
  // 读取像素 - 不同GPU渲染结果有细微差异
  const pixels = new Uint8Array(4);
  gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
  
  return pixels.join(',');
}

为什么WebGL指纹如此强大?

1. 唯一性极高:GPU型号+驱动版本的组合几乎是唯一的 2. 难以伪造:除非使用虚拟机或模拟器,否则无法欺骗真实的GPU 3. 跨会话稳定:除非更换显卡或驱动,否则指纹基本不变

但有个致命弱点:某些浏览器(如Tor Browser)完全禁用WebGL,或者某些隐私插件会拦截WEBGL_debug_renderer_info扩展。


Audio指纹:声音里的身份

如果说Canvas和WebGL是"视觉指纹",那Audio指纹就是"听觉指纹"——通过音频处理管道的微小差异来识别设备。

原理剖析

Audio指纹的原理是利用AudioContext API:

  1. 创建一个离线的AudioContext
  2. 生成一个特定的音频信号(通常是正弦波或压缩信号)
  3. 通过音频处理节点(如DynamicsCompressorNode)
  4. 读取处理后的音频样本
  5. 不同设备的音频处理硬件和软件会导致微小的差异

为什么会产生差异?

  • 采样率转换:不同系统使用不同的重采样算法
  • 浮点精度:CPU处理浮点运算的精度差异
  • 音频驱动:操作系统音频驱动层的实现差异

实战代码

async function getAudioFingerprint() {
  try {
    const AudioContext = window.OfflineAudioContext || window.webkitOfflineAudioContext;
    if (!AudioContext) return null;
    
    // 创建离线音频上下文
    const context = new AudioContext(1, 44100, 44100);
    
    // 创建振荡器(音源)
    const oscillator = context.createOscillator();
    oscillator.type = 'triangle';
    oscillator.frequency.setValueAtTime(10000, 0);
    
    // 创建压缩器 - 关键!不同设备的压缩算法有差异
    const compressor = context.createDynamicsCompressor();
    compressor.threshold.setValueAtTime(-50, 0);
    compressor.knee.setValueAtTime(40, 0);
    compressor.ratio.setValueAtTime(12, 0);
    compressor.attack.setValueAtTime(0, 0);
    compressor.release.setValueAtTime(0.25, 0);
    
    // 连接节点
    oscillator.connect(compressor);
    compressor.connect(context.destination);
    
    // 播放并获取音频数据
    oscillator.start(0);
    const renderedBuffer = await context.startRendering();
    
    // 提取特征点(取特定时间点的样本)
    const channelData = renderedBuffer.getChannelData(0);
    const samples = [];
    for (let i = 4500; i < 5000; i += 10) {
      samples.push(channelData[i].toFixed(10));
    }
    
    return hashString(samples.join(','));
  } catch (e) {
    return null;
  }
}

Audio指纹的稳定性

Audio指纹的优势在于它不太受软件版本影响,更多取决于硬件(声卡/音频芯片)。这意味着:

  • 跨浏览器稳定:Chrome和Firefox在同一个设备上会产生相似的音频指纹
  • 难以软件欺骗:单纯的浏览器插件难以模拟硬件级音频特征
  • ⚠️ 但不够唯一:相比Canvas和WebGL,Audio指纹的区分度稍低,通常作为辅助指纹使用

其他指纹维度

除了三大核心指纹技术,还有很多"小而美"的指纹维度:

1. 字体指纹

检测已安装的字体列表:

function getFontFingerprint() {
  const baseFonts = ['Arial', 'Times New Roman', 'Courier New'];
  const testFonts = ['Helvetica', 'Georgia', 'Verdana', 'Tahoma'];
  const detected = [];
  
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  
  // 使用基线字体测量文本宽度
  ctx.font = '72px ' + baseFonts[0];
  const baselineWidth = ctx.measureText('mmmmmmmmlli').width;
  
  // 测试每种字体
  testFonts.forEach(font => {
    ctx.font = '72px "' + font + '", ' + baseFonts[0];
    const width = ctx.measureText('mmmmmmmmlli').width;
    if (width !== baselineWidth) {
      detected.push(font);
    }
  });
  
  return detected.join(',');
}

2. 硬件信息

function getHardwareFingerprint() {
  return {
    deviceMemory: navigator.deviceMemory, // RAM(GB)
    hardwareConcurrency: navigator.hardwareConcurrency, // CPU核心数
    maxTouchPoints: navigator.maxTouchPoints, // 触摸点数
    platform: navigator.platform,
  };
}

3. 时区和语言

function getTimezoneFingerprint() {
  return {
    timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
    timezoneOffset: new Date().getTimezoneOffset(),
    languages: navigator.languages,
    language: navigator.language,
  };
}

反指纹技术:现代浏览器的防御

既然指纹技术如此强大,有没有办法防御呢?答案是——有,但不完美

1. Canvas Farbling(随机化噪声)

这是Brave浏览器首创的技术,后来被Firefox采用。

原理:在Canvas读取像素数据时,向某些像素注入微小的随机噪声(通常是RGB值的±1)。人眼无法察觉,但会破坏指纹哈希的稳定性。

// Brave的Farbling原理示意
const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
HTMLCanvasElement.prototype.toDataURL = function(...args) {
  const data = originalToDataURL.apply(this, args);
  // 注入基于会话的伪随机噪声
  return addFarblingNoise(data, getSessionSeed());
};

检测Farbling的方法(来自fingerprintjs):

function detectCanvasFarbling() {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#000';
  ctx.fillRect(0, 0, 1, 1);
  
  const data1 = canvas.toDataURL();
  const data2 = canvas.toDataURL();
  
  return data1 !== data2; // 如果两次读取不同,说明有Farbling
}

2. WebGL扩展拦截

隐私插件(如ScriptSafe)会拦截对WEBGL_debug_renderer_info的访问:

// 防追踪脚本的典型做法
const originalGetExtension = WebGLRenderingContext.prototype.getExtension;
WebGLRenderingContext.prototype.getExtension = function(name) {
  if (name === 'WEBGL_debug_renderer_info') {
    return null; // 返回null,阻止获取真实GPU信息
  }
  return originalGetExtension.call(this, name);
};

3. User Agent标准化

现代浏览器开始减少User Agent的熵值:

// 过去的User Agent(信息丰富)
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 
// (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.0

// 未来的User Agent(精简版)
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 
// (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.0
// 版本号将简化为主要版本

4. Tor Browser的极端策略

Tor Browser采取了最激进的反指纹措施:

  • 完全禁用WebGL
  • 统一所有用户的User Agent(都显示为Windows 7 + Firefox ESR)
  • 标准化屏幕分辨率(仅报告几种常见尺寸)
  • 禁用Canvas读取(或返回空白数据)
  • 禁用所有时区检测(统一使用UTC)

代价是:网站兼容性极差,很多现代Web应用无法在Tor Browser中正常运行。


实战:用开源库生成你的指纹

方案1:FingerprintJS(最流行)

npm install @fingerprintjs/fingerprintjs
import FingerprintJS from '@fingerprintjs/fingerprintjs';

async function getVisitorId() {
  // 加载指纹库
  const fp = await FingerprintJS.load();
  
  // 获取指纹结果
  const result = await fp.get();
  
  // 输出访客ID(稳定标识符)
  console.log('Visitor ID:', result.visitorId);
  
  // 查看各个组件
  console.log('Components:', result.components);
  
  return result;
}

// 实际项目中的使用场景(如Grafana)
class BackendService {
  async initDeviceID() {
    try {
      const fp = await FingerprintJS.load();
      const result = await fp.get();
      this.deviceID = result.visitorId;
    } catch (error) {
      console.error('Fingerprint failed:', error);
    }
  }
}

方案2:GuardianJS(免费开源)

npm install guardian-js-free
import { load } from 'guardian-js-free';

async function getGuardianFingerprint() {
  const guardian = await load();
  const visitorId = await guardian.getVisitorId();
  
  console.log('Guardian ID:', visitorId);
  return visitorId;
}

方案3:纯浏览器API实现

如果你想自己实现(用于学习):

class BrowserFingerprinter {
  async getFingerprint() {
    const components = await Promise.all([
      this.getCanvasFingerprint(),
      this.getWebGLFingerprint(),
      this.getAudioFingerprint(),
      this.getFontFingerprint(),
      this.getHardwareInfo(),
      this.getTimezoneInfo(),
    ]);
    
    // 组合所有组件并哈希
    const combined = components.join('::');
    return this.hash(combined);
  }
  
  // ... 实现各个指纹方法
}

// 使用
const fingerprinter = new BrowserFingerprinter();
const id = await fingerprinter.getFingerprint();
console.log('Your fingerprint:', id);

总结与思考

核心要点回顾

  1. 浏览器指纹利用了Web的开放性:它不需要Cookie,不违反任何协议,只是"读取浏览器本来就公开的信息"。

  2. 三大核心技术

    • Canvas指纹:2D渲染差异,利用显卡驱动和字体渲染的不同
    • WebGL指纹:GPU型号和渲染管道特征,几乎无法伪造
    • Audio指纹:音频处理差异,硬件级特征
  3. 2025年的新趋势

    • 学术研究首次实证指纹用于广告追踪
    • Google政策转向,Privacy Sandbox拥抱指纹技术
    • 浏览器厂商加大反指纹力度(Farbling成为标准)

给开发者的建议

如果你需要实现设备识别

  • 优先考虑服务器端Session + 登录态
  • 如果需要客户端识别,可以使用FingerprintJS等成熟库
  • 永远不要将指纹用于违法追踪或侵犯隐私

如果你想保护用户隐私

  • 教育用户使用Brave、Firefox等注重隐私的浏览器
  • 安装Privacy Badger、uBlock Origin等扩展
  • 对于高安全需求,考虑使用Tor Browser

给普通用户的建议

  1. 不要迷信"无痕模式":它只清除本地数据,无法阻止指纹追踪
  2. 安装隐私扩展:uBlock Origin、Privacy Badger能有效阻止大部分追踪
  3. 使用隐私浏览器:Brave的Farbling是目前最有效的反指纹手段
  4. 接受现实:完全的匿名在当前Web技术下几乎不可能,除非你准备好牺牲便利性

参考链接

AI Agent 安全权限设计:blade-code 的 5 种权限模式与三级控制

blade-code 技术深度系列第 2 篇。本文基于源码剖析 AI Agent 的权限设计——5 种权限模式、allow/ask/deny 三级控制、基于签名的精确匹配。

问题

把 AI Agent 接入开发环境,第一个问题不是"它能做什么",而是"它不能做什么"。

场景:

  • Agent 想执行 rm -rf /
  • Agent 想读取 .env 里的密钥
  • Agent 想 curl 下载脚本并执行
  • Agent 想 sudo 提权

你会让它直接跑吗?

blade-code 从设计之初就在解决这个问题:赋予 Agent 能力的同时,保证安全

本文内容:

  • 工具分类(ReadOnly / Write / Execute)
  • 5 种权限模式
  • allow/ask/deny 三级控制
  • 基于签名的精确匹配

一、工具分类:三种 ToolKind

blade-code 把所有工具分成三类,这是权限控制的基础:

export enum ToolKind {
  ReadOnly = 'readonly',  // 只读,无副作用
  Write = 'write',        // 文件写入
  Execute = 'execute',    // 命令执行,可能有副作用
}

ReadOnly 工具

只读操作,无副作用,最安全:

工具 功能
Read 读取文件
Glob 路径匹配
Grep 文本搜索
WebFetch 获取网页
WebSearch 网络搜索
TaskOutput 子任务输出
Plan 生成计划

Write 工具

文件写入,有副作用但可控:

工具 功能
Edit 编辑文件
Write 写入文件
NotebookEdit 编辑 Notebook

Execute 工具

命令执行,副作用不可预测:

工具 功能
Bash Shell 命令
Task 子任务
Skill 调用技能
SlashCommand 斜杠命令

二、5 种权限模式

export enum PermissionMode {
  DEFAULT = 'default',
  AUTO_EDIT = 'autoEdit',
  YOLO = 'yolo',
  PLAN = 'plan',
  SPEC = 'spec',
}

DEFAULT(默认)

平衡安全与效率:

  • ✅ 自动批准:ReadOnly 工具
  • ❌ 需要确认:Write 工具
  • ❌ 需要确认:Execute 工具
blade "帮我分析这个项目"

AUTO_EDIT

频繁编码场景:

  • ✅ 自动批准:ReadOnly 工具
  • ✅ 自动批准:Write 工具
  • ❌ 需要确认:Execute 工具
blade --mode=autoEdit "重构这个模块"

日常开发中,文件编辑最频繁。AUTO_EDIT 让 Agent 自由改代码,但执行命令仍需确认。

YOLO(危险)

完全信任 AI:

  • ✅ 自动批准:所有工具
  • ⚠️ 跳过所有确认
blade --mode=yolo "自动修复所有 lint 错误"

适用场景:沙箱环境、演示、已验证安全的自动化脚本。

源码实现:

if (permissionMode === PermissionMode.YOLO) {
  return {
    result: PermissionResult.ALLOW,
    matchedRule: 'mode:yolo',
    reason: 'YOLO mode: automatically approve all tool invocations',
  };
}

PLAN

只读模式,用于调研:

  • ✅ 自动批准:ReadOnly 工具
  • ❌ 拦截:Write 和 Execute 工具
  • 🔵 特殊工具:ExitPlanMode(提交方案)
blade --mode=plan "分析这个项目的架构"

适用场景:代码审查、架构分析、生成方案后用户批准再执行。

源码实现:

if (permissionMode === PermissionMode.PLAN) {
  if (!isReadOnlyKind(toolKind)) {
    return {
      result: PermissionResult.DENY,
      matchedRule: 'mode:plan',
      reason: 'Plan mode: modification tools are blocked',
    };
  }
}

SPEC(Spec-Driven Development)

结构化功能开发:

  • ✅ 自动批准:ReadOnly + Spec 专用工具
  • ❌ 需要确认:其他 Write 和 Execute 工具
  • 🔵 特殊工具:InitSpec, UpdateSpec, ValidateSpec, GetSpecContext, ExitSpecMode
  • 📁 持久化:.blade/specs/<feature>/
blade --mode=spec "实现用户认证功能"

适用场景:复杂功能开发,需要 Requirements → Design → Tasks → Implementation 工作流。

模式对比

模式 ReadOnly Write Execute 场景
DEFAULT ✅ 自动 ❌ 确认 ❌ 确认 日常开发
AUTO_EDIT ✅ 自动 ✅ 自动 ❌ 确认 频繁编码
YOLO ✅ 自动 ✅ 自动 ✅ 自动 沙箱/演示
PLAN ✅ 自动 ❌ 拦截 ❌ 拦截 调研/审查
SPEC ✅ 自动 ❌ 确认 ❌ 确认 复杂功能

三、三级权限控制:allow / ask / deny

权限模式之外,blade-code 还有更细粒度的控制:

export interface PermissionConfig {
  allow: string[];  // 自动批准
  ask: string[];    // 需要确认
  deny: string[];   // 直接拒绝
}

优先级

deny > allow > ask > 默认(ask)

// 1. 检查 deny(最高优先级)
const denyMatch = this.matchRules(signature, this.config.deny);
if (denyMatch) {
  return { result: PermissionResult.DENY, ... };
}

// 2. 检查 allow
const allowMatch = this.matchRules(signature, this.config.allow);
if (allowMatch) {
  return { result: PermissionResult.ALLOW, ... };
}

// 3. 检查 ask
const askMatch = this.matchRules(signature, this.config.ask);
if (askMatch) {
  return { result: PermissionResult.ASK, ... };
}

// 4. 默认:需要确认
return { result: PermissionResult.ASK, ... };

默认配置

blade-code 内置了一套安全配置:

allow 列表(自动批准):

allow: [
  // 系统信息命令
  'Bash(pwd)', 'Bash(which *)', 'Bash(whoami)',
  'Bash(hostname)', 'Bash(uname *)', 'Bash(date)', 'Bash(echo *)',

  // 目录列表
  'Bash(ls *)', 'Bash(tree *)',

  // Git 只读
  'Bash(git status)', 'Bash(git log *)', 'Bash(git diff *)',
  'Bash(git branch *)', 'Bash(git show *)', 'Bash(git remote *)',

  // 包管理器只读
  'Bash(npm list *)', 'Bash(npm view *)', 'Bash(npm outdated *)',
  'Bash(pnpm list *)', 'Bash(yarn list *)',
  'Bash(pip list *)', 'Bash(pip show *)',
]

ask 列表(需要确认):

ask: [
  // 网络下载(可能下载恶意代码)
  'Bash(curl *)', 'Bash(wget *)', 'Bash(aria2c *)', 'Bash(axel *)',

  // 危险删除
  'Bash(rm -rf *)', 'Bash(rm -r *)', 'Bash(rm --recursive *)',

  // 网络连接
  'Bash(nc *)', 'Bash(netcat *)', 'Bash(telnet *)', 'Bash(ncat *)',
]

deny 列表(直接拒绝):

deny: [
  // 敏感文件
  'Read(./.env)', 'Read(./.env.*)',

  // 危险命令
  'Bash(rm -rf /)', 'Bash(rm -rf /*)', 'Bash(sudo *)', 'Bash(chmod 777 *)',

  // Shell 嵌套(可绕过安全检测)
  'Bash(bash *)', 'Bash(sh *)', 'Bash(zsh *)', 'Bash(fish *)', 'Bash(dash *)',

  // 代码注入
  'Bash(eval *)', 'Bash(source *)',

  // 系统级操作
  'Bash(mkfs *)', 'Bash(fdisk *)', 'Bash(dd *)', 'Bash(format *)', 'Bash(parted *)',

  // 浏览器(可打开恶意链接)
  'Bash(open http*)', 'Bash(open https*)',
  'Bash(xdg-open http*)', 'Bash(xdg-open https*)',
]

设计原则

allow:只读命令无副作用,可以自动批准。pwdlsgit status 不会改变任何东西。

askcurlwget 可能下载恶意代码,rm -rf 可能删数据。需要确认,但不完全禁止。

deny.env 包含密钥,sudo 风险太高,Shell 嵌套可能绕过检测,mkfsdd 可能造成不可逆损害。


四、基于签名的精确匹配

blade-code 的权限系统支持多种匹配模式。

签名格式

ToolName(content)

例如:

  • Bash(git status) — 执行 git status
  • Read(src/index.ts) — 读取文件
  • Edit(src/utils.ts) — 编辑文件

匹配模式

  1. 精确匹配Read(src/index.ts)
  2. 前缀匹配Read(匹配所有 Read 调用)
  3. 通配符匹配Read(*)Bash(git *)
  4. Glob 模式Read(**/*.env)
规则 匹配 不匹配
Bash(git status) Bash(git status) Bash(git log)
Bash(git *) Bash(git status), Bash(git log) Bash(npm install)
Bash 所有 Bash 命令 Read(...)
Read(*.env) Read(.env), Read(.env.local) Read(config.json)
Read(**/*.ts) Read(src/index.ts) Read(package.json)

实现

blade-code 用 picomatch 库做 glob 匹配:

private matchRule(signature: string, rule: string): MatchType | null {
  // 精确匹配
  if (signature === rule) return 'exact';

  // 通配符匹配所有
  if (rule === '*' || rule === '**') return 'wildcard';

  // 工具名 glob 匹配
  if (ruleToolName.includes('*')) {
    if (!picomatch.isMatch(sigToolName, ruleToolName, { dot: true, bash: true })) {
      return null;
    }
  }

  // 参数 glob 匹配
  if (rule.includes('*')) {
    const isMatch = picomatch.isMatch(sigValue, ruleValue, { dot: true, bash: true });
    if (isMatch) return ruleValue.includes('**') ? 'glob' : 'wildcard';
  }

  return null;
}

五、权限执行管道

blade-code 的权限检查在 PipelineStages 中实现。

优先级

YOLO 模式 > PLAN 模式 > DENY 规则 > ALLOW 规则 > 模式规则 > ASK

private applyModeOverrides(
  toolKind: ToolKind,
  checkResult: PermissionCheckResult,
  permissionMode: PermissionMode
): PermissionCheckResult {
  // 1. YOLO:全部放开
  if (permissionMode === PermissionMode.YOLO) {
    return { result: PermissionResult.ALLOW, ... };
  }

  // 2. PLAN:拒绝非只读
  if (permissionMode === PermissionMode.PLAN) {
    if (!isReadOnlyKind(toolKind)) {
      return { result: PermissionResult.DENY, ... };
    }
  }

  // 3. deny 规则已拒绝,不覆盖
  if (checkResult.result === PermissionResult.DENY) return checkResult;

  // 4. allow 规则已批准,不覆盖
  if (checkResult.result === PermissionResult.ALLOW) return checkResult;

  // 5. 只读工具:自动批准
  if (isReadOnlyKind(toolKind)) {
    return { result: PermissionResult.ALLOW, ... };
  }

  // 6. AUTO_EDIT + Write:自动批准
  if (permissionMode === PermissionMode.AUTO_EDIT && toolKind === ToolKind.Write) {
    return { result: PermissionResult.ALLOW, ... };
  }

  // 7. 其他:保持原结果(通常是 ASK)
  return checkResult;
}

流程图

flowchart TD
    A[工具调用请求] --> B{YOLO 模式?}
    B -->|是| C[✅ 直接批准]
    B -->|否| D{PLAN 模式?}
    D -->|是| E{只读工具?}
    E -->|否| F[❌ 直接拒绝]
    E -->|是| G[✅ 批准]
    D -->|否| H{匹配 deny 规则?}
    H -->|是| F
    H -->|否| I{匹配 allow 规则?}
    I -->|是| C
    I -->|否| J{只读工具?}
    J -->|是| C
    J -->|否| K{AUTO_EDIT + Write?}
    K -->|是| C
    K -->|否| L[⚠️ 请求用户确认]

六、实战配置

项目级配置

在项目根目录创建 .blade/settings.json

{
  "permissionMode": "default",
  "permissions": {
    "allow": [
      "Bash(npm run *)",
      "Bash(pnpm *)",
      "Bash(git commit *)",
      "Bash(git push *)"
    ],
    "ask": [],
    "deny": [
      "Read(config/secrets.json)",
      "Bash(rm -rf node_modules)"
    ]
  }
}

命令行切换

# 启动时指定
blade --mode=autoEdit "重构这个模块"
blade --mode=plan "分析项目架构"
blade --mode=yolo "自动修复所有问题"

# 运行时切换
> /mode autoEdit
> /mode plan
> /mode default

场景推荐

场景 模式 原因
日常开发 DEFAULT 平衡安全与效率
频繁编码 AUTO_EDIT 减少文件编辑确认
代码审查 PLAN 只读不写
自动化脚本 YOLO 无需人工干预(确保安全)
复杂功能 SPEC 结构化工作流

总结

  1. 工具分类:ReadOnly / Write / Execute
  2. 5 种权限模式:DEFAULT / AUTO_EDIT / YOLO / PLAN / SPEC
  3. 三级权限控制:deny > allow > ask
  4. 精确匹配:精确、前缀、通配符、glob

设计原则:

  • 默认安全(DEFAULT 模式)
  • 灵活可控(用户可切换)
  • 细粒度(精确到命令级别)
  • 可扩展(项目级配置覆盖全局)

参考

❌