阅读视图

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

长文本 AIGC:Web 端大篇幅内容生成的技术优化策略

在计算机科学的漫长历史里,人们曾担忧磁带不够长、内存不足、线程要死锁。如今我们有了一个更文艺的烦恼:如何在浏览器里舒舒服服地生成万字文章,而不会把用户的笔记本烤熟、把服务器的 CPU 炸裂

本文试图以一名计算机科学家的视角,结合底层原理,给出 Web 端长文本 AIGC(AI Generated Content)优化策略。但担心太枯燥,我会在严肃里夹点幽默,在字节流中撒点诗意。


一、为什么长文本生成困难?

想象一下,你请朋友在烧烤摊上一口气背诵《红楼梦》。前几句可能字正腔圆,等到过了二十回,朋友已经开始咳嗽走音。浏览器里的大模型生成长文本亦然

  1. 内存开销巨大

    • 模型在推理时,每多生成一个 token,历史上下文长度都会被再次计算。
    • 这导致“越写越慢”,像作家写小说写到 200 万字时,稿纸堆得到处都是。
  2. 网络传输延迟

    • 如果你在 Web 端一次性返回大段 JSON,浏览器可能要等到最后才渲染。
    • 就像等人写完一部长篇小说才开印,读者已睡着。
  3. 用户体验脆弱

    • 用户不是“科学家”,他们的耐心是宝石般稀少。若页面卡住三秒以上,他们怀疑设备;卡住十秒,他们怀疑人生。

二、核心策略:把诗长长地写,把技术细细地切

策略 1:流式输出(Streaming Response)

原理

  • HTTP/2 或 WebSocket 通道可以让模型逐 Token 输出。
  • 浏览器端一边接收、一边渲染,像小说家边说边打字。

示例(Node.js 服务端)

import express from "express";

const app = express();

app.get("/stream-text", async (req, res) => {
  res.setHeader("Content-Type", "text/event-stream"); // SSE 通道
  res.setHeader("Cache-Control", "no-cache");

  const tokens = ["长", "文", "本", "A", "I", "G", "C", "开", "始", "啦"];
  for (let token of tokens) {
    res.write(`data: ${token}\n\n`);
    await new Promise(r => setTimeout(r, 200)); // 模拟逐字生成
  }

  res.write("data: [DONE]\n\n");
  res.end();
});

app.listen(3000);

客户端用 EventSource 即可实时显示。


策略 2:分段生成 + 拼接

原理

  • 把用户请求拆解成若干小段(比如每段 500 tokens)。
  • 每次请求只处理短上下文,服务端拼接结果。

这样模型的计算复杂度近似于“线性分段”,避免了“指数爆炸”。

现实比喻
这像写作业,你不可能一口气写完一千道题,而是每天做二十道。


策略 3:浏览器端渐进渲染

原理

  • 使用虚拟列表(Virtualized Rendering),避免直接把 10 万字 DOM 挂载到页面。
  • 只保持可视区域附近的 DOM 节点,其余用占位符。

片段代码(React 中)

import { FixedSizeList } from "react-window";

function LongTextViewer({ text }) {
  const lines = text.split("\n");
  return (
    <FixedSizeList
      height={600}
      itemSize={22}
      itemCount={lines.length}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>{lines[index]}</div>
      )}
    </FixedSizeList>
  );
}

这就好比电影院屏幕只展示当下的画面,而不是在放映厅里同时堆叠几十万帧。


策略 4:缓存与断点续写

原理

  • 在长文本生成中,随时可能断网或浏览器崩溃。
  • 通过 IndexedDB 或服务端 Redis 缓存,用户下次进入仍能继续。

现实比喻
小说家可能突遇电话,关掉台灯,下次继续写时不要忘了前文。


三、底层原理的一点探讨

  1. 注意力机制的代价
    Transformer 模型的注意力计算规模与序列长度平方成正比。序列翻倍,运算大约涨四倍。就像开餐厅:多两个食客,后厨却要加倍忙碌。
  2. Streaming 背后的 TCP
    每个 token 发送时,其实是通过 TCP 报文分片传递。底层是滑动窗口流控,保证即使用户点击刷新、切换 Wi-Fi,数据还能尽可能递达而非全部丢失。
  3. 虚拟化渲染的哲学
    在前端,DOM 是性能的隐形杀手。虚拟化思想就像“舞台剧演员只出现在灯光照亮的地方”,观众看不到的角落无需站满演员。

四、幽默一点的经验总结

  • 别让用户看见转菊花超过 3 秒:因为他们会想起 PPT 崩溃的黑历史。
  • 别一次性返还十万字:除非你的目标是造福文学评论家。
  • 缓存是神:没它,你就是凌晨被打断灵感的诗人。
  • 流式响应是魔法:它让机械文本拥有“呼吸感”,像现场朗诵。

五、诗意的收尾

长文本生成,就像在浏览器里种下一颗星辰。
它需要理性的内存管理、冷静的算法优化,
也需要人类对文字的温情眷恋。

当我们把流式输出、分段生成、虚拟列表、缓存策略组合起来,
便能让 AI 的长篇如江河般奔涌,
而用户依旧只感受到屏幕的轻盈。

所以,别惧怕长文本的重量。
我们要做的,是让技术托举它的重量,
而用户只需享受文字的美感。

在混沌宇宙中捕捉错误的光——Next.js 全栈 Sentry / LogRocket

当一位开发者将 Next.js 搭起来,前后端一体,仿佛双剑合璧;当 Bugs 潜藏于页面,仿佛量子涨落中的暗能量。我们需要一双眼,不仅能看前端用户的点击、怒吼和错愕,还能窥见后端的报错、追踪和堆栈。于是,Sentry 和 LogRocket 登场了。

它们就像科幻小说中的两位角色:

  • Sentry:手握望远镜的侦察兵,精准捕捉错误与堆栈轨迹。
  • LogRocket:一位带录像机的时间旅行者,把用户亲手点过的按钮、划过的屏幕全都录进时光胶片。

我们把这些装备装进 Next.js 的盔甲,结果就是:不仅能知道错误在哪,还能知道用户是怎么走进那个坑的


一、前置准备:不要赤手上阵

在征程开始前,请确认:

  1. 你有一个 Next.js 项目(如果没有,终端里敲下 npx create-next-app@latest my-app)。

  2. 注册账号:

拿到两个“神器”的 DSN(通常是一段 URL 形式的秘钥地址)。


二、后端的眼睛 —— 将 Sentry 注入 API 层

Sentry 在 Next.js 中往往需要分两块接入:服务端客户端
服务器上的错误(比如数据库没连上,JWT 解码失败),需要靠它来保存将错就错的真相。

sentry.server.config.js 文件里写下:

// sentry.server.config.js
import * as Sentry from "@sentry/nextjs";

Sentry.init({
  dsn: process.env.SENTRY_DSN, // 来自 Sentry 后台的 DSN
  tracesSampleRate: 1.0,       // 采样率,建议开发环境调低
});

再来一个小例子,比如我们的 API:

// pages/api/hello.js
export default function handler(req, res) {
  try {
    throw new Error("世界说:Hello Bug!");
  } catch (err) {
    // 把错误丢给 Sentry 收集
    console.error("捕获到错误:", err.message);
    const Sentry = require("@sentry/nextjs");
    Sentry.captureException(err);

    res.status(500).json({ error: "发生了一点点小意外" });
  }
};

当你访问 /api/hello,后台的错误将出现在 Sentry 控制台。仿佛给服务器配了“黑匣子”。


三、前端的记忆 —— Sentry 与 LogRocket 的双剑合璧

客户端的接入在 sentry.client.config.js

// sentry.client.config.js
import * as Sentry from "@sentry/nextjs";
import LogRocket from "logrocket";

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  tracesSampleRate: 1.0,
  integrations: [
    new Sentry.Integrations.Breadcrumbs({ console: true }),
  ],
});

LogRocket.init("你的-logrocket-项目ID/应用名");

// 让 LogRocket 的 session ID 和 Sentry 错误互相连接起来
LogRocket.getSessionURL((sessionURL) => {
  Sentry.configureScope((scope) => {
    scope.setExtra("logrocketSessionURL", sessionURL);
  });
});

这样一来:

  • 如果用户点了一个永远卡死的按钮,Sentry 会告诉你错误堆栈位置;
  • 而 LogRocket 会让你“回看录像”,看到用户点按钮之前是否疯狂刷新了十几次,还是在低网速下无情挣扎。

四、文学化的调试逻辑

编程调试有时就像侦探小说:现场残留的线索、倒下的咖啡杯、封存的指纹。
如果只有 Sentry,就像读到了一份血淋淋的案发报告,却不知嫌疑人如何潜入。
如果只有 LogRocket,就像看了一部默片,却听不到剧情里的对白。

把它们结合在一起,就是既有文字,又有画面——仿佛在一场交响乐里,既能听到阴沉的大提琴,也能看到指挥家的手势。


五、一些底层的原理碎片

  1. 堆栈收集(Stack Trace)
    JavaScript 的运行时会记录调用栈,当错误对象被抛出时,Sentry 将它序列化,上传到远程服务。
    这就像是程序员的“黑箱数据”,告诉你飞机在失事前执行了哪些函数。
  2. 事件回放(Session Replay)
    LogRocket 并非真的录像,而是记录了用户交互事件(按钮点击、输入框变化、DOM 更新),然后在不同客户端用虚拟 DOM 技术重放。
    就像漫画分镜头,而不是实拍影像。
  3. 性能追踪(Tracing)
    Sentry 内部利用浏览器 Performance API 以及 Node.js 的事件循环监控,记录请求的时延。这类似于管弦乐里记录某一声小号延迟了半拍。

这些原理串联起来,使得错误追踪不再是盲目的漆黑,而是有温度、有维度的全景图


六、最终的诗意收尾

在 Next.js 的星空下,用户点下按钮是偶然,Bug 却可能是必然。
Sentry 是那位冷静的档案员,写满每次事故的细节;
LogRocket 则像一台时光机,把错误前的每一步都还原。

作为开发者,我们不再是茫然的摆渡人,而是历史的目击者。
所以,别害怕 Bug,它们是程序世界的诗意裂缝。
而我们,只需把望远镜和录像机调好,便能在混沌中笑看光流。

前端登录token到底应该存在哪?LocalStorage、SessionStorage还是Cookie?一篇说透!

大家好,我是大华! 前几天有个小伙伴问我:“我登录之后拿到了token,到底该往哪儿存?LocalStorageSessionStorage还是Cookie?为啥不同网站做法不一样?” 说实话,这个问题我也曾经纠结过好久!每次看到不同项目用不同的存储方式,我就想问:到底哪个是对的?

前言

为什么token存储这么重要?想象一下,你家的钥匙你会放哪儿?随身携带?藏在门垫下面?还是交给保安?放错了地方,小偷就可能进你家门!

token就是用户进入系统的钥匙,存错了地方,黑客就能冒充用户登录账号,后果不堪设想啊!


一、区别

1. LocalStorage

  • 永久存储(除非手动删)
  • 同源就能读(JS随便拿)
  • 刷新不丢,关浏览器也不丢
  • XSS攻击下,Token直接暴露
  • 需要手动添加到请求头

2. SessionStorage

  • 只在当前会话有效
  • 同源可读
  • 适合临时操作
  • 关了浏览器标签就没了
  • 同样有XSS风险
  • 需要手动管理

3. Cookie

  • 可设置过期时间
  • 可设置HttpOnly(JS拿不到!)
  • 可设置Secure(只走HTTPS)
  • 可设置SameSite(防CSRF)
  • 自动随请求发送(比如发API时自动带Token)
  • 容量限制(4KB)
  • 每次请求都携带(可能浪费流量)

4. 内存存储(Memory)

  • 页面刷新就丢失
  • 完全前端控制,不持久化
  • 最快最安全,但生命周期最短
  • 页面刷新就丢失
  • 不适合持久化需求
  • 标签页关闭就没了

二、为什么大家都用LocalStorage?

我懂,很多前端的朋友第一反应:“用LocalStorage最方便啊!”

两行代码搞定:

// 存
localStorage.setItem('token', res.token);

// 发请求时
axios.defaults.headers.common['Authorization'] = localStorage.getItem('token');

方便、简单是真的,但同时也会伴随着安全隐患。

案例1:XSS攻击,Token被偷

假设你网站有个评论区,用户输入没做转义:

<script>
  fetch('/steal?token=' + localStorage.getItem('token'))
</script>

用户打开页面,这条JS一执行,你的Token就会被发送到黑客服务器上。 而如果 Token 在HttpOnly Cookie里,JS 读不到,XSS 攻击直接失效。


三、用Cookie就完美了吗?

不,Cookie也有坑。黑客诱导你访问一个恶意页面:

<img src="https://yourbank.com/transfer?to=hacker&amount=100000" />

如果你的登录态在 Cookie 里,浏览器会自动带上 Cookie,请求就成功了!

用户没点确认,钱就没了。

解决方案

方案一:前后端分离 + JWT + LocalStorage(最常见)

这是目前绝大多数新项目采用的方式。

技术栈:

  • 前端:Vue3 + Vue CLI / Vite,部署在 Nginx / CDN
  • 后端:SpringBoot,提供 RESTful API
  • 通信:Axios + JWT(JSON Web Token)
  • Token存储localStorage或内存

部署方式:

用户浏览器
    ↓
