阅读视图

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

uni-app项目Tabbar实现切换icon动效

前情

不知道该说是公司对产品要求稿,还是公司喜欢作,最近接手公司的一个全新跨端(快抖微支+app)的项目,项目还没有上线就已经改了4版了,改改UI,换换皮也就算了,关键是流程也是在一直修改,最近就接到上层说这小程序UI太丑了,要重新出UI,说现在的UI只能打60分,希望UI能多玩出点花样来,于是就接到现在的需求了,UI希望实现展形Tabbar,根据不同页面主题色需要切换Tabbar的背景,还希望默认高亮选中的是Tabbar项的中间项,而且还希望实现一些icon切换动效,里面任何一条想实现都只能靠自定义 Tabbar来做了

心里虽有排斥,排斥的是修改太频率了,每次都是刚刚调通又来大调整,心态上多少有点浮动,但这就是工作吧,互相配合才能打造老板喜欢的好的产品,用户喜欢不喜欢只能靠市场验证了,我也就偷偷骂了二句娘就接着开发这一个需求了

自定义Tabbar

异常Tabbar,对于我来说不是什么大问题,因为我已经在插件市场分享了一个自定义Tabbar的组件,就是为了能快速应对这种需求,我发布在插件市场的组件地址是:ext.dcloud.net.cn/plugin?id=2…

我实现异形组件的关键代码如下

这是Tabbar的配置数据:

...
tabbar: {
  color: '#8D8E91',
  selectedColor: '#000000',
  borderStyle: 'white',
  backgroundColor: 'transparent',
  tabbarHeight: 198,
  holderHeight: 198,
  iconStyle: { width: '44rpx', height: '44rpx' },
  activeIconStyle: { width: '44rpx', height: '44rpx' },
  textStyle: { fontSize: '24rpx' },
  activeTextStyle: { fontSize: '24rpx' },
  list: [
    {
      pagePath: '/pages/discover/discover',
      iconPath: '/static/tabbarnew/fx.png',
      selectedIconPath: '/static/tabbarnew/fxactive.png',
      text: '发现',
      key: 'discover',
    },
    {
      pagePath: '/pages/games/games',
      iconPath: '/static/tabbarnew/yx.png',
      selectedIconPath: '/static/tabbarnew/yxactive.png',
      text: '游戏',
      key: 'games',
    },
    {
      pagePath: '/pages/index/index',
      iconPath: 'https://cdn.dianbayun.com/static/tabs/xwz.gif',
      selectedIconPath: 'https://cdn.dianbayun.com/static/tabs/xwzactive.gif',
      text: '新物种',
      key: 'index',
    },
    {
      pagePath: '/pages/product/product',
      iconPath: '/static/tabbarnew/sc.png',
      selectedIconPath: '/static/tabbarnew/scactive.png',
      text: '商城',
      key: 'product',
    },
    {
      pagePath: '/pages/my/my',
      iconPath: '/static/tabbarnew/wd.png',
      selectedIconPath: '/static/tabbarnew/wdactive.png',
      text: '我的',
      key: 'my',
    },
  ],
}
...

下面是导航栏组件的关键结构和一些为了实现icon切换动效的css:

<!-- CustomTabBar 组件关键代码 -->
<hbxw-tabbar 
    :config="globalInstance.tabbar" 
    :active-key="activeKey" 
    :tabbar-style="{ backgroundImage: bgType === 'black' ? 'url('黑色背景')' : 'url('白色背景')', backgroundSize: '100% auto' }"
>
    <template #default="{ item, isActive, color, selectColor, iconStyleIn, activeIconStyleIn, textStyleIn, activeTextStyleIn }">
      <view
        class="w-full flex flex-col items-center justify-center h-[134rpx] relative"
        v-if="item.key !== 'index'"
      >
        <view class="w-[44rpx] h-[44rpx] relative" :class="{'active': isActive}">
          <image
            class="w-[44rpx] h-[44rpx] absolute top-0 left-0 normal-img"
            :src="item.iconPath"
            :style="iconStyleIn"
          />
          <image
            class="w-[44rpx] h-[44rpx] absolute top-0 left-0 active-img"
            :src="item.selectedIconPath"
            :style="activeIconStyleIn"
          />
        </view>
        <text
          class="text-[24rpx]"
          :style="{ color: !isActive ? color : selectColor, ...(isActive ? activeTextStyleIn : textStyleIn) }"
        >
          {{ item.text }}
        </text>
      </view>
      <view
        class="w-full flex flex-col items-center justify-center h-[134rpx] relative"
        v-else
      >
        <view class="w-[103rpx] h-[103rpx] relative" :class="{'active': isActive}">
          <image
            class="w-[103rpx] h-[103rpx] absolute top-0 left-0 normal-img"
            :src="item.iconPath"
          />
          <image
            class="w-[103rpx] h-[103rpx] absolute top-0 left-0 active-img"
            :src="item.selectedIconPath"
          />
        </view>
      </view>
    </template>
  </hbxw-tabbar>
</template>

// 这个是为了实现icon动添加的css
<style lang="scss" scoped>
  @keyframes normalimg {
    0% {
      opacity: 1;
      transform: scale(1);
    }
    100% {
      opacity: 0;
      transform: scale(.3);
    }
  }
  @keyframes activeimg {
    0% {
      opacity: 0;
      transform: scale(.3);
    }
    100% {
      opacity: 1;
      transform: scale(1);
    }
  }
    .active-img{
    opacity: 0;
    transform: scale(.3);
  }
  .normal-img{
    opacity: 1;
    transform: scale(1);
  }
  .active {
    .normal-img{
      animation: normalimg 0.4s ease-in-out 0s forwards;
    }
    .active-img{
      animation: activeimg 0.4s ease-in-out 0.4s forwards;
    }
  }   
</style>

注:当前项目我使用了Tainwind CSS原子化CSS框架来书写样式

在页面上使用的代码如下:

<!-- 这是首页的页面Tabbar 高亮index项,同时背景用黑色 -->
<CustomTabBar activeKey="index" bgType="black" />

其实原理很简单,因为我的发布在应用市场的组件有提供 slot,你可以自由定义Tabbar的每一项的结构样式,我这里的做法就是中间项单独一块结构来实现异形效果,实现后的效果如下:

image.png

坑位?

展开tabbar效果是好实现的,但是在实现Tabbar切换icon动效的时候,我遇到了麻烦,小程序虽然有提供专门用于做动画的API,但是我个人不太喜欢用,我比较喜欢使用css3实现动画,使用上更简单的同时,动画流畅度也优于JS来做

因为是切换动效,首先想到的就是通过transition来实现,通过给父组添加一个active的类名控制下面icon的来实现切换动效,这是实现状态变化动效的首选,但是发现完全没有用,一度怀疑是不是小程序不支持transition,于是想到换方案,我通过aniamtion来实现动效,确实是有效果的,但是只有首次切换tabbar的时候有效果

Why?

我一开始是怀疑是不是小程序对于css3动画有兼容性问题,或者是支付宝不支持动效,因为我此时正在开发的就是支付宝端,也去小程序论坛逛了逛 ,确实有一些帖子说到transition在小程序上兼容问题,也问了AI,AI也说是有,而且不现标签组件可能支持的transition还不一样,此时我陷入了怀疑,难道真的要靠JS来实现么,但是以我的个人开发经验,我不止在一个小程序项目中使用过css3来实现动效,都是没有问题的,在经过一段时间的思考我,我突然意识到一个问题,动画没出现真的不是兼容性的问题,而是没有动效或者只有首次有这根本就是正常现象

transition没有是因为当你切换tabbar的时候整个组件是重新渲染的,对于初次渲染的元素你是没法使用transition的,至于为什么后面点也都没有,是我在尝试 animation的时候发现它只有首次点击切换的时候才有我才突然意识到,因为这是tabbar啊,小程序是有会缓存的,你显示一次后,小程序页面会运行在后台,你再次切换的时候只是激活而已,根本不会有样式的变化

解决方案

既然Tabbar切换页在不会重新从0渲染,只是显示与隐藏而已,那我们就手动的让它来实现Tabbar的高亮样式切换即可,虽然Tabbar切换页面不会重新渲染,但是它会触发二个小程序的生命钩子onShow/onHide,那我们就从这二处着手,因为是多个页面要复用,我此处抽了hooks,关键代码如下:

import { onShow, onLoad, onHide } from '@dcloudio/uni-app'
import { ref } from 'vue'

export const usePageTabbar = (type) => {
  const activeType = ref('')
  onLoad(() => {
    uni.hideTabBar()
    activeType.value = type
  })

  onShow(() => {
    activeType.value = type
    uni.hideTabBar()
  })

  onHide(() => {
    activeType.value = ''
    uni.showTabBar()
  })

  return {
    activeType
  }
}

页面上使用也做了调整,关键代码如下:

<script setup>
    ...
    import { usePageTabbar } from '@/hooks/pagesTabbar'

    const { activeType } = usePageTabbar('index')
    ...
</script>

<template>
        ...
        <!-- 页面tabbar -->
    <CustomTabBar :activeKey="activeType" />
    ...
</template>

至此完成了这一次的 tabbar大改造,实现的效果如下:

20250906_201121.gif

其实此时再切换回用transition去做动画,这也是可以的,只是我后面已经用 animaltion实现了就懒得改它了

思考

对于做开么的我们,平时抽取一些可以复用的组件并分享真的是值得做的一件事,它可以在很多时候帮你提高开发速度,同时也减少了你反复的写一些重复代码

对于需求调整这是很多开发都不喜欢的事,因为当项目需求调整的过多,原来已经快接近屎山的代码更加加还变成屎山,但是这个对于一些小公司开发流程不是特别规范的需求调整是不可避免的,我们无需过多烦恼,只要项目进度允许,他们要调就让他调吧,相信大家都是为了打造一款精品应用在使劲而已,何乐而不为了

个人的能力和认识都有限,对于一个问题的解决方案有很多种,我上面的实现方案并不一定最好的,如果你有更好的解决方案,欢迎不吝分享,一起学习一起进步

下周逾966亿元市值限售股解禁

下周(9月8日至12日)共有40家公司限售股陆续解禁,合计解禁40.8亿股,按9月5日收盘价计算,解禁总市值为966.01亿元。其中,时代电气限售股解禁市值达278.23亿元,南网储能限售股解禁市值达230.81亿元。(财联社)

非洲航空峰会在卢旺达举行 “中国智造”成亮点

2025年非洲航空峰会于9月4日至5日在卢旺达首都基加利举行,与会嘉宾围绕非洲航空业面临的困境与科技带来的新机遇展开深入探讨。峰会期间,“中国智造”重磅亮相。中国首款获中国民航局“适航三证”认证的无人驾驶载人电动垂直起降飞行器,成功完成非洲首飞,成为本次峰会的最大亮点。(央视新闻)

九三阅兵:4款新鹰击导弹“反舰天团”盘点解读

谁也没想到九三阅兵,咱家一下子亮出4款从没露过面的鹰击反舰导弹:鹰击-15、17、19、20,三款高超音速导弹,一款准高超音速导弹。而且他们的技术、功能和用途都各不相同,能从天上、海面、水下一起招呼敌方舰艇,包括航母。

下载虎嗅APP,第一时间获取深度独到的商业科技资讯,连接更多创新人群与线下活动

近7万人爆仓,比特币交易额锐减近72%

比特币、以太坊等虚拟货币价格突然下跌, 成交额更是几乎全线重挫。 截至9月7日7:30,过去24小时内,比特币向下逼近11万美元/枚,成交额跌近72%,XRP成交量跌近70%,以太坊、SOL跌超60%。Coinglass数据显示,过去24小时加密货币市场共有6.7万人被爆仓,爆仓总金额为1.18亿美元。

GPT-4o 见 AV 女优的次数比「您好」还多 2.6 倍,AI 正在被中文互联网疯狂污染?

好家伙,我直呼好家伙。

号称「赛博白月光」的 GPT-4o,在它的知识体系里,对日本女优「波多野结衣」的熟悉程度,竟然比中文日常问候语「您好」还要高出 2.6 倍。

这可不是我瞎编的。一篇来自清华、蚂蚁和南洋理工的最新研究直接揭了老底:我们天天在用的大语言模型,有一个算一个,都存在不同程度的数据污染。

