阅读视图

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

踩坑记:NPM 发布脚本导致组件重复发布

麻子哥问鼎了...

在日常的前端工程化实践中,GitHub Actions 自动化发布 NPM 包是提升效率的常用手段,但稍不注意的配置细节就可能引发诡异的问题。本文将分享我遇到的「Filter 组件发布时同一版本重复发布两次,最终因版本冲突报错」的问题,从现象到原理拆解根因,并给出可落地的解决方案。

一、问题现象:发布流程执行两次,版本冲突报错

1. 现象描述

基于 GitHub Actions 编写了自动化发布脚本,用于发布 ui-web-cli、search-chat、filter 等多个前端组件到 NPM 仓库。其中 Filter 组件发布时,日志中出现两次构建(vite build)和两次 npm publish 执行,最终抛出以下错误:

npm error You cannot publish over the previously published versions: 0.0.4.

关键日志片段:

> @infinilabs/filter@0.0.4 prepublishOnly
> vite build # 第一次构建

# 第一次发布成功日志...

> @infinilabs/filter@0.0.4 publish
> npm publish # 触发二次发布

> @infinilabs/filter@0.0.4 prepublishOnly
> vite build # 第二次构建

# 第二次发布因版本已存在报错...

2. 问题影响

  • 发布流程执行冗余的构建和发布操作,耗时翻倍;
  • 第二次发布因版本冲突直接失败,导致 CI 流程报错中断;
  • 虽第一次发布已成功,但 CI 失败会误导开发人员认为发布未完成,增加排查成本。

二、根因分析:NPM 发布钩子与自定义脚本的递归触发

1. 核心原理:NPM 发布的生命周期钩子

NPM 提供了一系列发布相关的生命周期脚本(scripts),当执行 npm publish 时,会按以下顺序触发:

  1. prepublishOnly:发布前执行(如构建、校验),不可跳过;
  2. publish:发布过程中执行;
  3. postpublish:发布完成后执行。

这些钩子的设计初衷是简化发布流程,但如果自定义脚本中包含 npm publish,就会触发递归调用

2. 问题根源定位

查看 Filter 组件的 package.json 发现了关键配置:

{
  "name": "@infinilabs/filter",
  "version": "0.0.4",
  "scripts": {
    "prepublishOnly": "vite build",
    "publish": "npm publish" // 罪魁祸首
  }
}

当 GitHub Actions 脚本中执行 npm publish 时,触发了以下递归流程:

CI 执行 npm publish 
  → 触发 prepublishOnly → 执行 vite build(第一次构建)
  → 触发 publish 脚本 → 执行 npm publish(第二次发布)
    → 再次触发 prepublishOnly → 执行 vite build(第二次构建)
    → 再次触发 publish 脚本 → 执行 npm publish(版本已存在,报错)

简单来说:自定义的 publish 脚本会递归调用 npm publish ,导致发布流程执行两次

3. 辅助诱因:CI 脚本的幂等性缺失

除了核心的递归触发问题,CI 脚本本身的两个设计缺陷加剧了问题:

  • 发布前未校验 NPM 仓库中是否已存在该版本,即使第一次发布成功,仍会无脑执行第二次发布;
  • PR 合并时未添加 [skip ci] 标记,可能触发 workflow 二次执行(虽本次问题非此原因,但会放大问题影响)。

三、解决方案:切断递归+保证发布幂等性

针对根因,我们采取「移除有害脚本 + 发布前校验 + 跳过冗余钩子」的组合方案,彻底解决重复发布问题。

步骤 1:移除 package.json 中的递归脚本

删除 package.json 中自定义的 publish 脚本,这是最核心的一步:

{
  "name": "@infinilabs/filter",
  "version": "0.0.4",
  "scripts": {
    "build": "vite build",
    "prepublishOnly": "vite build" // 保留用于本地发布校验,CI 中会跳过
    // 移除 "publish": "npm publish"
  }
}

步骤 2:优化 CI 脚本,保证发布幂等性

修改 GitHub Actions 脚本,增加版本校验、跳过冗余钩子、防止二次触发:

关键修改点 1:发布前校验版本是否已存在

在发布步骤前添加校验,避免重复发布:

- name: Check if version already published to NPM
  working-directory: ${{ env.REPO_NAME }}
  run: |
    cd ${{ env.DIST_DIR }}
    # 获取包名
    PACKAGE_NAME=$(jq -r .name package.json)
    # 检查 NPM 上是否已存在该版本
    if npm view "$PACKAGE_NAME@$VERSION" version >/dev/null 2>&1; then
      echo "::warning::Version $VERSION of $PACKAGE_NAME is already published, skipping publish."
      echo "SKIP_PUBLISH=true" >> $GITHUB_ENV
    else
      echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
    fi

关键修改点 2:发布时跳过生命周期钩子

CI 流程中已提前执行构建,发布时通过 --ignore-scripts 跳过 prepublishOnlypublish 钩子,避免重复构建:

- name: Inject Repository & Publish
  if: ${{ env.SKIP_PUBLISH == 'false' }}
  working-directory: ${{ env.REPO_NAME }}
  run: |
    cd ${{ env.DIST_DIR }}
    # 移除可能残留的 publish 脚本(兜底)
    tmp=$(mktemp)
    jq 'del(.scripts.publish)' package.json > "$tmp" && mv "$tmp" package.json
    # 发布时跳过所有生命周期脚本
    npm publish --provenance --access public --ignore-scripts

关键修改点 3:防止 PR 合并触发二次执行

在创建 PR 的 commit message 中添加 [skip ci],避免 PR 合并触发 workflow 重复执行:

- name: Create pull request for version bump
  uses: peter-evans/create-pull-request@v7
  with:
    commit-message: "chore: release ${{ env.COMPONENT }} v${{ env.VERSION }} [skip ci]"
    # 其他配置...

完整优化后的 CI 脚本片段

# 省略其他配置...
- name: Check if version already published to NPM
  working-directory: ${{ env.REPO_NAME }}
  run: |
    cd ${{ env.DIST_DIR }}
    PACKAGE_NAME=$(jq -r .name package.json)
    if npm view "$PACKAGE_NAME@$VERSION" version >/dev/null 2>&1; then
      echo "::warning::Version $VERSION already published, skipping."
      echo "SKIP_PUBLISH=true" >> $GITHUB_ENV
    else
      echo "SKIP_PUBLISH=false" >> $GITHUB_ENV
    fi

- name: Inject Repository & Publish
  if: ${{ env.SKIP_PUBLISH == 'false' }}
  working-directory: ${{ env.REPO_NAME }}
  run: |
    cd ${{ env.DIST_DIR }}
    # 兜底移除 publish 脚本
    tmp=$(mktemp)
    jq 'del(.scripts.publish)' package.json > "$tmp" && mv "$tmp" package.json
    # 仅当 repository 不存在时注入(避免重复修改)
    if ! jq '.repository' package.json >/dev/null 2>&1; then
      tmp=$(mktemp)
      jq --arg url "$REPO_URL" '.repository = { type: "git", url: $url }' package.json > "$tmp" && mv "$tmp" package.json
    fi
    # 跳过脚本发布
    npm publish --provenance --access public --ignore-scripts

四、避坑总结:NPM 发布自动化的核心原则

通过本次问题排查,我们总结出前端自动化发布 NPM 包的 3 个核心避坑原则:

1. 禁用递归的 publish 脚本

永远不要在 package.jsonscripts.publish 中执行 npm publish,这会触发无限递归(直到版本冲突/超时)。如需自定义发布逻辑,建议使用 release 等自定义脚本名:

{
  "scripts": {
    "release": "npm publish" // 手动执行 npm run release,而非依赖钩子
  }
}

2. 保证发布流程的幂等性

发布前必须校验 NPM 仓库中是否已存在该版本,即使流程重复触发,也能自动跳过发布步骤,避免版本冲突。

3. CI 中显式控制生命周期钩子

CI 流程中建议提前执行构建、校验等操作,发布时通过 --ignore-scripts 跳过 NPM 生命周期钩子,避免重复执行构建逻辑。

4. 防止 CI 流程的循环触发

通过 [skip ci] 标记、分支忽略等方式,避免 PR 合并、代码提交等操作触发发布流程的二次执行。

五、最终效果

优化后,Filter 组件的发布流程执行一次构建、一次发布,CI 日志无报错,版本发布成功且无冗余操作:

> @infinilabs/filter@0.0.4 build
> vite build # 仅一次构建

# 发布校验通过,执行一次发布
npm notice 📦  @infinilabs/filter@0.0.4
npm notice Tarball Details
npm notice name: @infinilabs/filter
npm notice version: 0.0.4
# 发布成功日志...

结语

自动化发布的核心是「可预测性」,看似简单的 npm publish 背后,隐藏着 NPM 生命周期钩子的执行逻辑。本次问题的根源是对 NPM 脚本钩子的理解不足,导致递归触发发布流程。在工程化实践中,理解工具的底层原理,再辅以幂等性、防重复触发的设计,才能构建稳定可靠的自动化流程。

React 防抖函数中的闭包陷阱与解决方案

背景

在 React 开发中,我们经常需要对用户输入进行防抖处理,以减少不必要的 API 请求。例如,在搜索功能中,我们希望用户停止输入 500ms 后再发起搜索请求。

然而,当我们在 useEffect 中使用防抖函数时,如果不注意处理,很容易遇到**闭包陷阱(Stale Closure)**问题,导致防抖函数内部访问的是过时的状态值,而不是最新的值。

问题场景

假设我们有一个搜索组件,需要在用户输入关键词时,延迟 500ms 后获取推荐商品列表:

useEffect(() => {
  const fetchProductList = async () => {
    // 如果没有搜索关键字,不请求
    if (!keyWord || !keyWord.trim()) {
      setProductList([])
      return
    }

    const params: Search.SearchParams = {
      keyword: keyWord.trim(),  // 使用 keyWord
      size: 5,
      page: 1,
    }

    if (vehicleData?.attributeValueId) {
      params.attributeValueId = vehicleData.attributeValueId
    }

    const response = await getPublicSearchFilter(params)
    // ... 处理响应
  }

  // 创建防抖函数
  const debouncedFn = debounce(fetchProductList, 500)
  debouncedFn()

  return () => {
    debouncedFn.cancel()
  }
}, [keyWord, vehicleData?.attributeValueId, vehicleData?.allAttributeValue])

问题表现

当用户快速输入时,比如:

  1. 输入 "tire" → keyWord = "tire"
  2. 继续输入 "tire 17" → keyWord = "tire 17"

期望:防抖函数执行时应该使用最新的 keyWord = "tire 17" 实际:防抖函数可能使用的是旧的 keyWord = "tire"

问题分析:闭包陷阱

什么是闭包?

闭包(Closure)是 JavaScript 中的一个重要概念。当一个函数内部定义了另一个函数,并且内部函数引用了外部函数的变量时,就形成了闭包。内部函数会"记住"创建时能访问到的变量。

function createCounter() {
  let count = 0  // 外部变量

  return function() {
    count++  // 闭包捕获了 count
    console.log(count)
  }
}

const counter = createCounter()
counter()  // 输出: 1
counter()  // 输出: 2

React 中的闭包陷阱

在 React 中,每次组件重新渲染时,函数组件都会重新执行,创建新的作用域。这导致了一个问题:

// 第一次渲染:keyWord = "tire"
useEffect(() => {
  // fetchProductList_1 闭包捕获:keyWord = "tire"
  const fetchProductList_1 = async () => {
    console.log(keyWord)  // "tire"
  }

  // debouncedFn 保存了 fetchProductList_1 的引用
  const debouncedFn = debounce(fetchProductList_1, 500)

}, [keyWord])

// 第二次渲染:keyWord = "tire 17"
useEffect(() => {
  // fetchProductList_2 闭包捕获:keyWord = "tire 17"
  const fetchProductList_2 = async () => {
    console.log(keyWord)  // "tire 17"
  }

  // 但是!如果防抖函数已经存在,不会重新创建
  // debouncedFn 内部仍然引用的是 fetchProductList_1
  // 当防抖执行时,调用的是 fetchProductList_1,打印的是 "tire" ❌

}, [keyWord])

为什么会出现这个问题?

  1. 防抖函数只创建一次:为了保持防抖效果,我们通常会将防抖函数保存在 useRef 中,只创建一次
  2. 闭包捕获旧值:第一次创建防抖函数时,fetchProductList 通过闭包捕获了当时的 keyWord
  3. 后续更新无效:即使 keyWord 变化,useEffect 重新执行,但防抖函数已经存在,它内部仍然引用的是旧的 fetchProductList,而旧的 fetchProductList 闭包捕获的是旧值

闭包链的传递

keyWord (第一次渲染的值,比如 "tire")fetchProductList (闭包捕获了 keyWord = "tire")debounce(fetchProductList) (保存了 fetchProductList 的引用)debouncedFetchProductListRef.current (防抖函数只创建一次)

