阅读视图

发现新文章,点击刷新页面。

Three.js 收藏吃灰系列:一套代码,把你的 GLB 模型变成漫威级全息投影

Three.js 进阶实战:打造“锐利边缘”的赛博朋克特效

20260127_113057.gif

在 Three.js 的 3D 开发中,我们经常需要制作“扫描”、“全息”或者“能量护盾”的效果。最常用的手段是使用 菲涅尔(Fresnel) 效应。

但是,默认的菲涅尔效果往往呈现出一种“软绵绵”的雾化感,中心区域总是有一层洗不掉的朦胧。如果你想要一种硬核、锐利、仅保留物体轮廓的赛博朋克风格(Hard-surface Edge),就需要对 Shader 进行一些特殊的数学运算截断。

今天,我们就来拆解一套**“锐利边缘能量体”**的代码实现。

前排提示:本文涉及的特效逻辑,源自我正在开发的 3D 编辑器项目 Meteor3DEditor。如果你对 Web3D 引擎开发感兴趣,欢迎 Star 和体验!


核心难点:如何让菲涅尔变得“锐利”?

普通的菲涅尔公式通常是 1.0 - dot(viewDir, normal)。这会产生一个从边缘向中心平滑过渡的渐变。

为了得到锐利的边缘,我们需要在 Fragment Shader 中做三件事:

  1. 极高次幂压缩:把边缘压扁。
  2. 阈值截断:把中心的“雾气”彻底切除。
  3. 亮度重映射:把剩下的边缘提亮。

1. Shader 代码解析

这是核心的 ShaderMaterial 实现。请注意看 fragmentShader 中的注释部分:

JavaScript

const sharpFresnelShader = {
    uniforms: {
        color: { value: new THREE.Color(0x00ffff) }, // 赛博青色
        opacityMultiplier: { value: 1.0 }
    },
    vertexShader: `
        varying vec3 vWorldNormal;
        varying vec3 vViewDirection;
        void main() {
            // 获取世界坐标系下的法线
            vWorldNormal = normalize(mat3(modelMatrix) * normal);
            vec4 worldPosition = modelMatrix * vec4(position, 1.0);
            // 计算视线方向
            vViewDirection = normalize(cameraPosition - worldPosition.xyz);
            gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
        }
    `,
    fragmentShader: `
        uniform vec3 color;
        uniform float opacityMultiplier;
        varying vec3 vWorldNormal;
        varying vec3 vViewDirection;
        
        void main() {
            vec3 normal = normalize(vWorldNormal);
            vec3 viewDir = normalize(vViewDirection);

            // --- 基础菲涅尔 ---
            float dotProduct = dot(viewDir, normal);
            // 1.0 - abs(dot) 确保双面渲染时背面也能正确发光
            float fresnel = 1.0 - abs(dotProduct); 
            fresnel = clamp(fresnel, 0.0, 1.0);

            // --- 关键魔法区域 ---
            
            // 技巧1:使用 pow(6.0) 甚至更高。
            // 默认的菲涅尔可能是 pow(2.0),看起来很软。
            // 6.0 的指数会让函数曲线极速陡峭,只有最边缘的地方保留数值,其余迅速归零。
            fresnel = pow(fresnel, 6.0); 

            // 技巧2:阈值截断 (Threshold)
            // 即使是 pow(6.0),中心正对相机的地方可能还有 0.01 的微弱值。
            // 这种微弱值在叠加模式下会导致模型看起来脏脏的。
            // 我们减去 0.1,强制把这一部分变成纯黑(透明)。
            fresnel = max(fresnel - 0.1, 0.0); 
            
            // 技巧3:亮度补偿
            // 因为刚才减去了 0.1,最大值只剩 0.9 了,而且整体变暗。
            // 乘一个系数把边缘亮度拉爆。
            fresnel *= 2.5; 

            // ------------------

            float finalAlpha = fresnel * opacityMultiplier;
            
            // 颜色也乘以 alpha,配合 AdditiveBlending 食用效果更佳
            gl_FragColor = vec4(color * finalAlpha, finalAlpha);
        }
    `
};

2. 材质配置的关键点

Shader 写好了,Material 的配置同样重要。为了达到“全息投影”那种通透感,我们需要关闭深度写入,并开启叠加混合。

JavaScript

this.energyMaterial = new THREE.ShaderMaterial({
    uniforms: THREE.UniformsUtils.clone(sharpFresnelShader.uniforms),
    vertexShader: sharpFresnelShader.vertexShader,
    fragmentShader: sharpFresnelShader.fragmentShader,
    transparent: true,
    side: THREE.DoubleSide,      // 双面渲染:让模型内部的结构线条也能透出来,增加复杂度
    depthWrite: false,           // 关闭深度写入:这是实现“透明重叠”不穿帮的关键
    blending: THREE.AdditiveBlending // 叠加混合:重叠的部分会变亮(变白),模拟光的物理叠加
});

动态系统:上升的能量残影

光有一个静止的边缘是不够帅的。我们需要让它动起来。这里我们实现了一个简单的粒子系统思想:不断克隆模型,向上发射,然后消失。

系统逻辑 (RisingEnergySystem)

这个类负责管理所有的“残影(Ghost)”。

  1. Spawn (生成) :每隔几百毫秒,克隆一次目标模型。

  2. Update (更新)

    • 将所有残影沿 Y 轴向上移动。
    • 随着时间流逝,减小 opacityMultiplier (透明度)。
    • 当生命周期结束,从场景移除并销毁内存。

JavaScript

spawn(originPosition, originRotation, scale) {
    if (this.sourceGeoGroups.length === 0) return;

    const ghostGroup = new THREE.Group();
    
    // 技巧:每次生成时微调颜色,让能量场色彩更丰富
    const instanceMat = this.energyMaterial.clone();
    instanceMat.uniforms.color.value.setHSL(0.5 + Math.random() * 0.1, 1.0, 0.5);

    this.sourceGeoGroups.forEach(template => {
        const mesh = template.clone();
        mesh.material = instanceMat;
        ghostGroup.add(mesh);
    });

    // ...设置位置、旋转、缩放...
    
    // 稍微向上偏移一点出生点,避免和实体模型完全重叠导致 Z-Fighting 闪烁
    ghostGroup.position.y += 0.05;

    this.scene.add(ghostGroup);
    this.ghosts.push({
        mesh: ghostGroup,
        materialBtn: instanceMat, 
        life: 1.0,
        speed: 1.5
    });
}

为什么会有“扫描”的感觉?

因为我们使用了 AdditiveBlending(叠加混合)。

当新的残影生成时,它离实体模型很近。实体模型本身可能有一个基础亮度,加上残影的亮度,重叠部分会爆亮。随着残影上升,它与本体分离,光线被“拉”开了。这种高亮 -> 分离 -> 消失的过程,视觉上就形成了强烈的能量扫描感。

![效果图占位:建议放一张局部特写,展示边缘重叠变亮的效果]


性能优化小贴士

在代码中,有一个细节处理:

JavaScript

// update 循环中
ghost.materialBtn.dispose(); // 清理内存

由于我们不断地 new ShaderMaterial(或者 clone),这会创建大量的 WebGL Program。如果不手动 dispose,Three.js 可能会保留这些材质引用,导致内存泄漏。虽然在 Demo 级别无所谓,但在生产环境中,建议使用材质池(Material Pool)或者InstancedMesh来复用资源,避免频繁创建销毁。

完整体验与源码

这个特效非常适合用于展示高科技感的 3D 模型,比如无人机、芯片、或者飞船内部结构。

如果你想看完整的运行效果,或者想直接 Copy 源码运行,我已经整理在文章开头了。而如果你想在一个可视化的环境中直接调整这些 Shader 参数,而不用每次都改代码,欢迎尝试我开发的 Web3D 编辑器:

🌌 Meteor3DEditor

Meteor3D 是一个基于 Web 的轻量级 3D 编辑器,旨在简化 Three.js 的场景搭建和特效调试。

如果你觉得这个特效对你有帮助,欢迎来 GitHub 点个 Star ⭐️,也欢迎提 Issue 交流更多的 Shader 技巧!


总结

  1. Pow(6.0) 制造锐利边缘。
  2. Minus Offset 去除中心雾化。
  3. AdditiveBlending 制造光的叠加感。
  4. DoubleSide 增加模型内部细节的透视感。

希望这篇文章能给你的 WebGL 开发带来一点灵感!

学习Three.js--轨道+火车自动行进

学习Three.js--轨道+火车自动行进

前置核心说明

开发目标

基于Three.js实现带真实纹理的火车轨道 + 沿轨道自动循环行进的简易火车,核心能力包括:

  1. 轨道模块化构建(钢轨分段创建+枕木间隔排布,支持参数化配置);
  2. 简易火车模型搭建(车身+4个车轮,车轮精准贴合钢轨);
  3. 火车沿Z轴自动行进,到达轨道末端后循环重置;
  4. 自然光照系统+纹理贴图(金属钢轨、木质枕木);
  5. 基础交互(拖拽旋转视角、滚轮缩放)。

在这里插入图片描述

核心技术栈(关键知识点)

技术点 作用
THREE.Group 分组管理轨道/火车的子组件(钢轨/枕木/车身/车轮),方便整体控制
BoxGeometry/CylinderGeometry 分别创建轨道(立方体)、车轮(圆柱体)的基础几何形状
MeshStandardMaterial PBR物理材质,支持纹理、金属度、粗糙度,模拟真实钢轨/车轮质感
纹理重复配置(wrapS/wrapT/repeat 避免纹理拉伸,适配轨道分段尺寸,提升视觉真实度
动画循环(requestAnimationFrame 实时更新火车位置,实现自动行进+循环逻辑
OrbitControls 基础视角交互(旋转/缩放/阻尼)
几何体旋转/定位 车轮旋转适配钢轨方向、轨道/火车精准贴地/居中

核心开发流程

A[初始化场景/相机/渲染器/控制器] --> B[配置轨道+火车参数(可复用)]
B --> C[加载纹理并配置重复规则]
C --> D[创建轨道(钢轨分段+枕木间隔)]
D --> E[创建火车模型(车身+车轮精准定位)]
E --> F[创建地面+添加所有元素到场景]
F --> G[窗口适配+动画循环(火车行进+循环)]

分步开发详解

步骤1:基础环境搭建(场景/相机/渲染器/控制器/光照)

1.1 核心代码
// 1. 场景初始化(所有元素的容器)
const scene = new THREE.Scene();

// 2. 透视相机(模拟人眼视角,适合3D场景)
const camera = new THREE.PerspectiveCamera(
  60, // 视角(FOV)
  window.innerWidth / window.innerHeight, // 宽高比
  1, // 近裁切面
  3000 // 远裁切面
);
camera.position.set(5, 3, 10); // 调整视角,俯视轨道

// 3. 渲染器(抗锯齿+高清适配)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

// 4. 轨道控制器(交互:旋转/缩放/阻尼)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 阻尼(惯性),交互更顺滑
controls.dampingFactor = 0.05; // 阻尼系数

// 5. 光照系统(模拟自然光照)
// 环境光:补充暗部,避免纯黑
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
// 方向光:模拟太阳光,提升立体感
const dirLight = new THREE.DirectionalLight(0xffffff, 0.9);
dirLight.position.set(200, 400, 300); // 高位斜向光照
dirLight.castShadow = false; // 简化场景,禁用阴影
scene.add(dirLight);
1.2 关键说明
  • 相机视角position.set(5, 3, 10) 采用俯视视角,既能看到轨道延伸,又能清晰观察火车行进;
  • 光照组合AmbientLight + DirectionalLight 是Three.js基础光照方案,兼顾暗部细节和主体立体感;
  • 控制器阻尼:启用阻尼后,视角拖拽/缩放有惯性,交互体验更自然。

步骤2:核心参数配置

2.1 核心代码
// 轨道参数配置(集中管理,方便修改)
const TRACK_PARAMS = {
  railWidth: 0.2,       // 钢轨宽度
  railHeight: 0.1,      // 钢轨高度
  railSpacing: 1.0,     // 两条钢轨中心距
  sleeperWidth: 0.1,    // 枕木宽度(沿Z轴)
  sleeperHeight: 0.05,  // 枕木高度
  sleeperLength: 1.5,   // 枕木长度(超过钢轨间距,覆盖两侧)
  sleeperGap: 1.0,      // 枕木间距
  trackLength: 50,      // 轨道总长度
  segmentLength: 1.0    // 钢轨分段长度(避免单个几何体过长)
};

// 火车参数配置(集中管理)
const TRAIN_PARAMS = {
  speed: 0.01,          // 火车行进速度(每帧移动距离)
  wheelRadius: 0.1,     // 车轮半径(刚好贴合钢轨高度)
  wheelWidth: 0.1,      // 车轮宽度
  bodyLength: 2.0,      // 车身长度
  bodyWidth: 0.8,       // 车身宽度(小于钢轨间距,居中)
  bodyHeight: 0.5       // 车身高度
};
2.2 关键设计思路
  • 参数集中化:将轨道/火车的尺寸、间距、速度等参数集中定义,后续修改无需遍历代码,符合「高内聚低耦合」原则;
  • 尺寸匹配wheelRadius = railHeight 保证车轮刚好贴合钢轨上表面,bodyWidth < railSpacing 保证车身居中在两条钢轨之间。

步骤3:纹理加载与配置(避免拉伸,提升真实度)

3.1 核心代码
const texLoader = new THREE.TextureLoader();

// 钢轨纹理(生锈金属,适配轨道质感)
const railTexture = texLoader.load('./rusty_metal_05_diff_1k.jpg', () => {
  renderer.render(scene, camera); // 纹理加载完成后重绘
});
// 纹理关键配置:避免拉伸
railTexture.colorSpace = THREE.SRGBColorSpace; // 正确的颜色空间
railTexture.wrapS = THREE.RepeatWrapping;      // 水平(X轴)重复
railTexture.wrapT = THREE.RepeatWrapping;      // 垂直(Y轴)重复
railTexture.repeat.set(2, 10);                 // 重复次数(适配钢轨尺寸)
railTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); // 提升斜向清晰度

// 枕木纹理(木质纹理)
const sleeperTexture = texLoader.load('./bark_willow_02_diff_1k.jpg');
sleeperTexture.colorSpace = THREE.SRGBColorSpace;
sleeperTexture.wrapS = THREE.RepeatWrapping;
sleeperTexture.wrapT = THREE.RepeatWrapping;
sleeperTexture.repeat.set(1, 1); // 单个枕木单次纹理
3.2 核心技术点
  • 纹理重复(RepeatWrapping):默认纹理是ClampToEdgeWrapping(拉伸),设置为RepeatWrapping后,纹理会按repeat值重复排列,避免长钢轨纹理拉伸模糊;
  • 各向异性(anisotropy):提升纹理在斜向视角下的清晰度,尤其适合轨道这种长条状物体;
  • 颜色空间SRGBColorSpace 是纹理的标准颜色空间,保证纹理颜色显示正确。

步骤4:轨道创建(核心逻辑:分段钢轨+间隔枕木)

4.1 核心代码
function createTrainTrack() {
  const trackGroup = new THREE.Group(); // 轨道组,统一管理钢轨/枕木

  // 1. 创建钢轨(两条平行,分段构建)
  const railMaterial = new THREE.MeshStandardMaterial({ 
    map: railTexture,
    metalness: 0.8, // 高金属度,模拟钢轨质感
    roughness: 0.2  // 低粗糙度,有反光
  });

  // 左侧钢轨(分段创建)
  for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.segmentLength) {
    const railGeometry = new THREE.BoxGeometry(
      TRACK_PARAMS.railWidth,
      TRACK_PARAMS.railHeight,
      TRACK_PARAMS.segmentLength
    );
    const rail = new THREE.Mesh(railGeometry, railMaterial);
    // 定位:左侧钢轨(X负方向),底部贴地
    rail.position.set(
      -TRACK_PARAMS.railSpacing / 2, // 左侧钢轨X坐标
      TRACK_PARAMS.railHeight / 2,   // Y轴:底部贴地(高度/2)
      z + TRACK_PARAMS.segmentLength / 2 // Z轴:分段居中
    );
    trackGroup.add(rail);
  }

  // 右侧钢轨(分段创建,逻辑同左侧)
  for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.segmentLength) {
    const railGeometry = new THREE.BoxGeometry(
      TRACK_PARAMS.railWidth,
      TRACK_PARAMS.railHeight,
      TRACK_PARAMS.segmentLength
    );
    const rail = new THREE.Mesh(railGeometry, railMaterial);
    rail.position.set(
      TRACK_PARAMS.railSpacing / 2, // 右侧钢轨X坐标
      TRACK_PARAMS.railHeight / 2,
      z + TRACK_PARAMS.segmentLength / 2
    );
    trackGroup.add(rail);
  }

  // 2. 创建枕木(横向间隔排布)
  const sleeperMaterial = new THREE.MeshStandardMaterial({ map: sleeperTexture });
  for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.sleeperGap) {
    const sleeperGeometry = new THREE.BoxGeometry(
      TRACK_PARAMS.sleeperLength, // 长度(X轴)覆盖两条钢轨
      TRACK_PARAMS.sleeperHeight,
      TRACK_PARAMS.sleeperWidth
    );
    const sleeper = new THREE.Mesh(sleeperGeometry, sleeperMaterial);
    // 定位:居中,底部贴地,沿Z轴间隔排布
    sleeper.position.set(
      0, // X轴居中
      TRACK_PARAMS.sleeperHeight / 2, // Y轴贴地
      z // Z轴间隔排布
    );
    trackGroup.add(sleeper);
  }

  return trackGroup;
}
4.2 核心技术点解析
  • 钢轨分段创建:若直接创建长度为50的钢轨几何体,顶点数过多且纹理拉伸严重;分段(segmentLength=1.0)后,每个钢轨段尺寸小、纹理重复合理,性能更优;
  • Group分组管理:将所有钢轨、枕木添加到trackGroup,后续可通过操作trackGroup整体移动/旋转轨道,便于扩展;
  • 定位逻辑:所有几何体的Y轴位置为「高度/2」,保证底部贴地(Y=0),避免悬浮/埋地。

