普通视图

发现新文章,点击刷新页面。
今天 — 2025年10月17日技术

OpenAI推出Apps SDK,你的企业App跟上了吗?

作者 FinClip
2025年10月17日 11:57

导语:当OpenAI推出Apps SDK,试图将所有应用入口收归于ChatGPT对话框之时,凡泰极客正式发布FinClip ChatKit,为企业开启"对话流"新纪元。这不仅是一场技术升级,更是一次交互革命——从"指尖点击"迈向"自然对话"的时代已经到来。

渐变质感风蓝色互联网活动宣传公众号首图__2025-10-15+14_20_37.png 对拥有海量存量App和IT资产的企业而言,这既是机遇也是挑战:如何在保护现有投资的同时,平滑过渡到AI原生体验?FinClip ChatKit给出了完美答案——通过非入侵式、渐进式的升级路径,让企业App在保持现有架构的基础上,轻松获得智能对话能力。

你的 App 真的“智能”吗?

当前,许多企业 App 正面临三重挑战 :

  • 交互挑战: 复杂的图形用户界面(GUI)和深埋的功能,让用户表达意图非常困难。简单粗暴地嵌入一个“聊天机器人”,并不能让 App 实现真正的智能 。 

  • 架构挑战:基于“点击流”(Clickstream)的App 架构设计 ,难以与 AI 基建所需要的“会话流”(Dialog stream)融合,容易形成了信息孤岛 。

  • 沉没成本挑战: 面对巨大的历史投入,企业既希望抓住AI机遇,又担心推倒重来的风险与成本。

FinClip ChatKit:助力企业 App 开启“会话流”时代

作为深耕企业级应用领域的先行者,凡泰极客推出的FinClip ChatKit,直击企业AI落地"最后一公里"的痛点。这套与FinClip小程序技术完美互补的新技术,致力于成为企业从"点击流"平稳过渡到"会话流"的关键桥梁。

图片

(▲FinClip Chatkit、CopilotKit与OpenAI Chatkit能力对比)

当你的用户已经习惯用自然语言与AI交流时,那个还需要层层点击的企业App,确实到了该升级的时候。但升级的方向,不一定要放弃自己的阵地,投奔超级平台;也可以选择在自己的App里,打造一个更懂业务、更安全、更贴心的专属智能助手。

这既是对现有投资的保护,也是对用户体验的深度革新。想要进一步探索 FinClip ChatKit 的强大能力?欢迎私信我们,共同开启"对话流"新纪元。

随机菜谱解救选择困难!YunYouJun/cook 成为你的厨房锦囊:cpolar内网穿透实验室第549个成功挑战

NO.549 YunYouJun cook -01.png

软件名称:YunYouJun/cook

操作系统支持

  • 全平台通用:Windows、macOS、Linux 及移动端浏览器均可直接访问。
  • PWA 支持:一键添加到手机桌面,离线也能使用部分功能。

软件介绍

YunYouJun/cook是一款开源的“今日吃什么”决策工具,通过简洁易用的 Web 界面提供随机菜谱推荐。它支持自定义食材过滤、烹饪难度筛选,并鼓励用户贡献新食谱。项目在 GitHub 上获得 5.9K+ stars,是免费且完全开放的社区共建项目。

NO.549 YunYouJun cook -02.png

YunYouJun/cook:用代码给你一碗“心灵鸡汤”

  • 随机灵感生成:输入关键词或限制条件(如“素食”“10分钟完成”),系统立即推荐菜谱。
  • 社区共建宝藏:用户可提交自家私藏菜谱,比如“东北乱炖配方”或“网红螺蛳粉改良版”,让工具越来越懂你!

NO.549 YunYouJun cook -03.png

实用场景举例

场景1:拯救深夜食堂选择困难症

  • 痛点:“凌晨三点饿醒,冰箱只剩火腿肠和速冻水饺?”
  • 爽点:打开 YunYouJun/cook 输入“5分钟+微波炉可用”,秒出“芝士火腿蛋饼”方案,吃完还能睡个回笼觉!

场景2:情侣约会晚餐的面子工程

  • 痛点:“第一次带对象回家做饭,既想露一手又怕翻车?”
  • 爽点:筛选“浪漫氛围+难度新手级”,软件推荐“西芹百合炒腰果”和“番茄奶油意面”,上桌时还能附赠菜谱小卡片装逼!

场景3:远程家庭厨房协作

  • 痛点:“爸妈想学做你爱吃的川菜,但微信发的步骤图总是看不清?”
  • 爽点:用 cpolar 将 YunYouJun/cook 部署到内网穿透服务器后,父母手机直接访问你的专属菜谱库,还能同步收藏“麻婆豆腐教学视频”!

NO.549 YunYouJun cook -04.png

让厨房灵感无边界——cpolar 如何让 YunYouJun/cook 飞出内网
  • 远程访问家庭食谱库:部署 YunYouJun/cook 到本地服务器后,通过 cpolar 一键生成公网地址。无论出差或旅行,随时用手机查看家人上传的独家菜谱(比如奶奶手写的“秘制红烧肉步骤”)。
  • 多人协作开发新菜系:美食博主团队可共享同一个 YunYouJun/cook 实例,远程提交“川湘融合创意菜”,再也不怕灵感被锁在办公室电脑里!
  • 局域网设备联动:连接智能冰箱的 YunYouJun/cook 可自动读取食材库存(如“鸡蛋还剩3个”),结合 cpolar 的跨网络访问能力,让出差时也能远程生成符合家庭需求的菜谱。

NO.549 YunYouJun cook -05.png

总结

YunYouJun/cook是一款用技术解决生活痛点的开源神器,搭配 cpolar 的内网穿透能力后,它不仅是你的“电子版《舌尖上的中国》”,更成为了连接家人、朋友与美食创意的桥梁。无论是想快速决定晚餐还是打造专属家庭食谱库,这套组合都能让你在厨房中玩得风生水起!

不知道吃什么的朋友,总被别人问吃什么的朋友。赶快去部署吧。放在手机里就成为专业美食博主喽!

1.本地部署cook与运行

在线版用起来很方便,但也建议在电脑里留一个“本地版”。这样一来,哪怕官网偶尔打不开或很慢,出门在外没网络时,你也能照常使用;在自己电脑上打开会更利索;你的食材和口味偏好只保存在本机,更放心;还可以自己增删菜谱、改标签,做成更符合你家口味的小帮手。想要一个随时可用、简单可靠、还能按喜好慢慢调的版本,本地部署就很合适。

在使用 Docker 部署前,请先安装 Docker(参考:www.cpolar.com/blog/docker…)。

首先,在cmd中执行如下命令:

docker run -it -d --name cook -p 3333:80 yunyoujun/cook:latest

image-20250921175526732

如上图即代表成功啦!是不是很简单,一条命令就搞定了!

接着,让我们在浏览器中访问一下:

http://localhost:3333/

image-20250921175755085

可以看到,成功访问啦!

2.使用 cpolar 将 cook 安全暴露到公网

2.1 为什么要穿透 cook

借助 cpolar 内网穿透,我们无需公网 IP 与路由配置,即可将本地 cook 稳定、安全地发布到公网,支持 HTTPS 与固定二级域名。

很多时候我们在本地电脑上跑起了 cook,也想用手机看看、分享给家人朋友一起用,或者让同事在外网直接访问。但 cook 默认只能在本机打开,外部网络连不上。通过 cpolar 这样的内网穿透工具,可以把本地的 cook 安全映射到公网,生成一个随时可用、带 HTTPS 的访问地址,这样无论你身在何处,都能轻松打开和分享。

2.2 什么是 cpolar(内网穿透)?

image-20250910114418412

  • cpolar 是一款内网穿透工具,可以将你在局域网内运行的服务(如本地 Web 服务器、SSH、远程桌面等)通过一条安全加密的中间隧道映射至公网,让外部设备无需配置路由器即可访问。
  • 广泛支持 Windows、macOS、Linux、树莓派、群晖 NAS 等平台,并提供一键安装脚本方便部署。

2.3 下载cpolar

打开cpolar官网的下载页面:www.cpolar.com/download 点击立即下载 64-bit按钮,下载cpolar的安装包:

image-20250815171202537

下载下来是一个压缩包,解压后执行目录中的应用程序,一路默认安装即可,安装完成后,打开cmd窗口输入如下命令确认安装:

cpolar version

image-20250815171446129

出现如上版本即代表安装成功!

安装完成后,cpolar 将作为本方案“公网访问能力”的关键基础,贯穿后续所有远程访问与协作场景。

2.4注册及登录cpolar web ui管理界面

2.4.1 注册cpolar

官网链接:www.cpolar.com/

访问cpolar官网,点击免费注册按钮,进行账号注册

image-20250804085039567

进入到如下的注册页面进行账号注册: image-20250804085208319

2.4.2 访问web ui管理界面

注册完成后,在浏览器中输入如下地址访问 web ui管理界面:

http://127.0.0.1:9200

image-20250815171734046

输入刚才注册好的cpolar账号登录即可进入后台页面:

image-20250815171846757

2.5 穿透 cook 项目的WebUI界面

2.5.1 随机域名方式(免费方案)

随机域名方式适合预算有限的用户。使用此方式时,系统会每隔 24 小时 左右自动更换一次域名地址。对于长期访问的不太友好,但是该方案是免费的,如果您有一定的预算,可以查看大纲4.5.2固定域名方式,且访问更稳定

点击左侧菜单栏的隧道管理,展开进入隧道列表页面,页面下默认会有 2 个隧道:

  • remoteDesktop隧道,指向3389端口,tcp协议
  • website隧道,指向8080端口,http协议(http协议默认会生成2个公网地址,一个是http,另一个https,免去配置ssl证书的繁琐步骤)

image-20250914174356363

点击编辑website的隧道,修改成我们cook需要的信息:

image-20250921181201168

接着来到在线隧道列表,可以看到名称为cook-3333隧道的两条记录信息,一条协议为http,另一条协议为https:

image-20250921181811858

以https为例,访问测试:

image-20250921181852562

可以看到,已成功打开 cook 的网页界面。现在无论你身在何处,只要网络可用,就能把本机的 cook 分享给家人或朋友一起用。温馨提示:免费方案的随机域名约每 24 小时会更新一次,记得在后台获取新地址即可持续访问。

2.4.2 固定域名方式(升级任意套餐皆可)

通过前面的配置,我们已经成功让本机的 cook 可以被外网访问,但免费随机域名方案的局限性也逐渐显现:每 24 小时自动更换域名地址,意味着你需要频繁更新书签、重新分享链接,甚至可能因为忘记更新而无法访问。固定域名方案正是为了解决这些痛点而生,让你拥有一个长期不变的专属地址,更适合长期分享与家庭共用。

好啦,接下来开始固定保留二级子域名教程!

首先,进入官网的预留页面:

https://dashboard.cpolar.com/reserved

选择预留菜单,即可看到保留二级子域名项,填写其中的地区名称描述(可不填)项,然后点击保留按钮,操作步骤图如下:

image-20250921184446900

列表中显示了一条已保留的二级子域名记录:

  • 地区:显示为China Top
  • 二级域名:显示为cook

注:二级域名是唯一的,每个账号都不相同,请以自己设置的二级域名保留的为主

接着,进入侧边菜单栏的隧道管理下的隧道列表,可以看到名为cook-3333的隧道,点击编辑按钮进入编辑页面:

image-20250921184522602

修改域名类型为二级子域名,然后填写前面配置好的子域名,点击更新按钮:

image-20250921184622071

来到状态菜单下的在线隧道列表可以看到隧道名称为cook-3333的公网地址已经变更为二级子域名+固定域名主体及后缀的形式了:

image-20250921184650007

这里以https协议做访问测试:

image-20250921184729134

访问成功!这样一来,你就拥有了一个长期不变的专属域名,不必再担心 24 小时域名更换的问题。现在可以把这个固定地址加入浏览器书签,分享给家人朋友长期使用,或在家庭私有服务中稳定使用。

总结

YunYouJun/cook 不仅仅是一个简单的工具,它更是一种生活态度的体现——告别选择困难,拥抱烹饪乐趣。它将“今天吃什么”的烦恼转化为一种轻松愉快的探索,让你的餐桌每天都充满新鲜感和期待。无论你是厨房新手,还是资深吃货,YunYouJun/cook 都能成为你厨房里的得力助手。

还在等什么?立即体验 YunYouJun/cook 的魅力吧!它将为你打开美食世界的新大门,让你的每一次用餐都成为一次惊喜!


感谢您阅读本篇文章,有任何问题欢迎留言交流。cpolar官网-安全的内网穿透工具 | 无需公网ip | 远程访问 | 搭建网站

fluth-vue: 体验流式编程范式之美

作者 yiludegeX
2025年10月17日 11:12

感受流式编程范式在代码组织、代码维护、代码调试、页面渲染方面的全新体验

背景

在 如何处理复杂前端业务代码 文章中简单的介绍了流在异步数据依赖关系维护和异步逻辑依赖关系维护的一些应用,本篇文章详细的介绍在 vue 响应式框架中如何将流这种全新的编程范式无缝的融入,并阐述了通过流式编程这种全新编程维度在代码组织、代码维护、代码调试、页面渲染方面带来的全新体验。

当前 vue 开发的痛点

响应式数据开发调试痛点

vue 的响应式数据用在 template 绑定上体验感非常好,深层的数据修改后可以立马触发组件的更新;但是响应式数据在逻辑层面体验就比较糟糕:

  1. 响应式数据是mutable的,所以想知道数据的 previous 状态是非常困难的;
  2. watch 响应式数据,然后做逻辑处理,首先没有语义,其次实现复杂控制标记位过多;
  3. 对于复杂对象常常很难找出在逻辑中哪个响应式的属性的修改以及代码的哪个位置修改导致组件更新;

用响应式的数据来组织逻辑虽然看上去效率很高,但是在代码阅读和代码维护以及debug方面常常带来很大的困扰。

代码组织维护痛点

写 vue 业务组件或者逻辑的时候,只要稍不注意代码体积就在膨胀;哪怕采用了 hook 编程理念,膨胀依旧发生在hook。这个膨胀体现在:函数处理的场景越来越多,入参就要越来越多,函数体里面 if-else 也就越来越多;似乎只有反复拆解重构才能达到一个比较好的平衡,对心智负担和时间成本消耗都比较大。

复杂度不会消失,只能转移或者隔离;需要成本更低的代码组织形式来对抗业务的复杂度。

流式编程开发的痛点

rxjs 是一个大家都熟悉的响应式编程库,内置了大量的操作符并通过pipe这种管道的形式进行串联,让数据在管道内通过操作符进行处理和流转;在这里将这种通过管道的形式来对数据进行响应式的编程方式叫做流式编程,那么rxjs就是一个流式编程范式的库,流式编程范式具备如下优点:

  1. 响应式:流可以不断触发也可以被订阅;
  2. 管道式:任何一个环节都处于管道之中,上游一目了然;
  3. 声明式:管道内的操作可以内聚成操作符,大大提升代码的表达力和简洁性;

但是流式编程长期以来被认为是“牛刀”,似乎只有复杂的异步数据源场景才配用上,这里面很大归功于 rxjs 较高的上手门槛和繁多的概念。

响应式和流的结合

如果可以简化流式编程的使用成本,采取数据的响应式来更新视图,流的响应式来组织业务逻辑代码代码,结合响应式数据带来的页面自动更新的便捷和流式编程带来的声明式逻辑丝滑处理,是否可以解决上面的痛点并给前端开发带来效率上的提升呢?

下面介绍 fluth-vue 结合数据响应式和流式编程,在开发调试、代码组织、页面渲染等方面发生的奇妙的化学反应。

Promise流

fluth

fluth (/fluːθ/) 由 flux + then 两个单词组合而来,代表类似 promise 的流。Promise 是前端最常接触的异步流式编程范式,类 Promise 的流式编程范式极大地降低了流式编程的门槛,

认为 promise 是发布者而 then 方法是订阅者,promise 的发布行为则只有一次。fluth 加强了 promise,让 promise 可以不断的发布!如果你熟悉 Promise,那么你已经掌握了 fluth 的基础。

以下面代码为例:

import { $ } from 'fluth'

