掘

掘金 - 分类

| 后端 | 前端 | Android | iOS | 人工智能 | 开发工具 | 代码人生 | 阅读 | | ------- | -------- | ------- | --- | -------- | -------- | -------- | ------- | | backend | frontend | android | ios | ai | freebie | career | article |

02-08-原理篇-异步加载与性能优化

<h2 data-id="heading-0">异步加载与性能优化</h2> <blockquote> <p><strong>篇章</strong>:02-原理篇 · 基础 <strong>阅读时间</strong>:约 40 分钟 <strong>前置知识</strong>:了解 Unity 基本资源加载方式</p> </blockquote> <hr> <h3 data-id="heading-1">一、引言</h3> <p>异步加载是 Unity 资源管理中的重要技术,它允许开发者在不阻塞主线程的情况下加载资源。性能优化则是确保游戏流畅运行的关键。本章将深入解析异步加载和性能优化的原理和方法,结合 YooAsset 的实际实现进行详细说明。</p> <p>异步加载和性能优化是资源管理的核心技术。合理的异步加载策略可以提高游戏启动速度,减少加载等待时间,而有效的性能优化可以确保游戏流畅运行。理解这些原理,可以帮助开发者设计出更合理的资源管理方案。</p> <hr> <h3 data-id="heading-2">二、异步加载</h3> <h4 data-id="heading-3">2.1 异步加载方式</h4> <p>Unity 支持多种异步加载方式:</p> <ul> <li><strong>协程(Coroutine)</strong>:Unity 内置的异步加载方式</li> <li><strong>Task</strong>:C# 内置的异步加载方式</li> <li><strong>委托(Callback)</strong>:通过回调函数处理加载结果</li> <li><strong>AsyncOperationHandle</strong>:Addressables 和 YooAsset 提供的异步操作句柄</li> </ul> <p><strong>协程加载详解</strong>:</p> <p>协程加载是 Unity 特有的异步加载方式,它可以在协程中等待异步操作完成,代码简洁易读。</p> <pre><code class="hljs language-csharp" lang="csharp"><span class="hljs-function">IEnumerator <span class="hljs-title">LoadAssetCoroutine</span>()</span> { AssetBundleCreateRequest request = AssetBundle.LoadFromMemoryAsync(fileData); <span class="hljs-keyword">yield</span> <span class="hljs-keyword">return</span> request; AssetBundle bundle = request.assetBundle; GameObject prefab = bundle.LoadAsset&lt;GameObject&gt;(<span class="hljs-string">"MyPrefab"</span>); Instantiate(prefab); } </code></pre> <p><strong>Task 加载详解</strong>:</p> <p>Task 加载是 C# 内置的异步加载方式,它支持 async/await 语法,代码更加简洁。</p> <pre><code class="hljs language-csharp" lang="csharp"><span class="hljs-function"><span class="hljs-keyword">async</span> Task <span class="hljs-title">LoadAssetTask</span>()</span> { <span class="hljs-keyword">var</span> bundle = <span class="hljs-keyword">await</span> AssetBundle.LoadFromMemoryAsync(fileData); <span class="hljs-keyword">var</span> prefab = bundle.LoadAsset&lt;GameObject&gt;(<span class="hljs-string">"MyPrefab"</span>); Instantiate(prefab); } </code></pre> <p><strong>委托回调详解</strong>:</p> <p>委托回调是通过回调函数处理加载结果的方式。它可以在加载完成后自动调用回调函数,代码简洁。</p> <pre><code class="hljs language-csharp" lang="csharp">AssetBundle.LoadFromMemoryAsync(fileData).Completed += (op) =&gt; { <span class="hljs-keyword">if</span> (op.Status == AsyncOperationStatus.Succeeded) { AssetBundle bundle = op.Result; GameObject prefab = bundle.LoadAsset&lt;GameObject&gt;(<span class="hljs-string">"MyPrefab"</span>); Instantiate(prefab); } }; </code></pre> <p><strong>AsyncOperationHandle 详解</strong>:</p> <p>AsyncOperationHandle 是 Addressables 和 YooAsset 提供的异步操作句柄,它支持多种异步操作,如加载资源、加载场景等。</p> <h4 data-id="heading-4">2.2 异步加载最佳实践</h4> <p><strong>使用异步加载</strong>:</p> <pre><code class="hljs language-csharp" lang="csharp"><span class="hljs-comment">// 使用 Addressables 异步加载</span> Addressables.LoadAsset&lt;GameObject&gt;(<span class="hljs-string">"MyPrefab"</span>).Completed += (handle) =&gt; { <span class="hljs-keyword">if</span> (handle.Status == AsyncOperationStatus.Succeeded) { Instantiate(handle.Result); } }; </code></pre> <p><strong>预加载</strong>:</p> <pre><code class="hljs language-csharp" lang="csharp"><span class="hljs-comment">// 预加载资源</span> Addressables.PreloadAssets&lt;GameObject&gt;(<span class="hljs-string">"MyPrefab"</span>, <span class="hljs-literal">null</span>, AddressableAssetLoadOptions.Default); </code></pre> <p><strong>流式加载</strong>:</p> <p>流式加载可以逐步加载大型资源,避免一次性加载大量资源导致内存溢出。</p> <hr> <h3 data-id="heading-5">三、性能优化</h3> <h4 data-id="heading-6">3.1 加载性能优化</h4> <p><strong>资源分包详解</strong>:</p> <p>将资源分成多个小包,按需加载,避免一次性加载大量资源。资源分包是加载性能优化的核心手段。</p> <p><strong>异步加载详解</strong>:</p> <p>使用异步加载,避免阻塞主线程。异步加载是加载性能优化的基本手段。</p> <p><strong>预加载详解</strong>:</p> <p>在需要之前预加载资源,减少加载等待时间。预加载是加载性能优化的重要手段。</p> <p><strong>资源池详解</strong>:</p> <p>使用资源池,避免频繁创建和销毁资源。资源池是加载性能优化的有效手段。</p> <h4 data-id="heading-7">3.2 内存性能优化</h4> <p><strong>资源卸载详解</strong>:</p> <p>及时卸载不再使用的资源,释放内存。资源卸载是内存性能优化的核心手段。</p> <p><strong>资源共享详解</strong>:</p> <p>通过依赖分析,避免资源重复加载。资源共享是内存性能优化的重要手段。</p> <p><strong>纹理压缩详解</strong>:</p> <p>使用合适的纹理压缩格式,减少内存占用。纹理压缩是内存性能优化的有效手段。</p> <h4 data-id="heading-8">3.3 网络性能优化</h4> <p><strong>多线程下载详解</strong>:</p> <p>使用多线程下载,提高下载速度。多线程下载是网络性能优化的核心手段。</p> <p><strong>断点续传详解</strong>:</p> <p>支持断点续传,避免重复下载。断点续传是网络性能优化的重要手段。</p> <p><strong>压缩传输详解</strong>:</p> <p>使用压缩传输,减少网络流量。压缩传输是网络性能优化的有效手段。</p> <h4 data-id="heading-9">3.4 GC 优化</h4> <p><strong>减少分配详解</strong>:</p> <p>减少运行时的内存分配,减少 GC 压力。减少分配是 GC 优化的核心手段。</p> <p><strong>对象池详解</strong>:</p> <p>复用对象,减少 GC 压力。对象池是 GC 优化的重要手段。</p> <p><strong>结构体替代类详解</strong>:</p> <p>使用结构体替代类,减少堆分配。结构体替代类是 GC 优化的有效手段。</p> <hr> <h3 data-id="heading-10">四、YooAsset 性能优化</h3> <h4 data-id="heading-11">4.1 YooAsset 性能特性</h4> <p>YooAsset 提供了多种性能优化特性:</p> <ul> <li><strong>异步加载</strong>:支持协程、Task、委托等多种异步加载方式</li> <li><strong>多线程下载</strong>:支持多线程下载,提高下载速度</li> <li><strong>断点续传</strong>:支持断点续传,避免重复下载</li> <li><strong>引用计数</strong>:使用引用计数管理资源生命周期,避免内存泄漏</li> </ul> <p><strong>异步加载详解</strong>:</p> <p>YooAsset 支持协程、Task、委托等多种异步加载方式,开发者可以根据需求选择合适的加载方式。</p> <p><strong>多线程下载详解</strong>:</p> <p>YooAsset 支持多线程下载,可以显著提高下载速度。多线程下载是 YooAsset 的核心性能优化特性之一。</p> <p><strong>断点续传详解</strong>:</p> <p>YooAsset 支持断点续传,可以确保下载的稳定性和效率。断点续传是 YooAsset 的核心性能优化特性之一。</p> <p><strong>引用计数详解</strong>:</p> <p>YooAsset 使用引用计数管理资源生命周期,避免内存泄漏。引用计数是 YooAsset 的核心性能优化特性之一。</p> <h4 data-id="heading-12">4.2 YooAsset 性能 API</h4> <pre><code class="hljs language-csharp" lang="csharp"><span class="hljs-comment">// 异步加载资源</span> <span class="hljs-keyword">var</span> handle = package.LoadAssetAsync&lt;GameObject&gt;(<span class="hljs-string">"MyPrefab"</span>); <span class="hljs-keyword">yield</span> <span class="hljs-keyword">return</span> handle; GameObject prefab = handle.Asset; Instantiate(prefab); <span class="hljs-comment">// 异步加载场景</span> <span class="hljs-keyword">var</span> sceneHandle = package.LoadSceneAsync(<span class="hljs-string">"MyScene"</span>); <span class="hljs-keyword">yield</span> <span class="hljs-keyword">return</span> sceneHandle; <span class="hljs-comment">// 卸载资源</span> package.UnloadAssetAsync(handle); </code></pre> <p><strong>异步加载 API 详解</strong>:</p> <p>YooAsset 提供了多种异步加载 API,如 <code>LoadAssetAsync</code>、<code>LoadSceneAsync</code>、<code>UnloadAssetAsync</code> 等。这些 API 支持协程、Task、委托等多种异步加载方式。</p> <p><strong>异步加载场景 API 详解</strong>:</p> <p>YooAsset 提供了 <code>LoadSceneAsync</code> API,支持异步加载场景。异步加载场景可以避免阻塞主线程,提升用户体验。</p> <p><strong>卸载资源 API 详解</strong>:</p> <p>YooAsset 提供了 <code>UnloadAssetAsync</code> API,支持异步卸载资源。异步卸载资源可以释放内存,避免内存泄漏。</p> <hr> <h3 data-id="heading-13">五、性能监控</h3> <h4 data-id="heading-14">5.1 加载时间监控</h4> <p><strong>加载耗时详解</strong>:</p> <p>记录每个资源的加载时间,分析加载性能瓶颈。加载耗时是性能监控的核心指标之一。</p> <p><strong>瓶颈分析详解</strong>:</p> <p>分析加载过程中的瓶颈,提出优化建议。瓶颈分析是性能监控的重要手段。</p> <p><strong>优化建议详解</strong>:</p> <p>根据监控数据提出优化建议,持续优化加载性能。优化建议是性能监控的最终目标。</p> <h4 data-id="heading-15">5.2 内存监控</h4> <p><strong>内存占用详解</strong>:</p> <p>监控资源的内存占用,分析内存使用趋势。内存占用是性能监控的核心指标之一。</p> <p><strong>内存泄漏详解</strong>:</p> <p>检测内存泄漏,避免内存溢出。内存泄漏是性能监控的重要指标之一。</p> <p><strong>内存趋势详解</strong>:</p> <p>分析内存使用趋势,预测内存使用情况。内存趋势是性能监控的重要手段。</p> <h4 data-id="heading-16">5.3 性能分析工具</h4> <p><strong>Profiler 详解</strong>:</p> <p>Unity 内置的性能分析工具,可以实时监控内存使用情况、加载时间等性能指标。</p> <p><strong>AssetBundle Debugger 详解</strong>:</p> <p>可视化的 AB 包调试工具,可以查看 Bundle 的依赖关系、资源列表等信息。</p> <p><strong>自定义监控详解</strong>:</p> <p>根据需求自定义监控指标,满足特定场景的性能监控需求。</p> <hr> <h3 data-id="heading-17">六、总结</h3> <p>异步加载和性能优化是 Unity 资源管理中的重要技术。合理的异步加载策略可以提高游戏启动速度,减少加载等待时间,而有效的性能优化可以确保游戏流畅运行。</p> <p>在实际项目中,建议:</p> <ol> <li><strong>使用异步加载</strong>:避免阻塞主线程</li> <li><strong>合理使用缓存</strong>:充分利用内置缓存机制</li> <li><strong>及时卸载资源</strong>:在合适的时机卸载不再使用的资源</li> <li><strong>监控性能</strong>:使用 Profiler 监控性能</li> </ol> <p>通过深入理解异步加载和性能优化的原理,我们可以更好地利用 Unity 的资源管理功能,开发出性能更优、体验更好的游戏。</p> <hr> <blockquote> <p><strong>下一篇</strong>:<a href="https://link.juejin.cn/?target=..%2F03-%25E5%25AE%259E%25E6%2588%2598%25E7%25AF%2587%2F03-01-%25E5%25AE%259E%25E6%2588%2598%25E7%25AF%2587-%25E5%259F%25BA%25E7%25A1%2580-YooAsset%25E5%25BF%25AB%25E9%2580%259F%25E5%2585%25A5%25E9%2597%25A8.md" target="_blank" title="../03-%E5%AE%9E%E6%88%98%E7%AF%87/03-01-%E5%AE%9E%E6%88%98%E7%AF%87-%E5%9F%BA%E7%A1%80-YooAsset%E5%BF%AB%E9%80%9F%E5%85%A5%E9%97%A8.md" ref="nofollow noopener noreferrer">03-实战篇</a></p> </blockquote>

2026/9/23
阅读更多

面试被问 RAG 说不清楚?故事 + 代码带你吃透检索增强生成

<p>很多新手只会背 RAG = 检索/增强/生成,但根本说不清它的核心价值。本文用<strong>沉浸式真实业务故事 + 有无RAG强力对比 + 完整可跑代码</strong>,带你彻底弄懂:大模型幻觉是怎么产生的?为什么必须要用 RAG?RAG 到底约束了模型什么能力?看完彻底告别纸上谈兵!</p> <p><strong>适合人群</strong>:AI 入门、LangChain 新手、面试需要口述 RAG 原理、想彻底解决大模型幻觉问题的同学</p> <p><strong>学习收获</strong>:吃透 RAG 核心本质、理解幻觉成因、掌握新版 LangChain 实战、拥有可直接落地的知识库问答代码</p> <hr> <h2 data-id="heading-0">一、前言:绝大多数人都学错了 RAG</h2> <p>初学 AI 应用开发,大家都会死记硬背一句话:<strong>RAG = 检索 + 增强 + 生成</strong>。</p> <p>但面试官一问就露馅:</p> <ul> <li> <p>大模型为什么会产生幻觉?</p> </li> <li> <p>不加 RAG 会出现什么致命业务问题?</p> </li> <li> <p>RAG 到底是增强模型能力,还是约束模型能力?</p> </li> </ul> <p>很多人答不上来,因为只学了<strong>代码形式</strong>,没懂<strong>业务本质</strong>。</p> <p>今天我用一套<strong>虚构的企业产品场景</strong>,用最通俗的职场故事,带你彻底击穿 RAG 的底层逻辑,搭配可直接运行的实战代码,看完永久不忘。</p> <hr> <h2 data-id="heading-1">二、沉浸式场景故事:看懂 RAG 的真正价值</h2> <h3 data-id="heading-2">1. 业务背景</h3> <p>假设我们自研了一款旗舰户外储能设备:<strong>EcoStore X7</strong>。</p> <p>2026 年中,产品完成重大硬件迭代,官方参数全面更新:</p> <ul> <li> <p>旧版电池容量 1200Wh → <strong>新版升级 1800Wh</strong></p> </li> <li> <p>旧版整机质保 1 年 → <strong>新版升级 3 年</strong></p> </li> </ul> <p>全新产品手册属于<strong>企业 2026 私有最新数据</strong>。</p> <p>但市面上所有公开大模型的训练数据,<strong>全部截止到 2025 年</strong>。</p> <p>换句话说:<strong>大模型完全不知道我们这款新品的存在和最新参数。</strong></p> <h3 data-id="heading-3">2. 通俗角色人设(秒懂整套 RAG 架构)</h3> <p><strong>🧠 大模型 = 聪明绝顶,但记忆冻结、爱自作聪明的新实习生</strong></p> <p>这个实习生逻辑超强、话术完美、表达一流。但有两个致命短板:</p> <p>第一,<strong>知识停更</strong>,2026 年的新品资料完全空白;</p> <p>第二,<strong>死要面子</strong>,遇到不懂的问题从不承认,会凭借往年老产品的经验,<strong>自动推理、脑补出一套看似合理、实则错误的答案</strong>。</p> <p>这就是所有人都头疼的:<strong>AI 幻觉</strong>——不是乱答,是<strong>一本正经地胡说八道</strong>。</p> <p><strong>📚 FAISS 向量库 = 沉默严谨、绝不撒谎的档案室老管家</strong></p> <p>老管家没有创造力、不会脑补、不会润色话术。</p> <p>他只干一件事:死死保管公司<strong>最新官方产品手册</strong>,用户提问,他只检索、返回原文,不多一字、不少一字,绝对真实可靠。</p> <p>唯一缺点:只会找资料,不会组织语言回复客户。</p> <p><strong>🔤 Embedding 向量模型 = 双语翻译官</strong></p> <p>客户说自然语言,档案室只识别数字向量。</p> <p>翻译官负责把「人话」转成「机器向量」,打通检索通路,让老管家精准匹配对应的文档片段。</p> <h3 data-id="heading-4">3. 无 RAG 裸奔上线 = 致命业务翻车</h3> <p><strong>客户真实提问</strong>:EcoStore X7 的电池容量和整机质保是多少?</p> <p><strong>裸 LLM(无 RAG)幻觉输出</strong>:</p> <blockquote> <p>EcoStore X7 电池容量为 1200Wh,整机质保 1 年。</p> </blockquote> <p><strong>✅ 官方真实参数</strong>:1800Wh、整机质保 3 年</p> <p>这不是模型 bug,是<strong>模型太强导致的问题</strong>:</p> <p>它通过学习海量历史产品数据,统计推理出了一套「大概率参数」,语句通顺、逻辑自洽,<strong>但是完全不符合企业最新私有数据</strong>。</p> <p>如果直接上线客服、电商问答、企业知识库:</p> <ul> <li> <p>客户误以为产品减配,直接流失</p> </li> <li> <p>参数虚假宣传,引发平台处罚</p> </li> <li> <p>销售误判参数,造成合同纠纷与经济损失</p> </li> </ul> <h3 data-id="heading-5">4. 接入 RAG = 彻底根治幻觉</h3> <p>RAG 从来不是为了让模型更聪明,而是<strong>约束模型别乱聪明</strong>。</p> <p>它给实习生定下一条铁律:<strong>禁止凭记忆答题、禁止经验脑补、必须查阅最新官方档案再作答</strong>。</p> <p>完整工作流闭环:</p> <ol> <li> <p><strong>R(Retrieve 检索)</strong>:向量翻译官解析用户问题,档案室老管家精准捞出新品手册原文</p> </li> <li> <p><strong>A(Augment 增强)</strong>:将「用户问题 + 权威原文」强制注入提示词,锁死答案范围</p> </li> <li> <p><strong>G(Generate 生成)</strong>:大模型仅基于真实文档整理话术,无发挥、无脑补、无幻觉</p> </li> </ol> <hr> <h2 data-id="heading-6">三、核心原理总结(面试直接背)</h2> <p><strong>Q:大模型为什么会产生幻觉?</strong></p> <p>大模型具备极强的统计拟合与文本生成能力,面对<strong>训练数据之外的新知识、新文档、私有数据</strong>,会自动推理补全答案,最终输出「通顺但虚假」的内容。</p> <p><strong>Q:RAG 的核心本质是什么?</strong></p> <p>用<strong>外部真实权威数据</strong>弥补模型知识滞后的缺陷,用<strong>检索约束</strong>限制模型自由发挥,让 AI 从「凭想象答题」变成「凭事实答题」。</p> <hr> <h2 data-id="heading-7">四、实战开发:新版 LangChain 完整无报错代码</h2> <p>本次采用 <strong>最新 LangChain 官方 Runnable 管道写法</strong>,彻底废弃老旧报错 Chain API,适配阿里百炼 Embedding,解决 400 参数报错,可直接复制运行。</p> <h3 data-id="heading-8">1. 环境依赖安装</h3> <pre><code class="hljs language-Plain" lang="Plain">uv add dashscope langchain langchain-core langchain-openai langchain-text-splitters langchain-community python-dotenv faiss-cpu uv sync </code></pre> <h3 data-id="heading-9">2. 项目 .env 配置</h3> <pre><code class="hljs language-Plain" lang="Plain">DASHSCOPE_API_KEY=你的阿里百炼API_KEY </code></pre> <h3 data-id="heading-10">3. 完整可运行代码(有无 RAG 双向对比)</h3> <pre><code class="hljs language-python" lang="python"><span class="hljs-keyword">import</span> os <span class="hljs-keyword">import</span> warnings <span class="hljs-comment"># 屏蔽老旧包废弃警告,控制台干净清爽</span> warnings.filterwarnings(<span class="hljs-string">"ignore"</span>, category=DeprecationWarning) <span class="hljs-keyword">from</span> dotenv <span class="hljs-keyword">import</span> load_dotenv <span class="hljs-keyword">from</span> langchain_openai <span class="hljs-keyword">import</span> ChatOpenAI <span class="hljs-keyword">from</span> langchain_community.embeddings <span class="hljs-keyword">import</span> DashScopeEmbeddings <span class="hljs-keyword">from</span> langchain_text_splitters <span class="hljs-keyword">import</span> CharacterTextSplitter <span class="hljs-keyword">from</span> langchain_community.vectorstores <span class="hljs-keyword">import</span> FAISS <span class="hljs-keyword">from</span> langchain_core.prompts <span class="hljs-keyword">import</span> PromptTemplate <span class="hljs-keyword">from</span> langchain_core.runnables <span class="hljs-keyword">import</span> RunnablePassthrough <span class="hljs-keyword">from</span> langchain_core.output_parsers <span class="hljs-keyword">import</span> StrOutputParser <span class="hljs-comment"># 加载环境变量</span> load_dotenv() DASHSCOPE_API_KEY = os.getenv(<span class="hljs-string">"DASHSCOPE_API_KEY"</span>) <span class="hljs-comment"># 1. 初始化大模型(知识截止2025,易产生幻觉) </span> llm = ChatOpenAI( model=<span class="hljs-string">"qwen-plus"</span>, openai_api_key=DASHSCOPE_API_KEY, openai_api_base=<span class="hljs-string">"https://dashscope.aliyuncs.com/compatible-mode/v1"</span>, temperature=<span class="hljs-number">0</span>, ) <span class="hljs-comment"># 2. 纯裸LLM问答(无RAG,幻觉重灾区)</span> <span class="hljs-keyword">def</span> <span class="hljs-title function_">llm_no_rag</span>(<span class="hljs-params">question: <span class="hljs-built_in">str</span></span>) -&gt; <span class="hljs-built_in">str</span>: <span class="hljs-string">"""不经过检索,大模型直接凭旧知识推理回答"""</span> <span class="hljs-keyword">return</span> llm.invoke(<span class="hljs-string">f"用户产品问题:<span class="hljs-subst">{question}</span>"</span>).content <span class="hljs-comment"># 3. 搭建完整RAG检索增强链路 </span> <span class="hljs-comment"># 向量翻译官:适配阿里百炼,彻底解决400参数报错</span> embedding = DashScopeEmbeddings( model=<span class="hljs-string">"text-embedding-v2"</span>, dashscope_api_key=DASHSCOPE_API_KEY ) <span class="hljs-comment"># 企业私有知识库:2026虚构新品手册</span> knowledge_text = <span class="hljs-string">""" EcoStore X7户外储能电源产品手册【2026最新版】 1. 电池容量:1800Wh,磷酸铁锂电池,循环寿命≥6000次。 2. 输出规格:交流最大输出功率2200W,支持快充。 3. 质保政策:整机质保3年,电池模组质保5年。 4. 工作温度:-20℃ ~ +55℃。 5. 净重:21kg,自带提手,户外便携。 """</span> <span class="hljs-comment"># 文档切分:拆分长文本,提升检索精准度</span> text_splitter = CharacterTextSplitter(chunk_size=<span class="hljs-number">120</span>, chunk_overlap=<span class="hljs-number">20</span>, separator=<span class="hljs-string">"\n"</span>) docs = text_splitter.create_documents([knowledge_text]) <span class="hljs-comment"># FAISS本地向量库构建</span> vector_store = FAISS.from_documents(docs, embedding) retriever = vector_store.as_retriever(k=<span class="hljs-number">2</span>) <span class="hljs-comment"># 强约束Prompt:严禁脑补,只认官方文档</span> prompt = PromptTemplate( template=<span class="hljs-string">""" 你是专业严谨的产品咨询客服,严禁依靠自身知识库猜测、脑补参数。 请严格依据下方参考资料回答用户问题,资料无相关信息则如实告知。 若无对应信息,直接回复:暂无该参数的官方信息,请咨询产品经理。 参考资料:{context} 用户问题:{question} """</span>, input_variables=[<span class="hljs-string">"context"</span>, <span class="hljs-string">"question"</span>] ) <span class="hljs-comment"># 文档拼接工具</span> <span class="hljs-keyword">def</span> <span class="hljs-title function_">format_docs</span>(<span class="hljs-params">docs</span>): <span class="hljs-keyword">return</span> <span class="hljs-string">"\n"</span>.join([d.page_content <span class="hljs-keyword">for</span> d <span class="hljs-keyword">in</span> docs]) <span class="hljs-comment"># RAG链路</span> rag_chain = ( {<span class="hljs-string">"context"</span>: retriever | format_docs, <span class="hljs-string">"question"</span>: RunnablePassthrough()} | prompt | llm | StrOutputParser() ) <span class="hljs-comment"># 4. 双场景对比测试</span> <span class="hljs-keyword">if</span> __name__ == <span class="hljs-string">"__main__"</span>: <span class="hljs-comment"># 场景1:查询模型知识外的新版参数</span> <span class="hljs-built_in">print</span>(<span class="hljs-string">"场景1:查询2025新版更新参数"</span>) q1 = <span class="hljs-string">"EcoStore X7电池容量和整机质保是多少?"</span> <span class="hljs-built_in">print</span>(<span class="hljs-string">"❌ 无RAG裸模型回答:"</span>, llm_no_rag(q1)) <span class="hljs-built_in">print</span>(<span class="hljs-string">"✅ 有RAG检索精准回答:"</span>, rag_chain.invoke(q1)) <span class="hljs-comment"># 场景2:查询手册未收录参数,测试幻觉抑制能力</span> <span class="hljs-built_in">print</span>(<span class="hljs-string">"\n===== 场景2:查询手册未记载参数 ====="</span>) q2 = <span class="hljs-string">"EcoStore X7支持太阳能最大充电功率多少瓦?"</span> <span class="hljs-built_in">print</span>(<span class="hljs-string">"❌ 无RAG裸模型回答:"</span>, llm_no_rag(q2)) <span class="hljs-built_in">print</span>(<span class="hljs-string">"✅ 有RAG诚实回答:"</span>, rag_chain.invoke(q2)) </code></pre> <hr> <h2 data-id="heading-11">五、运行结果直观对比(冲击力拉满)</h2> <h3 data-id="heading-12">场景一:查询模型未知的新版参数</h3> <p><strong>❌ 无 RAG(幻觉出错)</strong></p> <p>EcoStore X7电池容量为1200Wh,整机质保1年。</p> <p><strong>✅ 有 RAG(精准无误)</strong></p> <p>EcoStore X7户外储能电源电池容量为1800Wh,整机质保3年。</p> <h3 data-id="heading-13">场景二:查询文档不存在的参数</h3> <p><strong>❌ 无 RAG(强行脑补)</strong></p> <p>该设备支持太阳能充电,最大可支持800W太阳能板输入。</p> <p><strong>✅ 有 RAG(杜绝幻觉)</strong></p> <p>暂无该参数的官方信息,请咨询产品经理。</p> <hr> <h2 data-id="heading-14">六、架构角色一一对应(面试口述满分素材)</h2> <table><thead><tr><th>代码模块</th><th>故事角色</th><th>核心作用</th></tr></thead><tbody><tr><td>ChatOpenAI</td><td>爱脑补的聪明实习生</td><td>擅长语言生成,但知识滞后,易产生幻觉</td></tr><tr><td>DashScopeEmbeddings</td><td>向量翻译官</td><td>打通自然语言与向量,实现精准检索</td></tr><tr><td>CharacterTextSplitter</td><td>文档整理员</td><td>切割长文本,优化检索精度</td></tr><tr><td>FAISS+Retriever</td><td>档案室老管家</td><td>存储权威文档,提供真实事实依据</td></tr><tr><td>PromptTemplate</td><td>企业规章制度</td><td>强制约束模型,禁止脑补编造</td></tr><tr><td>RAG 链路</td><td>标准化工作流</td><td>先检索事实,再生成回答,根治幻觉</td></tr></tbody></table> <hr> <h2 data-id="heading-15">七、文末总结</h2> <p>很多人误解 RAG 是「提升模型智商」,其实它的核心价值是<strong>规范模型行为</strong>。</p> <p>大模型的幻觉,源于<strong>能力太强、知识滞后、无人约束</strong>。</p> <p>而 RAG 就是最好的解决方案:<strong>用最新的私有事实弥补知识缺口,用检索机制约束自由发挥</strong>。</p> <p><strong>无 RAG,AI 靠想象说话;有 RAG,AI 靠事实说话。</strong></p> <hr> <p><strong>欢迎点赞、收藏、关注,持续更新 AI 落地实战教程!</strong></p>

