阅读视图

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

改了CSS刷新没反应-你可能不懂HTTP缓存

改了 CSS 刷新没反应?你可能不懂 HTTP 缓存

做前端的应该都遇到过这种情况:明明改了 CSS,浏览器里看到的还是旧样式。网上搜一下,告诉你"清缓存",Ctrl+F5 一刷新就好了。

但问题是:

  • 缓存到底是怎么工作的?
  • 为什么有时候会用缓存,有时候不会?
  • Network 面板里的 (disk cache)304 Not Modified 有什么区别?

今天把这个问题彻底搞清楚。

HTTP 缓存的两层机制

浏览器缓存分两层:强缓存协商缓存

简单说:

  • 强缓存:直接用本地缓存,不问服务器
  • 协商缓存:问一下服务器"我这个还能用吗?"服务器说"能",就用缓存
浏览器想请求资源
    │
    ▼
检查本地缓存
    │
    ├── 有缓存且没过期 → 强缓存命中 → 返回 200 (from cache)
    │
    └── 没缓存 / 过期了
            │
            ▼
        发请求给服务器,带上验证信息
            │
            ├── 服务器说没变 → 协商缓存命中 → 返回 304
            │
            └── 服务器说变了 → 返回 200 + 新资源

强缓存:完全不发请求

强缓存命中时,浏览器直接从本地拿资源,Network 面板会显示:

  • Size 列:(disk cache)(memory cache)
  • Time 列:0ms 或很小的数字

控制强缓存的响应头:

# 方式1:Cache-Control(推荐)
Cache-Control: max-age=31536000

# 方式2:Expires(老方式,优先级低于 Cache-Control)
Expires: Wed, 21 Oct 2025 07:28:00 GMT

max-age=31536000 意思是"这个资源 31536000 秒(一年)内有效"。

memory cache vs disk cache

两者区别:

类型 存储位置 速度 什么时候用
memory cache 内存 最快 当前会话、小文件、频繁访问的资源
disk cache 硬盘 稍慢 跨会话、大文件

关了浏览器 Tab,memory cache 就没了,disk cache 还在。

协商缓存:问一下服务器

当强缓存过期(或被设置为 no-cache),浏览器会发请求给服务器验证资源是否变化。

有两种验证方式:

方式1:ETag / If-None-Match(推荐)

基于内容哈希:

# 第一次请求,服务器返回
HTTP/1.1 200 OK
ETag: "abc123"
Cache-Control: no-cache

# 后续请求,浏览器带上 If-None-Match
GET /style.css HTTP/1.1
If-None-Match: "abc123"

# 如果内容没变,服务器返回
HTTP/1.1 304 Not Modified
ETag: "abc123"

# 如果内容变了,服务器返回
HTTP/1.1 200 OK
ETag: "xyz789"
[新的文件内容]

方式2:Last-Modified / If-Modified-Since

基于修改时间:

# 第一次请求,服务器返回
HTTP/1.1 200 OK
Last-Modified: Wed, 21 Oct 2024 07:28:00 GMT

# 后续请求,浏览器带上 If-Modified-Since
GET /style.css HTTP/1.1
If-Modified-Since: Wed, 21 Oct 2024 07:28:00 GMT

# 如果文件没改过,服务器返回 304

ETag vs Last-Modified

优先用 ETag,因为 Last-Modified 有问题:

  1. 精度只到秒 - 1 秒内改多次,检测不到
  2. 时间可能不准 - 服务器时间、部署时间等问题
  3. 只看时间不看内容 - 文件内容没变但时间变了,也会重新下载

不同资源该用什么策略?

静态资源(JS/CSS/图片)

Cache-Control: max-age=31536000, immutable

配合文件名哈希:app.abc123.js

内容变了 → 文件名变了 → 请求新文件,老文件继续用缓存。

这是现代前端打包工具(Webpack、Vite)的标准做法。

HTML 文件

Cache-Control: no-cache
ETag: "page-v1"

no-cache 不是"不缓存",而是"每次都要验证"。HTML 文件要保证用户能拿到最新版本,否则会引用旧的 JS/CSS 文件名。

API 接口

根据数据特性选择:

# 几乎不变的数据(如配置)
Cache-Control: max-age=3600

# 实时性要求高的数据
Cache-Control: no-cache

# 敏感数据(不要缓存)
Cache-Control: no-store

no-store 才是真正的"不缓存",连验证都不做。

常见问题排查

改了文件,浏览器没反应

原因:强缓存还没过期。

解决方案:

  1. 开发环境 - 打开 DevTools,勾选 "Disable cache"
  2. 生产环境 - 用文件名哈希,别用 ?v=1.0 这种

用了 ?v=1.0 还是不行

<link rel="stylesheet" href="/css/style.css?v=1.0">

问题:有些 CDN 会忽略查询参数,或者浏览器缓存策略不一致。

正确做法:

<link rel="stylesheet" href="/css/style.abc123.css">

让打包工具自动生成哈希,内容变了文件名才变。

CDN 缓存了旧文件

部署了新版本,但用户还是拿到旧文件。

原因:CDN 节点还在缓存期内。

解决方案:

  1. 部署时清除 CDN 缓存
  2. 用文件名哈希(CDN 会认为是新文件)

Nginx 配置参考

# 静态资源长期缓存
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

# HTML 文件协商缓存
location ~* \.html$ {
    add_header Cache-Control "no-cache";
    etag on;
}

# API 不缓存
location /api/ {
    add_header Cache-Control "no-store";
}

快速判断缓存类型

打开 DevTools Network 面板:

看到什么 缓存类型 含义
200 + (disk cache) 强缓存 从硬盘读取
200 + (memory cache) 强缓存 从内存读取
304 Not Modified 协商缓存 服务器说没变
200 + 正常大小 无缓存 重新下载

一句话总结

  • 强缓存:不问服务器,直接用。适合长期不变的静态资源。
  • 协商缓存:问服务器,没变就用缓存。适合需要保持最新的内容。
  • 文件名哈希:解决缓存更新问题的银弹。

如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

老项目改造 vue-cli 2.6 升级 rsbuild 提升开发效率300% upupup!!!

先上图打包 100m大小和时间37秒

image.png

image.png

image.png

升级后打包37秒

image.png

启动项目2秒左右 打开第一次页面7秒左右后续正常访问 总的9秒左右 和vite差不多项目很大

先上vue-cli 升级示例项目 github.com/1438343098/…

贴配置

import { defineConfig } from '@rsbuild/core'
import { pluginVue2 } from '@rsbuild/plugin-vue2'
import { pluginBabel } from '@rsbuild/plugin-babel'
import { pluginVue2Jsx } from '@rsbuild/plugin-vue2-jsx'
import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill'
import { pluginBasicSsl } from '@rsbuild/plugin-basic-ssl'
import path from 'path'
const resolve = dir => path.resolve(__dirname, dir)

export default defineConfig({
  plugins: [
    pluginNodePolyfill(),
    pluginBabel({
      // 处理子包
      include: [/src/, /@kuaizi\/saas-components/],
      babelLoaderOptions: {
        presets: [['@babel/preset-env', { targets: 'defaults' }]]
      }
    }),
    pluginVue2(),
    // jsx
    pluginVue2Jsx(),
    // ssl
    pluginBasicSsl()
  ],
  source: {
    entry: {
      index: './src/main.js'
    },
    // 对齐process.env
    define: {
      ...(function () {
        return Object.fromEntries(
          Object.entries(process.env).map(([key, value]) => [
            `process.env.${key}`,
            JSON.stringify(value)
          ])
        )
      })()
    }
  },
  // @符号
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'),
      // 兼容vue cdn注入和
      vue$: require.resolve('vue/dist/vue.runtime.esm.js')
      // vuex$: require.resolve('vuex')
    }
  },
  html: {
    template: './public/index.html',
    // 注入环境变量
    templateParameters: (compilation, assets, assetTags) => {
      return {
        // 兼容 Vue CLI 的 htmlWebpackPlugin 语法 之前webpack 的html直接写这里就不需要修改html了
        htmlWebpackPlugin: {
          options: {
            title: 'test',
            assetPrefix: assets.publicPath
          }
        },
        autoprefixer: {
          browsers: ['> 1%', 'last 2 versions', 'not dead']
        }
      }
    }
  },
  output: {
    cssModules: {
      auto: /\.module\.(css|less|scss|sass)$/,
      localIdentName: '[local]--[hash:base64:5]'
    }
  },
  // Vue 2 项目需要关闭 experiments.css
  experiments: {
    css: false
  },
  // 关闭性能提示 一些日志可以点开查看
  performance: {
    hints: false
  },
  server: {
    // https: true 如果需要https
  },
  tools: {
    rspack(config, { addRules }) {
      // externals 兼容cdn注入
      // config.externals = {
      //   vue: 'Vue',
      //   vuex: 'Vuex',
      //   'vue-router': 'VueRouter',
      //   'vue-i18n': 'VueI18n',
      //   'element-ui': 'ELEMENT',
      //   axios: 'axios'
      // }

      config.module.rules = (config.module.rules || []).filter(rule => {
        return !(rule.test && rule.test.toString().includes('less'))
      })

      // 全局css变量注入
      const lessVars = {
        colorPrimary: '#0066ff',
        colorPrimaryLight8: '#d4e7ff',
        colorSuccess: '#3ec07d',
        colorWarning: '#f97c56',
        colorDanger: '#f56c6c',
        colorBg: 'white',
        colorBorder: '#999',
        colorText: '#777b7e',
        colorTitle: '#1d2328',
        colorDark: 'rgba(0, 0, 0, 0.75)',
        headerHeight: '64px',
        minWidth: '1200px',
        sideBarWidth: '210px',
        font: '14px',
        fontSmall: '12px',
        bg: 'white'
      }
      // svg + rules 和less处理
      addRules([
        {
          test: /\.less$/,
          oneOf: [
            {
              // 处理 <style module lang="less">
              resourceQuery: /module/,
              use: [
                'vue-style-loader',
                {
                  loader: 'css-loader',
                  options: {
                    modules: {
                      localIdentName: '[local]--[hash:base64:5]'
                    }
                  }
                },
                'postcss-loader',
                {
                  loader: 'less-loader',
                  options: {
                    lessOptions: {
                      javascriptEnabled: true,
                      globalVars: lessVars
                    }
                    // additionalData: sharedLessImports
                  }
                }
              ]
            },
            {
              // 普通 less
              use: [
                'vue-style-loader',
                'css-loader',
                // 需要有postcss.config.js
                'postcss-loader',
                {
                  loader: 'less-loader',
                  options: {
                    lessOptions: {
                      javascriptEnabled: true,
                      globalVars: lessVars
                    }
                    // 注入所有style 顶部的 css代码例如 @import "${resolve('src/style/theme.less')}";
                    // additionalData: sharedLessImports
                  }
                }
              ]
            }
          ]
        },
        {
          test: /\.svg$/,
          include: [resolve('src/asset/icons/svg')],
          use: [
            {
              loader: require.resolve('svg-sprite-loader'),
              options: {
                symbolId: 'icon-[name]'
              }
            }
          ]
        }
      ])
    }
  }
})

升级遇到的坑详解

  1. module不通过问题
    去除官方的import { pluginLess } from '@rsbuild/plugin-less'
     //pluginLess()
    改成'vue-style-loader','vue-style-loader', 'css-loader', 'postcss-loader',
    这几个都集合
    这个也需要 关闭
     // Vue 2 项目需要关闭 experiments.css
  experiments: {
    css: false
  },

2. jsx 编译问题 安装 import { pluginVue2Jsx } from '@rsbuild/plugin-vue2-jsx'

3.不想改原来的webpack页面就这样子修改

    // 兼容 Vue CLI 的 htmlWebpackPlugin 语法
        htmlWebpackPlugin: {
          options: {
            title: 'Kuaizi™ - 内容商业一站式AI应用平台',
            cdn,
            kzCDN: process.env.VUE_APP_CDN_URL,
            npsId: process.env.VUE_APP_NPS_ID || '',
            personalNpsId: process.env.VUE_APP_PERSONAL_NPS_ID || '',
            kzENVScript,
            assetPrefix: process.env.VUE_APP_CDN_URL
              ? `${process.env.VUE_APP_CDN_URL}/plus`
              : '/',
            content: ''
          }
        }

4.处理项目里面大量的process 问题

 source: {
    entry: {
      index: './src/main.js'
    },
    // 对齐process.env
    define: {
      ...(function () {
        return Object.fromEntries(
          Object.entries(process.env).map(([key, value]) => [
            `process.env.${key}`,
            JSON.stringify(value)
          ])
        )
      })()
    }
  },

如果大家遇到问题可以下面留言问我

2025OpenTiny星光ShowTime!年度贡献者征集启动!

前言

携手共创,致敬不凡!

2025年,OpenTiny持续在前端开源领域扎根,每一位开发者都是推动项目共同前行的宝贵力量。从bug修复,到技术探讨;从参与开源活动,到输出技术文章;从使用项目,到参与共建,每一步跨越,都凝聚了开发者的智慧与汗水。致敬所有在OpenTiny社区里默默付出、积极贡献、引领创新的杰出个人,我们正式启动“OpenTiny年度贡献者评选”活动!欢迎各位开发者踊跃报名~

活动详情

活动简介:

本次活动主要是通过开发者申报+社区评选+开发者投票形式开展,入选开发者后续可获得相应活动礼品。本次活动一共设置 4 类奖项。

  1.  “技术炼金师”(参与共建)、“布道魔法师”(参与分享)、“社区宝藏玩家”(参与社区讨论) 三个类目奖项通过投票评选获奖选手,本次投票共选出5名获奖选手,按照名次顺利依次给予相应奖励。
  2. “技术硬核奖”则由社区自主根据实际共建情况评选 2 位,获得机械键盘/蓝牙音响(2选1)及荣誉证书

活动奖品:

荣誉 奖项 礼品
第一名
  • 技术炼金师
  • 布道魔法师
  • 社区宝藏玩家
机械键盘 / 蓝牙音响(2选1) +荣誉证书
第二名 华为 66W 快充充电宝+荣誉证书
第三名 BKT 护腰坐垫椅+荣誉证书
第四/五名 屏幕挂灯+荣誉证书
社区优秀共建者 技术硬核奖 机械键盘 / 蓝牙音响(2选1) +荣誉证书

活动时间:

  • 年度贡献者征集时间:2025年12月17日-2025年12月24日
  • 年度贡献者投票评选时间:2025年12月25日-2025年12月31日

报名入口:

v.wjx.cn/vm/tdGJdjR.…

默认标题__2025-12-17+16_34_23.jpg

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

微信小程序真机预览-数字不等宽如何解决

数字显示不等宽

image.png

你按照设计稿, 把右侧时间设置了同一个字号, 以为就是100%还原了UI设计,还心里小小得意🤔

真机预览你发现

image.png

哎呦我去,我猜你肯定区检查了字体大小,肯定也预览了好几遍,才确认这个情况确实不是字体大小不一样导致的

😂😂😂 其实是因为 01 与其它数字宽度不一致导致的

那解决方法肯定是 设置一个等宽字体 但是那么多等宽字体 到底应该选择那个字体?

选择的不对,就会造成下面的效果

image.png

或者这样

image.png

如果你也出现上述问题,你不妨设置

font-family: Helvetica Neue;

这个试试看,你会得到以下结果

image.png

好了,最后希望大家都不要踩坑,愿所有bug的解决方法 都可以百度到 谢谢

从零到一:打造企业级 Vue 3 高性能表格组件的设计哲学与实践

本文将深入剖析一个企业级 Vue 3 表格组件的架构设计、性能优化策略与工程化实践,涵盖 Composition API 深度应用、响应式系统优化、TypeScript 类型体操等核心技术点。

前言

在企业级 B 端应用开发中,表格组件堪称"兵家必争之地"——它承载着数据展示、交互操作、状态管理等核心职责,其设计质量直接影响着整个系统的用户体验与可维护性。

本文将以笔者主导设计的 CmcCardTable 组件为例,系统性地阐述:

  • 🎯 架构设计:如何运用 Composition API 实现关注点分离
  • 性能优化:响应式系统的精细化调优策略
  • 🔒 类型安全:TypeScript 在复杂组件中的深度实践
  • 🧪 工程质量:可测试性设计与单元测试最佳实践

一、架构设计:Composables 模式的深度实践

1.1 从"上帝组件"到"关注点分离"

传统的表格组件往往会演变成一个庞大的"上帝组件"(God Component),动辄数千行代码,维护成本极高。我们采用 Composables 模式 将表格的核心功能解耦为独立的组合式函数:

src/components/CmcCardTable/
├── CmcCardTable.vue          # 主组件(视图层)
├── composables/
│   ├── useTableSelection.ts  # 选择状态管理
│   ├── useTableExpand.ts     # 展开/折叠逻辑
│   ├── useTableSort.ts       # 排序功能
│   └── useTableLayout.ts     # 布局计算
├── types.ts                  # 类型定义
└── SubRowGrid.vue            # 子行网格组件

这种架构带来的收益:

维度 传统方案 Composables 方案
单文件代码量 3000+ 行 主组件 < 800 行
可测试性 需要挂载整个组件 可独立单元测试
复用性 难以复用 可跨组件复用
认知负载 低(单一职责)

1.2 Composable 的设计原则

useTableSelection 为例,一个优秀的 Composable 应遵循以下原则:

// useTableSelection.ts
export interface UseTableSelectionOptions {
  data: Ref<TableRow[]>
  rowKey: string
  selectionMode: Ref<SelectionMode>
  selectedRowKeys: Ref<(string | number)[]>
  reserveSelection?: boolean
  selectable?: (row: TableRow, index: number) => boolean  // 🆕 行级选择控制
}

export interface UseTableSelectionReturn {
  internalSelectedKeys: Ref<(string | number)[]>
  selectionState: ComputedRef<{
    isAllSelected: boolean
    isIndeterminate: boolean
  }>
  isRowSelected: (row: TableRow) => boolean
  isRowSelectable: (row: TableRow, index: number) => boolean
  handleRowSelect: (row: TableRow, selected: boolean) => void
  handleSingleSelect: (row: TableRow) => void
  handleSelectAll: (selected: boolean) => void
  clearSelection: () => void
  getAllSelectedRows: () => TableRow[]
}

设计要点

  1. 显式的输入/输出接口:通过 TypeScript 接口明确定义 Options 和 Return,消除隐式依赖
  2. 响应式数据作为参数:传入 Ref 而非原始值,保持响应式链路
  3. 纯函数式设计:无副作用,所有状态变更都是显式的

二、响应式系统的精细化优化

2.1 shallowRef vs ref:内存与性能的权衡

在处理大数据量表格时,响应式系统的开销不容忽视。我们采用分层的响应式策略:

// ❌ 反模式:深层响应式导致不必要的依赖追踪
const selectedRowsMap = ref(new Map<string | number, TableRow>())

// ✅ 优化方案:使用 shallowRef 减少响应式开销
const selectedRowsMap = shallowRef(new Map<string | number, TableRow>())

// 更新时手动触发响应
function updateSelection(key: string, row: TableRow) {
  const newMap = new Map(selectedRowsMap.value)
  newMap.set(key, row)
  selectedRowsMap.value = newMap  // 触发响应式更新
}

性能对比(1000 行数据场景):

方案 内存占用 批量选择耗时
ref + Map ~2.4MB ~45ms
shallowRef + Map ~1.8MB ~12ms

2.2 Computed 依赖的精准控制

computed 的依赖追踪是自动的,但这也可能导致"过度追踪"问题:

// ❌ 反模式:整个 data 数组变化都会触发重计算
const selectionState = computed(() => {
  const allKeys = data.value.map(row => row[rowKey])
  // ...
})

// ✅ 优化方案:只追踪必要的依赖
const selectableRows = computed(() => 
  data.value.filter((row, index) => isRowSelectable(row, index))
)

const selectionState = computed(() => {
  const selectableKeys = selectableRows.value.map(row => row[rowKey])
  const selectedSet = new Set(internalSelectedKeys.value)
  
  const selectedCount = selectableKeys.filter(key => selectedSet.has(key)).length
  const totalSelectable = selectableKeys.length
  
  return {
    isAllSelected: totalSelectable > 0 && selectedCount === totalSelectable,
    isIndeterminate: selectedCount > 0 && selectedCount < totalSelectable,
  }
})

2.3 Watch 的防抖与节流策略

对于高频变化的数据源,直接 watch 可能导致性能问题:

// useTableExpand.ts
watch(
  () => data.value.length,  // 👈 只监听长度变化,而非整个数组
  () => {
    if (defaultExpandAll.value) {
      initializeExpandedKeys()
    }
  },
  { flush: 'post' }  // 👈 在 DOM 更新后执行,避免重复计算
)

三、TypeScript 类型体操:从"能用"到"好用"