步骤5:火车模型创建(车身+车轮,精准贴合轨道)

5.1 核心代码
function createTrain() {
  const trainGroup = new THREE.Group(); // 火车组,统一管理车身/车轮

  // 1. 创建车身(立方体)
  const bodyMaterial = new THREE.MeshStandardMaterial({ 
    color: 0xff4444, // 红色车身
    metalness: 0.1,
    roughness: 0.8
  });
  const bodyGeometry = new THREE.BoxGeometry(
    TRAIN_PARAMS.bodyWidth,
    TRAIN_PARAMS.bodyHeight,
    TRAIN_PARAMS.bodyLength
  );
  const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
  // 车身定位:居中在钢轨之间,Y轴=车轮半径+车身高度/2
  body.position.set(
    0, // X轴居中
    TRAIN_PARAMS.wheelRadius + TRAIN_PARAMS.bodyHeight / 2, // Y轴高度
    0 // Z轴初始位置
  );
  trainGroup.add(body);

  // 2. 创建车轮(圆柱体,共4个:前左/前右/后左/后右)
  const wheelMaterial = new THREE.MeshStandardMaterial({ 
    color: 0x333333, // 黑色车轮
    metalness: 0.8,
    roughness: 0.2
  });
  const wheelGeometry = new THREE.CylinderGeometry(
    TRAIN_PARAMS.wheelRadius, // 顶部半径
    TRAIN_PARAMS.wheelRadius, // 底部半径
    TRAIN_PARAMS.wheelWidth,  // 圆柱体高度(车轮宽度)
    16 // 分段数,越多越圆
  );

  // 前左车轮
  const frontLeftWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
  frontLeftWheel.rotation.z = Math.PI / 2; // 旋转90°,横向贴合钢轨
  frontLeftWheel.position.set(
    -TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2, // 对齐左侧钢轨
    TRAIN_PARAMS.wheelRadius, // Y轴=车轮半径(贴钢轨)
    TRAIN_PARAMS.bodyLength / 2 - 0.2 // 车身前部位置
  );
  trainGroup.add(frontLeftWheel);

  // 前右车轮(逻辑同前左,X轴反向)
  const frontRightWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
  frontRightWheel.rotation.z = Math.PI / 2;
  frontRightWheel.position.set(
    TRACK_PARAMS.railSpacing / 2 - TRACK_PARAMS.railWidth / 2,
    TRAIN_PARAMS.wheelRadius,
    TRAIN_PARAMS.bodyLength / 2 - 0.2
  );
  trainGroup.add(frontRightWheel);

  // 后左车轮(Z轴反向)
  const backLeftWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
  backLeftWheel.rotation.z = Math.PI / 2;
  backLeftWheel.position.set(
    -TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2,
    TRAIN_PARAMS.wheelRadius,
    -TRAIN_PARAMS.bodyLength / 2 + 0.2
  );
  trainGroup.add(backLeftWheel);

  // 后右车轮(X/Z轴反向)
  const backRightWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
  backRightWheel.rotation.z = Math.PI / 2;
  backRightWheel.position.set(
    TRACK_PARAMS.railSpacing / 2 - TRACK_PARAMS.railWidth / 2,
    TRAIN_PARAMS.wheelRadius,
    -TRAIN_PARAMS.bodyLength / 2 + 0.2
  );
  trainGroup.add(backRightWheel);

  // 初始化火车位置(轨道起点)
  trainGroup.position.z = 0;

  return trainGroup;
}
5.2 核心技术点解析
  • 车轮旋转CylinderGeometry 默认是垂直方向(Y轴),通过rotation.z = Math.PI / 2 旋转为水平方向(X轴),贴合钢轨延伸方向;
  • 车轮定位-TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2 精准对齐钢轨内侧,避免车轮偏移;
  • 车身高度wheelRadius + bodyHeight / 2 保证车身底部与车轮顶部贴合,模拟真实火车结构。

步骤6:地面创建+元素组装

6.1 核心代码
// 创建地面(大平面,衬托轨道)
const groundGeometry = new THREE.PlaneGeometry(100, 100); // 足够大的地面
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x888888 });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2; // 旋转90°,从垂直变为水平(贴地)
ground.position.y = 0;
scene.add(ground);

// 创建轨道和火车,添加到场景
const track = createTrainTrack();
const train = createTrain();
scene.add(track);
scene.add(train);
6.2 关键说明
  • 地面旋转PlaneGeometry 默认是XY平面(垂直),rotation.x = -Math.PI / 2 旋转为XZ平面(水平),作为场景地面;
  • 地面尺寸100x100 远大于轨道长度(50),保证轨道完全覆盖在地面上。

步骤7:窗口适配+动画循环(火车行进核心)

7.1 核心代码
// 窗口适配(响应式)
window.addEventListener('resize', () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix(); // 必须更新投影矩阵
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// 渲染循环(火车行进+循环逻辑)
function animate() {
  requestAnimationFrame(animate);
  
  // 核心:火车沿Z轴行进
  train.position.z += TRAIN_PARAMS.speed;
  
  // 循环逻辑:到达轨道末端后重置到起点
  if (train.position.z > TRACK_PARAMS.trackLength) {
    train.position.z = -TRAIN_PARAMS.bodyLength; // 起点略超前,避免突兀
  }

  controls.update(); // 更新控制器(阻尼)
  renderer.render(scene, camera); // 渲染场景
}
animate();
7.2 核心技术点解析
  • 火车行进逻辑:通过每帧修改train.position.z 实现沿轨道(Z轴)行进,speed 控制行进速度;
  • 循环重置train.position.z > TRACK_PARAMS.trackLength 时,重置为-TRAIN_PARAMS.bodyLength(车身长度负值),保证火车从轨道起点外进入,循环更自然;
  • 窗口适配:相机宽高比修改后,必须调用updateProjectionMatrix() 使修改生效。

核心技术点深度解析

1. 钢轨分段的必要性

  • 性能层面:单个长度为50的钢轨几何体,顶点数=(宽度分段×高度分段×长度分段),远多于50个长度为1的分段几何体总和;
  • 纹理层面:分段后每个钢轨段的纹理可通过repeat 精准控制,避免长钢轨纹理拉伸模糊;
  • 扩展层面:分段轨道更容易实现弯曲轨道(后续可通过修改每个分段的旋转/位置实现曲线)。

2. 车轮与钢轨的精准贴合

参数匹配 效果
wheelRadius = railHeight 车轮半径等于钢轨高度,车轮底部刚好落在钢轨上表面
车轮X坐标 = ±railSpacing/2 ± railWidth/2 车轮内侧对齐钢轨外侧,避免偏移
车轮Y坐标 = wheelRadius 车轮中心Y轴高度=半径,底部贴钢轨上表面

3. 动画循环的核心逻辑

A[每帧执行animate] --> B[train.position.z += speed]
B --> C{z > trackLength?}
C -- 是 --> D[z = -bodyLength(重置)]
C -- 否 --> E[继续行进]
D --> F[渲染场景]
E --> F[渲染场景]

完整优化代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Three.js 轨道+火车自动行进</title>
  <style>
    body { margin: 0; overflow: hidden; }
    #info {
      position: absolute;
      top: 10px;
      width: 100%;
      text-align: center;
      color: white;
      font-family: Arial, sans-serif;
      text-shadow: 0 0 5px rgba(0,0,0,0.8);
      pointer-events: none;
      z-index: 10;
    }
  </style>
</head>
<body>
  <div id="info">火车沿轨道自动行进 | 拖拽旋转视角 | 滚轮缩放</div>

<script type="module">
  import * as THREE from 'https://esm.sh/three@0.174.0';
  import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';

  // ========== 1. 基础环境初始化 ==========
  // 场景:所有元素容器
  const scene = new THREE.Scene();

  // 透视相机:俯视视角观察轨道+火车
  const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 3000);
  camera.position.set(5, 3, 10); // 俯视视角参数

  // 渲染器:抗锯齿+高清适配
  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  document.body.appendChild(renderer.domElement);

  // 轨道控制器:启用阻尼,交互更顺滑
  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true;
  controls.dampingFactor = 0.05;

  // 光照系统:环境光+方向光,兼顾暗部和立体感
  const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
  scene.add(ambientLight);
  const dirLight = new THREE.DirectionalLight(0xffffff, 0.9);
  dirLight.position.set(200, 400, 300);
  dirLight.castShadow = false; // 简化场景,禁用阴影
  scene.add(dirLight);

  // ========== 2. 核心参数配置(集中管理,方便修改) ==========
  // 轨道参数
  const TRACK_PARAMS = {
    railWidth: 0.2,       // 钢轨宽度
    railHeight: 0.1,      // 钢轨高度
    railSpacing: 1.0,     // 两条钢轨中心距
    sleeperWidth: 0.1,    // 枕木宽度(沿Z轴)
    sleeperHeight: 0.05,  // 枕木高度
    sleeperLength: 1.5,   // 枕木长度(覆盖钢轨两侧)
    sleeperGap: 1.0,      // 枕木间距
    trackLength: 50,      // 轨道总长度
    segmentLength: 1.0    // 钢轨分段长度(避免单个几何体过长)
  };

  // 火车参数
  const TRAIN_PARAMS = {
    speed: 0.01,          // 火车行进速度(每帧移动距离)
    wheelRadius: 0.1,     // 车轮半径(贴合钢轨高度)
    wheelWidth: 0.1,      // 车轮宽度
    bodyLength: 2.0,      // 车身长度
    bodyWidth: 0.8,       // 车身宽度(小于钢轨间距,居中)
    bodyHeight: 0.5       // 车身高度
  };

  // ========== 3. 纹理加载与配置(避免拉伸,提升真实度) ==========
  const texLoader = new THREE.TextureLoader();

  // 钢轨纹理:生锈金属,配置重复规则
  const railTexture = texLoader.load('./rusty_metal_05_diff_1k.jpg', () => {
    renderer.render(scene, camera); // 纹理加载完成后重绘
  });
  railTexture.colorSpace = THREE.SRGBColorSpace;
  railTexture.wrapS = THREE.RepeatWrapping;
  railTexture.wrapT = THREE.RepeatWrapping;
  railTexture.repeat.set(2, 10); // 纹理重复次数
  railTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); // 提升斜向清晰度

  // 枕木纹理:木质纹理
  const sleeperTexture = texLoader.load('./bark_willow_02_diff_1k.jpg');
  sleeperTexture.colorSpace = THREE.SRGBColorSpace;
  sleeperTexture.wrapS = THREE.RepeatWrapping;
  sleeperTexture.wrapT = THREE.RepeatWrapping;
  sleeperTexture.repeat.set(1, 1);

  // ========== 4. 轨道创建(分段钢轨+间隔枕木) ==========
  function createTrainTrack() {
    const trackGroup = new THREE.Group(); // 轨道组,统一管理

    // 钢轨材质:高金属度,模拟钢轨质感
    const railMaterial = new THREE.MeshStandardMaterial({ 
      map: railTexture,
      metalness: 0.8,
      roughness: 0.2
    });

    // 左侧钢轨:分段创建
    for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.segmentLength) {
      const railGeometry = new THREE.BoxGeometry(
        TRACK_PARAMS.railWidth,
        TRACK_PARAMS.railHeight,
        TRACK_PARAMS.segmentLength
      );
      const rail = new THREE.Mesh(railGeometry, railMaterial);
      // 定位:左侧+贴地+分段居中
      rail.position.set(
        -TRACK_PARAMS.railSpacing / 2,
        TRACK_PARAMS.railHeight / 2,
        z + TRACK_PARAMS.segmentLength / 2
      );
      trackGroup.add(rail);
    }

    // 右侧钢轨:分段创建
    for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.segmentLength) {
      const railGeometry = new THREE.BoxGeometry(
        TRACK_PARAMS.railWidth,
        TRACK_PARAMS.railHeight,
        TRACK_PARAMS.segmentLength
      );
      const rail = new THREE.Mesh(railGeometry, railMaterial);
      // 定位:右侧+贴地+分段居中
      rail.position.set(
        TRACK_PARAMS.railSpacing / 2,
        TRACK_PARAMS.railHeight / 2,
        z + TRACK_PARAMS.segmentLength / 2
      );
      trackGroup.add(rail);
    }

    // 枕木材质:木质纹理
    const sleeperMaterial = new THREE.MeshStandardMaterial({ map: sleeperTexture });
    // 枕木:间隔创建
    for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.sleeperGap) {
      const sleeperGeometry = new THREE.BoxGeometry(
        TRACK_PARAMS.sleeperLength,
        TRACK_PARAMS.sleeperHeight,
        TRACK_PARAMS.sleeperWidth
      );
      const sleeper = new THREE.Mesh(sleeperGeometry, sleeperMaterial);
      // 定位:居中+贴地+间隔排布
      sleeper.position.set(
        0,
        TRACK_PARAMS.sleeperHeight / 2,
        z
      );
      trackGroup.add(sleeper);
    }

    return trackGroup;
  }

  // ========== 5. 火车创建(车身+车轮,精准贴合轨道) ==========
  function createTrain() {
    const trainGroup = new THREE.Group(); // 火车组,统一管理

    // 车身材质:红色,低金属度
    const bodyMaterial = new THREE.MeshStandardMaterial({ 
      color: 0xff4444,
      metalness: 0.1,
      roughness: 0.8
    });
    // 车身几何体
    const bodyGeometry = new THREE.BoxGeometry(
      TRAIN_PARAMS.bodyWidth,
      TRAIN_PARAMS.bodyHeight,
      TRAIN_PARAMS.bodyLength
    );
    const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
    // 车身定位:居中+贴合车轮顶部
    body.position.set(
      0,
      TRAIN_PARAMS.wheelRadius + TRAIN_PARAMS.bodyHeight / 2,
      0
    );
    trainGroup.add(body);

    // 车轮材质:黑色,高金属度
    const wheelMaterial = new THREE.MeshStandardMaterial({ 
      color: 0x333333,
      metalness: 0.8,
      roughness: 0.2
    });
    // 车轮几何体:圆柱体
    const wheelGeometry = new THREE.CylinderGeometry(
      TRAIN_PARAMS.wheelRadius,
      TRAIN_PARAMS.wheelRadius,
      TRAIN_PARAMS.wheelWidth,
      16
    );

    // 前左车轮
    const frontLeftWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
    frontLeftWheel.rotation.z = Math.PI / 2; // 旋转为横向
    frontLeftWheel.position.set(
      -TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2,
      TRAIN_PARAMS.wheelRadius,
      TRAIN_PARAMS.bodyLength / 2 - 0.2
    );
    trainGroup.add(frontLeftWheel);

    // 前右车轮
    const frontRightWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
    frontRightWheel.rotation.z = Math.PI / 2;
    frontRightWheel.position.set(
      TRACK_PARAMS.railSpacing / 2 - TRACK_PARAMS.railWidth / 2,
      TRAIN_PARAMS.wheelRadius,
      TRAIN_PARAMS.bodyLength / 2 - 0.2
    );
    trainGroup.add(frontRightWheel);

    // 后左车轮
    const backLeftWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
    backLeftWheel.rotation.z = Math.PI / 2;
    backLeftWheel.position.set(
      -TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2,
      TRAIN_PARAMS.wheelRadius,
      -TRAIN_PARAMS.bodyLength / 2 + 0.2
    );
    trainGroup.add(backLeftWheel);

    // 后右车轮
    const backRightWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
    backRightWheel.rotation.z = Math.PI / 2;
    backRightWheel.position.set(
      TRACK_PARAMS.railSpacing / 2 - TRACK_PARAMS.railWidth / 2,
      TRAIN_PARAMS.wheelRadius,
      -TRAIN_PARAMS.bodyLength / 2 + 0.2
    );
    trainGroup.add(backRightWheel);

    // 初始化火车位置(轨道起点)
    trainGroup.position.z = 0;

    return trainGroup;
  }

  // ========== 6. 地面创建+元素组装 ==========
  // 地面:大平面,衬托轨道
  const groundGeometry = new THREE.PlaneGeometry(100, 100);
  const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x888888 });
  const ground = new THREE.Mesh(groundGeometry, groundMaterial);
  ground.rotation.x = -Math.PI / 2; // 旋转为水平地面
  ground.position.y = 0;
  scene.add(ground);

  // 创建轨道和火车,添加到场景
  const track = createTrainTrack();
  const train = createTrain();
  scene.add(track);
  scene.add(train);

  // ========== 7. 窗口适配 ==========
  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });

  // ========== 8. 动画循环(火车行进+循环) ==========
  function animate() {
    requestAnimationFrame(animate);
    
    // 火车沿Z轴行进
    train.position.z += TRAIN_PARAMS.speed;
    
    // 循环逻辑:到达末端后重置到起点
    if (train.position.z > TRACK_PARAMS.trackLength) {
      train.position.z = -TRAIN_PARAMS.bodyLength; // 起点略超前,避免突兀
    }

    controls.update(); // 更新控制器阻尼
    renderer.render(scene, camera); // 渲染场景
  }
  animate();
