普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月8日掘金 前端

chrome单页签内存分配上限问题,怎么解决

2026年2月8日 21:27

前言

我们都知道 chrome 的单个标签内存占用都是有限制的,平均也就2~4G,32位的只会更少,并且这很多情况下只是理想状态,当桌面应用多开的时候,chrome 单个页签的大小更是会大幅度缩水,那么我们怎样解决chrome的内存分配上限呢

为什么

疑问:为何要管这个内存分配上限问题?

答:内存不足时,无论是操作系统还是浏览器都会有一些内存交换或者释放内存的方式,无论是哪一个短时间内都是极度耗费性能的,尤其是单纯的内存占用高的问题,频繁的内存交换,这个只会造成更严重的页面严重卡顿,甚至无响应,也就是说到底内存问题还是会再转化为标注你的性能优化问题

一般场景

解决单页签内存分配上限时,我们首先要看问题在哪里,一般会有下面几种场景:

  • 页面递归死循环,堆栈溢出,这个属于重大bug,需要及时解决,不多解释
  • 页面出现了循环引用内存泄漏,导致内存没能及时释放,出现内存占用过高,此时我们要想尽办法解决内存泄漏问题,当然这个问题算是中等问题,因为还有垃圾回收,只要不是全局引用的一般都会随着页面资源的释放释放,或者内存不足时统一释放回收
  • 页面出现了海量数据需要展示的数据,但是又无可奈何的时候,只能想尽办法来避免占用过高,常见的:echarts图标大量数据,直播巨量弹幕等

一些解决方案

递归死循环

对于递归死循环的问题,只能说,靠实力或者队友解决此bug,不应该出现

内存泄漏

当页面出现内存泄漏的时候,前端一般出现在闭包场景,或者强引用场景,出现了类似的循环引用问题,导致内存无法释放或者无法及时释放,累计起来也是一个不小的数字

对于常规页面内的循环引用,一般会随着页面的释放,那片内存空间会成为无主之物,会成为垃圾回收的养料,一般不管

主要是涉及到模块或者全局场景,这类大对象的强引用问题需要注意,如果不及时释放持有,则可能会长期持有,导致内存居高不不下,成为压死骆驼的最后一根稻草

海量数据问题

对于海量的数据问题,常见的就是大屏echart 之类的表格折线图等数据了,这类数据可以通过一些算法,优化尽量减少数据、分段渲染,甚至更改展示区间逻辑,这样内存高占用以及非高占用的卡顿问题能够改善

对于海量数据,还有另外一种情况,就是需要展示海量内容很多,单独一个页面无论怎么处理都是非常麻烦的,此时可以将该页面展示一个概览数据,当需要看详情的时候,直接跳转到另外一个浏览器标签,这样新的把标签的内存占用情况会明显改善,对于新标签页仍然占用过高的情况,仍然使用之前的数据分段减少数据等手段解决

最后

从chrome的内存分配上限,到转化为实际的性能瓶颈优化,到bug修复,到内存泄漏问题,再到海量数据优化,再到页面分布改善标签跳转等

解决问题的思路总是随着我们的疑问,一步一步向外延伸,问题和解决的方案上面也只是其中一部分,分析问题实际上也是我们进步的一环

本篇文章也就介绍到这里了,下次见😄

生成随机数,Math.random的使用

2026年2月8日 21:26

有时候我们有生成随机数的一些想法,可能会直接想使用 Math.random,但这个Math.random 优缺也很明显,优点简单方便兼容性高,缺点就是算法固定,后续可能会预测,且全是数字,长度也不固定,对于一些场景不太友好

我们可以通过使用码表 + 随机数偏移的方式生成随机数,这样效率实际也不低,且不仅仅是纯数字了,长度也固定了,并且码表也是可以随机变动的,这样上面的一些隐患实际就没了

//码表可以扩展加乱序,可打乱减少被预测概率,这里就不写码表打乱的算法了,我们就解决数字 + 长度问题,看起来舒服
const mapString = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

//生成随机数,根据随机数生成偏移,取出码表中的一个字符,根据长度取出指定个数
function getRandomString(length) {
  let result = "";
  const charactersLength = mapString.length;
  for (let i = 0; i < length; i++) {
    //当然想进一步提速,也可以转化为数组的访问,这里不多做操作了
    result += mapString[Math.floor(Math.random() * charactersLength)];
  }
  return result;
}

如果对于码表没有要求的话仅仅是当一个唯一id的话,我们实际上直接将随机数转化成36进制字符串,取出其中一项就行了,实际上转化成36进制,取出其中一项,就是 [0-9a-z]+ 了,但感觉没上面好

function getRandomString(length) {
  let result = '';
    for (let i = 0; i < length; i++) {
        result += Math.random().toString(36).charAt(2);
    }
    return result;
}

看了上面的写法,是不是感觉思路更多了,确实如此,我们实际使用的话,就使用第一种方案感觉就挺好的,不管是生成表单的id,还是生成其他地方的随机数,都足够用了😄

js数组之快速组、慢数组、密集数组、稀松数组

2026年2月8日 21:26

前言

我们大多数只知道数组是一个有序集合,但由于js的数组使用非常灵活,因而衍生出来非常多的疑问,本篇文章会对一些常对其一些场景情况出现的名词和逻辑进行一定程度探讨

实际上有学习过数据结构和算法的基本上都能想到大致可能的场景或者关键字,线性数组、索引、哈希表

另外数组中经常听到的快速组、慢数组、密集数组、稀松数组,也会从这里慢慢带入

js数组介绍

集合有很多,js中的数组主要目的是存放有序内容,当然实际也会使用和对象一样

密集数组与快数组

密集也就是我们常见的数组使用方式,按照数字索引逐步存放的,按顺序每个索引都存放内容了即使是undefined、null也是如此,这就叫做密集数组

密集数组通常是数组中的快数组,顾名思义,效率高,快

const list = []
list.push('第一个')
list.push('第二个')
list.push(null)
//实际上存放的是 ['第一个', '第二个']

稀疏数组与慢数组

了解了密集数组,也很容易想到稀疏数组,并不是索引逐步存放的,中间存在断档,也就是跳过了部分索引赋值