3.1 泛型约束与条件类型

为了让 API 更加智能,我们使用了条件类型来约束参数:

// types.ts
export interface TableColumn<T = TableRow> {
  key: keyof T | string
  label: string
  width?: string
  flex?: string
  align?: 'left' | 'center' | 'right'
  sortable?: boolean
  
  // 条件渲染函数
  render?: (value: unknown, row: T, column: TableColumn<T>) => string
  
  // 行操作项(支持静态配置和动态函数)
  actionItems?: ActionItem[] | ((row: T) => ActionItem[])
}

// 使用泛型约束行数据类型
interface OrderRow {
  id: number
  orderNo: string
  amount: number
  status: 'pending' | 'confirmed' | 'shipped'
}

const columns: TableColumn<OrderRow>[] = [
  {
    key: 'status',  // ✅ IDE 自动补全,类型安全
    label: '状态',
    render: (value) => statusMap[value as OrderRow['status']]
  }
]

3.2 事件类型的完整定义

为组件事件提供完整的类型定义,让使用方获得最佳的开发体验:

// 定义强类型的 emit 接口
export interface TableEmits {
  'update:selectedRowKeys': [keys: (string | number)[]]
  'update:expandedRowKeys': [keys: (string | number)[]]
  'selection-change': [data: {
    selectedRows: TableRow[]
    selectedRowKeys: (string | number)[]
  }]
  'select': [data: {
    row: TableRow
    selected: boolean
    selectedRows: TableRow[]
  }]
  'select-all': [data: {
    selected: boolean
    selectedRows: TableRow[]
    selectionChanges: TableRow[]
  }]
  'sort-change': [data: {
    column: string | null
    order: 'asc' | 'desc' | null
    sortState: SortState
  }]
  'expand-change': [data: {
    row: TableRow
    expanded: boolean
    expandedRows: TableRow[]
  }]
}

// 组件中使用
const emit = defineEmits<TableEmits>()

3.3 Props 的智能默认值

利用 TypeScript 的类型推导,实现带默认值的 Props 定义:

const props = withDefaults(
  defineProps<{
    data?: TableRow[]
    columns: TableColumn[]
    rowKey?: string
    selectionMode?: SelectionMode
    selectable?: (row: TableRow, index: number) => boolean
    reserveSelection?: boolean
    defaultExpandAll?: boolean
  }>(),
  {
    data: () => [],
    rowKey: 'id',
    selectionMode: 'none',
    selectable: () => true,  // 默认所有行可选
    reserveSelection: false,
    defaultExpandAll: false,
  }
)

四、高级特性实现剖析

4.1 跨分页选择保持(Reserve Selection)

在分页场景下,如何保持用户的选择状态是一个经典问题。我们的解决方案:

// 使用 Map 存储选中行的完整数据,而非仅存储 key
const selectedRowsMap = shallowRef(new Map<string | number, TableRow>())

// 数据变化时的处理策略
watch(data, (newData) => {
  if (!reserveSelection) {
    // 非保留模式:清除不在新数据中的选中项
    const newDataKeys = new Set(newData.map(row => row[rowKey]))
    const newMap = new Map<string | number, TableRow>()
    
    for (const [key, row] of selectedRowsMap.value) {
      if (newDataKeys.has(key)) {
        newMap.set(key, row)
      }
    }
    selectedRowsMap.value = newMap
  }
  // 保留模式:Map 中的数据不会被清除,即使该行不在当前页
}, { deep: false })

// 获取所有选中行数据(跨分页)
function getAllSelectedRows(): TableRow[] {
  return Array.from(selectedRowsMap.value.values())
}

核心思想:将选中行的完整数据存储在 Map 中,而非仅存储 key。这样即使数据源(当前页)不包含某些选中行,我们依然可以获取其完整信息。

4.2 部分行禁用选择(Selectable)

业务场景中常需要根据行数据动态禁用选择,我们通过 selectable 函数实现:

/**
 * 判断行是否可选择
 * @description 支持业务自定义禁用逻辑
 */
function isRowSelectable(row: TableRow, index: number): boolean {
  if (!selectable) return true
  return selectable(row, index)
}

// 全选逻辑需要排除不可选行
function handleSelectAll(selected: boolean): void {
  const selectableRows = data.value.filter((row, index) => 
    isRowSelectable(row, index)
  )
  
  if (selected) {
    // 只选中可选的行
    selectableRows.forEach(row => {
      const key = row[rowKey]
      if (!internalSelectedKeys.value.includes(key)) {
        internalSelectedKeys.value.push(key)
        selectedRowsMap.value.set(key, row)
      }
    })
  } else {
    // 只取消可选行的选中状态
    const selectableKeys = new Set(selectableRows.map(row => row[rowKey]))
    internalSelectedKeys.value = internalSelectedKeys.value.filter(
      key => !selectableKeys.has(key)
    )
  }
}

// 计算全选状态时只考虑可选行
const selectionState = computed(() => {
  const selectableRows = data.value.filter((row, index) => 
    isRowSelectable(row, index)
  )
  const selectableKeys = selectableRows.map(row => row[rowKey])
  const selectedSet = new Set(internalSelectedKeys.value)
  
  const selectedCount = selectableKeys.filter(key => selectedSet.has(key)).length
  
  return {
    isAllSelected: selectableKeys.length > 0 && selectedCount === selectableKeys.length,
    isIndeterminate: selectedCount > 0 && selectedCount < selectableKeys.length,
  }
})

使用示例

<template>
  <CmcCardTable
    v-model:selected-row-keys="selectedKeys"
    :data="tableData"
    :columns="columns"
    selection-mode="multiple"
    :selectable="(row) => row.status !== 'locked'"
  />
</template>

4.3 远程排序与本地排序的统一抽象

支持本地排序和远程排序两种模式,通过配置切换:

// useTableSort.ts
export function useTableSort(options: UseTableSortOptions) {
  const { data, defaultSort, remoteSort, onSortChange } = options
  
  // 当前排序状态
  const sortState = shallowRef<SortState>({
    column: defaultSort?.column ?? null,
    order: defaultSort?.order ?? null,
  })
  
  // 排序后的数据
  const sortedData = computed(() => {
    // 远程排序模式:直接返回原数据,排序由后端处理
    if (remoteSort?.value) {
      return data.value
    }
    
    // 本地排序模式
    if (!sortState.value.column || !sortState.value.order) {
      return data.value
    }
    
    return [...data.value].sort((a, b) => {
      const aVal = a[sortState.value.column!]
      const bVal = b[sortState.value.column!]
      const result = compareValues(aVal, bVal)
      return sortState.value.order === 'desc' ? -result : result
    })
  })
  
  // 切换排序
  function toggleSort(column: string) {
    const newOrder = getNextSortOrder(sortState.value, column)
    sortState.value = { column: newOrder ? column : null, order: newOrder }
    
    // 触发事件,交由父组件处理(远程排序时调用接口)
    onSortChange?.({
      column: sortState.value.column,
      order: sortState.value.order,
      sortState: sortState.value,
    })
  }
  
  return { sortState, sortedData, toggleSort }
}

五、工程化实践:可测试性设计

5.1 Composable 的单元测试

得益于 Composables 的独立性,我们可以脱离组件进行单元测试:

// useTableSelection.test.ts
import { describe, it, expect, vi } from 'vitest'
import { ref } from 'vue'
import { useTableSelection } from '../useTableSelection'

describe('useTableSelection', () => {
  const createTestData = () => [
    { id: 1, name: '张三', status: 'active' },
    { id: 2, name: '李四', status: 'locked' },
    { id: 3, name: '王五', status: 'active' },
  ]

  describe('selectable 部分禁用', () => {
    it('全选时应只选中可选行', () => {
      const data = ref(createTestData())
      const selectedRowKeys = ref<(string | number)[]>([])
      
      const { handleSelectAll, internalSelectedKeys } = useTableSelection({
        data,
        rowKey: 'id',
        selectionMode: ref('multiple'),
        selectedRowKeys,
        selectable: (row) => row.status === 'active',  // 只有 active 可选
      })
      
      handleSelectAll(true)
      
      // 应只选中 id=1 和 id=3(status='active')
      expect(internalSelectedKeys.value).toEqual([1, 3])
      expect(internalSelectedKeys.value).not.toContain(2)
    })
    
    it('isIndeterminate 应只基于可选行计算', () => {
      const data = ref(createTestData())
      const selectedRowKeys = ref([1])  // 选中一个可选行
      
      const { selectionState } = useTableSelection({
        data,
        rowKey: 'id',
        selectionMode: ref('multiple'),
        selectedRowKeys,
        selectable: (row) => row.status === 'active',
      })
      
      // 可选行有 2 个(id=1,3),选中了 1 个,应为半选状态
      expect(selectionState.value.isIndeterminate).toBe(true)
      expect(selectionState.value.isAllSelected).toBe(false)
    })
  })
  
  describe('reserveSelection 跨分页保留', () => {
    it('应保留不在当前页的选中数据', () => {
      const data = ref(createTestData())
      const selectedRowKeys = ref([1, 99])  // 99 不在当前数据中
      
      const { internalSelectedKeys, getAllSelectedRows } = useTableSelection({
        data,
        rowKey: 'id',
        selectionMode: ref('multiple'),
        selectedRowKeys,
        reserveSelection: true,
      })
      
      // key 99 应被保留
      expect(internalSelectedKeys.value).toContain(99)
    })
  })
})

5.2 测试覆盖率与质量保障

我们为核心 Composables 编写了全面的单元测试:

✓ useTableSelection.test.ts (25 tests)
✓ useTableExpand.test.ts    (27 tests)
✓ useTableSort.test.ts      (22 tests)
✓ useTableLayout.test.ts    (31 tests)

Total: 105 tests passed

测试用例覆盖:

  • ✅ 正常流程
  • ✅ 边界条件(空数据、重复操作等)
  • ✅ 模式切换(单选/多选/禁用)
  • ✅ 响应式同步
  • ✅ 事件触发

六、API 设计的艺术:向 Element Plus 学习

6.1 Props 命名的一致性

我们在 API 设计上尽量与 Element Plus 保持一致,降低用户的学习成本:

<!-- Element Plus Table -->
<el-table
  :data="tableData"
  :row-key="rowKey"
  :default-sort="{ prop: 'date', order: 'descending' }"
  @selection-change="handleSelectionChange"
  @sort-change="handleSortChange"
>
  <el-table-column type="selection" />
  <el-table-column type="index" />
  <el-table-column type="expand" />
</el-table>

<!-- CmcCardTable(风格一致) -->
<CmcCardTable
  :data="tableData"
  :row-key="rowKey"
  :default-sort="{ column: 'date', order: 'desc' }"
  @selection-change="handleSelectionChange"
  @sort-change="handleSortChange"
  :columns="[
    { key: 'selection', type: 'selection' },
    { key: 'index', type: 'index' },
    { key: 'expand', type: 'expand' },
  ]"
/>

6.2 渐进式的功能启用

通过 Props 开关式地启用功能,保持 API 的简洁性:

<!-- 最简用法 -->
<CmcCardTable :data="data" :columns="columns" />

<!-- 启用选择 -->
<CmcCardTable 
  :data="data" 
  :columns="columns"
  selection-mode="multiple"
/>

<!-- 启用选择 + 部分禁用 -->
<CmcCardTable 
  :data="data" 
  :columns="columns"
  selection-mode="multiple"
  :selectable="row => row.status === 'active'"
/>

<!-- 启用选择 + 跨分页保留 + 部分禁用 -->
<CmcCardTable 
  :data="data" 
  :columns="columns"
  selection-mode="multiple"
  reserve-selection
  :selectable="row => row.status === 'active'"
/>

七、性能优化清单

优化点 技术手段 效果
减少响应式开销 shallowRef 替代 ref 内存降低 25%
避免重复计算 computed 缓存 渲染性能提升 40%
精准依赖追踪 拆分细粒度 computed 减少无效更新
大列表渲染 CSS Grid 布局 重排性能提升
事件处理 事件委托 减少监听器数量

八、进阶优化:突破性能瓶颈

8.1 虚拟滚动支持:突破万级数据渲染

当表格需要渲染数千甚至上万行数据时,传统的 v-for 渲染会导致严重的性能问题。我们实现了 useVirtualScroll composable 来解决这个问题:

// useVirtualScroll.ts
export function useVirtualScroll(options: UseVirtualScrollOptions) {
  const { data, itemHeight, containerHeight, overscan = 5 } = options
  
  // 当前滚动位置
  const scrollTop = ref(0)
  
  // 计算可见范围(起始和结束索引)
  const visibleRange = computed(() => {
    const start = Math.floor(scrollTop.value / itemHeight)
    const end = start + Math.ceil(containerHeight / itemHeight)
    
    // 应用 overscan(预渲染额外行数)
    return {
      start: Math.max(0, start - overscan),
      end: Math.min(data.value.length - 1, end + overscan)
    }
  })
  
  // 只渲染可见范围内的数据
  const virtualItems = computed(() => {
    const { start, end } = visibleRange.value
    return data.value.slice(start, end + 1).map((item, i) => ({
      data: item,
      index: start + i,
      offsetTop: (start + i) * itemHeight
    }))
  })
  
  return { virtualItems, visibleRange, onScroll, ... }
}

性能对比(渲染 10,000 行数据):

指标 普通渲染 虚拟滚动
首屏时间 ~3200ms ~45ms
DOM 节点数 10,000+ ~30
内存占用 ~50MB ~2MB

8.2 WeakMap vs Map:内存管理的深层思考

selectedRowsMap 的实现中,我们选择了 Map 而非 WeakMap,这是经过深思熟虑的决定:

// ✅ 当前方案:使用 shallowRef + Map
const selectedRowsMap = shallowRef(new Map<string | number, TableRow>())

// ❓ 为什么不用 WeakMap?
// const selectedRowsMap = shallowRef(new WeakMap<TableRow, boolean>())

不选择 WeakMap 的原因

特性 Map WeakMap
键类型 任意类型 仅对象
可枚举 ✅ 支持 ✖️ 不支持
size 属性 ✅ 有 ✖️ 无
GC 回收 手动管理 自动回收

关键问题

  1. 我们需要用 rowKey(字符串/数字)作为键,WeakMap 只接受对象作为键
  2. 我们需要遍历所有选中行(getAllSelectedRows),WeakMap 不可枚举
  3. 我们需要知道选中数量,WeakMap 没有 size 属性

内存泄漏防护

// 通过 shallowRef 包装,确保随组件生命周期管理
const selectedRowsMap = shallowRef(new Map())

// 组件卸载时,shallowRef 的引用释放,Map 自然被 GC 回收
// 无需手动清理

8.3 Vue 3.4 defineModel:简化双向绑定

Vue 3.4 稳定版引入的 defineModel 宏可以大幅简化双向绑定的代码:

// ❌ Vue 3.4 之前的写法
const props = defineProps<{ selectedRowKeys: (string | number)[] }>()
const emit = defineEmits<{ 'update:selectedRowKeys': [keys: (string | number)[]] }>()

// 需要手动同步
watch(() => props.selectedRowKeys, (newKeys) => {
  internalSelectedKeys.value = [...newKeys]
})

function updateSelection(keys: (string | number)[]) {
  emit('update:selectedRowKeys', keys)
}

// ✅ Vue 3.4+ 使用 defineModel
const selectedRowKeys = defineModel<(string | number)[]>('selectedRowKeys', {
  default: () => []
})

const expandedRowKeys = defineModel<(string | number)[]>('expandedRowKeys', {
  default: () => []
})

// 直接修改即可触发更新,无需手动 emit
selectedRowKeys.value = [...newKeys]

优势

  • 减少约 60% 的模板代码
  • 自动处理 props 和 emit 的同步
  • 更直观的双向绑定语义

8.4 Vue Vapor Mode:无虚拟 DOM 的未来

Vue 3.6 alpha 引入了实验性的 Vapor Mode,它完全跳过虚拟 DOM,直接生成 DOM 操作代码:

<!-- 启用 Vapor Mode:只需添加 vapor 关键字 -->
<script setup vapor>
import { ref } from 'vue'
const count = ref(0)
</script>

<template>
  <button @click="count++">{{ count }}</button>
</template>

Vapor Mode 的优势

  • 🚀 更小的包体积(无 VDOM 运行时)
  • ⚡ 更快的渲染(无 diff/patch 开销)
  • 📊 更低的内存占用

兼容性设计

// CmcCardTable 的 Vapor Mode 兼容性设计
import { vaporInteropPlugin } from 'vue'

// 在 VDOM 应用中使用 Vapor 组件
const app = createApp(App)
app.use(vaporInteropPlugin)  // 启用互操作插件
app.mount('#app')

// CmcCardTable 可以渐进式迁移到 Vapor Mode
// 1. 将性能关键的子组件转为 Vapor
// 2. 与现有 VDOM 组件无缝共存

迁移指南

特性 支持状态 说明
Composition API ✅ 完全支持 唯一支持的 API 风格
Options API ✖️ 不支持 需迁移到 Composition API
<Transition> ✖️ 暂不支持 后续版本支持
Element Plus ✅ 支持 需配合 vaporInteropPlugin

九、总结与展望

通过 CmcCardTable 组件的设计实践,我们验证了以下技术方案的可行性:

  1. Composables 模式 是组织复杂组件逻辑的最佳实践
  2. 分层响应式策略 能显著提升大数据量场景下的性能
  3. 完备的类型定义 是提升开发体验的关键
  4. 可测试性设计 应贯穿组件开发的始终
  5. 虚拟滚动 是突破大数据量渲染瓶颈的关键技术

已完成的优化

  • ✅ 虚拟滚动 useVirtualScroll composable
  • shallowRef + Map 内存管理优化
  • ✅ 部分行禁用选择 selectable 支持

未来计划

  • 🔮 迁移到 Vue 3.4 defineModel 简化双向绑定
  • 🔮 探索 Vapor Mode 零虚拟 DOM 方案
  • 🔮 实现列的拖拽排序与宽度调整
  • 🔮 支持动态行高的虚拟滚动

参考资料


如果这篇文章对你有帮助,欢迎点赞收藏 ⭐️,你的支持是我持续输出的动力!

有任何问题欢迎在评论区交流讨论 💬

关于作者:专注于企业级 Vue 3 应用开发,热衷于组件设计与性能优化。


本文首发于掘金,转载请注明出处。

前端趣味交互:如何精准判断鼠标从哪个方向进入元素?

当我们希望一个元素在鼠标移入时,能像一扇门一样从正确的一侧滑开,或者让提示信息从鼠标来的方向自然浮现,这个看似“智能”的效果,其核心就是一个经典的几何计算:判断鼠标的进入方向

核心原理

想象一下,你正站在一个房间的正中央。有人从门口进来,你怎么判断他是从左边门还是右边门进来的?你会对比他出现的位置和你(中心点)的左右关系。我的思路是:

image.png

  1. 算出元素中心点坐标
  2. 根据中心坐标建立坐标象限
  3. 每个象限分为两个部分
  4. 依据鼠标进入的位置,算出鼠标进入时的坐标点
  5. 根据坐标点判断进入方向

