普通视图

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

React 手写实现的 KeepAlive 组件 🚀

作者 xiaoxue_
2026年1月30日 17:42

React 手写实现的 KeepAlive 组件 🚀

引言 📝

在 React 开发中,你是否遇到过这样的场景:切换 Tab 页面后,返回之前的页面,输入的内容、计数状态却 “消失不见” 了?🤔 这是因为 React 组件默认在卸载时会销毁状态,重新渲染时会创建新的实例。而 KeepAlive 组件就像一个 “状态保鲜盒”,能让组件在隐藏时不卸载,保持原有状态,再次显示时直接复用。今天我们就结合实战代码,从零拆解 KeepAlive 组件的实现逻辑,带你吃透这一实用技能!

一、什么是 Keep-Alive? 🧩

Keep-Alive 源于 Vue 的内置组件,在 React 中并没有原生支持,但提供了组件缓存能力的第三方库react-activation,我们可以通过import {KeepAlive} from 'react-activation'; 导入KeepAlive获得状态保存能力。

现在我们来手动实现其核心功能,它本质是一个组件缓存容器,核心特性如下:

  • 缓存组件实例,避免组件频繁挂载 / 卸载,减少性能开销;
  • 保持组件状态(如 useState 数据、表单输入值等),提升用户体验;
  • 通过 “显隐控制” 替代 “挂载 / 卸载”,组件始终存在于 DOM 中,并未卸载,只是通过样式隐藏;
  • 支持以唯一标识(如 activeId)管理多个组件的缓存与切换。

简单说,Keep-Alive 就像给组件 “冬眠” 的能力 —— 不用时休眠(隐藏),需要时唤醒(显示),状态始终不变 ✨。

二、为什么需要 Keep-Alive?(作用 + 场景 + 使用)🌟

1. 核心作用

  • 状态保留:避免组件切换时丢失临时状态(如表单输入、计数、滚动位置);
  • 性能优化:减少重复渲染和生命周期函数执行(如 useEffect 中的接口请求);
  • 体验提升:切换组件时无加载延迟,操作连贯性更强。

2. 适用场景

  • Tab 切换页面:如后台管理系统的多标签页、移动端的底部导航切换;
  • 路由跳转:列表页跳转详情页后返回,保留列表筛选条件和滚动位置;
  • 高频切换组件:如表单分步填写、弹窗与页面的切换;
  • 资源密集型组件:如包含大量图表、视频的组件,避免重复初始化。

3. 基础使用方式

在我们的实战代码中,Keep-Alive 的使用非常简洁:

jsx

// 父组件中包裹需要缓存的组件,传入 activeId 控制激活状态
<KeepAlive activeId={activeTab}>
  {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
</KeepAlive>
  • activeId:唯一标识,用于区分当前激活的组件;
  • children:需要缓存的组件实例,支持动态切换不同组件。

三、手写 KeepAlive 组件的实现思路 🔍

1. 核心需求分析

要实现一个通用的 Keep-Alive 组件,需满足以下条件:

  • 支持多组件缓存:能同时缓存多个组件,通过 activeId 区分;
  • 自动更新缓存:新组件首次激活时自动存入缓存,已缓存组件直接复用;
  • 灵活控制显隐:只显示当前激活的组件,其余组件隐藏;
  • 兼容性强:不侵入子组件逻辑,子组件无需修改即可使用;
  • 状态稳定:缓存的组件状态不丢失,生命周期不重复执行。

2. 实现步骤拆解(结合代码讲解)

初始化一个React项目,选择JavaScript语言。

我们的 KeepAlive 组件代码位于 src/components/KeepAlive.jsx,核心分为 3 个步骤,一步步拆解如下:

步骤一:定义缓存容器 📦

核心思路:用 React 的 useState 定义一个缓存对象 cache,以 activeId 为 key,缓存对应的组件实例(children)。

jsx

import { useState, useEffect } from 'react';

const KeepAlive = ({ activeId, children }) => {
  // 定义缓存容器:key 是 activeId,value 是对应的组件实例(children)
  // 初始值为空对象,保证首次渲染时无缓存组件
  const [cache, setCache] = useState({}); 

  // 后续逻辑...
};
  • 为什么用对象作为缓存容器?对象的 key 支持字符串类型的 activeId,查询和修改效率高(O (1)),且配合 Object.entries 方便遍历;
  • Map 也可作为缓存容器(key 可支持对象类型),但本例中 activeId 是字符串,对象足够满足需求,更简洁。
步骤二:监听依赖,更新缓存 🔄

核心思路:通过 useEffect 监听 activeIdchildren 的变化,当切换组件时,若当前 activeId 对应的组件未被缓存,则存入缓存。

jsx

useEffect(() => {
  // 逻辑:如果当前 activeId 对应的组件未在缓存中,就添加到缓存
  if (!cache[activeId]) { 
    // 利用函数式更新,确保拿到最新的缓存状态(prev 是上一次的 cache)
    setCache((prev) => ({
      ...prev, // 保留已有的缓存组件
      [activeId]: children // 新增当前 activeId 对应的组件到缓存
    }))
  }
}, [activeId, children, cache]); // 依赖项:activeId 变了、组件变了、缓存变了,都要重新检查
  • 依赖项说明:

    • activeId:切换标签时触发,检查新标签对应的组件是否已缓存;
    • children:若传入的组件实例变化(如 props 改变),需要更新缓存中的组件;
    • cache:确保获取最新的缓存状态,避免覆盖已有缓存;
  • 为什么不直接 setCache({...cache, [activeId]: children})? 因为 cache 是状态,直接使用可能拿到旧值,函数式更新(prev => {...})能保证拿到最新的状态,避免缓存丢失。

步骤三:遍历缓存,控制组件显隐 🎭

核心思路:通过 Object.entries 将缓存对象转为 [key, value] 二维数组,遍历渲染所有缓存组件,通过 display 样式控制显隐(激活的组件显示,其余隐藏)。

jsx

return (
  <>
    {
      // Object.entries(cache):将缓存对象转为二维数组,格式如 [[id1, component1], [id2, component2]]
      Object.entries(cache).map(([id, component]) => (
        <div 
          key={id} // 用缓存的 id 作为 key,确保 React 正确识别组件
          // 显隐控制:当前 id 等于 activeId 时显示(block),否则隐藏(none)
          style={{ display: id === activeId ? 'block' : 'none' }}
        >
          {component} {/* 渲染缓存的组件实例 */}
        </div>
      ))
    }
  </>
);
  • 关键逻辑:所有缓存的组件都会被渲染到 DOM 中,但通过 display: none 隐藏未激活的组件,这样组件不会卸载,状态得以保留;
  • key 的作用:必须用 id 作为 key,避免 React 误判组件身份,导致状态丢失。

3.关键逻辑拆解

四、完整代码及效果演示 📸

1. 完整 KeepAlive 组件(src/components/KeepAlive.jsx

jsx

import { useState, useEffect } from 'react';

/**
 * KeepAlive 组件:缓存 React 组件,避免卸载,保持状态
 * @param {string} activeId - 当前激活的组件标识(唯一key)
 * @param {React.ReactNode} children - 需要缓存的组件实例
 * @returns {JSX.Element} 渲染所有缓存组件,控制显隐
 */
const KeepAlive = ({ activeId, children }) => {
  // 缓存容器:key 为 activeId,value 为对应的组件实例
  const [cache, setCache] = useState({});

  // 监听 activeId、children、cache 变化,更新缓存
  useEffect(() => {
    // 若当前 activeId 对应的组件未缓存,则添加到缓存
    if (!cache[activeId]) {
      // 函数式更新,确保拿到最新的缓存状态
      setCache((prevCache) => ({
        ...prevCache, // 保留已有缓存
        [activeId]: children // 新增当前组件到缓存
      }));
    }
  }, [activeId, children, cache]);

  // 遍历缓存,渲染所有组件,通过 display 控制显隐
  return (
    <>
      {Object.entries(cache).map(([id, component]) => (
        <div
          key={id}
          style={{
            display: id === activeId ? 'block' : 'none',
          }}
        >
          {component}
        </div>
      ))}
    </>
  );
};

export default KeepAlive;

2. 模拟 Tab 切换场景(src/App.jsx

jsx

import { useState, useEffect } from 'react';
import KeepAlive from './components/KeepAlive.jsx';

// 计数组件 A:演示状态保留
const Counter = ({ name }) => {
  const [count, setCount] = useState(0);

  // 模拟组件挂载/卸载生命周期
  useEffect(() => {
    console.log(`✨ 组件 ${name} 挂载完成`);
    return () => {
      console.log(`❌ 组件 ${name} 卸载完成`);
    };
  }, [name]);

  return (
    <div style={{ padding: '20px', border: '1px solid #646cff', borderRadius: '8px', margin: '10px 0' }}>
      <h3 style={{ color: '#646cff' }}>{name} 视图</h3>
      <p>当前计数:{count} 🎯</p>
      <button onClick={() => setCount(count + 1)} style={{ marginRight: '10px' }}>+1</button>
      <button onClick={() => setCount(0)}>重置</button>
    </div>
  );
};

// 计数组件 B:与 A 功能一致,用于模拟切换
const OtherCounter = ({ name }) => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`✨ 组件 ${name} 挂载完成`);
    return () => {
      console.log(`❌ 组件 ${name} 卸载完成`);
    };
  }, [name]);

  return (
    <div style={{ padding: '20px', border: '1px solid #535bf2', borderRadius: '8px', margin: '10px 0' }}>
      <h3 style={{ color: '#535bf2' }}>{name} 视图</h3>
      <p>当前计数:{count} 🎯</p>
      <button onClick={() => setCount(count + 1)} style={{ marginRight: '10px' }}>+1</button>
      <button onClick={() => setCount(0)}>重置</button>
    </div>
  );
};

