普通视图

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

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

作者 168169
2025年9月7日 12:06

前情

不知道该说是公司对产品要求稿,还是公司喜欢作,最近接手公司的一个全新跨端(快抖微支+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实现了就懒得改它了

思考

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

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

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

备忘录模式(Memento Pattern)详解

2025年9月7日 11:23

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

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

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

核心角色

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

  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 端低延迟生成的技术难点与突破

作者 LeonGao
2025年9月7日 11:11

各位开发者朋友,当你在 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

2025年9月7日 11:08

一、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 的底层修炼手册

作者 LeonGao
2025年9月7日 11:07

在前端性能优化的江湖里,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语法、组件之间传值

2025年9月7日 11:03

一、使用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 功能

2025年9月7日 10:31

解锁时光机:用 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中间件实现原理:从概念到源码

作者 北辰alk
2025年9月7日 10:23

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开发者的三大必备利器,再向高手靠近一步

2025年9月7日 10:23

朋友们好呀,欢迎继续我们的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()

🔥🔥🔥Vue部署踩坑全记录:publicPath和base到底啥区别?99%的前端都搞错过!

2025年9月7日 09:38

引言

在Vue项目开发和部署过程中,很多开发者都会遇到这样的困扰:本地开发时一切正常,但项目打包部署到服务器后却出现白屏、资源加载失败、路由跳转异常等问题。这些问题的根源往往在于路径配置不当。本文将深入解析Vue项目中的两个重要配置项:vue.config.js中的publicPath和Vue Router中的base,通过实际案例帮助您彻底理解它们的区别与联系。

一、认识publicPath与base

1.1 publicPath是什么?

publicPath是Vue CLI项目中vue.config.js的配置项,用于指定应用部署的基本URL路径。它决定了打包后静态资源(JS、CSS、图片等)的引用路径。

官方定义:部署应用包时的基本 URL。用法和 webpack 本身的 output.publicPath 一致,但是 Vue CLI 在一些其他地方也需要用到这个值,所以请始终使用 publicPath 而不要直接修改 webpack 的 output.publicPath

1.2 base是什么?

base是Vue Router的配置项,用于指定应用的基路径。当单页应用部署在非根目录时,需要通过设置base来确保路由的正确解析。

官方定义:应用的基路径。例如,如果整个单页应用服务在 /app/ 下,然后 base 就应该设为 "/app/"

二、两者的核心区别

特性 publicPath base
作用对象 静态资源路径 路由路径
配置位置 vue.config.js Vue Router配置
影响范围 资源加载 路由跳转
默认值 '/' '/'
使用场景 资源引用路径 路由匹配路径

2.1 功能差异详解

publicPath主要解决的是"资源在哪里"的问题:

  • 影响打包后index.html中引用的JS、CSS等资源路径
  • 决定开发环境下静态资源的访问路径
  • 控制webpack输出资源的公共路径

base主要解决的是"路由怎么匹配"的问题:

  • 为所有路由路径添加前缀
  • 确保路由在子目录部署时能正确解析
  • 影响路由的跳转和匹配逻辑

三、实际案例分析

3.1 案例背景

假设我们有一个Vue项目,包含两个页面:

  • 首页(Home):/
  • 关于页面(About):/about

项目需要部署到服务器的子目录/my-app/下,服务器地址为http://example.com

3.2 无任何配置的问题

部署情况:项目打包后部署到http://example.com/my-app/

访问结果

  • 访问http://example.com/my-app/:页面空白,控制台报错Failed to load resource: the server responded with a status of 404
  • 资源加载路径:http://example.com/js/app.js(错误,少了/my-app/)
  • 路由跳转:点击路由链接时路径为http://example.com/about(错误,少了/my-app/)

问题分析

  1. 资源加载失败:因为publicPath默认为/,资源引用路径为绝对路径,直接从域名根目录查找
  2. 路由跳转异常:因为base默认为/,路由路径没有包含子目录前缀

3.3 只配置publicPath

配置代码

// vue.config.js
module.exports = {
  publicPath: '/my-app/'
}

部署结果

  • 资源加载:http://example.com/my-app/js/app.js(正确)
  • 路由跳转:仍然为http://example.com/about(错误)

问题分析: 资源加载问题解决了,但路由跳转仍然有问题,因为base还未配置。

3.4 只配置base

配置代码

// router/index.js
const router = new VueRouter({
  mode: 'history',
  base: '/my-app/',
  routes: [...]
})

部署结果

  • 资源加载:http://example.com/js/app.js(错误)
  • 路由跳转:http://example.com/my-app/about(正确)

问题分析: 路由跳转问题解决了,但资源加载仍然有问题,因为publicPath还未配置。

3.5 同时配置publicPath和base

完整配置

// vue.config.js
module.exports = {
  publicPath: '/my-app/'
}

// router/index.js
const router = new VueRouter({
  mode: 'history',
  base: '/my-app/',
  routes: [...]
})

部署结果

  • 资源加载:http://example.com/my-app/js/app.js(正确)
  • 路由跳转:http://example.com/my-app/about(正确)
  • 页面访问:http://example.com/my-app/(正常显示)

四、进阶配置技巧

4.1 环境区分配置

// vue.config.js
module.exports = {
  publicPath: process.env.NODE_ENV === 'production' 
    ? '/my-app/' 
    : '/'
}

// router/index.js
const router = new VueRouter({
  mode: 'history',
  base: process.env.NODE_ENV === 'production' 
    ? '/my-app/' 
    : '/',
  routes: [...]
})

4.2 使用环境变量

# .env.production
VUE_APP_PUBLIC_PATH=/my-app/
VUE_APP_ROUTER_BASE=/my-app/
// vue.config.js
module.exports = {
  publicPath: process.env.VUE_APP_PUBLIC_PATH || '/'
}

// router/index.js
const router = new VueRouter({
  mode: 'history',
  base: process.env.VUE_APP_ROUTER_BASE || '/',
  routes: [...]
})

4.3 相对路径配置

在某些特殊场景下,可以使用相对路径:

// vue.config.js
module.exports = {
  publicPath: './'
}

注意:使用相对路径有局限性,不推荐在使用HTML5 history模式或构建多页面应用时使用。

五、常见问题排查

5.1 白屏问题

症状:页面空白,控制台显示资源加载404错误 排查步骤

  1. 检查浏览器开发者工具中的Network标签
  2. 确认资源请求路径是否正确
  3. 检查publicPath配置是否与部署路径一致

5.2 路由刷新404

症状:路由跳转正常,但刷新页面显示404 原因:服务器未配置history模式Fallback 解决方案

  • Nginx配置:
location /my-app/ {
  try_files $uri $uri/ /my-app/index.html;
}

5.3 资源路径错误

症状:部分资源加载失败,路径明显错误 排查方法

  1. 检查资源引用是否使用绝对路径
  2. 确认publicPath配置正确
  3. 检查是否有硬编码的路径

六、最佳实践建议

6.1 配置原则

  1. 同时配置:子目录部署时,publicPath和base必须同时配置
  2. 保持一致:两者的路径值应该保持一致
  3. 环境区分:开发环境和生产环境使用不同配置
  4. 避免硬编码:使用环境变量管理路径配置

6.2 部署 checklist

  • 确认部署路径
  • 配置publicPath
  • 配置base
  • 检查路由模式(hash/history)
  • 配置服务器重写规则(history模式)
  • 测试资源加载
  • 测试路由跳转
  • 测试页面刷新

七、总结

理解publicPath和base的区别与联系,对于Vue项目的正确部署至关重要:

  • publicPath解决的是资源加载路径问题
  • base解决的是路由匹配路径问题
  • 子目录部署时,两者需要同时配置且保持一致
  • 通过环境变量管理不同环境的配置

掌握这两个配置项的使用,可以避免90%以上的Vue项目部署问题。希望本文能帮助您在今后的项目开发中,更加游刃有余地处理路径配置相关的挑战。

参考资料

HTTP内容类型:从基础到实战的全方位解析

作者 前端嘿起
2025年9月7日 08:27

前言

在现代Web开发中,HTTP请求内容类型(Content-Type)是一个看似简单却极其重要的概念。无论是前端开发、后端开发还是API设计,理解不同的内容类型及其应用场景都能帮助我们构建更高效、更可靠的Web应用。

这篇文章将以通俗易懂的方式,带你深入了解HTTP请求内容类型的方方面面,包括不同类型的用途、使用场景、优缺点以及实际开发中的最佳实践。无论你是刚入门的新手,还是有经验的开发者,相信都能从中获得实用的知识。

一、HTTP内容类型基础

1.1 什么是HTTP内容类型?

HTTP内容类型(Content-Type)是HTTP协议中的一个头部字段,用于描述请求或响应中携带的数据的格式。它告诉接收方应该如何解析和处理这些数据。

简单来说,当你在网上浏览网页、上传文件或发送API请求时,Content-Type就像是一个"数据说明书",告诉服务器或浏览器:"这些数据是这种格式,请用相应的方式处理它们。"

1.2 Content-Type的基本格式

Content-Type的值通常由两部分组成:主类型(primary type)和子类型(subtype),中间用斜杠(/)分隔。例如:

Content-Type: text/html
Content-Type: application/json

除了基本格式外,Content-Type还可以包含一些可选参数,最常见的是charset参数,用于指定字符编码:

Content-Type: text/html; charset=utf-8
Content-Type: application/json; charset=utf-8

二、常见的HTTP内容类型详解

2.1 文本类型

文本类型是最基础、最常见的内容类型,用于传输各种文本数据。

2.1.1 text/plain

这是最简单的文本类型,用于传输纯文本数据。它不包含任何格式信息,只是简单的字符序列。

作用类型:既可作为请求头,也可作为返回头。作为请求头时,表示客户端发送的是纯文本数据;作为返回头时,表示服务器返回的是纯文本数据。

应用场景

  • 简单的文本数据交换
  • 日志文件传输
  • 配置文件传输
  • 简单API接口的返回数据

后端配置接收: 大多数后端框架会自动识别并处理text/plain类型的数据。以下是一些常见框架的配置示例:

Node.js/Express: 不需要额外配置,可直接通过req.body获取,但需要使用body-parser中间件:

const express = require('express');
const app = express();
const bodyParser = require('body-parser');

// 解析纯文本请求体
app.use(bodyParser.text());

app.post('/api/text', (req, res) => {
  console.log('接收到的文本:', req.body);
  res.send('文本已接收');
});

Django: Django默认不处理纯文本请求体,需要自定义中间件或直接读取请求体:

from django.http import HttpResponse

def text_handler(request):
    if request.method == 'POST':
        text_content = request.body.decode('utf-8')
        print(f'接收到的文本: {text_content}')
        return HttpResponse('文本已接收')

示例

Content-Type: text/plain; charset=utf-8

Hello, world! This is a plain text message.

2.1.2 text/html

用于传输HTML(超文本标记语言)内容,是网页的基础。浏览器通过识别这个类型,知道接收到的是HTML代码,并会按照HTML规则进行解析和渲染。

作用类型:主要作为返回头使用,表示服务器返回的是HTML内容。在极少数情况下,也可作为请求头,例如前端向服务器提交HTML内容进行保存。

应用场景

  • 网页浏览
  • Web应用的前端页面
  • 服务器端渲染(SSR)的响应
  • HTML模板文件的传输

后端配置接收: 当需要接收HTML内容作为请求时,后端需要进行相应配置:

Node.js/Express: 使用body-parser中间件的text选项,并指定类型为text/html:

app.use(bodyParser.text({ type: 'text/html' }));

app.post('/api/html-content', (req, res) => {
  console.log('接收到的HTML:', req.body);
  res.send('HTML内容已接收');
});

Spring Boot: 在控制器方法中使用@RequestBody并指定MediaType:

@PostMapping(value = "/api/html-content", consumes = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> receiveHtml(@RequestBody String htmlContent) {
    System.out.println("接收到的HTML: " + htmlContent);
    return ResponseEntity.ok("HTML内容已接收");
}

示例

Content-Type: text/html; charset=utf-8

<!DOCTYPE html>
<html>
<head><title>Example</title></head>
<body><h1>Hello, world!</h1></body>
</html>

2.1.3 text/css

用于传输CSS(层叠样式表)内容,用于描述HTML或XML文档的呈现方式。浏览器接收到此类型的内容后,会将其解析为样式规则并应用于相应的文档。

作用类型:主要作为返回头使用,表示服务器返回的是CSS样式表。也可作为请求头,例如前端通过API提交自定义样式。

应用场景

  • 网页样式定义
  • 主题文件
  • 动态生成的样式表
  • 样式编辑器应用中的样式传输

后端配置接收: 当需要接收CSS内容作为请求时,后端需要进行相应配置:

Node.js/Express: 使用body-parser中间件的text选项,并指定类型为text/css:

app.use(bodyParser.text({ type: 'text/css' }));

app.post('/api/css-content', (req, res) => {
  console.log('接收到的CSS:', req.body);
  res.send('CSS内容已接收');
});

Flask: 在Flask中,可以通过request对象直接获取原始请求体:

from flask import Flask, request

app = Flask(__name__)

@app.route('/api/css-content', methods=['POST'])
def receive_css():
    if request.headers.get('Content-Type') == 'text/css':
        css_content = request.get_data(as_text=True)
        print(f'接收到的CSS: {css_content}')
        return 'CSS内容已接收'
    return '不支持的内容类型', 415

示例

Content-Type: text/css

h1 { color: blue; font-size: 24px; }
body { background-color: #f0f0f0; }

2.2 表单类型

表单类型主要用于Web表单提交数据,是前后端交互的重要方式。

2.2.1 application/x-www-form-urlencoded

这是HTML表单的默认提交方式,数据会被编码成键值对的形式。

作用类型:主要作为请求头使用,表示客户端提交的是表单数据。通常不会作为返回头使用。

特点

  • 数据以键值对(key=value)的形式组织
  • 不同键值对之间用&符号分隔
  • 特殊字符会被URL编码(如空格会变成+,其他特殊字符会变成%XX形式)
  • 数据大小有限制,不适合传输大量数据或二进制数据

应用场景

  • 简单的表单提交(如登录、注册表单)
  • 少量文本数据的提交
  • API调用中简单参数的传递

后端配置接收: 几乎所有后端框架都内置了对application/x-www-form-urlencoded的支持:

Node.js/Express: 使用express.urlencoded中间件:

app.use(express.urlencoded({ extended: true })); // extended: true 允许解析嵌套对象

app.post('/api/form', (req, res) => {
  console.log('表单数据:', req.body);
  res.json({ received: true, data: req.body });
});

PHP: 自动解析到$_POST全局变量中:

<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $username = $_POST['username'] ?? '';
    $email = $_POST['email'] ?? '';
    echo "接收到的用户名: $username, 邮箱: $email";
}

示例

Content-Type: application/x-www-form-urlencoded

username=john&email=john%40example.com&age=30

2.2.2 multipart/form-data

当表单需要上传文件或包含二进制数据时,应该使用这种类型。它可以将不同类型的数据(文本、文件等)组合成一个请求。

作用类型:主要作为请求头使用,表示客户端提交的是多部分表单数据,通常包含文件。几乎不会作为返回头使用。

特点

  • 支持同时传输文本和二进制数据
  • 每个部分都有自己的头部信息
  • 使用边界字符串(boundary)分隔不同部分
  • 可以传输大量数据和大文件

应用场景

  • 文件上传(图片、文档、视频等)
  • 包含文件的表单提交
  • 混合数据类型的提交
  • 需要传输二进制数据的API调用

后端配置接收: 需要使用专门的中间件或库来处理multipart/form-data:

Node.js/Express: 使用multer库:

const multer = require('multer');
const upload = multer({ dest: 'uploads/' }); // 设置文件存储目录

// 单个文件上传
app.post('/api/upload-single', upload.single('file'), (req, res) => {
  console.log('文件信息:', req.file);
  console.log('表单字段:', req.body);
  res.json({ success: true, filename: req.file.filename });
});

// 多个文件上传
app.post('/api/upload-multiple', upload.array('files', 5), (req, res) => {
  console.log('文件数量:', req.files.length);
  res.json({ success: true, files: req.files.map(f => f.filename) });
});

Django: 使用Django的FileUploadHandler或第三方库如django-import-export:

from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt

@csrf_exempt
def upload_file(request):
    if request.method == 'POST' and request.FILES['file']:
        uploaded_file = request.FILES['file']
        # 处理文件...
        return JsonResponse({'success': True, 'filename': uploaded_file.name})
    return JsonResponse({'success': False}, status=400)

示例

Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW

------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="username"

john
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="profile_picture"; filename="avatar.jpg"
Content-Type: image/jpeg

[二进制数据]
------WebKitFormBoundary7MA4YWxkTrZu0gW--

2.3 应用类型

应用类型涵盖了各种结构化数据格式,适用于更复杂的数据交换场景,特别是在API开发和前后端分离架构中广泛使用。

2.3.1 application/json

这是现代Web开发中最常用的数据交换格式,用于传输JSON(JavaScript Object Notation)数据。JSON格式简洁、易读,并且易于在各种编程语言中解析和生成。

作用类型:既可作为请求头,也可作为返回头。作为请求头时,表示客户端发送的是JSON数据;作为返回头时,表示服务器返回的是JSON数据。在现代Web开发中,是API通信的首选格式。

特点

  • 轻量级的数据交换格式
  • 易于人阅读和编写
  • 易于机器解析和生成
  • 支持复杂的数据结构(对象、数组、嵌套等)
  • 与JavaScript原生对象格式兼容,前端处理非常方便

应用场景

  • RESTful API的数据交换
  • 前后端分离架构中的数据传输
  • 配置文件
  • 移动应用与服务器之间的通信
  • 微服务之间的数据交换

后端配置接收: 几乎所有现代后端框架都内置了对JSON的支持:

Node.js/Express: 使用express.json()中间件:

app.use(express.json()); // 解析JSON请求体

app.post('/api/json-data', (req, res) => {
  console.log('接收到的JSON:', req.body);
  // req.body已经是解析好的JavaScript对象
  res.json({ received: true, data: req.body });
});

Spring Boot: 自动配置,使用@RequestBody注解:

@RestController
public class JsonController {
    
    @PostMapping("/api/json-data")
    public ResponseEntity<Map<String, Object>> receiveJson(@RequestBody Map<String, Object> jsonData) {
        System.out.println("接收到的JSON: " + jsonData);
        Map<String, Object> response = new HashMap<>();
        response.put("received", true);
        response.put("data", jsonData);
        return ResponseEntity.ok(response);
    }
}

示例

Content-Type: application/json

{
  "name": "John Doe",
  "email": "john@example.com",
  "age": 30,
  "hobbies": ["reading", "coding", "swimming"]
}

2.3.2 application/xml

用于传输XML(可扩展标记语言)数据,是一种更复杂但功能更强大的数据交换格式。虽然在现代Web开发中被JSON逐渐取代,但在某些特定领域仍然广泛使用。

作用类型:既可作为请求头,也可作为返回头。在企业级应用和传统系统集成中较为常见。

特点

  • 高度结构化和可扩展
  • 支持命名空间和模式验证(XSD)
  • 文档体积较大,解析相对复杂
  • 更适合复杂的结构化数据和需要严格验证的场景

应用场景

  • 企业级应用的数据交换
  • 需要严格验证的数据传输
  • SOAP Web服务
  • 遗留系统集成
  • 金融、政府等行业的标准接口

后端配置接收: 需要使用专门的XML解析库:

Node.js/Express: 使用body-parser-xml中间件:

const xmlparser = require('body-parser-xml');

app.use(xmlparser());

app.post('/api/xml-data', (req, res) => {
  console.log('接收到的XML:', req.body);
  // req.body已经是解析好的JavaScript对象
  res.type('application/xml');
  res.send('<response><status>success</status></response>');
});

Python/Flask: 使用xml.etree.ElementTree或第三方库如lxml:

from flask import Flask, request
import xml.etree.ElementTree as ET

app = Flask(__name__)

@app.route('/api/xml-data', methods=['POST'])
def receive_xml():
    if request.headers.get('Content-Type') == 'application/xml':
        xml_data = request.data.decode('utf-8')
        root = ET.fromstring(xml_data)
        # 处理XML数据...
        return '<response><status>success</status></response>', 200, {'Content-Type': 'application/xml'}
    return '不支持的内容类型', 415

示例

Content-Type: application/xml

<person>
  <name>John Doe</name>
  <email>john@example.com</email>
  <age>30</age>
  <hobbies>
    <hobby>reading</hobby>
    <hobby>coding</hobby>
    <hobby>swimming</hobby>
  </hobbies>
</person>

2.3.3 application/octet-stream

用于传输任意的二进制数据,是一种通用的二进制类型。当具体的媒体类型未知或不重要时使用。

作用类型:既可作为请求头,也可作为返回头。作为请求头时,表示客户端发送的是二进制数据;作为返回头时,表示服务器返回的是二进制数据。

特点

  • 通用性强,可以表示任何二进制数据
  • 没有特定的格式约束
  • 通常需要与Content-Disposition头部一起使用来指定文件名

应用场景

  • 未知类型的二进制文件下载
  • 原始二进制数据传输
  • 二进制协议实现
  • 加密数据传输

后端配置接收: 需要处理原始二进制数据流:

Node.js/Express: 可以直接读取请求流或使用raw中间件:

// 使用raw中间件
app.use(express.raw({ type: 'application/octet-stream', limit: '10mb' }));

app.post('/api/binary-data', (req, res) => {
  console.log('接收到的二进制数据大小:', req.body.length, 'bytes');
  // req.body是Buffer对象
  res.sendStatus(200);
});

// 或者直接处理流
app.post('/api/stream-data', (req, res) => {
  const chunks = [];
  
  req.on('data', (chunk) => {
    chunks.push(chunk);
  });
  
  req.on('end', () => {
    const binaryData = Buffer.concat(chunks);
    console.log('接收到的二进制数据大小:', binaryData.length, 'bytes');
    res.sendStatus(200);
  });
});

示例

Content-Type: application/octet-stream
Content-Disposition: attachment; filename="data.bin"

[二进制数据]

2.4 图片类型

用于传输各种格式的图片文件,是Web开发中不可或缺的内容类型。

2.4.1 image/jpeg

用于传输JPEG(Joint Photographic Experts Group)格式的图片,是互联网上最常用的图片格式之一。

作用类型:主要作为返回头使用,表示服务器返回的是JPEG图片。也可作为请求头,例如图片上传API。

特点

  • 支持有损压缩,压缩率高
  • 适合照片等复杂图像
  • 不支持透明背景

应用场景

  • 网站中的照片展示
  • 图像库应用
  • 社交媒体平台

后端配置接收: 通常与multipart/form-data一起使用,用于文件上传:

// 使用multer处理图片上传
const upload = multer({
  dest: 'uploads/',
  fileFilter: (req, file, cb) => {
    // 只接受JPEG格式
    if (file.mimetype === 'image/jpeg') {
      cb(null, true);
    } else {
      cb(new Error('只接受JPEG格式的图片'));
    }
  }
});

app.post('/api/upload-jpeg', upload.single('image'), (req, res) => {
  res.json({ success: true, filename: req.file.filename });
});

2.4.2 image/png

用于传输PNG(Portable Network Graphics)格式的图片,支持透明背景的常用图片格式。

作用类型:主要作为返回头使用,表示服务器返回的是PNG图片。也可作为请求头,例如图片上传API。

特点

  • 支持无损压缩
  • 支持透明背景(alpha通道)
  • 适合图标、图形等需要精确像素的图像

应用场景

  • 网站图标和按钮
  • 图形设计元素
  • 需要透明背景的图像

后端配置接收: 类似于JPEG,通常与multipart/form-data一起使用:

const upload = multer({
  dest: 'uploads/',
  fileFilter: (req, file, cb) => {
    if (file.mimetype === 'image/png') {
      cb(null, true);
    } else {
      cb(new Error('只接受PNG格式的图片'));
    }
  }
});

2.4.3 image/gif

用于传输GIF(Graphics Interchange Format)格式的图片,支持动画的老式图片格式。

作用类型:主要作为返回头使用,表示服务器返回的是GIF图片。

特点

  • 支持动画
  • 最多支持256种颜色
  • 文件体积相对较小
  • 支持基本的透明背景

应用场景

  • 简单动画效果
  • 表情包
  • 小型图标

后端配置接收: 类似于其他图片类型,通常使用文件上传中间件处理。

2.4.4 image/webp

用于传输WebP格式的图片,这是一种由Google开发的现代图片格式,提供更好的压缩率和质量。

作用类型:主要作为返回头使用,表示服务器返回的是WebP图片。

特点

  • 提供比JPEG和PNG更好的压缩率
  • 支持有损和无损压缩
  • 支持透明背景
  • 支持动画
  • 现代浏览器支持良好,但旧浏览器可能不支持

应用场景

  • 现代网站的图片优化
  • 移动应用的图片资源
  • 需要高质量但低带宽的场景

兼容性考虑: 由于WebP是相对较新的格式,在使用时应考虑浏览器兼容性,可以提供JPEG或PNG作为备选:

<picture>
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" alt="Example image">
</picture>

应用场景

  • 网页中的图片显示
  • 图片上传和下载
  • 图像处理应用
  • 移动应用资源

三、Content-Type在实际开发中的应用

在实际的Web开发中,正确设置和处理Content-Type是确保应用正常运行的关键因素。下面将介绍在前端和后端开发中如何正确使用Content-Type。

3.1 前端开发中的Content-Type

在前端开发中,我们经常需要设置Content-Type来发送不同类型的请求。不同的前端库和框架有不同的设置方式,下面是一些常见框架中的示例:

3.1.1 使用Fetch API

Fetch API是浏览器原生提供的HTTP请求API,使用它发送请求时需要显式设置Content-Type(除非使用FormData):

// 发送JSON数据
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    name: 'John',
    age: 30
  })
})
.then(response => {
  // 检查响应的Content-Type
  const contentType = response.headers.get('content-type');
  if (contentType && contentType.includes('application/json')) {
    return response.json();
  } else {
    throw new Error('响应不是JSON格式');
  }
})
.then(data => console.log(data))
.catch(error => console.error('请求错误:', error));