2026/9/23
阅读更多

ref、reactive、toRefs 到底该用哪个?我把三个反例都写了一遍

<h2 data-id="heading-0">ref、reactive、toRefs 到底该用哪个?我把三个反例都写了一遍</h2> <blockquote> <p>摘要:ref 和 reactive 不是"哪个更好",是设计前提不同。三个反例,把 toRefs 为什么存在讲透。</p> </blockquote> <hr> <p>上一篇《我重写了 Vue3 的响应式》里,我讲了 ref 为什么必须 <code>.value</code>——那是从"语言层面"解释的。这篇换个角度:实际项目里,ref、reactive、toRefs 到底该怎么选。</p> <p><img src="https://imgproxy.citrusreader.com/ipUNgjVdLiKDk61QfBRZ2xTIkgCrOs2O9cQGw08Kilk/raw:1/aHR0cHM6Ly9wOS14dGpqLXNpZ24uYnl0ZWltZy5jb20vdG9zLWNuLWktNzNvd2p5bWRrNi81Nzg3NmYxZTc0ZmI0ZTE0YjFkN2JiYzFjNWI4NDc3Nn50cGx2LTczb3dqeW1kazYtamotbWFyay12MTowOjA6MDowOjVvNlk2WWVSNW9xQTVweXY1NlMtNVl5NklFQWdRMjl3ZVVOdlpHVT06cTc1LmF3ZWJwP3JrM3M9ZjY0YWIxNWImeC1leHBpcmVzPTE3OTA3Mzc1MzgmeC1zaWduYXR1cmU9MGwlMkY4M0txSyUyQiUyQmlWSVFTZ0ZJWVFUY2hvT0djJTNE" alt="image.png" loading="lazy" referrerpolicy="no-referrer"></p> <hr> <h3 data-id="heading-1">先给结论</h3> <p>这三个 API 不是"三选一"的关系,它们解决的是<strong>三个不同层面的问题</strong>:</p> <table><thead><tr><th>API</th><th>解决的问题</th></tr></thead><tbody><tr><td><code>reactive</code></td><td>把一个<strong>对象</strong>变成响应式</td></tr><tr><td><code>ref</code></td><td>把<strong>任意值</strong>(包括基本类型)变成响应式</td></tr><tr><td><code>toRefs</code></td><td>让 reactive 对象的属性<strong>解构之后仍然是响应式</strong></td></tr></tbody></table> <p>所以"ref 还是 reactive"这个问题本身问错了——它们不是竞品,是互补的。</p> <p>真正需要记住的是这句话:<strong>对象用 <code>reactive</code> 少写 <code>.value</code>;团队若统一风格,全用 <code>ref</code> 也完全合理。选型看场景,不是谁更高级。<code>reactive</code> 有两个绕不开的限制,所以 <code>ref</code> 往往更通用;而 <code>toRefs</code> 解决的是一个完全独立的问题:解构。</strong></p> <hr> <h3 data-id="heading-2">reactive 的两个致命限制</h3> <h4 data-id="heading-3">限制 1:reactive 不能接基本类型</h4> <pre><code class="hljs language-js" lang="js"><span class="hljs-keyword">const</span> count = <span class="hljs-title function_">reactive</span>(<span class="hljs-number">0</span>) <span class="hljs-comment">// ❌ 控制台警告:value cannot be made reactive</span> </code></pre> <p>原因上一篇讲过了:<code>Proxy</code> 的第一个参数必须是对象。基本类型(数字、字符串、布尔)没法被代理。</p> <p>所以基本类型只能用 <code>ref</code>:</p> <pre><code class="hljs language-js" lang="js"><span class="hljs-keyword">const</span> count = <span class="hljs-title function_">ref</span>(<span class="hljs-number">0</span>) <span class="hljs-comment">// ✅ 包了一层 { value: 0 }</span> </code></pre> <h4 data-id="heading-4">限制 2:reactive 解构后会丢响应式</h4> <p>这是 90% 的人踩过的坑,也是 <code>toRefs</code> 最常见的存在理由(后面还会看到:拆给组合式函数、读 props 也是):</p> <pre><code class="hljs language-js" lang="js"><span class="hljs-keyword">const</span> state = <span class="hljs-title function_">reactive</span>({ <span class="hljs-attr">count</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">name</span>: <span class="hljs-string">'Vue'</span> }) <span class="hljs-comment">// 解构(用 let 才能 ++;用 const 解构后无法重新赋值)</span> <span class="hljs-keyword">let</span> { count, name } = state count++ <span class="hljs-comment">// ❌ 视图不会更新</span> </code></pre> <p>为什么?因为解构出来的 <code>count</code> 是一个<strong>普通的数字 0</strong>,它和原来的 <code>state.count</code> 已经没有关系了。<code>state.count</code> 是被 Proxy 拦截的属性,但 <code>count</code> 只是一个值拷贝。</p> <pre><code class="hljs language-mermaid" lang="mermaid">flowchart LR subgraph 解构 S1[reactive state] --&gt;|解构| C1[&amp;#34;count: 0&lt;br/&gt;❌ 普通值,断开连接&amp;#34;] end subgraph toRefs S2[reactive state] --&gt;|toRefs| C2[&amp;#34;count: ref&lt;br/&gt;✅ 指向原对象&amp;#34;] end </code></pre> <p><code>toRefs</code> 做的事不是"把 reactive 转成 ref",而是把 reactive 的每个属性转成一个<strong>指向原对象的 ref</strong>——你改这个 ref,改的还是原对象。</p> <pre><code class="hljs language-js" lang="js"><span class="hljs-keyword">const</span> state = <span class="hljs-title function_">reactive</span>({ <span class="hljs-attr">count</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">name</span>: <span class="hljs-string">'Vue'</span> }) <span class="hljs-keyword">const</span> { count, name } = <span class="hljs-title function_">toRefs</span>(state) count.<span class="hljs-property">value</span>++ <span class="hljs-comment">// ✅ 视图会更新</span> </code></pre> <hr> <h3 data-id="heading-5">三个反例</h3> <h4 data-id="heading-6">反例 1:以为 reactive 解构一定会丢响应式</h4> <pre><code class="hljs language-js" lang="js"><span class="hljs-keyword">const</span> state = <span class="hljs-title function_">reactive</span>({ <span class="hljs-attr">userInfo</span>: { <span class="hljs-attr">name</span>: <span class="hljs-string">''</span> } }) <span class="hljs-keyword">const</span> { userInfo } = state userInfo.<span class="hljs-property">name</span> = <span class="hljs-string">'xxx'</span> <span class="hljs-comment">// ✅ 会更新</span> </code></pre> <p>这个会更新。因为 <code>userInfo</code> 是一个对象的<strong>引用</strong>,解构出来的是同一个对象的引用。</p> <p>但:</p> <pre><code class="hljs language-js" lang="js"><span class="hljs-keyword">const</span> state = <span class="hljs-title function_">reactive</span>({ <span class="hljs-attr">count</span>: <span class="hljs-number">0</span> }) <span class="hljs-keyword">let</span> { count } = state count++ <span class="hljs-comment">// ❌ 不会更新</span> </code></pre> <p><strong>解构基本类型会丢,解构对象引用不会。</strong> 这个差异很多人没意识到。</p> <p><img src="https://imgproxy.citrusreader.com/m9T7LCAc5HAV8yOiaOjcWHiynV6Wv8TXxblgo_UOaWg/raw:1/aHR0cHM6Ly9wOS14dGpqLXNpZ24uYnl0ZWltZy5jb20vdG9zLWNuLWktNzNvd2p5bWRrNi8xOWRjYTY3N2ExOTU0Y2M0OWZkYmU3ZmU3YjI2NTljZn50cGx2LTczb3dqeW1kazYtamotbWFyay12MTowOjA6MDowOjVvNlk2WWVSNW9xQTVweXY1NlMtNVl5NklFQWdRMjl3ZVVOdlpHVT06cTc1LmF3ZWJwP3JrM3M9ZjY0YWIxNWImeC1leHBpcmVzPTE3OTA3Mzc1MzgmeC1zaWduYXR1cmU9cDFYdFJDSFE0Q1lVS2RmMWtWcEx6VkV4MjlZJTNE" alt="image.png" loading="lazy" referrerpolicy="no-referrer"></p> <h4 data-id="heading-7">反例 2:ref 用在对象上,多写了一层 .value</h4> <pre><code class="hljs language-js" lang="js"><span class="hljs-comment">// ❌ 不是不能用,只是脚本里多一层 .value</span> <span class="hljs-keyword">const</span> user = <span class="hljs-title function_">ref</span>({ <span class="hljs-attr">name</span>: <span class="hljs-string">'Vue'</span>, <span class="hljs-attr">age</span>: <span class="hljs-number">3</span> }) user.<span class="hljs-property">value</span>.<span class="hljs-property">name</span> = <span class="hljs-string">'React'</span> <span class="hljs-comment">// 只有一层 .value,再访问属性</span> </code></pre> <p>对象直接用 <code>reactive</code> 更省事:</p> <pre><code class="hljs language-js" lang="js"><span class="hljs-comment">// ✅</span> <span class="hljs-keyword">const</span> user = <span class="hljs-title function_">reactive</span>({ <span class="hljs-attr">name</span>: <span class="hljs-string">'Vue'</span>, <span class="hljs-attr">age</span>: <span class="hljs-number">3</span> }) user.<span class="hljs-property">name</span> = <span class="hljs-string">'React'</span> </code></pre> <p>补充:模板里 <code>ref</code> 会自动解包,写 <code>user.name</code> 就行,不用写 <code>user.value.name</code>。多出来的 <code>.value</code> 主要烦在 <strong>script</strong> 里。</p> <p><img src="https://imgproxy.citrusreader.com/YrbFnQ0Hu0Zl9eyKuqG2Q6zxfyMYQZ6bg92oGLhNrEk/raw:1/aHR0cHM6Ly9wOS14dGpqLXNpZ24uYnl0ZWltZy5jb20vdG9zLWNuLWktNzNvd2p5bWRrNi9hYWI2ZTdiNDU4YmM0ZmU1OGI4Mjc4MTE2YmZmNjE0NX50cGx2LTczb3dqeW1kazYtamotbWFyay12MTowOjA6MDowOjVvNlk2WWVSNW9xQTVweXY1NlMtNVl5NklFQWdRMjl3ZVVOdlpHVT06cTc1LmF3ZWJwP3JrM3M9ZjY0YWIxNWImeC1leHBpcmVzPTE3OTA3Mzc1MzgmeC1zaWduYXR1cmU9VSUyQjNkTjJuSGladkpTS1N0ZFI5eVdVNyUyRllERSUzRA" alt="image.png" loading="lazy" referrerpolicy="no-referrer"></p> <h4 data-id="heading-8">反例 3:return 时把 reactive 拆散了,模板丢响应式</h4> <p>容易踩的不是「模板要不要写 <code>.value</code>」(模板里 <code>ref</code> 会自动解包),而是 <strong>return 时把响应式拆坏了</strong>:</p> <pre><code class="hljs language-js" lang="js"><span class="hljs-comment">// ❌ 展开 / 解构后再 return,拿到的是普通值</span> <span class="hljs-keyword">const</span> state = <span class="hljs-title function_">reactive</span>({ <span class="hljs-attr">count</span>: <span class="hljs-number">0</span> }) <span class="hljs-keyword">return</span> { ...state } <span class="hljs-comment">// 或:const { count } = state; return { count }</span> </code></pre> <pre><code class="hljs language-html" lang="html"><span class="hljs-comment">&lt;!-- 点按钮改 count,视图不会更新 --&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">button</span> @<span class="hljs-attr">click</span>=<span class="hljs-string">"count++"</span>&gt;</span>{{ count }}<span class="hljs-tag">&lt;/<span class="hljs-name">button</span>&gt;</span> </code></pre> <p>正确写法:把整个代理 return 出去,或用 <code>toRefs</code> 保住连接:</p> <pre><code class="hljs language-js" lang="js"><span class="hljs-comment">// ✅ 方案 A:整体 return</span> <span class="hljs-keyword">const</span> state = <span class="hljs-title function_">reactive</span>({ <span class="hljs-attr">count</span>: <span class="hljs-number">0</span> }) <span class="hljs-keyword">return</span> { state } <span class="hljs-comment">// 模板:state.count</span> <span class="hljs-comment">// ✅ 方案 B:要解构就用 toRefs</span> <span class="hljs-keyword">const</span> state = <span class="hljs-title function_">reactive</span>({ <span class="hljs-attr">count</span>: <span class="hljs-number">0</span> }) <span class="hljs-keyword">return</span> { ...<span class="hljs-title function_">toRefs</span>(state) } <span class="hljs-comment">// 模板:count(自动解包,不用 .value)</span> </code></pre> <hr> <h3 data-id="heading-9">到底该用哪个?</h3> <table><thead><tr><th>场景</th><th>推荐</th><th>理由</th></tr></thead><tbody><tr><td>基本类型(数字、字符串、布尔)</td><td><code>ref</code></td><td><code>reactive</code> 接不了</td></tr><tr><td>纯对象、表单数据</td><td><code>reactive</code> 或 <code>ref</code></td><td>前者少写 <code>.value</code>;团队统一用 <code>ref</code> 也行</td></tr><tr><td>需要解构给子组件/模板</td><td><code>toRefs(state)</code></td><td>保证解构后仍是响应式</td></tr><tr><td>需要整体替换对象</td><td><code>ref</code></td><td><code>reactive</code> 替换变量会丢代理</td></tr><tr><td>传给组合式函数</td><td><code>ref</code> 或 <code>toRefs</code></td><td>组合函数通常按 ref 的 <code>.value</code> 来读</td></tr></tbody></table> <p><strong>再补一条隐藏坑</strong>:<code>reactive</code> 如果整体替换变量指向,会丢掉原来的代理。</p> <pre><code class="hljs language-js" lang="js"><span class="hljs-keyword">let</span> state = <span class="hljs-title function_">reactive</span>({ <span class="hljs-attr">count</span>: <span class="hljs-number">0</span> }) state = { <span class="hljs-attr">count</span>: <span class="hljs-number">1</span> } <span class="hljs-comment">// ❌ 变量指向了普通对象,代理断了</span> <span class="hljs-comment">// ❌ 这样也不完美:新变量是响应式的,但别处还握着的旧代理已经对不上了</span> state = <span class="hljs-title function_">reactive</span>({ <span class="hljs-attr">count</span>: <span class="hljs-number">1</span> }) <span class="hljs-comment">// ✅ 原地改,代理还在</span> <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">assign</span>(state, { <span class="hljs-attr">count</span>: <span class="hljs-number">1</span> }) <span class="hljs-comment">// ✅ 或者一开始就用 ref,整体替换 .value</span> <span class="hljs-keyword">const</span> stateRef = <span class="hljs-title function_">ref</span>({ <span class="hljs-attr">count</span>: <span class="hljs-number">0</span> }) stateRef.<span class="hljs-property">value</span> = { <span class="hljs-attr">count</span>: <span class="hljs-number">1</span> } <span class="hljs-comment">// 仍然是响应式</span> </code></pre> <p><code>ref</code> 之所以更通用,是因为它把「值的引用」也包进了对象里——替换 <code>.value</code> 不会丢失响应式;<code>reactive</code> 更适合「同一个对象上改属性」,不适合「整份对象换掉」。</p> <hr> <h3 data-id="heading-10">toRefs 的进阶用法</h3> <h4 data-id="heading-11">用法 1:把 reactive 拆成多个 ref 传给组合式函数</h4> <pre><code class="hljs language-js" lang="js"><span class="hljs-keyword">function</span> <span class="hljs-title function_">useCounter</span>(<span class="hljs-params">countRef</span>) { <span class="hljs-keyword">const</span> double = <span class="hljs-title function_">computed</span>(<span class="hljs-function">() =&gt;</span> countRef.<span class="hljs-property">value</span> * <span class="hljs-number">2</span>) <span class="hljs-keyword">return</span> { double } } <span class="hljs-keyword">const</span> state = <span class="hljs-title function_">reactive</span>({ <span class="hljs-attr">count</span>: <span class="hljs-number">0</span> }) <span class="hljs-keyword">const</span> { count } = <span class="hljs-title function_">toRefs</span>(state) <span class="hljs-keyword">const</span> { double } = <span class="hljs-title function_">useCounter</span>(count) </code></pre> <h4 data-id="heading-12">用法 2:toRef 和 toRefs 的区别</h4> <ul> <li><code>toRef(obj, 'key')</code> —— 单个属性转 ref</li> <li><code>toRefs(obj)</code> —— 所有属性转 ref</li> </ul> <h4 data-id="heading-13">用法 3:toRef 可以用来读 props 的某个属性</h4> <pre><code class="hljs language-js" lang="js"><span class="hljs-keyword">const</span> props = <span class="hljs-title function_">defineProps</span>([<span class="hljs-string">'count'</span>]) <span class="hljs-keyword">const</span> countRef = <span class="hljs-title function_">toRef</span>(props, <span class="hljs-string">'count'</span>) <span class="hljs-comment">// ✅ 保持响应式</span> <span class="hljs-comment">// 直接解构 props.count 会丢响应式</span> </code></pre> <pre><code class="hljs language-mermaid" lang="mermaid">flowchart TB R[reactive state] --&gt;|toRefs| T1[&amp;#34;count: ref&amp;#34;] R --&gt;|toRefs| T2[&amp;#34;name: ref&amp;#34;] T1 -.-&gt;|&amp;#34;.value 读写&amp;#34;| R T2 -.-&gt;|&amp;#34;.value 读写&amp;#34;| R </code></pre> <hr> <h3 data-id="heading-14">总结 + 面试速答</h3> <p>如果只记三件事:</p> <ol> <li><strong><code>reactive</code> 和 <code>ref</code> 不是"二选一"</strong>,是前者只接对象、后者接任意值</li> <li><strong><code>toRefs</code> 解决的不是"用哪个"</strong>,而是解构后响应式会丢这个独立问题</li> <li><strong><code>ref</code> 比 <code>reactive</code> 更通用</strong>,因为 reactive 不能整体替换、不能接基本类型</li> </ol> <p><strong>面试速答版(可以直接背):</strong></p> <blockquote> <p><code>reactive</code> 基于 Proxy,只能接受对象,返回的是代理对象;<code>ref</code> 通过 <code>{ value }</code> 包装可以支持任意类型,所以基本类型只能用 <code>ref</code>。</p> <p><code>toRefs</code> 的作用是把 <code>reactive</code> 的每个属性转换成指向原对象的 <code>ref</code>,解决解构后响应式丢失的问题。如果直接解构 <code>reactive</code> 返回的普通值,就会变成一个脱离响应式系统的普通变量。</p> <p>选择上:对象用 <code>reactive</code> 更简洁(少写 <code>.value</code>),基本类型和需要整体替换的场景用 <code>ref</code>;团队若追求风格统一,全用 <code>ref</code> 也合理。需要解构给子组件或组合式函数时用 <code>toRefs</code>。</p> </blockquote> <hr> <h3 data-id="heading-15">参考</h3> <ul> <li>Vue 3 官方文档 — 响应式基础(<a href="https://link.juejin.cn/?target=https%3A%2F%2Fcn.vuejs.org%2Fguide%2Fessentials%2Freactivity-fundamentals.html" target="_blank" title="https://cn.vuejs.org/guide/essentials/reactivity-fundamentals.html" ref="nofollow noopener noreferrer">cn.vuejs.org/guide/essen…</a>)</li> <li>Vue 3 官方文档 — 响应式 API:核心(<a href="https://link.juejin.cn/?target=https%3A%2F%2Fcn.vuejs.org%2Fapi%2Freactivity-core.html" target="_blank" title="https://cn.vuejs.org/api/reactivity-core.html" ref="nofollow noopener noreferrer">cn.vuejs.org/api/reactiv…</a>)</li> <li>Vue 3 官方文档 — 响应式 API:工具函数(<a href="https://link.juejin.cn/?target=https%3A%2F%2Fcn.vuejs.org%2Fapi%2Freactivity-utilities.html" target="_blank" title="https://cn.vuejs.org/api/reactivity-utilities.html" ref="nofollow noopener noreferrer">cn.vuejs.org/api/reactiv…</a>)</li> </ul>

2026/9/23
阅读更多

第 3 章:工作台——数据看板

