普通视图

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

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

作者 GIS之路
2026年1月17日 21:33

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

大家有没有这种经历,每次新建或者打开一个已有的ArcGIS Pro工程的话,在加载场景页面都要等待很长时间,这无疑对使用体验造成非常不好的影响。

首先需要检查一下电脑配置,如果你的电脑运行内存稍微低一点,显卡也不太给力,对软件运行会造成一定影响。

可访问ESRI网站查看配置需求。

访问如下地址,可检测本机运行环境。

https://www.systemrequirementslab.com/client-app?refid=1256&appkey=6D681CD0-BA6C-4B6B-9A82-639759CFD094&requirementsetid=23091

标识ArcGIS Pro版本为3.3到3.6,我本机的是3.5。

好吧,虽然··············,但是我的电脑没运行出结果,或者说检测出错了,估计是个bug有完整运行结果的同学可以截图看一下。

电脑配置这关过了的话,咱么可以进行软件设置了。

我的主要问题是每次新建工程或者打开原有工程,创建场景的时间都很漫长(底图没被封之前就加载缓慢),也有可能是网络的原因。

但我的需求其实不需要一开始就添加一个完整的底图,包括地形、影像或者矢量,大多数情况下我只需要一个空白工程就可以了,所以我的解决办法是在设置里面将默认加载底图去除。

首先,打开设置选项Options

然后找到Map and Scene,在右侧将Map项和Scene项修改为None,也就是默认不加载底图。

经过此步骤之后,我再打开ArcGIS Pro那就舒服多了。

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

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

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

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

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


    

GeoTools 开发合集(全)

OpenLayers 开发合集

ArcGIS Pro 实现影像波段合成

自然资源部党组关于苗泽等4名同志职务任免的通知

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

GDAL 数据类型大全

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

GDAL 实现投影转换

国产版的Google Earth,吉林一号卫星App“共生地球”来了

2026年全国自然资源工作会议召开

日本欲打造“本土版”星链系统

吉林一号国内首张高分辨率彩色夜光卫星影像发布

2025 年度信创领军企业名单出炉!

🌰在 OpenLayers 中实现图层裁切

作者 黑心皮蛋
2026年1月17日 19:31

img💬 前言

做地图的时候,常会给地图套个“遮罩”——把不关心的地方调暗,把想看的地方突出出来。但有时我们并不想动整张地图的视觉效果,只想把某一张图层按不规则形状展示出来,比如只在某块多边形里显示指定图层。

🎯 最终效果如下

给定一个多边形(polygonCoords),对单一 WMTS/瓦片图层进行裁切:图层仅在该多边形内部可见,其他图层不受影响。

图层裁切最终效果

🧭 实现思路

通过监听图层的 prerender 事件 和 postrender事件 实现裁切

  1. 在图层开始绘制之前(prerender)准备裁切:
    • 回调里会拿到两个东西:OpenLayers 的矢量渲染上下文和原生的 Canvas 上下文(ctx)。
    • 先用 ctx.save() 保存当前 canvas 状态。
    • drawFeature(feature, style) 把多边形画到当前画布上(这会在 canvas 上生成路径)。
    • 然后调用 ctx.clip(),把这条路径设为裁切区,将当前图层进行裁切。
  2. 在图层绘制结束后(postrender)恢复状态:
    • 在 postrender 回调里调用 ctx.restore(),把之前保存的 canvas 状态还原,移除裁切,确保别的图层不受影响。

⚙️ 初始化项目

这里我设置地图的坐标系是3857,你可以根据自己的需要设置对应的坐标系

<!DOCTYPE html>
<html lang="zh-cn">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>openlayer图层裁切</title>
    <style>
        html,
        body,
        #map {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
        }
    </style>
</head>

<body>
    <div id="map"></div>
    <script src="./ol.js"></script>
    <script>
        const projection = ol.proj.get('EPSG:3857');
        const map = new ol.Map({
            layers: [
                // 矢量底图
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: `https://t{0-7}.tianditu.gov.cn/DataServer?T=vec_w&x={x}&y={y}&l={z}&tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    }),
                }),
                // 矢量注记
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: `https://t{0-7}.tianditu.gov.cn/DataServer?T=cva_w&x={x}&y={y}&l={z}&tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    }),
                }),
            ],
            target: 'map',
            view: new ol.View({
                zoom: 5,
                maxZoom: 17,
                minZoom: 1,
                projection: projection,
                center: [116.406393, 39.909006],
            }),
        })
    </script>
</body>

</html>

效果如下

image-20260117182119131

📐 多边形预览

前面实现思路说了,我们需要一个多边形裁切图层,我这里准备了一个

   const polygonCoords =
                [
                    [
                        [
                            911245.7835522988,
                            1672839.829528198
                        ],
                        [
                            1517290.255658307,
                            2962709.5078420187
                        ],
                        [
                            2862581.9534774087,
                            3011639.670051078
                        ],
                        [
                            3492423.0665472616,
                            2736407.507625122
                        ],
                        [
                            3675871.934431684,
                            2161478.101668681
                        ],
                        [
                            3131640.2930412292,
                            1647711.398473564
                        ],
                        [
                            2141016.406465345,
                            999386.7492035348
                        ],
                        [
                            2581293.6893879604,
                            63597.39695528569
                        ],
                        [
                            2165476.2555166017,
                            -523564.5495534199
                        ],
                        [
                            3107180.4439899744,
                            -982284.8202633462
                        ],
                        [
                            2232740.8404075587,
                            -1379842.3882119493
                        ],
                        [
                            1609014.6896005198,
                            -1300330.874622228
                        ],
                        [
                            1040323.1991588091,
                            -1104610.2257859926
                        ],
                        [
                            471631.7087170966,
                            -450169.3062398317
                        ],
                        [
                            465516.74645428266,
                            240969.23496312322
                        ],
                        [
                            324872.6144095585,
                            913758.9653376816
                        ],
                        [
                            593930.953973379,
                            1757804.2634439464
                        ],
                        [
                            911245.7835522988,
                            1672839.829528198
                        ]
                    ]
                ]

image-20260117190344593

后面就会用这个多边形来对图层进行裁切

🗺️ 添加一张影像图

<!DOCTYPE html>
<html lang="zh-cn">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>openlayer图层裁切</title>
    <style>
        html,
        body,
        #map {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
        }
    </style>
</head>

<body>
    <div id="map"></div>
    <script src="./ol.js"></script>
    <script>
        const projection = ol.proj.get('EPSG:3857');
        const projectionExtent = projection.getExtent();
        const size = ol.extent.getWidth(projectionExtent) / 256;
        const resolutions = [];
        for (let z = 2; z < 19; ++z) {
            resolutions[z] = size / Math.pow(2, z);
        }
        const matrixIds = ['0',
            '1',
            '2',
            '3',
            '4',
            '5',
            '6',
            '7',
            '8',
            '9',
            '10',
            '11',
            '12',
            '13',
            '14',
            '15',
            '16',
            '17',
            '18']
        const map = new ol.Map({
            layers: [
                // 矢量底图
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: `https://t{0-7}.tianditu.gov.cn/DataServer?T=vec_w&x={x}&y={y}&l={z}&tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    }),
                }),
                // 矢量注记
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: `https://t{0-7}.tianditu.gov.cn/DataServer?T=cva_w&x={x}&y={y}&l={z}&tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    }),
                }),
            ],
            target: 'map',
            view: new ol.View({
                zoom: 5,
                maxZoom: 17,
                minZoom: 1,
                projection: projection,
                center: [116.406393, 39.909006],
            }),
        })
        const wmtsLayer = new ol.layer.Tile({
            source: new ol.source.WMTS({
                url: `http://t{0-6}.tianditu.gov.cn/img_c/wmts?tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                layer: 'img',
                matrixSet: 'c',
                style: 'default',
                crossOrigin: 'anonymous',
                format: 'tiles',
                wrapX: true,
                crossOrigin: 'anonymous',
                tileGrid: new ol.tilegrid.WMTS({
                    origin: ol.extent.getTopLeft(projectionExtent),
                    resolutions: resolutions,
                    matrixIds: matrixIds
                })
            })
        })
        map.addLayer(wmtsLayer);
    </script>
</body>

</html>

效果如下

image-20260117191715249

🔔 监听图层事件

前面已经将这个影像图添加到地图上面去了, 接下来就可以监听这个图层事件,然后用多边形去裁切这张图层

// wmtsLayer 就是前面添加的影像图
wmtsLayer.on('prerender', function (evt) {
    const layerContent = ol.render.getVectorContext(evt)
    const ctx = evt.context;
    ctx.save();
    layerContent.drawFeature(polygon, polygonStyle)
    ctx.clip();
});

// 渲染后恢复 canvas 状态,移除裁切
wmtsLayer.on('postrender', function (evt) {
    const ctx = evt.context;
    if (ctx) ctx.restore();
});
const layer = map.addLayer(wmtsLayer);

📄 完整代码

<!DOCTYPE html>
<html lang="zh-cn">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>openlayer图层裁切</title>
    <style>
        html,
        body,
        #map {
            width: 100%;
            height: 100%;
            margin: 0;
            padding: 0;
        }

        /* 右上角形状按钮组 */
        #shape-controls {
            position: fixed;
            top: 12px;
            right: 12px;
            display: flex;
            flex-direction: column;
            gap: 8px;
            z-index: 1000;
        }

        #shape-controls button {
            padding: 6px 10px;
            font-size: 14px;
            cursor: pointer;
        }

        #shape-controls button.active {
            background: #0b5ed7;
            color: #fff;
        }
    </style>
</head>

<body>
    <div id="map"></div>
    <div id="shape-controls">
        <button id="btn-polygon">添加多边形影像图</button>
    </div>
    <script src="./ol.js"></script>
    <script>
        const projection = ol.proj.get('EPSG:3857');
        const projectionExtent = projection.getExtent();
        const size = ol.extent.getWidth(projectionExtent) / 256;
        const resolutions = [];
        for (let z = 2; z < 19; ++z) {
            resolutions[z] = size / Math.pow(2, z);
        }
        const matrixIds = ['0',
            '1',
            '2',
            '3',
            '4',
            '5',
            '6',
            '7',
            '8',
            '9',
            '10',
            '11',
            '12',
            '13',
            '14',
            '15',
            '16',
            '17',
            '18']
        const map = new ol.Map({
            layers: [
                // 矢量底图
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: `https://t{0-7}.tianditu.gov.cn/DataServer?T=vec_w&x={x}&y={y}&l={z}&tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    }),
                }),
                // 矢量注记
                new ol.layer.Tile({
                    source: new ol.source.XYZ({
                        url: `https://t{0-7}.tianditu.gov.cn/DataServer?T=cva_w&x={x}&y={y}&l={z}&tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    }),
                }),
            ],
            target: 'map',
            view: new ol.View({
                zoom: 5,
                maxZoom: 17,
                minZoom: 1,
                projection: projection,
                center: [116.406393, 39.909006],
            }),
        })

        document.getElementById('btn-polygon').addEventListener('click', function () {
            const polygonCoords =
                [
                    [
                        [
                            911245.7835522988,
                            1672839.829528198
                        ],
                        [
                            1517290.255658307,
                            2962709.5078420187
                        ],
                        [
                            2862581.9534774087,
                            3011639.670051078
                        ],
                        [
                            3492423.0665472616,
                            2736407.507625122
                        ],
                        [
                            3675871.934431684,
                            2161478.101668681
                        ],
                        [
                            3131640.2930412292,
                            1647711.398473564
                        ],
                        [
                            2141016.406465345,
                            999386.7492035348
                        ],
                        [
                            2581293.6893879604,
                            63597.39695528569
                        ],
                        [
                            2165476.2555166017,
                            -523564.5495534199
                        ],
                        [
                            3107180.4439899744,
                            -982284.8202633462
                        ],
                        [
                            2232740.8404075587,
                            -1379842.3882119493
                        ],
                        [
                            1609014.6896005198,
                            -1300330.874622228
                        ],
                        [
                            1040323.1991588091,
                            -1104610.2257859926
                        ],
                        [
                            471631.7087170966,
                            -450169.3062398317
                        ],
                        [
                            465516.74645428266,
                            240969.23496312322
                        ],
                        [
                            324872.6144095585,
                            913758.9653376816
                        ],
                        [
                            593930.953973379,
                            1757804.2634439464
                        ],
                        [
                            911245.7835522988,
                            1672839.829528198
                        ]
                    ]
                ]

            const wmtsLayer = new ol.layer.Tile({
                source: new ol.source.WMTS({
                    url: `http://t{0-6}.tianditu.gov.cn/img_c/wmts?tk=2aedde694311cf1e8ac3feca2da4fd3e`,
                    layer: 'img',
                    matrixSet: 'c',
                    style: 'default',
                    crossOrigin: 'anonymous',
                    format: 'tiles',
                    wrapX: true,
                    crossOrigin: 'anonymous',
                    tileGrid: new ol.tilegrid.WMTS({
                        origin: ol.extent.getTopLeft(projectionExtent),
                        resolutions: resolutions,
                        matrixIds: matrixIds
                    })
                })
            })
            const polygon = new ol.Feature({
                geometry: new ol.geom.Polygon(polygonCoords)
            })
            const polygonStyle = new ol.style.Style({
                stroke: new ol.style.Stroke({
                    color: 'rgba(255, 0, 0, 1)',
                    width: 2
                })
            });

            // 在渲染前使用多边形对图层进行裁切(clip)
            wmtsLayer.on('prerender', function (evt) {
                const layerContent = ol.render.getVectorContext(evt)
                const ctx = evt.context;
                ctx.save();
                layerContent.drawFeature(polygon, polygonStyle)
                ctx.clip();
            });

            // 渲染后恢复 canvas 状态,移除裁切
            wmtsLayer.on('postrender', function (evt) {
                const ctx = evt.context;
                if (ctx) ctx.restore();
            });
            const layer = map.addLayer(wmtsLayer);
        });
    </script>
</body>

</html>

taro项目踩坑指南——(一)

作者 Snack
2026年1月17日 18:27

自定义导航栏

在开发小程序的时候没有使用开发者工具,用的H5。想要有一个自定义的底部导航栏,所以就尝试了一下taro中的自定义导航栏。结果配置之后,直接让页面显示不出来内容了。

image.png

随后查询官网后,自定义导航栏好像只支持微信小程序的。H5的应该是没有的,把这段代码删掉之后就可以正常显示了。好烦!!!

在自定义导航栏时,应该由导航栏组件自行控制页面的变更和导航栏的状态变化。在taro中使用useDidShow这个生命周期Hook,通过页面显示,判断当前路由,并根据当前路由设置activeTab。在点击导航栏跳转页面时,使用redirectTo进行跳转。

  useDidShow(() => {
    const pages = Taro.getCurrentPages();
    const current = pages[pages.length - 1];
    const route = current.route;
    const matched = tabList.find((tab) => tab.pagePath === route);
    if (matched) {
      setActiveTab(matched.id);
    }
  });

  const switchTab = (tab) => {
    if (activeTab !== tab.id) {
      Taro.redirectTo({ url: tab.pagePath });
    }
  };

页面报错或者无渲染

页面报错类型

image.png

这通常是因为Taro无法识别到正确的页面组件。当使用react进行开发时,成为页面的那个组件需要默认导出 export default functoin Index(){return <></>}并且要在app.config.ts中配置正确的路由信息。

如果出现页面白屏没有节点渲染并且控制台没有出现报错的情况,这可能是因为组件需要接收的外部组件传来的数据为空或者是属性不匹配导致的。在使用的时候需要注意组件中的数据安全兜底,进行判空。

// 如果外部组件没有传入post,那么就不会渲染到页面上。(这是一个嵌入到页面中的组件)
export function Index(post){
    return (
        <div>{post.msg}</div>
    )
}

小程序 Markdown 渲染血泪史:mp-html 组件从入门到放弃再到重生

作者 小时前端
2026年1月17日 18:14

前言:小程序 MD 渲染的难点

在小程序开发中,Markdown 内容的渲染一直是一个让人头疼的问题。与 Web 端丰富的生态不同,小程序平台有着诸多限制:

  • 平台差异性:微信小程序、支付宝小程序、百度小程序等平台对 HTML 和 CSS 的支持程度不同
  • 安全限制:小程序不允许直接使用 dangerouslySetInnerHTML 或动态执行脚本
  • 样式兼容性:部分 CSS 属性在小程序中不被支持
  • 性能瓶颈:复杂的富文本渲染容易影响页面性能
  • 交互限制:图片预览、链接跳转等交互行为需要特殊处理

这些限制使得在小程序中实现完整的 Markdown 渲染变得异常困难。开发者往往需要在功能完整性和性能体验之间寻找平衡。

最近的踩坑经历

最近在开发一个小程序时,需要在小程序中渲染包含代码块、表格、图片等多种元素的 Markdown 内容。一开始天真地以为可以用简单的 HTML 解析器搞定,结果踩了一堆坑。

坑一:代码高亮完全不工作

最先遇到的就是代码高亮问题。我按照文档启用 Markdown 插件后,代码块倒是能显示,但是语法高亮完全没有效果。

// 这是我一开始的配置
<mp-html
  :content="markdownContent"
  :markdown="true"
/>

代码块显示成了普通文本,完全没有颜色区分。后来才发现需要单独启用 highlight 插件,而且还需要重新构建组件。

坑二:多语言代码块识别失败

启用代码高亮后,发现只有 JavaScript 代码能正常高亮,其他语言如 Java、Python、Go 等都显示为纯文本。原来 mp-html 默认只支持基础的几种语言,需要手动下载完整的 Prism.js 文件来支持更多编程语言。

坑三:图片路径和预览问题

Markdown 中的图片链接在小程序中无法直接显示。一开始我尝试使用相对路径,结果全部加载失败。后来发现需要配置 domain 属性来处理图片路径,而且图片预览功能还需要手动开启。

坑四:表格滚动和样式问题

复杂的表格在小程序中显示不全,内容会被截断。特别是手机端,表格列数多时根本看不清内容。而且表格的样式也需要特殊处理,默认样式很难看。

坑五:文本选择和复制功能

用户希望能复制代码块的内容,但是默认情况下小程序不支持文本选择。后来发现可以通过配置 selectable 属性来开启这个功能。

坑六:Mermaid 图表渲染失败

最棘手的问题是 Mermaid 图表渲染。在 Markdown 中,Mermaid 图表通常以代码块形式存在:

```mermaid
graph TD;
    A-->B;
    A-->C;
    B-->D;
    C-->D;

mp-html 组件本身不支持 Mermaid 语法渲染,因为 Mermaid 需要 JavaScript 引擎来解析和绘制图表,而小程序环境对这有限制。

一开始我尝试直接在 Markdown 中嵌入 Mermaid 代码,但结果只能显示为普通的代码块,完全没有图表效果。

## 解决方案大揭秘

经过一番折腾,终于找到了完整的解决方案。以下是我的实战配置:

### 1. 完整的组件配置

```vue
<template>
  <view class="article-container">
    <mp-html
      :content="articleContent"
      :markdown="true"
      :preview-img="true"
      :scroll-table="true"
      :selectable="true"
      :use-anchor="true"
      :lazy-load="true"
      container-style="padding: 20rpx; background: #fff; border-radius: 12rpx;"
      @load="onContentLoad"
      @ready="onContentReady"
      @imgtap="onImageTap"
      @linktap="onLinkTap"
      @error="onError"
    />
  </view>
</template>

2. 插件配置(重点)

需要在 node_modules/mp-html/tools/config.js 中启用所有必要的插件:

module.exports = {
  plugins: [
    'markdown',    // Markdown 解析
    'highlight',   // 代码高亮
    'emoji',       // Emoji 支持
  ],

  // 全局样式配置
  externStyle: `
    .markdown-body {
      color: #333;
      line-height: 1.6;
      font-size: 28rpx;
    }
    .markdown-body h1,
    .markdown-body h2,
    .markdown-body h3 {
      color: #2c3e50;
      margin: 40rpx 0 20rpx 0;
      font-weight: 600;
    }
    .markdown-body p {
      margin: 20rpx 0;
      text-align: justify;
    }
    .markdown-body pre {
      background: #f6f8fa;
      border-radius: 8rpx;
      padding: 20rpx;
      overflow-x: auto;
    }
    .markdown-body code {
      background: #f1f3f4;
      padding: 4rpx 8rpx;
      border-radius: 4rpx;
      font-family: 'Consolas', 'Monaco', monospace;
    }
    .markdown-body blockquote {
      border-left: 4rpx solid #ddd;
      padding-left: 20rpx;
      margin: 20rpx 0;
      color: #666;
      background: #fafafa;
    }
    .markdown-body table {
      border-collapse: collapse;
      width: 100%;
      margin: 20rpx 0;
    }
    .markdown-body th,
    .markdown-body td {
      border: 1rpx solid #ddd;
      padding: 12rpx;
      text-align: left;
    }
    .markdown-body th {
      background: #f5f5f5;
      font-weight: 600;
    }
  `
}