第一版代码

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>判断鼠标进入方向</title>
  </head>
  <style>
    * {
      margin: 0;
      padding: 0;
    }
    body {
      width: 100vw;
      height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .box {
      width: 600px;
      height: 600px;
      background-color: aquamarine;
    }
  </style>
  <body>
    <div class="box"></div>
  </body>
  <script>
    const box = document.querySelector(".box");

    box.addEventListener("mouseenter", (event) => {
      // 计算元素中心点坐标
      const rect = box.getBoundingClientRect();
      const centerX = rect.x + rect.width / 2;
      const centerY = rect.y + rect.height / 2;
      
      // 鼠标位置
      const clientX = event.clientX;
      const clientY = event.clientY;
      
      // 坐标
      const x = clientX - centerX;
      const y = centerY - clientY;

      // 第一象限
      if (x > 0 && y > 0) {
        if (y > x) {
          console.log("top");
        } else {
          console.log("right");
        }
      }
      // 第四象限
      if (x > 0 && y < 0) {
        if (x > -y) {
          console.log("right");
        } else {
          console.log("bottom");
        }
      }
      // 第三象限
      if (x < 0 && y < 0) {
        if (-x > -y) {
          console.log("left");
        } else {
          console.log("bottom");
        }
      }
      // 第二象限
      if (x < 0 && y > 0) {
        if (y > -x) {
          console.log("top");
        } else {
          console.log("left");
        }
      }
    });
  </script>
</html>

但是第一版代码分支判断过多,我们仔细观察后能发现,实际上核心判断就是“比较水平偏差和垂直偏差哪个更大

第二版代码

const box = document.querySelector(".box");

box.addEventListener("mouseenter", (event) => {
  // 获取中心点坐标
  const rect = box.getBoundingClientRect();
  const centerX = rect.x + rect.width / 2;
  const centerY = rect.y + rect.height / 2;

  const clientX = event.clientX;
  const clientY = event.clientY;

  // 优化后的核心逻辑
  const deltaX = event.clientX - centerX; // 水平偏差
  const deltaY = event.clientY - centerY; // 垂直偏差

  if (Math.abs(deltaX) > Math.abs(deltaY)) {
    // 水平方向主导
    console.log(deltaX > 0 ? "right" : "left");
  } else {
    // 垂直方向主导
    console.log(deltaY > 0 ? "bottom" : "top");
  }
});

在vue3项目使用示例

我们可以做一个鼠标移入时图片从不同方向进入的效果

<template>
  <div class="mouse-direction-container">
    <p>鼠标方向:{{ direction }}</p>

    <div :class="['box', direction]">
      <img class="top-img" src="@/assets/top.jpg" alt="" />
      <img class="right-img" src="@/assets/right.jpg" alt="" />
      <img class="bottom-img" src="@/assets/bottom.jpg" alt="" />
      <img class="left-img" src="@/assets/left.jpg" alt="" />
      <img class="center-img" src="@/assets/center.jpg" alt="" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";

const direction = ref<"top" | "bottom" | "left" | "right" | "">("");

onMounted(() => {
  const box = document.querySelector(".box")!;

  box.addEventListener("mouseenter", (e) => {
    const { clientX, clientY } = e as MouseEvent;
    const { left, top, width, height } = box.getBoundingClientRect();

    const centerX = left + width / 2;
    const centerY = top + height / 2;

    const deltaX = clientX - centerX; // 水平偏差
    const deltaY = clientY - centerY; // 垂直偏差
    if (Math.abs(deltaX) > Math.abs(deltaY)) {
      direction.value = deltaX > 0 ? "right" : "left";
    } else {
      direction.value = deltaY > 0 ? "bottom" : "top";
    }
  });
});
</script>

<style scoped lang="scss">
.mouse-direction-container {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 100px;
}

.box {
  width: 400px;
  height: 300px;
  background-color: red;
  position: relative;
  overflow: hidden;
  &.top {
    .top-img {
      transform: translateY(0);
    }
  }
  &.right {
    .right-img {
      transform: translateX(0);
    }
  }
  &.bottom {
    .bottom-img {
      transform: translateY(0);
    }
  }
  &.left {
    .left-img {
      transform: translateX(0);
    }
  }
  img {
    position: absolute;
    transition: all 0.3s linear;
    &.center-img {
      top: 0;
      left: 0;
      z-index: 1;
    }
    &.top-img {
      transform: translateY(-100%);
      z-index: 2;
    }
    &.right-img {
      transform: translateX(100%);
      z-index: 2;
    }
    &.bottom-img {
      transform: translateY(100%);
      z-index: 2;
    }
    &.left-img {
      transform: translateX(-100%);
      z-index: 2;
    }
  }
}
</style>

image.png

NuxtImage 配置上传目录配置

NuxtImage 配置上传目录配置

// https://nuxt.com/docs/api/configuration/nuxt-config
import { join } from 'path'

export default defineNuxtConfig({
  compatibilityDate: "2025-07-15",
  devtools: { enabled: true },
  app: {
    head: {
      title: "XJ",
      link: [
        {
          rel: "icon",
          href: "/images/favicon.ico",
        },
      ],
    },
  },
  modules: [
    "@nuxt/eslint",
    "@nuxt/image",
    "@nuxt/scripts",
    "@nuxt/ui",
    "nuxt-icons",
    "@nuxtjs/i18n"
  ],
  // 某些v-if里的图片无法加载
  nitro: {
    // [新配置] 将根目录下的 uploads 文件夹挂载为公共资源
    // 这样 Nitro 会直接服务这个目录下的文件,无需 Nginx 或自定义 API
    publicAssets: [
      {
        baseURL: '/uploads',
        dir: join(process.cwd(), 'uploads'),
        maxAge: 60 * 60 * 24 * 30, // 缓存 30 天
      }
    ],
    prerender: {
      routes: [
        "/_ipx/f_webp,avif,png&q_80/images/card_bg_1_xl.png",
        "/_ipx/f_webp,avif,png&q_80/images/card_bg_1_2xl.png",
        "/_ipx/f_webp,avif,png&q_80/images/card_bg_2_xl.png",
        "/_ipx/f_webp,avif,png&q_80/images/card_bg_3_xl.png",
        "/_ipx/f_webp,avif,png&q_80/images/card_bg_3_2xl.png",
        "/_ipx/f_webp,avif,png&q_80&s_457x534/images/contact_us_img.png",
        "/_ipx/f_webp,avif,png&q_80/images/contact_us_img_2.png",
      ],
    },
  },

  css: ["~/assets/css/main.css"],

  image: {
    domains: ['localhost', 'gwweb.gametest6.com'],
    alias: {
    // upload 目录需要挂载,并且能够访问
      '/uploads': process.env.NODE_ENV === 'production' ? 'https://mydomain/uploads' : 'http://localhost:3000/uploads'
    }
  },

  i18n: {
    defaultLocale: "en",
    locales: [
      {
        code: "en",
        name: "English",
        file: "en.json",
        icon: "/images/flag_en.png",
      },
      {
        code: "cn",
        name: "China",
        file: "cn.json",
        icon: "/images/flag_ch.png",
      },
    ],
  },
});

面试官问 React Fiber,这一篇文章就够了

看 React 源码或者技术文章的时候,经常会遇到 Fiber 这个词:

  • Fiber 到底是什么?为什么 React 要搞出这么个东西?
  • 都说 Fiber 让渲染可以"中断",具体是怎么实现的?
  • Fiber 和 Hooks、Suspense、Concurrent Mode 这些特性有什么关系?

这篇文章会把 Fiber 架构从头到尾讲清楚,包括它的设计动机、数据结构、工作流程,以及它是如何支撑 React 18/19 那些"并发特性"的。

为什么需要 Fiber?

Stack Reconciler 的困境

React 16 之前用的是 Stack Reconciler(栈调和器)。名字来源于它的工作方式——依赖 JavaScript 的调用栈来递归处理组件树。

当你调用 setState() 的时候,React 会从根节点开始,递归遍历整棵组件树,计算出哪些节点需要更新,然后一次性把变更应用到 DOM 上。

这个过程有个致命问题:同步且不可中断

假设你有一个包含 1000 个节点的列表,用户在输入框里打了个字,触发了状态更新。React 必须一口气把这 1000 个节点都 diff 完、更新完,才能把控制权还给浏览器。在这期间:

  • 用户输入没有响应(输入框卡住)
  • 动画掉帧(因为主线程被占用)
  • 整个页面感觉"卡顿"

问题的根源在于:所有更新都被当成同等优先级处理。用户的输入响应、动画渲染、后台数据更新,在 Stack Reconciler 眼里都一样——必须按顺序执行,谁也不能插队。

Fiber 的解题思路

2015 年,Facebook 开始开发 Fiber,2017 年随 React 16 正式发布。

Fiber 的核心思想是:把不可中断的递归调用,改成可中断的循环遍历

用 Andrew Clark(React 核心开发者)的话说:

"Fiber 是对调用栈的重新实现,专门为 React 组件设计。你可以把一个 fiber 想象成一个虚拟的栈帧,好处是你可以把这些栈帧保存在内存里,然后随时随地执行它们。"

这段话有点抽象,展开来说就是:

  1. 把大任务拆成小任务:每个组件的处理变成一个独立的"工作单元"(fiber)
  2. 每个小任务执行完都可以暂停:检查是否有更紧急的事情要做
  3. 高优先级任务可以插队:用户输入比后台数据更新重要
  4. 被中断的任务可以恢复:从上次暂停的地方继续

Fiber 数据结构

Fiber 不只是一个概念,它是一个具体的数据结构。每个 React 组件在内部都对应一个 fiber 节点,这些节点组成一棵树,但不是普通的树——是用链表串联的树。

FiberNode 的关键字段

直接看 React 源码中的 FiberNode 构造函数(简化版):

function FiberNode(tag, pendingProps, key, mode) {
  // ========== 身份标识 ==========
  this.tag = tag;           // fiber 类型:FunctionComponent、ClassComponent、HostComponent 等
  this.key = key;           // 用于 diff 的唯一标识
  this.type = null;         // 组件函数/类,或者 DOM 标签名(如 'div')
  this.stateNode = null;    // 对应的真实 DOM 节点,或者类组件的实例

  // ========== 树结构指针 ==========
  this.return = null;       // 父节点
  this.child = null;        // 第一个子节点
  this.sibling = null;      // 下一个兄弟节点
  this.index = 0;           // 在兄弟节点中的位置

  // ========== 状态相关 ==========
  this.pendingProps = pendingProps;   // 新的 props(待处理)
  this.memoizedProps = null;          // 上次渲染用的 props
  this.memoizedState = null;          // 上次渲染的 state(Hooks 链表也存这里)
  this.updateQueue = null;            // 状态更新队列

  // ========== 副作用 ==========
  this.flags = NoFlags;               // 副作用标记:Placement、Update、Deletion 等
  this.subtreeFlags = NoFlags;        // 子树中的副作用标记
  this.deletions = null;              // 需要删除的子节点

  // ========== 调度相关 ==========
  this.lanes = NoLanes;               // 当前节点的优先级
  this.childLanes = NoLanes;          // 子树中的优先级

  // ========== 双缓冲 ==========
  this.alternate = null;              // 指向另一棵树中对应的 fiber
}

tag 字段:fiber 的类型标识

tag 决定了 React 如何处理这个 fiber。常见的类型包括:

tag 值 类型名称 说明
0 FunctionComponent 函数组件
1 ClassComponent 类组件
3 HostRoot 根节点(ReactDOM.createRoot() 创建的)
5 HostComponent 原生 DOM 元素,如 <div>
6 HostText 文本节点
7 Fragment <React.Fragment>
11 ForwardRef React.forwardRef() 创建的组件
14 MemoComponent React.memo() 包装的组件
15 SimpleMemoComponent 简单的 memo 组件

React 根据 tag 来决定调用什么方法。比如遇到 FunctionComponent 就执行函数拿返回值,遇到 ClassComponent 就调用 render() 方法。

链表树结构

Fiber 树用三个指针串联:

         ┌─────────────────────────────────────────────┐
         │                   App                        │
         │          (return: null)                      │
         └─────────────────────────────────────────────┘
                           │
                         child
                           ↓
         ┌─────────────────────────────────────────────┐
         │                 Header                       │
         │            (return: App)                     │
         └─────────────────────────────────────────────┘
                           │
                         child                  sibling
                           ↓                       ↓
         ┌─────────────────┐              ┌─────────────────┐
         │      Logo       │   sibling    │      Nav        │
         │  (return: Header)│ ─────────→  │  (return: Header)│
         └─────────────────┘              └─────────────────┘
  • child:指向第一个子节点
  • sibling:指向下一个兄弟节点
  • return:指向父节点

为什么用链表而不是数组存子节点?因为链表可以方便地暂停和恢复遍历——只需要记住当前处理到哪个节点就行。

双缓冲机制

Fiber 使用"双缓冲"技术,同时维护两棵树:

  • current 树:当前屏幕上显示的内容
  • workInProgress 树:正在构建的新树

两棵树的节点通过 alternate 指针互相引用:

   current 树                    workInProgress 树

   ┌─────────┐      alternate     ┌─────────┐
   │  App    │ ←─────────────────→│  App    │
   │(current)│                    │ (WIP)   │
   └─────────┘                    └─────────┘
       │                              │
   ┌─────────┐      alternate     ┌─────────┐
   │ Header  │ ←─────────────────→│ Header  │
   │(current)│                    │ (WIP)   │
   └─────────┘                    └─────────┘

这个设计的好处:

  1. 渲染过程中不影响当前显示:所有变更都在 workInProgress 树上进行
  2. 原子化提交:构建完成后,直接把 workInProgress 变成 current(交换指针)
  3. 复用 fiber 节点:下次更新时,current 树变成 workInProgress 树的基础,减少内存分配

工作循环:Fiber 如何执行

Fiber 的核心执行逻辑在 workLoop 函数中。整个过程分为两个阶段:Render 阶段Commit 阶段

整体流程

flowchart TB
    subgraph render["Render 阶段(可中断)"]
        direction TB
        A[开始更新] --> B[选取下一个工作单元]
        B --> C{有工作单元?}
        C -->|是| D[beginWork: 处理当前节点]
        D --> E{有子节点?}
        E -->|是| B
        E -->|否| F[completeWork: 完成当前节点]
        F --> G{有兄弟节点?}
        G -->|是| B
        G -->|否| H[返回父节点继续 complete]
        H --> I{回到根节点?}
        I -->|否| G
        I -->|是| J[Render 阶段结束]
        C -->|否| J
    end

    subgraph commit["Commit 阶段(不可中断)"]
        direction TB
        K[开始提交] --> L[Before Mutation]
        L --> M[Mutation: 操作 DOM]
        M --> N[Layout: 执行副作用]
        N --> O[完成]
    end

    J --> K

    style render fill:#cce5ff,stroke:#0d6efd
    style commit fill:#d4edda,stroke:#28a745

Render 阶段:构建 workInProgress 树

Render 阶段的目标是:遍历 fiber 树,找出哪些节点需要更新,打上标记(flags),构建完整的 workInProgress 树。

这个阶段是可中断的——React 可以在处理完任意一个 fiber 后暂停,把控制权交还给浏览器。

workLoop:工作循环

function workLoop() {
  // 循环处理每个工作单元
  while (workInProgress !== null) {
    performUnitOfWork(workInProgress);
  }
}

function performUnitOfWork(unitOfWork) {
  const current = unitOfWork.alternate;

  // 1. beginWork:处理当前节点,返回子节点
  const next = beginWork(current, unitOfWork, renderLanes);

  // 2. 更新 memoizedProps
  unitOfWork.memoizedProps = unitOfWork.pendingProps;

  if (next === null) {
    // 没有子节点了,完成当前节点的工作
    completeUnitOfWork(unitOfWork);
  } else {
    // 有子节点,继续处理子节点
    workInProgress = next;
  }
}

beginWork:向下遍历,标记更新

beginWork 负责处理当前 fiber 节点,主要做这些事:

  1. 判断是否可以跳过:如果 props 和 state 都没变,直接跳过这个子树
  2. 根据 fiber 类型执行不同逻辑:函数组件就执行函数,类组件就调用 render 方法
  3. 创建/更新子 fiber 节点:对比新旧 children,进行 diff
  4. 返回第一个子 fiber:作为下一个工作单元
function beginWork(current, workInProgress, renderLanes) {
  // 优化:如果没有更新,可以跳过
  if (current !== null) {
    const oldProps = current.memoizedProps;
    const newProps = workInProgress.pendingProps;

    if (oldProps === newProps && !hasContextChanged()) {
      // 没有变化,尝试跳过
      return bailoutOnAlreadyFinishedWork(current, workInProgress, renderLanes);
    }
  }

  // 根据 fiber 类型分发处理
  switch (workInProgress.tag) {
    case FunctionComponent:
      return updateFunctionComponent(current, workInProgress, renderLanes);
    case ClassComponent:
      return updateClassComponent(current, workInProgress, renderLanes);
    case HostComponent:
      return updateHostComponent(current, workInProgress, renderLanes);
    // ... 其他类型
  }
}

completeWork:向上回溯,准备 DOM

当一个 fiber 节点没有子节点(或所有子节点都处理完了),就会调用 completeWork

  1. 创建/更新真实 DOM 节点:但还不挂载到页面上
  2. 收集副作用:把有 flags 的节点串成链表,方便 Commit 阶段处理
  3. 冒泡 subtreeFlags:让父节点知道子树中有没有需要处理的副作用
function completeWork(current, workInProgress, renderLanes) {
  const newProps = workInProgress.pendingProps;

  switch (workInProgress.tag) {
    case HostComponent: {
      const type = workInProgress.type; // 如 'div'

      if (current !== null && workInProgress.stateNode !== null) {
        // 更新:对比新旧 props,计算需要更新的属性
        updateHostComponent(current, workInProgress, type, newProps);
      } else {
        // 新建:创建 DOM 元素
        const instance = createInstance(type, newProps);
        // 把所有子 DOM 节点挂到这个元素上
        appendAllChildren(instance, workInProgress);
        workInProgress.stateNode = instance;
      }

      // 收集副作用标记到父节点
      bubbleProperties(workInProgress);
      return null;
    }
    // ... 其他类型
  }
}

遍历顺序示意

假设有这样一棵组件树:

       App
      /   \
   Header  Main
    /  \      \
  Logo  Nav   Content

Fiber 的遍历顺序是深度优先,但会在每个节点上执行 beginWork(向下)和 completeWork(向上):

1. beginWork(App)
2. beginWork(Header)
3. beginWork(Logo)
4. completeWork(Logo)     ← Logo 没有子节点,开始 complete
5. beginWork(Nav)         ← 回到 Header,处理下一个子节点
6. completeWork(Nav)
7. completeWork(Header)   ← Header 所有子节点处理完,complete 自己
8. beginWork(Main)
9. beginWork(Content)
10. completeWork(Content)
11. completeWork(Main)
12. completeWork(App)     ← 回到根节点,Render 阶段结束

Commit 阶段:应用变更

Render 阶段完成后,workInProgress 树已经构建好了,所有需要的变更也都标记好了。接下来就是 Commit 阶段——把这些变更实际应用到 DOM 上。

Commit 阶段是同步的、不可中断的。因为这个阶段要操作真实 DOM,必须一次性完成,否则用户会看到不一致的 UI。

Commit 阶段分为三个子阶段:

1. Before Mutation(DOM 操作前)

  • 调用 getSnapshotBeforeUpdate 生命周期方法
  • 调度 useEffect 的清理函数(异步)

2. Mutation(执行 DOM 操作)

这是真正修改 DOM 的阶段:

function commitMutationEffects(root, finishedWork) {
  while (nextEffect !== null) {
    const flags = nextEffect.flags;

    // 处理 DOM 插入
    if (flags & Placement) {
      commitPlacement(nextEffect);
    }

    // 处理 DOM 更新
    if (flags & Update) {
      commitWork(current, nextEffect);
    }

    // 处理 DOM 删除
    if (flags & Deletion) {
      commitDeletion(root, nextEffect);
    }

    nextEffect = nextEffect.nextEffect;
  }
}

3. Layout(DOM 操作后)

  • 调用 componentDidMount / componentDidUpdate
  • 调用 useLayoutEffect 的回调
  • 更新 ref

最后,React 把 current 指针指向 workInProgress 树,完成树的切换。

优先级调度:让重要的事先做

Fiber 架构的一大优势是支持优先级调度。不是所有更新都同等重要——用户输入应该比后台数据刷新更快响应。

Lane 优先级模型

React 18 使用 Lane 模型来表示优先级。每个优先级是一个 32 位整数中的一个位:

const NoLanes = 0b0000000000000000000000000000000;
const SyncLane = 0b0000000000000000000000000000001;         // 最高优先级:同步
const InputContinuousLane = 0b0000000000000000000000000100; // 连续输入,如拖拽
const DefaultLane = 0b0000000000000000000000000010000;      // 默认优先级
const TransitionLane1 = 0b0000000000000000000001000000;     // Transition
const IdleLane = 0b0100000000000000000000000000000;         // 空闲时执行

位运算的好处是可以高效地合并、比较多个优先级:

// 合并优先级
const mergedLanes = lane1 | lane2;

// 判断是否包含某优先级
const includesLane = (lanes & lane) !== 0;

// 获取最高优先级
const highestLane = lanes & -lanes;

Scheduler:任务调度器

React 有一个独立的 Scheduler 包,负责按优先级调度任务。它的核心思想是时间切片(Time Slicing):

  1. 把渲染工作拆成多个小任务
  2. 每个小任务执行完后,检查是否有更高优先级的任务
  3. 如果有,暂停当前工作,先处理高优先级任务
  4. 如果时间片用完了(通常是 5ms),让出主线程给浏览器
function workLoopConcurrent() {
  // 循环执行工作单元,但会在时间片用完时暂停
  while (workInProgress !== null && !shouldYield()) {
    performUnitOfWork(workInProgress);
  }
}

function shouldYield() {
  // 检查是否还有剩余时间,或者是否有更高优先级的任务
  return getCurrentTime() >= deadline || hasHigherPriorityWork();
}

时间切片的效果

没有时间切片的情况下,一个耗时 200ms 的渲染任务会完全阻塞主线程:

主线程:[==== 200ms 渲染任务 ====]
         ^                    ^
         |                    |
    用户点击                200ms 后才响应

有时间切片后,渲染任务被拆成多个 5ms 的小块,中间可以响应用户交互:

主线程:[5ms][响应点击][5ms][5ms][5ms]...[5ms]
         ^      ^
         |      |
    用户点击   立即响应

startTransition:标记低优先级更新

React 18 提供了 startTransition API,让开发者可以手动标记哪些更新是"可以延迟的":

import { startTransition } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  function handleChange(e) {
    // 高优先级:立即更新输入框
    setQuery(e.target.value);

    // 低优先级:搜索结果可以稍后更新
    startTransition(() => {
      setResults(search(e.target.value));
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <SearchResults results={results} />
    </>
  );
}

在这个例子中,用户每次输入都会触发两个更新:

  1. setQuery:高优先级,输入框立即响应
  2. setResults:低优先级,包裹在 startTransition 里,可以被打断

如果用户快速输入,搜索结果的渲染可能会被跳过几次,直到用户停止输入。这保证了输入框始终流畅响应,而不会被搜索结果的渲染阻塞。

Hooks 与 Fiber 的关系

Hooks 是怎么实现的?答案就在 Fiber 节点的 memoizedState 字段里。

Hooks 链表

每个函数组件的 fiber 节点都有一个 memoizedState 字段,存储着该组件所有 hooks 的状态。这些状态以链表的形式串联:

FiberNode
├── memoizedState ─→ Hook1 ─→ Hook2 ─→ Hook3 ─→ null
│                    (useState) (useEffect) (useMemo)
└── ...

每个 hook 节点的结构大致如下:

const hook = {
  memoizedState: any,     // 这个 hook 存储的值
  baseState: any,         // 更新前的基础状态
  baseQueue: Update | null,
  queue: UpdateQueue,     // 待处理的更新队列
  next: Hook | null       // 指向下一个 hook
};

useState 的实现原理

当你在组件里调用 useState(0) 时,React 做的事情:

首次渲染(mount):

function mountState(initialState) {
  // 1. 创建一个新的 hook 节点,挂到链表上
  const hook = mountWorkInProgressHook();

  // 2. 计算初始值
  if (typeof initialState === 'function') {
    initialState = initialState();
  }

  // 3. 保存状态
  hook.memoizedState = hook.baseState = initialState;

  // 4. 创建更新队列
  hook.queue = {
    pending: null,
    dispatch: null,
    // ...
  };

  // 5. 返回 [state, setState]
  const dispatch = dispatchSetState.bind(null, currentlyRenderingFiber, hook.queue);
  hook.queue.dispatch = dispatch;

  return [hook.memoizedState, dispatch];
}

后续更新(update):

function updateState() {
  // 1. 找到当前 hook(按顺序从链表取)
  const hook = updateWorkInProgressHook();

  // 2. 处理所有待处理的更新
  const queue = hook.queue;
  let newState = hook.baseState;

  let update = queue.pending;
  while (update !== null) {
    // 应用每个更新
    newState = typeof update.action === 'function'
      ? update.action(newState)
      : update.action;
    update = update.next;
  }

  // 3. 保存新状态
  hook.memoizedState = newState;

  return [newState, queue.dispatch];
}

为什么 Hooks 不能在条件语句里调用?

因为 hooks 是按调用顺序存储在链表里的,React 靠顺序来匹配每次渲染时的 hook。

假设你这样写:

function Bad() {
  const [count, setCount] = useState(0);

  if (count > 0) {
    useEffect(() => { /* ... */ }); // 危险!
  }

  const [name, setName] = useState('');
  // ...
}

第一次渲染时 count 是 0,hooks 链表是:

Hook1(useState: count) → Hook2(useState: name) → null

第二次渲染时 count 变成 1,useEffect 被执行了,链表变成:

Hook1(useState: count) → Hook2(useEffect) → Hook3(useState: name) → null

React 按顺序取 hook,第二个位置取到了 useEffect,但代码里第二个 hook 是 useState,类型对不上,直接报错。

所以 React 的规则是:Hooks 只能在函数组件的最顶层调用,不能在条件、循环或嵌套函数里。

useEffect 的实现原理

useEffect 的状态存储方式略有不同,它的副作用会被收集到 fiber 的 updateQueue 中:

function mountEffect(create, deps) {
  const hook = mountWorkInProgressHook();
  const nextDeps = deps === undefined ? null : deps;

  // 标记这个 fiber 有 Passive 副作用
  currentlyRenderingFiber.flags |= PassiveEffect;

  // 创建 effect 对象,挂到 fiber 的 updateQueue
  hook.memoizedState = pushEffect(
    HasEffect | Passive,
    create,
    undefined,
    nextDeps
  );
}

Effect 链表结构:

FiberNode.updateQueue.effectsEffect1Effect2Effect3Effect1 (循环链表)
  ├── create: () => { ... }     // 执行的函数
  ├── destroy: () => { ... }    // 清理函数(上次 create 的返回值)
  ├── deps: [a, b]              // 依赖数组
  └── next: Effect2

在 Commit 阶段完成后,React 会异步执行所有 Passive 类型的 effects:

  1. 先执行所有 effect 的 destroy(清理函数)
  2. 再执行所有 effect 的 create(新的副作用)

这也是为什么 useEffect 总是在 DOM 更新后异步执行。

Fiber 支撑的高级特性

Fiber 架构不只是让渲染可以中断,它还是很多 React 高级特性的基础。

Suspense:优雅处理异步

Suspense 让你可以在等待异步数据时显示 fallback UI:

function ProfilePage() {
  return (
    <Suspense fallback={<Spinner />}>
      <ProfileDetails />
    </Suspense>
  );
}

function ProfileDetails() {
  const user = use(fetchUser()); // 这个 promise 还没 resolve 时会"挂起"
  return <h1>{user.name}</h1>;
}

Suspense 的实现依赖 Fiber 的以下能力:

  1. 抛出 Promise:当组件需要等待异步数据时,可以 throw 一个 Promise
  2. 捕获并暂停:Fiber 在遍历时捕获这个 Promise,标记当前子树为"挂起"状态
  3. 显示 fallback:渲染最近的 Suspense 边界的 fallback
  4. 恢复渲染:Promise resolve 后,从挂起的地方重新开始渲染
sequenceDiagram
    participant App
    participant Suspense
    participant ProfileDetails
    participant Fiber

    App->>Suspense: 渲染
    Suspense->>ProfileDetails: 渲染
    ProfileDetails->>Fiber: throw Promise
    Fiber->>Suspense: 捕获 Promise,显示 fallback
    Note over Suspense: 显示 <Spinner />
    ProfileDetails-->>Fiber: Promise resolved
    Fiber->>ProfileDetails: 重新渲染
    ProfileDetails->>Suspense: 返回真实内容
    Note over Suspense: 显示 <h1>用户名</h1>

Concurrent Rendering:并发渲染

React 18 的并发模式完全建立在 Fiber 之上。它的核心能力是:React 可以同时准备多个版本的 UI

比如使用 useTransition

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('home');

  function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <>
      <TabButton onClick={() => selectTab('home')}>Home</TabButton>
      <TabButton onClick={() => selectTab('posts')}>Posts</TabButton>
      <TabButton onClick={() => selectTab('contact')}>Contact</TabButton>

      {isPending && <Spinner />}

      <TabPanel tab={tab} />
    </>
  );
}

