普通视图

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

Day01-API

2025年12月14日 14:26

1.变量声明

1.优先选择const

  1. 建议数组和对象都用const来声明
image.png

1.1 引用数据类型修改仍可用const

只要地址不修改,它也不会报错

<script>
        1.数组即使追加也可以定义成const
        因为数组地址没变
        const arr = ['莎莎','vv']
        arr.push('鑫鑫')
        console.log(arr);
        下面这样会报错,因为这样子是开辟了一个新地址,并且赋给了arr
        arr = [1,2,3]
        console.log(arr);       
    </script>

2.API作用与分类

image.png

3.什么是DOM

Document Object Model----文档对象模型

作用:通过js操作网页内容,实现用户放纵

image.png

4.DOM树

document是DOM提供的一个对象,网页中所有内容都在document里面 image.png

5.DOM对象(重要)

html的标签 js获取后就变成了对象

核心:把内容当对象处理

6.获取DOM对象

1.根据CSS选择器来获取DOM元素(重点)

2.其他获取DOM元素的方法(了解)

6.1 利用css选择器来获取

image.pngimage.pngimage.png
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .box {
            width: 200px;
            height: 200px;
        }
        #nav {
            background-color: pink;
        }
    </style>
</head>
<body>
    <div class="box">123</div>
    <div class="box">456</div>
    <p id="nav">导航栏</p>
    <ul>
        <li>啦啦啦1</li>
        <li>啦啦啦2</li>
        <li>啦啦啦3</li>
    </ul>
    <script>
    // 1. 获取匹配的第一个元素
    const box1 = document.querySelector('div')
    console.log(box1)
    const box2 = document.querySelector('box')
    console.log(box2)
    // id选择器一定要加#号
    const nav = document.querySelector('#nav')
    nav.style.background = 'green'
    console.log(nav);
    // 获取第一个li
    const li = document.querySelector('ul li:first-child')
    console.log(li);

    //2.选择所有的小li
    const lis = document.querySelectorAll('ul li')
    console.log(lis);
    </script>
</body>
</html>  
  • 遍历得到的伪数组
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .box {
            width: 200px;
            height: 200px;
        }
        #nav {
            background-color: pink;
        }
    </style>
</head>
<body>
    <div class="box">123</div>
    <div class="box">456</div>
    <p id="nav">导航栏</p>
    <ul class="nav">
        <li>啦啦啦1</li>
        <li>啦啦啦2</li>
        <li>啦啦啦3</li>
    </ul>

    <script>
        // 遍历这个伪数组
        const lis = document.querySelectorAll('.nav li')
        for(let i = 0; i < lis.length; i++){
            console.log(lis[i])
        }
    </script>
</body>
</html>

6.2 其他方法

image.png

7.操作元素的内容

image.png

7.1 对象.innerText属性

image.png
    <script>
        // 1.获取元素
        const box = document.querySelector('.box')
        // 2.修改文字内容
        console.log(box.innerText)
        box.innerText = '我是莎莎'
        console.log(box.innerText);
    </script>

7.2 对象.innerHTML属性

image.png

7.3 年会抽奖案例

<script>
    // 1.声明数组
    const arr = ['莎莎','vv','鑫鑫','大伟','坤哥']
    // 2.随机生成一个数字
    for(let i = 0; i < 3; i++){
        let random = Math.floor(Math.random()*arr.length)
        // 获取这个元素并修改
        if(i === 0){
            const span = document.querySelector('.wrapper #one')
            span.innerText = arr[random]
        }else if(i=== 1){
            const span = document.querySelector('.wrapper #two')
            span.innerText = arr[random]
        }else{
            const span = document.querySelector('.wrapper #three')
            span.innerText = arr[random]
        }
        arr.splice(random,1)
        
    }
  </script>

8.操作元素属性

8.1 操作元素常用属性href、src、title

image.png

8.1.1 随机刷新图片案列

<body>
    <img src="./images/1.webp" alt="">
    <script>
        function getRandom(min, max) {
            // 先处理边界:如果min > max,交换两者
            if (min > max) [min, max] = [max, min];
            // 核心公式:Math.floor(Math.random() * (max - min + 1)) + min
            return Math.floor(Math.random() * (max - min + 1)) + min;
        }
        // 1.获取图片对象
        const img = document.querySelector('img')  
        // 2.修改图片的src属性
        const random = getRandom(1,6)

        img.src = `./images/${random}.webp`
        img.title = '这就是你啊' 

    </script>
</body>

8.2 操作元素样式属性

8.2.1 通过style修改

image.png
  1. body的样式就不需要获取了,可以直接使用,因为body是唯一的

2.css中遇到bckground-image这种,用小驼峰解决,写成backgroundImage

<body>
    <div class="box"></div>
    <script>
        // 1.获取元素
        const box = document.querySelector('.box')
        // 2.修改样式属性,别忘了加单位
        box.style.width = '300px'
        // 遇到css总-的命名方式,用小驼峰命名法解决
        box.style.backgroundColor = 'blue'
        // 加边框
        box.style.border = '2px solid red'
        box.style.borderTop = '5px solid pink'
    </script>
</body>
<script>
        // 因为body是唯一的,所以不需要获取
        function getRandom(min, max) {
            // 先处理边界:如果min > max,交换两者
            if (min > max) [min, max] = [max, min];
            // 核心公式:Math.floor(Math.random() * (max - min + 1)) + min
            return Math.floor(Math.random() * (max - min + 1)) + min;
        }
        const random = getRandom(1,10)
        document.body.style.backgroundImage = `url(./images/desktop_${random}.jpg)`
        
    </script>

8.2.2 通过className来修改

好处:简洁 image.png

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        div {
            height: 200px;
            width: 200px;
            background-color: pink;
        }
        .nav {
            color: red;
        }

        .box {
            width: 300px;
            height: 300px;
            background-color: skyblue;
            margin: 20px auto;
            padding: 10px;
            border: 1px solid #000;
        }
    </style>
</head>
<body>
    <div class="nav">可爱莎莎</div>
    <script>
        // 1.获取元素
        const div = document.querySelector('div')
        // 2.添加类名,并且会覆盖前面的类型
        div.className = 'box'
        // 3.如果想保留之前的类名,可以使用下面的方法
        div.className = 'nav box'
    </script>
</body>
</html>

8.2.3 通过classList操作类控制css

这个是用的最多的 image.png

8.2.4 随机切换轮播图

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

<head>
  <meta charset="UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>轮播图点击切换</title>
  <style>
    * {
      box-sizing: border-box;
    }

    .slider {
      width: 560px;
      height: 400px;
      overflow: hidden;
    }

    .slider-wrapper {
      width: 100%;
      height: 320px;
    }

    .slider-wrapper img {
      width: 100%;
      height: 100%;
      display: block;
    }

    .slider-footer {
      height: 80px;
      background-color: rgb(100, 67, 68);
      padding: 12px 12px 0 12px;
      position: relative;
    }

    .slider-footer .toggle {
      position: absolute;
      right: 0;
      top: 12px;
      display: flex;
    }

    .slider-footer .toggle button {
      margin-right: 12px;
      width: 28px;
      height: 28px;
      appearance: none;
      border: none;
      background: rgba(255, 255, 255, 0.1);
      color: #fff;
      border-radius: 4px;
      cursor: pointer;
    }

    .slider-footer .toggle button:hover {
      background: rgba(255, 255, 255, 0.2);
    }

    .slider-footer p {
      margin: 0;
      color: #fff;
      font-size: 18px;
      margin-bottom: 10px;
    }

    .slider-indicator {
      margin: 0;
      padding: 0;
      list-style: none;
      display: flex;
      align-items: center;
    }

    .slider-indicator li {
      width: 8px;
      height: 8px;
      margin: 4px;
      border-radius: 50%;
      background: #fff;
      opacity: 0.4;
      cursor: pointer;
    }

    .slider-indicator li.active {
      width: 12px;
      height: 12px;
      opacity: 1;
    }
  </style>
</head>

<body>
  <div class="slider">
    <div class="slider-wrapper">
      <img src="./images/slider01.jpg" alt="" />
    </div>
    <div class="slider-footer">
      <p>对人类来说会不会太超前了?</p>
      <ul class="slider-indicator">
        <li></li>
        <li></li>
        <li></li>
        <li></li>
        <li></li>
        <li></li>
        <li></li>
        <li></li>
      </ul>
      <div class="toggle">
        <button class="prev">&lt;</button>
        <button class="next">&gt;</button>
      </div>
    </div>
  </div>
  <script>
    // 1. 初始数据,这是一个数组对象
    const sliderData = [
      { url: './images/slider01.jpg', title: '对人类来说会不会太超前了?', color: 'rgb(100, 67, 68)' },
      { url: './images/slider02.jpg', title: '开启剑与雪的黑暗传说!', color: 'rgb(43, 35, 26)' },
      { url: './images/slider03.jpg', title: '真正的jo厨出现了!', color: 'rgb(36, 31, 33)' },
      { url: './images/slider04.jpg', title: '李玉刚:让世界通过B站看到东方大国文化', color: 'rgb(139, 98, 66)' },
      { url: './images/slider05.jpg', title: '快来分享你的寒假日常吧~', color: 'rgb(67, 90, 92)' },
      { url: './images/slider06.jpg', title: '哔哩哔哩小年YEAH', color: 'rgb(166, 131, 143)' },
      { url: './images/slider07.jpg', title: '一站式解决你的电脑配置问题!!!', color: 'rgb(53, 29, 25)' },
      { url: './images/slider08.jpg', title: '谁不想和小猫咪贴贴呢!', color: 'rgb(99, 72, 114)' },
    ]
    // 2.需要一个随机数
    const random = Math.floor(Math.random() * sliderData.length)
    // 3.获取图片
    const img = document.querySelector('.slider-wrapper img')
    // 4.修改图片路径
    img.src = sliderData[random].url
    // 5.获取文字
    const text = document.querySelector('.slider-footer p')
    // 6.修改文字内容
    text.innerHTML = sliderData[random].title
    // 7.修改底部颜色,括号里面要写css选择器
    const footer = document.querySelector('.slider-footer')
    footer.style.backgroundColor = sliderData[random].color

    // 8.修改底部小圆点高亮特效
    const li = document.querySelector(`.slider-indicator li:nth-child(${random+1})`)
    li.classList.add('active')
  </script>
</body>

</html> 

8.3 操作表单元素属性

image.png

GIS 数据转换:GDAL 实现将 CSV 转换为 Shp 数据(一)

作者 GIS之路
2025年12月14日 14:24

前言

CSV 作为一种以逗号分隔符存储文本数据格式,可以很方便的存储点数据。在 GIS 开发中,经常需要进行数据的转换处理,其中常见的便是将 CSV 转换为 Shp数据进行展示。

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

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

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

1. 开发环境

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

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 导入依赖

CSV作为一种矢量数据格式,可以使用矢量库OGR进行处理,以实现CSV数据从文本格式转换为Shp格式。其中还涉及坐标定义,所以还需要引入osr模块。

from osgeo import ogr,osr
import os
import csv

3. 创建字符编码文件

定义一个方法CreateCpgFile2Encode用于创建Shapefile数据字符编码文件。由于属性数据在读取、写入过程中经常会出现中文乱码问题,所以创建一个.cpg文件用于指定字符编码。

"""
说明:创建.cpg文件指定字符编码
参数:
    -shpPath:Shp文件路径
    -encoding:Shp文件字符编码
"""
def CreateCpgFile2Encode(shpPath,encoding):
    fileName = os.path.splitext(shpPath)[0]
    cpgFile = fileName + ".cpg"

    with open(cpgFile,"w",encoding=encoding) as f:
        f.write(encoding)
        print(f"成功创建编码文件: {cpgFile}")

4. 数据转换

定义一个方法Csv2Shp(csvPath,shpPath,lonField="longitude",latField="latitude",encoding="UTF-8")用于将CSV数据转换为Shp数据。


"""
说明:将CSV文件转换为Shapfile文件
参数:
    -csvPath:CSV文件路径
    -shpPath:Shp文件路径
    -lonField:经度字段
    -latField:纬度字段
    -encoding:CSV 文件编码
"""
def Csv2Shp(csvPath,shpPath,lonField="longitude",latField="latitude",encoding="UTF-8"):

在进行CSV数据格式转换之前,需要检查数据路径是否存在。

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

通过GetDriverByName获取Shp数据驱动,并使用os.path.exists方法检查Shp文件是否已经创建,如果存在则将其删除。

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

# 添加Shp数据源
shpDriver = ogr.GetDriverByName('ESRI Shapefile')

if os.path.exists(shpPath):
    try:
        shpDriver.DeleteDataSource(shpPath)
        print("文件已删除!")
    except Exception as e:
        print(f"文件删除出错:{e}")
        return False

接着创建Shp数据源和空间参考,数据坐标系这里定义为4326。

# 创建Shp数据源
shpDataSource = shpDriver.CreateDataSource(shpPath)
if shpDataSource is None:
    print("无法创建Shp数据源,请检查文件!")
    return false
# 创建空间参考
srs = osr.SpatialReference()
srs.ImportFromEPSG(4326)

然后使用CreateLayer方法创建一个点图层,读取CSV行数据,并且将属性字段名称、属性字段值以及几何对象写入Shapefile文件,在数据读取完成之后调用CreateCpgFile2Encode方法创建字符编码文件。

# 创建点图层
layer = shpDataSource.CreateLayer("points",srs,ogr.wkbPoint)

# 读取 CSV 并创建字段
try:
    with open(csvPath,"r",encoding=encoding) as CsvFile:
        reader = csv.DictReader(CsvFile)

        fieldnames  = reader.fieldnames 

        # 创建属性字段
        fieldObj = {}

        for field in fieldnames:
            if field not in [lonField, latField]:
                # 创建字段定义
                fieldDefn = ogr.FieldDefn(field, ogr.OFTString)
                fieldDefn.SetWidth(254)
                # 直接创建字段,不要存储 FieldDefn 对象
                layer.CreateField(fieldDefn)

        CsvFile.seek(0)
        # 跳过标题行,重新开始读取
        next(reader)

        # 添加要素
        featureCount = 0
        for row in reader:
            try:
                lon = float(row[lonField])
                lat = float(row[latField])
            except (ValueError,KeyError):
                continue

            # 创建要素
            feature = ogr.Feature(layer.GetLayerDefn())

            # 设置属性
            for field in fieldnames:
                if field not in [lonField, latField]:
                    feature.SetField(field, str(row[field]))

            # 创建几何
            point = ogr.Geometry(ogr.wkbPoint)
            point.AddPoint(lon,lat)
            feature.SetGeometry(point)

            # 保存要素
            layer.CreateFeature(feature)
            feature = None
            featureCount += 1
        print(f"成功转换【{featureCount}】个要素")
except Exception as e:
    print(f"转换过程出错:{e}")
    return False
finally:
    shpDataSource = None
    CreateCpgFile2Encode(shpPath,"UTF-8")

return True

程序执行成功显示如下:

5. 完整代码

注:请将数据路径替换为自己的实际路径,并更换目标字符编码

from osgeo import ogr,osr
import os
import csv

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

# 设置Shapefile的编码为GBK
# os.environ['SHAPE_ENCODING'] = "UTF-8"
os.environ['SHAPE_ENCODING'] = "ISO-8859-1"

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

"""
说明:将CSV文件转换为Shapfile文件
参数:
    -csvPath:CSV文件路径
    -shpPath:Shp文件路径
    -lonField:经度字段
    -latField:纬度字段
    -encoding:CSV 文件编码
"""
def Csv2Shp(csvPath,shpPath,lonField="longitude",latField="latitude",encoding="UTF-8"):
    # 检查文件是否存在
    if os.path.exists(csvPath):
        print("CSV 文件存在。")
    else:
        print("CSV 文件不存在,请重新选择文件!")
        return

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

    # 添加Shp数据源
    shpDriver = ogr.GetDriverByName('ESRI Shapefile')
    if os.path.exists(shpPath):
        try:
            shpDriver.DeleteDataSource(shpPath)
            print("文件已删除!")
        except Exception as e:
            print(f"文件删除出错:{e}")
            return False

    # 创建Shp数据源
    shpDataSource = shpDriver.CreateDataSource(shpPath)
    if shpDataSource is None:
        print("无法创建Shp数据源,请检查文件!")
        return false

    # 创建空间参考
    srs = osr.SpatialReference()
    srs.ImportFromEPSG(4326)

    # 创建点图层
    layer = shpDataSource.CreateLayer("points",srs,ogr.wkbPoint)

    # 读取 CSV 并创建字段
    try:
        with open(csvPath,"r",encoding=encoding) as CsvFile:
            reader = csv.DictReader(CsvFile)

            fieldnames  = reader.fieldnames 

            # 创建属性字段
            fieldObj = {}

            for field in fieldnames:
                if field not in [lonField, latField]:
                    # 创建字段定义
                    fieldDefn = ogr.FieldDefn(field, ogr.OFTString)
                    fieldDefn.SetWidth(254)
                    # 直接创建字段,不要存储 FieldDefn 对象
                    layer.CreateField(fieldDefn)

            CsvFile.seek(0)
            # 跳过标题行,重新开始读取
            next(reader)

            # 添加要素
            featureCount = 0
            for row in reader:
                try:
                    lon = float(row[lonField])
                    lat = float(row[latField])
                except (ValueError,KeyError):
                    continue

                # 创建要素
                feature = ogr.Feature(layer.GetLayerDefn())

                # 设置属性
                for field in fieldnames:
                    if field not in [lonField, latField]:
                        feature.SetField(field, str(row[field]))
                # 创建几何
                point = ogr.Geometry(ogr.wkbPoint)
                point.AddPoint(lon,lat)
                feature.SetGeometry(point)

                # 保存要素
                layer.CreateFeature(feature)
                feature = None
                featureCount += 1
            print(f"成功转换【{featureCount}】个要素")
    except Exception as e:
        print(f"转换过程出错:{e}")
        return False
    finally:
        shpDataSource = None
        CreateCpgFile2Encode(shpPath,"UTF-8")

    return True

"""
说明:创建.cpg文件指定字符编码
参数:
    -shpPath:Shp文件路径
    -encoding:Shp文件字符编码
"""
def CreateCpgFile2Encode(shpPath,encoding):
    fileName = os.path.splitext(shpPath)[0]
    cpgFile = fileName + ".cpg"

    with open(cpgFile,"w",encoding=encoding) as f:
        f.write(encoding)
        print(f"成功创建编码文件: {cpgFile}")

if __name__  == "__main__":

    csvPath = "E:\data\test_data\景点.csv"
    shpPath = "E:\data\test_data\景点.shp"

    lonField = "LNGQ"
    latField = "LATQ"

    encoding = "ISO-8859-1"
    # encoding = "UTF-8"

    Csv2Shp(csvPath,shpPath,lonField,latField,encoding)

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

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

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

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

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

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

react beginwork

作者 polaris_tl
2025年12月14日 13:22

这是一个非常核心且深入的问题。beginWork 是 React Fiber 架构中最关键的函数之一。

简单来说,beginWork 是 React Render 阶段(协调阶段) 的“入场口”。React 在遍历 Fiber 树时,每到达一个节点,就会调用一次 beginWork

它的主要任务是:根据新的 Props 和 State,计算出当前组件应该呈现什么样子(即新的子节点),并将这个新的结构与旧的结构进行对比(Reconciliation/diff),找出差异,最后返回第一个子节点以便继续向下遍历。

下面我们详细拆解 beginWork 所做的事情,最后我会梳理成一张流程图。


beginWork 的核心职责拆解

beginWork 的工作流程可以大致分为三个主要阶段:

阶段一:性能优化探测(Bailout 策略)

在动手做任何真正的渲染工作之前,React 会先尝试“偷懒”。这是 React 高性能的关键所在。

  1. 检查是否需要更新:

    beginWork 会对比当前节点的旧状态 (current fiber) 和新状态 (workInProgress fiber)。它会检查以下几点:

    • Props 是否改变? (旧 props !== 新 props)
    • Context 是否改变? (对于使用了 Context 的组件)
    • 是否有足够优先级的更新任务? (检查 renderLanes,看当前节点是否有需要在本次渲染中处理的更新)
  2. 尝试复用 (Bailout):

    如果上述检查发现什么都没变,并且当前节点本身没有高优先级的更新任务,React 就会认为这个节点及其子树可能不需要更新。

    • 完全跳过 (Full Bailout): 如果连它的子树也没有任何更新任务 (检查 childLanes),那么 beginWork 会直接返回 null。这意味着:“这条分支到此为止,下面都不用看了”。
    • 浅层跳过 (Shallow Bailout): 如果当前节点不用更新,但是它的子孙节点有更新任务,React 就不能完全停下。它会克隆当前的子节点,然后返回这个子节点,继续向下走,跳过当前组件的重渲染逻辑(比如不会重新执行函数组件体)。

总结:这一阶段的目标是尽量复用旧的 Fiber 节点,避免不必要的计算。

阶段二:根据组件类型执行渲染逻辑(The Switch Statement)

如果无法复用(Bailout 失败),说明当前节点确实需要更新。

beginWork 内部是一个巨大的 switch (workInProgress.tag) 语句。它会根据 Fiber 节点的类型(函数组件、类组件、原生 DOM 节点等),执行不同的处理逻辑。