// 上传文件
const formData = new FormData();
formData.append('file', fileInput.files[0]);
formData.append('description', 'Profile picture');

fetch('https://api.example.com/upload', {
  method: 'POST',
  body: formData // 不需要设置Content-Type,浏览器会自动设置
})
.then(response => response.json())
.then(data => console.log(data));

3.1.2 使用Axios

Axios是一个流行的HTTP客户端库,它提供了更简洁的API和更多的功能。Axios会根据请求数据自动设置Content-Type,但也可以手动覆盖:

// 发送JSON数据(Axios默认Content-Type为application/json)
axios.post('https://api.example.com/data', {
  name: 'John',
  age: 30
})
.then(response => console.log(response.data))
.catch(error => console.error('请求错误:', error));

// 发送表单数据(x-www-form-urlencoded)
axios.post('https://api.example.com/login', 
  new URLSearchParams({
    username: 'john',
    password: 'secret'
  }),
  {
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    }
  }
)
.then(response => console.log(response.data));

// 发送multipart/form-data(文件上传)
const formData = new FormData();
formData.append('username', 'john');
formData.append('profile_picture', fileInput.files[0]);

axios.post('https://api.example.com/upload-profile', formData, {
  headers: {
    'Content-Type': 'multipart/form-data'
  }
})
.then(response => console.log(response.data));

3.1.3 使用jQuery Ajax

虽然jQuery在现代前端开发中使用减少,但在一些遗留项目中仍然常见:

// 发送JSON数据
$.ajax({
  url: 'https://api.example.com/data',
  type: 'POST',
  contentType: 'application/json',
  data: JSON.stringify({
    name: 'John',
    age: 30
  }),
  success: function(data) {
    console.log(data);
  },
  error: function(xhr, status, error) {
    console.error('请求错误:', error);
  }
});

// 发送表单数据
$.ajax({
  url: 'https://api.example.com/login',
  type: 'POST',
  data: $('#loginForm').serialize(), // 自动设置为application/x-www-form-urlencoded
  success: function(data) {
    console.log(data);
  }
});

3.2 后端开发中的Content-Type处理

后端服务器需要正确解析不同Content-Type的请求数据,并返回适当Content-Type的响应。下面是一些常见后端框架中的配置和处理示例:

3.2.1 Node.js/Express

在Node.js的Express框架中,处理不同的Content-Type需要使用不同的中间件:

const express = require('express');
const app = express();

// 解析JSON请求体(Content-Type: application/json)
app.use(express.json());

// 解析表单请求体(Content-Type: application/x-www-form-urlencoded)
app.use(express.urlencoded({ extended: true }));

// 处理纯文本请求体(Content-Type: text/plain)
app.use(express.text({
  type: ['text/plain', 'text/html', 'text/css'] // 可以处理多种文本类型
}));

// 处理二进制数据(Content-Type: application/octet-stream)
app.use(express.raw({
  type: 'application/octet-stream',
  limit: '10mb' // 设置大小限制
}));

// 处理JSON数据的POST请求
app.post('/api/data', (req, res) => {
  // req.body已经被解析为JavaScript对象
  console.log('接收到的JSON数据:', req.body);
  
  // 返回JSON响应(自动设置Content-Type为application/json)
  res.json({
    status: 'success',
    data: req.body
  });
});

// 处理表单数据的POST请求
app.post('/api/form', (req, res) => {
  console.log('接收到的表单数据:', req.body);
  res.json({
    status: 'success',
    received: req.body
  });
});

// 处理文件上传
const multer = require('multer');
const upload = multer({ dest: 'uploads/' });

app.post('/api/upload', upload.single('file'), (req, res) => {
  // req.file包含上传的文件信息
  console.log('上传的文件:', req.file);
  // req.body包含其他表单字段
  console.log('表单字段:', req.body);
  
  res.json({
    status: 'success',
    filename: req.file.filename
  });
});

// 提供静态文件并正确设置Content-Type
app.use(express.static('public', {
  setHeaders: (res, path, stat) => {
    // 可以根据文件扩展名自定义Content-Type
    if (path.endsWith('.md')) {
      res.setHeader('Content-Type', 'text/markdown');
    }
  }
}));

app.listen(3000, () => {
  console.log('服务器运行在端口3000');
});

3.2.2 Python/Django

Django是Python中流行的Web框架,它提供了强大的表单处理和文件上传功能:

# views.py
from django.http import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST

# 处理JSON数据
@csrf_exempt
@require_POST
def handle_json(request):
    if request.content_type == 'application/json':
        try:
            data = json.loads(request.body)
            return JsonResponse({
                'status': 'success',
                'data': data
            })
        except json.JSONDecodeError:
            return JsonResponse({
                'status': 'error',
                'message': '无效的JSON数据'
            }, status=400)
    return JsonResponse({
        'status': 'error',
        'message': '不支持的Content-Type'
    }, status=415)

# 处理表单数据(使用Django表单)
from django import forms
from django.views import View

class ContactForm(forms.Form):
    name = forms.CharField(max_length=100)
    email = forms.EmailField()
    message = forms.CharField(widget=forms.Textarea)

class ContactView(View):
    def post(self, request, *args, **kwargs):
        form = ContactForm(request.POST)
        if form.is_valid():
            # 处理表单数据
            name = form.cleaned_data['name']
            email = form.cleaned_data['email']
            message = form.cleaned_data['message']
            # ... 保存数据或发送邮件
            return JsonResponse({'status': 'success'})
        return JsonResponse({
            'status': 'error',
            'errors': form.errors
        }, status=400)

3.2.3 PHP/Laravel

Laravel是PHP中流行的Web框架,它提供了简洁的API来处理不同的Content-Type:

// routes/web.php
use Illuminate\Http\Request;

// 处理JSON数据
Route::post('/api/json', function (Request $request) {
    if ($request->isJson()) {
        $data = $request->json()->all();
        return response()->json([
            'status' => 'success',
            'data' => $data
        ]);
    }
    return response()->json([
        'status' => 'error',
        'message' => '期望JSON数据'
    ], 415);
});

// 处理表单数据
Route::post('/api/form', function (Request $request) {
    $name = $request->input('name');
    $email = $request->input('email');
    
    // 表单验证
    $validated = $request->validate([
        'name' => 'required|string|max:100',
        'email' => 'required|email'
    ]);
    
    return response()->json([
        'status' => 'success',
        'message' => '表单数据已接收'
    ]);
});

// 处理文件上传
Route::post('/api/upload', function (Request $request) {
    $request->validate([
        'file' => 'required|file|max:2048' // 最大2MB
    ]);
    
    $path = $request->file('file')->store('uploads');
    
    return response()->json([
        'status' => 'success',
        'path' => $path
    ]);
});

四、HTTP内容类型的最佳实践

正确使用HTTP内容类型可以提高应用性能、增强安全性并改善用户体验。下面是一些在实际开发中应遵循的最佳实践:

4.1 选择合适的内容类型

根据不同的应用场景,选择合适的内容类型至关重要:

  • 简单文本数据:选择text/plain
  • HTML内容:选择text/html
  • CSS样式:选择text/css
  • 表单提交(无文件):选择application/x-www-form-urlencoded
  • 包含文件的表单:选择multipart/form-data
  • API数据交换:优先选择application/json
  • XML数据交换:需要时选择application/xml
  • 二进制文件:根据实际类型选择,如image/jpegapplication/pdf等,未知类型可用application/octet-stream

4.2 设置正确的字符编码

为文本类型的内容设置正确的字符编码(通常是UTF-8)可以避免乱码问题,特别是在处理多语言内容时:

Content-Type: text/html; charset=utf-8
Content-Type: application/json; charset=utf-8
Content-Type: text/plain; charset=utf-8

注意:在现代Web开发中,UTF-8是事实上的标准编码,几乎所有浏览器和服务器都支持它。始终使用UTF-8编码可以最大程度地避免字符编码问题。

4.3 处理不同内容类型的兼容性

在开发跨平台、跨浏览器的应用时,需要考虑不同客户端对内容类型的支持情况:

  • 浏览器兼容性:对于较新的内容类型(如WebP图片),提供传统格式(如JPEG、PNG)作为备选
  • API文档:对于API,应该在文档中明确说明支持的内容类型和数据格式
  • 错误处理:在处理不支持的内容类型时,应该返回适当的HTTP错误码(如415 Unsupported Media Type)和清晰的错误信息
  • 内容协商:使用HTTP的内容协商机制(Accept和Content-Type头部)让客户端和服务器能够就使用的内容类型达成一致

内容协商示例

// Express中的内容协商示例
app.get('/api/resource', (req, res) => {
  const acceptHeader = req.headers.accept || '';
  
  if (acceptHeader.includes('application/json')) {
    // 返回JSON格式
    res.json({ data: 'resource data' });
  } else if (acceptHeader.includes('text/html')) {
    // 返回HTML格式
    res.type('text/html').send('<div>resource data</div>');
  } else {
    // 默认返回格式
    res.text('resource data');
  }
});

4.4 安全性考虑

在处理用户提交的内容时,需要注意安全问题,特别是在接收来自不可信来源的数据时:

  • 验证Content-Type:不要盲目信任用户提供的Content-Type,应该根据实际数据内容进行验证
  • 文件类型检查:对于文件上传,不仅要检查Content-Type,还要检查文件扩展名和文件头(magic numbers),防止恶意文件上传
  • 大小限制:对于大型文件或数据,应该设置合理的大小限制,避免DoS(拒绝服务)攻击
  • 内容验证:对接收的数据进行验证和清理,防止注入攻击(如XSS、SQL注入等)
  • CORS策略:合理配置跨域资源共享(CORS)策略,限制哪些域可以访问你的API和资源

安全的文件上传示例

// 使用file-type库检查实际文件类型
const FileType = require('file-type');
const upload = multer({
  dest: 'uploads/',
  limits: {
    fileSize: 5 * 1024 * 1024 // 限制5MB
  },
  fileFilter: async (req, file, cb) => {
    // 检查MIME类型(不可靠,但可以作为初步检查)
    const allowedMimes = ['image/jpeg', 'image/png', 'image/gif'];
    if (!allowedMimes.includes(file.mimetype)) {
      return cb(new Error('不支持的文件类型'));
    }
    
    // 检查实际文件类型(通过文件头)
    const chunk = await new Promise((resolve, reject) => {
      const chunks = [];
      file.stream.on('data', chunk => chunks.push(chunk));
      file.stream.on('end', () => resolve(Buffer.concat(chunks)));
      file.stream.on('error', reject);
    });
    
    const fileType = await FileType.fromBuffer(chunk.slice(0, 4100));
    if (!fileType || !['jpg', 'jpeg', 'png', 'gif'].includes(fileType.ext)) {
      return cb(new Error('文件类型不匹配或文件已损坏'));
    }
    
    cb(null, true);
  }
});

五、总结

HTTP内容类型(Content-Type)是Web开发中一个基础但关键的概念,它定义了请求和响应体中数据的格式,确保客户端和服务器能够正确地解析和处理数据。

本文全面介绍了各种常见的HTTP内容类型,包括:

  • 文本类型(如text/plain、text/html、text/css):用于传输各种文本数据
  • 表单类型(如application/x-www-form-urlencoded、multipart/form-data):用于Web表单提交数据
  • 应用类型(如application/json、application/xml、application/octet-stream):用于更复杂的数据交换场景
  • 图片类型(如image/jpeg、image/png、image/gif、image/webp):用于传输各种格式的图片文件

我们还详细讨论了Content-Type在前端和后端开发中的实际应用,以及在不同框架中如何设置和处理不同的内容类型。最后,我们分享了一些最佳实践,包括如何选择合适的内容类型、设置正确的字符编码、处理兼容性问题以及注意安全性考虑。

正确理解和应用HTTP内容类型,可以帮助你构建更高效、更可靠、更安全的Web应用。在实际开发中,我们应该根据具体的需求和场景,灵活选择和使用不同的内容类型,并始终遵循相关的最佳实践。

希望本文能够帮助你更好地掌握HTTP内容类型这一重要概念,提升你的Web开发技能!

六、扩展阅读与资源

如果你想深入了解更多关于HTTP内容类型的知识,可以参考以下资源:

  1. HTTP规范

  2. Web开发文档

  3. 相关工具

    • Postman:强大的API测试工具,可以方便地设置和查看Content-Type
    • cURL:命令行工具,用于发送HTTP请求并查看响应
  4. 安全相关

深入理解事件捕获与冒泡(详细版)

作者 bug_kada
2025年9月6日 23:57

什么是事件机制?

在前端开发中,事件机制是 JavaScript 与用户交互的核心。当用户点击按钮、滚动页面或按下键盘时,浏览器会创建事件对象,并通过一套复杂的流程确定哪些元素应该响应这些事件。这套流程就是事件机制,它主要包含两个关键阶段:事件捕获和事件冒泡。

事件绑定的演进

DOM0 级事件

早期的 HTML 允许直接在内联属性中定义事件处理:

<button onclick="handleClick()">点击我</button>

这种方式虽然简单,但将 JavaScript 与 HTML 混合,不利于维护。

DOM2 级事件

现代 JavaScript 使用 addEventListener 方法注册事件处理器:

element.addEventListener('click', handler, useCapture);

这里的第三个参数 useCapture 决定了事件处理器在捕获阶段还是冒泡阶段触发。

事件流:捕获与冒泡

当事件发生时,它会经历三个阶段的传播过程:

  1. 捕获阶段:从 window 对象向下传播到目标元素
  2. 目标阶段:到达事件目标元素
  3. 冒泡阶段:从目标元素向上传播回 window 对象

捕获阶段 (Capturing Phase)

事件从最外层的祖先元素(window)开始,逐级向下直到目标元素的父级。在这个阶段,使用 addEventListener 注册且第三个参数为 true 的事件监听器会被触发。

目标阶段 (Target Phase)

事件到达目标元素本身。注册在目标元素上的事件监听器会被触发,无论它们在捕获还是冒泡阶段注册。

冒泡阶段 (Bubbling Phase)

事件从目标元素开始,逐级向上回溯到 window 对象。在这个阶段,使用 addEventListener 注册且第三个参数为 false(默认值)的事件监听器会被触发。

代码示例

<div id="grandparent">
  <div id="parent">
    <div id="child">点击我</div>
  </div>
</div>

<script>
  const elements = ['grandparent', 'parent', 'child'];
  
  // 为所有元素注册捕获和冒泡阶段的事件监听器
  elements.forEach(id => {
    const element = document.getElementById(id);
    
    // 捕获阶段(第三个参数为true)
    element.addEventListener('click', () => {
      console.log(`捕获: ${id}`);
    }, true);
    
    // 冒泡阶段(第三个参数为false或省略)
    element.addEventListener('click', () => {
      console.log(`冒泡: ${id}`);
    }, false);
  });
</script>

当点击 "child" 元素时,控制台输出将是:

捕获: grandparent
捕获: parent
捕获: child
冒泡: child
冒泡: parent
冒泡: grandparent

事件对象的重要属性

在事件处理函数中,事件对象提供了几个重要属性:

  • event.target:最初触发事件的元素(事件起源)
  • event.currentTarget:当前正在处理事件的元素(与 this 相同)
  • event.eventPhase:指示当前所处阶段(1-捕获,2-目标,3-冒泡)

阻止事件传播

有时我们需要控制事件的传播:

element.addEventListener('click', (event) => {
  event.stopPropagation(); // 阻止事件进一步传播
  event.stopImmediatePropagation(); // 阻止事件传播并阻止同元素上其他处理器的执行
});

事件委托的应用

利用事件冒泡机制,我们可以实现事件委托(Event Delegation):

// 而不是为每个列表项单独添加事件监听器
document.getElementById('list').addEventListener('click', (event) => {
  if (event.target.tagName === 'LI') {
    console.log('点击了列表项:', event.target.textContent);
  }
});

事件委托的优点:

  • 减少内存使用(更少的事件监听器)
  • 动态添加的元素无需单独绑定事件
  • 代码更简洁易维护

实际应用建议

  1. 大多数情况下使用冒泡阶段(默认行为),因为它更符合直觉且兼容性更好
  2. 需要提前拦截事件时使用捕获阶段,例如在页面级别阻止某些操作
  3. 谨慎使用事件传播阻止,除非确实需要,因为它可能会影响其他监听器
  4. 优先使用事件委托处理动态内容或大量相似元素

与 React 事件机制的区别

