普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月27日掘金 前端

GDAL 遥感影像数据读取-plus

作者 GIS之路
2026年1月27日 19:51

^ 关注我,带你一起学GIS ^

前言

遥感影像数据作为GIS的重要数据源,在GIS开发中具有重要意义。在日常开发中,需要熟练掌握GDAL读取栅格数据的方式方法。

由于本文由一些前置知识,在正式开始之前,需要你掌握一定的Python开发基础和GDAL的基本概念。在之前的文章中讲解了如何使用GDAL或者ogr2ogr工具将txt以及csv文本数据转换为Shp格式,可以作为基础入门学习。

GDAL系列开始之前的一篇文章已经讲解过如何使用**GDAL读取遥感数据**,但是内容不太精确和完整,所以才有了现在这篇文章,属于GDAL读取遥感影像数据的plus版本。

本篇教程在之前一系列文章的基础上讲解如何使用GDAL 实现遥感影像数据读取-plus

如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2026年

系统:Windows 11

Python:3.11.11

GDAL:3.11.1

2. 数据准备

俗话说巧妇难为无米之炊,数据就是软件的基石,没有数据,再美好的设想都是空中楼阁。因此,第一步需要下载遥感影像数据。

但是,影像数据在哪里下载呢?别着急,本文都给你整理好了。

数据下载可参考文章:GIS 影像数据源介绍

如下,这是我在【地理空间数据云】平台下载的landsat8遥感影像。

3. 查看数据支持格式

GDAL支持的栅格数据格式包括常见和非常见的足足有几百种,可谓是非常之多。

(一)常见数据格式

  • CAD:AutoCAD DWG raster layer
  • COG:Cloud Optimized GeoTIFF generator
  • JPG:JPEG JFIF File Format
  • PNG:Portable Network Graphics
  • GTiff:GeoTIFF File Format
  • WEBP:WEBP
  • ...........

(二)非常见数据格式

  • ACE2:ACE2
  • BAG:Bathymetry Attributed Grid
  • COASP:DRDC COASP SAR Processor Raster
  • DDS:DirectDraw Surface
  • HEIF:ISO/IEC 23008-12 High Efficiency Image File Format
  • NTv2:NTv2 Datum Grid Shift
  • ...........

可以通过以下地址查看GDAL支持的栅格数据格式。

https://gdal.org/en/stable/drivers/raster/

为了查看你使用的GDAL支持哪些数据格式,可以通过工具gdalinfo进行查看。

如果你不知道gdalinfo工具安装在哪里的话,可以使用搜索工具everything进行查找。

如果找到gdalinfo.exe程序的话,在命令行窗口中执行以下语句。

gdalinfo --formats

如果找不到gdalinfo.exe程序,可以寻找gdalinfo.py脚本,在命令行窗口中执行以下语句。

python gdalinfo.py --formats

如下是我的本地输出结果。

4. 导入依赖

直接使用from osgeo import gdal语句导入GDAL数据驱动。

from osgeo import gdal

5. 注册数据驱动

在使用之前需要注册驱动,而数据驱动的名称可在https://gdal.org/en/stable/drivers/raster/进行查找。

使用AllRegister方法一次性注册所有数据驱动。

# 注册数据驱动
gdal.AllRegister()

也可以通过GetDriverByName方法获取驱动名称进行单个注册。

# 根据驱动名称进行注册
driver = gdal.GetDriverByName("GTiff")
driver.Register()

6. 读取栅格数据集

在数据驱动注册之后,直接使用Open方法打开数据源,该方法将会返回一个栅格栅格数据集。

# 根据驱动名称进行注册
driver = gdal.GetDriverByName("GTiff")

if driver is None:
    print("当前版本的gdal不支持此栅格数据格式!!")

driver.Register()

tiff_image = "file.tif"

datasource = gdal.Open(tiff_image,0)
if datasource is None:
    print(f"{tiff_image} 数据源打开失败,请检查")

6.1. 读取栅格信息

在数据源中,栅格数据按照行和列存储栅格信息,可以使用数据源属性读取栅格数据的总行数、总列数以及波段数。

# 读取行列以及波段数据
rows = datasource.RasterYSize
cols = datasource.RasterXSize
bands = datasource.RasterCount

6.2. 读取地理信息

使用数据源方法GetGeoTransform 获取栅格坐标数据,该方法返回一个包含6个元素的元组数据。GeoTransform中,具有左上角起点坐标、行和列分辨率以及旋转参数。

# 获取地理信息
geotransform = datasource.GetGeoTransform()
origion_x = geotransform[0]
origion_y = geotransform[3]
pixel_width = geotransform[1]
pixel_height = geotransform[5]

"""
geotransform[0] /* top left x */
geotransform[1] /* w-e pixel resolution */
geotransform[2] /* rotation, 0 if image is "north up" */
geotransform[3] /* top left y */
geotransform[4] /* rotation, 0 if image is "north up" */
geotransform[5] /* n-s pixel resolution *
"""

坐标信息输出结果如下。

6.3. 计算像素偏移

使用当前点的x坐标减去起点x坐标,再除以像素宽度,可得到目标点在x方向上的像素偏移量;同理,使用y坐标减去起点y坐标,再除以像素高度,可得到目标点再y方向上的像素偏移量。

使用以下代码将地理坐标转换为像素坐标。

# 计算坐标偏移
x_offset = int((x-origion_x)/pixel_width)
y_offset = int((y-origion_y)/pixel_height)

6.4. 获取单个像素值

通过GetRasterBand方法获取目标波段数据,该方法接收一个索引参数,用于指定读取波段。使用 ReadAsArray方法将数据转换为二维数组。data = band.ReadAsArray(xOffset,yOffset,1,1)

# 读取波段数据
band = datasource.GetRasterBand(1)
data = band.ReadAsArray()

然后需要指定偏移量获取特定位置的值。

value = data[0,0]

6.5. 读取整个影像数据

将偏移量设置为0,并且将栅格数据的行和列数传递给ReadAsArray方法。

data = band.ReadAsArray(0, 0, cols, rows)

使用[yoff,xoff]读取单个像素值,注意,是[row,col],不是[col,row]

获取第十五列,第十行像素值。

# 获取第十五列,第十行像素值
col_15_row_10 = data_all[9,14]

7. 内存管理

在数据读取完成之后记得将变量设置为None,特别是在读取大型数据的时候非常重要。

# 关闭内存
band = None
datasource = None

图片效果


OpenLayers示例数据下载,请在公众号后台回复:vector

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

都看到这了,不要忘记点赞、收藏 + 关注

本号不定时更新有关 GIS开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

地图海报生成项目定位方式修改

关于 PyQT5 和 GDAL 导入顺序引发程序崩溃的解决记录

关于浏览器无法进入断点的解决记录

GDAL 实现影像裁剪

GDAL 实现影像合并

小小声说一下GDAL的官方API接口

《云南省加快构建现代化产业体系推进产业强省建设行动计划》发布ArcGIS Pro 添加底图的方式

为什么每次打开 ArcGIS Pro 页面加载都如此缓慢?

ArcGIS 波段合成操作

ArcGIS Pro 实现影像波段合成

GDAL 创建矢量图层的两种方式

GDAL 实现矢量数据转换处理(全)

GDAL 实现投影转换

伴影- Chrome 扩展,想给每天的数字生活一点仪式感

2026年1月27日 19:47

作为一名开发者,每天面对的是写不完的 Bug 和数不清的 Tab。直到有一天,我回看浏览历史,发现那只是冰冷的 URL 堆砌,却没有一点“生活过”的痕迹。于是,我利用业余时间开发了 「伴影 · Shadow Mate」。它不是那种催你提高效率的工具,而是一个会在 18:00 准时出现的“数字影子”。✨ 它最让我心动的几个瞬间:

20260127-193047.gif

  1. 余晖模式:下班前半小时,浏览器会悄悄泛起橙色的余晖。那种“该回家了”的视觉暗示,比闹钟温柔得多。

  2. 私语引擎:它不会只告诉你“访问了 50 个网页”,它会说:“今天你在知识森林漫步了许久,是个充实的下午。”

  3. 数字漂流瓶:当你偶然回到半个月前深夜翻阅的文档,它会带着当时的思绪浮现。

第一次用自己做的产品是什么感觉?

今天下午 6 点,我设定的"归航时刻"到了。突然有个小幽灵 👻 从网页右下角冒出来,跟我说:

"今天你在代码海洋里航行了 4 小时,在知识森林里漫步了 2 小时。辛苦了,现在的你值得被温柔对待。"

image.jpg

说实话,那一刻我真的愣了一下。虽然是我自己写的代码,但看到这段文字的时候,还是有种被理解的感觉。

点开卡片,它把我今天访问的所有网站、停留时间、滚动距离、输入字符数都列出来了。最让我意外的是,它根据我停留最久的页面,提取出了今天的关键词,然后生成了一段特别有温度的话。

下午 5 点半的时候,我还发现网页突然泛起了淡淡的橙色,就像夕阳一样。这是我做的"余晖模式",在归航前 30 分钟提醒你该下班了。虽然是我自己写的逻辑,但真的看到效果的时候,还是觉得挺治愈的。

【关于隐私】:没有任何服务器,数据 24h 物理粉碎,只存在你的本地。我想,在追求极致效率的时代,我们或许也需要一点“无用”的仪式感。项目开源/下载地址: 伴影(Shadow Mate)欢迎评论区交流,大家平时几点“归航”?🕯️

1 分钟 CSS 小技巧让你的 UI 看起来贵 10 倍

作者 冴羽
2026年1月27日 18:28

为什么同样是按钮,有的看起来高档大气,有的却显得廉价劣质?

秘诀就在于层次感

就像 3D 电影比 2D 电影更有沉浸感一样,有深度的界面比扁平的界面更能抓住用户的注意力。

扁平的化界面就像一张平铺的纸,而有层次的界面就像立体的雕塑,自然显得更高级。

核心秘诀

苹果的产品为什么看起来那么高级?

其实原理很简单——就像化妆一样,层次感来自多重叠加

回忆一下女朋友化妆的步骤:

  1. 第一层:浅色打底(提亮)
  2. 第二层:深色阴影(立体感)

界面设计也是同理:

  • 第一层阴影:让元素“浮起来”
  • 第二层阴影:让元素“站得住”

就这么简单!但效果却能让你惊叹。

现在让我们看些实际的例子。

应用场景

1. 鼠标悬停

CSS 代码很简单:

.card {
  background: var(--shade);
  border-radius: 10px;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), /* top glow */ 0 4px 6px rgba(0, 0, 0, 0.12); /* bottom drop */
}

鼠标悬停时:

.card:hover {
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5), 0 10px 20px rgba(0, 0, 0, 0.16);
  transform: translateY(-2px);
}

使用效果如下:

这种轻微的悬停提升效果能让用户界面感觉响应迅速且高端,而无需使用动画库。

激活标签

当前激活的标签页看起来应该比其他标签页位置更高。

代码如下:

.tab.active {
  background: var(--shade);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4), 0 3px 6px rgba(0, 0, 0, 0.12);
}

使用效果如下:

结论

我以前认为,优秀的 UI 需要复杂的渐变、自定义图标或大规模的重新设计。

事实证明,优秀设计很大程度上来自于细微的、有意设计的深度细节。

颜色图层 + 柔和阴影 = 廉价 UI → 高级 UI

现在就去试试吧!花 1 分钟,你就能让界面看起来贵 10 倍。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

cross-env@10.1.0源码阅读

作者 米丘
2026年1月27日 18:26

发布日期: 2025 年 9 月 30 日

cross-env 是一个 Node.js 工具,用于解决不同操作系统间环境变量设置方式不一致的问题,支持 Windows、Linux 和 macOS平台。

package.json

cross-env-10.1.0/package.json

{
  "name": "cross-env",
  "version": "0.0.0-semantically-released",
  "description": "Run scripts that set and use environment variables across platforms",
  "type": "module",
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "bin": {
    "cross-env": "./dist/bin/cross-env.js",
    "cross-env-shell": "./dist/bin/cross-env-shell.js"
  },
  "engines": {
    "node": ">=20"
  },
  "scripts": {
    "build": "zshy",
    "dev": "zshy --watch",
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "typecheck": "tsc --noEmit",
    "test": "vitest",
    "test:ui": "vitest --ui",
    "test:run": "vitest run",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "node e2e/test-cross-env.js && node e2e/test-cross-env-shell.js && node e2e/test-default-values.js",
    "validate": "npm run build && npm run typecheck && npm run lint && npm run format:check && npm run test:run"
  },
  "files": [
    "dist"
  ],
  "keywords": [
    "cross-environment",
    "environment variable",
    "windows",
    "cross-platform"
  ],
  "author": "Kent C. Dodds <me@kentcdodds.com> (https://kentcdodds.com)",
  "license": "MIT",
  "dependencies": {
    "@epic-web/invariant": "^1.0.0",
    "cross-spawn": "^7.0.6"
  },
  "devDependencies": {
    "@epic-web/config": "^1.21.1",
    "@types/cross-spawn": "^6.0.6",
    "@types/node": "^24.1.0",
    "@vitest/coverage-v8": "^3.2.4",
    "@vitest/ui": "^3.2.4",
    "eslint": "^9.32.0",
    "prettier": "^3.6.2",
    "typescript": "^5.8.3",
    "vitest": "^3.2.4",
    "zshy": "^0.3.0"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/kentcdodds/cross-env.git"
  },
  "bugs": {
    "url": "https://github.com/kentcdodds/cross-env/issues"
  },
  "homepage": "https://github.com/kentcdodds/cross-env#readme",
  "zshy": {
    "cjs": false,
    "exports": {
      ".": "./src/index.ts",
      "./bin/cross-env": "./src/bin/cross-env.ts",
      "./bin/cross-env-shell": "./src/bin/cross-env-shell.ts"
    }
  },
  "prettier": "@epic-web/config/prettier",
  "module": "./dist/index.js",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.js"
    },
    "./bin/cross-env": {
      "types": "./dist/bin/cross-env.d.ts",
      "import": "./dist/bin/cross-env.js"
    },
    "./bin/cross-env-shell": {
      "types": "./dist/bin/cross-env-shell.d.ts",
      "import": "./dist/bin/cross-env-shell.js"
    }
  }
}

cross-env

cross-env-10.1.0/src/bin/cross-env.ts

#!/usr/bin/env node

import { crossEnv } from '../index.js'

// process.argv.slice(2) 用于获取命令行参数(排除掉 node 和脚本路径本身)
crossEnv(process.argv.slice(2))

cross-env-shell

cross-env-10.1.0/src/bin/cross-env-shell.ts

#!/usr/bin/env node

import { crossEnv } from '../index.js'

crossEnv(process.argv.slice(2), { shell: true })

crossEnv

cross-env-10.1.0/src/index.ts

import { spawn } from 'cross-spawn'

function crossEnv(
  args: string[],
  options: CrossEnvOptions = {},
): ProcessResult | null {

  // 1、解析命令行参数
  // envSetters:需要设置的环境变量键值对(如 { NODE_ENV: 'production' })
  // command:要执行的命令(如 node 或 webpack)
  // commandArgs:命令的参数(如 ['server.js'])
  const [envSetters, command, commandArgs] = parseCommand(args)

  // 2、构建环境变量对象
  // 基于当前进程的环境变量(process.env)
  // 合并 envSetters 中的自定义环境变量(会经过格式处理)
  // 保证跨平台兼容性(如保留 Windows 的 APPDATA 变量)
  const env = getEnvVars(envSetters)

  // 3、执行命令(当命令存在时)
  if (command) {
    // 配置子进程启动选项
    const spawnOptions: SpawnOptions = {
      stdio: 'inherit',   // 子进程共享父进程的输入输出流(控制台)
      shell: options.shell,  // 是否通过 shell 执行命令(可选)
      env,  // 使用上面构建的环境变量对象
    }

    // 启动子进程执行命令
    // 使用 cross-spawn 的 spawn 函数(跨平台版本的 child_process.spawn)启动子进程
    const proc = spawn(
      // run `path.normalize` for command(on windows)
      // 处理命令名称(如 Windows 路径规范化)
      commandConvert(command, env, true),
      // by default normalize is `false`, so not run for cmd args
       // 处理命令参数
      commandArgs.map((arg) => commandConvert(arg, env)),
      spawnOptions,
    )

    // 4、信号处理(进程间通信)
    // 当父进程收到终止信号时,将信号传递给子进程
    // 确保父进程收到终止信号(如用户按 Ctrl+C)时,子进程能同步终止,避免僵尸进程
    process.on('SIGTERM', () => proc.kill('SIGTERM'))
    process.on('SIGINT', () => proc.kill('SIGINT'))
    process.on('SIGBREAK', () => proc.kill('SIGBREAK'))
    process.on('SIGHUP', () => proc.kill('SIGHUP'))

    // 5、处理子进程退出
    proc.on('exit', (code: number | null, signal?: string) => {
      let crossEnvExitCode = code
      // 如果退出码为 null(通常是被信号终止),设置默认退出码
      // 处理信号终止的情况(如 SIGINT 通常是用户主动中断,返回 0 表示正常退出)
      if (crossEnvExitCode === null) {
        crossEnvExitCode = signal === 'SIGINT' ? 0 : 1
      }
      // 父进程使用子进程的退出码退出
      process.exit(crossEnvExitCode)
    })

    return proc
  }

  return null
}
"scripts": {
  "test": "echo \"Error: no test specified\" && exit 1",
  "test-crossenv": "cross-env NODE_ENV=test node test-crossenv.js"
},