keyWord 变成 "tire 17" 时:

  • useEffect 重新执行
  • 创建新的 fetchProductList(捕获新的 keyWord = "tire 17"
  • 但防抖函数已经存在,不会重新创建
  • 防抖函数内部仍然引用旧的 fetchProductList(捕获的是 "tire"

解决方案:使用 useRef 保存最新值

核心思路

使用 useRef 来保存最新的状态值,因为 ref.current 是一个可变的对象属性,不受闭包影响,读取时总是最新值。

实现步骤

1. 创建 ref 保存最新值

// 用于保存最新的 keyWord 和 vehicleData
const keyWordRef = useRef<string>(keyWord)
const vehicleDataRef = useRef(vehicleData)
// 用于保存防抖函数
const debouncedFetchProductListRef = useRef<ReturnType<typeof debounce> | null>(null)

2. 在独立的 useEffect 中更新 ref

// 更新 ref 的值,确保防抖函数总是使用最新的值
useEffect(() => {
  keyWordRef.current = keyWord
  vehicleDataRef.current = vehicleData
}, [keyWord, vehicleData])

3. 在防抖函数中使用 ref 获取最新值

useEffect(() => {
  const fetchProductList = async () => {
    // 使用 ref 获取最新的值,而不是直接使用闭包变量
    const currentKeyWord = keyWordRef.current
    const currentVehicleData = vehicleDataRef.current

    // 如果没有搜索关键字,不请求
    if (!currentKeyWord || !currentKeyWord.trim()) {
      setProductList([])
      return
    }

    const params: Search.SearchParams = {
      keyword: currentKeyWord.trim(),  // 使用 ref 的值
      size: 5,
      page: 1,
    }

    if (currentVehicleData?.attributeValueId) {
      params.attributeValueId = currentVehicleData.attributeValueId
    }

    const response = await getPublicSearchFilter(params)
    // ... 处理响应
  }

  // 创建防抖函数(如果还没有创建)
  if (!debouncedFetchProductListRef.current) {
    debouncedFetchProductListRef.current = debounce(fetchProductList, 500)
  }

  // 调用防抖函数
  debouncedFetchProductListRef.current()

  // 清理函数:组件卸载时取消待执行的防抖调用
  return () => {
    if (debouncedFetchProductListRef.current) {
      debouncedFetchProductListRef.current.cancel()
    }
  }
}, [keyWord, vehicleData?.attributeValueId, vehicleData?.allAttributeValue])

完整代码示例

import { useState, useEffect, useRef } from 'react'
import debounce from '@/utils/my-lodash/debounce'

function SearchList() {
  const [keyWord, setKeyWord] = useState('')
  const [vehicleData, setVehicleData] = useState(null)
  const [productList, setProductList] = useState([])
  const [productLoading, setProductLoading] = useState(false)

  // 用于跟踪当前请求的 ID,确保只处理最新请求的结果
  const requestIdRef = useRef<number>(0)
  // 用于保存防抖函数
  const debouncedFetchProductListRef = useRef<ReturnType<typeof debounce> | null>(null)
  // 用于保存最新的 keyWord 和 vehicleData,供防抖函数使用
  const keyWordRef = useRef<string>(keyWord)
  const vehicleDataRef = useRef(vehicleData)

  // 更新 ref 的值,确保防抖函数总是使用最新的值
  useEffect(() => {
    keyWordRef.current = keyWord
    vehicleDataRef.current = vehicleData
  }, [keyWord, vehicleData])

  // 获取推荐商品列表
  useEffect(() => {
    const fetchProductList = async () => {
      // 使用 ref 获取最新的值
      const currentKeyWord = keyWordRef.current
      const currentVehicleData = vehicleDataRef.current

      // 如果没有搜索关键字,不请求
      if (!currentKeyWord || !currentKeyWord.trim()) {
        setProductList([])
        return
      }

      // 生成新的请求 ID
      const currentRequestId = ++requestIdRef.current
      const currentKeyword = currentKeyWord.trim()

      setProductLoading(true)
      try {
        const params: Search.SearchParams = {
          keyword: currentKeyword,
          size: 5,
          page: 1,
        }

        // 如果有车型信息,添加到参数中
        if (currentVehicleData?.attributeValueId) {
          params.attributeValueId = currentVehicleData.attributeValueId
        }
        if (currentVehicleData?.allAttributeValue) {
          params.allAttributeValue = currentVehicleData.allAttributeValue
        }

        const response = await getPublicSearchFilter(params)

        // 检查是否是最新的请求,如果不是则忽略结果
        if (currentRequestId !== requestIdRef.current) {
          return
        }

        // 再次检查关键词是否仍然匹配(双重保险)
        if (currentKeyword !== keyWordRef.current.trim()) {
          return
        }

        const items = response?.itemList?.data || []
        setProductList(items.slice(0, 5))
      } catch (error) {
        if (currentRequestId !== requestIdRef.current) {
          return
        }
        console.error('获取推荐商品失败:', error)
        setProductList([])
      } finally {
        if (currentRequestId === requestIdRef.current) {
          setProductLoading(false)
        }
      }
    }

    // 创建防抖函数(如果还没有创建)
    if (!debouncedFetchProductListRef.current) {
      debouncedFetchProductListRef.current = debounce(fetchProductList, 500)
    }

    // 调用防抖函数
    debouncedFetchProductListRef.current()

    // 清理函数:组件卸载时取消待执行的防抖调用
    return () => {
      if (debouncedFetchProductListRef.current) {
        debouncedFetchProductListRef.current.cancel()
      }
    }
  }, [keyWord, vehicleData?.attributeValueId, vehicleData?.allAttributeValue])

  return (
    // ... JSX
  )
}

原理解析

为什么 ref 可以解决闭包陷阱?

关键在于理解 ref.current 的特性:

  1. ref 是可变的对象属性ref.current 是一个对象的属性,不是闭包变量
  2. 读取时总是最新值:每次读取 ref.current 时,获取的都是当前最新的值
  3. 不受闭包影响:即使函数通过闭包捕获了 ref 对象,读取 ref.current 时仍然能获取最新值

对比分析

// ❌ 错误方式:直接使用闭包变量
useEffect(() => {
  const fetchProductList = async () => {
    console.log(keyWord)  // 闭包捕获:keyWord = "tire"(创建时的值)
  }
  const debouncedFn = debounce(fetchProductList, 500)
}, [keyWord])

// ✅ 正确方式:使用 ref
const keyWordRef = useRef(keyWord)

useEffect(() => {
  keyWordRef.current = keyWord  // 更新同一个对象的属性
}, [keyWord])

useEffect(() => {
  const fetchProductList = async () => {
    console.log(keyWordRef.current)  // 读取对象属性,总是最新值
  }
  const debouncedFn = debounce(fetchProductList, 500)
}, [])

执行流程对比

错误方式(直接使用闭包变量):

第一次渲染:keyWord = "tire"
  → fetchProductList 闭包捕获 keyWord = "tire"
  → debounce(fetchProductList) 保存引用
  → 防抖函数内部:永远只能访问 "tire"

第二次渲染:keyWord = "tire 17"
  → 创建新的 fetchProductList(捕获 "tire 17")
  → 但防抖函数已存在,不会重新创建
  → 防抖函数仍然调用旧的 fetchProductList(捕获 "tire")❌

正确方式(使用 ref):

第一次渲染:keyWord = "tire"keyWordRef.current = "tire"
  → fetchProductList 读取 keyWordRef.current
  → debounce(fetchProductList) 保存引用

第二次渲染:keyWord = "tire 17"keyWordRef.current = "tire 17"(更新同一个对象属性)
  → 防抖函数执行时,读取 keyWordRef.current = "tire 17"

最佳实践

1. 分离 ref 更新逻辑

将 ref 的更新放在独立的 useEffect 中,确保每次状态变化时都能及时更新:

// 更新 ref 的值
useEffect(() => {
  keyWordRef.current = keyWord
  vehicleDataRef.current = vehicleData
}, [keyWord, vehicleData])

2. 防抖函数只创建一次

使用条件判断确保防抖函数只创建一次:

if (!debouncedFetchProductListRef.current) {
  debouncedFetchProductListRef.current = debounce(fetchProductList, 500)
}

3. 在清理函数中取消防抖

组件卸载时取消待执行的防抖调用,避免内存泄漏:

return () => {
  if (debouncedFetchProductListRef.current) {
    debouncedFetchProductListRef.current.cancel()
  }
}

4. 使用请求 ID 防止竞态条件

对于异步请求,使用请求 ID 确保只处理最新请求的结果:

const requestIdRef = useRef<number>(0)

const fetchProductList = async () => {
  const currentRequestId = ++requestIdRef.current
  // ... 发起请求

  // 检查是否是最新的请求
  if (currentRequestId !== requestIdRef.current) {
    return  // 忽略旧请求的结果
  }
  // ... 处理响应
}

常见错误

错误 1:在防抖函数中直接使用状态变量

// ❌ 错误
useEffect(() => {
  const fetchProductList = async () => {
    if (!keyWord || !keyWord.trim()) {  // 闭包捕获旧值
      return
    }
    // ...
  }
  const debouncedFn = debounce(fetchProductList, 500)
}, [keyWord])

错误 2:每次重新创建防抖函数

// ❌ 错误:失去防抖效果
useEffect(() => {
  const fetchProductList = async () => { /* ... */ }
  const debouncedFn = debounce(fetchProductList, 500)
  debouncedFn()
  return () => debouncedFn.cancel()
}, [keyWord])  // 每次 keyWord 变化都重新创建,防抖失效

错误 3:忘记更新 ref

// ❌ 错误:ref 没有更新
const keyWordRef = useRef(keyWord)

useEffect(() => {
  const fetchProductList = async () => {
    const currentKeyWord = keyWordRef.current  // 永远是初始值
    // ...
  }
}, [keyWord])  // 缺少更新 ref 的 useEffect

总结

  1. 问题根源:防抖函数只创建一次,但内部函数通过闭包捕获了创建时的状态值,导致后续状态更新无法反映到防抖函数中。

  2. 解决方案:使用 useRef 保存最新的状态值,在防抖函数中通过 ref.current 访问最新值,避免闭包陷阱。

  3. 关键要点

    • 防抖函数只创建一次,保持防抖效果
    • 通过 ref 访问最新状态,避免闭包陷阱
    • 及时更新 ref 的值
    • 正确处理清理逻辑
  4. 适用场景:所有需要在防抖/节流函数中访问最新状态的场景,如搜索输入、滚动事件处理、窗口大小变化等。

参考资料

最后感谢阅读!欢迎关注我,微信公众号:《鲫小鱼不正经》。欢迎点赞、收藏、关注,一键三连!!!

前端实战:让表格Header优雅吸顶的魔法

❤ 写在前面
如果觉得对你有帮助的话,点个小❤❤ 吧,你的支持是对我最大的鼓励~
个人独立开发wx小程序,感谢支持! small.png


引言:当表格太长时,表头去哪了?

你有没有遇到过这样的尴尬场景:查看一个超长的数据表格,滚动到下面时,完全忘记了每一列代表什么意思?只能不停地上下来回滚动,就像在玩“表头捉迷藏”游戏。

今天,我要分享的正是解决这个痛点的实用技巧——表格Header吸顶效果。就像给你的表格表头装上“磁铁”,无论怎么滚动,表头都会固定在顶部!

效果展示

想象一下这样的体验:

  • 默认状态:表头在表格顶部
  • 向下滚动:表头“粘”在浏览器顶部
  • 继续滚动:表头始终可见
  • 向上滚动:表头回归原位

是不是很酷?让我们一步步实现它!

技术方案对比

方案一:CSS position: sticky(简单但有限制)

thead {
  position: sticky;
  top: 0;
  background: white;
  z-index: 10;
}

优点:一行代码搞定! 缺点:父容器不能有overflow: hidden,兼容性需要考虑

方案二:JavaScript动态计算(灵活可控)

// 监听滚动,动态切换样式
window.addEventListener('scroll', () => {
  if (table到达顶部) {
    表头添加固定定位
  } else {
    表头移除固定定位
  }
});

优点:兼容性好,控制精细 缺点:需要写更多代码

完整实现方案(JavaScript版)

第一步:HTML结构准备

<div class="container">
  <div class="page-header">
    <h1>员工信息表</h1>
    <p>共128条记录,滚动查看详情</p>
  </div>
  
  <div class="table-wrapper">
    <table id="sticky-table">
      <thead class="table-header">
        <tr>
          <th>ID</th>
          <th>姓名</th>
          <th>部门</th>
          <th>职位</th>
          <th>入职时间</th>
          <th>状态</th>
        </tr>
      </thead>
      <tbody>
        <!-- 数据行会通过JS生成 -->
      </tbody>
    </table>
  </div>
</div>

第二步:CSS样式设计

/* 基础样式 */
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.page-header {
  background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
  color: white;
  padding: 30px;
  border-radius: 10px;
  margin-bottom: 30px;
  text-align: center;
}

.table-wrapper {
  border-radius: 8px;
  overflow: hidden;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}

/* 表格基础样式 */
#sticky-table {
  width: 100%;
  border-collapse: collapse;
}

/* 表头默认样式 */
.table-header {
  background-color: #2c3e50;
  color: white;
}

.table-header th {
  padding: 16px 12px;
  text-align: left;
  font-weight: 600;
  border-bottom: 2px solid #34495e;
}

/* 表头吸顶时的样式 */
.table-header.sticky {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 1000;
  background-color: #2c3e50;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
  animation: slideDown 0.3s ease;
}

/* 吸顶动画 */
@keyframes slideDown {
  from {
    transform: translateY(-100%);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

/* 数据行样式 */
tbody tr {
  border-bottom: 1px solid #eee;
  transition: background-color 0.2s;
}

tbody tr:hover {
  background-color: #f9f9f9;
}

tbody td {
  padding: 14px 12px;
  color: #333;
}

.status-active {
  color: #27ae60;
  font-weight: bold;
}

.status-inactive {
  color: #e74c3c;
  font-weight: bold;
}

第三步:JavaScript实现逻辑

class StickyTableHeader {
  constructor(tableId) {
    this.table = document.getElementById(tableId);
    this.header = this.table.querySelector('thead');
    this.placeholder = null;
    this.isSticky = false;
    
    this.init();
  }
  
  init() {
    // 1. 创建占位元素(防止表头固定后表格内容跳动)
    this.createPlaceholder();
    
    // 2. 生成测试数据
    this.generateTableData();
    
    // 3. 监听滚动事件
    window.addEventListener('scroll', this.handleScroll.bind(this));
    
    // 4. 监听窗口大小变化(重新计算位置)
    window.addEventListener('resize', this.handleResize.bind(this));
  }
  
  createPlaceholder() {
    // 创建与表头相同大小的透明占位元素
    this.placeholder = document.createElement('div');
    this.placeholder.style.height = `${this.header.offsetHeight}px`;
    this.placeholder.style.display = 'none';
    this.table.parentNode.insertBefore(this.placeholder, this.table);
  }
  
  handleScroll() {
    // 获取表格相对于视口的位置
    const tableRect = this.table.getBoundingClientRect();
    const headerHeight = this.header.offsetHeight;
    
    // 判断逻辑:表格顶部是否滚动出视口
    if (tableRect.top <= 0 && !this.isSticky) {
      this.activateSticky();
    } else if (tableRect.top > 0 && this.isSticky) {
      this.deactivateSticky();
    }
    
    // 额外优化:如果表格底部已经在视口中,取消固定
    if (tableRect.bottom <= headerHeight + 100 && this.isSticky) {
      this.deactivateSticky();
    }
  }
  
  handleResize() {
    // 窗口大小变化时,更新占位元素高度
    if (this.placeholder) {
      this.placeholder.style.height = `${this.header.offsetHeight}px`;
    }
    
    // 如果当前是吸顶状态,需要重新计算宽度
    if (this.isSticky) {
      this.setHeaderWidth();
    }
  }
  
  activateSticky() {
    this.isSticky = true;
    
    // 添加吸顶类名
    this.header.classList.add('sticky');
    
    // 显示占位元素
    this.placeholder.style.display = 'block';
    
    // 设置表头宽度与表格一致
    this.setHeaderWidth();
  }
  
  deactivateSticky() {
    this.isSticky = false;
    
    // 移除吸顶类名
    this.header.classList.remove('sticky');
    
    // 隐藏占位元素
    this.placeholder.style.display = 'none';
  }
  
  setHeaderWidth() {
    // 确保固定表头的宽度与表格容器一致
    const tableWidth = this.table.offsetWidth;
    this.header.style.width = `${tableWidth}px`;
    
    // 同步每一列的宽度
    const ths = this.header.querySelectorAll('th');
    const tbodyFirstRow = this.table.querySelector('tbody tr');
    
    if (tbodyFirstRow) {
      const tds = tbodyFirstRow.querySelectorAll('td');
      
      ths.forEach((th, index) => {
        if (tds[index]) {
          th.style.width = `${tds[index].offsetWidth}px`;
        }
      });
    }
  }
  
  generateTableData() {
    // 生成模拟数据
    const departments = ['技术部', '市场部', '设计部', '人力资源', '财务部'];
    const positions = ['工程师', '经理', '设计师', '专员', '总监', '助理'];
    const statuses = ['active', 'inactive'];
    
    const tbody = this.table.querySelector('tbody');
    
    for (let i = 1; i <= 50; i++) {
      const row = document.createElement('tr');
      
      const department = departments[Math.floor(Math.random() * departments.length)];
      const position = positions[Math.floor(Math.random() * positions.length)];
      const status = statuses[Math.floor(Math.random() * statuses.length)];
      const startDate = new Date(2018 + Math.floor(Math.random() * 5), 
                                 Math.floor(Math.random() * 12), 
                                 Math.floor(Math.random() * 28) + 1);
      
      row.innerHTML = `
        <td>${1000 + i}</td>
        <td>员工${i}</td>
        <td>${department}</td>
        <td>${position}</td>
        <td>${startDate.getFullYear()}-${String(startDate.getMonth() + 1).padStart(2, '0')}-${String(startDate.getDate()).padStart(2, '0')}</td>
        <td class="status-${status}">${status === 'active' ? '在职' : '离职'}</td>
      `;
      
      tbody.appendChild(row);
    }
  }
}

// 初始化表格
document.addEventListener('DOMContentLoaded', () => {
  new StickyTableHeader('sticky-table');
  
  // 添加滚动提示
  const hint = document.createElement('div');
  hint.style.cssText = `
    position: fixed;
    bottom: 20px;
    right: 20px;
    background: #3498db;
    color: white;
    padding: 10px 15px;
    border-radius: 20px;
    font-size: 14px;
    z-index: 1001;
    box-shadow: 0 3px 10px rgba(0,0,0,0.2);
    animation: bounce 2s infinite;
  `;
  hint.innerHTML = '👇 滚动试试,表头会吸顶哦!';
  document.body.appendChild(hint);
  
  // 添加提示动画
  const style = document.createElement('style');
  style.textContent = `
    @keyframes bounce {
      0%, 100% { transform: translateY(0); }
      50% { transform: translateY(-5px); }
    }
  `;
  document.head.appendChild(style);
  
  // 5秒后隐藏提示
  setTimeout(() => {
    hint.style.opacity = '0';
    hint.style.transition = 'opacity 1s';
    setTimeout(() => hint.remove(), 1000);
  }, 5000);
});

实现原理流程图

graph TD
    A[开始] --> B[初始化表格和表头]
    B --> C[创建占位元素]
    C --> D[生成表格数据]
    D --> E[监听滚动事件]
    
    E --> F{表格顶部是否滚动出视口?}
    F -->|是| G[激活吸顶效果]
    F -->|否| H[检查是否已吸顶]
    
    H -->|是| I[取消吸顶效果]
    H -->|否| E
    
    G --> J[添加sticky类名]
    J --> K[显示占位元素]
    K --> L[设置表头宽度]
    L --> E
    
    I --> M[移除sticky类名]
    M --> N[隐藏占位元素]
    N --> E

关键技巧和注意事项

1. 占位元素的重要性

吸顶效果会让表头脱离文档流,导致下面的内容突然上跳。占位元素在表头固定时显示,保持布局稳定。

2. 性能优化

  • 使用requestAnimationFrame优化滚动事件
  • 添加防抖处理,避免频繁计算
  • 缓存DOM查询结果

优化后的滚动处理:

handleScroll() {
  // 使用requestAnimationFrame优化性能
  if (!this.ticking) {
    requestAnimationFrame(() => {
      this.updateStickyState();
      this.ticking = false;
    });
    this.ticking = true;
  }
}

3. 边界情况处理

  • 表格数据很少时,不需要吸顶
  • 窗口大小变化时,重新计算宽度
  • 表格完全滚动出视口时,取消吸顶

4. 视觉细节

  • 添加平滑过渡动画
  • 固定时添加阴影,增强层次感
  • 保持表头列宽与数据列对齐

响应式设计考虑

在移动设备上,我们可能需要调整吸顶策略:

/* 移动端调整 */
@media (max-width: 768px) {
  .table-header.sticky {
    /* 移动端可以缩小内边距 */
    padding: 8px 4px;
  }
  
  /* 表格水平滚动 */
  .table-wrapper {
    overflow-x: auto;
  }
  
  #sticky-table {
    min-width: 600px;
  }
}

总结

实现表格Header吸顶效果,就像给用户提供了一个"阅读助手",让长表格的浏览体验大大提升。通过今天分享的方法,你可以:

  1. 用少量代码实现核心功能
  2. 处理各种边界情况
  3. 优化性能确保流畅体验
  4. 适配不同设备屏幕

记住,好的用户体验往往就藏在这些细节中。下次当你遇到长表格时,不妨试试这个"吸顶魔法",让你的页面变得更加友好!

动手试试

你可以复制上面的代码到本地HTML文件,或者访问我在CodePen上创建的示例(模拟链接),直接体验和修改代码。

小挑战:尝试添加一个功能,当表头吸顶时,右侧显示一个"回到顶部"的按钮,点击后平滑滚动到表格开始位置。祝你好运!


希望这篇教程对你有所帮助!如果有任何问题或改进建议,欢迎在评论区留言讨论。Happy coding! 🚀

前端必备技能:彻底搞懂JavaScript深浅拷贝,告别数据共享的坑!

❤ 写在前面
如果觉得对你有帮助的话,点个小❤❤ 吧,你的支持是对我最大的鼓励~
个人独立开发wx小程序,感谢支持! small.png


开篇故事:为什么我的数据“打架”了?

想象一下这个场景:你在开发一个购物车功能,复制了一个商品对象准备修改数量,结果发现原始商品的数据也变了!这种“灵异事件”让很多前端开发者头疼不已。

let originalProduct = {
  name: "JavaScript高级程序设计",
  price: 99,
  details: {
    publisher: "人民邮电出版社",
    pages: 728
  }
};

// 你以为的“复制”
let copiedProduct = originalProduct;
copiedProduct.price = 79; // 修改副本的价格

console.log(originalProduct.price); // 79?!原对象也被修改了!

这就是我们今天要解决的“深浅拷贝”问题!下面,让我们一步步解开这个谜团。

内存模型:理解深浅拷贝的基础

在深入之前,我们先看看JavaScript中数据是如何存储的:

┌─────────────┐      ┌───────────────┐
│  栈内存     │      │   堆内存       │
│ (Stack)     │      │   (Heap)      │
├─────────────┤      ├───────────────┤
│ 基本类型    │      │               │
│ 变量名|值   │      │  引用类型     │
│ a -> 10     │      │  的对象数据   │
│ b -> true   │      │  {name: "xxx"}│
│             │      │  [1,2,3]      │
│ obj1 -> ↗═══╪══════> {x: 1, y: 2}  │
│ obj2 -> ↗═══╪══════> {name: "test"}│
└─────────────┘      └───────────────┘

基本类型(Number, String, Boolean等)直接存储在栈内存中,而引用类型(Object, Array等)在栈中只存储地址指针,真正的数据在堆内存中。

深浅拷贝对比流程图

flowchart TD
    A[原始对象] --> B{选择拷贝方式}
    B --> C[浅拷贝]
    B --> D[深拷贝]
    
    C --> E[只复制第一层属性]
    E --> F[嵌套对象仍共享内存]
    F --> G[修改嵌套属性会影响原对象]
    
    D --> H[递归复制所有层级]
    H --> I[完全独立的新对象]
    I --> J[新旧对象互不影响]
    
    G --> K[适用场景:简单数据结构]
    J --> L[适用场景:复杂嵌套对象]

浅拷贝:只挖第一层

浅拷贝就像只复制房子的钥匙,不复制房子里的家具。

常见的浅拷贝方法

方法1:展开运算符(最常用)

let shallowCopy = { ...originalObject };
let shallowCopyArray = [...originalArray];

方法2:Object.assign()

let shallowCopy = Object.assign({}, originalObject);

方法3:数组的slice()和concat()

let shallowCopyArray = originalArray.slice();
let anotherCopy = originalArray.concat();

浅拷贝的陷阱

let user = {
  name: "小明",
  settings: {
    theme: "dark",
    notifications: true
  }
};

let userCopy = { ...user };
userCopy.name = "小红"; // ✅ 不会影响原对象
userCopy.settings.theme = "light"; // ❌ 原对象的theme也被修改了!

console.log(user.settings.theme); // "light" 中招了!

深拷贝:连根拔起的复制

深拷贝是真正的"克隆",创建一个完全独立的新对象。

方法1:JSON大法(最简单但有局限)

let deepCopy = JSON.parse(JSON.stringify(originalObject));

注意限制:

  • 不能复制函数、undefined、Symbol
  • 不能处理循环引用
  • Date对象会变成字符串

方法2:手写递归深拷贝函数

function deepClone(obj, hash = new WeakMap()) {
  // 处理基本类型和null
  if (obj === null || typeof obj !== 'object') {
    return obj;
  }
  
  // 处理Date
  if (obj instanceof Date) {
    return new Date(obj);
  }
  
  // 处理数组
  if (Array.isArray(obj)) {
    return obj.map(item => deepClone(item, hash));
  }
  
  // 处理普通对象
  if (hash.has(obj)) {
    return hash.get(obj); // 解决循环引用
  }
  
  let cloneObj = Object.create(Object.getPrototypeOf(obj));
  hash.set(obj, cloneObj);
  
  for (let key in obj) {
    if (obj.hasOwnProperty(key)) {
      cloneObj[key] = deepClone(obj[key], hash);
    }
  }
  
  return cloneObj;
}

// 测试
let complexObj = {
  name: "测试",
  date: new Date(),
  nested: { x: 1 },
  arr: [1, 2, [3, 4]]
};
complexObj.self = complexObj; // 循环引用

let cloned = deepClone(complexObj);
console.log(cloned !== complexObj); // true
console.log(cloned.nested !== complexObj.nested); // true
console.log(cloned.self === cloned); // true,循环引用正确处理

方法3:使用现成库

// lodash
import _ from 'lodash';
let deepCopy = _.cloneDeep(originalObject);

// 或者使用structuredClone(现代浏览器原生API)
if (typeof structuredClone === 'function') {
  let deepCopy = structuredClone(originalObject);
}

实战场景:什么时候用什么拷贝?

场景1:表单编辑(推荐深拷贝)

// 编辑前深拷贝原始数据
let editData = deepClone(originalData);

// 用户随意编辑...
editData.user.profile.avatar = "new_avatar.jpg";

// 点击取消,原始数据完好无损
// 点击保存,提交editData

场景2:状态管理中的状态更新(浅拷贝足够)

// Redux reducer中的状态更新
function reducer(state = initialState, action) {
  switch (action.type) {
    case 'UPDATE_SETTINGS':
      return {
        ...state, // 浅拷贝
        settings: {
          ...state.settings, // 嵌套对象也需要展开
          theme: action.payload
        }
      };
    // ...
  }
}

场景3:配置对象合并(浅拷贝+深度合并)

function mergeConfig(defaultConfig, userConfig) {
  return {
    ...defaultConfig,
    ...userConfig,
    // 深度合并嵌套对象
    options: {
      ...defaultConfig.options,
      ...userConfig.options
    }
  };
}

性能考虑:深浅拷贝的选择

方法 速度 内存占用 适用场景
浅拷贝 ⚡⚡⚡⚡⚡(快) 简单对象,无嵌套修改
JSON深拷贝 ⚡⚡⚡(中) 数据简单,无函数/日期
递归深拷贝 ⚡⚡(慢) 复杂对象,需要完整复制
structuredClone ⚡⚡⚡⚡(较快) 现代浏览器,需要原生支持

总结:拷贝选择速查表

  1. 只需要复制第一层数据? → 使用展开运算符 ...
  2. 需要完全独立的副本? → 使用深拷贝
  3. 数据简单,不含函数/日期?JSON.parse(JSON.stringify())
  4. 需要处理复杂类型和循环引用? → 手写递归或使用lodash
  5. 现代浏览器环境? → 尝试 structuredClone

记住这个黄金法则:当你不知道嵌套属性是否需要独立修改时,选择深拷贝更安全!

互动挑战

试试看你能看出下面代码的输出吗?

let puzzle = {
  a: 1,
  b: { inner: 2 },
  c: [3, 4]
};

let copy1 = { ...puzzle };
let copy2 = JSON.parse(JSON.stringify(puzzle));

copy1.b.inner = 999;
copy1.c.push(888);

console.log(puzzle.b.inner); // 是多少?
console.log(puzzle.c.length); // 是多少?
console.log(copy2.b.inner); // 是多少?

答案:第一个是999(浅拷贝共享嵌套对象),第二个是4(数组也被修改了),第三个是2(深拷贝完全独立)。

希望这篇博客能帮你彻底理解JavaScript中的深浅拷贝!下次遇到数据"打架"的情况,你就知道该怎么处理了。

React 跨层级组件通信:使用 `useContext` 打破“长安的荔枝”困境

在 React 开发中,组件通信是绕不开的核心话题。当应用结构逐渐复杂,父子组件之间的简单 props 传递就显得力不从心——尤其是当数据需要跨越多层组件传递时,开发者常常陷入“一路往下传”的泥潭。这种模式不仅代码冗余,还极难维护,被戏称为 “长安的荔枝” :为了把一颗荔枝从岭南送到长安,要层层接力,劳民伤财。

幸运的是,React 提供了 useContext + createContext 的组合拳,让我们能在任意深度的子组件中直接获取顶层数据,彻底告别 props drilling(属性层层透传)。

本文将通过一个完整示例,带你掌握 useContext 的使用方法、原理和最佳实践。


一、问题场景:为什么需要跨层级通信?

假设我们有如下组件树:


App
 └── Page
      └── Header
           └── UserInfo   ← 需要显示用户信息
  • 用户信息(如 name: 'Andrew')在最顶层的 App 中定义。

  • 而真正需要展示它的组件是深层嵌套的 UserInfo

  • 如果用传统 props 传递:

    • App → 传给 Page
    • Page → 传给 Header
    • Header → 传给 UserInfo

中间的 PageHeader 根本不关心用户数据,却被迫成为“传话筒”。这就是典型的 props drilling 问题。

🍒 “长安的荔枝”比喻
就像唐代为杨贵妃运送荔枝,从岭南到长安,沿途设驿站接力传递。中间每一站都不吃荔枝,只为传递而存在——效率低下,成本高昂。


二、解决方案:React Context + useContext

React 的 Context API 允许我们在组件树中创建一个全局可访问的数据容器,任何后代组件都可以直接“订阅”这个容器,无需中间组件介入。

✅ 核心三要素:

角色 API 作用
1. 创建上下文 createContext(defaultValue) 创建一个 Context 对象
2. 提供数据 <Context.Provider value={data}> 在顶层包裹组件树,注入数据
3. 消费数据 const data = useContext(Context) 在任意子组件中读取数据

三、实战:用 useContext 实现用户信息共享

步骤 1:创建 Context 容器(通常在 App.js 或单独文件)


// App.jsx
import { createContext, useContext } from 'react';
import Page from './views/Page';

// 1. 创建 Context(可导出供其他文件使用)
export const UserContext = createContext(null);

export default function App() {
  // 2. 定义要共享的数据
  const user = {
    name: 'Andrew',
    role: 'Developer'
  };

  return (
    // 3. 用 Provider 包裹整个子树,提供 value
    <UserContext.Provider value={user}>
      <Page />
    </UserContext.Provider>
  );
}

💡 createContext(null) 中的 null 是默认值,当组件未被 Provider 包裹时使用。


步骤 2:在深层子组件中消费数据


// components/UserInfo.jsx
import { useContext } from 'react';
import { UserContext } from '../App'; // 导入 Context

function UserInfo() {
  // 4. 使用 useContext 获取数据
  const user = useContext(UserContext);

  console.log(user); // { name: 'Andrew', role: 'Developer' }

  return (
    <div>
      <h3>欢迎你,{user?.name}!</h3>
      <p>角色:{user?.role}</p>
    </div>
  );
}

export default UserInfo;

✅ 注意:

  • UserInfo 不需要任何 props
  • 即使它嵌套在 Header → Page 之下,也能直接访问 user

步骤 3:中间组件完全“无感”


// components/Header.jsx
import UserInfo from './UserInfo';

function Header() {
  // Header 完全不知道 user 的存在!
  return (
    <header>
      <UserInfo /> {/* 直接使用,无需传 props */}
    </header>
  );
}

export default Header;

// views/Page.jsx
import Header from '../components/Header';

function Page() {
  // Page 也完全无感
  return (
    <main>
      <Header />
    </main>
  );
}

export default Page;

🎯 关键优势
中间组件 零耦合、零负担,只负责自己的 UI 结构。


四、useContext 的工作原理

你可以把 UserContext 想象成一个全局广播站

  • <UserContext.Provider value={user}>:开启广播,内容为 user
  • useContext(UserContext):在任意位置“收听”这个频道
  • 数据变化时,所有“听众”组件自动重新渲染(类似 state)

⚠️ 注意:Context 适合低频更新的全局状态(如用户信息、主题、语言)。高频状态(如表单输入)建议用 Zustand、Redux 或 useState 提升。


五、最佳实践与注意事项

✅ 1. 将 Context 抽离到单独文件(推荐)

避免循环依赖,提高可维护性:


// contexts/UserContext.js
import { createContext } from 'react';

export const UserContext = createContext(null);
jsx
编辑
// App.jsx
import { UserContext } from './contexts/UserContext';

✅ 2. 提供默认值或空对象

防止未包裹 Provider 时崩溃:


const user = useContext(UserContext) || {};

✅ 3. 避免滥用 Context

  • 不要为每个小状态都创建 Context
  • 合并相关状态到一个 Context(如 AuthContext 包含 user、login、logout)

✅ 4. 性能优化:拆分 Context

如果多个不相关的数据放在一起,会导致无关组件不必要的重渲染


// ❌ 不好:一个 Context 包含所有
<UserContext.Provider value={{ user, theme, lang }}>

// ✅ 好:按功能拆分
<AuthContext.Provider value={auth}>
<ThemeContext.Provider value={theme}>

六、useContext vs 其他状态管理方案

方案 适用场景 学习成本 适用规模
useState + Props 简单父子通信 小型组件
useContext 跨层级、低频全局状态 ⭐⭐ 中小型应用
Zustand / Jotai 复杂状态、高频更新 ⭐⭐ 中大型应用
Redux 超大型应用、时间旅行调试 ⭐⭐⭐ 大型团队项目

💡 对于大多数 React 应用,useContext + useReducer 已足够应对 80% 的状态管理需求


七、总结:告别“长安的荔枝”,拥抱 Context

  • 问题:props drilling 导致中间组件冗余、维护困难。
  • 方案:使用 createContext + Provider + useContext 创建全局数据通道。
  • 效果:任意深度子组件直接访问数据,中间组件零负担。
  • 原则:用于跨层级、低频更新的共享状态。

🌟 记住
Context 不是万能的,但它是解决“跨层级通信”最轻量、最 React 原生的方式。

现在,你可以自信地重构那些“传了五层 props 才到目标组件”的代码了!让数据像空气一样,在组件树中自由流动,而无需层层搬运 🍃。


动手试试吧!

Three.js:Web 最重要的 3D 渲染引擎的技术综述

理解赋能 Web 实时 3D 图形的抽象层、渲染管线和性能特征。

现代 Web 越来越依赖丰富的视觉体验——数据可视化、模拟仿真、产品预览、生成艺术以及沉浸式 UI。虽然浏览器早已通过 Canvas 和 SVG 支持 2D 图形,但实时 3D 渲染需要一套复杂得多的 GPU 驱动操作。Three.js 已成为填补这一空白的事实标准(de facto standard)

虽然大多数介绍将 Three.js 描述为“一个 JavaScript 3D 库”,但它在架构上的角色更为基础。Three.js 是 WebGL 之上的一个结构化抽象层,旨在减少直接与 GPU 交互时的脚手架代码、复杂性和脆弱性。开发者无需手动管理着色器(shaders)、缓冲区(buffers)和渲染状态,而是使用连贯的高级构造——场景、相机、网格、材质——而库则负责高效地编排底层的 GPU 管线。

本文将从技术角度概述 Three.js 的工作原理、支持其性能的内部系统,以及为什么一旦应用程序超越简单的演示(demo)阶段,理解这些系统就变得至关重要。

I. Three.js 作为 WebGL 的抽象层

WebGL 是一个低级 API,它将可编程图形管线暴露给浏览器。在其核心,WebGL 要求开发者手动处理:

  • 着色器的编译和链接
  • 顶点和索引缓冲区的创建
  • 属性(Attribute)和统一变量(Uniform)的绑定
  • 纹理上传
  • 状态变更
  • 绘制调用(Draw call)的执行

WebGL:底层图形 API 和状态机 Three.js 通过统一的渲染架构抽象了这些职责。

架构目的:结构化的 GPU 交互

Three.js 不是 WebGL 的替代品;它是一个控制层,旨在消除冗余的复杂性,同时保留对底层 GPU 特性的访问能力。

它提供了:

  • 场景图(Scene graph)
  • 几何体(Geometry)和材质(Material)抽象
  • 中心化的渲染器(Renderer)
  • 相机系统
  • 对光照、阴影、动画和加载器的内置支持

这在不降低能力的情况下减少了认知负荷。

Three.js 不是 WebGL 的替代品;它是一个控制层

II. 场景图:核心数据结构

Three.js 将 3D 世界组织成一个分层的场景图(Scene Graph) ,其中每个对象都表示为一个具有变换(transform)和可选子节点的节点。

Scene (场景)
 ├── Mesh (网格)
 │     ├── Geometry (几何体)
 │     └── Material (材质)
 ├── Group (组)
 ├── Camera (相机)
 └── Lights (光源)

场景图在技术上的重要性

每个节点都携带:

  • 局部变换矩阵(Local transformation matrix)
  • 世界变换矩阵(World transformation matrix)
  • 位置、旋转、缩放
  • 父子关系

在渲染期间,Three.js 遍历场景图以计算:

  • 更新后的世界矩阵
  • 基于视锥体剔除(Frustum culling)的可见性
  • 材质 + 几何体的组合
  • 渲染顺序和绘制调用

这种层级结构使得复杂的动画、实例化(instancing)和空间组织变得可预测且高效。如果没有场景图,开发者将需要手动同步数以百计或千计的独立 GPU 绑定对象。

III. 几何体、缓冲区和类型化数组

在 GPU 层面,所有 3D 网格最终只是结构化的数字数组。Three.js 通过 BufferGeometry 暴露了这一点,它直接反映了 GPU 如何使用顶点数据。

一个 BufferGeometry 包含:

  • position 属性:每个顶点的 3D 坐标
  • normal 属性:用于光照计算
  • uv 属性:用于纹理映射
  • 可选的 index 缓冲区 — 定义哪些顶点构成三角形

每个属性都由类型化数组(Typed Array)支持,如 Float32ArrayUint16Array

为什么类型化数组是必要的

类型化数组提供:

  • 连续的内存布局
  • 可预测的性能
  • 到 GPU 缓冲区的直接二进制传输
  • 每帧更新时的最小开销

JavaScript 对象无法匹配这种级别的可预测性或效率。通过将几何体结构化为连续的缓冲区,Three.js 最小化了 CPU-GPU 的同步开销,即使在包含数万个顶点的场景中也能确保稳定的性能。

为什么类型化数组是必要的

IV. 材质和着色器程序的生成

Three.js 提供了多种材质类型——MeshBasicMaterialMeshStandardMaterialMeshPhysicalMaterialShaderMaterial 等。无论抽象级别如何,所有材质最终都会编译成在 GPU 上执行的 GLSL 着色器程序。

内部着色器系统

Three.js 根据以下内容动态生成着色器:

  • 光照配置
  • 阴影设置
  • 雾化参数
  • 纹理使用情况
  • 材质类型和参数
  • 精度和性能指令

这种动态编译允许材质保持灵活性,同时确保着色器程序针对特定的场景配置进行优化。

为什么理解着色器仍然重要

虽然 Three.js 抽象了着色器的创建,但开发者通常需要理解:

  • 法线映射(Normal mapping)
  • 粗糙度/金属度工作流(Roughness/metalness workflows)
  • BRDF 计算
  • 片元操作(Fragment operations)
  • 渲染目标(Render target)行为

自定义材质或高级效果几乎总是需要手动编写着色器,这使得 GLSL 读写能力成为严肃的 Three.js 开发的一项宝贵技能。

V. 渲染循环和帧生命周期

Three.js 运行在一个可预测的渲染循环上,通常由 requestAnimationFrame 驱动。

每一帧涉及:

  1. 处理动画更新
  2. 更新相机矩阵
  3. 遍历场景图
  4. 运行视锥体剔除
  5. 准备材质 + 着色器程序
  6. 准备几何体缓冲区
  7. 执行 WebGL 绘制调用
  8. 呈现帧

整个过程必须在约 16 毫秒内完成,以维持 60 FPS。

关于渲染成本的技术观察

  • 每个网格至少触发一次绘制调用
  • 材质切换会产生 GPU 状态变更
  • 阴影需要额外的渲染通道(Render passes)
  • 动态对象比静态对象更昂贵

渲染循环的效率直接决定了应用程序的性能。

VI. 性能架构:Three.js 中真正关键的因素

Three.js 的性能瓶颈通常不在于 JavaScript 的执行,而在于 GPU 限制、显存带宽和绘制调用的开销。

以下是影响实际性能的领域:

1. 最小化绘制调用(Draw Call)

GPU 执行少量的大型绘制调用比执行许多小型绘制调用更高效。每次绘制调用都需要状态绑定、程序切换和缓冲区设置。

优化措施包括:

  • 几何体合并(Geometry merging)
  • 实例化网格(InstancedMesh
  • 减少材质变体
  • 策略性地使用图层(Layers)和分组

2. 纹理和内存策略

高分辨率纹理会增加:

  • VRAM(显存)使用
  • 上传成本
  • Mipmap 生成时间

WebGPU 将改善某些方面,但在 WebGL 上,使用压缩纹理格式(如 Basis/KTX2)可提供显著的性能提升。

3. CPU-GPU 同步约束

在渲染循环内分配对象或每帧修改几何体属性会导致垃圾回收(GC)压力和缓冲区重新上传。

性能准则包括:

  • 避免在循环内重新创建向量或矩阵
  • 除非必要,避免修改缓冲区几何体
  • 优先使用基于着色器的变换

4. 材质复杂性

基于物理的渲染(PBR)材质(如 MeshStandardMaterial)计算量大,原因在于:

  • 环境采样
  • 多光源计算
  • 基于 BRDF 的着色

选择最简单的适用材质通常能立即带来 FPS 的提升。

VII. Three.js 与 TypeScript:强大的架构组合

Three.js 提供了健壮的 TypeScript 定义,强制执行:

  • 属性类型安全
  • 几何体一致性
  • 材质参数正确性
  • 相机和渲染器配置的有效性

在大型应用程序——可视化仪表盘、模拟仿真或产品配置器——中,Three.js 与类型化场景定义的结合显著减少了运行时缺陷。

VIII. 技术学习路径:从高级 API 到 GPU 理解

Three.js 让人可以在几分钟内构建出功能性的 3D 场景。然而,一旦项目对性能、保真度或自定义视觉效果提出要求,深入的理解就变得不可或缺。

关键领域包括:

  • GLSL 着色器开发
  • WebGL 管线状态机行为
  • GPU 内存限制
  • 纹理流式传输(Streaming)和 Mipmapping
  • 实例化(Instancing)和批处理(Batching)
  • 渲染目标管理
  • 后处理链(Post-processing chains)

Three.js 提供了脚手架,但高性能的 3D 开发要求对库本身以及底层图形学原理都能熟练掌握。

技术学习路径:从高级 API 到 GPU 理解

结论

Three.js 远不止是一个 WebGL 的便捷包装器。它是一个精心设计的渲染层,旨在使 GPU 编程变得易于上手,同时在需要时保留低级控制权。它在几何体、着色器、渲染循环和性能优化方面的结构化方法,为 JavaScript 和实时 3D 图形之间搭建了一座高效的桥梁。

随着 3D 界面、模拟仿真、数字孪生和 AR/VR 驱动的应用程序变得越来越普遍,从技术层面理解 Three.js 将成为一种有意义的工程优势。那些既理解抽象层又理解其背后 GPU 层面含义的开发者,将能够设计出不仅视觉震撼,而且稳健、高性能且可扩展的系统。

翻译整理自:Three.js: A Technical Overview of the Web’s Most Important 3D Rendering Engine

通用管理后台组件库-3-vue-i18n国际化集成

i18n国际化集成

说明:使用vue-i18n库实现系统国际化,结合@intlify/unplugin-vue-i18n插件实现预加载(开发中国际化显示),同时集成elementplus中en和zh-cn文件的组件国际化。

1.国际化文件

/locales/en.json

{
  "anything": "anything",
  "hello": "Hello"
}

/locales/zh-CN.json

{
  "hello": "你好"
}

2.插件i18n ally, 使用命令添加文本国际化翻译

.vscode/setting.json

{
  "i18n-ally.localesPaths": ["locales"],
  "i18n-ally.keystyle": "nested",
  "i18n-ally.sortKeys": true,
  "i18n-ally.namespace": true,
  "i18n-ally.enabledParsers": ["yaml", "js", "json"],
  "i18n-ally.sourceLanguage": "en",
  "i18n-ally.displayLanguage": "zh-CN",
  "i18n-ally.enabledFrameworks": ["vue"],
  "i18n-ally.translate.engines": [
    "baidu",
    "google"
  ]
}

3.国际化i18n.ts

import type { App } from 'vue'
import { createI18n, type Locale } from 'vue-i18n'

// Legacy模式(选项式API):语言设置是直接赋值,vue2的赋值方式
// Composition模式(组合式API):语言设置是赋值给响应式变量

// 创建一个i18n实例
const i18n = createI18n({
  legacy: false,
  locale: '',
  messages: {}
})

// 解析locales目录下的所有语言文件,转换为对象,例如:{ en: () => import('../../locales/en.js'), zh-CN: () => import('../../locales/zh-CN.js') }
const localesMap = Object.fromEntries(
  Object.entries(import.meta.glob('../../locales/*.json')).map(([path, loadLocale]) => [
    path.match(/([\w-]*)\.json$/)?.[1],
    loadLocale
  ])
) as Record<Locale, () => Promise<{ default: Record<string, string> }>>

// 集成elementplus中的国际化文件en和zh-CN
const elementPlusLocaleMap = Object.fromEntries(
  Object.entries(import.meta.glob('../../node_modules/element-plus/dist/locale/*.mjs')).map(
    ([path, loadLocale]) => [path.match(/([\w-]*)\.mjs$/)?.[1], loadLocale]
  )
) as Record<Locale, () => Promise<{ default: Record<string, string> }>>

// 获取存在的语言数组
export const availableLocales = Object.keys(localesMap)

// 过滤elementplus中的国际化文件,只保留en和zh-CN
const filterEPLocaleMap = availableLocales.reduce(
  (acc: Record<Locale, () => Promise<{ default: Record<string, string> }>>, locale: Locale) => {
    return {
      ...acc,
      //locale.toLowerCase()将zh-CN转换为小写,elementplus中的语言文件都是小写的
      [locale]: elementPlusLocaleMap[locale.toLowerCase()]
    }
  },
  {}
)
// 记住用户选择的语言
const loadedLanguages: string[] = []

// 设置国际化(i18n)语言的函数
export function setI18nLanguage(locale: string) {
  // Composition模式赋值
  i18n.global.locale.value = locale
  if (typeof document !== 'undefined') {
    document.querySelector('html')?.setAttribute('lang', locale)
  }
}

// 加载国际化(i18n)语言包的异步函数
export async function loadLocaleMessages(lang: string) {
  // 如果语言包i18n已经加载过,则直接设置i18n.locale
  if (i18n.global.locale.value === lang || loadedLanguages.includes(lang)) {
    return setI18nLanguage(lang)
  }
  // 通过语言代码从预定义的语言映射中获取对应的语言包加载函数并执行
  const messages = await localesMap[lang]()
  // 获取elementplus的语言包
  const messagesEP = await filterEPLocaleMap[lang]()
  // 将加载的语言包设置到i18n实例中
  i18n.global.setLocaleMessage(lang, { ...messagesEP.default, ...messages.default })
  loadedLanguages.push(lang)
  return setI18nLanguage(lang)
}

// 挂载到app上
export default {
  install(app: App) {
    app.use(i18n)
    // 设置默认语言为中文
    loadLocaleMessages('zh-CN')
  }
}

main.ts中挂载到app上

import i18n from './modules/i18n'
app.use(i18n)

4.构建和打包优化,因elementplus包中不止en和zh-cn文件,还有其他语言文件,所有在构建时过滤出en和zh-cn文件打包到dist中。

vite.config.ts

import { fileURLToPath, URL } from 'node:url'
import path from 'node:path'
import fs from 'node:fs'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
import UnoCSS from 'unocss/vite'

import VueRouter from 'unplugin-vue-router/vite'
import { VueRouterAutoImports } from 'unplugin-vue-router'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import Layouts from 'vite-plugin-vue-layouts'
import { VitePWA } from 'vite-plugin-pwa'
import { viteMockServe } from 'vite-plugin-mock'
// import dotenv from 'dotenv'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
// Load environment variables
import { createSvgIconsPlugin } from 'vite-plugin-svg-icons'

import VueI18nPlugin from '@intlify/unplugin-vue-i18n/vite'

// https://vite.dev/config/
export default defineConfig(({ mode }) => {
  // 加载环境变量
  const env = loadEnv(mode, process.cwd()) // 加载 `.env.[mode]`

  const enablePWADEBUG = env.VITE_PWA_DEBUG === 'true'
  const enableMock = env.VITE_MOCK_ENABLE === 'true'

  /**
   * elementplus国际化文件打包和构建优化,只保留zh-cn和en文件到dist中。
   * 过滤elementplus的.mjs文件,不打包不需要的locales
   * 判断,/locales中对应的文件名的.mjs文件作为过滤条件->保留
   */
  function filterElementPlusLocales(id: string) {
    // 返回true表示打包,false表示不打包
    // locales文件查找
    const localesDir = path.resolve(__dirname, 'locales')
    const localesFiles = fs
      .readdirSync(localesDir)
      .map((file) => file.match(/([\w-]*)\.json$/)?.[1] || '')
    if (id.includes('element-plus/dist/locale')) {
      // 获取id的basename
      const basename = path.basename(id, '.mjs')
      // 判断basename是否在localesFiles中
      return !localesFiles.some((file) => basename === file.toLowerCase())
    }
    return false
  }

  return {
    build: {
      rollupOptions: {
        // id:文件名,external:是否打包
        external: (id) => filterElementPlusLocales(id)
      }
    },
    plugins: [
      VueRouter(),
      vue(),
      vueJsx(),
      vueDevTools(),
      UnoCSS(),
      AutoImport({
        include: [
          /\.[tj]sx?$/, // .ts, .tsx, .js, .jsx
          /\.vue$/,
          /\.vue\?vue/, // .vue
          /\.md$/ // .md
        ],

        // global imports to register
        imports: [
          // presets
          'vue',
          // 'vue-router'
          VueRouterAutoImports,
          '@vueuse/core'
        ],
        resolvers: [ElementPlusResolver()]
      }),
      Components({
        directoryAsNamespace: false,
        collapseSamePrefixes: true,
        resolvers: [ElementPlusResolver()]
      }),
      Layouts({
        layoutsDirs: 'src/layouts',
        defaultLayout: 'default'
      }),
      VitePWA({
        injectRegister: 'auto',
        manifest: {
          name: 'Vite App',
          short_name: 'Vite App',
          theme_color: '#ffffff',
          icons: [
            {
              src: '/192x192.png',
              sizes: '192x192',
              type: 'image/png'
            },
            {
              src: '/512x512.png',
              sizes: '512x512',
              type: 'image/png'
            }
          ]
        },
        registerType: 'autoUpdate',
        workbox: {
          navigateFallback: '/',
          // 如果大家有很大的资源文件,wasm bundle.js
          globPatterns: ['**/*.*']
        },
        devOptions: {
          enabled: enablePWADEBUG,
          suppressWarnings: true,
          navigateFallbackAllowlist: [/^\/$/],
          type: 'module'
        }
      }),
      viteMockServe({
        mockPath: 'mock',
        enable: enableMock
      }),
      createSvgIconsPlugin({
        // 指定需要缓存的图标文件夹
        iconDirs: [path.resolve(process.cwd(), 'src/assets/icons')],
        // 指定symbolId格式
        symbolId: 'icon-[dir]-[name]'
      }),
      VueI18nPlugin({
        include: [path.resolve(__dirname, './locales/**')],
        // 组合式赋值方式,契合Vue3.0
        compositionOnly: true
      })
    ],
    resolve: {
      alias: {
        '@': fileURLToPath(new URL('./src', import.meta.url))
      }
    }
  }
})

5.使用和效果

<div>自定义国际化:{{ $t('hello') }}</div>
<div>elementplus国际化:{{ $t('el.colorpicker.confirm') }}</div>
<div>{{ $t('anything') }}</div>
<el-select v-model="locale" placeholder="请选择" @change="changeLocale"
  <el-option label="中文" value="zh-CN" />
  <el-option label="英文" value="en" />
</el-select>

image.png

image.png

用 React Context 实现全局主题切换:从零搭建暗黑/亮色模式系统

用 React Context 实现全局主题切换:从零搭建暗黑/亮色模式系统

在现代 Web 应用中,主题切换(如白天/夜间模式)已成为提升用户体验的标配功能。用户希望界面能随环境光线自动适应,或按个人偏好自由切换。然而,如何在 React 应用中高效、优雅地实现这一功能?答案就是:React Context + 自定义 Provider 封装

本文将带你从零开始,手把手构建一个完整的主题管理系统,涵盖状态共享、UI 响应、持久化存储等核心环节,并深入解析其背后的设计思想与最佳实践。


一、为什么需要 Context?告别“Props Drilling”之痛

假设我们想在应用顶部放一个“切换主题”按钮,而底部某个卡片组件需要根据主题改变背景色。若使用传统 props 传递:

<App theme={theme} toggleTheme={toggleTheme}><Header theme={theme} toggleTheme={toggleTheme}><Content><Card theme={theme} />

每一层组件都必须接收并透传 themetoggleTheme,即使它们自身并不使用。这种 “属性层层透传” (Props Drilling)不仅代码冗余,还导致组件耦合度高、难以维护。

React Context 正是为解决此类跨层级状态共享问题而生。它提供了一种机制:

父组件创建一个“数据广播站”,所有后代组件都能直接“收听”,无需中间人传话。


二、核心架构:三大组件协同工作

我们的主题系统由三个关键部分组成:

1. ThemeContext:数据通道

// contexts/ThemeContext.js
import { createContext } from 'react';
export const ThemeContext = createContext(null);
  • 使用 createContext(null) 创建一个全局可访问的上下文对象;
  • null 是默认值,当组件未被 Provider 包裹时返回。

2. ThemeProvider:状态管理 + 数据广播

// contexts/ThemeContext.js (续)
import { useState, useEffect } from 'react';

export default function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme(t => t === 'light' ? 'dark' : 'light');
  };

  // 关键:同步主题到 HTML 根元素
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}
  • 状态管理:用 useState 维护当前主题('light' 或 'dark');
  • 操作封装toggleTheme 函数封装切换逻辑;
  • DOM 同步:通过 useEffect 将主题写入 <html data-theme="dark">,便于 CSS 选择器响应。

3. Header:消费主题状态

// components/Header.js
import { useContext } from 'react';
import { ThemeContext } from '../contexts/ThemeContext';

export default function Header() {
  const { theme, toggleTheme } = useContext(ThemeContext);
  
  return (
    <div style={{ marginBottom: 24 }}>
      <h2>当前主题: {theme}</h2>
      <button onClick={toggleTheme}>切换主题</button>
    </div>
  );
}
  • 使用 useContext(ThemeContext) 直接获取主题状态和切换函数;
  • 完全解耦:无需父组件传递 props,无论嵌套多深都能访问。

三、应用组装:自上而下的数据流

根组件 App:启动主题服务

// App.js
import ThemeProvider from './contexts/ThemeContext';
import Page from './Pages/Page';

export default function App() {
  return (
    <ThemeProvider>
      <Page />
    </ThemeProvider>
  );
}
  • 用 <ThemeProvider> 包裹整个应用,确保所有子组件处于主题上下文中。

页面组件 Page:透明中转

// Pages/Page.js
import Header from '../components/Header';

export default function Page() {
  return (
    <div style={{ padding: 24 }}>
      Page
      <Header />
    </div>
  );
}
  • Page 无需知道主题存在,直接渲染 Header,实现零耦合

四、CSS 如何响应主题变化?

虽然你的示例未使用 Tailwind,但原理相通。关键在于 利用 data-theme 属性编写条件样式

/* 全局样式 */
body {
  background-color: white;
  color: black;
}

/* 暗色模式覆盖 */
html[data-theme='dark'] body {
  background-color: #1a1a1a;
  color: #e0e0e0;
}

/* 组件级样式 */
.card {
  background: #f5f5f5;
}

html[data-theme='dark'] .card {
  background: #2d2d2d;
}

✅ 优势:

  • 不依赖 JavaScript 动态设置 class;
  • 样式集中管理,易于维护;
  • 支持服务端渲染(SSR)。

若使用 Tailwind CSS,只需配置 darkMode: 'class',然后写:

<div class="bg-white dark:bg-gray-900 text-black dark:text-white">

并通过 JS 切换 <html class="dark"> 即可。


五、进阶优化:持久化用户偏好

当前实现刷新后会重置为 'light'。要记住用户选择,只需两步:

1. 初始化时读取 localStorage

const [theme, setTheme] = useState(() => {
  if (typeof window !== 'undefined') {
    return localStorage.getItem('theme') || 'light';
  }
  return 'light';
});

2. 切换时保存到 localStorage

const toggleTheme = () => {
  const newTheme = theme === 'light' ? 'dark' : 'light';
  setTheme(newTheme);
  localStorage.setItem('theme', newTheme); // 👈 保存
};

💡 注意:需判断 window 是否存在,避免 SSR 报错。


六、设计思想:为什么这样封装?

1. 单一职责原则

  • ThemeContext:只负责创建通道;
  • ThemeProvider:只负责状态管理与广播;
  • Header:只负责 UI 展示与交互。

2. 高内聚低耦合

  • 中间组件(如 Page)完全 unaware 主题存在;
  • 新增组件只需调用 useContext,无需修改父组件。

3. 可复用性

  • ThemeProvider 可直接复制到新项目;
  • 配合自定义 Hook(如 useTheme())进一步简化调用。

七、常见陷阱与解决方案

问题 原因 解决方案
useContext 返回 null 组件未被 Provider 包裹 确保根组件正确包裹
切换无效 CSS 未响应 data-theme 检查选择器优先级
SSR 不一致 客户端/服务端初始状态不同 在 useEffect 中初始化状态
性能问题 高频更新导致重渲染 拆分 Context,避免大对象

八、总结:Context 是 React 的“神经系统”

通过这个主题切换案例,我们看到:

  • Context 不是“传数据”,而是“建通道”
  • Provider 是数据源,useContext 是接收器
  • 中间组件完全透明,实现极致解耦

这种模式不仅适用于主题,还可用于:

  • 用户登录状态
  • 国际化语言
  • 购物车数据
  • 应用配置

掌握 Context,你就掌握了 React 全局状态管理的第一把钥匙

未来,你可以在此基础上集成 useReducer 管理复杂状态,或结合 Zustand/Jotai 等轻量库进一步简化。但无论如何,理解 Context 的底层机制,永远是进阶之路的基石

现在,打开你的编辑器,亲手实现一个主题切换吧——让用户在白天与黑夜之间,自由穿梭! 🌓☀️

【高斯泼溅】3DGS城市模型从“硬盘杀手”到“轻盈舞者”?看我们如何实现14倍压缩

如何把一座城市渲染出来?

三年前,NeRF给出的答案是**“隐式网络+无尽采样”**,渲染的算力黑洞让人望而却步;如今,3D Gaussian Splatting(3DGS)用“显式高斯椭球”消除了渲染阶段对网络的依赖,却悄悄把问题翻了个面——模型体量大成了新瓶颈。

3DGS模型与椭球分布

当场景从“桌面摆件”扩展到“十字街头”再到“万亩城区”,数据像吹气球一样膨胀:数千万椭球、上百GB模型文件,训练时卡爆GPU,加载时撑爆显卡,传输时堵爆带宽,保存时挤占硬盘

“怎样在保真的同时变得更轻”,本文将主要分析3DGS模型潜在的冗余椭球问题及探讨相应的解决方法。

冗余椭球与训练缓慢

当我们训练一个3DGS模型时,总会有一个疑问:这个场景到底需要多少椭球,才能既保证渲染质量又保证训练速度?

01椭球数量的增长

3DGS原始实现并没有设置一个固定的数量上限,而是靠着人为设置的阈值在指定训练次数后停止增长椭球。在典型的小区域环绕场景中椭球可以增长到500~600万左右。这个数量一般远超想要表达的主要兴趣区域所需。

图中在仅使用234万椭球的情况下即可达到570万的渲染指标。

原生算法和简化效果对比

02训练速度的降低

随着椭球数量的增长,随之而来的是显卡显存的占用和计算压力的提升,带来的直接影响便是训练时长的显著增加。3DGS原始实现训练常用的小场景数据集时间平均在20~40分钟之间,而且我们可以显著的观察到训练速度是随着点数增长而相应降低的。

如何用更少的椭球数量实现同等的渲染质量是一个必须研究的方向。识别冗余椭球并删除,是一个显而易见的可行方案。

主流删除方案:直接法与剪枝法

直接法剪枝法目的都是删除冗余点,但他们之间最主要的区别是:删除依据是可学习的还是人工设计的。

01直接法

直接法一般不参与训练中的梯度计算,仅在训练中/后直接计算每个椭球的重要性分数,逐步/一次删除至设定的点数。

这方面的代表有LightGaussianTamingGSMini-splatting等论文。重要性分数的计算共有步骤有:

  1. 输入训练视角的所有图片和位姿
  2. 遍历图片,每张图片计算与其有关椭球的重要性分数(不透明度、命中像素次数等)
  3. 累加所有视角,计算出最终每个椭球的重要性分数
  4. 根据需要,删除低分数椭球

LightGaussian重要性计算

02剪枝法

剪枝法认为直接限制椭球数量上限难以满足渲染质量的要求,因此一般是在训练过程中识别并逐渐删除对渲染质量贡献较小的冗余高斯。

剪枝法的核心思想是为每个椭球增加一个可训练的参数,用于表示其对渲染的贡献程度,从而可以依据该参数直接进行删除。该方法的相关工作有CompactGS、MaskGS、GaussianSpa等。

MaskGS流程图,M为可学习参数

与直接法重要性得分计算类似,剪枝法需要构建一个可以微分的重要性得分。在上述工作中,主要与椭球的不透明度、形状、透射率等参数相关联,依托椭球本身的属性来构建重要性。

比如CompactGS方法思路较为直接,直接构建mask掩膜,为每个椭球分配一个二值变量(0或1),其中1表示渲染,0表示删除。虽然二值操作本身不可导,不能被优化,但是可以通过构造可导的间接变量,使得梯度可以传导至掩码。

CompactGS掩码公式

从下表可以看出:

  • 原始3DGS中确实存在较多冗余椭球
  • 两类方法均可以在较大的压缩比下实现接近甚至超出的质量
  • 删除椭球后训练时间有不同程度降低(GaussianSPA训练轮数更多)

建模质量与数量对比

两种方法的适用情况也不尽相同,直接法的重要性分数一般可以在训练后算出剪枝法一般需要伴随一定量的训练步骤。如果是已经训练完的原始模型,最好使用直接法删除椭球,以降低可能的额外训练时间。

Mapmost 高斯泼溅建模平台

结合学术界压缩方法,我们在Mapmost高斯泼溅建模平台综合并改进了一套模型轻量化算法,可以在保证细节的情况下,最高达到14倍的压缩比。

图中14倍压缩率情况下,模型体积从170MB降低至16MB,文字细节和屋顶轮廓仍然保持较高还原度。这样不仅减小了存储占用,同时也有利于建模和渲染性能的提升。

Mapmost高斯泼溅建模平台正式开放体验!让专业级城市三维建模,从此没有门槛。上传航拍图,即可获得一个高精度、轻量化、即拿即用的3DGS模型。

登录Mapmost 3DGS Builder体验版(studio.mapmost.com/3dgs),立即开始建模!

申请试用,请至Mapmost官网联系客服

手把手实现 Gin + Socket.IO 实时聊天功能

手把手实现 Gin + Socket.IO 实时聊天功能

在 Web 开发中,实时通信场景(如在线聊天、实时通知、协同编辑等)十分常见,而 Socket.IO 作为一款成熟的实时通信库,支持 WebSocket 协议并提供轮询降级方案,能很好地兼容各类浏览器和场景。本文将手把手教你使用 Go 语言的 Gin 框架整合 Socket.IO,搭建一套完整的前后端实时聊天系统,包含房间广播、跨域处理、静态资源托管等核心功能。

一、项目准备

1. 技术栈说明

  • 后端:Go 1.18+、Gin 框架(轻量高性能 HTTP 框架)、googollee/go-socket.io(Socket.IO Go 服务端实现)
  • 前端:原生 JavaScript、Socket.IO 客户端(兼容服务端版本)
  • 运行环境:Windows/Linux/Mac(本文以 Windows 为例,跨平台无差异)

2. 项目目录结构

先搭建规范的项目目录,便于后续开发和维护:

plaintext

chat-demo/
├── go.mod       // Go 模块依赖配置
├── main.go      // 后端核心代码
└── static/      // 前端静态资源目录
    ├── index.html       // 前端聊天页面
    ├── jquery-3.6.0.min.js  // jQuery(可选,本文未实际依赖)
    ├── socket.io-1.2.0.js   // Socket.IO 客户端
    └── favicon.ico      // 网站图标(可选)

3. 初始化 Go 模块

打开终端,进入项目目录,执行以下命令初始化 Go 模块:

bash

运行

go mod init chat-demo

然后安装所需依赖:

bash

运行

# 安装 Gin 框架
go get github.com/gin-gonic/gin
# 安装 Socket.IO Go 服务端
go get github.com/googollee/go-socket.io

二、后端实现:Gin + Socket.IO 服务搭建

后端核心功能包括:Gin 引擎配置、跨域处理、静态资源托管、Socket.IO 服务初始化、房间管理与消息广播。

1. 完整后端代码(main.go)

go

运行

package main

import (
"github.com/gin-gonic/gin"
socketio "github.com/googollee/go-socket.io"
"github.com/googollee/go-socket.io/engineio"
"github.com/googollee/go-socket.io/engineio/transport"
"github.com/googollee/go-socket.io/engineio/transport/polling"
"github.com/googollee/go-socket.io/engineio/transport/websocket"
"log"
"net/http"
)

func main() {
// 1. Gin 引擎优化:生产环境启用 Release 模式,关闭调试日志
gin.SetMode(gin.ReleaseMode)
router := gin.Default()

// 2. 跨域中间件配置:解决前后端跨域通信问题
router.Use(func(c *gin.Context) {
// 允许所有来源跨域(生产环境可指定具体域名,更安全)
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
// 允许的 HTTP 请求方法
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
// 允许的请求头
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// 处理 OPTIONS 预检请求
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusOK)
return
}
c.Next()
})