Vue 前端(http://fe.yourcompany.com) ←→ SpringBoot 后端(http://api.yourcompany.com)

前端和后端完全独立部署,通过CORS跨域通信。

认证流程:

  1. 用户登录
  2. SpringBoot验证用户名密码,生成JWT
  3. 返回给前端:{ token: "xxxxxx" }
  4. 前端存入 localStorage
  5. 后续请求,前端手动加Header:
    axios.defaults.headers.common['Authorization'] = 'Bearer ' + token
    
  6. SpringBoot在拦截器中解析JWT,验证身份

优点:

  • 前后端完全解耦,各自独立开发、部署、扩展
  • 适合微服务、云原生架构
  • 开发简单,调试方便
  • 可配合Nginx做负载均衡、缓存

缺点:

  • XSS 风险高:一旦有富文本漏洞,localStorage中的token可能被盗
  • 需要手动管理token(过期、刷新)
  • 跨域配置麻烦(CORS)

📌 适用场景:

  • 中后台管理系统
  • ToC产品(官网、商城)
  • 快速上线的MVP项目

这是目前最主流的方案,90% 的新项目都这么干。


方案二:前后端合并部署(传统做法,逐渐减少)

把Vue打包后的dist文件放到SpringBoot的 resources/static 目录下,由SpringBoot统一提供页面和 API。

目录结构:

src/
 └── main/
     ├── java/        ← SpringBoot 代码
     └── resources/
         ├── static/  ← Vue 打包后的 css/js
         └── templates/ ← index.html(可选)

访问方式:

  • 页面:http://localhost:8080/
  • API:http://localhost:8080/api/xxx

优点:

  • 部署简单,一个 jar 包搞定
  • 没有跨域问题
  • 适合小型项目、内部系统

缺点:

  • 前后端耦合,不利于独立迭代
  • 静态资源由 Java 服务提供,性能不如 Nginx
  • 不适合高并发场景

📌 适用场景:

  • 内部工具、小项目
  • 学习 demo
  • 对性能要求不高的系统

这种方案在企业级项目中逐渐被淘汰,但在教学和小项目中依然常见。


方案三:双 Token 机制(高安全要求项目)

这是金融、银行、高权限系统中越来越流行的“专业做法”。

方案核心:

  • access_token:短期JWT,存前端内存,用于API认证
  • refresh_token:长期token,存HttpOnly Cookie,用于刷新access_token

流程:

  1. 登录成功
    • 后端:Set-Cookie: refresh_token=xxx; HttpOnly; Secure
    • 响应体:{ access_token: "yyy" }
  2. 前端:
    • access_token到内存
    • 请求时加 Authorization: Bearer yyy
  3. access_token过期后:
    • /refresh 接口
    • 浏览器自动带refresh_token Cookie
    • 拿到新access_token

优点:

  • XSS 攻不破(refresh_tokenJS 拿不到)
  • 即使access_token泄露,有效期短(5-15分钟)
  • 安全性极高

缺点:

  • 实现复杂
  • 需要后端配合
  • 刷新机制要处理好并发

📌 适用场景:

  • 银行、支付、高权限后台
  • 对安全要求极高的系统

方案四:使用 Cookie + Session(传统安全做法)

SpringBoot使用Spring Security + Session,登录后Set-Cookie: JSESSIONID=xxx; HttpOnly

Vue 前端不需要管 token,浏览器自动带 Cookie。

优点:

  • 安全性高(防 XSS)
  • 后端可管理 session(如强制下线)
  • 适合内网系统

缺点:

  • 需要处理 CSRF
  • 不适合无状态、微服务架构
  • 跨域配置复杂

📌 适用场景:

  • 传统企业系统
  • 内网管理系统
  • 已有 Spring Security 架构的项目

总结:四种方案对比

方案 安全性 易用性 推荐度 适用场景
前后端分离 + JWT + LocalStorage ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐☆ 90% 的新项目
前后端合并部署 ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 小项目、学习
双 Token 机制 ⭐⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ 高安全系统
Cookie + Session ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ 传统企业系统

最终建议

  1. 如果你是新手或者做小项目:用前后端分离 + JWT + localStorage,简单直接。

  2. 如果你做中后台或者ToC产品:同上,但必须做好 XSS 防护(输入过滤、CSP、DOMPurify)。

  3. 如果你做金融或者高权限系统:上双 Token 机制,安全第一。

  4. 如果你是传统企业或者内网系统:可以考虑Cookie + Session,但要配好 CSRF。


目前 Vue + SpringBoot 的标准就是:前后端分离 + JWT + LocalStorage。

虽然它有 XSS 风险,但凭借开发效率高、架构清晰、适合云原生等优势,已经成为主流。

安全问题不是靠“不用 localStorage”解决的,而是靠:

  • 严格的输入验证
  • CSP 策略
  • 定期安全审计
  • 使用Content-Security-Policy

技术选型,永远是安全、效率、成本的权衡。

公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《工作 5 年没碰过分布式锁,是我太菜还是公司太稳?网友:太真实了!》

《别再被 Stream.toMap() 劝退了!3 个真实避坑案例,建议收藏》

《写给小公司前端的 UI 规范:别让页面丑得自己都看不下去》

《终于找到 Axios 最优雅的封装方式了,再也不用写重复代码了》

动态组件库建设

动态组件扩展的核心价值:告别硬编码组件

传统开发中,每增加一个功能模块都需要:

  1. 硬编码组件引入:手动 import 并注册组件
  2. 维护成本激增:组件越多,代码越臃肿,修改牵一发动全身

而动态组件扩展设计通过 "配置驱动的组件管理" 彻底改变这一现状:

  • 配置即组件:通过 DSL 配置自动生成组件实例
  • 统一交互协议:所有组件遵循相同的生命周期和事件规范
  • 按需加载:只渲染配置中声明的组件,避免冗余

elpis 动态组件架构解析

第一层:组件注册中心(ComponentConfig)

component-config.js 作为组件注册中心,采用 "键值映射" 模式管理所有动态组件:

// app/pages/dashboard/complex-view/schema-view/components/component-config.js
export const ComponentConfig = {
  createForm: {
    component: createForm,
  },
  editForm: {
    component: editForm,
  },
  detailPanel: {
    component: detailPanel,
  }
}

设计亮点

  1. 解耦组件与业务:组件实现与业务逻辑分离,通过 key 建立关联
  2. 扩展性极强:新增组件只需在 ComponentConfig 中注册,无需修改主逻辑

第二层:Schema 转换引擎(buildDtoSchema)

动态组件的核心难题是 "如何将通用 schema 转换为特定组件的配置" 。elpis 通过 buildDtoSchema 函数实现智能转换:

// 核心转换逻辑
const buildDtoSchema = (_schema, comName) => {
  if (!_schema.properties) return {}
  const dtoSchema = {
    type: 'object',
    properties: {}
  }

  for (const key in _schema.properties) {
    const props = _schema.properties[key];
    // 关键:根据组件名提取对应配置
    if (props[`${comName}Option`]) {
      let dtoProps = {}
      // 提取非 Option 的通用属性
      for (const pKey in props) {
        if (pKey.indexOf('Option') < 0) {
          dtoProps[pKey] = props[pKey]
        }
      }
      // 合并组件特定配置
      dtoProps = Object.assign({}, dtoProps, {
        option: props[`${comName}Option`]
      })
      dtoSchema.properties[key] = dtoProps
    }
  }
  return dtoSchema
}

第三层:动态渲染与事件协调

schema-view.vue 中,通过 Vue 的动态组件机制实现组件的自动渲染和事件协调:

<template>
  <!-- 搜索面板 -->
  <search-panel @search="onSearch" />

  <!-- 数据表格 -->
  <table-panel @operate="onTableOperate" />

  <!-- 动态组件:核心实现 -->
  <component
    :is="ComponentConfig[key]?.component"
    v-for="(component, key) in components"
    :key="key"
    ref="comListRef"
    :component="component"
    @command="onComponentCommand"
  />
</template>

<script setup>
// 事件映射机制
const EventHandlerMap = {
  showComponent: showComponent,
}

// 根据 DSL 配置映射表格操作到组件显示
const onTableOperate = ({btnConfig, rowData}) => {
  const { eventKey } = btnConfig;
  if(EventHandlerMap[eventKey]) {
    EventHandlerMap[eventKey]({btnConfig, rowData})
  }
}

// 动态显示组件
function showComponent({btnConfig, rowData}) {
  const { comName } = btnConfig.eventOption;
  const component = comListRef.value.find(item => item.name === comName);
  if(component) {
    component.handleShow(rowData)
  }
}
</script>

设计精髓

  1. 数据传递标准化:通过 component prop 传递 schema 和 config
  2. 事件冒泡机制:组件内部事件可向上传递,实现跨组件通信

具体组件实现:以 CreateForm 为例

动态组件需要遵循统一的接口规范,以 create-form.vue 为例:

<template>
  <el-drawer v-model="isShow" @close="handleHide">
    <template #header>
      <h3>{{ title }}</h3>
    </template>

    <!-- 核心:使用转换后的 schema 渲染表单 -->
    <schemaForm
      ref="schemaFormRef"
      :schema="components[name].schema"
    />

    <template #footer>
      <el-button @click="handleSave">{{ saveBtnText }}</el-button>
    </template>
  </el-drawer>
</template>

<script setup>
// 接收 DSL 数据
const { api, components } = inject('schemaViewData')
const name = ref('createForm')

// 统一接口实现
const handleShow = () => {
  const { config } = components.value[name.value]
  title.value = config.title
  saveBtnText.value = config.saveBtnText
  isShow.value = true
}

const handleSave = async () => {
  const formData = schemaFormRef.value.getFormData()
  await $curl({
    url: api.value,
    method: 'post',
    data: formData
  })
  // 通知父组件刷新数据
  emit('command', { event: 'loadTableData' })
}

// 暴露标准接口
defineExpose({
  name,
  handleShow,
  handleHide
})
</script>

核心特性

  1. 配置驱动:组件的标题、按钮文案等都来自 DSL 配置
  2. Schema 复用:直接使用转换后的 schema 渲染表单,无需重复定义
  3. 事件通信:通过 emit 向上传递事件,实现数据联动

DSL 配置到组件的完整链路

让我们通过一个完整示例,展示从 DSL 配置到组件渲染的全流程:

1. DSL 配置定义

{
  "schemaConfig": {
    "api": "/api/user",
    "schema": {
      "type": "object",
      "properties": {
        "userName": {
          "type": "string",
          "label": "用户名",
          "createFormOption": {
            "comType": "input",
            "required": true,
            "placeholder": "请输入用户名"
          }
        }
      }
    },
    "tableConfig": {
      "headerButtons": [
        {
          "label": "新增用户",
          "eventKey": "showComponent",
          "eventOption": { "comName": "createForm" }
        }
      ]
    },
    "componentConfig": {
      "createForm": {
        "title": "新增用户",
        "saveBtnText": "确认新增"
      }
    }
  }
}

2. Schema 转换处理

// useSchema Hook 中的处理
const { componentConfig } = sConfig;
const dtoComponents = {}

for(const key in componentConfig){
  dtoComponents[key] = {
    // 转换为组件专用 schema
    schema: buildDtoSchema(configSchema, key),
    // 组件配置
    config: componentConfig[key]
  }
}
components.value = dtoComponents

3. 动态组件渲染

<!-- 自动渲染 createForm 组件 -->
<component
  :is="ComponentConfig['createForm']?.component"
  :component="{
    schema: { /* 转换后的 createForm schema */ },
    config: { title: '新增用户', saveBtnText: '确认新增' }
  }"
/>

扩展新组件:从配置到实现

假设需要新增一个 "批量导入" 组件,整个流程如下:

1. 创建组件实现

<!-- batch-import.vue -->
<template>
  <el-dialog v-model="isShow">
    <el-upload :action="uploadUrl">
      <el-button>选择文件</el-button>
    </el-upload>
  </el-dialog>
</template>

<script setup>
const name = ref('batchImport')

const handleShow = () => {
  isShow.value = true
}

defineExpose({ name, handleShow, handleHide })
</script>

2. 注册到 ComponentConfig

// component-config.js
import batchImport from './batch-import/batch-import.vue'

export const ComponentConfig = {
  createForm: { component: createForm },
  editForm: { component: editForm },
  detailPanel: { component: detailPanel },
  // 新增注册
  batchImport: { component: batchImport }
}

3. 配置 DSL

{
  "tableConfig": {
    "headerButtons": [
      {
        "label": "批量导入",
        "eventKey": "showComponent",
        "eventOption": { "comName": "batchImport" }
      }
    ]
  },
  "componentConfig": {
    "batchImport": {
      "title": "批量导入用户",
      "uploadUrl": "/api/user/batch-import"
    }
  }
}

4. 自动生效

无需修改主逻辑代码,新组件即可自动参与渲染和事件处理。这就是动态组件扩展设计的威力所在。

展望:AI 时代的动态组件

随着 AI 技术发展,动态组件扩展设计将迎来新的可能:

  1. 智能组件生成:AI 根据业务描述自动生成组件代码和配置
  2. 自适应布局:根据用户行为动态调整组件排列和交互方式
  3. 个性化定制:为不同用户角色生成专属的组件组合

动态组件扩展设计不仅是技术架构的创新,更是开发思维的转变 —— 从 "写代码实现功能" 到 "设计机制解决问题"。在 DSL 驱动的低代码时代,这种设计理念将成为构建可扩展、易维护系统的核心基石。

出处:《哲玄课堂-大前端全栈实践》

Uni-App跨端实战:微信小程序WebView与H5通信全流程解析(01)

前言

在uniapp开发过程中,uniapp编译出的平台的小程序、APP中,都需要使用webview组件进行内嵌H5。

内嵌H5有哪些好处:

  • 通过web-view内嵌,APP和小程序可以直接复用这些H5资源,避免了重复开发。
  • 无需升级原生代码就能展示新的H5内容,支持动态更新功能

当然H5页面也可调用原生API,这就涉及到内嵌H5与webview通信。对于uniapp可以编译出多平台的代码,每个平台的webview通信都会有所差别。

如图所示:

image.png

本文主要讲解和实践微信小程序的webview与h5的双向通信。

1. 前置工作

因为是uniapp开发的,按照[uni-app webview官方文档]H5可以使用window.postMessage,但经过实践,这个方法并没办法发送数据.

image.png

1.1 引入uni.webview

所以我们使用的都是使用uni.webView.postMessage,需要说明的是不管你的H5是uniapp编译、还是非uniapp编译,都需要额外引入一个js(uni.webview.1.5.6.js)才能支持,需要1.5.6版本才支持鸿蒙系统。

特别需要注意的是: 引入的文件提供的全局变量是uni,如果你的H5是uni-app编译出的,会与原有的uni冲突,会导致覆盖掉你引入的js变量。可以直接修改引入的全局变量,例如将其修改为webUni

image.png

解决方案:修改全局uni的名称

image.pngimage.png

可以直接在index.html中引入:

// 微信sdk
<script src='https://res2.wx.qq.com/open/js/jweixin-1.6.0.js'></script>
// 支付宝sdk
<script src='https://appx/web-view.min.js'></script>
// uni sdk
<script type="text/javascript" src="/static/uni.webview.1.5.6.js"></script>
<script>
  document.addEventListener('UniAppJSBridgeReady', function () {
    window.webUni = webUni.webView
    webUni.getEnv(function (res) {
      console.log('当前环境:' + JSON.stringify(res));
    });
  });
</script>

1.2 引入对应小程序的sdk

如果不引入,通过uni.getEnv获取的环境一直为h5,引入后才能判断环境。

image.png