当用户点击 "Posts" tab 时:

  1. React 开始在"后台"渲染 Posts 页面(构建 workInProgress 树)
  2. 同时保持显示当前的 Home 页面(current 树不变)
  3. 如果 Posts 渲染很慢,用户可以点击其他 tab,之前的渲染会被放弃
  4. 渲染完成后,React 才会切换到新的 UI

这就是"并发"的含义:多个渲染任务可以同时存在,React 可以在它们之间切换。

Error Boundaries:错误边界

Error Boundaries 也依赖 Fiber 的树结构:

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, info) {
    logError(error, info.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}

当子组件抛出错误时,Fiber 会:

  1. 沿着 return 指针向上查找最近的 Error Boundary
  2. 标记该 Error Boundary 的 fiber 有 ShouldCapture flag
  3. 在 Commit 阶段调用 componentDidCatch
  4. 重新渲染 Error Boundary,显示降级 UI

Server Components:服务端组件

React Server Components 也依赖 Fiber 架构。服务端渲染时,React 会:

  1. 在服务端构建 Fiber 树
  2. 将 Fiber 树序列化成特殊的"Flight"格式
  3. 客户端接收后,重建 Fiber 树,进行 Hydration

由于 Fiber 是一个可序列化的数据结构(本质上是一棵树),它可以在服务端和客户端之间传输。

实际影响:性能优化建议

理解了 Fiber 架构,可以更好地进行性能优化。

1. 避免频繁创建新对象

每次渲染时创建新对象会导致 props 变化,触发不必要的子组件更新:

// 差:每次渲染都创建新的 style 对象
function Bad() {
  return <div style={{ color: 'red' }}>...</div>;
}

// 好:把常量提到组件外面
const style = { color: 'red' };
function Good() {
  return <div style={style}>...</div>;
}

在 Fiber 的 beginWork 阶段,React 会比较新旧 props。如果是同一个引用,可以快速跳过子树的处理。

2. 合理使用 key

key 帮助 React 在 diff 时识别哪些元素改变了位置:

// 差:用 index 作为 key,列表重排序时会有问题
items.map((item, index) => <Item key={index} {...item} />)

// 好:用稳定的唯一 ID
items.map(item => <Item key={item.id} {...item} />)

Fiber 在 reconcile children 时,会用 key 来复用已有的 fiber 节点,而不是删除再创建。

3. 使用 startTransition 处理大量更新

如果某个操作会触发大量组件更新(比如筛选长列表),用 startTransition 标记为低优先级:

function FilteredList({ items }) {
  const [filter, setFilter] = useState('');
  const [filteredItems, setFilteredItems] = useState(items);

  function handleFilterChange(e) {
    const value = e.target.value;
    setFilter(value); // 高优先级:输入框立即响应

    startTransition(() => {
      // 低优先级:过滤操作可以稍后执行
      setFilteredItems(items.filter(item =>
        item.name.includes(value)
      ));
    });
  }

  return (
    <>
      <input value={filter} onChange={handleFilterChange} />
      <ItemList items={filteredItems} />
    </>
  );
}

4. 使用 useDeferredValue 延迟非关键更新

useDeferredValue 可以让某个值的更新延后:

function SearchResults({ query }) {
  // 这个值会延迟更新,不阻塞输入
  const deferredQuery = useDeferredValue(query);

  // 基于延迟的值进行渲染
  const results = useMemo(
    () => slowSearch(deferredQuery),
    [deferredQuery]
  );

  return <ResultList results={results} />;
}

5. Suspense 配合 lazy 做代码分割

const HeavyComponent = lazy(() => import('./HeavyComponent'));

function App() {
  return (
    <Suspense fallback={<Loading />}>
      <HeavyComponent />
    </Suspense>
  );
}

Fiber 会在 HeavyComponent 加载时挂起,显示 Loading,加载完成后自动恢复渲染。

调试 Fiber

React DevTools 可以直接查看 Fiber 树。打开 DevTools 的 Components 面板,你看到的组件树本质上就是 Fiber 树的可视化。

你还可以在浏览器控制台里访问 fiber:

// 获取某个 DOM 元素对应的 fiber
const fiber = domElement._reactFiber$...; // 键名是动态的

// 查看 fiber 的结构
console.log(fiber.type);         // 组件类型
console.log(fiber.memoizedState); // 状态(hooks 链表)
console.log(fiber.memoizedProps); // props
console.log(fiber.child);        // 第一个子节点
console.log(fiber.sibling);      // 兄弟节点
console.log(fiber.return);       // 父节点

总结

React Fiber 是 React 从 16 版本开始的核心架构,它解决了旧版 Stack Reconciler 的几个关键问题:

  1. 可中断渲染:把递归调用改成循环遍历,每处理完一个 fiber 就可以暂停
  2. 优先级调度:不同类型的更新有不同优先级,重要的先做
  3. 并发渲染:可以同时准备多个版本的 UI,按需切换
  4. 增量渲染:把渲染工作拆成小块,分散到多个帧中

Fiber 的核心是一个链表树结构的数据模型,加上双缓冲技术和两阶段渲染(Render + Commit)。它是 Hooks、Suspense、Concurrent Mode、Server Components 等现代 React 特性的基础设施。

理解 Fiber 不是为了在日常开发中直接操作它,而是为了:

  • 理解 React 的工作原理,写出更高效的代码
  • 更好地使用 startTransitionuseDeferredValue 等 API
  • 排查性能问题时知道从哪里入手
  • 读懂 React 源码和技术文章

最后放几个深入学习的资源:


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

Gemini 3做粒子交互特效很出圈?拿 TRAE SOLO 来实现一波!

大家好,我是不如摸鱼去,欢迎来到我的 AI 编程分享专栏。

前几天在家养病时刷抖音,刷到了很多用 Gemini 3做粒子交互特效的视频,感觉很不错,TRAE SOLO 正好也集成了 Gemini 3 pro ,今天就分享一下如何用 TRAE SOLO 快速制作一个告白粒子交互特效。

关于 TRAE SOLO 正式版

11 月 12 日 TRAE SOLO 正式版上线了,新增三栏布局、DiffView 工具、SOLO Coder + Plan,支持多任务并行等功能,在前几天集成了 Gemini 3 pro 也算是填补了 Claude Sonnet 4 的空白。

我可是 TRAE SOLO 老用户了,SOLO 模式(Beta 版)上线的时候我就拿到了 SOLO CODE,并且使用它完成了「复刻童年坦克大战」、「像老乡鸡那样做饭小程序」等实践,并且在实际工作中使用 TRAE 参与了很多任务的开发。如今正式版发布,而且集成了 Gemini 3 pro,自然要尝试一番。

想想做点什么

当刷到类似的视频的时候,我想可以做个《伍六七》里面的「以气御剪」或者「魔刀千刃」,后来想了想放弃了,那个模型应该好复杂😂。后面又想到《楚门的世界》,不知怎么突然想到的,里面有句台词:假如再也见不到你,祝你早安午安晚安。就拿这句台词来做个交互效果吧,假如你再也刷不到我的文章,再也不会给我点赞,祝你早安午安晚安(手动🐶)。

开始SOLO

准备

我们可以把自己当作一个小白,完全不懂应该如何实现这个粒子交互特效,但是要哪些功能和手势是我们需要提供的,我总结了需求如下:

基于原生h5、浏览器、PC摄像头实现手势控制粒子特效交互的逻辑,粒子默认离散,类似银河系分布缓慢移动,同时有5种手势:

手势1: 握拳,握拳后粒子聚拢显示爱心的形状

手势2: 展开手掌并挥手,展开手掌挥手后粒子从当前状态恢复到离散状态

手势3: 👆 比 1 :只有食指伸直,其他 3 根弯曲,此时粒子聚拢显示第一句话:以防再也见不到你

手势4: ✌ 比 2 :只有食指和中指伸直,其他 2 根弯曲手此时粒子聚拢显示第二句话: 祝你

手势5: 👌 比 3 :只有中指、无名指、小指伸直,食指弯曲,此时粒子聚拢显示第三句话:早安,午安,晚安

然后打开 TRAE,切换到 SOLO Builder 模式,选择 Gemini-3-Pro 模型,当然也可以选择 GPT5 ,不过我感觉它不如 Gemini-3-Pro 好用。

SOLO

发送我们的需求给 TRAE SOLO

我们的需求很简单,它一轮就做出来了,并且会输出总结

我们可以看到它完整实现了需求中提到的功能,并且将技术栈和代码结构介绍得很清楚。

部署

在 TRAE 的中栏选择浏览器,并部署,它会帮我们把制作好的内容部署到 vercel 上

部署地址:traeproject1ckaf.vercel.app/

体验效果

效果还不错,符合需求了,哈哈。

ezgif-1f6b77c49a889e7d.gif

总结

本次我们用TRAE SOLO 轻松实现了《早安午安晚安粒子交互特效》,可以看出 TRAE SOLO 正式版发布之后,功能上的增强还是不错的,还有很多我们本次没用到的功能例如 Plan 模式等也很有用,接入了 Gemini-3-Pro 后大模型的短板基本补上了。还有一点我们也可以从最近的更新动作上看出 TRAE 是想走大众编程的路线,它想让更多少基础和无基础的人也能使用到 AI 编程。我在这也希望国产的 AI 编程工具和大模型能更越来越好吧。

最后,祝你早安午安晚安。

欢迎评论区沟通、讨论、分享👇👇

WebP/AVIF 有多香?比 JPEG 小 30%,<picture>标签完美解决兼容性

🖼️ 告别“图多杀猫”:前端图片加载策略全攻略,让你的页面又快又美!

前端性能优化专栏 - 第七篇

在前端性能优化的战场上,图片无疑是最大的“资源杀手”。它们通常占据了网页下载体积的一半以上。一个图片加载策略不当的网站,就像一辆装满了超重行李的跑车,再好的引擎也跑不快。

本篇,我们就来深入探讨如何系统性地优化图片加载,让你的页面既高清又秒开!


⚠️ 图片优化的意义:不仅仅是快

图片优化是一种系统性工程,它的意义远超乎“加载快一点”:

  • 体积最大: 图片是网页中体积最大的静态资源之一,优化可显著降低页面首屏加载时间。
  • 影响 SEO: 页面加载速度是搜索引擎排名的重要指标。
  • 影响转化率: 页面每延迟一秒,用户流失率和转化率都会受到负面影响。
  • 用户留存: 快速、流畅的体验是用户留下的关键。

✨ 策略一:格式选择 — 新旧格式的博弈

选择合适的图片格式是优化的第一步,也是最重要的一步。我们必须根据场景和兼容性,在新旧格式之间做出权衡。

1. 新一代格式:WebP 与 AVIF

格式 优势 适用场景 兼容性
WebP 更高压缩率,体积比 JPEG 小约 30%,支持无损和有损压缩,支持透明度。 网页中的大部分图片,尤其是需要透明度的图标。 广泛支持(Chrome, Firefox, Edge, Safari)。
AVIF 基于 AV1 视频编码,压缩率比 WebP 更高,体积更小。 对性能要求极高,且目标用户群浏览器支持度较高的场景。 较新,支持度稍低于 WebP。

图片格式体积与质量对比图

2. 传统格式:JPEG 与 PNG

  • JPEG: 适合色彩丰富的照片大图,采用有损压缩,不支持透明度。
  • PNG: 适合需要透明背景的图标、Logo 或截图,采用无损压缩,体积通常较大。

🔄 策略二:响应式图片 — 按需加载的智慧

在移动设备、平板和高清屏并存的今天,给所有设备都加载同一张大图是极大的浪费。响应式图片就是解决这个问题的“智慧大脑”。

1. srcset 实现分辨率适配

srcset 允许浏览器根据设备的分辨率视口大小来选择最合适的图片源。

<img srcset="image-480w.jpg 480w,
             image-800w.jpg 800w"(图片源的集合)
     sizes="(max-width: 600px) 480px, 800px"(提供上下文,<=600像素使用480,大于使用800)
     src="image-800w.jpg"(降级方案,为旧浏览器提供)
     alt="响应式图片">
  • srcset 告诉浏览器有哪些可用的图片资源及其对应的宽度(w)。
  • sizes 告诉浏览器在不同视口大小下,图片将占据的宽度。
  • src 作为旧浏览器的降级方案,保证兼容性。

2. <picture> 元素实现格式切换

<picture> 元素可以实现更高级的控制,让浏览器根据格式兼容性来选择图片源,完美解决新格式的兼容性问题。

<picture>(从上往下依次询问是否符合这个source)
  <source srcset="image.avif" type="image/avif">
  <source srcset="image.webp" type="image/webp">
  <img src="image.jpg" alt="多格式图片">
</picture>

工作原理: 浏览器会从上往下依次检查 <source> 标签,一旦找到一个它支持的 type 或符合条件的 srcset,就会加载该资源,并忽略后续的 <source>。如果所有 <source> 都不支持,则会加载最后的 <img> 标签中的 src

picture 和 srcset 响应式加载示意图

核心优势:

  • 浏览器自动选择最佳格式与尺寸
  • 避免浪费带宽与不必要的下载

🔧 策略三:加载时机 — 懒加载与预加载

除了图片本身,何时加载图片也至关重要。

1. 懒加载(Lazy Loading)

对于首屏以外的图片,应采用懒加载。即只有当图片进入或即将进入用户视口时才开始加载。

  • 原生支持: 现代浏览器已支持 loading="lazy" 属性,无需 JavaScript 库。

    <img src="image.jpg" loading="lazy" alt="懒加载图片">
    

2. 预加载(Preloading)

对于首屏关键的图片(如 Logo、背景图),应使用预加载,确保它们尽快被加载。

  • HTML 标签:

    <link rel="preload" href="critical-image.jpg" as="image">
    

✅ 总结与实践:图片优化 Check List

图片优化是一个多维度的工程,以下是实践中的 Check List:

优化层面 实践方法 目标
格式层面 优先使用 WebP / AVIF,PNG/JPEG 作为降级。 最小化文件体积。
响应式层面 利用 <picture>srcset 实现格式和尺寸的按需加载。 避免加载不必要的资源。
加载层面 首屏关键图使用预加载,非首屏图使用懒加载。 优化加载时机,提升首屏速度。
压缩层面 在构建阶段使用工具(如 imagemin)进行自动化无损/有损压缩 保证图片质量的同时进一步减小体积。
架构层面 接入 CDN 与专业的图片服务(如阿里云 OSS、七牛云等)。 自动处理格式转换、缩放和压缩。

下一篇预告: 面对大量的图标和小图片,一个个请求不仅慢,还会造成大量的 HTTP 开销。下一篇我们将探讨如何将这些零散的资源“打包”起来,学习 雪碧图(CSS Sprites) 的原理和实践,以及如何利用它来优化 SVG 的加载。敬请期待!

告别 AI 输出的重复解析:正常 markdown 解析渲染也能提速 2-10 倍以上

incremark 的惊人实力

昨天发布了周日开发的 incremark,没想到实际体验性能远超预期,本想作为自己产品内部的一个小工具,但想了想,开源未尝不是一个好的方向。

先看结果:普通比较短的 markdown 在线测试结果

image.png

稍长的 markdown 在线测试结果

image.png

说明:网站每次 benchmark 测试每次提升倍数可能不一样,这是由于分块策略导致的,demo 页面每次分块的 markdown 字符串长度是随机的 const chunks = content.match(/[\s\S]{1,20}/g) || [],分块会影响稳定块的生成,这样也更符合真实场景,某个块可能会有上一个块的内容跟下一个块的内容。但无论怎样分,性能提升是必然的,demo 网站未通过迎合自身利益的手段进行 chunk 拆分。

vue demo 在线链接:incremark-vue.vercel.app/

react demo 在线链接:incremark-react.vercel.app/

文档链接:incremark-docs.vercel.app/

超长的 markdown 输出会来更更加离谱的速度,20k 的 markdown benchmark 可以达到恐怖的 46 倍速度提升。

为什么它会有如此提升?

传统解析方案弊端

做过 AI 聊天应用的人可能都知道,AI 的流式输出在前端每次获取到的都是一个个简短的 chunk,每次 chunk 过来后,都需要将完整的 markdown 字符串喂给 markdown 解析器,可能是 remark、 markedjs,也可能是 markdown-it。它们每次都需要将所有的 markdown 进行解析,最后在进行渲染,在此过程中会有一部分性能浪费,即已经稳定渲染的块也要被重复解析。

vue-stream-markdown 这类型工具在渲染层也做了努力,将稳定的 token 渲染为稳定的组件,每次只更新不稳定的组件,以此达到渲染层的平滑流式输出。

但、这仍无法解决性能浪费的本质问题,也就是 markdown 文本的重复解析,这一步才是吃掉 cpu 性能的怪兽,输出文档越长,则性能浪费越高。

incremark 核心性能优化原理

incremark 除了做到了 UI 渲染层的组件复用平滑更新,最主要的是在 markdown 解析上做了文档,只解析不稳定的 markdown,不再重复解析稳定的块。因此将解析性能从 O(n²) 拉到了 O(n),理论上输出越长,提升越多。

1. 增量解析:从 O(n²) 到 O(n)

传统解析器每次都重新解析整个文档,导致解析量呈平方增长。Incremark 的 IncremarkParser 类采用增量解析策略,见代码 IncremarkParser.ts

// 设计思路:
// 1. 维护一个文本缓冲区,接收流式输入 
// 2. 识别"稳定边界",将已完成的块标记为 completed 
// 3. 对于正在接收的块,每次重新解析,但只解析该块的内容 
// 4. 复杂嵌套节点作为整体处理,直到确认完成

2. 智能边界检测机制

append 方法中的 findStableBoundary() 是关键优化点

append(chunk: string): IncrementalUpdate {  
  this.buffer += chunk  
  this.updateLines()  
    
  const { line: stableBoundary, contextAtLine } = this.findStableBoundary()  
    
  if (stableBoundary >= this.pendingStartLine && stableBoundary >= 0) {  
    // 只解析新完成的块,不重复解析已完成内容  
    const stableText = this.lines.slice(this.pendingStartLine, stableBoundary + 1).join('\n')  
    const ast = this.parse(stableText)  
    // ...  
  }  
}

3. 状态管理避免重复计算

解析器维护多个关键状态来避免重复工作

  • buffer: 累积的未解析内容
  • completedBlocks: 已完成且永不重新解析的块数组
  • lineOffsets: 行偏移量前缀和,支持 O(1) 行位置计算
  • context: 跟踪代码块、列表等嵌套状态

4. 增量行更新优化

updateLines() 方法只处理新增内容,避免全量 split 操作

private updateLines(): void {  
  // 找到最后一个不完整的行(可能被新 chunk 续上)  
  const lastLineStart = this.lineOffsets[prevLineCount - 1]  
  const textFromLastLine = this.buffer.slice(lastLineStart)  
    
  // 重新 split 最后一行及之后的内容  
  const newLines = textFromLastLine.split('\n')  
  // 只更新变化的部分  
}

性能对比数据

这种设计在实际测试中表现优异 :

文档大小 传统解析字符量 Incremark 解析字符量 减少比例
1KB 1,010,000 20,000 98%
5KB 25,050,000 100,000 99.6%
20KB 400,200,000 400,000 99.9%

关键不变量

Incremark 的性能优势源于一个关键不变量:一旦块被标记为 completed,就永远不会被重新解析。这确保了每个字符最多只被解析一次,实现了 O(n) 的时间复杂度。

小结

昨天在掘金发布了 # AI 时代真正流式解析+渲染双重优化的 Incremark 并没有获得推流,在 V 站随意发帖下,github 意外收获了 35 颗 star,npm 收获 92 下载量,突然感觉它似乎应该是能对一些小伙伴产生作用的,所以尝试当个事来做,如果你也感兴趣,欢迎试用以及代码贡献。

flutter使用package_info_plus库获取应用信息的教程

1. 简介

在 Flutter 应用开发中,获取应用自身的信息(如应用名称、版本号、包名等)是一项常见需求,这些信息可用于展示在关于页面、用于统计分析或实现特定业务逻辑。package_info_plus 插件是 Flutter 生态中获取应用元数据的最佳选择,支持多平台且使用简单。本文将详细介绍如何使用该插件获取各类应用信息。

package_info_plus 是一个跨平台插件,用于获取应用的包信息和元数据,它是 package_info 插件的升级版,由 Flutter Community 维护。

核心特点

  • 支持 iOS、Android、Web、Windows、macOS 和 Linux 多平台
  • 能够获取应用名称、包名、版本号、构建号等关键信息
  • 用法简单,API 直观
  • 与最新的 Flutter 版本保持兼容

可获取的主要信息

  • 应用名称(应用商店中显示的名称)
  • 包名/应用 ID(如 com.example.myapp)
  • 版本名称(如 1.0.0)
  • 版本号/构建号(如 1)
  • 应用签名(部分平台)

2. 安装与配置

在项目的 pubspec.yaml 文件中添加 package_info_plus 依赖:

dependencies:
  flutter:
    sdk: flutter
  package_info_plus: ^4.0.0  # 使用最新版本

运行以下命令安装依赖:

flutter pub get

2.1. 平台特定配置

package_info_plus 在大多数平台上无需额外配置即可使用,但某些平台可能需要注意以下事项:

Web 平台: 需要在 web/index.html 中添加一些元数据,插件会从这里读取信息:

<head>
  <!-- 其他头部内容 -->
  <meta name="application-name" content="你的应用名称">
  <meta name="version" content="1.0.0">
  <meta name="build-number" content="1">
  <meta name="package" content="com.example.myapp">
</head>

其他平台: Android、iOS、Windows、macOS 和 Linux 平台不需要额外配置,插件会自动从各自的配置文件中读取信息。

3. 基本使用方法

在需要使用的 Dart 文件中导入 package_info_plus

import 'package:package_info_plus/package_info_plus.dart';

3.1. 获取应用信息

package_info_plus 提供了一个 PackageInfo 类,通过它可以获取所有应用信息。获取过程是异步的,通常使用 await 关键字:

Future<void> getAppInfo() async {
  PackageInfo packageInfo = await PackageInfo.fromPackageInfo();
  
  String appName = packageInfo.appName;
  String packageName = packageInfo.packageName;
  String version = packageInfo.version;
  String buildNumber = packageInfo.buildNumber;
  
  print('应用名称: $appName');
  print('包名: $packageName');
  print('版本号: $version');
  print('构建号: $buildNumber');
}

3.2. 详细信息说明

PackageInfo 类提供的主要属性:

属性 说明 平台支持
appName 应用的用户可见名称 所有平台
packageName 应用的唯一标识符(Android 的包名,iOS 的 Bundle ID 等) 所有平台
version 应用的版本名称(如 1.0.0) 所有平台
buildNumber 应用的构建号或版本代码(通常是整数) 所有平台
buildSignature 应用的签名信息 Android 特有
installerStore 应用的安装来源商店 Android 特有

4. 实战示例

下面是一个完整的示例,创建一个展示应用信息的"关于"页面:

import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';

class AboutPage extends StatefulWidget {
  const AboutPage({super.key});

  @override
  State<AboutPage> createState() => _AboutPageState();
}

class _AboutPageState extends State<AboutPage> {
  late Future<PackageInfo> _packageInfoFuture;

  @override
  void initState() {
    super.initState();
    // 初始化时获取应用信息
    _packageInfoFuture = PackageInfo.fromPackageInfo();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('关于我们'),
      ),
      body: FutureBuilder<PackageInfo>(
        future: _packageInfoFuture,
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          } else if (snapshot.hasError) {
            return Center(child: Text('获取应用信息失败: ${snapshot.error}'));
          } else if (!snapshot.hasData) {
            return const Center(child: Text('无法获取应用信息'));
          }

          PackageInfo packageInfo = snapshot.data!;

          return SingleChildScrollView(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              children: [
                // 应用图标
                const CircleAvatar(
                  radius: 60,
                  child: Icon(Icons.android, size: 60), // 实际项目中替换为应用图标
                ),
                const SizedBox(height: 24),
                
                // 应用名称
                Text(
                  packageInfo.appName,
                  style: Theme.of(context).textTheme.headlineMedium,
                ),
                const SizedBox(height: 8),
                
                // 版本信息
                Text(
                  '版本: ${packageInfo.version} (${packageInfo.buildNumber})',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                const SizedBox(height: 24),
                
                // 详细信息列表
                _buildInfoItem('包名', packageInfo.packageName),
                if (packageInfo.buildSignature.isNotEmpty)
                  _buildInfoItem('应用签名', packageInfo.buildSignature),
                if (packageInfo.installerStore != null && packageInfo.installerStore!.isNotEmpty)
                  _buildInfoItem('安装来源', packageInfo.installerStore!),
                
                const SizedBox(height: 32),
                
                // 版权信息
                const Text(
                  '© 2023 你的公司名称. 保留所有权利.',
                  style: TextStyle(color: Colors.grey),
                ),
              ],
            ),
          );
        },
      ),
    );
  }

  // 构建信息项
  Widget _buildInfoItem(String label, String value) {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 8.0),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '$label: ',
            style: const TextStyle(
              fontWeight: FontWeight.bold,
              width: 100,
            ),
          ),
          Expanded(
            child: Text(value),
          ),
        ],
      ),
    );
  }
}