▲ 论文:从模型 Token 列表推测大语言模型的中文训练数据污染(🔗 https://arxiv.org/abs/2508.17771)

论文中把这些污染数据定义为 「污染中文词元」(Polluted Chinese Tokens,简称 PoC Tokens)。它们大多指向色情、网络赌博等灰色地带,像病毒一样寄生在 AI 的词汇库深处。

这些中文污染词元的存在,不仅对 AI 来说是一种隐患,更是直接影响到我们的日常体验,被迫接受 AI 各种各样的胡言乱语。

▲ 要求 ChatGPT 重复「给主人留下些什么吧」,ChatGPT 根本不知道在回答什么。

中文互联网的色情赌博信息,怎么「污染」AI

我们可能都曾遇到过这样的情况:

  • 想让 ChatGPT 推荐几部经典电影、相关的论文等,它突然回了一堆奇怪的乱码网站名、打不开的链接、或者根本不存在的论文。
  • 输入一个看似普通的词语,比如「大神推荐」之类的,它有时候却吐出不相关的符号,甚至生成一些让人摸不着头脑的句子。

研究团队的解释是:这背后很可能就是 污染词元在作怪

我们都知道大语言模型的训练需要大量的语料,这些海量数据大多是从网络上进行爬取收集。

但 AI 注意不到的是,它阅读的网页中,竟然充斥着无数「性感荷官,在线发牌」的弹窗广告和「点击就送屠龙宝刀」的垃圾链接。久而久之,这些内容也成了它知识体系的一部分,并变得混乱。

就跟前段时间 DeepSeek 闹出的几起乌龙事件一样,先是莫名其妙的一封道歉信,然后再自己编造一个 R2 的发布日期。这些没有营养的营销内容,一旦被模型吸收,就很容易出现幻觉。

如果说,DeepSeek 出现这些幻觉,需要我们去引导模型;但「污染词元」,甚至不需要引导,AI 自己就乱了套。

什么是「污染词元」,它遵循「3U 原则」:即从主流中文语言学的角度看,这些词元是不受欢迎的(Undesirable)、不常见的(Uncommon),或是无用的(Useless)

目前主要包括成人内容、在线赌博、在线游戏(特指私服等灰色服务)、在线视频(常与盗版和色情内容关联)以及其他难以归类的异常内容。

▲ 大语言模型分词过程

那「词元」又是什么东西?和我们理解一段话不同,AI 会把一个句子分成多个「词元」,也叫 Token。你可以把它想象成 AI 专属的一本《新华字典》,而词元(Token)就是这本字典里的一个个「词条」

AI 在理解我们说的话时,一开始就需要先去翻这本字典。而字典的编纂者,是一种叫 BPE(字节对编码技术) 的分词算法。它判断一个词组,是否有资格被收录为独立词条的唯一标准,就是出现频率

这意味着这个词组越常见,就越有资格成为一个独立词元。

你或许能理解,这两年大语言模型流量正攀升的时候,豆包和稀土掘金曾经像是「疯了」一样,把自己平台 AI 生成的大量内容放到互联网上,提高自己的出现频率。以至于那段时间,用谷歌搜索,还有 AI 总结,引用的来源都是豆包和掘金。

现在,我们再来看研究人员的发现。他们通过 OpenAI 官方开源的 tiktoken 库,获取了 GPT-4o 的词汇库,结果发现,里面塞满了大量的污染词条。

▲ 长中文词元,全是需要打码的内容。

超过 23% 的长中文词元(即包含两个以上汉字的词元)都与色情或网络赌博有关。这些词元不仅仅是「波*野结衣」,还包括了大量普通人一眼就能认出的灰色词汇,例如:

在线赌博类:「大*快三」、「菲律宾申*」、「天天中*票」。在线游戏(私服)类:「传奇*服」。隐蔽的成人内容类:除了名人,还有像「青*草」这样表面正常,实则指向色情软件的词汇。

这些词元,因为在训练数据中出现频率极高,被算法自动识别并固化为模型的基本构成单位。

AI 吃了垃圾食品但不能消化

按理说,既然这些污染词元,它们的语料库是如此丰富,应该也能正常训练。

怎么就现在只要一跟 ChatGPT 聊到这些污染词元,ChatGPT 就 100% 出现幻觉呢?

像是下面我们测试的这个例子,要 ChatGPT 5 翻译这句话,它完全没有办法正确理解,这个北京赛车群也是无中生有。

其实不难理解,回到我们之前提到的「词元 Token」,我们说 AI 从互联网上读取数万亿词元的海量数据,一些集中、且反复地一起出现(频率高)的词语就能成为一个单独的词元。

AI 通过这些词元,来建立对文本理解的基础。它知道了这些 Token 是出现频繁、有可能相关,但不知道它们是什么意思。继续拿字典举例子,这些高频污染词在字典里,但是字典给不出解释。

因为 AI 在这个阶段,学到的只是一种原始的、强烈的「肌肉记忆」,它记住了 A 词元总是和 B 词元、C 词元一起登场,在它们之间建立了紧密的统计关联。

等到正式的训练阶段,大部分 AI 都会经过 清洗 + 对齐(alignment)。这时,污染内容往往被过滤掉,或者被安全策略压制,不会进入强化学习/微调。

不良内容的过滤,就导致了污染词元没有机会被正式、正确地训练。它们因此成了「欠训练」(under-trained)的词元。

另一方面,这些词元虽然「高频」,但它们大多出现在语境单一、重复的垃圾信息中(例如一些广告网页头尾横幅),模型根本学习不到任何有意义的「语义网络」。

最终的结果就是,当我们输入一个污染词元时,AI 的语义模块是空白的,因为它在正式训练阶段没学过这个词。于是,它只能求助于第一阶段学到的「肌肉记忆」,直接输出与之关联的其他污染词元。

▲ 论文中案例:当输入涉及 PoC 词语时,GPT-4.5、4.1 和 4o 的输出。GPT 无法解释或重复 PoC 标记。

这就解释了开头,当被要求一个可能是色情的词元「给主人留下些什么吧」时,GPT 可能会回复一个不相关的类似污染内容词元「黑*战」、以及一些看不懂的符号。在用户看来,这就是莫名其妙的幻觉。

以及下面这个要求 ChatGPT 解释「大发展有限公司官网」,回复的内容根本是乱来。

总结一下,污染 Token 出现频繁 ≠ 有效学习。它们集中在脏网页的角落、缺乏正常上下文,而在后续训练和对齐阶段又被压制,结果就是 词表固化了垃圾,但语义训练缺失

这也导致了我们日常在使用 AI 的时候,如果意外有涉及到相关的词语,AI 会没有办法正确处理,甚至还有人通过这种方法,绕过了 AI 的安全监管机制。

这是可以被量化的幻觉原因

既然如此,为什么不在预训练的时候就把这些脏东西筛掉呢?

道理都懂,但做起来太难了。互联网的原始数据量级之大,现有的清理技术根本不可能把它们一网打尽。

而且很多污染内容非常隐蔽。就像「青*草」这个词,本身看起来完全绿色健康小清新,任何简单的关键词过滤系统都会放过它。只有通过搜索引擎,才会发现它指向的是什么。

连 Google 这种搜索引擎巨头都搞不定这些「内容农场」,更别说 OpenAI 了。

我前段时间想用 AI 整理一下广州有哪些好玩的地方,然后发现 AI 引用的一篇文章来源,是另一个 AI 账号生成的文章。

一时间,我都有点分不清,究竟是我们每天搜索「波多野结衣」搞脏了 AI,还是 AI 生成的垃圾正在污染我们的内容环境。这简直就是个先有鸡还是先有蛋的问题。

▲ 标记方法

为了搞清楚这盆水到底有多浑,研究团队开发了两个工具:

1. POCDETECT:一个 AI 污染检测工具。它不只看字面意思,还会自己上网 Google,分析上下文,堪称 AI 界的「鉴黄师」。

利用这个工具,研究团队对 9 个系列、共 23 个主流 LLM 进行了检测,结果发现污染问题普遍存在,但程度各不相同。除了 GPT 系列以 46.6% 的长中文词元污染率遥遥领先外,其他模型的表现如下:

▲ 不同大语言模型中,中文词汇表中 PoC 词元的数量(比例 %)(一个词元包含超过两个汉字)。Qwen 系列 为 1.00%。GLM4 和 DeepSeek-V3 的表现则相当不错,分别只有 0.25% 和 0.17%。

最值得关注的是,GPT-4、GPT-4-turbo 和 GPT-3.5 这些模型的词汇库中,污染词元数量为 0。这可能意味着它们的训练语料经过了更彻底的清理。

所以当我们拿着前面那些,让 ChatGPT 开启了胡编乱造模式的问题,给这些模型再问一遍时,确实没再出现幻觉,但是直接忽略了。

2. POCTRACE:一个能通过词元 ID 反推其出现频率的工具。原理很简单,在分词算法里,词元的 ID 号越靠前,说明它在训练数据里出现得越多。

关于文章开头我们提到的 2.6 倍,就是通过这个工具进行计算得到的。

在 GPT 的海量词汇库中,能够被完整收录为一个独立词元的人名凤毛麟角,除了「特朗普」(Donald Trump)这样的世界级公众人物,就剩下极少数特例,而「波*野结衣」就是其中之一。

更令人惊讶的是,不仅是全名,甚至连它的子序列,如「野结衣」、「野结」也都被单独做成了词元。这在语言学上是一个极强的信号,表明这个词组在训练数据中的出现频率达到了一个恐怖的量级。

▲ 将与「波*野结衣」相关的网页以及作者估计的比例(0.5%)混合,可以重现 GPT-4o 中「波*野结衣」的标记 ID 及其子序列。

他们输入「波*野结衣」(Token ID 185,946)和「您好」(Token ID 188,633)的 ID 号,最终得出了那个惊人的结论,前者的频率估算值约为后者的 2.6 倍

这篇论文通讯作者,清华教授邱寒教授告诉 APPSO,与「波*野结衣」相关的中文网页,占据了整个 pre-train 语料库的 0.5%——而 4o 里的中文语料占比,预估在 3-5%。因此,4o 的 pre-train 语料库的中文污染情况,实际上可能极其夸张。

论文里进一步推算出,要想达到这样的频率,与「波多野结衣」相关的污染网页,可能需要占据了 GPT-4o 整个中文训练数据集约 0.5% 的庞大份额

为了验证,他们真的按这个比例「投毒」了一个干净的数据集,结果生成的词元 ID 和  GPT-4o 的惊人地接近。
这几乎是实锤了。

但很显然不是每个污染词源都需要出现这么多次,有些时候,几篇文章(甚至可能是 AI 写的),反反复复地提到,AI 就记住了,然后再下次我们问他的时候,给出一个根本不知道真假的答案。


添加一个对抗样本,AI 能把雪山识别成一只狗

当我们和 AI ,都在「垃圾堆」里冲浪

为了应对数据污染,大家也确实都想了很多办法。

财新网就很聪明,在自己的文章页面里用代码「偷偷」藏了一句话,好让 AI 在搬运内容时,能老老实实保留原文链接。Reddit、Quora 等社区也曾尝试限制 AI 内容。

但面对数据污染的汪洋大海,这些行为显然都只是螳臂当车。

就连奥特曼自己都发文感慨,X(推特)上的 AI 账号泛滥成灾,我们得认真思考「互联网已死」这种论调了。

而我们这些普通用户,看起来更是别无他法,每天被迫接受着垃圾信息的轮番攻击。马斯克老说 AI 是个无所不知的「博士」,没想到它背地里天天都在「垃圾堆」里翻东西吃。

有人说,这是中文语料库的问题,用英文 Prompt 模型就会变聪明。Medium 上有作者统计过统计了每种语言的 100 个最长 token,中文全是我们今天聊的这些色情、赌博网站。

而英文的分词和中文不同,它只能统计单词,所以都是一些较长的专业性、技术类单词;日文和韩文都是礼貌性、商业服务类词语。

▲ 中文 Token 前 100 部分词元列表

这十分令人感慨。AI 的能力,除了靠算力和模型堆砌,更深层次的,还是它吃进去的数据。如果喂给 AI 的是垃圾,那无论它的算力多强、记忆力多好,最终也只会变成一个「会说人话的垃圾桶」。

我们总说,希望 AI 越来越像人类。现在看来,某种程度上确实是实现了:我们把互联网这个大垃圾场里的东西源源不断投喂给它,它也开始原封不动地回敬给我们。

如果我们给一个 AI 造一个信息茧房,让它在「无菌环境」中长大,它的智能也是脆弱的、经不起考验的。一个孩子如果只被允许接触教科书里的经典课文,他永远无法应对生活里五花八门的口语和俚语。

说到底,当 AI 对「波多野结衣」比对「您好」更熟悉时,它不是在堕落,而是提醒了我们:它的智能,依然只是统计学上的概率,而非文明意义上的认知。

这些污染词元就像一面放大镜,它将 AI 在语义理解上的缺失,以一种荒诞方式呈现在我们面前。AI 离「像人一样思考」,还差着最关键的一步。

所以,我们真正应该害怕的,不是 AI 被污染,而是害怕在 AI 这面过于清晰的镜子里,看到了我们自己创造的、却又不愿承认的那个肮脏的数字倒影。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


招商证券:中报后业绩上修集中在医药、TMT和中高端制造等

招商证券发布研报称,上市公司中报业绩披露前后,盈利上修的个股主要集中在医药(化学制剂、医疗研发外包、其他生物制品、原料药)、TMT(数字芯片设计、IT服务Ⅲ、垂直应用软件、通信网络设备及器件、游戏Ⅲ、印制电路板、模拟芯片设计)、中高端制造(底盘与发动机系统、其他专用设备、能源及重型设备、航空装备Ⅲ、锂电池),以及证券、铜、农药、其他化学制品、火电等。(财联社)

科大讯飞与昭通市人民政府签署战略合作协议

据科大讯飞集团消息,9月6日,科大讯飞股份有限公司与云南昭通市人民政府战略合作协议签约仪式在科大讯飞合肥总部举行。下一步,双方将加速推进务实合作,以此次协议签订为重要契机,在数字经济产业园及数据标注基地建设、人工智能应用等领域务实开展深度合作。(界面新闻)

备忘录模式(Memento Pattern)详解

前一篇文章解锁时光机用到了备忘录模式,那么什么是备忘录模式?

备忘录模式是一种行为型设计模式,它的核心思想是在不暴露对象内部细节的情况下,捕获并保存一个对象的内部状态,以便在将来可以恢复到这个状态

这个模式就像一个“时光机”,能够让你在程序运行时记录下某个时刻的状态,并在需要时“穿越”回去。

核心角色

备忘录模式通常包含三个主要角色:

  1. 发起人(Originator)

    • 这是需要被保存状态的对象。
    • 它负责创建一个备忘录(Memento),来保存自己的当前状态。
    • 它也能够使用备忘录来恢复到之前的状态。
    • 在我们的 React 例子中,reducer 函数和其中的 state 对象就是发起人。它能创建和恢复状态。
  2. 备忘录(Memento)

    • 这是用于存储发起人内部状态的快照对象。
    • 它提供一个受限的接口,只允许发起人访问其内部状态。外部对象(比如 caretaker)无法直接修改备忘录的内容,只能将其作为“黑盒子”传递。
    • 在我们的 React 例子中,past 和 future 数组中的每一个 present 值,就是一个备忘录。它是一个简单的数值,不需要复杂的对象来封装。
  3. 管理者(Caretaker)

    • 负责保存和管理备忘录。
    • 它不知道备忘录内部的具体细节,只知道备忘录是从发起人那里来的,并能在需要时将它还给发起人。
    • 它不能对备忘录的内容进行任何操作,只能存储和检索。
    • 在我们的 React 例子中,state 对象中的 past 和 future 数组就是管理者reducer 函数负责将备忘录(即状态值)放入或取出这些数组。

工作流程

  1. 保存状态:发起人(Originator)在需要时,创建一个备忘录(Memento),将自己的当前状态保存进去。然后将这个备忘录交给管理者(Caretaker)。
  2. 恢复状态:当需要恢复时,管理者(Caretaker)将之前保存的备忘录交给发起人(Originator)。发起人通过备忘录中的信息,将自己的状态恢复到之前的样子。

备忘录模式与代码示例

现在,让我们把这些角色对应到代码中,一切就变得清晰了:

  • 发起人(Originator)state 对象中的 present 值。它代表了当前的核心状态。
  • 备忘录(Memento)past 和 future 数组中的每一个数值。每个数值都是一个“状态快照”。
  • 管理者(Caretaker)state 对象中的 past 和 future 数组。它们负责存储这些状态快照。

具体实现流程:

  1. Increment 操作

    • Originatorpresent)的状态即将改变。
    • Originator 告诉 Caretakerpast 数组),“我马上要变了,这是我现在的样子,你帮我存一下。”
    • Caretaker 将当前的 present 值 [...past, present] 存入 past 数组。
  2. Undo 操作

    • Caretakerpast 数组)将最后一个备忘录(past.at(-1))交给 Originator
    • Originator 接收这个备忘录,并将其恢复为自己的状态(present: past.at(-1))。
    • 同时,Originator 将当前状态作为新的备忘录,交给另一个 Caretakerfuture 数组),以便重做。