需要注意的是,React 实现了自己的合成事件系统(Synthetic Event),它是对原生 DOM 事件的跨浏览器包装。虽然合成事件的行为与原生事件相似,但有一些重要区别:

  1. React 事件使用事件委托,几乎所有事件都委托到 document 对象(v17+ 改为委托到 root 组件)
  2. 事件处理函数自动绑定到组件实例
  3. 事件对象是跨浏览器兼容的包装器

事件流:深入理解事件冒泡、事件捕获与事件委托

作者 Lingxing
2025年9月6日 23:51

事件流:深入理解事件冒泡、事件捕获与事件委托

掌握事件冒泡、事件捕获和事件委托不仅能帮助我们编写更高效的代码,还能解决许多实际开发中的复杂问题。

DOM事件流:三个阶段

当一个事件发生时,它会在DOM树中经历三个不同的阶段:

  1. 事件捕获阶段:从window对象向下传播到目标元素
  2. 目标阶段:事件到达目标元素
  3. 事件冒泡阶段:从目标元素向上传播回window对象
<!DOCTYPE html>
<html>
<head>
  <title>事件流演示</title>
  <style>
    div { padding: 20px; margin: 10px; border: 1px solid #ccc; }
    #outer { background-color: #fdd; }
    #middle { background-color: #dfd; }
    #inner { background-color: #ddf; }
  </style>
</head>
<body>
  <div id="outer">外层
    <div id="middle">中间
      <div id="inner">内层</div>
    </div>
  </div>

  <script>
    function logEvent(event) {
      console.log(`${event.currentTarget.id} 触发事件: ${event.eventPhase === 1 ? '捕获' : event.eventPhase === 2 ? '目标' : '冒泡'}`);
    }

    const elements = document.querySelectorAll('div');
    
    // 注册捕获阶段事件(第三个参数为true)
    elements.forEach(elem => {
      elem.addEventListener('click', logEvent, true);
    });
    
    // 注册冒泡阶段事件(第三个参数为false或省略)
    elements.forEach(elem => {
      elem.addEventListener('click', logEvent, false);
    });
  </script>
</body>
</html>

事件冒泡 (Event Bubbling)

事件冒泡是默认的事件传播机制。当事件在目标元素上触发后,它会沿着DOM树向上传播,依次触发每个祖先元素上的同类事件。

// 事件冒泡示例
document.getElementById('inner').addEventListener('click', function() {
  console.log('内层元素被点击');
});

document.getElementById('middle').addEventListener('click', function() {
  console.log('中间元素被点击');
});

document.getElementById('outer').addEventListener('click', function() {
  console.log('外层元素被点击');
});

// 点击内层元素时,控制台将输出:
// 内层元素被点击
// 中间元素被点击
// 外层元素被点击

事件捕获 (Event Capturing)

与事件冒泡相反,事件捕获是从最外层元素开始,沿着DOM树向下传播,直到到达目标元素。

// 事件捕获示例
document.getElementById('inner').addEventListener('click', function() {
  console.log('内层元素被点击');
}, true); // 第三个参数为true,表示在捕获阶段处理

document.getElementById('middle').addEventListener('click', function() {
  console.log('中间元素被点击');
}, true);

document.getElementById('outer').addEventListener('click', function() {
  console.log('外层元素被点击');
}, true);

// 点击内层元素时,控制台将输出:
// 外层元素被点击
// 中间元素被点击
// 内层元素被点击

事件委托 (Event Delegation)

事件委托是一种利用事件冒泡机制的技术,它将事件处理程序绑定到父元素而不是每个子元素上。这种方法对于动态内容或大量元素特别有效。

<!DOCTYPE html>
<html>
<head>
  <title>事件委托演示</title>
</head>
<body>
  <ul id="itemList">
    <li data-id="1">项目 1</li>
    <li data-id="2">项目 2</li>
    <li data-id="3">项目 3</li>
    <li data-id="4">项目 4</li>
    <li data-id="5">项目 5</li>
  </ul>
  
  <button id="addButton">添加新项目</button>

  <script>
    const itemList = document.getElementById('itemList');
    const addButton = document.getElementById('addButton');
    let counter = 5;

    // 使用事件委托处理所有li的点击事件
    itemList.addEventListener('click', function(event) {
      // 检查点击的元素是否是LI或者是LI的子元素
      let target = event.target;
      while (target && target !== itemList) {
        if (target.tagName === 'LI') {
          console.log(`点击了项目: ${target.textContent}, ID: ${target.dataset.id}`);
          // 可以在这里添加具体的处理逻辑
          target.classList.toggle('selected');
          break;
        }
        target = target.parentNode;
      }
    });

    // 添加新项目
    addButton.addEventListener('click', function() {
      counter++;
      const newItem = document.createElement('li');
      newItem.textContent = `项目 ${counter}`;
      newItem.dataset.id = counter;
      itemList.appendChild(newItem);
    });
  </script>
</body>
</html>

实际应用场景

1. 阻止事件传播

// 阻止事件冒泡
document.getElementById('inner').addEventListener('click', function(event) {
  console.log('内层元素被点击,但不会冒泡');
  event.stopPropagation();
});

// 阻止默认行为并阻止事件传播
document.getElementById('myLink').addEventListener('click', function(event) {
  event.preventDefault();
  event.stopPropagation();
  console.log('链接被点击,但不会跳转也不会冒泡');
});

2. 性能优化:大量元素处理

// 传统方式:为每个元素绑定事件(性能差)
const items = document.querySelectorAll('.item');
items.forEach(item => {
  item.addEventListener('click', handleClick);
});

// 事件委托方式:只需一个事件处理程序(性能好)
document.getElementById('container').addEventListener('click', function(event) {
  if (event.target.classList.contains('item')) {
    handleClick(event);
  }
});

3. 动态内容处理

// 对于动态添加的元素,事件委托仍然有效
function addNewItem(text) {
  const newItem = document.createElement('div');
  newItem.className = 'item';
  newItem.textContent = text;
  document.getElementById('container').appendChild(newItem);
  
  // 不需要为新元素单独绑定事件处理程序
  // 父元素上的事件委托会自动处理
}

// 初始化容器的事件委托
document.getElementById('container').addEventListener('click', function(event) {
  if (event.target.classList.contains('item')) {
    console.log('点击了项目:', event.target.textContent);
  }
});

总结

经过十年的开发经验,我深刻体会到:

  1. 事件冒泡是默认的机制,适用于大多数场景
  2. 事件捕获在某些特定场景下非常有用,但使用较少
  3. 事件委托是优化性能和处理动态内容的强大技术
  4. 理解事件流可以帮助我们更好地控制事件处理顺序和行为

掌握这些概念不仅能让代码更加高效,还能解决许多复杂的前端交互问题。希望这篇文章能帮助你更深入地理解DOM事件流的工作原理和实际应用。

2025年,跟 encodeURIComponent() 说再见吧

作者 傻梦兽
2025年9月6日 23:40

说起来你可能不信,很长时间以来,我们这些 JavaScript 程序员就像是在用石器时代的工具——encodeURIComponent() 来确保 URL 查询参数的安全性。说它能用吧,确实能用……但就是让人用得不爽。

想象一下,你每次都得把动态数据包在 encodeURIComponent() 里,然后手动拼接字符串,还得反复检查每个 & 和 ? 有没有写对。就像是用算盘算账一样,虽然能算出结果,但过程实在太痛苦了。

幸好,现代的 URL API 给了我们一个更清爽、更安全的选择。咱们一起来看看吧!

手动编码的噩梦

假设你正在为一个商品搜索页面构建链接,传统的做法是这样的:

const keyword = "coffee & cream";
const category = "beverages";

const url =
  "https://shop.example.com/search?query=" +
  encodeURIComponent(keyword) +
  "&cat=" +
  encodeURIComponent(category);

console.log(url);
// "https://shop.example.com/search?query=coffee%20%26%20cream&cat=beverages"

看到了吗?这代码就像是在搭积木,一块一块地拼,稍微不小心就会出错。忘记 encodeURIComponent() 的话,URL 直接就废了。

更干净的方法:new URL()

有了现代的 URL API,我们就不用操心编码的细节了:

const url = new URL("https://shop.example.com/search");
url.searchParams.set("query""coffee & cream");
url.searchParams.set("cat""beverages");

console.log(url.toString());
// "https://shop.example.com/search?query=coffee+%26+cream&cat=beverages"

是不是清爽多了?就像是从手洗衣服升级到了洗衣机——省心又高效。

为什么这样更好?

用 URL API 有这些好处:

自动编码 → 不用担心特殊字符,API 会自动处理
更易读 → 代码逻辑一目了然,不用在一堆字符串拼接中找 bug
灵活性 → 随时添加、更新或删除参数,不用重写字符串
通用支持 → 现代浏览器和 Node.js 都支持

不只是搜索查询

你可以用这个方法处理各种动态链接。比如构建一个天气预报的 URL,包含多个参数:

const url = new URL("https://api.weather.com/forecast");
url.searchParams.set("city""Los Angeles");
url.searchParams.set("unit""imperial");
url.searchParams.set("days"5);

console.log(url.toString());
// "https://api.weather.com/forecast?city=Los+Angeles&unit=imperial&days=5"

如果用 encodeURIComponent() 来做,代码会长很多,还更难读懂。

现在你可以忘掉的东西

  • • 把每个值都包在 encodeURIComponent()  里
  • • 用 + "&param=" + 拼接字符串
  • • 担心漏掉分隔符(? 和 &
  • • 处理特殊字符和空格
  • • 调试那些看起来像外星文的 URL 字符串

最后的话

还记得我们从 callback 过渡到 async/await 的时候吗?现在是时候和 encodeURIComponent() 说再见了。

URL 和 URLSearchParams API 为你提供了一种现代、优雅的方式来构建安全、可读且易于维护的链接。

下次生成 URL 的时候,跳过那些老套路,让 new URL() 来处理复杂的部分吧!

就像换了新手机一样,用过新方法之后,你绝对不想再回到石器时代了。

JavaScript 入门精要:从变量到对象,构建稳固基础

作者 San30
2025年9月6日 22:28

本文系统梳理 JavaScript 核心概念,涵盖数据类型、变量声明、对象操作等基础知识,助你打下坚实 JS 基础。

一、代码书写位置与基本语法

在浏览器环境中,JavaScript 代码有两种书写方式:

<!-- 方式1:直接写在script标签中 -->
<script>
  console.log("这是内部JS代码");
</script>

<!-- 方式2:引用外部JS文件(推荐) -->
<script src="script.js"></script>

推荐使用外部文件的原因在于代码分离原则:内容(HTML)、样式(CSS)、功能(JS)三者分离,更易于维护和阅读。

注意

  • 页面中可以存在多个 script 元素,执行顺序为从上到下
  • script 元素引用了外部文件,其内部不能再书写任何代码
  • type 属性为可选属性,用于指定代码类型

基本语法规则

// 语句以分号结束(非强制但建议)
let name = "张三";

// 大小写敏感
let age = 20;
let Age = 30; // 这是不同的变量

// 代码从上到下执行
console.log("第一行");
console.log("第二行");

输入输出语句

需要注意的是,所有的输入输出语句都不是 ES 标准。

// 输出语句示例
document.write("这是页面输出"); // 输出到页面
alert("这是弹窗提示"); // 弹窗显示
console.log("这是控制台输出"); // 输出到控制台

// 输入语句示例
let userAge = prompt("请输入你的年龄"); // 获取用户输入
console.log("用户年龄是:" + userAge);

注释的使用

注释是提供给代码阅读者使用的,不会参与执行。

// 这是单行注释

/*
  这是多行注释
  可以跨越多行
*/

// 实际代码
let score = 100; // 设置分数为100

VScode 快捷键

  • Ctrl + /: 快速添加/取消单行注释
  • Alt + Shift + A: 快速添加/取消多行注释

二、数据类型与字面量

数据是指有用的信息,数据类型则是数据的分类。

原始类型(基本类型)

原始类型指不可再细分的类型:

// 1. 数字类型 (number)
let integer = 100; // 整数
let float = 3.14; // 浮点数
let hex = 0xff; // 十六进制:255
let octal = 0o10; // 八进制:8
let binary = 0b1100; // 二进制:12

// 2. 字符串类型 (string)
let str1 = 'Hello'; // 单引号
let str2 = "World"; // 双引号
let str3 = `Hello 
World`; // 模板字符串,可以换行

// 转义字符
let escaped = "这是第一行\n这是第二行\t这里有一个制表符";

// 3. 布尔类型 (boolean)
let isTrue = true; // 真
let isFalse = false; // 假

// 4. undefined 类型
let notDefined; // 值为undefined
console.log(notDefined); // 输出: undefined

// 5. null 类型
let empty = null; // 表示空值

引用类型

// 对象 (Object)
let person = {
  name: "张三",
  age: 25,
  isStudent: false
};

// 函数 (Function)
function sayHello() {
  console.log("Hello!");
}

获取数据类型

使用 typeof 运算符可以获取数据的类型。

console.log(typeof 100); // "number"
console.log(typeof "Hello"); // "string"
console.log(typeof true); // "boolean"
console.log(typeof undefined); // "undefined"
console.log(typeof null); // "object" (注意这是JS的著名特性)

注意typeof null 得到的是 Object,这是 JavaScript 的一个著名特性(或称为 bug)。

字面量(常量)

直接书写的具体数据称为字面量

// 数字字面量
123
3.14

// 字符串字面量
"Hello"
'World'

// 布尔字面量
true
false

// 对象字面量
{ name: "张三", age: 25 }

// 数组字面量
[1, 2, 3, 4]

三、变量的声明与使用

变量是一块内存空间,用于保存数据。

变量的使用步骤

  1. 声明变量

    var message; // 使用var声明(旧方式)
    let count; // 使用let声明(推荐)
    const PI = 3.14; // 使用const声明常量
    

    变量声明后,其值为 undefined

  2. 变量赋值

    let name; // 声明
    name = "张三"; // 赋值
    
    let age = 25; // 声明并赋值
    
  3. 声明与赋值合并

    let name = "张三", age = 25, isStudent = true; // 多个变量声明
    

    这是语法糖——仅为方便代码书写或记忆,不会有实质性改变。

变量命名规范(标识符)

标识符是需要自行命名的位置,遵循以下规范:

// 合法标识符
let userName;
let _privateData;
let $element;
let data2023;

// 不合法标识符
// let 123abc; // 不能以数字开头
// let user-name; // 不能包含连字符
// let let; // 不能使用关键字

驼峰命名法

  • 大驼峰:每个单词首字母大写 UserName
  • 小驼峰:除第一个单词外,首字母大写 userName

目前常用的是小驼峰命名法。

重要特性

  • 变量提升: var变量的声明会自动提升到代码最顶部(但不超越脚本块)
// 变量提升示例
console.log(x); // undefined (不会报错)
var x = 5;
console.log(x); // 5

// 注意:let和const不存在变量提升
// console.log(y); // 报错
let y = 10;

// 重复声明
var z = 1;
var z = 2; // 允许重复声明
console.log(z); // 2

let w = 1;
// let w = 2; // 报错,不能重复声明
  • 任何可以书写数据的地方都可以书写变量
  • 使用未声明的变量会导致错误(例外:typeof 未声明的变量得到 undefined
  • 变量提升: 所有使用var声明的变量会自动提升到代码最顶部(但不超越脚本块)
  • JS 允许使用var定义多个同名变量(提升后会合并为一个)

四、变量与对象操作

在变量中存放对象

// 创建对象
let person = {
  name: "张三",
  age: 25,
  "favorite-color": "blue" // 属性名包含特殊字符
};

// 1. 读取对象属性
console.log(person.name); // "张三"
console.log(person.age); // 25
console.log(person.hobby); // undefined (属性不存在)
// console.log(nullObj.name); // 报错 (对象不存在)

// 2. 更改对象属性
person.age = 26; // 修改属性
person.hobby = "读书"; // 添加新属性

// 3. 删除属性
delete person.age;
console.log(person.age); // undefined

// 4. 属性表达式
console.log(person["name"]); // "张三"
let propName = "age";
console.log(person[propName]); // 26 (动态访问属性)
console.log(person["favorite-color"]); // "blue" (访问含特殊字符的属性)

注意: JS 对属性名的命名不严格,属性可以是任何形式的名字,但属性名只能是字符串(数字会自动转换为字符串)。

全局对象

// 浏览器环境中
console.log(window); // 全局对象

// 开发者定义的变量会成为window对象的属性
var globalVar = "我是全局变量";
console.log(window.globalVar); // "我是全局变量"

// 但使用let/const声明的变量不会添加到window对象
let localVar = "我是局部变量";
console.log(window.localVar); // undefined
  • 浏览器环境中,全局对象为 window(表示整个窗口)
  • 全局对象的所有属性可直接使用,无需写上全局对象名
  • 使用var定义的变量实际上会成为 window 对象的属性
  • 使用let/const定义的变量不会添加到window对象

五、引用类型的深层理解

// 原始类型存放具体值
let a = 10;
let b = a; // b是a的值副本
a = 20;
console.log(a); // 20
console.log(b); // 10 (值不变)

// 引用类型存放内存地址
let obj1 = { value: 10 };
let obj2 = obj1; // obj2和obj1指向同一对象
obj1.value = 20;
console.log(obj1.value); // 20
console.log(obj2.value); // 20 (值也跟着变了)

// 对象字面量每次都会创建新对象
let o1 = {};
let o2 = {}; // 这是两个不同的对象
console.log(o1 === o2); // false
  • 原始类型变量存放的是具体值
  • 引用类型变量存放的是内存地址
  • 凡是出现对象字面量的位置(两个大括号),都会在内存中创建一个新对象

拓展知识:垃圾回收

JavaScript 拥有自动垃圾回收机制:

function createObject() {
  let obj = { value: 100 }; // 对象被创建
  return obj;
}

let myObj = createObject(); // 对象被引用,不会被回收
myObj = null; // 对象不再被引用,将成为垃圾被回收
  • 垃圾回收器会定期发现内存中无法访问的对象
  • 这些对象被称为垃圾
  • 垃圾回收器会在合适的时间释放它们占用的内存

结语

掌握 JavaScript 的基础概念是成为优秀开发者的第一步。从变量声明到对象操作,从数据类型到内存管理,这些基础知识构成了 JavaScript 编程的基石。建议初学者多加练习,深入理解每个概念背后的原理,为后续学习更高级的 JavaScript 特性打下坚实基础。

学习建议:多动手实践,尝试不同的代码组合,使用控制台查看结果,加深对每个知识点的理解。

// 实践示例:创建一个简单的个人信息对象
let person = {
  name: prompt("请输入你的姓名"),
  age: parseInt(prompt("请输入你的年龄")),
  hobbies: ["读书", "运动", "音乐"]
};

console.log("姓名:", person.name);
console.log("年龄:", person.age);
console.log("爱好数量:", person.hobbies.length);

// 动态添加新属性
let newHobby = prompt("请添加一个新爱好");
person.hobbies.push(newHobby);

console.log("更新后的爱好:", person.hobbies);

通过这样的实践,你可以更好地理解JavaScript对象和变量的工作方式。

面试取经:浏览器篇-跨标签页通信

2025年9月6日 22:09

什么是跨标签页通信

标签页之间可以进行数据传递

业内常见方案

  • BroadCast Channel
  • Service Worker
  • LocalStorage window.onstorage 监听
  • Shared Worker 定时器轮询
  • IndexDB 定时器轮询
  • cookie 定时器轮询
  • window.open、window.postMessage
  • Websocket

BroadCast Channel

BroadCast Channel 可以帮我们创建一个用于广播的通信频道。当所有页面都监听同一频道的消息时,其中某一个页面通过它发送的消息就会被其他所有页面收到。但是前提是同源页面

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面A</title>
  </head>
  <body>
    <input type="text" id="content" />
    <button id="btn">发送数据</button>

    <script>
      const input = document.querySelector("#content")
      const btn = document.querySelector("#btn")

      // 创建一个名:b1的通信通道
      const bc = new BroadcastChannel("b1")

      btn.onclick = function () {
        // 发送消息
        bc.postMessage({
          value: input.value,
        })
      }
    </script>
  </body>
</html>

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
  </head>
  <body>
    <h1 id="content"></h1>
    <script>
      const content = document.querySelector("#content")

      // 创建一个名:b1的通信通道, 与之前创建的名称保持一致
      const bc = new BroadcastChannel("b1")
      // 监听消息
      bc.onmessage = function (message) {
        console.log(message.data.value)
        content.innerHTML = message.data.value
      }
    </script>
  </body>
</html>

Service Worker

Service Worker 实际上是浏览器和服务器之间的代理服务器,它最大的特点是在页面中注册并安装成功后,运行于浏览器后台,不受页面刷新的影响,可以监听和截拦作用域范围内所有页面的 HTTP 请求。

Service Worker 的目的在于离线缓存,转发请求和网络代理。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面A</title>
  </head>
  <body>
    <input type="text" id="content" />
    <button id="btn">发送数据</button>

    <script>
      const input = document.querySelector("#content")
      const btn = document.querySelector("#btn")

      // 注册 sw
      const sw = navigator.serviceWorker
      console.log(sw)

      navigator.serviceWorker.register("./sw.js").then(() => {
        console.log("sw注册成功")
      })
      btn.onclick = function () {
        // 发送消息
        navigator.serviceWorker.controller.postMessage({
          value: input.value,
        })
      }
    </script>
  </body>
</html>

self.addEventListener("message",async event=>{
    const clients = await self.clients.matchAll();
    clients.forEach(function(client){
        client.postMessage(event.data.value)
    });
});
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
  </head>
  <body>
    <h1 id="content"></h1>
    <script>
      const content = document.querySelector("#content")
      // 注册 sw
      const sw = navigator.serviceWorker
      navigator.serviceWorker.register("./sw.js").then(() => {
        console.log("sw注册成功")
      })
      // 监听消息
      navigator.serviceWorker.onmessage = function ({data}) {
        content.innerHTML = data
      }
    </script>
  </body>
</html>

LocalStorage window.onstorage 监听

Web Storage 中,每次将一个值存储到本地存储时,就会触发一个 storage 事件。

由事件监听器发送给回调函数的事件对象有几个自动填充的属性如下:

  • key:告诉我们被修改的条目的键。
  • newValue:告诉我们被修改后的新值。
  • oldValue:告诉我们修改前的值。
  • storageArea:指向事件监听对应的 Storage 对象。
  • url:原始触发 storage 事件的那个网页的地址。

注意:这个事件只在同一域下的任何窗口或者标签上触发,并且只在被存储的条目改变时触发。

示例如下:这里我们需要打开服务器进行演示,本地文件无法触发 storage 事件

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面A</title>
  </head>
  <body>
    <script>
      localStorage.name = "john"
      localStorage.age = "18"
      console.log("信息设置完毕")
    </script>
  </body>
</html>

在上面的代码中,我们在该页面下设置了两个 localStorage 本地数据。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
  </head>
  <body>
    <script>
      window.onstorage = function (e) {
        console.log("修改的键为", e.key)
        console.log("旧值", e.oldValue)
        console.log("新值", e.newValue)
        console.log(e.storageArea)
        console.log(e.url)
      }
    </script>
  </body>
</html>

在该页面中我们安装了一个 storage 的事件监听器,安装之后只要是同一域下面的其他 storage 值发生改变,该页面下面的 storage 事件就会被触发。

Shared Worker 定时器轮询( setInterval

SharedWorker 接口代表一种特定类型的 worker,可以从几个浏览上下文中访问,例如几个窗口、iframe 或其他 worker。它们实现一个不同于普通 worker 的接口,具有不同的全局作用域,如果要使 SharedWorker 连接到多个不同的页面,这些页面必须是同源的(相同的协议、host 以及端口)

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面A</title>
  </head>
  <body>
    <input type="text" id="content" />
    <button id="btn">发送数据</button>

    <script>
      const input = document.querySelector("#content")
      const btn = document.querySelector("#btn")

      const worker = new SharedWorker('worker.js')

      btn.onclick = function () {
        // 发送消息
        worker.port.postMessage({
          value: input.value,
        })
      }
    </script>
  </body>
</html>

let data = ''
onconnect = function(e){
    const port = e.ports[0]
    port.onmessage = function(e){
        if(e.data === 'get'){
            port.postMessage(data)
            data = ''
        }else{
            data = e.data
        }
    }
}
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
  </head>
  <body>
    <h1 id="content"></h1>
    <script>
      const content = document.querySelector("#content")
      const worker = new SharedWorker("worker.js")
      worker.port.start()
      // 监听消息

      worker.port.onmessage = function (e) {
        if (e.data) {
          content.innerHTML = e.data.value
        }
      }
      setInterval(() => {
        worker.port.postMessage("get")
      }, 1000)
    </script>
  </body>
</html>

IndexedDB 定时器轮询( setInterval

IndexedDB 是一种底层 API,用于在客户端存储大量的结构化数据(也包括文件/二进制大型对象(blobs))。该 API 使用索引实现对数据的高性能搜索。

通过对 IndexedDB 进行定时器轮询的方式,我们也能够实现跨标签页的通信。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面A</title>
  </head>
  <body>
    <h1>新增学生</h1>
    <div>
      <span>学号</span>
      <input type="text" name="stuId" id="stuId" />
    </div>
    <div>
      <span>姓名</span>
      <input type="text" name="stuName" id="stuName" />
    </div>
    <div>
      <span>年龄</span>
      <input type="text" name="stuAge" id="stuAge" />
    </div>
    <button id="btn">提交</button>
    <script src="./db.js"></script>
    <script>
      const btn = document.querySelector("#btn")
      let stuId = document.querySelector("#stuId")
      console.log(stuId.value)

      let stuName = document.querySelector("#stuName")
      let stuAge = document.querySelector("#stuAge")

      btn.onclick = function () {
        openDB("stuDB", 1).then(db => {
          addData(db, "stu", {
            stuId: stuId.value,
            stuName: stuName.value,
            stuAge: stuAge.value,
          })
          stuId.value = stuName.value = stuAge.value = ""
        })
      }
    </script>
  </body>
</html>

// db.js
/**
 * 打开数据库
 * @param {object} dbName 数据库的名字
 * @param {string} storeName 仓库名称
 * @param {string} version 数据库的版本
 * @return {object} 该函数会返回一个数据库实例
 */
function openDB(dbName, version = 1) {
  return new Promise((resolve, reject) => {
    var db // 存储创建的数据库
    // 打开数据库,若没有则会创建
    const request = indexedDB.open(dbName, version)

    // 数据库打开成功回调
    request.onsuccess = function (event) {
      db = event.target.result // 存储数据库对象
      console.log("数据库打开成功")
      resolve(db)
    }

    // 数据库打开失败的回调
    request.onerror = function (event) {
      console.log("数据库打开报错")
    }

    // 数据库有更新时候的回调
    request.onupgradeneeded = function (event) {
      // 数据库创建或升级的时候会触发
      console.log("onupgradeneeded")
      db = event.target.result // 存储数据库对象
      var objectStore
      // 创建存储库
      objectStore = db.createObjectStore("stu", {
        keyPath: "stuId", // 这是主键
        autoIncrement: true, // 实现自增
      })
      // 创建索引,在后面查询数据的时候可以根据索引查
      objectStore.createIndex("stuId", "stuId", { unique: true })
      objectStore.createIndex("stuName", "stuName", { unique: false })
      objectStore.createIndex("stuAge", "stuAge", { unique: false })
    }
  })
}

/**
 * 新增数据
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {string} data 数据
 */
function addData(db, storeName, data) {
  var request = db
    .transaction([storeName], "readwrite") // 事务对象 指定表格名称和操作模式("只读"或"读写")
    .objectStore(storeName) // 仓库对象
    .add(data)

  request.onsuccess = function (event) {
    console.log("数据写入成功")
  }

  request.onerror = function (event) {
    console.log("数据写入失败")
  }
}

/**
 * 通过主键读取数据
 * @param {object} db 数据库实例
 * @param {string} storeName 仓库名称
 * @param {string} key 主键值
 */
function getDataByKey(db, storeName, key) {
  return new Promise((resolve, reject) => {
    var transaction = db.transaction([storeName]) // 事务
    var objectStore = transaction.objectStore(storeName) // 仓库对象
    var request = objectStore.getAll() // 通过主键获取数据

    request.onerror = function (event) {
      console.log("事务失败")
    }

    request.onsuccess = function (event) {
      // console.log("主键查询结果: ", request.result);
      resolve(request.result)
    }
  })
}

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
    <style>
      table {
        border: 1px solid;
        border-collapse: collapse;
      }
      table td {
        border: 1px solid;
      }
    </style>
  </head>
  <body>
    <h1>学生表</h1>
    <table id="table">
      <!-- <tr>
            <td>学号</td>
            <td>姓名</td>
            <td>年龄</td>
        </tr>
        <tr>
            <td>1</td>
            <td>john</td>
            <td>18</td>
        </tr>
        <tr>
            <td>2</td>
            <td>tom</td>
            <td>20</td>
        </tr> -->
    </table>
    <script src="./db.js"></script>
    <script>
      function init() {
        openDB("stuDB", 1).then(db => {
          addData(db, "stu", { stuId: 1, stuName: "john", stuAge: 18 })
          addData(db, "stu", { stuId: 2, stuName: "tom", stuAge: 18 })
          addData(db, "stu", { stuId: 3, stuName: "jane", stuAge: 18 })
        })
      }
      function render(arr) {
        let tab = document.querySelector("#table")
        tab.innerHTML = `
            <tr>
            <td>学号</td>
            <td>姓名</td>
            <td>年龄</td>
        </tr>
            `
        let str = arr
          .map(item => {
            return `
        <tr>
            <td>${item.stuId}</td>
            <td>${item.stuName}</td>
            <td>${item.stuAge}</td>
        </tr>`
          })
          .join("")

        tab.innerHTML += str
      }
      async function renderTable() {
        let db = await openDB("stuDB", 1)
        let stuInfo = await getDataByKey(db, "stu")
        render(stuInfo)

        setInterval(async () => {
          let stuInfo2 = await getDataByKey(db, "stu")
          if (stuInfo2.length !== stuInfo.length) {
            render(stuInfo2)
          }
        }, 1000)
      }
      //   init()
      renderTable()
    </script>
  </body>
</html>

cookie 定时器轮询( setInterval

我们同样可以通过定时器轮询的方式来监听 Cookie 的变化,从而达到一个多标签页通信的目的。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>页面A</title>
</head>
<body>
    <script>
        document.cookie = 'name=john'
        console.log("coookie设置成功")
    </script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
  </head>
  <body>
    <script>
      let cookie = document.cookie
      setInterval(() => {
        if (document.cookie !== cookie) {
          console.log("cookie发生了变化", document.cookie)
          cookie = document.cookie
        }
      }, 1000)
    </script>
  </body>
</html>

window.open、window.postMessage

MDN 上是这样介绍 window.postMessage 的:

window.postMessage( ) 方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为https),端口号(443为https的默认值),以及主机 (两个页面的模数 Document.domain设置为相同的值) 时,这两个脚本才能相互通信。window.postMessage( ) 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。

从广义上讲,一个窗口可以获得对另一个窗口的引用(比如 targetWindow = window.opener),然后在窗口上调用 targetWindow.postMessage( ) 方法分发一个 MessageEvent 消息。接收消息的窗口可以根据需要自由处理此事件 (en-US)。传递给 window.postMessage( ) 的参数(比如 message )将通过消息事件对象暴露给接收消息的窗口。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面A</title>
  </head>
  <body>
    <button id="popBtn">弹出新窗口</button>
    <input type="text" id="content" />
    <button id="btn">发送数据</button>
    <script>
      const popBtn = document.querySelector("#popBtn")
      const input = document.querySelector("#content")
      const btn = document.querySelector("#btn")

      let opener = null
      popBtn.onclick = function () {
        opener = window.open(
          "2.html",
          "标题",
          "height=400,width=400,top=20,resizeable=yes"
        )
      }

      btn.onclick = function () {
        let data = {
          value: input.value,
        }
        // data 代表要发送的数据,*代表所有域
        opener.postMessage(data, "*")
      }
    </script>
  </body>
</html>
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>页面B</title>
  </head>
  <body>
    <h1>这是页面B</h1>
    <div>
      <span>接收到数据:</span>
      <p id="content"></p>
    </div>
    <script>
      const content = document.querySelector("#content")
      window.addEventListener("message", function (e) {
        content.innerHTML = e.data.value
      })
    </script>
  </body>
</html>

Websocket

WebSocket 协议在 2008 年诞生,2011 年成为国际标准。所有浏览器都已经支持了。

它的最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话,属于服务器推送技术的一种。

server.js

// 初始化一个 node 项目 npm init -y
// 安装依赖 npm i -save ws

// 获得 WebSocketServer 类型
var WebSocketServer = require('ws').Server;

// 创建 WebSocketServer 对象实例,监听指定端口
var wss = new WebSocketServer({
    port: 8080
});

// 创建保存所有已连接到服务器的客户端对象的数组
var clients = [];

// 为服务器添加 connection 事件监听,当有客户端连接到服务端时,立刻将客户端对象保存进数组中
wss.on('connection', function (client) {
    // 如果是首次连接
    if (clients.indexOf(client) === -1) {
        // 就将当前连接保存到数组备用
        clients.push(client)
        console.log("有" + clients.length + "客户端在线");

        // 为每个 client 对象绑定 message 事件,当某个客户端发来消息时,自动触发
        client.on('message', function (msg) {
            console.log(msg, typeof msg);
            console.log('收到消息' + msg)
            // 遍历 clients 数组中每个其他客户端对象,并发送消息给其他客户端
            for (var c of clients) {
                // 排除自己这个客户端连接
                if (c !== client) {
                    // 把消息发给别人
                    c.send(msg.toString());
                }
            }
        });

        // 当客户端断开连接时触发该事件
        client.onclose = function () {
            var index = clients.indexOf(this);
            clients.splice(index, 1);
            console.log("有" + clients.length + "客户端在线")
        }
    }
});

console.log("服务器已启动...");

在上面的代码中,我们创建了一个 Websocket 服务器,监听 8080 端口。每一个连接到该服务器的客户端,都会触发服务器的 connection 事件,并且会将此客户端连接实例作为回调函数的参数传入。

我们将所有的客户端连接实例保存到一个数组里面。为该实例绑定了 messageclose 事件,当某个客户端发来消息时,自动触发 message 事件,然后遍历 clients 数组中每个其他客户端对象,并发送消息给其他客户端。

close 事件在客户端断开连接时会触发,我们要做的事情就是从数组中删除该连接。

index.html

<body>
  <!-- 这个页面是用来发送信息的 -->
  <input type="text" id="msg">
  <button id="send">发送</button>
  <script>
    // 建立到服务端 webSoket 连接
    var ws = new WebSocket("ws://localhost:8080");
    send.onclick = function () {
      // 如果 msg 输入框内容不是空的
      if (msg.value.trim() != '') {
        // 将 msg 输入框中的内容发送给服务器
        ws.send(msg.value.trim())
      }
    }
    // 断开 websoket 连接
    window.onbeforeunload = function () {
      ws.close()
    }
  </script>
</body>

index2.html

<body>
  <script>
    //建立到服务端webSoket连接
    var ws = new WebSocket("ws://localhost:8080");
    var count = 1;
    ws.onopen = function (event) {
          // 当有消息发过来时,就将消息放到显示元素上
          ws.onmessage = function (event) {
                var oP = document.createElement("p");
                oP.innerHTML = `第${count}次接收到的消息:${event.data}`;
                document.body.appendChild(oP);
                count++;
          }
    }
    // 断开 websoket 连接
    window.onbeforeunload = function () {
          ws.close()
    }
  </script>
</body

tips:以上信息来自渡一相关学习资料,供自己学习和面试使用。

从0死磕全栈第五天:React 使用zustand实现To-Do List项目

2025年9月6日 21:38

代码世界是现实的镜像,状态管理教会我们:真正的控制不在于凝固不变,而在于优雅地引导变化。

这是「从0死磕全栈」系列的第5篇文章,前面我们已经完成了环境搭建、路由配置和基础功能开发。今天,我们将引入一个轻量级但强大的状态管理工具 —— Zustand,来实现一个完整的 TodoList 应用。


Zustand 简介

Zustand 是一个轻量级、灵活的 React 状态管理库,以极简 API 解决复杂状态共享问题。

  • ✅ 无需 Provider 包裹,直接导入使用
  • ✅ 使用 create 创建 store,通过 useStore 随时随地访问状态
  • ✅ 支持中间件(如持久化、日志)、异步逻辑
  • ✅ 代码简洁易维护,是中小型项目的理想选择

下面我们将用 React + TypeScript + Zustand 实现一个功能完整的 TodoList。


要实现的功能

  1. 添加待办事项

    • 在输入框中输入内容,点击“添加”按钮或按 Enter 键
    • 新事项添加到列表底部
  2. 标记完成/未完成

    • 点击复选框切换完成状态
    • 已完成事项显示删除线样式
  3. 删除事项

    • 点击右侧“删除”按钮移除该事项
  4. 统计信息

    • 显示总事项数和已完成事项数

1. 安装依赖

npm install zustand

2. 创建 Zustand Store

// store/todoStore.ts
import { create } from 'zustand';

// 定义 Todo 项的类型
interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

// 定义 Store 的类型
interface TodoStore {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
  deleteTodo: (id: number) => void;
}

// 创建 Zustand Store
const useTodoStore = create<TodoStore>((set) => ({
  // 初始状态:空数组
  todos: [],

  // 添加新的待办事项
  addTodo: (text: string) =>
    set((state) => ({
      todos: [
        ...state.todos,
        {
          id: Date.now(), // 使用时间戳作为唯一ID
          text,
          completed: false,
        },
      ],
    })),

  // 切换完成状态
  toggleTodo: (id: number) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      ),
    })),

  // 删除待办事项
  deleteTodo: (id: number) =>
    set((state) => ({
      todos: state.todos.filter((todo) => todo.id !== id),
    })),
}));

export default useTodoStore;

💡 设计思想:Zustand 的 set 函数类似于 Java 中的 setter 方法,提供了统一的状态修改入口,提升了代码的封装性和可读性。


进阶:使用 get 获取当前状态

Zustand 还提供了 get 函数,用于获取当前状态。我们可以扩展 store,添加计算逻辑:

// store/todoStore.ts(增强版)
interface TodoStore {
  todos: Todo[];
  addTodo: (text: string) => void;
  toggleTodo: (id: number) => void;
  deleteTodo: (id: number) => void;
  getIncompleteCount: () => number; // 新增:获取未完成数量
}

const useTodoStore = create<TodoStore>((set, get) => ({
  todos: [],

  addTodo: (text: string) =>
    set((state) => ({
      todos: [
        ...state.todos,
        {
          id: Date.now(),
          text,
          completed: false,
        },
      ],
    })),

  toggleTodo: (id: number) =>
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      ),
    })),

  deleteTodo: (id: number) =>
    set((state) => ({
      todos: state.todos.filter((todo) => todo.id !== id),
    })),

  // 使用 get() 获取当前状态并计算
  getIncompleteCount: () => {
    const todos = get().todos;
    return todos.filter((todo) => !todo.completed).length;
  },
}));