执行 npm run test-crossenv

parseCommand

cross-env-10.1.0/src/index.ts

function parseCommand(
  args: string[],
): [Record<string, string>, string | null, string[]] {

  // 存储环境变量
  const envSetters: Record<string, string> = {}
  // 存储命令名称
  let command: string | null = null
  // 存储命令参数
  let commandArgs: string[] = []

  // 遍历处理参数
  for (let i = 0; i < args.length; i++) {
    const arg = args[i]
    if (!arg) continue // 跳过空参数
    const match = envSetterRegex.exec(arg)

    // 解析环境变量
    if (match && match[1]) {
      let value: string
      if (typeof match[3] !== 'undefined') {
        value = match[3]
      } else if (typeof match[4] === 'undefined') {
        value = match[5] || ''
      } else {
        value = match[4]
      }

      envSetters[match[1]] = value

      // 解析命令和命令参数
    } else {
      // No more env setters, the rest of the line must be the command and args
      const cStart = args
        .slice(i)
        .map((a) => {
          const re = /\\\\|(\\)?'|([\\])(?=[$"\\])/g
          // Eliminate all matches except for "\'" => "'"
          return a.replace(re, (m) => {
            if (m === '\\\\') return '\\'
            if (m === "\\'") return "'"
            return ''
          })
        })
      const parsedCommand = cStart[0]
      invariant(parsedCommand, 'Command is required') // 确保命令存在
      command = parsedCommand
      commandArgs = cStart.slice(1).filter(Boolean) // 过滤空参数
      // 退出循环,后续参数已处理
      break
    }
  }

  return [envSetters, command, commandArgs]
}
const envSetterRegex = /(\w+)=('(.*)'|"(.*)"|(.*))/
const re = /\\\\|(\\)?'|([\\])(?=[$"\\])/g

getEnvVars

cross-env-10.1.0/src/index.ts

function getEnvVars(
  envSetters: Record<string, string>
): NodeJS.ProcessEnv {
  
  // 初始化环境变量对象
  const envVars = { ...process.env }

  // 特殊处理 Windows 系统的 APPDATA 变量
  // APPDATA 是 Windows 系统中存储应用程序数据的目录路径环境变量
  // 通常路径为 C:\Users\<用户名>\AppData\Roaming)
  if (process.env.APPDATA) {
    envVars.APPDATA = process.env.APPDATA
  }

  // 合并并处理自定义环境变量
  Object.keys(envSetters).forEach((varName) => {
    const value = envSetters[varName]
    if (value !== undefined) {
      envVars[varName] = varValueConvert(value, varName)
    }
  })
  return envVars
}

varValueConvert

cross-env-10.1.0/src/variable.ts

function varValueConvert(
originalValue: string,
originalName: string,
): string {
return resolveEnvVars(replaceListDelimiters(originalValue, originalName))
}

replaceListDelimiters

cross-env-10.1.0/src/variable.ts

function replaceListDelimiters(varValue: string, varName = ''): string {

  // 1、确定目标分隔符
  // Windows 系统的路径列表分隔符是 ;(例如 PATH=C:\a;C:\b)
  // 类 Unix 系统(Linux、macOS 等)的路径列表分隔符是 :(例如 PATH=/usr/bin:/bin)
  const targetSeparator = isWindows() ? ';' : ':'

  // pathLikeEnvVarWhitelist 是一个白名单集合(包含 PATH、NODE_PATH 环境变量名)
  if (!pathLikeEnvVarWhitelist.has(varName)) {
    return varValue
  }

  // 匹配一个或多个反斜杠(\\*)后面紧跟一个冒号(:)
  // (\\*): 捕获组,匹配0个或多个反斜杠
  // 在JavaScript字符串中需要用两个反斜杠表示一个实际的反斜杠
  return varValue.replace(
    /(\\*):/g, 
    // 替换回调
    // match: 完整的匹配结果(反斜杠序列加冒号)
    // backslashes: 捕获组中匹配的反斜杠部分
    (match, backslashes) => {
      if (backslashes.length % 2) {
        // 反斜杠数量为奇数:表示分隔符被转义,移除一个反斜杠
        return match.substring(1)
      }
      // 反斜杠数量为偶数:表示是普通分隔符,替换为目标分隔符
      return backslashes + targetSeparator
    })
}
const pathLikeEnvVarWhitelist = new Set(['PATH', 'NODE_PATH'])

resolveEnvVars

cross-env-10.1.0/src/variable.ts

function resolveEnvVars(varValue: string): string {

  const envUnixRegex = /(\\*)(\$(\w+)|\${(\w+)})/g

  return varValue.replace(
    envUnixRegex,
    // 替换回调函数
    // escapeChars:环境变量引用前的所有反斜杠(捕获组 1)
    // varNameWithDollarSign:完整的环境变量引用(如 $VAR 或 ${VAR})
    // varName:$VAR 格式中的变量名(捕获组 3)
    // altVarName:${VAR} 格式中的变量名(捕获组 4)
    (_, escapeChars, varNameWithDollarSign, varName, altVarName) => {
      // 奇数个反斜杠
      // 当反斜杠数量为奇数时,表示这个环境变量引用被转义了,应该保留原始格式(不替换为实际值)
      if (escapeChars.length % 2 === 1) {
        return varNameWithDollarSign
      }
      // 偶数个反斜杠
      // 反斜杠数量为偶数时,表示是正常的环境变量引用
      // 保留一半的反斜杠(因为偶数个反斜杠是成对的转义)
      // 拼接上环境变量的实际值(从 process.env 获取,不存在则用空字符串)
      return (
        escapeChars.substring(0, escapeChars.length / 2) +
        (process.env[varName || altVarName] || '')
      )
    },
  )
}

commandConvert

cross-env-10.1.0/src/command.ts

function commandConvert(
  command: string,
  env: NodeJS.ProcessEnv,
  normalize = false,
): string {
  // 1、非 Windows 系统直接返回
  if (!isWindows()) {
    return command
  }

  // 2、定义正则
  // 匹配简单变量引用: $var 或 ${var}
  const simpleEnvRegex = /\$(\w+)|\${(\w+)}/g
  // 匹配带默认值的 Bash 参数扩展: ${var:-default}
  const defaultValueRegex = /\$\{(\w+):-([^}]+)\}/g

  let convertedCmd = command

  // First, handle bash parameter expansion with default values
  // 3、处理带默认值的变量引用
  convertedCmd = convertedCmd.replace(
    defaultValueRegex,
    // 替换回调函数
    // match:整个匹配的字符串(如 ${PORT:-3000})
    // varName:正则捕获组 1 的值,即环境变量名(如 PORT)
    // defaultValue:正则捕获组 2 的值,即默认值(如 3000)
    (match, varName, defaultValue) => {
      // 优先用环境变量值,否则用默认值
      const value = env[varName] || defaultValue
      return value
    },
  )

  // 4、处理简单变量引用
  convertedCmd = convertedCmd.replace(
    simpleEnvRegex, 
    // 替换回调函数
    // match:整个匹配的字符串(如 $PATH 或 ${HOME})
    // $1:正则第一个捕获组的值,对应 $VAR 格式中的变量名(如 PATH)
    // $2:正则第二个捕获组的值,对应 ${VAR} 格式中的变量名(如 HOME)
    (match, $1, $2) => {
      // 从捕获组获取变量名($1 对应 $VAR,$2 对应 ${VAR})
      const varName = $1 || $2
      // 如果环境变量存在,返回 Windows 风格的 %VAR%,否则返回空字符串
      return env[varName] ? `%${varName}%` : ''
    })

  // 5、路径规范化(可选)
  return normalize === true ? path.normalize(convertedCmd) : convertedCmd
}

isWindows

function isWindows(): boolean {
  return (
    // 条件1:检测原生 Windows 系统
    process.platform === 'win32' ||
    // 条件2:检测 Windows 兼容环境(msys/cygwin)
    /^(msys|cygwin)$/.test(process.env.OSTYPE || '')
  )
}

process.platform:是 Node.js 内置的进程属性,用于返回当前操作系统的平台标识,不同系统对应固定值:

  1. Windows 系统(包括 Windows 10/11、Windows Server 等)返回 'win32'(注意:即使是 64 位 Windows,也返回 'win32',这是历史兼容设计);
  2. macOS 系统返回 'darwin'
  3. Linux 系统返回 'linux'

process.env.OSTYPE:是环境变量中存储的 “操作系统类型” 标识,常见于 Unix-like 环境或 Windows 兼容层(如 msys、cygwin):

  1. msys/cygwin:是 Windows 系统上的两款类 Unix 兼容层工具(可模拟 Linux/macOS 的命令行环境,如 Git Bash 基于 msys),它们会将 OSTYPE 设为 'msys''cygwin'
  2. 原生 Linux/macOS 中,OSTYPE 通常为 'linux-gnu''darwin',不会匹配此正则。

cross-env 适用场景

  1. 简单命令执行,不需要 shell 特性。
  2. 需要高性能执行的场景(不经过 shell 可以略微提高性能)。
  3. 安全敏感场景(避免 shell 注入风险)。

cross-env-shell 适用场景

  1. 需要使用 shell 特性(如管道 |、重定向 >、命令组合 && 等)。
  2. 需要执行复杂的 shell 脚本。
  3. 需要变量替换、通配符等 shell 功能。

React 移动端性能王者组合:懒加载 + 无限滚动,实战级深度解析

作者 栀秋666
2026年1月27日 18:21

引言

首屏卡顿?流量浪费?内存飙升?列表滚动像幻灯片?
别再让一次性加载毁掉你的用户体验了!

在移动端信息流场景中(如新闻资讯、社交动态、电商商品页),数据量大、图片密集是常态。但若处理不当,首屏加载慢、内存占用高、滚动卡顿等问题会直接劝退用户。

而真正的高性能体验,不是“堆硬件”,而是“精调度”。本文将带你深入 React 生态下两大核心优化技术——图片懒加载 + 无限滚动,从原理到实战,逐层拆解,手把手教你打造丝滑流畅的信息流页面。

💡 全文基于真实项目重构经验,所有代码可直接复用,Zustand + Intersection Observer + react-lazy-load 技术栈全链路打通,助你轻松冲上性能巅峰!


🌟 为什么你需要关注这两个技术?

问题 后果 懒加载 & 无限滚动的作用
首屏加载上百张图 白屏时间长、CLS 高 图片按需加载,首屏秒开
一次性拉取几千条数据 内存爆炸、React 渲染卡顿 分页加载,只渲染可视区域附近内容
用户滚动到底才发现分页按钮 交互割裂、体验差 自动触发加载,沉浸式浏览
浏览器主线程被 onscroll 占满 滚动卡顿、响应延迟 使用原生异步 API,不阻塞主线程

👉 一句话总结:懒加载管「资源」,无限滚动管「数据」,两者结合才是移动端高性能信息流的黄金搭档。


🛠️ 一、技术选型:轻量高效才是王道

我们坚持三个原则:轻量、高效、可复用。因此选择了以下技术组合:

技术 选择理由
Zustand 替代 Redux/Context,体积仅 1KB,无 Provider 嵌套,状态更新精准,避免无效重渲染
react-lazy-load 封装了 Intersection Observer 的图片懒加载组件,使用简单且兼容性好
Intersection Observer API 浏览器原生命令,运行在主线程之外,监听元素可见性无性能损耗
自定义 InfiniteScroll 组件 解耦业务逻辑,支持任意列表复用,防重复请求、防内存泄漏
Lucide React 图标库 轻量、可 Tree Shaking、图标丰富,适合移动端

✅ 所有依赖总包体积 < 5KB,真正实现“小身材大能量”。


🖼️ 二、图片懒加载:让首屏快如闪电

❌ 传统方式的三大痛点

  1. 所有 <img src="real-url"> 一上来就发起请求 → 首屏网络拥堵
  2. 图片加载前后容器高度变化 → 页面布局偏移(CLS 指标爆表)
  3. 用户根本没看到的图片也被加载 → 浪费流量 & 用户耐心

✅ 正确姿势:按需加载 + 占位控制 + 提前预判

核心实现:react-lazy-load + loading="lazy" 双保险

<LazyLoad className="w-full h-full" offset={100}>
  <img 
    loading="lazy" // 原生降级方案
    src={post.thumbnail}
    className="w-full h-full object-cover"
  />
</LazyLoad>

关键点解析:

特性 说明
offset={100} 提前 100px 开始加载,用户滑到时图已显示,实现“无感知加载”
loading="lazy" 浏览器原生懒加载属性,作为不支持 Intersection Observer 时的兜底方案
object-cover + 固定尺寸 容器 w-24 h-24 不随图片加载改变,防止布局偏移
条件渲染 {post.thumbnail && ...} 空值不生成 DOM,减少渲染负担

🚀 进阶优化技巧(提升体验细节)

技巧 效果
LQIP(低质量占位图) 先展示模糊小图,让用户感知内容正在加载
WebP/AVIF 图片格式 同等画质下体积减少 50%,CDN 可自动转换
取消监听机制 图片加载完成后自动卸载 observer,释放资源

⚠️ 注意:手动实现时务必在 onload 中调用 observer.unobserve(),否则会造成内存泄漏!


🔁 三、无限滚动:打造沉浸式内容消费体验

🤔 为什么要用无限滚动?

相比传统“点击加载更多”或“翻页”:

  • ✅ 减少操作步骤,提升浏览效率
  • ✅ 更符合移动端“一直往下刷”的直觉
  • ✅ 数据加载更平滑,体验更沉浸

但如果不加以控制,很容易引发:

  • ❌ 请求风暴(反复触发加载)
  • ❌ 内存泄漏(Observer 未清理)
  • ❌ 列表重复渲染

✅ 正确实现思路:哨兵元素 + 状态锁 + 资源回收

实现核心:InfiniteScroll 通用组件封装

import { useRef, useEffect } from "react";

interface InfiniteScrollProps {
  hasMore: boolean;
  isLoading?: boolean;
  onLoadMore: () => void;
  children: React.ReactNode;
}

const InfiniteScroll: React.FC<InfiniteScrollProps> = ({
  hasMore,
  isLoading = false,
  onLoadMore,
  children,
}) => {
  const sentinelRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!hasMore || isLoading) return;

    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          onLoadMore();
        }
      },
      { threshold: 0 }
    );

    if (sentinelRef.current) {
      observer.observe(sentinelRef.current);
    }

    return () => {
      if (sentinelRef.current) {
        observer.unobserve(sentinelRef.current);
      }
      observer.disconnect(); // 必须断开连接!
    };
  }, [onLoadMore, hasMore, isLoading]);

  return (
    <>
      {children}
      <div ref={sentinelRef} className="h-4" /> {/* 哨兵 */}
      {isLoading && <div className="text-center py-4">加载中...</div>}
      {!hasMore && <div className="text-center py-4">没有更多了</div>}
    </>
  );
};

export default InfiniteScroll;

🔍 核心设计亮点

设计 作用
sentinelRef 哨兵元素 触发加载的“开关”,不可见但能被监听
threshold: 0 元素刚进入视口即触发,响应及时
hasMore与isLoading 判断 防止重复请求和无效加载
useEffect 返回清理函数 防止内存泄漏,组件销毁后释放资源

🧠 四、状态管理:用 Zustand 实现精准控制

为了统一管理分页状态,我们使用 Zustand 创建全局状态:

// store/useHomeStore.ts
import { create } from 'zustand';
import { fetchPosts } from '@/api/posts';

interface HomeState {
  posts: Post[];
  page: number;
  loading: boolean;
  hasMore: boolean;
  loadMore: () => Promise<void>;
}

export const useHomeStore = create<HomeState>((set, get) => ({
  posts: [],
  page: 1,
  loading: false,
  hasMore: true,

  loadMore: async () => {
    if (get().loading) return; // 加载锁,防重复请求

    set({ loading: true });

    try {
      const { items } = await fetchPosts(get().page);

      if (items.length === 0) {
        set({ hasMore: false });
        return;
      }

      set({
        posts: [...get().posts, ...items],
        page: get().page + 1,
        loading: false,
      });
    } catch (err) {
      console.error('加载失败', err);
      set({ loading: false }); // 失败也要释放锁
    }
  },
}));

加载锁机制 是防止请求风暴的关键!任何情况下都要确保 loading 最终会被重置。


🧩 五、页面整合:Home 组件完整示例

export default function Home() {
  const { banners, posts, hasMore, loading, loadMore } = useHomeStore();

  // 首次加载第一页
  useEffect(() => {
    loadMore();
  }, []);

  return (
    <>
      <Header title="首页" />
      <div className="p-4 space-y-4">
        <SlideShow slides={banners} />
        
        {/* 文章列表 */}
        <div className="container mx-auto py-8">
          <h1 className="text-2xl font-bold mb-6">文章列表</h1>

          <InfiniteScroll
            hasMore={hasMore}
            isLoading={loading}
            onLoadMore={loadMore}
          >
            <ul>
              {posts.map((post) => (
                <PostItem key={post.id} post={post} />
              ))}
            </ul>
          </InfiniteScroll>
        </div>
      </div>
    </>
  );
}

📌 关键整合逻辑:

  • useEffect 初始化加载第一页
  • key={post.id} 保证 React 列表 diff 高效
  • InfiniteScroll 接收状态,自动控制加载提示和结束标识

实现效果:

1.当滑动到文章列表最下端时: image.png 2.当继续往下滑时: image.png 页面会自动刷新,此时文章列表加长 当一直往下滑,滑倒文章全部显现时:

image.png 此时已经到底部,显示没有更多了


⚠️ 六、常见坑点 & 解决方案(避雷必看)

问题 原因 解决方案
重复请求 / 请求风暴 未判断 loadingthreshold 设置不合理 添加加载锁,合理设置阈值(建议 01
内存泄漏 组件卸载后仍监听哨兵元素 useEffect 返回中调用 unobservedisconnect
布局偏移(CLS 高) 图片容器无固定尺寸 设置宽高 + object-cover + 占位图
懒加载失效 直接写了 src 或未包裹组件 确保由懒加载组件控制 src 加载时机
移动端兼容性差 旧浏览器不支持 Intersection Observer 使用 loading="lazy" 降级 + polyfill(可选)

🏆 七、性能优化总结:六大核心要点

优化方向 具体措施
资源调度 图片懒加载 + 数据分页加载
监听性能 使用 Intersection Observer 替代 onscroll
内存安全 组件卸载时清理 Observer
用户体验 提前加载(offset)、占位图、无闪烁过渡
状态精准 Zustand 管理 loading、page、hasMore
可维护性 封装通用组件,业务层只关心数据和 UI

✅ 八、结语:这才是现代 React 移动端该有的样子

通过本文的实践,你应该已经掌握:

✅ 如何用 react-lazy-load 实现高性能图片懒加载
✅ 如何封装一个防重复、防泄漏的 InfiniteScroll 组件
✅ 如何用 Zustand 实现优雅的状态管理
✅ 如何规避移动端常见的性能陷阱

这些方案已在多个生产项目中验证,无论是资讯类 App、社交 Feed 流还是电商商品页,均可直接复用。


📣 最后呼吁:别再写“一次性加载”的代码了!

如果你还在这样做:

useEffect(() => {
  fetchAllData(); // 拉取几千条
}, []);

那你真的该停下来思考一下:你在为谁牺牲性能?

从今天起,拥抱“按需加载”的理念,用懒加载 + 无限滚动,给用户一个更快、更稳、更省流量的移动体验。

CSS-如何在 Chrome 中实现小于 12px 的字体?

2026年1月27日 18:12

前言

在前端开发中,设计师有时会给出 10px 甚至更小的字体设计。然而,Chrome 等 Chromium 核浏览器为了保证中文的可读性,默认限制了最小字号为 12px。本文将带你梳理解决这一限制的几种方案及其优缺点。

一、 为什么有这个限制?

背景: Chrome 中文版浏览器默认设定最小字号为 12px。这是因为在早期的显示器上,复杂的汉字如果小于 12px,笔画会由于像素点不足而挤在一起,导致难以辨认。虽然现在的视网膜屏幕(Retina)已经不存在这个问题,但这一规范被保留了下来。


二、 核心解决方案

1. 推荐方案:Transform 缩放

这是目前兼容性最好、也是最主流的方案。通过 transform: scale() 对元素进行整体缩放。

  • 实现思路

    先设置字体为 12px,然后使用缩放属性将其缩小。

  • 核心代码

    .small-font {
      font-size: 12px;
      display: inline-block; /* 注意:transform 仅对块级或行内块元素生效 */
      transform: scale(0.8333); /* 10px = 12px x 0.8333 */
      transform-origin: left center; /* 关键:设置缩放中心,否则文字会向中心靠拢 */
    }
    
  • 优点:兼容性极佳,视觉效果平滑。

  • 缺点:缩放会占据原有的 12px 空间,可能导致边距看起来变大,需要手动调整偏移。


2. SVG 矢量缩放方案

如果你需要极其精准的字体渲染,可以利用 SVG 的 text 标签。

  • 实现思路

    SVG 内部的文字不受浏览器最小字号限制。

  • 核心代码

    <svg width="100" height="20">
      <text x="0" y="15" font-size="10" fill="#000">我是10px文字</text>
    </svg>
    
  • 优点:渲染清晰,完全不受浏览器限制。


三、 已失效或不推荐的方案(避坑指南)

1. -webkit-text-size-adjust (已废除)

  • 现状:Chrome 27 以后已经废除。

2. Zoom 方案

  • 现状zoom 本质上是非标准的属性。
  • 副作用Firefox 完全不支持。且 zoom 会引起复杂的重绘和重排,性能弱于 transform

四、 总结与最佳实践

方案 推荐指数 优点 缺点
Transform Scale ⭐⭐⭐⭐⭐ 兼容性高,性能好 需要处理 transform-origin 和空间占位
SVG 渲染 ⭐⭐⭐ 绝对精准 编写略显繁琐,不适合大段文字

3年历程,这是OpenTiny的逐光之旅!

2026年1月27日 18:09

1、前言

2025年,前端智能化转型的浪潮来得又快又猛,作为深耕企业级前端开源的一员,OpenTiny始终抱着“让前端开发更高效、更智能”的初心,完成了从组件生态到智能开发平台的大跨越。这一年,我们最亮眼的里程碑,就是OpenTiny NEXT前端智能化解决方案的上线,同时在技术创新、生态搭建、活动联动和影响力扩散上都交出了不错的答卷,和全球开源开发者们一起,朝着前端智能化的方向迈进。

一晃三年,OpenTiny已经在开源路上走了三年。这三年,是打磨技术的三年,是和社区小伙伴们并肩共建的三年,更是陪着开发者们一起成长的三年——我们更愿意把这段旅程,称作OpenTiny的“逐光之旅”。一路上,我们守着长期主义的初心不跑偏,也跟着行业变化灵活调整,从摸着石头过河的探索者,慢慢长成了能汇聚数千开发者的开源生态大家庭。 OpenTiny的开源路,是从一套前端组件库起步的,但我们从来不是单打独斗的项目,而是一群“小伙伴”并肩撑起生态。最开始的跨端、跨框架、跨版本TinyVue组件库,成为了开发者手里顺手的基础工具;服务华为内部多个业务的TinyNG(Angular)组件库,也在为Angular框架开发者提供更多选择。还有TinyCli前端脚手架、TinyPro中后台模板、TinyTheme主题定制工具,从开发、部署到视觉美化,一步步搭起了全流程提效的工具链。后来,我们又把TinyEngine低代码引擎、TinyCharts图表库纳入开源大家庭,接着推出TinyEditor富文本组件、TinySearchBox选择器组件,迭代优化TinyProVue中后台系统,慢慢从单纯的组件库,升级成了一站式前端开发解决方案。 

每一个项目的成长迭代,都藏着团队小伙伴和所有开源贡献者的用心付出。大家日夜打磨优化,既要帮业务前端少走弯路、提升效率,又在悄悄沉淀可复用的基础能力,为生态的壮大铺好路。也正是这三年的积累沉淀,才让2025年OpenTiny NEXT的重磅发布有了底气,让前端智能化的突破,有了足够扎实的技术和生态支撑。

image.png

2、启幕:探索与开发者之间的距离,我们向阳而生!

2023年,是OpenTiny开源故事的正式开篇,这一年,我们秉持“帮助开发者高效开发Web应用”的愿景,迈出了开源探索关键的一步。

2 月,OpenTiny GitHub 仓库迎来了第一条 commit,如同播下一颗希望的种子,开启了技术深耕的征程。

4 月,我们首次走出线上社区,参与线下技术活动,华为云前端技术专家华宇果老师在东莞HDC开发者日活动上为大家演示基于OpenTiny简单四步完成一个智能制造设备页面开发配置,第一次与开发者面对面交流,倾听真实需求,为项目方向锚定了用户导向的核心。

7 月 8 日,在华为开发者大会上,OpenTiny 正式宣布开源,向业界呈现了跨端、跨框架的技术理念,这一刻成为开源历程中极具里程碑意义的节点。

9 月,TinyEngine 低代码引擎紧随其后正式开源,进一步丰富了项目生态,为开发者提供了从组件库到低代码开发的更多可能。

这一年,我们在技术打磨与社区建设中同步发力。TinyVue 组件库全年发布 11 个版本,不断优化组件性能与体验;11 月,TinyVue 单仓库成功突破 1000+ Star,成为社区认可的重要标志。截至年末,OpenTiny 已汇聚 60+ 开源共建者,社群用户突破 1700 人,公众号粉丝达 3000+,开源 PR 数累计 1.1k+ 条,初步构建起充满活力的社区雏形。

这一年我们势如破竹,在开源道路上探索各种可能性,通过18 场外部活动、90+ 篇技术文章与 18+ 条视频内容,让更多开发者了解 OpenTiny,技术传播总浏览量超50万次。同时我们也获得了OSCHINA“2023年度优秀开源技术团队”、“开源原子2023年快速成长开源项目”、中国信通院“可信开源项目”认证、掘金“2023年度人气创作团队”、并入选“2023中国开源开发者报告等荣誉。

2.PNG

虽然整个过程看起来很顺利,但在社区稳步搭建的过程中,我们也经历了一次让团队成长的小插曲——2023年9月TinyEngine低代码引擎开源后,凭借独特的技术定位吸引了大量前端开发者关注,社群内频繁出现“华为开源的低代码项目核心优势是什么”“能否落地企业实际项目、真正实现提效”等讨论,多元声音的碰撞本是开源社区的活力所在,我们始终鼓励开发者通过提问、提建议的方式参与项目共建。不过,当部分讨论逐渐呈现出持续的质疑性表达时,负责社区运营的我因担心项目受到不当影响,陷入了认知偏差,误将开发者对技术的极致追求解读为针对性抹黑。在一次相关讨论中,我情绪上头,利用管理员权限将这位持续发声的开发者移出了社群。事后,项目负责人Kagol与我沟通,提醒我开源社区需要包容不同声音,开发者的尖锐提问本质上是对项目的关注与期待。在这之后,我也迅速意识到问题所在:我不该仅凭主观猜测做出移除社群的草率决定。随后,我主动联系到这位开发者,诚恳表达了歉意,坦诚了作为开源运营者的顾虑——OpenTiny作为纯公益开源项目,核心目标是通过技术服务开发者、助力项目提效,始终无任何商业属性掺杂。

3.JPG

这次插曲也让我展开了深刻的反思:本质上,这是我们尚未真正走进开发者、未能充分理解社群用户需求的体现。基于这份反思,我们明确了“深度贴近开发者”的方向,这也为2024年密集举办编程大赛、深化社群互动奠定了基础。

3、成长:探贴近开发者,凝聚点点微光!

2024 年,OpenTiny 进入生态加速拓展期,在技术迭代、产品矩阵完善与社区影响力提升上实现全方位突破。TinyVue 组件库持续精进,全年优化 8 个大版本,新增 232 个新特性,修复 184 个 bug,用更稳定、更丰富的功能回馈开发者信赖。TinyEngine 低代码引擎迎来重要升级,不仅发布私有化部署方案,更重磅推出 2.0 版本,采用包引入模式与 CLI 工具,创新推出洛书架构,大幅提升低代码开发的灵活性与扩展性。产品矩阵不断丰富是这一年的重要亮点。7 月,TinyCharts 图表库正式开源;8 月,TinyEditor 富文本组件全新上线,与原有组件库、低代码引擎形成互补,构建起更完整的企业级前端开发解决方案。官网也完成 2.0 版迭代优化,修复 50+ 问题,月均访问量达 20 万+,月均访问人数 1万+,成为开发者获取信息、学习使用的核心阵地。社区共建氛围愈发浓厚。我们成功举办 OpenTiny 前端 Web 应用开发挑战赛、开源之夏 2024、CCF开源创新大赛 等多项编程活动,以 30 万奖金激励开发者深度参与共建;同时我们还走进华为云开发者日、HDC 开发者大会等多个线下场景,主办技术交流茶话会与大前端分论坛,与全国各地开发者近距离沟通,在深度交流中精准get到大家的需求和真实反馈。4.PNG这一年,我们一直把开发者需求放在重要位置,通过用户调研、线下交流等多元渠道,梳理出自身发展中的待优化之处:在年初的用户调研中,就有很多开发者提出了”组件库 UI 美观度有待提升,认为现有风格更适配硬件系统页面,视觉上要更潮一点,整体视觉呈现需进一步迭代”;在HDC开发者大会上,也有熟悉的开发者朋友反馈“ GitHub上的issue响应和问题解决效率得提一提”;还有参与开源之夏的学生小伙伴问“TinyEngine低代码引擎的学习资料在哪找,想系统学学”。这些反馈对我们来说太宝贵了!,之后我们也随即成立专项小组推进整改:针对 UI 美观度问题,我们联动设计团队完成官网整体改版,升级为 2.0 动态版本,同时持续通过用户调研收集反馈,细化优化各类视觉细节;针对技术支持效率,我们建立 GitHub issue 定期响应机制,确保问题得到及时跟进与处理;针对低代码引擎知识普及,我们与黄大年茶思屋合作,定制推出 TinyEngine 低代码引擎系列课程,系统拆解技术要点,帮助更多开发者深入理解项目。此外,我们全年输出 100+ 篇技术文章,开展多场系列直播课,持续沉淀技术资产,让开源价值触达更多开发者。这一年,已有赛意信息、观测云、DataCap 等多家企业开始采用 OpenTiny 项目,生态影响力持续扩大。5.PNG

4、传承:沐光而行拥抱变化,从吸收能量到释放光芒!

经过两年的积累与沉淀,2025 年的 OpenTiny 已然成长为更成熟、更具口碑的开源项目,但是我们不仅止步于此!2025年是AI Agent元年,我们拥抱时代浪潮,想在现有基础上做一些新的突破~ 于是,OpenTiny NEXT 前端智能化解决方案就“破土而出”了!在AI技术百花齐放的当下,让所有OpenTiny的开源开发者有了一个新的方向和目标。还记得3月份的时候,团队大胆提出“让智能体助力员工高效完成任务”的设想,还定下了6月HDC大会向开发者们呈现全新智能化方案的小目标。当然啦,传统技术积淀与前沿AI创新的融合之路,其实充满了挑战,团队也历经多轮研讨、演练与验证,反复打磨优化,甚至多次推翻原有思路重新出发,最终才确定了“基于Web MCP + 生成式UI”的核心技术方案。现在,OpenTiny NEXT 前端智能化解决方案已经在官网和大家见面啦!不过智能化探索本就是一场持久战,前行之路虽充满挑战,但我们满怀热忱,也期待有更多开发者小伙伴加入进来,和我们一起把这个项目打磨得更完善、更强大!

当然在此过程中,更让我们暖心的是,这一年社区里还涌现出了超多温暖又有力量的身影!比如啸哥,一开始是TinyEngine的使用者,后来逐渐通过这个项目在社群中与大家展开积极讨论,主动成为了贡献者,之后还在开源之夏活动中担任TinyEngine运行时渲染的导师,把自己的经验毫无保留地分享给小伙伴们;还有GaoNeng-wWw老师,作为陪伴OpenTiny走过三年的核心贡献者,经常主动参与社区的技术直播,用生动易懂的讲解帮更多开发者快速了解项目;还有zzxming老师,作为TinyEditor的核心贡献者,从TinyEditor刚开源就参与贡献,为TinyEditor贡献了大量新特性和优化,用他的专业精神和无私奉献,为项目的持续健康发展奠定了坚实基础;当然啦,徐新宇、李小雨、陈胜老师也超棒,都是各自领域的佼佼者,一直在为社区添砖加瓦;还有观默老师,在使用TinyEngine的过程中,结合自己的实战经验,分享了很多优质的技术文章,让更多人get到项目的价值。真的很庆幸,我们能在OpenTiny社区相遇,大家在这里汲取成长的能量,也用自己的光和热照亮更多人~6.PNG与此同时,我们一直把开发者的需求放在首位!这一年,很多朋友反馈想通过更多形式了解“如何用TinyEngine进行二次开发”。我把大家的诉求仔细整理好后,第一时间同步给了TinyEngine的项目负责人。项目团队也特别重视,快速对需求做了优先级排序,在2025年专门为大家推出了视频版本的TinyEngine二开教程~ 正是因为社区开发者和团队小伙伴们的共同付出,也让OpenTiny项目在行业中获得了一些认可,在社区中凝聚更强的力量!例如:在第一季度,OpenTiny被黄大年茶思屋评委Q1季度分享之星,第二季度在内部社区被评上知识管理创新奖,第三季度TinyVue项目在内源社区被评为内源活力项目奖,第四季度获得了开源中国共创社区的荣誉称号及茶思屋分享之星。

5、结语

三年时光,OpenTiny 从一颗种子成长为枝繁叶茂的生态之树,离不开每一位共建者的代码贡献、每一位用户的反馈建议、每一位支持者的默默关注。从组件库到低代码引擎,从技术探索到生态共建,我们始终坚守开源初心,以技术创新赋能前端开发。站在三周年的新起点,OpenTiny 未来将继续深耕技术,完善产品矩阵,拓展生态边界;持续拥抱社区,为开发者提供更优质的服务与支持。开源之路,道阻且长,行则将至。期待未来有更多志同道合的开发者加入我们,添加微信小助手:opentiny-official 一起参与交流前端技术~一同书写 OpenTiny 更精彩的开源新篇章!

👉 扫码关注 OpenTiny 公众号,获取最新技术动态与活动资讯
👉 访问 OpenTiny 官网,探索企业级前端开发解决方案
👉 前往 GitHub 仓库,Star 支持我们,参与共建开源生态
如果你也想要共建,可以进入代码仓库,找到 good first issue 标签,一起参与开源贡献~

Hooks 使用注意事项及来龙去脉

作者 sophie旭
2026年1月27日 18:07

背景

前几篇文章已经聊了Hooks 的意义以及如何实现的,那么现在我们来看下 我们平时 使用 hook的一些注意事项,也就是我们说的 “心智负担”,但是当我们理解它背后的实现原理后,也许 这种负担会大大减轻吧

为什么Hooks必须在函数组件顶层调用,不能写在条件语句中?

前置知识

我们知道 hook链表绑在了fiber上了,那么下次 更新时,首先要找到对应的旧fiber对吗?那怎么找呢??

正确的「新旧 Fiber 树构建 + alternate 绑定」流程

以「首次渲染后,点击 setCount 触发更新」为例,完整步骤:

步骤 1:首次渲染后,先建立“旧树(Current)”
  • 首次渲染时,React 从根节点开始,为每个组件(根→App→Button 等)创建全新的 fiber 节点,构建出「Current Fiber 树」(旧树);
  • 此时每个 fiber 节点的 alternate 为 null(无新树);
  • 根 Fiber 的 current 指向这棵旧树。
步骤 2:触发更新后,创建新树(WorkInProgress)的“骨架”

React 不会复制旧树,而是:

  1. 从根 Fiber 开始,遍历旧树(Current)的节点(按 child/sibling 层级);
  2. 对每个旧 fiber 节点,执行「调和(Reconciliation)」:
    • 如果组件类型未变(且key也没变)(比如还是 <App />):确认结构没变化,创建新的 workInProgress fiber 节点,(只复用旧节点的“结构信息”,比如层级、类型,不复制状态);
    • 如果组件类型变了(比如 <App /> 变成 <Other />):丢弃旧节点,创建全新的新节点;
    • 如果组件被卸载:不创建新节点;
  3. 关键:为新创建的 workInProgress fiber 绑定 alternate 指针 → workInProgress.alternate = 旧 fiber(同时旧 fiber.alternate = workInProgress,双向绑定)。

举例说明:

假设旧 fiber(Current)是这样的:

// 旧 fiber(Current)- App 组件
const oldFiber = {
  // 结构信息(静态)
  type: App, // 组件类型是 App 函数
  key: null,
  return: rootFiber, // 父节点是根 fiber
  child: buttonFiber, // 子节点是 Button 组件的 fiber
  sibling: null, // 无兄弟节点
  
  // 状态信息(动态)
  memoizedState: { memoizedState: 0, queue: null }, // Hook 链表(count=0)
  updateQueue: null, // 副作用队列
  props: { name: "旧名称" } // 父组件传的旧 props
};

当创建新 fiber(workInProgress)时,React 做的是:

// 新 fiber(workInProgress)- App 组件
const newFiber = {
  // 1. 复用旧 fiber 的「结构信息」(直接抄,不用重新判断)
  type: oldFiber.type, // 复用 type: App
  key: oldFiber.key, // 复用 key: null
  return: oldFiber.return, // 复用父节点:rootFiber
  child: null, // 先留空,后续调和子节点时再复用旧 child 的结构
  sibling: oldFiber.sibling, // 复用兄弟节点:null
  
  // 2. 「状态信息」完全清空/重新计算(不复用旧值)
  memoizedState: null, // 清空 Hook 链表(后续通过 renderWithHooks 重新构建)
  updateQueue: null, // 清空副作用队列(后续重新收集)
  props: { name: "新名称" } // 用本次父组件传的新 props(不复用旧 props)
};

// 3. 绑定 alternate 指针(新→旧)
newFiber.alternate = oldFiber;
oldFiber.alternate = newFiber;

👉 核心看差异:

  • 结构信息(type/return/key):直接从旧 fiber 拿,不用重新判断“这个组件是不是 App?它的父节点是谁?”——省掉了重复的层级/类型判断成本;
  • 状态信息(memoizedState/updateQueue):完全清空,后续执行 renderWithHooks 时,再从旧 fiber 的 memoizedState 读取 Hook 状态、计算新状态,最后填充到新 fiber 中。
步骤 3:遍历新树骨架,执行渲染函数填充内容

React 遍历刚创建的「WorkInProgress 新树骨架」,对每个函数组件节点:

  1. 调用 updateFunctionComponent → 进而调用 renderWithHooks
  2. 执行组件的渲染函数(比如 App()),触发 Hook 调用;
  3. 通过 workInProgress.alternate 找到旧 fiber,读取旧 Hook 状态,计算新状态;
  4. 把新状态填充到 workInProgress fiber 的 memoizedState(Hook 链表)中,完成新树的“内容填充”。
步骤 4:提交新树,替换旧树

渲染完成后,React 把「WorkInProgress 新树」提交到 DOM,然后:

  • 根 Fiber 的 current 指向这棵新树(它变成了下一次更新的“旧树”);
  • 原来的旧树被标记为可回收,等待 GC。

总结:

  • React 找旧 fiber 的最终“快捷方式”是 workInProgress.alternate
  • 双树的核心价值是“增量更新”——不用每次重新创建完整 fiber 树,只需基于旧树更新变化的部分;
  • alternate 指针是双树关联的“物理纽带”,保证了旧状态能被精准找到。

旧fiber找到了,那么hook呢?新hook和旧hook的关系是??

首先旧hook链表从哪里来?其实我们可以理解为旧fiber的hook

第一步:先看错误代码(Hook 放条件里)

function Form() {
  const [name, setName] = useState(""); // Hook1
  
  if (name) { // 条件:name有值时才执行Hook2
    const [phone, setPhone] = useState(""); // Hook2
  }
  
  const [submit, setSubmit] = useState(false); // Hook3
  return <div>{/* 渲染 */}</div>;
}

第二步:首次渲染(name 为空,条件不满足)

  • 执行顺序:Hook1(name)→ Hook3(submit);
  • 旧 Hook 链表(current fiber):Hook1(name)→ Hook3(submit)(共2个节点);
  • 指针记录:遍历到第2个节点(Hook3)结束。

第三步:更新渲染(输入name,条件满足)

此时用户输入了 name,if (name) 为 true,Hook 执行顺序变了:

  • 执行顺序:Hook1(name)→ Hook2(phone)→ Hook3(submit);
  • React 开始遍历旧链表(只有2个节点):
    1. 本次第1个 Hook(name)→ 匹配旧链表第1个节点(name)→ 正常;
    2. 本次第2个 Hook(phone)→ 匹配旧链表第2个节点(submit)→ 错位! phone 会读取到 submit 的旧状态(false);
    3. 本次第3个 Hook(submit)→ 旧链表已无节点(只有2个)→ React 直接报错(Hook 数量不匹配)。

第四步:关键补充(不只是“遍历拿”,还有“指针移动”

你说的“依次遍历之前的旧hook链表拿到一个,返回一个”,可以再补全一点: React 不是“一次性遍历旧链表”,而是 每调用一个新 Hook,就把旧链表的指针往后移一位 -- 重点,好好理解!!!!

  • 调用第1个新 Hook → 取旧链表第1个节点,指针移到第2个;
  • 调用第2个新 Hook → 取旧链表第2个节点,指针移到第3个;
  • ……

条件语句会让“新 Hook 的数量/顺序”和“旧链表的节点数量/顺序”对不上,指针一错位,状态就张冠李戴;数量对不上的话,React 直接抛出警告(Hook 调用数量不一致)。

大致步骤如下:

整个过程和 next 复制无关,完全由「本次 Hook 调用次数」驱动,步骤如下(结合源码简化版):

步骤 1:初始化 currentHook 指向旧链表头

更新渲染时,先把 currentHook 指向旧 fiber 的 Hook 链表头(这是唯一和旧链表 next 相关的一步,但只是“读取头节点”,不是复制):

// 初始化:currentHook 指向旧链表头(靠旧链表的 next,但只取头节点)
let currentHook = current !== null ? current.memoizedState : null;
// 新 Hook 链表头初始为空(不复制任何 next)
let workInProgressHook = null;
步骤 2:每调用一个 Hook,手动移动 currentHook 找下一个旧 Hook

每次执行 updateState(比如 useState),React 会:

  1. 当前的 currentHook 创建新 Hook(复制 memoizedState/queue);
  2. 手动执行 currentHook = currentHook.next——这就是“找到下一个旧 Hook”的核心操作;
  3. 新 Hook 的 next 设为 null,后续按本次顺序手动拼接(而非复制旧 next)。因为旧next指向的是就hook,我们说过每次都会重新创建新hook,所以应该新 Hook 的next永远指向下一个新创建的 Hook
完整代码演示(顺序一致的场景):
// 旧链表:Hook1(name)→ Hook2(phone)→ Hook3(submit)(旧 next 正常)
let currentHook = oldHook1; // 初始化指向旧链表头

// 第1次调用 useState(Hook1)
function updateState() {
  // 用当前 currentHook(oldHook1)创建新 Hook1(复制 memoizedState/queue)
  const newHook1 = createWorkInProgressHook(currentHook);
  newHook1.next = null; // 不复制 oldHook1.next(oldHook1.next 是 oldHook2)
  
  // 手动移动 currentHook → 找到下一个旧 Hook(oldHook2)
  currentHook = currentHook.next; 
  
  workInProgressHook = newHook1; // 新链表头指向 newHook1
}

// 第2次调用 useState(Hook2)
function updateState() {
  // 用当前 currentHook(oldHook2)创建新 Hook2
  const newHook2 = createWorkInProgressHook(currentHook);
  newHook2.next = null; // 不复制 oldHook2.next(oldHook2.next 是 oldHook3)
  
  // 手动移动 currentHook → 找到下一个旧 Hook(oldHook3)
  currentHook = currentHook.next; 
  
  // 手动拼接新链表的 next(本次顺序)
  workInProgressHook.next = newHook2;
  workInProgressHook = newHook2;
}

// 第3次调用 useState(Hook3)
function updateState() {
  // 用当前 currentHook(oldHook3)创建新 Hook3
  const newHook3 = createWorkInProgressHook(currentHook);
  newHook3.next = null;
  
  // 手动移动 currentHook → 旧链表结束(null)
  currentHook = currentHook.next; 
  
  // 手动拼接新链表的 next
  workInProgressHook.next = newHook3;
  workInProgressHook = newHook3;
}
关键:找下一个旧 Hook 靠的是 currentHook = currentHook.next,而非新 Hook 的 next
  • 旧链表的 next 只被 currentHook 用来“移动指针”,但新 Hook 完全不复制这个 next
  • 新 Hook 的 next按本次调用顺序手动拼接的,和旧 Hook 的 next 无关;
  • 哪怕旧链表的 next 异常(比如指向 null/僵尸节点),currentHook 移动时能立刻发现,及时报错,而复制旧 next 会把异常带入新链表。

通俗比喻:像“按顺序领快递”

把旧 Hook 链表比作“快递柜的格子”(按顺序编号1、2、3…),每次渲染领快递:

  • 正常情况:每次都按1→2→3的顺序领,格子里的快递(状态)永远对应你的 Hook;
  • 条件语句:第一次领1→3(跳过2),第二次领1→2→3——此时领2号格子时,里面是原本3号的快递(submit的状态),领3号时格子空了,直接报错。

总结

Hook 不能放条件语句里的核心原因:

  1. React 更新时,会按本次 Hook 调用顺序,逐个匹配旧 Hook 链表的节点(指针逐次后移);
  2. 条件语句会导致本次 Hook 的调用顺序/数量,和旧链表的节点顺序/数量不一致;
  3. 最终要么状态错位(张冠李戴),要么直接报错(数量不匹配),完全破坏 Hook 状态和组件的绑定关系。

关于useEffect

useEffect副作用函数啥时候执行?

一、先搞懂核心概念:React 工作的三大步骤

文章里提到的 React 工作流程是理解所有问题的基础,先把这个核心框架记住:

  1. 调度器:决定「什么时候更新」(比如用户点击、数据请求完成触发更新)

    调度器就是 React 的「时间管家」,它判断 “什么时候该干活”。比如你点击按钮想改页面内容,调度器会说 “现在可以开始更新了”;再比如请求接口拿到数据后,调度器会说 “数据到了,该更新页面了”。

  2. 协调器:决定「更新什么内容」(生成 Fiber 树,给需要操作的 Fiber 打标记,构建 effectList)

    协调器是 React 的「规划师」,它不管 “什么时候干”,只管 “干什么”。

  3. 渲染器:执行「实际的更新操作」(遍历 effectList,执行 DOM 插入/更新、useEffect 回调等)

    渲染器是 React 的「打工人」,它照着协调器列的 effectList 清单干活 —— 遍历链表,看到「Placement」标记就插 DOM,看到「Passive」标记就执行 useEffect 回调。

其中,Fiber 可以简单理解为「带标记的虚拟 DOM 节点」,

可以理解为「带备注的虚拟DOM树」,每个组件对应一个 Fiber 节点;
打标记:比如某个组件需要新增 DOM,就给它的 Fiber 打「Placement(插入)」标记;某个组件有 useEffect 要执行,就打「Passive」标记;

effectList 是「需要执行副作用(比如 DOM 操作、useEffect)的 Fiber 链表」。

 effectList:把所有“有标记要干活”的 Fiber 节点串成一个链表
(比如“Child要执行useEffect → Parent要执行useEffect → App要执行useEffect”),方便后续挨个处理。

二、第一个问题:useEffect 的执行顺序(child -> parent -> app)

核心逻辑:effectList 的构建在「归阶段」

React 协调器处理组件树时,分「递」和「归」两个阶段:

  • 递阶段:从根组件(App)向下遍历到叶子组件(Child),相当于「从上到下找要更新的组件」;
  • 归阶段:从叶子组件(Child)向上回到根组件(App),相当于「从下到上给找到的组件打标记、构建 effectList」。

而 useEffect 对应的 Fiber 会被打上 Passive 标记,这个标记的添加、effectList 的构建都发生在「归阶段」。

所以 effectList 的顺序是:Child → Parent → App,渲染器遍历 effectList 执行 useEffect 回调时,自然也是这个顺序,控制台就会先打印 child,再 parent,最后 app

举个通俗例子

你可以把组件树想象成「爷爷(App)→ 爸爸(Parent)→ 儿子(Child)」:

  • 递阶段:爷爷先喊爸爸,爸爸再喊儿子,确认大家是否需要更新;
  • 归阶段:儿子先报告「我要执行 useEffect」,然后爸爸报告,最后爷爷报告;
  • 执行时:按「儿子→爸爸→爷爷」的顺序执行 useEffect 回调。

三、第二个问题:useEffect([]) 和 componentDidMount 调用时机不同

表面相似,底层逻辑不同
  • 相似点:两者都只在「组件挂载(mount)」时执行一次(useEffect 因为 deps 为空,不会触发更新);
  • 核心不同:执行时机的「同步/异步」差异:
    1. componentDidMount:属于类组件的生命周期钩子,在「DOM 渲染完成后同步执行」(渲染完立刻执行,阻塞后续代码);
    2. useEffect([]):属于 Passive Effect,React 会在「DOM 渲染完成后,把所有 Passive Effect 的回调放到异步队列里执行」(不阻塞主线程,浏览器先绘制页面,再执行回调)。
更贴近 componentDidMount 的是 useLayoutEffect

useLayoutEffect 的回调会在「DOM 渲染完成后同步执行」,和 componentDidMount 时机几乎一致;而 useEffect 是异步执行,这也是为什么有时用 useEffect 操作 DOM 会看到「一闪而过」的视觉问题(页面先渲染,再执行回调修改 DOM),而 useLayoutEffect 不会。

四、useEffect 完整执行流程(结合文章)

  1. 组件触发更新 → 协调器执行 FunctionComponent,遇到 useEffect 检查 deps 是否变化;
  2. 如果 deps 变化 → 给当前组件的 Fiber 打上 Passive 标记;
  3. 协调器在「归阶段」构建 effectList(顺序:叶子→根);
  4. 渲染器遍历 effectList:
    • 先执行所有 useEffect 的「销毁函数」(即回调的返回值);
    • 再执行所有 useEffect 的「回调函数」(create);
  5. 整个过程在页面渲染后异步完成。

总结

  1. useEffect 执行顺序:由 effectList 决定,而 effectList 构建于「归阶段」,所以是「子组件 → 父组件 → 根组件」;
  2. useEffect 执行时机:DOM 渲染后异步执行,和类组件的 componentDidMount(同步执行)时机不同,useLayoutEffect 才是同步执行,更贴近 componentDidMount;
  3. 核心思路:理解 useEffect 不要依赖「生命周期类比」,要从 React「调度→协调→渲染」的底层流程出发,核心是 effectList 的构建和遍历逻辑。

依赖数组传空数组 [] 和完全不写依赖 的区别

写法 deps 变化判断逻辑 Passive 标记时机 执行次数 底层本质
useEffect(() => {}, []) 仅首次挂载时,因“无旧 deps”视为 deps 变化;后续更新时,新旧 deps 都是空数组,判定为“无变化” 首次挂载的调和阶段,给 Fiber 打 Passive 标记 只执行 1 次(组件挂载时) 对应“仅 mount 执行”,和 componentDidMount 逻辑对齐
useEffect(() => {}) 完全不判断 deps 变化,React 视为“每次更新都有变化” 每次组件更新的调和阶段,都给 Fiber 打 Passive 标记 组件每次渲染/更新都执行(包括首次挂载) 对应“mount + 每次 update 执行”,和 componentDidUpdate 无依赖版对齐

结合底层流程,拆解差异根源

我们还是以 App → Parent → Child 组件为例,结合调和阶段的递/归流程,看两种写法的执行逻辑:

1. 写法1:useEffect(() => {}, [])(空数组依赖)
  • 递阶段
    • 首次挂载:执行组件时,检查 deps(无旧 deps)→ 判定“变化”,记录 needPassiveEffect = true
    • 后续更新(比如 Parent 组件 setState):执行组件时,对比新旧 deps(都是 [])→ 判定“无变化”,记录 needPassiveEffect = false
  • 归阶段
    • 首次挂载:因 needPassiveEffect = true → 打 Passive 标记,加入 effectList → commit 阶段执行回调;
    • 后续更新:因 needPassiveEffect = false → 不打 Passive 标记,不会加入 effectList → 回调不执行;
  • 最终表现:仅组件首次挂载时执行 1 次,后续无论父/子组件怎么更新,都不再执行。
2. 写法2:useEffect(() => {})(无依赖数组)
  • 递阶段: React 对“不写 deps”的处理逻辑是——跳过 deps 对比,直接判定为“变化”,无论首次挂载还是后续更新,都记录 needPassiveEffect = true
  • 归阶段: 每次组件更新的调和阶段,都会给 Fiber 打 Passive 标记,加入 effectList;
  • 最终表现
    • 首次挂载执行 1 次;
    • 只要组件自身/父组件触发更新(比如 Parent 改 state 导致 Child 重渲染),该 useEffect 就会重新执行;
    • 极端情况:如果父组件频繁 setState,这个 useEffect 会被频繁触发,甚至导致性能问题。

实战例子:直观看到差异

function Child() {
  // 写法1:空数组依赖
  useEffect(() => {
    console.log('空数组依赖:Child 执行');
  }, []);

  // 写法2:无依赖数组
  useEffect(() => {
    console.log('无依赖:Child 执行');
  });

  return <p>Child</p>;
}

function Parent() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount(count + 1)}>点我更新</button>
      <Child />
    </div>
  );
}
执行结果:
  • 首次挂载:
    空数组依赖:Child 执行
    无依赖:Child 执行
    
  • 点击按钮(Parent 触发更新,Child 重渲染):
    无依赖:Child 执行 // 空数组依赖的回调不再执行,无依赖的每次都执行
    