为什么这种模式更优越?

备忘录模式的优点在于它实现了解耦。管理者(past/future 数组)和发起人(present 值)之间只需要知道如何存取备忘录,而不需要知道备忘录内部的具体结构或如何改变状态。这意味着你可以轻松地改变 increment 或 decrement 的逻辑,而 undo 和 redo 的逻辑完全不需要改动。

这就是为什么代码二的设计如此优雅和可扩展。它不关心“如何”改变,只关心“改变前”和“改变后”的状态是什么,并将这些状态作为备忘录保存起来。

实时 AIGC:Web 端低延迟生成的技术难点与突破

各位开发者朋友,当你在 Web 页面上敲下 “帮我生成一篇关于太空旅行的短文”,按下回车后,是愿意等待一杯咖啡凉透,还是希望答案像闪电般出现在屏幕上?答案不言而喻。实时 AIGC(生成式人工智能)在 Web 端的应用,就像一场 “速度与精度” 的极限竞速,而低延迟生成,正是这场比赛中最具挑战性的关卡。作为一名深耕 AI 与 Web 技术交叉领域的研究者,今天我们就扒开技术的外衣,从底层原理出发,聊聊实时 AIGC 在 Web 端实现低延迟的那些 “拦路虎” 和 “破局招”。

一、实时 AIGC 的 “生死线”:Web 端低延迟的核心挑战

在讨论技术细节前,我们得先明确一个标准:Web 端的 “实时” 到底意味着什么?从用户体验角度看,端到端延迟超过 300 毫秒,用户就会明显感觉到 “卡顿”;而对于对话式 AI、实时图像生成等场景,延迟需要压缩到100 毫秒以内,才能达到 “无缝交互” 的效果。但 AIGC 模型本身就像一个 “贪吃的巨人”,要在 Web 这个 “狭窄的舞台” 上快速完成 “表演”,面临着三大核心难题。

1. 模型 “体重超标”:Web 环境的 “承重危机”

AIGC 模型(尤其是大语言模型 LLM 和 diffusion 图像生成模型)的 “体重” 是低延迟的第一只 “拦路虎”。以主流的 LLM 为例,一个千亿参数的模型,其权重文件大小可能超过 10GB,即使是经过压缩的轻量模型,也可能达到数百 MB。而 Web 环境的 “带宽天花板” 和 “存储小仓库”,根本无法承受这样的 “重量级选手”。

从底层原理来看,模型的推理过程本质上是大量的矩阵乘法和非线性变换运算。假设一个模型有 N 层网络,每一层需要处理 M 个特征向量,那么单次推理的运算量会随着 N 和 M 的增加呈 “平方级” 增长。在 Web 端,浏览器的 JavaScript 引擎(如 V8)和 GPU 渲染线程虽然具备一定的计算能力,但面对这种 “海量运算”,就像让一台家用轿车去拉火车,力不从心。

举个通俗的例子:如果把模型推理比作 “做蛋糕”,传统服务器端推理是在大型烘焙工厂,有无数烤箱和厨师;而 Web 端推理则是在你家的小厨房,只有一个微波炉和你自己。要在同样时间内做出同样的蛋糕,难度可想而知。

2. 数据 “长途跋涉”:端云交互的 “延迟陷阱”

很多开发者会想:既然 Web 端算力有限,那把模型放在云端,Web 端只负责 “传输入输出” 不就行了?这确实是目前的主流方案,但它又陷入了另一个 “延迟陷阱”——端云数据传输延迟

从网络底层来看,数据从 Web 端(客户端)发送到云端服务器,需要经过 “TCP 三次握手”“数据分片”“路由转发” 等一系列流程,每一步都需要时间。假设用户在上海,而云端服务器在北京,光信号在光纤中传输的时间就需要约 20 毫秒(光速约 30 万公里 / 秒,京沪直线距离约 1300 公里,往返就是 2600 公里,计算下来约 8.7 毫秒,加上路由转发等耗时,实际会超过 20 毫秒)。如果模型在云端推理需要 50 毫秒,再加上数据返回的 20 毫秒,仅端云交互和推理就已经超过 90 毫秒,再加上 Web 端的渲染时间,很容易突破 100 毫秒的 “生死线”。

更麻烦的是,Web 端与云端的通信还可能面临 “网络抖动”—— 就像你在高峰期开车,时而顺畅时而拥堵。这种抖动会导致延迟忽高忽低,严重影响用户体验。比如,在实时对话场景中,用户说完一句话,AI 回复时而 “秒回”,时而 “卡顿 5 秒”,这种 “薛定谔的延迟” 会让用户崩溃。

3. 资源 “抢地盘”:Web 端的 “资源争夺战”

Web 页面本身就是一个 “资源密集型” 应用,浏览器要同时处理 DOM 渲染、CSS 样式计算、JavaScript 执行、网络请求等多个任务。而 AIGC 推理需要占用大量的 CPU/GPU 资源,这就必然引发一场 “资源争夺战”。

从浏览器的事件循环机制来看,JavaScript 是单线程执行的(虽然有 Web Worker 可以开启多线程,但计算能力有限)。如果 AIGC 推理在主线程中执行,就会 “阻塞” 其他任务,导致页面卡顿、按钮点击无响应 —— 这就像你在电脑上同时开着视频会议、玩游戏、下载文件,电脑会变得异常卡顿。

即使使用 Web Worker 将推理任务放到后台线程,GPU 资源的竞争依然存在。浏览器的 WebGL 或 WebGPU 接口虽然可以调用 GPU 进行并行计算,但 GPU 同时还要负责页面的 3D 渲染、视频解码等任务。当 AIGC 推理占用大量 GPU 算力时,页面的动画效果可能会掉帧,视频可能会卡顿 —— 就像一条公路上,货车(AIGC 推理)和轿车(页面渲染)抢道,最终导致整个交通瘫痪。

二、破局之路:从底层优化到上层创新的 “组合拳”

面对上述三大难题,难道 Web 端实时 AIGC 就只能 “望洋兴叹”?当然不是。近年来,从模型压缩到推理引擎优化,从网络传输到 Web 技术创新,业界已经打出了一套 “组合拳”,让实时 AIGC 在 Web 端的实现成为可能。下面我们就从技术底层出发,逐一拆解这些 “破局招”。

1. 模型 “瘦身”:从 “巨人” 到 “轻骑兵” 的蜕变

要让模型在 Web 端 “跑得动”,第一步就是给它 “瘦身”。模型压缩技术就像 “健身教练”,通过科学的方法,在尽量不损失精度的前提下,减少模型的 “体重” 和 “运算量”。目前主流的 “瘦身” 手段有三种:量化、剪枝和知识蒸馏

(1)量化:给模型 “降精度”

量化的核心思路是:将模型中 32 位浮点数(float32)表示的权重和激活值,转换为 16 位浮点数(float16)、8 位整数(int8)甚至 4 位整数(int4)。这样一来,模型的体积会大幅减小,运算速度也会显著提升。

从底层原理来看,浮点数的运算比整数运算复杂得多。以乘法运算为例,float32 的乘法需要经过 “符号位计算”“指数位相加”“尾数位相乘” 等多个步骤,而 int8 的乘法只需要简单的整数相乘。在 Web 端的 JavaScript 引擎中,整数运算的效率比浮点数高 30%-50%(不同引擎略有差异)。

举个例子:一个 float32 的权重文件大小为 4GB,量化为 int8 后,大小会压缩到 1GB,体积减少 75%。同时,推理时的运算量也会减少 75%,这对于 Web 端的算力来说,无疑是 “雪中送炭”。

当然,量化也有 “副作用”—— 精度损失。但通过 “量化感知训练”(在训练时就模拟量化过程),可以将精度损失控制在 5% 以内,对于大多数 Web 端应用(如对话、简单图像生成)来说,完全可以接受。

在 Web 端,我们可以使用 TensorFlow.js(TF.js)实现模型量化。下面是一个简单的 JS 示例,将一个预训练的 LLM 模型量化为 int8:

// 加载未量化的模型
const model = await tf.loadGraphModel('https://example.com/llm-model.json');
// 配置量化参数
const quantizationConfig = {
  quantizationType: tf.io.QuantizationType.INT8, // 量化为int8
  inputNames: ['input_ids'], // 模型输入名称
  outputNames: ['logits'] // 模型输出名称
};
// 量化模型并保存
await tf.io.writeGraphModel(
  model,
  'https://example.com/llm-model-quantized',
  { quantizationConfig }
);
// 加载量化后的模型
const quantizedModel = await tf.loadGraphModel('https://example.com/llm-model-quantized.json');
console.log('模型量化完成,体积减少约75%');

(2)剪枝:给模型 “砍枝丫”

如果说量化是 “降精度”,那剪枝就是 “砍冗余”。模型在训练过程中,会产生很多 “冗余参数”—— 就像一棵大树,有很多不必要的枝丫。剪枝的目的就是把这些 “枝丫” 砍掉,只保留核心的 “树干” 和 “主枝”。

剪枝分为 “结构化剪枝” 和 “非结构化剪枝”。对于 Web 端来说,结构化剪枝更实用 —— 它会剪掉整个卷积核或全连接层中的某些通道,而不是单个参数。这样做的好处是,剪枝后的模型依然可以被 Web 端的推理引擎高效处理,不会引入额外的计算开销。

举个例子:一个包含 1024 个通道的卷积层,如果通过剪枝去掉其中的 256 个通道(冗余通道),那么该层的运算量会减少 25%,同时模型体积也会减少 25%。而且,由于通道数减少,后续层的输入特征向量维度也会降低,进一步提升整体推理速度。

(3)知识蒸馏:让 “小模型” 学会 “大模型” 的本领

知识蒸馏的思路很有趣:让一个 “小模型”(学生模型)通过学习 “大模型”(教师模型)的输出和决策过程,掌握与大模型相当的能力。就像一个徒弟通过模仿师傅的技艺,最终达到师傅的水平,但徒弟的 “精力”(算力需求)却远低于师傅。

在 Web 端,我们可以先在云端用大模型对海量数据进行 “标注”(生成软标签),然后用这些软标签训练一个小模型。小模型不仅体积小、运算量低,还能继承大模型的 “智慧”。例如,用千亿参数的 GPT-4 作为教师模型,训练一个亿级参数的学生模型,学生模型在 Web 端的推理速度可以达到大模型的 10 倍以上,同时精度损失控制在 10% 以内。

2. 推理 “加速”:让 Web 端算力 “物尽其用”

模型 “瘦身” 后,下一步就是优化推理过程,让 Web 端的 CPU 和 GPU 发挥最大潜力。这就像给 “轻骑兵” 配备 “快马”,进一步提升速度。目前主流的推理优化技术包括WebGPU 加速、算子融合和动态批处理

(1)WebGPU:给 Web 端装上 “GPU 引擎”

在 WebGPU 出现之前,Web 端调用 GPU 进行计算主要依赖 WebGL。但 WebGL 是为图形渲染设计的,用于通用计算(如 AI 推理)时效率很低,就像用 “炒菜锅” 来 “炼钢”。而 WebGPU 是专门为通用计算设计的 Web 标准,它可以直接调用 GPU 的计算核心,让 AI 推理的效率提升 10-100 倍。

从底层原理来看,WebGPU 支持 “计算着色器”(Compute Shader),可以将模型推理中的矩阵乘法等并行运算,分配给 GPU 的多个计算单元同时处理。例如,一个 1024x1024 的矩阵乘法,在 CPU 上可能需要几毫秒,而在 GPU 上,通过并行计算,可能只需要几十微秒。

在 TF.js 中,我们可以很容易地启用 WebGPU 后端,为模型推理加速。下面是一个 JS 示例:

// 检查浏览器是否支持WebGPU
if (tf.getBackend() !== 'webgpu' && tf.backend().isWebGPUSupported()) {
  await tf.setBackend('webgpu'); // 切换到WebGPU后端
  console.log('已启用WebGPU加速,推理速度预计提升10倍以上');
}
// 加载量化后的模型并进行推理
const input = tf.tensor2d([[1, 2, 3, 4]], [1, 4]); // 模拟输入数据
const output = await quantizedModel.predict(input); // 推理
output.print(); // 输出结果

需要注意的是,目前 WebGPU 还未在所有浏览器中普及(Chrome、Edge 等已支持,Safari 正在逐步支持),但它无疑是 Web 端 AI 推理的未来趋势。

(2)算子融合:减少 “数据搬运” 时间

模型推理过程中,有大量的 “算子”(如卷积、激活、池化等)需要依次执行。在传统的推理方式中,每个算子执行完成后,都会将结果写入内存,下一个算子再从内存中读取数据 —— 这就像 “接力赛”,每一棒都要停下来交接,浪费大量时间。