经过实践,uniapp编译的H5和非uniapp编译的H5用法一致。

2. 微信小程序webview通信概述

uniapp编译出的微信小程序weiview通信,参考微信小程序webview组件官方文档,webview 指向网页的链接。可打开关联的公众号的文章,其它网页需登录小程序管理后台配置业务域名。

可以用一个图简述下通信的过程:

image.png

下面我们具体实践下。

3. 微信小程序->H5

从微信小程序发送参数给H5,通过url传参数给H5,并且参数需要encodeURIComponent,参数的长度是有限制的,如果需要带过多参数,建议通过接口保存和获取。

例如:可以约定一个随机值,关联对应的传递参数,H5通过query接收到这个随机值,通过接口去获取对应的参数,完成相应的操作。

具体代码如下:

  <web-view :src="url" @message="bindMessage" @load="bindLoad"></web-view>

4. H5->微信小程序

H5发送到微信小程序的参数,web-view网页中可使用JSSDK 1.3.2提供的接口返回小程序页面。wx.miniProgram.postMessage

image.png

webUni.postMessage({
    data: {
      name: 'jack',
      age: 20,
      title: '复制'
    }
 })

如果H5直接发送数据,微信小程序是不会实时接收到数据,需要在特定的时机触发,例如分享。

image.png

发送完成,点击微信小程序开发者工具分享,此时数据通过message接收到,接收的数据是一个数组,需要取第一个,不同的小程序取值不同

image.png

5. 总结

最后总结一下: 微信小程序webview通信,是有一些限制,小程序通过url传参给H5,H5通过postMessage发送给小程序,小程序接收数据是要在特定的时机才能获取。

如果错误,请指正O^O!

鸿蒙应用开发从入门到实战(四):ArkTS 语言概述

大家好,我是潘Sir,持续分享IT技术,帮你少走弯路。《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,欢迎关注!

一、HarmonyOS开发语言ArkTS概述

HarmonyOS 应用的主要开发语言是 ArkTS,它由 TypeScript(简称TS)扩展而来,在继承TypeScript语法的基础上进行了一系列优化,使开发者能够以更简洁、更自然的方式开发应用。值得注意的是,TypeScript 本身也是由另一门语言 JavaScript 扩展而来。因此三者的关系如下图所示

1ArkTS语言概述.png

ArkTS是HarmonyOS优选的主力应用开发语言。ArkTS围绕应用开发在TypeScript(简称TS)生态基础上做了进一步扩展,继承了TS的所有特性,是TS的超集。

因此,在学习ArkTS语言之前,建议开发者具备TS语言开发能力

当前,ArkTS在TS的基础上主要扩展了如下能力:

  • 基本语法

ArkTS定义了声明式UI描述、自定义组件和动态扩展UI元素的能力,再配合ArkUI开发框架中的系统组件及其相关的事件方法、属性方法等共同构成了UI开发的主体。

  • 状态管理

ArkTS提供了多维度的状态管理机制。在UI开发框架中,与UI相关联的数据可以在组件内使用,也可以在不同组件层级间传递,比如父子组件之间、爷孙组件之间,还可以在应用全局范围内传递或跨设备传递。另外,从数据的传递形式来看,可分为只读的单向传递和可变更的双向传递。开发者可以灵活地利用这些能力来实现数据和UI的联动。

  • 渲染控制

ArkTS提供了渲染控制的能力。条件渲染可根据应用的不同状态,渲染对应状态下的UI内容。循环渲染可从数据源中迭代获取数据,并在每次迭代过程中创建相应的组件。数据懒加载从数据源中按需迭代数据,并在每次迭代过程中创建相应的组件。

未来,ArkTS会结合应用开发/运行的需求持续演进,逐步提供并行和并发能力增强、系统类型增强、分布式开发范式等更多特性。

二、ArkTS基本语法

2.1 基本语法概述

在初步了解了ArkTS语言之后,我们以一个具体的示例来说明ArkTS的基本组成。如下图所示,当开发者点击按钮时,文本内容从“Hello World”变为“Hello ArkUI”。

2arkts基本语法案例.gif

本示例中,ArkTS的基本组成如下所示

3ArkTS基本组成.png

自定义变量不能与基础通用属性/事件名重复。

  • 装饰器

用于装饰类、结构、方法以及变量,并赋予其特殊的含义。如上述示例中@Entry、@Component和@State都是装饰器,@Component表示自定义组件,@Entry表示该自定义组件为入口组件,@State表示组件中的状态变量,状态变量变化会触发UI刷新。

  • UI描述

    以声明式的方式来描述UI的结构,例如build()方法中的代码块。

  • 自定义组件

    可复用的UI单元,可组合其他组件,如上述被@Component装饰的struct Hello。

  • 系统组件

ArkUI框架中默认内置的基础和容器组件,可直接被开发者调用,比如示例中的Column、Text、Divider、Button。

  • 属性方法

    组件可以通过链式调用配置多项属性,如fontSize()、width()、height()、backgroundColor()等。

  • 事件方法

    组件可以通过链式调用设置多个事件的响应逻辑,如跟随在Button后面的onClick()。

除此之外,ArkTS扩展了多种语法范式来使开发更加便捷:

  • @Builder/@BuilderParam

    特殊的封装UI描述的方法,细粒度的封装和复用UI描述。

  • @Extend/@Styles

    扩展内置组件和封装属性样式,更灵活地组合内置组件。

  • stateStyles

    多态样式,可以依据组件的内部状态的不同,设置不同样式。

2.2 声明式UI描述

ArkTS以声明方式组合和扩展组件来描述应用程序的UI,同时还提供了基本的属性、事件和子组件配置方法,帮助开发者实现应用交互逻辑。

2.2.1 创建组件

根据组件构造方法的不同,创建组件包含有参数和无参数两种方式。

创建组件时不需要new运算符。

  • 无参数

如果组件的接口定义没有包含必选构造参数,则组件后面的“()”不需要配置任何内容。例如,Divider组件不包含构造参数:

Column() {
  Text('item 1')
  Divider()
  Text('item 2')
}
  • 有参数

如果组件的接口定义包含构造参数,则在组件后面的“()”配置相应参数。

(1)Image组件的必选参数src。

Image('https://xyz/test.jpg')

(2)Text组件的非必选参数content。

// string类型的参数
Text('test')
// $r形式引入应用资源,可应用于多语言场景
Text($r('app.string.title_value'))
// 无参数形式
Text()

(3)变量或表达式也可以用于参数赋值,其中表达式返回的结果类型必须满足参数类型要求。

例如,设置变量或表达式来构造Image和Text组件的参数。

Image(this.imagePath)
Image('https://' + this.imageUrl)
Text(`count: ${this.count}`)

2.2.2 配置属性

属性方法以“.”链式调用的方式配置系统组件的样式和其他属性,建议每个属性方法单独写一行。

(1)配置Text组件的字体大小。

Text('test')
  .fontSize(12)

(2)配置组件的多个属性。

Image('test.jpg')
  .alt('error.jpg')    
  .width(100)    
  .height(100)

(3)除了直接传递常量参数外,还可以传递变量或表达式。

Text('hello')
  .fontSize(this.size)
Image('test.jpg')
  .width(this.count % 2 === 0 ? 100 : 200)    
  .height(this.offset + 100)

(4)对于系统组件,ArkUI还为其属性预定义了一些枚举类型供开发者调用,枚举类型可以作为参数传递,但必须满足参数类型要求。

例如,可以按以下方式配置Text组件的颜色和字体样式。

Text('hello')
  .fontSize(20)
  .fontColor(Color.Red)
  .fontWeight(FontWeight.Bold)

2.2.3 配置事件

事件方法以“.”链式调用的方式配置系统组件支持的事件,建议每个事件方法单独写一行。

(1)使用箭头函数配置组件的事件方法。

Button('Click me')
  .onClick(() => {
    this.myText = 'ArkUI';
  })

(2)使用匿名函数表达式配置组件的事件方法,要求使用bind,以确保函数体中的this指向当前组件。

Button('add counter')
  .onClick(function(){
    this.counter += 2;
  }.bind(this))

(3)使用组件的成员函数配置组件的事件方法。

myClickHandler(): void {
  this.counter += 2;
}
...
Button('add counter')
  .onClick(this.myClickHandler.bind(this))

(4)使用声明的箭头函数,可以直接调用,不需要bind this。

fn = () => {
  console.info(`counter: ${this.counter}`)
  this.counter++
}
...
Button('add counter')
  .onClick(this.fn)

2.2.4 配置子组件

如果组件支持子组件配置,则需在尾随闭包"{...}"中为组件添加子组件的UI描述。Column、Row、Stack、Grid、List等组件都是容器组件。

(1)以下是简单的Column组件配置子组件的示例。

Column() {
  Text('Hello')
    .fontSize(100)
  Divider()
  Text(this.myText)
    .fontSize(100)
    .fontColor(Color.Red)
}

(2)容器组件均支持子组件配置,可以实现相对复杂的多级嵌套。

Column() {
  Row() {
    Image('test1.jpg')
      .width(100)
      .height(100)
    Button('click +1')
      .onClick(() => {
        console.info('+1 clicked!');
      })
  }
}

基本语法中的自定义组件和扩展UI的能力在后续进行介绍。

三、ArkTS状态管理

3.1 状态管理概述

在前文的描述中,我们构建的页面多为静态界面。如果希望构建一个动态的、有交互的界面,就需要引入“状态”的概念。

在上面的示例中,用户与应用程序的交互触发了文本状态变更,状态变更引起了UI渲染,UI从“Hello World”变更为“Hello ArkUI”。

在声明式UI编程框架中,UI是程序状态的运行结果,用户构建了一个UI模型,其中应用的运行时的状态是参数。当参数改变时,UI作为返回结果,也将进行对应的改变。这些运行时的状态变化所带来的UI的重新渲染,在ArkUI中统称为状态管理机制

自定义组件拥有变量,变量必须被装饰器装饰才可以成为状态变量,状态变量的改变会引起UI的渲染刷新。如果不使用状态变量,UI只能在初始化时渲染,后续将不会再刷新。 下图展示了State和View(UI)之间的关系。

4ArkTS状态机制.png

  • View(UI):UI渲染,指将build方法内的UI描述和@Builder装饰的方法内的UI描述映射到界面。
  • State:状态,指驱动UI更新的数据。用户通过触发组件的事件方法,改变状态数据。状态数据的改变,引起UI的重新渲染。

3.1.1 基本概念

  • 状态变量:被状态装饰器装饰的变量,状态变量值的改变会引起UI的渲染更新。示例:@State num: number = 1,其中,@State是状态装饰器,num是状态变量。
  • 常规变量:没有被状态装饰器装饰的变量,通常应用于辅助计算。它的改变永远不会引起UI的刷新。以下示例中increaseBy变量为常规变量。
  • 数据源/同步源:状态变量的原始来源,可以同步给不同的状态数据。通常意义为父组件传给子组件的数据。以下示例中数据源为count: 1。
  • 命名参数机制:父组件通过指定参数传递给子组件的状态变量,为父子传递同步参数的主要手段。示例:CompA: ({ aProp: this.aProp })。
  • 从父组件初始化:父组件使用命名参数机制,将指定参数传递给子组件。子组件初始化的默认值在有父组件传值的情况下,会被覆盖。示例:
@Component
struct MyComponent {
  @State count: number = 0;
  private increaseBy: number = 1;

  build() {
  }
}

@Component
struct Parent {
  build() {
    Column() {
      // 从父组件初始化,覆盖本地定义的默认值
      MyComponent({ count: 1, increaseBy: 2 })
    }
  }
}
  • 初始化子节点:父组件中状态变量可以传递给子组件,初始化子组件对应的状态变量。示例同上。
  • 本地初始化:在变量声明的时候赋值,作为变量的默认值。示例:@State count: number = 0。

3.1.2 装饰器总览

ArkUI提供了多种装饰器,通过使用这些装饰器,状态变量不仅可以观察在组件内的改变,还可以在不同组件层级间传递,比如父子组件、跨组件层级,也可以观察全局范围内的变化。根据状态变量的影响范围,将所有的装饰器可以大致分为:

  • 管理组件拥有状态的装饰器:组件级别的状态管理,可以观察组件内变化,和不同组件层级的变化,但需要唯一观察同一个组件树上,即同一个页面内。
  • 管理应用拥有状态的装饰器:应用级别的状态管理,可以观察不同页面,甚至不同UIAbility的状态变化,是应用内全局的状态管理。

从数据的传递形式和同步类型层面看,装饰器也可分为:

  • 只读的单向传递;
  • 可变更的双向传递。

ArkUI提供的装饰器如下图,开发者可以灵活地利用这些能力来实现数据和UI的联动。

5ArkUI提供的装饰器.png

上图中,Components部分的装饰器为组件级别的状态管理,Application部分为应用的状态管理。开发者可以通过@StorageLink/@LocalStorageLink实现应用和组件状态的双向同步,通过@StorageProp/@LocalStorageProp实现应用和组件状态的单向同步。

(1)管理组件拥有的状态 ,即图中Components级别的状态管理:

  • @State:@State装饰的变量拥有其所属组件的状态,可以作为其子组件单向和双向同步的数据源。当其数值改变时,会引起相关组件的渲染刷新。
  • @Prop:@Prop装饰的变量可以和父组件建立单向同步关系,@Prop装饰的变量是可变的,但修改不会同步回父组件。
  • @Link:@Link装饰的变量和父组件构建双向同步关系的状态变量,父组件会接受来自@Link装饰的变量的修改的同步,父组件的更新也会同步给@Link装饰的变量。
  • @Provide/@Consume:@Provide/@Consume装饰的变量用于跨组件层级(多层组件)同步状态变量,可以不需要通过参数命名机制传递,通过alias(别名)或者属性名绑定。
  • @Observed:@Observed装饰class,需要观察多层嵌套场景的class需要被@Observed装饰。单独使用@Observed没有任何作用,需要和@ObjectLink、@Prop连用。
  • @ObjectLink:@ObjectLink装饰的变量接收@Observed装饰的class的实例,应用于观察多层嵌套场景,和父组件的数据源构建双向同步。

仅@Observed/@ObjectLink可以观察嵌套场景,其他的状态变量仅能观察第一层,详情见各个装饰器章节的“观察变化和行为表现”小节。