3. 代码高亮扩展(关键步骤)

要支持多种编程语言的代码高亮,需要:

  1. 访问 prismjs.com/download.ht…
  2. 选择 Tomorrow Night 主题
  3. 勾选需要的语言:JavaScript, Java, Python, Go, C++, C, HTML, CSS 等
  4. 下载 prism.min.jsprism.css
  5. 替换 node_modules/mp-html/plugins/highlight/ 目录下的对应文件
  6. 重新构建组件:npm run build:uni-app

4. 代码高亮配置优化

// plugins/highlight/config.js
module.exports = {
  copyByLongPress: true,    // 长按复制代码
  showLanguageName: true,   // 显示语言名称
  showLineNumber: true       // 显示行号
}

5. Mermaid 图表处理

对于 Mermaid 图表渲染问题,需要在前端预处理,将 Mermaid 代码块转换为图片:

// utils/mermaidProcessor.js
export function processMermaidInMarkdown(content) {
  if (!content) return content

  // 匹配 mermaid 代码块
  const mermaidRegex = /```mermaid\s*\n([\s\S]*?)\n```/g

  return content.replace(mermaidRegex, (match, mermaidCode) => {
    try {
      // 将 mermaid 代码发送到服务器生成图片
      // 这里需要实现一个服务,将 mermaid 代码转换为图片 URL
      const imageUrl = generateMermaidImageUrl(mermaidCode)
      return `![Mermaid 图表](${imageUrl})`
    } catch (error) {
      console.error('Mermaid 处理失败:', error)
      // 处理失败时返回原始代码块
      return match
    }
  })
}

// 在 Vue 组件中使用
const processedContent = computed(() => {
  const content = question.value?.content
  if (!content) return ''

  // 先处理 mermaid(将 mermaid 代码块转换为图片)
  const mermaidProcessed = processMermaidInMarkdown(content)
  // 再转换为 HTML
  return mermaidProcessed
})

关键点

  • Mermaid 图表无法在小程序中直接渲染,需要预先转换为图片
  • 需要后端服务或第三方 API 将 Mermaid 代码转换为图片
  • 处理顺序:先转换 Mermaid → 再解析 Markdown → 最后渲染 HTML

6. 图片处理策略

// 处理图片路径
getImageUrl(originalUrl) {
  // 如果是相对路径,拼接完整域名
  if (originalUrl.startsWith('/')) {
    return `https://your-domain.com${originalUrl}`
  }
  // 如果是外部链接,可以添加代理或直接返回
  return originalUrl
}

6. 事件处理

export default {
  methods: {
    onContentLoad() {
      console.log('Markdown 内容加载完成')
      // 可以在这里添加加载完成后的逻辑
    },

    onContentReady() {
      console.log('所有资源加载完成')
      // 图片等资源加载完成后的处理
    },

    onImageTap(e) {
      const { src, i } = e.detail
      console.log(`点击第 ${i + 1} 张图片:`, src)
      // 可以在这里添加图片统计或其他逻辑
    },

    onLinkTap(e) {
      const { href } = e.detail
      // 处理内部链接跳转
      if (href.startsWith('#')) {
        // 锚点跳转
        this.$refs.mpHtml.navigateTo(href.substring(1))
      } else if (href.startsWith('/')) {
        // 内部页面跳转
        uni.navigateTo({ url: href })
      } else {
        // 外部链接,可以复制或用 webview 打开
        uni.setClipboardData({
          data: href,
          success: () => {
            uni.showToast({
              title: '链接已复制',
              icon: 'success'
            })
          }
        })
      }
    },

    onError(err) {
      console.error('渲染错误:', err)
      // 错误处理逻辑
    }
  }
}

性能优化建议

1. 图片懒加载

<mp-html :lazy-load="true" />

2. 按需加载

只在需要 Markdown 渲染的页面引入组件,避免全局注册增加包体积。

3. 缓存策略

对于频繁使用的 Markdown 内容,可以考虑缓存解析结果。

4. 样式优化

使用 externStyle 统一配置样式,避免内联样式过多影响性能。

总结

mp-html 确实是一个功能强大的小程序 Markdown 渲染组件,但在使用的过程中需要注意:

  1. 插件配置:必须正确启用和配置相关插件
  2. 代码高亮:需要手动扩展 Prism.js 来支持更多语言
  3. 样式处理:充分利用 externStyletag-style 进行样式定制
  4. 事件处理:合理处理图片、链接等交互事件
  5. 性能优化:开启懒加载,合理使用缓存策略
  6. Mermaid 图表:需要预处理将 Mermaid 代码转换为图片

虽然踩了不少坑,但最终的效果还是很满意的。现在我们的小程序可以完美地渲染包含代码高亮、表格、图片、Mermaid 图表等多种元素的 Markdown 内容,为用户提供了良好的阅读体验。

特别值得一提的是 Mermaid 图表的处理,这在技术面试题中非常常见。通过预处理将图表转换为图片,我们成功解决了小程序环境中无法直接渲染 Mermaid 的问题。

如果你也在小程序开发中遇到 Markdown 渲染的问题,不妨试试 mp-html 这个组件,希望这篇踩坑记能为你节省一些时间!

从零构建本地AI应用:React与Ollama全栈开发实战

2026年1月17日 17:39

博客开篇:为什么选择本地大模型开发?

在当前的AI浪潮中,OpenAI等云端API虽然强大,但存在数据隐私、Token成本和网络延迟的问题。Ollama 的出现,让开发者可以在本地轻松部署开源大模型(如Qwen、Llama 3等)。结合前端框架(如React),我们可以构建完全私有、低成本的AI应用。

本博客将带你从环境搭建到代码实现,一步步构建一个基于 React 和 Ollama 的聊天应用,并深入剖析其中的技术细节。


第一部分:环境与基础架构

1.1 Ollama 核心原理

Ollama 是一个在本地运行大型语言模型的工具。它通过一个简单的命令行接口,让开发者可以拉取(Pull)、运行(Run)和管理模型。

  • 核心命令:

    • ollama pull qwen2.5:0.5b:拉取特定版本的千问模型。
    • ollama run qwen2.5:0.5b:启动模型。
    • 端口服务: Ollama 默认在 11434 端口提供服务,且提供了兼容 OpenAI 格式的 /v1/chat/completions 接口。

1.2 项目初始化

我们需要一个 React 项目来作为前端界面。使用 Vite 或 Create React App 初始化项目,并安装必要的依赖(如 axios 用于 HTTP 请求)。


第二部分:后端通信层(API 模块)

这是应用的“神经系统”,负责前端与本地大模型的对话。

2.1 代码逻辑解析

在提供的代码中,ollamaApi.js 文件负责创建与 Ollama 服务的连接。

import axios from 'axios';

// 创建 axios 实例
const ollamaApi = axios.create({
  baseURL: 'http://localhost:11434/v1', // 指向本地 Ollama 服务
  headers: {
    'Authorization': 'Bearer ollama', // 注意:Ollama 的固定 Token
    'Content-Type': 'application/json',
  }
});

// 封装聊天请求函数
export const chatCompletions = async (messages) => {
  try {
    const response = await ollamaApi.post('/chat/completions', {
      model: 'qwen2.5:0.5b', // 指定模型名称
      messages, // 对话历史
      stream: false, // 关闭流式输出(简化处理)
      temperature: 0.7, // 控制生成文本的随机性
    });
    return response.data.choices.message.content;
  } catch(err) {
    console.error('ollama 请求失败');
  }
}

2.2 技术点详解

  1. Axios 封装: 使用 axios.create 创建实例,统一管理 baseURLheaders,避免在每个请求中重复配置。

  2. 兼容性接口: Ollama 采用了 OpenAI 的 API 规范,这意味着如果你的后端换成 OpenAI,前端代码几乎不需要修改。

  3. 请求参数:

    • messages: 这是一个数组,包含 role (system/user/assistant) 和 content注意: 必须传入完整的对话历史,模型才能理解上下文。
    • temperature: 值越低越确定,越高越有创造性。

🧠 答疑解惑:易错点与排查

  • 问题:跨域错误 (CORS) 或 网络连接失败

    • 原因: 前端运行在 localhost:3000,而 Ollama 服务运行在 localhost:11434。虽然同源策略通常允许不同端口,但如果 Ollama 服务未启动,会报 ECONNREFUSED

    • 解决方案:

      1. 确保 Ollama 服务已启动(终端运行 ollama serve 或直接运行模型)。
      2. 检查防火墙设置。
      3. 在开发环境中,如果遇到严格的 CORS 限制,可以考虑使用 Vite 的代理配置(Proxy)。
  • 问题:401 Unauthorized 错误

    • 原因: 虽然 Ollama 本地部署通常不需要复杂的鉴权,但根据代码规范,它要求 Header 中必须包含 Authorization: Bearer ollama
    • 解决方案: 确保在请求头中正确设置了该字段。
  • 问题:模型未找到 (Model not found)

    • 原因: 代码中写死了 qwen2.5:0.5b,但本地未下载该模型。
    • 解决方案: 运行 ollama pull qwen2.5:0.5b,或者修改代码中的 model 字段为你本地已有的模型(如 llama3)。

💼 面试模拟:API 层设计

面试官: “在封装 API 时,为什么要使用 Axios 实例而不是直接使用 axios.post?”
候选人:

  1. 统一管理: 如果 baseURL 变更(例如从开发环境切换到生产环境),只需修改实例配置,无需修改每个请求。
  2. 拦截器: 实例可以添加请求拦截器(自动加 Token)和响应拦截器(统一错误处理)。
  3. 复用性: 可以创建多个实例对应不同的后端服务。

第三部分:前端状态管理(Hooks 模块)

3.1 代码逻辑解析

useLLM.js 文件是一个自定义 Hook,用于管理聊天应用的状态。

import { useState } from 'react';
import { chatCompletions } from '../api/ollamaApi.js';

