普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月23日首页

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

2025年11月23日 21:58

前言

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

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画图

作者 wwwzhouhui
2025年11月23日 21:26

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篇

2025年11月23日 21:03

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)

作者 小满zs
2025年11月23日 21:00

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年11月23日 19:23

想获取更多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脚手架搭建(六)【登录篇】

作者 寻找光_sxy
2025年11月23日 18:19

前言

上一篇我们介绍了在脚手架中配置代码格式规范、代码提交规范: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

作者 Tzarevich
2025年11月23日 16:35

深入理解 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

作者 Zyx2007
2025年11月23日 16:34

在 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解决方案:在线编辑/导入导出/权限切换(已开源)

作者 Electrolux
2025年11月23日 16:32

效果展示

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

🌐 在线演示: 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

作者 一千柯橘
2025年11月23日 16:31

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 架构全解析:从概念到落地的完整指南

作者 Wect
2025年11月23日 16:06

一、什么是 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 挠头?看完这篇让你彻底从懵圈到精通

2025年11月23日 16:00

前言

各位 前端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,让你的代码更上一层楼吧!

Re: 0x03. 从零开始的光线追踪实现-多球体着色

作者 壕壕
2025年11月23日 15:18

目标

上一节 已经实现一个球显示在窗口中央,这节的目标是显示多个球。

本节最终效果

image.png

先显示两个球

我们先来想像现实场景,假设你桌子有一个有显示器,此时你举起手机录屏,你能很直观认识到手机离你更近,显示器离你更远,你的眼睛就是那个摄像机,它发出的射线,肯定是先到手机,再到显示器。
现在我们代码做得事情就是,球就是“手机”,背景(天空)就是“显示器”,通过 intersect_sphere 我们可以计算出把“显示器”挡住的“手机”。

回到之前的代码,只显示一个球,也就是满足光线跟球相交时,就告诉 fragment shader 这里要应该显示某个颜色

if (intersect_sphere(ray, sphere) > 0) {
  return vec4f(1, 0.76, 0.03, 1);
}

现在我们要显示两个球,所以先弄一个数组。需要注意到,这里用 constant 是因为 MSL(Metal Shading Language)规定 Program scope variable must reside in constant address space(程序作用域的变量,必须放在常量地址空间),总之就是你要是写个函数外的常量,那就用 constant 把它放到常量地址空间去。

constant u32 OBJECT_COUNT = 2;

constant Sphere scene[OBJECT_COUNT] = {
  { .center = vec3f(0., 0., -1.), .radius = 0.5 },
  { .center = vec3f(0., -100.5, -1.), .radius = 100. },
};

声明结束后,在 fragment shader 函数内循环匹配光线相交。
我们把离咱们最近的值定义为 closest_t,初始值给个 Metal 内置的常量 FLT_MAX,它表示 float 的最大值(因为我们用了 float 类型),然后循环通过调用 intersect_sphere 计算的值 t 去更新 closest_t(因为 intersect_sphere 没匹配到会返回 -1,所以很显然我们要判断 t > 0.,同时要再判断下这个 t 是比已知最近的还要近的值,也就是要满足 t < closest_t)。

fragment vec4f fragmentFn(Vertex in [[stage_in]], constant Uniforms &uniforms [[buffer(1)]]) {
  // ...
  let ray = Ray { origin, direction };
  var closest_t = FLT_MAX;
  for (u32 i = 0; i < OBJECT_COUNT; ++i) {
    var t = intersect_sphere(ray, scene[i]);
    if (t > 0. && t < closest_t) {
      closest_t = t;
    }
  }
  if (closest_t < FLT_MAX) {
    return vec4f(1, 0.76, 0.03, 1);
  }
  return vec4f(sky_color(ray), 1);
}

于是就会显示

image.png

改颜色

这里因为我们设置的颜色是相同,所以连在一块根本分不清哪跟哪,所以我们可以让离得近得更亮,离得远的更暗,给原先设置的颜色再乘上一个值,saturate 这个是 MSL 内置的函数,作用是把小于 0 的转成 0,大于 1 的转成 1,在 [0,1][0, 1] 范围内的不变,等于讲,大的就乘多一点,小的就乘少一点,符合近得更亮,远得更暗的要求