这个示例创建了一个完整的"关于"页面,包含以下功能:

  • 使用 FutureBuilder 处理异步获取应用信息的过程
  • 展示加载状态、错误状态和成功状态
  • 显示应用名称、图标、版本号等基本信息
  • 根据平台特性选择性展示签名和安装来源信息

5. 高级应用场景

下面是一些高级的应用场景,例如:

5.1. 版本更新检查

可以使用获取到的版本号实现版本更新检查功能:

class VersionChecker {
  // 假设这是从服务器获取的最新版本号
  final String _latestVersion = "1.1.0";
  
  Future<bool> isUpdateAvailable() async {
    PackageInfo packageInfo = await PackageInfo.fromPackageInfo();
    return _compareVersions(packageInfo.version, _latestVersion) < 0;
  }
  
  // 版本号比较工具方法
  int _compareVersions(String current, String latest) {
    List<int> currentParts = current.split('.').map(int.parse).toList();
    List<int> latestParts = latest.split('.').map(int.parse).toList();
    
    int maxLength = max(currentParts.length, latestParts.length);
    
    for (int i = 0; i < maxLength; i++) {
      int currentPart = i < currentParts.length ? currentParts[i] : 0;
      int latestPart = i < latestParts.length ? latestParts[i] : 0;
      
      if (currentPart < latestPart) return -1;
      if (currentPart > latestPart) return 1;
    }
    
    return 0;
  }
}

// 使用示例
void checkForUpdates() async {
  VersionChecker checker = VersionChecker();
  bool hasUpdate = await checker.isUpdateAvailable();
  if (hasUpdate) {
    // 显示更新提示
    print("有新版本可用!");
  }
}

5.2. 应用使用统计

结合设备信息和应用信息,可以实现应用使用统计:

import 'package:device_info_plus/device_info_plus.dart';

Future<void> trackAppUsage() async {
  PackageInfo packageInfo = await PackageInfo.fromPackageInfo();
  DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
  
  String trackingInfo = '''
  应用信息:
  - 名称: ${packageInfo.appName}
  - 版本: ${packageInfo.version}
  - 包名: ${packageInfo.packageName}
  
  设备信息:
  ''';
  
  if (Platform.isAndroid) {
    AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
    trackingInfo += '- 设备: ${androidInfo.brand} ${androidInfo.model}\n';
    trackingInfo += '- 系统版本: Android ${androidInfo.version.release}';
  } else if (Platform.isIOS) {
    IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
    trackingInfo += '- 设备: ${iosInfo.model}\n';
    trackingInfo += '- 系统版本: iOS ${iosInfo.systemVersion}';
  }
  
  // 实际项目中,这里会将统计信息发送到服务器
  print(trackingInfo);
}

5.3. 动态配置应用行为

根据应用版本动态配置应用行为:

Future<void> configureAppBehavior() async {
  PackageInfo packageInfo = await PackageInfo.fromPackageInfo();
  
  // 根据不同版本启用不同功能
  if (packageInfo.version.startsWith('1.0.')) {
    // v1.0.x 版本的配置
    print('配置基础功能');
  } else if (packageInfo.version.startsWith('1.1.')) {
    // v1.1.x 版本的配置
    print('配置高级功能');
  }
  
  // 根据构建号启用测试功能
  int buildNumber = int.tryParse(packageInfo.buildNumber) ?? 0;
  if (buildNumber >= 100) {
    print('启用beta功能');
  }
}

6. 注意事项

  • 异步处理
    • PackageInfo.fromPackageInfo() 是异步方法,必须正确处理异步操作
    • 建议在应用启动时获取一次应用信息并缓存,避免重复获取
    • 使用 FutureBuilder 或状态管理工具(如 Provider、Bloc)处理 UI 渲染
  • 平台差异
    • 不同平台上的信息可能有所不同,尤其是 buildSignatureinstallerStore 等平台特定属性
    • Web 平台需要手动配置元数据,其他平台则自动读取
    • 处理平台特定属性时,应先检查其是否存在或不为空
  • 版本号管理
    • 保持 pubspec.yaml、AndroidManifest.xml 和 Info.plist 中的版本信息同步
    • 建立清晰的版本号命名规则(如语义化版本 主版本.次版本.修订号)
    • 构建号应随每次构建递增,用于区分不同构建
  • 性能考虑
    • 应用信息不会频繁变化,无需多次获取
    • 建议在应用初始化阶段获取并存储在全局状态中
    • 避免在 UI 渲染关键路径中获取应用信息

7. 常见问题解决

  • 信息不准确或未更新
    • 确保所有平台的配置文件都已更新版本信息
    • 运行 flutter clean 清理缓存后重新构建
    • 对于 Web 平台,检查 index.html 中的元数据是否正确
  • 某些属性为空
    • 某些属性(如 installerStore)仅在特定条件下才有值
    • 检查是否在正确的平台上访问平台特定属性
    • Web 平台需要手动配置所有属性
  • 构建错误
    • 确保使用的 package_info_plus 版本与 Flutter 版本兼容
    • 检查是否正确导入了包
    • 尝试更新插件到最新版本

8. 总结

package_info_plus 是 Flutter 开发中获取应用元数据的实用工具,它提供了简单直观的 API,支持多平台,能够满足大多数应用对获取自身信息的需求。

合理利用应用信息可以提升用户体验,简化开发流程,并为应用的统计分析提供支持。更多详细信息可以参考 官方文档


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

Cocos游戏开发中的箭头游戏效果

游戏录屏

引言

Cocos游戏开发中的箭头游戏效果

近日,笔者看到小伙伴正在推广他的小游戏,深入游玩了一波,非常精致和益智(就是有点花眼)。看了下排行榜,这类游戏目前依旧非常的火爆。

言归正传,游戏体验过之后,非常感兴趣这个游戏中的箭头游戏效果是如何实现的呢?

今天给大家介绍一下如何在Cocos游戏开发中实现箭头游戏效果,非常感谢小伙伴的投稿。

本文源工程在文末获取,小伙伴们自行前往。

1. 分析一下游戏

我们先来分析一下实现这个游戏有哪些关键点:

  1. 箭头:既然是箭头游戏,游戏的核心元素就是横七竖八的箭头。

  2. 箭头的移动:箭头点击之后,可以移动,若前方无阻挡,则可以移出消除,反之则不可移动。

  3. 关卡编辑:关卡类游戏离不开关卡的编辑,除非关卡的难度可以通过某些参数控制并随机,但是纯随机的游戏也不一定好玩。

2. 怎么实现?

1.箭头

箭头其实就是各种路径的线,然后在顶部加上一个三角形,即可形成箭头。

在Cocos游戏开发中,画线常用的组件就是Graphics组件。

来源于Cocos官方文档

我们画箭头要用到的核心接口如下。

来源于Cocos官方文档

画线过程如下

画箭头过程如下

2.箭头移动

箭头移动的逻辑其实和贪吃蛇比较相似,如下图,箭头由5段,6个点组成,箭头移动时,实际上只需要根据方向移动头和尾两个点即可,其他点不用动。

尾巴的点移动到下一个点的位置时,我们只需要把尾巴的点去掉,那么下一个点就成了新的尾巴。

移动的方向只需要根据相邻的两个点即可判断。

3. 关卡编辑

游戏的功能实现比较简单,较为复杂的在于编辑器,编辑器既要做到方便编辑,最理想的状态下是能够轻松地根据难度生成不同的关卡,又要能够检测关卡是否能正常通关。下面列举几种:

  • 手动编辑:生成格子盘,然后通过鼠标点击标注不产生箭头的区域,然后生成。
  • 自动生成:根据规则自动生成,自动检测生成的关卡是否能顺利通关。
  • 上传图片生成:上传不同形状的图片,根据图片像素去生成关卡,使得关卡更加有趣。
  • Excel编辑生成: 最近看到有小伙伴在Excel中进行关卡编辑,并且通过宏生成关卡数据,让笔者眼前一亮。

最后看看小伙伴们编辑生成的精美关卡(文末可获取编辑器)。

3. 箭头游戏效果实战

1.创建工程

引擎版本Cocoscreator 3.8.7

编程语言TypeScript

首先创建一个Arrow工程:

2.创建脚本

新建一个Main.ts脚本,并且拖拽到Canvas,用于实现我们的实战逻辑。

3.画箭头

首先根据原理,我们通过Graphics组件实现,打开Main.ts脚本,在start方法中添加Graphics组件,并且画一根线宽为10,坐标从(0,0)(0,200)的线。

  • moveTo: 移动到画线起点。
  • lineTo: 画线到该点。
  • stroke: 开始画线。

运行后就能看到一根长200的线。

接下来我们要画一个三角形,让线头变成箭头,先找到三个点,然后通过fill进行填充形成三角形。

  • close: 从当前点画线到起点。
  • fillColor: 填充颜色。
  • fill: 填充。

运行后就能看到线变成了箭头。

箭头一般不止2个点,于是我们把方法整理一下。

箭头的路径点加进去,然后进行绘制。

运行后就能得到一个这样的箭头。

4.箭头移动

箭头移动的逻辑相对好写一点,就是让头和尾巴的点沿着方向移动即可,当尾巴点和前一节接触后移除。

点击开始移动。

5.效果演示

结语

还有没玩过这类游戏的吗?实在太火爆了,变种的才刚出来,还能分口热汤吗?

本文实战源码可通过阅读原文获取。


我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐文章:

Cocos游戏如何接入安卓穿山甲广告变现?

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