补充:容易踩的坑

  1. 误以为“不写依赖”=“依赖所有变量”: 很多新手觉得“不写 deps”是“监听所有变量变化”,但底层逻辑是——React 不做任何 deps 对比,直接判定“每次都变化”,哪怕组件内没有任何变量变化,只要组件重渲染,就会执行;
  2. 空数组依赖≠“完全不依赖”: 空数组只是“不依赖组件内的任何 state/props”,但如果依赖的是“外部全局变量”(比如 window 的属性),全局变量变化时,回调也不会重新执行(因为 deps 还是空数组);
  3. 性能风险: 不写依赖的 useEffect 若包含耗时操作(比如接口请求、DOM 频繁修改),会导致组件每次更新都触发耗时操作,严重时卡顿,实战中绝对要避免不写依赖的写法

总结

  1. 空数组 []:仅首次挂载打 Passive 标记,回调只执行 1 次,对应“仅 mount 执行”;
  2. 不写依赖:每次更新都打 Passive 标记,回调每次渲染都执行,对应“mount + 每次 update 执行”;
  3. 底层核心差异:React 对“空数组”做严格的 deps 对比,对“不写依赖”直接跳过对比、判定为每次都变化。

实战建议

  • 除非明确需要“每次更新都执行”(几乎没有这种场景),否则绝对不要省略依赖数组
  • 若想“仅挂载执行”,用 []
  • 若想“依赖某个变量变化才执行”,把变量放进依赖数组(比如 [count]),React 会精准对比该变量,仅变化时打 Passive 标记。

