普通视图

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

JavaScript 闭包经典问题:为什么输出 10 次 i=10

作者 卷帘依旧
2026年3月30日 20:07

JavaScript 闭包经典问题:为什么输出 10 次 i=10

问题代码

先观察以下代码,思考输出结果:

function f() {
    for(var i = 0; i < 10; i++) {
        setTimeout(() => {
            console.log('i=', i)
        });
    }
}

f();

输出结果:

i= 10
i= 10
i= 10
...(共 10 次)

执行过程详解

第一步:var 变量的作用域

for(var i = 0; i < 10; i())
    ↑
    └── var 声明的变量是函数作用域
        整个函数 f 内都能访问这个 i

第二步:循环执行过程

循环次数     i 的值    循环条件 (i < 10)
---------------------------------------
第 1 次      0         ✓ 通过
第 2 次      1         ✓ 通过
...
第 10 次     9         ✓ 通过
             10        ✗ 不通过,循环结束

循环结束后:i = 10

第三步:创建 10 个回调函数

for(var i = 0; i < 10; i++) {
    setTimeout(() => {
        console.log('i=', i)  // ← 所有回调共享同一个 i
    });
}

每次循环创建一个箭头函数,都通过闭包引用变量 i

第四步:异步执行时序

时间轴:
─────────────────────────────────────────
| 同步执行阶段        | 异步执行阶段        |
─────────────────────────────────────────
for 循环完成      setTimeout 回调执行
i 递增到 10       读取 i 的值(此时 i=10)
                  输出 10i=10
─────────────────────────────────────────

核心原因

三个关键点

  1. var 是函数作用域

    • 不是块级作用域
    • 整个函数内只有一个 i 变量
  2. 闭包共享变量

    • 10 个箭头函数都引用同一个 i
    • 不是创建 10 个独立的 i 副本
  3. setTimeout 异步执行

    • 回调函数放入任务队列延迟执行
    • 执行时循环已结束,i 已经是 10

图示理解

变量 i 的生命周期:
─────────────────────────────→ 时间
     0 1 2 3 4 5 6 7 8 9  10
     └───┬───┘ └───┬───┘
         │                │
     同步循环执行        循环结束
                        i=10

回调函数 1:  ────────────────────→ 读取 i (10)
回调函数 2:  ────────────────────→ 读取 i (10)
...
回调函数 10: ────────────────────→ 读取 i (10)

解决方案

方案 1:使用 let(推荐)✨

function f() {
    for(let i = 0; i < 10; i++) {
        setTimeout(() => {
            console.log('i=', i)
        });
    }
}

原理: let 是块级作用域,每次循环创建新的 i 绑定

输出: i= 0i= 9 各一次


方案 2:IIFE 立即执行函数

function f() {
    for(var i = 0; i < 10; i++) {
        (function(j) {
            setTimeout(() => {
                console.log('i=', j)
            });
        })(i);
    }
}

原理: 通过函数参数保存每次循环的 i 值


方案 3:传递参数给 setTimeout

function f() {
    for(var i = 0; i < 10; i++) {
        setTimeout((j) => {
            console.log('i=', j)
        }, 0, i);
    }
}

原理: setTimeout 的第三个参数会传递给回调函数


知识点总结

概念 说明
var 作用域 函数作用域,非块级作用域
let 作用域 块级作用域,每次循环创建新绑定
闭包 函数可以访问其声明时所在作用域的变量
异步 setTimeout 的回调会延迟执行
共享引用 同一作用域的闭包引用同一个变量

一句话总结

var 的函数作用域 + 闭包共享变量 + setTimeout 异步执行 = 所有回调读取到循环结束后的同一个 i 值(10)

Three.js × Blender:从建模到 Web 3D 的完整工作流深度解析

作者 柳杉
2026年3月30日 19:52

一名同时精通 Blender 建模与 Three.js 渲染的工程师,带你打通从艺术创作到 Web 实时渲染的全链路。


前言

很多开发者在学习 Three.js 时,习惯用代码"画"出几何体——一个 BoxGeometry,一个 SphereGeometry,就此打住。而建模师则沉浸在 Blender 的雕刻与材质世界里,对 Web 端渲染一无所知。

真正有价值的 3D Web 项目,往往需要这两者的深度结合:Blender 负责内容生产,Three.js 负责实时呈现。本文将从工作流、格式选择、材质映射、性能优化到动画同步,系统拆解这套协作体系的每一个关键节点。

图片


一、为什么选择 Blender + Three.js?

Blender 的优势

Blender 是目前最强大的开源 3D 创作套件,集建模、雕刻、UV 展开、材质编辑、骨骼绑定、动画、渲染于一体。它的 PBR(基于物理的渲染)材质系统与 Three.js 的 MeshStandardMaterial 有高度的理论对应关系,这使得材质迁移成为可能,而非只能靠"近似"。

Three.js 的优势

Three.js 是 WebGL 的高级封装,让开发者无需手写 GLSL Shader 就能实现实时 3D 渲染。它支持 glTF 2.0 标准、骨骼动画、变形动画、PBR 材质、HDR 环境贴图,在浏览器端提供接近原生的视觉质量。

两者的天然契合点

图片图片

特性 Blender Three.js
材质模型 Principled BSDF MeshStandardMaterial
动画系统 Action / NLA AnimationMixer
导出格式 glTF 2.0 原生支持 GLTFLoader 原生支持
坐标系 Y-up(可配置) Y-up
PBR 贴图 BaseColor / Roughness / Metallic / Normal map / roughnessMap / metalnessMap / normalMap

glTF 2.0 是连接两者的"通用语言",它由 Khronos Group 制定,Blender 对其有完整的原生导出支持,Three.js 也将其作为首推格式。


二、Blender 建模的 Web 友好实践

在 Blender 中建模时,如果目标是 Web 端实时渲染,需要从一开始就考虑"面向 Web 的建模规范",而不是按离线渲染的标准来做。

2.1 多边形控制:少即是多

离线渲染可以承受千万面模型,但 Web 端 GPU 对三角面数极为敏感。经验值如下:

图片

  • 单个主体模型:5,000 ~ 50,000 三角面(取决于镜头距离)
  • 背景/远景物体:500 ~ 2,000 三角面
  • 整个场景:尽量控制在 300,000 三角面以内(移动端减半)

实用技巧:

在 Blender 中使用 Decimate 修改器可以智能减面,
Ratio 参数从 1.0 逐渐降低,观察模型形变程度,
通常 0.5 ~ 0.7 可在不明显损失细节的前提下大幅减面。

高模细节应通过 法线贴图(Normal Map)  烘焙到低模上,而不是保留在几何体中。这是 Web 3D 最核心的优化手段之一。

2.2 UV 展开的关键性

Three.js 中所有贴图都依赖 UV 坐标。Blender 中的 UV 展开质量直接决定贴图利用率和最终画质。

UV 展开建议:

  1. 使用 Smart UV Project 快速处理机械类硬表面模型
  2. 有机体(角色、生物)使用 Mark Seam + Unwrap 手动控制接缝位置
  3. UV 岛之间保留 至少 4 像素的边距(Margin)  ,防止贴图渗色
  4. 避免 UV 重叠(除非是对称模型且确定共用贴图)

2.3 坐标系与朝向

Blender 默认坐标系:Z 轴朝上,Y 轴朝前。 Three.js 坐标系:Y 轴朝上,Z 轴朝向观察者。

图片图片

在 glTF 导出时,Blender 会自动进行坐标系转换,因此通常不需要手动旋转。但如果你在 Blender 中对模型进行了非标准旋转,导出后可能在 Three.js 中出现方向错误。

最佳实践:  在 Blender 中完成所有变换后,按 Ctrl+A → All Transforms 应用变换,确保对象的 Location/Rotation/Scale 归零。


三、PBR 材质:从 Blender 到 Three.js 的精确映射

这是整个工作流中最需要深入理解的环节。

3.1 Principled BSDF 与 MeshStandardMaterial 的对应关系

Blender 的 Principled BSDF 节点是工业级 PBR 着色器,其核心参数与 Three.js 的 MeshStandardMaterial 有如下对应:

图片图片

Principled BSDF 参数 Three.js 对应属性 说明
Base Color color / map 基础颜色/漫反射贴图
Metallic metalness / metalnessMap 金属度(0=非金属,1=纯金属)
Roughness roughness / roughnessMap 粗糙度(0=镜面,1=完全漫反射)
Normal Map normalMap 法线贴图(切线空间)
Emission emissive / emissiveMap 自发光
Alpha opacity / alphaMap 透明度
Ambient Occlusion aoMap 环境遮蔽(需要第二套 UV)

注意:  glTF 2.0 格式将 Roughness 存储在贴图的 G 通道,Metallic 存储在 B 通道,合并为一张 metallicRoughnessMap。Three.js 的 GLTFLoader 会自动处理这个细节,开发者无需干预。

3.2 在 Blender 中烘焙 PBR 贴图

当模型使用了复杂的程序化材质(Noise、Voronoi、Wave 节点等)时,需要将其"烘焙"成位图才能在 Web 端使用。

烘焙流程:

图片

  1. 选中模型,进入 Cycles 渲染引擎(烘焙必须使用 Cycles)
  2. 新建一张空白图像节点(Image Texture),不连接任何节点,只需选中它
  3. 在 Render Properties → Bake 中选择烘焙类型:
    • Diffuse(取消 Direct/Indirect,仅勾选 Color)→ Base Color 贴图
    • Roughness → Roughness 贴图
    • Normal(Space: Tangent)→ 法线贴图
    • Combined → 全局光照效果烘焙(含 AO、阴影,适合静态场景)
  4. 点击 Bake,等待计算完成
  5. 导出图像为 PNG(法线图)或 JPEG(颜色图,注意法线图不能用 JPEG 有损压缩!)

这里我推荐一个B站的烘焙教材,很不错的。

链接如下:

(www.bilibili.com/video/BV185…)

3.3 法线贴图的空间问题

Blender 默认导出**切线空间(Tangent Space)**法线贴图,Three.js 也默认使用切线空间法线,两者一致,无需额外处理。

但如果你在 Blender 中烘焙了**对象空间(Object Space)**法线贴图,则需要在 Three.js 中将 normalMapType 设置为 THREE.ObjectSpaceNormalMap

material.normalMapType = THREE.ObjectSpaceNormalMap;

四、glTF 导出配置详解

glTF 是连接 Blender 和 Three.js 的核心桥梁,正确的导出配置至关重要。

4.1 .gltf vs .glb

格式 说明 适用场景
.gltf JSON 文本 + 外部 .bin + 外部贴图 调试、需要单独管理资产
.glb 全部打包为单一二进制文件 生产环境,减少 HTTP 请求

图片

推荐:生产环境使用 .glb,加载更快,管理更简单。

4.2 Blender 导出设置

图片

File → Export → glTF 2.0,关键设置:

图片图片

Format: glTF Binary (.glb)

 Include:
  - Selected Objects(只导出选中对象,避免导出无关物体)
  - Custom Properties(可传递自定义属性到 Three.js)

 Transform:
  - Y Up(确保坐标系正确)

 Geometry:
  - Apply Modifiers(应用所有修改器,但注意会破坏骨骼绑定)
  - UVs / Normals / Tangents / Vertex Colors
  - Loose Edges / Points(通常取消勾选)

 Animation:
  - Animations(导出动画数据)
  - Skinning(导出骨骼绑定)
  - Shape Keys(导出变形目标/Morph Targets)
  - NLA Strips(从非线性动画编辑器导出所有 Action)

 Draco Mesh Compression:
  开启后可大幅压缩几何体数据,但 Three.js 需要额外加载 DRACOLoader

4.3 在 Three.js 中加载 glTF

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader.js';

// 配置 Draco 解码器(如果导出时开启了 Draco 压缩)
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('/draco/'); // 需要将 draco 解码器文件放在此路径

const loader = new GLTFLoader();
loader.setDRACOLoader(dracoLoader);

loader.load(
  '/models/scene.glb',
  (gltf) => {
    const model = gltf.scene;

    // 遍历所有网格,开启阴影
    model.traverse((node) => {
      if (node.isMesh) {
        node.castShadow = true;
        node.receiveShadow = true;

        // 如果使用 HDR 环境贴图,开启环境光反射
        node.material.envMapIntensity = 1.0;
      }
    });

    scene.add(model);
  },
  (progress) => {
    console.log(`加载进度: ${(progress.loaded / progress.total * 100).toFixed(1)}%`);
  },
  (error) => {
    console.error('加载失败:', error);
  }
);

五、动画系统深度对接

Blender 的动画系统与 Three.js 的 AnimationMixer 是整个工作流中最复杂的对接点。

5.1 Blender 动画类型与 Three.js 对应

骨骼动画(Armature Animation)

最常见的角色动画类型。Blender 中为骨骼创建 Action,绑定到 Mesh。导出后 Three.js 通过 SkinnedMesh 和 AnimationClip 驱动。

// 加载后获取所有动画
const animations = gltf.animations; // AnimationClip[]
const mixer = new THREE.AnimationMixer(gltf.scene);

// 播放特定动画(如 "Walk" Action)
const walkClip = THREE.AnimationClip.findByName(animations, 'Walk');
const walkAction = mixer.clipAction(walkClip);
walkAction.play();

// 在渲染循环中更新
const clock = new THREE.Clock();
function animate() {
  requestAnimationFrame(animate);
  const delta = clock.getDelta();
  mixer.update(delta); // 关键:每帧更新动画混合器
  renderer.render(scene, camera);
}
animate();

变形目标动画(Shape Keys / Morph Targets)

适用于面部表情、布料变形等。Blender 中的 Shape Key 导出为 glTF 的 Morph Target。

// 访问变形目标
const mesh = gltf.scene.getObjectByName('Face');
console.log(mesh.morphTargetDictionary); // { "Smile": 0, "Blink": 1, ... }

// 直接设置变形权重(0~1)
mesh.morphTargetInfluences[0] = 0.8; // 80% 的 Smile 表情

// 或通过 AnimationMixer 驱动
const smileClip = THREE.AnimationClip.findByName(animations, 'Smile');
mixer.clipAction(smileClip).play();

5.2 动画过渡:crossFadeTo

游戏和交互应用中,动画之间的平滑过渡至关重要:

图片

let currentAction = idleAction;

function transitionTo(newAction, duration = 0.3) {
  if (currentAction === newAction) return;

  newAction.reset();
  newAction.play();
  currentAction.crossFadeTo(newAction, duration, true);
  currentAction = newAction;
}

// 按键触发动画切换
document.addEventListener('keydown', (e) => {
  if (e.code === 'Space') transitionTo(runAction);
});

document.addEventListener('keyup', (e) => {
  if (e.code === 'Space') transitionTo(idleAction);
});

5.3 NLA 编辑器与多 Action 管理

在 Blender 中,推荐使用 NLA(Non-Linear Animation)编辑器将不同 Action(Idle、Walk、Run、Attack)管理在同一骨骼上。导出时勾选 NLA Strips,Three.js 端会收到所有 Action 作为独立的 AnimationClip