<h2 data-id="heading-0">本章学习目标</h2> <ul> <li>学会设计数据看板类页面的布局</li> <li>掌握统计卡片组件的实现(数字动效、趋势指标)</li> <li>集成 ECharts 实现趋势图、饼图、热力图</li> <li>实现待办队列(Tab 切换 + 虚拟列表)</li> <li>了解 Mock 数据与真实接口切换方案</li> </ul> <hr> <h2 data-id="heading-1">3.1 需求分析与布局设计</h2> <p>工作台是用户登录后看到的第一个页面,承担「数据总览」和「快速入口」两个核心职能。</p> <h3 data-id="heading-2">3.1.1 页面结构</h3> <pre><code class="hljs language-bash" lang="bash">┌──────────────────────────────────────────────────┐ │ 顶部:欢迎语 + 快捷操作按钮 │ ├──────────┬───────────────────────────────────────┤ │ │ ┌───────────────────────────────┐ │ │ 待办 │ │ 统计卡片组(4 个) │ │ │ 队列 │ ├───────────────────────────────┤ │ │ (左侧) │ │ 趋势图(折线图) │ │ │ │ ├───────────────────────────────┤ │ │ │ │ 分类分布(饼) │ 团队效率(柱) │ │ │ │ └───────────────────────────────┘ │ └──────────┴───────────────────────────────────────┘ </code></pre> <h3 data-id="heading-3">3.1.2 布局实现</h3> <p>使用 Ant Design 的 Row + Col 栅格系统:</p> <pre><code class="hljs language-tsx" lang="tsx"><span class="hljs-comment">// src/pages/workbench/index.tsx</span> <span class="hljs-keyword">import</span> { <span class="hljs-title class_">Row</span>, <span class="hljs-title class_">Col</span>, <span class="hljs-title class_">Card</span>, <span class="hljs-title class_">Typography</span>, <span class="hljs-title class_">Space</span>, <span class="hljs-title class_">Button</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'antd'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">PlusOutlined</span>, <span class="hljs-title class_">BellOutlined</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@ant-design/icons'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">StatCard</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'@components/StatCard'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">TodoList</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./components/TodoList'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">TrendChart</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./components/TrendChart'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">CategoryPie</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./components/CategoryPie'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">TeamRanking</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./components/TeamRanking'</span>; <span class="hljs-keyword">import</span> { useWorkbenchStore } <span class="hljs-keyword">from</span> <span class="hljs-string">'./store'</span>; <span class="hljs-keyword">const</span> { <span class="hljs-title class_">Title</span> } = <span class="hljs-title class_">Typography</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Workbench</span>(<span class="hljs-params"></span>) { <span class="hljs-keyword">const</span> stats = <span class="hljs-title function_">useWorkbenchStore</span>(<span class="hljs-function">(<span class="hljs-params">s</span>) =&gt;</span> s.<span class="hljs-property">stats</span>); <span class="hljs-keyword">const</span> userInfo = <span class="hljs-title function_">useUserStore</span>(<span class="hljs-function">(<span class="hljs-params">s</span>) =&gt;</span> s.<span class="hljs-property">userInfo</span>); <span class="hljs-keyword">return</span> ( <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">padding:</span> <span class="hljs-attr">24</span> }}&gt;</span> {/* 顶部欢迎区 */} <span class="hljs-tag">&lt;<span class="hljs-name">Card</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">marginBottom:</span> <span class="hljs-attr">16</span> }}&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Space</span> <span class="hljs-attr">size</span>=<span class="hljs-string">"large"</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">width:</span> '<span class="hljs-attr">100</span>%', <span class="hljs-attr">justifyContent:</span> '<span class="hljs-attr">space-between</span>' }}&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Title</span> <span class="hljs-attr">level</span>=<span class="hljs-string">{3}</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">margin:</span> <span class="hljs-attr">0</span> }}&gt;</span> 你好,{userInfo?.name || '用户'} 👋 <span class="hljs-tag">&lt;/<span class="hljs-name">Title</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">p</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">color:</span> '#<span class="hljs-attr">999</span>', <span class="hljs-attr">margin:</span> '<span class="hljs-attr">8px</span> <span class="hljs-attr">0</span> <span class="hljs-attr">0</span>' }}&gt;</span> 今天是 {new Date().toLocaleDateString('zh-CN')},祝你工作顺利 <span class="hljs-tag">&lt;/<span class="hljs-name">p</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Space</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Button</span> <span class="hljs-attr">icon</span>=<span class="hljs-string">{</span>&lt;<span class="hljs-attr">BellOutlined</span> /&gt;</span>}&gt;消息中心<span class="hljs-tag">&lt;/<span class="hljs-name">Button</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Button</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"primary"</span> <span class="hljs-attr">icon</span>=<span class="hljs-string">{</span>&lt;<span class="hljs-attr">PlusOutlined</span> /&gt;</span>}&gt; 新建任务单 <span class="hljs-tag">&lt;/<span class="hljs-name">Button</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Space</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Space</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Card</span>&gt;</span> {/* 主体双栏布局 */} <span class="hljs-tag">&lt;<span class="hljs-name">Row</span> <span class="hljs-attr">gutter</span>=<span class="hljs-string">{16}</span>&gt;</span> {/* 左侧待办队列 */} <span class="hljs-tag">&lt;<span class="hljs-name">Col</span> <span class="hljs-attr">span</span>=<span class="hljs-string">{6}</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">TodoList</span> /&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Col</span>&gt;</span> {/* 右侧数据看板 */} <span class="hljs-tag">&lt;<span class="hljs-name">Col</span> <span class="hljs-attr">span</span>=<span class="hljs-string">{18}</span>&gt;</span> {/* 统计卡片 */} <span class="hljs-tag">&lt;<span class="hljs-name">Row</span> <span class="hljs-attr">gutter</span>=<span class="hljs-string">{16}</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">marginBottom:</span> <span class="hljs-attr">16</span> }}&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Col</span> <span class="hljs-attr">span</span>=<span class="hljs-string">{6}</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">StatCard</span> <span class="hljs-attr">title</span>=<span class="hljs-string">"待处理"</span> <span class="hljs-attr">value</span>=<span class="hljs-string">{stats.pending}</span> <span class="hljs-attr">trend</span>=<span class="hljs-string">{12}</span> /&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Col</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Col</span> <span class="hljs-attr">span</span>=<span class="hljs-string">{6}</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">StatCard</span> <span class="hljs-attr">title</span>=<span class="hljs-string">"处理中"</span> <span class="hljs-attr">value</span>=<span class="hljs-string">{stats.processing}</span> <span class="hljs-attr">trend</span>=<span class="hljs-string">{-5}</span> /&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Col</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Col</span> <span class="hljs-attr">span</span>=<span class="hljs-string">{6}</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">StatCard</span> <span class="hljs-attr">title</span>=<span class="hljs-string">"已完成"</span> <span class="hljs-attr">value</span>=<span class="hljs-string">{stats.resolved}</span> <span class="hljs-attr">trend</span>=<span class="hljs-string">{23}</span> /&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Col</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Col</span> <span class="hljs-attr">span</span>=<span class="hljs-string">{6}</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">StatCard</span> <span class="hljs-attr">title</span>=<span class="hljs-string">"超时"</span> <span class="hljs-attr">value</span>=<span class="hljs-string">{stats.overdue}</span> <span class="hljs-attr">trend</span>=<span class="hljs-string">{-8}</span> <span class="hljs-attr">color</span>=<span class="hljs-string">"#ff4d4f"</span> /&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Col</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Row</span>&gt;</span> {/* 趋势图 */} <span class="hljs-tag">&lt;<span class="hljs-name">Card</span> <span class="hljs-attr">title</span>=<span class="hljs-string">"任务趋势"</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">marginBottom:</span> <span class="hljs-attr">16</span> }}&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">TrendChart</span> /&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Card</span>&gt;</span> {/* 饼图 + 柱状图 */} <span class="hljs-tag">&lt;<span class="hljs-name">Row</span> <span class="hljs-attr">gutter</span>=<span class="hljs-string">{16}</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Col</span> <span class="hljs-attr">span</span>=<span class="hljs-string">{12}</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Card</span> <span class="hljs-attr">title</span>=<span class="hljs-string">"分类分布"</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">CategoryPie</span> /&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Card</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Col</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Col</span> <span class="hljs-attr">span</span>=<span class="hljs-string">{12}</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Card</span> <span class="hljs-attr">title</span>=<span class="hljs-string">"团队效率榜"</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">TeamRanking</span> /&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Card</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Col</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Row</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Col</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Row</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span> ); } </code></pre> <hr> <h2 data-id="heading-4">3.2 统计卡片组件(StatCard)</h2> <p>统计卡片是看板最基本的元素,需要支持数字动效和趋势指标。</p> <h3 data-id="heading-5">3.2.1 组件实现</h3> <pre><code class="hljs language-tsx" lang="tsx"><span class="hljs-comment">// src/components/StatCard/index.tsx</span> <span class="hljs-keyword">import</span> { useEffect, useState } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">Card</span>, <span class="hljs-title class_">Typography</span>, <span class="hljs-title class_">Space</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'antd'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">ArrowUpOutlined</span>, <span class="hljs-title class_">ArrowDownOutlined</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@ant-design/icons'</span>; <span class="hljs-keyword">const</span> { <span class="hljs-title class_">Text</span> } = <span class="hljs-title class_">Typography</span>; <span class="hljs-keyword">interface</span> <span class="hljs-title class_">StatCardProps</span> { <span class="hljs-attr">title</span>: <span class="hljs-built_in">string</span>; <span class="hljs-attr">value</span>: <span class="hljs-built_in">number</span>; trend?: <span class="hljs-built_in">number</span>; <span class="hljs-comment">// 百分比,正数上升,负数下降</span> color?: <span class="hljs-built_in">string</span>; prefix?: <span class="hljs-built_in">string</span>; suffix?: <span class="hljs-built_in">string</span>; onClick?: <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">void</span>; } <span class="hljs-comment">// 数字滚动动效</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">AnimatedNumber</span>(<span class="hljs-params">{ value, duration = <span class="hljs-number">800</span> }: { value: <span class="hljs-built_in">number</span>; duration?: <span class="hljs-built_in">number</span> }</span>) { <span class="hljs-keyword">const</span> [display, setDisplay] = <span class="hljs-title function_">useState</span>(<span class="hljs-number">0</span>); <span class="hljs-title function_">useEffect</span>(<span class="hljs-function">() =&gt;</span> { <span class="hljs-keyword">let</span> <span class="hljs-attr">startTime</span>: <span class="hljs-built_in">number</span>; <span class="hljs-keyword">let</span> <span class="hljs-attr">animationFrame</span>: <span class="hljs-built_in">number</span>; <span class="hljs-keyword">const</span> <span class="hljs-title function_">animate</span> = (<span class="hljs-params">timestamp: <span class="hljs-built_in">number</span></span>) =&gt; { <span class="hljs-keyword">if</span> (!startTime) startTime = timestamp; <span class="hljs-keyword">const</span> progress = <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">min</span>((timestamp - startTime) / duration, <span class="hljs-number">1</span>); <span class="hljs-comment">// 缓动函数:easeOutQuart</span> <span class="hljs-keyword">const</span> eased = <span class="hljs-number">1</span> - <span class="hljs-title class_">Math</span>.<span class="hljs-title function_">pow</span>(<span class="hljs-number">1</span> - progress, <span class="hljs-number">4</span>); <span class="hljs-title function_">setDisplay</span>(<span class="hljs-title class_">Math</span>.<span class="hljs-title function_">floor</span>(eased * value)); <span class="hljs-keyword">if</span> (progress &lt; <span class="hljs-number">1</span>) { animationFrame = <span class="hljs-title function_">requestAnimationFrame</span>(animate); } }; animationFrame = <span class="hljs-title function_">requestAnimationFrame</span>(animate); <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">cancelAnimationFrame</span>(animationFrame); }, [value, duration]); <span class="hljs-keyword">return</span> <span class="xml"><span class="hljs-tag">&lt;&gt;</span>{display.toLocaleString()}<span class="hljs-tag">&lt;/&gt;</span></span>; } <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">StatCard</span>(<span class="hljs-params">{ title, value, trend, color = <span class="hljs-string">'#1677ff'</span>, prefix = <span class="hljs-string">''</span>, suffix = <span class="hljs-string">''</span>, onClick, }: StatCardProps</span>) { <span class="hljs-keyword">const</span> isPositive = (trend || <span class="hljs-number">0</span>) &gt;= <span class="hljs-number">0</span>; <span class="hljs-keyword">return</span> ( <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Card</span> <span class="hljs-attr">hoverable</span> <span class="hljs-attr">onClick</span>=<span class="hljs-string">{onClick}</span> <span class="hljs-attr">styles</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">body:</span> { <span class="hljs-attr">padding:</span> <span class="hljs-attr">20</span> } }} &gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Space</span> <span class="hljs-attr">direction</span>=<span class="hljs-string">"vertical"</span> <span class="hljs-attr">size</span>=<span class="hljs-string">"middle"</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">width:</span> '<span class="hljs-attr">100</span>%' }}&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Text</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"secondary"</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">fontSize:</span> <span class="hljs-attr">14</span> }}&gt;</span> {title} <span class="hljs-tag">&lt;/<span class="hljs-name">Text</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">fontSize:</span> <span class="hljs-attr">28</span>, <span class="hljs-attr">fontWeight:</span> <span class="hljs-attr">600</span>, <span class="hljs-attr">color</span> }}&gt;</span> {prefix} <span class="hljs-tag">&lt;<span class="hljs-name">AnimatedNumber</span> <span class="hljs-attr">value</span>=<span class="hljs-string">{value}</span> /&gt;</span> {suffix} <span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span> {trend !== undefined &amp;&amp; ( <span class="hljs-tag">&lt;<span class="hljs-name">Space</span> <span class="hljs-attr">size</span>=<span class="hljs-string">{4}</span>&gt;</span> {isPositive ? ( <span class="hljs-tag">&lt;<span class="hljs-name">ArrowUpOutlined</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">color:</span> '#<span class="hljs-attr">52c41a</span>' }} /&gt;</span> ) : ( <span class="hljs-tag">&lt;<span class="hljs-name">ArrowDownOutlined</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">color:</span> '#<span class="hljs-attr">ff4d4f</span>' }} /&gt;</span> )} <span class="hljs-tag">&lt;<span class="hljs-name">Text</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">color:</span> <span class="hljs-attr">isPositive</span> ? '#<span class="hljs-attr">52c41a</span>' <span class="hljs-attr">:</span> '#<span class="hljs-attr">ff4d4f</span>', <span class="hljs-attr">fontSize:</span> <span class="hljs-attr">12</span> }}&gt;</span> {Math.abs(trend)}% 较上周 <span class="hljs-tag">&lt;/<span class="hljs-name">Text</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Space</span>&gt;</span> )} <span class="hljs-tag">&lt;/<span class="hljs-name">Space</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Card</span>&gt;</span></span> ); } </code></pre> <h3 data-id="heading-6">3.2.2 设计要点</h3> <ul> <li><strong>数字动效</strong>:用 <code>requestAnimationFrame</code> 实现平滑滚动,给用户「数据在变化」的感觉</li> <li><strong>趋势指标</strong>:箭头 + 颜色 + 百分比,一目了然</li> <li><strong>hoverable</strong>:卡片可点击,作为快速入口跳转到对应列表</li> <li><strong>可配置</strong>:颜色、前缀、后缀都通过 props 传入,提高复用性</li> </ul> <hr> <h2 data-id="heading-7">3.3 ECharts 图表集成</h2> <p>ECharts 是最强大的前端图表库,我们用 <code>echarts-for-react</code> 简化 React 中的使用。</p> <h3 data-id="heading-8">3.3.1 安装</h3> <pre><code class="hljs language-bash" lang="bash">npm install echarts echarts-for-react </code></pre> <h3 data-id="heading-9">3.3.2 趋势折线图</h3> <pre><code class="hljs language-tsx" lang="tsx"><span class="hljs-comment">// src/pages/workbench/components/TrendChart.tsx</span> <span class="hljs-keyword">import</span> <span class="hljs-title class_">ReactECharts</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'echarts-for-react'</span>; <span class="hljs-keyword">import</span> { useWorkbenchStore } <span class="hljs-keyword">from</span> <span class="hljs-string">'../store'</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">TrendChart</span>(<span class="hljs-params"></span>) { <span class="hljs-keyword">const</span> trendData = <span class="hljs-title function_">useWorkbenchStore</span>(<span class="hljs-function">(<span class="hljs-params">s</span>) =&gt;</span> s.<span class="hljs-property">trendData</span>); <span class="hljs-keyword">const</span> option = { <span class="hljs-attr">tooltip</span>: { <span class="hljs-attr">trigger</span>: <span class="hljs-string">'axis'</span> }, <span class="hljs-attr">legend</span>: { <span class="hljs-attr">data</span>: [<span class="hljs-string">'新建'</span>, <span class="hljs-string">'完成'</span>], <span class="hljs-attr">right</span>: <span class="hljs-number">0</span> }, <span class="hljs-attr">grid</span>: { <span class="hljs-attr">left</span>: <span class="hljs-number">40</span>, <span class="hljs-attr">right</span>: <span class="hljs-number">20</span>, <span class="hljs-attr">top</span>: <span class="hljs-number">40</span>, <span class="hljs-attr">bottom</span>: <span class="hljs-number">30</span> }, <span class="hljs-attr">xAxis</span>: { <span class="hljs-attr">type</span>: <span class="hljs-string">'category'</span>, <span class="hljs-attr">data</span>: trendData.<span class="hljs-property">dates</span>, <span class="hljs-attr">boundaryGap</span>: <span class="hljs-literal">false</span>, }, <span class="hljs-attr">yAxis</span>: { <span class="hljs-attr">type</span>: <span class="hljs-string">'value'</span> }, <span class="hljs-attr">series</span>: [ { <span class="hljs-attr">name</span>: <span class="hljs-string">'新建'</span>, <span class="hljs-attr">type</span>: <span class="hljs-string">'line'</span>, <span class="hljs-attr">smooth</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">data</span>: trendData.<span class="hljs-property">created</span>, <span class="hljs-attr">areaStyle</span>: { <span class="hljs-attr">color</span>: { <span class="hljs-attr">type</span>: <span class="hljs-string">'linear'</span>, <span class="hljs-attr">x</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">y</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">x2</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">y2</span>: <span class="hljs-number">1</span>, <span class="hljs-attr">colorStops</span>: [ { <span class="hljs-attr">offset</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">color</span>: <span class="hljs-string">'rgba(22, 119, 255, 0.3)'</span> }, { <span class="hljs-attr">offset</span>: <span class="hljs-number">1</span>, <span class="hljs-attr">color</span>: <span class="hljs-string">'rgba(22, 119, 255, 0.05)'</span> }, ], }, }, <span class="hljs-attr">lineStyle</span>: { <span class="hljs-attr">color</span>: <span class="hljs-string">'#1677ff'</span>, <span class="hljs-attr">width</span>: <span class="hljs-number">2</span> }, <span class="hljs-attr">itemStyle</span>: { <span class="hljs-attr">color</span>: <span class="hljs-string">'#1677ff'</span> }, }, { <span class="hljs-attr">name</span>: <span class="hljs-string">'完成'</span>, <span class="hljs-attr">type</span>: <span class="hljs-string">'line'</span>, <span class="hljs-attr">smooth</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">data</span>: trendData.<span class="hljs-property">resolved</span>, <span class="hljs-attr">areaStyle</span>: { <span class="hljs-attr">color</span>: { <span class="hljs-attr">type</span>: <span class="hljs-string">'linear'</span>, <span class="hljs-attr">x</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">y</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">x2</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">y2</span>: <span class="hljs-number">1</span>, <span class="hljs-attr">colorStops</span>: [ { <span class="hljs-attr">offset</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">color</span>: <span class="hljs-string">'rgba(82, 196, 26, 0.3)'</span> }, { <span class="hljs-attr">offset</span>: <span class="hljs-number">1</span>, <span class="hljs-attr">color</span>: <span class="hljs-string">'rgba(82, 196, 26, 0.05)'</span> }, ], }, }, <span class="hljs-attr">lineStyle</span>: { <span class="hljs-attr">color</span>: <span class="hljs-string">'#52c41a'</span>, <span class="hljs-attr">width</span>: <span class="hljs-number">2</span> }, <span class="hljs-attr">itemStyle</span>: { <span class="hljs-attr">color</span>: <span class="hljs-string">'#52c41a'</span> }, }, ], }; <span class="hljs-keyword">return</span> <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">ReactECharts</span> <span class="hljs-attr">option</span>=<span class="hljs-string">{option}</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">height:</span> <span class="hljs-attr">280</span> }} /&gt;</span></span>; } </code></pre> <h3 data-id="heading-10">3.3.3 分类饼图</h3> <pre><code class="hljs language-tsx" lang="tsx"><span class="hljs-comment">// src/pages/workbench/components/CategoryPie.tsx</span> <span class="hljs-keyword">import</span> <span class="hljs-title class_">ReactECharts</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'echarts-for-react'</span>; <span class="hljs-keyword">import</span> { useWorkbenchStore } <span class="hljs-keyword">from</span> <span class="hljs-string">'../store'</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">CategoryPie</span>(<span class="hljs-params"></span>) { <span class="hljs-keyword">const</span> categoryData = <span class="hljs-title function_">useWorkbenchStore</span>(<span class="hljs-function">(<span class="hljs-params">s</span>) =&gt;</span> s.<span class="hljs-property">categoryData</span>); <span class="hljs-keyword">const</span> option = { <span class="hljs-attr">tooltip</span>: { <span class="hljs-attr">trigger</span>: <span class="hljs-string">'item'</span>, <span class="hljs-attr">formatter</span>: <span class="hljs-string">'{b}: {c} ({d}%)'</span> }, <span class="hljs-attr">legend</span>: { <span class="hljs-attr">orient</span>: <span class="hljs-string">'vertical'</span>, <span class="hljs-attr">left</span>: <span class="hljs-string">'left'</span> }, <span class="hljs-attr">series</span>: [ { <span class="hljs-attr">type</span>: <span class="hljs-string">'pie'</span>, <span class="hljs-attr">radius</span>: [<span class="hljs-string">'40%'</span>, <span class="hljs-string">'70%'</span>], <span class="hljs-attr">center</span>: [<span class="hljs-string">'60%'</span>, <span class="hljs-string">'50%'</span>], <span class="hljs-attr">avoidLabelOverlap</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">itemStyle</span>: { <span class="hljs-attr">borderRadius</span>: <span class="hljs-number">6</span>, <span class="hljs-attr">borderColor</span>: <span class="hljs-string">'#fff'</span>, <span class="hljs-attr">borderWidth</span>: <span class="hljs-number">2</span> }, <span class="hljs-attr">label</span>: { <span class="hljs-attr">show</span>: <span class="hljs-literal">false</span> }, <span class="hljs-attr">emphasis</span>: { <span class="hljs-attr">label</span>: { <span class="hljs-attr">show</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">fontSize</span>: <span class="hljs-number">14</span>, <span class="hljs-attr">fontWeight</span>: <span class="hljs-string">'bold'</span> }, }, <span class="hljs-attr">data</span>: categoryData, }, ], }; <span class="hljs-keyword">return</span> <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">ReactECharts</span> <span class="hljs-attr">option</span>=<span class="hljs-string">{option}</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">height:</span> <span class="hljs-attr">280</span> }} /&gt;</span></span>; } </code></pre> <h3 data-id="heading-11">3.3.4 团队效率柱状图</h3> <pre><code class="hljs language-tsx" lang="tsx"><span class="hljs-comment">// src/pages/workbench/components/TeamRanking.tsx</span> <span class="hljs-keyword">import</span> <span class="hljs-title class_">ReactECharts</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'echarts-for-react'</span>; <span class="hljs-keyword">import</span> { useWorkbenchStore } <span class="hljs-keyword">from</span> <span class="hljs-string">'../store'</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">TeamRanking</span>(<span class="hljs-params"></span>) { <span class="hljs-keyword">const</span> teamData = <span class="hljs-title function_">useWorkbenchStore</span>(<span class="hljs-function">(<span class="hljs-params">s</span>) =&gt;</span> s.<span class="hljs-property">teamData</span>); <span class="hljs-keyword">const</span> option = { <span class="hljs-attr">tooltip</span>: { <span class="hljs-attr">trigger</span>: <span class="hljs-string">'axis'</span>, <span class="hljs-attr">axisPointer</span>: { <span class="hljs-attr">type</span>: <span class="hljs-string">'shadow'</span> } }, <span class="hljs-attr">grid</span>: { <span class="hljs-attr">left</span>: <span class="hljs-number">80</span>, <span class="hljs-attr">right</span>: <span class="hljs-number">20</span>, <span class="hljs-attr">top</span>: <span class="hljs-number">20</span>, <span class="hljs-attr">bottom</span>: <span class="hljs-number">30</span> }, <span class="hljs-attr">xAxis</span>: { <span class="hljs-attr">type</span>: <span class="hljs-string">'value'</span> }, <span class="hljs-attr">yAxis</span>: { <span class="hljs-attr">type</span>: <span class="hljs-string">'category'</span>, <span class="hljs-attr">data</span>: teamData.<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">t</span>) =&gt;</span> t.<span class="hljs-property">name</span>), }, <span class="hljs-attr">series</span>: [ { <span class="hljs-attr">type</span>: <span class="hljs-string">'bar'</span>, <span class="hljs-attr">data</span>: teamData.<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">t</span>) =&gt;</span> t.<span class="hljs-property">value</span>), <span class="hljs-attr">itemStyle</span>: { <span class="hljs-attr">color</span>: { <span class="hljs-attr">type</span>: <span class="hljs-string">'linear'</span>, <span class="hljs-attr">x</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">y</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">x2</span>: <span class="hljs-number">1</span>, <span class="hljs-attr">y2</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">colorStops</span>: [ { <span class="hljs-attr">offset</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">color</span>: <span class="hljs-string">'#1677ff'</span> }, { <span class="hljs-attr">offset</span>: <span class="hljs-number">1</span>, <span class="hljs-attr">color</span>: <span class="hljs-string">'#69c0ff'</span> }, ], }, <span class="hljs-attr">borderRadius</span>: [<span class="hljs-number">0</span>, <span class="hljs-number">4</span>, <span class="hljs-number">4</span>, <span class="hljs-number">0</span>], }, <span class="hljs-attr">barWidth</span>: <span class="hljs-number">16</span>, <span class="hljs-attr">label</span>: { <span class="hljs-attr">show</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">position</span>: <span class="hljs-string">'right'</span> }, }, ], }; <span class="hljs-keyword">return</span> <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">ReactECharts</span> <span class="hljs-attr">option</span>=<span class="hljs-string">{option}</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">height:</span> <span class="hljs-attr">280</span> }} /&gt;</span></span>; } </code></pre> <hr> <h2 data-id="heading-12">3.4 待办队列</h2> <p>待办队列是工作台的核心交互区,展示当前用户需要处理的任务。</p> <h3 data-id="heading-13">3.4.1 Tab 切换 + 列表</h3> <pre><code class="hljs language-tsx" lang="tsx"><span class="hljs-comment">// src/pages/workbench/components/TodoList.tsx</span> <span class="hljs-keyword">import</span> { useState } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">Card</span>, <span class="hljs-title class_">Tabs</span>, <span class="hljs-title class_">List</span>, <span class="hljs-title class_">Tag</span>, <span class="hljs-title class_">Avatar</span>, <span class="hljs-title class_">Badge</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'antd'</span>; <span class="hljs-keyword">import</span> { useNavigate } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router-dom'</span>; <span class="hljs-keyword">import</span> dayjs <span class="hljs-keyword">from</span> <span class="hljs-string">'dayjs'</span>; <span class="hljs-keyword">import</span> relativeTime <span class="hljs-keyword">from</span> <span class="hljs-string">'dayjs/plugin/relativeTime'</span>; <span class="hljs-keyword">import</span> { useWorkbenchStore } <span class="hljs-keyword">from</span> <span class="hljs-string">'../store'</span>; dayjs.<span class="hljs-title function_">extend</span>(relativeTime); <span class="hljs-keyword">const</span> tabs = [ { <span class="hljs-attr">key</span>: <span class="hljs-string">'pending'</span>, <span class="hljs-attr">label</span>: <span class="hljs-string">'待处理'</span>, <span class="hljs-attr">badge</span>: <span class="hljs-number">5</span> }, { <span class="hljs-attr">key</span>: <span class="hljs-string">'processing'</span>, <span class="hljs-attr">label</span>: <span class="hljs-string">'处理中'</span>, <span class="hljs-attr">badge</span>: <span class="hljs-number">3</span> }, { <span class="hljs-attr">key</span>: <span class="hljs-string">'reminder'</span>, <span class="hljs-attr">label</span>: <span class="hljs-string">'提醒'</span>, <span class="hljs-attr">badge</span>: <span class="hljs-number">2</span> }, ]; <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">TodoList</span>(<span class="hljs-params"></span>) { <span class="hljs-keyword">const</span> [activeTab, setActiveTab] = <span class="hljs-title function_">useState</span>(<span class="hljs-string">'pending'</span>); <span class="hljs-keyword">const</span> navigate = <span class="hljs-title function_">useNavigate</span>(); <span class="hljs-keyword">const</span> todoList = <span class="hljs-title function_">useWorkbenchStore</span>(<span class="hljs-function">(<span class="hljs-params">s</span>) =&gt;</span> s.<span class="hljs-property">todoList</span>[activeTab <span class="hljs-keyword">as</span> keyof <span class="hljs-keyword">typeof</span> s.<span class="hljs-property">todoList</span>]); <span class="hljs-keyword">const</span> <span class="hljs-title function_">priorityColor</span> = (<span class="hljs-params">p: <span class="hljs-built_in">number</span></span>) =&gt; { <span class="hljs-keyword">const</span> <span class="hljs-attr">colors</span>: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">number</span>, <span class="hljs-built_in">string</span>&gt; = { <span class="hljs-number">1</span>: <span class="hljs-string">'red'</span>, <span class="hljs-number">2</span>: <span class="hljs-string">'orange'</span>, <span class="hljs-number">3</span>: <span class="hljs-string">'blue'</span>, <span class="hljs-number">4</span>: <span class="hljs-string">'default'</span> }; <span class="hljs-keyword">return</span> colors[p] || <span class="hljs-string">'default'</span>; }; <span class="hljs-keyword">const</span> <span class="hljs-title function_">priorityText</span> = (<span class="hljs-params">p: <span class="hljs-built_in">number</span></span>) =&gt; { <span class="hljs-keyword">const</span> <span class="hljs-attr">texts</span>: <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">number</span>, <span class="hljs-built_in">string</span>&gt; = { <span class="hljs-number">1</span>: <span class="hljs-string">'紧急'</span>, <span class="hljs-number">2</span>: <span class="hljs-string">'高'</span>, <span class="hljs-number">3</span>: <span class="hljs-string">'中'</span>, <span class="hljs-number">4</span>: <span class="hljs-string">'低'</span> }; <span class="hljs-keyword">return</span> texts[p] || <span class="hljs-string">'低'</span>; }; <span class="hljs-keyword">return</span> ( <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Card</span> <span class="hljs-attr">title</span>=<span class="hljs-string">"待办队列"</span> <span class="hljs-attr">extra</span>=<span class="hljs-string">{</span>&lt;<span class="hljs-attr">a</span> <span class="hljs-attr">onClick</span>=<span class="hljs-string">{()</span> =&gt;</span> navigate('/ticket')}&gt;查看全部<span class="hljs-tag">&lt;/<span class="hljs-name">a</span>&gt;</span>} styles={{ body: { padding: 0 } }} &gt; <span class="hljs-tag">&lt;<span class="hljs-name">Tabs</span> <span class="hljs-attr">activeKey</span>=<span class="hljs-string">{activeTab}</span> <span class="hljs-attr">onChange</span>=<span class="hljs-string">{setActiveTab}</span> <span class="hljs-attr">items</span>=<span class="hljs-string">{tabs.map((t)</span> =&gt;</span> ({ key: t.key, label: ( <span class="hljs-tag">&lt;<span class="hljs-name">Badge</span> <span class="hljs-attr">count</span>=<span class="hljs-string">{t.badge}</span> <span class="hljs-attr">size</span>=<span class="hljs-string">"small"</span> <span class="hljs-attr">offset</span>=<span class="hljs-string">{[4,</span> <span class="hljs-attr">0</span>]}&gt;</span> {t.label} <span class="hljs-tag">&lt;/<span class="hljs-name">Badge</span>&gt;</span> ), }))} style={{ padding: '0 16px' }} /&gt; <span class="hljs-tag">&lt;<span class="hljs-name">List</span> <span class="hljs-attr">dataSource</span>=<span class="hljs-string">{todoList}</span> <span class="hljs-attr">renderItem</span>=<span class="hljs-string">{(item)</span> =&gt;</span> ( <span class="hljs-tag">&lt;<span class="hljs-name">List.Item</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">padding:</span> '<span class="hljs-attr">12px</span> <span class="hljs-attr">16px</span>', <span class="hljs-attr">cursor:</span> '<span class="hljs-attr">pointer</span>' }} <span class="hljs-attr">onClick</span>=<span class="hljs-string">{()</span> =&gt;</span> navigate(`/ticket/${item.id}`)} &gt; <span class="hljs-tag">&lt;<span class="hljs-name">List.Item.Meta</span> <span class="hljs-attr">avatar</span>=<span class="hljs-string">{</span>&lt;<span class="hljs-attr">Avatar</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">backgroundColor:</span> '#<span class="hljs-attr">1677ff</span>' }}&gt;</span>{item.creator?.[0]}<span class="hljs-tag">&lt;/<span class="hljs-name">Avatar</span>&gt;</span>} title={ <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">display:</span> '<span class="hljs-attr">flex</span>', <span class="hljs-attr">justifyContent:</span> '<span class="hljs-attr">space-between</span>' }}&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">span</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">fontWeight:</span> <span class="hljs-attr">500</span>, <span class="hljs-attr">maxWidth:</span> <span class="hljs-attr">180</span>, <span class="hljs-attr">overflow:</span> '<span class="hljs-attr">hidden</span>', <span class="hljs-attr">textOverflow:</span> '<span class="hljs-attr">ellipsis</span>', <span class="hljs-attr">whiteSpace:</span> '<span class="hljs-attr">nowrap</span>' }}&gt;</span> {item.title} <span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Tag</span> <span class="hljs-attr">color</span>=<span class="hljs-string">{priorityColor(item.priority)}</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">marginLeft:</span> <span class="hljs-attr">8</span> }}&gt;</span> {priorityText(item.priority)} <span class="hljs-tag">&lt;/<span class="hljs-name">Tag</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span> } description={ <span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">fontSize:</span> <span class="hljs-attr">12</span>, <span class="hljs-attr">color:</span> '#<span class="hljs-attr">999</span>' }}&gt;</span> #{item.id} · {dayjs(item.createdAt).fromNow()} <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span> } /&gt; <span class="hljs-tag">&lt;/<span class="hljs-name">List.Item</span>&gt;</span> )} style={{ maxHeight: 520, overflow: 'auto' }} /&gt; <span class="hljs-tag">&lt;/<span class="hljs-name">Card</span>&gt;</span></span> ); } </code></pre> <hr> <h2 data-id="heading-14">3.5 Mock 数据与真实接口切换</h2> <p>在项目早期,后端接口还没就绪时,我们需要 Mock 数据来推进前端开发。</p> <h3 data-id="heading-15">3.5.1 Store 层的数据来源策略</h3> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-comment">// src/pages/workbench/store/index.ts</span> <span class="hljs-keyword">import</span> { createStore } <span class="hljs-keyword">from</span> <span class="hljs-string">'@store/createStore'</span>; <span class="hljs-keyword">import</span> { workbenchApi } <span class="hljs-keyword">from</span> <span class="hljs-string">'@services/workbench'</span>; <span class="hljs-keyword">import</span> { mockStats, mockTrendData, mockCategoryData, mockTeamData, mockTodoList } <span class="hljs-keyword">from</span> <span class="hljs-string">'../mock'</span>; <span class="hljs-keyword">interface</span> <span class="hljs-title class_">WorkbenchState</span> { <span class="hljs-attr">stats</span>: { <span class="hljs-attr">pending</span>: <span class="hljs-built_in">number</span>; <span class="hljs-attr">processing</span>: <span class="hljs-built_in">number</span>; <span class="hljs-attr">resolved</span>: <span class="hljs-built_in">number</span>; <span class="hljs-attr">overdue</span>: <span class="hljs-built_in">number</span> }; <span class="hljs-attr">trendData</span>: { <span class="hljs-attr">dates</span>: <span class="hljs-built_in">string</span>[]; <span class="hljs-attr">created</span>: <span class="hljs-built_in">number</span>[]; <span class="hljs-attr">resolved</span>: <span class="hljs-built_in">number</span>[] }; <span class="hljs-attr">categoryData</span>: { <span class="hljs-attr">name</span>: <span class="hljs-built_in">string</span>; <span class="hljs-attr">value</span>: <span class="hljs-built_in">number</span> }[]; <span class="hljs-attr">teamData</span>: { <span class="hljs-attr">name</span>: <span class="hljs-built_in">string</span>; <span class="hljs-attr">value</span>: <span class="hljs-built_in">number</span> }[]; <span class="hljs-attr">todoList</span>: { <span class="hljs-attr">pending</span>: <span class="hljs-built_in">any</span>[]; <span class="hljs-attr">processing</span>: <span class="hljs-built_in">any</span>[]; <span class="hljs-attr">reminder</span>: <span class="hljs-built_in">any</span>[]; }; <span class="hljs-attr">loading</span>: <span class="hljs-built_in">boolean</span>; } <span class="hljs-keyword">const</span> <span class="hljs-variable constant_">USE_MOCK</span> = <span class="hljs-keyword">import</span>.<span class="hljs-property">meta</span>.<span class="hljs-property">env</span>.<span class="hljs-property">DEV</span>; <span class="hljs-comment">// 开发环境用 mock</span> <span class="hljs-keyword">const</span> workbenchStore = createStore&lt;<span class="hljs-title class_">WorkbenchState</span>&gt;({ <span class="hljs-attr">stats</span>: { <span class="hljs-attr">pending</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">processing</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">resolved</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">overdue</span>: <span class="hljs-number">0</span> }, <span class="hljs-attr">trendData</span>: { <span class="hljs-attr">dates</span>: [], <span class="hljs-attr">created</span>: [], <span class="hljs-attr">resolved</span>: [] }, <span class="hljs-attr">categoryData</span>: [], <span class="hljs-attr">teamData</span>: [], <span class="hljs-attr">todoList</span>: { <span class="hljs-attr">pending</span>: [], <span class="hljs-attr">processing</span>: [], <span class="hljs-attr">reminder</span>: [] }, <span class="hljs-attr">loading</span>: <span class="hljs-literal">false</span>, }); <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> useWorkbenchStore = workbenchStore.<span class="hljs-property">useStore</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> setWorkbenchState = workbenchStore.<span class="hljs-property">setState</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title function_">fetchWorkbenchData</span> = <span class="hljs-keyword">async</span> (<span class="hljs-params"></span>) =&gt; { <span class="hljs-title function_">setWorkbenchState</span>({ <span class="hljs-attr">loading</span>: <span class="hljs-literal">true</span> }); <span class="hljs-keyword">try</span> { <span class="hljs-keyword">if</span> (<span class="hljs-variable constant_">USE_MOCK</span>) { <span class="hljs-comment">// 模拟请求延迟</span> <span class="hljs-keyword">await</span> <span class="hljs-keyword">new</span> <span class="hljs-title class_">Promise</span>(<span class="hljs-function">(<span class="hljs-params">r</span>) =&gt;</span> <span class="hljs-built_in">setTimeout</span>(r, <span class="hljs-number">500</span>)); <span class="hljs-title function_">setWorkbenchState</span>({ <span class="hljs-attr">stats</span>: mockStats, <span class="hljs-attr">trendData</span>: mockTrendData, <span class="hljs-attr">categoryData</span>: mockCategoryData, <span class="hljs-attr">teamData</span>: mockTeamData, <span class="hljs-attr">todoList</span>: mockTodoList, <span class="hljs-attr">loading</span>: <span class="hljs-literal">false</span>, }); } <span class="hljs-keyword">else</span> { <span class="hljs-keyword">const</span> data = <span class="hljs-keyword">await</span> workbenchApi.<span class="hljs-title function_">getOverview</span>(); <span class="hljs-title function_">setWorkbenchState</span>({ ...data, <span class="hljs-attr">loading</span>: <span class="hljs-literal">false</span> }); } } <span class="hljs-keyword">catch</span> { <span class="hljs-title function_">setWorkbenchState</span>({ <span class="hljs-attr">loading</span>: <span class="hljs-literal">false</span> }); } }; </code></pre> <h3 data-id="heading-16">3.5.2 Mock 数据文件</h3> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-comment">// src/pages/workbench/mock/index.ts</span> <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> mockStats = { <span class="hljs-attr">pending</span>: <span class="hljs-number">28</span>, <span class="hljs-attr">processing</span>: <span class="hljs-number">15</span>, <span class="hljs-attr">resolved</span>: <span class="hljs-number">142</span>, <span class="hljs-attr">overdue</span>: <span class="hljs-number">6</span>, }; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> mockTrendData = { <span class="hljs-attr">dates</span>: [<span class="hljs-string">'周一'</span>, <span class="hljs-string">'周二'</span>, <span class="hljs-string">'周三'</span>, <span class="hljs-string">'周四'</span>, <span class="hljs-string">'周五'</span>, <span class="hljs-string">'周六'</span>, <span class="hljs-string">'周日'</span>], <span class="hljs-attr">created</span>: [<span class="hljs-number">12</span>, <span class="hljs-number">19</span>, <span class="hljs-number">15</span>, <span class="hljs-number">22</span>, <span class="hljs-number">18</span>, <span class="hljs-number">8</span>, <span class="hljs-number">5</span>], <span class="hljs-attr">resolved</span>: [<span class="hljs-number">10</span>, <span class="hljs-number">16</span>, <span class="hljs-number">18</span>, <span class="hljs-number">20</span>, <span class="hljs-number">15</span>, <span class="hljs-number">6</span>, <span class="hljs-number">4</span>], }; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> mockCategoryData = [ { <span class="hljs-attr">name</span>: <span class="hljs-string">'系统问题'</span>, <span class="hljs-attr">value</span>: <span class="hljs-number">35</span> }, { <span class="hljs-attr">name</span>: <span class="hljs-string">'账号权限'</span>, <span class="hljs-attr">value</span>: <span class="hljs-number">28</span> }, { <span class="hljs-attr">name</span>: <span class="hljs-string">'业务咨询'</span>, <span class="hljs-attr">value</span>: <span class="hljs-number">22</span> }, { <span class="hljs-attr">name</span>: <span class="hljs-string">'功能建议'</span>, <span class="hljs-attr">value</span>: <span class="hljs-number">15</span> }, { <span class="hljs-attr">name</span>: <span class="hljs-string">'其他'</span>, <span class="hljs-attr">value</span>: <span class="hljs-number">10</span> }, ]; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> mockTeamData = [ { <span class="hljs-attr">name</span>: <span class="hljs-string">'张三'</span>, <span class="hljs-attr">value</span>: <span class="hljs-number">32</span> }, { <span class="hljs-attr">name</span>: <span class="hljs-string">'李四'</span>, <span class="hljs-attr">value</span>: <span class="hljs-number">28</span> }, { <span class="hljs-attr">name</span>: <span class="hljs-string">'王五'</span>, <span class="hljs-attr">value</span>: <span class="hljs-number">24</span> }, { <span class="hljs-attr">name</span>: <span class="hljs-string">'赵六'</span>, <span class="hljs-attr">value</span>: <span class="hljs-number">20</span> }, { <span class="hljs-attr">name</span>: <span class="hljs-string">'钱七'</span>, <span class="hljs-attr">value</span>: <span class="hljs-number">16</span> }, ]; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> mockTodoList = { <span class="hljs-attr">pending</span>: <span class="hljs-title class_">Array</span>.<span class="hljs-title function_">from</span>({ <span class="hljs-attr">length</span>: <span class="hljs-number">8</span> }).<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">_, i</span>) =&gt;</span> ({ <span class="hljs-attr">id</span>: <span class="hljs-string">`TK<span class="hljs-subst">${<span class="hljs-number">1000</span> + i}</span>`</span>, <span class="hljs-attr">title</span>: <span class="hljs-string">`待处理任务 <span class="hljs-subst">${i + <span class="hljs-number">1</span>}</span>`</span>, <span class="hljs-attr">priority</span>: (i % <span class="hljs-number">4</span>) + <span class="hljs-number">1</span>, <span class="hljs-attr">creator</span>: [<span class="hljs-string">'张三'</span>, <span class="hljs-string">'李四'</span>, <span class="hljs-string">'王五'</span>, <span class="hljs-string">'赵六'</span>][i % <span class="hljs-number">4</span>], <span class="hljs-attr">createdAt</span>: <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(<span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>() - i * <span class="hljs-number">3600000</span>).<span class="hljs-title function_">toISOString</span>(), })), <span class="hljs-attr">processing</span>: <span class="hljs-title class_">Array</span>.<span class="hljs-title function_">from</span>({ <span class="hljs-attr">length</span>: <span class="hljs-number">5</span> }).<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">_, i</span>) =&gt;</span> ({ <span class="hljs-attr">id</span>: <span class="hljs-string">`TK<span class="hljs-subst">${<span class="hljs-number">2000</span> + i}</span>`</span>, <span class="hljs-attr">title</span>: <span class="hljs-string">`处理中任务 <span class="hljs-subst">${i + <span class="hljs-number">1</span>}</span>`</span>, <span class="hljs-attr">priority</span>: (i % <span class="hljs-number">3</span>) + <span class="hljs-number">1</span>, <span class="hljs-attr">creator</span>: [<span class="hljs-string">'张三'</span>, <span class="hljs-string">'李四'</span>, <span class="hljs-string">'王五'</span>][i % <span class="hljs-number">3</span>], <span class="hljs-attr">createdAt</span>: <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(<span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>() - i * <span class="hljs-number">7200000</span>).<span class="hljs-title function_">toISOString</span>(), })), <span class="hljs-attr">reminder</span>: <span class="hljs-title class_">Array</span>.<span class="hljs-title function_">from</span>({ <span class="hljs-attr">length</span>: <span class="hljs-number">3</span> }).<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">_, i</span>) =&gt;</span> ({ <span class="hljs-attr">id</span>: <span class="hljs-string">`TK<span class="hljs-subst">${<span class="hljs-number">3000</span> + i}</span>`</span>, <span class="hljs-attr">title</span>: <span class="hljs-string">`提醒任务 <span class="hljs-subst">${i + <span class="hljs-number">1</span>}</span>`</span>, <span class="hljs-attr">priority</span>: i + <span class="hljs-number">1</span>, <span class="hljs-attr">creator</span>: [<span class="hljs-string">'张三'</span>, <span class="hljs-string">'李四'</span>][i % <span class="hljs-number">2</span>], <span class="hljs-attr">createdAt</span>: <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(<span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>() - i * <span class="hljs-number">1800000</span>).<span class="hljs-title function_">toISOString</span>(), })), }; </code></pre> <hr> <h2 data-id="heading-17">本章小结</h2> <table><thead><tr><th>知识点</th><th>关键内容</th></tr></thead><tbody><tr><td>布局设计</td><td>Row + Col 栅格系统,左侧列表 + 右侧看板的双栏布局</td></tr><tr><td>统计卡片</td><td><code>requestAnimationFrame</code> 实现数字滚动动效,趋势指标用颜色+箭头</td></tr><tr><td>ECharts</td><td><code>echarts-for-react</code> 封装,折线图/饼图/柱状图三种常用图表</td></tr><tr><td>待办队列</td><td>Tabs 切换、List 列表、点击跳转详情</td></tr><tr><td>Mock 方案</td><td>开发环境走 mock 数据,生产环境走真实接口,通过环境变量切换</td></tr></tbody></table> <p><strong>下一章预告</strong>:我们进入核心业务模块——任务单管理,学习数据模型设计、高级筛选、状态流转等复杂业务场景的实现。</p>