以下是几种常见类型的处理方式:

  1. FunctionComponent (函数组件):

    • 执行函数体: 调用你写的组件函数,例如 MyComponent(props)
    • 处理 Hooks: 在执行函数体时,useState, useEffect 等 Hooks 会被按顺序执行,计算出最新的 State。
    • 产出结果: 函数的返回值(通常是 JSX 转换成的 React.createElement 调用结果),这就是新的子元素 (New Children Elements)
  2. ClassComponent (类组件):

    • 更新实例: 处理 getDerivedStateFromProps,处理 State 更新队列,更新组件实例的 stateprops
    • 判断是否更新: 调用 shouldComponentUpdate(如果定义了)。
    • 调用 Render: 如果需要更新,调用实例的 render() 方法。
    • 产出结果: render() 方法的返回值,即新的子元素
  3. HostComponent (原生 DOM 节点,如 <div>):

    • 原生节点本身没有业务逻辑运行。
    • 它的主要任务是准备处理它的子节点。React 会查看它的新 children prop。
    • 注意:原生节点的属性 diff(比如 style 变了)并不在 beginWork 做,而是在 completeWork 阶段做。
  4. 其他类型 (Fragment, ContextProvider, Suspense 等):

    • 各自有特定的处理逻辑,但最终目的都是为了确定新的子元素是什么。例如 ContextProvider 会将新的 value 推入 Context 栈。

总结:这一阶段的目标是运行组件代码,拿到组件最新的“图纸”(新的 React Elements)。

阶段三:协调子节点 (Reconciliation / Diffing)

这是 beginWork 最核心的一步,也是“Virtual DOM Diff”算法真正发生的地方。

不管阶段二是什么类型的组件,最终都拿到了一组新的子元素 (New Elements) 。现在的任务是把这些新元素和旧的子 Fiber 节点 (Current Child Fibers) 进行对比。

React 调用 reconcileChildren(current, workInProgress, nextChildren) 函数来完成这项工作:

  1. 对比 (Diffing):

    • 它会遍历新的 Elements 数组,并尝试与旧的 Fiber 链表进行匹配。
    • Key 的作用: 优先使用 key 进行匹配。如果 key 相同且类型相同,就复用旧的 Fiber 节点。
    • 类型对比: 如果 key 不同或者类型不同(比如 <div> 变成了 <span>),则标记旧节点为删除,创建新节点的 Fiber。
  2. 创建新的 WIP Fiber:

    • 根据对比结果,生成新的 Fiber 结构,连接到 workInProgress.child 上。
  3. 标记副作用 (Flags / Side Effects):

    • 在创建新 Fiber 的过程中,如果发现需要进行 DOM 操作,就会在新 Fiber 上打上标记(flags)。
    • 例如:新插入的节点打上 Placement 标记;需要更新属性的节点打上 Update 标记;需要删除的旧节点打上 Deletion 标记,并添加到父节点的副作用列表中。

总结:这一阶段生成了新的 Fiber 子树结构,并找出了新旧之间的差异,打上了标记,为 Commit 阶段的 DOM 操作做好了准备。

函数返回

完成协调后,beginWork 的工作就完成了。它会返回 workInProgress.child(即新生成的第一个子 Fiber 节点)。

React 的工作循环会拿到这个返回值,将指针移动到这个子节点上,然后对它再次调用 beginWork,从而实现深度优先遍历(DFS)。


梳理图 (React beginWork 流程图)

这张图梳理了 beginWork 的决策路径。

代码段

graph TD
    Start["Start beginWork"] --> Input["输入: current Fiber, workInProgress Fiber, renderLanes"]
    Input --> CheckBailout{"1. 性能优化检查<br>Props/Context变了吗?<br>有高优更新吗?"}
    CheckBailout -- 否 --> CheckChildLanes{"检查子树 Lanes<br>子孙有更新吗?"}
    CheckChildLanes -- 无 --> BailoutEnd["全量 Bailout<br>返回 null, 停止分支遍历"]
    CheckChildLanes -- 有 --> CloneChild["浅层 Bailout<br>克隆子节点 Fiber"]
    CloneChild --> ReturnChild["结束<br>返回 workInProgress.child<br>继续向下遍历"]
    CheckBailout -- 是 --> SwitchTag{"2. 根据 Tag 处理<br>switch WIP.tag"}
    SwitchTag -- FunctionComp --> ExecFC["执行函数组件体 <br>运行 Hooks useState等"]
    ExecFC --> GetChildrenFC["得到新的 Children Elements"]
    SwitchTag -- ClassComp --> UpdateClass["更新 Class 实例<br>处理 State, 调用 render"]
    UpdateClass --> GetChildrenCC["得到新的 Children Elements"]
    SwitchTag -- HostComp --> ProcessHost["处理原生节点<br>如 div, span"]
    ProcessHost --> GetChildrenHost["从 props 获取 Children Elements"]
    SwitchTag -- Other --> ProcessOther["处理 Context Fragment 等"]
    ProcessOther --> GetChildrenOther["得到新的 Children Elements"]
    GetChildrenFC --> Reconcile["3. 协调子节点 Diffing<br>调用 reconcileChildren"]
    GetChildrenCC --> Reconcile
    GetChildrenHost --> Reconcile
    GetChildrenOther --> Reconcile
    Reconcile --> Diffing["对比旧子Fibers 和 新子Elements"]
    Diffing --> CreateWIP["创建/复用 Fiber<br>构建新的 WIP 子 Fiber 树"]
    CreateWIP --> MarkFlags["标记副作用 Flags<br>Placement, Update, Deletion"]
    MarkFlags --> ReturnChild

    style Start fill:#f9f,stroke:#333,stroke-width:2px
    style BailoutEnd fill:#eee,stroke:#333,stroke-dasharray: 5 5
    style ReturnChild fill:#cce5ff,stroke:#004085,stroke-width:2px
    style Reconcile fill:#d4edda,stroke:#28a745,stroke-width:2px

Vue项目Axios封装全攻略:从零到一打造优雅的HTTP请求层

作者 北辰alk
2025年12月14日 12:51

今天我们来聊聊Vue项目中一个看似基础却至关重要的技能——Axios封装。相信不少前端开发者都有过这样的困惑:为什么我的接口请求代码总是重复?为什么错误处理如此混乱?如何统一管理API地址?

别担心,读完这篇文章,你将彻底掌握Axios封装的精髓,打造出属于你自己的优雅HTTP请求层!

为什么要封装Axios?

在开始之前,我们先思考一个问题:为什么要封装Axios?

不封装的痛苦:

  • • 每个请求都要重复写axios.get()axios.post()
  • • 每个请求都要单独处理错误
  • • 切换环境时,需要手动修改大量baseURL
  • • 缺乏统一的loading处理
  • • 难以管理接口和统一添加认证信息

封装的好处:

  • • 统一管理API地址和接口
  • • 统一处理错误和loading
  • • 减少重复代码,提高开发效率
  • • 方便维护和更新
  • • 增强代码的可读性和可维护性

封装设计思路

我们先来看一下整体设计思路:

添加token

显示loading

序列化数据

处理错误

隐藏loading

转换数据

发起HTTP请求请求拦截器拦截器处理添加认证信息显示加载状态数据处理发送请求接收响应响应拦截器拦截器处理统一错误处理隐藏加载状态数据转换返回Promise

第一步:基础封装

1. 安装依赖

首先,确保已经安装了axios:

npm install axios
# 或
yarn add axios

2. 创建Axios实例

src/utils/request.js中创建基础的axios实例:

import axios from 'axios'
import { Message } from 'element-ui' // 以Element UI为例,可根据项目使用UI库调整

// 创建axios实例
const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API// 从环境变量读取API地址
  timeout10000// 请求超时时间
  headers: {
    'Content-Type''application/json;charset=utf-8'
  }
})

// 请求拦截器
service.interceptors.request.use(
  config => {
    // 在发送请求之前做些什么
    // 1. 添加token
    const token = localStorage.getItem('token')
    if (token) {
      config.headers['Authorization'] = `Bearer ${token}`
    }
    
    // 2. 显示loading(如果需要)
    if (config.showLoading) {
      // 显示loading组件
      showLoading()
    }
    
    return config
  },
  error => {
    // 对请求错误做些什么
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(
  response => {
    // 对响应数据做点什么
    // 隐藏loading
    if (response.config.showLoading) {
      hideLoading()
    }
    
    const res = response.data
    
    // 假设后端返回的数据格式为 { code: 200, data: {}, message: 'success' }
    if (res.code !== 200) {
      // 处理业务错误
      Message.error(res.message || '请求失败')
      return Promise.reject(new Error(res.message || 'Error'))
    } else {
      return res.data
    }
  },
  error => {
    // 对响应错误做点什么
    // 隐藏loading
    if (error.config && error.config.showLoading) {
      hideLoading()
    }
    
    // 处理HTTP错误
    handleHttpError(error)
    
    return Promise.reject(error)
  }
)

// HTTP错误处理函数
function handleHttpError(error) {
  if (error.response) {
    // 请求成功发出且服务器也响应了状态码,但状态码超出了 2xx 的范围
    switch (error.response.status) {
      case 400:
        Message.error('请求错误')
        break
      case 401:
        Message.error('未授权,请重新登录')
        // 跳转到登录页
        router.push('/login')
        break
      case 403:
        Message.error('拒绝访问')
        break
      case 404:
        Message.error('请求的资源不存在')
        break
      case 500:
        Message.error('服务器内部错误')
        break
      case 502:
        Message.error('网关错误')
        break
      case 503:
        Message.error('服务不可用')
        break
      case 504:
        Message.error('网关超时')
        break
      default:
        Message.error(`请求失败: ${error.response.status}`)
    }
  } else if (error.request) {
    // 请求已经成功发起,但没有收到响应
    Message.error('网络异常,请检查网络连接')
  } else {
    // 发送请求时出了点问题
    Message.error('请求失败,请稍后重试')
  }
}

// loading计数器
let loadingCount = 0

// 显示loading
function showLoading() {
  if (loadingCount === 0) {
    // 这里根据项目使用的UI库来显示loading
    // Element UI示例
    // Loading.service({ fullscreen: true })
  }
  loadingCount++
}

// 隐藏loading
function hideLoading() {
  loadingCount--
  if (loadingCount <= 0) {
    // 关闭loading
    // Loading实例的close方法
  }
}

export default service

第二步:高级封装 - 创建API管理层

1. 创建API管理文件

src/api目录下创建接口管理文件:

// src/api/index.js
import request from '@/utils/request'

// 用户相关API
export const userApi = {
  // 登录
  login(data) {
    return request({
      url'/user/login',
      method'post',
      data,
      showLoadingtrue // 可配置是否显示loading
    })
  },
  
  // 获取用户信息
  getUserInfo(params) {
    return request({
      url'/user/info',
      method'get',
      params
    })
  },
  
  // 退出登录
  logout() {
    return request({
      url'/user/logout',
      method'post'
    })
  }
}

// 商品相关API
export const productApi = {
  // 获取商品列表
  getProductList(params) {
    return request({
      url'/product/list',
      method'get',
      params
    })
  },
  
  // 获取商品详情
  getProductDetail(id) {
    return request({
      url`/product/detail/${id}`,
      method'get'
    })
  },
  
  // 创建商品
  createProduct(data) {
    return request({
      url'/product/create',
      method'post',
      data
    })
  },
  
  // 更新商品
  updateProduct(id, data) {
    return request({
      url`/product/update/${id}`,
      method'put',
      data
    })
  },
  
  // 删除商品
  deleteProduct(id) {
    return request({
      url`/product/delete/${id}`,
      method'delete'
    })
  }
}

// 订单相关API
export const orderApi = {
  // 获取订单列表
  getOrderList(params) {
    return request({
      url'/order/list',
      method'get',
      params
    })
  },
  
  // 创建订单
  createOrder(data) {
    return request({
      url'/order/create',
      method'post',
      data,
      showLoadingtrue
    })
  }
}

2. 在Vue组件中使用

<template>
  <div>
    <el-button @click="getUserInfo">获取用户信息</el-button>
    <el-button @click="getProductList">获取商品列表</el-button>
  </div>
</template>

<script>
import { userApi, productApi } from '@/api'

export default {
  methods: {
    async getUserInfo() {
      try {
        const userInfo = await userApi.getUserInfo({ userId123 })
        console.log('用户信息:', userInfo)
        this.$message.success('获取用户信息成功')
      } catch (error) {
        console.error('获取用户信息失败:', error)
      }
    },
    
    async getProductList() {
      try {
        const params = {
          page1,
          pageSize10,
          category'electronics'
        }
        const productList = await productApi.getProductList(params)
        console.log('商品列表:', productList)
        this.$message.success('获取商品列表成功')
      } catch (error) {
        console.error('获取商品列表失败:', error)
      }
    }
  }
}
</script>

第三步:进阶功能 - 添加更多特性

1. 请求重试机制

// 在request.js中添加重试机制
const RETRY_COUNT = 3 // 重试次数
const RETRY_DELAY = 1000 // 重试延迟

// 重试请求函数
async function retryRequest(config, retryCount = 0) {
  try {
    return await service(config)
  } catch (error) {
    if (shouldRetry(error) && retryCount < RETRY_COUNT) {
      // 延迟后重试
      await new Promise(resolve => setTimeout(resolve, RETRY_DELAY))
      return retryRequest(config, retryCount + 1)
    }
    throw error
  }
}

// 判断是否需要重试
function shouldRetry(error) {
  // 只在网络错误或特定状态码时重试
  return !error.response || 
         error.code === 'ECONNABORTED' || 
         error.response.status >= 500
}

// 修改原始的request函数
export default function request(config) {
  if (config.retry) {
    return retryRequest(config)
  }
  return service(config)
}

2. 请求缓存

// 请求缓存工具
class RequestCache {
  constructor() {
    this.cache = new Map()
    this.maxSize = 100 // 最大缓存数量
  }
  
  // 生成缓存key
  generateKey(config) {
    const { method, url, params, data } = config
    return JSON.stringify({ method, url, params, data })
  }
  
  // 获取缓存
  get(config) {
    const key = this.generateKey(config)
    const cached = this.cache.get(key)
    
    if (!cached) return null
    
    // 检查缓存是否过期
    if (Date.now() > cached.expire) {
      this.cache.delete(key)
      return null
    }
    
    return cached.data
  }
  
  // 设置缓存
  set(config, data, cacheTime = 5 * 60 * 1000) { // 默认5分钟
    const key = this.generateKey(config)
    
    // 清理最旧的缓存如果达到最大限制
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value
      this.cache.delete(firstKey)
    }
    
    this.cache.set(key, {
      data,
      expire: Date.now() + cacheTime
    })
  }
  
  // 清除缓存
  clear() {
    this.cache.clear()
  }
  
  // 删除特定缓存
  delete(config) {
    const key = this.generateKey(config)
    this.cache.delete(key)
  }
}

const requestCache = new RequestCache()

// 在request函数中添加缓存逻辑
export default function request(config) {
  const {
    useCache = false,
    cacheTime = 5 * 60 * 1000,
    ...requestConfig
  } = config
  
  // 如果启用缓存且是GET请求,先检查缓存
  if (useCache && requestConfig.method?.toLowerCase() === 'get') {
    const cachedData = requestCache.get(requestConfig)
    if (cachedData) {
      return Promise.resolve(cachedData)
    }
  }
  
  return service(requestConfig).then(response => {
    // 缓存响应数据
    if (useCache && requestConfig.method?.toLowerCase() === 'get') {
      requestCache.set(requestConfig, response, cacheTime)
    }
    return response
  })
}

3. 并发请求控制

// 并发请求控制器
class RequestController {
  constructor(maxConcurrent = 5) {
    this.maxConcurrent = maxConcurrent
    this.queue = []
    this.activeCount = 0
  }
  
  // 添加请求到队列
  async add(requestFn) {
    return new Promise((resolve, reject) => {
      this.queue.push({ requestFn, resolve, reject })
      this.next()
    })
  }
  
  // 执行下一个请求
  next() {
    if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
      return
    }
    
    this.activeCount++
    const { requestFn, resolve, reject } = this.queue.shift()
    
    Promise.resolve(requestFn())
      .then(resolve)
      .catch(reject)
      .finally(() => {
        this.activeCount--
        this.next()
      })
  }
  
  // 清空队列
  clear() {
    this.queue = []
  }
}

// 创建全局请求控制器
const requestController = new RequestController(5)

// 支持并发控制的request函数
export function controlledRequest(config) {
  return requestController.add(() => request(config))
}

第四步:完整封装示例

下面是完整的、生产环境可用的Axios封装:

// src/utils/request.js
import axios from 'axios'
import router from '@/router'
import { MessageLoading } from 'element-ui'

// 配置
const config = {
  baseURL: process.env.VUE_APP_BASE_API,
  timeout10000,
  withCredentialstrue// 跨域请求时是否需要使用凭证
  headers: {
    'Content-Type''application/json;charset=utf-8'
  }
}

// 创建axios实例
const service = axios.create(config)

// 请求缓存
class RequestCache {
  constructor(maxSize = 100) {
    this.cache = new Map()
    this.maxSize = maxSize
  }
  
  generateKey(config) {
    const { method, url, params, data } = config
    return `${method}:${url}:${JSON.stringify(params)}:${JSON.stringify(data)}`
  }
  
  get(config) {
    const key = this.generateKey(config)
    const cached = this.cache.get(key)
    
    if (!cached || Date.now() > cached.expire) {
      if (cached) this.cache.delete(key)
      return null
    }
    
    return cached.data
  }
  
  set(config, data, cacheTime = 300000) {
    const key = this.generateKey(config)
    
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value
      this.cache.delete(firstKey)
    }
    
    this.cache.set(key, {
      data,
      expireDate.now() + cacheTime
    })
  }
  
  delete(config) {
    const key = this.generateKey(config)
    this.cache.delete(key)
  }
  
  clear() {
    this.cache.clear()
  }
}

const requestCache = new RequestCache()

// loading控制
let loadingCount = 0
let loadingInstance = null

function showLoading() {
  if (loadingCount === 0) {
    loadingInstance = Loading.service({
      locktrue,
      text'加载中...',
      background'rgba(0, 0, 0, 0.7)'
    })
  }
  loadingCount++
}

function hideLoading() {
  loadingCount--
  if (loadingCount <= 0 && loadingInstance) {
    loadingInstance.close()
    loadingInstance = null
    loadingCount = 0
  }
}

// 错误处理
function handleError(error) {
  // 请求被取消的错误不提示
  if (axios.isCancel(error)) {
    return
  }
  
  let message = '请求失败,请稍后重试'
  
  if (error.response) {
    switch (error.response.status) {
      case 400:
        message = error.response.data?.message || '请求参数错误'
        break
      case 401:
        message = '未授权,请重新登录'
        // 清除token
        localStorage.removeItem('token')
        // 跳转到登录页
        setTimeout(() => {
          router.replace({
            path'/login',
            query: { redirect: router.currentRoute.fullPath }
          })
        }, 1000)
        break
      case 403:
        message = '拒绝访问'
        break
      case 404:
        message = '请求的资源不存在'
        break
      case 408:
        message = '请求超时'
        break
      case 500:
        message = '服务器内部错误'
        break
      case 501:
        message = '服务未实现'
        break
      case 502:
        message = '网关错误'
        break
      case 503:
        message = '服务不可用'
        break
      case 504:
        message = '网关超时'
        break
      case 505:
        message = 'HTTP版本不受支持'
        break
      default:
        message = `连接错误 ${error.response.status}`
    }
  } else if (error.request) {
    message = '网络连接异常,请检查网络设置'
  } else {
    message = error.message || '请求失败'
  }
  
  Message.error(message)
  console.error('请求错误:', error)
  
  return Promise.reject(error)
}

// 请求拦截器
service.interceptors.request.use(
  config => {
    // 添加token
    const token = localStorage.getItem('token') || sessionStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    
    // 显示loading
    if (config.showLoading) {
      showLoading()
    }
    
    // 取消请求配置
    if (config.cancelToken) {
      config.cancelToken = new axios.CancelToken(c => {
        config.cancel = c
      })
    }
    
    return config
  },
  error => {
    // 对请求错误做些什么
    if (error.config?.showLoading) {
      hideLoading()
    }
    return Promise.reject(error)
  }
)

// 响应拦截器
service.interceptors.response.use(
  response => {
    // 隐藏loading
    if (response.config.showLoading) {
      hideLoading()
    }
    
    const res = response.data
    
    // 自定义状态码处理
    if (res.code === undefined) {
      // 如果没有code字段,直接返回数据
      return res
    }
    
    // 根据后端返回的code进行判断
    if (res.code === 200 || res.code === 0) {
      return res.data !== undefined ? res.data : res
    } else {
      // 处理业务错误
      const errorMsg = res.message || '请求失败'
      Message.error(errorMsg)
      
      // 特殊状态码处理
      if (res.code === 401) {
        // token过期,跳转到登录页
        localStorage.removeItem('token')
        router.replace('/login')
      }
      
      return Promise.reject(new Error(errorMsg))
    }
  },
  error => {
    // 隐藏loading
    if (error.config?.showLoading) {
      hideLoading()
    }
    
    // 处理错误
    return handleError(error)
  }
)