const App = () => {
  // 控制当前激活的 Tab,默认激活 A 组件
  const [activeTab, setActiveTab] = useState('A');

  return (
    <div style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}>
      <h1 style={{ textAlign: 'center', marginBottom: '2rem', color: '#242424' }}>
        React KeepAlive 组件实战 🚀
      </h1>

      {/* Tab 切换按钮 */}
      <div style={{ textAlign: 'center', marginBottom: '2rem' }}>
        <button
          onClick={() => setActiveTab('A')}
          style={{
            marginRight: '1rem',
            padding: '0.8rem 1.5rem',
            backgroundColor: activeTab === 'A' ? '#646cff' : '#f9f9f9',
            color: activeTab === 'A' ? 'white' : '#242424'
          }}
        >
          显示 A 组件
        </button>
        <button
          onClick={() => setActiveTab('B')}
          style={{
            padding: '0.8rem 1.5rem',
            backgroundColor: activeTab === 'B' ? '#535bf2' : '#f9f9f9',
            color: activeTab === 'B' ? 'white' : '#242424'
          }}
        >
          显示 B 组件
        </button>
      </div>

      {/* 用 KeepAlive 包裹需要缓存的组件 */}
      <KeepAlive activeId={activeTab}>
        {activeTab === 'A' ? <Counter name="A" /> : <OtherCounter name="B" />}
      </KeepAlive>

      <div style={{ marginTop: '2rem', textAlign: 'center', color: '#888' }}>
        👉 切换 Tab 试试,组件状态不会丢失哦!
      </div>
    </div>
  );
};

export default App;

3. 效果展示

(1)功能效果
  • 首次进入页面:显示 A 组件,计数为 0;
  • 点击 A 组件 “+1” 按钮,计数变为 7;
  • 切换到 B 组件:B 组件计数为 0,A 组件隐藏(未卸载);
  • 点击 B 组件 “+1” 按钮,计数变为 5;
  • 切换回 A 组件:A 组件计数依然是 7,无需重新初始化;
  • 控制台日志:只有组件挂载日志,无卸载日志,证明组件始终存在。
(2)用户体验
  • 切换无延迟,状态无缝衔接;
  • 避免重复执行 useEffect 中的逻辑(如接口请求),提升性能;

QQ20260130-172541.gif

五、核心知识点梳理 📚

通过手写 KeepAlive 组件,我们掌握了这些关键知识点:

  1. React Hooks 实战useState 管理缓存状态,useEffect 监听依赖更新,函数式更新避免状态覆盖;
  2. 组件生命周期控制:通过 display 样式控制组件显隐,替代挂载 / 卸载,从而保留状态;
  3. 数据结构应用:对象作为缓存容器,Object.entries 实现对象遍历;
  4. Props 传递与复用children props 让 KeepAlive 组件通用化,支持任意子组件缓存;
  5. 状态管理思路:以唯一标识(activeId)关联组件,确保缓存的准确性和唯一性;
  6. 性能优化技巧:避免组件频繁挂载 / 卸载,减少 DOM 操作和资源消耗;
  7. 组件设计原则:通用、低侵入、易扩展,不修改子组件逻辑即可实现缓存功能。

补充: Map 与 JSON 的区别 ——Map 可以直接存储对象作为 key,而 JSON 只能存储字符串。如果需要缓存以对象为标识的组件,可将 cache 改为 Map 类型,优化如下:

jsx

// 用 Map 替代对象作为缓存容器
const [cache, setCache] = useState(new Map());

// 更新缓存
useEffect(() => {
  if (!cache.has(activeId)) {
    setCache((prev) => new Map(prev).set(activeId, children));
  }
}, [activeId, children, cache]);

// 遍历缓存
return (
  <>
    {Array.from(cache.entries()).map(([id, component]) => (
      <div key={id} style={{ display: id === activeId ? 'block' : 'none' }}>
        {component}
      </div>
    ))}
  </>
);

六、结语 🎉

手写 Keep-Alive 组件看似简单,却涵盖了 React 组件设计、状态管理、性能优化等多个核心知识点。它的核心思想是 “缓存 + 显隐控制”,通过巧妙的状态管理避免组件卸载,从而保留状态。

在实际开发中,我们可以基于这个基础版本扩展更多功能:比如设置缓存上限(避免内存溢出)、手动清除缓存、支持路由级缓存等。掌握了这个组件的实现逻辑,你不仅能解决实际开发中的状态保留问题,还能更深入理解 React 组件的渲染机制和生命周期。

希望这篇文章能带你吃透 Keep-Alive 组件的核心原理,下次遇到类似需求时,也能从容手写实现!如果觉得有收获,欢迎点赞收藏,一起探索 React 的更多实战技巧吧~ 🚀

React-Hooks逻辑复用艺术

2026年1月30日 17:38

前言

在 React 开发中,Hooks 的出现彻底改变了逻辑复用的方式。它让我们能够将复杂的、可复用的逻辑从 UI 组件中抽离,实现真正的“关注点分离”。本文将分享 Hooks 的核心原则,并提供 4 个在真实业务场景中封装的实战案例。

一、 Hooks 核心

1. 概念理解

Hooks 本质上是将组件间共享的逻辑抽离并封装成的特殊函数

2. 使用“红线”:规则与原理

  • 命名规范:必须以 use 开头(如 useChat),这不仅是约定,也是静态检查工具(ESLint)识别 Hook 的依据。
  • 调用位置严禁在循环、条件判断或嵌套函数中调用 Hook

底层原理: React 内部并不是通过“变量名”来记录 Hook 状态的,而是通过链表 。每次渲染时,React 严格依赖 Hook 的调用顺序来查找对应的状态。

注意: 如果在 if 语句中调用 Hook,一旦条件不成立导致某次渲染跳过了该 Hook,整个链表的指针就会错位,导致状态读取异常。

二、 实战:自定义 Hooks 封装

1. AI 场景:消息点赞/点踩逻辑 (useChatEvaluate)

在 AI 对话系统中,消息评价是通用功能。我们需要处理:状态切换(点赞 -> 取消点赞)、单选逻辑、以及异步接口调用。

import React, { useState } from 'react';

// 模拟接口
const public_evaluateMessage = async (params: any) => ({ data: true });

type EvaluateType = "GOOD" | "BAD" | "NONE";

export const useChatEvaluate = (initialType: EvaluateType = "NONE") => {
  const [ratingType, setRatingType] = useState<EvaluateType>(initialType);

  const evaluateMessage = async (contentId: number, type: "GOOD" | "BAD") => {
    let newEvaluateType: EvaluateType;

    // 逻辑:如果点击已选中的类型,则取消选中(NONE);否则切换到新类型
    if (type === "GOOD") {
      newEvaluateType = ratingType === "GOOD" ? "NONE" : "GOOD";
    } else {
      newEvaluateType = ratingType === "BAD" ? "NONE" : "BAD";
    }

    try {
      const res = await public_evaluateMessage({
        contentId,
        ratingType: newEvaluateType,
        content: "",
      });

      if (res.data === true) {
        setRatingType(newEvaluateType);
      }
    } catch (error) {
      console.error("评价失败:", error);
    }
  };

  return { ratingType, evaluateMessage };
};

// 使用示例
const ChatMessage: React.FC<{ id: number }> = ({ id }) => {
  const { ratingType, evaluateMessage } = useChatEvaluate();
  return (
    <button onClick={() => evaluateMessage(id, "GOOD")}>
      {ratingType === "GOOD" ? "👍 已点赞" : "👍 点赞"}
    </button>
  );
};

2. 响应式布局:屏幕尺寸监听 (useMediaSize)

在响应式系统中,封装一个能根据窗口宽度自动切换“设备类型”的 Hook,可以极大地简化响应式开发。

import { useState, useEffect, useMemo } from 'react';

export enum MediaType {
  mobile = 'mobile',
  tablet = 'tablet',
  pc = 'pc',
}

const useMediaSize = (): MediaType => {
  const [width, setWidth] = useState<number>(globalThis.innerWidth);

  useEffect(() => {
    const handleWindowResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleWindowResize);
    // 记得清理事件监听
    return () => window.removeEventListener('resize', handleWindowResize);
  }, []);

  // 使用 useMemo 避免每次渲染都重新运行计算逻辑
  const media = useMemo(() => {
    if (width <= 640) return MediaType.mobile;
    if (width <= 768) return MediaType.tablet;
    return MediaType.pc;
  }, [width]);

  return media;
};

export default useMediaSize;

3. 性能优化:防抖与节流 Hook

A. 防抖 Hook (useDebounce)