稀疏数组可能会转化为慢数组,顾名思义,效率低,慢

const list = []
list[10] = 10
list[100] = 100
//实际上存放了2个元素,长度缺是101,多出来的都是空元素

快数组与慢数组

当我们生成的数组是一个密集数组的时候,我们的数组会生成一个连续的内存结构,用于存放我们的索引,因此访问效率很高,时间复杂度位O(1),这就是快速组

连续内存,可以理解为,一个小区的楼栋分别按照顺序排列,我们知道了第一个只需要加上4个偏移就是第五栋了,在寻址中,知道地址就能快速访问到对应的地址内容了

当我们的数组是一个稀松数组,或者一个类数组时,会转化为慢数组,也就是我们常见的对象结构时,此时就是传说中的慢数组了,访问起来就是一个哈希结构了,这也是为了节省内存,毕竟学过数据结构或者了解过计算机内存分布的都知道,空间是申请好才能用的,且是连续的,不可能上来就乱申请一个巨大的连续空间,然后就为了保存那两个数据,非常不合理

尽管申请的内存块都是连续的,对于索引不连续的储存,一般都是采用哈希表的处理方式,将key转化为索引,处理冲突等,来解决内存映射问题,当然对于哈希结构用的比较多的就是线性表、树表了,而对于哈希表尽管优化,但操作、访问效率是不如顺序表的平均来看大约是O(1) ~ O(logn),并且由于线性表有冲突处理是要高于顺序表的,这里并不多介绍这两者,就介绍这么多x(想多了解的可以翻我以前写的数据结构与算法中的哈希表环节)

那么平时我们怎样做能用到快数组,什么样的场景慢数组呢

//快数组,索引有序逐步填充
const list = []
list.push('第一个')
list.push('第二个')

//慢数组,仍然使用数字的
const list = []
list[10] = 10
list[100] = 100

//慢数组,直接使用非数字的
const list = [] //使用new Array(101)也一样
list['name'] = 10
list[name2] = 100

实际上还有一种密集数组,仍然会转化为慢数组,那就是数据内容过多,比如超过1000,可能就会被v8转化为哈希结构的数组的,可能会问为什么,因为一块非常大连续的内存空间申请也是一个技术活,内存申请释放很容易出现内存碎片,简单说就是很多申请释放之间的连续内存区域不足够大,这么做也是为了更好使用碎片化内存,毕竟内存不够申请的话,也是一个大问题

最后

我们都知道数组实际上也是继承自对象,也有自己的原型,针对于上面的不同结构,我们用的数组操作是js提供给我们的一个统一接口,如果我们使用同一个接口用来转化操作不同结构的对象,我们会怎么处理呢,又会想到哪些设计模式呢?

本篇文章就介绍到这里了,希望大家有点收获😄

css外边距重叠问题

2026年2月8日 21:26

前端开发过程中,是否碰到这样一个场景,同时设置了上部元素下边距和下部元素上边距,发现实际的边距竟然不是两个外边距之和,这就是我们这里讲的css 外边距重叠了

css外边距重叠通常发生在 block 块级元素中(flex、inline不会出现),且只包括上下边距,左右边距不会重叠

有三种场景会出现边距重叠问题:

场景一:两个子节点同时设置上下边距

出现了两个子节点,分上部元素下边距(margin-bottom)和下部元素上边距(margin-top),此时,两者边距会出现以下合并场景

  • 两者均为正、均为负,取绝对值的最大值
  • 两者一正一负,取他们的和

场景二:父子节点的元素穿透

父节点没设置margin-top,子节点设置了margin-top,则子节点的margin-top则会穿透到父节点产生间距,当然这种场景一般我们直观感觉不到,确实存在这种场景

场景二:空元素上下边距重叠

一个空元素的存在,且设置了上下边距,一般我们认为就是一个占高的元素,通常我们会直接设置height,如果同时设置了margin-top、margin-bottom,则上下边距会重叠选择最大值,合并成他的占位高度,这个见到的非常少了

ps:我们正常编写为了避免出现此类问题,可以尽量使用 BFC 场景,例如:我们使用flex布局,子元素就不会出现重叠了

css实现一个宽高固定百分比的布局的一个方式

2026年2月8日 21:26

如果说要实现一个固定百分比布局,有不少方案,但大多数是针对于一个固定的值进行计算宽高的,搭配js非常的方便,如果要使用纯css的话有什么好的相对简单的办法呢

当然实际不推荐直接这么用,可以根据情况使用最合适的方法,这里只是提供一种思考思路,也就是css的一些特性,能让我们有更多方式实现同一种效果

这里 使用padding-top、padding-bottom 的百分比,相对于父节点宽度百分比的思路来实现

function App() {
  return (
    <div className="App">
    </div>
  );
}

cs效果如下所示

.App {
  padding-top: 50%;
  height: 0;
  width: 100%;
  background-color: red;
}

这样就实现了一个宽高固定百分比的方案,搭配一个简单的reactive、absolute 就可以轻松实现内容百分比展示、

当然有些场景使用 calc 直接计算也行,配合js、css变量更不多说了,方法多得很,看怎么操作

@tdesign/uniapp 图标瘦身

作者 Novlan1
2026年2月8日 20:35

1. 背景

随着 tdesign-icons 图标的不断丰富,@tdesign/uniapp 包的图标文件大小也不断变大。但是普通业务一般并不需要同时使用这么多图标,并且小程序等场景图标只能使用 iconfont 等,不支持动态加载,所以需要一种方式减包、瘦身。

2. 解决方案

不同于原生小程序,@tdesign/uniapp 在上传前有构建一步,可以利用插件来减包。

可以使用postcss-plugin-remove-selector 来在构建时进行无用图标的删减。

点此查看文档

3. 如何使用

安装

pnpm add @novlan/postcss-plugin-remove-selector -D

3.1. 简化模式(推荐)

通过 mode 指定预设,只需关注 used/unused,无需手动配置 fileselectorPattern

import { defineConfig } from 'vite';
import { postcssPluginRemoveSelector } from '@novlan/postcss-plugin-remove-selector';

export default defineConfig({
  css: {
    postcss: {
      plugins: [
        postcssPluginRemoveSelector({
          mode: 'tdesign',
          used: ['home', 'chat', 'user', 'add', 'search', 'close'],
        }),
      ],
    },
  },
});