Blender 操作:

  1. 在 Dope Sheet 中切换到 NLA Editor
  2. 将 Action 下压为 NLA Strip
  3. 每个 Strip 对应一个独立动画
  4. 确保每个 Action 有清晰的命名(会直接成为 Three.js 中 clip.name

六、灯光与环境:让 Web 端复现 Blender 的视觉效果

图片

6.1 HDR 环境贴图

Blender 中使用 HDR 环境光照(World → Environment Texture),Three.js 中同样支持,且是让模型在 Web 端看起来"专业"的关键。

import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader.js';

const pmremGenerator = new THREE.PMREMGenerator(renderer);
pmremGenerator.compileEquirectangularShader();

new RGBELoader().load('/hdr/studio_small.hdr', (texture) => {
  const envMap = pmremGenerator.fromEquirectangular(texture).texture;

  scene.environment = envMap;  // 影响所有 MeshStandardMaterial 的反射
  scene.background = envMap;   // 可选:将 HDR 作为背景

  texture.dispose();
  pmremGenerator.dispose();
});

推荐资源:Poly Haven(polyhaven.com/hdris) 提供大量免费高质量 HDR 文件。

6.2 灯光类型对应

图片

Blender 灯光 Three.js 等价
Sun DirectionalLight
Point PointLight
Spot SpotLight
Area RectAreaLight
World (HDR) scene.environment

注意:  glTF 导出支持 Point、Spot、Directional 灯光(需开启 KHR_lights_punctual 扩展,Blender 默认勾选),Area Light 不支持直接导出,需在 Three.js 端手动添加。

6.3 阴影质量配置

// 渲染器阴影配置
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // 柔和阴影

// 方向光阴影配置
const dirLight = new THREE.DirectionalLight(0xffffff, 1);
dirLight.castShadow = true;
dirLight.shadow.mapSize.width = 2048;  // 阴影贴图分辨率
dirLight.shadow.mapSize.height = 2048;
dirLight.shadow.camera.near = 0.1;
dirLight.shadow.camera.far = 50;
dirLight.shadow.camera.left = -10;
dirLight.shadow.camera.right = 10;
dirLight.shadow.camera.top = 10;
dirLight.shadow.camera.bottom = -10;
dirLight.shadow.bias = -0.001; // 防止阴影痤疮(Shadow Acne)

七、性能优化:从 Blender 到浏览器的极致压缩

图片

7.1 纹理压缩:KTX2 + Basis Universal

传统 JPEG/PNG 贴图在 GPU 中需要解码为原始像素,占用大量显存。KTX2/Basis Universal 是可以直接在 GPU 上保持压缩状态的格式,显存占用可降低 4~8 倍。

工具链:

# 安装 KTX-Software
# 将 PNG 转换为 KTX2(UASTC 模式,高质量)
toktx --uastc --uastc_rdo_l 4 output.ktx2 input.png

# Three.js 中加载 KTX2
import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';

const ktx2Loader = new KTX2Loader()
  .setTranscoderPath('/basis/')
  .detectSupport(renderer);

loader.setKTX2Loader(ktx2Loader);

7.2 实例化渲染:InstancedMesh

场景中有大量相同模型(树木、石头、草地)时,使用 InstancedMesh 可以将数千次 Draw Call 合并为一次:

// 在 Blender 中建好单个树木模型,导出后:
const treeGeometry = treeModel.geometry;
const treeMaterial = treeModel.material;

const COUNT = 1000;
const instancedMesh = new THREE.InstancedMesh(treeGeometry, treeMaterial, COUNT);

const dummy = new THREE.Object3D();
for (let i = 0; i < COUNT; i++) {
  dummy.position.set(
    (Math.random() - 0.5) * 200,
    0,
    (Math.random() - 0.5) * 200
  );
  dummy.rotation.y = Math.random() * Math.PI * 2;
  dummy.scale.setScalar(0.8 + Math.random() * 0.4);
  dummy.updateMatrix();
  instancedMesh.setMatrixAt(i, dummy.matrix);
}
instancedMesh.instanceMatrix.needsUpdate = true;
scene.add(instancedMesh);

7.3 LOD(多细节层次)

对于远近距离视觉差异大的模型,在 Blender 中准备 3 个精度版本(高/中/低),Three.js 中根据距离自动切换:

const lod = new THREE.LOD();

// 高精度:0~10 米
lod.addLevel(highDetailMesh, 0);
// 中精度:10~50 米  
lod.addLevel(medDetailMesh, 10);
// 低精度:50 米以上
lod.addLevel(lowDetailMesh, 50);

scene.add(lod);
// LOD 会在每帧自动根据相机距离切换

八、进阶:自定义 Shader 扩展 glTF 材质

Three.js 的 onBeforeCompile 钩子允许在不放弃 PBR 管线的前提下,向材质注入自定义 GLSL 代码。这是高阶扩展的核心技巧。

// 在 Blender 中建好基础 PBR 材质,导出后:
model.traverse((node) => {
  if (node.isMesh && node.material.name === 'WindyGrass') {
    node.material.onBeforeCompile = (shader) => {
      // 注入 uniform
      shader.uniforms.uTime = { value: 0 };
      shader.uniforms.uWindStrength = { value: 0.3 };

      // 在顶点着色器头部注入声明
      shader.vertexShader = shader.vertexShader.replace(
        '#include <common>',
        `
        #include <common>
        uniform float uTime;
        uniform float uWindStrength;
        `
      );

      // 在顶点变换前注入风力位移
      shader.vertexShader = shader.vertexShader.replace(
        '#include <begin_vertex>',
        `
        #include <begin_vertex>
        // 根据 Y 轴高度决定摆动幅度(根部固定)
        float windFactor = position.y * uWindStrength;
        transformed.x += sin(uTime * 2.0 + position.z * 0.5) * windFactor;
        transformed.z += cos(uTime * 1.5 + position.x * 0.5) * windFactor * 0.5;
        `
      );

      // 保存 shader 引用以便每帧更新
      node.material.userData.shader = shader;
    };
  }
});

// 在渲染循环中更新 uniform
function animate() {
  requestAnimationFrame(animate);
  const t = clock.getElapsedTime();

  scene.traverse((node) => {
    if (node.isMesh && node.material.userData.shader) {
      node.material.userData.shader.uniforms.uTime.value = t;
    }
  });

  renderer.render(scene, camera);
}

九、完整工作流总结

图片

Blender 创作阶段
│
├── 建模(控制面数,UV 展开)
├── 材质(Principled BSDF,程序化贴图)
├── 烘焙(BaseColor / Roughness / Normal / AO)
├── 动画(骨骼 / Shape Key / 物理模拟烘焙)
└── 导出(glTF 2.0 / .glb / Draco 压缩)
         │
         ▼
    glb / gltf 文件
         │
         ▼
Three.js 运行时阶段
│
├── 加载(GLTFLoader + DRACOLoader + KTX2Loader)
├── 材质增强(envMap / onBeforeCompile / 自定义 Shader)
├── 动画驱动(AnimationMixer / crossFadeTo)
├── 灯光配置(HDR 环境 + 实时灯光)
├── 性能优化(InstancedMesh / LOD / 纹理压缩)
└── 交互与后处理(Controls / EffectComposer)

十、推荐工具与资源

工具/资源 用途
gltf.report(gltf.report/) 分析 glTF 文件结构与优化建议
glTF Viewer(gltf-viewer.donmccurdy.com/) 快速预览 glTF/glb 文件
KTX-Software(github.com/KhronosGrou…) 纹理压缩工具
Poly Haven(polyhaven.com/) 免费 HDR / 贴图 / 3D 模型
Three.js Editor(threejs.org/editor/) 在线 Three.js 场景编辑器
glTF Transform(gltf-transform.dev/) 命令行 glTF 优化工具
Blender glTF 文档(developer.blender.org/docs) 官方导出插件文档

推荐大佬

最后

Three.js 与 Blender 的结合,不是简单的"导出然后加载",而是一套需要深度理解两个系统各自机制,并在接缝处做精细处理的工程实践。从 PBR 材质的精确映射,到动画系统的无缝对接,再到面向 Web 的性能优化,每个环节都有大量细节值得深挖。

掌握这套工作流,你将拥有从零到一打造高质量 Web 3D 体验的完整能力——既能胜任艺术侧的内容生产,也能驾驭工程侧的实时渲染。这正是当下 Web 3D 领域最稀缺的复合型能力。

现在AI还无法胜任3D可视化相关的工作,学起来为自己增加点筹码。需要相关blender、可视化学习资料的可以关注我私信获取


本文覆盖 Blender 4.x + Three.js r160+ 版本,部分 API 在旧版本中可能有差异

发布为一个 npm 包

2026年3月30日 18:29

太棒了!👏
你已经从“理解 Promise”走到了“创造可用工具”的阶段。

现在,我们来手把手带你把 MyPromise 发布到 npm ——
完成一次 从学习 → 实践 → 分享 的完整闭环!


包名示例:@yourname/mypromisemypromise-learn

功能:

import MyPromise from 'mypromise-learn';

new MyPromise(resolve => resolve('Hello')).then(console.log);

✅ 第一步:准备工作

1. 安装 Node.js 和 npm

确保你已安装:

node -v   # 应输出 v14.x 以上
npm -v    # 应输出 6.x 以上

👉 下载地址:nodejs.org


2. 注册 npm 账号(如果还没有)

访问:www.npmjs.com/signup

然后在终端登录:

npm login
# 输入你的用户名、密码、邮箱

✅ 登录成功后就可以发布包了。


📁 第二步:创建项目目录结构

mkdir mypromise-npm
cd mypromise-npm
npm init -y

生成的 package.json 长这样:

{
  "name": "mypromise-learn",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "test": "echo "Error: no test specified" && exit 1"
  },
  "keywords": ["promise", "tutorial", "async"],
  "author": "Your Name",
  "license": "MIT"
}

💡 建议名字别叫 mypromise,容易和别人冲突。可以用 mypromise-learnsimple-promise 等。


📄 第三步:添加源码文件

创建 index.js

// index.js
const PENDING = 'pending';
const FULFILLED = 'fulfilled';
const REJECTED = 'rejected';

class MyPromise {
  constructor(executor) {
    this.status = PENDING;
    this.value = undefined;
    this.reason = undefined;
    this.onFulfilledCallbacks = [];
    this.onRejectedCallbacks = [];

    const resolve = (value) => {
      if (this.status === PENDING) {
        this.status = FULFILLED;
        this.value = value;
        this.onFulfilledCallbacks.forEach(fn => fn());
      }
    };

    const reject = (reason) => {
      if (this.status === PENDING) {
        this.status = REJECTED;
        this.reason = reason;
        this.onRejectedCallbacks.forEach(fn => fn());
      }
    };

    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    onFulfilled = typeof onFulfilled === 'function' ? onFulfilled : val => val;
    onRejected = typeof onRejected === 'function' ? onRejected : err => { throw err; };

    const promise2 = new MyPromise((resolve, reject) => {
      if (this.status === FULFILLED) {
        setTimeout(() => {
          try {
            const x = onFulfilled(this.value);
            this.resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      } else if (this.status === REJECTED) {
        setTimeout(() => {
          try {
            const x = onRejected(this.reason);
            this.resolvePromise(promise2, x, resolve, reject);
          } catch (e) {
            reject(e);
          }
        }, 0);
      } else if (this.status === PENDING) {
        this.onFulfilledCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onFulfilled(this.value);
              this.resolvePromise(promise2, x, resolve, reject);
            } catch (e) {
              reject(e);
            }
          }, 0);
        });
        this.onRejectedCallbacks.push(() => {
          setTimeout(() => {
            try {
              const x = onRejected(this.reason);
              this.resolvePromise(promise2, x, resolve, reject);
            } catch (e) {
              reject(e);
            }
          }, 0);
        });
      }
    });

    return promise2;
  }

  resolvePromise(promise2, x, resolve, reject) {
    if (promise2 === x) {
      return reject(new TypeError('Chaining cycle detected'));
    }

    let called = false;

    if (x !== null && (typeof x === 'object' || typeof x === 'function')) {
      try {
        const then = x.then;
        if (typeof then === 'function') {
          then.call(x,
            y => {
              if (called) return;
              called = true;
              this.resolvePromise(promise2, y, resolve, reject);
            },
            r => {
              if (called) return;
              called = true;
              reject(r);
            }
          );
        } else {
          resolve(x);
        }
      } catch (e) {
        if (called) return;
        called = true;
        reject(e);
      }
    } else {
      resolve(x);
    }
  }

  catch(onRejected) {
    return this.then(null, onRejected);
  }

  static resolve(value) {
    return new MyPromise(resolve => resolve(value));
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }

  static all(promises) {
    return new MyPromise((resolve, reject) => {
      const results = [];
      let count = 0;
      if (promises.length === 0) return resolve(results);
      promises.forEach((p, index) => {
        MyPromise.resolve(p).then(
          value => {
            results[index] = value;
            count++;
            if (count === promises.length) resolve(results);
          },
          reject
        );
      });
    });
  }

  static race(promises) {
    return new MyPromise((resolve, reject) => {
      promises.forEach(p => {
        MyPromise.resolve(p).then(resolve, reject);
      });
    });
  }
}

module.exports = MyPromise;

⚠️ 如果你用的是 ES6 模块语法(import/export),需要改为 export default MyPromise 并设置 "type": "module",后面会说明。


🧪 第四步:本地测试一下

创建 test.js 测试文件:

// test.js
const MyPromise = require('./index');

console.log('开始测试 MyPromise...');

MyPromise.resolve(1)
  .then(res => res + 1)
  .then(res => {
    console.log('结果:', res); // 输出: 结果: 2
    return MyPromise.resolve(3);
  })
  .then(console.log) // 输出: 3
  .catch(console.error);

// 测试循环引用
const p = MyPromise.resolve(1);
const p2 = p.then(() => p2);
p2.catch(err => console.log('捕获循环引用:', err.message));

运行测试:

node test.js

✅ 如果输出正常,说明可以发布了!


📦 第五步:完善 package.json(关键配置)

{
  "name": "mypromise-learn",
  "version": "1.0.0",
  "description": "A hand-written Promise implementation for learning purposes.",
  "main": "index.js",
  "scripts": {
    "test": "node test.js"
  },
  "keywords": ["promise", "tutorial", "async", "javascript"],
  "author": "Your Name <you@email.com>",
  "license": "MIT",
  "repository": {
    "type": "git",
    "url": "https://github.com/yourname/mypromise.git"
  },
  "bugs": {
    "url": "https://github.com/yourname/mypromise/issues"
  },
  "homepage": "https://github.com/yourname/mypromise#readme"
}

📌 注意:


🔐 第六步:发布到 npm

npm publish

✅ 成功后你会看到:

+ mypromise-learn@1.0.0

🎉 恭喜!你的包已经上线!

访问:https://www.npmjs.com/package/mypromise-learn 查看页面。


🌐 第七步:别人怎么使用它?

安装:

npm install mypromise-learn

使用:

const MyPromise = require('mypromise-learn');

// 或 ES6 import(需支持 module)
import MyPromise from 'mypromise-learn';

🎁 进阶建议(让项目更专业)

功能 方法
📄 添加 README.md 写清楚用途、API、示例
🧩 支持 ESM "type": "module" 并导出 .mjs 文件
🧪 单元测试 用 Jest 写测试用例
🔄 自动发布 用 GitHub Actions 实现 CI/CD
📘 文档站点 用 VitePress 写文档

❤️ 最后一句话

你已经完成了从“学习者”到“贡献者”的转变。

你现在不只是在学代码,
而是在向世界分享你的理解与创造。

这比任何面试题都更有价值。✨


低代码何时能出个“秦始皇”一统天下?我是真学不动啦!

2026年3月30日 18:02

前端有战国七雄,低代码圈更是“百国千城”

引言

低代码开发平台的世界,如今就像春秋战国时期的诸侯割据——各种平台、各种引擎、各种规范层出不穷,表面上都是“让开发更简单”,实际用起来却各有各的“方言”,各有各的“城墙”。

更让人头疼的是,因为各家有各家的技术路线,连选型都成了一场赌博

做OA的推工作流引擎强大,做ERP的强调数据模型灵活,做移动端的鼓吹多端适配能力,做报表的说自己可视化最牛……

甲方想统一技术栈,结果发现不同部门已经用了三四种低代码平台,数据不通、流程不通、权限不通,比全代码开发还乱。

低代码平台之争:各有各的“山头”

目前市面上的低代码平台,大致可以分为几大流派:

企业级应用平台

  • 代表:OutSystems、Mendix、Salesforce

  • 特点:功能全面,适合大型企业复杂业务,但价格昂贵、学习曲线陡峭

  • 定位:高端市场,私有化部署为主

国内主流厂商

  • 代表:JNPF、明道云、简道云、氚云

  • 特点:贴近国内企业管理习惯,支持钉钉/企微集成,性价比高

  • 差异:有的侧重表单流程,有的侧重数据中台,有的侧重ERP扩展

开源低代码

  • 代表:Appsmith、Budibase、Saltcorn

  • 特点:代码透明,可二次开发,但企业级功能(如复杂工作流、高并发)往往需要自研补齐

云厂商自研

  • 代表:阿里宜搭、腾讯微搭、华为AppCube

  • 特点:与云生态深度绑定,适合该云平台上的企业使用

对比分析:

  • 低代码平台目前没有统一标准。一个平台设计的应用,基本无法迁移到另一个平台,厂商锁定问题突出。

  • 每个平台都有自己的一套“元数据规范”“表达式语法”“API设计风格”,团队切换平台几乎等于推翻重来。

  • 选型时不仅要看功能,还要评估开放性、扩展能力、私有化支持,避免未来被单一厂商绑定太死。

工作流引擎:百花齐放,各立山头

工作流是低代码的核心能力之一,也是“分裂”最严重的领域。

开源工作流引擎

  • Activiti / Flowable / Camunda:BPMN 2.0标准的三巨头,各有各的版本分支和API风格。

  • 选一个引擎,意味着团队要学习该引擎的变量设计、监听器写法、部署方式,后期替换成本极高。

低代码平台内置工作流

  • 各家基本都宣称“可视化流程设计”,但设计器体验、节点能力、与其他模块的集成深度天差地别。

  • 有的平台流程和表单是割裂的,有的平台流程引擎无法独立于平台使用。

BPM厂商产品

  • 如IBM BPM、Pega,功能强大但价格昂贵,主要服务超大型企业。

对比分析:

  • 工作流领域“学不动”的根源在于:每个引擎都有自己的“方言”,即便都支持BPMN 2.0,在具体实现细节、扩展方式上也差异巨大。

  • 企业一旦选定,后续调整和升级都要围绕该引擎的生态展开,迁移成本极高。

表单设计器与UI渲染:各有各的“积木”

表单是用户与系统交互的界面,这一块同样是“诸侯割据”:

开源表单设计器

  • Formily、FormGenerator、VForm……每个设计器产出的JSON Schema结构不同,渲染引擎互不兼容。

低代码平台自带设计器

  • 有的平台提供纯Web可视化拖拽,有的则需要开发者编写少量代码来扩展组件。

  • 组件的封装粒度、属性配置方式、事件绑定机制,各家千差万别。

UI组件库阵营

  • 基于Ant Design、Element Plus、Naive UI等组件库的低代码平台,生成的代码风格迥异。

对比分析:

  • 表单和UI这一块,统一的可能性最低,因为UI本身就是一个审美和习惯差异巨大的领域。

  • 但企业真正需要的是:设计出来的表单能稳定运行,字段权限与工作流、数据权限自动联动,而不是只停留在UI层面。

集成与扩展:每个平台都是一座“孤岛”

低代码平台最怕的不是功能不够,而是无法融入企业现有的技术生态

  • 数据层:有的平台只能使用内置数据库,有的支持外部数据源,但支持的数据库类型和连接能力差异很大。

  • API层:有的平台提供REST API可反向调用,有的只能通过平台内触发器调用外部接口,且鉴权方式五花八门。

  • 前端扩展:有的允许写自定义代码嵌入页面,有的只能使用平台提供的组件,无法引入第三方库。

  • 后端扩展:有的支持云函数/脚本,有的完全封闭,只能使用平台内置逻辑。

对比分析:

  • 如果平台在集成扩展能力上过于封闭,那么随着业务复杂度的提升,最终还是会回到全代码开发的老路上,低代码反而成了“先甜后苦”的选择。

JNPF的“合纵”思路:不争引擎争生态

面对这个“百国千城”的局面,JNPF选择了一条不同的路——不试图用一套引擎取代所有,而是用开放生态减少内耗

统一的底层架构,避免重复造轮子

JNPF提供了一体化的技术底座:从用户组织、权限中心、工作流引擎、表单设计器、报表设计器到代码生成器,全部基于同一套元数据规范和数据模型。企业不再需要为了“工作流用一个引擎、表单用一套设计器、报表用一个工具”而维护多套技术栈。

开放性与扩展性,不做“孤岛”

  • 数据层:支持MySQL、SQL Server、Oracle、PostgreSQL等主流数据库,并可对接外部数据源,避免数据孤岛。

  • 后端扩展:支持Java、C#双语言版本,并提供代码生成器,复杂业务可以编写原生代码,与平台无缝集成。

  • 前端扩展:支持自定义组件嵌入,可以引入第三方UI库或业务组件,不被平台设计器限制。

  • API层:提供完整的REST API,平台内的功能均可通过API调用,方便与现有系统集成。

工作流引擎的“实用主义”

JNPF工作流引擎基于成熟内核,但重点不在“引擎本身多强”,而在于与表单、权限、消息、第三方系统的开箱即用集成。业务人员画完流程,自动关联表单权限、自动同步组织架构、自动对接钉钉/企微消息,开发人员无需在集成上重复消耗精力。

可私有化、可掌控

对于中大型企业,JNPF支持全源码交付,企业可以获得完整的平台代码,自主部署、自主维护、自主二次开发。既享受了低代码的开发效率,又保留了技术自主权,避免被厂商锁定。