常用于搜索框,防止用户快速输入时频繁触发请求。

import { useState, useEffect } from 'react';

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // 关键:在下一次 useEffect 执行前清理上一次的定时器
    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

export default useDebounce;

B. 节流 Hook (useThrottle)

常用于滚动加载、窗口缩放,确保在规定时间内只执行一次。

import { useState, useEffect, useRef } from 'react';

function useThrottle<T>(value: T, delay: number): T {
  const [throttledValue, setThrottledValue] = useState<T>(value);
  const lastExecuted = useRef<number>(Date.now());

  useEffect(() => {
    const now = Date.now();
    const remainingTime = delay - (now - lastExecuted.current);

    if (remainingTime <= 0) {
      // 立即执行
      setThrottledValue(value);
      lastExecuted.current = now;
    } else {
      // 设置定时器处理剩余时间
      const timer = setTimeout(() => {
        setThrottledValue(value);
        lastExecuted.current = Date.now();
      }, remainingTime);

      return () => clearTimeout(timer);
    }
  }, [value, delay]);

  return throttledValue;
}

export default useThrottle;

三、 总结:封装自定义 Hook 的心法

  1. 抽离状态而非仅逻辑:如果一段逻辑只涉及纯函数计算,不需要 Hook;只有涉及 useStateuseEffect 等状态管理时,才有必要封装 Hook。
  2. 保持纯净:自定义 Hook 应该只关心逻辑,而不应该直接操作 DOM。
  3. TS 类型保护:利用泛型 <T> 增强 Hook 的兼容性,让它能适配各种数据类型。

一个Vite插件实现PC到移动端项目的高效迁移方案

作者 浮幻云月
2026年1月30日 17:24

当PC端项目需要迁移到移动端时,你是否还在手动复制粘贴代码?今天分享一个我们团队自研的Vite插件,帮你实现跨仓库、跨技术栈的代码高效复用!

背景:从PC到移动端的迁移之痛

最近我们团队遇到了一个典型的企业级需求:将一个成熟的PC端医疗管理系统(MDM)迁移到移动端。听起来简单,但实际上却面临诸多挑战:

面临的挑战

  1. 技术栈差异:PC端使用Element Plus,移动端需要Vant
  2. 仓库隔离:PC端和移动端在不同的Git仓库
  3. 代码复用:希望复用80%的业务逻辑,但UI组件完全不同
  4. 维护同步:业务逻辑更新需要在两端同步

传统的解决方案要么是完全重写(成本高),要么是复制粘贴(维护噩梦),或者是Monorepo (代码和依赖放一起)。我们需要的是一种既能复用核心逻辑,代码在不同仓库维护,又能灵活定制UI的解决方案。

解决方案:vite-plugin-code-reuse

基于这个需求,我们开发了 vite-plugin-code-reuse 插件,它的核心思想是:多仓库代码复用、智能替换、无缝集成

插件核心能力

// 插件的核心配置结构
interface PluginConfig {
  repositories: RepositoryConfig[];     // 代码仓库配置
  repoImportMappings: RepoImportMappings[]; // 路径映射,主要是规避不同仓库路径别名冲突问题
  importReplacements: ImportReplacementConfig[]; // 导入替换
}

实战案例:医疗管理系统迁移

让我通过实际案例展示这个插件的效果。

项目背景

  • PC端项目xlian-web-mdm(Vue 3 + Element Plus + TypeScript)
  • 移动端项目:新建的H5项目(Vue 3 + Vant + TypeScript)
  • 目标:复用PC端80%的业务逻辑,100%替换UI组件

第一步:安装配置插件

npm install vite-plugin-code-reuse --save-dev
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import codeReusePlugin from 'vite-plugin-code-reuse'
import path from 'path'

export default defineConfig({
  plugins: [
    vue(),
    codeReusePlugin({
      // 1. 引入PC端代码仓库
      repositories: [
        {
          url: 'http://gitlab.xinglianhealth.com/web/mdm/xlian-web-mdm.git',
          branch: 'master',
          dir: 'mdm-pc'  // 本地存放目录
        }
      ],
      
      // 2. 修正路径别名映射,pc和移动都配置了“@”别名,需要各自引用正确的路径
      repoImportMappings: [
        {
          alias: '@',
          repoDir: 'mdm-pc',
          // PC端中的“@”别名还是引用pc端目录下的代码,但是需要排除路由、状态管理等路径,引用移动端的,因为路由和store不能存在两个
          ignorePatterns: ['src/router', 'src/store', 'src/axios']
        }
      ],
      
      // 3. 智能导入替换,pc端的部分组件无法复用时,可以做替换
      importReplacements: [
        // 场景1:组件替换(Element Plus → Vant)
        {
          pattern: /\/views\/MasterData\/components\/UploadFile\.vue$/,
          file: 'src/components/MbUpload.vue' // 移动端专用上传组件
        },
        {
          pattern: /\/views\/MasterData\/components\/TextInput\.vue$/,
          file: 'src/components/MbTextInput.vue' // 移动端输入框
        },
        
        // 场景2:复杂表单组件替换
        {
          pattern: /\/views\/MasterData\/components\/MasterForm\.vue$/,
          file: 'src/components/MbForm.vue' // 移动端表单封装
        },
        
        // 场景3:PC端特有功能在移动端隐藏
        {
          pattern: /\/views\/MasterData\/components\/HeaderFilter\/HeaderFilter\.vue$/,
          code: '<template></template>' // 移动端不需要复杂的表头筛选
        },
        
        // 场景4:UI库替换
        {
          pattern: /^@?element-plus/,
          resolve: path.resolve('src/components/ElementPlus.ts') // 重定向到兼容层
        }
      ]
    })
  ]
})

第二步:UI组件替换

// src/components/ElementPlus.ts - UI库兼容层
import { Button, Input, Select } from './ui-adaptor'

// 创建Element Plus到Vant的映射
export {
  ElButton: Button,
  ElInput: Input,
  ElSelect: Select,
  // ... 更多映射
}

// src/components/ui-adaptor.ts - UI库适配层,以Button组件为例

<template><van-button v-bind="$attrs" /></template>
<script>
const props = defineProps({
// 对element-plus Button Props做适配,比如size不支持"medium",可以处理成其他的,相同的属性不用处理
})
</script>

第三步:复用业务逻辑组件

<!-- src/views/MasterData/PatientList.vue -->
<template>
  <!-- 复用PC端的模板结构,但使用移动端组件 -->
  <div class="patient-list">
    <!-- 搜索框(PC端是ElInput,自动替换为Vant Field) -->
    <van-field 
      v-model="searchKey"
      placeholder="搜索患者"
      @input="handleSearch"
    />
    
    <!-- 患者列表 -->
    <van-list 
      v-model:loading="loading"
      :finished="finished"
      @load="loadPatients"
    >
      <patient-item 
        v-for="patient in patients"
        :key="patient.id"
        :patient="patient"
        @click="handlePatientClick"
      />
    </van-list>
  </div>
</template>

<script setup lang="ts">
// 这里直接复用PC端的业务逻辑!
import { usePatientList } from '@/mdm-pc/src/views/MasterData/composables/usePatientList'

// 完全复用PC端的逻辑,包括:
// 1. 数据获取逻辑
// 2. 搜索过滤逻辑  
// 3. 分页逻辑
// 4. 事件处理逻辑
const {
  patients,
  loading,
  finished,
  searchKey,
  loadPatients,
  handleSearch,
  handlePatientClick
} = usePatientList()
</script>

<style scoped>
/* 移动端特有的样式 */
.patient-list {
  padding: 12px;
}
</style>

插件的核心实现原理

1. 代码仓库管理

vite build start开始时,自动拉取PC端仓库代码,如无更新不会重复拉取,默认只拉取一个commit,速度极快。

{
  url: 'http://gitlab.xinglianhealth.com/web/mdm/xlian-web-mdm.git', //仓库地址
  branch: 'master', // 仓库分支
  dir: 'mdm-pc'  // 本地存放目录
}

image.png


### 2. 智能导入替换,支持三种替换方式,任意选择