算子融合的核心思路是:将多个连续的算子 “合并” 成一个算子,在 GPU 中直接完成所有计算,中间结果不写入内存。这样可以大幅减少 “数据搬运” 的时间,提升推理效率。例如,将 “卷积 + ReLU 激活 + 批归一化” 三个算子融合成一个 “卷积 - ReLU - 批归一化” 算子,推理速度可以提升 30% 以上。

在 Web 端的推理引擎(如 TF.js、ONNX Runtime Web)中,算子融合已经成为默认的优化策略。开发者不需要手动进行融合,引擎会自动分析模型的算子依赖关系,完成融合优化。

(3)动态批处理:让 “闲置算力” 不浪费

在 Web 端的实时 AIGC 场景中,用户请求往往是 “零散的”—— 可能某一时刻有 10 个用户同时发送请求,某一时刻只有 1 个用户发送请求。如果每次只处理一个请求,GPU 的算力就会大量闲置,就像 “大货车只拉一个包裹”,效率极低。

动态批处理的思路是:在云端推理服务中,设置一个 “批处理队列”,将短时间内(如 10 毫秒)收到的多个用户请求 “打包” 成一个批次,一次性送入模型推理。推理完成后,再将结果分别返回给各个用户。这样可以充分利用 GPU 的并行计算能力,提升单位时间内的处理量,从而降低单个请求的延迟。

例如,一个模型处理单个请求需要 50 毫秒,处理一个包含 10 个请求的批次也只需要 60 毫秒(因为并行计算的开销增加很少)。对于每个用户来说,延迟从 50 毫秒降到了 6 毫秒,效果非常显著。

在 Web 端,动态批处理需要云端服务的支持。开发者可以使用 TensorFlow Serving 或 ONNX Runtime Server 等工具,配置动态批处理参数。下面是一个简单的配置示例(以 ONNX Runtime Server 为例):

{
  "model_config_list": [
    {
      "name": "llm-model",
      "base_path": "/models/llm-model",
      "platform": "onnxruntime",
      "batch_size": {
        "max": 32, // 最大批处理大小
        "dynamic_batching": {
          "max_queue_delay_milliseconds": 10 // 最大队列等待时间
        }
      }
    }
  ]
}

3. 传输 “提速”:打通端云交互的 “高速公路”

解决了模型和推理的问题后,端云数据传输的延迟就成了 “最后一公里”。要打通这 “最后一公里”,需要从网络协议优化、边缘计算部署和数据压缩三个方面入手。

(1)HTTP/3 与 QUIC:给数据传输 “换条快车道”

传统的端云通信主要基于 HTTP/2 协议,而 HTTP/2 依赖 TCP 协议。TCP 协议的 “三次握手” 和 “拥塞控制” 机制,在网络不稳定时会导致严重的延迟。而 HTTP/3 协议基于 QUIC 协议,QUIC 是一种基于 UDP 的新型传输协议,它具有 “0-RTT 握手”“多路复用无阻塞”“丢包恢复快” 等优点,可以将端云数据传输的延迟降低 30%-50%。

从底层原理来看,QUIC 协议在建立连接时,不需要像 TCP 那样进行三次握手,而是可以在第一次数据传输时就完成连接建立(0-RTT),节省了大量时间。同时,QUIC 的多路复用机制可以避免 TCP 的 “队头阻塞” 问题 —— 即使某一个数据流出现丢包,其他数据流也不会受到影响,就像一条有多条车道的高速公路,某一条车道堵车,其他车道依然可以正常通行。

目前,主流的云服务提供商(如阿里云、AWS)和浏览器(Chrome、Edge)都已经支持 HTTP/3 协议。开发者只需要在云端服务器配置 HTTP/3,Web 端就可以自动使用 HTTP/3 进行通信,无需修改代码。

(2)边缘计算:把 “云端” 搬到用户 “家门口”

边缘计算的核心思路是:将云端的模型推理服务部署在离用户更近的 “边缘节点”(如城市边缘机房、基站),而不是集中在遥远的中心机房。这样可以大幅缩短数据传输的物理距离,降低传输延迟。

举个例子:如果用户在杭州,中心机房在北京,数据传输延迟需要 20 毫秒;而如果在杭州部署一个边缘节点,数据传输延迟可以降低到 1-2 毫秒,几乎可以忽略不计。对于实时 AIGC 场景来说,这 18-19 毫秒的延迟节省,足以决定用户体验的好坏。

目前,各大云厂商都推出了边缘计算服务(如阿里云边缘计算、腾讯云边缘计算)。开发者可以将训练好的模型部署到边缘节点,然后通过 CDN 的方式完成使用。

五、Redux进阶:UI组件、容器组件、无状态组件、异步请求、Redux中间件:Redux-thunk、redux-saga,React-redux

一、UI组件和容器组件

  1. UI组件负责页面的渲染(傻瓜组件)
  2. 容器组件负责页面的逻辑(聪明组件)

当一个组件内容比较多,同时有逻辑处理和UI数据渲染时,维护起来比较困难。这个时候可以拆分成“UI组件”和"容器组件"。 拆分的时候,容器组件把数据和方法传值给子组件,子组件用props接收。

需要注意的是: 子组件调用父组件方法函数时,并传递参数时,可以把方法放在箭头函数中(直接在函数体使用该参数,不需要传入箭头函数)。

拆分实例

未拆分前原组件

import React, {Component} from 'react';
import 'antd/dist/antd.css'; // or 'antd/dist/antd.less'
import { Input, Button, List } from 'antd';
// 引用store
import store from './store';
import { inputChangeAction, addItemAction, deleteItemAction } from './store/actionCreators';

class TodoList extends Component {
  constructor(props) {
    super(props);
    // 获取store,并赋值给state
    this.state = store.getState();
    
    // 统一在constructor中绑定this,提交性能
    this.handleInputChange = this.handleInputChange.bind(this);
    this.handleStoreChange = this.handleStoreChange.bind(this);
    this.handleClick = this.handleClick.bind(this);

    // 在组件中订阅store,只要store改变就触发这个函数
    this.unsubscribe = store.subscribe(this.handleStoreChange);
  }

  // 当store状态改变时,更新state
  handleStoreChange() {
    // 用从store中获取的state,来设置state
    this.setState(store.getState());
  }
  
  render() {
    return (
      <div style={{margin: '10px'}}>
        <div className="input">
          <Input
            style={{width: '300px', marginRight: '10px'}}
            value={this.state.inputValue}
            onChange={this.handleInputChange}
          />
          <Button type="primary" onClick={this.handleClick}>提交</Button>
        </div>
        <List
          style={{marginTop: '10px', width: '300px'}}
          bordered
          dataSource={this.state.list}
          renderItem={(item, index) => (<List.Item onClick={this.handleDelete.bind(this, index)}>{item}</List.Item>)}
        />
      </div>
    )
  }

  // 组件注销前把store的订阅取消
  componentWillUnmount() {
    this.unsubscribe();
  }

  // 输入内容时(input框内容改变时)
  handleInputChange(e) {
    const action = inputChangeAction(e.target.value);
    store.dispatch(action);
  }

  // 添加一项
  handleClick () {
    const action = addItemAction();
    store.dispatch(action);
  }
  
  // 点击删除当前项
  handleDelete (index) {
    const action = deleteItemAction(index);
    store.dispatch(action);
  }
}

export default TodoList;

拆分后-容器组件

import React, {Component} from 'react';

// 引用store
import store from './store';
import { inputChangeAction, addItemAction, deleteItemAction } from './store/actionCreators';
import TodoListUI from './TodoListUI';

class TodoList extends Component {
  constructor(props) {
    super(props);
    // 获取store,并赋值给state
    this.state = store.getState();
    
    // 统一在constructor中绑定this,提交性能
    this.handleInputChange = this.handleInputChange.bind(this);
    this.handleStoreChange = this.handleStoreChange.bind(this);
    this.handleClick = this.handleClick.bind(this);

    // 在组件中订阅store,只要store改变就触发这个函数
    this.unsubscribe = store.subscribe(this.handleStoreChange);
  }

  // 当store状态改变时,更新state
  handleStoreChange() {
    // 用从store中获取的state,来设置state
    this.setState(store.getState());
  }
  
  render() {
    return (
      <TodoListUI
        inputValue={this.state.inputValue}
        list={this.state.list}
        handleInputChange={this.handleInputChange}
        handleClick={this.handleClick}
        handleDelete={this.handleDelete}
      />
    )
  }

  // 组件注销前把store的订阅取消
  componentWillUnmount() {
    this.unsubscribe();
  }

  // 输入内容时(input框内容改变时)
  handleInputChange(e) {
    const action = inputChangeAction(e.target.value);
    store.dispatch(action);
  }

  // 添加一项
  handleClick () {
    const action = addItemAction();
    store.dispatch(action);
  }

  // 点击删除当前项
  handleDelete (index) {
    const action = deleteItemAction(index);
    store.dispatch(action);
  }
}

export default TodoList;

拆分后-UI组件

import React, { Component } from 'react';
import 'antd/dist/antd.css'; // or 'antd/dist/antd.less'
import { Input, Button, List } from 'antd';

class TodoListUI extends Component {
  render() {
    return (
      <div style={{margin: '10px'}}>
        <div className="input">
          <Input
            style={{width: '300px', marginRight: '10px'}}
            value={this.props.inputValue}
            onChange={this.props.handleInputChange}
          />
          <Button type="primary" onClick={this.props.handleClick}>提交</Button>
        </div>
        <List
          style={{marginTop: '10px', width: '300px'}}
          bordered
          dataSource={this.props.list}
          // renderItem={(item, index) => (<List.Item onClick={(index) => {this.props.handleDelete(index)}}>{item}-{index} </List.Item>)}
          renderItem={(item, index) => (<List.Item onClick={() => {this.props.handleDelete(index)}}>{item}-{index} </List.Item>)}
        />
        {/* 子组件调用父组件方法函数时,并传递参数时,可以把方法放在箭头函数中(直接在函数体使用该参数,不需要传入箭头函数)。 */}
      </div>
    )
  }
}

export default TodoListUI;

二、无状态组件

当一个组件只有render函数时,可以用无状态组件代替。

  1. 无状态组件比普通组件性能高; 因为无状态组件只是函数,普通组件是class声明的类要执行很多生命周期函数和render函数。
  2. 无状态组件中的函数接收一个参数作为父级传过来的props。

例如下面这个例子 普通组件:

class TodoList extends Component {
  render() {
    return <div> {this.props.item} </div>
  }
}

无状态组件:

const TodoList = (props) => {
  return(
    <div> {props.item} </div>
  )}

三、Redux 中发送异步请求获取数据

1、引入axios,使用axios发送数据请求

import axios from 'axios';

2、在componentDidMount中调用接口

componentDidMount() {
  axios.get('/list.json').then(res => {
    const data = res.data;
    // 在actionCreators.js中定义好initListAction,并在reducer.js中作处理(此处省略这部分)
    const action = initListAction(data);
    store.dispatch(action);
  })
}

四、使用Redux-thunk 中间件实现ajax数据请求

1、安装和配置Redux-thunk

1.1、安装Redux-thunk

npm install redux-thunk --save

1.2、正常使用redux-thunk中间件在store中的写法

// 引用applyMiddleware
import { createStore, applyMiddleware } from 'redux';
import reducer from './reducer';
import thunk from 'redux-thunk';

// 创建store时,第二个参数传入中间件
const store = createStore(
  reducer,
  applyMiddleware(thunk)
);

export default store;

redux-thunk使用说明

1.3、redux-thunk中间件 和 redux-devtools-extension 一起使用的写法

// 引入compose
import { createStore, applyMiddleware, compose} from 'redux';
import reducer from './reducer';
import thunk from 'redux-thunk';

const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) : compose;
const enhancer = composeEnhancers(
  applyMiddleware(thunk),
);

const store = createStore(reducer, enhancer);

export default store;

Redux DevTools插件配置说明

2、redux-thunk 的作用和优点

  1. 不使用redux-thunk中间件,store接收的action只能是对象;有了redux-thunk中间件,action也可以是一个函数。这样子就可以在action中做异步操作等。
  2. store接收到action之后发现action是函数而不是对象,则会执行调用这个action函数。
  3. 可以把复杂的异步数据处理从组件的生命周期里摘除出来(放到action中),避免组件过于庞大,方便后期维护、自动化测试。

3、使用redux-thunk的流程

  1. 在创建store时,使用redux-thunk。详见以上配置说明。

  2. 在actionCreators.js中创建返回一个方法的action,并导出。在这个方法中执行http请求。

import types from './actionTypes';
import axios from 'axios';

export const initItemAction = (value) => ({
  type: types.INIT_TODO_ITEM,
  value: value
})

// 当使用redux-thunk后,action不仅可以是对象,还可以是函数
// 返回的如果是方法会自动执行
// 返回的方法可以接收到dispatch方法,去派发其它action
export const getTodoList = () => {
  return (dispatch) => {
    axios.get('/initList').then(res => {
      const action = initItemAction(res.data);
      dispatch(action);
    })
  }
}

export const inputChangeAction = (value) => ({
  type: types.CHANGE_INPUT_VALUE,
  value: value
})

export const addItemAction = (value) => ({
  type: types.ADD_TODO_ITEM
})

export const deleteItemAction = (index) => ({
  type: types.DELETE_TODO_ITEM,
  value: index
})
  1. 在组件中引用这个action,并在componentDidMount中派发该action给store
import React, {Component} from 'react';

import store from './store';
import { getTodoList } from './store/actionCreators';

class TodoList extends Component {

  ...

  // 初始化数据(使用redux-thunk派发/执行一个action函数)
  componentDidMount() {
    const action = getTodoList();
    store.dispatch(action);
  }

  ...
}

export default TodoList;

4、具体执行流程

  1. 组件加载完成后,把处理异步请求的action函数派发给store;
  2. 因使用了redux-thunk中间件,所以可以接收一个action函数(正常只能接收action对象)并执行该方法;
  3. 在这个方法中执行http异步请求,拿到结果后再次派发一个正常的action对象给store;
  4. store发现是action对象,则根据拿来的值修改store中的状态。

五、什么是Redux的中间件

  1. 中间件指的是action 和 store 中间。
  2. 中间件实现是对store的dispatch方法的升级。

Redux数据流

