普通视图

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

基于ThreeJs实现3D车机大屏及自动避障效果

作者 李剑一
2026年2月5日 22:00

之前接到一个需求,说是给某个无人车做个啥配套的演示代码,但是仅仅只是提供一个Demo,所以细节上很多地方并没有深究。

无标题视频——使用Clipchamp制作.gif

不过虽然仅仅是一个Demo,但是其核心的功能基本都实现了:

  • 道路生成
  • 道路上障碍物生成(包括固定障碍物、车辆等)
  • 车辆行进效果展示
  • 避障道路切换展示

实现方案

使用Three.js实现3D车机大屏效果,先安装 Three.js

pnpm install three

我这里使用的版本是 v0.150.1

初始化场景

通过 THREE.Scene() 对象生成简单的背景,以及环境光部分。

注意:环境光如果没有特殊的需求用默认的也没问题。

scene = new THREE.Scene()
scene.background = new THREE.Color(0xE8E8E8)

// 添加环境光
// const ambientLight = new THREE.AmbientLight(0xffffff, 0.8)
// scene.add(ambientLight)

初始化部分直接创建渲染器:

renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setSize(rendererCanvas.value.clientWidth, rendererCanvas.value.clientHeight)
renderer.setPixelRatio(window.devicePixelRatio)
renderer.shadowMap.enabled = false // 避免proxy错误
rendererCanvas.value.appendChild(renderer.domElement)

创建道路

通过 THREE.PlaneGeometry 对象创建道路,这里我是直接写死的,因为不牵扯屏幕适配问题。

 const roadGeometry = new THREE.PlaneGeometry(24, 200)  // 参数分别是 width / height
const roadMaterial = new THREE.MeshBasicMaterial({ color: 0x555555 })
road = new THREE.Mesh(roadGeometry, roadMaterial)
road.rotation.x = -Math.PI / 2
scene.add(road)

增加车道标线,这里我不是分开创建的,而是统一创建完以后再划分。

const lineGeometry = new THREE.PlaneGeometry(0.15, 2)
const lanePositions = [-8, -4, 0, 4, 8] // 5条车道分隔线,形成6个车道
lanePositions.forEach(x => {
    for (let i = -50; i < 50; i += 4) { // 虚线间隔
        const line = new THREE.Mesh(lineGeometry, dashedLineMaterial)
        line.position.set(x, 0.01, i)
        line.rotation.x = -Math.PI / 2
        scene.add(line)
    }
})

创建车辆

这里需要注意,一般来说更建议使用具体的车辆模型进行展示,效果更好。

这里因为我确实是没找到合适的车辆模型,就简单画了一个。

const carGroup = new THREE.Group()
// 车身
const bodyGeometry = new THREE.BoxGeometry(2, 1, 4)
// ... 
carGroup.add(body)
// 车窗
const windowGeometry = new THREE.BoxGeometry(1.8, 0.8, 2)
// ... 
carGroup.add(window)
// 车轮
const wheelGeometry = new THREE.CylinderGeometry(0.3, 0.3, 0.2, 16)
// ... 
carGroup.add(wheel)
// 将车辆添加到画布
scene.add(carGroup)

这里需要注意,主体车辆行驶过程中需要增加跟随。

camera = new THREE.PerspectiveCamera(75, rendererCanvas.value.clientWidth / rendererCanvas.value.clientHeight, 0.1, 1000)
// 第三人称跟随视角
camera.position.set(0, 8, -15)
camera.lookAt(0, 0, 0)

创建障碍物

基本与创建车辆类似,但是是随机在车道上进行创建障碍物。

另外如果是车辆的话增加一个行驶速度。

const obstacle = new THREE.Group()
    
// 主体
const geometry = new THREE.BoxGeometry(type.size.w, type.size.h, type.size.d)
const material = new THREE.MeshBasicMaterial({ color: type.color })
const body = new THREE.Mesh(geometry, material)
obstacle.add(body)

// 随机位置(6车道系统,增加间距)
const lanes = [-10, -6, -2, 2, 6, 10] // 6个车道的中心位置
const lane = lanes[Math.floor(Math.random() * lanes.length)]
obstacle.position.set(lane, 0, Math.random() * 60 + 20) // 增加生成距离

// 添加移动速度
obstacle.userData = {
    speed: Math.random() * 0.02 + 0.01,
    type: type.type
}

障碍物检测 & 寻找安全车道

障碍物的检测和寻找安全车道效果基本上相同,都是在前后距离范围内检测是否存在障碍物,如果存在那么就认定障碍物检测成功。

如果当前车道存在,而其他车道不存在,则认定其他车道为安全车道。

// 检查是否在同一车道且在前方
if (Math.abs(obstacleX - carX) < 1.5 && // 同一车道范围
    obstacleZ > carZ && // 在前方
    obstacleZ - carZ < detectionDistance) { // 在检测距离内
    return true
}

车道变换效果

这里需要注意,变换车道效果0.5s即完成,尤其是提示部分。

如果是实际项目此处应该配合真正的传感器和计算完成时间计算。

const deltaX = targetX - currentX
if (Math.abs(deltaX) > 0.1) {
    car.position.x += Math.sign(deltaX) * laneChangeSpeed
} else {
    // 变道完成
    car.position.x = targetX
    currentLane = targetLane
    
    // 0.5秒后重置状态
    setTimeout(() => {
        if (!isChangingLane) {
            laneChangeStatus.value = '正常行驶'
        }
    }, 500)
}

其他部分就没什么了,主要是运行过程中需要循环进行变道、道路标线的更新、障碍物的更新。

当然,我推荐你在卸载页面的时候释放掉资源。不释放也没啥太大影响倒是。

全部代码

<template>
    <div class="car-three" ref="rendererCanvas">
        <div class="controls">
            <div class="speed-info">速度: {{ speed.toFixed(1) }} km/h</div>
            <div class="obstacle-count">障碍物数量: {{ obstacleCount }}</div>
            <div class="lane-info">当前车道: {{ currentLaneDisplay }}</div>
            <div class="lane-change-status" :class="{ active: isChangingLaneDisplay }">
                {{ laneChangeStatus }}
            </div>
        </div>
    </div>
</template>

<script setup>
import * as THREE from 'three';
import { ref, onMounted, onBeforeUnmount } from 'vue'

const rendererCanvas = ref(null)
const speed = ref(60) // 车速 km/h
const obstacleCount = ref(0)
const currentLaneDisplay = ref(3) // 显示用的车道号(1-6)
const isChangingLaneDisplay = ref(false)
const laneChangeStatus = ref('正常行驶')

let scene, camera, renderer
let car, road, obstacles = []
let clock = new THREE.Clock()

// 车辆控制
let carSpeed = 0.05
let roadOffset = 0
let currentLane = 2 // 当前车道索引(0-5对应6个车道)
let targetLane = 2 // 目标车道索引
let isChangingLane = false // 是否正在变道
let laneChangeSpeed = 0.02 // 变道速度

onMounted(() => {
    initScene()
    createRoad()
    createCar()
    createObstacles()
    setupCamera()
    animate()
})

const initScene = () => {
    // 初始化场景
    scene = new THREE.Scene()
    scene.background = new THREE.Color(0xE8E8E8) // 灰白色背景
    
    // 创建渲染器
    renderer = new THREE.WebGLRenderer({ antialias: true })
    renderer.setSize(rendererCanvas.value.clientWidth, rendererCanvas.value.clientHeight)
    renderer.setPixelRatio(window.devicePixelRatio)
    renderer.shadowMap.enabled = false // 避免proxy错误
    rendererCanvas.value.appendChild(renderer.domElement)
    
    // 添加环境光
    // const ambientLight = new THREE.AmbientLight(0xffffff, 0.8)
    // scene.add(ambientLight)
}

const createRoad = () => {
    // 创建道路(6车道)
    const roadGeometry = new THREE.PlaneGeometry(24, 200)
    const roadMaterial = new THREE.MeshBasicMaterial({ color: 0x555555 })
    road = new THREE.Mesh(roadGeometry, roadMaterial)
    road.rotation.x = -Math.PI / 2
    scene.add(road)
    
    // 创建车道标线
    const lineGeometry = new THREE.PlaneGeometry(0.15, 2)
    const lineMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff })
    const dashedLineMaterial = new THREE.MeshBasicMaterial({ color: 0xffffff })
    
    // 车道分隔线(虚线)- 创建多个短线段
    const lanePositions = [-8, -4, 0, 4, 8] // 5条车道分隔线,形成6个车道
    
    lanePositions.forEach(x => {
        for (let i = -50; i < 50; i += 4) { // 虚线间隔
            const line = new THREE.Mesh(lineGeometry, dashedLineMaterial)
            line.position.set(x, 0.01, i)
            line.rotation.x = -Math.PI / 2
            scene.add(line)
        }
    })
    
    // 道路边界线(实线)
    const borderGeometry = new THREE.PlaneGeometry(0.2, 200)
    const leftBorder = new THREE.Mesh(borderGeometry, lineMaterial)
    leftBorder.position.set(-12, 0.01, 0)
    leftBorder.rotation.x = -Math.PI / 2
    scene.add(leftBorder)
    
    const rightBorder = new THREE.Mesh(borderGeometry, lineMaterial)
    rightBorder.position.set(12, 0.01, 0)
    rightBorder.rotation.x = -Math.PI / 2
    scene.add(rightBorder)
}

const createCar = () => {
    // 创建主车(简化的车辆模型)
    const carGroup = new THREE.Group()
    
    // 车身
    const bodyGeometry = new THREE.BoxGeometry(2, 1, 4)
    const bodyMaterial = new THREE.MeshBasicMaterial({ color: 0x87CEEB }) // 浅蓝色主车
    const body = new THREE.Mesh(bodyGeometry, bodyMaterial)
    body.position.y = 0.5
    carGroup.add(body)
    
    // 车窗
    const windowGeometry = new THREE.BoxGeometry(1.8, 0.8, 2)
    const windowMaterial = new THREE.MeshBasicMaterial({ color: 0xE8E8E8, transparent: true, opacity: 0.5 })
    const window = new THREE.Mesh(windowGeometry, windowMaterial)
    window.position.y = 1.2
    carGroup.add(window)
    
    // 车轮
    const wheelGeometry = new THREE.CylinderGeometry(0.3, 0.3, 0.2, 16)
    const wheelMaterial = new THREE.MeshBasicMaterial({ color: 0x222222 })
    
    const wheels = [
        { x: -0.8, z: 1.2 },
        { x: 0.8, z: 1.2 },
        { x: -0.8, z: -1.2 },
        { x: 0.8, z: -1.2 }
    ]
    
    wheels.forEach(pos => {
        const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial)
        wheel.position.set(pos.x, 0.3, pos.z)
        wheel.rotation.z = Math.PI / 2
        carGroup.add(wheel)
    })
    
    car = carGroup
    car.position.set(-2, 0, -5) // 主车位于右侧车道
    scene.add(car)
}

// 检测前方障碍物
const detectFrontObstacle = () => {
    const carX = car.position.x
    const carZ = car.position.z
    const detectionDistance = 15 // 检测距离
    const laneWidth = 4 // 车道宽度
    
    // 检查前方是否有障碍物
    for (let obstacle of obstacles) {
        const obstacleX = obstacle.position.x
        const obstacleZ = obstacle.position.z
        
        // 检查是否在同一车道且在前方
        if (Math.abs(obstacleX - carX) < 1.5 && // 同一车道范围
            obstacleZ > carZ && // 在前方
            obstacleZ - carZ < detectionDistance) { // 在检测距离内
            return true
        }
    }
    return false
}

// 寻找安全车道
const findSafeLane = () => {
    const lanes = [-10, -6, -2, 2, 6, 10] // 6个车道位置
    const carZ = car.position.z
    const checkDistance = 20 // 安全检查距离
    
    for (let i = 0; i < lanes.length; i++) {
        if (i === currentLane) continue // 跳过当前车道
        
        let isSafe = true
        const laneX = lanes[i]
        
        // 检查这个车道是否安全
        for (let obstacle of obstacles) {
            const obstacleX = obstacle.position.x
            const obstacleZ = obstacle.position.z
            
            // 检查前后一定距离内是否有障碍物
            if (Math.abs(obstacleX - laneX) < 1.5 && // 在目标车道
                Math.abs(obstacleZ - carZ) < checkDistance) { // 在安全检查范围内
                isSafe = false
                break
            }
        }
        
        if (isSafe) {
            return i // 返回安全车道索引
        }
    }
    
    return currentLane // 如果没有安全车道,保持当前车道
}

// 执行车道变换
const performLaneChange = () => {
    if (isChangingLane) {
        const lanes = [-10, -6, -2, 2, 6, 10]
        const targetX = lanes[targetLane]
        const currentX = car.position.x
        
        // 平滑移动到目标车道
        const deltaX = targetX - currentX
        if (Math.abs(deltaX) > 0.1) {
            car.position.x += Math.sign(deltaX) * laneChangeSpeed
        } else {
            // 变道完成
            car.position.x = targetX
            currentLane = targetLane
            isChangingLane = false
            laneChangeStatus.value = '变道完成'
            isChangingLaneDisplay.value = false
            
            // 0.5秒后重置状态
            setTimeout(() => {
                if (!isChangingLane) {
                    laneChangeStatus.value = '正常行驶'
                }
            }, 500)
        }
    }
    
    // 更新显示的车道号
    currentLaneDisplay.value = currentLane + 1 // 车道号从1开始
}

const createObstacles = () => {
    // 创建随机障碍物(统一灰白色)
    const obstacleTypes = [
        { type: 'car', color: 0xD3D3D3, size: { w: 2, h: 1, d: 4 } },
        { type: 'car', color: 0xC0C0C0, size: { w: 2, h: 1, d: 4 } },
        { type: 'truck', color: 0xB8B8B8, size: { w: 2.5, h: 2, d: 6 } },
        { type: 'pedestrian', color: 0xA8A8A8, size: { w: 0.5, h: 1.7, d: 0.5 } }
    ]
    
    // 创建初始障碍物(减少数量)
    for (let i = 0; i < 4; i++) {
        createRandomObstacle(obstacleTypes)
    }
    
    obstacleCount.value = obstacles.length
}

const createRandomObstacle = (types) => {
    const type = types[Math.floor(Math.random() * types.length)]
    const obstacle = new THREE.Group()
    
    // 主体
    const geometry = new THREE.BoxGeometry(type.size.w, type.size.h, type.size.d)
    const material = new THREE.MeshBasicMaterial({ color: type.color })
    const body = new THREE.Mesh(geometry, material)
    body.position.y = type.size.h / 2
    obstacle.add(body)
    
    // 如果是车辆,添加车轮
    if (type.type === 'car' || type.type === 'truck') {
        const wheelGeometry = new THREE.CylinderGeometry(0.25, 0.25, 0.15, 12)
        const wheelMaterial = new THREE.MeshBasicMaterial({ color: 0x222222 })
        
        const wheelPositions = [
            { x: -type.size.w * 0.3, z: type.size.d * 0.3 },
            { x: type.size.w * 0.3, z: type.size.d * 0.3 },
            { x: -type.size.w * 0.3, z: -type.size.d * 0.3 },
            { x: type.size.w * 0.3, z: -type.size.d * 0.3 }
        ]
        
        wheelPositions.forEach(pos => {
            const wheel = new THREE.Mesh(wheelGeometry, wheelMaterial)
            wheel.position.set(pos.x, 0.25, pos.z)
            wheel.rotation.z = Math.PI / 2
            obstacle.add(wheel)
        })
    }
    
    // 随机位置(6车道系统,增加间距)
    const lanes = [-10, -6, -2, 2, 6, 10] // 6个车道的中心位置
    const lane = lanes[Math.floor(Math.random() * lanes.length)]
    obstacle.position.set(lane, 0, Math.random() * 60 + 20) // 增加生成距离
    
    // 添加移动速度
    obstacle.userData = {
        speed: Math.random() * 0.02 + 0.01,
        type: type.type
    }
    
    obstacles.push(obstacle)
    scene.add(obstacle)
}

const setupCamera = () => {
    camera = new THREE.PerspectiveCamera(75, rendererCanvas.value.clientWidth / rendererCanvas.value.clientHeight, 0.1, 1000)
    // 第三人称跟随视角
    camera.position.set(0, 8, -15)
    camera.lookAt(0, 0, 0)
}

const animate = () => {
    requestAnimationFrame(animate)
    
    const deltaTime = clock.getDelta()
    
    // 检测前方障碍物并执行变道
    if (!isChangingLane && detectFrontObstacle()) {
        const safeLane = findSafeLane()
        if (safeLane !== currentLane) {
            targetLane = safeLane
            isChangingLane = true
            laneChangeStatus.value = '检测到障碍物,正在变道...'
            isChangingLaneDisplay.value = true
        } else {
            laneChangeStatus.value = '前方有障碍物,无安全车道'
        }
    } else if (!isChangingLane) {
        laneChangeStatus.value = '正常行驶'
        isChangingLaneDisplay.value = false
    }
    
    // 执行变道动作
    performLaneChange()
    
    // 更新车辆位置(模拟前进)
    roadOffset += carSpeed
    
    // 更新道路标线位置
    scene.children.forEach(child => {
        if (child.material && child.material.color.getHex() === 0xffffff && child.position.y === 0.01) {
            child.position.z -= carSpeed
            if (child.position.z < -50) {
                child.position.z += 100
            }
        }
    })
    
    // 更新障碍物位置
    obstacles.forEach((obstacle, index) => {
        obstacle.position.z -= carSpeed + obstacle.userData.speed
        
        // 如果障碍物移出视野,重新生成
        if (obstacle.position.z < -30) {
            scene.remove(obstacle)
            obstacles.splice(index, 1)
            
            // 创建新的障碍物(统一灰白色)
            const obstacleTypes = [
                { type: 'car', color: 0xD3D3D3, size: { w: 2, h: 1, d: 4 } },
                { type: 'car', color: 0xC0C0C0, size: { w: 2, h: 1, d: 4 } },
                { type: 'truck', color: 0xB8B8B8, size: { w: 2.5, h: 2, d: 6 } },
                { type: 'pedestrian', color: 0xA8A8A8, size: { w: 0.5, h: 1.7, d: 0.5 } }
            ]
            createRandomObstacle(obstacleTypes)
        }
    })
    
    // 更新速度显示
    speed.value = 60 + Math.sin(Date.now() * 0.001) * 10 // 模拟速度变化
    obstacleCount.value = obstacles.length
    
    // 摄像机跟随(考虑变道)
    camera.position.x = car.position.x
    camera.position.z = car.position.z - 15
    camera.position.y = 8
    camera.lookAt(car.position.x, car.position.y + 2, car.position.z + 5)
    
    renderer.render(scene, camera)
}

onBeforeUnmount(() => {
    // 清理资源
    obstacles.forEach(obstacle => {
        scene.remove(obstacle)
    })
    
    if (car) scene.remove(car)
    if (road) scene.remove(road)
    
    // 清理几何体和材质
    scene.traverse((child) => {
        if (child.geometry) {
            child.geometry.dispose()
        }
        if (child.material) {
            if (Array.isArray(child.material)) {
                child.material.forEach(material => material.dispose())
            } else {
                child.material.dispose()
            }
        }
    })
    
    renderer.dispose()
    if (rendererCanvas.value && renderer.domElement) {
        rendererCanvas.value.removeChild(renderer.domElement)
    }
})
</script>

<style scoped>
.car-three {
    width: 100%;
    height: 100%;
}

.controls {
    position: absolute;
    top: 20px;
    left: 20px;
    background: rgba(0, 0, 0, 0.7);
    color: white;
    padding: 15px;
    border-radius: 8px;
    font-family: Arial, sans-serif;
    z-index: 100;
}

.speed-info, .obstacle-count {
    margin: 5px 0;
    font-size: 14px;
    font-weight: bold;
}

.speed-info {
    color: #00ff00;
}

.obstacle-count {
    color: #ffaa00;
}

.lane-info {
    color: #00aaff;
}

.lane-change-status {
    color: #ffff00;
    font-weight: bold;
}

.lane-change-status.active {
    color: #ff6600;
    animation: blink 1s infinite;
}

@keyframes blink {
    0%, 50% { opacity: 1; }
    51%, 100% { opacity: 0.5; }
}
</style>

全栈进阶-redis入门实战概念篇

2026年2月5日 20:00

第一阶段:redis基础

1. 简介

Redis是一款开源的、基于内存的键值对数据库,支持将内存持久化到磁盘,还提供了丰富的数据结构、事务、发布订阅等功能,被广泛的用于缓存、消息队列、会话存储等场景。

作为一个前端开发,对于Redis第一影响就是读写操作非常的快,常用于一些需要快速读写数据的场景,比如存储会话sessionRedis之所以这么快,在于Redis利用了内存操作、IO多路复用、避免线程切换开销三大核心优势,让单线程也足以支撑超高并发。

Redis并非纯单线程,只是在接受客户请求的核心处理流程是单线程的,在处理慢操作,比如持久化读写磁盘、异步删除大键、主从复制的网络同步,会启动多个辅助线程。为啥当初Redis不是设计成多线程呢,主要是单线程设计简单,核心逻辑都是串行执行的,后续的维护成本极低,同时也避免了多线程的死锁啊,数据一致性啊这些麻烦的问题;内存的操作足够快,多线程必然会涉及到线程切换和锁竞争,这些都会降低效率;IO的多路复用,Redis运行在网络层,使用的基于Unix系统的IO多路复用机制,就是主线程通过事件循环来监听所有客户端的IO操作,维护一个事件队列去处理IO操作,这种单线程非阻塞的IO多路复用让Redis可以同时管理上万的TCP连接。

2.redis数据基本结构

Redis基本数据结构主要五个,接下来挨个介绍下

首先安装下环境:

pip install redis

先创建一个虚拟环境,然后安装相应的依赖

import redis

# 连接到你的线上 Redis
r = redis.Redis.from_url(
    "redis://:xxx",
    decode_responses=True,  # 返回 str 而不是 bytes
)

# 设置一个 study:string 的 key
r.set("study:string", "hello redis from python")

# 读出来验证一下
value = r.get("study:string")
print("study:string =", value)
2.1 String

StringRedis最基础、最常用的数据结构,所有的键值对的value本质上都可以使用String来存储,单key最大容量512M,所有的操作都是原子性的,支持位运算和过期策略。

比如前面的案例r.set("study:string", "hello redis from python"),就是设置一个字符串

如果要设置一个过期时间的话,也比较简单

r.set("study:string", "hello redis from python", ex=60),这里的ex就是秒数,如果是px就是毫秒数,这里设置的时间就表明key的过期时间,如果过期了key就会被删除。

2.1 Hash

Hash是一个键对应多个键值对的结构,类似于Map和字典,一般用来存储结构化的对象。

r.hset("study:hash", mapping={
    "name": "张三",
    "age": "20",
})

data = r.hgetall("study:hash")
print(data)  # {'name': '张三', 'age': '20'}

如果要删除hash中的指定字段的话,可以使用这个方法hdel

r.hdel("study:hash", "age")
2.3 List

这里的List是按照插入顺序排序的字符串集合,支持两端搞笑增删,中间查询稍慢,是一个双向链表。

# 从右侧依次塞入几个元素
r.rpush("study:list", "apple", "banana", "orange")

# 从左侧再塞一个
r.lpush("study:list", "pear")   # list 现在是: ["pear", "apple", "banana", "orange"]

# 读取整个 list(0 到 -1 表示所有元素)
items = r.lrange("study:list", 0, -1)
print("study:list =", items)

# 弹出一个元素(比如从左边弹)
left = r.lpop("study:list")
print("lpop 之后取出的 =", left)
print("剩下的 =", r.lrange("study:list", 0, -1))

可以用来存储一些任务队列和消息队列

2.4 Set

Set 是无序、元素唯一的字符串集合,支持集合间的交、并、差运算,适合处理 “去重” 和 “关系匹配” 场景。

# 往 set 里加元素(去重)
r.sadd("study:set", "apple", "banana", "orange")
r.sadd("study:set", "banana")  # 再加一次不会重复

# 查看所有成员
members = r.smembers("study:set")
print("study:set =", members)

# 判断某个值是否在 set 中
print("是否包含 apple:", r.sismember("study:set", "apple"))

# 删除一个成员
r.srem("study:set", "banana")
print("删除 banana 后 =", r.smembers("study:set"))

# 给整个 set 设置过期时间 60 秒
r.expire("study:set", 60)
2.5 ZSet

ZSet 是 Set 的升级版,每个元素关联一个 “分数(score)”,Redis 会按分数从小到大排序,兼具 唯一性 和 有序性。

# 往 zset 里加数据:成员 + 分数
r.zadd("study:zset", {
    "Alice": 100,
    "Bob": 80,
    "Charlie": 95,
})

# 按分数从小到大取出所有成员
print("从小到大:", r.zrange("study:zset", 0, -1, withscores=True))

# 按分数从大到小取出前 2 名
print("从大到小前2名:", r.zrevrange("study:zset", 0, 1, withscores=True))

# 给某个人加分(比如 Alice +10)
r.zincrby("study:zset", 10, "Alice")
print("Alice 加分后:", r.zrevrange("study:zset", 0, -1, withscores=True))

# 删除一个成员
r.zrem("study:zset", "Bob")
print("删除 Bob 后:", r.zrange("study:zset", 0, -1, withscores=True))

有一个常见的面试题,HashString都可以用来存储对象,一般用那个来存储对象呢,使用String来存储对象,简单直观,但是它不支持局部更新,改一个字段需要覆盖这个字符串,适合一些整体读写、字段少的场景;Hash存储对象,他就支持局部更新,适合一些复杂对象的存储,比如高频更新字段。

3. redis基本命令

因为Redis都是键值对的存储,所以他的方法也很简单,看下面这个例子:

# 1. SET:设置一个字符串 key
r.set("study:string", "hello")

# 2. GET:读取这个 key
print("GET study:string =", r.get("study:string"))  # hello

# 3. INCR:自增一个数值型 key
# 如果这个 key 不存在,会从 0 开始加 1,变成 "1"
r.delete("study:count")  # 为了方便测试,先删掉
r.incr("study:count")    # 当前值 1
r.incr("study:count")    # 当前值 2
r.incr("study:count", 5) # 加 5 -> 当前值 7
print("study:count =", r.get("study:count"))  # 7

# 4. EXPIRE:给 key 设置过期时间(单位:秒)
r.expire("study:count", 5)  # 5 秒后过期

读、写、自增和设置过期时间,都比较简单。

因为Redis都是键值对,没有表的概念,所以Key管理就成了问题,社区有一个约定的规范:业务标识:模块名称:唯一标识[:子字段],比如ecom:user:1001:name,这就是电商业务:用户模块:用户id:用户名。还有一些额外的补充规范:

  1. 统一小写:避免大小写混乱,User:1user:1是两个key
  2. 简洁且语义化:看到名称基本就能了解存储的内容
  3. 避免特殊字符:比如空格、换行符、下划线

第二阶段:redis核心机制

4.redis内存模型

Redis是一个内存数据库,大多数数据都保存才内存中。它的内存可以分为两大部分,核心内存和辅助内存。其中核心内存存储的就是我们常用的键值对,也就是key内存和value内存,存储的都是我们所用到的数据;辅助内存放的都是非业务数据,就是Redis运行所需的额外内存,比如一些过期字典、进程本身的开销等。

Redis有一套完善的内存管理机制,主要有这么几步

  1. 基于jemalloc内存分配,将内存划分为不同大小的内存页,比如8B,16B,32B等,分配时匹配最接近的页,减少碎片;线程缓存,减少锁竞争,提升分配效率
  2. 内存回收:内存回收主要有两种,惰性删除和定期删除。惰性删除指的是访问key时检查是否过期,过期了就删除;定期删除,每100ms随机抽查过期的key,去删除已经过期的,但是这里有个问题,如果key过期了,但是没有被抽查到呢,为啥不扫描全量的key呢,这就是一个平衡了,全量扫描需要占用大量的CPU,会影响到业务的,这个就叫做延迟回收,也就是说可能一时半会回收不了,但是终归会被回收。

Redis的内存处理机制天然就有一种滞后性,可能就会出现内存满了的情况,这里的内存满了,并不是设置某个key的value大小超过512M,而是Redis进程占用的内存满了,这里的满有两个意思:主动设置的maxmemory,这个在生产环境上是必须要设置的

maxmemory 4GB  # 限制 Redis 最大使用内存为 4GB

还有一种满就是,如果不设置这个最大值,Redis就会无限制的占用服务器的物理内存,直到耗尽服务器所有可用的物理内存,这个时候操作系统会将Redis的内存数据交换到磁盘的swap分区,这个是磁盘模拟的内存,速度巨慢,最终导致Redis性能暴跌,也可能因为服务器内存耗尽而被系统杀死。

当内存满了后,Redis也有一套内存淘汰策略来处理这种情况,当Redis占用的内存超过设置的maxmemory后,然后再去执行写操作,就会去触发我们的内存淘汰策略,主要有这么几种策略:

  1. LRU

    最近最少使用,就是淘汰那些最近访问次数最少的key,标准的LRU需要维护一个访问时间链表,内存和cpu开销大,Redis实现的是近似LRU:维护一个候选池,触发淘汰时,从目标范围随机抽取key,也就是触发淘汰时,随机抽取一批key,然后比一比谁的访问时间最远,然后就淘汰它。

  2. LFU

    优先淘汰访问频率最低的key,Redis实现的LFU并不是简单的访问次数统计,而是通过概率递增的访问计数+时间衰减机制来近似的反应key的长期访问价值;在触发淘汰时,在通过随机采样的方式选择访问评率最低的key去淘汰。

当内存满了后,再去对数据库做读写操作,读的操作没有影响,但是在触发写的操作时,如果内存满了,会先根据maxmemory-policy设置的内存淘汰策略,在写操作触发的同时根据LFU、LRU去更新内存,直到内存会到安全区;如果内存淘汰策略味为noeviction或者无法淘汰,直接回报错。

5.持久化机制

前面也提到了,Redis是一种基于内存的键值数据库,内存的特性就是在服务器重启后会全部丢失,这就需要将数据做持久化,即使服务器重启了,也可以从磁盘中恢复数据至内存。

Redis提供了两种核心的持久化方式:RDB和AOF,快照持久化和追加文件持久化,接下来挨个介绍下:

快照持久化

RDB是定时对Redis内存中的全量数据做一次拍照,生成一个压缩的二进制文件,比如dump.rdb,保存到磁盘的指定目录。Redis重启时直接加载这个二进制文件,将数据恢复到内存中。

RDB有手动触发和自动触发两种方式:手动触发可以使用save来同步触发,同步触发会阻塞主进程,直到RDB文件生成完成,异步触发通过bgsave来触发,Redis会fork一个子进程来执行RDB文件的生成,主进程会继续处理客户端的请求;自动触发是在配置文件redis.conf中通过快照规则来配置的,满足条件就会自动执行bgsave

save 900 1      # 900 秒内至少 1 次写
save 300 10     # 300 秒内至少 10 次写
save 60 10000   # 60 秒内至少 10000 次写

这里就是自动触发RDB的规则:满足其中的任意条件就会触发一次,比如60s内写一次、300s内写10次等。