export const useLLM = () => {
  const [messages, setMessages] = useState([
    { role: 'user', content: '你好' },
    { role: 'assistant', content: '你好,我是qwen2.5 0.5b 模型' }
  ]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  // 发送消息逻辑
  const sendMessage = async (userMessage) => {
    // 1. 更新UI:添加用户消息
    const newMessages = [...messages, { role: 'user', content: userMessage }];
    setMessages(newMessages);
    setLoading(true);
    setError(null);

    try {
      // 2. 调用API
      const botResponse = await chatCompletions(newMessages);
      // 3. 更新UI:添加机器人回复
      setMessages(prev => [...prev, { role: 'assistant', content: botResponse }]);
    } catch (err) {
      setError('请求失败,请重试');
    } finally {
      setLoading(false);
    }
  };

  const resetChat = () => {
    setMessages([]);
  };

  return { messages, loading, error, sendMessage, resetChat };
};

3.2 技术点详解

  1. 状态设计: 使用 messages 数组存储对话历史,loading 控制按钮状态,error 处理异常。
  2. 闭包与异步:sendMessage 中,我们使用了函数式更新 setMessages(prev => [...prev, ...]) 来确保获取到最新的状态,避免闭包陷阱。
  3. 错误边界: 使用 try-catch 捕获 API 异常,并通过 setError 反馈给 UI。

🧠 答疑解惑:易错点与排查

  • 问题:机器人回复总是“上一轮”的内容

    • 原因: 这是一个经典的 State 闭包问题。如果你在调用 API 前没有正确更新 messages,或者在调用 API 时传入的是旧的 messages 快照。

    • 解决方案:

      • 方案 A: 如上面的代码所示,先构造新的消息数组 newMessages,传给 API,成功后再更新 State。
      • 方案 B: 使用 useRef 保存最新的消息列表,或者在 setMessages 的回调中处理后续逻辑(虽然 React 18 严格模式下可能执行两次渲染,但逻辑上应保证幂等性)。
  • 问题:输入框无法输入或按钮一直禁用

    • 原因: 逻辑错误导致 loading 状态未重置。
    • 解决方案: 确保 try-catch-finally 结构完整。无论成功或失败,finally 块中必须将 loading 设为 false

💼 面试模拟:React Hooks

面试官: “在 sendMessage 函数中,为什么在 setMessages 之后立即调用 API,传入的 messages 可能不是最新的?如何解决?”
候选人:
React 的 setState 是异步的。在 setMessages 调用后,messages 变量的值在当前函数作用域内并没有立即改变。如果直接传 messages 给 API,会丢失刚刚添加的用户消息。
解决方法:

  1. 预计算: 像代码中那样,先用 const newMessages = [...messages, userMsg] 计算出新数组,传给 API,然后 setMessages(newMessages)
  2. 函数式更新: 如果逻辑复杂,可以使用 useRef 来维护一个可变的引用。

第四部分:视图层(UI 组件)

4.1 代码逻辑解析

App.jsx 是应用的主组件,负责展示和用户交互。

import { useEffect, useState } from 'react';
import { useLLM } from './hooks/useLLM.js';

export default function App() {
  const [inputValue, setInputValue] = useState('');
  const { messages, loading, sendMessage } = useLLM(); // 使用自定义 Hook

  const handleSend = (e) => {
    e.preventDefault();
    if (!inputValue.trim()) return;
    sendMessage(inputValue); // 调用 Hook 中的逻辑
    setInputValue(''); // 清空输入框
  };

  // 页面挂载时的初始化逻辑(可选)
  useEffect(() => {
    // 例如:加载历史记录
  }, []);

  return (
    <div className="min-h-screen bg-gray-50 flex flex-col items-center py-6 px-4">
      <div className="w-full max-w-[800px] bg-white rounded-lg shadow-md flex flex-col h-[90vh] max-h-[800px]">
        {/* 聊天内容区域 */}
        <div className="flex-1 p-4 overflow-y-auto">
          {messages.map((msg, idx) => (
            <div key={idx} className={`mb-4 p-3 rounded-lg max-w-xs ${msg.role === 'user' ? 'bg-blue-100 ml-auto' : 'bg-gray-100'}`}>
              {msg.content}
            </div>
          ))}
        </div>
      </div>
      
      <form className="p-4 border-t" onSubmit={handleSend}>
        <div className="flex gap-2">
          <input
            type="text"
            value={inputValue}
            onChange={e => setInputValue(e.target.value)} 
            placeholder="输入消息....按回车发送"
            disabled={loading} 
            className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          <button 
            type="submit"
            disabled={loading || !inputValue.trim()}
            className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
          >
            {loading ? '思考中...' : '发送'}
          </button>
        </div>
      </form>
    </div>
  );
}

4.2 技术点详解

  1. 受控组件: inputvalue 绑定到 inputValue,通过 onChange 更新状态,保证 UI 与 State 一致。
  2. 表单处理: 使用 onSubmit 处理表单提交,并调用 e.preventDefault() 阻止页面刷新。
  3. 条件渲染: 根据 loading 状态禁用按钮和改变按钮文本,防止重复提交。

🧠 答疑解惑:易错点与排查

  • 问题:按下回车键页面刷新了

    • 原因: <form> 标签的默认行为是 onSubmit 触发页面跳转。
    • 解决方案:handleSend 函数的第一行加上 e.preventDefault();
  • 问题:聊天记录滚动条没有自动到底部

    • 原因: DOM 更新后,容器的 scrollTop 没有自动调整。
    • 解决方案: 使用 useRef 获取聊天容器的 DOM 引用,在 useEffect 中监听 messages 变化,并设置 container.scrollTop = container.scrollHeight

💼 面试模拟:UI 与用户体验

面试官: “如何优化这个聊天界面的用户体验(UX)?”
候选人:

  1. 流式响应: 当前代码设置 stream: false,用户需要等待模型生成完所有文本才能看到结果。开启 stream: true 可以实现逐字输出的效果,体验更像真人打字。
  2. 加载状态: 除了按钮禁用,聊天区域可以增加一个“机器人正在思考...”的 Typing 动画。
  3. 错误重试: 当 API 调用失败时,UI 应该提供一个“重试”按钮,而不是仅仅显示错误文本。

第五部分:进阶与优化

5.1 流式传输 (Streaming)

目前的代码是等待模型生成完所有内容后一次性返回。为了实现类似 ChatGPT 的打字机效果,我们需要开启流式传输。

  • 原理: 设置 stream: true,后端会以 text/event-stream 格式分块传输数据。
  • 实现:chatCompletions 函数中,需要使用 fetch API 替代 Axios(因为 Axios 对流式处理支持较弱),并读取 ReadableStream

5.2 上下文管理

qwen2.5:0.5b 这种 0.5B 参数的模型内存有限。如果对话过长,模型会“忘记”开头的内容,或者出现显存溢出。

  • 解决方案: 实现一个简单的上下文截断逻辑,只保留最近的 N 轮对话传给模型。

5.3 模型切换

可以扩展 UI,让用户在界面上选择不同的本地模型(如从 qwen 切换到 llama3)。


博客结语

通过这篇博客,我们完成了一个从本地大模型部署到前端全栈应用的开发流程。这不仅是一个技术Demo,更是理解现代AI应用架构的基石。

核心收获:

  1. Ollama 是本地AI的基石,它让大模型触手可及。
  2. React Hooks 极大地简化了状态管理的复杂度。
  3. 前后端分离 的思想依然适用,即使是与本地服务通信。

希望这篇教程能帮助你在本地AI开发的道路上更进一步!

Cursor 最新发现:超大型项目 AI 也能做了,上百个 Agent 一起上

作者 张拭心
2026年1月17日 17:13

大家好,我是拭心。

2008 年 9 月 2 日,Google Chrome 浏览器正式发布。这个项目从 2005 年立项到发布,「历时 3 年,投入了数千名工程师」。如今,Chromium 代码规模已超过 3600 万行,被称为“人类史上最复杂的软件工程项目之一”。

图片

而就在最近,Cursor 团队做了一件让人震惊的事:「他们用上百个 AI Agent,花了不到一周时间,从零开始构建了一个浏览器,写出了超过 100 万行代码」

这不是概念验证,也不是玩具项目。

他们用 Agent 持续运行数周,在多个超大型项目上写出了数百万行代码:Java LSP(55 万行)、Windows 7 模拟器(120 万行)、Excel(160 万行)。更令人震撼的是,它们还直接在 Cursor 自己的生产代码库中完成了一次大规模框架迁移,代码增删量达到 +266K/-193K,用时 3 周多。

「从 3 年到 1 周,从数千名工程师到上百个 Agent,这不是量变,是质变」

AI 编程正在跨越一个关键门槛:从“辅助写代码”到“自主开发项目”。

这篇文章我们来了解下 Cursor 是如何做到的,以及对我们意味着什么。

一、多 Agent 协作:从失败到突破

单个 AI Agent 能写出几百行代码,但要开发一个百万行级别的项目,光靠一个 Agent 显然不够。

Cursor 团队的目标是让编码 Agent 持续运行数周,完全自主地完成超大型项目。

这意味着必须让上百个 Agent 同时工作。但问题来了:「怎么让它们高效协作,而不是互相干扰?」

1.1 扁平结构:一场灾难

Cursor 团队最初的想法很直觉:让所有 Agent 具有同等地位,通过一个共享文件自行协同。

每个 Agent 会检查其他 Agent 在做什么、认领一个任务并更新自己的状态。为防止两个 Agent 抢占同一项任务,他们使用了锁机制。

但这套方案在并发方面失败了:

「锁机制成了瓶颈」。 Agent 会持有锁太久,或者干脆忘记释放锁。即使锁机制正常工作,它也会成为瓶颈。二十个 Agent 的速度会下降到相当于两三个 Agent 的有效吞吐量,大部分时间都花在等待上。

「系统非常脆弱」。 Agent 可能在持有锁的情况下失败、尝试获取自己已经持有的锁,或者在完全没有获取锁的情况下更新协调文件。

后来他们尝试用乐观并发控制来替代锁:Agent 可以自由读取状态,但如果自上次读取后状态已经发生变化,则写入会失败。这种方式更简单、也更健壮,但更深层的问题依然存在。

在没有确定任务的情况下,Agent 变得非常规避风险。 它们会回避困难任务,转而做一些小而安全的修改。「没有任何一个 Agent 承担起解决难题或端到端实现的责任」(像极了曾经遇到的同事)。结果就是工作长时间在空转,却没有实质性进展。

这就像一个没有项目经理的团队,每个人都在做“看起来安全”的小任务,没人敢碰核心难题。

1.2 分层结构:像真实的团队一样工作

Cursor 团队后来尝试里将不同角色拆分开来。不再使用每个 Agent 都什么都做的扁平结构,而是搭建了一条职责清晰的流水线:

  • 「规划者」(Planners):持续探索代码库并创建任务。他们可以针对特定区域派生子规划者,使规划过程本身也可以并行且递归地展开。
  • 「执行者」(Workers):领取任务并专注于把任务完成到底。他们不会与其他执行者协调,也不关心整体大局,只是全力处理自己被分配的任务,完成后再提交变更。

在每个周期结束时,会有一个**「评审 Agent」**判断是否继续,然后下一轮迭代会从干净的初始状态重新开始。

这套结构基本解决了协同问题,并且让他们可以扩展到非常大的项目,而不会让任何单个 Agent 陷入视野过于狭窄的状态。成百上千个 Worker 并发运行,向同一个分支推送代码,而且几乎没有冲突。

这就像一个真实的开发团队:「有人负责架构设计和任务拆解,有人专注执行具体任务,各司其职,高效协作。」

1.3 三个震撼案例

有了这套系统后,Cursor 团队开始测试它的边界:

「从零开始构建浏览器」。 Agent 持续运行了将近一周,在 1,000 个文件中写出了超过 100 万行代码。虽然看起来只是一张简单的截图,但从零开始构建一个浏览器极其困难。尽管代码库规模庞大,新启动的 Agent 仍然可以理解它并取得实质性进展。

「Cursor 代码库的框架迁移」。 他们在 Cursor 代码库中就地将 Solid 迁移到 React,整个过程持续了 3 周多,代码增删量达到 +266K/-193K。随着测试的进行,他们确实认为有可能合并这次大规模改动。

「产品性能提升 25 倍」。 在一款即将上线的产品中,一个长时间运行的 Agent 通过一个高效的 Rust 实现,让视频渲染速度提升了 25 倍。它还新增了平滑缩放和平移的能力,使用自然的弹簧过渡和运动模糊效果,并能跟随光标顺畅移动。这部分代码已经合并,不久就会在生产环境中上线。

这些案例证明,多 Agent 开发大型项目不再是概念验证,而是真实的生产力。

二、为什么现在可以做到

上百个 Agent 协作开发超大型项目,这在一年前几乎是不可想象的。Cursor 团队的成功,背后有几个关键因素。

2.1 模型能力的质变

在运行时间极长的任务中,模型选择至关重要。Cursor 团队发现,不同模型在长时间自主工作时表现差异巨大。

「GPT-5.2 系列在长时间自主工作方面要优秀得多:更能遵循指令、保持专注、避免偏离,并且在实现上更加精确和完整」。相比之下,Opus 4.5 往往会更早结束、在方便的时候走捷径,更快地把控制权交还给用户。

这不是说 Opus 4.5 不好,而是不同模型有不同的“性格”。Opus 4.5 更适合需要人类频繁介入的场景,而 GPT-5.2 更适合长时间无人值守的自主开发。

更有意思的是,不同模型在不同角色上各有所长。即便 GPT-5.1-codex 是专门为编码训练的,GPT-5.2 依然是更好的规划者。现在 Cursor 团队会**「针对每个角色选择最适合的模型,而不是依赖单一通用模型」**。

规划者用 GPT-5.2,执行者用 GPT-5.1-codex,评审者可能又是另一个模型。这就像组建一个真实团队,你会根据每个岗位的特点选择最合适的人。

2.2 提示词比框架更重要

系统中有相当大一部分行为,很大程度上取决于如何为这些 Agent 设计提示词。要让它们良好协作、避免异常行为,并在长时间内保持专注,Cursor 团队做了大量实验。

运行框架和模型本身固然重要,但提示词更重要。

这个结论可能让很多人意外。我们往往以为技术架构和模型才是关键,但 Cursor 团队发现,同样的架构,不同的提示词设计,Agent 的表现会有天壤之别。

如何让规划者拆解任务时粒度合适?如何让执行者在遇到困难时不放弃?如何让评审者准确判断工作质量?这些都需要精心设计的提示词。

这也揭示了一个重要趋势:「Prompt Engineering(提示词工程)是成为 AI 时代的核心技能。」

推荐阅读我写的《提示词工程:你缺的不只是专业术语

2.3 减法思维:少即是多

Cursor 团队的许多改进来自“减法”而不是“加法”。

一开始他们为质量控制和冲突解决设计了一个集成者(Integrator)角色,专门负责协调各个 Worker 的代码、解决冲突、确保质量。听起来很合理,对吧?

但后来发现,「集成者制造的瓶颈多于解决的问题。各个 Worker 本身就已经有能力处理彼此之间的冲突。多出来的这个角色反而让流程变得复杂、脆弱,成了整个系统的瓶颈」

去掉集成者后,系统反而更流畅了。

这个经验很有启发性:最好的系统往往比你想的更简单。起初 Cursor 团队尝试借鉴分布式计算和组织设计中的系统模型,但并不是所有这些方法都适用于 Agent。

三、剧变来临的信号

Cursor 团队告诉我们,「上百个 Agent 可以在同一个代码库上协同工作数周,推动雄心勃勃的项目取得实质进展。这不是理论,而是已经发生的现实」

但他们也坦承:多智能体协同仍然是一个难题。当前的系统虽然可用,但离最优状态还差得很远。Planner 应该在任务完成时自动“醒来”规划下一步,Agent 有时会运行时间过长,他们仍然需要定期从头重启,以对抗漂移和思维视野过于狭窄的问题。

即便如此,对于核心问题——“能否通过投入更多 Agent 来扩展 AI 自主编码能力”——他们得到的答案依然比预期更乐观。

回顾过去几年 AI 编程工具的发展:

  • 2022 年:GitHub Copilot 补全代码片段
  • 2023 年:ChatGPT 生成完整函数
  • 2024 年:Cursor 理解项目上下文
  • 2025 年:Cursor/TRAE 支持 Agent 自主开发
  • 2026 年:Cursor 探索多 Agent 系统自主开发超大型项目

编程工具进展神速,AI 从“辅助写代码”变成了“自主开发项目”。

今天,Cursor 用上百个 Agent 写出了 100 万行代码。明年呢?后年呢?AI 编程的能力边界正在快速扩张。

对于开发者来说:如果你的核心能力是“写代码”,那么你需要警惕了。但如果你的核心能力是“理解需求、设计架构、协调资源、解决问题”,那么你反而会因为 AI 而变得更强大。

那些提前拥抱 AI、学会与 AI 协作的人,正在获得巨大的竞争优势。不要等到 AI 完全成熟了再去学习,因为到那时候,窗口期可能已经关闭了。

Cursor 用上百个 Agent 开发超大型项目,这不是终点,而是起点。剧变正在发生,你准备好了吗?

好了,这篇文章到这里就结束了。感谢你的阅读,愿你平安顺遂。

如果对你有帮助,欢迎评论点赞转发,你的支持是我最大的动力❤️

参考资料:

cursor.com/cn/blog/sca…

async/await : 一场生成器和 Promise的里应外合

作者 sophie旭
2026年1月17日 16:43

背景

说实话,async/await 我在日常工作中好像并没有很主动的去用,可能对于异步编程,我宁愿去用 Promise这种显式链式方式告诉自己,这是异步流程。同步式的代码让我有点心有余悸,为啥会这样放着更过“高级”的API不用呢?我想可能有一方面的原因是,我对它的了解不深吧,或者只停留在了解吧,这两天看了下 async/await 的原理,发现它并没有那么神秘,并且是站在两位巨人的肩膀上的。

巨人一号:生成器 Generator

Generator基本使用

Generator相信大家都不陌生啦,我们直接上代码看运行结果吧

// 定义一个生成器函数(普通函数 + *)
function* numberGenerator() {
  console.log("生成器函数开始执行");
  
  // 第一次yield,向外返回值
  const firstValue = yield 1; // yield左边可以接收next传入的参数
  console.log("第一个yield接收到的参数:", firstValue);
  
  // 第二次yield
  const secondValue = yield 2;
  console.log("第二个yield接收到的参数:", secondValue);
  
  // 第三次yield
  yield 3;
  console.log("生成器函数即将执行完毕");
  
  // 函数结束,done会变为true
  return "执行结束";
}

// 1. 调用生成器函数,不会执行函数体,只会得到生成器对象
const generator = numberGenerator();
console.log("调用生成器函数后得到的对象:", generator); // 输出 Generator 对象,无函数体执行日志

// 2. 第一次调用next(),函数体开始执行,直到第一个yield暂停
console.log("第一次next返回:", generator.next()); 
// 输出:
// 生成器函数开始执行
// 第一次next返回: { value: 1, done: false }

// 3. 第二次调用next()并传入参数,参数会作为上一个yield的返回值
console.log("第二次next返回:", generator.next("我是第一个yield的返回值"));
// 输出:
// 第一个yield接收到的参数: 我是第一个yield的返回值
// 第二次next返回: { value: 2, done: false }

// 4. 第三次调用next()并传入参数
console.log("第三次next返回:", generator.next("我是第二个yield的返回值"));
// 输出:
// 第二个yield接收到的参数: 我是第二个yield的返回值
// 生成器函数即将执行完毕
// 第三次next返回: { value: 3, done: false }

// 5. 第四次调用next(),函数执行到return,done变为true
console.log("第四次next返回:", generator.next());
// 输出:
// 第四次next返回: { value: '执行结束', done: true }

// 6. 后续调用next(),value为undefined,done保持true
console.log("第五次next返回:", generator.next());
// 输出:
// 第五次next返回: { value: undefined, done: true }

关键特性解释

  1. 生成器函数定义function* 函数名() 是生成器函数的语法(* 的位置可以在 function 后、函数名前,空格不影响),这是和普通函数最核心的区别。
  2. 调用不执行:调用 numberGenerator() 不会执行函数体,只会返回一个 Generator 对象,这和普通函数调用立即执行完全不同。
  3. yield 暂停与返回
    • yield 是生成器的核心关键词,执行到 yield 时,函数会暂停执行(而非结束),并将 yield 后的值作为 next() 返回对象的 value
    • yield 不像 return 那样终止函数,下次调用 next() 会从暂停的位置继续执行。
  4. next 传参generator.next(参数) 传入的参数,会作为上一个 yield 语句的返回值(注意:第一个 next() 传参无效,因为此时还没有执行过任何 yield)。
  5. done 属性next() 返回对象的 done 属性表示生成器是否执行完毕:
    • false:生成器未执行完,还能继续调用 next() 获取值;
    • true:生成器执行完毕(执行到 return 或函数末尾),后续 next()valueundefined(这一点我经常忽略)。

总结

  1. 生成器函数通过 function* 定义,调用后返回生成器对象,而非立即执行函数体。
  2. yield 负责暂停函数并向外返回值,next() 负责恢复执行,且 next() 可传参作为上一个 yield 的返回值。
  3. next() 返回的对象包含 value(返回值)和 done(执行状态),done: true 表示生成器执行完毕。

Generator throw

// 定义一个包含异常处理的生成器函数
function* fooGenerator() {
  console.log("fooGenerator 开始执行(第一次next触发)");
  
  try {
    // 第一个yield:暂停并返回值,后续可能接收next传参
    const receivedValue = yield "第一个yield返回值";
    console.log("第一个yield接收到next的参数:", receivedValue);

    // 执行到这里时,如果外部调用throw(),异常会抛到当前执行位置
    console.log("准备执行第二个yield...");
    yield "第二个yield返回值";

    console.log("生成器未被中断,继续执行");
  } catch (error) {
    // 捕获外部throw()抛出的异常
    console.log("生成器内部捕获到异常:", error.message);
    // 捕获异常后,生成器可继续yield返回值
    yield "异常捕获后返回的兜底值";
  }

  console.log("生成器函数执行到末尾");
  return "执行结束";
}

// 1. 调用生成器函数,仅得到生成器对象,函数体不执行
const generator = fooGenerator();
console.log("调用fooGenerator后得到的对象:", generator); // Generator 对象

// 2. 第一次调用next():函数体开始执行,直到第一个yield暂停
console.log("===== 第一次调用 next() =====");
const firstNext = generator.next();
console.log("第一次next返回:", firstNext);
// 输出:
// fooGenerator 开始执行(第一次next触发)
// 第一次next返回: { value: '第一个yield返回值', done: false }

// 3. 第二次调用next(参数):从第一个yield暂停处继续执行,参数作为yield返回值
console.log("\n===== 第二次调用 next('bar') =====");
const secondNext = generator.next("bar");
console.log("第二次next返回:", secondNext);
// 输出:
// 第一个yield接收到next的参数: bar
// 准备执行第二个yield...
// 第二次next返回: { value: '第二个yield返回值', done: false }

// 4. 调用throw():从第二个yield暂停处继续执行,但抛出异常
console.log("\n===== 调用 throw() 方法 =====");
const throwResult = generator.throw(new Error("外部手动抛出的异常"));
console.log("throw()返回:", throwResult);
// 输出:
// 生成器内部捕获到异常: 外部手动抛出的异常
// throw()返回: { value: '异常捕获后返回的兜底值', done: false }

// 5. 异常捕获后,生成器仍可继续执行next()
console.log("\n===== 异常后调用 next() =====");
const thirdNext = generator.next();
console.log("第三次next返回:", thirdNext);
// 输出:
// 生成器函数执行到末尾
// 第三次next返回: { value: '执行结束', done: true }

throw() 方法核心逻辑解释

  1. throw() 的执行触发
    • next() 一样,调用 generator.throw() 会让生成器从上一次暂停的位置继续执行;
    • 但不同于 next() 传入普通参数,throw() 会在生成器当前执行位置主动抛出一个异常 -- 意思就是 从当前位置直接去走catch逻辑,try逻辑不会再走了
  2. 异常的捕获与处理
    • 如果生成器内部有 try/catch 块包裹了暂停位置后的执行逻辑,异常会被内部捕获,生成器不会立即终止;
    • 如果内部没有捕获,异常会抛出到外部,生成器状态变为 done: true,后续调用 next() 只会返回 { value: undefined, done: true }
  3. throw() 的返回值
    • 即使抛出了异常,只要内部捕获了,throw() 也会返回和 next() 一样的对象({ value, done });
    • 这个 value 是异常捕获后,生成器继续执行到下一个 yield 时返回的值(示例中就是 异常捕获后返回的兜底值)。

总结

  1. throw() 方法和 next() 一样能触发生成器继续执行,但核心作用是向生成器内部抛出异常
  2. 生成器内部可通过 try/catch 捕获 throw() 抛出的异常,捕获后生成器不会终止,仍可继续执行并返回新的 yield 值;
  3. 生成器的执行始终是“暂停-恢复”模式:next() 恢复执行并传参,throw() 恢复执行并抛异常,本质都是改变生成器的执行状态。

这个特性在异步编程中很实用,比如可以用 throw() 主动终止异步任务、处理异步流程中的错误等。

巨人二号:Promise

Promise大家应该再熟悉不过了,由于篇幅原因,这篇先不做深入了解

下面让我们看看他们结合在一起会发生什么吧

“回调式” 写法 =》 “同步式” 写法

// 传统 Promise 回调写法:有明显的回调嵌套感,代码“右移”
fetch('https://jsonplaceholder.typicode.com/todos/1')
  .then(response => response.json())
  .then(data => {
    console.log('请求结果:', data);
    // 如果还要发第二个请求,就要在这继续嵌套 then,越套越深
    fetch(`https://jsonplaceholder.typicode.com/todos/${data.id + 1}`)
      .then(res => res.json())
      .then(data2 => console.log('第二个请求结果:', data2));
  })
  .catch(error => console.log('错误:', error));

以上代码,我们看到 虽然用到了Promise,但是也难免会出现嵌套,下面我们看看下面这段代码,

// 模拟 Ajax 请求:返回 Promise(真实项目里是 fetch/axios 等)
function requestData(url) {
  return fetch(url)
    .then(response => response.json()) // 解析 JSON 数据
    .catch(error => console.error('请求失败:', error));
}

// 定义生成器函数 main:内部用 yield 暂停,写“同步风格”的异步逻辑
function* main() {
  console.log('开始执行生成器,准备发请求');
  
  // 1. yield 抛出 Promise(Ajax 请求),生成器暂停执行
  // 这里的 result 会接收后续 next(data) 传进来的请求结果
  const result = yield requestData('https://jsonplaceholder.typicode.com/todos/1');
  
  // 3. 当外部调用 next(data) 后,生成器从暂停处恢复,执行这行
  console.log('生成器内部拿到的请求结果:', result);
  
  // 可以继续发第二个请求,依然是“同步写法”
  const result2 = yield requestData(`https://jsonplaceholder.typicode.com/todos/${result.id + 1}`);
  console.log('第二个请求结果:', result2);
}

// 外界执行生成器的逻辑
const generator = main(); // 1. 调用生成器,得到生成器对象(函数体不执行)

// 2. 第一次调用 next():生成器开始执行,直到第一个 yield 暂停
const firstNextResult = generator.next(); 
// firstNextResult.value 就是 yield 抛出的 Promise(requestData 的返回值)
const promise = firstNextResult.value;

// 3. 等 Promise 执行完成(请求返回),把结果传给生成器
promise.then(data => {
  // 调用 next(data):把请求结果传进去,生成器从 yield 处恢复执行
  generator.next(data);
  
  // 第二个请求的处理(如果有):这里可以封装成自动执行逻辑,不用手动写
  const secondNextResult = generator.next();
  secondNextResult.value.then(data2 => {
    generator.next(data2);
  });
});

逐行解释(对应你的描述)

  1. 定义生成器函数 main

    • 内部 yield requestData(...)requestData 返回 Promise(Ajax 请求),yield 会把这个 Promise 抛出去,同时暂停生成器的执行(不会立刻执行后面的 console.log)。
    • const result = yield ...:这里的 result 暂时没有值,要等后续把请求结果传进来才会赋值。
  2. 外界调用生成器

    • const generator = main():调用生成器函数,只得到生成器对象,main 的函数体完全不执行
    • generator.next():第一次调用 nextmain 开始执行,直到遇到 yield 暂停;next() 返回的对象 { value: Promise, done: false } 中,value 就是 yield 抛出来的 Promise(Ajax 请求)。
  3. 处理 Promise 结果,恢复生成器

    • 给 Promise 加 then 回调,等请求返回拿到 data 后,调用 generator.next(data)
      • data 会作为上一个 yield 语句的返回值,赋值给 main 里的 result
      • 生成器从暂停的 yield 位置继续执行,执行后面的 console.log(result) —— 这一步就像“同步代码”一样,直接拿到了异步请求的结果。

为什么说“消灭了回调,近乎同步体验”

对比两种写法:

  • 传统 Promise:请求结果的处理逻辑必须写在 then 回调里(异步风格);
  • 生成器写法:main 内部没有任何 then 回调,const result = yield ... 看起来就像“同步赋值”,拿到结果后直接 console.log —— 代码结构和同步代码完全一致,只是多了 yield 关键字。

总结

  1. 核心目的:用生成器的 yield 暂停特性,把异步 Promise 的“回调式”写法,改成生成器内部的“同步式”写法,消灭回调嵌套;
  2. 核心流程:生成器 yield 抛出 Promise → 等 Promise 执行完成 → 调用 next(结果) 把值传回生成器 → 生成器恢复执行,拿到结果;

等一下,怎么看着更复杂了??

不知道你发现没有发出疑问,经过转换,怎么比我写个嵌套更复杂了?别着急,我们一步步简化!

先看问题:手动处理多个 yield 的弊端

如果 main 里有 3 个 yield(3 次 Ajax 请求),手动处理会写成这样,重复代码特别多:

// 手动处理多个 yield 的糟糕写法(仅举例,不要这么写)
const gen = main();
// 处理第一个请求
let res1 = gen.next();
res1.value.then(data1 => {
  // 处理第二个请求
  let res2 = gen.next(data1);
  res2.value.then(data2 => {
    // 处理第三个请求
    let res3 = gen.next(data2);
    res3.value.then(data3 => {
      gen.next(data3); // 最后一次 next
    });
  });
});

这种写法和回调嵌套没区别,完全违背了用生成器简化异步的初衷 —— 而递归自动执行器就是解决这个问题的关键。

核心思路:递归自动执行器

我们写一个通用的递归函数(比如叫 runGenerator),它会:

  1. 调用生成器的 next(),拿到返回结果(包含 value(Promise)和 done);
  2. 判断 done:如果是 true,递归终止;
  3. 如果是 false,给 value(Promise)加 then 回调;
  4. then 里拿到异步结果,再递归调用自身,把结果传给 next(),继续处理下一个 yield

完整代码示例(含递归执行器)

// 1. 模拟 Ajax 请求:返回 Promise
function requestData(url) {
  return fetch(url)
    .then(res => res.json())
    .catch(err => console.error('请求失败:', err));
}

// 2. 定义有多个 yield 的生成器函数
function* main() {
  console.log('开始第一个请求');
  // 第一个 yield:请求 todo/1
  const result1 = yield requestData('https://jsonplaceholder.typicode.com/todos/1');
  console.log('第一个请求结果:', result1);

  console.log('开始第二个请求');
  // 第二个 yield:基于第一个结果请求 todo/2
  const result2 = yield requestData(`https://jsonplaceholder.typicode.com/todos/${result1.id + 1}`);
  console.log('第二个请求结果:', result2);

  console.log('开始第三个请求');
  // 第三个 yield:基于第二个结果请求 todo/3
  const result3 = yield requestData(`https://jsonplaceholder.typicode.com/todos/${result2.id + 1}`);
  console.log('第三个请求结果:', result3);

  console.log('所有请求执行完毕');
  return '最终结果';
}

// 3. 核心:递归自动执行器(通用函数,任何生成器都能用)
function runGenerator(generator) {
  // 定义递归函数
  function next(data) {
    // 调用 next(),拿到当前执行结果({ value: Promise, done: boolean })
    const result = generator.next(data);

    // 终止条件:如果 done 为 true,递归结束
    if (result.done) {
      console.log('生成器执行完毕,最终返回值:', result.value);
      return; // 终止递归
    }

    // 如果 done 为 false,处理当前的 Promise
    result.value
      .then(data => {
        // 递归调用 next,把当前 Promise 的结果传给下一个 yield
        next(data);
      })
      .catch(err => {
        // 处理异常:也可以调用 generator.throw(err) 抛到生成器内部
        console.error('异步执行出错:', err);
      });
  }

  // 启动第一次递归(第一次 next 不传参,因为第一个 yield 左边没值可接)
  next();
}

// 4. 启动执行器,自动处理所有 yield
runGenerator(main());

逐行解释递归执行器的逻辑

  1. 执行器初始化:调用 runGenerator(main()),先创建生成器对象,再执行 next()(无参),启动第一次递归。
  2. 第一次递归
    • generator.next() → 执行 main 到第一个 yield,返回 { value: 第一个请求的 Promise, done: false }
    • 因为 done: false,给 Promise 加 then
    • 等第一个请求返回 data1,递归调用 next(data1)
  3. 第二次递归
    • generator.next(data1)data1 赋值给 result1main 执行到第二个 yield,返回 { value: 第二个请求的 Promise, done: false }
    • 给第二个 Promise 加 then,拿到 data2 后递归调用 next(data2)
  4. 第三次递归
    • generator.next(data2)data2 赋值给 result2main 执行到第三个 yield,返回 { value: 第三个请求的 Promise, done: false }
    • 拿到 data3 后递归调用 next(data3)
  5. 第四次递归
    • generator.next(data3)data3 赋值给 result3main 执行到末尾,返回 { value: '最终结果', done: true }
    • 检测到 done: true,递归终止。

为什么递归能解决问题?

  • 自动迭代:不管 main 里有多少个 yield,递归都会自动处理,不用手动写每一次的 next()then()
  • 终止条件清晰:靠 done: true 判断生成器是否执行完,避免无限递归;
  • 代码复用runGenerator 是通用函数,任何“yield 出 Promise”的生成器都能直接用,不用改逻辑。

生成器 + Promise 实现异步同步化 完整方案

再梳理这个“完整执行器”的核心逻辑

把我们之前的“简化版递归执行器”升级成工业级的 co 函数,完整逻辑如下(伪代码梳理):

// 1. 定义通用执行器 co
function co(generatorFunc) {
  // 创建生成器对象
  const generator = generatorFunc();

  // 2. 定义递归处理函数 handlerResult
  function handlerResult(result) {
    // 终止条件:生成器执行完毕
    if (result.done) {
      return Promise.resolve(result.value); // 最终返回 Promise,对齐 async 函数
    }

    // 处理 Promise(成功/失败)
    return Promise.resolve(result.value) // 确保 value 是 Promise(兼容非 Promise 情况)
      .then(
        // 成功回调:把结果传给 next,递归处理下一个 result
        (data) => handlerResult(generator.next(data)),
        // 失败回调:调用 throw() 抛异常给生成器内部
        (error) => handlerResult(generator.throw(error))
      );
  }

  // 3. 启动递归:传入第一次 next() 的结果
  return handlerResult(generator.next());
}

// 4. 生成器内部用 try/catch 捕获异常
function* main() {
  try {
    const res1 = yield requestData('url1');
    const res2 = yield requestData('url2');
  } catch (err) {
    console.log('捕获异常:', err); // 捕获 generator.throw() 抛的异常
  }
}

// 5. 调用 co 执行生成器(通用复用)
co(main).then(finalRes => console.log('最终结果:', finalRes));

总结

这些内容,是我们之前讨论的生成器+Promise 实现异步同步化的“完整版落地”:

  1. 核心逻辑完全一致:递归执行器 + 暂停-恢复 + Promise 处理;
  2. 补充了关键细节:异常处理(throw() + try/catch);

好的,该 async/await登场了

讲了这么多啊都没提到我们的主角,好的他们来了~

用 async/await 改写你这段手动代码

你手动处理 2 个请求的代码,换成 async/await 后长这样,没有任何手动的 next/then,但底层逻辑完全一致:

// 原生成器逻辑 → 等价的 async/await 代码
async function mainAsync() {
  // 对应:第一次 next() + promise.then + generator.next(data)
  const result = await requestData('https://jsonplaceholder.typicode.com/todos/1');
  console.log('生成器内部拿到的请求结果:', result);

  // 对应:secondNextResult = generator.next() + secondNextResult.value.then + generator.next(data2)
  const result2 = await requestData(`https://jsonplaceholder.typicode.com/todos/${result.id + 1}`);
  console.log('第二个请求结果:', result2);
}

// 调用 async 函数(引擎自动执行所有 next/then 逻辑)
mainAsync();

关键补充:async/await 不是“新东西”,是语法糖

async/await 并不是 JavaScript 新增的底层特性,它就是生成器 + 自动执行器的“语法糖”—— 本质上:

  1. async 函数 = 生成器函数 + 内置自动执行器;
  2. await 关键字 = yield 关键字的“语义化包装”(更易读,不用写 yield);
  3. 你手动写的递归执行器 → JS 引擎内置的、更高效的自动执行逻辑。

举个更直观的例子:引擎如何处理 await

当你写:

async function fn() {
  const a = await Promise.resolve(1);
  const b = await Promise.resolve(a + 1);
  return b;
}
fn().then(res => console.log(res)); // 输出 2

JS 引擎在底层会做这些事(对应你的手动代码):

// 引擎模拟的底层逻辑(伪代码)
function* fnGenerator() {
  const a = yield Promise.resolve(1);
  const b = yield Promise.resolve(a + 1);
  return b;
}

// 引擎内置的自动执行器(类似你写的递归函数)
function autoRun(generator) {
  const gen = generator();
  function next(data) {
    const { value, done } = gen.next(data);
    if (done) return Promise.resolve(value);
    return value.then(res => next(res));
  }
  return next();
}

// 引擎自动调用
autoRun(fnGenerator).then(res => console.log(res)); // 输出 2

总结

  1. 你手动写的 next()then()、传参、处理下一个 yield 这些操作,全部由 async/await 自动完成,不用你写一行;
  2. async/await 是生成器+自动执行器的语法糖,核心逻辑和你手动封装的递归执行器完全一致;
  3. 区别仅在于:引擎的内置执行器更高效、更健壮(处理了异常、中断等边界情况),而你手动写的是简化版。

简单说:async/await 就是把你手动做的“脏活累活”(调 next、等 Promise、传结果)全部自动化了,让你只需要写“看起来像同步”的代码就行。

async/await用了哪些设计模式呢?

async/await 并不是单一的设计模式,而是组合了多个经典模式的“语法糖封装”,核心是这3个:

设计模式 对应 async/await 的角色
迭代器模式(Iterator) 生成器(Generator)本身就是迭代器的一种实现,next() 方法就是迭代器的核心(一步步“迭代”执行异步任务);
观察者模式(Observer) Promise 是典型的观察者模式(then/catch 监听 Promise 状态变化),await 本质是“监听 Promise 完成后通知生成器继续执行”;
模板方法模式(Template Method) JS 引擎内置的“自动执行器”就是模板方法:把“调用 next → 等 Promise → 传参 → 递归”的固定逻辑封装成模板,async/await 使用者只需要写业务逻辑(await 后面的异步任务),不用关心执行流程;

关于设计模式,我应该会拉出一个主题好好研究

现在你有没有理解我说的“里应外合“

先明确“里”和“外”的角色

角色 对应代码/逻辑 核心动作
里(生成器内部) async 函数里的 await 异步任务 1. 抛出 Promise 给“外”;2. 暂停等待;3. 接收“外”传回来的值继续执行
外(JS引擎/执行器) 引擎内置的自动执行器 1. 接住“里”抛出来的 Promise;2. 等待 Promise 执行完成;3. 把结果传回“里”,触发继续执行

用“里应外合”拆解完整执行流程(一步一步对应)

我们用 await requestData() 为例,还原这个互动过程:

第一步:“里”先出招 —— 抛出 Promise,原地待命
async function fn() {
  // 「里」的动作:
  // 1. 执行 await requestData(),先调用 requestData 得到 Promise;
  // 2. 把这个 Promise “扔”给外面的引擎;
  // 3. 自己暂停执行(就像士兵原地待命,等外面的消息);
  const res = await requestData('url'); 
  console.log(res); // 暂停后,这行暂时不执行
}

👉 对应“里应外合”:里先“应”—— 抛出异步任务(Promise),告诉外面“我要等这个做完”

第二步:“外”接招 —— 处理 Promise,等结果

JS 引擎(外面的执行器)接住这个 Promise 后,不会闲着:

  1. 监听 Promise 的状态变化(成功/失败);
  2. 等待异步任务完成(比如接口请求返回数据);
  3. 拿到结果(比如接口返回的 data)。

👉 对应“里应外合”:外“合”—— 响应里面的请求,处理异步任务,拿到结果

第三步:“外”回传结果 —— 给“里”赋值,触发继续执行

引擎拿到 data 后,会做两件关键事:

  1. 把 data 赋值给 await 左边的变量 res(相当于 res = data);
  2. 告诉“里”:“结果拿到了,你继续往下走!” —— 生成器从暂停的位置恢复执行。
async function fn() {
  const res = await requestData('url'); 
  // 「里」的动作:
  // 接收到外面传的 data,赋值给 res,然后执行这行
  console.log(res); 
}

👉 对应“里应外合”:外把结果“合”进里面,里收到后继续执行,完成一次互动

第四步:多轮“里应外合”(多个 await)

如果有多个 await,就是重复上面的过程:

async function fn() {
  // 第一轮里应外合:
  const res1 = await requestData('url1'); 
  // 第二轮里应外合:
  const res2 = await requestData(`url2?${res1.id}`); 
}

👉 里抛第一个 Promise → 外处理 → 回传 res1 → 里再抛第二个 Promise → 外处理 → 回传 res2 → 直到执行完。

补充:异常场景的“里应外合”(更完整)

如果 Promise 失败(比如接口报错),互动逻辑变成:

  1. 里:抛出失败的 Promise;
  2. 外:接住失败的 Promise,拿到错误信息;
  3. 外:把错误“扔回”里(对应 generator.throw());
  4. 里:如果有 try/catch,就捕获这个错误,完成“合”。
async function fn() {
  try {
    const res = await requestData('error-url'); // 里抛出失败的 Promise
  } catch (err) {
    console.log(err); // 外把错误传回来,里捕获处理
  }
}

记忆强化:用“里应外合”总结核心

  1. 里的核心:抛 Promise 等结果,接结果继续走(“应”—— 响应异步需求,等外部反馈);
  2. 外的核心:接 Promise 处理完,传结果促执行(“合”—— 配合内部需求,反馈处理结果);
  3. async/await 就是把这个“里应外合”的过程,从“手动写执行器”变成“引擎自动做”,你只需要写“里”的逻辑就行。

这个比喻完全抓住了本质 —— 生成器(里)和执行器(外)的互动,就是靠“抛 Promise-处理 Promise-传结果”完成的“里应外合”,记住这个比喻,就能记住 async/await 的底层执行逻辑了。

这下明白了 async/await 很多用法的底层逻辑

一、为什么必须有 async?—— 标记“异步执行器容器”

核心原因:

async 是给函数打一个“标记”,告诉 JS 引擎:这个函数内部有 await,需要按“生成器+自动执行器”的逻辑来处理,而不是普通函数的“一次性执行完”。

对应底层逻辑(里应外合):
  • 没有 async 的普通函数:调用后会“一口气执行完所有代码”,无法暂停;
  • 加了 async 的函数:JS 引擎会把它当成「生成器+自动执行器」的封装体——调用 async 函数时,引擎先创建“生成器式”的执行上下文,为后续 await 的“暂停-恢复”铺路。
记忆例子:
// 错误:没有 async,用 await 会直接报错
function fn() {
  const res = await requestData(); // Uncaught SyntaxError: await is only valid in async functions
}

// 正确:async 标记函数是“异步执行容器”
async function fn() {
  const res = await requestData(); 
}

二、为什么要用 try/catch?—— 统一捕获“里应外合”的异常

核心原因:

await 后面的 Promise 一旦失败(rejected),底层会触发 generator.throw() 向函数内部抛异常——如果不捕获,这个异常会变成“未捕获异常”导致程序崩溃;而 try/catch 是同步代码的错误处理方式,async/await 把异步错误“伪装”成同步错误,自然用 try/catch 捕获最贴合直觉。

对应底层逻辑(里应外合):
  • 外(引擎):发现 Promise 失败 → 调用 generator.throw(错误) 把异常抛回“里”;
  • 里(async 函数):如果没有 try/catch,异常会从“里”逃出,变成全局未捕获异常;有 try/catch 则能在内部接住,和同步代码的错误处理完全一致。
记忆例子:
async function fn() {
  try {
    // await 后面的 Promise 失败 → 引擎抛异常到这里
    const res = await Promise.reject(new Error('请求失败'));
  } catch (err) {
    console.log('捕获错误:', err.message); // 输出:请求失败
  }
}

三、为什么说“await 后面的代码相当于 promise.then()”?—— 都是“等待完成后执行”

核心原因:

await 会暂停函数执行,等后面的 Promise 完成后,才执行 await 下面的代码——这和 promise.then(回调) 里“回调函数等 Promise 完成后执行”的逻辑完全一致,只是 await 把回调里的代码“扁平化”了。

对应底层逻辑(里应外合):
  • Promise.then 写法:回调函数是“外”通知“里”执行的逻辑(观察者模式);
  • await 写法:await 下面的代码,就是引擎自动帮你放到 then 回调里的逻辑,只是不用写回调嵌套。
记忆对比:
// Promise.then 写法(回调嵌套)
requestData().then(res => {
  console.log(res); // 这行在 then 回调里,等 Promise 完成后执行
});

// async/await 写法(扁平化)
async function fn() {
  const res = await requestData();
  console.log(res); // 这行等价于上面 then 里的代码,等 Promise 完成后执行
}

四、为什么说“async 函数返回的是 Promise”?—— 对齐异步生态,兼容执行器逻辑

核心原因:
  1. 底层逻辑:async 函数的自动执行器最终要返回一个“结果容器”,而 Promise 是 JS 里唯一的“异步结果容器”——不管函数内部有没有 await,引擎都会把返回值包装成 Promise;
  2. 生态兼容:Promise 是 JS 异步的“通用语言”,返回 Promise 能让 async 函数和现有异步逻辑(.then/.catch、Promise.all 等)无缝衔接。
记忆例子:
// 1. 显式返回普通值 → 自动包装成 Promise
async function fn1() {
  return 123; // 等价于 return Promise.resolve(123)
}
fn1().then(res => console.log(res)); // 输出 123

// 2. 没有 return → 返回 Promise.resolve(undefined)
async function fn2() {
  await requestData();
}
fn2().then(res => console.log(res)); // 输出 undefined

// 3. 抛出异常 → 返回 Promise.reject(错误)
async function fn3() {
  throw new Error('出错了');
}
fn3().catch(err => console.log(err.message)); // 输出 出错了

五、补充:我经常混淆的点——“await 不是返回 Promise,是等待 Promise 并取其结果”

  • ❌ 错误:await 返回 Promise;
  • ✅ 正确:await 等待 后面的 Promise 完成,然后取出 Promise 的最终结果(成功值/失败抛异常)。
例子验证:
async function fn() {
  // requestData() 返回 Promise,await 等待它完成,取出结果赋值给 res
  const res = await requestData(); 
  console.log(res); // res 是 Promise 的成功值(比如接口返回的 data),不是 Promise
}

对应到底层执行器的逻辑:

// 对应的生成器函数
function* genFn() {
  const res = yield Promise.resolve(1); // await 的底层:yield 抛 Promise,等结果
  console.log(res); // res = 1(next 传进来的)
  return 3; // 生成器最终的 value = 3
}

// 执行器处理
autoRun(genFn).then(val => console.log(val)); // 输出 3
// autoRun 里的逻辑:
// - 第一次 next():yield 出 Promise.resolve(1),等待完成后 next(1)
// - 第二次 next(1):res = 1,执行到 return 3,done = true
// - 触发 if (done),返回 Promise.resolve(3) → 这就是 async 函数的返回值

记忆口诀:await 取 Promise 的“值”,async 包返回值成“Promise”——前者是“拆包”,后者是“打包”。

总结(核心记忆点)

把这些“为什么”浓缩成4句话,记下来就彻底懂了:

  1. async 是“容器标记”:告诉引擎这个函数要按“生成器+自动执行器”逻辑跑;
  2. try/catch 是“异常兜底”:接住引擎从外部抛回的 Promise 失败异常;
  3. await 下面的代码 = then 回调:都是等 Promise 完成后执行,只是写法更平;
  4. async 函数返回 Promise:对齐异步生态,让结果能被 .then/.catch 处理。

本质上,async/await 所有的“规则”(必须加 async、用 try/catch 等),都是为了让“生成器+自动执行器”的底层逻辑,能以“同步代码”的形式呈现——这也是它最巧妙的地方。

Message组件和Vue3 进阶:手动挂载组件与 Diff 算法深度解析

作者 干死前端
2026年1月17日 16:24

ai_image_1768638049154_1.jpg

Vue3 进阶:手动挂载组件与 Diff 算法深度解析

很多 Vue 开发者习惯了在 <template> 里写组件,但在开发 Message(全局提示)、Modal(模态框)或 Notification(通知)这类组件时,我们往往希望通过 JS 函数直接调用,而不是在每个页面都写一个 <MyMessage /> 标签。本文将带你深入 Vue3 底层,看看如何手动渲染组件

1. 为什么需要手动挂载?

想象一下,如果你想弹出一个成功提示,哪种方式更优雅?

方式 A (声明式): 需要在每个页面的 template 里都写一遍组件,还要定义一个变量来控制显示隐藏。

<template>
  <MyMessage :visible="showMsg" message="操作成功" />
  <button @click="showMsg = true">点击</button>
</template>

方式 B (命令式): 直接在 JS 里调用,随用随调,完全解耦。

import { message } from 'my-ui'

function handleClick() {
  message.success('操作成功')
}

显然,方式 B 是组件库的标准做法。但 Vue 的组件通常是渲染在父组件的模板里的,如何把它“凭空”变出来并挂载到 document.body 上呢?

这就需要用到 Vue3 暴露的两个底层 API:createVNoderender

2. 核心 API 解密

createVNode:画图纸

在 Vue 中,一切皆 VNode(虚拟节点)。普通的 .vue 文件只是一个组件定义,它不是 DOM,也不是 VNode。我们需要用 createVNode 把它实例化。

import { createVNode } from 'vue'
import MyComponent from './MyComponent.vue'

// 这就像是拿着图纸 (MyComponent)
// 创建了一个具体的实例化对象 (vm)
// 第二个参数是 props
const vnode = createVNode(MyComponent, { title: 'Hello' })

render:施工队

有了 VNode,它还只是内存里的对象。我们需要 render 函数把它变成真实的 DOM 节点,并挂载到某个容器上。

import { render } from 'vue'

const container = document.createElement('div')
// 把 vnode 渲染到 container 盒子里
render(vnode, container)

// 最后把盒子放到 body 上
document.body.appendChild(container)

3. 实战:手写一个简单的 Message 函数

让我们来看看 packages/components/message/src/method.ts 是如何实现的。

第一步:创建容器与 VNode

import { createVNode, render } from 'vue'
import MessageConstructor from './message.vue'

export function message(options) {
  // 1. 创建一个临时的 div 容器
  const container = document.createElement('div')

  // 2. 创建 VNode,并将 options 作为 props 传入
  // 例如:createVNode(MessageConstructor, { message: '你好', type: 'success' })
  const vnode = createVNode(MessageConstructor, options)

  // 3. 将 VNode 渲染到 container 中
  // 此时 container.firstElementChild 就是组件生成的真实 DOM
  render(vnode, container)

  // 4. 将真实 DOM 追加到 body
  document.body.appendChild(container.firstElementChild!)
}

第二步:处理组件卸载

这就完事了吗?并没有。如果我们不处理销毁逻辑,这些 DOM 节点会一直堆积在 body 里,造成内存泄漏。

我们需要在组件内部发射一个 destroy 事件(比如在动画结束时),然后在外部监听它。

const vnode = createVNode(MessageConstructor, {
  ...options,
  // 监听组件内部 emit('destroy')
  onDestroy: () => {
    // 移除 DOM
    render(null, container) // 这一步会触发组件的 unmounted 钩子
  }
})

4. 源码深潜:createVNode 和 render 到底干了啥?

对于好奇心强的同学,可能想知道:Vue 内部到底是怎么把这几行代码变成页面的?让我们用最通俗的伪代码来拆解一下。

4.1 createVNode:给节点“打标签”

createVNode 的核心任务不仅仅是创建一个对象,更是为了性能优化。它会根据你传入的类型,给 VNode 打上一个二进制标记(ShapeFlag)。

// 伪代码简化版
function createVNode(type, props, children) {
  // 1. 定义 VNode 结构
  const vnode = {
    type, // 组件对象 或 'div' 标签名
    props,
    children,
    component: null, // 稍后挂载组件实例
    el: null, // 稍后挂载真实 DOM
    shapeFlag: 0 // 核心:类型标记
  }

  // 2. 通过位运算打标记
  if (typeof type === 'string') {
    vnode.shapeFlag = ShapeFlags.ELEMENT // 这是一个 HTML 标签 (div, span)
  }
  else if (typeof type === 'object') {
    vnode.shapeFlag = ShapeFlags.STATEFUL_COMPONENT // 这是一个 Vue 组件
  }

  return vnode
}

为什么要这么做? Vue 的更新过程非常频繁。有了 shapeFlag,在后续的 Diff 过程中,Vue 就不需要每次都去猜“这是个啥”,直接看二进制位就知道怎么处理,速度极快。

4.2 render:万能的包工头

render 函数其实非常简单,它背后真正的干活主力是 patch 函数。

// 伪代码简化版
function render(vnode, container) {
  if (vnode == null) {
    // 如果传 null,说明要销毁
    if (container._vnode) {
      unmount(container._vnode) // 卸载旧节点
    }
  }
  else {
    // 如果有新 VNode,就开始“打补丁”
    // 参数:(旧节点, 新节点, 容器)
    patch(container._vnode || null, vnode, container)
  }

  // 记住这次渲染的 VNode,下次更新时它就是“旧节点”了
  container._vnode = vnode
}

4.3 patch:分发任务

patch 是 Vue 渲染器的核心。它根据我们前面打的 shapeFlag,把任务分发给不同的处理函数。

function patch(n1, n2, container) {
  if (n1 && !isSameVNodeType(n1, n2)) {
    // 如果类型都不一样(比如从 div 变成了 span),直接卸载旧的
    unmount(n1)
    n1 = null
  }

  const { shapeFlag } = n2
  if (shapeFlag & ShapeFlags.ELEMENT) {
    // 这是一个 HTML 标签
    processElement(n1, n2, container)
  }
  else if (shapeFlag & ShapeFlags.COMPONENT) {
    // 这是一个 Vue 组件
    processComponent(n1, n2, container)
  }
}

4.4 深入 processComponent:组件是怎么跑起来的?

patch 发现这是个组件时,它会区分是“初次挂载”还是“更新”。

function processComponent(n1, n2, container) {
  if (n1 == null) {
    // 1. 挂载组件 (Mount)
    mountComponent(n2, container)
  }
  else {
    // 2. 更新组件 (Update)
    updateComponent(n1, n2)
  }
}

mountComponent 做的事情:

  1. 创建实例const instance = createComponentInstance(vnode)
  2. 设置状态:初始化 propsslots,执行 setup() 函数。
  3. 建立副作用:创建一个 effect(响应式副作用),运行组件的 render 函数生成子树(subTree),并监听响应式数据变化。

4.5 深入 processElement:挂载与更新

patch 遇到 HTML 标签时,会根据 n1(旧节点)是否存在来决定是初始化还是更新。

1. 挂载 (Mount)

如果 n1null,说明是初次渲染。Vue 会调用宿主环境的 API(如 document.createElement)创建真实 DOM,并将其插入到容器中。

function mountElement(vnode, container) {
  // 1. 创建真实 DOM
  const el = (vnode.el = hostCreateElement(vnode.type))

  // 2. 处理 Props (Style, Class, Event)
  for (const key in vnode.props) {
    hostPatchProp(el, key, null, vnode.props[key])
  }

  // 3. 处理子节点 (递归 mount)
  mountChildren(vnode.children, el)

  // 4. 插入页面
  hostInsert(el, container)
}
2. 更新 (Patch)

如果 n1 存在,Vue 就需要对比新旧节点,做最小量的 DOM 操作。

  1. 更新 Props:对比新旧 Props,修改变动的 Class、Style 或事件监听器。
  2. 更新 Children (核心 Diff):这是最复杂的部分。

4.6 核心 Diff 算法:Vue3 是如何“增删改移”的?

Vue3 采用的是快速 Diff 算法 (Quick Diff)。它的核心思想是:先处理两端容易对比的节点,最后再处理中间复杂的乱序部分

我们通过一个具体的代码示例来模拟这个过程。

假设场景:

// 旧列表 (n1)
const oldChildren = [
  { key: 'A' }, { key: 'B' }, // 头
  { key: 'C' }, { key: 'D' }, { key: 'E' }, // 中间
  { key: 'F' }, { key: 'G' }  // 尾
]

// 新列表 (n2)
const newChildren = [
  { key: 'A' }, { key: 'B' }, // 头 (不变)
  { key: 'E' }, { key: 'C' }, { key: 'D' }, { key: 'H' }, // 中间 (乱序 + 新增)
  { key: 'F' }, { key: 'G' }  // 尾 (不变)
]
第一步:掐头(Sync from start)

Vue 会维护一个索引 i = 0,从头部开始向后遍历,如果 key 相同,直接 patch(更新属性),然后 i++

let i = 0
const e1 = oldChildren.length - 1 // 旧列表尾部索引
const e2 = newChildren.length - 1 // 新列表尾部索引

// 1. 从头往后比
while (i <= e1 && i <= e2) {
  const n1 = oldChildren[i]
  const n2 = newChildren[i]
  
  if (isSameVNodeType(n1, n2)) {
    patch(n1, n2, container) // 复用节点,更新 Props
    i++
  } else {
    break // 遇到不同的 (C vs E),停下来
  }
}
// 此时 i = 2,指向 C 和 E
第二步:去尾(Sync from end)

同样的逻辑,从尾部开始向前遍历。

// 2. 从尾往前比
while (i <= e1 && i <= e2) {
  const n1 = oldChildren[e1]
  const n2 = newChildren[e2]
  
  if (isSameVNodeType(n1, n2)) {
    patch(n1, n2, container)
    e1--
    e2--
  } else {
    break // 遇到不同的 (E vs H),停下来
  }
}
// 此时 e1 = 4 (指向 E), e2 = 5 (指向 H)

此时的状态:

  • 头部 A, B 已处理。
  • 尾部 F, G 已处理。
  • 剩下的烂摊子
    • 旧:[C, D, E] (索引 2 到 4)
    • 新:[E, C, D, H] (索引 2 到 5)
第三步:处理新增与删除(简单情况)

如果预处理后,旧列表没了(i > e1),新列表还剩,说明全是新增。 如果新列表没了(i > e2),旧列表还剩,说明全是删除

if (i > e1) {
  if (i <= e2) {
    // 旧的没了,新的还有 -> 挂载剩余的新节点
    while (i <= e2) patch(null, newChildren[i++], container)
  }
} 
else if (i > e2) {
  // 新的没了,旧的还有 -> 卸载剩余的旧节点
  while (i <= e1) unmount(oldChildren[i++])
}
第四步:处理乱序(Unknown Sequence)

这是最复杂的情况(如我们的例子)。Vue 需要判断哪些节点移动了,哪些需要新建。

1. 构建新节点映射表与初始化

// 1. 构建新节点的 key 映射表
const keyToNewIndexMap = new Map()
for (let k = i; k <= e2; k++) {
  keyToNewIndexMap.set(newChildren[k].key, k)
}

// 2. 待处理新节点数量
const count = e2 - i + 1
// 3. 记录新节点在旧列表中的位置(用于计算最长递增子序列)
const newIndexToOldIndexMap = new Array(count).fill(0)

2. 遍历旧节点:复用与删除

// 4. 遍历旧节点,寻找可复用的节点
for (let k = i; k <= e1; k++) {
  const oldChild = oldChildren[k]
  const newIndex = keyToNewIndexMap.get(oldChild.key)

  if (newIndex === undefined) {
    // 旧节点在新列表中找不到了 -> 删除
    unmount(oldChild)
  } else {
    // 找到了!记录旧索引 + 1(防止 0 索引冲突)
    // newIndex - i 是为了映射到从 0 开始的 count 数组中
    newIndexToOldIndexMap[newIndex - i] = k + 1
    // 进行递归比对
    patch(oldChild, newChildren[newIndex], container)
  }
}
/**
 * 此时产生的映射关系图例:
 * 
 * 索引 (i):      0    1    2    3   (对应新列表中的位置)
 * 新节点 key:   [E]  [C]  [D]  [H]
 * 旧索引 + 1:   [5]  [3]  [4]  [0]  (对应 newIndexToOldIndexMap)
 * 
 * 其中:
 * - 0 代表 H 是新来的,需要挂载 (Mount)
 * - 3, 4 是递增的序列 -> 这就是 LIS (最长递增子序列)
 * - 5 打破了递增性 -> 说明 E 发生了移动
 */

💡 小白专属解释:

你可以把 newIndexToOldIndexMap 想象成一张 “寻人启事表”。 表格的长度就是新列表里乱序的人数(这里是 4 个:E, C, D, H)。

  • 第 0 格 (E):表里写着 5。意思是:“我是旧列表里的第 4 号(5-1)人”。
  • 第 1 格 (C):表里写着 3。意思是:“我是旧列表里的第 2 号(3-1)人”。
  • 第 2 格 (D):表里写着 4。意思是:“我是旧列表里的第 3 号(4-1)人”。
  • 第 3 格 (H):表里写着 0。意思是:“查无此人,我是新来的”。

Vue 看到这张表,就知道谁是从哪儿来的,谁是新来的。然后只要算出哪个序列是递增的(3 -> 4),就说明这几个人(C 和 D)的相对站位没变,可以不用动,省力气!

3. 计算最长递增子序列 (LIS)

Vue 使用一个算法算出 newIndexToOldIndexMap 中的最长递增子序列。这个序列里的节点,在旧列表和新列表里的相对顺序是一样的,所以不需要移动

// 获取最长递增子序列的相对索引
// 传入: [5, 3, 4, 0] (忽略 0)
// 返回: [1, 2] (对应值 3, 4,即 C 和 D,它们在 newIndexToOldIndexMap 中的下标)
const increasingNewIndexSequence = getSequence(newIndexToOldIndexMap)

// j 指向 LIS 数组的末尾 (即最大索引)
let j = increasingNewIndexSequence.length - 1

4. 倒序遍历与移动 (Moving)

最后,我们从后往前遍历需要处理的新节点。 为什么倒序?因为 insert 操作需要一个参照节点 (Anchor)。从后往前遍历时,当前节点的后一个节点一定已经处理好了(要么是刚移动完的,要么是末尾固定的),可以放心地作为 Anchor。

// 遍历待处理的新节点 (倒序)
// k: 当前处理节点在乱序区间内的相对索引 (0 ~ count-1)
// i: 乱序区间的起始索引 (全局索引)
for (let k = count - 1; k >= 0; k--) {
  // 1. 计算该节点在新列表中的真实全局索引
  const nextIndex = i + k
  const nextChild = newChildren[nextIndex]
  
  // 2. 找锚点 (Anchor):就是它后面那个节点
  // 如果 nextIndex + 1 超过了长度,说明它是最后一个,锚点是 null (插到容器末尾)
  const anchor = nextIndex + 1 < newChildren.length ? newChildren[nextIndex + 1].el : null

  if (newIndexToOldIndexMap[k] === 0) {
    // ------------------------------------
    // 情况 1: 标记是 0 -> 新增节点
    // ------------------------------------
    patch(null, nextChild, container, anchor)
  
  } else if (j < 0 || k !== increasingNewIndexSequence[j]) {
    // ------------------------------------
    // 情况 2: 需要移动
    // ------------------------------------
    // 这里的 k 不在 LIS 里,说明位置不对,需要搬家
    move(nextChild, container, anchor) 
  
  } else {
    // ------------------------------------
    // 情况 3: 命中 LIS -> 原地不动
    // ------------------------------------
    // k === seq[j]: 恭喜,这个节点在最长递增序列里
    // 它的相对位置没变,不需要动 DOM,只需要让 LIS 指针前移
    j--
  }
}

💡 核心逻辑图解:

  1. H (i=3): Map 值为 0 -> 新建,插到末尾。
  2. D (i=2): 命中 LIS (seq[j]=2) -> 不动j--
  3. C (i=1): 命中 LIS (seq[j]=1) -> 不动j--
  4. E (i=0): 不在 LIS 里 -> 移动,插到 C 前面。

5. 源码级细节:为什么需要 Context?

你可能会发现我们的源码里有这么一行:

vnode.appContext = context || null

这是为了让动态挂载的组件能继承当前 App 的上下文。比如,你在主 App 里注册了 vue-routeri18n,如果不把 appContext 赋值给新的 VNode,那么在这个 Message 组件里就无法使用 useRouter()$t()

6. 总结

通过手动使用 createVNoderender,我们突破了 Vue 模板的限制,实现了能够动态创建、挂载、销毁的命令式组件。

这也是开发高级组件(如弹窗、抽屉、通知)的必经之路。

关键点复习

  1. createVNode(Component, props) 创建虚拟节点。
  2. render(vnode, container) 将虚拟节点转化为真实 DOM。
  3. render(null, container) 销毁组件,释放内存。

Vue转React学习笔记(1): 关于useEffect的困惑和思考

作者 mo_Mo
2026年1月17日 16:12

零、写在前面

  1. 之前实习和项目中都是学的Vue,由于之后工作大概率会进入React的技术生态,最近才开始学React并且缺少企业级的项目开发经验,也没有对源码做系统研究,以下的内容只是个人学习过程中的记录和思考,因此大概率会显得稚嫩而且主观,需要大佬们的指点和修正。
  2. 软件开发毕竟属于工程实践领域,对于相同的目标可以有不同的实现方案,而一样的技术在不同的场景下也需要做取舍权衡,它并不像数学物理这样的学科有一套不容置疑的公理。而且对于团队开发者而言,一套技术的开发体验、学习成本、认知对齐等因素也同样重要,它们应该是带有主观色彩的,至少是可讨论的;
  3. 为了描述简便,以下会根据官方文档的写法和称呼,对于useEffect(setup,dependencies),将渲染中产生的副作用称为Effect,将第一个函数参数称为setup,将依赖数组简称为deps,将setup返回的清理函数称为cleanup
  4. 文中提到的观点有些是React官方的提倡,有些是笔者自己的思考,请注意辨别,官方怎么说不代表我们真的要那么做/想。关于这个API,其实在各种React社区已经做出了大量讨论了,本文可以说是一个读后感。
  5. 以下提到的Vue指的是3.0+版本,而React指的是React18+的函数式组件+hook模式。

一、Vue视角的对比:一些粗浅的理解

由于之前有Vue框架的学习经验,因此在学习新的框架的时候,主播总会习惯上联想到Vue中相对应的Api,并且官方文档和大多数技术教程都会提到三种不同deps对应的情景和用法。

(一) 三种依赖情景

1. 不传任何dep——类比onMountedonUpdated

不传递任何依赖的时候,函数式组件首次渲染及之后更新,都会setup函数都会执行。对Vue来讲也是类似的,组件首次挂载执行onMounted和onUpdated钩子,区别在于Vue的onUpdated钩子的使用频率是很低的。

2. 传递空数组——类比onMounted

传递空数组意味着dep永远不变,setup函数只会在组件挂载的时候运行一次

3. 传递依赖值——类比watchwatchEffect

依赖值发生变化的时候,注册的回调函数会重新执行

useEffect(() => {
  // 会在每次渲染后运行
});

useEffect(() => {
  // 只会在组件挂载(首次出现)时运行
}, []);

useEffect(() => {
  // 会在组件挂载时运行,而且当 a 或 b 的值自上次渲染后发生变化后也会运行
}, [a, b]);

(二) 清理函数

cleanup函数会在调用和组件卸载的时候执行,Vue中也提供了相关的功能,比如在onUnmounted的时候执行卸载逻辑,watch和watchEffect都能返回函数以用于清理副作用。

watchEffect(() => {
  // 副作用逻辑:创建定时器
  const timer = setInterval(() => {
    count.value++
  }, 1000)

  // 清理函数:清除上一轮的定时器
  return () => {
    clearInterval(timer)
    console.log('定时器已清除')
  }
})

(三) 思维差异

对于Vue开发者或者React类组件的开发者来说,很容易用生命周期的组件思维来理解hook,然而官方觉得这对Effect来讲是不适宜的。如果用Vue角度来类比,大概率会觉得useEffect起到了一个“监听器”的作用,这引发了后面关于是否使用effect和如何写deps数组的差异。

二、如何理解副作用:区分EventEffect

(一) 副作用的定义

React hook倡导的是UI=f(state)的函数式编程理念,理论上组件函数应该是纯函数,也就是相同的输入返回相同的输出,并且不依赖也不影响外部系统,也就是不和外部产生任何交互。

// 非纯函数
const fn=(arr)=>{
  arr.push(1) // 修改了外部的参数
}

在日常开发中产生副作用的频率是很高的,常见的比如:

  • 发送http请求:和服务器交互
  • 操作dom元素:和浏览器API交互
  • 定时器:和浏览器API交互
  • 甚至console.log也是

很多文章提到,useEffect是用来管理副作用的,但如果抱有一遇到“网络请求”“定时器”之类的操作,就全部放进useEffect处理,这肯定是不对的,至少不符合官方推荐的理念。

事实上,官方文档多次强调要区分由用户交互引起的副作用event和由渲染过程引起的Effect

(二) 区分eventEffect

Effect 允许你指定由渲染自身,而不是特定事件引起的副作用

当你决定将某些逻辑放入事件处理函数还是 Effect 中时,你需要回答的主要问题是:从用户的角度来看它是 怎样的逻辑。如果这个逻辑是由某个特定的交互引起的,请将它保留在相应的事件处理函数(event handler)中。如果是由用户在屏幕上 看到 组件时引起的,请将它保留在 Effect 中。

1. 官方文档的例子

假设你正在实现一个聊天室组件,需求如下:

  1. 组件应该自动连接选中的聊天室。
  2. 每当你点击“Send”按钮,组件应该在当前聊天界面发送一条消息。
function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');
  // ...
  function handleSendClick() {
    sendMessage(message);
  }
  // ...
  return (
    <>
      <input value={message} onChange={e => setMessage(e.target.value)} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}
2. 日常项目中常见的例子

比如我们点击按钮切换不同的数据类型,需要发送网络请求拉取最新数据,用effect需要这样写

useEffect(()=>{
  fetchData(type)
},[type])

这样就会带来一个问题,比如页面中有一些情况会改变type,但是那个场景下不需要发送请求,却仍旧触发了Effect,因此我们需要在setup函数里添加判断:

useEffect(()=>{
  if (type==='1'){
    fetchData(type)
  }
},[type])

未来,假设业务需求变更,你的请求又依赖了另一个参数userId,你需要在dep中添加另一个数据,甚至新数据也会再页面其他地方被修改,你需要对新数据进行条件判断。这样的话,代码的维护成本将会增高不少。

useEffect(()=>{
  if (type==='1' && userId){
    fetchData(type,userId)
  }
},[type,userId])

但实际上,假如请求这是点击事件触发的,官方更推荐把它直接写在事件处理函数里。这样写的好处是:只有当用户点击按钮这个事件触发的时候才会发送请求,而不是type变化的时候发送请求,从而做到切断type和useEffect的逻辑关系,让发送请求专属于点击事件。

function handleClick(e){
  const type=e.target.type
  fetchData(type)
}

在常见的管理页中,搜索框输入,搜索框点击,分页页码,下拉菜单选项都有可能触发重新请求,那么应该怎么办呢?

我们可能会这么写:

useEffect(()=>{
fetchData({
pageNum,
pageSize,
searchKey,
selectType
})
},
[pageNum,pageSize,searchKey,selectType]
)

大多数人我感觉也会这么写,把useEffect当成“监听”来使用,当数据变化时,拿着最新的数据发送请求,好像也没什么问题。

但是按照React官方的说法,每个事件函数里都要发送一次请求吗?我的答案是:也许是的。。。但其实也没那么复杂,多写几次而已,前提是把fetchData提前封装好。

const handleSearch = () => {
setPageNum(1); 
fetchData(); // 调用统一请求函数
};

const handlePageChange = (newPageNum, newPageSize) => {
setPageNum(newPageNum);
setPageSize(newPageSize);
fetchData(); // 调用统一请求函数
};

const handleSelectChange = (value) => {
setSelectType(value);
setPageNum(1); 
fetchData(); // 调用统一请求函数
};

(三) 响应式 VS 命令式——我们真的需要“监听”吗?

扯远一点,或许再考虑一个问题,我们真的在React中需要实现“监听”吗?

JavaScript层面,我们永远做不到对一个变量是否被改变进行监听,Vue和React作为JS框架当然都无法改变这一点。那为什么Vue可以做到监听呢?笔者觉得这涉及到两个框架对于响应式设计在理念上的巨大差异,也就是响应式编程和命令式编程。

简单讲,Vue主打一个“自动化”,基于发布订阅模式,当你声明一个数据需要为“响应式”的时候,框架内部会利用getter和setter帮你自动地进行依赖的收集和触发更新。

function reactive(target) {
  return new Proxy(target, {
    get(obj, key) {
      track(obj, key); // 启动依赖跟踪
      return Reflect.get(obj, key);
    },
    set(obj, key, value) {
      Reflect.set(obj, key, value);
      trigger(obj, key); // 触发副作用
      return true;
    }
  });
}

而React则把控制权交给开发者,类似于“手动挡”,根据UI=f(state)的理念,React中只有状态这个概念,因此开发者必须手动管理好每一个状态,通过调用setState进行状态修改,由React来帮你自动重渲染并更新。这样一来,每一处变动都是开发者手动触发的,React自然没有进行自动监听的义务。

所以笔者觉得,或许当我们真的需要用React来进行自动监听的时候,应该持有的实现思路可能是像Vue一样基于JS的发布订阅这条路去实现gettersetter,而不是useEffect

幸运的是,我们已经拥有了第三方库Mobx,它便是基于Vue响应式中的getter和setter模式,实现了自动监听。假如你使用React + Mobx则完全不需要手动setState了,可以看到两个框架的设计思想正在发生一定程度的交融。

import {observable, computed} from "mobx";

class OrderLine {
    @observable price = 0;
    @observable amount = 1;
    constructor(price) {
        this.price = price;
    }
  // 很像Vue的computed
    @computed get total() {
        return this.price * this.amount;
    }
}
const todos = observable([
    {
        title: "Make coffee",
        done: true,
    },
]);

// reaction 很像watch
const reaction = reaction(
    () => todos.map(todo => todo.title),
    titles => console.log("reaction 2:", titles.join(", "))
);

// autorun 很像watchEffect
const autorun1 = autorun(
    () => console.log("autorun 1:", todos.map(todo => todo.title).join(", "))
);

三、巨大的心智负担:deps到底怎么写

从以上例子可以看出,Effect的创作者担心开发人员理解不了这个hook,因此用了大量的案例来提示使用者在某些场景下别用Effect,但用不用Effect可能还不是最大的争议,useEffect钩子最大的争议点应该是在于依赖数组,也就是deps该怎么写,是否应该写完整。

(一) 官方的推荐:写全deps数组

文档中提到:任何响应式值都可以在重新渲染时发生变化,所以需要将响应式值包括在 Effect 的依赖项中。这里的响应式值可能含有:

    1. state
    1. props
    1. 所有在组件函数中用到的普通函数和普通变量

也就是所有在setup中用到,并且函数组件作用域内的可能变化的值,都需要添加到dep中。

正是这一点,给开发者带来巨大的心智负担,即使官方推荐安装eslint的插件辅助我们补全deps,但实践中还是将它设置为warning而并非error,这仅仅相当于起到了一个心理安慰的作用。

特别是当dep用到了函数的时候,可能会导致useCallback嵌套的问题

//组件函数体内部
function init(){
// 每次重渲染都会产生新引用
}

useEffect(()=>{
init(data) // 会频繁触发
},[data,init]) 

添加useCallback

const initFn=useCallback(init,[dep])
// 如果dep里面还有函数,可能需要再次包裹useCallback,代码可读性会降低很多

useEffect(()=>{
initFn(data) // 会频繁触发
},[data,initFn]) 

(二) 先实现setup,再确定deps,而不是反过来

官方文档提到,dep数组是完全由setup函数确定的,而不是由开发者选择的

个人是这么理解的:

    1. 你应该先确定这里的副作用是什么
    2. dep是副作用的过滤器,通过指定dep来跳过副作用的执行,保证性能
    3. 同样地,如果要修改dep,应该先修改setup,而不是反过来
    4. 如果抱着“监听”的思维去理解,会先确定dep(你要监听的东西)再实现setup,那样的话,你可能会漏写dep

四、如何建立自己的最佳实践

既然useEffect的心智负担那么重,我们应该这么做,以下是一点个人的思考

1.尽量多使用成熟的第三方库,减少自己裸写useEffect的情景

  • 状态管理上,已经有很多第三方库帮你解决数据的收集和更新的问题了,比如Mobx和Zustand,本质上都是发布订阅的模式
  • 发送请求上,优先使用Tanstack-Query、SWR、ahooks中的useRequest等第三方库,来发送请求,帮你管理请求中的各种问题(包括网络竞态、数据缓存、loading error管理等),比自己写useEffect要稳得多。

2.可以使用useEffect的情况

  • 需要操作原生dom元素的情况:比如手动设置元素的scrollTop的值
  • 确保副作用仅一次性执行,可以使用空数组的场景:比如初始化第三方SDK
  • 监听浏览器原生事件的情况:比如窗口大小变化window.resize
  • 需要和外部环境进行同步的情况:比如websocket链接,SSE链接等
  • 你觉得自己完全搞懂了useEffect的话可以用。。。(反正我是没搞懂。。)

3.协作开发的过程需要和团队对齐观念

如果是团队开发的话,要能看懂并理解别人的代码,而不是强迫别人理解自己的观点。团队的领导者最好能制定一份团队的开发规范,拉齐所有人的技术认知。当然这不是我这种新手该考虑的东西,只能说领导让怎么做就怎么做QvQ。

五、写在最后

作为一点Vue转React的新手,有几点个人的主观感受想提一下

  1. React只是一个JS库,它没有义务覆盖前端所有的业务场景,也没义务提供所谓的最佳实践,而是让开发者自己选择,这种理念应该是可以理解的。
  2. 但不管怎么说,useEffect都是一个认知曲线极其陡峭、给开发者造成巨大心智负担的API,从开发文档的篇幅以及React提出的useEffectEvent就可以看出来,但其实都是在缝缝补补。
  3. 官方对这个API的设计理念,和开发者的日常用法存在不少的割裂,这也是官方文档用大部分篇幅来讲这个hook的原因,就是为了让开发者少用,少滥用。
  4. 从体验上看,Vue即使对底层原理不那么熟悉也能进行开发,但React的开发过程中,还是需要不断按照React的机制去模拟整个流程,所以理解原理还是比较重要。至于这是不是框架在甩锅给开发者,就见仁见智了。

六、参考

  1. 《为什么我们要删掉100%的useEffect》:www.yuque.com/jiango/code…
  2. 《React hook使用误区,驳官方文档》:zhuanlan.zhihu.com/p/450513902
  3. 《A Complete Guide to useEffect》:overreacted.io/a-complete-…
  4. 《精读useEffect完全指南》:zhuanlan.zhihu.com/p/60277120
  5. React官方文档《脱围机制》:zh-hans.react.dev/learn/escap…
  6. React官方文档 useEffect: zh-hans.react.dev/reference/r…

前端路由不再难:React Router 从入门到工程化

作者 xhxxx
2026年1月17日 16:05

掌握 React Router:从基础到工程化实践

前端路由:从页面跳转到体验升级

在 Web 开发的早期,前端只是“静态页面的搬运工”——所有跳转逻辑由后端掌控,每次点击链接都会触发一次完整的页面刷新。用户看到的是白屏、等待、再加载;开发者面对的是耦合、重复和低效。

随着前后端分离架构的普及,前端不再只是展示层,而是逐渐承担起完整的应用逻辑。路由,也由此从前端的“盲区”变成了核心能力之一

今天,借助像 React Router 这样的工具,我们可以在不刷新页面的前提下,实现 URL 与 UI 的精准同步,构建真正意义上的单页应用(SPA)——既保持了 URL 的可分享性与书签能力,又带来了接近原生应用的流畅体验。

而这一切,都始于一个简单的映射关系:
路径(URL) → 组件(View)

接下来,我们将深入 React Router DOM,从安装配置到高级用法,一步步揭开前端路由的面纱。

React Router DOM

不同框架的路由实现虽然不同,但基本原理是相似的。这里我们以 React 路由为例,带你了解路由的魅力。

传统的 Web 开发是多页应用,基于 HTTP 请求实现。每当请求新的 URL 时,页面都会完全刷新,导致瞬间白屏,体验较差。

单页应用 (SPA)

得益于路由的出现,我们能够实现单页应用(SPA)。当用户点击链接跳转时,只触发对应的事件并更新局部视图,而不是重新渲染整个页面,从而提供流畅的用户体验。

安装路由

npm install react-router-dom

路由表示的就是路径和组件的映射关系

路由的使用

1. 引入路由
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
2. 路由的模式
  • HashRouter (#/)
    • URL 带有 # 号。
    • 兼容性极好,通常在路径之后会自动加上 #/
  • BrowserRouter (/)
    • URL 和后端路由一致,基于 HTML5 History API。
    • 兼容性较好(IE11 之前不支持),现代浏览器几乎都支持。
    • 推荐:现代开发中更推荐使用 BrowserRouter
    • 注意BrowserRouter as Router 是为了提高可读性,后续统一使用 Router 命名。
3. 核心组件
  • Routes:路由容器,负责管理一组路由,通常包裹在 Router 组件中。
  • Route:定义单个路由规则,当路径匹配时渲染对应的组件。

路由的配置

在现代的开发目录中,我们通常将路由的配置都放在 router 目录下。

import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import Home from './Home';
import About from './About';

<Router>
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
    </Routes>
</Router>

配置说明

  • / 对应 Home 组件
  • /about 对应 About 组件

当用户访问 / 时,会渲染 Home 组件;当访问 /about 时,会渲染 About 组件。

路由导航

在 SPA 中,我们不能使用 <a> 标签进行导航,因为它会导致页面的刷新。我们需要使用 React Router 提供的 Link 组件。

当用户点击 Link 组件时,会触发路由的导航,而不会导致页面刷新。我们需要在 Link 组件中指定 to 属性,来指定跳转的路径。

import { Link } from 'react-router-dom';

<Link to="/">Home</Link>
<Link to="/about">About</Link>

点击对应的链接就会跳转到对应的页面。


路由的种类

1. 普通路由

最基本的路由,当路径匹配时,会渲染对应的组件。 例如:about 对应 About 组件。

 <Route path="/about" element={<About />} />
2. 动态路由

路径中包含动态参数。

<Route path="/user/:id" element={<UserProfile />} />

动态路由当路径匹配时,会渲染对应的组件,并且会将路径中的参数传递给组件。 例如:/user/123 对应 UserProfile 组件,并且会将 123 传递给 UserProfile 组件。

UserProfile 组件中,我们可以通过 useParams hook 来获取路由参数:

import { useParams } from 'react-router-dom';
const { id } = useParams();
console.log(id); // 123
3. 嵌套路由
<Route path="/products" element={<Product/>}>
    <Route path=":productId" element={<ProductDetail />}/>
    <Route path="new" element={<NewProduct />}/>
</Route>

嵌套路由是局部更新的一种典型。当用户访问到产品页面时,点击产品列表中的不同按钮(例如产品详情和新产品),会渲染对应的子组件,而不会导致整个页面的刷新。 这样的路径配置是基于 /products 继续匹配的。

Product 组件中,我们可以通过 Outlet 组件来渲染嵌套路由的内容。 例如:/products/123 对应 ProductDetail 组件,并且会将 123 传递给 ProductDetail 组件。

import{
    Outlet
} from 'react-router-dom'
export default function Product() {
  return (
    <>
      <h1>产品列表</h1>
      <Outlet />
    </>
  )
}
4. 重定向路由
import {
  Routes, 
  Route,
  Navigate
} from 'react-router-dom'

<Route path="/old-path" element={<Navigate to="/new-path" />} />

当一个网站可能已经废弃或者不再更新资源,但用户依然访问旧路径时,我们通常就需要使用重定向路由帮助用户进行导航。 引入 Navigate,当用户访问 /old-path 时,会自动跳转到 /new-path。默认是使用 replace 模式,不会保留旧的历史记录。

如果我们想要保留旧的历史记录,可以设置 replace 属性为 false

<Route path="/old-path" element={<Navigate to="/new-path" replace={false} />} />
5. 鉴权路由 (路由守卫)
import ProtectRoute from './ProtectRoute';
<Route path='/pay' element={
   <ProtectRoute>
    <Pay />
   </ProtectRoute>
}/>
//protectRoute.jsx
import { Navigate } from 'react-router-dom'
export default function ProductRoute({children}) {
    const isLoggedIn = localStorage.getItem('isLogin')==='true'
    if(!isLoggedIn){
        return <Navigate to="/login" />
    }
  return (
    <>
      {children}
    </>
  )
}

鉴权路由也叫路由守卫,只有当满足条件时才能够放行。就像很多软件,你不登录就无法使用很多功能;当你想要付款时,如果没有登录,它就会自动弹出登录页。 使用一个组件来包裹需要鉴权的路由,当用户访问到这个路由时,会先判断是否登录。如果没有登录,就会跳转到登录页,否则就会渲染对应的组件。

6. 404 路由
<Route path="*" element={<NotFound />} />

* 代表除配置外的所有路由。 当用户访问到一个不存在的路由时,会渲染 NotFound 组件。


性能优化

路由的懒加载

在现代前端框架(如 React、Vue 等)中,路由的懒加载 (Lazy Loading) 是一种优化应用性能的重要手段。它允许你按需加载组件,而不是在应用初始加载时一次性加载所有路由组件,从而减小首屏 bundle 体积、加快页面加载速度。

例如当你打开应用首页时,你会看见很多功能按钮,但是如果要在首次进入时把这些按钮对应的所有组件一次性全部加载,这无疑是很糟糕的。这样不仅仅卡顿,还会增加首屏加载时间,甚至有些组件你都不使用,它却被加载了,这是非常浪费资源的。

而懒加载就很好地处理了这个问题。

// 传统的引入
import Home from './Home';
import About from './About';

// 懒加载引入
import { lazy } from 'react';
const Home = lazy(() => import('./Home'));
const About = lazy(() => import('./About'));

当然了,所有懒加载的组件都需要在 Suspense 组件中包裹起来,否则会报错。

import { Suspense } from 'react';
<Suspense fallback={<div>Loading...</div>}>
   <Route path="/" element={<Home />} />
   <Route path="/about" element={<About />} />
</Suspense>

HomeAbout 组件被加载时,会显示 Loading...,当加载完成后,会显示对应的组件。 当我们访问 / 时,会显示 Home 组件,而 About 则会延迟加载;只有当用户访问到 /about 时,才会加载 About 组件。


路由职责分离

为了保持代码的整洁和可维护性,我们通常会将路由的配置导航逻辑从主应用组件中剥离出来。 这种分离让 App.jsx 更加简洁,专注于布局和全局组件的组合。

1. 独立的路由配置 (src/router/index.jsx)

所有的路由规则、懒加载 (lazy)、路由守卫 (ProtectRoute) 等逻辑都集中管理。

// src/router/index.jsx
import { Routes, Route } from 'react-router-dom';
import { lazy, Suspense } from 'react';

// ... 懒加载组件定义 ...

export default function RouterConfig(){
    return(
        <Suspense fallback={<LoadingFallback />}>
          <Routes>
            <Route path="/" element={<Home />} />
            {/* ...其他路由配置 */}
          </Routes>
        </Suspense>
    )
}
2. 独立的导航组件 (src/components/Navigation.jsx)

导航菜单、链接高亮等 UI 逻辑封装在独立的组件中。

// src/components/Navigation.jsx
import { Link } from 'react-router-dom';
export default function Navigation() {
  return (
    <nav>
      <Link to="/">Home</Link>
      <Link to="/about">About</Link>
      {/* ... */}
    </nav>
  )
}
3. 简洁的主入口 (src/App.jsx)

主组件只负责引入和组装,不再包含复杂的路由逻辑。

// src/App.jsx
import Navigation from './components/Navigation'
import RouterConfig from './router'

export default function App() {
  return (
    <Router>
      <Navigation />   {/* 导航区域 */}
      <RouterConfig /> {/* 路由视图区域 */}
    </Router>
  )
}

样式优化

导航区高亮 (像掘金一样)

当我们点击导航区的不同链接时,我们通常会希望对应的导航项高亮显示,以告知用户当前所在的位置。 React Router 提供的 useResolvedPathuseMatch 可以帮助我们实现这个功能。

  • useResolvedPath: 将目标路径(to 属性)解析为绝对路径对象。
  • useMatch: 接收一个路径模式,判断当前 URL 是否与该模式匹配。
import { useResolvedPath, useMatch, Link } from 'react-router-dom'

export default function Navigation(){
  const isActive = (to) => {
     // 1. 将 to 解析为 location 对象,处理相对路径等情况
     const resolvedPath = useResolvedPath(to);
     
     // 2. 检查当前路径是否匹配 resolvedPath.pathname
     const match = useMatch({
      path: resolvedPath.pathname,
      end: true // 精准匹配,避免 / 匹配所有路径
     })

     return match ? 'active' : ''
  }
  
  return (
    <nav>
      <ul>
        <li><Link to="/" className={isActive('/')}>Home</Link></li>
        <li><Link to="/about" className={isActive('/about')}>About</Link></li>
        {/* ... */}
      </ul>
    </nav>
  )
}

结语

前端路由的本质,是路径与组件的映射;而 React Router,则让这种映射变得清晰、灵活又强大。

从基础跳转到动态参数,从嵌套路由到权限控制,再到懒加载与工程化拆分——我们不仅是在配置 URL,更是在构建一个可维护、高性能、用户体验流畅的现代 Web 应用。

掌握 React Router,不只是学会几个 API,而是理解 SPA 背后的导航逻辑与架构思维。希望这篇指南能成为你开发路上的可靠参考,助你写出更优雅的路由代码。

回顾浮动布局

2026年1月17日 15:50

引言

在现代 Web 开发中,虽然 Flexbox 和 Grid 已成为主流布局方案,但 CSS 浮动(float)  作为早期实现网页多列布局的核心技术,仍然具有重要的历史地位和实用价值。尤其在处理图文混排、旧项目维护或兼容性要求较高的场景中,掌握浮动布局依然是前端开发者的必备技能。

本文将从原理、行为、常见问题及解决方案等方面,系统讲解 CSS 浮动布局。


一、什么是浮动(Float)?

float 是 CSS 中的一个属性,最初设计用于实现文本环绕图片的效果。其基本语法如下:

element {
  float: left | right | none | inherit;
}
  • left:元素向左浮动;
  • right:元素向右浮动;
  • none(默认值):元素不浮动;
  • inherit:继承父元素的浮动设置。

示例:基础浮动

<img src="example.jpg" style="float: left; margin: 10px;">
<p>这是一段文字,会环绕在图片右侧……</p>

在这个例子中,图片脱离了正常文档流并向左浮动,文字则围绕在其右侧流动。


二、浮动的核心行为

要真正掌握浮动,必须理解它的三个关键特性:

1. 脱离正常文档流(但仍在页面中)

浮动元素会脱离块级格式化上下文(BFC)中的正常流,不再占据原来的空间(对后续块级元素而言),但仍会影响行内内容的排布(如文字环绕)。

注意:浮动元素并未完全“消失”,它依然存在于渲染树中,并参与布局计算。

2. 向左/右尽可能靠边

浮动元素会向指定方向移动,直到碰到包含块的边界另一个浮动元素为止。

3. 块级元素“坍塌”问题

当一个容器内的所有子元素都浮动时,该容器高度会变为 0(即“高度坍塌”)。这是因为浮动元素脱离了正常流,父容器无法感知其高度。

<div class="container">
  <div style="float: left; width: 100px; height: 100px; background: red;"></div>
  <div style="float: left; width: 100px; height: 100px; background: blue;"></div>
</div>
<!-- .container 高度为 0 -->

这个问题需要通过清除浮动(Clearfix)  来解决。


三、清除浮动(Clearing Floats)

为了解决父容器高度坍塌的问题,我们需要“清除浮动”。常用方法有:

方法 1:使用 clear 属性

clear: both 是 CSS 中用于控制元素与浮动元素之间关系的一个属性值,它的作用是:强制该元素不与任何左侧或右侧的浮动元素处于同一行,即“清除浮动”的影响。

在浮动元素后添加一个空元素并设置 clear: both

<div class="container">
  <div style="float: left;">A</div>
  <div style="float: left;">B</div>
  <div style="clear: both;"></div>
</div>

缺点:引入无语义的 HTML 元素。

方法 2:使用伪元素(推荐 —— Clearfix 技巧)

clearfix::after {
  content: "";
  display: table;
  clear: both;
}
<div class="container clearfix">
  <div style="float: left;">A</div>
  <div style="float: left;">B</div>
</div>

这是最经典的 clearfix 解决方案,兼容性好且语义清晰。

方法 3:触发 BFC(Block Formatting Context)

让父容器形成一个新的 BFC,使其能包含浮动子元素。常见方式包括:

  • overflow: hidden / auto
  • display: flow-root(现代浏览器)
  • float: left/right
  • position: absolute/fixed
.container {
  overflow: hidden; /* 触发 BFC */
}

推荐在现代项目中使用 display: flow-root,它专为解决此问题而生,且不会带来副作用(如裁剪内容)。


四、浮动布局的典型应用场景

尽管 Flex/Grid 更强大,但浮动仍有其用武之地:

1. 图文混排

img {
  float: left;
  margin-right: 15px;
}

这是浮动最初的设计目的,至今仍非常高效。

2. 简单的多列布局(旧项目)

在 Flexbox 出现前,开发者常通过浮动实现两栏或三栏布局:

sidebar { float: left; width: 200px; }
main { float: right; width: calc(100% - 200px); }

注意:需处理清除浮动和响应式适配。

3. 列表项横向排列(如导航菜单)

nav li {
  float: left;
  list-style: none;
}
nav::after {
  content: "";
  display: table;
  clear: both;
}

五、浮动的局限性与替代方案

局限性:

  • 容易引发高度坍塌;
  • 布局逻辑复杂,调试困难;
  • 不支持垂直居中、等高列等常见需求;
  • 响应式适配能力弱。

现代替代方案:

需求 推荐方案
多列布局 CSS Grid
单行/单列弹性布局 Flexbox
图文环绕 仍可用 float,或结合 shape-outside
容器包含浮动子元素 display: flow-root

5大核心分析维度+3种可视化方案:脑肿瘤大数据分析系统全解析 毕业设计 选题推荐 毕设选题 数据分析 机器学习

2026年1月17日 15:51
涵盖患者画像、肿瘤特征、治疗方案、症状关联及风险因素五大模块,通过Python与Django后端处理数据,Vue与Echarts前端展示,为医疗数据分析提供直观工具。

ArcGIS Pro 实现影像波段合成

作者 GIS之路
2026年1月17日 15:17

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

前言

通常,我们下载的卫星影像数据每个波段都存在一个单独的波段中,但是在生产实践中,我们往往需要由各个波段组成的完整数据集。所以,这个时候就需要进行波段合成操作。

本节主要讲解如何在ArcGIS Pro中实现TIFF影像波段合成。

1. 软件环境

本文使用以下软件环境,仅供参考。

时间:2026 年

操作软件:ArcGIS Pro 3.5

操作系统:windows 11

2. 下载卫星影像数据

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

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

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


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

3. ArcGIS Pro 软件安装

要想使用ArcGIS Pro实现影像波段合成,那你得安装好ArcGIS Pro软件。

但是,软件安装说明不在本文的教程之内,就不进行介绍了,请未安装的同学自行解决。

4. 波段合成

好了,经过上面一堆废话,下面正式进入主题,进行实操。

如果有过ArcGIS版本软件基础的同学,可以很快完成,因为ArcGIS ProArcGIS的工具设置大体相同。

我们首先需要找到数据处理工具箱。点击菜单栏分析按钮Analysis,然后再点击工具Tools

或者点击软件搜索框中,其中会出现推荐的地理处理工具箱。

打开工具之后,点击数据数据处理工具Data Management Tools

然后依次点击栅格Raster、栅格处理Raster Processing、波段合成Composite Bands即可。

如果你觉得上述操作路径太长,或者你熟悉操作工具名称的话,可以直接在页面顶部搜索框输入工具名称Composite Bands进行检索。又或者在地理处理搜索框输入工具名称Composite Bands进行检索。

打开波段合成工具,先在输入栅格中选择需要进行合成的波段数据,然后选择输出位置,最后点击运行。

如下为波段4、波段3、波段2合成的彩色效果图。

如下为波段5、波段4、波段3合成的彩色效果图。

可在属性中查看源数据信息,其中三个波段显示如下。

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

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

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

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

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


    

GeoTools 开发合集(全)

OpenLayers 开发合集

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

GDAL 实现投影转换

自然资源部党组关于苗泽等4名同志职务任免的通知

国产版的Google Earth,吉林一号卫星App“共生地球”来了

2026年全国自然资源工作会议召开

日本欲打造“本土版”星链系统

吉林一号国内首张高分辨率彩色夜光卫星影像发布

2025 年度信创领军企业名单出炉!

2026 年 Node.js + TS 开发:别再纠结 nodemon 了,聊聊热编译的最优解

作者 donecoding
2026年1月17日 14:18

在开发 Node.js 服务端时,“修改代码 -> 自动生效”的开发体验(即热编译/热更新)是影响效率的关键。随着 Node.js 23+  原生支持 TS 以及 Vite 5 的普及,我们的工具链已经发生了巨大的更迭。

今天我们深度拆解三种主流的 Node.js TS 开发实现方式,帮你选出最适合 2026 年架构的方案。


一、 方案对比大盘点

方案 核心原理 优点 缺点 适用场景
tsx (Watch Mode) 基于 esbuild 的极速重启 零配置、性能强、生态位替代 nodemon 每次修改重启整个进程,状态丢失 小型服务、工具脚本
vite-node 基于 Vite 的模块加载器 完美继承 Vite 配置、支持模块级 HMR 配置相对复杂,需手动处理 HMR 逻辑 中大型 Vite 全栈项目
Node.js 原生 Node 23+ Type Stripping 无需第三方依赖,官方标准 需高版本 Node,功能相对单一 追求极简、前瞻性实验

二、 方案详解

  1. 现代替代者:tsx —— 告别 nodemon + ts-node

过去我们常用 nodemon --exec ts-node,但在 ESM 时代,这套组合经常报 ERR_UNKNOWN_FILE_EXTENSION 错误。

tsx 内部集成了 esbuild,它是目前 Node 18+ 环境下最稳健的方案。

  • 实现热编译:

    bash

    npx tsx --watch src/index.ts
    

    请谨慎使用此类代码。

  • 为什么选它:  它不需要额外的加载器配置(--loader),且 watch 模式非常智能,重启速度在毫秒级。

  1. 开发者体验天花板:vite-node —— 真正的 HMR

如果你已经在项目中使用 Vite 5,那么 vite-node 是不二之选。它不仅是“重启”,而是“热替换”。

  • 核心优势:

    • 共享配置:直接复用 vite.config.ts 中的 alias 和插件。
    • 按需编译:只编译当前运行到的模块,项目越大优势越明显。
  • 实现热更新(不重启进程):

    typescript

    // src/index.ts
    import { app } from './app';
    let server = app.listen(3000);
    
    if (import.meta.hot) {
      import.meta.hot.accept('./app', (newModule) => {
        server.close(); // 优雅关闭旧服务
        server = newModule.app.listen(3000); // 启动新逻辑,DB连接可复用
      });
    }
    

    请谨慎使用此类代码。

  1. 官方正统:Node.js 原生支持

如果你能使用 Node.js 23.6+ ,那么可以摆脱所有构建工具。

  • 运行:  node --watch src/index.ts
  • 点评:  这是未来的趋势,但在 2026 年,由于生产环境往往还停留在 Node 18/20 LTS,该方案目前更多用于本地轻量级开发。

三、 避坑指南:Vite 5 打包 Node 服务的报错

在实现热编译的过程中,如果你尝试用 Vite 打包 Node 服务,可能会遇到:

Invalid value for option "preserveEntrySignatures" - setting this option to false is not supported for "output.preserveModules"

原因:  当你开启 preserveModules: true 想保持源码目录结构输出时,Rollup 无法在“强制保留模块”的同时又“摇树优化(Tree Shaking)”掉入口导出。

修复方案:
在 vite.config.ts 中明确设置:

typescript

build: {
  rollupOptions: {
    preserveEntrySignatures: 'exports-only', // 显式声明保留导出
    output: {
      preserveModules: true
    }
  }
}

请谨慎使用此类代码。


四、 总结:我该选哪个?

  1. 如果你只想快速写个接口,不想折腾配置:请直接使用 tsx。它是 2026 年 nodemon 的完美继承者。
  2. 如果你在做复杂全栈项目,或者有大量的路径别名:请使用 vite-node。它能让你在 Node 端获得跟前端 React/Vue 编写时一样丝滑的 HMR 体验。
  3. 如果是为了部署生产环境:无论开发环境用什么,生产环境请务必通过 vite build 产出纯净的 JS,并使用 node dist/index.js 运行。

使用 LangChain.js 在node端 连接glm大模型示例

作者 kevinIcoding
2026年1月17日 12:47

使用 LangChain 在后端连接大模型:实践指南 🚀

本文以实战项目代码为例,包含完整后端接入、流式(SSE)实现、前端接收示例与调试方法,读者可直接复制运行。


介绍 ✨

随着大模型在各类应用中的普及,后端如何稳健地接入并把模型能力以 API/流式方式对外提供,成为常见需求。本文基于 LangChain(JS)演示如何在 Node.js 后端(Koa)中:

  • 初始化并调用大模型(示例使用智谱 GLM 的接入方式)
  • 支持普通请求与流式(Server-Sent Events,SSE)响应
  • 在前端用 fetch 读取流并实现打字机效果

适用人群:熟悉 JS/TS、Node.js、前端基本知识,想把模型能力放到后端并对外提供 API 的工程师。


一、准备与依赖 🧩

环境:Node.js 16+。

安装依赖(Koa 示例):

npm install koa koa-router koa-bodyparser @koa/cors dotenv
npm install @langchain/openai @langchain/core

在项目根创建 .env

ZHIPU_API_KEY=你的_api_key_here
PORT=3001

提示:不同模型提供方的 baseURL 与认证字段会不同,请根据提供方文档调整。


二、后端:服务封装(chatService)🔧

把模型调用封装到服务层,提供普通调用与流式调用接口:

// backend/src/services/chatService.js
import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage, SystemMessage } from "@langchain/core/messages";

const chatService = {
  llm: null,

  init() {
    if (!this.llm) {
      this.llm = new ChatOpenAI({
        openAIApiKey: process.env.ZHIPU_API_KEY,
        modelName: "glm-4.5-flash",
        temperature: 0.7,
        configuration: { baseURL: "https://open.bigmodel.cn/api/paas/v4/" },
      });
    }
  },

  async sendMessage(message, conversationHistory = []) {
    this.init();
    const messages = [
      new SystemMessage("你是一个有帮助的 AI 助手,使用中文回答问题。"),
      ...conversationHistory.map((msg) =>
        msg.role === "user"
          ? new HumanMessage(msg.content)
          : new SystemMessage(msg.content),
      ),
      new HumanMessage(message),
    ];

    try {
      const response = await this.llm.invoke(messages);
      return {
        success: true,
        content: response.content,
        usage: response.usage_metadata,
      };
    } catch (error) {
      console.error("聊天服务错误:", error);
      return { success: false, error: error.message };
    }
  },

  async sendMessageStream(message, conversationHistory = []) {
    this.init();
    const messages = [
      new SystemMessage("你是一个有帮助的 AI 助手,使用中文回答问题。"),
      ...conversationHistory.map((msg) =>
        msg.role === "user"
          ? new HumanMessage(msg.content)
          : new SystemMessage(msg.content),
      ),
      new HumanMessage(message),
    ];

    try {
      // 假设 llm.stream 返回异步迭代器,逐 chunk 返回 { content }
      const stream = await this.llm.stream(messages);
      return { success: true, stream };
    } catch (error) {
      console.error("流式聊天服务错误:", error);
      return { success: false, error: error.message };
    }
  },
};

export default chatService;

说明:实际 SDK 接口名(如 invokestream)请依据你所用的 LangChain / provider 版本调整。


三、控制器:普通与 SSE 流式(Koa)🌊

SSE 要点:需要直接写原生 res,并设置 ctx.respond = false,防止 Koa 在中间件链结束时覆盖响应或返回 404。

// backend/src/controllers/chatController.js
import chatService from "../services/chatService.js";

const chatController = {
  async sendMessage(ctx) {
    try {
      const { message, conversationHistory = [] } = ctx.request.body;
      if (!message) {
        ctx.status = 400;
        ctx.body = { success: false, error: "消息内容不能为空" };
        return;
      }
      const result = await chatService.sendMessage(
        message,
        conversationHistory,
      );
      if (result.success) ctx.body = result;
      else {
        ctx.status = 500;
        ctx.body = result;
      }
    } catch (error) {
      ctx.status = 500;
      ctx.body = { success: false, error: "服务器内部错误" };
    }
  },

  async sendMessageStream(ctx) {
    try {
      const { message, conversationHistory = [] } = ctx.request.body;
      if (!message) {
        ctx.status = 400;
        ctx.body = { success: false, error: "消息内容不能为空" };
        return;
      }

      const result = await chatService.sendMessageStream(
        message,
        conversationHistory,
      );
      if (!result.success) {
        ctx.status = 500;
        ctx.body = result;
        return;
      }

      ctx.set("Content-Type", "text/event-stream");
      ctx.set("Cache-Control", "no-cache");
      ctx.set("Connection", "keep-alive");
      ctx.status = 200;
      // 关键:让我们直接操作 Node 原生 res
      ctx.respond = false;

      for await (const chunk of result.stream) {
        const content = chunk.content;
        if (content) ctx.res.write(`data: ${JSON.stringify({ content })}\n\n`);
      }

      ctx.res.write("data: [DONE]\n\n");
      ctx.res.end();
    } catch (error) {
      console.error("流式控制器错误:", error);
      ctx.status = 500;
      ctx.body = { success: false, error: "服务器内部错误" };
    }
  },
};

export default chatController;

四、路由与启动 🌐

// backend/src/routes/index.js
import Router from "koa-router";
import chatController from "../controllers/chatController.js";
const router = new Router({ prefix: "/api" });
router.post("/chat", chatController.sendMessage);
router.post("/chat/stream", chatController.sendMessageStream);
export default router;

// backend/src/app.js
import dotenv from "dotenv";
import Koa from "koa";
import bodyParser from "koa-bodyparser";
import cors from "@koa/cors";
import router from "./routes/index.js";

dotenv.config();
const app = new Koa();
app.use(cors({ origin: "*", credentials: true }));
app.use(bodyParser());
app.use(router.routes());
app.use(router.allowedMethods());
app.listen(process.env.PORT || 3001, () => console.log("Server running"));

五、前端:接收 SSE 流并实现“打字机”效果 ⌨️

前端用 fetch + ReadableStream 读取 SSE 后端发送的 chunk(格式为 data: {...}\n\n)。下面给出简洁示例:

// frontend/src/services/chatService.ts (核心片段)
export const sendMessageStream = async (
  message,
  conversationHistory,
  onChunk,
  onComplete,
  onError,
) => {
  try {
    const response = await fetch("/api/chat/stream", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ message, conversationHistory }),
    });

    if (!response.ok) throw new Error("网络请求失败");
    const reader = response.body.getReader();
    const decoder = new TextDecoder();

    let buffer = "";
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      const chunk = decoder.decode(value);
      // 简单按行解析 SSE data: 行
      const lines = chunk.split("\n");
      for (const line of lines) {
        if (line.startsWith("data: ")) {
          const data = line.slice(6);
          if (data === "[DONE]") {
            onComplete();
            return;
          }
          try {
            const parsed = JSON.parse(data);
            if (parsed.content) onChunk(parsed.content);
          } catch (e) {}
        }
      }
    }
  } catch (err) {
    onError(err.message || "流式请求失败");
  }
};