使用 customUsed / customUnused 可在预设基础上增量追加,不会覆盖预设中已有的列表:

postcssPluginRemoveSelector({
  mode: 'tdesign',
  // 在 tdesign 预设默认的 used 列表上,额外追加 'star' 和 'heart'
  customUsed: ['star', 'heart'],
  // 从结果中额外移除 'loading'
  customUnused: ['loading'],
})

3.2. 标准模式

通过 list 数组传入完整配置,适用于需要匹配多个文件的复杂场景:

import { defineConfig } from 'vite';
import { postcssPluginRemoveSelector } from '@novlan/postcss-plugin-remove-selector';
import {
  TDESIGN_ICON_REMOVE_SELECTOR
} from '@novlan/postcss-plugin-remove-selector/lib/tdesign-uniapp-icon';


export default defineConfig({
  css: {
    postcss: {
      plugins: [postcssPluginRemoveSelector(TDESIGN_ICON_REMOVE_SELECTOR)],
    },
  },
});

4. 类型

插件支持两种配置方式,传入 SimpleOptions(简化模式)或 Options(标准模式)均可。

4.1. SimpleOptions(简化模式)

属性 类型 是否必填 说明
mode 'tdesign' 预设模式,设置后自动使用对应的 fileselectorPattern 默认值
file RegExp | string 文件匹配规则。使用 mode 时可省略
used string[] 正在使用的图标名称列表,这些图标会被保留
unused string[] 未使用的图标名称列表,这些图标会被移除
customUsed string[] 增量追加到 used 列表(不覆盖预设或已有的 used
customUnused string[] 增量追加到 unused 列表(不覆盖预设或已有的 unused
selectorPattern RegExp 选择器匹配模式。使用 mode 时可省略
debug boolean 是否开启调试模式

modefile 至少需要指定一个。当 modefile/selectorPattern 同时指定时,file/selectorPattern 优先。

4.2. Options(标准模式)

属性 类型 是否必填 说明
list FileConfig[] 配置列表
debug boolean 是否开启调试模式

4.3. FileConfig

属性 类型 是否必填 说明
file RegExp | string 文件匹配规则,可以是字符串或正则表达式
used string[] 正在使用的图标名称列表,这些图标会被保留
unused string[] 未使用的图标名称列表,这些图标会被移除
customUsed string[] 增量追加到 used 列表(不覆盖已有的 used
customUnused string[] 增量追加到 unused 列表(不覆盖已有的 unused
selectorPattern RegExp 选择器匹配模式,只处理匹配该模式的选择器

4.4. 内置预设

mode 说明 默认 file 默认 selectorPattern
tdesign TDesign UniApp 图标减包 /[@/]tdesign[/]uniapp[/]dist[/]icon[/]icon\.[css|vue]/ /^\.t-icon-[\w-]+:before$/

5. 文档

  1. 文档
  2. 更新日志
  3. 示例项目

6. 效果

tdesign-uniapp-starter 为例,使用此插件,可使小程序大小减少138KB,约95.8%

使用前:

转存失败,建议直接上传图片文件

使用后:

转存失败,建议直接上传图片文件

虚拟列表:支持“向上加载”的历史消息(Vue 3 & React 双版本)

2026年2月8日 19:00

前言

在AI聊天产品中,向上滚动加载历史消息是一个经典场景。如果直接渲染万级聊天记录,页面必卡无疑。而使用虚拟列表时,向上插入数据导致的位置偏移是最大的技术痛点。本文将分享如何实现一个支持“滚动位置锁定”和“动态高度补偿”的虚拟列表方案。

react虚拟列表向上加载.gif

一、 核心困难点:为什么向上加载这么难?

  1. 滚动位置丢失:当你向数组头部插入 5 条新消息时,总高度会增加。如果不处理,浏览器会停留在原来的 scrollTop,导致用户看到的内容被“顶走”。
  2. 动态高度计算:聊天内容(图片、长文本)高度不一,必须在 DOM 渲染后通过 ResizeObserver 实时修正。
  3. 索引偏移:插入数据后,原来的索引全部失效,必须依赖“累计高度数组”和二分查找重新定位。

二、 实现思路

1、第一步:搭个“戏台子”(基础结构)

我们要搭一个三层嵌套的戏台,每一层都有它的“使命”:

  1. 外层大管家:固定好高度,别让列表把页面撑坏了。
  2. “虚胖”占位层:这是个空盒子,高度设为 totalHeight。它的唯一作用是欺骗浏览器,让滚动条以为这里有成千上万条数据,从而产生真实的滚动感。
  3. 舞台中心(可视区) :绝对定位。它会像电梯一样,跟着你的滚动距离通过 translateY 灵活位移,永远保证自己出现在观众视线内。

2、第二步:准备核心数据

为了让“戏”不演砸,我们需要掌握这些情报:

  • 预判值MIN_ITEM_HEIGHT(哪怕不知道多高,也得有个保底值)和 BUFFER_SIZE(多渲染几行,别让用户一滑就看到白屏)。
  • 雷达站LOAD_THRESHOLD(距离顶部还有多远时,赶紧去后台搬救兵/加载数据)。
  • 记账本:用一个 Map 记录每个消息的真实高度,再整一个 cumulativeHeights(累计高度数组),记录每一条消息距离顶部的距离。

3、第三步:索引计算

  • 找起点:用二分查找在“记账本”里搜一下,看现在的滚动位置对应哪一行的地盘。
  • 定终点:起点加上你能看到的行数,再算上“缓冲区”的几位,就是这一幕的结束。
  • 定位置:算出起点项对应的累计高度,把舞台一推(offsetY),搞定!

4、第四步:时间回溯(向上加载的核心!核心!)

这是实现向上加载最难的地方:往开头塞了新胶片,怎么保证观众看到的画面不跳动?

  1. 做标记:触发加载前,先死死记住现在的 scrollHeight(总高)和 scrollTop(进度)。
  2. 塞数据:把新消息“砰”地一下插到 listData 的最前面。
  3. 神操作(高度补偿) :数据塞进去后,总高度肯定变了。这时候赶紧算一下:新高度 - 旧高度 = 增加的高度
  4. 瞬间平移:把滚动条位置强制修改为 旧进度 + 增加的高度。这套动作要在浏览器刷新前完成,用户只会觉得加载了新内容,但眼前的画面纹丝不动。

5、第五步:实时监控(高度纠正)

万一某条消息里突然蹦出一张大图,高度变了怎么办?

  • 派出侦察兵:子组件自带 ResizeObserver,一旦发现自己长高了,立马报告给父组件。
  • 精准打击:父组件收到报告,更新账本。如果这个变高的项在观众视线上方,还得手动把滚动条再推一推,防止内容在眼皮子底下“乱跳”。

6、终章:开幕仪式(初始化)

  1. 一滚到底:聊天室嘛,进场肯定得看最下面(最新消息)。
  2. 双重保险:调用 scrollToBottom 时,先用 requestAnimationFrame 请浏览器配合,再加个 setTimeout 兜底,确保无论网络多慢,都能准确降落在列表底部。

三、 Vue 3 + TailwindCSS 实现

1. 虚拟列表组件:

<template>
  <div
    class="min-h-screen bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5"
  >
    <div
      class="bg-white mt-10 rounded-xl border shadow-lg relative"
      ref="containerRef"
    >
      <div
        ref="virtualListRef"
        class="h-full overflow-auto relative overflow-anchor-none"
        @scroll="handleScroll"
      >
        <!-- 顶部加载提示 -->
        <div
          v-if="isLoading"
          class="sticky top-0 z-10 py-2 flex justify-center items-center text-sm text-gray-500"
        >
          <div class="flex items-center space-x-2">
            <span>正在加载...</span>
          </div>
        </div>

        <div :style="{ height: `${totalHeight}px` }"></div>
        <div
          class="absolute top-0 left-0 right-0"
          :style="{ transform: `translateY(${offsetY}px)` }"
        >
          <VirtualListItem
            v-for="item in visibleList"
            :key="item.id"
            :item="item"
            @update-height="handleItemHeightUpdate"
          />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watch } from 'vue';
import VirtualListItem from './listItem.vue';

const MIN_ITEM_HEIGHT = 80; //预设虚拟列表项最小高度
const BUFFER_SIZE = 5; // 缓冲区大小,用于预加载项
const LOAD_THRESHOLD = 50; // 加载消息触发距离

const virtualListRef = ref<HTMLDivElement | null>(null); // 虚拟列表容器引用
const listData = ref<any[]>([]); // 列表数据
const itemHeights = ref<Map<number, number>>(new Map()); // 列表项高度数组:存储每个项的高度
const scrollTop = ref(0); // 滚动位置:当前滚动的垂直偏移量
const isLoading = ref(false); // 是否正在加载更多数据
const isInitialized = ref(false); // 是否已初始化:用于判断是否已加载初始数据
const hasMore = ref(true); // 是否有更多数据可加载
const containerRef = ref<HTMLDivElement | null>(null);

let minId = 10000; // 模拟生成消息ID

// 计算累计高度数组,对应了每个元素在列表中的垂直位置
const cumulativeHeights = computed(() => {
  const heights: number[] = [0];
  let currentSum = 0;
  for (const item of listData.value) {
    const h = itemHeights.value.get(item.id) || MIN_ITEM_HEIGHT;
    currentSum += h;
    heights.push(currentSum);
  }
  return heights;
});

// 列表总高度:列表所有项的累计高度
const totalHeight = computed(() => {
  const len = cumulativeHeights.value.length;
  return len > 0 ? cumulativeHeights.value[len - 1] : 0;
});

// 起始索引
const startIndex = computed(() => {
  let low = 0,
    high = cumulativeHeights.value.length - 1;
  // 核心:根据二分查找法出可视区内第一个可见项的索引!!!!!
  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    if (cumulativeHeights.value[mid] < scrollTop.value) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return Math.max(0, low - 1 - BUFFER_SIZE);
});

// 结束索引
const endIndex = computed(() => {
  if (!virtualListRef.value) return 10;
  const t = scrollTop.value + virtualListRef.value.clientHeight; // 可视区底部在列表中的垂直位置`
  let low = 0,
    high = cumulativeHeights.value.length - 1;
  // 核心:根据二分查找法出可视区内最后一个可见项的索引!!!!!
  while (low <= high) {
    const mid = Math.floor((low + high) / 2);
    if (cumulativeHeights.value[mid] < t) {
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return Math.min(listData.value.length, low + BUFFER_SIZE);
});

// 可见列表项:根据起始索引和结束索引截取列表数据
const visibleList = computed(() => {
  return listData.value.slice(startIndex.value, endIndex.value);
});

// 偏移量:根据起始索引计算列表项的垂直偏移量
const offsetY = computed(() => {
  if (startIndex.value === 0) return 0;
  return cumulativeHeights.value[startIndex.value];
});

// mock真实数据
const generateData = (count: number) => {
  const arr = [];
  for (let i = 0; i < count; i++) {
    minId--;
    arr.push({
      id: minId,
      content: `历史消息 ${minId}`,
      timestamp: new Date().toLocaleTimeString(),
    });
  }
  return arr;
};

// 初始化数据
const initData = async () => {
  const initialData = await new Promise<any[]>(
    (resolve) => setTimeout(() => resolve(generateData(20)), 100) // 模拟异步数据加载,初始加载时加载20条数据防止数据量过少撑不起容器
  );
  listData.value = initialData.reverse();
  await nextTick(); // 等待listData渲染到DOM中
  await nextTick(); // 再次等待子组件完全渲染并计算好实际高度

  isInitialized.value = true;
  scrollToBottom(); // 滚动到底部显示最新消息
};

// 滚动到底
const scrollToBottom = () => {
  if (!virtualListRef.value) return;
  const scroll = () => {
    nextTick(() => {
      if (virtualListRef.value) {
        const scrollHeight = virtualListRef.value.scrollHeight;
        const clientHeight = virtualListRef.value.clientHeight;
        virtualListRef.value.scrollTop = scrollHeight - clientHeight;
        scrollTop.value = virtualListRef.value.scrollTop;
      }
    });
  };

  // 双重保障:先使用requestAnimationFrame等待浏览器完成一次重绘,此时 scrollHeight 和 clientHeight 已正确计算,
  // 再用setTimeout兜底确保即使 requestAnimationFrame 失效也能执行
  requestAnimationFrame(() => {
    scroll();
    // 兜底方案,确保滚动执行
    setTimeout(() => {
      scroll();
    }, 100);
  });
};

// 监听totalHeight变化,初始化时确保滚动到底部
watch(
  totalHeight,
  (newVal, oldVal) => {
    if (isInitialized.value && oldVal === 0 && newVal > 0) {
      scrollToBottom();
    }
  },
  { immediate: true }
);

// 加载新消息
const loadNewMessages = async () => {
  if (isLoading.value || !hasMore.value || !isInitialized.value) return;
  isLoading.value = true;
  try {
    await new Promise((resolve) => setTimeout(resolve, 1000));    // 模拟1秒延迟
    const newData = generateData(5); // 每次加载5条新消息
    const currentScrollHeight = virtualListRef.value?.scrollHeight || 0;    // 记录当前滚动状态,为未加载前整个列表的高度(含不可见)!!!
    const currentScrollTop = scrollTop.value;
    listData.value = [...newData, ...listData.value];    // 在顶部添加新数据
    await nextTick();    // 等待DOM更新
    // 保持滚动位置,让用户停留在原来的地方
    if (virtualListRef.value) {
      const newScrollHeight = virtualListRef.value.scrollHeight;
      const heightAdded = newScrollHeight - currentScrollHeight;
      virtualListRef.value.scrollTop = currentScrollTop + heightAdded;
      scrollTop.value = virtualListRef.value.scrollTop;
    }
    // 模拟没有更多数据的情况
    if (minId <= 9000) {
      hasMore.value = false;
    }
  } catch (error) {
    console.error('加载消息失败:', error);
  } finally {
    isLoading.value = false;
  }
};

// 处理项目高度更新
const handleItemHeightUpdate = (id: number, realHeight: number) => {
  const oldHeight = itemHeights.value.get(id) || MIN_ITEM_HEIGHT;
  const diff = realHeight - oldHeight;
  if (Math.abs(diff) < 1) return;

  itemHeights.value.set(id, realHeight);
  // 如果项目在可视区域上方,调整滚动位置
  const index = listData.value.findIndex((item) => item.id === id);
  if (index < 0) return;

  const itemTop = cumulativeHeights.value[index];
  const viewportTop = scrollTop.value;

  if (itemTop < viewportTop && virtualListRef.value) {
    virtualListRef.value.scrollTop += diff;
    scrollTop.value = virtualListRef.value.scrollTop;
  }
};

// 处理滚动事件
const handleScroll = (e: Event) => {
  const target = e.target as HTMLDivElement;
  scrollTop.value = target.scrollTop;

  // 当滚动到距离顶部LOAD_THRESHOLD像素时,加载更多消息
  if (
    scrollTop.value <= LOAD_THRESHOLD &&
    !isLoading.value &&
    hasMore.value &&
    isInitialized.value
  ) {
    loadNewMessages();
  }
};


// 初始化
onMounted(() => {
  // 计算容器高度:视口高度减去上下边距和标题区域
  if (containerRef.value) {
    const computedHeight = window.innerHeight - 200; // 等价于 calc(100vh - 200px)
    containerRef.value.style.height = `${Math.max(200, computedHeight)}px`; // 防止负数或太小
  }
  // 确保DOM完全挂载后再初始化数据
  nextTick(() => {
    initData();
  });
});
</script>

<style scoped>
.overflow-anchor-none {
  overflow-anchor: none;
}
</style>

2. 子组件:

<template>
  <div
    ref="itemRef"
    class="py-2 px-4 border-b border-gray-200"
    :class="{
      'bg-pink-200': item.id % 2 !== 0,
      'bg-green-200': item.id % 2 === 0,
    }"
    :style="{ height: item.id % 2 === 0 ? '150px' : '100px' }"
  >
    {{ item.content }}
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUpdated, onUnmounted, watch, nextTick } from 'vue';

// 定义props:接收父组件传递的item数据
const props = defineProps<{
  item: {
    id: number;
    content: string;
  };
}>();

// 定义emit:向父组件传递高度更新事件
const emit = defineEmits<{
  (e: 'update-height', id: number, height: number): void;
}>();

const itemRef = ref<HTMLDivElement | null>(null);
let resizeObserver: ResizeObserver | null = null;

// 计算并发送当前组件的高度
const sendItemHeight = () => {
  if (!itemRef.value) return;
  const realHeight = itemRef.value.offsetHeight;
  emit('update-height', props.item.id, realHeight);
};

// 监听组件挂载:首次发送高度 + 监听高度变化
onMounted(() => {
  // 首次渲染完成后发送高度
  nextTick(() => {
    sendItemHeight();
  });

  // 监听元素高度变化(适配动态内容导致的高度变化)
  if (window.ResizeObserver) {
    resizeObserver = new ResizeObserver(() => {
      sendItemHeight();
    });
    if (itemRef.value) {
      resizeObserver.observe(itemRef.value);
    }
  }
});

// 组件更新后重新发送高度(比如内容变化)
onUpdated(() => {
  nextTick(() => {
    sendItemHeight();
  });
});

// 组件卸载:清理监听
onUnmounted(() => {
  if (resizeObserver) {
    resizeObserver.disconnect();
    resizeObserver = null;
  }
});

// 监听item变化:如果item替换,重新计算高度
watch(
  () => props.item.id,
  () => {
    nextTick(() => {
      sendItemHeight();
    });
  }
);
</script>

3. 效果图:

vue虚拟列表向上加载.gif


四、 React + TailwindCSS实现

在React中我们需要利用 useMemo 优化索引计算,并利用 useLayoutEffect 处理滚动位置,避免视觉闪烁。

1. 虚拟列表组件:

import React, {
  useState,
  useRef,
  useEffect,
  useMemo,
  useCallback,
} from 'react';
import VirtualListItem from './VirtualListItem';

const MIN_ITEM_HEIGHT = 80; // 每个列表项的最小高度
const BUFFER_SIZE = 5; // 缓冲区大小,用于预加载
const LOAD_THRESHOLD = 30; // 触发加载的px值
const NEW_DATA_COUNT = 5; // 每次加载的新数据数量
const PRE_LOAD_OFFSET = 100; // 预加载偏移量,用于提前加载部分数据

// 列表项类型定义
interface ListItem {
  id: number; // 列表项的唯一标识符
  content: string; // 列表项的内容
  timestamp: string; // 列表项的时间戳
}

const VirtualList: React.FC = () => {
  const virtualListRef = useRef<HTMLDivElement>(null); // 虚拟列表容器引用
  const containerRef = useRef<HTMLDivElement>(null); // 列表容器引用
  const loadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); // 加载定时器引用
  const initScrollAttemptsRef = useRef(0); // 初始化滚动尝试次数引用,最多10次
  const [listData, setListData] = useState<ListItem[]>([]); // 列表数据状态,初始为空数组
  const [itemHeights, setItemHeights] = useState<Map<number, number>>(
    new Map()
  ); // 列表项高度映射Map
  const [scrollTop, setScrollTop] = useState<number>(0); // 滚动位置状态,初始为0
  const [isLoading, setIsLoading] = useState<boolean>(false); // 加载状态,初始为false
  const [isInitialized, setIsInitialized] = useState<boolean>(false); // 初始化状态,初始为false
  const [hasMore, setHasMore] = useState<boolean>(true); // 是否还有更多数据状态,初始为true

  const minIdRef = useRef(10000); // 最小ID引用,初始为10000
  const isLoadingRef = useRef(false); // 正在加载状态
  const hasMoreRef = useRef(true); // 是否还有更多数据
  const isFirstInitRef = useRef(true); // 是否第一次初始化
  const scrollStateRef = useRef<{
    isManualScroll: boolean;
    lastScrollTop: number;
  }>({
    isManualScroll: false,
    lastScrollTop: 0,
  }); // 滚动状态引用

  // 同步 ref 和 state
  useEffect(() => {
    isLoadingRef.current = isLoading;
    hasMoreRef.current = hasMore;
  }, [isLoading, hasMore]);

  // 计算累计高度
  const cumulativeHeights = useMemo(() => {
    const heights: number[] = [0];
    let currentSum = 0;
    for (const item of listData) {
      const h = itemHeights.get(item.id) || MIN_ITEM_HEIGHT;
      currentSum += h;
      heights.push(currentSum);
    }
    return heights;
  }, [listData, itemHeights]);

  // 列表总高度
  const totalHeight = useMemo(() => {
    return cumulativeHeights[cumulativeHeights.length - 1] || 0;
  }, [cumulativeHeights]);

  // 起始索引
  const startIndex = useMemo(() => {
    if (!virtualListRef.current || listData.length === 0) return 0;
    let low = 0,
      high = cumulativeHeights.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      if (cumulativeHeights[mid] < scrollTop) {
        low = mid + 1;
      } else {
        high = mid - 1;
      }
    }

    const baseIndex = Math.max(0, low - 1);
    return Math.max(0, baseIndex - BUFFER_SIZE);
  }, [cumulativeHeights, scrollTop, listData.length]);

  // 结束索引
  const endIndex = useMemo(() => {
    if (!virtualListRef.current || listData.length === 0)
      return BUFFER_SIZE * 2;
    const clientHeight = virtualListRef.current.clientHeight;
    const t = scrollTop + clientHeight + PRE_LOAD_OFFSET;
    let low = 0,
      high = cumulativeHeights.length - 1;
    while (low <= high) {
      const mid = Math.floor((low + high) / 2);
      if (cumulativeHeights[mid] < t) {
        low = mid + 1;
      } else {
        high = mid - 1;
      }
    }

    return Math.min(listData.length, low + BUFFER_SIZE);
  }, [cumulativeHeights, scrollTop, listData.length]);

  // 可见列表项
  const visibleList = useMemo(() => {
    return listData.slice(startIndex, endIndex);
  }, [listData, startIndex, endIndex]);

  // 偏移量
  const offsetY = useMemo(() => {
    return startIndex === 0 ? 0 : cumulativeHeights[startIndex];
  }, [cumulativeHeights, startIndex]);

  // 生成模拟数据
  const generateData = useCallback(
    (count: number, isInitialLoad: boolean = false) => {
      const arr: ListItem[] = [];
      for (let i = 0; i < count; i++) {
        minIdRef.current--;
        arr.push({
          id: minIdRef.current,
          content: `历史消息 ${minIdRef.current}`,
          timestamp: new Date().toLocaleTimeString(),
        });
      }
      console.log('生成数据:', arr);
      if (!isInitialLoad) {
        arr.reverse();
      }
      return arr;
    },
    []
  );

  // 滚动到底部
  const scrollToBottom = useCallback(() => {
    if (!virtualListRef.current) return;

    const scrollEl = virtualListRef.current;

    // 使用多次尝试,直到成功滚动到底部
    const attemptScroll = () => {
      requestAnimationFrame(() => {
        const scrollHeight = scrollEl.scrollHeight;
        const clientHeight = scrollEl.clientHeight;

        if (scrollHeight > clientHeight) {
          const targetScrollTop = scrollHeight - clientHeight;
          const currentScrollTop = scrollEl.scrollTop;

          // 如果还没到底部,继续滚动
          if (Math.abs(currentScrollTop - targetScrollTop) > 1) {
            scrollEl.scrollTop = targetScrollTop;
            setScrollTop(targetScrollTop);

            // 增加尝试次数
            initScrollAttemptsRef.current++;

            // 最多尝试10次,每次间隔50ms
            if (initScrollAttemptsRef.current < 10) {
              setTimeout(attemptScroll, 50);
            } else {
              console.log('初始化滚动到底部完成');
              isFirstInitRef.current = false;
            }
          } else {
            console.log('已经滚动到底部');
            isFirstInitRef.current = false;
          }
        } else {
          isFirstInitRef.current = false; // 内容高度小于容器高度,不需要滚动
        }
      });
    };

    // 重置尝试次数并开始滚动
    initScrollAttemptsRef.current = 0;
    attemptScroll();
  }, []);

  // 初始化数据
  const initData = useCallback(async () => {
    try {
      const initialData = await new Promise<ListItem[]>((resolve) =>
        setTimeout(() => resolve(generateData(20, true)), 100)
      );
      setListData(initialData);
      setIsInitialized(true);
    } catch (error) {
      console.error('初始化数据失败:', error);
    }
  }, [generateData]);

  // 核心:加载新消息
  const loadNewMessages = useCallback(async () => {
    if (isLoadingRef.current || !hasMoreRef.current || !isInitialized) return;

    isLoadingRef.current = true;
    setIsLoading(true);

    try {
      await new Promise((resolve) => setTimeout(resolve, 1000));
      const newData = generateData(NEW_DATA_COUNT, false);

      const scrollEl = virtualListRef.current;
      if (!scrollEl) return;

      // 1. 记录加载前的滚动位置
      const beforeScrollTop = scrollEl.scrollTop;
      const beforeScrollHeight = scrollEl.scrollHeight;

      // 2. 更新数据
      setListData((prev) => [...newData, ...prev]);

      // 3. 等待DOM更新后调整滚动位置
      requestAnimationFrame(() => {
        if (scrollEl) {
          const afterScrollHeight = scrollEl.scrollHeight;
          const heightAdded = afterScrollHeight - beforeScrollHeight;

          // 关键修复:检查当前是否仍在顶部附近
          const isStillNearTop = scrollEl.scrollTop <= LOAD_THRESHOLD + 50;

          // 只有当用户没有手动滚动且仍在顶部时才调整
          if (!scrollStateRef.current.isManualScroll && isStillNearTop) {
            scrollEl.scrollTop = beforeScrollTop + heightAdded;
            setScrollTop(scrollEl.scrollTop);
          }
        }
      });

      // 模拟没有更多数据
      if (minIdRef.current <= 9000) {
        hasMoreRef.current = false;
        setHasMore(false);
      }
    } catch (error) {
      console.error('加载消息失败:', error);
    } finally {
      isLoadingRef.current = false;
      setIsLoading(false);
    }
  }, [generateData, isInitialized]);

  // 处理列表项高度更新
  const handleItemHeightUpdate = useCallback(
    (id: number, realHeight: number) => {
      setItemHeights((prev) => {
        const newHeights = new Map(prev);
        const oldHeight = newHeights.get(id) || MIN_ITEM_HEIGHT;
        const diff = realHeight - oldHeight;

        if (Math.abs(diff) < 1) return prev;

        newHeights.set(id, realHeight);

        // 自动调整滚动位置
        if (
          virtualListRef.current &&
          !isFirstInitRef.current &&
          !scrollStateRef.current.isManualScroll
        ) {
          const scrollEl = virtualListRef.current;
          const index = listData.findIndex((item) => item.id === id);

          if (index >= 0) {
            const itemTop = cumulativeHeights[index];
            const viewportTop = scrollEl.scrollTop;

            // 仅当元素在视口上方时调整
            if (itemTop < viewportTop) {
              scrollEl.scrollTop += diff;
              setScrollTop(scrollEl.scrollTop);
            }
          }
        }

        return newHeights;
      });
    },
    [listData, cumulativeHeights]
  );

  // 处理滚动事件
  const handleScroll = useCallback(
    (e: React.UIEvent<HTMLDivElement>) => {
      const target = e.target as HTMLDivElement;
      const currentScrollTop = target.scrollTop;
      setScrollTop(currentScrollTop);

      // 标记手动滚动
      scrollStateRef.current = {
        isManualScroll: true,
        lastScrollTop: currentScrollTop,
      };

      // 检查是否需要加载
      const shouldLoad = currentScrollTop <= LOAD_THRESHOLD;

      if (
        shouldLoad &&
        !isLoadingRef.current &&
        hasMoreRef.current &&
        isInitialized
      ) {
        // 清除之前的防抖计时器
        if (loadTimerRef.current) {
          clearTimeout(loadTimerRef.current);
        }

        // 防抖处理
        loadTimerRef.current = setTimeout(() => {
          if (target.scrollTop <= LOAD_THRESHOLD && !isLoadingRef.current) {
            loadNewMessages();
          }
        }, 100);
      }
    },
    [isInitialized, loadNewMessages]
  );

  // 初始化
  useEffect(() => {
    console.log('组件挂载,开始初始化');

    // 设置容器高度
    if (containerRef.current) {
      const computedHeight = window.innerHeight - 200;
      containerRef.current.style.height = `${Math.max(200, computedHeight)}px`;
    }

    initData();

    // 清理函数
    return () => {
      console.log('组件卸载,清理定时器');
      if (loadTimerRef.current) {
        clearTimeout(loadTimerRef.current);
      }
    };
  }, [initData]);

  // 监听总高度变化,在数据完全渲染后滚动到底部
  useEffect(() => {
    if (isInitialized && totalHeight > 0 && isFirstInitRef.current) {
      // 延迟一段时间确保DOM完全渲染
      const timer = setTimeout(() => {
        scrollToBottom();
      }, 300); // 增加延迟时间,确保所有列表项都已渲染并测量高度

      return () => clearTimeout(timer);
    }
  }, [isInitialized, totalHeight, scrollToBottom]);

  // 监听列表数据变化,确保在高度测量后滚动
  useEffect(() => {
    if (listData.length > 0 && isInitialized && isFirstInitRef.current) {
      console.log('列表数据更新,当前数据量:', listData.length);

      // 再给一些时间让所有列表项完成高度测量
      const timer = setTimeout(() => {
        if (isFirstInitRef.current) {
          console.log('高度测量后尝试滚动');
          scrollToBottom();
        }
      }, 500);

      return () => clearTimeout(timer);
    }
  }, [listData.length, isInitialized, scrollToBottom]);

  // 重置手动滚动标记
  useEffect(() => {
    const timer = setTimeout(() => {
      scrollStateRef.current.isManualScroll = false;
    }, 500);
    return () => clearTimeout(timer);
  }, [scrollTop]);

  return (
    <div className="h-full bg-gradient-to-br from-indigo-600 to-purple-600 py-10 px-5">
      <div
        ref={containerRef}
        className="bg-white mt-10 rounded-xl border shadow-lg relative"
      >
        <div
          ref={virtualListRef}
          className="h-full overflow-auto relative"
          onScroll={handleScroll}
          style={{
            overflowAnchor: 'none',
            overscrollBehavior: 'contain',
            scrollBehavior: 'auto',
          }}
        >
          {/* 加载提示(绝对定位,不影响布局) */}
          {isLoading && (
            <div className="absolute top-0 left-0 right-0 z-10 py-2 flex justify-center items-center text-sm text-gray-500 ">
              <div className="flex items-center space-x-2">
                <div className="w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></div>
                <span>正在加载历史消息...</span>
              </div>
            </div>
          )}

          {/* 列表占位容器 */}
          <div
            style={{
              height: `${totalHeight}px`,
              pointerEvents: 'none',
              opacity: 0,
            }}
          ></div>

          {/* 可视区域内容 */}
          <div
            className="absolute top-0 left-0 right-0"
            style={{
              transform: `translateY(${offsetY}px)`,
              width: '100%',
            }}
          >
            {visibleList.length === 0 ? (
              <div className="py-4 text-center text-gray-400">
                {listData.length === 0
                  ? '正在初始化...'
                  : '加载更多历史消息...'}
              </div>
            ) : (
              visibleList.map((item) => (
                <VirtualListItem
                  key={item.id}
                  item={item}
                  onUpdateHeight={handleItemHeightUpdate}
                />
              ))
            )}
          </div>

          {/* 没有更多数据的提示 */}
          {!hasMore && (
            <div className="absolute bottom-0 left-0 right-0 py-2 text-center text-sm text-gray-400 bg-white border-t">
              没有更多历史消息了
            </div>
          )}
        </div>
      </div>
    </div>
  );
};

export default VirtualList;

2. 子组件:

import React, {
  useEffect,
  useRef,
  forwardRef,
  useImperativeHandle,
} from 'react';

export interface ListItemProps {
  item: {
    id: number;
    content: string;
    timestamp: string;
  };
  onUpdateHeight: (id: number, height: number) => void;
}

const VirtualListItem = forwardRef<HTMLDivElement, ListItemProps>(
  ({ item, onUpdateHeight }, ref) => {
    const itemRef = useRef<HTMLDivElement>(null);
    const resizeObserverRef = useRef<ResizeObserver | null>(null);

    useImperativeHandle(ref, () => {
      if (itemRef.current) {
        return itemRef.current;
      }
      // 提供一个安全的默认值
      const emptyDiv = document.createElement('div');
      return emptyDiv;
    });

    // 使用 ResizeObserver 监听尺寸变化
    useEffect(() => {
      const updateHeight = () => {
        if (itemRef.current) {
          const height = itemRef.current.offsetHeight;
          onUpdateHeight(item.id, height);
        }
      };

      // 立即执行一次初始测量
      updateHeight();

      if (!resizeObserverRef.current) {
        resizeObserverRef.current = new ResizeObserver(() => {
          // 防抖处理,避免频繁触发
          if (itemRef.current) {
            requestAnimationFrame(updateHeight);
          }
        });
      }

      if (itemRef.current && resizeObserverRef.current) {
        resizeObserverRef.current.observe(itemRef.current);
      }

      // 额外的初始延迟测量,确保样式已应用
      const timer = setTimeout(() => {
        updateHeight();
      }, 10);

      return () => {
        if (resizeObserverRef.current && itemRef.current) {
          resizeObserverRef.current.unobserve(itemRef.current);
        }
        clearTimeout(timer);
      };
    }, [item.id, onUpdateHeight]);

    // 模拟不同的内容高度
    const itemStyle: React.CSSProperties = {
      height: item.id % 2 === 0 ? '150px' : '100px',
    };

    const itemClass = `${item.id % 2 !== 0 ? 'bg-pink-200' : 'bg-green-200'}`;

    return (
      <div ref={itemRef} className={itemClass} style={itemStyle}>
        {item.id}
      </div>
    );
  }
);

VirtualListItem.displayName = 'VirtualListItem';
export default VirtualListItem;

3. 效果图:

react虚拟列表向上加载.gif


五、 注意事项

  • 浏览器干扰:必须设置 overflow-anchor: none。现代浏览器尝试自动调整滚动位置,这会与我们的手动补偿冲突。

  • 索引边界检查:对切片索引执行 Math.max(0, ...)Math.min(total, ...) 的区间收敛,防止因 startIndexendIndex 越界导致的渲染异常。

  • 初始化时机:首次加载数据后,应调用 scrollToBottom()。为了确保渲染完成,建议采用 requestAnimationFrame + setTimeout 的双重保险。

  • 无感加载策略:执行头部数据插入前,需快照记录当前的 scrollHeight。数据推送至渲染引擎后,通过 newScrollHeight - oldScrollHeight 算得 空间增量,并将其累加至当前滚动偏移量上。该补偿逻辑需在渲染刷新前完成,以实现“无感加载”

  • 性能瓶颈:随着 listData 增加到数万条,cumulativeHeights 的计算可能变慢。此时可考虑分段计算维护高度。


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

2026年2月8日 17:14

“破案”笔记: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

作者 anOnion
2026年2月8日 16:43

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 教程

作者 千寻girling
2026年2月8日 15:49

第一章 安装和配置 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

作者 code_YuJun
2026年2月8日 15:32

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 作用

作者 code_YuJun
2026年2月8日 15:06

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`的天壤之别

作者 Lee川
2026年2月8日 14:57

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年终总结

作者 海石
2026年2月8日 14:56

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

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 安全与反编译源码下的权限设计:构建前后端一体的信任防线

作者 LeonGao
2026年2月8日 14:35

引言

在现代 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的两种主流方案深度解析

作者 鹿心肺语
2026年2月8日 14:33

引言

在现代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 安全与反编译源码下的权限设计:构筑前后端一致的防护体系

作者 LeonGao
2026年2月8日 14:32

引言

在 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 翻页书籍组件,呼吸海浪式翻页,交互体验带感!

作者 前端市界
2026年2月8日 13:46

用 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:复杂模型加载优化黑魔法

作者 叶智辽
2026年2月8日 13:34

前言:正当我沉浸在将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优化后体积,看看谁是真正的"压王"😏

❌
❌