</script>
</body>
</html>

总结与扩展建议

核心总结

  1. 模块化设计:通过Group分组管理轨道/火车的子组件,参数集中化配置,便于维护和扩展;
  2. 几何体精准定位:所有元素的Y轴位置为「高度/2」保证贴地,车轮通过旋转+坐标计算精准贴合钢轨;
  3. 纹理优化:使用RepeatWrapping避免纹理拉伸,anisotropy提升斜向清晰度,PBR材质(MeshStandardMaterial)模拟真实质感;
  4. 动画核心:通过requestAnimationFrame每帧更新火车position.z实现行进,结合边界判断实现循环逻辑。

扩展建议

  1. 车轮旋转动画:在animate中添加wheel.rotation.x += 0.1,让车轮随行进旋转,更真实;
  2. 弯曲轨道:修改钢轨分段的rotation.yposition,实现曲线轨道(需计算圆弧坐标);
  3. 火车细节增强:添加车窗、烟囱、车头等细节,或加载3D模型替代简易几何体;
  4. 轨道材质优化:启用阴影(castShadow/receiveShadow),添加钢轨反光、枕木磨损效果;
  5. 交互增强:添加速度控制(滑块调整TRAIN_PARAMS.speed)、轨道开关、火车启停按钮;
  6. 性能优化:使用InstancedMesh替代重复创建的钢轨/枕木,减少DrawCall。

flutter 屏幕适配方案

flutter 屏幕适配方案,用于调整屏幕和字体大小的flutter插件,让你的UI在不同尺寸的屏幕上都能显示合理的布局!

以下是 flutter_screenutil 插件最常用的基本使用方式,我把它们按实际开发中最常出现的场景列出来(从最基础到稍进阶),方便你快速上手和查阅。

1. 初始化(必须在入口处做一次)

main.dart文件

// flutter 屏幕适配方案
import 'package:flutter_screenutil/flutter_screenutil.dart';

void main() {
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return ScreenUtilInit(
      designSize: const Size(360, 690),
      minTextAdapt: true,                  // 开启字体自适应(推荐)
      splitScreenMode: true,               // 支持分屏/折叠屏
      builder: (context, child) {
        return MaterialApp(
          title: 'Flutter Demo',
          home: const MyHomePage(),
        );
      },
    );
  }
}

2. 常用单位后缀(最核心的使用方式)

// 宽度(类似 rpx)
Container(
  width: 300.w,          // 设计稿 300 → 自动适配
  height: 200.h,         // 高度适配
  margin: EdgeInsets.only(top: 40.h, left: 32.w),
  padding: EdgeInsets.all(16.w),
)

// 字体大小(推荐用 .sp,支持系统字体缩放)
Text(
  '标题文字',
  style: TextStyle(
    fontSize: 32.sp,
    fontWeight: FontWeight.w600,
  ),
)

// 圆角、边框等
decoration: BoxDecoration(
  borderRadius: BorderRadius.circular(16.r),   // .r 适配圆角
  border: Border.all(width: 1.r, color: Colors.grey),
)

// 图标 / Image 大小
Image.asset(
  'assets/icon.png',
  width: 48.w,
  height: 48.h,
)

3. 获取当前设备/屏幕信息(常用工具方法)

// 当前屏幕宽度(逻辑像素)
double screenWidth = ScreenUtil().screenWidth;

// 当前屏幕高度
double screenHeight = ScreenUtil().screenHeight;

// 设计稿宽度(你设置的 750)
double designWidth = ScreenUtil().designSize.width;

// 宽度缩放比例(当前屏幕宽度 / 设计稿宽度)
double scaleWidth = ScreenUtil().scaleWidth;

// 高度缩放比例
double scaleHeight = ScreenUtil().scaleHeight;

// 状态栏高度(刘海屏、安全区等)
double statusBarHeight = ScreenUtil().statusBarHeight;

// 底部安全区(iPhone 有 Home Indicator)
double bottomBarHeight = ScreenUtil().bottomBarHeight;

// 是否是横屏
bool isLandscape = ScreenUtil().isLandscape;

4. 响应式判断(小屏 / 大屏 / 平板等)

if (ScreenUtil().screenWidth < 600.w) {
  // 小屏幕(手机竖屏)
  fontSize: 28.sp
} else if (ScreenUtil().screenWidth < 900.w) {
  // 中等屏幕
  fontSize: 32.sp
} else {
  // 大屏 / 平板
  fontSize: 40.sp
}

5. 常见组合写法示例

// 一个典型的卡片
Container(
  width: 690.w,                    // 左右留边 30.w
  margin: EdgeInsets.symmetric(horizontal: 30.w, vertical: 20.h),
  padding: EdgeInsets.all(24.w),
  decoration: BoxDecoration(
    color: Colors.white,
    borderRadius: BorderRadius.circular(24.r),
    boxShadow: [
      BoxShadow(
        color: Colors.black.withOpacity(0.08),
        blurRadius: 20.r,
        offset: Offset(0, 8.h),
      ),
    ],
  ),
  child: Column(
    children: [
      Image.network('...', width: 120.w, height: 120.h),
      SizedBox(height: 16.h),
      Text('商品名称', style: TextStyle(fontSize: 32.sp)),
      SizedBox(height: 8.h),
      Text('¥99.00', style: TextStyle(fontSize: 36.sp, color: Colors.red)),
    ],
  ),
)

总结:最常使用的几个后缀(记这几个基本够用)

后缀 含义 典型场景
.w 宽度适配 width, margin.left/right, padding.left/right
.h 高度适配 height, margin.top/bottom, padding.vertical
.sp 字体大小适配 fontSize(最推荐用这个)
.r 圆角 / 阴影模糊半径 borderRadius, blurRadius
.sm 较小的尺寸(可选) 图标、间距等细微调整

掌握以上这些,基本就能覆盖 95% 的日常 UI 适配需求

所有的使用方法请去官方文档查看

React 之后,前端的下一个“十年之约”叫 Signals

在这个前端技术更迭比天气还快的时代,我们似乎正处于一个微妙的临界点。React 统治了过去十年,Vue 赢得了开发者的心,但当我们回过头看,复杂的“心智负担”和“性能损耗”依然是挥之不去的阴影。

最近,Signals(信号) 这个概念在 SolidJS、Preact、Qwik 甚至 Angular 中全线爆发,连 Vue 也一直深耕于此。

今天,我们就来聊聊这个让前端圈再次“躁动”的底层逻辑:Signals 究竟是什么?它会是状态管理的终点吗?

01 范式演进:从“全量刷新”到“精确制导”

要理解 Signals,必须先看清它的对手:Virtual DOM(虚拟 DOM)。

在 React 的世界观里,状态改变 = 重新执行函数 = 生成新的虚拟 DOM 树 = Diff 对比 = 更新真实 DOM。

  • 痛点: 即使只改了一个计数器,整个组件甚至子组件树都要重新“跑一遍”。为了优化,我们不得不祭出 useMemo、useCallback、memo 等“心智枷锁”。

Signals 走的是另一条路:精确制导。

如果说 React 是“拉(Pull)”模式——发生变化后由框架去比对哪里变了;那么 Signals 就是“推(Push)”模式——状态自己知道谁依赖了它,当它变了,直接通知对应的 DOM 节点去更新。

技术比喻:

  • Virtual DOM: 班主任(框架)每天进教室大喊:“谁的名字改了?大家都站起来让我比对一遍!”
  • Signals: 学生(状态)直接给特定的同学(DOM 节点)发个微信:“我改名了,你改一下笔记。”

02 诸神黄昏:主流框架的“信号”之战

虽然大家都在提 Signals,但各家的实现思路和现状大不相同。

1. Vue:生而为信号的“老江湖”

Vue 的响应式系统(从 Object.defineProperty 到 Proxy)本质上一直具有 Signals 的特性。Vue 3 的 ref 和 computed 其实就是 Signals 的变体。通过模板编译优化,Vue 能够实现非常精细的组件级更新。

2. SolidJS & Svelte:彻底的“去 VDOM 化”

SolidJS 是 Signals 的集大成者。它直接放弃了虚拟 DOM,将代码编译成原生的 DOM 操作。当你改变一个 Signal 时,真的只有那个特定的文本节点在闪烁,这种“手术刀级别”的更新,让它在性能榜单上常年霸榜。

3. React:倔强的“单向数据流”

React 官方目前对 Signals 保持克制。React 的核心哲学是“UI 即函数”,追求的是声明式的简洁。引入 Signals 意味着打破这一哲学,转向响应式监听。虽然 Preact 等社区库已经实现了 React 版本的 Signals,但官方更倾向于通过 React Compiler(Forget) 自动处理优化,而不是改变底层响应式模型。

03 为什么是现在?Signals 爆发的三大诱因

为什么这个几十年前就在 Smalltalk 存在的概念,会在 2026 年的前端圈翻红?

  1. 性能压榨到了极致: 随着 Web 应用复杂度指数级上升,虚拟 DOM 的 Diff 开销在移动端和低端设备上愈发明显。
  2. DX(开发者体验)的回归: 开发者厌倦了写满屏幕的 Dependency Array(依赖项数组)。Signals 自动追踪依赖,让代码看起来更像原生 JavaScript。
  3. 细粒度更新与跨端需求: 尤其是在低功耗的跨端场景(如 IoT 设备、复杂的小程序环境),Signals 的按需更新能显著降低 CPU 占用。

04 终点,还是又一个循环?

Signals 真的很完美吗?并不。

当应用规模极其巨大时,复杂的响应式链路可能导致“追踪地狱”,调试一个状态为何变化可能会变得像迷宫一样复杂。此外,它对开发者底层认知的要求其实更高了。

Signals 会是终点吗? 在「研究所」看来,它更像是一次“底层的拨乱反正”。

前端框架正从“黑盒式的全量更新”转向“透明化的按需驱动”。未来的趋势可能不再是“我们要不要用 Signals”,而是“Signals 将成为框架默认的底层基础设施”。开发者不需要感知它的存在,却能享受它带来的极致性能。

💡作为开发者,我们不必纠结于名词的更迭。从 JQuery 的手动挡,到 React 的半自动挡,再到 Signals 驱动的“自动驾驶”,技术的本质永远是消除重复,让创意更自由地表达。

如果你正在追求极致的 C 端性能,或者厌倦了 React 繁琐的 Hooks 优化,不妨去尝试一下 SolidJS 或 Vue 3 的最新特性,感受一下那种“指哪打哪”的快感。


微信公众号:Next Tech研究局 站在前端与 AI 的交叉口,分享最好用的工具与最前沿的跨端实践。

从零打造 AI 全栈应用(五):Post List 背后的前后端解耦与 Mock 工程化实践

在上一篇《从零打造 AI 全栈应用(四):别小看幻灯片,一个 Carousel 组件背后的工程化与性能思维一、为什么一个幻灯片组件》 中,我们通过一个轮播组件,拆解了组件状态、插件模式与性能取舍

这一篇,我们把视角从「组件内部」拉回到「页面级数据流」,聊一个在真实项目中几乎绕不开的问题

前端页面的数据,到底该怎么来?


一、Post List 是 UI 问题,还是数据问题?

在很多博客 / 社区 / AI 内容产品中,都会有一个核心页面:

文章列表(Post List)

表面上它是一个 UI:

  • 卡片列表
  • 分页
  • 点赞数 / 评论数 / 标签

但工程上,它首先是一个数据协作问题

  • 数据来自哪里?
  • 前端要不要等后端?
  • 后端接口没好,页面怎么办?

如果这几个问题想不清楚,项目一定会出现:

  • 前端被后端接口卡进度
  • 大量 if (data) 防御式代码
  • 开发阶段和线上环境差异巨大

二、前后端分离的核心前提:前端不能等后端

很多同学嘴上说前后端分离,但心智模型还是:

「等后端接口写好,我再写页面」

这是不成立的

真正的前后端分离,意味着:

  • 前端可以独立完成页面与交互
  • 后端只要遵守接口契约即可无缝接入

而这个“契约”,就是 —— API 接口文档


三、先定义接口,而不是先写代码

以 Post List 为例,我们先约定接口形态:

GET /api/posts?page=1&limit=10

返回结构:

{
  "code": 200,
  "items": Post[],
  "pagination": {
    "current": 1,
    "limit": 10,
    "total": 45,
    "totalPage": 5
  }
}

注意这里的几个工程化点:

  • 分页信息由后端返回,不是前端猜
  • 字段命名稳定,不夹杂 UI 语义
  • 返回结构对 mock / 真接口完全一致

只要这个结构不变,
前端切换真实后端地址是无感的


四、Mock 的工程意义,而不是“假数据”

很多人一听 mock,就觉得是:

随便造点数据糊页面

这是对 mock 的严重低估。

在工程中,mock 的作用是:

  • 解耦前后端开发节奏
  • 固化接口结构
  • 提前暴露分页 / 边界问题
  • 让前端在真实请求语义下开发

五、Vite + vite-plugin-mock:最小成本接入

pnpm i vite-plugin-mock -D

它的优势在于:

  • 与 Vite 深度集成
  • 开发环境自动启用
  • 不侵入业务代码

上线前只需要切换 baseURL,
mock 即可完全下线。


六、Post Mock 的核心不是数据,而是「逻辑完整度」

1️⃣ 使用 Mock.js 构造真实数据结构

const posts = Mock.mock({
  'list|45': [{
    title: '@ctitle(8, 20)',
    brief: '@ctitle(20,100)',
    totalComment: '@integer(0, 30)',
    totalLikes: '@integer(0, 500)',
    publishedAt: '@datetime("yyyy-MM-dd HH:mm")',
    user: {
      id: '@integer(1, 10)',
      name: '@cname(2, 4)',
      avatar: '@image(300x200)'
    },
    tags: () => Mock.Random.pick(tags, 2),
    thumbnail: '@image(300x200)',
    id: '@increment(1)'
  }]
}).list;