3. 创建 TodoList 组件

// components/TodoList.tsx
import { useState } from 'react';
import useTodoStore from '../store/todoStore';

const TodoList = () => {
  // 从 store 中解构状态和方法
  const { todos, addTodo, toggleTodo, deleteTodo } = useTodoStore();
  const [inputValue, setInputValue] = useState('');

  // 添加待办事项
  const handleAddTodo = () => {
    if (inputValue.trim()) {
      addTodo(inputValue);
      setInputValue(''); // 清空输入框
    }
  };

  return (
    <div style={{ maxWidth: '400px', margin: '0 auto' }}>
      <h1>Todo List</h1>

      {/* 输入框和添加按钮 */}
      <div style={{ display: 'flex', marginBottom: '20px' }}>
        <input
          type="text"
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          placeholder="输入待办事项"
          style={{ flex: 1, padding: '8px' }}
          onKeyPress={(e) => e.key === 'Enter' && handleAddTodo()}
        />
        <button
          onClick={handleAddTodo}
          style={{ marginLeft: '10px', padding: '8px 16px' }}
        >
          添加
        </button>
      </div>

      {/* 待办事项列表 */}
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {todos.map((todo) => (
          <li
            key={todo.id}
            style={{
              display: 'flex',
              alignItems: 'center',
              marginBottom: '8px',
              textDecoration: todo.completed ? 'line-through' : 'none',
              color: todo.completed ? '#888' : '#000',
            }}
          >
            {/* 复选框 */}
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
              style={{ marginRight: '10px' }}
            />

            {/* 文本 */}
            <span style={{ flex: 1 }}>{todo.text}</span>

            {/* 删除按钮 */}
            <button
              onClick={() => deleteTodo(todo.id)}
              style={{
                background: '#ff4444',
                color: 'white',
                border: 'none',
                padding: '4px 8px',
                borderRadius: '4px',
              }}
            >
              删除
            </button>
          </li>
        ))}
      </ul>

      {/* 统计信息 */}
      <div style={{ marginTop: '20px', color: '#666' }}>
        总计: {todos.length} 项 | 已完成: {todos.filter(t => t.completed).length} 项
      </div>
    </div>
  );
};