2026/9/23
阅读更多

第 2 章:架构设计与状态管理

<h2 data-id="heading-0">本章学习目标</h2> <ul> <li>理解后台系统的分层架构设计</li> <li>从零实现一个轻量级状态管理方案</li> <li>掌握 axios 的拦截器封装与统一错误处理</li> <li>学会设计路由体系(嵌套路由、权限路由、动态菜单)</li> </ul> <hr> <h2 data-id="heading-1">2.1 后台系统架构分层</h2> <p>在写业务代码之前,先把架构想清楚。一个好的分层能让项目<strong>可维护、可测试、可扩展</strong>。</p> <h3 data-id="heading-2">2.1.1 六层架构</h3> <pre><code class="hljs language-bash" lang="bash">┌──────────────────────────────────┐ │ 页面层 (Pages) │ 页面容器,组装组件 ├──────────────────────────────────┤ │ 组件层 (Components) │ 可复用 UI 组件 ├──────────────────────────────────┤ │ Hook 层 (Hooks) │ 可复用逻辑 ├──────────────────────────────────┤ │ 状态层 (Store) │ 全局/页面级状态 ├──────────────────────────────────┤ │ 服务层 (Services) │ API 调用与数据转换 ├──────────────────────────────────┤ │ 模型层 (Types) │ 数据类型定义 └──────────────────────────────────┘ </code></pre> <h3 data-id="heading-3">2.1.2 各层职责</h3> <table><thead><tr><th>层级</th><th>职责</th><th>依赖方向</th></tr></thead><tbody><tr><td>页面层</td><td>组合组件、绑定数据、处理页面级逻辑</td><td>依赖组件层、Hook 层、Store</td></tr><tr><td>组件层</td><td>纯 UI 渲染,通过 props 接收数据</td><td>只依赖工具函数和 antd</td></tr><tr><td>Hook 层</td><td>抽离通用逻辑(分页、防抖、表格…)</td><td>依赖服务层和 Store</td></tr><tr><td>状态层</td><td>管理跨组件共享状态</td><td>依赖服务层</td></tr><tr><td>服务层</td><td>封装 API 调用,统一请求/响应处理</td><td>依赖类型层</td></tr><tr><td>类型层</td><td>定义所有数据结构和接口类型</td><td>零依赖</td></tr></tbody></table> <blockquote> <p><strong>核心原则</strong>:依赖只能从上往下,不能反向依赖。类型层最稳定,页面层最易变。</p> </blockquote> <hr> <h2 data-id="heading-4">2.2 轻量级状态管理:不用 Redux</h2> <p>很多项目一上来就上 Redux,但后台系统 80% 的场景其实不需要这么重的方案。我们用 <code>useSyncExternalStore</code> + 发布订阅模式,自己实现一个轻量 Store。</p> <h3 data-id="heading-5">2.2.1 为什么不用 Redux?</h3> <ul> <li><strong>学习成本高</strong>:reducer、action、middleware、saga…概念太多</li> <li><strong>模板代码多</strong>:改一个状态要改 3 个文件</li> <li><strong>后台场景简单</strong>:大部分状态是页面级的,不需要全局管理</li> </ul> <h3 data-id="heading-6">2.2.2 自己实现 Store 工厂函数</h3> <p>创建 <code>src/store/createStore.ts</code>:</p> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-keyword">import</span> { useSyncExternalStore } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>; <span class="hljs-keyword">type</span> <span class="hljs-title class_">Listener</span> = <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">void</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> createStore&lt;<span class="hljs-title class_">TState</span>&gt;(<span class="hljs-attr">initialState</span>: <span class="hljs-title class_">TState</span>) { <span class="hljs-keyword">let</span> state = initialState; <span class="hljs-keyword">const</span> listeners = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Set</span>&lt;<span class="hljs-title class_">Listener</span>&gt;(); <span class="hljs-keyword">const</span> <span class="hljs-title function_">getState</span> = (<span class="hljs-params"></span>) =&gt; state; <span class="hljs-keyword">const</span> <span class="hljs-title function_">setState</span> = (<span class="hljs-params">partial: Partial&lt;TState&gt; | ((prev: TState) =&gt; Partial&lt;TState&gt;)</span>) =&gt; { <span class="hljs-keyword">const</span> nextState = <span class="hljs-keyword">typeof</span> partial === <span class="hljs-string">'function'</span> ? <span class="hljs-title function_">partial</span>(state) : partial; state = { ...state, ...nextState }; listeners.<span class="hljs-title function_">forEach</span>(<span class="hljs-function">(<span class="hljs-params">listener</span>) =&gt;</span> <span class="hljs-title function_">listener</span>()); }; <span class="hljs-keyword">const</span> <span class="hljs-title function_">subscribe</span> = (<span class="hljs-params">listener: Listener</span>) =&gt; { listeners.<span class="hljs-title function_">add</span>(listener); <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> listeners.<span class="hljs-title function_">delete</span>(listener); }; <span class="hljs-keyword">const</span> useStore = &lt;<span class="hljs-title class_">TSelected</span>&gt;(<span class="hljs-attr">selector</span>: <span class="hljs-function">(<span class="hljs-params">state: TState</span>) =&gt;</span> <span class="hljs-title class_">TSelected</span>): <span class="hljs-function"><span class="hljs-params">TSelected</span> =&gt;</span> { <span class="hljs-keyword">return</span> <span class="hljs-title function_">useSyncExternalStore</span>( subscribe, <span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">selector</span>(state), <span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">selector</span>(initialState), ); }; <span class="hljs-keyword">return</span> { getState, setState, subscribe, useStore }; } </code></pre> <p><strong>原理说明</strong>:</p> <ul> <li><code>useSyncExternalStore</code> 是 React 18 提供的 Hook,专门用于订阅外部状态</li> <li>每次 <code>setState</code> 触发所有订阅者更新</li> <li><code>selector</code> 让组件只订阅自己关心的那部分状态,避免不必要的重渲染</li> </ul> <h3 data-id="heading-7">2.2.3 使用示例:用户全局 Store</h3> <p>创建 <code>src/store/useUserStore.ts</code>:</p> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-keyword">import</span> { createStore } <span class="hljs-keyword">from</span> <span class="hljs-string">'./createStore'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">UserInfo</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@types/user'</span>; <span class="hljs-keyword">import</span> { authApi } <span class="hljs-keyword">from</span> <span class="hljs-string">'@services/auth'</span>; <span class="hljs-keyword">interface</span> <span class="hljs-title class_">UserState</span> { <span class="hljs-attr">userInfo</span>: <span class="hljs-title class_">UserInfo</span> | <span class="hljs-literal">null</span>; <span class="hljs-attr">token</span>: <span class="hljs-built_in">string</span>; <span class="hljs-attr">permissions</span>: <span class="hljs-built_in">string</span>[]; <span class="hljs-attr">isLoading</span>: <span class="hljs-built_in">boolean</span>; } <span class="hljs-keyword">const</span> userStore = createStore&lt;<span class="hljs-title class_">UserState</span>&gt;({ <span class="hljs-attr">userInfo</span>: <span class="hljs-literal">null</span>, <span class="hljs-attr">token</span>: <span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">getItem</span>(<span class="hljs-string">'token'</span>) || <span class="hljs-string">''</span>, <span class="hljs-attr">permissions</span>: [], <span class="hljs-attr">isLoading</span>: <span class="hljs-literal">false</span>, }); <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> useUserStore = userStore.<span class="hljs-property">useStore</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> setUserState = userStore.<span class="hljs-property">setState</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> getUserState = userStore.<span class="hljs-property">getState</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title function_">login</span> = <span class="hljs-keyword">async</span> (<span class="hljs-params">username: <span class="hljs-built_in">string</span>, password: <span class="hljs-built_in">string</span></span>) =&gt; { <span class="hljs-title function_">setUserState</span>({ <span class="hljs-attr">isLoading</span>: <span class="hljs-literal">true</span> }); <span class="hljs-keyword">try</span> { <span class="hljs-keyword">const</span> { token, userInfo, permissions } = <span class="hljs-keyword">await</span> authApi.<span class="hljs-title function_">login</span>({ username, password }); <span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">setItem</span>(<span class="hljs-string">'token'</span>, token); <span class="hljs-title function_">setUserState</span>({ token, userInfo, permissions, <span class="hljs-attr">isLoading</span>: <span class="hljs-literal">false</span> }); <span class="hljs-keyword">return</span> <span class="hljs-literal">true</span>; } <span class="hljs-keyword">catch</span> { <span class="hljs-title function_">setUserState</span>({ <span class="hljs-attr">isLoading</span>: <span class="hljs-literal">false</span> }); <span class="hljs-keyword">return</span> <span class="hljs-literal">false</span>; } }; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-title function_">logout</span> = (<span class="hljs-params"></span>) =&gt; { <span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">removeItem</span>(<span class="hljs-string">'token'</span>); <span class="hljs-title function_">setUserState</span>({ <span class="hljs-attr">userInfo</span>: <span class="hljs-literal">null</span>, <span class="hljs-attr">token</span>: <span class="hljs-string">''</span>, <span class="hljs-attr">permissions</span>: [] }); }; </code></pre> <p>在组件中使用:</p> <pre><code class="hljs language-tsx" lang="tsx"><span class="hljs-keyword">import</span> { <span class="hljs-title class_">Button</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'antd'</span>; <span class="hljs-keyword">import</span> { useUserStore, logout } <span class="hljs-keyword">from</span> <span class="hljs-string">'@store/useUserStore'</span>; <span class="hljs-keyword">function</span> <span class="hljs-title function_">UserProfile</span>(<span class="hljs-params"></span>) { <span class="hljs-keyword">const</span> userInfo = <span class="hljs-title function_">useUserStore</span>(<span class="hljs-function">(<span class="hljs-params">s</span>) =&gt;</span> s.<span class="hljs-property">userInfo</span>); <span class="hljs-keyword">return</span> ( <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">span</span>&gt;</span>{userInfo?.name}<span class="hljs-tag">&lt;/<span class="hljs-name">span</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Button</span> <span class="hljs-attr">onClick</span>=<span class="hljs-string">{logout}</span>&gt;</span>退出登录<span class="hljs-tag">&lt;/<span class="hljs-name">Button</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span> ); } </code></pre> <h3 data-id="heading-8">2.2.4 页面级 Store 的使用</h3> <p>不是所有状态都要放全局。比如任务单列表的筛选条件、分页状态,离开页面就没用了。</p> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-comment">// src/pages/ticket/store/useTicketListStore.ts</span> <span class="hljs-keyword">import</span> { createStore } <span class="hljs-keyword">from</span> <span class="hljs-string">'@store/createStore'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">Ticket</span>, <span class="hljs-title class_">TicketFilters</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@types/ticket'</span>; <span class="hljs-keyword">interface</span> <span class="hljs-title class_">TicketListState</span> { <span class="hljs-attr">list</span>: <span class="hljs-title class_">Ticket</span>[]; <span class="hljs-attr">total</span>: <span class="hljs-built_in">number</span>; <span class="hljs-attr">page</span>: <span class="hljs-built_in">number</span>; <span class="hljs-attr">pageSize</span>: <span class="hljs-built_in">number</span>; <span class="hljs-attr">filters</span>: <span class="hljs-title class_">TicketFilters</span>; <span class="hljs-attr">loading</span>: <span class="hljs-built_in">boolean</span>; } <span class="hljs-keyword">const</span> ticketListStore = createStore&lt;<span class="hljs-title class_">TicketListState</span>&gt;({ <span class="hljs-attr">list</span>: [], <span class="hljs-attr">total</span>: <span class="hljs-number">0</span>, <span class="hljs-attr">page</span>: <span class="hljs-number">1</span>, <span class="hljs-attr">pageSize</span>: <span class="hljs-number">20</span>, <span class="hljs-attr">filters</span>: {}, <span class="hljs-attr">loading</span>: <span class="hljs-literal">false</span>, }); <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> useTicketListStore = ticketListStore.<span class="hljs-property">useStore</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> setTicketListState = ticketListStore.<span class="hljs-property">setState</span>; </code></pre> <blockquote> <p><strong>什么时候用全局 Store?</strong></p> <ul> <li>用户信息、权限、token(登录后到处用)</li> <li>全局主题、语言设置</li> <li>通知/消息中心数据</li> </ul> <p><strong>什么时候用页面级 Store?</strong></p> <ul> <li>列表筛选条件、分页</li> <li>详情页的数据</li> <li>只有当前页面用得到的状态</li> </ul> </blockquote> <hr> <h2 data-id="heading-9">2.3 API 层封装</h2> <p>好的 API 层封装能让业务代码更干净,也方便统一处理错误、loading、token 等。</p> <h3 data-id="heading-10">2.3.1 axios 实例与拦截器</h3> <p>创建 <code>src/services/request.ts</code>:</p> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-keyword">import</span> axios, { <span class="hljs-title class_">AxiosError</span>, <span class="hljs-title class_">AxiosResponse</span>, <span class="hljs-title class_">InternalAxiosRequestConfig</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'axios'</span>; <span class="hljs-keyword">import</span> { message } <span class="hljs-keyword">from</span> <span class="hljs-string">'antd'</span>; <span class="hljs-keyword">const</span> request = axios.<span class="hljs-title function_">create</span>({ <span class="hljs-attr">baseURL</span>: <span class="hljs-keyword">import</span>.<span class="hljs-property">meta</span>.<span class="hljs-property">env</span>.<span class="hljs-property">VITE_API_BASE_URL</span>, <span class="hljs-attr">timeout</span>: <span class="hljs-number">30000</span>, <span class="hljs-attr">headers</span>: { <span class="hljs-string">'Content-Type'</span>: <span class="hljs-string">'application/json'</span> }, }); <span class="hljs-comment">// 取消请求的 Map</span> <span class="hljs-keyword">const</span> pendingRequests = <span class="hljs-keyword">new</span> <span class="hljs-title class_">Map</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-title class_">AbortController</span>&gt;(); <span class="hljs-keyword">const</span> <span class="hljs-title function_">generateRequestKey</span> = (<span class="hljs-params">config: InternalAxiosRequestConfig</span>) =&gt; { <span class="hljs-keyword">const</span> { method, url, params, data } = config; <span class="hljs-keyword">return</span> [method, url, <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(params), <span class="hljs-title class_">JSON</span>.<span class="hljs-title function_">stringify</span>(data)].<span class="hljs-title function_">join</span>(<span class="hljs-string">'&amp;'</span>); }; <span class="hljs-comment">// 请求拦截器</span> request.<span class="hljs-property">interceptors</span>.<span class="hljs-property">request</span>.<span class="hljs-title function_">use</span>( <span class="hljs-function">(<span class="hljs-params">config</span>) =&gt;</span> { <span class="hljs-keyword">const</span> token = <span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">getItem</span>(<span class="hljs-string">'token'</span>); <span class="hljs-keyword">if</span> (token) { config.<span class="hljs-property">headers</span>.<span class="hljs-property">Authorization</span> = <span class="hljs-string">`Bearer <span class="hljs-subst">${token}</span>`</span>; } <span class="hljs-comment">// 防重复请求:同一个请求正在进行的话,取消上一个</span> <span class="hljs-keyword">const</span> key = <span class="hljs-title function_">generateRequestKey</span>(config); <span class="hljs-keyword">if</span> (pendingRequests.<span class="hljs-title function_">has</span>(key)) { pendingRequests.<span class="hljs-title function_">get</span>(key)?.<span class="hljs-title function_">abort</span>(); } <span class="hljs-keyword">const</span> controller = <span class="hljs-keyword">new</span> <span class="hljs-title class_">AbortController</span>(); config.<span class="hljs-property">signal</span> = controller.<span class="hljs-property">signal</span>; pendingRequests.<span class="hljs-title function_">set</span>(key, controller); <span class="hljs-keyword">return</span> config; }, <span class="hljs-function">(<span class="hljs-params">error</span>) =&gt;</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">reject</span>(error), ); <span class="hljs-comment">// 响应拦截器</span> request.<span class="hljs-property">interceptors</span>.<span class="hljs-property">response</span>.<span class="hljs-title function_">use</span>( <span class="hljs-function">(<span class="hljs-params">response: AxiosResponse&lt;ApiResponse&gt;</span>) =&gt;</span> { <span class="hljs-keyword">const</span> key = <span class="hljs-title function_">generateRequestKey</span>(response.<span class="hljs-property">config</span>); pendingRequests.<span class="hljs-title function_">delete</span>(key); <span class="hljs-keyword">const</span> { code, data, msg } = response.<span class="hljs-property">data</span>; <span class="hljs-keyword">if</span> (code === <span class="hljs-number">0</span>) { <span class="hljs-keyword">return</span> data <span class="hljs-keyword">as</span> <span class="hljs-built_in">any</span>; } <span class="hljs-comment">// 业务错误处理</span> <span class="hljs-keyword">if</span> (code === <span class="hljs-number">401</span>) { message.<span class="hljs-title function_">error</span>(<span class="hljs-string">'登录已过期,请重新登录'</span>); <span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">removeItem</span>(<span class="hljs-string">'token'</span>); <span class="hljs-variable language_">window</span>.<span class="hljs-property">location</span>.<span class="hljs-property">href</span> = <span class="hljs-string">'/login'</span>; <span class="hljs-keyword">return</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">reject</span>(<span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(msg || <span class="hljs-string">'未授权'</span>)); } message.<span class="hljs-title function_">error</span>(msg || <span class="hljs-string">'请求失败'</span>); <span class="hljs-keyword">return</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">reject</span>(<span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(msg || <span class="hljs-string">'请求失败'</span>)); }, <span class="hljs-function">(<span class="hljs-params">error: AxiosError</span>) =&gt;</span> { <span class="hljs-keyword">if</span> (error.<span class="hljs-property">code</span> === <span class="hljs-string">'ERR_CANCELED'</span>) { <span class="hljs-keyword">return</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">reject</span>(<span class="hljs-keyword">new</span> <span class="hljs-title class_">Error</span>(<span class="hljs-string">'请求已取消'</span>)); } <span class="hljs-keyword">if</span> (error.<span class="hljs-property">response</span>?.<span class="hljs-property">status</span> === <span class="hljs-number">401</span>) { message.<span class="hljs-title function_">error</span>(<span class="hljs-string">'登录已过期,请重新登录'</span>); <span class="hljs-variable language_">localStorage</span>.<span class="hljs-title function_">removeItem</span>(<span class="hljs-string">'token'</span>); <span class="hljs-variable language_">window</span>.<span class="hljs-property">location</span>.<span class="hljs-property">href</span> = <span class="hljs-string">'/login'</span>; } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (error.<span class="hljs-property">response</span>?.<span class="hljs-property">status</span> === <span class="hljs-number">500</span>) { message.<span class="hljs-title function_">error</span>(<span class="hljs-string">'服务器错误,请稍后重试'</span>); } <span class="hljs-keyword">else</span> <span class="hljs-keyword">if</span> (error.<span class="hljs-property">code</span> === <span class="hljs-string">'ECONNABORTED'</span>) { message.<span class="hljs-title function_">error</span>(<span class="hljs-string">'请求超时,请检查网络'</span>); } <span class="hljs-keyword">else</span> { message.<span class="hljs-title function_">error</span>(error.<span class="hljs-property">message</span> || <span class="hljs-string">'网络错误'</span>); } <span class="hljs-keyword">return</span> <span class="hljs-title class_">Promise</span>.<span class="hljs-title function_">reject</span>(error); }, ); <span class="hljs-comment">// 通用响应类型</span> <span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">ApiResponse</span>&lt;T = <span class="hljs-built_in">any</span>&gt; { <span class="hljs-attr">code</span>: <span class="hljs-built_in">number</span>; <span class="hljs-attr">data</span>: T; <span class="hljs-attr">msg</span>: <span class="hljs-built_in">string</span>; } <span class="hljs-comment">// 分页响应类型</span> <span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">PageResponse</span>&lt;T&gt; { <span class="hljs-attr">list</span>: T[]; <span class="hljs-attr">total</span>: <span class="hljs-built_in">number</span>; <span class="hljs-attr">page</span>: <span class="hljs-built_in">number</span>; <span class="hljs-attr">pageSize</span>: <span class="hljs-built_in">number</span>; } <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> request; </code></pre> <h3 data-id="heading-11">2.3.2 业务模块接口封装</h3> <p>按业务模块组织 API 文件,例如 <code>src/services/ticket.ts</code>:</p> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-keyword">import</span> request, { <span class="hljs-title class_">PageResponse</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'./request'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">Ticket</span>, <span class="hljs-title class_">TicketFilters</span>, <span class="hljs-title class_">TicketCreateData</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@types/ticket'</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> ticketApi = { <span class="hljs-attr">getList</span>: <span class="hljs-function">(<span class="hljs-params">params: TicketFilters &amp; { page: <span class="hljs-built_in">number</span>; pageSize: <span class="hljs-built_in">number</span> }</span>) =&gt;</span> request.<span class="hljs-property">get</span>&lt;<span class="hljs-title class_">PageResponse</span>&lt;<span class="hljs-title class_">Ticket</span>&gt;&gt;(<span class="hljs-string">'/api/v1/tickets'</span>, { params }), <span class="hljs-attr">getDetail</span>: <span class="hljs-function">(<span class="hljs-params">id: <span class="hljs-built_in">string</span></span>) =&gt;</span> request.<span class="hljs-property">get</span>&lt;<span class="hljs-title class_">Ticket</span>&gt;(<span class="hljs-string">`/api/v1/tickets/<span class="hljs-subst">${id}</span>`</span>), <span class="hljs-attr">create</span>: <span class="hljs-function">(<span class="hljs-params">data: TicketCreateData</span>) =&gt;</span> request.<span class="hljs-property">post</span>&lt;<span class="hljs-title class_">Ticket</span>&gt;(<span class="hljs-string">'/api/v1/tickets'</span>, data), <span class="hljs-attr">update</span>: <span class="hljs-function">(<span class="hljs-params">id: <span class="hljs-built_in">string</span>, data: Partial&lt;TicketCreateData&gt;</span>) =&gt;</span> request.<span class="hljs-property">put</span>&lt;<span class="hljs-title class_">Ticket</span>&gt;(<span class="hljs-string">`/api/v1/tickets/<span class="hljs-subst">${id}</span>`</span>, data), <span class="hljs-attr">delete</span>: <span class="hljs-function">(<span class="hljs-params">id: <span class="hljs-built_in">string</span></span>) =&gt;</span> request.<span class="hljs-title function_">delete</span>(<span class="hljs-string">`/api/v1/tickets/<span class="hljs-subst">${id}</span>`</span>), <span class="hljs-attr">batchUpdate</span>: <span class="hljs-function">(<span class="hljs-params">ids: <span class="hljs-built_in">string</span>[], data: Record&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">any</span>&gt;</span>) =&gt;</span> request.<span class="hljs-title function_">post</span>(<span class="hljs-string">'/api/v1/tickets/batch'</span>, { ids, ...data }), }; </code></pre> <hr> <h2 data-id="heading-12">2.4 路由设计</h2> <p>后台系统的路由有三个特点:嵌套布局、权限控制、动态菜单。</p> <h3 data-id="heading-13">2.4.1 路由表配置</h3> <p>创建 <code>src/router/routes.ts</code>:</p> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-keyword">import</span> { lazy } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">Navigate</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router-dom'</span>; <span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">RouteObject</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router-dom'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">BasicLayout</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'@layouts/BasicLayout'</span>; <span class="hljs-keyword">const</span> <span class="hljs-title class_">Workbench</span> = <span class="hljs-title function_">lazy</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'@pages/workbench'</span>)); <span class="hljs-keyword">const</span> <span class="hljs-title class_">TicketList</span> = <span class="hljs-title function_">lazy</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'@pages/ticket/list'</span>)); <span class="hljs-keyword">const</span> <span class="hljs-title class_">TicketDetail</span> = <span class="hljs-title function_">lazy</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'@pages/ticket/detail'</span>)); <span class="hljs-keyword">const</span> <span class="hljs-title class_">TicketCreate</span> = <span class="hljs-title function_">lazy</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'@pages/ticket/create'</span>)); <span class="hljs-keyword">const</span> <span class="hljs-title class_">ScheduledTask</span> = <span class="hljs-title function_">lazy</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'@pages/scheduled-task'</span>)); <span class="hljs-keyword">const</span> <span class="hljs-title class_">Knowledge</span> = <span class="hljs-title function_">lazy</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'@pages/knowledge'</span>)); <span class="hljs-keyword">const</span> <span class="hljs-title class_">Statistics</span> = <span class="hljs-title function_">lazy</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'@pages/statistics'</span>)); <span class="hljs-keyword">const</span> <span class="hljs-title class_">AIAssistant</span> = <span class="hljs-title function_">lazy</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'@pages/ai-assistant'</span>)); <span class="hljs-keyword">const</span> <span class="hljs-title class_">Approval</span> = <span class="hljs-title function_">lazy</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'@pages/approval'</span>)); <span class="hljs-keyword">const</span> <span class="hljs-title class_">Login</span> = <span class="hljs-title function_">lazy</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'@pages/login'</span>)); <span class="hljs-keyword">const</span> <span class="hljs-title class_">NotFound</span> = <span class="hljs-title function_">lazy</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">import</span>(<span class="hljs-string">'@pages/404'</span>)); <span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">RouteMeta</span> { title?: <span class="hljs-built_in">string</span>; icon?: <span class="hljs-built_in">string</span>; requiresAuth?: <span class="hljs-built_in">boolean</span>; permission?: <span class="hljs-built_in">string</span>; hidden?: <span class="hljs-built_in">boolean</span>; } <span class="hljs-keyword">export</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">AppRouteObject</span> <span class="hljs-keyword">extends</span> <span class="hljs-title class_">Omit</span>&lt;<span class="hljs-title class_">RouteObject</span>, 'children'&gt; { meta?: <span class="hljs-title class_">RouteMeta</span>; children?: <span class="hljs-title class_">AppRouteObject</span>[]; } <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> <span class="hljs-attr">routes</span>: <span class="hljs-title class_">AppRouteObject</span>[] = [ { <span class="hljs-attr">path</span>: <span class="hljs-string">'/login'</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Login</span> /&gt;</span></span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">hidden</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">requiresAuth</span>: <span class="hljs-literal">false</span> }, }, { <span class="hljs-attr">path</span>: <span class="hljs-string">'/'</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">BasicLayout</span> /&gt;</span></span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">requiresAuth</span>: <span class="hljs-literal">true</span> }, <span class="hljs-attr">children</span>: [ { <span class="hljs-attr">index</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Navigate</span> <span class="hljs-attr">to</span>=<span class="hljs-string">"/workbench"</span> <span class="hljs-attr">replace</span> /&gt;</span></span> }, { <span class="hljs-attr">path</span>: <span class="hljs-string">'workbench'</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Workbench</span> /&gt;</span></span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">title</span>: <span class="hljs-string">'工作台'</span>, <span class="hljs-attr">icon</span>: <span class="hljs-string">'DashboardOutlined'</span> }, }, { <span class="hljs-attr">path</span>: <span class="hljs-string">'ticket'</span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">title</span>: <span class="hljs-string">'任务单管理'</span>, <span class="hljs-attr">icon</span>: <span class="hljs-string">'FileTextOutlined'</span> }, <span class="hljs-attr">children</span>: [ { <span class="hljs-attr">path</span>: <span class="hljs-string">''</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">TicketList</span> /&gt;</span></span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">title</span>: <span class="hljs-string">'任务单列表'</span> } }, { <span class="hljs-attr">path</span>: <span class="hljs-string">'create'</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">TicketCreate</span> /&gt;</span></span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">title</span>: <span class="hljs-string">'新建任务单'</span>, <span class="hljs-attr">hidden</span>: <span class="hljs-literal">true</span> } }, { <span class="hljs-attr">path</span>: <span class="hljs-string">':id'</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">TicketDetail</span> /&gt;</span></span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">title</span>: <span class="hljs-string">'任务单详情'</span>, <span class="hljs-attr">hidden</span>: <span class="hljs-literal">true</span> } }, ], }, { <span class="hljs-attr">path</span>: <span class="hljs-string">'scheduled-task'</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">ScheduledTask</span> /&gt;</span></span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">title</span>: <span class="hljs-string">'定时任务'</span>, <span class="hljs-attr">icon</span>: <span class="hljs-string">'ClockCircleOutlined'</span> }, }, { <span class="hljs-attr">path</span>: <span class="hljs-string">'approval'</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Approval</span> /&gt;</span></span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">title</span>: <span class="hljs-string">'审批中心'</span>, <span class="hljs-attr">icon</span>: <span class="hljs-string">'CheckCircleOutlined'</span> }, }, { <span class="hljs-attr">path</span>: <span class="hljs-string">'knowledge'</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Knowledge</span> /&gt;</span></span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">title</span>: <span class="hljs-string">'知识中心'</span>, <span class="hljs-attr">icon</span>: <span class="hljs-string">'BookOutlined'</span> }, }, { <span class="hljs-attr">path</span>: <span class="hljs-string">'statistics'</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Statistics</span> /&gt;</span></span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">title</span>: <span class="hljs-string">'数据统计'</span>, <span class="hljs-attr">icon</span>: <span class="hljs-string">'BarChartOutlined'</span> }, }, { <span class="hljs-attr">path</span>: <span class="hljs-string">'ai-assistant'</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">AIAssistant</span> /&gt;</span></span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">title</span>: <span class="hljs-string">'AI 助手'</span>, <span class="hljs-attr">icon</span>: <span class="hljs-string">'RobotOutlined'</span> }, }, { <span class="hljs-attr">path</span>: <span class="hljs-string">'*'</span>, <span class="hljs-attr">element</span>: <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">NotFound</span> /&gt;</span></span>, <span class="hljs-attr">meta</span>: { <span class="hljs-attr">hidden</span>: <span class="hljs-literal">true</span> } }, ], }, ]; </code></pre> <h3 data-id="heading-14">2.4.2 权限路由守卫</h3> <p>创建 <code>src/router/PrivateRoute.tsx</code>:</p> <pre><code class="hljs language-tsx" lang="tsx"><span class="hljs-keyword">import</span> { <span class="hljs-title class_">Navigate</span>, useLocation } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router-dom'</span>; <span class="hljs-keyword">import</span> { useUserStore } <span class="hljs-keyword">from</span> <span class="hljs-string">'@store/useUserStore'</span>; <span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">AppRouteObject</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'./routes'</span>; <span class="hljs-keyword">interface</span> <span class="hljs-title class_">Props</span> { <span class="hljs-attr">route</span>: <span class="hljs-title class_">AppRouteObject</span>; <span class="hljs-attr">children</span>: <span class="hljs-title class_">React</span>.<span class="hljs-property">ReactNode</span>; } <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">PrivateRoute</span>(<span class="hljs-params">{ route, children }: Props</span>) { <span class="hljs-keyword">const</span> token = <span class="hljs-title function_">useUserStore</span>(<span class="hljs-function">(<span class="hljs-params">s</span>) =&gt;</span> s.<span class="hljs-property">token</span>); <span class="hljs-keyword">const</span> permissions = <span class="hljs-title function_">useUserStore</span>(<span class="hljs-function">(<span class="hljs-params">s</span>) =&gt;</span> s.<span class="hljs-property">permissions</span>); <span class="hljs-keyword">const</span> location = <span class="hljs-title function_">useLocation</span>(); <span class="hljs-comment">// 需要登录但未登录</span> <span class="hljs-keyword">if</span> (route.<span class="hljs-property">meta</span>?.<span class="hljs-property">requiresAuth</span> !== <span class="hljs-literal">false</span> &amp;&amp; !token) { <span class="hljs-keyword">return</span> <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Navigate</span> <span class="hljs-attr">to</span>=<span class="hljs-string">"/login"</span> <span class="hljs-attr">state</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">from:</span> <span class="hljs-attr">location.pathname</span> }} <span class="hljs-attr">replace</span> /&gt;</span></span>; } <span class="hljs-comment">// 需要特定权限</span> <span class="hljs-keyword">if</span> (route.<span class="hljs-property">meta</span>?.<span class="hljs-property">permission</span> &amp;&amp; !permissions.<span class="hljs-title function_">includes</span>(route.<span class="hljs-property">meta</span>.<span class="hljs-property">permission</span>)) { <span class="hljs-keyword">return</span> <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Navigate</span> <span class="hljs-attr">to</span>=<span class="hljs-string">"/403"</span> <span class="hljs-attr">replace</span> /&gt;</span></span>; } <span class="hljs-keyword">return</span> <span class="xml"><span class="hljs-tag">&lt;&gt;</span>{children}<span class="hljs-tag">&lt;/&gt;</span></span>; } </code></pre> <h3 data-id="heading-15">2.4.3 动态菜单生成</h3> <p>侧边栏菜单根据路由表自动生成,不用维护两份配置:</p> <pre><code class="hljs language-tsx" lang="tsx"><span class="hljs-comment">// src/layouts/BasicLayout/Sidebar.tsx</span> <span class="hljs-keyword">import</span> { <span class="hljs-title class_">Menu</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'antd'</span>; <span class="hljs-keyword">import</span> { useNavigate, useLocation } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router-dom'</span>; <span class="hljs-keyword">import</span> { routes } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/router/routes'</span>; <span class="hljs-keyword">import</span> <span class="hljs-keyword">type</span> { <span class="hljs-title class_">AppRouteObject</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@/router/routes'</span>; <span class="hljs-keyword">import</span> * <span class="hljs-keyword">as</span> <span class="hljs-title class_">Icons</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'@ant-design/icons'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">React</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>; <span class="hljs-keyword">const</span> <span class="hljs-title function_">getIcon</span> = (<span class="hljs-params">iconName?: <span class="hljs-built_in">string</span></span>) =&gt; { <span class="hljs-keyword">if</span> (!iconName) <span class="hljs-keyword">return</span> <span class="hljs-literal">null</span>; <span class="hljs-keyword">const</span> <span class="hljs-title class_">IconComponent</span> = (<span class="hljs-title class_">Icons</span> <span class="hljs-keyword">as</span> <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-title class_">React</span>.<span class="hljs-property">ComponentType</span>&lt;<span class="hljs-built_in">any</span>&gt;&gt;)[iconName]; <span class="hljs-keyword">return</span> <span class="hljs-title class_">IconComponent</span> ? <span class="hljs-title class_">React</span>.<span class="hljs-title function_">createElement</span>(<span class="hljs-title class_">IconComponent</span>) : <span class="hljs-literal">null</span>; }; <span class="hljs-keyword">interface</span> <span class="hljs-title class_">MenuItem</span> { <span class="hljs-attr">key</span>: <span class="hljs-built_in">string</span>; <span class="hljs-attr">label</span>: <span class="hljs-built_in">string</span>; icon?: <span class="hljs-title class_">React</span>.<span class="hljs-property">ReactNode</span>; children?: <span class="hljs-title class_">MenuItem</span>[]; } <span class="hljs-keyword">const</span> buildMenuItems = (<span class="hljs-attr">routeList</span>: <span class="hljs-title class_">AppRouteObject</span>[], parentPath = <span class="hljs-string">''</span>): <span class="hljs-title class_">MenuItem</span>[] =&gt; { <span class="hljs-keyword">return</span> routeList .<span class="hljs-title function_">filter</span>(<span class="hljs-function">(<span class="hljs-params">r</span>) =&gt;</span> !r.<span class="hljs-property">meta</span>?.<span class="hljs-property">hidden</span> &amp;&amp; r.<span class="hljs-property">path</span>) .<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">route</span>) =&gt;</span> { <span class="hljs-keyword">const</span> fullPath = parentPath ? <span class="hljs-string">`<span class="hljs-subst">${parentPath}</span>/<span class="hljs-subst">${route.path}</span>`</span> : route.<span class="hljs-property">path</span> || <span class="hljs-string">''</span>; <span class="hljs-keyword">const</span> <span class="hljs-attr">item</span>: <span class="hljs-title class_">MenuItem</span> = { <span class="hljs-attr">key</span>: fullPath, <span class="hljs-attr">label</span>: route.<span class="hljs-property">meta</span>?.<span class="hljs-property">title</span> || route.<span class="hljs-property">path</span> || <span class="hljs-string">''</span>, <span class="hljs-attr">icon</span>: <span class="hljs-title function_">getIcon</span>(route.<span class="hljs-property">meta</span>?.<span class="hljs-property">icon</span>), }; <span class="hljs-keyword">if</span> (route.<span class="hljs-property">children</span>?.<span class="hljs-property">length</span>) { <span class="hljs-keyword">const</span> children = <span class="hljs-title function_">buildMenuItems</span>(route.<span class="hljs-property">children</span>, fullPath); <span class="hljs-keyword">if</span> (children.<span class="hljs-property">length</span> &gt; <span class="hljs-number">0</span>) { item.<span class="hljs-property">children</span> = children; } } <span class="hljs-keyword">return</span> item; }) .<span class="hljs-title function_">filter</span>(<span class="hljs-function">(<span class="hljs-params">item</span>) =&gt;</span> item.<span class="hljs-property">children</span> || item.<span class="hljs-property">label</span>); }; <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Sidebar</span>(<span class="hljs-params"></span>) { <span class="hljs-keyword">const</span> navigate = <span class="hljs-title function_">useNavigate</span>(); <span class="hljs-keyword">const</span> location = <span class="hljs-title function_">useLocation</span>(); <span class="hljs-keyword">const</span> menuItems = <span class="hljs-title function_">buildMenuItems</span>(routes[<span class="hljs-number">1</span>].<span class="hljs-property">children</span> || []); <span class="hljs-keyword">return</span> ( <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">Menu</span> <span class="hljs-attr">mode</span>=<span class="hljs-string">"inline"</span> <span class="hljs-attr">theme</span>=<span class="hljs-string">"dark"</span> <span class="hljs-attr">selectedKeys</span>=<span class="hljs-string">{[location.pathname]}</span> <span class="hljs-attr">items</span>=<span class="hljs-string">{menuItems}</span> <span class="hljs-attr">onClick</span>=<span class="hljs-string">{({</span> <span class="hljs-attr">key</span> }) =&gt;</span> navigate(key)} style={{ height: '100%', borderRight: 0 }} /&gt;</span> ); } </code></pre> <h3 data-id="heading-16">2.4.4 路由入口</h3> <p>创建 <code>src/router/index.tsx</code>:</p> <pre><code class="hljs language-tsx" lang="tsx"><span class="hljs-keyword">import</span> { createBrowserRouter, <span class="hljs-title class_">RouterProvider</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router-dom'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">Suspense</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">Spin</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'antd'</span>; <span class="hljs-keyword">import</span> { routes, <span class="hljs-title class_">AppRouteObject</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'./routes'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">PrivateRoute</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./PrivateRoute'</span>; <span class="hljs-keyword">const</span> renderRoutes = (<span class="hljs-attr">routeList</span>: <span class="hljs-title class_">AppRouteObject</span>[]): <span class="hljs-built_in">any</span>[] =&gt; { <span class="hljs-keyword">return</span> routeList.<span class="hljs-title function_">map</span>(<span class="hljs-function">(<span class="hljs-params">route</span>) =&gt;</span> { <span class="hljs-keyword">const</span> element = route.<span class="hljs-property">element</span> ? ( <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">PrivateRoute</span> <span class="hljs-attr">route</span>=<span class="hljs-string">{route}</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Suspense</span> <span class="hljs-attr">fallback</span>=<span class="hljs-string">{</span> &lt;<span class="hljs-attr">div</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">display:</span> '<span class="hljs-attr">flex</span>', <span class="hljs-attr">justifyContent:</span> '<span class="hljs-attr">center</span>', <span class="hljs-attr">padding:</span> <span class="hljs-attr">100</span> }}&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Spin</span> <span class="hljs-attr">size</span>=<span class="hljs-string">"large"</span> /&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span> } &gt; {route.element} <span class="hljs-tag">&lt;/<span class="hljs-name">Suspense</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">PrivateRoute</span>&gt;</span></span> ) : <span class="hljs-literal">undefined</span>; <span class="hljs-keyword">return</span> { <span class="hljs-attr">path</span>: route.<span class="hljs-property">path</span>, <span class="hljs-attr">index</span>: route.<span class="hljs-property">index</span>, element, <span class="hljs-attr">children</span>: route.<span class="hljs-property">children</span> ? <span class="hljs-title function_">renderRoutes</span>(route.<span class="hljs-property">children</span>) : <span class="hljs-literal">undefined</span>, }; }); }; <span class="hljs-keyword">const</span> router = <span class="hljs-title function_">createBrowserRouter</span>(<span class="hljs-title function_">renderRoutes</span>(routes)); <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> router; </code></pre> <hr> <h2 data-id="heading-17">2.5 通用 Hooks 封装</h2> <p>在进入业务开发之前,先准备一些高频使用的自定义 Hook。</p> <h3 data-id="heading-18">2.5.1 useTable —— 表格通用逻辑</h3> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-comment">// src/hooks/useTable.ts</span> <span class="hljs-keyword">import</span> { useState, useCallback, useEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">PageResponse</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@services/request'</span>; <span class="hljs-keyword">interface</span> <span class="hljs-title class_">UseTableOptions</span>&lt;<span class="hljs-title class_">TData</span>, <span class="hljs-title class_">TParams</span>&gt; { <span class="hljs-attr">fetchFn</span>: <span class="hljs-function">(<span class="hljs-params">params: TParams &amp; { page: <span class="hljs-built_in">number</span>; pageSize: <span class="hljs-built_in">number</span> }</span>) =&gt;</span> <span class="hljs-title class_">Promise</span>&lt;<span class="hljs-title class_">PageResponse</span>&lt;<span class="hljs-title class_">TData</span>&gt;&gt;; defaultParams?: <span class="hljs-title class_">TParams</span>; defaultPageSize?: <span class="hljs-built_in">number</span>; immediate?: <span class="hljs-built_in">boolean</span>; } <span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> useTable&lt;<span class="hljs-title class_">TData</span>, <span class="hljs-title class_">TParams</span> <span class="hljs-keyword">extends</span> <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">any</span>&gt; = <span class="hljs-title class_">Record</span>&lt;<span class="hljs-built_in">string</span>, <span class="hljs-built_in">any</span>&gt;&gt;( <span class="hljs-attr">options</span>: <span class="hljs-title class_">UseTableOptions</span>&lt;<span class="hljs-title class_">TData</span>, <span class="hljs-title class_">TParams</span>&gt;, ) { <span class="hljs-keyword">const</span> { fetchFn, defaultParams = {} <span class="hljs-keyword">as</span> <span class="hljs-title class_">TParams</span>, defaultPageSize = <span class="hljs-number">20</span>, immediate = <span class="hljs-literal">true</span> } = options; <span class="hljs-keyword">const</span> [dataSource, setDataSource] = useState&lt;<span class="hljs-title class_">TData</span>[]&gt;([]); <span class="hljs-keyword">const</span> [total, setTotal] = <span class="hljs-title function_">useState</span>(<span class="hljs-number">0</span>); <span class="hljs-keyword">const</span> [loading, setLoading] = <span class="hljs-title function_">useState</span>(<span class="hljs-literal">false</span>); <span class="hljs-keyword">const</span> [page, setPage] = <span class="hljs-title function_">useState</span>(<span class="hljs-number">1</span>); <span class="hljs-keyword">const</span> [pageSize, setPageSize] = <span class="hljs-title function_">useState</span>(defaultPageSize); <span class="hljs-keyword">const</span> [searchParams, setSearchParams] = useState&lt;<span class="hljs-title class_">TParams</span>&gt;(defaultParams); <span class="hljs-keyword">const</span> fetchData = <span class="hljs-title function_">useCallback</span>(<span class="hljs-keyword">async</span> () =&gt; { <span class="hljs-title function_">setLoading</span>(<span class="hljs-literal">true</span>); <span class="hljs-keyword">try</span> { <span class="hljs-keyword">const</span> res = <span class="hljs-keyword">await</span> <span class="hljs-title function_">fetchFn</span>({ ...searchParams, page, pageSize }); <span class="hljs-title function_">setDataSource</span>(res.<span class="hljs-property">list</span>); <span class="hljs-title function_">setTotal</span>(res.<span class="hljs-property">total</span>); } <span class="hljs-keyword">finally</span> { <span class="hljs-title function_">setLoading</span>(<span class="hljs-literal">false</span>); } }, [fetchFn, searchParams, page, pageSize]); <span class="hljs-title function_">useEffect</span>(<span class="hljs-function">() =&gt;</span> { <span class="hljs-keyword">if</span> (immediate) { <span class="hljs-title function_">fetchData</span>(); } }, [fetchData, immediate]); <span class="hljs-keyword">const</span> <span class="hljs-title function_">handleSearch</span> = (<span class="hljs-params">params: TParams</span>) =&gt; { <span class="hljs-title function_">setSearchParams</span>(params); <span class="hljs-title function_">setPage</span>(<span class="hljs-number">1</span>); }; <span class="hljs-keyword">const</span> <span class="hljs-title function_">handleReset</span> = (<span class="hljs-params"></span>) =&gt; { <span class="hljs-title function_">setSearchParams</span>(defaultParams); <span class="hljs-title function_">setPage</span>(<span class="hljs-number">1</span>); }; <span class="hljs-keyword">const</span> <span class="hljs-title function_">handlePageChange</span> = (<span class="hljs-params">newPage: <span class="hljs-built_in">number</span>, newPageSize: <span class="hljs-built_in">number</span></span>) =&gt; { <span class="hljs-title function_">setPage</span>(newPage); <span class="hljs-title function_">setPageSize</span>(newPageSize); }; <span class="hljs-keyword">const</span> <span class="hljs-title function_">refresh</span> = (<span class="hljs-params"></span>) =&gt; { <span class="hljs-title function_">fetchData</span>(); }; <span class="hljs-keyword">return</span> { dataSource, total, loading, page, pageSize, searchParams, handleSearch, handleReset, handlePageChange, refresh, }; } </code></pre> <h3 data-id="heading-19">2.5.2 useDebounce —— 防抖</h3> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-comment">// src/hooks/useDebounce.ts</span> <span class="hljs-keyword">import</span> { useState, useEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">'react'</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> useDebounce&lt;T&gt;(<span class="hljs-attr">value</span>: T, delay = <span class="hljs-number">300</span>): T { <span class="hljs-keyword">const</span> [debouncedValue, setDebouncedValue] = <span class="hljs-title function_">useState</span>(value); <span class="hljs-title function_">useEffect</span>(<span class="hljs-function">() =&gt;</span> { <span class="hljs-keyword">const</span> timer = <span class="hljs-built_in">setTimeout</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">setDebouncedValue</span>(value), delay); <span class="hljs-keyword">return</span> <span class="hljs-function">() =&gt;</span> <span class="hljs-built_in">clearTimeout</span>(timer); }, [value, delay]); <span class="hljs-keyword">return</span> debouncedValue; } </code></pre> <hr> <h2 data-id="heading-20">本章小结</h2> <table><thead><tr><th>知识点</th><th>关键内容</th></tr></thead><tbody><tr><td>架构分层</td><td>页面层 / 组件层 / Hook 层 / 状态层 / 服务层 / 类型层,自上而下依赖</td></tr><tr><td>状态管理</td><td>基于 <code>useSyncExternalStore</code> 实现轻量 Store,分全局和页面级</td></tr><tr><td>API 封装</td><td>axios 拦截器统一处理 token、错误、防重复请求、响应解包</td></tr><tr><td>路由体系</td><td>嵌套布局、路由懒加载、权限守卫、动态菜单生成</td></tr><tr><td>通用 Hook</td><td><code>useTable</code> 封装表格 CRUD 逻辑,<code>useDebounce</code> 防抖</td></tr></tbody></table> <p><strong>下一章预告</strong>:我们开始做第一个完整的业务模块——工作台数据看板,学习统计卡片、ECharts 图表、双栏布局等常见模式。</p>