重点不在“随机”,而在:

  • 字段类型是否稳定
  • 嵌套结构是否真实
  • 是否足够支撑 UI 复杂度

2️⃣ 分页逻辑必须和真实后端一致

const currentPage = parseInt(page, 10);
const size = parseInt(limit, 10);

const start = (currentPage - 1) * size;
const end = start + size;
const paginatedData = posts.slice(start, end);

以及返回的 pagination:

pagination: {
  current: currentPage,
  limit: size,
  total,
  totalPage: Math.ceil(total / size)
}

如果 mock 不做分页,
那前端分页逻辑一定会在真接口时翻车。


七、Axios:前端的“数据适配层”

前端通过 axios 请求接口,本质上并不关心:

  • 数据是真是假
  • 接口来自 mock 还是真后端

它只关心:

  • 返回结构是否符合约定
  • 错误码是否可控

这也是为什么:

mock 必须 100% 模拟真实接口语义


八、顺带一提:Auth Mock 是怎么服务页面的?

登录 mock 使用 JWT:

  • login:签发 token
  • check:验证 token

这带来的好处是:

  • 前端可以完整开发登录态逻辑
  • 请求拦截器、权限判断全部可写
  • 不等真实用户系统

九、回到工程视角:这一篇你真正学到什么?

通过一个 Post List,你应该能讲清楚:

  • 什么是“前端不能等后端”
  • mock 在工程中的真实价值
  • 分页为什么必须由接口驱动
  • 如何设计一个可无缝切换的 API 层

面试官真正关心的不是你会不会用 mock,
而是你是否具备前后端协作的工程意识


vue3.5+antdv3.2封装table自定义表头-自定义排序-自定义伸缩列

业务需求:

1.表头自定义

2.表格伸缩列自定义

3.字段长度自适应伸缩列,超过宽度设置自动省略,popover气泡卡牌显示全部字段

4.表格排序自定义

5.可展开数据通过操作栏展开

自定义表头:

<template>
  <a-modal
      :visible="modelValue"
      title="自定义表头"
      :destroyOnClose="true"
      :keyboard="false"
      :maskClosable="false"
      width="800px"
      @ok="handleOk"
      @cancel="handleCancel"
  >
    <div class="column-selector-grid">
      <div class="selector-actions">
        <a-button @click="resetToDefault">恢复默认</a-button>
        <a-button @click="selectAll">全选</a-button>
        <a-button @click="clearAll">清空</a-button>
      </div>
      <div class="grid-container">
        <draggable
            v-model="internalColumns"
            group="columns"
            item-key="dataIndex"
            class="grid-layout"
            :disabled="props.disabled"
            @start="onDragStart"
            @end="onDragEnd"
            :move="onMove">
          <template #item="{ element }">
            <div
                class="grid-item"
                :class="{
                'required-item': element.required,
                'selected-item': element.visible,}">
              <div class="drag-handle" >
                <drag-outlined />
              </div>
              <div class="item-content">
                <a-checkbox
                    v-model:checked="element.visible"
                    :disabled="element.required "
                    class="content-checkbox"
                >
                  <span class="content-title">
                    {{ element.title }}
                    <a-tooltip v-if="element.tip" :title="element.tip">
                      <question-circle-outlined class="tip-icon" />
                    </a-tooltip>
                  </span>
                </a-checkbox>
              </div>
            </div>
          </template>
        </draggable>
      </div>
      <div class="fixed-columns-info">
        <p class="info-text">
          <info-circle-outlined class="info-icon" />
          <strong>固定表头申明:</strong>
          <span class="fixed-first-text">序号</span> 固定显示在第一位,
          <span class="fixed-last-text">操作</span> 固定显示在最后一位,以及其他所有被固定的表头都不能取消选择或调整位置。
        </p>
      </div>
    </div>
  </a-modal>
</template>

<script setup>
import { ref, watch, nextTick } from 'vue';
import { QuestionCircleOutlined, DragOutlined, InfoCircleOutlined } from '@ant-design/icons-vue';
import draggable from 'vuedraggable';

// 固定列配置
const FIXED_COLUMNS = {
  FIRST: 'no',      // 序号列固定在第一位
  LAST: 'action'    // 操作列固定在最后一位
};

const props = defineProps({
  allColumns: Array,        // 所有可用列,按照 tableColumns 顺序
  defaultColumns: Array,    // 当前选中的列
  modelValue: Boolean,       // 控制弹窗显示
  disabled:{
    type:Boolean,
    default:false
  }
});

const emit = defineEmits(['update:modelValue', 'confirm','closeSelector']);

// 内部维护的列数据
const internalColumns = ref([]);

// 拖拽状态
const isDragging = ref(false);
const draggedElement = ref(null);

// 拖拽开始事件
const onDragStart = (evt) => {
  const draggedItem = evt.item;
  const draggedIndex = evt.oldIndex;
  const draggedData = internalColumns.value[draggedIndex];
  // 检查是否是固定列
  if (draggedData.required ) {
    // 阻止拖拽固定列
    evt.preventDefault();
    return;
  }
  isDragging.value = true;
  draggedElement.value = draggedData;
};

// 拖拽结束事件
const onDragEnd = (evt) => {
  isDragging.value = false;
  draggedElement.value = null;
  // 延迟执行位置恢复,确保拖拽完全结束
  nextTick(() => {
    ensureFixedColumnsPosition();
  });
};

// 拖拽移动事件
const onMove = (evt) => {
  const draggedItem = evt?.dragged.__draggable_context?.element;
  const targetIndex = evt.draggedContext.futureIndex;
  // 检查被拖动列是否是固定列
  if (draggedItem?.required ) {
    // 阻止拖拽固定列
    return false;
  }
  // 检查目标是否是固定列
  const targetItem = internalColumns.value[targetIndex];
  if (targetItem && targetItem.required) {
    // 阻止拖拽到固定列
    return false;
  }
  // 智能位置调整:确保拖拽后固定列位置仍然正确
  const adjustedTargetIndex = adjustTargetIndex(targetIndex);
  if (adjustedTargetIndex !== targetIndex) {
    // 如果目标位置需要调整,更新拖拽目标
    evt.draggedContext.futureIndex = adjustedTargetIndex;
  }
  // 允许正常列在固定列之间自由拖拽
  return true;
};

// 智能调整目标位置,确保不破坏序号和操作的位置
const adjustTargetIndex = (targetIndex) => {
  const columns = internalColumns.value;
  // 找到序号和操作的位置
  const noIndex = columns.findIndex(col => col.dataIndex === FIXED_COLUMNS.FIRST);
  const actionIndex = columns.findIndex(col => col.dataIndex === FIXED_COLUMNS.LAST);
  // 如果目标位置是序号列位置,调整到序号列后面
  if (targetIndex === 0) {
    return 1;
  }
  // 如果目标位置是操作列位置,调整到操作列前面
  if (targetIndex === columns.length - 1) {
    return columns.length - 2;
  }
  // 如果目标位置在序号列之前,调整到序号列后面
  if (targetIndex < noIndex) {
    return noIndex + 1;
  }
  // 如果目标位置在操作列之后,调整到操作列前面
  if (targetIndex > actionIndex) {
    return actionIndex - 1;
  }
  return targetIndex;
};

// 确保固定列在正确位置
const ensureFixedColumnsPosition = () => {
  const columns = [...internalColumns.value];
  let hasChanges = false;

  // 确保序号列在第一位
  const noColumn = columns.find(col => col.dataIndex === FIXED_COLUMNS.FIRST);
  if (noColumn) {
    const noIndex = columns.indexOf(noColumn);
    if (noIndex !== 0) {
      columns.splice(noIndex, 1);
      columns.unshift(noColumn);
      hasChanges = true;
    }
  }

  // 确保操作列在最后一位
  const actionColumn = columns.find(col => col.dataIndex === FIXED_COLUMNS.LAST);
  if (actionColumn) {
    const actionIndex = columns.indexOf(actionColumn);
    if (actionIndex !== columns.length - 1) {
      columns.splice(actionIndex, 1);
      columns.push(actionColumn);
      hasChanges = true;
    }
  }

  // 如果有变化,更新列顺序
  if (hasChanges) {
    internalColumns.value = [...columns];
  }
};

// 初始化列数据,保持 tableColumns 的顺序
const initColumns = () => {
  // 按照 allColumns 的顺序初始化
  internalColumns.value = props.allColumns.map(col => ({
    ...col,
    visible: props.defaultColumns.includes(col.dataIndex) || col.required
  }));
  // 确保固定列在正确位置
  ensureFixedColumnsPosition();
};

// 恢复默认
const resetToDefault = () => {
  initColumns();
};

// 全选
const selectAll = () => {
  internalColumns.value.forEach(col => {
    col.visible = true;
  });
};

// 清空(除了必需列和固定列)
const clearAll = () => {
  internalColumns.value.forEach(col => {
    if (!col.required ) {
      col.visible = false;
    }
  });
};

// 确认保存
const handleOk = () => {
  // 按照当前顺序返回可见列的 dataIndex 数组
  const visibleColumns = internalColumns.value
      .filter(col => col.visible)
      .map(col => col.dataIndex);

  emit('confirm', visibleColumns);
  emit('update:modelValue', false);
};

const handleCancel = () => {
  if(props.disabled){
    internalColumns.value.forEach(col => {
      col.visible = props.defaultColumns.includes(col.dataIndex) || col.required ;
    });
  }

  emit('update:modelValue', false);
  emit('closeSelector');
};

// 监听 props 变化重新初始化
watch(() => props.allColumns, initColumns, { immediate: true });
watch(() => props.defaultColumns, () => {
  internalColumns.value.forEach(col => {
    col.visible = props.defaultColumns.includes(col.dataIndex) || col.required ;
  });
});

// 监听内部列顺序变化,确保固定列位置正确
watch(() => internalColumns.value, (newColumns) => {
  if (newColumns && newColumns.length > 0) {
    // 检查固定列位置
    const noIndex = newColumns.findIndex(col => col.dataIndex === FIXED_COLUMNS.FIRST);
    const actionIndex = newColumns.findIndex(col => col.dataIndex === FIXED_COLUMNS.LAST);

    // 如果固定列位置不正确,立即恢复
    if (noIndex !== 0 || actionIndex !== newColumns.length - 1) {
      nextTick(() => {
        ensureFixedColumnsPosition();
      });
    }
  }
}, { deep: true });
</script>

<style scoped>
.column-selector-grid {
  padding: 12px;
}

.selector-actions {
  margin-bottom: 16px;
  display: flex;
  gap: 8px;
}

.grid-container {
  border: 1px solid #f0f0f0;
  border-radius: 4px;
  padding: 12px;
  background: #fafafa;
  margin-bottom: 16px;
}

.grid-layout {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 12px;
}

.grid-item {
  position: relative;
  height: 80px;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  background: #fff;
  cursor: move;
  transition: all 0.2s;
  padding: 8px;
  overflow: hidden;
}