打字机效果思路(前端)📌:

  • 后端 chunk 通常是按小段返回,前端把每个 chunk 追加到 buffer
  • 用一个定时器以固定速度(如 20–40ms/字符)把 buffer 的字符逐个移动到展示内容,使文本逐字出现。
  • onComplete 时快速显示剩余字符并停止定时器。

你可以参考项目中 App.tsx 的实现(已实现逐 chunk 追加与打字机渲染逻辑)。


App.tsx

import React, { useState } from "react";
import MessageList from "./components/MessageList";
import ChatInput from "./components/ChatInput";
import { chatService, Message } from "./services/chatService";
import "./App.css";

const App: React.FC = () => {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isStreaming, setIsStreaming] = useState(false);

  const handleSendMessage = async (message: string) => {
    const userMessage: Message = { role: "user", content: message };
    setMessages((prev) => [...prev, userMessage]);
    setIsStreaming(true);

    let assistantContent = "";
    const conversationHistory = messages;

    await chatService.sendMessageStream(
      message,
      conversationHistory,
      (chunk: string) => {
        assistantContent += chunk;
        setMessages((prev) => {
          const newMessages = [...prev];
          const lastMessage = newMessages[newMessages.length - 1];
          if (lastMessage && lastMessage.role === "assistant") {
            lastMessage.content = assistantContent;
          } else {
            newMessages.push({ role: "assistant", content: assistantContent });
          }
          return newMessages;
        });
      },
      () => {
        setIsStreaming(false);
      },
      (error: string) => {
        console.error("流式响应错误:", error);
        setMessages((prev) => [
          ...prev,
          { role: "assistant", content: "抱歉,发生了错误,请稍后重试。" },
        ]);
        setIsStreaming(false);
      },
    );
  };

  return (
    <div className="app">
      <header className="app-header">
        <h1>🤖 LangChain + 智谱 GLM</h1>
        <p>AI 聊天助手</p>
      </header>
      <main className="app-main">
        <MessageList messages={messages} isStreaming={isStreaming} />
        <ChatInput onSendMessage={handleSendMessage} disabled={isStreaming} />
      </main>
    </div>
  );
};