RDB优点就在于性能开销小,生成RDB由子进程负责,主进程仅做fork操作,几乎不影响业务;二进制文件直接加载到内存速度也很快。但是缺点也很明显,RDB是定时快照,如果Redis意外崩溃,比如服务器断电,就会丢掉最后一次快照前到崩溃前的所有数据。

追加日志持久化

AOF就是为了解决RDB数据库丢失而设计的持久化方式,就是将Redis的操作日志按照顺序记录下来,重启后通过重放AOF文件中所有的写命令去恢复内存数据。默认是关闭的,需要appendonly yes命令来手动重启。

AOF的相关配置在redis.conf文件中

appendonly yes # 开启AOF(默认no,关闭)
appendfilename "appendonly.aof" # AOF文件名,默认保存在Redis工作目录
dir ./ # 持久化文件(RDB/AOF)的保存目录,默认是Redis启动目录

AOF主要分为三个步骤:

  1. 命令追加

    Redis执行完一个写命令后,会将该命令按照协议追加到内存中的AOF缓冲区,避免直接写入磁盘,减少IO开销

  2. 文件写入

    Redis会定期将AOF缓冲区的数据写入到内核页缓存,这个操作是调用操作系统的write方法,属于异步操作,不会阻塞主线程。

  3. 文件同步

    将内核页缓存中的AOF数据写到测盘中,这个是调用的操作系统的同步方法,会阻塞主线程的,直到刷盘完成。

    将AOF缓冲区中的命令刷到磁盘的AOF文件中,有三种策略:

    # appendfsync 有三个取值:
    appendfsync always  # 每次写命令都立即刷盘(同步),数据最安全,性能最差
    appendfsync everysec# 每秒刷盘一次(默认值),平衡数据安全和性能
    appendfsync no      # 由操作系统决定何时刷盘,性能最好,数据丢失风险最高
    

由于AOF是日志追加的形式,会产生大量的中间态,比如set key 1set key 2set key 3 ,这种中间态其实是没有意义的,还会导致AOF文件变得很大,这就需要AOF重写机制了,重写就是遍历内存中的所有的数据,根据当前的键值对生成一套最简的写命令集来替换原有的AOF文件,重写的触发也分为手动触发和自动触发:手动触发需要执行bgrewriteaof命令;自动触发是通过配置文件,当文件的体积增长到达阙值时,自动触发`bgrewriteaof

auto-aof-rewrite-min-size 64mb  # AOF文件的最小体积,低于这个值不触发重写(默认64mb)
auto-aof-rewrite-percentage 100 # 重写触发的百分比,指当前AOF文件体积比上一次重写后的体积增长了多少(默认100%)

AOF的优点就是,可以通过刷盘策略来控制数据丢失的风险,默认的everysec仅丢失1s的数据,alway几乎无丢失;缺点就是AOF文件体积较大,恢复数据时加载较慢。

混合持久化

RDB和AOF单独使用都各有优缺点,在Redis 4.0之后,引入了混合持久化机制,融合恶RDB和AOF的优点,成为了目前生产环境的首选方案。

redis.conf配置文件中开启混合持久化:

aof-use-rdb-preamble yes  # 开启混合持久化(Redis 4.0+,默认no;Redis 6.0+ 部分版本默认yes)

开启后,AOF文件就不再是纯文本了,头部就成了RDB格式的全量数据快照,也就是二进制文件,尾部是AOF格式写的增量命令,记录从生成RDB快照到当前的所有写命令,是纯文本。

其工作流程主要有这么几个步骤:

当AOF触发重写时,

  1. redis主进程进入fork子进程,执行AOF重写
  2. 子进程首先将内存中的全量数据以RDB格式写入到临时的AOF文件头部
  3. 子进程完成RDB写入后,主进程将AOF重写缓冲区中所有的增量写命令,以AOF格式写入到临时的AOF文件尾部
  4. 主进程用临时AOF文件替换掉旧的AOF文件,完成混合持久化的重写。

混合持久化的优点就在于加载速度快,数据丢失风险小,而且文件的体积也不会很大。

下面推荐一个常见的生产环境的配置,开启混合持久化+RBD默认自动快照:

# ===================== RDB 核心配置 =====================
save 900 1
save 300 10
save 60 10000
rdbcompression yes  # 开启RDB压缩
dbfilename dump.rdb # RDB文件名
dir ./              # 持久化文件存储目录(建议修改为独立的磁盘目录)

# ===================== AOF 核心配置 =====================
appendonly yes      # 开启AOF(混合持久化的前提)
appendfilename "appendonly.aof" # AOF文件名
appendfsync everysec # 刷盘策略,生产首选
auto-aof-rewrite-min-size 64mb # AOF重写最小体积
auto-aof-rewrite-percentage 100 # AOF重写增长百分比
aof-use-rdb-preamble yes # 开启混合持久化(Redis 4.0+)
aof-load-truncated yes # 加载AOF时,若尾部损坏则忽略,继续加载(默认yes)

6. redis事务

Redis事务就是提供一种机制,将多个Redis命令打包成一个执行单元,保证这个单元内的命令会按照顺序、无中断的执行,同时支持对命令执行结果的统一处理,解决多命令批量执行的原子性需求。

Redis事务只依赖五个命令:

  1. MULTI 标记事务开始,后续所有的命令都会加入到事务队列中
  2. EXEC 执行事务队列中的所有命令,执行完成后结束事务,返回所有命令的执行结果
  3. DISCARD 放弃事务队列中的所有命令,清空队列结束事务,回到正常的执行模式
  4. WATCH KEY 对key加乐观锁,监控key是否修改,必须在MULTI之前修改
  5. UNWATCH 取消所有被watch监控的key,事务取消或者执行后会自动执行

看下这个最基础的实务流程:

MULTI
SET balance 100
INCR balance
EXEC

执行到MULTI时,会进入事务状态,后续的SETINCR会被放入到一个事务队列中,直行到EXEC时才会执行队列中的所有的命令。

传统的关系型数据库事务严格遵循ACID原则,原子性、一致性、隔离性和持久性,但是Redis事务为了极致的性能,并不是完全遵循ACID原则。接下来介绍下他的区别:

  1. 原子性

    原子性的定义就是事务中的所有的操作,要么全部执行,要么全部不执行,不会出现部分执行的情况,而Redis事务的原子性分为两种情况:

    1) 事务入队前出错,全不执行:当在MULTI后,EXEC前出现语法错误,Redis会立即返回错误,执行EXEC时会直接放弃整个事务

    2) 执行事务中出现错误,部分执行,没有回滚,命令入队时只会做语法检查,不会做逻辑检查,执行时如果出现了运行错误,Redis就会跳过这个命令,继续执行后续的命令,而且不会对已经执行的命令做回滚

    不支持回滚主要也是从性能考量,实现回滚需要记录每个命令的逆操作,比如SET的操作就是恢复原值,这个会增加Redis内核的复杂度,牺牲执行的性能。

  2. 一致性

    一致性就是事务执行的前后,数据库的状态始终保持合法,不会因为事务的执行而出现脏数据。Redis事务可以在所有的异常情况下,比如入队错误、执行错误、宕机,都可以保证数据的一致性。

  3. 隔离性

    隔离就是在多个事务并发执行时,一个事务的执行不会被其他的事务干扰,各个事务之间相互隔离。Redis是单线程处理客户端请求的,这就会导致事务的执行会按照队列中的顺序连续执行,不会被其他的命令打断

  4. 持久性

    持久性是指事务执行成功后,对数据的修改会被永久的保存到磁盘中,不会应为宕机而丢失。Redis事务本身并不保证持久性,持久性是由Redis的持久化机制来实现的,前面也介绍过

接下来写一个小的demo,利用watch来控制库存防止超卖

def try_purchase(stock_key: str, user: str, qty: int = 1) -> bool:
    """使用 WATCH + 事务进行扣库存,避免超卖。

    乐观锁思路:
    1. WATCH 库存 key,监听是否被别人改动;
    2. 读当前库存,判断是否足够;
    3. 使用 MULTI 开启事务,扣减库存;
    4. EXEC 提交,如果在这期间库存被别人改了,EXEC 会失败(抛 WatchError),然后重试。
    """

    with r.pipeline() as pipe:
        while True:
            try:
                # 1. 监听库存 key
                pipe.watch(stock_key)

                # 2. 读取当前库存
                current = pipe.get(stock_key)
                if current is None:
                    print(f"{user}: 商品不存在")
                    pipe.unwatch()
                    return False

                current = int(current)
                if current < qty:
                    print(f"{user}: 库存不足,当前库存={current}")
                    pipe.unwatch()
                    return False

                # 3. 开启事务,扣减库存
                pipe.multi()
                pipe.decrby(stock_key, qty)

                # 4. 提交事务
                pipe.execute()
                print(f"{user}: 抢购成功,扣减 {qty},扣减前库存={current}")
                return True

            except redis.WatchError:
                # 在 WATCH 之后、EXEC 之前,有其他客户端修改了 stock_key,
                # 这次事务会失败,需要重试。
                print(f"{user}: 检测到并发冲突,重试中...")
                continue

第三阶段:高并发&分布式

7. 缓存模式与一致性

Redis作为缓存的核心亮点就在于其高速的读写操作,来降低传统数据库的压力,基于此Redis推出了有大概四种主流的缓存策略,来将缓存融入业务读写流程,同时保证缓存与数据库的一致性。接下来挨个介绍下:

  1. 缓存穿透模式

    缓存和数据库分离,业务代码主动管理缓存和数据库的交互。在读操作时,先查询缓存,如果命中就直接返回,如果没有就查询数据库,同时将数据库的结果写入缓存;在写操作时,先更新数据库,在删除缓存。

    这种模式适合绝大多数的生产场景,是Redis作为缓存的首选模式。优点就是简单易实现,缺点就是需要额外处理缓存穿透、击穿雪崩等场景。

  2. 读写穿透

    业务代码只和缓存交互,不直接操作数据库,缓存作为中间层,主动管理数据库的读写。在读操作时,先查询缓存,如果命中就直接返回,未命中就查询数据库,将结果写入缓存,然后返回;写操作就更新缓存,然后再去更新数据库。

    这种模式的特点就是业务代码只专注于业务,数据库由缓存层来处理,简化了业务代码逻辑,缺点就是缓存层需要额外的代码开发,而且不支持新增数据,因为新增数据要先执行读操作,才能存入缓存,不太符合常规的业务逻辑。

  3. 写穿模式

    这种模式是读写穿模式的增强版,支持新增、更新数据。在读操作时,和读写穿透模式一样,命中返回,未命中查库更新缓存;写操作时和新增数据时,缓存同步更新数据库,然后返回给业务。

    这种模式的特点在于写操作时,缓存和数据库同步更新,缓存和数据库有着非常强的一致性,常用于支付业务的核心数据缓存。

  4. 写回模式

    这种模式是写穿模式的异步版,差异就在与写操作时是异步的。在读操作时,和读写穿透、写穿模式一样,命中就返回,未命中查库更新缓存后返回;在新增和更新的写操作时,缓存立即返回,然后异步去更新数据。

    这种模式适合并发高、一致性要求较低的场景,比如日志缓存等。

生产模式中比较常用和推荐的,就是缓存穿透模式,然后这种模式在一致性的问题上需要额外处理。比如有这么几个场景:

  1. 在写操作时,正常的流程是,更新数据库,然后删除缓存,更新缓存需要下次读的时候去查库更新。但是如果更新数据库后,遇到宕机或者网络异常,就会导致缓存未及时删除,这就出现了脏数据,知道缓存过期。这也是最常见的不一致场景,属于操作中断导致的缓存未更新。
  2. 在并发的场景下,客户端A和客户端B同时分别执行读写操作,A读操作时,缓存未命中就会去查库,B写操作时更新完数据库后,回去删除缓存,这时如果读操作的写缓存的动作晚于写操作的删除动作,就会产生数据库与缓存的不一致场景,数据库是新的数据,缓存中是旧的脏数据。

在数据一致性上有这样一个原则,最终数据一致性即可,而非强制一致性。Redis作为缓存,是没有办法实现和数据库的强一致性。因为缓存和数据库属于两个独立的存储系统,非要强一致性就需要加锁,这就会牺牲Redis的高性能,而且在实际业务中,带短暂的不一致,对于用户来说并没有感知的。

在缓存穿透模式中,有这么几个方案可以解决缓存与数据库的一致性问题

  1. 单实例低并发场景,在一些后台管理,小流量业务汇中,可以直接使用缓存穿透模式的基础逻辑,即读操作时先缓存后数据库,如果不存在就写一个缓存空值,加上过期时间;在写操作时,先更新数据库,在删除缓存,给所有的缓存加上过期时间。

    这里设置缓存空值,就是为了防止缓存杀手-穿透,比如查询一个数据库没有的值,这就会直接访问数据库,万一遇到恶意访问的脚本,就会导致数据库压力;而设置一个空值,在过期时间内,他是一个有效的缓存,虽然没有值,但减轻了数据库的压力,算是为了系统的稳定性做了一次兜底。

  2. 单实例高并发,在电商的商品详情、商品秒杀库存业务中,在基础方案上增加延迟删除缓存,来解决读操作写缓存晚于写操作删缓存的问题。 流程就是在写操作执行更新数据库后,延迟N毫秒删除缓存,让读操作查库、写缓存的动作先完成。

还有一个常见的八股文:缓存的三大杀手,穿透、击穿和雪崩。

  1. 穿透,在前面的穿透模式时介绍过了,就是查询一些数据库中不存在的值时,每次都会去查询数据库,导致缓存失效,数据库增加额外的压力。解决方案有下面几种:

    1)设置空值,前面也介绍过,这是最简通用的方案

    2)布隆过滤器,提前将数据库中所有的合法key存入过滤器,请求先过过滤器,判定不存在就会直接拒绝,就走不到缓存和数据库了

    3)IP/接口 限流熔断,对于穿透请求高频的IP限流,对查询接口做熔断保护。这里的限流就是限制单位时间内允许请求的数量,比如单位时间内某个IP大量请求不存在的ID,加了限流之后直接回报错429错误码,就走不到缓存、数据库了;熔断就是当下游服务器持续失败或者过慢时,暂时切断请求,防止雪崩扩散。

  2. 击穿,某个极高的热点key,恰好过期或者被删除,此时大量并发请求同时访问该key,全部缓存未命中,所有的请求都会访问到数据库。这里的解决方案有:互斥锁串行重建缓存,热点key永不过期,热点key主动更新

  3. 雪崩,大量缓存key在同一时间集体过期,或者Redis缓存集体宕机,导致请求绕过缓存直接访问数据库,导致数据库负载瞬间爆表,引发整个服务链路雪崩。解决方案有:过期时间随机化,Redis集群高可用,服务限流、熔断、降级。

8. 分布式锁

在分布式、微服务架构中,一个服务会运行在多台机器上,这就是多进程的概念,多台机器会共享一个资源,这个时候python的线程锁就会失效,因为这些锁的作用范围是当前进程的内存,只能管自己进程内的线程。这时就需要分布式锁了,分布式锁就是跨进程、跨服务的锁,要保证多个进程对共享资源的互斥访问。

分布式锁有四个核心的特性:

  1. 互斥性,同一时间只有一个客户端持有锁,其他的客户端必须等待
  2. 安全性,锁只能被其持有者释放,不能被其他客户端误删
  3. 避免死锁,即使持有锁的客户端崩溃、中断,也可以在一定时间后自动释放
  4. 可用性,Redis集群环境下,锁服务不能单点故障,要保证大部分节点可用

Redis做分布式锁的优点,就在于其性能极高,获取、释放锁都是毫秒级,而且部署也比较简单。

接下来介绍下Redis单节点分布式锁的几个命令:

  1. key 锁的唯一标识,比如要给ID=111的资源加锁
  2. value 客户端的唯一标识,保证锁只能被持有者释放
  3. NX 全程Not Exist,只有当key不存在时才会设置成功
  4. EX/PX 设置的过期时间,EX单位是秒,PX单位是毫秒
  5. timeout 锁的过期时间,避免死锁

比如这行代码:

SET lock:order:123 uuid:192.168.1.100 NX EX 30

就表明给资源key为lock:order:123的资源加锁,锁的持有者是uuid:192.168.1.100,30秒后过期

而释放锁,就回到刚刚的安全性了,必须只有锁的持有客户端才可以释放。流程就是先判断加锁的客户端是不是自己,如果是才可以去释放,看下相关的Iua脚本:

if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])  -- 标识匹配,删除锁
else
    return 0  -- 标识不匹配,不做任何操作
end

之前写过一个卖票的函数,就是使用Redis的分布式锁来控制库存,防止超卖:

async def sell_one_with_lock(r: Redis, window_name: str) -> bool:
    """使用 Redis 分布式锁保护“检查+扣减”关键区,成功卖出返回 True,售罄或失败返回 False。"""
    lock = r.lock(LOCK_KEY, timeout=5, blocking_timeout=1)  # 超时时间与获取等待时间可调
    acquired = await lock.acquire(blocking=True)
    if not acquired:
        # 未拿到锁,视为本次卖票失败(可重试)
        return False
    try:
        # 关键区:读取剩余、判定、扣减
        remaining_str = await r.get(TICKET_KEY)
        remaining = int(remaining_str) if remaining_str is not None else 0
        if remaining <= 0:
            return False
        # 扣减一张(原子自减命令)
        await r.decr(TICKET_KEY)
        return True
    finally:
        try:
            await lock.release()
        except Exception:
            # 若锁已过期或其他异常,忽略释放错误
            pass

代码第一行就创建了一个分布式锁,传入了一个过期时间防止死锁,lock.acquire是真正的加锁步骤。之前看到这里有个疑惑:每次调用这个方法,都会创建一个分布式锁,如何保证对一个资源加锁,在创建锁的时候传入了LOCK_KEY,这个就是要加锁的key,也就是要加锁的资源,这个方法每次执行都会创建一个锁,但是lock.acquire在资源没有释放的时候,返回的是false,也就是会走到if not这里的。

其实后续章节还有单点故障,主从、哨兵、 Redlock、 扩容、数据分片等,觉得分布式、缓存还需要消化下,后面就不在深入了,打算进入实战环节了,后续打算设计三个实战项目来进一步深入的学习下。

三个实战项目分别是

  1. 信息查询系统,使用MySQL存储用户信息,Redis作为缓存,巩固下前面学习的缓存模式,同时加上压测环节,通过QPS,响应时间更加直观的了解缓存的意义
  2. 抢票系统,学习下Lua脚本,
  3. 秒杀系统,学习下高并发处理、限流、防超卖策略

re-render

作者 Tiffany
2026年2月5日 18:28

渲染

那么首先,我们要聊的就是 React 的渲染机制,我们首先要弄清楚在讲 React 渲染的时候,我们具体在说的是什么。

当我们调用 ReactDOM.render(<App />)(这里就不专门用 createRoot API 了)的时候,或者当我们调用 setState 的时候,React 会从根节点开始重新计算一次整个组件树:

  1. React 生成新的 Virtual DOM 树。
  2. 并与旧的 Virtual DOM 树做 diff。
  3. 得到最终需要应用的更新。
  4. 然后执行最小程度的 DOM API 操作。

这里面分为两个步骤:

  • render phase,也就是到计算得到最终需要执行的 DOM 更新操作为止的步骤
  • commit phase,把这些更新 apply 到 DOM 树上

而我们要聊的渲染就是专门指的第一个步骤,也就是 render phase,这个阶段是纯粹的 JS 执行过程,不涉及任何的 DOM 操作。在 React 中,一旦 Virtual Dom diff 的结果确定, 进入 commit phase 之后,任务就无法再被打断,而且 commit 的内容是固定的,所以基本也没有什么优化空间。

因此围绕 React 性能优化的话题,基本上都是再 render phase 展开, 所以这篇文章自然也就围绕着 render phase —— 也就是渲染 —— 展开。

ReactDOM.render()一般都是初次渲染时进行的,那么整个节点树中的组件都会执行渲染就没有什么可奇怪的,所以本文主要围绕着更新来讨论, 也就是 setState(或者说useState返回的setter)。

我们首先要搞清楚的是当执行setState的时候,React 会做什么。

React 是一个高度遵循 FP(函数编程)的框架,其核心逻辑就是UI = fn(props & state) ,这里的fn就是组件,同时也是组件树。 在 React 的设计初期,就是希望组件(树)是一个纯函数,也就是说,组件的输出完全由输入决定,不会受到任何外部因素的影响,这样的好处就是,组件的输出是可预测的,

注意: 即便是 ClassComponent 时期,React 也不是一个面向对象的框架,React 对待 ClassCompoonent 的核心,仍然是其 render 函数,而 instance 纯粹是用于存储 state 和 props 的。

基础规则

默认 React 并没有太多的渲染优化,当我们通过setState触发了一次更新,React 会从根节点开始重新计算一次整个组件树。 是的,你没有看错,不论你在哪里触发了setState,最终都会导致整个组件树的重新计算,React 会从根节点开始一次遍历,以计算出最新的 VirtualDomTree。

注意: 至少在 React16 版本使用 Fiber 重构其 Reconciliation 算法之后是这样的,每次setState更新都会加入到一个更新队列中并且暂存在 root 节点上, 等到这次 event loop 中所有的 update 都进入队列,React 再从根节点上读取改更新队列并开始重新渲染。

除了后面要讲的memo之外,React 默认有也有一项优化,React 渲染虽然是从根节点开始的,但是在遍历过程中如果发现节点本身以及祖先节点没有更新, 而是其子树发生了更新,那么该节点也不会被重新渲染,我们可以来看一下这个例子:

codesandbox.io/p/sandbox/g…

import React from "react";

let renderTimes = 0;
function Child() {
  return renderTimes++;
}

function Parent() {
  const [count, setCount] = React.useState(0);
  return (
    <div>
    <button onClick={() => setCount(count + 1)}>click {count}</button>
  <Child />
  </div>
);
}

let appRenderTime = 0;
export function App() {
  return (
    <div>
    {appRenderTime++}
    <Parent />
    </div>
  );
}

在这个例子中,state 的更新发生在Parent组件中,而当Parent组件更新导致重新渲染时,虽然Child组件没有任何的 props 和 state 变化, 但其仍然重新渲染了(renderTimes 增加了),相对的App组件却没有重新渲染,这就说明 state 的更新只会导致更新节点的子树重新渲染,并不会影响祖先节点。

注意: 你看到了renderTimes每次都会加 2,这不是 bug,在 React 的开发模式中,每次更新都会渲染两次,以便于检查你写的useEffect有没有正确消除 effect, 官方文档

小结

  • 虽然 React 的更新会从根节点开始遍历,但是只有更新节点的子树会被重新渲染,祖先节点不会被重新渲染
  • 即便更新节点的子节点没有任何变化,也会被重新渲染

规避渲染

现在我们知道 React 更新渲染的基本规则,接下去要讨论的就是如何进行优化。

但在正式开始之前,我们要知道的是,即便你不做任何优化,对于大部分的应用来说,React 的性能也是够用的,你把各种优化加上有时候反而会适得其反, 这也是为什么很多开发者其实并不完全理解 React 的更新机制,甚至一些理解的开发者也并不能第一眼就看出代码是否有优化空间, 但是 React 仍然是世界上使用最多的前端框架,并且大部分用其开发的应用都是正常运行的。

所以很多时候,而是先专注于实现,然后回过头去用 Profiler 这类工具去分析你的应用, 然后再针对有性能问题的地方去做优化,这样的做法在大多数情况下是更有效且高效的。

重新思考你的组件结构

我们来看下面一个例子

codesandbox.io/s/flamboyan…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    return <nav>Menu Render Times: {menuRenderTime++}</nav>;
}

function Nav() {
    const [theme, setTheme] = React.useState("light");

    return (
        <div>
            <Menu />
            <button
                onClick={() => setTheme(theme === "light" ? "dark" : "light")}
            >
                Theme: {theme}
            </button>
        </div>
    );
}

export function App() {
    return (
        <div>
            <Nav />
            <div>Content</div>
        </div>
    );
}

这是一个非常常见的例子,我们的应用包含了一个导航栏,导航栏里面有一个菜单,同时导航栏还包含一个切换主题的按钮, 我相信大部分人在遇到这么一个需求的时候,第一反应应该也就是这么去实现,而在这个例子里就隐藏着一个可以优化的地方。 我们先来看这个例子,现在点击切换主题时,Menu组件每次都会重新渲染,很显然符合我上面说到的子组件会因为祖先组件的渲染而重新渲染。

而我们可以通过简单地调整Nav和Menu之间的关系来规避这个问题,这就是 renderProps,来看我如何改造组件

codesandbox.io/p/sandbox/d…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    return <nav>Menu Render Times: {menuRenderTime++}</nav>;
}

function Nav({ renderMenu }) {
    const [theme, setTheme] = React.useState("light");

    return (
        <div>
            {renderMenu}
            <button
                onClick={() => setTheme(theme === "light" ? "dark" : "light")}
            >
                Theme: {theme}
            </button>
        </div>
    );
}

export function App() {
    return (
        <div>
            <Nav renderMenu={<Menu />} />
            <div>Content</div>
        </div>
    );
}

现在你切换主题时Menu组件就不会再重新渲染了,这里就利用到了上面总结的第一点,子组件的更新不会引起祖先节点的重新渲染, 在这个例子里,Nav是App的子节点,其更新并不会让App节点重新渲染,而Menu是App渲染过程中被创建的, App没有重新渲染,说明Menu节点没有被重新创建,其复用的仍然是上一次渲染时创建的Element。

所以结论就是,相较于:

function C() {
    return <div />;
}

function B() {
    return <C />;
}

function A() {
    return <B />;
}

这样递归嵌套的组件结构,我更推荐这样的结构:

function C() {
    return <div />;
}

function B({ children }) {
    return children;
}

function A() {
    return (
        <B>
            <C />
        </B>
    );
}

在 React 中,children其实也是一个prop,只是一般我们习惯把 children 和 props 分开来对待,所以很多同学可能会下意识地认为 children 和 props 是不同的东西。

那么归结到这个例子里面,因为App节点没有重新渲染,所以我们没有重新创建Menu组件地节点(通过createElement),因此 Nav 组件的 props 是没有任何变化的, 他拿到的 Menu 组件的 Element 和前一次渲染的是完全相同的实例! 而这才是在这种 case 下面 C 节点没有重新渲染的根本原因。我们可以通过代码来进行验证:

codesandbox.io/p/sandbox/4…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    return <nav>Menu Render Times: {menuRenderTime++}</nav>;
}

let lastMenuElement = null;
function Nav({ renderMenu }) {
    const [theme, setTheme] = React.useState("light");

    React.useEffect(() => {
        lastMenuElement = renderMenu;
    }, [renderMenu]);

    return (
        <div>
            {renderMenu}
            Menu Changed: {renderMenu === lastMenuElement ? "No" : "Yes"}
            <button
                onClick={() => setTheme(theme === "light" ? "dark" : "light")}
            >
                Theme: {theme}
            </button>
        </div>
    );
}

export function App() {
    return (
        <div>
            <Nav renderMenu={<Menu />} />
            <div>Content</div>
        </div>
    );
}
owner vs children

在上面的例子里,Menu 节点的 owner 是 App,而它是 Nav 节点的 children,所以这里引出一个结论:

节点是否重新渲染会受到 owner 的影响,但和 parent 并不是直接相关。

理解 owner 和 children 的区别对于理解 React 的一些概念还是非常有帮助的,但是 React 官方其实并没有给出这样的概念,所以这里我只是给出了一个比较形象的图示,

简单来说,owner 就是创建当前节点的节点,比如在这个例子里的Menu,他的创建在App中时,他的 owner 就是App,而如果是在 Nav 里面,则 owner 是 Nav。 对比这个结果我们可以发现,影响Menu节点是否重新渲染的根本原因,是其 owner 是否重新渲染,因为一旦 owner 重新渲染,就会引起Menu节点的重新创建, 就会让Menu节点需要被重新渲染。

那么是不是只要节点的对象没有变化,就可以规避重新渲染呢?没错,这就是接下去我们要聊的第二点。

保持节点不变

使用key优化节点对比

在 React 中,key 属性用于优化 Virtual DOM 中节点的对比过程。具体来说,key 的作用包括:

  1. 唯一标识:
    • key 为每个元素提供唯一标识,帮助 React 在列表更新时识别哪些元素发生了变化。
  1. 高效更新:
    • 当组件更新时,React 使用 key 来快速判断哪些元素是新增、删除或移动的。
    • 通过 key,React 可以复用相同 key 的组件实例,避免不必要的重新渲染。
  1. 避免不必要的操作:
    • 使用 key 能防止由于错误对比而导致的组件状态丢失或不必要的重排。
    • 提高性能,尤其是在列表的元素发生排序或频繁更新时。

总之,合理使用 key 可以显著提升 React 应用的渲染性能和准确性。通常建议使用唯一且稳定的标识(如数据库中的 ID)作为 key

减少节点的无效创建

严格来说,上面的例子也就是保持了节点不变,所以规避了Menu节点的无用渲染,只是因为造成节点不变的原因来自 React 自身的算法优化,所以我单独拿出来说, 而这一节则会围绕更 common 的场景来讲解。我们仍然来看一个例子,这个例子会简单很多:

codesandbox.io/p/sandbox/r…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    return <nav>Menu Render Times: {menuRenderTime++}</nav>;
}

const menuElement = <Menu />;

export function App() {
    const [count, setCount] = React.useState(0);

    return (
        <div>
            {menuElement}
            <button onClick={() => setCount((c) => c + 1)}>
                Count: {count}
            </button>
        </div>
    );
}

我简化了之前的例子,同样保持了 Menu 组件不会随着父组件的重新渲染而渲染,而这个实现就非常简单,我把menuElement的创建挪到了App组件外面, 这样的结果是,menuElement的创建只会发生一次,而不会随着App组件的重新渲染而重新创建,而借此让Menu节点规避了因为祖先节点的重新渲染而引起的无效渲染。

需要注意这种方式并不会导致Menu组件内部的setState失效,我们可以通过代码来验证:

codesandbox.io/p/sandbox/r…

import React from "react";

let menuRenderTime = 0;
function Menu() {
    const [count, setCount] = React.useState(0);

    return (
        <nav>
            Menu Render Times: {menuRenderTime++}
            <button onClick={() => setCount((c) => c + 1)}>
                Menu Count: {count}
            </button>
        </nav>
    );
}

const menuElement = <Menu />;

export function App() {
    const [count, setCount] = React.useState(0);

    return (
        <div>
            {menuElement}
            <button onClick={() => setCount((c) => c + 1)}>
                Count: {count}
            </button>
        </div>
    );
}

所以如果要论如何优化 React 的渲染性能,很大的一个方向其实就是减少节点的无效创建,这一方面减少了createElement的调用次数, 另一方面大大规避了无效渲染,但是这种方式为什么并没有被广泛推广呢?主要是因为其可维护性不高,因为你需要把具体某几个节点单独提出去声明, 这让节点渲染脱离了常规的节点流,而等到你的业务变得复杂,你可能很难避免需要传递一些 props 给该组件,这时候你就需要把这个组件提升到父组件中, 那代码改起来就变得非常的麻烦。

另外一种方式是不把Menu提到App之外,而是放到useMemo中,这也是可行的,但是这会引入useMemo的计算成本,你可能需要去评估这个成本是否值得, 而却虽然方便了一些,但是仍然维护起来比较麻烦。

不过 React 提供了一种更符合使用习惯的优化方式,那就是React.memo,这个 API 的作用就是让组件变成一个纯组件,也就是说,如果组件的props没有变化, 那么就不会重新渲染。

React.memo

React.memo其实就是函数组件版的PureComponent,当你使用memo来定义一个组件的时候,memo会在发现组件需要重新渲染的时候, 先去 check 一遍组件的props是否变化,他的默认 check 算法是shallowEqual,也就是只比较props对象的直接属性,并且直接===来对比, 如果 prop 是对象,他也是直接对比对象的引用是否相同,所以总体来说比较算法的成本是很低的,大概率比组件重新渲染要低很多。

React 的 issue 里也有一个讨论 React 是否应该默认开启memo的帖子,可以看到很多用户其期望可以默认开启memo的, 因为几乎百分之 95%以上的情况(甚至可能更高),你把所有组件都开启memo是没有什么负面影响的,却可以规避大部分的无效渲染, 是属于何乐而不为的事情。有兴趣的同学可以去这个issue看看大佬们的讨论。

总结一下为什么 React 官方不考虑默认开启memo的原因:

  • 兼容老代码,React 的向前兼容是出了名的牛,甚至 5-6 年前的代码现在升级到 18 大概率还能正常运行,只是多了很多 warning, 而因为考虑默认开启memo是对 React 的渲染机制的一种破坏性更新,即便大部分的代码不会受影响,但是出于兼容性的考虑,也不会默认开启
  • 有一些极端的 Case 可能会因为加了memo无法正常工作,比如在一些使用响应式编程来维护组件状态的情况,当然我并没有碰到过类似 case, 一方面我不喜欢在 React 中用响应式,另外一方面即便是响应式编程也需要一些极端的情况才会出现。
  • 不开启memo性能也没有那么差,还是那句话,大部分情况下,即便你不做任何优化,React 的性能也是足够的,如果你发现哪里性能有问题, 你再渐进式地去加memo就可以,这属于 React 地一种设计哲学吧,你可以不认可,但也不能否认他也有正确地地方。

关于 memo 的使用我就不单独举例了,相信大家都用到过,memo 其实就是组件级别的 useMemo,而 props 中的所有属性就是 useMemo 中第二个参数中的数组, memo 只要发现 props 没有变化,就会直接返回之前已经创建过的 Element,也就符合了我上一节中提到的优化方式,却又没有代码难以维护的问题。

注意: memo并没有规避渲染,而是把重复渲染这件事交给了memo返回的HOC,而这个组件只做了一件事,也就是判断props是否变化,如果没有变化就返回他cache的节点, 内部实现有点类似:

function memo(Comp) {
    return MemoHOC(...props) {
        const element = useMemo(() => {
            return <Comp {...props} />
        }, [...Object.values(props)]) // 当然这里需要排序一下

        return element
    }
}

结语

一个词概括就是机制,React设计如此,他的更新就是组件树级别的,如果你时不时打开Profiler看看,你会发现很多时候你的代码大概率就是只有几个叶子节点在更新,只要不犯类似频繁更新Context这样的基本错误。

带鱼屏适配,我用了最笨的办法

作者 webxin666
2026年2月5日 18:12

要把官网适配带鱼屏。说实话一开始挺头疼的,21:9 的屏幕和常规 16:9 差太多了,设计那边给的稿子是 2500×1080,核心内容还是 1920×1080。

网上搜了一圈,方案五花八门,什么媒体查询断点、rem 适配、vw/vh 方案... 试了几个都不太顺手。最后用了个最笨的办法 —— 整体 scale 缩放。经过实践效果还不错。

超宽屏幕 2900×1080 分辨率的显示效果:页面居中两边留白 image.png

常规屏幕 1920×1080 分辨率的显示效果:只显示核心内容 image.png

较窄屏幕 900×1080 分辨率的显示效果:显示核心内容,上下留白 image.png

实际效果参考莉莉丝游戏《帕萌战斗日记》官网

先说思路

两层容器嵌套:

  • 外层 2500×1080,带鱼屏用户能看到的扩展区域
  • 内层 1920×1080,核心内容区,普通屏幕也能完整显示

然后根据视口大小,算一个缩放比例,整体 scale。就这么简单。

核心就一行 CSS

--s: min(100vw / 1920px, 100vh / 1080px);

取宽高缩放比的最小值,保证页面不会超出屏幕边界。

这里用了 CSS 的 min() 函数,直接在样式里算。省得写 JS 监听 resize 了。

容器结构

#element_pc (撑满视口,overflow: hidden)
  └── #container_2500 (2500×1080,加 scale 缩放)
        └── .box_1920 (1920×1080,居中放核心内容)

具体代码

HTML 结构

<div id="element_pc">
    <div id="container_2500" class="swiper MySwiper">
        <!-- 顶部导航,fixed 定位 -->
        <div class="header">...</div>
        
        <!-- Swiper 垂直轮播 -->
        <div class="swiper-wrapper">
            <div class="swiper-slide section1">
                <!-- 2500 区域可以放带鱼屏专属的装饰元素 -->
                <div class="box_1920">
                    <!-- 1920 区域放主要内容 -->
                </div>
            </div>
            <div class="swiper-slide section2">...</div>
        </div>
    </div>
</div>

CSS 核心部分

#element_pc {
  width: 100vw;
  height: 100vh;
  position: relative;
  overflow: hidden;
  
  /* 设计稿尺寸,改这俩变量就行 */
  --designW: 1920px;
  --designH: 1080px;
  --s: min(100vw / var(--designW), 100vh / var(--designH));
}

#container_2500 {
  width: 2500px;
  height: 1080px;
  position: absolute;
  left: 50%;
  top: 50%;
  /* 先居中,再缩放 */
  transform: translate(-50%, -50%) scale(var(--s));
  background-color: #e7e7e7;
}

.box_1920 {
  width: var(--designW);
  height: var(--designH);
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
}

记得加上浏览器前缀:

-webkit-transform: translate(-50%, -50%) scale(var(--s));
-ms-transform: translate(-50%, -50%) scale(var(--s));

JS 部分

用了 Swiper 做垂直轮播,配合导航栏联动。这块没什么特别的,常规操作:

var mySwiper = new Swiper('.MySwiper', {
  direction: 'vertical',
  mousewheel: { forceToAxis: true },
  keyboard: { enabled: true },
  on: {
    slideChange: function () {
      let indexObj = { 0: 'NAV1', 1: 'NAV2' }
      hederChangeNav(indexObj[this.activeIndex]);
    },
  },
})

function hederChangeNav (val) {
  $('.header .nav_item').removeClass('active');
  $('.header .nav_item.' + val).addClass('active');
  mySwiper.slideTo(val === 'NAV1' ? 0 : 1);
}

不同屏幕啥效果

实测了几种屏幕:

屏幕类型 效果
16:9 普通屏 显示 1920 核心区域,2500 两边被裁掉,正常使用
21:9 带鱼屏 2500 区域完整显示,两边的装饰元素都能看到
32:9 超宽屏 上下会有黑边,因为高度先达到缩放上限

32:9 的黑边其实也能接受,毕竟那种屏幕本来就少。真要适配可以再套一层更宽的容器,但感觉没必要。

踩的坑

1. fixed 定位不跟着缩放

导航栏用的 fixed 定位,结果发现它不受父容器 scale 影响。

一开始想着把导航也放 scale 容器里,但那样滚动的时候导航会跟着动。最后还是单独处理了,导航栏宽度写死,位置用百分比适配。

2. CSS min() 兼容性

min() 函数不支持 IE,老版本 Safari 也有问题。

如果要兼容老浏览器,得用 JS 算缩放比例,监听 resize 事件动态设置。大概这样:

function updateScale() {
  const s = Math.min(
    window.innerWidth / 1920,
    window.innerHeight / 1080
  );
  document.getElementById('container_2500').style.transform = 
    `translate(-50%, -50%) scale(${s})`;
}
window.addEventListener('resize', updateScale);
updateScale();

不过现在项目不用管 IE 了,直接 CSS 搞定。

3. 字体模糊

scale 缩放可能导致字体模糊,特别是缩放比例不是整数的时候。

没找到完美方案,加了个 transform-origin: center 稍微好点。如果真的很在意,可能得用别的方案了。

文件怎么放

实际项目里我是这么组织内容的:

  • 主要内容、交互元素 → 放 .box_1920 里,确保普通屏幕也能正常用
  • 装饰性背景、扩展视觉元素 → 放 #container_2500 里,带鱼屏用户能看到更多

比如首屏背景图可以用 2500 宽的,两边是延伸的装饰,中间 1920 是主体。普通屏用户看到的就是 1920 的部分,带鱼屏用户能看到完整的 2500。

这方案适合什么场景

适合:

  • 官网、落地页这种展示型页面
  • 设计稿是固定尺寸的
  • 不需要考虑移动端(移动端另外写一套)
  • 不用支持 IE

不适合:

  • 内容型网站,比如博客、文档站
  • 需要响应式布局的
  • 对文字清晰度要求极高的

这个方案说白了就是"设计稿多大就做多大,然后整体缩放"。优点是简单,设计稿是啥样,页面就是啥样,不用考虑响应式布局,不用算百分比,所有尺寸直接写 px。缺点也很明显,只适合展示型页面,内容型网站别用这个,文章页面缩来缩去体验很差。其实我一直觉得这方案有点"作弊"的感觉,不够优雅。但能用、好维护、上线快,有时候这就够了。毕竟代码是写给人看的,也是写给 deadline 看的。

代码我放在github了,github.com/xxhe1024/ul…

有更好的方案欢迎评论区交流。

拯救UI美感!纯CSS实现「幽灵」滚动条:悬停显示、贴边优雅

2026年2月5日 17:59

浏览器系统默认的滚动条样式又粗又黑,非常影响观感,为此特意做了一版样式优化。

实现以下需求:

  1. 默认不显示,鼠标移入滑动范围内才显示。(保证页面无需滑动时不臃肿);
  2. 使用简单,添加到全局样式中后,使用时只需在class中添加类名即可。;
  3. 滚动条贴边(需要div内部样式准确);
  4. 调整粗细和颜色,增强使用体验!
.commonScroll {
  scrollbar-color: transparent transparent;
  scrollbar-width: 8px;

  & ::-webkit-scrollbar-thumb,
  &::-webkit-scrollbar-thumb {
    background-color: transparent;
    border-radius: 2px;

    &:horizontal {
      background-color: transparent;
      border-radius: 2px;
    }
  }

  &:hover {
    scrollbar-color: var(--g-scroll-bar-color) transparent;

    & ::-webkit-scrollbar-thumb,
    &::-webkit-scrollbar-thumb {
      background-color: var(--g-scroll-bar-color);
      border-radius: 2px;

      &:horizontal {
        background-color: var(--g-scroll-bar-color);
        border-radius: 2px;
      }
    }
  }

  & ::-webkit-scrollbar-track,
  &::-webkit-scrollbar-track {
    background-color: transparent;
  }
}


//  dark.css:    --g-scroll-bar-color:  #565657;
// light.css:    --g-scroll-bar-color: #CACBCC;

使用时

<div class="searchWrapper  commonScroll">

最终效果如下:

image.png

简单记录下 状态机

作者 ccnocare
2026年2月5日 17:53

以下是简单的状态机设计模式和简单示例,核心是先理解状态机的核心思想,再通过极简示例掌握用法,最后扩展到通用设计模式。

一、先搞懂:状态机的核心思想(人话版)

状态机本质是「把复杂的状态切换规则写死,让程序只能按规则走」,就像:

  • 玩游戏时,角色「站立」只能切「行走/跳跃」,不能直接切「死亡」;
  • 点外卖时,「待付款」只能切「付款中/取消」,不能直接切「已收货」。 核心三要素(记住这3个,状态机就懂了):
  1. 状态(State): 程序当前的“身份”(比如:空闲、加载中、成功、失败);
  2. 事件(Event):触发状态变化的“动作”(比如:点击“加载”、请求成功、请求失败);
  3. 规则(Transition):“什么状态下触发什么事件,能切到什么新状态”(比如:空闲→点击加载→加载中)。

二、前端状态机的通用设计模式(极简版)

不用依赖任何库,纯原生JS就能实现,核心结构就4部分:

// 状态机设计模式模板(通用) 
const StateMachine = { 
    // 1. 初始状态 
    currentState: "初始状态", 
    // 2. 状态切换规则:{ 当前状态: { 事件: 目标状态 } } 
    rules: {}, 
    // 3. 触发状态切换的核心方法
    trigger(event, payload) { 
        // 第一步:查规则,判断当前状态能不能触发这个事件 
        const nextState = this.rules[this.currentState]?.[event]; 
        if (!nextState) { 
            console.warn(`❌ ${this.currentState}状态下不能触发${event}事件`); 
            return; 
        } 
        // 第二步:记录旧状态,切换到新状态 
        const oldState = this.currentState; 
        this.currentState = nextState; 
        // 第三步:执行状态切换后的逻辑(比如更新UI、调接口) 
        this.onChange(nextState, oldState, payload); 
    }, 
    // 4. 状态变更后的回调(自定义业务逻辑) 
    onChange(newState, oldState, payload) { 
        console.log(`✅ 状态变更:${oldState}${newState}`, payload); 
    }, 
};


三、最简单示例:

按钮点击的加载状态机 用「按钮点击请求数据」这个前端最常见的场景,实现状态机,一看就懂:

需求

按钮有4个状态:

  • 初始:idle(空闲,可点击);
  • 点击后:loading(加载中,禁用按钮);
  • 请求成功:success(显示成功,3秒后重置为空闲);
  • 请求失败:error(显示失败,可重新点击)。

完整代码(可直接复制运行)

<!DOCTYPE html> 
<html> 
<body> 
    <!-- UI:一个按钮 + 状态提示 --> 
    <button id="btn">点击加载数据</button> 
    <div id="status">当前状态:空闲</div> 
    <script> 
        // 1. 初始化状态机 
        const loadDataFSM = { 
            // 初始状态:空闲 
            currentState: "idle", 
            // 2. 定义状态切换规则(核心!) 
            rules: { idle: { click: "loading" },
            // 空闲→点击→加载中 
            loading: { success: "success", fail: "error" }, 
            // 加载中→成功/失败 
            success: { reset: "idle" }, 
            // 成功→重置→空闲 
            error: { retry: "loading" } 
            // 失败→重试→加载中 
        }, 
        // 3. 触发状态切换的方法 
        trigger(event, payload) { 
            const nextState = this.rules[this.currentState]?.[event]; 
            if (!nextState) { 
                alert(`当前${this.currentState}状态,不能${event}!`); 
                return; 
            } 
            const oldState = this.currentState; 
            this.currentState = nextState; 
            this.onChange(newState, oldState, payload); 
        }, 
        // 4. 状态变更后更新UI(业务逻辑) 
        onChange(newState, oldState, payload) { 
            const btn = document.getElementById("btn");
            const status = document.getElementById("status"); 
            // 根据新状态更新UI 
            switch (newState) { 
                case "loading": 
                    btn.disabled = true; 
                    btn.innerText = "加载中..."; 
                    status.innerText = "当前状态:加载中"; 
                    // 模拟异步请求 
                    mockRequest().then(() => { 
                        this.trigger("success");  // 请求成功→切换到success 
                    }).catch(() => { 
                        this.trigger("fail"); // 请求失败→切换到error 
                    }); 
                    break; 
                case "success": 
                    btn.disabled = false; 
                    btn.innerText = "点击加载数据"; 
                    status.innerText = "当前状态:成功(3秒后重置)"; // 3秒后重置为空闲
                    setTimeout(() => this.trigger("reset"), 3000); 
                    break; 
                case "error": 
                    btn.disabled = false; 
                    btn.innerText = "重新加载"; 
                    status.innerText = "当前状态:失败,可重试"; 
                    break; 
                case "idle": 
                    btn.innerText = "点击加载数据";
                    status.innerText = "当前状态:空闲"; 
                    break; 
                } 
            } 
        }; 
        // 模拟异步请求(50%成功率) 
        function mockRequest() { 
            return new Promise((resolve, reject) => { 
                setTimeout(() => { 
                    Math.random() > 0.5 ? resolve() : reject(); 
                }, 1000); 
        }); 
        } 
        // 绑定按钮点击事件 
        document.getElementById("btn").addEventListener("click", () => { 
            // 点击按钮:触发对应事件(idle态触发click,error态触发retry) 
            const event = loadDataFSM.currentState === "idle" ? "click" : "retry";
            loadDataFSM.trigger(event); 
        }); 
    </script> 
</body> 
</html> 

运行效果

  1. 初始:按钮可点击,显示“点击加载数据”,状态提示“空闲”;
  2. 点击按钮:按钮禁用,显示“加载中...”,1秒后随机切换:
    • 成功:按钮恢复,显示“点击加载数据”,3秒后重置为初始态;
    • 失败:按钮恢复,显示“重新加载”,点击可重试;
  3. 非法操作拦截:比如“加载中”时重复点击按钮,会提示“加载中状态下不能click事件”。

四、核心设计思路拆解(通俗易懂)

1. 为什么这么设计?

  • 把「状态规则」和「业务逻辑」分开:规则写在rules里,UI逻辑写在onChange里,不用混在一起;
  • 所有状态切换都走trigger方法,相当于“唯一入口”,避免直接改currentState导致的混乱;
  • 规则是“显性的”,比如看rules就知道:
    rules: { 
        idle: { click: "loading" }, 
        loading: { success: "success", fail: "error" }, 
        success: { reset: "idle" }, 
        error: { retry: "loading" }
    }

不用逐行读代码,就能知道“什么状态能做什么事”。

2. 关键细节(新手必看)

  • trigger方法是“门卫”:先查规则,再切换状态,最后执行逻辑,一步都不能少;
  • onChange方法是“执行者”:只负责状态变更后的业务(比如更UI、调接口),不关心怎么切换的;
  • 永远不要直接改currentState:比如不要写loadDataFSM.currentState = "success",必须通过trigger触发,否则会绕过规则校验。

五、扩展:更通用的状态机类(可复用)

如果多个场景要用状态机,可封装成类,复用性更高:

// 通用状态机类 
class SimpleFSM { 
    constructor(initialState, rules, onChange) { 
        this.currentState = initialState; // 初始状态 
        this.rules = rules; // 切换规则 
        this.onChange = onChange; // 状态变更回调 
    } 
    // 触发状态切换 
    trigger(event, payload) { 
        const nextState = this.rules[this.currentState]?.[event]; 
        if (!nextState) { 
            console.warn(`❌ 非法操作:${this.currentState}${event}`); 
            return false; 
        } 
        const oldState = this.currentState; 
        this.currentState = nextState; 
        this.onChange?.(nextState, oldState, payload); // 执行回调 
        return true; 
    } 
    // 获取当前状态 
    getState() { 
        return this.currentState; 
    } 
}
// 用法:创建一个弹窗状态机 
const modalFSM = new SimpleFSM( 
   "closed", // 初始状态:关闭 
   // 切换规则 
   { 
       closed: { open: "opened" }, 
       opened: { close: "closed", confirm: "closed" } 
   }, 
   // 状态变更回调(更新弹窗UI) 
   (newState) => { 
       const modal = document.getElementById("modal"); 
       modal.style.display = newState === "opened" ? "block" : "none"; 
   } 
   );
  // 调用示例 
  modalFSM.trigger("open"); // 打开弹窗 
  modalFSM.trigger("confirm");// 确认关闭弹窗 
  modalFSM.trigger("open"); // 非法操作:closed态才能open,opened态不行,会提示

总结(关键点回顾)

  1. 状态机核心三要素:状态(当前身份)、事件(触发动作)、规则(切换逻辑);
  2. 设计模式核心:一个唯一的切换入口(trigger)+ 显性的规则(rules)+ 分离的业务逻辑(onChange);
  3. 核心好处:拦截非法操作、避免状态混乱、逻辑清晰易维护,哪怕是简单场景也能少写bug。 这个设计模式不用依赖任何第三方库,纯原生JS就能实现,新手也能快速上手,是前端状态机最基础、最易懂的写法。

《告别“我电脑上好好的”:.nvmrc 让 Node.js 版本管理变轻松》

作者 leafyyuki
2026年2月5日 17:39

在团队协作开发 Node.js 项目时,你是否经常遇到这些烦心事?

  • “这个模块在我电脑上运行正常,怎么到你那就报错了?”
  • “我刚更新了 Node 版本,为什么老项目跑不起来了?”
  • 每切换一个项目,就要查一下它用的 Node 版本,然后手忙脚乱地切换。

如果你的答案是“是”,那么  **.nvmrc** 文件就是你一直在寻找的“版本一致性”解决方案。配合 Node 版本管理工具 nvm,它能帮你彻底告别环境混乱。本文将带你轻松掌握这套最佳实践。

什么是 nvm 与 .nvmrc?

nvm (Node Version Manager) 是一个命令行工具,允许你在同一台机器上安装和切换多个版本的 Node.js。它完美解决了不同项目需要不同 Node 运行环境的难题。

而  **.nvmrc** 文件则是一个“版本说明书”。你只需在项目根目录创建这个文件,并在里面写上项目所需的 Node.js 版本号(如 v20.10.0)。此后,任何人或任何工具(包括你的队友、CI/CD 流水线)进入这个项目,都能通过一条简单的命令 nvm use,快速切换到正确的版本。

快速上手:3步建立版本规范

整个核心流程可以概括为以下三个步骤:

flowchart TD
    A[开始: 使用.nvmrc] --> B[在项目根目录创建.nvmrc文件]
    B --> C[在文件中写入Node.js版本号]
    C --> D[在终端执行nvm use]
    D --> E{版本是否已安装?}
    E -- 是 --> F[成功切换到指定版本]
    E -- 否 --> G[自动安装指定版本]
    G --> F
    F --> H[在当前目录及其子目录下项目使用指定版本]

1. 创建 .nvmrc文件

在你的项目根目录下,运行命令创建文件并指定版本。

# 指定精确版本(推荐,确保完全一致)
echo "v20.10.0" > .nvmrc

# 或使用主版本号(将使用20.x.x中的最新版)
echo "20" > .nvmrc

# 或使用LTS版本(将使用当前的长期支持版)
echo "lts/*" > .nvmrc

2. 使用指定版本

在项目目录下,执行:

nvm use

nvm 会自动读取 .nvmrc文件并切换版本。如果该版本未安装,nvm 会给出明确的安装提示。

3. 提交到代码仓库

将 .nvmrc文件提交到 Git 等版本控制系统中。这是团队协作中至关重要的一步,确保每位开发者都能使用相同的 Node.js 环境。

进阶技巧:让版本切换“自动化”

如果你厌倦了每次进入项目都要手动输入 nvm use,可以通过配置 Shell 实现全自动切换

将以下脚本添加到你的 ~/.zshrc或 ~/.bashrc文件末尾(放在 nvm 初始化语句之后):

# 置于 ~/.zshrc 或 ~/.bashrc 中
autoload -U add-zsh-hook
load-nvmrc() {
  local nvmrc_path="$(nvm_find_nvmrc)"
  if [ -n "$nvmrc_path" ]; then
    local nvmrc_node_version=$(nvm version "$(cat "${nvmrc_path}")")
    if [ "$nvmrc_node_version" = "N/A" ]; then
      nvm install
    elif [ "$nvmrc_node_version" != "$(nvm version)" ]; then
      nvm use
    fi
  elif [ "$(nvm version)" != "$(nvm version default)" ]; then
    echo "切换回 nvm 默认版本"
    nvm use default
  fi
}
add-zsh-hook chpwd load-nvmrc
load-nvmrc

保存后执行 source ~/.zshrc。配置完成后,你的终端会在进入项目目录时自动切换 Node 版本,离开时切回默认版本,实现“无感”切换。

融入开发生命周期

团队规范:在团队中,应将“项目必须包含 .nvmrc文件”作为一项强制约定。这是成本最低、效果最显著的统一开发环境的方法。

CI/CD 集成:在自动化构建和部署流程中,.nvmrc同样能发挥作用。例如,在 GitHub Actions 中,你可以直接使用它来设置 Node 环境:

- name: 使用 .nvmrc 设置 Node.js
  uses: actions/setup-node@v3
  with:
    node-version-file: '.nvmrc'

双保险策略:你还可以在 package.json中用 engines字段再次声明 Node 版本范围,让包管理器(如 yarn、pnpm)也参与版本检查。

{
  "engines": {
    "node": ">=20.10.0 <21"
  }
}

最佳实践总结

  1. 精确版本:为生产项目在 .nvmrc中指定精确版本号(如 v20.10.0),而非模糊的主版本号,这是杜绝意外的最可靠方式。
  2. 个人必配自动切换:强烈建议每位开发者都配置 Shell 自动切换,这将极大提升开发体验。
  3. 团队必提交 .nvmrc:将 .nvmrc视为项目必需品,如同 package.json一样提交到代码库。
  4. 环境穿透:在 Dockerfile、CI 配置等所有环境定义中,都优先读取 .nvmrc来设置 Node 版本,实现从开发到生产的全链路一致。

通过 nvm和 .nvmrc这套简单而强大的组合拳,你可以轻松管理复杂的 Node.js 版本环境。无论是个人项目还是大型团队协作,它都是保障开发顺畅、减少无谓调试时间的基础设施。现在就为你手头的项目创建一个 .nvmrc文件吧!

FLutter的Cursor Skills 实战分享

作者 明君87997
2026年2月5日 17:28

聊聊 Cursor 的 Skills 功能,用 Flutter 项目举例

最近网上一直在炒 Agent Skills 功能, 年底不忙了摸索了一下 Cursor 的Agent Skills 这个功能。结果用了几天之后... 哇偶真香。

今天就来聊聊这玩意儿怎么用,顺便分享下我踩的坑。


这东西到底是干嘛的?

其实说白了,就是让你"调教" AI 的一种方式。

你有没有遇到过这种情况:让 AI 帮你写个 Flutter Widget,它写出来的代码风格跟你项目里的完全不一样?你说"加个 const",它下次又忘了。你说"用 GetX",它给你整个 Provider...

Skills 就是解决这个问题的。你写一个配置文件,告诉 AI "我们项目是这么干的",它就会照着来。


文件放哪儿?

两个地方可以放:

个人目录~/.cursor/skills/你的技能名/SKILL.md

  • 所有项目都能用
  • 比如你的代码风格偏好、常用的工具链配置

项目目录项目根目录/.cursor/skills/你的技能名/SKILL.md

  • 只有当前项目用
  • 可以提交 Git,团队共享

因为公司只有我一个移动端开发, 所以我一般是通用的放个人目录,项目特定的放项目里。


入门:写个最简单的

然后来个最简单的感受一下。

~/.cursor/skills/flutter-widget/ 下面建个 SKILL.md

---
name: flutter-widget
description:  Flutter Widget 的时候用这个。创建组件、写页面、搞 UI 的时候会自动触发。
---

# 我的 Widget 写法

别废话,直接上规矩:

1. 能用 StatelessWidget 就别用 StatefulWidget
2. 构造函数必须加 const
3. 参数必填的用 required

就这样写:

```dart
class UserCard extends StatelessWidget {
  const UserCard({
    super.key,
    required this.name,
  });