2026/9/23
阅读更多

第 1 章:项目初始化与技术选型

<h2 data-id="heading-0">本章学习目标</h2> <ul> <li>理解为什么选择 React 19 + TypeScript + Vite 技术栈</li> <li>从零搭建一个企业级前端项目脚手架</li> <li>掌握目录结构设计的思路</li> <li>学会配置 ESLint、Prettier 和路径别名</li> <li>了解多环境配置方案</li> <li>接入 Ant Design 组件库</li> </ul> <hr> <h2 data-id="heading-1">1.1 为什么选这套技术栈</h2> <p>在开始写代码之前,我们先聊聊技术选型的思考。</p> <h3 data-id="heading-2">React 19</h3> <p>React 19 带来了很多新特性,对后台系统开发特别友好:</p> <ul> <li><strong>useOptimistic</strong>:乐观更新,提升操作反馈体验</li> <li><strong>useActionState</strong>:表单处理更简洁</li> <li><strong>ref 作为 prop</strong>:不再需要 forwardRef 包裹</li> <li><strong>Server Components</strong>:复杂场景下的性能优化手段</li> </ul> <h3 data-id="heading-3">TypeScript</h3> <p>后台系统的核心痛点是<strong>数据模型复杂</strong>、<strong>字段多</strong>、<strong>状态流转繁琐</strong>。TypeScript 能在编译期就发现大量 Bug,是企业级项目的必选项。</p> <h3 data-id="heading-4">Vite</h3> <p>相比 Webpack,Vite 的开发体验提升明显:</p> <ul> <li><strong>冷启动快</strong>:基于 ESM,不需要打包整个项目</li> <li><strong>HMR 快</strong>:模块级热更新,改完代码秒级刷新</li> <li><strong>配置简单</strong>:开箱即用,插件生态成熟</li> </ul> <h3 data-id="heading-5">Ant Design 5.x</h3> <p>企业级后台最常用的组件库,没有之一:</p> <ul> <li>组件丰富(表格、表单、弹窗、树…应有尽有)</li> <li>设计规范统一</li> <li>主题定制方便(CSS-in-JS)</li> <li>TypeScript 支持好</li> </ul> <hr> <h2 data-id="heading-6">1.2 从零搭建项目脚手架</h2> <h3 data-id="heading-7">1.2.1 初始化 Vite 项目</h3> <pre><code class="hljs language-bash" lang="bash">npm create vite@latest admin-hub -- --template react-ts <span class="hljs-built_in">cd</span> admin-hub npm install </code></pre> <h3 data-id="heading-8">1.2.2 安装基础依赖</h3> <pre><code class="hljs language-bash" lang="bash"><span class="hljs-comment"># 路由</span> npm install react-router-dom <span class="hljs-comment"># HTTP 请求</span> npm install axios <span class="hljs-comment"># UI 组件库</span> npm install antd @ant-design/icons <span class="hljs-comment"># 图表</span> npm install echarts echarts-for-react <span class="hljs-comment"># 工具库</span> npm install dayjs lodash-es <span class="hljs-comment"># 开发依赖 - 代码规范</span> npm install -D eslint prettier eslint-config-prettier eslint-plugin-prettier npm install -D @typescript-eslint/parser @typescript-eslint/eslint-plugin npm install -D eslint-plugin-react-hooks eslint-plugin-react-refresh </code></pre> <hr> <h2 data-id="heading-9">1.3 目录结构设计</h2> <p>一个清晰的目录结构是项目可维护的基础。推荐以下分层:</p> <pre><code class="hljs language-bash" lang="bash">src/ ├── assets/ <span class="hljs-comment"># 静态资源(图片、字体等)</span> ├── components/ <span class="hljs-comment"># 通用组件(可跨页面复用)</span> │ ├── StatCard/ <span class="hljs-comment"># 统计卡片</span> │ ├── TimeLine/ <span class="hljs-comment"># 时间线</span> │ └── Empty/ <span class="hljs-comment"># 空状态</span> ├── hooks/ <span class="hljs-comment"># 自定义 Hooks(跨页面复用的逻辑)</span> │ ├── usePagination.ts <span class="hljs-comment"># 分页逻辑</span> │ ├── useDebounce.ts <span class="hljs-comment"># 防抖</span> │ └── useTable.ts <span class="hljs-comment"># 表格通用逻辑</span> ├── layouts/ <span class="hljs-comment"># 布局组件</span> │ ├── BasicLayout/ <span class="hljs-comment"># 基础布局(侧边栏 + 顶栏 + 内容)</span> │ └── BlankLayout/ <span class="hljs-comment"># 空白布局(登录页等)</span> ├── pages/ <span class="hljs-comment"># 页面层(按业务模块划分)</span> │ ├── workbench/ <span class="hljs-comment"># 工作台</span> │ ├── ticket/ <span class="hljs-comment"># 任务单管理</span> │ ├── approval/ <span class="hljs-comment"># 审批管理</span> │ ├── scheduled-task/ <span class="hljs-comment"># 定时任务</span> │ ├── ai-assistant/ <span class="hljs-comment"># AI 助手</span> │ ├── knowledge/ <span class="hljs-comment"># 知识中心</span> │ └── statistics/ <span class="hljs-comment"># 数据统计</span> ├── services/ <span class="hljs-comment"># 服务层(API 调用)</span> │ ├── request.ts <span class="hljs-comment"># axios 实例与拦截器</span> │ ├── ticket.ts <span class="hljs-comment"># 任务单相关接口</span> │ └── ... ├── store/ <span class="hljs-comment"># 状态管理</span> │ ├── createStore.ts <span class="hljs-comment"># 轻量 Store 工厂函数</span> │ ├── useUserStore.ts <span class="hljs-comment"># 用户信息(全局)</span> │ └── useTicketStore.ts<span class="hljs-comment"># 任务单(页面级)</span> ├── types/ <span class="hljs-comment"># 类型定义</span> │ ├── api.ts <span class="hljs-comment"># 通用接口类型</span> │ ├── ticket.ts <span class="hljs-comment"># 任务单类型</span> │ └── ... ├── utils/ <span class="hljs-comment"># 工具函数</span> │ ├── format.ts <span class="hljs-comment"># 格式化工具</span> │ ├── storage.ts <span class="hljs-comment"># 本地存储封装</span> │ └── validate.ts <span class="hljs-comment"># 校验规则</span> ├── router/ <span class="hljs-comment"># 路由配置</span> │ ├── index.tsx <span class="hljs-comment"># 路由入口</span> │ └── routes.ts <span class="hljs-comment"># 路由表定义</span> ├── App.tsx <span class="hljs-comment"># 根组件</span> └── main.tsx <span class="hljs-comment"># 入口文件</span> </code></pre> <h3 data-id="heading-10">为什么这样分?</h3> <table><thead><tr><th>目录</th><th>职责</th><th>特点</th></tr></thead><tbody><tr><td>pages/</td><td>页面容器,组合组件与业务逻辑</td><td>按业务模块分,每个模块一个文件夹</td></tr><tr><td>components/</td><td>纯 UI 组件,不包含业务逻辑</td><td>可在任何页面复用</td></tr><tr><td>hooks/</td><td>逻辑复用单元</td><td>抽离状态逻辑,组件只负责渲染</td></tr><tr><td>services/</td><td>数据获取层</td><td>统一管理 API,便于 mock 和切换</td></tr><tr><td>store/</td><td>状态管理</td><td>全局状态放这里,页面级状态尽量用 useState</td></tr><tr><td>types/</td><td>类型定义</td><td>业务类型集中管理,避免循环引用</td></tr></tbody></table> <hr> <h2 data-id="heading-11">1.4 配置 ESLint + Prettier + 路径别名</h2> <h3 data-id="heading-12">1.4.1 ESLint 配置</h3> <p>在项目根目录创建 <code>.eslintrc.cjs</code>:</p> <pre><code class="hljs language-javascript" lang="javascript"><span class="hljs-variable language_">module</span>.<span class="hljs-property">exports</span> = { <span class="hljs-attr">root</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">env</span>: { <span class="hljs-attr">browser</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">es2021</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">node</span>: <span class="hljs-literal">true</span> }, <span class="hljs-attr">extends</span>: [ <span class="hljs-string">'eslint:recommended'</span>, <span class="hljs-string">'plugin:@typescript-eslint/recommended'</span>, <span class="hljs-string">'plugin:react-hooks/recommended'</span>, <span class="hljs-string">'plugin:prettier/recommended'</span>, ], <span class="hljs-attr">ignorePatterns</span>: [<span class="hljs-string">'dist'</span>, <span class="hljs-string">'.eslintrc.cjs'</span>], <span class="hljs-attr">parser</span>: <span class="hljs-string">'@typescript-eslint/parser'</span>, <span class="hljs-attr">parserOptions</span>: { <span class="hljs-attr">ecmaVersion</span>: <span class="hljs-string">'latest'</span>, <span class="hljs-attr">sourceType</span>: <span class="hljs-string">'module'</span>, <span class="hljs-attr">ecmaFeatures</span>: { <span class="hljs-attr">jsx</span>: <span class="hljs-literal">true</span> }, }, <span class="hljs-attr">settings</span>: { <span class="hljs-attr">react</span>: { <span class="hljs-attr">version</span>: <span class="hljs-string">'detect'</span> } }, <span class="hljs-attr">plugins</span>: [<span class="hljs-string">'react-refresh'</span>], <span class="hljs-attr">rules</span>: { <span class="hljs-string">'react-refresh/only-export-components'</span>: [ <span class="hljs-string">'warn'</span>, { <span class="hljs-attr">allowConstantExport</span>: <span class="hljs-literal">true</span> }, ], <span class="hljs-string">'@typescript-eslint/no-unused-vars'</span>: [<span class="hljs-string">'warn'</span>, { <span class="hljs-attr">argsIgnorePattern</span>: <span class="hljs-string">'^_'</span> }], <span class="hljs-string">'@typescript-eslint/no-explicit-any'</span>: <span class="hljs-string">'warn'</span>, }, }; </code></pre> <h3 data-id="heading-13">1.4.2 Prettier 配置</h3> <p>创建 <code>.prettierrc</code>:</p> <pre><code class="hljs language-json" lang="json"><span class="hljs-punctuation">{</span> <span class="hljs-attr">"semi"</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">true</span></span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"singleQuote"</span><span class="hljs-punctuation">:</span> <span class="hljs-literal"><span class="hljs-keyword">true</span></span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"trailingComma"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"all"</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"printWidth"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">100</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"tabWidth"</span><span class="hljs-punctuation">:</span> <span class="hljs-number">2</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"arrowParens"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"always"</span> <span class="hljs-punctuation">}</span> </code></pre> <h3 data-id="heading-14">1.4.3 路径别名</h3> <p>Vite 配置 <code>vite.config.ts</code>:</p> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-keyword">import</span> { defineConfig } <span class="hljs-keyword">from</span> <span class="hljs-string">'vite'</span>; <span class="hljs-keyword">import</span> react <span class="hljs-keyword">from</span> <span class="hljs-string">'@vitejs/plugin-react'</span>; <span class="hljs-keyword">import</span> path <span class="hljs-keyword">from</span> <span class="hljs-string">'path'</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-title function_">defineConfig</span>({ <span class="hljs-attr">plugins</span>: [<span class="hljs-title function_">react</span>()], <span class="hljs-attr">resolve</span>: { <span class="hljs-attr">alias</span>: { <span class="hljs-string">'@'</span>: path.<span class="hljs-title function_">resolve</span>(__dirname, <span class="hljs-string">'src'</span>), <span class="hljs-string">'@components'</span>: path.<span class="hljs-title function_">resolve</span>(__dirname, <span class="hljs-string">'src/components'</span>), <span class="hljs-string">'@hooks'</span>: path.<span class="hljs-title function_">resolve</span>(__dirname, <span class="hljs-string">'src/hooks'</span>), <span class="hljs-string">'@services'</span>: path.<span class="hljs-title function_">resolve</span>(__dirname, <span class="hljs-string">'src/services'</span>), <span class="hljs-string">'@store'</span>: path.<span class="hljs-title function_">resolve</span>(__dirname, <span class="hljs-string">'src/store'</span>), <span class="hljs-string">'@utils'</span>: path.<span class="hljs-title function_">resolve</span>(__dirname, <span class="hljs-string">'src/utils'</span>), <span class="hljs-string">'@pages'</span>: path.<span class="hljs-title function_">resolve</span>(__dirname, <span class="hljs-string">'src/pages'</span>), <span class="hljs-string">'@types'</span>: path.<span class="hljs-title function_">resolve</span>(__dirname, <span class="hljs-string">'src/types'</span>), }, }, }); </code></pre> <p>同步配置 <code>tsconfig.json</code> 的 <code>compilerOptions</code>:</p> <pre><code class="hljs language-json" lang="json"><span class="hljs-punctuation">{</span> <span class="hljs-attr">"compilerOptions"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">"baseUrl"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"."</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"paths"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">"@/*"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">"src/*"</span><span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"@components/*"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">"src/components/*"</span><span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"@hooks/*"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">"src/hooks/*"</span><span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"@services/*"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">"src/services/*"</span><span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"@store/*"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">"src/store/*"</span><span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"@utils/*"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">"src/utils/*"</span><span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"@pages/*"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">"src/pages/*"</span><span class="hljs-punctuation">]</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"@types/*"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">[</span><span class="hljs-string">"src/types/*"</span><span class="hljs-punctuation">]</span> <span class="hljs-punctuation">}</span> <span class="hljs-punctuation">}</span> <span class="hljs-punctuation">}</span> </code></pre> <hr> <h2 data-id="heading-15">1.5 环境变量与多环境配置</h2> <p>后台系统通常有三个环境:开发、测试、生产。</p> <h3 data-id="heading-16">1.5.1 环境变量文件</h3> <p>在项目根目录创建:</p> <pre><code class="hljs language-env" lang="env"># .env.development —— 开发环境 VITE_API_BASE_URL=/api VITE_APP_TITLE=AdminHub (开发环境) </code></pre> <pre><code class="hljs language-env" lang="env"># .env.staging —— 测试环境 VITE_API_BASE_URL=https://staging-api.example.com VITE_APP_TITLE=AdminHub (测试环境) </code></pre> <pre><code class="hljs language-env" lang="env"># .env.production —— 生产环境 VITE_API_BASE_URL=https://api.example.com VITE_APP_TITLE=AdminHub </code></pre> <h3 data-id="heading-17">1.5.2 类型声明</h3> <p>在 <code>src/vite-env.d.ts</code> 中补充类型:</p> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-comment">/// &lt;reference types="vite/client" /&gt;</span> <span class="hljs-keyword">interface</span> <span class="hljs-title class_">ImportMetaEnv</span> { <span class="hljs-keyword">readonly</span> <span class="hljs-attr">VITE_API_BASE_URL</span>: <span class="hljs-built_in">string</span>; <span class="hljs-keyword">readonly</span> <span class="hljs-attr">VITE_APP_TITLE</span>: <span class="hljs-built_in">string</span>; } <span class="hljs-keyword">interface</span> <span class="hljs-title class_">ImportMeta</span> { <span class="hljs-keyword">readonly</span> <span class="hljs-attr">env</span>: <span class="hljs-title class_">ImportMetaEnv</span>; } </code></pre> <h3 data-id="heading-18">1.5.3 package.json 脚本</h3> <pre><code class="hljs language-json" lang="json"><span class="hljs-punctuation">{</span> <span class="hljs-attr">"scripts"</span><span class="hljs-punctuation">:</span> <span class="hljs-punctuation">{</span> <span class="hljs-attr">"dev"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"vite"</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"build:staging"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"tsc &amp;&amp; vite build --mode staging"</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"build:prod"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"tsc &amp;&amp; vite build --mode production"</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"preview"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"vite preview"</span><span class="hljs-punctuation">,</span> <span class="hljs-attr">"lint"</span><span class="hljs-punctuation">:</span> <span class="hljs-string">"eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"</span> <span class="hljs-punctuation">}</span> <span class="hljs-punctuation">}</span> </code></pre> <hr> <h2 data-id="heading-19">1.6 接入 Ant Design 组件库</h2> <h3 data-id="heading-20">1.6.1 基础配置</h3> <p>在 <code>App.tsx</code> 中配置全局主题:</p> <pre><code class="hljs language-tsx" lang="tsx"><span class="hljs-keyword">import</span> { <span class="hljs-title class_">ConfigProvider</span>, <span class="hljs-title class_">App</span> <span class="hljs-keyword">as</span> <span class="hljs-title class_">AntdApp</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'antd'</span>; <span class="hljs-keyword">import</span> zhCN <span class="hljs-keyword">from</span> <span class="hljs-string">'antd/locale/zh_CN'</span>; <span class="hljs-keyword">import</span> <span class="hljs-string">'dayjs/locale/zh-cn'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">RouterProvider</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'react-router-dom'</span>; <span class="hljs-keyword">import</span> router <span class="hljs-keyword">from</span> <span class="hljs-string">'./router'</span>; <span class="hljs-keyword">const</span> theme = { <span class="hljs-attr">token</span>: { <span class="hljs-attr">colorPrimary</span>: <span class="hljs-string">'#1677ff'</span>, <span class="hljs-attr">borderRadius</span>: <span class="hljs-number">6</span>, <span class="hljs-attr">fontFamily</span>: <span class="hljs-string">'"PingFang SC", "Microsoft YaHei", sans-serif'</span>, }, }; <span class="hljs-keyword">function</span> <span class="hljs-title function_">App</span>(<span class="hljs-params"></span>) { <span class="hljs-keyword">return</span> ( <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">ConfigProvider</span> <span class="hljs-attr">locale</span>=<span class="hljs-string">{zhCN}</span> <span class="hljs-attr">theme</span>=<span class="hljs-string">{theme}</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">AntdApp</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">RouterProvider</span> <span class="hljs-attr">router</span>=<span class="hljs-string">{router}</span> /&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">AntdApp</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">ConfigProvider</span>&gt;</span></span> ); } <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-title class_">App</span>; </code></pre> <blockquote> <p><strong>为什么用 AntdApp?</strong> Ant Design 5.x 推荐用 <code>App</code> 组件来获取全局的 <code>message</code>、<code>modal</code>、<code>notification</code> 实例,不需要再手动 import 和处理 Context。</p> </blockquote> <h3 data-id="heading-21">1.6.2 按需加载</h3> <p>Ant Design 5.x 默认支持 Tree Shaking,不需要额外配置按需加载插件。直接 <code>import { Button } from 'antd'</code> 即可。</p> <hr> <h2 data-id="heading-22">1.7 第一个页面:Hello AdminHub</h2> <p>在 <code>src/pages/demo/Hello.tsx</code> 写一个简单的验证页面:</p> <pre><code class="hljs language-tsx" lang="tsx"><span class="hljs-keyword">import</span> { <span class="hljs-title class_">Button</span>, <span class="hljs-title class_">Card</span>, <span class="hljs-title class_">Space</span>, <span class="hljs-title class_">Typography</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'antd'</span>; <span class="hljs-keyword">import</span> { <span class="hljs-title class_">SmileOutlined</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@ant-design/icons'</span>; <span class="hljs-keyword">const</span> { <span class="hljs-title class_">Title</span>, <span class="hljs-title class_">Paragraph</span> } = <span class="hljs-title class_">Typography</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">Hello</span>(<span class="hljs-params"></span>) { <span class="hljs-keyword">return</span> ( <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">div</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">padding:</span> <span class="hljs-attr">24</span> }}&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Card</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Space</span> <span class="hljs-attr">direction</span>=<span class="hljs-string">"vertical"</span> <span class="hljs-attr">size</span>=<span class="hljs-string">"middle"</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">width:</span> '<span class="hljs-attr">100</span>%' }}&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Title</span> <span class="hljs-attr">level</span>=<span class="hljs-string">{2}</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">SmileOutlined</span> <span class="hljs-attr">style</span>=<span class="hljs-string">{{</span> <span class="hljs-attr">marginRight:</span> <span class="hljs-attr">8</span> }} /&gt;</span> 欢迎使用 AdminHub <span class="hljs-tag">&lt;/<span class="hljs-name">Title</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Paragraph</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"secondary"</span>&gt;</span> 这是你的第一个后台页面。接下来我们会一步步构建完整的管理系统。 <span class="hljs-tag">&lt;/<span class="hljs-name">Paragraph</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">Button</span> <span class="hljs-attr">type</span>=<span class="hljs-string">"primary"</span>&gt;</span>开始学习<span class="hljs-tag">&lt;/<span class="hljs-name">Button</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Space</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">Card</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span></span> ); } </code></pre> <p>运行 <code>npm run dev</code>,看到页面说明项目搭建成功。</p> <hr> <h2 data-id="heading-23">本章小结</h2> <table><thead><tr><th>知识点</th><th>关键内容</th></tr></thead><tbody><tr><td>技术选型</td><td>React 19 + TypeScript + Vite + Ant Design 5.x</td></tr><tr><td>目录结构</td><td>按职责分层:pages / components / hooks / services / store / utils</td></tr><tr><td>代码规范</td><td>ESLint 检查代码质量,Prettier 统一格式</td></tr><tr><td>路径别名</td><td>Vite + TSConfig 双配置,使用 <code>@/</code> 替代深层相对路径</td></tr><tr><td>多环境</td><td><code>.env.*</code> 文件 + <code>--mode</code> 参数,三环境隔离</td></tr><tr><td>UI 框架</td><td>Ant Design 5.x,通过 ConfigProvider 配置主题和语言</td></tr></tbody></table> <p><strong>下一章预告</strong>:我们会深入架构设计,实现一套轻量级状态管理方案,并完成 axios 的统一封装和路由体系搭建。</p>

