普通视图
杉杉股份:若重整成功,公司实际控制人将变更为安徽省国资委
马斯克:是时候大规模重返月球了
“破案”笔记: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)进行彻底的重装修。
-
拆旧:浏览器先把房间里(iframe 内)所有旧的家具、管道(旧的文档、窗口)全清空。
-
异步装修:然后开始根据你给的新图纸(HTML字符串)异步施工。这需要时间,水电、墙面、家具都在同步安排。
-
施工中:在装修队喊“完工啦!”(触发
load事件)之前,这个房间处于施工状态。- 你对着一个还在铺水泥的墙面(不稳定的内部窗口)喊“打印!”(
print()),工人会无视你。 - 你告诉一面还没砌好的墙“打印完喊我一声”(绑
afterprint),这个请求可能会丢失。
- 你对着一个还在铺水泥的墙面(不稳定的内部窗口)喊“打印!”(
-
竣工:只有等
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-labelledby 或 aria-label 关联。另外,对话框的内容应该通过 aria-describedby 关联,以便屏幕阅读器能够正确读取完整信息。这些属性的正确使用对于无障碍体验至关重要。
三、WAI-ARIA 角色、状态和属性
正确使用 WAI-ARIA 属性是构建无障碍 Alert Dialog 组件的技术基础。Alert Dialog 组件的 ARIA 要求包含多个属性的配合使用。
role="alertdialog" 是 Alert Dialog 组件的必需属性,它向辅助技术表明这个元素是一个警告对话框。这个属性使浏览器和辅助技术能够将 Alert Dialog 与其他类型的对话框区分开来,从而提供特殊的处理方式,如播放系统提示音。
aria-labelledby 或 aria-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 进行交互。
-
Enter或Space用于激活默认按钮,通常是对话框中的主要操作按钮。 -
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> 元素的默认 role 是 dialog,表示普通对话框。对于 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 的区别
理解 Alert 和 Alert Dialog 的区别对于正确选择通知组件至关重要。虽然两者都是用于传达重要信息,但它们服务于不同的目的和使用场景。
Alert 是一种被动通知组件,它不需要用户进行任何交互操作。Alert 会在不被中断用户工作流程的前提下自动通知用户重要信息。用户可以继续当前的工作,Alert 只是在视觉和听觉上提供通知。这种设计适用于不紧急、不需要用户立即响应的信息,例如操作成功确认、后台处理完成通知等。
Alert Dialog 则是一种需要用户主动响应的对话框组件。当用户需要做出决定或者提供确认时,应该使用 Alert Dialog。Alert Dialog 会中断用户的工作流程,获取键盘焦点,要求用户必须与之交互才能继续其他操作。这种设计适用于紧急警告、确认删除操作、放弃更改确认等需要用户明确响应的场景。
选择建议:如果信息需要用户立即响应并做出决定,使用 Alert Dialog;如果只是被动通知信息,使用 Alert。
八、总结
构建无障碍的对话框组件需要关注元素选择、焦点管理、键盘交互三个层面的细节。从元素选择角度,推荐优先使用原生 <dialog> 元素,它内置了无障碍支持和焦点管理。从焦点管理角度,需要确保打开和关闭时焦点的正确移动。从用户体验角度,应该避免过度使用对话框,只在真正需要用户响应时使用。
WAI-ARIA Alert Dialog Pattern 为我们提供了清晰的指导方针,遵循这些规范能够帮助我们创建更加包容和易用的 Web 应用。每一个正确实现的对话框,都是提升用户体验和确保重要信息有效传达的重要一步。
文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。
明冠新材:终止太阳能背板及功能性膜生产基地项目投资协议
国联民生:拟向民生证券增资2亿元
湖南白银:董事荣起因工作调整原因辞职
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 官网
安装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}`)
})
第三章 洋葱模型
洋葱模型
当你处理一个请求时,
可以想象成是在 "剥洋葱" ,从外向内一层一层地往里剥,直到剥到中心部分
这个过程涉及对 请求 的多个层面进行解析、验证、处理
在处理完洋葱(请求)后,
构建 响应 的过程就像是从精心准备的食材 ( 处理请求 后得到的数据) 开始,
从内向外逐层添加调料(格式化、封装等),最终形成一道色香味俱佳的菜肴(响应)
![]()
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
- 访问
http://localhost:8008/cookie/set→ 设置 Cookie; - 访问
http://localhost:8008/cookie/get→ 获取 Cookie,输出{username: "张三"}; - 访问
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
- 访问
http://localhost:8008/session/set→ 设置 Session; - 访问
http://localhost:8008/session/get→ 获取 Session,输出用户信息; - 访问
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 无效或过期"}。
总结
-
核心流程:Koa 开发的核心是「中间件 + 路由」,所有功能(跨域、上传、JWT)都通过中间件扩展;
-
关键依赖:
@koa/router(路由)、koa-body(POST / 上传)、@koa/cors(跨域)、koa-session(Session)、jsonwebtoken/koa-jwt(JWT); -
生产建议:
-
Session/JWT 密钥需随机生成并加密存储;
-
文件上传需限制大小和类型,防止恶意上传;
-
跨域需指定具体域名,而非
*; -
JWT 载荷不要存敏感数据,过期时间不宜过长。
-
热血渐凉:被耗尽的小米SU7 Ultra
出品|虎嗅汽车组
作者|王亚骏
头图|视觉中国
面对转岗的机会,小米员工林露(化名)并未考虑太多,选择了离开。
他此前所在的团队有一个亮眼的名称,叫“Ultra Master”。这个团队的职责是销售小米旗下最为昂贵的产品:售价52.99万至62.99万元的小米SU7 Ultra。
不过在一月底,小米开放了这款车的销售权限,所有销售人员均可参与小米SU7 Ultra的售卖;而林露们,则可以转岗去销售小米其他车型。小米官方将其解释为“销售策略调整升级”。
林露认为,自己擅长的技能以及累积的基盘客户,与走量车型其实并不匹配,留下来也是“熬着”。在他认识的其他Ultra Master中,“也只有一两个人留下了。”
![]()
小米各款车型价位
在林露加入团队之初,他可能想不到,这段经历会以这种结果收场。
小米SU7 Ultra承担着拉升品牌调性,增加小米在高端市场话语权的重任。雷军曾表示,小米SU7 Ultra将全部重新定义豪车的新标准,性能比肩保时捷、科技紧追特斯拉、豪华媲美BBA。
在更具体的销量上,雷军当时希望这款车实现年销1万辆的目标。
为此,小米下了相当大的功夫与本钱来打造销售团队,很多Ultra Master不仅具备豪车品牌的销售经验,甚至他们自己就是圈子中的“玩家”。林露告诉虎嗅,他所在的城市,第一批Ultra Master必须持有赛道驾驶执照。
在薪资方面,他们的月薪最高可达3万元。供职于经销商的前Ultra Master崇义(化名)告诉虎嗅,他有时赚得甚至比直营的Ultra Master更多。
对于许多团队成员而言,销售小米SU7 Ultra也不仅仅是一份薪水颇高的工作。崇义还记得,“在入职之初,许多人都怀着给中国汽车产业出一份力的情怀,毕竟在车行干了这么多年,大家还是希望中国性能车能做起来。”
除了过硬的专业能力和一腔热血,他们手中的武器也颇为锋利。
小米SU7 Ultra,最大马力达1548匹、零百加速1.98秒,这两个数据均领先于特斯拉Model S Plaid和保时捷Taycan Turbo GT,是纽北史上最速量产电动车。央视曾将这款车称为“中国汽车工业的辉煌成就”。
![]()
小米SU7 Ultra;图源:视觉中国
此外,在去年初,小米汽车业务势头正旺,这也为小米SU7 Ultra带来了泼天的流量和品牌势能。
在一系列有利因素的帮助下,这款车的开局可谓梦幻。2025年2月27日,小米SU7 Ultra正式上市,从3月到了8月,这款车累计销量已超1.4万辆,超额完成全年销量目标,是同期保时捷Panamera销量的两倍多(当然,小米SU7 Ultra能取得这个成绩,也有一部分售价偏低的原因)。
从销量层面来看,Ultra Master们所销售的汽车,的确正在颠覆国内性能车市场被欧洲品牌所主导的格局。
当时的他们可能并不会想到,接下来会发生什么。
从2025年9月起,小米SU7 Ultra月销量暴跌,直至去年12月份,从最高峰月销三千多辆,下滑到月销只有45辆。
![]()
数据来源:易车网
小米SU7 Ultra为何折戟?
小米SU7 Ultra开卖后,崇义的心中有了一个疑问。在他看来,小米很用心地打造出了这款汽车,但是在对车主的服务上,却“不太用心”。
他与林露两人相隔千里,但他俩所接触的小米SU7 Ultra意向客户,在身份上颇为一致:青年才俊,年龄多为90后、通过创业或职场上的成功积累了一定财富。
这个群体颇为重视情绪价值。崇义以往服务的豪车品牌,会赠送印有品牌Logo的钢笔、打火机等礼品。这些礼品的重点不在于成本或价值,而在于定制、有考究感、在市面上买不到,“这能让客户感受到被重视。”
小米SU7 Ultra车主所收到的礼品,则是棒球帽、背包等。在崇义看来,这些礼品看上去并不如其他豪车品牌的礼物“那么考究”,他眼中“最不考究”的礼品是小米不锈钢直饮水杯,“在商城就能买到,售价29块9,我都不知道这个点子是怎么想出来的...”
![]()
图源:小米商城
礼品方面的问题,尚可用“小米第一次做这么贵的车,经验不足”来解释,并以这款车不俗的产品力来弥补。
但是“锁马力事件”就不一样了,这件事的的确确伤害到了客户的体验。
2025年5月,小米SU7 Ultra汽车更新了1.7.0车机版本,此次更新后,车主要在指定赛道达成官方建议圈速,方可解锁最大马力。崇义认为这个行为是正确的,因为超过1500匹的马力本身就不是在街道上用的。
“但是你要提前沟通啊,或者是在销售的时候就锁上,告知客户通过测试才能解锁。客户要是觉得行就买,不行就算,总比这样子强。”
一波未平,一波又起。
在锁马力事件发生当月,小米SU7 Ultra又陷入碳纤维双风道前舱盖争议,多位准车主质疑该车型选装的碳纤维双风道前舱盖存在宣传与实际功能不符的情况。到了10月,成都一辆SU7 Ultra发生严重事故,现场流出的画面显示,多名救援人员尝试破门未果,这引发了外界对电子门锁与隐藏式门把手设计的质疑。
性能车的销量,很大程度上依赖于它在圈子玩家中口口相传的声誉,而这一系列的舆情,势必会损害小米SU7 Ultra的口碑。
对比小米官方所公布的大定数和实际销量,便不难看出口碑下滑对小米SU7 Ultra订单的影响。
在小米SU7 Ultra上市后的第二天,雷军曾公布该车型大定超1.5万辆;2025年,这款车的实际销量为1.57万辆(除了新增订单减少外,大概率也有大定用户放弃的原因)。
“小米人红是非多,舆情会被放大,我能理解,可是小米为什么不能多照顾下客户的情绪,多采取一些措施去把这些事更好地解决。”
12月,小米SU7 Ultra的销量为45辆。这个成绩在一定程度上宣告,小米SU7 Ultra未能提升公司在高端市场话语权。
同时,这款车市场表现的高开低走也让崇义颇为惋惜,他还记得在这款车上市之初的盛景,在他的客户中,有超过一半都是选择了价格更高的版本(62.99万元),“不光是我们希望这款车卖得好,客户们玩了这么久的外国车,也很希望能支持下中国品牌,但是...”
人散曲未终
虽然销量和口碑下滑严重,不少Ultra Master也已经离开,但小米SU7 Ultra仍在公司未来的计划之中。从小米的动作来看,这款车并没有因为销量下滑而被完全边缘化。
目前,小米SU7 Ultra已经正式入驻全球顶级赛车游戏《跑车浪漫旅7》(简称GT7),这是首辆在此游戏里上线的中国品牌车型。雷军在2月份的微博中,多次提及此事。
小米的其他高管也没有忘记维护小米SU7 Ultra的形象。今年频繁出现在直播间的小米汽车副总裁李肖爽,也在1月转发了“打假小米SU7 Ultra二手车价格崩盘”的相关微博。
这么做的原因并不难理解。小米SU7 Ultra的存在,仍可以展示公司技术实力,进而对小米的走量车型起到一定拉动作用。
![]()
初代小米SU7已经停售,新款小米SU7要等到第二季度才会上市,小米目前的走量车仅有一款小米YU7;数据来源:易车网
那么在自身销量方面,小米SU7 Ultra还有望回暖吗?
崇义告诉虎嗅,小米SU7 Ultra是一款非常“有功夫”的汽车,就算是圈子内的玩家,都不一定能把这款车“开明白”,“罗开罗这样的国内顶尖车手,才能完全驾驭这款车”,所以他认为,小米量产车型的销售人员,可能会讲不清楚这款车的技术细节,同时,量产车型销售人员为了完成销量KPI,也不会将精力重点放在小米SU7 Ultra上面。由此来看,小米SU7 Ultra大概率难以复现往日的辉煌。
不过,崇义、林露们会记得,2025年,他们所销售的汽车,曾在中国车市掀起过一股巨浪。
下载虎嗅APP,第一时间获取深度独到的商业科技资讯,连接更多创新人群与线下活动
从特斯拉一日自驾,看纯电在日本的实际体验
pnpm-workspace.yaml
pnpm-workspace.yaml 是 pnpm 的“项目组织与调度中枢”,告诉 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 不是用来装依赖的,是用来“管包管理器的版本和使用权”的。
它让“这个项目该用哪个包管理器、哪个版本”变成一件确定的事。