普通视图

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

告别字体闪烁 / 首屏卡顿!preload 让关键资源 “高优先级” 提前到

2025年12月12日 23:43

⚡️ 浏览器“未卜先知”的秘密:资源提示符,让你的页面加载速度快人一步!

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

在前端性能优化的战场上,时间就是金钱,尤其是在页面加载的关键时刻。我们上一篇讲到 PerformanceObserver 可以精准地测量性能,但测量只是第一步,更重要的是主动出击,让浏览器在用户需要资源之前,就提前做好准备。

今天,我们就来揭秘浏览器“未卜先知”的秘密武器——资源提示符(Resource Hints)


💡 什么是资源提示符?

资源提示符(Resource Hints)是 <link> 标签 rel 属性的一组特殊值,用于告诉浏览器未来即将发生的资源处理策略,让它提前做准备

简单来说,它们是开发者给浏览器下达的“预处理指令”,让浏览器在空闲或关键时刻,提前完成一些耗时的网络操作,从而:

  • 提高网页的首屏加载性能
  • 减少 DNS、TCP、TLS 等连接延迟
  • 预加载关键或预测性资源
<!-- 资源提示符示例 -->
<link rel="preconnect" href="//cdn.example.com">

🔧 四大金刚:资源提示符的家族成员

资源提示符家族主要有四个核心成员,它们各有神通,针对不同的优化场景:

1. dns-prefetch:最小开销的“打听”

<link rel="dns-prefetch" href="//api.example.com">
  • 作用: 仅提前解析 DNS,将域名解析为 IP 地址,不建立连接

  • 开销: 最小,兼容性最好。

  • 使用场景:

    • 非关键的第三方资源(如分析脚本、广告、插件)。
    • 可作为 preconnect降级方案

专业名词解释:DNS 解析 DNS(Domain Name System)解析是将人类可读的域名(如 www.google.com)转换为机器可读的 IP 地址(如 142.250.190.14)的过程。这是一个网络请求的起点,通常需要几十到几百毫秒。

2. preconnect:提前握手的“老朋友”

<link rel="preconnect" href="//cdn.example.com" crossorigin>
  • 作用: 完成 DNS 解析 + TCP 握手 + TLS 加密握手,全流程建立连接。

  • 效果: 极大地消除了后续资源请求的网络延迟。

  • 使用时机:

    • 字体库核心 APICDN 静态资源关键第三方域名
    • 注意: 建立连接会消耗资源,建议控制数量(一般建议 ≤6 个)。

Preconnect 提前握手过程示意图

3. preload:高优先级的“快递”

<link rel="preload" href="font.woff2" as="font" type="font/woff2" crossorigin>
  • 作用: 直接以高优先级下载关键资源,但下载后暂不执行

  • 特点: 提前触发关键资源的加载,确保资源在需要时立即可用。

  • 常见场景:

    • CSS 定义的字体文件(避免文本闪烁 FOUT/FOIT)。
    • 背景图或 LCP 元素图片(加速最大内容绘制)。
    • 首屏必需的动态脚本

注意: preload 必须配合 as 属性指定资源类型,否则浏览器会重复下载。

4. prefetch:空闲时的“下一站”

<link rel="prefetch" href="next-page.js">
  • 作用:当前页加载完成后,利用浏览器空闲时间请求资源。

  • 特点: 优先级最低,不会与当前页面的关键资源竞争带宽。

  • 使用场景:

    • 优化“下一个页面”的加载体验
    • SPA 路由中,预取用户可能访问的下一个 chunk
    • 基于用户行为预测的预加载。

💡 总结:让资源“早一步”准备好

资源提示符家族的目标一致:让资源“早一步”准备好

它们的核心区别在于时机与深度

提示符 深度(提前到哪一步) 时机(何时触发) 优先级 适用场景
dns-prefetch 仅 DNS 解析 尽早 非关键第三方资源
preconnect DNS + TCP + TLS 尽早 关键第三方域名
preload 下载资源 尽早(高优先级) 当前页面的关键资源
prefetch 下载资源 页面空闲时 最低 下一个页面的资源

资源提示符概览图

重要提醒: 资源提示符虽好,但过度使用可能导致浪费带宽或建立过多连接,反而拖慢性能。请务必根据实际的性能数据(比如 RUM 采集的数据)来合理规划和使用。


下一篇预告: 既然资源都提前加载了,如何让它们在下次访问时更快出现呢?下一篇我们将深入探讨前端性能优化的“节流大师”——HTTP 缓存机制。敬请期待!

性能数据别再瞎轮询了!PerformanceObserver 异步捕获 LCP/CLS,不卡主线程

2025年12月12日 23:38

🚀 性能监控的“最强大脑”:PerformanceObserver API,如何让你告别轮询的噩梦?

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

在上一篇中,我们聊到了 RUM(真实用户监控)是如何帮助我们打破“薛定谔的 Bug”魔咒的。既然 RUM 是性能监控的“雷达”,那么谁来负责实时、精准地采集数据呢?

答案就是今天的主角——PerformanceObserver API。它就像是浏览器内置的“高性能数据采集器”,彻底改变了我们获取性能数据的方式。


⚠️ 为什么需要 PerformanceObserver?告别“老黄历”

在 PerformanceObserver 出现之前,我们获取性能数据的方式,简直就是一场“噩梦”:

传统方式:性能监控的“老黄历”

  1. performance.timingperformance.getEntries()

    • 问题: 这些 API 只能获取页面加载完成那一刻的静态数据。对于像 First Input Delay (FID) 这种发生在用户交互过程中的动态指标,它们就无能为力了。
    • 痛点: 想要获取实时数据?你只能轮询(不断地去问:“数据好了吗?好了吗?”)。这种方式不仅时机难以掌握,还会带来额外的性能开销,甚至可能阻塞主线程,让页面更卡!

专业名词解释:轮询 (Polling) 轮询是一种计算机通信技术,指客户端程序或设备不断地向服务器程序或设备发送请求,以查询是否有新的数据或状态更新。在前端性能监控中,轮询意味着需要定时检查性能数据是否生成,效率低下且消耗资源。

✨ 优化方案:事件驱动的“高性能引擎”

PerformanceObserver 的出现,彻底解决了轮询的痛点。它提供了一种事件驱动、异步回调的机制:

  • 高效、非阻塞: 它在浏览器记录到性能事件时,会异步通知你,不会阻塞主线程。
  • 实时性: 能够实时捕获动态指标,如用户首次输入延迟(FID)和布局偏移(CLS)。
  • 可订阅: 你可以像订阅报纸一样,选择你感兴趣的性能事件类型。

🔄 PerformanceObserver 的工作原理:三步走战略

PerformanceObserver 的使用流程非常简洁,可以概括为“创建、指定、接收”三步走战略:

步骤 1:创建观测器(Observer)

首先,我们需要创建一个 PerformanceObserver 实例,并传入一个回调函数 (callback)

const observer = new PerformanceObserver((list) => {
  // 浏览器在记录到性能条目时,会自动异步触发这个回调函数
  // list.getEntries() 包含了所有被观测到的性能数据
})

工作原理揭秘: 浏览器在内部记录性能数据时,会检查是否有 PerformanceObserver 在监听。如果有,它就会将最新的性能条目(Performance Entry)打包,并在下一个空闲时机(异步)调用你提供的回调函数。

步骤 2:指定观测目标(Observe)

创建好观测器后,你需要明确告诉它:“我想看哪些数据? ” 这通过 observer.observe() 方法实现,你需要指定一个或多个 entryTypes

observer.observe({
  entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift']
})

常见的核心观测指标:

entryType 对应指标 含义
largest-contentful-paint LCP 最大内容绘制时间,衡量加载速度。
first-input FID 首次输入延迟,衡量交互响应速度。
layout-shift CLS 累积布局偏移,衡量视觉稳定性。
resource Resource Timing 资源加载(图片、CSS、JS)的详细耗时。

PerformanceObserver 与传统方式对比图

步骤 3:接收和处理数据(Callback)

在回调函数中,你可以通过 list.getEntries() 获取到所有新产生的性能条目。每个条目(Entry)都是一个包含详细信息的对象。

示例:基础用法

const observer = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    console.log('指标名称:', entry.name)
    console.log('开始时间:', entry.startTime)
    console.log('持续时间:', entry.duration)

    // 针对不同指标进行特殊处理,例如获取 CLS 的具体值
    if (entry.entryType === 'layout-shift') {
      console.log('CLS 值:', entry.value)
    }
    // 在这里将数据上报到 RUM 服务器
  }
})

observer.observe({
  entryTypes: ['largest-contentful-paint', 'first-input', 'layout-shift']
})

总结:PerformanceObserver 的核心优势

PerformanceObserver 是前端性能监控领域的一次重大飞跃,它的核心优势在于:

  • 实时性: 事件驱动,性能数据一产生就能被捕获,无需低效的轮询。
  • 低开销: 异步执行,不占用主线程资源,对用户体验影响极小。
  • 可扩展: 通过 entryTypes,可以轻松订阅未来浏览器新增的各种性能事件。
  • 易集成: 它是现代 RUM 监控体系中,最核心、最可靠的数据采集组件。

结论: PerformanceObserver 是构建前端性能可观测性的核心组件,它让我们从“猜测性能”迈向了 “数据驱动的性能优化” ,让性能数据采集变得高效、优雅。


下一篇预告: 既然我们能精准地测量性能了,下一步就是如何主动出击,让浏览器提前加载资源。下一篇我们将深入讲解前端性能优化的“预加载神器”——浏览器资源提示符。敬请期待!

GDAL 读取KML数据

作者 GIS之路
2025年12月12日 22:52

前言

KML是一种基于XML的地理数据格式,最初有Keyhole公司开发,后来被Google采用并成为OGC标准。在GIS开发中,属于一种重要的数据格式,使用GDAL读取KML数据,有助于认识、了解KML数据结构与特点,从而提高开发效率。

本篇教程在之前一系列文章的基础上讲解

  • GDAL 简介[1]
  • GDAL 下载安装[2]
  • GDAL 开发起步[3]

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

1. 开发环境

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

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 导入依赖

KML作为一种矢量数据格式,可以使用GDAL直接读取或者使用其矢量库OGR进行处理,以实现KML图层和属性数据读取。

from osgeo import ogr,gdal
import os

3. 读取KML数据

(一)使用GDAL读取

定义一个方法ReadKMLOfGDAL(kmlPath)用于读取KML数据,其中kmlPath为数据路径。在读取KML数据之前,需要检查数据路径是否存在。

# 检查文件是否存在
if os.path.exists(kmlPath):
    print("文件存在")
else:
    print("文件不存在,请重新选择文件!")
    return

KML数据路径正确,则可以使用OpenEx方法打开KML文件。需要判断KML数据集是否正常,若无法打开,则退出数据读取程序。

# 打开KML文件
dataset = gdal.OpenEx(kmlPath)
if dataset is None:
    print("KML 文件打开异常,请检查文件路径!")
    return

通过数据集方法GetLayerCount可以获取图层数量。

# 获取图层数量
layerCount = dataset.GetLayerCount()
print(f"图层数量:{layerCount}")

图层数量信息显示如下:

之后通过遍历图层获取图层字段数量、字段名称以及字段类型等信息,在输出结果中读取要素属性信息和几何对象并限制要素输出数量。

# 遍历图层
for i in range(layerCount):
    print(f"################开始打印第【{i+1}】个图层################n")
    # 根据索引获取目标图层
    layer = dataset.GetLayerByIndex(i)
    # 获取图层名称
    layerName = layer.GetName()
    # 获取图层要素数量
    layerFeatureCount = layer.GetFeatureCount()

    print(f"图层名称:{layerName}")
    print(f"要素数量:{layerFeatureCount}")

    # 获取图层属性
    layerProperty = layer.GetLayerDefn()
    # 获取图层字段数量
    fieldCount = layerProperty.GetFieldCount()
    print(f"字段数量:{fieldCount}")

    # 获取字段信息
    for j in range(fieldCount):
        # 获取字段属性对象
        fieldProperty = layerProperty.GetFieldDefn(j)
        # 获取字段属性名称
        fieldName = fieldProperty.GetName()
        # 获取字段属性类型
        fieldType = fieldProperty.GetTypeName()

        print(f"第 【{j}】 个字段名称:{fieldName},字段类型:{fieldType}")

    # 获取要素
    feature = layer.GetNextFeature()
    limitCount = 0

    # 限制打印前十个要素
    while feature and limitCount < 10:
        print(f"打印第【{limitCount+1}】个要素")
        # print(f"打印要素类型:{type(feature)},{feature}")

        # 读取要素属性
        for k in range(fieldCount):
            # 属性字段名
            fieldName = layerProperty.GetFieldDefn(j).GetName()
            # 属性字段值
            fieldValue = feature.GetField(k)
            # fieldValue = feature.GetField(fieldName)

            print(f"第 【{k}】 个字段名:{fieldName},字段值:{fieldValue}")

        # 读取几何属性
        geom = feature.GetGeometryRef()
        if geom:
            # 获取几何类型
            geomType = geom.GetGeometryName()
            # 获取WKT格式几何对象,打印前100个字符
            geomWKT = geom.ExportToWkt()[:100]

            print(f"第 【{limitCount}】 个几何对象类型:{geomType},几何对象:{geomWKT}")

        feature = layer.GetNextFeature()
        limitCount += 1

    # 重置读取位置
    layer.ResetReading()

    print(f"n################结束打印第【{i+1}】个图层################n")

图层要素属性信息显示如下:

(二)使用OGR读取

定义一个方法ReadKMLOfOGR(kmlPath)用于读取KML数据,其中kmlPath为数据路径。在读取KML数据之前,需要检查数据路径是否存在。

# 检查文件是否存在
if os.path.exists(kmlPath):
    print("文件存在")
else:
    print("文件不存在,请重新选择文件!")
    return

KML数据路径正确,则可以注册KML数据驱动用于读取KML数据,如使用RegisterAll方法注册所有矢量驱动。然后调用ogr对象Open方法打开KML数据源,若其不存在,则退出数据读取程序。

# 注册所有驱动
ogr.RegisterAll()

# 打开KML数据源
dataSource = ogr.Open(kmlPath)

# 检查数据源是否正常
if dataSource is None:
    print("文件打开出错,请重新选择文件!")
    return

之后通过遍历图层获取图层空间参考、字段名称以及字段类型等信息,在输出结果中读取要素属性信息。

# 遍历图层
for i in range(dataSource.GetLayerCount()):
    # 根据索引获取目标图层
    layer = dataSource.GetLayer(i)
    # 获取图层名称
    layerName = layer.GetName()
    print(f"第【{i}】个图层名称:{layerName}")

    # 获取空间参考
    spatialReference = layer.GetSpatialRef()
    if spatialReference:
        print(f"空间参考:{spatialReference.GetName()}")
    else:
        print(f"图层【{layerName}】空间参考不存在")

    # 读取几何属性
    for feature in layer:
        # 读取几何属性
        geom = feature.GetGeometryRef()
        if geom:
            # 获取四至范围
            envelope = geom.GetEnvelope()
            print(f"几何范围:{envelope}")

        # 读取要素属性
        for field in feature.keys():
            # 获取属性字段值
            fieldValue = feature.GetField(field)
            print(f"属性字段名称:{field},属性字段值:{fieldValue}")

# 关闭数据源
dataSource = None        

图层要素属性信息显示如下:

4. 注意事项

注1:数据路径读取异常

在windows系统中建议使用"\"定义数据路径。

注2:中文数据读取异常(中文乱码)

GIS开发中,涉及属性数据读取时经常会遇到中文乱码问题,需要根据图层编码设置正确的字符集。

# 设置Shapefile的编码为GBK
os.environ['SHAPE_ENCODING'] = "GBK"

注3:代码运行异常

需要开启代码异常处理

# 启用异常处理(推荐)
ogr.UseExceptions()

注4:坐标读取异常

在读取坐标参考时报错已安装PostgreSQL数据库中的投影文件版本与GDAL中的投影文件不兼容,此时需要为GDAL单独指定投影文件,在代码开头添加以下代码指定目标投影文件路径。

# 找到proj文件路径
os.environ['PROJ_LIB'] = r'D:\Programs\Python\Python311\Lib\site-packages\osgeo\data\proj'

5. 完整代码

from osgeo import ogr,gdal
import os

# 如果是通过 pip 安装的,可能需要找到对应位置
os.environ['PROJ_LIB'] = r'D:ProgramsPythonPython311Libsite-packagesosgeodataproj'

# 设置Shapefile的编码为GBK
os.environ['SHAPE_ENCODING'] = "GBK"

# 启用异常处理(推荐)
ogr.UseExceptions()

# 注册所有驱动
ogr.RegisterAll()

"""
使用GDAL读取KML数据
"""
def ReadKMLOfGDAL(kmlPath):

    # 检查文件是否存在
    if os.path.exists(kmlPath):
        print("文件存在")
    else:
        print("文件不存在,请重新选择文件!")
        return

    # 打开KML文件
    dataset = gdal.OpenEx(kmlPath)
    if dataset is None:
        print("KML 文件打开异常,请检查文件路径!")
        return

    # 获取图层数量
    layerCount = dataset.GetLayerCount()
    print(f"图层数量:{layerCount}")

    # 遍历图层
    for i in range(layerCount):
        print(f"################开始打印第【{i+1}】个图层################n")
        # 根据索引获取目标图层
        layer = dataset.GetLayerByIndex(i)
        # 获取图层名称
        layerName = layer.GetName()
        # 获取图层要素数量
        layerFeatureCount = layer.GetFeatureCount()

        print(f"图层名称:{layerName}")
        print(f"要素数量:{layerFeatureCount}")

        # 获取图层属性
        layerProperty = layer.GetLayerDefn()
        # 获取图层字段数量
        fieldCount = layerProperty.GetFieldCount()
        print(f"字段数量:{fieldCount}")

        # 获取字段信息
        for j in range(fieldCount):
            # 获取字段属性对象
            fieldProperty = layerProperty.GetFieldDefn(j)
            # 获取字段属性名称
            fieldName = fieldProperty.GetName()
            # 获取字段属性类型
            fieldType = fieldProperty.GetTypeName()

            print(f"第 【{j}】 个字段名称:{fieldName},字段类型:{fieldType}")

        # 获取要素
        feature = layer.GetNextFeature()
        limitCount = 0

        # 限制打印前十个要素
        while feature and limitCount < 10:
            print(f"打印第【{limitCount+1}】个要素")
            # print(f"打印要素类型:{type(feature)},{feature}")

            # 读取要素属性
            for k in range(fieldCount):
                # 属性字段名
                fieldName = layerProperty.GetFieldDefn(j).GetName()
                # 属性字段值
                fieldValue = feature.GetField(k)
                # fieldValue = feature.GetField(fieldName)

                print(f"第 【{k}】 个字段名:{fieldName},字段值:{fieldValue}")

            # 读取几何属性
            geom = feature.GetGeometryRef()
            if geom:
                # 获取几何类型
                geomType = geom.GetGeometryName()
                # 获取WKT格式几何对象,打印前100个字符
                geomWKT = geom.ExportToWkt()[:100]

                print(f"第 【{limitCount}】 个几何对象类型:{geomType},几何对象:{geomWKT}")

            feature = layer.GetNextFeature()
            limitCount += 1

        # 重置读取位置
        layer.ResetReading()

        print(f"n################结束打印第【{i+1}】个图层################n")

"""
使用OGR读取KML数据
"""
def ReadKMLOfOGR(kmlPath):

    # 检查文件是否存在
    if os.path.exists(kmlPath):
        print("文件存在")
    else:
        print("文件不存在,请重新选择文件!")
        return
    # 注册所有驱动
    ogr.RegisterAll()

    # 打开KML数据源
    dataSource = ogr.Open(kmlPath)

    # 检查数据源是否正常
    if dataSource is None:
        print("文件打开出错,请重新选择文件!")
        return

    # 遍历图层
    for i in range(dataSource.GetLayerCount()):
        # 根据索引获取目标图层
        layer = dataSource.GetLayer(i)
        # 获取图层名称
        layerName = layer.GetName()
        print(f"第【{i}】个图层名称:{layerName}")

        # 获取空间参考
        spatialReference = layer.GetSpatialRef()
        if spatialReference:
            print(f"空间参考:{spatialReference.GetName()}")
        else:
            print(f"图层【{layerName}】空间参考不存在")

        # 读取几何属性
        for feature in layer:
            # 读取几何属性
            geom = feature.GetGeometryRef()
            if geom:
                # 获取四至范围
                envelope = geom.GetEnvelope()
                print(f"几何范围:{envelope}")

            # 读取要素属性
            for field in feature.keys():
                # 获取属性字段值
                fieldValue = feature.GetField(field)
                print(f"属性字段名称:{field},属性字段值:{fieldValue}")

    # 关闭数据源
    dataSource = None        

if __name__ == "__main__":

    # 数据路径
    kmlPath = "E:\data\test_data\四姑娘山三峰.kml"

    # GDAL读取KML数据
    ReadKMLOfGDAL(kmlPath)

    # OGR读取KML数据
    ReadKMLOfOGR(kmlPath)

6. KML示例数据