// 3. 静态资源托管:映射 static 目录,提供前端页面和静态文件
router.Static("/static", "./static")

// 4. Socket.IO 服务器配置:支持 polling(轮询)和 websocket(优先推荐)
sio := socketio.NewServer(&engineio.Options{
Transports: []transport.Transport{
polling.Default,
websocket.Default,
},
})

// 5. Socket.IO 事件监听:处理连接、消息、加入房间、断开连接等事件
// 5.1 客户端连接事件
sio.OnConnect("/", func(s socketio.Conn) error {
log.Println("客户端已连接:", s.ID())
return nil
})

// 5.2 接收客户端发送的消息事件,并广播到 chat 房间
sio.OnEvent("/", "message", func(s socketio.Conn, msg string) {
log.Println("收到消息:", msg, "(来自:", s.ID(), ")")
// 广播消息到 / 命名空间下的 chat 房间
sio.BroadcastToRoom("/", "chat", "message", msg)
})

// 5.3 客户端加入房间事件
sio.OnEvent("/", "join", func(s socketio.Conn, room string) {
// 让当前客户端加入指定房间
s.Join(room)
log.Println("客户端", s.ID(), "已加入房间:", room)
})

// 5.4 客户端断开连接事件
sio.OnDisconnect("/", func(s socketio.Conn, reason string) {
log.Println("客户端", s.ID(), "已断开连接;原因:", reason)
})