useState 和 useRef

一、完整闭包陷阱题目

1. 题目代码(可直接运行)

import { useState } from 'react';

// 闭包陷阱示例组件
function CountDemo() {
  // 初始count为0
  const [count, setCount] = useState(0);

  // 点击按钮触发:延迟1秒更新count
  const handleClick = () => {
    setTimeout(() => {
      // 传值更新:基于当前闭包的count计算
      setCount(count + 1);
    }, 1000);
  };

  return (
    <div style={{ padding: '20px' }}>
      <p>当前count:{count}</p>
      <button onClick={handleClick}>点击更新count(延迟1秒)</button>
    </div>
  );
}

export default CountDemo;

2. 操作与问题

操作:快速点击按钮 3 次(1秒内完成)。 问题:1秒后页面上的 count 最终显示多少? 答案1(而非预期的 3)。

二、现象原因分析(闭包陷阱核心)

结合你之前理解的「闭包快照」逻辑,拆解核心原因:

  1. 每次点击触发的是「同一版本的 handleClick」: 快速点击3次时,所有点击都发生在「1秒延迟期内」,组件还没重新渲染,<button>onClick 始终绑定「初始渲染的 handleClick(版本1)」。
  2. 版本1 handleClick 的闭包捕获的是初始 count=0: 3次点击都会预约「1秒后执行 setCount(0 + 1)」,最终 React 处理这3个更新时,都是将 count 设为 1,因此最终只显示 1
  3. 核心本质:传值更新(setCount(count + 1))依赖「闭包内的旧快照值」,而非 React 内部的最新状态。

三、改造方案:延迟1秒拿到最新count(两种常用方案)

方案1:函数式更新(React 官方推荐,最优解)

改造思路

setCount 的「传值更新」改为「函数式更新」,让 React 自动传入「最新的 prevCount」,脱离闭包对旧值的依赖。

改造后代码
import { useState } from 'react';

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

  const handleClick = () => {
    setTimeout(() => {
      // 函数式更新:参数prevCount是React维护的最新状态
      setCount(prevCount => prevCount + 1);
    }, 1000);
  };

  return (
    <div style={{ padding: '20px' }}>
      <p>当前count:{count}</p>
      <button onClick={handleClick}>点击更新count(延迟1秒)</button>
    </div>
  );
}

export default CountDemo;
原理与效果
  • 原理:函数式更新的参数 prevCount 是 React 在「处理更新时」传入的「最新状态值」,而非闭包内的旧快照;
  • 效果:快速点击3次后,1秒内 React 会依次执行: 第1次:prevCount=0 → 0+1=1; 第2次:prevCount=1 → 1+1=2; 第3次:prevCount=2 → 2+1=3; 最终 count 显示 3,符合预期。

方案2:useRef 保存最新值(适合需主动获取最新状态的场景)

改造思路

利用 useRefcurrent 属性是「引用类型」的特性(闭包中能拿到最新值,不会被快照锁定),同步 countref,延迟操作时从 ref 取最新值。

改造后代码
import { useState, useRef } from 'react';

function CountDemo() {
  const [count, setCount] = useState(0);
  // 用ref保存最新count(ref.current是引用类型,闭包能拿到最新值)
  const countRef = useRef(count);

  // 关键:每次渲染同步ref和最新count
  countRef.current = count;

  const handleClick = () => {
    setTimeout(() => {
      // 从ref中获取最新count值
      setCount(countRef.current + 1);
    }, 1000);
  };

  return (
    <div style={{ padding: '20px' }}>
      <p>当前count:{count}</p>
      <button onClick={handleClick}>点击更新count(延迟1秒)</button>
    </div>
  );
}

export default CountDemo;
原理与效果
  • 原理useRefcurrent 属性存储在堆内存中,引用地址不变,每次组件渲染都会同步最新的 countcountRef.current;延迟回调执行时,能直接读取到「最新的 count 值」;
  • 效果:同样实现点击3次后 count 显示 3,适合需要「在闭包中主动获取最新状态」的场景(比如除了更新,还需打印/使用最新count)。

四、方案对比与适用场景

方案 核心逻辑 适用场景 优点
函数式更新 依赖React传入的最新prevState 仅需「基于最新状态更新」(绝大多数场景) 简洁、React官方推荐
useRef 手动同步最新状态到ref 需要「主动读取最新状态」(如打印、传参等) 灵活,可主动控制状态

总结

  1. 闭包陷阱本质:快速点击时,延迟更新依赖「闭包内的旧count快照」,多次更新都是基于同一个旧值,最终只生效一次;
  2. 核心改造逻辑:脱离闭包对旧值的依赖——要么用函数式更新让React传最新值,要么用useRef保存最新值;
  3. 最佳实践:优先使用「函数式更新」(setXxx(prev => prev + 1)),这是React为解决闭包陷阱设计的最优方案,适配90%以上的延迟更新场景。

useState 和 useRef的区别

一、核心区别(从底层逻辑到使用表现)

为了让你一目了然,先通过对比表总结核心差异,再逐一拆解:

维度 useState useRef
核心定位 管理渲染相关的状态 存储非渲染相关的持久化数据
数据修改影响 调用setter函数会触发组件重新渲染 直接修改.current不触发任何渲染
数据访问方式 直接访问状态变量(如count 必须通过.current属性(如ref.current
数据更新特性 状态更新是“不可变”的(新值替换旧值) 数据修改是“可变”的(直接修改引用值)
依赖监听 状态变化会触发依赖该状态的useEffect/useCallback 修改.current不会触发依赖监听(需手动配合useState/useEffect
初始值特性 初始值仅在组件首次挂载时生效(后续渲染忽略) 初始值仅用于初始化.current,后续可自由修改

二、关键差异拆解(结合代码示例)

1. 数据修改是否触发渲染(最核心区别)
  • useState:状态更新必然触发组件重渲染,这是它的核心设计目的——让页面跟随状态变化更新。

    import { useState } from 'react';
    function StateDemo() {
      const [count, setCount] = useState(0);
      console.log('StateDemo 渲染了'); // 每次点击都会打印(重渲染)
      
      return (
        <button onClick={() => setCount(count + 1)}>
          计数:{count}
        </button>
      );
    }
    
  • useRef:修改.current完全不触发渲染,数据仅在内存中更新,页面无感知。

    import { useRef } from 'react';
    function RefDemo() {
      const countRef = useRef(0);
      console.log('RefDemo 渲染了'); // 仅首次挂载打印一次
      
      return (
        <button onClick={() => {
          countRef.current += 1; // 修改不触发渲染
          console.log('内存中的值:', countRef.current); // 控制台能看到值变化,但页面不变
        }}>
          计数:{countRef.current} {/* 始终显示0,因为没渲染 */}
        </button>
      );
    }
    
2. 数据更新的“可变/不可变”特性
  • useState:状态是“不可变”的,必须通过setter函数更新(不能直接修改原始值),React会用新值替换旧值。

    // 错误用法:直接修改state不会触发渲染
    const [obj, setObj] = useState({ name: '张三' });
    obj.name = '李四'; // 无效,页面不更新
    // 正确用法:通过setter传递新对象
    setObj({ ...obj, name: '李四' }); // 触发渲染
    
  • useRef.current是“可变”的,可以直接修改(类似操作普通JS对象),无需特殊方法。

    const objRef = useRef({ name: '张三' });
    objRef.current.name = '李四'; // 直接修改生效,无需要setter
    
3. 依赖监听的差异

useEffectuseCallback等Hook会监听useState的状态变化,但不会监听useRef.current的变化:

import { useState, useRef, useEffect } from 'react';
function EffectDemo() {
  const [count, setCount] = useState(0);
  const countRef = useRef(0);

  // 监听count:count变化时执行
  useEffect(() => {
    console.log('count变了:', count);
  }, [count]);

  // 监听countRef.current:永远不会执行(因为ref不是依赖项)
  useEffect(() => {
    console.log('countRef变了:', countRef.current);
  }, [countRef.current]); // 无效!

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>修改state</button>
      <button onClick={() => countRef.current += 1}>修改ref</button>
    </div>
  );
}

三、适用场景(帮你快速选对Hook)

用useState的场景(渲染相关)
  • 页面需要展示的数据(如按钮文案、列表数据、表单输入值);
  • 需要触发UI更新的状态(如弹窗显隐、加载中状态、切换主题);
  • 依赖状态变化执行副作用的场景(如根据筛选条件请求数据)。
用useRef的场景(非渲染相关)
  • 存储DOM元素引用(如获取输入框焦点、操作DOM尺寸);
  • 存储跨生命周期的临时数据(如定时器ID、请求取消令牌);
  • 保存上一次的状态/属性值(如对比当前值和上一次值);
  • 存储无需页面展示的内存数据(如计算中间结果、缓存数据)。

四、特殊场景:useRef + useState 结合使用

如果需要“数据持久化且修改时触发渲染”,可以结合两者:

import { useRef, useState } from 'react';
function CombineDemo() {
  const countRef = useRef(0); // 持久化存储
  const [, forceRender] = useState({}); // 用于强制渲染

  const increment = () => {
    countRef.current += 1; // 修改ref(不触发渲染)
    forceRender({}); // 手动触发渲染(通过更新空对象)
  };

  return (
    <button onClick={increment}>
      计数:{countRef.current} {/* 能正常更新 */}
    </button>
  );
}

总结

  1. 核心差异useState管理渲染状态(更新触发重渲染),useRef存储持久化数据(更新不触发渲染);
  2. 使用原则:数据需要展示在页面上→用useState;数据仅在内存中使用→用useRef
  3. 更新特性useState是不可变更新(需setter),useRef是可变更新(直接改.current)。

RESTful 设计与 NestJS 实践:构建语义清晰的 API 服务

作者 Zyx2007
2026年1月27日 18:05

在现代 Web 开发中,API 不仅是前后端通信的桥梁,更是系统能力的对外契约。如何设计一套直观、一致、可预测的接口?RESTful 架构风格提供了最佳答案——它将一切视为资源,通过标准 HTTP 方法表达操作语义。NestJS 凭借其装饰器驱动的路由机制和 TypeScript 的类型安全,让实现 RESTful API 变得既规范又高效。

RESTful 核心:用 HTTP 方法表达意图

REST(Representational State Transfer)强调使用 HTTP 动词的原始语义来操作资源:

  • GET:获取资源列表或详情(如 /todos 获取所有任务);
  • POST:创建新资源(如提交表单新增任务);
  • PUT:全量更新资源(如替换整个用户资料);
  • PATCH:局部更新资源(如仅修改昵称或密码);
  • DELETE:删除资源(如移除一条任务)。

这种设计让 API 自解释——开发者看到 DELETE /todos/1,无需文档就能理解其作用。它降低了认知成本,提升了协作效率。

NestJS 控制器:声明式路由定义

在 NestJS 中,控制器通过装饰器将方法映射到具体路由:

@Controller('todos')
export class TodosController {
  constructor(private readonly todosService: TodosService) {}

  @Get()
  getTodos() {
    return this.todosService.findAll();
  }

  @Post()
  addTodo(@Body('title') title: string) {
    return this.todosService.addTodo(title);
  }

  @Delete(':id')
  deleteTodo(@Param('id', ParseIntPipe) id: number) {
    return this.todosService.deleteTodo(id);
  }
}
  • @Controller('todos') 定义基础路径 /todos
  • @Get() 对应 GET /todos,返回任务列表;
  • @Post() 处理 POST /todos,从请求体提取 title 创建任务;
  • @Delete(':id') 匹配 DELETE /todos/123,并通过 ParseIntPipe 确保 id 为数字。

这种声明式写法将路由逻辑与业务代码解耦,结构清晰,易于维护。

服务层:专注业务逻辑

控制器只负责协议转换,核心逻辑由服务类处理:

@Injectable()
export class TodosService {
  private todos: Todo[] = [
    { id: 1, title: '周五狂欢', completed: false },
    { id: 2, title: '三角洲首胜', completed: true }
  ];

  findAll() {
    return this.todos;
  }

  addTodo(title: string) {
    const todo: Todo = {
      id: +Date.now(),
      title,
      completed: false
    };
    this.todos.push(todo);
    return todo;
  }

  deleteTodo(id: number) {
    this.todos = this.todos.filter(todo => todo.id !== id);
    return { message: 'Todo deleted', code: 200 };
  }
}

服务类通过 @Injectable() 标记,可被 NestJS 容器自动注入到控制器中。它封装了数据操作细节,未来若需接入数据库,只需修改此层,不影响上层 API。

全局数据库模块:统一连接管理

对于真实项目,数据需持久化存储。NestJS 支持通过自定义提供者管理数据库连接:

@Global()
@Module({
  providers: [
    {
      provide: 'PG_CONNECTION',
      useValue: new Pool({
        user: process.env.DB_USER,
        host: process.env.DB_HOST,
        database: process.env.DB_NAME,
        password: process.env.DB_PASSWORD,
        port: parseInt(process.env.DB_PORT || '5432', 10),
      })
    }
  ],
  exports: ['PG_CONNECTION']
})
export class DatabaseModule {}

使用 @Global() 装饰器使该模块全局可用,其他模块无需重复导入即可注入 PG_CONNECTION。连接配置从环境变量读取,符合安全与部署规范。

类型安全:从接口到运行时

TypeScript 在整个链路中提供强类型保障:

export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

该接口定义了任务的数据结构。服务返回 Todo[],控制器透传该类型,前端调用时即可获得准确的字段提示。即使未来新增 createdAt 字段,编译器也会提醒所有未适配的地方,避免运行时错误。

结语

NestJS 将 RESTful 原则与工程化实践完美结合。通过语义化的 HTTP 方法、声明式的路由装饰器、分层的服务架构和全局的依赖管理,它帮助开发者构建出结构清晰、类型安全、易于扩展的后端服务。无论是简单的 Todo 应用,还是复杂的微服务系统,这套模式都能提供坚实的基础。在 API 成为产品核心竞争力的今天,掌握这样的设计范式,无疑是每位后端工程师的必备技能。

antd组件Upload的实现原理

2026年1月27日 18:03

平时把<Upload/>这个组件拿过来直接用,但是实现原理不是很明确,自己记录一下。

Ant Design Upload 组件基于原生 <input type="file">实现,

<Upload
  name="file"  // 文件字段名
  action="/api/upload"  // 上传地址
  headers={{ Authorization: 'Bearer token' }}  // 请求头
  data={{ extra: 'data' }}  // 额外参数
  multiple={true}  // 多文件
  accept=".jpg,.png"  // 文件类型限制
  beforeUpload={beforeUpload}  // 上传前处理
  onChange={handleChange}  // 状态变化处理
  fileList={fileList}  // 受控文件列表
>
  <Button>上传文件</Button>
</Upload>

上传流程

一、初始化流程

// Upload组件初始化时:

  1. 创建隐藏的 input[type="file"] 元素
  2. 设置 input 的 accept、multiple 属性
  3. 绑定 change 事件监听器
  4. 如果有 fileList 属性,显示已上传文件

二、选择文件流程

image.png

三、上传请求处理

// 实际的文件上传是通过 XMLHttpRequest 或 fetch 实现的
const xhr = new XMLHttpRequest();

// 1. 创建 FormData
const formData = new FormData();
formData.append('file', file);
formData.append('extra', 'data');

// 2. 配置请求
xhr.open('POST', action, true);
xhr.setRequestHeader('Authorization', 'Bearer token');

// 3. 监听进度
xhr.upload.onprogress = (e) => {
  const percent = e.total ? Math.round(e.loaded * 100 / e.total) : 0;
  onProgress({ percent }, file);
};

// 4. 发送请求
xhr.send(formData);

核心方法实现

一、文件验证逻辑

const beforeUpload = (file: RcFile, fileList: RcFile[]) => {
  // 1. 文件类型验证
  const isJpgOrPng = file.type === 'image/jpeg' || file.type === 'image/png';
  if (!isJpgOrPng) {
    message.error('只能上传 JPG/PNG 格式的文件!');
    return Upload.LIST_IGNORE; // 阻止上传
  }

  // 2. 文件大小验证
  const isLt2M = file.size / 1024 / 1024 < 2;
  if (!isLt2M) {
    message.error('文件大小不能超过 2MB!');
    return false;
  }

  // 3. 自定义验证
  if (!checkFileName(file.name)) {
    message.error('文件名不合法!');
    return false;
  }

  // 4. 返回 Promise 进行异步验证
  return new Promise((resolve, reject) => {
    // 图片尺寸验证
    const reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onload = (e) => {
      const img = document.createElement('img');
      img.src = e.target.result as string;
      img.onload = () => {
        if (img.width < 300 || img.height < 300) {
          reject(new Error('图片尺寸过小'));
        } else {
          resolve(true);
        }
      };
    };
  });
};

前端打印性能优化实战:从 iframe 卡顿到 Node.js 服务端渲染的演进

作者 Roger_zz
2026年1月27日 18:02

1. 背景与痛点

1.1 原始方案:Iframe 方案

在项目的初期,为了快速实现打印需求,我们采用了经典的前端方案:

  1. 前端生成 HTML 字符串。
  2. 创建一个隐藏的 <iframe>
  3. 将 HTML 插入 iframe document。
  4. 调用 iframe.contentWindow.print()

1.2 遇到的问题

随着业务发展,打印报表中包含了大量的高清图片复杂的 DOM 结构。在低性能设备或数据量较大时,出现了严重的用户体验事故:

  • 💥 主线程阻塞 (UI Freeze) :浏览器在解析大量图片(Image Decoding)和构建庞大的 DOM 树时,主线程被长时间占用。
  • ⏳ 页面假死:用户点击“打印”后,页面完全失去响应(无法滚动、无法点击),持续时间长达 10s - 30s。
  • 📉 样式不稳:不同浏览器(Chrome vs Safari)对打印样式的渲染存在细微差异,且 CSS 分页控制(page-break)时常失效。

2. 根因分析

经过 Chrome Performance 面板分析,我们定位到了核心性能瓶颈:

  1. 图片解码开销:浏览器在渲染 DOM 之前,必须在主线程完成图片数据的解码。数百张图片的解码任务堆积,导致了巨大的 Long Task
  2. 强制回流 (Reflow Thrashing) :向 iframe 插入大量节点会触发大规模的布局计算。
  3. 资源抢占:打印逻辑占用了浏览器有限的资源,导致用户无法操作当前页面的其他功能。

3. 解决方案演进

为了彻底解决“阻塞主线程”的问题,我们决定将渲染压力转移,从“客户端渲染”转向“服务端渲染 (SSR)”。

3.1 架构升级:Node.js + Puppeteer

我们搭建了一个独立的 Node.js 打印微服务

  • 原理:前端仅发送 JSON 数据,Node.js 启动 Headless Chrome(无头浏览器)在服务器端完成渲染和截图,最终返回 PDF 文件流。

  • 优势

    • 前端零负担:浏览器只负责发请求和下载,UI 丝滑无卡顿。
    • 样式统一:服务器环境固定(Linux + Chrome),打印结果在任何客户端都一致。

3.2 性能保障:Puppeteer Cluster (集群连接池)

单纯引入 Puppeteer 会带来高内存消耗风险。我们引入了 puppeteer-cluster

  • 连接池管理:复用浏览器实例,避免频繁启动/关闭 Chrome 的巨大开销。
  • 并发控制:设置 maxConcurrency(如 2-4),并通过队列系统管理请求。当并发请求过多时,任务自动排队,防止服务器宕机。
  • 资源隔离:使用 CONCURRENCY_CONTEXT 模式,每个任务使用独立的隐身页面,极速且安全。

3.3 工程化落地:NPM 模版包管理

为了解决“模版维护”和“资源加载”问题,我们设计了 UI 库分离 策略:

  • 模版开发:前端团队维护一个独立的 NPM 包(UI Library)。
  • 构建优化:利用 Vite/Rollup,在构建阶段将 CSS 和小图片内联 (Inline) 到 HTML/JS 中。
  • 版本控制:Node 服务通过 package.json 锁定模版版本,确保线上打印样式绝对稳定。

4. 最终架构图

Gemini_Generated_Image_bbh94bbbh94bbbh9.png


5. 核心代码摘要

服务端 (Node.js):

const { Cluster } = require('puppeteer-cluster');
// 引入独立的模版 UI 包
const { renderSalesReport } = require('@company/print-templates');

// 1. 启动集群
const cluster = await Cluster.launch({
  concurrency: Cluster.CONCURRENCY_CONTEXT, // 共享浏览器,独立上下文
  maxConcurrency: 4, // 限制并发
});

// 2. 定义任务
await cluster.task(async ({ page, data: { htmlContent } }) => {
  // 因为资源已内联,无需等待网络,直接等待 DOM 加载
  await page.setContent(htmlContent, { waitUntil: 'load' });
  return await page.pdf({ format: 'A4', printBackground: true });
});

// 3. 执行业务
app.post('/print', async (req, res) => {
  // 同步生成 HTML,毫秒级
  const html = renderSalesReport(req.body.data);
  // 扔入队列
  const pdfBuffer = await cluster.execute({ htmlContent: html });
  res.send(pdfBuffer);
});

6. 成效与收益

Gemini_Generated_Image_ml67grml67grml67.png


从 0 到 1:前端 CI/CD 实战(第四篇:Nginx + GitLab CI 实现前端自动发布上线)

作者 饼饼饼
2026年1月27日 18:01

前言

在前几篇中,我们已经完成了 GitLab、GitLab Runner 以及前端项目的自动构建流程。但在真实项目中,CI/CD 往往不仅仅是“跑通一次构建”这么简单,而是需要支持多环境、多项目、多分支部署。

本篇将结合我的真实项目结构,介绍如何通过 Nginx + GitLab CI,将前端项目分别部署到 dev、test、uat、prod 等环境中,实现一套可长期维护的自动发布方案。

目录规划

以 dev 环境为例:

cd /apps/deploy/dev

mkdir -p frontend/project-1
mkdir -p backend

结构如下:

/apps/deploy/dev
├── frontend
│   └── project-1
└── backend

配置 Nginx 配置文件

所有环境的 Nginx 配置统一放在:

/apps/nginx-conf

1. 创建 dev 配置文件

进入目录:

cd /apps/nginx-conf

新建文件:

vim front-dev.conf

写入内容:

server {
    listen 80;
    server_name _;

    location = /project-1 {
        return 301 /project-1/;
    }

    location /project-1 {
        alias /usr/share/nginx/html/project-1/;
        try_files $uri $uri/ /project-1/index.html;
    }

    location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico)$ {
        root /usr/share/nginx/html/project-1;
        expires max;
        log_not_found off;
    }
}

保存退出:

:wq

2. 创建 test 配置(同理)

vim front-test.conf

内容与 dev 基本一致,生产环境同理。

创建多环境 Nginx 容器

创建 dev 环境的 Nginx 服务。

进入项目目录

cd /apps/deploy/dev/frontend/project-1

创建 compose 文件:

vim docker-compose.yml

编写 docker-compose.yml

version: "3.9"

services:
  front-dev-nginx:
    image: nginx:alpine
    container_name: front-dev-nginx
    ports:
      - "8000:80"
    volumes:
      - /apps/deploy/dev/frontend:/usr/share/nginx/html:ro
      - /apps/nginx-conf/front-dev.conf:/etc/nginx/conf.d/default.conf:ro
    restart: unless-stopped

创建 test 环境的 Nginx 服务。

进入项目目录

cd /apps/deploy/test/frontend/project-1

创建 compose 文件并写入

services:
  front-test-nginx:
    image: nginx:alpine
    container_name: front-test-nginx
    ports:
      - "8001:80"
    volumes:
      - /apps/deploy/test/frontend:/usr/share/nginx/html:ro
      - /apps/nginx-conf/front-test.conf:/etc/nginx/conf.d/default.conf:ro
    restart: unless-stopped

创建 prod 环境的 Nginx 服务。

进入项目目录

cd /apps/deploy/prod/frontend/project-1

创建 compose 文件并写入

services:
  front-prod-nginx:
    image: nginx:alpine
    container_name: front-prod-nginx
    ports:
      - "8002:80"
    volumes:
      - /apps/deploy/prod/frontend:/usr/share/nginx/html:ro
      - /apps/nginx-conf/front-prod.conf:/etc/nginx/conf.d/default.conf:ro
    restart: unless-stopped

3. 启动容器

切换到 dev 文件夹下:

执行:

docker compose up -d

查看状态:

docker ps

出现:nginx-dev说明启动成功。

其他环境同理。

配置 GitLab CI 自动部署

前面已经配置好构建流程,下面重点是部署阶段。

创建多环境主 CI 文件

在 project-1 项目根目录创建:

vim .gitlab-ci.yml

写入

include:
  - local: '.gitlab-ci-dev.yml'
    rules:
      - if: '$CI_COMMIT_REF_NAME == "dev"'

  - local: '.gitlab-ci-test.yml'
    rules:
      - if: '$CI_COMMIT_REF_NAME == "test"'

  - local: '.gitlab-ci-main.yml'
    rules:
      - if: '$CI_COMMIT_REF_NAME == "main"'

dev 环境 CI 文件

在 project-1 项目根目录创建:

vim .gitlab-ci-dev.yml

写入:

stages:
  - build
  - deploy

variables:
  BUILD_DIR: dist
  DEPLOY_DIR: /apps/deploy/dev/frontend/project-1

build_project:
  stage: build
  tags: ["build"]
  image: node:24-alpine
  
  script:
    - npm install -g pnpm@8.15.3
    - pnpm install
    - npm run build:dev
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour


deploy_project:
  stage: deploy
  tags: ["build"]
  image: alpine:latest
  script:
    - mkdir -p $DEPLOY_DIR
    - rm -rf $DEPLOY_DIR/*
    - cp -r $CI_PROJECT_DIR/$BUILD_DIR/* $DEPLOY_DIR/

test 环境 CI 文件

同理创建:

vim .gitlab-ci-test.yml

写入:

stages:
  - build
  - deploy

variables:
  BUILD_DIR: dist
  DEPLOY_DIR: /apps/deploy/test/frontend/project-1

build_project:
  stage: build
  tags: ["build"]
  image: node:24-alpine
  script:
    - npm install -g pnpm@8.15.3
    - pnpm install
    - npm run build:test
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour


deploy_project:
  stage: deploy
  tags: ["build"]
  image: alpine:latest
  script:
    - mkdir -p $DEPLOY_DIR
    - rm -rf $DEPLOY_DIR/*
    - cp -r $CI_PROJECT_DIR/$BUILD_DIR/* $DEPLOY_DIR/

prod 环境 CI 文件

同理创建:

vim .gitlab-ci-main.yml

写入:

stages:
  - build
  - deploy

variables:
  BUILD_DIR: dist
  DEPLOY_DIR: /apps/deploy/prod/frontend/project-1

build_project:
  stage: build
  tags: ["build"]
  image: node:24-alpine
  script:
    - npm install -g pnpm@8.15.3
    - pnpm install
    - npm run build:prod
  artifacts:
    paths:
      - dist/
    expire_in: 1 hour


deploy_project:
  stage: deploy
  tags: ["build"]
  image: alpine:latest
  script:
    - mkdir -p $DEPLOY_DIR
    - rm -rf $DEPLOY_DIR/*
    - cp -r $CI_PROJECT_DIR/$BUILD_DIR/* $DEPLOY_DIR/

测试完整发布流程

到这里,可以开始验证完整链路。

1. 推送代码

git push origin dev

2. 查看流水线

进入 GitLab:

CI/CD → Pipelines

确认:

  • build 成功
  • deploy 成功

3. 查看服务器目录

ls /apps/deploy/dev/frontend/project-1

应看到:

index.html
assets

4. 浏览器访问

打开:

http://服务器IP:8000/project-1/

页面正常显示即 dev 环境部署成功,其他环境同理。

本篇小结

这一路走来,我们不仅跑通了流程,更点亮了一整套实战技能树:从 搭建云服务GitLab 私有化部署,从 GitLab CI 自动化构建到 Docker + Nginx 的多环境容器化隔离。

现在,只需一次 git push,代码就能自动流过我们亲手搭建的流水线。这种告别 FTP 搬砖、告别手动改配置的“全链路自动化”,才是前端工程化的核心魅力。至此,发布闭环正式达成——从此发布不求人,环境不抓瞎。

感谢你陪我走完这个系列,希望这套方案能帮你彻底告别手动发布的琐碎,把时间留给更酷的代码,我们下个系列见🎉🎉🎉!

JavaScript LocalStorage 深度解析:原理、对比与实战

作者 NEXT06
2026年1月27日 17:58

本文旨在全面解析 JavaScript 中的 LocalStorage 机制,深入对比其与 Cookie、SessionStorage 的差异,并提供工程化的代码实现方案与面试回答技巧。

1. 概念与背景

什么是 LocalStorage

LocalStorage 是 HTML5 Web Storage API 的一部分,它允许 Web 应用程序在用户的浏览器中本地存储键值对(Key-Value)数据。与早期的存储机制不同,LocalStorage 中的数据没有过期时间,除非通过 JavaScript 代码显式删除或用户手动清除浏览器缓存,否则数据将永久保留。

它解决了什么问题

在 HTML5 出现之前,Web 开发者主要依赖 Cookie 进行客户端存储,但 Cookie 存在显著缺陷,LocalStorage 的出现正是为了解决以下核心痛点:

  1. HTTP 无状态协议的补充:HTTP 协议本身是无状态的,无法记录用户行为。虽然 Cookie 可以维持状态,但其设计初衷是用于服务端识别用户,而非作为通用的大容量客户端数据库。
  2. 存储容量限制:Cookie 的存储空间非常有限,通常被限制在 4KB 左右,难以存储复杂的应用配置或缓存数据。
  3. 网络性能损耗:Cookie 具有“随请求发送”的特性。无论服务端是否需要,每次 HTTP 请求都会在 Header 中携带当前域名下的所有 Cookie。这在移动端或低带宽环境下会造成严重的带宽浪费和性能延迟。

核心特性

  • 容量:通常为 5MB(不同浏览器略有差异),远大于 Cookie。
  • 持久化:数据存储在硬盘上,关闭浏览器或重启电脑后数据依然存在。
  • 同源策略:遵循浏览器的同源策略,数据只能被同一协议、同一域名、同一端口的页面访问。

2. 核心差异对比(面试重点)

理解 LocalStorage、SessionStorage 和 Cookie 的区别是前端面试中的高频考点。

特性对比表

维度 Cookie LocalStorage SessionStorage
生命周期 可设置过期时间;若不设置,默认为会话级(关闭浏览器失效) 永久有效,除非手动删除或代码清除 仅在当前会话有效,关闭标签页或窗口即失效
存储容量 极小,约 4KB 较大,约 5MB 较大,约 5MB
与服务器通信 自动携带在 HTTP 请求头中,浪费带宽 仅存在于客户端,不参与服务端通信 仅存在于客户端,不参与服务端通信
易用性 (API) 原生 API 难用(需自行解析字符串),通常需封装 提供原生 API (setItem, getItem),简单易用 提供原生 API,简单易用
作用域 在所有同源窗口中共享 在所有同源窗口中共享 不在不同的标签页/窗口共享,即使是同源

本质区别总结

  1. 数据持久化:LocalStorage 侧重于“长期存储”,SessionStorage 侧重于“临时会话”,而 Cookie 侧重于“服务器验证”。
  2. 网络流量:使用 Web Storage(Local/Session)可以将数据仅仅保留在客户端,避免了 Cookie 带来的无谓网络负载,显著提升了网络请求的效率。

3. 应用场景分析

最佳实践场景

  1. 用户偏好设置:如存储用户选择的主题(深色/浅色模式)、字体大小、默认语言等。这些数据不需要频繁发送给服务器。
  2. 长期保存的购物车数据(未登录状态) :用户未登录时添加的商品信息,可以在下次访问时恢复,提升转化率。
  3. 表单草稿缓存:在用户撰写长文或填写复杂表单时,实时保存内容。若用户意外关闭页面,重新打开时可恢复现场。
  4. 静态资源缓存:存储一些不经常变更的 Base64 图片或 JS/CSS 字符串(需配合版本控制),减少服务器请求。

不适合的场景与安全风险

  1. 敏感信息严禁在 LocalStorage 中存储用户的明文密码、信用卡号或高权限的 Authentication Token。

    • 风险说明:LocalStorage 容易受到 XSS(跨站脚本攻击)的威胁。如果攻击者成功在页面注入恶意脚本,可以通过 window.localStorage 轻松读取所有数据。对于身份验证 Token,推荐使用设置了 HttpOnly 属性的 Cookie。
  2. 频繁变动的大数据:LocalStorage 的操作是同步的(Synchronous)。如果频繁写入大量数据,可能会阻塞浏览器主线程,导致页面卡顿。

4. 代码实战与封装

LocalStorage 原生 API 只能存储字符串。在实际开发中,我们通常需要存储对象或数组,因此必须进行序列化(JSON.stringify)和反序列化(JSON.parse)处理。此外,还需要考虑到 JSON 解析失败的异常情况。

基础 API 展示

JavaScript

// 存储数据
localStorage.setItem('username', 'Developer');

// 读取数据
const user = localStorage.getItem('username');

// 删除特定数据
localStorage.removeItem('username');

// 清空所有数据
localStorage.clear();

生产环境通用封装(含错误处理)

以下代码封装了一个带有类型转换和异常捕获的工具类,可直接用于项目中:

JavaScript

const StorageUtils = {
    /**
     * 存储数据
     * @param {string} key 键名
     * @param {any} value 值(对象、数组、字符串等)
     */
    set(key, value) {
        try {
            // 将对象或数组转换为 JSON 字符串
            const stringValue = JSON.stringify(value);
            localStorage.setItem(key, stringValue);
        } catch (e) {
            console.error('LocalStorage 存储失败,可能是配额已满:', e);
        }
    },

    /**
     * 读取数据
     * @param {string} key 键名
     * @returns {any} 解析后的对象或原始字符串,若不存在返回 null
     */
    get(key) {
        const value = localStorage.getItem(key);
        // 如果数据为空,直接返回 null
        if (!value) return null;

        try {
            // 尝试将 JSON 字符串还原为对象
            return JSON.parse(value);
        } catch (e) {
            // 如果不是 JSON 格式(例如纯字符串),则直接返回原值
            return value;
        }
    },

    /**
     * 删除数据
     * @param {string} key 键名
     */
    remove(key) {
        localStorage.removeItem(key);
    }
};