<?xml version="1.0" encoding="utf-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2">
  <Document>
    <name>Track 登顶四姑娘山三峰 :wikiloc.com</name>
    <visibility>1</visibility>
    <LookAt>
      <longitude>102.8793075</longitude>
      <latitude>31.0426283</latitude>
      <altitude>0</altitude>
      <heading>3</heading>
      <tilt>66</tilt>
      <range>15000</range>
    </LookAt>
    <StyleMap id="m1367020">
      <Pair>
        <key>normal</key>
        <styleUrl>#n1367020</styleUrl>
      </Pair>
      <Pair>
        <key>highlight</key>
        <styleUrl>#h1367020</styleUrl>
      </Pair>
    </StyleMap>
    <Style id="h1367020">
      <IconStyle>
        <Icon>
          <href>http://s1.wklcdn.com/wikiloc/images/pictograms/ge/1.png</href>
        </Icon>
      </IconStyle>
      <BalloonStyle>
        <text>$[description]</text>
      </BalloonStyle>
    </Style>
    <Style id="lineStyle">
      <LineStyle>
        <color>f03399ff</color>
        <width>4</width>
      </LineStyle>
    </Style>
    <Style id="n1367020">
      <LabelStyle>
        <scale>0</scale>
      </LabelStyle>
      <BalloonStyle>
        <text>$[description]</text>
      </BalloonStyle>
      <Icon>
        <href>http://s1.wklcdn.com/wikiloc/images/pictograms/ge/1.png</href>
      </Icon>
    </Style>
    <Style id="waypointStyle">
      <IconStyle>
        <Icon>
          <href>http://sc.wklcdn.com/wikiloc/images/pictograms/ge/wpt.png</href>
        </Icon>
      </IconStyle>
      <BalloonStyle>
        <text>$[description]</text>
      </BalloonStyle>
    </Style>
    <Folder>
      <name>Trails</name>
      <visibility>1</visibility>
      <Folder>
        <name>登顶四姑娘山三峰</name>
        <visibility>1</visibility>
        <Placemark>
          <name>Path</name>
          <visibility>1</visibility>
          <LookAt>
            <longitude>102.8862617</longitude>
            <latitude>31.052715</latitude>
            <altitude>0</altitude>
            <heading>0</heading>
            <tilt>0.00779126500014642</tilt>
            <range>5250.96911517065</range>
          </LookAt>
          <Style>
            <IconStyle>
              <color>ffffffff</color>
              <scale>1</scale>
              <Icon>
                <href/>
              </Icon>
            </IconStyle>
            <LabelStyle>
              <color>ffffffff</color>
              <scale>1</scale>
            </LabelStyle>
            <LineStyle>
              <color>f00000ff</color>
              <width>4</width>
            </LineStyle>
            <PolyStyle>
              <color>ffffffff</color>
              <fill>1</fill>
              <outline>1</outline>
            </PolyStyle>
          </Style>
          <LineString>
            <altitudeMode>clampToGround</altitudeMode>
            <coordinates>
              102.8527267,31.0061667,3255.400146
              102.8530967,31.00604,3254.899902
              102.8537967,31.0060883,3256.899902
              102.8547817,31.0064133,3270.100098
              102.8558183,31.0071067,3271.100098
              102.8575333,31.00785,3271.699951
              102.8588867,31.0093867,3278.899902
              102.8599,31.0099067,3281.5
              102.8605217,31.01093,3289.899902
              102.8613217,31.0128967,3298.899902
              102.863045,31.014905,3307.199951
              102.8638983,31.016515,3313.100098
              102.8639067,31.01642,3306.699951
              102.86423,31.0168667,3317.199951
              102.8645867,31.017765,3330.000244
              102.8655283,31.0190083,3314.100342
              102.86643,31.0211683,3324.100098
              102.8665367,31.0217183,3321.300049
              102.86754,31.0228467,3328.399902
              102.8682333,31.023345,3331.699951
              102.868495,31.02422,3338.399902
              102.86873,31.0245367,3336.199951
              102.8697533,31.0251667,3343.100098
              102.870035,31.0256033,3345.800049
              102.86997,31.02594,3350.099854
              102.870195,31.0265117,3357.800049
              102.8706917,31.0273617,3360.300049
              102.8717183,31.0284717,3374
              102.8735067,31.0298317,3377.699951
              102.8744233,31.0310767,3382.300049
              102.8748283,31.0321567,3378.699951
              102.8747833,31.0328433,3391.800049
              102.8756183,31.0336933,3406.399902
              102.875455,31.034915,3408
              102.8754967,31.0361467,3406.399902
              102.8759333,31.037405,3412
              102.8763117,31.0379283,3415.999756
              102.87597,31.0385567,3416.199951
              102.8757067,31.0415767,3399.100098
              102.87552,31.0419067,3415.999756
              102.8758433,31.0423217,3424.100098
              102.8762517,31.0425117,3439.200195
              102.8762617,31.04284,3444
              102.8764567,31.0430117,3450.199951
              102.8766917,31.0436783,3461.399902
              102.8771717,31.0439417,3481.399902
              102.876935,31.04407,3486.899902
              102.8771133,31.04414,3494.399902
              102.8772133,31.0444317,3502.300049
              102.8782383,31.0450583,3541.100098
              102.878835,31.045955,3559.100098
              102.8790667,31.0470883,3574.699951
              102.8792533,31.0472867,3574.5
              102.8790733,31.04746,3574.199951
              102.8791133,31.0475933,3575.300049
              102.879595,31.0479917,3586
              102.8803283,31.0490267,3626.399902
              102.8804683,31.0489483,3627.600098
              102.880595,31.049135,3626.800049
              102.8807983,31.0491317,3629.199951
              102.8807333,31.0493933,3629.800049
              102.88088,31.04944,3629.100098
              102.880855,31.049585,3628.699951
              102.8811167,31.0496783,3629
              102.8812417,31.049575,3629.600098
              102.8814083,31.049755,3632.600098
              102.881335,31.0500367,3634.5
              102.8811333,31.0499417,3638.800049
              102.88138,31.05021,3638.699951
              102.8812683,31.0501417,3639
              102.8813417,31.0499933,3637.499756
              102.8813383,31.0501217,3642.600098
              102.8822067,31.050155,3652.599854
              102.8823317,31.050305,3655.699951
              102.8827433,31.0501883,3663.399902
              102.882945,31.0503983,3691
              102.8835383,31.0504067,3708.600098
              102.883635,31.0504717,3713.199707
              102.88357,31.0509167,3720.699951
              102.8834217,31.0509483,3723.000244
              102.8837983,31.0511317,3728.600342
              102.8841217,31.0509617,3733
              102.8840783,31.0516483,3760.400146
              102.8844567,31.0517517,3780.399902
              102.8844183,31.0518767,3795.699951
              102.884775,31.0518117,3818.499756
              102.8848583,31.0522,3863
              102.885575,31.051965,3896.800049
              102.88583,31.05217,3908.600098
              102.885545,31.0519417,3948.100098
              102.88575,31.0519467,3951.500244
              102.8857867,31.0521417,3960.899902
              102.8861367,31.0522567,3973.300293
              102.8862617,31.052715,3985.5
              102.8865033,31.0528033,3996.699707
              102.8865233,31.0531233,4007.399902
              102.886855,31.053565,4025.600098
              102.8878733,31.0542133,4081.300049
              102.888465,31.0543383,4096.399902
              102.8887633,31.05476,4105.5
              102.8889883,31.0546883,4115.200195
              102.8891233,31.0549117,4131
              102.8893483,31.0548067,4143.200195
              102.8900367,31.055275,4164.200195
              102.8902983,31.0563283,4190.399902
              102.8902633,31.0578033,4191.899902
              102.890535,31.05789,4203.200195
              102.89051,31.058235,4225.799805
              102.8909267,31.0584983,4262.799805
              102.8911817,31.05891,4273.899902
              102.8913883,31.05877,4285
              102.8913233,31.0584617,4289.399902
              102.89199,31.0583817,4299
              102.8919,31.058545,4308.200195
              102.8920433,31.05873,4319.299805
              102.8924917,31.05891,4352
              102.8927133,31.0588033,4365.200195
              102.8930267,31.059215,4373.200195
              102.89327,31.0590433,4388.899902
              102.8934967,31.0592717,4391.299805
              102.8934583,31.0594417,4395.899902
              102.8937567,31.0595283,4406.299805
              102.8940683,31.0601267,4421
              102.8943233,31.06027,4429.5
              102.8943667,31.0605067,4435.600098
              102.8941,31.0606483,4444
              102.89444,31.0607917,4452.799805
              102.89331,31.0618433,4485.899902
              102.893345,31.061985,4489.799805
              102.8938833,31.0621483,4498.399902
              102.8937483,31.0619783,4499
              102.89363,31.0620033,4499.399902
              102.8937967,31.062175,4499.799805
              102.8943467,31.0621867,4503.899902
              102.8943433,31.062095,4504.700195
              102.8943767,31.0622417,4504.5
              102.8948533,31.062295,4503.600098
              102.8957933,31.0629667,4506.299805
              102.8959517,31.0628633,4506.399902
              102.89649,31.0635683,4509.799805
              102.8966483,31.063565,4509.399902
              102.8967717,31.0639033,4511.600098
              102.8974033,31.0641033,4518.100098
              102.8982783,31.0652517,4530.399902
              102.8985533,31.0661067,4556.299805
              102.899115,31.0666583,4589.600098
              102.8990783,31.0670983,4620.700195
              102.8994317,31.0674483,4636
              102.8997217,31.068335,4650.799805
              102.9004533,31.0686783,4657.799805
              102.90056,31.0690317,4672.100098
              102.9008217,31.069215,4664.5
              102.9005883,31.0696883,4677.399902
              102.9007033,31.0700017,4692.100098
              102.9013133,31.070325,4701.100098
              102.9020567,31.0710117,4716.899902
              102.902175,31.0713983,4738.899902
              102.9026167,31.0719533,4748
              102.903125,31.0721467,4758.299805
              102.9036383,31.0726467,4757.299805
              102.9035233,31.072715,4757.399902
              102.9036517,31.0728533,4759.5
              102.9047917,31.0735717,4823.5
              102.905155,31.07431,4862.299805
              102.9062583,31.0745867,4891.799805
              102.9065483,31.07534,4962.100098
              102.906415,31.075375,4966
              102.906495,31.0755583,4993.700195
              102.9062583,31.0755983,4994.899902
              102.9066633,31.0755817,4990.700195
              102.9064633,31.0757367,5003.000488
              102.9069417,31.0759117,5031.500488
              102.9069833,31.0760817,5034.899902
              102.9068167,31.076175,5040.100098
              102.9069583,31.0762483,5041.700195
              102.9070367,31.0766883,5058.5
              102.906675,31.0769033,5078.899902
              102.906895,31.0768783,5081.200195
              102.90672,31.0772267,5096.200195
              102.9071467,31.0774933,5137.5
              102.9072017,31.07771,5142.200195
              102.90558,31.0791683,5322.200195
              102.905505,31.0793567,5341.899902
              102.905815,31.0797233,5358.100098
              102.9054383,31.07938,5345.500488
              102.9055167,31.07932,5349.5
              102.90543,31.0794,5349.100098
            </coordinates>
          </LineString>
        </Placemark>
      </Folder>
    </Folder>
  </Document>
</kml>

OpenLayers示例数据下载,请回复关键字:ol数据

全国信息化工程师-GIS 应用水平考试资料,请回复关键字:GIS考试

【GIS之路】 已经接入了智能助手,欢迎关注,欢迎提问。

欢迎访问我的博客网站-长谈GIShttp://shanhaitalk.com

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

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

昨天 — 2025年12月12日技术

记住这张时间线图,你再也不会乱用 useEffect / useLayoutEffect

2025年12月12日 22:27

useEffect 和 useLayoutEffect 的区别:别背定义,按“什么时候上屏”来选

以前一直写vue,现在写react了,写react代码的时候有时候会碰到一个选择题:

  • 这个副作用用 useEffect 还是 useLayoutEffect
  • 为什么我用 useEffect 量 DOM 会闪一下?
  • Next.js 里 useLayoutEffect 为什么会给我一个 warning?

这俩 Hook 的差别,说穿了就一句:它们跑在“上屏(paint)”的前后


一句话结论(先拿走)

  • 默认用 useEffect:不会挡住浏览器绘制。
  • 只有在“必须读布局/写布局且不能闪”的时候用 useLayoutEffect:它会在浏览器 paint 之前同步执行。

如果你脑子里只留两句话,就留这两句。


它们到底差在哪:在浏览器 paint 的前后

把 React DOM 的一次更新粗暴拆成四步,你就不会混了:

flowchart LR
  A[render 计算 JSX] --> B[commit 写入 DOM]
  B --> C[useLayoutEffect 同步执行]
  C --> D[浏览器 paint 上屏]
  D --> E[useEffect 执行]

  classDef info fill:#cce5ff,stroke:#0d6efd,color:#004085
  classDef warning fill:#fff3cd,stroke:#ffc107,color:#856404

  class C warning
  class E info
  • useLayoutEffectDOM 已经变了,但还没 paint。它会阻塞本次 paint。
  • useEffect页面已经 paint 了。它不会阻塞上屏(但也意味着你在里面改布局可能会“先错后改”,肉眼看到就是闪)。

注意我在说的是“commit 后”的那个时间点,不是 render 阶段。


一个很真实的例子:测量 DOM 决定位置(useEffect 会闪)

比如你做一个 Tooltip:初始不知道自己宽高,得先 render 出来,然后用 getBoundingClientRect() 量一下,再把位置修正。

如果你用 useEffect

  • 第一次 paint:Tooltip 先用默认位置上屏
  • effect 里量完 -> setState
  • 第二次 paint:位置修正

用户看到的就是“闪一下”。如果你用 useLayoutEffect,修正发生在 paint 之前,第一帧就是对的。

下面这段代码可以直接在 React DOM 里跑(为了不违反 Hooks 规则,我写成两个组件,用 checkbox 切换时会 remount):

import React, { useEffect, useLayoutEffect, useRef, useState } from "react";

type TooltipPosition = {
  anchorRef: React.RefObject<HTMLButtonElement | null>;
  tipRef: React.RefObject<HTMLDivElement | null>;
  left: number;
};

function calcLeft(anchor: HTMLButtonElement, tip: HTMLDivElement) {
  const a = anchor.getBoundingClientRect();
  const t = tip.getBoundingClientRect();
  return Math.round(a.left + a.width / 2 - t.width / 2);
}

function useTooltipPositionWithEffect(): TooltipPosition {
  const anchorRef = useRef<HTMLButtonElement | null>(null);
  const tipRef = useRef<HTMLDivElement | null>(null);
  const [left, setLeft] = useState(0);

  useEffect(() => {
    const anchor = anchorRef.current;
    const tip = tipRef.current;
    if (!anchor || !tip) return;
    setLeft(calcLeft(anchor, tip));
  }, []);

  return { anchorRef, tipRef, left };
}

function useTooltipPositionWithLayoutEffect(): TooltipPosition {
  const anchorRef = useRef<HTMLButtonElement | null>(null);
  const tipRef = useRef<HTMLDivElement | null>(null);
  const [left, setLeft] = useState(0);

  useLayoutEffect(() => {
    const anchor = anchorRef.current;
    const tip = tipRef.current;
    if (!anchor || !tip) return;
    setLeft(calcLeft(anchor, tip));
  }, []);

  return { anchorRef, tipRef, left };
}

function TooltipFrame({ pos }: { pos: TooltipPosition }) {
  return (
    <>
      <button ref={pos.anchorRef} style={{ marginLeft: 120 }}>
        Hover me
      </button>

      <div
        ref={pos.tipRef}
        style={{
          position: "fixed",
          top: 80,
          left: pos.left,
          padding: "8px 10px",
          borderRadius: 8,
          background: "#111827",
          color: "#fff",
          fontSize: 12,
          whiteSpace: "nowrap",
        }}
      >
        I am a tooltip
      </div>
    </>
  );
}

function DemoUseEffect() {
  return <TooltipFrame pos={useTooltipPositionWithEffect()} />;
}

function DemoUseLayoutEffect() {
  return <TooltipFrame pos={useTooltipPositionWithLayoutEffect()} />;
}

export function Demo() {
  const [layout, setLayout] = useState(false);

  return (
    <div style={{ padding: 40 }}>
      <label style={{ display: "block", marginBottom: 12 }}>
        <input
          type="checkbox"
          checked={layout}
          onChange={(e) => setLayout(e.target.checked)}
        />{" "}
        用 useLayoutEffect(勾上后更不容易闪)
      </label>

      {layout ? <DemoUseLayoutEffect /> : <DemoUseEffect />}
    </div>
  );
}

真实项目里你可能还会处理 resize、内容变化(ResizeObserver)、字体加载导致的宽度变化等;但对理解这两个 Hook 的差别,上面这个例子够用了。


怎么选:我自己用的“决策口诀”

1)只要不读/写布局,就用 useEffect

典型场景:

  • 请求数据、上报埋点
  • 订阅/取消订阅(WebSocket、EventEmitter)
  • document.titlelocalStorage 同步
  • 给 window/document 绑事件

这些东西不需要卡在 paint 之前完成,useEffect 更合适。

2)你要读布局(layout read)并且会影响第一帧渲染,就用 useLayoutEffect

典型场景:

  • getBoundingClientRect() / offsetWidth / scrollHeight 这种
  • 计算初始滚动位置、同步滚动
  • 需要避免视觉抖动的“测量 -> setState”
  • focus / selection(输入框聚焦、光标定位)对首帧体验敏感

一句话:“不想让用户看到中间态”

3)别在 useLayoutEffect 里干重活

因为它会阻塞 paint:

  • 你在里面做重计算,页面就掉帧
  • 你在里面频繁 setState,可能放大卡顿

如果你只是“想早点跑一下”,但并不依赖布局,别用它。


Next.js / SSR 里那个 warning 怎么回事

在服务端渲染(SSR)时:

  • useEffect 本来就不会执行(它只在浏览器跑)
  • useLayoutEffect 也不会执行,但 React 会提示你:它在服务端没意义,可能导致你写出“依赖布局但 SSR 不存在布局”的代码

如果你写的是“浏览器才有意义的 layout effect”,又不想看到 warning,常见做法是包一层:

import { useEffect, useLayoutEffect } from "react";

export const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

然后把需要 layout 的地方用 useIsomorphicLayoutEffect


容易踩的坑(顺手说两句)

  • Strict Mode 下 effect 会在开发环境额外执行一次useEffectuseLayoutEffect 都一样,别拿这个现象判断线上行为。
  • “我在 useEffect 里 setState 为什么会闪?”:因为你改的是布局相关内容,第一帧已经 paint 了。
  • 不要把数据请求塞进 useLayoutEffect:它既不需要 paint 前完成,还可能拖慢上屏。

简单总结一下

  • useEffect:大多数副作用的默认选择。
  • useLayoutEffect:只在“必须卡在 paint 前解决”的那一小撮场景里用。

真要说区别,其实就是一句:你愿不愿意为了“第一帧正确”去挡住 paint


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

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

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

  • prompt-vault - Prompt 管理器,Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

我只是给Typescript提个 typo PR,为什么还要签协议?

2025年12月12日 20:37

第一次给大公司的开源项目(Typescript)提 PR,提交完 GitHub 就弹出一条评论,让你签什么 CLA:

@microsoft-github-policy-service agree

image.png 什么玩意儿?我就改了个拼写错误,还要签协议?

CLA 是什么

CLA,全称 Contributor License Agreement,翻译过来叫"贡献者许可协议"。

简单说,就是一份法律文件,你签了之后,就授权项目方可以合法使用你贡献的代码。

为什么需要这东西

想象一个场景:

张三给某开源项目提了个 PR,合并了。过了两年,张三突然跳出来说:"这段代码是我写的,你们用了我的代码,侵犯我版权,赔钱!"

项目方一脸懵:代码是你自己提交的啊?

张三:提交归提交,我没说授权你们用啊。

听起来像碰瓷,但法律上还真不好说。毕竟代码确实是张三写的,版权默认归作者。

CLA 就是为了堵这个漏洞。你签了 CLA,就相当于白纸黑字写清楚了:

  • 这代码是我自己写的(不是抄的)
  • 我授权你们用、改、分发
  • 以后不会反悔找你们麻烦

CLA 里具体写了啥

以微软的 CLA 为例,核心条款就这几条:

1. 原创声明

你保证提交的代码是你自己写的。如果包含别人的代码,要标注清楚来源和许可证。

2. 版权授权

你授予项目方永久的、全球范围的、免版税的版权许可。说白了就是:他们可以随便用,不用给你钱,也不用每次都问你。

3. 专利授权

如果你的代码涉及专利(虽然大多数情况下不会),你也授权他们使用。

4. 雇主确认

如果你是在工作中写的代码,公司可能对代码有知识产权。这种情况下,你得先拿到公司的许可才能签 CLA。

签了会怎样

签 CLA 不会让你:

  • 失去代码的版权(版权还是你的)
  • 不能在别处使用这段代码
  • 承担什么法律责任

签 CLA 只是说:

  • 项目方可以合法使用你的贡献
  • 你不会秋后算账

不同项目的 CLA

不是所有开源项目都要签 CLA,主要是大公司的项目:

公司 需要 CLA
微软
Google
Meta
Apache 基金会
个人项目 通常不需要

个人维护的开源项目一般不搞这套,太麻烦。但大公司不行,法务部不允许有法律风险敞口。

怎么签

以 GitHub 上的微软项目为例:

  1. 提交 PR
  2. 机器人会自动评论,让你签 CLA
  3. 回复:@microsoft-github-policy-service agree
  4. 搞定

就这么简单。签一次就行,以后再给同一个组织提 PR 就不用重复签了。

如果你是代表公司贡献代码,需要加上公司名:

@microsoft-github-policy-service agree company="你的公司名"

一些细节

Q:我就改了个 typo,也要签?

是的。哪怕只改了一个字符,也是贡献,也要签。

Q:签了 CLA,代码版权归谁?

版权还是你的。CLA 只是授权,不是转让。

Q:能撤回吗?

理论上你不能撤回已经合并的代码的授权。但你可以随时停止贡献。

Q:CLA 和开源许可证什么关系?

开源许可证(MIT、Apache 等)是项目对外的授权,告诉使用者可以怎么用这个项目。

CLA 是贡献者对项目的授权,告诉项目方可以怎么用贡献者的代码。

两个方向不一样。

小结

CLA 这东西,说白了就是大公司的法务需求。对贡献者来说,签一下也没什么损失,就是授权项目方合法使用你的代码。

第一次遇到可能有点懵,但理解了它的目的,就知道这是正常流程,不是什么坑。

签就完了。


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

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

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

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

使用Git 实现Hugo热更新部署方案(零停机、自动上线)

2025年12月12日 20:15

Hugo 静态网站最大的优势之一,就是 构建快、部署轻。但如果你希望在每次更新文章后:

  • 一条命令就完成构建 + 部署
  • 服务器自动同步最新 public 文件
  • 整个过程不需要登录服务器
  • 也不需要 CI/CD 或额外工具

那么 基于 Git 的 Hugo Public 目录热更新方案 是最轻量、最稳定的部署方式。