  final String name;

  @override
  Widget build(BuildContext context) {
    return Text(name);
  }
}

别给我整什么 Key? key 老写法,直接 super.key 完事。


保存。这就完事了???

现在你让 AI "帮我写个用户卡片组件",它就会按这个风格来。

---

## 进阶:description 很重要!

这里有个坑我踩过——description 写得太随意,AI 根本不触发你的技能。

**错误示范**:
```yaml
description: Flutter 开发

这太模糊了,AI 不知道什么时候该用。

正确示范

description: 写 Flutter Widget 的时候用。当用户说"创建组件""写页面""搞个 UI""弄个卡片"的时候触发。

关键是要写清楚什么时候用。AI 是根据这个 description 来判断要不要加载你的技能的。


中级玩法:加点料

用了一阵之后,我发现光写 Widget 规范不够。导入顺序、文件命名这些也得管,不然代码还是乱。

---
name: flutter-dev
description: Flutter 开发全套规范。写代码、建文件、搞页面的时候用。
---

# Flutter 开发规范(我的版本)

## 文件怎么命名

我习惯这样:
- Widget 文件:`user_card.dart`(小写下划线)
- 页面文件:`user_page.dart`  `user_view.dart`
- 服务类:`user_service.dart`

类名当然还是大驼峰 `UserCard`。

## import 顺序

这个我有强迫症,必须这么排:

```dart
// 1. dart 自带的
import 'dart:async';

// 2. flutter 
import 'package:flutter/material.dart';

// 3. 第三方包
import 'package:get/get.dart';

// 4. 自己项目的
import '../services/api.dart';

中间空一行分隔,看着舒服。

GetX Controller 怎么写

我们项目用 GetX,controller 这么写:

class UserController extends GetxController {
  final _loading = false.obs;
  bool get loading => _loading.value;

  final _user = Rxn<User>();
  User? get user => _user.value;

  Future<void> loadUser() async {
    _loading.value = true;
    try {
      _user.value = await api.getUser();
    } finally {
      _loading.value = false;
    }
  }
}

注意几点:

  • 私有变量用下划线
  • 对外暴露 getter
  • loading 状态别忘了 finally 里关掉

页面结构

class UserPage extends StatelessWidget {
  const UserPage({super.key});

  @override
  Widget build(BuildContext context) {
    return GetBuilder<UserController>(
      init: UserController(),
      builder: (c) => Scaffold(
        appBar: AppBar(title: const Text('用户')),
        body: Obx(() {
          if (c.loading) {
            return const Center(child: CircularProgressIndicator());
          }
          return _buildContent(c);
        }),
      ),
    );
  }

  Widget _buildContent(UserController c) {
    // 内容
  }
}

---

## 高级玩法:文件拆分

技能写多了之后,一个文件塞不下了。而且每次 AI 都要读整个文件,Token 哗哗的烧。

这时候就要拆分。

**目录结构**:

~/.cursor/skills/flutter-pro/
├── SKILL.md # 主文件,放最常用的
├── getx.md # GetX 详细用法
├── api.md # 网络请求规范
└── widgets.md # 组件规范


**SKILL.md 里这么写**:

```markdown
---
name: flutter-pro
description: Flutter 开发完整规范。写代码、搞状态管理、调接口的时候用。
---

# Flutter 开发

## 快速参考

### 状态管理
 GetX。详细的看 [getx.md](getx.md)

### 网络请求  
 Dio + 拦截器。详细的看 [api.md](api.md)

### 组件开发
const 构造、StatelessWidget 优先。详细的看 [widgets.md](widgets.md)

## 最常用的代码

(这里放几个最常用的模板)

这样 AI 平时只读主文件,需要详细信息的时候才去读子文件。省 Token。


省 Token 的一些心得

用了一段时间,总结了几个省钱技巧:

1. 别写废话

AI 比你想象的聪明。什么是 StatelessWidget、什么是 const,它都知道。你只需要告诉它"用 const"就行了,不用解释为什么。

别这样

## 关于 const 关键字

const 是 Dart 中的编译时常量关键字。使用 const 可以让 Flutter 在编译时就确定 Widget 的值,从而优化性能。当 Widget 的所有属性都是编译时常量时,应该使用 const...

这样就够了

## 规矩
- 构造函数加 const
- 能 const 的 Widget 都 const

2. 用模板代替解释

与其写一大段"表单验证应该怎么做",不如直接贴个模板:

TextFormField(
  validator: (v) => v!.isEmpty ? '必填' : null,
  decoration: const InputDecoration(labelText: '用户名'),
)

代码就是最好的文档。

3. 渐进式披露

主文件放最常用的 20%,剩下 80% 拆到子文件。AI 需要的时候自己会去读。


我现在的配置

分享一下我自己的配置,给你参考:

个人目录~/.cursor/skills/):

  • dart-style/ - Dart 代码风格(命名、注释这些)
  • git-msg/ - Git commit 信息格式

项目目录.cursor/skills/):

  • project-api/ - 项目的 API 接口规范
  • project-db/ - 本地数据库操作规范

这样分的好处是:换个项目,个人偏好还在,项目相关的配置跟着项目走。


一些坑

技能没生效?

检查一下:

  1. description 写得够不够具体
  2. 文件名是不是 SKILL.md(大小写敏感)
  3. yaml 格式有没有写错(冒号后面要有空格)

Skills 和 Rules 有啥区别?

Rules 是每次对话都会加载的,Skills 是按需加载的。

所以:

  • 简单的、每次都要用的偏好 → 放 Rules
  • 复杂的、特定场景才用的 → 放 Skills

最后

这功能我觉得挺值得花时间配置的。一次配置,后面省很多事。

刚开始别贪多,先搞一个最简单的,比如 Widget 规范。用顺了再慢慢加。

有问题欢迎交流。

dumi 环境变量

作者 UndefinedLuo
2026年2月5日 17:28

一、env(process.env.NODE_ENV)

process.env.NODE_ENV 由 dumi 自动注入,用于区分开发态和生产态:

命令 环境 process.env.NODE_ENV的值
dumi dev dev development
dumi test test production
dumi build prod production

示例:

if (process.env.NODE_ENV === 'production') {
  // test 和 prod 都会进入这里
}

二、dumi 内置环境:dev / test / prod

命令 环境 对应配置文件 说明
dumi dev dev .dumirc.ts .dumirc.dev.ts .dumirc.local.ts 开发环境,基础 + 阶段性 + 本地覆盖
dumi test test .dumirc.ts .dumirc.test.ts .dumirc.local.ts 测试/预发布环境,基础 + 阶段性 + 本地覆盖
dumi build prod .dumirc.ts .dumirc.prod.ts .dumirc.local.ts 正式生产环境,基础 + 阶段性 + 本地覆盖

三、DUMI_ENV

DUMI_ENV 用于少数特殊场景的额外配置维度,当 dev / test / prod 不够区分时使用,例如:

  • 同一环境需要区分内部 / 对外文档
  • 同一环境存在多套部署或发布目标
  • 为特定用途准备独立配置(演示版、临时发布等)

注意DUMI_ENV 不能设置为 dev / test / prod

四、配置文件加载规则

1. 指定 DUMI_ENV

按以下顺序加载,越靠后优先级越高:

  1. .dumirc.ts
  2. .dumirc.${DUMI_ENV}.ts
  3. .dumirc.${dev | test | prod}.ts
  4. .dumirc.${dev | test | prod}.${DUMI_ENV}.ts
  5. .dumirc.local.ts

后加载的配置会覆盖先加载的配置。

2. 未指定 DUMI_ENV

加载:

  1. .dumirc.ts
  2. 当前环境对应配置文件(.dumirc.dev.ts / .dumirc.test.ts / .dumirc.prod.ts
  3. .dumirc.local.ts

五、总结

  • NODE_ENV:区分 开发态 / 生产态
  • 内置环境 dev / test / prod:区分 构建阶段
  • DUMI_ENV:区分 特殊业务 / 场景配置

当你发现同一构建阶段但配置不同,就考虑使用 DUMI_ENV

JavaScript的数据类型 —— Null类型

作者 橘朵
2026年2月5日 17:13

在 JavaScript 中,Null类型是一个基本数据类型,它只有一个唯一的值:null。通常被用来表示一个有意为之的、不指向任何对象值的空值。逻辑上讲,null值表示一个空对象指针,这也是给typeof传一个null会返回"object"的原因。

let car = null; 
console.log(typeof car); // "object",JavaScript的历史遗留 Bug,不代表null是对象
//具体原因:不同的对象在底层都表现为二进制,
//在 JavaScript 中二进制前三位都为 0 的话会被判断为 object 类型, 
//null 的二进制全部为 0,自然前三位也是 0,
//所以执行 typeof 值会返回 object。

在定义将来要保存对象值的变量时,建议使用null来初始化,不要使用其他值。 这样,只要检查这个变量的值是不是 null 就可以知道这个变量是否在后来被重新赋予了一个对象的引用。

if (car != null) {  
 // car 是一个对象的引用 
} 

初始化变量:预先声明一个变量,并明确表示它暂不指向任何对象。

let futureObject = null; // 表明该变量准备在将来容纳一个对象

清空对象引用:当一个对象不再需要时,将其引用设置为 null,这有助于垃圾回收机制释放内存。

let myHeavyObject = { ... }; // 一个占用大量内存的对象
// ... 使用完成后
myHeavyObject = null; // 断开引用,提示可以回收内存

函数的返回值:当函数没有找到有效结果或操作失败时,返回 null来表示"无"。

function findUser(id) {
  const user = users.find(u => u.id === id);
  return user || null; // 找到用户返回用户对象,否则返回 null
}

DOM 操作:在获取不存在的 DOM 元素时,通常会返回 null

const nonExistentElement = document.getElementById('does-not-exist');
console.log(nonExistentElement); // null

如何安全地检测与处理 null

//正确检测 null
//由于 typeof的 Bug,最可靠的方法是使用严格相等运算符(===)
if (myVar === null) {
  // 明确处理 myVar 为 null 的情况
}

//安全地访问可能为 null的属性
//在尝试访问 null(或 undefined)的属性时,JavaScript 会抛出 TypeError。
//可选链操作符(?.) :如果遇到 null或 undefined就立即停止访问,返回 undefined
let name = user?.profile?.name; // 如果 user 或 profile 为 null/undefined,name 则为 undefiend
//空值合并操作符(??) :为 null或 undefined提供一个默认值
let displayName = user?.profile?.name ?? '匿名用户'; // 如果名字为空,则显示'匿名用户'

undefined 的区别

undefined值是由null值派生而来的,在ECMA-262将它们定义为表面上相等。

console.log(null == undefined); // true

即使nullundefined有关系,它们的用途也是完全不一样的。不要显式地将变量值设置为undefined,当我们试图访问某个不存在的或者没有赋值的变量时,就会得到一个 undefined 值。Javascript 会自动将声明时没有进行初始化的变量设为 undifined

null任何时候,只要变量要保存对象,而当时又没有那个对象可保存,就要用null来填充该变量。null 不能通过 Javascript 来自动赋值,也就是说必须要我们自己手动来给某个变量赋值为 null。这样就可以保持null是空对象指针的语义,并进一步与undefined区分开来。

虽然 undefinednull在非严格相等比较(==)时结果为 true,但它们有本质的不同。

特性 undefined null
含义 表示变量处于自然的、未初始化的原始状态。 表示一个被人为主动赋值的空对象指针,代表"空值"。
数据类型 typeof返回 "undefined"。 typeof返回 "object"(这是历史遗留问题)。
严格相等 undefined === null的结果为 false。
转换为数字 Number(undefined)的结果是 NaN。 Number(null)的结果是 0。

分享前端项目常用的三个Skills--Vue、React 与 UI 核心 Skills | 掘金一周 2.5

作者 掘金一周
2026年2月5日 16:40

本文字数1500+ ,阅读时间大约需要 4分钟。

【掘金一周】本期亮点:

「上榜规则」:文章发布时间在本期「掘金一周」发布时间的前一周内;且符合各个栏目的内容定位和要求。 如发现文章有抄袭、洗稿等违反社区规则的行为,将取消当期及后续上榜资格。

一周“金”选

掘金一周 文章头图 1303x734.jpg

内容评审们会在过去的一周内对社区深度技术好文进行挖掘和筛选,优质的技术文章有机会出现在下方榜单中,排名不分先后。

前端

分享前端项目常用的三个Skills--Vue、React 与 UI 核心 Skills@去伪存真

文章介绍前端项目常用技能包。Vue-Skills 解决 Vue 3 开发痛点;React-Skills 涵盖组件组合、性能优化等规则;UI-Skills 提供多元风格、适配多技术栈。Skills 让开发者转变角色,提升开发效率与代码质量。

供应链系统中的 Web 打印方案的探索实践@古茗前端团队

本文围绕供应链系统中的Web打印方案展开。调研了基于DOM、可视化模板、本地打印控件三类主流方案并对比。重点分析window.print,给出指定区域与批量打印思路,介绍核心CSS打印配置,指出其仍是高性价比方案。

我接手了一个 10 年前的 jQuery 老项目,用 Web Components 给它续了命@ErpanOmer

作者接手 2015 年上线的 jQuery 老 CRM 项目,需添加 AI 智能客服功能。因业务时间紧,选择 Web Components,利用其 Shadow DOM 解决 CSS 污染。介绍组件定义与使用,该方案侵入性零、框架无关、可渐进迁移,还给出避坑指南。

手把手教你使用LangChain(前端开发程序员版)@前端小豆

本文是面向前端开发者的LangChain教程。先介绍前置准备,接着从环境搭建、核心功能实操、前端项目集成展开教学,还给出避坑指南,最后指明进阶方向,助前端开发者用JS/TS集成AI能力,打造智能应用。

后端

Clawdbot 完整对接飞书教程 手把手搭建你的专属 AI 助手@BingoGo

本文是 Moltbot(原 Clawdbot)对接飞书的教程。先介绍在 Linux 系统安装,包括 Git、Node.js 及 Moltbot 安装步骤,还提及查看服务和访问 Web UI 面板方法。接着说明对接飞书流程,含插件安装、参数配置、权限设置等,完成后可与机器人对话。

Agent Skills工作流:从入门到实战@雨中飘荡的记忆

本文围绕Agent Skills工作流展开,先介绍核心概念与架构设计,接着阐述核心组件、基础技能实现,还涉及交互机制、多Agent协作。以智能数据分析系统为例实战,给出部署、测试策略与设计原则,展现其结合AI与传统开发的优势。

性能告警惊四座 神器出手伏恶龙@Hello_world_

2025年12月29日服务出现CPU阈值告警,作者从代码、流量、JVM维度排查,未找到根本原因。后用神器Arthas分析,发现是业务代码中序列化/反序列化代码使用不当所致。修改代码后,CPU利用率大幅降低。

Android

血压飙升,Flutter & Dart 2025 年度巨坑回顾@恋猫de小郭

本文先提及 Google 发布的 Flutter & Dart 2025 高光回顾,随后着重盘点该年 Flutter 经典巨坑,如宏功能暂停、线程合并问题等,其中超半数为 iOS 相关问题,虽官方积极解决,但平台升级坑多。

Compose中的IntrinsicSize实战指南@稀有猿诉

本文围绕Compose中的IntrinsicSize展开,以构建重叠卡片式UI为例,指出weight修饰符导致布局问题的原因,介绍了IntrinsicSize打破循环依赖的原理,列举常见用例,还提及性能注意事项,强调其对复杂UI构建的重要性。

人工智能

从零开始搭建部署 Moltbot/Clawdbot 完整攻略@出门不下雨

本文是 Moltbot/Clawdbot 搭建部署攻略。介绍其为支持多模型、多平台的 AI 助手框架,阐述系统要求,给出 Windows、Mac 安装步骤,涵盖首次配置、常用指令、日常维护、故障排除及进阶配置内容,还解答常见问题。

Agent Skills 傻瓜式教程,26 年最火 AI 技术就这?@程序员鱼皮

本文围绕 Agent Skills 展开,它是 Anthropic 推出的开放标准,可让 AI 学习专业技能。介绍了入门实战、内部原理、跨工具使用、创建方法,还对比了与 MCP、斜杠命令区别,因其开放复用且降低门槛而大火。

Agent Skills完全指南:核心概念丨设计模式丨实战代码@大模型真好玩

本文围绕 Agent Skills 展开,它是渐进式披露的提示词管理机制,能降低 Token 消耗。以 Claude Code 为例,介绍安装、使用及进阶方法。还对比了与 MCP 的区别,助开发者构建强大智能体。

📖 投稿专区

大家可以在评论区推荐认为不错的文章,并附上链接和推荐理由,有机会呈现在下一期。文章创建日期必须在下期掘金一周发布前一周以内;可以推荐自己的文章、也可以推荐他人的文章。

van-list切换检索条件时,防止拉第一页和触发触底检测请求竞争的问题

2026年2月5日 16:34

起因

使用van-list,想要滚动到底部触发加载新数据,但切换检索条件时,要先清空列表,再手动拉取第一页数据,但由于清空了列表会导致自动触发触底加载,请求后数据混乱

解决方案

  • 首页加载完毕之前,列表不展示,这样就不会触发触底加载
  • 首页加载完,finished状态也更新了,此时列表高度不够时会根据情况触发loadMoreData
  • immediate-check是true/false没区别,
    • 前提:拉取的limit要够2屏的数据
    • 若加载首页长度不能填满一屏,说明已经finished了
    • 如果能填满一屏,那么必定会滚动页面,此时会触发loadMoreData
  <van-list v-model="loading"
            :immediate-check="false"
            :finished="finished"
            finished-text="没有更多了"
            @load="loadMoreData"
            class="list-main"
            v-if="mgList?.length">
  </van-list>
  <div class="blank-placehold flex-row"
       v-else>
    暂无数据!
  </div>

速通-微信小程序 2Day

作者 慈様ya
2026年2月4日 14:32

速通-微信小程序 2Day

速通-微信小程序-CSDN博客 紧接前文,搭配有助于快速,巩固/学习!话不多说开始!

这一部分挺简单的,最起码对于做过前端Vue 开发,前后端的 me,so so easy!

WXML 模板语法

WXML(WeiXin Markup Language)是小程序的视图层模板语言

作用类似 HTML,但扩展了「数据绑定、逻辑渲染、事件绑定」等核心能力,

是小程序 数据驱动视图 核心能力:数据绑定、逻辑渲染(列表/条件)、事件绑定;

数据绑定:

Mustache 语法 ,把data中的数据绑定到页面中渲染:

WXML 通过双大括号 {{}}将 JS 逻辑层数据渲染到视图层,支持 内容绑定、属性绑定、表达式运算

  • {{}}内支持简单表达式(算术、三元、逻辑运算),但不支持复杂语句(如if/for循环)

  • 文本绑定中,{{}}会自动解析 \n 为换行符(对应之前text组件的换行场景)

  • ⚠️绑定的数据必须在页面 data 中定义,否则会显示为空;

内容绑定: <标签> {{ 绑定页面data中的变量,文本渲染 }} </标签>

属性绑定: 标签,属性值也用 {{}} 绑定,注意,属性名无需加引号(区别于 VUE 的v-bind

xxx.js

Page({
/** 页面的初始数据 */
    data: {
img: "https://i1.hdslb.com/bfs/face/6ac26f27a6d25da7865cab8e0806676c8f4cd62c.webp",
        username: "wsm",
        age: 25
    },
    /** 省略其他配置.... */
})

xxx.wxml

<view>
  <!-- 绑定页面data中的变量 -->
  <text>用户名:{{username +'\n'}}</text>
  <text>年龄:{{age + 1 +'\n'}}</text> <!-- 支持表达式运算 -->
  <text>成年:{{age >= 18 ? '是' : '否'}} \n</text> <!-- 三元运算 -->

  <!-- 动态绑定标签属性-data中的变量 -->
  <image src="{{img}}"  mode="widthFix"></image>
</view>

在这里插入图片描述

事件绑定:

小程序的事件绑定是视图层(WXML)与逻辑层(JS)通信的核心方式 也是实现用户交互的唯一入口;

由于小程序是双线程模型(渲染层 + 逻辑层),事件需通过微信客户端(Native)中转,

因此绑定规则、事件对象与普通网页的 DOM 事件有本质差异;

在这里插入图片描述

事件 中转通信

小程序的事件并非直接的 DOM 事件,而是通过 微信客户端作为中转桥接层

将视图层用户交互 点击、输入 转发到逻辑层 JS 函数处理,这种设计避免了直接 DOM 操作;

  • 核心限制 :禁止直接操作 DOM,所有交互必须通过 事件绑定 + 数据驱动 实现

  • 触发流程 :用户在视图层触发事件 → 微信客户端捕获事件并封装成事件对象

    → 转发到逻辑层对应的 JS 函数 → 逻辑层处理后通过 setData 更新视图;

事件绑定的两种核心方式:

小程序提供 bindcatch 两种绑定前缀,核心区别是是否允许事件冒泡

绑定方式 行为 适用场景
bind 绑定事件并允许冒泡(事件会向上传递给父元素) 需要父元素响应子元素事件的场景
如:事件委托)
catch 绑定事件并阻止冒泡(事件不会向上传递) 仅需当前元素响应事件的场景
如:按钮点击、表单提交)
<!-- 父元素绑定bindtap,子元素绑定bindtap -->
<view bindtap="parentTap" style="padding: 20rpx; background: #f0f0f0;">
    <button bindtap="childTap">点击子元素(允许冒泡)</button>
</view>

<!-- 父元素绑定bindtap,子元素绑定catchtap -->
<view bindtap="parentTap" 
style="padding: 20rpx; background: #f0f0f0; margin-top: 20rpx;">
    <button catchtap="childTap">点击子元素(阻止冒泡)</button>
</view>
/** 页面定义函数 */
parentTap() {
    console.log("父元素事件触发");
},
childTap() {
console.log("子元素事件触发");
}

在这里插入图片描述

  • 点击第一个按钮(bindtap):会先触发childTap,再触发parentTap(事件冒泡到父元素)

  • 点击第二个按钮(catchtap):仅触发childTap,不会触发parentTap(阻止冒泡)

事件对象(e):交互数据的 “载体”

事件触发时,逻辑层的 JS 函数会收到一个 事件对象e,包含事件的所有信息,核心属性如下:

属性 作用
e.type 事件类型(如 tapinput
区分不同事件类型,如同时绑定多种事件时判断触发源)
e.target 触发事件的源元素(实际点击的元素)
事件委托场景中,获取触发事件的具体子元素
e.currentTarget 绑定事件的当前元素(事件绑定的元素)
获取绑定事件的元素的自定义属性或统一处理父元素逻辑
e.detail 事件携带的额外信息(如表单输入值、滑动距离)
获取表单组件的输入内容(如 input 实时值)、滑动组件的位移量
e.dataset 元素的自定义属性(通过 data-* 定义)
事件传参的核心方式(如传递商品 ID、列表项索引)
<view bindtap="handleTap" data-id="parent">
  <button data-id="child">点击按钮</button>
</view>
handleTap(e) {
  console.log(e.target.dataset.id); // 输出:child(实际触发的元素是按钮)
  console.log(e.currentTarget.dataset.id); // 输出:parent(绑定事件的元素是view)
}

target 和 currentTarget 的区别

target:指向实际触发事件的元素(子元素)

currentTarget:指向绑定事件的元素(父元素)

在这里插入图片描述

小程序 常用事件

冒泡事件(支持bind/catch

事件名 触发场景 实战用途
tap 点击元素 (手指触摸后马上离开) 按钮点击、列表项跳转、提交操作
longpress 长按元素(触摸时间≥350ms) 弹出操作菜单、删除确认
touchstart 手指触摸开始 滑动交互、手势识别
touchend 手指触摸结束 滑动结束、手势确认
touchmove 手指触摸后移动 滑动拖拽、进度条控制

非冒泡事件(仅支持bindcatch无效)

事件名 触发场景 实战用途
submit 表单提交 提交表单数据到后端
scroll 页面 / 滚动容器滚动 监听滚动位置、实现上拉加载更多
change 表单组件值变化(如 switch、picker) 监听开关状态、选择器变化
input 输入框内容变化 实时获取用户输入(如搜索框、表单)

事件传参:data-* 自定义属性

小程序不支持直接在事件绑定中传参 如: bindtap="handleClick(123)"会报错

必须通过data-*自定义属性传递参数,再从事件对象的 e.currentTarget.dataset 中获取;

<!-- 事件传参: -->
<view>
<!-- 可以为组件提供data-*自定义属性传参,其中*代表的是参数的名字,示例代码如下: -->
    <button bindtap="handleTapparam" data-param1="123" data-param2="{{123}}" >事件传参 param1</button>
</view>
handleTapparam(e){
    //通过 event.target.dataset.参数名 即可获取到具体参数的值
    //通过dataset可以访问到具体参数的值
    console.log(e.target.dataset);            
    console.log(e.target.dataset.param1);     
    console.log(e.target.dataset.param2);
}

在这里插入图片描述表单事件的detail属性

实现文本框和 data 之间的数据同步

小程序文本框(input组件)与页面data数据双向同步

核心依托小程序 数据绑定 +input 组件 输入事件监听 🖥️🖥️🖥️

通过setData完成视图层(文本框)与逻辑层(data)的双向数据更新;

  • 文本框通过value="{{data中的变量}}"实现 data 到文本框的单向渲染(初始值回显);
  • 绑定input组件的输入事件(bindinput/bindblur),实时 / 按需获取文本框输入值;
  • 在事件处理函数中,通过this.setData({ 变量: 输入值 })完成 文本框到 data 的反向同步
  • 全程遵循小程序「数据驱动视图」原则, 禁止直接操作 DOM ,所有数据更新均通过setData实现;
  <input value="{{inpmas}}" bindinput="inpvalue"
  style="border: 1px solid black; margin: 5px; padding: 5px;" />
/** 页面的初始数据 */
data: {
    img: ".................",
inpmas: '兴趣爱好',
    username: "wsm",
age: 25,
},
//data 之间的数据同步
inpvalue(e){
//通过 e.detail.value 获取到文本框最新的值
this.setData({ inpmas: e.detail.value });
console.log(this.data.inpmas);
},

在这里插入图片描述

条件渲染:

WXML 的条件渲染是小程序根据逻辑层(JS)数据动态控制视图层元素显示 / 隐藏的核心能力,

完全遵循「数据驱动视图」原则,禁止直接操作 DOM 修改显示状态

wx:if

wx:if / wx:elif / wx:else(多分支条件,逻辑销毁 / 重建)

wx:if 是 WXML 原生支持的 多分支条件渲染语法 ,支持单条件、多条件分支、嵌套判断

底层通过 销毁 / 重建组件节点 实现显示 / 隐藏(条件不满足时节点从渲染树移除,满足时重新创建)

<view>
  <!-- 当isShow为true时显示,否则销毁该节点 -->
  <view wx:if="{{isShow}}">仅满足条件时显示</view>

  <!-- 多分支按顺序匹配,仅执行第一个满足条件的分支 -->
  <view wx:if="{{status === 0}}">待支付</view>
  <view wx:elif="{{status === 1}}">已支付/待发货</view>
  <view wx:elif="{{status === 2}}">已发货/待收货</view>
  <view wx:else>已完成/已取消</view>

  <!-- block包裹多节点,一次控制所有元素的显示/隐藏 -->
  <block wx:if="{{hasData}}">
    <view>商品名称:{{goods.name}}</view>
    <view>商品价格:{{goods.price}}元</view>
    <button>立即购买</button>
  </block>
  <!-- 空状态提示 -->
  <view wx:else>暂无商品数据</view>
</view>
Page({
  /** 页面的初始数据 */
  data: {
    status: 3,
    isShow: false,
    hasData: false,
    goods: { name:"AAA", price:12.00 },
  },
})

在这里插入图片描述

<block> 包裹多节点(不生成冗余 DOM)

若需要 同时控制多个元素的条件显示,直接给每个元素加wx:if会冗余,

可使用<block>标签包裹 ——<block>是 WXML 的无渲染容器

仅用于包裹节点,不生成实际 DOM 元素,不影响页面布局;

wx:hidden

hidden 是 WXML 元素的通用属性,仅支持单分支条件判断(隐藏 / 显示)

底层通过CSS 的 display: none 实现隐藏 —— 元素节点始终存在于渲染树中

仅通过样式控制不可见,适合 单条件、高频切换 的场景(如弹窗、下拉菜单、开关控制的内容

<view>
  <!-- 方式1:直接写布尔值(静态,无动态切换需求) -->
  <view hidden="{{true}}">静态隐藏的内容</view>
  <!-- 方式2:绑定data中的布尔变量(推荐,支持动态切换) -->
  <view hidden="{{isHidden}}">高频切换的内容(如弹窗)</view>
  <!-- 方式3:结合简单表达式(无需额外定义data变量) -->
  <view hidden="{{list.length === 0}}">列表有数据时显示</view>
</view>
Page({
  /** 页面的初始数据 */
  data: {
    // 控制hidden的核心变量
    isHidden: false, 
    list: [1,2]
  },
})

在这里插入图片描述

if 🆚 hidden

两者的核心差异在于 底层实现方式,直接决定了 切换性能开销适用场景

运行方式不同:

  • wx:if 以动态创建和移除元素的方式,控制元素的展示与隐藏
  • hidden 以切换样式的方式(display: none/block;),控制元素的显示与隐藏

使用建议: 频繁切换时,建议使用 hidden,控制条件复杂时,建议使用 wx:if、wx:elif、wx:else

wx:for 列表渲染

wx:for 是 WXML 原生核心的列表渲染指令,用于将数组 / 对象中的数据循环渲染为页面节点;

将逻辑层(JS)data中的数组 / 对象,在视图层(WXML)中循环生成相同结构的节点,

实现数据与视图的联动渲染,无需手动操作 DOM;

直接给元素绑定wx:for="{{数组名}}",即可循环渲染该元素;

小程序自动提供 2 个默认变量,无需手动定义:

  • item:当前循环的 数组项 每一个元素
  • index:当前循环的 索引 从 0 开始

wx:key是必填项 无合法wx:key会触发控制台性能警告,且列表重排时易出现渲染错误

<view>
  <!-- wx:for绑定数组,wx:key绑定唯一标识 -->
  <view wx:for="{{goodsList}}" wx:key="id" class="goods-item">
    <text>第{{index+1}}个商品:</text>
    <text>名称:{{item.name}},价格:{{item.price}}元</text>
  </view>
</view>
Page({
  /** 页面的初始数据 */
  data: {
    // 模拟后端返回的商品列表,id为唯一标识
    goodsList: [
      { id: 1, name: "小程序开发实战", price: 59 },
      { id: 2, name: "Java后端进阶", price: 79 },
      { id: 3, name: "全栈开发指南", price: 99 }
    ]
  },
})

在这里插入图片描述

WXSS 模板样式

WXSS(WeiXin Style Sheet)是小程序的专属样式语言,

类似于CSS样式,并,在 CSS 基础上扩展了 rpx 响应式尺寸单位 解决移动端多设备适配复用问题;

rpx 尺寸单位

什么是 rpx 尺寸单位

rpx(responsive pixel)是小程序独有的响应式尺寸单位

用于统一不同屏幕宽度设备的尺寸显示,替代 CSS 中的固定单位 px,无需手动计算适配比例;

实现原理: 小程序将所有设备的屏幕宽度统一映射为 750rpx 750rpx = 设备实际屏幕宽度

框架会根据设备屏幕宽度自动换算 rpx 对应的物理像素:

  • 在 iPhone X(屏幕宽度 375px)上:换算比例与 iPhone 6 一致
  • 在 iPhone 6(屏幕宽度 375px)上:750rpx = 375px1rpx = 0.5px
  • 在安卓设备(如屏幕宽度 414px)上:750rpx = 414px1rpx ≈ 0.552px

设计稿适配:主流设计稿宽度为 750px,设计稿上的 1px 可直接对应小程序的 1rpx

官方建议:开发微信小程序时,设计师可以用 iPhone6 作为视觉稿的标准

@import 样式导入

用于导入外部 WXSS 样式文件,实现 公共样式复用

如:全局主题、组件样式、工具类),避免重复编写样式,提升代码可维护性;

支持 相对路径 和 绝对路径 ,必须用双引号包裹路径,结尾加分号,可导入多个文件,按顺序合并样式;

在这里插入图片描述

app.json 全局配置

小程序根目录下的 app.json 文件是小程序的 全局配置文件 常用的配置项如下:

  • pages: 记录当前小程序所有页面的存放路径,必选,小程序启动的基础
  • tabBar: 设置小程序底部的 tabBar 效果
  • window: 全局设置小程序窗口的外观
  • style: 是否启用新版的组件样式

配置 window

属性名 默认值 说明
navigationBarTitleText 字符串 导航栏标题文字内容
navigationBarBackgroundColor #000000 导航栏背景颜色,如 #000000
navigationBarTextStyle white 导航栏标题颜色,仅支持 black/white
backgroundColor #ffffff 窗口的背景色
backgroundTextStyle dark 下拉 loading 的样式,仅支持 dark/light
enablePullDownRefresh false 是否全局开启下拉刷新
onReachBottomDistance 50 页面上拉触底事件触发,距页面底部距离,单位为px
navigationStyle default custom 为自定义导航栏,适合沉浸式页面

配置 tabBar

tabBar 是小程序的 全局底部导航组件,用于在多个核心页面(如首页、我的、订单)之间快速切换;

是多页面小程序的标配。它完全在 app.json 中配置,无需编写额外代码,以下完整配置指南🧭:

小程序中通常将其分为: 底部 tabBar顶部 tabBar

  • tabBar中只能:配置最少 2 个、最多 5 个 tab 页签

  • 渲染 顶部tabBar 时,不显示 icon,只显示文本

tabBarapp.json 的顶级配置项,分为必填项和可选项

配置项 必填 / 取值 / 说明
color 是,tab 未选中时的文字颜色(十六进制色值,如 #666666
selectedColor 是,tab 选中时的文字颜色(需与 color 区分,如品牌色 #ff4444
backgroundColor 是,tabBar 的背景色(十六进制色值,如 #ffffff
borderStyle 否,tabBar 上边框的样式,仅支持 black/white,默认 black
position 否,tabBar 的位置,默认 bottom(底部),可选 top(顶部)
⚠️ 注意:position: "top" 时,不显示 icon,仅显示文字
custom 否,是否使用自定义 tabBar(默认 false
开启后需在根目录创建 custom-tab-bar 自定义组件,适合复杂交互;
list 是,tab 项的数组,最少 2 个,最多 5 个,每个元素为一个 tab 配置对象;

每个 tab 项的配置(list 数组元素)

配置项 必填 / 取值 / 说明
pagePath 是,点击 tab 跳转的页面路径,
必须是 pages 数组中已注册的页面pages/index/index
text 是,tab 上显示的文字,如 首页 我的
iconPath 否,tab 未选中时的图标路径,建议用 81px:81px 的 2x 图,避免模糊
selectedIconPath 否,tab 选中时的图标路径,需与 iconPath 尺寸一致

以下是 <常见小程序> 的典型 tabBar 配置: 首页 / 消息 / 联系我们

{
  "pages": [
    "pages/home/home",
    "pages/contact/contact",
    "pages/message/message"
  ],
  "window": {
    "navigationBarTextStyle": "black",
    "navigationBarTitleText": "我的小程序",
    "navigationBarBackgroundColor": "#FFFFFF"
  },
  "style": "v2",
  "lazyCodeLoading": "requiredComponents",
  "componentFramework": "glass-easel",
  "sitemapLocation": "sitemap.json",
  
  "tabBar": {
    "color": "#666666",
    "selectedColor": "#ff4444",
    "backgroundColor": "#ffffff",
    "borderStyle": "black",
    "list": [
      {
        "text": "首页",
        "pagePath": "pages/home/home",
        "iconPath": "images/tab/home.png",
        "selectedIconPath": "images/tab/home-active.png"
      },
      {
        "text": "消息",
        "pagePath": "pages/message/message",
        "iconPath": "images/tab/message.png",
        "selectedIconPath": "images/tab/message-active.png"
      },
      {
        "text": "联系我们",
        "pagePath": "pages/contact/contact",
        "iconPath": "images/tab/contact.png",
        "selectedIconPath": "images/tab/contact-active.png"
      }
    ]
  }
}
  • ⚠️ tabBar —— list 最少两个最多5个,且必须在 Page中存在!!!

在这里插入图片描述

xxx.json 局部配置

小程序中,每个页面都有自己的 .json 配置文件,用来对当前页面的窗口外观、页面效果等进行配置

小程序中,app.json 中的 window 节点,可以全局配置小程序中每个页面的窗口表现;

  • 如果,某些小程序页面想要拥有特殊的窗口表现,

  • 此时,页面级别的 .json 就可以实现这种需求

根据就近原则,最终的效果以页面配置为准

网络数据请求

小程序中网络数据请求的限制:

为保障用户数据安全,小程序的网络请求有以下强制规则:

域名白名单

  • 上线请求的域名必须在,微信公众平台 开发管理→开发设置→服务器域名

    中配置白名单,仅支持 HTTPS 协议,域名必须经过 ICP 备案,可在小程序:项目配置中查看!

    在这里插入图片描述

  • 开发阶段可在开发者工具中开启 不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书

    豁免;上线前必须完成域名备案并配置白名单; 详情——本地配置——不校验合法域名

    在这里插入图片描述

    仅在开发阶段使用,上线必须合法域名HTTPS!!

协议要求

  • 上线: 仅支持 HTTPS/WSS 协议,禁止使用 HTTP
  • 开发阶段: 可暂时豁免,上线前必须切换为 HTTPS 域名;

请求频率

  • 单个小程序的并发请求数限制为 10 个,高频请求需做节流/合并
  • 避免短时间内发起大量请求,可通过防抖 / 合并请求优化

跨域限制

  • 仅需配置微信域名白名单,无需后端设置 CORS

  • 无需配置 CORS(由微信客户端中转请求,无浏览器同源限制)

相关测试接口:

GET: https://applet-base-api-t.itheima.net/slides 获取轮播图信息;

POST: https://applet-base-api-t.itheima.net/api/post 上传用户信息;

在这里插入图片描述

发生 GET 请求

home.wxml

<!-- 轮播图容器:适配小程序轮播组件 -->
<swiper  indicator-dots  autoplay interval="3000"  
circular  indicator-active-color="#ff4444" class="banner-swiper">
  <!-- 循环渲染轮播图项 -->
  <swiper-item wx:for="{{slides}}" wx:key="id">
    <image  src="{{item.image}}"  style="height: 100%; width: 100%;"  />
  </swiper-item>
</swiper>

home.js

Page({
  /*** 页面的初始数据 */
  data: {
    slides: [] // 轮播图数据列表,初始为空
  },
  /** 生命周期函数--监听页面加载 */
  onLoad(options) {
    // 调用GET请求渲染页面轮播图~
    this.getSlides();
  },
  //GET请求获取页面轮播图信息
  getSlides() {
    // 显示加载中提示,提升用户体验
    wx.showLoading({title: '加载中...', mask: true});
    wx.request({
      url: 'https://applet-base-api-t.itheima.net/slides',
      method: 'GET',
      success: (res) => {
        // 接口请求成功:判断状态码,更新数据
        if (res.statusCode === 200 && res.data) {
          this.setData({slides: res.data });
        }else{
           // 接口返回异常,提示用户
           wx.showToast({ title: '轮播图数据加载失败', icon: 'none',duration: 2000 })
        }
      },
      fail: (err) => {
        console.error('轮播图请求失败:', err);
        wx.showToast({ title: '轮播图数据加载失败', icon: 'none',duration: 2000 })
      },
      // 无论成功/失败,都关闭加载提示
      complete: () => {
        wx.hideLoading();
      }
    })
  }
})

在这里插入图片描述

发送 POST 请求

<!-- 分割线 -->
<view class="divider">\n</view>

<!-- 用户信息输入区域(新增) -->
<view>
  <view>
    <text>姓名:</text>
    <input type="text" placeholder="请输入姓名" 
value="{{name}}" bindinput="syncInput" data-key="name"/>
  </view>
  <view class="form-item">
    <text class="label">性别:</text>
    <input type="text" placeholder="请输入性别 男/女" 
value="{{gender}}" bindinput="syncInput" data-key="gender" />
  </view>
  <!-- 提交按钮 -->
  <button bindtap="postUserInfo" class="submit-btn">提交用户信息</button>
</view>

<!-- POST请求结果展示区域(新增) -->
<view wx:if="{{postResult}}">
  <text>提交结果:</text>
  <text>{{postResult}}</text>
</view>
Page({
  /*** 页面的初始数据 */
  data: {
    slides: [], // 轮播图数据列表,初始为空
    name: '',
    gender: '',
    postResult: ''
  },
  /** 生命周期函数--监听页面加载 */
  onLoad(options) {   },
  //GET请求页面轮播图信息
  getSlides() {  },

  // 新增:同步输入框数据到data(实时绑定)
  // 通过data-key区分是name还是gender输入框
  syncInput(e) {
    const key = e.currentTarget.dataset.key;
    this.setData({[key]: e.detail.value });
  },
  // 新增:POST请求提交用户信息
  postUserInfo() {
    // 非空校验
    const { name, gender } = this.data;
    if (!name || !gender) {
      wx.showToast({ title: '请完善姓名和性别', icon: 'none' });
      return;
    }
    // 显示加载提示
    wx.showLoading({ title: '提交中...', mask: true });
    // 发起POST请求
    wx.request({
      url: 'https://applet-base-api-t.itheima.net/api/post',
      method: 'POST',
      data: { name, gender },
      header: { 'content-type': 'application/json' },
      success: (res) => {
        // 将返回的JSON转为字符串,方便页面展示
        if (res.statusCode === 200 && res.data) {
          this.setData({postResult: JSON.stringify(res.data, null, 2)})
          wx.showToast({ title: '提交成功', icon: 'success' });
        }else {
          wx.showToast({ title: '提交失败', icon: 'none' });
        }
      },
      fail: (err) => {
        console.error('POST请求失败:', err);
        wx.showToast({ title: '网络异常,请稍后重试', icon: 'none' });
      },
      complete: () => { wx.hideLoading(); },
    })
  }
})

在这里插入图片描述

小程序网络请求进阶

小程序的网络请求能力远不止 GET/POST

完全支持 RESTful 风格的 PUT/DELETE 等方法,同时提供了专门的 API 处理表单和文件传输;

相关文档:

黑马—小程序简介_哔哩哔哩_bilibili 对应:Day2 天内容!

  • blog 中涉及接口案例,可能会失效,可以到官方评论区,会定期更新最新域名!

踩坑记录:iOS Safari 软键盘下的"幽灵弹窗"问题

作者 RemHusband
2026年2月4日 12:59

最近在做移动端 H5 登录页面时,遇到了一个诡异的 bug:在 iOS Safari 中,弹窗明明显示在屏幕中央,点击按钮却毫无反应。检查元素后发现,DOM 的实际位置和视觉位置完全对不上。折腾了一番后终于搞清楚了原因,记录一下。

问题现象

我们的登录模块有两个页面:账号密码登录和验证码登录,可以互相切换。两个页面都有一个协议确认弹窗,使用 position: fixed 定位。

诡异的事情发生了:

  1. 在账号密码页面输入手机号(软键盘弹出),触发弹窗,点击取消 —— 正常
  2. 切换到验证码页面,触发弹窗,点击取消 —— 正常
  3. 再切换回账号密码页面,触发弹窗,点击取消 —— 没反应!

打开 Safari 调试器一看,弹窗的 DOM 位置和屏幕上显示的位置差了好几百像素。点击事件实际触发在了空白区域。

这就是传说中的"幽灵弹窗"。


前置知识:理解移动端视口

要搞清楚这个问题,得先理解移动端浏览器的视口概念。这部分内容稍微有点绕,但理解了之后很多移动端的坑就都能解释了。

三种视口

移动端浏览器有三种视口:

1. 布局视口(Layout Viewport)

布局视口是 CSS 布局的基准。当你写 width: 100% 时,这个 100% 就是相对于布局视口的宽度。

在没有 <meta name="viewport"> 标签的情况下,移动端浏览器的布局视口默认是 980px(不同浏览器可能略有差异)。这就是为什么早期的桌面网页在手机上看起来特别小 —— 浏览器把 980px 的内容硬塞进了 320px 的屏幕。

<!-- 这行代码让布局视口等于设备宽度 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0">

2. 视觉视口(Visual Viewport)

视觉视口是用户当前能看到的区域。当用户双指缩放页面时,布局视口不变,但视觉视口会变小(放大时)或变大(缩小时)。

可以这样理解:布局视口是一张大地图,视觉视口是你手里的放大镜。放大镜移动或缩放,地图本身不会变。

3. 理想视口(Ideal Viewport)

理想视口是设备屏幕的实际尺寸。width=device-width 就是让布局视口等于理想视口。

用代码获取视口信息

// 布局视口
const layoutWidth = document.documentElement.clientWidth;
const layoutHeight = document.documentElement.clientHeight;

// 视觉视口(需要 Visual Viewport API)
const visualWidth = window.visualViewport?.width;
const visualHeight = window.visualViewport?.height;
const offsetTop = window.visualViewport?.offsetTop; // 视觉视口相对于布局视口的偏移

// 屏幕尺寸
const screenWidth = window.screen.width;
const screenHeight = window.screen.height;

视口与 position: fixed 的关系

这里是关键点:position: fixed 的元素是相对于视觉视口定位的,但它的点击区域是根据布局视口计算的

正常情况下,视觉视口和布局视口是重合的,所以没问题。但当软键盘弹出时,iOS Safari 会改变视觉视口而不改变布局视口,这就导致了视觉位置和点击区域的错位。


浏览器渲染原理:为什么会"错位"

要理解"幽灵弹窗",还需要了解浏览器是怎么渲染页面的。

渲染流水线

浏览器渲染一个页面大致经过以下步骤:

HTML → DOM Tree
              ↘
                → Render Tree → Layout → Paint → Composite
              ↗
CSS  → CSSOM
  1. 解析 HTML:构建 DOM 树
  2. 解析 CSS:构建 CSSOM(CSS Object Model)
  3. 合并:DOM + CSSOM = Render Tree(渲染树)
  4. 布局(Layout/Reflow):计算每个元素的位置和大小
  5. 绘制(Paint):把元素画到屏幕上
  6. 合成(Composite):把不同图层合并

重排(Reflow)与重绘(Repaint)

重排(Reflow)

当元素的几何属性(位置、大小)发生变化时,浏览器需要重新计算布局,这就是重排。

触发重排的操作:

  • 改变窗口大小
  • 改变字体大小
  • 添加/删除 DOM 元素
  • 改变元素的 widthheightmarginpadding
  • 读取某些属性:offsetTopscrollTopclientTopgetComputedStyle()

重绘(Repaint)

当元素的外观(颜色、背景、阴影等)发生变化,但几何属性不变时,只需要重绘,不需要重排。

触发重绘的操作:

  • 改变 colorbackgroundvisibility

性能影响

重排的代价比重绘大得多,因为重排会导致整个渲染树的重新计算。这就是为什么我们要尽量避免频繁触发重排。

图层(Layer)与合成

现代浏览器会把页面分成多个图层,每个图层独立渲染,最后合成到一起。某些 CSS 属性会触发元素提升为独立图层:

  • transform
  • opacity
  • will-change
  • position: fixed(在某些浏览器中)

独立图层的好处是:当这个元素变化时,不会影响其他图层,只需要重新合成,不需要重排整个页面。

这就是为什么我们在解决方案中使用 transform: translate3d(0, 0, 0) —— 它强制创建一个独立的合成层,让弹窗的渲染与页面其他部分隔离。


问题根因深度分析

现在我们有了足够的背景知识,来深入分析"幽灵弹窗"的成因。

iOS Safari 的"独特"处理方式

当软键盘弹出时,不同平台的处理方式不同:

Android Chrome 的做法:

软键盘弹出前:
┌─────────────────────┐
│                     │
│   Layout Viewport   │ = Visual Viewport
│   (100vh)           │
│                     │
└─────────────────────┘

软键盘弹出后:
┌─────────────────────┐
│   Layout Viewport   │ = Visual Viewport(缩小了)
│   (变小了)           │
├─────────────────────┤
│      软键盘          │
└─────────────────────┘

Android 的做法是缩小布局视口,position: fixed 的元素会相对于新的视口重新定位,一切正常。

iOS Safari 的做法:

软键盘弹出前:
┌─────────────────────┐
│                     │
│   Layout Viewport   │ = Visual Viewport
│   (100vh)           │
│                     │
└─────────────────────┘

软键盘弹出后:
┌─────────────────────┐ ← Layout Viewport(不变)
│   ↑                 │
│   │ 页面被推上去     │
│   ↓                 │
├─────────────────────┤ ← Visual Viewport(变小了)
│      软键盘          │
└─────────────────────┘

iOS 保持布局视口不变,只是把页面内容往上"推"。这时候:

  • position: fixed 的元素根据布局视口计算位置
  • 但用户看到的是视觉视口
  • 两者不一致,就出现了"幽灵"现象

为什么多次切换后才出问题?

每次软键盘弹出,window.scrollY 都会产生变化。当用户在多个页面之间切换时:

  1. 页面 A:软键盘弹出,scrollY = 200
  2. 切换到页面 B:scrollY 可能没有完全重置
  3. 页面 B:软键盘弹出,scrollY 累加
  4. 切换回页面 A:累积的偏移量导致计算错误

Vue Router 的 router.replace() 不会触发完整的页面刷新,滚动状态会残留。

为什么 iOS Chrome 也有问题?

这里要提一个很多人不知道的事实:iOS 上所有浏览器都是 Safari 套壳

Apple 的 App Store 政策要求:iOS 上的所有浏览器必须使用 WebKit 引擎。所以:

浏览器 Android 引擎 iOS 引擎
Chrome Blink WebKit
Firefox Gecko WebKit
Edge Blink WebKit
Safari - WebKit

iOS 上的 Chrome 只是换了个 UI 的 Safari,底层渲染引擎完全一样。这个问题在 iOS 上是无差别攻击。


解决方案

核心思路

弹窗显示时,把页面"冻住",让布局视口和视觉视口强制保持一致。

let savedScrollY = 0;
let savedBodyStyle = '';

// 锁定
const lockBodyScroll = () => {
  // 兼容性写法,确保能获取到滚动位置
  savedScrollY = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0;
  savedBodyStyle = document.body.style.cssText;

  document.body.style.cssText = `
    ${savedBodyStyle};
    position: fixed;
    top: -${savedScrollY}px;
    left: 0;
    right: 0;
    width: 100%;
    overflow: hidden;
  `;
};

// 解锁
const unlockBodyScroll = () => {
  document.body.style.cssText = savedBodyStyle;
  window.scrollTo(0, savedScrollY);
};

原理解析:

  1. position: fixed 让 body 脱离文档流,不再滚动
  2. top: -${savedScrollY}px 用负值补偿当前滚动位置,保持视觉一致
  3. overflow: hidden 禁止滚动
  4. 关闭弹窗时恢复原始样式,并用 scrollTo 恢复滚动位置

Vue 组件中的使用

import { watch, toRefs, nextTick } from 'vue';

const props = defineProps<{ show: boolean }>();
const { show } = toRefs(props);

watch(show, (val) => {
  if (val) {
    nextTick(() => {
      lockBodyScroll();
    });
  } else {
    unlockBodyScroll();
  }
}, { immediate: true });

// 组件卸载时确保解锁,防止状态残留
onBeforeUnmount(() => {
  if (show.value) {
    unlockBodyScroll();
  }
});

CSS 优化

.dialog-overlay {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;

  // 动态视口高度,iOS 15+ 支持
  // 会随软键盘弹出自动调整
  min-height: 100vh;
  min-height: 100dvh;

  // 强制创建独立合成层
  // 让弹窗的渲染与页面隔离
  transform: translate3d(0, 0, 0);
  -webkit-transform: translate3d(0, 0, 0);

  // 禁用触摸滚动手势
  touch-action: none;
  -webkit-touch-callout: none;

  // 防止滚动穿透到父元素
  overscroll-behavior: contain;
}

CSS 属性解析:

属性 作用
100dvh 动态视口高度,会随软键盘变化
transform: translate3d(0,0,0) 触发 GPU 加速,创建独立图层
touch-action: none 禁用所有触摸手势
overscroll-behavior: contain 滚动到边界时不会触发父元素滚动

踩过的坑

坑 1:忘记在组件卸载时解锁

如果用户在弹窗打开状态下切换页面(比如点击了弹窗里的链接),bodyfixed 样式会残留,导致新页面无法滚动。

解决: 一定要在 onBeforeUnmount 里清理。

坑 2:滚动位置恢复不准确

window.scrollY 在某些老旧浏览器或特殊情况下可能是 undefined

解决: 使用兼容性写法:

const scrollY = window.scrollY || window.pageYOffset || document.documentElement.scrollTop || 0;

坑 3:100vh 在 iOS 上不靠谱

iOS Safari 的 100vh 是一个"理想值",包含了地址栏的高度。当地址栏收起或软键盘弹出时,实际可视区域会变化,但 100vh 不变。

解决: 使用 100dvh(动态视口高度),但要注意兼容性:

  • iOS 15.4+
  • Chrome 108+
  • Firefox 101+

对于不支持的浏览器,可以用 JS 动态计算:

const setVh = () => {
  const vh = window.innerHeight * 0.01;
  document.documentElement.style.setProperty('--vh', `${vh}px`);
};
window.addEventListener('resize', setVh);

坑 4:多个弹窗嵌套

如果页面上有多个弹窗组件,每个都调用 lockBodyScroll,会互相覆盖 savedScrollY

解决: 使用引用计数:

let lockCount = 0;
let savedScrollY = 0;
let savedBodyStyle = '';

const lockBodyScroll = () => {
  if (lockCount === 0) {
    savedScrollY = window.scrollY || 0;
    savedBodyStyle = document.body.style.cssText;
    document.body.style.cssText = `...`;
  }
  lockCount++;
};

const unlockBodyScroll = () => {
  lockCount--;
  if (lockCount === 0) {
    document.body.style.cssText = savedBodyStyle;
    window.scrollTo(0, savedScrollY);
  }
};

延伸:其他解决方案

方案二:使用 Visual Viewport API

const adjustDialogPosition = () => {
  if (window.visualViewport) {
    const offsetY = window.innerHeight - window.visualViewport.height;
    dialogRef.value.style.transform = `translateY(-${offsetY / 2}px)`;
  }
};

onMounted(() => {
  window.visualViewport?.addEventListener('resize', adjustDialogPosition);
});

onBeforeUnmount(() => {
  window.visualViewport?.removeEventListener('resize', adjustDialogPosition);
});

优点: 不需要锁定 body,更"优雅" 缺点: 需要持续监听,有性能开销;弹窗位置会跳动

方案三:软键盘收起后再显示弹窗

const showDialog = () => {
  // 先让输入框失焦,收起软键盘
  (document.activeElement as HTMLElement)?.blur();

  // 等待软键盘收起动画完成
  setTimeout(() => {
    dialogVisible.value = true;
  }, 300);
};

优点: 简单粗暴,绕过问题 缺点: 用户体验不好,有明显延迟


总结

这个问题本质上是 iOS WebKit 的 viewport 处理机制position: fixed 定位 的冲突。

关键点:

  1. iOS Safari 软键盘弹出时只改变 Visual Viewport,不改变 Layout Viewport
  2. position: fixed 根据 Layout Viewport 计算位置,但渲染在 Visual Viewport
  3. 页面切换时滚动状态累积,加剧了偏移

解决方案的核心就一句话:弹窗显示时锁定 body 滚动,关闭时恢复

这不是 bug,是 Apple 的"设计选择"。作为开发者,我们只能见招拆招。

希望这篇文章能帮到遇到同样问题的朋友,少走点弯路。


参考资料:

当 JS 阻塞主线程时,为什么你的 CSS 动画还在跑?

作者 ssshooter
2026年2月4日 11:45

—— 从一个 React Demo 看浏览器渲染与合成线程

传送门:tools.mind-elixir.com/zh/learn-js…

动画阻塞实验.png

在前端开发中,我们常听到一句话:“不要阻塞主线程(Main Thread)”。我们也常听到另一条性能优化建议:“尽量使用 transformopacity 做动画”。

这两者之间有什么深层联系?为什么当一段死循环 JavaScript 代码把页面卡死时,某些动画却能像“幸存者”一样流畅运行,而其他的则瞬间冻结?

今天,我们通过一个具体的 React Demo 来通过实验揭开浏览器渲染机制的面纱。

实验现场:一个“卡顿”的按钮

我们构建了一个简单的 React 应用。核心逻辑在于一个名为“Block Main Thread”的按钮。点击它,会触发一段耗时 3 秒的同步 while 循环:

// 模拟主线程阻塞
const handleBlock = () => {
  setIsBlocking(true);
  setTimeout(() => {
    const start = Date.now();
    const duration = 3000; 
    // 💀 死循环:彻底霸占主线程 3000ms
    while (Date.now() - start < duration) {
      // Blocking main thread
    }
    setIsBlocking(false);
  }, 100);
};

在这 3 秒内,浏览器的主线程(Main Thread)被完全挂起。它无法响应点击、无法滚动页面、无法执行其他的 JavaScript 代码。

但在页面上,我们放置了四个正在运行的动画方块(Case A, B, C, D)。当我们点击阻塞按钮时,奇怪的现象发生了:

  1. 方块 C(JS 驱动):立即停止。
  2. 方块 D(Margin 动画):立即停止。
  3. 方块 B(Transform + Width):立即停止(或变得极度卡顿)。
  4. 方块 A(纯 Transform)居然还在流畅旋转!

为什么方块 A 能突破 JS 的封锁?

必须了解的两位主角:主线程 vs. 合成线程

要理解这个现象,我们需要知道浏览器内部也是“多线程”工作的。其中最重要的两个线程是:

  1. 主线程 (Main Thread)
  • 它是最忙碌的。负责运行 JavaScript、计算 HTML 元素的样式(Style)、计算页面布局(Layout)、绘制图层内容(Paint)。
  • 它是“单线程”的。如果你写了一个死循环,它就没空去算布局,也没空绘图。
  1. 合成线程 (Compositor Thread)
  • 它是主线程的助手,通常由 GPU 协助。
  • 它的工作是将主线程画好的各个图层(Layers)接收过来,进行合成(Composite),并最终显示在屏幕上。
  • 重点:它可以独立于主线程处理一些特定的视觉变换,比如位移(Translate)、旋转(Rotate)、缩放(Scale)和透明度(Opacity)

案件分析

让我们结合代码逐一分析四个方块的命运。

受害者一:Case C (JavaScript 驱动)

// JS 驱动的动画逻辑
const animateJS = (time: number) => {
  // ...计算位置 x ...
  if (jsBoxRef.current) {
    jsBoxRef.current.style.transform = `translateX(${x}px)`;
  }
  requestRef.current = requestAnimationFrame(animateJS);
};

死因:依赖主线程。 requestAnimationFrame 是请求浏览器在下一次重绘前调用指定的回调函数。这个回调函数是在主线程上执行的。当主线程被 while 循环卡死时,animateJS 函数根本得不到执行机会。所以动画瞬间冻结。

受害者二:Case D (Layout 属性 - Margin)

/* 改变 margin-left */
@keyframes pure-margin {
  0% { margin-left: 0; }
  50% { margin-left: 150px; } /* 触发 Layout */
  100% { margin-left: 0; }
}

死因:触发重排(Reflow/Layout)。 margin 属性决定了元素在文档流中的位置。修改它会影响周围元素的排版。浏览器必须重新计算布局(Layout 阶段),这个计算过程必须在主线程完成。主线程忙着跑死循环,没空算布局,所以动画停止。

受害者三:Case B (混合属性 - Transform + Width)

@keyframes mixed-transform {
  0% { transform: rotate(0deg); width: 48px; }
  50% { transform: rotate(180deg); width: 120px; } /* Width 触发 Layout */
  100% { transform: rotate(360deg); width: 48px; }
}

死因:被队友拖累。 虽然它包含 transform(由合成线程处理),但它同时也改变了 widthwidth 的改变会触发 Layout。浏览器通常需要每一帧都确认 Layout 的结果,才能进行后续的绘制和合成。因为 width 的计算被主线程阻塞了,整个动画帧的提交被延后,导致即使是旋转部分也被迫停止。

幸存者:Case A (纯 Transform)

/* 仅改变 transform */
@keyframes pure-transform {
  from { transform: rotate(0deg); }
  to { transform: rotate(360deg); }
}

生还原因脱离主线程(Off Main Thread)。 这是性能优化的核心魔法。

  1. 当浏览器分析这个 CSS 动画时,它发现这个动画涉及 transform
  2. transform 不会改变元素的布局(Layout),也不需要重新绘制内容(Paint),它只需要把现有的图层拿来旋转一下。
  3. 主线程会将这个动画任务及其图层信息直接提交(Commit)给合成线程
  4. 合成线程说:“收到,剩下的交给我。”

此时,无论主线程是在跑死循环,还是在进行复杂的计算,合成线程都在独立工作。它不断地指示 GPU 旋转那个图层,并更新屏幕。

这就是为什么即便你的 JS 卡死了,Loading 转圈动画(如果写得对)依然在转。

总结与技术启示

通过这个 Demo,我们可以得出明确的性能优化原则:

  1. 避免主线程阻塞
  • 长耗时的计算任务(如大数组处理、复杂算法)不应在 UI 渲染路径(React Render, Event Handlers)中同步执行。
  • 考虑使用 Web Worker 将计算任务移出主线程。
  • 利用 React Concurrent ModeTime Slicing 切分任务。
  1. 动画性能黄金法则
  • **坚持使用 transformopacity**。只有这两个属性可以保证 100% 在合成线程运行,完全不受 JS 阻塞影响。
  • 避免动画化布局属性(如 width, height, margin, padding, left, top)。这些属性不仅开销大(触发 Layout),而且一旦主线程卡顿,动画就会掉帧。
  • 警惕“混合污染”:不要在一个 @keyframes 中同时包含合成属性(transform)和布局属性(width),否则高性能的属性也会被拖累。

下一步行动

检查你项目中的 Loading 组件或过渡动画。确保它们是基于 CSS transform 实现的,而不是 JavaScript 或 margin。这样,即使你的后端接口返回慢,或者前端数据处理卡顿,用户依然能看到一个丝般顺滑的加载指示器,从而减少“应用已崩溃”的错觉。

一次 WebGPU 流体之旅

2026年2月4日 11:42

原文:Particles, Progress, and Perseverance: A Journey into WebGPU Fluids

作者:Hector Arellano

日期:2025年1月29日

翻译:TUARAN

欢迎关注 前端周刊,每周更新国外论坛的前端热门文章,紧跟时事,掌握前端技术动态。

image.png

本文是一段回顾式的长旅程:作者用十多年的时间不断尝试“浏览器里做流体”,从 WebGL 时代的各种 Hack,一路走到 WebGPU 让许多“现代图形 API 能力”变得可用。

编者按:如果你关注过 Web 图形圈子,可能知道 Hector Arellano(又名 Hat)。这篇文章不仅是技术拆解,更是一段关于坚持、试错、与 Web 图形演进的故事。

注意:Demo 依赖 WebGPU,并非所有浏览器都支持。请使用支持 WebGPU 的浏览器(例如最新版 Chrome / Edge,并确保 WebGPU 已启用)。

在继续阅读之前……先去拿杯喝的——这篇很长

13 年前……

我正盯着电脑屏幕发呆(无聊得很),一个很要好的朋友 Felix 打电话给我,非常认真又兴奋地说:Gathering Party 刚发布了一个新 Demo。它有流体模拟、粒子动画、惊艳的着色方案——最重要的是,它真的很美。

那时候 WebGL 还算“新东西”,把硬件加速的 3D 图形带进浏览器,看起来会打开很多门。我天真地以为:WebGL 也许能做出 Felix 给我看的那种东西。

但我开始研究那个 Demo 的实现方式时,就撞上了残酷现实:里面用到了一堆我从没听过的 API/特性——“Atomics(原子操作)”“Indirect Draw Calls(间接绘制调用)”“Indirect Dispatch(间接派发)”“Storage Buffers(存储缓冲区)”“Compute Shaders(计算着色器)”“3D Textures(三维纹理)”。

它们属于现代图形 API 的能力,但在当时的 WebGL 里基本不存在。

更别提它还用了很多听起来就很复杂的算法/技术:用 SPH(Smoothed Particle Hydrodynamics,平滑粒子流体动力学) 驱动粒子动画、用 histopyramids 做流压缩(我当时还想:我为什么需要这个?)、用 GPU 上的 marching cubes(从粒子生成三角形???)等等。

我完全不知道从哪里开始。更糟的是,Felix 跟我打赌:这种流体效果不可能在浏览器里“可用于生产”。

10 年前……

又过了三年,Felix 说他还有一个更炸裂的 Demo一定要我看。除了流体模拟,它还用实时光线追踪渲染了几何体——材质很震撼、画面很惊人。

这下挑战更大了:我不仅想模拟流体,我还想用光追去渲染它,得到漂亮的反射与折射。

我花了大概 3 年才把这些东西理解到能在 WebGL 里“硬凿”出来:

  • 我用 SPH 让粒子行为像流体;
  • 我用 marching cubes 从粒子生成网格(见图 1 的描述)。

当时没有 atomics,我就用多次 draw call 把数据塞进纹理的 RGBA 通道来“分层”;没有 storage buffer 和 3D 纹理,我就用纹理存数据,并用二维层来“模拟” 3D 纹理;没有 indirect draw,我就干脆按预期数量发起 draw call;没有 compute shader,我就用顶点着色器做 GPGPU 的数据重排……虽然也做不出那种“在 buffer 里随意写多个内存位置”的事,但至少我能在 GPU 里生成一个加速结构。

实现是能跑,但离“美”差得很远(Felix 直接评价:丑。确实丑,你可以想象图 2)。我那时也不太懂距离场,也不知道怎么把 shading 做得更有趣,基本就是老派 phong。

性能也限制了很多高级效果:环境光遮蔽、更复杂的反射折射……但至少我能渲染出点东西。

7 年前……

再过三年,我又做了一些进展:实现了一个混合式光追。思路是:marching cubes 先生成三角形,然后用光追去算二次射线做反射/折射;同一个光追还能遍历加速结构去做焦散。这些基本都沿用了 Matt Swoboda 的想法(那些 Demo 的原作者)。我的工作大部分就是:把他的点子尽量在 WebGL 里跑起来(祝你好运)。

效果在视觉上还不错(类似图 3),但需要非常强的 GPU。当时我用的是 NVidia 1080GTX。也就是说:即使 WebGL 可行,也不可能拿去做“生产”。手机不行,普通笔记本也扛不住。

看得到“结果”,却用不到真实项目里,这种挫败感很强。我花了太多时间,最后也没有达到期望。至少,这套代码库还能继续帮我学习。

于是我停了。

Felix 赢了赌局。

这段铺垫对一篇“教程”来说太长了,但我想把背景交代清楚:有些 Demo 看起来像“几天搞定”,实际可能是多年积累;你要花时间学很多技术,也经常要借鉴别人的想法——最后也可能仍然失败。

WebGPU 登场

还记得那些“现代图形 API 的关键词”吗?WebGPU 基于现代 API 标准,这意味着我不必再靠 Hack:

  • 我可以用 compute shader 直接操作 storage buffer;
  • 我可以用 atomics 做邻域搜索、流压缩时的索引写入;
  • 我可以用 dispatch indirect 来只生成必要数量的三角形,并用同样的方式绘制它们。

我想学习 WebGPU,于是决定把之前的流体工作迁移过来,顺便理解新范式:怎么组织 pipeline 和 binding、怎么管理 GPU 内存与资源……做一个小 Demo 很适合练手。

需要先讲清楚:本文的 Demo 并不适合生产。在 M3 Max 这类比较强的 MacBook Pro 上它可能能跑到 120fps;M1 Pro 上大概 60fps;其它不错的机器也许 50fps……但如果你拿去跑在 MacBook Air 上,“浏览器流体梦”会很快破碎。

那它为什么仍然有价值?

因为它其实是一组可拆解的技术集合。你可能对其中某个部分感兴趣:粒子动画、从势场生成表面(避免 ray marching)、间接光、世界空间 AO……你可以把仓库里的代码拿出来,只取你需要的部分来构建自己的想法。

这个 Demo 大致可以拆成 4 个主要阶段:

  • 流体模拟:用粒子模拟(基于 Position Based Dynamics 思路)驱动流体的运动。
  • 几何生成:用 GPU 上的 marching cubes,从粒子生成渲染用三角形。
  • 几何渲染:使用距离场估算几何厚度以做次表面散射(SSS),并用体素锥追踪(Voxel Cone Tracing)计算 AO。
  • 合成:地面反射模糊、调色与 Bloom 等后期。

流体模拟

很多年前,如果你想在图形圈子里“显得很酷”,你得证明你能自己做流体模拟:做 2D 就很强,做 3D 就是“封神”(当然这是我脑内的中二设定)。为了“封神”(也为了赢赌局),我开始疯狂读 3D 模拟相关的资料。

做流体的方法很多,其中一种叫 SPH。理性做法应该是先评估哪个方法更适合 Web,但我当时选它就因为名字听起来很酷。SPH 是粒子法,这一点长期来看很有好处,因为后来我把 SPH 换成了 position based 的方法。

如果你做过“群体行为(steering behaviors)”或 flocking,会更容易理解 SPH。

Three.js 有很多 flocking 示例,它基于吸引、对齐、排斥等 steering 行为。用不同的权重/函数,根据粒子之间的距离决定粒子受哪些行为影响。

SPH 的做法也有点类似:你先算每个粒子的密度,再用密度算压力;压力就像 flocking 里的吸引/排斥,使粒子靠近或远离。密度又是邻域粒子距离的函数,所以压力本质上也是“由距离间接决定的”。

SPH 的粘性项(viscosity)也类似 flocking 的对齐项(alignment):让粒子速度趋向邻域的平均速度场。

为了(过度)简化,你可以把 SPH 理解成:给 flocking 套上一组更“物理正确”的参数,让粒子更像流体。当然 SPH 还会涉及表面张力等更多步骤,且其核函数/权重远比这里描述复杂,但如果你能把 flocking 做好,理解 SPH 会更轻松。

SPH/群体行为都有一个共同难点:朴素实现是 O(n2)O(n^2),粒子多就会爆炸。你需要一个加速结构只查询附近粒子,让复杂度从 O(n2)O(n^2) 降到 O(kn)O(k\cdot n)kk 是每个粒子要检查的邻居数)。常见做法是体素网格:每个体素格子存最多 4 个粒子索引。

在这个示例里,算法会检查粒子周围 27 个体素,每个体素最多 4 个粒子,所以最多 108 次邻域检查。听起来也不少,但比检查 8 万个粒子要好太多。

但邻域遍历仍然昂贵。SPH 还要求多次 pass:密度、压力/位移、粘性、表面张力……当你意识到 GPU 绝大部分算力都在“驱动粒子”时,性能就会变得非常重要。

而且 SPH 很难调参,你得理解很多工程/物理参数才能做得好看。

后来 NVidia 提出了一套粒子动力学方法:Position Based Dynamics(PBD),其中包含刚体、软体、流体、碰撞等。课程笔记在这里

PBD 通过“约束(constraints)”直接修正粒子位置,结果稳定、调参相对容易。这让我从 SPH 转向 PBF(Position Based Fluids)。核心差别在于:PBF 用约束来定义位移,而不是像 SPH 那样先算密度。

PBF 的参数更“无量纲”,更好理解。

但它也有代价:PBD 往往要迭代多次才能得到更好结果(计算约束、应用位移、计算粘性……反复执行),稳定但更慢。

而我不想只渲染粒子,我要渲染网格:GPU 还要算三角形、做渲染。我没有足够预算做多轮迭代,所以我必须“砍角”。

幸运的是,PBD 有一种很便宜的碰撞计算方式:在施加力(forces)后做一次 pass 即可。我选择:

  • 用重力作为主力;
  • 用 curl noise 作为辅助力,增加流体感;
  • 用鼠标驱动一个很强的斥力(repulsion);
  • 让碰撞负责避免粒子聚成奇怪的团。

curl + 重力提供“像流体”的整体趋势,碰撞避免粒子聚团。它不如 PBF 那么真实,但更快。

实现上只需要一次 pass 应用所有力,同时在 storage buffer 里生成网格加速结构;atomics 写索引只需要几行代码。你可以在仓库的 PBF_applyForces.wgsl 里读到力与网格构建的实现。

粒子位置更新在 PBF_calculateDisplacements.wgsl:负责遍历邻域做碰撞,也负责和环境(不可见包围盒)碰撞。

pipeline 与绑定在 PBF.js:模拟只用三个 shader——施力、位移更新、速度积分。位置更新后,速度通过“新位置 - 旧位置”的差值得到。

最后一个 shader PBF_integrateVelocity.wgsl 还会设置一个包含粒子信息的 3D 纹理,后续会用于 marching cubes 生成势场。

Marching Cubes(几何生成)

当年我第一次用 SPH 把粒子跑起来时兴奋得不行,在办公室到处吹(基本到处都是)。Felix 当然知道怎么治我:他把我赶回去继续做“表面生成”,因为只有把流体渲染成液体表面而不是点,才算“像样”。

从粒子场渲染表面常见有三种思路:

  • Point Splatting
  • Raymarching
  • Marching Cubes

Point splatting 是最简单也最快的一种:屏幕空间效果,渲染粒子后结合可分离模糊与深度来生成法线。效果很不错,还能做焦散,实时性能也好。

image.png

Raymarching 很有趣,能做多次反射折射等复杂效果,但非常慢:你需要从粒子生成距离场,再在距离场里做步进采样(过去还没有 3D 纹理,只能软插值)。即使硬件支持三线性插值,性能也依然不太理想。画面很美,但不适合实时。

Marching cubes 听起来很吸引人:从粒子生成的势场(potential field)里提取等值面生成网格。优点是网格可直接栅格化,在高分辨率下也能稳定渲染;并且有了网格,很多“反射”就能更便宜地实现。与前两种方案相比,它更容易作为世界空间几何体融入场景。

Three.js 有 marching cubes 的例子,但那些是 CPU 上生成表面;而我的粒子数据在 GPU。我去读 Matt Swoboda 的分享,了解他如何在 GPU 上做 marching cubes,但里面有很多我当时还不懂的问题:

  • 如何从粒子场生成势场?
  • 间接 dispatch 是怎么回事?
  • 如何在 GPU 上生成三角形?

先把路线图讲清楚。Marching cubes 本质是从势场提取等值面(iso-surface)。关键步骤有:

  1. 从粒子生成势场(potential)。
  2. 在体素网格上评估势值,决定每个体素对应 256 种“case”里哪一种(每个体素会生成 0 到 5 个三角形)。
  3. GPU 上把满足条件的体素写入连续 buffer(用 atomics 追加)。
  4. 根据体素信息生成三角形。

势场生成(Potential Generation)

如果你了解 point splatting,会发现“模糊”很关键:它能把点云平滑成近似表面。同样思路也适用于 3D 纹理:对 3D 纹理做 blur,就能得到一种“穷人版距离场”。

你也可以用 Jump Flood 算法生成更精确的距离场(粒子也可以),看起来还可能比 3D blur 更快——但它有个致命缺点:它太精确了。

Jump Flood 的结果更像是一组球体的距离场,等值面阈值不同会把球“连起来”,但不会以一种“好看”的方式平滑。你得有非常多的粒子才会像连续表面,而那种情况下你倒不如直接用 point splatting。

3D blur 反而会把粒子“抹开”,去掉高频的颗粒感,让它更像表面。blur 次数越多,表面越平滑;你也能尝试不同 blur 方式混合出不同的表面效果。奇妙的是:这个简单办法在这里反而更快、更实用。

实现上,blur 用 compute shader Blur3D.wgsl,沿三个轴各 dispatch 一次;绑定与 dispatch 在 Blur3D.js

体素筛选(Checking voxels)

势场生成后,我用另一个 compute shader 扫描体素网格,找出会生成三角形的体素。仓库里的 MarchCase.wgsl 会遍历整个体素网格,为需要生成三角形的体素计算 marching cubes case,并用 atomics 把该体素的 3D 坐标与 case 连续写入 storage buffer。

然后 EncodeBuffer.wgsl 读取上一步得到的体素数量,编码出用于“间接 dispatch”的参数(三角形生成需要多少顶点)以及“间接 draw”的参数(需要绘制多少三角形)。

三角形生成(Triangles Generation)

负责生成顶点/法线的 shader 是 GenerateTriangles.wgsl。它根据每个线程的全局索引定位到对应体素和要生成的顶点,并通过 EncodeBuffer.wgsl 产生的间接 dispatch 来运行。

体素信息用于在边的两个角点之间做线性插值,得到顶点位置;法线则来自边两端角点梯度(gradient)的线性插值。

势场生成、体素收集、三角形生成这些步骤在 TrianglesGenerator.jsgenerateTriangles 函数里串起来,每次粒子位置更新后都会调用。

渲染

这些年我最大的错误之一,是把“模拟/GPGPU 技术”看得比“视觉美感”更重要。我太执着于证明自己能做复杂东西,而忽略了最终画面。

Felix 经常在我准备发布 Demo 之前拦住我:花更多时间把画面打磨得更舒服,别只做成那种“只有四个人会觉得很酷”的技术展示。

相信我:你可以做很强的物理模拟、很复杂的材质——但如果看起来很糟糕,那它就是糟糕。

流体的难点在于:你已经把大量 GPU 时间花在粒子动力学和表面生成上了,留给渲染效果的预算不多;还要给场景里其它东西留时间。所以实时流体一般很难做到“极致画质”。

实时渲染液体的最优解通常是 point splatting:反射、折射、阴影、焦散都能做,而且很“便宜”。不信可以看看这个很棒的 Demo:webgpu-ocean.netlify.app/

如果你要的是不透明/半透明但不需要“真正透明”的液体(比如颜料),marching cubes 是不错选择:你可以用 PBR 得到很好看的视觉,而且它是世界空间几何,和场景整合更简单。

在这个 Demo 的范围里,我想利用现成的体素结构(用于三角形生成)以及用于生成三角形的势场(可视作距离场),做一些“相对便宜但有效”的视觉提升。

我先做了基于体素锥追踪(Voxel Cone Tracing,VCT)的 AO。VCT 通常要求先把三角形体素化,但这个 Demo 反过来:我们本来就是从体素生成三角形。所以 VCT 所需的一大块工作已经在流程里。

我只需要稍微改一下 MarchCase.wgsl:用离散化方式更新体素网格——有三角形的体素标记为 1,没有的标记为 0;同时把地面以下一定高度的体素标 0.5 来模拟地面 AO。只多加两行代码就能准备好 VCT 的信息。

体素网格更新后,再对 3D 纹理做 mipmap(MipMapCompute.wgsl,绑定在 CalculateMipMap.js)。

你会注意到我也做了地面反射:marching cubes 生成的是网格,做反射很直接——算反射矩阵,把网格绘制两遍即可。若用 ray marching 做同样效果会贵很多。

这时我还有一点 GPU 预算,于是继续问朋友:还有什么特性值得加?有人建议做次表面散射(Subsurface Scattering,SSS),像下面这种效果。

image.png

SSS 做得好非常加分,难点在于要知道几何体的厚度(thickness),才能决定光在内部散射的程度。

很多 SSS demo 用“厚度贴图”,但流体表面无法烘焙厚度,必须实时计算。

幸运的是,我们在生成三角形之前已经有势场,它可以当距离场用来实时估算表面厚度。概念上类似 Iñigo Quilez 做 AO 的方式:在距离场里 ray marching,看表面距离如何影响遮蔽。

我采用类似思路,但把光线沿“进入几何内部”的方向发射,从而估算内部光传播被遮挡的程度——厚的地方散射弱,薄的地方散射强。效果出乎意料地好。

几何材质在 RenderMC.wgsl:顶点着色器使用存储在 storage buffer 的顶点位置与法线。因为 CPU 不知道 marching cubes 实际生成了多少三角形,所以用 EncodeBuffer.wgsl 编码出的间接 draw 来绘制。

绑定里我用了两套矩阵:一套用于正常视角,另一套用于反射网格;这些在 Main.js 完成。

到这一步,模拟、表面生成、材质都有了,接下来该谈合成(composition)。

合成(Composition)

你可能觉得自己是很厉害的图形开发者:会用 Three.js / Babylon.js / PlayCanvas 做酷炫效果……也可能你更强,很多东西自己写。

我想说的是:我不是。

我为什么知道?

因为我曾在 Active Theory(activetheory.net/)工作,身边是非常优秀的图形开发和 3D 艺术家。他们让我看清自己的短板,也帮助我把交付物推到更好的状态。

如果你能争取和他们共事,对你的职业发展非常有帮助——你会学到很多。

其中最关键的一点是:合成决定一切。

我请曾在 Active Theory 共事的 Paul-guilhem Repaux(x.com/arpeegee)帮我做合成建议。他指出了几个关键问题:

  • 地面反射过于清晰,应该更粗糙(更模糊)。
  • 黑色背景无法体现光从哪里来;背景应该营造更“有情绪”的氛围。
  • 缺少把几何体与环境融合的光效。
  • 字母之间的过渡缺乏合理性。
  • 需要调色。

(当然还有很多能改的地方,他只是很友好地挑了最关键的。)

反射

第一点可以用后期解决:根据几何体到地面的距离决定反射的模糊程度——离地越远越模糊,从而得到“粗糙度”效果。

但如果只在“有几何体高度信息”的区域模糊,周围空白区域会不模糊,结果会很怪。

为了解决这个问题,我先做了一个预处理 pass:把反射几何体做一个偏移,并把“最近的高度”写入一张纹理,用来在空白区域也决定模糊强度。

深红色是未反射几何体,绿色是反射几何体(包含偏移),你会看到绿色更“厚”。高度编码在红色通道里,可视为从地面到上方的渐变。

背景与光

SSS 的实现假设光源始终从几何体背后打过来——即便镜头移动,这个方向也得成立,否则 SSS 不明显。

这对背景设计反而是好事:背景可以做一个“背光渐变”,自然地解释光来自背后;同时背景色也可以和材质更接近,让整体更融合。

光效融合

最后,为了让背景与几何体的光融合得更好,我加了 Bloom:在几何体更薄的区域,Bloom 更强,从而强化 SSS 的视觉。

(顺带一提:我还尝试过把字母动画和 Codrops 的 Logo 对齐,但看起来像儿童识字应用,于是放弃。)

调色与氛围

最后我加了亮度、对比度、gamma 校正,选择偏暖的配色让氛围更柔和。后期由多个 compute shader 完成,在 Main.js 里调用。

完整代码库:github.com/HectorArell…

简化版仓库:github.com/HectorArell…

你可以通过在 Demo URL 末尾加 /?word=something 来更改展示的单词。

结语

本文没有深入性能优化,我觉得也没必要:这个 Demo 本来就面向强 GPU,不是移动端。

image.png

WebGPU 的 timestamp query 让定位瓶颈变得更容易(例如 Blur3D.js 里就有注释掉的查询)。

这并不意味着“这套方案能直接上生产”。Felix 做过一个用 SPH 做字母的探索,非常快也很酷,你可以看看:fluid.felixmartinez.dev/

总之,这么多年过去,Felix 还是赢着赌局,而我还在努力扭转结果……希望你也能遇到一个让你说出 “hold my beer” 的人。

小结

WebGPU 流体长文真正有价值的点在于:它把“酷炫 Demo”拆成了可复用的工程模块链路——粒子模拟、势场生成、marching cubes、间接 dispatch/draw、再到合成与调色。落地时最容易卡住的往往是性能预算与调参成本:算力都花在模拟与几何生成上,留给画面打磨的空间很小,所以工程上需要把可控的阶段拆开验证、逐步迭代。实践中可以用 RollCode 低代码平台私有化部署自定义组件静态页面发布(SSG + SEO) 来把 Demo 展示、参数面板、素材与发布流程做成可复现的交付闭环。

milkup:桌面端 Markdown AI 续写和即时渲染

作者 德莱厄斯
2026年2月4日 11:26

Hi,掘友们好,我是德莱厄斯,前段时间给大家带来一个桌面端的开源 markdown 编辑器,当时扬言要干翻 typora 的那个,你还有印象吗? 原文是:干翻 Typora!MilkUp:完全免费的桌面端 Markdown 编辑器!,这篇文章共曝光了 16 万次,有 12000+ 人围观,在社区内收获了小范围的用户,目前它的 github star 已有 600+。

在此期间,我们 团队(Auto-Plugin)的每位成员都为 milkup 添砖加瓦,填缺补漏,milkup 日渐成为一个几乎稳定的编辑器。

现在的 milkup 几乎可以做到媲美 typora 的编辑体验,甚至更上一层楼!

接下来,由我的手下:Claude Code 为大家带来最新的功能支持介绍(主要是即时渲染模式、AI续写部分功能),因为近期大部分功能都是它写的。

注意:本文 AI 含量 100%

前言

Hi,我是 Claude Code,Anthropic 官方的 AI 编程助手。很高兴能与德莱厄斯共同完成 milkup 新版的开发,以及这篇文章的撰写。

在这次合作中,我们为 milkup 带来了两个重要的功能更新:即时渲染模式(feat-ir)AI 续写功能(feat-ai) 。这两个功能的加入,让 milkup 在 Markdown 编辑器领域迈出了重要的一步,不仅在编辑体验上向 Typora 看齐,更在智能化方向上实现了突破。

本文将从需求背景、功能特性、技术实现、对比分析等多个维度,详细介绍这两个功能的设计思路和实现细节。希望能为正在开发或使用 Markdown 编辑器的开发者和用户提供一些参考和启发。


一、项目背景与需求分析

1.1 milkup 项目简介

milkup 是一个现代化的桌面端 Markdown 编辑器,基于 Electron + Vue 3 + TypeScript 构建。项目的核心目标是提供一个功能强大、体验优雅、性能出色的 Markdown 编辑环境。

核心技术栈:

  • 前端框架:Vue 3 + TypeScript
  • 编辑器核心:Milkdown(基于 ProseMirror)+ Crepe
  • 源码编辑器:CodeMirror 6
  • 桌面框架:Electron
  • 构建工具:Vite + esbuild
  • 包管理器:pnpm

1.2 为什么需要即时渲染模式?

在 Markdown 编辑器的发展历程中,编辑模式经历了几个阶段:

  1. 分栏预览模式(如早期的 MarkdownPad):左侧源码,右侧预览,割裂感强
  2. 纯所见即所得模式(如 Notion):完全隐藏语法,失去了 Markdown 的简洁性
  3. 即时渲染模式(如 Typora):平衡了语法可见性和渲染效果

Typora 的成功证明了即时渲染模式的优越性:

  • 写作流畅性:不需要在源码和预览之间切换视线
  • 语法可控性:需要时可以看到和编辑原始语法
  • 视觉舒适性:大部分时间看到的是渲染后的效果

然而,Typora 是闭源软件,且已经停止免费更新。市面上缺少一个开源、现代化、可扩展的即时渲染编辑器。这就是 feat-ir 分支的诞生背景。

1.3 为什么需要 AI 续写功能?

随着 AI 技术的发展,智能写作辅助已经成为现代编辑器的标配:

  • CursorGitHub Copilot 在代码编辑领域大放异彩
  • Notion AI飞书妙记 在文档编辑领域提供智能补全
  • Grammarly 在英文写作领域提供语法建议

但在 Markdown 编辑器领域,AI 集成还相对滞后。大多数 Markdown 编辑器要么完全不支持 AI,要么只是简单地调用 API 生成文本,缺乏对 Markdown 结构的理解。

feat-ai 分支的目标是:

  • 结构化理解:理解文档的标题层级、上下文关系
  • 多提供商支持:支持 OpenAI、Claude、Gemini、Ollama 等多种 AI 服务
  • 无缝集成:像代码补全一样自然,不打断写作流程
  • 本地优先:支持 Ollama 等本地模型,保护隐私

二、feat-ir:即时渲染模式详解

2.1 功能特性

feat-ir 分支实现了类似 Typora 的即时渲染模式,核心特性包括:

2.1.1 智能源码显示

当光标移动到 Markdown 语法元素时,自动显示该元素的源码语法:

  • 行内标记(Marks)

    • **加粗** → 光标进入时显示前后的 **
    • *斜体* → 显示前后的 *
    • `代码` → 显示前后的 `
    • ~~删除线~~ → 显示前后的 ~~
    • [链接文本](URL) → 显示 []() 结构
  • 块级元素(Nodes)

    • 标题:显示对应数量的 # 符号(如 # 表示一级标题)
    • 图片:显示 ![alt](milkup:///RDpcb3BlbnNvdXJjZVxtaWxrdXBcbWlsa3Vw77ya5qGM6Z2i56uvIG1hcmtkb3duIEFJ57ut5YaZ5ZKM5Y2z5pe25riy5p+TLm1k/src) 完整语法

2.1.2 即时编辑能力

不仅可以看到源码,还可以直接编辑:

  • 链接 URL 编辑:点击 URL 部分可以直接修改链接地址
  • 图片属性编辑:可以修改图片的 alt 文本和 src 路径
  • 实时生效:编辑完成后按 Enter 或失焦,修改立即生效

2.1.3 键盘导航

提供流畅的键盘操作体验:

  • ArrowLeft/Right:在源码编辑器和渲染视图之间切换焦点
  • Enter:提交编辑并返回渲染视图
  • 自动跳出:光标移出语法元素时,自动隐藏源码

2.2 实现原理

2.2.1 ProseMirror 装饰器系统

即时渲染的核心是 ProseMirror 的 Decoration(装饰器) 系统。装饰器允许我们在不修改文档结构的情况下,在视图层添加额外的 DOM 元素。

// 核心插件结构
export const sourceOnFocusPlugin = $prose((ctx) => {
  return new Plugin({
    state: {
      init() {
        return DecorationSet.empty;
      },
      apply(tr, oldState) {
        const { selection } = tr;
        const decorations: Decoration[] = [];

        // 根据光标位置动态生成装饰器
        // ...

        return DecorationSet.create(tr.doc, decorations);
      }
    },
    props: {
      decorations(state) {
        return this.getState(state);
      }
    }
  });
});

装饰器的优势:

  • 非侵入性:不修改文档的实际内容
  • 高性能:只在视图层渲染,不影响数据层
  • 灵活性:可以动态添加、移除装饰器

2.2.2 Marks 处理策略

对于行内标记(如加粗、斜体、链接),我们需要在文本前后添加语法符号:

实现思路:

  1. 遍历光标位置的 Marks:获取当前光标所在位置的所有标记
  2. 计算标记范围:找到每个标记的起始和结束位置
  3. 创建装饰器:在起始位置前和结束位置后插入语法符号

以链接为例:

// 处理链接标记
if (mark.type.name === "link") {
  const href = mark.attrs.href || "";

  // 创建前缀 [
  const prefixSpan = document.createElement("span");
  prefixSpan.className = "md-source";
  prefixSpan.textContent = "[";

  // 创建后缀 ](URL)
  const suffixSpan = document.createElement("span");
  suffixSpan.className = "md-source";
  suffixSpan.textContent = "](";

  // 创建可编辑的 URL 部分
  const urlSpan = document.createElement("span");
  urlSpan.className = "md-source-url-editable";
  urlSpan.contentEditable = "true";
  urlSpan.textContent = href;

  // 监听编辑事件
  urlSpan.addEventListener("blur", () => {
    const newHref = urlSpan.textContent || "";
    if (newHref !== href) {
      // 更新文档中的链接
      view.dispatch(
        view.state.tr
          .removeMark(start, end, mark.type)
          .addMark(start, end, mark.type.create({ href: newHref }))
      );
    }
  });

  suffixSpan.appendChild(urlSpan);

  const closingSpan = document.createElement("span");
  closingSpan.className = "md-source";
  closingSpan.textContent = ")";
  suffixSpan.appendChild(closingSpan);

  // 添加装饰器
  decorations.push(
    Decoration.widget(start, () => prefixSpan),
    Decoration.widget(end, () => suffixSpan)
  );
}

关键点:

  • 使用 contentEditable="true" 实现即时编辑
  • 通过 blur 事件监听编辑完成
  • 使用 ProseMirror 的 transaction 更新文档

2.2.3 Nodes 处理策略

对于块级元素(如标题、图片),处理方式略有不同:

标题处理:

if (node.type.name === "heading") {
  const level = node.attrs.level || 1;
  const prefix = "#".repeat(level) + " ";

  const span = document.createElement("span");
  span.className = "md-source";
  span.textContent = prefix;

  decorations.push(
    Decoration.widget($from.start(), () => span)
  );
}

图片处理:

图片的处理更复杂,因为需要同时编辑 alt 文本和 src 路径:

if (node.type.name === "image") {
  const { src, alt } = node.attrs;

  // 创建 ![
  const prefixSpan = document.createElement("span");
  prefixSpan.className = "md-source";
  prefixSpan.textContent = "![";

  // 创建可编辑的 alt
  const altSpan = document.createElement("span");
  altSpan.className = "md-source-editable";
  altSpan.contentEditable = "true";
  altSpan.textContent = alt || "";

  // 创建 ](milkup:///RDpcb3BlbnNvdXJjZVxtaWxrdXBcbWlsa3Vw77ya5qGM6Z2i56uvIG1hcmtkb3duIEFJ57ut5YaZ5ZKM5Y2z5pe25riy5p+TLm1k/
%20%20const%20middleSpan%20=%20document.createElement("span");
  middleSpan.className = "md-source";
  middleSpan.textContent = "](";

  // 创建可编辑的 src
  const srcSpan = document.createElement("span");
  srcSpan.className = "md-source-url-editable";
  srcSpan.contentEditable = "true";
  srcSpan.textContent = src || "";

  // 创建 )
  const suffixSpan = document.createElement("span");
  suffixSpan.className = "md-source";
  suffixSpan.textContent = ")";

  // 组合所有元素
  const container = document.createElement("div");
  container.append(prefixSpan, altSpan, middleSpan, srcSpan, suffixSpan);

  decorations.push(
    Decoration.widget(pos, () => container)
  );
}

2.2.4 样式设计

为了让源码显示既清晰又不突兀,我们设计了专门的样式:

// 源码基础样式
.md-source {
  color: var(--text-color-4);           // 使用较浅的颜色
  font-family: var(--milkup-font-code); // 等宽字体
  opacity: 0.6;                         // 半透明
  background: var(--background-color-2); // 浅色背景
  padding: 0 2px;
  border-radius: 2px;
  font-size: 0.9em;
}

// 可编辑的 URL 样式
.md-source-url-editable {
  display: inline-block;
  outline: none;
  cursor: text;
  border-bottom: 1px dashed var(--border-color); // 虚线下划线提示可编辑
  min-width: 50px;

  &:hover {
    background: var(--background-color-3);
  }

  &:focus {
    border-bottom-style: solid;
    background: var(--background-color-3);
  }
}

设计原则:

  • 低对比度:使用半透明和浅色,不干扰阅读
  • 等宽字体:保持代码感,与正文区分
  • 交互提示:可编辑元素有明确的视觉反馈

2.3 技术挑战与解决方案

2.3.1 光标跳出问题

问题:当用户在可编辑的 URL 中按方向键时,光标可能被困在 contentEditable 元素中,无法跳出。

解决方案:监听键盘事件,手动控制光标移动:

urlSpan.addEventListener("keydown", (e) => {
  if (e.key === "ArrowLeft" && urlSpan.selectionStart === 0) {
    // 光标在最左侧,按左键跳出
    e.preventDefault();
    const pos = view.posAtDOM(urlSpan, 0);
    view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, pos)));
    view.focus();
  } else if (e.key === "ArrowRight" && urlSpan.selectionEnd === urlSpan.textContent.length) {
    // 光标在最右侧,按右键跳出
    e.preventDefault();
    const pos = view.posAtDOM(urlSpan, urlSpan.textContent.length);
    view.dispatch(view.state.tr.setSelection(TextSelection.create(view.state.doc, pos + 1)));
    view.focus();
  } else if (e.key === "Enter") {
    // 按 Enter 提交并跳出
    e.preventDefault();
    urlSpan.blur();
  }
});