```typescript
interface ImportReplacementConfig {
    /**
     * 匹配模式(正则表达式字符串)
     */
    pattern: RegExp;
    /**
     * 替换方式:代码字符串
     */
    code?: string;
    /**
     * 替换方式:文件路径
     */
    file?: string;
    /**
     * 替换方式:重定向路径
     */
    resolve?: string;
}

3. 别名路径冲突映射修正,将被引用的仓库内部别名指向仓库内部,规避和外层别名的冲突

interface RepoImportMappings {
    /**
     * 路径别名
     */
    alias: string;
    /**
     * 冲突的仓库目录
     */
    repoDir: string;
    /**
     * 忽略列表(路径匹配时跳过),使用外出仓库别名解析
     */
    ignorePatterns?: string[];
}

实际效果对比

迁移前(传统方式)

├── 移动端项目
│   ├── src
│   │   ├── views
│   │   │   ├── PatientList.vue    ← 需要重写
│   │   │   ├── DoctorList.vue     ← 需要重写
│   │   │   └── ... (20+个页面)
│   │   └── components
│   │       ├── MbForm.vue         ← 需要重写
│   │       └── ... (50+个组件)
└── 工时:3-4人月

迁移后(使用插件)

├── 移动端项目
│   ├── mdm-pc/                    ← 自动引入的PC端代码
│   ├── src
│   │   ├── views
│   │   │   ├── PatientList.vue    ← 直接引用PC端PatientList组件,只对样式做适配覆盖
│   │   │   ├── DoctorList.vue     ← 直接引用PC端DoctorList组件,只对样式做适配覆
│   │   │   └── ... (复用80%)
│   │   └── components
│   │       ├── MbUploader.vue     ← 无法复用的组件,用移动端专用文件组件做替换
│   │       └── ... (只需写30%的组件)
└── 工时:1-1.5人月(节省60%+)

高级应用场景

场景1:多项目共享工具库

// 多个项目共享工具函数
codeReusePlugin({
  repositories: [
    {
      url: 'git@github.com:company/shared-utils.git',
      branch: 'main',
      dir: 'shared-utils'
    },
    ...
  ],
  importReplacements: [
    {
      pattern: /^@shared\/utils/,
      resolve: path.resolve('shared-utils/src')
    },
    ...
  ]
})

场景2:主题系统切换

// 根据环境切换主题
codeReusePlugin({
  importReplacements: [
    {
      pattern: /\/themes\/default/,
      file: process.env.NODE_ENV === 'mobile' 
        ? 'src/themes/mobile.vue'
        : 'src/themes/pc.vue'
    }
  ]
})

场景3:A/B测试版本

// A/B测试不同版本的组件
codeReusePlugin({
  importReplacements: [
    {
      pattern: /\/components\/CheckoutButton\.vue$/,
      file: Math.random() > 0.5 
        ? 'src/components/CheckoutButtonVariantA.vue'
        : 'src/components/CheckoutButtonVariantB.vue'
    }
  ]
})

总结

vite-plugin-code-reuse 插件为我们解决了跨项目代码复用的核心痛点:

🎯 核心价值

  1. 大幅提升开发效率:节省60%+的开发时间
  2. 保证代码一致性:业务逻辑完全一致,减少BUG
  3. 降低维护成本:一处修改,多处生效
  4. 灵活定制:UI层完全可定制,不影响核心逻辑

🚀 适用场景

  • PC端到移动端的项目迁移
  • 多项目共享组件库
  • 微前端架构中的模块复用
  • A/B测试和实验性功能

如果你也面临类似的多项目代码复用问题,不妨试试这个插件。项目已在GitHub开源,欢迎Star和贡献代码!

GitHub地址vite-plugin-code-reuse

让代码复用变得简单,让开发效率飞起来! 🚀

《打造高效的前端工程师终端:一份可复制的 Zsh + Powerlevel10k 配置实践》

作者 WaitingChen
2026年1月30日 17:21

目标很简单:快、稳、爽

快:打开终端不拖泥带水; 稳:补全、历史、插件不互相打架; 爽:git commit 这种高频命令,肌肉记忆级体验。

这篇文章基于一份可直接使用的 .zshrc 配置,完整讲清楚每一段配置解决了什么问题、为什么要这样放、以及它带来的实际体验提升。

一、为什么要“认真配置”终端?

对于前端工程师来说,终端并不是偶尔用一下的工具,而是:

  • 高频 Git 操作(git commit / rebase / checkout
  • 包管理(pnpm / npm / yarn
  • 本地调试、构建、脚手架

如果终端具备下面这些能力:

  • ↑ / ↓ 只在“相关历史”里循环
  • 自动联想但不抢键
  • 补全稳定、无卡顿

每天节省的不是几秒,而是注意力和心智负担

二、整体结构设计原则

这份配置遵循几个明确的原则:

  1. 启动相关的配置尽量靠前(避免闪屏、卡顿)
  2. 功能性模块分区清晰(补全、历史、联想、高亮各司其职)
  3. 顺序严格(这是很多问题的根源)

下面按模块逐一拆解。

三、Powerlevel10k Instant Prompt:启动速度的关键

if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then
  source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh"
fi

这是 Powerlevel10k 官方推荐的 Instant Prompt 写法,作用是:

  • 在 Zsh 完全初始化之前,先渲染 Prompt
  • 避免终端启动时的“白屏等待”

四、NVM 与主题加载:稳定优先

export NVM_DIR="$HOME/.nvm"
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && . "/opt/homebrew/opt/nvm/nvm.sh"

source /opt/homebrew/share/powerlevel10k/powerlevel10k.zsh-theme
[[ -f ~/.p10k.zsh ]] && source ~/.p10k.zsh

这里选择的是最稳妥、最不容易出坑的方式:

  • 不做过度魔改
  • 不提前优化启动时间

如果后续需要追求极致启动速度,再对 NVM 做 lazy load 即可。

五、PATH 管理:去重是隐藏的工程师细节

# 示例:将自定义工具加入 PATH(请按需修改或删除)
# export PATH="$HOME/.comate/bin:$PATH"

typeset -U PATH path

typeset -U PATH path 的作用是:

  • 自动去重 PATH
  • 保留第一次出现的顺序

这能避免:

  • PATH 无限增长
  • 同一个二进制被多次扫描

属于“看不见,但很专业”的配置。

六、补全系统:一切体验的地基

autoload -Uz compinit
compinit -d ~/.zcompdump

补全系统是很多插件的基础(包括自动联想)。

显式指定 .zcompdump 文件可以:

  • 避免偶发的补全重建
  • 提高稳定性

顺序要求

compinit 必须在 autosuggestions 之前。

七、历史行为优化:让“↑”变聪明

setopt HIST_IGNORE_ALL_DUPS
setopt HIST_REDUCE_BLANKS
setopt INC_APPEND_HISTORY
setopt SHARE_HISTORY

这些选项共同解决了几个痛点:

  • 同一条命令不会无限刷历史
  • 多余空格不会影响匹配
  • 多个终端窗口共享历史

结果是:

历史记录更干净,搜索更精准。

八、↑ / ↓ 前缀历史搜索(核心体验)

bindkey -M emacs '^[[A' history-beginning-search-backward
bindkey -M emacs '^[[B' history-beginning-search-forward

这是整份配置的灵魂部分

行为对比

历史中有:

git commit -m "init"
git commit --amend
git checkout main
pnpm install

当你输入:

git commit

按 ↑ / ↓:

  • ✅ 只会出现 git commit ...
  • ❌ 不会混入 checkout / pnpm

这是严格的前缀匹配,非常适合 Git 这类命令族。

九、自动联想:辅助而不是干扰

ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=8'
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh
  • 灰色提示足够克制
  • 只做“提示”,不抢控制权

并且通过 -M emacs 的 bindkey 写法,确保它不会和 ↑ / ↓ 行为冲突。

十、语法高亮:为什么必须放最后?

source /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

这是官方和社区反复强调的一点:

zsh-syntax-highlighting 必须是最后加载的插件

否则会出现:

  • 高亮失效
  • 与其他插件冲突

十一、最终配置(可直接使用)

下面是一份完整、可直接复制使用的 .zshrc 最终配置

说明:

  • 已包含 Powerlevel10k Instant Prompt
  • 支持 git commit 等命令的 ↑ / ↓ 严格前缀历史搜索
  • 自动联想、高亮、补全顺序全部正确
  • 适合长期作为主力终端配置使用
########################################
# Powerlevel10k instant prompt(必须靠前)
########################################
if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then
  source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh"
fi

########################################
# 基础环境
########################################
export NVM_DIR="$HOME/.nvm"
[ -s "/opt/homebrew/opt/nvm/nvm.sh" ] && . "/opt/homebrew/opt/nvm/nvm.sh"

# Powerlevel10k 主题
source /opt/homebrew/share/powerlevel10k/powerlevel10k.zsh-theme
[[ -f ~/.p10k.zsh ]] && source ~/.p10k.zsh

# PATH 去重
typeset -U PATH path

########################################
# Zsh 补全系统
########################################
autoload -Uz compinit
compinit -d ~/.zcompdump

########################################
# 历史行为优化
########################################
setopt HIST_IGNORE_ALL_DUPS
setopt HIST_REDUCE_BLANKS
setopt INC_APPEND_HISTORY
setopt SHARE_HISTORY

########################################
# 自动联想(灰色提示)
########################################
ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE='fg=8'
source /opt/homebrew/share/zsh-autosuggestions/zsh-autosuggestions.zsh

########################################
# ↑ / ↓ 前缀历史搜索(git commit 友好)
########################################
bindkey -M emacs '^[[A' history-beginning-search-backward
bindkey -M emacs '^[[B' history-beginning-search-forward

########################################
# 语法高亮(必须放最后)
########################################
source /opt/homebrew/share/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh

十二、最终效果总结

使用这份配置,你会得到:

  • 启动快、不闪屏(Powerlevel10k Instant Prompt)
  • 补全稳定(compinit 顺序正确)
  • 历史搜索精准(前缀匹配)
  • 自动联想不干扰操作

这不是“炫技型配置”,而是:

适合长期使用的工程师终端基建。

结语

终端配置的价值,不在于你用了多少插件,而在于:

  • 是否减少了无意义的操作
  • 是否让高频行为变成肌肉记忆

如果你每天要敲几十次 git commit, 那么让 ↑ 键“只做正确的事”,本身就已经非常值得了。

后续可以继续进阶的方向:

  • NVM lazy load(秒开终端)
  • Git / pnpm 专用快捷键
  • ZLE Widget 深度定制

这些,都可以在这份配置之上自然演进。

React-Scheduler 调度器如何掌控主线程?

2026年1月30日 17:21

前言

在 React 18 的并发时代,Scheduler(调度器) 是实现非阻塞渲染的幕后英雄。它不只是 React 的一个模块,更是一个通用的、高性能的 JavaScript 任务调度库。它不仅让 React 任务可以“插队”,还让“长任务”不再阻塞浏览器 UI 渲染。

一、 核心概念:什么是 Scheduler?

Scheduler 是一个独立的包,它通过与 React 协调过程(Reconciliation)的紧密配合,实现了任务的可中断、可恢复、带优先级执行。

主要职责

  1. 优先级管理:根据任务紧急程度(如用户点击 vs 数据预取)安排执行顺序。
  2. 空闲时间利用:在浏览器每一帧的空闲时间处理不紧急的任务。
  3. 防止主线程阻塞:通过“时间片(Time Slicing)”机制,避免长任务导致页面假死。

二、 Scheduler 的完整调度链路

当一个 setState 触发后,Scheduler 内部会经历以下精密流程:

1. 任务创建与通知

当状态更新时,React 不会立即执行 Render。它首先会创建一个 Update对象来记录这次变更,这个对象中包含这次更新所需的全部信息,例如更新后的状态值,Lane车道模型分配的任务优先级.

2. 优先级排序与队列维护

  • 任务优先级排序: 创建更新后,react会调用scheduleUpdateOnFiber函数通知scheduler调度器有个一个新的任务需要调度,这时scheduler会对该任务确定一个优先级,以及过期时间(优先级越高,过期时间越短,表示越紧急)

  • 队列维护: 接着scheduler会将该任务放入到循环调度中,scheduler对于任务循环调度在内部维护着两个队列,一个是立即执行队列taskQueue和延迟任务队列timeQueue,新任务会根据优先级进入到相应对列

    • timerQueue(延时任务队列) :存放还未到开始时间的任务,按开始时间排序。
    • taskQueue(立即任务队列) :存放已经就绪的任务,按过期时间排序。优先级越高,过期时间越短。

3. 时间片的开启:MessageChannel

将任务放入队列后,scheduler会调用requetHostCallback函数去请求浏览器在合适的时机去执行调度,该函数通过 MessageChannel对象中的port.postMessage 方法创建一个宏任务,浏览器在下一个宏任务时机触发 port.onmessage,并在这宏任务回调中启动 workLoop函数。

补充:Scheduler 会调用 requestHostCallback 请求浏览器调度。它没有选择 setTimeout,而是选择了 MessageChannel

为什么选 MessageChannel? setTimeout(fn, 0) 在浏览器中通常有 4ms 的最小延迟,且属于宏任务中执行时机较晚的。MessageChannelport.postMessage 产生的宏任务执行时机更早,且能更精准地在浏览器渲染帧之间切入。

4. 工作循环:workLoop

  • 在宏任务回调中,调度器会进入 workLoop。它会调用performUnitOfWork函数循环地处理Fiber节点,对比新旧节点的props、state,并从队列中取出最紧急的任务交给 React 执行。

  • workLopp中会包含一个shouldYield函数中断检查函数,用于检查当前时间片是否耗尽以及是否有更高优先级的任务执行,如果有的话则会将主线程控制权交还给浏览器,以保证高优先级任务(如用户输入、动画)能及时响应。


5. 中断与恢复:shouldYield 的魔力

workLoop 执行过程中,每一项单元工作完成后,都会调用 shouldYield() 函数进行“路况检查”。

  • 中断条件:如果当前时间片(通常为 5ms)耗尽,或者检测到有更紧急的用户交互(高优任务插队),shouldYield 返回 true
  • 状态保存:此时 React 会记录当前 workInProgress 树的位置,将控制权交还给浏览器。
  • 任务恢复:Scheduler 会在下一个时间片通过 MessageChannel 再次触发,从记录的位置继续执行,从而实现可恢复。

6. 任务插队

如果在执行一个低优先级任务时,有高优先级任务加入(如用户突然点击按钮),Scheduler会中断当前的低优任务并记录该位置,先执行高优任务。等高优任务完成后,再重新执行或继续之前的低优任务


三、 补充

  1. 执行时机对比MessageChannel 确实在宏任务中非常快,但在某些极其特殊的情况下(如没有 MessageChannel 的旧环境),它会回退到 setTimeout
  2. 饥饿现象防止:如果一个低优先级任务一直被插队怎么办?Scheduler 通过过期时间解决。一旦任务过期,它会从 taskQueue 中被提升为同步任务,强制执行。

如何监控qiankun中子应用的内存使用情况

作者 MQliferecord
2026年1月30日 17:17

希望监控 qiankun 中子应用的内存使用情况(包括 JS 堆内存、DOM 占用、资源泄漏等),核心思路是「主应用统一监控 + 子应用自主上报 + 浏览器原生 API 采集 + 异常告警」,结合 qiankun 的生命周期和通信机制,实现对每个子应用内存的精准、可追溯监控

核心原理
  1. 浏览器原生 API:使用 window.performance.memory(Chrome 特有,最核心)获取 JS 堆内存,document.querySelectorAll('*').length 统计 DOM 节点数;
  2. qiankun 通信机制:主应用通过「全局状态 / 自定义事件」接收子应用上报的内存数据;
  3. 生命周期绑定:在子应用的 mount/unmount/「激活 / 失活」时机采集内存数据,确保数据和子应用状态关联;
  4. 数据汇总:主应用汇总所有子应用的内存数据,形成「子应用 - 内存 - 时间」的维度表,便于分析泄漏趋势。
步骤 1:搭建监控基础框架(主应用)

主应用负责「接收子应用数据、存储监控日志、阈值告警、可视化展示」,先定义监控数据结构和核心方法。

// 主应用 src/utils/memoryMonitor.ts
interface SubAppMemoryData {
  appName: string; // 子应用名称
  timestamp: number; // 采集时间戳
  // 核心内存指标(Chrome 特有)
  usedJSHeapSize: number; // 已使用 JS 堆内存(字节)
  totalJSHeapSize: number; // 总 JS 堆内存(字节)
  jsHeapSizeLimit: number; // JS 堆内存上限(字节)
  // 辅助指标(判断泄漏)
  domCount: number; // DOM 节点数
  timerCount: number; // 定时器数量
  listenerCount: number; // 全局事件监听数
  status: 'mounted' | 'active' | 'inactive' | 'unmounted'; // 子应用状态
}

// 存储所有子应用的内存监控日志
const memoryLogs: Record<string, SubAppMemoryData[]> = {};
// 内存阈值(超出则告警,单位:MB)
const MEMORY_THRESHOLD = 200;
// 告警回调(可对接企业微信/钉钉)
let alarmCallback: (data: SubAppMemoryData) => void = () => {};

/**
 * 初始化内存监控
 * @param callback 告警回调
 */
export function initMemoryMonitor(callback: typeof alarmCallback) {
  alarmCallback = callback;
  // 初始化每个子应用的日志容器
  ['sub-app-a', 'sub-app-b', 'sub-app-c'].forEach(appName => {
    memoryLogs[appName] = [];
  });
}

/**
 * 接收子应用上报的内存数据
 */
export function receiveSubAppMemoryData(data: SubAppMemoryData) {
  // 转换为 MB 便于阅读
  const usedMB = (data.usedJSHeapSize / 1024 / 1024).toFixed(2);
  // 存储日志
  memoryLogs[data.appName].push({ ...data, usedJSHeapSize: Number(usedMB) });
  // 保留最近 100 条日志,避免主应用内存溢出
  if (memoryLogs[data.appName].length > 100) {
    memoryLogs[data.appName].shift();
  }
  // 阈值告警
  if (Number(usedMB) > MEMORY_THRESHOLD) {
    alarmCallback({ ...data, usedJSHeapSize: Number(usedMB) });
  }
}

/**
 * 获取指定子应用的内存日志
 */
export function getSubAppMemoryLogs(appName: string) {
  return memoryLogs[appName] || [];
}

步骤 2:子应用内存采集 & 上报

子应用内部实现「内存数据采集函数」,并通过 qiankun 的「全局通信」上报给主应用

// 子应用 src/utils/reportMemory.ts
/**
 * 采集当前子应用的内存数据
 * @param appName 子应用名称
 * @param status 子应用状态
 */
export function collectMemoryData(appName: string, status: string) {
  // 1. 获取 JS 堆内存(Chrome 特有,需做兼容)
  const memory = window.performance.memory || {
    usedJSHeapSize: 0,
    totalJSHeapSize: 0,
    jsHeapSizeLimit: 0
  };

  // 2. 统计 DOM 节点数(当前子应用容器内的 DOM)
  const container = document.querySelector('#app') || document.body;
  const domCount = container.querySelectorAll('*').length;

  // 3. 统计定时器数量(检测未清理的定时器)
  const timerCount = countTimers();

  // 4. 统计全局事件监听数(检测未移除的监听)
  const listenerCount = countGlobalListeners();

  // 5. 构造上报数据
  return {
    appName,
    timestamp: Date.now(),
    usedJSHeapSize: memory.usedJSHeapSize,
    totalJSHeapSize: memory.totalJSHeapSize,
    jsHeapSizeLimit: memory.jsHeapSizeLimit,
    domCount,
    timerCount,
    listenerCount,
    status
  };
}

// 辅助:统计定时器数量(简化版,生产可扩展)
function countTimers() {
  const maxTimerId = setTimeout(() => {}, 0);
  let count = 0;
  // 遍历所有定时器 ID(仅作参考,精准统计需子应用手动记录)
  for (let i = 1; i <= maxTimerId; i++) {
    if (window.clearTimeout(i)) count++;
  }
  clearTimeout(maxTimerId);
  return count;
}

// 辅助:统计全局事件监听数(如 resize/scroll 等)
function countGlobalListeners() {
  const events = ['resize', 'scroll', 'click', 'mousemove'];
  return events.reduce((total, event) => {
    return total + (window.eventListenerCount?.(window, event) || 0);
  }, 0);
}

/**
 * 上报内存数据给主应用
 * @param appName 子应用名称
 * @param status 子应用状态
 */
export function reportMemoryToMain(appName: string, status: string) {
  const data = collectMemoryData(appName, status);
  // 方式1:通过 qiankun 全局状态通信(推荐)
  if (window.__POWERED_BY_QIANKUN__) {
    window.qiankunGlobalState?.setState({
      memoryReport: data
    });
  }
  // 方式2:兼容低版本 qiankun,用自定义事件
  window.parent.postMessage(
    { type: 'MEMORY_REPORT', data },
    '*' // 生产环境替换为主应用域名
  );
}

子应用生命周期绑定上报(以 Vue 子应用为例)

// 子应用A main.ts
import { reportMemoryToMain } from './utils/reportMemory';

const appName = 'sub-app-a';
let app: any = null;
let isFirstMount = true;

// 1. 初始化时监听主应用的「激活/失活」通知
function initGlobalListener() {
  if (window.__POWERED_BY_QIANKUN__) {
    // 监听主应用的全局状态变化
    window.qiankunGlobalState?.onGlobalStateChange((state) => {
      if (state.appStatus === appName) {
        // 子应用被激活:上报激活状态的内存
        reportMemoryToMain(appName, 'active');
      } else if (state.appStatus) {
        // 子应用被失活:上报失活状态的内存
        reportMemoryToMain(appName, 'inactive');
      }
    });
  }
}

function bootstrap() {
  console.log(`${appName} bootstrap`);
  initGlobalListener();
}

function mount(props: any) {
  if (isFirstMount) {
    // 首次挂载:初始化应用
    const { createApp } = window.Vue;
    const pinia = window.pinia;
    const container = props.container.querySelector('#app') || '#app';
    app = createApp((await import('./App.vue')).default);
    app.use(pinia);
    app.mount(container);
    isFirstMount = false;
  }
  // 挂载完成:上报内存
  reportMemoryToMain(appName, 'mounted');
}

function unmount(props: any) {
  if (!props.isKeepAlive) {
    app.unmount();
    app = null;
    isFirstMount = true;
  }
  // 卸载/保活失活:上报内存
  reportMemoryToMain(appName, props.isKeepAlive ? 'inactive' : 'unmounted');
}

export { bootstrap, mount, unmount };
步骤 3:主应用接收 & 处理上报数据

主应用在初始化时绑定「内存上报」的接收逻辑,结合 qiankun 的子应用生命周期触发监控。

// 主应用 main.ts
import { initMemoryMonitor, receiveSubAppMemoryData } from './utils/memoryMonitor';
import { registerMicroApps, start, initGlobalState } from 'qiankun';

// 1. 初始化 qiankun 全局状态(用于通信)
const qiankunGlobalState = initGlobalState({
  appStatus: '', // 当前激活的子应用
  memoryReport: null // 子应用内存上报数据
});
window.qiankunGlobalState = qiankunGlobalState;

// 2. 初始化内存监控,设置告警回调
initMemoryMonitor((data) => {
  // 告警逻辑:控制台提示 + 对接企业微信/钉钉
  console.error(`【内存告警】${data.appName} 内存占用 ${data.usedJSHeapSize}MB,超出阈值 ${MEMORY_THRESHOLD}MB`);
  // 示例:调用钉钉告警接口
  // fetch('https://oapi.dingtalk.com/robot/send', {
  //   method: 'POST',
  //   body: JSON.stringify({ msgtype: 'text', text: { content: `【内存告警】${data.appName} 内存占用 ${data.usedJSHeapSize}MB` } })
  // });
});

// 3. 监听子应用的内存上报
qiankunGlobalState.onGlobalStateChange((state) => {
  if (state.memoryReport) {
    receiveSubAppMemoryData(state.memoryReport);
    // 清空上报数据,避免重复处理
    qiankunGlobalState.setState({ memoryReport: null });
  }
});

// 4. 监听自定义事件(兼容低版本子应用)
window.addEventListener('message', (e) => {
  if (e.data.type === 'MEMORY_REPORT') {
    receiveSubAppMemoryData(e.data.data);
  }
});

// 5. 注册子应用 + 切换时通知状态
const apps = [/* 子应用配置 */];
registerMicroApps(apps);
start({
  singular: false,
  sandbox: { strictStyleIsolation: true }
});

// 6. 切换子应用时,更新全局状态(触发子应用上报)
function switchApp(appName: string) {
  qiankunGlobalState.setState({ appStatus: appName });
  // 原有切换逻辑...
}

步骤 4:可视化监控面板(主应用)

主应用可开发一个简易的监控面板,展示各子应用的内存趋势(用 ECharts 实现),便于运维 / 开发实时查看。

<!-- 主应用 src/components/MemoryMonitor.vue -->
<template>
  <div class="memory-monitor">
    <h3>子应用内存监控</h3>
    <select v-model="selectedApp" @change="renderChart">
      <option value="sub-app-a">子应用A</option>
      <option value="sub-app-b">子应用B</option>
    </select>
    <div id="memory-chart" style="width: 800px; height: 400px;"></div>
    <div class="data-table">
      <table>
        <thead>
          <tr>
            <th>时间</th>
            <th>内存占用(MB)</th>
            <th>DOM节点数</th>
            <th>状态</th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="item in logs" :key="item.timestamp">
            <td>{{ new Date(item.timestamp).toLocaleString() }}</td>
            <td :style="{ color: item.usedJSHeapSize > 200 ? 'red' : 'black' }">
              {{ item.usedJSHeapSize }}
            </td>
            <td>{{ item.domCount }}</td>
            <td>{{ item.status }}</td>
          </tr>
        </tbody>
      </table>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import * as echarts from 'echarts';
import { getSubAppMemoryLogs } from '../utils/memoryMonitor';

const selectedApp = ref('sub-app-a');
const logs = ref([]);
let chart: echarts.ECharts | null = null;

// 渲染内存趋势图
const renderChart = () => {
  logs.value = getSubAppMemoryLogs(selectedApp.value);
  if (!chart) {
    chart = echarts.init(document.getElementById('memory-chart'));
  }
  chart.setOption({
    title: { text: `${selectedApp.value} 内存趋势` },
    xAxis: {
      type: 'category',
      data: logs.value.map(item => new Date(item.timestamp).toLocaleTimeString())
    },
    yAxis: { type: 'value', name: '内存(MB)' },
    series: [{
      name: 'JS堆内存',
      type: 'line',
      data: logs.value.map(item => item.usedJSHeapSize),
      markLine: { // 阈值线
        data: [{ type: 'line', yAxis: 200, name: '阈值' }]
      }
    }]
  });
};

onMounted(() => {
  renderChart();
  // 每30秒刷新一次
  setInterval(renderChart, 30 * 1000);
});
</script>

步骤 5:线下深度分析(定位内存泄漏)

线上监控发现异常后,需用 Chrome DevTools Memory 面板做内存泄漏分析,选择快照观察半个小时的内存变化,定位是否是误报内存泄漏,如果停留在页面一段时间内,内存依然持续增长,基本可以断定发生内存泄漏,然后快照分析对象引用链,分析内存泄漏的可能性

注意事项:

  • 子应用不要高频上报(如每秒上报),建议「挂载 / 卸载 / 激活 / 失活」时上报 + 每 5 分钟定时上报,避免主应用被大量上报数据阻塞;
  • 主应用保留的日志数量不宜过多(如最多 100 条),避免主应用自身内存溢出。
  • 子应用的定时器 / 事件监听统计是「简化版」,生产环境建议子应用手动维护「定时器列表 / 事件列表」(如 const timers = []; timers.push(setInterval(...))),卸载时统一清理并上报准确数量;
  • 沙箱隔离场景下,子应用的 window 是代理对象,统计 eventListenerCount 时需指向真实 windowwindow.parent

深度解析 React Router v6:构建企业级单页应用(SPA)的全栈式指南

作者 San30
2026年1月30日 17:12

在 Web 开发的演进史中,从早期的多页应用(MPA)到现代的单页应用(SPA),我们见证了前端工程师角色的巨大转变。曾几何时,前端开发被戏称为“切图仔”,路由和页面跳转的控制权完全掌握在后端手中。每一次页面的切换,都意味着浏览器需要向服务器发起一次全新的 HTTP 请求,重新下载 HTML、CSS 和 JavaScript。这种模式不仅由于网络延迟导致页面频繁出现“白屏”闪烁,更加重了服务器的渲染压力。

随着 React 等现代框架的崛起,前端路由应运而生。它将页面的跳转逻辑从后端剥离,移交至客户端处理。当路由发生改变时,浏览器不再刷新页面,而是通过 JavaScript 动态卸载旧组件、挂载新组件。这种“无刷新”的体验,让 Web 应用拥有了媲美原生桌面软件的流畅度。

本文将基于一套成熟的 React Router v6 实践方案,深入剖析如何构建一个高性能、安全且交互友好的路由系统。

第一章:路由模式的抉择与底层原理

在初始化路由系统时,我们面临的第一个架构决策就是:选择哪种路由模式?

1.1 HashRouter:传统的妥协

在早期的 SPA 开发中,HashRouter 是主流选择。它的 URL 特征非常明显,总是带着一个 # 号(例如 http://domain.com/#/user/123)。

  • 原理:它利用了浏览器 URL 中的 Hash 属性。Hash值的变化不会触发浏览器向服务器发送请求,但会触发 hashchange 事件,前端路由通过监听这个事件来切换组件。
  • 优势:即插即用。由于 # 后面的内容不被发送到服务器,因此无论如何刷新页面,服务器只接收到根路径请求,不会报 404 错误。
  • 适用场景:适合部署在 GitHub Pages 等无法配置服务器重定向规则的静态托管服务上,或者完全离线的本地文件系统应用(如 Electron 包裹的本地网页)。

1.2 BrowserRouter:现代的标准

我们在项目中采用了 BrowserRouter,并将其重命名为 Router 以保持代码的可读性。这是基于 HTML5 History API 构建的模式,它生成的 URL 干净、标准(例如 http://domain.com/user/123)。

  • 原理——一场精心的“骗局”

    所谓的 History 路由,本质上是前端与浏览器合谋的一场“欺骗”。

    1. 跳转时:当你点击链接,React Router 阻止了 <a> 标签的默认跳转行为,调用 history.pushState() 修改地址栏 URL,同时渲染新组件。浏览器认为 URL 变了,但实际上并没有发起网络请求。
    2. 后退时:当你点击浏览器后退按钮,Router 监听 popstate 事件,根据历史记录栈(Stack)中的状态,手动把旧组件渲染回来。
  • 部署的挑战

    这种模式的代价在于“刷新”。当你在 /user/123 页面按下 F5 刷新时,这场“骗局”就穿帮了。浏览器会真的拿着这个 URL 去请求服务器。如果服务器(Nginx/Apache)上只有 index.html 而没有 user/123 这个目录,服务器就会一脸茫然地返回 404 Not Found

    • 解决方案:这需要后端配合。在 Nginx 配置中,必须将所有找不到的路径重定向回 index.html,让前端接管路由渲染。

第二章:性能优化的核心——懒加载策略

随着应用规模的扩大,构建产物(Bundle)的体积会呈指数级增长。如果采用传统的 import 方式,所有页面的代码(首页、个人中心、支付页、后台管理)都会被打包进同一个 bundle.js 文件中。用户仅仅是为了看一眼首页,却被迫下载了整个应用的代码,导致首屏加载时间过长,用户体验极差。

2.1 代码分割(Code Splitting)

为了解决这个问题,我们在路由配置中全面引入了 React 的 lazy 函数。

// 静态引入(不推荐用于路由组件)
// import Product from './pages/Product';

// 动态引入(推荐)
const Product = lazy(() => import('../pages/Product'));
const UserProfile = lazy(() => import('../pages/UserProfile'));

这种写法的魔力在于,Webpack 等打包工具在识别到 import() 语法时,会自动将这部分代码分割成独立的 chunk 文件。只有当用户真正点击了“产品”或“用户资料”的链接时,浏览器才会去通过网络请求下载对应的 JS 文件。这大大减少了首屏的资源消耗。

2.2 优雅的加载过渡(Suspense & Fallback)

由于网络请求是异步的,从点击链接到组件代码下载完成之间,存在一个短暂的时间差。为了避免页面在这个空档期“开天窗”(一片空白),React 强制要求配合 Suspense 组件使用。

我们在路由配置的外层包裹了 Suspense,并提供了一个 fallback 属性:

<Suspense fallback={<LoadingFallback />}>
    <Routes>...</Routes>
</Suspense>

这里引入的 LoadingFallback 组件并非简单的文字提示,而是一个精心设计的 CSS 动画组件。

2.3 CSS 关键帧动画的艺术

为了缓解用户的等待焦虑,我们在 index.module.css 中实现一个双环旋转的加载动画。

  • 布局:使用 Flexbox 将加载器居中定位,背景设置为半透明白,遮罩住主要内容。

  • 动画原理:利用 CSS3 的 @keyframes 定义了 spin 动画,从 0 度旋转至 360 度。

    • 外层圆环:顺时针旋转,颜色为清新的蓝色(#3498db)。
    • 内层圆环:通过 animation-direction: reverse 属性实现逆时针旋转,颜色为活力的红色(#e74c3c),并调整了大小和位置。
  • 呼吸灯效果:下方的 "Loading..." 文字应用了 pulse 动画,通过透明度(opacity)在 0.6 到 1 之间循环变化,产生呼吸般的节奏感。

这种视觉上的微交互(Micro-interaction)能显著降低用户对加载时间的感知。

第三章:路由配置的立体化网络

路由不仅仅是 URL 到组件的映射,更是一个分层的立体网络。在我们的配置中,涵盖了普通路由、动态路由、嵌套路由和重定向路由等多种形态。

3.1 动态路由与参数捕获

在用户系统中,每个用户的个人主页结构相同,但数据不同。我们通过在路径中使用冒号(:)来定义参数,例如 /user/:id

在组件内部,我们不再需要解析复杂的 URL 字符串,而是通过 React Router 提供的 useParams Hook 直接获取参数对象:

const { id } = useParams();

这样,无论是访问 /user/123 还是 /user/admin,组件都能精准捕获 ID 并请求相应的数据。

3.2 嵌套路由(Nested Routes)

对于像“产品中心”这样复杂的板块,通常包含“列表”、“详情”和“新增”等子功能。我们采用了嵌套路由的设计:

<Route path='/products' element={<Product />}>
    <Route path=':productId' element={<ProductDetail />}></Route>
    <Route path='new' element={<NewProduct />}></Route>
</Route>

这种结构清晰地反映了 UI 的层级关系。父组件 Product 充当布局容器,子路由通过父组件中的 <Outlet />(虽未直接展示但在 React Router v6 中隐含)进行渲染。这使得代码结构与页面结构高度统一。

3.3 历史记录管理与重定向

在处理旧链接迁移时,我们使用了 <Navigate /> 组件。

例如,将 /old-path 重定向到 /new-path

<Route path='/old-path' element={<Navigate replace to='/new-path' />}></Route>

这里的 replace 属性至关重要。如果不加它,跳转是 push 行为,用户重定向后点击“后退”按钮,又会回到 /old-path,再次触发重定向,从而陷入死循环。加上 replace 后,新路径会替换掉历史栈中的当前记录,保证了导航历史的干净。

第四章:安全防线——高阶路由守卫

在企业级应用中,安全性是不可忽视的一环。对于“支付”、“订单管理”等敏感页面,必须确保用户已登录。我们没有在每个组件里重复写判断逻辑,而是封装了一个 ProtectRoute(路由守卫) 组件。

4.1 鉴权逻辑的封装

ProtectRoute 作为一个高阶组件(HOC),包裹在需要保护的子组件外层。

  1. 状态检查:它首先从持久化存储(如 localStorage)中读取登录标识(例如 isLogin)。

  2. 条件渲染

    • 未登录:直接返回 <Navigate to='/login' />。这会在渲染阶段立即拦截请求,并将用户“踢”回登录页。
    • 已登录:原样渲染 children(即被包裹的业务组件)。

4.2 路由层面的应用

在路由表中,我们这样使用守卫:

<Route path='/pay' element={
    <ProtectRoute>
        <Pay />
    </ProtectRoute>
}></Route>

这种声明式的写法让权限控制逻辑一目了然,且易于维护。

第五章:交互细节——导航反馈与 404 处理

一个优秀的应用不仅要功能强大,还要体贴入微。

5.1 智能导航高亮

在导航菜单中,用户需要知道自己当前处于哪个页面。我们编写了一个辅助函数 isActive,它利用 useLocation Hook 获取当前路径。

const isActive = (to) => {
    const location = useLocation();
    return location.pathname === to ? 'active' : '';
}

通过这个逻辑,当用户访问 /about 时,对应的导航链接会自动获得 active 类名,我们可以通过 CSS 为其添加高亮样式。这种即时的视觉反馈大大增强了用户的方位感。

5.2 友好的 404 页面

当用户迷路(访问了不存在的 URL)时,展示一个冷冰冰的错误页是不够的。我们配置了通配符路由 path='*' 来捕获所有未定义的路径,并渲染 NotFound 组件。

NotFound 组件中,我们不仅告知用户页面丢失,还实现了一个自动跳转机制:

useEffect(() => {
    setTimeout(() => {
        navigate('/');
    }, 6000)
}, [])

利用 useEffectsetTimeout,页面会在 6 秒后自动通过 useNavigate 导航回首页。这种设计既保留了错误提示,又无需用户手动操作,体现了产品的温度。

结语

通过 React Router v6,我们不仅仅是将几个页面简单地链接在一起。

  • 利用 History APIBrowserRouter,我们构建了符合现代 Web 标准的 URL 体系。
  • 通过 Lazy LoadingSuspense,我们兼顾了应用体积与首屏性能。
  • 借助 路由守卫Hooks,我们实现了严密的安全控制和灵活的数据交互。

这套路由架构方案,从底层的原理到上层的交互,构成了一个健壮、高效且用户体验优秀的单页应用骨架。对于任何致力于构建现代化 Web 应用的开发者来说,深入理解并掌握这些模式,是通往高级前端工程师的必经之路。

公司低代码框架-列表个性化开发最佳实践

作者 王小菲
2026年1月30日 17:03
一、引言 当前低代码组件的功能框架已趋于稳定,而业务侧的需求设计却持续迭代、不断涌现。要落地各类个性化需求,正需要我们秉持‘人有多大胆,地有多大产’的探索精神,勇于构思、大胆尝试。比如低代码列表中,针

HTML代码规范

作者 hypnos_xy
2026年1月30日 11:39

HTML代码规范

缩进

建议缩进4个字符,在现代前端工程化,缩进4个字符比2字符更好的可读性。也不影响最终的结果

DOCTYPE 声明

统一使用HTML5声明<!DOCTYPE html>

meta 标签

  • 编码格式
<meta charset="utf-8"/>
  • SEO优化
<!-- 网页标题 -->
<title>网页标题</title>
<!-- 页面关键词 -->
<meta name ="keywords" content =""/>
<!-- 页面描述 -->
<meta name ="description" content =""/>
<!-- 网页作者 -->
<meta name ="author" content =""/>
  • 优先使用 IE 最新版本和 Chrome
<meta http-equiv="X-UA-Compatible" content="IE = edge,chrome = 1"/>
  • 为移动设备添加视口
<!-- device-width 是指这个设备最理想的 viewport 宽度 -->
<!-- initial-scale=1.0 是指初始化的时候缩放大小是1,也就是不缩放 -->
<!-- user-scalable=no 是指禁止用户进行缩放 yes 允许缩放-->
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
  • 禁止自动识别页面中有可能是电话格式的数字
<meta name="format-detection" content="telephone=no"/>

pc端建议

<title>网页标题</title>
<meta charset="utf-8"/>
<meta name="keywords" content="your keywords"/>
<meta name="description" content="your description"/>
<meta name="author" content="author,email address"/>
<meta http-equiv="X-UA-Compatible" content="IE=Edge,chrome=1"/>

移动端建议

<title>网页标题</title>
<meta charset="utf-8"/>
<meta name="keywords" content="your keywords"/>
<meta name="description" content="your description"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no"/>
<meta name="format-detection" content="telephone=no"/>

标签

  • 所有标签都统一使用小写字母
  • 自闭合标签:写法<br/>后面的/不要省略,常用的自闭合标签:
<br/>
<img/>
<input/>
<link/>
<meta/>
<base/>
<source/>
  • 非自闭合标签必须有结束标签,且不要省略:
<html></html>
<body></body>
<head></head>
<script></script>
<style></style>
<iframe></iframe>
<noscript></noscript>
<title></title>
<!-- 普通标签 -->
<h1></h1>
<p></p>
<ul></ul>
<ol></ol>
<li></li>
<option></option>
<div></div>
<span></span>
<a></a>
<form></form>
<label></label>
<button></button>
<textarea></textarea>
<select></select>
<canvas></canvas>
<audio></audio>
<video></video>
<!-- 语义化标签 -->
<header></header>
<footer></footer>
<nav></nav>
<menu></menu>
<main></main>
<article></article>
<section></section>
<aside></aside>
<figure></figure>
<figcatption></figcaption>
<time></time>
<address></address>
  • 尽量减少标签数量:如果设计到div装饰的小物件,尽量使用伪元素实现,而不是先建立一个元素实现
  • vue组件自定义标签: 如果设计到多个单词便签不要使用驼峰法,而是使用-分割。且需要闭合标签
  • 对于<span><a>、等行内元素不要在嵌套其他块级元素。
  • 块元素不要和行内元素并列

转移字符

  1. &nbsp;空格
  2. &lt;小于
  3. &gt;大于
  4. &amp;
  5. &quot;引号

元素属性

  • 元素属性值使用双引号语法
  • 自定义数据属性data-*,使用小写字符,避免使用特殊字符,多单词使用-分割

js 使用

// 获取属性data-user-id="123"
let userId = userDiv.dataset.userId
userId = userDiv.getAttribute('data-user-id');
// 修改
userDiv.dataset.userId = "456";
userDiv.setAttribute('data-user-id', '789');
// 删除属性
delete userDiv.dataset.userRole;
userDiv.removeAttribute('data-user-id');
//访问所有data
const allData = element.dataset; // 返回一个 DOMStringMap 对象

css 使用

[data-role="admin"] {
  background-color: #ffebee;
}
.tooltip::before {
  content: attr(data-tooltip);
  /* 其他样式 */
}

其他规范

  1. img必须(尽量)要使用alt属性
  2. 多个表单组合应该放入form表单内
  3. html 注释必须要单独一行,不能和任何标签同行
<!-- Comment Text -->
<div>...</div>
  1. 单行字数限制120字符比较合适
  2. 多属性建议分行写(属性超过3个建议分行)
<input type="text"
       class="form-control"
       id="exampleInputEmail1"
       placeholder="Enter email"
       data-attribute1="value1"
       data-attribute2="value2"/>

vscode提示找不到名称“Map”的解决方案

作者 南雨北斗
2026年1月30日 11:33

错误提示

“编辑器报错:找不到名称“Map”。是否需要更改目标库? 请尝试将 “lib” 编译器选项更改为“es2015”或更高版本。”

原因:

Map 是 ES2015(ES6)引入的新特性,而 TS 默认的 lib 配置可能只包含 ES5 相关的类型定义(ES5 中没有 Map),因此编译器不认识 Map,从而抛出 “找不到名称‘Map’” 的错误。

解决方案(两种方式,任选其一)

方式 1:修改 tsconfig.json 配置(推荐,全局生效)

这是最规范的做法,通过配置文件指定 TS 编译时使用的库版本:

  1. 找到项目根目录的 tsconfig.json 文件(如果没有,执行 tsc --init 生成);
  2. 找到 compilerOptions 中的 lib 字段,添加 ES2015 或更高版本(如 ES2020),同时保留必要的 DOM(如果涉及浏览器环境);
  3. 保存后重启编辑器(如 VS Code),报错会自动消失。

完整的 tsconfig.json 示例

json

{
  "compilerOptions": {
    "target": "ES2015", // 编译目标版本,建议和 lib 版本匹配
    "lib": ["ES2015", "DOM"], // 核心:添加 ES2015 及以上
    "module": "CommonJS",
    "strict": true, // 开启严格模式,增强类型检查
    "esModuleInterop": true
  }
}
  • lib 字段说明:

    • ES2015:包含 ES6 所有新特性(Map、Set、Promise 等)的类型定义;
    • DOM:包含浏览器环境的类型定义(如 documentwindow),如果是 Node.js 项目可省略。

方式 2:临时指定文件级别的 lib(仅当前文件生效)

如果不想修改全局配置,可在使用 Map 的 TS 文件顶部添加一行注释,强制指定该文件使用的库版本:

typescript

运行

/// <reference lib="es2015" />

// 此时 Map 就能被识别
const map = new Map<string, number>();

验证是否解决问题

修改配置后,编写以下代码测试:

typescript

运行

const map = new Map<string, number>();
map.set("test", 123);
console.log(map.get("test"));
  • 如果编辑器不再报 “找不到名称‘Map’” 的错误,且代码能正常编译(执行 tsc 无报错),说明配置生效。

补充说明

  • target vs lib

    • target:指定 TS 编译后生成的 JS 版本(如 ES5、ES2015);
    • lib:指定 TS 编译时参考的类型定义库版本(决定 TS 认识哪些内置对象 / 方法);
    • 建议 targetlib 版本保持一致(如都设为 ES2015),避免类型和编译结果不匹配。
  • 若项目是 Node.js 环境:除了 ES2015,还可根据 Node 版本选择 ES2020ESNext 等,无需添加 DOM

总结

  1. 报错核心原因:TS 编译器的 lib 配置未包含 ES2015,导致无法识别 Map 类型;
  2. 最优解决方案:在 tsconfig.jsoncompilerOptions.lib 中添加 ES2015(或更高版本);
  3. 关键配置:lib 字段控制 TS 能识别的内置 API 类型,target 控制编译后的 JS 版本,两者建议匹配。
❌
❌