这篇文章将完整介绍如何使用 Git 将 Hugo 的 public 目录推送到服务器,并通过服务器端 hook 实现热更新自动上线


为什么要用 Git 部署 Hugo ?

Hugo 的 public 目录只是纯静态文件,部署可以非常简单:

  • 不需要 Nginx reload
  • 不需要重启服务
  • 不需要 build 服务器
  • 甚至不需要 ssh 登录

只要 push 一下,就能让服务器自动同步最新 public 文件,实现真正的 Zero-Downtime 热更新


一、整体方案架构

本地电脑
├── Hugo 项目
│    ├── content/
│    ├── themes/
│    ├── public/ ← 构建后目录
│
└── git push deploy main
            ↓
服务器
└── /var/repo/hugo_public.git (bare repo)
            ↓ post-receive hook 自动触发
└── /var/www/website    ← 网站根目录(热更新)

流程:

  1. 本地执行:
hugo && git push deploy main
  1. push 到服务器的 bare 仓库(没有工作区)
  2. 服务器触发 post-receive hook
  3. 自动 checkout 最新 public 文件到 /var/www/website
  4. 网站立即更新(零停机)

二、服务器初始化部署仓库(bare repo)

服务器上执行:

mkdir -p /var/repo
cd /var/repo
git init --bare hugo_public.git

设置默认主分支(避免 “branch yet to be born”):

cd hugo_public.git
git symbolic-ref HEAD refs/heads/main

三、配置 post-receive 自动部署脚本

编辑:

/var/repo/hugo_public.git/hooks/post-receive

内容:

#!/bin/bash
set -e

echo ">>> 正在部署 Hugo 静态文件..."

WORK_TREE=/var/www/website
GIT_DIR=/var/repo/hugo_public.git

# 自动创建 main 分支(首次部署不会失败)
if ! git rev-parse --verify main >/dev/null 2>&1; then
    git checkout -b main
fi

git --work-tree=$WORK_TREE --git-dir=$GIT_DIR checkout -f main

echo ">>> 部署完成!"

赋予可执行权限:

chmod +x /var/repo/hugo_public.git/hooks/post-receive

四、本地配置 deploy 远程仓库

进入 Hugo 项目目录:

cd public
git init
git add .
git commit -m "init public"

添加服务器地址:

git remote add deploy ssh://ubuntu@服务器IP/var/repo/hugo_public.git

五、发布流程:一条命令完成构建 + 部署

你可以直接用两段式:

hugo
cd public
git add .
git commit -m "update"
git push deploy main

或者把它合成一个命令:

hugo && (cd public && git add . && git commit -m "update" && git push deploy main)

服务器自动执行:

>>> 正在部署 Hugo 静态文件...
>>> 部署完成!

网站秒级更新


六、方案优势(为什么说这是“热更新”?)

优势 说明
🟢 零停机 静态文件直接覆盖,无需 reload
🟢 不需登录服务器 所有操作本地 push 完成
🟢 不需 CI/CD 无 GitHub Actions / GitLab Runner
🟢 安全 仅开放 SSH
🟢 高速 Hugo 构建 + Git push 极快
🟢 可回滚 服务器 public 目录有完整 Git 历史

这是部署 Hugo 最轻便、最优雅的方法之一。


七、总结

本方案的核心逻辑:

  • 本地 push public → 服务器自动 checkout
  • 利用 bare 仓库 + hook
  • 完整的版本控制
  • 热更新、零停机

如果你正在运营博客、文档站、产品官网,这是目前最强的 Hugo 私有部署方案。

Next.js SSR 项目生产部署全攻略

2025年12月12日 20:14

Next.js 是 React 生态中非常流行的前端框架,支持 SSR(Server-Side Rendering)API 路由静态生成。在开发阶段,Next.js 可以通过 next dev 运行,但在生产环境下,我们需要打包并部署 SSR 服务。本文将介绍完整的 Next.js SSR 生产部署流程,适用于 1Panel 或 VPS 服务器。


一、 本地准备

假设你的 Next.js 项目目录如下:

package.json
package-lock.json / yarn.lock
pages/
public/
components/
next.config.js
node_modules/

SSR 项目需要 Node.js 后端支持,因此生产环境必须有 Node.js 环境。


二、安装依赖

如果使用 npm:

npm install

如果使用 yarn:

yarn install

确保依赖完整,尤其是 nextreactreact-dom


三、 构建生产版本

Next.js 提供 next build 命令生成生产构建产物:

npm run build
# 或者
yarn build
  • 会生成 .next/ 目录,包含 SSR 所需文件
  • 构建完成后可以通过 next start 启动生产服务

四、 测试生产环境(可选)

npm start
# 或者
yarn start
  • 默认端口 3000
  • 确认 SSR 页面可以正常访问

五、准备上传文件

生产部署至少需要以下内容:

package.json
package-lock.json / yarn.lock
.next/        # SSR 构建产物
public/       # 静态资源
node_modules/ # 可上传,也可服务器安装

可以打包成 ZIP 上传:

zip -r next-prod.zip package.json package-lock.json .next public .env

六、上传到服务器 / 1Panel

  • 1Panel:直接上传 ZIP 或 Git 克隆项目
  • VPS / 云服务器:通过 scp 上传
scp next-prod.zip user@yourserver:/path/to/app
ssh user@yourserver
unzip next-prod.zip -d next-app
cd next-app

七、安装生产依赖(如果未上传 node_modules)

npm install --production
# 或者
yarn install --production

SSR 生产环境只需要安装 dependencies,不需要 devDependencies


八、配置环境变量

Next.js SSR 项目常用环境变量:

NODE_ENV=production
PORT=3000
NEXT_PUBLIC_API_URL=https://api.example.com

可以在服务器环境中设置,也可以使用 .env.production 文件


九、启动生产 SSR 服务

方式一:直接 Node.js

npm start
# 或者
yarn start
  • 访问 http://你的域名或公网IP:3000
  • 页面由服务器渲染并返回 HTML(SSR)

方式二:PM2 守护

npm install -g pm2
pm2 start npm --name "next-app" -- start
pm2 save
pm2 startup
  • 保证服务器重启后自动启动
  • 提供日志管理和进程监控

方式三:Docker 部署(可选)

Dockerfile 示例

FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
RUN npm run build
CMD ["npm", "start"]
EXPOSE 3000

构建 & 运行:

docker build -t next-app .
docker run -d -p 3000:3000 next-app

十、 可选:Nginx 反向代理 + HTTPS

server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}
  • 配合 Let's Encrypt 可免费启用 HTTPS
  • 提高安全性与稳定性

十一、完整部署流程总结

  1. 本地 npm install / yarn install
  2. npm run build / yarn build 生成 .next/
  3. 上传 .next/ + public/ + package.json + node_modules(可选)
  4. 服务器安装依赖(如需要)
  5. 配置环境变量
  6. 使用 npm start / PM2 / Docker 启动
  7. 可选 Nginx 反向代理 + HTTPS
  8. 访问页面,确认 SSR 正常渲染

十二、 小技巧

  • 端口管理:1Panel 默认分配公网端口,确保 PORT 与之匹配
  • 守护进程:PM2 或 Docker 保证 SSR 服务稳定运行
  • 环境变量:生产环境变量一定要配置正确,否则 SSR 可能报错
  • 安全与性能:建议 Nginx 反向代理 + HTTPS

通过以上步骤,你可以快速将 Next.js SSR 项目 打包、上传,并在 1Panel 或 VPS 上运行生产环境,实现服务端渲染。

为什么浏览器能看懂网页代码?——从HTML到渲染引擎的奇幻之旅

作者 无限大6
2025年12月12日 18:54

🌐 为什么浏览器能看懂网页代码?——从HTML到渲染引擎的奇幻之旅 💻

欢迎大家来到今日份的无限大博客,今天又又又又是一期计算机十万个为什么系列的文章

让我来带领你开启今日份的学习吧

当你在浏览器地址栏输入 https://www.baidu.com 并按下回车时,一场精彩的"魔法表演"就开始了——短短几百毫秒后,原本枯燥的代码就变成了色彩斑斓的网页。

这就像你给了厨师一堆生食材(代码),他瞬间就端出了一道美味佳肴(网页)。🍳 浏览器到底是怎么做到的?今天咱们就来揭开这个"魔法"的神秘面纱!

🚦 网页加载的"流水线":从输入URL到看到网页

想象一下,浏览器加载网页就像工厂生产产品,有一条完整的"流水线"。咱们来看看这条流水线的每个环节:

1. 🔍 DNS解析:找到服务器的"电话号码"

当你输入 baidu.com 时,浏览器首先要问:"这个域名对应的服务器IP地址是多少?"这就像你要给朋友打电话,得先查他的电话号码。

DNS解析的过程就像查电话簿:

  • 先问本地DNS缓存(手机通讯录)
  • 再问ISP的DNS服务器(小区物业)
  • 最后问根域名服务器(国家电信总局)

整个过程通常只需要几毫秒,快得就像你从通讯录里找到朋友的电话!📞

2. 🛰️ TCP连接:建立"数据高速公路"

拿到IP地址后,浏览器和服务器之间会建立一条TCP连接——这就像在两地之间修了一条高速公路,数据可以在上面快速传输。

TCP连接的建立需要"三次握手":

  1. 浏览器:"你好,我想连接你!" 👋
  2. 服务器:"你好,我收到了!" 👌
  3. 浏览器:"好的,咱们开始传输数据吧!" 🚀

这三次握手确保了连接的可靠性,就像你和朋友确认"喂?喂?能听到吗?"一样。

3. 🔄 HTTP请求:发送"购物清单"

连接建立后,浏览器会向服务器发送HTTP请求——这就像你给超市发了一份购物清单,上面写着你想要的网页内容。

请求里包含了很多信息:

  • 请求方法(GET/POST):是想买东西还是退货?
  • 请求头:你的浏览器类型、支持的格式等
  • 请求体:你要提交的数据(比如登录信息)

4. 📦 HTTP响应:收到"快递包裹"

服务器收到请求后,会根据请求内容准备响应——这就像超市根据你的清单打包商品,然后快递给你。

响应里也包含了很多信息:

  • 状态码:200表示成功,404表示找不到页面
  • 响应头:内容类型、长度、编码等
  • 响应体:网页的实际内容(HTML/CSS/JS等)

5. 🧙 渲染引擎:"魔法厨师"的表演

收到服务器的响应后,浏览器的渲染引擎就开始工作了——这是整个过程中最精彩的部分!渲染引擎就像一位"魔法厨师",把各种"食材"(HTML/CSS/JS)变成一道"美味佳肴"(网页)。

🧠 渲染引擎:浏览器的"大脑"

目前主流的渲染引擎有两个:

  • WebKit:Safari和早期Chrome使用 🍎
  • Gecko:Firefox使用 🦊
  • Blink:现在Chrome和Edge使用(基于WebKit) 🔴

不管是哪种渲染引擎,它们的工作原理都差不多,主要分为以下几个步骤:

1. 📄 HTML解析:构建DOM树

首先,渲染引擎会把HTML代码解析成一个树形结构——DOM树(Document Object Model)。

想象一下,HTML代码就像一本家谱:

<html>
  <head>
    <title>我的网页</title>
  </head>
  <body>
    <h1>欢迎来到我的博客</h1>
    <p>这是一段正文</p>
  </body>
</html>

解析后生成的DOM树就像这样:

  • html 是根节点
  • headbody 是它的子节点
  • titleh1p 是孙子节点

就像一个大家庭,每个标签都是一个家庭成员,有着明确的层级关系。👨‍👩‍👧‍👦

2. 🎨 CSS解析:构建CSSOM树

接下来,渲染引擎会解析CSS代码,生成CSSOM树(CSS Object Model)。

CSSOM树记录了每个元素的样式信息:

  • 颜色、字体、大小
  • 边距、padding
  • 定位方式
  • 等等

这就像给每个家庭成员穿上不同的衣服,有的穿西装,有的穿T恤,有的穿裙子。👗👔

3. 🌳 构建渲染树

有了DOM树和CSSOM树,渲染引擎会把它们合并成一棵渲染树(Render Tree)。

渲染树只包含可见的元素:

  • 会忽略 display: none 的元素
  • 会忽略 <head> 里的元素(除非有可见的内容)
  • 会应用CSS样式到每个可见元素

这就像在拍照前,只让穿好衣服的家庭成员站成一排,准备合影。📸

4. 📏 布局(Layout):计算每个元素的位置和大小

接下来是布局阶段,渲染引擎会计算每个元素在页面上的确切位置和大小。

这个过程也叫"重排"(Reflow),它会考虑:

  • 元素的尺寸
  • 元素的位置
  • 元素的外边距和内边距
  • 父元素的约束
  • 等等

就像在布置舞台,导演会精确计算每个演员的站位和移动路线。🎭

5. 🎨 绘制(Paint):给元素上色

布局完成后,渲染引擎会开始绘制阶段,将渲染树转换为屏幕上的像素。

绘制过程会按照一定的顺序进行:

  • 背景色
  • 边框
  • 文字
  • 阴影
  • 等等

就像画家作画,先画背景,再画主体,最后画细节。🖌️

6. 🔄 合成(Composite):将图层合并

最后是合成阶段,渲染引擎会将所有绘制好的图层合并成一个完整的页面。

现代浏览器会使用硬件加速(GPU)来完成这个过程,这样可以提高性能。就像电影后期制作,把不同场景的胶片合成一部完整的电影。🎬

🚀 渲染引擎工作流程图

步骤 名称 作用 比喻
1 HTML解析 生成DOM树 写家谱
2 CSS解析 生成CSSOM树 穿衣服
3 构建渲染树 合并DOM和CSSOM 排合影
4 布局 计算位置和大小 舞台布置
5 绘制 填充像素 画家作画
6 合成 合并图层 电影后期

💡 性能优化:让网页"飞"起来

网页加载速度对用户体验至关重要。研究表明:

页面加载时间每增加1秒,转化率下降7%,用户满意度下降16%! 📊

那么,我们该如何优化网页性能呢?

1. ⚡ 减少HTTP请求:少跑腿,多办事

每一个HTTP请求都需要建立连接、传输数据、断开连接,这需要时间。减少HTTP请求可以显著提高加载速度。

优化方法

  • 合并CSS和JavaScript文件
  • 使用CSS Sprites合并小图标
  • 内联关键CSS
  • 减少不必要的图片和脚本

就像你去超市买东西,一次买齐所有东西比跑十次超市要快得多!🛒

2. 📦 压缩资源:给文件"减肥"

压缩HTML、CSS、JavaScript文件可以减少文件大小,加快传输速度。

优化方法

  • 使用gzip或brotli压缩文本文件
  • 压缩图片(JPEG优化、PNG压缩、WebP格式)
  • 移除不必要的代码和注释

就像你寄快递,把东西压缩打包后,不仅省钱,还能更快送达!📮

3. 🌐 使用CDN:就近发货

CDN(内容分发网络)可以将你的静态资源分发到全球各地的服务器上,用户可以从离他最近的服务器获取资源。

优化效果

  • 减少网络延迟
  • 提高资源加载速度
  • 减轻源服务器压力

就像你在网上购物,选择本地仓发货,第二天就能收到商品!🚚

4. ⏳ 懒加载:按需加载

懒加载是指只加载用户当前可见区域的内容,当用户滚动页面时再加载其他内容。

适用场景

  • 长页面的图片
  • 视频内容
  • 列表页的分页内容

就像你去餐厅吃饭,服务员不会一次性把所有菜都端上来,而是按需上菜!🍽️

5. 🚫 避免重排和重绘:减少不必要的操作

重排(Layout)和重绘(Paint)是性能杀手,我们应该尽量避免。

避免方法

  • 不要频繁修改DOM样式
  • 使用transform和opacity来做动画(只会触发合成,不会触发重排和重绘)
  • 批量修改DOM
  • 使用documentFragment

就像你在拍照时,不要频繁让模特换姿势,一次性摆好姿势拍照更高效!📸

🎯 不同浏览器的"魔法厨师"

不同的浏览器有不同的渲染引擎,它们就像不同风格的厨师,做出的"菜"味道略有不同:

浏览器 渲染引擎 特点 比喻
Chrome Blink 速度快,兼容性好 快餐连锁店,高效便捷
Firefox Gecko 开源,安全,灵活 私房菜馆,注重品质
Safari WebKit 流畅,适合苹果设备 法式餐厅,精致优雅
Edge Blink 现代,整合微软服务 新派餐厅,融合创新
IE Trident 古老,兼容性差 传统老店,逐渐淘汰

🧪 趣味实验:亲眼见证渲染过程

咱们来做个小实验,看看浏览器是如何渲染网页的。打开Chrome浏览器,按F12打开开发者工具,点击"Performance"标签,然后点击"Record"按钮,刷新页面。

你会看到一个详细的渲染时间线,上面清楚地显示了每个阶段的耗时:

  • DNS Lookup
  • Initial Connection
  • Request/Response
  • DOMContentLoaded
  • Load

这就像你看电影的进度条,能清楚地知道每个环节用了多长时间!⏱️

🔮 未来:渲染引擎的发展趋势

随着Web技术的发展,渲染引擎也在不断进化:

  1. 🖼️ WebAssembly:让浏览器能运行接近原生速度的代码,适合游戏和复杂应用
  2. ⚡ 渲染优化:更好的硬件加速,更智能的渲染策略
  3. 📱 响应式设计:更好地支持不同设备和屏幕尺寸
  4. 🔒 安全性:更强的沙箱机制,更好的恶意代码防护

未来的渲染引擎会越来越智能,网页加载速度会越来越快,用户体验也会越来越好!🚀

🎓 互动时间:你答对了吗?

来做个小测验,看看你对浏览器渲染了解多少:

问题 答案 你答对了吗?
浏览器渲染的第一步是什么? HTML解析 ✅/❌
display: none的元素会被包含在渲染树中吗? 不会 ✅/❌
重排和重绘哪个更消耗性能? 重排 ✅/❌
CSS Sprites的作用是什么? 减少HTTP请求 ✅/❌
CDN的中文全称是什么? 内容分发网络 ✅/❌

🎯 结语:浏览器的"魔法"其实很简单

浏览器能看懂网页代码,靠的不是魔法,而是一套复杂但有序的工作流程。从DNS解析到最终渲染,每个环节都经过了精心设计和优化。

下次当你在浏览器中看到一个精美的网页时,不妨想一想:

在这背后,有多少工程师的心血,有多少技术的积累,有多少优化的努力。

就像我们看到的每一道美味佳肴,背后都有厨师的精心准备和烹饪技巧。浏览器的"魔法",其实是人类智慧的结晶!✨


💬 互动话题

  1. 你遇到过的最慢的网页加载时间是多少?
  2. 你知道哪些提升网页性能的小技巧?
  3. 你觉得未来的浏览器会是什么样子?

快来评论区聊聊你的想法!💬 点赞收藏不迷路,咱们下期继续探索计算机的"十万个为什么"!🎉

关注我,下期带你解锁更多计算机的"奇葩冷知识"!🤓

✨TRAE SOLO + Holopix AI | 复刻 GBA 游戏-"🐛口袋妖怪"

作者 coder_pig
2025年12月12日 17:01

1. 引言

😄 不知不觉,今年已经用「AI编程工具 + Holopix AI 」复刻了好几款游戏:塔防转刀射击自走棋

😶 但,都不是我喜欢的,我更怀念读书时的 "白月光" —— GBA《口袋妖怪(绿宝石)》

读初中那会,同学买了 GBA,软磨硬泡,才愿意晚上借我回家玩。🤡 有 "网瘾史",父母不给玩游戏,等他们 "查房" 完,透过门缝看外面的灯关了,确认没动静后,才敢偷偷拿出来玩,有时没注意时间,一玩就是一个通宵,🤣 记得有次理发,钓🐟钓着钓着睡过去了。

唉,年轻真好啊,通宵几天都吃得消,现在不行了,晚上一两点睡,第二天起来浑身难受...


AI 发展迅猛,今年的尾声,借助【TRAE SOLO + Holopix AI】来复刻这款游戏,试着找回当年那个 "无忧无虑" 的自己👶~

2. SOLO Time

上节重构两项目用的 AgentSOLO Coder,这节是 "创意快速落地",所以用 SOLO Builder

Vibe Coding 关键是 "文档先行" ❗️ 开发过程的每一步都要围绕 "文档" 进行,先让 AIPlan 文档,你 审阅校对编辑 没问题后,才让 AI 照着文档开始干活。

这样做 "把控性" 更强,你能清楚知道 AI 要怎么做,而不是 "抽卡",全靠 AI 自己发挥。产品开发的起点是 PRD (产品需求文档),游戏则是 GDD (游戏设计文档),先写 PromptTrae 对 "原始需求" 进行细化。

SOLO Builder 模式没有 Plan 开关,需要在 Prompt 中写明生成 GDD 文档,不然它有时会直接开始 "堆代码":

生成结果:

让我们确认几个 "立项目标"-复刻程度、技术栈选择、美术与资源,简单做下 "填空题",Prompt 后面同样要加上 "先不要写代码,理解完需求,更新GDD文档":

再次确认文档是否有误,比如:技术栈是否对味~

🤔 接着,直接让 Trae 按照这个 GDD 文档来干活吗?可以,但我倾向于再 "",先做 "可行性的快速验证",而不是直接一股脑干到头,这样省 Token效率更佳,有不对的可以 快速调整

😏 不够,我还要玩更 "骚" 的 "多Agent并行",把 Trae 的效率拉爆,写 Prompt大任务 拆成多个 "可并行执行的小任务",然后还是得生成 "文档":


Trae 拆解成了 4 个小的 Task,并告知了我 "建议执行顺序":

结构目录不是很好 (没分层),让它调整一下:

接着就是点击左上角的 " + 新任务",然后在每个 Agent 里写不同的 Prompt:"执行xxx.md":

🤣 三架马车,并驾齐驱,何其壮观,泰裤辣!2333,就是 Token 烧得有点快...

😄 "简陋" 游戏雏形有了,接着,可以让 Trae 自检任务完成情况:

也可以让它基于 GDD 生成下一步的 Plan,然后就是重复上面的 "套路" 拆解任务分步执行, 💁‍♂️人靠衣裳马靠鞍,同时请出我们的老朋友「Holopix AI」来生成 UI素材,对游戏进行 "美化"~

3. Holopix AI 生成素材

3.1. 定制一个 "GBA像素风" 模型

😀 既然是想 "复刻",那 "美术风格" 妥妥得搞成 "GBA像素风",Holopix AI 提供了大量不同风格的 "预设模型",随手搜了下 "像素",都有 20+ 种:

😃 不过,杰哥想更加 "原汁原味",Holopix AI ****是支持 "模型定制" 的,还记得上节 "坤坤自走棋",我们用网上搜罗到的 "ikun小黄鸡" 图片作为素材,训练了一个专用用来生成小黄鸡的 "ikun" 模型吗?

😄 这里我们 照葫芦画瓢,训练一个 "GBA像素风" 的模型,搜了下 "口袋妖怪素材":

发现都是这样的 "精灵表" (纹理图集),这是 2D游戏开发 的标准做法,为的是 "性能与效率":

GBA的图形芯片 (PPU) 没有 "加载图片" 的概念,它只能读取连续的显存块。开发者把所有角色动画帧拼成一张图,通过修改 UV坐标 (读取位置) 来切换帧。切换动画帧只需改变内存偏移量,不消耗CPU,而且一张512x512的图集仅占256KB,而100张独立图片会因内存对齐浪费30%空间。

即使今天硬件强大,"图集" 仍是最佳实践,表现在:

  • 减少Draw Call:渲染100个独立图片=100次Draw Call;渲染1张图集=1次Draw Call。GPU批量渲染,帧率提升5-10倍。
  • 动画管理:动画软件能直接输出图集+坐标数据,游戏引擎可自动识别,无需手动切割和配置。
  • 动态合批:游戏引擎会将零散小图在运行时合并成图集 (VRAM),提供预制图集可跳过这一步,启动更快。
  • 像素完美:独立图片导入时可能因压缩产生1像素透明边,图集一次导出,所有帧共用统一调色板,杜绝色差。

😁 扯得有点远了,接着写 PromptTRAE哈基米3 写脚本切下图:

有点切歪了,问题不大,截图发它重新切,顺手 去掉蓝色背景 + 调整图片分辨率256x256,这是 训练模型 用到素材图片的 "最小分辨率":

打开 Holopix AI 官网,点击左侧 "模型定制" → "开始模型训练":

训练类型选 "icon道具",训练强度选 "",然后点击 上传图片

选中我们的宝可梦素材 (最多200张):

提交后,等模型训练完,会有消息通知,一般要 2-10h

收到成功通知后,可到 "模型定制" → "我的模型" 找到训练好的模型,点击 开始创作

随便输几个描述词看看效果:皮卡丘、绿毛龟、绿色喷火龙,水箭龟:

😳 卧槽,"DNA" 动了!然后,现在这个模型只有 "自己可见",好东西肯定要分享的,接着发布一下模型:

做下填空,然后 示例图 直接选 "从创作记录导入",选几张还凑合的,点击发布,然后别人就能搜到你的模型了:

3.2. 宝可梦 & 道具 & 角色

😆 接着用我们上面训练的模型来生成 "宝可梦" 和 "道具",生图 Prompt 可以让 Trae 生成一个素材清单:

不过这种批量生成 Prompt 的一般都比较 "简陋",可以用 Holopix AI 提供的 "智能优化" 进行润色:

接着就是重复 "抽卡",选择中意的素材了,部分生成素材:

接着让 Trae 应用看下效果:

3.3. 建筑

🤔 起始城镇 "主角的家" 和 "博士研究所" 分别占据 3x35x3 的格子,没法用像素块拼接...直接找个像素模型,这里用的「等距像素化建筑模型 | HOLO-V1」:

Prompt 直接输入 3x3的房子,让 Holopix AI 进行智能优化,接着翻译为英文,接着微调下:

4. 最终效果

Holopix AI 生成的素材全部应用上,加上反复跟 Trae Vibe 的最终效果:

💁‍♂️ 捣鼓了一早上,就复刻了 "口袋妖怪" 的 "基本玩法" (虽然有点简陋,还有各种BUG🤣),归功于:

  • Holopix AI:依旧保持稳定快速,高质量的游戏素材输出。
  • Gemini 3 Pro:当之无愧编程能力最强的 "前端之王LLM"。
  • TRAE SOLO:同时驱使 多 Agent 并行干活太爽了,😆 就是 Token 烧得飞快...

😏 AI 时代,人人都是 "创造家",你有创意你就来,赶紧动手试试吧~

VUE中使用AXIOS包装API代理

作者 _杨瀚博
2025年12月12日 17:59

VUE中使用AXIOS包装API代理

0. 背景

在VUE开发过程,与后端接口交互时,可以简单几句代码就剋调用后端POST或者GET请求. 实际效果就是,前端定义一个对象

{
  getPageList: 'GET /pagelist',
  commitData: 'POST /savedata',
  getDetail: 'GET /detail/{id}',
}

然后在业务代码中就可以调用getPageList方法,实际效果就是发送一个GET请求,请求地址为/pagelist 常用场景如下:

  • api.getPageList("gameName=地心侠士&gameType=小游戏') 会转换成GET请求,参数在URL上 /pagelist?gameName=地心侠士&gameType=小游戏
  • api.commitData({gameName:"地心侠士",gameType:"小游戏"}) 会转换成POST请求,参数通过JOSN提交 /savedata
  • api.getDetail({id:1}) 会转换成GET请求,参数在URL上 /detail/1

1. 整合全局axios配置

整合axios主要是配置一些全局的请求,响应,以及请求头,请求超时配置等.全局配置代码request.js如下:

import axios from 'axios';
import loading from './loading';
const ENV = import.meta.env;
const { VITE_GLOB_API_URL } = ENV;
let req = axios.create({
  baseURL: VITE_GLOB_API_URL || '',
  timeout: 30000,
  params: {'g.type': '1'},
  headers:{'g.type': '1'},
});
// 请求拦截 公众号 小满小慢
req.interceptors.request.use((cfg) => {
  loading.showLoading();
  return cfg;
});
// 响应拦截 公众号 小满小慢
req.interceptors.response.use(
  (resp) => {
    loading.hideLoading();
    if (resp.data.code && resp.data.code != '0') {
      // 全局拦截错误信息
      loading.showError(resp.data.message);
      return Promise.reject(resp.data);
    }
    return resp;
  },(error) => {
    loading.hideLoading();
    if (error.response && error.response.data) {
      loading.showError(error.response.data.message);
    }
    return Promise.reject(error);
  },
);
export default {
  request: req.request, 
};

2. 创建API请求包装器

请求包装器主要有以下作用

  • 请求参数处理
  • 通用接口暴露

实际效果可以把 GET /pagelist 暴露成一个可以调用的方法 ,创建API请求包装器apiconvert.js如下:

import req from './request.js';
export function convertApi(apis) {
  const ENV = import.meta.env;
  const { VITE_GLOB_API_URL } = ENV;
  const api = {};
  for (const key in apis) {
    const apiInfos = apis[key].split(' ');
    const [method, apiUrl] = apiInfos;
    let base = VITE_GLOB_API_URL;
    if (key == 'ajax') base = '/';
    api[key] = (data) => {
    return new Promise((resolve, reject) => {
      let targetUrl = apiUrl;
      if (method === 'GET' && data && typeof data === 'string') {
        // get请求参数处理 公众号 小满小慢
        data = encodeURI(data);
        const index = targetUrl.indexOf('?');
        if (index === -1) {
          data = `?${data}`;
        }
        targetUrl = `${targetUrl}${data}`;
        data = null;
      }
      if (/{\w+}/.test(targetUrl)) {
        targetUrl = targetUrl.replace(/{(\w+)}/gi, function (match, p) {
          return data[p] || '0'; 
        });
        console.log(`change url:${targetUrl}`);
      }
      req.request({ method: method, url: targetUrl, data: data,baseURL: base})
        .then((res) => resolve(res.data))
        .catch((error) => {
          reject(error);
        });
    });
    };
  }
  return api;
}
// 暴露一个通用接口
const api = convertApi({
  ajax: 'GET /commonDataProc',
});

export default api;

3. 使用API请求包装器

实际的业务接口可以通过键值对的方式配置,然后通过convertApi方法进行转换,转换后的接口可以调用. 如下:

  • 'GET /pagelist'
  • 'POST /savedata'

实际业务接口biz_api.js 定义如下

import commapi, { convertApi } from '@/assets/js/apiconvert';
const api = convertApi({
  // 这里可以扩展业务接口
  getPageList: 'GET /pagelist',
});
// 合并通用接口
Object.assign(api, commapi);
export default api;

4. 使用业务接口

实际业务代码中,通过import api from '@/assets/js/biz_api'引入业务接口,然后调用业务接口即可.

import api from './biz_api.js'
const data = ref([]);
const loadingStatus = ref(true);
async function getPages() {
  const res = await api.getPageList();
  let arr = [];
  for (let i in res) {
    arr.push(res[i]);
  }
  data.value = arr;
  loadingStatus.value = false;
}
onMounted(() => {
  getPages()
});

5. 总结

通过以上封装后,前端调用后端的API清晰明了.api定义在单独的文件,也可以自由组合. 从设计上来说,主要使用了两层代理转换. 所有还是印证那句话,一层代理解决不了问题,那就再加一层. 以上仅为个人项目落地总结,若有更优雅的方式,欢迎告知.微信公众号:小满小慢 私信或者直接留言都可以. 原文地址 mp.weixin.qq.com/s/aqHVyq_I3…

基于 Body 滚动的虚拟滚动组件技术实现

作者 张有志
2025年12月12日 17:41

前言

在现代 Web 应用中,树形结构是一种常见的数据展示方式,广泛应用于文件管理、组织架构、菜单导航等场景。然而,当树节点数量达到成千上万时,传统的全量渲染方式会导致严重的性能问题。本文将分享一个基于 React 实现的高性能虚拟滚动树组件,特别是其使用 Body 滚动条控制虚拟滚动的创新实现方案。

功能亮点

1. 🚀 高性能虚拟滚动

  • 按需渲染:只渲染视口内可见的节点,大幅减少 DOM 数量
  • 动态计算:实时计算可见范围,支持数万节点流畅滚动
  • 智能预加载:通过 overscan 参数预渲染视口外的节点,避免滚动时的白屏

2. 📏 不定高节点支持

  • 自适应高度:每个节点可以有不同的高度
  • ResizeObserver 监听:自动检测节点高度变化并更新缓存
  • 精确定位:基于高度缓存精确计算每个节点的位置

3. 🎯 Body 滚动条控制

这是本组件的核心创新点

  • 全局滚动体验:使用页面的原生滚动条,而非组件内部滚动
  • 无缝集成:树组件可以与页面其他内容(如表单、卡片)自然融合
  • 单一滚动条:整个页面只有一个滚动条,符合用户习惯

4. 🎨 拖拽排序

  • 直观交互:支持节点拖拽重新排序
  • 三种放置模式:before(前面)、after(后面)、inside(内部)
  • 视觉反馈:拖拽过程中提供清晰的视觉指示

5. 🌲 完整的树操作

  • 展开/收起:支持单个节点或全部节点的展开收起
  • 节点点击:自定义节点点击事件处理
  • 图标定制:支持自定义节点图标

技术实现原理

核心架构

┌─────────────────────────────────────┐
│         Window (Body Scroll)        │
│  ┌───────────────────────────────┐  │
│  │      Form Area (Fixed)        │  │
│  └───────────────────────────────┘  │
│  ┌───────────────────────────────┐  │
│  │   Virtual Tree Container      │  │
│  │  ┌─────────────────────────┐  │  │
│  │  │  Visible Node 1         │  │  │ ← 视口内
│  │  │  Visible Node 2         │  │  │
│  │  │  Visible Node 3         │  │  │
│  │  ├─────────────────────────┤  │  │
│  │  │  (Hidden Nodes)         │  │  │ ← 虚拟占位
│  │  │  Total Height: 10000px  │  │  │
│  │  └─────────────────────────┘  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

1. Body 滚动监听机制

这是本组件最具特色的技术实现:

useEffect(() => {
  const handleScroll = () => {
    if (!containerRef.current) return;
    
    const rect = containerRef.current.getBoundingClientRect();
    const containerTop = rect.top;
    
    // 计算容器相对于视口的滚动位置
    // 如果容器顶部在视口上方,scrollTop为正值
    const newScrollTop = Math.max(0, -containerTop);
    setScrollTop(newScrollTop);
  };

  // 初始化滚动位置
  handleScroll();
  
  // 监听window滚动事件
  window.addEventListener('scroll', handleScroll, { passive: true });
  
  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

关键点解析

  • 监听 window.scroll 事件而非容器的 scroll 事件
  • 通过 getBoundingClientRect() 获取容器相对于视口的位置
  • 当容器顶部滚出视口时(rect.top < 0),计算出虚拟的 scrollTop
  • 使用 passive: true 优化滚动性能

2. 可见范围计算

基于 Body 滚动位置计算哪些节点应该被渲染:

const visibleRange = useMemo(() => {
  if (positions.length === 0) {
    return { start: 0, end: 0 };
  }

  const rect = containerRef.current.getBoundingClientRect();
  const viewportHeight = window.innerHeight;
  
  // 计算视口内可见的范围
  const viewportTop = Math.max(0, -rect.top);
  const viewportBottom = viewportTop + viewportHeight;
  
  // 找到第一个可见节点
  let start = 0;
  for (let i = 0; i < positions.length; i++) {
    if (positions[i].top + positions[i].height >= viewportTop) {
      start = Math.max(0, i - overscan);
      break;
    }
  }
  
  // 找到最后一个可见节点
  let end = positions.length - 1;
  for (let i = start; i < positions.length; i++) {
    if (positions[i].top > viewportBottom) {
      end = Math.min(positions.length - 1, i + overscan);
      break;
    }
  }
  
  return { start, end };
}, [positions, scrollTop, overscan]);

算法优势

  • 基于视口高度和容器位置动态计算
  • 支持 overscan 预渲染,提升滚动流畅度
  • 使用二分查找可进一步优化(当前为线性查找)

3. 不定高节点处理

每个节点的高度可能不同,需要精确测量和缓存:

// 使用 ResizeObserver 监听高度变化
useEffect(() => {
  if (!nodeRef.current) return;

  const resizeObserver = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const height = entry.contentRect.height;
      onUpdateHeight(node.key, height);
    }
  });

  resizeObserver.observe(nodeRef.current);

  return () => {
    resizeObserver.disconnect();
  };
}, [node.key, onUpdateHeight]);

高度缓存策略

const { positions, totalHeight } = useMemo(() => {
  const positions = [];
  let currentTop = 0;
  
  flattenedData.forEach((node) => {
    const height = nodeHeights[node.key] || itemMinHeight;
    positions.push({
      key: node.key,
      top: currentTop,
      height
    });
    currentTop += height;
  });
  
  return {
    positions,
    totalHeight: currentTop
  };
}, [flattenedData, nodeHeights, itemMinHeight]);

4. 树数据扁平化

将树形结构转换为一维数组,便于虚拟滚动处理:

const flattenTree = useCallback((nodes, level = 0, parentKey = null) => {
  const result = [];
  
  nodes.forEach((node, index) => {
    const key = node.key || `${parentKey}-${index}`;
    const item = {
      ...node,
      key,
      level,
      parentKey,
      hasChildren: node.children && node.children.length > 0,
      isExpanded: expandedKeys.has(key)
    };
    
    result.push(item);
    
    // 只有展开的节点才递归处理子节点
    if (item.hasChildren && item.isExpanded) {
      result.push(...flattenTree(node.children, level + 1, key));
    }
  });
  
  return result;
}, [expandedKeys]);

扁平化优势

  • 将树形结构转换为线性数组,便于索引访问
  • 只包含可见的节点(未展开的子节点不在数组中)
  • 记录每个节点的层级信息,用于缩进显示

5. 拖拽实现

支持节点拖拽重新排序:

const handleDrop = ({ dragNode, dropNode, position }) => {
  // 1. 深拷贝树数据
  const newTreeData = JSON.parse(JSON.stringify(treeData));
  
  // 2. 从原位置删除节点
  const removedNode = removeNode(newTreeData, dragNode.key);
  
  // 3. 插入到新位置
  const inserted = insertNode(newTreeData, dropNode.key, removedNode, position);
  
  // 4. 更新树数据
  setTreeData(newTreeData);
};

拖拽位置判断

const handleDragOver = (e, node, position) => {
  const rect = nodeRef.current.getBoundingClientRect();
  const offsetY = e.clientY - rect.top;
  const height = rect.height;
  
  let position;
  if (offsetY < height * 0.25) {
    position = 'before';  // 上方 25%
  } else if (offsetY > height * 0.75) {
    position = 'after';   // 下方 25%
  } else {
    position = 'inside';  // 中间 50%
  }
};

性能优化策略

1. 渲染优化

  • useMemo 缓存计算结果:避免重复计算可见范围和节点位置
  • useCallback 缓存函数:防止子组件不必要的重新渲染
  • React.memo:对 TreeNode 组件进行记忆化

2. 滚动优化

  • passive 事件监听:提升滚动性能
  • requestAnimationFrame:可选的滚动节流(当前未使用)
  • overscan 预渲染:减少滚动时的白屏

3. 内存优化

  • 按需渲染:只渲染可见节点,大幅减少 DOM 数量
  • 高度缓存:避免重复测量节点高度
  • 及时清理:组件卸载时清理事件监听和 Observer

使用示例

import VirtualTree from './components/VirtualTree';

function App() {
  const treeRef = useRef(null);
  const [treeData, setTreeData] = useState([...]);

  const handleNodeClick = (node) => {
    console.log('点击节点:', node);
  };

  const handleDrop = ({ dragNode, dropNode, position }) => {
    // 处理拖拽逻辑
  };

  return (
    <div>
      {/* 页面其他内容 */}
      <Form>...</Form>
      
      {/* 树组件 - 使用 body 滚动条 */}
      <VirtualTree
        ref={treeRef}
        data={treeData}
        itemMinHeight={32}
        overscan={5}
        draggable={true}
        onNodeClick={handleNodeClick}
        onDrop={handleDrop}
      />
    </div>
  );
}

受控/非受控组件分析

2025年12月12日 17:38

基础概念

日常开发中一定会碰到表单处理的需求,比如输入框、下拉框、单选框、上传等,既然是组件,不管是ui组件还是自定义组件,优秀或者说完善的组件一定得是同时支持受控和非受控的,那么何为受控和非受控组件呢?

改变一个组件的值,只能通过两种方式

image.png

用户去改变组件的value或者代码去改变组件的value

如果不能通过代码去改变组件的value, 那么这个组件的value只能通过用户的行为去改变,那么这个组件就不受我们的代码控制,那么它就是一个非受控组件,反之能通过代码改变组件的value值,组件受我们代码控制,那么它就是一个受控组件。

非受控模式下,代码可以组件设置默认值defaulValue,但是代码设置完默认值后就不能控制value,能改变value的只能是用户行为,代码只能通过监听onChange事件获取value或者获取dom实例来获取value值。

image.png

注意:defaultValue和value不一样,defaultValue是value的初始值,用户后面改变的是value的值

受控模式下,代码一旦给组件设置了value,用户就不能再去通过行为改变它,只能通过代码监听onChange事件拿到value重新赋值去改变.

image.png

圈起来,这句话要考:value能通过用户控制就是非受控、通过打码控制就是受控

受控示例

一个典型的受控代码片段

import { Input } from 'antd'
import { ChangeEvent, useState } from 'react'

export default function Demos() {
  const [text, setText] = useState('')
  const inputHandler = (e: ChangeEvent<HTMLInputElement>) => {
    // 通过监听Input的onChange去重新赋值来改变value,用户无法控制输入框的值
    setText(e.target.value)
  }

  return <Input value={text} onChange={inputHandler} />
}

非受控示例

一个典型的非受控代码片段

import { Input, InputRef } from 'antd'
import { useRef } from 'react'

export default function Demos() {
  const inputRef = useRef<InputRef>(null)

  setTimeout(() => {
    // 通过ref获取dom元素来获取value
    console.log(inputRef.current?.input?.value)
  }, 4000)

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // 通过监听onChange来获取value
    console.log(e.target.value)
  }

  return <Input ref={inputRef} onChange={onChange} />
}

通过以上使用,我们可以发现,antd的Input组件同时支持了受控和非受控模式,那么我们能不能也自己封装一个同时支持受控和非受控模式的组件呢?

自定义同时支持受控和非受控模式的Radio组件

完整封装如下

import { useSettingStore } from '@/stores'
import { cn } from '@/utils'
import { useMemo, useState } from 'react'
import SpaceItem from '../spaceItem/SpaceItem'

// 自定义时间选择组件的属性
export type MyRadioProps = {
  value?: number
  defaultValue?: number
  onChange?: (value: number) => void
  options?: { label: string; value: number }[] // 选项
}

// 非受控/受控单选组件
export default function MyRadio(props: MyRadioProps) {
  const { colorPrimary } = useSettingStore()
  const { value, defaultValue, options, onChange } = props

  // 是否是受控组件
  const isControlled = useMemo(() => {
    return Reflect.has(props, 'value')
  }, [props])

  const [selectedValue, setSelectedValue] = useState<number | undefined>(
    isControlled ? value : defaultValue,
  ) // 内部状态

  // 最终拿去渲染的值
  const mergedValue = useMemo(() => {
    return isControlled ? value : selectedValue
  }, [selectedValue, value, isControlled])

  // 选择的回调
  const onSelect = (value: number) => () => {
    // 非受控时才更新内部状态
    if (!isControlled) {
      setSelectedValue(value)
    }

    onChange?.(value)
  }

  return (
    <SpaceItem wrap align="left">
      {options?.map((item) => (
        <div
          key={item.value}
          onClick={onSelect(item.value)}
          className={cn(
            'w-[65px] h-[35px] rounded-md flex items-center justify-center cursor-pointer border-[1px] border-[#585455] border-solid p-1',
            { 'text-white': mergedValue === item.value },
          )}
          style={{
            backgroundColor: mergedValue === item.value ? colorPrimary : '',
          }}
        >
          {item.label}
        </div>
      ))}
    </SpaceItem>
  )
}
// 是否是受控组件
  const isControlled = useMemo(() => {
    return Reflect.has(props, 'value')
  }, [props])