export default App;

MessageList

import React, { useRef, useEffect } from "react";
import { Message } from "../services/chatService";

interface MessageListProps {
  messages: Message[];
  isStreaming: boolean;
}

const MessageList: React.FC<MessageListProps> = ({ messages, isStreaming }) => {
  const messagesEndRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages, isStreaming]);

  return (
    <div className="message-list">
      {messages.length === 0 ? (
        <div className="empty-state">
          <p>👋 欢迎使用 LangChain + 智谱 GLM 聊天助手!</p>
          <p>开始提问吧,我会尽力帮助你 💬</p>
        </div>
      ) : (
        messages.map((msg, index) => (
          <div key={index} className={`message ${msg.role}`}>
            <div className="message-avatar">
              {msg.role === "user" ? "👤" : "🤖"}
            </div>
            <div className="message-content">
              <div className="message-role">
                {msg.role === "user" ? "用户" : "AI 助手"}
              </div>
              <div className="message-text">{msg.content}</div>
            </div>
          </div>
        ))
      )}
      {isStreaming && (
        <div className="message assistant">
          <div className="message-avatar">🤖</div>
          <div className="message-content">
            <div className="message-role">AI 助手</div>
            <div className="message-text streaming">
              <span className="typing-indicator">...</span>
            </div>
          </div>
        </div>
      )}
      <div ref={messagesEndRef} />
    </div>
  );
};