(2)管理应用拥有的状态,即图中Application级别的状态管理:

  • AppStorage是应用程序中的一个特殊的单例LocalStorage对象,是应用级的数据库,和进程绑定,通过@StorageProp和@StorageLink装饰器可以和组件联动。
  • AppStorage是应用状态的“中枢”,将需要与组件(UI)交互的数据存入AppStorage,比如持久化数据PersistentStorage和环境变量Environment。UI再通过AppStorage提供的装饰器或者API接口,访问这些数据。
  • 框架还提供了LocalStorage,AppStorage是LocalStorage特殊的单例。LocalStorage是应用程序声明的应用状态的内存“数据库”,通常用于页面级的状态共享,通过@LocalStorageProp和@LocalStorageLink装饰器可以和UI联动。

(3)其他状态管理功能

除了前面提到的组件状态管理和应用状态管理,ArkTS还提供了@Watch和$$来为开发者提供更多功能:

@Watch用于监听状态变量的变化。

## 四、ArkTS渲染控制 ArkUI通过自定义组件的build()函数和@builder装饰器中的声明式UI描述语句构建相应的UI。在声明式描述语句中开发者除了使用系统组件外,还可以使用渲染控制语句来辅助UI的构建,这些渲染控制语句包括控制组件是否显示的条件渲染语句,基于数组数据快速生成组件的循环渲染语句以及针对大数据量场景的数据懒加载语句。 #### 4.1 条件渲染 ArkTS提供了渲染控制的能力。条件渲染可根据应用的不同状态,使用if、else和else if渲染对应状态下的UI内容。 #### 4.2 循环渲染 ForEach接口基于数组类型数据来进行循环渲染,需要与容器组件配合使用,且接口返回的组件应当是允许包含在ForEach父容器组件中的子组件。例如,ListItem组件要求ForEach的父容器组件必须为List组件。 #### 4.3 数据懒加载 LazyForEach从提供的数据源中按需迭代数据,并在每次迭代过程中创建相应的组件。当在滚动容器中使用了LazyForEach,框架会根据滚动容器可视区域按需创建组件,当组件滑出可视区域外时,框架会进行组件销毁回收以降低内存占用。 **《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,欢迎关注!**

Mac版微信开发者工具登录二维码不显示问题解决方案

Mac版微信开发者工具登录二维码不显示问题解决方案

问题背景

在使用Mac系统进行微信小程序开发时,很多开发者都会遇到一个常见问题:下载并安装微信开发者工具后,登录界面无法正常显示二维码,导致无法完成登录验证。这个问题通常与网络代理配置有关,本文将详细介绍如何通过手动配置代理来解决这个问题。

问题现象

  • 微信开发者工具启动正常,但登录界面空白
  • 提示"网络连接失败"或"无法获取登录信息"
  • 登录按钮点击后无响应
  • 控制台可能显示网络请求失败的错误信息

问题原因分析

1. 代理配置问题

微信开发者工具默认使用系统代理设置,如果系统代理配置不当或与微信服务器不兼容,就会导致无法正常获取登录二维码。

2. 网络环境限制

某些网络环境(如企业内网、校园网等)可能对微信相关域名进行了限制,影响开发者工具的正常连接。

3. 防火墙或安全软件拦截

系统防火墙或第三方安全软件可能误判微信开发者工具的网络请求,导致连接被阻止。

解决方案

方案一:手动配置代理设置(推荐)

步骤1:打开微信开发者工具设置
  1. 启动微信开发者工具
  2. 点击菜单栏中的"微信开发者工具" → "设置"
  3. 在弹出的设置窗口中选择"代理设置"
步骤2:配置代理选项
  1. 在代理设置页面,选择"手动设置代理"
  2. 根据您的网络环境配置以下参数:
    • HTTP代理:输入您的HTTP代理服务器地址和端口
    • HTTPS代理:输入您的HTTPS代理服务器地址和端口
    • SOCKS代理:如果使用SOCKS代理,输入相应配置
步骤3:保存并重启
  1. 点击"确定"保存设置
  2. 完全关闭微信开发者工具
  3. 重新启动开发者工具
  4. 检查登录界面是否正常显示二维码

方案二:禁用代理设置

如果您不需要使用代理,可以尝试以下步骤:

步骤1:系统代理设置
  1. 打开"系统偏好设置" → "网络"
  2. 选择当前使用的网络连接(Wi-Fi或以太网)
  3. 点击"高级"按钮
  4. 切换到"代理"选项卡
  5. 取消勾选所有代理选项
  6. 点击"好"并应用更改
步骤2:微信开发者工具设置
  1. 在微信开发者工具中进入"设置" → "代理设置"
  2. 选择"不使用任何代理"
  3. 保存设置并重启工具

方案三:更改默认浏览器

某些情况下,默认浏览器设置可能影响微信开发者工具的运行:

  1. 打开"系统偏好设置" → "通用"
  2. 在"默认网页浏览器"中选择"Safari"
  3. 重启微信开发者工具

验证解决方案

完成上述配置后,请按以下步骤验证问题是否解决:

  1. 重启微信开发者工具
  2. 检查登录界面:确认二维码是否正常显示
  3. 尝试登录:使用微信扫码登录,确认登录流程正常
  4. 测试功能:登录成功后,尝试创建或打开小程序项目,确认工具功能正常

常见问题解答

Q1:配置代理后仍然无法显示二维码怎么办?

A1:请检查代理服务器是否正常工作,可以尝试在浏览器中访问微信相关域名测试连接。

Q2:企业网络环境下如何配置?

A2:请联系网络管理员获取正确的代理配置信息,或申请将微信开发者工具相关域名加入白名单。

Q3:是否可以使用VPN解决?

A3:可以尝试使用VPN,但需要确保VPN连接稳定且支持微信相关域名的访问。

预防措施

为了避免类似问题再次发生,建议:

  1. 定期更新:保持微信开发者工具为最新版本
  2. 网络环境:在稳定的网络环境下使用开发者工具
  3. 备份配置:保存有效的代理配置,以便快速恢复
  4. 监控日志:关注开发者工具的控制台日志,及时发现网络问题

总结

微信开发者工具在Mac上登录时二维码不显示的问题,主要是由于代理配置不当导致的。通过手动配置代理设置,大多数情况下都能有效解决这个问题。如果问题仍然存在,建议检查网络环境或联系技术支持。

希望本文能够帮助遇到类似问题的开发者快速解决困扰,顺利开始微信小程序的开发工作。


作者提示:如果您在解决过程中遇到其他问题,欢迎在评论区分享您的经验和解决方案,帮助更多开发者。

🎙️ 站在巨人肩膀上:基于 SenseVoice.cpp 的前端语音识别实践

🎙️ 站在巨人肩膀上:基于 SenseVoice.cpp 的前端语音识别实践

image.png

最近在做一个项目,需要在前端实现实时语音识别,作为一个非 C++ 开发者踩了不少坑。好在有大佬开源的 SenseVoice.cpp 库和 AI 工具的帮助,总算搞出了一个能用的方案,今天分享一下折腾过程。

背景:为什么选择前端语音识别?

做过语音相关项目的同学都知道,传统的语音识别方案通常是这样的:

  1. 前端录音 → 上传到服务器 → 调用云端API → 返回结果
  2. 延迟高、成本贵、还要担心隐私问题

但如果是做实时字幕、语音笔记这类应用,用户体验就很糟糕了。每说一句话都要等个几秒钟,谁受得了?

所以我开始研究前端本地语音识别的方案。

技术选型:为什么是 SenseVoice + WebAssembly?

市面上的前端语音识别方案不多:

  • Web Speech API:兼容性差,Chrome 还行,Safari 基本废了
  • 各种云端API:又回到了延迟和隐私问题

直到我发现了 SenseVoice,这是阿里巴巴开源的多语言语音识别模型:

  • ✅ 支持中英日韩粤 5 种语言
  • ✅ 模型小(200MB 左右),加载快
  • ✅ 识别准确率高,特别是中文
  • ✅ 支持实时流式识别
  • ✅ 内置 VAD(语音活动检测)

更幸运的是,GitHub 上有大佬 @lovemefan 已经用 C++ 重写了推理引擎(SenseVoice.cpp),我只需要基于这个库编译成 WebAssembly 版本就行了。

实战:基于现有轮子快速上手

声明:这个 WASM 包是基于 SenseVoice.cpp 项目编译的,核心算法都是大佬们的工作,我只是做了个搬运工 + 简单封装。

1. 安装

npm install sense-voice-wasm

2. 基础使用

import SenseVoice from 'sense-voice-wasm';

// 创建实例
const senseVoice = new SenseVoice({
  use_vad: true,        // 开启语音检测
  language: 'zh',       // 中文识别
  vad_threshold: 0.5    // VAD 阈值
});

// 加载模型(从 HuggingFace 下载)
const success = await senseVoice.loadModel('/path/to/model.gguf');

// 识别音频
const pcmData = new Float32Array(/* 你的音频数据 */);
const result = await senseVoice.recognizeComplete(pcmData);
console.log('识别结果:', result);

3. 实时麦克风识别

这是最有意思的部分,实现实时语音转文字:

// 获取麦克风
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
const processor = audioContext.createScriptProcessor(4096, 1, 1);

senseVoice.resetStream();

processor.onaudioprocess = async (event) => {
  const inputData = event.inputBuffer.getChannelData(0);
  
  // 重采样到 16kHz
  const resampledData = resample(inputData, audioContext.sampleRate, 16000);
  
  // 实时识别
  const segments = await senseVoice.addAudioData(resampledData);
  
  segments.forEach(segment => {
    console.log(`[${segment.start_time.toFixed(2)}s]: ${segment.text}`);
    // 更新 UI 显示识别结果
    updateTranscription(segment.text);
  });
};

source.connect(processor);
processor.connect(audioContext.destination);

踩坑记录:解决卡顿问题

刚开始用的时候发现一个问题:开启 VAD 后页面会卡顿

作为前端开发,一开始完全不知道怎么办,后来在 Claude 的帮助下分析发现,onaudioprocess 每 128ms 就会调用一次,频繁的 VAD 计算阻塞了主线程。

解决方案:音频批处理

// 批处理优化
let audioBatchBuffer = new Float32Array(0);
let lastProcessTime = 0;
const PROCESS_INTERVAL_MS = 500; // 每500ms处理一次

processor.onaudioprocess = async (event) => {
  const inputData = event.inputBuffer.getChannelData(0);
  const resampledData = resample(inputData, audioContext.sampleRate, 16000);
  
  // 添加到缓冲区
  const newBuffer = new Float32Array(audioBatchBuffer.length + resampledData.length);
  newBuffer.set(audioBatchBuffer);
  newBuffer.set(resampledData, audioBatchBuffer.length);
  audioBatchBuffer = newBuffer;
  
  // 定时处理
  const now = Date.now();
  if (now - lastProcessTime >= PROCESS_INTERVAL_MS && audioBatchBuffer.length > 0) {
    const segments = await senseVoice.addAudioData(audioBatchBuffer);
    // 处理结果...
    audioBatchBuffer = new Float32Array(0);
    lastProcessTime = now;
  }
};

这个优化方案也是在 AI 工具的建议下实现的,页面总算流畅了!

实际效果如何?

基于这个封装做了个简单的语音笔记应用,测试效果:

  • 识别准确率:中文 95%+,英文 90%+
  • 资源占用:内存 ~200MB,CPU 30-50%
  • 模型大小:182MB(可接受)

特别是中文识别,得益于 SenseVoice 模型的优秀表现,比 Whisper 强不少,标点符号、数字、专业术语都能准确识别。

踩过的坑

1. 性能限制

⚠️ 重要提醒:当前方案只支持 CPU 推理,如果你的项目对性能要求很高,需要 WebGPU 加速推理,那么目前只有 sherpa-onnx 支持 WebGPU。这是一个比较大的限制,特别是在移动设备上可能会有性能瓶颈。

2. 音频格式要求严格

  • 必须是 16kHz 单声道 PCM
  • 数据类型:Float32Array
  • 取值范围:[-1.0, 1.0]

3. 浏览器兼容性

  • Chrome/Edge:完美支持
  • Firefox:需要启用 SharedArrayBuffer
  • Safari:部分功能受限

4. HTTPS 环境

多线程功能需要 SharedArrayBuffer,必须在 HTTPS 环境下使用。

5. 内存管理

记得及时调用 cleanup() 释放资源:

// 页面卸载时清理
window.addEventListener('beforeunload', () => {
  if (senseVoice) {
    senseVoice.cleanup();
  }
});

总结

感谢开源社区的贡献,SenseVoice WASM 让前端语音识别变得简单可行:

  • 🚀 性能好:WebAssembly 接近原生性能
  • 🔒 隐私安全:本地识别,数据不上传
  • 💰 成本低:无需调用云端API
  • 🌍 多语言:支持中英日韩粤
  • 实时性:低延迟响应

如果你也在做语音相关的项目,推荐试试这个方案。当然,如果你是 C++ 大佬,建议直接用原版的 SenseVoice.cpp,功能更完整。

资源链接


最后,如果这篇分享对你有帮助,记得点个赞👍,有问题欢迎在评论区讨论!

再次感谢 SenseVoice.cpp 的作者和开源社区,让我们这些非 C++ 开发者也能享受到优秀的语音识别能力。


👆正片软广都是AI写的

使用PySide6/PyQt6实现程序启动画面的处理

在 PySide6 / PyQt6 或其他 GUI 程序中,启动画面主要有以下几个作用:提升用户体验,当主程序初始化需要几秒钟时,如果界面一直空白,用户可能以为程序没响应;品牌展示,常见做法是在启动画面上放置 公司 Logo、应用图标、版本号、版权信息;程序架构上的过渡,启动画面在主窗口创建前显示,等主程序准备就绪后再关闭,起到 过渡和占位 的作用。

1、简单例子代码介绍

在 PySide6 / PyQt6 里要实现启动画面(Splash Screen),通常可以用 QSplashScreen 来完成,和 wx.adv.SplashScreen 类似。它的主要作用是在主窗口加载前,先显示一个过渡画面(通常放 logo、版本号、加载提示)。

简单的案例代码如下所示。

def main():
    app = QApplication(sys.argv)

    # 创建启动画面
    pixmap = QPixmap(400, 300)  # 可以替换为 QPixmap("logo.png")
    pixmap.fill(Qt.white)  # 这里用纯白背景
    splash = QSplashScreen(pixmap)
    splash.showMessage("正在加载,请稍候...", Qt.AlignBottom | Qt.AlignCenter, Qt.black)
    splash.show()

    # 模拟加载过程(比如初始化数据库、加载配置等)
    for i in range(1, 6):
        splash.showMessage(f"正在加载资源 {i}/5 ...", Qt.AlignBottom | Qt.AlignCenter, Qt.black)
        app.processEvents()  # 让界面刷新
        time.sleep(0.5)

    # 加载完成后进入主窗口
    window = MainWindow()

    # 延迟关闭启动画面并显示主窗口
    QTimer.singleShot(500, lambda: (
        splash.finish(window),
        window.show()
    ))

    sys.exit(app.exec())