通过判断props中是否有value属性来判断到底是受控还是非受控

const [selectedValue, setSelectedValue] = useState<number | undefined>(
    isControlled ? value : defaultValue,
  ) // 内部状态

保存一个内部状态,来存储非受控模式时的值

// 最终拿去渲染的值
  const mergedValue = useMemo(() => {
    return isControlled ? value : selectedValue
  }, [selectedValue, value, isControlled])

组件最终显示的值,受控时显示父组件传入的value值,非受控时显示组件内部存储的值

// 选择的回调
  const onSelect = (value: number) => () => {
    // 非受控时才更新内部状态
    if (!isControlled) {
      setSelectedValue(value)
    }

    onChange?.(value)
  }

组件值改变时,如果是非受控,更新组件内部的值,并触发onChange事件回调(受控和非受控时都可以传onChange事件)

组件使用

<Card title="自定义单选组件非受控用法" size="small">
    <MyRadio
      options={Array.from({ length: 10 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      defaultValue={8}
      onChange={(value) => console.log(value)}
    />
</Card>
<Card title="自定义单选组件受控用法" size="small">
    <MyRadio
      options={Array.from({ length: 10 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      value={radio1}
      onChange={(value) => setRadio1(value)}
    />
</Card>

image.png

useMergeState封装

以上代码成功的实现了自定义组件同时支持受控和非受控,但是逻辑太分散,是否可以将处理逻辑再次封装呢?那么我们就来封装一个自定义hook来统一处理受控和非受控的逻辑

import { getTypeOf } from '@/utils'
import {
  SetStateAction,
  useCallback,
  useEffect,
  useMemo,
  useState,
} from 'react'

// 参数属性
export type MergeStateProps<T> = {
  value?: T
  defaultValue?: T
  onChange?: (value: T) => void
  [props: string]: any
}

// 配置属性
export type MergeStateOption<T> = {
  defaultValue?: T // 默认值
  defaultValuePropName?: string // 默认值属性名
  valuePropName?: string // 值属性名
  trigger?: string // 触发
}

/**
 * @description 合并状态hook
 */
function useMergeState<T = any>(
  props: MergeStateProps<T> = {},
  options: MergeStateOption<T> = {},
): [T, (v: SetStateAction<T>, ...args: any[]) => void] {
  const {
    defaultValue,
    defaultValuePropName = 'defaultValue',
    valuePropName = 'value',
    trigger = 'onChange',
  } = options

  const value = props[valuePropName] // 获取当前值
  const isControlled = Reflect.has(props, valuePropName) // 是否受控

  // 初始值
  const initialValue = useMemo(() => {
    if (isControlled) {
      return value
    }

    if (Reflect.has(props, defaultValuePropName)) {
      return props[defaultValuePropName]
    }

    return defaultValue
  }, [
    defaultValue,
    value,
    isControlled,
    defaultValuePropName,
    props,
  ])

  const [state, setState] = useState(initialValue) // 保存内部状态

  // 可控的情况,外部传入值时,更新内部状态
  useEffect(() => {
    if (isControlled) {
      setState(value)
    }
  }, [isControlled, value])

  // 设置值
  const handleSetState = useCallback(
    (v?: SetStateAction<T>, ...args: any[]) => {
      const res = getTypeOf(v) === 'Function' ? (v as any)(state) : v
      // 非受控时才更新内部状态
      if (!isControlled) {
        setState(res)
      }
      if (props[trigger]) {
        props[trigger](res, ...args)
      }
    },
    [props, trigger, isControlled, state],
  )

  return [state, handleSetState]
}

export default useMergeState
const value = props[valuePropName] // 获取当前value值
const isControlled = Reflect.has(props, valuePropName) // 是否受控

获取当前的value,并判断是否是受控模式

  // 初始值
const initialValue = useMemo(() => {
    if (isControlled) {
      return value
    }

    if (Reflect.has(props, defaultValuePropName)) {
      return props[defaultValuePropName]
    }

    return defaultValue
}, [
    defaultValue,
    value,
    isControlled,
    defaultValuePropName,
    props,
])

const [state, setState] = useState(initialValue) // 保存内部状态

设置内部状态的值 1、如果是受控,则返回value的值 2、如果不是受控,则返回传入配置中定义的defaultValue的属性名对应的值 3、否则返回传入的defaultValue值

这里为什么需要传valuePropName这个属性呢,因为Switch/CheckBox组件没有value属性,只有checked属性,是为了兼容

// 受控的情况,外部传入值时,更新内部状态
  useEffect(() => {
    if (isControlled) {
      setState(value)
    }
  }, [isControlled, value])

受控的情况下,外部传入值时,更新内部状态

// 设置值
const handleSetState = useCallback(
    (v?: SetStateAction<T>, ...args: any[]) => {
      const res = getTypeOf(v) === 'Function' ? (v as any)(state) : v
      // 非受控时才更新内部状态
      if (!isControlled) {
        setState(res)
      }
      if (props[trigger]) {
        props[trigger](res, ...args)
      }
    },
    [props, trigger, isControlled, state],
)

返回组件的第二个返回值,设置值的方法 1、首先判断使用该hook第二个返回值时传入的参数是不是一个函数,是的话先执行 2、非受控时才去更新内部状态,受控时不用更新,直接由父组件改变value 3、触发事件回调

使用上述hook再封装一个同时支持受控和非受控的自定义组件

import useMergeState from '@/hooks/useMergeState'
import { useThemeToken } from '@/hooks/useThemeToken'
import { cn } from '@/utils'
import SpaceItem from '../spaceItem/SpaceItem'

// 自定义时间选择组件的属性
export type MyCheckboxProps = {
  value?: number[]
  defaultValue?: number[]
  onChange?: (value: number[]) => void
  options?: { label: string; value: number }[] // 选项
}

// 非受控/受控多选组件
export default function MyCheckbox(props: MyCheckboxProps) {
  const { colorPrimary } = useThemeToken()

  const { options } = props
  const [selectedValue, setSelectedValue] = useMergeState<number[]>(props)

  // 选择的回调
  const onSelect = (value: number) => () => {
    let res = [
      ...(Array.isArray(selectedValue) ? selectedValue : [selectedValue]),
    ]
    if (Array.isArray(selectedValue) && selectedValue?.includes(value)) {
      res = selectedValue.filter((item) => item !== value)
    } else {
      res.push(value)
    }
    setSelectedValue(res)
  }

  return (
    <SpaceItem wrap align="left">
      {options?.map((item) => (
        <div
          key={item.value}
          onClick={onSelect(item.value)}
          className={cn(
            'w-[65px] h-[35px] rounded-md flex items-center justify-center cursor-pointer border-[1px] border-[#585455] border-solid p-1',
            {
              'bg-[#1890ff] text-white':
                Array.isArray(selectedValue) &&
                selectedValue?.includes(item.value),
            },
          )}
          style={{
            backgroundColor: selectedValue?.includes(item.value)
              ? colorPrimary
              : '',
          }}
        >
          {item.label}
        </div>
      ))}
    </SpaceItem>
  )
}

组件使用

<Card title="自定义多选组件非受控用法" size="small">
    <MyCheckbox
      options={Array.from({ length: 12 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      defaultValue={[4, 6, 9]}
      onChange={(value) => console.log(value)}
    />
</Card>
<Card title="自定义多选组件受控用法" size="small">
    <MyCheckbox
      options={Array.from({ length: 16 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      value={checkbox1}
      onChange={(value) => setCheckbox1(value)}
    />
</Card>

image.png

useControllableValue的使用

以上的封装,都是开发者自定义的,强大的ahooks怎么可能没有想到这种需求呢,所以ahooks也提供了useControllableValue这个hook

基本使用

import React, { useState } from 'react';
import { useControllableValue } from 'ahooks';

const ControllableComponent = (props: any) => {
  const [state, setState] = useControllableValue<string>(props);

  return <input value={state} onChange={(e) => setState(e.target.value)} style={{ width: 300 }} />;
};

const Parent = () => {
  const [state, setState] = useState<string>('');
  const clear = () => {
    setState('');
  };

  return (
    <>
      <ControllableComponent value={state} onChange={setState} />
      <button type="button" onClick={clear} style={{ marginLeft: 8 }}>
        Clear
      </button>
    </>
  );
};

使用useControllableValue封装一个自定义时间选择组件

import { cn, dateFormat } from '@/utils'
import { useControllableValue } from 'ahooks'
import dayjs from 'dayjs'
import WhiteSpace from '../whiteSpace'

// 自定义时间选择组件的属性
export type TimeProps = {
  value?: number
  defaultValue?: number
  onChange?: (value: number) => void
  timeNum?: number
}

// 非受控/受控时间选择组件
export default function MyTime(props: TimeProps) {
  const { timeNum = 10 } = props
  const [value, setValue] = useControllableValue(props)

  // 时间选择的回调
  const onSelectTime = (time: number) => () => {
    setValue(time)
  }

  return (
    <div>
      <div>当前时间:{dateFormat(value, 'YYYY-MM-DD HH:mm:ss')}</div>
      <WhiteSpace />
      {Array.from({ length: timeNum }).map((_, index) => {
        const time = dayjs()
          .subtract(index + 1, 'days')
          .startOf('day')
          .valueOf()

        return (
          <div
            onClick={onSelectTime(time)}
            key={index}
            className={cn({ 'text-red-500': time === value })}
          >
            {dateFormat(time, 'YYYY-MM-DD HH:mm:ss')}
          </div>
        )
      })}
    </div>
  )
}

image.png

用useControllableValue结合antd的DatePicker组件二次封装一个时间选择组件

import { useControllableValue } from 'ahooks'
import { DatePicker, DatePickerProps } from 'antd'
import dayjs from 'dayjs'
import { useMemo } from 'react'

const defaultShortcuts = [
  {
    label: '今天',
    value: dayjs(),
  },
  {
    label: '昨天',
    value: dayjs().subtract(1, 'day'),
  },
  {
    label: '三天前',
    value: dayjs().subtract(3, 'days'),
  },
  {
    label: '一周前',
    value: dayjs().subtract(1, 'week'),
  },
  {
    label: '15天前',
    value: dayjs().subtract(15, 'days'),
  },
  {
    label: '一个月前',
    value: dayjs().subtract(1, 'month'),
  },
]

// 时间选择器组件的属性
export interface MyDatePickerProps extends DatePickerProps {
  shortcuts?: number[] // 快捷选项
  shortcutsMap?: Record<number, string> // 快捷选项的映射
  shortcutsRender?: (shortcuts?: number[]) => DatePickerProps['presets']
  showPresets?: boolean // 是否显示快捷选项
}

// 非受控/受控时间选择器组件
export default function MyDatePicker(props: MyDatePickerProps) {
  const {
    shortcuts,
    shortcutsMap,
    showPresets = true,
    shortcutsRender,
    ...rests
  } = props

  const [values, setValues] =
    useControllableValue<DatePickerProps['value']>(props)

  const presets = useMemo(() => {
    if (!showPresets) return undefined
    if (shortcutsRender && shortcuts?.length) {
      return shortcutsRender(shortcuts)
    }
    if (shortcuts?.length) {
      return shortcuts.map((shortcut) => {
        return {
          label: shortcutsMap?.[shortcut] || `近${shortcut}天`,
          value: dayjs().subtract(shortcut, 'days'),
        }
      })
    }
    return defaultShortcuts
  }, [shortcuts, shortcutsMap, showPresets, shortcutsRender])

  return (
    <DatePicker
      presets={presets}
      {...rests}
      value={values}
      onChange={setValues}
    />
  )
}

用useControllableValue结合antd的Upload组件二次封装一个图片上传组件

import type { UploadProps } from 'antd/es/upload/interface'
import { ButtonProps } from 'antd/lib'

export type ImgsValueType = string[] | string // 上传的值类型

// 上传参数类型
export interface IImgsUploadProps
  extends Omit<UploadProps, 'onChange' | 'value' | 'defaultValue'> {
  validate?: boolean // 是否需要验证接收类型和文件大小
  validateSize?: boolean // 是否需要验证图片的宽高
  limitWidth?: number // 验证图片的宽
  limitHeight?: number // 验证图片的高
  size?: number // 限制的尺寸,以M为单位
  successText?: string // 上传成功的提示文字
  failedText?: string // 上传失败的提示文字
  uploadText?: string // 上传按钮文字
  uploadStyles?: React.CSSProperties // 上传按钮的样式
  imgsStyles?: React.CSSProperties // 图片的样式
  imgList?: string[] // 已上传的图片列表
  preview?: boolean // 图片是否可预览
  count?: number // 图片总数限制
  tips?: string // 提示tips
  tipStyle?: React.CSSProperties // 提示tips的样式
  plusSizeTip?: string // 超过尺寸大小的提示语
  errorAcceptTip?: string // 上传格式不正确的提示语
  compress?: boolean // 是否压缩图片
  quality?: number // 压缩比例
  value?: ImgsValueType // 值
  defaultValue?: ImgsValueType // 默认值
  width?: number // 图片展示的宽
  height?: number // 图片展示的高
  multi?: boolean // 是否上传多张图片
  uploadBtn?: React.ReactNode // 自定义上传按钮
  uploadBtnProps?: ButtonProps // 上传按钮属性
  showImgs?: boolean // 是否显示已上传的图片
  fileValidateTip?: string // 文件校验不通过的提示语
  fileValidate?: (file: File) => Promise<boolean> // 文件校验
  onChange?: (data: ImgsValueType) => void // 改变的回调
  remove?: (url: string) => void // 移除已上传图片的回调
  onUploaded?: (url: string, fileInfo?: any, ...restParams: any) => void // 单张上传成功后接收结果
}


import { compressPic } from '@/utils'
import { CloseOutlined, LoadingOutlined } from '@ant-design/icons'
import { useControllableValue } from 'ahooks'
import { Button, Image as IM, message, Spin, Upload } from 'antd'
import type { UploadProps } from 'antd/es/upload/interface'
import { useState } from 'react'
import styles from './imgsUpload.module.less'
import type { IImgsUploadProps } from './typings'

// 允许上传的图片类型
export const ACCEPTIMG = '.jpg, .jpeg, .png, .gif, .webp, .ico, .bmp'

// 图片列表(多张、单张)上传通用组件
const ImgsUpload: React.FC<IImgsUploadProps> = (props: IImgsUploadProps) => {
  const {
    validate = true,
    validateSize = false,
    limitWidth = 1080,
    limitHeight = 1920,
    size,
    successText = '上传成功',
    failedText = '上传失败',
    plusSizeTip,
    errorAcceptTip,
    compress = false,
    quality = 0.6,
    accept,
    uploadStyles = {},
    imgsStyles = {},
    preview = true,
    multi = false,
    count = multi ? 5 : 1,
    tips,
    disabled,
    tipStyle = {},
    width = 80,
    height = 80,
    uploadText,
    uploadBtn,
    uploadBtnProps,
    showImgs = true,
    fileValidateTip,
    value,
    fileValidate,
    onChange,
    remove,
    onUploaded,
    ...restProps
  } = props || {}

  console.log(value, onChange)

  const accepts = accept ?? ACCEPTIMG // 接收类型
  const limit = size ?? 5 // 限制大小
  const [spin, setSpin] = useState<boolean>(false) // 上传中
  const [imgs, setImgs] = useControllableValue(props, {
    defaultValue: multi ? [] : '',
  }) // 已上传的图片列表

  // 上传的回调
  const handleChange = (info: any) => {
    if (info.file.status === 'uploading') {
      setSpin(true)
    }
    if (info.file.status === 'done' && info.file?.response?.msg === 'success') {
      setSpin(false)
      const result = info.file?.response?.result[0]
      if (!!result && !result?.endsWith('.bin')) {
        message.success(successText)

        // 多张图片
        if (multi) {
          setImgs((pre: any) => {
            if ((pre || []).length < count) {
              return [...(pre || []), result]
            }
            return pre
          })
        } else {
          // 单张图片
          setImgs(result)
        }

        // 上传成功的回调
        onUploaded?.(result, info.file)
      } else {
        message.error(failedText)
      }
    }
    if (info.file.status === 'done' && info.file?.response?.msg !== 'success') {
      setSpin(false)
      message.error(info.file?.response?.msg || failedText)
    }
    if (info.file.status === 'error') {
      setSpin(false)
      message.error(failedText)
    }
  }

  // 获取上传图片的原始宽高
  const getImgWidthHeight = (
    file: File,
  ): Promise<{ width: number; height: number }> => {
    return new Promise((resolve) => {
      const img = new Image()
      img.crossOrigin = 'anonymous' // 跨域
      img.src = URL.createObjectURL(file)
      img.onload = function () {
        resolve({ width: img.width, height: img.height })
      }
      img.onerror = function () {
        resolve({ width: 0, height: 0 })
      }
    })
  }

  // 上传之前的回调
  const beforeUpload = async (file: File) => {
    if (validateSize) {
      const widthHeight = await getImgWidthHeight(file)
      const { width, height } = widthHeight
      if (width !== limitWidth || height !== limitHeight) {
        message.warning(`图片的大小应该为${limitWidth} * ${limitHeight}`)
        return false
      }
    }
    if (validate) {
      const file_typename = file.name.substring(file.name.lastIndexOf('.'))
      const isRightfile = accepts.includes(file_typename?.toLowerCase())
      // 检验格式
      if (!isRightfile) {
        message.warning(errorAcceptTip || `请上传${accepts}格式的图片`)
      }
      const isLt = file.size / 1024 / 1024 <= limit
      if (!isLt) {
        message.warning(plusSizeTip || `图片大小不超过${limit}M`)
      }

      // 自定义文件校验
      if (fileValidate) {
        const pass = await fileValidate(file)
        if (pass === false) {
          if (fileValidateTip) {
            message.warning(fileValidateTip)
          }
          return false
        }
      }

      // 如果要压缩
      if (isRightfile && isLt && compress) {
        return compressPic(file, quality)
      }
      return isRightfile && isLt
    }
    return true
  }

  // 上传参数
  const uploadProps: UploadProps = {
    showUploadList: false,
    action: `${import.meta.env.VITE_UPLOAD_BASE_URL}/admin/file/upload`,
    accept: accepts,
    disabled: !!spin || disabled,
    multiple: true,
    onChange: handleChange,
    beforeUpload,
  }

  // 移除图片
  const removeImg = (url: string) => {
    if (multi) {
      setImgs((pre: any) => {
        const newImgs = (pre || []).filter((p: string) => p !== url)
        return newImgs
      })
    } else {
      setImgs('')
    }

    // 移除图片的回调
    remove?.(url)
  }

  return (
    <Spin spinning={!!spin}>
      <div className={styles.upload}>
        {showImgs && imgs ? (
          <div className={styles.imgs}>
            {((multi ? imgs : [imgs]) as string[])?.map((url) => (
              <div key={url} className={styles.imgItem}>
                <IM
                  src={`${import.meta.env.VITE_ASSET_BASE_URL}/${url}`}
                  width={width}
                  height={height}
                  style={imgsStyles}
                  preview={preview}
                />
                <CloseOutlined
                  onClick={() => {
                    if (disabled) {
                      return
                    }
                    removeImg(url)
                  }}
                />
              </div>
            ))}
          </div>
        ) : null}
        {(!multi && !imgs) || !imgs || imgs?.length < count ? (
          <Upload
            disabled={disabled}
            {...uploadProps}
            {...restProps}
            style={uploadStyles}
          >
            {spin ? (
              <LoadingOutlined />
            ) : (
              uploadBtn || (
                <Button type="primary" {...uploadBtnProps}>
                  {uploadText || '请选择上传图片'}
                </Button>
              )
            )}
          </Upload>
        ) : null}
      </div>
      {tips ? (
        <div className="pt-2" style={tipStyle}>
          {tips}
        </div>
      ) : null}
    </Spin>
  )
}

export default ImgsUpload

使用useControllableValue来封装同时支持受控和非受控组件,非常的快捷方便,强烈推荐

useControllableValue源码

function useControllableValue<T = any>(
  props: StandardProps<T>,
): [T, (v: SetStateAction<T>) => void];
function useControllableValue<T = any>(
  props?: Props,
  options?: Options<T>,
): [T, (v: SetStateAction<T>, ...args: any[]) => void];
function useControllableValue<T = any>(props: Props = {}, options: Options<T> = {}) {
  const {
    defaultValue, // 默认值,会被 props.defaultValue 和 props.value 覆盖
    defaultValuePropName = 'defaultValue', // 默认值的属性名
    valuePropName = 'value', // 值的属性名
    trigger = 'onChange', // 修改值时,触发的函数
  } = options;
  // 外部(父级)传递进来的 props 值
  const value = props[valuePropName] as T;
  // 是否受控:判断 valuePropName(默认即表示value属性),有该属性代表受控
  const isControlled = props.hasOwnProperty(valuePropName);

  // 首次默认值
  const initialValue = useMemo(() => {
    // 受控:则由外部的props接管控制 state
    if (isControlled) {
      return value;
    }
    // 外部有传递 defaultValue,则优先取外部的默认值
    if (props.hasOwnProperty(defaultValuePropName)) {
      return props[defaultValuePropName];
    }
    // 优先级最低,组件内部的默认值
    return defaultValue;
  }, []);

  const stateRef = useRef(initialValue);
  // 受控组件:如果 props 有 value 字段,则由父级接管控制 state
  if (isControlled) {
    stateRef.current = value;
  }

  // update:调用该函数会强制组件重新渲染
  const update = useUpdate();

  function setState(v: SetStateAction<T>, ...args: any[]) {
    const r = isFunction(v) ? v(stateRef.current) : v;

    // 非受控
    if (!isControlled) {
      stateRef.current = r;
      update(); // 更新状态
    }
    // 只要 props 中有 onChange(trigger 默认值未 onChange)字段,则在 state 变化时,就会触发 onChange 函数
    if (props[trigger]) {
      props[trigger](r, ...args);
    }
  }

  // 返回 [状态值, 修改 state 的函数]
  return [stateRef.current, useMemoizedFn(setState)] as const;
}

总结

以上就是对于受控和非受控的总结,文章中部分代码可能有错误之处,还望指正,不喜勿喷哦

# 🌟 JavaScript原型与原型链终极指南:从Function到Object的完整闭环解析 ,深入理解JavaScript原型系统核心

作者 AY1024
2025年12月12日 16:55

深入理解JavaScript原型系统核心

📖 目录


🎯 核心概念

四大基本原则

  1. 原则一:每个对象都有构造函数(constructor)

    • 指向构建该对象或实例的函数
  2. 原则二:只有函数对象才有prototype属性

    • 非函数对象没有prototype属性
    • 实例只有__proto__属性
    • 两者指向同一个对象(函数的原型对象)
  3. 原则三:Function函数是所有函数的构造函数

    • 包括它自己
    • 代码中声明的所有函数都是Function的实例
  4. 原则四:Object也是函数

    • 所以Object也是Function函数的实例

实例,函数,对象,原型对象,构造函数,关系总览图

image.png

🔍 非函数对象分类

  • 实例对象,const person = new Foo(),person就是实例对象
  • 普通对象({}new Object()
  • 内置非函数对象实例

🔄 显式原型与隐式原型

对象分类

  • 函数对象:拥有prototype属性
  • 非函数对象:只有__proto__属性

相同点

  • 都指向同一个原型对象

📝 示例代码

function Person(){}
const person = new Person();

console.log("Person.prototype指向:", Person.prototype)
console.log("person.__proto__指向", person.__proto__)

🖼️ 执行结果

显式原型

隐式原型


🎯 构造函数的指向

默认情况

function Person(){}
const person = new Person();

console.log("Person.prototype.constructor指向", Person.prototype.constructor)
// 输出:[Function: Person]

执行结果

默认构造函数指向

默认构造函数指向详情


修改原型对象后

function Person(){}
const person = new Person();

Person.prototype = new foo();  // 修改原型对象

console.log("Person.prototype.constructor指向", Person.prototype.constructor)
// 输出:[Function: foo]

执行结果

修改后构造函数指向

修改后构造函数指向详情


📊 核心原理说明

解释

Person.prototype被当作函数foo的实例,继承了foo函数(此篇不展开继承详解)

总结规律

  • 每个原型对象或实例都有.constructor属性
  • 实例通过原型链查找constructor
  • 原型对象默认指向自身的函数(如果不是其他函数的实例)

查找过程示例

// Person.prototype被当作实例时
Person.prototype.__proto__ → foo.prototypefoo()

🖼️ 可视化关系图

三者关系图

原型关系图


🔬 代码验证

function Person(){}

// 创建新的原型对象
Person.prototype = {
    name: "杨",
    age: "18",
    histype: "sleep"
}

// 添加方法
Person.prototype.print = function(){
    console.log("你好我是原型对象");
}

// 创建实例
const person01 = new Person();
const person02 = new Person();

// 验证指向
console.log("Person.prototype指向:", Person.prototype)
console.log("person01.__proto__指向", person01.__proto__)
console.log("person02.__proto__指向", person02.__proto__)
console.log("Person.prototype.constructor指向", Person.prototype.constructor)

执行结果

代码验证结果


⚠️ 特别说明

关键细节

创建新对象时,Person.prototype.constructor指向Object,因为Person.prototype成了Object的实例。

对比情况

  • 创建新对象时Person.prototype.constructorObject
  • 未创建新对象时Person.prototype.constructorPerson

示意图

构造函数指向对比

构造函数指向对比详情


Function和Object

小故事

从前有个力大无穷的大力神,能举起任何东西,有一天,小A在路上和这个大力神相遇了。

大力神:小子,我可是力大无穷的大力神,我能举起任何东西,你信不信?

小A:呦呦呦,还大力神,你说你能举起任何东西,那你能把你自己抬起来吗?

...

  • Function是所有函数的加工厂,你在代码声明的所有函数都是Function的实例,包括Function函数本身,Object也是函数,所以它也是Functiod的实例

  • Function就是这样的大力神,而且是可以把自己抬起来的大力神,这听起来比较扯,但是这就是事实,请看VCR:

function Person (){}

const person01 = new Person();

console.log("Function.__proto__指向",Function.__proto__)//Function.__proto__指向 [Function (anonymous)] Object
console.log("Function.prototype指向",Function.prototype)//Function.prototype指向 [Function (anonymous)] Object
console.log("Function.__proto__ == Function.prototype???",Function.__proto__ == Function.prototype)
//Function.__proto__ == Function.prototype??? true

image.png

image.png

Object 在 JavaScript 中扮演三重角色:

  • 构造函数:用于创建对象

  • 命名空间:提供一系列静态方法用于对象操作

  • 原型终点:Object.prototype 是所有原型链的终点,在往上没有了,值==null

请看VCR:

function Person (){};

const persoon01 = new Person();
const obj = {};//通过对象字面量{}创建obj实例
const obj1 = new Object();//通过构造函数new Object()创建obj1实例
const obj2 = Object.create(Object.prototype);//通过委托创建,或者叫原型创建,来创建obj2实例

console.log("Person.prototype.__proto__指向",Person.prototype.__proto__);
//Person.prototype.__proto__指向 [Object: null prototype] {}

console.log("Function.prototype.__proto__指向",Function.prototype.__proto__)
//Function.prototype.__proto__指向 [Object: null prototype] {}

console.log("通过对象字面量{}创建的obj实例,obj.__proto__指向",obj.__proto__);
//通过对象字面量{}创建的obj实例,obj.__proto__指向 [Object: null prototype] {}

console.log("通过构造函数new Object()创建obj1实例,指向",obj1.__proto__);
//通过构造函数new Object()创建obj1实例,指向 [Object: null prototype] {}

console.log("通过委托创建,或者叫原型创建,来创建obj2实例,指向",obj2.__proto__);
//通过委托创建,或者叫原型创建,来创建obj2实例,指向 [Object: null prototype] {}

image.png

image.png

Function和Object的关系

  • 相互依赖的循环引用
    • Object 是 Function 的实例(构造函数层面)

    • Function 是 Object 的子类(原型继承层面)

    • 这是 JavaScript 的自举(Bootstrap)机制

根据关系总览图,我们可以看到,Function和Object,它们两形成了一个闭环,将所有的函数和对象都包裹在这个闭环里

📋 JavaScript 原型系统核心概念表

概念 描述 示例 特殊说明
prototype 函数特有,指向原型对象 Person.prototype 只有函数对象才有此属性
proto 所有对象都有,指向构造函数的原型 person.__proto__ 实际应使用 Object.getPrototypeOf()
constructor 指向创建该对象的构造函数 Person.prototype.constructor 可被修改,查找时沿原型链进行
原型链查找 通过 __proto__ 逐级向上查找 person.__proto__.__proto__ 终点为 null
Function 所有函数的构造函数 Function.prototype Function.__proto__ === Function.prototype
Object 所有对象的基类 Object.prototype 原型链终点,Object.prototype.__proto__ === null

🔍 补充说明

prototype 补充

  • 函数的 prototype 属性默认包含 constructor 属性指向函数自身
  • 用于实现基于原型的继承

proto 补充

  • 现在更推荐使用 Object.getPrototypeOf(obj)Object.setPrototypeOf(obj, proto)
  • __proto__ 是访问器属性,不是数据属性

constructor 补充

  • constructor 属性可以通过原型链查找
  • 示例:person.constructor === Person(实际查找的是 person.__proto__.constructor

原型链查找补充

  • 当访问对象属性时,如果对象自身没有,会沿着原型链向上查找
  • 直到找到该属性或到达原型链终点 null

Function 补充

  • 是所有函数的构造函数,包括内置构造函数(Object、Array等)和自定义函数
  • 自身也是函数,所以 Function.__proto__ === Function.prototype

Object 补充

  • Object.prototype 是所有原型链的最终原型对象
  • 通过 Object.create(null) 可以创建没有原型的"纯净对象"

💡 记忆口诀

  • 函数看prototype,实例看__proto__
  • constructor找根源,原型链上寻答案
  • Object是终点,Function是关键

结语:

看完这篇文章,你应该可以读懂上面的关系总览图了,望学习愉快!!!

制作一个简单的HTML个人网页

2025年12月12日 16:54

下面给你一个超级简单但又好看、手机电脑都能完美访问的个人主页模板,5 分钟就能改成你自己的专属网页!

功能特点

  • 纯 HTML + CSS(一行 JavaScript 都不用)
  • 自适应移动端(手机看起来也好看)
  • 深色/浅色自动切换(跟随系统)
  • 一键替换头像、姓名、简介、社交链接就完事
  • 支持添加博客、项目、作品、摄影等模块

完整代码(直接复制保存为 index.html 双击打开即可)

<!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>
        :root {
            --bg: #f8f9fa;
            --text: #2c3e50;
            --card: #ffffff;
            --accent: #ff6b6b;   /* 主色调,喜欢可换成 #e91e63、#4ecdc4 等 */
        }
        @media (prefers-color-scheme: dark) {
            :root {
                --bg: #121212;
                --text: #e0e0e0;
                --card: #1e1e1e;
            }
        }

        * { margin:0; padding:0; box-sizing:border-box; }
        body {
            font-family: "Segoe UI", Arial, sans-serif;
            background: var(--bg);
            color: var(--text);
            line-height: 1.6;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
        }
        .card {
            background: var(--card);
            max-width: 420px;
            width: 100%;
            border-radius: 20px;
            overflow: hidden;
            box-shadow: 0 15px 35px rgba(0,0,0,0.1);
            text-align: center;
            transition: transform 0.3s;
        }
        .card:hover { transform: translateY(-10px); }

        .avatar {
            width: 120px;
            height: 120px;
            border-radius: 50%;
            margin: 30px auto 10px;
            object-fit: cover;
            border: 5px solid var(--accent);
        }
        h1 {
            font-size: 28px;
            margin: 10px 0;
            color: var(--accent);
        }
        .tagline {
            color: #888;
            font-size: 16px;
            margin-bottom: 20px;
        }
        .bio {
            padding: 0 30px;
            margin-bottom: 25px;
            font-size: 15px;
        }
        .links {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            gap: 12px;
            padding: 0 20px 30px;
        }
        .links a {
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 10px 20px;
            background: var(--accent);
            color: white;
            text-decoration: none;
            border-radius: 50px;
            font-size: 14px;
            transition: all 0.3s;
        }
        .links a:hover {
            transform: scale(1.08);
            box-shadow: 0 5px 15px rgba(255,107,107,0.4);
        }
        .links a i { font-size: 18px; }

        footer {
            margin-top: 40px;
            font-size: 14px;
            color: #aaa;
        }
    </style>
    <!-- 图标库(可选) -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
</head>
<body>

<div class="card">
    <!-- 1. 替换成你的头像(放同目录下或用网络链接) -->
    <img src="https://avatars.githubusercontent.com/u/你的githubid?v=4" alt="头像" class="avatar">
    <!-- 或者本地图片:src="myphoto.jpg" -->

    <h1>张三</h1>
    <p class="tagline">前端开发者 / 摄影爱好者 / 正在努力变厉害的人</p>

    <div class="bio">
        Hi~我是张三,目前在广州做前端,喜欢写代码、拍风景、撸猫。<br>
        生活很普通,但希望每天都能进步一点点
    </div>

    <div class="links">
        <!-- 直接改链接和图标就行 -->
        <a href="https://github.com/yourname" target="_blank">
            <i class="fab fa-github"></i> GitHub
        </a>
        <a href="https://weibo.com/yourname" target="_blank">
            <i class="fab fa-weibo"></i> 微博
        </a>
        <a href="https://space.bilibili.com/123456" target="_blank">
            <i class="fab fa-bilibili"></i> B站
        </a>
        <a href="mailto:your@email.com">
            <i class="fas fa-envelope"></i> 邮件
        </a>
        <a href="https://yourblog.com" target="_blank">
            <i class="fas fa-blog"></i> 博客
        </a>
        <a href="https://juejin.cn/user/你的id" target="_blank">
            <i class="fab fa-juejin"></i> 掘金
        </a>
    </div>
</div>

<footer>© 2025 张三 | 手工码的个人主页</footer>

</body>
</html>

只需要改 4 处就完全是你自己的网页了!

  1. 头像:把 src="https://..." 换成你自己的照片链接(推荐放同目录下改成 src="avatar.jpg"
  2. 名字:改 <h1>张三</h1>
  3. 一句话介绍:改 .tagline 那行
  4. 自我介绍 + 社交链接:按需增删即可

想更炫酷?再加这几行(任选)

  • 背景粒子特效(加在 <body> 前面):
  <script src="https://cdn.jsdelivr.net/npm/particles.js@2.0.0/particles.min.js"></script>
  <div id="particles-js"></div>
  <script>
  particlesJS("particles-js", {"particles":{"number":{"value":80},"color":{"value":"#ff6b6b"},"shape":{"type":"circle"},"opacity":{"value":0.5},"size":{"value":3},"move":{"speed":1}}});
  </script>
  <style>#particles-js{position:fixed;top:0;left:0;width:100%;height:100%;z-index:-1;}</style>
  • 音乐自动播放(七夕专用):
  <audio src="your-music.mp3" autoplay loop hidden></audio>

5 分钟搞定一个高颜值个人主页,赶紧发给朋友/对象/面试官吧~
需要再加「作品集」「时间轴」「留言板」等功能,随时告诉我,我继续给你升级!

HTML标签 - 表格标签

作者 GinoWi
2025年12月12日 16:46

HTML标签 - 表格标签

在过去网站开发过程中,表格标签的使用是非常非常多,绝大多数的网站都是使用表格标签来制作的,也就是说表格标签是一个时代的代表。

什么是表格标签?

表格标签的作用是用来给一堆数据添加表格语义,其实表格是一种数据的展现形式,当数据量非常大的时候,表格这种展现形式被认为是最为清晰的一种展现形式。

表格结构

由于表格中存储的数据比较复杂,为了方便管理、阅读以及提升语义,我们可以对表格中存储的数据进行分类。

  • 表格中存储的数据可以分为四类:

    1. 表格标题
    2. 表格表头
    3. 表格主体
    4. 表格的页尾信息
  • 表格完整结构

<table>
  <caption>表格的标题</caption>
  <thead>
    <tr>
      <th>每一列的标题</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>数据</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <td>数据</td>
    </tr>
  </tfoot>
</table>

表格标签

  • table标签

    • 作用:表格标签中的table代表整个表格,也就是一对table标签就是一个表格。
  • tr标签

    • 作用:表格标签的tr代表表格中的一行数据,也就是说一对tr标签就是表格中的一行。
  • td标签

    • 作用:表格标签中的td标签代表表格中的一个单元格,也就是说一对td标签就是表格中的一个单元格。
  • caption标签

    • 作用:在表格标签中提供了一个标签专门用来设置表格的标题,这个标签叫做caption。只要将标题写在caption标签中,那么标题就会自动相对于表格的宽度居中。
    • 注意点:
      • caption标签一定要写在table标签中,否则无效。
      • caption一定要紧跟在table标签后面。
  • th标签

    • 作用:在表格标签中提供了一个标签专门用来展示每一列的标题,这个标签叫做th标签,只要使用th标签展现当前列的标题,这时标题就会在该标签单元格中自动居中。
  • thead标签

    • 作用:指定表格表头信息。
  • tbody标签

    • 作用:指定表格主体信息。
    • 注意点:如果我们没有编写tbody,系统会自动给我门添加tbody
  • tfoot标签

    • 作用:指定表格附加信息。
    • 注意点:如果指定了theadtfoot,那么在修改整个表格的高度时,theadtfoot有自己的默认高度,不会随着表格的高度变化而变化。

总结:

  • 表格标签有一个边框属性,这个属性决定了边框的宽度,默认情况下这个属性的值是0,所以看不到边框。

  • 表格标签和列表标签一样,它是一个组合标签,所以table/tr/td要么一起出现,要么一起不出现,不会单独出现。

  • 表格中有两种单元格,一种是td,一种是thtd是专门用来存储数据的,th是专门用来存储当前列的标题的。

表格标签的属性(这部分内容仅为了解,以后均通过CSS来进行修改):

  • 宽度和高度的属性(可以给table标签和td标签使用)

    • 表格的宽度和高度默认按照内容尺寸调整,也可以通过给table标签设置width/height属性的方式来手动指定表格宽高。
    • 如果给td标签设置width/height属性,会修改当前单元格的宽度(会同时影响当前列单元格宽度)和高度(会同时影响当前行单元格高度),不会影响整个表格的宽度和高度。
      • 当给一行中不同单元格设置不同的height属性,保留一行中高度最高的属性值作为该行单元格的高度。
      • 当给一列中不同单元格设置不同的width属性,保留一行中宽度最宽的属性值作为该列单元格的宽度。
  • 水平对齐和垂直对齐的属性(其中水平对齐可以给table标签和tr标签和td标签使用,垂直对齐只能给tr标签和td标签使用)

    • table标签设置align属性,可以控制表格在水平方向的对齐方式。
    • tr标签设置align属性,可以控制当前行中所有单元格内容的水平方向对齐方式。
    • td标签设置align属性,可以控制当前单元格中内容在水平方向的对齐方式(如果td标签中设置了align属性,tr中也设置了align属性,那么单元格中的内容会按照td中设置的来对齐)。
    • tr标签设置valign属性,可以控制当前行中所有单元格内容的垂直方向对齐方式。
    • td标签设置valign属性,可以控制当前单元格中内容在垂直方向对齐方式。(如果td标签中设置了valign属性,tr中也设置了valign属性,那么单元格中的内容会按照td中设置的来对齐。
  • 外边距和内边距的属性(只能给table标签使用)

    • 外边距(cellspacing)就是单元格和单元格之间的距离(默认情况下单元格与单元格的外边距是2px)
    • 内边距(cellpadding)就是文字距离单元格之间的距离(默认情况下内边距是1px)
  • 通过属性设置完成细线表格的绘制:

    • 在表格标签中想通过指定外边距为0来实现细线表格是不靠谱的,其实它是将2条合并为了一条线,所以看上去很不舒服。 通过设置外边距实现的表格

    • 细线表格的制作方式:(table标签、tr标签以及td标签都支持bgcolor属性,但是样式以后通过css完成控制。)

      1. table标签设置bgcolor属性
      2. tr标签设置bgcolor属性
      3. table标签设置cellspacing="1px"
    • 代码:

    <table bgcolor="black" cellspacing="1px" width="500px" height="300px">
        <tr bgcolor="white">
            <td></td>
            <td></td>
            <td></td>
        </tr>
        <tr bgcolor="white">
            <td></td>
            <td></td>
            <td></td>
        </tr>
        <tr bgcolor="white">
            <td></td>
            <td></td>
            <td></td>
        </tr>
    </table>
    
    • 效果展示: 通过设置背景颜色实现的表格

      通过放大比较上述两张表格图片,就能够很明显的看出表格边框的差别。

单元格合并

  • 水平方向上的单元格合并

    • 可以给td标签添加colspan属性,把某一个单元格当作多个单元格来看待(水平)。
    • 格式:<td colspan="2"></td>含义:把当前单元格当作两个单元格来看待。
    • 注意点:
      • 由于把某一个单元格当作多个单元格来看待,所以就会多出一些单元格,需要删掉一些单元格确保表格正常显示。
      • 单元格合并永远都是向后或者向下合并,而不能向前或者向上合并。
  • 垂直方向上的单元格合并

    • 可以给td标签添加rowspan属性,把某一个单元格当作多个单元格来看待(垂直)。
    • 格式:<td rowspan="2"></td>含义:把当前单元格当作两个单元格来看待。
    • 注意点:
      • 由于把某一个单元格当作多个单元格来看待,所以就会多出一些单元格,需要删掉一些单元格确保表格正常显示。
      • 单元格合并永远都是向后或者向下合并,而不能向前或者向上合并。

参考链接:

W3School官方文档:www.w3school.com.cn

护航隐私!小程序纯前端“证件加水印”:OffscreenCanvas 全屏平铺实战

作者 小皮虾
2025年12月12日 16:34

1. 背景与痛点:证件“裸奔”的风险

在日常生活中,我们经常需要上传身份证、驾照或房产证照片来办理各种业务。然而,直接发送原图存在巨大的安全隐患:

  • 被二次盗用:不法分子可能将你的证件照用于网贷、注册账号等非法用途。
  • 服务器隐私泄露:如果使用在线工具加水印,图片必须上传到第三方服务器,这就好比“把钥匙交给陌生人保管”,风险不可控。

为了解决这一痛点,可利用小程序的 OffscreenCanvas 能力,在用户手机本地毫秒级合成水印,图片数据永远不会离开用户手机

2. 核心思路:离屏渲染 + 矩阵平铺

实现全屏倾斜水印,主要难点在于坐标计算性能平衡。我们的方案如下:

  1. 离屏渲染 (OffscreenCanvas): 使用离屏画布在内存中处理,避免页面闪烁,且支持高性能的 2D 渲染模式。
  2. 智能 DPR 降级: 沿用我们之前文章提到的防爆内存策略。证件照通常分辨率很高,必须计算安全尺寸,防止 Canvas 内存溢出闪退。
  3. 矩阵平铺算法: 不简单的旋转画布,而是采用 “保存环境 -> 平移 -> 旋转 -> 绘制 -> 恢复环境” 的策略,在一个网格循环中将文字铺满全屏,确保无论图片比例如何,水印都能均匀分布。

3. 硬核代码实现

以下是封装好的 watermarkUtils.js。包含了智能 DPR 计算全屏水印绘制的核心逻辑。

// utils/watermarkUtils.js

// 1. 获取系统基础信息
const wxt = {
  dpr: wx.getSystemInfoSync().pixelRatio || 2
};

// 图片缓存,避免重复加载
const cacheCanvasImageMap = new Map();

/**
 * 内部方法:获取/创建 Canvas Image 对象
 */
async function getCanvasImage(canvas, imageUrl) {
  if (cacheCanvasImageMap.has(imageUrl)) return cacheCanvasImageMap.get(imageUrl);
  
  // 兼容性处理:若不支持 Promise.withResolvers,请改用 new Promise
  const { promise, resolve, reject } = Promise.withResolvers();
  const image = canvas.createImage();
  image.onload = () => {
    cacheCanvasImageMap.set(imageUrl, image);
    resolve(image);
  };
  image.onerror = (e) => reject(new Error(`加载失败: ${e.errMsg}`));
  image.src = imageUrl;
  await promise;
  return image;
}

/**
 * 给图片添加全屏倾斜水印
 * @param {string} imageUrl 图片路径
 * @param {string} text 水印文字,如 "仅供办理租房业务使用"
 * @param {object} options 配置项 { color, size, opacity }
 */
export async function addWatermark(imageUrl, text = '仅供办理业务使用', options = {}) {
  // 默认配置
  const config = {
    color: '#aaaaaa',
    opacity: 0.5,
    fontSize: 0, // 0 表示自动计算
    gap: 100,    // 水印间距
    ...options
  };

  const offscreenCanvas = wx.createOffscreenCanvas({ type: '2d' });
  const image = await getCanvasImage(offscreenCanvas, imageUrl);
  const { width, height } = image;

  // --- ⚡️ 性能优化:智能 DPR 计算 (防止大图闪退) ---
  const LIMIT_SIZE = 4096; 
  let useDpr = wxt.dpr;
  if (Math.max(width, height) * useDpr > LIMIT_SIZE) {
    useDpr = LIMIT_SIZE / Math.max(width, height);
  }

  // 设置画布尺寸
  offscreenCanvas.width = width * useDpr;
  offscreenCanvas.height = height * useDpr;

  const ctx = offscreenCanvas.getContext('2d');
  ctx.scale(useDpr, useDpr);
  
  // 1. 绘制底图
  ctx.drawImage(image, 0, 0, width, height);

  // 2. 配置水印样式
  // 自动计算字号:约为图片宽度的 4%
  const fontSize = config.fontSize || Math.floor(width * 0.04); 
  ctx.font = `bold ${fontSize}px sans-serif`;
  ctx.fillStyle = config.color;
  ctx.globalAlpha = config.opacity;
  ctx.textBaseline = 'middle';
  ctx.textAlign = 'center';

  // 3. 计算平铺逻辑
  // 旋转 45 度后,覆盖范围需要比原图大,这里简单取对角线长度作为边界
  const maxSize = Math.sqrt(width * width + height * height);
  // 步长 = 文字宽度 + 间距
  const step = ctx.measureText(text).width + config.gap; 
  
  // 4. 循环绘制水印
  // 从负坐标开始绘制,确保旋转后边缘也有水印
  for (let x = -maxSize; x < maxSize; x += step) {
    for (let y = -maxSize; y < maxSize; y += step) {
      ctx.save();
      
      // 核心变换:平移到网格点 -> 旋转 -> 绘制
      ctx.translate(x, y);
      ctx.rotate(-45 * Math.PI / 180); // 逆时针旋转 45 度
      ctx.fillText(text, 0, 0);
      
      ctx.restore();
    }
  }

  // 5. 导出图片
  const res = await wx.canvasToTempFilePath({
    canvas: offscreenCanvas,
    fileType: 'jpg',
    quality: 0.8, // 稍微压缩以减小体积
  });

  return res.tempFilePath;
}

4. 业务调用示例

在小程序页面中,用户选择图片并输入水印文字后,实时预览效果。

// pages/watermark/index.js
import { addWatermark } from '../../utils/watermarkUtils';

Page({
  data: {
    originImg: '',
    resultImg: '',
    watermarkText: '仅供本次业务使用 他用无效'
  },

  async onAddWatermark() {
    if (!this.data.originImg) return;

    wx.showLoading({ title: '安全合成中...' });
    
    try {
      const tempFilePath = await addWatermark(
        this.data.originImg, 
        this.data.watermarkText,
        {
          color: '#ffffff', // 白色水印
          opacity: 0.4,     // 半透明
          gap: 120          // 间距疏松一点
        }
      );
      
      this.setData({ resultImg: tempFilePath });
      
    } catch (err) {
      console.error(err);
      wx.showToast({ title: '合成失败', icon: 'none' });
    } finally {
      wx.hideLoading();
    }
  }
})

5. 避坑与实战经验

  1. 自动字号的重要性: 不要写死 fontSize = 20px。用户上传的图片分辨率差异极大(有的 500px 宽,有的 4000px 宽)。最佳实践是根据图片宽度动态计算字号(如 width * 0.04),这样无论处理缩略图还是 4K 原图,水印比例看起来都是协调的。
  2. 平铺范围的陷阱: 因为文字需要旋转 45 度,如果循环只从 0width,图片的左下角和右上角可能会出现空白。代码中我们从 -maxSize(负数区域)开始循环,确保旋转后的文字能完全覆盖画布的每一个角落。
  3. 隐私第一: 在工具的 UI 界面上,建议显著提示 “纯本地处理,无上传服务器”,这能极大地增加用户的信任感,提升工具的使用率。

写在最后

通过帮小忙工具箱的这个实践案例,我们可以看到,利用小程序强大的 Canvas 能力,开发者完全可以在保护用户隐私的前提下,提供专业级的图片处理服务。

技术不只是代码,更是对用户安全的守护。 希望这篇分享能帮你在小程序中实现更安全、更高效的功能!

无废话之 useState、useRef、useReducer 的使用场景与选择指南

2025年12月12日 16:28

在 React 中,最常用的状态管理 Hook 有三个:

  • useState
  • useRef
  • useReducer

它们都能“存数据”,但作用完全不同。
本文通过对比、代码示例和最佳实践,让你一眼看懂三者的异同点与使用策略。

1. 三者一句话总结(记住这个就够了)

Hook 特点 什么时候用
useState 会触发组件重新渲染 UI 需要根据数据变化而更新
useRef 不会触发渲染,可持久存储 保存 DOM、保存不影响 UI 的数据、避免频繁渲染
useReducer 适合复杂状态逻辑,集中管理 多步骤状态、复杂更新规则、类似 Vuex/Redux

2. useState —— 最常用的 UI 状态管理方式

📌 用途

  • 管理与 UI 显示相关的状态
  • 一旦更新 → React 会重新渲染组件

📌 示例:计数器

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

return (
  <button onClick={() => setCount(count + 1)}>
    {count}
  </button>
);

👉 每次 setCount 运行,UI 都会更新。

3. useRef —— 不触发渲染的“可变容器”

📌 用途

  • 保存不会影响 UI 的值(计时器、缓存、临时变量)
  • 保存 DOM 节点引用
  • 在渲染周期之间持久化数据

📌 示例:保存一个不会影响 UI 的计数器

const counterRef = useRef(0);

function add() {
  counterRef.current += 1;
  console.log(counterRef.current);
}

return <button onClick={add}>Add</button>;

👉 按多少次,UI 都不会变化,因为它 不会触发重渲染

4. useReducer —— 多分支、复杂逻辑的状态管理

📌 用途

适合以下情况:

  • 状态结构复杂(多字段)
  • 更新逻辑复杂(多 if/else 或 switch)
  • 想将逻辑分离,让代码更清晰

📌 示例:管理一个表单对象

function reducer(state, action) {
  switch (action.type) {
    case "setName":
      return { ...state, name: action.payload };
    case "setAge":
      return { ...state, age: action.payload };
    case "reset":
      return { name: "", age: 0 };
    default:
      return state;
  }
}

const [form, dispatch] = useReducer(reducer, {
  name: "",
  age: 0,
});

👉 适合“动作驱动”的状态结构。

5. 三者的核心区别(最关键)

对比点 useState useRef useReducer
是否触发渲染 ✔ 会 ❌ 不会 ✔ 会
存储数据类型 简单/基本 任意 复杂对象
逻辑复杂度 简单 简单 中-高
适合多字段状态 不太合适 不合适 ✔ 最合适
跨 render 保留值
管理 DOM
适合封装业务逻辑 一般 ✔ 非常好

6. 如何选择?(最实用的决策树)

🟦 1)数据是否影响 UI?

  • 是 → useState 或 useReducer
  • 否 → useRef

🟩 2)数据更新逻辑是否复杂?

  • 复杂(多字段、多动作)→ useReducer
  • 简单(一个值)→ useState

🟨 3)更新是否非常频繁?(例如输入法、mousemove)

  • 是,但 UI 不依赖 → useRef
  • 是,且 UI 要更新 → useState + 性能优化

🟧 4)是否需要类似 Redux 的写法?

  • 是 → useReducer
  • 否 → useState / useRef

7. 最容易犯的错误(务必注意)

把 useRef 当 useState 用

const count = useRef(0);
count.current++; // UI 不更新!

👉 你以为 UI 会变,但不会。

用 useState 处理频繁更新、但 UI 不需要的数据

例如 storing mousemove 坐标:

  • 会造成大量 re-render,卡顿
  • 推荐用 useRef

在 useReducer 中修改 state(不可变规则)

错误 ❌:

state.age = 10;
return state;

正确 ✔:

return { ...state, age: 10 };

8. 三者组合使用示例(真实项目中常见)

示例:表单组件

状态 用哪个? 为什么
表单字段 useReducer 多字段、动作复杂
表单提交 loading useState 简单布尔值
DOM 节点(input) useRef 保存 DOM
防抖计时器 useRef 不触发渲染

9. 小结:最佳实践

场景 推荐
UI 状态简单 useState
UI 状态复杂、多字段 useReducer
数据不会用于渲染 useRef
保存 DOM 节点 useRef
保存缓存、前一次值 useRef
避免频繁 re-render useRef
需要统一管理 action useReducer

结语

useState、useRef、useReducer 都可以存储数据,但它们在 React 渲染机制中的角色完全不同。

  • useState = UI 状态
  • useRef = 自定义缓存 / DOM
  • useReducer = 逻辑复杂的状态机

掌握好这三者的边界,就能写出结构清晰且性能优秀的 React 代码。

Vue 3 组件开发最佳实践:可复用组件设计模式

作者 微芒不朽
2025年12月12日 16:28

Vue 3 组件开发最佳实践:可复用组件设计模式

前言

组件化是现代前端开发的核心思想之一,而在 Vue 3 中,借助 Composition API 和更完善的响应式系统,我们能够设计出更加灵活、可复用的组件。本文将深入探讨 Vue 3 组件开发的最佳实践,介绍多种可复用组件的设计模式,帮助开发者构建高质量的组件库。

组件设计基本原则

1. 单一职责原则

每个组件应该只负责一个明确的功能,避免功能过于复杂。

2. 开放封闭原则

组件对扩展开放,对修改封闭,通过合理的接口设计支持定制化。

3. 可组合性

组件应该易于与其他组件组合使用,形成更复杂的 UI 结构。

基础组件设计模式

1. Props 透传模式

<!-- BaseButton.vue -->
<template>
  <button 
    :class="buttonClasses"
    v-bind="$attrs"
    @click="handleClick"
  >
    <slot />
  </button>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  variant: {
    type: String,
    default: 'primary',
    validator: (value) => ['primary', 'secondary', 'danger', 'ghost'].includes(value)
  },
  size: {
    type: String,
    default: 'medium',
    validator: (value) => ['small', 'medium', 'large'].includes(value)
  },
  block: {
    type: Boolean,
    default: false
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['click'])

const buttonClasses = computed(() => [
  'btn',
  `btn--${props.variant}`,
  `btn--${props.size}`,
  {
    'btn--block': props.block,
    'btn--disabled': props.disabled
  }
])

const handleClick = (event) => {
  if (!props.disabled) {
    emit('click', event)
  }
}

// 允许父组件访问子组件实例
defineExpose({
  focus: () => {
    // 实现焦点管理
  }
})
</script>

<style scoped>
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: all 0.2s ease;
  text-decoration: none;
}

.btn--primary {
  background-color: #42b883;
  color: white;
}

.btn--secondary {
  background-color: #6c757d;
  color: white;
}

.btn--danger {
  background-color: #dc3545;
  color: white;
}

.btn--ghost {
  background-color: transparent;
  color: #42b883;
  border: 1px solid #42b883;
}

.btn--small {
  padding: 4px 8px;
  font-size: 12px;
}

.btn--medium {
  padding: 8px 16px;
  font-size: 14px;
}

.btn--large {
  padding: 12px 24px;
  font-size: 16px;
}

.btn--block {
  display: flex;
  width: 100%;
}

.btn--disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.btn:hover:not(.btn--disabled) {
  opacity: 0.8;
  transform: translateY(-1px);
}
</style>

2. 插槽分发模式

<!-- Card.vue -->
<template>
  <div class="card" :class="cardClasses">
    <!-- 默认插槽 -->
    <div v-if="$slots.header || title" class="card__header">
      <slot name="header">
        <h3 class="card__title">{{ title }}</h3>
      </slot>
    </div>
  
    <!-- 内容插槽 -->
    <div class="card__body">
      <slot />
    </div>
  
    <!-- 底部插槽 -->
    <div v-if="$slots.footer" class="card__footer">
      <slot name="footer" />
    </div>
  
    <!-- 操作区域插槽 -->
    <div v-if="$slots.actions" class="card__actions">
      <slot name="actions" />
    </div>
  </div>
</template>

<script setup>
import { computed } from 'vue'

const props = defineProps({
  title: {
    type: String,
    default: ''
  },
  bordered: {
    type: Boolean,
    default: true
  },
  shadow: {
    type: Boolean,
    default: false
  },
  hoverable: {
    type: Boolean,
    default: false
  }
})

const cardClasses = computed(() => ({
  'card--bordered': props.bordered,
  'card--shadow': props.shadow,
  'card--hoverable': props.hoverable
}))
</script>

<style scoped>
.card {
  background: #fff;
  border-radius: 8px;
}

.card--bordered {
  border: 1px solid #e5e5e5;
}

.card--shadow {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.card--hoverable:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.card__header {
  padding: 16px 24px;
  border-bottom: 1px solid #f0f0f0;
}

.card__title {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
  color: #333;
}

.card__body {
  padding: 24px;
}

.card__footer {
  padding: 16px 24px;
  border-top: 1px solid #f0f0f0;
}

.card__actions {
  padding: 16px 24px;
  text-align: right;
}
</style>

使用示例:

<template>
  <Card title="用户信息" bordered hoverable>
    <template #header>
      <div class="custom-header">
        <h3>用户详情</h3>
        <BaseButton size="small" variant="ghost">编辑</BaseButton>
      </div>
    </template>
  
    <p>这里是卡片内容</p>
  
    <template #footer>
      <div class="card-footer">
        <span>创建时间: 2023-01-01</span>
      </div>
    </template>
  
    <template #actions>
      <BaseButton variant="primary">保存</BaseButton>
      <BaseButton variant="ghost">取消</BaseButton>
    </template>
  </Card>
</template>

高级组件设计模式

1. Renderless 组件模式

Renderless 组件专注于逻辑处理,不包含任何模板,通过作用域插槽传递数据和方法:

<!-- FetchData.vue -->
<template>
  <slot 
    :loading="loading"
    :data="data"
    :error="error"
    :refetch="fetchData"
  />
</template>

<script setup>
import { ref, onMounted } from 'vue'

const props = defineProps({
  url: {
    type: String,
    required: true
  },
  immediate: {
    type: Boolean,
    default: true
  }
})

const loading = ref(false)
const data = ref(null)
const error = ref(null)

const fetchData = async () => {
  loading.value = true
  error.value = null

  try {
    const response = await fetch(props.url)
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`)
    }
    data.value = await response.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

onMounted(() => {
  if (props.immediate) {
    fetchData()
  }
})

defineExpose({
  fetchData
})
</script>

使用示例:

<template>
  <FetchData url="/api/users" v-slot="{ loading, data, error, refetch }">
    <div class="user-list">
      <div v-if="loading">加载中...</div>
      <div v-else-if="error">错误: {{ error }}</div>
    
      <template v-else>
        <div v-for="user in data" :key="user.id" class="user-item">
          {{ user.name }}
        </div>
      
        <button @click="refetch">刷新</button>
      </template>
    </div>
  </FetchData>
</template>

2. Compound Components 模式

复合组件模式允许相关组件协同工作,共享状态和配置:

<!-- Tabs.vue -->
<template>
  <div class="tabs">
    <div class="tabs__nav" role="tablist">
      <slot name="nav" :active-key="activeKey" :change-tab="changeTab" />
    </div>
    <div class="tabs__content">
      <slot :active-key="activeKey" />
    </div>
  </div>
</template>

<script setup>
import { ref, provide } from 'vue'

const props = defineProps({
  modelValue: {
    type: [String, Number],
    default: ''
  }
})

const emit = defineEmits(['update:modelValue'])

const activeKey = ref(props.modelValue)

const changeTab = (key) => {
  activeKey.value = key
  emit('update:modelValue', key)
}

// 提供给子组件使用的上下文
provide('tabs-context', {
  activeKey,
  changeTab
})
</script>

<style scoped>
.tabs {
  border: 1px solid #e5e5e5;
  border-radius: 8px;
  overflow: hidden;
}

.tabs__nav {
  display: flex;
  background-color: #f8f9fa;
  border-bottom: 1px solid #e5e5e5;
}

.tabs__content {
  padding: 24px;
}
</style>
<!-- TabNav.vue -->
<template>
  <div class="tab-nav">
    <slot />
  </div>
</template>

<style scoped>
.tab-nav {
  display: flex;
}
</style>
<!-- TabNavItem.vue -->
<template>
  <button
    :class="classes"
    :aria-selected="isActive"
    @click="handleClick"
  >
    <slot />
  </button>
</template>

<script setup>
import { inject, computed } from 'vue'

const props = defineProps({
  tabKey: {
    type: [String, Number],
    required: true
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

const tabsContext = inject('tabs-context')

const isActive = computed(() => tabsContext.activeKey.value === props.tabKey)

const classes = computed(() => [
  'tab-nav-item',
  {
    'tab-nav-item--active': isActive.value,
    'tab-nav-item--disabled': props.disabled
  }
])

const handleClick = () => {
  if (!props.disabled) {
    tabsContext.changeTab(props.tabKey)
  }
}
</script>

<style scoped>
.tab-nav-item {
  padding: 12px 24px;
  border: none;
  background: transparent;
  cursor: pointer;
  font-size: 14px;
  color: #666;
  transition: all 0.2s ease;
}

.tab-nav-item:hover:not(.tab-nav-item--disabled) {
  color: #42b883;
  background-color: rgba(66, 184, 131, 0.1);
}

.tab-nav-item--active {
  color: #42b883;
  font-weight: 600;
  background-color: #fff;
  border-bottom: 2px solid #42b883;
}

.tab-nav-item--disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>
<!-- TabPanel.vue -->
<template>
  <div v-show="isActive" class="tab-panel" role="tabpanel">
    <slot />
  </div>
</template>

<script setup>
import { inject, computed } from 'vue'

const props = defineProps({
  tabKey: {
    type: [String, Number],
    required: true
  }
})

const tabsContext = inject('tabs-context')

const isActive = computed(() => tabsContext.activeKey.value === props.tabKey)
</script>

<style scoped>
.tab-panel {
  outline: none;
}
</style>

使用示例:

<template>
  <Tabs v-model="activeTab">
    <template #nav="{ activeKey, changeTab }">
      <TabNavItem tab-key="profile">个人信息</TabNavItem>
      <TabNavItem tab-key="settings">设置</TabNavItem>
      <TabNavItem tab-key="security" disabled>安全</TabNavItem>
    </template>
  
    <TabPanel tab-key="profile">
      <p>这是个人信息面板</p>
    </TabPanel>
  
    <TabPanel tab-key="settings">
      <p>这是设置面板</p>
    </TabPanel>
  
    <TabPanel tab-key="security">
      <p>这是安全面板</p>
    </TabPanel>
  </Tabs>
</template>

<script setup>
import { ref } from 'vue'

const activeTab = ref('profile')
</script>

3. Higher-Order Component (HOC) 模式

虽然 Vue 更推荐使用 Composition API,但在某些场景下 HOC 仍然有用:

// withLoading.js
import { h, ref, onMounted } from 'vue'

export function withLoading(WrappedComponent, loadingMessage = '加载中...') {
  return {
    name: `WithLoading(${WrappedComponent.name || 'Component'})`,
    inheritAttrs: false,
    props: WrappedComponent.props,
    emits: WrappedComponent.emits,
    setup(props, { attrs, slots, emit }) {
      const isLoading = ref(true)
    
      onMounted(() => {
        // 模拟异步操作
        setTimeout(() => {
          isLoading.value = false
        }, 1000)
      })
    
      return () => {
        if (isLoading.value) {
          return h('div', { class: 'loading-wrapper' }, loadingMessage)
        }
      
        return h(WrappedComponent, {
          ...props,
          ...attrs,
          on: Object.keys(emit).reduce((acc, key) => {
            acc[key] = (...args) => emit(key, ...args)
            return acc
          }, {})
        }, slots)
      }
    }
  }
}

4. State Reducer 模式

借鉴 React 的理念,通过 reducer 函数管理复杂状态:

<!-- Toggle.vue -->
<template>
  <div class="toggle">
    <slot 
      :on="on"
      :toggle="toggle"
      :set-on="setOn"
      :set-off="setOff"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'

const props = defineProps({
  modelValue: {
    type: Boolean,
    default: false
  },
  reducer: {
    type: Function,
    default: null
  }
})

const emit = defineEmits(['update:modelValue'])

const internalOn = ref(props.modelValue)

const getState = () => ({
  on: internalOn.value
})

const dispatch = (action) => {
  const changes = props.reducer 
    ? props.reducer(getState(), action)
    : defaultReducer(getState(), action)
  
  if (changes.on !== undefined) {
    internalOn.value = changes.on
    emit('update:modelValue', changes.on)
  }
}

const defaultReducer = (state, action) => {
  switch (action.type) {
    case 'toggle':
      return { on: !state.on }
    case 'setOn':
      return { on: true }
    case 'setOff':
      return { on: false }
    default:
      throw new Error(`Unknown action type: ${action.type}`)
  }
}

const toggle = () => dispatch({ type: 'toggle' })
const setOn = () => dispatch({ type: 'setOn' })
const setOff = () => dispatch({ type: 'setOff' })

defineExpose({
  toggle,
  setOn,
  setOff
})
</script>

使用示例:

<template>
  <Toggle :reducer="toggleReducer" v-slot="{ on, toggle, setOn, setOff }">
    <div class="toggle-demo">
      <p>状态: {{ on ? '开启' : '关闭' }}</p>
      <BaseButton @click="toggle">切换</BaseButton>
      <BaseButton @click="setOn">开启</BaseButton>
      <BaseButton @click="setOff">关闭</BaseButton>
    </div>
  </Toggle>
</template>

<script setup>
const toggleReducer = (state, action) => {
  switch (action.type) {
    case 'toggle':
      // 添加日志记录
      console.log('Toggle state changed:', !state.on)
      return { on: !state.on }
    case 'setOn':
      return { on: true }
    case 'setOff':
      return { on: false }
    default:
      return state
  }
}
</script>

组件通信最佳实践

1. Provide/Inject 模式

// theme.js
import { ref, readonly, computed } from 'vue'

const themeSymbol = Symbol('theme')

export function createThemeStore() {
  const currentTheme = ref('light')

  const themes = {
    light: {
      primary: '#42b883',
      background: '#ffffff',
      text: '#333333'
    },
    dark: {
      primary: '#42b883',
      background: '#1a1a1a',
      text: '#ffffff'
    }
  }

  const toggleTheme = () => {
    currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light'
  }

  const themeConfig = computed(() => themes[currentTheme.value])

  return {
    currentTheme: readonly(currentTheme),
    themeConfig,
    toggleTheme
  }
}

export function provideTheme(themeStore) {
  provide(themeSymbol, themeStore)
}

export function useTheme() {
  const themeStore = inject(themeSymbol)
  if (!themeStore) {
    throw new Error('useTheme must be used within provideTheme')
  }
  return themeStore
}

2. Event Bus 替代方案

使用 mitt 库替代传统的事件总线:

// eventBus.js
import mitt from 'mitt'

export const eventBus = mitt()

// 在组件中使用
// eventBus.emit('user-login', userInfo)
// eventBus.on('user-login', handler)

性能优化策略

1. 组件懒加载

// router/index.js
const routes = [
  {
    path: '/heavy-component',
    component: () => import('@/components/HeavyComponent.vue')
  }
]

// 组件内部懒加载
const HeavyChart = defineAsyncComponent(() => 
  import('@/components/charts/HeavyChart.vue')
)

2. 虚拟滚动

<!-- VirtualList.vue -->
<template>
  <div 
    ref="containerRef" 
    class="virtual-list"
    @scroll="handleScroll"
  >
    <div :style="{ height: totalHeight + 'px' }" class="virtual-list__spacer">
      <div 
        :style="{ transform: `translateY(${offsetY}px)` }"
        class="virtual-list__content"
      >
        <div
          v-for="item in visibleItems"
          :key="item.id"
          :style="{ height: itemHeight + 'px' }"
          class="virtual-list__item"
        >
          <slot :item="item" />
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'

const props = defineProps({
  items: {
    type: Array,
    required: true
  },
  itemHeight: {
    type: Number,
    default: 50
  },
  bufferSize: {
    type: Number,
    default: 5
  }
})

const containerRef = ref(null)
const scrollTop = ref(0)

const totalHeight = computed(() => props.items.length * props.itemHeight)

const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.bufferSize)
})

const endIndex = computed(() => {
  const containerHeight = containerRef.value?.clientHeight || 0
  return Math.min(
    props.items.length - 1,
    Math.floor((scrollTop.value + containerHeight) / props.itemHeight) + props.bufferSize
  )
})

const visibleItems = computed(() => {
  return props.items.slice(startIndex.value, endIndex.value + 1)
})

const offsetY = computed(() => {
  return startIndex.value * props.itemHeight
})

const handleScroll = () => {
  scrollTop.value = containerRef.value.scrollTop
}

onMounted(() => {
  // 初始化滚动监听
})

onUnmounted(() => {
  // 清理资源
})
</script>

<style scoped>
.virtual-list {
  height: 400px;
  overflow-y: auto;
  border: 1px solid #e5e5e5;
}

.virtual-list__spacer {
  position: relative;
}

.virtual-list__content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.virtual-list__item {
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid #f0f0f0;
}
</style>

测试友好的组件设计

1. 明确的 Props 定义

// Button.test.js
import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'

describe('BaseButton', () => {
  test('renders slot content', () => {
    const wrapper = mount(BaseButton, {
      slots: {
        default: 'Click me'
      }
    })
    expect(wrapper.text()).toContain('Click me')
  })

  test('emits click event when clicked', async () => {
    const wrapper = mount(BaseButton)
    await wrapper.trigger('click')
    expect(wrapper.emitted()).toHaveProperty('click')
  })

  test('applies correct CSS classes based on props', () => {
    const wrapper = mount(BaseButton, {
      props: {
        variant: 'primary',
        size: 'large'
      }
    })
    expect(wrapper.classes()).toContain('btn--primary')
    expect(wrapper.classes()).toContain('btn--large')
  })
})

2. 可访问性考虑

<!-- AccessibleModal.vue -->
<template>
  <teleport to="body">
    <div 
      v-if="visible"
      ref="modalRef"
      role="dialog"
      aria-modal="true"
      :aria-labelledby="titleId"
      :aria-describedby="descriptionId"
      class="modal"
      @keydown.esc="close"
    >
      <div class="modal__overlay" @click="close"></div>
      <div class="modal__content" ref="contentRef">
        <div class="modal__header">
          <h2 :id="titleId" class="modal__title">{{ title }}</h2>
          <button 
            type="button"
            class="modal__close"
            @click="close"
            aria-label="关闭对话框"
          >
            ×
          </button>
        </div>
      
        <div :id="descriptionId" class="modal__body">
          <slot />
        </div>
      
        <div v-if="$slots.footer" class="modal__footer">
          <slot name="footer" />
        </div>
      </div>
    </div>
  </teleport>
</template>

<script setup>
import { ref, watch, nextTick } from 'vue'

const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  },
  title: {
    type: String,
    required: true
  }
})

const emit = defineEmits(['update:visible', 'close'])

const modalRef = ref(null)
const contentRef = ref(null)
const titleId = `modal-title-${Math.random().toString(36).substr(2, 9)}`
const descriptionId = `modal-desc-${Math.random().toString(36).substr(2, 9)}`

const close = () => {
  emit('update:visible', false)
  emit('close')
}

watch(() => props.visible, async (newVal) => {
  if (newVal) {
    await nextTick()
    // 自动聚焦到模态框
    contentRef.value?.focus()
  }
})
</script>

结语

Vue 3 组件开发的最佳实践涉及多个方面,从基础的 Props 和插槽使用,到高级的设计模式如 Renderless 组件和 Compound Components,每种模式都有其适用场景。关键是要根据具体需求选择合适的设计模式,并遵循以下原则:

  1. 保持组件简洁:每个组件专注于单一功能
  2. 提供良好的 API:清晰的 Props 定义和事件接口
  3. 重视可访问性:确保所有用户都能正常使用组件
  4. 考虑性能影响:特别是在处理大量数据或复杂交互时
  5. 便于测试:设计易于测试的组件接口

通过合理运用这些设计模式和最佳实践,我们可以构建出既灵活又可靠的组件库,为整个应用提供一致且高质量的用户体验。记住,好的组件设计不是一次性的任务,而是需要在实践中不断迭代和完善的过程。

Vue 3 动画效果实现:Transition和TransitionGroup详解

作者 微芒不朽
2025年12月12日 16:22

Vue 3 动画效果实现:Transition和TransitionGroup详解

前言

在现代Web应用中,流畅的动画效果不仅能提升用户体验,还能有效传达界面状态变化的信息。Vue 3 提供了强大的过渡和动画系统,通过 <transition><transition-group> 组件,开发者可以轻松地为元素的进入、离开和列表变化添加动画效果。本文将深入探讨这两个组件的使用方法和高级技巧。

Transition 组件基础

基本用法

<transition> 组件用于包装单个元素或组件,在插入、更新或移除时应用过渡效果。

<template>
  <div>
    <button @click="show = !show">切换显示</button>
    <transition name="fade">
      <p v-if="show">Hello Vue 3!</p>
    </transition>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

过渡类名详解

Vue 3 为进入/离开过渡提供了6个CSS类名:

  1. v-enter-from:进入过渡的开始状态
  2. v-enter-active:进入过渡生效时的状态
  3. v-enter-to:进入过渡的结束状态
  4. v-leave-from:离开过渡的开始状态
  5. v-leave-active:离开过渡生效时的状态
  6. v-leave-to:离开过渡的结束状态

注意:在 Vue 3 中,类名前缀从 v-enter 改为 v-enter-from,其他类名也相应调整。

JavaScript 钩子函数

除了CSS过渡,还可以使用JavaScript钩子来控制动画:

<template>
  <transition
    @before-enter="beforeEnter"
    @enter="enter"
    @after-enter="afterEnter"
    @before-leave="beforeLeave"
    @leave="leave"
    @after-leave="afterLeave"
  >
    <div v-if="show" class="box">Animated Box</div>
  </transition>
</template>

<script setup>
import { ref } from 'vue'
import gsap from 'gsap'

const show = ref(true)

const beforeEnter = (el) => {
  el.style.opacity = 0
  el.style.transform = 'scale(0)'
}

const enter = (el, done) => {
  gsap.to(el, {
    duration: 0.5,
    opacity: 1,
    scale: 1,
    onComplete: done
  })
}

const afterEnter = (el) => {
  console.log('进入完成')
}

const beforeLeave = (el) => {
  el.style.transformOrigin = 'center'
}

const leave = (el, done) => {
  gsap.to(el, {
    duration: 0.5,
    opacity: 0,
    scale: 0,
    onComplete: done
  })
}

const afterLeave = (el) => {
  console.log('离开完成')
}
</script>

常见动画效果实现

1. 淡入淡出效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Fade</button>
    <transition name="fade">
      <div v-if="show" class="content">Fade Effect Content</div>
    </transition>
  </div>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease-in-out;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

2. 滑动效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Slide</button>
    <transition name="slide">
      <div v-if="show" class="content">Slide Effect Content</div>
    </transition>
  </div>
</template>

<style>
.slide-enter-active,
.slide-leave-active {
  transition: all 0.3s ease;
  max-height: 200px;
  overflow: hidden;
}

.slide-enter-from,
.slide-leave-to {
  max-height: 0;
  opacity: 0;
  transform: translateY(-20px);
}
</style>

3. 弹跳效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Bounce</button>
    <transition name="bounce">
      <div v-if="show" class="content">Bounce Effect Content</div>
    </transition>
  </div>
</template>

<style>
.bounce-enter-active {
  animation: bounce-in 0.5s;
}

.bounce-leave-active {
  animation: bounce-in 0.5s reverse;
}

@keyframes bounce-in {
  0% {
    transform: scale(0);
    opacity: 0;
  }
  50% {
    transform: scale(1.2);
  }
  100% {
    transform: scale(1);
    opacity: 1;
  }
}
</style>

4. 翻转效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Flip</button>
    <transition name="flip">
      <div v-if="show" class="content flip-content">Flip Effect Content</div>
    </transition>
  </div>
</template>

<style>
.flip-enter-active {
  animation: flip-in 0.6s ease forwards;
}

.flip-leave-active {
  animation: flip-out 0.6s ease forwards;
}

@keyframes flip-in {
  0% {
    transform: perspective(400px) rotateY(90deg);
    opacity: 0;
  }
  40% {
    transform: perspective(400px) rotateY(-10deg);
  }
  70% {
    transform: perspective(400px) rotateY(10deg);
  }
  100% {
    transform: perspective(400px) rotateY(0deg);
    opacity: 1;
  }
}

@keyframes flip-out {
  0% {
    transform: perspective(400px) rotateY(0deg);
    opacity: 1;
  }
  100% {
    transform: perspective(400px) rotateY(90deg);
    opacity: 0;
  }
}
</style>

TransitionGroup 组件详解

基本列表动画

<transition-group> 用于为列表中的元素添加进入/离开过渡效果:

<template>
  <div class="list-demo">
    <button @click="addItem">添加项目</button>
    <button @click="removeItem">删除项目</button>
  
    <transition-group name="list" tag="ul">
      <li v-for="item in items" :key="item.id" class="list-item">
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const items = reactive([
  { id: 1, text: '项目 1' },
  { id: 2, text: '项目 2' },
  { id: 3, text: '项目 3' }
])

let nextId = 4

const addItem = () => {
  const index = Math.floor(Math.random() * (items.length + 1))
  items.splice(index, 0, {
    id: nextId++,
    text: `新项目 ${nextId - 1}`
  })
}

const removeItem = () => {
  if (items.length > 0) {
    const index = Math.floor(Math.random() * items.length)
    items.splice(index, 1)
  }
}
</script>

<style>
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

.list-move {
  transition: transform 0.5s ease;
}

.list-item {
  padding: 10px;
  margin: 5px 0;
  background-color: #f0f0f0;
  border-radius: 4px;
}
</style>

列表排序动画

<template>
  <div class="shuffle-demo">
    <button @click="shuffle">随机排序</button>
    <button @click="add">添加</button>
    <button @click="remove">删除</button>
  
    <transition-group name="shuffle" tag="div" class="grid">
      <div 
        v-for="item in items" 
        :key="item.id" 
        class="grid-item"
        @click="removeItem(item)"
      >
        {{ item.number }}
      </div>
    </transition-group>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'

const items = reactive([
  { id: 1, number: 1 },
  { id: 2, number: 2 },
  { id: 3, number: 3 },
  { id: 4, number: 4 },
  { id: 5, number: 5 }
])

const shuffle = () => {
  // Fisher-Yates 洗牌算法
  for (let i = items.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [items[i], items[j]] = [items[j], items[i]]
  }
}

const add = () => {
  const newNumber = items.length > 0 ? Math.max(...items.map(i => i.number)) + 1 : 1
  items.push({
    id: Date.now(),
    number: newNumber
  })
}

const remove = () => {
  if (items.length > 0) {
    items.pop()
  }
}

const removeItem = (item) => {
  const index = items.indexOf(item)
  if (index > -1) {
    items.splice(index, 1)
  }
}
</script>

<style>
.grid {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 20px;
}

.grid-item {
  width: 60px;
  height: 60px;
  background-color: #42b883;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  cursor: pointer;
  font-weight: bold;
  user-select: none;
}

.shuffle-enter-active,
.shuffle-leave-active {
  transition: all 0.5s ease;
}

.shuffle-enter-from {
  opacity: 0;
  transform: scale(0.5);
}

.shuffle-leave-to {
  opacity: 0;
  transform: scale(0.5);
}

.shuffle-move {
  transition: transform 0.5s ease;
}
</style>

高级动画技巧

1. FLIP 技术实现平滑动画

FLIP (First, Last, Invert, Play) 是一种优化动画性能的技术:

<template>
  <div class="flip-demo">
    <button @click="filterItems">筛选奇数</button>
    <button @click="resetFilter">重置</button>
  
    <transition-group 
      name="flip-list" 
      tag="div" 
      class="flip-container"
      @before-enter="beforeEnter"
      @enter="enter"
      @leave="leave"
    >
      <div 
        v-for="item in filteredItems" 
        :key="item.id" 
        class="flip-item"
      >
        {{ item.value }}
      </div>
    </transition-group>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue'

const items = ref(Array.from({ length: 20 }, (_, i) => ({
  id: i + 1,
  value: i + 1
})))

const filterOdd = ref(false)

const filteredItems = computed(() => {
  return filterOdd.value 
    ? items.value.filter(item => item.value % 2 === 1)
    : items.value
})

const filterItems = () => {
  filterOdd.value = true
}

const resetFilter = () => {
  filterOdd.value = false
}

const positions = new Map()

const beforeEnter = (el) => {
  el.style.opacity = '0'
  el.style.transform = 'scale(0.8)'
}

const enter = (el, done) => {
  // 获取最终位置
  const end = el.getBoundingClientRect()
  const start = positions.get(el)

  if (start) {
    // 计算位置差
    const dx = start.left - end.left
    const dy = start.top - end.top
    const ds = start.width / end.width
  
    // 反向变换
    el.style.transform = `translate(${dx}px, ${dy}px) scale(${ds})`
  
    // 强制重绘
    el.offsetHeight
  
    // 执行动画
    el.style.transition = 'all 0.3s ease'
    el.style.transform = ''
    el.style.opacity = '1'
  
    setTimeout(done, 300)
  } else {
    el.style.transition = 'all 0.3s ease'
    el.style.transform = ''
    el.style.opacity = '1'
    setTimeout(done, 300)
  }
}

const leave = (el, done) => {
  // 记录初始位置
  positions.set(el, el.getBoundingClientRect())
  el.style.position = 'absolute'
  done()
}
</script>

<style>
.flip-container {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
  gap: 10px;
  position: relative;
  min-height: 200px;
}

.flip-item {
  background-color: #3498db;
  color: white;
  padding: 20px;
  text-align: center;
  border-radius: 8px;
  font-weight: bold;
}

.flip-list-enter-active,
.flip-list-leave-active {
  transition: all 0.3s ease;
}

.flip-list-enter-from,
.flip-list-leave-to {
  opacity: 0;
  transform: translateY(30px);
}

.flip-list-move {
  transition: transform 0.3s ease;
}
</style>

2. 交错动画

<template>
  <div class="stagger-demo">
    <button @click="loadItems">加载项目</button>
    <button @click="clearItems">清空</button>
  
    <transition-group 
      name="staggered-fade" 
      tag="ul" 
      class="staggered-list"
    >
      <li 
        v-for="(item, index) in items" 
        :key="item.id"
        :data-index="index"
        class="staggered-item"
      >
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

<script setup>
import { ref } from 'vue'

const items = ref([])

const loadItems = () => {
  items.value = Array.from({ length: 10 }, (_, i) => ({
    id: Date.now() + i,
    text: `项目 ${i + 1}`
  }))
}

const clearItems = () => {
  items.value = []
}
</script>

<style>
.staggered-list {
  list-style: none;
  padding: 0;
}

.staggered-item {
  padding: 15px;
  margin: 5px 0;
  background-color: #e74c3c;
  color: white;
  border-radius: 6px;
  opacity: 0;
}

/* 进入动画 */
.staggered-fade-enter-active {
  transition: all 0.3s ease;
}

.staggered-fade-enter-from {
  opacity: 0;
  transform: translateX(-30px);
}

/* 离开动画 */
.staggered-fade-leave-active {
  transition: all 0.3s ease;
  position: absolute;
}

.staggered-fade-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

/* 移动动画 */
.staggered-fade-move {
  transition: transform 0.3s ease;
}

/* 交错延迟 */
.staggered-item:nth-child(1) { transition-delay: 0.05s; }
.staggered-item:nth-child(2) { transition-delay: 0.1s; }
.staggered-item:nth-child(3) { transition-delay: 0.15s; }
.staggered-item:nth-child(4) { transition-delay: 0.2s; }
.staggered-item:nth-child(5) { transition-delay: 0.25s; }
.staggered-item:nth-child(6) { transition-delay: 0.3s; }
.staggered-item:nth-child(7) { transition-delay: 0.35s; }
.staggered-item:nth-child(8) { transition-delay: 0.4s; }
.staggered-item:nth-child(9) { transition-delay: 0.45s; }
.staggered-item:nth-child(10) { transition-delay: 0.5s; }
</style>

3. 页面切换动画

<!-- App.vue -->
<template>
  <div id="app">
    <nav>
      <router-link to="/">首页</router-link>
      <router-link to="/about">关于</router-link>
      <router-link to="/contact">联系</router-link>
    </nav>
  
    <router-view v-slot="{ Component }">
      <transition name="page" mode="out-in">
        <component :is="Component" />
      </transition>
    </router-view>
  </div>
</template>

<style>
.page-enter-active,
.page-leave-active {
  transition: all 0.3s ease;
  position: absolute;
  top: 60px;
  left: 0;
  right: 0;
}

.page-enter-from {
  opacity: 0;
  transform: translateX(30px);
}

.page-leave-to {
  opacity: 0;
  transform: translateX(-30px);
}

nav {
  padding: 20px;
  background-color: #f8f9fa;
}

nav a {
  margin-right: 20px;
  text-decoration: none;
  color: #333;
}

nav a.router-link-active {
  color: #42b883;
  font-weight: bold;
}
</style>

性能优化建议

1. 使用 transform 和 opacity

优先使用 transformopacity 属性,因为它们不会触发重排:

/* 推荐 */
.good-animation {
  transition: transform 0.3s ease, opacity 0.3s ease;
}

/* 避免 */
.bad-animation {
  transition: left 0.3s ease, top 0.3s ease;
}

2. 合理使用 will-change

对于复杂的动画,可以提前告知浏览器优化:

.animated-element {
  will-change: transform, opacity;
}

3. 避免阻塞主线程

对于复杂动画,考虑使用 Web Workers 或 requestAnimationFrame:

const animateElement = (element, duration) => {
  const startTime = performance.now()

  const animate = (currentTime) => {
    const elapsed = currentTime - startTime
    const progress = Math.min(elapsed / duration, 1)
  
    // 更新元素样式
    element.style.transform = `translateX(${progress * 100}px)`
  
    if (progress < 1) {
      requestAnimationFrame(animate)
    }
  }

  requestAnimationFrame(animate)
}

结语

Vue 3 的过渡和动画系统为我们提供了强大而灵活的工具来创建丰富的用户界面体验。通过合理运用 <transition><transition-group> 组件,结合 CSS3 动画和 JavaScript 控制,我们能够实现从简单到复杂的各种动画效果。

关键要点总结:

  1. 理解过渡类名机制:掌握6个核心类名的作用时机
  2. 善用 JavaScript 钩子:实现更复杂的自定义动画逻辑
  3. 列表动画的重要性:使用 <transition-group> 处理动态列表
  4. 性能优化意识:选择合适的 CSS 属性和动画技术
  5. 用户体验考量:动画应该增强而不是阻碍用户操作

在实际项目中,建议根据具体需求选择合适的动画方案,并始终考虑性能影响。适度的动画能够显著提升用户体验,但过度或不当的动画反而会适得其反。希望本文能够帮助你在 Vue 3 项目中更好地实现和控制动画效果。

❌
❌