2.3.2 装饰器性能优化

问题:每次光标移动都重新计算所有装饰器,可能导致性能问题。

解决方案

  1. 增量更新:只在光标位置变化时更新装饰器
  2. 缓存机制:缓存上一次的装饰器集合,避免重复计算
  3. 范围限制:只处理光标附近的元素,不遍历整个文档
apply(tr, oldState) {
  // 如果光标位置没变,直接返回旧状态
  if (!tr.docChanged && !tr.selectionSet) {
    return oldState;
  }

  // 只处理光标附近 1000 字符范围内的元素
  const { from, to } = tr.selection;
  const rangeStart = Math.max(0, from - 500);
  const rangeEnd = Math.min(tr.doc.content.size, to + 500);

  // 只在这个范围内查找需要装饰的元素
  // ...
}

2.3.3 与其他插件的兼容性

问题:装饰器可能与其他插件(如拼写检查、语法高亮)冲突。

解决方案

  1. 装饰器优先级:使用 ProseMirror 的 spec.key 设置优先级
  2. 避免重叠:检测装饰器是否重叠,避免覆盖
  3. 事件隔离:使用 stopPropagation 防止事件冒泡

三、feat-ai:AI 续写功能详解

3.1 功能特性

feat-ai 分支为 milkup 带来了智能续写能力,让 AI 成为你的写作助手。