if __name__ == "__main__":
    main()

关键点:

  1. QSplashScreen

    • 通过 QSplashScreen(QPixmap) 创建。
    • showMessage(text, alignment, color) 用来显示提示信息。
    • finish(widget) 在主窗口准备好后关闭 Splash,并显示目标窗口。
  2. app.processEvents()

    • 在耗时操作中调用,确保 Splash 画面能刷新,不会卡死。
  3. QTimer.singleShot()

    • 可以避免界面卡顿,等初始化完成后关闭启动画面。

2、我使用PySide6/PyQt6实现程序启动画面

参照上面的过程,我们可以改进下程序启动画面,并结合程序初始化等过程进行展示。

我们在程序登录界面展示,用户确认登录成功后,提示启动画面的。

image

 用户登录成功后,闪屏启动页面进行展示

image

 实现过程也是和上面的例子类似,不过增加了一些特殊的处理。

首先封装好显示闪屏界面的函数,如下所示。

def show_splash_screen():
    """显示启动闪屏"""
    splash_pix = QPixmap("app/images/splash.png")
    splash = QSplashScreen(splash_pix, Qt.WindowType.WindowStaysOnTopHint)

    # 设置字体
    font = QFont("Arial", 20, QFont.Weight.Bold)
    splash.setFont(font)
    splash.showMessage(
        "正在加载,请稍候...",
        Qt.AlignmentFlag.AlignBottom | Qt.AlignmentFlag.AlignCenter,
        Qt.GlobalColor.yellow,
    )
    splash.show()
    return splash

然后再启动的main.py的main函数中处理各个操作过程即可。

async def init_app():
    app = SystemApp()
    await app.SetLoginInfo()

def main():
    app = QApplication(sys.argv)

    event_loop = QEventLoop(app)
    asyncio.set_event_loop(event_loop)

    app_close_event = asyncio.Event()
    app.aboutToQuit.connect(app_close_event.set)

    app.setStyle("Fusion")  # 设置样式# 显示登录窗口
    loginDialog = FrmLogin()
    if loginDialog.exec() != QDialog.DialogCode.Accepted:
        # 如果登录失败或取消,程序退出
        sys.exit(0)

    # 显示闪屏
    splash = show_splash_screen()
    # 主窗口
    main_window = MainWindow()
    # 设置托盘图标
    setup_tray_icon(app, main_window)
    # 闪屏后显示主界面, 1秒后窗口最大化显示
    QTimer.singleShot(1000, lambda: (splash.close(), main_window.showMaximized()))

    # sys.exit(app.exec())

    with event_loop:
        event_loop.create_task(init_app())
        event_loop.run_until_complete(app_close_event.wait())

if __name__ == "__main__":
    main()

我们的主程序使用了异步的操作,因此和上面的例子有所差异,在用户登录成功后,前端会获得相关的用户身份信息,并在 init_app()  函数中进行用户身份信息的获取和设置。

我们把用户身份信息的处理,单独抽取出来,放在system_app类里面进行处理,如下所示,可以根据登录用户的信息,获取用户的当前拥有的功能权限,角色集合等等。

image

前面随笔我介绍过, 对于列表和对话框界面的封装,能够简化对泛型模型数据的统一处理,通用对于前端用户身份信息,我们也可以集中在基类中获取。

image

 编辑对话框的基类同样的处理。

image

 这样我们在用户前端界面中,需要设置用户当前信息的时候,就可以随时通过基类函数进行获取了。

上面代码,结合闪屏启动界面的处理过程,介绍了在用户登录成功后,对用户相关信息的处理过程。

3分钟搞定Vue组件库

还在为写前端页面发愁?还在为设计按钮、表格这些基础组件浪费时间?

经过上一篇《WebStorm代码一键美化》的学习,相信你已经掌握了 Prettier、ESLint、TypeScript 这三大开发神器。

今天,我要教你一个更厉害的招式:3分钟搞定高颜值UI组件库!学会这一招,你的前端开发效率将提升10倍,再也不用为界面美观度发愁。

为什么普通程序员都在用 Ant Design Vue?

想象一下:你正在写一个表单页面,需要设计按钮、输入框、日期选择器...光是调样式就得折腾半天。但如果有一套现成的、好看的组件呢?

这就是 Ant Design Vue 的魅力!它是蚂蚁金服团队打造的一套专业级组件库,就像一个装满了各种精美家具的宝库,拿来就用,不用自己造轮子。

想快速上手?直接参考 官方文档 就对了。

小贴士:本文使用的是 Ant Design Vue v4.2.6 版本,如果你跟着操作时发现语法不一样,记得检查版本号!

安装这个神器

安装 Ant Design Vue 组件库:

npm install ant-design-vue@4.2.6 --save

让所有组件都能用

main.ts 中引入 Ant Design Vue 组件库,为了方便起见,这里直接引入了所有组件,实际项目中可以按需引入。

// 引入 Ant Design Vue
import Antd from 'ant-design-vue';
import 'ant-design-vue/dist/reset.css';
// 使用 Ant Design Vue
app.use(Antd);

第三步:验证是否成功

至此,Ant Design Vue 组件库就引入完成了,接下来我们可以在项目中使用 Ant Design Vue 提供的各种组件了。

引入之后简单测试一下,随便找一个组件试用一下,在 App.vue 中,引入 Button 按钮组件:

<a-button type="primary">Primary Button</a-button>

效果如图:

写在最后

恭喜你!现在你已经成功掌握了 Ant Design Vue 的引入方法。从此告别手写样式的苦恼,再也不用为界面设计而头疼了。

记住这3个步骤:安装 → 引入 → 测试,不到3分钟就能让你的项目拥有专业级的UI组件库。

学会了这套方法,其他UI组件库的引入也是一样的套路。下次遇到新的组件库,你就知道该怎么快速上手了!

WebStorm代码一键美化

还在手动调整代码格式?还在为团队代码风格不统一而头疼?

相信很多朋友都遇到过这样的痛苦场景:

  • 写完代码一团糟,看着就难受
  • 团队成员代码风格千差万别,维护起来要命
  • 每次提交代码前都要手动整理格式,费时费力

上一篇《10分钟搞定Vue3项目》已经搭建好了项目基础架构,脚手架已经帮我们集成了三大神器:

  • Prettier:代码自动美化,告别手动格式化
  • ESLint:代码规范检查,统一团队风格
  • TypeScript:类型检查,减少低级错误

但是光有工具还不够,还得让你的开发工具"认识"这些神器才行!今天就用WebStorm来演示,教你如何让开发工具自动帮你干活。

让WebStorm认识Prettier

打开 Settings,搜索 Prettier,找到 Prettier 选项。

WebStorm Prettier配置界面

配置 Prettier package 为项目当中的 Prettier 包路径,点击右侧的 ... 选择项目当中的 node_modules/prettier 路径。

这里我还勾选了一个 Run on 'Reformat Code' action,意思是当我在 WebStorm 当中使用 Reformat Code 功能时,也会触发 Prettier 代码美化。

测试Prettier是否生效

好了这样 Prettier 就配置好了,接下来测试一下,随便找一个 Vue 文件,右键选择 Reformat with Prettier。

右键菜单选择Prettier格式化

可以看到代码被 Prettier 美化了。

代码格式化前后对比

只要在执行 Prettier 代码美化的时候不报错,表示配置工程化成功。

如果发现格式化效果不好,也没有关系,之后可以使用另外一种格式化方式。

ESLint配置(可选)

为了开发效率更高,可以关闭 ESLint 的校验导致的编译错误,同样可以在开发工具中禁用 ESLint。

打开 Settings,搜索 ESLint,找到 ESLint 选项,选择 Disable ESLint。

ESLint配置界面

如果要开启 ESLint 校验,选择 Automatic ESLint configuration,意思就是自动配置 ESLint。

还有就是修改 eslint.config.js、.prettierrc.json、tsconfig.json 文件可以改变 ESLint、Prettier、TypeScript 的校验规则。

如果不使用脚手架,就需要自己整合这些工具:

手动整合工具的复杂配置

工具配置文件示例

对于前端新手来说, 直接使用脚手架即可,省时省力。不需要再深入了解这些工具的配置细节。

纯当工具使用即可,应该把更多的精力放在业务代码的开发上。

Java MQTT 主流开发方案对比

楔子

最近在开发一个IOT平台,结合孪生可视化平台,做底层的数据采集和分析,正好涉及到各种协议的研究,包括Modbus,MQTT,Bacnet,COAP,OpcUa等等。下面是IOT数据采集平台的主要模块:

其中有设备接入,包括协议管理,产品分类,产品管理和设备管理。 协议管理的部分,就是各种协议的数据采集实现。 而产品 设备 会对接不同协议,实现具体的数据接入。

采集的数据可以应用到我们的数据孪生平台(webgl/UE/Unity多个技术融合的平台)该平台用于智慧园区,数字工厂,水务水利等多个行业的三维展示,动画播放,仿真模拟 , 数据融合,视频融合,如下图所示:

前言:MQTT——物联网时代的轻量级通信协议

随着物联网(IoT)设备的爆发式增长,设备间高效、可靠的通信需求日益迫切。传统的HTTP协议因高开销、低实时性等问题,难以满足海量设备低功耗、弱网络环境下的通信需求。在此背景下,MQTT(Message Queuing Telemetry Transport)凭借其轻量级、低带宽占用和发布/订阅模式,成为物联网领域的核心通信协议。

MQTT由IBM于1999年提出,2014年成为OASIS标准,其设计目标包括:

  • 极简协议头:固定头部仅2字节,降低传输开销;
  • 低功耗:支持持久化会话和遗嘱消息(Last Will),适应网络不稳定场景;
  • 灵活拓扑:通过Broker实现一对多通信,简化设备管理;
  • QoS分级:提供“至多一次”“至少一次”“恰好一次”三种消息传递保障。

在Java生态中,MQTT的开发方案多样,从开源的Eclipse Paho到企业级的HiveMQ,再到轻量级的Mosquitto,不同方案在性能、功能、适用场景上各有侧重。本文将从协议特性出发,对比主流Java MQTT开发方案,为开发者提供选型参考。


一、Eclipse Paho:全能型开源选手

核心优势

  1. 多语言支持与协议兼容性
    • 覆盖Java、C、Python等主流语言,支持MQTT 3.1.1和5.0协议,适配新旧项目。
    • 提供同步/异步API,满足不同开发需求。
  2. 开箱即用的功能
    • 内置连接池、自动重连、QoS分级(0/1/2),简化基础功能开发。
    • 支持SSL/TLS加密,保障数据传输安全。
  3. 活跃的社区与生态
    • 由Eclipse基金会维护,文档完善,插件扩展性强(如与Spring Boot集成)。

典型场景

  • 中小型物联网项目:如智能家居设备接入,通过简单配置实现设备与服务器通信。
  • 边缘计算:在资源受限的边缘设备上,利用其轻量级特性降低功耗。
  • 快速原型开发:结合Spring Boot,快速搭建物联网管理平台。

代码示例(Spring Boot集成)

java




@Configuration
public class MqttConfig {
    @Bean
    public MqttConnectOptions getMqttConnectOptions() {
        MqttConnectOptions options = new MqttConnectOptions();
        options.setServerURIs(new String[]{"tcp://localhost:1883"});
        options.setKeepAliveInterval(20);
        return options;
    }
}
 
@Service
public class MqttPublisher {
    public void publish(String topic, String payload) {
        MqttPahoMessageHandler handler = new MqttPahoMessageHandler("clientId", mqttClientFactory());
        handler.start();
        handler.handleMessage(new GenericMessage<>(payload));
    }
}

二、HiveMQ:企业级高并发利器

核心优势

  1. 极致性能与扩展性
    • 单节点支持百万级连接,延迟低于30ms,通过异步I/O和批量处理优化资源占用。
    • 支持动态负载均衡,业务增长时可无缝扩展集群节点。
  2. 企业级安全与可靠性
    • 内置TLS加密、JWT认证、消息持久化,满足金融级数据安全要求。
    • 提供可视化监控Dashboard,实时追踪吞吐量、连接状态。
  3. MQTT 5.0全面支持
    • 提供更精细的QoS控制、会话管理和属性扩展,适应复杂业务场景。

典型场景

  • 大型工业物联网:如智能工厂中,数万台设备需实时同步生产数据。
  • 金融实时数据同步:股票交易、支付系统等对延迟敏感的场景。
  • 高可靠消息通知:航空管制、医疗设备监控等需零丢失的系统。

性能对比

指标 Eclipse Paho HiveMQ
单节点连接数 千级 百万级
延迟 50-100ms <30ms
扩展性 手动扩展 自动集群扩展

三、Mosquitto:资源受限环境的轻量之选