几个常见中间件的作用(对dispatch方法的升级)

  1. redux-thunk:使store不但可以接收action对象,还可以接收action函数。当action是函数时,直接执行该函数。
  2. redux-log:每次dispatch时,在控制台输出内容。
  3. redux-saga:也是处理异步逻辑,把异步逻辑单独放在一个文件中管理。

六、redux-saga中间件入门

1、安装和配置redux-saga

1.1、安装redux-saga

npm install --save redux-saga

yarn add redux-saga

1.2、正常使用redux-saga中间件在store中的写法

import { createStore, applyMiddleware} from 'redux';
import createSagaMiddleware from 'redux-saga';

import reducer from './reducer';
import mySaga from './sagas';

const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducer, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(mySaga);

export default store;

redux-saga使用说明

1.3、redux-saga中间件 和 redux-devtools-extension 一起使用的写法

import { createStore, applyMiddleware, compose} from 'redux';
import createSagaMiddleware from 'redux-saga';

import reducer from './reducer';
import mySaga from './sagas';

const sagaMiddleware = createSagaMiddleware();
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({}) : compose;
const enhancer = composeEnhancers(
  applyMiddleware(sagaMiddleware),
);
const store = createStore(reducer, enhancer);
sagaMiddleware.run(mySaga);

export default store;

Redux DevTools插件配置说明

2、redux-saga 的作用和与redux-thunk的比较

  1. redux-saga也是解决异步请求的。但是redux-thunk的异步处理还是在aciton中,而redux-saga的异步处理是在一个单独的文件(sagas.js)中处理。
  2. redux-saga同样是作异步代码拆分的中间件,可以使用redux-saga完全代替redux-thunk。(redux-saga使用起来更复杂,更适合大型项目)
  3. redux-thunk只是把异步请求放到action中,并没有多余的API。而redux-saga是单独放在一个文件中处理,并且有很多PAI。
  4. 使用流程上的区别; 4.1. 使用redux-thunk时,从组件中派发action(action函数)时,监测到是函数,会在action中接收并处理,然后拿到结果后再派发一个普通action交给store的reducer处理,更新store的状态。 4.2. 使用redux-saga时,从组件中派发action(普通action对象)时,会先交给sagas.js匹配处理异步请求。拿到结果后再使用put方法派发一个普通action交给store的reducer处理,更新store的状态。

3、使用redux-saga的流程

  1. 在创建store时,使用redux-saga。详见以上配置说明。

  2. 在actionCreators.js中创建一个普通的action,并导出。

import types from './actionTypes';
// import axios from 'axios';

export const initItemAction = (value) => ({
  type: types.INIT_TODO_ITEM,
  value: value
})

// redux-thunk的写法,异步请求依然在这个文件中
// export const getTodoList = () => {
//   return (dispatch) => {
//     axios.get('/initList').then(res => {
//       const action = initItemAction(res.data);
//       dispatch(action);
//     })
//   }
// }

// redux-saga的写法,这里返回一个普通action对象;
// sagas.js中会用takeEvery监听这个type类型,然后执行对应的异步请求
export const getTodoList = () => ({
  type: types.GET_INIT_ACTION,
})

export const inputChangeAction = (value) => ({
  type: types.CHANGE_INPUT_VALUE,
  value: value
})

export const addItemAction = (value) => ({
  type: types.ADD_TODO_ITEM
})

export const deleteItemAction = (index) => ({
  type: types.DELETE_TODO_ITEM,
  value: index
})
  1. 在store文件夹中,创建一个文件sagas.js,使用redux-saga的takeEvery方法监听刚才派发的type类型,然后执行对应的函数,执行异步请求代码。拿到结果后再使用redux-saga的put方法派发一个普通的action对象,交给store的reducer处理。
import { takeEvery, put } from 'redux-saga/effects';
import types from './actionTypes';
import axios from 'axios';
import { initItemAction } from './actionCreators';

function* getInitList() {
  try {
    const res = yield axios.get('/initList');
    const action = initItemAction(res.data);
    yield put(action);
  } catch(e) {
    console.log('接口请求失败');
  }
}

// generator 函数
function* mySaga() {
  yield takeEvery(types.GET_INIT_ACTION, getInitList);
}

export default mySaga;
  1. 在组件中引用这个action,并在componentDidMount中派发该action给store
import React, {Component} from 'react';

import store from './store';
import { getTodoList } from './store/actionCreators';

class TodoList extends Component {

  ...

  // 初始化数据(使用redux-saga派发一个普通action对象,经由sagas.js的generator 函数匹配处理后,再交由store的reducer处理)
  componentDidMount() {
    const action = getTodoList();
    store.dispatch(action);
  }

  ...
}

export default TodoList;

4、具体执行流程

  1. 组件加载完成后,把一个普通的action对象派发给store;
  2. 因使用了redux-saga中间件,所以会被sagas.js中的generator函数匹配到,并交给对应的函数(一般也是generator函数)处理;
  3. sagas.js的函数拿到结果后,使用redux-saga的put方法再次派发一个普通action对象给store;
  4. sagas.js中没有匹配到对应的类型,则store交由reducer处理并更新store的状态。

七、如何使用React-redux完成TodoList功能

安装React-redux

npm install react-redux --save

1、把redux写法改成React-redux写法

1.1、 入口文件(src/index.js)的修改

  • 使用react-redux的Provider组件(提供器)包裹所有组件,把 store 作为 props 传递到每一个被 connect() 包装的组件。
  • 使组件层级中的 connect() 方法都能够获得 Redux store,这样子内部所有组件就都有能力获取store的内容(通过connect链接store)。

原代码

import React from 'react';
import ReactDOM from 'react-dom';
import TodoList from './todoList';

ReactDOM.render(<TodoList />, document.getElementById('root'));

修改后代码 ```jsx import React from 'react'; import ReactDOM from 'react-dom'; import TodoList from './TodoList'; import { Provider } from 'react-redux'; import store from './store';

// Provider向内部所有组件提供store,内部组件都可以获得store const App = ( )

ReactDOM.render(App, document.getElementById('root'));

<br/>
#### 1.2、组件(TodoList.js)代码的修改

Provider的子组件通过react-redux中的connect连接store,写法:
```jsx
connect(mapStateToProps, mapDispatchToProps)(Component)
  • mapStateToProps:store中的数据映射到组件的props中;
  • mapDispatchToProps:把store.dispatch方法挂载到props上;
  • Component:Provider中的子组件本身;

导出的不是单纯的组件,而是导出由connect处理后的组件(connect处理前是一个UI组件,connect处理后是一个容器组件)。


原代码
import React, { Component } from 'react';
import store from './store';

class TodoList extends Component {
  constructor(props) {
    super(props);
    // 获取store,并赋值给state
    this.state = store.getState();
    
    // 统一在constructor中绑定this,提交性能
    this.handleChange = this.handleChange.bind(this);
    this.handleStoreChange = this.handleStoreChange.bind(this);
    this.handleClick = this.handleClick.bind(this);

    // 在组件中订阅store,只要store改变就触发这个函数
    this.unsubscribe = store.subscribe(this.handleStoreChange);
  }

  // 当store状态改变时,更新state
  handleStoreChange() {
    // 用从store中获取的state,来设置state
    this.setState(store.getState());
  }

  render() {
    return(
      <div>
        <div>
          <input value={this.state.inputValue} onChange={this.handleChange} />
          <button onClick={this.handleClick}>提交</button>
        </div>
        <ul>
          {
            this.state.list.map((item, index) => {
              return <li onClick={() => {this.handleDelete(index)}} key={index}>{item}</li>
            })
          }
        </ul>
      </div>
    )
  }

  // 组件注销前把store的订阅取消
  componentWillUnmount() {
    this.unsubscribe();
  }
  
  handleChange(e) {
    const action = {
      type: 'change-input-value',
      value: e.target.value
    }
    store.dispatch(action);
  }

  handleClick() {
    const action = {
      type: 'add-item'
    }
    store.dispatch(action)
  }

  handleDelete(index) {
    const action = {
      type: 'delete-item',
      value: index
    }
    store.dispatch(action);
  }
}

export default TodoList;

修改后代码

省去了订阅store使用store.getState()更新状态的操作。组件会自动更新数据。

import React, { Component } from 'react';
import { connect } from 'react-redux';

class TodoList extends Component {
  render() {
    // const { inputValue, handleChange, handleClick, list, handleDelete} = this.props;

    return(
      <div>
        <div>
          <input value={this.props.inputValue} onChange={this.props.handleChange} />
          <button onClick={this.props.handleClick}>提交</button>
        </div>
        <ul>
          {
            this.props.list.map((item, index) => {
              return <li onClick={() => {this.props.handleDelete(index)}} key={index}>{item}</li>
            })
          }
        </ul>
      </div>
    )
  }
}

// 把store的数据 映射到 组件的props中
const mapStateToProps = (state) => {
  return {
    inputValue: state.inputValue,
    list: state.list
  }
}