// 封装请求方法
const request = {
  /**
   * GET请求
   * @param {string} url 请求地址
   * @param {object} params 请求参数
   * @param {object} options 配置选项
   */
  get(url, params = {}, options = {}) {
    return this.request({
      url,
      method'GET',
      params,
      ...options
    })
  },
  
  /**
   * POST请求
   * @param {string} url 请求地址
   * @param {object} data 请求数据
   * @param {object} options 配置选项
   */
  post(url, data = {}, options = {}) {
    return this.request({
      url,
      method'POST',
      data,
      ...options
    })
  },
  
  /**
   * PUT请求
   * @param {string} url 请求地址
   * @param {object} data 请求数据
   * @param {object} options 配置选项
   */
  put(url, data = {}, options = {}) {
    return this.request({
      url,
      method'PUT',
      data,
      ...options
    })
  },
  
  /**
   * DELETE请求
   * @param {string} url 请求地址
   * @param {object} params 请求参数
   * @param {object} options 配置选项
   */
  delete(url, params = {}, options = {}) {
    return this.request({
      url,
      method'DELETE',
      params,
      ...options
    })
  },
  
  /**
   * 通用请求方法
   * @param {object} config 请求配置
   */
  request(config) {
    const {
      useCache = false,
      cacheTime = 300000,
      showLoading = false,
      ...requestConfig
    } = config
    
    // 缓存处理
    if (useCache && requestConfig.method?.toUpperCase() === 'GET') {
      const cachedData = requestCache.get(requestConfig)
      if (cachedData) {
        return Promise.resolve(cachedData)
      }
    }
    
    // 发送请求
    return service(requestConfig).then(response => {
      // 缓存响应
      if (useCache && requestConfig.method?.toUpperCase() === 'GET') {
        requestCache.set(requestConfig, response, cacheTime)
      }
      return response
    })
  },
  
  /**
   * 上传文件
   * @param {string} url 上传地址
   * @param {FormData} formData 表单数据
   * @param {object} options 配置选项
   */
  upload(url, formData, options = {}) {
    return this.request({
      url,
      method'POST',
      data: formData,
      headers: {
        'Content-Type''multipart/form-data'
      },
      ...options
    })
  },
  
  /**
   * 下载文件
   * @param {string} url 下载地址
   * @param {object} params 请求参数
   * @param {string} filename 文件名
   */
  download(url, params = {}, filename = 'download') {
    return this.request({
      url,
      method'GET',
      params,
      responseType'blob'
    }).then(response => {
      const blob = new Blob([response])
      const downloadUrl = window.URL.createObjectURL(blob)
      const link = document.createElement('a')
      link.href = downloadUrl
      link.download = filename
      document.body.appendChild(link)
      link.click()
      document.body.removeChild(link)
      window.URL.revokeObjectURL(downloadUrl)
    })
  },
  
  /**
   * 清除缓存
   * @param {object} config 可选,清除特定缓存
   */
  clearCache(config = null) {
    if (config) {
      requestCache.delete(config)
    } else {
      requestCache.clear()
    }
  },
  
  /**
   * 创建取消令牌
   */
  createCancelToken() {
    return new axios.CancelToken(c => {
      this.cancel = c
    })
  },
  
  /**
   * 取消请求
   */
  cancelRequest(message = '请求已取消') {
    if (this.cancel) {
      this.cancel(message)
    }
  }
}

// 导出
export default request

第五步:最佳实践和注意事项

1. 环境配置

在项目根目录创建环境配置文件:

// .env.development
VUE_APP_BASE_API = '/api'
VUE_APP_ENV = 'development'

// .env.production
VUE_APP_BASE_API = 'https://api.yourdomain.com'
VUE_APP_ENV = 'production'

2. 代理配置(开发环境)

// vue.config.js
module.exports = {
  devServer: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        pathRewrite: {
          '^/api'''
        }
      }
    }
  }
}

3. TypeScript支持

如果你使用TypeScript,可以添加类型定义:

// src/types/request.d.ts
export interface ResponseData<T = any> {
  codenumber
  data: T
  messagestring
}

export interface RequestConfig {
  urlstring
  method?: string
  data?: any
  params?: any
  headers?: Record<stringstring>
  timeout?: number
  showLoading?: boolean
  useCache?: boolean
  cacheTime?: number
  cancelToken?: any
}

export interface RequestInstance {
  request<T = any>(configRequestConfig): Promise<T>
  get<T = any>(urlstring, params?: any, options?: Partial<RequestConfig>): Promise<T>
  post<T = any>(urlstring, data?: any, options?: Partial<RequestConfig>): Promise<T>
  put<T = any>(urlstring, data?: any, options?: Partial<RequestConfig>): Promise<T>
  delete<T = any>(urlstring, params?: any, options?: Partial<RequestConfig>): Promise<T>
  upload<T = any>(urlstringformDataFormData, options?: Partial<RequestConfig>): Promise<T>
  download(urlstring, params?: any, filename?: string): Promise<void>
  clearCache(config?: RequestConfig): void
  createCancelToken(): any
  cancelRequest(message?: string): void
}

总结

通过以上完整的封装,我们实现了:

  1. 1. 基础功能:统一的请求/响应拦截器、错误处理、token管理
  2. 2. 高级特性:请求缓存、并发控制、文件上传下载
  3. 3. 用户体验:智能loading、友好的错误提示
  4. 4. 开发体验:TypeScript支持、API模块化管理

这样的封装不仅提高了开发效率,还增强了应用的稳定性和用户体验。记住,好的封装不是为了炫技,而是为了解决问题。希望这篇文章能帮助你在Vue项目中构建出更优雅、更强大的HTTP请求层!

最后的小提示:  封装不是一成不变的,要根据项目的实际需求进行调整。比如,如果你的项目需要支持GraphQL,或者有特殊的认证方式,都需要在封装中做相应调整。

如果你觉得这篇文章对你有帮助,欢迎分享给更多的开发者朋友。我们下期再见!

微信支付集成_JSAPI

作者 _杨瀚博
2025年12月14日 12:48

微信支付集成_JSAPI

0.背景

产品接入微信支付,需要实现PC端扫码支付,移动端公众号支付,以及小程序支付.经过调研统一采用微信的JSAPI实现.主要过程分两个大步骤:

  • 下单接口(/v3/pay/transactions/jsapi),获取预付单号
  • 切换到微信环境(公众号,小程序)并结合预付单号,唤起支付界面,用户完成支付

需要实际在手机完成支付,本次测试代码全部通过前端实现.针对访问微信下单接口,通过ngin代理避免前端跨域问题. 有完整测试代码,若需要请关注公众号小满小慢回复wepay获取.获取代码后,你只需把src部署到你服务器,然后在微信中访问 即可.如果域名能映射到你本地开发机上,直接npm run server运行也是不错的选择. 这种方式运行,已经内置代理.不需要单独再配置nginx

1.集成前置准备

需要在微信支付平台申请商户号.申请好以后需要在商户号上关联对应的公众号,小程序等.涉及微信支付平台,需要提前准备一下信息

  • 微信支付地址: api.mch.weixin.qq.com
  • 商户号ID (mchid)
  • 商户API证书 (apiclient_key.pem)
  • 商户API证书序列号(merchant_serial_no)
  • 公众号或小程序的appid (appid)
  • 测试用户对应公众或小程序的openid (openid)

系统集成需要准备以下信息

  • 备案通过的域名
  • 微信支付回调地址 (notify_url)

商户平台配置

  • 产品中心/AppID账号管理 关联小程序或者公众号
  • 产品中心/开发配置 注册唤起支付url必须一样

2. 测试页面开发

测试页面方便不同的商户号测试,整体分两部分内容

  1. 全局配置,主要配置 mchid,apiclient_key.pem,merchant_serial_no,appid,openid,notify_url 配置好以后,信息存储在localStorage中
  2. 支付信息,主要设置 支付金额, 支付说明,是否分账.支付金额最多两位小数

整体测试页面如下 支付测试页面

3. JSAPI下单接口

支付接口需要做签名处理,签名采用RAS算法,使用forge.js提供的算法

官方参考文档 https://pay.weixin.qq.com/doc/v3/merchant/4012791856

下单接口的签名串规则如下:

HTTP请求方法\n
URL\n
请求时间戳\n
请求随机串\n
请求报文主体\n

实际代码体现如下:

const message =
  "POST\n" +
  "/v3/pay/transactions/jsapi\n" +
  timeStamp + "\n" +
  nonceStr +"\n" +
  JSON.stringify(payData) +"\n";

在前端针对timeStamp有些特殊处理

  • 参数要求到秒,前端通过new Date().getTime()是到毫秒的,需要除1000
  • 必须转换成字符串,否则微信接口会报错

生成签名的主要代码如下:

// 小游戏 地心侠士
function getSign(message, privateKeyStr) {
  const privateKey = forge.pki.privateKeyFromPem(privateKeyStr);
  const sha256 = forge.md.sha256.create();
  sha256.update(forge.util.encodeUtf8(message));
  const signature = forge.util.encode64(privateKey.sign(sha256));
  return signature
}

最终完整的认证信息如下:

WECHATPAY2-SHA256-RSA2048 mchid="${mchid}",serial_no="${serialNo}",nonce_str="${nonceStr}",timestamp="${timeStamp}",signature="${signature}"

前端完整下单代码如下:

async function processPayinfo(cfgInfo, payInfo) {
  const payData =
  {
      "appid": cfgInfo.appid,
      "mchid": cfgInfo.mchid,
      "description": payInfo.description,
      "out_trade_no": payInfo.out_trade_no || new Date().getTime() + "",
      "attach": payInfo.attach,
      "notify_url": cfgInfo.callback_url,
      "support_fapiao": false,
      "amount": {
          "total": Math.round(payInfo.amount * 100),
          "currency": "CNY"
      },
      "payer": {
          "openid": cfgInfo.openid
      },
      "settle_info": {
          "profit_sharing": payInfo.split_payment
      }
  };
  const nonceStr = generateNonceStr();
  const timeStamp = getTimeStamp();
  const mchid = cfgInfo.mchid;
  const method = "POST";
  const payUri = "/v3/pay/transactions/jsapi";
  // 小游戏 地形侠士 签名原始串
  const message =method +"\n" +payUri +"\n" +timeStamp +
                "\n" +nonceStr +"\n" +JSON.stringify(payData) +"\n";
  // 小游戏 地心侠士 生成签名    
  const serialNo = cfgInfo.merchant_serial_no
  const privateKeyStr = cfgInfo.apiclient_key;
  const signature = getSign(message, privateKeyStr);
  // 小游戏 地心侠士 完整认证串
  let auth = `WECHATPAY2-SHA256-RSA2048 mchid="${mchid}",serial_no="${serialNo}",nonce_str="${nonceStr}",timestamp="${timeStamp}",signature="${signature}`
  const response = await fetch(payUri, {
      method: method,
      mode: 'cors',
      headers: {
          'Content-Type': 'application/json',
          'Authorization': auth
      },
      body: JSON.stringify(payData)
  });
  if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
  }
  const result = await response.json();
  console.log(result)
  return result;
}

以上代码,执行成功就会返回预付订单号,类似如下的响应正文

{
    "prepay_id": "wx132023572988633421178322a067e20000"
}

4. 唤起微信支付

在上边拿到预付单号以后,就可以在微信环境唤起微信支付界面了.唤起微信支付通用涉及到签名,这里的签名方法一样,但是签名串规则略有变化.签名串规则如下:

appId\n
时间戳\n
随机字符串\n
prepay_id=\n

这需要注意的是,package并不是下单接口返回的JSON串,需要转换成key=value的形式 完成唤起支付代码如下:

function onBridgeReady(cfgInfo, prepayinfo, cb) {
  const nonceStr = generateNonceStr();
  const timeStamp = getTimeStamp();
  // 小游戏 地心侠士 转换预付单号
  const package = "prepay_id=" + prepayinfo.prepay_id;
  const message =cfgInfo.appid +"\n" +timeStamp +"\n" +
                 nonceStr +"\n" + package +"\n";
  // 小游戏 地心侠士 生成签名   
  const privateKeyStr = cfgInfo.apiclient_key;
  const signature = getSign(message, privateKeyStr);
  WeixinJSBridge.invoke('getBrandWCPayRequest', {
      "appId": cfgInfo.appid,
      "timeStamp": timeStamp,
      "nonceStr": nonceStr,
      "package": package,
      "signType": "RSA",
      "paySign": signature,
  }, function (res) {
      cb && cb(res);
      if (res.err_msg == "get_brand_wcpay_request:ok") {
          console.log("支付成功")
      }
  });
}

运行成功后,就会弹出微信支付的页面,测试界面显示如下

微信支付

5. nginx跨域配置

整个测试代码完全通过前端JS实现,在访问微信下单接口时会有跨域限制,在实际服务器中,需要通过nginx对微信接口实现代理.关键配置如下

# === 小游戏 地心侠士 微信支付代理配置 ===
location ^~ /v3/pay/ {
  # 目标服务器地址(替换为实际地址)
  proxy_pass https://api.mch.weixin.qq.com;
  
  # 关键头设置
  proxy_set_header Host api.mch.weixin.qq.com;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto $scheme;
  proxy_set_header User-Agent "WeChat-Pay-Proxy/1.0";
  
  # 超时设置
  proxy_connect_timeout 30s;
  proxy_send_timeout 30s;
  proxy_read_timeout 30s;
  
  # 处理CORS
  add_header 'Access-Control-Allow-Origin' '*' always;
  add_header 'Access-Control-Allow-Methods' '*' always;
  add_header 'Access-Control-Allow-Headers' '*' always;
  
  if ($request_method = 'OPTIONS') {
      add_header 'Access-Control-Allow-Origin' '*';
      add_header 'Access-Control-Allow-Methods' '*';
      add_header 'Access-Control-Allow-Headers' '*';
      return 204;
  }
}

6. 总结

微信交互常规的做法是通过后端发送请求到微信服务器,微信服务器返回数据,然后通过前端获取数据,再进行交互。在测试时,前端唤起支付,后端提供预定单接口.前后端都不方便测试支付情况.所有才结合微信提供POSTMAN中接口测试方法,完成这个纯前端的,通用的,微信支付测试页面.如需要完成源码,请在微信公众小满小慢回复wepay获取测试代码 原文地址:mp.weixin.qq.com/s/r8kAPXyuW…

从零到一:打造专业级小说地图设计工具的技术实践

作者 龙国浪子
2025年12月14日 12:30

🗺️ 从零到一:打造专业级小说地图设计工具的技术实践

💡 本文详细介绍 51mazi 项目中地图设计页的整体架构与核心功能实现,这是一个基于 Vue 3 + Canvas 的专业级地图编辑器,集成了多种绘图工具、资源管理、缩放控制等完整功能。通过模块化的 Composables 架构设计,实现了高可维护性和可扩展性的代码结构。

📋 目录

🎯 项目背景

在小说创作过程中,作者经常需要绘制故事中的地图来帮助读者理解故事背景和地理关系。传统的写作软件往往缺乏这样的可视化工具,因此我们在 51mazi 中集成了基于 Canvas 的专业级地图设计功能。

🗺️ 地图设计工具

maps.png

强大的地图设计工具 - 专业级 Canvas 绘图与资源管理

✨ 功能特性概览

地图设计页提供了完整的绘图工具集,包括:

  • 🎨 10+ 种绘图工具: 移动、选框、画笔、橡皮擦、形状、油漆桶、文字、资源、背景等
  • 🔍 画布控制: 缩放、平移、重置视图,支持鼠标滚轮和快捷键操作
  • 🎯 精确操作: 选框工具支持选择、移动、调整大小、旋转等操作
  • 💾 数据管理: 完整的撤销/重做系统,自动保存和加载地图数据
  • 🎨 参数控制: 颜色选择器、大小滑块、透明度滑块,精确控制绘制效果
  • 🖼️ 资源管理: 预设图标资源库,支持拖拽添加和调整
  • ⌨️ 快捷键支持: 完整的键盘快捷键系统,提升操作效率

🏗️ 技术架构概览

技术栈

// 核心技术栈
{
  "vue": "^3.5.13",           // Vue 3 Composition API
  "element-plus": "^2.10.1",  // UI 组件库
  "canvas": "原生 Canvas API"  // 绘图引擎
}

整体架构设计

地图设计页采用 模块化的 Composables 架构,将不同功能拆分为独立的 composable 函数,实现了清晰的代码组织和高度可维护性。

MapDesign.vue (主组件)
├── 基础 Composables
│   ├── useCanvasState.js      # 画布状态管理(缩放、平移、边界)
│   ├── useCoordinate.js       # 坐标转换工具
│   ├── useHistory.js          # 历史记录管理(撤销/重做)
│   ├── useElements.js         # 元素数据管理
│   ├── useRender.js           # 渲染函数集合
│   └── useCanvas.js            # 画布渲染和管理
│
└── 工具 Composables
    ├── usePencilTool.js        # 画笔工具
    ├── useEraserTool.js        # 橡皮擦工具
    ├── useShapeTool.js         # 形状工具(线条、矩形、圆形等)
    ├── useTextTool.js          # 文字工具
    ├── useBucketTool.js        # 油漆桶工具
    ├── useResourceTool.js      # 资源工具
    ├── useSelectTool.js        # 选框工具(选择、移动、调整、旋转)
    ├── useMoveTool.js          # 移动工具(平移画布)
    └── useBackgroundTool.js    # 背景工具

核心组件结构

<!-- src/renderer/src/views/MapDesign.vue -->
<template>
  <div class="map-design">
    <!-- 工具栏 -->
    <MapToolbar
      v-model="tool"
      :shape-tool-type="shapeToolType"
      @update:model-value="onToolChange"
      @clear="handleClearCanvas"
      @resource-select="selectResource"
      @save-map="handleSaveMap"
    />

    <!-- 颜色选择器 -->
    <Transition name="color-picker-fade">
      <div v-if="showColorPicker" class="color-picker-container">
        <el-color-picker v-model="currentColor" />
      </div>
    </Transition>

    <!-- 滑块控制工具 -->
    <FloatingSidebar :visible="tool === 'pencil' || tool === 'eraser' || tool === 'shape'">
      <MapSlider v-model="size" label="大小" />
      <MapSlider v-model="opacity" label="透明度" />
    </FloatingSidebar>

    <!-- 画布容器 -->
    <div class="editor-container" @wheel="handleWheel">
      <canvas
        ref="canvasRef"
        :width="canvasDisplayWidth"
        :height="canvasDisplayHeight"
        @mousedown="handleCanvasMouseDown"
        @mousemove="handleCanvasMouseMove"
        @mouseup="handleCanvasMouseUp"
      />
    </div>

    <!-- 缩放控制器和撤销/回退按钮 -->
    <MapZoomControls
      :scale="canvasState.scale.value"
      @zoom-in="handleZoomIn"
      @zoom-out="handleZoomOut"
      @reset-zoom="handleResetZoom"
      @undo="handleUndo"
      @redo="handleRedo"
    />
  </div>
</template>

💡 完整代码请查看: src/renderer/src/views/MapDesign.vue

🔧 核心功能模块

1. 画布状态管理 (useCanvasState)

管理画布的缩放、平移、边界等核心状态:

// 核心状态管理
const canvasState = useCanvasState()

// 主要状态
- scale: 画布缩放比例
- scrollX/scrollY: 画布平移偏移
- contentBounds: 内容边界(用于自动调整画布大小)
- canvasDisplayWidth/Height: 画布显示尺寸

💡 详细实现请查看: src/renderer/src/composables/map/useCanvasState.js

2. 元素管理系统 (useElements)

统一管理所有绘制元素,包括画笔路径、形状、文字、资源、填充区域等:

// 元素类型
const elements = useElements()

// 元素分类
- freeDrawElements: 画笔绘制的路径
- shapeElements: 形状元素(线条、矩形、圆形等)
- textElements: 文字元素
- resourceElements: 资源元素(图标、图片)
- fillElements: 填充区域

// 核心方法
- serialize(): 序列化所有元素为 JSON
- deserialize(): 从 JSON 恢复元素
- clearAll(): 清空所有元素

💡 详细实现请查看: src/renderer/src/composables/map/useElements.js

3. 历史记录系统 (useHistory)

实现完整的撤销/重做功能:

// 历史记录管理
const { history } = useHistory(canvasRef)

// 核心功能
- saveState(): 保存当前状态
- undo(): 撤销操作
- redo(): 重做操作
- canUndo(): 是否可以撤销
- canRedo(): 是否可以重做

💡 详细实现请查看: src/renderer/src/composables/map/useHistory.js

4. 渲染系统 (useRender)

统一的渲染函数集合,负责绘制各种元素:

// 渲染函数
const renderFunctions = useRender(canvasRef)

// 渲染方法
- renderFreeDrawPath(): 渲染画笔路径
- renderShape(): 渲染形状(使用 Rough.js)
- renderText(): 渲染文字
- renderResource(): 渲染资源(图标/图片)
- renderFill(): 渲染填充区域
- renderSelection(): 渲染选框

💡 详细实现请查看: src/renderer/src/composables/map/useRender.js

5. 工具系统

每个绘图工具都是独立的 composable,实现了统一的接口:

画笔工具 (usePencilTool)
const pencilTool = usePencilTool({
  canvasRef,
  elements,
  history,
  renderCanvas,
  color,
  size,
  opacity
})

// 核心方法
- onMouseDown(): 开始绘制
- onMouseMove(): 绘制过程
- onMouseUp(): 结束绘制

💡 详细实现请查看: src/renderer/src/composables/map/tools/usePencilTool.js

选框工具 (useSelectTool)

最复杂的工具之一,支持选择、移动、调整大小、旋转等操作:

const selectTool = useSelectTool({
  elements,
  history,
  renderCanvas,
  selectedElementIds,
  canvasState,
  canvasCursor
})

// 核心功能
- 单选和多选
- 拖拽移动元素
- 调整元素大小(8个控制点)
- 旋转元素
- 删除选中元素
- 智能光标样式