fragment vec4f fragmentFn(Vertex in [[stage_in]], constant Uniforms &uniforms [[buffer(1)]]) {
  // ...
  let ray = Ray { origin, direction };
  var closest_t = FLT_MAX;
  for (u32 i = 0; i < OBJECT_COUNT; ++i) {
    var t = intersect_sphere(ray, scene[i]);
    if (t > 0. && t < closest_t) {
      closest_t = t;
    }
  }
  if (closest_t < FLT_MAX) {
    return vec4f(1, 0.76, 0.03, 1) * saturate(1. - closest_t);
  }
  return vec4f(sky_color(ray), 1);
}

现在我们能看到这个效果

image.png

实现目标效果

其实到这一步,只是换个颜色,为了实现目标效果,我们直接用 closest_t 作为基础值,在它的基础上转成颜色向量

if (closest_t < FLT_MAX) {
  return vec4f(saturate(closest_t) * 0.5);
}

这样就能实现最终效果


最后总结一下代码

#include <metal_stdlib>

#define let const auto
#define var auto

using namespace metal;

using vec2f = float2;
using vec3f = float3;
using vec4f = float4;

using u8 = uchar;
using i8 = char;
using u16 = ushort;
using i16 = short;
using i32 = int;
using u32 = uint;
using f16 = half;
using f32 = float;
using usize = size_t;

struct VertexIn {
  vec2f position;
};

struct Vertex {
  vec4f position [[position]];
};

struct Uniforms {
  u32 width;
  u32 height;
};

struct Ray {
  vec3f origin;
  vec3f direction;
};

struct Sphere {
  vec3f center;
  f32 radius;
};

constant u32 OBJECT_COUNT = 2;
constant Sphere scene[OBJECT_COUNT] = {
  { .center = vec3f(0., 0., -1.), .radius = 0.5 },
  { .center = vec3f(0., -100.5, -1.), .radius = 100. },
};

f32 intersect_sphere(const Ray ray, const Sphere sphere) {
  let v = ray.origin - sphere.center;
  let a = dot(ray.direction, ray.direction);
  let b = dot(v, ray.direction);
  let c = dot(v, v) - sphere.radius * sphere.radius;
  let d = b * b - a * c;
  if (d < 0.) {
    return -1.;
  }
  let sqrt_d = sqrt(d);
  let recip_a = 1. / a;
  let mb = -b;
  let t = (mb - sqrt_d) * recip_a;
  if (t > 0.) {
    return t;
  }
  return (mb + sqrt_d) * recip_a;
}

vec3f sky_color(Ray ray) {
  let a = 0.5 * (normalize(ray.direction).y + 1);
  return (1 - a) * vec3f(1) + a * vec3f(0.5, 0.7, 1);
}

vertex Vertex vertexFn(constant VertexIn *vertices [[buffer(0)]], uint vid [[vertex_id]]) {
  return Vertex { vec4f(vertices[vid].position, 0, 1) };
}

fragment vec4f fragmentFn(Vertex in [[stage_in]], constant Uniforms &uniforms [[buffer(1)]]) {
  let origin = vec3f(0);
  let focus_distance = 1.0;
  let aspect_ratio = f32(uniforms.width) / f32(uniforms.height);
  var uv = in.position.xy / vec2f(f32(uniforms.width - 1), f32(uniforms.height - 1));
  uv = (2 * uv - vec2f(1)) * vec2f(aspect_ratio, -1);
  let direction = vec3f(uv, -focus_distance);
  let ray = Ray { origin, direction };
  var closest_t = FLT_MAX;
  for (u32 i = 0; i < OBJECT_COUNT; ++i) {
    var t = intersect_sphere(ray, scene[i]);
    if (t > 0. && t < closest_t) {
      closest_t = t;
    }
  }
  if (closest_t < FLT_MAX) {
//    return vec4f(1, 0.76, 0.03, 1);
//    return vec4f(1, 0.76, 0.03, 1) * saturate(1. - closest_t);
    return vec4f(saturate(closest_t) * 0.5);
  }
  return vec4f(sky_color(ray), 1);
}

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