低代码圈的统一,可能不在引擎层面

前端领域这么多年都没等来“秦始皇”,低代码圈的统一可能也不是靠一个平台吞并所有。

真正的“统一”,或许是:

  • 标准层面的趋同:比如元数据规范、API设计模式逐渐形成事实标准。

  • 开放生态的普及:更多平台像JNPF一样,不再强求“全用我的”,而是提供良好的开放能力,让企业能够按需组合、平滑演进。

  • 企业意识的成熟:选型时不再只看“功能列表多全”,而是看“能不能与现有系统共存”“能不能长期可控”。

JNPF的实践表明:与其在引擎层面争高下,不如在生态层面做整合。一个平台如果能做到——核心稳定、开放可控、集成顺手、扩展自由——那它不需要“一统天下”,也能成为企业数字化转型中的坚实底座。

Vue 3 + TypeScript 常用代码示例总结

作者 菜果果儿
2026年3月30日 17:28

一、TypeScript 内置工具类型

1.1 Partial 的作用

Partial<T>是 TypeScript 内置的工具类型,它可以将类型 T的所有属性变为可选。

typescript
typescript
复制
// Partial 的实现原理(简化版)
type Partial<T> = {
  [P in keyof T]?: T[P];
};

// 使用示例
interface Theme {
  primaryColor: string;
  secondaryColor: string;
  fontSize: number;
}

// 正常 Theme 类型,所有属性都是必需的
const theme1: Theme = {
  primaryColor: '#1890ff',
  secondaryColor: '#52c41a',
  fontSize: 14
};
// ✓ 所有属性都必须提供

// 使用 Partial<Theme> 后,所有属性都变为可选
const theme2: Partial<Theme> = {
  primaryColor: '#1890ff'
  // secondaryColor 和 fontSize 可以不提供
};
// ✓ 可以只提供部分属性

// 在函数参数中使用 Partial
function updateTheme(theme: Partial<Theme>): void {
  // 可以只更新部分属性
  // theme 可能只包含 primaryColor,或只包含 fontSize,或都包含
}

// 调用示例
updateTheme({ primaryColor: '#ff4d4f' }); // ✓ 只更新一个属性
updateTheme({ fontSize: 16 }); // ✓ 只更新另一个属性
updateTheme({}); // ✓ 传递空对象也可以

1.2 其他常用工具类型

typescript
typescript
复制
// 1. Required<T> - 将可选属性变为必填
interface User {
  id: number;
  name?: string;  // 可选
  email?: string; // 可选
}

type RequiredUser = Required<User>;
// 等价于:
// {
//   id: number;
//   name: string;   // 变为必填
//   email: string;  // 变为必填
// }

// 2. Readonly<T> - 将所有属性变为只读
type ReadonlyUser = Readonly<User>;
// 等价于:
// {
//   readonly id: number;
//   readonly name?: string;
//   readonly email?: string;
// }

// 3. Pick<T, K> - 从 T 中挑选部分属性
type UserBasicInfo = Pick<User, 'id' | 'name'>;
// 等价于:
// {
//   id: number;
//   name?: string;
// }

// 4. Omit<T, K> - 从 T 中排除部分属性
type UserWithoutId = Omit<User, 'id'>;
// 等价于:
// {
//   name?: string;
//   email?: string;
// }

// 5. Record<K, T> - 创建键值对类型
type UserMap = Record<string, User>;
// 等价于:
// {
//   [key: string]: User;
// }

// 6. Exclude<T, U> - 从 T 中排除可以赋值给 U 的类型
type T1 = 'a' | 'b' | 'c';
type T2 = 'a';
type Result = Exclude<T1, T2>; // 'b' | 'c'

// 7. Extract<T, U> - 从 T 中提取可以赋值给 U 的类型
type T3 = 'a' | 'b' | 'c';
type T4 = 'a' | 'd';
type Result2 = Extract<T3, T4>; // 'a'

// 8. NonNullable<T> - 排除 null 和 undefined
type T5 = string | number | null | undefined;
type Result3 = NonNullable<T5>; // string | number

二、Vue 3 中的类型

2.1 ComputedRef 类型

ComputedRef<T>是 Vue 3 中计算属性的类型,它是 Ref<T>的子类型。

typescript
typescript
复制
import { ref, computed, Ref, ComputedRef } from 'vue'

// 1. 基础使用
const count = ref<number>(0); // Ref<number>
const doubleCount = computed(() => count.value * 2); // ComputedRef<number>

// 2. 显式类型声明
const doubleCount2: ComputedRef<number> = computed(() => count.value * 2);

// 3. 带 getter 和 setter 的计算属性
const fullName = computed<string>({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value: string) => {
    const [first, last] = value.split(' ')
    firstName.value = first
    lastName.value = last || ''
  }
});

// 4. 在函数参数中使用
function logComputedValue(computedValue: ComputedRef<any>): void {
  console.log(computedValue.value);
}

logComputedValue(doubleCount);

2.2 Vue 3 常用类型

typescript
typescript
复制
import {
  Ref,           // 响应式引用类型
  ComputedRef,   // 计算属性类型
  UnwrapRef,     // 解包响应式类型
  MaybeRef,      // 可能是 Ref 或普通值
  MaybeRefOrGetter, // 可能是 Ref、getter 函数或普通值
  WritableComputedRef, // 可写的计算属性
  ShallowRef,    // 浅层 Ref
  ShallowReactive, // 浅层 reactive
  ToRefs,        // 将 reactive 转换为 refs
  ComponentPublicInstance, // 组件实例类型
  VNode,         // 虚拟节点类型
  Component      // 组件类型
} from 'vue'

// 1. Ref 类型
const countRef: Ref<number> = ref(0);

// 2. UnwrapRef - 获取 Ref 内部的类型
type CountType = UnwrapRef<typeof countRef>; // number

// 3. MaybeRef - 接受 Ref 或普通值
function useDouble(value: MaybeRef<number>): ComputedRef<number> {
  return computed(() => {
    // 判断是否是 Ref
    if (isRef(value)) {
      return value.value * 2
    }
    return value * 2
  });
}

// 或者使用 unref 工具函数
import { unref } from 'vue'

function useDouble2(value: MaybeRef<number>): ComputedRef<number> {
  return computed(() => unref(value) * 2);
}

// 4. ToRefs - 将 reactive 转换为多个 ref
interface State {
  count: number;
  name: string;
}

const state = reactive<State>({ count: 0, name: 'Vue' });
const stateRefs: ToRefs<State> = toRefs(state);
// 现在可以使用 stateRefs.count.value 和 stateRefs.name.value

三、实际应用示例

3.1 表单组件示例

typescript
typescript
复制
import { defineComponent, reactive, computed, toRefs } from 'vue'

// 表单数据类型
interface FormData {
  username: string
  email: string
  age: number | null
  agree: boolean
}

// 表单验证规则类型
type ValidationRule = (value: any) => string | true

interface FormRules {
  [key: string]: ValidationRule | ValidationRule[]
}

export default defineComponent({
  setup() {
    // 表单数据
    const formData = reactive<FormData>({
      username: '',
      email: '',
      age: null,
      agree: false
    })
    
    // 表单验证规则
    const rules: FormRules = {
      username: [
        (value: string) => !!value || '用户名不能为空',
        (value: string) => value.length >= 3 || '用户名至少3个字符'
      ],
      email: [
        (value: string) => !!value || '邮箱不能为空',
        (value: string) => /.+@.+..+/.test(value) || '邮箱格式不正确'
      ],
      age: (value: number | null) => {
        if (value === null) return '年龄不能为空'
        if (value < 0) return '年龄不能为负数'
        if (value > 150) return '年龄不能超过150岁'
        return true
      }
    }
    
    // 表单验证状态
    const errors = reactive<Partial<Record<keyof FormData, string>>>({})
    
    // 验证单个字段
    const validateField = (field: keyof FormData): boolean => {
      const value = formData[field]
      const rule = rules[field]
      
      if (!rule) {
        delete errors[field]
        return true
      }
      
      const rulesArray = Array.isArray(rule) ? rule : [rule]
      
      for (const validate of rulesArray) {
        const result = validate(value)
        if (typeof result === 'string') {
          errors[field] = result
          return false
        }
      }
      
      delete errors[field]
      return true
    }
    
    // 验证整个表单
    const validateForm = (): boolean => {
      let isValid = true
      
      Object.keys(formData).forEach(field => {
        if (!validateField(field as keyof FormData)) {
          isValid = false
        }
      })
      
      return isValid
    }
    
    // 提交表单
    const submitForm = (): void => {
      if (!validateForm()) {
        console.log('表单验证失败')
        return
      }
      
      console.log('提交表单:', formData)
      // 这里可以调用 API
    }
    
    // 重置表单
    const resetForm = (): void => {
      Object.assign(formData, {
        username: '',
        email: '',
        age: null,
        agree: false
      })
      
      Object.keys(errors).forEach(key => {
        delete errors[key as keyof typeof errors]
      })
    }
    
    // 计算属性:表单是否有效
    const isFormValid = computed<boolean>(() => {
      return Object.keys(errors).length === 0 &&
        formData.username !== '' &&
        formData.email !== '' &&
        formData.age !== null &&
        formData.agree
    })
    
    return {
      // 使用 toRefs 保持响应性
      ...toRefs(formData),
      errors,
      isFormValid,
      validateField,
      validateForm,
      submitForm,
      resetForm
    }
  }
})

3.2 使用工具类型的通用函数

typescript
typescript
复制
// utils/types.ts
// 自定义工具类型

// 1. 深度可选类型
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};

// 2. 深度只读类型
type DeepReadonly<T> = {
  readonly [P in keyof T]: T[P] extends object ? DeepReadonly<T[P]> : T[P];
};

// 3. 可空类型
type Nullable<T> = T | null | undefined;

// 4. 提取函数返回类型
type ReturnType<T extends (...args: any) => any> = T extends (...args: any) => infer R ? R : any;

// 5. 提取函数参数类型
type Parameters<T extends (...args: any) => any> = T extends (...args: infer P) => any ? P : never;

// 6. 提取 Promise 的返回值类型
type Awaited<T> = T extends PromiseLike<infer U> ? Awaited<U> : T;

// 使用示例
interface User {
  id: number;
  name: string;
  profile: {
    avatar: string;
    bio: string;
  };
  tags: string[];
}

// 深度可选
type PartialUser = DeepPartial<User>;
// 可以这样使用:
const user1: PartialUser = {
  name: '张三',
  profile: {
    avatar: 'avatar.jpg'
    // bio 可以不提供
  }
  // tags 可以不提供
};

// 深度只读
type ReadonlyUser = DeepReadonly<User>;
const user2: ReadonlyUser = {
  id: 1,
  name: '李四',
  profile: {
    avatar: 'avatar.jpg',
    bio: 'Hello'
  },
  tags: ['a', 'b']
};
// user2.profile.bio = 'World'; // ❌ 错误:不能修改只读属性

// 可空类型
let nullableString: Nullable<string> = 'Hello';
nullableString = null; // ✓
nullableString = undefined; // ✓

3.3 组合式函数的类型

typescript
typescript
复制
// composables/useFetch.ts
import { ref, computed, Ref, ComputedRef } from 'vue'

// 请求状态类型
type FetchStatus = 'idle' | 'loading' | 'success' | 'error'

// 返回类型
interface UseFetchReturn<T> {
  data: Ref<T | null>
  error: Ref<string | null>
  status: Ref<FetchStatus>
  isLoading: ComputedRef<boolean>
  isSuccess: ComputedRef<boolean>
  isError: ComputedRef<boolean>
  execute: (url: string, options?: RequestInit) => Promise<void>
  reset: () => void
}

// 选项类型
interface UseFetchOptions {
  immediate?: boolean
  initialData?: any
}