💡 详细实现请查看: src/renderer/src/composables/map/tools/useSelectTool.js

形状工具 (useShapeTool)

支持多种形状绘制:线条、矩形、圆形、圆角矩形、五角星、箭头等:

const shapeTool = useShapeTool({
  canvasRef,
  elements,
  history,
  renderCanvas,
  color,
  size,
  opacity
})

// 支持的形状类型
- line: 线条
- rect: 矩形
- circle: 圆形
- roundRect: 圆角矩形
- star: 五角星
- arrow: 箭头

💡 详细实现请查看: src/renderer/src/composables/map/tools/useShapeTool.js

其他工具
  • 橡皮擦工具: 精确擦除已绘制内容
  • 文字工具: 添加文字标注,支持双击编辑
  • 油漆桶工具: 区域填充,使用洪水填充算法
  • 资源工具: 拖拽添加预设图标和图片
  • 移动工具: 平移画布视图
  • 背景工具: 设置画布背景色

💡 所有工具实现请查看: src/renderer/src/composables/map/tools/

🎨 模块化设计

Composables 架构优势

  1. 高内聚低耦合: 每个 composable 专注于单一职责
  2. 易于测试: 独立的 composable 便于单元测试
  3. 代码复用: 可以在其他组件中复用相同的 composable
  4. 易于维护: 修改某个功能只需关注对应的 composable
  5. 类型安全: 清晰的接口定义,减少错误

工具统一接口

所有工具 composable 都遵循统一的接口模式:

// 工具接口规范
export function useXxxTool(options) {
  // 状态
  const drawingActive = ref(false)
  
  // 方法
  function onMouseDown(pos) { /* ... */ }
  function onMouseMove(pos) { /* ... */ }
  function onMouseUp() { /* ... */ }
  
  return {
    drawingActive,
    onMouseDown,
    onMouseMove,
    onMouseUp
  }
}

事件处理流程

// 统一的事件处理
function handleCanvasMouseDown(e) {
  const pos = getCanvasPos(e) // 坐标转换
  
  // 根据当前工具调用对应的方法
  if (tool.value === 'pencil') {
    pencilTool.onMouseDown(pos)
  } else if (tool.value === 'eraser') {
    eraserTool.onMouseDown(pos)
  } else if (tool.value === 'shape') {
    shapeTool.onMouseDown(pos)
  }
  // ... 其他工具
}

⚡ 技术亮点

1. 坐标转换系统

实现屏幕坐标到画布坐标的精确转换,支持缩放和平移:

// 坐标转换
const { getCanvasPos } = useCoordinate(
  canvasRef,
  editorContainerRef,
  canvasState.scale,
  canvasState.scrollX,
  canvasState.scrollY
)

// 使用
const pos = getCanvasPos(event) // 自动处理缩放和平移

💡 详细实现请查看: src/renderer/src/composables/map/useCoordinate.js

2. 智能画布管理

自动调整画布大小以适应内容,支持无限画布:

// 画布管理
const { renderCanvas, canvasWrapStyle, updateContentBounds } = useCanvas(
  canvasRef,
  editorContainerRef,
  canvasState,
  elements,
  // ... 其他参数
)

// 自动更新内容边界
updateContentBounds()

💡 详细实现请查看: src/renderer/src/composables/map/useCanvas.js

3. 缩放控制

支持多种缩放方式:鼠标滚轮、按钮、快捷键,以鼠标位置为中心缩放:

// 缩放控制
function handleWheel(e) {
  // Ctrl/Cmd + 滚轮:缩放
  if (e.ctrlKey || e.metaKey) {
    // 以鼠标位置为中心缩放
    const sceneX = mouseX / scale.value - scrollX.value
    const sceneY = mouseY / scale.value - scrollY.value
    // ... 计算新的缩放和平移
  }
}

4. 数据序列化

完整的地图数据序列化和反序列化,支持保存和加载:

// 保存地图
async function handleSaveMap() {
  // 生成预览图
  const imageData = await generatePreviewImage()
  
  // 序列化画板内容
  const mapData = elements.serialize(backgroundColor.value)
  
  // 保存到文件系统
  await window.electron.updateMap({
    bookName,
    mapName: mapName.value,
    imageData,
    mapData
  })
}

// 加载地图
async function loadMapData() {
  const mapData = await window.electron.loadMapData({ bookName, mapName })
  if (mapData) {
    const loadedBackgroundColor = elements.deserialize(mapData)
    backgroundColor.value = loadedBackgroundColor
    renderCanvas(true)
  }
}

5. 快捷键系统

完整的键盘快捷键支持,提升操作效率:

// 快捷键配置
function handleKeyDown(e) {
  // 工具快捷键
  switch (e.key.toLowerCase()) {
    case 'v': onToolChange('select'); break
    case 'h': onToolChange('move'); break
    case 'p': onToolChange('pencil'); break
    case 'e': onToolChange('eraser'); break
    case 's': onToolChange('shape'); break
    case 't': onToolChange('text'); break
    case 'b': onToolChange('bucket'); break
    case 'r': onToolChange('resource'); break
  }
  
  // 撤销/重做
  if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
    handleUndo()
  }
  
  // 空格键:临时切换到移动模式
  if (e.key === ' ' && tool.value !== 'move') {
    spaceKeyPressed.value = true
  }
}

6. 资源管理系统

支持预设图标资源库,拖拽添加和调整:

// 资源工具
const resourceTool = useResourceTool({
  canvasRef,
  elements,
  history,
  renderCanvas,
  getCanvasPos
})

// 支持的功能
- 图标资源库(SVG 图标)
- 拖拽添加资源
- 调整资源大小和位置
- 旋转资源

💡 详细实现请查看: src/renderer/src/composables/map/tools/useResourceTool.js

📊 功能特性总结

✅ 已实现功能

功能模块 功能描述 技术实现
绘图工具 10+ 种绘图工具 Composables 架构
画布控制 缩放、平移、重置 useCanvasState + useCanvas
元素管理 统一管理所有元素 useElements
历史记录 撤销/重做 useHistory
渲染系统 统一渲染函数 useRender
坐标转换 屏幕坐标转画布坐标 useCoordinate
数据持久化 保存和加载地图 序列化/反序列化
快捷键 完整的键盘快捷键 事件监听系统
资源管理 图标资源库 useResourceTool
参数控制 颜色、大小、透明度 响应式状态管理

🚀 技术亮点

  1. 模块化架构: Composables 设计实现高可维护性
  2. 统一接口: 所有工具遵循相同的接口规范
  3. 性能优化: 智能渲染,只重绘变化的部分
  4. 用户体验: 流畅的交互,完整的快捷键支持
  5. 可扩展性: 易于添加新的绘图工具
  6. 数据安全: 完整的保存和加载机制

📝 总结与展望

地图设计页是 51mazi 项目中最复杂的功能模块之一,通过模块化的 Composables 架构设计,我们实现了:

  • 清晰的代码组织: 每个功能模块独立,易于理解和维护
  • 高度可扩展: 添加新工具只需创建新的 composable
  • 优秀的用户体验: 流畅的交互和完整的快捷键支持
  • 强大的功能: 10+ 种绘图工具,满足各种地图绘制需求

🎯 技术价值

  • 架构设计: 模块化的 Composables 架构,高内聚低耦合
  • 代码质量: 清晰的接口定义,易于测试和维护
  • 性能优化: 智能渲染机制,保证流畅体验
  • 用户体验: 完整的快捷键系统和直观的操作界面

🔮 未来规划

  • 更多工具: 支持更多绘图工具和效果(如渐变、阴影等)
  • 图层系统: 支持多层绘图和图层管理
  • 导入导出: 支持多种图片格式导入导出
  • 协作功能: 支持多人协作绘图
  • 模板系统: 提供地图模板,快速创建常用地图

📚 相关链接

🏷️ 标签

#Vue3 #Canvas #小说地图 #地图设计 #Composables #前端开发 #架构设计 #模块化 #绘图工具


💡 如果这篇文章对你有帮助,请给个 ⭐️ 支持一下!

💡 想深入了解某个具体功能的实现?欢迎查看 GitHub 上对应的代码文件,每个模块都有详细的注释说明!

暴力分隔计算,python两行,100% | 2147. 分隔长廊的方案数

2023年5月18日 12:26

Problem: 2147. 分隔长廊的方案数

[TOC]

思路

第一步、计数'S'

如果'S'为0或奇数,不符合题意,直接返回0

第二步、按'S'进行分隔

每2个提取出来,去掉首尾2个 → 中间所有'P'的长度 + 1 → 求积 → 取余 → 返回

image.png

Code

python两行,100%:

时间252 ms击败100%;内存18.8 MB击败38.46%

###Python3

class Solution:
    def numberOfWays(self, corridor: str) -> int:
        if (cnt := corridor.count('S')) == 0 or cnt & 1: return 0
        return reduce(lambda x, y : x * y, map(lambda x : len(x) + 1, [""] + corridor.split('S')[2: -2: 2])) % 1000000007

您若还有不同方法,欢迎贴在评论区,一起交流探讨! ^_^

↓ 点个赞,点收藏,再划走,感谢您支持作者! ^_^

🚀 告别“变形”与“留白”:前端可视化大屏适配的终极方案(附源码)

作者 王霸天
2025年12月14日 10:12

摘要: 当产品拿着一份炫酷的 1920x1080 设计稿让你开发时,你是否还在为如何适配客户现场 4K 屏、异形屏甚至拼接屏而发愁?网上流传的 remvw/vh 方案在大屏场景下往往力不从心。

本文将深入剖析大屏适配的核心痛点,并推荐目前社区最主流的开源适配工具,带你用最少的代码(不到 20 行)搞定全屏适配。


🤔 引言:为什么大屏适配这么难?

在普通 Web 开发中,我们习惯了流式布局,容器宽度自适应。但在数据可视化大屏领域,情况截然不同:

  1. 分辨率碎片化:设计通常基于 1920x1080 (16:9) 开发,但现场可能是 4K (3840x2160)、超宽屏 (32:9) 甚至老旧的 4:3 投影仪。
  2. 像素级要求:大屏通常包含复杂的背景图、装饰边框,一旦拉伸变形,视觉效果将大打折扣。
  3. 硬件差异:LED 拼接屏可能存在物理像素点距差异。

传统的响应式方案(如 flex%)无法满足这种**“既要铺满屏幕,又要保持比例,还不能变形”**的苛刻需求。


🧠 核心原理:为什么选择 transform: scale

在深入工具之前,我们需要明确一点:目前大屏适配的主流方案是基于 CSS3 的 transform: scale

1. 方案对比

方案 原理 优点 缺点 适用场景
rem / vw/vh 动态改变根字体大小或视口单位 适合文本流式布局 计算繁琐,背景图难处理,容易出现 1px 偏差 普通自适应网页
媒体查询 针对不同分辨率写多套 CSS 精准控制 代码量爆炸,维护困难 极少数固定分辨率场景
Scale 缩放 以设计稿为基准,整体缩放 代码少,不丢失精度,完美还原设计稿 需要处理缩放后的定位问题 大屏可视化 (推荐)

2. 核心逻辑

大屏适配的本质是:将基于特定设计尺寸(如 1920x1080)的内容,通过矩阵变换,缩放到不同尺寸的屏幕中。


🔧 推荐工具:开源社区的“三驾马车”

如果你不想从零造轮子,以下是目前掘金和 GitHub 上热度最高、最值得信赖的三个开源适配方案。

1. autofit.js —— 简单粗暴的通用方案

这是一个轻量级的无框架依赖库,非常适合原生 JS 或老项目快速接入。

  • 特点
    • 不依赖任何框架,引入即用。
    • 核心逻辑就是获取屏幕宽高,计算缩放比,然后对 body 或容器进行 scale
  • 适用场景:简单的 Vue/React 项目,或者不需要复杂局部定位的静态大屏。

2. vfit.js —— Vue 3 的“高定”裁缝

如果你的项目是基于 Vue 3 + TypeScript,那么 vfit 是目前的最佳选择。它由社区开发者针对 Vue 3 生态深度优化。

  • 特点
    • 组件化思维:它不仅仅是一个缩放工具,更提供了一个 <FitContainer> 组件。
    • 解决定位痛点:它完美解决了缩放后绝对定位(position: absolute)元素的偏移问题。你可以通过设置 unit="%"unit="px" 来让元素跟随缩放自动调整位置。
    • 响应式:与 Vue 3 的 Composition API 配合得天衣无缝。
  • 适用场景:复杂的 Vue 3 可视化项目,特别是包含大量需要精确定位的图表、装饰物的场景。

3. DataV (Vue) / DataV-React —— “开箱即用”的大屏全家桶

虽然阿里云有商业版 DataV,但开源社区有两个非常优秀的仿制品(通常由社区维护,如 DataV-Team 出品)。

  • 特点
    • 自带适配:这些库在设计之初就考虑了适配问题,通常内置了 full-screen-container 这样的组件。
    • 视觉组件丰富:提供了边框、装饰、飞线图等大屏专用组件,这些组件内部已经处理好了缩放逻辑。
  • 适用场景:不想自己写 CSS 和适配逻辑,想直接拖拽组件快速搭建大屏的开发者。

💻 代码实战:不到 20 行代码搞定适配

不想引入第三方库?其实原生 JS 实现一个高性能的适配器也非常简单。以下是一个基于 “等比缩放” 策略的通用方案。

1. 核心代码 (flexible.ts)

// 定义设计稿基准
const DESIGN_WIDTH = 1920;
const DESIGN_HEIGHT = 1080;

// 计算缩放比例的核心逻辑
const calculateScale = () => {
  const { clientWidth, clientHeight } = document.documentElement;
  
  // 策略:取宽度和高度缩放比例的最小值,确保内容完整显示(类似 background-size: cover)
  const scaleX = clientWidth / DESIGN_WIDTH;
  const scaleY = clientHeight / DESIGN_HEIGHT;
  
  return Math.min(scaleX, scaleY);
};

// 应用缩放
const applyScale = () => {
  const scale = calculateScale();
  const container = document.getElementById('screen-container');
  
  if (container) {
    // 关键 CSS:以左上角为原点进行缩放
    container.style.transform = `scale(${scale})`;
    container.style.transformOrigin = '0 0';
    
    // 可选:如果需要居中显示,可以计算偏移量
    // const offsetX = (clientWidth - DESIGN_WIDTH * scale) / 2;
    // container.style.marginLeft = `${offsetX}px`;
  }
};

// 监听窗口变化
window.addEventListener('resize', applyScale);
export default applyScale;

2. 在 Vue/React 中使用

main.js 或根组件的 mounted 阶段调用即可:

import applyScale from './utils/flexible';
// 初始化
applyScale();

3. CSS 样式配合

为了让容器撑满全屏并承载缩放,CSS 需要这样写:

html, body, #app {
  width: 100%;
  height: 100%;
  overflow: hidden; /* 隐藏滚动条 */
    margin: 0;
    padding: 0;
}

#screen-container {
  width: 1920px; /* 固定设计稿宽度 */
  height: 1080px; /* 固定设计稿高度 */
  position: relative;
  /* 背景图等样式 */
}

📝 总结与建议

在 2025 年的今天,面对可视化大屏适配,我的建议是:

  1. 首选 scale 方案:除非你有特殊的业务逻辑要求,否则不要尝试用 rem 去硬抗大屏适配,transform: scale 是目前社区公认的最优解。
  2. 技术栈匹配
    • 如果是 Vue 3 项目,强烈推荐使用 vfit.js,它能帮你省去 80% 的定位调试时间。
    • 如果是 React 项目,可以寻找类似的 react-fit-screen 库,或者直接使用上述的通用 JS 代码。
    • 如果追求极致开发速度,直接上开源版 DataV
  3. 设计沟通:在开发前,务必确认大屏的部署环境。如果是 16:9 的标准屏,上述方案完美适用;如果是超宽屏(如 32:9),可能需要考虑“两边留白”或者“背景拉伸”的特殊处理。

希望这篇文章能帮你搞定那个让人头疼的“大屏适配”需求,早点下班!✨

new Array() 与 Array.from() 的差异与陷阱

2025年12月14日 09:42

JS 数组初始化的两种方式:空槽(hole) vs undefined。 下面从结果结构、可遍历性、行为差异、使用场景几个维度,系统对比

一、最直观的差异(重点)

new Array(10)
// [ <10 empty items> ]

Array.from({ length: 10 })
// [ undefined, undefined, ..., undefined ](10 个)

核心区别

  • new Array(10)稀疏数组(holes)
  • Array.from({ length: 10 })密集数组(元素存在,值为 undefined)

二、数组“空槽(hole)” vs undefined

特性 new Array(10) Array.from({ length: 10 })
数组长度 10 10
是否有元素 ❌ 没有(hole) ✅ 有
0 in arr false true
arr[0] undefined undefined
JSON.stringify [null,null,...] [null,null,...]
const a = new Array(10)
const b = Array.from({ length: 10 })
// 检查0下标
0 in a // false
0 in b // true

⚠️ undefined !== hole

三、遍历 & 高阶函数行为差异(非常重要)

1️⃣ map / forEach / filter

new Array(3).map(() => 1)
// [ <3 empty items> ]

Array.from({ length: 3 }).map(() => 1)
// [1, 1, 1]

原因

  • 高阶方法会 跳过 hole
  • 不会跳过 undefined

2️⃣ for...of

for (const x of new Array(3)) {
  console.log(x)
}
// undefined undefined undefined

for (const x of Array.from({ length: 3 })) {
  console.log(x)
}
// undefined undefined undefined

for...of 会遍历 hole(与 map 不同)

3️⃣ for...in

for (const i in new Array(3)) console.log(i)
// 什么都不输出

for (const i in Array.from({ length: 3 })) console.log(i)
// 0 1 2

四、性能与语义差异

维度 new Array(10) Array.from({ length: 10 })
创建速度 更快 稍慢
内存 更省(无元素) 占用更多
可预测性 ❌ 容易踩坑 ✅ 行为一致
函数式友好

五、典型使用场景

✅ 适合 new Array(10) 的情况

// 只关心 length
const buffer = new Array(1024)

// 之后立即填充
const arr = new Array(10)
arr.fill(0)

✅ 适合 Array.from({ length: 10 })

// 需要 map / filter / reduce
const list = Array.from({ length: 10 }, (_, i) => i)

// JSX / Vue 渲染
{Array.from({ length: 5 }).map((_, i) => (
  <Item key={i} />
))}

六、等价但更常见的写法

Array.from({ length: 10 }, (_, i) => i)

// 等价于
[...Array(10).keys()]

七、总结一句话(面试 / 设计建议)

new Array(n) 创建的是“空槽数组”,
Array.from({ length: n }) 创建的是“真实元素数组”。

推荐原则

  • 遍历 / map / 渲染Array.from
  • 只当 占位容器new Array

深度复盘 III: 核心逻辑篇:构建 WebGL 数字孪生的“业务中枢”与“安全防线”

作者 Addisonx
2025年12月14日 07:40

🚀 前言

在 Z-TWIN 污水处理厂项目的前两篇复盘中,我们解决了 渲染管线(Rendering Pipeline) 的性能瓶颈与 HMI 工程化 的多端适配问题。这两步走完,我们构建了一个“好看”且“能跑”的系统骨架。

然而,从 POC(概念验证) 走向 Production(生产环境) 的过程中,真正的挑战在于如何让这套 3D 系统承载复杂的工业业务。在实际工程交付中,我们深知:视觉只是表层,逻辑才是骨架。 一个合格的工业级数字孪生系统,必须具备极低的操作门槛、绝对的安全控制机制以及深度的数据追溯能力。

本文将剥离表面的视觉特效,深入源码的 HTML/CSS/JS 铁三角,复盘我们在 交互约束体系工业控制协议 以及 时空数据架构 中的核心设计决策。


🧭 一、 交互设计的辩证:基于“物理约束”的巡检逻辑

在 Web 3D 开发初期,为了展示技术能力,开发者常通过 OrbitControls 给予用户无限的自由度。但在高压力的工业运维场景下,过度的自由往往导致操作迷失。

为了解决这一痛点,我们通过代码构建了一套**“带阻尼的第一人称巡检模式”**。我们认为,适度的约束能显著降低认知负荷。

1. HTML:语义化的引导结构

我们在系统入口处预置了强制引导层,用于建立用户的心理模型,明确操作逻辑。

文件:index.html (部分核心结构)

<div id="welcome-guide" class="welcome-overlay">
    <div class="keyboard-grid">
        <!-- 模拟物理控制台的键位映射 -->
        <div class="keyboard-key"><div class="key-cap">W</div><span>推进</span></div>
        <div class="keyboard-key"><div class="key-cap">S</div><span>退行</span></div>
        <div class="keyboard-key"><div class="key-cap wide">Shift</div><span>巡检加速</span></div>
    </div>
</div>

2. JS:基于向量的物理运动逻辑

在逻辑层,我们并没有简单地修改相机坐标,而是引入了速度向量阻尼系数。这种处理方式模拟了真实的人体运动惯性,消除了画面急停急转带来的眩晕感。

文件:logic/PlayerController.js (物理计算核心逻辑)