Cocos游戏如何快速接入抖音小游戏广告变现?

如何在CocosCreator3.8中实现割绳子游戏效果

如何在CocosCreator3.8中实现动态切割模型?

小米不仅造车,还造模型?309B参数全开源,深度思考完胜DeepSeek 🐒🐒🐒

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

小米不仅造车,还造模型?

2024 年 12 月,当所有人还在关注小米汽车的时候,小米却悄然开源了一款震撼整个 AI 界的大语言模型——MiMo-V2-Flash。这款拥有 309B总参数15B激活参数 的超大规模模型,不仅在性能上达到了世界顶尖水平,更在深度思考能力上完胜 DeepSeek,重新定义了 AI 模型的效率天花板。

本文将详细介绍这款模型的技术特点、性能表现以及使用方式。

MiMo-V2-Flash

MiMo-V2-Flash 是一个混合专家(MoE)语言模型,拥有 309B总参数15B激活参数。专为高速推理和智能体工作流设计,它采用了新颖的混合注意力架构和多 Token 预测(MTP)技术,在显著降低推理成本的同时实现了最先进的性能。

image.png

1. 介绍

MiMo-V2-Flash 在长上下文建模能力和推理效率之间创造了新的平衡。主要特性包括:

  • 混合注意力架构:以 5:1 的比例交织滑动窗口注意力(SWA)和全局注意力(GA),采用激进的 128-token 窗口。通过可学习的 attention sink bias(注意力沉降偏置),在保持长上下文性能的同时,将 KV缓存 存储减少了近 6 倍。
  • 多 Token 预测(MTP):配备了轻量级 MTP 模块(每块 0.33B 参数),使用密集 FFN。这将推理期间的输出速度提升了 3 倍,并有助于加速 RL 训练中的 rollout
  • 高效预训练:使用 FP8 混合精度在 27T token 上训练,原生 32k 序列长度。上下文窗口支持最长 256k。
  • 智能体能力:后训练利用多教师在线策略蒸馏(MOPD)和大规模智能体 RL,在 SWE-Bench 和复杂推理任务上实现了卓越性能。

2. 模型下载

模型 总参数 激活参数 上下文长度 下载地址
MiMo-V2-Flash-Base 309B 15B 256k 🤗 HuggingFace
MiMo-V2-Flash 309B 15B 256k 🤗 HuggingFace

重要提示:我们还开源了 3 层 MTP 权重,以促进社区研究。

3. 评估结果

基础模型评估

MiMo-V2-Flash-Base 在标准基准测试中展现出强大的性能,超越了参数量显著更大的模型。

类别 基准测试 设置/长度 MiMo-V2-Flash Base Kimi-K2 Base DeepSeek-V3.1 Base DeepSeek-V3.2 Exp Base
参数 激活参数 / 总参数 - 15B / 309B 32B / 1043B 37B / 671B 37B / 671B
通用 BBH 3-shot 88.5 88.7 88.2 88.7
MMLU 5-shot 86.7 87.8 87.4 87.8
MMLU-Redux 5-shot 90.6 90.2 90.0 90.4
MMLU-Pro 5-shot 73.2 69.2 58.8 62.1
DROP 3-shot 84.7 83.6 86.3 86.6
ARC-Challenge 25-shot 95.9 96.2 95.6 95.5
HellaSwag 10-shot 88.5 94.6 89.2 89.4
WinoGrande 5-shot 83.8 85.3 85.9 85.6
TriviaQA 5-shot 80.3 85.1 83.5 83.9
GPQA-Diamond 5-shot 55.1 48.1 51.0 52.0
SuperGPQA 5-shot 41.1 44.7 42.3 43.6
SimpleQA 5-shot 20.6 35.3 26.3 27.0
数学 GSM8K 8-shot 92.3 92.1 91.4 91.1
MATH 4-shot 71.0 70.2 62.6 62.5
AIME 24&25 2-shot 35.3 31.6 21.6 24.8
代码 HumanEval+ 1-shot 70.7 84.8 64.6 67.7
MBPP+ 3-shot 71.4 73.8 72.2 69.8
CRUXEval-I 1-shot 67.5 74.0 62.1 63.9
CRUXEval-O 1-shot 79.1 83.5 76.4 74.9
MultiPL-E HumanEval 0-shot 59.5 60.5 45.9 45.7
MultiPL-E MBPP 0-shot 56.7 58.8 52.5 50.6
BigCodeBench 0-shot 70.1 61.7 63.0 62.9
LiveCodeBench v6 1-shot 30.8 26.3 24.8 24.9
SWE-Bench (AgentLess) 3-shot 30.8 28.2 24.8 9.4*
中文 C-Eval 5-shot 87.9 92.5 90.0 91.0
CMMLU 5-shot 87.4 90.9 88.8 88.9
C-SimpleQA 5-shot 61.5 77.6 70.9 68.0
多语言 GlobalMMLU 5-shot 76.6 80.7 81.9 82.0
INCLUDE 5-shot 71.4 75.3 77.2 77.2
长上下文 NIAH-Multi 32K 99.3 99.8 99.7 85.6
64K 99.9 100.0 98.6 85.9
128K 98.6 99.5 97.2 94.3
256K 96.7 - - -
GSM-Infinite Hard 16K 37.7 34.6 41.5 50.4
32K 33.7 26.1 38.8 45.2
64K 31.5 16.0 34.7 32.6
128K 29.0 8.8 28.7 25.7
  • 表示模型可能无法遵循提示或格式。

后训练模型评估

通过采用 MOPD 和智能体 RL 的后训练范式,模型在推理和智能体性能上达到了最先进水平。

基准测试 MiMo-V2 Flash Kimi-K2 Thinking DeepSeek-V3.2 Thinking Gemini-3.0 Pro Claude Sonnet 4.5 GPT-5 High
推理
MMLU-Pro 84.9 84.6 85.0 90.1 88.2 87.5
GPQA-Diamond 83.7 84.5 82.4 91.9 83.4 85.7
HLE (无工具) 22.1 23.9 25.1 37.5 13.7 26.3
AIME 2025 94.1 94.5 93.1 95.0 87.0 94.6
HMMT Feb. 2025 84.4 89.4 92.5 97.5 79.2 88.3
LiveCodeBench-v6 80.6 83.1 83.3 90.7 64.0 84.5
通用写作
Arena-Hard (困难提示) 54.1 71.9 53.4 72.6 63.3 71.9
Arena-Hard (创意写作) 86.2 80.1 88.8 93.6 76.7 92.2
长上下文
LongBench V2 60.6 45.1 58.4 65.6 61.8 -
MRCR 45.7 44.2 55.5 89.7 55.4 -
代码智能体
SWE-Bench Verified 73.4 71.3 73.1 76.2 77.2 74.9
SWE-Bench Multilingual 71.7 61.1 70.2 - 68.0 55.3
Terminal-Bench Hard 30.5 30.6 35.4 39.0 33.3 30.5
Terminal-Bench 2.0 38.5 35.7 46.4 54.2 42.8 35.2
通用智能体
BrowseComp 45.4 - 51.4 - 24.1 54.9
BrowseComp (带上下文管理) 58.3 60.2 67.6 59.2 - -
τ²-Bench 80.3 74.3 80.3 85.4 84.7 80.2

4. 模型架构

image.png

混合滑动窗口注意力

MiMo-V2-Flash 通过交织局部滑动窗口注意力(SWA)和全局注意力(GA)来解决长上下文的平方复杂度问题。

  • 配置:M=8 个混合块的堆叠。每个块包含 N=5 个 SWA 层,之后是 1 个 GA 层。
  • 效率:SWA 层使用 128 个 token 的窗口大小,显著减少了 KV缓存
  • 沉降偏置:应用可学习的注意力沉降偏置,即使在激进的窗口大小下也能保持性能。

轻量级多 Token 预测(MTP)

与传统的推测解码不同,我们的 MTP 模块原生集成用于训练和推理。

  • 结构:使用密集 FFN(而非 MoE)和 SWA(而非 GA)来保持较低的参数量(每块 0.33B)。
  • 性能:促进自推测解码,将生成速度提升 3 倍,并缓解小批量 RL 训练期间的 GPU 空闲问题。

5. 后训练技术亮点

MiMo-V2-Flash 利用专门设计的后训练流程,通过创新的蒸馏和强化学习策略最大化推理和智能体能力。

5.1 多教师在线策略蒸馏(MOPD)

我们引入了 多教师在线策略蒸馏(MOPD),这是一种将知识蒸馏重新定义为强化学习过程的新范式。

  • 密集 Token 级指导:与依赖稀疏序列级反馈的方法不同,MOPD 利用领域特定的专家模型(教师)在每个 token 位置提供监督。
  • 在线策略优化:学生模型从自己生成的响应中学习,而不是从固定数据集学习。这消除了暴露偏差,并确保更小、更稳定的梯度更新。
  • 固有的奖励鲁棒性:奖励源于学生和教师之间的分布差异,使该过程天然抵抗奖励黑客攻击。

5.2 扩展智能体强化学习

我们大幅扩展了智能体训练环境,以提高智能和泛化能力。

  • 大规模代码智能体环境:我们利用真实世界的 GitHub 问题创建了超过 100,000 个可验证任务。我们的自动化流程维护着一个能够运行超过 10,000 个并发 pod 的 Kubernetes 集群,环境设置成功率达 70%。
  • Web 开发的多模态验证器:对于 Web 开发任务,我们采用基于视觉的验证器,通过录制的视频而非静态截图来评估代码执行。这减少了视觉幻觉并确保功能正确性。
  • 跨域泛化:我们的实验表明,在代码智能体上的大规模 RL 训练能有效泛化到其他领域,提升数学和通用智能体任务的性能。

5.3 先进的强化学习基础设施

为了支持大规模 MoE 模型的高吞吐量 RL 训练,我们在 SGLangMegatron-LM 基础上实现了多项基础设施优化。

  • Rollout 路由重放(R3):解决 MoE 路由在推理和训练之间的数值精度不一致问题。R3 在训练阶段重用 rollout 中的确切路由专家,以可忽略的开销确保一致性。
  • 请求级前缀缓存:在多轮智能体训练中,此缓存存储先前轮次的 KV 状态和路由专家。它避免了重新计算,并确保跨轮次的采样一致性。
  • 细粒度数据调度器:我们扩展了 rollout 引擎以调度细粒度序列而非微批次。结合部分 rollout,这显著减少了长尾任务导致的 GPU 空闲。
  • 工具箱和工具管理器:使用 Ray actor 池的两层设计来处理资源争用。它消除了工具执行的冷启动延迟,并将任务逻辑与系统策略隔离。

6. 推理与部署

MiMo-V2-Flash 支持 FP8 混合精度推理。我们推荐使用 SGLang 以获得最佳性能。

使用建议:我们推荐将采样参数设置为 temperature=0.8, top_p=0.95

使用 SGLang 快速开始

pip install sglang

# 启动服务器
python3 -m sglang.launch_server \
        --model-path XiaomiMiMo/MiMo-V2-Flash \
        --served-model-name mimo-v2-flash \
        --pp-size 1 \
        --dp-size 2 \
        --enable-dp-attention \
        --tp-size 8 \
        --moe-a2a-backend deepep \
        --page-size 1 \
        --host 0.0.0.0 \
        --port 9001 \
        --trust-remote-code \
        --mem-fraction-static 0.75 \
        --max-running-requests 128 \
        --chunked-prefill-size 16384 \
        --reasoning-parser qwen3 \
        --tool-call-parser mimo \
        --context-length 262144 \
        --attention-backend fa3 \
        --speculative-algorithm EAGLE \
        --speculative-num-steps 3 \
        --speculative-eagle-topk 1 \
        --speculative-num-draft-tokens 4 \
        --enable-mtp

# 发送请求
curl -i http://localhost:9001/v1/chat/completions \
    -H 'Content-Type:application/json' \
    -d  '{
            "messages" : [{
                "role": "user",
                "content": "Nice to meet you MiMo"
            }],
            "model": "mimo-v2-flash",
            "max_tokens": 4096,
            "temperature": 0.8,
            "top_p": 0.95,
            "stream": true,
            "chat_template_kwargs": {
                "enable_thinking": true
            }
        }'

注意事项

重要提示:在带有多轮工具调用的思考模式中,模型会在 tool_calls 旁边返回一个 reasoning_content 字段。要继续对话,用户必须在每个后续请求的 messages 数组中保留所有历史 reasoning_content

重要提示:强烈推荐使用以下系统提示,请从英文和中文版本中选择。

英文版本

You are MiMo, an AI assistant developed by Xiaomi.

Today's date: {date} {week}. Your knowledge cutoff date is December 2024.

中文版本

你是MiMo(中文名称也是MiMo),是小米公司研发的AI智能助手。

今天的日期:{date} {week},你的知识截止日期是2024年12月。

7. 引用

如果您觉得我们的工作有帮助,请引用我们的技术报告:

@misc{mimo2025flash,
  title={MiMo-V2-Flash Technical Report},
  author={LLM-Core Xiaomi},
  year={2025},
  url={https://github.com/XiaomiMiMo/MiMo-V2-Flash/paper.pdf}
}

8. 相关链接

9. 结论

MiMo-V2-Flash 不仅在基准测试中展现出卓越的性能,更在实际应用场景中展现出独特的优势。特别是在深度思考能力方面,通过对比测试可以明显看出,在基本相同的输出结果质量下,小米 MiMo-V2-Flash 的深度思考功能相比 DeepSeek 具有显著优势。

这一优势体现在多个方面:

  • 思考深度MiMo-V2-Flash 能够进行更深入、更系统的思考,展现出更强的逻辑推理能力
  • 思考效率:在保证输出质量的前提下,能够更快速地完成深度思考过程
  • 思考质量:思考过程更加结构化、条理清晰,能够更好地展现推理路径

这种深度思考能力的优势,使得 MiMo-V2-Flash 在复杂推理任务、学术研究、代码分析等需要深度思考的场景中,能够为用户提供更高质量、更可靠的智能服务。

深度思考对比测试 1

深度思考对比测试 2

从思想到实践:前端工程化体系与 Webpack 构建架构深度解析

工程化思想抽象

1.工程化的本质目标

  • 标准化: 统一代码规范、目录结构、开发流程
  • 自动化: 减少人工重复劳动,降低人为失误
  • 模块化: 分离关注点,提升代码复用性和可维护性

2.核心流程

┌─────────────┐      ┌──────────────┐      ┌─────────────┐      ┌──────────────┐
   业务源码层      -->    解析引擎        -->    构建产物       -->     Koa 渲染   
└─────────────┘      └──────────────┘      └─────────────┘      └──────────────┘
   .vue/.js             编译/分包/优化        .js/.css/.tpl         页面输出

2.1业务源码层

  • 使用高级语言特性(ES6+ CSS预处理器 Typescript)
  • 组件化/模块化开发方式
  • 面向开发者的可读性和表达力
  • 产物包括 .vue .jsx .scss .ts等源码文件

2.2解析引擎

这是工程化的核心,负责将"对人类友好的代码" -> “对机器友好的代码”

2.2.1解析编译

核心任务: 模块依赖分析与语法转译

入口发现 -> 依赖分析 -> 语法转译 -> 产物输出

  • 入口发现:通过文件命名规范自动发现入口(eg. entry.*.js)
  • 依赖分析
entry.page1.js
    ├─> page1.vue
    │   ├─> common/utils.js
    │   └─> styles/page1.less
    ├─> axios (第三方)
    └─> vue (第三方)
  • 语法转译
源格式    目标格式    转译内容
Vue SFC    JS + CSS模板编译、样式提取
ES6+    ES5        语法降级、Polyfill
Less/Sass   CSS        预处理器编译
TypeScriptJavaScript类型擦除、语法转译
图片/字体Base64/URL资源处理
  • 产物输出: 将编译后的资源自动注入到 tpl 中
<!-- 源模板 -->
<div id="root"></div>

<!-- 注入后 -->
<link rel="stylesheet" href="/dist/entry.page1.css">
<div id="root"></div>
<script src="/dist/vendor.js"></script>
<script src="/dist/entry.page1.js"></script>
2.2.2模块分包

核心任务: 将代码按变化频率和复用关系拆分,最大化缓存利用率

┌─────────────────────────────────────────────────┐
│  第三方依赖层 (Vendor Layer)                     │
│  - React/Vue/Angular 等框架                      │
│  - 工具库 (lodash/axios/moment)                 │
│  - 特点:几乎不变,除非升级依赖                   │
│  - 缓存策略:长效缓存(数月甚至永久)              │
├─────────────────────────────────────────────────┤
│  公共业务模块层 (Common Layer)                   │
│  - 跨页面复用的组件 (Header/Footer)              │
│  - 通用工具函数 (utils/helpers)                  │
│  - 特点:偶尔变化,改动频率较低                   │
│  - 缓存策略:中期缓存(数周)                     │
├─────────────────────────────────────────────────┤
│  页面差异化逻辑层 (Entry Layer)                  │
│  - 各页面独有的业务逻辑                          │
│  - 页面级组件                                    │
│  - 特点:频繁变化,跟随需求迭代                   │
│  - 缓存策略:短期缓存(数天)                     │
├─────────────────────────────────────────────────┤
│  运行时层 (Runtime Layer)                       │
│  - 模块加载器逻辑                                │
│  - Chunk 映射关系                               │
│  - 特点:随每次构建变化                          │
│  - 缓存策略:不依赖内容缓存                      │
└─────────────────────────────────────────────────┘

分包决策

  • 优先级判定: vendor -> common -> entry
  • 复用度计算: 被N个入口引用则提升到 common 层
  • 体积阈值: 过小的模块不单独拆分(减少 HTTP 请求)

效果量化

假设用户连续访问3个页面

传统单包方案:
  Page1: 800KB (下载)
  Page2: 750KB (下载)
  Page3: 820KB (下载)
  总流量: 2370KB

分包方案:
  Page1: 300KB(vendor) + 50KB(common) + 80KB(entry1) = 430KB
  Page2: (缓存) + (缓存) + 70KB(entry2) = 70KB
  Page3: (缓存) + (缓存) + 90KB(entry3) = 90KB
  总流量: 590KB (节省 75%)
2.2.3压缩优化

核心任务: 根据运行环境的差异,执行不同的优化策略

维度         开发环境           生产环境
核心目标     快速迭代 + 便捷调试      极致性能 + 最小体积
构建速度     极速(秒级)           可接受慢(分钟级)
代码可读性 保留(需要调试)       可丢弃(压缩混淆)
Source Map 完整映射           可选/隐藏
模块热替换 必须支持           不需要

开发环境优化策略

  • 增量编译

    • 只重新编译变化的模块
    • 利用缓存跳过未变化的依赖
  • 资源内存化

    • 构建产物写入内存而非磁盘
    • 减少 I/O 开销,提升重新构建速度
  • 热更新(HMR)

文件变化 -> 增量编译 -> 推送更新清单 -> 浏览器热更新
    ↑                                          ↓
    └────────── WebSocket/SSE 长连接 ───────────┘
  • Source Map生成: 建立 "转译后代码" -> “源码”的映射关系

生产环境优化策略

  • 代码压缩(Minification)

    • 移除空白字符、注释
    • 变量名混淆(userName -> a)
    • 函数内联、常规折叠
  • Tree Shaking: 基于 ES Module 的静态分析,移除未使用的导出

// utils.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }  // 未被使用

// main.js
import { add } from './utils';
console.log(add(1, 2));

// 最终产物(subtract 被删除)
function add(a, b) { return a + b; }
console.log(add(1, 2));
  • 资源提取与并行加载

    • CSS 提取为独立文件(JS 和 CSS 并行下载)
    • 图片转 Base64 内联(减少小资源的 HTTP 请求)
  • 多线程并行处理(利用 CPU 多核能力,将编译任务分发到多个进程)

主进程
  ├─> Worker 1: 编译模块 A
  ├─> Worker 2: 编译模块 B
  ├─> Worker 3: 编译模块 C
  └─> Worker 4: 编译模块 D

2.3构建产物

输出可直接部署的静态资源

  • 浏览器可直接执行的标准格式(ES5 JS、 CSS3)
  • 文件带 hash 支持版本控制
  • 按模块拆分,支持按需加载

2.4运行产物(服务端渲染)

服务端渲染模版并响应给浏览器

浏览器请求 /page1
    ↓
后端路由匹配
    ↓
读取 entry.page1.tpl 模板
    ↓
注入动态数据 (用户信息、环境变量等)
    ↓
返回完整 HTML
    ↓
浏览器解析并加载 JS/CSS 资源

3.工程化的横向关注点

3.1约定优于配置

通过统一的命名规范和目录结构,减少显示配置

  • 配置模式
entry: {
  page1: './pages/page1/entry.page1.js',
  page2: './pages/page2/entry.page2.js',
  page3: './pages/page3/entry.page3.js'
}
  • 约定模式
pages/
  ├── page1/entry.page1.js  ✓ 自动识别
  ├── page2/entry.page2.js  ✓ 自动识别
  └── page3/entry.page3.js  ✓ 自动识别

约定模式的优势

  • 零配置扩展,降低认知负担
  • 规范及文档,团队人员能快速上手

3.2环境隔离

不同环境采用不同的构建策略,避免配置污染

基础配置 (Base Config)
     ├─> 开发环境配置 (Dev Config)
     └─> 生产环境配置 (Prod Config)

3.3模块解析策略

  • 路径别名
// 配置前
import utils from '../../../common/utils';

// 配置后
import utils from '$common/utils';
  • 自动扩展名补全
// 配置前
import MyComponent from './MyComponent.vue';

// 配置后
import MyComponent from './MyComponent';  // 自动补全 .vue

3.4依赖注入模式

全局依赖自动注入

对于高频使用的库(如 ui 框架、vue等),可配置自动注入,无需每个文件手动 import

// 源码(无需显式 import)
new Vue({ el: '#app' });
axios.get('/api/data');

// 构建工具自动注入
import Vue from 'vue';
import axios from 'axios';
new Vue({ el: '#app' });
axios.get('/api/data');

Webpack工程化实现

1.Webpack 在工程化中的定义

Webpack 在工程化架构中扮演"编译转换层"的角色,负责

  • 解析模块依赖关系
  • 调度各种 loader 转译资源
  • 执行 plugin 扩展构建流程
  • 输出优化后的构建产物

2.解析编译的 Webpack 实现

2.1入口发现

实现原理

  • glob.sync() 同步扫描文件
  • path.basename() 提取出文件名作为入口名称
  • 最终生成如 { 'entry.page1': './app/pages/page1/entry.page1.js' }
const glob = require('glob');
const pageEntries = {};

// 获取 app/pages 目录下所有入口文件 (entry.xx.js)
const entryList = path.resolve(process.cwd(), './app/pages/**/entry.*.js');
glob.sync(entryList).forEach((file) => {
  const entryName = path.basename(file, '.js');
  // 构造 entry
  pageEntries[entryName] = file;
});

module.exports = {
  entry: pageEntries  // 动态生成的多入口配置
};

2.2资源转译

Webpack 通过 loader 链式调用,实现资源转译

  • vue sfc 处理
{
  test: /.vue$/,
  use: 'vue-loader'
}

plugins: [
  new VueLoaderPlugin()  // 将其他规则应用到 .vue 文件的各个块
]
  • JavaScript 转译
{
  test: /.js$/,
  include: [path.resolve(process.cwd(), './app/pages')],  // 只处理业务代码,加快打包速度
  use: 'babel-loader'
}
  • 样式处理链
// 开发环境
{
  test: /.less$/,
  use: ['style-loader', 'css-loader', 'less-loader']
}

// 生产环境
{
  test: /.less$/,
  use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader']
  // 执行顺序 less-loader -> css-loader -> MiniCssExtractPlugin.loader
}
  • 静态资源处理
{
  test: /.(png|jpg|jpeg|gif)(?.+)?$/,
  use: {
    loader: 'url-loader',
    options: {
      limit: 300,  // 小于 300 字节转 base64
      esModule: false
    }
  }
}

2.3产物注入实现

通过 html-webpack-plugin 自动生产 HTML 并注入资源

const htmlWebpackPluginList = [];

Object.keys(pageEntries).forEach((entryName) => {
  htmlWebpackPluginList.push(
    new HtmlWebpackPlugin({
      filename: path.resolve(process.cwd(), './app/public/dist/', `${entryName}.tpl`),
      template: path.resolve(process.cwd(), './app/view/entry.tpl'),
      chunks: [entryName]  // 只注入当前入口的 chunk
    })
  );
});

module.exports = {
  plugins: [...htmlWebpackPluginList]
};

3.模块分包的 Webpack 实现

optimization: {
  splitChunks: {
    chunks: 'all', // 对同步和异步模块都进行分割
    maxAsyncRequests: 10, // 每次异步加载的最大并行请求数
    maxInitialRequests: 10, // 初始加载的最大并行请求数
    cacheGroups: {
      // 第三方依赖层
      vendor: {
        test: /[\/]node_modules[\/]/,
        name: 'vendor', // 模块名称
        priority: 20, // 优先级,值越大优先级越高
        enforce: true, // 强制执行
        reuseExistingChunk: true // 复用已有的公共 chunk
      },
      // 公共业务模块层
      common: {
        name: 'common',
        minChunks: 2, // 被至少 2  chunk 引用
        minSize: 1, // 最小分割文件大小(此处 1 byte 方便于测试)
        priority: 10,
        reuseExistingChunk: true
      }
    }
  },
  //  webpack 运行时生成的代码打包到 runtime.js
  runtimeChunk: true
}

4.压缩优化的 Webpack 实现

4.1开发环境实现

  • Source Map
mode: 'development',
devtool: 'eval-cheap-module-source-map'
  • 热更新(HMR)
// 入口改造:注入 HMR 客户端
Object.keys(baseConfig.entry).forEach((v) => {
  if (v !== 'vendor') {
    baseConfig.entry[v] = [
      baseConfig.entry[v],
      `webpack-hot-middleware/client?path=http://127.0.0.1:9002/__webpack_hmr&timeout=20000&reload=true`
    ];
  }
});

// 插件配置
plugins: [
  new webpack.HotModuleReplacementPlugin({
    multiStep: false
  })
]
  • 开发服务器
const express = require('express');
const webpack = require('webpack');
const devMiddleware = require('webpack-dev-middleware');
const hotMiddleware = require('webpack-hot-middleware');

const app = express();
const compiler = webpack(webpackConfig);

// 监听文件变化,增量编译
app.use(devMiddleware(compiler, {
  writeToDisk: (filepath) => filepath.endsWith('.tpl'),  // 只有 .tpl 落盘
  publicPath: webpackConfig.output.publicPath
}));

// 引入 hotMiddleware 中间件 (实现热更新通讯)
app.use(hotMiddleware(compiler, {
  path: '/__webpack_hmr'
}));

app.listen(9002);

4.2生产环境实现

  • JavaScript 压缩
optimization: {
  minimize: true,
  minimizer: [
    new TerserWebpackPlugin({
      cache: true,
      parallel: true,
      terserOptions: {
        compress: {
          drop_console: true  // 移除 console.log
        }
      }
    })
  ]
}
  • CSS 提取与压缩
plugins: [
  // 提取 CSS
  new MiniCssExtractPlugin({
    chunkFilename: 'css/[name]_[chunkhash:8].chunk.css'
  }),
  // 压缩 CSS
  new CssMinimizerPlugin()
]
  • 多线程编译
//  module.rules 中直接配置
{
  test: /.js$/,
  use: [
    {
      loader: 'thread-loader',
      options: {
        workers: 2,
        workerParallelJobs: 50,
        poolTimeout: 2000
      }
    },
    'babel-loader'
  ]
}
  • 构建前清理
plugins: [
  new CleanWebpackPlugin(['public/dist'], {
    root: path.resolve(process.cwd(), './app/'),
    verbose: true,
    dry: false
  })
]

5.其他 Webpack 工程化配置

5.1模块解析配置

resolve: {
  extensions: ['.js', '.vue', '.less', '.css'],
  alias: {
    $pages: path.resolve(process.cwd(), './app/pages'),
    $common: path.resolve(process.cwd(), './app/pages/common'),
    $widgets: path.resolve(process.cwd(), './app/pages/widgets'),
    $store: path.resolve(process.cwd(), './app/pages/store')
  }
}

5.2全局依赖注入

plugins: [
  new webpack.ProvidePlugin({
    Vue: 'vue',
    axios: 'axios',
    _: 'lodash'
  })
]

5.1Vue编译选项

plugins: [
  new webpack.DefinePlugin({
    __VUE_OPTIONS_API__: true,
    __VUE_PROD_DEVTOOLS__: false,
    __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: false
  })
]

5.1配置分层设计

// webpack.base.js - 基础配置
module.exports = { entry, module, resolve, plugins, optimization };

// webpack.dev.js - 开发环境
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base');

module.exports = merge.smart(baseConfig, {
  mode: 'development',
  devtool: 'eval-cheap-module-source-map',
  plugins: [new webpack.HotModuleReplacementPlugin()]
});

// webpack.prod.js - 生产环境
module.exports = merge.smart(baseConfig, {
  mode: 'production',
  plugins: [new CleanWebpackPlugin(), new MiniCssExtractPlugin()]
});

Vite:现代前端构建工具的革命与实战指南

Vite:现代前端构建工具的革命

引言:前端构建工具的演进

在 Vite 出现之前,Webpack 几乎统治了前端构建工具领域。Webpack 通过静态分析依赖关系,将项目中的所有模块打包成少数几个 bundle 文件,这种"打包优先"的策略在早期确实解决了模块化开发的问题。但随着项目规模的增长,Webpack 的构建速度逐渐成为开发体验的瓶颈——即使是小型项目,冷启动时间也可能达到数十秒,热更新也需要几秒钟。

正是在这样的背景下,Vue.js 作者尤雨溪于 2020 年推出了 Vite(法语意为"快速"),它彻底改变了前端开发的构建范式,带来了革命性的开发体验提升。

Vite 的核心架构优势

1. 基于原生 ES 模块的急速冷启动

Vite 最显著的特点是极快的冷启动速度。与传统打包器不同,Vite 在开发环境下直接使用浏览器原生 ES 模块:

<!-- index.html -->
<script type="module" src="/src/main.js"></script>
// main.js - 浏览器直接执行 ES 模块
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')

工作原理:

  • Vite 将应用模块分为依赖源码两部分
  • 依赖使用 esbuild 预构建(Go 语言编写,比 JavaScript 快 10-100 倍)
  • 源码按需编译并提供,浏览器只请求当前页面所需的模块
  • 这种方式避免了整个应用的打包过程,实现了毫秒级的启动速度

2. 高效的热模块替换(HMR)

Vite 的热更新同样基于原生 ES 模块系统,实现了精准的更新策略:

// 当修改一个 Vue 组件时
// Vite 只会重新编译该组件,并通过 HMR API 快速更新
if (import.meta.hot) {
  import.meta.hot.accept('./Foo.vue', (newModule) => {
    // 更新逻辑
  })
}

HMR 优势:

  • 更新速度不受应用规模影响

  • 保持应用状态不变

  • 支持 Vue 单文件组件的模板和样式热更新

  • 后端node会自动检查文件的修改情况,并且自动更新

  • 如图:


屏幕录制 2025-12-17 125503.gif

3. 开箱即用的现代化支持

# 一键创建项目
npm create vite@latest my-vue-app -- --template vue

Vite 原生支持:

  • TypeScript
  • JSX
  • CSS 预处理器(Sass、Less、Stylus)
  • PostCSS
  • 现代 CSS 功能(CSS Modules、CSS Nesting)
  • 静态资源处理
  • WebAssembly

工程化实践:构建完整的 Vue 应用

项目结构标准化

my-project/
├── src/
│   ├── main.js              # 应用入口
│   ├── App.vue              # 根组件
│   ├── views/               # 页面组件
│   │   ├── Home.vue
│   │   └── About.vue
│   ├── components/          # 可复用组件
│   ├── router/              # 路由配置
│   └── store/               # 状态管理
├── index.html               # 入口 HTML
└── vite.config.js           # Vite 配置

Vue Router 集成

// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import About from '../views/About.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/about', component: About }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

export default router
<!-- App.vue -->
<template>
  <nav>
    <router-link to="/">Home</router-link>
    <router-link to="/about">About</router-link>
  </nav>
  <router-view/>
</template>

生产构建优化

虽然 Vite 开发体验优秀,但生产构建仍使用 Rollup:

// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [vue()],
  build: {
    // 生产构建配置
    rollupOptions: {
      output: {
        manualChunks: {
          // 代码分割策略
          vendor: ['vue', 'vue-router'],
          utils: ['lodash', 'axios']
        }
      }
    },
    // 构建输出目录
    outDir: 'dist',
    // 静态资源处理
    assetsDir: 'assets'
  },
  server: {
    // 开发服务器配置
    port: 3000,
    open: true
  }
})

Vite 生态系统与插件

Vite 拥有丰富的插件生态系统:

// 常用插件配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import AutoImport from 'unplugin-auto-import/vite'

export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
    AutoImport({
      imports: ['vue', 'vue-router'],
      dts: true // 生成 TypeScript 声明文件
    })
  ]
})

4.构建一个vite项目

  • 安装项目依赖npm init vite,在终端输入
  • 输入项目名称:vite-test
  • 选择项目框架,vue/react,都可以
  • 选择语言,我们选择js
  • Use rolldown-vite (Experimental)?选择no,这个是问我们是否要选择实验性的打包器,我们不选择,因为其还在实验阶段,可能不稳定,选择no,使用默认的 Rollup 打包器(稳定)
  • Install with npm and start now?这是 Vite 在询问你是否要使用 npm 安装依赖并立即启动,我们选择yes
  • 最后按住Ctrl键,然后点击Local: http://localhost:5173/,就可以看到我们的初始化项目了
  • 如图

屏幕截图 2025-12-17 130611.png

image.png

vite目录解析

Vite 项目目录结构解析

以下是典型的 Vite + Vue 3 项目目录结构及详细解析:

基础目录结构

my-vite-project/
├── node_modules/          # 依赖包
├── public/               # 静态资源(不参与打包)
├── src/                  # 源代码目录
├── .gitignore           # Git 忽略文件
├── index.html           # 项目入口 HTML
├── package.json         # 项目配置和依赖
├── package-lock.json    # 依赖锁定文件
├── vite.config.js       # Vite 配置文件
└── README.md            # 项目说明

详细解析

1. node_modules/

node_modules/
└── 所有通过 npm/yarn 安装的依赖包
  • 作用:存放项目依赖的第三方库
  • 注意:此文件夹不应提交到 Git,通过 .gitignore 忽略

2. public/ 目录

public/
├── favicon.ico          # 网站图标
└── robots.txt           # 搜索引擎爬虫协议
  • 作用:存放不会被处理的静态资源
  • 特点
    • 不会被 Vite 处理或编译
    • 通过 / 根路径直接访问
    • 例如:public/logo.png 可以通过 /logo.png 访问

3. src/ 目录(核心)

src/
├── assets/              # 静态资源(会被处理)
│   ├── logo.png
│   └── styles/
│       └── main.css
├── components/          # 组件目录
│   ├── HelloWorld.vue
│   └── Navbar.vue
├── views/               # 页面级组件
│   ├── Home.vue
│   ├── About.vue
│   └── User/
│       ├── Profile.vue
│       └── Settings.vue
├── router/              # 路由配置
│   └── index.js
├── stores/              # 状态管理(Pinia)
│   └── counter.js
├── utils/               # 工具函数
│   └── helpers.js
├── api/                 # API 接口
│   └── user.js
├── App.vue              # 根组件
└── main.js              # 应用入口

4. 关键文件详解

index.html - 项目入口
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + Vue</title>
  </head>
  <body>
    <div id="app"></div>
    <!-- 引入 main.js -->
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
  • 特点:Vite 将 index.html 作为入口点
  • ES 模块:通过 <script type="module"> 支持原生 ES 模块
src/main.js - 应用入口
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/main.css'

// 创建 Vue 应用
const app = createApp(App)

// 使用插件
app.use(router)

// 挂载到 DOM
app.mount('#app')
src/App.vue - 根组件
<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/about">About</router-link>
    </nav>
    <router-view/>
  </div>
</template>

<script>
export default {
  name: 'App'
}
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
}
</style>
vite.config.js - Vite 配置
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()],
  server: {
    port: 3000,               // 开发服务器端口
    open: true,               // 自动打开浏览器
    proxy: {                  // 代理配置
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true
      }
    }
  },
  resolve: {
    alias: {                  // 路径别名
      '@': '/src',
      '@components': '/src/components'
    }
  },
  build: {
    outDir: 'dist',           // 打包输出目录
    sourcemap: true           // 生成 sourcemap
  }
})

5. package.json

{
  "name": "my-vite-project",
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",           // 开发模式
    "build": "vite build",   // 生产构建
    "preview": "vite preview" // 预览生产版本
  },
  "dependencies": {
    "vue": "^3.3.0",
    "vue-router": "^4.2.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^4.2.0",
    "vite": "^4.4.0"
  }
}

6. 配置文件详解

.gitignore
# 依赖
node_modules/

# 构建输出
dist/
dist-ssr/

# 环境变量
.env
.env.local

# 日志
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# 编辑器
.vscode/
.idea/
*.swp
*.swo
环境变量文件
.env                # 所有情况下加载
.env.local          # 本地覆盖,不提交到 Git
.env.development    # 开发环境
.env.production     # 生产环境
.env.test           # 测试环境

Vite 特殊目录/文件

src/env.d.ts - TypeScript 环境声明

/// <reference types="vite/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

src/auto-imports.d.ts - 自动导入声明

/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// Generated by unplugin-auto-import
export {}
declare global {
  const ref: typeof import('vue')['ref']
  const reactive: typeof import('vue')['reactive']
  // ... 其他自动导入的 API
}

项目结构建议

小型项目

src/
├── components/
├── views/
├── App.vue
└── main.js

中型项目

src/
├── assets/
├── components/
│   ├── common/      # 通用组件
│   ├── layout/      # 布局组件
│   └── ui/          # UI 基础组件
├── composables/     # 组合式函数
├── router/
├── stores/
├── utils/
├── views/
├── App.vue
└── main.js

大型项目

src/
├── api/             # API 接口管理
├── assets/
├── components/
├── composables/
├── directives/      # 自定义指令
├── filters/         # 过滤器(Vue 2)
├── i18n/           # 国际化
├── layouts/         # 布局组件
├── middleware/      # 中间件
├── plugins/         # 插件
├── router/
├── stores/
├── types/          # TypeScript 类型定义
├── utils/
├── views/
├── App.vue
└── main.js

Vite 的优势总结

✅ 显著优势:

  1. 极速启动:冷启动时间比 Webpack 快 10-100 倍
  2. 即时更新:HMR 更新几乎无感知延迟
  3. 开发友好:错误提示清晰,配置简单
  4. 现代化:原生支持 ES 模块、TypeScript 等
  5. 生态完善:与 Vue、React、Svelte 等框架深度集成
  6. 插件丰富:活跃的插件生态系统

⚠️ 需要考虑的点:

  1. 浏览器兼容性

    • 开发依赖现代浏览器(支持原生 ES 模块)
    • 生产构建会自动转换为兼容格式
  2. 生态成熟度

    • 相比 Webpack,部分插件和工具链仍在完善中
    • 大型企业级应用迁移需要考虑现有工具链兼容性
  3. 构建优化

    • 生产构建基于 Rollup,对于超大型项目可能需要额外优化
    • 代码分割策略需要手动配置
  4. SSR 支持

    • Vite 的 SSR 支持相对较新,部分场景可能需要更多配置

实际性能对比

指标 Webpack Vite
冷启动(小型项目) 5-10s 50-200ms
冷启动(大型项目) 30-60s 1-3s
HMR 更新 1-3s 10-100ms
生产构建 优秀 优秀
配置复杂度

结语:以上就是对vite的介绍和使用教程了,望学习愉快!!!

Web-Tech:CORS的触发机制

触及了CORS的核心机制 细解释其中的关键区别:

关键区别:浏览器导航 vs JavaScript API请求

1. 描述的情况(Google搜索结果):

点击Google搜索结果中的链接时,发生的是:

  • 浏览器导航:浏览器地址栏直接变成 https://abcnews.go.com/
  • 整个页面被替换:浏览器完全离开Google,加载ABC News的页面
  • 这不是跨源请求:这是用户主动的页面跳转,浏览器允许这种行为

2. CORS限制的情况:

JavaScript代码在一个页面中尝试异步获取另一个域的资源时:

  • 页面不跳转:仍然在Google页面上
  • JavaScript悄悄获取数据:JS试图在后台获取 https://abcnews.go.com/ 的数据
  • 浏览器阻止:因为这会带来安全风险(网站可以偷偷读取其他网站数据)

用现实世界做类比

类比1:图书馆查询系统(类似Google搜索)

  • 合法行为:通过图书馆系统查到了《纽约时报》的文章索引
  • 点击查看离开图书馆系统,直接去《纽约时报》网站阅读全文 ✅
  • 非法行为:让图书馆系统自动复制《纽约时报》的全部内容到图书馆网站上 ❌

类比2:新闻报道引用

  • 合法引用:报道中说"据《华尔街日报》报道...",读者点击链接去看原文 ✅
  • 非法抄袭:报道中直接把《华尔街日报》的全文数据嵌入到自己的网站中 ❌