export default TodoList;

4. 创建 App 组件

// App.tsx
import TodoList from './components/TodoList';

function App() {
  return (
    <div className="App">
      <TodoList />
    </div>
  );
}

export default App;

5. 项目结构说明

src/
├── store/
│   └── todoStore.ts        # Zustand 状态管理
├── components/
│   └── TodoList.tsx        # 主组件
└── App.tsx                 # 应用入口

6. Zustand 与 useState 对比

Zustand vs useState

✅ 使用 useState 的场景:

  • 管理表单输入、UI 开关状态等组件私有状态
  • 简单的父子组件通信(通过 props 传递)

✅ 使用 Zustand 的场景:

  • 多个无关组件需要共享状态(如全局登录 token、用户信息、主题设置)
  • 状态逻辑复杂,需要集中管理
  • 避免“props drilling”(层层传递 props)

结语:大道至简

Zustand 的哲学很简单 —— “不要为了状态管理而引入复杂性”

它没有强制架构,没有繁琐规则,只提供最核心的能力:让状态管理变得简单、直观、高效

如果你正在寻找一种“刚刚好”的状态管理方案,Zustand 绝对值得尝试。它可能不会解决所有问题,但一定能让你:

  • ✅ 少写很多代码
  • ✅ 减少嵌套层级
  • ✅ 提升开发愉悦感