.grid-item:hover {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.grid-item.required-item {
  background-color: #f6ffed;
}

.grid-item.selected-item {
  background-color: #e6f7ff;
}

.drag-handle {
  position: absolute;
  top: 4px;
  right: 4px;
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #f0f0f0;
  border-radius: 2px;
  color: #666;
  cursor: move;
  z-index: 1;
}

.grid-item.required-item .drag-handle {
  background: #d9f7be;
}

.item-content {
  height: 100%;
  display: flex;
  align-items: center;
}

.content-checkbox {
  width: 100%;
  display: flex;
  align-items: center;
  margin: 0;
  padding-right: 20px;
}

.content-checkbox >>> .ant-checkbox {
  flex-shrink: 0;
}

.content-checkbox >>> .ant-checkbox + span {
  display: flex;
  align-items: center;
  flex: 1;
  padding-right: 4px;
  white-space: normal;
}

.content-title {
  display: inline-flex;
  align-items: center;
  font-size: 12px;
  line-height: 1.4;
  word-break: break-word;
  text-align: left;
}

.tip-icon {
  margin-left: 4px;
  color: #999;
  font-size: 12px;
}

.fixed-columns-info {
  background-color: #f6ffed;
  border: 1px solid #b7eb8f;
  border-radius: 4px;
  padding: 12px;
}

.info-text {
  margin: 0;
  font-size: 12px;
  color: #666;
  line-height: 1.5;
}

.info-icon {
  margin-right: 6px;
  color: #52c41a;
}

.fixed-first-text {
  color: #52c41a;
  font-weight: bold;
}

.fixed-last-text {
  color: #1890ff;
  font-weight: bold;
}
</style>

表格封装:

<template>
  <div class="list-page">
    <div>
      <slot name="header"/>
    </div>
    <div>
      <a-table
          bordered
          row-key="id"
          size="small"
          ref="tableRef"
          :loading="loading"
          :columns="currentTableColumns"
          :data-source="dataSource"
          :expand-row-by-click="false"
          :row-selection="rowSelection"
          :expandIconColumnIndex="-1"
          @expand="handleExpand"
          :expanded-row-keys="expandedRowKeys"
          @resizeColumn="handleResizeColumn"
          :scroll="{ x: '120%', y: 'calc(100vh - 375px)' }"
          :pagination="false">

        <!-- 动态插槽处理 -->
        <template v-for="slotName in Object.keys($slots)" #[slotName]="scope" :key="slotName">
          <slot :name="slotName" v-bind="scope" />
        </template>

        <!--通用bodyCell插槽封装-->
        <template #bodyCell="{text,record,column,index}">
          <!--  先检查是否有自定义的bodyCell插槽-->
          <template v-if="$slots.bodyCell">
            <slot name="bodyCell" :text="text" :record="record" :column="column" :index="index"></slot>
          </template>
          <!-- 无自定义的bodyCell插槽时的默认处理逻辑-->
          <template v-else>
            {{ formatCellText(text,record,column) }}
          </template>
        </template>

        <!-- 通用操作列 -->
        <template #action="{ record, index }">
          <slot name="action" :record="record" :index="index">
            <!-- 默认操作列 -->
          </slot>
        </template>

        <!-- 扩展行插槽 -->
        <template #expandedRowRender="{ record, index }">
          <slot name="expandedRowRender" :record="record" :index="index" />
        </template>

      </a-table>
      <div class="pagination-wrapper">
        <a-pagination
            show-size-changer
            size="small"
            v-model:current="pagination.page"
            v-model:page-size="pagination.pageSize"
            :show-total="(total) => `共 ${total} 条`"
            :total="pagination.total"
            :pageSizeOptions="['100', '200', '300', '500']"
            @change="onChangePage"
        >
        </a-pagination>
      </div>
    </div>

    <!--   自定义表头模态框-->
    <ColumnSelector
        :visible="showColumnSelector"
        :all-columns="enhancedColumns"
        :default-columns="selectedColumns"
        @confirm="handleColumnConfirm"
        @closeSelector="closeSelector"
    />
  </div>
</template>

<script setup>
import {computed, h, nextTick, onMounted, onUnmounted, onUpdated, reactive, ref} from "vue";
import {settingStore} from "../../store/index.js";
import {storeToRefs} from "pinia";
import ColumnSelector from "../../components/ColumnsSelector/index.vue";
import {debounce, deepClone} from "../../utils/util.js";
const settingStoreRef = settingStore();
const { tableOrderByName, tableAscOrDescOrNomal } = storeToRefs(settingStoreRef)
import customizeHeaderApi from '../../api/CustomizeHeader/index.js'
import {message} from "ant-design-vue";
import {useRoute} from "vue-router";

let tableRef = ref(null)
const props = defineProps({
  //表格加载状态
  loading:Boolean,
  showColumnSelector:Boolean,
  rowSelection: {
    type: Object
  },
  pagination:{
    type: Object,
    default: { },
  },
  //需要排序的表格标题
  sortTableList:{
    type: Array,
    default: [],
  },
  //补充必需的列
  requiredColumns:{
    type: Array,
    default: [],
  },
  expandedRowKeys:{
    type: Array,
    default: [],
  },
  columns:Array,
  dataSource:{
    type: Array,
    default: [],
  },
})
const route = useRoute();

const emit = defineEmits(['onChangePage','closeSelector','resetPage','columnResized'])

//自定义表头拉伸
const handleResizeColumn=(w, col)=>{
  col.width = w;
  // 强制表格重新渲染
  nextTick(() => {
    tableRef.value?.$forceUpdate?.();
  })
  debounceResizeColumn({
    dataIndex: col.dataIndex,
    width: w,
    routePath: route.path
  })
}
const debounceResizeColumn = debounce((payload)=>{
  emit('columnResized',payload)
  checkAllCellsOverflow();
},500)

let DEFAULT_VISIBLE_COLUMNS = props.columns.map((item)=>{
  return item.dataIndex
})
const handleExpand = (expanded, record) => {
  emit('handleExpand',expanded, record)
};

const onChangePage=(data)=>{
  emit('onChangePage', data)
}
const closeSelector=()=>{
  emit('closeSelector')
}
//排序
// 当前排序状态
const currentSorter = ref({
  field: tableOrderByName.value,
  order: tableAscOrDescOrNomal.value ?
      (tableAscOrDescOrNomal.value === '0' ? 'ascend' : 'descend') :
      null
});
// 生成带有自定义排序图标的列配置
const currentSortTableColumns = computed(() => {
  return props.columns.map(col => {
    if (props.sortTableList.includes(col.dataIndex)) {
      const isCurrentSorted = currentSorter.value.field === col.dataIndex;
      const sortUpActive = isCurrentSorted && currentSorter.value.order === 'ascend';
      const sortDownActive = isCurrentSorted && currentSorter.value.order === 'descend';
      return {
        ...col,
        // 禁用 Ant Design 的默认排序图标
        sorter: false,
        // 使用自定义标题渲染
        title: () => h('div', {
          class: 'sortable-header',
          onClick: (event) => {
            handleHeaderClick(col.dataIndex, event);
          }
        }, [
          h('span', {
            class: 'header-title',
          }, col.title),
          h('div', {
            class: 'sort-icons',
            style: 'display: flex; flex-direction: column; margin-left: 4px; line-height: 1;',
            onClick: (event) => {
              // 阻止事件冒泡,排序图标有自己的点击事件
              event.stopPropagation();
            }
          }, [
            // 升序三角形图标(正三角)
            h('div', {
              class: `sort-icon sort-up ${sortUpActive ? 'active' : ''}`,
              onClick: () => handleSortClick(col.dataIndex, 'ascend')
            }),
            // 降序三角形图标(倒三角)
            h('div', {
              class: `sort-icon sort-down ${sortDownActive ? 'active' : ''}`,
              onClick: () => handleSortClick(col.dataIndex, 'descend')
            })
          ])
        ])
      };
    }
    return col;
  });
});
// 处理排序图标点击
const handleSortClick = (field, order) => {
  // 如果点击的是当前排序字段且当前已经是该排序方式,则取消排序
  if (currentSorter.value.field === field && currentSorter.value.order === order) {
    settingStoreRef.$patch({
      tableOrderByName: undefined,
      tableAscOrDescOrNomal: undefined
    });
    currentSorter.value = {
      field: undefined,
      order: null
    };
  } else {
    // 设置新的排序
    const sortOrder = order === 'ascend' ? '0' : '1';
    settingStoreRef.$patch({
      tableOrderByName: field,
      tableAscOrDescOrNomal: sortOrder
    });
    currentSorter.value = {
      field: field,
      order: order
    };
  }
  // 重置页码并重新加载数据
  emit('resetPage')
};
// 处理列标题点击(点击标题其他位置)
// 处理表头点击
const handleHeaderClick = (field, event) => {
  const target = event.target;
  // 判断是否点击了排序图标区域
  const isSortIcon = target.closest('.sort-icons') ||
      target.classList.contains('sort-icon') ||
      target.classList.contains('sort-up') ||
      target.classList.contains('sort-down') ||
      target.tagName === 'DIV' && (
          target.style.borderLeft.includes('transparent') ||
          target.style.borderRight.includes('transparent')
      );

  // 如果点击的是排序图标区域,不处理(由 handleSortClick 处理)
  if (isSortIcon) {
    return;
  }
  // 获取当前列的排序状态
  const isCurrentColumn = currentSorter.value.field === field;
  const isAscending = currentSorter.value.order === 'ascend';
  const isDescending = currentSorter.value.order === 'descend';
  if (isCurrentColumn) {
    // 如果当前列已经是排序状态,点击表头其他区域取消排序
    if (isAscending || isDescending) {
      settingStoreRef.$patch({
        tableOrderByName: undefined,
        tableAscOrDescOrNomal: undefined
      });
      currentSorter.value = {
        field: undefined,
        order: null
      };
      emit('resetPage')
    }
  } else {
    // 如果当前列没有排序,点击表头设置为默认升序
    settingStoreRef.$patch({
      tableOrderByName: field,
      tableAscOrDescOrNomal: '0' // 0 表示升序
    });
    currentSorter.value = {
      field: field,
      order: 'ascend'
    };
    emit('resetPage')
  }
};
function initTableOrder(){
  settingStoreRef.$patch({
    tableOrderByName:undefined,
    tableAscOrDescOrNomal:undefined
  })
  currentSorter.value = {
    field: undefined,
    order: null
  };
}

//自定义表头 start
const visibleColumns = ref([...DEFAULT_VISIBLE_COLUMNS]);
let allDefaultColumns = ref([])
// 获取实际显示的表格列
const currentTableColumns = computed(() => {
  const allColumns = currentSortTableColumns.value;
  // 过滤出需要显示的列,并保持顺序
  return visibleColumns.value
      .map(dataIndex => allColumns.find(col => col.dataIndex === dataIndex))
      .filter(Boolean);
});

// 当前选中的列
const selectedColumns = ref([...DEFAULT_VISIBLE_COLUMNS]);
const selectorColumns = computed(() => {
  // 过滤出需要显示的列,并保持顺序
  return allDefaultColumns.value
      .map(dataIndex => props.columns.find(col => col.dataIndex === dataIndex))
      .filter(Boolean);
});
function getColumnTip(dataIndex) {
  let tips = {};
  props.columns.forEach((item)=>{
    tips[item.dataIndex] =  item.title;
  })
  return tips[dataIndex];
}
// 增强列信息
const enhancedColumns = computed(() => {
  let columns = selectorColumns.value;
  // 确保顺序与 tableColumns 一致
  return columns.map(col => ({
    ...col,
    tip: getColumnTip(col.dataIndex),
    required: props.requiredColumns.includes(col.dataIndex)
  }));
});
// 保存列设置
async function handleColumnConfirm(columns) {
  try {
    // 更新visibleColumns和selectedColumns
    visibleColumns.value = columns;
    selectedColumns.value = columns;
    // 接口保存
    await customizeHeaderApi.update({
      pageRoutePath: route.path,
      tableColumsNameLst: columns
    })
    emit('closeSelector');
    message.success('表头设置已保存');
  } catch (e) {
    console.error('保存列设置失败', e);
    message.error('保存表头设置失败');
  }
}
// 加载保存的设置
async function loadColumnPreference() {
  try {
    //获取已保存的设置
    const res = await customizeHeaderApi.get({pageRoutePath:route?.path});
    const saved = res.data?.tableColumsNameLst;
    // 验证并处理保存的列设置
    if (saved && saved.length > 0) {
      // 1. 过滤掉不存在的列
      const validColumns = saved.filter(dataIndex =>
          props.columns.some(col => col.dataIndex === dataIndex)
      );
      // 2. 补充必需的列
      props.requiredColumns.forEach(col => {
        if (!validColumns.includes(col)) {
          validColumns.unshift(col); // 必需列放在前面
        }
      });
      visibleColumns.value = validColumns;
      //必须列
      let requiredColumns = deepClone(props.columns);
      requiredColumns = requiredColumns.filter((col)=>props.requiredColumns.includes(col.dataIndex));
      //已保存的必须列
      let savedNonRequiredColumns = saved.filter(dataIndex => !props.requiredColumns.includes(dataIndex)).map(dataIndex=>
          props.columns.find(col=> col.dataIndex == dataIndex)).filter(Boolean);
      //未保存的列
      let unsavedCols = props.columns.filter(col=>!props.requiredColumns.includes(col.dataIndex) && !saved.includes(col.dataIndex));
      const orderColumns = [...requiredColumns,...savedNonRequiredColumns,...unsavedCols];
      allDefaultColumns.value = orderColumns.map(col=>col.dataIndex);
    } else {
      // 使用默认值
      visibleColumns.value = [...DEFAULT_VISIBLE_COLUMNS];
      allDefaultColumns.value = [...DEFAULT_VISIBLE_COLUMNS];
    }
    // 更新selectedColumns(用于ColumnSelector)
    selectedColumns.value = [...visibleColumns.value];
  } catch (e) {
    console.error('加载列设置失败', e);
    visibleColumns.value = [...DEFAULT_VISIBLE_COLUMNS];
    selectedColumns.value = [...DEFAULT_VISIBLE_COLUMNS];
  }
}
//自定义表头 end

// 格式化单元格文本
const formatCellText = (text, record, column) => {
  if (typeof text === 'object') {
    return JSON.stringify(text)
  }
  return text || ''
}
// 检查文本是否溢出
const overflowMap = reactive(new Map());
const isOverflow = (columnKey, rowKey) => {
  return overflowMap.get(`${rowKey}-${columnKey}`) || false;
};
const checkAllCellsOverflow =()=>{
  nextTick(()=>{
    const cells = document.querySelectorAll('.table-cell');
    cells.forEach((cell)=>{
      const columnKey = cell.getAttribute('data-column-key');
      const rowKye = cell.getAttribute('data-row-key');
      const key = `${rowKye}-${columnKey}`;
      const isOverFlow = cell.scrollWidth > cell.clientWidth;
      overflowMap.set(key,isOverFlow);
    })
  })
}

onMounted(()=>{
  initTableOrder();
  loadColumnPreference();
  // 监听窗口变化
  window.addEventListener('resize', checkAllCellsOverflow);
})
onUpdated(()=>{
  checkAllCellsOverflow();
})
// 清理事件监听
onUnmounted(() => {
  window.removeEventListener('resize', checkAllCellsOverflow);
});
// 暴露方法给父组件
defineExpose({
  initTableOrder,
  isOverflow,
});
</script>

<style scoped>
.pagination-wrapper {
  text-align: right;
  margin-top: 10px;
}
</style>
/**  防抖函数,规定时间内连续出发的函数只执行最后一次 */
function debounce(func, wait, immediate) {
    let timeout; // 定义一个计时器变量,用于延迟执行函数
    return function (...args) { // 返回一个包装后的函数
        const context = this; // 保存函数执行上下文对象
        const later = function () { // 定义延迟执行的函数
            timeout = null; // 清空计时器变量
            if (!immediate) func.apply(context, args); // 若非立即执行,则调用待防抖函数
        };
        const callNow = immediate && !timeout; // 是否立即调用函数的条件
        clearTimeout(timeout); // 清空计时器
        timeout = setTimeout(later, wait); // 创建新的计时器,延迟执行函数
        if (callNow) func.apply(context, args); // 如果满足立即调用条件,则立即执行函数
    };
}
import { defineStore } from "pinia";

export const columWidthStore = defineStore('columnWidthStore', {
    state: () => ({
        columnWidthList: [],
    }),
    actions: {
        setColumnList(record){
            const { routePath,dataIndex,width } = record;
            let routeConfig = this.columnWidthList.find(item=>item.routePath == routePath);
            if(routeConfig){
                const columnConfig = routeConfig?.columnList.find(item=>item.dataIndex == dataIndex);
                if(columnConfig){
                    columnConfig.width = width;
                }else{
                    routeConfig.columnList.push({dataIndex,width})
                }
            }else{
                this.columnWidthList.push({
                    routePath,columnList:[{dataIndex,width}]
                })
            }

        },
        getRouteColumnWidths(routePath) {
            const routeConfig = this.columnWidthList.find(item=>item.routePath == routePath);
            if(!routeConfig)return [];
            return routeConfig.columnList;
        },
        deleteRouteConfig(routePath) {
            let index = this.columnWidthList.findIndex((item)=>{return item.routePath == routePath});
            this.columnWidthList.splice(index,1);
        },
        deleteAllRoute(){
            this.columnWidthList = []
        }

    },
    getters: {

    },
    persist: {
        enabled: true,
        // 自定义持久化参数
        strategies: [
            {
                // 自定义key,默认就是仓库的key
                key: "columWidthStore",
                // 自定义存储方式,默认sessionStorage
                storage: localStorage,
                // 指定要持久化的数据,默认所有 state 都会进行缓存,可以通过 paths 指定要持久化的字段,其他的则不会进行持久化。
                paths: [
                    "columnWidthList",
                ],
            }
        ],
    },
})


import { defineStore } from "pinia";
import { deepClone } from "../../utils/util.js";
const customTitlesStr = sessionStorage.getItem(import.meta.env.VITE_APP_TBAS_TITLES_KEY)
const customTitles = (customTitlesStr && JSON.parse(customTitlesStr)) || []
export const settingStore = defineStore('settingStore', {
    state: () => ({
        isMobile: false,
        visible: false,
        pageMinHeight: 0,
        tableOrderByName:undefined,
        tableAscOrDescOrNomal:undefined,
        menuData: [],
        menuList: {},
        activeValue: '',
        activatedFirst: undefined,
        customTitles,
    }),
    actions: {
        reset() {
            this.isMobile = false;
            this.tableOrderByName = undefined;
            this.tableAscOrDescOrNomal = undefined;
            this.visible = false;
            this.pageMinHeight = 0;
            this.menuData = [];
            this.menuList = {};
            this.activeValue = '';
            this.activatedFirst = undefined;       
            this.customTitles = customTitles;
        },
        setDevice(state, isMobile) {
            state.isMobile = isMobile
        },
        setVisible(state, visible) {
            state.visible = visible
        },
        setMenuValue(value) {
            this.menuData.push(value)
        },

    },
    getters: {
 
    },
    persist: {
        enabled: true,
        // 自定义持久化参数
        strategies: [
            {
                // 自定义key,默认就是仓库的key
                key: "settingStore",
                // 自定义存储方式,默认sessionStorage
                storage: localStorage,
                // 指定要持久化的数据,默认所有 state 都会进行缓存,可以通过 paths 指定要持久化的字段,其他的则不会进行持久化。
                paths: [
                    "menuData",
                    "activeValue",
                    "menuList",
                    "tableOrderByName",
                    "tableAscOrDescOrNomal"
                ],
            }
        ],
    },
})

组件使用:

<template>
  <div>
    <customizeTable
        ref="customizeTableRef"
        :loading="loading"
        :columns="tableColumns"
        :pagination="pagination"
        :data-source="dataSource"
        :requiredColumns="requiredColumns"
        :sortTableList="sortTableList"
        :showColumnSelector="showColumnSelector"
        :expandedRowKeys="expandedRowKeys"
        @onChangePage="onChangePage"
        @closeSelector="closeSelector"
        @expand="handleExpand"
        @resetPage="resetPage"
        @columnResized="columnResized"
    >
      <template #header>
        <div style="display: flex;margin-bottom: 10px">
          <span>测试:</span>
          <a-input size="small" style="width:120px;margin-right: 4px" v-model:value="testValue" ></a-input>
          <a-button size="small" @click="testValue = undefined">清空</a-button>
        </div>
      </template>
      <template #bodyCell="{ text, record, column }">
        <template v-if="overFlowColumns.includes(column.dataIndex)">
          <div class="table-cell"
               :data-row-key="record.key"
               :data-column-key="column.dataIndex"
               @dblclick="handleCellDoubleClick(record, column)">
            <a-popover  placement="leftBottom" v-if="getCellOverflow(column.dataIndex, record.key)" :content="record[column.dataIndex]">
              <span> {{ text }}</span>
            </a-popover>
            <div v-else>
              {{ text }}
            </div>
          </div>
        </template>
        <template v-if="column.dataIndex === 'action'">
          <a-dropdown placement="bottom" >
            <a class="ant-dropdown-link" @click.prevent>
              操作
            </a>
            <template #overlay>
              <a-menu class="menu-active-background-color">
                <a-menu-item >
                  <a-button
                      size="small"
                      @click="toggleExpand(record.id)"
                      style="padding: 0 4px;"
                  >
                    {{ expandedRowKeys.includes(record.id) ? '收起' : '详情' }}
                  </a-button>
                </a-menu-item>
              </a-menu>
            </template>
          </a-dropdown>
        </template>
      </template>

      <template #expandedRowRender="{ record }">
        <div class="expanded-style">
          <a-descriptions class="mt-10" title="详细信息" bordered :column="2">
            <a-descriptions-item :contentStyle="contentColor" label="1">{{record.one}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="2">{{record.two}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="3">{{record.three}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="4">{{record.four}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="5">{{record.five}}</a-descriptions-item>
            <a-descriptions-item :contentStyle="contentColor" label="6" >{{record.six}}</a-descriptions-item>
          </a-descriptions>
        </div>
      </template>
    </customizeTable>
  </div>
</template>
<script setup>
import { onMounted,  ref} from "vue";
import {columWidthStore} from "../src/store/index.js";
import customizeTable from './components/CustomizeTable/index.vue'
import {useRoute} from "vue-router";
import {copyToClipboard} from './utils/util.js'
let route = useRoute();
let customizeTableRef = ref(null)
let loading = ref(false)
let testValue = ref('')
let contentColor = ref({
  backgroundColor:'#fff'
});
let tableColumns = ref([
  {
    title: "序号",
    dataIndex: "no",
    width: 46,
    fixed:'left',
    customRender: ({index}) => `${index + 1}`,
  },
  {
    title: "1",
    dataIndex: "one",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "2",
    dataIndex: "two",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "3",
    dataIndex: "three",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "4",
    dataIndex: "four",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "5",
    dataIndex: "five",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "6",
    dataIndex: "six",
    width: 80,
    key:'one',
    resizable: true,
    ellipsis:true,
  },
  {
    title: "操作",
    dataIndex: "action",
    fixed:'right',
    key:'action',
    width: 50,
  },
])
let overFlowColumns = tableColumns.value.filter((item)=>!['no','action'].includes(item.dataIndex)).map((item)=>item.dataIndex);
let dataSource = ref([
  {one:'111',two:'2222',three:'333',four:'444444444444444444444444444444444444444',five:'555',six:'666',id:1,key:1111}
])
const pagination = ref({
  page: 1,
  pageSize: 10,
  total: 0,
  size: "default",
});
let requiredColumns = ref(['no','one','two'])
let sortTableList = ref(['one','two','three'])
let showColumnSelector = ref(false)
function handleCellDoubleClick(record, column) {
  const text = record[column.dataIndex];
  copyToClipboard(text);
}
const onChangePage = (data) => {
  pagination.value.page = data;
  // getList();
};
const resetPage=()=>{
  pagination.value.page = 1;
  // getList();
}
const closeSelector=()=>{
  showColumnSelector.value = false;
}

/* 表格详情展开关闭 start */
// 添加展开行的状态管理
let expandedRowKeys = ref([]);
// 切换展开状态的方法
const toggleExpand = (id) => {
  const index = expandedRowKeys.value.indexOf(id);
  if (index > -1) {
    // 如果已经展开,则关闭
    expandedRowKeys.value.splice(index, 1);
  } else {
    // 如果未展开,则展开并关闭其他已展开的行
    expandedRowKeys.value = [id];
  }
};
// 处理表格展开事件
const handleExpand = (expanded, record) => {
  if (expanded) {
    // 展开时关闭其他行
    expandedRowKeys.value = [record.id];
  } else {
    // 收起时移除
    const index = expandedRowKeys.value.indexOf(record.id);
    if (index > -1) {
      expandedRowKeys.value.splice(index, 1);
    }
  }
};
/* 表格详情展开关闭 end */

const columStore = columWidthStore();
const columnResized=(record)=>{
  columStore.setColumnList({
    routePath: record.routePath,
    dataIndex: record.dataIndex,
    width: record.width,
  });

}
const initColumnWidth=()=>{
  let temp = columStore.getRouteColumnWidths(route?.path)
  if(temp?.length){
    tableColumns.value.forEach((el)=>{
      let index = temp.findIndex(item=>item.dataIndex == el.dataIndex);
      if(index>=0)el.width = temp[index].width;
    })
  }
}
const getCellOverflow = (columnKey, rowKey) => {
  if (!customizeTableRef.value?.isOverflow) {
    console.warn('无法访问子组件的 isOverflow 方法');
    return false;
  }
  try {
    return customizeTableRef.value.isOverflow(columnKey, rowKey);
  } catch (error) {
    console.error('调用 isOverflow 方法出错:', error);
    return false;
  }
};
onMounted(()=>{
  initColumnWidth();
})

</script>
<style scoped>
.table-cell {
  white-space: nowrap;
  text-overflow: ellipsis;
  overflow: hidden;
}
</style>


GIF 2026-1-27 11-27-26.gif

React Router v6 实战:从基础用法到组件抽离

React Router 是 React 生态中实现单页面路由跳转的核心工具,今天我们就以这个完整的 Demo 为例,一步步讲解它的基础用法、组件抽离方式和关键注意事项。

一、项目结构与准备工作

我们先梳理一下这个 Demo 的文件结构,这是典型的路由拆分方式:

carbon.png

1. 安装依赖

首先确保已经安装了 React Router v6:bashnpm install react-router-dom

yarn add react-router-dom

二、核心实现:从基础配置到组件抽离

1. 第一步:抽离路由配置组件

我们把所有路由规则统一放到 src/routes/index.tsx 中,这样便于维护和复用。

carbon (1).png

2. 第二步:在根组件中引入路由上下文

App.tsx 中,我们用 <BrowserRouter> 包裹整个应用,提供路由运行所需的上下文环境,然后引入我们抽离好的 AppRoute 组件。

carbon (2).png

3. 第三步:页面组件中使用 <Link> 实现跳转

Home.tsxAbout.tsx 中,我们用 <Link> 标签替代原生 <a> 标签,实现无刷新跳转。

carbon (3).png

carbon (4).png

三、关键注意事项(避坑指南)

  1. 包名不要写错很多新手会把 react-router-dom 误写成其他名字,导致依赖找不到。正确的导入方式是:

carbon (5).png

  1. <Route> 标签不能包含文本内容<Route> 是纯配置型标签,只能用来定义路径和组件映射,不能在标签内直接写文本,否则会报错。❌ 错误写法:<Route path="/" element={<Home />}>去Home页面</Route>✅ 正确写法:<Route path="/" element={<Home />} />

  2. <Link>to 属性建议用绝对路径to="/about" 这样的绝对路径更稳定,避免了相对路径在嵌套路由中可能出现的问题。

  3. 路由上下文的作用域useParamsuseNavigate 等路由钩子,以及 <Link><Routes> 等组件,必须在 <BrowserRouter> 包裹的范围内使用,否则会报错。

  4. 抽离路由的好处

    • 路由规则集中管理,修改和维护更方便
    • 根组件代码更简洁,职责更清晰
    • 复杂项目中可以进一步拆分成多个路由模块,再统一导入

四、进阶拓展:动态路由与嵌套路由

这个 Demo 是最基础的路由配置,在实际开发中你还会用到这些进阶功能:

  1. 动态路由:用于匹配带参数的路径,比如 /user/123,可以用 useParams() 来获取参数。
  2. 嵌套路由:适合实现侧边栏 + 内容区的布局,通过 <Outlet> 来渲染子路由内容。
  3. 编程式导航:通过 useNavigate() 钩子实现代码跳转,比如登录成功后跳转到首页。

从零打造 AI 全栈应用(四):别小看幻灯片,一个 Carousel 组件背后的工程化与性能思维

在上一篇文章 《从零打造 AI 全栈应用(三):一个 BackToTop 组件背后的工程化与性能思维》中,我们拆解了一个看似简单却极易被忽视的组件,并通过它讨论了事件监听、性能优化与工程边界的问题。

但真实项目中,工程能力往往体现在细节

这篇文章,我们从一个再常见不过的组件 —— 幻灯片 / Carousel 入手,聊聊它背后隐藏的组件设计、状态管理、性能优化和工程化取舍。


一、为什么一个幻灯片组件值得单独写一篇?

很多同学会觉得:

幻灯片不就是个 UI 组件吗?能滑就行。

但在真实项目(尤其是首页、活动页、AI 产品的内容入口)中,它往往意味着:

  • 页面首屏核心组件
  • 高频渲染、长时间驻留
  • 涉及自动播放、交互、状态同步
  • 很容易成为性能与可维护性的隐患

所以我们这次的目标不是「写一个能跑的轮播图」,而是:

写一个工程上站得住脚的 Carousel 组件


二、技术选型:为什么是 shadcn/ui + Embla?

1️⃣ shadcn/ui 的 Carousel 设计哲学

shadcn/ui 提供的并不是一个“黑盒组件”,而是一组组合式组件

  • Carousel
  • CarouselContent
  • CarouselItem

特点很明显:

  • 结构清晰,层次分明
  • 不强绑定样式
  • 底层基于 Embla Carousel,性能成熟

本质上,它更像是一个“轮播能力的外壳”,而不是一个定死的 UI。

这点非常重要 —— 可定制性 = 长期可维护性


2️⃣ 自动播放为什么用插件,而不是自己写 setInterval

自动播放我们选择的是:

import AutoPlay from 'embla-carousel-autoplay'

原因很简单:

  • 和 Embla API 深度适配
  • 内部处理了生命周期、交互中断
  • 不需要自己处理定时器清理

工程中一个重要原则:
能用成熟插件解决的,不要重复造轮子


三、组件设计:从 Props 到职责边界

1️⃣ 数据结构设计

export interface SlideData {
  id: number | string;
  image: string;
  title?: string;
}

几个刻意的设计点:

  • id 不强制 number,兼容服务端数据
  • title 可选,UI 自动适配
  • 组件只关心展示所需的最小数据

不要把组件变成业务垃圾桶。


2️⃣ 组件 Props:只暴露真正需要的能力

interface SlideShowProps {
  slides: SlideData[];
  autoPlay?: boolean;
  autoPlayDelay?: number;
}

这里没有:

  • 当前 index 的受控状态
  • 复杂回调

原因是:

这是一个偏展示型组件,而不是业务中枢


四、状态管理:selectedIndex 为什么是私有状态?

const [selectedIndex, setSelectedIndex] = useState<number>(0);
const [api, setApi] = useState<CarouselApi | null>(null);

关键点:

  • selectedIndex 不从外部传入
  • 通过 CarouselApi 与底层同步
useEffect(() => {
  if (!api) return;
  setSelectedIndex(api.selectedScrollSnap());

  const onSelect = () => {
    setSelectedIndex(api.selectedScrollSnap());
  };

  api.on('select', onSelect);
  return () => api.off('select', onSelect);
}, [api]);

这段代码的工程意义:

  • UI 状态 来源单一(底层 carousel)
  • React 状态只是一个映射
  • 避免“双向状态不同步”

面试常考:如何避免状态源混乱?
这个例子非常典型。


五、自动播放:为什么一定要用 useRef

const plugin = useRef(
  autoPlay
    ? AutoPlay({ delay: autoPlayDelay, stopOnInteraction: true })
    : null
);

如果不用 useRef 会发生什么?

  • 每次 render 都创建新插件实例
  • 自动播放被反复重置
  • 性能抖动、行为不可控

useRef 的本质价值:

在 React 渲染体系外,持久化一个可变对象

这是一个非常典型、非常“面试级别”的用法。


六、交互细节:为什么鼠标移入要暂停?

onMouseEnter={() => plugin.current?.stop()}
onMouseLeave={() => plugin.current?.reset()}

这是一个看似很小,但体验影响极大的细节:

  • 用户正在看内容
  • 自动切走 = 强干扰

好的组件,不是功能多,而是尊重用户行为


七、指示点设计:状态驱动,而不是 DOM 操作

slides.map((_, i) => (
  <button
    key={i}
    className={`h-2 w-2 rounded-full transition-all
      ${selectedIndex === i ? 'bg-white w-6' : 'bg-white/50'}`}
  />
));

几个工程要点:

  • 循环渲染,不操作 DOM
  • 动态类名完全由状态驱动
  • transition-all 提供平滑过渡

React 组件的本质:
UI = f(state)


八、CSS 与性能:渐变背景为什么优于图片?

bg-gradient-to-t from-black/60 to-transparent

相比图片背景,渐变的优势:

  • 不需要额外 HTTP 请求
  • 减少并发资源下载
  • GPU 友好
  • 更容易适配深浅色

在高性能场景下,
能不用图片,就不用图片


九、总结:一个小组件,体现哪些工程能力?

回看这个 Carousel,你至少可以聊清楚:

  • 组件拆分与职责边界
  • 第三方库的正确使用方式
  • React 状态与外部状态同步
  • useRef 的真实应用场景
  • UI 细节与性能取舍

面试官真正想看的,不是你会不会写轮播图,
而是你为什么这么写


vue 表格 vxe-table 如何设置列默认不显示,用户可以手动通过工具栏设置显示

vue 表格 vxe-table 如何设置列默认不显示,用户可以手动通过工具栏设置显示。实现该方式非常简单,可以通过列的 visible 属性设置为默认不显示,然后用户可以在工具栏的自定义列里面勾选显示或隐藏。自定义方式就很方便了。

vxetable.cn

table_custom_def_hide

通过设置 toolbar-config.custom 启用列个性化设置功能,然后将列的 visible=false 设置为默认隐藏,用户可以通过自定义勾选显示;还可以设置是否否禁用,拖拽排序,冻结列等

<template>
  <div>
    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

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

const gridOptions = reactive({
  border: true,
  toolbarConfig: {
    custom: true
  },
  columns: [
    { field: 'seq', type: 'seq', width: 70 },
    { field: 'role', title: 'Role', visible: false },
    { field: 'name', title: 'Name' },
    { field: 'sex', title: 'Sex' },
    { field: 'age', title: 'Age' },
    { field: 'address', title: 'Address', visible: false }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'test abc' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' },
    { id: 10004, name: 'Test4', role: 'Designer', sex: 'Women', age: 24, address: 'Shanghai' }
  ]
})
</script>

多种显示模式

自定义列弹出层支持多种方式,可以设置为弹出层,窗口,抽屉等方式.

通过设置 custom-config.mode='modal' 启用窗口模式

image

设置 custom-config.mode='drawer' 启用窗口模式

image

gitee.com/x-extends/v…

GitHub 入门笔记

作为前端开发新手,Git是我们版本控制的基础工具。虽然我记了笔记,但是我依旧很害怕我的笔记本丢失。因此再在这里发一遍我自己所整理的笔记,也希望能帮到同样在入门Git的小伙伴~


一、Git基础准备

  1. 什么是Git?
Git 是一个分布式版本控制系统,能帮我们跟踪代码变更、协作开发,是前端工程师的必备技能。
  1. macOS安装:
 在 App Store 下载 Xcode,安装后会自动包含 Git。 (注:command+space,输入Terminal可以直接找到mac自带的终端。)

二、本地操作全流程

Step 1:创建版本库

终端输入:# 1. 创建本地文件夹
              mkdir learngit
        # 2. 进入文件夹
              cd learngit
        # 3. 查看当前路径(macOS路径示例)
              pwd

Step 2:初始化仓库

终端输入:git init
        # 输出:Initialized empty Git repository in /Users/michael/learngit/.git/

注:.git是隐藏目录,用ls -ah才能看到。

Step 3:关于Git的常用的指令

  1. 添加文件到暂存区
        git add file名字
  
  2. 提交到仓库
        git commit -m "想要留言的内容"
  
  3. 查看当前文件状态
        git status
  
  4. 查看具体修改内容
        git diff file名字
 
  5. 查看提交历史
        git log
 
  6. 简洁查看历史, 用一行显示提交记录,方便快速浏览
        git log --pretty=oneline
  
  7. 回退到上一个版本
        git reset --hard HEAD^
        注:reset 会直接丢弃修改,慎用,推荐先用 git reflog 找回。
  
  8. 回退到指定版本(通过 Commit ID)
        git reset --hard 1094a...(commit id)
  
  9. 查看所有操作记录(找回回退前的版本)
        git reflog
  
  10. 撤销工作区的修改
        git checkout -- file名字
  
  11. 撤销暂存区的修改(放回工作区)
        git reset HEAD file名字
  
  12. 撤销已提交的修改(安全方式)
        git revert HEAD
  
  13. 添加远程仓库地址
        git remote add origin git@github.com:你的用户名/learngit.git
  
  14. 查看远程仓库信息
        git remote -v
  
  15. 首次推送并关联分支
        git push -u origin main
  
  16. 后续推送
        git push origin main
        
  17. 创建并切换到分支
        1) git checkout -b 分支名字
        2) git switch -c 分支名字
        这两个都可以

  19. 查看当前分支
        git branch
  
  20. 切换到主分支
        1) git checkout main
        2) git switch main
        这两个都可以
  
  21. 合并分支
        git merge 分支名字
        
  22. 删除分支
        git branch -d 分支名字
        
  23. 禁用Fast Forward 合并
        git merge --no-ff -m "merge with no-ff" dev
        
  24. 临时保存当前工作进度
        git stash

  25. 查看保存的进度
        git stash list

  26. 恢复进度(不删除 stash 记录)
        git stash apply

  27. 恢复进度并删除 stash 记录
        git stash pop

  28. 恢复指定的 stash
        git stash apply stash@{0} (注:这里的stash@{0}默认的是第一个)
  
  29. 复制指定 Commit 到当前分支
        git cherry-pick commitID
        
  30. 配置别名
        git config --global alias.想要配置的别名 原来的名称
        例如:git config --global alias.st status
        
  31. 如何查看自己所配置的别名
        cat .gitconfig
        
  32. 创建标签
        git tag v1.0(注:一般是v什么什么,有点类似于版本)
  
  33. 创建带说明的标签
        git tag -a v1.0 -m "Verrsion 1.0"
        
  34. 查看所有的标签
        git tag
      
  35. 查看标签详情
        git show 想要查看的标签的名字
        
  36. 删除标签
        git tag -d 标签的名字
  
  37. .gitignore文件
        1.如果需要强制添加被忽略的文件
            git add -f 文件名称
        
        2.检查.gitignore规则是否正确
            git check-ignore -v 文件名称
            
  38. Cmake基础操作
        1. 创建 build 目录
            mkdir build && cd build
        
        2. 生成 Makefile
            cmake ..
            
        3. 编译
            make
        
        4. 运行程序
            ./main

总结

这篇笔记整理了 Git 的基础操作和常用命令,适合初学者快速入门。

在 Trae IDE 中利用 MCP 高效抓取与收录网络文章

背景

在维护 奥升官网(基于 Nuxt 3 构建)的过程中,我们需要将发布在其他平台的技术文章迁移到官网的内容管理系统中。手动复制粘贴不仅效率低下,还容易遗漏图片资源或导致格式错乱。

借助 Trae IDEChrome MCP,我们可以将这一过程高度自动化。本文将详细介绍这套抓取方案的思路与实现。

环境与工具

本方案依赖以下核心环境与工具:

  1. Trae IDE: 新一代 AI 驱动的集成开发环境(我使用的国际版,开了会员),可调用 Gemini-3-Pro-Preview 模型,支持复杂的上下文理解与多步任务执行。
  2. MCP (Model Context Protocol):
    • mcp_chrome: 提供浏览器自动化能力,用于访问网页、读取 DOM 结构、截图等。

抓取思路

整个抓取流程可以概括为以下四个步骤:

  1. 目标分析: AI 通过 MCP 浏览器工具访问目标 URL,解析页面结构,识别标题、正文、发布时间及图片链接。
  2. 内容提取: 将 HTML 内容转换为符合 Nuxt Content 规范的 Markdown 格式,并自动提取 Frontmatter(元数据)。
  3. 图片处理:
    • 自动下载文章中的图片到 public/images/articles 目录,并按文章id分文件夹存储。
    • 使用 sharp 对图片进行压缩优化(WebP/JPG 转换、尺寸调整)。
    • 将图片按上传到 OSS 指定文件路径中。
    • 替换 Markdown 中的图片链接为 OSS 上的路径。
  4. 自动收录: 将处理好的 Markdown 文件写入 content/articles/ 目录。

关键实现

1. 页面内容读取

利用 MCP 的 chrome_read_pagechrome_get_web_content 工具,我们可以直接获取渲染后的页面内容,这对于动态加载的页面尤为重要。

2. 图片资源的自动化处理

1. 提取链接 AI 会从 DOM 中找出所有 <img> 标签的 src 属性。

2. 批量下载 AI 会生成并执行 Shell 命令(如 curlwget)来下载图片。

# AI 自动生成的下载命令示例
mkdir -p publichttps://ucode-orise.oss-cn-beijing.aliyuncs.com/website/article/20
curl -o publichttps://ucode-orise.oss-cn-beijing.aliyuncs.com/website/article/20/1.jpg http://example.com/path/to/image.jpg

3. 图片压缩 利用项目中的 sharp 脚本对下载的图片进行批量处理,确保加载性能。

4. 图片上传到 OSS 使用ali-oss 库,编写 upload_oss 脚本,将压缩后的图片上传到 OSS 上指定的目录。

async function uploadFile(filePath, retryCount = 0) {
    // 处理上传到oss的文件路径
    const relativePath = path.relative(articlesDir, filePath);
    const objectName = `website/article/${relativePath.split(path.sep).join('/')}`;

    try {
        const result = await client.put(objectName, filePath);
        //... 
    } catch (err) {
        //...
    }
}

3. Markdown 生成与 Frontmatter 注入

Nuxt Content 要求每篇文章包含特定的元数据。我们定义了统一的 Prompt 模板,让 AI 生成标准格式:

---
title: 文章标题
slug: article-slug
description: 文章摘要...
date: 2024-03-20
category: 分类
cover: https://ucode-orise.oss-cn-beijing.aliyuncs.com/website/article/[slug]/cover.jpg
---

## 正文内容
...

实际效果展示

下图是我们在 Trae 环境中开发时的文章列表页面截图,展示了收录后的效果:

IDEIDEIDE

总结

通过 Trae IDE + MCP 的组合,我们将原本需要 10 分钟/篇的人工迁移工作缩短到了 2-3 分钟/篇(主要是AI对话推理时间)。开发者只需提供一个 URL,剩下的分析、下载、转换、优化工作全部由 AI 代理完成。这不仅极大提升了效率,还保证了代码风格与目录结构的一致性。

起底 Nuxt 构建魔法:一份代码是如何变成两套“平行宇宙”产物的?

在系列的前两篇文章中,我们聊了 SSR 的避坑指南和水合(Hydration)的底层逻辑。今天,我们触达最核心的工程化问题:

我们在编辑器里写的是同一套 Vue 代码,但为什么在服务端它能避开 window 运行,在客户端又能精准操作 DOM?Nuxt 到底在构建阶段做了什么手脚?

  1. 物理层面的“分家”:双重并行构建

当你运行 npx nuxi build 时,Nuxt 并不是简单地打包了一次代码。实际上,它驱动构建引擎启动了两轮完全独立且并行的编译任务:

  1. Server Build(服务端构建)

    • 产物:生成一个运行在 Node.js 或 Edge Worker 环境的 .mjs 模块。
    • 入口:对应服务端渲染逻辑,负责接收请求、执行逻辑、拼装 HTML。
  2. Client Build(客户端构建)

    • 产物:生成由浏览器下载的 .js 静态资源。
    • 入口:对应客户端交互逻辑,负责数据响应、DOM 变更、SPA 路由跳转。

这两个产物物理隔离,入口文件不同,最终被打到了 .output/server 和 .output/public 两个完全不同的目录下。

  1. 编译宏:代码里的“时空转换开关”

在代码中,我们经常使用 import.meta.client 或 process.client。你可能以为这是一个运行时变量,但实际上它是编译时的静态占位符(Macro)

在构建流水线上,Vite 会根据当前的任务目标,暴力地进行静态替换

  • 在 Client Build 任务中:Vite 会把所有的 import.meta.client 替换为字面量 true
  • 在 Server Build 任务中:它会被替换为 false

随之而来的奇迹是:死代码消除 (Tree-shaking)。
如果编译器看到 if (false) { ... },它会确认这段代码永远不会执行,从而在最终的产物中物理删除掉这块代码。这意味着,你写在 if (import.meta.server) 里的逻辑,根本不会出现在发给浏览器的 JS 文件中。

⚠️ 安全提醒:  此处仅为说明代码裁剪的极端物理效果。在生产实践中,数据库密钥等敏感信息绝不应硬编码在源码中,而应通过环境变量(Runtime Config)在运行时注入,并在服务器端通过进程环境读取。

  1. SSR 只是“一锤子买卖”:路由接管逻辑

这里是很多新手的误区: “是不是每次点页面跳转,服务器都要重新渲染一次 HTML?”

答案是:不。  Nuxt 采用的是 通用渲染(Universal Rendering)  架构。

  • 首屏访问:由 服务端入口 A 接管。它执行渲染,生成 HTML(死代码),将静态内容发给浏览器。
  • 激活(Hydration) :浏览器加载 客户端入口 B。B 会读取服务端留下的数据(Payload),在内存中重建响应式系统,并接管现有的 DOM。
  • 后续跳转:一旦水合完成,应用就变成了一个标准的 SPA(单页面应用) 。当你通过 <NuxtLink> 跳转时,浏览器不会再请求新的 HTML,而是通过 JS 异步加载数据并直接在客户端更新视图。
  1. 插件系统的真相:任务队列的激活

Nuxt 插件并不是在 onMounted 后才注册的,而是在应用启动的最早期

插件的本质是一个预初始化任务队列。在 Vue 实例挂载前,Nuxt 会依次执行这些插件。

  • .server 插件:只在服务端构建任务中被包含。
  • .client 插件:只在客户端构建任务中被包含。

这种“环境标记”让插件能够精准地在各自的“平行宇宙”中初始化。如果你的插件需要操作 BOM/DOM,将其命名为 .client.ts 是最工程化的做法,这能确保它在构建阶段就被服务端彻底剔除。

  1. 总结:SSR 的工程闭环

通过这三篇文章,我们勾勒出了 Vue SSR 的完整闭环:

  1. 编写阶段:利用环境判断和生命周期钩子编写同构代码。
  2. 构建阶段:Nuxt 将源码拆解为两套物理产物,通过硬编码宏实现代码裁剪与瘦身。
  3. 运行阶段(首屏) :服务端入口 A 生产 HTML,发送给浏览器实现秒开。
  4. 交互阶段(SPA) :客户端入口 B 完成水合后全面接管,实现后续的无刷新跳转。

理解了“两套并行产物”的逻辑,你就再也不会为环境报错感到焦虑。底层架构的复杂,换来的是开发者的心智解耦。


这是《Vue3 组件库 SSR 深度解析》系列的终结篇。如果你对 NuxtLink 与 RouterLink 的底层差异,或者 Vue 为何不提供原生环境判断变量感兴趣,请关注我的后续番外篇!

从一道面试题看算法思维:最小栈(Min Stack)的从 O(N) 到 O(1) 进化之路

“最小栈”(LeetCode 155题)作为一道非常经典的试金石。它不涉及复杂的动态规划或图论,却能精准地考察候选人对数据结构的理解深度以及空间换时间这一核心算法思想的掌握程度。

题目要求看似简单:设计一个栈,支持 push、pop、top 操作,并能在常数时间 

O(1)

 内检索到最小元素 getMin。

很多候选人在看到题目时,往往忽略了 

O(1)

 这个关键约束,直接给出了一个功能正确但性能不达标的方案。今天,我们就从面试官的视角,来剖析这道题从 

O(N)

 到 

O(1)

 的进化过程。

第一阶段:直觉与妥协 (暴力解法)

当被问到“如何实现一个支持获取最小值的栈”时,大多数人的第一直觉是利用 JavaScript 数组的原生方法。既然栈的本质是先进后出(LIFO),那么 push、pop 和 top 都很容易实现。

对于 getMin,最朴素的想法是:既然我要找最小值,那就遍历整个数组好了。

以下是这类回答的典型代码实现:

JavaScript

// ES5 构造函数
const MiniStack = function() {
    this.stack = []; // 存储数据的数组
}

MiniStack.prototype.push = function(x) {
    this.stack.push(x);
}

MiniStack.prototype.pop = function() {
    return this.stack.pop();
}

MiniStack.prototype.top = function() {
    if(!this.stack || !this.stack.length){
        return;
    }
    return this.stack[this.stack.length-1];
}

// 暴力解法核心:遍历查找 O(N)
MiniStack.prototype.getMin = function() {
    let minValue = Infinity; // 初始化为无穷大
    const { stack } = this;
    // 遍历整个栈寻找最小值
    for(let i = 0; i < stack.length; i++){
        if(stack[i] < minValue){
            minValue = stack[i];
        }
    }
    return minValue;
}

面试官点评

这份代码从功能上来说是正确的。它利用了 JS 数组模拟栈,push、pop、top 的时间复杂度确实是 

O(1)

但是,getMin 方法的实现存在致命弱点:它的时间复杂度是

O(N) 

随着栈内元素数量(N)的增加,获取最小值的耗时将线性增长。如果在高频调用的场景下,这种遍历操作是不可接受的性能瓶颈。在面试中,这只能算是一个“勉强及格”的答案,因为它没有体现出任何算法优化的思维。

第二阶段:思维跃迁 (空间换时间)

如何将 

O(N)

 优化为 

O(1)

?这需要我们转换思维。

在暴力解法中,我们每次调用 getMin 都要重新计算。也就是我们“忘记”了之前的比较结果。如果我们能有一种机制,能够“记住”随着数据入栈过程中,每一个状态下的最小值,那就不需要回头遍历了。

这里引入算法设计中极重要的思想:空间换时间

我们需要引入一个辅助栈(Auxiliary Stack)

  • 数据栈 (stack) :负责常规的数据存储,维持栈的正常逻辑。
  • 辅助栈 (stack2) :负责同步存储当前数据栈对应的最小值。

核心策略:辅助栈的栈顶,永远存储着当前数据栈中所有元素的最小值。这实际上维护了一个非严格单调递减的序列。

第三阶段:完美实现 (双栈协同)

有了辅助栈的思路,接下来的难点在于:如何保持两个栈的状态同步?

我们需要处理好两个关键逻辑:

  1. 入栈 (Push) :新元素进来了,辅助栈存不存?
  2. 出栈 (Pop) :数据栈弹出了,辅助栈要不要跟着弹?

以下是经过逻辑修正后的 

O(1)

 完美实现:

JavaScript

const MiniStack = function() {
    this.stack = [];      // 数据栈
    this.stack2 = [];     // 辅助栈(单调栈,栈顶即为最小值)
}

// O(1)
MiniStack.prototype.push = function(x) {
    // 1. 数据栈必须入栈
    this.stack.push(x);
    
    // 2. 辅助栈入栈逻辑
    // 如果辅助栈为空,或者新元素 x 小于等于 辅助栈栈顶,则入辅助栈
    // 注意:这里必须是 <=,不能只是 <,否则会有重复最小值丢失的问题
    if(this.stack2.length === 0 || x <= this.stack2[this.stack2.length-1]) {
        this.stack2.push(x);
    }
}

// O(1)
MiniStack.prototype.pop = function() {
    // 1. 数据栈弹出
    const val = this.stack.pop();
    
    // 2. 辅助栈同步逻辑
    // 如果弹出的元素等于辅助栈栈顶元素,说明最小值被移除,辅助栈也要弹出
    if(val === this.stack2[this.stack2.length-1]) {
        this.stack2.pop();
    }
    return val;
}

// O(1)
MiniStack.prototype.top = function() {
    return this.stack[this.stack.length-1];
}

// O(1)
MiniStack.prototype.getMin = function() {
    // 直接返回辅助栈栈顶,无需遍历
    return this.stack2[this.stack2.length-1];
}

深度解析

1. Push 操作的去重与同步

在 push 方法中,判断条件 x <= this.stack2[top] 至关重要。

  • 为什么要判断大小?  我们只关心比当前最小值更小(或相等)的数。如果新来的数比当前最小值还大,它绝不可能是当前的最小值,因此不需要压入辅助栈。这保证了辅助栈的单调递减特性。

  • 为什么要包含等于(<=)?  这是一个常见的坑。假设入栈序列为 [5, 2, 2]。

    • 如果不包含等于:辅助栈通过判断只存入第一个 2。
    • 当数据栈弹出最上面的 2 时,代码会误以为最小值被移除了,导致辅助栈唯一的 2 也被弹出。
    • 此时数据栈里还剩一个 2,但辅助栈里的最小值变成了 5。这就产生了 Bug。
    • 因此,重复的最小值必须同时压入辅助栈

2. Pop 操作的同步

在 pop 方法中,我们对比数据栈弹出的值与辅助栈栈顶的值。
只有当两者相等时,才弹出辅助栈。这意味着:我们移除的正是当前的最小值。辅助栈弹出后,新的栈顶自然就变成了“次小值”(即之前的最小值),完美还原了历史状态。

3. GetMin 的极致性能

由于辅助栈的精心维护,其栈顶永远是全局最小值。getMin 变成了简单的数组索引访问,没有任何循环,时间复杂度稳稳落在 O(1)

总结:

辅助栈就像是数据栈的“历史快照”索引。无论数据栈怎么进出,辅助栈的栈顶始终指向“当前存活数据中的最小者”。这种设计将 getMin 的复杂度从线性阶降维到了常数阶。在实际面试中,写出代码往往只占 60% 的分数。剩下的 40% 取决于你能否清晰地解释“为什么引入辅助栈”、“如何处理重复最小值”以及“空间与时间的权衡”。算法不仅仅是背诵代码,更是对数据流动和资源消耗的精准控制。

如果这篇文章对你有帮助的话,就请你点个赞吧!!!

webpack5搭建vue3项目

Webpack5+Vue3+TS 项目搭建(从核心到功能完善)

一、核心基础搭建(确保项目能启动运行)

目标:搭建项目骨架,整合 Vue3、TS、Webpack5 核心依赖,实现最基础的页面渲染,确保开发环境能正常启动。

步骤 1:初始化项目 & 搭建目录

先创建项目文件夹并初始化,搭建最精简的核心目录:

# 创建项目目录并进入
mkdir vue3-ts-webpack5 && cd vue3-ts-webpack5
# 初始化 npm 项目
npm init -y
# 创建核心目录和文件
mkdir -p src/components public
touch tsconfig.json webpack.config.ts src/main.ts src/App.vue src/shims-vue.d.ts public/index.html

基础目录结构(仅保留核心文件):

vue3-ts-webpack5/
├── src/
│   ├── components/  # 组件目录
│   ├── App.vue      # 根组件
│   ├── main.ts      # 入口文件
│   └── shims-vue.d.ts  # Vue 类型声明
├── public/
│   └── index.html   # HTML 模板
├── tsconfig.json    # TS 配置
├── webpack.config.ts  # Webpack 核心配置
└── package.json     # 依赖管理

二、安装核心依赖

仅安装启动项目必需的依赖,避免冗余:

# 核心业务依赖
npm i vue
# Webpack 基础依赖
npm i -D webpack webpack-cli webpack-dev-server
# Vue+TS 编译依赖
npm i -D typescript ts-loader @vue/compiler-sfc vue-loader
# 类型声明依赖
npm i -D @types/node @types/webpack ts-node
# HTML 处理插件
npm i -D html-webpack-plugin

步骤 3:配置 TS(tsconfig.json)

适配 Vue3+TS 编译规则,确保 TS 能识别 Vue 文件和模块:

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "ESNext",
    "moduleResolution": "Node",
    "strict": true,
    "jsx": "preserve",
    "sourceMap": true,
    "resolveJsonModule": true,
    "esModuleInterop": true,
    "lib": ["ES2020", "DOM"],
    "types": ["node", "webpack-env"],
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] },  # 配置别名,方便导入
    "skipLibCheck": true
  },
  "include": ["src/**/*", "webpack.config.ts"],
  "exclude": ["node_modules", "dist"]
}