// 5.5 错误处理事件
sio.OnError("/", func(s socketio.Conn, e error) {
log.Println("客户端", s.ID(), "发生错误:", e)
})

// 6. 注册 Socket.IO 路由:将 Socket.IO 请求委托给 Gin 处理
router.GET("/socket.io/*any", gin.WrapH(sio))
router.POST("/socket.io/*any", gin.WrapH(sio))

// 7. 根路径路由:访问 http://127.0.0.1:8080/ 直接返回前端聊天页面
router.GET("/", func(c *gin.Context) {
c.File("./static/index.html")
})

// 8. 启动 Socket.IO 服务器(异步启动,不阻塞 Gin 启动)
go sio.Serve()
defer sio.Close() // 程序退出时关闭 Socket.IO 服务

// 9. 启动 Gin 服务器,监听 8080 端口
if err := router.Run(":8080"); err != nil {
log.Fatalf("服务器启动失败: %v", err)
}
}

2. 后端核心功能说明

  • Gin 优化:启用 gin.ReleaseMode 关闭调试日志,提升服务性能,适合生产环境部署。

  • 跨域处理:通过自定义中间件设置 CORS 响应头,处理 OPTIONS 预检请求,解决前后端跨域通信障碍。

  • 静态资源托管:通过 router.Static 将 ./static 目录映射到 /static 路由,前端可通过该路径访问 JS、图片等静态资源。

  • Socket.IO 配置:同时支持 polling 和 websocket 传输方式,websocket 为高性能全双工通信,polling 作为降级方案兼容低版本浏览器。

  • 事件处理

    • OnConnect:监听客户端连接,打印客户端唯一 ID;
    • OnEvent("message"):接收客户端消息,并通过 BroadcastToRoom 广播到 chat 房间;
    • OnEvent("join"):处理客户端加入房间请求,通过 s.Join(room) 让客户端加入指定房间;
    • OnDisconnect/OnError:监听客户端断开连接和错误事件,便于问题排查和日志监控。
  • 路由配置:根路径 / 直接返回前端 index.html,无需手动拼接静态资源路径,使用更便捷;Socket.IO 路由注册后,可处理前端的 Socket.IO 连接请求。

三、前端实现:Socket.IO 客户端与页面交互

前端核心功能包括:页面布局搭建、Socket.IO 客户端连接、加入房间、消息发送与接收、页面渲染。

1. 完整前端代码(static/index.html)

html

预览

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Socket.IO 实时聊天示例</title>
    <!-- 引入 jQuery(本文未实际使用,可按需移除) -->
    <script src="/static/jquery-3.6.0.min.js"></script>
    <!-- 引入 Socket.IO 客户端库(需与服务端协议兼容) -->
    <script src="/static/socket.io-1.2.0.js"></script>
    <!-- 网站图标(可选) -->
    <link rel="icon" href="/static/favicon.ico" type="image/x-icon">
</head>

<body>
    <!-- 聊天界面布局:输入框、发送按钮、消息展示区域 -->
    <input type="text" id="message-input" placeholder="输入消息">
    <button id="send-button">发送</button>
    <div id="messages"></div>

    <script>
        // 1. 连接 Socket.IO 服务端
        var socket = io('http://127.0.0.1:8080/', {
            transports: ['websocket', 'polling'], // 优先使用 websocket,降级为 polling
            timeout: 5000 // 连接超时时间:5 秒
        });

        // 2. 监听连接成功事件,连接后立即加入 chat 房间
        socket.on('connect', () => {
            // 发送 join 事件,加入 chat 房间
            socket.emit('join', 'chat');
            console.log('已连接到服务器');
        });

        // 3. 监听服务端广播的 message 事件,渲染消息到页面
        socket.on('message', function (msg) {
            const messagesDiv = document.getElementById('messages');
            const newMessage = document.createElement('p');
            newMessage.textContent = msg;
            messagesDiv.appendChild(newMessage);
        });

        // 4. 绑定发送按钮点击事件,发送消息到服务端
        const sendButton = document.getElementById('send-button');
        const messageInput = document.getElementById('message-input');
        sendButton.addEventListener('click', function () {
            const message = messageInput.value;
            if (message) {
                // 发送 message 事件,携带输入的消息内容
                socket.emit('message', message);
                // 清空输入框
                messageInput.value = '';
            }
        });
    </script>
</body>

</html>

2. 前端核心功能说明

  • Socket.IO 连接:通过 io() 方法连接服务端地址 http://127.0.0.1:8080/,配置传输方式优先级和连接超时时间。
  • 连接成功处理:监听 connect 事件,连接成功后立即发送 join 事件,加入服务端的 chat 房间,确保能接收房间内的广播消息。
  • 消息接收与渲染:监听服务端的 message 事件,收到消息后创建 <p> 标签,将消息内容插入到页面的消息展示区域。
  • 消息发送:绑定按钮点击事件,获取输入框内容,通过 socket.emit('message', message) 发送到服务端,发送后清空输入框,提升交互体验。

四、项目运行与测试

1. 启动服务

  1. 将前端文件(index.htmlsocket.io-1.2.0.js 等)放入 static 目录;

  2. 在项目目录终端执行以下命令启动后端服务:

    bash

    运行

    go run main.go
    
  3. 服务启动成功后,终端会打印日志,监听端口为 8080

2. 测试步骤

  1. 打开多个浏览器窗口(或不同浏览器),访问 http://127.0.0.1:8080/
  2. 在任意一个窗口的输入框中输入消息,点击「发送」按钮;
  3. 观察其他窗口,会实时收到该消息,实现多客户端实时聊天功能;
  4. 查看后端终端,可看到客户端连接、加入房间、接收消息、断开连接等日志信息。

五、常见问题与优化建议

1. 常见问题排查

  • 前后端无法通信:大概率是 Socket.IO 客户端与服务端版本不兼容,建议客户端使用 1.x 或 2.x 版本,与 googollee/go-socket.io 保持协议兼容;
  • 跨域报错:检查后端跨域中间件配置,确保 Access-Control-Allow-Origin 配置正确,生产环境建议指定具体域名而非 *
  • 无法接收广播消息:确认前端已发送 join 事件加入 chat 房间,服务端广播时指定了正确的命名空间和房间名。

2. 优化建议

  • 性能优化:后端可调整 Socket.IO 传输方式优先级,优先使用 websocket;Gin 框架可自定义 http.Server 配置,优化 TCP 连接复用和并发处理能力;
  • 体验优化:前端可添加回车键发送消息、消息区分发送者与接收者、自动滚动到最新消息等功能;
  • 安全优化:生产环境中,跨域配置指定具体域名,添加身份验证(如 Token 验证),防止非法客户端连接;
  • 部署优化:可将静态资源部署到 CDN,提升前端加载速度;后端可使用进程管理工具(如 supervisor)保障服务稳定运行。

六、总结

本文通过 Gin 框架与 Socket.IO 的整合,实现了一套完整的前后端实时聊天系统,核心亮点如下:

  1. 后端完成了跨域处理、静态资源托管、Socket.IO 事件监听与房间广播;
  2. 前端实现了 Socket.IO 连接、房间加入、消息发送与接收渲染;
  3. 项目结构清晰,代码可直接复用,支持多客户端实时通信,可扩展为在线客服、实时通知等场景。

通过本文的实战,你不仅能掌握 Gin 与 Socket.IO 的使用方法,还能理解实时通信的核心原理,为后续复杂实时系统的开发打下坚实基础。

从 0 到 1:前端 CI/CD 实战(第二篇:用Docker 部署 GitLab)

前言

在完成云服务器的 Docker 环境搭建后,下一步就是部署整个 CI/CD 体系中最核心的组件 —— GitLab。本篇将继续通过 Docker 的方式,在云服务器上部署一套稳定、可维护的 GitLab 服务,涵盖容器运行、端口映射、数据持久化以及虚拟内存配置等关键步骤。完成本篇后,你将拥有一套真正可以长期使用的 GitLab 服务,为后续接入 CI/CD 流水线打下基础。

Docker Compose 简介

虽然可以直接使用 docker run 启动 GitLab,但实际操作中命令会非常冗长,而且端口、数据目录、环境变量等配置分散在命令行里,后期维护成本很高。为了解决这些问题,本文使用 Docker Compose 来统一管理容器。

Docker Compose 允许将一个或多个 docker run 命令的配置集中写入一个 YAML 文件,一次性定义镜像、端口映射、数据挂载、环境变量和重启策略。这样做有三个明显好处:

  • 配置集中:所有关键配置都在一个文件中,清晰可读
  • 易于维护:修改配置只需要改文件,不必反复敲命令
  • 可复现性强:换服务器或重建环境,只需一条命令即可恢复

常用命令如下:

# 启动服务(后台运行)
docker compose up -d

# 停止并删除容器
docker compose down

# 查看服务运行状态
docker compose ps

相比直接使用 docker run,Docker Compose 更适合长期运行的基础服务,也是实际生产环境中的常见选择。


GitLab 容器目录规划

规划宿主机目录

在部署 GitLab 之前提前规划宿主机目录,并不是为了“规范好看”,而是为了数据安全、后期维护和可迁移性。GitLab 属于典型的有状态服务,如果不将配置、日志和数据明确挂载到宿主机,一旦容器被删除或服务器重装,仓库和用户数据都会一起丢失。清晰的目录结构也是生产环境中的常见做法,后续无论是升级、迁移服务器,还是接入 GitLab Runner 和其他基础服务,都可以直接复用这套结构,一次规划,长期受益。

创建目录

本文将 GitLab 安装在宿主机的 /apps/infra/gitlab 目录下,用于存放 GitLab 的所有相关数据,并按 配置、日志、数据 三类进行拆分。

在服务器上执行以下命令创建目录:

mkdir -p /apps/infra/gitlab/{config,logs,data}

目录说明如下:

  • config:GitLab 核心配置文件目录(如 gitlab.rb)
  • logs:GitLab 运行日志目录,用于排查启动和运行问题
  • data:仓库、数据库、CI 产物等核心数据目录

后续将在 docker-compose.yml 中,将这三个目录分别挂载到容器内对应位置,实现数据持久化。


编写 docker-compose.yml

基础服务定义

在 docker-compose.yml 中,image、container_name 和 restart 是最基础但也最重要的配置。

  • image 使用 GitLab 官方社区版 gitlab/gitlab-ce,功能完整、社区成熟,对中小规模团队和学习环境已经完全足够
  • container_name 明确指定为 gitlab,避免 Docker 自动生成随机名称,方便后续查看状态和排查问题
  • restart: always 用于保证容器在异常退出或服务器重启后能够自动拉起,是长期运行服务的必选项

示例配置如下:

gitlab:
  image: gitlab/gitlab-ce:latest
  container_name: gitlab
  restart: always

端口映射设计思路

GitLab 对外主要提供三类访问能力:Web 页面、HTTPS 服务以及 Git SSH,因此端口映射需要提前规划。

  • 80:HTTP 访问 GitLab Web 页面
  • 443:预留 HTTPS 端口,后续接入证书时无需改配置
  • 2222:宿主机 SSH 端口,映射到容器内的 22

将 SSH 端口映射为 2222,一方面可以避免与宿主机自身 SSH 服务冲突,另一方面也能降低被自动化脚本扫描的概率。需要注意的是,端口映射修改后,GitLab 内部的 SSH 端口配置也必须同步修改,否则会导致 git clone 或 git push 失败。

示例配置如下:

ports:
  - "80:80"
  - "443:443"
  - "2222:22"

提醒:2222 端口默认未放行,需要在云服务器安全组中手动放行。

volumes 挂载与数据持久化

GitLab 是一个强依赖数据的有状态服务,数据持久化不是可选项,而是必选项。本文中将 GitLab 的数据按用途拆分为三类并分别挂载:

  • /etc/gitlab:核心配置目录
  • /var/log/gitlab:运行日志目录
  • /var/opt/gitlab:仓库、数据库和 CI 产物等核心数据

配置如下:

volumes:
  - /apps/infra/gitlab/config:/etc/gitlab
  - /apps/infra/gitlab/logs:/var/log/gitlab
  - /apps/infra/gitlab/data:/var/opt/gitlab

只要宿主机目录仍然存在,即使容器被删除,也可以通过重新启动容器快速恢复整套 GitLab 服务。

资源限制与性能取舍

默认情况下 Docker 不会限制容器的资源使用,而 GitLab 在启动和运行过程中会主动占用可用资源。如果不加限制,在 4G 或 8G 的服务器上很容易导致系统响应变慢甚至不可用。

本文中通过 deploy.resources.limits 对资源进行限制:

  • CPU:2.5 核
  • 内存:3200M

示例配置:

deploy:
  resources:
    limits:
      cpus: "2.5"
      memory: "3200M"

资源限制的目的不是压榨性能,而是保证服务器整体稳定性,这对学习环境和中小团队来说更加重要。

共享内存与稳定性

GitLab 内部包含数据库和缓存组件,对共享内存(shm)比较敏感。Docker 默认的共享内存较小,容易引发一些难以定位的异常问题,因此建议显式设置:

shm_size: '1gb'

配置并启动 GitLab 容器

在 /apps/infra/gitlab 目录下创建 docker-compose.yml 文件,并写入完整配置内容后,执行以下命令启动服务:

services:
  gitlab:
    image: gitlab/gitlab-ce:latest
    container_name: gitlab
    restart: always
    # 填写真实云服务器ip 地址
    hostname: "xxx.xxx.195.160"
    ports:
      - "80:80"
      - "443:443"
      - "2222:22"
    volumes:
      - /apps/infra/gitlab/config:/etc/gitlab
      - /apps/infra/gitlab/logs:/var/log/gitlab
      - /apps/infra/gitlab/data:/var/opt/gitlab
    deploy:
      resources:
        limits:
          cpus: "2.5"
          memory: "3200M"
    shm_size: '1gb'
    environment:
      GITLAB_OMNIBUS_CONFIG: |
        # 填写真实云服务器ip 地址
        external_url "http://xxx.xxx.195.160"
        gitlab_rails['gitlab_shell_ssh_port'] = 2222
        unicorn['worker_processes'] = 1
        sidekiq['concurrency'] = 2
        prometheus_monitoring['enable'] = false
        registry['enable'] = false
        node_exporter['enable'] = false
        gitlab_exporter['enable'] = false
        mattermost['enable'] = false

运行

docker compose up -d

首次启动会拉取镜像并初始化 GitLab,通常需要 3~5 分钟。完成后,在浏览器中访问:

http://<你的服务器 IP>

即可看到 GitLab 登录页面。

如果发现页面加载缓慢或服务器内存占用接近上限,这是 GitLab 初始化阶段的正常现象,下一节将通过配置虚拟内存进行优化。


配置虚拟内存(Swap)

在内存较小的服务器上,为 GitLab 配置 Swap 可以显著提升稳定性。

fallocate -l 8G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile

验证是否生效:

free -h

当出现 Swap: 8.0Gi 证明配置成功

设置开机自动挂载:

echo '/swapfile swap swap defaults 0 0' >> /etc/fstab

GitLab 初始化

确认容器运行状态:

docker ps

获取初始 root 密码:

docker exec -it gitlab grep 'Password:' /etc/gitlab/initial_root_password

成功登录后点击右上角头像 > Edit profile > Password 中修改初始密码


本篇小结

本篇完成了使用 Docker Compose 在云服务器上部署 GitLab 的全过程,包括目录规划、资源限制、数据持久化以及虚拟内存优化。通过这些配置,即使在低配服务器上,也可以稳定运行一套可长期使用的 GitLab 服务。

在下一篇中,我们将部署 GitLab Runner,把这套 GitLab 真正变成一条可以自动构建和发布的 CI/CD 流水线。

动态配色方案:在 Next.js 中实现 Shadcn UI 主题色切换

前言

Hi,大家好,我是白雾茫茫丶!

你是否厌倦了千篇一律的网站配色?想让你的 Next.js 应用拥有像 Figma 那样灵活的主题切换能力?在当今追求个性化和用户体验的时代,单一的配色方案早已无法满足用户多样化的审美需求。无论是适配品牌形象、响应节日氛围,还是提供用户自定义选项,动态主题色切换已成为现代 Web 应用的重要特性。

本文将带你深入探索如何在 Next.js 应用中实现专业级的主题色切换系统。我们将利用 Shadcn UI 的设计系统架构,结合 CSS 自定义属性(CSS Variables)的强大能力,打造一个不仅支持多套预设配色方案,还能保持代码优雅和性能高效的主题切换方案。无论你是想为用户提供“蓝色商务”、“绿色生态”还是“紫色创意”等不同视觉主题,这篇文章都将为你提供完整的实现路径。

告别单调,迎接多彩——让我们一起构建让用户眼前一亮的动态主题系统!

开发思路

我的实现思路主要基于 CSS 自定义属性(CSS Variables)。每套主题配色对应一组预定义的变量值,以独立的类型(或类名)标识。在切换主题时,只需为 <html> 根元素动态添加对应的类型类名,即可通过 CSS 变量的作用域机制,全局应用相应的配色方案,从而高效、无缝地完成主题切换。

主题构建工具

当然,要高效地实现基于 CSS 变量的动态主题系统,离不开一个强大的主题构建工具来生成和管理不同配色方案。在这里,我强烈推荐一款专为 shadcn/ui 打造的主题编辑与生成工具:

tweakcn.com/

TweakCN 不仅界面简洁直观,更深度集成了 shadcn/ui 的设计规范,支持实时预览、一键导出 Tailwind CSS 配置及 CSS 变量定义。你可以自由调整主色、辅助色、语义色(如成功、警告、错误等),并自动生成适配深色/浅色模式的完整配色方案。更重要的是,它输出的代码可直接用于 Next.js 项目,配合 CSS 变量策略,轻松实现主题切换——无需手动计算颜色值或反复调试样式,极大提升了开发效率与设计一致性。对于希望快速定制品牌化 UI 风格的开发者来说,TweakCN 无疑是一个强大而贴心的助手。

定义多套配色方案

1、在主题编辑页面,TweakCN 默认提供了 43 套精心设计的配色方案。你可以逐一浏览并实时预览每种方案在实际 UI 组件中的呈现效果。从中挑选几套符合项目风格或个人审美的配色,也可以基于现有方案进一步微调主色、辅助色或语义色,打造完全属于你自己的定制化主题。