关注我,持续更新「从0死磕全栈」系列,带你一步步构建完整的全栈应用。

从0死磕全栈第4天:使用React useState实现用户注册功能

2025年9月6日 21:19

导语useState 以极简的语法包裹深刻的哲学思想:用函数式思维解构状态管理,以不可变性构建可预测性,借引用比较实现渲染的精准控制。它将状态从“程序的副产品”升华为“逻辑的参与者”,让每一次状态变更都成为组件生命历程中的有意义事件。

本文将基于 Vite + React + TypeScript 技术栈,通过构建一个完整的用户注册页面,深入实践 useState 的核心用法,掌握 React 函数组件中的状态管理之道。


一、核心概念:useState 简介

useState 是 React 的一个 Hook,用于在函数组件中添加和管理状态。

  • 基本语法

    const [state, setState] = useState(initialState);
    
    • state:当前状态值
    • setState:更新状态的函数(命名惯例为 setXxx
    • initialState:状态的初始值
  • 核心思想

    • 状态驱动视图:状态改变,组件自动重新渲染。
    • 不可变性(Immutability):更新状态时,应创建新对象,而非直接修改原对象。
    • 受控组件(Controlled Components):表单元素的值由 React 状态控制,通过 onChange 事件同步更新。

二、功能分析:注册页面的组件化设计

1. 组件分层

遵循单一职责原则,将注册功能独立为一个组件:Register.tsx

2. 状态与UI分离

  • 状态层:管理表单数据、错误信息。
  • UI层:负责渲染表单、展示错误提示、处理用户交互。

3. 数据流设计

用户输入 → 触发 onChange → 更新 state → 视图重新渲染
         ↓
提交表单 → 触发 onSubmit → 验证 state → 调用 API / 跳转

三、完整实现:带类型安全的注册表单

1. 创建注册组件

// src/pages/Register.tsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';

// 定义用户注册表单数据类型
interface RegisterFormData {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
}

const Register = () => {
  const navigate = useNavigate();

  // 使用 useState 管理表单数据
  const [formData, setFormData] = useState<RegisterFormData>({
    username: '',
    email: '',
    password: '',
    confirmPassword: ''
  });

  // 使用 useState 管理表单错误信息
  const [errors, setErrors] = useState<Partial<RegisterFormData>>({});

  // 处理输入框变化
  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    // 更新对应的表单字段
    setFormData({
      ...formData,
      [name]: value
    });
    // 清除当前字段的错误信息
    if (errors[name as keyof typeof errors]) {
      setErrors({
        ...errors,
        [name]: ''
      });
    }
  };

  // 验证表单
  const validateForm = (): boolean => {
    const newErrors: Partial<RegisterFormData> = {};

    if (!formData.username.trim()) {
      newErrors.username = '用户名不能为空';
    }

    if (!formData.email.trim()) {
      newErrors.email = '邮箱不能为空';
    } else if (!/^\S+@\S+\.\S+$/.test(formData.email)) {
      newErrors.email = '邮箱格式不正确';
    }

    if (!formData.password) {
      newErrors.password = '密码不能为空';
    } else if (formData.password.length < 6) {
      newErrors.password = '密码至少需要6位';
    }

    if (formData.password !== formData.confirmPassword) {
      newErrors.confirmPassword = '两次输入的密码不一致';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  // 处理表单提交
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    // 验证表单
    if (!validateForm()) {
      return;
    }

    try {
      // 这里应该是实际的API调用
      // const response = await registerUser(formData);
      console.log('注册数据:', formData);

      // 注册成功后跳转到登录页面
      navigate('/login');
    } catch (error) {
      console.error('注册失败:', error);
      // 可以在这里设置错误状态提示用户
    }
  };

  return (
    <div className="register-container">
      <h2>用户注册</h2>
      <form onSubmit={handleSubmit}>
        <div className="form-group">
          <label htmlFor="username">用户名</label>
          <input
            type="text"
            id="username"
            name="username"
            value={formData.username}
            onChange={handleInputChange}
          />
          {errors.username && <span className="error">{errors.username}</span>}
        </div>

        <div className="form-group">
          <label htmlFor="email">邮箱</label>
          <input
            type="email"
            id="email"
            name="email"
            value={formData.email}
            onChange={handleInputChange}
          />
          {errors.email && <span className="error">{errors.email}</span>}
        </div>

        <div className="form-group">
          <label htmlFor="password">密码</label>
          <input
            type="password"
            id="password"
            name="password"
            value={formData.password}
            onChange={handleInputChange}
          />
          {errors.password && <span className="error">{errors.password}</span>}
        </div>

        <div className="form-group">
          <label htmlFor="confirmPassword">确认密码</label>
          <input
            type="password"
            id="confirmPassword"
            name="confirmPassword"
            value={formData.confirmPassword}
            onChange={handleInputChange}
          />
          {errors.confirmPassword && (
            <span className="error">{errors.confirmPassword}</span>
          )}
        </div>

        <button type="submit" className="submit-btn">
          注册
        </button>
      </form>
    </div>
  );
};

export default Register;

四、关键代码解析

1. useState 的双重应用

const [formData, setFormData] = useState<RegisterFormData>({ ... });
const [errors, setErrors] = useState<Partial<RegisterFormData>>({});
  • formData:存储用户输入的完整表单数据。
  • errors:存储验证失败的错误信息。Partial<T> 表示该对象可以只包含 T 类型的部分属性。

2. 受控组件的实现

input 元素上同时设置了 valueonChange

<input 
  value={formData.username} 
  onChange={handleInputChange} 
/>

这确保了:

  • 数据流向state → view
  • 事件响应view → state

3. 类型断言的妙用

if (errors[name as keyof typeof errors])

name 是字符串,TypeScript 无法确定它一定是 errors 的键。使用 as keyof typeof errors 进行类型断言,告诉编译器 nameerrors 对象的合法属性。


五、路由配置

// src/App.tsx
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Register from './pages/Register';

function App() {
  return (
    <Router>
      <Routes>
        <Route path="/register" element={<Register />} />
        {/* 其他路由... */}
      </Routes>
    </Router>
  );
}

export default App;

六、样式示例 (CSS)

/* src/pages/Register.css */
.register-container {
  width: 500px;
  margin: 2rem auto;
  padding: 2rem;
  border: 1px solid #ddd;
  border-radius: 8px;
}

.form-group {
  margin-bottom: 1rem;
}

.form-group label {
  display: block;
  margin-bottom: 0.5rem;
}

.form-group input {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.error {
  color: red;
  font-size: 0.8rem;
  padding-top: 0.5rem;
  white-space: nowrap;
}

.submit-btn {
  width: 100%;
  padding: 0.75rem;
  background-color: #007bff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  margin-top: 1rem;
}

.submit-btn:hover {
  background-color: #0056b3;
}

七、功能总结

通过本次实战,我们掌握了:

  • useState 的核心用法:管理组件内部状态。
  • 表单的受控模式:实现双向数据绑定。
  • 客户端表单验证:提供即时反馈。
  • TypeScript 类型安全:定义接口,避免类型错误。
  • useNavigate 路由跳转:在操作成功后导航到新页面。

总结

useState 是 React 函数组件的基石。它不仅仅是 this.setState 的替代品,更是一种声明式、函数式的状态管理哲学。通过这个注册表单的实践,你已经掌握了构建复杂交互式 UI 的核心技能。

从今天起,你不再是代码的堆砌者,而是状态的指挥家。

标签:#React #useState #TypeScript #前端开发 #Vite #全栈 #掘金AI

Flutter ListTile 组件总结

2025年9月6日 21:06

Flutter ListTile 组件总结

概述

ListTile 是 Flutter 中基于 Material Design 设计规范的列表项组件,继承自 StatelessWidget,专门用于在列表中展示结构化信息,如标题、副标题、图标等。它是构建列表界面的核心组件,提供了标准化的布局和交互方式。

原理说明

核心原理

ListTile 通过组合多个子组件来构建统一的列表项布局:

  1. 布局结构:采用水平排列的方式,从左到右依次为 leading、主内容区域(titlesubtitle)、trailing
  2. 继承关系:继承自 StatelessWidget,通过 build 方法构建UI
  3. Material Design:严格遵循 Material Design 规范,确保界面一致性
  4. 自适应高度:根据内容自动调整高度,支持单行、双行、三行模式

内部实现机制

// ListTile 内部布局原理示意
Container(
  child: Row(
    children: [
      leading,        // 前导组件
      Expanded(
        child: Column(
          children: [
            title,      // 主标题
            subtitle,   // 副标题(可选)
          ],
        ),
      ),
      trailing,       // 尾部组件
    ],
  ),
)

高度计算规则

  • 单行模式:56.0 逻辑像素(dense: true 时为 48.0)
  • 双行模式:72.0 逻辑像素(dense: true 时为 64.0)
  • 三行模式:88.0 逻辑像素(dense: true 时为 76.0)

构造函数详解

ListTile 构造函数签名

const ListTile({
  Key? key,                                    // Widget的唯一标识符,用于Widget树优化
  Widget? leading,                             // 前导组件,显示在标题左侧(如图标、头像)
  Widget? title,                               // 主标题组件,列表项的主要内容
  Widget? subtitle,                            // 副标题组件,显示在主标题下方的次要信息
  Widget? trailing,                            // 尾部组件,显示在标题右侧(如箭头、按钮)
  bool isThreeLine = false,                    // 是否为三行布局模式,需要subtitle不为null
  bool? dense,                                 // 是否使用紧凑布局,减少垂直空间
  VisualDensity? visualDensity,                // 视觉密度,控制组件的紧凑程度
  ShapeBorder? shape,                          // 形状边框,定义ListTile的外形轮廓
  ListTileStyle? style,                        // ListTile样式类型(drawer、list等)
  Color? selectedColor,                        // 选中状态下文本和图标的颜色
  Color? iconColor,                            // 图标的默认颜色
  Color? textColor,                            // 文本的默认颜色
  EdgeInsetsGeometry? contentPadding,          // 内容内边距,控制内部元素间距
  bool enabled = true,                         // 是否启用用户交互,false时禁用点击等手势
  GestureTapCallback? onTap,                   // 点击事件回调函数
  GestureLongPressCallback? onLongPress,       // 长按事件回调函数
  GestureTapCallback? onFocusChange,           // 焦点变化时的回调函数
  MouseCursor? mouseCursor,                    // 鼠标悬停时显示的光标样式
  bool selected = false,                       // 是否处于选中状态,影响外观样式
  Color? focusColor,                           // 获得焦点时的背景颜色
  Color? hoverColor,                           // 鼠标悬停时的背景颜色
  Color? splashColor,                          // 点击时水波纹动画的颜色
  FocusNode? focusNode,                        // 焦点节点,用于管理键盘焦点
  bool autofocus = false,                      // 是否在初始化时自动获取焦点
  Color? tileColor,                            // 默认状态下的背景颜色
  Color? selectedTileColor,                    // 选中状态下的背景颜色
  bool? enableFeedback,                        // 是否启用触觉反馈(振动等)
  double? horizontalTitleGap,                  // 标题与前导组件之间的水平间距
  double? minVerticalPadding,                  // 最小垂直内边距
  double? minLeadingWidth,                     // 前导组件的最小宽度
  double? minTileHeight,                       // ListTile的最小高度
  TextStyle? titleTextStyle,                   // 主标题的文本样式
  TextStyle? subtitleTextStyle,                // 副标题的文本样式
  TextStyle? leadingAndTrailingTextStyle,      // 前导和尾部组件中文本的样式
  Clip clipBehavior = Clip.none,               // 内容溢出时的裁剪行为
})

构造函数参数分类说明

核心内容参数
  • leading: 前导组件,位于标题左侧,通常为图标或头像
  • title: 主标题组件,列表项的主要内容
  • subtitle: 副标题组件,位于主标题下方的次要内容
  • trailing: 尾部组件,位于标题右侧,通常为操作按钮或状态图标
布局控制参数
  • isThreeLine: 布尔值,指定是否为三行布局模式
  • dense: 布尔值,是否使用紧凑布局(减少高度和内边距)
  • visualDensity: 视觉密度配置,用于微调组件的紧凑程度
  • contentPadding: 内容内边距,控制ListTile内部元素的间距
  • horizontalTitleGap: 标题与前导组件之间的水平间距
  • minVerticalPadding: 最小垂直内边距
  • minLeadingWidth: 前导组件的最小宽度
  • minTileHeight: ListTile的最小高度
样式与外观参数
  • shape: 形状边框,定义ListTile的外形(圆角、边框等)
  • style: ListTile样式类型(drawer, list等)
  • tileColor: 默认背景颜色
  • selectedTileColor: 选中状态下的背景颜色
  • selectedColor: 选中状态下文本和图标的颜色
  • iconColor: 图标颜色
  • textColor: 文本颜色
  • titleTextStyle: 标题文本样式
  • subtitleTextStyle: 副标题文本样式
  • leadingAndTrailingTextStyle: 前导和尾部文本样式
交互与状态参数
  • enabled: 是否启用交互,false时禁用所有手势
  • selected: 是否处于选中状态
  • onTap: 点击事件回调函数
  • onLongPress: 长按事件回调函数
  • onFocusChange: 焦点变化回调函数
  • autofocus: 是否自动获取焦点
  • focusNode: 焦点节点,用于管理焦点状态
鼠标与反馈参数
  • mouseCursor: 鼠标悬停时的光标样式
  • focusColor: 获得焦点时的颜色
  • hoverColor: 鼠标悬停时的颜色
  • splashColor: 点击时的水波纹颜色
  • enableFeedback: 是否启用触觉反馈
其他参数
  • key: Widget的唯一标识符
  • clipBehavior: 裁剪行为,控制内容溢出时的处理方式

构造函数使用示例

1. 基础构造函数使用
// 最简单的构造函数调用
ListTile(
  title: Text('简单标题'),
)

// 带有完整参数的构造函数调用
ListTile(
  key: ValueKey('user_tile_1'),
  leading: CircleAvatar(
    backgroundImage: NetworkImage('https://example.com/avatar.jpg'),
  ),
  title: Text('用户名称'),
  subtitle: Text('用户描述信息'),
  trailing: Icon(Icons.arrow_forward_ios),
  isThreeLine: false,
  dense: false,
  contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  enabled: true,
  selected: false,
  onTap: () => print('点击了用户'),
  onLongPress: () => print('长按了用户'),
  tileColor: Colors.white,
  selectedTileColor: Colors.blue.withOpacity(0.1),
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(8),
  ),
)
2. 不同场景的构造函数示例

设置页面列表项:

ListTile(
  leading: Icon(Icons.notifications),
  title: Text('通知设置'),
  subtitle: Text('管理应用通知偏好'),
  trailing: Switch(
    value: notificationEnabled,
    onChanged: (value) => toggleNotification(value),
  ),
  onTap: () => navigateToNotificationSettings(),
)

联系人列表项:

ListTile(
  leading: CircleAvatar(
    child: Text(contact.initials),
    backgroundColor: Colors.blue,
  ),
  title: Text(contact.name),
  subtitle: Text(contact.phoneNumber),
  trailing: PopupMenuButton(
    itemBuilder: (context) => [
      PopupMenuItem(child: Text('呼叫')),
      PopupMenuItem(child: Text('发消息')),
      PopupMenuItem(child: Text('删除')),
    ],
  ),
  onTap: () => showContactDetails(contact),
  dense: true,
  contentPadding: EdgeInsets.symmetric(horizontal: 12),
)

文件列表项:

ListTile(
  leading: Icon(
    getFileIcon(file.extension),
    color: getFileColor(file.extension),
  ),
  title: Text(
    file.name,
    overflow: TextOverflow.ellipsis,
  ),
  subtitle: Text(
    '${formatFileSize(file.size)}${formatDate(file.modifiedDate)}',
    style: TextStyle(fontSize: 12),
  ),
  trailing: IconButton(
    icon: Icon(Icons.more_vert),
    onPressed: () => showFileActions(file),
  ),
  onTap: () => openFile(file),
  visualDensity: VisualDensity.compact,
  minLeadingWidth: 40,
)

构造函数参数验证规则

Flutter在运行时会对构造函数参数进行验证:

  1. isThreeLine 验证

    // 如果 isThreeLine 为 true,subtitle 不能为 null
    assert(isThreeLine != null),
    assert(!isThreeLine || subtitle != null),
    
  2. dense 与 visualDensity 关系

    // dense 和 visualDensity 不能同时设置
    assert(dense == null || visualDensity == null),
    
  3. enabled 与交互回调

    // 当 enabled 为 false 时,交互回调应该为 null
    assert(enabled == null || enabled || (onTap == null && onLongPress == null)),
    

构造函数最佳实践

  1. 参数顺序:按照重要性和使用频率排列参数
  2. 必需参数:虽然所有参数都是可选的,但通常至少需要提供 title
  3. 性能考虑:避免在构造函数中传入复杂的Widget树
  4. 类型安全:确保传入的Widget类型符合预期

主要属性详解

布局相关属性

属性 类型 描述 默认值
leading Widget? 位于标题前的组件,通常为图标或头像 null
title Widget? 主要内容,通常为文本 null
subtitle Widget? 次要内容,位于标题下方 null
trailing Widget? 位于标题后的组件,通常为图标或按钮 null

样式相关属性

属性 类型 描述 默认值
tileColor Color? 未选中状态的背景颜色 null
selectedTileColor Color? 选中状态的背景颜色 null
titleTextStyle TextStyle? 标题文本样式 null
subtitleTextStyle TextStyle? 副标题文本样式 null
leadingAndTrailingTextStyle TextStyle? 前导和尾部文本样式 null

交互相关属性

属性 类型 描述 默认值
onTap GestureTapCallback? 点击事件回调 null
onLongPress GestureLongPressCallback? 长按事件回调 null
selected bool 是否选中状态 false
enabled bool 是否启用交互 true

布局控制属性

属性 类型 描述 默认值
dense bool? 是否使用紧密布局 null
isThreeLine bool 是否为三行模式 false
contentPadding EdgeInsetsGeometry? 内容内边距 null
minTileHeight double? 最小高度 null
horizontalTitleGap double? 标题与前导组件间距 null
minVerticalPadding double? 最小垂直内边距 null

实现方式

基本用法

import 'package:flutter/material.dart';

// 简单的 ListTile
ListTile(
  leading: Icon(Icons.account_circle),
  title: Text('用户名'),
  subtitle: Text('用户描述'),
  trailing: Icon(Icons.arrow_forward_ios),
  onTap: () {
    print('列表项被点击');
  },
)

完整示例

class ListTileExample extends StatefulWidget {
  @override
  _ListTileExampleState createState() => _ListTileExampleState();
}

class _ListTileExampleState extends State<ListTileExample> {
  int selectedIndex = -1;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('ListTile 示例')),
      body: ListView.builder(
        itemCount: 10,
        itemBuilder: (context, index) {
          return ListTile(
            leading: CircleAvatar(
              child: Text('${index + 1}'),
              backgroundColor: Colors.blue,
            ),
            title: Text('标题 ${index + 1}'),
            subtitle: Text('这是副标题内容 ${index + 1}'),
            trailing: Icon(
              selectedIndex == index 
                ? Icons.check_circle 
                : Icons.radio_button_unchecked,
              color: selectedIndex == index ? Colors.blue : null,
            ),
            selected: selectedIndex == index,
            selectedTileColor: Colors.blue.withOpacity(0.1),
            onTap: () {
              setState(() {
                selectedIndex = index;
              });
            },
            onLongPress: () {
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text('长按了项目 ${index + 1}')),
              );
            },
          );
        },
      ),
    );
  }
}