步骤 4:Vue 类型声明(shims-vue.d.ts)

解决 TS 无法识别 .vue 文件的问题:

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}

步骤 5:基础 Webpack 配置(webpack.config.ts)

仅配置核心规则,确保 Vue、TS、HTML 能正常编译:

import path from 'path'
import { Configuration } from 'webpack'
import HtmlWebpackPlugin from 'html-webpack-plugin'
import { VueLoaderPlugin } from 'vue-loader'

const config: Configuration = {
  mode: 'development',  // 先以开发模式搭建
  entry: path.resolve(__dirname, 'src/main.ts'),
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: 'js/[name].js'
  },
  devServer: {
    port: 8080,
    hot: true,
    open: true,
    compress: true
  },
  resolve: {
    extensions: ['.ts', '.vue', '.js', '.json'],
    alias: { '@': path.resolve(__dirname, 'src') }
  },
  module: {
    rules: [
      // 处理 Vue 文件
      { test: /.vue$/, loader: 'vue-loader', exclude: /node_modules/ },
      // 处理 TS 文件
      { test: /.ts$/, loader: 'ts-loader', options: { appendTsSuffixTo: [/.vue$/] }, exclude: /node_modules/ }
    ]
  },
  plugins: [
    new VueLoaderPlugin(),  // Vue 编译必需
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'public/index.html'),
      filename: 'index.html'
    })
  ],
  devtool: 'eval-cheap-module-source-map'
}