202512/w0sy4ok3lelxx4fjx2tafw52lk1kunjr.gif

2、在确认主题配色后,点击右上角的 {} Code 按钮,点击 Copy 复制样式:

202512/ucjfomwfkpjmbuz9n82kgyx1u9ia8eym.png

3、新建一个 theme.css 文件,用来保存不同的主题配色:

:root {
  --background: oklch(1 0 0);
  --foreground: oklch(0.1450 0 0);
  --card: oklch(1 0 0);
  --card-foreground: oklch(0.1450 0 0);
  --popover: oklch(1 0 0);
  --popover-foreground: oklch(0.1450 0 0);
  --primary: oklch(0.2050 0 0);
  --primary-foreground: oklch(0.9850 0 0);
  --secondary: oklch(0.9700 0 0);
  --secondary-foreground: oklch(0.2050 0 0);
  --muted: oklch(0.9700 0 0);
  --muted-foreground: oklch(0.5560 0 0);
  --accent: oklch(0.9700 0 0);
  --accent-foreground: oklch(0.2050 0 0);
  --destructive: oklch(0.5770 0.2450 27.3250);
  --destructive-foreground: oklch(1 0 0);
  --border: oklch(0.9220 0 0);
  --input: oklch(0.9220 0 0);
  --ring: oklch(0.7080 0 0);
  --chart-1: oklch(0.646 0.222 41.116);
  --chart-2: oklch(0.6 0.118 184.704);
  --chart-3: oklch(0.398 0.07 227.392);
  --chart-4: oklch(0.828 0.189 84.429);
  --chart-5: oklch(0.769 0.188 70.08);
  --sidebar: oklch(0.9850 0 0);
  --sidebar-foreground: oklch(0.1450 0 0);
  --sidebar-primary: oklch(0.2050 0 0);
  --sidebar-primary-foreground: oklch(0.9850 0 0);
  --sidebar-accent: oklch(92.2% 0 0);
  --sidebar-accent-foreground: oklch(0.2050 0 0);
  --sidebar-border: oklch(0.9220 0 0);
  --sidebar-ring: oklch(0.7080 0 0);
  --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
  --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  --radius: 0.625rem;
  --shadow-x: 0;
  --shadow-y: 1px;
  --shadow-blur: 3px;
  --shadow-spread: 0px;
  --shadow-opacity: 0.1;
  --shadow-color: oklch(0 0 0);
  --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
  --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
  --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
  --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
  --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
  --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
  --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
  --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
  --tracking-normal: 0em;
  --spacing: 0.25rem;
}

.dark {
  --background: oklch(0.1450 0 0);
  --foreground: oklch(0.9850 0 0);
  --card: oklch(0.2050 0 0);
  --card-foreground: oklch(0.9850 0 0);
  --popover: oklch(0.2690 0 0);
  --popover-foreground: oklch(0.9850 0 0);
  --primary: oklch(0.9220 0 0);
  --primary-foreground: oklch(0.2050 0 0);
  --secondary: oklch(0.2690 0 0);
  --secondary-foreground: oklch(0.9850 0 0);
  --muted: oklch(0.2690 0 0);
  --muted-foreground: oklch(0.7080 0 0);
  --accent: oklch(0.3710 0 0);
  --accent-foreground: oklch(0.9850 0 0);
  --destructive: oklch(0.7040 0.1910 22.2160);
  --destructive-foreground: oklch(0.9850 0 0);
  --border: oklch(0.2750 0 0);
  --input: oklch(0.3250 0 0);
  --ring: oklch(0.5560 0 0);
  --chart-1: oklch(0.488 0.243 264.376);
  --chart-2: oklch(0.696 0.17 162.48);
  --chart-3: oklch(0.769 0.188 70.08);
  --chart-4: oklch(0.627 0.265 303.9);
  --chart-5: oklch(0.645 0.246 16.439);
  --sidebar: oklch(0.2050 0 0);
  --sidebar-foreground: oklch(0.9850 0 0);
  --sidebar-primary: oklch(0.4880 0.2430 264.3760);
  --sidebar-primary-foreground: oklch(0.9850 0 0);
  --sidebar-accent: oklch(0.2690 0 0);
  --sidebar-accent-foreground: oklch(0.9850 0 0);
  --sidebar-border: oklch(0.2750 0 0);
  --sidebar-ring: oklch(0.4390 0 0);
  --font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
  --font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
  --radius: 0.625rem;
  --shadow-x: 0;
  --shadow-y: 1px;
  --shadow-blur: 3px;
  --shadow-spread: 0px;
  --shadow-opacity: 0.1;
  --shadow-color: oklch(0 0 0);
  --shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
  --shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
  --shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
  --shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
  --shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
  --shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
  --shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
  --shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
}

4、这样我们就默认一个主题,如果是多套配色,我们可以加上主题类名区分,例如:

/* Amber Minimal */
:root.theme-amber-minimal{
}
.dark.theme-amber-minimal{
}

/* Amethyst Haze */
:root.theme-amethyst-haze{
}
.dark.theme-amethyst-haze {
}

5、然后把 theme.css 导入到全局样式中,Next.js 项目一般是 global.css

@import "./themes.css";

到这里,我们的准备工作就算完成了,接下来我们就完成主题色的切换逻辑!

具体实现

1、这里我们需要用到 zustand 来保存主题色的状态:

pnpm add zustand

2、创建主题配色枚举:

    /**
     * @description: 主题色
     */
    export const THEME_PRIMARY_COLOR = Enum({
      DEFAULT: { value: 'default', label: 'Default', color: 'oklch(0.205 0 0)' },
      AMBER_MINIMAL: { value: 'amber-minimal', label: 'Amber', color: 'oklch(0.7686 0.1647 70.0804)' },
      AMETHYST_HAZE: { value: 'amethyst-haze', label: 'Amethyst', color: 'oklch(0.6104 0.0767 299.7335)' },
      CANDYLAND: { value: 'candyland', label: 'Candyland', color: 'oklch(0.8677 0.0735 7.0855)' },
      DARKMATTER: { value: 'darkmatter', label: 'Darkmatter', color: 'oklch(0.6716 0.1368 48.5130)' },
      ELEGANT_LUXURY: { value: 'elegant-luxury', label: 'Elegant', color: 'oklch(0.4650 0.1470 24.9381)' },
      SAGE_GARDEN: { value: 'sage-garden', label: 'Garden', color: 'oklch(0.6333 0.0309 154.9039)' },
      SUPABASE: { value: 'supabase', label: 'Supabase', color: 'oklch(0.8348 0.1302 160.9080)' },
      TWITTER: { value: 'twitter', label: 'Twitter', color: 'oklch(0.6723 0.1606 244.9955)' },
    });

3、新建 store/useAppStore.ts 文件:

    'use client'
    import { create } from 'zustand'
    import { createJSONStorage, persist } from 'zustand/middleware'

    import { THEME_PRIMARY_COLOR } from '@/enums';
    import { initializePrimaryColor } from '@/lib/utils';

    type AppState = {
      primaryColor: typeof THEME_PRIMARY_COLOR.valueType; // 主题色
      setPrimaryColor: (color: typeof THEME_PRIMARY_COLOR.valueType) => void; // 设置主题色
    }

    export const useAppStore = create(
      persist<AppState>(
        (set) => ({
          primaryColor: THEME_PRIMARY_COLOR.DEFAULT, // 默认主题色
          setPrimaryColor: (color) => {
            set({ primaryColor: color })
            initializePrimaryColor(color);
          }
        }),
        {
          name: 'app-theme', // 用于存储在 localStorage 中的键名
          storage: createJSONStorage(() => localStorage)// 指定使用 localStorage 存储
        }))

4、创建主题色初始化函数:

    /**
     * @description: 初始化主题色
     * @param {typeof} color
     */
    export const initializePrimaryColor = (color: typeof THEME_PRIMARY_COLOR.valueType) => {
      if (typeof document !== 'undefined') {
        // 清空 theme- 开头的类名
        const html = document.documentElement;
        Array.from(html.classList)
          .filter((className) => className.startsWith("theme-"))
          .forEach((className) => {
            html.classList.remove(className)
          })
        // 如果不是默认主题色,则添加对应的类名
        if (color !== THEME_PRIMARY_COLOR.DEFAULT) {
          html.classList.add(`theme-${color}`);
        }
      }
    }

5、创建主题切换按钮:

    import { type FC, useCallback } from "react";

    import { getClipKeyframes } from '@/components/animate-ui/primitives/effects/theme-toggler';
    import { Button } from '@/components/ui';
    import { THEME_PRIMARY_COLOR } from '@/enums';
    import { useAppStore } from '@/store/useAppStore';

    const PrimaryColorPicker: FC = () => {
      const primaryColor = useAppStore((s) => s.primaryColor);
      const setPrimaryColor = useAppStore((s) => s.setPrimaryColor);
      const themeModeDirection = useAppStore((s) => s.themeModeDirection);

      const [fromClip, toClip] = getClipKeyframes(themeModeDirection);

      // 点击颜色切换
      const onChangeColor = useCallback(async (color: typeof THEME_PRIMARY_COLOR.valueType) => {
        if (primaryColor === color) {
          return;
        }
        if ((!document.startViewTransition)) {
          setPrimaryColor(color);
          return;
        }
        await document.startViewTransition(async () => {
          setPrimaryColor(color);
        }).ready;
        document.documentElement
          .animate(
            { clipPath: [fromClip, toClip] },
            {
              duration: 700,
              easing: 'ease-in-out',
              pseudoElement: '::view-transition-new(root)',
            },
          )
      }, [primaryColor, setPrimaryColor, fromClip, toClip])
      return (
        <>
          <div className="grid grid-cols-3 gap-2">
            {THEME_PRIMARY_COLOR.items.map(({ value, label, raw }) => (
              <Button
                size="sm"
                aria-label="PrimaryColorPicker"
                variant={primaryColor === value ? "secondary" : "outline"}
                key={value}
                className="text-xs justify-start"
                onClick={() => onChangeColor(value)}
              >
                <span className="inline-block size-2 rounded-full"
                  style={{ backgroundColor: raw.color }} />
                {label}
              </Button>
            ))}
          </div>
          <style>{`::view-transition-old(root), ::view-transition-new(root){animation:none;mix-blend-mode:normal;}`}</style>
        </>
      )
    }
    export default PrimaryColorPicker;

这里我加了切换过渡动画,不需要的可以自行去掉!

6、页面刷新的时候需要同步,在 Provider.tsx 中初始化:

    import { initializePrimaryColor } from '@/lib/utils';
    const primaryColor = useAppStore((s) => s.primaryColor);

    // 初始化主题色
    useEffect(() => {
      if (primaryColor) {
        initializePrimaryColor(primaryColor);
      }
    }, [primaryColor])

效果预览

202512/fzqxxw5cxexwp2kqeklcevk6o2dwulqi.gif

总结

实现动态主题配色的方式多种多样——从 CSS-in-JS、Tailwind 的 class 切换,到运行时注入样式表等,各有优劣。本文分享的是基于 CSS 自定义属性(CSS Variables)HTML 根元素类名切换 的轻量级方案,配合 TweakCN 这样的可视化工具,能够快速构建出结构清晰、易于维护的主题系统。当然,这仅是我个人在项目中的一种实践思路,如果你有更优雅、更高效的实现方式,欢迎在评论区留言交流!技术因分享而进步,期待看到你的创意方案 🌈。

线上预览:next.baiwumm.com

Github 地址:github.com/baiwumm/nex…

历史性突破!LCP 和 INP 终于覆盖所有主流浏览器,iOS 性能盲点彻底消失

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

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

随着 Safari 26.2 在 12 月 12 日的发布,Web 性能领域迎来了一个令人振奋的年终礼物:最大内容绘制(LCP)和交互到下次绘制(INP)现已正式成为 Baseline 新可用功能。所有主流浏览器的最新版本现在都包含了测量这些指标所需的最大内容绘制 API 和事件计时 API。这是 Interop 2025 项目的一部分,很高兴看到这些功能在今年成功交付!

这意味着什么

核心 Web 指标(Core Web Vitals)已成为衡量网页体验的广泛采用标准,无论是对于 Web 开发者还是业务利益相关者而言都是如此。它们试图将复杂的 Web 性能故事总结为几个关键指标:页面加载速度(LCP)、交互响应速度(INP)以及内容稳定性(CLS)。

长期以来,这些指标只能在基于 Chromium 的浏览器(如 Chrome 和 Edge)中测量。在 iOS 设备上,由于所有浏览器都使用驱动 Safari 的 WebKit 浏览器引擎,这些指标完全不可用。这造成了一个盲点:网站可能不知道大量访问者正在经历完全不同的体验。虽然许多 Web 性能改进确实使所有浏览器受益,但某些技术和 API 仅在部分浏览器中可用。此外,浏览器内部的工作方式、页面加载方式以及处理交互的方式可能彼此不同。仅拥有网站性能的部分视图远非理想状态。

随着所有主流浏览器现在都支持这两个指标,我们现在可以更好地了解网站的关键加载和交互性能。这将使网站所有者能够更好地理解性能问题并识别可以进行的改进,最终使用户和业务指标受益。

其他浏览器的数据会进入 CrUX 吗?

不会。Chrome 用户体验报告(CrUX)仅基于符合条件的 Chrome 用户,这一点不会改变。这也适用于使用此数据的下游系统,如 PageSpeed Insights、Google Search Console 和 CrUX Vis。

这也将继续排除 Chrome iOS 用户,因为他们使用 WebKit 浏览器引擎。

如何从其他浏览器测量

CrUX 数据仍然作为网站性能的摘要很有用,并且可以与网络上的其他网站进行基准测试。然而,由于它是一个高级摘要,我们长期以来一直建议测量更详细的真实用户数据(field data)以帮助识别和改进性能。

真实用户监控(RUM)工具现在能够收集额外的真实用户数据,包括通过 Chrome 团队的 web-vitals 库测量的数据。在大多数情况下,这应该自动开始包含在您现有的解决方案中,但如果您有任何问题,请与您的 RUM 提供商确认。

请注意,RUM 和 CrUX 之间可能存在差异,现在这些指标在更多不包括在 CrUX 中的浏览器中可用,这种差异可能更加明显。

实现方式有什么不同吗?

虽然所有浏览器引擎在加载和显示网页方面大致执行相同的任务,但这些浏览器的构建方式存在许多差异,特别是在它们的渲染管道中,这些管道将网站的代码(主要是 HTML、CSS 和 JavaScript)转换为屏幕上的像素。

渲染循环的结束大致是可互操作的,被定义为 paintTime。然而,在这之后,有一个稍后的 presentationTime,这是特定于实现的,旨在指示像素实际绘制到屏幕上的时间。Chrome 测量 LCP 直到 presentationTime 结束,而 Firefox 和 Safari 不包括 presentationTime,因此测量到更早的 paintTime。这导致测量结果之间存在几毫秒的差异。从 Chrome 145 开始,paintTime 测量也将为 LCP 公开,以便那些希望能够在浏览器之间进行同类比较的人使用。

同样的差异也适用于 INP。

其他浏览器实现这些指标的事实,有助于识别一些需要澄清和更好定义的未解决问题。这再次可能导致轻微差异——尽管这些主要出现在边缘情况中。这就是拥有多个实现和关注 API 的好处!我们将继续致力于这些以及指标的任何其他改进。

然而,尽管存在这些小的差异,我们确信 LCP 和 INP 大致是可互操作的,因此我们很高兴它们被标记为 Baseline 新可用功能。那些实现 RUM 解决方案或深入研究数据的人可能会注意到其中一些差异,但 Web 开发者应该对跨浏览器测量这些指标充满信心,尽管存在这些微小差异。

不支持这些 API 的浏览器怎么办?

Baseline 新可用功能仅在所有主流浏览器的最新版本中可用。您的用户群可能不会立即升级,或者可能无法升级,这取决于他们的操作系统和提供商。30 个月后,它们将被视为 Baseline 广泛可用,因为大多数用户可能会使用支持这些功能的浏览器。

然而,作为测量 API 而不是网站的核心功能,您可以安全地为支持这些功能的浏览器测量这些指标——就像您到目前为止可能一直在做的那样。只需注意,您可能正在看到过滤后的用户视图——那些已升级的用户——特别是在最初的几个月里。

累积布局偏移(CLS)呢?

第三个核心 Web 指标是累积布局偏移(CLS),它不是 Interop 2025 项目的一部分——尽管它已被提议用于 Interop 2026。目前,除了基于 Chromium 的浏览器之外,它不受支持。

结论

Web Vitals 计划的目标是通过为 Web 平台创建一套标准 API 来改善 Web 性能,使关键指标能够被测量并被网站所有者广泛理解。很高兴看到这些指标中的两个现在得到了所有主流浏览器的支持。我们期待看到这些指标为网站所有者提供什么见解,以及这如何带来更好的用户体验!

参考来源: web.dev - LCP and INP are now Baseline Newly available

深入理解 AbortSignal:前端异步操作取消的原生方案

在前端开发中,异步操作(如网络请求、定时器、文件读取)的“取消需求”十分常见——比如用户切换页面时取消未完成的 fetch 请求、关闭弹窗时终止定时器、表单提交后取消之前的验证任务。AbortSignal 是 HTML5 原生提供的异步操作取消信号机制,配合 AbortController 使用,能统一、优雅地实现各类异步任务的取消,避免内存泄漏或无效操作浪费资源。

本文将从基础用法、核心特性、常用场景到避坑指南,全面拆解 AbortSignal 的实用价值。

一、AbortSignal 是什么?

AbortSignalAbortController 接口的核心组成部分,本质是一个“取消信号载体”:

  • 它由 AbortController 生成,用于关联一个或多个异步操作;
  • 当调用 AbortController.abort() 时,对应的 AbortSignal 会被标记为“已取消”,所有关联该信号的异步操作会收到通知并终止;
  • 原生支持 fetchReadableStreamTimer(部分场景)等异步 API,也可用于自定义异步函数的取消逻辑。

核心关系:AbortController ↔ AbortSignal

AbortSignal 不能单独使用,必须与 AbortController 配套:

  • AbortController:负责“触发取消”的控制器(相当于“开关”);
  • AbortSignal:负责“传递取消信号”的载体(相当于“电线”);
  • 一个控制器可以生成一个信号,一个信号可以关联多个异步操作(一键取消所有关联任务)。

二、基本用法(3 步上手)

AbortSignal 的使用流程极其简洁,核心是“创建控制器 → 关联信号 → 触发取消”,以下是最基础的示例:

1. 基础流程:取消 fetch 请求

// 1. 创建 AbortController 实例(控制器)
const controller = new AbortController();
// 2. 获取关联的 AbortSignal(信号)
const signal = controller.signal;

// 3. 异步操作关联信号(fetch 原生支持 signal 参数)
fetch('https://api.example.com/data', { signal })
  .then(res => res.json())
  .then(data => console.log('请求成功:', data))
  .catch(err => {
    // 取消时会抛出 AbortError,需捕获
    if (err.name === 'AbortError') {
      console.log('请求被手动取消');
    } else {
      console.error('请求失败:', err);
    }
  });

// 4. 触发取消(比如用户点击“取消”按钮)
document.getElementById('cancelBtn').addEventListener('click', () => {
  controller.abort(); // 调用 abort() 后,signal 会标记为“已取消”
});

2. 核心 API 说明

相关对象/API 作用
new AbortController() 创建控制器实例,用于生成信号和触发取消
controller.signal 获取与控制器绑定的 AbortSignal 实例(信号载体)
controller.abort(reason) 触发取消:标记 signalaborted: true,可选传递取消原因(reason)
signal.aborted 只读属性:返回布尔值,表示信号是否已触发取消(true = 已取消)
signal.addEventListener('abort', () => {}) 监听信号的“取消事件”(自定义异步操作时需用到)
signal.reason 只读属性:获取 abort(reason) 传递的取消原因(默认 undefined

三、核心特性与进阶用法

1. 信号的“一次性”特性

AbortSignal 一旦被触发(controller.abort() 调用后),状态会永久变为 aborted: true,无法重置。若需再次取消异步操作,必须重新创建 AbortController 和信号

// 错误示例:信号触发后复用
const controller = new AbortController();
const signal = controller.signal;

controller.abort(); // 首次取消,signal.aborted = true
console.log(signal.aborted); // true

// 再次调用 abort() 无效,信号状态不会改变
controller.abort('再次取消');
console.log(signal.reason); // undefined(仍为第一次的默认原因)

// 正确做法:重新创建控制器和信号
const newController = new AbortController();
newController.abort('新的取消原因');
console.log(newController.signal.aborted); // true
console.log(newController.signal.reason); // "新的取消原因"

2. 传递取消原因

调用 abort() 时可传递任意类型的“取消原因”(字符串、对象等),通过 signal.reason 读取,便于调试或业务逻辑处理:

const controller = new AbortController();
const signal = controller.signal;

// 监听取消事件,读取原因
signal.addEventListener('abort', () => {
  console.log('取消原因:', signal.reason); // { code: 400, msg: '用户主动取消' }
});

// 传递对象类型的取消原因
controller.abort({ code: 400, msg: '用户主动取消' });

3. 信号合并(AbortSignal.any()

AbortSignal.any() 可将多个信号合并为一个“复合信号”,任意一个原始信号触发取消,复合信号就会触发取消,适用于“多个条件触发取消”的场景(如“超时 + 用户手动取消”):

// 场景:同时支持“超时取消”和“用户手动取消”
const userController = new AbortController(); // 用户手动取消控制器
const timeoutController = new AbortController(); // 超时取消控制器

// 合并两个信号:任意一个触发,复合信号就触发
const combinedSignal = AbortSignal.any([
  userController.signal,
  timeoutController.signal
]);

// 5 秒后自动触发超时取消
setTimeout(() => {
  timeoutController.abort('请求超时(5秒)');
}, 5000);

// 关联复合信号到 fetch
fetch('https://api.example.com/slow-data', { signal: combinedSignal })
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('取消原因:', combinedSignal.reason);
      // 可能输出:"请求超时(5秒)" 或 "用户手动取消"
    }
  });

// 用户点击按钮触发手动取消
document.getElementById('cancelBtn').addEventListener('click', () => {
  userController.abort('用户手动取消');
});

4. 预定义信号(AbortSignal.timeout()

AbortSignal.timeout(ms) 是 ES2022 新增的静态方法,可直接创建一个“超时自动取消”的信号,无需手动创建 AbortController,简化超时取消逻辑:

// 场景:3 秒后自动取消 fetch 请求
const timeoutSignal = AbortSignal.timeout(3000); // 3 秒超时

fetch('https://api.example.com/slow-data', { signal: timeoutSignal })
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('请求超时被取消'); // 3 秒后触发
    }
  });

四、AbortSignal 的常用场景

AbortSignal 几乎可用于所有异步操作的取消,以下是实际开发中最高频的场景:

1. 取消 fetch/axios 请求

这是最常见的场景,用于中断未完成的网络请求(如用户切换页面、搜索框输入防抖时取消之前的请求):

// 搜索框防抖:输入时取消之前的请求
let controller;

document.getElementById('searchInput').addEventListener('input', async (e) => {
  const keyword = e.target.value.trim();
  if (!keyword) return;

  // 取消上一次未完成的请求
  if (controller) controller.abort();

  // 新建控制器和信号
  controller = new AbortController();
  const signal = controller.signal;

  try {
    const res = await fetch(`/api/search?keyword=${keyword}`, { signal });
    const data = await res.json();
    console.log('搜索结果:', data);
  } catch (err) {
    if (err.name !== 'AbortError') {
      console.error('搜索失败:', err);
    }
  }
});

2. 取消定时器/延时任务

原生 setTimeout/setInterval 不直接支持 AbortSignal,但可通过监听信号的 abort 事件实现取消:

// 场景:取消一个 3 秒后执行的定时器
const controller = new AbortController();
const signal = controller.signal;

const timer = setTimeout(() => {
  console.log('定时器执行');
}, 3000);

// 监听信号取消,清理定时器
signal.addEventListener('abort', () => {
  clearTimeout(timer);
  console.log('定时器被取消');
});

// 触发取消(比如 1 秒后取消)
setTimeout(() => {
  controller.abort();
}, 1000);

3. 自定义异步操作取消

对于自定义的异步函数(如文件读取、数据处理),可通过监听 signal.abort 事件实现取消,并清理资源:

// 自定义异步函数:模拟耗时数据处理
function processData(data, { signal }) {
  return new Promise((resolve, reject) => {
    // 若信号已取消,直接拒绝
    if (signal.aborted) {
      return reject(new DOMException('操作已取消', 'AbortError'));
    }

    // 监听取消事件:清理资源并拒绝 Promise
    const abortHandler = () => {
      // 清理耗时操作的资源(如终止计算、关闭文件流)
      console.log('清理资源,取消数据处理');
      reject(new DOMException(signal.reason || '操作已取消', 'AbortError'));
    };
    signal.addEventListener('abort', abortHandler, { once: true }); // once: true 避免重复触发

    // 模拟耗时处理(2 秒)
    setTimeout(() => {
      const result = data.map(item => item * 2);
      signal.removeEventListener('abort', abortHandler); // 处理完成,移除监听
      resolve(result);
    }, 2000);
  });
}