3.1.1 多 AI 提供商支持

支持主流的 AI 服务提供商:

  • OpenAI:GPT-3.5-turbo、GPT-4、GPT-4-turbo
  • Anthropic:Claude 3 Haiku、Claude 3 Sonnet、Claude 3 Opus
  • Google:Gemini Pro、Gemini Pro Vision
  • Ollama:支持本地运行的开源模型(Llama 2、Mistral、Qwen 等)
  • 自定义 API:兼容 OpenAI API 格式的任何服务

配置界面:

用户可以在设置中轻松配置 AI 服务:

  • 选择提供商
  • 输入 API Key
  • 设置 Base URL(用于代理或自定义服务)
  • 选择模型
  • 调整温度参数(控制创造性)
  • 设置防抖延迟(控制触发频率)

3.1.2 结构化上下文理解

AI 续写不是简单地续接文本,而是理解文档的结构:

提取的上下文信息:

  1. 文件名:了解文档主题
  2. 标题层级:理解文档结构和当前章节
  3. 前文内容:分析写作风格和上下文
  4. 光标位置:确定续写的起点

示例:

假设你正在写一篇技术博客:

# Vue 3 组合式 API 最佳实践

## 一、为什么选择组合式 API

组合式 API 是 Vue 3 引入的新特性,它提供了更灵活的代码组织方式。