技术层面的明确区分

允许的(不会触发CORS):

<!-- 用户点击链接 -->
<a href="https://abcnews.go.com/article">点击前往ABC News</a>

<!-- 用户提交表单 -->
<form action="https://other-site.com/submit" method="GET">

会触发CORS的:

// JavaScript试图在后台获取其他网站的数据
fetch("https://abcnews.go.com/api/data")  // 会触发CORS检查
  .then(response => response.json())
  .then(data => {
    // 将ABC News的数据显示在Google页面上
    document.getElementById("content").innerHTML = data;
  });

为什么浏览器要这样设计?

安全风险示例:

假设恶意网站 evil.com 可以任意读取其他网站的数据:

  1. 登录网上银行 bank.com
  2. 在另一个标签页打开了 evil.com
  3. evil.com 的JavaScript可以:
    // 悄悄获取银行余额(如果允许跨域)
    fetch("https://bank.com/api/balance")
      .then(data => sendToHackerServer(data));
    

同源策略的保护:

  • 隔离数据:每个网站的JavaScript只能访问自己网站的数据
  • 用户知情:跨域数据访问需要目标网站的明确同意(CORS头)
  • 防止CSRF:防止其他网站冒充用户发起请求

实际示例对比

示例A:正常的网页跳转 ✅

<!-- Google搜索结果页面 -->
<body>
  <h1>搜索结果</h1>
  <a href="https://news.bbc.co.uk">BBC News</a>  <!-- 用户点击这里 -->
  <a href="https://www.nytimes.com">New York Times</a>
</body>

结果:用户离开Google,进入BBC网站

示例B:会触发CORS的请求 ❌

<!-- Google搜索结果页面 -->
<body>
  <h1>搜索结果</h1>
  <div id="news-content">正在加载BBC新闻...</div>
  
  <script>
    // 尝试在Google页面上直接显示BBC的内容
    fetch("https://news.bbc.co.uk/latest-news")  // CORS错误!
      .then(response => response.text())
      .then(html => {
        document.getElementById("news-content").innerHTML = html;
      });
  </script>
</body>

特别说明:iframe的中间情况

<!-- 在Google页面中嵌入BBC -->
<iframe src="https://news.bbc.co.uk"></iframe>
  • 允许:可以嵌入其他网站
  • 限制:Google的JavaScript不能访问iframe中BBC的内容
  • 隔离:两个网站的JavaScript完全隔离

总结困惑

  1. Google搜索到ABC News的链接:用户点击 → 页面跳转 → 不是CORS场景
  2. APK列表页面:JavaScript在后台fetch数据 → 是CORS场景
  3. CORS只限制:JavaScript的fetch()XMLHttpRequestaxios等API调用
  4. CORS不限制<a>链接点击、<form>提交、<iframe>加载、<script>标签加载、<img>加载等

简单记忆

  • 用户主动操作(点击链接):✅ 允许
  • JavaScript偷偷操作(ajax请求):❌ 需要CORS授权

这就是为什么不需要CORS插件就能浏览网页,但需要它来开发前端应用的原因

react组件(3)---组件间的通信

引言

在React应用开发中,组件是构建用户界面的基本单元。随着应用复杂度增加,多个组件之间的通信变得至关重要。无论是简单的父子组件通信,还是复杂的跨组件数据流,选择合适的通信方式将直接影响代码的可维护性和性能。本文将全面介绍React中组件通信的各种方法,帮助你在不同场景下做出最佳选择。

1. 父子组件通信

1.1 父组件向子组件传递数据:Props

最基本的通信方式是父组件通过props向子组件传递数据。这是React单向数据流的核心体现。

// 父组件
function ParentComponent() {
  const userData = { name: '张三', age: 28 };
  
  return (
    <div>
      <ChildComponent user={userData} title="用户信息" />
    </div>
  );
}

// 子组件
function ChildComponent({ user, title }) {
  return (
    <div>
      <h3>{title}</h3>
      <p>姓名: {user.name}</p>
      <p>年龄: {user.age}</p>
    </div>
  );
}

1.2 子组件向父组件传递数据:回调函数

子组件通过调用父组件传递的回调函数,实现向父组件传递数据。

// 父组件
function ParentComponent() {
  const [message, setMessage] = useState('');
  
  const handleDataFromChild = (data) => {
    setMessage(data);
    console.log('来自子组件的数据:', data);
  };
  
  return (
    <div>
      <p>父组件接收到的消息: {message}</p>
      <ChildComponent onSendData={handleDataFromChild} />
    </div>
  );
}

// 子组件
function ChildComponent({ onSendData }) {
  const handleClick = () => {
    onSendData('Hello from Child!');
  };
  
  return (
    <button onClick={handleClick}>向父组件发送数据</button>
  );
}

2. 兄弟组件通信

兄弟组件之间的通信需要通过它们的共同父组件作为中介。

function ParentComponent() {
  const [sharedData, setSharedData] = useState('');
  
  // 处理来自第一个子组件的数据
  const handleDataFromA = (data) => {
    setSharedData(data);
  };
  
  return (
    <div>
      <ChildA onDataUpdate={handleDataFromA} />
      <ChildB receivedData={sharedData} />
    </div>
  );
}

function ChildA({ onDataUpdate }) {
  const sendData = () => {
    onDataUpdate('数据来自ChildA');
  };
  
  return <button onClick={sendData}>发送数据给兄弟组件</button>;
}

function ChildB({ receivedData }) {
  return <div>ChildB接收到的数据: {receivedData}</div>;
}

这种方式的优点是数据流清晰可见,但当组件层级较深时,可能会导致"prop drilling"问题。

Prop Drilling(直译 “属性钻取”,也常被称为 “Prop 透传”)指的是:当一个数据需要从父组件传递到深层嵌套的子组件(如祖父→父亲→儿子→孙子)时,中间的每一层组件都需要接收并传递这个属性,即使中间组件本身并不需要使用该属性

3. 跨层级组件通信

3.1 Context API

对于深层嵌套的组件通信,React的Context API提供了一种在组件树中传递数据的方法,而无需显式地通过每个层级传递props。

// 1. 创建Context
const ThemeContext = React.createContext();

// 2. 提供数据(Provider)
function App() {
  const [theme, setTheme] = useState('light');
  
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Header />
      <MainContent />
    </ThemeContext.Provider>
  );
}

// 3. 在任意层级消费数据(Consumer)
function Header() {
  return (
    <header>
      <ThemeToggle />
    </header>
  );
}

function ThemeToggle() {
  const { theme, setTheme } = useContext(ThemeContext);
  
  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };
  
  return (
    <button onClick={toggleTheme}>
      当前主题: {theme} (点击切换)
    </button>
  );
}

Context API特别适合全局数据,如主题、用户认证信息、语言偏好等。

3.2 组合模式(Component Composition)

通过组合组件的方式,可以避免不必要的层级嵌套。

// 不推荐:深层prop传递
function App() {
  const user = { name: 'John' };
  return <Header user={user} />;
}

function Header({ user }) {
  return <Navigation user={user} />;
}

function Navigation({ user }) {
  return <UserMenu user={user} />;
}

// 推荐:组件组合
function App() {
  const user = { name: 'John' };
  return (
    <Header>
      <Navigation>
        <UserMenu user={user} />
      </Navigation>
    </Header>
  );
}

4. 全局状态管理

4.1 使用Redux

对于复杂应用,Redux提供了可预测的状态管理。

// store.js
import { createStore } from 'redux';

const initialState = { count: 0 };

function counterReducer(state = initialState, action) {
  switch (action.type) {
    case 'INCREMENT':
      return { ...state, count: state.count + 1 };
    case 'DECREMENT':
      return { ...state, count: state.count - 1 };
    default:
      return state;
  }
}

const store = createStore(counterReducer);

// Component.js
import { useSelector, useDispatch } from 'react-redux';

function Counter() {
  const count = useSelector(state => state.count);
  const dispatch = useDispatch();
  
  return (
    <div>
      <p>计数: {count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
    </div>
  );
}

4.2 使用useReducer + Context

对于中等复杂度应用,可以使用useReducer配合Context实现类Redux的状态管理。

const AppStateContext = React.createContext();

function appReducer(state, action) {
  switch (action.type) {
    case 'SET_USER':
      return { ...state, user: action.payload };
    case 'SET_THEME':
      return { ...state, theme: action.payload };
    default:
      return state;
  }
}

function AppProvider({ children }) {
  const [state, dispatch] = useReducer(appReducer, {
    user: null,
    theme: 'light'
  });
  
  return (
    <AppStateContext.Provider value={{ state, dispatch }}>
      {children}
    </AppStateContext.Provider>
  );
}

// 在任何组件中使用
function UserProfile() {
  const { state, dispatch } = useContext(AppStateContext);
  
  const updateUser = (user) => {
    dispatch({ type: 'SET_USER', payload: user });
  };
  
  return <div>用户名: {state.user?.name}</div>;
}

5. 其他通信方式

5.1 事件总线(Event Bus)

对于非父子关系且层级较远的组件,可以使用事件总线实现通信。

// eventBus.js
class EventBus {
  constructor() {
    this.events = {};
  }
  
  on(event, callback) {
    if (!this.events[event]) {
      this.events[event] = [];
    }
    this.events[event].push(callback);
  }
  
  emit(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(callback => callback(data));
    }
  }
}

export default new EventBus();

// 组件A - 发布事件
function ComponentA() {
  const handleClick = () => {
    eventBus.emit('dataUpdate', { message: 'Hello from A' });
  };
  
  return <button onClick={handleClick}>发布事件</button>;
}

// 组件B - 订阅事件
function ComponentB() {
  const [data, setData] = useState('');
  
  useEffect(() => {
    eventBus.on('dataUpdate', (newData) => {
      setData(newData.message);
    });
  }, []);
  
  return <div>接收到的数据: {data}</div>;
}

5.2 Ref方式通信

父组件通过ref直接调用子组件的方法。

// 子组件
class ChildComponent extends React.Component {
  doSomething() {
    console.log('子组件方法被调用');
  }
  
  render() {
    return <div>子组件</div>;
  }
}

// 父组件
function ParentComponent() {
  const childRef = useRef();
  
  const handleClick = () => {
    childRef.current.doSomething();
  };
  
  return (
    <div>
      <button onClick={handleClick}>调用子组件方法</button>
      <ChildComponent ref={childRef} />
    </div>
  );
}

6. 通信方式选择指南

以下表格总结了不同场景下推荐的通信方式:

通信场景 推荐方式 优点 缺点
父子组件 Props + 回调函数 简单直观,数据流清晰 深层嵌套时繁琐
兄弟组件 共同父组件中转 易于理解 可能导致父组件臃肿
跨层级组件 Context API 避免prop drilling 可能引起不必要的重渲染
全局状态 Redux/Zustand 可预测,调试友好 概念复杂,样板代码多
解耦通信 事件总线/发布订阅 组件完全解耦 数据流不够明显

7. 最佳实践与注意事项

  1. 保持状态提升合理:将状态提升到足够高的层级,但不要过度提升。
  2. 避免过度使用Context:Context的变动会引起所有消费者组件重新渲染,性能敏感场景需谨慎。
  3. 不可变更新状态:始终以不可变方式更新状态,避免直接修改对象或数组。
  4. TypeScript集成:使用TypeScript定义props和context的类型,提高代码可靠性。
  5. 性能优化:使用React.memo、useMemo、useCallback避免不必要的重渲染。

结语

React组件通信是应用开发的核心环节,选择正确的通信方式至关重要。简单场景优先考虑props和回调函数,复杂场景再考虑Context或状态管理库。记住,没有一种方法适合所有场景,关键是理解每种方法的优缺点,根据具体需求做出合理选择。

希望本文能帮助你在React开发中更加游刃有余地处理组件通信问题。Happy Coding!

react组件(2)---State 与生命周期

1. 什么是 React State?

State(状态)是 React 组件中存储可变数据的容器,它决定了组件的行为和渲染输出。与 Props 不同,State 是组件内部管理且可以变化的,而 Props 是从父组件传递过来的只读属性。

State 的核心特征包括:

  • 可变性:State 可以在组件生命周期内发生变化
  • 响应式:State 的改变会自动触发组件的重新渲染
  • 局部性:State 是组件私有的,其他组件无法直接访问
  • 异步性:setState 操作可能是异步的,React 会批量处理状态更新

在 React 中,将组件视为一个状态机,通过管理状态的变化来驱动 UI 的更新。

2. 类组件中的 State

2.1 状态的声明与初始化

在类组件中,State 通常在构造函数中初始化:

class Counter extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      count: 0,
      isActive: false
    };
  }
}

现代写法也可以使用类属性语法:

class Counter extends React.Component {
  state = {
    count: 0,
    isActive: false
  };
}

2.2 状态的更新

更新 State 必须使用 setState()方法,绝对不能直接修改 state

// 错误做法
this.state.count = 1; // 不会触发重新渲染!

// 正确做法
this.setState({ count: 1 });

// 当新状态依赖于旧状态时,使用函数形式
this.setState(prevState => ({
  count: prevState.count + 1
}));

3. 函数组件中的 State(useState Hook)

React 16.8 引入了 Hooks,允许函数组件使用 State。useState是最基础的 Hook。

3.1 useState 的基本用法

import React, { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>当前计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>
        点击增加
      </button>
    </div>
  );
}

3.2 useState 的高级用法

函数式更新(当新状态依赖于旧状态时):

const [count, setCount] = useState(0);

// 推荐:使用函数式更新确保基于最新状态
const increment = () => {
  setCount(prevCount => prevCount + 1);
};

延迟初始化(当初始状态需要复杂计算时):

const [state, setState] = useState(() => {
  const expensiveValue = performExpensiveCalculation();
  return expensiveValue;
});

对象和数组的状态管理

// 对象状态更新
const [user, setUser] = useState({ name: 'Alice', age: 25 });
setUser(prevUser => ({ ...prevUser, name: 'Bob' }));

// 数组状态更新
const [items, setItems] = useState(['apple', 'banana']);
setItems(prevItems => [...prevItems, 'orange']);

4. React 组件生命周期

类组件生命周期 函数组件 useEffect 实现方式
componentDidMount useEffect (() => {}, [])(空依赖数组)
componentDidUpdate useEffect (() => {}, [deps])(依赖项变化时执行)
componentWillUnmount useEffect (() => { return () => {} }, [])(返回清理函数)

4.1 生命周期概述

React 组件的生命周期可以分为三个主要阶段:

  • 挂载阶段:组件被创建并插入 DOM
  • 更新阶段:组件的 props 或 state 发生变化时重新渲染
  • 卸载阶段:组件从 DOM 中移除

4.2 类组件的生命周期方法

挂载阶段

  • constructor():初始化 state 和绑定方法
  • static getDerivedStateFromProps():在渲染前根据 props 更新 state
  • render():渲染组件(必需方法)
  • componentDidMount():组件挂载后执行,适合进行数据获取、订阅等副作用操作

更新阶段

  • static getDerivedStateFromProps():更新前根据 props 调整 state
  • shouldComponentUpdate():决定组件是否应该更新(性能优化关键)
  • render():重新渲染组件
  • getSnapshotBeforeUpdate():在 DOM 更新前捕获一些信息
  • componentDidUpdate():组件更新后执行,可以操作 DOM 或执行网络请求

卸载阶段

  • componentWillUnmount():组件卸载前执行清理操作,如取消定时器、网络请求等

4.3 函数组件中的"生命周期"(useEffect Hook)

useEffectHook 在函数组件中承担了生命周期方法的职责:

import React, { useState, useEffect } from 'react';

function Example() {
  const [count, setCount] = useState(0);
  
  // 相当于 componentDidMount + componentDidUpdate
  useEffect(() => {
    document.title = `点击了 ${count} 次`;
  });
  
  // 只在挂载时执行(类似 componentDidMount)
  useEffect(() => {
    console.log('组件挂载完成');
  }, []);
  
  // 依赖变化时执行(类似 componentDidUpdate)
  useEffect(() => {
    console.log(`count 变为: ${count}`);
  }, [count]);
  
  // 清理效果(类似 componentWillUnmount)
  useEffect(() => {
    const timer = setInterval(() => {
      // 一些操作
    }, 1000);
    
    return () => {
      clearInterval(timer); // 清理函数
    };
  }, []);
  
  return (
    <div>
      <p>你点击了 {count} 次</p>
      <button onClick={() => setCount(count + 1)}>
        点击我
      </button>
    </div>
  );
}

5. State 设计的最佳实践

5.1 State 的最小化原则

只将真正需要响应式更新的数据放入 State,派生数据可以在渲染时计算:

// 不好的做法:将派生数据放入 State
state = {
  items: [],
  totalCount: 0, // 可以从 items.length 派生
  filteredItems: [] // 可以从 items 过滤得到
};

// 好的做法:只存储原始数据
state = {
  items: []
};

// 派生数据在 render 中计算
get totalCount() {
  return this.state.items.length;
}

5.2 State 结构的扁平化

避免嵌套过深的 State 结构:

// 不好的嵌套结构
state = {
  user: {
    profile: {
      personalInfo: {
        name: '',
        age: 0
      }
    }
  }
};

// 好的扁平结构
state = {
  userName: '',
  userAge: 0
};

5.3 不可变更新模式

始终使用不可变的方式更新 State:

// 数组更新
// 错误:直接修改原数组
this.state.items.push(newItem);
// 正确:创建新数组
this.setState({
  items: [...this.state.items, newItem]
});

// 对象更新
// 错误:直接修改原对象
this.state.user.name = 'New Name';
// 正确:创建新对象
this.setState({
  user: { ...this.state.user, name: 'New Name' }
});

6. 常见场景与实战示例

6.1 数据获取场景

class DataFetcher extends React.Component {
  state = {
    data: [],
    loading: true,
    error: null,
    page: 1
  };
  
  componentDidMount() {
    this.fetchData();
  }
  
  componentDidUpdate(prevProps, prevState) {
    if (prevState.page !== this.state.page) {
      this.fetchData();
    }
  }
  
  fetchData = async () => {
    try {
      this.setState({ loading: true, error: null });
      const response = await fetch(`/api/data?page=${this.state.page}`);
      const result = await response.json();
      this.setState({ data: result, loading: false });
    } catch (error) {
      this.setState({ error: error.message, loading: false });
    }
  };
  
  render() {
    const { data, loading, error, page } = this.state;
    
    if (loading) return <div>加载中...</div>;
    if (error) return <div>错误: {error}</div>;
    
    return (
      <div>
        {/* 渲染数据 */}
      </div>
    );
  }
}

6.2 表单处理场景

function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  });
  
  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: value
    }));
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    // 提交表单数据
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        name="name"
        value={formData.name}
        onChange={handleChange}
      />
      <input
        name="email"
        value={formData.email}
        onChange={handleChange}
      />
      <textarea
        name="message"
        value={formData.message}
        onChange={handleChange}
      />
      <button type="submit">提交</button>
    </form>
  );
}

7. 常见陷阱与解决方案

7.1 过时状态问题

在闭包中捕获过时状态值:

// 问题代码:可能捕获过时状态
const [count, setCount] = useState(0);
const increment = () => {
  setTimeout(() => {
    setCount(count + 1); // 可能使用过时的 count 值
  }, 3000);
};

// 解决方案:使用函数式更新
const increment = () => {
  setTimeout(() => {
    setCount(prevCount => prevCount + 1); // 总是基于最新状态
  }, 3000);
};

7.2 useEffect 的依赖数组

正确处理 useEffect 的依赖数组避免无限循环:

// 错误:缺少依赖可能导致过时数据
useEffect(() => {
  fetchData(userId);
}, []);

// 错误:依赖不完整可能导致意外行为
useEffect(() => {
  fetchData(userId);
}, [userId]); // 如果 fetchData 使用了其他状态,需要包含

// 正确:包含所有依赖
useEffect(() => {
  fetchData(userId);
}, [userId, fetchData]); // 如果 fetchData 在渲染中定义,需要用 useCallback 包装

8. 总结

React 的 State 和生命周期是构建交互式界面的核心概念。无论是类组件还是函数组件,合理管理状态和理解组件生命周期都是开发高质量 React 应用的关键。

主要要点回顾

  • State 是组件内部的可变数据,Props 是从外部传入的只读数据
  • 类组件使用 this.statethis.setState(),函数组件使用 useStateHook
  • 生命周期方法让你在组件不同阶段执行代码,useEffect在函数组件中承担类似职责
  • 遵循 State 设计最佳实践(最小化、扁平化、不可变更新)
  • 注意常见陷阱,如过时状态和 useEffect 的依赖处理

随着 React 的发展,函数组件和 Hooks 已成为主流,但理解类组件的生命周期对于维护现有项目和深入理解 React 原理仍然很有价值。

希望本篇博客能帮助你更好地理解和应用 React 的 State 与生命周期概念!

❌