function updateCamera(delta) {
    // 1. 根据按键输入计算加速度 (Acceleration)
    const acceleration = new THREE.Vector3(0, 0, 0);
    if (inputState.moveForward) acceleration.z -= 1.0;
    if (inputState.moveBackward) acceleration.z += 1.0;
    
    // 2. 应用速度与阻尼 (Velocity & Damping)
    velocity.add(acceleration.multiplyScalar(delta * params.speed));
    velocity.multiplyScalar(1.0 - params.damping * delta); 
    
    // 3. 碰撞检测与位置更新 (Collision & Update)
    const nextPosition = camera.position.clone().add(velocity);
    if (!checkWallCollision(nextPosition)) {
        camera.position.copy(nextPosition);
    }
    
    // 4. 强制高度锁定 (模拟人眼高度 1.7m)
    camera.position.y = 1.7; 
}

设计思考: 这种“降维”设计迫使用户放弃容易迷失的上帝视角,专注于平视的设备状态巡检,显著降低了非技术人员(如现场老师傅)的学习成本。


🔒 二、 工业安全锁:构建“三步握手”控制闭环

数字孪生的深水区是反向控制(Reverse Control)。在工业现场,前端的一次误触可能导致严重的生产事故。因此,我们坚决摒弃了“点击 3D 模型直接触发 API”的短路逻辑,构建了一套严密的 UI 拦截机制

1. HTML:独立的物理拦截层

我们在 index.html 中预埋了一个模态框,利用 DOM 层级遮挡 Canvas,实现了交互上的物理隔离。

文件:index.html (安全确认弹窗结构)

<div id="confirm-dialog" class="confirm-dialog hidden">
    <div class="confirm-card">
        <h3>确认操作</h3>
        <p>该操作将实时下发至 PLC 控制柜,请确认!</p>
        <div class="confirm-device">
            <span class="label">设备ID:</span>
            <span class="value" id="confirm-device-name">--</span>
        </div>
        <button id="confirm-ok-btn" class="ok-btn">执行启动</button>
    </div>
</div>

2. JS:严格的“三步握手”协议

在逻辑层,我们实现了展示与控制的完全解耦,并坚持状态驱动原则。只有物理世界的设备真正响应了,数字孪生中的状态才会随之改变。

文件:logic/InteractionManager.js (指令流逻辑)

// 第一步:拾取与拦截 (Pick & Intercept)
function onDeviceClick(deviceMesh) {
    // 仅弹出面板,绝不直接发送指令
    showPropertyPanel(deviceMesh.userData);
}

// 第二步:UI 握手 (Handshake)
document.getElementById('panel-start-btn').onclick = () => {
    // 挂起 3D 交互,弹出全屏遮罩
    controls.enabled = false; 
    document.getElementById('confirm-dialog').classList.remove('hidden');
};

// 第三步:执行与状态回显 (Execute & Feedback)
document.getElementById('confirm-ok-btn').onclick = async () => {
    const deviceId = currentSelection.id;
    
    // 发送指令 (UI 进入 Loading 态)
    setButtonLoading(true);
    await mqttClient.publish(`device/${deviceId}/control`, 'START');
    
    // 注意:此处绝不修改模型颜色!
    // 模型颜色的变更,严格等待后端 WebSocket 的状态推送
};

// 第四步:数据驱动视图 (Data Driven)
socket.on('device_status_change', (msg) => {
    if (msg.id === deviceId && msg.status === 'RUNNING') {
        // 只有收到物理世界的确认,数字世界才随之改变
        targetMesh.material.color.setHex(0x00FF00); 
    }
});

设计思考: 屏幕上的绿色,必须代表物理水泵真的转起来了,而不是代表用户点击了按钮。这种闭环确认机制是工业软件可信度的基石。


⏳ 三、 数据架构的升维:从“状态监视”到“时空推演”

传统的监控大屏通常只展示 Current State(当前值)。但在故障排查(RCA)场景中,**“过去发生了什么”往往比“现在是什么”**更有价值。我们通过一套双模态架构,赋予了系统“时空穿梭”的能力。

1. HTML/CSS:时间轴组件

我们在控制面板中集成了一个时间轴组件,通过 CSS 样式明确区分当前系统的运行模式。

文件:index.html (部分结构)

<div class="replay-controls">
    <button id="replay-play-btn"></button>
    <!-- 核心组件:进度条 -->
    <input type="range" id="replay-slider" min="0" max="100" value="100">
</div>

文件:css/panels.css (状态样式)

/* 实时模式为蓝色 */
.replay-controls input[type="range"] {
    accent-color: var(--color-primary); 
}
/* 回放模式变黄,警示用户数据非实时 */
.replay-mode .replay-controls input[type="range"] {
    accent-color: var(--color-warning); 
}

2. JS:双模态数据流架构

DataManager.js 中,我们重构了数据消费逻辑,支持在实时流历史快照之间无缝切换。

文件:data/DataManager.js (模式切换逻辑)

let isReplayMode = false;

// 监听滑块拖动
slider.addEventListener('input', (e) => {
    const value = parseInt(e.target.value);
    
    if (value < 100) {
        // 进入回放模式
        isReplayMode = true;
        document.body.classList.add('replay-mode');
        // 暂停实时 WebSocket 处理,防止数据污染
        socketManager.pause(); 
        // 从 IndexedDB 读取历史快照并插值渲染
        renderHistoricalFrame(value); 
    } else {
        // 回到实时模式
        isReplayMode = false;
        document.body.classList.remove('replay-mode');
        socketManager.resume();
        // 追赶最新状态
        syncLatestState();
    }
});

function renderHistoricalFrame(timePercent) {
    // 线性插值算法 (Lerp) 计算历史状态,保证动画平滑
    const snapshot = timeSeriesDB.getSnapshotAt(timePercent);
    sceneGraph.updateFromData(snapshot);
}

设计思考: 这一架构将数字孪生从单纯的“监视器”升级为“故障推演机”,运维人员可以像看视频一样回溯事故现场,极大提升了系统的业务价值。


🔭 四、 演进与展望:下一代技术布局

虽然目前的架构已满足交付标准,但面对日益复杂的工业场景,我们正在探索更前沿的技术边界:

  1. 计算性能:WebAssembly & WebGPU 目前的架构受限于 JS 单线程。我们正在尝试引入 WebAssembly 处理复杂的流体物理计算,并将大规模粒子系统迁移至 WebGPU Compute Shader,以释放 CPU 性能,支持更大规模的场景。

  2. 智能辅助:AI Agent 集成 结合 LLM,未来的交互将不再局限于点击。操作员可以说“高亮显示所有温度异常的机柜”,前端 AI Agent 自动解析语义、调用 3D API 并规划漫游路径,实现真正的智能辅助。

  3. 端云协同:Pixel Streaming 针对老旧的移动终端,我们测试引入 Pixel Streaming(像素流送) 技术。将高保真渲染卸载至云端,前端仅作为视频流接收端,在 iPad 上实现电影级的画质体验。


🤝 五、 技术探讨与落地

工业级 Web 3D 开发是一项复杂的系统工程,从模型资产、渲染优化到业务逻辑闭环,每一个环节都需要精细打磨。

我们团队在实战中沉淀了这套全链路解决方案。我们非常乐意与同行或有需求的朋友进行深度交流

如果您正面临以下场景,欢迎沟通:

  1. 业务团队互补:拥有深厚的后端/工业协议积累,但急需一支能打硬仗的 3D 前端团队。
  2. 项目集成合作:手头有智慧城市、智慧工厂或 IDC 可视化项目,需要集成高性能的 Web 3D 模块。
  3. 技术瓶颈突破:现有的 3D 场景卡顿、交互混乱或效果不达标,寻求优化方案。

在线演示环境: 👉 www.byzt.net:70/ (注:建议使用 PC 端 Chrome 访问以获得最佳体验)

不管是技术探讨源码咨询还是项目协作,都欢迎在评论区留言或点击头像私信,交个朋友,共同进步。


声明:本文核心代码与架构思路均为原创,转载请注明出处。

JavaScript 事件循环机制详解及项目中的应用

作者 cindershade
2025年12月14日 02:35

第一部分:基础概念

1. JavaScript 执行环境

JavaScript 是单线程的,这意味着它一次只能执行一个任务。为了处理异步操作,JavaScript 使用事件循环机制。

2. 核心组件

  • 调用栈(Call Stack) :执行同步代码的地方
  • 任务队列(Task Queue) :分为宏任务队列和微任务队列
  • 事件循环(Event Loop) :协调调用栈和任务队列的机制

第二部分:举例详细解析

console.log('1. 同步任务开始');

setTimeout(() => {
    console.log('2. setTimeout 回调');
}, 0);

Promise.resolve().then(() => {
    console.log('3. Promise.then 回调');
});

console.log('4. 同步任务结束');

执行步骤分析:

第1步:同步任务执行

  1. console.log('1. 同步任务开始') 压入调用栈,立即执行,输出 1
  2. setTimeout 压入调用栈,Web API 开始计时(0ms),回调函数放入宏任务队列
  3. Promise.resolve().then() 压入调用栈,.then() 的回调函数放入微任务队列
  4. console.log('4. 同步任务结束') 压入调用栈,立即执行,输出 4

此时状态:

  • 调用栈:空
  • 微任务队列[Promise.then回调]
  • 宏任务队列[setTimeout回调]

第2步:事件循环检查

  1. 调用栈为空,事件循环开始工作
  2. 优先检查微任务队列,发现有一个任务
  3. 执行微任务:console.log('3. Promise.then 回调'),输出 3
  4. 微任务队列清空

第3步:继续事件循环

  1. 微任务队列为空,现在检查宏任务队列
  2. 执行宏任务:setTimeout 回调,输出 2
  3. 宏任务队列清空

最终输出顺序:1 4 3 2

console.log('script start') 
async function async1() { 
   await async2() 
   console.log('async1 end')
} 
async function async2() { 
   console.log('async2 end') 
} 
async1() 
setTimeout(function() { 
   console.log('setTimeout') 
}, 0)
new Promise(resolve =>{
  console.log('Promise')
  resolve()
}).then(function(){
  console.log('Promise1')
})

关键概念:async/await

  • async 函数总是返回一个 Promise
  • await 会暂停 async 函数的执行,直到 Promise 解决
  • await 后面的代码相当于放在 .then() 中,属于微任务

执行步骤分析:

第1步:同步任务执行

  1. console.log('script start') → 输出 script start

  2. 定义函数 async1 和 async2(不执行)

  3. 调用 async1()

    • 进入 async1,遇到 await async2()
    • 调用 async2() → console.log('async2 end') → 输出 async2 end
    • await 暂停执行,console.log('async1 end') 被包装成微任务放入微任务队列
  4. setTimeout → 回调函数放入宏任务队列

  5. 执行 new Promise

    • console.log('Promise') 是同步代码 → 输出 Promise
    • resolve() 执行,.then() 的回调放入微任务队列

此时状态:

  • 调用栈:空
  • 微任务队列[async1 end, Promise1](注意顺序!)
  • 宏任务队列[setTimeout回调]

第2步:事件循环检查微任务

  1. 调用栈为空,执行微任务

  2. 按入队顺序执行微任务:

    • 第一个微任务:console.log('async1 end') → 输出 async1 end
    • 第二个微任务:console.log('Promise1') → 输出 Promise1
  3. 微任务队列清空

第3步:执行宏任务

  1. 执行 setTimeout 回调 → 输出 setTimeout

最终输出顺序:script start → async2 end → Promise → async1 end → Promise1 → setTimeout

那么到此,应该是可以理解到事件循环的感觉了,那接下来我们就开始看看事件循环的完整逻辑

1. 任务分类

宏任务(Macrotasks)

  • setTimeoutsetInterval
  • setImmediate(Node.js)
  • requestAnimationFrame(浏览器)
  • I/O 操作
  • UI 渲染(浏览器)
  • 主线程的 script 标签内容

微任务(Microtasks)

  • Promise.then().catch().finally()
  • process.nextTick()(Node.js,优先级最高)
  • MutationObserver(浏览器)
  • queueMicrotask()
  • async/await 的后续代码

2. 事件循环执行顺序

1. 执行一个宏任务(script标签内容)
2. 执行过程中遇到异步任务:
   - 宏任务 → 放入宏任务队列
   - 微任务 → 放入微任务队列
3. 当前宏任务执行完毕
4. 检查微任务队列,依次执行所有微任务
5. 如有必要,进行UI渲染
6. 从宏任务队列取出下一个宏任务执行
7. 回到步骤3,形成循环

3. 重要规则

规则1:微任务优先

  • 每执行完一个宏任务,都要清空所有微任务
  • 微任务执行期间产生的新微任务会加入当前队列,并在本次循环中执行

规则2:async/await 转化

javascript

async function example() {
  await foo()        // 相当于 Promise.resolve(foo()).then(...)
  console.log('A')   // 这部分在微任务队列中
}

规则3:多个任务队列

  • 宏任务可能有多个来源(定时器、I/O等),有各自的队列
  • 微任务只有一个队列,按入队顺序执行

在举例理解一下

javascript

// 测试微任务嵌套
Promise.resolve().then(() => {
    console.log('微任务1');
    Promise.resolve().then(() => {
        console.log('微任务中的微任务');
    });
}).then(() => {
    console.log('微任务2');
});

// 输出顺序:微任务1 → 微任务中的微任务 → 微任务2

javascript

// 测试多个宏任务
setTimeout(() => console.log('宏任务1'), 10);
Promise.resolve().then(() => console.log('微任务1'));
setTimeout(() => {
    console.log('宏任务2');
    Promise.resolve().then(() => console.log('宏任务2中的微任务'));
}, 1);
Promise.resolve().then(() => console.log('微任务2'));
setTimeout(() => {
    console.log('宏任务3'); 
    Promise.resolve().then(() => console.log('宏任务3中的微任务')); 
}, 0);

结果:
微任务1
微任务2
宏任务3
宏任务3中的微任务
宏任务1
宏任务2
宏任务2中的微任务
为什么呢?聪明的你已经会了
一开始 微任务 放入 微任务1, 微任务2;然后宏任务放入 宏任务3
这个时候, 计时器还没有到底100ms的时候, 打印微任务1、微任务2;然后微任务清空,开始 宏任务3, 放入微任务 宏任务3中的微任务,然后打印宏任务3中的微任务; 然后计时器到了,打印宏任务1、宏任务2
、放入微任务, 打印宏任务2中的微任务;大概就是这种感觉

总结

  1. 同步任务立即执行
  2. 微任务宏任务优先级高
  3. 每个宏任务执行后,都要清空所有微任务
  4. async/await 本质是 Promise 的语法糖,await 后面的代码是微任务
  5. 事件循环确保了 JavaScript 的单线程能够处理异步操作

Vite 8 发布 beta 版本了,升级体验一下 Rolldown

作者 Airene
2025年12月14日 00:56

Vite 8 发布 beta 版本了,升级体验一下 Rolldown

编译时间

当前是用 beta.2 版本,因为测试项目非常的小(真实项目),编译时间是从大概 260ms 到 80ms 的量级 🐶,这可能是个不公平的对比,因为发现 rolldown 第一次也是 260+ms,然后原地再编译才是前面的结果(有cache?),也就是 rollup 始终差不多的速度不区分第一次,再次编译 rolldown 会快, 区分是不是第一次。

vite.config 的变化

我有一个特殊的需求,就是每个页面都非常小,而且页面不多,希望是不要生成那么多 js 文件,所以之前的做法是 rollup 的选项。

rollupOptions: {
    output: {
        manualChunks(id) {
            if (id.includes('pages')) {
                return 'pages'
            }
        }
    }
}
// 之前的结果 vite7 rollup
// dist/index.html                  0.45 kB │ gzip:  0.30 kB
// dist/assets/style-D2W9t87U.css   5.50 kB │ gzip:  1.58 kB
// dist/assets/index-D5uKrQyu.js   28.56 kB │ gzip: 11.05 kB
// dist/assets/pages-BL4EWsDv.js   83.42 kB │ gzip: 32.14 kB

rolldown 没有这个概念,manualChunks 对等的是 advancedChunks,折腾半天 advancedChunks 达不到想要的效果(可能是针对 node_modules 比较强,可能是没找对方法),找了半天找到一个方法可以做到,这个做法对我的项目有用,但是大项目应该不行,所有项目文件都生成一个文件了。

rolldownOptions: {
    output: {
        inlineDynamicImports: true
    }
}
// rolldown 的输出内容多了一个 rolldown-runtime
// dist/index.html                           0.54 kB │ gzip:  0.31 kB
// dist/assets/style-Bac8aBaN.css            5.46 kB │ gzip:  1.56 kB
// dist/assets/rolldown-runtime-BcdYIZKG.js  0.19 kB │ gzip:  0.17 kB
// dist/assets/index-pOi5csA0.js             25.03 kB │ gzip:  9.38 kB
// dist/assets/vendor-p3Su3_y3.js            85.17 kB │ gzip: 33.12 kB

不加上面选项的结果

// 文件很多 vite 8 & rolldown, vite 7 类似
dist/index.html                     0.45 kB │ gzip:  0.29 kB
dist/assets/style-Bac8aBaN.css      5.46 kB │ gzip:  1.56 kB
dist/assets/not-found-DAToitwy.js   0.12 kB │ gzip:  0.13 kB
dist/assets/api-Br4_g19J.js         0.42 kB │ gzip:  0.17 kB
dist/assets/home-BSTox8wn.js        0.59 kB │ gzip:  0.38 kB
dist/assets/things-BUk3p1b5.js      0.83 kB │ gzip:  0.53 kB
dist/assets/eol-WmdroNCa.js         1.88 kB │ gzip:  0.97 kB
dist/assets/angling-Bh7xqOUY.js     1.92 kB │ gzip:  0.78 kB
dist/assets/fin-5dWvAvtB.js         1.97 kB │ gzip:  0.87 kB
dist/assets/genuine-DKX79-2_.js     2.40 kB │ gzip:  1.01 kB
dist/assets/frame-main-CGg78mi8.js  2.41 kB │ gzip:  1.27 kB
dist/assets/pc-hLrRv_Pv.js          2.55 kB │ gzip:  0.68 kB
dist/assets/vers-COZW8I_8.js        2.90 kB │ gzip:  1.15 kB
dist/assets/index-CbFw2-HB.js       3.76 kB │ gzip:  1.60 kB
dist/assets/about-BcSEGuXl.js       4.18 kB │ gzip:  2.39 kB
dist/assets/vendor-BMuo1oit.js      84.70 kB │ gzip: 32.75 kB

结论

  • 只依赖 vue, vue-router 的项目没问题,生产可用。
  • rolldown 的 node_modules 大小居然比 vite7 的 esbuild 和 rollup 的还大了一点 47.5MB > 35.2MB 🥴
"dependencies": {
    "vue": "^3.5.25",
    "vue-router": "^4.6.4"
},
"devDependencies": {
    "@vitejs/plugin-vue": "^6.0.3",
    "vite": "^8.0.0-beta.2"
}

Vue 3 做 todos , ref 能看懂,computed 终于也懂了

作者 T___T
2025年12月14日 00:06

刚开始学 Vue 3,看到 refcomputedv-model 就有点晕乎乎。

为了练手,我抄着教程写了一个超简单的待办清单

结果写着写着,发现这个小玩具刚好能把我最不理解的那个家伙——computed——讲清楚。
下面就用我的视角,拆一下这个小 demo,到底在干嘛,以及 computed 为什么值得单拿出来说。

响应式数据:ref 开局

核心状态有两个:

const title = ref('');
const todos = ref([
  { id: 1, title: '打王者', done: true },
  { id: 2, title: '吃饭',   done: true }
]);
  • title
    输入框当前内容,双向绑定在输入框上。
  • todos
    一个数组,每一项是一个待办对象:idtitledone

在模板里通过 v-modelv-forv-if 等就能把这些数据“长”成界面。


模板怎么把数据“长”出来?

几个关键点:

  • 输入框双向绑定

    <input type="text" v-model="title" @keydown.enter="addTodo">
    
    • v-model="title":输入的内容自动同步到 title
    • keydown.enter="addTodo":按下回车,就调用 addTodo 新增待办。
  • 列表循环 + 勾选状态

    <ul v-if="todos.length">
      <li v-for="todo in todos" :key="todo.id">
        <input type="checkbox" v-model="todo.done">
        <span :class="{ done: todo.done }">{{ todo.title }}</span>
      </li>
    </ul>
    <div v-else>暂无待办事项</div>
    
    • v-for 负责把数组“摊开”成一个个 li
    • 每条的复选框用 v-model="todo.done",直接双向绑定完成状态。
    • :class="{ done: todo.done }" 决定要不要加上 .done 这个类,实现中划线 + 灰色。

样式就是很简单的:

.done {
  text-decoration: line-through;
  color: gray;
}

新增待办:一个小小的 addTodo

const addTodo = () => {
  if (!title.value) return;
  todos.value.push({
    id: Math.random(),
    title: title.value,
    done: false
  });
  title.value = '';
};
  • 为空就不加:简单的校验。
  • 往 todos 里 push 新对象:新待办默认 done: false
  • 清空输入框:体验自然一点。

这里用的是最基础的响应式数组操作:修改 todos.value,界面自然会跟着更新。

真正的主角:computed 计算未完成数量

来看统计那一行:

{{ active }} / {{ todos.length }}

前面那个 active,就是一个计算属性:

const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length;
});

这行代码,核心逻辑其实就一句话:

把还没完成的待办筛出来,数一数有多少条。

你可能会问:
“那我为啥不干脆在模板里直接写呢,比如:

{{ todos.filter(todo => !todo.done).length }} / {{ todos.length }}

能不能这么写?当然可以。
但计算属性有几个很实际的好处。

computed 有什么好处?

1. 它是“派生数据”的家