// 使用示例
const userSettings = { theme: 'dark', notifications: true };

// 1. 存入对象
StorageUtils.set('settings', userSettings);

// 2. 取出对象(自动解析为 JSON)
const settings = StorageUtils.get('settings'); 
console.log(settings.theme); // 输出: dark

// 3. 容错测试
localStorage.setItem('legacy_string', 'hello world');
console.log(StorageUtils.get('legacy_string')); // 输出: hello world (不会因为 JSON.parse 报错)

5. 面试高分指南

模拟面试场景

面试官提问:“请谈谈 Cookie、LocalStorage 和 SessionStorage 的区别,以及你在项目中是如何选择的?”

结构化回答模板

建议采用  “总-分-总”  的逻辑结构进行回答:

  1. 共同点(总)
    “这三者都是浏览器端用于存储数据的机制,都遵循同源策略。”

  2. 核心差异(分 - 关键得分点)

    • 容量方面:Cookie 容量非常小,只有 4KB 左右;而 LocalStorage 和 SessionStorage 容量较大,通常在 5MB 左右。
    • 生命周期方面:Cookie 的生命周期由过期时间决定;LocalStorage 是持久化的,除非手动清除否则一直存在;SessionStorage 则是会话级的,关闭标签页就失效了。
    • 网络交互方面:这是最大的区别。Cookie 会自动随每一个 HTTP 请求发送到服务器,如果数据量大且不需要服务端处理,会严重浪费带宽;而 Web Storage(Local/Session)的数据仅保存在本地,不参与服务器通信。
  3. 项目应用与选择(总 - 展示实战经验)
    “在我的实际项目中,我会根据数据的特性来选择:

    • 如果需要与服务器交互(如身份认证 Token),或者需要设置过期时间,我会选择 Cookie
    • 如果是单纯的客户端数据持久化,比如用户的个性化配置、夜间模式状态,我会选择 LocalStorage
    • 如果是临时表单数据,为了防止刷新丢失,但不需要长期保存,我会选择 SessionStorage

    另外,在使用 LocalStorage 时,我会注意封装 JSON 序列化逻辑,并避免存储敏感数据以防范 XSS 攻击。”

JS-原生实战:手把手带你封装一个高性能模态框(Modal)组件

2026年1月27日 17:57

前言

在网页开发中,弹框(Modal)是最常见的交互组件之一。一个合格的弹框不仅要能“跳出来”,还需要处理好遮罩层、层级关系(z-index)、防滚动穿透以及用户便捷退出的体验。本文将从零开始带你实现一个具备丝滑动画效果的模态框。

一、 核心设计思路

实现弹框主要分为三个部分,它们的层级关系如下:

  1. 遮罩层 (Overlay) :铺满全屏的半透明背景,用于阻断用户对背景页面的操作,并突出弹框,初始diasplaynone,并使用position: fixed使其铺满屏幕。

  2. 内容盒 (Content) :位于遮罩层之上,水平垂直居中,承载具体信息,初始diasplaynone

  3. 控制逻辑

    • 打开:显示遮罩与弹框,同时锁定背景滚动,将遮罩层与内容盒的display属性均设置为block,并设置弹框内容盒子的z-index让其比遮罩层高。
    • 关闭:隐藏元素,恢复背景滚动。支持点击“关闭按钮”、点击“遮罩层”以及按下 “Esc 键”退出。

二、 关键技术点解析

1. 为什么使用 position: fixed 而非 absolute

在遮罩层和弹框的样式中,我们优先选择 fixed

  • absolute 是相对于最近的已定位祖先元素,如果页面很长,滚动后弹框可能会“飞走”。
  • fixed 是相对于浏览器视口(Viewport)定位,无论页面如何滚动,弹框始终保持在屏幕中央。

2. 完美的水平垂直居中

利用 top: 50%left: 50% 将左上角定位到中心,再配合 transform: translate(-50%, -50%) 将盒子向左和向上平移自身宽高的 50%,实现真正的精准居中。

3. 防止“滚动穿透”

当弹框打开时,用户依然可以滚动背景页面,这在用户体验上是不好的。

  • 解决方案:打开弹框时设置 document.body.style.overflow = "hidden"

三、 完整代码实现

以下是优化后的代码,加入了细腻的淡入(FadeIn)和滑入(SlideIn)动画。

<!DOCTYPE html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>弹框组件演示</title>
    <style>
      body {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }

      .container {
        max-width: 1200px;
        margin: 0 auto;
        padding: 20px;
      }

      /* 背景遮罩层 */
      .modal-overlay {
        display: none;
        position: fixed;
        top: 0;
        left: 0;
        right: 0;
        bottom: 0;
        background-color: rgba(0, 0, 0, 0.6);
        z-index: 999;
        animation: fadeIn 0.3s ease-out;
      }

      /* 弹框样式 */
      .modal {
        display: none;
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        width: 50%;
        max-width: 500px;
        background-color: white;
        border-radius: 16px;
        z-index: 1000;
        padding: 32px;
        animation: slideIn 0.4s ease-out;
        overflow: hidden;
      }
      /* 动画效果 */
      @keyframes fadeIn {
        from {
          opacity: 0;
        }
        to {
          opacity: 1;
        }
      }

      /* 淡入动画 */
      @keyframes slideIn {
        from {
          opacity: 0;
          transform: translate(-50%, -60%);
        }
        to {
          opacity: 1;
          transform: translate(-50%, -50%);
        }
      }
    </style>
  </head>
  <body>
    <div class="container">
      <header>
        <h1>弹框组件演示</h1>
      </header>

      <section class="content-section">
        <button class="open-btn" id="openModalBtn">打开弹框</button>
      </section>
      <section style="height: 1000px;"></section>
    </div>

    <!-- 灰色背景遮罩层 -->
    <div class="modal-overlay" id="modalOverlay"></div>

    <!-- 弹框 -->
    <div class="modal" id="modal">
      <h2>欢迎使用弹框组件</h2>
      <button id="closeModalBtn">关闭弹框</button>
    </div>

    <script>
      // 获取DOM元素
      const openModalBtn = document.getElementById("openModalBtn"); //打开按钮
      const modalOverlay = document.getElementById("modalOverlay"); //遮罩层
      const modal = document.getElementById("modal"); //弹框
      const closeModalBtn = document.getElementById("closeModalBtn"); //关闭按钮

      // 打开弹框
      function openModal() {
        modalOverlay.style.display = "block";
        modal.style.display = "block";
        document.body.style.overflow = "hidden"; // 防止背景滚动
      }

      // 关闭弹框
      function closeModal() {
        modalOverlay.style.display = "none";
        modal.style.display = "none";
        document.body.style.overflow = "auto"; // 恢复滚动
      }

      // 给打开按钮绑定打开弹框操作
      openModalBtn.addEventListener("click", openModal);

      // 点击背景遮罩层关闭弹框
      modalOverlay.addEventListener("click", closeModal);

      // 按ESC键关闭弹框
      document.addEventListener("keydown", function (event) {
        if (event.key === "Escape" && modal.style.display === "block") {
          closeModal();
        }
      });

    </script>
  </body>
</html>

🚀 深度解析:JS 数组的性能黑洞与 V8 引擎的“潜规则”

2026年1月27日 17:35

一、那个让服务器 CPU 飙升 100% 的“...”

上周五下午 4:50,正当我准备收工去吃火锅时,告警群突然炸了。某核心微服务的 CPU 占用率瞬间从 15% 飙升到 100%,内存也在疯狂抖动。

定位代码后,我发现了一行看起来人畜无害的代码:

// 为了合并三个从数据库查出来的结果集(每个约 5 万条数据)
const combinedData = [...largeArray1, ...largeArray2, ...largeArray3];

在开发环境下一切正常,但在高并发、大数据量的生产场景下,这一行代码直接成了“性能杀手”。

为什么? 很多人觉得 ES6 的扩展运算符(Spread Operator)只是 Array.prototype.concat 的语法糖,但实际上,V8 对它们的处理逻辑有着天壤之别。


二、V8 引擎的“潜规则”:数组的几种形态

在 V8 引擎内部,数组并不是简单的线性表。为了极致的性能,V8 会根据数组存储的内容动态切换存储模式。如果你不了解这些,你的代码可能正在悄悄拖慢整个系统的速度。

1. Packed vs Holey (连续 vs 有洞)

这是数组性能的分水岭。

  • Packed (连续数组):数组中所有的索引都有值。V8 可以直接计算偏移量,性能接近 C++ 数组。
  • Holey (有洞数组):数组中存在缺失的索引(例如 const arr = [1, , 3])。一旦数组变“洞”,V8 就必须在原型链上进行查找,甚至退化到“字典模式”,性能骤降。

避坑案例:千万不要用 delete arr[0] 来删除元素,这会产生一个永久的“洞”。请务必使用 splice

2. Smi -> Double -> Elements (类型演化)

  • Smi (Small Integer):存储的是小整数,这是最快的一种模式。
  • Double:一旦你往数组推入一个浮点数,数组就会演变为 Double 模式,涉及到“装箱/拆箱”开销。
  • Elements:一旦推入对象或混合类型,性能最慢。

重点:这种演化是不可逆的。即使你把对象删掉,数组依然会保留在 Elements 模式。


三、性能大 PK:ES5 方法 vs ES6 新特性

1. 扩展运算符 (...) vs Array.concat

回到开头的事故案例。为什么 [...a, ...b] 慢?

  • 扩展运算符 (...):它本质上是调用数组的迭代器(Iterator)。V8 必须逐个遍历元素并推入新数组,这涉及到大量的函数调用和迭代开销。
  • Array.prototype.concat:它是高度优化的内置方法。V8 内部可以直接进行内存块拷贝 (Memcpy),完全不经过 JavaScript 层的迭代。

实测数据:在处理 10 万级数据合并时,concatspread 快了近 3 倍,且内存峰值更低。

2. for vs forEach vs for...of

  • for 循环:永远的王者,没有任何额外开销。
  • forEach (ES5):带回调函数。早期由于闭包和函数调用开销确实慢,但现代 V8 通过 Inlining (内联优化),在大多数场景下已经能和 for 循环平起平坐。
  • for...of (ES6):基于迭代器。虽然语法优美,但在极高性能要求的循环中,迭代器的创建和 next() 调用依然存在细微开销。

3. find (ES6) vs filter (ES5)

如果你只需要找一个元素,永远不要用 filter().length

  • find()短路操作,找到即停。
  • filter() 会完整遍历数组并创建一个中间数组,浪费 CPU 和内存。

四、如何编写“高性能”的数组代码?

作为一名资深工程师,建议你在核心链路遵循以下原则:

1. 预分配数组空间

如果你预先知道数组的大小,直接 new Array(size) 比不断 push 要快。不断 push 会触发 V8 的动态扩容逻辑,导致内存重新分配和数据迁移。

2. 保持数组的“纯净度”

const arr = [1, 2, 3]; // Smi 模式,极速
arr.push(4.5);         // 退化为 Double 模式
arr.push('oops');      // 退化为 Elements 模式,性能滑坡

尽量让数组内部存储同类型的数据,尤其是避免在高性能循环中混合数字和对象。

3. 大数据合并禁用 Spread

在 React/Redux 的 reducer 中,我们习惯了 return [...state, newItem]。如果 state 只有几十个元素没问题,但如果是上万条记录的列表,请改用 concat 或先 push 再返回。


五、总结

性能优化不是为了“卷”语法,而是为了理解底层逻辑。

  1. 小规模数据:语义清晰最重要,大方使用 ES6 扩展符和 for...of
  2. 大规模数据 (万级以上):回归 for 循环与 concat,警惕迭代器开销。
  3. 核心库开发:必须关注 Packed/Holey 状态,确保数组在 V8 内部保持最快路径。

那天凌晨三点,当我把 spread 改回 concat 后,CPU 监控曲线瞬间恢复了平滑,我也终于赶上了那顿火锅。


「iDao 技术魔方」—— 聚焦 AI、前端、全栈矩阵,让技术落地更有深度。

CSS -实战技巧:彻底搞定单行、多行文本溢出省略号显示

2026年1月27日 17:32

前言

在 UI 布局中,为了保证页面的整洁,我们经常需要对超出容器范围的文本进行截断并显示省略号(...)。针对不同的布局需求和浏览器兼容性,通常有三种主要的实现方案。

一、 单行文本溢出省略

这是最常用、兼容性最好的方案,适用于标题、标签等场景。

核心代码:

.overflow-text {
  width: 200px;           /* 必须设置宽度 */
  white-space: nowrap;    /* 1. 强制文字不换行 */
  overflow: hidden;       /* 2. 隐藏超出部分 */
  text-overflow: ellipsis; /* 3. 使用省略号代替被截断文字 */
}

二、 多行文本溢出省略(WebKit 内核方案)

这是目前移动端和现代浏览器(Chrome, Safari, Edge)最常用的方案。它利用了 WebKit 的私有属性,实现简单且效果精准。

核心代码:

.multi-overflow {
  display: -webkit-box;           /* 1. 必须结合 box 模型使用 */
  -webkit-box-orient: vertical;   /* 2. 设置伸缩盒子的子元素排列方式 */
  -webkit-line-clamp: 2;          /* 3. 限制显示的行数 */
  overflow: hidden;               /* 4. 隐藏超出范围的内容 */
}

注意:该属性虽然是 WebKit 私有,但目前主流浏览器支持度已经非常高,是实际开发的首选。


三、 自定义多行省略(伪元素方案)

如果你需要兼容一些不支持 line-clamp 的旧版浏览器,或者需要更灵活的自定义样式,可以使用伪元素模拟。

核心代码:

.demo {
  position: relative;     /* 为伪元素提供定位参照 */
  line-height: 20px;      /* 行高 */
  height: 40px;           /* 高度必须是行高的整数倍(此处为2行) */
  overflow: hidden;       /* 隐藏多余内容 */
  word-break: break-all;
}