核心优势

  1. 超小体积与低功耗
    • 代码不足200KB,内存占用低至15KB,适配单片机(如ESP8266)。
    • 优化电池供电设备的续航时间。
  2. 极简API与嵌入式兼容性
    • 支持同步/异步通信,提供主题通配符(#/+)和遗言机制。
    • 可直接嵌入到固件中,减少依赖复杂度。

典型场景

  • 智能家居传感器:温湿度传感器通过MQTT上报数据至网关。
  • 农业物联网:田间部署的土壤监测节点,使用Mosquitto降低功耗。
  • 可穿戴设备:智能手环等资源受限设备的数据同步。

代码示例(主题订阅)

java




MqttClient client = new MqttClient("tcp://localhost:1883", MqttClient.generateClientId());
client.connect();
client.subscribe("sensor/#", (topic, message) -> {
    System.out.println("Received: " + new String(message.getPayload()));
});

四、Spring Integration MQTT:企业应用集成专家

核心优势

  1. 高层次抽象与解耦
    • 通过网关通道分离业务逻辑与协议实现,降低代码耦合度。
    • 支持动态修改订阅主题,适应业务变化。
  2. 无缝集成Spring生态
    • 可扩展事务管理、消息转换器、过滤器等组件,构建复杂消息流。
    • 支持XML/注解配置,提升开发效率。

典型场景

  • 微服务架构:在Spring Cloud生态中实现服务间异步通信。
  • 复杂消息处理:订单系统根据消息内容路由至不同队列。
  • 遗留系统改造:通过适配器将旧系统接入MQTT网络。

配置示例(XML方式)

xml




<int-mqtt:message-driven-channel-adapter 
    id="mqttInbound"
    client-id="sensorGateway"
    url="tcp://localhost:1883"
    topics="alert/#"
    client-factory="clientFactory"
    channel="inputChannel"/>

五、方案选型建议

需求场景 推荐方案 关键考量
中小型项目/快速开发 Eclipse Paho 开源免费、社区活跃、文档丰富
高并发企业应用 HiveMQ 性能、集群扩展、企业级功能
资源受限设备 Mosquitto 体积、功耗、嵌入式兼容性
复杂企业集成 Spring Integration MQTT 与Spring生态集成、可扩展性
多语言混合开发 Eclipse Paho 跨语言支持、协议兼容性

六、未来趋势

  1. MQTT 5.0普及:HiveMQ等商业方案已全面支持,提供更精细的QoS控制和会话管理。
  2. 边缘计算融合:Eclipse Paho与Kubernetes集成,实现边缘设备的动态管理。
  3. 安全强化:TLS 1.3、双向认证成为标配,HiveMQ的JWT认证可防止未授权访问。

结语

MQTT协议的轻量级特性使其成为物联网通信的基石,而Java生态中丰富的开发方案则覆盖了从嵌入式设备到企业级集群的全场景需求。开发者应根据项目规模、性能要求及团队技术栈,选择最适合的方案:Eclipse Paho适合大多数中小型项目;HiveMQ是金融、工业等高可靠性场景的首选;Mosquitto则专为资源受限设备设计;而Spring Integration MQTT则适用于复杂企业集成。随着MQTT 5.0和边缘计算的普及,未来开发方案将更加注重性能、安全与可扩展性的平衡。

最后,关注公号“ITMan彪叔” 可以添加作者微信进行交流,及时收到更多有价值的文章。

React中的onChange事件:从原理到实践的全方位解析

深入理解React中onChange事件的工作机制,掌握表单处理的最佳实践

引言

在React应用开发中,表单处理是必不可少的部分。无论是简单的登录框、复杂的多步表单,还是简单的复选框交互,都离不开事件的正确处理。其中,onChange事件是最常用但也最容易误解的事件之一。本文将从基础概念出发,深入剖析onChange事件在React中的工作原理、使用场景和最佳实践。

什么是onChange事件?

onChange是React中的一个合成事件(SyntheticEvent),它是对原生DOM变更事件的跨浏览器包装。当用户与表单元素交互并改变其值时,就会触发这个事件。

与原生DOM的change事件不同,React的onChange事件行为更接近于原生的input事件,会在值每次改变时触发,而不是等到元素失去焦点时才触发。

基本用法:复选框示例

让我们从一个简单的复选框示例开始:

import { useState } from 'react';

function MyCheckbox() {
  const [liked, setLiked] = useState(true);

  function handleChange(e) {
    setLiked(e.target.checked);
  }

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={liked}
          onChange={handleChange}
        />
        I liked this
      </label>
      <p>You {liked ? 'liked' : 'did not like'} this.</p>
    </>
  );
}

ezgif-574f11ad88325a.gif

在这个例子中,我们创建了一个受控组件(Controlled Component):

  • checked属性绑定到liked状态变量
  • onChange事件绑定到handleChange处理函数
  • 当用户点击复选框时,会调用handleChange函数
  • handleChange通过e.target.checked获取当前状态并更新liked
  • 状态更新触发组件重新渲染,显示最新状态

没有onChange会发生什么?

如果去掉onChange={handleChange},代码会变成:

<input
  type="checkbox"
  checked={liked}
  // 没有onChange处理函数
/>

2.gif

这时会出现以下情况:

  1. 视觉上可交互但功能失效:复选框看起来可以点击,但实际无法改变状态
  2. 控制台警告:React会显示警告"You provided a checked prop to a form field without an onChange handler"
  3. 状态与UI不同步:用户交互不会更新组件状态,UI也不会重新渲染
  4. 形成只读复选框:实际上创建了一个无法通过交互改变的静态元素

这是因为React中的受控组件需要同时提供value(或checked)和onChangeprop,才能实现双向数据绑定。

onChange事件对象详解

onChange处理函数接收一个事件对象参数,通常称为eevent。这个事件对象是React封装后的合成事件,具有跨浏览器一致性。

对于不同类型的表单元素,可以从事件对象中获取不同的值:

// 对于复选框和单选按钮
function handleCheckboxChange(e) {
  console.log(e.target.checked); // 布尔值
}

// 对于文本输入框、选择框等
function handleInputChange(e) {
  console.log(e.target.value); // 字符串值
}

// 对于文件输入
function handleFileChange(e) {
  console.log(e.target.files); // 文件列表
}

在不同表单元素中的应用

文本输入框

function TextInput() {
  const [value, setValue] = useState('');
  
  const handleChange = (e) => {
    setValue(e.target.value);
  };
  
  return (
    <input
      type="text"
      value={value}
      onChange={handleChange}
      placeholder="Enter text..."
    />
  );
}

3.gif

单选按钮组

function RadioGroup() {
  const [selected, setSelected] = useState('option1');
  
  const handleChange = (e) => {
    setSelected(e.target.value);
  };
  
  return (
    <div>
      <label>
        <input
          type="radio"
          value="option1"
          checked={selected === 'option1'}
          onChange={handleChange}
        />
        Option 1
      </label>
      <label>
        <input
          type="radio"
          value="option2"
          checked={selected === 'option2'}
          onChange={handleChange}
        />
        Option 2
      </label>
    </div>
  );
}

4.gif

下拉选择框

function SelectExample() {
  const [selected, setSelected] = useState('apple');
  
  const handleChange = (e) => {
    setSelected(e.target.value);
  };
  
  return (
    <select value={selected} onChange={handleChange}>
      <option value="apple">Apple</option>
      <option value="banana">Banana</option>
      <option value="orange">Orange</option>
    </select>
  );
}

5.gif

高级用法与最佳实践

处理多个输入字段

当表单中有多个输入字段时,可以为每个字段编写单独的处理函数,也可以使用一个处理函数处理所有字段:

function MultiInputForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: ''
  });
  
  // 统一处理函数
  const handleInputChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };
  
  return (
    <form>
      <input
        type="text"
        name="username"
        value={formData.username}
        onChange={handleInputChange}
      />
      <input
        type="email"
        name="email"
        value={formData.email}
        onChange={handleInputChange}
      />
      <input
        type="password"
        name="password"
        value={formData.password}
        onChange={handleInputChange}
      />
    </form>
  );
}

6.gif

防抖处理

对于搜索框等需要频繁触发onChange的场景,可以使用防抖技术优化性能:

import { useDebouncedCallback } from 'use-debounce';

function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  
  // 防抖处理函数,300毫秒内只执行一次
  const debouncedSearch = useDebouncedCallback((searchTerm) => {
    // 执行搜索API调用
    fetchResults(searchTerm).then(setResults);
  }, 300);
  
  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value);
    debouncedSearch(value);
  };
  
  return (
    <div>
      <input
        type="text"
        value={query}
        onChange={handleChange}
        placeholder="Search..."
      />
      {/* 显示搜索结果 */}
    </div>
  );
}

自定义表单验证

结合onChange事件实现实时表单验证:

function ValidatedForm() {
  const [email, setEmail] = useState('');
  const [error, setError] = useState('');
  
  const validateEmail = (email) => {
    const regex = /^[^\s@]+@[^\s@]+.[^\s@]+$/;
    return regex.test(email);
  };
  
  const handleEmailChange = (e) => {
    const value = e.target.value;
    setEmail(value);
    
    if (!validateEmail(value)) {
      setError('Please enter a valid email address');
    } else {
      setError('');
    }
  };
  
  return (
    <div>
      <input
        type="email"
        value={email}
        onChange={handleEmailChange}
        placeholder="Enter your email"
      />
      {error && <div className="error">{error}</div>}
    </div>
  );
}

常见问题与解决方案

1. 为什么我的onChange事件没有触发?

  • 检查是否正确绑定了onChange处理函数
  • 确保没有使用readOnly或disabled属性
  • 确认事件处理函数没有阻止事件传播

2. 如何处理异步setState问题?

由于setState是异步的,如果需要在状态更新后执行操作,可以使用useEffect:

function AsyncExample() {
  const [value, setValue] = useState('');
  
  const handleChange = (e) => {
    setValue(e.target.value);
  };
  
  useEffect(() => {
    // 在value更新后执行的操作
    console.log('Value updated:', value);
  }, [value]);
  
  return <input value={value} onChange={handleChange} />;
}

3. 如何获取事件对象的持久引用?

React的合成事件会被回收以供后续重用,如果需要在异步操作中访问事件属性,需要先持久化:

function PersistentEventExample() {
  const handleChange = (e) => {
    // 持久化事件属性
    const value = e.target.value;
    
    // 异步操作中使用持久化的值
    setTimeout(() => {
      console.log('Value after delay:', value);
    }, 1000);
  };
  
  return <input onChange={handleChange} />;
}

总结

onChange事件是React表单处理的核心机制,它使得创建受控组件和实现双向数据绑定成为可能。通过深入理解其工作原理和应用场景,我们可以构建出更加交互丰富、用户体验良好的表单界面。

记住以下关键点:

  • 受控组件需要同时提供value/checkedonChangeprop
  • 不同类型表单元素从事件对象中获取值的方式不同
  • 可以使用统一处理函数处理多个输入字段
  • 对于频繁触发的情况,考虑使用防抖优化性能
  • 结合onChange事件可以实现实时表单验证

希望本文能帮助你更好地理解和应用React中的onChange事件,提升表单处理的技能水平。

从零到一打造 Vue3 响应式系统 Day 5 - 核心概念:单向链表、双向链表

ZuB1M1H.png

在昨天,我们建立了响应式的基本运作模式。在继续深入之前,要先了解 Vue 内部用来优化性能的一个核心概念:数据结构。Vue 3 的响应式系统之所以效率高,其内部对数据结构的选择是关键。

一个理想的数据结构需要能有效处理以下操作:

  • 动态关联:effect 与数据之间的依赖关系是能动态建立与解除的。
  • 快速增删:当依赖关系变化时,需要快速地执行新增或移除操作。

为了满足这些高性能要求,Vue 选择了链表 (Linked List) 作为解决方案。本文将深入探讨其运作原理。

单向链表

  • 类型是对象
  • 第一个节点是头节点、最后一个节点称为尾节点
  • 所有节点都通过 next 属性连接起来。

day05-01.png

// 头节点是 head
let head = { value: 1, next: undefined }
const node2 = { value: 2, next: undefined }
const node3 = { value: 3, next: undefined }
const node4 = { value: 4, next: undefined }

// 建立链表之间的关系
head.next = node2
node2.next = node3
node3.next = node4

删除中间节点

假设我们要删除 node3,但在单向链表中,仅凭 node3 本身的引用是无法直接进行操作的,因为我们无法访问到它的前一个节点 (node2) 。因此,我们必须从头节点 (head) 开始遍历,直到找到 node2 为止:

const node3 = { value: 3, next: undefined }

let current = head
while (current) {
  // 找到 node3 的上一个节点
  if (current.next === node3) {
    // 把 node3 的上一个节点指向 node3 的下一个节点
    current.next = node3.next
    break
  }
  current = current.next
}

console.log(head) // 输出新的链表 1->2->4

双向链表

  • 每个节点都有:

    • value: 存储的值
    • next: 指向下一个节点
    • prev: 指向上一个节点
  • 双向链表中,通常头节点没有 prev,尾节点没有 next

它最大的优势在于,从任何一个节点出发,都能够双向遍历,这使得在特定节点前后进行新增或删除操作都非常快速。

// 假设链表的头节点是 head
let head = { value: 1, next: undefined, prev: undefined }
const node2 = { value: 2, next: undefined, prev: undefined }
const node3 = { value: 3, next: undefined, prev: undefined }
const node4 = { value: 4, next: undefined, prev: undefined }

// 建立链表之间的关系
head.next = node2
// node2 的上一个节点指向 head
node2.prev = head
// node2 的下一个节点指向 node3
node2.next = node3
// node3 的上一个节点指向 node2
node3.prev = node2
// node3 的下一个节点指向 node4
node3.next = node4
// node4 的上一个节点指向 node3
node4.prev = node3

删除中间节点

假设我们现在手上有中间节点 node3 要删除,该怎么做:

const node3 = { value: 3, next: undefined, prev: undefined }

// 如果 node3 有上一个节点,就把上一个节点的 next 指向 node3 的下一个节点
if (node3.prev) {
  node3.prev.next = node3.next
} else {
  // 如果 node3 没有上一个节点,说明它是头节点
  head = node3.next
}

// 如果 node3 有下一个节点,就把下一个节点的 prev 指向 node3 的上一个节点
if (node3.next) {
  node3.next.prev = node3.prev
}
console.log(head) // 输出新的链表 1->2->4

可以看到,在已知目标节点的前提下,执行删除操作完全不需要从头遍历,时间复杂度为 O(1)。

单向链表与双向链表比较

现在我们要在 C 节点之前新增一个 X 节点。

单向链表

day05-02.png

  • 时间复杂度:O(n)
  • 原因:需要遍历才能找到前一个节点。

执行步骤

步骤 1:从头节点开始遍历查找。

步骤 2:检查节点 A,不是目标节点的前一个,继续遍历。

步骤 3:找到目标节点 C 的前一个节点 B(因为 B 的 next 属性是 C)。

步骤 4:创建新节点 X。

步骤 5:设置 X.next = C

步骤 6:设置 B.next = X

双向链表

day05-03.png

  • 时间复杂度:O(1)
  • 原因:直接通过 prev 指针访问前一个节点。

执行步骤

步骤 1:直接通过目标节点的 prev 指针找到前一个节点 B。

步骤 2:创建新节点 X。

步骤 3:设置 X.next = C, X.prev = B

步骤 4:设置 B.next = X, C.prev = X


我们可以发现:

  • 单向链表:结构简单,适合只需要向前遍历的场景。
  • 双向链表:更灵活但占用更多内存,适合需要双向操作的场景。

到目前为止,我们已经了解了链表的原理。然而在许多可以用来存储数据集合的结构中,为什么 Vue 的响应式系统会选择链表,而不是我们更常用的数组 (Array) 呢?

链表与数组的比较

特性