像“未完成数量”这种数据:

  • 不需要自己单独存一份;
  • 完全可以根据 todos 推导出来。

这种就叫派生数据
computed 天生就是为它们准备的:

const active = computed(() => {
  return todos.value.filter(todo => !todo.done).length;
});

好处是:

  • 代码一眼就能看出:active 是“依据 todos 计算得来”的结果。
  • 模板里看到 {{ active }},基本就能猜到意思,不会被一长串过滤逻辑干扰。

2. 自带缓存:只在需要的时候重新算

模板里的表达式,每次渲染都会重新执行。

也就是说,如果写成:

{{ todos.filter(todo => !todo.done).length }}

只要组件重新渲染(不管是不是 because todos 变了),它就会再跑一次 filter

而 computed 则不一样:

  • 它会自动追踪依赖todos 以及每个 todo.done
  • 只有当这些依赖发生变化时,active 才会重新计算。
  • 其他不相干的响应式数据(比如 title)变了,并不会让它重算。

在这个小例子里,列表很短,差异你感觉不到。
但在真实项目里:

  • todos 很大;
  • 统计里用到的逻辑复杂;
  • 或者同一个统计在多个地方用到;

这时候 computed 的缓存机制,就能明显减少不必要的重复计算。

3. 模板更干净,逻辑集中在 JS 里

模板里写太多逻辑,阅读成本会明显升高。
想象一下,如果有好几个统计项都长这样:

{{ todos.filter(t => !t.done && t.priority === 'high').length }}

项目一大,很快你就会讨厌在模板里翻来翻去的复杂表达式。

把逻辑抽到 computed 里:

const activeHighPriority = computed(() =>
  todos.value.filter(t => !t.done && t.priority === 'high').length
);

模板里只保留结果:

{{ activeHighPriority }}
  • 模板更像“结构 + 文案”;
  • 逻辑都待在 JS 里,改起来更顺手,也好测试。

4. 复用方便

如果你有多个地方都要用到“未完成数量”,
用模板表达式的话,要把 todos.filter(...).length 复制来复制去。

computed 则只用定义一次:

const active = computed(...);

模板任何地方都可以直接:

{{ active }}

以后改规则(比如不统计某些类型的待办)也只需要改一处逻辑。

computed 的进阶用法:get / set 做“全选”

这个例子里还有一个更高级一点的用法:**带 **

get / set 的计算属性,用来实现“全选”:

const allDone = computed({
  get() {
    return todos.value.every(todo => todo.done);
  },
  set(value) {
    todos.value.forEach(todo => {
      todo.done = value;
    });
  }
});

再配合模板:

全选<input type="checkbox" v-model="allDone">

这里发生了几件很有意思的事:


  • get:从数据推导视图

    • 每当界面需要知道“当前是不是全选状态”,就会调用 get()。
    • every(todo => todo.done) 判断是不是所有都完成。
    • 如果全部完成,allDone 为 true,全选框就被勾上。

  • set:从视图反推数据

    • 当你点击“全选”复选框时,因为用了 v-model="allDone",会触发 set(value)。
    • value 是你勾选后的新值(true / false)。
    • set 里把每一条 todo.done 全部改成这个值。

这种写法的妙处在于:

  • 模板里看起来就像在绑一个普通的布尔值;

  • 实际上背后是一个可以双向联动的“计算属性”:

    • 列表状态决定“全选”的勾选;
    • “全选”的勾选又能反过来更新列表状态。

这也是 computed 非常有魅力的一面:**不只是“算结果”,还可以通过 **

set 去“驱动数据变化”

小结:一个小待办里,装着 Vue 的几个核心习惯

这个小例子里,其实就体现了几个很值得养成的编码习惯:

  • 状态集中在 ref / 响应式对象里管理
    titletodos 这样一眼明了。
  • 模板只做轻量逻辑,复杂逻辑交给 computed / 函数
    未完成数量用 computed,而不是长长的一串模板表达式。
  • 把“派生数据”都塞进 computed
    既清晰又有缓存,量一大就知道好处。
  • 用带 get/set 的 computed 实现更自然的双向绑定
    比如“全选”这种,同时依赖和影响其他状态的字段。

每日一题-分隔长廊的方案数🔴

2025年12月14日 00:00

在一个图书馆的长廊里,有一些座位和装饰植物排成一列。给你一个下标从 0 开始,长度为 n 的字符串 corridor ,它包含字母 'S' 和 'P' ,其中每个 'S' 表示一个座位,每个 'P' 表示一株植物。

在下标 0 的左边和下标 n - 1 的右边 已经 分别各放了一个屏风。你还需要额外放置一些屏风。每一个位置 i - 1 和 i 之间(1 <= i <= n - 1),至多能放一个屏风。

请你将走廊用屏风划分为若干段,且每一段内都 恰好有两个座位 ,而每一段内植物的数目没有要求。可能有多种划分方案,如果两个方案中有任何一个屏风的位置不同,那么它们被视为 不同 方案。

请你返回划分走廊的方案数。由于答案可能很大,请你返回它对 109 + 7 取余 的结果。如果没有任何方案,请返回 0 。

 

示例 1:

输入:corridor = "SSPPSPS"
输出:3
解释:总共有 3 种不同分隔走廊的方案。
上图中黑色的竖线表示已经放置好的屏风。
上图每种方案中,每一段都恰好有 两个 座位。

示例 2:

输入:corridor = "PPSPSP"
输出:1
解释:只有 1 种分隔走廊的方案,就是不放置任何屏风。
放置任何的屏风都会导致有一段无法恰好有 2 个座位。

示例 3:

输入:corridor = "S"
输出:0
解释:没有任何方案,因为总是有一段无法恰好有 2 个座位。

 

提示:

  • n == corridor.length
  • 1 <= n <= 105
  • corridor[i] 要么是 'S' ,要么是 'P'

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(三)

2025年12月13日 23:54

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(三)

Flutter: 3.35.6

因为实现了单个的,给出github链接:github.com/yhtqw/Front…

前面我们简单实现了元素的平移和缩放,接下来我们继续实现旋转功能。

元素的旋转会改变角度,角度一变,那么响应事件的热区也会跟着改变,所以我们得提前考虑这些会因为角度改变而改变的地方。

先来简单实现一下旋转,先不考虑上述的热区问题。

要实现旋转,我们就得知道元素的旋转角度,主要得出旋转的角度,那么实现起来就比较简单,所以简单使用数学的知识分析一下吧

从我们这个需求中可以提取到的数据为按下点的坐标,拖动时变换的坐标;所以我们能否根据一个点的坐标,计算出该点与某点形成的夹角,好像刚好有个满足部分,就是arctan2,arctan2的主要作用是根据一个点的坐标,计算出该点与坐标原点所形成的夹角(主要作用);如果我们要知道给出点与任意(x', y')形成的夹角呢?前面的arctan2中将坐标原点换成任意某点不就行了?

使用 arctan2 计算两点连线的角度,核心是计算两点之间的坐标差 (Δx, Δy),然后将其作为 arctan2 的参数。所以实现起来就比较简单了。其实这个实现的原理在很多地方都一样,例如web端的元素拖动旋转也可以使用这个原理。实现的方式应该不止一种吧,只要能计算出这个角度就行了。

值得注意的是,现在我们研究的是单个元素,所以坐标系就是以元素自身形成的(响应的事件也是在这个元素上),等后期要实现多个,坐标系就得以外层容器作为参考了。

// 其他省略...

/// 新增旋转状态热区字符串
const String statusRotate = 'rotate';

/// 抽取响应旋转操作区域的大小
final double rotateWidth = 20;
final double rotateHeight = 20;

/// 旋转角度
double rotateNumber = 0;
double initRotateNumber = 0;

void _onPanUpdate(DragUpdateDetails details) {
  print('更新: $details');
  if (status == statusMove) {
    _onMove(details.localPosition.dx, details.localPosition.dy);
  } else if (status == statusScale) {
    _onScale(details.delta.dx, details.delta.dy);
  } else if (status == statusRotate) {
    // 新增旋转热区的响应事件
    _onRotate(details.localPosition.dx, details.localPosition.dy);
  }
}

void _onPanEnd() {
  print('抬起或者因为某些原因并没有触发onPanDown事件');
  setState(() {
    // 当次结束后重新记录,也可以在按下时记录
    initX = x;
    initY = y;
    // 新增旋转角度的记录
    initRotateNumber = rotateNumber;
  });
}

/// 处理旋转
void _onRotate(double dx, double dy) {
  /// 要计算点 (x, y) 与任意点 (x', y') 连线所成的角度,可以使用 arctan2 函数。
  /// 关键在于将两点之间的相对坐标差作为 arctan2 的输入参数。
  /// 这里我们以元素的中心为旋转中心
  /// 利用上述方法计算起始点(按下时)与中心的连线组成的夹角为初始夹角,
  /// 拖动的点与中心点连线组层的夹角为结束时的夹角,
  /// 通过初始夹角与结束夹角计算旋转的角度

  // 确定旋转中心,因为这里的拖动是单个元素,坐标都是相对于元素自身形成的坐标系,所以坐标中心始终都是元素的中心
  double centerX = elementWidth / 2;
  double centerY = elementHeight / 2;

  double diffStartX = startPosition.dx - centerX;
  double diffStartY = startPosition.dy - centerY;
  double diffEndX = dx - centerX;
  double diffEndY = dy - centerY;
  double angleStart = atan2(diffStartY, diffStartX);
  double angleEnd = atan2(diffEndY, diffEndX);

  setState(() {
    rotateNumber = initRotateNumber + angleEnd - angleStart;
  });
}

/// 判断点击在什么区域
String? _onDownZone(double x, double y) {
  if (
    x >= elementWidth - scaleWidth &&
    x <= elementWidth &&
    y >= elementHeight - scaleHeight &&
    y <= elementHeight
  ) {
    return statusScale;
  } else if (
    x >= elementWidth - rotateHeight &&
    x <= elementWidth &&
    y >= 0 &&
    y <= rotateHeight
  ) {
    // 固定右上角为旋转热区
    return statusRotate;
  } else if (
    x >= 0 &&
    x <= elementWidth &&
    y >= 0 &&
    y <= elementHeight
  ) {
    return statusMove;
  }

  return null;
}

// 新增响应旋转操作
Positioned(
  left: x,
  top: y,
  child: Transform.rotate(
    angle: rotateNumber,
    child: GestureDetector(
      onPanDown: _onPanDown,
      onPanUpdate: _onPanUpdate,
      onPanEnd: (details) => _onPanEnd(),
      onPanCancel: _onPanEnd,
      child: Container(
        width: elementWidth,
        height: elementHeight,
        color: Colors.transparent,
        child: Stack(
          alignment: Alignment.center,
          clipBehavior: Clip.none,
          children: [
            Container(
              width: elementWidth,
              height: elementHeight,
              color: Colors.amber,
            ),

            // 响应旋转操作
            Positioned(
              top: 0,
              right: 0,
              child: Container(
                width: scaleWidth,
                height: scaleHeight,
                color: Colors.white,
              ),
            ),

            // 响应缩放操作
          ],
        ),
      ),
    ),
  ),
),

// 其他省略...

运行效果:

image01.gif

这样就简单实现了旋转。然后我们继续考虑热区的问题,当旋转一定角度的时候,再次点击对应的热区,就无法响应事件了,因为旋转后热区坐标已经发生改变,所以我们得对点击判断中加入角度的影响。

已知某点坐标和旋转角度,求旋转后的坐标值?

要计算旋转后的坐标,可以使用旋转矩阵。给定一个点 (x, y) 绕原点逆时针旋转角度 θ 后的新坐标 (x', y') 计算公式如下:

x' = x * cosθ - y * sinθ; y' = x * sinθ + y * cosθ;

如果我们是绕任意点而不是原点,需要先平移坐标系

  1. 平移: 将 (x, y) 平移到原点,新坐标为 (x - a, y - b);
  2. 旋转: 按照上述公式计算 (x', y');
  3. 平移回原坐标系: 新坐标为(x' + a, y' + b)。

基于上面的公式,我们更改热区点击判断方法:

/// 判断点击在什么区域
String? _onDownZone(double x, double y) {
  final offsetScale = rotatePoint(elementWidth, elementHeight);
  // 设置都是最大的顶点坐标,方便下面判断区域的方式结构一致
  // 后续就好抽取方法
  final offsetRotate = rotatePoint(elementWidth, rotateHeight);

  if (
    x >= offsetScale.dx - scaleWidth &&
    x <= offsetScale.dx &&
    y >= offsetScale.dy - scaleHeight &&
    y <= offsetScale.dy
  ) {
    return statusScale;
  } else if (
    x >= offsetRotate.dx - rotateHeight &&
    x <= offsetRotate.dx &&
    y >= offsetRotate.dy - rotateHeight &&
    y <= offsetRotate.dy
  ) {
    return statusRotate;
  } else if (
    x >= 0 &&
    x <= elementWidth &&
    y >= 0 &&
    y <= elementHeight
  ) {
    return statusMove;
  }

  return null;
}

/// 计算旋转后的点坐标
Offset rotatePoint(double x, double y) {
  final deg = rotateNumber * pi / 180;
  // 确定旋转中心,因为这里的拖动是单个元素,坐标都是相对于元素自身形成的坐标系,所以坐标中心始终都是元素的中心
  final centerX = elementWidth / 2;
  final centerY = elementHeight / 2;
  final diffX = x - centerX;
  final diffY = y - centerY;

  final dx = diffX * cos(deg) - diffY * sin(deg) + centerX;
  final dy = diffX * sin(deg) + diffY * cos(deg) + centerY;
  return Offset(dx, dy);
}

image02.gif

可以看到的是旋转和缩放热区即使在旋转后依然能够正常响应,还有最后一点,就是移动的时候也要应用旋转角度计算,因为我们使用的是元素自身为坐标系,坐标系旋转了,自然移动时的计算方式也得跟着变,其实对于后期将事件应用到容器上了过后就不需要考虑这些了,因为外层容器并不会变换,所以后期不使用逆运算,所以我们这里直接使用globalPosition来计算值即可(变换计算坐标感兴趣的可以自行研究一下):

void _onPanDown(DragDownDetails details) {
  print('按下: $details');

  String? tempStatus = _onDownZone(details.localPosition.dx, details.localPosition.dy);

  print(tempStatus);

  setState(() {
    if (tempStatus == statusMove) {
      // 如果是移动,则使用globalPosition
      startPosition = details.globalPosition;
    } else {
      startPosition = details.localPosition;
    }
    status = tempStatus;
  });
}

void _onPanUpdate(DragUpdateDetails details) {
  print('更新: $details');
  if (status == statusMove) {
    _onMove(details.globalPosition.dx, details.globalPosition.dy);
  } else if (status == statusScale) {
    _onScale(details.delta.dx, details.delta.dy);
  } else if (status == statusRotate) {
    _onRotate(details.localPosition.dx, details.localPosition.dy);
  }
}

image03.gif

这样就对单个元素实现了变换的效果,前置就算时铺垫完成了,后续就开始实现多个的。

感兴趣的也可以关注我的微信公众号【前端学习小营地】,不定时会分享一些小功能~

今天的分享到此结束,感谢阅读~拜拜~

深入理解 useTransition:React 并发渲染的性能优化利器

作者 bytemanx
2025年12月13日 22:14

引言

React 16 引入了 Fiber 架构,这是 React 核心算法的重构。Fiber 把渲染工作拆成多个小的工作单元(fiber 节点),每个工作单元可以独立执行、暂停和恢复。这种可中断的渲染机制让 React 能更好地控制渲染时机,为后续的并发特性打下了基础。关于 Fiber 架构的详细原理,可以参考这篇文章

基于 Fiber 架构,React 18 引入了并发特性(Concurrent Features),这是 React 历史上最重要的架构升级之一。并发渲染让 React 能在渲染过程中中断和恢复工作,从而保持用户界面的响应性。useTransition Hook 正是这一特性的核心 API 之一,它允许我们把某些状态更新标记为"非紧急",让 React 优先处理更重要的更新(比如用户输入),从而显著提升用户体验。

在本文中,我们会通过一个实际的演示案例,深入对比三种不同的更新策略:同步更新防抖更新并发更新(useTransition),然后从 React 源码层面解析 useTransition 的实现原理,帮你全面理解这个强大的性能优化工具。

交互式演示

在深入技术细节之前,我们先通过一个交互式演示来直观感受三种策略的差异:

在这个演示里,你可以:

  1. 在输入框里输入文字,输入越长,图表渲染的数据点越多
  2. 切换三种不同的更新策略(Synchronous、Debounced、Concurrent)
  3. 观察右上角的时钟动画,它是检测 UI 是否卡顿的"晴雨表"

性能对比演示

我们通过三个 GIF 动图来直观对比三种策略的表现:

1. 同步更新(Synchronous)

synchronous.gif

重要说明:图中显示的输入暂停是页面卡顿导致的结果,不是用户主动停止输入。当用户持续输入时,由于同步渲染阻塞了主线程,导致输入框无法及时响应。右上角时钟动画的明显卡顿证明了主线程被渲染任务完全阻塞。这是同步更新的最大问题:即使输入响应即时,但渲染会阻塞用户交互。

特点

  • ✅ 输入响应即时,无延迟
  • ❌ 每次输入都会立即触发完整渲染
  • ❌ 当数据量大时,时钟动画会明显卡顿
  • ❌ 用户输入可能被阻塞,体验不流畅

适用场景:数据量小、渲染简单的场景

2. 防抖更新(Debounced)

debounce.gif

重要说明:图中显示的等待期间是防抖延迟机制的表现(固定1000ms延迟)。防抖的延迟太大,用户输入后需要等1秒才能看到结果更新。虽然避免了频繁渲染,但固定的延迟时间影响了用户体验。用户无法及时看到输入反馈,需要等防抖时间结束。

特点

  • ✅ 减少渲染次数,避免频繁更新
  • ✅ 等待用户停止输入后才更新
  • ❌ 有固定的延迟(1000ms),用户需要等待
  • ❌ 时钟动画在等待期间可能不流畅
  • ❌ 无法利用 React 的并发特性

适用场景:需要减少 API 调用或计算次数的场景

3. 并发更新(Concurrent / useTransition)

concurrent.gif

重要说明:图中显示的流畅表现是并发渲染的效果。并发模式可以及时响应用户输入,无需等待延迟,输入框立即响应。即使在大数据量渲染时,时钟动画始终保持流畅,证明主线程未被阻塞。渲染过程可以被中断,优先处理用户输入,然后继续完成渲染任务。这是并发更新的核心优势:既保证了输入响应性,又完成了复杂渲染。

特点

  • ✅ 输入响应即时,无延迟
  • ✅ 渲染过程可中断,保持 UI 响应性
  • ✅ 时钟动画始终保持流畅
  • ✅ 自动平衡输入响应和渲染性能
  • ✅ 利用 React 18 的并发特性

适用场景:需要保持 UI 响应性的复杂渲染场景

演示代码解析

这个演示应用基于 React 官方的 time-slicing fixture,展示了三种不同的状态更新策略。我们来看看关键代码实现:

三种更新策略

function AppContent({ complexity }: AppProps) {
  const [value, setValue] = useState('');
  const [strategy, setStrategy] = useState<Strategy>('sync');
  const [isPending, startTransition] = useTransition();

  // 防抖处理函数
  const debouncedSetValue = useMemo(
    () =>
      debounce((newValue: string) => {
        setValue(newValue);
      }, 1000),
    []
  );

  const handleChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      const newValue = e.target.value;
      switch (strategy) {
        case 'sync':
          // 策略 1: 同步更新 - 立即触发渲染
          setValue(newValue);
          break;
        case 'debounced':
          // 策略 2: 防抖更新 - 延迟 1000ms 后更新
          debouncedSetValue(newValue);
          break;
        case 'async':
          // 策略 3: 并发更新 - 使用 useTransition
          startTransition(() => {
            setValue(newValue);
          });
          break;
      }
    },
    [strategy, debouncedSetValue, startTransition]
  );

  const data = getStreamData(value, complexity);
  // ...
}

useTransition 使用详解

在上面的代码里,我们看到了 useTransition 的基本使用。我们详细解析一下:

1. useTransition 的基本用法

useTransition 是一个 Hook,它返回一个包含两个元素的数组:

const [isPending, startTransition] = useTransition();
  • isPending:一个布尔值,表示当前有没有正在进行的 transition 更新。当 startTransition 里的更新正在处理时,isPendingtrue;更新完成后变为 false
  • startTransition:一个函数,用来把状态更新标记为"非紧急"的 transition 更新。

2. startTransition 的使用方式

startTransition 接收一个回调函数,在这个回调函数里执行的状态更新会被标记为低优先级:

case 'async':
  // 策略 3: 并发更新 - 使用 useTransition
  startTransition(() => {
    setValue(newValue);
  });
  break;

关键点

  • 所有在 startTransition 回调里调用的 setState 都会被标记为 transition 更新
  • 可以同时更新多个状态:
    startTransition(() => {
      setValue(newValue);
      setFilteredResults(filterResults(newValue));
      setSearchHistory(prev => [...prev, newValue]);
    });
    
  • startTransition 是同步执行的,但里面的状态更新会被异步处理
  • 不要在 startTransition 里执行副作用(比如 API 调用、DOM 操作等),只用来更新状态

3. isPending 的实际应用

isPending 可以用来向用户提供视觉反馈,表示应用正在处理 transition 更新。在演示代码里,我们用 isPending 来改变输入框的透明度:

<input
  className={`p-3 sm:p-4 text-xl sm:text-3xl w-full block bg-white text-black rounded ${inputColorClass} ${
    isPending ? 'opacity-70' : ''
  }`}
  placeholder="longer input → more components"
  onChange={handleChange}
/>

isPendingtrue 时,输入框会变成半透明(opacity-70),给用户一个视觉提示,表明后台正在处理更新。

关键组件说明

1. Charts 组件 用 Victory 图表库渲染大量数据点。根据输入长度动态生成数据复杂度:

function getStreamData(input: string, complexity: number): StreamData {
  const cacheKey = `${input}-${complexity}`;
  if (cachedData.has(cacheKey)) {
    return cachedData.get(cacheKey)!;
  }
  const multiplier = input.length !== 0 ? input.length : 1;
  const data = range(5).map(() =>
    range(complexity * multiplier).map((j: number) => ({
      x: j,
      y: random(0, 255),
    }))
  );
  cachedData.set(cacheKey, data);
  return data;
}

2. Clock 组件 一个实时 SVG 动画时钟,用来检测 UI 是否卡顿。如果主线程被阻塞,时钟动画会明显掉帧。

3. 数据生成策略

  • 输入长度越长,生成的数据点越多
  • 用缓存机制避免重复计算
  • 复杂度参数控制基础数据量

React 源码深度解析

我们用了很多次 useTransition,也看到了它的效果。现在来看看 React 源码里它是怎么实现的。

useTransition 入口函数

useTransition 的入口在 packages/react/src/ReactHooks.js 文件中:

export function useTransition(): [
  boolean,
  (callback: () => void, options?: StartTransitionOptions) => void,
] {
  const dispatcher = resolveDispatcher();
  return dispatcher.useTransition();
}

很简单,就是通过 resolveDispatcher() 拿到 dispatcher,然后调用 dispatcher.useTransition()

这个 dispatcher 是什么呢?React 会根据当前是首次渲染还是更新渲染,给你不同的 dispatcher。首次渲染的时候,dispatcher 里的 useTransition 会调用 mountTransition;更新渲染的时候,会调用 updateTransition

有同学说,为什么要这样设计?因为 React 需要区分首次渲染和更新渲染。首次渲染的时候,需要创建新的 Hook 对象;更新渲染的时候,需要复用之前的 Hook 对象。

mountTransition:首次渲染

组件首次渲染时,会调用 mountTransition。我们来看看它的实现:

packages/react-reconciler/src/ReactFiberHooks.js 中:

function mountTransition(): [
  boolean,
  (callback: () => void, options?: StartTransitionOptions) => void,
] {
  const stateHook = mountStateImpl((false: Thenable<boolean> | boolean));
  // The `start` method never changes.
  const start = startTransition.bind(
    null,
    currentlyRenderingFiber,
    stateHook.queue,
    true,
    false,
  );
  const hook = mountWorkInProgressHook();
  hook.memoizedState = start;
  return [false, start];
}

这段代码做了几件事:

  1. mountStateImpl 创建一个内部状态,初始值是 false。这个状态用来表示当前是不是 pending 状态。
  2. 通过 bind 创建一个 start 函数,绑定了当前 fiber 和 state queue。这个 start 函数就是 startTransition,但已经绑定了必要的上下文。
  3. start 函数存到 hook 的 memoizedState 里,这样下次更新的时候还能拿到同一个函数。
  4. 返回 [false, start],初始状态不是 pending。

updateTransition:更新渲染

组件更新渲染时,会调用 updateTransition

packages/react-reconciler/src/ReactFiberHooks.js 中:

function updateTransition(): [
  boolean,
  (callback: () => void, options?: StartTransitionOptions) => void,
] {
  const [booleanOrThenable] = updateState(false);
  const hook = updateWorkInProgressHook();
  const start = hook.memoizedState;
  const isPending =
    typeof booleanOrThenable === 'boolean'
      ? booleanOrThenable
      : // This will suspend until the async action scope has finished.
        useThenable(booleanOrThenable);
  return [isPending, start];
}

这里做了几件事:

  1. 调用 updateState(false) 获取当前的 pending 状态。这个状态在 startTransition 执行的时候会被更新。
  2. 从 hook 的 memoizedState 里拿到之前存的 start 函数。这个函数在首次渲染的时候就创建好了,之后不会变。
  3. 判断 isPending。如果状态是 boolean,直接返回;如果是 Promise(比如异步 action),就用 useThenable 等待它完成。
  4. 返回 [isPending, start]

体会到 useTransition 的设计了么?它本质上就是 useState + startTransition。用 useState 来管理 pending 状态,用 startTransition 来标记更新为低优先级。

startTransition 函数实现

startTransition 的实现在 packages/react/src/ReactStartTransition.js 文件中。这个函数的核心作用是设置一个全局的 transition 上下文,让 React 知道当前正在执行 transition 更新。

export function startTransition(
  scope: () => void,
  options?: StartTransitionOptions,
): void {
  const prevTransition = ReactSharedInternals.T;
  const currentTransition: Transition = ({}: any);
  if (enableViewTransition) {
    currentTransition.types =
      prevTransition !== null
        ? // If we're a nested transition, we should use the same set as the parent
          // since we're conceptually always joined into the same entangled transition.
          // In practice, this only matters if we add transition types in the inner
          // without setting state. In that case, the inner transition can finish
          // without waiting for the outer.
          prevTransition.types
        : null;
  }
  if (enableGestureTransition) {
    currentTransition.gesture = null;
  }
  if (enableTransitionTracing) {
    currentTransition.name =
      options !== undefined && options.name !== undefined ? options.name : null;
    currentTransition.startTime = -1; // TODO: This should read the timestamp.
  }
  if (__DEV__) {
    currentTransition._updatedFibers = new Set();
  }
  ReactSharedInternals.T = currentTransition;

  try {
    const returnValue = scope();
    const onStartTransitionFinish = ReactSharedInternals.S;
    if (onStartTransitionFinish !== null) {
      onStartTransitionFinish(currentTransition, returnValue);
    }
    if (
      typeof returnValue === 'object' &&
      returnValue !== null &&
      typeof returnValue.then === 'function'
    ) {
      if (__DEV__) {
        // Keep track of the number of async transitions still running so we can warn.
        ReactSharedInternals.asyncTransitions++;
        returnValue.then(releaseAsyncTransition, releaseAsyncTransition);
      }
      returnValue.then(noop, reportGlobalError);
    }
  } catch (error) {
    reportGlobalError(error);
  } finally {
    warnAboutTransitionSubscriptions(prevTransition, currentTransition);
    if (prevTransition !== null && currentTransition.types !== null) {
      // If we created a new types set in the inner transition, we transfer it to the parent
      // since they should share the same set. They're conceptually entangled.
      if (__DEV__) {
        if (
          prevTransition.types !== null &&
          prevTransition.types !== currentTransition.types
        ) {
          // Just assert that assumption holds that we're not overriding anything.
          console.error(
            'We expected inner Transitions to have transferred the outer types set and ' +
              'that you cannot add to the outer Transition while inside the inner.' +
              'This is a bug in React.',
          );
        }
      }
      prevTransition.types = currentTransition.types;
    }
    ReactSharedInternals.T = prevTransition;
  }
}

这段代码的逻辑很简单:

  1. 保存之前的 transition:先把当前的 ReactSharedInternals.T 存起来,因为可能已经有 transition 在运行了(支持嵌套)。
  2. 创建新的 transition 对象:初始化一个新的 transition 对象。如果是嵌套的 transition,会继承外层的 types。
  3. 设置全局 transition:把新创建的 transition 赋值给 ReactSharedInternals.T。这样,在 scope 函数里调用的 setState 就能知道当前在 transition 里了。
  4. 执行用户代码:在 try 块里执行你传入的 scope 函数。
  5. 处理异步返回值:如果 scope 返回的是 Promise,会做一些处理,比如在开发模式下跟踪异步 transition 的数量。
  6. 恢复状态:在 finally 块里恢复之前的 transition 状态。这样嵌套的 transition 就能正确恢复。

这个设计还支持嵌套 transition。比如你在一个 startTransition 里又调用了另一个 startTransition,内层的会继承外层的 types,它们会被当作同一个 transition 处理。

优先级调度机制

React 用 Lane 模型来管理更新的优先级。Lane 就是"车道"的意思,不同的更新走不同的车道,优先级高的车道可以先走。

当你在 startTransition 里调用 setState 的时候,React 怎么知道这个更新是低优先级的呢?我们来看看 requestUpdateLane 这个函数:

packages/react-reconciler/src/ReactFiberWorkLoop.js 中:

export function requestUpdateLane(fiber: Fiber): Lane {
  // Special cases
  const mode = fiber.mode;
  if (!disableLegacyMode && (mode & ConcurrentMode) === NoMode) {
    return (SyncLane: Lane);
  } else if (
    (executionContext & RenderContext) !== NoContext &&
    workInProgressRootRenderLanes !== NoLanes
  ) {
    // This is a render phase update. These are not officially supported. The
    // old behavior is to give this the same "thread" (lanes) as
    // whatever is currently rendering. So if you call `setState` on a component
    // that happens later in the same render, it will flush. Ideally, we want to
    // remove the special case and treat them as if they came from an
    // interleaved event. Regardless, this pattern is not officially supported.
    // This behavior is only a fallback. The flag only exists until we can roll
    // out the setState warning, since existing code might accidentally rely on
    // the current behavior.
    return pickArbitraryLane(workInProgressRootRenderLanes);
  }

  const transition = requestCurrentTransition();
  if (transition !== null) {
    if (enableGestureTransition) {
      if (transition.gesture) {
        throw new Error(
          'Cannot setState on regular state inside a startGestureTransition. ' +
            'Gestures can only update the useOptimistic() hook. There should be no ' +
            'side-effects associated with starting a Gesture until its Action is ' +
            'invoked. Move side-effects to the Action instead.',
        );
      }
    }
    if (__DEV__) {
      if (!transition._updatedFibers) {
        transition._updatedFibers = new Set();
      }
      transition._updatedFibers.add(fiber);
    }

    return requestTransitionLane(transition);
  }

  return eventPriorityToLane(resolveUpdatePriority());
}

这个函数的工作流程很简单:

  1. 先检查 fiber 的模式,如果不是并发模式,直接返回同步 lane。
  2. 检查是不是在渲染阶段更新(这个不推荐,但 React 还是支持了)。
  3. 关键步骤:调用 requestCurrentTransition() 检查当前有没有 active transition。这个函数会去读 ReactSharedInternals.T,也就是 startTransition 设置的那个全局变量。
  4. 如果有 transition,调用 requestTransitionLane() 返回 transition lane(低优先级)。
  5. 否则,用 eventPriorityToLane() 返回默认的 event priority lane(高优先级)。

所以,当你在 startTransition 里调用 setState 的时候,React 会检查到当前有 active transition,然后给你分配一个低优先级的 lane。

requestTransitionLane

requestTransitionLane 负责分配 transition lane。我们来看看它的实现:

packages/react-reconciler/src/ReactFiberRootScheduler.js 中:

export function requestTransitionLane(
  // This argument isn't used, it's only here to encourage the caller to
  // check that it's inside a transition before calling this function.
  // TODO: Make this non-nullable. Requires a tweak to useOptimistic.
  transition: Transition | null,
): Lane {
  // The algorithm for assigning an update to a lane should be stable for all
  // updates at the same priority within the same event. To do this, the
  // inputs to the algorithm must be the same.
  //
  // The trick we use is to cache the first of each of these inputs within an
  // event. Then reset the cached values once we can be sure the event is
  // over. Our heuristic for that is whenever we enter a concurrent work loop.
  if (currentEventTransitionLane === NoLane) {
    // All transitions within the same event are assigned the same lane.
    const actionScopeLane = peekEntangledActionLane();
    currentEventTransitionLane =
      actionScopeLane !== NoLane
        ? // We're inside an async action scope. Reuse the same lane.
          actionScopeLane
        : // We may or may not be inside an async action scope. If we are, this
          // is the first update in that scope. Either way, we need to get a
          // fresh transition lane.
          claimNextTransitionUpdateLane();
  }
  return currentEventTransitionLane;
}

这个函数做了几件事:

  1. 事件级别的 lane 缓存:同一个事件里的所有 transition 更新共享同一个 lane。比如你在一个事件处理函数里调用了多个 startTransition,它们会用同一个 lane。
  2. 异步 action scope 支持:如果你在异步 action scope 里(比如 Server Action),会复用相同的 lane。
  3. lane 分配:如果没有缓存的 lane,就用 claimNextTransitionUpdateLane() 分配一个新的 transition lane。

Lane 优先级系统

Lane 是用位运算实现的,这样判断和分配都很快。我们来看看 transition lane 是怎么分配的:

packages/react-reconciler/src/ReactFiberLane.js 中:

export function isTransitionLane(lane: Lane): boolean {
  return (lane & TransitionLanes) !== NoLanes;
}

export function claimNextTransitionUpdateLane(): Lane {
  // Cycle through the lanes, assigning each new transition to the next lane.
  // In most cases, this means every transition gets its own lane, until we
  // run out of lanes and cycle back to the beginning.
  const lane = nextTransitionUpdateLane;
  nextTransitionUpdateLane <<= 1;
  if ((nextTransitionUpdateLane & TransitionUpdateLanes) === NoLanes) {
    nextTransitionUpdateLane = TransitionLane1;
  }
  return lane;
}

claimNextTransitionUpdateLane 的逻辑很简单:

  1. 取当前的 nextTransitionUpdateLane 作为要返回的 lane。
  2. nextTransitionUpdateLane 左移一位(相当于乘以 2),这样下次就能分配下一个 lane。
  3. 如果左移后超出了 transition lanes 的范围,就循环回到第一个 transition lane。

这样,每个新的 transition 都会得到自己的 lane,直到 lanes 用尽,然后循环使用。

Lane 的优先级规则是:Transition lanes 的优先级低于同步 lanes(SyncLane)和默认 lanes(DefaultLane)。所以当有高优先级的更新(比如用户输入)时,React 会中断 transition 的渲染,先处理高优先级的更新,然后再回来继续渲染 transition。

工作原理流程图

让我们通过流程图来理解 useTransition 的完整工作流程:

flowchart TD
    A[用户调用 startTransition] --> B[设置 ReactSharedInternals.T]
    B --> C[执行 scope 函数]
    C --> D[调用 setState]
    D --> E[requestUpdateLane]
    E --> F{检查是否有 active transition?}
    F -->|是| G[requestTransitionLane]
    F -->|否| H[eventPriorityToLane]
    G --> I[分配 Transition Lane]
    I --> J[标记更新为低优先级]
    J --> K[React 调度器处理]
    K --> L{有更高优先级更新?}
    L -->|是| M[中断 transition 渲染]
    L -->|否| N[继续渲染 transition 更新]
    M --> O[处理高优先级更新]
    O --> P[恢复 transition 渲染]
    N --> Q[完成渲染]
    P --> Q
    Q --> R[恢复 ReactSharedInternals.T]
    
    style I fill:#e1f5ff
    style J fill:#e1f5ff
    style M fill:#fff4e6
    style O fill:#fff4e6

流程说明

  1. 用户调用 startTransition,设置全局 transition 上下文
  2. 在 scope 中调用 setState 触发更新
  3. React 通过 requestUpdateLane 检查是否有 active transition
  4. 如果有,分配 transition lane(低优先级)
  5. React 调度器可以中断 transition 渲染来处理更高优先级的更新
  6. 高优先级更新完成后,恢复 transition 渲染
  7. 最终恢复 transition 上下文

最佳实践

何时使用 useTransition

useTransition 特别适合以下场景:

  1. 搜索和筛选

    const [isPending, startTransition] = useTransition();
    const [query, setQuery] = useState('');
    
    const handleSearch = (value: string) => {
      setQuery(value); // 高优先级:立即更新输入框
      startTransition(() => {
        setFilteredResults(filterResults(value)); // 低优先级:延迟更新结果
      });
    };
    
  2. 标签切换

    const [isPending, startTransition] = useTransition();
    const [activeTab, setActiveTab] = useState('home');
    
    const handleTabChange = (tab: string) => {
      startTransition(() => {
        setActiveTab(tab); // 标签内容渲染可以延迟
      });
    };
    
  3. 列表渲染

    const [isPending, startTransition] = useTransition();
    const [items, setItems] = useState([]);
    
    const loadMoreItems = () => {
      startTransition(() => {
        setItems(prev => [...prev, ...newItems]); // 大量列表项渲染
      });
    };
    

与防抖/节流的区别

特性 useTransition 防抖/节流
延迟机制 基于优先级调度 基于时间延迟
输入响应 即时响应 有固定延迟
渲染控制 React 自动管理 手动控制
中断能力 支持中断和恢复 不支持
适用场景 复杂渲染场景 减少计算/请求

关键区别

  • useTransition 不会延迟用户输入,而是让 React 智能地调度渲染
  • 防抖/节流会固定延迟,可能影响用户体验
  • useTransition 利用 React 的并发特性,可以中断和恢复渲染

注意事项和限制

  1. 不要用于紧急更新

    // ❌ 错误:紧急更新不应该用 useTransition
    startTransition(() => {
      setError(error); // 错误信息应该立即显示
    });
    
    // ✅ 正确:非紧急的 UI 更新
    startTransition(() => {
      setSearchResults(results); // 搜索结果可以延迟显示
    });
    
  2. isPending 的使用

    const [isPending, startTransition] = useTransition();
    
    return (
      <div>
        <input onChange={handleChange} />
        {isPending && <Spinner />} {/* 显示加载状态 */}
      </div>
    );
    
  3. 避免在 transition 中进行副作用

    // ❌ 错误:副作用应该在 transition 外
    startTransition(() => {
      setState(newState);
      document.title = 'Updated'; // 副作用
    });
    
    // ✅ 正确:只更新状态
    startTransition(() => {
      setState(newState);
    });
    document.title = 'Updated'; // 副作用在 transition 外
    
  4. 与 Suspense 配合使用

    <Suspense fallback={<Loading />}>
      <SearchResults query={query} />
    </Suspense>
    

总结

useTransition 是 React 18 并发特性的核心 API 之一,它通过以下机制实现了优秀的性能优化:

  1. 优先级调度:将非紧急更新标记为低优先级,让 React 优先处理用户交互
  2. 可中断渲染:支持中断和恢复,保持 UI 响应性
  3. 智能平衡:自动平衡输入响应和渲染性能

通过本文的源码分析,我们了解到:

  • useTransition 通过内部状态管理 pending 状态
  • startTransition 通过设置全局上下文标记更新为低优先级
  • React 调度器通过 Lane 模型管理不同优先级的更新
  • Transition lanes 的优先级低于同步和默认 lanes

在实际开发中,我们应该:

  • 将非紧急的 UI 更新包装在 startTransition
  • 使用 isPending 提供加载反馈
  • 避免在 transition 中进行副作用
  • 理解与防抖/节流的区别,选择合适的技术

React 18 的并发特性为构建高性能、响应迅速的用户界面提供了强大的工具。useTransition 作为其中的重要组成部分,值得我们深入理解和合理使用。

参考资料

我对防抖(Debounce)的一点理解与实践:从基础到立即执行

作者 cindershade
2025年12月13日 21:53

我对防抖(Debounce)的一点理解与实践

这篇文章主要是我在项目中使用防抖过程中的一些总结,只代表个人理解,如果有不严谨或可以优化的地方,欢迎指出和讨论。


一、防抖的概念

防抖(Debounce) ,简单来说就是:

在短时间内多次触发同一个函数时,只让它在“合适的时机”执行一次。

常见的两种形式:

  • 尾触发:停止触发一段时间后才执行
  • 立即执行:第一次触发立刻执行,随后一段时间内不再执行

防抖本身并不复杂,真正复杂的地方在于:什么时候该用哪一种,以及实现细节是否可靠。


二、为什么要做防抖(重点)

在实际项目中,高频触发几乎无处不在:

  • 用户快速点击按钮
  • 表单多次提交
  • 输入框实时搜索

如果不加控制,往往会带来一些问题:

  • 接口被重复调用
  • 产生重复副作用(多次提交、多次弹窗)
  • 状态错乱,难以维护

防抖解决的核心问题是:

函数触发频率过高,而这些触发中,只有一部分是真正“有意义”的。

通过防抖,我们可以在函数入口处统一控制执行频率,而不是在函数内部到处加判断。


2.1 除了防抖,还有其它方案吗?(简单带过)

实际开发中,也经常能看到一些方案:

  • loading 状态控制

  • 页面多个按钮,loading按钮过多,然后二次封装按钮组件

接下来先按照我个人的理解,来说一下还是防抖。


三、基础版本防抖实现

3.1 最基础的防抖写法

function debounce(func, wait = 200) {
  let timeout = null

  return function (...args) {
    clearTimeout(timeout)
    timeout = setTimeout(() => {
      func.apply(this, args)
    }, wait)
  }
}

这个版本属于最经典的尾触发防抖

  • 多次触发只会执行最后一次

3.2 普通函数与箭头函数的区别

防抖实现中经常会看到两种写法:

const content = this

setTimeout(function () {
  func.apply(content, args)
}, wait)

以及:

setTimeout(() => {
  func.apply(this, args)
}, wait)

这两种写法的核心区别在于 this 的绑定机制不同

  • 普通函数的 this 是运行时动态绑定的,由函数的调用方式决定,在 setTimeout 等场景中容易发生 this 丢失。
  • 箭头函数不会创建自己的 this,它的 this 在定义时就已经确定,始终指向外层作用域的 this,因此非常适合用于定时器和回调函数中。