// 使用自定义函数并取消
const controller = new AbortController();
processData([1, 2, 3], { signal: controller.signal })
  .then(res => console.log('处理结果:', res))
  .catch(err => {
    if (err.name === 'AbortError') {
      console.log('数据处理被取消:', err.message);
    }
  });

// 1 秒后取消
setTimeout(() => {
  controller.abort('用户终止处理');
}, 1000);

4. 批量取消多个异步任务

一个 AbortSignal 可关联多个异步操作,调用 abort() 时会一次性取消所有关联任务,适用于“批量清理”场景(如页面卸载时取消所有未完成的请求和定时器):

// 场景:页面卸载时取消所有异步任务
const globalController = new AbortController();
const globalSignal = globalController.signal;

// 关联任务 1:fetch 请求
fetch('/api/data1', { signal: globalSignal });
// 关联任务 2:fetch 请求
fetch('/api/data2', { signal: globalSignal });
// 关联任务 3:自定义异步函数
processData([4, 5, 6], { signal: globalSignal });

// 页面卸载时,批量取消所有任务
window.addEventListener('beforeunload', () => {
  globalController.abort('页面卸载,取消所有异步任务');
});

五、注意事项与避坑指南

1. 信号一旦触发,无法复用

如前文所述,AbortSignal 是“一次性”的,触发 abort() 后状态永久为 aborted: true。若需再次执行异步操作,必须重新创建 AbortController

// 正确做法:每次异步操作都创建新的控制器
function fetchData(url) {
  const controller = new AbortController();
  const signal = controller.signal;
  const request = fetch(url, { signal });
  // 返回请求和取消方法
  return { request, cancel: () => controller.abort() };
}

// 第一次请求
const { request: req1, cancel: cancel1 } = fetchData('/api/1');
// 取消第一次请求
cancel1();

// 第二次请求(新的控制器和信号)
const { request: req2, cancel: cancel2 } = fetchData('/api/2');

2. fetch 取消会抛出 AbortError,必须捕获

fetch 接收 signal 后,若信号触发取消,会立即抛出 AbortError(属于 DOMException),若不捕获会导致控制台报错:

// 正确示例:捕获 AbortError
fetch('/api/data', { signal })
  .catch(err => {
    if (err.name === 'AbortError') {
      // 预期的取消,无需处理
    } else {
      // 其他错误(网络错误、404 等),需要处理
      console.error('请求失败:', err);
    }
  });

3. 自定义异步函数需手动清理资源

原生 API(如 fetch)已内置 AbortSignal 支持,但自定义异步函数需手动监听 abort 事件,清理资源(如定时器、文件流、计算任务)并拒绝 Promise,否则可能导致内存泄漏。

4. 兼容性

现代浏览器(Chrome 66+、Firefox 60+、Safari 12.1+、Edge 79+)均支持 AbortSignalAbortController,IE 完全不支持。若需兼容老旧浏览器,可使用 abortcontroller-polyfill 进行兼容。

六、总结

AbortSignal 是前端异步取消的“标准解决方案”,核心价值在于:

  • 统一API:无需为不同异步操作设计单独的取消逻辑;
  • 原生支持:直接兼容 fetch 等原生异步 API,无需额外封装;
  • 高效可靠:信号触发后立即中断异步操作,避免无效资源消耗;
  • 灵活扩展:支持信号合并、传递取消原因,满足复杂场景需求。

在网络请求防抖、页面卸载清理、异步任务超时控制等场景中,AbortSignal 能大幅简化代码,提升应用性能和用户体验,是现代前端开发中不可或缺的原生 API。

告别“千里传荔枝”:React useContext 打造跨层级通信“任意门”

在 React 的开发江湖里,组件通信是绕不开的基本功。父传子用 Props,子传父用回调,这都没问题。但一旦项目稍微复杂一点,你就会遇到一个经典痛点:Props Drilling(属性钻取)

想象一下,你作为“皇上”(顶层组件 App)想吃一口新鲜的荔枝(数据),但这荔枝得从岭南(底层组件)一路运到长安。中间隔着千山万水(Page, Header, Content...),每一层组件都得像驿站一样接手这个荔枝,再传给下一站。

古人云:“一骑红尘妃子笑,无人知是荔枝来。” 程序员云:“一层一层传 Props,改个需求火葬场。”

这种“千里传荔枝”的模式性价比极低,中间的组件明明不需要这个数据,却被迫持有并传递它。今天,我们就来聊聊 React 官方提供的“虫洞”技术 —— Context API,以及如何用 useContext 优雅地解决跨层级通信难题。


一、 为什么我们需要 Context?

在 React 的设计哲学里,数据流是单向的(Top-Down)。通常情况下,外层组件(父组件)持有并管理复杂数据,拥有“规矩”。

  • 没有 Context 时:数据必须通过 Props 就像接力棒一样,父 -> 子 -> 孙 -> 曾孙。只要链路断了一环,数据就丢了。
  • 有了 Context 后:我们相当于建立了一个全局广播塔。数据被放在一个查找上下文中(Context),任何层级的组件,只要它需要,就可以直接向 Context 申请:“把数据给我”,而不需要中间商赚差价。

核心思想转变:

  • Props:被动接收。父给什么,子接什么。
  • Context:主动消费。组件拥有了“找数据”的能力,按需索取。

二、不仅是传值:Context 的三板斧

使用 Context 其实非常简单,只需要记住三个步骤:创建(Create)、提供(Provide)、消费(Consume)

我们先用你提供的 UserContext 例子来走一遍流程。

1. 创建容器 (Create)

首先,我们需要一个容器来装这些共享的数据。

JavaScript

// App.js
import { createContext } from 'react';

// 创建 Context 对象
// null 是默认值,只有当组件在树中找不到匹配的 Provider 时,才会使用这个默认值
export const UserContext = createContext(null);

2. 提供数据 (Provide)

在组件树的顶层(或者任何你希望开始共享数据的层级),使用 Provider 组件把数据“广播”出去。

JavaScript

// App.js
import Page from './views/Page';

export default function App() {
    // 这是我们要共享的数据,皇上的荔枝
    const user = {
        name: "Andrew",
        role: "Admin"
    }

    return (
        // UserContext.Provider 就是数据提供者
        // value 属性决定了下层组件能拿到什么
        <UserContext.Provider value={user}>
            <Page />
        </UserContext.Provider>
    )
}

注意,这里的 <Page /> 组件根本不需要知道 user 是什么,它只需要安静地做一个容器。

JavaScript

// Page.js
import Header from '../components/Header';
// Page 组件完全解耦,不接收任何 user 相关的 props
export default function Page() {
  return (
    <Header />
  )
}

// Header.js
import UserInfo from './UserInfo';
// Header 同样不需要关心 user
export default function Header() {
    return (
        <UserInfo />
    )
}

3. 消费数据 (Consume)

到了最底层的 UserInfo,我们要用 useContext 这个 Hook 像变魔术一样把数据取出来。

JavaScript

// UserInfo.js
import { useContext } from 'react';
import { UserContext } from '../App'; // 引入刚才创建的 Context

export default function UserInfo() {
    // 核心代码:一句话拿到 value
    const user = useContext(UserContext);
    
    console.log(user); // output: { name: "Andrew", role: "Admin" }

    return (
        <div className="user-card">
            <p>Name: {user.name}</p>
        </div>
    )
}

看,是不是清爽多了? 中间的 PageHeader 组件完全从数据传递中解脱了出来,代码耦合度大大降低。


三、 进阶实战:封装 ThemeProvider(动态 Context)

上面的例子是静态数据,但在真实业务中,我们通常需要传递状态(State)以及修改状态的方法

最经典的场景就是 深色模式(Dark Mode)切换。我们要构建一个 ThemeProvider,它不仅提供当前的主题,还提供一个 toggleTheme 方法给子组件调用。

1. 封装 Context 逻辑

为了保持 App.js 的整洁,我们通常会将 Context 的逻辑抽离到一个单独的文件中。这是一个非常好的工程化习惯

JavaScript

// contexts/ThemeContext.js
import { createContext, useState, useEffect } from "react";

// 创建 Context
export const ThemeContext = createContext(null);

// 创建一个独立的 Provider 组件
// 这里的 children 是 React 也就是被包裹的子组件
export default function ThemeProvider({ children }) {
    const [theme, setTheme] = useState('light');

    // 切换逻辑
    const toggleTheme = () => {
        setTheme((t) => t === 'light' ? 'dark' : 'light');
    }

    // 副作用处理:同步到 DOM
    // 这是一个非常妙的操作,利用 data-* 属性配合 CSS 变量
    useEffect(() => {
        // document.documentElement 获取的是 <html> 标签
        document.documentElement.setAttribute('data-theme', theme);
    }, [theme]);

    return (
        // value 中同时包含 数据(theme) 和 方法(toggleTheme)
        <ThemeContext.Provider value={{ theme, toggleTheme }}>
            {children}
        </ThemeContext.Provider>
    )
}

2. 在入口处包裹

JavaScript

// App.js
import ThemeProvider from "./contexts/ThemeContext";
import Page from "./pages/Page";

export default function App(){
  return(
    // 只要被 ThemeProvider 包裹,里面的所有组件都能访问到主题
    <ThemeProvider>
       <Page/>
    </ThemeProvider>
  )
}

3. 在任意深处消费

比如在 Header 中切换主题,在 Content 中展示主题样式。

Header (控制者):

JavaScript

// components/Header.js
import { useContext } from "react";
import { ThemeContext } from "../contexts/ThemeContext";

export default function Header(){
    // 解构出 toggleTheme 方法
    const { theme, toggleTheme } = useContext(ThemeContext);
    
    return (
        <header style={{ padding: '10px', borderBottom: '1px solid #ccc' }}>
            <span>当前模式:{theme === 'light' ? '🌞' : '🌙'}</span>
            <button onClick={toggleTheme} style={{ marginLeft: '10px' }}>
                切换主题
            </button>
        </header>
    )
}

Content (消费者):

JavaScript

// components/Content.js
import { useContext } from "react";
import { ThemeContext } from "../contexts/ThemeContext";

export default function Content() {
    const { theme } = useContext(ThemeContext);
    
    // 根据 theme 调整样式
    const styles = {
        padding: 24, 
        // 实际项目中建议配合 CSS Variables,这里为了演示直接写内联样式
        backgroundColor: theme === 'light' ? '#f0f0f0' : '#2a2a2a', 
        color: theme === 'light' ? '#000' : '#fff',
        borderRadius: 8,
        transition: 'all 0.3s ease'
    };

    return (
        <div style={styles}>
            <h3>内容区域</h3>
            <p>这是一个使用 React Context API 实现的主题切换示例。</p>
        </div>
    )
}

四、 避坑指南与最佳实践

虽然 useContext 很香,但也不要贪杯(滥用)。

  1. 性能陷阱: Context 的机制是:只要 Provider 的 value 发生变化,所有消费该 Context 的组件都会强制重新渲染

    • 坏习惯value={{ theme, toggleTheme }}。如果 Provider 组件自身重渲染(例如父组件更新),这个对象就是新的引用,会导致下层所有消费者重渲染。
    • 优化:配合 useMemo 缓存 value 对象。
  2. 上下文地狱: 不要为了避免 Props Drilling 就把所有数据都塞进 Context。如果你的组件树顶层包了十几个 Provider (UserProvider, ThemeProvider, LanguageProvider, AuthProvider...),代码维护性也会变差。

  3. 组件复用性: 一旦组件使用了 useContext,它就依赖了特定的环境。如果你想复用 UserInfo 组件到一个没有 UserContext 的地方,它就会报错或失效。对于简单的父子通信,Props 依然是首选


五、 总结

React 的 useContext 是解决跨层级通信的一把利剑。

  • 它像一个虫洞,打通了组件层级的壁垒。
  • 它让数据流向更加清晰,避免了中间组件的冗余代码。
  • 配合 useStateuseEffect,我们可以轻松实现全局的状态管理(如主题、用户信息、多语言)。

下次当你发现自己在写 props={props.something} 超过两层时,停下来想一想: “是不是该给长安送个荔枝虫洞了?”

关于vue2中使用el-table进行跨页选择并回显编辑的功能实现

简要

业务场景中,经常会出现跨页选择的需求。在vue2中,el-table自带有reserve-selection属性,再加上指定row-key属性,就可以实现基本的功能。

上述方法仅能实现单向,即跨页选中并保存的业务。

但有时候会出现,在编辑中根据id数组,回显出之前选择的行数据,并且支持再次编辑。

解决方案

核心为对列表数据的重新选择

  // 重新选中当前页,已选中的选项
    setSelectedRows(){
      // 使用 Set 来存储 selectedRows 中的 id,以提高查找速度
      const selectedIds = new Set(this.selectedRows.map(row => row.id))
      // 遍历 tableList 并根据 selectedIds 设置选择状态
      this.tableList.forEach(item => {
        if (selectedIds.has(item.id)) {
          this.$nextTick(() => {
            this.$refs.table.toggleRowSelection(item, true);
          });
        }
      });
    },

对表格的选中操作要分成三种方法处理

// 选择数据
    selectOne(val, row) {
      // 如果 row.id 存在于 this.selectedRows 但不在 val.id 中,则从 this.selectedRows 中删除这一项
      if (this.selectedRows.some(selectedRow => selectedRow.id === row.id) && !val.some(selectedVal => selectedVal.id === row.id)) {
        this.selectedRows = this.selectedRows.filter(selectedRow => selectedRow.id !== row.id)
      }
    },
    // 表格全选操作
    selectAll(val, row) {
      if (val.length === 0) {
        // 此时为取消当页全选
        this.tableList.map(item => {
          this.selectedRows = this.selectedRows.filter(selectedRow => selectedRow.id !== item.id)
        })
      }
    },
    handleSelectionChange(val) {
      // 先合并 val 和 this.selectedRows
      const combinedRows = [...this.selectedRows, ...val]
      // 使用 Map 来存储 dvpId 和对应的 val,这样可以自动去重
      const map = new Map()
      combinedRows.forEach(item => {
        // 如果 dvpId 已经存在,则不覆盖
        if (!map.has(item.id)) {
          map.set(item.id, item)
        }
      })
      // 将 Map 转换回数组
      this.selectedRows = Array.from(map.values())
    }

以上仅为个人业务使用理解,如有优化或问题,欢迎探讨。

ant design vue Table根据数据合并单元格

之前在外包做项目的时候,甲方提出一个想要合并单元格的需求,表格里展示的内容是领导们的一周行程,因为不想出现重复内容的单元格,实际场景中领导可能连续几天参加某个会议或者某个其他行程,本来 系统中对会议时间冲突是做了限制,也就是不能创建时间冲突的会议,那么对重复行程的单元格直接进行合并是没有问题的;但是后来又放开了限制、又允许存在会议时间冲突的情况了,因为实际中可能存在连续几天的大会行程中,又安排了几个小会,所以在后续的沟通中确定的方案是:单独的连续行程进行合并,如果中间出现多个行程就不合并,如果单独的长行程还没结束,后面连续的排期还是合并。最终的效果参考下图中的“会议111”。

根据表格的数据合并单元格,因为用的是ant design vue这个UI库,所以我第一时间想的就是去翻文档,查到的用法如下:

可是把这段代码写到项目里并没有生效,才发现最新已经是"ant-design-vue": "^4.2.6",而项目里用的版本是"ant-design-vue": "^1.6.3",看懵了🤧🤧🤧,查了之后才发现这个版本的使用方法是这样的:

于是我就按着这么写:

结果发现rowSpan的设置不管用,在网上搜索了一番,又自己试了几次,发现加上style的设置才实现了合并单元格。

很烦接手这种项目,总是用一套模板开发新项目,永远不更新三方库,大量公司的“降本增效”以后这种情况会越来越多吧,反正当下能用就行,以后维护不了了再去考虑更新三方库不知道会爆出什么问题呢😅

具体的单元格是否合并就是按照业务逻辑来判断了。在这个项目里,每日行程的原始数据结构类似如下,就是把每个领导本周内的行程给查询出来。

{
    staff1: [
      {
        event: '会议111',
        startTime: '2025-01-01 9:00',
        endTime: '2025-01-04 18: 00'
      },
      {
        event: '这是一个测试会议22',
        startTime: '2025-01-02 13:00',
        endTime: '2025-01-02 16: 00'
      },
      {
        event: '这是一个测试会议33',
        startTime: '2025-01-05 09:00',
        endTime: '2025-01-05 17: 00'
      }
    ],
    staff2: [
      {
        event: '会议q',
        startTime: '2025-01-01 9:00',
        endTime: '2025-01-01 18: 00'
      },
      {
        event: '这是一个测试会议ww',
        startTime: '2025-01-02 13:00',
        endTime: '2025-01-07 16: 00'
      },
    ],
    staff3: [
      {
        event: '待办事项x',
        startTime: '2025-01-01 9:00',
        endTime: '2025-01-01 18: 00'
      },
      {
        event: '这是一个待办事项ww',
        startTime: '2025-01-05 13:00',
        endTime: '2025-01-07 16: 00'
      },
    ]
 }

后端会做简单的处理,把日程按单日分组,返回给前端的数据结构类似如下(项目里原本是week0~week6,本文简单演示就直接使用日期了):

{
    staff1: {
      '2025-01-01': [
        {
          event: '会议111',
          startTime: '2025-01-01 9:00',
          endTime: '2025-01-04 18: 00'
        },
      ],
      '2025-01-02': [
        {
          event: '会议111',
          startTime: '2025-01-01 9:00',
          endTime: '2025-01-04 18: 00'
        },
        {
          event: '这是一个测试会议22',
          startTime: '2025-01-02 13:00',
          endTime: '2025-01-02 16: 00'
        },
      ],
      '2025-01-03': [
        {
          event: '会议111',
          startTime: '2025-01-01 9:00',
          endTime: '2025-01-04 18: 00'
        },
      ],
      '2025-01-04': [
        {
          event: '会议111',
          startTime: '2025-01-01 9:00',
          endTime: '2025-01-04 18: 00'
        },
      ],
      '2025-01-05': [
        {
          event: '这是一个测试会议33',
          startTime: '2025-01-05 09:00',
          endTime: '2025-01-05 17: 00'
        }
      ],
      '2025-01-06': [],
      '2025-01-07': [],
    },
    staff2: {
      '2025-01-01': [
        {
          event: '会议q',
          startTime: '2025-01-01 9:00',
          endTime: '2025-01-01 18: 00'
        },
      ],
      '2025-01-02': [
        {
          event: '这是一个测试会议ww',
          startTime: '2025-01-02 13:00',
          endTime: '2025-01-07 16: 00'
        },
      ],
      '2025-01-03': [
        {
          event: '这是一个测试会议ww',
          startTime: '2025-01-02 13:00',
          endTime: '2025-01-07 16: 00'
        },
      ],
      '2025-01-04': [
        {
          event: '这是一个测试会议ww',
          startTime: '2025-01-02 13:00',
          endTime: '2025-01-07 16: 00'
        },
      ],
      '2025-01-05': [
        {
          event: '这是一个测试会议ww',
          startTime: '2025-01-02 13:00',
          endTime: '2025-01-07 16: 00'
        },
      ],
      '2025-01-06': [
        {
          event: '这是一个测试会议ww',
          startTime: '2025-01-02 13:00',
          endTime: '2025-01-07 16: 00'
        },
      ],
      '2025-01-07': [
        {
          event: '这是一个测试会议ww',
          startTime: '2025-01-02 13:00',
          endTime: '2025-01-07 16: 00'
        },
      ],
    },
    staff3: {
      '2025-01-01': [
        {
          event: '待办事项x',
          startTime: '2025-01-01 9:00',
          endTime: '2025-01-01 18: 00'
        },
      ],
      '2025-01-02': [],
      '2025-01-03': [],
      '2025-01-04': [],
      '2025-01-05': [
        {
          event: '这是一个待办事项ww',
          startTime: '2025-01-05 13:00',
          endTime: '2025-01-07 16: 00'
        },
      ],
      '2025-01-06': [
        {
          event: '这是一个待办事项ww',
          startTime: '2025-01-05 13:00',
          endTime: '2025-01-07 16: 00'
        },
      ],
      '2025-01-07': [
        {
          event: '这是一个待办事项ww',
          startTime: '2025-01-05 13:00',
          endTime: '2025-01-07 16: 00'
        },
      ],
    },
}

前端就在以上的结构基础上进行遍历处理。

第一步准备工作,先简单判断当前处理的行程是否在一天内结束,并且判断是否跨时段(上下午),把这个两个判断结果存储起来用于后续操作。

const inOneDay =
    moment(schedule.endTime).format('YYYY-MM-DD') ===
    moment(schedule.startTime).format('YYYY-MM-DD') // 是否在一天内完成(开始日期和结束日期一致)
let inOneRange = false // 是否在同个时段(上下午),判断一天内的日程是否跨时段
if (inOneDay) {
  const startMorning = moment(schedule.startTime).isSameOrBefore(
      weekData[weekIndex].dateStr + ' ' + MORNING_END
  )
  const endAfternoon = moment(schedule.endTime).isSameOrAfter(
      weekData[weekIndex].dateStr + ' ' + AFTERNOON_START
  )
  if ((startMorning && !endAfternoon) || (!startMorning && endAfternoon)) inOneRange = true
}

第二步就在第一步的基础上先做第一轮简单的筛选,如果满足以下条件之一,则当前处理的行程不用跨行处理。

  1. 当前行程所在时段存在多个行程
  2. 当前行程本身不跨时段
  3. 当前行程跨上下午时段,当前处理的是下午,但是上午存在多个行程
if (
      weekData[weekIndex][account].length > 1 || // 当前员工单个时段有多个行程
      (inOneDay && inOneRange) || // 某行程不跨时段
      (inOneDay &&
          !inOneRange &&
          weekIndex % 2 === 1 &&
          weekData[weekIndex - 1][account].length > 1) // 当前行程跨上下午时段,当前处理的是下午,但是上午存在多个行程
  ) {
    // 不做跨行处理
    result.isCross = false
    return result
}

第三步做第二轮筛选,首先做两个判断并保存判断结果。

  1. 当前是否为跨行的开始行

    // 判断是否是跨行的开始(满足条件之一):
    // 1. 行程的开始日期等于当前行的日期,行程的开始时间晚于等于当前行的startTime
    // 2. 行程的开始日期等于当前行的日期,行程的结束日期晚于当前行的日期
    // 3. 行程的开始日期早于当前行的日期,且前一行的行程数量大于1
    // 4. 当前行程在第一行
    const isStart =
        (scheduleStartDate === weekData[weekIndex].dateStr &&
            scheduleStartTime >= weekData[weekIndex].startTime) ||
        (weekIndex % 2 === 1 &&
            weekData[weekIndex - 1][account].length > 1 &&
            scheduleStartDate === weekData[weekIndex].dateStr &&
            scheduleEndDate >= weekData[weekIndex].dateStr) ||
        (scheduleStartDate < weekData[weekIndex].dateStr &&
            weekIndex > 0 &&
            weekData[weekIndex - 1][account].length > 1) ||
        weekIndex === 0
    
  2. 当前是否为跨行的结束行

    // 判断是否是跨行的结束(满足条件之一):
    // 1. 当前行程在最后一行
    // 2. 行程的结束日期等于当前行的日期,行程的结束时间晚于当前行的startTime且早于等于当前行的endTime
    // 3. 下一行的日程数量大于1
    const isEnd =
        weekIndex === 13 ||
        (scheduleEndDate === weekData[weekIndex].dateStr &&
            scheduleEndTime >= weekData[weekIndex].startTime &&
            scheduleEndTime <= weekData[weekIndex].endTime) ||
        weekData[weekIndex + 1][account].length > 1
    

如果两个判断结果都为true,则说明既是开始行,同是又是结束行,那就不用做跨行处理。

最后筛出来的就是要跨行的单元格了,就要计算跨的行数了,也就是起始行的rowSpan值,非起始行的rowSpan就是0了。

起始行的rowSpan就是计算具体这个行程在表格里跨的行数。

首先计算单个行程自身原本跨了几个时段。

const diffScheduleEnd = moment(scheduleEndDate).diff(
    moment(weekData[weekIndex].dateStr),
    'days'
) // 与行程结束日期的天数差值
const diffWeekEnd = moment(weekData[13].dateStr).diff(
    moment(weekData[weekIndex].dateStr),
    'days'
) // 与周最后一天的天数差值
const dayOff = Math.min(diffScheduleEnd, diffWeekEnd) // 跨的天数
const timeOff = scheduleEndTime <= MORNING_END ? 1 : 2 // 跨的时段
let offRows = 0
// 行位移 = (天数-1)*2 + 跨的时段
if (dayOff > 0) offRows = (dayOff - 1) * 2 + timeOff
// 如果当前行程是上午开始的,再加一个行跨
if (weekIndex % 2 === 0) offRows++

再向后遍历碰到存在多个行程的单元格就表示跨行结束,得到了rowSpan的值。

const len = weekIndex + 1 + offRows
let rowSpan = 1
for (let i = weekIndex + 1; i < len; i++) {
  if (weekData[i][account].length > 1) {
    break
  } else {
    rowSpan++
  }
}