export default MessageList;

六、调试建议与常见坑 ⚠️

  • 404:确认前端请求路径 /api/chat/stream 与路由前缀一致;开发时若使用 Vite,请在 vite.config.ts 配置 proxy 到后端端口。
  • SSE 返回 404/空响应:确认控制器里 ctx.respond = false 已设置,并且在设置 header 后立即开始 ctx.res.write
  • headers already sent:不要在写入原生 res 后再次设置 ctx.bodyctx.status
  • CORS:若跨域,确保后端 CORS 配置允许 Content-TypeAuthorization 等必要 header。

七、快速上手测试命令 🧪

启动后端:

# 在 backend 目录下
node src/app.js
# 或使用 nodemon
npx nodemon src/app.js

用 curl 测试流式(查看快速返回流):

curl -N -H "Content-Type: application/json" -X POST http://localhost:3001/api/chat/stream -d '{"message":"你好","conversationHistory":[]} '

你应能看到类似:

data: {"content":"你"}
data: {"content":"好"}
data: [DONE]

推荐一个超好用的全栈通用瀑布流布局库 universal-waterfall-layout

作者 Summer不秃
2026年1月17日 12:00

在前端开发中,瀑布流布局(Masonry Layout)一直是一个让人又爱又恨的需求。爱它是因为它能极大提升图片类应用(如 Pinterest、小红书)的视觉体验和空间利用率;恨它是因为实现起来坑点满满——图片高度不固定、窗口缩放重排、框架兼容性问题等等。