2026/9/23
阅读更多

Vue3框架项目初始化

<h2 data-id="heading-0">创建项目</h2> <ul> <li> <p>vite 创建项目</p> <pre><code class="hljs language-lua" lang="lua">pnpm <span class="hljs-built_in">create</span> vite </code></pre> </li> <li> <p>下载依赖</p> <pre><code class="hljs language-css" lang="css">pnpm <span class="hljs-selector-tag">i</span> </code></pre> </li> <li> <p>启动项目</p> <pre><code class="hljs">pnpm dev </code></pre> </li> </ul> <h2 data-id="heading-1">路由配置</h2> <ul> <li> <p>下载插件</p> <pre><code class="hljs language-css" lang="css">pnpm install vue-router<span class="hljs-keyword">@4</span>.4.5 </code></pre> </li> <li> <p>创建路由页面</p> </li> <li> <p>路由映射</p> <pre><code class="hljs language-javascript" lang="javascript"><span class="hljs-keyword">import</span> { createRouter, createWebHashHistory } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue-router'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">Home</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./pages/home.vue'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">ArticleDetail</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./pages/article-detail.vue'</span>; <span class="hljs-keyword">const</span> routes = [ { <span class="hljs-attr">path</span>: <span class="hljs-string">'/'</span>, <span class="hljs-attr">redirect</span>: <span class="hljs-string">'/home'</span> }, { <span class="hljs-attr">path</span>: <span class="hljs-string">'/home'</span>, <span class="hljs-attr">component</span>: <span class="hljs-title class_">Home</span> }, { <span class="hljs-attr">path</span>: <span class="hljs-string">'/article-detail'</span>, <span class="hljs-attr">component</span>: <span class="hljs-title class_">ArticleDetail</span> }, ]; <span class="hljs-keyword">const</span> router = <span class="hljs-title function_">createRouter</span>({ <span class="hljs-attr">history</span>: <span class="hljs-title function_">createWebHashHistory</span>(), routes, }); <span class="hljs-keyword">export</span> <span class="hljs-keyword">default</span> router; </code></pre> </li> <li> <p>注册路由</p> <pre><code class="hljs language-javascript" lang="javascript"><span class="hljs-keyword">import</span> { createApp } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span>; <span class="hljs-keyword">import</span> <span class="hljs-string">'./style.css'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">App</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./App.vue'</span>; <span class="hljs-comment">// 路由</span> <span class="hljs-keyword">import</span> router <span class="hljs-keyword">from</span> <span class="hljs-string">'./router.js'</span>; <span class="hljs-keyword">const</span> app = <span class="hljs-title function_">createApp</span>(<span class="hljs-title class_">App</span>); app.<span class="hljs-title function_">use</span>(router).<span class="hljs-title function_">mount</span>(<span class="hljs-string">'#app'</span>); </code></pre> </li> </ul> <h2 data-id="heading-2">状态管理Pinia配置</h2> <ul> <li> <p>下载插件</p> <pre><code class="hljs">pnpm install pinia pinia-plugin-persistedstate </code></pre> </li> <li> <p>store/user.js</p> <pre><code class="hljs language-javascript" lang="javascript"><span class="hljs-keyword">import</span> { ref } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span>; <span class="hljs-keyword">import</span> { defineStore } <span class="hljs-keyword">from</span> <span class="hljs-string">'pinia'</span>; <span class="hljs-keyword">export</span> <span class="hljs-keyword">const</span> userStore = <span class="hljs-title function_">defineStore</span>( <span class="hljs-string">'user'</span>, <span class="hljs-function">() =&gt;</span> { <span class="hljs-keyword">const</span> username = <span class="hljs-title function_">ref</span>(<span class="hljs-string">''</span>); <span class="hljs-keyword">const</span> <span class="hljs-title function_">changeUsername</span> = (<span class="hljs-params">value</span>) =&gt; { username.<span class="hljs-property">value</span> = value; }; <span class="hljs-keyword">return</span> { username, changeUsername }; }, { <span class="hljs-attr">persist</span>: <span class="hljs-literal">true</span>, <span class="hljs-comment">// 数据持久化</span> }, ); </code></pre> </li> <li> <p>配置注册</p> <pre><code class="hljs language-javascript" lang="javascript"><span class="hljs-keyword">import</span> { createApp } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span>; <span class="hljs-keyword">import</span> <span class="hljs-string">'./style.css'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">App</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./App.vue'</span>; <span class="hljs-comment">// 路由</span> <span class="hljs-keyword">import</span> router <span class="hljs-keyword">from</span> <span class="hljs-string">'./router.js'</span>; <span class="hljs-comment">// Pinia</span> <span class="hljs-keyword">import</span> { createPinia } <span class="hljs-keyword">from</span> <span class="hljs-string">'pinia'</span>; <span class="hljs-keyword">import</span> piniaPluginPersistedstate <span class="hljs-keyword">from</span> <span class="hljs-string">'pinia-plugin-persistedstate'</span>; <span class="hljs-keyword">const</span> app = <span class="hljs-title function_">createApp</span>(<span class="hljs-title class_">App</span>); <span class="hljs-keyword">const</span> pinia = <span class="hljs-title function_">createPinia</span>(); <span class="hljs-comment">// 配置数据持久化</span> pinia.<span class="hljs-title function_">use</span>(piniaPluginPersistedstate); app.<span class="hljs-title function_">use</span>(router).<span class="hljs-title function_">use</span>(pinia).<span class="hljs-title function_">mount</span>(<span class="hljs-string">'#app'</span>); </code></pre> </li> <li> <p>测试使用</p> <pre><code class="hljs language-xml" lang="xml"><span class="hljs-tag">&lt;<span class="hljs-name">script</span> <span class="hljs-attr">setup</span>&gt;</span><span class="javascript"> <span class="hljs-keyword">import</span> { userStore } <span class="hljs-keyword">from</span> <span class="hljs-string">"../store/user"</span>; <span class="hljs-keyword">const</span> user = <span class="hljs-title function_">userStore</span>(); <span class="hljs-keyword">function</span> <span class="hljs-title function_">test</span>(<span class="hljs-params"></span>) { user.<span class="hljs-title function_">setUsername</span>(<span class="hljs-string">"Steven Wong"</span>); } </span><span class="hljs-tag">&lt;/<span class="hljs-name">script</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">template</span>&gt;</span> <span class="hljs-tag">&lt;<span class="hljs-name">div</span>&gt;</span>{{ user.username }}<span class="hljs-tag">&lt;/<span class="hljs-name">div</span>&gt;</span> <span class="hljs-tag">&lt;/<span class="hljs-name">template</span>&gt;</span> </code></pre> </li> </ul> <h2 data-id="heading-3">Element Plus配置</h2> <ul> <li> <p>下载插件</p> <pre><code class="hljs language-bash" lang="bash">pnpm install element-plus @element-plus/icons-vue </code></pre> <pre><code class="hljs language-javascript" lang="javascript"><span class="hljs-keyword">import</span> { createApp } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span>; <span class="hljs-keyword">import</span> <span class="hljs-string">'./style.css'</span>; <span class="hljs-keyword">import</span> <span class="hljs-title class_">App</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'./App.vue'</span>; <span class="hljs-comment">// 路由</span> <span class="hljs-keyword">import</span> router <span class="hljs-keyword">from</span> <span class="hljs-string">'./router.js'</span>; <span class="hljs-comment">// Pinia</span> <span class="hljs-keyword">import</span> { createPinia } <span class="hljs-keyword">from</span> <span class="hljs-string">'pinia'</span>; <span class="hljs-keyword">import</span> piniaPluginPersistedstate <span class="hljs-keyword">from</span> <span class="hljs-string">'pinia-plugin-persistedstate'</span>; <span class="hljs-comment">// Element Plus</span> <span class="hljs-keyword">import</span> <span class="hljs-title class_">ElementPlus</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'element-plus'</span>; <span class="hljs-keyword">import</span> <span class="hljs-string">'element-plus/dist/index.css'</span>; <span class="hljs-keyword">import</span> * <span class="hljs-keyword">as</span> <span class="hljs-title class_">ElementPlusIconsVue</span> <span class="hljs-keyword">from</span> <span class="hljs-string">'@element-plus/icons-vue'</span>; <span class="hljs-keyword">const</span> app = <span class="hljs-title function_">createApp</span>(<span class="hljs-title class_">App</span>); <span class="hljs-keyword">const</span> pinia = <span class="hljs-title function_">createPinia</span>(); <span class="hljs-comment">// 配置数据持久化</span> pinia.<span class="hljs-title function_">use</span>(piniaPluginPersistedstate); <span class="hljs-comment">// 自动引入图标</span> <span class="hljs-title class_">Object</span>.<span class="hljs-title function_">keys</span>(<span class="hljs-title class_">ElementPlusIconsVue</span>).<span class="hljs-title function_">forEach</span>(<span class="hljs-function">(<span class="hljs-params">key</span>) =&gt;</span> { app.<span class="hljs-title function_">component</span>(key, <span class="hljs-title class_">ElementPlusIconsVue</span>[key]); }); app.<span class="hljs-title function_">use</span>(router).<span class="hljs-title function_">use</span>(pinia).<span class="hljs-title function_">use</span>(<span class="hljs-title class_">ElementPlus</span>).<span class="hljs-title function_">mount</span>(<span class="hljs-string">'#app'</span>); </code></pre> </li> <li> <p>测试使用button</p> </li> </ul> <pre><code class="hljs language-bash" lang="bash">&lt;el-button <span class="hljs-built_in">type</span>=<span class="hljs-string">"primary"</span>&gt;测试&lt;/el-button&gt; </code></pre>

