普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月4日掘金 前端

告别异地登录告警!用 GitHub Self-Hosted Runner 打造“零打扰”全栈自动化部署

作者 1024肥宅
2025年12月4日 16:23
前言 作为一个全栈开发者,最爽的时刻莫过于 git push 之后,端起咖啡,看着代码自动跑通测试、构建镜像、并在服务器上平滑更新。 但在实现这个目标的过程中,我经历了从“手动搬砖”到“SSH 自动登

如何使用 vxe-table 导出为带图片的单元格到 excel 格式文件

2025年12月4日 16:06

如何使用 vxe-table 导出为带图片的单元格到 excel 格式文件 需要注意导出图片时,需确保图片是有效链接,且允许跨域获取,否则不支持导出图片

查看官网:vxetable.cn
gitbub:github.com/x-extends/v…
gitee:gitee.com/x-extends/v…

image

image

通过定义 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>

点击导出后效果

image

gitee.com/x-extends/v…

svelte 学习章节

作者 JIOP
2025年12月4日 15:40

svelte 前端框架

官方网站

svelte.dev/docs/kit/in…

创建新的项目

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代理进行静态快照,请使用state代理进行静态快照,请使用state.snapshot:

<script> let counter = $state({ count: 0 }); 
function onclick() 
{ // Will log `{ count: ... }` rather than `Proxy { ... }` console.log($state.snapshot(counter)); } 
  </script>

你想将一些状态传递给不期望代理的外部库或 API(例如 structuredClone)时,这会很方便。

![image-20251111153213801](/Users/wangyuan/Library/Application Support/typora-user-images/image-20251111153213801.png)

返回当前的代理对象 做浅克隆的时候非常有用

$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>

测试一下?

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

如果没添加的话 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 并且是表单的一部分,则在表单重置时,它将恢复为该值而不是空字符串。请注意,对于初始渲染,绑定的值优先,除非它是 nullundefined

<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 中重复它们,不如将它们放在布局

{@render children()}

渲染子组件。 (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 函数中获取 urlparams

+page.js+layout.jsload 函数中,你可以直接访问路由信息:

// 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(服务端渲染)时会出错。始终用 $pageload 上下文。

✅ 总结:如何“获取当前路由”

需求 推荐方式
获取当前路径、参数、查询字符串 使用 $page store(在 .svelte 文件中)
在数据加载时获取路由信息 +page.jsload({ 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 无缝无限轮播

作者 小杨累了
2025年12月4日 15:29

🚀 纯 CSS Keyframes 实现 Vue 无缝无限轮播组件(跑马灯效果)

实现一个基于 Vue 和纯 CSS 实现的双向无限轮播(跑马灯)组件的使用,支持多行,并且鼠标悬停自动暂停!


🌟 组件核心

  • 性能: 使用 CSS transformanimation
  • 无缝: 通过巧妙的数据复制-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>

💡 无缝滚动原理揭秘

  1. 数据翻倍: 在 模板中,使用 v-for 将数据 (row1row2) 渲染了两遍

  2. width: max-content 确保两个列表被强制排在一行,且容器总宽度是单个列表宽度的两倍。

  3. 关键的 -50%

    • 向左: 动画从 translateX(0) 移动到 translateX(-50%)。当第一组数据完全移出屏幕时(即移动了 50% 的总宽度),动画瞬间重置回 0,此时屏幕上显示的是第二组数据的起点,完美对接,实现视觉上的无限循环。
    • 向右: 动画从 translateX(-50%)(即第二组数据的起点)移动到 translateX(0)(即第一组数据的起点),实现反向循环。

通过这种方式,我们避免了使用 JavaScript 计算或操作 DOM,将动画交给 CSS 处理,极大地提高了性能和流畅度。

从零实现一个“类微信”表情输入组件

作者 streaker303
2025年12月4日 15:06

📋 背景

最近接到一个需求:在系统中添加表情输入功能。由于需要与腾讯某平台保持数据一致,表情包的数量和取值都要完全相同。

翻阅文档后发现并没有提供现成组件,只能自己实现。

先观察了实现方式:打开控制台面板,点击表情后会瞬间请求大量图片。

输入逻辑上,用户选择 😊 后值自动变成 [微笑],用户直接输入 [微笑] 也能映射成对应表情图片。

🚩 最终目标

  • ✅ 支持点击表情面板插入表情
  • ✅ 支持输入 [微笑] 自动转换为表情图片
  • ✅ 完成双向绑定,取值时图片转回 [微笑] 文本
  • ✅ 字符长度计算:中文 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️⃣ 输入法兼容

处理中文输入法的组合事件:compositionstartcompositionupdatecompositionend,避免在拼音输入阶段触发转换。

5️⃣ 删除表情

确保删除时完整移除表情图片节点,不留残留。

最终效果:

动画.gif

💡总结

借助 AI 辅助开发,核心在于:告知 AI 需要处理的特殊情况,以及不断测试和调优。

只要明确需求规范和边界情况,就能高效实现功能。怎么有种从开发转测试的感觉。

100¥ 实现的React项目 Keep-Alive 缓存控件

作者 香煎藕饼
2025年12月4日 14:59

前言

目前在做自己的后台管理系统,采用React构建,利用Antd加了Tabs可关闭页签,结合dnd-kit实现了可拖拽Tabs,页面渲染之前参考网上采用Map缓存,结果实际到手发现不是缓存失效,就是刷新、清除影响缓存,反正问题挺多。

Vue是有自带的Keep-Alive控件,咱小胳膊小腿的,也不敢比,也没那时间精力去研究🧐,但React百度搜了是没有自带的Keep-Alive的,网上的教程也仅止于静态实例(可能我没搜到,万望大佬勿喷),但自己又很想要学这个功能。

最近AI确实很🔥很👍,之前使用过字节的Trae,当时效果还不错,刚好赶上Clude的末班车,自从Clude不让用后,Trae的体验一言难尽。于是抱着体验的态度,花了20$买了Cursor,于是就拿缓存Keep-Alive开🔪,从昨晚开始搞到现在,在我不断测试不断调教下,终于有了成果,但代价也不一般,直接上图

image.png 一天过去,直接烧掉100大洋,欢迎品尝

线上地址:www.liyq666.top/

git仓库:gitee.com/lyqjob/proj…

项目实例图

image.png

附上AI生成的使用文档,以下内容AI生成

概述

什么是页面缓存?

想象一下,你在浏览器中打开了多个标签页(比如:用户管理、角色管理、菜单管理)。当你从一个标签页切换到另一个标签页时,页面缓存系统会帮你:

  1. 保存页面状态:切换标签页时,页面不会重新加载,之前填写的数据、滚动位置等都会保留
  2. 避免重复请求:切换回之前的标签页时,不会重新请求接口,直接显示之前的数据
  3. 提升用户体验:页面切换更流畅,没有闪烁和重新加载的感觉

为什么需要缓存?

  • 性能优化:减少不必要的 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 个)时,系统会自动删除最久未使用的页面缓存。

工作原理

  1. 每次访问页面时,将该页面移到访问顺序数组的最后
  2. 当缓存超过 8 个时,删除访问顺序数组第一个(最久未使用的)
  3. 这样保证最常用的页面始终在缓存中

示例

访问顺序:['/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)                │
│  - 跟踪已初始化的组件                                      │
│  - 防止重复加载数据                                        │
└─────────────────────────────────────────────────────────────┘

组件关系

  1. BasicLayout:最外层布局,包含所有组件
  2. TabsContainer:显示和管理标签页
  3. KeepAliveOutlet:核心缓存组件,管理页面缓存
  4. useTabsManager:管理标签页状态,发送清除缓存指令
  5. useGlobalMessage:全局消息系统,用于组件间通信
  6. 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.__clearComponentInitwindow.__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 存储最新引用
}

关键改进

  1. 使用 Set 而不是 Map 存储初始化状态
  2. 使用 effectRef 存储最新的 effect 函数,避免依赖项变化导致的问题
  3. 双重检查机制:全局检查(initializedComponents)和组件级别检查(hasExecutedRef
  4. 自动关闭进度条:当检测到已初始化时,自动调用 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. 符合用户习惯:用户经常访问的页面会保留在缓存中
  2. 自动管理:无需手动清理,系统自动管理缓存大小
  3. 性能优化:最常用的页面始终在缓存中,切换速度快

使用指南

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: 这是因为页面被缓存了,组件不会重新挂载。如果需要实时数据,应该:

  1. 将页面添加到白名单(不缓存)
  2. 使用刷新功能(右键菜单 → 刷新)
  3. 在数据变化时手动刷新(例如:保存成功后刷新列表)

Q2: 为什么有些页面切换时会重新加载?

A: 可能的原因:

  1. 页面在白名单中:这些页面每次访问都会重新加载
  2. 缓存已满:LRU 算法删除了该页面的缓存
  3. 页面刷新:浏览器刷新会清空所有缓存

Q3: 如何调试缓存问题?

A: 在开发环境下,KeepAlive 组件会输出调试日志:

// 查看控制台输出
[KeepAlive] 新增缓存: { cacheKey: '/setting/user', cacheSize: 1, ... }
[KeepAlive] 使用缓存: '/setting/user'
[KeepAlive] 清除所有缓存,清除前: { cacheSize: 3, cachedKeys: [...] }

Q4: 为什么 useCacheableEffect 不执行?

A: 检查以下几点:

  1. 是否设置了 cacheable: true(非白名单页面必须设置)
  2. 组件是否已被标记为已初始化(检查 initializedComponents Set)
  3. 缓存 key 是否正确(路径 + 查询参数)
  4. 是否是白名单页面(白名单页面应该使用普通 useEffect,不使用 useCacheableEffect

调试方法

// 在浏览器控制台查看初始化状态
console.log(window.__clearComponentInit)  // 应该是一个函数
console.log(window.__clearAllInit)        // 应该是一个函数

// 查看缓存状态
console.log(window.__checkCache('/setting/user'))  // 检查是否有缓存

Q5: 如何强制刷新页面数据?

A: 有几种方式:

  1. 右键菜单 → 刷新:清除缓存并重新加载(remove: true
  2. 关闭标签页后重新打开:会重新加载数据(因为初始化状态被清除)
  3. 在代码中手动清除缓存
    // 清除缓存和初始化状态(强制刷新)
    globalMessageUtils.keepAlive('drop', { 
        key: location.pathname + location.search,
        remove: true 
    })
    
    // 只清除初始化状态(保留缓存,但会重新加载数据)
    if (window.__clearComponentInit) {
        window.__clearComponentInit(location.pathname + location.search)
    }
    

Q8: 为什么切换回缓存页面时数据是空的?

A: 这可能是组件状态丢失导致的。系统已经实现了数据恢复机制

  1. 自动检测:当检测到使用缓存但数据为空时,会自动重新加载数据
  2. 延迟检查:延迟 100ms 检查,确保组件状态已恢复
  3. 避免重复:使用 ref 跟踪,避免重复加载

如果仍然出现问题,检查:

  • 数据恢复机制的 useEffect 是否正确实现
  • hasTriedRestoreRefprevCacheKeyRef 是否正确设置
  • 数据判断条件是否正确(data.length === 0 && total === 0

Q6: 缓存会占用多少内存?

A: 缓存的是 React 组件实例,内存占用取决于:

  1. 组件复杂度:组件越复杂,占用内存越多
  2. 数据量:页面数据越多,占用内存越多
  3. 缓存数量:默认最多 8 个页面

如果内存紧张,可以:

  • 减少 CACHE_LIMIT(默认 8)
  • 将不需要缓存的页面添加到白名单

Q7: 页面刷新后缓存会丢失吗?

A: 是的。缓存存储在内存中(Map 对象),页面刷新后会清空。这是正常行为,因为:

  1. 页面刷新意味着用户想要重新加载应用
  2. 缓存数据可能已过期,需要重新获取
  3. 避免内存泄漏

总结

核心要点

  1. 缓存存储:使用 Map 存储页面组件,accessOrder 数组记录访问顺序
  2. LRU 算法:自动删除最久未使用的缓存,保持缓存数量在限制内
  3. 白名单机制:某些页面不缓存,每次访问都重新加载(使用普通 useEffect
  4. 消息系统:通过发布-订阅模式实现组件间通信
  5. useCacheableEffect:确保页面数据只加载一次(非白名单页面必须使用,并添加 cacheable: true
  6. 数据恢复机制:自动检测并恢复丢失的数据,确保页面正常显示
  7. 智能缓存管理:关闭标签页时保留缓存,刷新时才清除缓存

最佳实践

  1. 非白名单页面使用 useCacheableEffect 加载初始数据,并添加 cacheable: true
  2. 白名单页面使用普通 useEffect,不使用 useCacheableEffect
  3. 使用普通 useEffect 处理依赖变化(如分页、搜索等)
  4. 实现数据恢复机制,确保从白名单页面切换回缓存页面时数据正常
  5. 将实时数据页面添加到白名单(如日志、看板等)
  6. 合理设置缓存数量限制(默认 8 个)
  7. 关闭标签页时保留缓存,刷新时才清除缓存
  8. 不要在 useCacheableEffect 中处理依赖变化(应该使用普通 useEffect
  9. 不要在白名单页面使用 useCacheableEffect(应该使用普通 useEffect
  10. 不要在 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中弹框出现后禁止页面滚动

2025年12月4日 14:54

项目场景:

在写带有遮罩层的弹窗时,弹窗出现时,弹框后面的页面依然会保持滚动状态,其实这种情况并么什么影响,但是有很多时候想禁止滚动。无论在移动端还是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获取指定文件下的内容

2025年12月4日 14:52

最近做一个语言切换功能,所有的的语言翻译都在同一个文件中,导致文件过大,难以维护于是想到了对文件按模块进行拆分,绞尽脑汁,查了各种资料,果然功夫不负有心人,现在方法总结出来,希望能帮助更多有需要的人。

一、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全栈系列(七)-增加验证码登录

作者 小胖霞
2025年12月4日 14:26

这是一个非常实用的功能,能有效防止暴力破解密码。

我们将采用 svg-captcha 库来生成验证码。这是一种不需要安装复杂图形库(如 Python 的 pillow 或 C++ 的 gd)的轻量级方案,生成的 SVG 图片在前端渲染非常清晰且速度快。

实现逻辑如下:

  1. 生成:前端请求验证码 -> 后端生成一个随机字符 + 一个唯一ID (UUID) -> 后端把 {UUID: 验证码} 存到内存中 -> 把 UUID 和 SVG 图片返给前端。
  2. 验证:前端登录时,把 账号 + 密码 + 验证码 + 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>

总结变化点

  1. 后端

    • 引入 svg-captcha 库。
    • 新增 /captcha 接口,生成 SVG 并在内存(captchaStore Map)中记录 uuid -> code 的映射。
    • 修改 /login 接口,在验证账号前,先拿 uuid 去内存查,对比前端传来的 code。
  2. 前端

    • UI 上增加了输入框和 v-html 来显示 SVG。
    • 进入页面或点击图片时,调用 /captcha 接口,保存 uuid,渲染 img。
    • 登录时把 code 和 uuid 一起发给后端。

这样你就拥有了一个安全、轻量且体验流畅的图形验证码功能了!

Cdiscount API 调用合规指南:频率限制、数据用途边界与封号风险规避

2025年12月4日 14:23

Cdiscount API 调用合规指南:频率限制、数据用途边界与封号风险规避

Cdiscount 作为法国头部电商平台,对 API 调用的合规性管控严格,违规操作(如超限请求、数据滥用)会触发从临时封禁到永久封号的处罚。本文从「频率限制合规、数据用途边界、风险规避实操」三大维度,给出可落地的合规指南,适配开发者 / 企业的 API 使用场景。

一、核心合规前提

所有 API 调用需基于 Cdiscount 官方开发者平台([Cdiscount Developer Portal])的合法授权:

  1. 完成企业 / 个人账号认证,创建应用并获取Client ID/Client Secret
  2. 仅使用平台开放的正式 API 端点(禁止抓包、逆向非公开接口);
  3. 所有请求携带有效 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 调用合规的核心是「控频率、守边界、防风险」:

  1. 频率合规:用精准限流工具控制请求节奏,避免 429 错误和封禁;
  2. 用途合规:仅在授权范围内使用数据,禁止商业化、滥用隐私数据;
  3. 风险规避:事前防控、事中监控、事后补救,全链路降低封号概率。

合规的本质是「匹配业务需求的最小化调用」—— 不超额请求、不滥用数据、不触碰平台规则红线,既能避免封号风险,也能保障业务长期稳定运行。对于企业用户,建议建立专门的 API 合规小组,定期审计调用行为,确保全流程符合平台规则。

从零打造专业级前端 SDK (一):架构与工程化

2025年12月4日 14:15

从零打造专业级前端 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 的骨架

  1. 类型安全:完整的 TS 支持。
  2. 构建产物:支持 ESM/CJS/UMD。
  3. 架构设计:单例保证状态唯一,外观提供极致体验。

但现在的 SDK 还是个"哑巴",只能在控制台打印日志。 下一篇,我们将为它注入灵魂——实现网络发送能力,并探讨如何使用策略模式来应对复杂的浏览器环境。


本文代码已开源,欢迎 Star ⭐️

JavaScript 执行机制深度解析:从 V8 引擎到作用域链、变量提升与闭包的全面剖析

作者 AAA阿giao
2025年12月4日 14:03

前言:为什么你需要彻底理解 JS 的执行机制?

你是否曾遇到过以下“诡异”现象?

  • 函数在声明前就能调用?
  • console.log(x) 输出 undefined 而不是报错?
  • 循环中的 setTimeout 全部输出同一个值?
  • if 块中用 var 声明变量,却影响了整个函数?

这些看似“反直觉”的行为,其实都源于 JavaScript 引擎底层的 执行机制。而要真正掌握这门语言,不能只停留在“会用 API”,必须深入理解:

  • JS 是如何运行的?
  • 什么是执行上下文(Execution Context)?
  • 变量提升(Hoisting)是怎么回事?
  • 作用域(Scope)到底是什么?
  • 为什么 let/constvar 更安全?
  • V8 引擎内部是如何处理这些概念的?

本文将结合基础代码,逐行注解、层层递进,带你从零构建对 JavaScript 执行机制的完整认知体系。全文基于现代 JS(ES6+)标准,并兼容历史背景,力求既讲清原理,又解决实际问题。


第一章:JavaScript 的运行模型——编译 + 执行

很多人误以为 JavaScript 是“解释型语言”,一行一行直接执行。但事实是:现代 JavaScript 引擎(如 V8)采用“先编译,再执行”的两阶段模型

1.1 两阶段执行流程

当一段 JS 代码被加载时,V8 引擎会经历两个关键阶段:

阶段一:编译阶段(Compilation / Parsing)

  • 扫描整段代码
  • 识别所有 变量声明var, let, const)和 函数声明
  • 构建 执行上下文(Execution Context)
  • varfunction 进行 变量提升(Hoisting)

注意:只有声明被提升,赋值不会!

阶段二:执行阶段(Execution)

  • 按代码顺序执行赋值、函数调用、表达式求值等操作
  • 动态创建新的执行上下文(如调用函数时)
  • 管理 调用栈(Call Stack)

这种设计使得 JS 能在运行前“预知”有哪些变量和函数可用,但也带来了“变量提升”这一争议特性。


第二章:执行上下文(Execution Context)——JS 运行的“容器”

每当 JS 执行一段可执行代码(全局代码或函数),引擎会为其创建一个 执行上下文。它是 JS 代码运行的“沙盒环境”。

2.1 执行上下文的组成

每个执行上下文包含三个核心部分:

组成 说明
变量环境(Variable Environment) 存放 var 声明的变量(函数级作用域)
词法环境(Lexical Environment) 存放 let/const 声明的变量(支持块级作用域)
this 绑定 当前上下文中的 this 指向

关键区分

  • var → 变量环境
  • let/const → 词法环境

这就是为什么 varlet 行为不同——它们被存放在执行上下文的不同“房间”里!

2.2 执行上下文的生命周期

  1. 创建阶段(编译阶段):

    • 确定作用域
    • 提升变量和函数
    • 初始化变量环境和词法环境
  2. 执行阶段

    • 逐行执行代码
    • 赋值、调用、修改变量
  3. 销毁阶段

    • 函数执行完毕后,上下文出栈
    • 变量被垃圾回收(除非被闭包引用)

第三章:变量提升(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) localVarmyFunction 内部,属于函数作用域。
  • (7) 尝试在函数外访问 localVarReferenceError: localVar is not defined

作用域隔离:函数内部变量对外部不可见,这是封装的基础。


第五章:var 的致命缺陷——无视块级作用域

5.1 问题根源

在 ES5 中,var 只认函数边界,不认 {}。即使你在 ifforwhile 中用 var,变量仍属于整个函数!


5.2 文件 3.jsvar 在 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;

 这比 varundefined 更安全——早报错,早修复


6.2 文件 4.jslet 的块级作用域

let name = '张三';                // (1) 全局 let 变量
function showName() { 
    console.log(name);            // (2) → '张三'
    if (false) { 
        let name = '李四';        // (3) 块级变量(但 if 为 false,不执行)
    }
    console.log(name);            // (4) → '张三'
}
showName();

分析:

  • (1) 全局 namelet,块级作用域。
  • (3) if (false) 块未执行,let name = '李四' 从未创建。
  • (2)(4) 都访问全局 name'张三'

无污染、无遮蔽let 完美隔离作用域。


6.3 对比 var vs let:循环中的经典问题

文件 6.jsvar 版):

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 只有一个共享变量。


第七章:执行上下文视角——varlet 如何共存?

现代 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
    }
}

执行过程:

  1. 进入 foo,创建执行上下文
  2. 扫描 var a → 放入 变量环境(初始 undefined
  3. 扫描 let b → 放入 词法环境(处于 TDZ)
  4. 执行 var a = 1 → 变量环境中的 a 赋值为 1
  5. 执行 let b = 2 → 词法环境中的 b 赋值为 2
  6. 进入 {} 块 → 创建 新的词法环境
  7. 在新环境中 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 块中有效
}

重点:

  • ifwhilefor{} 都是 块级作用域
  • 但只有使用 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 时:

    1. bar 自己没有 myName
    2. foo 里找 → 找到 var myName = '张三'
    3. 停止查找!返回 '张三'

注意:即使 bar 被拿到全局调用,结果也一样!因为作用域链在声明时就锁定了


9.3 文件 1.js 再分析:为什么输出 李四

 1.js 是一个经典反直觉案例:

function bar(){
    console.log(myName);
}
function foo(){
    var myName = '张三';
    bar(); //运行时查找,先在当前作用域查找,没有找到,就去父作用域查找
}
var myName = '李四';
foo(); // 李四

为什么不是 '张三'

关键点bar 函数是在全局作用域中声明的!

所以 bar 的作用域链是:bar → 全局根本不包含 foo

执行过程:

  1. foo() 被调用

  2. foo 内部创建局部变量 myName = '张三'

  3. 调用 bar()

  4. bar 开始查找 myName

    • 自己没有
    • 看“爸爸”是谁 → 全局作用域
    • 在全局找到 myName = '李四'
  5. 输出 '李四'

💡 记住:作用域链看函数在哪写的,不是在哪调用的!

“作用域链查找的规则 一定要知道作用域链 变量的查找路径按函数申明的时候,即在编译阶段就确定了,不会改变词法作用域”


9.4 作用域链查找优先级:词法环境 vs 变量环境

当查找一个变量时,JS 引擎会按以下顺序:

  1. 当前词法环境let/const
  2. 当前变量环境var
  3. 外层函数的词法/变量环境
  4. 全局环境
  5. 找不到 → ReferenceError

✅ 因此,let name 会遮蔽 var name,因为词法环境优先。


第十章:最佳实践与总结

10.1 为什么变量提升是“设计缺陷”?

  • 导致代码行为与直觉不符
  • 容易造成变量覆盖
  • 本应销毁的变量未被销毁(内存泄漏风险)
  • 无法实现真正的块级逻辑隔离

但早期 JS 为了快速实现,选择了最简单的方案。


10.2 ES6 如何解决?

问题 ES5 (var) ES6 (let/const)
作用域 函数级 块级 ✅
提升行为 声明提升,值为 undefined 暂时性死区(TDZ)✅
重复声明 允许(静默覆盖) 报错 ✅
循环变量 共享同一变量 每次迭代新绑定 ✅

10.3 开发建议

  1. 永远不要使用 var(除非维护老项目)
  2. 优先使用 const,只有需要重赋值时才用 let
  3. 理解 TDZ:不要在声明前访问 let/const 变量
  4. 利用块级作用域:用 {} 显式隔离逻辑
  5. 避免全局变量污染:使用模块化(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); // 100var 提升到函数顶部)
}

📄 7.js

function foo(){ 
    var a = 1;     // → 变量环境
    let b = 2;     // → 词法环境
    { 
        let b = 3; // → 新词法环境,遮蔽外层 b
    }
}

第十二章:闭包(Closure)——让函数“记住”它出生的地方

12.1 什么是闭包?

闭包(Closure) 是指:

一个函数能够访问并操作其词法作用域外部的变量,即使这个函数在外部被调用,这些变量依然存在。

简单说:函数带着它的“出生证明”(作用域)一起旅行。


12.2 闭包形成的两个必要条件

闭包的形成条件

  1. 函数嵌套
  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()); // 李四

逐行拆解:

  1. foo() 被调用

    • 创建执行上下文
    • 初始化 myName = '张三', test1 = 1, test2 = 2
    • 定义对象 innerBar,其方法 getNamesetName 引用了 myNametest1
  2. return innerBar

    • foo 执行完毕,执行上下文本应销毁
    • innerBar 的两个方法仍然引用着 myNametest1
    • JS 引擎发现:“这些变量还有人用!” → 不回收!
  3. var bar = foo()

    • bar 拿到了 innerBar 对象
    • 此时 foo 已出栈,但 myNametest1 仍在内存中
  4. bar.setName('李四')

    • 调用 setName,它修改了 foo 内部的 myName
    • myName'张三' 变成 '李四'
  5. bar.getName()

    • 访问 test1(输出 1
    • 返回 myName(现在是 '李四'

💡 这就是闭包!
即使 foo 已经执行完、出栈,它的局部变量 myNametest1 依然活着,因为被返回的函数“背”着它们。


12.4 闭包的内存模型:专属“背包”比喻

readme.md 有一个绝妙比喻:

“有点像给 getName,setName 方法背的一个专属背包。这个闭包里面的变量叫自由变量”

  • 每个闭包函数都有一个隐藏的背包([[Scope]] 内部属性)
  • 背包里装着它声明时所在作用域的所有自由变量(被引用但不在自己作用域内的变量)
  • 无论函数在哪里被调用,打开背包就能拿到这些变量

12.5 闭包的作用与风险

作用:

  • 封装私有变量(如 myName 外部无法直接访问,只能通过 getName/setName
  • 延长变量生命周期
  • 实现模块模式、工厂函数等高级模式

风险:

  • 内存泄漏:如果闭包长期持有大对象,且未释放,会导致内存占用过高
  • 循环引用:闭包引用 DOM,DOM 又引用闭包 → 垃圾回收失败

最佳实践:

  • 不再需要时,将引用设为 nullbar = null
  • 避免不必要的闭包

12.6 闭包 + 作用域链 = JS 的灵魂

闭包之所以能工作,完全依赖于作用域链的静态性

  • 函数声明时,作用域链就固定了
  • 即使函数被传递到任何地方,它的作用域链不变
  • 因此总能找到“老家”的变量

🌟 一句话总结闭包
“函数 + 它声明时的作用域 = 闭包”


结语:掌握机制,方能驾驭语言

JavaScript 的执行机制看似复杂,但一旦理解 执行上下文、作用域链、变量提升、块级作用域、闭包 这五大支柱,你就能:

  • 写出更安全、可预测的代码
  • 轻松调试“诡异”问题
  • 深入理解 this、异步、模块等高级概念
  • 在面试中展现扎实功底

记住:JS 不是魔法,只是你还没看清它的执行上下文。

React CVE-2025-55182漏洞排查与修复指南

2025年12月4日 13:47

漏洞概览

  • 漏洞类型:不安全反序列化(React Server Components 的 Flight 协议处理),可导致 未认证的远程代码执行(RCE) 。CVSS v3 基准分数为 10.0(Critical)
  • 受影响组件/版本:react-server-dom-webpackreact-server-dom-parcelreact-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的。这些框架也发布了紧急修复,你需要根据自己使用的框架版本进行更新。

  1. 如果你使用 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
  1. 如果你使用其他框架或构建工具
    请参照以下列表更新你的依赖:
  • 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.jsonyarn.lock)后重新生成,以防锁住漏洞版本。
  • 资产盘点:梳理你所有线上项目,优先排查对外公开访问的应用,因为它们是直接攻击目标。注意,有安全公司扫描发现,约39%的云环境中存在受此漏洞影响的React/Next.js实例。
  • 重新构建与部署:更新依赖后,必须重新构建你的Docker镜像或服务器端应用包,并部署到生产环境,仅更新本地node_modules无效。

第四步:验证修复与加强监控

完成修复后,建议采取以下措施巩固安全:

  • 验证修复:检查部署后应用的版本号,并可以尝试在测试环境安全地模拟请求,确认异常请求已被正确处理。
  • 监控与防护:
    • 监控日志:密切关注应用日志中是否有大量异常请求或“Server Function”调用错误。
    • 启用WAF:如果你使用云服务(如Vercel、Cloudflare),它们已部署了针对此漏洞的临时防护规则。但你不应依赖于此,仍需完成自身应用的升级。
    • 考虑运行时沙箱:对于运行服务器端JS代码,可以考虑采用额外的沙箱机制进行隔离。

总结:

应对此漏洞,最核心且唯一的根治方法是立即将React核心包及相关框架升级到官方指定的安全版本,并重新构建部署。整个过程应作为最高优先级的安全事件处理。

❌
❌