今天给大家推荐一个最近发现的宝藏开源库:universal-waterfall-layout

正如其名,不仅仅是给 Vue 用,也不仅仅是给 React 用,它是一个**通用(Universal)**的解决方案。

为什么选择它?

现在的市面上的瀑布流插件往往绑定特定框架,或者包体积过大。而 universal-waterfall-layout 有几个非常击中痛点的特性:

1. 🚀 全栈通用,一次学习

无论你是 Vue 3 爱好者,还是 React 拥趸,甚至是 原生 JS 的死忠粉,这个库都能无缝支持。它的核心逻辑是用原生 TypeScript 编写的,零依赖,仅仅在核心之上封装了轻量级的 Vue 和 React 组件适配层。

2. ⚡️ 内置"刚需"功能

很多瀑布流插件只管布局,不管体验。但这个库在 v1.0.3 版本中贴心地加入了很多实战刚需功能:

  • 骨架屏 (Skeleton Loading): 数据还没回来?直接通过 props 开启加载状态,自带脉冲动画的骨架屏,拒绝白屏。
  • 空状态 (Empty State): 列表为空时自动显示占位提示,不再需要自己写 v-if/v-else
  • 图片懒加载 (Lazy Load): 内置原生图片懒加载支持,性能直接拉满,不需要再引入额外的 lazyload 库。