2026/9/23
阅读更多

Vue 3 中 watch 与 watchEffect 的深度解析:从用法差异到源码实现

<p>在 Vue 3 的 Composition API 中,<code>watch</code> 和 <code>watchEffect</code> 都用于观察响应式数据的变化并执行副作用,但两者的设计哲学和底层实现存在本质区别。理解这些差异,不仅有助于在开发中做出正确的选择,也能更深入地把握 Vue 3 响应式系统的运行机制。</p> <hr> <h2 data-id="heading-0">一、核心差异:一张表看清本质</h2> <table><thead><tr><th>特性维度</th><th><code>watch</code></th><th><code>watchEffect</code></th></tr></thead><tbody><tr><td><strong>依赖收集方式</strong></td><td>显式指定监听源(ref、getter、reactive 对象等)</td><td>自动追踪回调函数内部访问的所有响应式依赖</td></tr><tr><td><strong>初始执行时机</strong></td><td>默认惰性执行,仅在源变化时触发;可通过 <code>immediate: true</code> 立即执行</td><td>创建时立即执行一次,以收集初始依赖</td></tr><tr><td><strong>回调参数</strong></td><td>接收 <code>(newValue, oldValue)</code>,支持新旧值对比</td><td>无参数,仅能通过闭包访问当前最新值</td></tr><tr><td><strong>依赖精确度</strong></td><td>高,仅追踪显式声明的源</td><td>可能追踪到回调中所有被访问的响应式属性</td></tr><tr><td><strong>适用场景</strong></td><td>需要旧值对比、精确控制触发条件、条件性监听</td><td>依赖关系复杂或动态、无需旧值、初始化即执行的副作用</td></tr></tbody></table> <hr> <h2 data-id="heading-1">二、<code>watch</code> 的深入用法与源码剖析</h2> <h3 data-id="heading-2">2.1 基本使用</h3> <p><code>watch</code> 接受三个参数:<code>source</code>(监听源)、<code>callback</code>(回调函数)、<code>options</code>(配置对象)。</p> <p>javascript</p> <pre><code class="hljs language-javascript" lang="javascript"><span class="hljs-keyword">import</span> { ref, watch } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span> <span class="hljs-keyword">const</span> count = <span class="hljs-title function_">ref</span>(<span class="hljs-number">0</span>) <span class="hljs-title function_">watch</span>(count, <span class="hljs-function">(<span class="hljs-params">newVal, oldVal</span>) =&gt;</span> { <span class="hljs-variable language_">console</span>.<span class="hljs-title function_">log</span>(<span class="hljs-string">`count 从 <span class="hljs-subst">${oldVal}</span> 变为 <span class="hljs-subst">${newVal}</span>`</span>) }, { <span class="hljs-attr">immediate</span>: <span class="hljs-literal">true</span> }) </code></pre> <p><code>source</code> 可以是以下形式:</p> <ul> <li>一个 <code>ref</code> 或 <code>computed</code> 引用</li> <li>一个 <code>reactive</code> 对象(自动启用深度监听)</li> <li>一个 getter 函数(返回需要监听的值)</li> <li>上述类型的数组(同时监听多个源)</li> </ul> <p>javascript</p> <pre><code class="hljs language-scss" lang="scss"><span class="hljs-comment">// 监听 reactive 对象</span> const state = <span class="hljs-built_in">reactive</span>({ a: <span class="hljs-number">1</span>, nested: { b: <span class="hljs-number">2</span> } }) <span class="hljs-built_in">watch</span>(state, (newVal) =&gt; { <span class="hljs-comment">/* 自动深度监听 */</span> }) <span class="hljs-comment">// 监听 getter</span> <span class="hljs-built_in">watch</span>(() =&gt; state<span class="hljs-selector-class">.a</span> + state<span class="hljs-selector-class">.nested</span><span class="hljs-selector-class">.b</span>, (sum) =&gt; { <span class="hljs-comment">/* ... */</span> }) <span class="hljs-comment">// 监听多个源</span> <span class="hljs-built_in">watch</span>([refA, () =&gt; refB<span class="hljs-selector-class">.value</span>], ([newA, newB], [oldA, oldB]) =&gt; { <span class="hljs-comment">/* ... */</span> }) </code></pre> <h3 data-id="heading-3">2.2 源码入口:<code>watch</code> 与 <code>doWatch</code></h3> <p><code>watch</code> 函数的实现非常简洁,它本质上是一个包装层,最终将调用委托给 <code>doWatch</code>:</p> <p>javascript</p> <pre><code class="hljs language-bash" lang="bash"><span class="hljs-keyword">function</span> watch(<span class="hljs-built_in">source</span>, cb, options) { <span class="hljs-built_in">return</span> doWatch(<span class="hljs-built_in">source</span>, cb, options) } </code></pre> <p><code>doWatch</code> 是 <code>watch</code> 和 <code>watchEffect</code> 的共享核心,位于 <code>packages/runtime-core/src/apiWatch.ts</code> 中<a href="https://link.juejin.cn/?target=https%3A%2F%2Fcloud.tencent.com.cn%2Fdeveloper%2Farticle%2F2471531%3FpolicyId%3D1003%231" target="_blank" title="https://cloud.tencent.com.cn/developer/article/2471531?policyId=1003#1" ref="nofollow noopener noreferrer"></a>。两者的区别在进入 <code>doWatch</code> 之前就已经通过参数确定了:<code>watch</code> 传入非空的 <code>cb</code>,而 <code>watchEffect</code> 的 <code>cb</code> 为 <code>null</code>。</p> <h3 data-id="heading-4">2.3 监听源的标准化</h3> <p><code>doWatch</code> 的首要任务是将各种形式的 <code>source</code> 统一转换为一个 <code>getter</code> 函数。这个 <code>getter</code> 的作用是返回当前被监听的值,供 <code>ReactiveEffect</code> 执行时进行依赖追踪。</p> <p>javascript</p> <pre><code class="hljs language-ini" lang="ini">if (isRef(source)) { <span class="hljs-attr">getter</span> = () =&gt; source.value } else if (isReactive(source)) { <span class="hljs-attr">getter</span> = () =&gt; source <span class="hljs-attr">deep</span> = <span class="hljs-literal">true</span> // reactive 对象默认深度监听 } else if (isArray(source)) { <span class="hljs-attr">getter</span> = () =&gt; source.map(s =&gt; isRef(s) ? s.value : s()) } else if (isFunction(source)) { <span class="hljs-attr">getter</span> = source } </code></pre> <p>对于 <code>reactive</code> 对象,Vue 会自动启用深度监听,因为 <code>reactive</code> 的 Proxy 特性使得任何嵌套属性的访问都会被追踪。而对于 <code>ref</code>,<code>getter</code> 仅返回 <code>source.value</code>,是否深度监听取决于 <code>deep</code> 选项。</p> <h3 data-id="heading-5">2.4 <code>immediate</code> 的实现机制</h3> <p><code>immediate: true</code> 的核心逻辑在 <code>doWatch</code> 中处理:</p> <p>javascript</p> <pre><code class="hljs language-arduino" lang="arduino"><span class="hljs-type">const</span> runsImmediately = (cb &amp;&amp; immediate) || (!cb &amp;&amp; flush !== <span class="hljs-string">'post'</span>) </code></pre> <p>对于 <code>watch</code>,<code>runsImmediately</code> 取决于 <code>immediate</code> 选项;对于 <code>watchEffect</code>(<code>cb</code> 为 <code>null</code>),只要 <code>flush</code> 不是 <code>'post'</code>,就会立即执行。当 <code>runsImmediately</code> 为真时,<code>doWatch</code> 会在创建 <code>effect</code> 后立刻调用一次 <code>job()</code> 或 <code>effect.run()</code>。</p> <h3 data-id="heading-6">2.5 <code>flush</code> 选项与调度器</h3> <p><code>flush</code> 决定了副作用回调的执行时机,底层通过 <code>ReactiveEffect</code> 的 <code>scheduler</code> 实现:</p> <ul> <li><strong><code>'pre'</code>(默认)</strong> :在组件更新前执行,调度器将任务推入 <code>queueJob</code> 队列,在 <code>nextTick</code> 中异步执行。</li> <li><strong><code>'post'</code></strong> :在组件更新后执行,调度器使用 <code>queuePostFlushCb</code>,确保 DOM 已更新。</li> <li><strong><code>'sync'</code></strong> :同步执行,调度器直接调用 <code>job()</code>,不进入异步队列。</li> </ul> <p>javascript</p> <pre><code class="hljs language-ini" lang="ini">const <span class="hljs-attr">scheduler</span> = flush === <span class="hljs-string">'sync'</span> ? job : <span class="hljs-attr">flush</span> === <span class="hljs-string">'post'</span> ? () =&gt; queuePostFlushCb(job) : () =&gt; queueJob(job) </code></pre> <p><code>watchPostEffect</code> 和 <code>watchSyncEffect</code> 分别是 <code>watchEffect</code> 在 <code>flush: 'post'</code> 和 <code>flush: 'sync'</code> 下的语法糖。</p> <hr> <h2 data-id="heading-7">三、<code>watchEffect</code> 的深入用法与源码剖析</h2> <h3 data-id="heading-8">3.1 基本使用与自动依赖收集</h3> <p><code>watchEffect</code> 只需传入一个副作用函数,Vue 会自动追踪该函数执行过程中访问的所有响应式依赖,并在任何依赖变化时重新执行该函数。</p> <p>javascript</p> <pre><code class="hljs language-csharp" lang="csharp">import { <span class="hljs-keyword">ref</span>, watchEffect } <span class="hljs-keyword">from</span> <span class="hljs-string">'vue'</span> <span class="hljs-keyword">const</span> count = <span class="hljs-keyword">ref</span>(<span class="hljs-number">0</span>) <span class="hljs-keyword">const</span> <span class="hljs-built_in">double</span> = <span class="hljs-keyword">ref</span>(<span class="hljs-number">0</span>) watchEffect(() =&gt; { <span class="hljs-built_in">double</span>.<span class="hljs-keyword">value</span> = count.<span class="hljs-keyword">value</span> * <span class="hljs-number">2</span> console.log(<span class="hljs-string">'count:'</span>, count.<span class="hljs-keyword">value</span>, <span class="hljs-string">'double:'</span>, <span class="hljs-built_in">double</span>.<span class="hljs-keyword">value</span>) }) <span class="hljs-comment">// 立即执行,输出 count: 0 double: 0</span> </code></pre> <p>当 <code>count</code> 变化时,副作用函数会自动重新执行,<code>double</code> 也会随之更新。</p> <h3 data-id="heading-9">3.2 源码入口:极简的委托</h3> <p><code>watchEffect</code> 的实现同样是一个极简的委托:</p> <p>javascript</p> <pre><code class="hljs language-javascript" lang="javascript"><span class="hljs-keyword">export</span> <span class="hljs-keyword">function</span> <span class="hljs-title function_">watchEffect</span>(<span class="hljs-params">effect, options</span>) { <span class="hljs-keyword">return</span> <span class="hljs-title function_">doWatch</span>(effect, <span class="hljs-literal">null</span>, options) } </code></pre> <p>这里的关键在于:<code>cb</code> 参数为 <code>null</code>,因此 <code>doWatch</code> 会走 <code>watchEffect</code> 的处理分支<a href="https://link.juejin.cn/?target=https%3A%2F%2Fblog.csdn.net%2Fqq_51757896%2Farticle%2Fdetails%2F147295330%231" target="_blank" title="https://blog.csdn.net/qq_51757896/article/details/147295330#1" ref="nofollow noopener noreferrer"></a>。</p> <h3 data-id="heading-10">3.3 自动依赖收集的底层机制</h3> <p><code>watchEffect</code> 的依赖收集完全依托于 Vue 3 的响应式系统。当 <code>doWatch</code> 创建 <code>ReactiveEffect</code> 并执行 <code>effect.run()</code> 时,<code>ReactiveEffect</code> 会被设置为当前活跃的 effect。此时,副作用函数内部对任何响应式属性(<code>ref.value</code> 或 <code>reactive</code> 属性)的读取操作,都会触发 Proxy 的 <code>get</code> 拦截器,进而调用 <code>track</code> 方法,将当前 effect 添加到该属性的依赖集合中。</p> <p>javascript</p> <pre><code class="hljs language-csharp" lang="csharp"><span class="hljs-comment">// 简化后的依赖收集流程</span> <span class="hljs-function">function <span class="hljs-title">track</span>(<span class="hljs-params">target, key</span>)</span> { <span class="hljs-keyword">const</span> depsMap = targetMap.<span class="hljs-keyword">get</span>(target) <span class="hljs-keyword">const</span> dep = depsMap.<span class="hljs-keyword">get</span>(key) dep.<span class="hljs-keyword">add</span>(activeEffect) <span class="hljs-comment">// activeEffect 即为 watchEffect 的 ReactiveEffect</span> } </code></pre> <p>当依赖发生变化时,<code>trigger</code> 方法会遍历该属性的依赖集合,调用每个 effect 的调度器或直接执行 effect<a href="https://link.juejin.cn/?target=https%3A%2F%2Fblog.csdn.net%2Fqq_51757896%2Farticle%2Fdetails%2F147295330%231" target="_blank" title="https://blog.csdn.net/qq_51757896/article/details/147295330#1" ref="nofollow noopener noreferrer"></a>。</p> <h3 data-id="heading-11">3.4 动态依赖的“可加不可减”特性</h3> <p><code>watchEffect</code> 的依赖收集是<strong>动态的</strong>,但有一个重要限制:每次重新执行时,依赖集合会被<strong>清空后重新收集</strong>。这意味着:</p> <ul> <li>如果某次执行中某个依赖没有被访问,它将从依赖集合中移除,后续变化不再触发副作用。</li> <li>如果条件分支导致新的依赖被访问,它们会被加入依赖集合。</li> </ul> <p>javascript</p> <pre><code class="hljs language-scss" lang="scss">const enabled = <span class="hljs-built_in">ref</span>(false) const counter = <span class="hljs-built_in">ref</span>(<span class="hljs-number">0</span>) <span class="hljs-built_in">watchEffect</span>(() =&gt; { if (enabled.value) { console<span class="hljs-selector-class">.log</span>('counter:', counter.value) <span class="hljs-comment">// 仅当 enabled 为 true 时追踪 counter</span> } }) </code></pre> <p>当 <code>enabled</code> 为 <code>false</code> 时,<code>counter</code> 不会被追踪;只有 <code>enabled</code> 变为 <code>true</code> 后,<code>counter</code> 才进入依赖集合<a href="https://link.juejin.cn/?target=https%3A%2F%2Fbbs.huaweicloud.com%2Fblogs%2F470623%231" target="_blank" title="https://bbs.huaweicloud.com/blogs/470623#1" ref="nofollow noopener noreferrer"></a>。</p> <hr> <h2 data-id="heading-12">四、深层监听与 <code>deep</code> 选项的源码差异</h2> <h3 data-id="heading-13">4.1 <code>watch</code> 的 <code>deep</code> 处理</h3> <p>当 <code>watch</code> 的 <code>source</code> 是 <code>reactive</code> 对象时,<code>deep</code> 自动设为 <code>true</code>。对于 <code>ref</code> 或 getter,需要显式指定 <code>deep: true</code>。Vue 3.5+ 还支持 <code>deep</code> 传入数字,表示监听的层数:</p> <p>javascript</p> <pre><code class="hljs language-scss" lang="scss"><span class="hljs-built_in">watch</span>(state, cb, { deep: <span class="hljs-number">2</span> }) <span class="hljs-comment">// 只监听两层深度</span> </code></pre> <p>源码中通过 <code>traverse</code> 函数递归访问对象的每一层属性,从而建立完整的依赖关系。<code>deep: true</code> 等价于 <code>deep: Infinity</code>,即监听所有层级。</p> <h3 data-id="heading-14">4.2 <code>watchEffect</code> 与深层监听</h3> <p><code>watchEffect</code> 本身没有 <code>deep</code> 选项,因为它会自动追踪回调中实际访问的所有属性。如果回调中访问了嵌套对象的深层属性,该属性自然会被追踪;如果没有访问,则不会被追踪。这种“按需追踪”的方式比 <code>watch</code> 的 <code>deep: true</code> 更加精细,但在需要监听整个对象时,可能需要手动遍历或配合 <code>toRefs</code> 使用。</p> <hr> <h2 data-id="heading-15">五、副作用清理:<code>onCleanup</code> 与 <code>onInvalidate</code></h2> <h3 data-id="heading-16">5.1 基本用法</h3> <p><code>watch</code> 和 <code>watchEffect</code> 都支持副作用清理,用于处理异步请求竞态、定时器清理等场景。</p> <p>在 <code>watchEffect</code> 中,清理函数通过回调的第一个参数 <code>onCleanup</code> 注册:</p> <p>javascript</p> <pre><code class="hljs language-scss" lang="scss"><span class="hljs-built_in">watchEffect</span>((onCleanup) =&gt; { const controller = new <span class="hljs-built_in">AbortController</span>() <span class="hljs-built_in">fetch</span>(`/api/data/${id.value}`, { signal: controller.signal }) <span class="hljs-built_in">onCleanup</span>(() =&gt; controller<span class="hljs-selector-class">.abort</span>()) }) </code></pre> <p>在 <code>watch</code> 中,清理函数通过回调的第三个参数 <code>onCleanup</code> 注册:</p> <p>javascript</p> <pre><code class="hljs language-scss" lang="scss"><span class="hljs-built_in">watch</span>(id, async (newId, oldId, onCleanup) =&gt; { const controller = new <span class="hljs-built_in">AbortController</span>() <span class="hljs-built_in">onCleanup</span>(() =&gt; controller<span class="hljs-selector-class">.abort</span>()) const data = await <span class="hljs-built_in">fetch</span>(`/api/data/${newId}`, { signal: controller.signal }) }, { flush: <span class="hljs-string">'post'</span> }) </code></pre> <h3 data-id="heading-17">5.2 清理时机的源码实现</h3> <p>清理函数被存储在 <code>ReactiveEffect</code> 的 <code>onStop</code> 属性上。当副作用即将重新执行前、侦听器被手动停止、或组件卸载时,<code>onStop</code> 会被调用:</p> <p>javascript</p> <pre><code class="hljs language-javascript" lang="javascript"><span class="hljs-keyword">let</span> <span class="hljs-attr">cleanup</span>: (<span class="hljs-function">() =&gt;</span> <span class="hljs-keyword">void</span>) | <span class="hljs-literal">undefined</span> <span class="hljs-keyword">const</span> <span class="hljs-title function_">onCleanup</span> = (<span class="hljs-params">fn: () =&gt; <span class="hljs-keyword">void</span></span>) =&gt; { cleanup = runner.<span class="hljs-property">options</span>.<span class="hljs-property">onStop</span> = <span class="hljs-function">() =&gt;</span> <span class="hljs-title function_">fn</span>() } </code></pre> <p>在每次 <code>job</code> 执行前,<code>doWatch</code> 会先调用 <code>cleanup</code>,再执行新的副作用逻辑<a href="https://link.juejin.cn/?target=https%3A%2F%2Fblog.csdn.net%2Fqq_51757896%2Farticle%2Fdetails%2F147295330%231" target="_blank" title="https://blog.csdn.net/qq_51757896/article/details/147295330#1" ref="nofollow noopener noreferrer"></a>。Vue 3.5 还引入了 <code>onWatcherCleanup</code> 函数,可以在 <code>watch</code> / <code>watchEffect</code> 的同步执行阶段注册清理函数,提供了更灵活的 API。</p> <hr> <h2 data-id="heading-18">六、停止侦听与 <code>pause</code> / <code>resume</code></h2> <h3 data-id="heading-19">6.1 手动停止</h3> <p><code>watch</code> 和 <code>watchEffect</code> 都返回一个停止函数:</p> <p>javascript</p> <pre><code class="hljs language-scss" lang="scss">const stop = <span class="hljs-built_in">watchEffect</span>(() =&gt; { <span class="hljs-comment">/* ... */</span> }) <span class="hljs-built_in">stop</span>() <span class="hljs-comment">// 停止侦听</span> </code></pre> <p><code>doWatch</code> 的返回值正是 <code>ReactiveEffect</code> 的 <code>stop</code> 方法包装。调用 <code>stop()</code> 后,<code>effect</code> 会从所有依赖集合中移除,后续变化不再触发回调。</p> <h3 data-id="heading-20">6.2 <code>pause</code> 与 <code>resume</code>(Vue 3.5+)</h3> <p>Vue 3.5 为 <code>watch</code> 和 <code>watchEffect</code> 的返回值增加了 <code>pause</code> 和 <code>resume</code> 方法,可以在不销毁侦听器的情况下临时暂停和恢复:</p> <p>javascript</p> <pre><code class="hljs language-scss" lang="scss">const { <span class="hljs-attribute">pause</span>, resume } = <span class="hljs-built_in">watchEffect</span>(() =&gt; { <span class="hljs-comment">/* ... */</span> }) <span class="hljs-attribute">pause</span>() <span class="hljs-comment">// 暂停侦听</span> <span class="hljs-built_in">resume</span>() <span class="hljs-comment">// 恢复侦听</span> </code></pre> <p>这对于在批量更新数据时避免不必要的回调触发非常有用。</p> <hr> <h2 data-id="heading-21">七、最佳实践与选择建议</h2> <h3 data-id="heading-22">7.1 何时选择 <code>watch</code></h3> <ul> <li><strong>需要旧值对比</strong>:如表单验证中比较前后值、状态同步逻辑。</li> <li><strong>需要精确控制监听源</strong>:只关心特定属性或计算结果的变化,避免不必要的副作用执行。</li> <li><strong>条件性监听</strong>:某些场景下需要根据条件动态启用或禁用监听。</li> <li><strong>需要 <code>immediate</code> 控制</strong>:默认惰性执行,仅在需要时立即触发。</li> </ul> <h3 data-id="heading-23">7.2 何时选择 <code>watchEffect</code></h3> <ul> <li><strong>依赖关系复杂或动态变化</strong>:如多个状态聚合计算、动态条件分支中的依赖。</li> <li><strong>无需旧值</strong>:只关心当前最新状态。</li> <li><strong>初始化即执行</strong>:如组件挂载时自动发起数据请求。</li> <li><strong>代码简洁性优先</strong>:避免手动维护依赖列表。</li> </ul> <h3 data-id="heading-24">7.3 性能考量</h3> <p><code>watch</code> 的依赖收集更加精确,性能开销通常低于 <code>watchEffect</code>,因为后者可能追踪到回调中所有被访问的响应式属性,包括那些并非“真正需要”触发副作用的属性。但在依赖关系复杂时,<code>watchEffect</code> 的开发效率优势往往更为重要<a href="https://link.juejin.cn/?target=https%3A%2F%2Fblog.csdn.net%2Fqq_51757896%2Farticle%2Fdetails%2F147295330%231" target="_blank" title="https://blog.csdn.net/qq_51757896/article/details/147295330#1" ref="nofollow noopener noreferrer"></a>。</p> <h3 data-id="heading-25">7.4 一个常见的陷阱</h3> <p>在 <code>watch</code> 回调中修改监听源本身,可能导致无限循环。务必在回调中添加条件判断,确保仅在特定条件下才执行修改操作。</p> <hr> <h2 data-id="heading-26">八、总结</h2> <p><code>watch</code> 和 <code>watchEffect</code> 共享 <code>doWatch</code> 这一核心实现,但通过 <code>cb</code> 参数的有无和 <code>options</code> 的差异,走上了两条不同的设计路径。<code>watch</code> 提供了更精确的控制和旧值对比能力,适合需要精细管理的场景;<code>watchEffect</code> 则以自动依赖收集和立即执行为核心,在依赖复杂或动态时显著简化代码。理解它们在 <code>getter</code> 标准化、<code>scheduler</code> 调度、依赖收集与清理机制上的源码实现,有助于在开发中做出更明智的选择,并在遇到性能或时序问题时能够从底层找到答案。</p>