作者 Tzarevich
2025年11月23日 15:17

前端调用大语言模型:基于 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` 调用 大模型(详解)

作者 烟袅
2025年11月23日 14:34

在现代 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 调用模块 😊

BLE 通信设计与架构落地

2025年11月22日 12:35

面向低功耗、稳定可靠 BLE 通信方案,从软件设计目标、方案选型到分层架构与关键流程,配套流程图与实现要点,直达量产质量与研发效率。

背景与目标

  • 背景:电机控制、状态采集、故障诊断与 OTA 升级都依赖移动端与设备端的低延迟、低功耗、稳定连接。
  • 目标:低功耗、快速连接、高可靠传输、应用层安全、可扩展协议、易维护 SDK。

架构总览

  • 分层清晰:UI → SDK → 协议 → BLE 客户端 → 设备固件 → 服务/状态机
  • 控制与状态分离:控制通道低延迟,状态通道稳定流式;OTA 独立不阻塞控制

截屏2025-11-22 12.45.56.png

方案选型

  • 传输:基于 GATT;命令走写入特征,设备通过通知特征上行响应与事件。
  • 编码:采用起止分隔符与转义机制,配合 XOR 校验保证帧边界与数据一致性;帧结构为 7E | cmd(1) | len(2) | payload | xor(1) | 7E
  • 命令集:包含应用版本查询、应用控制设置、仪表信息上报、控制响应等,区分查询型与设置型。
  • 解析:先进行包格式与校验验证,再按协议掩码解析为结构化的状态/响应模型。
  • MTU:优先协商更大 MTU 值,写入根据协商结果自动选择短写或长写以提升吞吐。
  • 服务与特征:定义稳定的服务 UUID 与写/通知特征 UUID;扫描阶段可结合常见服务进行辅助过滤提升发现成功率。

连接生命周期

  • 秒级发现、自动检测系统已连/已配对设备;MTU 协商后开启 Notify 再发指令
flowchart TB
  A[扫描广播] --> B{过滤设备}
  B -->|匹配UUID/厂商数据| C[建立GATT连接]
  C --> D[MTU协商]
  D --> E[订阅Notify特征]
  E --> F[开启通知]
  F --> G[正常通信]
  H --> I{断链?}
  I -->|是| J[退回扫描/优先重连]
  I -->|否| H

指令与应答

  • 上层采用统一命令模型构建请求,协议层负责组帧与发送;设备通过通知返回响应或状态,协议处理器完成验证与解析,上层按请求-响应模式匹配回调。
sequenceDiagram
  participant App
  participant SDK
  participant Device

  App->>SDK: send(Command)
  SDK->>SDK: 组帧(7E/转义/XOR)
  SDK->>Device: 写入数据包
  Device-->>SDK: 通知上行数据包
  SDK-->>SDK: 验证/解析为模型
  SDK-->>App: 回调(响应/状态)

服务与特征

  • 服务与特征保持稳定命名,便于跨端协作与维护;扫描阶段可结合通用服务过滤以提升发现效率。
  • 写入策略根据 MTU 协商结果选择短写/长写,兼顾效率与兼容性。
flowchart LR
  CtrlSvc[控制服务 0xFFF0] --> cmd_write[cmd_write: Write NR]
  CtrlSvc --> cmd_notify[cmd_notify: Notify]
  StateSvc[状态服务 0xFFF1] --> state_notify[state_notify: Notify]
  OTASvc[OTA服务 0xFFF2] --> ota_write[ota_write: Write]
  OTASvc --> ota_notify[ota_notify: Notify]

协议帧设计

  • 结构:7E | cmd(1) | len(2,BE) | payload(N) | xor(1) | 7E
  • 转义:对分隔符与转义字符采用双字节转义,保证帧边界不被误判。
  • 校验:使用 XOR 校验覆盖命令、长度与载荷,快速验证数据一致性。
  • 验证:先校验起止与最小长度,再重算校验对比,失败立即丢弃并上报错误。

安全策略(可选)

  • 基于明文帧 + 校验的基本可靠性方案;如需加强安全,可在载荷层加入签名与时间戳,或在连接建立后协商会话密钥并进行载荷加密与认证。

OTA 升级

  • 使用长写支持大数据包传输,结合 MTU 协商提升吞吐;分片与断点续传由上层控制,升级流程与控制通道隔离,保障常规通信不受影响。
flowchart LR
  Start[开始升级] --> Prepare[校验包/签名/版本]
  Prepare --> Switch[进入DFU模式/暂停控制通道]
  Switch --> Chunk[分片发送]
  Chunk --> Ack{应答/校验通过?}
  Ack -->|否| Retry[重传/断点续传]
  Ack -->|是| Commit[提交/应用新固件]
  Commit --> Reboot[重启/回到正常模式]
  Reboot --> End[升级完成]

低功耗策略

  • 广播:普通 300–500ms;待机 800–1200ms;事件触发短时提升
  • 连接:interval=30–50msslaveLatency=4–10timeout=4–6s
  • MTU:协商至 185,分片随 ATT 与机型差异自适应
  • 节流:状态合并与节流(100–200ms);小包合帧减少唤醒

关键实现要点

  • 重传窗口:大小 3–5;超时 300–500ms;指数退避防抖
  • 指令模型:区分必答控制与可跳过状态;明确重试与时限
  • 线程模型:移动端 BLE 回调线程与业务线程解耦;设备侧事件与控制循环分离
  • 指标与日志:连接时延、MTU、吞吐、丢包、重试、握手耗时、失败原因

调试与调优

  • 吞吐压测:不同 MTU 下 pps/重试率 对比;寻找最佳分片
  • 稳定性:高干扰场景(电机 PWM/金属环境)重连成功率与耗时
  • 兼容性:iOS/Android 栈差异(如 Android GATT 133);连接后强制 discoverServices
  • 低功耗验证:记录电流曲线,量化参数调整影响
  • OTA 可靠性:断电/断链恢复、双镜像回滚、进度一致性

常见坑与规避

  • iOS 后台:后台模式声明与速率控制;避免系统挂起
  • Android 缓存:特征缓存可能写旧;连接后重新 discoverServices
  • MTU 协商:必须在连接成功后;返回值可能不等于实际可用
  • Notify 订阅:先订阅再发指令;避免早期响应丢失
  • 多连接:一车一连;设备侧拒绝第二连接请求

实现思路要点

  • 命令模型统一、组帧规范化、解析结构化,方便扩展与回归测试。
  • 请求-响应严格时序:先订阅响应流,再发送请求,避免早期响应丢失。
  • 设备数据流按设备维度隔离,支持多设备并发管理与订阅清理。
  • 日志与指标贯穿链路:连接与协商、吞吐与丢包、解析与错误,辅助定位与调优。

结语

  • 核心在于稳定、可靠与可维护。围绕 GATT 传输、稳健的帧/转义/校验与清晰的请求-响应模式,既保证 eBike 场景的低功耗与高可靠,又为后续安全与功能扩展留足空间。

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

作者 全栈阿笑
2025年11月22日 11:39

最近为我的开源记账 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翻译后竟然变化了

2025年11月22日 02:09

当前负责的项目主打海外业务,总免不了和多语言打交道。但最近我在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结构容易被翻译器误判?有没有更根本的解决办法,而不是给每个关键节点加豁免?

美团:小雪节气当天,洗浴行业交易规模破亿

2025年11月23日 18:12
为助推全国洗浴消费热潮,11月19日,美团正式上线 “半价洗浴” 活动专区,推出系列半价神券,11月22日小雪节气当天,洗浴品类当天交易规模突破1亿元,北京、上海、沈阳、长春、长沙、扬州等多个城市交易规模同比增速超100%。全国超2000家优质特色洗浴中心,整体交易规模同比激增超152%。
❌
❌