数组 (Array) 最大的优点是读取性能极佳。由于内存空间是连续的,我们可以通过索引 [i] 直接定位到任何元素,时间复杂度为 O(1)。

const arr = ['a', 'b', 'c', 'd'] // a=>0  b=>1  c=>2  d=>3

// 删除数组的第一项
arr.shift()

console.log(arr) // ['b', 'c', 'd'] b=>0  c=>1  d=>2

链表:新增、删除元素更快 (O(1)),但查找元素需要遍历整个链表(O(n))。

// 头节点是 head
let head = {
  value: 1,
  next: {
    value: 2,
    next: {
      value: 3,
      next: {
        value: 4, 
        next: null
      }
    }
  }
}
// 删除链表第一个节点
head = head.next // 将头节点指向下一个节点 node2
console.log(head)
// 输出新的头节点 [2, 3, 4]

删除头、尾项

数组

  • 新增操作(如 unshift)需要移动后续所有元素,可能导致性能下降(O(n))。
  • 删除操作(如 shift)同样需要移动后续所有元素,性能也为(O(n))。

链表

  • 新增操作只需修改指针,性能为 O(1)。
  • 删除操作也只需修改指针,性能为 O(1)。

总的来说,虽然双向链表在内存占用上略高于单向链表,但它提供的 O(1) 复杂度的新增与删除方法,对于需要频繁操作依赖集合的响应式系统来说,是非常重要的。

我们理解了链表的运作原理后,明天我们会继续在 ref 的实现中,结合今天学到的链表知识来改造响应式系统。


想了解更多 Vue 的相关知识,抖音、B站搜索我师父「远方os」,一起跟日安当同学。

Vue3 后台分页写腻了?我用 1 个 Hook 删掉 90% 重复代码(附源码)

还在为每个列表页写重复的分页代码而烦恼吗? 还在复制粘贴 currentPage、pageSize、loading 等状态吗? 一个 Hook 帮你解决所有分页痛点,减少90%重复代码

背景与痛点

在后台管理系统开发中,分页列表查询非常常见,我们通常需要处理:

  • 当前页、页大小、总数等分页状态
  • 加载中、错误处理等请求状态
  • 搜索、刷新、翻页等分页操作
  • 数据缓存和重复请求处理

这些重复逻辑分散在各个组件中,维护起来很麻烦。

为了解决这个烦恼,我专门封装了分页数据管理 Hook。现在只需要几行代码,就能轻松实现分页查询,省时又高效,减少了大量重复劳动

使用前提 - 接口格式约定

查询接口返回的数据格式:

{
  list: [        // 当前页数据数组
    { id: 1, name: 'user1' },
    { id: 2, name: 'user2' }
  ],
  total: 100     // 数据总条数
}

先看效果:分页查询只需几行代码!

import usePageFetch from '@/hooks/usePageFetch' // 引入分页查询 Hook,封装了分页逻辑和状态管理
import { getUserList } from '@/api/user'        // 引入请求用户列表的 API 方法

// 使用 usePageFetch Hook 实现分页数据管理
const {
  currentPage,      // 当前页码
  pageSize,         // 每页条数
  total,            // 数据总数
  data,             // 当前页数据列表
  isFetching,       // 加载状态,用于控制 loading 效果
  search,           // 搜索方法
  onSizeChange,     // 页大小改变事件处理方法
  onCurrentChange   // 页码改变事件处理方法
} = usePageFetch(
  getUserList,  // 查询API
  { initFetch: false }  // 是否自动请求一次(组件挂载时自动拉取第一页数据)     
)

这样子每次分页查询只需要引入hook,然后传入查询接口就好了,减少了大量重复劳动

解决方案

我设计了两个相互配合的 Hook:

  • useFetch:基础请求封装,处理请求状态和缓存
  • usePageFetch:分页逻辑封装,专门处理分页相关的状态和操作
usePageFetch (分页业务层)
├── 管理 page / pageSize / total 状态
├── 处理搜索、刷新、翻页逻辑  
├── 统一错误处理和用户提示
└── 调用 useFetch (请求基础层)
    ├── 管理 loading / data / error 状态
    ├── 可选缓存机制(避免重复请求)
    └── 成功回调适配不同接口格式

核心实现

useFetch - 基础请求封装

// hooks/useFetch.js
import { ref } from 'vue'

const Cache = new Map()

/**
 * 基础请求 Hook
 * @param {Function} fn - 请求函数
 * @param {Object} options - 配置选项
 * @param {*} options.initValue - 初始值
 * @param {string|Function} options.cache - 缓存配置
 * @param {Function} options.onSuccess - 成功回调
 */
function useFetch(fn, options = {}) {
  const isFetching = ref(false)
  const data = ref()
  const error = ref()

  // 设置初始值
  if (options.initValue !== undefined) {
    data.value = options.initValue
  }

  function fetch(...args) {
    isFetching.value = true
    let promise

    if (options.cache) {
      const cacheKey = typeof options.cache === 'function'
        ? options.cache(...args)
        : options.cache || `${fn.name}_${args.join('_')}`

      promise = Cache.get(cacheKey) || fn(...args)
      Cache.set(cacheKey, promise)
    } else {
      promise = fn(...args)
    }

    // 成功回调处理
    if (options.onSuccess) {
      promise = promise.then(options.onSuccess)
    }

    return promise
      .then(res => {
        data.value = res
        isFetching.value = false
        error.value = undefined
        return res
      })
      .catch(err => {
        isFetching.value = false
        error.value = err
        return Promise.reject(err)
      })
  }

  return {
    fetch,
    isFetching,
    data,
    error
  }
}

export default useFetch

usePageFetch - 分页逻辑封装

// hooks/usePageFetch.js
import { ref, onMounted, toRaw, watch } from 'vue'
import useFetch from './useFetch' // 即上面的hook ---> useFetch 
import { ElMessage } from 'element-plus'

/**
 * 分页数据管理 Hook
 * @param {Function} fn - 请求函数
 * @param {Object} options - 配置选项
 * @param {Object} options.params - 默认参数
 * @param {boolean} options.initFetch - 是否自动初始化请求
 * @param {Ref} options.formRef - 表单引用
 */
function usePageFetch(fn, options = {}) {
  // 分页状态
  const page = ref(1)
  const pageSize = ref(10)
  const total = ref(0)
  const data = ref([])
  const params = ref()
  const pendingCount = ref(0)

  // 初始化参数
  params.value = options.params

  //  使用基础请求 Hook
  const { isFetching, fetch: fetchFn, error, data: originalData } = useFetch(fn)

  //  核心请求方法
  const fetch = async (searchParams, pageNo, size) => {
    try {
      // 更新分页状态
      page.value = pageNo
      pageSize.value = size
      params.value = searchParams

      // 发起请求
      await fetchFn({
        page: pageNo,
        pageSize: size,
        // 使用 toRaw 避免响应式对象问题
        ...(searchParams ? toRaw(searchParams) : {})
      })

      // 处理响应数据
      data.value = originalData.value?.list || []
      total.value = originalData.value?.total || 0
      pendingCount.value = originalData.value?.pendingCounts || 0
    } catch (e) {
      console.error('usePageFetch error:', e)
      ElMessage.error(e?.msg || e?.message || '请求出错')
      // 清空数据,提供更好的用户体验
      data.value = []
      total.value = 0
    }
  }

  //  搜索 - 重置到第一页
  const search = async (searchParams) => {
    await fetch(searchParams, 1, pageSize.value)
  }

  // 刷新当前页
  const refresh = async () => {
    await fetch(params.value, page.value, pageSize.value)
  }

  // 改变页大小
  const onSizeChange = async (size) => {
    await fetch(params.value, 1, size) // 重置到第一页
  }

  // 切换页码
  const onCurrentChange = async (pageNo) => {
    await fetch(params.value, pageNo, pageSize.value)
  }

  // 组件挂载时自动请求
  onMounted(() => {
    if (options.initFetch !== false) {
      search(params.value)
    }
  })

  // 监听表单引用变化(可选功能)
  watch(
    () => options.formRef,
    (formRef) => {
      if (formRef) {
        console.log('Form ref updated:', formRef)
      }
    }
  )

  return {
    // 分页状态
    currentPage: page,
    pageSize,
    total,
    pendingCount,
    
    // 数据状态
    data,
    originalData,
    isFetching,
    error,
    
    // 操作方法
    search,
    refresh,
    onSizeChange,
    onCurrentChange
  }
}

export default usePageFetch

完整使用示例

用element ui举例

<template>
    <el-form :model="searchForm" >
      <el-form-item label="用户名">
        <el-input v-model="searchForm.username" />
      </el-form-item>
      <el-form-item>
        <el-button type="primary" @click="handleSearch">搜索</el-button>
      </el-form-item>
    </el-form>
  <!-- 表格数据展示,绑定 data 和 loading 状态 -->
  <el-table :data="data" v-loading="isFetching">
    <!-- ...表格列定义... -->
  </el-table>

  <!-- 分页组件,绑定当前页、页大小、总数,并响应切换事件 -->
  <el-pagination
    v-model:current-page="currentPage"
    v-model:page-size="pageSize"
    :total="total"
    @size-change="onSizeChange"
    @current-change="onCurrentChange"
  />
</template>
<script setup>
import { ref } from 'vue'
import usePageFetch from '@/hooks/usePageFetch' // 引入分页查询 Hook,封装了分页逻辑和状态管理
import { getUserList } from '@/api/user'        // 引入请求用户列表的 API 方法

// 搜索表单数据,响应式声明
const searchForm = ref({
  username: ''
})

// 使用 usePageFetch Hook 实现分页数据管理
const {
  currentPage,      // 当前页码
  pageSize,         // 每页条数
  total,            // 数据总数
  data,             // 当前页数据列表
  isFetching,       // 加载状态,用于控制 loading 效果
  search,           // 搜索方法
  onSizeChange,     // 页大小改变事件处理方法
  onCurrentChange   // 页码改变事件处理方法
} = usePageFetch(
  getUserList, 
  { initFetch: false }  // 是否自动请求一次(组件挂载时自动拉取第一页数据)     
)

/**
* 处理搜索操作
*/
const handleSearch = () => {
  search({ username: searchForm.value.username })
}

</script>

高级用法

带缓存

const {
  data,
  isFetching,
  search
} = usePageFetch(getUserList, {
  cache: (params) => `user-list-${JSON.stringify(params)}` // 自定义缓存 key
})

设计思路解析

  • 职责分离:useFetch 专注请求状态管理,usePageFetch 专注分页逻辑
  • 统一错误处理:在 usePageFetch 层统一处理错误
  • 智能缓存机制:支持多种缓存策略
  • 生命周期集成:自动在组件挂载时请求数据

总结

这套分页管理 Hook 的优势:

  • 开发效率高,减少90%的重复代码,新增列表页从 30 分钟缩短到 5 分钟
  • 状态管理完善,自动处理加载、错误、数据状态
  • 缓存机制,避免重复请求
  • 错误处理统一,用户体验一致
  • 易于扩展,支持自定义配置和回调

如果觉得对您有帮助,欢迎点赞 👍 收藏 ⭐ 关注 🔔 支持一下!

JS 打造「放大镜 + 缩略图」一体组件

电商详情页的经典三件套:缩略图列表 + 中等图展示 + 局部放大图。本文js原生代码实现一条无依赖、可复用、可扩展的放大镜链路,涵盖事件委托、边界计算、背景定位三大核心技能。

效果预览

JS 打造「放大镜 + 缩略图」一体组件.gif

一、HTML 骨架

<div class="container">
  <!-- 中等图 -->
  <div class="left-img">
    <div class="mask"></div>
  </div>
  <!-- 放大图 -->
  <div class="right-img"></div>
  <!-- 缩略图列表 -->
  <ul class="img-list"></ul>
</div>
  • left-img:中等图,承担点击切换与鼠标探测双重职责
  • mask:绝对定位遮罩,用于局部高亮与边界计算
  • right-img:放大图,背景图定位实现局部放大效果

二、数据约定

var imgs = {
  small: ['imgA_1.jpg', 'imgB_1.jpg', 'imgC_1.jpg'],
  middle: ['imgA_2.jpg', 'imgB_2.jpg', 'imgC_2.jpg'],
  large: ['imgA_3.jpg', 'imgB_3.jpg', 'imgC_3.jpg']
}
  • 三张图按索引一一对应,切换时只需同步 backgroundImage
  • 缩略图定宽定高,中等图与放大图定宽不定高,保持比例

三、JS链式事件三步走

1.初始化:渲染缩略图 + 默认激活

function initPage() {
  let html = ''
  for (let i = 0; i < imgs.small.length; i++) {
    html += `<li style="background-image:url(./images/${imgs.small[i]});"></li>`
  }
  $('.img-list').innerHTML = html
  $('.img-list li').style.border = '2px solid #000'   // 默认选中第一张
}

2.点击缩略图:同步切换中等图与大图

$('.img-list').onclick = function (e) {
  if (e.target.tagName !== 'LI') return
  // 让所有 LI 失活
  $$('li').forEach(li => li.style.border = 'none')
  // 让当前 LI 激活
  e.target.style.border = '2px solid #000'

  const index = [].indexOf.call(this.children, e.target)
  $('.left-img').style.backgroundImage = `url(./images/${imgs.middle[index]})`
  $('.right-img').style.backgroundImage = `url(./images/${imgs.large[index]})`
}

使用事件委托,缩略图数量随意增减,无需重新绑定。

3.鼠标移动:遮罩层跟随 + 大图定位

$('.left-img').onmousemove = function (e) {
  const mask = $('.mask')
  const large = $('.right-img')
  mask.style.opacity = 1
  large.style.opacity = 1

  // 计算遮罩层中心坐标
  let left = e.clientX - this.offsetLeft - mask.offsetWidth / 2
  let top = e.clientY - this.offsetTop - mask.offsetHeight / 2

  // 边界条件:不让遮罩跑出中等图
  left = Math.max(0, Math.min(left, this.offsetWidth - mask.offsetWidth))
  top = Math.max(0, Math.min(top, this.offsetHeight - mask.offsetHeight))

  mask.style.left = left + 'px'
  mask.style.top = top + 'px'

  // 大图反向移动,实现局部放大
  large.style.backgroundPositionX = -left + 'px'
  large.style.backgroundPositionY = -top + 'px'
}

边界计算拆解

  • left ≤ 0 时强制归零
  • left ≥ (中等图宽 - 遮罩宽) 时强制贴边
  • 同理处理 top 高度方向

背景定位技巧