export default config

步骤 6:编写基础业务代码

1. public/index.html(模板文件)
<!DOCTYPE html>
核心基础版
2. src/main.ts(入口文件)
import { createApp } from 'vue'
import App from './App.vue'

createApp(App).mount('#app')
3. src/App.vue(根组件)
核心基础搭建完成{{ msg }}<script setup ">
const msg: string = 'Vue3 + TS + Webpack5 运行正常'
console.log(msg)

步骤 7:配置启动脚本并测试

修改 package.json 的 scripts 字段,添加开发环境脚本:

"scripts": {
  "dev": "webpack serve --config webpack.config.ts"
}

启动项目,验证核心功能:

npm run dev

浏览器自动打开 localhost:8080,能看到页面内容即说明核心搭建成功。

三、基础功能完善(处理样式、静态资源)

目标:添加 CSS 处理、图片/字体资源加载能力,让项目支持常见静态资源引用。

步骤 1:安装资源处理依赖

# CSS 处理依赖
npm i -D css-loader@6.10.0 style-loader@3.3.4
# 补充静态资源目录
mkdir -p src/assets/{images,fonts,styles}

步骤 2:更新 Webpack 配置(添加资源处理规则)

在 module.rules 中添加 CSS、图片、字体的处理规则:

// 新增规则,插入到 module.rules 数组中
{
  test: /.css$/,
  use: ['style-loader', 'css-loader'],  // 开发环境嵌入 JS
  exclude: /node_modules/
},
{
  test: /.(png|jpg|jpeg|gif|svg)$/i,
  type: 'asset',  // webpack5 内置,小文件转 base64,大文件输出单独文件
  parser: { dataUrlCondition: { maxSize: 8 * 1024 } },  // 8kb 为界
  generator: { filename: 'assets/images/[name].[hash:8][ext]' }
},
{
  test: /.(woff2?|eot|ttf|otf)$/i,
  type: 'asset/resource',
  generator: { filename: 'assets/fonts/[name].[hash:8][ext]' }
}