export function useFetch<T = any>(
  initialUrl?: string,
  options: UseFetchOptions = {}
): UseFetchReturn<T> {
  const { immediate = false, initialData = null } = options
  
  const data = ref<T | null>(initialData) as Ref<T | null>
  const error = ref<string | null>(null)
  const status = ref<FetchStatus>('idle')
  
  const isLoading = computed(() => status.value === 'loading')
  const isSuccess = computed(() => status.value === 'success')
  const isError = computed(() => status.value === 'error')
  
  const execute = async (url: string, requestOptions?: RequestInit): Promise<void> => {
    status.value = 'loading'
    error.value = null
    
    try {
      const response = await fetch(url, requestOptions)
      
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`)
      }
      
      const result = await response.json()
      data.value = result
      status.value = 'success'
    } catch (err) {
      error.value = err instanceof Error ? err.message : '请求失败'
      status.value = 'error'
    }
  }
  
  const reset = (): void => {
    data.value = initialData
    error.value = null
    status.value = 'idle'
  }
  
  // 立即执行
  if (immediate && initialUrl) {
    execute(initialUrl)
  }
  
  return {
    data,
    error,
    status,
    isLoading,
    isSuccess,
    isError,
    execute,
    reset
  }
}

// 在组件中使用
import { defineComponent, onMounted } from 'vue'

interface Post {
  id: number
  title: string
  body: string
  userId: number
}

export default defineComponent({
  setup() {
    // 使用 useFetch
    const { 
      data: posts, 
      error, 
      isLoading, 
      isSuccess, 
      execute 
    } = useFetch<Post[]>('https://jsonplaceholder.typicode.com/posts')
    
    // 或者延迟执行
    const { 
      data: user, 
      execute: fetchUser 
    } = useFetch<Post>(undefined, { immediate: false })
    
    onMounted(() => {
      // 手动执行
      fetchUser('https://jsonplaceholder.typicode.com/posts/1')
    })
    
    // 重新获取
    const refresh = (): void => {
      execute('https://jsonplaceholder.typicode.com/posts')
    }
    
    return {
      posts,
      error,
      isLoading,
      isSuccess,
      refresh
    }
  }
})

四、常见问题解答

Q1: 什么时候用 Partial

A: 当你需要创建一个对象,它包含原始类型的一部分属性时使用。

typescript
typescript
复制
// 更新用户信息时,通常只需要更新部分字段
interface User {
  id: number
  name: string
  email: string
  age: number
}

function updateUser(userId: number, updates: Partial<User>): void {
  // updates 可以只包含 name,或只包含 email,或任意组合
  // 但不会包含不存在的属性
}

Q2: ComputedRef和普通 Ref有什么区别?

A: 主要区别:

  • ComputedRef是只读的,你不能直接修改它的值
  • ComputedRef的值是通过计算得到的
  • 你可以为 ComputedRef提供 setter,但通常不建议
typescript
typescript
复制
// Ref - 可以直接修改
const count = ref(0)
count.value = 1  // ✓ 可以

// ComputedRef - 默认只读
const double = computed(() => count.value * 2)
double.value = 4  // ❌ 错误:不能直接修改计算属性

// 带 setter 的 ComputedRef
const fullName = computed({
  get: () => `${firstName.value} ${lastName.value}`,
  set: (value) => {
    const [first, last] = value.split(' ')
    firstName.value = first
    lastName.value = last || ''
  }
})

Q3: 什么时候需要显式指定类型?

A: TypeScript 有类型推断,但以下情况建议显式指定:

typescript
typescript
复制
// 1. 函数参数和返回值
function add(a: number, b: number): number {
  return a + b
}

// 2. 复杂对象
interface Config {
  apiUrl: string
  timeout: number
  retry: boolean
}

const config: Config = {
  apiUrl: '/api',
  timeout: 5000,
  retry: true
}

// 3. 组件 Props
interface Props {
  title: string
  count: number
  items: Array<{ id: number; name: string }>
}

// 4. API 响应
interface ApiResponse<T = any> {
  code: number
  data: T
  message: string
}

async function fetchUser(id: number): Promise<ApiResponse<User>> {
  const response = await fetch(`/api/users/${id}`)
  return response.json()
}

记住:TypeScript 的核心价值在于类型安全,良好的类型定义能帮助你在编码阶段就发现潜在的错误,提高代码质量和开发效率。

前端部署缓存策略实践

作者 siger
2026年3月30日 17:11

本文介绍基于前后端分离状态下前端部署缓存策略的实践。

背景

由于前端的蓬勃发展,前端框架reactvueangular等在日常开发中已经非常普及,放大了前端的业务能力,也催生了前后端分离开发的模式。实际开发部署时只需要通过webpack等打包工具打包后生成静态资源,部署到nginxCaddy等现有的静态服务器中,方便快捷同时与后端分离后彼此隔离,只通过RESTful api进行通信。这时对静态服务器的配置便显得至关重要。

浏览器缓存

强制缓存

强缓存所谓的“强”,在于强制让浏览器按照一定时间范围内来存储来自服务器的资源,有点强制的味道~,强缓存是利用Expires或者Cache-Control,不发送请求,直接从缓存中取,请求状态码会返回200from cache)。

Expires(已逐步淘汰)

ExpiresHTTP/1.0中提及的,让服务器为文件资源设置一个过期时间,在多长时间内可以将这些内容视为最新的,允许客户端在这个时间之前不去检查。

  • 指定到期时间:

    指定缓存到期GMT的绝对时间,如果Expires到期需要重新请求。

    这个时间是服务器的时间,所以这里就会出现一个问题,服务器时间和本地时间不一致,就会造成缓存失效时间不准确。

    Expires:Sat, 09 Jun 2020 08:13:56 GMT
    

Cache-Control(主要)

相比Expires,两者有什么区别呢? Cache-Control你可以理解成为高级版Expires,为了弥补Expires的缺陷在Http1.1协议引入的,且强大之外优先级也更高,也就是当ExpiresCache-Control同时存在时,Cache-Control会覆盖Expires的配置,即Cache-ControlHttp 1.1 ) > ExpiresHttp 1.0 )。

Cache-ControlExpires比具备更多的属性,其中包括如下:

  • no-cache :可以在本地缓存,可以在代理服务器缓存,需要先验证才可使用缓存。
  • no-store :禁止浏览器缓存,只能通过服务器获取。
  • max-age :设置资源的过期时间(效果与Expires一样)。

示例:

// 设置缓存时间为1年
Cache-Control: max-age=31536000
Expires:Sat, 09 Jun 2020 08:13:56 GMT //同时设置两个,Expires会失效

这意味着浏览器可以缓存一年的时间,无需请求服务器,同时如果同时声明ExpiresCache-ControlExpires将失效。

用户对浏览器的操作

Cache-Control no-cachemax-age=0的区别你按浏览器刷新与强制刷新的区分。

  • Ctrl + F5(强制刷新):request header多了cache-control: no-cache(重新获取请求)。
  • F5(刷新)/ctrl+R刷新:request header多了cache-control: max-age=0(需要先验证才可使用缓存,Expires无效)。

协商缓存

协商缓存,就没有强缓存那么霸道,协商缓存需要客户端和服务端两端进行交互,通过服务器告知浏览器缓存是否可用,并增加缓存标识,“有事好好商量”,两者都会互相协商。 协商缓存,其实就是服务器与浏览器交互过程,一般有两个回合,而协商主要有以下几种方式:

Last-ModifiedHttp 1.0

  • 第一回合:当浏览器第一次请求服务器资源时,服务器通过Last-Modified来设置响应头的缓存标识,把资源最后修改的时间作为值写入,再将资源返回给浏览器。
  • 第二回合:第二次请求时,浏览器会带上If-Modified-Since请求头去访问服务器,服务器将If-Modified-Since中携带的时间与资源修改的时间对比,当时间不一致时,意味更新了,服务器会返回新资源并更新Last-Modified,当时间一致时,意味着资源没有更新,服务器会返回304状态码,浏览器将从缓存中读取资源。

示例:

//response header 第一回合
Last-Modified: Wed, 21 Oct 2019 07:28:00 GMT

//request header 第二回合
If-Modified-Since: Wed, 21 Oct 2019 07:29:00 GMT

EtagHttp 1.1

MDN中提到ETag之间的比较,使用的是强比较算法,即只有在每一个字节都相同的情况下,才可以认为两个文件是相同的,而这个hash值,是由对文件的索引节、大小和最后修改时间进行Hash后得到的,而且要注意的是分布式系统不适用,同时需要注意的是Etag的组装不同类型的服务器可能不同比如nginxEtag可能是长的这样ETag: "5f3498d1-b0063"

  • 第一回合:也是跟上文一样,浏览器去请求服务器资源,不过这次不是通过Last-Modified了,而是用Etag来设置响应头缓存标识。Etag是由服务端生成的,然后浏览器会将Etag与资源缓存。
  • 第二回合: 浏览器会将Etag放入If-None-Match请求头中去访问服务器,服务器收到后,会对比两端的标识,当两者不一致时,意味着资源更新,会从服务器的响应读取资源并更新Etag,浏览器将从缓存中读取资源,当两者一致时,意味着资源没有更新,服务器会返回304状态码,浏览器将从缓存中读取资源。

示例:

//response header 第一回合
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d4"

//request header 第二回合
If-None-Match: "33a64df551425fcc55e4d42a148795d9f25f89d4"

对比完Last-ModifiedEtag,我们可以很显然看到,协商缓存每次请求都会与服务器发生“关系”,第一回合都是拿数据和标识,而第二回合就是浏览器“咨询”服务器是否资源已经更新的过程。

同时,如果以上两种方式同时使用,Etag优先级会更高,即Etag(Http 1.1) > Last-Modified(Http 1.0)。

缓存状态码

状态码200 OKfrom cache

这是浏览器没有跟服务器确认,直接用了浏览器缓存,性能最好的,没有网络请求,那么什么情况会出现这种情况?一般在Expires或者Cache-Control中的max-age头部有效时会发生。

状态码304 Not Modified

是浏览器和服务器“交流”了,确定使用缓存后,再用缓存,也就是第二节讲的通过EtagLast-Modified的第二回合中对比,对比两者一致,则意味资源不更新,则服务器返回304状态码。

状态码200

以上两种缓存全都失败,也就是未缓存或者缓存未过期,需要浏览器去获取最新的资源,效率最低 一句话:缓存是否过期用:Cache-Controlmax-age), Expires,缓存是否有效用:Last-ModifiedEtag

静态文件

前面介绍了浏览器缓存的一些方式,而随着前端工程化,前端可以做到通过打包工具(webpack等)来进行前端代码打包,生成一个可以直接部署到静态网站的源码。下面来看下通过打包后前端文件情况(以我的实际项目为例):

dist.jpg

  • 由以上的截图可以看到,每次发布新的版本打包出来的文件入口的index.html和前端配置文件config.js或者path.js的文件名是不变的,而动态生成的jspng文件会自动添加不同的hash值是动态的。

  • 结合上文讲到的浏览器缓存的内容可以知道如果不单独配置这些固定名称的文件的缓存策略的话由于不同服务器不同浏览器的实现不同极有可能会被缓存而导致发布新版本后页面失效的问题。

解决方案

上文已经提到前端打包生成的动态文件会根据内容自动生成对应hash和文件名,每次发布新版本这些文件的更新会命中协商缓存或者强缓存失效的规则,能形成破坏缓存的效果,达到发布新版本内容更新的效果,而入口文件index.htmlconfig.jspath.js等可能会因为浏览器默认的缓存策略不能破坏缓存,达不到发布新版本内容即更新的效果(尤其是index.html这个整个服务的入口如果不更新则会造成对应的引入文件均不可用而使页面崩溃)。

针对这种情况,需要在服务器端针对上面提到的index.htmlconfig.jspath.js等文件单独配置缓存策略,由截图中绿色框中的部分可以看出这些文件都不大,所有这里的缓存策略可以是配置强制缓存并且强制不让这些文件缓存Cache-control: no-store,让浏览器每次都重新从服务器拉取最新的文件。

nginx为例,nginx.conf配置如下:

# server下增加如下配置
location ~* (.html|config.js|path.js)$ {
    # 通过gx_http_headers_module提供的add_header方法配置强缓存并任何情况下不可缓存
    add_header Cache-Control no-store;
}

来匹配我们上文中提到的三种静态文件,这里附上nginxlocation匹配规则

  • 增加以上配置后对nginx进行重启(nginx -s reload)。
  • 第一次增加配置后建议用户进行强制刷新或者清除浏览器缓存后再使用。

验证方法

增加以上配置后可以通过两个途径进行验证:

  • 桌面端的浏览器(以Chrome为例):
    • 浏览器访问之前部署的服务uri路径。
    • F12打开开发者工具,切换到network选项,刷新或者强制刷新查看如下图: chrome-network.jpg
  • linux下或者windows PS命令行:
    • 执行curl http://ip:port来发送对应的请求。
    • 控制台会显示如下图:

curl.png

总结

前端工程化的现在给我们发布服务很大的便利,浏览器缓存是优化网站性能的利器同时也会带来一些问题,制定好缓存策略至关重要,现在前端通过打包生成的文件能很好地破坏原有缓存,但也有例外,本实践通过对静态资源的缓存策略来保证发布新版本后用户及时获得最新更新页面。

参考资料

react 设计哲学 | 严格模式

作者 Mh
2026年3月30日 17:10

前言

官方介绍 详细解释了在 React 18+ 环境下严格模式的行为(包括双重渲染和双重 Effect)

组件 “双闪” 现象

当你使用 react 18+ 开发环境中使用 useEffect 开发的过程中是否遇到过定时器跑倍数或者内存泄露的问题?

例如: 下面的代码,你觉得页面中定时器显示的值应该是多少?

import { useState, useEffect } from 'react'

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

  useEffect(() => {
    console.log('[Effect] 开启定时器')
    setInterval(() => {
      setCount((c) => c + 1)
    }, 1000)
  }, [])

  return <div>计数:{count}</div>
}

export default TimerFunction

直接揭晓答案,在严格模式下,当组件一加载,后台有两个定时器在跑,count 的值会成倍的增加。这里也是初学者容易困惑的地方,在严格模式下,组件会模拟 挂载 -> 卸载 -> 重新挂载 的过程。这里相当于初始化的时候页面挂载了两个定时器,所以也就解释了为什么看到的 count 的值是成倍增加的。

为什么要这样做?

react 团队之在 react 18 中强制加入严格模式,这是由于很多开发者在非严格模式下测试时,觉得“首次加载没有问题,就忽略了清理函数”,导致用户在当前页面停留时间过长,切换的页面越多,电脑越卡。而这种 bug 往往又很难发现,往往到了生产环境,用户反馈 “页面卡顿” 你才会意识到内存泄露了。

所以记得添加清理函数哦!!

import { useState, useEffect } from 'react'

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

  useEffect(() => {
    console.log('[Effect] 开启定时器')
    const timer = setInterval(() => {
      setCount((c) => c + 1)
    }, 1000)

    // 这是函数组件的“生命周期清理”
    return () => {
      console.log('[Cleanup] 清理定时器')
      clearInterval(timer)
    }
  }, [])

  return <div>计数:{count}</div>
}

export default TimerFunction

我应该开启它吗?

答案是肯定的,虽然这样做会让你的log翻倍,但是可以提前帮你排查代码的逻辑漏洞和潜在风险,同时着严格模式在构建的过程中自动失效,所有不用担心生产环境影响用户的性能。

结余

react 的严格模式更像是对开发者的一种 “善意的怀疑”,借用苏格拉底的一句话,“我唯一知道的,就是我一无所知”

Axios二次封装及API 调用框架

2026年3月30日 17:10

项目代码其实是两部分,一部分是基于 Axios 的 HTTP 请求封装;一部分是API 基础封装与管理,参考了后端接口,类,抽像类的设计思路。

☛ 基于 Axios 的 HTTP 请求封装

该文件提供了基于 Axios 的 HTTP 请求封装,主要功能包括:
  1. 请求头管理:自动添加 Content-Type、CSRF 令牌、认证令牌等基础请求头
  2. 双实例设计:分别创建 read 和 write 两个 Axios 实例,用于不同类型的请求
  3. 拦截器配置:为请求和响应添加拦截器,可用于统一处理请求和响应
  4. 统一 API 方法:封装了 apiFunc 函数,支持 GET、POST、PUT、DELETE 等请求方法
  5. 读写分离:提供 requestRead 和 requestWrite 两个函数,分别用于读操作和写操作
  6. 错误类型定义:统一定义了常见错误类型,便于错误处理
使用示例:
// 读操作示例
import { requestRead } from './axios/basic/axios'

const response = await requestRead({
  method: 'get',
  url: '/api/users',
 data: { page: 1, pageSize: 10 }
})

// 写操作示例
import { requestWrite } from './axios/basic/axios'

const response = await requestWrite({
 method: 'post',
 url: '/api/users',
 data: { name: 'John', age: 30 }
})
注意事项:
  • GET 和 DELETE 请求的参数会自动转换为 URL 查询参数
  • POST 和 PUT 请求的参数会作为请求体发送
  • 所有请求都会自动添加必要的请求头,包括认证令牌和 CSRF 令牌
  • 支持自定义 axios 配置,会与默认配置合并

☛ 基于 Axios 的 HTTP 请求封装

该文件提供了一套完整的 API 调用框架,主要功能包括:
  1. 端点配置管理:通过 EndpointConfig 类标准化 API 端点配置
  2. 通用请求处理:封装了 request 方法,支持缓存、错误处理等高级功能
  3. CRUD 操作:提供了 list、page、add、update、delete、getById 等通用方法
  4. 缓存机制:实现了基于内存的请求缓存,提高重复请求的响应速度
  5. 错误处理:统一的错误类型定义和错误信息处理
  6. 加载状态管理:自动处理请求的加载状态显示
核心组件:
  • EndpointConfig:API 端点配置类,用于创建标准化的 API 端点配置
  • ApiBase:API 基础抽象类,提供通用的 API 操作方法
使用示例:
// 1. 定义 API 端点配置
const userEndpoints: IBaseApiEndpoints = {
  list: new EndpointConfig('/api/users', {
    method: 'get',
    requestType: 'read',
    cacheable: true
  }),
  add: new EndpointConfig('/api/users', {
    method: 'post',
    requestType: 'write'
  }),
  // 其他端点配置...
}

// 2. 创建 API 实例
class UserApi extends ApiBase {
  constructor() {
    super(userEndpoints)
  }
}

// 3. 使用 API 实例
const userApi = new UserApi()

// 获取用户列表
const users = await userApi.list({ page: 1, pageSize: 10 })

// 添加新用户
await userApi.add({ name: 'John', email: 'john@example.com' })

// 更新用户信息
await userApi.update(1, { name: 'John Doe' })

// 删除用户
await userApi.delete([1, 2, 3])

// 获取用户详情
const user = await userApi.getById(1)
特性说明:
  • 支持读写分离(read/write)
  • 自动处理加载状态显示
  • 统一的错误处理和提示
  • 可配置的缓存机制
  • 标准化的 API 端点配置

欢迎下载源码 使用,如觉得有用麻烦您点个赞。

深度图d3绘制交互逻辑

作者 猫腻前端
2026年3月30日 17:02

深度图和价格图都是基于D3.js库来绘制的svg图形

D3.js (Data-Driven Documents) 是一个用于操作文档的JavaScript库,它可以通过使用HTML, SVG 和 CSS等技术在网页上动态生成数据可视化。下面跟着以下步骤实现深度图的绘制:

1.数据集定义

首先我们需要定义好我们要展示的数据,看下将要用到的数据集:liquidity表示y轴数据,token0Price表示x轴数据

[ {    "tick" : -887200,    "liquidity" : "2516871586768523698747878",    "token0Price" : "0.000000000000000000000000000000000000002960192591918355544122581114448831",    "token1Price" : "337815857904011940012765396015654000000",    "timestamp" : 1691676253239  }, {    "tick" : -610800,    "liquidity" : "2516871586768523698747878",    "token0Price" : "0.000000000000000000000000002982766743656641268864955452810751",    "token1Price" : "335259202593253190167580281.8803577",    "timestamp" : 1691676253239  },  ......  ]

2.设置画布

我们需要定义好将要使用的画布大小、内边距等属性。我们将会创建一个宽度为400像素,高度为200像素,且四周留有{ top: 20, right: 2, bottom: 20, left: 0 }像素的空白的画布

const margins = { top: 20, right: 2, bottom: 20, left: 0 };
const width = 400
const height = 200

3.定义比例尺

根据数据集中的数值范围,我们需要创建与之对应的比例尺。使用scaleLinear()函数分别创建x轴和y轴的比例尺:

使用domain()方法来设置比例尺的输入域(即数据范围),使用range()方法来确定输出范围(在画布上的位置)。

  // 计算好绘制面积
  const [innerHeight, innerWidth] = useMemo(() => {
    return [      height - margins.top - margins.bottom,      width - margins.left - margins.right,    ];
  }, [width, height, margins]);
 
 // 创建X轴、Y轴比例尺
 const scales = {
      xScale: scaleLinear()
        .domain([
          getPriceOnTick(computedCurrentPrice * zoomLevel.initialMin),
          getPriceOnTick(computedCurrentPrice * zoomLevel.initialMax),
        ])
        .range([0, innerWidth]),
      yScale: scaleLinear()
        .domain([
          0,
          max(formattedData, (d) => {
            return yAccessor(d); // Y轴上的最大值
          }),
        ])
        .range([innerHeight, 0]),
    };

4.创建面积图

现在我们需要根据流动性值,来绘制生成面积图。可以使用d3.area()来创建一个面积图:

该函数创建的面积图会使用series数据集合中的值,将x轴和y轴上的各个点用曲线连接起来;使用.curve()方法指定了折线的形状,curveStepAfter 是d3提供的一种曲线类型定义。

import { area, curveStepAfter } from 'd3';
export const xAccessor = (d) => {
  return d.price0;  //x轴取值
};
export const yAccessor = (d) => {
  return d.activeLiquidity; // y轴取值
};

/**
 * 
 * @param 
     xScale: x轴比例尺
     yScale: y轴比例尺
     series: 数据集合
     fill  :面积填充颜色
     xValue :xAccessor
     yValue :yAccessor
 * @returns 
 */
export const Area = ({ xScale, yScale, series, xValue, yValue, fill }) => {
  const chartArea =
    xScale && yScale
      ? area()
          .curve(curveStepAfter)
          .x((d) => {
            return xScale(xValue(d));
          })
          .y0(yScale(0))
          .y1((d) => {
            return yScale(yValue(d));
          })(
          series.filter((d) => {
            const value = xScale(xValue(d));
            return value > 0;
          })
        )
      : null;
  return useMemo(() => {
    return <path fill={fill} d={chartArea} />;
  }, [fill, series, xScale, xValue, yScale, yValue]);
};

5.设置X坐标轴

我们需要添加坐标轴和数字标签,以便更好地显示数据。

深度图,我们只需要设置X坐标轴,并通过调用g元素上的.call()方法向画布上添加了这些坐标轴。我们还使用.transform()方法将坐标轴移动到正确的位置,使用.ticks(number)设置显示刻度的数量,并使用.tickFormat()自定义格式化坐标轴数据,.attr()可以像css一样设置刻度的样式

//X坐标轴
export const AxisBottom = ({ xScale, innerHeight, offset = 0 }) => {
  return useMemo(() => {
    if (xScale) {
      return (
        <g transform={`translate(0, ${innerHeight + offset})`}>
          <Axis
            axisGenerator={axisBottom(xScale)
              .ticks(6)
              .tickFormat((d) => {
                return formatD3value(d);
              })}
          />
        </g>
      );
    }
    return null;
  }, [innerHeight, offset, xScale]);
};

const Axis = ({ axisGenerator }) => {
  const axisRef = (axis) => {
    axis &&
      select(axis)
        .call(axisGenerator)
        .call((g) => {
          return g.select('.domain').remove();
        })
        .call((g) => {
          // 移除刻度上的锯齿
          return g.selectAll('.tick line').attr('display', 'none');
        })
        .call((g) => {
          return g
            .selectAll('.tick text')
            .attr('transform', `translate(${0},${2})`)
            .attr('fill', '#BDBDBD')
            .attr('font-size', '8px');
        });
  };

  return <g ref={axisRef} />;
};

6.绘制左右两根旗子

这里需要根据path来绘制,需要手动一点点调试样式

// 旗杆本身的path路径
export const brushHandlePath = (height) => {
  return [
    // handle
    `M 0 0`, // move to origin
    `v ${height}`, // vertical line
    // 'm 2 0', // move 1px to the right
    // `V 0`, // second vertical line
    `M 0 1`, // move to origin
    // head
    'h 10', // horizontal line
    'q 1 0, 1 1', // rounded corner
    'v 22', // vertical line
    'q 0 1 -1 1', // rounded corner
    'h -10', // horizontal line
    `z`, // close path
  ].join(' ');
};
// 旗杆头部填充的两根白色竖条的路径
export const brushHandleAccentPath = () => {
  return [
    'M 0 -3', // move to origin
    'm 3 7', // move to first accent
    'v 18', // vertical line
    'M 0 -3', // move to origin
    'm 8 7', // move to second accent
    'v 18', // vertical line
    'z',
  ].join(' ');
};
// 使用上面填好的path路径
const Handle = ({ color, d }) => {
  return (
    <path
      d={d}
      stroke={color}
      strokeWidth="2.5"
      fill={color}
      cursor="ew-resize"
      pointerEvents="none"
    />
  );
};

7.一切就绪,组装各UI模块

此时UI层面已经大功告成,就可以各模块组合看效果了

    // svg : 整个画布布局宽度、高度设置
     <svg  
        width="100%"
        height="100%"
        viewBox={`0 0 ${width} ${height}`}
        style={{ overflow: 'visible' }}
      >
       <defs>
       
          // brushDomain 就是两根旗子选中的范围,算出两个旗子的范围之差,然后截取出高亮的面积图
          {brushDomain && (
            // mask to highlight selected area
            <clipPath id={`${id}-chart-area-mask`}>
              <rect
                fill="white"
                x={xScale(brushDomain[0])}
                y={0}
                width={xScale(brushDomain[1]) - xScale(brushDomain[0])}
                height={innerHeight}
              />
            </clipPath>
          )}
        </defs>
        <g transform={`translate(${margins.left},${margins.top})`}>
          <g clipPath={`url(#${id}-chart-clip)`}>
            // 这是面积图
            <Area
              series={series}
              xScale={xScale}
              yScale={yScale}
              xValue={xAccessor}
              yValue={yAccessor}
              // fill="#78DE9D"
              fill="var(--okd-color-green-200)"
            />
            // 这里又绘制了一次面积图,原因是:两根旗子之间选中的流动性需要高亮,所以需要再绘制一个高亮颜色的面积图,并且 clipth = {`url(#${id}-chart-area-mask)`} 与上面的cliptath就对应上了。
            {brushDomain && (
              // duplicate area chart with mask for selected area
              <g clipPath={`url(#${id}-chart-area-mask)`}>
                <Area
                  series={series}
                  xScale={xScale}
                  yScale={yScale}
                  xValue={xAccessor}
                  yValue={yAccessor}
                  fill="var(--okd-color-green-500)"
                />
              </g>
            )}
            // 表示当前价格的一条竖线
            <Line value={current} xScale={xScale} innerHeight={innerHeight} />
            // X轴坐标轴
            <AxisBottom xScale={xScale} innerHeight={innerHeight} />
          </g>
           // 放大缩小
          <ZoomOverlay width={innerWidth} height={height} ref={zoomRef} />
          // 两根旗子
          <Brush
            id={id}
            xScale={xScale}
            interactive
            brushLabelValue={brushLabels}
            brushExtent={brushDomain ?? (xScale && xScale.domain())}
            innerWidth={innerWidth}
            innerHeight={innerHeight}
            setBrushExtent={onBrushDomainChange}
            westHandleColor="#31BD65"
            eastHandleColor="#31BD65"
          />
        </g>
      </svg>

8.静态部分完成,旗子可以动起来了

需要使用d3.brushX()函数:一维画笔X-尺寸。同样有brushY()函数:一维画笔Y-尺寸(价格图使用这个方法)

用法:d3.brushX();

参数:该函数不接受任何参数。 返回值:此函数沿x轴返回新创建的一维笔刷

  1、d3设置一维画笔
  brushBehavior.current = brushX()
      .extent([
        [Math.max(0 + BRUSH_EXTENT_MARGIN_PX, xScale(0)), 0],
        [innerWidth - BRUSH_EXTENT_MARGIN_PX, innerHeight],
      ]) //  extent 设置可刷取的范围
      .handleSize(30) // 设置brush柄的大小、默认为6
      .on('brush end', brushed); // 滑动结束事件,brushed里可以定义业务回调
    brushBehavior.current(select(brushRef.current)); // 选中的元素
    

2、画笔动作完成后,处理数据
  const onBrushDomainChange = useCallback((domain, mode) => {
    let leftRangeValue = Number(domain[0]);
    let rightRangeValue = Number(domain[1]);
    if (leftRangeValue <= 0) {
      leftRangeValue = 1 / 10 ** 18;
    }
    if (rightRangeValue > 1e35) {
      rightRangeValue = 1e35;
    }
    setLocalBrushExtent([leftRangeValue, rightRangeValue]);

    // 拖拽柱子时,handle:单根拖拽, drag:两个一起拖拽
    if (mode === 'handle' || mode === 'drag') {
      const { minPrice, maxPrice } = priceRange;

      // 左侧价格变化(minPrice)
      if (!compareIsEqualPrice(minPrice, leftRangeValue)) {
        // transformPriceOnTickPoint 把价格转化在整tick点上
        const { price, tick } = transformPriceOnTickPoint(
          leftRangeValue,
          false
        );
        // 改变下方价格输入框的值,以及和下单器询价联动
        onLeftRangeInput(tick, price);
      }

      //右侧价格变化(maxPrice)
      if (!compareIsEqualPrice(maxPrice, rightRangeValue)) {
        const { price, tick } = transformPriceOnTickPoint(
          rightRangeValue,
          mode === 'handle'
        );
        onRightRangeInput(tick, price);
      }
      updatePros({
        hasChangePrice: true,
      });
    }

    // 初始化时、点击重置价格区间时
    if (mode === 'reset' || mode === 'init') {
      if (leftRangeValue > 0) {
        const { price, tick } = transformPriceOnTickPoint(
          leftRangeValue,
          false
        );
        onLeftRangeInput(tick, price);
      }

      if (rightRangeValue > 0) {
        const { price, tick } = transformPriceOnTickPoint(
          rightRangeValue,
          mode !== 'init'
        );
        onRightRangeInput(tick, price);
      }
    }
    // 汇率反转时
    if (mode === 'reverse') {
      if (leftRangeValue > 0) {
        setPriceRange(PRICE_TYPE.MIN_PRICE, leftRangeValue);
      }
      if (rightRangeValue > 0) {
        setPriceRange(PRICE_TYPE.MAX_PRICE, rightRangeValue);
      }
    }
  });

  3、onRightRangeInput(以右侧价格为例) 价格输入框、下单器开始联动起来  
  const onRightRangeInput = (tickValue: number, priceValue: number) => {
    let rightInputTick = tickValue;
    let rightInputPrice = priceValue;
    
    const currentLeftRangeTick = isReverse
      ? -tickRange.tickUpper
      : tickRange.tickLower;
      // 判断滑动左右两个旗子的tick是否相同,如果是增加一个tickSpacing
    if (rightInputTick == currentLeftRangeTick) {
      rightInputTick += tickSpacing;
      rightInputPrice = getPriceByTick(
        rightInputTick,
        token0Precisions!,
        token1Precisions!
      );
    }

    // 更新价格范围-最大的价格
    uniV3SubscribeStore.setPriceRange(PRICE_TYPE.MAX_PRICE, rightInputPrice);
    
    // 判断是否是反转,决定更新tick的范围
    const tickType = isReverse ? TICK_TYPE.TICK_LOWER : TICK_TYPE.TICK_UPPER;
    // 更新tick
    uniV3SubscribeStore.setTickRange(
      tickType,
      isReverse ? -rightInputTick : rightInputTick
    );
    judgeOneSidedLiquidity(); // 判断单边流动性, 下单器是否投资单币、双币
    debounceV3ReceiveInfo(); // 根据最终的tick范围,开始询价
  };    

9.总结

以上就是深度图从UI层一步步的绘制,再到滑动旗杆改变价格,再计算得出tick范围,最终在下单器询价的整体流程

小程序双模式(文件 / 照片)上传组件封装与解析

作者 TT_哲哲
2026年3月30日 16:39

在小程序业务开发中,上传功能是高频场景,而部分业务需要同时支持文件上传(如 PDF、文档)和照片上传(如图片凭证)两种模式。

基于完整的业务代码,从 WXML 结构、JS 逻辑、WXSS 样式三个维度,深度解析一个支持双模式切换、带上传 / 预览 / 删除、容错处理完善的小程序上传组件。该组件直接复用即可落地,适配绝大多数表单类业务(如用印申请、资料提交等)。

image.png

image.png

通过wx:if根据uploadMode切换文件 / 照片上传 UI,同时实现切换按钮、上传区域、删除 / 预览功能。

js模式切换、文件上传、照片上传、删除 / 预览、异常处理全流程逻辑,代码注释详细,直接复用。

<view class="seal_wrapper">
  <view class="items_wrap">
    <!-- 标题栏:带必填标识 -->
    <view class="items_titles">
      <text>*</text>用印文件原件
    </view>
    <!-- 模式切换开关:文件/照片 -->
    <view class="upload-switch">
      <view class="upload-switch-item {{uploadMode === 'file' ? 'active' : ''}}" bind:tap="setUploadMode" data-mode="file">文件</view>
      <view class="upload-switch-item {{uploadMode === 'image' ? 'active' : ''}}" bind:tap="setUploadMode" data-mode="image">照片</view>
    </view>
    <!-- 上传内容区域:根据模式渲染 -->
    <view class="items_input">
      <!-- 文件上传模式 -->
      <view class="file-uploader" wx:if="{{uploadMode === 'file'}}">
        <!-- 已上传文件:显示文件名+删除按钮 -->
        <view class="file-item" wx:if="{{fileUrl}}">
          <view class="file-name">{{fileName || '已上传文件'}}</view>
          <image class="file-del" src="/img/close.png" mode="aspectFit" bind:tap="removeFile" />
        </view>
        <!-- 未上传文件:显示上传入口 -->
        <view class="file-add" bind:tap="chooseFile" wx:if="{{!fileUrl}}">
          <image class="file-add-icon" src="/img/add.png" mode="aspectFit"/>
          <view class="file-add-text">上传文件</view>
        </view>
      </view>

      <!-- 照片上传模式 -->
      <view class="images-grid" wx:if="{{uploadMode === 'image'}}">
        <!-- 已上传照片:显示图片+删除+预览 -->
        <view class="img-item" wx:if="{{images && images.length}}">
          <image class="img" src="{{images[0]}}" mode="aspectFit" bind:tap="previewImage" data-idx="0"/>
          <image class="img-del" src="/img/close.png" mode="aspectFit" bind:tap="removeImage" data-idx="0"/>
        </view>
        <!-- 未上传照片:显示上传入口 -->
        <view class="img-add" bind:tap="chooseImages">
          <image class="img-add-icon" src="/img/add.png" mode="aspectFit"/>
        </view>
      </view>
    </view>
  </view>
</view>

Page({
  data: {
    // 默认选中文件上传模式
    uploadMode: 'file',
    fileUrl: '', // 已上传文件的服务端链接
    fileName: '', // 已上传文件名称
    images: [] // 已上传照片链接数组
  },

  /**
   * 切换上传模式(文件/照片)
   */
  setUploadMode(e) {
    const mode = e?.currentTarget?.dataset?.mode || ''
    // 仅允许切换到合法模式
    if (mode !== 'file' && mode !== 'image') return
    this.setData({ uploadMode: mode })
  },

  /**
   * 删除已上传文件
   */
  removeFile() {
    this.setData({
      fileUrl: '',
      fileName: ''
    })
  },

  /**
   * 选择并上传文件(文档)
   */
  chooseFile() {
    wx.chooseMessageFile({
      count: 1, // 单次上传1个文件
      type: 'file', // 类型为文件
      success: (res) => {
        const files = res?.tempFiles || []
        const file = files[0] || null
        if (!file?.path) return

        // 保存文件名称
        this.setData({ fileName: file.name || '' })

        // 上传中loading
        wx.showLoading({ title: '上传中...' })
        // 调用上传接口
        wx.uploadFile({
          filePath: file.path,
          name: 'file', // 服务端接收的文件字段名
          url: api.ImgUpload(), // 替换为你的图片上传接口
          formData: {
            // 附加参数:用户身份标识
            OpenID: wx.getStorageSync('openId'),
            MID: wx.getStorageSync('mid'),
          },
          success: (resu) => {
            // 解析服务端返回(捕获JSON解析异常)
            let data = null
            try {
              data = JSON.parse(resu.data)
            } catch (e) {
              data = null
            }
            // 上传成功:保存文件链接
            if (data && data.code === 200 && data.data) {
              this.setData({ fileUrl: data.data })
              return
            }
            // 上传失败:提示错误信息
            wx.showToast({
              title: data?.msg || '上传失败',
              icon: 'none'
            })
          },
          fail: (err) => {
            console.error('文件上传失败:', err)
            wx.showToast({ title: '上传失败', icon: 'none' })
          },
          complete: () => {
            wx.hideLoading() // 无论成功失败,关闭loading
          }
        })
      },
      fail: (err) => {
        console.error('选择文件失败:', err)
      }
    })
  },

  /**
   * 预览照片
   */
  previewImage(e) {
    const idx = e?.currentTarget?.dataset?.idx || 0
    const urls = this.data.images || []
    if (!urls.length) return
    // 调用小程序原生预览API
    wx.previewImage({
      urls,
      current: urls[idx] || urls[0]
    })
  },

  /**
   * 删除已上传照片
   */
  removeImage(e) {
    const idx = e?.currentTarget?.dataset?.idx || 0
    const urls = [...(this.data.images || [])] // 浅拷贝避免直接修改原数据
    if (!urls.length) return
    urls.splice(idx, 1) // 删除对应索引的图片
    this.setData({ images: urls })
  },

  /**
   * 选择并上传照片
   */
  chooseImages() {
    wx.chooseMedia({
      count: 1, // 单次上传1张照片
      mediaType: ['image'], // 仅选择图片
      sourceType: ['album', 'camera'], // 支持相册/相机
      success: (res) => {
        const files = res?.tempFiles || []
        const tempFilePath = files[0]?.tempFilePath || ''
        if (!tempFilePath) return

        wx.showLoading({ title: '上传中...' })
        wx.uploadFile({
          filePath: tempFilePath,
          name: 'file',
          url: api.ImgUpload(), // 替换为你的图片上传接口
          formData: {
            OpenID: wx.getStorageSync('openId'),
            MID: wx.getStorageSync('mid'),
          },
          success: (resu) => {
            let data = null
            try {
              data = JSON.parse(resu.data)
            } catch (e) {
              data = null
            }
            if (data && data.code === 200 && data.data) {
              // 照片仅支持单张,直接覆盖数组
              this.setData({ images: [data.data] })
              return
            }
            wx.showToast({
              title: data?.msg || '上传失败',
              icon: 'none'
            })
          },
          fail: (err) => {
            console.error('照片上传失败:', err)
            wx.showToast({ title: '上传失败', icon: 'none' })
          },
          complete: () => {
            wx.hideLoading()
          }
        })
      },
      fail: (err) => {
        console.error('选择照片失败:', err)
      }
    })
  }
})



/* 外层容器:避免样式污染 */
.seal_wrapper .items_wrap {
  width: 100%;
  padding: 30rpx 0;
  border-bottom: 1rpx solid #f0f0f0;
}
/* 最后一项去掉下边框 */
.seal_wrapper .items_wrap:last-child {
  border-bottom: none;
}
/* 标题样式 */
.seal_wrapper .items_titles {
  font-size: 32rpx;
  margin-bottom: 20rpx;
  color: #222;
}
/* 必填红色星号 */
.seal_wrapper .items_titles text {
  color: #ff4d4f;
  margin-right: 8rpx;
}
/* 输入/上传区域 */
.seal_wrapper .items_input {
  width: 100%;
}
/* 模式切换容器 */
.seal_wrapper .upload-switch{
  display: flex;
  gap: 16rpx;
  margin: 8rpx 0 16rpx;
}
/* 切换项样式 */
.seal_wrapper .upload-switch-item{
  padding: 10rpx 26rpx;
  border-radius: 999rpx;
  background: #f3f4f6;
  color: #666;
  font-size: 26rpx;
  line-height: 1.4;
}
/* 选中态样式 */
.seal_wrapper .upload-switch-item.active{
  background: #e8f1ff;
  color: #2f7cff;
  font-weight: 700;
}
/* 文件上传容器 */
.seal_wrapper .file-uploader{
  width: 100%;
}
/* 未上传文件:虚线边框+居中 */
.seal_wrapper .file-add{
  width: 100%;
  height: 96rpx;
  border-radius: 16rpx;
  border: 2rpx dashed #d8d8d8;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14rpx;
}
/* 上传图标 */
.seal_wrapper .file-add-icon{
  width: 44rpx;
  height: 44rpx;
  opacity: 0.7;
}
/* 上传文字 */
.seal_wrapper .file-add-text{
  font-size: 28rpx;
  color: #666;
}
/* 已上传文件:背景色+弹性布局 */
.seal_wrapper .file-item{
  width: 100%;
  min-height: 96rpx;
  border-radius: 16rpx;
  background: #f9fafb;
  padding: 18rpx;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
/* 文件名:超出省略 */
.seal_wrapper .file-name{
  flex: 1;
  min-width: 0;
  font-size: 28rpx;
  color: #333;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  padding-right: 16rpx;
}
/* 删除按钮 */
.seal_wrapper .file-del{
  width: 36rpx;
  height: 36rpx;
  background: rgba(0,0,0,0.3);
  border-radius: 18rpx;
  padding: 4rpx;
  box-sizing: border-box;
}
/* 照片网格布局 */
.seal_wrapper .images-grid{
  display: flex;
  flex-wrap: wrap;
  gap: 18rpx;
}
/* 已上传照片容器 */
.seal_wrapper .img-item{
  width: 160rpx;
  height: 160rpx;
  border-radius: 16rpx;
  overflow: hidden;
  position: relative;
}
/* 照片 */
.seal_wrapper .img{
  width: 100%;
  height: 100%;
}
/* 照片删除按钮 */
.seal_wrapper .img-del{
  position: absolute;
  top: 6rpx;
  right: 6rpx;
  width: 36rpx;
  height: 36rpx;
  background: rgba(0,0,0,0.3);
  border-radius: 18rpx;
  padding: 4rpx;
  box-sizing: border-box;
}
/* 未上传照片:虚线边框+居中 */
.seal_wrapper .img-add{
  width: 160rpx;
  height: 160rpx;
  border-radius: 16rpx;
  border: 2rpx dashed #d8d8d8;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* 上传图标 */
.seal_wrapper .img-add-icon{
  width: 54rpx;
  height: 54rpx;
  opacity: 0.7;
}

最新版vue3+TypeScript开发入门到实战教程之Pinia详解

作者 angerdream
2026年3月30日 16:34

概述

Pinia 是 Vue.js 的官方状态管理库,可以把它看作是 Vuex 的升级版。它提供了更简洁的 API 和更好的 TypeScript 支持,已经成为 Vue 生态中推荐的状态管理方案。Pinia基本三要素:

  • store ,数据,用户自定义数据存储在store
  • getters,获取数据或进行加工后的数据,类似计算属性computed
  • actions,修改数据的方法

Pinia存储读取数据的基本方法

  • 安装Pinia,npm install pinia
  • 在main.ts引入Pinia,创建引用实例
  • 创建Fish组件,数据name,price,site
  • 创建store文件夹,创建useFishStore,存储Fish组件数据 文件结构目录 在这里插入图片描述 main.ts代码:
import { createApp } from 'vue'
import App from './App.vue'
import { createPinia } from 'pinia'
const app = createApp(App)
const pinia = createPinia()
app.use(pinia)
app.mount('#app')

Fis组件代码

<template>
  <div>
    <h2>鱼类:{{ store.name }}</h2>
    <h2>价格:{{ store.price }}</h2>
    <h2>位置:{{ store.site }}</h2>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
const store = useFishStore();
</script>

useFishStore.ts代码

import { defineStore } from 'pinia'
export const useFishStore = defineStore('fish', {
  state: () => ({
    name: '鲫鱼',
    price: 10,
    site:'河里'
  })
})

运行效果 在这里插入图片描述

Pinia修改数据的三种方法

  • 直接修改
  • 通过$patch方法修改
  • 通过actions修改

直接修改数据

Fish组件

<template>
  <div>
    <h2>鱼类:{{ store.name }}</h2>
    <h2>价格:{{ store.price }}</h2>
    <h2>位置:{{ store.site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
const store = useFishStore();
function changeFish() {
  store.name += '~';
  store.price += 10;
  store.site+='!'

}
</script>

修改效果如下: 在这里插入图片描述

通过$patch方法修改

Fish组件源码

<template>
  <div>
    <h2>鱼类:{{ store.name }}</h2>
    <h2>价格:{{ store.price }}</h2>
    <h2>位置:{{ store.site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
const store = useFishStore();
function changeFish() {
   store.$patch({
    name: '带鱼',
    price: 300,
    site:'海里'
  });
}
</script>

修改效果如图: 在这里插入图片描述

通过actions修改

useFishStore增加actions,添加方法changeFish

import { defineStore } from 'pinia'
export const useFishStore = defineStore('fish', {
  state: () => ({
    name: '鲫鱼',
    price: 10,
    site:'河里'
  }),
  actions: {
    changeFish(fish: any) {
      this.name = fish.name;
      this.price = fish.price;
      this.site = fish.site
    }
  }
})

Fish组件代码

<template>
  <div>
    <h2>鱼类:{{ store.name }}</h2>
    <h2>价格:{{ store.price }}</h2>
    <h2>位置:{{ store.site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
const store = useFishStore();
function changeFish() {
  store.changeFish({
    name: '带鱼',
    price: 300,
    site: '海里'
  });
}
</script>

运行效果如下: 在这里插入图片描述

Pinia函数storeToRefs应用

在Fish引用useFishStore,从useFishStore()直接解析数据,会丢失响应式,需要使用toRefs转换,但toRefs会将所有成员变成响应式对象。storeToRefs只会将数据转换成响应式对象。 Fish组件代码

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}</h2>
    <h2>位置:{{ site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { toRefs } from 'vue';
import { storeToRefs } from 'pinia'
console.log(toRefs(useFishStore()));
console.log(storeToRefs(useFishStore()));
let { name, price, site } = storeToRefs(useFishStore());

function changeFish() {
  name.value += '~';
  price.value += 2;
  site.value += '!';

}
</script>

运行效果如图,注意控制台打印的日志: 在这里插入图片描述

Getters用法

类似组件的 computed,对state 数据进行派生计算。state数据发生改变,调用getters函数。 useFishStore.ts代码:

import { defineStore } from 'pinia'
export const useFishStore = defineStore('fish', {
  state: () => ({
    name: '鲫鱼',
    price: 10,
    site:'河里'
  }),
  actions: {
    changeFish(fish: any) {
      this.name = fish.name;
      this.price = fish.price;
      this.site = fish.site
    }
  },
  getters: {
    changeprice():number {
      return this.price * 20;
    },
    changesite():string {
      return this.name+'在'+this.site+'游泳'
    }
  }
})

注意changeprice():number,ts语法检查,函数返回类型为number。 Fish组件代码

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}新价格:{{ changeprice }}</h2>
    <h2>位置:{{ site }}新位置:{{ changesite }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { toRefs } from 'vue';
import { storeToRefs } from 'pinia'
console.log(toRefs(useFishStore()));
console.log(storeToRefs(useFishStore()));
let { name, price, site,changeprice,changesite } = storeToRefs(useFishStore());

function changeFish() {
  name.value += '~';
  price.value += 2;
  site.value += '!';

}
</script>

运行效果如图: 在这里插入图片描述

$subscribe用法

subscribe订阅信息,当数据发生变化,回调subscribe订阅信息,当数据发生变化,回调subscribe函数设定的回调函数,该函数有两个参数:一是事件信息,一是修改后的数据数据。 $subscribe用于两组件的数据通信,Fish组件数据发生变化时,通知Cat组件。

import { defineStore } from 'pinia'
export const useFishStore = defineStore('fish', {
  state: () => ({
    name: '鲫鱼',
    price: 10,
    site:'河里'
  }),
  actions: {
    changeFish(fish: any) {
      this.name = fish.name;
      this.price = fish.price;
      this.site = fish.site
    }
  }
})

Fish组件:

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}</h2>
    <h2>位置:{{ site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { storeToRefs } from 'pinia'
let store = useFishStore()
let { name, price, site } = storeToRefs(store);
function changeFish() {
  name.value += '~';
  price.value += 2;
  site.value += '!';

}
</script>

Cat组件

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}</h2>
    <h2>位置:{{ site }}</h2>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { ref } from 'vue';
let name = ref('');
let price = ref(0);
let site=ref('')
let store = useFishStore();
store.$subscribe((mutate, state) => {
  console.log(mutate);
  console.log(state);
  name.value = state.name;
  price.value = state.price;
  site.value = state.site;
});

</script>

效果如图: 在这里插入图片描述 注意控制台打印的数据

Pinia组合式写法

组合式是vue3中新语法,有以下优势,

  • 轻松提取和组合业务逻辑
  • 使用所有 Vue 组合式 API(ref、computed、watch、生命周期等)
  • 逻辑可以聚合在一起,而不是分散在不同配置项中
import { defineStore } from 'pinia'
import { computed, ref } from 'vue';
export const useFishStore = defineStore('fish', () => {
  let name = ref('鲫鱼');
  let price = ref(10);
  let site = ref('河里');
  function changeFish(fish: any) {
    console.log(fish)
    name.value = fish.name;
    price.value = fish.price;
    site.value = fish.site;
  }
  let calcPrice = computed(() => {
    return price.value * 2;

  })
  return { name, price,site,changeFish,calcPrice };

})

Fish组件

<template>
  <div>
    <h2>鱼类:{{ name }}</h2>
    <h2>价格:{{ price }}新价格:{{ calcPrice }}</h2>
    <h2>位置:{{ site }}</h2>
    <button @click="changeFish()">修改鱼的数据</button>
  </div>
</template>
<script setup lang="ts">
import { useFishStore } from '@/store/useFishStore'
import { storeToRefs } from 'pinia'
let store = useFishStore()
let { name, price, site ,calcPrice} = storeToRefs(store);
function changeFish() {
  store.changeFish({ name: '带鱼', price: 11, site: '海里' })

}
</script>

运行效果 在这里插入图片描述

小程序解析字符串拼接多图 点击放大展示

作者 TT_哲哲
2026年3月30日 16:28
detailsinfo: { // 逗号分隔的图片链接字符串(核心字段) 
    DocumentUrls: "http://192.168.0.28:53417/UpImg/20260330/20260330143414_583.png,http://192.168.0.28:53417/UpImg/20260330/20260330143416_482.png"
}
<view class="details_item" bind:tap="previewDocImage" data-url="{{detailsinfo.DocumentUrls}}">
    <view class="details_item_title">图片:</view>
    <view class="details_item_info text-d">详情</view>
</view>
  /**
 * 图片预览方法
 * 兼容:数组格式 / 逗号分隔字符串格式 的图片链接
 */
previewDocImage(e) {
  // 1. 从自定义属性中获取图片链接(容错获取,防止undefined报错)
  let url = e?.currentTarget?.dataset?.url || '';
  let urls = [];

  // 2. 格式兼容处理:数组直接用,字符串分割转数组
  if (Array.isArray(url)) {
    // 后端直接返回数组
    urls = url;
  } else if (typeof url === 'string') {
    // 后端返回逗号拼接字符串:分割 + 去空格 + 过滤空值
    urls = url.split(',').map(s => (s || '').trim()).filter(Boolean);
  }

  // 3. 兜底处理:如果dataset传参失败,直接从页面数据中获取
  if (!urls.length) {
    const detailsinfo = this.data.detailsinfo || {};
    const fallbackUrl = detailsinfo.DocumentUrls || '';
    
    if (Array.isArray(fallbackUrl)) {
      urls = fallbackUrl;
    } else if (typeof fallbackUrl === 'string') {
      urls = fallbackUrl.split(',').map(s => (s || '').trim()).filter(Boolean);
    }
  }

  // 4. 无图片时提示用户
  if (!urls.length) {
    wx.showToast({
      title: '暂无图片',
      icon: 'none',
    });
    return;
  }

  // 5. 调用微信原生图片预览API
  wx.previewImage({
    current: urls[0], // 默认显示第一张
    urls: urls, // 所有需要预览的图片链接
  });
},

image.png

点击详情后展示👇

image.png

① 容错获取传参

使用 ES6 可选链操作符 ?.,避免因 e/currentTarget/dataset 不存在导致代码报错,是小程序开发必备的容错写法。

② 格式兼容处理

  • 数组:直接赋值使用
  • 字符串:split(',') 分割成数组 + trim() 去空格 + filter(Boolean) 过滤空链接,保证数据纯净

③ 双层兜底保障

防止 data-url 传参失败,直接从页面 data 中重新获取图片链接,双重保险,绝不崩溃。

④ 无图友好提示

数组长度为 0 时,用 wx.showToast 提示用户,提升体验。

⑤ 调用原生预览

wx.previewImage 是微信官方 API:

  • current:当前显示的图片链接
  • urls:需要预览的所有图片数组

实现多角色模式切换

2026年3月30日 16:25

新增功能 支持 4 种模式:

  • 默认助手
  • 技术顾问
  • 面试教练
  • 陪伴模式

效果:

  • 已有会话可切换新建会话时可指定角色角色
  • 切换后自动更新该会话的 system prompt
  • 左侧列表可看到当前角色标签

1)新增文件

web/src/utils/persona.js

export const PERSONA_MAP = {
  default: {
    label: '默认助手',
    systemPrompt: '你是一个专业、友好、清晰的 AI 助手,回答准确,表达简洁。',
  },
  tech: {
    label: '技术顾问',
    systemPrompt:
      '你是一个资深技术顾问,擅长前端、后端、AI Agent、工程架构。回答要结构化、专业、可落地,优先给出实操建议。',
  },
  interview: {
    label: '面试教练',
    systemPrompt:
      '你是一个资深面试教练,擅长帮助候选人准备前端、全栈、AI Agent、系统设计面试。回答要突出面试重点、考点、答题思路和表达方式。',
  },
  companion: {
    label: '陪伴模式',
    systemPrompt:
      '你是一个温柔、聪明、会长期陪伴用户的 AI 伙伴。你会共情、鼓励、倾听,并自然结合上下文进行交流。',
  },
}

export const PERSONA_OPTIONS = Object.entries(PERSONA_MAP).map(([value, item]) => ({
  value,
  label: item.label,
}))

2)改 web/src/utils/session.js

import { PERSONA_MAP } from './persona'

createSession 改成:

export function createSession(title = '新对话', mode = 'companion') {
  const persona = PERSONA_MAP[mode] || PERSONA_MAP.companion

  return {
    id: crypto.randomUUID(),
    title,
    mode,
    pinned: false,
    createdAt: Date.now(),
    updatedAt: Date.now(),
    messages: [
      {
        role: 'system',
        content: persona.systemPrompt,
      },
      {
        role: 'assistant',
        content: '你好,我已经准备好了。你今天想聊什么?',
      },
    ],
  }
}

loadSessions 里的 normalize 部分

    const normalized = sessions.map(item => ({
      mode: 'companion',
      pinned: false,
      ...item,
    }))

3)改 web/src/App.vue

改 import

import { createSession, loadSessions, saveSessions, sortSessions } from './utils/session'
import { PERSONA_MAP, PERSONA_OPTIONS } from './utils/persona'

新增状态

const createMode = ref('companion')

新增方法

const resetSystemMessageByMode = (messages = [], mode = 'companion') => {
  const persona = PERSONA_MAP[mode] || PERSONA_MAP.companion
  const nextMessages = [...messages]

  const systemIndex = nextMessages.findIndex(item => item.role === 'system')
  if (systemIndex > -1) {
    nextMessages[systemIndex] = {
      ...nextMessages[systemIndex],
      content: persona.systemPrompt,
    }
  } else {
    nextMessages.unshift({
      role: 'system',
      content: persona.systemPrompt,
    })
  }

  return nextMessages
}

const handleChangeSessionMode = (id, mode) => {
  sessions.value = sortSessions(
    sessions.value.map(item =>
      item.id === id
        ? {
            ...item,
            mode,
            updatedAt: Date.now(),
            messages: resetSystemMessageByMode(item.messages, mode),
          }
        : item
    )
  )
}

handleCreateSession

const handleCreateSession = () => {
  const session = createSession(`新对话 ${sessions.value.length + 1}`, createMode.value)
  sessions.value = sortSessions([session, ...sessions.value])
  currentSessionId.value = session.id
}

4)改模板

在左侧新建按钮下面加角色选择

<select v-model="createMode" class="mode-select">
  <option
    v-for="item in PERSONA_OPTIONS"
    :key="item.value"
    :value="item.value"
  >
    {{ item.label }}
  </option>
</select>

在每个会话卡片里加角色标签和切换

<div class="session-meta">
  <span class="mode-badge">
    {{ PERSONA_MAP[item.mode]?.label || '陪伴模式' }}
  </span>

  <select
    class="session-mode-select"
    :value="item.mode"
    @click.stop
    @change.stop="handleChangeSessionMode(item.id, $event.target.value)"
  >
    <option
      v-for="option in PERSONA_OPTIONS"
      :key="option.value"
      :value="option.value"
    >
      {{ option.label }}
    </option>
  </select>
</div>

<div class="session-time">{{ formatTime(item.updatedAt) }}</div>

5)补充样式

.mode-select {
  width: 100%;
  box-sizing: border-box;
  border: 1px solid #d1d5db;
  border-radius: 10px;
  padding: 10px 12px;
  font-size: 14px;
  outline: none;
  margin-bottom: 12px;
  background: #fff;
}

.session-meta {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 6px;
}

.mode-badge {
  display: inline-flex;
  align-items: center;
  font-size: 11px;
  line-height: 1;
  padding: 5px 8px;
  border-radius: 999px;
  background: #ecfeff;
  color: #0f766e;
}

.session-mode-select {
  max-width: 110px;
  border: 1px solid #d1d5db;
  border-radius: 8px;
  padding: 4px 6px;
  font-size: 12px;
  background: #fff;
  color: #374151;
  outline: none;
}

6)验证

新建会话时选模式

先切换成:

  • 技术顾问
  • 面试教练
  • 陪伴模式

再点新建,会话会带着对应系统人设创建。

已有会话切模式

在某个会话卡片里切换角色后,再提问:

不同模式的回答风格不同。

image.png

image.png

非常nice !

仓库地址

完整代码请看仓库,仓库地址:github.com/huanhunmao/… star 🌟🌟🌟 谢谢~

el-popover气泡宽度由内容撑起

2026年3月30日 16:21

前言

  • el-popover有个width属性,就算不填也有默认值150。想要让气泡内容撑起气泡宽度要特殊处理
  • 以下内容由Trae改造生成

方案

<template>
  <el-popover
    placement="top"
    :width="0"
    effect="light"
    popper-class="auto-fit-popover"
    v-model:visible="popoverShow"
  >
    <div class="chat-popover-options">
      <div
        class="ctrl-item"
        @click="fuc1()"
      >
        功能1
      </div>
    </div>
    <template #reference>
      <button @click="popoverShow = true"></button>
    </template>
  </el-popover>
</template>

<style>
.auto-fit-popover {
  min-width: unset !important;
  width: auto !important;
  white-space: nowrap;
}
</style>

实现原理

  • :width="0" :通过设置宽度为 0,让 Element Plus 的 popover 组件不使用默认的固定宽度
  • !important :使用 !important 来覆盖 Element Plus 的默认样式,确保我们的样式能够生效
  • white-space: nowrap; :确保内容不会换行,这样气泡框的宽度就会根据内容的实际宽度来调整

「九九八十一难」从回调地狱到异步秩序:深入理解 JavaScript Promise

作者 从文处安
2026年3月30日 16:18

前言

在 JavaScript 的世界里,Promise 几乎已经成为异步编程的基础设施。
很多人会用 thencatchasync/await,但如果继续追问:Promise 到底解决了什么问题?
为什么它能让异步代码变得可控? then 为什么总会返回一个新的 Promise?
微任务和事件循环与 Promise 究竟是什么关系?
为什么有时 await 看起来像同步,实际上却不是?
这些问题,才是理解 Promise 的分水岭。

这篇文章不打算停留在“Promise 是什么、怎么用”的入门层面,而是尝试从设计动机、状态机模型、链式调用、错误传播到事件循环机制,系统地把 Promise 讲透。
因为只有理解其背后的抽象,我们才能在复杂业务里真正写出稳定、可维护的异步代码。

一、Promise 出现之前:JavaScript 的异步困境

JavaScript 天生是单线程语言。
它的设计目标不是做高并发服务端调度,而是服务浏览器中的交互逻辑。
因此,面对网络请求、定时器、文件读取这类耗时任务时,JavaScript 不能阻塞主线程,只能采用异步回调的方式处理。

早期最常见的写法如下:

getUser(userId, function (user) {
  getOrders(user.id, function (orders) {
    getDetail(orders[0].id, function (detail) {
      console.log(detail)
    }, function (err) {
      console.error(err)
    })
  }, function (err) {
    console.error(err)
  })
})

这类代码的问题并不只是“丑”,而是它暴露了几个根本缺陷。

1. 控制流分散

异步逻辑嵌套在多个回调里,主流程被撕裂,代码阅读顺序不再等于执行逻辑顺序。

2. 错误处理不统一

每一层都可能有自己的错误回调,异常处理策略难以收敛,导致遗漏和重复都很常见。

3. 信任问题

把回调交给第三方函数后,你无法完全控制它:它会不会被调用两次?会不会永远不调用?会不会同步调用、破坏你的预期?会不会吞掉错误?这被称为控制反转带来的信任危机。

而 Promise,本质上就是为了解决这种“不可靠的异步协作关系”。

二、Promise 的本质:异步结果的“未来值”

很多文章说 Promise 是“承诺”,这翻译没错,但不够技术化。更准确地说:Promise 是一个用于表示“未来某个时间点才会确定的值”的对象

这个对象不直接保存最终结果,而是保存一种状态演进关系:

  • pending:等待中
  • fulfilled:已成功
  • rejected:已失败

例如:

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('done')
  }, 1000)
})

这里 promise 在创建时是 pending,一秒后变成 fulfilled,值为 'done'

但 Promise 真正重要的不是“保存结果”,而是它提供了三种能力:状态不可逆、结果可传递、错误可冒泡。这三点共同构成了 Promise 的工程价值。

三、Promise 为什么可靠:状态机思想

Promise 可以被理解为一个一次性、不可逆的状态机。

1. 状态只能从 pending 变为终态

Promise 一旦从 pending 变成 fulfilledrejected,就不能再改变。

const p = new Promise((resolve, reject) => {
  resolve(1)
  reject(2)
  resolve(3)
})

p.then(console.log) // 1

只有第一次状态变更有效,后续都会被忽略。

2. 一旦状态确定,结果就固定

这意味着 Promise 的结果是可缓存的、可复用的。即使在 Promise 已经完成后再调用 then,依然能拿到同样的结果。

const p = Promise.resolve(42)

p.then(value => console.log(value))
p.then(value => console.log(value))

这两次都会输出 42

3. 回调一定是异步调度的

即使 Promise 已经完成,then 中的回调也不会立即同步执行,而是进入微任务队列。

Promise.resolve().then(() => console.log('promise'))
console.log('sync')

输出顺序是:

sync
promise

这避免了“有时同步、有时异步”的执行不一致问题,增强了异步代码的可预测性。

四、 Promise 真正的灵魂 then

很多人以为 Promise 的核心是 new Promise(...),其实不是。
Promise 的核心是 then
因为真正让异步逻辑能够线性组合、持续传递的,不是构造器,而是 then 的返回机制。

1. then 总会返回一个新的 Promise

const p1 = Promise.resolve(1)

const p2 = p1.then(value => value + 1)

p2.then(console.log) // 2

这里 p2 不是 p1 本身,而是一个全新的 Promise。每一次 then,都像是在当前异步结果之上,构造下一段异步关系。

2. 返回普通值,会包装成成功 Promise

Promise.resolve(1)
  .then(value => value + 1)
  .then(value => console.log(value)) // 2

value + 1 是普通值,但会被自动包装成 Promise.resolve(2)

3. 返回 Promise,会自动“接管”其状态

Promise.resolve(1)
  .then(value => {
    return new Promise(resolve => {
      setTimeout(() => resolve(value + 1), 1000)
    })
  })
  .then(console.log) // 1秒后输出 2

这被称为 Promise Resolution Procedure。也正是因为这个规则,Promise 才可以自然地“接住”后续异步任务。

4. 抛出异常,会转为失败 Promise

Promise.resolve()
  .then(() => {
    throw new Error('something wrong')
  })
  .catch(err => console.error(err.message))

输出:

something wrong

这一点非常关键:异常和异步失败在 Promise 体系里被统一了。

五、错误传播:Promise 最强大的工程价值之一

传统回调模式下,错误处理往往分布在各层回调里。但 Promise 提供了类似同步代码的“异常冒泡”能力。

getUser()
  .then(user => getOrders(user.id))
  .then(orders => getDetail(orders[0].id))
  .then(detail => console.log(detail))
  .catch(err => {
    console.error('统一处理错误:', err)
  })

这段代码的优雅之处在于:任意一步失败,都会直接跳到 catch;中间不需要层层传递错误处理逻辑;业务主流程和异常处理逻辑可以自然分离。

但也要注意一个误区:catch 不是“只捕获 reject”,它还会捕获前面 then 回调里抛出的同步异常。

Promise.resolve()
  .then(() => {
    JSON.parse('{')
  })
  .catch(err => {
    console.error('捕获到异常')
  })

所以从抽象层看,Promise 做的是:把“异步失败”和“同步异常”统一纳入一个可链式传播的错误模型中。

六、Promise 并不是“让异步变同步”

这是一个非常常见的误解。尤其在 async/await 普及之后,很多人会说:await 就是把异步代码写成同步了。这个说法只对了一半。

1. 从写法上,它更像同步

async function main() {
  try {
    const user = await getUser()
    const orders = await getOrders(user.id)
    const detail = await getDetail(orders[0].id)
    console.log(detail)
  } catch (err) {
    console.error(err)
  }
}

这比链式 then 更贴近人的思维顺序。

2. 但从执行机制上,它仍然是异步

await 本质上只是 Promise 的语法糖。它不会阻塞整个 JavaScript 线程,而是暂停当前 async 函数,把后续逻辑放进微任务队列,等待 Promise 完成后再恢复执行。

所以 Promise 并没有消灭异步,只是把异步从“回调嵌套”提升为“可组合的流程控制”

七、Promise 与事件循环:为什么它总比 setTimeout 更早执行?

理解 Promise,绕不开事件循环。

来看这段经典代码:

setTimeout(() => {
  console.log('timeout')
}, 0)

Promise.resolve().then(() => {
  console.log('promise')
})

console.log('sync')

输出顺序:

sync
promise
timeout

原因在于:先执行主线程同步代码,输出 syncPromise.then 回调进入微任务队列;setTimeout 回调进入宏任务队列;当前同步任务执行完毕后,先清空微任务队列,再进入下一轮事件循环执行宏任务。

也就是说,Promise 的回调并不是“立刻执行”,而是进入微任务队列;但微任务的优先级高于宏任务。这也是为什么在性能敏感或执行顺序敏感的场景里,Promise 往往比 setTimeout(fn, 0) 更精确。

八、Promise 的价值,不只是“写法更优雅”

如果只把 Promise 看成“为了避免回调地狱”,其实低估了它。
它的真正价值在于:为异步操作提供了一套可组合、可传递、可推理的抽象。

这体现在三个层面:

1. 抽象层面:异步结果被对象化

Promise 把“未来的值”封装成对象,使异步结果可以像普通值一样被传递、组合和包装。

2. 控制流层面:异步逻辑被线性化

通过链式调用和错误冒泡,原本分裂的异步控制流变得连续、可读、可维护。

3. 工程层面:协作关系更加可信

Promise 明确规定了:状态只能改变一次、回调一定异步执行、错误一定可传播。这消除了传统回调里大量不确定行为。

九、Promise 常见误区

1. new Promise 不是越多越好

很多人喜欢把任何异步都手动包一层:

return new Promise((resolve, reject) => {
  fetch(url)
    .then(res => resolve(res))
    .catch(err => reject(err))
})

这通常是多余的,应直接返回原 Promise:

return fetch(url)

原则是:如果一个函数已经返回 Promise,就不要再手动套一层 Promise。

2. then(success, fail) 不如 then(success).catch(fail)

因为第二种写法能捕获 then 内部抛出的异常,错误处理链更完整

3. Promise 不是取消机制

Promise 一旦创建,内部任务通常就已经开始执行。它表示结果,不负责中断过程。真正的取消往往要借助 AbortController 等额外机制。

4. Promise.all 不是“等待所有都完成再决定”

Promise.all 只要有一个失败,就会立刻进入 reject。如果你想看所有结果,不管成功失败,应使用 Promise.allSettled()

十、如何真正写好 Promise 代码?

理解原理之后,还需要形成实践习惯。

1. 保持链式结构清晰

每个 then 尽量只做一件事,不要塞入过多逻辑。

2. 统一错误出口

优先让错误自然冒泡,在链尾统一处理,而不是中途到处 catch

3. 能返回就返回

then 中要明确返回值,否则链会丢失结果。

doSomething()
  .then(result => {
    return process(result)
  })
  .then(finalResult => {
    console.log(finalResult)
  })

4. async/await 用于流程表达,Promise 用于组合能力

  • 顺序流程:async/await
  • 并发聚合:Promise.all
  • 容错收集:Promise.allSettled
  • 竞速场景:Promise.race / Promise.any

这是一种更成熟的使用方式。

十一、Promise 的终局意义:让异步编程进入“秩序时代”

如果说回调函数让 JavaScript 获得了异步能力,那么 Promise 则让 JavaScript 的异步编程第一次拥有了秩序。

它不是简单的语法改良,而是一次抽象升级:它把异步结果对象化,把控制流链式化,把错误传播标准化,让复杂异步系统具备了更强的可推理性。

从这个意义上说,Promise 的真正价值不在于“避免回调地狱”,而在于它重新定义了 JavaScript 处理异步的方式。

今天我们大量使用 async/await,看似已经离 Promise 很远,但实际上,async/await 只不过是站在 Promise 之上的语法糖。如果不理解 Promise,就很难真正理解现代 JavaScript 的异步本质。

结语

Promise 不是一个“需要会背 API”的知识点,而是 JavaScript 异步编程模型的核心组成部分。

当你真正理解了 Promise,你得到的不只是几个方法的记忆,而是一种处理异步问题的思维方式:如何描述未来的结果,如何组织复杂的异步流程,如何让错误传播变得统一,如何在事件循环中理解执行顺序。

而这些,才是 Promise 最值得学习的地方。

通用管理后台组件库-14-图表和富文本组件

作者 没想好d
2026年3月30日 15:46

图表组件和富文本组件

说明:图表组件使用Echarts做二次封装,富文本组件使用Vditor插件做二次封装。

1.实现效果

image.png

image.png

2.图表组件Charts.vue

<template>
  <div ref="chartRef" :style="chartStyle"></div>
</template>

<script setup lang="ts">
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
import type { CSSProperties } from 'vue'

interface ChartsProps {
  option: EChartsOption
  height?: number | string
  width?: number | string
  autoresize?: boolean
}
const props = withDefaults(defineProps<ChartsProps>(), {
  autoresize: true
})

const attrsStyle = useAttrs()

const emits = defineEmits(['init'])

// shallowRef浅层响应式
const chartRef = shallowRef()

const chartInstance = shallowRef<ECharts>()

const chartStyle = computed(() => {
  // 获取父组件传递的样式
  const style = (attrsStyle.style || {}) as CSSProperties
  return {
    height: props.height
      ? typeof props.height === 'number'
        ? props.height + 'px'
        : props.height
      : '400px',
    width: props.width
      ? typeof props.width === 'number'
        ? props.width + 'px'
        : props.width
      : '100%',
    ...style
  }
})

function initChart() {
  if (chartRef.value) {
    // 初始化echarts实例
    chartInstance.value = echarts.init(chartRef.value)
    // 使用指定的配置项和数据显示图表
    chartInstance.value.setOption(props.option as EChartsOption)
    // 导出echarts实例
    emits('init', chartInstance.value)
  }
}
function resizeChart() {
  if (chartInstance.value) {
    chartInstance.value.resize()
  }
}
watch(
  () => props.option,
  () => {
    if (chartInstance.value) {
      chartInstance.value.setOption(props.option as EChartsOption)
    }
  },
  { deep: true }
)
const fn = useThrottleFn(resizeChart, 50)
onMounted(() => {
  initChart()
  // 监听窗口变化
  if (props.autoresize) {
    window.addEventListener('resize', fn)
  }
})
onUnmounted(() => {
  // 销毁实例
  chartInstance.value && chartInstance.value?.dispose()
  // 移除监听
  if (props.autoresize) {
    window.removeEventListener('resize', fn)
  }
})
</script>

<style scoped></style>

demo文件charts.vue

  <!-- <VueEcharts :option="option" autoresize theme="dark" :height="300" /> -->
  <Charts :option="option" autoresize :height="300" @init="handleInit" />
  <el-button @click="handleClick">更换数据</el-button>
</template>

<script setup lang="ts">
import type { EChartsOption } from 'echarts'

definePage({
  meta: {
    title: '基础图表',
    icon: 'mdi:bee'
  }
})

const option = ref<EChartsOption>({
  title: {
    text: 'Traffic Sources',
    left: 'center'
  },
  tooltip: {
    trigger: 'item',
    formatter: '{a} <br/>{b} : {c} ({d}%)'
  },
  legend: {
    orient: 'vertical',
    left: 'left',
    data: ['Direct', 'Email', 'Ad Networks', 'Video Ads', 'Search Engines']
  },
  series: [
    {
      name: 'Traffic Sources',
      type: 'pie',
      radius: '55%',
      center: ['50%', '60%'],
      data: [
        { value: 335, name: 'Direct' },
        { value: 310, name: 'Email' },
        { value: 234, name: 'Ad Networks' },
        { value: 135, name: 'Video Ads' },
        { value: 1548, name: 'Search Engines' }
      ],
      emphasis: {
        itemStyle: {
          shadowBlur: 10,
          shadowOffsetX: 0,
          shadowColor: 'rgba(0, 0, 0, 0.5)'
        }
      }
    }
  ]
})
const handleClick = () => {
  if (option.value.series && option.value.series[0] && option.value.series[0].data) {
    option.value.series[0].data[0].value = 100
  }
}
const handleInit = (instance) => {
  console.log('🚀 ~ handleInit ~ instance:', instance)
}

</script>

<style scoped></style>

3.富文本组件

类型文件types.d.ts

/// <reference types="vditor/dist/types" />

export type VditorOptions = IOptions

export interface EditorProps {
  options: VditorOptions
}

封装的组件Editor.vue

<template>
  <div ref="editorRef"></div>
</template>

<script setup lang="ts">
import Vditor from 'vditor'
import type { EditorProps, VditorOptions } from './types.d'
import 'vditor/dist/index.css'

const props = defineProps<EditorProps>()
const emits = defineEmits(['init'])

// 编辑器容器
const editorRef = ref()
// 编辑器实例
const editorInstance = shallowRef<Vditor>()

// 输入框内容
const modelValue = defineModel()

// 记录历史值
const history = ref('')

// 默认配置
const defaultOptions: VditorOptions = {
  rtl: false,
  mode: 'ir',
  value: '',
  debugger: false,
  typewriterMode: true,
  height: 'auto',
  minHeight: 400,
  width: 'auto',
  placeholder: '',
  fullscreen: { index: 90 },
  counter: {
    enable: false, // 默认值: false
    type: 'markdown' // 默认值: 'markdown'
  },
  link: {
    isOpen: true // 默认值: true
  },
  image: {
    isPreview: true // 默认值: true
  },
  cache: { enable: true, id: Math.random().toString(16).slice(2) },
  lang: 'zh_CN',
  theme: 'classic',
  icon: 'ant',
  cdn: 'https://unpkg.com/vditor@3.9.6'
}

// 监听输入框内容变化
watch(modelValue, (newValue) => {
  // 输入的值是否和旧值相同
  const isCommon = `${newValue}` !== editorInstance.value?.getValue()
  // 给编辑器赋值
  if (editorInstance.value && newValue && isCommon) {
    editorInstance.value?.setValue(newValue + '')
  }
})

// 监听props.options的变化
// watch(
//   // 使用箭头函数获取props.options作为监听源
//   () => props.options,
//   // 当options发生变化时执行的回调函数
//   (newOptions) => {
//     // 保存当前编辑器的内容到历史记录
//     history.value = editorInstance.value?.getValue() || ''
//     // 销毁当前的编辑器实例
//     editorInstance.value?.destroy()
//     // 使用新的options重新初始化编辑器
//     initEditor(newOptions)
//   },
//   { deep: true }
// )
// 初始化编辑器
function initEditor(options) {
  // 编辑器异步渲染完成后的回调方法
  const defaultAfter = options?.after
  // 输入后触发的回调方法
  const defaultInput = options?.input
  // 初始化编辑器实例
  const instance = new Vditor(
    editorRef.value,
    Object.assign(defaultOptions, {
      ...options,
      after: () => {
        defaultAfter && defaultAfter()
        // 如果有历史记录,则将历史记录赋值给编辑器
        if(history.value) {
          instance.setValue(history.value, true)
        }
        // 编辑器渲染完成时,获取输入框内容
        modelValue.value = instance.getValue()
      },
      input: (md) => {
        defaultInput && defaultInput(md)
        // 编辑器内容变化时,获取输入框内容
        modelValue.value = md
      }
    })
  )

  // 赋值初始值
  modelValue.value = props.options?.value || ''
}
onMounted(() => {
  initEditor(props.options)
  emits('init', editorInstance.value)
})

onBeforeUnmount(() => {
  editorInstance.value?.destroy()
})
</script>

<style scoped></style>

demo组件editor.vue

<template>
  <Editor :options="{ value: '2323' }" v-model="editorValue"></Editor>
</template>

<script setup lang="ts">

definePage({
  meta: {
    title: '基础编辑器',
    icon: 'mdi:chart-box-plus-outline'
  }
})


const editorValue = ref('')
</script>

<style scoped></style>

ag-grid 在 Vue 项目中的实战:为什么它是表格组件的天花板

作者 小哈猪
2026年3月30日 15:41

ag-grid 在 Vue 项目中的实战:为什么它是表格组件的天花板

在 Vue 项目中做数据表格,你可能用过 Element Plus 的 el-table、Ant Design Vue 的 a-table,或者自己封装过原生 table。但如果你真正用过 ag-grid,再回头看其他表格组件,会有一种"从马车换到汽车"的感觉。本文不讲 API 文档,只讲它的核心能力,以及为什么它是目前 Vue 生态中最强大的表格方案。


一、为什么 Vue 项目里要用 ag-grid?

先说清楚一件事:ag-grid 不只是一个"表格",它是一个企业级数据展示和交互平台

Vue 自带的 v-for 渲染表格,Element Plus 的 el-table,这些做简单表格没问题。但当你的需求进入这些场景:

  • 数据量超过 10000 行
  • 需要服务端分页+排序+过滤组合查询
  • 列要拖拽调整宽度、锁定、合并
  • 单元格要自定义渲染(图表、下拉、按钮)
  • 要导出 Excel/PDF
  • 要做行选中、行分组、小计汇总

el-table 开始吃力,ag-grid 却游刃有余。


二、Vue 项目集成 ag-grid,三分钟上手

安装

npm install ag-grid-community ag-grid-vue3

基本使用

<template>
  <div class="ag-theme-quartz" style="height: 600px; width: 100%">
    <AgGridVue
      :rowData="rowData"
      :columnDefs="columnDefs"
      :defaultColDef="defaultColDef"
      rowSelection="multiple"
      @grid-ready="onGridReady"
    />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import 'ag-grid-community/styles/ag-grid.css'
import 'ag-grid-community/styles/ag-theme-quartz.css'

const rowData = ref([
  { name: '张三', age: 30, score: 98, city: '上海' },
  { name: '李四', age: 25, score: 85, city: '北京' },
  { name: '王五', age: 35, score: 72, city: '深圳' },
])

const columnDefs = ref([
  { field: 'name', headerName: '姓名', filter: true, sortable: true },
  { field: 'age', headerName: '年龄', filter: true, sortable: true },
  { field: 'score', headerName: '分数', sortable: true },
  { field: 'city', headerName: '城市', filter: true },
])

const defaultColDef = {
  resizable: true,
  flex: 1,
}

const onGridReady = (params) => {
  console.log('Grid ready', params)
}
</script>

三十行代码,一个带分页、排序、过滤、拖拽列宽的表格就出来了。


三、它真正强大的功能点

1. 百万行数据秒渲染 — 别框架做不到

这是 ag-grid 最核心的壁垒:行虚拟化(Row Virtualization)

主流表格框架渲染 10000 行时,会把 10000 个 DOM 节点全部插入页面,滚动卡顿不可避免。ag-grid 只渲染可视区域内的行(约 20-30 行),不管数据有多少行,DOM 节点始终只有几十个。

实测数据对比:
- el-table + 10000行:滚动帧率 8-12fps,明显卡顿
- ag-grid + 100万行:滚动帧率 60fps,如丝般顺滑

这是所有竞品的硬伤,Element Plus、Vuetify 的表格目前都做不到这个级别。

2. 服务端数据模式 — 真正的分页不是假分页

大多数表格的"服务端分页"只是把请求封装了一下。ag-grid 的服务端模式(Server-Side Row Model)是真正为大数据设计的:

const gridOptions = {
  rowModelType: 'serverSide',
  serverSideStoreType: 'partial', // 部分加载,不是一次性拉全部
  // 你只需要实现这个方法
  getRowId: (params) => params.data.id,
}

// 当滚动到下一页时,ag-grid 自动触发这个回调
gridApi.setGridOption('serverSideDatasource', {
  getRows: (params) => {
    // params.request 包含:startRow, endRow, sortModel, filterModel
    fetchMyServerData(params.request).then(response => {
      params.success({
        rowData: response.rows,
        rowCount: response.totalCount, // ag-grid 自动计算分页信息
      })
    })
  }
})

它会在用户滚动时自动按需加载数据,你不需要手动写分页逻辑。

3. 单元格渲染器 — 自由度极高的自定义能力

ag-grid 的单元格渲染是它最强大的差异化功能之一。

// 渲染一个进度条
const ScoreCellRenderer = {
  template: `
    <div style="display:flex; align-items:center; gap:8px">
      <div style="flex:1; background:#eee; border-radius:4px; height:8px">
        <div :style="{width: params.value + '%', background: color, height:'100%', borderRadius:'4px'}"></div>
      </div>
      <span style="min-width:40px; font-size:12px">{{ params.value }}分</span>
    </div>
  `,
  setup(props) {
    const color = computed(() =>
      props.params.value >= 90 ? '#52c41a' :
      props.params.value >= 60 ? '#faad14' : '#ff4d4f'
    )
    return { color }
  }
}

// 注册使用
const columnDefs = [{
  field: 'score',
  headerName: '得分',
  cellRenderer: ScoreCellRenderer,
}]

不只是进度条,你可以把图表、地图、按钮、下拉框、头像、标签……任何 Vue 组件塞进单元格里。这是其他框架很难做到的。

4. Excel 导出 — 原生支持,不用任何额外配置

gridApi.exportDataAsExcel({
  fileName: '数据报表.xlsx',
  sheetName: '第一页',
  columnKeys: ['name', 'age', 'score'],
  processCellCallback: (params) => {
    return params.value // 自定义导出内容
  }
})

一行代码导出 Excel,还支持自定义列、样式、工作表名称。Element Plus 的 el-table 导出需要额外引入 xlsx 库,配置复杂得多。

5. 列的锁定、冻结、分组 — 企业级交互

const columnDefs = [
  // 冻结在左侧,不随横向滚动移动
  { field: 'id', pinned: 'left', width: 80 },

  // 普通列,可排序、过滤、拖拽
  { field: 'name', headerName: '姓名' },
  { field: 'age', headerName: '年龄' },
  { field: 'score', headerName: '分数' },

  // 冻结在右侧
  { field: 'actions', headerName: '操作', pinned: 'right', cellRenderer: ActionRenderer },

  // 列分组
  {
    headerName: '个人信息',
    children: [
      { field: 'name', headerName: '姓名' },
      { field: 'age', headerName: '年龄' },
    ]
  }
]

三行代码实现列冻结、列分组、右侧固定列,UI 体验和 Excel 几乎一致。

6. 行选择 + 操作 — 不只是 checkbox

// 选中变化时触发
onGridReady(params) {
  params.api.addEventListener('selectionChanged', () => {
    const selected = params.api.getSelectedRows()
    console.log('已选中:', selected)
  })
}

// 批量操作按钮
const handleBatchDelete = () => {
  const selected = gridApi.getSelectedRows()
  if (selected.length === 0) return ElMessage.warning('请先选择')
  // 执行删除...
}

// 自定义选择列的样式
const columnDefs = [{
  field: 'selection',
  headerCheckboxSelection: true, // 全选复选框
  checkboxSelection: true,        // 每行复选框
  width: 50,
  pinned: 'left',
}]

四、别框架做不到的事 — 核心差异对比

功能 ag-grid Element Plus el-table Ant Design Vue a-table
百万行秒渲染 ✅ 60fps虚拟化 ❌ 万行卡顿 ❌ 万行卡顿
服务端分页+排序+过滤 ✅ 原生完整支持 ⚠️ 需要自己封装 ⚠️ 需要自己封装
Excel导出 ✅ 一行代码 ⚠️ 需引入xlsx ⚠️ 需引入xlsx
单元格自定义渲染 ✅ Vue/React/Angular原生组件 ⚠️ scoped slot受限 ⚠️ scoped slot受限
列冻结 ✅ 左侧/右侧均可 ⚠️ 仅左侧固定 ⚠️ 仅左侧固定
行分组+小计 ✅ 原生支持 ❌ 无 ❌ 无
服务端行模型 ✅ 完整SSR支持 ❌ 无 ❌ 无
主题定制 ✅ 20+预设主题+CSS变量 ⚠️ 覆盖样式 ⚠️ 覆盖样式
社区版免费 ✅ 功能完整 ✅ 功能完整 ✅ 功能完整

五、价格和使用建议

ag-grid 有完全免费社区版(Community),包含 90% 的核心功能。

需要付费的是企业版,主要解锁:

  • 多级行分组(Row Grouping 2+ 层)
  • 不规则行高
  • 高级过滤(条件组)
  • 技术支持

对于 95% 的 Vue 项目来说,社区版完全够用


六、总结:什么时候选 ag-grid?

选 ag-grid 如果:

  • 数据量超过 5000 行
  • 需要复杂的数据交互(服务端分页、过滤、多维排序)
  • 单元格需要高度自定义
  • 需要 Excel 导出
  • 企业级应用,对性能有要求

继续用 el-table 如果:

  • 数据量小于 5000 行
  • 需求简单:增删改查、基础分页
  • 团队对 ag-grid 学习成本有顾虑

选 ag-grid 不是因为它"更好看",而是因为它解决的是其他表格组件在工程层面解决不了的问题。 当你的数据量上来、需求变复杂的时候,ag-grid 的价值才会真正体现。


如果这篇文章对你有帮助,欢迎点赞。有具体使用问题欢迎评论区交流~

uniapp 如何实现google登录-安卓端

作者 怜悯
2026年3月30日 15:31

uniapp 如何实现google登录-安卓端

本文只讲解uniapp安卓端如何获取到idToken,ios使用uniapp官方方法可以获取

海外app貌似最常用的就是邮箱登录,在app上表现出来最常用的就是谷歌一键登录,或者邮箱加网页验证;google登录流程大致如下

  • 点击google登录->选择账号->获取到idToken等信息->给到后端->校验并创建自己的token

1、官方给的uni.login登录获取不到idToken、serverAuthCode

主要是安卓获取不到,ios可以获取到,不过ios我没有测试

const info = ref()
uni.login({
  provider: 'google' as any,
  success(loginRes) {
    console.log('登录成功', loginRes)
    info.value.loginRes = loginRes
    uni.getUserInfo({
      provider: 'google' as any,
      success(info1) {
        info.value.userInfo = info1
        console.log('获取用户信息成功', info1)
      },
      fail(err) {
        console.log('获取用户信息失败', err)
        info.value.err2 = err
      },
    })
  },
  fail(err) {
    console.log('登录授权失败', err)
    info.value.err = err
  },
})

最终可以拿到信息只有以下内容(这个是自身的基座运行拿到的数据)

{
    "loginRes": {
        "authResult": {
            "openid": "xxx",
            "unionid": "xxx"
        },
        "errMsg": "login:ok"
    },
    "userInfo": {
        "userInfo": {
            "headimgurl": "https://lh3.googleusercontent.com/a/ACg8ocJErWXFYxbMX6kU6VxErv2PSreD-Lj-Pu8wfOACqXqLBUA9UGo",
            "nickname": "m ds",
            "unionid": "xxx",
            "openid": "xxx",
            "email": "2222122121@gmail.com",
            "openId": "xxx",
            "nickName": "m ds",
            "avatarUrl": "https://lh3.googleusercontent.com/a/ACg8ocJErWXFYxbMX6kU6VxErv2PSreD-Lj-Pu8wfOACqXqLBUA9UGo"
        },
        "errMsg": "getUserInfo:ok"
    }
}

里面并没有需要的idToken,无法提供给后端完整鉴权校验

2、我的实现方式

我查到的谷歌登录获取到idToken的方式有两种

  • 使用Google Sign-In SDK
  • Credential Manager (我选择的实现方式,不过还是使用别人的插件)

插件地址:ext.dcloud.net.cn/plugin?id=2…

这个插件是在官方插件上改版的,官方插件有一些问题,这个做了部分修改,并增加了部分功能

不过我在使用过程中还是存在一些问题

  • 1、插件获取到登录数据了,但是解析失败,这个主要是没有对谷歌返回的数据进行分类处理

修改插件代码修复解析失败问题:

// 将以下handleSignIn方法替换原有的handleSignIn方法即可
// 原有的代码仅处理了GoogleIdTokenCredential一种情况,没有处理CustomCredential

fun handleSignIn(result: GetCredentialResponse): GoogleLoginSuccess? {
val credential = result.credential
if (credential is com.google.android.libraries.identity.googleid.GoogleIdTokenCredential) {
val idToken = credential.idToken
// 验证 Token 并获取用户信息
return getUserFromToken(idToken)
} else if (credential is androidx.credentials.CustomCredential) {
            if (credential.type == com.google.android.libraries.identity.googleid.GoogleIdTokenCredential.TYPE_GOOGLE_ID_TOKEN_CREDENTIAL) {
                try {
                    val googleIdTokenCredential = com.google.android.libraries.identity.googleid.GoogleIdTokenCredential.createFrom(credential.data)
                    return getUserFromToken(googleIdTokenCredential.idToken)
                } catch (e: Exception) {
                    // console.log("Failed to parse GoogleIdTokenCredential", e)
                    return null
                }
            }
        }
// 处理其他类型的凭证(如密码)
return null
}

插件使用方式

// #ifdef APP
import { googleLogin, googleLogout } from '@/uni_modules/coc-oauth-google'
// #endif

function googleLogin_() {
  googleLogin({
    serverClientId: 'xxxxx.apps.googleusercontent.com', // 必填web端id
    success: (res: any) => {
      console.log('success------111--------', res)

      copy(JSON.stringify(res))
    },
    fail: (res: any) => {
      console.log('fail-------222-------', res)
    },
    complete: (res: any) => {
      console.log('complete====333=======', res)
    },
  })
}

拿到的数据如下,包含需要使用的idToken

{
    "email": "xxxxx@gmail.com",
    "nickname": "xxxx",
    "idToken": "xxxxxxx.xxxxxxxx.xxxxxxxx",
    "headimgurl": "https://lh3.googleusercontent.com/a/ACg8ocJErWXFYxbMX6kU6VxErv2PSreD-Lj-Pu8wfOACqXqLBUA9UGo=s96-c",
    "openId": "xxxxxxxxxxxxxxxxxxx"
}

3、google登录注意点

  • ClientId必须使用web端ClientId,不能使用android端ClientId,原因不明,只知道必须这样子
  • 打包发布到google的应用市场需要修改android凭证的sha-1值

4、其他参考的帖子和实现方法

Vue2、Vue3中的$scopedSlots和$slots区别

2026年3月30日 15:21

$scopedSlots(作用域插槽)

定义:子组件提供数据,父组件决定如何渲染,数据作用域属于子组件。

本质:子组件不直接渲染内容,而是接收一个函数,这个函数在子组件作用域内执行,从而让父组件的模板可以访问子组件的数据。

// 作用域插槽的本质:一个函数,子组件调用时传入数据
this.$scopedSlots.default = function(data) {
  // 这个函数在父组件的作用域编译
  // 但参数 data 来自子组件
  return VNode  // 返回渲染好的节点
}

$slots(普通插槽)

定义:父组件提供内容,子组件决定在哪里渲染,数据作用域属于父组件。

本质:父组件在编译时就已经确定了插槽内容的所有数据和逻辑,子组件只是作为一个容器来摆放这些内容。

// 普通插槽的本质:父组件编译好的 VNode 数组
// 子组件只是被动接收
this.$slots.default = [VNode, VNode, ...]  // 已经是渲染好的节点

数据作用域的指向

<!-- 父组件 Parent.vue -->
<template>
  <div>
    <!-- 普通插槽 -->
    <ChildComponent>
      <div>{{ parentMessage }}</div>  <!-- ✅ 可以访问父组件数据 -->
      <!-- <div>{{ childMessage }}</div>  ❌ 不能访问子组件数据 -->
    </ChildComponent>
    
    <!-- 作用域插槽 -->
    <ChildComponent>
      <template v-slot:default="slotProps">
        <div>{{ parentMessage }}</div>  <!-- ✅ 可以访问父组件数据 -->
        <div>{{ slotProps.childMessage }}</div>  <!-- ✅ 可以访问子组件数据 -->
      </template>
    </ChildComponent>
  </div>
</template>

<script>
export default {
  data() {
    return {
      parentMessage: '父组件的数据'  // 父组件作用域
    }
  }
}
</script>

<!-- 子组件 ChildComponent.vue -->
<template>
  <div>
    <!-- 普通插槽:直接渲染父组件传来的内容 -->
    <slot></slot>
    
    <!-- 作用域插槽:将子组件数据传递给父组件 -->
    <slot :childMessage="childMessage"></slot>
  </div>
</template>

<script>
export default {
  data() {
    return {
      childMessage: '子组件的数据'  // 子组件作用域
    }
  }
}
</script>

编译时 vs 运行时

普通插槽:编译时确定

// 父组件模板
<template>
  <child>
    <span>{{ message }}</span>  <!-- message 在编译时就绑定到父组件 -->
  </child>
</template>

// 编译后的渲染函数(简化)
render() {
  // 在父组件作用域中创建 VNode
  const children = [createVNode('span', null, this.message)]
  
  // 传递给子组件
  return h(Child, null, { default: () => children })
}

作用域插槽:运行时确定

// 父组件模板
<template>
  <child>
    <template v-slot="props">
      <span>{{ props.message }}</span>  <!-- message 来自子组件 -->
    </template>
  </child>
</template>

// 编译后的渲染函数(简化)
render() {
  // 父组件不直接创建 VNode,而是创建一个函数
  const scopedSlotFn = (props) => {
    return createVNode('span', null, props.message)
  }
  
  // 把这个函数传递给子组件
  return h(Child, null, { default: scopedSlotFn })
}

// 子组件中
render() {
  // 子组件调用这个函数,传入自己的数据
  const vnode = this.$scopedSlots.default({ message: this.childMessage })
  return vnode
}

总结对比表

维度 普通插槽 作用域插槽
数据来源 父组件 子组件
存储形式 VNode数组 函数
编译时机 父组件编译时 子组件运行时调用
使用场景 布局、内容填充 自定义渲染,列表渲染
灵活性 低(内容固定) 高(可动态渲染)
数据流向 父 → 子(仅传递内容) 子 → 父 (仅传递数据)

vue3变化,scopedSlots被移除,统一使用scopedSlots被移除,统一使用slots,所有插槽都是函数。

<!-- 子组件 Child.vue -->
<template>
  <div>
    <!-- 普通插槽 -->
    <slot></slot>
    
    <!-- 作用域插槽 -->
    <slot name="item" :data="itemData"></slot>
  </div>
</template>

<script setup>
import { onMounted } from 'vue'

const itemData = { name: 'Vue 3', version: 3 }

onMounted(() => {
  // Vue 3 中,所有插槽都是函数
  console.log(typeof $slots.default)  // 'function'
  console.log(typeof $slots.item)     // 'function'
  
  // 调用函数获取 VNode
  const defaultVNode = $slots.default()
  const itemVNode = $slots.item({ data: itemData })
  
  // 注意:Vue 3 中 $slots 返回的是 VNode 数组
  console.log(Array.isArray(defaultVNode))  // true
})
</script>
特性 Vue 2 Vue 3
普通插槽存储 $slots (VNode 数组) $slots (函数)
作用域插槽存储 $scopedSlots (函数) $slots (函数)
模板语法 slot + slot-scope 统一 v-slot 或 #
访问方式 this.$slots / this.$scopedSlots useSlots() 或 $slots
类型判断 Array.isArray($slots.default) typeof $slots.default === 'function'
调用方式 普通插槽直接使用,作用域插槽需调用 所有插槽都需调用
❌
❌