遮罩向右移动 10px,大图背景向左移动 10px,形成「窗口」效果;Y 轴同理。


四、鼠标离开:自动隐藏

$('.left-img').onmouseleave = function () {
  $('.mask').style.opacity = 0
  $('.right-img').style.opacity = 0
}

只用 opacity 控制显隐,避免 display: none 触发重排;背景图已预加载,无闪烁。

性能 & 扩展

  • 零依赖:原生 DOM API,gzip < 1 KB
  • 零重排:仅用 opacitybackground-position,不改动布局
  • 可异步:把 initPage 换成 fetch 即可接入后端图片列表
  • 可响应:把 offsetWidth 换成 getBoundingClientRect 即可适配缩放

全面开发!可同时支持“倾斜模型”以及“地形”的填挖方(土方)分析工具

大家好,我是日拱一卒的攻城师不浪,致力于前沿科技探索,摸索小而美工作室,这是2025年输出的第59/100篇原创文章。

效果预览

www.bilibili.com/video/BV1bg…

前言

在工程建设领域,土方量的精确计算一直是个技术活。传统的测量方式不仅耗时费力,而且精度难以保证。这时候,就体现出数字化能力的重要性了!

今天,我们用Cesium来构建一个专业级的填挖方分析工具,让复杂的土方计算变得简单、直观又精确。

为什么选择数字化土方分析?

想象一下,你在施工现场需要计算一片不规则区域的土方量。传统方法需要大量的实地测量点,然后用复杂的数学公式进行计算。

而我们的数字化方案,只需要在三维场景中圈定区域,系统就能自动完成复杂的计算工作。

这套系统主要解决三个核心问题:

  • 交互性:用户可以直接在三维场景中绘制分析区域

  • 精确性:基于高精度地形数据进行体积计算

  • 可视化:直观展示填方、挖方的分布情况

支持倾斜摄影测量

技术原理:把复杂问题简单化

多边形细分:化整为零

首先,我们来看看系统是如何处理用户绘制的不规则多边形的。在 ExcavationAndFillAnalysis 类中,有这样一段关键代码:

createPolygonGeo(points) {
    let granularity = Math.PI / Math.pow(2, 11);
    granularity = granularity / this.precision;
    let polygonGeometry = new Cesium.PolygonGeometry.fromPositions({
        positions: points,
        vertexFormat: Cesium.PerInstanceColorAppearance.FLAT_VERTEX_FORMAT,
        granularity: granularity,
    });
    this.geom = Cesium.PolygonGeometry.createGeometry(polygonGeometry);
}

这里的 granularity 参数很有意思,它控制着多边形的细分程度。Math.PI / Math.pow(2, 11) 计算出约等于0.00153的弧度值,这个值除以精度参数后,决定了多边形会被分解成多少个小三角形。

精度越高,三角形越多,计算越精确,但性能开销也越大。

海伦公式:古老数学在现代工程中的应用

对于每个小三角形,我们需要计算它的面积。这里用到了著名的海伦公式:

computeArea4Triangle(pos1, pos2, pos3) {
    let a = Cesium.Cartesian3.distance(pos1, pos2);
    let b = Cesium.Cartesian3.distance(pos2, pos3);
    let c = Cesium.Cartesian3.distance(pos3, pos1);
    let S = (a + b + c) / 2;
    return Math.sqrt(S * (S - a) * (S - b) * (S - c));
}

海伦公式的美妙之处在于,只需要知道三角形的三边长度,就能算出面积。

在我们的场景中,pos1pos2pos3 是投影到水平面的三个顶点,通过 Cesium.Cartesian3.distance 计算出边长,然后套用公式即可。

填挖方判断依据:基准面

最核心的逻辑在于如何判断某个区域是需要填方还是挖方:

const averageHeight = (returnPosition.pos0.height + returnPosition.pos1.height + returnPosition.pos2.height) / 3;

if (averageHeight < this.height) {
    // 需要填方的部分
    fillArea += area;
    fillVolume = fillVolume + area * (this.height - averageHeight);
} else {
    // 需要挖方的部分  
    cutArea += area;
    cutVolume = cutVolume + area * (averageHeight - this.height);
}

一个简单而有效的思路:

  • 计算三角形三个顶点的平均高度

  • 与设定的基准面高度 this.height 比较

  • 低于基准面的需要填方,高于基准面的需要挖方

  • 体积 = 面积 × 高度差

交互式绘制:让用户体验更友好

DrawPolygon 类负责处理用户的交互操作:

start(callback) {
    this.handler = new Cesium.ScreenSpaceEventHandler(this.viewer.scene.canvas);
    this.handler.setInputAction((event) => {
        let earthPosition = this.viewer.scene.pickPosition(event.position);
        // 处理左键点击,添加顶点
        this.activeShapePoints.push(earthPosition);
        this.createPoint(earthPosition);
    }, Cesium.ScreenSpaceEventType.LEFT_CLICK);
    
    this.handler.setInputAction((event) => {
        // 处理鼠标移动,实时更新多边形
        let newPosition = this.viewer.scene.pickPosition(event.endPosition);
        this.floatingPoint.position.setValue(newPosition);
    }, Cesium.ScreenSpaceEventType.MOUSE_MOVE);
}

让用户可以:

  • 左键点击:添加多边形顶点

  • 鼠标移动:实时预览多边形形状

  • 右键点击:完成绘制并开始分析

  • Ctrl+Z:撤销上一个点

地形高度采样:精度的保障

returnPosition 方法中,有一个关键的高度获取逻辑:

let height = this.viewer.scene.sampleHeightSupported
    ? this.viewer.scene.sampleHeight(cartographic)
    : this.viewer.scene.globe.getHeight(cartographic);

这里做了一个兼容性处理:

  • 优先使用 sampleHeight,它能提供更精确的高度信息

  • 如果不支持,则使用 getHeight 作为备选方案

这种设计确保了在不同的 Cesium 版本和配置下,都能获取到可靠的地形高度数据。

可视化展示:让数据说话

计算出结果,要直观地展示出来:

// 计算三个点的平均高度
const averageHeight = (pos0.height + pos1.height + pos2.height) / 3;
// 根据平均高度判断是挖方(红色)还是填方(绿色)
const materialColor = averageHeight < this.height ? Cesium.Color.GREEN : Cesium.Color.RED;

let polygon = this.viewer.entities.add({
    polygon: {
        hierarchy: [pos0.topheightPos, pos1.topheightPos, pos2.topheightPos],
        perPositionHeight: true,
        material: materialColor,

        extrudedHeight: this.height,
    },
});
this.entitypolygon.push(polygon);

根据三角形的三个点计算他们的平均高度,然后跟基准面高度进行对比,小于基准面的(填方)用绿色,大于基准面(挖方)的用红色表示。

性能优化的考量

我们在工具中添加了对不同精度的支持:

const fly3DTiles = async () => {
    precision.value = 256  // 3D模型场景使用较高精度
};

const flyMountain = () => {
    precision.value = 20   // 山体地形使用较低精度
};

这是一个很实用的优化策略:

  • 3D建筑模型:细节丰富,需要高精度分析

  • 自然地形:相对平缓,可以适当降低精度以提升性能

实际应用场景

这套系统特别适合以下场景:

  • 建筑工地:计算基础开挖和回填土方量

  • 道路建设:分析路基的填挖方分布

  • 景观设计:规划地形改造的工程量

  • 矿山开采:估算开采和复垦的土方需求

最后

通过 Cesium 强大的三维能力,我们将复杂的土方分析变成了一个直观、精确、易用的数字化工具。

该系统目前可同时支持倾斜摄影以及山地地形的填挖方测量!

支持地形测量

想系统学习Cesium的小伙伴儿,可以了解下不浪的教程《Cesium从入门到实战》,将Cesium的知识点进行串联,让不了解Cesium的小伙伴拥有一个完整的学习路线,并最终完成一个智慧城市的完整项目,关注公众号:攻城师不浪,即可获取教程介绍,也可+作者:brown_7778(备注来意)。

有需要进可视化&Webgis交流群可以加我:brown_7778(备注来意)。

『译』资深前端开发者如何看待React架构

原文:How Senior Frontend Developers think about React Architecture

作者:Scripting Soul

小声 BB

本文在翻译过程中确保意思传达准确的前提下,会加入很多本人的个人解释和一些知识补充(使用引用块&&括号标注) ,像这样

(我是一个平平无奇的知识补充块)

🎊如果觉得文章内容有用,交个朋友,点个赞再走~ 🎊

一些比较清晰的视角,可以学习到如何高屋建瓴地去构造 React 组件。和读者分享什么才是一个资深开发者应该关注和做到的事情。

正文

好久没写了。

当我们谈论 React 时,我们通常会想到组件。无论是一个简单的按钮、一整个表格,还是一个带图表的完整仪表盘页面。这是我们大多数人开始时的方式。我们打开设计稿,看看屏幕上有什么,然后尝试用代码去还原。

但是,当我们已经开发 React 应用多年,做过数十个功能、上千个组件、几百个边界情况后,我们会意识到,React 其实不只是关于组件,而是关于架构。

而高级前端开发者看待架构的方式,和初级开发者完全不同。我在许多文章中都强调过这一点。这并不是因为他们知道某本书里隐藏的某个神秘设计模式,而是因为……他们看待系统的方式不一样。

img

架构在第一个组件之前就开始了

初级开发者从 UI 开始:

“这是一个界面,我先来为它构建组件。”

资深开发者不会从这里开始。他们从边界开始。

  • 这个功能属于哪里?
  • 它依赖什么?
  • 谁拥有数据,这些数据应该传播到什么程度?

他们认为组件不是第一步,而是最后一步。组件只是更深层次内容的表层。其下隐藏着一个流程:数据、状态、业务规则、副作用,最后才是 UI。

这种思维转变改变了一切。它能防止在功能增长时架构的崩塌。在我看来,这是最重要的事情之一。

他们保持关注点分离

资深开发者通过艰难的经验学到一件事:当关注点混在一起时,项目就会(某种程度上)腐烂(变成屎山代码)。

初级开发者会很开心地在组件里放一个 API 调用,把表单验证和 UI 渲染混在一起,然后到处加 useEffect 来修修补补。它确实能工作……(能撑住一段时间)。

资深开发者会保持分离:

  • UI 层:纯粹的展示型组件。没有逻辑,没有副作用。
  • 状态层:数据存储、更新和同步的地方。
  • 领域逻辑层:业务规则所在,独立于 UI。

为什么要这么严格?因为分离带来的是自由。如果 API 变了,只需要改领域逻辑层。如果 UI 重新设计了,只需要改组件。每一层都能独立呼吸,而不会扼住其他层的喉咙。

这就是为什么大型应用能存活多年,而不是被自己的重量压垮。

他们不追求完美,而是让改变变得便宜

初级开发者想要“完美”的架构,想预测未来。

资深开发者知道未来总会出乎意料。API 会崩,设计会转向,产品经理会提出全新的需求。再多的远见也无法预测一切。

所以,他们不追求完美,而是为变化而设计:

  • 清晰的边界,方便替换。
  • 层与层之间的松耦合,方便重构。
  • 不做过度设计,只有在真正值得时才做抽象。

所以,我会说,真正的技能不在于今天做出最完美的架构,而在于让明天的改动毫无痛苦。

数据流如河流

React 的核心是数据向下流动、动作向上流动。但在真实的应用中,这些流会成倍增加。数据来自 API、缓存、Redux、context、socket 等等。

初级开发者会把 state 放在感觉方便的任何地方。

资深开发者会问:这个 state 真正属于哪里?

  • 只属于一个组件?保持本地化。
  • 跨越整个功能?放在 context 或某个状态切片中。
  • 属于整个应用?用 Redux、Zustand 或其他 store 集中管理。

一个很好的比喻是:他们把数据流当作河流。水流清晰地向下流动,系统就是健康的;如果它泄漏、积水或泛滥,系统就会崩溃。

他们按“意义”组织代码,而不是按“外观”

这是最隐形但也最深刻的区别之一。

初级开发者按组件组织代码:按钮、卡片、表单。

资深开发者按领域组织代码:用户、支付、设置。

为什么?因为 UI 是临时的。同一个“卡片”可能在十个地方出现,每个地方的含义都不一样。但领域,比如“用户”、“交易”、“通知”,这些东西是长期存在的。

所以,资深开发者不会有一个叫 components/ 的文件夹,而是有 features/ 或 domains/。每个领域都管理自己的 UI、状态和逻辑。代码库开始像是产品的地图,而不是一堆小部件的集合。

这就是为什么新工程师走进资深开发者的项目,能立刻知道东西应该放在哪。

他们拥抱“约束”,而不是“可选项”

初级开发者常犯的一个错误是过度灵活。

“让我们把这个组件做得超级可复用。让它接受 10 个 props,这样什么场景都能用。”

资深开发者走向了相反的方向:我们能限制什么?

  • 按钮组件只接受它真正需要的 props……别的不要。
  • API 层应该返回有类型、可预测的数据……没有猜测。
  • 团队的模式应该是严格的——没有无尽的变体。

约束减少了心理负担。它让系统以一种最好的方式变得无聊。每个人都以同样的节奏编码。架构变得可预测,而可预测性就是力量。

他们知道架构是为人服务的,而不是为机器

这是最后也是最重要的一个教训。我在其他文章里也强调过这一点。

架构根本不是关于代码的。它是关于人的。

  • 新开发者能不能不问别人就知道该把文件放哪?
  • 两个工程师能不能并行工作而不互相干扰?
  • 未来的维护者能不能在没有上下文的情况下读懂这段代码的意义?

这才是架构的真正考验。它并不总是关乎性能、优雅或巧妙的抽象,而是它能否帮助人们无痛地协作。

简而言之……

React 给了我们组件。组件是砖头。但架构才是建筑。

资深开发者不会纠结该用 Redux 还是 Zustand,也不会纠结文件应该放在 src/components 还是 src/ui。这些都是细节。他们思考的是更深层次的东西:边界、数据流、领域、约束,以及人。

(有幸看过高手写代码,确实直接能上手,可以直接从目录结构,文件命名和方法命名,配合注释直接推导出功能,这也是后续学习的目标)

他们构建的系统能够让变更成本很低,让复杂性被控制,让代码的意义清晰可见。

而且他们解释得如此简单,以至于让人觉得理所当然。这就是资深开发者的特质。

我希望这篇文章能帮你用一个全新的视角看待前端开发。如果我有遗漏的地方,可以在评论区补充,让更多人看到。

❌