最后我们就可以得到合并的单元格。

Vant4图片懒加载源码解析(一)

组件简介

Lazyload懒加载,当页面需要加载大量内容时,使用懒加载可以实现延迟加载页面可视区域外的内容,从而使页面加载更流程。

如何使用

Lazyload是Vue指令,使用前需要对指令进行注册

1. 注册指令

import { createApp } from 'vue'; 
import { Lazyload } from 'vant'; 
const app = createApp(); 
app.use(Lazyload); // 注册时可以配置额外的选项 app.use(Lazyload, { lazyComponent: true, });

2. 将v-lazy指令的值设置为需要懒加载的图片

<img v-for="img in imageList" v-lazy="img" />

3. 背景图片懒加载,要使用v-lazy:background-image

<div v-for="img in imageList" v-lazy:background-image="img" />

4. 组件懒加载,将需要懒加载的组件放在lazy-component标签中

// 注意注册时设置“lazyComponent”选项
app.use(Lazyload, { lazyComponent: true, });

<lazy-component>
  <img v-for="img in imageList" v-lazy="img" />
</lazy-component>

源码解析

1. vue-lazyload/index.js

index.js是lazyload组件的入口文件,该js导出一个Lazyload对象,Lazyload对象会声明一个install方法, install函数是组件注册的必用函数,这里不再赘述。

import Lazy from './lazy';
import LazyComponent from './lazy-component';
import LazyContainer from './lazy-container';
import LazyImage from './lazy-image';

export const Lazyload = {
  /*
   * install function
   * @param  {App} app
   * @param  {object} options lazyload options
   */
  install(app, options = {}) {
    const LazyClass = Lazy();
    // 创建了一个laze实例
    const lazy = new LazyClass(options);
    // 创建了一个lazeContainer实例
    const lazyContainer = new LazyContainer({ lazy });
    
    // 将laze实例放到vue实例的global属性上
    app.config.globalProperties.$Lazyload = lazy;
    
    // 如果options中存在lazyComponent, 注册LazyComponent组件,这里是我们上文“如何使用第4点”强调使用组件的话需要增加传参lazyComponent: true了
    if (options.lazyComponent) {
      app.component('LazyComponent', LazyComponent(lazy));
    }
    // 根据传参lazeImage注册LazyImage组件
    if (options.lazyImage) {
      app.component('LazyImage', LazyImage(lazy));
    }
    
    // 注册指令lazy, 我们就知道为啥指令是v-lazy了啦
    app.directive('lazy', {
      beforeMount: lazy.add.bind(lazy),
      updated: lazy.update.bind(lazy),
      unmounted: lazy.remove.bind(lazy),
    });
    // 注册指令lazy-container
    app.directive('lazy-container', {
      beforeMount: lazyContainer.bind.bind(lazyContainer),
      updated: lazyContainer.update.bind(lazyContainer),
      unmounted: lazyContainer.unbind.bind(lazyContainer),
    });
  },
};

2. vue-lazyload/lazy.js

lazy.js导入了很多工具函数,导出一个匿名函数,外部执行这个匿名函数,会return一个类class Lazy。
函数可以将内部逻辑和变量放到当前函数做作用域中,避免外包访问和修改。

2.1 constructor构造函数

constuctor函数初始化一些配置和函数

import { nextTick } from 'vue';
import { inBrowser, getScrollParent } from '@vant/use';
import {
  remove,
  on,
  off,
  throttle,
  supportWebp,
  getDPR,
  getBestSelectionFromSrcset,
  hasIntersectionObserver,
  modeType,
  ImageCache,
} from './util';
import { isObject } from '../../utils';
import ReactiveListener from './listener';

const DEFAULT_URL =
  'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
const DEFAULT_EVENTS = [
  'scroll',
  'wheel',
  'mousewheel',
  'resize',
  'animationend',
  'transitionend',
  'touchmove',
];
const DEFAULT_OBSERVER_OPTIONS = {
  rootMargin: '0px',
  threshold: 0,
};

export default function () {
  return class Lazy {
    constructor({
      preLoad,
      error,
      throttleWait,
      preLoadTop,
      dispatchEvent,
      loading,
      attempt,
      silent = true,
      scale,
      listenEvents,
      filter,
      adapter,
      observer,
      observerOptions,
    }) {
      this.mode = modeType.event;
      this.listeners = [];
      this.targetIndex = 0;
      this.targets = [];
      this.options = {
        silent,
        dispatchEvent: !!dispatchEvent,
        throttleWait: throttleWait || 200,
        preLoad: preLoad || 1.3,
        preLoadTop: preLoadTop || 0,
        error: error || DEFAULT_URL,
        loading: loading || DEFAULT_URL,
        attempt: attempt || 3,
        scale: scale || getDPR(scale),
        ListenEvents: listenEvents || DEFAULT_EVENTS,
        supportWebp: supportWebp(),
        filter: filter || {},
        adapter: adapter || {},
        observer: !!observer,
        observerOptions: observerOptions || DEFAULT_OBSERVER_OPTIONS,
      };
      this.initEvent();
      this.imageCache = new ImageCache({ max: 200 });
      this.lazyLoadHandler = throttle(
        this.lazyLoadHandler.bind(this),
        this.options.throttleWait,
      );

      this.setMode(this.options.observer ? modeType.observer : modeType.event);
    }
    ...
    }
 }

2.2 remove & on & off

  • remove是移除数组中的元素

  • on和off是通过addEventListener绑定和解绑函数

  • 为什么addEventListener要设置capture和passive呢?
    capture: false
    表示监听器在 事件冒泡阶段 触发(这是最常用的模式)

    passive: true
    明确告诉浏览器:此监听器绝不会调用 event.preventDefault()
    浏览器因此可以立即执行默认行为(比如滚动、缩放),无需等待 JS 执行完毕,从而提升流畅度。

// 移除数组中的元素
export function remove(arr, item) {
  if (!arr.length) return;
  const index = arr.indexOf(item);
  if (index > -1) return arr.splice(index, 1);
}
// 绑定事件
export function on(el, type, func) {
  el.addEventListener(type, func, {
    capture: false, // 默认在**冒泡阶段**触发事件
    passive: true, // 明确告诉浏览器:**此监听器绝不会调用 `event.preventDefault()`**
  });
}
// 解绑事件
export function off(el, type, func) {
  // 第三个参数是一个布尔值,指定需要移除的事件监听器是否为捕获监听器,默认false
  el.removeEventListener(type, func, false);
}

2.3 throttle节流函数

  • 第一次立即执行, lastRun = 0, Date.now() - lastRun是一个很大的数,肯定比delay大,所以第一次会立即执行
  • 第二次注入一个定时器,等待delay秒后执行,此时timeout有值且是一个数字,
  • 等待delay秒后执行runCallback函数,timeout被清空,lastRun被重新赋值为当前时间
  • 事件在时间间隔为delay秒内触发,就会继续注入setTimeout,等待执行。。。如此一直循环
  • 只要当没有setTimeout回调事件之后,最后会在elapsed >= delay条件下再执行一次runCallback
export function throttle(action, delay) {
  let timeout = null;
  let lastRun = 0;
  return function (...args) {
    if (timeout) {
      return;
    }
    const elapsed = Date.now() - lastRun;
    const runCallback = () => {
      lastRun = Date.now();
      timeout = false;
      action.apply(this, args);
    };
        // 第一次立即执行
    if (elapsed >= delay) {
      runCallback();
    } else {
    // 第二次注入一个定时器
      timeout = setTimeout(runCallback, delay);
    }
  };
}

2.4 supportWebp & getDPR

函数 作用 典型用途
supportWebp() 检测是否支持 WebP 图片格式 优先加载 WebP,节省带宽、提升加载速度
getDPR(scale) 获取设备像素比(DPR) ,获取当前设备的物理像素与 CSS 像素的比率(DPR) 为高清屏加载高分辨率图片,避免模糊
  • inBrowser是判断条件是typeof window是否存在值
    const inBrowser = 'undefined' != typeof window;
  • canvas.toDataURL('image/webp') 如果浏览器支持WebP格式的图片,就会返回'data:image/webp'...图片base64格式的字符串
    如果不支持,就会降级为PNG, 返回"data:image/png..."
    哇哦,知识面又拓宽了~
export function supportWebp() {
// 不是browser, 直接return
  if (!inBrowser) return false;

  let support = true;

  try {
    const elem = document.createElement('canvas');
    // 检测canvas是否可用
    if (elem.getContext && elem.getContext('2d')) {
      // 尝试将canvas导出为WebP格式的数据URL
      support = elem.toDataURL('image/webp').indexOf('data:image/webp') === 0;
    }
  } catch (err) {
    support = false;
  }

  return support;
}

export const getDPR = (scale = 1) =>
  inBrowser ? window.devicePixelRatio || scale : scale;

2.5 getBestSelectionFromSrcset

这个函数 getBestSelectionFromSrcset 的作用是:

根据图片容器的缩放后宽度(考虑设备像素比),从 <img> 元素的 data-srcset 属性中智能选择最合适的一张图片源(URL) ,优先选择 WebP 格式,并适配高清屏。

export function getBestSelectionFromSrcset(el, scale) {
  // 判断是img标签 且 存在data-srcset属性
  if (el.tagName !== 'IMG' || !el.getAttribute('data-srcset')) return;
  
  // 注意不是标准 `srcset`,是自定义属性data-srcset
  let options = el.getAttribute('data-srcset');
  const container = el.parentNode;
  // 计算父容器的宽度,考虑缩放比例
  const containerWidth = container.offsetWidth * scale;

  let spaceIndex;
  let tmpSrc;
  let tmpWidth;
   
  // 标准的srcset格式image-400.jpg 400w, image-800.webp 800w, image-1200.jpg 1200w
  options = options.trim().split(',');

  const result = options.map((item) => {
    item = item.trim();
    spaceIndex = item.lastIndexOf(' ');
    if (spaceIndex === -1) {
      tmpSrc = item;
      tmpWidth = 999998;// 无宽度描述,视为“兜底图”
    } else {
      tmpSrc = item.substr(0, spaceIndex);
    // 提取 "800w" → 去掉末尾 'w'(通过 -2 截断)
      tmpWidth = parseInt(
        item.substr(spaceIndex + 1, item.length - spaceIndex - 2),
        10,
      );
    }
    return [tmpWidth, tmpSrc];
  });
   // 从大到小排序
  result.sort((a, b) => {
    if (a[0] < b[0]) { // a宽度小,放后面
      return 1;
    }
    if (a[0] > b[0]) { // a宽度大,放前面
      return -1;
    }
    if (a[0] === b[0]) {
   // 宽度相同:WebP 优先
      if (b[1].indexOf('.webp', b[1].length - 5) !== -1) {
        return 1;
      }
      if (a[1].indexOf('.webp', a[1].length - 5) !== -1) {
        return -1;
      }
    }
    return 0;
  });
  let bestSelectedSrc = '';
  let tmpOption;
/** 排序完成后的数据大概如下,目的是找到最契合容器宽度的图片src后返回
[
  [1200, 'img-1200.webp'],
  [800,  'img-800.jpg'],
  [400,  'img-400.jpg']
] **/

  for (let i = 0; i < result.length; i++) {
    tmpOption = result[i];
    bestSelectedSrc = tmpOption[1];
    const next = result[i + 1];
    // 如果下一个图片的宽度小于父级容器的宽度,则获取当前值
    if (next && next[0] < containerWidth) {
      bestSelectedSrc = tmpOption[1];
      break;
    } else if (!next) {
      bestSelectedSrc = tmpOption[1];
      break;
    }
  }
  // 返回最合适的图片
  return bestSelectedSrc;
}

2.6 hasIntersectionObserver & ImageCache

  • hasIntersectionObserver 是判断浏览器是否支持IntersectionObserver方法
    IntersectionObserver 提供了一种异步观察目标元素与其祖先元素或顶级文档[视口]交叉状态的方法。其祖先元素或视口被称为根。 通俗的讲就是某个目标元素出现在屏幕范围内,就能通过ntersectionObserver捕获到这个元素,从而对这个元素做一些操作。我们这里使用的目的是图片element元素出现在屏幕可视范围才加载图片的资源。

  • 如何封装一个Cache类?
    this.imageCache = new ImageCache({ max: 200 });

判断浏览器是否支持IntersectionObserver 方法
export const hasIntersectionObserver =
  inBrowser &&
  'IntersectionObserver' in window &&
  'IntersectionObserverEntry' in window &&
  'intersectionRatio' in window.IntersectionObserverEntry.prototype;

// 图片cache类
export class ImageCache {
  constructor({ max }) {
    this.options = {
      max: max || 100,
    };
    this.caches = [];
  }

  has(key) {
    return this.caches.indexOf(key) > -1;
  }

  add(key) {
    if (this.has(key)) return;
    this.caches.push(key);
    if (this.caches.length > this.options.max) {
      this.free();
    }
  }

  free() {
    this.caches.shift();
  }
}

2.7 initEvent

这个 initEvent() 函数的作用是:在当前类实例(如 Lazy)上初始化一个简易的自定义事件系统(发布-订阅模式) ,提供 $on$once$off$emit 等方法,用于组件内部或外部监听和触发特定事件(如图片加载中、加载完成、加载失败等

    initEvent() {
      this.Event = {
        listeners: {
          loading: [],
          loaded: [],
          error: [],
        },
      };
        
      // 绑定事件
      this.$on = (event, func) => {
        if (!this.Event.listeners[event]) this.Event.listeners[event] = [];
        this.Event.listeners[event].push(func);
      };
      // 绑定一次性事件
      this.$once = (event, func) => {
        const on = (...args) => {
          // 先调用$off解绑事件
          this.$off(event, on);
          // 执行回调方法
          func.apply(this, args);
        };
        this.$on(event, on);
      };
      // 解绑事件
      this.$off = (event, func) => {
        // 如果不存在func
        if (!func) {
          // 如果event类名在Event没有注册过,直接return
          if (!this.Event.listeners[event]) return;
          // 注册过就直接清空数组中数据
          this.Event.listeners[event].length = 0;
          return;
        }
        // 存在func回调函数则从数组中移除
        remove(this.Event.listeners[event], func);
      };
      // 触发注册过的event事件
      this.$emit = (event, context, inCache) => {
        if (!this.Event.listeners[event]) return;
        this.Event.listeners[event].forEach((func) => func(context, inCache));
      };
    }

2.7 lazyLoadHandler

遍历所有被监听的元素(如图片),检查它们是否进入视口(可见区域),如果是,则触发加载;同时清理已从 DOM 中移除的无效监听器,防止内存泄漏。具体的listener是数据结构是什么样的,我们下篇讲。

    lazyLoadHandler() {
      const freeList = [];
      this.listeners.forEach((listener) => {
        // 没有目标元素的监听器,放入待释放数组中
        if (!listener.el || !listener.el.parentNode) {
          freeList.push(listener);
        }
        // 检查图片是否已经进入了视口区域,如果是,则加载图片
        const catIn = listener.checkInView();
        if (!catIn) return;
        listener.load();
      });
      // 释放无用的监听器,防止内存泄漏
      freeList.forEach((item) => {
        remove(this.listeners, item);
        item.$destroy();
      });
    }

总结

  • 本文深入解析了 LazyLoad 中常用的一些工具函数,涵盖了如何基于发布-订阅模式实现一个轻量级的事件收集与触发机制,回顾了节流(throttle)、addEventListener 的高级参数(如 passive 和 capture)等核心概念,并探讨了 WebP 图片格式在性能优化中的重要意义。
  • 我们梳理了源码中的关键知识点,下篇再见~

Commit 阶段的 3 个子阶段与副作用执行全解析

一、前言

Commit 阶段是 React 更新流程的最后一环,用于把 Render 阶段计算出的变更与副作用原子化地应用到宿主环境(DOM),确保界面一致性与不出现中间态。

二、React 全流程视角下的 Commit 阶段

2.1 React 渲染全流程回顾

7-1.png

  1. 触发:来自事件、setStatedispatchstartTransition、异步回调等,进入调度。
  2. 调度:为更新分配车道(Lane),选择下一批工作(getNextLanes),安排回调。
  3. Render(可中断):自顶向下构建/复用 Fiber,Diff 得出宿主层变更与副作用列表;不会触碰 DOM。
  4. Commit(不可中断):原子化应用 DOM 变更,执行 layout 类副作用与 ref,保证可见更新的一致性。
  5. 绘制(Paint):浏览器将提交的变更绘制到屏幕。
  6. 被动阶段(Passive):在一个独立宏任务中异步冲洗 useEffect(HookPassive)的清理与安装,避免阻塞提交与布局。

2.2 Commit 阶段的核心目标

  1. 原子应用宿主层变更:在一次不可中断的关键段内完成所有 DOM 插入/删除/属性更新,避免“半提交”导致的视觉撕裂。
  2. 正确的副作用时序:useInsertionEffect 在变更前、useLayoutEffect 清理于变更期/安装于布局期、类组件 DidMount/DidUpdate 与 ref 绑定于布局期。
  3. 界面一致性与可预期的读写:布局读/写必须发生在稳定的 DOM 状态上,确保测量与同步操作正确。
  4. 与调度器的配合:提交期间不让步、不给调度器插入中断,确保一个提交完成后再进入绘制与被动阶段。
  5. 产出后续异步工作:将 useEffect 的清理与安装延迟到绘制后,尽量降低对交互的阻塞。
// 提交阶段的顺序(简化伪代码)
commitBeforeMutationEffects(root, finishedWork, lanes); // 变更前处理,如焦点/过渡
commitMutationEffects(root, finishedWork, lanes); // 应用 DOM 变更
commitLayoutEffects(root, finishedWork, lanes); // ref、类组件、layout effect 安装
// 绘制发生在上述之后;随后进入被动阶段:
flushPassiveEffects(); // useEffect 的清理和安装(异步宏任务)

三、Commit 阶段源码关键函数解析

3.1 入口函数:commitRoot

3.1.1 角色定位:

提交阶段的总调度器;将 Render 阶段完成的变更与副作用在宿主环境原子化应用。

3.1.2 核心特征:

  1. 不可中断:提交路径置位 CommitContext,不调用 shouldYield
  2. 明确分期:严格依序执行 Before Mutation → Mutation → Layout。
  3. 提交后异步:被动副作用(useEffect)在一个后续宏任务中冲洗,避免阻塞交互。
  4. 关键职责(串联三子阶段并安排 Passive):
  • 冲刷潜在残留的被动效果,确保提交干净。
  • 进入提交上下文并短期提升更新优先级到离散事件级别。
  • 在 Mutation 结束后切换当前树:root.current = finishedWork
  • 调度并在绘制后冲洗被动副作用,设置合适的更新优先级。

3.1.3 核心实现逻辑

function commitRoot(root, finishedWork, lanes /* ... */) {
  // 先冲刷可能残留的被动效果,确保提交干净
  do {
    flushPendingEffects();
  } while (pendingEffectsStatus !== NO_PENDING_EFFECTS);

  // 设置提交阶段的性能标记与校验
  // ...

  // 安排被动效果(Passive)后续冲洗(必要时)
  if (
    (finishedWork.subtreeFlags & PassiveMask) !== NoFlags ||
    (finishedWork.flags & PassiveMask) !== NoFlags
  ) {
    // 常规:调度一个普通优先级任务执行 flushPassiveEffects
    root.callbackNode = null;
    root.callbackPriority = NoLane;
    scheduleCallback(NormalSchedulerPriority, () => {
      flushPassiveEffects(true);
      return null;
    });
  } else {
    root.callbackNode = null;
    root.callbackPriority = NoLane;
  }

  // Before Mutation 子阶段(提交上下文 + 提升优先级)
  if (hasBeforeMutationEffects) {
    const prev = getCurrentUpdatePriority();
    setCurrentUpdatePriority(DiscreteEventPriority); // 提交路径短期提升为离散优先级
    const prevCtx = executionContext;
    executionContext |= CommitContext; // 进入提交上下文
    try {
      commitBeforeMutationEffects(root, finishedWork, lanes); // DOM 读前置与快照
    } finally {
      executionContext = prevCtx;
      setCurrentUpdatePriority(prev);
    }
  }

  if (!startedViewTransition) {
    flushMutationEffects(); // 执行 DOM 增删改
    flushLayoutEffects(); // 执行 useLayoutEffect、更新 ref 等
    flushSpawnedWork(); // 提交后异步执行 flushPassiveEffects
  }
}

3.1.4 示例:一次“点击打开弹层”的完整提交与副作用时序

以下示例展示一次离散事件(click)触发的更新如何穿越三个子阶段,以及绘制后的被动副作用如何执行与设定优先级。

function DialogExample() {
  const [open, setOpen] = useState(false);
  const dialogRef = useRef(null);

  // Layout 阶段执行:此处可安全测量并同步读写 DOM
  useLayoutEffect(() => {
    if (open && dialogRef.current) {
      // 运行于 Layout 子阶段:DOM 已变更且稳定
      const rect = dialogRef.current.getBoundingClientRect(); // 同步测量
      // 例如根据尺寸定位弹层
      dialogRef.current.style.top = `${rect.bottom + 8}px`;
    }
    return () => {
      // 下次提交时,Layout 清理发生在 Mutation 之前(避免脏读)
      // 可在这里撤销同步副作用(如绑定的同步事件等)
    };
  }, [open]);

  // Passive 阶段执行:绘制后再执行,避免阻塞提交/布局
  useEffect(() => {
    if (!open) return;
    // 示例:给窗口添加滚动监听,滚动时关闭弹层
    const onScroll = () => {
      // 注意:此处 setState 的更新优先级由 Passive 阶段设定:
      // priority = lowerEventPriority(DefaultEventPriority, lanesToEventPriority(pendingEffectsLanes))
      // 通常为 Default;如果本次渲染包含 Idle 等更低优先级,效果更新也会更低
      setOpen(false);
    };
    window.addEventListener("scroll", onScroll);
    return () => window.removeEventListener("scroll", onScroll); // Passive 清理
  }, [open]);

  return (
    <div>
      <button
        onClick={() => {
          setOpen(true); // 离散事件入口(click):触发一次高优先级更新
        }}
      >
        打开弹层
      </button>

      {open && (
        <div ref={dialogRef}>
          弹层内容(Layout 效果会在提交后立刻测量并定位)
        </div>
      )}
    </div>
  );
}

时间线解读:

  • Before Mutation:读取现有焦点/选择,准备快照与过渡信息;不触碰 DOM。
  • Mutation:插入弹层节点、属性写入、可能的宿主层重置;随后 root.current = finishedWork
  • Layout:执行 useLayoutEffect 清理与安装、类组件 DidMount/DidUpdateref 绑定;支持同步测量。
  • Paint:浏览器将变更绘制到屏幕(用户现在看到弹层)。
  • Passive:宏任务中冲洗 useEffect 清理与安装,此处绑定滚动监听;若滚动触发 setOpen(false),该更新优先级不会高于 DefaultEventPriority,并可能更低(比如渲染包含 Idle 车道时)。

3.1.5 commit 流程图

7-2.png

3.2 子阶段一:Before Mutation

简单说,这个阶段的核心是:在 DOM 发生任何增删改之前,先完成 “读状态” 和 “做准备” 的工作—— 比如记录 DOM 快照、处理焦点,避免后续 DOM 变更破坏这些需要的信息,确保数据和交互的一致性。

3.2.1 核心目标与执行时机

  1. 核心目标:DOM 变更前,安全读取当前页面状态(如滚动位置、焦点元素、选择的文本),执行前置准备(如快照、焦点处理)。
  2. 关键时机:在 Mutation 阶段(实际改 DOM)之前,且整个过程同步执行、不可中断。
  3. 为什么要单独分这个阶段:如果先改 DOM 再读状态,拿到的就是变更后的数据,可能导致逻辑错误(比如想记录列表滚动位置,结果 DOM 先更新了,滚动位置变了)。

3.2.2 核心实现逻辑

commitBeforeMutationEffects 先初始化提交环境(获取聚焦实例句柄、判断视图过渡场景),通过 commitBeforeMutationEffects_begin 以深度优先方式遍历 Fiber 树,先处理待删除子节点的提交前副作用,有子节点且子树含相关副作用标记则深入遍历,无子节点则记录视图过渡状态并切换到兄弟 / 父节点;遍历过程中通过 commitBeforeMutationEffects_onFiber 处理单个节点逻辑,包括焦点在隐藏 Suspense 边界内的失焦处理、类组件 getSnapshotBeforeUpdate 快照执行、根节点容器清空等,全程在 DOM 变更前完成,为后续 Mutation 阶段做准备,执行完毕后清理环境状态。

export function commitBeforeMutationEffects(
  root: FiberRoot,
  firstChild: Fiber,
  committedLanes: Lanes
): void {
  focusedInstanceHandle = prepareForCommit(root.containerInfo);
  shouldFireAfterActiveInstanceBlur = false;

  const isViewTransitionEligible =
    enableViewTransition &&
    includesOnlyViewTransitionEligibleLanes(committedLanes);

  // 初始化副作用遍历指针,指向待处理的第一个 Fiber 节点
  nextEffect = firstChild;
  // 开始执行提交前阶段核心逻辑
  commitBeforeMutationEffects_begin(isViewTransitionEligible);

  focusedInstanceHandle = null;
  resetAppearingViewTransitions();
}

function commitBeforeMutationEffects_begin(isViewTransitionEligible: boolean) {
  // 确定副作用掩码:视图过渡场景用专用掩码,否则用默认提交前掩码
  const subtreeMask = isViewTransitionEligible
    ? BeforeAndAfterMutationTransitionMask
    : BeforeMutationMask;

  // 遍历副作用链表(nextEffect 驱动)
  while (nextEffect !== null) {
    const fiber = nextEffect;

    // 先处理 deletions:父→子
    if (enableCreateEventHandleAPI || isViewTransitionEligible) {
      const deletions = fiber.deletions;
      if (deletions !== null) {
        for (let i = 0; i < deletions.length; i++) {
          commitBeforeMutationEffectsDeletion(
            deletions[i],
            isViewTransitionEligible
          );
        }
      }
    }

    // 隐藏子树优化(Offscreen/Suspense)与视图过渡特殊处理
    if (enableViewTransition && fiber.tag === OffscreenComponent) {
      // 根据隐藏/显现状态跳过或特殊遍历
      commitBeforeMutationEffects_complete(isViewTransitionEligible);
      continue;
    }

    const child = fiber.child;
    // 有子节点且子树含提交前副作用标记:深入子节点遍历(深度优先)
    if ((fiber.subtreeFlags & subtreeMask) !== NoFlags && child !== null) {
      child.return = fiber;
      nextEffect = child;
    } else {
      if (isViewTransitionEligible) {
        // 视图过渡:记录子树内变更前状态
        commitNestedViewTransitions(fiber);
      }
      // 完成当前节点处理,切换到兄弟/父节点
      commitBeforeMutationEffects_complete(isViewTransitionEligible);
    }
  }
}

// 处理单个 Fiber 节点的提交前副作用
function commitBeforeMutationEffects_onFiber(
  finishedWork: Fiber,
  isViewTransitionEligible: boolean
) {
  const current = finishedWork.alternate;
  const flags = finishedWork.flags;

  // 焦点在隐藏边界内的处理
  if (enableCreateEventHandleAPI) {
    if (!shouldFireAfterActiveInstanceBlur && focusedInstanceHandle !== null) {
      if (
        finishedWork.tag === SuspenseComponent &&
        isSuspenseBoundaryBeingHidden(current, finishedWork) &&
        doesFiberContain(finishedWork, focusedInstanceHandle)
      ) {
        shouldFireAfterActiveInstanceBlur = true;
        beforeActiveInstanceBlur(finishedWork); //  // 执行活跃实例失焦前回调
      }
    }
  }

  switch (finishedWork.tag) {
    case ClassComponent: {
      if ((flags & Snapshot) !== NoFlags && current !== null) {
        // 类组件:存在 Snapshot 标记且有旧节点,执行快照逻辑(如 getSnapshotBeforeUpdate)
        commitClassSnapshot(finishedWork, current);
      }
      break;
    }
    case HostRoot: {
      // 根节点:存在 Snapshot 标记,清空容器(DOM 变更前准备)
      if ((flags & Snapshot) !== NoFlags) {
        if (supportsMutation) {
          const root = finishedWork.stateNode;
          clearContainer(root.containerInfo);
        }
      }
      break;
    }
    case FunctionComponent: {
      // useEffectEvent:更新事件实现引用(非 DOM 快照)
      // 省略具体细节
      break;
    }
    default: {
      if ((flags & Snapshot) !== NoFlags) {
        throw new Error("Unexpected Snapshot flag for this fiber tag.");
      }
    }
  }
}

3.3 子阶段二:Mutation

Mutation 阶段是 React 真正 “动手修改 DOM” 的阶段,核心任务是将 Render 阶段计算出的 DOM 变更(插入、删除、更新)应用到真实 DOM 上,同时完成旧 Ref 解绑、宿主状态清理等关键操作。这一阶段的操作直接影响用户可见的界面,且全程同步执行、不可中断。

3.3.1 核心目标与执行时机

  1. 核心目标:执行真实 DOM 操作(插入新节点、删除旧节点、更新属性 / 文本),并完成与 DOM 变更相关的前置清理(如旧 Ref 解绑)。
  2. 关键时机:在 Before Mutation 阶段(读状态)之后,Layout 阶段(读新 DOM 状态)之前,是 “写 DOM” 的唯一阶段。
  3. 为什么重要:这是虚拟 DOM 映射到真实 DOM 的 “最后一步”,所有 Render 阶段的计算结果在此落地,直接决定用户看到的界面。

3.3.2 核心实现逻辑

  1. 和 Before Mutation 阶段类似,Mutation 阶段也需要确保操作不被打断,因此会先切换到高优先级环境,会调用commitMutationEffectsOnFiber 来递归处理 finishedWork 树。
const prev = getCurrentUpdatePriority();
setCurrentUpdatePriority(DiscreteEventPriority);
const prevCtx = executionContext;
executionContext |= CommitContext;
try {
  commitMutationEffects(root, finishedWork, lanes); // 核心:执行所有 DOM 变更操作
  resetAfterCommit(root.containerInfo); // 宿主层收尾
} finally {
  executionContext = prevCtx;
  setCurrentUpdatePriority(prev);
}

// 关键:DOM 变更完成后,切换当前 Fiber 树(旧树 → 新树)
root.current = finishedWork;
// 标记进入下一阶段(Layout 阶段)
pendingEffectsStatus = PENDING_LAYOUT_PHASE;
  1. commitMutationEffectsOnFiber 是实际执行副作用的核心。它会根据 fiber.flags 的不同,执行相应的操作。其基本流程是 “自上而下” 递归,但处理副作用的顺序有所不同。

首先处理 deletions,在遍历子节点之前,优先处理当前 Fiber 节点上标记为 ChildDeletion 的所有待删除子节点。这确保了在执行其他变更前,旧的 DOM 节点已被移除;然后递归子节点:如果子树中存在 MutationMask 标记的变更,则继续向下遍历,对每个子节点调用 commitMutationEffectsOnFiber;在递归回到当前 Fiber 后,处理自身的副作用。例如,对于 HostComponent,会根据 flags 执行 commitHostUpdate(更新属性)或 commitHostPlacement(插入 DOM 树)。对于函数组件,会在这里触发 useInsertionEffect 的销毁和创建回调。

function commitMutationEffectsOnFiber(
  finishedWork: Fiber,
  root: FiberRoot,
  lanes: Lanes
) {
  const flags = finishedWork.flags;

  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      // 递归处理子节点的 Mutation Effects
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      // 处理当前节点的 Placement (插入)
      commitReconciliationEffects(finishedWork);

      // 3. 如果有 Update 标记 (通常由 Hook 副作用引起)
      if (flags & Update) {
        // 卸载之前的插入副作用钩子 (useInsertionEffect)
        commitHookEffectListUnmount(
          HookInsertion | HookHasEffect,
          finishedWork,
          finishedWork.return
        );
        // 挂载新的插入副作用钩子 (useInsertionEffect)
        commitHookEffectListMount(HookInsertion | HookHasEffect, finishedWork);
        // 卸载之前的插入副作用钩子 (useLayoutEffect)
        commitHookLayoutUnmountEffects(
          finishedWork,
          finishedWork.return,
          HookLayout | HookHasEffect
        );、
      }
      break;
    }
    case ClassComponent: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);

      if (flags & Ref) {
        // 解绑旧的 ref
        const current = finishedWork.alternate;
        if (current !== null) {
          safelyDetachRef(current, current.return);
        }
      }
      break;
    }
    // DOM 元素
    case HostComponent: {
      recursivelyTraverseMutationEffects(root, finishedWork, lanes);
      commitReconciliationEffects(finishedWork);

      if (flags & Ref) {
        const current = finishedWork.alternate;
        if (current !== null) {
          safelyDetachRef(current, current.return);
        }
      }

      if (flags & Update) {
        const instance = finishedWork.stateNode;
        if (instance != null) {
          // 应用 DOM 属性更新
          const newProps = finishedWork.memoizedProps;
          const oldProps = current !== null ? current.memoizedProps : newProps;
          const type = finishedWork.type;
          const updatePayload = finishedWork.updateQueue;
          finishedWork.updateQueue = null;
          if (updatePayload) {
            // 调用 commitHostUpdate 执行 DOM 更新
            commitHostUpdate(
              instance,
              updatePayload,
              type,
              oldProps,
              newProps,
              finishedWork
            );
          }
        }
      }
      break;
    }
    // ... 其他类型的 Fiber
  }
}