.demo::after {
  content: "...";         /* 插入省略号 */
  position: absolute;
  bottom: 0;
  right: 0;
  padding-left: 10px;
  background: white;      /* 关键:背景色需与容器背景一致,遮盖末尾文字 */
}

方案优缺点:

  • 优点:兼容性极佳。
  • 缺点:即使文字没超出行数,省略号也会一直显示(可通过 JS 判断高度动态添加类名来解决)。

四、 总结与最佳实践

需求场景 推荐方案 关键词
单行文本 标准 CSS 方案 nowrap + ellipsis
移动端/现代浏览器 WebKit 方案 -webkit-line-clamp
极端兼容性要求 伪元素方案 ::after + 绝对定位

JavaScript事件循环与异步执行全解析

2026年1月27日 17:26

📝 代码示例一:Promise与setTimeout混合

setTimeout(() => {
  console.log("set!");
  new Promise((resolve) => {
    resolve();
  }).then(() => {
    new Promise((resolve) => {
      resolve();
    }).then(() => {
      console.log("then4");
    });
    console.log("then2");
  });
});

new Promise((resolve) => {
  console.log("pr1");
  resolve();
}).then(() => {
  console.log("then1");
});

setTimeout(() => {
  console.log("set2");
});

console.log(2);

queueMicrotask(() => {
  console.log("microtask");
});

new Promise((resolve) => {
  resolve();
}).then(() => {
  console.log("then3");
});

🎯 输出顺序:

pr1
2
then1
microtask
then3
set!
then2
then4
set2

📝 代码示例二:async/await与Promise混合

async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}

async function async2() {
  console.log("async2");
}

console.log("script start");

setTimeout(function () {
  console.log("setTimeout");
}, 0);

async1();

new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("promise2");
});

console.log("script end");

🎯 输出顺序:

script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout

🔍 事件循环核心概念

任务队列分类

宏任务 (Macrotask)

  • setTimeout / setInterval
  • I/O 操作
  • UI 渲染
  • setImmediate (Node.js)
  • 脚本整体代码

微任务 (Microtask)

  • Promise.then / .catch / .finally
  • queueMicrotask
  • MutationObserver
  • async/await 后的代码

⚡ 执行规则(黄金法则)

  1. 同步代码立即执行:所有非异步代码立即执行
  2. 微任务优先:当前宏任务结束后,立即执行所有微任务
  3. 微任务队列必须完全清空:包括执行过程中新加入的微任务
  4. 按序执行宏任务:微任务清空后才执行下一个宏任务

🚀 详细执行机制解析

第一阶段:同步代码执行(立即执行)

在示例一中:

  1. setTimeout1 回调加入宏任务队列
  2. Promise 构造函数同步执行,输出 pr1then1 加入微任务队列
  3. setTimeout2 回调加入宏任务队列
  4. console.log(2) 输出 2
  5. queueMicrotask 回调加入微任务队列
  6. 第二个 Promisethen3 加入微任务队列

在示例二中:

  1. console.log("script start") 输出
  2. setTimeout 加入宏任务队列
  3. 执行 async1()
    • 输出 async1 start
    • 执行 async2(),输出 async2
    • await 后的代码转为微任务
  4. new Promise 构造函数同步执行,输出 promise1
  5. console.log("script end") 输出

第二阶段:微任务执行(同步代码完成后)

关键特性:微任务执行期间产生的新微任务会加入当前队列并继续执行

示例一微任务顺序:

  1. then1 → 输出 then1
  2. microtask → 输出 microtask
  3. then3 → 输出 then3

示例二微任务顺序:

  1. async1 end → 输出 async1 end
  2. promise2 → 输出 promise2

第三阶段:宏任务执行(微任务清空后)

按加入队列的顺序执行宏任务:

示例一:

  1. 第一个 setTimeout 回调:
    • 输出 set!
    • 执行 Promise.then,输出 then2
    • 内部 Promise.then 加入微任务队列
    • 清空新产生的微任务,输出 then4
  2. 第二个 setTimeout 回调:输出 set2

示例二:

  1. setTimeout 回调:输出 setTimeout

💡 关键技术点详解

1. Promise执行机制

new Promise((resolve) => {
  console.log("构造函数内 - 同步执行");  // 同步代码
  resolve();
}).then(() => {
  console.log("then回调 - 微任务");      // 微任务
});

2. async/await本质

// async/await 写法
async function example() {
  console.log("A");          // 同步执行
  await someFunction();      // 同步执行someFunction,后面的代码转为微任务
  console.log("B");          // 微任务
}

// 等价 Promise 写法
function example() {
  console.log("A");          // 同步执行
  return Promise.resolve(someFunction()).then(() => {
    console.log("B");        // 微任务
  });
}

3. 微任务嵌套执行

微任务执行期间产生的新的微任务,会在当前事件循环中被执行,不会延迟到下一轮:

Promise.resolve().then(() => {
  console.log("微任务1");
  Promise.resolve().then(() => {
    console.log("嵌套微任务");  // 会在当前微任务循环中执行
  });
});

🎓 记忆口诀

同步先行微随后,
宏任务在最后头。
await前是同步走,
await后微任务留。
微任务清空才宏任务,
新微任务,继续跟。

📊 事件循环流程图

┌─────────────────────────┐
│     同步代码执行栈        │ ← 立即执行
└─────────────────────────┘
           ↓
┌─────────────────────────┐
│       微任务队列          │ ← 完全清空(包括嵌套)
│   • Promise.then        │
│   • await后的代码        │
│   • queueMicrotask      │
└─────────────────────────┘
           ↓
┌─────────────────────────┐
│       宏任务队列          │ ← 按序执行
│   • setTimeout/setInterval│
│   • I/O回调              │
│   • UI渲染              │
└─────────────────────────┘
           ↓
        循环执行

🏆 面试要点总结

  1. 同步代码永远最先执行
  2. 微任务在同步代码之后、宏任务之前执行
  3. async函数中,await前的代码同步执行
  4. await后的代码作为微任务执行
  5. Promise构造函数是同步的,只有.then()才是微任务
  6. 微任务队列必须完全清空后才执行宏任务
  7. 微任务执行期间产生的新微任务会立即加入当前队列
  8. 多个微任务按入队顺序执行
  9. 宏任务按事件循环顺序执行

📚 扩展知识

复杂场景分析

async function nestedAsync() {
  console.log("A");              // 同步
  await Promise.resolve();
  console.log("B");              // 微任务1
  await Promise.resolve();
  console.log("C");              // 微任务2(在微任务1中产生)
}
// 输出: A → B → C(每个await后都是新的微任务)

浏览器与Node.js差异

  • Node.js中有process.nextTick,优先级高于Promise微任务
  • Node.js的setImmediatesetTimeout执行时机有所不同

性能考虑

  • 避免在微任务中执行耗时操作,会阻塞UI渲染
  • 合理拆分任务,避免长时间占用事件循环

掌握这些核心概念,你就能准确预测任何JavaScript异步代码的执行顺序,这是前端面试中的高频考点,也是编写可靠异步代码的基础!

JavaScript的数据类型 —— Undefined类型

作者 橘朵
2026年1月27日 17:24

在 JavaScript 中,Undefined类型是一个特殊且基础的数据类型,它表示一个变量最原始的自然状态——"未定义"。

Undefined类型只有一个值,就是 undefined

当使用varlet声明了变量但没有初始化时,就相当于给变量赋予了undefined值。

let message;  
console.log(message == undefined); // true 
//等于
let message = undefined ; //默认情况下,任何未经初始化的变量都会取得undefined值,因此,不必显式的初始化为undefined。
console.log(message == undefined); // true 

可以通过 typeof操作符来确认一个变量是否为 Undefined类型。无论是声明还是未声明,typeof返回的都是字符串"undefined"。

// 变量被声明了,但未初始化,值为 undefined 
let message; 

console.log(message); // "undefined"  
console.log(age); // 没有声明变量,报错

console.log(typeof message); // "undefined" 
console.log(typeof age); // "undefined"

//访问对象不存在的属性
let obj = {};
console.log(obj.name); // undefined

//函数没有返回值
function foo() {} 
console.log(foo()); // undefined

//函数参数未传递
function bar(param) { 
console.log(param); 
} 
bar(); // undefined

//使用 void 操作符
console.log(void(0)); // undefined

使用严格相等进行检查:使用 ===来严格判断一个值是否为 undefined,避免使用 ==(因为 undefined == nulltrue)。

if (myVar === undefined) { /* 处理 undefined */ }

安全地访问对象属性(可选链操作符 ?.):当访问一个可能不存在的深层嵌套属性时,可选链操作符可以避免抛出错误。

let city = user?.address?.city; // 如果 address 不存在,则 city 为 undefined,而非报错。

提供默认值(空值合并运算符 ??):当变量为 undefinednull时,空值合并运算符可以提供一个默认值。它与 ||不同,不会过滤掉其他假值(如 0, "")。

let displayName = username ?? "匿名用户"; // 仅在 username 是 null 或 undefined 时使用默认值。

安全的类型检查:如果要检查一个可能未声明的变量(直接使用会报引用错误),使用 typeof是最安全的方式。

if (typeof someUndeclaredVar === 'undefined') {
  // 不会报错,即使 someUndeclaredVar 从未被声明。
}

HMRouter 的简单使用

作者 xym
2026年1月27日 17:20

HMRouter 的简单使用

1.针对使用配置按照使用文档即可;链接如下:

OpenHarmony三方库中心仓

2.在引入库后,可能针对不同的module 配置有所差异,module 包分为 hap,har,hsp 大致三种格式;在module下 havigorfile.ts 中增加如下代码

//har library 
import { harPlugin } from '@hadss/hmrouter-plugin';
export default {
  plugins:[harPlugin()]      /* Custom plugin to extend the functionality of Hvigor. */
}
//hap
import { modulePlugin } from '@hadss/hmrouter-plugin';
export default {
  plugins: [modulePlugin()] // 使用HMRouter标签的模块均需要配置,与模块类型保持一致
}
//hsp
import { hspPlugin } from '@hadss/hmrouter-plugin';
export default {
  plugins: [hspPlugin()]     

执行跳转任务:

首先需要定义路由入口参考文档即可;下面代码是直接搬过来的;

@Entry
@Component
export struct Index {
  modifier: MyNavModifier = new MyNavModifier();

  build() {
    // @Entry中需要再套一层容器组件,Column或者Stack
    Column(){
      // 使用HMNavigation容器
      HMNavigation({
        navigationId: 'MainNavigation', homePageUrl: 'HomePage',
        options: {
          standardAnimator: HMDefaultGlobalAnimator.STANDARD_ANIMATOR,
          dialogAnimator: HMDefaultGlobalAnimator.DIALOG_ANIMATOR,
          modifier: this.modifier
        }
      })
    }
    .height('100%')
    .width('100%')
  }
}

class MyNavModifier extends AttributeUpdater<NavigationAttribute> {
  initializeModifier(instance: NavigationAttribute): void {
    instance.hideNavBar(true);
  }
}

跳转任务:

1.简单跳转携带参数:

首先在目标页面定义路径如下:

@HMRouter({ pageUrl: PAGE_ABOUT })
HMRouterMgr.push({pageUrl: PAGE_ABOUT,param:10})//方式一
HMRouterMgr.to(PAGE_ABOUT)
             .withParam(10)
             .push()//方式二

如果想要针对跳转路径进行统一的管理,可以创建一个.ets 文件,在内部定义静态常量如下(不用写在静态类中,如果写在类中,就无法找到路径,报错):

export const  PAGE_HOME:string = '/home'
export const  PAGE_ABOUT:string = '/about'
export const  PAGE_HOT:string = '/hot'
export const  PAGE_MAIN:string = '/main'

2.传递自定义的 bean

HMRouterMgr.push({pageUrl:PAGE_ABOUT,param:new UserBean(10,'小明')})

3.跳转并添加回调监听

         //跳转携带参数
          HMRouterMgr.push({ pageUrl: PAGE_ABOUT, param: '小明' }, {
            onResult(popInfo: HMPopInfo) {
              //这个是那个页面返回的路由信息
              // const srcName = popInfo.srcPageInfo.name
              //这个是跳转到那个页面的路由信息
              // const info = popInfo.info.name
              //接收的参数是String,
              // const result = popInfo.result as string
              //获取到返回值
              // const  result = popInfo.result as UserBean
              // console.log("-----------> params " + srcName + "..... result ... " + result.name+' .... '+result.age + '.... ' + info)
            }
            // ,
            // onArrival() {
            //   //属于跳转触发的时候执行的方法
            //   console.log("-----------> params arrive")
            // },
            // onLost() {
            //   //出现问题时报错
            //   console.log("-----------> params onLost")
            // }
          })

4.跳转到下个页面后,当前页面销毁;

      HMRouterMgr.to(PAGE_HOT)
        .withParam('小明')
         .replace()

注意:在使用 replace 的时候,如果在一级页面设置的回调监听,这个时候,在下级页面再次调用pop 方法回传数据将失效;在一级页面无法接受到数据;

 同时如果使用replace 跳转的时候,会导致返回出错有时;不清楚是否是模拟器问题;

5.点击退出当前页面

   HMRouterMgr.pop()
   //如果想要退出到指定页面
   HMRouterMgr.to(PAGE_MAIN)
          .withParam('小明')
          .pop()

设置拦截器:

首先创建拦截器的类

/**
 *   interceptorName: string;// 拦截器的名字
priority?: number; //拦截器的优先级
global?: boolean;//是不是全局拦截 如果设置了全局拦截器,就不需要再 指定页面的路径参数中配置该拦截器了
 */
@HMInterceptor({ interceptorName: JUMP_INFO_INTERCEPTOR })
export class JumpInfoInterceptor implements IHMInterceptor {
  handle(info: HMInterceptorInfo): HMInterceptorAction {
    let connectionInfo: string = info.type === 'push' ? 'jump to' : 'back to';
    // info.srcName//属于触发源,就是从那个页面开始跳的 路由信息
    // info.targetName//这个是目标源,也就是跳转到那个页面
    // info.type//属于触发的方法。例如是  push  pushAsync 对应的 type 都是push

    console.log('-----------> params JumpInfoInterceptor '+`${info.srcName} ${connectionInfo} ${info.targetName}  ${info.type}`)
    //设置了跳转拦截器
    switch (info.targetName){
      case PAGE_ABOUT:
        HMRouterMgr.to(PAGE_HOT)
          .withParam('小明')
          .replace()//目的就是把跳转的 about 页面给替换成 hot 页面
          // .push() 如果使用 push 的话,就会在返回的时候,一致在退出到 hot 页面
        // return HMInterceptorAction.DO_REJECT;//针对特殊的路径进行拒绝跳转
      return HMInterceptorAction.DO_TRANSITION
      default :
        return HMInterceptorAction.DO_NEXT;//不进行拦截进行跳转
    }
  }
}

然后添加拦截器

@HMRouter({ pageUrl: PAGE_ABOUT,interceptors:[JUMP_INFO_INTERCEPTOR] })

按照上述方式就可以进行跳转拦截,如果想要全局拦截,就更改global为true ,不需要在@HMRouter 中添加拦截器;

生命周期监听:

注意在生命周期拦截后,页面的生命周期方法只有aboutToAppear 和aboutToDisAppear会相应,其他的方法将被拦截,无法相应;

使用案例如下:

@HMLifecycle({ lifecycleName: 'PageDurationLifecycle' })
export class PageDurationLifecycle implements IHMLifecycle {
  private time: number = 0;
  module = new ARouterLifecycleModule()
   //重写方法即可
  onShown(ctx: HMLifecycleContext): void {
    this.time = new Date().getTime();
    // console.log('-----------> params  onShown ')
    this.module.status = 'onShown'
  }
    ....

}

页面中进行监听

 @HMRouter({ pageUrl: PAGE_ABOUT,lifecycle:'PageDurationLifecycle'})

    //获取到生命周期中的 module
  @Local module: ARouterLifecycleModule | null =
   (HMRouterMgr.getCurrentLifecycleOwner()?.getLifecycle() as PageDurationLifecycle).module
  // //监听状态值的改变
   @Monitor('module.status')
  private onModuleStatueChange(): void {
  console.log("-----------> params onModuleChange " + this.module?.status)
  }

module:

@ObservedV2
export class ARouterLifecycleModule{
  @Trace
  status?:string

}

对Router 跳转进行封装:

    因为 router 跳转传递参数,不能想 Android ARouter.withxxx 的方法传递多组,同时在获取数据的时候;也没有想 intent.getStringExtra()这种类似的获取方式;

    同时对应使用三方库的使用需要进行封装管理,为了避免后台更换跳转仓库,进行每个页面进行更换;

想法如下:

1.创建一个 Arouter.该类用于执行跳转功能,以及获取到页面传递数据功能;

2.创建一个 BundleModlue:该类内部是一个 map 集合,用于缓存跳转携带的参数;

3.创建一个 navigator 类:该类用于执行跳转任务,也就是最终的跳转方式,该类中包含了三方库的跳转方式;后期想要更换,直接替换该类即可;

4.创建一个数据接收管理类ARouterParamsModule:主要用于接收数据,然后将数据存储到 Bundle 中;并接收跳转需要的配置参数,例如 path,animator,skipAllInterceptor等;同时在跳转的时候,会将该类传递到 navigation 中,方便跳转的执行;

5.创建一个数据解析类ARouterParamsModule :该类主要用于解析传递的数据支持通过 getString,getNumber 等形式获取到数据,const bean = bundle.getCustomType('bean')获取创建自定义类型数据;

❌
❌