自定义样式示例

// 自定义样式的 ListTile
ListTile(
  leading: Container(
    width: 40,
    height: 40,
    decoration: BoxDecoration(
      color: Colors.orange,
      borderRadius: BorderRadius.circular(20),
    ),
    child: Icon(Icons.star, color: Colors.white),
  ),
  title: Text(
    '自定义标题',
    style: TextStyle(
      fontSize: 18,
      fontWeight: FontWeight.bold,
      color: Colors.black87,
    ),
  ),
  subtitle: Text(
    '自定义副标题内容',
    style: TextStyle(
      fontSize: 14,
      color: Colors.grey[600],
    ),
  ),
  trailing: Container(
    padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
    decoration: BoxDecoration(
      color: Colors.green,
      borderRadius: BorderRadius.circular(12),
    ),
    child: Text(
      '状态',
      style: TextStyle(color: Colors.white, fontSize: 12),
    ),
  ),
  contentPadding: EdgeInsets.symmetric(horizontal: 20, vertical: 8),
  tileColor: Colors.grey[50],
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(8),
    side: BorderSide(color: Colors.grey[300]!, width: 1),
  ),
  onTap: () {
    // 处理点击事件
  },
)

高级用法

1. 开关控制列表项

class SwitchListTileExample extends StatefulWidget {
  @override
  _SwitchListTileExampleState createState() => _SwitchListTileExampleState();
}

class _SwitchListTileExampleState extends State<SwitchListTileExample> {
  bool isEnabled = false;

  @override
  Widget build(BuildContext context) {
    return SwitchListTile(
      title: Text('启用通知'),
      subtitle: Text('接收应用推送通知'),
      value: isEnabled,
      onChanged: (bool value) {
        setState(() {
          isEnabled = value;
        });
      },
      secondary: Icon(Icons.notifications),
    );
  }
}

2. 复选框列表项

class CheckboxListTileExample extends StatefulWidget {
  @override
  _CheckboxListTileExampleState createState() => _CheckboxListTileExampleState();
}

class _CheckboxListTileExampleState extends State<CheckboxListTileExample> {
  List<bool> checkedStates = List.generate(5, (index) => false);

  @override
  Widget build(BuildContext context) {
    return Column(
      children: List.generate(5, (index) {
        return CheckboxListTile(
          title: Text('选项 ${index + 1}'),
          subtitle: Text('这是选项 ${index + 1} 的描述'),
          value: checkedStates[index],
          onChanged: (bool? value) {
            setState(() {
              checkedStates[index] = value ?? false;
            });
          },
          secondary: Icon(Icons.label),
        );
      }),
    );
  }
}

3. 分组列表

class GroupedListExample extends StatelessWidget {
  final Map<String, List<String>> groupedData = {
    '工作': ['会议', '报告', '邮件'],
    '个人': ['购物', '运动', '阅读'],
    '学习': ['课程', '作业', '复习'],
  };

  @override
  Widget build(BuildContext context) {
    return ListView(
      children: groupedData.entries.map((entry) {
        return ExpansionTile(
          title: Text(
            entry.key,
            style: TextStyle(fontWeight: FontWeight.bold),
          ),
          children: entry.value.map((item) {
            return ListTile(
              leading: Icon(Icons.task_alt),
              title: Text(item),
              trailing: Icon(Icons.arrow_forward_ios, size: 16),
              onTap: () {
                print('点击了:${entry.key} - $item');
              },
            );
          }).toList(),
        );
      }).toList(),
    );
  }
}

主题化与自定义

全局主题设置

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: ThemeData(
        listTileTheme: ListTileThemeData(
          tileColor: Colors.grey[100],
          selectedTileColor: Colors.blue[100],
          titleTextStyle: TextStyle(
            fontSize: 16,
            fontWeight: FontWeight.w500,
            color: Colors.black87,
          ),
          subtitleTextStyle: TextStyle(
            fontSize: 14,
            color: Colors.grey[600],
          ),
          contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
          horizontalTitleGap: 16,
          minVerticalPadding: 8,
        ),
      ),
      home: MyHomePage(),
    );
  }
}

局部主题覆盖

Theme(
  data: Theme.of(context).copyWith(
    listTileTheme: ListTileThemeData(
      tileColor: Colors.amber[50],
      selectedTileColor: Colors.amber[100],
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(12),
      ),
    ),
  ),
  child: ListTile(
    title: Text('自定义主题的列表项'),
    subtitle: Text('使用局部主题覆盖'),
    onTap: () {},
  ),
)

性能优化建议

1. 使用 ListView.builder

// 推荐:对于大量数据使用 ListView.builder
ListView.builder(
  itemCount: items.length,
  itemBuilder: (context, index) {
    return ListTile(
      title: Text(items[index].title),
      subtitle: Text(items[index].subtitle),
      onTap: () => handleTap(items[index]),
    );
  },
)

// 避免:直接创建大量 ListTile
ListView(
  children: items.map((item) => ListTile(/* ... */)).toList(),
)

2. 图片缓存优化

ListTile(
  leading: CachedNetworkImage(
    imageUrl: item.imageUrl,
    width: 40,
    height: 40,
    placeholder: (context, url) => CircularProgressIndicator(),
    errorWidget: (context, url, error) => Icon(Icons.error),
  ),
  title: Text(item.title),
)

3. 避免频繁重建

class OptimizedListTile extends StatelessWidget {
  final String title;
  final String subtitle;
  final VoidCallback onTap;

  const OptimizedListTile({
    Key? key,
    required this.title,
    required this.subtitle,
    required this.onTap,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(title),
      subtitle: Text(subtitle),
      onTap: onTap,
    );
  }
}

常见问题与解决方案

1. 高度不一致问题

// 问题:ListTile 高度不一致
// 解决:设置统一的 minTileHeight
ListTile(
  minTileHeight: 60,
  title: Text('标题'),
  subtitle: Text('副标题'),
)

2. 溢出问题

// 问题:文本溢出
// 解决:使用 Flexible 或设置 overflow
ListTile(
  title: Text(
    '这是一个很长的标题,可能会溢出',
    overflow: TextOverflow.ellipsis,
    maxLines: 1,
  ),
  subtitle: Text(
    '这是一个很长的副标题内容,也可能会溢出',
    overflow: TextOverflow.ellipsis,
    maxLines: 2,
  ),
)

3. 触摸反馈问题

// 问题:触摸反馈不明显
// 解决:自定义 splashColor 和 highlightColor
Material(
  child: InkWell(
    splashColor: Colors.blue.withOpacity(0.3),
    highlightColor: Colors.blue.withOpacity(0.1),
    onTap: () {},
    child: ListTile(
      title: Text('自定义触摸反馈'),
    ),
  ),
)

最佳实践

1. 合理使用层次结构

// 好的实践:清晰的信息层次
ListTile(
  leading: Icon(Icons.person),
  title: Text('主要信息'),           // 最重要的信息
  subtitle: Text('次要信息'),        // 补充信息
  trailing: Text('12:30'),          // 状态或时间信息
)

2. 保持一致性

// 在整个应用中保持 ListTile 样式一致
class AppListTile extends StatelessWidget {
  final Widget leading;
  final String title;
  final String? subtitle;
  final Widget? trailing;
  final VoidCallback? onTap;

  const AppListTile({
    Key? key,
    required this.leading,
    required this.title,
    this.subtitle,
    this.trailing,
    this.onTap,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ListTile(
      leading: leading,
      title: Text(title, style: AppTextStyles.listTitle),
      subtitle: subtitle != null 
        ? Text(subtitle!, style: AppTextStyles.listSubtitle)
        : null,
      trailing: trailing,
      onTap: onTap,
      contentPadding: AppSpacing.listTilePadding,
    );
  }
}

3. 适当的交互反馈

ListTile(
  title: Text('可点击项'),
  onTap: () {
    // 提供明确的反馈
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('操作已执行')),
    );
  },
  // 视觉提示这是可点击的
  trailing: Icon(Icons.arrow_forward_ios),
)

总结

ListTile 是 Flutter 中功能强大且灵活的列表项组件,通过合理使用其丰富的属性和方法,可以构建出符合 Material Design 规范的美观列表界面。在实际开发中,应该:

  1. 理解原理:掌握 ListTile 的布局机制和高度计算规则
  2. 合理使用:根据具体需求选择合适的属性组合
  3. 性能优化:对于大量数据使用 ListView.builder 等优化手段
  4. 保持一致:在整个应用中维持统一的设计风格
  5. 用户体验:提供清晰的视觉层次和适当的交互反馈

掌握这些要点,就能充分发挥 ListTile 组件的优势,构建出高质量的列表界面。

❌
❌