const promise$ = $(
promise$.then(
  (r) => console.log('resolve', r),
  (e) => console.log('reject', e)
)
promise$.next(1)
promise$.next(2)
promise$.next(Promise.reject(3))
console.log('end')

// 打印:
// resolve 1
// resolve 2
// end
// reject 3

但是 fluth 相比 promise 有如下差异点:

  1. 相比 promise,fluth 可以不断发布并且支持取消定订阅
  2. 相比 promise,fluth 同步执行 then 方法,及时更新数据
  3. 相比 promise,fluth 完全支持 PromiseLike

还有一个重要的差异点:fluth 保留每个订阅节点的数据供后续使用

以下面代码为例:

import { $ } from 'fluth'

const promise$ = $(0)
const observable$ = promise$.thenImmediate(v => v + 1)

promise$.value === 0 ✅
observable$.value === 1 ✅

promise$.next(1)

promise$.value === 1 ✅
observable$.value === 2

流上面的每一个子节点,返回的值都可以通过 value 属性来获得,这样每个节点既可以进行逻辑处理又能保留处理后的数据。

相比 rxjs 这样非常成熟的流式编程库,和 fluth 相比而言有几个区别

  1. fluth 上手非常简单,是类 promise 的流式编程库,只要会使用 promise 就可以使用
  2. fluth 的流是 hot、multicast 的,而 rxjs 的流还具备 cold、unicast 的特性
  3. fluth 可以流链式订阅,而 rxjs 的订阅后无法再链式订阅
  4. fluth 保留了每个订阅节点的数据以及状态供后续消费
  5. fluth 订阅节点存在和 promise 类似的 status 状态
  6. fluth 可以添加插件来扩展流的功能和添加自定义行为

如下代码所示:

// rxjs:
stream$.pipe(operator1, operator2, operator3)
stream$.subscribe(observer1)
stream$.subscribe(observer2)
stream$.subscribe(observer3)

//fluth:
stream$.use(plugin1, plugin2, plugin3)
stream$
  .pipe(operator1, operator2)
  .then(observer1)
  .pipe(operator3)
  .then(observer2)
  .pipe(operator4)
  .then(observer3);
stream$.next(1);

fluth-vue

fluth-vue 则进一步将响应式 + 流式编程完美融合;让流可以成为替代 ref、reactive 响应式数据的基础单元。

以下面代码为例:

import { $, filter, debounce } from "fluth-vue";
import { ref } from "vue";

const data = ref({name: 'xxx', age: '18'})
const data$ = $({name: 'xxx', age: '18'})

data$.pipe(
 debounce(300),
 filter((v) => v.age > 18),
 map((v) => ({name: v.name, age: v.age + 1}))
)

data 和 data$ 在响应式方面几乎完全一致,但是 data$ 却比 data 多了一个全新的流式编程的维度。

响应式能力

响应式数据

对于 fluth-vue 来说,和 ref 数据在响应式方面几乎完全一致体现在:

  1. 可以正常的 watch、computed
  2. 在 template 中可以正常的被解包,不需要使用 .value 
  3. 在 vue-devtools 中可以正常显示其值

如下所示,除了修改数据,$("fluth") 和 ref("fluth") 两者完全等价。

<template>
  <div>
    <p>{{ name$ }}</p>
  </div>
</template>

<script setup>
import { watch, computed } from "vue"
import { $ } from "fluth-vue";

const name$ = $("fluth");

const computed = computed(() => name$.value);

watch(name$, (value) => {
 console.log(value);
}); 
</script>

响应式更新

唯一的差异点在于:修改数据必须采用 fluth next 或 set 方法

import { $ } from "fluth-vue";

const stream$ = $({ obj: { name: "fluth", age: 0 } });

stream$.set((value) => (value.obj.age += 1));

通过 next 和 set 修改数据后,不但会触发 vue 响应式的更新;还会触发流的推流,所有订阅节点都将得到数据的推送。

不可变数据能力

在修改数据方面存在差异点的原因是 fluth 底层采用了 immutable 的数据存储

流的数据在流转的过程中被每个节点处理后再把处理结果给到下一个节点,而每个节点都需要保留处理后的数据,通过数据的 immutable 来保证数据之间隔离,让每个节点都能拥有不被污染的数据。fluth提供了setthenSetthenImmediateSetthenOnceSet方法和 set 操作符来对节点进行 immutable 处理。

数据和响应式解耦

使用 ref 或者 reactive 的时候,数据和响应式是一体的,修改了数据就会触发响应式,但是流可以做到数据和响应式的解耦

如下所示:

const wineList$ = $(["Red Wine", "White Wine", "Sparkling Wine", "Rosé Wine"]);

const age$ = $(0);
const availableWineList$ = age$
  .pipe(filter((age) => age > 18))
  .then(() => wineList.value);

只有 age$ 大于 18 的时候,才可以获取到 wineList$ 的最新值,但是后续 wineList$ 的 immutable 修改不会触发 availableWineList$ 的重新计算以及值的变化,只有 age$ 的变化才会触发 availableWineList$ 的重新取值,如果采用 vue computed 的方式进行运算,不管是 age 还是 wineList 的变化都会引起 availableWineList 的计算。

调试能力

fluth 提供了丰富的调试插件:

打印插件

通过consoleNode 插件可以方便的打印流节点数据

import { $, consoleNode } from "fluth-vue";

const data$ = $().use(consoleNode());

data$.next(1); // 打印 resolve 1
data$.next(2); // 打印 resolve 2
data$.next(3); // 打印 resolve 3

data$.next(Promise.reject(4)); // 打印 reject 4

由于 fluth-vue 底层采用 immutable 的数据,对于复杂对象使用打印插件可以保留每个修改时刻的快照供调试,而 ref 数据要做到则需要采用 JSON.stringify。

通过consoleAll 插件可以方便的查看流所有的节点数据

import { $, consoleAll } from "fluth-vue";

const data$ = $().use(consoleAll());
data$
  .pipe(debounce(300))
  .then((value) => {
    throw new Error(value + 1);
  })
  .then(undefined, (error) => ({ current: error.message }));

data$.next(1)
// 打印 resolve 1
// 打印 reject Error: 2
// 打印 resolve {current: '2'}

断点插件

通过debugNode插件可以方便的调试流节点数据,并可以查看流节点的调用栈

import { $, debugNode } from "fluth-vue";

const stream$ = $(0);

stream$.then((value) => value + 1).use(debugNode());

stream$.next(1);
// 触发调试器断点

条件调试

import { $ } from "fluth-vue";
import { debugAll } from "fluth-vue";

// 只对字符串类型触发调试器
const conditionFn = (value) => typeof value === "string";
const stream$ = $().use(debugNode(conditionFn));

stream$.next("hello"); // 触发调试器
stream$.next(42); // 不触发调试器

通过debugAll插件可以方便的调试流所有的节点数据,并可以查看流节点的调用栈,可以非常容易的找到数据修改的属性和位置。

import { $, debugAll } from "fluth-vue";

const data$ = $().use(debugAll());

data$.then((value) => value + 1).then((value) => value + 1);

const updateData$ = () => {
  data$.next(data$.value + 1);
};
// 在浏览器开发者工具中会在每个节点触发调试器断点
// 当前有三个节点,所以会触发三次断点

打印和调试插件的出现彻底的改变了以前调试 vue 复杂对象的体验。

异步能力

fluth-vue 提供了强大的异步处理能力,体现在下面两个方面:

更强大的promsie

fluth 流的每个节点都实现了 promise 的全套能力:then、catch、finally,与此同时还实现了then节点的同步运行:

Promise.resolve(1).then(v=> console.log(v));
console.log('hello');

// hello
// 1

const stream$ = $()
stream$.then(v => console.log(v));
stream$.next(1)
console.log('hello') 
// 1 
// hello

fluth 流如果节点都是同步操作,可以看到都是同步执行。同步执行对于前端非常重要,如果每个节点都异步执行那么会导致页面反复的渲染。

还支持对 then 进行取消订阅

import { $, consoleAll } from "fluth-vue";

const stream$ = $().use(consoleAll());
const observable$ = stream$.then( v =>  v + 1)

stream$.next(1)
//resolve 1
//resolve 2

observable$.unsubscribe() // 取消订阅
stream$.next(1)
//resolve 1

 由于 fluth 可以链式的进行订阅,而订阅的节点可能是异步节点,异步节点返回的时间是不确定的。当异步节点还没返回,如果此时流又推送了新的数据到节点则会产生异步竞态问题,fluth 解决了异步竞态的问题

const stream$ = $()

stream$
  .then(x => x+1)
  .then(x => new Promise(resolve => settimeout(() => resolve(x), 50)))
  .then(x => x*2)

stream$.next(1)
sleep(30)
stream$.next(2)
sleep(60)
stream$.next(3)
sleep(30)
stream$.next(4)

 第一个数据和第三个数据由于竞态问题会在节点处理中丢弃掉,如下图所示:

image.png

流式的api

fluth-vue 提供 useFetch 函数,让 api 的请求能够支持流,这样可以将 api 的请求作为流的一个节点看待。

const url$ = $("https://api.example.com/data");
const payload$ = $({ id: 1, name: "fluth" });
const { promise$ } = useFetch(url$, { immediate: false, refetch: true })
  .get(payload$)
  .json();

promise$.then((data) => {
  console.log(data); // api 请求的结果
});

url$.next("https://api.example.com/data2"); // 触发请求,并打印结果
payload$.next({ id: 2, name: "vue" }); // 触发请求,并打印结果

image.png 这样,不管是 url$ 还是 payload$ 发起推流,都会重新发起请求并通过 promise$ 进行推流给到下游进行消费。

流式渲染能力

fluth-vue流的数据就是响应式数据可以正常在 template 中渲染,除此之外 fluth-vue 还提供了强大的流式渲染render$功能,可以实现元素级渲染或者块级渲染,整体效果类似 signal 或者 block signal 的渲染。

元素级渲染

import { defineComponent, onUpdated } from "vue";
import { $, effect$ } from "fluth-vue";

export default defineComponent(
  () => {
    const name$ = $("hello");

    onUpdated(() => {
      console.log("Example 组件更新");
    });

    return effect$(() => (
      <div>
        <div>
          名字:{name$.render$()}
        </div>
        <button onClick={() => name$.set((v) => v + " world")}>更新</button>
      </div>
    );
  },
  {
    name: "Example",
  },
);

 点击按钮只会修改 div 元素下的 name$.render$() 内容,不触发组件 onUpdated 生命周期。

块级渲染

import { defineComponent, onUpdated, h } from "vue";
import { $, effect$ } from "fluth-vue";

export default defineComponent(
  () => {
    const user$ = $({ name: "", age: 0, address: "" });
    const order$ = $({ item: "", price: 0, count: 0 });

    return effect$(() => (
      <div class="card-light">
        <div> example component </div>
        <div>render time: {Date.now()}</div>
        <section style={{ display: "flex", justifyContent: "space-between" }}>
          {/* use$ emit data only trigger render content update*/}
          {user$.render$((v) => (
            <div key={Date.now()} class="card">
              <div>user$ render</div>
              <div>name:{v.name}</div>
              <div>age:{v.age}</div>
              <div>address:{v.address}</div>
              <div>render time: {Date.now()}</div>
            </div>
          ))}
          {/* order$ emit data only trigger render content update*/}
          {order$.render$((v) => (
            <div key={Date.now()} class="card">
              <div>order$ render</div>
              <div>item:{v.item}</div>
              <div>price:{v.price}</div>
              <div>count:{v.count}</div>
              <div>render time: {Date.now()}</div>
            </div>
          ))}
        </section>

        <div class="operator">
          <button class="button" onClick={() => user$.set((v) => (v.age += 1))}>
            update user$ age
          </button>
          <button
            class="button"
            onClick={() => order$.set((v) => (v.count += 1))}
          >
            update order$ count
          </button>
        </div>
      </div>
    ));
  },
  {
    name: "streamingRender",
  },
);

use$ 或者 order$ 流更新后,只会更新 render$ 函数里面的内容,不会引起组件的虚拟 dom diff 以及 update 的生命周期。

一旦流可以掌控渲染,那么可以做的事情就非常多了,比如 user$.pipe(debounce(300)).render$ 😋,这里就不进一步展开了。

代码组织能力

流这种编程范式和前端业务模型高度匹配在代码组织上表现的尤为明显。

下面以一个简单的例子——订单表单的提交页面,来展示流在业务模型中的应用:

image.png 传统的前端开发采用命令式编程模式

  • 点击按钮后,调用 handleSubmit 方法
  • handleSubmit 先 validateForm 方法,如果验证不通过,则提示报错
  • 验证通过拼装后台需要的数据
  • 调用后台 fetchAddOrderApi 方法
  • 如果调用成功,则继续调用 handleDataB 方法、handleDataC 方法
  • 如果调用失败,则提示报错

这应该是大部分前端开发者的日常,开发日常不代表天经地义,这种命令式开发模式、夹杂同步逻辑异步操作,随着业务复杂度增长,handleSubmit 方法会变得越来越臃肿,也将变得越来越难以复用

下面采用流的声明式编程方式重新实现:

image.png 按照业务逻辑,代码实现为六条流:form$、trigger$、submit$、validate$、payload$、addOrderApi$每一条流都承载着独立的逻辑,流的先后顺序按照业务真实顺序进行组织form$、trigger$ 负责将用户的输入转换为流,validate$、addOrderApi$ 则将流的处理结果传递用户。

通过代码可以发现:

  • 复用性提升,采用流式编程范式后逻辑充分的原子化了,而流既可以分流又可以合流可以轻易的对这些逻辑原子进行逻辑组合,代码的复用性空前的提高
  • 维护性提升,代码从上到下是按照业务真实顺序进行组织的,当前只有一个 handleSubmit 方法可能还不明显,当业务逻辑复杂后,按照业务事实顺序组织代码将对阅读性、维护性有极大的提升
  • 表达力提升auditdebouncefilter等操作符以声明式的方式处理了触发器、节流、条件过滤等复杂的异步控制逻辑,通过流的操作符,代码的表达力显著提升。
  • 控制反转,相对于方法调用这种”拉“的方式,流式编程范式是”推“的方式,可以实现数据、修改数据的方法、触发数据修改的行为都放置在同一个文件夹内,再也无需全局搜索哪里的调用改变了模块内部的数据。

复用性和可维护性优势

对于命令式的编程,在 handleSubmit 后续的迭代中可能需要分场景:

  • 场景 A 调用 fetchAddOrderApi 成功后只需要调用 handleDataB 方法
  • 场景 B 调用 fetchAddOrderApi 成功后只需要调用 handleDataC 方法

此时 handleSubmit 只能将场景变为参数交由 if - else 来处理,随着越来越多的分支逻辑,函数逐渐膨胀。如果用流式编程范式来实现,这个问题可以轻松解决:

  • 如果场景是流的话,通过组合流就可以轻松解决
// 场景 A 流
const caseA$ = $();
addOrderApi$.pipe(audit(caseA$)).then(handleDataB);

// 场景 B 流
const caseB$ = $();
addOrderApi$.pipe(audit(caseB$)).then(handleDataC);
  • 如果场景是数据的话,既可以通过分流也可以通过过滤来处理,两种方式都可以轻松解决
// 场景流,可能是 A,也可能是 B
const case$ = $<"A" | "B">();

// 方法1: 分流
const [caseA$, caseB$] = partition(case$, (value) => value === "A");
addOrderApi$.pipe(audit(caseA$)).then(handleDataB);
addOrderApi$.pipe(audit(caseA$)).then(handleDataC);

// 方法2: 过滤
const caseAA$ = addOrderApi$
  .pipe(filter(case$.value === "A"))
  .then(handleDataB);

const caseBB$ = addOrderApi$
  .pipe(filter(case$.value === "B"))
  .then(handleDataC);

代码逻辑原子化以及流的分流和合流让 fluth-vue 在代码组织能力上如鱼得水。

重构优势

上面是一个简单的示例,如果业务逻辑复杂传统开发模式下,一个 setup 函数下面可能有十几个 ref 和几十个 methods,如果认为 setup 是一个 class,那么这个 class 将拥有十几属性和几十个方法以及的坏味道的 watch “打洞”逻辑,阅读和维护成本将非常的高。

虽然更小粒度的的抽离组件以及 hooks 的开发理念可以解决部分问题,但现实是当前大量现存业务仍然是由很多这样臃肿的 setup 函数构造的组件组装的,因为种种原因一旦 setup 成为这个臃肿的 class,那么后续的开发者只能在这个 setup 上持续“深耕”。

而流式编程范式可以很好的解决这个问题,如果一开始采用 fluth-vue 开发业务,随着业务持续迭代,代码也会也来也长;但是流式编程是按照业务真实顺序进行声明式组织代码,相当于一条线不断延伸,此时要抽离逻辑只需要将线剪成几段分别放入 hook 就好了,完全没有心智负担,相当于有一个很重的业务,只需要几分钟就可以解决重构好。

总结

通过在实际业务中用流式编程范式进行开发和调试,发现流这种编程范式在前端领域被严重的低估,可能是 rxjs 概念或者使用较为复杂让大家认为是一把牛刀,只有复杂异步数据流组合场景才配用上,其实最简单的 ref("字符串"),当采用 $("字符串")后都能带来非常可观的收益。

fluth-vue 真正意义上将流式编程范式带给了vue开发者:让流成为前端最基础的数据形态并完美兼容响应式,将响应式进行彻底:除了数据和视图的响应式,逻辑也能用流响应式的组织。

实际体验下来的感受:流式编程范式与前端业务的异步、事件驱动特性天然契合,是组织前端业务逻辑的理想选择

最后项目已开源🎉🎉🎉,欢迎 star ⭐️⭐️⭐️ !!!

github.com/fluthjs/flu…

github.com/fluthjs/flu…

roocode + vscode + api_key = free GPT-5

2025年10月17日 11:00

最近cursor也是过期了,所以在找其他的ai工具,发现通过白嫖的api加上vscode上的 roo code 可以复刻一波agent模式,我觉得挺香的,记录一下

一、下载roocode

image.png

然后打开后可以看到这样的配置

image.png

二、获取api key

agentrouter.org/register?af… 用gitbug或者L站注册后有200刀的额度,去控制台创建令牌

创建令牌.png

在控制台里复制密钥

令牌.png

三、然后回到roocode

API提供商选择 openai compatible 基础URL 输入 agentrouter.org/v1 api密钥 输入刚刚你复制的内容 模型选择 gpt-5 点击保存

image.png

就可以提问了

image.png

这玩意可以检索文件去进行agent编程,然后gpt-5的token消耗量很少,可以用很久了。香

JS核心知识-模块化

作者 云枫晖
2025年10月17日 10:58

在前端技术飞速迭代的过程中,模块化始终是贯穿发展的核心命题之一。前端从早期的几行JS代码能搞定一个页面,到如今动辄百万行代码的大型单页面应用,开发者面临的最大挑战早已不是实现功能,而是如何让代码可维护、可复用、可扩展。当一个项目的JS文件堆成数百行,变量和函数在全局环境下互相打架,团队协作时稍微不注意就会引起命名冲突,上线前还要靠人为的梳理几十个script标签的加载顺序----这样的场景让无数开发者头疼,这正是模块化诞生的使命。

本文将以前端发展历程为主线,从最原始的代码组织方式开始,一步步拆解模块化如何从 “临时解决方案” 进化为 “官方标准”,深入剖析每个阶段的核心方案、解决的痛点与局限,并重点详解当前主流的 CommonJS 与 ESModule 规范,帮助你彻底理清前端模块化的来龙去脉。

前端蛮荒时代

在前端还处于刀耕火种的初级阶段时,网页主要以展示为主,交互逻辑简单,JS代码通常只有几十到几百行。开发者代码组织方式就是将所有函数和变量直接定义在全局作用域(window)中,通过script标签引入到HTML文件中。

// global-utils.js
function formatDate(date) {
 // 日期格式化逻辑
 return date.toLocaleDateString();
}

let userInfo = {
  name: "前端小白",
  role: "visitor"
};

// 直接在全局作用域挂载函数和变量
window.formatDate = formatDate;
window.userInfo = userInfo;

在HTML中引入上述文件,然后在通过另一个script标签调用执行即可

<script src="global-utils.js"></script>
<script>
  // 直接使用全局变量和函数
  console.log(formatDate(new Date()));
  console.log(userInfo.name);
</script>

这种方式虽然简单直接,但是当项目日益庞大时,这种全局暴露方式存在严重问题:

  1. 命名冲突:多个脚本文件中若定义了同名函数(如两个文件都有formatDate),后加载的脚本会覆盖先加载的,导致逻辑错乱

  2. 依赖混乱:若项目依赖多个<script>标签,必须严格保证加载顺序(如a.js依赖b.js,则b.js必须放在前面),一旦顺序出错,就会报 “变量未定义” 错误

  3. 维护困难:全局作用域下的变量和函数没有 “边界”,后期修改一个函数时,无法快速定位它被哪些地方引用,容易引发 “牵一发而动全身” 的 bug

  4. 污染全局环境:过多的全局变量会占用window对象的属性,可能与浏览器原生 API 或第三方库冲突(如自定义$变量可能与 jQuery 冲突)

此时的前端项目代码,就像是一间没有隔离板的大办公室,所有人的物品随意摆放,毫无秩序。

IIFE带来的改善和局限

为了解决全局变量污染问题,开发者开始利用作用域特性的方案。在ES6之前,JS中只有函数作用域,因此立即执行函数表达式(IIFE)成为了当时最为主流的临时解决方案。

原理: 通过 “定义一个匿名函数并立即执行”,创建一个独立的函数作用域,将变量和函数包裹在内部,只对外暴露需要共享的接口,从而避免全局污染。

// math-module.js
const MathModule = (function () {
  // 私有变量:仅在IIFE内部可见
  const PI = 3.14159;
  // 私有函数:不对外暴露
  function validateNumber(num) {
    return typeof num === "number";
  }
  // 公开接口:通过返回对象暴露给全局
  return {
    circleArea: function (radius) {
      if (!validateNumber(radius)) return 0;
      return PI * radius * radius;
    },
    rectangleArea: function (width, height) {
      if (!validateNumber(width) || !validateNumber(height)) return 0;
      return width * height;
    },
  };
})();
// 挂载到全局(仅暴露一个命名空间)
window.MathModule = MathModule;

使用时通过全局命名空间调用即可,避免了多个全局变量

<script src="math-module.js"></script>
<script>
  console.log(MathModule.circleArea(5)); // 78.53975
  console.log(MathModule.rectangleArea(3, 4)); // 12
  // 无法访问私有成员PI和validateNumber
  console.log(MathModule.PI); // undefined
</script>   

解决的问题:

  • 作用域隔离:通过函数作用域将大部分的变量和函数放入IIFE内部,仅仅暴露了一个全局命名空间,很大程度上减少了全局变量数量,降低命名冲突风险
  • 实现封装:以私有变量+公开接口的方式,实现了代码的初步封装,提高了代码安全性

存在的局限

IIFE虽然减少了全局污染,但是并没有解决核心痛点----依赖管理

  • 依赖手动维护:若MathModule依赖另一个 IIFE 模块(如ValidationModule),仍需手动在 HTML 中调整<script>加载顺序(先加载ValidationModule.js,再加载math-module.js),项目规模大时(如几十个模块),加载顺序管理会变得极其复杂
  • 无法按需加载:所有模块都在页面初始化时同步加载,即使某个模块只在用户点击按钮后才需要,也会提前加载,增加页面首屏加载时间
  • 命名空间冲突风险:若两个团队都定义了MathModule全局命名空间,依然会发生冲突(虽概率低于全局函数,但未彻底解决)

IIFE的方案,就像在大办公室添加简单隔板,但是同事之间的协作流程(依赖)仍然需要手动维护。效率低下。

CommonJS

在2009年,Node.js横空出世,随之而来的还有CommonJS。最初是为了解决服务器端JS的模块化问题,但由于其简洁语法和明确的规则,很快在前端社区借鉴。

CommonJS规范简介

CommonJS的核心思想是:

  • 每个文件视为一个模块
  • 每个模块拥有单独的作用域
  • 通过module.exports导出公开接口
  • 通过require()函数导入其他模块 它有三个核心变量:
  • module:代表当前模块,包含了模块的元信息(如模块ID、模块名称)
  • module.exports:模块的公开接口,导出的内容被require()获取
  • require():同步加载模块函数,接收模块路径(相对路径、绝对路径、第三方包名),返回模块导出的内容

特性及使用

1. 同步加载特性

CommonJS 采用同步加载机制:执行require('./a.js')时,会暂停当前模块的执行,先去加载并执行a.js,完成后再继续执行当前模块。这种机制在服务器端非常合适 —— 因为服务器端加载的是本地文件,速度极快,同步加载不会造成性能问题。

导出模块

// 方式1:直接给module.exports赋值(导出单个对象/函数)
function add(a, b) {
  return a + b;
}
function multiply(a, b) {
  return a * b;
}
// 导出多个函数(通过对象字面量)
module.exports = {
  add: add,
  multiply: multiply,
};

// 简写(ES6对象属性简写)
// module.exports = { add, multiply };

// 方式2:使用exports导出多个属性
exports.add = add;
exports.multiply = multiply;

// 方式3:导出默认值
// 一个模块只能有一个默认导出
function subtract(a, b) {
  return a - b;
}
module.exports = subtract;
// 导入时可以用任意变量名接收
// const mySubtract = require('./module');

导入模块

// 导入math模块(相对路径需加./,后缀.js可省略)
const math = require("./math");
console.log(math.add(2, 3)); // 5
console.log(math.multiply(4, 5)); // 20
// 解构导入(直接获取需要的成员)
const { add } = require("./math");
console.log(add(10, 20)); // 30

2. 缓存机制

CommonJS 有一个重要特性:模块被加载一次后,会被缓存到require.cache中,后续再require同一模块时,直接返回缓存的结果,不会重新执行模块代码。

这意味着模块中的代码(如初始化逻辑)只会执行一次,避免重复计算和资源浪费。

// counter.js(带有初始化逻辑的模块)
let count = 0;
count++; // 每次加载模块时会执行,但因缓存只执行一次
module.exports = {
  getCount: () => count
};
// main1.js
const counter1 = require('./counter');
console.log(counter1.getCount()); // 1
// main2.js
const counter2 = require('./counter');
console.log(counter2.getCount()); // 1(缓存生效,count未重新加1)
// 清除缓存(不推荐,可能导致模块重复加载)
delete require.cache[require.resolve('./counter')];
const counter3 = require('./counter');
console.log(counter3.getCount()); // 1?不,清除缓存后重新加载,count会重新初始化并加1,输出1?
// 纠正:清除缓存后重新require,counter.js会重新执行,count从0开始加1,输出1(因为每次重新执行都是0→1)

Common.js的不足

CommonJS 虽然在服务器端大放异彩,但直接移植到前端浏览器时,暴露了严重的性能问题:

  • 同步加载阻塞页面:浏览器加载 JS 文件需要通过网络请求(而非本地文件),同步加载会导致 JS 执行线程阻塞 —— 若某个模块体积大或网络慢,整个页面会卡住,直到模块加载完成,严重影响用户体验
  • 浏览器不原生支持:浏览器环境中没有module、module.exports和require这三个变量,需要通过工具(如 Browserify、Webpack)将 CommonJS 模块打包成浏览器可识别的全局代码,增加了构建成本
  • 无法按需加载:同步加载机制决定了所有依赖模块必须在页面初始化时全部加载,无法根据用户操作(如点击按钮)动态加载模块,导致首屏加载体积过大

由此可见CommonJS更加适合服务器端,前端专门针对浏览器的模块化规范 ---- AMD应运而生。

AMD规范

为了解决 CommonJS 在浏览器端的同步加载问题,2011 年,AMD 规范(Asynchronous Module Definition,异步模块定义) 正式推出,其核心思想是 异步加载模块,依赖前置,回调执行,最具代表性的实现库是 RequireJS。

AMD规范

AMD的核心函数:

  • define(id?, dependencies?, factory):用于定义模块
    • id 模块的唯一标识,若不指定,默认为模块文件的路径
    • dependencies 当前模块依赖的其他模块数组,若不指定,默认是['require', 'exports', 'module']
    • factory 模块的工厂函数,依赖模块加载完成后会执行该函数,函数的参数依次对应dependencies中的模块,返回值为模块的导出内容
  • require(dependencies, callback):用于加载模块
    • dependencies 参数与define的dependencies和factory类似。

AMD 的关键特性是异步加载:当加载一个模块时,浏览器会并行请求其依赖的模块,不会阻塞当前 JS 执行;所有依赖加载完成后,再执行工厂函数或回调函数。

异步加载原理及使用

异步加载原理:

以 “main模块依赖math模块” 为例,AMD 的加载流程是:

  1. 浏览器执行require(["main"], ...),发起main.js的请求
  2. 解析main.js时,发现其依赖math模块,发起math.js的请求(与其他请求并行)
  3. math.js加载完成后,再执行main模块的工厂函数,避免阻塞。 代码演示

步骤1 定义main模块

// 定义math模块,依赖为空(无其他模块依赖)
define("math", [], function () {
  return {
    add: function (a, b) {
      return a + b;
    },
    subtract: function (a, b) {
      return a - b;
    },
  };
});

步骤2 定义模块main的依赖模块

// 定义main模块,依赖"math"模块
define("main", ["math"], function (math) {
  console.log(math.add(3, 5)); // 8
  console.log(math.subtract(10, 4)); // 6
});

步骤3 在HTML中通过requireJS加载模块

<script src="https://cdn.jsdelivr.net/npm/requirejs@2.3.6/require.js" data-main="main"></script>

如果需要加载第三方库(如jQuery),只要符合AMD规范即可,无需额外包装。

define(["jquery", "math"], function($, math) {
  // 使用jQuery操作DOM
  $("body").append(`<p>3+5=${math.add(3,5)}</p>`);
});

AMD 的最大问题是语法冗余:为了支持异步和依赖前置,需要编写嵌套的回调函数(虽然 RequireJS 支持简化语法,但仍比 CommonJS 繁琐);此外,“依赖前置” 意味着即使某个依赖在工厂函数中未被使用,也会提前加载,造成一定的资源浪费。

为了平衡 “异步加载” 和 “语法简洁”,另一种浏览器端规范 ——CMD 应运而生。

CMD规范

CMD(Common Module Definition,通用模块定义)是由国内前端开发者玉伯(支付宝前端团队)提出的规范,核心实现库是 SeaJS。CMD 的设计理念是 “延迟加载,按需执行”,试图在 AMD 的异步优势和 CommonJS 的简洁语法之间找到平衡。

CMD规范简介

CMD 与 AMD 的核心区别在于依赖加载时机

  • AMD:依赖前置,在定义模块时就声明所有依赖,依赖模块会提前加载并执行
  • CMD:依赖就近,在模块工厂函数中需要使用某个依赖时,才通过require()加载,实现 “按需加载”

CMD 的define函数语法与 AMD 类似,但工厂函数的参数固定为require、exports、module(无需手动声明依赖数组):

  • require:加载模块的函数(同步,因依赖已提前异步加载)
  • exports:模块的导出接口(与 CommonJS 的module.exports类似)
  • module:模块元信息对象。

延迟加载机制及使用

1. 延迟加载机制

CMD 的加载流程是 “异步加载模块文件,但延迟执行依赖模块”:

  1. 浏览器加载模块文件时,会先解析模块代码,收集所有require()调用的依赖路径(但不立即加载)
  2. 执行模块工厂函数时,当遇到require('./math'),才会加载并执行math模块
  3. 若某个依赖只在特定条件下使用(如if (isDebug) require('./debug')),则只有条件满足时才会加载,实现真正的 “按需加载”

2. 代码演示

步骤1 定义math模块

// CMD模块定义:无需声明依赖,通过exports导出
define(function (require, exports, module) {
  // 私有函数
  function validateNum(num) {
    return typeof num === "number";
  }

  // 公开方法:通过exports挂载
  exports.add = function (a, b) {
    if (!validateNum(a) || !validateNum(b)) return 0;
    return a + b;
  };

  exports.subtract = function (a, b) {
    if (!validateNum(a) || !validateNum(b)) return 0;
    return a - b;
  };
});

步骤2 定义main模块 按需加载math模块

// CMD入口模块:依赖就近,需要时才加载
define(function (require, exports, module) {
  // 1. 先执行非依赖逻辑
  console.log("模块开始执行");

  // 2. 需要使用math时,才调用require加载
  const math = require("./math");
  console.log("3+5 =", math.add(3, 5)); // 8

  // 3. 条件依赖:仅在debug模式下加载debug模块
  const isDebug = true;
  if (isDebug) {
    const debug = require("./debug");
    debug.log("当前计算完成"); // 输出调试信息
  }
});

步骤 3:在 HTML 中通过 SeaJS 加载入口模块

<!-- 引入SeaJS -->
    <script src="https://cdn.jsdelivr.net/npm/seajs@3.0.3/dist/sea.js"></script>
<script>
  // 配置基础路径(可选)
  seajs.config({
    base: "./js",
    paths: {
      math: "math.js",
      debug: "debug.js",
    },
  });

  // 加载入口模块main.js
  seajs.use("./main");
</script>

CMD局限

尽管 CMD 兼顾了异步与简洁,但随着前端工具链(如 Webpack)的崛起,其局限性逐渐凸显:

  1. 生态碎片化:CMD 主要在国内推广,生态规模远小于 AMD 和 CommonJS,第三方库支持不足

  2. 工具链替代:Webpack 等构建工具支持按需加载(如import()动态导入),且能兼容多种模块化规范,CMD 的 “按需加载” 优势被覆盖

  3. 维护停滞:SeaJS 后续更新缓慢,逐渐被前端社区淘汰

CMD 的尝试为前端模块化提供了 “按需加载” 的思路,但真正终结 “规范混战” 的,是 ES6 推出的官方标准 ——ESModule。

ESModule:官方标准终极方案

2015 年,ES6(ECMAScript 2015)正式推出ESModule(简称 ESM) ,作为 JS 语言层面的官方模块化规范。它整合了之前各规范的优点(如 CommonJS 的简洁语法、AMD 的异步加载),解决了浏览器与服务器端模块化标准不统一的问题,成为当前前端模块化的绝对主流。

ESModule特性

1. 静态导入导出

ESM 最显著的特性是静态分析:import和export语句必须放在模块顶层(不能嵌套在if、函数等代码块中),依赖关系在代码编译阶段就能确定,而非运行时。这一特性让以下优化成为可能:

  • Tree-Shaking:构建工具(如 Webpack、Rollup)可删除未使用的导出成员,减小打包体积
  • TS 等类型语言可提前分析模块依赖的类型,提升开发体验

2. 值的动态引用

与 CommonJS 导出 “值的拷贝” 不同,ESM 导出的是 “值的引用”,且保持动态绑定:若导出模块中的值发生变化,导入方获取的值也会同步更新

// export-module.js(导出模块)
export let count = 0;
export function increment() {
  count++;
}
// import-module.js(导入模块)
import { count, increment } from './export-module.js';
console.log(count); // 0(初始值)
increment(); // 调用导出模块的函数修改count
console.log(count); // 1(同步更新,体现动态引用)

3. 支持异步加载

ESM 既支持静态import(编译时加载),也支持动态import()(运行时加载)。动态import()返回一个 Promise 对象,可在需要时(如用户点击、路由切换)按需加载模块,避免首屏加载冗余代码。

4. 自动启用严格模式

所有 ESM 模块默认运行在严格模式('use strict') 下,即使未显式声明

  • 禁止使用未声明的变量
  • 禁止this指向全局对象(this为undefined)
  • 禁止删除变量、函数等

5. 独立作用域与CORS请求

  • 独立作用域:每个 ESM 模块都有独立的私有作用域,模块内定义的变量、函数不会污染全局
  • CORS 请求:浏览器加载 ESM 模块时,会通过 CORS 机制验证跨域请求(需服务器返回Access-Control-Allow-Origin头),而普通脚本标签(type="text/javascript")无此限制

6. 延迟执行脚本

通过<script type="module">引入的 ESM 脚本,默认具备defer属性的行为

  • 脚本加载时不阻塞 HTML 解析
  • 脚本执行顺序与 HTML 中声明顺序一致
  • 脚本在 DOM 解析完成后、DOMContentLoaded事件触发前执行

ESModule导出使用

ESM 的导出分为 “命名导出” 和 “默认导出”,二者可组合使用,但一个模块只能有一个默认导出。

单个命名导出

// 导出单个变量
export const name = "ESModule";
// 导出单个函数
export function formatDate(date) {
  return date.toISOString();
}

批量命名导出

// 先定义成员,再批量导出
const version = "1.0.0";
function logInfo(info) {
  console.log(`[INFO] ${info}`);
}
// 批量导出(可指定别名,如version→moduleVersion)
export { version as moduleVersion, logInfo };

默认导出

默认导出用于导出模块的 “主要成员”,导入时可自定义成员名称(无需与导出名称一致)

1. 直接默认导出
// 导出默认函数
export default function(a, b) {
  return a + b;
}
// 导出默认对象
export default {
  name: "MathUtils",
  add: (a, b) => a + b
};
2. 先定义后默认导出
class User {
  constructor(name) {
    this.name = name;
  }
}

// 先定义类,再默认导出(注意:default后无大括号)
export default User;
3. 混合导出
// 命名导出:辅助工具函数
export function validateNum(num) {
  return typeof num === "number";
}

// 默认导出:主要功能函数
export default function calculateArea(radius) {
  if (!validateNum(radius)) return 0;
  return Math.PI * radius ** 2;
}

ESModule导入使用

导入语法需与导出语法对应,同时支持别名、整体导入、动态导入等灵活用法。

1. 导入命名导入

// 导入指定命名成员(名称需与导出一致)
import { name, formatDate } from "./module.js";

// 导入时指定别名(解决命名冲突)
import { version as moduleVersion, logInfo } from "./module.js";

// 整体导入:将所有命名成员挂载到一个对象上
import * as ModuleUtils from "./module.js";
console.log(ModuleUtils.name); // 访问整体导入的成员
ModuleUtils.logInfo("整体导入示例");

2. 导入默认导出成员

导入默认成员时,可自定义名称,无需使用大括号

// 导入默认函数(自定义名称为add)
import add from "./module.js";
console.log(add(2, 3)); // 5

// 导入默认类(自定义名称为UserClass)
import UserClass from "./module.js";
const user = new UserClass("张三");

3. 混合导入

// 方式1:分开导入
import calculateArea from "./module.js"; // 默认成员
import { validateNum } from "./module.js"; // 命名成员

// 方式2:合并导入(默认成员在前,命名成员在大括号内)
import calculateArea, { validateNum } from "./module.js";

// 使用示例
if (validateNum(5)) {
  console.log(calculateArea(5)); // 78.5398...
}

4. 动态导入

动态import()可在任意代码位置使用,返回的 Promise 成功后,通过解构或对象访问模块成员

// 场景1:用户点击后加载模块
document.getElementById("loadBtn").addEventListener("click", async () => {
  try {
    // 动态导入模块
    const { formatDate } = await import("./module.js");
    console.log(formatDate(new Date()));
  } catch (err) {
    console.error("模块加载失败:", err);
  }
});

// 场景2:路由切换时加载对应组件(Vue/React 路由懒加载原理)
function loadRouteComponent(route) {
  switch (route) {
    case "home":
      return import("./HomeComponent.js");
    case "about":
      return import("./AboutComponent.js");
  }
}

ESModule和CommonJS的差异

作为当前最主流的两种模块化规范,ESM 与 CommonJS 的差异直接影响开发选型,需重点掌握

对比维度 ESM CommonJS
标准归属 JS官方标准 社区规范
加载时机 编译时静态加载 运行时动态加载
导出内容 值的引用 值的拷贝
this的指向 undefined(严格模式) 模块对象(module.exports)
循环依赖处理 基于动态引用,支持循环依赖 基于缓存,可能导致依赖不完整
浏览器支持 原生支持 需构建工具(如webpack)配合
环境 浏览器 + Node.js(14.3.0+) 主要用于Node.js
Tree-Shaking 支持 不支持

小结

前端模块化的发展历程,是一个从 "野蛮生长" 到 "规范统一" 的演进过程:

  • 原始阶段:全局变量污染、依赖混乱、维护困难
  • IIFE阶段:通过函数作用域实现初步封装,减少全局污染
  • CommonJS:服务端模块化标准,同步加载机制适合Node.js环境
  • AMD/CMD:浏览器端异步加载方案,解决前端性能问题
  • ESModule:官方标准,统一前后端模块化,支持静态分析、动态导入等现代特性

当前,ESModule 已成为前端模块化的绝对主流,其官方标准地位、静态分析能力、Tree-Shaking 优化等特性,使其在现代化前端工程中不可或缺。而 CommonJS 凭借其在 Node.js 生态中的深厚根基,在服务端开发中仍占据重要地位。

理解模块化的发展历程和各规范的特点,不仅有助于我们在不同场景下做出合理的技术选型,更能深刻体会前端工程化思想的演进脉络,为构建可维护、可扩展的大型应用打下坚实基础。

书架效果的实现

2025年10月17日 10:34

1. 对齐目标

前端想实现一个类似的书架放置书籍的效果,目标如下:

Snipaste_2025-10-16_15-19-14.png

2. 思路梳理

我们使用的技术栈:vue

实现这样的一个效果,我们需要知道以下信息:

  1. 每行可以放置多少书本?
  2. 放下所有的书本需要多少行?
  3. 需要什么样的数据结构?

我们现在一个个来思考,既然我们选择了vue来实现,秉持着数据驱动视图的理念,我们先从需要什么样的数据结构进行入手,其实很简单,只需要一个二维数组就可以了。

二维数组的第一层就是书架的每一行,二维数组的第二层就是每一行对应的书本

[
    [
        {id:1,,name:"语文课本1"},//每一行放置的课本
        {id:2,name:"语文课本2"},
    ],  
    [
        {id:3,,name:"语文课本1"},//第二行放置的课本
        {id:4,name:"语文课本2"},
    ], 
]

那么我们就可以按照这样的一个数据结构来遍历展示即可。

3. 实现步骤

3.1 界面实现

我们可以先按照我们上面已经写好的数据,来写好对应的Html和css,然后将效果渲染出来。

<template>
    <div class="shelf">
        <div class="shlef-row" v-for="(row, rowIndex) in bookData" :key="rowIndex">
            <div class="book-item" v-for="book in row" :key="book.id">
                {{ book.bookName }}
            </div>
        </div>
    </div>
</template>

<script setup>
import { ref } from 'vue';

const bookData = ref([
    [
        { id: 1, bookName: "语文课本1" },
        { id: 2, bookName: "语文课本2" },
    ],
    [
        { id: 3, bookName: "语文课本1" },//第二行放置的课本
        { id: 4, bookName: "语文课本2" },
    ]
])
</script>

<style>
.shelf {
    width: 1200px;
    height: auto;
    border: 1px solid #ccc;
    margin: 0 auto;
}

.shlef-row {
    width: 100%;
    margin: 0 0 20px 0;
    display: flex;
    border-bottom: 2px solid orange;
}

.shlef-row:last-child {
    margin-bottom: 0;
}

.book-item {
    box-sizing: border-box;
    padding: 10px;
    margin-right: 20px;
    width: 130px;
    height: 160px;
    color: #fff;
    background-color: skyblue;
}
</style>

3.2 根据真实的数据构造页面数据

我们在真实的环境下,肯定是通过接口获取到真实的后端数据,后端给我们的数据可能并不是我们想要的,我们就要对后端的数据进行构造,我们先分析下我们获取到真实的后端数据,来做一下分析。

[
        { id: 1, bookName: "语文课本1" },
        { id: 2, bookName: "语文课本2" },
        { id: 3, bookName: "数学课本1" },
        { id: 4, bookName: "数学课本2" },
        { id: 5, bookName: "数学课本3" },
        { id: 6, bookName: "数学课本4" },
        { id: 7, bookName: "化学课本1" },
        { id: 8, bookName: "化学课本2" },
        { id: 9, bookName: "化学课本1" },
        { id: 10, bookName: "化学课本2" },
        { id: 11, bookName: "物理课本1" },
        { id: 12, bookName: "物理课本2" },
        { id: 13, bookName: "物理课本3" },
        { id: 14, bookName: "物理课本4" },
        { id: 15, bookName: "生物课本1" },
        { id: 16, bookName: "生物课本2" }
]

可以看出,后端的数据给我们的是一整个数组,那么对于我们来说就需要解决以下问题:

  • 计算一行可以放置多少本书
  • 计算总共多少行

每行可以放置书本数:Math.floor(书架宽度 / 每本书实际占据的宽度(包含margin))

总共多少行书架:Math.ceil(书本总数 / 每行可以放置的书本树)

截取数组:循环书架行数,然后不停的从后端数据中去截取对应数量数据即可。

// 构造页面数据 rawData:后端数据
const genBookData = (rawData) => {
    const counts = Math.floor(1200 / 150);//每行可放置书本数fam
    const rowCount = Math.ceil(rawData.length / counts);//总共有多少行
    const rowArr = [];//书架二维数组

    for (let i = 0; i < rowCount; i++) {
        //每次截取对应的书本,添加到二维数组
        const rowBooks = rawData.slice(i * counts, (i + 1) * counts);
        rowArr.push(rowBooks);
    }
    return rowArr;
}

其实,这个时候,就已经实现了基本的书架功能了。

Snipaste_2025-10-17_09-53-15.png

4. 附加功能优化

上面虽然已经实现了基本的书架效果,但是我们面临以下的问题:

  • 现在最后一本书距离右侧空间太大,我想让书本平分空间。
  • 当用户改变浏览器窗口,我对应的书架宽度改变了,需要去根据屏幕更新每行放置的书本数。

1. 书本平分空间遇到的问题

对于评分空间,大家一定觉得很容易处理,直接使用flex布局,让每本书flex:1平分空间即可。

但是这里我重点想说的是,如果最后一行书架的书本如果放不满书架,那么就会受到flex:1的影响,自动撑大宽度,导致和上一行的书本宽度不一致。效果如下:

Snipaste_2025-10-17_10-04-48.png

解决方法:就是添加一些虚拟的占位元素(placeholder),我们改动一下我们的构造数据的函数。

// 构造页面数据
const genBookData = (rawData) => {
    const counts = Math.floor(1200 / 150);//每行可放置书本数fam
    const rowCount = Math.ceil(rawData.length / counts);//总共有多少行
    const rowArr = [];//书架二维数组

    for (let i = 0; i < rowCount; i++) {
        const rowBooks = rawData.slice(i * counts, (i + 1) * counts);
        //+++
        if (i === rowCount - 1 && rowBooks.length < counts) {
            // 当这一行实际的书本数 < 每行能放置的书本数时     向二维数组中添加占位元素
            const placeholders = Array(counts - rowBooks.length).fill().map((_, index) => ({
                id: `placeholder-${index}`,
                isPlaceholder: true
            }));
            rowArr.push([...rowBooks, ...placeholders]);
        } else {
            rowArr.push(rowBooks);
        }
    }
    return rowArr;
}

这样就正常了,大家可以把占位元素直接给隐藏( visibility: hidden;)即可

2. 解决动态计算问题

动态计算的时候其实也很简单,我们只需要获取到当前书架的宽度,然后监听windowresize事件,再去重新执行我们的构造数据的逻辑即可。

但是我有一个更好的方法,使用计算属性! 我们计算属性中依赖一下我们当前屏幕宽度的变量(shelfWidth),这样我们在改变屏幕的时候,直接更新shelfWidth即可,然后计算属性会自动执行,重新计算我们的数据。直接看最终代码。

<template>
    <div class="shelf" ref="shelfRef">
        <div class="shlef-row" v-for="(row, rowIndex) in bookData" :key="rowIndex">
            <div class="book-item" v-for="book in row" :key="book.id">
                {{ book.bookName }}
            </div>
        </div>
    </div>

    <button @click="changeWidtn">改变宽度</button>
</template>

<script setup>
import { ref, onMounted, onBeforeUnmount, computed } from 'vue';

const changeWidtn = () => {
    shelfWidth.value = 900;
}

// 请求接口的数据
const apiData = [
    { id: 1, bookName: "语文课本1" },
    { id: 2, bookName: "语文课本2" },
    { id: 3, bookName: "数学课本1" },
    { id: 4, bookName: "数学课本2" },
    { id: 5, bookName: "数学课本3" },
    { id: 6, bookName: "数学课本4" },
    { id: 7, bookName: "化学课本1" },
    { id: 8, bookName: "化学课本2" },
    { id: 9, bookName: "化学课本1" },
    { id: 10, bookName: "化学课本2" },
    { id: 11, bookName: "物理课本1" },
    { id: 12, bookName: "物理课本2" },
    { id: 13, bookName: "物理课本3" },
    { id: 14, bookName: "物理课本4" },
    { id: 15, bookName: "生物课本1" },
]


/* 书架效果 */
const shelfRef = ref(null);//书架Ref
const shelfWidth = ref(1200);//书架宽度

// 构造页面数据
const bookData = computed(() => {               //页面渲染的数据
    if (!shelfRef.value || !shelfWidth.value) {
        return []
    }

    const counts = Math.floor(shelfWidth.value / 150);//每行可放置书本数
    const rowCount = Math.ceil(apiData.length / counts);//总共有多少行
    const rowArr = [];//书架二维数组

    // 如果是最后一行且不满,添加占位元素,解决flex问题
    for (let i = 0; i < rowCount; i++) {
        const rowBooks = apiData.slice(i * counts, (i + 1) * counts);

        if (i === rowCount - 1 && rowBooks.length < counts) {
            const placeholders = Array(counts - rowBooks.length).fill().map((_, index) => ({
                id: `placeholder-${index}`,
                isPlaceholder: true,
                bookName: '占位元素'
            }));
            rowArr.push([...rowBooks, ...placeholders]);
        } else {
            rowArr.push(rowBooks);
        }

    }

    return rowArr;
})

// 更新屏幕宽度
const updateShelfWidth = () => {
    shelfWidth.value = shelfRef.value.offsetWidth;
}

onMounted(() => {
    updateShelfWidth();//页面加载后,更新下屏幕宽度
    window.addEventListener('resize', updateShelfWidth);
})

onBeforeUnmount(() => {
    window.removeEventListener('resize', updateShelfWidth);
})
</script>

<style>
.shelf {
    width: 1200px;
    height: auto;
    border: 1px solid #ccc;
    margin: 0 auto;
    min-width: 1000px;
}

.shlef-row {
    width: 100%;
    margin: 0 0 20px 0;
    display: flex;
    border-bottom: 2px solid orange;
}

.shlef-row:last-child {
    margin-bottom: 0;
}

.shlef-row .book-item:last-child {
    margin-right: 0;
}

.book-item {
    flex: 1;
    box-sizing: border-box;
    padding: 10px;
    margin-right: 20px;
    width: 130px;
    height: 160px;
    color: #fff;
    background-color: skyblue;
}
</style>

JavaScript设计模式(十五)——解释器模式 (Interpreter)

作者 Asort
2025年10月17日 10:28

引言:解释器模式概览

解释器模式是一种行为设计模式,它用于定义一种语言的文法,并创建一个解释器来解释该语言中的句子。虽然JavaScript本身是解释型语言,但本文将探讨如何使用JavaScript实现解释器模式,以解决特定领域的问题。

解释器模式的核心概念

解释器模式由四个核心组件构成:抽象表达式接口定义解释操作,终结符表达式实现基本元素解释,非终结符表达式组合表达式,上下文存储全局信息。

该模式采用递归下降解析技术,将输入字符串分解为表达式树,然后递归解释每个表达式。这种结构特别适合处理具有明确语法定义的语言。

在JavaScript中实现解释器模式:

// 抽象表达式接口
class Expression {
  interpret() { throw new Error("Must be implemented"); }
}

// 终结符表达式
class NumberExpression extends Expression {
  constructor(value) { this.value = value; }
  interpret() { return this.value; }
}

// 非终结符表达式
class AddExpression extends Expression {
  constructor(left, right) { this.left = left; this.right = right; }
  interpret() { return this.left.interpret() + this.right.interpret(); }
}

// 使用示例
const expr = new AddExpression(new NumberExpression(5), new NumberExpression(10));
console.log(expr.interpret()); // 输出: 15

解释器模式适用于SQL解析、数学表达式计算等场景。它与组合模式共同构建表达式树,与访问者模式协同遍历表达式树,形成完整的解释体系。

实现解释器模式

抽象表达式接口是解释器模式的核心,通过定义interpret方法作为所有表达式的公共接口。终结符表达式表示语言的基本元素,如数字、变量;非终结符表达式表示复合结构,如加法、乘法等运算。上下文环境存储变量值并提供解析辅助方法。

// 抽象表达式接口
class Expression {
  interpret() {
    throw new Error('interpret method must be implemented');
  }
}

// 终结符表达式 - 数字
class NumberExpression extends Expression {
  constructor(value) {
    super();
    this.value = value;
  }
  
  interpret() {
    return this.value;
  }
}

// 非终结符表达式 - 加法
class AddExpression extends Expression {
  constructor(left, right) {
    super();
    this.left = left;
    this.right = right;
  }
  
  interpret() {
    return this.left.interpret() + this.right.interpret();
  }
}

通过组合这些表达式,可以构建高效的JavaScript解释器系统,处理复杂的数学表达式,如"(1+2)*3",实现自定义语言的解析功能,提升代码的表达能力和执行效率。

实际应用案例

解释器模式在实际开发中有广泛应用,以下是几个典型实现:

数学表达式解析器:通过定义表达式、运算符和变量的解释器,实现复杂数学表达式的解析与计算。

class Expression {
  // 抽象表达式类
  interpret() {}
}

class Number extends Expression {
  constructor(value) {
    super();
    this.value = value;
  }
  interpret() {
    return this.value;
  }
}

class Add extends Expression {
  constructor(left, right) {
    super();
    this.left = left;
    this.right = right;
  }
  interpret() {
    return this.left.interpret() + this.right.interpret();
  }
}

简单编程语言解释器:实现迷你语言解释器,支持变量赋值、条件判断和基本运算。

class MiniLangInterpreter {
  constructor() {
    this.variables = {};
  }
  
  parse(code) {
    // 解析代码并执行
    const tokens = code.split(' ');
    if (tokens[0] === 'let') {
      this.variables[tokens[1]] = parseInt(tokens[3]);
    }
  }
  
  getVariable(name) {
    return this.variables[name];
  }
}

业务规则解析器:将业务规则文本转换为可执行代码,实现动态规则引擎。

class RuleInterpreter {
  interpret(rule) {
    // 将规则字符串转换为函数
    return new Function('data', `
      with(data) {
        return ${rule};
      }
    `);
  }
}

性能优化:采用缓存机制存储已解析的表达式,使用惰性求值和编译优化技术提升解释器性能。对于复杂场景,可引入语法分析树和字节码优化。

优缺点分析

解释器模式在JavaScript中为特定语言提供了解析方案,其优点在于实现简单语法分析直观,通过类结构表示语法规则,符合开闭原则便于扩展。例如:

// 解释器模式核心示例
class Expression {
  interpret() {
    throw new Error("Must implement interpret method");
  }
}

class NumberExpression extends Expression {
  constructor(value) {
    super();
    this.value = value; // 存储数值
  }
  
  interpret() {
    return this.value; // 返回数值本身
  }
}

然而,解释器模式存在明显缺点:复杂语言下性能低下,语法规则增加会导致类数量爆炸,且调试过程复杂。该模式适合简单且稳定的语言场景,或需快速原型开发时。对于复杂语言,推荐使用ANTLR、PEG.js等解析器生成工具,避免直接使用eval等不安全替代方案。

最佳实践

设计高效表达式类时,应避免过度使用非终结符表达式,优先使用组合模式简化结构。采用享元模式共享相似对象可减少内存占用:

// 享元模式实现
const ExpressionFlyweight = {
  create: function(type, value) {
    const key = `${type}:${value}`;
    if (!this.cache[key]) {
      this.cache[key] = { type, value };
    }
    return this.cache[key];
  },
  cache: {}
};

处理复杂上下文时,使用链式上下文和回退机制增强灵活性:

// 链式上下文实现
class ContextChain {
  constructor(parent = null) {
    this.parent = parent;
    this.data = {};
  }
  
  set(key, value) {
    this.data[key] = value;
  }
  
  get(key) {
    return this.data[key] || (this.parent ? this.parent.get(key) : null);
  }
}

优化解释器性能的关键是实现结果缓存和使用高效算法:

// 缓存解释结果
class Interpreter {
  constructor() {
    this.cache = new Map();
  }
  
  interpret(expression) {
    if (this.cache.has(expression)) {
      return this.cache.get(expression);
    }
    // 解释逻辑...
    const result = this.doInterpret(expression);
    this.cache.set(expression, result);
    return result;
  }
}

常见陷阱包括无限递归、语法错误和内存泄漏。解决方案:设置递归深度限制、实现错误恢复机制、定期清理缓存。使用WeakMap存储临时数据可避免内存泄漏,确保解释器长期稳定运行。

总结

解释器模式 (Interpreter)由抽象表达式、终结符表达式、上下文和客户端组成,适用于表达式求值、规则引擎等场景。使用时需权衡其可维护性与性能开销,建议在语法简单且解析逻辑复杂的场景应用。

H5移动端页面实现快递单号条形码/二维码扫描,亲测可行!!

2025年10月17日 10:27

公司项目要求实现H5页面可以扫描快递单条形码并回显快递单号,于是用html5-qrcode库实现条形码扫描功能。

关于 html5-qrcode

html5-qrcode 是一个基于 HTML5 和 JavaScript 的开源库,它利用浏览器的MediaDevices.getUserMedia() API 访问设备摄像头,实时扫描二维码(QR Code)和条形码(Barcode),无需安装插件或 App
html5-qrcode 是一个轻量、高效、纯前端的 二维码/条形码扫描库,专为在网页(H5)中实现摄像头扫码功能而设计。

GitHub 仓库:github.com/mebjas/html…
详细用法:juejin.cn/spost/75616…

具体实现

实现效果:\ image.png

image.png

项目基于vue3框架开发:

1.安装html5-qrcode:
npm install html5-qrcode

2.页面代码实现:

<template>
  <div class="registration-container">
    <h2>快递登记</h2>
    <el-form label-position="top" :model="ruleForm" :rules="rules" ref="ruleFormRef">
      <el-form-item label="真实退回快递单号" prop="expressNumber">
        <el-input v-model="ruleForm.expressNumber" placeholder="请输入快递单号" clearable>
          <template #suffix>
            <SvgIcon @click="startScan" name="take-pictures" width="24" height="24" />
          </template>
        </el-input>
      </el-form-item>
      <el-form-item label="快递公司" prop="expressCompany">
        <el-input v-model="ruleForm.expressCompany" placeholder="请输入快递公司" clearable />
      </el-form-item>
    </el-form>
    <div class="footer">
      <el-button class="submit-btn" type="primary" style="width: 100%" @click="submitForm">提交</el-button>
    </div>
  </div>
  <!-- 全屏扫码层 -->
  <div v-if="scanning" class="scanner-container">
    <div id="qr-reader" ref="qrReaderRef"></div>
    <p class="scan-hint">请将快递单条形码对准扫描框</p>
    <el-button class="close-btn" @click="stopScan">关闭</el-button>
  </div>
</template>

<script lang="ts" setup>
import { ref, onUnmounted, reactive } from 'vue'
import { Html5Qrcode } from 'html5-qrcode'
import { type FormInstance, type FormRules, ElMessage } from 'element-plus'

defineOptions({
  name: 'after-sales-registration'
})

const ruleFormRef = ref<FormInstance>()
const ruleForm = reactive({
  expressNumber: '',
  expressCompany: ''
})
const rules = reactive<FormRules>({
  expressNumber: [{ required: true, message: '请输入快递单号', trigger: 'blur' }],
  expressCompany: [{ required: true, message: '请输入快递公司', trigger: 'blur' }]
})

const scanning = ref(false)
const qrReaderRef = ref(null)

let html5QrCode: Html5Qrcode | null = null

// 开始扫描
const startScan = (event: Event) => {
  // 阻止事件冒泡和默认行为,防止输入框获得焦点
  event.preventDefault()
  event.stopPropagation()

  if (scanning.value) return
  scanning.value = true

  // 如果有活动的输入框,尝试让它失去焦点
  if (document.activeElement instanceof HTMLElement) {
    document.activeElement.blur()
  }

  // 延迟确保 DOM 渲染
  setTimeout(() => {
    try {
      html5QrCode = new Html5Qrcode('qr-reader')

      const config = {
        fps: 10,
        qrbox: { width: 300, height: 100 }, // 横向长条,适合条形码
        formats: [
          'code_128', // ✅ 快递单最常用
          'code_39', // ✅ 部分快递使用
          'ean_13' // ✅ 商品类快递
        ]
      }

      html5QrCode
        .start(
          { facingMode: 'environment' }, // 后置摄像头
          config,
          (decodedText) => {
            // console.log('🎉 识别到快递单号:', decodedText)
            ruleForm.expressNumber = decodedText
            stopScan() // 自动关闭
          },
          () => {}
        )
        .catch((err) => {
          ElMessage.error('摄像头启动失败:' + (err.message || '未知错误'))
          scanning.value = false
          stopScan()
        })
    } catch (err) {
      ElMessage.error(`扫码库初始化失败,请重试${err}`)
      stopScan()
    }
  }, 300)
}

// 停止扫描
const stopScan = () => {
  if (html5QrCode) {
    html5QrCode
      .stop()
      .then(() => {
        html5QrCode?.clear()
        html5QrCode = null
        scanning.value = false
      })
      .catch(() => {
        // console.error('停止失败:', err)
        scanning.value = false
      })
  } else {
    scanning.value = false
  }
}

const submitForm = () => {
  ruleFormRef.value?.validate((valid) => {
    if (valid) {
      // TODO:提交事件
    } else {
      ElMessage.error('请填写完整信息')
    }
  })
}

// 组件卸载时确保关闭扫描
onUnmounted(() => {
  if (html5QrCode) {
    html5QrCode.stop()
  }
})
</script>

<style lang="scss" scoped>
.registration-container {
  width: 100%;
  height: 100%;
  padding: 20px;
  background: #f8f8fa;
  box-sizing: border-box;
  .footer {
    position: fixed;
    bottom: 0;
    left: 0;
    width: 100%;
    padding: 17px 0;
    text-align: center;
    box-shadow: 0px -4px 16px 0px rgba(60, 126, 254, 0.2);
    box-sizing: border-box;
    .submit-btn {
      width: 50% !important;
      background: #3c7efe;
      border-radius: 555px;
    }
  }
}
input {
  width: 100%;
  padding: 12px;
  margin: 10px 0;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 16px;
  box-sizing: border-box;
  text-align: center;
  background-color: #f9f9f9;
}
button {
  width: 100%;
  padding: 12px;
  background-color: #d32f2f;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 16px;
  cursor: pointer;
}
button:disabled {
  background-color: #ef5350;
  cursor: not-allowed;
}
button:hover:not(:disabled) {
  background-color: #c62828;
}
/* 全屏扫码层 */
.scanner-container {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: black;
  z-index: 9999;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-start;
  padding-top: 40px;
}
#qr-reader {
  width: 100%;
  max-width: 500px;
  border-radius: 12px;
  overflow: hidden;
}
.scan-hint {
  color: #4caf50;
  margin-top: 12px;
  font-size: 18px;
  font-weight: bold;
}
.close-btn {
  margin-top: 12px;
  padding: 8px 16px;
  background: #ff5252;
  color: white;
  border: none;
  border-radius: 4px;
  font-size: 14px;
}
</style>

其他

注意事项

  1. 必须 HTTPS:生产环境必须部署在 HTTPS 域名下。
  2. 用户授权:首次使用需用户允许摄像头权限。
  3. 性能优化:避免在低性能设备上长时间运行。
  4. 兼容性问题:在某些旧版浏览器可能不支持。
  5. 移动端体验:html5-qrcode 扫码体验一般,需要贴紧条形码。

其他方案对比

方案 技术栈 是否推荐 说明
1. html5-qrcode + 格式过滤 纯前端 JS ✅✅✅ 强烈推荐 轻量、易用、支持条形码
2. ZXing JS(原生) 纯前端 JS ✅ 推荐 功能强大,但配置复杂
3. 原生 getUserMedia + QuaggaJS 纯前端 JS ⚠️ 可用但不推荐 QuaggaJS 已停止维护
4. 调用微信 JS-SDK 扫码 微信内置 ✅ 限微信环境 依赖微信 App
5. 调用 App 原生能力(Hybrid) WebView + Native ✅ 高性能 需开发 App
6. 拍照上传 + 后端识别 前端 + 后端 ✅ 稳定兜底 适合弱网或兼容性差的设备

从0死磕全栈之Next.js 缓存与数据重新验证

2025年10月17日 10:14

在现代 Web 应用中,性能和数据新鲜度是两个关键指标。Next.js 提供了强大的缓存(Caching)与重新验证(Revalidation)机制,帮助开发者在提升性能的同时,确保用户获取到最新数据。本文将深入介绍 Next.js App Router 中的缓存策略及其使用方式。


1. 什么是缓存?

缓存是一种将计算结果(如 API 请求、数据库查询)临时存储起来的技术。当下次请求相同数据时,可以直接从缓存中读取,避免重复计算,从而显著提升响应速度和降低服务器负载。

在 Next.js 中,缓存不仅适用于数据请求,也适用于页面渲染结果。


2. fetch 的缓存与重新验证

默认行为

在 Next.js 的 App Router 中,fetch 请求默认不会被缓存。但值得注意的是,即使 fetch 不缓存,Next.js 仍会对包含 fetch 的页面进行静态预渲染(Prerendering),并将整个 HTML 页面缓存下来。

强制缓存

你可以通过设置 cache: 'force-cache' 来显式启用缓存:

export default async function Page() {
  const data = await fetch('https://api.example.com/data', {
    cache: 'force-cache', // 启用缓存
  });
  return <div>{data.title}</div>;
}

该请求的结果将被缓存,并在后续请求中直接返回。

基于时间的重新验证(Time-based Revalidation)

为了让缓存数据保持“新鲜”,Next.js 支持基于时间的重新验证:

export default async function Page() {
  const data = await fetch('https://api.example.com/data', {
    next: { revalidate: 3600 }, // 每小时重新验证一次
  });
}

这意味着:缓存最多保留 3600 秒(1 小时),之后下一次请求会触发重新获取数据,并更新缓存。


3. unstable_cache:缓存任意异步函数

除了 fetch,你可能还需要缓存数据库查询、复杂计算等操作。Next.js 提供了 unstable_cache API(注意:前缀 unstable_ 表示该 API 可能在未来变更)。

基本用法

import { unstable_cache } from 'next/cache';
import { getUserById } from '@/lib/data';

export default async function Page({ params }: { params: Promise<{ userId: string }> }) {
  const { userId } = await params;

  const getCachedUser = unstable_cache(
    async () => {
      return getUserById(userId);
    },
    [userId] // 缓存键:确保不同用户的数据独立缓存
  );

  const user = await getCachedUser();
  return <div>{user.name}</div>;
}

配置重新验证与标签

你可以通过第三个参数配置缓存策略:

const getCachedUser = unstable_cache(
  async () => getUserById(userId),
  [userId],
  {
    tags: ['user'],      // 用于后续按标签清除缓存
    revalidate: 3600,    // 1 小时后重新验证
  }
);

4. 按标签重新验证:revalidateTag

当你更新了用户数据,希望清除相关缓存时,可以使用 revalidateTag

步骤一:为缓存打标签

无论是 fetch 还是 unstable_cache,都可以添加 tags

// 使用 fetch 打标签
await fetch('https://api.example.com/user', {
  next: { tags: ['user'] }
});

// 或使用 unstable_cache 打标签
const getUser = unstable_cache(
  async (id) => db.user.find(id),
  ['user'],
  { tags: ['user'] }
);

步骤二:在更新操作中触发重新验证

通常在 Route HandlerServer Action 中调用:

import { revalidateTag } from 'next/cache';

export async function updateUser(id: string, data: any) {
  await db.user.update(id, data);
  revalidateTag('user'); // 清除所有带 'user' 标签的缓存
}

✅ 优势:一个标签可关联多个缓存项,实现批量失效。


5. 按路径重新验证:revalidatePath

如果你只想重新验证某个页面(及其数据),可以使用 revalidatePath

import { revalidatePath } from 'next/cache';

export async function createPost(formData: FormData) {
  await savePost(formData);
  revalidatePath('/blog'); // 重新生成 /blog 页面
}

这会触发 Next.js 重新预渲染指定路径,并更新其缓存的 HTML 和数据。


6. 缓存策略对比

方法 适用场景 是否支持 revalidate 是否支持标签 是否适用于非 fetch
fetch + cache API 请求缓存 ✅(next.revalidate ✅(next.tags
unstable_cache 数据库查询、自定义函数缓存
revalidateTag 按标签批量清除缓存
revalidatePath 重新生成特定页面

7. 最佳实践

  • 优先使用 fetch 缓存:对于外部 API,直接使用 fetch 的缓存选项最简单高效。
  • 敏感数据避免缓存:如用户个人资料、支付信息等,应设为动态请求(cache: 'no-store')。
  • 合理设置 revalidate 时间:高频更新的数据设短时间(如 60 秒),静态内容可设更长。
  • 善用标签管理缓存:通过 tags 实现精准、高效的缓存失效。
  • 结合 Server Actions 使用:在用户操作(如提交表单)后立即触发 revalidateTagrevalidatePath,实现“即时更新”。

结语

Next.js 的缓存系统兼顾了性能灵活性,通过 fetchunstable_cacherevalidateTagrevalidatePath 等 API,开发者可以构建既快速又实时的应用。掌握这些机制,是构建高性能 Next.js 应用的关键一步。

vue3 options模式

作者 GBVFtou
2025年10月17日 10:08

applyOptions

外部

image.png

内部

image.png

1.resolveMergedOptions

2.beforeCreate

3.resolveInjections

    const provides = instance ? instance.parent == null ? instance.vnode.appContext && instance.vnode.appContext.provides : instance.parent.provides : currentApp._context.provides;

4.methods

   Object.defineProperty(ctx, key, {
            value: methodHandler.bind(publicThis),
            configurable: true,
            enumerable: true,
            writable: true
          });
        } else {
          ctx[key] = methodHandler.bind(publicThis);

5.dataOptions

    const data = dataOptions.call(publicThis, publicThis);

6.computedOptions

 for (const key in computedOptions) {
      const opt = computedOptions[key];
      const get =  opt.get.bind(publicThis, publicThis)
      const set = opt.set.bind(publicThis)
      const c = computed({
        get,
        set
      });
      Object.defineProperty(ctx, key, {
        enumerable: true,
        configurable: true,
        get: () => c.value,
        set: (v) => c.value = v
      });

7.watchOptions

 for (const key in watchOptions) {
      createWatcher(watchOptions[key], ctx, publicThis, key);
    }
    
  const getter = key.includes(".") ? createPathGetter(publicThis, key) : () => publicThis[key];
    const handler = ctx[raw];
   const handler = isFunction(raw.handler) ? raw.handler.bind(publicThis) : ctx[raw.handler];

 watch(getter, handler);
 watch(getter, raw.bind(publicThis));
 watch(getter, handler, raw);

8.provideOptions

  let provides = currentInstance.provides;
    const parentProvides = currentInstance.parent && currentInstance.parent.provides;
    if (parentProvides === provides) {
      provides = currentInstance.provides = Object.create(parentProvides);
    }
    provides[key] = value;
    if (currentInstance.type.mpType === "app") {
      currentInstance.appContext.app.provide(key, value);
    }

9.created

    callHook$1(created, instance, "c");

10.registerLifecycleHook

  function registerLifecycleHook(register, hook) {
    if (isArray(hook)) {
      hook.forEach((_hook) => register(_hook.bind(publicThis)));
    } else if (hook) {
      register(hook.bind(publicThis));
    }
  }
  registerLifecycleHook(onBeforeMount, beforeMount);
  registerLifecycleHook(onMounted, mounted);
  registerLifecycleHook(onBeforeUpdate, beforeUpdate);
  registerLifecycleHook(onUpdated, updated);
  registerLifecycleHook(onActivated, activated);
  registerLifecycleHook(onDeactivated, deactivated);
  registerLifecycleHook(onErrorCaptured, errorCaptured);
  registerLifecycleHook(onRenderTracked, renderTracked);
  registerLifecycleHook(onRenderTriggered, renderTriggered);
  registerLifecycleHook(onBeforeUnmount, beforeUnmount);
  registerLifecycleHook(onUnmounted, unmounted);
  registerLifecycleHook(onServerPrefetch, serverPrefetch);

11.expose

 if (isArray(expose)) {
    if (expose.length) {
      const exposed = instance.exposed || (instance.exposed = {});
      expose.forEach((key) => {
        Object.defineProperty(exposed, key, {
          get: () => publicThis[key],
          set: (val) => publicThis[key] = val
        });
      });
    } else if (!instance.exposed) {
      instance.exposed = {};
    }
  }

11.render

if (render && instance.render === NOOP) {
    instance.render = render;
  }

12.inheritAttrs

 if (inheritAttrs != null) {
    instance.inheritAttrs = inheritAttrs;
  }

13.components

 if (components)
    instance.components = components;

14.directives

  if (directives)
    instance.directives = directives;

15.customApplyOptions

  const customApplyOptions = instance.appContext.config.globalProperties.$applyOptions;
if (customApplyOptions) {
    customApplyOptions(options, instance, publicThis);
  }

axios的使用

作者 BraveAriesZyc
2025年10月17日 10:00

1.首先引入axios

 yarn add axios

2.然后axios最常见的用法axios.create()

首先是创建一个axios的实例,加上默认配置
import axios from "axios";

const http = axios.create({
    // 设置请求的根路径
    baseURL: '/api',
    // 设置超时时间
    timeout: 5000,
    // 设置请求头
    headers: {}
})

3.可以在public下面新建json目录然后 任意文件名.json

然后按照json的格式随便写一点数据

image.png

4.将axios.create里的baseUrl的根路径指向项目的http://localhost:(你的端口 如 :5173)作为根路径

并且将http导出
import axios from "axios";

const http = axios.create({
    // 设置请求的根路径
    baseURL: 'http://localhost:5173',
    // 设置超时时间
    timeout: 5000,
    // 设置请求头
    headers: {}
})


export default http

5.src -> 新建api目录 -> useDemoApi.ts

api目录用来分类请求 比如我的 useDemoApi.ts 是用来存放测试的api
第一种用法
import http from "../utils/http";

export const useDemoApi = () => {

    const getDemo = () => {


        return http(
            {
                url: '/json/demo.json',
                method: 'get',
                params: {
                    id: 1
                }
            }
        )
    }


    return {
        getDemo
    }

}
在页面使用它

image.png

可以看到拿到了数据

image.png

第二种用法

在App.vue页面的使用不用更改
import http from "../utils/http";

export const useDemoApi = () => {

    const getDemo = () => {
        return http.get(
            '/json/demo.json',
            {
                params: {
                    id: 1
                }
            }
        )
    }


    return {
        getDemo
    }

}
可以看到依然能拿到而且一模一样

image.png

具体的使用,看个人爱好以及团队的统一

6.你会发现打印的数据有很多层每次要去获取真实数据都需要.data.data才能拿到json的数据 这时候可以使用拦截器来处理

拦截器分为两种

一种是响应拦截器(顾名思义 他就是在请求成功后的时候可以对请求到的数据做一些操作)

例 响应拦截器(不做任何处理)

import axios from "axios";

const http = axios.create({
    // 设置请求的根路径
    baseURL: 'http://localhost:5173',
    // 设置超时时间
    timeout: 5000,
    // 设置请求头
    headers: {}
})

// 响应拦截器
http.interceptors.response.use((res) => {
    return res
})


export default http
不做任何处理会发现拿到的数据是一模一样的

image.png

从这里我们可以直接在返回res的时候做一下处理res.data
import axios from "axios";

const http = axios.create({
    // 设置请求的根路径
    baseURL: 'http://localhost:5173',
    // 设置超时时间
    timeout: 5000,
    // 设置请求头
    headers: {}
})

// 响应拦截器
http.interceptors.response.use((res) => {
    return res.data
})


export default http
你会发现数据少了一层data包裹的了(所以在这里返回的时候统一处理数据结构就可以了,比如解决包裹层级太多或者获取响应状态码做message提示之类的)

image.png

我们可以打印一下res

它包含了请求地址的一些响应配置

image.png

一种是请求拦截器(顾名思义 他就是在发起请求前对一些请求的配置做一些操作)

我们可以直接添加这个请求拦截器并且打印一下看看他是啥

import axios from "axios";

const http = axios.create({
    // 设置请求的根路径
    baseURL: 'http://localhost:5173',
    // 设置超时时间
    timeout: 5000,
    // 设置请求头
    headers: {}
})

// 响应拦截器
http.interceptors.response.use((res) => {
    return res.data
})
// 请求拦截器
http.interceptors.request.use((config) => {
    console.log("请求拦截器",config)
    return config
})


export default http

你会看到这里面包含了本次请求的所有信息,基础的路径,请求头,参数啥都可以看到一般除去防抖节流的时候(意思就是防止用户多次点击发送请求造成堵塞)我们不做操作

image.png

从打印的响应拦截器的res和请求拦截的config可以得出结论

config是自己发送请求时的配置与参数 res是你获取的那个请求的响应参数

一文看懂 Next.js 数据获取与渲染策略:从 SSR 到 ISR 的取舍之道

作者 三木檾
2025年10月17日 09:57

🚀 【Next.js 深入系列 · 第三篇】一文看懂数据获取与渲染策略:从 SSR 到 ISR 的取舍之道

👨‍💻 作者:kd
📅 更新时间:2025-10
💡 标签:Next.js、SSR、ISR、数据获取、性能优化


💬 前言:为什么“渲染方式”决定了网站的灵魂

如果你正在用 Next.js 开发项目,你一定听过这些名词:
SSR、SSG、ISR、CSR

它们看似只是技术实现的选择,但在真正的生产环境中,却决定了:

  • 你的首屏速度快不快
  • 页面能不能被搜索引擎收录
  • 用户是否总看到旧数据
  • 构建时间是秒级还是分钟级

这篇文章将带你从原理、代码到选型思路,完整看懂 Next.js 的四种渲染策略,并结合最新的 App Router 模式 一步步解析。


🧩 一、Next.js 中的渲染方式总览

在 Next.js 里,所有页面的内容最终都来自「数据获取 + 渲染模式」的组合。

模式 渲染时机 适用场景 SEO 友好 示例
SSR (Server-Side Rendering) 每次请求时渲染 动态内容、实时数据 用户信息页
SSG (Static Site Generation) 构建时渲染 静态内容、文档页 博客文章页
ISR (Incremental Static Regeneration) 静态 + 定期再生 半静态数据 商品详情页
CSR (Client-Side Rendering) 前端请求渲染 纯交互页、用户态 Dashboard、个人中心

你可以简单记为:

“越靠服务器,SEO 越强;越靠客户端,交互越快。”


⚙️ 二、SSR:实时数据与 SEO 的完美结合

SSR(服务端渲染)是 Next.js 的基础能力。
它在每次请求时,在服务器上运行 React 组件并返回 HTML。

旧版(Pages Router)写法:

export async function getServerSideProps() {
  const data = await fetch('https://api.example.com/posts');
  return { props: { data } };
}

新版(App Router)写法:

export default async function Page() {
  const res = await fetch('https://api.example.com/posts', { cache: 'no-store' });
  const data = await res.json();
  return <PostList data={data} />;
}

cache: 'no-store' 即代表每次请求都重新获取数据,相当于 SSR。

✅ 优点:

  • 数据始终最新
  • SEO 效果好(直接返回完整 HTML)

⚠️ 缺点:

  • 请求延迟较高
  • 高并发下服务器压力大

💡 适用场景:动态资讯页、用户个人中心、权限数据等。


📦 三、SSG:构建时生成的“极速体验”

SSG(静态生成)在构建阶段预渲染所有页面,访问时直接返回 HTML。

旧版写法:

export async function getStaticProps() {
  const posts = await fetchPosts();
  return { props: { posts } };
}

新版写法(App Router):

export const dynamic = 'force-static';

export default async function Page() {
  const posts = await getData();
  return <PostList posts={posts} />;
}

✅ 优点:

  • 加载速度极快(CDN 缓存命中率高)
  • 几乎零后端负担

⚠️ 缺点:

  • 数据更新需重新构建
  • 不适合高频变动内容

💡 适用场景:博客、文档、营销页。


⏱ 四、ISR:让静态页面“自动刷新”

ISR(增量静态再生成)是 SSG 的升级版。
它允许页面静态化后定期自动刷新数据,而不需整站重构。

示例:

export default async function Page() {
  const res = await fetch('https://api.example.com/products', {
    next: { revalidate: 60 }, // 60秒后再生成一次
  });
  const products = await res.json();
  return <ProductList products={products} />;
}

✅ 优点:

  • 静态速度 + 动态数据的平衡
  • 后台自动再生,无需人工触发

⚠️ 缺点:

  • 存在数据延迟(缓存更新周期内)
  • 首次访问时可能触发“冷启动”

💡 适用场景:商品详情、排行榜、活动页等半动态页面。


🧠 五、CSR:让交互更自由

CSR(客户端渲染)则是传统 React 模式。
Next.js 也允许在组件中通过 useEffect 自行请求数据。

'use client';

export default function Page() {
  const [data, setData] = useState([]);
  useEffect(() => {
    fetch('/api/data').then(r => r.json()).then(setData);
  }, []);
  return <List data={data} />;
}

✅ 优点:

  • 响应快,交互流畅
  • 用户态数据灵活

⚠️ 缺点:

  • 首屏白屏时间长
  • SEO 效果差

💡 适用场景:用户中心、后台管理系统、纯交互模块。


🧩 六、App Router 中的新数据策略

Next.js 13+ 的 App Router 彻底重构了数据获取模型。
你可以在 组件级别 直接使用 fetch(),无需繁琐的 getStaticPropsgetServerSideProps

配置 含义 对应模式
cache: 'no-store' 每次请求都获取 SSR
next: { revalidate: N } N 秒后再生成 ISR
默认 构建时缓存 SSG

更棒的是:

你可以在同一页面中混合使用多种策略。

比如:

const user = await fetch('/api/user', { cache: 'no-store' });
const posts = await fetch('/api/posts', { next: { revalidate: 120 } });

即:

  • 用户信息用 SSR(实时)
  • 帖子列表用 ISR(缓存)

这种“混合渲染”是 App Router 带来的最大生产力提升。


⚡ 七、性能对比与选型建议

模式 构建时间 首屏速度 实时性 推荐使用
SSR 中等 较快 ✅ 实时 实时动态页
SSG 较慢 ⚡ 极速 ❌ 静态 文档、博客
ISR 较快 ⚡ 极速 ♻️ 可控延迟 半动态页
CSR 一般 ✅ 实时 后台、用户态

🧭 选型建议:

  • 若页面依赖 SEO → SSR / SSG / ISR
  • 若用户数据频繁变动 → SSR / CSR
  • 若性能优先 → ISR
  • 若内容长期稳定 → SSG

🔍 八、实战技巧:让渲染更高效

  1. 结合 Edge Runtime 提速 SSR

    export const runtime = 'edge';
    

    将 SSR 移到边缘节点,显著降低 TTFB。

  2. 缓存 API 响应
    使用 next: { revalidate } 控制刷新频率,减少 API 压力。

  3. 分片渲染(Streaming)
    利用 React Server Components 实现更快的首屏展示。

  4. 监控与优化
    使用 reportWebVitals 或 Vercel Analytics 量化渲染延迟。


🧾 九、总结:策略是性能的核心

Next.js 的渲染模式看似复杂,但归根结底是一场「性能与实时性的平衡游戏」。
理解它们的本质,你才能根据业务灵活取舍。

模式 核心优势 一句话总结
SSR 动态数据、SEO 友好 实时渲染,代价是服务器压力
SSG 构建时生成、加载快 一次生成,极速体验
ISR 静态 + 再生、折中方案 缓存自动更新的最佳平衡点
CSR 前端控制、灵活交互 SEO 弱但用户体验强

💡 一句话总结:
Next.js 的数据策略不是“选择题”,而是“组合题”。


✨ 结语:Next.js 的设计哲学

Next.js 的目标不是让你写更复杂的逻辑,而是让前端拥有后端的控制力
理解它的渲染策略,你才能真正构建出既快又稳的现代 Web 应用。


📚 延伸阅读

鸿蒙应用开发从入门到实战(二十四):一文搞懂ArkUI网格布局

2025年10月17日 09:33

大家好,我是潘Sir,持续分享IT技术,帮你少走弯路。《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容、欢迎关注!

ArkUI提供了各种布局组件用于界面布局,本文研究使用Grid组件实现网格布局。

一、概述

网格布局(Grid)是一种强大的布局方案,它将页面划分为组成的网格,然后将页面内容在二维网格中进行自由的定位,以下效果都可通过网格布局实现

1网格布局案例.png

网格布局的容器组件为 Grid,子组件为 GridItem,具体语法如下

代码

Grid() {
  GridItem() {
    Text('GridItem')   
  }
  GridItem() {
    Text('GridItem')   
  }
  GridItem() {
    Text('GridItem')   
  }
  GridItem() {
    Text('GridItem')   
  }
  ......
}

效果

2网格布局.png

二、常用属性

2.1 划分网格

Grid组件支持自定义行数和列数以及每行和每列的尺寸大小,上述内容需要使用rowsTemplate()方法和columnsTemplate()方法进行设置,具体用法如下

代码

Grid() {
  ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9], (item) => {
    GridItem() {
      Text(item.toString())
        .itemTextStyle()
    }
  })
}
.width(320)
.height(240)
.rowsTemplate('1fr 1fr 1fr')
.columnsTemplate('1fr 2fr 1fr')
.gridStyle()

效果

3grid划分网格.png

说明:

fr为 fraction(比例、分数) 的缩写。fr的个数表示网格布局的行数或列数,fr前面的数值大小,表示该行或列的尺寸占比。

示例代码

pages/layout目录下新建grid目录,新建GridBasic.ets文件

@Entry
@Component
struct GridBasic {
  build() {
    Column() {
      Grid() {
        ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9], (item) => {
          GridItem() {
            Text(item.toString())
              .itemTextStyle12()
          }
        })
      }
      .width(320)
      .height(240)
      .rowsTemplate('1fr 1fr 1fr')
      .columnsTemplate('1fr 2fr 1fr')
      .gridStyle12()
    }.width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

@Extend(Text) function itemTextStyle12() {
  .height('100%')
  .width('100%')
  .textAlign(TextAlign.Center)
  .fontColor(Color.White)
  .fontSize(40)
  .fontWeight(FontWeight.Bold)
  .backgroundColor('#008a00')
  .borderWidth(1)
}

@Extend(Grid) function gridStyle12() {
  .backgroundColor('#f5f5f5')
  .borderWidth(1)
}

2.2 子组件所占行列数

GridItem组件支持横跨几行或者几列,如下图所示

4子组件所占行列数.png

可以使用columnStart()columnEnd()rowStart()rowEnd()方法设置 GridItem 组件所占的单元格,其中rowStartrowEnd属性表示当前子组件的起始行号和终点行号,columnStartcolumnEnd属性表示指定当前子组件的起始列号和终点列号。

说明:

Grid容器中的行号和列号均从0开始。

具体用法如下

代码:

Grid() {
  GridItem() {
    Text('1')
      .itemTextStyle()
  }.rowStart(0).rowEnd(1).columnStart(0).columnEnd(1)

  GridItem() {
    Text('2')
      .itemTextStyle()
  }

  GridItem() {
    Text('3')
      .itemTextStyle()
  }

  GridItem() {
    Text('4')
      .itemTextStyle()
  }
  GridItem() {
    Text('5')
      .itemTextStyle()
  }.columnStart(1).columnEnd(2)
}
.width(320)
.height(240)
.rowsTemplate('1fr 1fr 1fr')
.columnsTemplate('1fr 2fr 1fr')
.gridStyle()

效果:

5grid案例.png

示例代码

pages/layout/grid目录,新建StartAndEndPage.ets文件

@Entry
@Component
struct StartAndEndPage {
  build() {
    Column() {
      Grid() {
        GridItem() {
          Text('1')
            .itemTextStyle13()
        }.rowStart(0).rowEnd(1).columnStart(0).columnEnd(1)

        GridItem() {
          Text('2')
            .itemTextStyle13()
        }

        GridItem() {
          Text('3')
            .itemTextStyle13()
        }

        GridItem() {
          Text('4')
            .itemTextStyle13()
        }

        GridItem() {
          Text('5')
            .itemTextStyle13()
        }.columnStart(1).columnEnd(2)
      }
      .width(320)
      .height(240)
      .rowsTemplate('1fr 1fr 1fr')
      .columnsTemplate('1fr 2fr 1fr')
      .gridStyle13()
    }.width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

@Extend(Text) function itemTextStyle13() {
  .height('100%')
  .width('100%')
  .textAlign(TextAlign.Center)
  .fontColor(Color.White)
  .fontSize(40)
  .fontWeight(FontWeight.Bold)
  .backgroundColor('#008a00')
  .borderWidth(1)
}

@Extend(Grid) function gridStyle13() {
  .backgroundColor('#f5f5f5')
  .borderWidth(1)
}

2.3 行列间距

使用rowsGap()columnsGap()属性,可以控制行列间距,具体用法如下

代码

Grid() {
  ......
}
.columnsGap(20)
.rowsGap(20)

效果

6行列间距.png

示例代码

pages/layout/grid目录,新建GridGap.ets文件

@Entry
@Component
struct GridGap {
  build() {
    Column() {
      Grid() {
        GridItem() {
          Text('1')
            .itemTextStyle14()
        }.rowStart(0).rowEnd(1).columnStart(0).columnEnd(1)

        GridItem() {
          Text('2')
            .itemTextStyle14()
        }.rowStart(0).rowEnd(1)


        GridItem() {
          Text('3')
            .itemTextStyle14()
        }

        GridItem() {
          Text('4')
            .itemTextStyle14()
        }.columnStart(1).columnEnd(2)
      }
      .width(320)
      .height(240)
      .rowsTemplate('1fr 1fr 1fr')
      .columnsTemplate('1fr 2fr 1fr')
      .gridStyle14()
      .rowsGap(20)
      .columnsGap(20)
    }.width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

@Extend(Text) function itemTextStyle14() {
  .height('100%')
  .width('100%')
  .textAlign(TextAlign.Center)
  .fontColor(Color.White)
  .fontSize(40)
  .fontWeight(FontWeight.Bold)
  .backgroundColor('#008a00')
  .borderWidth(1)
}

@Extend(Grid) function gridStyle14() {
  .backgroundColor('#f5f5f5')
  .borderWidth(1)
}

三、计算器案例

使用网格布局实现如下布局效果

7计算器案例.png

示例代码

pages/layout/grid目录,新建CalculatorPage.ets文件

@Entry
@Component
struct CalculatorPage {
  build() {
    Column() {
      Grid() {
        GridItem() {
          Text('0')
            .screenTextStyle()
        }.columnStart(0).columnEnd(3)

        GridItem() {
          Text('CE')
            .buttonTextStyle()
        }

        GridItem() {
          Text('C')
            .buttonTextStyle()
        }

        GridItem() {
          Text('÷')
            .buttonTextStyle()
        }

        GridItem() {
          Text('x')
            .buttonTextStyle()
        }

        GridItem() {
          Text('7')
            .buttonTextStyle()
        }

        GridItem() {
          Text('8')
            .buttonTextStyle()
        }

        GridItem() {
          Text('9')
            .buttonTextStyle()
        }

        GridItem() {
          Text('-')
            .buttonTextStyle()
        }

        GridItem() {
          Text('4')
            .buttonTextStyle()
        }

        GridItem() {
          Text('5')
            .buttonTextStyle()
        }

        GridItem() {
          Text('6')
            .buttonTextStyle()
        }

        GridItem() {
          Text('+')
            .buttonTextStyle()
        }

        GridItem() {
          Text('1')
            .buttonTextStyle()
        }

        GridItem() {
          Text('2')
            .buttonTextStyle()
        }

        GridItem() {
          Text('3')
            .buttonTextStyle()
        }

        GridItem() {
          Text('=')
            .buttonTextStyle()
            .backgroundColor('#1aa1e2')
        }.rowStart(4).rowEnd(5)

        GridItem() {
          Text('0')
            .buttonTextStyle()
        }.columnStart(0).columnEnd(1)

        GridItem() {
          Text('.')
            .buttonTextStyle()
        }
      }
      .gridStyle15()
      .rowsTemplate('1fr 1fr 1fr 1fr 1fr 1fr')
      .columnsTemplate('1fr 1fr 1fr 1fr')
    }.width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}


@Extend(Text) function screenTextStyle() {
  .backgroundColor('#bac8d3')
  .height('100%')
  .width('100%')
  .textAlign(TextAlign.End)
  .padding(10)
  .borderRadius(10)
  .borderWidth(1)
  .fontSize(40)
  .fontWeight(FontWeight.Bold)
}

@Extend(Text) function buttonTextStyle() {
  .backgroundColor('#f5f5f5')
  .height('100%')
  .width('100%')
  .textAlign(TextAlign.Center)
  .padding(10)
  .borderRadius(10)
  .borderWidth(1)
  .fontSize(25)
}

@Extend(Grid) function gridStyle15() {
  .width(320)
  .height(480)
  .borderRadius(10)
  .borderWidth(1)
  .padding(10)
  .rowsGap(10)
  .columnsGap(10)
}

《鸿蒙应用开发从入门到项目实战》系列文章持续更新中,陆续更新AI+编程、企业级项目实战等原创内容,防止迷路,欢迎关注!

js全局函数原来是这样啊

2025年10月17日 09:31

最近在开发移动端项目,有个不明白的问题是,我看代码里面有个 window.xxx 的方法调用 , xxx 是原生app提供的方法,这个是调用但是xxx 是什么时候挂载到window上的呢,我接着溯源,看到有个common.js,里面全是对原生api的定义,类似这样的定义

// common.js

function getUserInfo ()=>{
  if (ios) {
     // 调用ios提供的api
  }els{
    // 调用安卓提供的api
  }
}

....//
   

然后这个 common.js 是在 index.html 里面引入了,我纳闷的是虽有在index.html 里面引入,但是并没挂载到window上面啊,为啥代码里面能用 window.getUserInfo() 调用,并且按照这种方法,我在真机上调试还能那到原生返回的H5的结果。

带着我的疑问,我问了下豆包,豆包是这么回答我的

image.png

看到这个我恍然大悟,原来是这样啊,那就讲的通了,我得亲自试试

准备一个html文件,引入一个js文件

image.png

// index.js
function getInfo() {
  console.log("---getInfo--");
}

let name = "vue";

var age = "react";

var fun1 = () => {
  console.log("--fun1----");
};

然后看下浏览器的输出

image.png

还是基础不够扎实,但是我也不记得学过这个知识点 ‘emjo 笑哭’

从零到一打造 Vue3 响应式系统 Day 26 - 数组长度变更处理

作者 我是日安
2025年10月17日 09:26

ZuB1M1H.png

在我们构建响应式系统的过程中,虽然对于原生 JavaScript 对象的处理已经相当完善,但数组 (Array) 与普通对象的属性不同,数组的 length 属性与其数值索引之间有紧密的联动关系。

手动变更数组长度

最直接改变数组长度的方式就是手动赋值。虽然这在日常开发中不被鼓励,但一个健壮的响应式系统必须能正确处理这种情况。

<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive, effect } from '../dist/reactivity.esm.js'

    const state = reactive(['a', 'b', 'c','d'])

    effect(() => {
      console.log(state.length)
    })

    setTimeout(() => {
      state.length = 2
    }, 1000)
  </script>
</body>

在上述示例中,我们直接修改了数组长度,它会触发更新(通常我们会避免直接更改数组长度的做法)。

像这样直接缩短数组长度,超出新长度的元素会被删除,如下图:

day26-01.png

<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive, effect } from '../dist/reactivity.esm.js'

    const state = reactive(['a', 'b', 'c','d'])

    effect(() => {
      console.log(state[3])
    })

    setTimeout(() => {
      state.length = 2
    }, 1000)
  </script>
</body>

执行这段代码,控制台会先输出 d,这符合预期。然而,一秒后当 state.length 被修改为 2 时,console.log 并没有再次执行。

day26-02.png 然而,问题出在哪里?

我们的 effect 依赖的是 state[3]。当 state.length 被修改为 2 时,索引为 3 的元素实际上已经被删除了。

因此,这个 effect 所依赖的键 ('3') 后续不会再发生任何 set 行为,导致它再也没有机会被重新触发。依赖关系因此丢失。

这个“删除”操作,仅触发了 state 对象 length 属性的 set并未触发索引 '3'set

所以我们需要做的是,当 length 被缩短时,我们要找出所有依赖“被删除索引”的 effect,并通知它们重新执行:

// dep.ts
export function trigger(target, key) {
  const depsMap = targetMap.get(target)
  // 如果 depsMap 不存在,表示没有收集过依赖,直接返回
  if (!depsMap) return

  const targetIsArray = Array.isArray(target)

  // 新增:如果目标是数组且修改的是 length 属性
  if (targetIsArray && key === 'length') {
    // 遍历该数组的所有依赖
    depsMap.forEach((dep, depKey) => {
      // 如果依赖的键是 'length' 或大于等于新 length 值的索引
      // newValue 在 set 处理器中还拿不到,这里假设能拿到
      // 实际上应该在 set 中拿到 newValue 再传给 trigger
      if (depKey >= newValue || depKey === 'length') {
        // 通知访问了这些索引的 effect 以及访问了 length 的 effect 重新执行
        propagate(dep.subs)
      }
    })
  } else {
    // 如果不是数组,或者更新的不是 length,则直接获取依赖
    const dep = depsMap.get(key)
    // 如果依赖不存在,表示这个 key 没有在 effect 中被使用过,直接返回
    if (!dep) return

    // 找到依赖,触发更新
    propagate(dep.subs)
  }
}

(注:上面的代码片段为示意,newValue 需要从 set 处理器传入 trigger

state.length = 2 时,effect 会被重新触发。现在看我们的示例代码,会发现触发更新后,输出结果是 undefined,因为 state[3] 已不存在。

day26-03.png

数组方法导致长度变更

除了直接赋值外,一些我们常用的数组方法,如 poppushshift 等,也会隐式地影响到数组长度:

<body>
  <div id="app"></div>
  <script type="module">
    // import { ref, watch, effect } from '../../../node_modules/vue/dist/vue.esm-browser.js'
    import { reactive, effect } from '../dist/reactivity.esm.js'

    const state = reactive(['a', 'b', 'c','d'])

    effect(() => {
      console.log(state.length)
    })

    setTimeout(() => {
      state.push('e')
    }, 1000)
  </script>
</body>

这里可以看到,我们通过 push 增加了一个新元素,但是依赖 lengtheffect 并没有被触发更新:

day26-04.png 那么,当 push 这类方法被调用时,我们如何侦测到 length 的隐式变更呢?

关键在于拦截 set 操作。push('e') 的底层操作,除了在索引 4 上设置新值,也会修改 length 属性。

我们可以在 set 代理中,比较操作前后的数组长度,如果不一致,就主动触发 length 属性的依赖更新:

TypeScript

// baseHandlers.ts

export const mutableHandlers = {
  // ...
  set(target, key, newValue, receiver) {
    const oldValue = target[key]
    const targetIsArray = Array.isArray(target)
    // 如果 target 是数组,记录其旧长度
    const oldLength = targetIsArray ? target.length : 0
    
    const res = Reflect.set(target, key, newValue, receiver)
    
    // ... (ref 相关的处理)

    if (hasChanged(newValue, oldValue)) {
      // 触发当前 key 的更新
      trigger(target, key, newValue) // 把新值传进去
    }
    
    // 如果目标是数组,并且长度发生了变化
    if (targetIsArray && target.length !== oldLength) {
      // 并且被修改的 key 本身不是 'length' (避免重复触发)
      if (key !== 'length') {
        trigger(target, 'length', target.length) // 主动为 'length' 触发一次更新
      }
    }
    return res
  }
}

这段代码的逻辑是: 在 set 操作之后,我们检查目标是否为数组,并比较其新旧长度。

  • 如果长度发生了变化
  • 并且当前修改的 key 不是 length 本身(为了避免在 state.length = 2 这种情况下重复触发)
  • 我们就为 length 属性也主动触发一次更新。

今天我们聚焦于数组 length 属性的特殊性。通过分别在 trigger 函数和 set 代理中增加特殊的处理逻辑:

  • trigger:处理了手动缩短 length 时,对已删除索引的依赖触发。
  • set:处理了数组方法隐式改变 length 时,对 length 属性的依赖触发。

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

宝藏工具站!一个轻量实用的在线工具集合!

作者 Java陈序员
2025年10月17日 09:20

大家好,我是 Java陈序员

在日产生活中,常常需要各种小工具来解决日常遇到的问题,比如图片处理、文本转换、编程开发等。

快速搭建!一个轻量的在线工具箱!

8.7K+ Star!快速搭建个人在线工具箱

今天,给大家推荐一个集成了多种实用功能的轻量工具集合网站,无论是日常办公、学习还是娱乐,都能在这里找到合适的工具。

项目介绍

MikuTools —— 一个开源的在线工具集合,开发了图片处理、文本转换、编程开发等数十种工具。采用 Vue 全家桶 + Nuxt.js 技术栈开发,界面简洁清爽,操作简单直观。

实用工具

  • 日常工具:开发了屏幕录制、番茄时钟、人生小格、随机数生成、图片加包浆、亲戚关系计算器、二维码生成/解析、颜色处理、收款码合并、身份证号码生成等工具
  • 图片相关:开发了图片格式转换、九宫格切图、ACG 表情包制作、Logo 生成、微博生成器、抖音风格文字生成、图片编辑器、身份证加水印等工具
  • 文字处理:开发了文本比对、文本去重、数字转大写中文、富文本编辑器、摩斯电码转换等工具
  • 编程开发:开发了 CSS 兼容性处理、时间戳转换、URL 格式化、文本加密解密、Linux 命令查询、文本编码解码、执行 Cron 表达式、进制转换、JSON 编辑器、正则大全等工具

功能体验

  • 系统首页

  • 暗黑模式

  • 亲戚关系计算器

  • 九宫格切图

  • Pornhub 风格Logo生成

  • 微博生成器

  • 图片编辑器

  • 富文本编辑器

  • 文本加密解密

  • Linux 命令查询

  • 正则大全

  • 系统设置

本地部署

1、克隆或下载项目源码

git clone https://github.com/Ice-Hazymoon/MikuTools.git

2、安装依赖

yarn install

3、运行启动

yarn dev

4、启动成功后,浏览器访问

http://localhost:3000

5、打包部署

yarn generate

MikuTools 以轻量、实用、开源为特点,集成了多种日常所需的工具,无论是普通用户还是开发者,都能从中受益,快去部署试试吧~

项目地址:https://github.com/Ice-Hazymoon/MikuTools

最后

推荐的开源项目已经收录到 GitHub 项目,欢迎 Star

https://github.com/chenyl8848/great-open-source-project

或者访问网站,进行在线浏览:

https://chencoding.top:8090/#/

大家的点赞、收藏和评论都是对作者的支持,如文章对你有帮助还请点赞转发支持下,谢谢!


🔥🔥🔥收藏!面试常问JavaScript 中统计字符出现频率,一次弄懂!

2025年10月17日 08:52

关键词:字符频率、HashMap、Map、reduce、性能、Unicode、前端算法


一、前言:为什么“数字符”也会踩坑?

面试题里常出现这样一道“送分题”:
“给定任意字符串,统计每个字符出现的次数。”

很多小伙伴提笔就写:

const count = {};
for (let i = 0; i < str.length; i++) {
  count[str[i]] = (count[str[i]] || 0) + 1;
}

跑一下 "héllo👨‍👩‍👧‍👦",瞬间裂开:

  1. é 被拆成 e + ́
  2. emoji 家族直接乱成 8 个码元
  3. 中文标点、空格、换行全混在一起

这篇文章带你从“能跑”到“健壮”,覆盖:

  • ✅ ES6 之后最简写法
  • ✅ Unicode 安全(emoji、生僻汉字、组合字符)
  • ✅ 大小写/空白/标点过滤
  • ✅ 按频率排序并输出 TopN
  • ✅ 性能对比 & 内存占用
  • ✅ TypeScript 类型声明
  • ✅ 单元测试用例(Jest)

二、基础知识:字符串到底“长”什么样?

1. UTF-16 与码元

JavaScript 内部采用 UTF-16
一个“字符”在引擎眼里可能是:

  • 1 个码元(BMP,U+0000 ~ U+FFFF)
  • 2 个码元(代理对,SMP,emoji 常见)
"😊".length === 2   // 不是 1!

2. 组合字符(Combining Characters)

é 可以是一个码点(U+00E9),也可以是 e + ́ (U+0301) 两个码点。
肉眼看起来是一个“字符”,但码点长度不同。

3. 视觉字形 vs 字素簇(Grapheme Cluster)

Unicode 引入“字素簇”概念:用户眼中“不可再分割”的最小单元。
👨‍👩‍👧‍👦 由 4 个 emoji + 3 个 ZWJ(零宽连接符)组成,长度是 11 个码元,但用户看来只有 1 个“家庭”图标。


三、四种主流实现对比

方案 是否 Unicode 安全 代码量 性能 备注
for…of + Object ✅ BMP 最快 代理对会被拆
Array.from + Map ✅ 代理对 不支持字素簇
Intl.Segmenter ✅ 字素簇 较慢 浏览器新 API
第三方库 grapheme-splitter ✅ 字素簇 包体积 6 kB

结论:根据场景选工具

  • 纯中文/英文 → for…of 足够
  • 含 emoji → Array.fromSegmenter
  • 严谨排版/国际化 → 字素簇库

四、代码实战

1. 最快简版(BMP 安全)

function freqBasic(str) {
  const freq = Object.create(null); // 无原型污染
  for (const ch of str) {           // of 遍历码点
    freq[ch] = (freq[ch] || 0) + 1;
  }
  return freq;
}

console.log(freqBasic("abbccc"));
// { a: 1, b: 2, c: 3 }

2. emoji 安全版(代理对)

function freqEmoji(str) {
  const freq = new Map();
  // Array.from 按“码点”分割,不会拆代理对
  for (const ch of Array.from(str)) {
    freq.set(ch, (freq.get(ch) || 0) + 1);
  }
  return freq;
}

console.log(freqEmoji("👍👍❤️"));
// Map(2) { '👍' => 2, '❤️' => 1 }

3. 字素簇终极版(Segmenter)

function freqGrapheme(str) {
  const freq = new Map();
  const segmenter = new Intl.Segmenter("zh", { granularity: "grapheme" });
  for (const { segment } of segmenter.segment(str)) {
    freq.set(segment, (freq.get(segment) || 0) + 1);
  }
  return freq;
}

console.log(freqGrapheme("👨‍👩‍👧‍👦👨‍👩‍👧‍👦"));
// Map(1) { '👨‍👩‍👧‍👦' => 2 }

兼容性:Segmenter 2022 年已进 Chrome 103+、Edge、Safari 16+,Firefox 115+。
旧浏览器可降级为 grapheme-splitter

npm i grapheme-splitter
import GraphemeSplitter from "grapheme-splitter";
const splitter = new GraphemeSplitter();
function freqFallback(str) {
  const freq = new Map();
  for (const g of splitter.iterateGraphemes(str)) {
    freq.set(g, (freq.get(g) || 0) + 1);
  }
  return freq;
}

五、业务扩展:过滤 & 排序 & TopN

1. 忽略大小写 + 排除空白/标点

function freqAlpha(str) {
  const freq = new Map();
  for (const ch of Array.from(str)) {
    if (/\p{L}|\p{N}/u.test(ch)) {      // Unicode 属性转义
      const key = ch.toLowerCase();
      freq.set(key, (freq.get(key) || 0) + 1);
    }
  }
  return freq;
}

2. 按频率倒序并取 Top5

function topN(str, n = 5) {
  const freq = freqEmoji(str); // 任选上面实现
  return [...freq.entries()]
    .sort((a, b) => b[1] - a[1])
    .slice(0, n);
}

console.log(topN("mississippi", 3));
// [ [ 'i', 4 ], [ 's', 4 ], [ 'p', 2 ] ]

六、性能 Benchmark

测试字符串:5 MB 英文小说 + 1k 个 emoji
硬件:M1 Mac / Node 20

方案 ops/sec 内存峰值
for…of Object 1 220 000
Array.from Map 980 000
Intl.Segmenter 180 000
grapheme-splitter 240 000

结论:

  • 纯英文场景 for…of 遥遥领先
  • emoji 密集Array.from 是性能与兼容性最佳平衡
  • 字素簇需求优先考虑 Segmenter,其次 splitter

七、TypeScript 类型加持

type FreqMap = Map<string, number>;
type FreqObj = Record<string, number>;

function freqBasic(str: string): FreqObj {
  const freq: FreqObj = Object.create(null);
  for (const ch of str) {
    freq[ch] = (freq[ch] || 0) + 1;
  }
  return freq;
}

八、单元测试(Jest)

import { freqEmoji, topN } from "./freq";

describe("freqEmoji", () => {
  test("emoji", () => {
    const m = freqEmoji("👍👍❤️");
    expect(m.get("👍")).toBe(2);
    expect(m.get("❤️")).toBe(1);
  });
  test("empty", () => {
    expect(freqEmoji("")).toEqual(new Map());
  });
});

describe("topN", () => {
  test("sort", () => {
    expect(topN("aabbbc", 2)).toEqual([["b", 3], ["a", 2]]);
  });
});

九、常见坑汇总

现象 解决
str[i] 遍历 拆代理对 for…ofArray.from
组合字符 é 被算两次 字素簇分割
原型污染 __proto__ 被当键 Object.create(null)
大小写混淆 A ≠ a 统一 .toLowerCase()
正则遗漏 过滤不掉中文标点 \p{P} Unicode 属性

十、一句话总结

先确认“字符”定义,再选分割工具,最后 Hash 计数——
简单场景 for…of 一把梭,emoji 上来 Array.from,严谨排版请找 字素簇


附录:浏览器兼容速查

  • for…of:ES2015,全绿
  • Array.from:ES2015,IE11 需 polyfill
  • Intl.Segmenter:见 caniuse
  • grapheme-splitter:零依赖,兼容到 IE9

构建可维护的 React 应用:系统化思考 State 的分类与管理

作者 Dcc
2025年10月16日 22:14

构建可维护的 React 应用:系统化思考 State 的分类与管理

在 React 开发中,我们常说“状态是应用的命脉”。然而,对于如何组织和管理这些状态,许多开发者,尤其是新手,往往停留在“能用就行”的层面,习惯于将所有状态都塞进 useState 这个“万能”钩子中。这就像把所有的文件——无论是合同、照片、还是临时笔记——都杂乱地扔在同一个桌面上,短期内似乎方便,但随着项目复杂度的提升,寻找、修改和维护都会变得异常困难。

真正的解决方案来自于架构层的系统化思考:根据状态的来源、作用域和生命周期,对其进行清晰的分类,并为每一类选择最合适的管理工具。本文将状态系统地划分为四类:Server State、全局 Client State、本地组件 State 和 URL State,并深入探讨其管理策略。

为什么状态分类是架构的基石?

无差别的状态管理会导致:

  1. 组件过度耦合:状态散落在各处,难以追踪和调试。
  2. 性能瓶颈:不必要的重渲染,因为一个状态的更新可能会触发整个组件树的刷新。
  3. 数据不一致:特别是服务端状态,容易产生陈旧数据。
  4. 可测试性差:状态逻辑与 UI 耦合,难以进行单元测试。

通过分类,我们实现了 “关注点分离” ,让每种状态各司其职,从而构建出更清晰、更健壮、更易扩展的应用程序架构。


State 的四大分类与管理策略

1. Server State(服务端状态)

定义:从后端服务器获取的数据,如用户列表、商品信息、博文内容等。

特点

  • 所有权不属于前端,前端只是缓存和同步。
  • 可能存在多个副本(多个组件使用同一数据)。
  • 需要处理缓存、更新、失效、后台同步等复杂问题。
  • 需要处理加载和错误状态

错误示范:使用 useStateuseEffect 获取数据后,将数据保存在组件的本地状态中。这会导致:

  • 重复请求:多个组件需要同一数据时,会发起多个相同请求。
  • 陈旧数据:数据在其他地方更新后,当前组件无法感知。
  • 缺乏缓存:组件卸载后重新挂载,需要重新请求。

推荐管理工具React Query, SWR, Apollo Client

🌰(使用 React Query):

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

// 自动处理 loading, error, 缓存和数据更新
function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId], // 唯一的缓存键
    queryFn: () => fetchUser(userId), // 获取数据的函数
  });

  const queryClient = useQueryClient();
  const updateUserMutation = useMutation({
    mutationFn: updateUser,
    onSuccess: () => {
      // 更新成功后,使旧的缓存失效,触发重新获取
      queryClient.invalidateQueries(['user', userId]);
    },
  });

  if (isLoading) return 'Loading...';
  if (error) return 'An error occurred';

  return (
    <div>
      <h1>{user.name}</h1>
      <button onClick={() => updateUserMutation.mutate({ ...user, name: 'New Name' })}>
        Update Name
      </button>
    </div>
  );
}

优点

  • 自动化缓存:避免不必要的网络请求。
  • 后台自动更新:可以在窗口重新聚焦时重新请求数据。
  • 乐观更新:先更新 UI,再发送请求,提升用户体验。
  • 内置加载和错误状态
  • 数据共享:不同组件使用相同 queryKey 会共享同一份缓存数据。

2. 全局 Client State(全局客户端状态)

定义:在多个不相关的组件间需要共享的、由前端自身产生的状态。例如:用户认证信息、主题、全局通知、多步表单的共享数据、购物车。

特点

  • 作用域是整个应用或大部分模块
  • 状态更新需要能够触发多个组件的响应式更新

错误示范:使用 useState 提升到顶层并通过 props 层层传递(“Prop Drilling”)。这会导致组件耦合过紧,中间组件被迫传递它们不关心的数据。

推荐管理工具Zustand, Redux Toolkit, Context API

🌰(使用 Zustand):

// stores/useThemeStore.js
import { create } from 'zustand';

const useThemeStore = create((set) => ({
  theme: 'light',
  toggleTheme: () => set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}));

// ComponentA.js
function ComponentA() {
  const theme = useThemeStore((state) => state.theme);
  return <div>Current theme: {theme}</div>;
}

// ComponentB.js
function ComponentB() {
  const toggleTheme = useThemeStore((state) => state.toggleTheme);
  return <button onClick={toggleTheme}>Toggle Theme</button>;
}

优点

  • Zustand/Redux:状态与组件解耦,性能优化精细,支持中间件,开发工具强大。
  • Context API:React 原生,适合不频繁更新的简单全局状态(如 locale、主题)。对于频繁更新的复杂状态,需要手动优化以避免性能问题。

3. 本地组件 State(本地组件状态)

定义:完全属于单个组件或其直接子组件的临时状态。例如:一个输入框的值、一个下拉菜单的展开/收起状态、一个按钮的 loading 状态。

特点

  • 作用域严格限制在组件内部
  • 状态逻辑简单,生命周期与组件相同。

推荐管理工具useState, useReducer

🌰:

function LoginForm() {
  // 本地状态:输入框值和提交状态
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setIsSubmitting(true);
    // ... 提交逻辑
    setIsSubmitting(false);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input value={username} onChange={(e) => setUsername(e.target.value)} />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
}

优点

  • 简单直观:无需引入外部库,逻辑自包含。
  • 高内聚:状态和修改它的逻辑都封装在组件内部,易于理解和维护。

4. URL State(URL 状态)

定义:可以通过 URL 表示和共享的状态。例如:当前页面路由、查询参数、哈希值。

特点

  • 可分享:用户可以通过复制 URL 分享当前视图。
  • 可收藏:刷新页面后状态不丢失。
  • 与浏览器历史集成:支持前进/后退导航。

推荐管理工具React Router, Next.js Router 等路由库

🌰(使用 React Router):

import { useSearchParams, useParams } from 'react-router-dom';

function ProductList() {
  // 1. 管理查询参数:?category=books&sort=price
  const [searchParams, setSearchParams] = useSearchParams();
  const category = searchParams.get('category');
  const sort = searchParams.get('sort');

  const updateFilters = (newCategory, newSort) => {
    setSearchParams({ category: newCategory, sort: newSort });
  };

  // 2. 管理动态路由参数:/product/:productId
  const { productId } = useParams(); // 例如,URL 是 /product/123

  return (
    <div>
      <h1>Products in {category}</h1>
      <button onClick={() => updateFilters('electronics', 'name')}>
        Show Electronics
      </button>
      {/* 当 productId 存在时,显示产品详情 */}
      {productId && <ProductDetail id={productId} />}
    </div>
  );
}

优点

  • 状态持久化:刷新页面不丢失。
  • 极佳的用户体验:支持深度链接和浏览器导航。
  • 状态来源单一:URL 是许多 UI 状态的“唯一事实来源”。

总结与决策流程图

将状态正确地分类并选择相应的工具,是构建可扩展 React 应用架构的关键一步。

状态类型 特点 推荐工具 错误用法
Server State 来自后端,需缓存同步 React Query, SWR useState + useEffect
全局 Client State 跨组件共享 Zustand, Redux, Context Prop Drilling
本地组件 State 组件内部临时状态 useState, useReducer 过度使用全局状态
URL State 可分享、可收藏 React Router 用本地状态管理路由逻辑

当我们创建下一个状态时,可以先遵循以下决策流程思考一番:

  1. 这个状态是从服务器来的吗?
    • -> 考虑使用 React Query/SWR
  2. 这个状态需要在多个不相关的组件间共享吗?
    • -> 考虑使用 Zustand/Redux
  3. 这个状态是否定义了用户当前看到的界面(如页面、标签、筛选器),并且应该可以通过 URL 分享?
    • -> 考虑使用 URL State(路由库)
  4. 以上都不是?
    • -> 放心地使用 useStateuseReducer

通过这种系统化的思考方式,我们的代码库将不再是状态的“垃圾场”,而是一个条理清晰、职责分明、易于维护和扩展的现代化软件架构。

vue3中使用auto-import与cdn插件冲突问题

作者 jason_yang
2025年10月16日 18:34

背景

在开发vue3项目的时候,我们经常会用到unplugin-auto-import/vite来简化项目的import,同时为了提高加载的效率,我们会把固定的资源放在cdn加速访问。

在实际项目常用的是使用unplugin-auto-import/vite做自动导入,vite-plugin-cdn-import做cdn的引入

vite-plugin-cdn-import主要做两件事,

  • 在html把你插入一个script,地址是你配置的cdn的url。
  • 在通过别名映射,把编译时会把源码中的vue改成cdn的 window.Vue
  • 把cdn的资源排除在rollup打包信息里

问题

然后在实际应用中,我们在dev开发模式下,可以不import也能正式使用ref和onMounted,但是打包后就会无法正常使用

经过寻找,在vite-plugin-cdn-import 的 issues github.com/MMF-FE/vite… 找到这个解决方案,主要说的是插件执行顺序问题。

image.png

image.png

为了搞清楚这个问题,我们调试一下vite的配置

重现问题

新建一个vite项目并安装

pnpm i unplugin-auto-import vite-plugin-cdn-import -D

vite.config.ts

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import cdnImport from "vite-plugin-cdn-import";

const config = {
  base: `/`,
  plugins: [
    vue(),
    AutoImport({
      imports: ["vue"],
    }),
    cdnImport({
      modules: [
        {
          name: "vue",
          var: "Vue",
          path: "https://unpkg.com/vue@3.5.10/dist/vue.global.prod.js",
        },
      ],
    })
  ]
}; 
export default defineConfig(config)

src/components/HelloWorld.vue 文件

<script setup lang="ts">
// import { ref ,onMounted } from 'vue'
defineProps<{ msg: string }>()
onMounted(() => {
 count.value = 1000
 console.log('onMounted called')
})
const count = ref(0)
</script>

<template>
  <h1>{{ msg }}</h1>
  <div class="card">
    <button type="button" @click="count++">count is {{ count }}</button>
  </div>
</template>

image.png

验证开发模式

pnpm dev 

image.png 一切正常:自动把count 设置成1000 ,控制台输出onMounted called

验证打包模式

pnpm build
npx serve -s dist

打包自动在 index.html 输出 <script src="https://unpkg.com/vue@3.5.10/dist/vue.global.prod.js" crossorigin="anonymous"></script> image.png

并且文件大小只有12k 没有vue原文件信息

image.png

运行结果 image.png

但是运行结果不理想,count 没有设置成1000 ,控制台也没有输出onMounted called

打印配置

从issue上看说是plugin的enforce属性影响了。那我们来打印看看,我们在返回config的时候console一下

image.png

image.png

这是虽然看到plugins 是3个对象但是 第三个居然是数组,也就是vite插件支持对象或数组,没关系,我们提取出来再打印

    cdnImport({
      modules: [
        {
          name: "vue",
          var: "Vue",
          path: "https://unpkg.com/vue@3.5.10/dist/vue.global.prod.js",
        },
      ],
    })[0]

image.png

再次运行打印

image.png

其实不用打印,使用插件一样可以显示enforce的信息

pnpm i -D vite-plugin-inspect

加入inspect到plugins

/* eslint-disable */
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import externalGlobals from 'rollup-plugin-external-globals'
import cdnImport from "vite-plugin-cdn-import";
import inspect from 'vite-plugin-inspect'

const config = {
  base: `/`,
  plugins: [
    vue(),
    AutoImport({
      imports: ["vue"],
    }),
    cdnImport({
      modules: [
        {
          name: "vue",
          var: "Vue",
          path: "https://unpkg.com/vue@3.5.10/dist/vue.global.prod.js",
        },
      ],
    })[0] 
    , // 注意这里 他返回的是数组(vite数组一个都支持)所以要取第一个才好打印,需要访问第一个元素,
   inspect({}),
  ]
};

console.log(config)
export default defineConfig(config)

运行dev后,会多一个 http://localhost:5173/__inspect/

image.png

image.png

分析问题

好家伙 unplugin-auto-import 是post ,但vite-plugin-cdn-import 居然是 pre 。那会发生什么事情?

我们重新梳理一下 正常直觉我们都以为 plugin会按数组顺序依次执行

image.png

然而根据vite的规则,会在执行时先按enforce来排序, pre优先,post 最后,不设置则在中间,

最终执行会变成如下图

image.png

修复问题

根据issue的指引,我们只需要确保auto-import先执行就行了,所以调整cdn-import 的enforce为 post,当两个都是post的时候vite就会按数组顺序执行

image.png

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import externalGlobals from 'rollup-plugin-external-globals'
import cdnImport from "vite-plugin-cdn-import";
import inspect from 'vite-plugin-inspect'

const config = {
  base: `/`,
  plugins: [
    vue(),
    AutoImport({
      imports: ["vue"],
    }),
    {
      ...cdnImport({
      modules: [
        {
          name: "vue",
          var: "Vue",
          path: "https://unpkg.com/vue@3.5.10/dist/vue.global.prod.js",
        },
      ],
    })[0], 
    enforce: 'post' // 覆盖原来的enforce值
    }, 
   inspect({}),
  ]
};

console.log(config)
export default defineConfig(config)

我们通过解构重新给 cdnImport 设置 enforce: 'post'

image.png 结果报了另一一个错误,看来还是有兼容bug,不折腾找找别的方案。

换个库试试吧

其实cdn-import 无非就是插html点js 和 把代码中的vue改成映射后window.Vue,并且把cdn资源排除在roll打包信息里。 那我们用其他库也能实现,在上面issue有另外一个大神给出其他方案

image.png

在这位大侠的源码 确实找打了解决方案 ,就是用rollup-plugin-external-globals来处理cdn 的命名,并且自己在html页面输出对应的script的标签

vite.config.js

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import externalGlobals from 'rollup-plugin-external-globals'
 

const config = {
  base: `/`,
  plugins: [ 
    vue(),
    {
      ...AutoImport({
      imports: ["vue"],
    })
    }, 
      {
      ...externalGlobals({
        vue: 'Vue', 
      }),
      enforce: 'post', // 注意这里也要加上post ,不然上面AutoImport 又会后执行
    },
  ], 
    build: { 
    rollupOptions: {   
      external: ['vue'], // 告诉 Rollup 不要将 'vue' 打包进输出文件
      plugins: [
        externalGlobals({
          vue: 'Vue',              // import vue → window.Vue 
        })
      ], 
    }
  }
};

console.log(config)
export default defineConfig(config)

注意 externalGlobals 也是要加 post 不然上面AutoImport 又会后执行 image.png

告诉rollup 不要把vue打进来 image.png

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue + TS</title>
     <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>

在html 加入cdn,当然也可以改成动态配置,输出变量循环 image.png

最终运行 image.png nice

image.png 并且资源请求也符合预期,项目js 1.6k,vue.js 是另外下载

进一步分析

我们改造一下配置,让所有build的代码正常输出,通过对比两次差异

import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import externalGlobals from 'rollup-plugin-external-globals'
import cdnImport from "vite-plugin-cdn-import";
import inspect from 'vite-plugin-inspect'

const config = {
  base: `/`,
  plugins: [
    vue(),
    AutoImport({
      imports: ["vue"],
    }),
   {
      ...cdnImport({
      modules: [
        {
          name: "vue",
          var: "Vue",
          path: "https://unpkg.com/vue@3.5.10/dist/vue.global.prod.js",
        },
      ],
    }), 
    enforce: 'post'
    }, // 注意这里 他返回的是数组(vite数组一个都支持)所以要取第一个才好打印,需要访问第一个元素,
   inspect({}),
  ],
  build: {
    minify: false, // 禁用代码压缩混淆
    sourcemap: true, // 生成 sourcemap 便于调试
    rollupOptions: {  
      treeshake: false, // 禁用 tree shaking
      output: {
        compact: false, // 不压缩代码
      },
    }
  } 
};

console.log(config)
export default defineConfig(config)

左边是有问题的, 右边是正常的

image.png

image.png 在生成的代码里,我们看到主要差异就是,

左边 错误的

居然还保留了import { onMounted, ref } from "vue"; 并且使用onMounted 和 ref 是直接使用,所以证明了auto-import后置执行了,

右边 正确的

使用vue的库是通过 Vue.onMounted访问,并且没有 import 的代码。

扩展

其实还有一种解决方案,如果你的项目域名已经是cdn指向的前提下。可以使用vite的rollupOptions 的manualChunks,单独把vue资源报分离出来。由于vue的版本如果没有变化,每次hash值是一样的,这样生成的vue.js文件每次都是一样的,也能达到cdn加速效果。但是就不太适合分发给其他项目。

当然本地也要先安装vue

pnpm i vue -D
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import AutoImport from "unplugin-auto-import/vite";
import cdnImport from "vite-plugin-cdn-import";

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    // AutoImport({
    //   imports: ["vue"],
    // }),

    cdnImport({
      modules: [
        {
          name: "vue",
          var: "Vue",
          path: "https://unpkg.com/vue@3.5.10/dist/vue.global.prod.js",
        },
      ],
    }),
  ],
  base: `/`,
  build: {
    rollupOptions: {
      output: {
        manualChunks(id) {
          if (id.includes("node_modules")) {
            // 将大的库单独打包 
            if (id.includes("element-plus")) return "vendor-element";

            // 其他第三方库按类别分组
            if (id.includes("vue")) return "vendor-vue"; 
            console.log(id)
            return "vendor";
          }
        },
      },
    },
  },
});

参考代码

github.com/mjsong07/vu…

❌
❌