2026/9/23
阅读更多

一个人 + AI 做的小程序,一个半月把服务器钱赚回来一半了

<blockquote> <p>签小签 · 四十五天复盘</p> </blockquote> <hr> <p>上一篇「一个月赚了 36 块」发出去之后,很多人盯着我的服务器账单看笑话。</p> <blockquote> <p>"36 块,服务器一年 99,你还差 63" "别算了我帮你算,回本得等到明年"</p> </blockquote> <p>现在我可以理直气壮地回一句:<strong>快了。</strong></p> <p>先说结论:<strong>上线 44 天,累计收入 67.41 元,回本 52.3%——过半了。</strong></p> <p>成本 129 块(服务器 99 + 个人认证 30),我赚回来 67 块。别的不说,至少这台服务器,我已经买下一半了。</p> <hr> <h2 data-id="heading-0">先上数据</h2> <p>流量主累计明细(08/10 ~ 09/22,单位:元):</p> <table><thead><tr><th>阶段</th><th>天数</th><th>累计收入</th><th>日均</th></tr></thead><tbody><tr><td>半月</td><td>15 天</td><td>10.53</td><td>0.70</td></tr><tr><td>满月</td><td>31 天</td><td>36.21</td><td>1.17</td></tr><tr><td><strong>一个半月</strong></td><td><strong>44 天</strong></td><td><strong>67.41</strong></td><td><strong>1.53</strong></td></tr></tbody></table> <p>曲线一直在往上走。尤其是最近 7 天,日均已经涨到 <strong>2.42 元</strong>,比满月那会儿翻了一倍。</p> <p>广告漏斗的全貌是这样:</p> <table><thead><tr><th>拉取量</th><th>曝光量</th><th>曝光率</th><th>点击量</th><th>点击率</th><th>eCPM</th><th>收入</th></tr></thead><tbody><tr><td><strong>18,382</strong></td><td><strong>8,893</strong></td><td><strong>48.4%</strong></td><td><strong>127</strong></td><td><strong>1.43%</strong></td><td><strong>7.58 元</strong></td><td><strong>67.41 元</strong></td></tr></tbody></table> <p>还是那句话:没花一分钱推广,全是自然流量。</p> <hr> <h2 data-id="heading-1">到底回本了没有?</h2> <p>我算得很抠。</p> <table><thead><tr><th>项目</th><th>年费用</th></tr></thead><tbody><tr><td>服务器(阿里云)</td><td>99 元</td></tr><tr><td>微信小程序个人认证</td><td>30 元</td></tr><tr><td><strong>合计</strong></td><td><strong>129 元/年</strong></td></tr></tbody></table> <p>一天成本约 3 毛 5,近 7 天日均收入 2 块 4。</p> <p><strong>按最近这个速度,成本的一天,我赚 7 天成本。</strong></p> <p>照这个势头,剩下那 61 块多,用不了一个月就能收回来。到时候就是纯赚——服务器白嫖,一年到头的广告费全是利润。</p> <p>听起来是个很小的数字。但这个"从亏到平到赚"的闭环,是我一个人 + AI 一点点抠出来的。</p> <hr> <h2 data-id="heading-2">为什么突然涨上去了</h2> <p>满月的时候我说目标是"日均破 2 块",现在近 7 天已经站上了 2.4。复盘下来主要是三件事:</p> <p><strong>曝光率稳了。</strong> 广告位调优之后,曝光率从早期的 45% 抬到了快 50%。同样一波流量,能变现的曝光更多了,钱自然也多了。</p> <p><strong>用户还在自然涨。</strong> 全靠微信搜一搜和分享,"观音灵签"这个长尾词每天都有人搜进来。基数大了,日活了得,收入自然跟着上来。</p> <p><strong>更关键的是——我开始重视日活了。</strong> 流量主这门生意,本质是靠"人天天来"。摇一次签就走的用户不值钱,每天来摇一下的用户才值钱。这一点,是我这段时间最大的认知变化,也是这篇文章最想聊的部分。</p> <hr> <h2 data-id="heading-3">说件丢人的事:我在"求"用户天天来</h2> <p>做到一个半月,我摸清了一件事:<strong>流量主的收入,和 DAU(日活跃用户)几乎是线性关系。</strong></p> <p>有多少人每天来摇签,就有多少次广告曝光,就有多少钱。产品做得再好看,用户摇完就走、不回头,收入照样躺平。</p> <p>所以我干了一件挺"不要脸"的事——<strong>我拉了个微信群,把那些愿意每天帮我摇一下、维持日活的人聚了起来。</strong></p> <p>规则很简单,也说得很直白:</p> <ul> <li>群里都是真实在用「签小签」的朋友,不是刷量的机器人;</li> <li><strong>我会根据每个月流量主的收益,不定期在群里抽 9.9 元的瑞幸</strong>,收益越好,抽得越勤、金额越大;</li> <li>如果你也愿意每天帮我摇一下保持日活,<strong>私信我,也可以进群</strong>,互惠互利。</li> </ul> <p>说白了就是:<strong>你帮我保住日活,我把赚到的钱分一点出来请你喝咖啡。</strong> 你摇签不为别的,就当顺手帮个忙;我不摇到用户不赚钱,赚到钱了不分一点,也说不过去。</p> <p>有人可能会说,这不就是"拉人刷日活"吗?我觉得不太一样。群里的人是真的每天打开小程序、真的在摇签、真的在看广告——这是真实用户行为,不是机器刷量。我做产品、用户花时间、我分收益,这是一个诚实的小闭环。</p> <p><strong>这个群不收费、不搞套路,纯靠一杯杯瑞幸维系。</strong> 你要是觉得有意思,看完文章私信我就行。</p> <p>哦对了,<strong>今天刚在群里抽出去一杯。</strong> 说到做到,不是画饼。</p> <p><img src="https://imgproxy.citrusreader.com/RketDQS3CIoveAHOjBCNpOG-4piQiJOAf3xqtgplK2Q/raw:1/aHR0cHM6Ly9wOS14dGpqLXNpZ24uYnl0ZWltZy5jb20vdG9zLWNuLWktNzNvd2p5bWRrNi83NjU2MTYzYTNhMjc0M2I0YTU2ZmMyNWYxMWQ2Yzk3NX50cGx2LTczb3dqeW1kazYtamotbWFyay12MTowOjA6MDowOjVvNlk2WWVSNW9xQTVweXY1NlMtNVl5NklFQWc1YVc5NWJpQzVyQ1JYdz09OnE3NS5hd2VicD9yazNzPWY2NGFiMTViJngtZXhwaXJlcz0xNzkwNzMyNTA5Jngtc2lnbmF0dXJlPU1oM084cHBNc3BwUW5IM0klMkZYNVE2YkhzT1JzJTNE" alt="image.png" loading="lazy" referrerpolicy="no-referrer"></p> <hr> <h2 data-id="heading-4">对了,我还做了个新小程序</h2> <p>「签小签」跑通之后,我用同一套"人 + AI"的路子,又做了个新产品——</p> <h3 data-id="heading-5">「厨菜记」</h3> <p>一个帮你记录"今天吃了啥、明天做什么菜"的厨房小本本。</p> <p>做它的理由很俗:我自己做饭,每次都在"今天吃什么"这件事上卡壳半小时。菜谱 App 一堆,但没人帮你记住"你上周做过什么、你家里的口味偏好是什么"。所以我干脆自己做一个。</p> <p>还是老配方——全 AI 辅助开发,UI 自己画,一个人从零到上线。目前刚起步,也准备接入流量主验证一遍这套打法能不能复制。</p> <p><strong>微信搜「厨菜记」,或者直接扫下面的码体验,好用与否都欢迎来群里骂我。</strong></p> <p><img src="https://imgproxy.citrusreader.com/BYsjLtD0XooZxy1g9RAwj88Rdn2Z7uUacLynF1QaubA/raw:1/aHR0cHM6Ly9wOS14dGpqLXNpZ24uYnl0ZWltZy5jb20vdG9zLWNuLWktNzNvd2p5bWRrNi9iZjRmMGQxOWNjYzY0YzkyODc5MmNhZDhmMzdiMDhlZn50cGx2LTczb3dqeW1kazYtamotbWFyay12MTowOjA6MDowOjVvNlk2WWVSNW9xQTVweXY1NlMtNVl5NklFQWc1YVc5NWJpQzVyQ1JYdz09OnE3NS5hd2VicD9yazNzPWY2NGFiMTViJngtZXhwaXJlcz0xNzkwNzMyNTA5Jngtc2lnbmF0dXJlPVFwbkR2MUYlMkY1bXZZcTF0aDNpZTZJSFZwTE5RJTNE" alt="厨菜记邀请小程序码.png" loading="lazy" referrerpolicy="no-referrer"></p> <hr> <h2 data-id="heading-6">写在最后</h2> <p>一个半月,67 块,回本过半。</p> <p>数字依旧寒酸。但这次复盘,我不想只聊钱——我更想说的是一个<strong>认知升级</strong>:</p> <p><strong>从"做完产品等流量",到"主动维系日活"。</strong> 前一个半月我是被动等用户来,这一个半月我开始主动去经营那批真实愿意每天来的人。收入的拐点,恰恰出现在我开始重视这件事之后。</p> <p>产品不是上线就结束,上线只是开始。有人天天用,它才活着,才有钱,才值得我一直做下去。</p> <p>下一步的目标很简单:<strong>下篇复盘之前,把剩下的 61 块也收回来,正式宣告回本。</strong></p> <p>到时候我请全群的人喝瑞幸。</p> <hr> <h2 data-id="heading-7">三个入口,按需自取</h2> <p><strong>① 摇签看运气</strong> 👇 微信搜「签小签」,每天摇一摇</p> <p><img src="https://imgproxy.citrusreader.com/wfsfzPGXXMr977wKkzQr4AkQm1yJEVBTJJMHbvG-ELM/raw:1/aHR0cHM6Ly9wOS14dGpqLXNpZ24uYnl0ZWltZy5jb20vdG9zLWNuLWktNzNvd2p5bWRrNi9kYzhkMTM0YzQzOTg0MTk3YmUyZWMwMjM5YjNhZTgxN350cGx2LTczb3dqeW1kazYtamotbWFyay12MTowOjA6MDowOjVvNlk2WWVSNW9xQTVweXY1NlMtNVl5NklFQWc1YVc5NWJpQzVyQ1JYdz09OnE3NS5hd2VicD9yazNzPWY2NGFiMTViJngtZXhwaXJlcz0xNzkwNzMyNTA5Jngtc2lnbmF0dXJlPUklMkJRTFFod0VjUXpyeUpUJTJCdnV6N0NrV3ljJTJGTSUzRA" alt="小程序邀请码.png" loading="lazy" referrerpolicy="no-referrer"> <strong>② 想进群帮我保持日活、一起分瑞幸</strong> 👇 直接私信我,说明来意即可</p> <p><strong>③ 对新项目感兴趣的</strong> 👇 微信搜「厨菜记」,帮我提提意见</p> <p><img src="https://imgproxy.citrusreader.com/BYsjLtD0XooZxy1g9RAwj88Rdn2Z7uUacLynF1QaubA/raw:1/aHR0cHM6Ly9wOS14dGpqLXNpZ24uYnl0ZWltZy5jb20vdG9zLWNuLWktNzNvd2p5bWRrNi9iZjRmMGQxOWNjYzY0YzkyODc5MmNhZDhmMzdiMDhlZn50cGx2LTczb3dqeW1kazYtamotbWFyay12MTowOjA6MDowOjVvNlk2WWVSNW9xQTVweXY1NlMtNVl5NklFQWc1YVc5NWJpQzVyQ1JYdz09OnE3NS5hd2VicD9yazNzPWY2NGFiMTViJngtZXhwaXJlcz0xNzkwNzMyNTA5Jngtc2lnbmF0dXJlPVFwbkR2MUYlMkY1bXZZcTF0aDNpZTZJSFZwTE5RJTNE" alt="厨菜记邀请小程序码.png" loading="lazy" referrerpolicy="no-referrer"></p>

2026/9/23
阅读更多

HarmonyOS 7 平行视界进阶实战:配置写好了,为什么一上真机就崩?

<blockquote> <p>本文是「平行视界」系列第二篇(首篇见《HarmonyOS 7 平行视界 EasyGo 实战》)。基于 HarmonyOS 7(API 26)官方《平行视界》能力介绍与开发指导整理。文中代码是为说明问题自写的完整示例,不是官方示例的搬运;配置文件字段名、枚举值等事实性信息均标注官方出处,未能逐字核实的指向写法已注明"以官方文档为准";涉及真机表现的部分以真机实测为准,未做任何实测数据编造。</p> </blockquote> <hr> <p><img src="https://imgproxy.citrusreader.com/X2mPmB3Q8uH6w7MzIimQZGe1Xo6_UFU99W6yQRbFx7Q/raw:1/aHR0cHM6Ly9wNi14dGpqLXNpZ24uYnl0ZWltZy5jb20vdG9zLWNuLWktNzNvd2p5bWRrNi8zMWVmMDU2NzZhZDI0NmRmOTYxMWRiOWY5ZmMzZjc4Nn50cGx2LTczb3dqeW1kazYtamotbWFyay12MTowOjA6MDowOjVvNlk2WWVSNW9xQTVweXY1NlMtNVl5NklFQWc1YWlCNVpPbDU0aXg1N3lXNTZpTDpxNzUuYXdlYnA_cmszcz1mNjRhYjE1YiZ4LWV4cGlyZXM9MTc5MDczMTMzNiZ4LXNpZ25hdHVyZT1Pc2xUVEdibEx0VTdwallNUWdUZTAzV2E0aWclM0Q" alt="HarmonyOS 7 平行视界进阶实战:配置写好了为什么崩" loading="lazy" referrerpolicy="no-referrer"></p> <h2 data-id="heading-0">引子:easy_go.json 写完了,真机一展开全是问题</h2> <p>V哥上期把平行视界的配置讲透了——一份 easy_go.json、homePage 配 navBar、mode 切购物/导航、ratio 配 1:2。评论区有位兄弟照抄跑通了,隔了两天回来留言:"V哥,配置我一字不差抄的,模拟器美如画,真机一展开,列表页跑到右边去了,详情页直接被截断,分屏入口点了没反应。"</p> <p>这不是个例。V哥自己的电商 Demo 也踩过一模一样的坑。配置写对只是第一步,<strong>平行视界真正的坑在"配完之后"</strong>:homePage 识别、UI 截断、一屏三区、和自由多窗的边界,这四个地方有一处没想清楚,真机展开就是翻车现场。</p> <p>这期V哥把它拆开讲:上期讲"怎么配",这期讲"配完为什么崩",最后给一张"平行视界 / 自由多窗 / 自写 Navigation"的选型决策图。</p> <hr> <h2 data-id="heading-1">一、homePage 识别失败:左右关系整个反了</h2> <p>上期V哥就强调过,homePage 必须主动配。这里把"不配会怎样"讲透。</p> <p>官方在能力介绍里原话:<strong>默认的主页识别机制"在有些场景下不能准确识别"</strong>。什么场景会失准?典型两类:</p> <ul> <li>你的 Navigation 首页不是 navBar,而是某个 NavDestination 用 <code>NavPathStack</code> 直接当首页推的;</li> <li>冷启动走 <code>relatedPage</code> 进了二级页,路由栈初始态和系统认定"主页"对不上。</li> </ul> <p>识别错了的后果很直观:系统以为右边那页是主页,把列表塞进右侧、详情挤到左侧,左右关系整个反掉。模拟器因为窗口尺寸固定、路由栈干净,往往看不出来;真机折叠展开、分屏、多实例这些时序一搅,就暴露了。</p> <p><strong>V哥的硬规则</strong>:homePage 永远显式配。Navigation 首页就是 <code>"navBar"</code>;想让某个 NavDestination 当主页,就配它的 <code>name</code>。不要赌默认识别。</p> <pre><code class="hljs language-json5" lang="json5">{ "common": { "displayModeOptions": { "navigationSplitOptions": { // V哥注:首页必须显式声明,不赌默认识别 "homePage": "navBar", // 或配某个 NavDestination 的 name "relatedPage": "CategoryPage", // 冷启动直接进列表,缩短首屏路径 "mode": 0 } } } } </code></pre> <hr> <h2 data-id="heading-2">二、UI 截断:窗口一半宽度装原布局,元素必溢出</h2> <p>这是官方"常见问题"第一条,也是真机翻车率最高的一处。平行视界把原来整屏的布局塞进"右半页",窗口宽度直接砍半,你按整屏写的固定宽度元素、绝对定位、硬编码边距,全溢出。</p> <p>V哥给三板斧,按优先级来:</p> <p><strong>① 自适应布局优先(治本)。</strong> 用栅格、百分比、折行容器重写右页布局,让它能随容器宽自适应。这是唯一不依赖开关的方案。</p> <p><strong>② 虚拟容器兜底(性价比最高)。</strong> 开 <code>enableReducedContainerSize: true</code>——断点、lpx、窗口宽度全部按"右侧页面尺寸"重新计算,相当于让系统把右页当成一个独立小窗口。官方还配套 <code>drawableRectHook</code>,让 <code>Window.drawableRect</code> 也按右页尺寸算,避免你拿到的窗口矩形还是整屏大。</p> <p><strong>③ 监听接口留给精细场景。</strong> <code>onNavDestinationSizeChange()</code>、组件级的 <code>on('navDestinationUpdate')</code>(API 23+)能拿到页面真实尺寸,做动态字号、条件显隐。</p> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-comment">// GoodsDetailPage.ets —— 拿右页真实尺寸,精细适配</span> <span class="hljs-keyword">import</span> { <span class="hljs-variable language_">window</span> } <span class="hljs-keyword">from</span> <span class="hljs-string">'@kit.ArkUI'</span>; <span class="hljs-title function_">aboutToAppear</span>(): <span class="hljs-built_in">void</span> { <span class="hljs-comment">// V哥注:enableReducedContainerSize 开启后,这里拿到的尺寸已是右页尺寸</span> <span class="hljs-variable language_">window</span>.<span class="hljs-title function_">getLastWindow</span>(<span class="hljs-title function_">getContext</span>(<span class="hljs-variable language_">this</span>)).<span class="hljs-title function_">then</span>(<span class="hljs-function">(<span class="hljs-params">w</span>) =&gt;</span> { w.<span class="hljs-title function_">on</span>(<span class="hljs-string">'windowSizeChange'</span>, <span class="hljs-function">(<span class="hljs-params">size</span>) =&gt;</span> { <span class="hljs-comment">// size.width 是右页真实宽度,据此切换布局密度</span> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">adaptByWidth</span>(size.<span class="hljs-property">width</span>); }); }); } </code></pre> <p>另外两类页面要单独标记:<code>fullScreenPages</code> 配置图片浏览、视频播放这类"进页退分栏、返回恢复"的页面;临时编辑页用 <code>transPages</code> 固定右侧不被推挤。这两个字段配错,要么全屏页被卡在分栏里,要么编辑页跟着被推走。</p> <hr> <h2 data-id="heading-3">三、一屏三区:应用内分屏不是"点了就灵"</h2> <p>上期提过,配置 <code>enableInSplitScreen: true</code> 后,详情页顶部出分屏入口,应用以主窗口形式进系统窗口分屏,且原窗口继续保持平行视界。听着很美,真机有三个坑V哥必须提醒:</p> <p><strong>坑 1:入口可见性没判断对。</strong> 按钮要在"处于分栏态 且 还没分屏"时才露。用 <code>getUIContext().isEasySplit()</code> 判分栏态,<code>windowStatusType</code> 判是否已分屏,两个条件缺一不可——只判一个,要么不该露时露了,要么分屏后还露着。</p> <p><strong>坑 2:应用得支持多实例。</strong> 应用内分屏本质是再拉起一个 EntryAbility 实例。你的 Ability 没在 <code>module.json5</code> 里声明多实例支持,点了没反应,不报错但就是不开。</p> <p><strong>坑 3:主窗口占比有讲究。</strong> <code>SplitRatioPreference.PRIMARY_DOMINANT</code> 让主窗口占主导,但左列表+详情已经占了一屏三区里的两区,再开的分屏别抢太多,否则三区挤成两区半。</p> <pre><code class="hljs language-typescript" lang="typescript"><span class="hljs-comment">// 分屏入口:分栏态 + 未分屏时露出</span> <span class="hljs-title class_">Button</span>($r(<span class="hljs-string">'app.string.enter_split'</span>)) .<span class="hljs-title function_">visibility</span>( <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">getUIContext</span>().<span class="hljs-title function_">isEasySplit</span>() &amp;&amp; <span class="hljs-variable language_">this</span>.<span class="hljs-property">mainWindowInfo</span>.<span class="hljs-property">windowStatusType</span> !== <span class="hljs-variable language_">window</span>.<span class="hljs-property">WindowStatusType</span>.<span class="hljs-property">SPLIT_SCREEN</span> ? <span class="hljs-title class_">Visibility</span>.<span class="hljs-property">Visible</span> : <span class="hljs-title class_">Visibility</span>.<span class="hljs-property">None</span>) .<span class="hljs-title function_">onClick</span>(<span class="hljs-function">() =&gt;</span> <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">startSplitScreen</span>()) <span class="hljs-keyword">private</span> <span class="hljs-title function_">startSplitScreen</span>(): <span class="hljs-built_in">void</span> { <span class="hljs-keyword">const</span> ctx = <span class="hljs-variable language_">this</span>.<span class="hljs-title function_">getUIContext</span>().<span class="hljs-title function_">getHostContext</span>() <span class="hljs-keyword">as</span> common.<span class="hljs-property">UIAbilityContext</span>; ctx.<span class="hljs-title function_">startAbility</span>( { <span class="hljs-attr">bundleName</span>: <span class="hljs-string">'com.vge.demo'</span>, <span class="hljs-attr">abilityName</span>: <span class="hljs-string">'EntryAbility'</span> }, { <span class="hljs-attr">windowMode</span>: <span class="hljs-title class_">AbilityConstant</span>.<span class="hljs-property">WindowMode</span>.<span class="hljs-property">WINDOW_MODE_SPLIT_PRIMARY</span>, <span class="hljs-attr">splitRatio</span>: <span class="hljs-variable language_">window</span>.<span class="hljs-property">SplitRatioPreference</span>.<span class="hljs-property">PRIMARY_DOMINANT</span> }); } </code></pre> <p>接口名(<code>isEasySplit()</code>、<code>WindowStatusType.SPLIT_SCREEN</code>、<code>WINDOW_MODE_SPLIT_PRIMARY</code>、<code>SplitRatioPreference</code>)均出自官方开发指导示例代码,实际分屏表现以真机实测为准。</p> <hr> <h2 data-id="heading-4">四、边界:自由多窗模式下,平行视界不生效</h2> <p>这是最容易被忽略的一条硬边界。官方在 tablet 配置项里明确:<strong>自由多窗模式下,暂不支持平行视界</strong>。</p> <p>什么意思?用户在系统里把你的应用拖成自由窗口(自由缩放、悬浮),这个状态下平行视界那套左右分栏是不触发的,应用回退到单页。所以做窗口化、悬浮场景的业务,要先确认自己的主战场是不是自由多窗——是的话,平行视界那套配置等于没装。</p> <p>那三种"大屏多任务"方案到底怎么选?V哥画了一张边界图(见配图),翻译成大白话:</p> <ul> <li><strong>平行视界</strong>:系统级兜底,配置接入,能力受限。适合"左点右出"的路由栈场景(电商、IM、邮箱),一份 JSON 接住,不重写业务。</li> <li><strong>自由多窗</strong>:用户主动拖拽缩放、多应用同屏,应用声明支持后由系统调度。适合需要和应用外内容并排、窗口尺寸不固定的场景。</li> <li><strong>自写 Navigation 一多</strong>:主动适配,控制灵活,功能完整。适合要侧边栏、三分栏、跨栏联动、复杂转场的精细化设计。</li> </ul> <p>三者不互斥:平行视界在手机投宽屏/折叠屏上兜底,Navigation 一多在你精雕细琢的主战场发力,自由多窗交给系统让用户自己拖——分层用,别互相打架。</p> <hr> <h2 data-id="heading-5">五、V哥的真机验收清单</h2> <p>配置写完别急着合并,V哥列一份真机验收清单,按这个跑一遍:</p> <ol> <li>折叠展开、窗口分屏、多实例三种时序各跑一遍,homePage 左右关系不能反;</li> <li>右页把最长列表、最宽表格、绝对定位组件全点一遍,无截断无溢出;</li> <li>深色模式、横竖屏各看一遍,分割线颜色深浅色都配了;</li> <li>分屏入口在分栏态露出、分屏后隐藏,点了能开且主窗口保持平行视界;</li> <li>自由多窗态下确认你的设计意图(是退回单页还是另有预案)。</li> </ol> <p>真机表现千变万化,以真机实测为准;7.0 新能力需升级至 HarmonyOS 7 并以实际支持机型为准。</p> <hr> <h2 data-id="heading-6">参考与出处</h2> <p>本文涉及的机制、字段与交互规则来自以下官方材料,均为V哥动笔前逐条核验的原文出处:</p> <ul> <li><a href="https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.huawei.com%2Fconsumer%2Fcn%2Fdoc%2Fbest-practices%2Fbpta-easygo-parallel" target="_blank" title="https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-easygo-parallel" ref="nofollow noopener noreferrer">平行视界开发指导(最佳实践)</a></li> <li><a href="https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.huawei.com%2Fconsumer%2Fcn%2Fdoc%2Fdesign-guides%2Fparallel_view-0000002588655180" target="_blank" title="https://developer.huawei.com/consumer/cn/doc/design-guides/parallel_view-0000002588655180" ref="nofollow noopener noreferrer">平行视界能力介绍(设计指南)</a></li> <li><a href="https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.huawei.com%2Fconsumer%2Fcn%2Ffeatures%2F" target="_blank" title="https://developer.huawei.com/consumer/cn/features/" ref="nofollow noopener noreferrer">HarmonyOS 新能力一览(7 / API 26)</a></li> </ul> <hr> <p><strong>最后一句</strong>:平行视界的优雅在于"不写代码就能分屏",但它的坑也恰恰藏在"不写代码"的盲区里——homePage 别赌默认、截断先开虚拟容器、分屏先开多实例、自由多窗下承认它不生效,把这四个盲区填平,折叠屏展开的那一秒,你的应用才真正站得稳。</p>

2026/9/23
阅读更多

推荐订阅

| 精品推荐 | 技术干货 | 职场成长 | 社区动态 | 组件物料 | 行业动态 | | --------- | -------- | -------- | --------- | -------- | -------- | | recommend | quality | growth | community | material | industry |

::: tip 订阅 [管理与运维](https://tidb.net/blog/c/management-and-operation),其源网址为 `https://tidb.net/blog/c/management-and-operation`,请参考该 URL 指定部分构成参数,此时路由为 [`/tidb/blog/c/management-and-operation`](https://rsshub.app/tidb/blog/c/management-and-operation)。 ::: | 分类 | ID | | -------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | [全部文章](https://tidb.net/blog) | [latest](https://rsshub.app/tidb/blog) | | [管理与运维](https://tidb.net/blog/c/management-and-operation) | [management-and-operation](https://rsshub.app/tidb/blog/c/management-and-operation) | | [实践案例](https://tidb.net/blog/c/practical-case) | [practical-case](https://rsshub.app/tidb/blog/c/practical-case) | | [架构选型](https://tidb.net/blog/c/architecture-selection) | [architecture-selection](https://rsshub.app/tidb/blog/c/architecture-selection) | | [原理解读](https://tidb.net/blog/c/principle-interpretation) | [principle-interpretation](https://rsshub.app/tidb/blog/c/principle-interpretation) | | [应用开发](https://tidb.net/blog/c/application-development) | [application-development](https://rsshub.app/tidb/blog/c/application-development) | | [社区动态](https://tidb.net/blog/c/community-feeds) | [community-feeds](https://rsshub.app/tidb/blog/c/community-feeds) |