阅读视图

发现新文章,点击刷新页面。

网页也懂黑夜与白天:系统主题自动切换

前言

不知道大家有没有遇到过,网页会根据系统设置的外观模式,显示不同的主题样式。这是如何实现的呢?

1、核心原理:认识 prefers-color-scheme 媒体查询

CSS原生媒体特性 prefers-color-scheme 是实现主题自动切换的核心,它能检测用户操作系统(Windows、macOS、iOS、Android 等)的主题设置,返回三个可能值:

  • light:用户设置浅色主题
  • dark:用户设置深色主题
  • no-preference:用户未设置(默认按浅色处理)

能覆盖绝大多数现代设备,无需额外 JS 即可实现基础切换。

2、案例实现

2.1 定义浅色主题

先在:root中定义浅色主题的基础变量:

@media(prefers-color-scheme: light) {
    :root {
        --bg-color: #ffffff;
        /* 浅色背景 */
        --text-color: #333333;
        /* 浅色文字 */
    }
}

2.2 定义深色主题

通过 @media (prefers-color-scheme: dark) 覆盖深色主题的变量:

@media (prefers-color-scheme: dark) {
  :root {
    --bg-color: #121212; /* 深色背景(避免纯黑) */
    --text-color: #e5e7eb; /* 浅色文字(避免纯白) */
  }
}

2.3 页面使用

在页面样式中统一使用 CSS 变量,无需重复编写明暗逻辑:

如何监听系统模式的变化:可以监听prefers-color-scheme: dark的变化,当matches为true时,为深色模式。

 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {});

image.png

body {
  background-color: var(--bg-color);
  color: var(--text-color);
  font-size: 16px;
}

可以在谷歌浏览器中设置切换:

image.png

效果如下:

深色:

image.png

浅色:

image.png

3、如何简写

上面的写法稍微有点复杂,可以使用CSS 的 color-scheme和 light-dark()函数,来实现 根据系统主题(深色/浅色模式)自动切换背景和文字颜色 的效果。

:root {
    color-scheme: light dark;
}

body {
    background-color: light-dark(#ffffff, #121212);
    color: light-dark(#333333, #e5e7eb);
    font-size: 16px;
}

告诉浏览器和操作系统,这个网页 支持浅色(light)和深色(dark)两种主题模式CSS新增的 light-dark()函数,允许你 在一个声明里同时为浅色模式和深色模式定义颜色

4、用户手动切换主题

虽然系统自动切换已满足大部分需求,但提供手动切换按钮能提升灵活性,核心思路是通过 JS 覆盖 CSS 变量,步骤如下:

  1. 添加切换按钮:
theme-toggle">切换深色模式</button>

2. 编写 JS 逻辑:

const themeToggle = document.getElementById('theme-toggle');
const html = document.documentElement;
// 初始化:根据系统主题设置按钮文本
const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
themeToggle.textContent = isDark ? '切换浅色模式' : '切换深色模式';
// 切换主题
themeToggle.addEventListener('click', () => {
  const isCurrentlyDark = html.classList.contains('dark');
  if (isCurrentlyDark) {
    html.classList.remove('dark');
    themeToggle.textContent = '切换深色模式';
  } else {
    html.classList.add('dark');
    themeToggle.textContent = '切换浅色模式';
  }
});

3. 补充 CSS 样式:

/* 手动切换深色模式时的样式覆盖 */
:root.dark {
  --bg-color: #121212;
  --text-color: #e5e7eb;
}
/* 优先级:手动切换 > 系统主题 */
@media (prefers-color-scheme: dark) {
  :root:not(.dark) { /* 未手动切换时,遵循系统主题 */
    --bg-color: #121212;
    --text-color: #e5e7eb;
  }
}

总结

最后总结一下:系统主题自动切换的核心是通过 CSS 原生 prefers-color-scheme 媒体查询,结合 CSS 变量实现网页系统明暗主题自动切换。

dify案例分享-国内首发!手把手教你用Dify调用Nano Banana2AI画图

1.前言

在AI图像生成领域快速发展的今天,Google最新推出的Nano Banana Pro(正式名称:Gemini 3 Pro Image)以1501的Elo分数登顶LMArena排行榜第一,完美支持中文文字渲染、多图一致性、精准控制等功能,成为了图像生成领域的王者级模型。然而对于国内开发者来说,直接访问Gemini API存在诸多不便,而且需要科学上网才能使用,这让很多小伙伴望而却步。

为了解决这些痛点,我们开发了nano_banana2这个开源Dify插件,它基于Gemini 3 Pro Image Preview模型,支持双API提供商(Juxin API和Gemai公共API),不仅提供了免费调用选项,还能通过国内网络直接访问。通过Dify平台的可视化工作流,小伙伴们只需简单三个节点,无需编写代码,就能快速搭建属于自己的AI图像生成服务。

image-20251123175637746

这2天Nano Banana Pro非常火爆,今天我们就在Dify平台手把手教大家部署这个nano_banana2插件,通过简单三步配置,体验和感受一下这个王者级模型的强大能力。


2.项目介绍

✨ 核心特性

nano_banana2是一个专为Dify平台设计的AI图像生成插件,具备以下强大功能:

  • 🎨 多风格支持: 支持真实、动漫、油画、水彩、素描五种艺术风格
  • 📐 多比例生成: 提供1:1、16:9、9:16、4:3、3:4等多种画幅比例
  • 🔄 批量生成: 单次请求可生成1-4张图片,提升创作效率
  • 🚫 负面提示词: 支持排除不想要的元素,精准控制画面内容
  • 🌐 双API支持: 同时支持Juxin API(付费)和Gemai公共API(免费)
  • ⚡ 极简工作流: 仅需三个节点即可完成部署,新手友好
  • 📊 实时进度反馈: 完整的错误处理和进度提示
  • 🆓 开源免费: 完全开源,支持本地部署和二次开发

工作流演示

🛠️ 技术栈

Dify插件端

  • 插件框架: Dify Plugin SDK
  • API对接: Juxin API / Gemai Public API
  • 模型: Gemini 3 Pro Image Preview
  • 超时设置: 120秒请求超时
  • 输出格式: PNG高清图片

工作流端

  • 节点数量: 3个(开始 → 插件 → 回复)
  • 配置方式: 可视化拖拽,零代码
  • 参数配置: 支持风格、比例、数量等自定义

🎯 应用场景

基于Nano Banana Pro的28种经典玩法,该插件可广泛应用于:

  • 电商场景: 背景替换、商品展示、产品海报设计
  • 广告创意: 多面板蒙太奇、品牌logo植入、宣传物料制作
  • 社媒内容: 九宫格图、YouTube缩略图、朋友圈配图
  • 动漫创作: 连续漫画、角色设定、表情包制作
  • 教育科普: 信息图表、教学插画、知识可视化
  • 实用变现: 老照片修复、专业精修、3D模型商品化

📊 成本对比

方案 访问方式 成本 部署难度 工作流复杂度
官方Gemini 需科学上网 $0.12/张(2K) 中等 需API开发
AI Studio 付费API $0.12/张(2K) 较难 需API开发
nano_banana2插件 国内直连 免费(Gemai)或低成本 简单 3节点即可

通过nano_banana2插件,我们不仅解决了国内访问问题,还通过极简工作流降低了部署门槛,让普通用户也能轻松生成专业级图像。


3.部署实战

话不多说,下面带大家一步一步完成整个工作流的搭建。呵呵你会发现真的超级简单!

3.1 插件安装

我们首先需要在Dify平台安装nano_banana2插件。

方法一: 在线安装(推荐)

打开你的Dify平台,进入"插件管理"页面。

插件管理入口

在插件市场搜索"Nano Banana2",点击安装即可。

image-20251123203500412

方法二: 离线安装

如果插件还在审核中,可以从GitHub下载.difypkg安装包进行离线安装。

在插件管理页面选择"本地插件":

本地插件选项

选择下载好的nano_banana2v0.0.1.difypkg文件上传:

上传插件包

点击安装后等待几秒钟,看到安装成功提示:

安装成功

好家伙,插件安装就这么简单!

3.2 授权配置

安装成功后,我们需要对插件进行授权配置。nano_banana2支持两种API提供商:

Juxin API(付费,稳定)

访问 api.juxin.ai 注册账号并充值,获取API Key。

优势:

  • 稳定性高,响应速度快
  • 无限额限制,适合批量生成
  • 支持更高分辨率

Gemai Public API(免费,有限额)

访问相应平台获取免费API Key。

优势:

  • 完全免费,适合测试和个人使用
  • 每日有一定免费额度
  • 快速体验Nano Banana Pro功能

在插件设置页面填入至少一个API密钥:

授权配置

注意: 两个API可以同时配置,插件会根据你在工作流中的选择来调用对应的接口。建议先配置免费的Gemai API进行测试,满意后再使用付费的Juxin API进行批量生成。

3.3 工作流搭建(三步搞定!)

3.3.1 创建工作流

在Dify平台创建一个新的"聊天助手"工作流。

image-20251123203228715

3.3.2 第一步: 开始节点(默认即可)

开始节点会自动创建,它会接收用户的输入文本作为提示词。

默认配置:

  • 输入变量: {{#sys.query#}} (系统自动捕获用户输入)
  • 无需手动配置任何参数

image-20251123202939998

3.3.3 第二步: 添加Nano Banana2插件

从插件列表中找到刚才安装的"Nano Banana2 文生图"插件,拖入工作流画布:

添加插件

配置插件参数:

必填参数

1) 图像提示词 (prompt)

  • 填入: {{#sys.query#}} (使用用户输入的文本)
  • 说明: 这是生成图像的核心描述,支持中英文

image-20251123202911778

可选参数(根据需求配置)

2) 负向提示词 (negative_prompt) - 可选

  • 示例: blurry, low quality, distorted, ugly
  • 说明: 描述不想在图像中出现的内容

3) 图像数量 (num_images) - 默认1

  • 范围: 1-4张
  • 说明: 批量生成多张图片供选择

4) 创造力参数 (temperature) - 默认0.7

  • 范围: 0.0-1.0
  • 说明: 数值越高结果越有创意,越低越符合提示词

5) 宽高比 (aspect_ratio) - 可选

  • 选项: 1:1(正方形)、16:9(横屏)、9:16(竖屏)、4:3(标准)、3:4(纵向标准)
  • 说明: 根据使用场景选择合适的比例

6) 风格 (style) - 可选

  • 选项: realistic(写实)、anime(动漫)、oil-painting(油画)、watercolor(水彩)、sketch(素描)
  • 说明: 不同风格产生不同艺术效果

7) API提供商 (api_provider) - 必选

  • 选项: gemai(免费) 或 juxin(付费)
  • 说明: 选择你已授权的API服务商

image-20251123202853602

推荐配置示例:

动漫风格配置:

prompt: {{#sys.query#}}
api_provider: gemai
aspect_ratio: 16:9
style: anime
num_images: 1
temperature: 0.7

写实摄影配置:

prompt: {{#sys.query#}}
api_provider: gemai
aspect_ratio: 4:3
style: realistic
num_images: 1
temperature: 0.5
negative_prompt: blurry, low quality, cartoon

3.3.4 第三步: 添加直接回复节点

添加"直接回复"节点,将插件生成的图片输出给用户:

直接回复节点

配置输出内容:

在回复内容中填入:

{{#1763885273656.text#}}

{{#1763885273656.files#}}

说明:

  • text: 插件返回的文本信息(包含生成状态、提示词等)
  • files: 插件返回的图片文件

注意: 节点ID(如1763885273656)会根据你的实际工作流自动生成,请从变量列表中选择对应的输出。

image-20251123202817913

3.3.5 连接节点

将三个节点按顺序连接:

开始节点 → Nano Banana2插件 → 直接回复

完整工作流

至此,完整的工作流就搭建完成了!是不是超级简单?只需要三个节点,拖拖拽拽就搞定了,完全不需要写代码!

3.4 测试验证

测试用例1: 学生证变手办

我们使用一个专业的英文提示词直接生成手办效果:

提示词:

Turn this photo into a character figure. Behind it, place a box with the
character's image printed on it, and a computer showing the Blender modeling
process on its screen. In front of the box, add a round plastic base with
the character figure standing on it. Set the scene indoors if possible

点击"运行"按钮,等待几秒钟:

测试结果1

手办效果

呵呵,效果非常棒!学生证变成了精美的手办,还有包装盒和建模界面,细节满满!

测试用例2: 教育信息图

提示词:

创建一个解释[光合作用]的教育信息图。
视觉元素:展示关键组成部分:太阳、一株绿色植物、水(H2O)进入根部、
二氧化碳(CO2)进入叶片,以及氧气(O2)被释放。
风格:简洁、扁平化的矢量插图,适合高中科学教科书。
使用箭头表示能量和物质的流动。
标签:用简体中文清晰地标注每个元素。

光合作用信息图

中文标注清晰,流程展示专业,完全可以用于教学!这就是Nano Banana Pro强大的中文文字渲染能力。

测试用例3: 角色设定图

提示词:

横图,创作如图人物的A-pose设计图,米白色底。
有种初期设计的感觉。
有各个部位拆分、表情差分、多角度表情、物品拆分、细节特写。
并且使用手写体文字进行标注说明,最好使用中文。

角色设定

三视图、表情设定、服装拆解、细节特写,全都自动生成了,这对漫画创作者来说简直是福音!

测试用例4: 趣味创意

提示词:

一张像驾照一样的证件卡片特写,证件照是一只睡着的考拉。
证件名称写着:"国家一级摆烂许可证"。有效期写着:"永久有效"

摆烂许可证

中文文字渲染完美,字体清晰,完全没有乱码!这就是Nano Banana Pro的强大之处。

测试用例5: 趣味场景

提示词:

头发蓬乱的爱因斯坦困惑地盯着智能手机,试图自拍。
埃隆·马斯克站在他旁边,耐心地指着屏幕教他。
手机屏幕上的字:"怎么拍照?"

爱因斯坦学自拍

好家伙!人物表情生动,场景幽默有趣,手机屏幕上的中文也清晰可见!

通过以上测试来看,效果非常棒,完全达到了预期。是不是非常简单?只需要三个节点,几分钟就搭建完成了!

4.项目资源

GitHub开源地址

插件项目: github.com/wwwzhouhui/…

工作流DSL: github.com/wwwzhouhui/…

觉得项目不错可以点个赞⭐

在线体验地址

Dify工作流体验:

可以直接在线体验,无需安装!

插件下载

离线安装包: nano_banana2v0.0.1.difypkg

下载地址: 通过网盘分享的文件:nano_banana2_0.0.1.difypkg 链接: pan.baidu.com/s/1Q7B5opr_… 提取码: xm6f

相关教程

参考文章:


5.总结

今天主要带大家了解并实现了基于Dify工作流构建Nano Banana Pro(Gemini 3 Pro Image)AI图像生成系统的完整流程,该解决方案以"开源nano_banana2插件 + 极简三节点工作流"为核心优势,结合电商设计、广告创意、动漫创作、教育科普等多种应用场景需求,通过Dify平台的可视化工作流编排与双API提供商支持(免费Gemai API + 稳定付费Juxin API),形成了一套从文本提示词到高清图像输出的全链路AI创作解决方案。

通过这套实践方案,设计师、运营人员、内容创作者能够高效突破传统图像生成的访问瓶颈——借助完美的中文文字渲染(支持繁体、日韩文等多语言,准确率达97%+)、极简的工作流配置(仅需开始节点→插件节点→回复节点三步)、双API灵活选择(根据预算和需求自由切换),无需科学上网和复杂的API开发技能,就能快速实现商业级图像创作(如本次演示的"学生证变手办"、"光合作用教育图"、"角色设定图"、"趣味证件卡"、"爱因斯坦学自拍"等多个实战案例)。无论是产品海报设计、社交媒体配图、漫画角色创作、教学信息图表,还是老照片修复、3D手办制作、品牌物料生成,都能通过简单的三节点工作流配置完成,极大提升创作效率和降低技术门槛。

感兴趣的小伙伴可以按照文中提供的完整步骤进行实践,根据实际创作需求调整风格参数、画幅比例、创造力数值等配置项。今天的分享就到这里结束了,我们下一篇文章见。


前端性能优化之CSS篇

1、前言

当涉及到网站的性能优化时,CSS 的优化是一个非常重要的方面。如果你的 CSS 太复杂,那么你的网站可能会像一个慢吞吞的乌龟一样缓慢地加载和渲染,影响用户的留存网站的转化率以及网站的体验和传播等,所以对于 CSS 优化还是非常需要的,本文将介绍一些 CSS 性能优化的技巧,帮助你在编写 CSS 代码时提高性能。

2、前置知识

2.1 CSS 解析规则

我们一般书写 CSS 选择器都是从左往右书写的,所以自然就会以为 CSS 选择器是从左往右去匹配渲染的,但其实不是这样的,CSS 选择器是从右往左去匹配的

.header div span { 
  color: red;
}

比如上面的 CSS 样式匹配查找过程是这样的:

  1. 先找到所有的 span 元素。
  2. 从第一个 span 元素开始,沿着 span 元素的父级元素查到 div,最后沿着 div 的父级元素找到所有的 .header,找到满足条件的节点就加入到结果集中。

2.2 CSS 的加载过程

简述浏览器的渲染过程:在拿到 html 文件后,浏览器会调用 html 解析器解析 html 文件,构建 DOM 树和 CSSOM 树,然后 DOM 树和 CSSOM 树合并成渲染树,最后浏览器会根据渲染树进行布局和绘制,最终将页面呈现给用户。

CSSOM 的构建:当浏览器解析 html 文档时,如果遇到 <link><style> 标签,会开始下载和解析 CSS 文件,构建 CSSOM。在 CSSOM 构建完成之前,浏览器不会渲染任何已处理的内容,即使DOM已经解析完毕。

  • CSS 不会阻塞 DOM 的解析,但会阻塞 DOM 的渲染。
  • CSS 不会阻塞 Javascript 的下载,但会阻塞 JavaScript 的执行,因为 JavaScript 可以操作样式,所以需要等 CSSOM 构建完成之后,才能执行 JavaScript

所以从这里就可以得到以下优化思路:。

  • 将一些体积小的首屏关键 CSS 内联到 html 文件中,加快首屏渲染速度。
  • 将 CSS 放在 <head> 中,确保尽早加载。
  • 使用媒体查询(如 media="print")使 CSS 非阻塞渲染。
  • 在生产环境中,考虑使用 preload(如 <link rel="preload" as="style">)在不阻塞渲染的情况下加载非首屏的 CSS 资源。

2.3 CSS 权重优先级

CSS 选择器类型的优先级从低往高依次是:

  1. 类型选择器(比如 h1)和伪元素选择器(比如 ::before)。
  2. 类选择器(比如 .header)、属性选择器(比如 [type="checkbox"])和伪类选择器(比如 :hover)。
  3. ID 选择器(比如 #box)。
  4. !important:优先级最高。

2、CSS 加载性能优化

2.1. 提取公共的 CSS 文件

如果使用webpack进行项目打包,在打包阶段可以使用mini-css-extract-plugin提取公共 CSS 文件,便于缓存以及减少 CSS 请求次数。

2.2 避免使用 @import

  • 使用 @import 会阻塞浏览器的并行下载,导致加载速度变慢。
  • 多个 @import 会导致下载顺序紊乱。

2.3 压缩 CSS 文件

压缩 CSS 文件可以减小文件大小,从而加快加载速度。比如可以用optimize-css-assets-webpack-plugin配合mini-css-extract-plugin使用来优化和压缩 CSS 文件。

2.4 使用浏览器缓存

可以使用浏览器缓存来缓存 CSS 文件,从而在页面加载时加快速度。可以设置适当的缓存时间来确保文件在必要时能够更新。

2.5 使用 CDN 加速

  • 使用 CDN(内容分发网络) 可以将 CSS 文件分发到全国各个 CDN 节点的服务器上,用户可以就近下载,从而加快加载速度,也可以减少自己服务器的负载。
  • 如果静态资源很多,可以准备多个静态服务器域名,比如域名是 static.xx.com,做成支持 static0-5 的 6 个域名, 也就是 static0.xx.com、static1.xx.com、static2.xx.com...,每次请求时随机选一个域名地址进行请求,这样可以绕过浏览器同域名的连接数限制(一般是限制 6 个),6 个域名就可以同时发送 36 个连接请求。当然,这个域名个数不是越多越好,太分散的话又会涉及到多域名无法缓存静态资源的问题。

2.6 使用 CSS Sprite

CSS Sprite 技术就是我们常说的雪碧图,通过将多张小图标拼接成一张大图,能有效的减少HTTP请求数量以达到加速显示内容的技术。

2.7 CSS 样式抽离和去除无用 CSS

  • 平时开发过程中可以将一些可复用的 CSS 抽离到一个单独文件中,减少 CSS 代码冗余。
  • 在打包构建时可以利用一些插件去除没有用到的 CSS,相当于对 CSS 进行 Tree shaking,减少打包后体积。

2.8 合理使用内嵌 CSS

内嵌CSS 也就是通过元素的 style 来书写行内样式,比如 <div style="color: red"></div>,它的优缺点如下:

优点

  • 与使用 link 标签相比,可以减少 CSS 的 http 请求量。

缺点

  • 增加 html 文件的体积。
  • 使用 link 标签能很好的利用浏览器的缓存,而内联样式放在 html 里面,能否使用缓存需要看 html 文件的缓存策略。

综合它的优缺点,我们可以选择将一些体积小的首屏关键 CSS 内联到 html 文件中,加快首屏渲染速度。

3、CSS 选择器性能优化

3.1 避免使用通配符选择器

通配符选择器*符号可以匹配任何元素,但是这会导致浏览器需要遍历整个文档树来查找匹配的元素,从而降低性能。

3.2 使用子选择器代替后代选择器

后代选择器(如 .parent .child)会检查所有后代元素,而子选择器(如 .parent > .child)只检查直接子元素,匹配范围更小,性能更好。

3.3 优先使用类(Class)和 ID 选择器

类选择器(如 .box)和 ID 选择器(如 #box)匹配速度更快,因为它们直接指向特定元素。避免标签选择器(如 div)或属性选择器(如 [type="text"]),后者匹配更加宽泛,效率较低,尤其在大型 DOM 中。

3.4 避免深层嵌套的选择器

前面也说过,CSS 选择器需要从右往左去匹配的,嵌套的选择器如 .header div ul li a,因为浏览器需要遍历更多 DOM 节点。建议限制选择器深度在 3 层以内,使用更直接的类或 ID 选择器,如 .box。这能减少匹配时间。或者使用 BEM 命名规范,比如 .block__element--modifier,提高匹配效率。

另外在使用 ID 选择器的时候,前面就需要加上父级的选择器,比如不推荐用 .box #content,推荐用 #content

4、CSS 属性性能优化

4.1 避免使用过于复杂的属性

过于复杂的属性会增加浏览器的渲染负担,从而降低性能。应该尽量使用简单的属性来实现需要的样式效果,比如下面这些属性:

  • box-shadow:box-shadow 属性可以实现盒子的阴影效果,但是它会增加浏览器的计算和渲染成本。如果要实现一个简单的边框效果,可以使用 border 属性来替代。
/* 不推荐使用 */
.box {
  box-shadow: 2px 2px 2px #888;
}

/* 推荐使用 */
.box {
  border: 1px solid #888;
}
  • filter:filter 属性可以实现图像的滤镜效果,但是它会增加浏览器的计算和渲染成本。如果要实现一个简单的颜色变换效果,可以使用 background-color 属性来替代。
/* 不推荐使用 */
.box {
  filter: grayscale(50%);
}

/* 推荐使用 */
.box {
  background-color: #ccc;
}

不过需要注意的是,这些替代方案可能会有一些局限性,无法完全取代原来的属性。因此,在使用这些替代方案时,需要根据具体的需求和情况进行选择。

4.2 避免使用不必要的属性

不必要的属性会增加 CSS 文件的大小,从而降低加载速度。应该尽量避免使用不必要的属性。

4.3 避免使用 !important

!important 会覆盖其他样式,从而增加渲染时间。而且它不容易被替换或覆盖,后期可维护性也比较差,更好的方式使用更具体、更准确的选择器来定义样式。

5、CSS 动画性能优化

5.1 使用 transform 和 opacity 属性来进行动画

使用 transformopacity 属性可以减少浏览器的渲染负担,从而提高性能。

5.2 避免使用过于复杂的动画效果

过于复杂的动画效果会增加浏览器的渲染负担,从而降低性能。应该尽量使用简单的动画效果。

5.3 在动画中使用 will-change 属性

will-change 属性可以告诉浏览器哪些属性将要被改变,从而提前进行优化,减少渲染负担。

5.4 使用 requestAnimationFrame() 函数来优化动画

requestAnimationFrame() 函数可以在浏览器下一次渲染之前执行代码,从而减少渲染负担,提高性能。

6、CSS 渲染性能优化

6.1 使用 class 合并 DOM 的修改

比如我们需要根据不同情况给一个元素添加不同的样式,可能会写出如下的代码:

const el = document.querySelector('.box');
if (isHover) {
  el.style.color = 'red';
  el.style.background = 'blue';
} else {
  el.style.color = 'blue';
  el.style.background = 'red';
}

这时候我们可以定义两个 class 类,然后直接切换类名即可。

const el = document.querySelector('.box');
if (isHover) {
  el.classList.remove('normal');
  el.classList.add('hover');
} else {
  el.classList.remove('hover');
  el.classList.add('normal');
}

6.2 让 DOM 元素脱离文档流

使用 absoulte 或者 fixed 定位让 DOM 元素脱离文档流,这样在修改 DOM 元素的时候不会影响到其他 DOM 元素的布局,从而提高渲染性能。

7、总结

本文主要通过CSS 加载性能优化CSS 选择器性能优化CSS 属性性能优化CSS 动画性能优化CSS 渲染性能优化这些方向介绍了 CSS 的性能优化技巧。通过优化 CSS 性能,可以提高网站的性能和用户体验。

在实际开发中,可根据实际业务场景和需要去进行优化。

Next.js第十章(Proxy)

Proxy代理

从 Next.js 16 开始,中间件Middleware更名为代理(Proxy),以更好地体现其用途。其功能保持不变

如果你想升级为16.x版本,Next.js提供了命令行工具来帮助你升级,只需要执行以下命令即可:

npx @next/codemod@canary middleware-to-proxy .

代码转换会将文件和函数名从middleware重命名为proxy。

// middleware.ts -> proxy.ts
 
- export function middleware() {
+ export function proxy() {

基本使用

应用场景:

  • 处理跨域请求
  • 接口转发例如/api/user -> (可能是其他服务器java/go/python等) -> /api/user
  • 限流例如配合第三方服务做限流
  • 鉴权/判断是否登录

Prxoy代理其实跟拦截器类似,它可以在请求完成之前进行拦截,然后进行一些处理,例如:修改请求头、修改请求体、修改响应体等。

src/proxy.ts

定义proxy函数导出即可,Next.js会自动调用这个函数。

import { NextRequest, NextResponse } from "next/server";
export async function proxy(request: NextRequest) {
    console.log(request.url,'url');
}

但是你会发现,他会拦截项目中所有的请求,包括静态资源、API请求、页面请求等。

http://localhost:3000/.well-known/appspecific/com.chrome.devtools.json url
http://localhost:3000/_next/static/chunks/src_app_globals_91e4631d.css url
http://localhost:3000/_next/static/chunks/%5Bturbopack%5D_browser_dev_hmr-client_hmr-client_ts_cedd0592._.js url
http://localhost:3000/_next/static/chunks/node_modules_next_dist_compiled_react-dom_1e674e59._.js url
http://localhost:3000/_next/static/chunks/node_modules_next_dist_compiled_react-server-dom-turbopack_9212ccad._.js url
http://localhost:3000/_next/static/chunks/node_modules_next_dist_compiled_next-devtools_index_1dd7fb59.js url
http://localhost:3000/_next/static/chunks/node_modules_next_dist_compiled_a0e4c7b4._.js url
http://localhost:3000/_next/static/chunks/node_modules_next_dist_client_a38d7d69._.js url
http://localhost:3000/_next/static/chunks/node_modules_next_dist_4b2403f5._.js url
http://localhost:3000/_next/static/chunks/src_app_globals_91e4631d.css.map url
http://localhost:3000/_next/static/chunks/node_modules_%40swc_helpers_cjs_d80fb378._.js url
http://localhost:3000/_next/static/chunks/_a0ff3932._.js url
http://localhost:3000/api/login url

配置(config)

例如我们只想匹配'/api'下面的路径去做一些事情,我们可以使用config配置来实现。

import { NextRequest, NextResponse } from "next/server";
export async function proxy(request: NextRequest) {
    console.log(request.url,'url');
}
//配置匹配路径
export const config = {
    matcher: '/api/:path*',
    //matcher: ['/api/:path*','/api/user/:path*'], 支持单个以及多个路径匹配
    //matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'], 同样支持正则表达式匹配
}

结合之前的案例,在cookie那一集,我们还需要单独定义check接口检查cookie,现在我们可以直接在proxy中实现。

import { NextRequest, NextResponse } from "next/server";
export async function proxy(request: NextRequest) {
    const cookie = request.cookies.get('token');
    if (request.nextUrl.pathname.startsWith('/home') && !cookie) {
        console.log('redirect to login');
        return NextResponse.redirect(new URL('/', request.url));
    }
    if (cookie && cookie.value) {
        return NextResponse.next();
    }
    return NextResponse.redirect(new URL('/', request.url));
}

export const config = {
    matcher: ['/api/:path*', '/home/:path*'],
}

复杂匹配

  • source: 表示匹配路径
  • has: 表示匹配路径中必须(包含)某些条件
  • missing: 表示匹配路径中(必须不包含)某些条件

type 只能匹配: header, query, cookie

import { NextRequest, NextResponse } from "next/server";
import { ProxyConfig } from "next/server";
export async function proxy(request: NextRequest) {
   console.log('start proxy')
   return NextResponse.next();
}

export const config: ProxyConfig = {
    matcher: [
        {
            source: '/home/:path*',
            //表示匹配路径中必须(包含)Authorization头和userId查询参数
            has: [
                { type: 'header', key: 'Authorization', value: 'Bearer 123456' },
                { type: 'query', key: 'userId', value: '123' }
            ],
            //表示匹配路径中(必须不包含)cookie和userId查询参数
            missing: [
                { type: 'cookie', key: 'token', value: '123456' },
                { type: 'query', key: 'userId', value: '456' },
            ]
        },
    ]
}

访问url为:http://localhost:3000/home?userId=123

案例实战(处理跨域)

只要是/api下面的接口都可以被任意访问

import { NextRequest, NextResponse } from "next/server";
import { ProxyConfig } from "next/server";
export async function proxy(request: NextRequest) {
    const response = NextResponse.next();
    Object.entries(corsHeaders).forEach(([key, value]) => {
        response.headers.set(key, value);
    })
    return response;
}

const corsHeaders = {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}

export const config: ProxyConfig = {
   matcher:'/api/:path*',
}

image.png

再来聊聊,Vue3 项目中 Pinia 的替代方案

想获取更多2025年最新前端场景题可以看这里fe.ecool.fun

大家好,我是刘布斯。

之前转载了一篇文章Vue 项目不要再用 Pinia 了,先不可否认,这文章有点标题党的意思。但这篇文章的主要观点是说,在中小项目里,用 Vue 3 自带的组合式 API(reactive / ref)来管状态,很多时候比硬上 Pinia 要香。

好家伙,评论区一下就热闹了,总结起来是:“Pinia 多好用,你肯定是没用明白 Pinia”

说实话,我确实有点意外。

我先摆明态度:Pinia 是个非常优秀的状态管理库。 尤雨溪团队亲自操刀,API 设计简洁,TS 支持完美,插件系统灵活,Devtools 体验丝滑。这一点,没人能否认。

但我的核心观点是:优秀,不代表“所有场景都必须上”。

我发现现在很多团队,尤其是从 Vue 2 刚迁到 Vue 3 不久的,存在一种很强的 “状态管理惯性”

什么意思?

在 Vue 2 + Options API 的时代,组件(Component)和状态(State)是“隔离”的。data 里的状态天生就是“内向”的,组件一销毁,状态就没了。跨组件通信、全局状态共享,你怎么办?你没得选,你必须上 Vuex。Vuex 就像一个“中央空调”,你不用它,别的房间(组件)就享受不到冷气(状态)。

所以,Vue 2 时代养成了我们的思维定式:做项目 = Vue 全家桶 = Vue + Vue Router + Vuex。

到了 Vue 3,Vuex 退位,Pinia 继任。于是大家理所当然地把公式换成了:做项目 = Vue 3 + Vue Router + Pinia。

启动一个新项目,npm create vue@latest,一路 yes 下来,Pinia 就装好了。然后就开始 defineStore

但大家好像都忽略了 Vue 3 最大的革命性变化——组合式 API (Composition API) 本身,就已经是一种强大的状态管理模式了。

defineStore 帮我们做了什么?

还是用那篇文章中的例子,做一个最简单的“用户状态管理”,两种方式有什么区别。

1. Pinia 的方式: 你得先在 stores/user.ts 里:

import { defineStore } from'pinia'
import { ref, computed } from'vue'

exportconst useUserStore = defineStore('user'() => {
// State
const token = ref(null)
const userProfile = ref(null)

// Getters
const isLoggedIn = computed(() => !!token.value)

// Actions
function login(data) {
    token.value = data.token
    userProfile.value = data.profile
    // ... 存 localstorage
  }

function logout() {
    token.valuenull
    userProfile.valuenull
    // ... 清 localstorage
  }

return { token, userProfile, isLoggedIn, login, logout }
})

然后在组件里 useUserStore()

2. 组合式 API 的方式: 你在 stores/user.ts (对,你也可以叫 stores 目录,这只是个文件夹):

import { ref, computed, reactive } from'vue'

// 以前我们用 reactive,这里用 ref/computed 模拟 Pinia 的结构
const token = ref(null)
const userProfile = ref(null)

const isLoggedIn = computed(() => !!token.value)

function login(data) {
  token.value = data.token
  userProfile.value = data.profile
// ... 存 localstorage
}

function logout() {
  token.valuenull
  userProfile.valuenull
// ... 清 localstorage
}

// 导出一个 hook
exportfunction useUser() {
return { token, userProfile, isLoggedIn, login, logout }
}

然后在组件里 useUser()


好了,你对比下这两段代码。

你发现了什么?

在第二种方式里,我只是删掉了 defineStore('user', ...) 那层“壳”,然后把导出的 useUserStore 改成了 useUser (叫什么都行)。

其他的逻辑,一模一样! 都是在用 ref 和 computed

Pinia 的 defineStore 在这个场景里,本质上就是帮你做了一件事:创建了一个跨组件共享的、响应式的单例。

但在 Vue 3 里,import 一个在模块顶层(module scope)定义的 ref 或 reactive 对象,它天生就是单例!它天生就是跨组件共享的!

那你可能会反问了:“那 Pinia 岂不是多此一举?”

不,它当然不是多此一举。它提供了 defineStore 这个“壳”,是为了给你带来额外的好处,最核心的就是:

  1. Devtools 集成:这是 Pinia 最大的杀手锏。你可以在时间轴上看到 action 的调用、state 的变更。
  2. 插件系统:比如实现数据持久化,Pinia 有现成的插件,defineStore 的时候配置一下就行。
  3. SSR 支持:在服务端渲染时,Pinia 能帮你处理好状态的序列化和“注水”(hydration)。
  4. 更严格的“心智模型” :它强制你区分 stategettersactions,让团队协作更规范。

Pinia的优势,你的项目真的需要吗?

我们再回到上篇文章的核心观点:

在一个中小型项目、独立开发、或者团队成员对组合式 API 都很熟练的场景下,上面 Pinia 提供的 4 个好处,你真的都需要吗?

  • Devtools:说实话,在我十多年的生涯里,除了在 Redux/Vuex 刚出来那会儿,为了调试复杂的异步流和中间件,会去用时间旅行。在绝大多数业务场景里,console.log 和 Vue Devtools 里自带的组件状态检查,已经解决了 99% 的问题。为了那 1% 的“可能”,去引入一个库,划算吗?
  • 插件系统(如持久化) :用组合式 API 怎么做持久化?太简单了。你封装的 useUser hook 里面,login 的时候加一行 localStorage.setItem,初始化 token 的时候加一行 localStorage.getItem。这不就是最原始、最可控的持久化吗?你需要为这么点功能,去学一个 Pinia 插件的 API 吗?
  • SSR:如果你的项目压根就不是 SSR,那这条对你无效。
  • 严格的心智模型:这是最大的“陷阱”。组合式 API 的核心思想就是“自由”。它允许你把 state 和 action 放在一起,按“功能”去组织代码,而不是按“类型”(state/getter/action)去组织。defineStore 某种程度上,是又把我们拉回了 Vuex 的那种“分门别类”的思维里。

所以,“你会得到更少的心智负担”指的就是这个。

你不需要去记 defineStore 的 API,不需要去想“我这个逻辑是算 getter 还是 action”,你就是在写 JS/TS,你就是在写 function这难道不是一种解放吗?

什么叫“没用明白 Pinia”?

在我看来,恰恰相反。

把 Pinia 当成新时代的 Vuex,不分场景、启动项目就先装上,这才是“没用明白 Vue 3”。

如果没有明白 Vue 3 组合式 API 到底给了你多大的“自由”和“能力”,就可能继续用 Vue 2 的“保姆式”思维在写 Vue 3。

我想了几个场景,大家参考下:

  1. 场景一:个人项目、小团队敏捷开发、内部工具
  • 我的选择:100% 使用组合式 API。
  • 理由: 速度快,灵活,零依赖。我可以按功能拆分 useCounter.tsuseUser.tsuseCart.ts... 它们就是一堆 TS 模块,需要共享就在顶层定义 reactive,不需要就只导出函数。打包体积更小,心智负担为零。
  1. 场景二:大型企业级应用、多团队协作、需要强规范
  • 我的选择:我会倾向于使用 Pinia。
  • 理由: 这种项目,“规范”大于“灵活”。defineStore 提供的统一范式,能让不同水平的开发者(尤其是新人)写出风格更一致的代码。而且在这种复杂项目里,Devtools 的时间旅行和状态快照,在排查深层 Bug 时,确实能派上用场。
  1. 场景三:需要 SSR 的项目
  • 我的选择:用 Pinia。
  • 理由: 别重复造轮子。Pinia 对 SSR 状态处理得很好,自己搞一套组合式 API 的 SSR 状态同步,费时费力,不值得。

所以你看,并不是在全盘否定 Pinia,而是在呼吁大家 “按需选择”

不要因为“大家都用”或者“官方推荐”就去用。工具是死的,人是活的。Vue 3 给了我们一把更轻、更快的匕首(组合式 API),你为什么非要抱着那把很牛、但也很重的开山刀(Pinia)不放,连切个水果都要用它呢?

下次在项目里 npm install pinia 之前,先停 5 秒钟,问问自己:我这次,真的需要它吗?还是说,几个 ref 和 reactive 就能搞定了?

想明白这个问题,可能比你多刷 10 篇 Pinia 的教程都有用。

行了,今天就聊到这。

React+ts+vite脚手架搭建(六)【登录篇】

前言

上一篇我们介绍了在脚手架中配置代码格式规范、代码提交规范:React+ts+vite脚手架搭建(五)【规范篇】

本篇我们将开始脚手架中的最后一篇--登录篇

本文我们将完成一个传统的账号+密码登录功能

具体步骤

新建一个登录路由

在路由配置中新建一个登录路由 image.png

新建登录+代码

  • 在这段代码中我们分别做了:
    • 账号和密码的输入框、登录按钮
    • 点击登录按钮逻辑
    • 登录成功后将返回的token保存到localStorage image.png
import React, { useState } from "react";
import "./index.css";
const Login = () => {
  const [form, setForm] = useState({
    username: "",
    password: "",
  });

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setForm({
      ...form,
      [name]: value,
    });
  };

  const login = () => {
    if (!form.username || !form.password) {
      alert("请输入账号密码");
      return;
    } else {
      localStorage.setItem("token", "123456");
    }
  };

  return (
    <div className="login">
      <h1>登录页</h1>
      <div>
        <input
          type="text"
          placeholder="请输入账号"
          name="username"
          value={form.username}
          onChange={handleChange}
        />
      </div>
      <div>
        <input
          type="password"
          placeholder="请输入密码"
          name="password"
          value={form.password}
          onChange={handleChange}
        />
      </div>
      <button onClick={login}>登录</button>
    </div>
  );
};

export default Login;

页面如下:

image.png

token的携带

我们存储完token后,需要在每次的接口请求中都携带上,这里我们直接在请求拦截器上携带上

image.png

登录的校验

一般来说一个需要登录的系统,大部分的接口都是需要登录验证的,所以我们直接将登录的校验写在响应拦截器中,当发现接口响应回来的code为未登录的code时,即直接跳转到登录页重新开始登录

image.png

总结

这样一个极简的登录系统就已经做好。。。

这里有几点补充:

  • 系统登录的token一般是通过接口返回的,我这里是模拟了一下,大家后续可以根据自己项目的实际情况来写接口
  • 登录的token我是存在localStorage中的,你也可以存储在sessionStorage中

深入理解 JavaScript 中的异步编程:从回调到 async/await

深入理解 JavaScript 中的异步编程:从回调到 async/await

在现代 Web 开发中,异步操作无处不在——无论是从服务器获取数据、读取本地文件,还是处理用户交互。JavaScript 作为一门单线程语言,通过多种机制支持异步编程。本文将带你回顾异步编程的发展历程,并重点解析 async/await 这一 ES8 引入的强大语法糖。


1. 回调函数(Callback)时代:最初的异步方案

早期的 JavaScript 主要依赖回调函数来处理异步操作。例如,在 Node.js 中使用 fs.readFile 读取文件:

fs.readFile('./1.html', 'utf-8', (err, data) => {
    if (err) {
        console.log(err);
        return;
    }
    console.log(data);
    console.log(111);
});

这种方式简单直接,但存在明显问题:

  • 回调地狱(Callback Hell) :多层嵌套导致代码难以阅读和维护。
  • 错误处理分散:每个回调都需要单独处理错误。

2. Promise:ES6 带来的结构化异步

为了解决回调地狱,ES6 引入了 Promise 对象,它代表一个异步操作的最终完成(或失败)及其结果值。

我们可以将 fs.readFile 封装成一个 Promise:

const p = new Promise((resolve, reject) => {
    fs.readFile('./1.html', 'utf-8', (err, data) => {
        if (err) {
            reject(err);
            return;
        }
        resolve(data);
    });
});

p.then(data => {
    console.log(data);
    console.log(111);
}).catch(err => {
    console.error(err);
});

Promise 的优势:

  • 链式调用(.then().then()
  • 统一的错误处理(.catch()
  • 更清晰的异步流程控制

.then() 链仍然不够“同步感”,尤其在复杂逻辑中仍显繁琐。


3. async / await:ES8 的终极优雅方案

ES8(ECMAScript 2017)引入了 asyncawait,让异步代码写起来像同步代码一样直观。

基本用法

  • async 用于声明一个函数为异步函数,该函数总是返回一个 Promise
  • await 只能在 async 函数内部使用,用于“等待”一个 Promise 被 resolve,并将其结果赋值给变量。

例如,封装后的文件读取可以这样写:

const main = async () => {
    try {
        const html = await p; // 等待 Promise 完成
        console.log(html);
        console.log(111);
    } catch (err) {
        console.error(err);
    }
};
main();

再比如,从 GitHub API 获取用户仓库信息:

const main = async () => {
    try {
        const res = await fetch('https://api.github.com/users/shunwuyu/repos');
        const data = await res.json();
        console.log(data);
    } catch (error) {
        console.error('请求失败:', error);
    }
};
main();

优势总结

特性 说明
可读性强 代码结构接近同步逻辑,易于理解和调试
错误处理统一 使用 try...catch 捕获异步错误
避免回调地狱 不再需要层层嵌套 .then()
与现有 Promise 兼容 await 后可接任何 Promise

4. 实际应用场景对比

以获取 GitHub 用户仓库为例:

  • 传统 Promise 链式写法

    fetch('https://api.github.com/users/shunwuyu/repos')
      .then(res => res.json())
      .then(data => console.log(data))
      .catch(err => console.error(err));
    
  • async/await 写法

    const getRepos = async () => {
        try {
            const res = await fetch('https://api.github.com/users/shunwuyu/repos');
            const data = await res.json();
            console.log(data);
        } catch (err) {
            console.error(err);
        }
    };
    getRepos();
    

后者更接近自然语言:“先获取响应,再解析 JSON,最后打印数据”。


5. 注意事项

  • await 只能在 async 函数内使用。
  • async 函数总是返回 Promise,即使你 return 一个普通值。
  • 多个不相关的异步操作应避免串行 await,可使用 Promise.all() 并行处理以提升性能。
// ❌ 低效:串行执行
const a = await fetch(url1);
const b = await fetch(url2);

// ✅ 高效:并行执行
const [res1, res2] = await Promise.all([fetch(url1), fetch(url2)]);

结语

从回调函数到 Promise,再到 async/await,JavaScript 的异步编程模型不断演进,目标始终是:让异步代码更简洁、更安全、更易维护。如今,async/await 已成为现代前端和 Node.js 开发的标配。掌握它,不仅能提升开发效率,也能写出更具可读性和健壮性的代码。

正如那句老话所说:“异步不可怕,可怕的是写得像同步却不是同步。”而 async/await,正是让异步“看起来像同步”的最佳实践。

前端直连大模型:用原生 JavaScript 调用 DeepSeek API

在 AI 应用快速普及的今天,大语言模型(LLM)不再只是后端服务的专属能力。通过标准的 HTTP 接口,前端应用也能直接与模型对话——无需中间服务器,只需一个 API Key 和几行代码。本文将带你从零开始,使用原生 HTML/JavaScript 搭建一个能调用 DeepSeek 大模型的前端项目,并深入解析如何安全、规范地发起复杂请求。

项目初始化:选择合适的开发脚手架

虽然纯 HTML 文件足以完成基础调用,但现代前端开发更推荐使用工程化工具提升效率。Vite 是目前最轻量且功能强大的全栈脚手架之一,支持 TypeScript、环境变量、热更新等特性。

首先,初始化一个 Vite 项目:

npm create vite@latest my-llm-app -- --template vanilla
cd my-llm-app
npm install

这会创建一个基于原生 JavaScript 的项目结构,包含 index.htmlmain.js,非常适合快速集成 LLM 调用逻辑。

理解 LLM 的 HTTP 调用协议

大模型 API 本质上是一个标准的 RESTful 服务。以 DeepSeek 为例,其聊天接口地址为:

https://api.deepseek.com/v1/chat/completions

调用它需要构造一个符合要求的 POST 请求,包含三个关键部分:

1. 请求行(Request Line)

  • 方法:POST
  • URL:完整接口地址
  • 协议版本:HTTP/1.1(由浏览器自动处理)

2. 请求头(Headers)

必须包含两项:

  • Content-Type: application/json:声明请求体为 JSON 格式
  • Authorization: Bearer <your-api-key>:身份认证令牌

3. 请求体(Body)

  • 必须是字符串,不能直接传 JavaScript 对象
  • 需用 JSON.stringify() 序列化
  • 包含模型名称、消息历史等参数

前端代码实现:用 fetch 发起请求

下面是在 main.js 中封装的调用函数:

// 从环境变量读取 API Key(Vite 支持 .env 文件)
const API_KEY = import.meta.env.VITE_DEEPSEEK_API_KEY;
const API_URL = 'https://api.deepseek.com/v1/chat/completions';

async function askDeepSeek(messages) {
  const payload = {
    model: 'deepseek-reasoner',
    messages: messages,
    max_tokens: 500
  };

  const response = await fetch(API_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${API_KEY}`
    },
    body: JSON.stringify(payload)
  });

  if (!response.ok) {
    throw new Error(`HTTP error! status: ${response.status}`);
  }

  const data = await response.json();
  return data.choices[0].message.content;
}

关键点解析:

  • import.meta.env:Vite 提供的环境变量读取方式。需在项目根目录创建 .env 文件:

    VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx
    

    注意:Vite 要求环境变量必须以 VITE_ 开头才能在前端暴露,这是安全机制。

  • await fetch:相比 .then() 链式调用,async/await 语法更接近同步代码,逻辑清晰易读。

  • 错误处理:检查 response.ok 可捕获 4xx/5xx 错误,避免解析失败的 JSON。

在页面中使用

index.html 中添加简单交互:

<!DOCTYPE html>
<html>
<head>
  <title>AI 助手</title>
</head>
<body>
  <input type="text" id="question" placeholder="输入问题...">
  <button onclick="handleAsk()">提问</button>
  <div id="answer"></div>

  <script type="module" src="/main.js"></script>
  <script>
    async function handleAsk() {
      const input = document.getElementById('question');
      const answerDiv = document.getElementById('answer');
      
      try {
        const response = await askDeepSeek([
          { role: 'user', content: input.value }
        ]);
        answerDiv.textContent = response;
      } catch (err) {
        answerDiv.textContent = '请求失败:' + err.message;
      }
    }
  </script>
</body>
</html>

这里通过内联 <script> 调用模块化的 askDeepSeek 函数,实现了从用户输入到 AI 回答的完整流程。

安全提醒:前端暴露 API Key 的风险

必须明确:将 API Key 写在前端代码中存在泄露风险。任何访问你网页的用户都能通过开发者工具查看该密钥。因此,这种方式仅适用于:

  • 个人测试项目
  • 密钥有严格用量限制
  • 使用了 API 平台的域名白名单或 Referer 限制

对于生产环境,强烈建议通过自己的后端代理请求,由服务器保管密钥。

总结

通过原生 fetch API,前端可以直接与大模型服务通信,实现低延迟的智能交互。结合 Vite 的工程化能力,我们不仅能高效开发,还能通过环境变量管理敏感信息。虽然存在安全限制,但在可控场景下,这种“前端直连 LLM”的模式极大简化了 AI 应用的构建流程——从一行 HTML 到智能对话,不过百行代码的距离。

基于WASM的纯前端Office解决方案:在线编辑/导入导出/权限切换(已开源)

效果展示

所有操作均在浏览器进行,先来看看最终效果:

🌐 在线演示: mvp-onlyoffice.vercel.app/

image.png

核心功能演示

  • 文档上传:支持本地文件直接上传
  • 实时编辑:流畅的文档编辑体验
  • 格式转换:基于WASM的文档格式转换
  • 导出保存:一键导出编辑后的文档
  • 模式切换:只读/可编辑模式自由切换
  • 多语言支持:中英文界面无缝切换

技术架构

核心技术栈

  • React 19 + Next.js 15:现代化前端框架
  • OnlyOffice SDK:官方JavaScript SDK,提供文档编辑核心能力
  • WebAssembly (x2t-wasm):文档格式转换引擎
  • TypeScript:类型安全的开发体验
  • EventBus:事件驱动的架构设计
  • IndexedDB:WASM文件缓存优化

tip: 事实上不依赖于 react,你可以拿到 项目中的 src/onlyoffice-comp ,然后接入到任何系统中去,接入层可以参考 src/app/excel/page.tsx等应用层文件

架构流程图

用户上传文档
    ↓
React组件层
    ↓
EditorManager (编辑器管理器)
    ↓
X2T Converter (WASM转换器)
    ↓
OnlyOffice SDK (文档编辑器)
    ↓
EventBus (事件总线)
    ↓
导出/保存文档

WASM文档转换核心流程

转换流程图解

用户选择文件
    ↓
浏览器读取文件
    ↓
WASM虚拟文件系统
    ↓
X2T引擎执行转换
    ↓
生成二进制数据 + 媒体资源
    ↓
OnlyOffice编辑器加载

核心代码实现

// src/onlyoffice-comp/lib/x2t.ts

/**
 * X2T 工具类 - 负责文档转换功能
 */
class X2TConverter {
  private x2tModule: EmscriptenModule | null = null;
  
  // 支持的文件类型映射
  private readonly DOCUMENT_TYPE_MAP: Record<string, DocumentType> = {
    docx: 'word',
    doc: 'word',
    odt: 'word',
    rtf: 'word',
    txt: 'word',
    xlsx: 'cell',
    xls: 'cell',
    ods: 'cell',
    csv: 'cell',
    pptx: 'slide',
    ppt: 'slide',
    odp: 'slide',
  };

  /**
   * 转换文档格式
   */
  async convertDocument(file: File): Promise<ConversionResult> {
    // 初始化WASM模块
    await this.ensureReady();
    
    // 写入虚拟文件系统
    const data = await file.arrayBuffer();
    this.x2tModule!.FS.writeFile('/working/origin', new Uint8Array(data));
    
    // 执行C++编译的转换模块
    this.executeConversion('/working/params.xml');
    
    // 提取转换结果和媒体文件
    return {
      bin: this.x2tModule!.FS.readFile('/working/output.bin'),
      media: this.collectMediaFiles() // 提取图片等资源
    };
  }
}

编辑器管理器:Proxy模式的安全封装

项目采用Proxy模式对OnlyOffice编辑器实例进行安全封装,提供统一的API接口:

// src/onlyoffice-comp/lib/editor-manager.ts

class EditorManager {
  private editor: DocEditor | null = null;
  
  // 使用 Proxy 提供安全的访问接口
  private createProxy(): DocEditor {
    return new Proxy({} as DocEditor, {
      get: (_target, prop) => {
        if (prop === 'destroyEditor') {
          return () => this.destroy();
        }
        if (prop === 'sendCommand') {
          return (params) => {
            if (this.editor) {
              this.editor.sendCommand(params);
            }
          };
        }
        return this.editor ? (this.editor as any)[prop] : undefined;
      },
    });
  }
  
  // 导出文档(事件驱动)
  async export(): Promise<SaveDocumentData> {
    const editor = this.get();
    if (!editor) {
      throw new Error('Editor not available');
    }
    
    // 触发保存
    (editor as any).downloadAs();
    
    // 等待保存事件
    const result = await onlyofficeEventbus.waitFor(
      ONLYOFFICE_EVENT_KEYS.SAVE_DOCUMENT, 
      10000
    );
    
    return result;
  }
}

事件驱动架构:EventBus解耦设计

项目采用事件总线机制,实现组件间的松耦合通信:

// src/onlyoffice-comp/lib/eventbus.ts

class EventBus {
  private listeners: Map<EventKey, Array<(data: any) => void>> = new Map();
  
  // 监听事件
  on<K extends EventKey>(key: K, callback: (data: EventDataMap[K]) => void): void {
    if (!this.listeners.has(key)) {
      this.listeners.set(key, []);
    }
    this.listeners.get(key)!.push(callback);
  }
  
  // 等待事件触发(返回 Promise)
  waitFor<K extends EventKey>(key: K, timeout?: number): Promise<EventDataMap[K]> {
    return new Promise((resolve, reject) => {
      const timeoutId = timeout
        ? setTimeout(() => {
            this.off(key, handleEvent);
            reject(new Error(`Event ${key} timeout after ${timeout}ms`));
          }, timeout)
        : null;

      const handleEvent = (data: EventDataMap[K]) => {
        if (timeoutId) clearTimeout(timeoutId);
        this.off(key, handleEvent);
        resolve(data);
      };

      this.on(key, handleEvent);
    });
  }
}

支持的事件类型

  • saveDocument - 文档保存完成事件
  • documentReady - 文档加载就绪事件
  • loadingChange - 加载状态变化事件

核心功能特性

1. 国际化支持

项目内置多语言支持,可自由切换中英文界面:

// 切换语言
const handleLanguageSwitch = async () => {
  const newLang = currentLang === 'zh' ? 'en' : 'zh';
  setCurrentLang(newLang);
  
  // 如果编辑器已存在,重新创建以应用新语言
  if (editorManager.exists()) {
    await handleView(fileName, file);
  }
};

2. 导入导出功能

完整的文档导入导出能力:

// 导出文档
const result = await editorManager.export();
// result 包含: { fileName, fileType, binData, media }

// 转换并下载
const buffer = await convertBinToDocument(
  result.binData, 
  result.fileName,
  FILE_TYPE.XLSX, 
  result.media
);

const blob = new Blob([buffer.data], {
  type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
// 执行下载操作

3. 只读/可编辑模式切换

灵活的权限控制,支持动态切换编辑模式:

// 设置为只读模式
await editorManager.setReadOnly(true);

// 切换为可编辑模式
await editorManager.setReadOnly(false);

// 查询当前模式
const isReadOnly = editorManager.getReadOnly();

实现原理

  • 从只读切换到可编辑:重新创建编辑器实例
  • 从可编辑切换到只读:使用processRightsChange命令

4. IndexedDB缓存优化

使用IndexedDB缓存WASM文件,大幅提升二次加载速度:

// 拦截 fetch,缓存 WASM 文件到 IndexedDB
private interceptFetch(): void {
  const originalFetch = window.fetch;
  
  window.fetch = async function(input: RequestInfo | URL): Promise<Response> {
    // 先尝试从缓存读取
    const cached = await this.getCachedWasm(url);
    if (cached) {
      return new Response(cached, {
        headers: { 'Content-Type': 'application/wasm' }
      });
    }
    
    // 缓存未命中,从网络加载并缓存
    const response = await originalFetch(input);
    const arrayBuffer = await response.arrayBuffer();
    await this.cacheWasm(url, arrayBuffer);
    
    return response;
  };
}

使用示例

基本使用

import { createEditorView } from '@/onlyoffice-comp/lib/x2t';
import { editorManager } from '@/onlyoffice-comp/lib/editor-manager';

// 创建编辑器视图
await createEditorView({
  file: fileObject,        // File 对象(可选)
  fileName: 'document.xlsx', // 文件名
  isNew: false,            // 是否新建文档
  readOnly: false,        // 是否只读
  lang: 'zh',             // 界面语言
});

// 导出文档
const result = await editorManager.export();
console.log('导出成功:', result);

React组件集成

// src/app/excel/page.tsx
function ExcelPageContent() {
  const [readOnly, setReadOnly] = useState(false);
  const [currentLang, setCurrentLang] = useState<'zh' | 'en'>('zh');
  
  // 上传文档
  const handleView = async (fileName: string, file?: File) => {
    await initializeOnlyOffice();
    await createEditorView({
      file,
      fileName,
      isNew: !file,
      readOnly,
      lang: currentLang,
    });
  };
  
  // 导出文档
  const handleExport = async () => {
    const result = await editorManager.export();
    const buffer = await convertBinToDocument(
      result.binData, 
      result.fileName,
      FILE_TYPE.XLSX, 
      result.media
    );
    // 下载文件...
  };
  
  // 切换只读模式
  const toggleReadOnly = async () => {
    const newReadOnly = !readOnly;
    setReadOnly(newReadOnly);
    await editorManager.setReadOnly(newReadOnly);
  };
  
  return (
    <div>
      {/* UI组件 */}
    </div>
  );
}

项目结构

mvp-onlyoffice/
├── src/
│   ├── app/              # Next.js 应用页面
│   │   ├── excel/        # Excel 编辑器页面
│   │   ├── docs/         # Word 编辑器页面
│   │   └── ppt/          # PowerPoint 编辑器页面
│   ├── onlyoffice-comp/  # OnlyOffice 组件库
│   │   └── lib/
│   │       ├── editor-manager.ts  # 编辑器管理器
│   │       ├── x2t.ts             # 文档转换模块
│   │       ├── eventbus.ts        # 事件总线
│   │       └── utils.ts            # 工具函数
│   └── components/       # 通用组件
├── public/               # 静态资源
│   ├── web-apps/         # OnlyOffice Web 应用资源
│   ├── sdkjs/            # OnlyOffice SDK 资源
│   └── wasm/             # WebAssembly 转换器
└── onlyoffice-x2t-wasm/  # x2t-wasm 源码

部署方案

Vercel一键部署

项目已配置静态导出,可直接部署到Vercel:

# 安装依赖
npm install

# 构建项目
npm run build

# Vercel 会自动检测并部署

🌐 在线演示: mvp-onlyoffice.vercel.app/

静态文件部署

项目支持静态导出,构建后的文件可部署到任何静态托管服务:

# 构建静态文件
npm run build

# 输出目录: out/
# 可直接部署到 GitHub Pages、Netlify、Nginx 等

技术优势总结

特性 传统方案 本方案
数据安全 ❌ 需要上传服务器 ✅ 完全本地处理
部署成本 ❌ 需要后端服务 ✅ 纯静态部署
格式支持 ⚠️ 有限格式 ✅ 30+种格式
离线使用 ❌ 需要网络 ✅ 完全离线
性能优化 ⚠️ 依赖网络 ✅ IndexedDB缓存
国际化 ⚠️ 需额外配置 ✅ 内置支持
权限控制 ⚠️ 复杂实现 ✅ 简单API

技术原理

使用x2t-wasm替代OnlyOffice服务

传统OnlyOffice集成需要:

  1. 搭建OnlyOffice Document Server
  2. 配置文档转换服务
  3. 处理文档上传下载
  4. 管理服务器资源

本方案通过WASM技术:

  1. 在浏览器中直接运行x2t转换引擎
  2. 使用虚拟文件系统处理文档
  3. 完全客户端化,无需服务器

参考项目

开源地址

🔗 GitHub仓库: mvp-onlyoffice

总结

本项目提供了一个完整的纯前端OnlyOffice集成方案,通过WASM技术实现了文档格式转换的本地化,结合React和OnlyOffice SDK,打造了一个功能完善、性能优秀的文档编辑器。

核心亮点

  • 🚀 纯前端架构,无需后端服务
  • 🔒 数据完全本地化,保护隐私安全
  • ⚡ 基于WASM的高性能转换
  • 🌏 内置国际化支持
  • 📦 支持导入导出
  • 🔐 灵活的权限控制

欢迎Star和Fork,一起推动前端Office编辑技术的发展!


相关阅读

vite 下使用 Module Federation

Module Federation

Module Federation 的核心是  “打破构建边界,实现模块级的跨应用共享与协同” ,其最佳使用场景需满足以下特征:

  • 应用 / 模块由多团队独立开发维护;
  • 需要复用公共依赖或组件,避免重复打包;
  • 希望简化模块更新流程(免 npm 发布);
  • 需实现微前端、跨技术栈协作或模块级灰度发布。

注意: 每个应用都可以在 Federation 中暴露 或者加载 远程可共享模块

如何使用 vite 搭建 MF

项目 github 参考地址: github.com/kejuqu/febe…

创建两个应用 vite-reactvite-react-provider

  • vite-react-provider 暴露 Button 组件
  • vite-react 使用 vite-react-provider 应用暴露的 Button 组件

vite-react-provider 应用

// vite.config.ts
import { defineConfig, type PluginOption } from "vite";
import react from "@vitejs/plugin-react";
import { federation } from "@module-federation/vite";

// https://vite.dev/config/
export default defineConfig({
  server: {
    port: 3006,
  },
  plugins: [
    react({
      babel: {
        plugins: [["babel-plugin-react-compiler"]],
      },
    }),
    federation({
      name: "remote",
      filename: "remoteEntry.js",
      // exposes 暴露 组件或者使用的工具函数
      exposes: {
        "./c-button": "./src/components/button.tsx",
      },
      shared: ["react", "react-dom"],
    }) as PluginOption[],
  ],
});

// src/components/button.tsx
export default function Button(props: React.ComponentProps<"button">) {
  return <button {...props}>button from remote</button>;
}

vite-react 使用 React.Lazy + dynamic import 加载远程模块

// vite.config.ts
import { defineConfig } from "vite";
import { federation } from "@module-federation/vite";
import react from "@vitejs/plugin-react";

// https://vite.dev/config/
export default defineConfig({
  server: {
    port: 3005,
  },
  plugins: [
    react({
      babel: {
        plugins: [["babel-plugin-react-compiler"]],
      },
    }),
    federation({
      name: "customer",
      filename: "vite-react.js",
      // // exposes 暴露 组件或者使用的工具函数
      // exposes: {
      //   "./utils": "./src/utils.tsx",
      // },
      remotes: {
        remote: {
          type: "module",
          name: "remote",
          entry: "http://localhost:3006/remoteEntry.js",
          entryGlobalName: "remote",
          shareScope: "default",
        },
      },
      shared: ["react", "react-dom"],
    }),
  ],
});


// src/App.tsx
import React from "react";
import "./App.css";

function App() {
  const RemoteBtn = React.lazy(() => import("remote/c-button"));

  return (
    <>
      <React.Suspense fallback={<div>loading...</div>}>
        <RemoteBtn onClick={() => alert("clicked")} />
      </React.Suspense>
    </>
  );
}

export default App;

效果图

image.png

Monorepo 架构全解析:从概念到落地的完整指南

一、什么是 Monorepo?

1.1 核心概念

Monorepo(单体仓库)是一种软件开发架构模式,它将多个相关项目、应用或模块的源代码集中存储在单一的代码仓库中进行管理。与传统的多仓库(Multi-repo)模式不同,Monorepo 允许团队在一个统一的上下文中开发多个相关组件,从而简化了代码共享和项目间依赖管理。

1.2 与多仓库(Multi-repo)的对比

特性/方面 Monorepo(单体仓库) Multi-repo(多仓库)
代码组织 所有项目代码在一个仓库中 每个项目独立仓库
代码共享 直接通过引用共享代码,无需发布包 需要将共享代码发布为npm包才能复用
依赖管理 统一依赖版本,避免版本冲突 各仓库可能使用不同版本的依赖,易出现冲突
代码变更 跨项目变更可在一次提交中完成 需要在多个仓库中进行多次提交和协调
构建测试 可统一构建、测试所有项目 需要单独构建、测试每个仓库
存储效率 依赖只安装一次,节省空间 相同依赖在各仓库中重复安装
权限管理 较难实现细粒度的权限控制 可针对不同仓库设置不同权限
初始复杂度 配置相对复杂,需要专用工具支持 配置简单,容易上手

1.3 适用场景

Monorepo 特别适合以下场景:

  • 微服务架构:多个服务紧密相关,经常需要协同开发和部署
  • 组件库开发:共享UI组件、工具函数等公共资源
  • 前端应用与后端服务的联合开发:需要频繁跨项目协作
  • 大型团队协作:代码共享需求高,需要统一的工作流和规范
  • 需要频繁同步更新的多项目:相关项目之间存在紧密依赖关系

1.4 常见误区

需要注意的是,Monorepo 并非适用于所有场景:

  • 它不意味着所有代码都必须放在一个文件中,仍然保持良好的模块化结构
  • 它不消除代码隔离的需要,相反,Monorepo 更强调合理的项目边界划分
  • 它不是解决所有协作问题的银弹,仍需良好的开发规范和流程配合
  • 对于完全独立、技术栈差异大、几乎不需要代码共享的项目,多仓库模式可能更合适

二、Monorepo 核心目标

在动手前,先明确 Monorepo 的核心目标,避免走偏。其核心是“公共代码抽离、业务项目隔离”,具体目标包括:

  • 统一管理:代码、依赖、构建、测试、部署流程统一维护;
  • 共享复用:公共代码(如工具函数、组件、配置)抽为公共包,避免重复开发;
  • 隔离清晰:各项目/模块独立编译、测试、发布,互不干扰;
  • 高效协作:跨项目开发无需切换仓库,分支管理、代码审查更简化;
  • 版本一致:避免多仓库间依赖版本冲突,确保所有项目使用统一的依赖版本。

三、技术栈选择

在众多 Monorepo 解决方案中,本次选用的技术栈及核心理由如下:

技术组件 版本/作用 选择理由
核心包管理器 Yarn 4.9.1(Workspace 特性) 1. Workspace 功能成熟稳定;2. 依赖提升机制(hoisting)减少重复依赖;3. 支持 node-modules 模式;4. 丰富的命令行工具适配 Monorepo 操作
代码规范 EditorConfig, Prettier, ESLint 1. EditorConfig统一多编辑器代码格式配置;2. Prettier负责代码格式化;3. ESLint负责代码质量检查和语法规范;三者结合确保团队编码风格一致和代码质量
任务调度 concurrently 支持并行执行多个命令,提升开发和构建效率

四、项目目录结构设计

合理的目录结构是 Monorepo 成功的关键,本次采用经典的分层结构,清晰区分业务应用与公共资源,具体结构如下:

LowCode/
├── package.json                # 根项目配置(管理公共依赖、脚本命令)
├── tsconfig.base.json          # TypeScript 基础配置(共享给所有子项目)
├── .yarn/                      # Yarn 配置
├── .yarnrc.yml                 # Yarn 运行时配置
├── 其他配置...
├── apps/                       # 业务项目目录(独立部署的应用)
│   ├── web/                    # 前端 Web 应用
│   │   └── package.json
│   ├── api/                    # 后端 API 服务
│   │   └── package.json
├── packages/                   # 公共包目录(可被其他项目依赖)
│   ├── utils/                  # 工具函数库
│   │   └── package.json
│   ├── components/             # UI 组件库
│   │   └── package.json
│   └── config/                 # 共享配置库
│   │   └── package.json

核心目录说明

  • apps/ :存放业务应用,每个应用都是独立可部署的项目,如前端 Web 应用和后端 API 服务;
  • packages/ :存放可重用的公共库,供 apps 目录下的项目依赖使用,包括工具函数、UI 组件和共享配置等;
  • 根目录:承担全局管理职责,包含项目级公共配置、共享脚本、依赖声明和项目文档。

五、实现步骤详解

Monorepo 的实现遵循“初始化-配置-细化”的流程,从根项目搭建到子项目配置逐步推进,确保每个环节衔接顺畅。

1. 初始化根项目

首先创建项目根目录并通过 Yarn 初始化,生成基础的 package.json 文件:

mkdir LowCode
cd LowCode
yarn init -y

2. 配置 Yarn Workspace

Yarn Workspace 是实现 Monorepo 依赖管理和项目关联的核心,需在根项目的 package.json 中添加如下配置,明确工作空间范围和公共脚本:

{
  "name": "low_code",
  "version": "1.0.0",
  "packageManager": "yarn@4.9.1",
  "private": true,
  "workspaces": [
    "packages/*",    // 所有在 packages 目录下的子项目
    "apps/*"         // 所有在 apps 目录下的子项目
  ],
  "scripts": {
    "test": "yarn workspaces foreach -A run test",
    "build:all": "yarn workspaces foreach -A -p run build",
    "build:apps": "yarn workspaces foreach -A --include 'apps/*' -p run build",
    "build:packages": "yarn workspaces foreach -A --include 'packages/*' -p run build",
    "dev:web": "yarn workspace @wect/web dev",
    "dev:api": "yarn workspace @wect/api dev",
    "dev:all": "concurrently "yarn workspace @wect/web dev" "yarn workspace @wect/api dev"",
    "clean": "yarn workspaces foreach -A -p run clean",
    "add": "yarn workspace",
    "remove": "yarn workspace",
    "lint": "yarn workspaces foreach -A -p run lint",
    "lint:fix": "yarn workspaces foreach -A -p run lint:fix",
    "format": "prettier --write './**/*.{js,jsx,ts,tsx,json,md,yml}' --ignore-path .prettierignore",
    "format:check": "prettier --check './**/*.{js,jsx,ts,tsx,json,md,yml}' --ignore-path .prettierignore"
  },
  "devDependencies": {
    "concurrently": "^9.2.1"
  }
}

配置关键点

  • private: true 确保根项目不会被意外发布;
  • workspaces 数组定义工作空间位置,自动识别指定目录下的子项目;
  • 通过 workspace 命令操作单个子项目,workspaces foreach 批量操作所有子项目;
  • 子项目名称统一使用作用域前缀(如 @wect/web),避免命名冲突。

3. 配置 Yarn 运行时

创建 .yarnrc.yml 文件,指定 Yarn 的依赖链接模式,本次采用经典的 node-modules 模式,兼容性更强:

# Yarn配置文件
nodeLinker: node-modules

4. 初始化子项目

子项目初始化提供两种方式,可根据实际需求选择:

方法一:批量初始化(推荐)

先安装任务调度依赖 concurrently,再通过 Yarn 命令批量初始化所有子项目:

# 安装公共开发依赖
yarn add -D concurrently

# 批量初始化所有子项目
yarn workspaces foreach -A -p init

方法二:手动初始化(适用于个别项目)

进入具体子项目目录,单独执行初始化命令,以 web 应用为例:

cd apps/web
yarn init -y

5. 配置子项目

每个子项目需配置独立的 package.json,明确自身依赖、脚本命令等信息,同时通过 workspace 协议关联内部公共包。以下是核心子项目的配置示例:

Web 应用配置(apps/web/package.json)

{
  "name": "@wect/web",
  "version": "1.0.0",
  "private": true,
  "packageManager": "yarn@4.9.1",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview",
    "test": "echo '运行Web测试...'",
    "clean": "rm -rf dist",
    "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'",
    "lint:fix": "eslint 'src/**/*.{js,jsx,ts,tsx}' --fix",
    "format": "prettier --write 'src/**/*' --ignore-path ../../.prettierignore"
  },
  "dependencies": {
    "@wect/utils": "workspace:*",       // 依赖内部工具包
    "@wect/components": "workspace:*",  // 依赖内部组件库
    "@wect/config": "workspace:*",      // 依赖内部配置库
    "react": "^19.2.0",
    "react-dom": "^19.2.0"
  },
  "devDependencies": {
    "@types/node": "^20.14.10",
    "@types/react": "^19.2.0",
    "@types/react-dom": "^19.2.0",
    "@vitejs/plugin-react": "^5.2.0",
    "eslint": "^9.7.0",
    "eslint-plugin-react": "^7.35.0",
    "eslint-plugin-react-hooks": "^4.6.2",
    "typescript": "^5.5.3",
    "vite": "^6.3.9"
  }
}

API 服务配置(apps/api/package.json)

{
  "name": "@wect/api",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "echo "启动API开发服务器..."",
    "build": "echo "构建API服务..."",
    "test": "echo "运行API服务测试..."",
    "lint": "echo "检查API服务代码..."",
    "clean": "echo "清理API服务构建产物..."",
    "deploy": "echo "部署API服务...""
  },
  "dependencies": {
    "@wect/utils": "workspace:*",
    "@wect/config": "workspace:*"
  }
}

6. 依赖管理详解

Monorepo 中的依赖管理分为“外部依赖”和“内部依赖”,需采用不同的管理方式:

安装外部依赖

根据依赖的作用范围,可安装在根项目(所有子项目共享)或特定子项目:

# 安装根项目依赖(所有子项目共享)
yarn add -D prettier eslint

# 为 web 应用单独安装依赖
yarn workspace @wect/web add react react-dom

依赖内部子项目

使用 workspace: 协议引用其他工作空间,确保始终使用本地最新版本,避免版本不一致问题:

{
  "dependencies": {
    "@wect/utils": "workspace:*",    // * 表示匹配最新版本
    "@wect/components": "workspace:*"
  }
}

7. 常用脚本命令详解

根项目的 scripts 配置了批量操作子项目的命令,结合 Yarn Workspace 特性实现高效管理,核心命令及用途如下:

命令分类 具体命令 功能说明
开发命令 yarn dev:web 单独启动 web 应用开发服务器
yarn dev:api 单独启动 API 服务开发服务器
yarn dev:all 并行启动 web 和 API 开发服务(依赖 concurrently)
构建命令 yarn build:all 构建所有工作空间(应用+公共包)
yarn build:apps 仅构建 apps 目录下的业务应用
yarn build:packages 仅构建 packages 目录下的公共包
质量保障命令 yarn test 运行所有子项目的测试用例
yarn lint / lint:fix 检查/修复所有子项目的代码规范问题
yarn format / format:check 格式化代码/检查代码格式是否合规
yarn clean 清理所有子项目的构建产物(dist 目录)

批量命令技巧

  • -A 参数:操作所有工作空间;
  • -p 参数:并行执行命令,提升效率;
  • --include 参数:过滤目标工作空间,如 --include 'apps/*' 仅操作应用项目。

六、版本一致性保障

Monorepo 中依赖版本不一致是常见问题,可能导致运行报错或功能异常,需从以下三方面保障版本统一:

  1. 根级依赖锁定:将 TypeScript、构建工具、代码规范工具等公共依赖在根项目的 package.json 中明确定义版本,子项目无需重复声明,直接继承根依赖版本;
  2. 使用 workspace 协议:子项目间的依赖必须使用 workspace:* 协议,确保始终引用本地最新版本,避免子项目间依赖版本错位;
  3. 统一 TypeScript 配置:根项目创建 tsconfig.base.json 作为基础配置,子项目通过 "extends": "../../tsconfig.base.json" 继承,保证类型检查规则一致。

七、实际配置总结

本次 Monorepo 实现的核心配置要点可归纳为以下三点,便于后续维护和扩展:

  • 依赖模式:采用 node-modules 模式,兼容性强,避免 PnP 模式可能出现的工具适配问题;
  • 命名规范:子项目统一使用 @wect/ 作用域前缀,清晰区分项目归属,避免命名冲突;
  • 脚本统一:所有子项目定义一致的脚本命令(如 build、dev、test),确保批量操作顺畅;

快 2026 年了,谁还在为 this 挠头?看完这篇让你彻底从懵圈到精通

前言

各位 前端er 们,谁还没被 JavaScript 里的 this 虐过?这玩意简直就是编程界的 “变脸大师”,翻脸比孙猴子还快。一会儿是全局对象,一会儿是某个实例,一会儿又跟着调用场景改头换面,这不就是活生生的 “百变马丁” 吗?写代码时总被它搞得晕头转向,调试半天就因为 this 指向不对,真有种 “一杯茶一包烟,一个this改一天” 的崩溃感。如果你还搞不懂 JavaScript 里面的 this,那这篇将让你搞定原理并且拿捏用法,把那些绕人的绑定规则掰扯得明明白白!

一、为什么要有 this?—— 让代码 “优雅到飞起”

想象一下,你写了个函数,想在不同对象上复用它。要是没有this,就得每次手动传对象参数,想想都麻烦!this就像个 “智能代词”,悄咪咪地帮我们传递对象引用。通俗来说就是 this 提供了一种更优雅的方式来隐式的传递一个对象引用,可以让代码更简洁易于复用。

比如下面这两种写法:

function identify(context) {
    return context.name.toUpperCase();
}
function speak(context) {
    var greet = 'hello, I am ' + identify(context);
    console.log(greet);
}
var myname = {
    name: 'henry'
}
speak(myname);

我们定义 identify 函数接收对象参数,返回其 name 大写值;speak 函数接收对象,调用 identify 拼接问候语并打印;最后创建含 name 的 myname 对象,传给 speak 执行,输出 hello, I am HENRY。核心是手动传递对象参数实现复用。

输出结果:

image.png

结果没错,henry 确确实实大写了,但每次都要手动传参你自己不嫌麻烦吗?这时候我们请出大名鼎鼎的--this

function identify() {
    return this.name.toUpperCase();
}
function speak() {
    var greet = 'hello, I am ' + identify.call(this);
    console.log(greet);
}
var myname = {
    name: 'henry'
}
speak.call(myname);

image.png

诶,你会发现,我2个函数都没有传参,但是都输出了结果。这里有2个关键点:

  • 用 call 强制让 speak 的 this 指向 myname
  • speak 内部调用 identify.call(this) 时,this 已绑定 myname,因此 identify 也能访问 myname.name

至于call是个啥,往下看吧,嘿嘿!

二、this 用在哪?—— “代词” 的舞台

this就像 “变色龙”,在不同场景下指代不同的角色:

  • 全局作用域:在浏览器里,this就等于window(就像全局舞台的 “C 位”)。比如你直接写console.log(this),打印的就是window对象。

比如我在 Google Chrome 上面写console.log(this)

image.png

但在node.js里,打印出来的是global

image.png

  • 函数作用域:这就是this的 “主战场”了,在这它的身份变化可多了,咱们接着往下看。

三、this 的绑定规则 —— 给 this “定规矩”

1. 默认绑定 —— “自由散漫” 的 this

当函数被独立调用时,this就指向window(严格模式下是undefined)。就像你一个人逛街,没对象陪~

var a = 1;
function foo() {
    console.log(this.a);
}
function bar() {
    var a = 2;
    foo();
}
bar();

灵魂拷问:输出结果是多少?肯定有人说2,而且还不少!你不服了,这调用bar(),然后里面在调用foo(), 不应该根据变量提升先找bar()里面的a = 2吗?说明前面还没看懂嘻嘻,当函数被独立调用--就一个foo(),它就是指向全局,不管那些杂七杂八的,你就是一个人逛街,没有对象陪,所以答案就是全局的a = 1

image.png

最后就是打印出了1,这就是函数的独立调用。

2. 隐式绑定 —— “依附对象” 的 this

当函数被上下文对象调用时,this就绑定到这个对象上。比如:

function foo() {
    console.log(this);
}
var a = 1;
var obj = {
    foo: foo
}
obj.foo();

好,最后的调用obj.foo()。首先它不是单独调用,那么就要用到上下文对象调用,咱们一句话说清核心:obj.foo() 是通过对象 obj 调用函数 foo,所以 foo 里的 this 直接绑定到 obj,最终打印 obj 整个对象。

image.png

3. 隐式丢失 —— “层层剥离” 的 this

当函数被多层对象调用时,this会指向最近的那个对象。是不是有点像小时候玩的游戏🎮 “击鼓传花”,传到最后花落谁家?

好,那看一个例子:

function foo() {
    console.log(this.a);
}
var obj = {
    a: 1,
    foo: foo
}
var obj2 = {
    a: 2,
    foo: obj
}
obj2.foo.foo();

OK,懵了⊙▃⊙吧。怎么回事连续调用2次,最后输出的到底是obj里的还是obj2里的?答案是obj

关键误区提醒

不要以为 obj2 在前面,this 就指向 obj2

this 只看 「函数执行时的直接调用者」,和外层嵌套的对象(obj2)无关。如果想让 this 指向 obj2,需要让 obj2 直接调用 foo(比如 obj2.foo = foo; obj2.foo())。

就好比我们英语的 就近原则,离谁近就指向谁!

输出结果:

image.png

4. 显式绑定 —— “强行指定” 的 this(call、apply、bind 三位好室友登场!)

咱们可以把这三个方法想象成你的三个 “热心室友”:

  • call 室友:急性子,直接帮函数把this绑定到目标对象,参数一个个传。
  • apply 室友:爱偷懒,参数打包成数组传给函数。
  • bind 室友:慢性子,先绑定this和部分参数,返回一个 “半成品” 函数,想啥时候调用都行。
var obj = {
    a: 1
}
function foo(x, y) { 
    console.log(this.a, x + y);
}
foo.call(obj, 1, 2);    // call室友出马
foo.apply(obj, [1, 2]); // apply室友偷懒
const bar = foo.bind(obj, 1, 2);  
bar();  // bind室友慢性子
  1. foo.call(obj, 1, 2):call 室友 “急性子”,直接把 foo 的 this 绑定到 obj,再逐个传入参数 1 和 2 → 打印 obj 的 a(1)和 1+2(3),输出:1 3;
  2. foo.apply(obj, [1, 2]):apply 室友 “爱偷懒”,同样绑定 this 到 obj,但参数要打包成数组 [1,2] 传入 → 结果和 call 一致,输出:1 3;
  3. const bar = foo.bind(obj, 2, 3); bar():bind 室友 “慢性子”,先绑定 this 到 obj、预传参数 1 和 2,返回 “半成品” 函数 bar,调用 bar 时才执行 → 打印 obj 的 a(1)和 1+2(3),输出:1 3。

结果和我们分析的一样:

image.png

5. new 绑定 —— “实例专属” 的 this

new关键字就像个 “专属定制工厂”,专门用构造函数为新对象 “量身打造” 身份。当我们用new调用构造函数时,this会直接绑定到这个刚创建的实例对象上,相当于工厂把定制好的 “专属身份” 直接赋给了新实例。

但这个 “定制工厂” 有个特殊规则,分三种情况:

情况 1:正常定制 —— 返回绑定 this 的实例

当构造函数里没有手动return,或者只returnundefined(默认隐含)时,工厂会按流程完成 “定制”,返回绑定了this的实例对象。

function Person(name) {
    this.name = name;
}
let p1 = new Person('henry');
console.log(p1.name); // 输出"henry"
console.log(p1);      // 输出Person { name: "henry" },this绑定生效

image.png

情况 2:放弃定制 —— return 引用类型,返回手动指定的对象

如果构造函数里主动return了一个引用类型(比如对象、数组、函数等复杂数据),相当于你给工厂递了一个 “现成产品”,工厂会直接放弃原本的定制流程,返回这个手动指定的引用类型,原本绑定this的实例会被直接忽略。

function Person(name) {
    this.name = name;
    return { age: 20 }; 
}
let p2 = new Person('harvest');
console.log(p2);      // 输出{ age: 20 },实例被忽略
console.log(p2.name); // 输出undefined,拿不到原本的name属性

image.png

情况 3:无视 return —— return 原始类型,依然返回实例

如果构造函数里return的是原始数据类型(比如数字、字符串、布尔值、null、undefined),这个return会被工厂 “无视”,依然按原规则返回绑定了this的实例对象,相当于你递了个 “无效产品”,工厂还是按定制流程来。

function Person(name) {
    this.name = name;
    return 100; // return 无效
}
let p3 = new Person('henry');
console.log(p3.name); // 输出"henry",实例正常生效

image.png

6. 箭头函数 —— “没有 this” 的 this

箭头函数里没有自己的this,它的this外层非箭头函数的 this。就像 “小跟班”,永远跟着外层函数的this走,不会被任何绑定规则改变。

function foo() {
    var bar = () => {
        this.a = 2;
    }
    bar();
}
var obj = {
    a: 1,
    baz: foo
}
obj.baz();
console.log(obj);

再次灵魂拷问:a 是 1 还是 2 ? 答案:2

关键逻辑:

  1. obj.baz() 是隐式绑定:foo 函数被 obj 调用,所以 foo 里的this指向 obj;
  2. 箭头函数bar没有自己的this,直接 “偷” 了外层 foo 的this(也就是 obj);
  3. 执行bar()时,this.a = 2 其实是给obj.a赋值,把原来的 1 改成了 2;
  4. 最后打印 obj,a 属性已经变成 2,结果就是 { a: 2, baz: 函数foo }

image.png

OK,是不是觉得自己已经拿捏this了?

总结

从 “自由散漫” 的默认绑定、“依附对象” 的隐式绑定,到 “强行指定” 的三个“热心”的室友,再到 “定制化” 的 new 绑定(三种情况),最后是 “粘人跟班” 的箭头函数 —— 只要找准场景对号入座,this就再也不会让你头疼啦!

掌握this,让你的代码更上一层楼吧!

前端调用大语言模型:基于 Vite 的工程化实践与 HTTP 请求详解

前端调用大语言模型:基于 Vite 的工程化实践与 HTTP 请求详解

随着人工智能技术的迅猛发展,大语言模型已逐渐从科研实验室走向工业应用。本文将围绕“前端如何以 HTTP 请求方式调用大语言模型”这一核心主题,结合现代前端工程化工具 Vite,详细讲解项目初始化、环境变量配置、fetch 请求封装、安全注意事项等关键环节,帮助读者掌握从前端发起 LLM 调用的全流程。


一、为什么前端可以直接调用 LLM?

传统观点认为,AI 模型应由后端服务代理调用,前端仅负责展示结果。然而,在某些场景下,前端直连 LLM API 是可行且高效的,前提是:

  1. API 支持 CORS(跨域资源共享) :如 DeepSeek、OpenRouter 等部分服务商允许浏览器直接请求。
  2. 安全性可控:通过短期有效的 API Key、IP 白名单、请求频率限制等方式降低风险。
  3. 无需敏感数据处理:用户输入不涉及隐私或机密信息。

以 DeepSeek 为例,其官方 API 支持 CORS,允许前端通过 fetch 直接发起 POST 请求,这为快速原型开发和轻量级应用提供了极大便利。


二、项目初始化:使用 Vite 搭建全栈友好型前端项目

Vite 是新一代前端构建工具,以其极速的冷启动和热更新能力著称。虽然 Vite 本身是前端构建器,但其对环境变量、TypeScript、ESM 模块的原生支持,使其成为调用 LLM 的理想脚手架。

1. 创建项目

npm create vite@latest llm-frontend-demo -- --template vanilla
cd llm-frontend-demo
npm install

选择 vanilla 模板即可获得一个纯净的 HTML/CSS/JS 项目结构,适合教学和快速验证。

2. 配置环境变量

出于安全考虑,API Key 绝不能硬编码在源码中。Vite 提供了 .env 文件机制,所有以 VITE_ 开头的变量会被注入到客户端代码中。

创建 .env.local 文件(该文件通常加入 .gitignore,避免提交到版本控制):

VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

⚠️ 注意:此方法仅适用于 API 服务商允许前端直连的场景。若服务商禁止 CORS 或要求更高安全级别,则必须通过后端代理。


三、HTTP 请求详解:如何正确调用 LLM API

LLM API 通常遵循 RESTful 设计,使用 JSON 格式通信。以 DeepSeek 的 /chat/completions 接口为例,一次完整的请求包含三个部分:请求行、请求头、请求体

1. 请求行(Request Line)

  • Method: POST(因为需要发送消息内容)
  • URL: https://api.deepseek.com/chat/completions
  • HTTP 版本: 通常由浏览器自动处理,无需显式指定

2. 请求头(Headers)

必须包含两个关键字段:

const headers = {
  'Content-Type': 'application/json',
  'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
};
  • Content-Type: application/json 告知服务器请求体为 JSON 格式。
  • Authorization: Bearer <token> 是 OAuth 2.0 标准的认证方式,Bearer 为固定前缀。

3. 请求体(Body)

LLM 接口通常要求结构化的消息数组:

const payload = {
  model: 'deepseek-chat', // 指定模型名称
  messages: [
    { role: "system", content: "You are a helpful assistant." },
    { role: "user", content: "你好 DeepSeek" }
  ]
};

注意:body 必须是字符串,不能直接传入 JavaScript 对象。需使用 JSON.stringify() 序列化:

body: JSON.stringify(payload)
为什么 body 必须是字符串?

这是因为 fetch API 的底层实现遵循 HTTP 协议规范,而 HTTP 协议规定:请求体(request body)只能是字节流(即二进制数据) 。在浏览器环境中,JavaScript 无法直接发送对象、数组等高级数据结构——这些结构只存在于运行时内存中,网络传输必须将其转换为可序列化的格式。

当你调用 fetch 并设置 body 字段时,浏览器期望你提供以下几种类型之一:

  • string(如 JSON 字符串)
  • FormData
  • URLSearchParams
  • Blob / ArrayBuffer / ReadableStream 等二进制类型

如果你直接传入一个 JavaScript 对象(例如 { key: "value" }),浏览器会尝试将其隐式转换为字符串,结果通常是 [object Object] —— 这显然不是服务器期望的 JSON 格式,会导致 API 返回解析错误(如 400 Bad Request)。

因此,必须显式使用 JSON.stringify() 将对象转换为标准的 JSON 字符串,确保服务端能正确反序列化并理解你的请求内容。同时,配合设置请求头 'Content-Type': 'application/json',告知服务器:“我发送的是 JSON 格式的文本”。

✅ 正确做法:

body: JSON.stringify({ message: "hello" })

❌ 错误做法:

body: { message: "hello" }  // 实际发送的是 "[object Object]"

这一细节看似微小,却是前后端数据通信可靠性的关键保障。

四、使用 fetch 发起异步请求

现代浏览器原生支持 fetch API,它是发起 HTTP 请求的标准方式。

完整调用示例

// main.js
const endpoint = 'https://api.deepseek.com/chat/completions';

async function callDeepSeek(userMessage) {
  const headers = {
    'Content-Type': 'application/json',
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
  };

  const payload = {
    model: 'deepseek-chat',
    messages: [
      { role: "system", content: "You are a helpful assistant." },
      { role: "user", content: userMessage }
    ]
  };

  try {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers,
      body: JSON.stringify(payload)
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    const data = await response.json();
    return data.choices[0].message.content;
  } catch (error) {
    console.error('调用 DeepSeek 失败:', error);
    return '抱歉,暂时无法获取回复。';
  }
}

// 绑定按钮点击事件
document.getElementById('send-btn').addEventListener('click', async () => {
  const input = document.getElementById('user-input');
  const replyEl = document.getElementById('reply');

  const userMsg = input.value.trim();
  if (!userMsg) return;

  replyEl.textContent = '思考中...';
  const reply = await callDeepSeek(userMsg);
  replyEl.textContent = reply;
  input.value = '';
});

关键点说明

  • 使用 async/await 使异步代码更易读,避免回调地狱。
  • response.ok 进行判断,防止非 2xx 响应被误认为成功。
  • 错误处理必不可少,网络波动或配额耗尽可能导致请求失败。

五、工程化思维:代码如钢筋水泥

在 Trae 所倡导的“工程化”理念中,代码不仅是功能的载体,更是可维护、可扩展、可协作的“建筑材料”。调用 LLM 不应只是复制粘贴一段 fetch 代码,而应思考:

  • 可复用性:将 LLM 调用封装为独立函数或模块。
  • 可配置性:模型名称、系统提示词可通过参数传入。
  • 可测试性:模拟 API 响应进行单元测试。
  • 用户体验:加载状态、错误提示、输入限制等细节。

例如,可进一步抽象为:

class LLMClient {
  constructor(apiKey, model = 'deepseek-chat') {
    this.apiKey = apiKey;
    this.model = model;
    this.endpoint = 'https://api.deepseek.com/chat/completions';
  }

  async chat(messages, systemPrompt = "You are a helpful assistant.") {
    const fullMessages = [
      { role: "system", content: systemPrompt },
      ...messages
    ];

    const response = await fetch(this.endpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${this.apiKey}`
      },
      body: JSON.stringify({ model: this.model, messages: fullMessages })
    });

    const data = await response.json();
    return data.choices[0].message.content;
  }
}

这种面向对象的设计更利于大型项目集成。


六、安全与最佳实践

尽管前端直连 LLM 便捷高效,但也存在风险:

  1. API Key 泄露:一旦 .env 中的 Key 被提取,可能被滥用产生高额费用。

    • 解决方案:使用短期 Token、设置 IP 白名单、监控调用量。
  2. CORS 限制:并非所有 LLM 服务商都开放 CORS。

    • 替代方案:通过 Vite 的代理功能(仅开发环境)或部署轻量后端(如 Cloudflare Workers)中转。
  3. 速率限制:频繁请求可能触发限流。

    • 建议:添加防抖、队列机制或用户提示。

结语

前端调用大语言模型不再是遥不可及的概念,而是触手可及的工程实践。借助 Vite 的现代化开发体验和浏览器原生的 fetch 能力,我们可以快速构建具备 AI 能力的 Web 应用。然而,技术便利的背后是对工程规范、安全意识和用户体验的更高要求。

未来,随着 WebAssembly、WebGPU 等技术的发展,甚至可能在浏览器本地运行小型 LLM,实现完全离线的智能交互。但无论技术如何演进,“工程化”始终是高质量软件开发的基石——正如钢筋水泥之于摩天大楼,代码结构之于数字世界。

代码不是魔法,而是精心设计的工程。

从零开始:前端如何通过 `fetch` 调用 大模型(详解)

在现代 AI 应用开发中,我们经常需要在浏览器端直接调用大语言模型(LLM)的 API。虽然有 OpenAI SDK 等封装工具,但使用原生 fetch 发送 HTTP 请求是一种更灵活、可控且易于理解的方式。

本文将结合你提供的代码和笔记,深入解析 如何在前端通过 fetch 调用 DeepSeek 的聊天接口,并逐行解释每一个关键步骤。


🧩 一、完整的调用代码

// llm api 地址
const endpoint = 'https://api.deepseek.com/chat/completions';

// 请求头
const headers = {
  'Content-Type': 'application/json',
  'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
};

// 请求体
const payload = {
  model: 'deepseek-chat',
  messages: [
    { role: 'system', content: 'You are a helpful assistant.' },
    { role: 'user', content: '你好 Deepseek' }
  ]
};

// 发起请求
const response = await fetch(endpoint, {
  method: 'POST',
  headers,
  body: JSON.stringify(payload)
});

// 解析响应
const data = await response.json();
console.log(data);

// 显示结果
document.getElementById('reply').textContent = data.choices[0].message.content;

这段代码实现了从用户输入到模型回复的完整流程。下面我们逐部分拆解。


🔍 二、HTTP 请求结构解析(结合你的笔记)

1. 请求行(Request Line)

POST /chat/completions HTTP/1.1
Host: api.deepseek.com
  • POST:必须使用 POST 方法,因为 API 接口要求;
  • /chat/completions:DeepSeek 的聊天接口路径;
  • HTTP/1.1:标准版本,浏览器自动处理。

2. 请求头(Headers)

{
  'Content-Type': 'application/json',
  'Authorization': 'Bearer sk-xxxxxx'
}
  • 'Content-Type': 'application/json'
    告诉服务器请求体是 JSON 格式,这是必需的。
  • 'Authorization': 'Bearer <key>'
    认证方式,Bearer 是固定前缀,后面跟你的 API Key。

⚠️ 注意:API Key 不能硬编码在前端代码中,应通过 Vite 的环境变量管理:

// .env 文件
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxx

在代码中使用 import.meta.env.VITE_DEEPSEEK_API_KEY 获取。


3. 请求体(Body)

{
  model: 'deepseek-chat',
  messages: [
    { role: 'system', content: 'You are a helpful assistant.' },
    { role: 'user', content: '你好 Deepseek' }
  ]
}
  • model:指定使用的模型名称;

  • messages:对话历史,支持多轮交互;

    • role: "system":设定助手行为;
    • role: "user":用户的提问内容。

✅ 注意:请求体必须是字符串化后的 JSON,所以要用 JSON.stringify()


4. 使用 fetch 发送请求

const response = await fetch(endpoint, {
  method: 'POST',
  headers,
  body: JSON.stringify(payload)
});
  • method: 'POST':发送 POST 请求;
  • headers:设置请求头;
  • body: JSON.stringify(payload):将 JS 对象转为字符串,不能直接传对象

💡 await.then() 更直观,适合异步操作。


5. 处理响应

const data = await response.json();
console.log(data);
  • response.json() 将响应体解析为 JavaScript 对象;
  • data.choices[0].message.content 是模型返回的文本。

6. 显示结果

document.getElementById('reply').textContent = data.choices[0].message.content;
  • 将模型回复显示在页面上(假设有一个 id 为 reply 的元素)。

🔄 三、异步处理:.then vs await

你提到:

- await 异步变同步比 then 更方便

✅ 完全正确!

方式 特点
.then() 链式调用,适合复杂逻辑
await 代码像同步一样,更易读

推荐使用 await,尤其是在处理多个异步操作时。


🔐 四、安全建议:API Key 的存放位置

❌ 不推荐(前端暴露)

const apiKey = 'sk-xxxxxxxx'; // 直接写在代码里 → 容易泄露

✅ 推荐(后端代理)

// 后端路由
app.post('/api/chat', async (req, res) => {
  const response = await fetch('https://api.deepseek.com/chat/completions', {
    headers: {
      'Authorization': `Bearer ${process.env.DEEPSEEK_API_KEY}` // 后端环境变量,安全!
    },
    body: JSON.stringify(req.body)
  });
  res.json(result);
});

然后前端只调用自己的 /api/chat永远看不到真实 API Key


✅ 五、总结:前端调用 LLM 的核心要点

要点 说明
✅ 使用 fetch 发送 POST 请求 标准方式,兼容性强
✅ 设置 Content-Type: application/json 必须
✅ 添加 Authorization: Bearer <key> 认证必要
✅ 使用 JSON.stringify() 处理请求体 不能直接发送 JS 对象
✅ 使用 await 处理异步 更简洁
✅ 环境变量管理 API Key Vite 支持 VITE_ 开头变量
⚠️ 不要在生产环境中暴露 API Key 必须走后端代理

🎯 结语

技术的本质是解决问题,而不是追求“高大上”的工具。

当你理解了 fetch 的工作原理,就能轻松对接任何符合 OpenAI 协议的 LLM 服务。无论是 DeepSeek、Qwen 还是其他模型,只要你知道它的 API 地址和参数格式,就可以用这段代码快速接入。

希望这篇文章帮你彻底搞懂了前端调用 LLM 的全过程!
如果你正在做项目,我可以帮你封装一个通用的 AI 调用模块 😊

Flutter 实战:为开源记账 App 实现优雅的暗黑模式(Design Token + 动态主题)

最近为我的开源记账 App BeeCount 蜜蜂记账 实现了暗黑模式,踩了不少坑,也总结了一些经验。这篇文章会详细介绍整个技术方案和实现过程,希望对大家有帮助。

效果预览

1.jpg

2.jpg

3.jpg

4.jpg

5.jpg

6.jpg

暗黑模式采用「纯黑背景 + 主题色边框」的设计方案:

  • 纯黑背景:OLED 友好,夜间护眼
  • 主题色边框:保留个性化,层次分明
  • 全局适配:所有页面、组件统一风格

技术方案概述

在开始写代码之前,我先梳理了整体的技术方案:

  1. Design Token 系统:统一管理所有颜色,一处修改全局生效
  2. 主题状态管理:使用 Riverpod 管理主题模式,支持持久化
  3. MaterialApp 配置:配置 theme 和 darkTheme,支持跟随系统切换
  4. 组件级适配:针对特殊组件(图表、弹窗等)单独处理

下面逐一介绍。


1. Design Token 系统

为什么需要 Design Token?

项目初期,颜色都是硬编码的:

// 到处都是这种代码
Text('Hello', style: TextStyle(color: Colors.black87))
Container(color: Colors.white)

这样做的问题:

  • 要支持暗黑模式,得全局搜索替换
  • 颜色值不统一,同样的「次要文字」可能写成 black54greyColors.grey.shade600
  • 维护困难,改一个颜色要改 N 个地方

所以我借鉴了 Web 前端的 CSS Variables 思路,建立了一套 Design Token 系统。

核心实现

文件:lib/styles/tokens.dart

import 'package:flutter/material.dart';

/// Design Token 系统
/// 统一管理所有颜色,自动适配亮色/暗黑模式
class BeeTokens {

  // ========== 工具方法 ==========

  /// 判断当前是否暗黑模式
  static bool isDark(BuildContext context) {
    return Theme.of(context).brightness == Brightness.dark;
  }

  /// 获取主题色
  static Color primary(BuildContext context) {
    return Theme.of(context).primaryColor;
  }

  // ========== 背景色 ==========

  /// 页面背景色
  static Color scaffoldBackground(BuildContext context) {
    return isDark(context) ? Colors.black : const Color(0xFFF5F5F5);
  }

  /// 卡片/表面背景色
  static Color surface(BuildContext context) {
    return isDark(context) ? Colors.black : Colors.white;
  }

  /// 提升的表面(如弹窗、下拉菜单)
  static Color surfaceElevated(BuildContext context) {
    return isDark(context) ? const Color(0xFF1C1C1E) : Colors.white;
  }

  /// Header 背景色
  static Color surfaceHeader(BuildContext context) {
    return isDark(context) ? Colors.black : Colors.white;
  }

  // ========== 文字颜色 ==========

  /// 主要文字(标题、正文)
  static Color textPrimary(BuildContext context) {
    return isDark(context) ? Colors.white : Colors.black87;
  }

  /// 次要文字(说明文字、副标题)
  static Color textSecondary(BuildContext context) {
    return isDark(context) ? Colors.white70 : Colors.black54;
  }

  /// 提示文字(placeholder、禁用状态)
  static Color textTertiary(BuildContext context) {
    return isDark(context) ? Colors.white38 : Colors.black38;
  }

  // ========== 图标颜色 ==========

  static Color iconPrimary(BuildContext context) {
    return isDark(context) ? Colors.white : Colors.black87;
  }

  static Color iconSecondary(BuildContext context) {
    return isDark(context) ? Colors.white70 : Colors.black54;
  }

  static Color iconTertiary(BuildContext context) {
    return isDark(context) ? Colors.white38 : Colors.black38;
  }

  // ========== 边框和分割线 ==========

  /// 普通边框
  static Color border(BuildContext context) {
    if (isDark(context)) {
      return Theme.of(context).primaryColor.withOpacity(0.3);
    }
    return Colors.grey.shade200;
  }

  /// 分割线
  static Color divider(BuildContext context) {
    return isDark(context) ? Colors.white12 : Colors.grey.shade200;
  }

  /// 主题色边框(用于强调)
  static Color borderThemed(BuildContext context) {
    return Theme.of(context).primaryColor.withOpacity(0.3);
  }

  // ========== 语义颜色 ==========

  /// 成功色(收入、正数)
  static Color success(BuildContext context) {
    return isDark(context) ? const Color(0xFF4CD964) : const Color(0xFF34C759);
  }

  /// 错误色(支出、负数、错误提示)
  static Color error(BuildContext context) {
    return isDark(context) ? const Color(0xFFFF6B6B) : const Color(0xFFFF3B30);
  }

  /// 警告色
  static Color warning(BuildContext context) {
    return isDark(context) ? const Color(0xFFFFD60A) : const Color(0xFFFF9500);
  }
}

使用方式

替换原来的硬编码颜色:

// ❌ 之前
Container(
  color: Colors.white,
  child: Text(
    'Hello',
    style: TextStyle(color: Colors.black87),
  ),
)

// ✅ 之后
Container(
  color: BeeTokens.surface(context),
  child: Text(
    'Hello',
    style: TextStyle(color: BeeTokens.textPrimary(context)),
  ),
)

Token 命名规范

我采用了语义化命名,让代码更易读:

Token 用途 亮色 暗色
scaffoldBackground 页面背景 #F5F5F5 #000000
surface 卡片背景 #FFFFFF #000000
surfaceElevated 弹窗背景 #FFFFFF #1C1C1E
textPrimary 主要文字 black87 white
textSecondary 次要文字 black54 white70
textTertiary 提示文字 black38 white38
border 卡片边框 grey200 主题色30%
divider 分割线 grey200 white12

2. 主题状态管理

Provider 定义

使用 Riverpod 管理主题状态:

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

/// 主题模式枚举
enum ThemeModeOption {
  system,  // 跟随系统
  light,   // 始终亮色
  dark,    // 始终暗黑
}

/// 扩展方法:转换为 Flutter 的 ThemeMode
extension ThemeModeOptionExtension on ThemeModeOption {
  ThemeMode toThemeMode() {
    switch (this) {
      case ThemeModeOption.system:
        return ThemeMode.system;
      case ThemeModeOption.light:
        return ThemeMode.light;
      case ThemeModeOption.dark:
        return ThemeMode.dark;
    }
  }

  String toDisplayName(BuildContext context) {
    switch (this) {
      case ThemeModeOption.system:
        return '跟随系统';
      case ThemeModeOption.light:
        return '始终亮色';
      case ThemeModeOption.dark:
        return '始终暗黑';
    }
  }
}

/// 主题模式 Provider
final themeModeProvider = StateNotifierProvider<ThemeModeNotifier, ThemeModeOption>((ref) {
  return ThemeModeNotifier();
});

class ThemeModeNotifier extends StateNotifier<ThemeModeOption> {
  ThemeModeNotifier() : super(ThemeModeOption.system) {
    _loadFromPrefs();
  }

  static const _key = 'theme_mode';

  Future<void> _loadFromPrefs() async {
    final prefs = await SharedPreferences.getInstance();
    final value = prefs.getString(_key);
    if (value != null) {
      state = ThemeModeOption.values.firstWhere(
        (e) => e.name == value,
        orElse: () => ThemeModeOption.system,
      );
    }
  }

  Future<void> setThemeMode(ThemeModeOption mode) async {
    state = mode;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(_key, mode.name);
  }
}

主题色 Provider

用户可以自定义主题色,这个也要持久化:

/// 预设主题色列表
const List<Color> presetColors = [
  Color(0xFFFFB020), // 蜜蜂黄(默认)
  Color(0xFFFF6B6B), // 珊瑚红
  Color(0xFF4ECDC4), // 薄荷绿
  Color(0xFF5C7AEA), // 靛蓝
  Color(0xFFAB47BC), // 紫罗兰
  Color(0xFF26A69A), // 青绿
  Color(0xFFEC407A), // 粉红
  Color(0xFF42A5F5), // 天蓝
];

/// 主题色 Provider
final primaryColorProvider = StateNotifierProvider<PrimaryColorNotifier, Color>((ref) {
  return PrimaryColorNotifier();
});

class PrimaryColorNotifier extends StateNotifier<Color> {
  PrimaryColorNotifier() : super(presetColors[0]) {
    _loadFromPrefs();
  }

  static const _key = 'primary_color';

  Future<void> _loadFromPrefs() async {
    final prefs = await SharedPreferences.getInstance();
    final value = prefs.getInt(_key);
    if (value != null) {
      state = Color(value);
    }
  }

  Future<void> setPrimaryColor(Color color) async {
    state = color;
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt(_key, color.value);
  }
}

3. MaterialApp 配置

在 App 入口处配置主题:

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final themeMode = ref.watch(themeModeProvider);
    final primaryColor = ref.watch(primaryColorProvider);

    return MaterialApp(
      title: 'BeeCount',

      // 主题模式
      themeMode: themeMode.toThemeMode(),

      // 亮色主题
      theme: ThemeData(
        brightness: Brightness.light,
        primaryColor: primaryColor,
        scaffoldBackgroundColor: const Color(0xFFF5F5F5),
        colorScheme: ColorScheme.light(
          primary: primaryColor,
          secondary: primaryColor,
          surface: Colors.white,
        ),
        appBarTheme: AppBarTheme(
          backgroundColor: Colors.white,
          foregroundColor: Colors.black87,
          elevation: 0,
          systemOverlayStyle: SystemUiOverlayStyle.dark,
        ),
        dividerTheme: DividerThemeData(
          color: Colors.grey.shade200,
          thickness: 0.5,
        ),
        // ... 其他配置
      ),

      // 暗黑主题
      darkTheme: ThemeData(
        brightness: Brightness.dark,
        primaryColor: primaryColor,
        scaffoldBackgroundColor: Colors.black,
        colorScheme: ColorScheme.dark(
          primary: primaryColor,
          secondary: primaryColor,
          surface: Colors.black,
        ),
        appBarTheme: AppBarTheme(
          backgroundColor: Colors.black,
          foregroundColor: Colors.white,
          elevation: 0,
          systemOverlayStyle: SystemUiOverlayStyle.light,
        ),
        dividerTheme: const DividerThemeData(
          color: Colors.white12,
          thickness: 0.5,
        ),
        // ... 其他配置
      ),

      home: const HomePage(),
    );
  }
}

4. 组件级适配

卡片组件

封装一个统一的卡片组件:

class BeeCard extends StatelessWidget {
  final Widget child;
  final EdgeInsetsGeometry? padding;
  final EdgeInsetsGeometry? margin;
  final double borderRadius;

  const BeeCard({
    super.key,
    required this.child,
    this.padding,
    this.margin,
    this.borderRadius = 12,
  });

  @override
  Widget build(BuildContext context) {
    final isDark = BeeTokens.isDark(context);

    return Container(
      margin: margin,
      padding: padding ?? const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: BeeTokens.surface(context),
        borderRadius: BorderRadius.circular(borderRadius),
        // 暗黑模式:主题色边框
        border: isDark
            ? Border.all(color: BeeTokens.border(context), width: 1)
            : null,
        // 亮色模式:阴影
        boxShadow: isDark
            ? null
            : [
                BoxShadow(
                  color: Colors.black.withOpacity(0.05),
                  blurRadius: 8,
                  offset: const Offset(0, 2),
                ),
              ],
      ),
      child: child,
    );
  }
}

图表适配

使用 fl_chart 库时,需要单独处理颜色:

import 'package:fl_chart/fl_chart.dart';

Widget buildLineChart(BuildContext context, List<FlSpot> spots) {
  final isDark = BeeTokens.isDark(context);
  final primaryColor = Theme.of(context).primaryColor;

  return LineChart(
    LineChartData(
      // 网格线
      gridData: FlGridData(
        show: true,
        drawVerticalLine: false,
        horizontalInterval: 1,
        getDrawingHorizontalLine: (value) => FlLine(
          color: isDark ? Colors.white10 : Colors.grey.shade200,
          strokeWidth: 1,
        ),
      ),

      // 边框
      borderData: FlBorderData(
        show: true,
        border: Border(
          bottom: BorderSide(
            color: isDark ? Colors.white24 : Colors.grey.shade300,
          ),
          left: BorderSide(
            color: isDark ? Colors.white24 : Colors.grey.shade300,
          ),
        ),
      ),

      // 坐标轴标题
      titlesData: FlTitlesData(
        bottomTitles: AxisTitles(
          sideTitles: SideTitles(
            showTitles: true,
            getTitlesWidget: (value, meta) => Text(
              '${value.toInt()}',
              style: TextStyle(
                color: BeeTokens.textSecondary(context),
                fontSize: 12,
              ),
            ),
          ),
        ),
        leftTitles: AxisTitles(
          sideTitles: SideTitles(
            showTitles: true,
            getTitlesWidget: (value, meta) => Text(
              '${value.toInt()}',
              style: TextStyle(
                color: BeeTokens.textSecondary(context),
                fontSize: 12,
              ),
            ),
          ),
        ),
        topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
        rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
      ),

      // 数据线
      lineBarsData: [
        LineChartBarData(
          spots: spots,
          isCurved: true,
          color: primaryColor,
          barWidth: 2,
          dotData: FlDotData(
            show: true,
            getDotPainter: (spot, percent, barData, index) {
              return FlDotCirclePainter(
                radius: 4,
                color: primaryColor,
                strokeWidth: 2,
                strokeColor: isDark ? Colors.black : Colors.white,
              );
            },
          ),
          belowBarData: BarAreaData(
            show: true,
            color: primaryColor.withOpacity(0.1),
          ),
        ),
      ],
    ),
  );
}

弹窗适配

showModalBottomSheetshowDialog 等弹窗需要单独处理:

Future<T?> showBeeBottomSheet<T>({
  required BuildContext context,
  required WidgetBuilder builder,
  bool isScrollControlled = false,
}) {
  return showModalBottomSheet<T>(
    context: context,
    isScrollControlled: isScrollControlled,
    // 关键:指定背景色
    backgroundColor: BeeTokens.surfaceElevated(context),
    // 圆角
    shape: const RoundedRectangleBorder(
      borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
    ),
    builder: builder,
  );
}

Future<T?> showBeeDialog<T>({
  required BuildContext context,
  required WidgetBuilder builder,
}) {
  return showDialog<T>(
    context: context,
    builder: (context) => Dialog(
      backgroundColor: BeeTokens.surfaceElevated(context),
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(16),
        // 暗黑模式下加边框
        side: BeeTokens.isDark(context)
            ? BorderSide(color: BeeTokens.border(context))
            : BorderSide.none,
      ),
      child: builder(context),
    ),
  );
}

输入框适配

TextField(
  decoration: InputDecoration(
    hintText: '请输入',
    hintStyle: TextStyle(color: BeeTokens.textTertiary(context)),
    filled: true,
    fillColor: BeeTokens.isDark(context)
        ? Colors.white.withOpacity(0.05)
        : Colors.grey.shade100,
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(8),
      borderSide: BorderSide.none,
    ),
    focusedBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(8),
      borderSide: BorderSide(
        color: Theme.of(context).primaryColor,
        width: 1.5,
      ),
    ),
  ),
  style: TextStyle(color: BeeTokens.textPrimary(context)),
)

5. 踩坑记录

坑 1:硬编码颜色遍布全局

问题:项目历史代码中,颜色值散落在各处。

解决:我写了个脚本,全局搜索替换常见的硬编码颜色:

# 搜索硬编码颜色
grep -rn "Colors.black87" lib/
grep -rn "Colors.black54" lib/
grep -rn "Colors.white" lib/
grep -rn "Color(0xFF" lib/

然后逐个替换为对应的 Token。这个过程比较繁琐,但一劳永逸。

坑 2:弹窗背景色不生效

问题showModalBottomSheet 在暗黑模式下背景还是白色。

原因:Flutter 的 BottomSheet 默认使用 Theme.of(context).canvasColor

解决:显式指定 backgroundColor 参数。

showModalBottomSheet(
  context: context,
  backgroundColor: BeeTokens.surfaceElevated(context), // 必须指定!
  builder: (context) => ...
)

坑 3:状态栏图标颜色

问题:暗黑模式下状态栏图标还是黑色,看不清。

解决:在 AppBar 或页面级别设置 SystemUiOverlayStyle

// 方式 1:通过 AppBar
AppBar(
  systemOverlayStyle: BeeTokens.isDark(context)
      ? SystemUiOverlayStyle.light
      : SystemUiOverlayStyle.dark,
)

// 方式 2:通过 AnnotatedRegion
AnnotatedRegion<SystemUiOverlayStyle>(
  value: BeeTokens.isDark(context)
      ? SystemUiOverlayStyle.light
      : SystemUiOverlayStyle.dark,
  child: Scaffold(...),
)

坑 4:CupertinoDatePicker 颜色

问题:iOS 风格的日期选择器在暗黑模式下颜色不对。

解决:用 CupertinoTheme 包裹:

CupertinoTheme(
  data: CupertinoThemeData(
    brightness: BeeTokens.isDark(context) ? Brightness.dark : Brightness.light,
    primaryColor: Theme.of(context).primaryColor,
  ),
  child: CupertinoDatePicker(...),
)

坑 5:图片和图标

问题:某些图标/图片在暗黑模式下对比度不够。

解决

  1. 使用 Icon 时,显式指定颜色而非依赖默认值
  2. 对于图片,考虑准备暗黑模式版本,或使用 ColorFiltered 调整
// 图标:显式指定颜色
Icon(
  Icons.settings,
  color: BeeTokens.iconPrimary(context), // 不要省略
)

// 图片:可以用 ColorFiltered 调整
ColorFiltered(
  colorFilter: BeeTokens.isDark(context)
      ? const ColorFilter.mode(Colors.white, BlendMode.srcIn)
      : null,
  child: Image.asset('assets/logo.png'),
)

坑 6:主题切换后 Provider 状态

问题:主题切换后,某些页面颜色没更新。

原因:没有正确使用 ref.watch()

解决:确保在 build 方法中使用 ref.watch() 监听 Provider:

// ❌ 错误:只在 initState 读取一次
class _MyPageState extends ConsumerState<MyPage> {
  late Color _bgColor;

  @override
  void initState() {
    super.initState();
    _bgColor = BeeTokens.surface(context); // 这里读取后不会更新
  }
}

// ✅ 正确:在 build 中监听
class _MyPageState extends ConsumerState<MyPage> {
  @override
  Widget build(BuildContext context) {
    final bgColor = BeeTokens.surface(context); // 每次 build 都重新获取
    // ...
  }
}

设计思考

为什么选纯黑而不是深灰?

方案 优点 缺点
纯黑 #000000 OLED 省电、对比度高、极简 可能显得「空洞」
深灰 #121212 Material Design 推荐、柔和 OLED 不省电、对比度低

最终选择纯黑,因为:

  1. 现在大部分手机是 OLED 屏,纯黑真的能省电
  2. 配合主题色边框,不会显得单调
  3. 极简风格更符合这个 App 的调性

为什么用主题色边框?

  1. 保留个性化:用户可以自定义主题色,暗黑模式下也能体现
  2. 层次分明:边框让卡片和背景区分开,不会黑成一片
  3. 品牌感:彩色点缀让界面更有记忆点
  4. 技术简单:只需要在暗黑模式下加一个 border,不用准备两套资源

项目地址

GitHub: github.com/TNT-Likely/…

这是一个完全开源的 Flutter 记账 App,目前已有 600+ Star

主要特性:

  • 完全免费,无广告
  • 隐私优先,数据本地存储
  • 支持 WebDAV/Supabase 自托管同步
  • 多账本、账户管理、统计报表等完整功能
  • 支持 iOS 和 Android

如果这篇文章或这个项目对你有帮助,欢迎给个 ⭐ Star 支持一下!

代码都是开源的,欢迎参考和借鉴。


下载体验

欢迎试用并反馈意见!有问题可以在 GitHub Issues 或评论区告诉我。

Chrome浏览器自带翻译的诡异Bug:ID翻译后竟然变化了

当前负责的项目主打海外业务,总免不了和多语言打交道。但最近我在Vite+Vue3+Element Plus技术栈的项目里,遇到了一个堪称“玄学”的bug——Chrome浏览器自带翻译功能,居然能把表格里的数字ID直接改了!从印度同事到国内运营,两次触发都让我摸不着头脑,今天把整个过程记录下来,求大佬们看看这到底是怎么回事。

一、两次“诡异漂移”:翻译按钮成了ID篡改器

我们的后台管理系统很明确:部分页面根元素设为lang="zh"(中文界面),部分设为lang="en"(英文界面),语言标识没搞混也没遗漏。

第一次:印度同事的英文翻译触发

年初,负责海外业务的印度同事反馈:“搜索出来的广告数据有问题,Ad ID从10111872变成10111873了”。我第一时间查接口,返回的ID明明是正确的10111872;再看前端代码,就是最常规的Element Plus表格用法,没有任何多余计算。

远程连接他的电脑才发现,他把中文界面用Chrome右键翻译成了英文——我让他关闭翻译功能刷新页面,ID瞬间恢复10111872;重新开启翻译,数字又跳成10111873。当时以为是个案,没深查,只提醒他暂时不用翻译。

第二次:国内运营的中文翻译触发

前天,国内运营同事在英文界面操作时,把页面翻译成中文,结果历史重演:表格里的ID 20011872直接变成20011873。这次我确定不是偶然,而是Chrome翻译和Element Plus表格的“兼容性陷阱”。

贴一下我们的核心代码,真的就是最基础的el-table配置,没有任何花里胡哨的操作:

<template>
  <div lang="zh"> 
    <el-table
      :data="orderTableData"
      border
      stripe
      style="width: 100%"
    >
      <!-- 普通列:Ad Name翻译正常,无异常 -->
      <el-table-column
        label="Ad Name"  
        prop="adName"
        width="200"
      />
      
      <!-- 异常列:开启Chrome翻译后,该列adId数值会发生漂移(如10111872→10111873) -->
      <el-table-column
        label="Ad ID"  
        prop="adId"
        width="200"
      />
    </el-table>
  </div>
</template>

<script setup>
import { ref } from 'vue';
import { ElTable, ElTableColumn } from 'element-plus';

// 模拟接口返回数据,真实场景为接口拉取
const orderTableData = ref([
  { adId: 10111872, adName: "Summer Promotion", status: "已上线" },
  { adId: 10111172, adName: "New Product Launch", status: "待审核" },
  { adId: 10111272, adName: "Member Exclusive", status: "已下线" }
]);
</script>

二、排查过程:绕了一圈,排除所有常规可能

为了找到问题根源,我几乎把能排查的点都过了一遍,但结果全是“正常”:

  1. 接口与数据层面:Network面板反复确认,接口返回的adId是正确值,没有被篡改;前端接收数据后也没有做任何运算,直接赋值给表格数据源。
  2. 本地与多设备测试:我换了自己的Windows、MacBook,又找了5个同事用不同系统(Windows10/11、macOS)测试,无论怎么开翻译,ID都纹丝不动。
  3. 浏览器环境排查:远程检查触发异常的两台电脑,Chrome都是最新版本,没有安装任何可疑插件,清除缓存、重启浏览器后,问题依然存在。
  4. 代码与依赖检查:Element Plus版本是稳定版,没有兼容性问题;表格没有自定义渲染函数,也没有使用任何修改DOM的指令或插件。

排查到最后,我甚至怀疑是不是“量子纠缠”——直到发现两次异常都有一个共同触发条件:Chrome浏览器开启页面翻译

三、解决方案:给ID加“翻译豁免权”

既然确定是翻译功能搞的鬼,就顺着这个方向找解决方案。查HTML标准发现,HTML5原生有个translate属性,专门控制元素是否参与翻译——给元素加translate="no",浏览器就会跳过该元素的翻译处理。

针对表格ID列做了修改,核心是给ID所在的元素添加这个属性,修改后的代码如下:

<!-- 修复后的ID列代码,其他列不变 -->
<el-table-column label="Ad ID" width="200">
  <template #default="scope">
    <!-- 关键修复:添加translate="no",禁止Chrome翻译该节点,避免ID数值被篡改 -->
    <span class="ad-id" translate="no">{{ scope.row.adId }}</span>
  </template>
</el-table-column>

把修改后的代码发给出问题的同事,他们开启翻译再测试——ID稳稳地显示正确值,再也没有出现“漂移”。这个临时方案虽然解决了问题,但我的疑惑更重了。

四、疑惑:这到底是Chrome的bug,还是我漏了什么?

虽然问题解决了,但几个疑问始终萦绕在我心头,想借这篇文章问问各位大佬:

  1. 为什么只有特定设备触发这个问题?同样的Chrome版本、同样的操作,大部分设备正常,而触发的同事电脑上设备必现,难道和浏览器的某些隐藏配置有关?
  2. 为什么ID篡改有固定规律?两次异常都是ID末尾+1,不是随机乱改,这背后有没有特定的DOM处理逻辑?
  3. 这是Chrome翻译的bug,还是Element Plus表格的DOM结构容易被翻译器误判?有没有更根本的解决办法,而不是给每个关键节点加豁免?

🌐 阿里云 Linux 服务器 Let's Encrypt 免费 SSL 证书完整部署指南

🌐 阿里云 Linux 服务器 Let's Encrypt 免费 SSL 证书完整部署指南

适用系统:Alibaba Cloud Linux 3(兼容 CentOS/RHEL)
Web 服务器:Nginx
更新时间:2025 年 11 月
作者:DevOps Guide


✅ 一、前提条件

在开始前,请确保满足以下条件:

要求 说明
1. 阿里云 ECS 实例 已创建,操作系统为 Alibaba Cloud Linux 3
2. 域名已解析 yourdomain.com 的 A 记录指向 ECS 公网 IP
3. 安全组开放端口 入方向允许 80 (HTTP)443 (HTTPS)(来源:0.0.0.0/0
4. 域名备案(中国大陆地域) 若 ECS 位于中国内地(如杭州、北京),必须完成 ICP 备案
5. 已安装 Nginx 且能通过 http://yourdomain.com 访问

🔍 验证域名解析:

dig yourdomain.com +short
# 应返回你的 ECS 公网 IP

🚀 二、完整操作流程

步骤 1:安装 Nginx(如未安装)

# 安装 Nginx
sudo dnf install -y nginx

# 启动并设置开机自启
sudo systemctl start nginx
sudo systemctl enable nginx

步骤 2:配置 Nginx 站点(添加 server_name)

sudo vim /etc/nginx/conf.d/yourdomain.com.conf

写入以下内容:

server {
    listen 80;
    server_name yourdomain.com;  # ← 必须包含你要申请证书的域名
    root /usr/share/nginx/html;
    index index.html;
}

测试并重载:

sudo nginx -t
sudo systemctl reload nginx

✅ 此时应能通过浏览器访问 http://yourdomain.com


步骤 3:安装 Certbot

# 安装 EPEL 仓库
sudo dnf install -y epel-release

# 安装 Certbot 及 Nginx 插件
sudo dnf install -y certbot python3-certbot-nginx

步骤 4:申请并安装 SSL 证书

sudo certbot --nginx -d yourdomain.com

执行时会提示:

  • 输入邮箱(用于过期提醒)
  • 同意服务条款(按 A
  • 是否重定向 HTTP → HTTPS(建议选 Yes

✅ 成功后,Nginx 会自动启用 HTTPS,访问 https://yourdomain.com 应显示安全锁图标。


步骤 5:验证证书信息

sudo certbot certificates

输出示例:

Certificate Name: yourdomain.com
    Expiry Date: 2026-02-20 12:34:56+00:00 (VALID: 89 days)
    Certificate Path: /etc/letsencrypt/live/yourdomain.com/fullchain.pem
    Private Key Path: /etc/letsencrypt/live/yourdomain.com/privkey.pem

步骤 6:配置自动续期(关键!)

6.1 测试续期流程(安全,不会真续)
sudo certbot renew --dry-run

✅ 应看到:Congratulations, all simulated renewals succeeded.

6.2 设置定时任务
sudo crontab -e

在打开的编辑器中i 进入插入模式,粘贴以下内容:

0 2 * * * /usr/bin/certbot renew --quiet --post-hook "systemctl reload nginx"

保存退出:

  • ESC
  • 输入 :wq 并回车
6.3 验证 cron 是否设置成功
sudo crontab -l

应输出:

0 2 * * * /usr/bin/certbot renew --quiet --post-hook "systemctl reload nginx"

🔍 三、常见问题排查

❌ 问题 1:申请证书时超时(Timeout during connect)

错误示例

Fetching http://yourdomain.com/.well-known/acme-challenge/...: Timeout

原因与解决

原因 解决方案
阿里云安全组未开 80 端口 控制台 → 安全组 → 添加 80 入站规则
域名未备案(中国内地 ECS) 备案域名,或改用 DNS 验证(见附录)
DNS 未解析到公网 IP 检查 A 记录:dig yourdomain.com
本地防火墙阻止 检查:sudo firewall-cmd --list-ports(Alibaba Cloud Linux 默认关闭)

❌ 问题 2:Certbot 找不到 server_name

错误No matching server blocks located

解决

  • 确保 Nginx 配置中 server_name 精确包含 yourdomain.com
  • 配置文件必须在 /etc/nginx/conf.d/ 或被 nginx.confinclude 包含

❌ 问题 3:自动续期失败

排查步骤

# 查看 cron 执行日志
sudo grep CRON /var/log/cron

# 查看 certbot 日志
sudo tail -n 20 /var/log/letsencrypt/letsencrypt.log

# 手动运行续期(不静默)
sudo certbot renew

📎 附录:替代方案 —— DNS 验证(无需 80 端口)

适用于:未备案域名无法开放 80 端口 的场景

步骤 A:获取阿里云 AccessKey

  1. 进入 RAM 控制台
  2. 创建用户,授权 AliyunDNSFullAccess
  3. 获取 AccessKey IDAccessKey Secret

步骤 B:配置 DNS 插件

# 安装插件
sudo dnf install -y certbot-dns-alidns

# 创建凭证文件
mkdir -p ~/.secrets
cat > ~/.secrets/alidns.ini <<EOF
dns_alidns_access_key = YOUR_ACCESS_KEY_ID
dns_alidns_secret_key = YOUR_ACCESS_KEY_SECRET
EOF

chmod 600 ~/.secrets/alidns.ini

步骤 C:申请证书

sudo certbot certonly \
  --dns-alidns \
  --dns-alidns-credentials ~/.secrets/alidns.ini \
  -d yourdomain.com

步骤 D:手动配置 Nginx HTTPS

编辑 /etc/nginx/conf.d/yourdomain.com.conf,添加:

server {
    listen 443 ssl;
    server_name yourdomain.com;
    root /usr/share/nginx/html;

    ssl_certificate /etc/letsencrypt/live/yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/yourdomain.com/privkey.pem;
}

重载 Nginx:

sudo nginx -t && sudo systemctl reload nginx

⚠️ DNS 验证方式 仍需配置自动续期 cron(同上)


✅ 四、总结

步骤 命令/操作 状态
1. 安装 Nginx sudo dnf install nginx
2. 配置站点 创建 /etc/nginx/conf.d/*.conf
3. 安装 Certbot sudo dnf install certbot...
4. 申请证书 sudo certbot --nginx -d yourdomain.com
5. 设置自动续期 sudo crontab -e + 添加任务
6. 测试续期 sudo certbot renew --dry-run

🎉 完成!你的网站现在拥有免费、自动更新的 HTTPS 加密。


🔗 官方参考

💡 提示:每 3 个月手动运行一次 sudo certbot certificates 检查到期时间,确保万无一失。

游戏框架文档

框架启用和功能介绍

alt

将框架预制体拖入Hierarchy中即可,脚本中使用时using JKframe命名空间,框架的github地址,为了避免框架中UI部分对Scene场景中交互产生干扰,建议把框架交互屏蔽掉。

alt

框架类似工具箱和插件,除了UI窗口外的大多情况下,并不需要继承什么类或接口,直接通过XXXSystem调用即可。主要功能系统:

  1. 对象池系统:重复利用GameObject或普通class实例,并且支持设置对象池容量

  2. 事件系统:解耦工具,不需要持有引用来进行函数的调用

  3. 资源系统

    • Resources版本:关联对象池进行资源的加载卸载
    • Addressables版本:关联对象池进行资源的加载卸载,可结合事件工具做到销毁时自动从Addressables Unload
  4. MonoSystem:为不继承MonoBehaviour的对象提供Update、FixedUpdate、协程等功能

  5. 音效系统:背景音乐、背景音乐轮播、特效音乐、音量全局控制等

  6. 存档系统:

    • 支持多存档
    • 自动缓存,避免频繁读磁盘
    • 存玩家设置类数据,也就是不关联任何一个存档
    • 支持二进制和Json存档,开发时使用Json调试,上线后使用二进制加密与减少文件体积
  7. 日志系统:日志控制、保存等

  8. UI系统:UI窗口的层级管理、Tips功能

  9. 场景系统:对Unity场景加载封装了一层,主要用于监听场景加载进度

  10. 本地化系统:分为全局配置和局部配置(随GameObject加载)、UI自动本地化收集器(Text、Image组件无需代码即可自动本地化)

其他功能:

  1. 状态机:脚本逻辑状态机。
  2. 事件工具: 给物体添加点击、鼠标进入、鼠标拖拽、碰撞、触发、销毁等事件,而不需要额外在该物体上添加脚本等。
  3. 协程工具:协程避免GC。

对象池

在Unity中,对象的生成、销毁都需要性能开销,在一些特定的应用场景下需要进行大量重复物体的克隆,因此需要通过设计对象池来实现重复利用对象实例,减少触发垃圾回收。常用在频繁创建、销毁对象的情况下,比如子弹、AI生成等等、背包格子。

本框架的对象池系统有两类对象池(GameObject对象池和Object对象池)分别负责对需要在场景中实际激活/隐藏的GameObject和不需要显示在场景里的对象(脚本类、材质资源)进行管理。

本框架提供对象池容量的限制,且初始化时,可以预先传入要放入的对象根据默认容量实例化放入对象池,比如场景中默认使用20发子弹,可以在对象池初始化时就实例化好20枚子弹放入对象池。

如有特殊需求,可以通过持有PoolMoudle层来单独构建一个不同于全局对象池PoolSystem的Pool,默认正常情况下使用全局对象池PoolSystem即可。

GameObject对象池(GOP)

用于管理实际存在场景中并出现在Hierarchy窗口上的GameObject对象。

初始化GOP

// API
//根据keyName初始化GOP
//(string keyName, int maxCapacity = -1,GameObject prefab = null,int defaultQuantity = 0)
PoolSystem.InitGameObjectPool(keyName, maxCapacity, prefab, defaultQuanity);
PoolSystem.InitGameObjectPool(keyName, maxCapacity);
PoolSystem.InitGameObjectPool(keyName);
//根据prefab.name初始化GOP
//  //(GameObject prefab = null, int maxCapacity = -1,GameObject prefab = null,int defaultQuantity = 0)
PoolSystem.InitGameObjectPool(prefab, maxCapacity, defaultQuanity);
PoolSystem.InitGameObjectPool(prefab, maxCapacity);
PoolSystem.InitGameObjectPool(prefab);
//根据GameObject数组大小进行默认容量设置,并将数组对象作为默认对象全部置入对象池
//(string keyName, int maxCapacity = -1, GameObject[] gameObjects = null)
PoolSystem.InitGameObjectPool(keyName, maxCapacity, gameObject);


//简单示例
// 设定一个子弹Bullet对象池,最大容量30,默认填满
Gameobject bullet = GameObject.Find("bullet");
PoolSystem.InitGameObjectPool("Bullet", 30, bullet, 30);
PoolSystem.InitGameObjectPool(bullet, 30, 30);


//最简形式
PoolSystem.InitGameObjectPool(“对象池名字”);
  • 通过keyName或者直接传入prefab根据prefab.name 指定对象池的名字。
  • 可设置对象池最大容量maxCapacity(超过maxCapacity再放入对象会被Destroy掉)。
  • 初始化并不向构建出的空对象池填入内容,但可通过prefab和defaultQuanity设置默认容量填充空对象池(初始化时会自动按默认容量和最大容量的最小值自动生成GameObject放入对象池)。
  • 可通过传入GameObject数组初始化对象池的默认容量并放入对象填充空对象池。
  • maxCapacity, prefab, defaultQuantity可不填,默认无限容量maxCapacity = -1,不预先放入对象,prefab = null, defaultQuantity = 0。
  • defaultQuantity必须小于maxCapacity且如果想使用defaultQuantity则必须填入maxCapacity。
  • 可以通过重复初始化一个对象池的maxCapacity实现容量的更改,此时如果重新指定了defaultQuanity,则会补齐差量个数的对象进对象池。

将对象放入GOP

// API
//根据keyName/obj.name放入对象池
//(string assetName, GameObject obj)
PoolSystem.PushGameObject(keyName, obj);
PoolSystem.PushGameObject(obj);


//简单示例
//将一个子弹对象bullet放入Bullet对象池
PoolSystem.PushGameObject("Bullet", bullet);


// 扩展方法
bullet.GameObjectPushPool();
  • 通过keyName指定对象池名字放入对象obj,keyName不填则默认对象池名字为obj.name。
  • 封装了拓展方法,可以通过对象.GameObjectPushPool()简便地将GameObject放入对象池。
  • 可以使用拓展方法直接将对象放入同名对象池内。
  • 如果传入的keyName/prefab找不到对应的对象池(未Init),则会直接初始化生成一个同名的,无限容量的对象池并放入本次对象。
  • obj为null时本次放入操作无效,会进行报错提示。

将对象从GOP中取出

// API
//根据keyName加载GameObject
//(string keyName, Transform parent)
PoolSystem.GetGameObject(keyName, parent);
PoolSystem.GetGameObject(keyName);
//根据keyName和T加载GameObject并获取组件,返回值类型为T
PoolSystem.GetGameObject<T>(keyName, parent);
PoolSystem.GetGameObject<T>(keyName);


//简单实例
//将一个子弹对象从对象池中取出
GameObject bullet = PoolSystem.GetGameObject("Bullet");
//将一个子弹对象从对象池中取出并获取其刚体组件
GameObject bullet = PoolSystem.GetGameOjbect<Rigidbody>("Bullet");
  • 通过keyName指定对象池名字取出GameObject对象并设置父物体为parent,parent不填则默认无父物体在最顶层。
  • 可以通过传参获取对象上的某个组件,组件依托于GameObject存在,因此物体此时也已被从对象池中取出。
  • 当某个对象池内无对象时,其对象池仍会被保存,只有通过Clear才能彻底清空对象池。
  • 当对象池中无对象仍要取出时,会返回null。

清空GOP对象池

//API
//清空(GameObject/Object)对象池
//(bool clearGameObject = true, bool clearCSharpObject = true)
PoolSystem.ClearAll(true, false);
//清空GameObject类对象池中keyName索引的对象池
//(string assetName)
PoolSystem.ClearGameObject(keyName);


//简单实例
//清空所有GOP对象池
PoolSystem.ClearAll(true,false);
//清空Bullet对象池
PoolSystem.ClearGameObject("Bullet");
  • ClearAll方法用于清空所有GOP/OP对象池,两个bool参数是否清空GOP、是否清空OP。
  • 清空某一类GOP通过传入keyName对象池名索引。
  • 清空所有对象池时(ClearAll),所有资源都会被释放。
  • 清空某一类对象池时,GameObject中的数据载体和根节点会被放回对象池重复利用(使用时无需关心,底层实现)。

Object对象池(OP)

用于管理脚本类对象等非游戏物体对象,OP的API和GOP类似,只不过在传参部分OP支持更多方式

初始化OP

// API
//根据keyName初始化OP
//(string keyName, int maxCapacity = -1, int defaultQuantity = 0)
PoolSystem.InitObjectPool<T>(keyName, maxCapacity, defaultQuanity);
PoolSystem.InitObjectPool<T>(keyName, maxCapacity);
PoolSystem.InitObjectPool<T>(keyName);
//根据T的类型名初始化OP
PoolSystem.InitObjectPool<T>(maxCapacity, defaultQuanity);
PoolSystem.InitObjectPool<T>(maxCapacity);
PoolSystem.InitObjectPool<T>();
//根据keyName初始化OP,不考虑默认容量,无需传T
PoolSystem.InitObjectPool(keyName, maxCapacity);
PoolSystem.InitObjectPool(keyName);
//根据type类型名初始化OP
//System.Type type, int maxCapacity = -1
PoolSystem.InitObjectPool(type, maxCapacity);
PoolSystem.InitObjectPool(type);




//简单示例
// 设定一个Data数据类对象池,最大容量30,默认填满
PoolSystem.InitObjectPool<Data>("myData",30,30); //对象池名为myData
PoolSystem.InitObjectPool<Data>(30, 30); //对象池池名为Data
PoolSystem.InitObjectPool(xx.GetType()); //对象池名为xx的类型名
  • 通过keyName或者直接传入T根据T的类型名指定对象池的名字,优先使用keyName,在没有keyName的情况下以T类型名作为对象池名称。
  • 可设置对象池最大容量maxCapacity(超过maxCapacity再放入对象会被Destroy掉)。
  • 可通过T和defaultQuanity设置默认容量(初始化时会自动按默认容量和最大容量的最小值自动生成Object放入对象池),对应GameObject通过prefab和defaultQuanity设置默认容量。
  • 泛型T起两个作用,一个是不指定keyName时用于充当type名称,另一个是进行默认容量设置时指定预先放入对象池的对象类型,所以如果不想用默认容量功能可以使用不传T的API。
  • maxCapacity, prefab, defaultQuantity可不填,默认无限容量maxCapacity = -1,不预先放入对象,prefab = null, defaultQuantity = 0。
  • defaultQuantity必须小于maxCapacity且如果想使用defaultQuantity则必须填入maxCapacity。
  • 可以通过重复初始化一个对象池的maxCapacity实现容量的更改,此时如果重新指定了defaultQuanity,则会补齐差量个数的对象进对象池。
  • OP的初始化和GOP略有不同,使用了泛型T传递类型,参数列表更加精简,但只有有泛型参数的重载方法可以进行默认容量的初始化(需要指定泛型T进行类型转换)。
  • 可以选择通过传入某个实例的type类型,初始化同名的无限容量OP。

将对象放入OP

// API
//根据keyName/obj.getType().FullName即obj对应的类型名放入对象池
//(object obj, string keyName)
PoolSystem.PushObject(obj, keyName);
PoolSystem.PushObject(obj);


//简单示例
//将一个Data数据类对象data放入Data对象池
PoolSystem.PushObject(data, "Data";
PoolSystem.PushObject(data);


// 扩展方法
bullet.ObjectPushPool();
  • 通过keyName指定对象池名字放入对象obj,keyName不填则默认对象池名字为obj.name。
  • 封装了拓展方法,可以通过对象.GameObjectPushPool()简便地将GameObject放入对象池。
  • 可以使用拓展方法直接将对象放入同名对象池内。
  • 如果传入的keyName/obj找不到对应的对象池(未Init),则会直接初始化生成一个同名的,无限容量的对象池并放入本次对象。
  • obj为null时本次放入操作无效,会进行报错提示。

将对象从OP中取出

// API
//根据keyName返回System.object类型对象
//(string keyName)
PoolSystem.GetObject(keyName);
//根据keyName返回T类型的对象
PoolSystem.GetObject<T>(keyName);
//根据T类型名称返回对象
PoolSystem.GetObject<T>();
//根据type类型名返回对象
//(System.Type type)
PoolSystem.GetObject(xx.getType());




//简单实例
//将一个Data数据类对象data从对象池中取出
Data data = PoolSystem.GetObject("Data");
Data data = PoolSystem.GetObject<Data>();
  • 通过keyName,泛型T,type类型指定对象池名字取出Object对象。
  • 优先根据keyName索引,不存在keyName时,则通过泛型T的反射类型和type类型名索引
  • 推荐使用泛型方法,否则返回值是object类型还需要手动进行转换。

清空OP对象池

//API
//清空(GameObject/Object)对象池
//(bool clearGameObject = true, bool clearCSharpObject = true)
PoolSystem.ClearAll(false, true);
//清空Object类对象池下keyName/T类型名/type类型名对象池
//(string keyName)
PoolSystem.ClearObject(keyName);
PoolSystem.ClearObject<T>();
//(System.Type type)
PoolSystem.ClearObject(xx.getType());


//简单实例
//清空所有OP对象池
PoolSystem.ClearAll(false,true);
//清空Data对象池
PoolSystem.ClearObject("Data");
PoolSystem.ClearObject<Data>();
  • ClearAll方法用于清空所有GOP/OP对象池,两个bool参数是否清空GOP、是否清空OP。
  • 清空某一类OP通过传入keyName/泛型T的反射类型名/type类型名索引。
  • 清空所有对象池时(ClearAll),所有资源都会被释放。
  • 清空某一类对象池时,Object中的数据载体会被放回对象池重复利用(使用时无需关心,底层实现)。

对象池可视化

alt

可以通过PoolSystemViewer观察OP和GOP。

alt

注意

  • 对象池的名字可以和放入的对象名字不同,并且每一个放入对象池的对象名词也可以不同(只要类型一致),但为了避免混淆,我们推荐同名(同类名或者同GameObject名)或者使用配置、枚举来记录对象池名。
  • PoolSystem可以直接使用,但大多情况下,推荐使用ResSystem来获取GameObject/Object对象来保证返回值不为null。

资源系统

资源系统实现了Unity资源、游戏对象、类对象的获取、异步加载,并在加载游戏对象和类对象资源时优先从对象池中获取资源来优化性能,若对象池不存在对应资源再通过资源加载方法进行实例化(因为在直接使用对象池时,返回值允许为null,但)。提供Resources和Addressables两种版本:

  • Resources版本,关联对象池进行资源的加载、卸载。
  • Addressables版本,除关联对象池进行资源的加载、卸载外,结合事件工具实现对象Destroy时Adressables自动unload。

alt

两种版本在框架设置里进行切换,。

Resources版本

普通类对象(obj)

类对象资源不涉及异步加载、Resources和Addressables的区别,直接走对象池系统。

初始化

资源系统的底层基于对象池系统,所以在资源系统层面也开放对对象池的初始化设置,API和PoolSystem一致。

// API
//根据keyName初始化OP
//(string keyName, int maxCapacity = -1, int defaultQuantity = 0)
ResSystem.InitObjectPool<T>(keyName, maxCapacity, defaultQuanity);
ResSystem.InitObjectPool<T>(keyName, maxCapacity);
ResSystem.InitObjectPool<T>(keyName);
//根据T的类型名初始化OP
ResSystem.InitObjectPool<T>(maxCapacity, defaultQuanity);
ResSystem.InitObjectPool<T>(maxCapacity);
ResSystem.InitObjectPool<T>();
//根据keyName初始化OP,不考虑默认容量,无需传T
ResSystem.InitObjectPool(keyName, maxCapacity);
ResSystem.InitObjectPool(keyName);
//根据type类型名初始化OP
//System.Type type, int maxCapacity = -1
ResSystem.InitObjectPool(type, maxCapacity);
ResSystem.InitObjectPool(type);


//简单示例
// 设定一个Data数据类对象池,最大容量30,默认填满
ResSystem.InitObjectPool<Data>("myData",30,30);
ResSystem.InitObjectPool<Data>(30, 30);
ResSystem.InitObjectPool(xx.GetType());
  • 通过keyName或者直接传入T根据T的类型名指定对象池的名字。
  • 可设置对象池最大容量maxCapacity(超过maxCapacity再放入对象会被Destroy掉)。
  • 可通过T和defaultQuanity设置默认容量(初始化时会自动按默认容量和最大容量的最小值自动生成T类型的对象放入对象池)。
  • 泛型T起两个作用,一个是不指定keyName时用于充当type名称,另一个是进行默认容量设置时指定预先放入对象池的对象类型,所以如果不想用默认容量功能可以使用不传T的API。
  • maxCapacity, prefab, defaultQuantity可不填,默认无限容量maxCapacity = -1,不预先放入对象,prefab = null, defaultQuantity = 0。
  • defaultQuantity必须小于maxCapacity且如果想使用defaultQuantity则必须填入maxCapacity。
  • 可以通过重复初始化一个对象池的maxCapacity实现容量的更改,此时如果重新指定了defaultQuanity,则会补齐差量个数的对象进对象池。
  • 只有有泛型参数的重载方法可以进行默认容量的初始化(需要指定泛型T进行类型转换)。
  • 可以选择通过传入某个实例的type类型,初始化同名的无限容量OP。
obj加载
// API
//根据keyName从对象池中获取一个T类型对象,没有则new
//string keyName
ResSystem.GetOrNew<T>(keyName);
//根据T类型名从对象池中获取一个T类型对象,没有则new
ResSystem.GetOrNew<T>();


//简单示例,获取Data数据类的一个对象
GameObject go1 = ResSystem.GetOrNew<Data>("Data");
  • 通过keyName指定加载的类对象名,不填keyName则按照T的类型名加载。
  • 加载时优先通过对象池获取,如果对象池中无对应资源,自动new一个类对象返回,保证返回值不为null,这点体现了资源系统比对象池更完善,对象池get不存在的obj资源返回Null。
obj卸载

卸载obj即将obj放回对象池进行资源回收。

//API
//根据keyName/obj类型名将obj放回对象池
//object obj, string keyName
ResSystem.PushObjectInPool(obj);
ResSystem.PushObjectInPool(obj, string keyName);


//简单示例,卸载Data类的对象data
ResSystem.PushObjectInPool(data, "Data");
  • 通过obj指定卸载的对象,keyName指定对象池名,不填则按照obj的类型名卸载。
  • 卸载对象时如果没有初始化过对象池,则对应自动创建一个同名无限量对象池并将obj放入,保证对象卸载成功,这点体现了资源系统比对象池更完善,对象池push未初始化的对象池资源会报错。

游戏对象(GameObject)

初始化

资源系统的底层基于对象池系统,所以在资源系统层面也开放对对象池的初始化设置,API和PoolSystem大体一致,在prefab部分传参略有不同,通过传Resources下对应的路径由资源系统获得预制体,并克隆出来放入对象池。

//API
//根据keyName初始化GOP
//(string keyName, int maxCapacity = -1, string assetPath = null, int defaultQuantity = 0)
ResSystem.InitGameObjectPool(keyName, maxCapacity, assetPath, defaultQuantity);
ResSystem.InitGameObjectPool(keyName, maxCapacity);
ResSystem.InitGameObjectPool(keyName);
//根据assetPath切割的资源名初始化GOP
//(string assetPath, int maxCapacity = -1, int defaultQuantity = 0)
ResSystem.InitGameObjectPool(string assetPath, maxCapacity, defaultQuantity);
ResSystem.InitGameObjectPool(string assetPath, maxCapacity);
ResSystem.InitGameObjectPool(string assetPath);




//简单示例
// 设定一个子弹Bullet对象池(假设Bullet的路径在Resources文件夹下),最大容量30,默认填满
Gameobject bullet = GameObject.Find("bullet");
ResSystem.InitGameObjectPool("Bullet", 30, bullet, 30);
ResSystem.InitGameObjectPool(bullet, 30, 30);


//最简形式
ResSystem.InitGameObjectPool(“对象池名字”);
  • 通过keyName或者直接传入assetPath(完整资源路径)根据切割的资源名指定对象池的名字。
  • 传入的assetPath会自动切割获得资源名。
  • 可设置对象池最大容量maxCapacity(超过maxCapacity再放入对象会被Destroy掉)。
  • 可通过assetPath获取的资源和defaultQuanity设置默认容量(初始化时会自动按默认容量和最大容量的最小值自动生成GameObject放入对象池)。
  • 默认无限容量maxCapacity = -1,不预先放入对象,assetPath = null, defaultQuantity = 0。
  • defaultQuantity必须小于maxCapacity且如果想使用defaultQuantity则必须填入maxCapacity。
  • 可以通过重复初始化一个对象池的maxCapacity实现容量的更改,此时如果重新指定了defaultQuanity,则会补齐差量个数的对象进对象池。
  • 注意加载到内存的对象在被实例化之后会被自动释放。
GameObject加载并实例化
//API
//加载游戏物体
//(string assetPath, Transform parent = null,string keyName=null)
ResSystem.InstantiateGameObject(assetPath, parent, keyName);
ResSystem.InstantiateGameObject(assetPath, parent);
ResSystem.InstantiateGameObject(assetPath);
ResSystem.InstantiateGameObject(parent, keyName);
//加载游戏物体并获取组件T
ResSystem.InstantiateGameObject<T>(assetPath, parent, keyName);
ResSystem.InstantiateGameObject<T>(assetPath, parent);
ResSystem.InstantiateGameObject<T>(assetPath);
ResSystem.InstantiateGameObject<T>(parent, keyName);
//异步加载(void)游戏物体
//(string path, Action<GameObject> callBack = null, Transform parent = null, string keyName = null)
ResSystem.InstantiateGameObjectAsync(assetPath, Action<GameObject> callBack, parent, keyName);
ResSystem.InstantiateGameObjectAsync(assetPath, Action<GameObject> callBack, parent);
ResSystem.InstantiateGameObjectAsync(assetPath, Action<GameObject> callBack);
ResSystem.InstantiateGameObjectAsync(assetPath);
//异步加载(void)游戏物体并获取组件T
ResSystem.InstantiateGameObjectAsync<T>(assetPath, Action<GameObject> callBack, parent, keyName);
ResSystem.InstantiateGameObjectAsync<T>(assetPath, Action<GameObject> callBack, parent);
ResSystem.InstantiateGameObjectAsync<T>(assetPath, Action<GameObject> callBack);
ResSystem.InstantiateGameObjectAsync<T>(assetPath);


//简单示例
//实例化一个子弹对象(假设Bullet路径在Resources下)
GameObject bullet = ResSystem.InstantiateGameObject("Bullet");
//实例化一个子弹对象取出并获取其刚体组件
Rigidbody rb = ResSystem.InstantiateGameObject<Rigidbody>("Bullet");
//异步实例化一个子弹对象,并在其加载完后坐标归零
void getBullet(GameObject bullet)
{
    bullet.transform.position = Vector3.zero;
    }
ResSystem.InstantiateGameObjectAsync("Bullet", getBullet);
  • 通过assetPath加载游戏物体并实例化返回。
  • 实例化的游戏物体会设置父物体为parent,不填则默认为null无父物体在最顶层。
  • 实例化的物体名称优先为keyName,keyName为null时则为assetName。
  • 优先根据keyName从对象池获取,不填keyName则根据path加载的资源名在对象池中查找。
  • 对象池中找不到根据assetpath走Resources加载出对象,不填assetPath时则通过keyName查询路径加载对象。
  • 可以通过传参获取对象上的某个组件,组件依托于GameObject存在,因此物体此时也已被从对象池中取出。
  • 异步加载游戏物体及其组件的方法返回值为void类型,无法即时快速加载的游戏物体,需要通过callback回调函数获取加载的GameObject对象并进行使用。
  • 资源系统如果资源路径正确,则返回值必不为空,优先从对象池中获取,对象池中不存在则根据Load的对象进行实例化返回。
GameObject卸载

卸载GameObject即将GameObject放回对象池进行资源回收。

//API
//根据keyName/gameObject.name回收gameObject
//string keyName, GameObject gameObject
ResSystem.PushGameObjectInPool(string keyName, gameObject);
ResSystem.PushGameObjectInPool(gameObject);




//简单示例,卸载子弹对象bullet
ResSystem.PushGameObjectInPool(bullet, "Bullet");
  • 通过gameObject指定卸载的对象,keyName指定对象池名,不填则按照gameObject的对象名卸载。
  • 卸载对象时如果没有初始化过对象池,则对应自动创建一个同名无限量对象池并将gameObject放入。

Unity资源

这类资源不需要进行实例化,所以不需要过对象池,只需要直接使用数据或者引用,比如AudioClip,Sprite,prefab。

加载Asset
//API
//根据assetPath异步加载T类型资源
//(string assetPath, Action<T> callBack)
ResSystem.LoadAssetAsync<T>(assetPath, callBack);
//根据assetPath加载T类型资源
ResSystem.LoadAsset<T>(assetPath);
//加载指定路径的所有资源,返回object数组
ResSystem.LoadAssets(assetPath);
//加载指定路径的所有资源返回T类型
ResSystem.LoadAssets<T>(assetPath);


//简单示例,加载Resources下的clip音频资源
ResSystem.LoadAssets<AudioClip>("Resources/clip");
  • 通过assetPath路径加载资源,T用来指明加载的资源类型。
  • 异步加载资源需要通过传入callback回调获取加载的资源并进行使用。
  • 加载所有资源时不指定T则返回object数组。
  • 注意加载的资源不会被自动释放。
卸载Asset
//API
//卸载某个资源
//(UnityEngine.Object assetToUnload)
ResSystem.UnloadAsset(assetToUnload);
//卸载所有资源
ResSystem.UnloadUnusedAssets();

卸载资源实际指释放内存中的asset。

对象池是帮做资源回收利用的,避免频繁GC,对象池管理不了Asset资源。而释放是资源不用了也不需要回收卸载掉就行了,GO的自动释放资源系统已经做好了,Asset需要你根据自己的需求来释放,因为Asset也没有生命周期,只能自己释放。

Addressables版本

普通类对象(obj)

类对象资源不涉及异步加载、Resources和Addressables的区别,直接走对象池系统,。

初始化

资源系统的底层基于对象池系统,所以在资源系统层面也开放对对象池的初始化设置,API和PoolSystem一致。

// API
//根据keyName初始化OP
//(string keyName, int maxCapacity = -1, int defaultQuantity = 0)
ResSystem.InitObjectPool<T>(keyName, maxCapacity, defaultQuanity);
ResSystem.InitObjectPool<T>(keyName, maxCapacity);
ResSystem.InitObjectPool<T>(keyName);
//根据T的类型名初始化OP
ResSystem.InitObjectPool<T>(maxCapacity, defaultQuanity);
ResSystem.InitObjectPool<T>(maxCapacity);
ResSystem.InitObjectPool<T>();
//根据keyName初始化OP,不考虑默认容量,无需传T
ResSystem.InitObjectPool(keyName, maxCapacity);
ResSystem.InitObjectPool(keyName);
//根据type类型名初始化OP
//System.Type type, int maxCapacity = -1
ResSystem.InitObjectPool(type, maxCapacity);
ResSystem.InitObjectPool(type);


//简单示例
// 设定一个Data数据类对象池,最大容量30,默认填满
ResSystem.InitObjectPool<Data>("myData",30,30);
ResSystem.InitObjectPool<Data>(30, 30);
ResSystem.InitObjectPool(xx.GetType());
  • 通过keyName或者直接传入T根据T的类型名指定对象池的名字。
  • 可设置对象池最大容量maxCapacity(超过maxCapacity再放入对象会被Destroy掉)。
  • 可通过T和defaultQuanity设置默认容量(初始化时会自动按默认容量和最大容量的最小值自动生成T类型的对象放入对象池)。
  • 泛型T起两个作用,一个是不指定keyName时用于充当type名称,另一个是进行默认容量设置时指定预先放入对象池的对象类型,所以如果不想用默认容量功能可以使用不传T的API。
  • maxCapacity, prefab, defaultQuantity可不填,默认无限容量maxCapacity = -1,不预先放入对象,prefab = null, defaultQuantity = 0。
  • defaultQuantity必须小于maxCapacity且如果想使用defaultQuantity则必须填入maxCapacity。
  • 可以通过重复初始化一个对象池的maxCapacity实现容量的更改,此时如果重新指定了defaultQuanity,则会补齐差量个数的对象进对象池。
  • 只有有泛型参数的重载方法可以进行默认容量的初始化(需要指定泛型T进行类型转换)。
  • 可以选择通过传入某个实例的type类型,初始化同名的无限容量OP。
obj加载
// API
//根据keyName从对象池中获取一个T类型对象,没有则new
//string keyName
ResSystem.GetOrNew<T>(keyName);
//根据T类型名从对象池中获取一个T类型对象,没有则new
ResSystem.GetOrNew<T>();


//简单示例,获取Data数据类的一个对象
GameObject go1 = ResSystem.GetOrNew<Data>("Data");
  • 通过keyName指定加载的类对象名,不填keyName则按照T的类型名加载。
  • 加载时优先通过对象池获取,如果对象池中无对应资源,自动new一个类对象返回,保证返回值不为null。
obj卸载

卸载obj即将obj放回对象池进行资源回收。

//API
//根据keyName/obj类型名将obj放回对象池
//object obj, string keyName
ResSystem.PushObjectInPool(obj);
ResSystem.PushObjectInPool(obj, string keyName);


//简单示例,卸载Data类的对象data
ResSystem.PushObjectInPool(data, "Data");
  • 通过obj指定卸载的对象,keyName指定对象池名,不填则按照obj的类型名卸载。
  • 卸载对象时如果没有初始化过对象池,则对应自动创建一个同名无限量对象池并将obj放入。

游戏对象(GameObject)

初始化

资源系统的底层基于对象池系统,所以在资源系统层面也开放对对象池的初始化设置,API和PoolSystem有区别,Addressables版本通过Addressables name来获取prefab(参考副本),Res需要传路径来获取prefab(参考副本)。

//API
//根据keyName初始化GOP
//(string keyName, int maxCapacity = -1, string assetName = null, int defaultQuantity = 0)
ResSystem.InitGameObjectPoolForKeyName(keyName, maxCapacity,assetName, defaultQuantity);
ResSystem.InitGameObjectPoolForKeyName(keyName, maxCapacity);
ResSystem.InitGameObjectPoolForKeyName(keyName);
//根据assetName在Addressables中的资源名初始化GOP
//(string assetName, int maxCapacity = -1, int defaultQuantity = 0)
ResSystem.InitGameObjectPoolForAssetName(assetName, maxCapacity, defaultQuantity);
ResSystem.InitGameObjectPoolForAssetName(assetName, maxCapacity);
ResSystem.InitGameObjectPoolForAssetName(assetName);




//简单示例
// 设定一个子弹Bullet对象池(假设Addressable资源名称为Bullet),最大容量30,默认填满
Gameobject bullet = GameObject.Find("bullet");
ResSystem.InitGameObjectPool("Bullet", 30, bullet, 30);
ResSystem.InitGameObjectPool(bullet, 30, 30);


//最简形式
ResSystem.InitGameObjectPool(“对象池名字”);
  • 通过keyName或者直接传入assetName(Addressable资源的名称)根据获取的资源名指定对象池的名字,优先keyName,没有keyName则使用assetName。
  • 可设置对象池最大容量maxCapacity(超过maxCapacity再放入对象会被Destroy掉)。
  • 可通过assetName和defaultQuanity设置默认容量(初始化时会自动按默认容量和最大容量的最小值自动加载GameObject放入对象池)。
  • 默认无限容量maxCapacity = -1,不预先放入对象,prefab = null, defaultQuantity = 0。
  • defaultQuantity必须小于maxCapacity且如果想使用defaultQuantity则必须填入maxCapacity。
  • 可以通过重复初始化一个对象池的maxCapacity实现容量的更改,此时如果重新指定了defaultQuanity,则会补齐差量个数的对象进对象池。
GameObject加载并实例化

Addressable版本中游戏物体参数通过Addressable资源名assetName(Res是资源路径assetPath)指定,支持加载出的对象Destroy时在Addressables中自动释放。

//API
//加载游戏物体
//(string assetName, Transform parent = null, string keyName = null, bool autoRelease = true)
ResSystem.InstantiateGameObject(assetName, parent, keyName, autoRelease);
ResSystem.InstantiateGameObject(assetName, parent, keyName);
ResSystem.InstantiateGameObject(assetName, parent);
ResSystem.InstantiateGameObject(assetName);
ResSystem.InstantiateGameObject(parent, keyName, autoRelease);
ResSystem.InstantiateGameObject(parent, keyName);
//加载游戏物体并获取组件T
ResSystem.InstantiateGameObject<T>(assetName, parent, keyName, autoRelease);
ResSystem.InstantiateGameObject<T>(assetName, parent, keyName);
ResSystem.InstantiateGameObject<T>(assetName, parent);
ResSystem.InstantiateGameObject<T>(assetName);
ResSystem.InstantiateGameObject<T>(parent, keyName, autoRelease);
ResSystem.InstantiateGameObject<T>(parent, keyName);
//异步加载(void)游戏物体
//(string assetName, Action<GameObject> callBack = null, Transform parent = null, string keyName = null, bool autoRelease = true)
ResSystem.InstantiateGameObjectAsync(assetName, callBack, parent, keyName, autoRelease);
ResSystem.InstantiateGameObjectAsync(assetName, callBack, parent, keyName);
ResSystem.InstantiateGameObjectAsync(assetName, callBack, parent);
ResSystem.InstantiateGameObjectAsync(assetName, callBack);
ResSystem.InstantiateGameObjectAsync(assetName);
//异步加载(void)游戏物体并获取组件T
//(string assetName, Action<T> callBack = null, Transform parent = null, string keyName = null, bool autoRelease = true)
ResSystem.InstantiateGameObjectAsync<T>(assetName, callBack, parent, keyName, autoRelease);
ResSystem.InstantiateGameObjectAsync<T>(assetName, callBack, parent, keyName);
ResSystem.InstantiateGameObjectAsync<T>(assetName, callBack, parent);
ResSystem.InstantiateGameObjectAsync<T>(assetName, callBack);
ResSystem.InstantiateGameObjectAsync<T>(assetName, callBack);
ResSystem.InstantiateGameObjectAsync<T>(assetName);


//简单示例
//实例化一个子弹对象(假设AB资源名称为Bullet)
GameObject bullet = ResSystem.InstantiateGameObject("Bullet");
//实例化一个子弹对象取出并获取其刚体组件
Rigbody rb = ResSystem.InstantiateGameObject<Rigbody>("Bullet");
//异步实例化一个子弹对象,并在其加载完后坐标归零
void getBullet(GameObject bullet)
{
    bullet.transform.position = Vector3.zero;
    }
ResSystem.InstantiateGameObjectAsync("Bullet", getBullet);
  • 通过assetName加载游戏物体并实例化返回
  • 实例化的游戏物体会设置父物体为parent,不填则默认为null无父物体在最顶层。
  • 实例化的物体名称优先为keyName,keyName为null时则为assetName。
  • 优先根据keyName从对象池获取,不填keyName则根据assetName在对象池中查找。
  • 对象池中无缓存,则根据assetName从Addressable中获取资源,不填assetName则根据keyName从Addressable中获取资源。
  • 可以通过传参获取对象上的某个组件,组件依托于GameObject存在,因此物体此时也已被从对象池中取出。
  • autoRelease为true则通过事件工具为加载出的对象添加事件监听,会在其Destroy时自动调用Addressables的Release API。
  • 异步加载游戏物体及其组件的方法返回值为void类型,无法即时直接加载的游戏物体,需要通过callback回调获取加载的GameObject对象并进行使用。
  • 如果资源路径正确,则返回值必不为空,优先从对象池中获取,对象池中不存在则根据Load的对象进行实例化返回。
GameObject卸载

卸载GameObject即将GameObject放回对象池进行资源回收。

//API
//根据keyName/gameObject.name卸载gameObject
//(string keyName, GameObject gameObject)
ResSystem.PushGameObjectInPool(keyName, gameObject);
ResSystem.PushGameObjectInPool(gameObject);


//简单示例,卸载子弹对象bullet
ResSystem.PushGameObjectInPool(bullet, "Bullet");
  • 通过gameObject指定卸载的对象,keyName指定对象池名,不填则按照gameObject的对象名卸载。
  • 卸载对象时如果没有初始化过对象池,则对应自动创建一个同名无限量对象池并将gameObject放入。

Unity资源

这类资源不需要进行实例化,所以不需要过对象池,只需要使用数据或者引用,比如AudioClip,Sprite,prefab(没有经过实例化的GameObject原本)。

加载Asset
//API


//根据assetName加载T类型资源
ResSystem.LoadAsset<T>(assetName);
//根据keyName批量加载所有资源(IList<T>)
//(string keyName, out AsyncOperationHandle<IList<T>> handle, Action<T> callBackOnEveryOne = null)
ResSystem.LoadAssets<T>(keyName, handle, callBackOnEveryOne);


//根据assetName异步加载T类型资源(void)
//(string assetName, Action<T> callBack)
ResSystem.LoadAssetAsync<T>(string assetName, Action<T> callBack);
//根据keyName批量异步加载所有资源(void)
//(string keyName, Action<AsyncOperationHandle<IList<T>>> callBack, Action<T> callBackOnEveryOne = null)
ResSystem.LoadAssetsAsync<T>(keyName, callBack, callBackOnEveryOne);


//简单示例,加载Addressable clip音频资源
ResSystem.LoadAssets<AudioClip>("clip");
  • 通过path路径加载资源,T用来指明加载的资源类型。
  • 异步加载单个资源需要通过传入callback回调获取加载的资源并进行使用。
  • 批量加载资源时keyName是Addressable中的Labels。
  • handle用于释放资源,批量加载时,如果释放资源要释放掉handle,直接去释放资源是无效的.
  • Addressable加载指定keyName的所有资源时,支持每加载一个资源调用一次callBackOnEveryOne。
  • 异步加载完指定keyName所有资源时,调用callback获取加载的资源集合并进行使用。
  • 注意加载的资源不会被自动释放。
卸载Asset
//API
//释放某个资源
//(T obj)
ResSystem.UnloadAsset<T>(T obj);
//销毁对象并释放资源
//(GameObject obj)
ResSystem.UnloadInstance(obj);
//卸载因为批量加载而产生的handle
//(AsyncOperationHandle<TObject> handle)
UnLoadAssetsHandle<TObject>(handle);

卸载Asset即释放资源,可以在Destroy游戏对象的同时释放Addressable资源。

资源系统-自动生成资源引用代码

针对Addressables版本,使用字符串来加载资源方式比较麻烦,而且容易输错,框架提供一种基于引用加载的方式。

alt

通过Editor工具会在指定路径下生成资源引用代码R。

alt

// API
//返回一个资源
R.GroupName.AddressableName;
//返回一个资源的实例
//(Transform parent = null,string keyName=null,bool autoRelease = true)
R.GroupName.AddressableName(parent, keyName, autoRelease);
R.GroupName.AddressableName(parent, keyName);
R.GroupName.AddressableName(parent);


//使用示例
//获取一个Bullet预制体资源(不实例化)
Gameobject bullet = R.DefaultLocalGroup.Bullet;
//获取一个Bullet实例
Gameobject bullet = R.DefaultLocalGroup.Bullet(x.transform);


//释放
ResSystem.UnloadAsset<GameObject>(bullet);
  • R是资源脚本的命名空间,固定。
  • GroupName是Addressable的组名。
  • AddressableName是资源名。
  • 如果填写keyName,则先去对象池中找资源实例,找不着再通过Addressable获取资源并实例化。
  • parent为实例的父物体。
  • autoRelease为true则实例会在Destroy时自动释放Addressable中对应的资源。

alt

对于Sprite的子图,也支持直接引用。

alt

//子图
R.LV2.Img_Img_0;
//总图
R.Lv2.Img;

事件系统

框架的事件系统主要负责高效的方法调用与数据传递,实现各功能之间的解耦,通常在调用某个实例的方法时,必须先获得这个实例的引用或者新实例化一个对象,低耦合度的框架结构希望程序本身不去关注被调用的方法所依托的实例对象是否存在,通过事件系统做中转将功能的调用封装成事件,使用事件监听注册、移除和事件触发完成模块间的功能调用管理。常用在UI事件、跨模块事件上。

事件系统支持无返回值的Action,Func实际应用意义不大。

事件监听添加

//API
//添加无参数的事件监听
//string eventName, Action action
EventSystem.AddEventListener(eventName, action); 
//添加多个参数的事件监听
//string eventName
EventSystem.AddEventListener<T>(eventName, action);
EventSystem.AddEventListener<T0, T1>(eventName, action);
EventSystem.AddEventListener<T0, T1, T2>(eventName, action);
...
EventSystem.AddEventListener<T0, T1, ..., T15>(eventName, action);


//简单示例
//添加无参数的事件监听,Doit方法对应名称为Test的事件
EventSystem.AddEventListener("Test", Doit);
void Doit()
{
    Debug.Log("Doit");
}
//添加多个参数的事件监听,Doit2对应名称为TestM的事件,参数为int,string
EventSystem.AddEventListener<int, string>("TestM", Doit2);
void Doit2(int a, string b)
{
    Debug.Log(a);
    Debug.Log(b); 
}
  • eventName是解耦执行的方法的标记,即事件名,是触发事件时的唯一依据。
  • action传无返回值方法。
  • T0~T15是泛型,用于指定参数表,支持最多16个参数的action。

事件监听移除

//API
//添加无参数的事件监听
//string eventName, Action action
EventSystem.RemoveEventListener(eventName, action); 
//添加多个参数的事件监听
//string eventName
EventSystem.RemoveEventListener<T>(eventName, action);
EventSystem.RemoveEventListener<T0, T1>(eventName, action);
EventSystem.RemoveEventListener<T0, T1, T2>(eventName, action);
...
EventSystem.RemoveEventListener<T0, T1, ..., T15>(eventName, action);


//简单示例
//移除无参数的事件监听,Doit方法对应名称为Test的事件
EventSystem.RemoveEventListener("Test", Doit);
void Doit()
{
    Debug.Log("Doit");
}
//移除多个参数的事件监听,Doit2对应名称为TestM的事件,参数为int,string
EventSystem.RemoveEventListener<int, string>("TestM", Doit2);
void Doit2(int a, string b)
{
    Debug.Log(a);
    Debug.Log(b); 
}
  • eventName是解耦执行的方法的标记,即事件名,是触发事件时的唯一依据。
  • action传无返回值方法。
  • T0~T15是泛型,用于指定参数表,支持最多16个参数的action。

事件触发

//API
//触发无参数事件
EventSystem.EventTrigger(string eventName);
//触发多个参数事件
EventSystem.EventTrigger<T>(string eventName, T arg);
EventSystem.EventTrigger<T0, T1>(string eventName, T0 arg0, T1 arg1);
EventSystem.EventTrigger<T0, T1,..., T15>(string eventName, T0 arg0, T1 arg1, ..., T15 arg15);
 
//简单示例,使用添加监听的方法例子
EventSystem.EventTrigger("Test");
EventSystem.EventTrigger<int,string>("TestM",1,"test");
  • eventName是解耦执行的方法的标记,即事件名,是触发事件时的唯一依据。
  • T0~T15是泛型,用于指定参数表,支持最多16个参数的action。
  • 事件的查询底层使用TryGetValue所以触发不存在的事件并不会报错。

事件移除

事件移除和事件监听移除的区别参与:

  • 事件监听移除只移除一条Action,比如添加了3次同名事件监听,则移除一次后触发还是会执行两次,且eventName记录不会被移除。
  • 事件移除会将事件中心字典中有关eventName的记录连带存储的Action一同清空。
//API
//移除一类事件
//(string eventName)
EventSystem.RemoveEvent(eventName);
//移除事件中心中所有事件
EventSystem.Clear();
  • eventName是解耦执行的方法的标记,即事件名,是触发事件时的唯一依据。

类型事件

支持对参数进行封装为一个struct传递,简化参数列表。

//API
//添加类型事件的监听
//Action<T> action
AddTypeEventListener<T>(action);
//移除类型事件的监听
RemoveTypeEventListener<T>(action);
//移除/删除一个类型事件
RemoveTypeEvent<T>();
//触发类型事件
// (T arg)
TypeEventTrigger<T>(arg);
  • T是封装的参数列表,一般为struct类型。
  • action设计使用封装参数T的事件。
  • arg是封装的参数。

注意

事件系统的运行逻辑是,预先添加/移除事件监听,再在能够获取相应参数的类内触发事件。

音效系统

音效服务集成了背景、特效音乐播放,音量、播放控制功能。包含了全局音量globalVolume、背景音量bgVolume、特效音量effectVolume、静音布尔量isMute、暂停布尔量isPause等音量相关的属性,播放背景音乐的PlayBGAudio方法且,播放特效音乐PlayOnShot方法且重载后支持在指定位置或绑定游戏对象播放特定的音乐,特效音乐由于要重复使用,可以从对象池中获取播放器并自动回收,支持播放后执行回调事件。

播放背景音乐

音量、播放属性控制

音效服务支持在Inspector面板上的值发生变化时自动执行相应的方法更新音量属性,也可以在属性值变化时自动调用相应的更新方法。

//API & 示例
//全局音量(float,0~1),音量设定为50%
AudioSystem.GlobalVolume = 0.5f;
//背景音乐音量(float,0~1),音量设定为50%
AudioSystem.BGVolume = 0.5f;
//特效音乐音量(flaot,0~1)
AudioStystem.EffectVolume = 0.5f;
//是否全局静音(bool),true则静音
AudioSystem.IsMute = true;
//背景音乐是否循环,true则循环
AudioSystem.IsLoop = true;
//背景音乐是否暂停,true则暂停
AudioSystem.IsPause = true;
  • GlobalVolume是全局音量,同时影响背景、特效音乐音量。
  • BGVolume是背景音乐音量
  • EffectVolume是特效音乐音量。
  • IsMute控制全局音量是否静音。
  • IsLoop控制背景音乐是否循环。
  • IsPause控制背景音乐是否暂停。

支持通过面板更新音量属性。

alt

播放背景音乐

//API
//播放背景音乐
//(AudioClip clip, bool loop = true, float volume = -1, float fadeOutTime = 0, float fadeInTime = 0)
AudioSystem.PlayBGAudio(clip, loop, volume, fadeOutTime, fadeInTime);
//轮播多个背景音乐
//(AudioClip[] clips, float volume = -1, float fadeOutTime = 0, float fadeInTime = 0);
AudioSystem.PlayBGAudioWithClips(clips, volume, fadeOutTime, fadeInTime);
//停止当前背景音乐
AudioSystem.StopBGAudio();
//暂停当前背景音乐
AudioSystem.PauseBGAudio();
//取消暂停当前音乐
AudioSystem.UnPauseBGAudio();


//简单示例
AudioClip clip = ResSystem.LoadAsset<AudioClip>("music");
AudioSystem.PlayBGAudio(clip);
  • clip是音乐片段,可以传clip数组来轮播音乐。
  • volume是音乐的音量,不指定则按原来的背景音量。
  • fadeOutTime是渐出音乐的时间。
  • fadeInTime是渐入音乐的时间。
  • 停止当前背景音乐会将当前背景音乐置空。
  • 暂停音乐可取消暂停恢复。

播放特效音乐

//API
//播放一次音效并绑定到游戏物体上,位置随物体变化
//(AudioClip clip, Component component = null, bool autoReleaseClip = false, float volumeScale = 1, bool is3d = true, Action callBack = null)
audioSystem.PlayOneShot(clip, component, autoReleaseClip, volumeScale, is3d, callBack);
audioSystem.PlayOneShot(clip, component, autoReleaseClip, volumeScale, is3d);
audioSystem.PlayOneShot(clip, component, autoReleaseClip, volumeScale);
audioSystem.PlayOneShot(clip, component, autoReleaseClip);
audioSystem.PlayOneShot(clip, component);
audioSystem.PlayOneShot(clip);
//在指定位置上播放一次音效
//(AudioClip clip, Vector3 position, bool autoReleaseClip = false, float volumeScale = 1, bool is3d = true, Action callBack = null)
audioSystem.PlayOneShot(clip, position, autoReleaseClip, volumeScale, is3d, callBack);
audioSystem.PlayOneShot(clip, position, autoReleaseClip, volumeScale, is3d);
audioSystem.PlayOneShot(clip, position, autoReleaseClip, volumeScale);
audioSystem.PlayOneShot(clip, position, autoReleaseClip);
audioSystem.PlayOneShot(clip, position);
//简单示例
//在玩家位置播放一次音效
AudioClip clip = ResSystem.LoadAsset<AudioClip>("music");
audioSystem.PlayOneShot(clip,player.transform.position);
//绑定玩家组件播放一次音效(等同于玩家位置)
audioSystem.PlayOneShot(clip,player.transform);
  • clip是音乐片段,音效系统中特效音乐在每次播放时优先从对象池中取出挂载了AudioSource的GameObject实例生成并会在音效播放完成后自动回收。
  • postion是播放的位置,必填。
  • component是绑定的组件,这个API的目的是让音效随着物体移动一起移动,不填则默认不绑定。
  • autoReleaseClip代表是否需要在音乐播放结束后自动释放clip资源,Res和Addressable均可。
  • volumeScle是音乐的音量,不指定默认按最大音量。
  • is3D是启用空间音效,默认开启。
  • callBack是回调事件,会在音效播放完执行一个无参无返回值方法。

使用Compoent绑定播放音效时,如果绑定物体如果在播放中被销毁了,那么AudioSource会提前解绑避免一同被销毁(通过事件工具提前添加监听),之后播放完毕会自动回收。

存档系统

完成对存档的创建,获取,保存,加载,删除,缓存,支持多存档。存档有两类,一类是用户型存档,存储着某个游戏用户具体的信息,如血量,武器,游戏进度,一类是设置型存档,与任何用户存档都无关,是通用的存储信息,比如屏幕分辨率、音量设置等。

alt

存档系统支持两类本地文件:,两者通过框架设置面板进行切换,切换时,原本地文件存档会清空!二进制流文件可读性较差不易修改,Json可读性较强,易修改,存档的数据存在Application.persistentDataPath下。

alt

alt

SaveData和setting分别存储用户存档和设置型存档。

alt

用户存档下根据saveID分成若干文件夹用于存储具体的对象。

设置型存档

设置存档实际就是一个全局唯一的存档,可以向其中存储全局通用数据。

保存设置

//API
//保存设置到全局存档
//(object saveObject, string fileName)
SaveSystem.SaveSetting(saveObject, fileName);
SaveSystem.SaveSetting(saveObject)
//简单示例
//见下一小节结合加载说明
  • saveObject是要保存的对象,System.Object类型。
  • fileName是保存的文件名称,不填默认取saveObject的类型名。

加载设置

///API
//从设置存档中加载设置
// string fileName
SaveSystem.LoadSetting<T>(fileName);
SaveSystem.LoadSetting<T>();


//简单示例
// GameSetting类中存储着游戏名称,作为全局数据
[Serializable]
public class GameSetting
{
    public string gameName;
}
GameSetting gameSetting = new GameSetting();
gameSetting.gameName = "测试";
//保存设置
SaveSystem.SaveSetting(gameSetting);
//取出来用
String gameName = SaveSystem.LoadSetting<gameSetting>().gameName;

删除设置

//API
//删除用户存档和设置存档
SaveSystem.DeleteAll();
  • fileName是加载设置存档的文件名,T限定了所存储的数据类型,不填fileName则默认以T的类型名作为文件名加载。

用户存档

用户存档与具体的用户相关,不同用户存档位置不同,数据也不同,索引为SaveID。

创建用户存档

创建的存档索引默认自增。

//API
SaveSystem.CreateSaveItem();


//简单示例
SaveItem saveItem = SaveSystem.CreateSaveItem();

获取用户存档

存档层面
获取所有用户存档

根据一定规则获取所有用户存档,返回List。

//API
//最新的在最后面
SaveSystem.GetAllSaveItem();
//最近创建的在最前面
SaveSystem.GetAllSaveItemByCreatTime();
//最近更新的在最前面
SaveSystem.GetAllSaveItemByUpdateTime();
//万能解决方案,自定义规则
GetAllSaveItem<T>(Func<SaveItem, T> orderFunc, bool isDescending = false)


//简单示例,万能方案,按照SaveID倒序获得存档
GameSetting gameSetting = new GameSetting();
List<SaveItem> testList = SaveSystem.GetAllSaveItem<int>(oderFunc, true);
//List<SaveItem> testList = SaveSystem.GetAllSaveItem();
foreach (var item in testList)
{
    Debug.Log(item.saveID);
}
//排序依据Func
int oderFunc(SaveItem item)
{
    return item.saveID;
}
  • 提供多种重载方法获取存档List。
  • 支持自定义排序依据的万解决方案,T传比较参数类型,orderFunc传比较方法。
获取某一项用户存档
//API
//(int id, SaveItem saveItem)
SaveSystem.GetSaveItem(id);
SaveSystem.GetSaveItem(saveItem);


//简单示例
SaveItem saveItem = SaveSystem.CreateSaveItem();
SaveSystem.GetSaveItem(saveItem);
  • id是用户存档的编号,存档系统会在创建时指定默认ID,使用时透明,因此推荐使用saveItem传参,saveItem是可维护的。
删除用户存档
删除所有用户存档
//API
//删除所有用户存档
SaveSystem.DeleteAllSaveItem();
删除某一项用户存档
//API
//(int id, SaveItem saveItem)
SaveSystem.DeleteSaveItem(id);
SaveSystem.DeleteSaveItem(saveItem);


//简单示例
SaveItem saveItem = SaveSystem.CreateSaveItem();
SaveSystem.DeleteSaveItem(saveItem);
  • id是用户存档的编号,存档系统会在创建时指定默认ID,使用时透明,因此推荐使用saveItem传参,saveItem是可维护的。

存档对象层面

保存用户存档中某一对象
//API
//(object saveObject, string saveFileName, SaveItem saveItemint, saveID = 0)
SaveSystem.SaveObject(saveObject, saveFileName, saveID);
SaveSystem.SaveObject(saveObject, saveFileName, saveItem);
SaveSystem.SaveObject(saveObject, saveID);
SaveSystem.SaveObject(saveObject, saveItem);


//简单示例
SaveItem saveItem = SaveSystem.CreateSaveItem();
GameSetting gameSetting = new GameSetting();
SaveSystem.SaveObject(gameSetting, saveItem);
  • saveObject是要保存的对象。
  • saveFileName是保存后生成的本地文件名(对象会单独作为一个文件存储在对应saveID的文件夹下),不填则以对象的类型名为文件名。
  • saveID/SaveItem是对象存储的存档。
  • 保存对象时会更新用户存档缓存。
获取用户存档中某一对象
//API
//(string saveFileName, SaveItem saveItem, int saveID = 0)
SaveSystem.LoadObject<T>(saveFileName, saveID);
SaveSystem.LoadObject<T>(saveFileName, saveItem);
SaveSystem.LoadObject<T>(saveID);
SaveSystem.LoadObject<T>(saveItem);


//简单示例
SaveItem saveItem = SaveSystem.CreateSaveItem();
GameSetting gameSetting = new GameSetting();
SaveSystem.SaveObject(gameSetting, saveItem);
GameSetting gameSetting = SaveSystem.LoadObject<GameSetting>(saveItem);
  • T指定获取对象类型。
  • saveFileName是获取对象的文件名,不填则默认以T的类型名作为文件名。
  • saveID/SaveItem是对象存储的存档。
  • 获取对象优先从缓存中读取,不存在则IO读文件获取,并加入缓存。
删除用户存档中某一对象
//API
//(string saveFileName, SaveItem saveItem, int saveID = 0)
SaveSystem.DeleteObject<T>(saveFileName, saveID);
SaveSystem.DeleteObject<T>(saveFileName, saveItem);
SaveSystem.DeleteObject<T>(saveID);
SaveSystem.DeleteObject<T>(saveItem);


//简单示例
SaveItem saveItem = SaveSystem.CreateSaveItem();
GameSetting gameSetting = new GameSetting();
SaveSystem.DeleteObject(gameSetting, saveItem);
GameSetting gameSetting = SaveSystem.DeleteObject<GameSetting>(saveItem);
  • T指定获取对象类型。
  • saveFileName是获取对象的文件名,不填则默认以T的类型名作为文件名。
  • saveID/SaveItem是对象存储的存档。
  • 删除某一对象时,如果存在对应的缓存,则一并删除。
注意

在从用户存档中取出对象时,底层优先从缓存中读取,避免读时IO,使用时无需关注。

序列化字典,vector,color

框架提供了字典的二进制序列化方法以进行存档,给字典包了一层壳,在序列化和反序列化时自动拆分成List存储、组合成Dictionary使用。同时将Color,vector2,vector3单独封装成结构体进行存储,舍弃掉Unity数据类型中自带的额外方法和属性,只保留rgba和xyz坐标。

//API
Vector3 -> Serialized_Vector3
Vector2 -> Serialized_Vector2
Color -> Serialized_Color
Dictionary->Serialized_Dic

在使用时,将原先定义字典等数据的语句关键字进行替换即可,框架重载了赋值运算符,构造函数以及类型转换方法,使得序列化的数据类型可以自动跟原生的Vector2,Vector3,Vector2Int,Vector3Int,Color互转,在使用体验上与原生的关键词无异。

UI框架

UI框架实现对窗口的生命周期管理,层级遮罩管理,按键物理响应等功能,对外提供窗口的打开、关闭、窗口复用API,对内优化好窗口的缓存、层级问题,能够和场景加载、事件系统联动,将Model、View、Controller完全解耦。通过与配置系统、脚本可视化合作,实现新UI窗口对象的快速开发和已有UI窗口的方便接入。

数据结构

虽然本文档使用手册,但为了便于上手理解,简单对UI框架的数据结构进行解释。

    //UI窗口数据字典
    Dictionary<string, UIWindowData> UIWindowDataDic;


    //UI窗口数据类
    public class UIWindowData
    {
        [LabelText("是否需要缓存")] public bool isCache;
        [LabelText("预制体Path或AssetKey")] public string assetPath;
        [LabelText("UI层级")] public int layerNum;
        /// <summary>
        /// 这个元素的窗口对象
        /// </summary>
        [LabelText("窗口实例")] public UI_WindowBase instance;


        public UIWindowData(bool isCache, string assetPath, int layerNum)
        {
            this.isCache = isCache;
            this.assetPath = assetPath;
            this.layerNum = layerNum;
            instance = null;
        }
    }

UI框架的核心在于维护字典UIWindowDataDic,通过windowKey索引了不同的UI窗口数据UiWindowData,其中包含了窗口是否要缓存,资源路径,UI层级,以及窗口类实例(脚本作为窗口对象的组件,持有他就相当于持有了窗口gameObject),UIWindowData可以通过运行时动态加载也可以在Editor时通过特性静态加载,设计windowKey的原因是如果不额外标定windowKey直接用资源路径作为索引,则同一个窗口资源无法复用,换句话说,同一个UI窗口游戏对象及窗口类,通过不同的windowKey和实例可以进行重用。

UI窗口对象及类配置

使用UI框架需要先为UI窗口游戏对象添加控制类,该类继承自UI_WindowBase,并将UI窗口游戏对象加入Addressable列表/Resources文件夹下。

UI窗口特性-Editor静态加载

可以选择为UI窗口类打上UIWindowData特性(Attribute可省略)用于配置数据。
alt

UIWindowDataAttribute(string windowKey, bool isCache, string assetPath, int layerNum){}
UIWindowDataAttribute(Type type,bool isCache, string assetPath, int layerNum){}
  • 特性中windowKey是UI窗口的名字唯一索引,可以直接传string也可以传Type使用其FullName。
  • isCache指明UI窗口游戏对象是否需要缓存重用,true则在窗口关闭时不会被销毁,下次使用时可以通过windowKey调用且不需要实例化。
  • assetPath是资源的路径,在Resources中是UI窗口对象在Resources文件夹下的路径,Addressable中是UI窗口对象的Addressable Name。
  • layerNum是UI窗口对象的层级,从0开始,越大则越接近顶层。
  • 支持一个窗口类多特性,复用同一份窗口类资源,n个特性,则有n份UI窗口数据,本质上对应了多个windowKey,因此windowKey必须不同。

经过配置后,在Editor模式下该UI类特性数据及UI窗口游戏对象(此时还没有实例化为空)会自动保存到GameRoot的配置文件中,即静态加载。

UI窗口运行时动态加载

在运行时动态加载UI窗口,不需要给窗口类打特性,窗口数据直接给出,与Onshow/OnClose不同,其不包含窗口游戏物体对象的显示/隐藏/销毁逻辑。

//API
//(string windowKey, UIWindowData windowData, bool instantiateAtOnce = false)
UISystem.AddUIWindowData(windowKey, windowData, instantiateAtOnce);
UISystem.AddUIWindowData(windowKey, windowData, instantiateAtOnce);
//(Type type, UIWindowData windowData, bool instantiateAtOnce = false)
UISystem.AddUIWindowData(type, windowData, instantiateAtOnce);
UISystem.AddUIWindowData(type, windowData);
UISystem.AddUIWindowData<T>(windowData, instantiateAtOnce);
UISystem.AddUIWindowData<T>(windowData);


//简单实例
UISystem.AddUIWindowData("Test1", new UIWindowData(true, "TestWindow", 1));
//上一步只添加了数据,显示在面板上还需要激活
UISystem.Show<TestWindow>("Test1");
  • 通过泛型T指定UI窗口子类类型,windowKey为UI窗口类的索引,对应UIWindowData中的windowKey,不指定则使用T的类型名作为索引。
  • instantiateAtOnce指明窗口对象及其类是否要进行实例化,默认为null,会在窗口打开时加载资源进行实例化且设置为不激活,若窗口资源较大,可以提前在动态加载时就进行实例化,如图。

alt

UI窗口数据管理

获取UI窗口数据,其中包含UI的windowKey,层级,资源路径,以及对象实例,可以对其进行操作。

//获取UI窗口数据
//(string windowKey) (Type windowType)
UISystem.GetUIWindowData(windowKey);
UISystem.GetUIWindowData<T>();
UISystem.GetUIWindowData(windowType);
//尝试获取UI窗口数据,返回bool
//(string windowKey, out UIWindowData windowData
UISystem.TryGetUIWindowData(windowKey, windowData);
//移除某条UI窗口数据
//(string windowKey, bool destoryWidnow = false)
UISystem.RemoveUIWindowData(windowKey, destoryWidnow);
//清除所有UI窗口数据
UISystem.ClearUIWindowData();


//简单实例
//获取testWindow的层级
UISystem.GetUIWindowData<testWindow>().layerNum;
  • 通过windowKey/泛型类型名/窗口对象类型传索引。
  • 支持Try方式获取窗口数据,成功返回true并将数据赋给输出参数。
  • 移除UI窗口数据,已存在的窗口对象实例会被强行删除。

UI窗口对象管理

这里的UI窗口对象只UI窗口数据UIWIndowData持有的那一份窗口脚本对象实例,其生命周期由框架管理,整体分为打开和关闭。

UI窗口打开

加载UI窗口对象并显示。

//API
//返回值为UI窗口类T,T受泛型约束必须为UI窗口基类子类
//(string windowKey, int layer = -1)
UISystem.Show<T>(windowKey, layer);
UISystem.Show<T>(windowKey);
UISystem.Show<T>(layer);
UISystem.Show<T>();
//返回值为UI_WindowBase类,对应不能确定窗口类型的情况, xx是窗口类的对象
//(Type type, int layer = -1)
UISystem.Show(xx.getType(), layer);
UISystem.Show(xx.getType());
//(string windowKey, int layer = -1)
UISystem.Show(windowKey, layer);
UISystem.Show(windowKey);


//简单实例,打开窗口UI_WindowTest并置于第三层
UISystem.Show<UI_WindowTest>(2);
  • 通过泛型T指定UI窗口子类类型,windowKey为UI窗口类的索引,对应UIWindowData中的windowKey,不指定则使用T的类型名作为索引,layer代表UI的层级,不填则默认-1表示使用数据中原有的层级(通过静态配置或者动态加载指定)。
  • 在明确UI窗口类型的时候可以直接通过泛型T指定,不明确则可以通过传对象反射来获取类型。
  • 简单解释逻辑为根据windowKey找到对应的窗口数据UIWindowData,根据数据中的assetPath加载UI窗口对象并根据T返回窗口类,无T则返回UI_WindowBase类。

由于UI窗口类继承了UIWIndowBase,其中提供了一些可供重写的方法,这些方***在UI窗口打开时自动执行。

    //初始化相关方法,只有在窗口第一次打开时执行
    public override void Init()
    {
        base.Init();
    }


    //窗口每次打开时执行,可用于数初始化,并会自动调用事件监听注册方法
    public override void OnShow()
    {
        base.OnShow();
    }
    //事件监听注册
    protected override void RegisterEventListener()
    {
        base.RegisterEventListener();
    }

UI窗口关闭

//API
//(Type type) (string windowKey)
UISystem.Close<T>();
UISystem.Close(type);
UISystem.Close(windowKey);
UISystem.TryClose(windowKey);
UISystem.CloseAllWindow();


//简单实例,关闭窗口UI_WindowTest
UISystem.Close<UI_WindowTest>();
  • 相比打开,关闭不需要返回值也不需要管理层级,通过T/Type/windowKey传入窗口的索引即可。
    • TryClose API在遇到窗口已关闭或不存在时并不会warning,而其他API会报warning。

由于UI窗口类继承了UIWIndowBase,其中提供了一些可供重写的方法,这些方法在UI窗口关闭时自动执行。

    //窗口每次关闭时执行,会动调用事件监听注销方法
    public override void OnClose()
    {
        base.OnClose();
    }


    //事件监听注销
    protected override void RegisterEventListener()
    {
        base.RegisterEventListener();
    }

获取/销毁UI窗口对象

获取/销毁UIWindowData持有的UI窗口对象实例,与Onshow/OnClose不同,其只获取实例,不包含窗口游戏物体对象的显示/隐藏/销毁逻辑。

//API
//返回值为UI窗口类T,T受泛型约束必须为UI窗口基类子类
//(string windowKey)
UISystem.GetWindow<T>(windowKey);
UISystem.GetWindow<T>(Type windowType);
UISystem.GetWindow<T>();
//返回值为UI_WindowBase类,对应不能确定窗口类型的情况
UISystem.GetWindow(windowKey);
//返回值为bool,表示窗口对是否存在
//(string windowKey, out T window)
UISystem.TryGetWindow(windowKey, window);
//(string windowKey, out T window)
UISystem.TryGetWindow<T>(windowKey, window);
//销毁窗口对象
UISystem.DestroyWindow(windowKey);


//简单实例,获取TestWindow上的UI Text组件Name
Text name = UISystem.GetWindow<TestWindow>().Name;
  • 通过windowKey/type Name/T类型名查找窗口对象。
  • 支持Try方式,查询成功则对象传递到输出参数out上,并返回bool为true,否则输出参数为null并返回false。
  • 销毁窗口对象API会直接销毁游戏内的窗口gameObject、控制类,但UIWindowData还存在。

UI层级管理

框架内部实现了对UI的层级管理,可以在面板的UISystem上每一层是否启用遮罩,默认每一层UI是层层堆叠覆盖的,一旦某一层中有UI窗口对象,则层级比它低的层级都不可以交互,同一层级中比它早打开的UI窗口不可以交互(保证每一层内最顶层只有一个窗口),可以勾选不启用遮罩,则这一层层内和层外都不存在遮罩关系。

启用遮罩如下图。

alt

Mask保证了每一层内最顶层只有一个窗口进行交互。

alt

另外框架单独提供了最顶层dragLayer,用于拖拽时临时需要把某个UI窗口置于最上层,可以通过UISystem.dragLayer获取。

UISystem.dragLayer;

UI Tips

弹窗工具。

//API
// 在窗口右下角弹出字符串tips提醒。
//(string tips)
UISystem.AddTips(tips)

判断鼠标是否在UI上

返回当前鼠标位置是否在UI上,(用于替换EventSystem.current.IsPointerOverGameObject(),避免当前窗口因启用交互或同时需要考虑多层UI的层级关系,而启用覆盖全屏幕的遮罩Mask的RaycastTaret,使得鼠标处于UI窗口外时,Unity API一直错误的返回在UI上)。

//bool
UISystem.CheckMouseOnUI();

日志系统

日志系统用于在控制台输出Log、Success、Error、Warning的提示信息(用白色、绿色、红色、黄色加以区分),并可以进行本地自定义命名保存,可以在面板上勾选是否启用日志输出、写入时间(毫秒级定位)、线程ID、堆栈(定位提示代码行)、本地保存。

alt

保留Unity提示自带的代码连接跳转功能。

alt

本地保存的日志可以用于在打包后进行调试输出。

alt

//API
//输出日志测试信息,等同于Debug.Log
JKLog.Log("测试Log");
//输出Warning类型的提示信息
JKLog.Warning("测试Warning");
//输出Error类型的提示信息
JKLog.Error("测试Error");
//输出Succeed类型的输出信息
JKLog.Succeed("测试Succeed");
  • 在方法参数部分传入要输出的字符串信息即可。

事件工具

用于给游戏对象快速绑定事件,而无需手动给游戏对象挂载脚本,功能逻辑在当前脚本实现。与事件系统区分:事件系统重点在于提供了一个事件监听添加和事件触发解耦的中间模块,使得事件的触发无需关注依赖的对象,但事件执行的功能逻辑还是要实现在对象挂载的脚本上的。而事件工具重点在于快速为游戏对象绑定常见的响应事件,这类事件不由脚本触发(后续支持自定义脚本触发条件),而是在特定的时机如碰撞、鼠标点击、对象销毁时自动触发,因此重点关注事件监听添加的简化,所有逻辑在当前脚本完成。

框架内置事件绑定与移除

鼠标相关事件

鼠标进入、鼠标移出、鼠标点击、鼠标按下、鼠标抬起、鼠标拖拽、鼠标拖拽开始、鼠标拖拽结束事件的绑定与移除。

//鼠标进入
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnMouseEnter<TEventArg>(action, args);
xx.OnMouseEnter(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnMouseEnter(action); //无参Action
xx.RemoveOnMouseEnter<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标移出
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnMouseExit<TEventArg>(action, args);
xx.OnMouseExit(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnMouseExit(action); //无参Action
xx.RemoveOnMouseExit<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标点击
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnClick<TEventArg>(action, args);
xx.OnClick(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnClick(action); //无参Action
xx.RemoveOnClick<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标按下
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnClickDown<TEventArg>(action, args);
xx.OnClickDown(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnClickDown(action); //无参Action
xx.RemoveOnClickDown<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标抬起
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnClickUp<TEventArg>(action, args);
xx.OnClickUp(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnClickUp(action); //无参Action
xx.RemoveOnClickUp<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标拖拽
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnDrag<TEventArg>(action, args);
xx.OnDrag(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnDrag(action); //无参Action
xx.RemoveOnDrag<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标拖拽开始
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnBeginDrag<TEventArg>(action, args);
xx.OnBeginDrag(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnBeginDrag(action); //无参Action
xx.RemoveOnBeginDrag<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//鼠标拖拽结束
//(this Component com, Action<PointerEventData, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnEndDrag<TEventArg>(action, args);
xx.OnEndDrag(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnEndDrag(action); //无参Action
xx.RemoveOnEndDrag<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//使用示例
Transform cube;
void Start()
{
    cube.OnClick<int>(Test1,1);
}
private void Test1(PointerEventData arg1, int arg2)
{
    Debug.Log(1);
    cube.RemoveOnClick<int>(Test1);
}
  • xx为绑定事件的对象组件,事件工具基于拓展方法调用,xx使用游戏对象的transform即可。
  • TEventArg指定事件的参数类型,添加监听时可以不填,可以通过参数args推断出,移除监听时则必须显示指出。
  • action是绑定的事件,根据事件类型(鼠标、碰撞、自定义事件),其方法的参数列表包含两部分,第一部分是事件本身的参数(PointerEventData、Collision),第二部分是参数列表TEventArg,可以通过值元组传入多个参数。

碰撞相关事件

2D、3D相关的碰撞事件绑定与移除。

//API
//3D碰撞进入
//(this Component com, Action<Collision, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnCollisionEnter<TEventArg>(action, args);
xx.OnCollisionEnter(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnCollisionEnter(action); //无参Action
xx.RemoveOnCollisionEnter<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//3D碰撞持续
//(this Component com, Action<Collision, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnCollisionStay<TEventArg>(action, args);
xx.OnCollisionStay(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnCollisionStay(action); //无参Action
xx.RemoveOnCollisionStay<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//3D碰撞脱离
//(this Component com, Action<Collision, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnCollisionExit<TEventArg>(action, args);
xx.OnCollisionExit(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnCollisionExit(action); //无参Action
xx.RemoveOnCollisionExit<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//2D碰撞进入
//(this Component com, Action<Collision2D, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnCollisionEnter2D<TEventArg>(action, args);
xx.OnCollisionEnter2D(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnCollisionEnter2D(action); //无参Action
xx.RemoveOnCollisionEnter2D<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//2D碰撞持续
//(this Component com, Action<Collision2D, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnCollisionStay2D<TEventArg>(action, args);
xx.OnCollisionStay2D(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnCollisionStay2D(action); //无参Action
xx.RemoveOnCollisionStay2D<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//2D碰撞脱离
//(this Component com, Action<Collision2D, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnCollisionExit2D<TEventArg>(action, args);
xx.OnCollisionExit2D(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnCollisionExit2D(action); //无参Action
xx.RemoveOnCollisionExit2D<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//简单示例
void Start()
{
    cube.OnCollisionEnter(Test2, 2);
}


private void Test2(Collision arg1, int arg2)
{
    Debug.Log(arg2);
    cube.RemoveOnCollisionEnter<int>(Test2);
}
  • 碰撞事件和鼠标事件的API类似,区别参与action的第一个事件本身参数不同,为Collision/Collision2D。
  • xx为绑定事件的对象组件,使用游戏对象的transform即可。
  • TEventArg指定事件的参数类型,添加监听时可以不填,可以通过参数args推断出,移除监听时则必须显示指出。
  • action是绑定的事件,根据事件类型(鼠标、碰撞、自定义事件),其方法的参数列表包含两部分,第一部分是事件本身的参数(PointerEventData、Collision),第二部分是参数列表TEventArg,可以通过值元组传入多个参数。

触发相关事件

2D、3D相关的触发事件绑定。

//API
//3D触发进入
//(this Component com, Action<Collider, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnTriggerEnter<TEventArg>(action, args);
xx.OnTriggerEnter(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnTriggerEnter(action); //无参Action
xx.RemoveOnTriggerEnter<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//3D触发持续
//(this Component com, Action<Collider, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnTriggerStay<TEventArg>(action, args);
xx.OnTriggerStay(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnTriggerStay(action); //无参Action
xx.RemoveOnTriggerStay<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//3D触发脱离
//(this Component com, Action<Collider, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnTriggerExit<TEventArg>(action, args);
xx.OnTriggerExit(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnTriggerExit(action); //无参Action
xx.RemoveOnTriggerExit<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//2D触发进入
//(this Component com, Action<Collider2D, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnTriggerEnter2D<TEventArg>(action, args);
xx.OnTriggerEnter2D(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnTriggerEnter2D(action); //无参Action
xx.RemoveOnTriggerEnter2D<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//2D碰撞持续
//(this Component com, Action<Collider2D, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnTriggerStay2D<TEventArg>(action, args);
xx.OnTriggerStay2D(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnTriggerStay2D(action); //无参Action
xx.RemoveOnTriggerStay2D<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//2D触发脱离
//(this Component com, Action<Collider2D, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnTriggerExit2D<TEventArg>(action, args);
xx.OnTriggerExit2D(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnTriggerExit2D(action); //无参Action
xx.RemoveOnTriggerExit2D<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//简单示例


void Start()
{
    cube.OnTriggerEnter(Test3, 2);
}


private void Test3(Collider arg1, int arg3)
{
    Debug.Log(arg3);
    cube.RemoveOnTriggerEnter<int>(Test3);
}
  • 触发事件和碰撞事件的API类似,区别参与action的第一个事件本身参数不同,为Collider/Collider2D。
  • xx为绑定事件的对象组件,使用游戏对象的transform即可。
  • TEventArg指定事件的参数类型,添加监听时可以不填,可以通过参数args推断出,移除监听时则必须显示指出。
  • action是绑定的事件,根据事件类型(鼠标、碰撞、自定义事件),其方法的参数列表包含两部分,第一部分是事件本身的参数(PointerEventData、Collision),第二部分是参数列表TEventArg,可以通过值元组传入多个参数。

资源相关事件

资源释放,对象销毁时绑定的事件。

//API
//资源释放(Addressable)
//(this Component com, Action<GameObject, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnReleaseAddressableAsset<TEventArg>(action, args);
xx.OnReleaseAddressableAsset(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnReleaseAddressableAssetOnReleaseAddressableAsset(action); //无参Action
xx.RemoveOnReleaseAddressableAsset<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//对象销毁
//(this Component com, Action<GameObject, TEventArg> action, TEventArg args = default(TEventArg))
xx.OnDestroy<TEventArg>(action, args);
xx.OnDestroy(action, args); //指定参数类型的泛型可以不填,可以通过参数推断出
xx.OnDestroy(action); //无参Action
xx.RemoveOnDestroy<TEventArg>(action) //Remove时不传参,参数类型必须传,无法推断


//简单实例
void Start()
{
    cube.OnDestroy(Test4, 4);
}
private void Test4(GameObject arg1, int arg4)
{
    Debug.Log(arg4);
    cube.RemoveOnDestroy<int>(Test4);
}
  • xx为绑定事件的对象组件,使用游戏对象的transform即可。
  • TEventArg指定事件的参数类型,添加监听时可以不填,可以通过参数args推断出,移除监听时则必须显示指出。
  • action是绑定的事件,根据事件类型(鼠标、碰撞、自定义事件),其方法的参数列表包含两部分,第一部分是事件本身的参数(PointerEventData、Collision),第二部分是参数列表TEventArg,可以通过值元组传入多个参数。

移除一类事件

移除鼠标/碰撞/触发/资源的一类所有事件。

//API
//int customEventTypeInt, JKEventType eventType
RemoveAllListener(customEventTypeInt);
RemoveAllListener(eventType);
RemoveAllListener();
  • customEventTypeInt/eventType为事件的类型,对应碰撞、鼠标事件对应的枚举类型或自定义事件的类型int值。
  • 不填则移除所有事件。

使用值元组传递多个事件参数

通过ValueTuple封装一个简单的参数列表结构体。

    void Start()
    {
        cube.OnClick(Test5, (arg1: 2, arg2: "test", arg3: true));
        //等同于下一行代码,上一行更简便,参数类型可以自动推断出
        cube.OnClick(Test5, ValueTuple.Create<int,string,bool>(1,"test",true));
    }


    private void Test5(PointerEventData arg1, (int arg1, string arg2, bool arg3) args)
    {
        Debug.Log($"{args.arg1},{args.arg2},{args.arg3}");
        cube.RemoveOnClick<(int arg1, string arg2, bool arg3)>(Test5);
    }

自定义事件类型

以上鼠标、碰撞等事件的触发由事件工具结合特定的时机自动完成,如果希望自定义事件的触发逻辑,则需要添加新的事件类型,对应在适合的地方触发事件,此时事件工具的作用与事件系统类似,区别在于不需要为对象挂载脚本。

//API
//(this Component com, int customEventTypeInt, Action<T, TEventArg> action, TEventArg args = default(TEventArg))
xx.AddEventListener<T, TEventArg>(customEventTypeInt, action, args);
xx.RemoveEventListener<T, TEventArg>(customEventTypeInt, action);
cube.TriggerCustomEvent<Transform>((int)myType.CustomType, transform);


//使用示例
    void Start()
    {
        cube.AddEventListener<Transform, int>((int)myType.CustomType, Test6, 1);
    }


    private void Test6(Transform arg1, int arg2)
    {
        Debug.Log(arg1.position = Vector3.zero);
        cube.RemoveEventListener<Transform, int>((int)myType.CustomType, Test6);
    }
    enum myType
    {
        CustomType = 0,
    }
  • customEventTypeInt是自定义的事件类型,是一个int值,可以使用枚举对应事件的类型。
  • T指明了自定义事件所使用的eventData,可以在触发的时候传入T以供使用,等同于Collision/Collider/PointerEventData。
  • args是参数列表。

补充说明

  • 事件工具针对的事件触发时都会提供eventData用于获取触发时的特定数据用于操作(有点类似于异步callback回调时传的那个参数),比如PointerEventData,因此他们与对象绑定,就算不传任何参数,触发时还是可以根据eventData去获取一些信息,比如碰撞发生的位置。
  • 开发事件工具的目的在于快速为游戏对象添加一类事件的监听,而不需要为其手动挂载脚本(类似于button.OnClick.AddEventListener,但Unity只支持按钮的自动添加,而事件工具支持常见的所有事件类型),实际上会自动为其自动挂载JKEventListener脚本,其中有对应事件的监听方法以及内置碰撞/鼠标等事件的触发方法。
  • 自定义事件类型是支持的,但此时事件的类型,触发需要自己实现。
  • 事件系统作用在于解耦对象和事件触发的逻辑,让事件中心保存监听的方法,触发时不需要访问对象。而事件工具所负责的是一类与对象强关联的事件,用于解耦对象和事件监听添加的逻辑,不需要手动挂载脚本。二者联动的效果就是A使用事件工具直接为B添加事件监听C,C内部再通过事件系统包一层添加事件监听D,这样外界就可以通过直接访问事件中心触发D(可能的应用场景比如要给所有子弹添加碰撞分解效果,这样无论是事件监听添加还是事件的触发都可以在一个脚本中完成,不需要手动给所有子弹度挂载脚本,也不需要触发时访问所有子弹对象)。

状态机

游戏中的状态机(state machine)是一种在编程中常用的概念,它用于表示对象或系统的状态以及从一个状态到另一个状态的转换。在游戏中,状态机通常被用于表示游戏对象的状态,例如玩家角色的行动状态,或者敌人的攻击状态。每个状态都有一个特定的行为或属性,而状态之间的转换通常是由特定的事件触发的,例如按下某个按钮或达到某个条件。框架中提供了状态基类和转换功能逻辑。

状态机的初始化

使用状态机,本质就是持有状态机脚本的引用与其绑定,为此控制脚本(比如角色控制器PlayerController)需要持有状态机对象,其也是一类脚本资源,可以通过资源系统进行回收管理。

//API
stateMachine.Init<T>(owner);
stateMachine.Init(owner);
//简单实例
public class PlayerController : MonoBehaviour, IStateMachineOwner
{
    StateMachine stateMachine;
    private void Start()
    {
        stateMachine = ResSystem.GetOrNew<StateMachine>();
        //初始化时进入默认状态Idle
        stateMachine.Init<PlayerIdleState>(this);
  
        //初始化时不进入默认状态
        stateMachine.Init(this);
    }
}
  • PlayerController需要继承IStateMachineOwner接口,目的是限制Init中填入的对象必须为接口子类;
  • 宿主owner用于将PlayerController作为引用传递给stateMachine,因为stateMachine不继承MonoBehavior,想要获取PlayerController的引用相对麻烦,所以直接传递引用。
  • T(PlayerIdleState)是初始的状态类,使用参数时会自动进入T状态,不填则状态机待机,等待进入新状态。

状态类

每一个状态实际都是一个状态类脚本,状态机通过切换调用其中的方法完成状态逻辑的切换,状态类脚本继承自状态基类StateBase,包含状态的初始化、进入、退出、Unity生命周期函数(虽然不继承MonoBehaviour,通过托管系统可以实现),初始化只执行一次,可以通过StateMachine调用state的Init方法传递过来的owner获取宿主的信息,比如在角色移动的相关状态中就可以获取Player的Transform组件。

public class PlayerIdleState:StateBase
{
    Transform transform;
    public override void Init(IStateMachineOwner owner)
    {
        transform = ((PlayerController)owner).transform;
    }


    public override void Enter()
    {
        base.Enter();
    }
    public override void Exit()
    {
        base.Exit();
    }
    public override void Update()
    {
        base.Update();
    }
    public override void FixedUpdate()
    {
        base.FixedUpdate();
    }


    public override void LateUpdate()
    {
        base.LateUpdate();
    }
}

状态的切换

在切换状态时,状态类会先被获取并存储起来供下次重复使用,当前状态的所有方法停止工作,切换到新状态的方法执行,在实际使用时,可以再封装一层ChangeState逻辑使用枚举与不同的状态类对应,简化代码。

//API
//(bool reCurrstate = false)
stateMachine.ChangeState<T>(reCurrstate)
  • reCurrstate指当前状态和要切换的状态相同时是否还要切换,默认为False相同状态不执行切换。

状态共享数据

为owner-stateMachine下的所有状态提供共享字典。

public void ShareData()//状态类内
{
    //(string key, object data)
    RemoveShareData("test");
    ContainsShareData("test");
    AddShareData("test",1);
    int result = 0;
    TryGetShareData<int>("test", out result);
    UpdateShareData("test", 1);
    CleanShareData();
  
    stateMachine.CleanShareData();
  
}
  • key,data是需要共享的字典数据,提供CRUD的API,共享数据可以在状态类内使用,也可以通过stateMachine调用API进行CRUD。

状态机状态清空与销毁

  • 状态机停止工作Stop时会清空所保存的所有状态类放入对象池,状态机本身与宿主的引用仍旧保留,可供下次直接使用。
  • 状态机销毁时Destroy除停止工作外会释放与宿主的引用关系,并将自己放回对象池回收利用。
stateMachine.Stop();
stateMachine.Destroy();

场景系统

场景系统提供了正常加载场景和异步加载的若干API。

正常加载场景

//API
//(string sceneName, LoadSceneMode mode = LoadSceneMode.Single)
SceneSystem.LoadScene(sceneName, LoadSceneMode mode = LoadSceneMode.Single);
//(int sceneBuildIndex, LoadSceneMode mode = LoadSceneMode.Single)
SceneSystem.LoadScene(sceneBuildIndex, LoadSceneMode mode = LoadSceneMode.Single);
//(string sceneName, LoadSceneParameters loadSceneParameters)
SceneSystem.LoadScene(sceneName, loadSceneParameters);
//(int sceneBuildIndex, LoadSceneParameters loadSceneParameters)
SceneSystem.LoadScene(sceneBuildIndex, loadSceneParameters);


//使用实例 加载SampleScene场景并Destroy当前场景
SceneSystem.LoadScene("SampleScene",LoadSceneMode.Single);
  • sceneName对应BuildSetting中的场景名。
  • sceneBuildIndex对应BuildSetting中的场景索引号。
  • mode是场景的加载模式,默认为Single表示加载新场景会销毁当前场景,Additive则保留当前场景,将新场景加入到当前场景中。
  • loadSceneParameters是场景加载的参数,除可指定加载模式外,还可指定优先级等,具体如图。

alt

异步加载场景

异步加载场景可在加载大规模场景时不阻塞主线程,而是通过协程或回调等方式在后台加载场景资源,并在加载完成后通知游戏主线程。

异步加载过程中主线程获取进度的方式有两种:

  • 场景系统异步加载时会将加载进度传递到事件中心中,可以通过监听"LoadingSceneProgress"、"LoadSceneSucceed"事件获取加载进度。
  • 异步加载提供了回调事件参数,可以通过传入回调函数获取加载进度。
//API
//(string sceneName, Action<float> callBack = null, LoadSceneMode mode = LoadSceneMode.Single)
SceneSystem.LoadSceneAsync(sceneName, callBack, mode);
//(int sceneBuildIndex, Action<float> callBack = null, LoadSceneMode mode = LoadSceneMode.Single)
SceneSystem.LoadSceneAsync(sceneBuildIndex, callBack, mode);


//简单实例 异步加载场景SampleScene并实时输出加载进度,在加载完成时输出Success
//方式1 监听事件获取加载进度
SceneSystem.LoadSceneAsync("SampleScene");
//(float不写也行,"LoadingSceneProgress"、"LoadSceneSucceed"为固定名称)
EventSystem.AddEventListener<float>("LoadingSceneProgress", LoadProgress);
EventSystem.AddEventListener("LoadSceneSucceed");


//方式2 传入回调事件callBack
SceneSystem.LoadSceneAsync("SampleScene", LoadProgress);
EventSystem.AddEventListener("LoadSceneSucceed");


void LoadProgress(float progress)
{
    Debug.Log(progress);
}
void LoadSceneSucceed()
{
    Debug.Log("Success");
}
  • sceneName对应BuildSetting中的场景名。
  • sceneBuildIndex对应BuildSetting中的场景索引号。
  • mode是场景的加载模式,默认为Single表示加载新场景会销毁当前场景,Additive则保留当前场景,将新场景加入到当前场景中。
  • callBack是float参数无返回值回调事件,用于获取加载进度。

Mono代理系统

Mono代理系统用于不继承MonoBehavior的脚本启用mono生命周期函数和协程,比如状态机里的状态类,场景系统异步加载时的协程,除代理系统外框架中的各系统都是静态工具类,需要使用Mono的相关方法则通过代理系统完成,因此也只有MonoSystem挂载在面板上,其内部实现是单例。

alt

Mono生命周期函数

将需要在Update、LateUpdate。FixedUpdate实际执行的逻辑托管给MonoSystem。

//(Action action)
MonoSystem.AddUpdateListener(action);
MonoSystem.RemoveUpdateListener(action);
MonoSystem.AddLateUpdateListener(action);
MonoSystem.RemoveLateUpdateListener(action);
MonoSystem.AddFixedUpdateListener(action);
MonoSystem.RemoveFixedUpdateListener(action);
  • action是要在生命周期执行的无参无返回值方法。

协程

启动/停止协程。

//API
//启动/停止一个协程
//(IEnumerator coroutine)
MonoSystem.Start_Coroutine(coroutine);
//(Coroutine routine)
MonoSystem.Stop_Coroutine(routine);


//启动/停止一个协程序并且绑定某个对象
//(object obj,IEnumerator coroutine)
MonoSystem.Start_Coroutine(obj, coroutine);
//(object obj,Coroutine routine)
MonoSystem.Stop_Coroutine(obj, routine);


//停止某个对象绑定的所有协程
MonoSystem.StopAllCoroutine(obj);


//停止所有协程
MonoSystem.StopAllCoroutine();
  • coroutine是一个迭代器,定义了协程。
  • routine是要停止的协程。
  • obj是与协程绑定的对象,可以用于区分不同对象上的相同协程。

协程工具

提前new好协程所需要的WaitForEndOfFrame、WaitForFixedUpdate、YieldInstruction类的对象,避免GC。

CoroutineTool.WaitForEndOfFrame();
CoroutineTool.WaitForFixedUpdate();
//(float time)
CoroutineTool.WaitForSeconds(time);
//(float time)不受TimeScale影响
CoroutineTool.WaitForSecondsRealtime(time);
//(int count=1)
CoroutineTool.WaitForFrames(count);
CoroutineTool.WaitForFrame();


//使用示例
private static IEnumerator DoLoadSceneAsync(...)
{
    yield return CoroutineTool.WaitForFrame();
}

扩展方法

框架提供了若干扩展方法用于快速调用与对象强关联的系统方法。

//API
//比较两个对象数组,返回bool
//(this object[] objs, object[] other)
xx.ArraryEquals(other);


//调用MonoSystem添加/移除生命周期函数
//(this object obj, Action action)
xx.AddUpdate(action);
xx.removeUpdate(action);
xx.AddLateUpdate(action);
xx.RemoveLateUpdate(action);
xx.AddFixedUpdate(action);
xx.RemoveLateUpdate(action);


//调用MonoSystem启动/停止协程(绑定此对象)
//(this object obj, IEnumerator routine)
xx.StartCoroutine(routine);
//(this object obj, Coroutine routine)
xx.StopCoroutine(routine);
xx.StopAllCoroutine();


//判断GameObject是否为空,返回bool
xx.IsNull();


//当前对象放入对象池
//(this GameObject go)
xx.GameObjectPushPool();
//(this Component com)
xx.GameObjectPushPool();
//(this object obj)
xx.ObjectPushPool();

本地化系统

用于切换不同语言对应的文字素材和图片素材,主要用于UI。

本地化配置文件的创建

project面板右键创建Localzation Config,通过SO的方式记录语言配置,红色框为一类素材的key,对应下属若干不同语言的素材,支持文字string或者图片Sprite内容,使用时通过调用这里的资源进行切换,可以作为全局或者专属于某一UI对象的本地化配置文件(即持有此config的SO并通过key和languagetype获取本地化内容)。

alt

全局配置

alt

将创建的Config拖拽给JKFrame下的LocalizationSystem组件,全局的本地化配置绑定完成,通过修改LocalizationSystem的LanguageType修改语言。(可以在运行时下修改全局配置)

API

//切换全局配置的当前语言类型(面板上显示的那个)
LocalizationSystem.LanguageType = LanguageType.SimplifiedChinese;
//注册/注销语言更新时触发的事件(含有LanguageType参数的无返回值方法)
LocalizationSystem.RegisterLanguageEvent(Action<LanguageType> action)
LocalizationSystem.UnregisterLanguageEvent(Action<LanguageType> action)
//获取全局配置文件的某一语言下的数据(文本/图片)
LocalizationSystem.GetContent<T>(string key, LanguageType languageType)
//继承UIWindowBase的窗口脚本可以重写语言更新事件
override void OnUpdateLanguage(LanguageType languageType)


//使用案例
//1.通过脚本直接获得全局本地化配置的数据内容
//文本string
string info = LocalizationSystem.GetContent<LocalizationStringData>("标题", LanguageType.SimplifiedChinese).content;//指定中文
string info = LocalizationSystem.GetContent<LocalizationStringData>("标题", LocalizationSystem.LanguageType).content;//当前全局本地化配置的语言
//Sprite图片
Sprite image = LocalizationSystem.GetContent<LocalizationImageData>("标题图片", LanguageType.SimplifiedChinese).content;


//2.通过Collecter由拖拽的方式绑定UI组件和语言配置(见下一小节)
//3.通过重写OnUpdateLanguage定制语言更新时的事件触发(见下一小节)
   
  • action是一个含有languageType的单参数无返回值方法,用于结合传入的语言类型定制触发事件逻辑。
  • 泛型T(LocalizationStringData/LocalizationImageData)用于限定GetContent返回的数据类型,目前支持string和Sprite,可以进行拓展。
  • key是本地化配置文件SO中的数据key。
  • LocalizationSystem.LanguageType是当前游戏的语言类型,修改会触发索引中的语言更新方法,进而触发所有窗口的语言更新事件修改语言类型。

UI特化工具及局部配置

alt

  • 在UI框架中继承UIWindowBase的窗口类会自动持有一个本地化配置A用于窗口的局部配置(可用可不用,只是提供了一个数据传入的接口)。
  • 方便起见,,直接通过面板拖拽的方式转递对象和其对应的配置数据key(任一语言即可),即完成了本地化配置,无需通过脚本访问(比如Title文本组件对应配置中的标题key)。注意,此时的Localization Config是一个专属于此UI的局部配置文件(且与持有的本地化配置A可以不同)。

PS:当局部配置找不到对应的key时,底层规定会去全局本地化配置表里寻找。

alt

集成了UI_WindowBase的UI窗口类可以通过重写OnUpdateLanguage方法定制语言更新时的事件触发,比如文字拼接部分更新。

    public Text test;
    protected override void OnUpdateLanguage(LanguageType languageType) {
        string info = LocalizationSystem.GetContent<LocalizationStringData>("标题", languageType).content;
        info += "test";
        test.text = info;
    }

拓展

尽管本地化系统目前仅支持文字和Sprite的切换,但是对于音效,配音等资源的切换也可以很方便拓展,这部分功能就不做预制了,由开发者自行拓展,以下是拓展思路。

//拓展API
//获取全局配置文件的某一语言下的数据(文本/图片)
LocalizationSystem.GetContent<T>(string key, LanguageType languageType)


//拓展位置  LocalizationData.cs
public abstract class LocalizationDataBase
{
}
public class LocalizationStringData : LocalizationDataBase
{
    public string content;
}
public class LocalizationImageData : LocalizationDataBase
{
    public Sprite content;
}
  • 在本地化系统的内部实现中,GetContent的泛型参数T指定了本地化保存的数据类型,内置了LocalizationStringData和LocalIzationImageData两种数据类型,分别持有string成员和Sprite成员,对应文本和图片数据。
  • 有两种修改思路,一种是在已有的两个个数据类型添加额外的数据成员,比如一个武器在UI上的显示除了有描述内容还有武器的类型string。
public class LocalizationStringData : LocalizationDataBase
{
    public string content;
    public string type;
}
  • 另外一种是直接继承抽象类LocalizationDataBase写一个新类,比如还是武器类型和武器描述的UI本地化数据,其实两种方式本质也没啥区别,只是说明数据类数量和类内的数据成员都可以扩展满足开发者想要的需求。
public class LocalizationWeaponData : LocalizationDataBase
{
    public string content;
    public string type;
}

浅入理解流式SSR的性能收益与工作原理

什么是流式 SSR

流式 SSR(Streaming Server-Side Rendering)是一种将服务端渲染和流式传输结合起来的技术。与传统的 SSR 不同,流式 SSR 可以在服务端渲染的同时,逐步将渲染结果传输到客户端,实现页面的渐进式展示。

在流式 SSR 中,服务端会根据客户端的请求,逐步生成页面内容,并将它们作为流式数据流式传输到客户端。客户端可以在接收到一部分数据后,就开始逐步显示页面,而不需要等待整个页面渲染完成。这种方式可以有效提高页面的加载速度和用户体验。

20251111002406.jpg (流式 SSR 的页面加载过程)

使用流式 SSR 的收益

  • 减少设备和网络情况的副作用

    这是 SSR 渲染模式相比于传统 CSR 渲染模式带来的优势,对于流式 SSR 应用同样适用。 CSR 渲染模式需要在终端设备上完成资源加载、数据加载以及整个渲染过程,受端侧设备自身 CPU 及网络性能影响大,如设备 CPU 性能不足或网络波动较大,则此时整个页面加载性能将严重下降,这也是为什么很多页面在高端设备上加载速度较快,但在低端设备上需要7-8秒的原因。

    而 SSR 的渲染模式,则在服务端提供了高性能的渲染容器及网络环境,服务端的渲染,不受端侧设备 CPU 或网络影响,始终提供稳定的渲染性能表现

    fa9a1944-b6b4-4e22-980a-47bf604c2e2c.png

  • 减少接口过慢对首屏性能的影响

    通常,页面会由多个区块组成,其中一些区块不依赖于数据,而其他区块可能依赖于快速或缓慢响应的接口。

    现有 SSR 渲染模式存在的一个不足是,整个渲染过程是同步的,需要在页面渲染之前完成所有数据请求,并一次性返回整个页面的 HTML。如果页面的某些接口响应过慢,将会导致整个页面的响应时间过长。

    流式渲染的最大好处在于它可以分块返回页面内容。例如,当请求进入时,它可以首先完成页面静态内容的渲染并响应给端侧进行渲染,等待其他依赖于数据的区块完成渲染后再分块返回。这样整个页面的渲染过程不再绑定在一起,而是一个异步的过程,先完成渲染的部分将先返回,从而优化了页面响应速度。

  • 提前资源的加载时机

    流式 SSR 相比传统 SSR 应用有另一个额外的收益是,由于 HTML 可以分块返回,页面的资源信息可以随第一个 HTML 片段一起下发,从而尽快开始加载。相比之下,在传统 SSR 应用中,资源信息必须等待整个 HTML 完成渲染后下发,请求开始的时机会受到渲染过程的阻塞。

    采用流式 SSR,可以使资源请求和页面渲染过程并行进行,进一步提升了页面的性能表现。

    752472f2-cf39-4c89-9738-e4ad3e084ba9.png

  • 提升页面的可交互时间

    在 SSR 渲染模式下,将页面节点达到可交互状态的过程称为 Hydrate,它需要在端侧执行 JavaScript。

    由于页面资源可以提前下发,并且 React 18 对 Hydrate 进行了异步化处理,在流式 SSR 应用中,可以进一步实现先渲染的页面先达到可交互状态的效果。对于部分首屏接口较慢的应用,这将进一步提升页面的可交互体验。

    20251111002828.jpg

基本工作原理

流式 SSR 的实现,最基本的原理是:

  • 基于 HTTP 协议中的 chunked 编码规范,设置响应头的 Transfer-Encoding 为 chunked 对 HTML 内容进行分块传输。
  • 在浏览器侧,流式地读取数据并进行渲染,这是主流浏览器默认支持的。

结合 Node.js 内置的 HTTP 模块,实现一个最简单的流式 DEMO 示例如下:

const http = require('http');

const server = http.createServer(async (req, res) => {
  res.setHeader('Content-Type', 'text/html');
  res.setHeader('Transfer-Encoding', 'chunked')

  // 分区块的传输页面内容
  res.write('<html>');
  res.write('<head><title>Stream Demo</title><head>');
  res.write('<body>');

  // 模拟服务端暂停
  await sleep(3000);
  res.write('<h2>Hello</h2>');

  await sleep(3000);
  res.write('<h2>ICE 3</h2>');
  res.write('</body></html>');
  res.end();
});

server.listen(3000);

基于这个基本原理,将页面分为骨架屏和几个区块,并行地渲染这些区块,然后将渲染好的区块分段返回,就可以实现基本的流式 SSR 。

WebView 接收流式Chunk渲染的实现原理(iOS)

核心组件

iOS WebView 接收流式 Chunk 渲染基于 NSURLProtocol 拦截机制 + NSURLProtocolClient 回调机制 实现。

NSURLProtocol(请求拦截器)

// 拦截 WebView 的网络请求
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    return [self shouldInterceptRequest:request];
}

- (void)startLoading {
    // 转发给自定义处理器
    [[SSRHandler shareInstance] sendRequest:self.request delegate:self];
}

NSURLProtocolClient(数据传递桥梁)

// 系统提供的协议,用于向 WebView 传递数据
@protocol NSURLProtocolClient
- (void)URLProtocol:didReceiveResponse:cacheStoragePolicy:;  // 响应头
- (void)URLProtocol:didLoadData:;                           // 数据块(可多次)
- (void)URLProtocolDidFinishLoading:;                       // 完成
- (void)URLProtocol:didFailWithError:;                      // 错误
@end

渲染流程

  • 阶段一:请求拦截

    WebView 发起请求 → NSURLProtocol 拦截 → 转发给 SSR 处理器
    
  • 阶段二:响应处理

    // 1. 返回响应头
    [client URLProtocol:protocol didReceiveResponse:response cacheStoragePolicy:policy];
    
    // 2. 流式返回数据(关键步骤)
    [client URLProtocol:protocol didLoadData:chunk1];  // 第1块
    [client URLProtocol:protocol didLoadData:chunk2];  // 第2块
    [client URLProtocol:protocol didLoadData:chunkN];  // 第N块
    
    // 3. 标记完成
    [client URLProtocolDidFinishLoading:protocol];
    
  • 阶段三:WebView 渲染

    每次 didLoadData 调用 → WebView 增量解析 HTML → 实时渲染到页面
    

数据流图示如下

┌─────────────┐    ┌──────────────┐    ┌─────────────┐
│   WebView   │───▶│NSURLProtocol │───▶│ SSR Handler │
│             │    │              │    │             │
│             │◀───│              │◀───│             │
└─────────────┘    └──────────────┘    └─────────────┘
       ▲                   │
       │                   ▼
   增量渲染          NSURLProtocolClient
       ▲                   │
       │                   ▼
   ┌─────────────────────────────┐
   │     didLoadData (chunk1)    │
   │     didLoadData (chunk2)    │
   │     didLoadData (chunkN)    │
   │  URLProtocolDidFinishLoading │
   └─────────────────────────────┘
❌