function recursivelyTraverseMutationEffects(root, parentFiber, lanes) {
  // 优先处理删除
  const deletions = parentFiber.deletions;
  if (deletions !== null) {
    for (let i = 0; i < deletions.length; i++) {
      const childToDelete = deletions[i];
      commitDeletionEffects(root, parentFiber, childToDelete);
    }
  }

  // 递归处理子节点
  if (parentFiber.subtreeFlags & MutationMask) {
    let child = parentFiber.child;
    while (child !== null) {
      commitMutationEffectsOnFiber(child, root, lanes);
      child = child.sibling;
    }
  }
}

3.4 子阶段三:Layout

Layout 阶段是提交过程中 DOM 稳定后、被动副作用执行前的关键环节,核心目标是处理需要依赖完整 DOM 状态的同步操作,同时完成组件生命周期与 ref 的最终绑定。

3.4.1 核心目标与执行时机

  1. 核心目标:在 DOM 状态稳定后同步处理依赖 DOM 的布局操作、生命周期回调与 ref 绑定。
  2. 关键时机:Mutation 阶段(DOM 增删改)之后、被动副作用(如 useEffect)之前。
  3. 为什么重要:为需要即时获取 / 操作最新 DOM 状态的场景提供同步执行环境,同时保证相关逻辑的执行时序与 DOM 状态一致性。

3.4.2 核心实现逻辑

export function commitLayoutEffects(
  finishedWork: Fiber,
  root: FiberRoot,
  committedLanes: Lanes
): void {
  nProgressLanes = committedLanes; // 记录当前处理的优先级车道
  inProgressRoot = root; // 记录当前根节点

  resetComponentEffectTimers(); // 重置组件副作用计时器

  const current = finishedWork.alternate; // 获取旧 Fiber 节点(current 树)
  // 处理当前 Fiber 节点的布局副作用
  commitLayoutEffectOnFiber(root, current, finishedWork, committedLanes);

  inProgressLanes = null;
  inProgressRoot = null;
}

function recursivelyTraverseLayoutEffects(
  root: FiberRoot,
  parentFiber: Fiber,
  lanes: Lanes
) {
  // 若子树存在布局副作用标记,才遍历子节点
  if (parentFiber.subtreeFlags & LayoutMask) {
    let child = parentFiber.child;
    while (child !== null) {
      const current = child.alternate;
      // 处理子节点的布局副作用
      commitLayoutEffectOnFiber(root, current, child, lanes);
      child = child.sibling; // 遍历兄弟节点
    }
  }
}

function commitLayoutEffectOnFiber(
  finishedRoot: FiberRoot,
  current: Fiber | null,
  finishedWork: Fiber,
  committedLanes: Lanes
): void {
  const flags = finishedWork.flags;
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      // 先递归处理子树布局副作用
      recursivelyTraverseLayoutEffects(
        finishedRoot,
        finishedWork,
        committedLanes
      );
      // 若节点有更新标记,执行 useLayoutEffect 回调
      if (flags & Update) {
        commitHookLayoutEffects(finishedWork, HookLayout | HookHasEffect);
      }
      break;
    }
    case ClassComponent: {
      // 先递归处理子树
      recursivelyTraverseLayoutEffects(
        finishedRoot,
        finishedWork,
        committedLanes
      );
      if (flags & Update) {
        // 若有更新,调用类组件生命周期(componentDidMount/Update)
        commitClassLayoutLifecycles(finishedWork, current);
      }
      if (flags & Callback) {
        // 处理回调(如 setState 的第二个参数)
        commitClassCallbacks(finishedWork);
      }
      if (flags & Ref) {
        // 绑定 ref
        safelyAttachRef(finishedWork, finishedWork.return);
      }
      break;
    }
    case HostComponent: {
      recursivelyTraverseLayoutEffects(
        finishedRoot,
        finishedWork,
        committedLanes
      );
      if (current === null && flags & Update) {
        // 初次挂载时执行额外逻辑(如输入框聚焦)
        commitHostMount(finishedWork);
      }
      if (flags & Ref) {
        safelyAttachRef(finishedWork, finishedWork.return);
      }
      break;
    }
    case HostRoot: {
      recursivelyTraverseLayoutEffects(
        finishedRoot,
        finishedWork,
        committedLanes
      );
      if (flags & Callback) {
        // 处理根节点回调(如 ReactDOM.render 的回调)
        commitRootCallbacks(finishedWork);
      }
      break;
    }
    // 性能分析节点
    case Profiler: {
      if (flags & Update) {
        recursivelyTraverseLayoutEffects(
          finishedRoot,
          finishedWork,
          committedLanes
        );
        commitProfilerUpdate(
          finishedWork,
          current,
          commitStartTime,
          finishedWork.stateNode.effectDuration
        );
      } else {
        recursivelyTraverseLayoutEffects(
          finishedRoot,
          finishedWork,
          committedLanes
        );
      }
      break;
    }
    case SuspenseComponent: {
      recursivelyTraverseLayoutEffects(
        finishedRoot,
        finishedWork,
        committedLanes
      );
      if (flags & Update) {
        commitSuspenseHydrationCallbacks(finishedRoot, finishedWork);
      }
      // Callback 注册逻辑略
      break;
    }
    case OffscreenComponent: {
      // 隐藏时跳过;重新显现时使用 reappear 递归;否则正常递归
      // 细节见源码的可见性与上下文切换逻辑
      break;
    }
    default: {
      // 其他类型节点默认递归处理子树
      recursivelyTraverseLayoutEffects(
        finishedRoot,
        finishedWork,
        committedLanes
      );
      break;
    }
  }
}

3.5 被动阶段(Passive Effects)

被动阶段是 Commit 阶段的最后一个子阶段,核心负责执行 useEffect 相关逻辑(清理 + 回调)。其设计核心是不阻塞 DOM 渲染与用户交互,将非紧急副作用延迟到浏览器绘制后异步执行,平衡性能与开发体验。

3.5.1 核心目标与执行时机

  1. 核心目标:在不阻塞 DOM 渲染与用户交互的前提下,安全执行非紧急异步副作用。
  2. 关键时机:本次提交的 Fiber 树或其子树包含 Passive 副作用标记(flags/subtreeFlags & PassiveMask)时,useEffect 会在布局完成且浏览器完成绘制后通过后续的非阻塞调度执行。

3.5.2 核心实现逻辑

先通过 flushPassiveEffects 校验阶段状态、取出提交数据,将渲染车道映射为不高于 Default 的更新优先级并保护原始上下文,再调用 flushPassiveEffectsImpl 做重入保护并进入提交上下文,随后按 “先卸载后挂载” 顺序,通过 commitPassiveUnmountEffects 递归遍历 Fiber 树执行 useEffect 清理函数(含被删除子树的副作用清理、离线组件的断开逻辑),再通过 commitPassiveMountEffects 递归遍历执行 useEffect 回调函数(含视图过渡场景适配、离线 / 根节点等特殊类型 Fiber 的差异化处理),全程同步串行且不阻塞 DOM 渲染与用户交互,执行完毕后恢复原始上下文并释放资源。

function flushPassiveEffects(wasDelayedCommit?: boolean): boolean {
  if (pendingEffectsStatus !== PENDING_PASSIVE_PHASE) {
    return false;
  }
  const root = pendingEffectsRoot;
  const remainingLanes = pendingEffectsRemainingLanes;
  pendingEffectsRemainingLanes = NoLanes;

  // 计算被动阶段的更新优先级:不高于Default,避免抢占高优任务
  const renderPriority = lanesToEventPriority(pendingEffectsLanes); // 渲染车道转事件优先级
  const priority = lowerEventPriority(DefaultEventPriority, renderPriority); // 取较低优先级

  const prevTransition = ReactSharedInternals.T;
  const previousPriority = getCurrentUpdatePriority();

  try {
    setCurrentUpdatePriority(priority);
    ReactSharedInternals.T = null;
    return flushPassiveEffectsImpl(wasDelayedCommit); // 执行副作用核心逻辑
  } finally {
    setCurrentUpdatePriority(previousPriority);
    ReactSharedInternals.T = prevTransition;
    releaseRootPooledCache(root, remainingLanes);
  }
}

function flushPassiveEffectsImpl(wasDelayedCommit: void | boolean) {
  const transitions = pendingPassiveTransitions;
  pendingPassiveTransitions = null;

  const root = pendingEffectsRoot;
  const lanes = pendingEffectsLanes;
  pendingEffectsStatus = NO_PENDING_EFFECTS;
  pendingEffectsRoot = (null: any);
  pendingFinishedWork = (null: any);
  pendingEffectsLanes = NoLanes;

  if ((executionContext & (RenderContext | CommitContext)) !== NoContext) {
    throw new Error("Cannot flush passive effects while already rendering.");
  }

  const prevExecutionContext = executionContext;
  executionContext |= CommitContext;

  // 严格执行顺序:先卸载(清理旧副作用),后挂载(执行新回调)
  commitPassiveUnmountEffects(root.current); // 执行useEffect清理函数
  // 执行useEffect回调函数
  commitPassiveMountEffects(
    root,
    root.current,
    lanes,
    transitions,
    pendingEffectsRenderEndTime
  );

  executionContext = prevExecutionContext;
}

// ReactFiberCommitWork.js — 卸载入口与遍历
export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
  resetComponentEffectTimers();
  commitPassiveUnmountOnFiber(finishedWork); // 开始处理单个Fiber节点的卸载逻辑
}

function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
  const deletions = parentFiber.deletions; // 待删除的子节点列表
  // 若父节点有子删除标记且存在待删除节点,处理被删除子树的卸载
  if ((parentFiber.flags & ChildDeletion) !== NoFlags && deletions !== null) {
    for (let i = 0; i < deletions.length; i++) {
      const childToDelete = deletions[i]; // 待删除的子节点
      nextEffect = childToDelete; // 标记当前处理的副作用节点
      // 处理被删除子树的被动卸载(深度优先遍历)
      commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
        childToDelete,
        parentFiber
      );
    }
    detachAlternateSiblings(parentFiber); // 分离旧Fiber树的兄弟节点引用,避免内存泄漏
  }
  // 若子树存在被动副作用标记,递归处理所有子节点
  if (parentFiber.subtreeFlags & PassiveMask) {
    let child = parentFiber.child;
    while (child !== null) {
      commitPassiveUnmountOnFiber(child);
      child = child.sibling;
    }
  }
}

function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      // 先递归处理子树的卸载副作用
      recursivelyTraversePassiveUnmountEffects(finishedWork);
      if (finishedWork.flags & Passive) {
        // 若节点有被动副作用标记,执行useEffect清理函数
        commitHookPassiveUnmountEffects(
          finishedWork,
          finishedWork.return,
          HookPassive | HookHasEffect
        );
      }
      break;
    }
    // 离线/隐藏组件
    case OffscreenComponent: {
      const instance: OffscreenInstance = finishedWork.stateNode;
      const isHidden = finishedWork.memoizedState !== null;
      if (
        isHidden &&
        instance._visibility & OffscreenPassiveEffectsConnected &&
        (finishedWork.return === null ||
          finishedWork.return.tag !== SuspenseComponent)
      ) {
        // 隐藏子树:断开被动副作用连接
        instance._visibility &= ~OffscreenPassiveEffectsConnected;
        recursivelyTraverseDisconnectPassiveEffects(finishedWork);
      } else {
        recursivelyTraversePassiveUnmountEffects(finishedWork);
      }
      break;
    }
    default: {
      recursivelyTraversePassiveUnmountEffects(finishedWork);
      break;
    }
  }
}

export function commitPassiveMountEffects(
  root: FiberRoot,
  finishedWork: Fiber,
  committedLanes: Lanes,
  committedTransitions: Array<Transition> | null,
  renderEndTime: number
): void {
  resetComponentEffectTimers();
  // 开始处理单个Fiber节点的挂载逻辑
  commitPassiveMountOnFiber(
    root,
    finishedWork,
    committedLanes,
    committedTransitions,
    renderEndTime
  );
}

function recursivelyTraversePassiveMountEffects(
  root: FiberRoot,
  parentFiber: Fiber,
  committedLanes: Lanes,
  committedTransitions: Array<Transition> | null,
  endTime: number
) {
  // 判断是否为视图过渡 eligible 场景(仅包含视图过渡相关车道)
  const isViewTransitionEligible =
    enableViewTransition &&
    includesOnlyViewTransitionEligibleLanes(committedLanes);
  // 确定被动副作用掩码:视图过渡场景用专用掩码,否则用默认被动掩码
  const subtreeMask = isViewTransitionEligible
    ? PassiveTransitionMask
    : PassiveMask;

  // 满足以下条件则遍历子节点:
  // 1. 子树存在对应被动副作用标记;
  // 2. 开启性能追踪且组件有渲染耗时,且子树结构变更(需重新执行副作用)
  if (
    parentFiber.subtreeFlags & subtreeMask ||
    (enableProfilerTimer &&
      enableComponentPerformanceTrack &&
      parentFiber.actualDuration !== 0 &&
      (parentFiber.alternate === null ||
        parentFiber.alternate.child !== parentFiber.child))
  ) {
    let child = parentFiber.child;
    while (child !== null) {
      // 处理单个子节点的被动挂载副作用
      commitPassiveMountOnFiber(
        root,
        child,
        committedLanes,
        committedTransitions,
        enableProfilerTimer && enableComponentPerformanceTrack
          ? child.sibling !== null
            ? ((child.sibling.actualStartTime: any): number)
            : endTime
          : 0
      );
      child = child.sibling;
    }
  } else if (isViewTransitionEligible) {
    restoreNestedViewTransitions(parentFiber);
  }
}

function commitPassiveMountOnFiber(
  finishedRoot: FiberRoot,
  finishedWork: Fiber,
  committedLanes: Lanes,
  committedTransitions: Array<Transition> | null,
  endTime: number
): void {
  const flags = finishedWork.flags;
  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      // 先递归处理子树的被动挂载副作用
      recursivelyTraversePassiveMountEffects(
        finishedRoot,
        finishedWork,
        committedLanes,
        committedTransitions,
        endTime
      );
      if (flags & Passive) {
        // 安装 useEffect
        commitHookPassiveMountEffects(
          finishedWork,
          HookPassive | HookHasEffect
        );
      }
      break;
    }
    case HostRoot: {
      const prevEffectDuration = pushNestedEffectDurations();
      // 递归处理子树的被动挂载副作用
      recursivelyTraversePassiveMountEffects(
        finishedRoot,
        finishedWork,
        committedLanes,
        committedTransitions,
        endTime
      );
      if (enableProfilerTimer && enableProfilerCommitHooks) {
        finishedRoot.passiveEffectDuration +=
          popNestedEffectDurations(prevEffectDuration);
      }
      break;
    }
    // ... existing code ...
    default: {
      recursivelyTraversePassiveMountEffects(
        finishedRoot,
        finishedWork,
        committedLanes,
        committedTransitions,
        endTime
      );
      break;
    }
  }
}

四、实践启示

4.1 Hook 与生命周期的正确选择

理解各阶段时序后,能更精准地选择合适的 API,避免常见坑: 需在 DOM 变更前插入样式(CSS-in-JS 动态样式)→ 用 useInsertionEffect(Before Mutation 阶段执行,无 DOM 读取能力); 需同步获取 DOM 尺寸、位置(如弹层定位)→ 用 useLayoutEffect(Layout 阶段执行,DOM 已稳定,支持同步读写); 需绑定事件监听、发起异步请求(如数据拉取、滚动监听)→ 用 useEffect(Passive 阶段执行,不阻塞渲染与交互); 类组件场景:getSnapshotBeforeUpdate 对应 Before Mutation 阶段(读快照),componentDidMount/componentDidUpdate 对应 Layout 阶段(操作 DOM)。

4.2 常见问题与解决方案

坑 1:在 useEffect 中同步操作 DOM 后立即测量 → 可能触发强制重排。 解决:需同步测量用 useLayoutEffect,异步操作用 useEffect。 坑 2:Ref 引用在组件挂载后立即访问为 null → 误解 Ref 绑定时机。 解决:Ref 绑定在 Layout 阶段,可在 useLayoutEffect 或 useEffect 中访问(前者同步,后者异步)。

4.3 借力 Commit 阶段的设计理念

优先使用 useEffect 而非同步副作用:将非紧急操作推迟到绘制后,减少对主线程的阻塞; 避免在 Layout 阶段执行 heavy 计算:该阶段同步执行,耗时操作会直接影响界面响应速度; 合理使用 startTransition:将低优先级更新标记为过渡任务,React 会在调度时避开高优先级交互(如输入、点击),避免阻塞 Commit 阶段。

五、最后

深入理解 Commit 阶段的三子阶段(Before Mutation → Mutation → Layout)及被动阶段(Passive Effects),不仅能帮我们看透 React 渲染的底层逻辑,更能指导开发中写出更高效、无隐患的代码 —— 毕竟很多性能问题、时序 Bug 都源于对副作用执行时机的误解。

❌