3. 📐 两种核心布局策略

它不仅仅是简单的“排列”,还提供了两种最常用的响应式策略:

  • 固定列宽 (Fixed Column Width): 指定每列 250px,容器会自动计算能放下几列,并自动居中。适合大屏展示。
  • 固定列数 (Fixed Column Count): 指定“我要 3 列”,无论屏幕多大,宽度都会自动拉伸填满。适合移动端 H5。

快速上手

安装

非常简单,体积也很小:

npm install universal-waterfall-layout
# 或者
pnpm add universal-waterfall-layout

via Vue 3

在 Vue 中使用非常丝滑,你可以直接利用 slot 来自定义加载中和空状态的 UI:

<template>
  <Waterfall 
    :gap="20" 
    :columnWidth="250"
    :loading="isLoading"
    :lazyload="true"
  >
    <!-- 数据列表 -->
    <div v-for="item in items" :key="item.id" class="card">
      <img :src="item.image" alt="" />
      <p>{{ item.title }}</p>
    </div>
    
    <!-- 自定义加载骨架 -->
    <template #loading>
       <div class="my-skeleton">正在加载精彩内容...</div>
    </template>

    <!-- 自定义空状态 -->
    <template #empty>
       <div class="empty-box">暂无相关数据</div>
    </template>
  </Waterfall>
</template>

<script setup>
import { Waterfall } from 'universal-waterfall-layout/vue';
import { ref } from 'vue';

const isLoading = ref(true);
const items = ref([]);

// 模拟请求
setTimeout(() => {
  items.value = [{/*...*/}, {/*...*/}];
  isLoading.value = false;
}, 1000);
</script>

via React

React 版本同样拥有优秀的类型提示(TypeScript Friendly):

import { Waterfall } from 'universal-waterfall-layout/react';

const MyGallery = ({ data, loading }) => {
  return (
    <Waterfall 
      gap={15} 
      columnCount={3} // 移动端常用固定3列
      loading={loading}
      lazyload={true}
      loadingComponent={<div>Loading Skeleton...</div>}
      emptyComponent={<div>Nothing here yet!</div>}
    >
      {data.map(item => (
        <div key={item.id} className="card">
           <img src={item.url} />
        </div>
      ))}
    </Waterfall>
  );
};

via Vanilla JS (原生 HTML)

如果你不使用任何框架,或者在传统的 HTML 页面中使用,也完全没问题。核心库提供了直接操作 DOM 的能力:

<div id="waterfall-container">
  <div class="item"><img src="..." /></div>
  <div class="item"><img src="..." /></div>
  <!-- ... -->
</div>

<script type="module">
  import { WaterFallCore } from 'universal-waterfall-layout';

  const container = document.getElementById('waterfall-container');
  
  const waterfall = new WaterFallCore({
    container: container, // 必填:容器 DOM 元素
    gap: 15,              // 间距
    columnWidth: 220,     // 固定列宽策略
    lazyload: true        // 开启懒加载
  });
  
  // 如果后续通过 JS 动态添加了元素,可以手动触发布局更新
  // waterfall.updateItems();
  // waterfall.layout();
</script>

结语

在造轮子泛滥的今天,找到一个克制且好用的库并不容易。universal-waterfall-layout 没有过度封装,但把最核心的布局算法响应式处理加载体验做得非常扎实。

如果你正在开发图片流、商品墙或者作品集展示页面,强烈推荐试一试!

🔗 NPM 地址: universal-waterfall-layout

通过英伟达平台免费调用 GLM4.7 教程

作者 子洋
2026年1月17日 11:39

前言

最近在折腾 AI Agent 和模型接入相关的事情时,意外发现英伟达居然提供了一个面向开发者、可以免费调用模型 API 的平台。更关键的是,这个平台上不仅能用到一些主流开源模型,还能直接使用最近热度很高,号称开源 Coding 能力最强的 GLM4.7,以及综合表现相当稳的 minimax-m2.1

说实话,在如今 API 几乎全面 token 计费、随便一个复杂任务就轻松几十万甚至上百万 token 的背景下,这种可以正经做开发实验、不用一上来就烧钱的平台,对个人开发者和学习阶段的人来说非常友好。所以这篇文章主要做三件事:

  • 介绍 NVIDIA Build 平台本身能做什么
  • 记录从注册到实际调用 API 的完整流程
  • 分享一段真实使用下来的模型体验与限制

整体偏实践,结论也会尽量基于实际使用情况展开。

NVIDIA Build 模型平台

NVIDIA Build可以理解为英伟达官方提供的一个模型集成与调试平台。平台上已经部署了大量模型,涵盖文生文(Chat / Reasoning / Coding)、文生图 / 图生文、语音相关等模型。目前平台上可见的模型数量在 200+,基本覆盖了市面上主流的开源模型生态,例如:deepseek-R1 / deepseek-v3.xqwen3 / qwen-coderkimi-k2minimax-m2.1z-ai/glm4.7,平台本身还提供了在线 Playground(支持参数调节、tools 调用)、OpenAI 风格的 API 接口、模型示例代码一键生成等能力。

注册账号与 API Key 申请

账号注册说明

注意:疑似因为近期注册用户激增,新账号存在一定概率无法正常申请 API Key 的问题。在不影响账号合规性的前提下,比较稳妥的做法是使用非国内常见的邮箱注册,例如相对少见的邮箱(yeah.net),或国外邮箱(gmail.com)等,以及注册时使用浏览器无痕窗口,避免历史状态干扰。

创建账号

访问:build.nvidia.com/ ,点击左上角 Login

在弹窗中输入邮箱并点击 Next,随后填写注册信息并完成人机验证。

这里需要注意:在“更多注册选项”中可以看到 QQ、微信等方式,但不建议使用第三方快捷登录。在当前阶段,使用这些方式注册后,账号更容易出现 API 权限受限的情况。

完成注册后,会进入一些偏个性化设置的步骤(例如名称、偏好选项),按需填写即可。

如果账号状态正常,稍等片刻后,页面顶部会出现提示:

Please verify your account to get API access

点击右上角的 Verify 进入验证流程。

手机号验证

在验证弹窗中,将默认的 +1 修改为 +86,输入国内手机号即可。这里不需要刻意规避,国内手机号是可以正常通过验证的

点击 Send Code via SMS,完成验证码验证。

创建 API Key

验证完成后,点击右上角头像,进入 API Keys 管理页面。

如果账号状态正常,这里可以看到 Generate API Key 按钮。

点击后,输入一个 Key 名称(仅用于区分),过期时间选择 Never Expire

生成完成后,复制并妥善保存该 API Key,后续调用只会展示一次。

如果在 API Keys 页面完全看不到生成按钮,而是类似下图所示的提示界面,基本可以确认该账号当前无法使用 API 功能,建议更换账号重新注册。

使用 API Key 调用

本地客户端配置

只要是支持 OpenAI 风格接口的客户端基本都可以直接使用,我这里以 Jan 为例。

进入设置页,添加一个新的模型提供商。

  • Provider Name:自定义(例如 Nvidia
  • API Key:填写刚刚生成的 Key
  • Base URL:https://integrate.api.nvidia.com/v1

完成后添加模型。

例如添加 GLM4.7:

z-ai/glm4.7

新建会话并选择该模型后,即可正常对话。从体感上看,在普通对话场景下 token 输出速度非常快

获取模型列表

Jan 也支持直接调用 /models 接口获取模型列表,点击刷新即可自动拉取并添加。

需要注意的是:

  • /models 返回的是平台全量模型列表
  • 其中包含文生图、语音、多模态等模型
  • 并非所有模型都支持 chat / text-to-text

因此,如果在客户端中直接选择不支持 chat 的模型发送消息,会直接报错,这是模型能力不匹配,不是接口问题。

Playground 与模型调试

在 NVIDIA Build 平台的 Models 页面中,可以通过搜索 Chat / Reasoning 筛选支持的模型,或者在 Playground 页面的左上角看到所有支持文生文的模型列表。

kimi-k2 为例,点击模型后可以进入在线调试界面。

  • 左侧 Tools:可启用模型支持的工具
  • 右侧 Parameters:控制温度、最大 token 等参数

点击右上角 View Code,可以直接看到对应的 API 调用示例,包括 Base URL、Model ID、Messages 结构等。

Tools 调用示例

在部分模型中可以直接启用 tools,这里以 minimax-m2 为例演示。

启用 get_current_weather 工具后,询问某地天气,模型会自动进行 tools 规划与调用,并返回结果。

再次点击 View Code,可以看到完整的 tools 调用示例代码。

模型与接口

NVIDIA Build 提供的是 OpenAI 风格 API,接口层面兼容 chat.completions / responses,是否支持 chattools、多模态,取决于模型本身。所以,最稳妥的方式仍然是在平台 Models 页面中筛选 chat / reasoning,再决定是否接入到本地客户端或代码中。

使用体验与限制

说一下 GLM4.7 这个模型。它并不是我第一次用,在刚发布不久时我就已经通过一些第三方 API 供应商接触过,这次算是第二次较完整地使用。综合两次实际开发体验,说实话体感并不算好。

首先一个比较明显的问题是,在我目前常用的模型里(比如 qwen-code、gpt-5.1、gpt-5.2、Claude 等),只有 GLM4.7 会在生成的文件头部插入 Markdown 的代码块标记。这个问题在代码编辑和文件生成场景下尤其影响体验,需要额外清理,看起来就很蠢。

其次是执行效率问题,这一点让我感觉很奇怪。纯对话场景下它的响应速度是很快的,但一旦进入干活模式,比如稍微复杂一点的任务编排、代码修改或多步执行,单个任务可能会跑十几甚至二十分钟。问题不在于我不能接受模型执行复杂任务耗时,而是过程中偶尔会出现明显的停顿或卡住一段时间再继续,节奏非常不稳定。

一开始我也怀疑是 API 调用频率或限流导致的,但后来在同样的客户端、同样的任务复杂度下切换到 minimax-m2,发现并不是这个原因。minimax 的整体执行节奏要顺畅得多,调用也更激进,甚至可以轻松跑到 40 次 / 分钟 的平台上限,当然代价就是一旦规划稍微激进,就很容易直接撞上限流,接口报错,任务中断。

从平台层面来看,这个平台整体体验其实是非常不错的:模型选择多、接入成本低、示例清晰,对学习和实验阶段的开发者非常友好。平台的限制也比较直观明确,比如 API 调用频率限制在 40 次 / 分钟,超出后直接返回错误,这一点在 minimax-m2 上体现得尤为明显。

回到 GLM4.7 本身,客观来说它的功能是完全正常的:工具调用没问题,代码编辑能用,对话速度也快,只是在复杂任务执行阶段明显偏慢,且稳定性不够好。相比之下,minimax-m2 在相同条件下执行节奏更线性、更听话,只是更容易触发平台限流 (当然了,因为频繁触发限流所以我也没深度使用 minimax)

总结来说,GLM4.7 并不是不能干活,但实际开发体验一般,尤其是在需要长时间、连续执行任务的场景下,效率和节奏上的问题会被放大。

结语

实话说,这个平台在当前这个时间点,真的算是相当良心的存在。对想学习 AI Agent、工具调用、多模型编排的开发者来说,能够在不额外付费的情况下反复试错,本身就很有价值。

当然了,平台策略和风控状态可能随时变化,如果只是想白嫖一点体验,建议还是尽早注册,至少在账号状态正常的前提下,把 API Key 拿到手。

至于模型怎么选,建议多试、多对比,别迷信单一模型。能稳定把活干完的模型,才是好模型。

相关链接

【节点】[Integer节点]原理解析与实际应用

作者 SmalBox
2026年1月17日 10:56

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity URP Shader Graph中,Integer节点是一个基础但功能强大的工具节点,它允许开发者在着色器程序中定义和使用整型常量。虽然着色器编程通常以浮点数运算为主,但整数在特定场景下具有不可替代的作用,特别是在控制流程、数组索引、循环计数和条件判断等方面。

Integer节点的基本概念

Integer节点在Shader Graph中代表一个整型常量值。与其他节点不同,Integer节点不接收输入,而是直接输出一个用户定义的整数值。这个特性使得它成为着色器中的固定参数或控制变量的理想选择。

Integer节点的核心特点

  • 输出值为整型,但在着色器运算中会自动转换为浮点数
  • 可用于控制着色器中的离散状态和条件分支
  • 适合用于数组索引、循环计数和枚举类型的表示
  • 在性能优化方面,整数运算通常比浮点数运算更高效

在Shader Graph中的定位

Integer节点属于Shader Graph的Input类别,与其他的常量节点如Float、Vector2、Vector3等并列。它提供了在可视化着色器编程中处理整数数据的能力,弥补了传统节点图主要以浮点数为中心的设计局限。

节点属性和配置

端口配置

Integer节点只有一个输出端口,其配置如下:

  • 名称:Out
  • 方向:输出
  • 类型:Float
  • 绑定:无
  • 描述:输出整数值,但在类型系统中作为Float处理

端口特性的深入理解

虽然端口类型标记为Float,但实际上输出的是整数值。这种设计是因为HLSL和GLSL着色语言中,整数和浮点数在很多时候可以隐式转换,而且Shader Graph的内部数据类型系统主要以浮点数为基础。在实际着色器代码生成时,这个整数值会被正确地处理为整数类型。

控件参数

Integer节点提供了一个简单的控件用于配置其输出值:

  • 名称:无(在节点上直接显示数值)
  • 类型:Integer
  • 选项:无
  • 描述:定义节点输出的整数值

控件使用要点

  • 可以直接在节点上的输入框中输入整数值
  • 支持正负整数,范围通常受着色语言限制但足够大多数应用场景
  • 数值改变会实时更新节点预览和生成的着色器代码

生成的代码分析

根据官方文档,Integer节点生成的代码示例如下:

HLSL

float _Integer = 1;

代码生成机制深入解析

虽然示例代码显示变量被声明为float类型,但在实际的HLSL编译中,当这个值用于整数上下文时(如数组索引、循环计数器),编译器会进行适当的优化和处理。在更复杂的使用场景中,生成的代码可能会有不同的表现形式:

HLSL

// 当Integer节点用于数组索引时
int index = 2;
float value = _MyArray[index];

// 当用于循环控制时
for (int i = 0; i < _IterationCount; i++)
{
    // 循环体
}

变量命名规则

在生成的代码中,Integer节点对应的变量名称会根据节点在Graph中的名称自动生成。如果节点被命名为"TileCount",则生成的变量可能是_TileCount_Integer_TileCount,具体命名规则取决于Shader Graph的版本和配置。

Integer节点的实际应用

基础数值应用

Integer节点最直接的用途是提供整型常量值,用于控制着色器的各种参数:

  • 平铺和偏移控制:指定纹理平铺次数
  • 循环次数设置:控制for循环的迭代次数
  • 数组大小定义:确定固定大小数组的维度
  • 枚举状态表示:用整数代表不同的渲染状态或材质类型

纹理平铺示例

在纹理采样节点中,使用Integer节点控制平铺参数:

Integer节点(值:4) → TilingAndOffset节点 → SampleTexture2D节点

这种配置可以实现纹理的精确平铺控制,比如确保纹理在模型表面重复恰好4次,而不是4.5次或其他非整数值。

条件逻辑控制

Integer节点在着色器条件逻辑中发挥重要作用,特别是在需要离散状态判断的场景:

  • 多重材质切换:使用整数值选择不同的材质属性集
  • LOD级别控制:根据整数距离值切换细节级别
  • 特效强度分级:将连续的特效参数离散化为几个固定级别

状态机实现示例

通过结合Branch节点和Integer节点,可以实现简单的着色器状态机:

Integer节点(状态值) → Branch节点 → 不同的颜色/纹理输出

数组和循环操作

在高级着色器编程中,数组和循环是常见的编程结构,Integer节点在其中扮演关键角色:

  • 数组索引:安全地访问数组元素
  • 循环计数器:控制固定次数的循环迭代
  • 多维数组处理:计算行主序或列主序数组的索引

数组访问模式

For循环节点(使用Integer节点作为最大值) → 数组索引计算 → 数组元素访问

这种模式常见于图像处理效果,如卷积核操作、多光源累积计算等。

与其他节点的协同工作

与数学节点的配合

Integer节点可以与各种数学节点结合,实现更复杂的数值计算:

  • 算术运算:与Add、Subtract、Multiply、Divide节点配合进行整数运算
  • 比较运算:与Equal、NotEqual、Greater Than、Less Than节点结合实现条件判断
  • 插值运算:虽然整数本身不插值,但可以控制插值参数

运算精度注意事项

当Integer节点参与浮点数运算时,会自动提升为浮点类型。在需要保持整数精度的场景,应尽量避免与浮点数进行混合运算,或确保在关键步骤中使用适当的舍入函数。

与控制流节点的集成

Integer节点与Shader Graph的控制流节点紧密结合,实现动态的着色器行为:

  • Branch节点:使用整数值作为条件输入
  • For循环节点:提供循环次数和索引值
  • Switch节点:作为选择器输入,决定执行哪个分支

性能优化提示

在Shader Graph中使用整数控制流通常比使用浮点数更高效,因为整数比较和分支操作在GPU上的开销较小。特别是在移动平台上,这种优化更为明显。

高级应用技巧

动态整数参数

虽然Integer节点本身表示常量,但可以通过多种方式实现动态的整数参数:

  • 脚本驱动:通过C#脚本在运行时修改材质属性
  • 动画控制:使用Unity动画系统或时间节点驱动整数值变化
  • 顶点数据:从顶点颜色或UV通道中提取整数值

脚本集成示例

CSHARP

// C#脚本中设置整数值
material.SetInt("_IntegerParameter", 5);

数组和数据结构模拟

在着色器中模拟复杂数据结构时,Integer节点用于索引和管理:

  • 查找表索引:访问预计算的查找表
  • 状态矩阵:管理多维状态数组
  • 有限状态机:实现复杂的着色器行为切换

多维度索引计算

通过组合多个Integer节点和数学运算,可以计算多维数组的线性索引:

行索引 × 列数 + 列索引 = 线性索引

性能优化策略

合理使用Integer节点可以显著提升着色器性能:

  • 循环展开优化:使用小的整数值作为循环次数,促进编译器自动展开循环
  • 常量传播:整型常量的优化效果通常比浮点数更好
  • 分支预测:整数条件语句的预测效率通常更高

平台特定考虑

不同GPU架构对整数运算的支持程度不同。在编写跨平台着色器时,应了解目标平台的整数运算特性,特别是在移动设备上的性能表现。

实际案例研究

案例一:离散化颜色调色板

创建一个使用Integer节点选择预定义颜色的着色器:

  • 设置Integer节点作为颜色索引
  • 使用Branch节点或数组索引选择对应颜色
  • 应用选中的颜色到材质表面

这种技术常用于低多边形风格游戏或需要特定颜色方案的应用程序。

案例二:多纹理混合系统

实现一个根据整数值混合多个纹理的系统:

  • 使用Integer节点选择基础纹理、细节纹理和遮罩纹理
  • 根据整数值决定混合模式和强度
  • 创建可配置的多材质系统

案例三:程序化几何生成

在曲面细分或几何着色器中使用Integer节点控制细节级别:

  • 根据距离或重要性设置细分因子
  • 使用整数值确保对称和一致的几何分布
  • 优化性能的同时保持视觉质量

故障排除和最佳实践

常见问题解决

整数精度问题

  • 问题:大整数导致精度丢失或意外行为
  • 解决:确保使用的整数值在合理范围内,通常0-255对于大多数应用足够

类型转换错误

  • 问题:整数到浮点的隐式转换导致意外结果
  • 解决:在关键计算中显式处理类型转换,使用Round、Floor或Ceiling节点

性能问题

  • 问题:使用整数节点后着色器变慢
  • 解决:检查是否创建了复杂的依赖关系,简化节点网络

最佳实践建议

  • 命名规范:为Integer节点使用描述性名称,提高可读性
  • 数值范围:限制整数值在必要的最小范围内,避免不必要的内存占用
  • 文档注释:在Shader Graph中使用注释节点说明Integer节点的用途和预期值范围
  • 测试验证:在不同平台和设备上测试整数相关功能,确保一致的行为

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

❌
❌