// 把store的dispatch 映射到 组件的props中
const mapDispatchToProps = (dispatch) => {
  return {
    handleChange(e) {
      const action = {
        type: 'change-input-value',
        value: e.target.value
      }
      dispatch(action);
    },
    handleClick() {
      const action = {
        type: 'add-item'
      }
      dispatch(action)
    },
    handleDelete(index) {
      const action = {
        type: 'delete-item',
        value: index
      }
      dispatch(action);
    }
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(TodoList);

#### 1.3、store/index.js 代码不需要修改 ```jsx import { createStore } from 'redux'; import reducer from './reducer'

const store = createStore(reducer);

export default store;

<br/>
#### 1.4、store/reducer.js 代码也不需要修改
```jsx
const defaultState = {
  inputValue: '',
  list: []
}
export default (state = defaultState, action) => {
  const { type, value } = action;
  let newState = JSON.parse(JSON.stringify(state));

  switch(type) {
    case 'change-input-value':
      newState.inputValue = value;
      break;
    case 'add-item':
      newState.list.push(newState.inputValue);
      newState.inputValue = '';
      break;
    case 'delete-item':
      newState.list.splice(value, 1);
      break;
    default:
      return state;
  }

  return newState;
}

2、代码精简及性能优化

  • 因现在组件(TodoList.js)中代码只是用来渲染,是UI组件。并且没有状态(state),是个无状态组件。所以可以改成无状态组件,提高性能。
  • 但connect函数返回的是一个容器组件。
import React from 'react';
import { connect } from 'react-redux';

const TodoList = (props) => {
  const { inputValue, handleChange, handleClick, list, handleDelete} = props;

  return(
    <div>
      <div>
        <input value={inputValue} onChange={handleChange} />
        <button onClick={handleClick}>提交</button>
      </div>
      <ul>
        {
          list.map((item, index) => {
            return <li onClick={() => {handleDelete(index)}} key={index}>{item}</li>
          })
        }
      </ul>
    </div>
  )
}


// 把store的数据 映射到 组件的props中
const mapStateToProps = (state) => {
  return {
    inputValue: state.inputValue,
    list: state.list
  }
}

// 把store的dispatch 映射到 组件的props中
const mapDispatchToProps = (dispatch) => {
  return {
    handleChange(e) {
      const action = {
        type: 'change-input-value',
        value: e.target.value
      }
      dispatch(action);
    },
    handleClick() {
      const action = {
        type: 'add-item'
      }
      dispatch(action)
    },
    handleDelete(index) {
      const action = {
        type: 'delete-item',
        value: index
      }
      dispatch(action);
    }
  }
}

export default connect(mapStateToProps, mapDispatchToProps)(TodoList);

Next.js 性能优化双绝:Image 与 next/font 的底层修炼手册

在前端性能优化的江湖里,Next.js 就像一位自带 “武功秘籍” 的高手,而Image组件与next/font模块,便是它克敌制胜的两大门派绝学。前者专治 “图片加载慢如龟爬” 的顽疾,后者则破解 “字体渲染闪瞎眼” 的魔咒。这两门手艺看似简单,实则暗藏计算机底层的运行逻辑,就像武侠小说里的招式,需懂其 “内力” 运转之法,方能融会贯通。

一、Image 组件:让图片加载 “轻装上阵”

网页加载时,图片往往是 “流量大户”—— 一张未经优化的高清图,可能比整个 JS 脚本还大。浏览器加载图片的过程,就像快递员送大件包裹:先得确认包裹(图片)的大小、地址(URL),再慢悠悠地搬运,期间还可能占用主干道(带宽),导致其他 “小包裹”(文本、按钮)迟迟无法送达。Next.js 的Image组件,本质上是给快递员配了 “智能调度系统”,从底层优化了整个运输流程。

(一)核心优化原理:直击浏览器渲染痛点

传统的标签就像个 “一根筋” 的快递员,不管用户的设备(手机 / 电脑)、网络(5G/WiFi)如何,都一股脑儿发送最大尺寸的图片。而Image组件的优化逻辑,源于计算机图形学与网络传输的底层规律:

  1. 自适应尺寸:按 “需求” 分配资源

不同设备的屏幕分辨率天差地别(比如手机 720p vs 电脑 2K 屏),但图片的 “像素密度”(PPI)只需匹配屏幕即可。Image组件会自动生成多种分辨率的图片(如 1x、2x、3x),让手机只加载小尺寸图,电脑加载高清图,避免 “小马拉大车” 的资源浪费。这就像裁缝做衣服,根据客户的身高体重(设备分辨率)裁剪布料(图片像素),而非给所有人都发一件 XXL 的外套。

  1. 懒加载:“按需配送” 省带宽

浏览器默认会加载页面上所有图片,哪怕是用户需要滚动很久才能看到的底部图片。这就像外卖小哥不管你吃不吃,先把一天的饭菜全送到你家门口。Image组件的懒加载功能,会监听用户的滚动位置(通过浏览器的IntersectionObserverAPI),只有当图片进入 “可视区域”(比如屏幕下方 100px)时才开始加载。从底层看,这减少了 HTTP 请求的并发数,避免了网络带宽被 “无效请求” 占用,让关键资源(如导航栏、正文)更快加载完成。

  1. 自动优化:给图片 “瘦身” 不 “缩水”

Next.js 会自动对图片进行格式转换(如将 JPG 转为 WebP,体积减少 30% 以上)和压缩,且不影响视觉效果。这背后的原理是:不同图片格式的 “压缩算法” 不同 ——WebP 采用了更高效的 “有损压缩 + 无损压缩” 混合策略,在相同画质下,文件体积比 JPG 小得多。就像把棉花糖(原始图片)放进真空袋(优化算法),体积变小了,但松开后还是原来的形状(画质不变)。

(二)实战用法:3 步掌握 “图片轻功”

使用Image组件只需记住一个核心:必须指定 width height (或通过 layout 属性动态适配) ,否则 Next.js 无法提前计算图片的占位空间,可能导致页面 “抖动”(Cumulative Layout Shift,CLS,核心 Web 指标之一)。

1. 基础用法:本地图片与远程图片

  • 本地图片(推荐) :放在public文件夹下,直接通过路径引入,Next.js 会自动处理优化。
import Image from 'next/image';
export default function Home() {
  return (
    <div>
      {/* 本地图片:自动优化尺寸、格式 */}
      <Image
        src="/cat.jpg" // public文件夹下的路径
        alt="一只可爱的猫"
        width={600} // 图片宽度像素height={400} // 图片高度像素)
        // layout="responsive" // 可选让图片适应父容器宽度保持宽高比
      />
    </div>
  );
}
  • 远程图片:需在next.config.js中配置domains,告诉 Next.js “这是安全的图片源”,避免被浏览器的 CSP(内容安全策略)拦截。
// next.config.js
module.exports = {
  images: {
    domains: ['picsum.photos'], // 允许加载的远程图片域名
  },
};
// 组件中使用
<Image
  src="https://picsum.photos/800/600" // 远程图片URL
  alt="随机图片"
  width={800}
  height={600}
  priority // 可选:标记为“优先加载”(如首屏Banner图)
/>

2. 进阶技巧:自定义占位符与加载效果

为了避免图片加载时出现 “空白区域”,可以用placeholder属性设置占位符,提升用户体验:

<Image
  src="/dog.jpg"
  alt="一只活泼的狗"
  width={600}
  height={400}
  placeholder="blur" // 模糊占位符(推荐)
  blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" // 模糊占位图的Base64编码(小尺寸,快速加载)
/>

这里的blurDataURL就像 “预告片”,在正片(原图)加载完成前,先给用户看一个模糊的缩略版,避免页面 “冷场”。从底层看,Base64 编码的图片会直接嵌入 HTML,无需额外 HTTP 请求,加载速度极快。

3. 避坑指南:别踩 “尺寸适配” 的坑

如果图片需要自适应父容器宽度(比如在响应式布局中),必须用layout="responsive"或layout="fill",且给父容器设置position: relative:

// 响应式图片:适应父容器宽度,保持宽高比
<div style={{ position: 'relative', width: '100%', maxWidth: '800px' }}>
  <Image
    src="/mountain.jpg"
    alt="山脉风景"
    layout="fill" // 让图片填充父容器
    objectFit="cover" // 类似CSS的object-fit,避免图片拉伸
  />
</div>

若不设置父容器的position: relative,layout="fill"的图片会 “飞” 出文档流,就像没系安全带的乘客在车里乱晃,导致页面布局混乱。

二、next/font:让字体渲染 “稳如泰山”

字体加载的 “闪屏问题”(Flash of Unstyled Text,FOUT),是前端开发者的 “老冤家”:浏览器加载网页时,会先显示默认字体(如宋体),等自定义字体(如思源黑体)加载完成后,再突然替换,导致页面 “跳一下”。这就像演员上台前没穿戏服,先穿着便服亮相,等戏服到了再慌忙换上,让观众一脸懵。next/font模块的出现,从底层解决了这个问题,让字体渲染 “无缝衔接”。

(一)核心优化原理:字体加载的 “暗度陈仓”

传统加载字体的方式(通过@font-face引入),本质是让浏览器 “边加载边渲染”,而next/font的优化逻辑,源于浏览器的 “字体渲染机制” 和 “构建时优化”:

  1. 构建时嵌入:把字体 “焊死” 在代码里

Next.js 在构建项目时,会将自定义字体文件(如.ttf、.woff2)处理成 “优化后的静态资源”,并直接嵌入到 JS 或 CSS 中(通过 Base64 编码或按需生成字体文件)。这就像厨师提前把调料(字体)炒进菜里(代码),而非等客人上桌了才临时找调料。从底层看,这减少了字体文件的 HTTP 请求,避免了 “字体加载滞后于页面渲染” 的问题。

  1. 字体子集化:只带 “必要的字” 出门

中文字体文件通常很大(比如思源黑体全量文件超过 10MB),但大多数网页只用到其中的几百个常用字。next/font会自动进行 “字体子集化”,只提取网页中实际用到的字符,生成体积极小的字体文件(可能只有几十 KB)。这就像出门旅行时,只带需要穿的衣服,而非把整个衣柜都搬走,极大减少了加载时间。

  1. 阻止 FOUT:让浏览器 “等字体再渲染”

通过next/font加载的字体,会被标记为 “关键资源”,浏览器会等待字体加载完成后再渲染文本,避免出现 “默认字体→自定义字体” 的跳转。但为了防止字体加载失败导致文本无法显示,Next.js 会设置一个 “超时时间”(默认 3 秒),若超时仍未加载完成,会自动降级为默认字体,兼顾性能与可用性。

(二)实战用法:2 步实现 “字体无痕加载”

next/font支持两种字体来源:本地字体文件Google Fonts,前者更灵活(可控制字体文件),后者更方便(无需手动下载字体)。

1. 本地字体:掌控字体 “全生命周期”

第一步:将字体文件(如SimHei.ttf)放在public/fonts文件夹下;

第二步:在组件中通过next/font/local加载,并应用到文本上。

import { localFont } from 'next/font/local';
// 加载本地字体:指定字体文件路径,设置显示策略
const myFont = localFont({
  src: [
    {
      path: '../public/fonts/SimHei-Regular.ttf',
      weight: '400', // 字体粗细
      style: 'normal', // 字体样式
    },
  ],
  display: 'swap', // 字体加载策略:swap表示“先显示默认字体,加载完成后替换”(适合非首屏文本)
  // display: 'block', // 适合首屏文本:等待字体加载完成后再显示,避免FOUT
});
export default function FontDemo() {
  // 将字体类名应用到元素上
  return <p className={myFont.className}>这段文字会使用本地的“黑体”字体,且不会闪屏!</p>;
}

2. Google Fonts:一键 “召唤” 免费字体

Next.js 内置了 Google Fonts 的优化支持,无需手动引入 CSS,直接通过next/font/google加载,且会自动处理字体子集化和缓存:

import { Inter } from 'next/font/google';
// 加载Google Fonts的“Inter”字体:weight指定需要的粗细
const inter = Inter({
  weight: ['400', '700'], // 加载400(常规)和700(粗体)两种粗细
  subsets: ['latin'], // 只加载“拉丁字符”子集(适合英文网站,体积更小)
  display: 'block',
});
export default function GoogleFontDemo() {
  return (
    <div className={inter.className}>
      <h1>标题使用Inter粗体</h1>
      <p>正文使用Inter常规体,加载速度飞快!</p>
    </div>
  );
}

这里的subsets参数是性能优化的关键 —— 如果你的网站只有中文,就不要加载latin子集;反之亦然。就像点外卖时,只点自己爱吃的菜,避免浪费。

3. 全局使用:让整个网站 “统一字体风格”

若想让字体应用到整个网站,只需在pages/_app.js(Next.js 13 App Router 则在app/layout.js)中全局引入:

// pages/_app.js
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
function MyApp({ Component, pageProps }) {
  // 将字体类名应用到根元素
  return (
    <main className={inter.className}>
      <Component {...pageProps} />
    </main>
  );
}
export default MyApp;

三、双剑合璧:性能优化的 “组合拳”

单独使用Image和next/font已能解决大部分性能问题,但若将两者结合,再配合 Next.js 的其他特性(如静态生成、边缘缓存),就能打造 “极致性能” 的网页。举个实战案例:

import Image from 'next/image';
import { Noto_Sans_SC } from 'next/font/google';
// 加载中文字体“Noto Sans SC”(适合中文显示)
const notoSansSC = Noto_Sans_SC({
  weight: '400',
  subsets: ['chinese-simplified'], // 只加载简体中文字符
  display: 'block',
});
export default function BlogPost() {
  return (
    <article className={notoSansSC.className} style={{ maxWidth: '800px', margin: '0 auto' }}>
      <h1>我的旅行日记</h1>
      {/* 首屏Banner图:优先加载,响应式布局 */}
      <div style={{ position: 'relative', width: '100%', height: '300px', margin: '20px 0' }}>
        <Image
          src="/travel.jpg"
          alt="旅行风景"
          layout="fill"
          objectFit="cover"
          priority // 首屏图片优先加载
          placeholder="blur"
          blurDataURL="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
        />
      </div>
      <p>这是一篇使用Next.js优化的博客文章,图片加载流畅,字体渲染无闪屏,用户体验拉满!</p>
      {/* 非首屏图片:懒加载 */}
      <Image
        src="/food.jpg"
        alt="当地美食"
        width={800}
        height={500}
        style={{ margin: '20px 0' }}
      />
    </article>
  );
}

这个案例中:

  • next/font确保中文显示美观且无闪屏,subsets: ['chinese-simplified']让字体文件体积缩减到几十 KB;
  • Image组件让首屏 Banner 图优先加载,非首屏图片懒加载,配合模糊占位符提升体验;
  • 整体代码兼顾了性能(核心 Web 指标优化)和开发效率(无需手动处理字体子集、图片压缩)。

四、总结:优化的本质是 “尊重底层规律”

Next.js 的Image和next/font之所以强大,并非因为它们 “发明了新技术”,而是因为它们 “顺应了计算机的底层运行规律”:

  • 图片优化的核心,是 “按需分配像素资源”,避免网络带宽和设备性能的浪费;
  • 字体优化的核心,是 “提前嵌入关键资源”,避免浏览器渲染流程的中断。

就像武侠高手练功,并非凭空创造招式,而是领悟 “天地自然之道”—— 水流就下,火炎上腾,顺应规律,方能事半功倍。掌握这两门 “绝学”,不仅能让你的 Next.js 项目性能飙升,更能让你看透前端优化的本质:所有优秀的上层框架,都是对底层原理的优雅封装

现在,不妨打开你的 Next.js 项目,给图片配上Image组件,给字体换上next/font,亲眼看看这 “双剑合璧” 的威力吧!

二、React基础精讲:编写TodoList、事件绑定、JSX语法、组件之间传值

一、使用React编写TodoList功能

JSX语法:render返回元素最外层必须由一个元素包裹。 Fragment 可以作为React的最外层元素占位符。

import React, {Component, Fragment} from 'react';

class TodoList extends Component {
  render() {
    return (
      <Fragment>
        <div>
          <input/>
          <button>提交</button>
        </div>
        <ul>
          <li>1111111</li>
          <li>2222222</li>
          <li>3333333</li>
        </ul>
      </Fragment>
    )
  }
}

export default TodoList;

二、React 中的响应式设计思想和事件绑定

  1. React在创建实例的时候, constructor(){} 是最先执行的;
  2. this.state 负责存储数据;
  3. 如果修改state中的内容,不能直接改,需要通过setState向里面传入对象的形式进行修改;
  4. JSX中js表达式用{}包裹;
  5. 事件绑定需要通过bind.(this)对函数的作用域进行变更;
import React, {Component, Fragment} from 'react';

class TodoList extends Component {
  constructor(props) {
    super(props);
    this.state = {
      inputValue: '',
      list: []
    }
  }
  
  render() {
    return (
      <Fragment>
        <div>
          <input value={this.state.inputValue} onChange={this.handleChange.bind(this)}/>
          <button>提交</button>
        </div>
        <ul>
          <li>1111111</li>
          <li>2222222</li>
          <li>3333333</li>
        </ul>
      </Fragment>
    )
  }

  handleChange (e) {
    this.setState({
      inputValue: e.target.value
    })
  }
}

export default TodoList;

三、实现 TodoList 新增删除功能

import React, {Component, Fragment} from 'react';

lass TodoList extends Component {
  constructor(props) {
    super(props);
    this.state = {
      inputValue: '',
      list: ['学习英语', '学习React']
    }
  }
  
  render() {
    return (
      <Fragment>
        <div>
          <input value={this.state.inputValue} onChange={this.handleChange.bind(this)}/>
          <button onClick={this.handleBtnClick.bind(this)}>提交</button>
        </div>
        <ul>
          {this.state.list.map((item, index) => {
            return <li key={index} onClick={this.handleItemDelete.bind(this, index)}>{item}</li>
          })}
        </ul>
      </Fragment>
    )
  }
  handleChange (e) {
    this.setState({
      inputValue: e.target.value
    })
  }

  // 点击提交后,列表中添加一项,input框中内容清空
  handleBtnClick () {
    this.setState({
      list: [...this.state.list, this.state.inputValue],
      inputValue: ''
    })
  }

  // 删除
  handleItemDelete(index) {
    let list = [...this.state.list]; // 拷贝一份原数组,因为是对象,所以不能直接赋值,会有引用问题
    list.splice(index, 1);

    this.setState({
      list: list
    })

    // 以下方法可以生效,但是不建议使用。
    // React中immutable的概念:  state 不允许直接操作改变,否则会影响性能优化部分。
    
    // this.state.list.splice(index, 1);
    // this.setState({
    //   list: this.state.list
    // })
  }
}

export default TodoList;

四、JSX语法细节补充

1、在jsx语法内部添加注释:

  {/*这里是注释*/}

或者:

{
  //这里是注释
}

2、JSX语法中的属性不能和js中自带的属性和方法名冲突

元素属性class 替换成 className lable标签中的for 替换成 htmlFor

3、解析html内容

如果需要在JSX里面解析html的话,可以在标签上加上属性dangerouslySetInnerHTML属性(标签中不需要再输出item):如dangerouslySetInnerHTML={{__html: item}}

...

render() {
  return (
    <Fragment>
      {/* 这是一个注释 */}
      {
        // class 换成 className
        // for 换成 htmlFor
      }
      <div className="input">
        <lable htmlFor={"insertArea"}>请输入内容</lable>
        <input id="insertArea" value={this.state.inputValue} onChange={this.handleChange.bind(this)}/>
        <button onClick={this.handleBtnClick.bind(this)}>提交</button>
      </div>
      <ul>
        {this.state.list.map((item, index) => {
          return (
            <li
              key={index}
              onClick={this.handleItemDelete.bind(this, index)}
              dangerouslySetInnerHTML={{__html: item}}
            >
            </li>

            // <li
            //   key={index}
            //   onClick={this.handleItemDelete.bind(this, index)}
            // >
            //   {item}
            // </li>
          )
        })}
      </ul>
    </Fragment>
  )
}

...

五、拆分组件与组件之间的传值

父子组件之间通讯:

①父=>子

父组件通过属性向子组件传递数据,子组件通过this.props.属性名 获取父组件传来的数据。

②子=>父

子组件调用父组件的方法来改变父组件的数据。也是父组件通过属性把父组件对应的方法传递给子组件(在父组件向子组件传入方法时,就要绑定this,不然在子组件找不到方法),然后在子组件中通过this.props.方法名(属性名) 调用对应的父组件的方法并传递对应的参数。通过触发父组件方法改变数据,数据改变从而重新渲染页面。

父组件(todoList.js)
import React, {Component, Fragment} from 'react';
import TodoItem from './todoItem';

class TodoList extends Component {
  constructor(props) {
    super(props);
    this.state = {
      inputValue: '',
      list: ['学习英语', '学习React']
    }
  }
  
  render() {
    return (
      <Fragment>
        <div className="input">
          <label htmlFor={"insertArea"}>请输入内容</label>
          <input id="insertArea" value={this.state.inputValue} onChange={this.handleChange.bind(this)}/>
          <button onClick={this.handleBtnClick.bind(this)}>提交</button>
        </div>
        <ul>
          {this.state.list.map((item, index) => {
            return (
              <TodoItem
                key={index}
                index={index}
                item={item}
                deleteItem={this.handleItemDelete.bind(this)}
              />
            )
          })}
        </ul>
      </Fragment>
    )
  }

  handleChange (e) {
    this.setState({
      inputValue: e.target.value
    })
  }

  // 点击提交后,列表中添加一项,input框中内容清空
  handleBtnClick () {
    this.setState({
      list: [...this.state.list, this.state.inputValue],
      inputValue: ''
    })
  }

  // 删除
  handleItemDelete(index) {
    let list = [...this.state.list];
    list.splice(index, 1);

    this.setState({
      list: list
    })
  }
}

export default TodoList;
子组件(todoItem.js)
import React, { Component } from 'react';

class TodoItem extends Component {
  constructor(props) {
    super(props);
    this.handleDeleteItem = this.handleDeleteItem.bind(this);
  }

  render() {
    return (
      <li onClick={this.handleDeleteItem}>
        {this.props.item}
      </li>
    )
  }

  handleDeleteItem() {
    this.props.deleteItem(this.props.index);
  }
}

export default TodoItem;

六、TodoList 代码优化

  1. 事件方法的this指向要在constructor里面统一进行绑定,这样可以优化性能,如:this.fn = this.fn.bind(this)
  2. setState在新版的react中写成:this.setState(()=>{retuen {}}) 或 this.setState(()=>({}))。第一中写法可以在return前写js逻辑,新版的写法有一个参数prevState,可以代替修改前的this.state,同样是可以提高性能,也能避免不小心修改state导致的bug。
  3. JSX中也可以把某一块代码提出来,直接定义一个方法把内容return出来,再在JSX中引用这个方法。以达到拆分代码的目的。
import React, {Component, Fragment} from 'react';
import TodoItem from './todoItem';

class TodoList extends Component {
  constructor(props) {
    super(props);
    this.state = {
      inputValue: '',
      list: ['学习英语', '学习React']
    }
    
    // 统一在constructor中绑定this,提交性能
    this.handleChange = this.handleChange.bind(this);
    this.handleBtnClick = this.handleBtnClick.bind(this);
    this.handleItemDelete = this.handleItemDelete.bind(this);
    this.getTodoItem = this.getTodoItem.bind(this);
  }
  
  render() {
    return (
      <Fragment>
        <div className="input">
          <label htmlFor={"insertArea"}>请输入内容</label>
          <input id="insertArea" value={this.state.inputValue} onChange={this.handleChange}/>
          <button onClick={this.handleBtnClick}>提交</button>
        </div>
        <ul>
          {this.getTodoItem()}
        </ul>
      </Fragment>
    )
  }

  handleChange (e) {
    // this.setState({
    //   inputValue: e.target.value
    // })

    // 因这种写法setState是异步的,有时e.target获取不到,所以先赋值给一个变量再使用。
    const value = e.target.value;
    // 新版写法,setState不但可以接受一个对象,也可以接受一个方法
    // this.setState(() => {
    //   return {
    //     inputValue: value
    //   }
    // })

    // this.setState(()=>{retuen {}}) 简写成 this.setState(()=>({}))
    // 还可以再简写成
    this.setState(() => (
      {
        inputValue: value
      }
    ))
  }

  // 点击提交后,列表中添加一项,input框中内容清空
  handleBtnClick () {
    // this.setState({
    //   list: [...this.state.list, this.state.inputValue],
    //   inputValue: ''
    // })

    // 新版写法,可以使用prevState代替修改前的this.state,不但可以提高性能,也能避免不小心修改state导致的bug。
    this.setState((prevState) => {
      return {
        list: [...prevState.list, prevState.inputValue],
        inputValue: ''
      }
    })
  }

  // 删除
  handleItemDelete(index) {
    // let list = [...this.state.list];
    // list.splice(index, 1);

    // this.setState({
    //   list: list
    // })

    // 新版写法,可以在return前写js逻辑
    this.setState(() => {
      let list = [...this.state.list];
      list.splice(index, 1);
      return {list: list}
    })
  }

  // 把循环提取出来,放在一个方法中
  getTodoItem () {
    return this.state.list.map((item, index) => {
      return (
        <TodoItem key={index} index={index} item={item} deleteItem={this.handleItemDelete}/>
      )
    })
  }
}

export default TodoList;

七、围绕 React 衍生出的思考

1、声明式开发 可减少大量的dom操作; 对应的是命令式开发,比如jquery,操作DOM。

2、可以与其它框架并存 React可以与jquery、angular、vue等框架并存,在index.html页面,React只渲染指定id的div(如:root),只有这个div跟react有关系。

3、组件化 继承Component,组件名称第一个字母大写。

4、单向数据流 父组件可以向子组件传递数据,但子组件绝对不能改变该数据(应该调用父级传入的方法修改该数据)。

5、视图层框架 在大型项目中,只用react远远不够,一般用它来搭建视图,在作组件传值时要引入一些框架(Fux、Redux等数据层框架);

6、函数式编程 用react做出来的项目更容易作前端的自动化测试。

解锁时光机:用 React Hooks 轻松实现 Undo/Redo 功能

解锁时光机:用 React Hooks 轻松实现 Undo/Redo 功能

在日常应用开发中,撤销(Undo)和重做(Redo)功能几乎是用户体验的标配。它让用户可以大胆尝试,无需担心犯错。但你是否曾觉得实现这个功能很复杂?本文将带你深入理解一个优雅而强大的设计模式,并结合 React useReducer,手把手教你如何用最简洁的代码实现一个完整的带“时光机”功能的计数器。


思路核心:从“操作”到“状态快照”

大多数人在初次尝试实现 Undo/Redo 时,会陷入一个误区:记录操作本身。例如,我们记录下用户做了“增加”或“减少”操作。当需要撤销时,我们再根据记录反向计算出上一个状态。

这种方法看似合理,但当操作类型变得复杂时,逻辑会迅速膨胀,难以维护。

而更优雅的解决方案是:记录状态的快照。我们不关心用户做了什么,只关心每个操作发生前,状态是什么样子。这就像为每一个重要的时刻拍张照片,需要撤销时,我们直接回到上一张照片。

我们的数据模型将由三个部分组成:

  • present:当前的状态值。
  • past:一个数组,存储所有历史状态的快照。
  • future:一个数组,存储所有被撤销的状态,以便重做。

接下来,我们将基于这个思路,一步步构建我们的 React 应用。


实现详解:用 useReducer 驱动状态流转

useReducer 是一个强大的 Hook,特别适合管理复杂状态和状态间的转换。我们的“时光机”逻辑将全部封装在 reducer 函数中。

1. 初始化状态

首先,我们定义初始状态。计数器从 0 开始,past 和 future 数组都是空的。

const initialState = {
  past: [],
  present: 0,
  future: []
};

2. 处理正常操作 (increment 和 decrement)

当用户点击“增加”或“减少”按钮时,我们的 reducer 需要做两件事:

  1. 当前的 present 值,作为“历史快照”,添加到 past 数组的末尾。
  2. 更新 present 的新值。
  3. 最关键的一步:清空 future 数组。因为任何新的操作都意味着所有“重做”的历史都失效了。
if (action.type === "increment") {
  return {
    past: [...past, present], // 存储当前值到历史
    present: present + 1,     // 更新为新值
    future: []                // 新操作清空未来
  };
}

if (action.type === "decrement") {
  return {
    past: [...past, present],
    present: present - 1,
    future: []
  };
}

past: [...past, present]  这一行是整个设计的核心。我们存的不是“操作”,而是“操作前的状态值”。

3. 处理撤销操作 (undo)

撤销是“时光机”的核心功能。当用户点击“撤销”时:

  1. 当前的 present 值,移动到 future 数组的开头。这是为了以后能够“重做”这个状态。
  2. 从 past 数组中取出最后一个元素(也就是上一个状态),并将其设置为新的 present 值。我们可以使用 past.slice(0, -1) 来得到新的 past 数组,并用 past.at(-1) 获取最后一个元素。
if (action.type === "undo") {
  return {
    past: past.slice(0, -1),      // 移除最后一个历史状态
    present: past.at(-1),         // 上一个状态成为当前状态
    future: [present, ...future]  // 将当前状态存入未来
  };
}

4. 处理重做操作 (redo)

重做是撤销的逆过程。当用户点击“重做”时:

  1. 当前的 present 值,添加到 past 数组的末尾。
  2. 将 future 数组的第一个元素(即下一个状态)取出,并将其设置为新的 present 值。
  3. 移除 future 数组的第一个元素。
if (action.type === "redo") {
  return {
    past: [...past, present], // 当前状态存入历史
    present: future[0],       // 下一个未来状态成为当前状态
    future: future.slice(1)   // 移除已重做的未来状态
  };
}

完整的 React 组件代码

结合上述 reducer 逻辑,我们可以轻松构建出完整的 CounterWithUndoRedo 组件。

import * as React from "react";

const initialState = {
  past: [],
  present: 0,
  future: []
};

function reducer(state, action) {
  const { past, present, future } = state;

  if (action.type === "increment") {
    return {
      past: [...past, present],
      present: present + 1,
      future: []
    };
  }

  if (action.type === "decrement") {
    return {
      past: [...past, present],
      present: present - 1,
      future: []
    };
  }

  if (action.type === "undo") {
    // 如果没有历史记录,则不执行
    if (!past.length) {
      return state;
    }
    return {
      past: past.slice(0, -1),
      present: past.at(-1),
      future: [present, ...future]
    };
  }

  if (action.type === "redo") {
    // 如果没有未来记录,则不执行
    if (!future.length) {
      return state;
    }
    return {
      past: [...past, present],
      present: future[0],
      future: future.slice(1)
    };
  }

  throw new Error("This action type isn't supported.")
}

export default function CounterWithUndoRedo() {
  const [state, dispatch] = React.useReducer(reducer, initialState);

  const handleIncrement = () => dispatch({ type: "increment" });
  const handleDecrement = () => dispatch({ type: "decrement" });
  const handleUndo = () => dispatch({ type: "undo" });
  const handleRedo = () => dispatch({ type: "redo" });

  return (
    <div>
      <h1>Counter: {state.present}</h1>
      <button className="link" onClick={handleIncrement}>
        Increment
      </button>
      <button className="link" onClick={handleDecrement}>
        Decrement
      </button>
      <button
        className="link"
        onClick={handleUndo}
        disabled={!state.past.length} // 禁用条件past为空
      >
        Undo
      </button>
      <button
        className="link"
        onClick={handleRedo}
        disabled={!state.future.length} // 禁用条件future为空
      >
        Redo
      </button>
    </div>
  );
}

通过这种  “状态快照”  的思维方式,我们成功地将 Undo/Redo 逻辑与具体操作类型解耦。这不仅让代码变得简洁明了,更重要的是,它为未来的功能扩展奠定了坚实的基础。当你的应用变得更加复杂时,你无需修改核心的 undo 和 redo 逻辑,只需在处理新操作时,记得保存好状态快照即可。

深入剖析Redux中间件实现原理:从概念到源码

image.png

1. 引言:为什么需要中间件?

在Redux的数据流中,action是一个普通的JavaScript对象,reducer是一个纯函数。这种设计使得状态变更是可预测的,但也带来了局限性:如何处理异步操作、日志记录、错误报告等副作用?

这就是Redux中间件(Middleware)要解决的问题。中间件提供了一种机制,可以在action被分发(dispatch)到reducer之前拦截并处理它们,从而扩展Redux的功能。

中间件的常见应用场景:

  • 异步API调用(如redux-thunk, redux-saga)
  • 日志记录
  • 错误跟踪
  • 分析上报

本文将深入探讨Redux中间件的实现原理,包括其核心概念、实现机制和源码分析。


2. Redux中间件的核心概念

2.1 什么是中间件?

Redux中间件是一个高阶函数,它包装了store的dispatch方法,允许我们在action到达reducer之前进行额外处理。

2.2 中间件的签名

一个Redux中间件的标准签名是:

const middleware = store => next => action => {
  // 中间件逻辑
}

这看起来可能有些复杂,但我们可以将其分解:

  1. store:Redux store的引用
  2. next:下一个中间件或真正的dispatch方法
  3. action:当前被分发的action

2.3 中间件的执行顺序

中间件按照"洋葱模型"执行,类似于Node.js的Express或Koa框架:

action → middleware1 → middleware2 → ... → dispatch → reducer

3. 中间件的实现原理

3.1 核心思想:函数组合

Redux中间件的核心是函数组合(function composition)。多个中间件被组合成一个链,每个中间件都可以处理action并将其传递给下一个中间件。

3.2 applyMiddleware源码分析

让我们看看Redux中applyMiddleware函数的实现(简化版):

function applyMiddleware(...middlewares) {
  return (createStore) => (reducer, preloadedState) => {
    const store = createStore(reducer, preloadedState)
    let dispatch = () => {
      throw new Error(
        'Dispatching while constructing your middleware is not allowed. ' +
          'Other middleware would not be applied to this dispatch.'
      )
    }

    const middlewareAPI = {
      getState: store.getState,
      dispatch: (action, ...args) => dispatch(action, ...args)
    }
    
    // 给每个中间件注入store API
    const chain = middlewares.map(middleware => middleware(middlewareAPI))
    
    // 组合中间件:middleware1(middleware2(dispatch))
    dispatch = compose(...chain)(store.dispatch)

    return {
      ...store,
      dispatch
    }
  }
}

3.3 compose函数实现

compose函数是中间件机制的关键,它负责将多个中间件组合成一个函数:

function compose(...funcs) {
  if (funcs.length === 0) {
    return arg => arg
  }

  if (funcs.length === 1) {
    return funcs[0]
  }

  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

这个reduce操作实际上创建了一个函数管道,例如:

compose(f, g, h) 等价于 (...args) => f(g(h(...args)))

4. 中间件执行流程图

为了更好地理解中间件的执行流程,我们来看一个详细的流程图:

graph TD
    A[Component调用dispatch] --> B[中间件链入口]
    B --> C[中间件1 before逻辑]
    C --> D[调用next指向中间件2]
    D --> E[中间件2 before逻辑]
    E --> F[调用next指向中间件3]
    F --> G[...更多中间件]
    G --> H[调用next指向原始dispatch]
    H --> I[Redux真正dispatch]
    I --> J[Reducer处理action]
    J --> K[返回新状态]
    K --> L[中间件n after逻辑]
    L --> M[...更多中间件after逻辑]
    M --> N[中间件2 after逻辑]
    N --> O[中间件1 after逻辑]
    O --> P[控制权返回Component]

这个流程图展示了中间件的"洋葱模型"执行过程:action先一层层向内传递,经过所有中间件处理后,再一层层向外返回。


5. 手写实现Redux中间件

5.1 实现一个简单的日志中间件

const loggerMiddleware = store => next => action => {
  console.group(action.type)
  console.log('当前状态:', store.getState())
  console.log('Action:', action)
  
  // 调用下一个中间件或真正的dispatch
  const result = next(action)
  
  console.log('下一个状态:', store.getState())
  console.groupEnd()
  
  return result
}

5.2 实现一个异步中间件(类似redux-thunk)

const thunkMiddleware = store => next => action => {
  // 如果action是函数,执行它并传入dispatch和getState
  if (typeof action === 'function') {
    return action(store.dispatch, store.getState)
  }
  
  // 否则,直接传递给下一个中间件
  return next(action)
}

5.3 组合使用多个中间件

import { createStore, applyMiddleware } from 'redux'
import rootReducer from './reducers'

const store = createStore(
  rootReducer,
  applyMiddleware(thunkMiddleware, loggerMiddleware)
)

6. 完整示例:从零实现Redux中间件系统

让我们自己实现一个简化版的Redux,包括中间件支持:

// 简化版createStore
function createStore(reducer, enhancer) {
  if (enhancer) {
    return enhancer(createStore)(reducer)
  }
  
  let state = undefined
  const listeners = []
  
  const getState = () => state
  
  const dispatch = (action) => {
    state = reducer(state, action)
    listeners.forEach(listener => listener())
  }
  
  const subscribe = (listener) => {
    listeners.push(listener)
    return () => {
      const index = listeners.indexOf(listener)
      listeners.splice(index, 1)
    }
  }
  
  // 初始化state
  dispatch({ type: '@@INIT' })
  
  return { getState, dispatch, subscribe }
}

// applyMiddleware实现
function applyMiddleware(...middlewares) {
  return createStore => (...args) => {
    const store = createStore(...args)
    let dispatch = () => {
      throw new Error('正在构建中间件时不能dispatch')
    }
    
    const middlewareAPI = {
      getState: store.getState,
      dispatch: (...args) => dispatch(...args)
    }
    
    const chain = middlewares.map(middleware => middleware(middlewareAPI))
    dispatch = compose(...chain)(store.dispatch)
    
    return {
      ...store,
      dispatch
    }
  }
}

// 组合函数
function compose(...funcs) {
  if (funcs.length === 0) {
    return arg => arg
  }
  
  if (funcs.length === 1) {
    return funcs[0]
  }
  
  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

// 使用示例
const counterReducer = (state = 0, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return state + 1
    case 'DECREMENT':
      return state - 1
    default:
      return state
  }
}

const store = createStore(
  counterReducer,
  applyMiddleware(loggerMiddleware, thunkMiddleware)
)

7. 常见中间件库原理分析

7.1 redux-thunk原理

redux-thunk非常简单但强大,它检查action是否为函数:

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument)
    }
    
    return next(action)
  }
}

7.2 redux-saga原理

redux-saga更为复杂,它使用Generator函数和ES6的yield关键字来处理异步操作:

  • 创建一个saga中间件
  • 运行rootSaga Generator函数
  • 监听特定的action类型
  • 执行相应的副作用处理

8. 总结

Redux中间件是一个强大而灵活的概念,其核心原理可以总结为以下几点:

  1. 高阶函数:中间件是三层高阶函数的组合
  2. 函数组合:使用compose方法将多个中间件组合成执行链
  3. 洋葱模型:中间件按照添加顺序先后执行,然后再反向执行
  4. AOP编程:中间件实现了面向切面编程,在不修改原有逻辑的情况下增强功能

理解中间件的实现原理不仅有助于我们更好地使用Redux,也能帮助我们设计出更优雅的JavaScript应用程序架构。


9. 参考资料

  1. Redux官方文档 - Middleware
  2. Redux源码
  3. Express中间件

希望本文能帮助您深入理解Redux中间件的实现原理。如果有任何问题或建议,欢迎在评论区留言讨论!


列表、元组与字典:Python开发者的三大必备利器,再向高手靠近一步

朋友们好呀,欢迎继续我们的Python学习之旅!🎉 如果你刚刚接触编程,还在疑惑“Python到底是个啥”,那完全没关系。今天我们继续从最基础的地方入手,一点点揭开Python的面纱。就像我刚学编程时,连变量都看不懂,总觉得是某种“黑魔法”。后来在朋友的耐心指导下,我才发现:变量其实就是用来存数据的小盒子罢了。😅


本章目标

这一节,我们要继续打地基,把Python的核心概念啃下来。它们会成为你未来写代码的“肌肉记忆”。🛠️

本系列将逐步覆盖:

  • 字符串(Strings)
  • 列表、字典和元组(Lists, Dictionaries, and Tuples)—— 本章重点
  • 条件语句(Conditional Statements)
  • 循环(Loops)
  • 推导式(Comprehensions)
  • 异常处理(Exception Handling)
  • 文件输入输出(File I/O)
  • 导入模块和包(Importing Modules and Packages)
  • 函数(Functions)
  • 类(Classes)

看上去任务不少?别担心,我们一步步来,每个知识点都有实例和解释,轻松学,不打瞌睡。😎


📖 列表、元组与字典:Python程序员的“三剑客”

写Python时,你会发现自己仿佛在拼搭积木🧩。不同的数据类型就像不同形状的积木,而列表、元组和字典就是最常用的三块“大积木”,组合灵活,功能强大。今天我们就来搞清楚它们的用法。


1️⃣ 列表(List):百搭的工具箱

列表是Python里最常用的数据结构,能存放各种类型的东西:数字、文本,甚至还能放另一个列表。

创建列表

# 空列表
my_list = []           
my_list_alt = list()   

# 带内容的列表
my_list = [1, 2, 3]            
my_list2 = ["a""b""c"]     
my_list3 = ["Python", 3.14, True]  

print(my_list)    # [1, 2, 3]
print(my_list2)   # ['a''b''c']
print(my_list3)   # ['Python', 3.14, True]

💡 列表的包容性很强,甚至可以嵌套。

嵌套列表

nested_list = [[1, 2, 3]["a""b""c"][True, False]]
print(nested_list)         # [[1, 2, 3]['a''b''c'][True, False]]
print(nested_list[0])      # [1, 2, 3]
print(nested_list[1][2])   # 'c'

合并列表的几种方式

# 方法1:extend
combo_list = [1, 2]
combo_list.extend([34])
print(combo_list)  # [1, 2, 3, 4]

# 方法2:加号拼接
combo_list = [1, 2] + [3, 4]
print(combo_list)  # [1, 2, 3, 4]

# 方法3:循环添加
for item in [5, 6]:
    combo_list.append(item)
print(combo_list)  # [123456]

排序

nums = [34, 23, 67, 100, 88, 2]
nums.sort()
print(nums)  # [2, 23, 34, 67, 88, 100]

nums = [34, 23, 67, 100, 88, 2]
sorted_nums = sorted(nums)  # 返回新列表
print(sorted_nums)  # [2, 23, 34, 67, 88, 100]
print(nums)         # 原列表不变

降序排列:

print(sorted(nums, reverse=True))  # [100, 88, 67, 34, 23, 2]

切片操作

alpha_list = [2, 23, 34, 67, 88, 100]

print(alpha_list[0:3])   # [2, 23, 34]
print(alpha_list[::2])   # [2, 34, 88]
print(alpha_list[::-1])  # [100, 88, 67, 34, 23, 2]

2️⃣ 元组(Tuple):稳定的“石头块”

元组和列表很像,但它的特点是不可修改,更适合保存固定的数据。

创建方式

my_tuple = (123)
my_tuple2 = tuple([456])
print(my_tuple)   # (123)
print(my_tuple2)  # (456)

不可变,但能容纳可变对象

nested_tuple = (1, [23])
nested_tuple[1][0] = 99
print(nested_tuple)  # (1, [993])

互转操作

# 元组转列表
t = (123)
l = list(t)
l.append(4)
print(l)  # [1, 2, 3, 4]

# 列表转元组
print(tuple(l))  # (1234)

3️⃣ 字典(Dictionary):快速检索的宝箱

字典是一种键值对存储结构,可以通过“键”快速定位到数据。

创建字典

my_dict = {"name""Alice""age"25}
another_dict = dict(city="Beijing", population=21_540_000)

print(my_dict)       # {'name''Alice''age'25}
print(another_dict)  # {'city''Beijing''population'21540000}

访问和修改

print(my_dict["name"])   # Alice

# 新增
my_dict["job"] = "Developer"
print(my_dict)  # {'name': 'Alice''age'25'job''Developer'}

# 修改
my_dict["age"] = 26
print(my_dict)  # {'name': 'Alice''age'26'job''Developer'}

字典方法与小技巧

print("name" in my_dict)   # True
print(my_dict.keys())      # dict_keys(['name''age''job'])

⚡ 记住:"key" in my_dict 会比 "key" in my_dict.keys() 更快。


🏁 小结:继续搭建你的Python积木城堡

列表、元组和字典是Python三大“基础结构”,也是写程序时最常用的工具。有了它们,就能自由搭建属于你的“积木世界”。下章我们将学习 条件语句,让代码拥有判断力,开始有点“聪明劲儿”了!

我帮你把这篇《列表、元组与字典:Python开发者的三大法宝》整理成一个思维导图结构,你可以拿去在 XMind、MindMaster 或者 draw.io 里直接画出来:


思维引导:🧩 Python 三大法宝:列表、元组与字典

1️⃣ 列表(List)—— 万能工具箱

  • 特点:可变、能存任意类型、支持嵌套

  • 创建方式

    • []list()
    • [1, 2, 3] / ["a", "b", "c"] / 混合元素
  • 常见操作

    • 嵌套nested_list[1][2] → 'c'

    • 合并

      • extend()
      • +
      • append() 循环
    • 排序

      • list.sort()(原地)
      • sorted(list)(返回新列表,可加 reverse=True
    • 切片

      • list[0:3] → 前3个
      • list[::2] → 隔一个取一个
      • list[::-1] → 倒序

2️⃣ 元组(Tuple)—— 稳如磐石

  • 特点:不可变(但可包含可变对象)

  • 创建方式

    • (1, 2, 3)
    • tuple([4, 5, 6])
  • 特殊性

    • 不可改,但可嵌套列表并修改其中内容

    • 转换:

      • tuple → list → 修改 → tuple

3️⃣ 字典(Dictionary)—— 精准百宝箱

  • 特点:键值对存储、快速查找

  • 创建方式

    • {"name": "Alice", "age": 25}
    • dict(city="Beijing", population=21_540_000)
  • 常见操作

    • 访问:my_dict["name"]
    • 添加:my_dict["job"] = "Developer"
    • 修改:my_dict["age"] = 26
  • 方法与技巧

    • 判断键:"name" in my_dict(比 keys() 快)
    • 查看所有键:my_dict.keys()

中国央行连续第10个月增持黄金

据中国央行,中国8月末黄金储备7402万盎司,7月末黄金储备报7396万盎司,为连续第10个月增持黄金。 中国8月外汇储备 33221.54亿美元,前值32922.4亿美元。(华尔街见闻)

8月末我国外汇储备规模为33222亿美元,较7月末上升299亿美元

国家外汇管理局统计数据显示,截至2025年8月末,我国外汇储备规模为33222亿美元,较7月末上升299亿美元,升幅为0.91%。 2025年8月,受主要经济体货币政策预期、宏观经济数据等因素影响,美元指数下跌,全球金融资产价格总体上涨。汇率折算和资产价格变化等因素综合作用,当月外汇储备规模上升。我国经济运行稳中有进,展现出强大韧性和活力,为外汇储备规模保持基本稳定提供支撑。(界面新闻)
❌