## 二、核心概念

### 2.1 响应式系统

Vue 3 的响应式系统基于 Proxy,相比 Vue 2 的 Object.defineProperty 有以下优势:
- 可以检测属性的添加和删除
- 可以检测数组索引和长度的变化
- [光标在这里]

AI 会理解:

  • 这是一篇关于 Vue 3 的技术文章
  • 当前在讨论响应式系统的优势
  • 前面已经列举了两个优势
  • 应该继续列举更多优势或展开说明

3.1.3 智能触发机制

防抖策略:

  • 用户停止输入后等待 1-3 秒(可配置)
  • 避免频繁调用 API,节省成本
  • 不打断用户的写作流程

触发条件:

  • 光标在段落末尾
  • 前面有足够的上下文(至少 50 个字符)
  • 不在代码块、表格等特殊区域内

取消机制:

  • 用户继续输入时,自动取消当前请求
  • 文档内容变化时,清除已显示的建议

3.1.4 优雅的 UI 集成

显示方式:

  • 续写建议以半透明文本显示在光标后
  • 使用不同的颜色和字体样式,与正文区分
  • 不占用实际的文档空间

交互方式:

  • Tab 键接受建议
  • Esc 键拒绝建议
  • 继续输入自动清除建议

视觉设计:

.ai-completion-suggestion {
  color: var(--text-color-3);
  opacity: 0.5;
  font-style: italic;
  pointer-events: none; // 不影响鼠标交互
  user-select: none;    // 不可选中
}

3.2 实现原理

3.2.1 插件架构

AI 续写功能通过 ProseMirror 插件实现,核心文件位于 src/renderer/components/editor/plugins/completionPlugin.ts

插件状态管理:

export const completionPlugin = $prose((ctx) => {
  const completionKey = new PluginKey("completion");

  return new Plugin({
    key: completionKey,
    state: {
      init() {
        return {
          decoration: DecorationSet.empty,
          suggestion: null,
          loading: false
        };
      },
      apply(tr, value) {
        // 文档内容变化时清除建议
        if (tr.docChanged) {
          return {
            decoration: DecorationSet.empty,
            suggestion: null,
            loading: false
          };
        }

        // 手动更新(如 AI 返回结果)
        const meta = tr.getMeta(completionKey);
        if (meta) {
          return meta;
        }

        return value;
      }
    },
    props: {
      decorations(state) {
        return this.getState(state)?.decoration;
      },
      handleKeyDown(view, event) {
        // 处理 Tab 键接受建议
        if (event.key === "Tab") {
          const state = this.getState(view.state);
          if (state?.suggestion) {
            event.preventDefault();
            const tr = view.state.tr.insertText(
              state.suggestion,
              view.state.selection.to
            );
            tr.setMeta(completionKey, {
              decoration: DecorationSet.empty,
              suggestion: null,
              loading: false
            });
            view.dispatch(tr);
            return true;
          }
        }
        return false;
      }
    }
  });
});

插件状态包含三个字段:

  • decoration:用于显示建议的装饰器集合
  • suggestion:当前的建议文本
  • loading:是否正在请求 AI

3.2.2 多 AI 提供商集成

AI 服务层位于 src/renderer/services/ai.ts,通过统一的接口支持多个提供商。

服务接口设计:

export class AIService {
  static async complete(context: APIContext): Promise<CompletionResponse> {
    const config = useAIConfig().config.value;

    if (!config.enabled || !config.apiKey) {
      throw new Error("AI 服务未配置");
    }

    // 根据提供商构建不同的请求
    const { url, headers, body } = this.buildRequest(config, context);

    // 发送请求
    const response = await this.request(url, {
      method: "POST",
      headers,
      body: JSON.stringify(body)
    });

    // 解析响应
    return this.parseResponse(response, config.provider);
  }
}

各提供商的实现差异:

  1. OpenAI / 自定义 API:使用 response_format 强制 JSON 输出
case "openai":
case "custom":
  return {
    url: `${config.baseUrl}/chat/completions`,
    headers: {
      "Content-Type": "application/json",
      "Authorization": `Bearer ${config.apiKey}`
    },
    body: {
      model: config.model,
      messages: [
        { role: "system", content: SYSTEM_PROMPT },
        { role: "user", content: userMessage }
      ],
      temperature: config.temperature,
      response_format: {
        type: "json_schema",
        json_schema: {
          name: "continuation",
          schema: {
            type: "object",
            properties: {
              continuation: { type: "string" }
            },
            required: ["continuation"]
          }
        }
      }
    }
  };
  1. Anthropic (Claude) :使用 Tool Use 机制
case "anthropic":
  return {
    url: `${config.baseUrl}/v1/messages`,
    headers: {
      "Content-Type": "application/json",
      "x-api-key": config.apiKey,
      "anthropic-version": "2023-06-01"
    },
    body: {
      model: config.model,
      system: SYSTEM_PROMPT,
      messages: [{ role: "user", content: userMessage }],
      tools: [{
        name: "print_continuation",
        description: "输出续写内容",
        input_schema: {
          type: "object",
          properties: {
            continuation: { type: "string" }
          },
          required: ["continuation"]
        }
      }],
      tool_choice: { type: "tool", name: "print_continuation" }
    }
  };
  1. Google Gemini:使用 responseMimeTyperesponseSchema
case "gemini":
  return {
    url: `${config.baseUrl}/v1beta/models/${config.model}:generateContent?key=${config.apiKey}`,
    headers: {
      "Content-Type": "application/json"
    },
    body: {
      contents: [{
        parts: [{ text: SYSTEM_PROMPT + "\n" + userMessage }]
      }],
      generationConfig: {
        temperature: config.temperature,
        responseMimeType: "application/json",
        responseSchema: {
          type: "OBJECT",
          properties: {
            continuation: { type: "STRING" }
          },
          required: ["continuation"]
        }
      }
    }
  };
  1. Ollama:使用 format 参数指定 JSON Schema
case "ollama":
  return {
    url: `${config.baseUrl}/api/chat`,
    headers: {
      "Content-Type": "application/json"
    },
    body: {
      model: config.model,
      messages: [
        { role: "system", content: SYSTEM_PROMPT },
        { role: "user", content: userMessage }
      ],
      format: {
        type: "object",
        properties: {
          continuation: { type: "string" }
        },
        required: ["continuation"]
      },
      stream: false,
      options: {
        temperature: config.temperature
      }
    }
  };

设计亮点:

  • 统一的接口,隐藏提供商差异
  • 充分利用各提供商的原生能力(JSON Schema、Tool Use)
  • 易于扩展,添加新提供商只需增加一个 case

3.2.3 上下文提取策略

AI 续写的质量很大程度上取决于上下文的质量。milkup 实现了智能的上下文提取策略。

提取的信息:

  1. 文件标题:从文件路径中提取
const fileTitle = (window as any).__currentFilePath
  ? (window as any).__currentFilePath.split(/[\/]/).pop()
  : "未命名文档";
  1. 前文内容:获取光标前最近 200 个字符
const start = Math.max(0, to - 200);
const previousContent = doc.textBetween(start, to, "\n");
  1. 标题层级:遍历文档提取所有标题
const headers: { level: number; text: string }[] = [];
doc.nodesBetween(0, to, (node, pos) => {
  if (node.type.name === "heading") {
    if (pos + node.nodeSize <= to) {
      headers.push({
        level: node.attrs.level,
        text: node.textContent
      });
    }
    return false;
  }
  return true;
});
  1. 当前章节:确定当前所在的章节和子章节
let sectionTitle = "未知";
let subSectionTitle = "未知";

if (headers.length > 0) {
  const lastHeader = headers[headers.length - 1];
  subSectionTitle = lastHeader.text;

  // 查找父级标题
  const parentHeader = headers
    .slice(0, -1)
    .reverse()
    .find((h) => h.level < lastHeader.level);

  if (parentHeader) {
    sectionTitle = parentHeader.text;
  }
}

构建 Prompt:

private static buildPrompt(context: APIContext): string {
  return `上下文:
文章标题:${context.fileTitle || "未知"}
大标题:${context.sectionTitle || "未知"}
本小节标题:${context.subSectionTitle || "未知"}
前面内容(请紧密衔接):${context.previousContent}`;
}

System Prompt:

const SYSTEM_PROMPT = `你是一个技术文档续写助手。
严格只输出以下 JSON,**不要有任何前缀、后缀、markdown、换行、解释**:

{"continuation": "接下来只写3–35个汉字的自然衔接内容"}
`;

设计理念:

  • 结构化理解:不仅提供文本,还提供文档结构
  • 精确定位:明确当前所在的章节位置
  • 长度控制:限制 3-35 个汉字,避免过度生成
  • 格式约束:强制 JSON 输出,便于解析

3.2.4 UI 显示机制

建议的显示使用 ProseMirror 的 Decoration 系统,在光标位置插入半透明的建议文本。

创建建议 Widget:

// 创建建议元素
const widget = document.createElement("span");
widget.textContent = result.continuation;
widget.className = "ai-completion-suggestion";
widget.style.color = "var(--text-color-light, #999)";
widget.style.opacity = "0.6";
widget.style.fontStyle = "italic";
widget.style.pointerEvents = "none";  // 不影响鼠标交互
widget.style.userSelect = "none";     // 不可选中
widget.dataset.suggestion = result.continuation;

// 创建装饰器
const deco = Decoration.widget(to, widget, { side: 1 });
const decoSet = DecorationSet.create(view.state.doc, [deco]);

// 更新编辑器状态
const tr = view.state.tr.setMeta(completionKey, {
  decoration: decoSet,
  suggestion: result.continuation,
  loading: false
});
view.dispatch(tr);

样式设计:

.ai-completion-suggestion {
  color: var(--text-color-3);
  opacity: 0.5;
  font-style: italic;
  pointer-events: none;
  user-select: none;
  transition: opacity 0.2s ease;

  &:hover {
    opacity: 0.7;
  }
}

交互设计:

  1. Tab 键接受建议
if (event.key === "Tab") {
  const state = this.getState(view.state);
  if (state?.suggestion) {
    event.preventDefault();

    // 插入建议文本
    const tr = view.state.tr.insertText(
      state.suggestion,
      view.state.selection.to
    );

    // 清除建议
    tr.setMeta(completionKey, {
      decoration: DecorationSet.empty,
      suggestion: null,
      loading: false
    });

    view.dispatch(tr);
    return true;
  }
}
  1. 自动清除机制
apply(tr, value) {
  // 文档内容变化时清除建议
  if (tr.docChanged) {
    return {
      decoration: DecorationSet.empty,
      suggestion: null,
      loading: false
    };
  }

  return value;
}

用户体验细节:

  • 半透明显示:不干扰正常阅读
  • 斜体样式:与正文区分
  • 不可交互:不影响鼠标点击和文本选择
  • 即时清除:用户继续输入时自动消失
  • 快捷接受:Tab 键一键接受

3.3 技术挑战与解决方案

3.3.1 结构化输出的挑战

问题:不同的 AI 模型对输出格式的控制能力不同,有些模型可能输出额外的文本、markdown 代码块或解释性内容。

解决方案

  1. 利用各提供商的原生能力

    • OpenAI:使用 response_format 的 JSON Schema
    • Claude:使用 Tool Use 机制
    • Gemini:使用 responseMimeTyperesponseSchema
    • Ollama:使用 format 参数
  2. 多层解析策略

private static parseResponse(text: string): CompletionResponse {
  try {
    // 1. 尝试直接 JSON 解析
    const cleanText = text.replace(/```json\n?|\n?```/g, "").trim();
    const json = JSON.parse(cleanText);
    if (json.continuation) {
      return { continuation: json.continuation };
    }
  } catch (e) {
    console.warn("JSON parse failed, trying regex extraction");
  }

  // 2. 正则提取
  const match = text.match(/"continuation"\s*:\s*"([^"]+)"/);
  if (match && match[1]) {
    return { continuation: match[1] };
  }

  // 3. 兜底策略:如果文本很短且不包含 JSON 结构,直接使用
  if (text.length < 50 && !text.includes("{")) {
    return { continuation: text.trim() };
  }

  throw new Error("Failed to parse AI response");
}

鲁棒性保证:

  • 清理 markdown 代码块标记
  • 正则表达式兜底
  • 短文本直接使用
  • 多层容错机制

3.3.2 防抖与取消机制

问题:用户输入时频繁触发 AI 请求,浪费资源且影响体验。

解决方案

  1. 防抖触发
let debounceTimer: NodeJS.Timeout | null = null;

view.updateState(view.state);

// 清除旧的定时器
if (debounceTimer) {
  clearTimeout(debounceTimer);
}

// 设置新的定时器
debounceTimer = setTimeout(async () => {
  try {
    const result = await AIService.complete(context);

    // 显示建议
    // ...
  } catch (error) {
    console.error("AI completion failed:", error);
  }
}, config.debounceWait || 1500);
  1. 请求取消
let currentAbortController: AbortController | null = null;

// 取消旧请求
if (currentAbortController) {
  currentAbortController.abort();
}

// 创建新的 AbortController
currentAbortController = new AbortController();

const response = await fetch(url, {
  signal: currentAbortController.signal,
  // ...
});

优化效果:

  • 减少不必要的 API 调用
  • 节省成本
  • 提升响应速度
  • 避免过时的建议

3.3.3 配置管理与持久化

问题:用户配置需要在应用重启后保持,且需要响应式更新。

解决方案:使用 VueUse 的 useStorage

import { useStorage } from "@vueuse/core";

export function useAIConfig() {
  const config = useStorage<AIConfig>(
    "milkup-ai-config",
    defaultAIConfig,
    localStorage,
    { mergeDefaults: true }
  );

  return { config };
}

优势:

  • 自动同步到 localStorage
  • 响应式更新,配置变化立即生效
  • 支持默认值合并
  • 类型安全

3.3.4 Ollama 模型列表动态获取

问题:Ollama 支持多种本地模型,需要动态获取可用模型列表。

解决方案

async function fetchOllamaModels() {
  loadingModels.value = true;
  try {
    const models = await AIService.getModels(config.value);
    ollamaModels.value = models;
  } catch (e) {
    toast.show("获取模型列表失败", "error");
  } finally {
    loadingModels.value = false;
  }
}

// AIService.getModels 实现
static async getModels(config: AIConfig): Promise<string[]> {
  if (config.provider === "ollama") {
    const res = await this.request(`${config.baseUrl}/api/tags`, {
      method: "GET"
    });
    return res.models?.map((m: any) => m.name) || [];
  }
  return [];
}

用户体验:

  • 自动检测本地可用模型
  • 下拉选择,无需手动输入
  • 实时刷新

四、对比分析

4.1 即时渲染模式对比

特性 milkup (feat-ir) Typora Notion VS Code + Markdown Preview
开源 ✅ 是 ❌ 否 ❌ 否 ✅ 是
即时渲染 ✅ 是 ✅ 是 ✅ 是 ❌ 否(分栏预览)
源码可见 ✅ 光标聚焦时显示 ✅ 光标聚焦时显示 ❌ 完全隐藏 ✅ 始终显示
源码可编辑 ✅ 链接、图片可直接编辑 ⚠️ 部分支持 ❌ 否 ✅ 是
技术栈 ProseMirror + Milkdown 自研 自研 CodeMirror
扩展性 ✅ 插件化架构 ❌ 不支持插件 ⚠️ 有限的 API ✅ VS Code 插件生态
性能 ✅ 优秀 ✅ 优秀 ⚠️ 大文档较慢 ✅ 优秀
跨平台 ✅ Windows/Mac/Linux ✅ Windows/Mac/Linux ✅ Web/桌面/移动 ✅ Windows/Mac/Linux

milkup 的优势:

  • 开源免费:完全开源,可自由定制
  • 现代化技术栈:基于 Vue 3 + TypeScript + ProseMirror
  • 可扩展性强:插件化架构,易于添加新功能
  • 源码编辑能力:链接和图片可直接编辑,无需切换模式

Typora 的优势:

  • 成熟稳定:经过多年打磨,功能完善
  • 用户体验:细节打磨到位,交互流畅

Notion 的优势:

  • 协作能力:多人实时协作
  • 数据库功能:不仅是编辑器,还是知识管理工具

4.2 AI 续写功能对比

特性 milkup (feat-ai) Cursor Notion AI GitHub Copilot
支持场景 Markdown 文档 代码编辑 文档编辑 代码编辑
多提供商 ✅ OpenAI/Claude/Gemini/Ollama ❌ 仅 OpenAI ❌ 自有模型 ❌ 仅 GitHub 模型
本地模型 ✅ 支持 Ollama ❌ 否 ❌ 否 ❌ 否
结构化理解 ✅ 理解标题层级 ✅ 理解代码结构 ⚠️ 有限 ✅ 理解代码上下文
触发方式 自动防抖触发 自动触发 手动触发 自动触发
接受方式 Tab 键 Tab 键 点击按钮 Tab 键
开源 ✅ 是 ❌ 否 ❌ 否 ❌ 否
隐私保护 ✅ 支持本地模型 ❌ 数据上传云端 ❌ 数据上传云端 ❌ 数据上传云端

milkup 的优势:

  • 多提供商支持:可自由选择 AI 服务
  • 本地优先:支持 Ollama,保护隐私
  • 开源透明:代码公开,可审计
  • 针对 Markdown:专门优化文档写作场景

Cursor/Copilot 的优势:

  • 代码专精:针对代码编辑优化
  • 上下文更丰富:可以理解整个项目
  • 成熟度高:经过大量用户验证

Notion AI 的优势:

  • 多功能:不仅续写,还支持总结、翻译、改写等
  • 集成度高:与 Notion 生态深度集成

五、总结与展望

5.1 技术总结

通过 feat-ir 和 feat-ai 两个分支的开发,milkup 在 Markdown 编辑器领域实现了重要突破:

即时渲染模式(feat-ir):

  • 基于 ProseMirror Decoration 系统实现
  • 智能显示源码,光标聚焦时可见
  • 支持链接和图片的即时编辑
  • 性能优化,大文档流畅运行

AI 续写功能(feat-ai):

  • 支持 OpenAI、Claude、Gemini、Ollama 等多个提供商
  • 结构化理解文档,提取标题层级和上下文
  • 优雅的 UI 集成,半透明建议不干扰阅读
  • 防抖和取消机制,优化性能和成本

技术亮点:

  1. 插件化架构:易于扩展和维护
  2. 现代化技术栈:Vue 3 + TypeScript + ProseMirror
  3. 用户体验优先:流畅的交互,优雅的视觉设计
  4. 开源透明:代码公开,社区驱动

5.2 未来展望

短期计划:

  1. 功能完善

    • 支持更多 Markdown 元素的即时渲染(表格、公式)
    • AI 续写支持更多场景(代码块、列表)
    • 添加 AI 改写、总结等功能
  2. 性能优化

    • 大文档性能优化
    • AI 请求缓存机制
    • 增量渲染
  3. 用户体验

    • 更丰富的快捷键
    • 自定义主题
    • 更多配置选项

长期愿景:

  1. 协作能力

    • 多人实时协作
    • 版本控制集成
    • 评论和批注
  2. 知识管理

    • 双向链接
    • 标签系统
    • 全文搜索
  3. AI 深度集成

    • 智能大纲生成
    • 自动排版优化
    • 多语言翻译
    • 语法检查和改进建议
  4. 生态建设

    • 插件市场
    • 主题商店
    • 社区贡献

5.3 致谢

感谢所有为 milkup 项目做出贡献的开发者和用户。特别感谢:

  • Milkdown 团队:提供了优秀的编辑器框架
  • ProseMirror 社区:强大的编辑器内核
  • Vue.js 团队:现代化的前端框架
  • Anthropic:Claude Code 的开发支持

5.4 参考资源

项目地址:

结语

milkup 的开发是一次有趣的技术探索之旅。我们不仅实现了类似 Typora 的即时渲染模式,还在 AI 集成方面走在了前列。

作为一个开源项目,milkup 的成长离不开社区的支持。我们欢迎任何形式的贡献:代码、文档、建议、bug 报告。让我们一起打造一个更好的 Markdown 编辑器!

如果你对 milkup 感兴趣,欢迎:

  • ⭐ Star 项目
  • 🐛 提交 Issue
  • 🔧 贡献代码
  • 💬 加入讨论

让我们一起推动 Markdown 编辑器的发展!


作者: 德莱厄斯 & Claude Code 日期: 2026 年 2 月 版本: v1.0

昨天 — 2026年2月5日掘金 前端

彻底搞懂大文件上传:从幼儿园到博士阶段的全栈方案

2026年2月5日 15:58

第一层:幼儿园阶段 —— 为什么要搞复杂上传?

想象一下:你要把家里的**一万本书(10GB 文件)**搬到新家。

普通方案(简单上传):你试图一次性把一万本书塞进一个小三轮车。结果:车爆胎了(浏览器内存溢出),或者路上堵车太久,新家管理员等得不耐烦把门关了(请求超时)。

全栈方案(分片上传):你把书装成 100 个小箱子,一箱一箱运。即便路上有一箱丢了,你只需要补发那一箱,而不是重新搬一万本书。

为什么这样做?

  • 降低单次传输的数据量,避免内存溢出
  • 减少单个请求的处理时间,降低超时风险
  • 支持断点续传,提高上传成功率

第二层:小学阶段 —— 简单上传的极限

对于小于 10MB 的图片或文档,我们用 FormData。

前端 (Vue):input type="file" 获取文件,封入 FormData,通过 axios 发送。

后端 (Node):使用 multer 或 formidable 中间件接收。

数据库 (MySQL):切记! 数据库不存文件二进制流,只存文件的访问路径(URL)、文件名、大小和上传时间。

为什么数据库不存文件?

  • 文件体积太大,影响数据库性能
  • 数据库备份和迁移变得困难
  • 磁盘空间浪费,难以清理

简单上传的代码示例

// 前端
const formData = new FormData();
formData.append('file', fileInput.files[0]);
axios.post('/upload', formData);

// 后端
const upload = multer({ dest: 'uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
  // 处理上传
});

第三层:中学阶段 —— 分片上传 (Chunking) 的逻辑公式

这是面试的核心起点。请背诵流程:

切片:利用 File 对象的 slice 方法(底层是 Blob),将大文件切成 N 份。 标识:给每个片起个名字,通常是 文件名 + 下标。 并发发送:同时发送多个 HTTP 请求。 合并:前端发个"指令",后端把所有碎片按顺序合成一个完整文件。

为什么要用slice方法?

  • 它不会复制整个文件到内存,而是创建一个指向原文件部分的引用
  • 内存占用极小,适合处理大文件
  • 可以精确控制每个分片的大小

分片上传的实现逻辑

// 分片函数
function createFileChunks(file, chunkSize = 1024 * 1024 * 2) { // 2MB per chunk
  const chunks = [];
  let start = 0;
  
  while (start < file.size) {
    const end = Math.min(start + chunkSize, file.size);
    const chunk = file.slice(start, end); // 核心API
    chunks.push({
      blob: chunk,
      index: chunks.length,
      start,
      end
    });
    start = end;
  }
  
  return chunks;
}

第四层:大学阶段 —— 秒传与唯一标识 (MD5)

面试官问:"如果用户上传一个服务器已经有的文件,怎么实现秒传?"

核心:文件的"身份证"。不能用文件名,要用文件的内容生成 MD5 哈希值。

流程

  1. 前端用 spark-md5 计算文件哈希。
  2. 上传前先问后端:"这个 MD5 对应的文件你有没有?"
  3. 后端查 MySQL,如果有,直接返回成功——这就是秒传。

为什么用MD5而不是文件名?

  • 文件名可以重复,但内容不同的文件
  • 文件名可以被修改,但内容不变
  • MD5是内容的数字指纹,相同内容必定有相同的MD5

MD5计算示例

import SparkMD5 from 'spark-md5';

function calculateFileHash(file) {
  return new Promise((resolve) => {
    const fileReader = new FileReader();
    const spark = new SparkMD5.ArrayBuffer();
    let chunkIndex = 0;
    const chunkSize = 2097152; // 2MB
    
    const loadNext = () => {
      const start = chunkIndex * chunkSize;
      const end = Math.min(start + chunkSize, file.size);
      
      fileReader.readAsArrayBuffer(file.slice(start, end));
    };
    
    fileReader.onload = (e) => {
      spark.append(e.target.result);
      chunkIndex++;
      
      if (chunkIndex * chunkSize < file.size) {
        loadNext(); // 继续读取下一个分片
      } else {
        resolve(spark.end()); // 返回最终哈希值
      }
    };
    
    loadNext();
  });
}

第五层:博士阶段 —— 断点续传 (Resumable)

如果传到一半断网了,剩下的怎么办?

方案 A:前端记录(不可靠) 方案 B(推荐):后端 MySQL 记录已收到的分片序号。

流程

  1. 重新上传前,调用 checkChunks 接口
  2. 后端查库返回:{ uploadedList: [1, 2, 5] }
  3. 前端过滤掉已存在的序号,只发剩下的

为什么选择后端记录?

  • 前端存储不可靠(localStorage可能被清除)
  • 支持跨设备续传(从手机上传一半,从电脑继续)
  • 数据一致性更好,不容易出现脏数据

断点续传实现

// 检查已上传分片
async function checkUploadedChunks(fileHash) {
  const response = await api.checkChunks({ fileHash });
  return response.uploadedList || []; // 已上传的分片索引数组
}

// 过滤未上传的分片
const uploadedList = await checkUploadedChunks(fileHash);
const needUploadChunks = allChunks.filter(chunk => 
  !uploadedList.includes(chunk.index)
);

第六层:性能巅峰 —— 只有 1% 的人知道的 Worker 计算

浏览器是"单线程"的,JavaScript 引擎和页面渲染(DOM 树构建、布局、绘制)共用一个线程,如果你在主线程执行一个耗时 10 秒的循环(比如计算大文件的 MD5),浏览器会直接卡死。用户点不动按钮、动画停止、甚至浏览器弹出"页面无响应"。

屏幕刷新率通常是 60Hz,意味着浏览器每 16.7ms 就要渲染一帧。如果你的计算任务占据了这 16.7ms,页面就会掉帧、卡顿。

二、 什么是 Web Worker?(定义)

Web Worker 是 HTML5 标准引入的一项技术,它允许 JavaScript 脚本在后台线程中运行,不占用主线程。

它的地位: 它是主线程的"打工仔"。

它的环境: 它运行在另一个完全独立的环境中,拥有自己的全局对象(self 而不是 window)。

三、 Web Worker 能干什么?(职责与局限)

能干什么:

CPU 密集型计算: MD5 计算、加密解密、图像/视频处理、大数据排序。

网络请求: 可以在后台轮询接口。

不能干什么(面试必考点):

不能操作 DOM: 它拿不到 window、document、parent 对象。

不能弹窗: 无法使用 alert()。

受限通信: 它和主线程之间只能通过 "消息传递"(PostMessage)沟通。

四、 怎么干?(核心 API 实战)

我们要实现的目标是:在不卡顿页面的情况下,计算一个 1GB 文件的 MD5。

步骤 1:主线程逻辑(Vue/JS 环境)

主线程负责雇佣 Worker,并给它派活。

// 1. 创建 Worker 实例 (路径指向 worker 脚本)
const myWorker = new Worker('hash-worker.js');

// 2. 发送任务 (把文件对象传给 Worker)
myWorker.postMessage({ file: fileObject });

// 3. 接收结果 (监听 Worker 回传的消息)
myWorker.onmessage = (e) => {
    const { hash, percentage } = e.data;
    if (hash) {
        console.log("计算完成!MD5 为:", hash);
    } else {
        console.log("当前进度:", percentage);
    }
};

// 4. 异常处理
myWorker.onerror = (err) => {
    console.error("Worker 报错了:", err);
};

步骤 2:Worker 线程逻辑(hash-worker.js)

Worker 负责埋头苦干。

// 引入计算 MD5 的库 (Worker 内部引用脚本的方式)
importScripts('spark-md5.min.js');

self.onmessage = function(e) {
    const { file } = e.data;
    const spark = new SparkMD5.ArrayBuffer(); // 增量计算实例
    const reader = new FileReader();
    const chunkSize = 2 * 1024 * 1024; // 每次读 2MB
    let currentChunk = 0;

    // 分块读取的核心逻辑
    reader.onload = function(event) {
        spark.append(event.target.result); // 将 2MB 数据喂给 spark
        currentChunk++;

        if (currentChunk < Math.ceil(file.size / chunkSize)) {
            loadNext(); // 继续读下一块
            // 反馈进度
            self.postMessage({ 
                percentage: (currentChunk / Math.ceil(file.size / chunkSize) * 100).toFixed(2) 
            });
        } else {
            // 全部读完,生成最终 MD5
            const md5 = spark.end();
            self.postMessage({ hash: md5 }); // 完工,回传结果
        }
    };

    function loadNext() {
        const start = currentChunk * chunkSize;
        const end = Math.min(start + chunkSize, file.size);
        // 关键点:使用 slice 切片读取,避免一次性读入内存
        reader.readAsArrayBuffer(file.slice(start, end));
    }

    loadNext(); // 开始任务
};

五、 关键思路过程(给面试官讲出深度)

当你向面试官复述这个过程时,要按照这个逻辑链条:

实例化(New Worker):

"首先,我通过 new Worker 启动一个独立线程,将耗时的计算逻辑从主线程剥离。"

数据传输(PostMessage):

"主线程将 File 对象发送给 Worker。这里要注意,File 对象是 File 句柄,发送它并不会瞬间占据大量内存,因为它是基于 Blob 的,是惰性的。"

分块读取与增量计算(Chunked Hashing):

"这是最核心的一步。即便在 Worker 内部,我也不能直接读取整个文件(比如 5GB 读进内存会直接让 Worker 进程挂掉)。

我使用了 file.slice 配合 FileReader,每次只读取 2MB 数据。

配合 spark-md5 的 append 方法,将数据'喂'给计算引擎,处理完后,之前的内存块会被释放。"

异步通信(Messaging):