步骤 3:补充类型声明(shims-vue.d.ts)

让 TS 识别图片、字体资源:

// 新增以下内容
declare module '*.png'
declare module '*.jpg'
declare module '*.ttf'
declare module '*.woff2'

步骤 4:测试资源引用

在 src/assets/images 放入一张图片(如 logo.png),修改 App.vue:

核心基础搭建完成{{ msg }}<script setup 
const msg: string = 'Vue3 + TS + Webpack5 运行正常'
console.log(msg)

重启 npm run dev,能正常显示图片和样式即说明功能生效。

四、进阶功能叠加(CSS 剥离、压缩、单位转换)

目标:优化 CSS 处理流程,实现生产环境剥离、压缩,以及 px 转 rem/vw 适配。

步骤 1:安装相关依赖

# CSS 剥离、压缩插件
npm i -D mini-css-extract-plugin css-minimizer-webpack-plugin
# 单位转换 + 浏览器兼容
npm i amfe-flexible  # rem 适配必需
npm i -D postcss-loader postcss-preset-env postcss-pxtorem

步骤 2:配置 PostCSS(px 转 rem)

创建 postcss.config.js 文件,实现 px 自动转 rem:

module.exports = {
  plugins: {
    'postcss-preset-env': {  // 自动添加浏览器前缀
      autoprefixer: { overrideBrowserslist: ['last 2 versions', '> 1%'] }
    },
    'postcss-pxtorem': {
      rootValue: 37.5,  // 设计稿 375px 适配,750px 设为 75
      propList: ['*'],  // 所有属性转 rem
      selectorBlackList: ['html'],  // 排除 html 标签
      exclude: /node_modules/i
    }
  }
}

步骤 3:更新 Webpack 配置(CSS 进阶处理)

区分开发/生产环境,实现 CSS 剥离、压缩,整合 PostCSS:

// 顶部引入新增插件
import MiniCssExtractPlugin from 'mini-css-extract-plugin'
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin'
import crossEnv from 'cross-env'  // 后续会安装,先引入

// 定义环境变量
const isProd = process.env.NODE_ENV === 'production'

// 更新 CSS 规则
{
  test: /.css$/,
  use: [
    isProd ? MiniCssExtractPlugin.loader : 'style-loader',  // 生产剥离,开发嵌入
    'css-loader',
    'postcss-loader'  // 单位转换 + 前缀
  ],
  exclude: /node_modules/
},

// 更新 plugins 数组
plugins: [
  new VueLoaderPlugin(),
  new HtmlWebpackPlugin({
    template: path.resolve(__dirname, 'public/index.html'),
    filename: 'index.html',
    minify: isProd ? { removeComments: true, collapseWhitespace: true } : false
  }),
  // 生产环境添加 CSS 剥离插件
  ...(isProd ? [
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash:8].css',
      chunkFilename: 'css/[name].[contenthash:8].chunk.css'
    })
  ] : [])
],

// 优化配置(添加 CSS 压缩)
optimization: {
  minimizer: [
    `...`,  // 保留默认 JS 压缩器
    new CssMinimizerPlugin()  // 生产环境压缩 CSS
  ]
}

步骤 4:适配 rem 单位

在入口文件 main.ts 中引入 amfe-flexible:

import { createApp } from 'vue'
import App from './App.vue'
import 'amfe-flexible'  // 动态设置根字体大小
import '@/assets/styles/index.css'  // 可新增全局样式文件

重启项目,查看元素样式,px 已自动转为 rem 即生效。

五、打包优化(清理旧资源、多进程编译)

目标:优化打包流程,清理旧资源,提升打包速度。

步骤 1:安装优化依赖

# 清理旧资源插件
npm i -D clean-webpack-plugin
# 多进程编译优化
npm i -D happypack thread-loader
# 环境变量统一
npm i -D cross-env

步骤 2:配置打包清理旧资源

在 Webpack 配置中添加 CleanWebpackPlugin:

// 顶部引入
import { CleanWebpackPlugin } from 'clean-webpack-plugin'

// 加入 plugins 数组(放在最前面,确保打包前清理)
plugins: [
  new CleanWebpackPlugin(),  // 新增
  new VueLoaderPlugin(),
  // 其他插件...
]

步骤 3:配置 HappyPack 多进程编译

优化 TS 编译速度,利用多 CPU 核心:

// 顶部引入
import HappyPack from 'happypack'
import os from 'os'

// 初始化线程池(根据 CPU 核心数设置)
const happyThreadPool = HappyPack.ThreadPool({ size: os.cpus().length })

// 修改 TS 处理规则
{
  test: /.ts$/,
  use: 'happypack/loader?id=ts',  // 用 HappyPack 代理
  exclude: /node_modules/
},

// 新增 HappyPack 插件配置(加入 plugins 数组)
new HappyPack({
  id: 'ts',
  threadPool: happyThreadPool,
  loaders: [
    {
      loader: 'ts-loader',
      options: {
        transpileOnly: true,  // 只编译不做类型检查,提升速度
        appendTsSuffixTo: [/.vue$/]
      }
    }
  ]
})

步骤 4:配置生产环境打包脚本

修改 package.json 的 scripts 字段:

"scripts": {
  "dev": "cross-env NODE_ENV=development webpack serve --config webpack.config.ts",
  "build": "cross-env NODE_ENV=production webpack --config webpack.config.ts"
}

六、最终测试与验证

步骤 1:测试生产环境打包

npm run build

验证 dist 目录:

  • dist 目录已自动清理,无旧文件残留;
  • CSS 已剥离为单独文件,且已压缩;
  • 图片、字体资源按配置路径输出,带哈希值;
  • JS 文件已分割(公共依赖、业务代码分离)。

步骤 2:验证功能完整性

打开 dist/index.html,确认:页面正常渲染、样式适配(rem 生效)、资源加载正常,所有需求功能均实现。

总结:搭建逻辑梳理

整个流程遵循「先跑通核心→再补基础功能→最后优化进阶」的原则,每一步都有明确目标,避免因配置叠加导致的问题:

  1. 核心层:Vue3+TS+Webpack5 基础整合,确保项目能启动;
  2. 基础层:处理样式、图片、字体,满足日常开发资源引用;
  3. 进阶层:CSS 剥离、压缩、单位转换,适配生产环境和移动端;
  4. 优化层:清理旧资源、多进程编译,提升打包效率和体验。

9.BTC-比特币脚本-北大肖臻老师客堂笔记

北京大学肖臻老师《区块链技术与应用》公开课第 9 讲的主题是**“比特币的脚本”**。本课深入探讨了比特币交易的底层结构、输入输出的关联以及脚本执行的逻辑。

以下是根据视频内容及你提供的图片进行的详细总结:

一、 比特币交易的宏观结构

比特币的交易并非简单的“ A 转账给 B ”,而是一个包含多个字段的复杂 JSON 对象。

  • 基础元数据:包括交易 ID (txid)、版本号、大小 (size) 和锁定时间 (locktime)。
  • 确认数 (confirmations):表示该交易所在区块之后已经产生了多少个区块,确认数越多,交易越不可篡改。
  • 输入 (vin) 与输出 (vout):这是交易的核心,每一笔交易都是将之前的输出作为现在的输入,并产生新的输出。

二、 交易的输入与输出细节

比特币通过脚本系统实现了资金的转移和锁定。

1. 交易输入 (vin)

  • 引用来源:每个输入必须指明资金来源,即前一笔交易的 ID (txid) 和对应的输出索引 (vout)。
  • 解锁脚本 (scriptSig):包含发送者的数字签名和公钥,用于证明对这笔资金的所有权。

2. 交易输出 (vout)

  • 金额 (value):该输出对应的比特币数量。
  • 锁定脚本 (scriptPubKey):定义了花费这笔钱的条件,通常要求提供能与特定公钥哈希匹配的签名。
  • 地址 (addresses):虽然代码中显示为地址,但本质上是公钥哈希的 Base58 编码。

三、 脚本执行逻辑:P2PKH

比特币最常见的交易类型是 P2PKH (Pay-to-Pubkey-Hash)。其验证过程是将当前交易的 scriptSig 和前一笔交易的 scriptPubKey 拼接在一起执行。

执行步骤如下(基于栈的操作):

  1. PUSHDATA(Sig):将签名压入栈。
  2. PUSHDATA(PubKey):将公钥压入栈。
  3. DUP:复制栈顶的公钥。
  4. HASH160:将栈顶公钥进行哈希处理。
  5. PUSHDATA(PubKeyHash):将预期的公钥哈希(来自锁定脚本)压入栈。
  6. EQUALVERIFY:比较两个哈希值是否一致。如果不一致,脚本执行失败。
  7. CHECKSIG:利用栈中的公钥验证签名的有效性。如果验证通过,返回 TRUE,交易合法。

四、 全节点在脚本系统中的角色

全节点是脚本执行的实际操作者:

  • 验证合法性:全节点通过执行上述脚本,验证网络上每一笔交易的输入是否真实引用了有效的 UTXO,以及签名是否正确。
  • 维护 UTXO 集合:为了提高验证效率,全节点在内存中维护 UTXO 集合,这样在收到新交易时,无需扫描整个硬盘账本即可确认资金是否已被花费。
  • 决定打包逻辑:全节点在验证脚本通过后,才会将交易放入交易池,并决定是否将其打包进下一个区块。

五、 核心名词总结

  • ScriptSig:解锁脚本,证明“我有权花这笔钱”。
  • ScriptPubKey:锁定脚本,规定“谁能花这笔钱”。
  • Stack-based Language:比特币脚本是一种简单的、非图灵完备的、基于栈的编程语言,这种设计是为了安全,防止死循环攻击。
  • UTXO (Unspent Transaction Output):尚未被花费的交易输出,是比特币账本的基本组成单位。

9.BTC-比特币脚本.png

❌