普通视图
高性能 Package构建系统设计与实现
JavaScript 一些小特性:让你的代码更优雅高效
告别异地登录告警!用 GitHub Self-Hosted Runner 打造“零打扰”全栈自动化部署
全栈项目:闲置二手交易系统(一)
如何使用 vxe-table 导出为带图片的单元格到 excel 格式文件
如何使用 vxe-table 导出为带图片的单元格到 excel 格式文件 需要注意导出图片时,需确保图片是有效链接,且允许跨域获取,否则不支持导出图片
查看官网:vxetable.cn
gitbub:github.com/x-extends/v…
gitee:gitee.com/x-extends/v…
![]()
![]()
通过定义 cellRender 为 VxeImage 来导出图片格式
<template>
<div>
<vxe-button status="primary" @click="exportEvent">高级导出</vxe-button>
<vxe-grid ref="gridRef" v-bind="gridOptions"></vxe-grid>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const gridRef = ref()
const imgUrlCellRender = reactive({
name: 'VxeImage',
props: {
width: 36,
height: 36
}
})
const gridOptions = reactive({
border: true,
showFooter: true,
showOverflow: true,
columnConfig: {
resizable: true
},
editConfig: {
trigger: 'click',
mode: 'cell'
},
mergeCells: [
{ row: 0, col: 2, rowspan: 1, colspan: 2 },
{ row: 2, col: 2, rowspan: 2, colspan: 1 }
],
exportConfig: {
type: 'xlsx'
},
menuConfig: {
body: {
options: [
[
{ code: 'EXPORT_ALL', name: '导出.xlsx' }
]
]
}
},
columns: [
{ field: 'checkbox', type: 'checkbox', width: 70 },
{ field: 'seq', type: 'seq', width: 70 },
{ field: 'name', title: 'Name', editRender: { name: 'VxeInput' } },
{ field: 'code', title: '字符串类型', cellType: 'string', editRender: { name: 'VxeInput' } },
{
title: '分组1',
children: [
{ field: 'num1', title: '数值', editRender: { name: 'VxeNumberInput', props: { type: 'float' } } },
{ field: 'num2', title: '数值(负数标红)', editRender: { name: 'VxeNumberInput', showNegativeStatus: true, props: { type: 'float' } } }
]
},
{ field: 'amount1', title: '货币', editRender: { name: 'VxeNumberInput', props: { type: 'amount', showCurrency: true } } },
{ field: 'amount2', title: '货币(负数标红)', editRender: { name: 'VxeNumberInput', showNegativeStatus: true, props: { type: 'amount', showCurrency: true } } },
{ field: 'imgUrl', title: '图片', width: 80, cellRender: imgUrlCellRender }
],
data: [
{ id: 10001, name: '张三', code: '000528697411', num1: 0.32, num2: -0.11, amount1: 1000000000, amount2: -0.11, imgUrl: 'https://vxeui.com/resource/img/fj586.png' },
{ id: 10002, name: '李四', code: '001236598563', num1: null, num2: 100000, amount1: -990000, amount2: 4, imgUrl: 'https://vxeui.com/resource/img/fj573.jpeg' },
{ id: 10003, name: '王五', code: '001499675653', num1: 100000, num2: null, amount1: 1, amount2: 100000, imgUrl: 'https://vxeui.com/resource/img/fj567.jpeg' },
{ id: 10004, name: '老六', code: '002568967545', num1: -0.11, num2: 1000000000, amount1: null, amount2: 1000000000, imgUrl: 'https://vxeui.com/resource/img/fj577.jpg' },
{ id: 10005, name: '小七', code: '005233368777', num1: -990000, num2: 28, amount1: 100000, amount2: null, imgUrl: 'https://vxeui.com/resource/img/bq673.gif' },
{ id: 10006, name: '小八', code: '000369877575', num1: 1000000000, num2: -990000, amount1: -0.11, amount2: -990000, imgUrl: 'https://vxeui.com/resource/img/fj124.jpeg' }
],
footerData: [
{ checkbox: '合计', name: '12人', no1: 356 }
]
})
const exportEvent = () => {
const $grid = gridRef.value
if ($grid) {
$grid.openExport()
}
}
</script>
点击导出后效果
![]()
TanStack Virtual 源码解析:定高/不定高虚拟列表实现原理以及框架无关设计
JavaScript中instanceof运算符的原理与实现
svelte 学习章节
svelte 前端框架
官方网站
创建新的项目
npx sv create my-app
cd my-app
npm run dev
脚本隔离
svelte怎么实现脚本隔离的呢
<button onclick={() => count++}> clicks: {count}
## $state.raw
在您不希望对象和数组深度反应式的情况下,您可以使用 `$state.raw`。
```html
<script module>
let person = $state.raw({
name: "Jane",
age: 18,
});
person.age+=1
person = {
name: 'Heraclitus',
age: 50
};
</script>
This reference only captures the initial value of
person. Did you mean to reference it inside a closure instead?
当然会报错 是否在闭包中使用它
$state.snapshot
要对深度响应式的state.snapshot:
<script> let counter = $state({ count: 0 });
function onclick()
{ // Will log `{ count: ... }` rather than `Proxy { ... }` console.log($state.snapshot(counter)); }
</script>
你想将一些状态传递给不期望代理的外部库或 API(例如 structuredClone)时,这会很方便。

返回当前的代理对象 做浅克隆的时候非常有用
$derived 无副作用
理解双向绑定或者计算属性. 替代没有 $:
$derived (...) 内部的表达式应该没有副作用。Svelte 会禁止在派生表达式中进行状态更改(例如 count++)。
<script>
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<button onclick={() => count++}>
{doubled}
</button>
<p>{count} doubled is {doubled}</p>
测试一下?
如果没添加的话 count 的值改 变 doubled 没有任何变化
Svelte 组件中的代码仅在创建时执行一次。如果 没有 $derived符文,即使count发生变化,doubled也将保持其原始值。
$effect
效果使您的应用程序“执行操作”。当 Svelte 运行效果函数时,它会跟踪访问了哪些状态(和派生状态)(除非在 untrack 内部访问),并在该状态稍后更改时重新运行该函数。
Svelte 应用程序中的大多数效果都是由 Svelte 本身创建的——例如,当 name 更改时,它们是更新 <h1>hello {name}!</h1> 中文本的部分。
但是,您还可以使用 $effect 符文创建自己的效果,当您需要将外部系统(无论是库、<canvas> 元素还是网络上的某些内容)与 Svelte 应用程序内的状态同步时,这很有用。
[^]: 避免过度使用 $effect!当您在效果中执行太多工作时,代码通常会变得难以理解和维护。请参阅 何时不使用 $effect 以了解替代方法。
和react 的useeffect 方式差不多,都能修改本身的state 状态change
修改 状态的时候会自动执行函数,无需配置依赖项
<script>
let size = $state(50);
let color = $state("#ff3e00");
let canvas;
$effect(() => {
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
console.log("执行了改变ui");
// this will re-run whenever `color` or `size` change
context.fillStyle = color;
context.fillRect(0, 0, size, size);
});
color = "#00";
</script>
<button onclick="{()=>(color = '#ff3e00')}">change</button>
<canvas bind:this={canvas} width="100" height="100" />
$props
组件的输入称为 props,它是 properties 的缩写。您可以像传递属性给元素一样传递 props 给组件
<script>
import MyComponent from './MyComponent.svelte';
</script>
<MyComponent adjective="cool" />
另一方面,在 MyComponent.svelte 内部,我们可以使用 $props 符文接收 props...
<script>
let props = $props();
</script>
<p>this component is {props.adjective}</p>
...尽管更常见的是,您会解构您的 props
<script>
let { adjective } = $props();
</script>
<p>this component is {adjective}</p>
回退值. (默认值 的方式好理解)
解构允许我们声明回退值,如果父组件未设置给定 prop,则使用这些值
let { adjective = 'happy' } = $props();
重命名 props
我们还可以使用解构赋值来重命名 props,如果它们是无效的标识符或 JavaScript 关键字(如 super),则需要这样做
let { super: trouper = 'lights are gonna find me' } = $props();
剩余 props
最后,我们可以使用 剩余属性 来获取,嗯,其余的 props
let { a, b, c, ...others } = $props();
更新 props
组件内部对 prop 的引用在 prop 本身更新时也会更新——当 App.svelte 中的 count 更改时,它也会在 Child.svelte 内部更改。但子组件能够临时覆盖 prop 值,这对于未保存的临时状态很有用 (演示)
app.svelte
<script>
import Child from './Child.<script>
import Child from './Child.svelte';
let count = $state(0);
</script>
<button onclick={() => (count += 1)}>
clicks (parent): {count}
</button>
<Child {count} />';
let count = $state(0);
</script>
<button onclick={() => (count += 1)}>
clicks (parent): {count}
</button>
<Child {count} />
Child
<script>
let { count } = $props();
</script>
<button onclick={() => (count += 1)}>
clicks (child): {count}
</button>
单纯的基本数据类形是可以修改的,子组件修改props 也是可以发生变化,但是如果是是一个对象就不能修改;
子传父 (vue emit)
createEventDispatcher
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
dispatch('temperatureChange', { temperature: acTemperature }); //后面是参数
$bindable。(不建议)
常,props 单向流动,从父组件到子组件。这使得理解数据在应用程序中的流动变得很容易。
在 Svelte 中,组件 props 可以被 绑定,这意味着数据也可以从子组件 向上 流向父组件。您不应该经常这样做,但如果谨慎而适量地使用,它可以简化您的代码。
这也意味着状态代理可以在子组件中被 修改。
!!!!对于普通 props 也可以进行修改,但强烈建议不要这样做 - 如果 Svelte 检测到组件正在修改它“不拥有”的状态,它会发出警告。
模板语法
@render
要渲染一个 代码片段,请使用 {@render ...} 标签。
{#snippet sum(a, b)}
<p>{a} + {b} = {a + b}</p>
{/snippet}
{@render sum(1, 2)}
{@render sum(3, 4)}
{@render sum(5, 6)}
当然可以这样
{@render (cool ? coolSnippet : lameSnippet)()}
可选代码片段
如果代码片段可能未定义——例如,因为它是一个传入的 prop——那么您可以使用可选链来仅在它已定义时渲染它
{@render children?.()}
bind 绑定
数据通常从上到下流动,从父组件到子组件。bind: 指令允许数据以相反的方式流动,从子组件到父组件。
双向绑定
<input bind:value={value} />
<input bind:value />
从 5.6.0 开始,如果 <input> 具有 defaultValue 并且是表单的一部分,则在表单重置时,它将恢复为该值而不是空字符串。请注意,对于初始渲染,绑定的值优先,除非它是 null 或 undefined。
<script>
let value = $state('');
</script>
<form>
<input bind:value defaultValue="not the empty string">
<input type="reset" value="Reset">
</form>
stores
stores是一个对象,它允许通过简单的商店契约以响应方式访问值。该svelte/store模块包含满足此契约的最小商店实现。
对带$前缀的变量的赋值要求该变量是可写商店,并且会导致调用商店的.set方法。
请注意,商店必须在组件的顶层声明 - 例如,不要在if块或函数内部声明。
<script>
import { writable } from 'svelte/store';
const count = writable(0);
console.log($count); // logs 0
count.set(1);
console.log($count); // logs 1
//局部变量(不表示商店值)不能带有$前缀。
$count = 2;
console.log($count); // logs 2
</script>
何时使用stores
在 Svelte 5 之前,商店是创建跨组件响应式状态或提取逻辑的首选解决方案。使用符文,这些用例已大大减少。
- 在提取逻辑时,最好利用符文的通用响应性:你可以在组件的顶层之外使用符文,甚至将它们放入 JavaScript 或 TypeScript 文件中(使用
.svelte.js或.svelte.ts文件结尾) - 在创建共享状态时,你可以创建一个包含所需值的
$state对象,然后操作该状态
export const userState = $state({
name: 'name',
/* ... */
});
App
<script>
import { userState } from './state.svelte';
</script>
<p>User name: {userState.name}</p>
<button onclick={() => {
userState.name = 'new name';
}}>
change name
</button>
上下文 context
大多数状态是组件级状态,其生命周期与组件相同。但是,也存在部分或应用程序范围的状态,也需要以某种方式进行处理。
最简单的方法是创建全局状态并导入它。
export const myGlobalState = $state({
user: {
/* ... */
}
/* ... */
});
sveltekit 路由
-
src/routes是根路由 -
src/routes/about创建了一个/about路由 -
src/routes/blog/[slug]创建了一个带有参数slug的路由,该参数可用于在用户请求/blog/hello-world等页面时动态加载数据
每个路由目录包含一个或多个路由文件,这些文件可以通过其
+前缀识别。+page.svelte
- 所有文件都可以在服务器上运行
- 除了
+server文件外,所有文件都在客户端运行+layout和+error文件适用于子目录以及它们所在的目录
+page.svelte
一个+page.svelte 组件定义了应用程序的一个页面。默认情况下,页面会在服务器上(SSR)渲染初始请求,并在浏览器中(CSR)渲染后续导航。
src/routes/about/+page
<h1>About this site</h1>
<p>TODO...</p>
<a href="/">Home</a>
+layout
到目前为止,我们一直将页面视为完全独立的组件——导航时,现有的+page.svelte 组件将被销毁,一个新的组件将取代它。
但在许多应用程序中,有一些元素应该在每个页面上可见,例如顶级导航或页脚。与其在每个+page.svelte 中重复它们,不如将它们放在布局中
渲染子组件。 (react 中的 omit)
SvelteKit 怎么获取当前路由的实例?
在 SvelteKit 中,你通常不需要获取“路由实例”(因为 SvelteKit 没有像 Vue Router 或 React Router 那样的“router instance”概念),但你可以轻松获取当前路由的相关信息
<!-- src/routes/+page.svelte -->
<script>
import { page } from '$app/stores';
</script>
<!-- 显示当前路径 -->
<p>当前路径: {$page.url.pathname}</p>
<!-- 显示查询参数 -->
<p>查询参数: {$page.url.searchParams.get('q')}</p>
<!-- 如果是动态路由,如 /blog/[slug] -->
{#if $page.params.slug}
<p>文章 slug: {$page.params.slug}</p>
{/if}
✅ 方法二:在 load 函数中获取 url 和 params
在 +page.js 或 +layout.js 的 load 函数中,你可以直接访问路由信息:
// src/routes/blog/[slug]/+page.js
export async function load({ params, url, fetch }) {
const { slug } = params; // ← 动态参数
const searchQuery = url.searchParams.get('highlight'); // ← 查询参数
const post = await fetch(`/api/posts/${slug}`).then(r => r.json());
return {
post,
highlight: searchQuery
};
}
然后在 +page.svelte 中通过 $page.data 使用:
<script>
import { page } from '$app/stores';
</script>
<h1>{$page.data.post.title}</h1>
{#if $page.data.highlight}
<mark>高亮关键词: {$page.data.highlight}</mark>
{/if}
✅ 方法三:编程式导航(类似“路由实例”的操作)
虽然没有 router 实例,但你可以使用 SvelteKit 提供的导航工具:
<script>
import { goto, invalidate } from '$app/navigation';
import { page } from '$app/stores';
function goToProfile() {
goto('/profile'); // 编程式跳转
}
function refreshData() {
// 重新运行当前页面的 load 函数
invalidate();
}
</script>
<button on:click={goToProfile}>去个人页</button>
<button on:click={refreshData}>刷新数据</button>
<p>当前路径: {$page.url.pathname}</p>
-
goto(url):跳转页面(客户端导航) -
invalidate():重新加载当前load数据 -
preloadData(url):预加载某个路由的数据
🚫 不要做的事
- ❌ 不要尝试
import router from '...'—— SvelteKit 没有暴露 router 实例。 - ❌ 不要手动解析
window.location—— 在 SSR(服务端渲染)时会出错。始终用$page或load上下文。
✅ 总结:如何“获取当前路由”
| 需求 | 推荐方式 |
|---|---|
| 获取当前路径、参数、查询字符串 | 使用 $page store(在 .svelte 文件中) |
| 在数据加载时获取路由信息 | 在 +page.js 的 load({ params, url }) 中 |
| 页面跳转或刷新 | 使用 goto()、invalidate() 等 $app/navigation 工具 |
| 全局监听路由变化 | 订阅 $page store 即可自动响应变化 |
svelte:component(官方动态组件标签)
<script>
import Home from './Home.svelte';
import About from './About.svelte';
let component = Home;
function switchToAbout() {
component = About;
}
</script>
<button on:click={switchToAbout}>切换到 About</button>
<svelte:component this={component} name="Alice" />
CSS Keyframes 实现 Vue 无缝无限轮播
🚀 纯 CSS Keyframes 实现 Vue 无缝无限轮播组件(跑马灯效果)
实现一个基于 Vue 和纯 CSS 实现的双向无限轮播(跑马灯)组件的使用,支持多行,并且鼠标悬停自动暂停!
🌟 组件核心
-
性能: 使用 CSS
transform和animation。 -
无缝: 通过巧妙的数据复制和
-50%滚动技巧实现完美无缝衔接。 -
交互: 鼠标悬停(
hover)时,自动暂停滚动。
💻 完整代码:MarqueeLoop.vue
1. Vue Template 和 Script
代码段
<template>
<div class="marquee-container">
<div class="marquee-row">
<div class="marquee-track">
<div class="logo-card" v-for="(item, index) in row1" :key="'r1-'+index" @click="openLink(item.url)">
<img :src="item.imgUrl" fit="fill" />
<span v-if="!item.logo">{{ item.name }}</span>
</div>
<div class="logo-card" v-for="(item, index) in row1" :key="'r1-dup-'+index"
@click="openLink(item.url)">
<img :src="item.imgUrl" fit="fill" />
<span v-if="!item.logo">{{ item.name }}</span>
</div>
</div>
</div>
<div class="marquee-row">
<div class="marquee-track-right">
<div class="logo-card" v-for="(item, index) in row2" :key="'r2-'+index" @click="openLink(item.url)">
<img :src="item.imgUrl" fit="fill" />
<span v-if="!item.logo">{{ item.name }}</span>
</div>
<div class="logo-card" v-for="(item, index) in row2" :key="'r2-dup-'+index"
@click="openLink(item.url)">
<img :src="item.imgUrl" fit="fill" />
<span v-if="!item.logo">{{ item.name }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
const row1 = ref([
{ name: 'Vue', imgUrl: 'https://cdn.example.com/vue.png', url: 'https://vuejs.org/' },
{ name: 'React', imgUrl: 'https://cdn.example.com/react.png', url: 'https://react.dev/' },
{ name: 'Node', imgUrl: '', logo: false, url: 'https://nodejs.org/' },
]);
const row2 = ref([
{ name: 'Google', imgUrl: 'https://cdn.example.com/google.png', url: 'https://www.google.com/' },
{ name: 'Apple', imgUrl: 'https://cdn.example.com/apple.png', url: 'https://www.apple.com/' },
]);
const openLink = (url) => {
if (url) {
window.open(url, '_blank');
}
};
</script>
2. CSS 样式和 Keyframes
CSS
<style scoped>
.marquee-container {
width: 100%;
background-color: #f5f7fa;
padding: 20px 0;
overflow: hidden;
}
.marquee-row {
display: flex;
margin-bottom: 20px;
overflow: hidden;
width: 100%;
}
/* --- 滚动轨道定义 --- */
.marquee-track,
.marquee-track-right {
display: flex;
width: max-content;
flex-shrink: 0;
}
.marquee-track {
animation: scroll-left 30s linear infinite; /* 向左滚动 */
}
.marquee-track-right {
animation: scroll-right 30s linear infinite; /* 向右滚动 */
}
/* 交互:鼠标悬停暂停动画 */
.marquee-track:hover,
.marquee-track-right:hover {
animation-play-state: paused;
}
/* --- 卡片样式 (可自定义) --- */
.logo-card {
width: 350px; /* 调整卡片宽度 */
height: 200px; /* 调整卡片高度 */
background: #ffffff;
margin: 0 10px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s;
}
.logo-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.logo-card img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
/* --- 动画关键帧 --- */
/* 1. 向左滚动:0% -> -50% */
@keyframes scroll-left {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
/* 2. 向右滚动:-50% -> 0% */
@keyframes scroll-right {
0% {
transform: translateX(-50%);
}
100% {
transform: translateX(0);
}
}
</style>
💡 无缝滚动原理揭秘
-
数据翻倍: 在 模板中,使用
v-for将数据 (row1或row2) 渲染了两遍。 -
width: max-content: 确保两个列表被强制排在一行,且容器总宽度是单个列表宽度的两倍。 -
关键的
-50%:-
向左: 动画从
translateX(0)移动到translateX(-50%)。当第一组数据完全移出屏幕时(即移动了 50% 的总宽度),动画瞬间重置回0,此时屏幕上显示的是第二组数据的起点,完美对接,实现视觉上的无限循环。 -
向右: 动画从
translateX(-50%)(即第二组数据的起点)移动到translateX(0)(即第一组数据的起点),实现反向循环。
-
向左: 动画从
通过这种方式,我们避免了使用 JavaScript 计算或操作 DOM,将动画交给 CSS 处理,极大地提高了性能和流畅度。
从零实现一个“类微信”表情输入组件
📋 背景
最近接到一个需求:在系统中添加表情输入功能。由于需要与腾讯某平台保持数据一致,表情包的数量和取值都要完全相同。
翻阅文档后发现并没有提供现成组件,只能自己实现。
先观察了实现方式:打开控制台面板,点击表情后会瞬间请求大量图片。
输入逻辑上,用户选择 😊 后值自动变成 [微笑],用户直接输入 [微笑] 也能映射成对应表情图片。
🚩 最终目标
- ✅ 支持点击表情面板插入表情
- ✅ 支持输入
[微笑]自动转换为表情图片 - ✅ 完成双向绑定,取值时图片转回
[微笑]文本 - ✅ 字符长度计算:中文 1 个字符,英文 0.5 个,表情 1 个
- ✅ 光标定位准确,体验流畅
- ✅ 输入法友好,不在拼音输入阶段转换
🧩 实现步骤
1、获取表情包数据
最初尝试在网上找表情包资源,但数量总是对不上。近百个表情包如果手动逐个校对太过折磨,于是尝试从页面爬取数据。 在浏览器控制台执行以下脚本:
// 获取所有表情元素
const emojiItems = document.querySelectorAll('.emoji-list li');
// 提取关键信息:图片地址、文本代码、文件名
const emojiArray = Array.from(emojiItems).map(li => {
const img = li.querySelector('img');
return img ? {
src: img.src,
alt: img.alt,
dataImage: img.getAttribute('data-image')
} : null;
}). filter(Boolean);
console.log(emojiArray);
执行后直接在控制台复制数组,保存为 JSON 文件。
src 只做下载使用,alt 需要用来做映射,dataImage 用于拼接读取表情图片路径。
[ { "alt": "[微笑]", "src": "https://xxx.qq.com/xxx/emojis/smiley_0.png" "dataImage": "smiley_0" }]
2、批量下载图片
使用 Node. js 脚本批量下载表情图片:
const fs = require('fs');
const path = require('path');
const outputDir = path. resolve(__dirname, 'emojis');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
async function downloadImage(item) {
const fileName = `${item.dataImage || item.alt || 'emoji'}. png`;
const filePath = path.join(outputDir, fileName);
try {
const res = await fetch(item.src);
if (!res.ok) throw new Error(`Failed to fetch ${item.src}`);
const buffer = await res.arrayBuffer();
fs.writeFileSync(filePath, Buffer.from(buffer));
console.log(`Downloaded: ${fileName}`);
} catch (err) {
console.error(`Error downloading ${item.src}:`, err.message);
}
}
async function downloadAll() {
for (const item of emojiArray) {
await downloadImage(item);
}
console.log('All downloads completed!');
}
downloadAll();
表情下载完,后面就好办了——交给 AI 🤖。
3、组件实现
大概思路是有的,本质就是一个 contenteditable 的 div,组件实现完全让 AI 完成,但需要考虑一些特殊情况,要给出明确期望:
1️⃣ 光标位置管理
- 长度达到限制时裁剪字符,统一将光标设置到末尾,防止位置异常(element-plus输入框效果如此)
- 点击空白区域自动定位到末尾
2️⃣ 文本与图片双向转换
文本 → 图片
- 使用防抖(200ms)避免频繁触发
- 仅转换光标前的内容,光标后的内容保持不变
- 通过正则匹配
[xxx]格式进行转换 - 关键:不在用户输入过程中转换,避免干扰输入体验
图片 → 文本
- 文本节点直接提取
textContent - 图片节点提取
data-alt属性转为文本 - 过滤零宽字符
\u200B
3️⃣ 字符长度计算
精确计算混合内容长度:中文 1 个字符,英文/数字 0.5 个,表情 1 个。
4️⃣ 输入法兼容
处理中文输入法的组合事件:compositionstart → compositionupdate → compositionend,避免在拼音输入阶段触发转换。
5️⃣ 删除表情
确保删除时完整移除表情图片节点,不留残留。
最终效果:
![]()
💡总结
借助 AI 辅助开发,核心在于:告知 AI 需要处理的特殊情况,以及不断测试和调优。
只要明确需求规范和边界情况,就能高效实现功能。怎么有种从开发转测试的感觉。
100¥ 实现的React项目 Keep-Alive 缓存控件
前言
目前在做自己的后台管理系统,采用React构建,利用Antd加了Tabs可关闭页签,结合dnd-kit实现了可拖拽Tabs,页面渲染之前参考网上采用Map缓存,结果实际到手发现不是缓存失效,就是刷新、清除影响缓存,反正问题挺多。
Vue是有自带的Keep-Alive控件,咱小胳膊小腿的,也不敢比,也没那时间精力去研究🧐,但React百度搜了是没有自带的Keep-Alive的,网上的教程也仅止于静态实例(可能我没搜到,万望大佬勿喷),但自己又很想要学这个功能。
最近AI确实很🔥很👍,之前使用过字节的Trae,当时效果还不错,刚好赶上Clude的末班车,自从Clude不让用后,Trae的体验一言难尽。于是抱着体验的态度,花了20$买了Cursor,于是就拿缓存Keep-Alive开🔪,从昨晚开始搞到现在,在我不断测试不断调教下,终于有了成果,但代价也不一般,直接上图
一天过去,直接烧掉100大洋,欢迎品尝
线上地址:www.liyq666.top/
git仓库:gitee.com/lyqjob/proj…
项目实例图
![]()
附上AI生成的使用文档,以下内容AI生成
概述
什么是页面缓存?
想象一下,你在浏览器中打开了多个标签页(比如:用户管理、角色管理、菜单管理)。当你从一个标签页切换到另一个标签页时,页面缓存系统会帮你:
- 保存页面状态:切换标签页时,页面不会重新加载,之前填写的数据、滚动位置等都会保留
- 避免重复请求:切换回之前的标签页时,不会重新请求接口,直接显示之前的数据
- 提升用户体验:页面切换更流畅,没有闪烁和重新加载的感觉
为什么需要缓存?
- 性能优化:减少不必要的 API 请求,提升页面响应速度
- 用户体验:保持页面状态,用户不会丢失已填写的数据
- 资源节约:避免重复渲染组件,节省浏览器资源
核心概念
1. 缓存存储(Cache Store)
缓存系统使用 Map 数据结构来存储页面组件:
// 三个核心存储结构
const cacheStore = new Map() // 存储:key -> ReactElement(页面组件)
const locationStore = new Map() // 存储:key -> Location(路由信息)
const accessOrder = [] // 存储:访问顺序数组,用于 LRU 算法
简单理解:
-
cacheStore:就像一个大仓库,每个页面都有一个编号(key),对应一个页面组件 -
locationStore:记录每个页面的路由信息(路径、参数等) -
accessOrder:记录页面的访问顺序,最近访问的排在最后
2. 缓存 Key(Cache Key)
每个页面都有一个唯一的标识符,由 路径 + 查询参数 组成:
// 例如:
'/setting/user' // 用户管理页面
'/setting/user?page=2' // 用户管理页面,第2页(不同的 key!)
'/setting/role' // 角色管理页面
重要:即使路径相同,查询参数不同,也会被视为不同的页面,需要分别缓存。
3. 白名单机制(Whitelist)
有些页面不需要缓存,每次访问都重新渲染。这些页面在白名单中:
const CACHE_WHITELIST = [
'/', // 首页
'/dashboard', // 数据看板
'/setting/cache', // 缓存管理页面
'/setting/log/loginlog', // 登录日志
'/setting/log/operlog', // 操作日志
'/monitor/online', // 在线用户
'/setting/role/info' // 角色详情
]
为什么需要白名单?
- 首页、看板等页面需要实时数据,不应该缓存
- 日志类页面需要显示最新数据,缓存会导致数据不准确
4. LRU 算法(Least Recently Used)
LRU = 最近最少使用
当缓存数量超过限制(默认 8 个)时,系统会自动删除最久未使用的页面缓存。
工作原理:
- 每次访问页面时,将该页面移到访问顺序数组的最后
- 当缓存超过 8 个时,删除访问顺序数组第一个(最久未使用的)
- 这样保证最常用的页面始终在缓存中
示例:
访问顺序:['/page1', '/page2', '/page3', '/page4', '/page5', '/page6', '/page7', '/page8']
访问 /page1 → ['/page2', '/page3', '/page4', '/page5', '/page6', '/page7', '/page8', '/page1']
访问 /page9 → 缓存已满,删除 /page2(最久未使用)
系统架构
整体架构图
┌─────────────────────────────────────────────────────────────┐
│ BasicLayout(布局组件) │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ TabsContainer(标签页容器) │ │
│ │ - 显示标签页 │ │
│ │ - 右键菜单(刷新、关闭等) │ │
│ │ - 拖拽排序 │ │
│ └───────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ KeepAliveOutlet(缓存核心组件) │ │
│ │ - 管理页面缓存 │ │
│ │ - LRU 算法 │ │
│ │ - 渲染缓存的页面 │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
│ 订阅/发布消息
▼
┌─────────────────────────────────────────────────────────────┐
│ useGlobalMessage(全局消息系统) │
│ - 发布 keep:alive:drop(删除单个缓存) │
│ - 发布 keep:alive:clear(清除所有缓存) │
└─────────────────────────────────────────────────────────────┘
│
│ 管理标签页状态
▼
┌─────────────────────────────────────────────────────────────┐
│ useTabsManager(标签页管理) │
│ - 添加/关闭标签页 │
│ - 切换标签页 │
│ - 发送清除缓存消息 │
└─────────────────────────────────────────────────────────────┘
│
│ 确保数据只加载一次
▼
┌─────────────────────────────────────────────────────────────┐
│ useCacheableEffect(可缓存的 Effect) │
│ - 跟踪已初始化的组件 │
│ - 防止重复加载数据 │
└─────────────────────────────────────────────────────────────┘
组件关系
- BasicLayout:最外层布局,包含所有组件
- TabsContainer:显示和管理标签页
- KeepAliveOutlet:核心缓存组件,管理页面缓存
- useTabsManager:管理标签页状态,发送清除缓存指令
- useGlobalMessage:全局消息系统,用于组件间通信
- useCacheableEffect:确保页面数据只加载一次
核心组件详解
1. KeepAliveOutlet(缓存核心组件)
位置:src/components/KeepAlive/index.jsx
职责:
- 管理页面缓存的生命周期
- 实现 LRU 算法
- 渲染缓存的页面组件
核心数据结构
// 缓存存储(模块级变量,所有实例共享)
const cacheStore = new Map() // key -> { element: ReactElement, location: Location }
const accessOrder = [] // 访问顺序数组,用于 LRU 算法
// 暴露到全局的工具函数
window.__checkCache = (key) => cacheStore.has(key) // 检查是否有缓存
window.__isWhitelisted = (pathname) => isWhitelisted(pathname) // 检查是否在白名单
注意:locationStore 已被移除,位置信息直接存储在 cacheStore 的 value 中。
关键函数
1. getCacheKey(pathname, search)
// 生成缓存 key
const getCacheKey = (pathname, search) => {
return pathname + search // 例如:'/setting/user?page=2'
}
2. isWhitelisted(pathname)
// 检查路径是否在白名单中
const isWhitelisted = (pathname) => {
return CACHE_WHITELIST.some(route => {
if (pathname === route) return true
if (pathname.startsWith(route + '/')) return true
return false
})
}
3. moveToRecent(key)
// 将 key 移到访问顺序数组的最后(标记为最近使用)
const moveToRecent = (key) => {
const index = accessOrder.indexOf(key)
if (index >= 0) {
accessOrder.splice(index, 1) // 从原位置删除
}
accessOrder.push(key) // 添加到末尾
}
4. evictLRU(excludeKey)
// LRU 清理:删除最久未使用的缓存(排除当前正在访问的)
const evictLRU = (excludeKey) => {
while (cacheStore.size >= CACHE_LIMIT) { // 默认 8 个
// 找到第一个不是 excludeKey 的 key(最久未使用的)
const keyToRemove = accessOrder.find(k => k !== excludeKey)
if (keyToRemove) {
removeCache(keyToRemove) // 删除缓存
} else {
break
}
}
}
5. removeCache(key)
// 移除指定 key 的缓存
const removeCache = (key) => {
if (cacheStore.has(key)) {
cacheStore.delete(key) // 删除组件缓存
const index = accessOrder.indexOf(key)
if (index >= 0) {
accessOrder.splice(index, 1) // 从访问顺序中删除
}
return true
}
return false
}
缓存管理流程
步骤 1:检查是否需要缓存
const shouldNotCache = useMemo(() => isWhitelisted(location.pathname), [location.pathname])
步骤 2:生成缓存 key
const cacheKey = getCacheKey(location.pathname, location.search)
步骤 3:处理缓存逻辑
useEffect(() => {
// 1. 白名单路由:不缓存,直接返回
if (shouldNotCache) {
if (removeCache(cacheKey)) {
setCacheVersion(v => v + 1) // 触发重新渲染
}
return
}
// 2. 如果 key 没变化,只更新访问顺序
if (prevKeyRef.current === cacheKey) {
if (cacheStore.has(cacheKey)) {
moveToRecent(cacheKey) // 标记为最近使用
}
return
}
// 3. key 变化了,处理新页面
prevKeyRef.current = cacheKey
// 如果还没有缓存,添加缓存
if (!cacheStore.has(cacheKey)) {
// 使用 setTimeout 确保 outlet 已经准备好
const timer = setTimeout(() => {
const currentOutlet = outletRef.current
if (currentOutlet) {
cacheStore.set(cacheKey, {
element: currentOutlet,
location: {
pathname: location.pathname,
search: location.search,
hash: location.hash,
state: location.state,
key: location.key
}
})
if (!accessOrder.includes(cacheKey)) {
accessOrder.push(cacheKey)
} else {
moveToRecent(cacheKey)
}
evictLRU(cacheKey) // 如果超过限制,删除最久未使用的
setCacheVersion(v => v + 1)
}
}, 0)
return () => {
clearTimeout(timer)
}
} else {
// 已缓存,只更新访问顺序
moveToRecent(cacheKey)
}
}, [cacheKey, shouldNotCache, outlet, location.pathname, location.search])
渲染逻辑
const nodes = useMemo(() => {
const list = []
// 1. 白名单路由:直接渲染,不缓存
if (shouldNotCache) {
if (outlet) {
list.push(<div key={cacheKey}>{outlet}</div>)
}
return list
}
// 2. 如果还没有缓存,但 outlet 存在,临时渲染(首次加载)
if (!cacheStore.has(cacheKey) && outlet) {
list.push(<div key={cacheKey}>{outlet}</div>)
}
// 3. 渲染所有缓存的组件(通过 display 控制显示/隐藏)
for (const [key, cache] of cacheStore.entries()) {
const isActive = key === cacheKey
list.push(
<div
key={key}
style={{
display: isActive ? 'block' : 'none', // 只有当前页面显示
height: '100%',
width: '100%'
}}
>
<CachedComponent cacheKey={key}>
{cache.element}
</CachedComponent>
</div>
)
}
return list
}, [cacheKey, cacheVersion, shouldNotCache, outlet])
消息订阅(接收清除缓存指令)
useEffect(() => {
// 订阅 'keep:alive:drop' 事件(删除单个缓存)
const onDrop = (detail) => {
const key = detail?.key
if (!key) return
// 🌟 只有在明确要求清除缓存时才清除(比如刷新标签页)
// 关闭标签页时不应该清除缓存,这样重新打开时可以快速恢复
const shouldRemove = detail?.remove === true
if (shouldRemove) {
if (removeCache(key)) {
setCacheVersion(v => v + 1)
}
// 清除组件初始化状态(刷新时才清除)
if (window.__clearComponentInit) {
window.__clearComponentInit(key)
}
}
// 关闭标签页时:不清除缓存,也不清除初始化状态
}
// 订阅 'keep:alive:clear' 事件(清除所有缓存)
const onClear = () => {
cacheStore.clear()
accessOrder.splice(0, accessOrder.length)
// 清除所有组件初始化状态
if (window.__clearAllInit) {
window.__clearAllInit()
}
setCacheVersion(v => v + 1)
}
const unsubscribeDrop = subscribe('keep:alive:drop', onDrop)
const unsubscribeClear = subscribe('keep:alive:clear', onClear)
return () => {
unsubscribeDrop()
unsubscribeClear()
}
}, [subscribe, error])
重要变化:
- 关闭标签页时:不清除缓存(
remove: false),保留页面状态,重新打开时可以快速恢复 - 刷新标签页时:清除缓存(
remove: true),强制重新加载数据 - 使用
window.__clearComponentInit和window.__clearAllInit清除初始化状态
2. useCacheableEffect(可缓存的 Effect Hook)
位置:src/hooks/useCacheableEffect.js
职责:
- 确保
useEffect只在首次挂载时执行 - 防止切换标签页时重复加载数据
核心数据结构
// 全局存储已初始化的组件(模块级变量)
const initializedComponents = new Set()
// 存储格式:'pathname+search::depsStr'
// 例如:'/setting/user::[]' 或 '/setting/role::[null,null]'
工作原理
export const useCacheableEffect = (effect, deps = [], options = {}) => {
const { cacheable = true, cacheKey } = options
const location = useLocation()
// 生成组件唯一标识
const componentKey = cacheKey || (location.pathname + location.search)
const depsStr = JSON.stringify(deps) // 依赖项的 JSON 字符串
const initKey = `${componentKey}::${depsStr}`
// 使用 ref 存储 effect 和是否已执行
const effectRef = useRef(effect)
const hasExecutedRef = useRef(false)
// 更新 effect 引用
useEffect(() => {
effectRef.current = effect
}, [effect])
useEffect(() => {
// 如果不可缓存,每次都执行
if (!cacheable) {
const cleanup = effectRef.current()
return cleanup
}
// 检查是否已初始化(全局检查)
if (initializedComponents.has(initKey)) {
window.process.done() // 关闭进度条
return // 已初始化,跳过执行
}
// 检查是否已执行(组件级别检查,防止重复执行)
if (hasExecutedRef.current) {
// 确保已标记为已初始化
if (!initializedComponents.has(initKey)) {
initializedComponents.add(initKey)
}
return
}
// 首次执行
hasExecutedRef.current = true
initializedComponents.add(initKey)
const cleanup = effectRef.current()
return () => {
if (typeof cleanup === 'function') {
cleanup()
}
}
}, [componentKey, cacheable, depsStr]) // 不包含 effect,使用 effectRef 存储最新引用
}
关键改进:
- 使用
Set而不是Map存储初始化状态 - 使用
effectRef存储最新的 effect 函数,避免依赖项变化导致的问题 - 双重检查机制:全局检查(
initializedComponents)和组件级别检查(hasExecutedRef) - 自动关闭进度条:当检测到已初始化时,自动调用
window.process.done()
使用示例
非白名单页面(需要缓存):
// 在页面组件中使用
const UserManagement = () => {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
// ✅ 首次加载数据 - 使用 useCacheableEffect,必须添加 cacheable: true
useCacheableEffect(() => {
getList(current, pageSize)
getDepartmentList()
}, [], { cacheable: true }) // 空依赖数组,确保只在首次挂载时执行
// ✅ 分页变化时单独处理(使用普通 useEffect)
useEffect(() => {
getList(current, pageSize)
}, [current, pageSize])
// ✅ 数据恢复机制:当检测到缓存但数据为空时,自动重新加载
const location = useLocation();
useEffect(() => {
const cacheKey = location.pathname + location.search;
const isUsingCache = window.__checkCache && window.__checkCache(cacheKey);
if (loading && isUsingCache && !hasTriedRestoreRef.current) {
const timer = setTimeout(() => {
if (data.length === 0 && total === 0) {
// 数据为空,重新加载
getList(current, pageSize);
} else {
// 数据存在,重置 loading
setLoading(false);
}
hasTriedRestoreRef.current = true;
}, 100);
return () => clearTimeout(timer);
}
}, [loading, data.length, total, location.pathname, location.search]);
// ...
}
白名单页面(不需要缓存):
// 白名单页面使用普通 useEffect,不使用 useCacheableEffect
const LoginLog = () => {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
// ✅ 白名单页面,使用普通 useEffect
useEffect(() => {
getList(current, pageSize)
}, [])
// ...
}
为什么需要 useCacheableEffect?
当页面被 KeepAlive 缓存后,组件不会重新挂载,但 useEffect 仍然会在某些情况下执行。使用 useCacheableEffect 可以确保数据只在首次加载时请求一次,避免重复请求。
数据恢复机制:
当从白名单页面切换回缓存页面时,可能会出现组件状态丢失的情况(数据为空)。系统会自动检测并重新加载数据,确保页面正常显示。
3. useTabsManager(标签页管理 Hook)
位置:src/hooks/useTabsManager.js
职责:
- 管理标签页的状态(添加、关闭、切换等)
- 发送清除缓存的指令
核心功能
1. 添加标签页
const addTab = useCallback((pathname, search) => {
const fullPathKey = pathname + (search || '')
setTabs(prevTabs => {
// 检查是否已存在
const existingTab = prevTabs.find(tab => tab.key === fullPathKey)
if (existingTab) {
return prevTabs // 已存在,不重复添加
}
// 创建新标签页
const newTab = getRouteInfo(pathname, search)
return [...prevTabs, newTab]
})
setActiveKey(fullPathKey)
}, [getRouteInfo])
2. 关闭标签页
const closeTab = useCallback((key) => {
setTabs(prevTabs => {
const targetTab = prevTabs.find(tab => tab.key === key)
if (!targetTab || targetTab.isPinned) return prevTabs
const newTabs = prevTabs.filter(tab => tab.key !== key)
// 如果关闭的是当前激活标签页,切换到其他标签页
if (activeKey === key) {
const nextTab = newTabs[0] || newTabs[newTabs.length - 1]
if (nextTab) {
setActiveKey(nextTab.key)
navigate(nextTab.key)
}
}
return newTabs
})
// 🌟 关闭标签页时不清除缓存,只清除初始化状态
// 这样重新打开时可以快速恢复,但会重新加载数据
globalMessageUtils.keepAlive('drop', { key, remove: false })
}, [activeKey, navigate])
注意:关闭标签页时,缓存不会被清除(remove: false),这样重新打开时可以快速恢复页面。只有刷新标签页时才会清除缓存。
3. 关闭所有标签页
const closeAllTabs = useCallback(() => {
// 发送全局清除消息
globalMessageUtils.keepAlive('clear')
// 重置状态
setTabs(DEFAULT_PINNED_TABS)
setActiveKey('/')
navigate('/')
}, [navigate, success])
4. 刷新标签页
const refreshTab = useCallback((key) => {
// 🌟 刷新标签页时清除缓存和初始化状态,强制重新加载
globalMessageUtils.keepAlive('drop', { key, remove: true })
}, [success])
5. 关闭其他标签页
const closeOtherTabs = useCallback((keepKey) => {
setTabs(prevTabs => {
// 找出即将被关闭的标签页的 key
const keysToDrop = prevTabs
.filter(tab => !tab.isPinned && tab.key !== keepKey)
.map(tab => tab.key);
// 清除其他标签页的缓存
keysToDrop.forEach(key => {
globalMessageUtils.keepAlive('drop', { key });
});
// 🌟 如果保留的标签页不是当前激活的,清除其初始化状态
// 这样会重新加载数据,确保页面状态正确
if (activeKey !== keepKey) {
if (window.__clearComponentInit) {
window.__clearComponentInit(keepKey);
}
}
// 返回保留的标签页列表
return prevTabs.filter(tab =>
tab.isPinned || tab.key === keepKey
);
});
// 激活目标 Key 并导航
setActiveKey(keepKey);
navigate(keepKey);
}, [success, navigate, activeKey]);
4. useGlobalMessage(全局消息系统)
位置:src/hooks/useGlobalMessage.js
职责:
- 实现发布-订阅模式
- 处理组件间通信
核心机制
发布消息
const publish = useCallback((eventType, payload = {}) => {
// 1. 通知订阅者
if (subscribersRef.current.has(eventType)) {
const subscribers = subscribersRef.current.get(eventType)
subscribers.forEach(callback => {
callback({ detail: payload })
})
}
// 2. 发送浏览器原生事件
const event = new CustomEvent(eventType, { detail: payload })
window.dispatchEvent(event)
}, [])
订阅消息
const subscribe = useCallback((eventType, callback, options = {}) => {
const { once = false } = options
if (!subscribersRef.current.has(eventType)) {
subscribersRef.current.set(eventType, new Set())
}
const subscribers = subscribersRef.current.get(eventType)
const wrappedCallback = (event) => {
try {
callback(event.detail)
if (once) unsubscribe(eventType, wrappedCallback)
} catch (error) {
console.error(`Error in subscriber for ${eventType}:`, error)
}
}
subscribers.add(wrappedCallback)
return () => unsubscribe(eventType, wrappedCallback)
}, [])
处理 keepAlive 事件
const handleKeepAlive = useCallback((detail) => {
const action = detail?.action || detail?.message || 'drop'
const options = detail?.options || {}
let eventType = EVENT_TYPES.KEEP_ALIVE + ':' + action // 'keep:alive:drop' 或 'keep:alive:clear'
publish(eventType, options)
}, [publish])
工具函数
export const globalMessageUtils = {
// 发送 keepAlive 消息
keepAlive(message = 'keepAlive', options = {}) {
window.dispatchEvent(new CustomEvent(EVENT_TYPES.KEEP_ALIVE, {
detail: { message, options }
}))
}
}
数据流转过程
场景 1:首次访问页面
1. 用户点击菜单 → 路由变化 → location.pathname = '/setting/user'
2. KeepAliveOutlet 检测到路由变化
3. 生成 cacheKey = '/setting/user'
4. 检查白名单 → 不在白名单中,需要缓存
5. 检查 cacheStore → 没有缓存
6. 等待 outlet(页面组件)加载完成
7. 保存到 cacheStore:cacheStore.set('/setting/user', outlet)
8. 保存到 locationStore:locationStore.set('/setting/user', location)
9. 添加到 accessOrder:accessOrder.push('/setting/user')
10. 执行 LRU 清理(如果超过 8 个)
11. 触发重新渲染,显示页面
12. 页面组件使用 useCacheableEffect 加载数据
13. 数据加载完成,标记为已初始化
场景 2:切换标签页
1. 用户点击其他标签页 → 路由变化 → location.pathname = '/setting/role'
2. KeepAliveOutlet 检测到路由变化
3. 生成 cacheKey = '/setting/role'
4. 检查 cacheStore → 已有缓存
5. 更新访问顺序:moveToRecent('/setting/role')
6. 触发重新渲染
7. 渲染逻辑:
- 显示 '/setting/role'(display: 'block')
- 隐藏 '/setting/user'(display: 'none')
8. 页面组件不会重新挂载,useCacheableEffect 不会执行
9. 直接显示缓存的数据,无需重新请求接口
场景 3:关闭标签页
1. 用户点击关闭按钮 → closeTab('/setting/user')
2. useTabsManager 更新 tabs 状态(移除该标签页)
3. 发送消息:globalMessageUtils.keepAlive('drop', { key: '/setting/user', remove: false })
4. useGlobalMessage 处理消息 → publish('keep:alive:drop', { key: '/setting/user', remove: false })
5. KeepAliveOutlet 订阅到消息 → onDrop({ key: '/setting/user', remove: false })
6. 检查 shouldRemove = false
7. 🌟 不清除缓存,保留页面状态(这样重新打开时可以快速恢复)
8. 触发重新渲染
注意:关闭标签页时,缓存会被保留,这样重新打开时可以快速恢复页面状态。
场景 4:关闭所有标签页
1. 用户点击"关闭所有" → closeAllTabs()
2. 发送全局清除消息:globalMessageUtils.keepAlive('clear')
3. useGlobalMessage 处理消息 → publish('keep:alive:clear', {})
4. KeepAliveOutlet 订阅到消息 → onClear()
5. 执行清除操作:
- cacheStore.clear()
- locationStore.clear()
- accessOrder.splice(0, accessOrder.length)
- window.clearAllInitialized()
6. 重置标签页状态:setTabs(DEFAULT_PINNED_TABS)
7. 导航到首页:navigate('/')
场景 5:刷新标签页
1. 用户右键点击标签页 → 选择"刷新" → refreshTab('/setting/user')
2. 发送删除缓存消息:globalMessageUtils.keepAlive('drop', { key: '/setting/user', remove: true })
3. KeepAliveOutlet 检查 shouldRemove = true
4. 执行 removeCache('/setting/user')
- cacheStore.delete('/setting/user')
- accessOrder 中删除 '/setting/user'
5. 清除组件初始化状态:window.__clearComponentInit('/setting/user')
6. 触发重新渲染
7. 由于缓存已删除,会重新渲染 outlet
8. 页面组件重新挂载,useCacheableEffect 重新执行
9. 重新加载数据
场景 6:关闭其他标签页
1. 用户右键点击标签页 → 选择"关闭其他" → closeOtherTabs('/setting/user')
2. 找出其他标签页的 key:['/setting/role', '/setting/menu']
3. 清除其他标签页的缓存:globalMessageUtils.keepAlive('drop', { key: '/setting/role' })
4. 如果保留的标签页不是当前激活的:
- 清除初始化状态:window.__clearComponentInit('/setting/user')
- 这样会重新加载数据,确保页面状态正确
5. 更新 tabs 状态,只保留目标标签页
6. 导航到目标标签页
7. 目标标签页重新加载数据(因为初始化状态被清除)
场景 7:数据恢复机制
1. 用户从白名单页面(如 /setting/cache)切换回缓存页面(如 /setting/user)
2. KeepAliveOutlet 恢复缓存的组件
3. 页面组件检测到使用缓存:window.__checkCache('/setting/user') === true
4. 检查数据状态:
- 如果数据为空且 total === 0,可能是状态丢失
- 延迟 100ms 后重新检查
5. 如果数据仍然为空,自动重新加载数据
6. 如果数据存在,重置 loading 状态
数据恢复机制的作用:
- 解决从白名单页面切换回缓存页面时,可能出现的数据丢失问题
- 自动检测并恢复数据,确保页面正常显示
LRU 缓存策略
算法原理
LRU(Least Recently Used):最近最少使用算法
核心思想:当缓存空间不足时,删除最久未使用的缓存。
实现细节
1. 访问顺序数组
const accessOrder = [] // 存储访问顺序,数组第一个是最久未使用的
2. 访问页面时
// 将页面移到数组末尾(标记为最近使用)
const moveToRecent = (key) => {
const index = accessOrder.indexOf(key)
if (index >= 0) {
accessOrder.splice(index, 1) // 从原位置删除
}
accessOrder.push(key) // 添加到末尾
}
3. 缓存满时清理
const evictLRU = (excludeKey) => {
while (cacheStore.size >= CACHE_LIMIT) { // 默认 8 个
// 找到第一个不是 excludeKey 的 key(最久未使用的)
const keyToRemove = accessOrder.find(k => k !== excludeKey)
if (keyToRemove) {
removeCache(keyToRemove) // 删除缓存
} else {
break
}
}
}
示例演示
假设 CACHE_LIMIT = 3(为了演示方便,实际是 8):
初始状态:
cacheStore: {}
accessOrder: []
访问 /page1:
cacheStore: { '/page1': <Component1> }
accessOrder: ['/page1']
访问 /page2:
cacheStore: { '/page1': <Component1>, '/page2': <Component2> }
accessOrder: ['/page1', '/page2']
访问 /page3:
cacheStore: { '/page1': <Component1>, '/page2': <Component2>, '/page3': <Component3> }
accessOrder: ['/page1', '/page2', '/page3']
访问 /page4(缓存已满):
1. 添加 /page4
2. 执行 evictLRU('/page4')
3. 删除 accessOrder[0] = '/page1'(最久未使用)
cacheStore: { '/page2': <Component2>, '/page3': <Component3>, '/page4': <Component4> }
accessOrder: ['/page2', '/page3', '/page4']
再次访问 /page2:
1. moveToRecent('/page2') → 移到末尾
cacheStore: { '/page2': <Component2>, '/page3': <Component3>, '/page4': <Component4> }
accessOrder: ['/page3', '/page4', '/page2']
访问 /page5(缓存已满):
1. 添加 /page5
2. 执行 evictLRU('/page5')
3. 删除 accessOrder[0] = '/page3'(最久未使用)
cacheStore: { '/page4': <Component4>, '/page2': <Component2>, '/page5': <Component5> }
accessOrder: ['/page4', '/page2', '/page5']
为什么使用 LRU?
- 符合用户习惯:用户经常访问的页面会保留在缓存中
- 自动管理:无需手动清理,系统自动管理缓存大小
- 性能优化:最常用的页面始终在缓存中,切换速度快
使用指南
1. 在页面组件中使用 useCacheableEffect
非白名单页面(需要缓存):
import { useCacheableEffect } from '@/hooks/useCacheableEffect'
import { useLocation } from 'react-router-dom'
import { useRef } from 'react'
const UserManagement = () => {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
const [total, setTotal] = useState(0)
const hasTriedRestoreRef = useRef(false)
const prevCacheKeyRef = useRef('')
const location = useLocation()
// ✅ 首次加载数据 - 使用 useCacheableEffect,必须添加 cacheable: true
useCacheableEffect(() => {
getList(current, pageSize)
getDepartmentList()
}, [], { cacheable: true }) // 空依赖数组,确保只在首次挂载时执行
// ✅ 数据恢复机制:当检测到缓存但数据为空时,自动重新加载
useEffect(() => {
const cacheKey = location.pathname + location.search
const isUsingCache = window.__checkCache && window.__checkCache(cacheKey)
if (prevCacheKeyRef.current !== cacheKey) {
hasTriedRestoreRef.current = false
prevCacheKeyRef.current = cacheKey
}
if (loading && isUsingCache && !hasTriedRestoreRef.current) {
const timer = setTimeout(() => {
if (data.length === 0 && total === 0) {
console.log('[User] 检测到缓存但数据为空,重新加载数据')
hasTriedRestoreRef.current = true
getList(current, pageSize)
} else {
setLoading(false)
hasTriedRestoreRef.current = true
}
}, 100)
return () => clearTimeout(timer)
}
if (loading && data.length > 0) {
setLoading(false)
}
}, [loading, data.length, total, location.pathname, location.search])
// ✅ 分页变化时单独处理(使用普通 useEffect)
useEffect(() => {
getList(current, pageSize)
}, [current, pageSize])
// ❌ 不要这样做(会导致每次切换标签页都重新加载)
useEffect(() => {
getList()
}, [])
}
白名单页面(不需要缓存):
// 白名单页面使用普通 useEffect,不使用 useCacheableEffect
const LoginLog = () => {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
// ✅ 白名单页面,使用普通 useEffect
useEffect(() => {
getList(current, pageSize)
}, [])
// ...
}
2. 添加页面到白名单
如果某个页面不需要缓存,添加到白名单:
// src/components/KeepAlive/index.jsx
const CACHE_WHITELIST = [
'/',
'/dashboard',
'/your-new-page' // 添加新页面
]
3. 手动清除缓存
import { globalMessageUtils } from '@/hooks/useGlobalMessage'
// 清除单个页面缓存
globalMessageUtils.keepAlive('drop', { key: '/setting/user' })
// 清除所有缓存
globalMessageUtils.keepAlive('clear')
4. 调整缓存数量限制
// src/components/KeepAlive/index.jsx
const CACHE_LIMIT = 8 // 修改为你需要的数量
常见问题
Q1: 为什么切换标签页后,页面数据没有更新?
A: 这是因为页面被缓存了,组件不会重新挂载。如果需要实时数据,应该:
- 将页面添加到白名单(不缓存)
- 使用刷新功能(右键菜单 → 刷新)
- 在数据变化时手动刷新(例如:保存成功后刷新列表)
Q2: 为什么有些页面切换时会重新加载?
A: 可能的原因:
- 页面在白名单中:这些页面每次访问都会重新加载
- 缓存已满:LRU 算法删除了该页面的缓存
- 页面刷新:浏览器刷新会清空所有缓存
Q3: 如何调试缓存问题?
A: 在开发环境下,KeepAlive 组件会输出调试日志:
// 查看控制台输出
[KeepAlive] 新增缓存: { cacheKey: '/setting/user', cacheSize: 1, ... }
[KeepAlive] 使用缓存: '/setting/user'
[KeepAlive] 清除所有缓存,清除前: { cacheSize: 3, cachedKeys: [...] }
Q4: 为什么 useCacheableEffect 不执行?
A: 检查以下几点:
-
是否设置了
cacheable: true(非白名单页面必须设置) -
组件是否已被标记为已初始化(检查
initializedComponentsSet) - 缓存 key 是否正确(路径 + 查询参数)
-
是否是白名单页面(白名单页面应该使用普通
useEffect,不使用useCacheableEffect)
调试方法:
// 在浏览器控制台查看初始化状态
console.log(window.__clearComponentInit) // 应该是一个函数
console.log(window.__clearAllInit) // 应该是一个函数
// 查看缓存状态
console.log(window.__checkCache('/setting/user')) // 检查是否有缓存
Q5: 如何强制刷新页面数据?
A: 有几种方式:
-
右键菜单 → 刷新:清除缓存并重新加载(
remove: true) - 关闭标签页后重新打开:会重新加载数据(因为初始化状态被清除)
-
在代码中手动清除缓存:
// 清除缓存和初始化状态(强制刷新) globalMessageUtils.keepAlive('drop', { key: location.pathname + location.search, remove: true }) // 只清除初始化状态(保留缓存,但会重新加载数据) if (window.__clearComponentInit) { window.__clearComponentInit(location.pathname + location.search) }
Q8: 为什么切换回缓存页面时数据是空的?
A: 这可能是组件状态丢失导致的。系统已经实现了数据恢复机制:
- 自动检测:当检测到使用缓存但数据为空时,会自动重新加载数据
- 延迟检查:延迟 100ms 检查,确保组件状态已恢复
- 避免重复:使用 ref 跟踪,避免重复加载
如果仍然出现问题,检查:
- 数据恢复机制的
useEffect是否正确实现 -
hasTriedRestoreRef和prevCacheKeyRef是否正确设置 - 数据判断条件是否正确(
data.length === 0 && total === 0)
Q6: 缓存会占用多少内存?
A: 缓存的是 React 组件实例,内存占用取决于:
- 组件复杂度:组件越复杂,占用内存越多
- 数据量:页面数据越多,占用内存越多
- 缓存数量:默认最多 8 个页面
如果内存紧张,可以:
- 减少
CACHE_LIMIT(默认 8) - 将不需要缓存的页面添加到白名单
Q7: 页面刷新后缓存会丢失吗?
A: 是的。缓存存储在内存中(Map 对象),页面刷新后会清空。这是正常行为,因为:
- 页面刷新意味着用户想要重新加载应用
- 缓存数据可能已过期,需要重新获取
- 避免内存泄漏
总结
核心要点
-
缓存存储:使用
Map存储页面组件,accessOrder数组记录访问顺序 - LRU 算法:自动删除最久未使用的缓存,保持缓存数量在限制内
-
白名单机制:某些页面不缓存,每次访问都重新加载(使用普通
useEffect) - 消息系统:通过发布-订阅模式实现组件间通信
-
useCacheableEffect:确保页面数据只加载一次(非白名单页面必须使用,并添加
cacheable: true) - 数据恢复机制:自动检测并恢复丢失的数据,确保页面正常显示
- 智能缓存管理:关闭标签页时保留缓存,刷新时才清除缓存
最佳实践
- ✅ 非白名单页面使用 useCacheableEffect 加载初始数据,并添加
cacheable: true - ✅ 白名单页面使用普通 useEffect,不使用
useCacheableEffect - ✅ 使用普通 useEffect 处理依赖变化(如分页、搜索等)
- ✅ 实现数据恢复机制,确保从白名单页面切换回缓存页面时数据正常
- ✅ 将实时数据页面添加到白名单(如日志、看板等)
- ✅ 合理设置缓存数量限制(默认 8 个)
- ✅ 关闭标签页时保留缓存,刷新时才清除缓存
- ❌ 不要在 useCacheableEffect 中处理依赖变化(应该使用普通
useEffect) - ❌ 不要在白名单页面使用 useCacheableEffect(应该使用普通
useEffect) - ❌ 不要在 useCacheableEffect 的依赖数组中包含函数引用(应该使用空数组
[])
相关文件
-
src/components/KeepAlive/index.jsx- 缓存核心组件 -
src/hooks/useCacheableEffect.js- 可缓存的 Effect Hook -
src/hooks/useTabsManager.js- 标签页管理 -
src/hooks/useGlobalMessage.js- 全局消息系统 -
src/components/TabsContainer/index.jsx- 标签页容器 -
src/layouts/BasicLayout.jsx- 基础布局
总结
不知道这次尝试是否值得,如果不是沉没成本太大,我可能已经中断尝试了,还好有了成果,也可能我测试不够全面,导致有遗留Bug,欢迎告知我,也欢迎后来者继续前进,技术实现的代码总在一步步往前走不是嘛
或许前端终将被AI替代,但路在脚下,你我共勉
好了,我也要去学习研究这个缓存了,看到这里,如果对你有帮助,欢迎git仓库给个⭐️标,谢谢了🙏
h5中弹框出现后禁止页面滚动
项目场景:
在写带有遮罩层的弹窗时,弹窗出现时,弹框后面的页面依然会保持滚动状态,其实这种情况并么什么影响,但是有很多时候想禁止滚动。无论在移动端还是PC端都会遇到这种情况。
在写带有遮罩层的弹窗时,弹窗出现时,页面会保持滚动状态,不符合我们的预期
看了些解决方案,大都是改变body的overflow,但是由于滚动条出现和消失,页面也会出现跳动
思路分析:
查看了很多方案,大多都是采用当弹框出现时,设置body的overflow为hidden,但是由于滚动条的出现和消失,会带动页面跟着跳动,这是不愿看到的结果。
深追下去,我们会发现,默认样式下,页面滚动条的父元素是html,而fixed的父元素是body。
第一种解决方法:在线运行
html {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
width: 100%;
height: 100%;
overflow: auto;
}
完整代码:
<template>
<div class="no-scroll">
<div class="bg-container">
<img
src="https://img-blog.csdnimg.cn/5a87670618fe4cc59d938f77d41cb816.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/2de8a9c47e054f7ba7bd959ea5041130.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/1f4a4d8d488c46f8acad53892fed08e6.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/7b6c25d2f32645f986a26648ef0b0001.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/fbc54f7c6e2a41889d3221e1d3223127.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/d6316c5661344816bbd664a1510f9978.jpeg"
alt=""
/>
</div>
<el-button class="open-btn" type="primary" round @click="open">
打开弹框
</el-button>
<div class="mask-container" v-show="showMask">
<div class="container">
<el-button type="primary" round @click="close"> 关闭弹框 </el-button>
</div>
</div>
</div>
</template>
<script>
export default {
name: "NoScroll",
data() {
return {
showMask: false,
};
},
methods: {
open() {
this.showMask = true;
},
close() {
this.showMask = false;
},
},
};
</script>
<style>
* {
margin: 0;
padding: 0;
}
html {
width: 100%;
height: 100%;
overflow: hidden;
}
body {
width: 100%;
height: 100%;
overflow: auto;
}
img {
width: 100%;
}
.open-btn {
position: fixed;
right: 100px;
bottom: 100px;
}
.mask-container {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 10;
background: rgba(0, 0, 0, 0.5);
}
.container {
display: flex;
align-items: center;
justify-content: center;
width: 300px;
height: 200px;
background: #fff;
border-radius: 10px;
}
</style>
如果整个架构都已经固定,担心改html、body会影响原始页面,还有另一种方案。只需要使后面滚动的容器添加一个高度就行,不让body出现滚动,通常设置为屏幕高度。
第二种解决方法:在线运行
.bg-container {
width: 100%;
height: 100vh;
overflow: auto;
}
完整代码:
<template>
<div class="no-scroll">
<div class="bg-container">
<img
src="https://img-blog.csdnimg.cn/5a87670618fe4cc59d938f77d41cb816.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/2de8a9c47e054f7ba7bd959ea5041130.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/1f4a4d8d488c46f8acad53892fed08e6.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/7b6c25d2f32645f986a26648ef0b0001.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/fbc54f7c6e2a41889d3221e1d3223127.jpeg"
alt=""
/>
<img
src="https://img-blog.csdnimg.cn/d6316c5661344816bbd664a1510f9978.jpeg"
alt=""
/>
</div>
<el-button class="open-btn" type="primary" round @click="open">
打开弹框
</el-button>
<div class="mask-container" v-show="showMask">
<div class="container">
<el-button type="primary" round @click="close"> 关闭弹框 </el-button>
</div>
</div>
</div>
</template>
<script>
export default {
name: "NoScroll",
data() {
return {
showMask: false,
};
},
methods: {
open() {
this.showMask = true;
},
close() {
this.showMask = false;
},
},
};
</script>
<style>
* {
margin: 0;
padding: 0;
}
.bg-container {
width: 100%;
height: 100vh;
overflow: auto;
}
img {
width: 100%;
}
.open-btn {
position: fixed;
right: 100px;
bottom: 100px;
}
.mask-container {
display: flex;
align-items: center;
justify-content: center;
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
z-index: 10;
background: rgba(0, 0, 0, 0.5);
}
.container {
display: flex;
align-items: center;
justify-content: center;
width: 300px;
height: 200px;
background: #fff;
border-radius: 10px;
}
</style>
js、node.js获取指定文件下的内容
最近做一个语言切换功能,所有的的语言翻译都在同一个文件中,导致文件过大,难以维护于是想到了对文件按模块进行拆分,绞尽脑汁,查了各种资料,果然功夫不负有心人,现在方法总结出来,希望能帮助更多有需要的人。
一、js获取指定文件下的内容
首先jieshao
require.context
可以给这个函数传入三个参数:一个要搜索的目录,一个标记表示是否还搜索其子目录, 以及一个匹配文件的正则表达式。
Webpack 会在构建中解析代码中的
require.context()。用法
require.context(directory, useSubdirectories = false, regExp = /^.//)
示例
读取template文件夹下面的所有js文件,并获取值;
项目环境:Vue2项目
目录结构
template
dom.js
tem.js
getmodules.js
// dom.js
module.exports = {
dom: "我是dom"
};
// tem.js
module.exports = {
tem: "我是tem"
};
const modulesFilesen = require.context("./template", true, /.js$/);
const modulesen = modulesFilesen.keys().reduce((modules, modulePath) => {
const moduleName = modulePath.replace(/^./(.*).js/, "$1");
const value = modulesFilesen(modulePath);
modules[moduleName] = value;
return modules;
}, {});
// 结果
modulesen = {
dom: {
dom: "我是dom",
},
tem: {
tem: "我是tem",
},
};
二、node.js获取指定文件下的内容
node.js需要借助模块,主要处理文件的读写、复制、s删除、重命名等操作。
建议使用同步读取文件模块fs.readFileSync(cur, "utf8") ,防止出现文件读取不到的情况。
reduce()的用法可以参考另一篇博文。
// 第一步:引入 fs 文件系统模块
let fs = require("fs");
const dirName = "./template"; // 读取目标文件夹名称
const reg = /(?<=[.])[a-z]+/; // 文件后缀匹配规则
// 第二步:读文件夹,获取文件名列表
// 同步 readdir().返回文件数组列表
let fileList = fs.readdirSync(dirName);
// 第三步:过滤出想要的文件类型
let result = fileList.reduce((pev, cur) => {
const curPicType = cur.match(reg)[0];
if (["js"].includes(curPicType)) {
// 同步读取
let res = fs.readFileSync(cur, "utf8");
pev[cur] = res;
}
return pev;
}, {});
// result = {
// dom: {
// dom: "我是dom",
// },
// tem: {
// tem: "我是tem",
// },
// };
node全栈系列(七)-增加验证码登录
这是一个非常实用的功能,能有效防止暴力破解密码。
我们将采用 svg-captcha 库来生成验证码。这是一种不需要安装复杂图形库(如 Python 的 pillow 或 C++ 的 gd)的轻量级方案,生成的 SVG 图片在前端渲染非常清晰且速度快。
实现逻辑如下:
- 生成:前端请求验证码 -> 后端生成一个随机字符 + 一个唯一ID (UUID) -> 后端把 {UUID: 验证码} 存到内存中 -> 把 UUID 和 SVG 图片返给前端。
- 验证:前端登录时,把 账号 + 密码 + 验证码 + UUID 一起发给后端 -> 后端根据 UUID 找内存里的验证码 -> 对比是否一致。
第一步:后端安装依赖
你需要安装两个库:
- svg-captcha: 生成验证码图片。
- uuid: 生成唯一标识符(用来标记这张验证码属于谁)。
npm install svg-captcha uuid
第二步:后端代码实现 (routes/auth.js)
我们需要修改 routes/auth.js,增加获取验证码接口,并修改登录接口。
注意:为了简单起见,我们将验证码存在全局变量 Map 中(内存)。如果是生产环境集群部署,通常存在 Redis 里,但作为学习项目,用内存 Map 足够了。
routes/auth.js
import express from 'express'
import { body, validationResult } from 'express-validator'
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import svgCaptcha from 'svg-captcha' // 引入验证码库
import { v4 as uuidv4 } from 'uuid' // 引入UUID
import { pool } from '../db/mysql.js'
import config from '../config.js'
import HttpError from '../utils/HttpError.js'
const router = express.Router()
// --- 全局变量:存储验证码 ---
// Key: uuid, Value: 验证码文字
const captchaStore = new Map()
// 1. 获取图形验证码接口
// GET /auth/captcha
router.get('/captcha', (req, res, next) => {
try {
// 生成验证码
const captcha = svgCaptcha.create({
size: 4, // 4个字符
ignoreChars: '0o1i', // 排除容易混淆的字符
noise: 2, // 干扰线条数量
color: true, // 文字有颜色
background: '#fff' // 背景色
})
// 生成一个唯一标识符
const uuid = uuidv4()
// 存入内存 (转成小写方便比对)
captchaStore.set(uuid, captcha.text.toLowerCase())
// 设置过期时间:5分钟后自动删除,防止内存泄露
setTimeout(() => {
captchaStore.delete(uuid)
}, 5 * 60 * 1000)
// 返回 SVG 图片代码和 UUID
res.json({
code: 200,
message: '获取成功',
data: {
uuid: uuid,
img: captcha.data // 这是 SVG 的 XML 字符串,前端可以直接渲染
}
})
} catch (err) {
next(err)
}
})
// 2. 登录接口 (增加验证码校验)
router.post(
'/login',
[
body('username').notEmpty().withMessage('账号不能为空'),
body('password').notEmpty().withMessage('密码不能为空'),
body('code').notEmpty().withMessage('验证码不能为空'), // 新增校验
body('uuid').notEmpty().withMessage('验证码已失效,请刷新重试') // 新增校验
],
async (req, res, next) => {
try {
const errors = validationResult(req)
if (!errors.isEmpty()) throw new HttpError(400, errors.array()[0].msg)
const { username, password, code, uuid } = req.body
// --- 核心修改:校验验证码 ---
const correctCode = captchaStore.get(uuid) // 从内存拿正确的码
if (!correctCode) {
throw new HttpError(400, '验证码已过期,请点击图片刷新')
}
if (correctCode !== code.toLowerCase()) {
throw new HttpError(400, '验证码错误')
}
// 校验通过后,立马删除该验证码(防止重复使用)
captchaStore.delete(uuid)
// ------------------------
// 下面是原有的登录逻辑
const [users] = await pool.query('SELECT * FROM sys_users WHERE username = ?', [username])
if (users.length === 0) throw new HttpError(400, '账号或密码错误')
const user = users[0]
if (user.status === 0) throw new HttpError(403, '账号已被停用')
const isMatch = await bcrypt.compare(password, user.password)
if (!isMatch) throw new HttpError(400, '账号或密码错误')
const payload = { userId: user.id }
const token = jwt.sign(payload, config.JWT_SECRET, { expiresIn: config.JWT_EXPIRES_IN })
res.json({
code: 200,
message: '登录成功',
data: { token }
})
} catch (err) { next(err) }
}
)
// ... info 接口保持不变 ...
export default router
第三步:前端 API 封装 (api/auth.ts)
import request from '@/utils/request'
// 登录 (现在需要多传 code 和 uuid)
export function login(data: any) {
return request({
url: '/auth/login',
method: 'post',
data
})
}
// 获取验证码
export function getCaptcha() {
return request({
url: '/auth/captcha',
method: 'get'
})
}
// 获取用户信息 (不变)
export function getUserInfo() {
return request({
url: '/auth/info',
method: 'get'
})
}
第四步:前端页面修改 (views/login/login.vue)
我们需要修改 Store 和 页面 UI。
1. 修改 Store (store/user.ts)
不需要修改 Store 的核心逻辑,因为 login action 只是透传参数。只要调用的时候传进去 {username, password, code, uuid} 即可。
2. 修改页面 UI (views/login/login.vue)
在密码框下面增加验证码输入框和图片。
<template>
<div class="login-container">
<div class="login-box">
<h2 class="title">后台管理系统</h2>
<el-form ref="loginFormRef" :model="loginForm" :rules="loginRules" size="large">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="请输入账号"
prefix-icon="User"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
placeholder="请输入密码"
prefix-icon="Lock"
type="password"
show-password
/>
</el-form-item>
<!-- 新增:验证码区域 -->
<el-form-item prop="code">
<div class="flex w-full gap-2">
<el-input
v-model="loginForm.code"
placeholder="验证码"
prefix-icon="Key"
class="flex-1"
@keyup.enter="handleLogin"
/>
<!-- 验证码图片容器 -->
<div
class="captcha-box cursor-pointer"
v-html="captchaSvg"
@click="refreshCaptcha"
title="点击刷新"
></div>
</div>
</el-form-item>
<el-button
type="primary"
class="w-full mt-4"
:loading="loading"
@click="handleLogin"
>
登 陆
</el-button>
</el-form>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUserStore } from '@/store/modules/user'
import { ElMessage } from 'element-plus'
import { getCaptcha } from '@/api/auth' // 引入API
const router = useRouter()
const route = useRoute()
const userStore = useUserStore()
const loginFormRef = ref()
const loading = ref(false)
const captchaSvg = ref('') // 存储 SVG 图片代码
const loginForm = reactive({
username: '',
password: '',
code: '', // 验证码输入值
uuid: '' // 验证码唯一ID
})
const loginRules = {
username: [{ required: true, message: '请输入账号', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
code: [{ required: true, message: '请输入验证码', trigger: 'blur' }]
}
// 获取/刷新验证码
const refreshCaptcha = async () => {
try {
const res: any = await getCaptcha()
// res.data 包含 { uuid, img }
loginForm.uuid = res.data.uuid
captchaSvg.value = res.data.img
loginForm.code = '' // 刷新后清空输入框
} catch (error) {
console.error(error)
}
}
const handleLogin = () => {
loginFormRef.value.validate(async (valid: boolean) => {
if (valid) {
loading.value = true
try {
await userStore.login(loginForm)
ElMessage.success('登录成功')
const redirect = route.query.redirect as string
router.push(redirect || '/')
} catch (error) {
// 登录失败(如验证码错误),自动刷新验证码
refreshCaptcha()
} finally {
loading.value = false
}
}
})
}
// 初始化时获取验证码
onMounted(() => {
refreshCaptcha()
})
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-box {
width: 400px;
padding: 40px;
background: white;
border-radius: 10px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
}
.title {
text-align: center;
margin-bottom: 30px;
font-size: 24px;
font-weight: bold;
color: #333;
}
/* 验证码图片样式 */
.captcha-box {
width: 120px;
height: 40px;
border: 1px solid #dcdfe6;
border-radius: 4px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f5f7fa;
}
/* 深度选择器:控制 SVG 尺寸适应容器 */
:deep(.captcha-box svg) {
width: 100%;
height: 100%;
}
</style>
总结变化点
-
后端:
- 引入 svg-captcha 库。
- 新增 /captcha 接口,生成 SVG 并在内存(captchaStore Map)中记录 uuid -> code 的映射。
- 修改 /login 接口,在验证账号前,先拿 uuid 去内存查,对比前端传来的 code。
-
前端:
- UI 上增加了输入框和 v-html 来显示 SVG。
- 进入页面或点击图片时,调用 /captcha 接口,保存 uuid,渲染 img。
- 登录时把 code 和 uuid 一起发给后端。
这样你就拥有了一个安全、轻量且体验流畅的图形验证码功能了!
Cdiscount API 调用合规指南:频率限制、数据用途边界与封号风险规避
Cdiscount API 调用合规指南:频率限制、数据用途边界与封号风险规避
Cdiscount 作为法国头部电商平台,对 API 调用的合规性管控严格,违规操作(如超限请求、数据滥用)会触发从临时封禁到永久封号的处罚。本文从「频率限制合规、数据用途边界、风险规避实操」三大维度,给出可落地的合规指南,适配开发者 / 企业的 API 使用场景。
一、核心合规前提
所有 API 调用需基于 Cdiscount 官方开发者平台([Cdiscount Developer Portal])的合法授权:
- 完成企业 / 个人账号认证,创建应用并获取
Client ID/Client Secret; - 仅使用平台开放的正式 API 端点(禁止抓包、逆向非公开接口);
- 所有请求携带有效 Access Token(OAuth 2.0 授权),禁止共享 Token。
二、频率限制合规:精准控制请求节奏(核心红线)
Cdiscount 对 API 调用频率的限制是最易触发的违规点,不同 API 的限流规则统一且严格,违规后果随次数递增:
1. 官方限流规则(2025 最新)
| API 类型 | 限流阈值 | 违规梯度处罚 |
|---|---|---|
| 商品 / 订单 / 库存 API | 100 次 / 分钟(≈0.6 秒 / 次) | 1 次超限:429 错误 + 临时封禁 10 分钟3 次超限:封禁 1 小时5 次以上:永久封号 |
| Token 获取 API | 10 次 / 小时 | 超限直接封禁 Token,需人工申诉重置 |
| 类目 / 评论 API | 50 次 / 分钟 | 处罚规则同商品 API |
2. 合规限流实现方案(Python)
方案 1:基础固定延迟(适合小流量)
python
运行
import time
import requests
def safe_api_request(url, payload, headers):
"""添加固定延迟,确保≤100次/分钟"""
time.sleep(0.6) # 100次/分钟 = 0.6秒/次,预留冗余
try:
response = requests.post(url, json=payload, headers=headers, timeout=15)
return response.json()
except Exception as e:
print(f"请求异常:{e}")
return None
方案 2:精准限流(推荐生产环境)
使用ratelimit库实现「请求数 / 时间窗口」精准控制,避免人工计算误差:
bash
运行
pip install ratelimit
python
运行
from ratelimit import limits, sleep_and_retry
# 100次/分钟(核心限流规则)
@sleep_and_retry # 触发限流时自动休眠重试
@limits(calls=100, period=60)
def limited_api_request(url, payload, headers):
try:
response = requests.post(url, json=payload, headers=headers, timeout=15)
# 捕获429错误,主动延长休眠
if response.status_code == 429:
time.sleep(60) # 休眠1分钟后重试
return limited_api_request(url, payload, headers)
return response.json()
except Exception as e:
print(f"限流请求异常:{e}")
return None
3. 批量采集限流优化(避免超限)
- 分批请求:单请求最多传入 20 个商品 ID(平台上限),减少总请求数;
- 峰值规避:避开欧洲高峰时段(北京时间 15:00-23:00,对应法国早 9 点 - 晚 5 点),该时段平台服务器负载高,限流判定更严格;
- 分布式限流:多账号 / 多应用调用时,按账号拆分请求量(如 2 个账号各 50 次 / 分钟),避免单账号超限;
- 重试策略:触发 429 错误后,采用「指数退避重试」(10s→20s→40s),禁止高频重试加剧违规。
三、数据用途边界:明确可做 / 不可做(合规核心)
Cdiscount API 数据的使用权限严格限定在「授权业务场景」,超出边界即触发违规,平台会通过数据审计追溯用途:
1. 合法用途(平台允许)
| 用途场景 | 具体说明 |
|---|---|
| 店铺运营管理 | 自有店铺的商品上下架、库存同步、订单处理、定价调整 |
| 合规市场分析 | 基于公开数据的行业趋势分析、竞品价格监控(仅用于自身业务优化,不对外传播) |
| 客户服务优化 | 基于订单 / 评论数据优化售后、物流服务(需脱敏用户信息) |
| 合规数据展示 | 自有平台 / APP 展示 Cdiscount 商品信息(需标注数据来源为 Cdiscount) |
2. 禁止用途(高风险,100% 封号)
| 禁止行为 | 风险后果 |
|---|---|
| 数据转售 / 商业化 | 永久封号 + 平台追责(民事赔偿) |
| 恶意竞争使用 | 如批量采集竞品价格恶意低价倾销 |
| 用户隐私数据滥用 | 如解析 / 传播买家信息、评论者昵称 |
| 非授权数据展示 | 如第三方平台聚合展示 Cdiscount 商品 |
| 伪造 API 请求数据 | 如篡改商品 ID、价格字段 |
| 爬虫 / 逆向非公开接口 | 即使未超限,也会直接永久封号 |
3. 数据使用合规实操
- 数据脱敏:存储 / 展示数据时,隐藏卖家 ID、买家昵称、SKU 参考码等敏感字段(仅保留商品 ID、公开价格等基础信息);
- 数据留存:仅保留业务必需的数据,且留存时间不超过 6 个月(平台要求),定期清理过期数据;
- 来源标注:对外展示 Cdiscount 数据时,必须标注「数据来源:Cdiscount Open API」,禁止宣称自有数据;
- 审计日志:记录所有 API 调用的用途、时间、数据量,平台审计时可提供合规证明。
四、封号风险规避:全链路防控策略
1. 事前防控:从源头降低风险
- 账号分级使用:测试环境用测试账号(低权限),生产环境用正式账号(高权限),避免测试操作污染正式账号;
- API 权限最小化:仅申请业务必需的 API 权限(如仅需商品查询则不申请订单修改权限),权限越多,违规风险越高;
- 监控预警配置:提前设置限流预警(如达到 90 次 / 分钟时触发告警),避免超限;
- 文档备案:留存 API 调用的业务用途说明、授权协议,便于平台核查时举证。
2. 事中监控:实时规避违规行为
| 监控维度 | 监控指标 | 预警 / 处理措施 |
|---|---|---|
| 频率监控 | 分钟级请求数、429 错误次数 | 达到 90 次 / 分钟时暂停请求;429 错误触发自动休眠 |
| 响应码监控 | 401(Token 失效)、403(权限不足) | 401 自动刷新 Token;403 立即停止调用并核查权限 |
| 数据量监控 | 单批次采集商品数、日采集总量 | 超过业务必需量时自动限流 |
| 异常请求监控 | 非 200 响应占比、空数据返回占比 | 占比>10% 时暂停调用,排查原因 |
3. 事后补救:封号后的应对措施
| 封号类型 | 补救措施 | 注意事项 |
|---|---|---|
| 临时封禁(10 分钟 - 1 小时) | 立即停止所有 API 调用,等待封禁结束后降低请求频率(如降至 80 次 / 分钟) | 禁止封禁期间继续请求,否则加重处罚 |
| Token 封禁 | 登录开发者平台申诉,说明违规原因 + 整改方案,申请重置 Token | 申诉需提供 API 调用日志、业务用途证明 |
| 永久封号 | 提交企业资质 + 合规承诺书,人工申诉(成功率<10%) | 优先用备用账号承接业务,避免业务中断 |
4. 核心规避实操技巧
- 避免高频重复请求:对同一商品 / 订单的查询,添加本地缓存(如 Redis 缓存 5 分钟),避免重复调用 API;
- 禁用并发请求:禁止多线程 / 多进程无节制并发调用,即使总频率未超限,短时间峰值也会触发风控;
- 规范请求参数:禁止传入无效参数(如不存在的商品 ID、负数库存),平台会判定为恶意请求;
- 备用账号预案:提前申请 2-3 个备用授权账号,主账号封停时可无缝切换,避免业务中断;
- 关注平台公告:定期查看 Cdiscount 开发者平台的规则更新,限流阈值、数据政策调整需及时适配。
五、合规自查清单(落地必看)
| 自查项 | 合规标准 | 检查方式 |
|---|---|---|
| 频率控制 | 所有 API 请求≤100 次 / 分钟 | 查看日志中分钟级请求数,验证限流代码生效 |
| Token 使用 | 仅自身使用,不共享、不泄露 | 检查代码中 Token 存储方式(禁止硬编码) |
| 数据用途 | 仅用于授权业务,无转售 / 对外传播 | 核查数据输出渠道(如是否对外展示) |
| 敏感数据处理 | 买家 / 卖家隐私字段已脱敏 | 抽查存储的数据集,确认无完整隐私信息 |
| 异常处理 | 429/401 错误有自动处理逻辑 | 模拟超限 / Token 失效,验证程序行为 |
| 日志留存 | 保留近 3 个月的 API 调用日志 | 检查日志文件 / 数据库,确认留存完整 |
六、总结
Cdiscount API 调用合规的核心是「控频率、守边界、防风险」:
- 频率合规:用精准限流工具控制请求节奏,避免 429 错误和封禁;
- 用途合规:仅在授权范围内使用数据,禁止商业化、滥用隐私数据;
- 风险规避:事前防控、事中监控、事后补救,全链路降低封号概率。
合规的本质是「匹配业务需求的最小化调用」—— 不超额请求、不滥用数据、不触碰平台规则红线,既能避免封号风险,也能保障业务长期稳定运行。对于企业用户,建议建立专门的 API 合规小组,定期审计调用行为,确保全流程符合平台规则。
从零打造专业级前端 SDK (一):架构与工程化
从零打造专业级前端 SDK (一):架构与工程化
前言:在前端开发中,我们经常需要接入各种第三方 SDK(如统计埋点、即时通讯、地图服务)。但你是否想过,如果让你从零开发一个 SDK,该如何设计?本文将带你一步步打造一个生产可用的前端埋点 SDK。
1. 为什么我们需要自研 SDK?
市面上已有 Google Analytics、Mixpanel 等成熟方案,为什么还要造轮子?
- 数据安全:敏感数据必须私有化部署。
- 定制需求:需要采集特定的业务数据(如停车场车位状态、设备温度)。
- 极致轻量:第三方 SDK 往往功能臃肿,我们只需要核心功能。
2. 工程化基石:TypeScript + Vite
工欲善其事,必先利其器。对于 SDK 开发,我的技术选型是:
- 语言: TypeScript (类型安全是 SDK 的生命线)。
- 构建: Vite (Library Mode) (基于 Rollup,打包体积小,配置简单)。
2.1 初始化项目
npm create vite@latest parking-tracker-sdk -- --template vanilla-ts
npm install uuid @types/uuid
npm install -D vite-plugin-dts # 用于生成 .d.ts 类型文件
2.2 Vite 配置 (Library Mode)
我们需要 SDK 能同时支持 import (ESM) 和 <script> (UMD) 引入。
vite.config.ts:
import { defineConfig } from 'vite';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';
export default defineConfig({
plugins: [dts({ include: ['src'] })], // 自动生成类型声明
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
name: 'ParkingTracker', // UMD 全局变量名
fileName: (format) => `index.${format}.js`
}
}
});
3. 核心设计模式
SDK 的代码质量直接决定了接入者的体验。这里我们使用了两个经典的设计模式。
3.1 单例模式 (Singleton) —— 唯一的指挥官
场景:无论用户调用多少次 init(),或者在多少个组件里引入 SDK,我们都希望全局只有一个 Tracker 实例,保证配置和状态的唯一性。
实现 (src/core/Tracker.ts):
export class Tracker {
private static instance: Tracker; // 静态属性存储实例
// 1. 私有构造函数:禁止外部直接 new Tracker()
private constructor() {
this.config = { ... };
}
// 2. 静态方法获取实例
public static getInstance(): Tracker {
if (!Tracker.instance) {
Tracker.instance = new Tracker();
}
return Tracker.instance;
}
}
3.2 外观模式 (Facade) —— 极简的门面
场景:Tracker.getInstance().track(...) 这种写法太啰嗦了。用户只想要 Tracker.track(...)。
实现 (src/index.ts):
import { Tracker } from './core/Tracker';
// 导出一个对象,代理核心方法
export default {
init: (config: TrackerConfig) => Tracker.getInstance().init(config),
track: (eventName: string, props?: any) => Tracker.getInstance().track(eventName, props)
};
这样用户就可以愉快地使用了:
import Tracker from 'parking-tracker-sdk';
Tracker.init({ appId: '123' });
Tracker.track('login');
4. 阶段总结
到目前为止,我们已经完成了一个 SDK 的骨架:
- 类型安全:完整的 TS 支持。
- 构建产物:支持 ESM/CJS/UMD。
- 架构设计:单例保证状态唯一,外观提供极致体验。
但现在的 SDK 还是个"哑巴",只能在控制台打印日志。 下一篇,我们将为它注入灵魂——实现网络发送能力,并探讨如何使用策略模式来应对复杂的浏览器环境。
本文代码已开源,欢迎 Star ⭐️
JavaScript 执行机制深度解析:从 V8 引擎到作用域链、变量提升与闭包的全面剖析
前言:为什么你需要彻底理解 JS 的执行机制?
你是否曾遇到过以下“诡异”现象?
- 函数在声明前就能调用?
-
console.log(x)输出undefined而不是报错? - 循环中的
setTimeout全部输出同一个值? - 在
if块中用var声明变量,却影响了整个函数?
这些看似“反直觉”的行为,其实都源于 JavaScript 引擎底层的 执行机制。而要真正掌握这门语言,不能只停留在“会用 API”,必须深入理解:
- JS 是如何运行的?
- 什么是执行上下文(Execution Context)?
- 变量提升(Hoisting)是怎么回事?
- 作用域(Scope)到底是什么?
- 为什么
let/const比var更安全? - V8 引擎内部是如何处理这些概念的?
本文将结合基础代码,逐行注解、层层递进,带你从零构建对 JavaScript 执行机制的完整认知体系。全文基于现代 JS(ES6+)标准,并兼容历史背景,力求既讲清原理,又解决实际问题。
第一章:JavaScript 的运行模型——编译 + 执行
很多人误以为 JavaScript 是“解释型语言”,一行一行直接执行。但事实是:现代 JavaScript 引擎(如 V8)采用“先编译,再执行”的两阶段模型。
1.1 两阶段执行流程
当一段 JS 代码被加载时,V8 引擎会经历两个关键阶段:
阶段一:编译阶段(Compilation / Parsing)
- 扫描整段代码
- 识别所有 变量声明(
var,let,const)和 函数声明 - 构建 执行上下文(Execution Context)
- 对
var和function进行 变量提升(Hoisting)
注意:只有声明被提升,赋值不会!
阶段二:执行阶段(Execution)
- 按代码顺序执行赋值、函数调用、表达式求值等操作
- 动态创建新的执行上下文(如调用函数时)
- 管理 调用栈(Call Stack)
这种设计使得 JS 能在运行前“预知”有哪些变量和函数可用,但也带来了“变量提升”这一争议特性。
第二章:执行上下文(Execution Context)——JS 运行的“容器”
每当 JS 执行一段可执行代码(全局代码或函数),引擎会为其创建一个 执行上下文。它是 JS 代码运行的“沙盒环境”。
2.1 执行上下文的组成
每个执行上下文包含三个核心部分:
| 组成 | 说明 |
|---|---|
| 变量环境(Variable Environment) | 存放 var 声明的变量(函数级作用域) |
| 词法环境(Lexical Environment) | 存放 let/const 声明的变量(支持块级作用域) |
| this 绑定 | 当前上下文中的 this 指向 |
关键区分:
var→ 变量环境let/const→ 词法环境
这就是为什么 var 和 let 行为不同——它们被存放在执行上下文的不同“房间”里!
2.2 执行上下文的生命周期
-
创建阶段(编译阶段):
- 确定作用域
- 提升变量和函数
- 初始化变量环境和词法环境
-
执行阶段:
- 逐行执行代码
- 赋值、调用、修改变量
-
销毁阶段:
- 函数执行完毕后,上下文出栈
- 变量被垃圾回收(除非被闭包引用)
第三章:变量提升(Hoisting)——JS 的“历史包袱”
3.1 什么是变量提升?
变量提升(Hoisting) 是指:在编译阶段,JS 引擎将 var 变量声明和 function 声明“移动”到当前作用域顶部的行为。
注意:只有声明被提升,赋值留在原地!
3.2 为什么会有变量提升?
因为JavaScript 当时是一个 KPI 项目,没想到会火起来,设计周期短……为了简单设计,没有块级作用域,再把变量统一提升到函数顶部,是最快最简单的设计。
早期 JS 为了快速实现浏览器脚本功能,牺牲了严谨性。变量提升虽简化了实现,却带来了大量陷阱。
3.3 实战分析:文件 1.js
showName(); // (1)
console.log(myname); // (2)
var myname = '张三'; // (3)
function showName() {
console.log(myname); // (4)
}
编译阶段(提升后等效代码):
// 函数声明完整提升(包括函数体)
function showName() {
console.log(myname);
}
// var 声明提升,但赋值不提升
var myname; // → undefined
// 执行阶段代码
showName(); // (1) 调用函数
console.log(myname); // (2) 输出 undefined
myname = '张三'; // (3) 赋值
执行结果:
- (1)
showName()→ 打印undefined(因为此时myname已声明但未赋值) - (2)
console.log(myname)→undefined - (4) 函数内
console.log(myname)→ 同样undefined
💡 结论:函数可以提前调用,但变量值在赋值前是
undefined。
第四章:作用域(Scope)——变量的“可见范围”
作用域决定了变量在哪些地方可以被访问,以及何时被销毁。
4.1 三种作用域类型
| 类型 | 描述 | 生命周期 |
|---|---|---|
| 全局作用域 | 在任何地方都能访问 | 页面存活期间 |
| 函数作用域 | 仅在函数内部可见(ES5 主要作用域) | 函数调用期间 |
| 块级作用域 | 仅在 {} 块内可见(ES6 新增) |
块执行期间 |
ES5 不支持块级作用域,这是
var问题的根源!
4.2 文件 2.js:全局 vs 局部变量
var globalVar = “全局变量”; // (1) 全局作用域
function myFunction() {
var localVar = “局部变量”; // (2) 函数作用域
console.log(globalVar); // (3) 访问全局变量
console.log(localVar); // (4) 访问局部变量
}
myFunction(); // (5) 调用函数
console.log(globalVar); // (6) 全局可访问
console.log(localVar); // (7) 报错!
分析:
- (1)
globalVar在全局作用域,任何地方可访问。 - (2)
localVar在myFunction内部,属于函数作用域。 - (7) 尝试在函数外访问
localVar→ReferenceError: localVar is not defined
作用域隔离:函数内部变量对外部不可见,这是封装的基础。
第五章:var 的致命缺陷——无视块级作用域
5.1 问题根源
在 ES5 中,var 只认函数边界,不认 {} 块。即使你在 if、for、while 中用 var,变量仍属于整个函数!
5.2 文件 3.js:var 在 if 块中的灾难
var name = '张三'; // (1) 全局变量
function showName() {
console.log(name); // (2) 输出什么?
if (true) {
var name = '李四'; // (3) var 声明!
}
console.log(name); // (4) 输出什么?
}
showName();
编译阶段(提升后):
var name = '张三';
function showName() {
var name; // 提升!遮蔽全局 name
console.log(name); // → undefined
if (true) {
name = '李四'; // 赋值
}
console.log(name); // → '李四'
}
执行结果:
- (2)
undefined(不是'张三'!) - (4)
'李四'
为什么不是
'张三'?
因为函数内部的var name遮蔽(shadowing) 了全局变量,并且提升后初始值为undefined。
这就是 var 的典型陷阱:你以为在操作全局变量,其实创建了一个同名的局部变量!
第六章:ES6 救星——let/const 与块级作用域
为了解决 var 的问题,ES6 引入了:
-
let:块级作用域变量 -
const:块级作用域常量 - 暂时性死区(Temporal Dead Zone, TDZ)
6.1 暂时性死区(TDZ)
在块内,let/const 声明的变量在声明前处于 TDZ,访问会抛出 ReferenceError。
console.log(x); // ReferenceError
let x = 1;
这比
var的undefined更安全——早报错,早修复。
6.2 文件 4.js:let 的块级作用域
let name = '张三'; // (1) 全局 let 变量
function showName() {
console.log(name); // (2) → '张三'
if (false) {
let name = '李四'; // (3) 块级变量(但 if 为 false,不执行)
}
console.log(name); // (4) → '张三'
}
showName();
分析:
- (1) 全局
name是let,块级作用域。 - (3)
if (false)块未执行,let name = '李四'从未创建。 - (2)(4) 都访问全局
name→'张三'
无污染、无遮蔽:
let完美隔离作用域。
6.3 对比 var vs let:循环中的经典问题
文件 6.js(var 版):
function foo(){
for (var i = 0; i < 100; i++) { }
console.log(i); // 输出 100!因为 var i 被提升到函数顶部
}
foo();
如果换成 let:
for (let i = 0; i < 100; i++) { }
console.log(i); // ReferenceError: i is not defined
let i仅存在于for循环的块级作用域中,循环结束后自动销毁。
闭包陷阱示例:
// var 版本(错误)
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 输出 3, 3, 3
}
// let 版本(正确)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 输出 0, 1, 2
}
原因:let 在每次循环迭代中创建一个新的绑定(binding),而 var 只有一个共享变量。
第七章:执行上下文视角——var 与 let 如何共存?
现代 JS 必须同时支持 var(向下兼容)和 let/const(新标准)。V8 引擎如何做到?
答案: “一国两制” —— 在同一个执行上下文中,使用两个独立的存储空间。
7.1 文件 7.js:融合示例
// 执行上下文的角度,var/let 融合
function foo(){
var a = 1; // → 存入“变量环境”
let b = 2; // → 存入“词法环境”
{
let b = 3; // → 新建块级词法环境,b=3 遮蔽外层 b=2
}
}
执行过程:
- 进入
foo,创建执行上下文 - 扫描
var a→ 放入 变量环境(初始undefined) - 扫描
let b→ 放入 词法环境(处于 TDZ) - 执行
var a = 1→ 变量环境中的a赋值为1 - 执行
let b = 2→ 词法环境中的b赋值为2 - 进入
{}块 → 创建 新的词法环境 - 在新环境中
let b = 3→ 不影响外层b
关键设计:
- 变量查找时,先查词法环境,再查变量环境
let/const优先级更高,避免与var冲突
第八章:其他控制结构的作用域行为
8.1 文件 5.js:空块与循环
if (1){} // 空 if 块(合法,但无意义)
while (1){} // 无限循环(语法合法,但会卡死)
function foo(){} // 函数声明(会被提升)
for (let i = 0; i < 100; i++) {
console.log(i); // let i 仅在此 for 块中有效
}
重点:
-
if、while、for的{}都是 块级作用域 - 但只有使用
let/const才能体现其作用 -
function foo(){}是函数声明,会被提升
即使块为空,JS 也为其创建作用域(虽然没变量,但结构存在)。
第九章:作用域链(Scope Chain)——变量查找的完整路径
9.1 什么是作用域链?
想象你在一个多层办公楼里找一个人:
- 你先在自己办公室找(当前作用域)
- 找不到,就去你部门的公共区域找(父级作用域)
- 还找不到,就去整栋楼的大厅找(全局作用域)
- 如果大厅也没有 → “查无此人!”
作用域链(Scope Chain)就是 JavaScript 引擎查找变量时,沿着“嵌套作用域”一层层向上搜索的路径。
关键规则:
- 查找顺序:从内向外(当前 → 父级 → … → 全局)
- 路径在函数声明时就固定了(不是调用时!)→ 这叫 词法作用域(Lexical Scope)
9.2 作用域链是如何构建的?
每当一个函数被声明(不是调用!),JS 引擎就会记录:
“这个函数是在哪个作用域里写的?它的‘爸爸’是谁?”
这个“家族关系链”就是作用域链。
举个生活化例子:
// 全局作用域(大楼大厅)
var myName = '李四';
function foo() {
// 函数作用域(foo 办公室)
var myName = '张三';
function bar() {
// 函数作用域(bar 小隔间)
console.log(myName);
}
bar(); // 调用 bar
}
foo();
问题:
console.log(myName)输出什么?
很多人以为:bar 是在 foo 里面调用的,所以应该输出 '张三' —— 这是对的,但原因更重要!
真正原因:
-
bar函数声明的位置在foo内部 -
所以
bar的作用域链是:bar 自身 → foo → 全局 -
查找
myName时:-
bar自己没有myName - 去
foo里找 → 找到var myName = '张三' - 停止查找!返回
'张三'
-
注意:即使
bar被拿到全局调用,结果也一样!因为作用域链在声明时就锁定了。
9.3 文件 1.js 再分析:为什么输出 李四?
1.js 是一个经典反直觉案例:
function bar(){
console.log(myName);
}
function foo(){
var myName = '张三';
bar(); //运行时查找,先在当前作用域查找,没有找到,就去父作用域查找
}
var myName = '李四';
foo(); // 李四
为什么不是 '张三'?
关键点:bar 函数是在全局作用域中声明的!
所以 bar 的作用域链是:bar → 全局,根本不包含 foo!
执行过程:
-
foo()被调用 -
foo内部创建局部变量myName = '张三' -
调用
bar() -
bar开始查找myName:- 自己没有
- 看“爸爸”是谁 → 全局作用域
- 在全局找到
myName = '李四'
-
输出
'李四'
💡 记住:作用域链看函数在哪写的,不是在哪调用的!
“作用域链查找的规则 一定要知道作用域链 变量的查找路径按函数申明的时候,即在编译阶段就确定了,不会改变词法作用域”
9.4 作用域链查找优先级:词法环境 vs 变量环境
当查找一个变量时,JS 引擎会按以下顺序:
-
当前词法环境(
let/const) -
当前变量环境(
var) - 外层函数的词法/变量环境
- 全局环境
- 找不到 →
ReferenceError
✅ 因此,
let name会遮蔽var name,因为词法环境优先。
第十章:最佳实践与总结
10.1 为什么变量提升是“设计缺陷”?
- 导致代码行为与直觉不符
- 容易造成变量覆盖
- 本应销毁的变量未被销毁(内存泄漏风险)
- 无法实现真正的块级逻辑隔离
但早期 JS 为了快速实现,选择了最简单的方案。
10.2 ES6 如何解决?
| 问题 | ES5 (var) |
ES6 (let/const) |
|---|---|---|
| 作用域 | 函数级 | 块级 ✅ |
| 提升行为 | 声明提升,值为 undefined
|
暂时性死区(TDZ)✅ |
| 重复声明 | 允许(静默覆盖) | 报错 ✅ |
| 循环变量 | 共享同一变量 | 每次迭代新绑定 ✅ |
10.3 开发建议
-
永远不要使用
var(除非维护老项目) -
优先使用
const,只有需要重赋值时才用let -
理解 TDZ:不要在声明前访问
let/const变量 -
利用块级作用域:用
{}显式隔离逻辑 - 避免全局变量污染:使用模块化(ESM)或 IIFE
第十一章:附录——所有文件逐行注解汇总
📄 1.js
showName(); // 函数声明被提升,可提前调用
console.log(myname); // var 提升 → undefined
var myname = '张三'; // 声明提升,赋值在执行阶段
function showName() {
console.log(myname); // 函数内访问提升后的 myname → undefined
}
📄 2.js
var globalVar = “全局变量”; // 全局作用域
function myFunction() {
var localVar = “局部变量”; // 函数作用域
console.log(globalVar); // 访问全局
console.log(localVar); // 访问局部
}
myFunction(); // 调用
console.log(globalVar); // 全局可访问
console.log(localVar); // 局部变量不可见
📄 3.js
var name = '张三';
function showName() {
console.log(name); // undefined(函数内 var name 提升遮蔽全局)
if (true) {
var name = '李四'; // var 无视块级,提升到函数顶部
}
console.log(name); // '李四'
}
📄 4.js
let name = '张三';
function showName() {
console.log(name); // '张三'(无遮蔽)
if (false) {
let name = '李四'; // 块级作用域,但未执行
}
console.log(name); // '张三'
}
📄 5.js
if (1){} // 空块
while (1){} // 无限循环(慎用)
function foo(){} // 函数声明(提升)
for (let i = 0; i < 100; i++) {
console.log(i); // let i 块级作用域
}
📄 6.js
function foo(){
for (var i = 0; i < 100; i++) { }
console.log(i); // 100(var 提升到函数顶部)
}
📄 7.js
function foo(){
var a = 1; // → 变量环境
let b = 2; // → 词法环境
{
let b = 3; // → 新词法环境,遮蔽外层 b
}
}
第十二章:闭包(Closure)——让函数“记住”它出生的地方
12.1 什么是闭包?
闭包(Closure) 是指:
一个函数能够访问并操作其词法作用域外部的变量,即使这个函数在外部被调用,这些变量依然存在。
简单说:函数带着它的“出生证明”(作用域)一起旅行。
12.2 闭包形成的两个必要条件
闭包的形成条件
- 函数嵌套
- 内部函数引用了外部函数的变量
二者缺一不可!
12.3闭包实战
以下是闭包的经典范例:
// 特殊的地方
function foo() {
var myName = '张三';
let test1 = 1;
const test2 = 2;
var innerBar = {
getName: function() {
console.log(test1);
return myName;
},
setName: function(newName) {
myName = newName;
}
}
// return 可以被外部访问
return innerBar; // 闭包形成的条件——函数嵌套函数
}
var bar = foo(); // 出栈
bar.setName('李四'); // setName 执行上下文创建
bar.getName();
console.log(bar.getName()); // 李四
逐行拆解:
-
foo()被调用:- 创建执行上下文
- 初始化
myName = '张三',test1 = 1,test2 = 2 - 定义对象
innerBar,其方法getName和setName引用了myName和test1
-
return innerBar:-
foo执行完毕,执行上下文本应销毁 - 但
innerBar的两个方法仍然引用着myName和test1 - JS 引擎发现:“这些变量还有人用!” → 不回收!
-
-
var bar = foo():-
bar拿到了innerBar对象 - 此时
foo已出栈,但myName和test1仍在内存中
-
-
bar.setName('李四'):- 调用
setName,它修改了foo内部的myName -
myName从'张三'变成'李四'
- 调用
-
bar.getName():- 访问
test1(输出1) - 返回
myName(现在是'李四')
- 访问
💡 这就是闭包!
即使foo已经执行完、出栈,它的局部变量myName和test1依然活着,因为被返回的函数“背”着它们。
12.4 闭包的内存模型:专属“背包”比喻
readme.md 有一个绝妙比喻:
“有点像给 getName,setName 方法背的一个专属背包。这个闭包里面的变量叫自由变量”
- 每个闭包函数都有一个隐藏的背包([[Scope]] 内部属性)
- 背包里装着它声明时所在作用域的所有自由变量(被引用但不在自己作用域内的变量)
- 无论函数在哪里被调用,打开背包就能拿到这些变量
12.5 闭包的作用与风险
作用:
-
封装私有变量(如
myName外部无法直接访问,只能通过getName/setName) - 延长变量生命周期
- 实现模块模式、工厂函数等高级模式
风险:
- 内存泄漏:如果闭包长期持有大对象,且未释放,会导致内存占用过高
- 循环引用:闭包引用 DOM,DOM 又引用闭包 → 垃圾回收失败
最佳实践:
- 不再需要时,将引用设为
null(bar = null) - 避免不必要的闭包
12.6 闭包 + 作用域链 = JS 的灵魂
闭包之所以能工作,完全依赖于作用域链的静态性:
- 函数声明时,作用域链就固定了
- 即使函数被传递到任何地方,它的作用域链不变
- 因此总能找到“老家”的变量
🌟 一句话总结闭包:
“函数 + 它声明时的作用域 = 闭包”
结语:掌握机制,方能驾驭语言
JavaScript 的执行机制看似复杂,但一旦理解 执行上下文、作用域链、变量提升、块级作用域、闭包 这五大支柱,你就能:
- 写出更安全、可预测的代码
- 轻松调试“诡异”问题
- 深入理解
this、异步、模块等高级概念 - 在面试中展现扎实功底
记住:JS 不是魔法,只是你还没看清它的执行上下文。
React CVE-2025-55182漏洞排查与修复指南
漏洞概览
- 漏洞类型:不安全反序列化(React Server Components 的 Flight 协议处理),可导致 未认证的远程代码执行(RCE) 。CVSS v3 基准分数为 10.0(Critical) 。
- 受影响组件/版本:
react-server-dom-webpack、react-server-dom-parcel、react-server-dom-turbopack在 React 19 的某些发行版中受影响(列举:19.0.0、19.1.0、19.1.1、19.2.0 等)。下游 Next.js 也受影响并列为重复 CVE(CVE-2025-66478)。 - 漏洞性质:默认配置即可被利用(例如用
create-next-app生成并生产构建的 Next.js 应用在默认情况下可被攻击)。实测可通过精心构造的 HTTP 请求成功触发。
针对CVE-2025-55182这个最高危(CVSS 10.0)的远程代码执行漏洞,排查和修复工作必须立即进行。该漏洞源于React服务器端组件(RSC)在处理“Flight”协议数据时存在不安全的反序列化问题,攻击者无需认证即可通过发送特制的HTTP请求在服务器上执行任意命令。
以下为详细排查与修复步骤,请按顺序操作。
第一步:检查并更新受影响的核心包
首先,你需要确定并更新项目中所有直接依赖的React核心包。受影响和已修复的版本对应关系如下:
| 受影响的包 | 受影响的版本 | 已修复的安全版本 |
|---|---|---|
react-server-dom-webpack |
19.0.0, 19.1.0, 19.1.1, 19.2.0 | 19.0.1, 19.1.2, 19.2.1 |
react-server-dom-parcel |
19.0.0, 19.1.0, 19.1.1, 19.2.0 | 19.0.1, 19.1.2, 19.2.1 |
react-server-dom-turbopack |
19.0.0, 19.1.0, 19.1.1, 19.2.0 | 19.0.1, 19.1.2, 19.2.1 |
操作方法:
1. 检查你的 package.json 文件和 node_modules 目录,确认是否安装了上表中列出的受影响包及其版本。
1) 在源码/项目根目录(package.json/lockfile)中查找受影响包
bash
# 查找 package.json / package-lock / pnpm-lock 中的 react-server-dom 或 react 19
grep -R --line-number '"react-server-dom' package.json package-lock.json yarn.lock pnpm-lock.yaml 2>/dev/null || true
grep -R --line-number '"react"' package.json yarn.lock package-lock.json pnpm-lock.yaml 2>/dev/null | head -n 50
或使用 npm 工具检查安装依赖(针对本地 node_modules):
# 如果在项目目录
npm ls react-server-dom-webpack react-server-dom-parcel react-server-dom-turbopack react --depth=0 || true
# yarn
yarn why react-server-dom-webpack || true
2) 在 Docker 镜像 / 容器 中查找
# 列出镜像中文件并查找 package.json(示例)
docker run --rm -it <image> sh -c "grep -R "react-server-dom" /app /usr/src 2>/dev/null || true"
# 或直接列出已安装的 node_modules 版本
docker run --rm -it <image> sh -c "node -e "console.log(require('/app/node_modules/react/package.json').version)""
3) 在运行时检测(HTTP 接口/服务)
- 搜索应用是否暴露与 RSC / Flight 协议相关的端点(常见于服务端函数或 app router 的 server-function 路径)。在 Next.js 情况,检查 App Router、server-actions、server functions 的路由是否启用。
- 检查 web server 日志中是否存在异常 POST 请求到 RSC 相关路径、或包含可疑二进制/序列化载荷
2. 升级命令(项目根目录)
注意:在升级前请在开发/测试环境完整回归测试(尤其 server-side codepaths)。下面示例仅做升级版本参考,请根据你的 package manager 调整。
npm:
# React 修复版(示例)
npm install react@19.2.1 react-dom@19.2.1 \
react-server-dom-webpack@19.2.1 react-server-dom-parcel@19.2.1 react-server-dom-turbopack@19.2.1
# Next.js(若使用):
npm install next@<patched-version> # 以 Next.js 官方公告的 patched 版本为准
yarn:
yarn add react@19.2.1 react-dom@19.2.1 react-server-dom-webpack@19.2.1
yarn add next@<patched-version>
pnpm:
pnpm add react@19.2.1 react-dom@19.2.1 react-server-dom-webpack@19.2.1
pnpm add next@<patched-version>
提醒:某些包(react-server-dom-parcel/turbopack)可能未直接在 package.json 中列为依赖(由上游框架间接引入)。如果是间接依赖,请查看 lockfile(pnpm-lock / yarn.lock / package-lock.json),或直接升级上游框架(如 Next.js)至被修复版本,以确保间接依赖被更新。
第二步:检查并更新你所用的框架
大多数开发者是通过框架(如Next.js)使用RSC的。这些框架也发布了紧急修复,你需要根据自己使用的框架版本进行更新。
- 如果你使用 Next.js
这是受影响的“重灾区”,你需要根据当前版本升级到以下对应的已修复版本:
| 你的当前版本 | 应升级到的安全版本 |
|---|---|
| Next.js 15.0.x | next@15.0.5 |
| Next.js 15.1.x | next@15.1.9 |
| Next.js 15.2.x | next@15.2.6 |
| Next.js 15.3.x | next@15.3.6 |
| Next.js 15.4.x | next@15.4.8 |
| Next.js 15.5.x | next@15.5.7 |
| Next.js 16.0.x | next@16.0.7 |
| Next.js 14.3.0-canary.77 或更高 | 必须降级到最新的稳定14.x版本:next@14
|
- 如果你使用其他框架或构建工具
请参照以下列表更新你的依赖:
- React Router:升级React相关包至最新版。
- Expo / Waku:升级
react-server-dom-webpack至最新版。 - Vite RSC插件:运行
npm install @vitejs/plugin-rsc@latest。 - Parcel RSC插件:运行
npm install react-server-dom-parcel@latest。 - Redwood SDK:确保版本 >=
1.0.0-alpha.0,并升级React包。
第三步:进行全面依赖扫描与资产盘点
升级后,还需要确保所有环节都已修复。
- 使用依赖扫描工具:在项目根目录运行
npm list或使用专业工具(如npm audit、Snyk、Tenable Nessus),确认所有深层依赖中的react-server-dom-*包都已更新到安全版本。检查并删除旧的锁文件(如package-lock.json、yarn.lock)后重新生成,以防锁住漏洞版本。 - 资产盘点:梳理你所有线上项目,优先排查对外公开访问的应用,因为它们是直接攻击目标。注意,有安全公司扫描发现,约39%的云环境中存在受此漏洞影响的React/Next.js实例。
- 重新构建与部署:更新依赖后,必须重新构建你的Docker镜像或服务器端应用包,并部署到生产环境,仅更新本地
node_modules无效。
第四步:验证修复与加强监控
完成修复后,建议采取以下措施巩固安全:
- 验证修复:检查部署后应用的版本号,并可以尝试在测试环境安全地模拟请求,确认异常请求已被正确处理。
- 监控与防护:
-
- 监控日志:密切关注应用日志中是否有大量异常请求或“Server Function”调用错误。
- 启用WAF:如果你使用云服务(如Vercel、Cloudflare),它们已部署了针对此漏洞的临时防护规则。但你不应依赖于此,仍需完成自身应用的升级。
- 考虑运行时沙箱:对于运行服务器端JS代码,可以考虑采用额外的沙箱机制进行隔离。
总结:
应对此漏洞,最核心且唯一的根治方法是立即将React核心包及相关框架升级到官方指定的安全版本,并重新构建部署。整个过程应作为最高优先级的安全事件处理。