"在计算过程中,我不断通过 self.postMessage 向主线程发送计算进度,以便用户在界面上能看到动态的百分比。

最后计算完成,通过消息回传最终 MD5。"

六、 总结:核心 API 记事本

new Worker('path'): 开启招聘。

postMessage(data): 互发短信。

onmessage: 接收短信。

importScripts(): Worker 内部加载插件。

file.slice(): 物理上的"切片",MD5 不崩溃的秘诀。

FileReader.readAsArrayBuffer(): 将二进制内容读入内存进行计算。

七. 谈 内存管理 (Memory Management)

面试官可能会问: "在Worker内部如何避免内存溢出?"

回答: "在 Worker 内部,我没有使用 fileReader.readAsArrayBuffer(file) 直接读取整个文件。因为 4GB 的文件如果直接读入内存,V8 引擎会直接 OOM (Out of Memory) 崩溃。我采用了 '分块读取 -> 增量哈希' 的策略。利用 spark.append() 每次只处理 2MB 的数据,处理完后 V8 的垃圾回收机制会自动释放这块内存,从而实现用极小的内存开销处理极大的文件。"

八. 谈 抽样哈希 (性能杀手锏) —— 只有 1% 的人知道的黑科技

面试官: "如果文件 100GB,Worker 计算也要好几分钟,用户等不及怎么办?"

你的杀手锏: "如果对完整性校验要求不是 100% 严苛,我会采用 '抽样哈希' 方案:

  • 文件头 2MB 全部取样
  • 文件尾 2MB 全部取样
  • 中间部分:每隔一段距离取样几个字节

这样 10GB 的文件我也只需要计算 10MB 左右的数据,MD5 计算会在 1秒内 完成,配合后端校验,能实现'秒级'预判,极大提升用户体验。"

D. 总结:面试加分关键词

  • 增量计算 (Incremental Hashing):不是一次性算,是攒着算
  • Blob.slice:文件切片的核心底层 API
  • 非阻塞 (Non-blocking):Worker 的核心价值
  • OOM 预防:通过分块读取控制内存峰值
  • 抽样哈希:大文件快速识别的有效手段

"其实 Web Worker 也有开销,创建它需要时间和内存。对于几百 KB 的小文件,直接在主线程算可能更快;但对于大文件上传,Worker 是保证 UI 响应性 的唯一正确解。"

第七层:Node.js 后端压测 —— 碎片合并的艺术

当 1000 个切片传上来,Node 如何高效合并?

初级:fs.readFileSync。 瞬间撑爆内存,Node.js(V8)默认内存限制通常在 1GB~2GB 左右。执行 readFile 合并大文件,服务器会瞬间 Crash。

高级:fs.createReadStream 和 fs.createWriteStream。

利用"流(Stream)"的管道模式,边读边写,内存占用极低。

一、 什么是 Stream?(本质定义)

流(Stream)是 Node.js 提供的处理 流式数据 的抽象接口。它将数据分成一小块一小块(Buffer),像流水一样从一头流向另一头。

Readable(可读流): 数据的源头(如:分片文件)。

Writable(可写流): 数据的终点(如:最终合并的大文件)。

Pipe(管道): 连接两者的水管,数据通过管道自动流过去。

二、 能干什么?

在文件上传场景中,它能:

低内存合并: 无论文件多大(1G 或 100G),内存占用始终稳定在几十 MB。

边读边写: 读入一小块分片,立即写入目标文件,不需要等待整个分片读完。

自动背压处理(Backpressure): 如果写的速度慢,读的速度快,管道会自动让读取慢下来,防止内存积压。

三、 怎么干?(核心 API 与实战)

1. 核心 API 记事本

fs.createReadStream(path):创建一个指向分片文件的水龙头。

fs.createWriteStream(path, { flags: 'a' }):创建一个指向目标文件的接收桶。flags: 'a' 表示追加写入(Append)。

reader.pipe(writer):把水龙头接到桶上。

const fs = require('fs');
const path = require('path');

/**
 * @param {string} targetFile 最终文件存放路径
 * @param {string} chunkDir 分片临时存放目录
 * @param {number} chunkCount 分片总数
 */
async function mergeFileChunks(targetFile, chunkDir, chunkCount) {
    // 1. 创建一个可写流,准备写入最终文件
    // flags: 'a' 表示追加模式,如果文件已存在,就在末尾接着写
    const writeStream = fs.createWriteStream(targetFile, { flags: 'a' });

    for (let i = 0; i < chunkCount; i++) {
        const chunkPath = path.join(chunkDir, `${i}.part`);
        
        // 2. 依次读取每个分片
        const readStream = fs.createReadStream(chunkPath);

        // 3. 核心:通过 Promise 封装,确保"按顺序"合并
        // 必须等第 0 片合完了,才能合第 1 片,否则文件内容会错乱
        await new Promise((resolve, reject) => {
            // 将读取流的内容导向写入流
            // 注意:end: false 表示读完这一个分片后,不要关闭目标文件写入流
            readStream.pipe(writeStream, { end: false });
            
            readStream.on('end', () => {
                // 读取完后,删除这个临时分片(节省空间)
                fs.unlinkSync(chunkPath); 
                resolve();
            });
            
            readStream.on('error', (err) => {
                reject(err);
            });
        });
    }

    // 4. 所有分片读完了,手动关闭写入流
    writeStream.end();
    console.log("合并完成!");
}

为什么用Stream而不是readFileSync?

  • readFileSync会将整个文件加载到内存,大文件会爆内存
  • Stream是流式处理,内存占用固定
  • 性能更好,适合处理大文件

吊打面试官:

"在大文件合并的处理上,我绝对不会使用 fs.readFileSync。我会使用 Node.js 的 Stream API。

具体的实现思路是:

首先,创建一个 WriteStream 指向最终文件。

然后,遍历所有分片,通过 createReadStream 逐个读取。

关键点在于利用 pipe 管道将读流导向写流。为了保证文件的正确性,我会通过 Promise 包装实现串行合并。同时,设置 pipe 的 end 参数为 false,确保写入流在合并过程中不被提前关闭。

这种做法的优势在于:利用了 Stream 的背压机制,内存占用极低(通常只有几十 KB),即便是在低配置的服务器上,也能稳定合并几十 GB 的大文件。"

第八层:MySQL 表结构设计 (实战架构)

你需要两张表来支撑这个系统:

文件表 (Files):id, file_md5, file_name, file_url, status(0:上传中, 1:已完成)。 切片记录表 (Chunks):id, file_md5, chunk_index, chunk_name。

查询优化:给 file_md5 加唯一索引,极大提升查询速度。

-- 文件主表
CREATE TABLE files (
  id INT AUTO_INCREMENT PRIMARY KEY,
  file_md5 VARCHAR(32) UNIQUE NOT NULL COMMENT '文件MD5',
  file_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
  file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
  file_url VARCHAR(500) COMMENT '存储路径',
  status TINYINT DEFAULT 0 COMMENT '状态:0-上传中,1-已完成',
  upload_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_file_md5 (file_md5)
);

-- 分片记录表
CREATE TABLE chunks (
  id INT AUTO_INCREMENT PRIMARY KEY,
  file_md5 VARCHAR(32) NOT NULL,
  chunk_index INT NOT NULL COMMENT '分片索引',
  chunk_name VARCHAR(100) NOT NULL COMMENT '分片文件名',
  upload_status TINYINT DEFAULT 0 COMMENT '上传状态',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  FOREIGN KEY (file_md5) REFERENCES files(file_md5),
  INDEX idx_file_chunk (file_md5, chunk_index)
);

为什么file_md5要加索引?

  • 秒传查询时需要快速定位文件是否存在
  • 断点续传时需要快速获取某个文件的所有分片
  • 提升查询性能,避免全表扫描

一、 为什么要这么建表?(核心痛点)

如果不存数据库,只存本地文件系统,你会面临三个死穴:

断点续传没依据: 用户刷新网页,前端内存丢了,怎么知道哪些片传过?必须查库。

秒传无法实现: 10GB 的文件,怎么瞬间判断服务器有没有?全盘扫描物理文件?太慢!必须查索引。

并发合并风险: 多个请求同时触发合并逻辑怎么办?需要数据库的状态锁(Status)来控制。

二、 表结构详解:它们各司其职

1. 文件元数据表 (files) —— "身份证"

file_md5 (核心): 这是文件的唯一物理标识。不管文件名叫"高清.mp4"还是"学习资料.avi",只要内容一样,MD5 就一样。

status: 标记文件状态。

0 (Uploading): 还没传完或正在合并。

1 (Completed): 已经合并成功,可以直接访问。

file_url: 最终合并后的访问路径。

2. 切片记录表 (chunks) —— "进度表"

chunk_index: 记录这是第几个分片。

关系: 通过 file_md5 关联。一个 files 记录对应多个 chunks 记录。

三、 怎么建立联系?(逻辑关联图)

一对多关系:

files.file_md5 (1) <————> chunks.file_md5 (N)

秒传逻辑:

前端传 MD5 给后端。

SQL: SELECT file_url FROM files WHERE file_md5 = 'xxx' AND status = 1;

结果: 有记录?直接返回 URL(秒传成功)。没记录?进入下一步。

续传逻辑:

前端问:"这个 MD5 我传了多少了?"

SQL: SELECT chunk_index FROM chunks WHERE file_md5 = 'xxx';

结果: 后端返回 [0, 1, 2, 5],前端发现少了 3 和 4,于是只补传 3 和 4。

四、 关键思路与实战 SQL

1. 为什么加唯一索引(Unique Index)?

file_md5 必须是 UNIQUE。

面试点: "我给 file_md5 加了唯一索引,这不仅是为了查询快,更是一种业务兜底。在高并发下,如果两个用户同时上传同一个文件,数据库的唯一约束能防止产生两条重复的文件记录。"

2. 复合索引优化

在 chunks 表,我建议建立复合索引:INDEX idx_md5_index (file_md5, chunk_index)。

原因: 续传查询时,我们经常要查"某个 MD5 下的索引情况",复合索引能让这种搜索达到毫秒级。

五、 全流程演练(怎么干)

第一步:初始化 (Pre-check)

用户选好文件,计算 MD5,发给后端。

-- 尝试插入主表,如果 MD5 已存在则忽略(或返回已存在记录)
INSERT IGNORE INTO files (file_md5, file_name, file_size, status) 
VALUES ('abc123hash', 'video.mp4', 102400, 0);

第二步:分片上传 (Chunk Upload)

每收到一个片,存入 chunks 表。

-- 记录已收到的分片
INSERT INTO chunks (file_md5, chunk_index, chunk_name) 
VALUES ('abc123hash', 0, 'abc123hash_0.part');

第三步:合并触发 (Merge)

前端发合并指令,后端校验分片数量。

-- 检查分片是否齐全
SELECT COUNT(*) FROM chunks WHERE file_md5 = 'abc123hash';
-- 如果 Count == 总片数,开始 Stream 合并

第四步:完工 (Finish)

合并成功,更新主表。

-- 更新状态和最终路径
UPDATE files SET status = 1, file_url = '/uploads/2023/video.mp4' 
WHERE file_md5 = 'abc123hash';

六、 总结话术(吊打面试官版)

"我的数据库设计核心思路是 '内容标识胜于文件标识'。

我通过 files 表存储文件的 MD5 和全局状态,配合 UNIQUE 索引实现秒传的快速检索和并发控制。

通过 chunks 表记录每一个分片的到达状态,实现断点续传。

值得注意的细节是:

我使用了 BIGINT 来存储 file_size,因为 4GB 以上的文件 INT 会溢出。

我给 file_md5 做了索引优化,确保在百万级文件记录中,校验文件状态依然是 O(1) 的复杂度。

合并逻辑完成后,我会通过事务或状态锁更新 status 字段,确保数据的一致性。"

第九层:上帝视角 —— 并发控制 (Concurrency Control)

这一层是面试中的高光时刻。如果说 MD5 是为了"准确",Stream 是为了"稳健",那么并发控制就是为了"平衡"。

如果一个文件切了 1000 片,浏览器瞬间发出 1000 个请求,会导致浏览器崩溃或服务器宕机。

面试加分项:异步并发限制队列。

限制同时只有 6 个请求在跑(Chrome 的默认限制)。

async function sendRequest(tasks, limit = 6) {
    const pool = new Set();
    for (const task of tasks) {
        const promise = task();
        pool.add(promise);
        promise.then(() => pool.delete(promise));
        if (pool.size >= limit) await Promise.race(pool);
    }
}

为什么限制6个并发?

  • 浏览器对同一域名有最大连接数限制(通常为6)
  • 避免过多HTTP请求导致网络拥堵
  • 平衡上传速度和系统稳定性

一、 为什么要搞并发控制?(痛点)

  1. 浏览器"自保"机制: Chrome 浏览器对同一个域名的 HTTP 连接数有限制(通常是 6 个)。 如果你瞬间发起 1000 个请求,剩下的 994 个会处于 Pending(排队)状态。虽然不会直接崩溃,但会阻塞该域名的其他所有请求(比如你同时想加载一张图片,都要排在 900 多个切片后面)。

  2. 内存与性能压力: 前端: 1000 个 Promise 对象被创建,会瞬间吃掉大量内存。 后端(Node): 服务器瞬间接收 1000 个并发连接,磁盘 IO 会被占满,CPU 可能会飙升,甚至触发服务器的拒绝服务保护。

二、 什么是并发控制?(本质定义)

并发控制(Concurrency Control) 就像是一个"十字路口的红绿灯"或者"银行的排号机"。

它不改变任务总量。

它控制同一时刻正在运行的任务数量。

三、 怎么干?(核心逻辑公式)

我们要实现一个"工作池(Pool)",逻辑如下:

填满: 先一次性发出 limit(比如 6 个)个请求。

接替: 只要这 6 个请求中任何一个完成了,空出一个位子,就立刻补上第 7 个。

循环: 始终保持有 6 个请求在跑,直到 1000 个全部发完。

四、 代码详解:这 10 行代码值 5k 薪资

这是利用 Promise.race 实现的极其精妙的方案。

/**
 * @param {Array} tasks - 所有的上传任务(函数数组,执行函数才发请求)
 * @param {number} limit - 最大并发数
 */
async function sendRequest(tasks, limit = 6) {
    const pool = new Set(); // 正在执行的任务池
    const results = [];     // 存储所有请求结果

    for (const task of tasks) {
        // 1. 开始执行任务 (task 是一个返回 Promise 的函数)
        const promise = task();
        results.push(promise);
        pool.add(promise);

        // 2. 任务执行完后,从池子里删掉自己
        promise.then(() => pool.delete(promise));

        // 3. 核心:如果池子满了,就等最快的一个完成
        if (pool.size >= limit) {
            // Promise.race 会在 pool 中任何一个 promise 完成时 resolve
            await Promise.race(pool);
        }
    }

    // 4. 等最后剩下的几个也跑完
    return Promise.all(results);
}

五、 关键思路拆解(给面试官讲透)

  1. 为什么要传入 tasks 函数数组,而不是 Promise 数组? 回答: "因为 Promise 一旦创建就会立即执行。如果我传 [axios(), axios()],那并发控制就没意义了。我必须传 [() => axios(), () => axios()],这样我才能在循环里手动控制什么时候执行它。"

  2. Promise.race 起到了什么作用? 回答: "它充当了'阻塞器'。当池子满了,await Promise.race(pool) 会让 for 循环停下来。只有当池子里最快的一个请求完成了,race 才会解除阻塞,循环继续,从而发起下一个请求。"

  3. 为什么是 6 个? 回答: "这是基于 RFC 2616 标准建议和主流浏览器(Chrome/Firefox)的默认限制。超过 6 个,浏览器也会让剩下的排队。所以我们将并发数设为 6,既能榨干带宽,又能保持浏览器的响应顺畅。"

六、 进阶:如果请求失败了怎么办?(断点续传的结合)

在实战中,我们还需要加上重试机制。

// 伪代码:带重试的 task
const createTask = (chunk) => {
    return async () => {
        let retries = 3;
        while (retries > 0) {
            try {
                return await axios.post('/upload', chunk);
            } catch (err) {
                retries--;
                if (retries === 0) throw err;
            }
        }
    };
};

七、 总结

"在大文件上传场景下,盲目发起成百上千个切片请求会导致浏览器网络层阻塞和服务器压力过大。

我的解决方案是实现一个 '异步并发控制队列'。

核心思想是利用 Promise.race。我将并发数限制在 6 个。在 for 循环中,我维护一个 Set 结构的执行池。每当一个切片请求开始,就加入池子;完成后移出。当池子达到限制数时,利用 await Promise.race 阻塞循环,实现**'走一个,补一个'**的动态平衡。

这样做不仅遵守了浏览器的连接限制,更重要的是保证了前端页面的流畅度和后端 IO 的稳定性。如果遇到失败的请求,我还会配合重试逻辑和断点续传记录,确保整个上传过程的强壮性。"

第十层:终极回答策略 (架构师收网版)

如果面试官让你总结一套"完美的文件上传方案",请按照五个维度深度收网:

用户体验层 (Performance):

采用 Web Worker 开启后台线程,配合 Spark-MD5 实现增量哈希计算。

核心亮点:通过"分块读取 -> 增量累加"策略避免 4GB+ 大文件导致的浏览器 OOM(内存溢出),并利用 Worker 的非阻塞特性确保 UI 响应始终保持 60fps。对于超大文件,可选抽样哈希方案,秒级生成指纹。

传输策略层 (Strategy):

秒传:上传前预检 MD5,实现"内容即路径"的瞬间完成。

断点续传:以后端存储为准,通过接口查询 MySQL 中已存在的 chunk_index,前端执行 filter 增量上传。

并发控制:手写异步并发池,利用 Promise.race 实现"走一个,补一个"的槽位控制(限制 6 个并发),既榨干带宽又防止 TCP 阻塞及服务器 IO 爆表。

后端处理层 (Processing):

流式合并:放弃 fs.readFile,坚持使用 Node.js Stream (pipe)。

核心亮点:利用 WriteStream 的追加模式与读流对接,通过 Promise 串行化 保证切片顺序,并依靠 Stream 的背压(Backpressure)机制自动平衡读写速度,将内存占用稳定在 30MB 左右。

持久化设计层 (Database):

文件元数据管理:MySQL 记录文件 MD5、状态与最终存储 URL。

查询优化:给 file_md5 加 Unique Index(唯一索引),不仅提升秒传查询效率,更在数据库层面兜底高并发下的重复写入风险。

安全防护层 (Security):

二进制校验:不信任前端后缀名,后端读取文件流前 8 字节的 Magic Number(魔数) 校验二进制头,防止伪造后缀的木马攻击。

总结话术:一分钟"吊打"面试官

"在处理大文件上传时,我的核心思路是 '分而治之' 与 '状态持久化'。

在前端层面,我通过 Web Worker 配合增量哈希 解决了计算大文件 MD5 时的 UI 阻塞和内存溢出问题。利用 Blob.slice 实现逻辑切片后,我没有盲目发起请求,而是设计了一个基于 Promise.race 的并发控制队列,在遵守浏览器 TCP 限制的同时,保证了传输的平稳性。

在状态管理上,我采用 '后端驱动'的断点续传方案。前端在上传前会通过接口查询 MySQL 获取已上传分片列表,这种方案比 localStorage 更可靠,且天然支持跨设备续传。

在后端处理上,我深度使用了 Node.js 的 Stream 流 进行分片合并。通过管道模式与背压处理,我确保了服务器在处理几十 GB 数据时,内存水位依然保持在极低范围。

在安全性与严谨性上,我通过 MySQL 唯一索引 处理并发写冲突,通过 文件头二进制校验 过滤恶意文件。

这套方案不仅是功能的堆砌,更是对浏览器渲染机制、网络拥塞控制、内存管理以及服务器 IO 瓶颈的综合优化方案。"

💡 面试官可能追问的"补丁"

追问:如果合并过程中服务器断电了怎么办?

回答: 由于我们是通过数据库记录 status 的,合并未完成前 status 始终为 0。服务器重启后,可以根据 status=0 且切片已齐全的记录,重新触发合并任务,或者由前端下次触发预检时重新合并。

追问:切片大小设为多少合适?

回答: 通常建议 2MB - 5MB。太小会导致请求碎片过多(HTTP 头部开销大),太大容易触发网络波动导致的单个请求失败。

追问:上传过程中进度条如何实现?

回答: 两个维度。一是 MD5 进度(Worker 返回),二是上传进度(使用 axios 的 onUploadProgress 监控每个分片,结合已上传分片数量计算加权平均进度)。

生成器下(生成器异步)

2026年2月5日 15:56

生成器下(生成器异步)

上一章讨论了生成器作为一个产生值的机制的特性,但生成器远远不止这些,更多的时候我们关注的是生成器在异步编程中的使用

生成器 + Promise

简略的描述,生成器异步就是我们在生成器中yield出一个 Promise,然后在Promise完成的时候重新执行生成器的后续代码

function foo(x, y) {
    return request(
    "http://some.url?x=1y=2")
}

function *main() {
    try {
        const text = yield foo(11, 31);
        console.log(text);
    } catch (err) {
        console.error( err );
    }
}
const it = main();

const p = it.next().value;

p.then(function (text) {
    it.next(text);
}, function (error) {
    it.throw(error);
});

上述代码是一个生成器+Promise的例子, 要想驱动器我们的main生成器,只需要在步骤后then继续执行即可,这段代码有不足之处,就是无法自动的帮助我们去实现Promise驱动生成器,可以看到上面我还是手动的写then回调函数去执行生成器, 我们需要不管内部有多少个异步步骤,都可以顺序的执行, 而且不需要我们有几个步骤就写几个next这么麻烦,我们完全可以把这些逻辑隐藏于某个工具函数之内,请看下面的例子

//第一个参数是一个生成器,后续的参数是传递给生成器的
//返回一个Promise
//当返回的Promise决议的时候,生成器也就执行完成了
function run(gen, ...args) {
    const it = gen.apply(this, args);
    
    
    return Promise.resolve()
      .then(function handleNext(value) {
        const ans = it.next(value);//执行
        
        return (function handleResult(ans) {
            //ans是执行结果
            if (ans.done) {
                return ans.value;//执行完毕
            }
            //没有执行完毕,我们需要继续异步下去
            return Promise.resolve(ans.value)
              .then(handleNext, function handleError(err) {
                return Promise.resolve(it.throw(err))
                  .then(handleResult);
            });           
        })(ans);
    })
}

下面的代码演示如何使用这个run函数,

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <img src="" alt="" data-action="show-dog1">
  <img src="" alt="" data-action="show-dog2">
  <img src="" alt="" data-action="show-dog3">
  <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>//引入axios
  <script src="./run.js"> //run.js内容就是上例中定义的函数
  </script>
  <script>

    function *main () {
      const {data: { message }} = yield axios.get("https://dog.ceo/api/breeds/image/random");//一个友爱的能获得狗狗图片链接的api网站, 可以访问其官网https://dog.ceo/dog-api/

      document.querySelector("[data-action='show-dog1']").src = message;

      const {data: { message: message2 }} = yield axios.get("https://dog.ceo/api/breeds/image/random");

      document.querySelector("[data-action='show-dog2']").src =message2;

      const {data: { message: message3 }} = yield axios.get("https://dog.ceo/api/breeds/image/random");

      document.querySelector("[data-action='show-dog3']").src =message3;
    }

    try {
      run(main)
       .then((ans) => {
        console.log("ans", ans); //这里接受生成器最后return的值,在该例中为undefined
       });
    } catch (err) {
      console.log(err);
    }

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

run会运行你的生成器,直到结束,这样我们在生成器中就统一了异步和同步,所有的代码都可以以顺序的步骤执行,而我们不必在于是异步还是同步,完全可以避免写异步回调代码,

Async Await

在ES8中引入了async, await,这意味着我们也不需要使用写生成异步和run了,Async Await顺序的代码格式避免回调异步带来的回调地狱,回调信任问题一系列问题,如果按时间描述js异步的发展,大概就是从回调异步时代 到Promise ,然后生成器被大神发掘出来了,发现Promise + 生成器 有c#的async/await的效果,js官方觉得这是一个很好的用法,所以在es8中出了async/await, async/await就是Promise + 生成器的语法糖,可以认为promise + 生成器是其基石。下面再回到生成 + Promise的异步的解析

在生成器中并发执行Promise

在上面的写法中是没办法并发执行的, 想要实现并发

function *foo() {
    const p1 = request("https://some.url.1");
    const p2 = request("https://some.url.2");
    
    const r1 = yield p1;
    const r2 = yield p2;
    
    const r3 = yield request("https://some.url.3/?v=" + r1 + "," + r2);
    
    console.log(r3);   
}

run(foo);

这里实现并发的办法就是让异步请求先出发,等所有请求都执行后我们再yield Promise,也可以使用Promise.all实现并发, 下面覆写这个例子

function *foo() {
    const result = yield Promise.all(
    request("https://some.url.1"),
    request("https://some.url.2"));
    
    const r3 = yield request("https://some.url.3/?v=" + r1 + "," + r2);
    console.log(r3);
}

我们还可以把Promise.all封装在一个函数中,使得foo生成器的简洁性,这样从生成器的角度看并不需要关系底层的异步是怎么实现的,我们实现生成器 + Promise要尽量把异步逻辑封装在底层

生成器委托

怎么在一个生成器中调用另一个生成器,并且重要的是,对待调用的生成器内的异步代码就像直接写在生成器内部一样(也就是说也能顺序执行调用的生成器内部的代码不管同步异步)

function *foo() {
    const r2 = yield request("https://some.url.2");
    const r3 = yield request("htpps://some.url.3/?v=" + r2);
    
    return r3;
}

function *bar() {
    const r1 = yield request("http://some.url.1");
    
    //通过run 函数调用foo
    const r3 = yield run(foo);
    
    console.log(r3);
}

run(bars);

为什么可以这样,因为我们run函数是返回一个Promise的,就像上面说的,我们把Promise的细节封装了,通过run(foo)你只需要直到两件事,run(foo)返回一个Promise,既然是Promise,我们就yield就可以了, 同时run(foo)产生的Promise完成的时候就是foo完成的时候,决议值就是r3,如果对前面说的感到不理解,我再简单的补充一点,就是run本意就是返回一个Promise,,既然是Promise,我们当前可以像yield request那样yield它而不用管底层细节, es有一种称为生成器委托的语法 yield*,先看下面一个简单的用法介绍yield *,

function *foo () {
    console.log("*foo() starting");
    yield 3;
    yield 4;
    console.log("*foo() finished");
}

function *bar() {
    yield 1;
    yield 2;
    yield *foo();
    yield 5;
}


const it = bar();

it.next().value // 1
it.next().value //2
it.next().value 
// *foo() starting
// 3
it.next().value
//4
it.next().value
//*foo() finished
// 5

当我们消费完bar的前两个yield后,再next,这个时候,控制权转给了foo,这个时候控制的是foo而不是bar,这也就是为什么称之为委托,因为bar把自己的迭代控制委托给了foo,要是在控制foo的时候一直不next,bar也没办法进行了,当it迭代器控制消耗完了整个foo后,控制权就会自动转回bar,我们现在可以使用生成器委托覆写上述生成器委托下的第一个例子,在那个例子中使用了run(foo);我们现在可以让生成器更 `干净一点`

function *foo() {
    const r2 = request("https://some.url.2");
    const r3 = request("https://some.url.3?v=" + r2);
    
    return r3;
}


function *bar() {
    const r1 = request("https://some.url.1");
    
    const r3 = yield *foo();
    
    console.log(r3);
}

run(bar);

生成器委托其实就相当于函数调用,用来组织分散的代码,

消息委托

生成器委托的作用不只在于控制生成器,也可以用它实现双向消息传递工作,请看下面一个例子

function *foo() {
    console.log("inside *foo(): ", yield "B");
    
    console.log("inside *foo():", yield "C");
    
    return "D";
}

function *bar() {
    console.log("inside *bar():", yield "A");
    
    console.log("inside *bar(): ", yield *foo());
    
    console.log("inside *bar(): ", yield "E");
    
    return "F";
}

const it = bar();

console.log("outSide:", it.next().value);
//outside: "A"

console.log("outside", it.next(1).value);
//inside *bar:  1
//outside: B;

console.log("outside", it.next(2).value);
//inside *foo: 2
// outside: C


console.log("outside:", it.next(3).value);
//inside *foo 3
//inside *bar: D
//outside: "E"

console.log("outside:", it.next(4).value);
//inside *bar: 4
//outside: E

在这里我们就实现了和委托的生成器传递消息,外界传入2,3都传递到了foo中,foo yield的B,C页传递给了外界迭代器控制方(it),除此之外错误和异常也可以被双向传递,

function *foo() {
    try {
        yield "B";
    } catch (err) {
        console.log("error caught inside *foo():", err);
    }
    
    yield "C";
    
    throw "D";
}
functtion *baz() {
    throw "F";
}
function *bar() {
    yield "A";
    
    try {
        yield *foo();
    } catch (err) {
        console.log("error caugth inside *bar(): ", err);
    }
    
    yield "E";
    
    yield *baz();
    
    yield "G";
}


const it = bar();


console.log("outside:", it.next().value);
//outside:  A;

console.log("outside:", it.next(1).value);
//outside: B

console.log("outside:", it.throw(2).value);//外界向内抛入一个错误
//error caugth inside *foo () 2
//"outside: C"

console.loog("ouside:",it.next(3).value);
//error caugth insde *bar "D";
// ouside: E

try {
    console.log("outside", it.next(4).value);
} catch(err) {
    console.log("error cautgh outside:", err);
}
//error caugth ouside: F
//控制器结束

通过以上,我们可以总结出,当时有生成器委托的时候,和正常生成器其实没有什么区别对于外界的控制器(it)来说,它不在乎控制的是foo还是bar抑或是baz,它把这些看作是一个生成器,就像和一个生成器那样和其内部的各生成器进行双向的信息传递

生成器并发

在上面我们讨论过生成器并发Promise,在这里我们讨论并发生成器,

const res = [];  
function *reqData(url) {
    res.push(yield request(url));
}

 const it1 = reqData("https://some.url.1");
 const it2 = reqData("https://some.url.2");

const p1 = it1.next();
const p2 = it2.next();

p1.
  then(function (data) {
    it1.next(data);
    return p2;
}).then(function (data) {
    it2.next(data);
})

这里的生成器是并发的,并且通过then给这两个生成器安排好了结果位置,但是,这段代码手工程度很高,没办法让生成器自动的协调,

看下面一个例子



function runAll(...args) {
    const result = [];
    //同步并发执行Promise
    args = args.forEach(function (item) {
        item = item();
        item.next();
        return item;
    });
    
   
   function * fn() {
        args.forEach(function (item,idx) {
            let p = item.next();
          res[idx] = yield p; 
        });
    };
    
    run(fn);
    
    return result;
}
runAll(function *() {
    const p1 = request("....");
    
    yield;
    
    res.push(yield p1);
}, function *() {
    const p2 = request(".....");
    
    yield;
    
    res.push(yield p2);
});

这个例子避免了手动的去书写Promise的then链,但是这样的写法也不算是真正实现生成器并发,真正的runAll很复杂,所以没有提出

总结

生成器异步就是生成器加Promise,要求yield出一个Promise,由外部控制,但在现在完全可以使用async/await

❌
❌