因此在防抖中,如果使用普通函数,往往需要额外保存 this;
而使用箭头函数,可以让代码更简洁。然后具体的情况需要具体分析

这里不展开细说 this 的规则。 而且里面还涉及到了apply,call,bind等知识


四、立即执行版防抖

4.1 为什么需要立即执行版

普通防抖有一个明显特点:

  • 第一次触发不会立即执行

在一些场景下,这并不是我们想要的行为,例如:

  • 提交按钮
  • 登录、支付等关键操作

这类场景下,更合理的预期是:

第一次点击立刻执行,但在短时间内禁止再次触发。

这就是立即执行版防抖存在的意义。


4.2 立即执行版完整实现

function debounce(func, wait = 200, immediate = false) {
  let timeout = null

  return function (...args) {
    // 是否需要立即执行(第一次触发)
    const callNow = immediate && !timeout

    // 清除之前的定时器
    if (timeout) clearTimeout(timeout)

    // 设置新的定时器,用于冷却期结束
    timeout = setTimeout(() => {
      // 冷却结束,重置状态
      timeout = null

      // 非立即执行模式,走尾触发
      if (!immediate) {
        func.apply(this, args)
      }
    }, wait)

    // 立即执行(只会执行一次)
    if (callNow) {
      func.apply(this, args)
    }
  }
}

4.3 这一版的核心思路

在这个实现中:

  • timeout 不只是一个定时器
  • 它同时承担了 “是否处于冷却期” 的状态标记
const callNow = immediate && !timeout
  • !timeout 表示当前不在冷却期
  • 只允许第一次触发立即执行

当定时器结束后:

timeout = null

表示冷却期结束,允许下一次立即执行。


五、结合源码理解实现逻辑

在理解了立即执行版防抖的实现后,再回看 Underscore.js 的 _.debounce 源码,其实可以发现:核心思想完全一致,只是写法更偏工程化。

_.debounce = function(func, wait, immediate) {
  var timeout, result;

  var later = function(context, args) {
    timeout = null;
    if (args) result = func.apply(context, args);
  };

  var debounced = function(...args) {
    if (timeout) clearTimeout(timeout);

    if (immediate) {
      var callNow = !timeout;
      timeout = setTimeout(later, wait);
      if (callNow) result = func.apply(this, args);
    } else {
      timeout = setTimeout(() => later(this, args), wait);
    }

    return result;
  };

  return debounced;
};

timeout 是防抖的核心状态

timeout 不只是定时器 ID,更是是否处于冷却期的状态标识

  • timeout === null:不在冷却期
  • timeout !== null:正在防抖中

立即执行模式正是通过 !timeout 来判断“是否第一次触发”。


为什么定时器里要 timeout = null

timeout = null;

这一步表示冷却期结束,为下一次立即执行创造条件。
如果不重置,immediate 只会生效一次。


立即执行的关键逻辑

var callNow = !timeout;
timeout = setTimeout(later, wait);
if (callNow) func.apply(this, args);

这三行完成了三件事:

  1. 判断是否第一次触发
  2. 立刻进入冷却期
  3. 只在第一次触发时立即执行

后续触发只会刷新定时器,不会重复执行。


为什么源码不用 this,而是传 context

later 是普通函数,this 不可靠。
因此 Underscore 选择 显式传递 context,保证 this 指向稳定,这是典型的库级写法。


核心结论

防抖并不依赖复杂 API,本质只有两点:

  • 定时器
  • 状态控制(是否处于冷却期)

立即执行与否,本质区别只是:

函数是在冷却期开始时执行,还是在冷却期结束时执行。

总结

防抖本身并不难,真正容易出问题的是:

  • 使用场景选错
  • this 指向理解不清
  • 状态与执行职责混在一起

这篇文章更多是我个人在项目中的一些理解与总结,
如果你在实践中有不同的经验或看法,也非常欢迎交流。

RequireJS 详解

2025年12月13日 21:13

RequireJS 详解

RequireJS 是一个基于 AMD(Asynchronous Module Definition,异步模块定义) 规范的 JavaScript 模块加载器,主要解决浏览器端模块化开发的依赖管理、异步加载问题,避免传统 <script> 标签加载的阻塞和依赖混乱问题。

一、核心优势

  1. 异步加载:模块按需异步加载,避免阻塞页面渲染;
  2. 依赖管理:明确声明模块依赖,自动按顺序加载依赖模块;
  3. 模块化封装:隔离模块作用域,避免全局变量污染;
  4. 跨环境兼容:支持浏览器端,也可配合工具(如 r.js)打包为生产环境的单文件。

二、快速上手

1. 引入 RequireJS

下载 RequireJS(官网),或通过 CDN 引入,在 HTML 中通过 <script> 标签加载,指定入口模块(data-main):

<!-- 引入 require.js,并指定入口模块为 main.js -->
<script src="https://cdn.jsdelivr.net/npm/requirejs@2.3.6/require.js" data-main="js/main"></script>
  • data-main:指定应用的入口模块(后缀 .js 可省略);
  • RequireJS 会自动加载 main.js,并以它为起点管理所有模块。

2. 定义模块

RequireJS 中模块通过 define() 定义,分三种场景:

(1)无依赖模块
// js/modules/hello.js
define(function() {
  return {
    sayHello: function(name) {
      return `Hello, ${name}!`;
    }
  };
});
(2)有依赖模块

依赖模块通过数组声明,回调函数的参数与依赖一一对应:

// js/modules/user.js
define(['./hello'], function(hello) { // 依赖同目录的 hello.js
  return {
    greet: function(username) {
      return hello.sayHello(username);
    }
  };
});
(3)命名模块(不推荐,RequireJS 会自动根据文件路径生成模块名)
// 显式指定模块名(一般用于第三方库)
define('myModule', ['jquery'], function($) {
  return {
    init: function() {
      $('body').css('background', '#f5f5f5');
    }
  };
});

3. 加载模块

通过 require() 加载模块并执行逻辑,通常在入口模块 main.js 中使用:

// js/main.js
// 配置模块路径(可选,推荐)
require.config({
  // 基础路径:所有模块的根路径
  baseUrl: 'js',
  // 路径映射:简化长路径/第三方库别名
  paths: {
    'jquery': 'https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min', // CDN 加载
    'user': 'modules/user', // 本地模块
    'hello': 'modules/hello'
  },
  // 非 AMD 模块适配(如一些全局变量式的库)
  shim: {
    'underscore': {
      exports: '_' // 暴露全局变量 _ 作为模块导出
    }
  }
});

// 加载并使用模块
require(['jquery', 'user'], function($, user) {
  $(document).ready(function() {
    const greetMsg = user.greet('RequireJS');
    $('body').html(`<h1>${greetMsg}</h1>`);
  });
});

三、核心 API 详解

1. define():定义模块

语法:

// 无依赖
define(function() { /* 模块逻辑,return 导出内容 */ });

// 有依赖
define([依赖1, 依赖2], function(模块1, 模块2) { /* 逻辑 */ });

// 命名模块
define('模块名', [依赖], function() { /* 逻辑 */ });

2. require.config():全局配置

核心配置项:

配置项 说明 示例
baseUrl 模块加载的基础路径 baseUrl: 'js/lib'
paths 模块路径映射(可省略 .js,支持 CDN) paths: { jquery: 'jquery-3.7.1' }
shim 适配非 AMD 模块(暴露变量、声明依赖) shim: { underscore: { exports: '_' } }
map 不同环境加载不同版本模块 map: { '*': { 'jquery': 'jquery-2' } }
waitSeconds 模块加载超时时间(默认 7 秒) waitSeconds: 10

3. require():加载模块并执行

语法:

require([依赖1, 依赖2], function(模块1, 模块2) {
  // 模块加载完成后的逻辑
}, function(err) {
  // 加载失败的回调(可选)
  console.error('模块加载失败:', err);
});

四、常见场景

1. 加载第三方非 AMD 模块

很多传统库(如 Underscore、Backbone)不是 AMD 模块,需通过 shim 适配:

require.config({
  paths: {
    underscore: 'lib/underscore',
    backbone: 'lib/backbone'
  },
  shim: {
    underscore: {
      exports: '_' // 暴露全局变量 _
    },
    backbone: {
      deps: ['underscore', 'jquery'], // 声明依赖
      exports: 'Backbone' // 暴露 Backbone
    }
  }
});

// 使用
require(['backbone'], function(Backbone) {
  console.log(Backbone);
});

2. 动态加载模块

通过 require() 动态加载模块(如用户交互后):

$('#loadModule').click(function() {
  // 动态加载 hello 模块
  require(['modules/hello'], function(hello) {
    alert(hello.sayHello('动态加载'));
  });
});

3. 模块打包(生产环境)

开发阶段模块分散,生产环境需打包为单文件,使用官方工具 r.js

  1. 安装 r.js:npm install -g requirejs
  2. 创建打包配置文件 build.js
    ({
      baseUrl: 'js',
      name: 'main', // 入口模块
      out: 'js/build/main.min.js', // 输出文件
      optimize: 'uglify2' // 压缩方式(uglify2/closure/none)
    })
    
  3. 执行打包:r.js -o build.js

五、注意事项

  1. 模块路径问题baseUrl 是相对 HTML 文件的路径,而非配置文件;
  2. 避免全局变量:模块内通过 return 导出,不要随意定义全局变量;
  3. 循环依赖:AMD 支持循环依赖,但需通过 require 回调获取依赖(而非参数):
    // a.js
    define(['b'], function(b) {
      return {
        foo: function() {
          return b.bar();
        }
      };
    });
    
    // b.js
    define(['a'], function(a) {
      return {
        bar: function() {
          // 循环依赖时,通过 require 实时获取 a
          return require('a').foo() + ' bar';
        }
      };
    });
    
  4. 生产环境优化:务必用 r.js 打包,减少 HTTP 请求;
  5. ES6 模块兼容:RequireJS 也支持加载 ES6 模块(需配置 packages),但现代项目更推荐 ES6 Module + Webpack/Rollup。

六、与 ES6 Module 的区别

特性 RequireJS(AMD) ES6 Module
加载方式 异步加载(运行时加载) 静态加载(编译时确定依赖)
语法 define/require import/export
作用域 函数作用域 块级作用域
浏览器支持 需加载器(RequireJS) 现代浏览器原生支持(需 <script type="module">
动态加载 原生支持 require() 需配合 import() 动态导入

注:现代前端项目(React/Vue/Angular)已普遍使用 ES6 Module + 构建工具(Webpack/Vite),但 RequireJS 仍适用于老项目维护、非构建型简单应用。

七、总结

RequireJS 是浏览器端模块化开发的经典解决方案,核心是通过 AMD 规范实现异步模块加载和依赖管理。其优势是轻量、无需构建工具即可使用,适合中小型项目或老项目维护;缺点是语法相对繁琐,现代项目更推荐 ES6 Module + 构建工具。

如果需要具体场景的代码示例(如循环依赖处理、打包优化),可以进一步说明!

从零掌握 React JSX:为什么它让前端开发像搭积木一样简单?

作者 不会js
2025年12月13日 20:33

从零掌握 React JSX:为什么它让前端开发像搭积木一样简单?

大家好,今天带大家深入聊聊 React 的核心灵魂——JSX。我们会结合真实代码示例,一步步拆解 JSX 的本质、组件化开发、状态管理,以及那些容易踩坑的地方。

React 为什么这么火?因为它把前端开发从“拼 HTML + CSS + JS”的手工活,变成了“搭积木式”的组件化工程。JSX 就是那把神奇的“胶水”,让 JavaScript 里直接写 HTML-like 代码成为可能。

5609728adb9d921c5649719c8cbf0517.jpg

JSX 是什么?XML in JS 的魔法

想象一下,你在 JavaScript 代码里直接写 HTML 标签,这听起来多酷?这就是 JSX(JavaScript XML)的核心。它不是字符串,也不是真正的 HTML,而是一种语法扩展,看起来像 XML,但最终会被编译成纯 JavaScript。

为什么需要 JSX?传统前端开发,HTML、CSS、JS 三分离,逻辑和视图混在一起时很容易乱套。React 说:不,我们把一切都放进 JS 里!这样,UI 描述和逻辑紧密耦合,代码更易维护。

来看个简单对比:

  • 不使用 JSX(纯 createElement):

    const element = createElement('h2', null, 'JSX 是 React 中用于描述用户界面的语法扩展');
    
  • 使用 JSX(语法糖):

    const element = <h2>JSX 是 React 中用于描述用户界面的语法扩展</h2>;
    

明显后者更直观、可读性更高!JSX 本质上是 React.createElement 的语法糖,Babel 会帮我们编译成后者。

:Babel 是一个开源的 JavaScript 编译器,更准确地说,是一个 转译器。它的主要作用是:把现代 JavaScript 代码(ES2015+,也就是 ES6 及更高版本)转换成向后兼容的旧版 JavaScript 代码,让这些代码能在老浏览器或旧环境中正常运行。

底层逻辑:JSX 被 Babel 转译后,生成 Virtual DOM 对象树。React 用这个虚拟树对比真实 DOM,只更新变化部分,这就是 React 高性能的秘密——Diff 算法 + 批量更新。

React vs Vue:为什么 React 更“激进”?

Vue 和 React 都是现代前端框架的代表,都支持响应式、数据绑定和组件化。但 React 更纯粹、更激进。

  • Vue:模板、脚本、样式三分离(单文件组件 .vue),上手友好,双向绑定 v-model 超级方便。适合快速原型开发。
  • React:一切皆 JS!JSX 把模板塞进 JS,单向数据流(props down, events up),逻辑更明确,但学习曲线陡峭。

React 的激进在于:它不提供“开箱即用”的模板语法,而是让你用完整的 JavaScript 能力构建 UI。你可以用 if、map、变量等原生 JS 控制渲染,而 Vue 模板需要指令(如 v-if、v-for)。

为什么很多人说 React 入门门槛高?因为它强制你思考“组件树”和“状态流”,而不是靠模板魔法。但一旦上手,你会发现它在大型项目中更可控、更灵活。Facebook、Netflix 都在用 React,就是因为组件化让代码像乐高积木一样可复用

组件化开发:从 DOM 树到组件树

传统前端靠 DOM 操作,审查元素是层层 div。React 说:不,我们用组件树代替 DOM 树!

组件是 React 的基本单位,每个组件是一个函数(现代 React 推荐函数组件),返回 JSX 描述 UI。

来看一个模拟掘金首页的例子:

function JuejinHeader() {
  return (
    <header>
      <h1>掘金的首页</h1>
    </header>
  );
}

const Articles = () => <main>Articles</main>;

function App() {
  return (
    <div>
      <JuejinHeader />
      <main>
        <Articles />
        <aside>{/* 侧边栏组件 */}</aside>
      </main>
    </div>
  );
}

这里,App 是根组件,组合了子组件。就像包工头分工:Header 负责头部,Articles 负责文章列表。页面就是这些组件搭起来的!

image.png

这张图的核心就是:把复杂 UI 拆分成组件树,每个组件专注自己的事,通过组合构建整个页面。

关键点:组件复用

  • 你会注意到 FancyText 出现了两次(一个直接在 App 下,一个在 InspirationGenerator 下)。
  • 这就是在强调:同一个组件可以被多个父组件多次渲染和复用!这正是 React 组件化开发的强大之处——写一次,到处用,像乐高积木一样组合。

为什么函数做组件? 因为函数纯净、无副作用,能完美封装 UI + 逻辑 + 状态。类组件(旧方式)有 this 绑定问题,函数组件 + Hooks 解决了这一切。

底层逻辑:React 渲染时,会递归调用每个组件的 render 函数,最终生成一棵完整的 Virtual DOM 树。也就是说每个组件渲染生成 Virtual DOM 片段,React 合并成一棵大树。更新时,只重渲染变化的组件子树。

useState:让函数组件拥有“记忆”

组件需要交互?就需要状态!useState 是 Hooks 的入门王牌。

import { useState } from 'react';

function App() {
  const [name, setName] = useState('vue'); // 初始值 'vue'
  const [todos, setTodos] = useState([
    { id: 1, title: '学习 React', done: false },
    { id: 2, title: '学习 Node', done: false }
  ]);
  const [isLoggedIn, setIsLoggedIn] = useState(false);

  const toggleLogin = () => setIsLoggedIn(!isLoggedIn);

  setTimeout(() => setName('react'), 3000); // 3秒后自动更新

  return (
    <>
      <h1>Hello <span className="title">{name}</span></h1>
      {todos.length > 0 ? (
        <ul>
          {todos.map(todo => (
            <li key={todo.id}>{todo.title}</li>
          ))}
        </ul>
      ) : (
        <div>暂无待办事项</div>
      )}
      {isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
      <button onClick={toggleLogin}>
        {isLoggedIn ? '退出登录' : '登录'}
      </button>
    </>
  );
}

image.png

useState 返回 [状态值, 更新函数]。调用更新函数会触发重渲染,React 记住最新状态。

函数组件 + Hooks,代码更简洁、复用性更强。常见 Hooks:

  • useState:管理状态
  • useEffect:处理副作用(数据获取、订阅等)
  • useContext、useReducer、useRef 等

易错提醒

setState 是异步的!多个 setState 可能批处理,不要直接依赖旧值。
// 错:可能加1多次只加1
setCount(count + 1);

// 对:函数式更新
setCount(prev => prev + 1);
  • 在同一个事件(如 onClick)里,React 不会立即更新状态,而是把所有 setCount 调用收集到一个队列里。

  • 所有 setCount 执行时,看到的 count 都是当前渲染的“快照值” (这里是 0)。

  • 等事件结束,React 一次性处理队列:两次都是 “0 + 1 = 1”,最后覆盖成同一个值 1,只重渲染一次。

对象状态更新不会自动合并,用展开运算符:

错的例子:直接替换对象,会丢失属性

假设初始状态是一个对象:

const [person, setPerson] = useState({
  name: 'Alice',
  age: 30,
  city: 'Beijing'
});

如果你想只改 age:

// 错!直接传新对象
setPerson({ age: 35 });

结果:新状态变成 { age: 35 },name 和 city 全没了!因为 React 直接用你传的对象替换了整个状态。

正确的做法:用展开运算符(...prev)手动合并

jsx

// 对!函数式更新 + 展开运算符
setPerson(prev => ({ ...prev, age: 35 }));

这里发生了什么?

  • prev 是当前最新的状态对象({ name: 'Alice', age: 30, city: 'Beijing' })
  • { ...prev }:用 ES6 展开运算符把 prev 的所有属性复制到一个新对象里 → { name: 'Alice', age: 30, city: 'Beijing' }
  • age: 35:覆盖 age 属性
  • 最终返回新对象:{ name: 'Alice', age: 35, city: 'Beijing' }

完美!只改了 age,其他属性保留了。

JSX 常见坑与最佳实践

JSX 强大,但也有陷阱:

  1. class → className:class 是 JS 关键字,必须用 className。

    <div className="title">错误会报错!</div>
    
  2. 最外层必须单根元素

JSX 的 return 必须返回一个元素(或 null),不能直接返回多个并列元素。

错的:

return (
  <h1>标题</h1>
  <p>段落</p>  // 报错!Adjacent JSX elements must be wrapped...
);

因为 JSX 最终转译成 React.createElement 调用,而函数返回值只能是一个表达式。

正确做法:用 Fragment <> </> 包裹(不渲染多余 DOM)

return (
  <>  {/* 短语法 */}
    <h1>标题</h1>
    <p>段落</p>
  </>
);

// 或
return (
  <React.Fragment>
    <h1>标题</h1>
    <p>段落</p>
  </React.Fragment>
);

3. 表达式用 {}:插值、条件、三元、map 都用大括号。 jsx {condition ? <A /> : <B />}

  1. key 必加:列表渲染 map 时,加唯一 key,帮助 React 高效 Diff。

    {todos.map(todo => <li key={todo.id}>...</li>)}
    

    缺 key 会警告,性能差。

  2. 事件用 camelCase:onClick,不是 onclick。

  3. 自闭合标签:单标签必须闭合,如 <img />

根组件挂载:从 main.jsx 看 React 启动

import { createRoot } from 'react-dom/client';
import App from './App.jsx';

createRoot(document.getElementById('root')).render(
  <App />
);

1. 这段代码在干啥?一步步拆解

  • document.getElementById('root') :找到 HTML 文件里的挂载点。通常 index.html 有个

    ,React 会把整个应用塞进去,接管这个 div 里的所有内容。

  • createRoot(...) :创建 React 的“根”(Root)。它返回一个 Root 对象,这个对象负责管理整个组件树和 DOM 更新。

  • .render() :告诉 React:“嘿,从现在开始渲染 App 组件吧!”React 会从 App 开始递归渲染组件树,生成 Virtual DOM,最终 commit 到真实 DOM。

整个过程:创建根 → 初始渲染 → 接管 DOM。应用启动后,React 就完全掌控了 #root 里的 UI。

总结:为什么选择 React 和 JSX?

JSX 让 React 成为“全栈 JS”的代表:逻辑、视图、状态全在 JS 里。组件化让你像建筑师一样设计页面,useState 等 Hooks 让函数组件强大无比。

相比 Vue,React 更适合大型、复杂应用(生态丰富,TypeScript 支持一流)。但 Vue 上手更快,适合中小项目。

学 React,不是学语法,而是学“声明式编程”和“组件思维”。掌握 JSX,你就掌握了 React 的半壁江山。

❌
❌