普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月23日技术

Three.js 着色器打造烟雾水云效果

2026年1月23日 11:42

概述

本文将详细介绍如何使用 Three.js 和自定义着色器来创建逼真的烟雾、水和云朵效果。我们将通过编写顶点着色器和片元着色器来实现动态变化的视觉效果。

screenshot_2026-01-23_11-40-16.gif

准备工作

首先,我们需要引入必要的 Three.js 库和相关工具:

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import gsap from "gsap";
import * as dat from "dat.gui";
import vertexShader from "../shaders/water/vertex.glsl";
import fragmentShader from "../shaders/water/fragment.glsl";

场景初始化

首先,我们需要创建一个基本的 Three.js 场景:

// 初始化场景
const scene = new THREE.Scene();

// 创建透视相机
const camera = new THREE.PerspectiveCamera(
  90,
  window.innerHeight / window.innerHeight,
  0.1,
  1000
);

// 设置相机位置
camera.position.set(0, 0, 2);
scene.add(camera);

// 加入辅助轴,帮助我们查看3维坐标轴
const axesHelper = new THREE.AxesHelper(5);
scene.add(axesHelper);

参数配置

为了方便调试和调整效果,我们使用 dat.GUI 创建参数控制面板:

const params = {
  uWaresFrequency: 14,          // 波纹频率
  uScale: 0.03,                 // 整体缩放
  uXzScale: 1.5,                // XZ平面缩放
  uNoiseFrequency: 10,          // 噪声频率
  uNoiseScale: 1.5,             // 噪声缩放
  uLowColor: "#ff0000",         // 低值颜色
  uHighColor: "#ffff00",        // 高值颜色
  uXspeed: 1,                   // X方向速度
  uZspeed: 1,                   // Z方向速度
  uNoiseSpeed: 1,               // 噪声速度
  uOpacity: 1                   // 透明度
};

着色器材质创建

接下来,我们创建一个使用自定义着色器的材质:

const shaderMaterial = new THREE.ShaderMaterial({
  vertexShader: vertexShader,
  fragmentShader: fragmentShader,
  side: THREE.DoubleSide,
  uniforms: {
    uWaresFrequency: {
      value: params.uWaresFrequency,
    },
    uScale: {
      value: params.uScale,
    },
    uNoiseFrequency: {
      value: params.uNoiseFrequency,
    },
    uNoiseScale: {
      value: params.uNoiseScale,
    },
    uXzScale: {
      value: params.uXzScale,
    },
    uTime: {
      value: 0,  // 时间变量,用于动画效果
    },
    uLowColor: {
      value: new THREE.Color(params.uLowColor),
    },
    uHighColor: {
      value: new THREE.Color(params.uHighColor),
    },
    uXspeed: {
      value: params.uXspeed,
    },
    uZspeed: {
      value: params.uZspeed,
    },
    uNoiseSpeed: {
      value: params.uNoiseSpeed,
    },
    uOpacity: {
      value: params.uOpacity,
    },
  },
  transparent: true,
});

控制面板设置

通过 dat.GUI 可以实时调整参数,观察效果变化:

gui
  .add(params, "uWaresFrequency")
  .min(1)
  .max(100)
  .step(0.1)
  .onChange((value) => {
    shaderMaterial.uniforms.uWaresFrequency.value = value;
  });

gui
  .add(params, "uScale")
  .min(0)
  .max(0.2)
  .step(0.001)
  .onChange((value) => {
    shaderMaterial.uniforms.uScale.value = value;
  });

// 更多参数控制...

几何体创建

创建一个平面几何体作为基础形状:

const plane = new THREE.Mesh(
  new THREE.PlaneBufferGeometry(1, 1, 1024, 1024),
  shaderMaterial
);
plane.rotation.x = -Math.PI / 2;

scene.add(plane);

渲染循环

最后,我们需要在渲染循环中更新时间参数,以实现动画效果:

const clock = new THREE.Clock();

function animate(t) {
  const elapsedTime = clock.getElapsedTime();
  shaderMaterial.uniforms.uTime.value = elapsedTime;
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

animate();

着色器详解

顶点着色器 (Vertex Shader)

顶点着色器负责计算每个顶点的位置变化,创建波浪效果:

precision lowp float;
uniform float uWaresFrequency;    // 波纹频率
uniform float uScale;             // 缩放系数
uniform float uNoiseFrequency;    // 噪声频率
uniform float uNoiseScale;        // 噪声缩放
uniform float uXzScale;           // XZ平面缩放
uniform float uTime;              // 时间变量
uniform float uXspeed;            // X方向速度
uniform float uZspeed;            // Z方向速度
uniform float uNoiseSpeed;        // 噪声速度

// 计算出的高度传递给片元着色器
varying float vElevation;

// 随机函数
float random (vec2 st) {
    return fract(sin(dot(st.xy,vec2(12.9898,78.233)))*43758.5453123);
}

// 噪声函数
float noise (in vec2 _st) {
    vec2 i = floor(_st);
    vec2 f = fract(_st);

    float a = random(i);
    float b = random(i + vec2(1.0, 0.0));
    float c = random(i + vec2(0.0, 1.0));
    float d = random(i + vec2(1.0, 1.0));

    vec2 u = f * f * (3.0 - 2.0 * f);

    return mix(a, b, u.x) +
            (c - a)* u.y * (1.0 - u.x) +
            (d - b) * u.x * u.y;
}

void main(){
    vec4 modelPosition = modelMatrix * vec4(position,1.0);

    // 计算波浪高度
    float elevation = sin(modelPosition.x*uWaresFrequency+uTime*uXspeed)*sin(modelPosition.z*uWaresFrequency*uXzScale+uTime*uZspeed);

    // 添加噪声效果
    elevation += -abs(cnoise(vec2(modelPosition.xz*uNoiseFrequency+uTime*uNoiseSpeed))) *uNoiseScale;
    
    vElevation = elevation;
    
    elevation *= uScale;

    modelPosition.y += elevation;

    gl_Position = projectionMatrix * viewMatrix *modelPosition;
}

片元着色器 (Fragment Shader)

片元着色器负责计算每个像素的颜色:

precision lowp float;

uniform vec3 uHighColor;    // 高值颜色
uniform vec3 uLowColor;     // 低值颜色
varying float vElevation;   // 从顶点着色器传入的高度值
uniform float uOpacity;     // 透明度

void main(){
    // 根据高度值计算颜色插值因子
    float a = (vElevation+1.0)/2.0;
    vec3 color = mix(uLowColor,uHighColor,a);
    gl_FragColor = vec4(color,uOpacity);
}

总结

通过使用自定义着色器,我们可以创建非常复杂的视觉效果,如烟雾、水波和云朵等。关键在于理解顶点着色器如何改变几何体的形状,以及片元着色器如何控制最终的颜色输出。配合参数控制面板,我们可以实时调整各种参数,获得不同的视觉效果。

这种技术在游戏开发、模拟仿真和艺术创作等领域有着广泛的应用。掌握着色器编程是实现高级视觉效果的重要技能。

gulp + conventional-changelog-core 自动生成组件库 CHANGELOG

2026年1月23日 11:33

本文基于 gulp 的自动化构建能力,结合 conventional-changelog-core 的规范日志生成特性,搭建一套组件库发版时 CHANGELOG 自动更新的方案。该方案可实现版本发布流程与日志更新的无缝联动,既保证了 CHANGELOG 的规范性与完整性,又大幅简化发版操作流程,有助于组件库的高效、标准化迭代。

前提条件

该方案基于规范的 git 提交消息格式:

<type>[optional scope]: <description> 

[optional body] 

[optional footer(s)]

如何配置规范的 commitlint 可以参考这篇文章 配置Commitlint与Husky实现Git提交消息规范化-开发者社区-阿里云

安装 gulp 和 conventional-changelog-core

考虑到 node 12 版本兼容性(主要是项目中有些依赖实在不兼容高版本 node),我这里选择了 gulp@^4.0.2conventional-changelog-core@^4.2.1

npm i gulp@^4.0.2 conventional-changelog-core@^4.2.1

配置 .conventional-changelog.js

这里提供一份我项目中使用的配置供参考,详细配置可以自行查看 官方文档

// .conventional-changelog.js

const moment = require("moment")

// 配置模板,可以按需调整模板

// https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-writer#maintemplate
const mainTemplate = `
{{> header}}

{{#each commitGroups}}
{{#each commits}}
{{> commit root=@root}}
{{/each}}
{{/each}}

{{> footer}}
`

// https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-writer#headerpartial
const headerPartial = `
## {{#if isPatch~}} <small>
  {{~/if~}} {{version}}
  {{~#if title}} - {{title}}
  {{~/if~}}
  {{~#if date}} ({{date}})
  {{~/if~}}
  {{~#if isPatch~}} </small>
  {{~/if}}
`

// 最多显示的发布版本个数
const maxReleaseCount = 15
let releaseCount = 0

module.exports = {
  // options
  // https://www.npmjs.com/package/conventional-changelog-core/v/4.2.1?activeTab=readme
  preset: "angular",
  outputUnreleased: false,
  title: 'CHANGELOG',
  config: {
    gitRawCommitsOpts: {
      // 配置起始提交日志的 commit sha
      from: "xxx",
    },
    parserOpts: {
      // https://github.com/conventional-changelog-archived-repos/conventional-commits-parser
      mergePattern: /^Merge branch '(.*)' into (.*)$/,
      mergeCorrespondence: ["id", "source"],
    },
    writerOpts: {
      // https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-writer#api
      groupBy: "type",
      commitGroupsSort: ["feat", "fix", "perf", "docs", "style", "refactor", "test", "chore", "revert"],
      commitsSort: ["scope", "subject"],
      noteGroupsSort: "title",
      notesSort: "title",
      mainTemplate,
      headerPartial
    },
  },
  transform: function (commit, cb) {
    // 配置发版消息的匹配格式并提取版本号,这里可以根据项目实际情况进行调整
    const subjectReleasePattern = /elements@(\d+\.\d+\.\d+)/
    const headerReleasePattern = /Update version (\d+\.\d+\.\d+)/
    if (commit.subject && subjectReleasePattern.test(commit.subject)) {
      commit.version = subjectReleasePattern.exec(commit.subject)[1]
    } else if (commit.header && headerReleasePattern.test(commit.header)) {
      commit.version = headerReleasePattern.exec(commit.header)[1]
    } else if (/^(chore|docs|test|doc)/.test(commit.header) && !/^chore\(element-ui\)/.test(commit.header)) {
      // 忽略部分提交日志信息,这里可以根据项目实际情况进行调整
      cb(null, null)
      return
    }
    if (commit.version) {
      releaseCount++
    }
    if (releaseCount > maxReleaseCount) {
      cb(null, null)
      return
    } else if (releaseCount === 1) {
      commit.title = 'latest'
    }
    commit.group = commit.subject || commit.type
    // 设置日期格式
    if (commit.committerDate) {
      commit.date = moment(new Date(commit.committerDate)).format("YYYY-MM-DD")
    }
    // 设置更新日志的文本模板,主要是提取信息并添加 markdown 格式,这里也可以按需加入一些 emoji 表情丰富日志
    let header = ''
    if (commit.type) {
      header += `**${commit.type}`
    }
    if (commit.scope) {
      header += `(${commit.scope})`
    }
    if (header.length) {
      header += `:** ${commit.subject.replace('@', '-')}`
      commit.header = header
    }
    cb(null, commit)
  },
}

配置 gulp

在 gulp 入口文件 gulpfile.js 中添加一个 task,代码参考如下

const fs = require("fs")
var conventionalChangelog = require("conventional-changelog-core")
const conventionalChangelogOpts = require("./.conventional-changelog")

gulp.task("conventional-changelog", function () {
  // CHANGE_LOG 文件的路径
  const changelogPath = "./CHANGE_LOG.md"
  return conventionalChangelog(conventionalChangelogOpts)
    .on("end", () => {
      // 生成文件完成后,读取文件内容
      fs.readFile(changelogPath, "utf8", (err, data) => {
        if (err) {
          return console.error(err)
        }
        // 在内容首行添加标题
        const newData = `# ${conventionalChangelogOpts.title || 'CHANGELOG'}\n\n` + data
        // 将修改后的内容写回文件
        fs.writeFile(changelogPath, newData, "utf8", err => {
          if (err) {
            return console.error(err)
          }
        })
      })
    })
    .pipe(fs.createWriteStream(changelogPath)) // or any writable stream
})

执行任务脚本,生成 CHANGELOG

gulp 任务创建完成后,可以在命令终端执行 npx gulp conventional-changelog 自动生成 CHANGELOG,更新后的文档内容如下:

image.png

进阶:创建自动发布脚本实现 打包-发布-更新文档 全自动化执行

在实际的组件库发版实践中,打包发布还需要同步更新子版本号,这里可以使用 npm --no-git-tag-version version patch 命令来实现子版本号自动更新。同时更新日志一般都会跟随版本发布一起更新到组件文档上,那么就可以将 打包-发布-更新文档 这些环节一起写到一个执行脚本中,组件发布时直接执行脚本即可全自动完成所有流程,具体实现如下:

1. 创建 node 脚本 scripts/release.js

const { execSync } = require('child_process');
const path = require('path');

// 执行命令的函数
function runCommand(command) {
  console.log(`执行命令: ${command}`);
  try {
    execSync(command, { stdio: 'inherit', shell: true });
    console.log(`命令执行成功: ${command}`);
  } catch (error) {
    console.error(`命令执行失败: ${command}`);
    console.error(error);
    process.exit(1);
  }
}

console.log('开始执行发布流程...');

console.log('\n===== 开始执行编译 =====');
runCommand('git fetch && git pull origin master && npm run lib');

console.log('\n===== 发布到 npm =====');
runCommand('npm --no-git-tag-version version patch && npm publish');

const { version } = require(path.join(__dirname, '../package.json'));

console.log('\n===== 提交版本号 =====');
runCommand(`git add package.json package-lock.json && git commit -m "chore: elementsc@${version}"`);

console.log('\n===== 提交更新日志 =====');
runCommand('gulp conventional-changelog && git add "./CHANGE_LOG.md" && git commit -m "docs: update changelog"');

console.log('\n发布流程执行完成!');

2. 在 package.json 中添加执行脚本

{
    "name": "elements",
    "version": "1.0.0",
    "scripts": {
        "publish:patch": "node scripts/release.js"
    }
}

3. 执行 npm run publish:patch 发布版本

脚本执行成功后,自动提交版本发布日志和更新CHANGELOG,到此完成 打包-发布-更新文档 全自动化执行

image.png

JS-Canvas进阶指南:从零构建你的 Web 图形世界

2026年1月23日 11:30

前言

在数据可视化(ECharts)、在线文档、H5 游戏等领域,Canvas 都是不可或缺的核心技术。不同于 DOM 操作,Canvas 提供了立即模式的绘图能力。本文将带你系统盘点 Canvas 的核心 API 与实战技巧。

一、 快速上手:环境搭建

Canvas 就像一块画布,而 context 则是你的画笔,Canvas使用方法如下:

  • 创建canvas元素,并为其设置width和height
  • 通过id查找到该元素
  • 使用getContext('2d')获取绘制图形的上下文
<canvas id="drawing" width="200" height="200"></canvas>
const drawing = document.getElementById("drawing");

// 严谨起见,先检查浏览器是否支持 getContext
if (drawing.getContext) {
  const context = drawing.getContext("2d");
  console.log("Canvas 上下文获取成功");
}

二、 基础图形:矩形绘制

矩形是 Canvas 中唯一原生支持的形状,其他图形都需要通过路径组合。

方法 描述
fillRect(x, y, w, h) 绘制填充矩形(实心)
strokeRect(x, y, w, h) 绘制描边矩形(空心)
clearRect(x, y, w, h) 清除指定区域(橡皮擦效果)
  • 这三种方法都接受4个参数:

    • x代表绘制矩形的起点横坐标
    • y代表绘制矩形的起点纵坐标(坐标轴是向下的)
    • w代表绘制矩形的宽度(从x位置向右延升w距离)
    • h代表会在矩形的高度(从y位置向下延升h距离)
  • fillRect绘制出来的矩形,可使用fillStyle('颜色值')可以给矩形填充颜色

  • storkeRect绘制出来的矩形,可使用strokeStyle('颜色值')可以给矩形绘制轮廓

const context = drawing.getContext("2d");

// 1. 填充绿色矩形
context.fillStyle = "green";
context.fillRect(10, 10, 50, 50);

// 2. 红色边框矩形
context.strokeStyle = "red";
context.lineWidth = 2; // 设置线宽
context.strokeRect(70, 10, 50, 50);

// 3. 橡皮擦:擦除中间一部分
context.clearRect(15, 15, 20, 20); 

image.png


三、 路径的艺术:绘制任意形状

路径是 Canvas 的灵魂。记住核心流程:开始路径 -> 移动画笔 -> 绘制线条 -> 闭合/描边

1. 核心 API

  • beginPath() : 清空当前路径列表,开始新路径(防止之前的路径被重复绘制)。
  • moveTo(x, y) : 移动画笔,只把绘制起始点移动到(x, y)
  • lineTo(x, y) : 绘制一条从上个结束点到(x, y)的直线。
  • arc(x, y, radius, startAngle, endAngle, counterclockwise):以坐标(x, y)为圆心,以 radius 为半径绘制一条弧线,起始角度为 startAngle,结束角度为 endAnglecounterclockwise 表示是否逆时针计算起始角度和结束角度(默认为顺时针)
  • arcTo(x1, y1, x2, y2, radius) :绘制从起始点P0P1(x1,y1)的一条连线,截止绘制从P1(x1,y1)P2(x2,y2)的连线,接着将这两条线当做切线绘制一个半径为radius的圆弧。(当圆弧过大时,会取两条切线的延长线)使用较少

2. 实战:绘制切线圆弧 (arcTo)

 <canvas id="drawing" width="600" height="600" style="border: 1px solid aqua"></canvas>
   
  let drawing = document.getElementById('drawing')
  if (drawing.getContext) {
    let context = drawing.getContext('2d')
    const p0 = { x: 50, y: 50 } // 起点
    const p1 = { x: 150, y: 100 } // 控制点1
    const p2 = { x: 250, y: 50 } // 控制点2
    const radius =100 // 圆角半径

    context.beginPath()
    context.moveTo(p0.x, p0.y) // 定位起点
    context.arcTo(p1.x, p1.y, p2.x, p2.y, radius) // 绘制圆弧
    // context.lineTo(p2.x, p2.y) // 连接到终点
    context.stroke() // 描边
  }1.y, p2.x, p2.y, radius); 
context.stroke();

image.png

3. 实战:绘制时钟表盘

  let drawing = document.getElementById('drawing')
  if (drawing.getContext) {
    let context = drawing.getContext('2d')
    // 创建路径
    context.beginPath()
    // 绘制外圆
    context.arc(100, 100, 99, 0, 2 * Math.PI, false)
    // 绘制内圆
    context.moveTo(194, 100)
    context.arc(100, 100, 94, 0, 2 * Math.PI, false)
    // 绘制分针
    context.moveTo(100, 100)
    context.lineTo(100, 15)
    // 绘制时针
    context.moveTo(100, 100)
    context.lineTo(35, 100)
    // 描画路径
    context.stroke()
  }

image.png


四、 绘制文本

Canvas 文本也是图像的一部分,无法像 DOM 文本那样选中。

属性/方法 描述
font 设置字体,如 "bold 20px Arial"
textAlign 水平对齐:start, end, left, right, center
textBaseline 垂直对齐:top, middle, bottom
fillText(text, x, y, maxWidth) 绘制实心文本,text为文本内容(字符串),xy为文本坐标,maxwidth为可选参数表示最大宽度
strokeText(text, x, y,maxWidth) 绘制空心文本,参数同fillText,同fillText一起调用可实现填充+描边效果
context.font = '20px Arial';
context.textAlign = 'center';

// 实心字
context.fillStyle = 'blue';
context.fillText('Combined', 150, 150);

// 空心描边字(重叠效果)
context.strokeStyle = 'red';
context.strokeText('Combined', 150, 150);

五、 图像处理与像素操作

1. 绘制图片 (drawImage)

注意:必须等待图片加载完成后才能绘制,否则画布是空的。

const img = new Image();
img.src = 'test.jpg';
img.onload = function() {
    // 参数:图片对象, x, y, width, height
    // 还可以传入9个参数实现裁剪:drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh)
    context.drawImage(img, 0, 0, 200, 200);
}

2. 像素级操作 (ImageData)

这是 Canvas 最强大的功能,可用于滤镜、取色器等。

  • getImageData(x, y, w, h) :获取像素数据,返回值为ImageData 的实例,包含三个属性widthheightdata其data 属性是包含图像的原始像素信息的数组,每 4 个值代表一个像素 (R,G,B,A)(R, G, B, A)
  • putImageData(imageData, x, y) :将图像数据再绘制到画布上,ImageData:为ImageData实例,w,h表示绘制图像的宽高。

跨域警告:如果绘制的图片跨域且未开启 CORS,调用 getImageData 会报错(画布被污染)。


六、 图像合成 (Composite)

决定了当两个图形重叠时,谁显示、谁隐藏,或者颜色如何混合。

  • globalAlpha:全局透明度 (01)(0 \sim 1)
  • globalCompositeOperation:该属性定义新图形与画布已有内容的像素混合规则,其取值如下:
属性值 (Value) 效果描述 典型场景
source-over (默认) 新图形覆盖在旧图形上方 最普通的绘图模式
source-in 仅显示新图形与旧图形重叠的部分(显示新图形内容) 裁剪图形(如:将图片裁成圆形头像)
source-out 仅显示新图形与旧图形不重叠的部分 镂空效果、反向遮罩
destination-over 新图形绘制在旧图形下方 背景叠加(如:给已有文字加底色)
destination-out 擦除旧图形中与新图形重叠的部分 橡皮擦功能实现
lighter 重叠区域颜色值相加(变亮效果) 制作光效、粒子系统、火焰效果

经典案例:刮刮乐效果 (destination-out)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <style>
    /* 容器设置:保证文字和Canvas重叠 */
    .scratch-card {
      position: relative;
      width: 300px;
      height: 150px;
      margin: 20px auto;
      user-select: none; /* 禁止选中文字 */
    }
    
    /* 底层的中奖文字 */
    .prize-text {
      position: absolute;
      width: 100%;
      height: 100%;
      line-height: 150px;
      text-align: center;
      font-size: 40px;
      color: red;
      font-weight: bold;
      background-color: #f9f9f9;
      border: 1px solid #ccc;
      z-index: 1; /* 层级较低 */
    }

    /* 上层的 Canvas 遮罩 */
    canvas {
      position: absolute;
      top: 0;
      left: 0;
      z-index: 2; /* 层级较高,盖住文字 */
      cursor: pointer;
    }
  </style>
</head>
<body>

  <div class="scratch-card">
    <div class="prize-text">🎉 中奖了!</div>
    <canvas id="mask" width="300" height="150"></canvas>
  </div>

  <script>
    const canvas = document.getElementById('mask');
    const ctx = canvas.getContext('2d');
    
    // 1. 初始化:填充灰色遮罩
    ctx.fillStyle = '#cccccc'; // 刮奖区的颜色
    ctx.fillRect(0, 0, 300, 150);
    
    // 状态标记:是否正在按下鼠标
    let isDrawing = false;

    // 2. 鼠标/触摸交互事件监听
    canvas.addEventListener('mousedown', () => isDrawing = true);
    canvas.addEventListener('mouseup', () => isDrawing = false);
    // 鼠标移出画布也停止刮奖
    canvas.addEventListener('mouseleave', () => isDrawing = false); 

    canvas.addEventListener('mousemove', (e) => {
      if (!isDrawing) return;

      // 获取鼠标在 Canvas 中的坐标
      // e.offsetX / e.offsetY 是相对于事件源元素的坐标
      const x = e.offsetX;
      const y = e.offsetY;

      // --- 核心代码开始 ---
      // 设置混合模式为“擦除”(即:让重叠部分变透明)
      ctx.globalCompositeOperation = 'destination-out';

      // 绘制圆形作为“笔触”(圆形比矩形手感更好)
      ctx.beginPath();
      ctx.arc(x, y, 15, 0, Math.PI * 2); // 15是半径,控制橡皮擦大小
      ctx.fill();
      // --- 核心代码结束 ---
    });
  </script>
</body>
</html>

更新完就跳槽系列之CSS篇吊打面试官

2026年1月23日 11:14

第一部分:基础概念

1. CSS 选择器优先级计算规则(权重计算)

面试官会问:"如果一个元素同时被多个规则匹配,谁生效?如果有 !important 呢?"

权重等级(由高到低):

  • !important:无敌的存在(破坏性,慎用)。
  • 内联样式 (Inline styles):style="..." —— 权重计为 (1, 0, 0, 0)。
  • ID 选择器:#id —— 权重计为 (0, 1, 0, 0)。
  • 类、伪类、属性选择器:.class, :hover, [type="text"] —— 权重计为 (0, 0, 1, 0)。
  • 元素、伪元素选择器:div, ::before —— 权重计为 (0, 0, 0, 1)。
  • 通配符 *、组合符 + > ~、逻辑伪类 :not():权重为 (0, 0, 0, 0)。

计算规则:

  • 不进位制:权重是基于等级叠加的,即使 11 个类选择器也打不过 1 个 ID 选择器(在现代规范中,虽然技术上是 256 进制,但在逻辑理解上应视为不可跨级)。
  • 后来居上:如果权重完全相同,则最后定义的样式生效。
  • 继承属性:继承而来的样式权重为 0,甚至不如通配符。

2. 盒模型(标准盒模型与 IE 盒模型)

面试官会问:"这两种盒模型在计算宽度时有什么区别?"

标准盒模型 (W3C Standard): box-sizing: content-box (默认)。

  • width = 内容宽度 (content)。
  • 盒子实际占据空间 = width + padding + border + margin。

IE 盒模型 (Quirks Mode): box-sizing: border-box。

  • width = 内容 + padding + border。
  • 盒子实际占据空间 = width + margin。

3. box-sizing: border-box 的作用与影响

面试官会问:"为什么要全局设置 border-box?"

作用: 将 padding 和 border 绘制在已设定的 width 和 height 之内。

影响:

  • 布局可预测性:给一个 width: 50% 的盒子加 padding 或 border,它不会撑破父元素。
  • 响应式设计必备:在处理百分比布局时,它是唯一的救星。
  • 开发效率:不再需要根据 padding 反向计算 width。

4. 元素的 display 属性

面试官会问:"inline-block 为什么会有空隙?"

  • block:独占一行;宽度默认 100%;可设置宽高、上下左右 margin/padding。
  • inline:不独占一行;无法设置宽高;垂直方向的 margin/padding 不占据空间(不影响布局流)。
  • inline-block:像文本一样排列,但拥有盒模型属性。
    • 坑:元素间会有 4px 左右的空隙。原因:HTML 中的换行符/空格被解析为文本。
    • 解:父元素 font-size: 0 或去掉 HTML 换行。
  • none:完全移除,不占位,触发回流(Reflow)和重绘(Repaint)。

5. 文档流(Normal Flow)的概念

面试官会问:"什么是脱离文档流?"

定义: 浏览器默认的排版方式。块级元素垂直排列,行内元素水平排列。

脱离文档流的方式:

  • float (半脱离:位置脱离,但文本会环绕)。
  • position: absolute (全脱离)。
  • position: fixed (全脱离)。

后果: 父元素高度塌陷(针对 float),后方的元素会无视脱离的元素占据其位置。

6. position 定位详解

面试官会问:"sticky 什么时候会失效?"

  • static:默认,遵循文档流。
  • relative:相对自身定位。不脱离文档流(原本的位置会被留空)。常用于做 absolute 的基准。
  • absolute:相对于最近的非 static 祖先元素定位。脱离文档流。
  • fixed:相对于浏览器视口 (Viewport) 定位。脱离文档流。
  • sticky (粘性定位)
    • 基于用户滚动,在 relative 与 fixed 之间切换。
    • 失效场景:1. 父元素设置了 overflow: hidden/auto/scroll;2. 未指定阈值(如 top: 0);3. 父元素高度等于 sticky 元素高度。

7. 外边距折叠(Margin Collapsing)

面试官会问:"如何防止两个相邻 div 的 margin 合并?"

现象: 垂直方向上,两个相邻的块级元素的 margin 会取最大值,而非相加。

三种情况:

  • 相邻兄弟元素。
  • 父元素与第一个/最后一个子元素(且父元素没 border/padding)。
  • 空的块级元素(自身上下 margin 折叠)。

解决方案(BFC): 给父元素加 overflow: hidden、border 或改用 padding。

8. z-index 与堆叠上下文 (Stacking Context)

面试官会问:"为什么我给子元素设置 z-index: 9999,它还是被隔壁的 z-index: 1 盖住了?"

核心逻辑: z-index 只在同一个堆叠上下文中比较。

如何产生堆叠上下文?

  • 根元素 。
  • position 为 relative/absolute 且 z-index 不是 auto。
  • position: fixed/sticky。
  • 现代触发方式:opacity 小于 1、transform 不为 none、filter 不为 none、flex/grid 子元素的 z-index 不为 auto。

层叠等级(从底向上):

  • 背景/边框
  • 负 z-index
  • 块级盒子
  • 浮动盒子
  • 行内盒子
  • z-index: 0 / auto
  • 正 z-index

9. CSS 单位

  • px:绝对单位,物理像素。
  • em:相对于当前元素的字体大小(如果设置在 font-size 上,则相对于父元素)。
  • rem:相对于根元素 () 的字体大小。响应式首选。
  • %
    • width/height:相对于父元素。
    • padding/margin:无论垂直还是水平,都相对于父元素的宽度(面试必考点)。
    • transform: translate:相对于自身。
  • vw/vh:视口宽高的 1%。

10. !important 的用法与影响

用法: 在属性值后加 !important,强制最高优先级。

影响:

  • 破坏性:打破了 CSS 的自然级联规则,导致调试困难。
  • 难以覆盖:除非再用一个权重更高或同样带 !important 的规则。

最佳实践:

  • 绝不要在全局 CSS 中使用。
  • 绝不要在制作插件/组件库时使用。
  • 仅在覆盖无法修改的内联样式或第三方库样式时作为最后手段。

总结: "CSS 布局的核心其实就是处理元素如何占据空间(盒模型)、如何定位(Position/Flex/Grid)以及如何相互影响(BFC/层叠上下文)。现代开发中,我们优先通过 box-sizing: border-box 规避计算坑位,通过 BFC 解决 margin 折叠,利用 rem 和 vw/vh 实现多端适配,并尽量避免 !important 以维护样式的鲁棒性。"


第二部分:核心布局系统

1. Flexbox 布局:弹性盒模型

Flexbox 是目前应用最广的一维布局方案(按行或按列)。

容器属性 (Container)

  • display: flex / inline-flex:开启弹性布局。
  • flex-direction:决定主轴 (Main Axis) 方向(row, column)。
  • flex-wrap:换行方式(默认 nowrap,即便子元素超出也不换行,而是会被压缩)。
  • justify-content:主轴对齐方式。
  • align-items:交叉轴对齐方式(单行)。
  • align-content:多行在交叉轴上的对齐方式(针对换行后的整体)。

项目属性 (Items)

  • flex-grow:剩余空间放大比例(默认 0,不放大)。
  • flex-shrink:空间不足缩小比例(默认 1,自动缩小)。
  • flex-basis:在分配多余空间之前,项目占据的主轴空间(默认 auto)。

深入理解 flex: 1 的计算方式

面试官必问:flex: 1 代表什么?和 flex: auto 有什么区别?

  • flex: 1 是 flex: 1 1 0% 的简写。
  • flex: auto 是 flex: 1 1 auto 的简写。
  • 区别点:在于 flex-basis。
    • 如果是 0%:计算剩余空间时,不考虑元素内容本身的大小,强行平分。
    • 如果是 auto:计算剩余空间时,先扣除元素内容的大小,只平分剩下的空间。

2. Grid 布局:网格系统

Grid 是 CSS 唯一的二维布局方案,它将容器划分为"行"和"列"。

核心概念

  • fr (fraction):片段单位,代表网格容器中可用空间的一份。
  • auto-fit vs auto-fill:
    • auto-fill:尽可能多地填充列,即便列是空的。
    • auto-fit:会将空的列"折叠",让占位的列充满剩余空间。
  • 显式网格 vs 隐式网格:
    • 显式 (Explicit):通过 grid-template-columns/rows 定义好的轨道。
    • 隐式 (Implicit):当项目超出了定义的轨道(比如定义了 2x2,但放了 5 个元素),浏览器自动生成的轨道。通过 grid-auto-rows 控制。

Grid 布局示例:自适应布局

.container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 10px;
}
/* 这行代码实现了:容器宽度足够时每列 200px 以上,不足时自动换行,且剩余空间平分。*/

3. 传统布局:浮动与清除浮动

虽然现在少用,但这是考察"老兵"基本功的必考题。

浮动的本质: 最初是为了实现"文字环绕图片"。

带来的问题: 父元素高度塌陷(因为浮动元素脱离了文档流)。

清除浮动 (Clearfix):

  • BFC 法:给父元素 overflow: hidden(最简单)。
  • 伪元素法(标准做法):
.clearfix::after {
  content: "";
  display: block;
  clear: both;
}

4. 水平垂直居中的多种方案

面试官会问:"你能写出几种居中方式?"

  • Flexbox (最推荐)display: flex; justify-content: center; align-items: center;
  • Griddisplay: grid; place-items: center;
  • Absolute + Transform (未知子元素宽高)position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  • Absolute + Margin: auto (已知宽高)position: absolute; top: 0; left: 0; right: 0; bottom: 0; margin: auto;

5. 圣杯布局与双飞翼布局

这两种布局的目标一致:三栏布局,中间栏(Main)最先加载(在 HTML 中排在最前面),两侧宽度固定,中间自适应。

圣杯布局 (Holy Grail Layout)

  • 原理:利用父元素的 padding 为左右侧留出空间,子元素全部 float: left,左右侧通过 relative 定位和负 margin 回位。
  • 缺点:当中间栏宽度小于侧边栏时,布局会乱。

双飞翼布局 (Double Wing Layout)

  • 原理:源自淘宝前端团队。中间栏内部增加一个包裹层(inner),直接设置 margin: 0 200px 为左右侧留空。不需要 relative。
  • 优点:结构更稳,不会因为宽度过小崩溃。

现代实现(Flex/Grid) 吊打点:不要再写 float 了。面试时说出:"在现代开发中,我会优先使用 Flex 或 Grid 实现,代码更简洁且语义化。"

6. 实战:左右两栏固定,中间自适应(且改变排序)

场景要求:HTML 结构中,Main 在最前(利于 SEO),但视觉上 Left 在左,Main 在中,Right 在右。

方案一:Flexbox (最简单)

<div class="container">
  <main class="content">Main (First in DOM)</main>
  <aside class="left">Left</aside>
  <aside class="right">Right</aside>
</div>
.container { display: flex; }
.content { flex: 1; order: 2; } /* order 控制视觉顺序 */
.left { width: 200px; order: 1; }
.right { width: 200px; order: 3; }

方案二:Grid (最强大)

.container {
  display: grid;
  grid-template-columns: 200px 1fr 200px;
  grid-template-areas: "L M R";
}
.left { grid-area: L; }
.content { grid-area: M; }
.right { grid-area: R; }

7. 多列布局 (Multi-column)

类似报纸排版。

  • column-count:设置列数。
  • column-gap:列间距。
  • column-rule:列之间的分割线。

应用场景:瀑布流布局的简单替代方案(虽然顺序是垂直填充的)。

总结: "布局方案的选择取决于场景。对于一维对齐(如导航栏、均匀分布列表),Flexbox 是绝对的首选,特别是 flex: 1 的剩余空间分配算法非常灵活。对于二维复杂的结构,Grid 提供的 grid-template-areas 让 CSS 代码几乎变成了视觉原型图,极大降低了维护成本。至于圣杯和双飞翼,它们是浮动时代的智慧结晶,理解其负 margin 的运用有助于深入理解文档流,但在现代项目中,我会倾向于使用 order 属性或 Grid 区域来优雅地解决 DOM 顺序与视觉顺序不一致的问题。"


第三部分:视觉表现与样式

1. CSS 变量(Custom Properties)

面试官会问:"CSS 变量和 Sass/Less 变量有什么区别?"

核心特性:

  • 动态性:CSS 变量在运行时生效,可以通过 JavaScript 动态修改(el.style.setProperty('--color', 'red'));而 Sass 变量在编译时就确定了。
  • 作用域:遵循层叠和继承规则。:root 中定义的是全局变量,在特定选择器内定义的是局部变量。
  • 备用值:var(--name, #000),当变量未定义时可回退。

吊打点:提到**"响应式主题切换"**。无需重写几百行 CSS,只需修改 :root 下的变量值,全站样式瞬间改变。

2. background 复合属性与细节

面试官会问:"如何实现背景图不随页面滚动?"

复合顺序: background: color image repeat attachment position / size。注意 size 必须跟在 position 后面,并用 / 分隔。

关键子属性:

  • background-attachment: fixed:背景相对于视口固定(视差滚动效果的基础)。
  • background-clip:背景绘制区域。重点:text 值(搭配 -webkit- 前缀)可实现文字渐变色。
  • background-origin:背景图片绘制的起点(border-box, padding-box, content-box)。
  • 多重背景:可以写多个 url(),用逗号分隔,第一个写在最上面。

3. 边框、轮廓与阴影

  • border:占据空间,影响盒模型。
  • outline (轮廓):不占据空间,不影响布局。常用于 focus 状态,对无障碍友好。
  • box-shadow:h-offset v-offset blur spread color inset。
    • 性能坑:巨大的 blur(模糊)会消耗大量 GPU 算力,滚动页面时可能导致掉帧。
    • 技巧:可以用多层阴影叠加实现极其逼真的材质感。

4. 文本样式与排版

line-height:

  • 单位区别:建议用无单位数值(如 1.5)。因为无单位数值会被继承为比例,而带单位(如 1.5em)会被继承为计算后的绝对像素值,导致子元素行高错乱。

文本截断(Ellipsis):

  • 单行:overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
  • 多行:-webkit-line-clamp: 3; display: -webkit-box; -webkit-box-orient: vertical;(这是标准事实,虽然带前缀但最通用)。

5. 过渡(Transition)与动画(Animation)

面试官会问:"这两者怎么选?怎么优化性能?"

  • Transition:状态 A 到状态 B 的简单补间(如 :hover)。需要触发源。
  • Animation:复杂的、多阶段的、可循环的(使用 @keyframes)。

性能优化(必考):

  • 尽量只动 transform 和 opacity。
  • 为什么?因为这两者在 CSS 渲染管线中属于 Composite(合成) 阶段,不触发 Reflow(回流)和 Repaint(重绘),性能极高。
  • will-change: transform:提前通知浏览器创建层,开启硬件加速。

6. CSS 变换(Transform) 2D/3D

  • 2D 操作:translate, scale, rotate, skew。
  • 3D 操作:translateZ, rotateY 等。

核心细节:

  • perspective (透视):必须设在父元素上,才能看到 3D 的远近效果。
  • 顺序影响:transform: rotate(45deg) translate(100px) 和反过来结果不同。因为坐标轴会随旋转而旋转。

7. 滤镜(Filter)与混合模式(Mix-blend-mode)

  • filter:作用于元素自身。常用:blur()(毛玻璃基础)、grayscale()(全站变灰)、drop-shadow()(给不规则形状加阴影,比 box-shadow 聪明)。
  • mix-blend-mode:元素内容与下层背景的融合方式。
    • 场景:白色背景的图片去掉背景(multiply),或者黑底白字的文字反色(difference)。

8. clip-path 与 mask

面试官会问:"怎么做一个三角形或五角星?"

  • clip-path:裁剪路径。可以定义 polygon, circle 等。超出部分不可见且不响应点击事件。
  • mask (遮罩):类似 Photoshop 的蒙版。使用一张图片或渐变来控制透明度。常用于图标的动态变色或复杂的图片淡入效果。

9. CSS 渐变(Gradients)

渐变不是图片,是浏览器动态生成的 Image。

  • linear-gradient:线性渐变。支持角度(45deg)或方向(to right)。
  • radial-gradient:径向渐变。
  • conic-gradient:锥形渐变。
    • 神技:可以用锥形渐变几行代码写出一个饼图。
  • 硬停止(Hard Stops):linear-gradient(#f00 50%, #00f 50%),通过设置相同的百分比,可以画出没有任何过渡的色块。

总结: "在处理 CSS 视觉表现时,我非常关注渲染性能。比如,我会优先使用 transform 代替 top/left 来实现位移,因为这能避开浏览器的布局阶段。对于复杂的交互,我倾向于利用 CSS 变量 结合 JS 来控制状态,这样能保持逻辑与样式的解耦。 此外,在处理特殊视觉需求时,我会考虑 clip-path 和 mask 的方案,这比传统的透明图片更具可扩展性且减小了请求体积。对于现代浏览器,mix-blend-mode 和 filter 能在无需 Canvas 的情况下实现非常炫酷的 UI 特效。"


第四部分:响应式与适配

1. 响应式 vs 自适应 (Responsive vs. Adaptive)

面试官会问:"这两者有什么本质区别?"

  • 响应式 (Responsive):一套代码,通过流式布局 (Fluid Grid) 和媒体查询,让布局在不同尺寸下"自动流动"。(如 Bootstrap)。
  • 自适应 (Adaptive):为不同设备提供几套固定的模板。浏览器检测设备(UA 或尺寸)后加载对应的静态布局。

吊打点:现代开发中,两者往往结合使用。我们会用响应式处理细微的尺寸变化,用自适应逻辑处理差异巨大的交互体验(如 PC 端是悬停菜单,移动端是侧边栏)。

2. 媒体查询(@media)与移动优先

移动优先 (Mobile First): 使用 min-width。先写移动端样式,再向上叠加。

  • 优点:代码更简洁(基础样式通常在小屏幕),对低性能设备更友好。

常用断点(避开特定机型,按设备类型):

  • < 576px:手机
  • 576px ~ 768px:平板(竖屏)
  • 768px ~ 992px:平板(横屏)
  • 992px ~ 1200px:普通桌面显示器
  • 1200px:大屏

3. 视口 (Viewport) 元标签与视口单位

<meta name="viewport" content="width=device-width, initial-scale=1.0">

核心作用: 告诉浏览器,页面的逻辑宽度等于设备的物理宽度,并禁止浏览器自动缩放(默认情况下,移动端会假装自己是 980px 宽)。

单位进阶:

  • vw / vh:相对于视口。
  • vmax / vmin:相对于宽高中较大/较小的一个。在处理屏幕横竖屏旋转时,vmin 常用于确保图标不超出屏幕。

4. 响应式图片(srcset, sizes, )

面试官会问:"如何根据屏幕像素密度(DPR)加载不同图片?"

  • srcset:给浏览器一组图片源及其宽度(如 image-1x.jpg 1x, image-2x.jpg 2x)。
  • sizes:告诉浏览器图片在布局中占据的实际宽度。
  • 标签:用于"美术指导(Art Direction)"。比如在 PC 端显示横图,在移动端裁剪为竖图。
<picture>
  <source media="(min-width: 800px)" srcset="large.jpg">
  <img src="small.jpg" alt="Responsive Image">
</picture>

5. 移动端 1 像素边框问题

面试官会问:"为什么 border: 1px 在 Retina 屏上看起来很粗?"

原因: CSS 像素是逻辑像素。在 DPR=2 的手机上,1px 的 CSS 像素等于 2px 的物理像素。

最佳方案(伪元素 + Scale):

.border-1px {
  position: relative;
}
.border-1px::after {
  content: "";
  position: absolute;
  top: 0; left: 0; width: 200%; height: 200%;
  border: 1px solid #000;
  transform: scale(0.5);
  transform-origin: left top;
}

6. 横竖屏适配与安全区域 (Safe Area)

  • 横竖屏查询:@media (orientation: landscape / portrait)。
  • 安全区域(针对 iPhone 留海屏):
    • 设置 viewport-fit=cover。
    • 使用环境变量:padding-bottom: env(safe-area-inset-bottom);。

7. 容器查询(@container) —— 2024年面试杀手锏

面试官会问:"除了媒体查询,还有什么能做自适应?"

媒体查询的局限性: 它是基于整个"视口"的。

容器查询 (Container Queries): 让组件根据其父容器的宽度来改变样式。

场景: 同一个侧边栏组件,放在侧边栏(窄)时显示垂直列表,放在中间区域(宽)时显示网格布局。

.parent { container-type: inline-size; }
@container (min-width: 400px) {
  .child { display: flex; }
}

8. CSS 层叠层(@layer)

作用:解决 CSS 权重战争。你可以定义不同的层级(如 base, theme, utilities),即便 base 里的选择器权重极高,也会被 utilities 里的样式覆盖。

优势:不再需要为了覆盖样式而疯狂加 !important。

9. clamp(), min(), max() 函数

面试官会问:"不用媒体查询,如何让字体随屏幕变大而变大,但有上下限?"

  • clamp(min, preferred, max)font-size: clamp(1rem, 5vw, 2rem); 这行代码实现了:字体最小 1rem,最大 2rem,中间随视口宽度(5vw)动态变化。

  • min() 和 max():用于在两个值之间取极值,常用于设置响应式宽度上限:width: min(90%, 1200px);。

10. 响应式排版(Responsive Typography)

技巧:使用 rem 结合 clamp()。 计算公式:通过设置 html { font-size: calc(16px + (24 - 16) * (100vw - 400px) / (1200 - 400)); } 这种线性增长公式,实现丝滑的字体过渡(虽然现在更推荐用 clamp)。


第五部分:性能与渲染

1. CSS 选择器性能:为什么从右向左匹配?

面试官会问:"#nav ul li a 这个选择器,浏览器是怎么找元素的?"

底层原理: 浏览器引擎(如 WebKit 或 Gecko)在匹配选择器时,是从**最右边的选择器(关键选择器,Key Selector)**开始向左匹配的。

为什么?

优化建议:

  • 避免使用通配符 * 作为关键选择器。
  • 减少层级嵌套(建议不超过 3 层)。
  • 多用类名,少用标签名作为末尾选择器。

2. 重排(Reflow/回流)与重绘(Repaint)

这是 CSS 性能最经典的话题,必须答出渲染流水线(Rendering Pipeline)。

重排 (Reflow):

  • 定义:当元素的几何属性(宽、高、位置、布局等)改变时,浏览器需要重新计算元素在页面上的位置,并重新生成布局树(Layout Tree)。
  • 触发场景:增删 DOM、修改 width/height/margin/padding、获取 offsetTop/getBoundingClientRect(会强行触发同步回流)。
  • 代价:最高,会引发父元素甚至全页面的重新布局。

重绘 (Repaint):

  • 定义:当元素的外观属性(颜色、背景色、阴影等)改变,但不影响布局时。
  • 代价:较轻,不需要重新布局,但依然需要重新像素化填充。

核心结论: 重排必将引起重绘,重绘不一定引起重排。

3. 硬件加速(GPU 加速)与 will-change

面试官会问:"如何让动画不卡顿?"

分层(Layering)机制: 浏览器会将复杂的页面分成多个层。某些属性会让元素拥有独立的合成层(Compositing Layer)。

如何触发: transform: translateZ(0)、opacity、filter。

GPU 的作用: 这些层在 GPU 中直接进行纹理混合,不经过主线程的 Layout 和 Paint 阶段。

will-change: 告诉浏览器该元素即将变化,请提前为其创建合成层。

坑:不可滥用。每个合成层都占内存,层过多会导致内存飙升,反而变卡(甚至白屏)。

4. CSS 对首屏加载(FCP, LCP)的影响

CSS 是性能瓶颈:CSS 是阻塞渲染的资源。在 CSSOM 构建完成前,浏览器不会渲染页面。

  • FCP (First Contentful Paint):如果 CSS 文件过大或嵌套太深,FCP 会被显著推迟。
  • LCP (Largest Contentful Paint):核心图片(如 Banner)的样式加载慢,会导致 LCP 指标变差。

优化思路:精简 CSS,减少 @import(会增加请求链路深度)。

5. 关键 CSS(Critical CSS)提取

吊打点:谈谈如何处理"白屏时间"。

概念: 将页面首屏(折叠屏上方区域)所需的 CSS 提取出来,直接内联在 中,而剩下的全量 CSS 采用异步加载。

作用: 消除渲染阻塞请求,实现近乎瞬时的首屏呈现。

工具: 可以使用 Critical 或 Puppeteer 自动提取。

6. CSS 代码分割与异步加载

代码分割: 利用 Webpack/Vite,将不同路由的 CSS 拆分成独立文件,实现"按需加载"。

异步加载技巧:

<!-- 经典 Trick:先声明为 print 媒体类型(不阻塞加载),加载完切回 all -->
<link rel="stylesheet" href="style.css" media="print" onload="this.media='all'">

7. CSS 优化最佳实践

  • 减少嵌套:Sass/Less 嵌套不要超过 3 层,生成的 CSS 选择器更精简。
  • 压缩:使用 CSSNano 等工具去除空格、注释。
  • 复用(原子化思想):类似 Tailwind CSS 的思路,通过复用工具类减少重复的样式定义,从而减小 CSS 体积。

8. 为什么 transform 和 opacity 做动画最高效?

这是"吊打面试官"的核心知识点。

渲染流水线:

  • JavaScript / CSS (修改样式)
  • Style (计算样式)
  • Layout (计算几何信息 - 重排)
  • Paint (绘制像素 - 重绘)
  • Composite (图层合成 - 合成阶段)

原因: 修改 left/top 会触发 Layout;修改 background-color 会触发 Paint。 但是:修改 transform 和 opacity 直接跳过了 Layout 和 Paint,只运行 Composite 阶段。这一步在 GPU 中完成,主线程被完全释放,因此即便 CPU 忙碌,动画依然丝滑(60FPS)。

总结: "对于 CSS 性能,我从加载和渲染两个维度考虑。 在加载阶段,我会通过 Critical CSS 提取和 媒体查询异步加载 来优化 FCP,确保页面能尽快渲染出内容。 在渲染阶段,我深度关注浏览器的渲染管线。我会尽量使用 transform 和 opacity 来代替 top/left 做位移和显隐动画,因为它们能避开 Layout 和 Paint,直接在 Composite 阶段 由 GPU 完成。 同时,我会警惕合成层爆炸问题,合理使用 will-change 而不滥用。在编写选择器时,我会考虑到浏览器从右向左匹配的特性,避免使用通配符或过深的嵌套,从而减轻 Style 计算的负担。"


第六部分:架构与工程化

1. BEM 命名方法论

面试官会问:"为什么要用这么长的命名?block__element--modifier 有什么好处?"

核心结构:

  • Block (块):独立、有意义的实体(如 .menu)。
  • Element (元素):块的一部分,不能脱离块独立存在(如 .menu__item)。
  • Modifier (修饰符):改变状态或外观(如 .menu__item--active)。

深层价值:

  • 解决权重战争:BEM 提倡几乎所有的选择器都只有一个类名,这使得优先级非常平整,不会出现 .nav ul li a 这种难以覆盖的高权重选择器。
  • 自解释性:从类名就能看出组件结构,降低维护成本。
  • 防止样式污染:通过长命名空间规避全局冲突。

吊打点:提到 "BEM 并不建议嵌套三层以上"。例如不要写 .block__el1__el2,而应提取为新的 Block 或扁平化为 .block__el2。

2. CSS Modules

面试官会问:"CSS Modules 是如何实现局部作用域的?"

原理: 在构建阶段(Webpack/Vite),将 CSS 类名哈希化。

  • 源文件:.title { color: red; }
  • 编译后:.title_x5a8j { color: red; }

优点:

  • 彻底解决命名冲突:每个文件都是独立作用域。
  • 显式依赖:在 JS 中通过 import styles from './style.module.css' 引入,代码引用关系清晰。

缺陷: 变量共享不方便(需要 :export 语法),类名动态拼接略显繁琐。

3. CSS-in-JS (Styled-components, Emotion)

面试官会问:"为什么 React 社区喜欢 CSS-in-JS?它有什么副作用?"

设计哲学: All in JS。样式是组件逻辑的一部分,利用 JS 的变量、函数和逻辑来增强 CSS。

优点:

  • 动态样式极致灵活:直接根据 props 计算样式()。
  • 自动按需加载:只有组件被渲染,样式才会被注入到 标签。
  • 零配置作用域:天然不存在冲突。

缺点(吊打点):

  • 运行时开销 (Runtime Overhead):在浏览器端实时解析 JS 并生成 CSS 插入 DOM,在大规模渲染时会导致掉帧。
  • Bundle 体积:需要引入额外的 JS 库(如 styled-components 库本身约 12KB)。
  • 无法缓存 CSS 文件:样式随 JS 逻辑一起加载,无法利用浏览器的 CSS 独立缓存机制。

4. 原子化 CSS (Tailwind CSS)

面试官会问:"Tailwind 会让 HTML 变得很乱,为什么它还会流行?"

哲学: Utility-First(实用优先)。不再写 .card,而是写 class="p-4 bg-white shadow-md rounded-lg"。

核心优势:

  • 不再为起名烦恼:减少了大脑开销。
  • CSS 体积不再膨胀:无论页面多复杂,CSS 总量会趋于稳定(因为常用的 utility 类是有限的)。
  • 极致的开发速度:无需在 JS 和 CSS 文件间反复切换。

底层机制: 使用 PurgeCSS 扫描 HTML/JS,只打包你用过的类,生产环境体积极小。

思考:虽然 HTML 看起来乱,但它解决了样式冗余和死代码问题。

5. CSS 预处理器(Sass, Less)与现代 CSS

面试官会问:"现代 CSS 已经支持变量和嵌套了,Sass 还有必要吗?"

Sass/Less 的核心价值:

  • 复杂的逻辑运算:@for, @if, @each 循环,Mixins 函数计算。
  • 模块化管理:@use 和 @import 将样式拆分并合并。

现状对比:

  • Native CSS Nesting(原生嵌套)现已得到主流浏览器支持。
  • CSS Variables(原生变量)是动态的(运行时),而 Sass 变量是静态的(编译时)。

结论: 对于简单的嵌套和变量,现代 CSS 足够;对于大型 UI 库或需要复杂逻辑的系统,Sass 依然不可替代。

6. PostCSS 的作用与常见插件

面试官会问:"PostCSS 是不是就是为了加浏览器前缀的?"

定义: PostCSS 是 CSS 界的 Babel。它本身不做什么,而是提供一个插件系统来处理 CSS。

常用插件(展现深度):

  • Autoprefixer:自动添加 -webkit- 等前缀。
  • PostCSS Preset Env:让你现在就能用未来的 CSS 语法(类似 Babel 转换 JS)。
  • cssnano:压缩 CSS,优化代码。
  • px-to-viewport / px2rem:移动端适配插件。

工作原理: CSS → AST (抽象语法树) → 插件处理 → 字符串。

💡 综合对比:架构如何选型?

如果你是技术负责人,你会怎么选?(这是面试官最想听到的"大局观")

  • 中小型项目 / 快速迭代:选 Tailwind CSS。开发速度快,体积小,维护成本低。
  • 大型企业级后台 / 复杂 UI 组件库:选 Sass + CSS Modules 或 BEM。结构清晰,逻辑复用性强。
  • 追求极致动态化 / 强交互设计系统:选 Styled-components。虽然有运行时损耗,但开发体验和动态响应最好。

💡 吊打面试官的总结话术:

"CSS 工程化本质上是在 '隔离性'、'复用性'、'灵活性' 和 '性能' 之间寻找平衡。 BEM 是规范上的约束,它用低成本解决了命名冲突; CSS Modules 是工具上的约束,它实现了真正的作用域隔离; CSS-in-JS 将 CSS 逻辑化,特别适合复杂状态驱动的 UI,虽然牺牲了一点点运行时性能; 而 Tailwind CSS 则是一种范式转移,它通过原子化从根本上解决了 CSS 代码膨胀问题。 在实际架构中,我会结合 PostCSS 来保证兼容性,并根据团队规模和项目生命周期,在 'Sass + CSS Modules'(稳健型)和 'Tailwind'(敏捷型)之间做抉择。同时,我也在关注现代 CSS 的发展,如原生嵌套和容器查询,以减少对预处理器的依赖。"


第七部分:深度思考与开放题

这是 CSS 知识体系的最后一块拼图——底层机制与架构设计。在高级前端面试中,面试官不仅看你能不能画出页面,更看重你对 CSS 设计哲学(Design Philosophy) 的理解以及在大规模工程中的博弈能力。

1. CSS 的层叠(Cascade)具体规则

面试官会问:"CSS 的 C 代表 Cascade(层叠),请问它是按照什么顺序决定最终样式的?"

层叠是 CSS 的核心灵魂。当多个样式规则指向同一个元素时,浏览器会遵循以下优先级顺序(由高到低):

来源与重要性 (Origin & Importance):

  • User Agent !important (浏览器默认样式的强制项,极少见)
  • User !important (用户自定义设置的强制项)
  • Author !important (开发者写的 !important)
  • Author (开发者写的普通 CSS)
  • User (用户自定义设置)
  • User Agent (浏览器默认样式,如 div 是 block)

层叠层 (Cascade Layers - @layer): 这是 CSS2022 引入的新特性。在同一个来源内,后声明的 @layer 优先级更高。

注意:未联网到层(Unlayered)的样式 优先级高于所有定义在 @layer 里的样式。

选择器权重 (Specificity): (Inline, ID, Class/Attribute, Element)。

出现顺序 (Order of Appearance): 权重相同时,"后来居上"。

吊打点:很多候选人只知道"权重",但不知道 "来源" 和 "层叠层"。如果你能解释清楚 @layer 如何解决第三方库样式覆盖问题,面试官会觉得你紧跟前沿规范。

2. CSS 继承(Inheritance)机制

面试官会问:"哪些属性可以继承?如何强制一个不继承的属性继承?"

默认继承的属性: 主要是文本相关的。

  • font-family, font-size, color, line-height, text-align, visibility, cursor 等。

默认不继承的属性: 主要是盒模型和布局相关的。

  • margin, padding, border, width/height, position, display, background 等。(想象一下,如果 border 能继承,父元素加个边框,所有子元素全带边框,那页面就乱套了)。

控制继承的四个关键字:

  • inherit:强制继承父元素的值。
  • initial:恢复为该属性的浏览器默认值(初始值)。
  • unset(高级):如果是默认继承的属性,效果同 inherit;如果是非继承属性,效果同 initial。
  • revert:回退到用户代理(浏览器)定义的样式。

3. 如何组织大型项目的 CSS 代码?

面试官会问:"当项目有几百个组件时,你怎么保证 CSS 不失控?"

这考察的是架构思想。你可以从以下维度回答:

方法论 (Methodologies):

  • ITCSS (Inverted Triangle CSS):倒三角架构。将 CSS 按从全局到具体的顺序分为 7 层:Settings -> Tools -> Generic -> Elements -> Objects -> Components -> Trumps。这能极好地控制样式流向和权重变化。
  • BEM:通过严格的命名规范确保组件独立性。

工程化手段:

  • CSS Modules:从工具层面实现样式私有化,杜绝全局污染。
  • Design Tokens:将颜色、间距、字号抽离为变量(CSS Variables 或 Sass Variables),实现"一处修改,全站生效"。
  • 按需加载:通过代码分割(Code Splitting)让每个页面只加载自己需要的 CSS 模块。

吊打点:提到 "样式死代码消除(Purge/Unused CSS)" 和 "原子化 CSS(Tailwind)" 的结合。大型项目最怕 CSS 只增不减,解释你如何通过自动化工具发现并清理废弃样式。

4. 如何处理 CSS 样式冲突?

面试官会问:"当你引入一个第三方库,它的样式把你的页面搞乱了,你怎么办?"

这是一个非常现实的博弈问题。你可以给出"组合拳"方案:

方案一:提高自身权重(权宜之计)。 增加一个父级 ID 或类名来提升权重。

方案二:使用 CSS Modules / Shadow DOM(从物理上隔离)。 如果项目支持 Web Components,使用 Shadow DOM 可以实现完美的样式真空隔离,外部样式绝对进不来。

方案三:利用 @layer 层叠层(最优雅方案)。 将第三方库的样式包裹在 @layer library 中,将自己的业务样式包裹在 @layer base 中。通过声明层级顺序,确保业务层永远覆盖库层,即便你的权重比它低。

方案四:样式重置 (Reset/Normalize)。 在项目入口处统一浏览器差异,确保基准线一致。

💡 总结:深度思考的深度在哪里?

面试官问这些开放题,是在看你的 "防御性编程" 意识。

  • 初级开发者:见招拆招,样式冲突了就加 !important。
  • 中级开发者:懂得用 BEM 或 CSS Modules 规避问题。
  • 高级/资深开发者:
    • 会考虑 "级联流" 的可预测性(Predictability)。
    • 会考虑 "样式系统的伸缩性"(Scalability)。
    • 会利用最新的 W3C 规范(如层叠层、容器查询)从底层架构上消灭冲突。

css布局篇——两栏布局(无废话版)

2026年1月23日 11:28

核心思路

一般两栏布局指的是左边一栏宽度固定,右边一栏宽度自适应

第一种方法

  • 浮动 + margin-left(将左边元素宽度设置为200px,并且设置向左浮动。将右边元素的margin-left设置为200px)
<body>
  <div class = "box">
    <div class = "left"></div>
    <div class = "right"></div>
  </div>
  我是文字
</body>
<style>
    .left{
        width: 200px;
        height: 400px;
        background-color: orange;
        float :left;
  }
    .right{
        height: 300px;
        margin-left: 200px;
        background-color: yellowgreen;
    }
</style>

image.png

oh my god 下面的其他内容怎么跑上去了? 是因为我们使用了浮动

啥是浮动?

浮动会导致元素脱离原来普通的文档流。元素可以向左或者向右浮动,直到碰到父容器或其他浮动元素才停下。在原来的文档流里,它就像被 “删掉” 了一样,不占位置。这样一来,父元素就会因为没东西撑着,高度变成 0,出现高度塌陷,后面的内容也会自动往上跑,跟浮动元素叠在一起。

解决办法:

  • BFC,块级格式化上下文,BFC 规定了内部的块级元素的布局方式。常见的做法是为父元素添加:
overflow:hidden;

啥是BFC

块格式化上下文(Block Formatting Context,BFC),是Web页面的可视化CSS渲染的一部分,是布局过程中生成块级盒子的区域,也是浮动元素与其他元素的交互限定区域。

BFC的特点

  • 垂直方向上,自上而下排列,和文档流的排列方式一致。
  • 在BFC中上下相邻的两个容器的margin会重叠
  • 计算BFC的高度时,需要计算浮动元素的高度
  • BFC区域不会与浮动的容器发生重叠
  • BFC是独立的容器,容器内部元素不会影响外部元素
  • 每个元素的左margin值和容器的左border相接触
人话

BFC就是一个独立的布局容器,内部元素自己玩自己的,不跟外面互相干扰,还能把浮动元素 “包住”,解决高度塌陷。

创建BFC的条件

  • 根元素:body;
  • 元素设置浮动:float 除 none 以外的值;
  • 元素设置绝对定位:position (absolute、fixed);
  • display 值为:inline-block、table-cell、table-caption、flex等;
  • overflow 值为:hidden、auto、scroll;

创建BFC后的效果

回归正题,我们实现两栏布局只需要为.box 元素添加样式:

overflow:hidden;

image.png

第二种方法

  • 利用flex布局,将左边元素设置为固定宽度200px,将右边的元素设置为flex:1。
<style>
    .box{
        display: flex;
    }
    .left{
        width: 200px;
        height: 400px;
        background-color: orange;    
  }
    .right{
        flex: 1;
        height: 300px;
        background-color: yellowgreen;
    }
</style>

那些年我们追过的 ECMAScript

作者 xuyanzhuqing
2026年1月23日 11:27

JavaScript 从 ES2015 到 ES2026 的核心更新梳理

ES2015 (ES6) - 里程碑式更新

这是 JS 史上最重大的一次更新,重构了语言核心语法和特性:

  1. 块级作用域与声明

    1. let/const:替代 var,提供块级作用域,const 声明只读常量

    2. 示例:

      • const PI = 3.14159;
        let count = 0;
        if (true) {
          let count = 1; // 块内独立作用域
          console.log(count); // 1
        }
        console.log(count); // 0
        
  2. 箭头函数:简化函数写法,绑定词法 this

    1. const add = (a, b) => a + b;
      const obj = {
        name: "JS",
        sayHi: () => console.log(this.name) // 箭头函数this指向外层(全局)
      };
      
  3. 类与模块

    1. class 语法:简化原型链编程
    2. import/export:模块化标准
    3. // 模块导出
      export const name = "ES6";
      export class Person {
        constructor(name) {
          this.name = name;
        }
      }
      // 模块导入
      import { Person } from './module.js';
      
  4. 解构赋值:快速提取对象/数组数据

    1. const [a, b] = [1, 2];
      const { name, age } = { name: "Tom", age: 20 };
      
  5. 其他核心特性:模板字符串 `hello ${name}`、默认参数、扩展运算符 ...PromiseMap/Set 等。

ES2016 (ES7) - 小版本增量更新

  1. 数组 includes() 方法:替代 indexOf 判断元素是否存在(支持 NaN

    1. [1, 2, NaN].includes(NaN); // true
      [1, 2, NaN].indexOf(NaN); // -1
      
  2. 幂运算符 ** :替代 Math.pow()

    1. 2 ** 3; // 8 等同于 Math.pow(2, 3)
      

ES2017 (ES8)

  1. async/await:异步编程终极方案,简化 Promise 链式调用

    1. async function fetchData() {
        try {
          const res = await fetch('/api/data');
          const data = await res.json();
          return data;
        } catch (err) {
          console.error(err);
        }
      }
      
  2. 对象扩展

    1. Object.values()/Object.entries():获取对象值/键值对数组
    2. Object.getOwnPropertyDescriptors():获取对象属性完整描述
    3. const obj = { a: 1, b: 2 };
      Object.values(obj); // [1, 2]
      Object.entries(obj); // [['a', 1], ['b', 2]]
      
  3. 字符串填充padStart()/padEnd()

    1. '123'.padStart(5, '0'); // '00123'
      '123'.padEnd(5, '0'); // '12300'
      

ES2018 (ES9)

  1. 异步迭代器for await...of 遍历异步可迭代对象

    1. async function processAsyncData() {
        const asyncIterable = {
          [Symbol.asyncIterator]() {
            let i = 0;
            return {
              next() {
                if (i < 3) return Promise.resolve({ value: i++, done: false });
                return Promise.resolve({ done: true });
              }
            };
          }
        };
        for await (const num of asyncIterable) {
          console.log(num); // 0, 1, 2
        }
      }
      
  2. 正则扩展:反向断言、dotAll 模式(. 匹配任意字符)

    1. // 后行断言
      /(?<=$)\d+/.exec('$100'); // ['100']
      // dotAll 模式
      /a.b/s.test('a\nb'); // true(默认模式下为 false
  3. 对象扩展运算符... 解构/合并对象

    1. const obj1 = { a: 1 };
      const obj2 = { ...obj1, b: 2 }; // { a: 1, b: 2 }
      

ES2019 (ES10)

  1. 数组方法扩展

    1. Array.prototype.flat():扁平化数组(默认1层)
    2. Array.prototype.flatMap()map + flat 组合
    3. [1, [2, [3]]].flat(2); // [1, 2, 3]
      [1, 2, 3].flatMap(x => [x * 2]); // [2, 4, 6]
      
  2. 字符串 trimStart() / trimEnd() :替代 trimLeft()/trimRight(),语义更清晰

  3. Object.fromEntries() :将键值对数组转回对象(Object.entries 反向操作)

    1. const arr = [['a', 1], ['b', 2]];
      Object.fromEntries(arr); // { a: 1, b: 2 }
      
  4. 可选捕获绑定try/catchcatch 可以省略参数

    1. try {
        // 可能出错的代码
      } catch { // 无需写 (err)
        // 处理错误(无需使用err参数时)
      }
      

ES2020 (ES11)

  1. 可选链操作符 ?. :避免访问嵌套对象属性时的 Cannot read property 'xxx' of undefined 错误

    1. const obj = { a: { b: 1 } };
      console.log(obj?.a?.b); // 1
      console.log(obj?.c?.d); // undefined(不会报错)
      
  2. 空值合并运算符 ?? :仅当左侧为 null/undefined 时返回右侧(区别于 ||

    1. 0 ?? 1; // 0(|| 会返回1)
      null ?? 1; // 1
      
  3. BigInt:支持超大整数运算(超出 Number.MAX_SAFE_INTEGER 范围)

    1. const bigNum = 9007199254740991n + 2n; // 9007199254740993n
      
  4. import() 动态导入:按需加载模块(返回 Promise)

    1. async function loadModule() {
        const module = await import('./module.js');
        module.doSomething();
      }
      

ES2021 (ES12)

  1. 数字分隔符 _ :提升大数字可读性

    1. const billion = 1_000_000_000; // 等同于 1000000000
      
  2. String.prototype.replaceAll() :替换所有匹配项(无需正则全局标志)

    1. 'a b a b'.replaceAll('a', 'x'); // 'x b x b'
      
  3. Promise.any() :只要有一个 Promise 成功就返回(区别于 Promise.race

    1. Promise.any([Promise.reject(1), Promise.resolve(2)])
        .then(res => console.log(res)); // 2
      
  4. 逻辑赋值运算符&&=||=??=

    1. let a = 0;
      a ||= 1; // 1(等同于 a = a || 1)
      let b = 2;
      b &&= 3; // 3(等同于 b = b && 3)
      let c = null;
      c ??= 4; // 4(等同于 c = c ?? 4)
      

ES2022 (ES13)

  1. 类字段声明:支持在类中直接声明实例/静态字段(无需在 constructor 中赋值)

    1. class Person {
        name = 'Tom'; // 实例字段
        static age = 20; // 静态字段
        #privateField = '私有值'; // 私有字段(# 开头)
        
        getPrivate() {
          return this.#privateField;
        }
      }
      const p = new Person();
      console.log(p.name); // Tom
      console.log(Person.age); // 20
      console.log(p.#privateField); // 报错(无法访问私有字段)
      
  2. at() 方法:支持数组/字符串负索引访问

    1. const arr = [1, 2, 3];
      arr.at(-1); // 3(最后一个元素)
      'hello'.at(-2); // 'l'
      
  3. Object.hasOwn() :替代 Object.prototype.hasOwnProperty.call(),更安全

    1. const obj = { a: 1 };
      Object.hasOwn(obj, 'a'); // true
      
  4. Top-level await:模块顶层可直接使用 await(无需包裹 async 函数)

    1. // module.js
      const data = await fetch('/api/data').then(res => res.json());
      export default data;
      

ES2023 (ES14)

  1. 数组方法扩展findLast()/findLastIndex()(从后往前查找)

    1. const arr = [1, 2, 3, 2];
      arr.findLast(x => x === 2); // 2(最后一个2)
      arr.findLastIndex(x => x === 2); // 3
      
  2. Array.fromAsync() :从异步可迭代对象创建数组

    1. async function test() {
        const asyncIterable = (async function* () {
          yield 1;
          yield 2;
        })();
        const arr = await Array.fromAsync(asyncIterable);
        console.log(arr); // [1, 2]
      }
      
  3. WeakMap 支持 Symbol 键:此前仅支持对象键

ES2024 (ES15)

  1. Promise.withResolvers() :简化 Promise 手动创建(替代手动声明 resolve/reject)

    1. // 旧写法
      const promise1 = new Promise((resolve, reject) => {
        // 逻辑
      });
      // 新写法
      const { promise: promise2, resolve, reject } = Promise.withResolvers();
      
  2. 正则 /v 标志(Unicode 属性转义扩展) :更精准匹配 Unicode 字符

    1. // 匹配所有中文(更精准)
      /\p{Script=Han}/v.test('中文'); // true
      
  3. Array.prototype.toReversed() / toSorted() / toSpliced() :非破坏性数组方法(原数组不变)

    1. const arr = [3, 1, 2];
      const sortedArr = arr.toSorted(); // [1, 2, 3]
      console.log(arr); // [3, 1, 2](原数组未变)
      

ES2025 (ES16) - 已定稿特性

  1. Object.groupBy() :按条件分组对象/数组(替代手动遍历分组)

    1. const arr = [1, 2, 3, 4, 5];
      const grouped = Object.groupBy(arr, num => num % 2 === 0 ? 'even' : 'odd');
      // { odd: [1, 3, 5], even: [2, 4] }
      
  2. String.prototype.isWellFormed() / toWellFormed() :处理无效 Unicode 字符

    1. const str = '\ud800'; // 无效 Unicode
      str.isWellFormed(); // false
      str.toWellFormed(); // '\ufffd'(替换为替换字符)
      

ES2026 (ES17) - 候选/提案阶段核心特性

(注:ES2026 尚未最终定稿,以下是当前进入 Stage 3+ 的核心提案)

  1. 管道运算符 |> :简化函数调用链(替代嵌套调用)

    1. // 旧写法
      const result = multiply(add(1, 2), 3); // 9
      // 新写法
      const result = 1 |> add(2) |> multiply(3); // 9
      
  2. Record / Tuple:不可变数据类型(Record 是不可变对象,Tuple 是不可变数组)

    1. const record = #{ name: 'Tom', age: 20 }; // 不可变Record
      const tuple = #[1, 2, 3]; // 不可变Tuple
      tuple.push(4); // 报错(不可变)
      
  3. do 表达式:将语句块转为表达式(可在赋值/返回中使用)

    1. const value = do {
        if (num > 10) 'big';
        else 'small';
      };
      

总结

  1. 核心演进逻辑:ES2015 奠定现代 JS 基础,后续版本以“增量更新”为主,聚焦简化开发(如 async/await、可选链)、增强安全性(如私有字段、Object.hasOwn)、提升可读性(如数字分隔符、管道运算符)、完善异步编程(如异步迭代、Promise.any)。
  2. 高频实用特性:日常开发中最常用的特性集中在 ES2015(let/const、箭头函数、解构)、ES2017(async/await)、ES2020(可选链、空值合并)、ES2022(类字段、at())。
  3. 未来趋势:ES2026 重点探索不可变数据Record/Tuple)和语法简化(管道运算符),进一步提升代码的可维护性和性能。

如何将 Shadcn/ui 默认的 primitive 组件迁移至 BaseUI

作者 乐潇游
2026年1月23日 11:23

目前,shadcn/ui 支持 radix-uibase-ui 两种 primitive 组件,根据最新生态情况,更推荐使用 base-ui 作为默认的 primitive 组件。

Avatar 组件为例:

image.png

我发现无论 Radix UI 还是切换至 Base UI ,Command 命令都是:

pnpm dlx shadcn@latest add avatar

也就是说命令都是一样的,那如何让 shadcn/ui 执行自动安装命令的时候默认选择 base-ui 而不是 radix-ui ?找了一圈文档,没发现如何配置,问 AI 给出的结果也是不可用的,重新跑了一遍创建新项目,倒是发现了一些区别,话不多说,直接看下文。

方案

components.json 文件中的 "style": "new-york"(项目原本的)改成 Base UI 支持的 style 名称(例如 "base-vega")即可。

修改完之后再去执行:pnpm dlx shadcn@latest add avatar ,再去检查文件 components/ui/avatar.tsx 开头的 import:

  • Radix 版本:import * as AvatarPrimitive from "@radix-ui/react-avatar"
  • Base UI 版本:import { Avatar } from "@base-ui/react/avatar"

只要看到 @base-ui/react,就代表成功切换过来了。

由img 标签跨域问题,而引出的浏览器跨域原理

作者 hmfy
2026年1月23日 11:21

User:

img 标签为什么不会跨域呢,本质上也是 get 请求,而只要是从浏览器发请求,只要是协议域名端口不同不是就会跨域吗

GitHub Copilot:

img 标签(以及 <script><link> 等资源标签)确实可以发起跨域 GET 请求并加载资源(如图片),而不会触发浏览器的跨域限制(CORS 错误)。这与你提到的“协议、域名、端口不同就会跨域”并不完全一致,因为跨域限制并非对所有浏览器请求都适用,而是有特定规则。让我解释一下本质原因:

1. 跨域限制的本质

  • 跨域(Cross-Origin Resource Sharing, CORS)是浏览器的一种安全机制,旨在防止恶意网站窃取用户数据。它主要针对 能读取响应内容的请求,尤其是通过 JavaScript 发起的请求(如 XMLHttpRequest 或 fetch)。
  • 如果请求的协议、域名或端口不同,浏览器会检查服务器是否返回了允许跨域的 HTTP 头(如 Access-Control-Allow-Origin)。如果没有,请求会被阻塞。
  • 但并非所有请求都会触发 CORS 检查。img 标签的请求是 GET,但它属于“简单请求”(simple request),并且浏览器对资源标签的处理方式不同。

2. 为什么 img 标签不会跨域?

  • img 标签不执行脚本,也不允许 JavaScript 直接读取响应内容
    • img 标签只是告诉浏览器加载并显示图片。它不会执行任何代码,也不会让 JavaScript 访问图片的像素数据(除非通过 Canvas API,但那需要服务器允许 CORS)。
    • 浏览器认为这种请求是“安全的”,因为它不会泄露敏感信息。即使图片来自不同域,浏览器也会加载它(前提是服务器允许公开访问)。
  • 历史原因和浏览器设计
    • 早期 Web 设计中,img、script 等标签就被允许跨域加载资源,以支持网页嵌入外部内容(如广告图片或 CDN 资源)。
    • 相比之下,AJAX 请求(通过 XMLHttpRequest 或 fetch)可以读取响应体(JSON、XML 等),这可能包含敏感数据,所以浏览器强制 CORS 检查。
  • 简单请求的例外
    • img 的 GET 请求属于“简单请求”(simple request),它不预检(preflight),浏览器直接发送请求。如果服务器返回图片数据,浏览器就显示它。
    • 如果服务器设置了 CORS 头(如 Access-Control-Allow-Origin: *),img 标签也可以正常工作,但通常不需要,因为它不读取内容。

3. 对比其他 GET 请求

  • 通过 fetch 或 XMLHttpRequest 的 GET 请求:如果跨域,会触发 CORS 检查,必须有服务器的允许头,否则报错(如 Access to XMLHttpRequest at 'https://example.com/image.jpg' from origin 'https://yourdomain.com' has been blocked by CORS policy)。
  • 通过表单提交或链接跳转的 GET 请求:这些也不触发 CORS,因为它们是导航请求,不是 AJAX。
  • img 标签的 GET 请求:浏览器允许它跨域,因为它不涉及数据读取。

4. 潜在风险和限制

  • 虽然 img 可以跨域加载,但如果图片服务器禁止访问(HTTP 403),或图片是私有的,浏览器仍会失败。
  • 如果你想在 JavaScript 中读取图片数据(例如通过 Canvas),仍然需要 CORS 头,因为那涉及跨域数据访问。
  • 对于现代 Web,推荐服务器配置 CORS 以控制访问。

User:

那请总结一下哪些情况下会跨域,哪些常见情况不会,并说明原因,另外你提到 preflight 请详细说一下

GitHub Copilot:

哪些情况下会跨域(触发 CORS 检查)

跨域(CORS)限制主要针对浏览器发起的请求,如果请求涉及跨域(协议、域名或端口不同)且可能读取响应内容,浏览器会强制检查服务器的 CORS 头(如 Access-Control-Allow-Origin)。如果服务器不允许,请求会被阻塞。以下是常见会触发跨域的情况:

  1. AJAX 请求(XMLHttpRequest 或 fetch)

    • 原因:这些请求允许 JavaScript 读取响应体(JSON、XML 等),可能泄露敏感数据。浏览器强制 CORS 检查以保护用户隐私和安全。
    • 示例:从 https://yourdomain.com 用 fetch 请求 https://api.example.com/data。如果服务器没设置允许头,会报 CORS 错误。
    • 条件:请求方法不是简单请求(见下文),或包含自定义头、特定 Content-Type 等。
  2. 复杂请求(Non-Simple Requests)

    • 原因:这些请求可能修改服务器状态或发送复杂数据,浏览器需要预先确认权限。
    • 示例:使用 PUT、DELETE 等方法,或设置自定义头(如 Authorization)。
  3. WebSocket 连接

    • 原因:WebSocket 允许双向通信,浏览器检查是否允许跨域握手。
    • 示例:从不同域发起 WebSocket 连接,如果服务器不支持,会失败。
  4. 通过 Canvas 或其他 API 读取跨域资源数据

    • 原因:即使 img 标签加载了图片,如果 JavaScript 试图通过 Canvas API 读取像素数据,仍需 CORS 头。
    • 示例canvas.getImageData() 从跨域 img 读取数据,会报错,除非服务器允许。

哪些常见情况不会跨域

以下情况不会触发 CORS 检查,因为它们不涉及读取敏感响应内容,或是浏览器历史兼容性设计:

  1. 资源标签(img、script、link、iframe 等)

    • 原因:这些标签只是加载资源(如图片、脚本、样式),不执行代码或读取数据。浏览器允许跨域加载以支持网页嵌入外部内容(如 CDN 图片)。JavaScript 无法直接访问响应内容。
    • 示例<img src="https://cdn.example.com/image.jpg"> 可以正常加载,即使跨域。
  2. 表单提交(GET/POST)

    • 原因:表单提交是导航请求(页面跳转),不涉及 JavaScript 读取响应。浏览器不检查 CORS,因为它是用户主动行为。
    • 示例<form action="https://api.example.com/submit" method="post"> 提交到不同域,不会跨域。
  3. 链接跳转(a 标签)

    • 原因:类似表单,是导航行为,不读取数据。
    • 示例<a href="https://example.com"> 跳转到不同域。
  4. 同域请求

    • 原因:协议、域名、端口相同,无需检查。
    • 示例:从 https://yourdomain.com 请求 https://yourdomain.com/api
  5. 嵌入式资源(如字体、视频)

    • 原因:类似 img,浏览器允许加载,但如果需要读取(如通过 FontFace API),可能需 CORS。
    • 示例<video src="https://cdn.example.com/video.mp4">

详细说明 Preflight(预检请求)

Preflight 是 CORS 机制的一部分,用于处理“复杂请求”(non-simple requests)。浏览器在发送实际请求前,先发送一个预检请求来确认服务器是否允许该跨域操作。

为什么需要 Preflight?

  • 简单请求(simple requests)直接发送,无需预检。
  • 复杂请求可能有副作用(如修改数据),浏览器需要先“询问”服务器,避免意外操作。

Preflight 的过程

  1. 触发条件:请求不是简单请求时,浏览器自动发送 OPTIONS 请求(预检)。

    • 简单请求的标准(无需预检):
      • 方法:GET、HEAD、POST。
      • 头:只允许简单头(如 Accept、Accept-Language、Content-Language)。
      • Content-Type:只允许 application/x-www-form-urlencodedmultipart/form-datatext/plain
      • 无自定义头、无凭据(credentials)。
    • 复杂请求的示例:使用 PUT 方法、设置 Authorization 头,或 Content-Type 为 application/json
  2. 预检请求详情

    • 方法:OPTIONS。
    • :包括 Origin(请求来源)、Access-Control-Request-Method(实际请求方法)、Access-Control-Request-Headers(自定义头)。
    • 示例
      OPTIONS /api/data HTTP/1.1
      Host: api.example.com
      Origin: https://yourdomain.com
      Access-Control-Request-Method: PUT
      Access-Control-Request-Headers: Authorization, Content-Type
      
  3. 服务器响应

    • 必须返回允许头,如:
      • Access-Control-Allow-Origin: https://yourdomain.com*
      • Access-Control-Allow-Methods: PUT, GET
      • Access-Control-Allow-Headers: Authorization, Content-Type
      • Access-Control-Max-Age: 86400(缓存预检结果,单位秒)。
    • 如果服务器拒绝,实际请求不会发送。
  4. 实际请求

    • 如果预检通过,浏览器发送实际请求,并检查响应头。

注意

  • Preflight 只针对复杂请求,简单请求跳过。
  • 预检失败不会影响页面加载,但会阻止 AJAX 请求。
  • 服务器(如 Node.js/Express)需配置中间件处理 OPTIONS 请求。

如果你的项目涉及 CORS 配置(如在 Vue.js 中),可以分享代码,我可以帮你优化!3. 服务器响应

  • 必须返回允许头,如:
    • Access-Control-Allow-Origin: https://yourdomain.com*
    • Access-Control-Allow-Methods: PUT, GET
    • Access-Control-Allow-Headers: Authorization, Content-Type
    • Access-Control-Max-Age: 86400(缓存预检结果,单位秒)。
  • 如果服务器拒绝,实际请求不会发送。
  1. 实际请求
    • 如果预检通过,浏览器发送实际请求,并检查响应头。

注意

  • Preflight 只针对复杂请求,简单请求跳过。
  • 预检失败不会影响页面加载,但会阻止 AJAX 请求。
  • 服务器(如 Node.js/Express)需配置中间件处理 OPTIONS 请求。

User:

你提到 submit 是 用户主动行为 ,那如果通过定时脚本去触发带有 submit 按钮的点击事件呢。

GitHub Copilot:

脚本触发表单提交的跨域行为

如果你通过定时脚本(例如 JavaScript 的 setTimeoutsetInterval)去触发带有 submit 按钮的点击事件(如 document.getElementById('submitBtn').click()),它仍然不会触发跨域检查。原因如下:

原因分析

  • 表单提交的本质:无论手动点击还是脚本触发,表单提交都是“导航请求”(navigation request),浏览器将其视为用户发起的页面跳转或数据发送行为。它不涉及 JavaScript 读取响应内容(response body),所以浏览器不强制 CORS 检查。
    • 脚本触发只是模拟用户行为,不会改变请求类型。浏览器允许这种自动化,因为它不泄露数据。
  • 对比 AJAX 请求:如果脚本用 fetchXMLHttpRequest 来发送表单数据(即使是 POST),那就是 AJAX 请求,会触发 CORS 检查。因为它允许 JavaScript 处理响应。
  • 历史和安全设计:早期 Web 设计中,表单提交就被允许跨域,以支持多站点交互(如登录表单)。脚本触发被视为扩展的用户行为,不受 CORS 限制。

示例

  • 脚本触发表单(不会跨域):

    setTimeout(() => {
      document.querySelector('form').submit();  // 模拟提交到 https://api.example.com/submit
    }, 1000);
    
    • 浏览器会发送请求并跳转页面,无 CORS 错误。
  • 用 fetch 模拟(会跨域):

    fetch('https://api.example.com/submit', {
      method: 'POST',
      body: new FormData(document.querySelector('form'))
    });
    
    • 如果跨域且服务器无 CORS 头,会报错。

潜在注意点

  • 自动化检测:一些网站可能检测脚本触发(如检查事件来源),并阻止它(非 CORS 问题,而是反爬虫机制)。
  • 安全风险:脚本触发可能被滥用(如 CSRF 攻击),但 CORS 不直接相关。如果你担心跨域数据安全,建议服务器端验证(如 CSRF token)。
  • 现代浏览器:行为一致,但如果表单目标是 iframe 或新窗口,规则类似。

User:

意思是,如果该请求结果,前端不可控不可获取不可读,则认为没风险,允许跨域,一旦某个请求得到的结果前端可以获取可以操作,就认为不安全,就有跨域限制,我的理解准确吗

GitHub Copilot:

对 CORS 理解的准确性分析

你的理解大致准确,但需要一些澄清和细节补充。CORS 的核心确实是浏览器为了防止恶意脚本窃取或操作跨域数据而设计的“安全网”。关键在于:如果请求的结果(响应内容)能被前端 JavaScript 读取、操作或控制,就认为有风险,需要 CORS 检查;否则,允许跨域。以下是详细解释:

准确的部分

  • 前端不可控/不可获取/不可读 → 允许跨域

    • 这些请求不让 JavaScript 访问响应体(response body),所以浏览器认为“没风险”。例如:
      • img、script、link 等标签:加载资源,但 JS 无法直接读取内容(script 会执行代码,但不读取数据)。允许跨域,以支持网页嵌入。
      • 表单提交或链接跳转:请求后页面跳转,JS 无法读取响应。允许跨域,因为它是导航行为。
    • 原因:浏览器假设这些请求是“被动”的,不涉及数据泄露。
  • 前端可以获取/操作结果 → 有跨域限制

    • AJAX 请求(fetch/XMLHttpRequest)允许 JS 读取响应(如 JSON 数据),可能泄露敏感信息。所以强制 CORS 检查:服务器必须明确允许(通过头如 Access-Control-Allow-Origin)。
    • 原因:恶意网站可能用 JS 从其他域窃取用户数据(如银行 API)。

需要澄清的细节和例外

  • 不是绝对的“不可读”就安全

    • 即使资源标签加载了内容,如果 JS 试图间接读取,仍可能触发 CORS。例如:
      • img 加载跨域图片,JS 不能直接读像素。但如果用 canvas.drawImage() 然后 canvas.getImageData() 读取像素数据,会报 CORS 错误(除非服务器允许)。
      • 类似地,字体或视频资源如果 JS 通过 API(如 FontFace)操作,也需 CORS。
    • 为什么?:浏览器区分“加载”和“读取”。加载允许,读取需要权限。
  • 历史和设计因素

    • CORS 不是一开始就有的;早期 Web 允许一切跨域,后来 AJAX 普及才引入限制。资源标签因兼容性被豁免。
    • 简单请求 vs 复杂请求:即使是 AJAX,如果是简单请求(GET/POST,无自定义头),浏览器直接发送;复杂请求需预检(OPTIONS)。
  • 其他情况

    • 同域请求:无风险,无需检查。
    • WebSocket 或其他协议:类似 AJAX,需要检查。
    • 服务器端代理:前端请求同域服务器,后者转发跨域请求,可绕过 CORS(但不推荐滥用)。

总结

你的理解抓住了 CORS 的本质:保护数据读取。但实际规则更细致,取决于请求类型和 JS 的访问能力。如果请求允许 JS 控制或读取结果,就有风险;否则,豁免。现代 Web 推荐服务器配置 CORS 以精细控制。

User:

GET/POST,无自定义头 就是简单请求吗,非这种情况就是复杂请求是吗,另外,如果是同域复杂请求是不是也不需要预检(因为同域不存在跨域问题),预检仅仅是为了判断是否允许跨域吗

GitHub Copilot:

CORS 简单请求 vs 复杂请求的详细说明

你的理解基本准确,但需要一些细节澄清。以下基于 W3C CORS 规范回答你的问题:

1. GET/POST,无自定义头,就是简单请求吗?

  • 不完全是。简单请求(Simple Request)有严格条件,必须同时满足以下所有:
    • 方法:只能是 GET、HEAD 或 POST。
    • 请求头:只能使用 CORS 安全列表的头(不包括自定义头)。安全头包括:Accept, Accept-Language, Content-Language, Content-Type, DPR, Downlink, Save-Data, Viewport-Width, Width。
    • Content-Type:如果有,只能是 application/x-www-form-urlencoded、multipart/form-data 或 text/plain。
    • 无凭据:不设置 credentials(如 cookies,除非服务器允许)。
  • 示例
    • 简单:fetch('https://api.com', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } })
    • 非简单:添加自定义头(如 Authorization)或用 PUT 方法。
  • 原因:这些条件确保请求“安全”,不会意外修改服务器状态或发送复杂数据。

2. 非这种情况就是复杂请求吗?

  • 是的。如果不满足简单请求的任一条件,就是复杂请求(Non-Simple Request)。复杂请求需要浏览器先发送预检请求(OPTIONS)来检查权限。
  • 示例
    • 用 PUT/DELETE/PATCH 方法。
    • 设置自定义头(如 X-Custom-Header)。
    • Content-Type 为 application/json。
    • 包含凭据(credentials: 'include')。

3. 同域复杂请求是不是也不需要预检?

  • 是的。同域请求(协议、域名、端口相同)永远不会发送预检,即使是复杂请求。因为同域不存在跨域问题,浏览器直接发送实际请求,无需检查 CORS 头。
  • 原因:预检是为了跨域安全。同域时,浏览器信任本地服务器,无风险。
  • 示例:在 https://yourdomain.com 上用 PUT 方法请求 https://yourdomain.com/api,直接发送,无 OPTIONS。

4. 预检仅仅是为了判断是否允许跨域吗?

  • 是的。预检(Preflight)的唯一目的是在发送实际跨域请求前,确认服务器是否允许该操作。它通过 OPTIONS 请求询问服务器的 CORS 策略。
  • 过程
    • 浏览器发送 OPTIONS 请求,包含 OriginAccess-Control-Request-Method 等头。
    • 服务器响应允许头(如 Access-Control-Allow-Origin)。
    • 如果通过,才发送实际请求。
  • 注意:预检只针对跨域复杂请求。同域或简单请求跳过。

总结

  • 简单请求:满足特定条件,无预检。
  • 复杂请求:不满足条件,跨域时需预检,同域时直接发送。
  • 预检是跨域保护机制的核心。

User:

Preflight 时的请求是浏览器自行发起的并且不受跨域限制对吗,并且只会带一些信息到服务器用于询问服务器是否允许是吗,那服务器如何响应是通过如何响应是不通过呢,以 node.js 为例。给我核心伪代码

GitHub Copilot:

Preflight 请求的机制和服务器响应

1. Preflight 请求是浏览器自行发起的并且不受跨域限制对吗?

  • 是的。Preflight 是浏览器自动发起的 OPTIONS 请求,用于检查跨域权限。它不受 CORS 限制,因为它是“询问”机制的一部分——浏览器假设服务器会诚实地响应。如果服务器不支持 CORS,实际请求会被阻塞,但预检本身不会被跨域阻止(除非服务器完全拒绝 OPTIONS)。

2. 并且只会带一些信息到服务器用于询问服务器是否允许是吗?

  • 是的。预检请求只携带必要信息,不会发送实际数据。它用于询问服务器是否允许后续的实际请求。关键头包括:
    • Origin:请求来源域。
    • Access-Control-Request-Method:实际请求的方法(如 PUT)。
    • Access-Control-Request-Headers:实际请求的自定义头。
    • 其他:如 Access-Control-Request-Private-Network(私有网络检查)。

3. 服务器如何响应是通过还是不通过?以 Node.js 为例,给核心伪代码

  • 通过:服务器返回 HTTP 200 状态码,并包含允许的 CORS 头。浏览器检查这些头,如果匹配,则发送实际请求。
  • 不通过:服务器返回错误状态(如 403)或不包含必要头。浏览器不会发送实际请求,并报 CORS 错误。
  • 核心头
    • Access-Control-Allow-Origin:允许的来源(* 或具体域)。
    • Access-Control-Allow-Methods:允许的方法(如 PUT, GET)。
    • Access-Control-Allow-Headers:允许的头。
    • Access-Control-Max-Age:预检缓存时间(秒)。
    • 可选:Access-Control-Allow-Credentials(允许凭据)。
Node.js 核心伪代码(使用 Express)
const express = require('express');
const app = express();

// 处理预检请求(OPTIONS)
app.options('/api/data', (req, res) => {
  // 检查 Origin、方法等(可选:添加自定义逻辑)
  const allowedOrigins = ['https://yourdomain.com'];
  const origin = req.headers.origin;

  if (allowedOrigins.includes(origin)) {
    // 通过:设置允许头
    res.set({
      'Access-Control-Allow-Origin': origin,  // 或 '*' 如果允许所有
      'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
      'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      'Access-Control-Max-Age': 86400  // 缓存 1 天
    });
    res.status(200).end();  // 空响应体
  } else {
    // 不通过:返回错误
    res.status(403).end();
  }
});

// 实际请求处理(假设是 PUT)
app.put('/api/data', (req, res) => {
  // 正常业务逻辑
  res.json({ message: 'Data updated' });
});

app.listen(3000);
  • 解释:预检时,服务器检查并响应头。如果通过,浏览器发送 PUT 请求;否则,阻止。
  • 注意:在生产中,使用中间件如 cors 包简化配置。实际请求也需设置 Access-Control-Allow-Origin

User:

cors 中间件的核心逻辑实际上就是给所有的请求加一个 opstions 响应的中间件对吗

GitHub Copilot:

CORS 中间件的理解

你的理解部分正确,但不完全准确。CORS 中间件(如 Express 的 cors 包)的核心逻辑不仅仅是“给所有请求加一个 OPTIONS 响应”,而是自动处理 CORS 头和预检请求。它为所有匹配的请求(包括预检和实际请求)添加必要的 CORS 头,并专门响应 OPTIONS 请求。以下是详细解释:

核心逻辑

  • 自动响应预检:当浏览器发送 OPTIONS 请求时,中间件检查并返回允许头(如 Access-Control-Allow-Origin),无需你手动写 OPTIONS 路由。
  • 为实际请求添加头:对于非 OPTIONS 请求(GET/POST 等),中间件添加 CORS 头,确保响应能被跨域访问。
  • 配置灵活:你可以指定允许的域、方法、头等。它不是“加 OPTIONS 响应”,而是统一管理 CORS 策略。
  • 为什么不是单纯的 OPTIONS 中间件?因为它处理整个 CORS 流程,包括缓存(Access-Control-Max-Age)和凭据。

以 Express 的 cors 中间件为例(核心伪代码模拟)

// 模拟 cors 中间件的简化逻辑
function corsMiddleware(options = {}) {
  const {
    origin = '*',  // 默认允许所有域
    methods = 'GET,HEAD,PUT,PATCH,POST,DELETE',
    allowedHeaders = '*',
    credentials = false
  } = options;

  return (req, res, next) => {
    // 设置通用头(适用于所有请求)
    res.set({
      'Access-Control-Allow-Origin': origin,
      'Access-Control-Allow-Methods': methods,
      'Access-Control-Allow-Headers': allowedHeaders,
      'Access-Control-Max-Age': 86400  // 缓存预检
    });
    if (credentials) {
      res.set('Access-Control-Allow-Credentials', 'true');
    }

    // 如果是 OPTIONS 请求(预检),直接响应,无需继续
    if (req.method === 'OPTIONS') {
      res.status(200).end();
      return;
    }

    // 否则,继续到下一个中间件/路由
    next();
  };
}

// 使用示例
app.use(corsMiddleware({ origin: 'https://yourdomain.com' }));
app.get('/api', (req, res) => res.json({ data: 'ok' }));
  • 解释:中间件拦截所有请求,添加头。如果是 OPTIONS,直接结束响应;否则,传递给路由。实际项目中,用 npm install corsapp.use(cors()) 即可。

User:

那我有一个问题,上一个问题你给我的伪代码表明,只要给每个请求 url 的 options 请求都加 res.set({ 'Access-Control-Allow-Origin': origin, // 或 '*' 如果允许所有 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': 86400 // 缓存 1 天 });并响应 200,那么对应的这个请求 url 就能够被允许跨域,因为在 options 中已经设置了这个 url 的规则,因此当这个 url 的 options 通过以后,这个 url 的复杂请求应该不需要再详情允许跨域的响应头了吧。

GitHub Copilot:

关于预检和实际请求的 CORS 头设置

你的理解有误。预检(OPTIONS)通过后,实际请求的响应仍然需要包含 CORS 头**。仅仅在 OPTIONS 中设置头是不够的**。以下是详细解释和修正:

为什么实际请求也需要 CORS 头?

  • 预检的目的:浏览器通过 OPTIONS 请求确认“是否允许发送这个跨域请求”。如果服务器响应允许头,浏览器才会发送实际请求。
  • 实际请求的检查:浏览器发送实际请求后,会检查响应中的 CORS 头(如 Access-Control-Allow-Origin)。如果响应没有这些头,浏览器会报 CORS 错误,即使预检通过了。
  • 原因:预检是“询问权限”,但实际响应必须“证明允许”。这是为了防止服务器在预检后改变策略,或确保响应安全。
  • 示例
    • 预检:OPTIONS 请求 → 服务器响应允许头 → 浏览器认为“可以发送”。
    • 实际请求:PUT 请求 → 服务器响应数据 + 允许头 → 浏览器允许前端读取响应。

修正你的理解

  • 在 OPTIONS 中设置头只是处理预检。
  • 实际请求的路由也必须设置相同的头(或通过中间件统一设置)。
  • 如果实际请求的响应缺少头,前端会报错,如 Access to fetch at 'https://api.com' from origin 'https://yourdomain.com' has been blocked by CORS policy

更新伪代码(确保实际请求也有头)

在之前的伪代码基础上,中间件需要为所有响应添加头,包括实际请求。以下是修正版:

function corsMiddleware(options = {}) {
  const {
    origin = '*',
    methods = 'GET,HEAD,PUT,PATCH,POST,DELETE',
    allowedHeaders = '*',
    credentials = false
  } = options;

  return (req, res, next) => {
    // 为所有请求设置 CORS 头(包括预检和实际请求)
    res.set({
      'Access-Control-Allow-Origin': origin,
      'Access-Control-Allow-Methods': methods,
      'Access-Control-Allow-Headers': allowedHeaders,
      'Access-Control-Max-Age': 86400
    });
    if (credentials) {
      res.set('Access-Control-Allow-Credentials', 'true');
    }

    // 如果是 OPTIONS(预检),直接响应 200
    if (req.method === 'OPTIONS') {
      res.status(200).end();
      return;
    }

    // 否则,继续到路由(路由的响应也会继承这些头)
    next();
  };
}

// 使用示例
app.use(corsMiddleware({ origin: 'https://yourdomain.com' }));

// 实际路由:响应会自动有 CORS 头
app.put('/api/data', (req, res) => {
  res.json({ message: 'Updated' });  // 响应包含 CORS 头
});
  • 关键:中间件在 next() 前设置头,所以所有响应(包括路由的)都会有头。Express 的 cors 包就是这样工作的。
  • 测试:用浏览器开发者工具检查网络请求,实际请求的响应头必须有 Access-Control-Allow-Origin

uni-app APP打开手机外面的google地图APP

作者 sosojie
2026年1月23日 11:20

记录一下,在开发APP功能唤醒APP外部的google地图APP,并且把数据回显上去,包括了安卓和ios写法

const app = getApp()

/**
 * 打开谷歌地图
 * @param {*} latitude // 纬度
 * @param {*} longitude // 经度
 * @param {*} address // 详细地址
 */
export const openGoogleMap = (latitude, longitude, address) => {
  if (plus.os.name === 'Android') {
    let url = `google.navigation:q=${latitude},${longitude}&zoom=15`

    plus.runtime.openURL(url, (err) => {
      console.log("🚀 ~ Android plus.runtime.openURL ~ err:", err)
      uni.showModal({
        title: app.globalData.$lang('common.Tips'),
        content: app.globalData.$lang('common.setupGoogleMap'),
        confirmText: app.globalData.$lang('common.setupBtnText'),
        cancelText: app.globalData.$lang('common.cancelBtnText'),
        success: (res) => {
          if (res.confirm) {
            plus.runtime.openURL('market://details?id=com.google.android.apps.maps');
          }
        }
      });
    });
  } else {
    // iOS
    let url = `comgooglemaps://?q=${latitude},${longitude}(${address})&zoom=15&directionsmode=driving`

    plus.runtime.launchApplication({ action: url }, (err) => {
      console.error("🚀 ~ IOS plus.runtime.launchApplication ~ err:", err)
      uni.showModal({
        title: app.globalData.$lang('common.Tips'),
        content: app.globalData.$lang('common.setupGoogleMap'),
        confirmText: app.globalData.$lang('common.setupBtnText'),
        cancelText: app.globalData.$lang('common.cancelBtnText'),
        success: (res) => {
          if (res.confirm) {
            plus.runtime.openURL(`itms-apps://itunes.apple.com/app/id585027354`);
          }
        }
      });
    });
  }
}

【节点】[BitangentVector节点]原理解析与实际应用

作者 SmalBox
2026年1月23日 11:17

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity URP Shader Graph中,BitangentVector节点是一个功能强大但常被忽视的节点,它为着色器编程提供了访问网格几何数据的重要能力。理解并正确使用这个节点对于创建高质量的材质效果至关重要,特别是在处理法线贴图、各向异性光照和高级表面渲染时。

BitangentVector节点概述

BitangentVector节点允许着色器访问网格的副切线矢量数据,这是计算机图形学中描述表面方向的关键几何信息之一。在三维建模和渲染中,每个顶点通常包含位置、法线、切线和副切线四个基本矢量,它们共同构成了描述表面局部方向的坐标系。

副切线矢量(有时称为双切线或次法线)与法线矢量和切线矢量相互垂直,形成了所谓的切线空间基向量。这个局部坐标系对于许多渲染技术至关重要,特别是那些涉及表面细节和光照计算的效果。

节点的基本功能

BitangentVector节点根据着色器当前执行的阶段(顶点着色器或片元着色器)提供相应的副切线矢量数据。在顶点着色器阶段,它提供顶点的副切线矢量;在片元着色器阶段,它提供经过插值的片元副切线矢量。

节点的核心价值在于它能够将副切线矢量转换到不同的坐标空间中,这使得开发者可以灵活地在各种空间中进行计算,满足不同的渲染需求。

在渲染管线中的作用

在现代渲染管线中,副切线矢量的作用不可小觑:

  • 法线贴图转换:将切线空间中的法线贴图转换到世界空间或其他空间
  • 各向异性光照:模拟具有方向性反射特性的材料,如拉丝金属、头发等
  • 切线空间计算:构建完整的切线空间坐标系用于各种表面相关计算
  • 高级材质效果:创建复杂的表面响应,如各向异性高光、 brushed金属效果等

端口详解

BitangentVector节点的输出端口是其数据流的核心接口,理解这个端口的特性和用法是有效使用该节点的前提。

输出端口特性

输出端口标记为"Out",提供三维矢量数据,代表了网格顶点或片元的副切线矢量。这个矢量的具体含义和数值取决于节点的配置和使用上下文。

  • 数据类型:Vector 3
  • 方向:输出
  • 绑定:无(表示这是一个独立的数据源,不依赖于其他节点的输入)

输出数据的几何意义

副切线矢量在几何上具有明确的定义和计算方式。在标准的顶点数据中,副切线矢量通常通过法线和切线的叉积计算得出:

bitangent = cross(normal, tangent) * tangent.w

这里的tangent.w是一个符号因子,通常为±1,用于处理镜像UV等情况。理解这个计算关系有助于在需要时手动重建副切线矢量,或在没有副切线数据的模型上模拟相关效果。

数据流与精度考量

当BitangentVector节点在顶点着色器阶段使用时,它直接输出顶点的副切线矢量;在片元着色器阶段使用时,输出的是经过顶点着色器输出插值后的副切线矢量。这种插值过程可能会导致矢量的长度发生变化,不再是单位矢量,因此在许多应用中需要重新归一化。

在实际使用中,特别是在片元着色器中,经常可以看到这样的代码模式:

HLSL

float3 bitangent = normalize(BitangentVector);

这种归一化操作确保了矢量的方向性正确,同时避免了因插值引起的长度变化问题。

空间转换控件

Space下拉选单是BitangentVector节点最强大的功能之一,它允许开发者选择副切线矢量输出的坐标空间,极大地扩展了节点的应用范围。

Object空间

Object空间(也称为模型空间)是相对于模型自身原点的坐标系。在这个空间中,副切线矢量是模型网格数据的原始表示,不受模型变换(位置、旋转、缩放)的影响。

Object空间的特点:

  • 与模型本地坐标系对齐
  • 不受模型变换矩阵影响
  • 在模型变形动画中保持稳定
  • 适用于模型空间效果和某些类型的顶点动画

使用Object空间的典型场景:

  • 模型空间法线贴图处理
  • 与模型形状直接相关的顶点着色效果
  • 需要忽略模型变换的特定计算

View空间

View空间(也称为相机空间或眼空间)是以摄像机为原点的坐标系。在这个空间中,所有几何体都相对于摄像机进行定位,Z轴通常指向摄像机的观察方向。

View空间的特点:

  • 以摄像机为参考系
  • 简化了与视角相关的计算
  • 在屏幕空间效果中常用作中间步骤
  • 适用于与视角方向相关的效果

使用View空间的典型场景:

  • 视角相关的各向异性高光
  • 屏幕空间反射和折射效果
  • 需要基于视图方向的计算

World空间

World空间是场景的全局坐标系,所有对象都在这个统一的坐标系中定位。World空间中的副切线矢量已经考虑了模型的变换(位置、旋转、缩放),反映了模型在场景中的实际方向。

World空间的特点:

  • 全局统一的坐标系
  • 考虑了模型的完整变换
  • 适用于场景级别的光照和交互
  • 在多个对象间保持一致的空间参考

使用World空间的典型场景:

  • 世界空间法线计算
  • 与场景中其他对象交互的效果
  • 全局光照计算
  • 环境遮挡和反射

Tangent空间

Tangent空间是表面本身的局部坐标系,由法线、切线和副切线三个相互垂直的矢量构成。在这个空间中,法线方向总是(0,0,1),切线和副切线分别对应表面的U和V方向。

Tangent空间的特点:

  • 相对于表面方向的局部坐标系
  • 法线方向始终向上
  • 切线和副切线对应纹理UV方向
  • 独立于模型的整体方向和位置

使用Tangent空间的典型场景:

  • 法线贴图的标准空间
  • 切线空间相关的表面计算
  • 需要相对于表面方向的效果

实际应用示例

BitangentVector节点在Shader Graph中的实际应用非常广泛,下面通过几个具体示例展示其强大功能。

法线贴图处理

法线贴图是现代实时渲染中增强表面细节的关键技术,而BitangentVector节点在法线贴图处理中扮演着核心角色。

世界空间法线贴图转换

在URP Shader Graph中实现正确的法线贴图效果通常需要以下步骤:

  • 采样法线贴图纹理,获取切线空间法线
  • 使用BitangentVector节点获取世界空间副切线
  • 结合世界空间法线和切线构建TBN矩阵
  • 将切线空间法线转换到世界空间

具体节点设置:

  • 使用Texture 2D节点采样法线贴图
  • 使用BitangentVector节点,Space设置为World
  • 使用NormalVector节点,Space设置为World
  • 使用TangentVector节点,Space设置为World
  • 构建3x3矩阵并将切线空间法线转换到世界空间

这种转换确保了法线贴图能够正确响应场景光照,同时保持表面的视觉细节。

各向异性光照模拟

各向异性表面(如拉丝金属、CD表面、头发等)在不同方向上表现出不同的反射特性,这种效果的实现严重依赖于副切线矢量。

拉丝金属效果实现

创建拉丝金属材质需要沿着副切线方向拉伸高光:

  • 使用BitangentVector获取世界空间副切线方向
  • 基于副切线方向计算各向异性高光
  • 使用噪声或纹理控制高光的强度和变化
  • 结合视角方向增强各向异性效果

关键节点配置:

  • BitangentVector节点Space设置为World
  • 使用Normalize节点确保矢量方向准确
  • 结合Dot产品计算副切线方向上的光照贡献
  • 使用Anisotropy参数控制效果强度

高级材质效果

BitangentVector节点可以用于创建各种复杂的材质表现,提升视觉质量和真实感。

毛发和纤维渲染

模拟毛发和纤维材料需要沿着生长方向控制光照响应:

  • 使用副切线方向作为毛发方向参考
  • 基于副切线计算各向异性高光和散射
  • 结合法线和切线完成完整的毛发光照模型
  • 使用多层着色模拟毛发体积感

织物材质增强

织物表面通常具有方向性结构,可以利用副切线矢量增强其视觉表现:

  • 识别织物纹理的方向性
  • 沿副切线方向应用特殊的镜面反射
  • 模拟织物纤维的光线散射特性
  • 创建velvet等特殊织物效果

性能优化与最佳实践

正确使用BitangentVector节点不仅关乎效果质量,也影响着色器性能。以下是一些重要的优化建议和最佳实践。

空间选择策略

不同的坐标空间选择对性能有直接影响:

  • Object空间:计算成本最低,但适用性有限
  • World空间:最常用,平衡了功能性和性能
  • View空间:适用于视角相关效果,性能中等
  • Tangent空间:构建完整切线空间时必要,但计算成本较高

选择原则:

  • 优先使用能满足需求的最简单空间
  • 避免不必要的空间转换
  • 在片元着色器中谨慎使用复杂空间计算

精度与质量平衡

在副切线矢量使用中需要在精度和性能之间找到平衡:

  • 在顶点着色器中计算,在片元着色器中插值:性能较好,但可能损失精度
  • 在片元着色器中直接计算:精度最高,但性能成本较高
  • 根据效果需求选择合适的计算阶段

常见问题排查

使用BitangentVector节点时可能遇到的典型问题及解决方案:

副切线方向不正确

  • 检查模型导入设置,确保生成切线数据
  • 验证UV布局,确保没有镜像或翻转
  • 检查Space设置是否符合预期用途

法线贴图效果异常

  • 确认TBN矩阵构建正确
  • 检查矢量归一化操作
  • 验证空间转换的一致性

性能问题

  • 减少不必要的空间转换
  • 在顶点着色器中进行复杂计算
  • 使用精度适当的变量类型

与其他节点的协同工作

BitangentVector节点很少单独使用,了解它与其他节点的协同工作方式对于创建复杂效果至关重要。

与法线和切线节点的配合

BitangentVector通常与Normal Vector和Tangent Vector节点一起使用,构建完整的切线空间:

  • Normal Vector提供表面法线方向
  • Tangent Vector提供表面切线方向
  • Bitangent Vector提供表面副切线方向
  • 三者共同构成TBN矩阵,用于空间转换

在自定义光照模型中的应用

在编写自定义光照函数时,BitangentVector提供了重要的几何信息:

  • 各向异性光照计算
  • 基于方向的阴影处理
  • 表面细节增强
  • 特殊材质的光照响应

与数学节点的结合

通过结合各种数学节点,可以扩展BitangentVector的应用范围:

  • 使用Dot Product计算方向相关性
  • 使用Cross Product验证或重建坐标系
  • 使用Transform节点进行空间转换
  • 使用Normalize节点确保矢量精度

高级技巧与创意应用

除了传统应用,BitangentVector节点还可以用于一些创新和高级的渲染技术。

动态效果创建

利用副切线方向创建各种动态表面效果:

  • 沿副切线方向流动的液体效果
  • 方向性表面变形和位移
  • 基于方向的纹理动画

非真实渲染风格

在风格化渲染中利用副切线矢量:

  • 方向性笔触效果
  • 各向异性轮廓线
  • 特定方向的色彩偏移

程序化材质生成

结合程序化噪声和纹理,利用副切线方向创建复杂的表面材质:

  • 方向性噪声图案
  • 程序化各向异性高光
  • 基于方向的材质混合

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

为什么我劝你慎用 Next.js App Router:一场架构设计的“过度狂欢”

2026年1月23日 11:15

摘要:当前端工程师们审视这两年的技术演变,会发现 Next.js 似乎把我们带进了一个精心设计的迷宫。从 Server Components 的“强制推销”,到默认缓存机制的“玄学黑箱”。本文将从技术实现与开发体验的角度,深度剖析 App Router 是如何把简单问题复杂化的。


01. “我只是想写个 Dashboard,你为什么让我学分布式系统?”

曾经的 Next.js 是“开箱即用”的代名词。 但现在的 App Router,让每一个新项目的启动都像是一场架构考试。

从“写代码”变成“写配置” 当你只是想快速搭一个后台管理系统时,你立刻会撞上第一堵墙:组件渲染边界的割裂

想引个 Ant Design 或者 Material UI 的 Button 组件? 报错:Error: createContext only works in Client Components。 原因很简单:这些 UI 库普遍使用了 Context,而 Server Components 不支持。 解决方案听起来很容易:“在文件头加 "use client"”。

但事情没那么简单。 为了能在 Server Component 的布局(Layout)里使用 Redux 或 ThemeProvider,你不能直接包一层。你必须创建一个单独的 Providers.tsx 文件,把所有 Context 逻辑塞进去,标记为 Client Component,然后再导出来给 Layout 使用。

代码结构瞬间变成了“俄罗斯套娃”。为了绕过 RSC(React Server Components)的限制,我们被迫创造了无数层仅为了“中转”的 Wrapper 组件。剥开这十层包装纸,里面可能只是为了渲染一个普通的 <div />

02. 缓存玄学:默认行为的傲慢

Next.js 在 App Router 中引入了极其激进的缓存策略。 默认情况下,fetch 请求被视为静态的,并且会永久缓存

这就导致了一个经典的“鬼故事”: 你改了数据库的配置,刷新页面,数据没变。 你重启了服务器,数据还是没变。 你甚至删了 .next 文件夹重新 build,数据可能还在(因为由上游 CDN 或 ISR 缓存了)。

为了解决这个问题,你不得不小心翼翼地在每一个 fetch 里加 { cache: 'no-store' },或者去配置那个复杂的 revalidatePathrevalidateTag开发者心智负担成倍增加。你不再是在写业务逻辑,而是在跟框架的缓存机制博弈。你感觉自己不是在写前端,而是在配置复杂的 CDN 规则。

03. RSC:杀鸡焉用牛刀?

React Server Components (RSC) 无疑是一个技术上的创新。 但它解决了谁的问题?

它解决的是像 Facebook 这样拥有亿级用户、极致追求首屏毫秒级加载、且交互极其复杂的应用的问题。 但对于 99% 的普通应用(企业官网、SaaS 后台、个人博客、内部工具),引入 RSC 带来的复杂度成本,远远超过了它节省的那几 KB 传输体积。

这就好比:你要去楼下便利店买瓶水,Next.js 给你派了一辆F1赛车。 很快,是很酷。但你需要一支专业的维修团队来维护这辆车,你需要考赛车驾照,而且这辆车还没有倒挡。

对于大多数应用来说,传统的 SPA(单页应用)或者简单的 SSR(服务端渲染)已经足够好用了。为了追求那理论上的“极致性能”,牺牲了原本流畅的开发体验(DX),这笔账真的划算吗?

04. 商业模式与技术导向的冲突

我们不得不审视 Next.js 背后的推手 Vercel 的商业模式。 作为一个商业公司,Vercel 的核心收入来源于计算资源的售卖

如果大家都写 SPA(单页应用),构建出来就是一堆静态 HTML/JS/CSS。这种静态文件,托管成本极低,甚至可以在很多平台上免费托管。 这对云厂商来说,不仅利润微薄,而且缺乏粘性。

但如果你用了 App Router,用了 Server Actions,用了 RSC。 你的每一次页面访问,每一个交互,都在消耗服务器算力。这意味着你需要 Serverless Function,需要 Edge Middleware。 而这些,正是 Vercel 这种平台的核心收费点。

Next.js 正在潜移默化地把简单的前端逻辑“后端化”。 它诱导开发者把能在浏览器里跑的代码(客户端免费算力),挪到服务器上跑(付费算力)。这在技术上或许说是“为了性能”,但在商业上,这无疑是一场精妙的布局。

05. 结语:回归简单的力量

现在的 Next.js,已经不再是一个单纯的 UI 框架了。它庞大得像个操作系统。 它有自己的编译器(Turbopack),有自己的缓存层,有自己的路由逻辑,甚至想通过 Server Actions 接管你的数据库访问。

如果你感到了疲惫,如果你发现写代码的时间里,有 80% 在处理 Hydration Error、Waterfall Request 和 Cold Start。 那么,是时候回头看看了。

Vite + React Router 依然香得一塌糊涂,简单纯粹。 HTMX + Go/Python/Rust 正在回归 HTML 的本源。 甚至 SvelteKitAstro,都比现在的 Next.js 更像“为人服务的工具”。

技术本该让开发更简单,而不是更让人头秃。 简单,才是最高的“鲁棒性”。


你被 Next.js 的报错折磨过吗?欢迎在评论区分享你的踩坑经历!

关于地图渲染加20w数据展示和地图动画怎么做

作者 sunshine_
2026年1月23日 11:14

前端性能优化实战:ECharts地图渲染12万+数据动态动画方案

本文记录了在实际项目中,使用ECharts地图组件渲染12万+设备安装数据的性能优化实战经验,包含完整的技术方案和代码实现。

项目背景

公司需要将全年设备安装量通过旗帜的形式展示在全国地图上,实现数据可视化大屏。主要技术挑战:

  • 数据量大:全年设备安装数据约20万条
  • 实时更新:通过WebSocket实时接收数据
  • 动画效果:需要展示数据逐条添加的动态效果
  • 性能要求:需要保持60fps的流畅动画

一、初始实现与性能瓶颈

1.1 基础地图配置

首先使用ECharts搭建基础地图框架:

initChart() {
    this.chart = echarts.init(this.$refs.chart);
    let option = {
        geo: {
            map: 'china',
            roam: true,
            zoom: 1.1,
            scaleLimit: { min: 1, max: 10 },
            itemStyle: {
                areaColor: 'rgba(91,97,141,.3)',
                borderColor: 'rgba(0,0,0,.2)'
            }
        },
        series: [
            // 基础地图层
            {
                type: 'map',
                map: 'china',
                itemStyle: {
                    areaColor: 'rgba(0,0,0,0)',
                    borderColor: 'rgba(255,255,255,1)',
                }
            },
            // 设备点图层
            {
                id: 'scatter',
                type: 'scatter',
                coordinateSystem: 'geo',
                data: [],
                symbol: 'image://flag.png',  // 旗帜图标
                symbolSize: [16, 22],
                animation: false  // 关闭内置动画
            }
        ]
    };
    this.chart.setOption(option);
}

1.2 动画设计

设计旗帜生长动画效果,通过随机数实现多样化的动画展示:

javascript

// 旗帜动画效果设计
const flagAnimations = [
    'scaleUp',      // 从小变大
    'fadeIn',       // 淡入
    'bounceIn',     // 弹跳进入
    'rotateIn'      // 旋转进入
];

function getRandomAnimation() {
    return flagAnimations[Math.floor(Math.random() * flagAnimations.length)];
}

1.3 遇到的性能瓶颈

当数据量达到3-5万条时,开始出现明显卡顿:

  • 动画帧率下降到30fps以下
  • 内存占用持续增长
  • 缩放平移操作卡顿
  • WebSocket数据堆积

二、分层策略优化方案

经过调研,我们采用了分层策略来优化性能,根据不同的缩放级别采用不同的渲染策略。

2.1 分层策略设计

javascript

const zoomConfigs = {
    low: {  // 低缩放级别:全国视图
        zoom: 2,
        sampleRate: 0.1,    // 10%抽样显示
        precision: 2,       // 经纬度精度:小数点后2位
        symbolSize: [8, 11] // 缩小图标
    },
    mid: {  // 中缩放级别:省级视图
        zoom: 5,
        sampleRate: 0.5,    // 50%抽样显示
        precision: 3,       // 经纬度精度:小数点后3位
        symbolSize: [12, 16]
    },
    high: { // 高缩放级别:市级视图
        zoom: 10,
        sampleRate: 1,      // 100%显示
        precision: 4,       // 经纬度精度:小数点后4位
        symbolSize: [16, 22]
    }
};

2.2 动态动画调度系统

javascript

class AnimationScheduler {
    constructor() {
        this.pendingList = [];      // 待处理数据队列
        this.allDeviceList = [];    // 所有数据存储
        this.displayList = [];      // 当前显示数据
        this.deviceSet = new Set(); // 数据去重
        this.displaySet = new Set(); // 显示去重
        this.animationTimer = null;
        
        // 帧率控制
        this.frameInterval = 50;    // 20fps
        this.batchSize = 100;       // 每批处理数量
    }
    
    // 启动动画调度
    startAnimation() {
        if (this.animationTimer) return;
        
        let lastTime = 0;
        const animate = (currentTime) => {
            if (this.pendingList.length === 0) {
                this.stopAnimation();
                return;
            }
            
            // 帧率控制
            if (currentTime - lastTime >= this.frameInterval) {
                lastTime = currentTime;
                this.processBatch();
            }
            
            this.animationTimer = requestAnimationFrame(animate);
        };
        
        this.animationTimer = requestAnimationFrame(animate);
    }
    
    // 处理一批数据
    processBatch() {
        const batch = this.pendingList.splice(0, this.batchSize);
        const config = this.getCurrentZoomConfig();
        let hasNewData = false;
        
        batch.forEach(item => {
            // 全局去重
            const globalKey = `${item.lng},${item.lat}`;
            if (this.deviceSet.has(globalKey)) return;
            this.deviceSet.add(globalKey);
            
            const point = {
                value: [item.lng, item.lat],
                createTime: item.createTime
            };
            this.allDeviceList.push(point);
            
            // 根据当前缩放级别抽样
            if (this.shouldDisplay(point, config)) {
                const displayKey = this.getDisplayKey(point, config);
                if (!this.displaySet.has(displayKey)) {
                    this.displaySet.add(displayKey);
                    this.displayList.push(point);
                    hasNewData = true;
                }
            }
        });
        
        // 批量更新图表
        if (hasNewData) {
            this.updateChart();
        }
    }
}

2.3 智能显示判断

javascript

// 根据缩放级别判断是否显示
shouldDisplay(point, config) {
    // 完全显示模式
    if (config.sampleRate >= 1) return true;
    
    // 抽样显示模式
    const displayChance = Math.random();
    return displayChance < config.sampleRate;
}

// 生成显示键(根据精度去重)
getDisplayKey(point, config) {
    const lng = point.value[0].toFixed(config.precision);
    const lat = point.value[1].toFixed(config.precision);
    return `${lng},${lat}`;
}

2.4 缩放级别变化处理

javascript

// 监听缩放变化
setupZoomListener() {
    this.chart.on('georoam', () => {
        const option = this.chart.getOption();
        if (option.geo && option.geo[0]) {
            const newZoom = option.geo[0].zoom;
            if (Math.abs(newZoom - this.currentZoom) > 0.1) {
                this.currentZoom = newZoom;
                this.handleZoomChange();
            }
        }
    });
}

// 处理缩放变化
handleZoomChange() {
    const config = this.getCurrentZoomConfig();
    
    // 只有层级变化时才重建显示数据
    if (config.level !== this.currentZoomLevel) {
        this.currentZoomLevel = config.level;
        this.rebuildDisplayList();
    }
}

// 重建显示数据
rebuildDisplayList() {
    const config = this.getCurrentZoomConfig();
    this.displayList = [];
    this.displaySet = new Set();
    
    if (config.sampleRate >= 1) {
        // 全量显示模式
        this.displayAllData(config);
    } else {
        // 抽样显示模式
        this.displaySampledData(config);
    }
    
    this.updateChart();
}

// 全量显示(高精度去重)
displayAllData(config) {
    for (const item of this.allDeviceList) {
        const key = this.getDisplayKey(item, config);
        if (!this.displaySet.has(key)) {
            this.displaySet.add(key);
            this.displayList.push(item);
        }
    }
}

// 抽样显示
displaySampledData(config) {
    const step = Math.max(1, Math.floor(1 / config.sampleRate));
    for (let i = 0; i < this.allDeviceList.length; i += step) {
        const item = this.allDeviceList[i];
        const key = this.getDisplayKey(item, config);
        if (!this.displaySet.has(key)) {
            this.displaySet.add(key);
            this.displayList.push(item);
        }
    }
}

三、其他优化技巧

3.1 内存管理优化

javascript

// 定期清理过期数据
setupMemoryManagement() {
    setInterval(() => {
        // 限制总数据量
        const maxTotal = 150000;
        if (this.allDeviceList.length > maxTotal) {
            const removeCount = this.allDeviceList.length - 120000;
            this.allDeviceList.splice(0, removeCount);
            
            // 清理相关缓存
            this.cleanCache();
            
            // 重建显示
            this.rebuildDisplayList();
        }
    }, 30000); // 每30秒检查一次
}

// WebSocket数据流控
setupWebSocketFlowControl() {
    let buffer = [];
    let processing = false;
    
    this.ws.onmessage = (event) => {
        const data = JSON.parse(event.data);
        buffer.push(...data);
        
        // 流量控制:如果缓冲过多,暂停接收
        if (buffer.length > 5000 && !processing) {
            this.ws.pause();
        }
        
        if (!processing) {
            this.processWebSocketBuffer();
        }
    };
}

3.2 性能监控

javascript

// 添加性能监控
setupPerformanceMonitor() {
    let frames = 0;
    let lastTime = performance.now();
    
    const monitor = () => {
        frames++;
        const currentTime = performance.now();
        
        if (currentTime - lastTime >= 1000) {
            const fps = Math.round(frames * 1000 / (currentTime - lastTime));
            console.log(`当前FPS: ${fps}`);
            
            // 动态调整策略
            this.adjustStrategyByFPS(fps);
            
            frames = 0;
            lastTime = currentTime;
        }
        
        requestAnimationFrame(monitor);
    };
    
    requestAnimationFrame(monitor);
}

// 根据FPS动态调整
adjustStrategyByFPS(fps) {
    if (fps < 30) {
        // 降低渲染质量
        this.frameInterval = 100; // 10fps
        this.batchSize = 50;
    } else if (fps > 50) {
        // 提高渲染质量
        this.frameInterval = 33; // 30fps
        this.batchSize = 150;
    }
}

四、效果对比

优化前:

  • 3万数据开始卡顿
  • 内存占用500MB+
  • 缩放操作延迟明显
  • 动画掉帧严重

优化后:

  • 12万数据流畅运行
  • 内存控制在200MB以内
  • 缩放操作流畅
  • 保持30fps以上动画

五、总结

通过分层策略优化,我们成功实现了:

  1. 智能显示:根据缩放级别动态调整显示策略
  2. 性能平衡:在视觉效果和性能之间找到平衡点
  3. 内存控制:有效管理大量数据的内存占用
  4. 流畅动画:保持高帧率的动画效果

这种分层策略不仅适用于地图可视化,也可以扩展到其他大规模数据可视化场景中。关键思想是:不同视角需要不同精度的数据展示


参考资料

text

这个版本优化了:
1. 增加了技术深度和实战经验分享
2. 完善了代码注释和说明
3. 添加了性能对比和总结
4. 适合掘金平台的阅读体验
5. 增加了思考题,促进互动

Unity UI事件的拓展与应用

2026年1月23日 10:59

前言

       Unity本身的UI事件监听机制没办法传递额外的数据,而我们在游戏实际开发中经常需要传递事件的业务数据,以方便业务功能的开发。例如,我们点击一个按钮,如果这个按钮的点击事件中能传递我们的自定义数据,例如枚举,那岂不是更加有效的提高开发效率和代码的可读性。下面我们来对Unity UI事件进行一个扩展,以实现这个功能。

一、UI事件数据的设计

       Unity 原本也提供了UI事件的数据结构传递,但它不满足我们的需求。我们需要在不改变它原有的传递结构上加上我们的自定义数据。我们可以定义一个类,如UIEventData。这个类有我们自定义的数据结构,它也把原本的事件数据包含进来,响应事件时传递这个类就行。这个类的代码如下:

UIEventData.cs

using UnityEngine;
using UnityEngine.UI;
using UnityEngine.EventSystems;
using System.Collections;
namespace Simple.UI
{
    public class UIEventData
    {
        public PointerEventData PointerData;
        public object OtherData;
        
    }
}

二、UI事件监听器的设计

       Unity 原本提供了各种UI事件的监听,例如OnPointerDown、OnPointerUp等。核心思想是,我们不需要改变它们的监听逻辑,只需要在它的监听逻辑之后加入我们的自定义数据,然后向上传递事件即可。要实现这个功能,我们可以定义一个UIListener,它继承自EventTrigger,代码如下:

using System;
using UnityEngine;
using UnityEngine.EventSystems;
using System.Collections.Generic;
using UnityEngine.UI;

namespace Simple.UI
{    
    /// <summary>
    /// UI事件监听器
    /// </summary>
    public class UIListener : EventTrigger
    {
        public UIEventData EventData { private set; get; }//我们自定义的事件数据
        public Action<UIEventData> OnClickDown;//自定义上向上传递的事件
        public Action<UIEventData> OnClickUp;
        public Action<UIEventData> OnClick;
        public Action<UIEventData> OnStartDrag;
        public Action<UIEventData> OnOverDrag;
        public Action<UIEventData> OnDraging;
        public Action<UIEventData> OnDroping;
        private bool _interactable = true;//是否可交互        

        //go为ui对象,如button\image等,eventData为安需要自定义的外部数据
        public static UIListener Get(GameObject go, object eventData)
        {
            UIListener listener = go.GetComponent<UIListener>();
            if (listener == null)
                listener = go.AddComponent<UIListener>();//加入监听器
            listener.EventData = new UIEventData();
            listener.EventData.OtherData = eventData;//保存自定义数据
            return listener;
        }

        public static UIListener Get(Transform t, object eventData)
        {
            return Get(t.gameObject, eventData);
        }

        public static UIListener Get(Component c, object eventData)
        {
            return Get(c.gameObject, eventData);
        }
        /// <summary>
        /// 设置是否可以交互
        /// </summary>
        /// <param name="interactable"></param>
        public void SetInteractable(bool interactable)
        {
            _interactable = interactable;
        }



        void OnDestroy()
        {
            OnClickDown = null;
            OnClickUp = null;
            OnClick = null;
            OnStartDrag = null;
            OnOverDrag = null;
            OnDraging = null;
            OnDroping = null;
            triggers.Clear();
        }
        //重写事件传递逻辑
        public override void OnPointerDown(PointerEventData eventData)
        {
            if (!_interactable)
                return;

            base.OnPointerDown(eventData);
            EventData.PointerData = eventData;//保存unity的事件数据
            OnClickDown?.Invoke(EventData);//向上传递事件         
            
        }
        
        public override void OnPointerUp(PointerEventData eventData)
        {
            if(!_interactable) return;

            base.OnPointerUp(eventData);
            EventData.PointerData = eventData;
            OnClickUp?.Invoke(EventData);            
        }
        
        public override void OnPointerClick(PointerEventData eventData)
        {
            if (!_interactable) return;

            base.OnPointerClick(eventData);
            EventData.PointerData = eventData;
            OnClick?.Invoke(EventData);
        }
        
        public override void OnBeginDrag(PointerEventData eventData)
        {
            if (!_interactable) return;

            base.OnBeginDrag(eventData);
            EventData.PointerData = eventData;
            OnStartDrag?.Invoke(EventData);

            _clickDownCount++;
        }
        
        public override void OnEndDrag(PointerEventData eventData)
        {
            if (!_interactable) return;

            base.OnEndDrag(eventData);
            EventData.PointerData = eventData;
            OnOverDrag?.Invoke(EventData);         
        }
        
        public override void OnDrag(PointerEventData eventData)
        {
            if (!_interactable) return;

            base.OnDrag(eventData);
            EventData.PointerData = eventData;
            OnDraging?.Invoke(EventData);
        }
        
        //public override void OnDrop(PointerEventData eventData)
        //{
        //    base.OnDrop(eventData);
        //    EventData.PointerData = eventData;
        //    OnDraging?.Invoke(EventData);
        //}
    }
}

三、监听器的应用

       我们以一个切换游戏语言的例子来说明拓展后的事件监听器如何应用。首先我们定义一个语言枚举,如下所示:

public enum LangTypeEnum
{
    none = 0,
    English = 1,
    ChineseSimplified = 2,
    ChineseTraditional = 3,
    French = 4,
    German = 5,
    Italian = 6,
    Japanese = 7,
    Dutch = 8,
    Spanish = 9,
    Portuguese = 10,
    Hebrew = 11,
    Russia = 12,
    Danish = 13,
    Norwegian = 14,
    Finnish = 15,
    Swedish = 16,
    Hindi = 17,
    Bengali = 18,
    Turkish = 19,
    Indonesian = 20,
    Filipino = 21,
    Thai = 22,
    Malay = 23,
    Arabic = 24,
    Vietnamese = 25,
    Ukrainian = 26,
    Korean = 27,
    Czech = 28,
    Polish = 29,
    Slovak = 30,
    Slovenian = 31,
    Hungarian = 32,
    Romanian = 33,
    Greek = 34,
    Croatian = 35,
    Bulgarian = 36,

}

       然后我们在游戏的语言设置界面上,当点击对应的语言按钮时传入相关的语言枚举,以实现游戏语言的切换,代码如下:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using Simple.UI;
using System;

namespace Gamelogic
{

    /// <summary>
    /// 设置界面绑定
    /// </summary>
    public class UIStageSettingsBinder : MonoBehaviour
    {
      
        private LangTypeEnum _curLang = LangTypeEnum.English;    
        private void Awake()
        {                    
            LoadLangItem();      
        }

        private void OnDestroy()
        {

        }
        private void LoadLangItem()
        {   
            var ems = Enum.GetNames(typeof(LangTypeEnum));
            //通过枚举实例化语言按键
            foreach (var e in ems)
            {
                var em = Enum.Parse<LangTypeEnum>(e);
                if (em == LangTypeEnum.none)
                    continue;
                var item = Pool.SpawnOut(ResConfig.Pre_UI_LangItem, true, 0);//语言按钮prefab
                item.SetParent(_langItemParent);                
                var igo = item.ActiveTransform;
                igo.localScale = Vector3.one;
                igo.localPosition = Vector3.zero;

                UIListener.Get(igo, em).OnClick = OnSelectedClick;//绑定事件和自定义数据
                UIListener.Get(igo, em).OnStartDrag = OnStartDrag;
                UIListener.Get(igo, em).OnDraging = OnDraging;
                UIListener.Get(igo, em).OnOverDrag = OnOverDrag;
                var binder = igo.GetComponent<UIStageLangItemBinder>();
                binder.SetName(e);
                binder.SetIcon($"flag_{e}");
            }
           
        }
       
        private void OnStartDrag(UIEventData ed)
        {
            
        }
        private void OnOverDrag(UIEventData ed)
        {
           
        }
        private void OnDraging(UIEventData ed)
        {
           
        }
        private void OnSelectedClick(UIEventData ed)
        {           
            var lt = (LangTypeEnum)ed.OtherData;         
            _curLang = lt;
          
            OnChangeClick(ed); //切换言语      
        }
 
        private void OnChangeClick(UIEventData ed)
        {
         
        }
      
    }
}

四、运行效果

       界面内的每个按钮对应着一个语言枚举,只要我们按下按钮,就可以把枚举值传递到相关业务层,对编码的友好度和效率的提升还是比较明显的。

多平台架构交互设计与方案:移动、PC、Pad的无缝响应式集成

作者 LeonGao
2026年1月23日 10:52

引言

在当今多设备、跨平台的数字世界中,用户期望能够在不同的设备上流畅无缝地体验应用。无论是移动端、PC端还是Pad端,每个平台的界面和交互设计都存在不同的特点和需求。因此,如何设计一个能在多个平台间自适应并提供一致体验的架构,成为了技术开发者面临的一个重要挑战。本文将深入探讨如何在多个平台(移动、PC、Pad)之间进行架构交互设计,保证响应式设计的无缝集成,并提出解决方案,帮助开发者构建跨平台的一致体验。

1. 问题定义与背景

随着移动互联网的快速发展,应用的使用场景已经不仅仅局限于单一设备,用户越来越多地在不同设备间切换。例如,同一个用户可能在出门时使用手机浏览网页,回到办公室后则使用PC端完成更复杂的操作,甚至在平板上进行更便捷的娱乐或文档处理。这种设备间的频繁切换要求开发者提供一个无缝且一致的用户体验。

主要问题包括

  • 平台间一致性:不同设备的屏幕尺寸、输入方式(触摸、鼠标、键盘)、硬件能力等差异使得跨平台设计成为一项复杂任务。
  • 响应式设计:如何在不同屏幕尺寸下,动态调整布局和功能,确保界面元素始终易于操作且可访问。
  • 架构适配:多平台架构要求对不同平台的交互设计进行适配和优化,避免重复开发和资源浪费。
  • 性能和加载速度:跨平台应用需要保证高效的性能,无论是在高性能PC端还是资源有限的移动端,都能提供流畅的体验。

因此,如何设计一个能够支持多个平台,并在不同平台间实现无缝切换的系统架构和交互设计,成为了技术团队需要重点考虑的问题。

2. 解决方案与技术实现

为了实现移动、PC、Pad之间的无缝响应式集成,开发者需要采用适应性设计、响应式布局和统一的开发架构。以下是一些具体的解决方案与技术实现方法:

2.1 响应式设计(Responsive Design)

响应式设计的核心思想是通过CSS媒体查询(Media Query)和灵活布局,动态适配不同屏幕尺寸的设备。常用的技术包括:

  • CSS Grid和Flexbox:这两种CSS布局方式可以帮助创建灵活的网格布局,使得界面元素可以根据屏幕宽度自动调整。
  • 媒体查询(Media Queries) :根据不同的设备特性(如屏幕宽度、分辨率、方向等),动态调整页面样式。

示例代码:

/* 针对大屏PC端 */
@media (min-width: 1024px) {
  .container {
    display: flex;
    flex-direction: row;
  }
}

/* 针对平板和移动设备 */
@media (max-width: 1024px) {
  .container {
    display: block;
    width: 100%;
  }
}
2.2 组件化与跨平台框架

为了在不同平台上实现统一的用户体验,采用组件化设计和跨平台开发框架至关重要。以下是一些流行的跨平台技术:

  • React Native:适用于构建移动端和PC端应用,能够共享大部分代码。
  • Flutter:Google推出的跨平台开发框架,支持iOS、Android、Web以及桌面端的应用开发,提供高度一致的用户体验。
  • Electron:用于构建跨平台桌面应用,支持Windows、macOS和Linux。

这些框架通过组件化开发,可以减少不同平台之间的重复代码,使得维护和开发变得更加高效。

2.3 设计适配与平台差异化

尽管响应式设计和组件化开发能帮助我们在不同平台上实现一致性,但平台差异依然不可忽视。不同平台的交互方式(如触摸屏和鼠标输入)和性能需求不同,因此我们需要对每个平台的特性进行适配:

  • 移动端:触摸屏操作频繁,需要关注按钮的大小、间距以及手势操作的支持。
  • PC端:需要考虑鼠标操作、键盘快捷键、窗口大小等,界面上可以容纳更多的内容。
  • Pad端:平板设备往往介于手机和PC之间,需要更加灵活的布局,适应不同的使用场景(例如横屏或竖屏)。

为此,可以使用不同平台的特定API来进一步优化交互体验。例如:

  • 移动端,可以通过使用Touch事件优化触摸操作;
  • PC端,可以通过响应鼠标和键盘事件来增强交互。
2.4 统一的后端架构与数据同步

为了确保在不同平台之间无缝切换,统一的后端架构和数据同步至关重要。开发者可以选择RESTful API或GraphQL作为前后端通信的标准协议,确保数据能够在不同设备间实时同步。

  • RESTful API:标准的HTTP接口,适用于大多数应用场景,简单易用。
  • GraphQL:一种灵活的查询语言,可以根据客户端的需要精确获取数据,避免不必要的数据加载。

数据同步方案可以利用WebSocket或者Firebase等实时数据同步工具,实现不同平台的数据实时更新。

3. 优缺点分析与实际应用建议

3.1 优点
  • 一致性体验:通过响应式设计和跨平台框架,可以实现不同平台上用户界面的统一性,使得用户无论在何种设备上使用应用,体验都十分流畅。
  • 开发效率:组件化设计和跨平台框架减少了重复开发,提高了开发效率,尤其是在多平台并行开发时。
  • 维护简化:统一的后端架构和数据同步机制,使得多平台应用的维护变得更加简洁和高效。
3.2 缺点
  • 性能问题:某些跨平台框架(如React Native或Flutter)在某些平台上可能会面临性能瓶颈,尤其是在图形密集型应用中(如游戏、3D渲染等)。
  • 平台差异:尽管响应式设计和跨平台框架能够处理大部分情况,但某些平台差异仍需特别关注,例如移动端的触摸输入与PC端的鼠标输入之间的差异。
实际应用建议:
  • 在开发应用时,应根据目标平台的特点来选择合适的框架和技术。例如,对于需要极高性能的桌面应用,使用Electron可能会面临性能问题,可以考虑专门针对PC平台开发的技术栈。
  • 在移动端和PC端的设计上,应通过用户测试来确定最佳的布局和交互方式,以确保用户在不同设备上的操作体验始终流畅。

4. 结论

在多平台架构的交互设计中,实现无缝响应式集成对于提升用户体验至关重要。通过采用响应式设计、跨平台开发框架、组件化开发以及统一的后端架构,我们能够在多个平台之间实现一致的功能和体验。然而,如何在性能和跨平台一致性之间取得平衡,仍然是开发者面临的挑战。随着技术的不断发展,未来可能会出现更多的解决方案,使得多平台开发变得更加高效和可靠。

5. 附录与参考资料

  • 相关书籍

    • 《响应式Web设计:HTML5和CSS3实战》 - Ben Frain
    • 《深入浅出React和Redux》 - 赵乾
  • 相关技术栈

  • 在线工具

Vue3中Watch与watchEffect的核心差异及适用场景是什么?

作者 kknone
2026年1月23日 10:50

一、Vue3 侦听器(Watch)核心概念与基本示例

1.1 什么是侦听器?

在Vue3中,侦听器(Watch)是一种强大的工具,用于在响应式状态变化时执行副作用操作。当你需要在数据变化时执行异步操作、更新DOM或者修改其他状态时,侦听器就派上用场了。它与计算属性不同,计算属性主要用于声明式地计算衍生值,而侦听器更适合处理副作用逻辑。

1.2 基本示例(选项式API)

export default {
  data() {
    return {
      question: '',
      answer: 'Questions usually contain a question mark. ;-)',
      loading: false
    }
  },
  watch: {
    // 每当 question 改变时,这个函数就会执行
    question(newQuestion, oldQuestion) {
      if (newQuestion.includes('?')) {
        this.getAnswer()
      }
    }
  },
  methods: {
    async getAnswer() {
      this.loading = true
      this.answer = 'Thinking...'
      try {
        const res = await fetch('https://yesno.wtf/api')
        this.answer = (await res.json()).answer
      } catch (error) {
        this.answer = 'Error! Could not reach the API. ' + error
      } finally {
        this.loading = false
      }
    }
  }
}
<template>
  <p>
    Ask a yes/no question:
    <input v-model="question" :disabled="loading" />
  </p>
  <p>{{ answer }}</p>
</template>

1.3 基本示例(组合式API)

<script setup>
import { ref, watch } from 'vue'

const question = ref('')
const answer = ref('Questions usually contain a question mark. ;-)')
const loading = ref(false)

// 可以直接侦听一个 ref
watch(question, async (newQuestion, oldQuestion) => {
  if (newQuestion.includes('?')) {
    loading.value = true
    answer.value = 'Thinking...'
    try {
      const res = await fetch('https://yesno.wtf/api')
      answer.value = (await res.json()).answer
    } catch (error) {
      answer.value = 'Error! Could not reach the API. ' + error
    } finally {
      loading.value = false
    }
  }
})
</script>

<template>
  <p>
    Ask a yes/no question:
    <input v-model="question" :disabled="loading" />
  </p>
  <p>{{ answer }}</p>
</template>

1.4 侦听数据源类型

watch的第一个参数可以是不同形式的数据源:

  • 单个ref(包括计算属性)
  • 响应式对象
  • getter函数
  • 多个数据源组成的数组
const x = ref(0)
const y = ref(0)

// 单个 ref
watch(x, (newX) => {
  console.log(`x is ${newX}`)
})

// getter 函数
watch(
  () => x.value + y.value,
  (sum) => {
    console.log(`sum of x + y is: ${sum}`)
  }
)

// 多个来源组成的数组
watch([x, () => y.value], ([newX, newY]) => {
  console.log(`x is ${newX} and y is ${newY}`)
})

二、深层侦听器

2.1 什么是深层侦听器?

watch默认是浅层的:被侦听的属性仅在被赋新值时才会触发回调函数,而嵌套属性的变化不会触发。如果想侦听所有嵌套的变更,你需要使用深层侦听器。

2.2 深层侦听器示例(选项式API)

export default {
  watch: {
    someObject: {
      handler(newValue, oldValue) {
        // 注意:在嵌套的变更中,只要没有替换对象本身,newValue和oldValue相同
      },
      deep: true
    }
  }
}

2.3 深层侦听器示例(组合式API)

const obj = reactive({ count: 0 })

// 直接传入响应式对象,隐式创建深层侦听器
watch(obj, (newValue, oldValue) => {
  // 在嵌套的属性变更时触发
  // 注意:newValue和oldValue是相等的,因为它们是同一个对象!
})

obj.count++

2.4 注意事项

深度侦听需要遍历被侦听对象中的所有嵌套属性,当用于大型数据结构时,开销很大。因此请只在必要时才使用它,并且要留意性能。在Vue 3.5+中,deep选项还可以是一个数字,表示最大遍历深度。

三、即时回调的侦听器

3.1 什么是即时回调的侦听器?

watch默认是懒执行的:仅当数据源变化时,才会执行回调。但在某些场景中,我们希望在创建侦听器时立即执行一遍回调,比如请求初始数据。

3.2 即时回调示例(选项式API)

export default {
  watch: {
    question: {
      handler(newQuestion) {
        // 在组件实例创建时会立即调用
      },
      // 强制立即执行回调
      immediate: true
    }
  }
}

3.3 即时回调示例(组合式API)

watch(
  source,
  (newValue, oldValue) => {
    // 立即执行,且当source改变时再次执行
  },
  { immediate: true }
)

四、一次性侦听器

4.1 什么是一次性侦听器?

一次性侦听器仅在被侦听源第一次变化时触发回调,之后自动停止侦听。这个特性在Vue 3.4及以上版本支持。

往期文章归档
免费好用的热门在线工具

4.2 一次性侦听器示例(选项式API)

export default {
  watch: {
    source: {
      handler(newValue, oldValue) {
        // 当source变化时,仅触发一次
      },
      once: true
    }
  }
}

4.3 一次性侦听器示例(组合式API)

watch(
  source,
  (newValue, oldValue) => {
    // 当source变化时,仅触发一次
  },
  { once: true }
)

五、watchEffect的使用

5.1 什么是watchEffect?

watchEffect允许我们自动跟踪回调的响应式依赖,它会立即执行,不需要指定immediate: true。在执行期间,它会自动追踪所有能访问到的响应式属性,每当这些属性变化时,回调会再次执行。

5.2 watchEffect示例

const todoId = ref(1)
const data = ref(null)

watchEffect(async () => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/todos/${todoId.value}`
  )
  data.value = await response.json()
})

5.3 注意事项

watchEffect仅会在其同步执行期间追踪依赖。在使用异步回调时,只有在第一个await正常工作前访问到的属性才会被追踪。

六、watch vs watchEffect

特性 watch watchEffect
追踪方式 只追踪明确侦听的数据源 在副作用发生期间自动追踪所有能访问到的响应式属性
执行时机 懒执行,仅在数据源变化时触发 立即执行,之后在依赖变化时再次执行
回调参数 可以获取新旧值 无法直接获取新旧值
使用场景 需要精确控制触发时机,或者需要获取新旧值时 多个依赖项的侦听器,或者不需要关心具体变化值时

七、副作用清理

7.1 什么是副作用清理?

当我们在侦听器中执行异步操作时,可能会出现竞态问题。例如,在请求完成之前数据源发生了变化,当上一个请求完成时,它仍会使用已经过时的数据触发回调。这时我们需要清理这些过时的副作用。

7.2 副作用清理示例

watch(id, (newId, oldId, onCleanup) => {
  const controller = new AbortController()

  fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
    // 回调逻辑
  })

  onCleanup(() => {
    // 终止过期请求
    controller.abort()
  })
})

八、回调的触发时机

8.1 默认触发时机

默认情况下,侦听器回调会在父组件更新之后、所属组件的DOM更新之前被调用。这意味着如果你尝试在侦听器回调中访问所属组件的DOM,那么DOM将处于更新前的状态。

8.2 后置刷新的侦听器

如果想在侦听器回调中能访问被Vue更新之后的所属组件的DOM,你需要指明flush: 'post'选项:

watch(source, callback, {
  flush: 'post'
})

// 或者使用watchPostEffect
import { watchPostEffect } from 'vue'

watchPostEffect(() => {
  /* 在Vue更新后执行 */
})

8.3 同步侦听器

你还可以创建一个同步触发的侦听器,它会在Vue进行任何更新之前触发:

watch(source, callback, {
  flush: 'sync'
})

// 或者使用watchSyncEffect
import { watchSyncEffect } from 'vue'

watchSyncEffect(() => {
  /* 在响应式数据变化时同步执行 */
})

九、this.$watch的使用

9.1 什么是this.$watch?

我们也可以使用组件实例的$watch方法来命令式地创建一个侦听器。这在特定条件下设置侦听器,或者只侦听响应用户交互的内容时很有用。

9.2 this.$watch示例

export default {
  created() {
    this.$watch('question', (newQuestion) => {
      // ...
    })
  }
}

十、停止侦听器

10.1 自动停止

用watch选项或者$watch()实例方法声明的侦听器,会在宿主组件卸载时自动停止。在setup()或script setup中用同步语句创建的侦听器,也会自动绑定到宿主组件实例上,并且会在宿主组件卸载时自动停止。

10.2 手动停止

在少数情况下,你需要在组件卸载之前就停止一个侦听器,这时可以调用$watch() API返回的函数:

const unwatch = this.$watch('foo', callback)

// ...当该侦听器不再需要时
unwatch()

如果用异步回调创建一个侦听器,那么它不会绑定到当前组件上,你必须手动停止它,以防内存泄漏:

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

// 这个需要手动停止
const unwatch = watchEffect(() => {})

// ...当该侦听器不再需要时
unwatch()
</script>

十一、课后Quiz

问题1:如何在Vue3中侦听响应式对象的嵌套属性变化?

答案解析: 有两种方式可以侦听响应式对象的嵌套属性变化:

  1. 使用getter函数:
watch(
  () => obj.nestedProperty,
  (newValue) => {
    console.log(newValue)
  }
)
  1. 使用深层侦听器:
watch(obj, (newValue) => {
  console.log(newValue.nestedProperty)
}, { deep: true })

注意:深层侦听器会遍历对象的所有嵌套属性,性能开销较大,建议优先使用getter函数的方式。

问题2:watch和watchEffect的主要区别是什么?

答案解析:

  • watch是懒执行的,仅在数据源变化时触发回调,而watchEffect会立即执行,之后在依赖变化时再次执行。
  • watch只追踪明确侦听的数据源,而watchEffect会自动追踪回调中访问到的所有响应式属性。
  • watch可以获取新旧值,而watchEffect无法直接获取新旧值。

问题3:如何清理侦听器中的副作用?

答案解析: 可以通过onCleanup函数来清理副作用,它作为第三个参数传递给watch回调函数,或者作为第一个参数传递给watchEffect的作用函数:

watch(id, (newId, oldId, onCleanup) => {
  const controller = new AbortController()

  fetch(`/api/${newId}`, { signal: controller.signal }).then(() => {
    // 回调逻辑
  })

  onCleanup(() => {
    // 终止过期请求
    controller.abort()
  })
})

十二、常见报错解决方案

报错1:Cannot read property 'xxx' of undefined

产生原因: 在侦听器回调中访问了未初始化的响应式属性。

解决办法: 在访问属性之前先进行判断:

watchEffect(() => {
  if (data.value) {
    console.log(data.value.xxx)
  }
})

报错2:Maximum call stack size exceeded

产生原因: 在侦听器回调中修改了被侦听的数据源,导致无限循环。

解决办法: 避免在侦听器回调中直接修改被侦听的数据源,或者使用条件判断来终止循环:

watch(count, (newCount) => {
  if (newCount < 10) {
    count.value++
  }
})

报错3:Invalid watch source: xxx

产生原因: watch的第一个参数不是有效的数据源类型,比如直接传递了响应式对象的属性值。

解决办法: 使用getter函数来返回响应式对象的属性:

// 错误写法
watch(obj.count, (count) => {
  console.log(count)
})

// 正确写法
watch(
  () => obj.count,
  (count) => {
    console.log(count)
  }
)

参考链接:

cn.vuejs.org/guide/essen…

使用 UniApp 集成 Reown 与 Wagmi:轻松实现钱包连接与智能合约交互

作者 qibmz
2026年1月23日 10:44

Wagmi

wagmi 是一个基于 React 的区块链开发工具库,兼容JavascriptVue,简化了与 EVM 兼容链的交互逻辑,内置了常见的区块链操作钩子,支持多链和钱包集成。

主要功能

  • 基于 React Hooks 的 API 设计,简化状态管理
  • 内置钱包连接(MetaMask、Coinbase Wallet 等)
  • 智能合约交互封装(读 / 写操作、事件监听)
  • 多链支持与链切换管理
  • 交易状态跟踪与错误处理

Reown

Reown使构建者能够创建安全,用户友好和富有洞察力的钱包和应用程序UX。提供所有工具,以更快地启动和更智能地扩展。

主要功能

  • 支持多种钱包的无缝连接
  • 提供标准化的连接流程
  • 支持移动端和桌面端钱包
  • 管理多个应用的连接会话
  • 提供会话状态的实时同步
  • 支持会话的持久化存储
  • 支持主流区块链网络

集成教程

1.安装(以Vue+wagmi为示例)

pnpm add @reown/appkit @reown/appkit-adapter-wagmi @tanstack/vue-query @wagmi/vue viem

2.申请ProjectId

前往Reown仪表板并立即创建一个新项目申请ProjectId

3.将配置添加到项目

在utils中添加wagmiConfig.ts配置

import type { AppKitNetwork } from '@reown/appkit/networks'
import { WagmiAdapter } from '@reown/appkit-adapter-wagmi'
import { bsc, bscTestnet } from '@reown/appkit/networks'
//填写reown仪表板申请的projectId
export const projectId = import.meta.env.VITE_PROJECT_ID
// 通过walletconnect协议连接时 钱包应用显示的dapp信息
export const metadata = {
  name: 'AppKit',
  description: 'AppKit Example',
  url: 'http://192.168.0.209:9001',
  icons: ['https://avatars.githubusercontent.com/u/179229932'],
}
//以单链为示例 正式服使用线上链  其余使用测试链
export const network = import.meta.env.MODE === 'production' ? bsc : bscTestnet 
export const networks: [AppKitNetwork, ...AppKitNetwork[]] = [network]
//适配器为wagmi
export const wagmiAdapter = new WagmiAdapter({
  networks,
  projectId,
})

在app.vue中添加reown的弹框


<script lang="ts" setup>
  import {createAppKit} from '@reown/appkit/vue'
  import {metadata, networks, projectId, wagmiAdapter} from './utils/wagmiConfig'

  const modal = createAppKit({
    adapters: [wagmiAdapter],
    networks,
    projectId,
    metadata,
// --w3m-font-family基本字体系列string
// --w3m-accent用于按钮、图标、标签等的颜色。string
// --w3m-color-mix与默认颜色混合的颜色string
// --w3m-color-mix-strength“—w3m-color-mix” 应该混合的百分比number
// --w3m-font-size-master字体的基本像素大小。string
// --w3m-border-radius-master基本边框半径(以像素为单位)。string
// --w3m-z-index模态框的 z-index。number
    themeVariables: {
      '--w3m-accent': '#00BB7F',
      '--w3m-font-size-master': '10px',
    },
  })
</script>
<template></template>

main.ts中导入


import { VueQueryPlugin } from '@tanstack/vue-query'
import { WagmiPlugin } from '@wagmi/vue'
import { wagmiAdapter } from './utils/wagmiConfig'

export function createApp() {
...
//添加这两行
  app.use(VueQueryPlugin)  
  app.use(WagmiPlugin, { config: wagmiAdapter.wagmiConfig }) //使用@wagmi/vue的usexxx可以不用传入配置
...
}

以上就完成了导入库与配置

使用示例

1.使用appkit提供的web组件

<template>
<appkit-button />
</template>

点击按钮后的效果: image.png

2.与合约交互

<script setup lang="ts"> 
import { readContract } from '@wagmi/core'
import { erc20Abi } from 'viem' 
const USDTAddress = '0x...'
//查询代币精度 
//用@wagmi/vue提供的useReadContract也类似 我这边比较喜欢用core的方式
const data = readContract(wagmiAdapter.wagmiConfig,{ abi: erc20Abi, address: USDTAddress, functionName: 'symbol' }) </script>

以上就基本完成了reown+wagmi的导入与配置

遇到的问题

1.本地运行一切正常,打包后部署到服务器插件报错undefined ..-nodexxxx 错误

解决方案

vite.config.ts的build对象中添加该段代码可解决 这个方案是ai提供的我这边是可以解决这个问题

      rollupOptions: {
        output: {
          manualChunks: (id) => {
            if (!id || typeof id !== 'string')
              return 'vendor'
              // 统一路径分隔符
            const normalizedId = id.replace(/\\/g, '/')
            if (!normalizedId.includes('node_modules'))
              return
              // 处理 @scope/package 和普通包
            const match = normalizedId.match(/node_modules\/(@[^/]+\/[^/]+|[^/]+)/)
            return match ? `vendor-${match[1].replace('@', '')}` : 'vendor'
          },
        },
      },

2.bigint报错

解决方案

vite.config.ts的build设置

  target: 'es2020',

3.部分老旧手机可能存在无法连接钱包 表现为reown提供的弹框连接时一直加载

解决方案

reown使用了Obejct.hasOwn() 部分谷歌内核低的手机会报错

image.png 可以通过导入corejs解决

pnpm add core-js

main.ts中导入(我暂时全量导入了,理论上是需要根据缺少什么去导入什么的,打包后包似乎大了1m左右)

import 'core-js/stable'

4.部分手机存在tp写入合约操作后获取hash但hash始终无法推送到区块链浏览器上

解决方案

/*
获取推荐最快的 gas 价格
@returns 返回包含 maxFeePerGas 和 maxPriorityFeePerGas 的对象
Gas 总价 = 基础费用(maxFeePerGas) + 优先级小费(maxPriorityFeePerGas)
*/
export async function getGasPrice() {
const fees = await estimateFeesPerGas(wagmiAdapter.wagmiConfig, {
chainId: networks[0].id // 替换为你实际使用的链 ID
});
return {
maxFeePerGas: fees.maxFeePerGas,
maxPriorityFeePerGas: fees.maxPriorityFeePerGas};
}

ThreeJS 着色器高级应用 - 孔明灯特效

2026年1月23日 10:27

本文档涵盖Three.js中高级着色器应用,以孔明灯特效为例,展示复杂着色器的实际应用。

最终效果如图: Title

1. 高级着色器应用概述

1.1 环境贴图与光照

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import gsap from "gsap";
import * as dat from "dat.gui";
import vertexShader from "../shaders/flylight/vertex.glsl";
import fragmentShader from "../shaders/flylight/fragment.glsl";
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";

// 初始化场景
const scene = new THREE.Scene();

// 加载HDR环境贴图
const rgbeLoader = new RGBELoader();
rgbeLoader.loadAsync("./assets/2k.hdr").then((texture) => {
  texture.mapping = THREE.EquirectangularReflectionMapping;
  scene.background = texture;  // 设置背景贴图
  scene.environment = texture;  // 设置环境贴图
});

1.2 渲染器高级设置

const renderer = new THREE.WebGLRenderer({ alpha: true });

// 启用色调映射以获得更好的视觉效果
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.2;

2. 孔明灯模型加载与着色器应用

2.1 GLTF模型加载

const gltfLoader = new GLTFLoader();
let LightBox = null;

gltfLoader.load("./assets/model/flyLight.glb", (gltf) => {
  console.log(gltf);

  // 获取孔明灯网格
  LightBox = gltf.scene.children[1];
  
  // 应用自定义着色器材质
  LightBox.material = shaderMaterial;

  // 批量创建孔明灯实例
  for (let i = 0; i < 150; i++) {
    let flyLight = gltf.scene.clone(true);  // 克隆模型
    let x = (Math.random() - 0.5) * 300;  // 随机X位置
    let z = (Math.random() - 0.5) * 300;  // 随机Z位置
    let y = Math.random() * 60 + 25;      // 随机Y位置
    
    flyLight.position.set(x, y, z);
    
    // 添加旋转动画
    gsap.to(flyLight.rotation, {
      y: 2 * Math.PI,
      duration: 10 + Math.random() * 30,  // 随机持续时间
      repeat: -1,                         // 无限重复
    });
    
    // 添加位置动画
    gsap.to(flyLight.position, {
      x: "+=" + Math.random() * 5,        // X方向随机移动
      y: "+=" + Math.random() * 20,       // Y方向随机移动
      yoyo: true,                         // 往返运动
      duration: 5 + Math.random() * 10,   // 随机持续时间
      repeat: -1,                         // 无限重复
    });
    
    scene.add(flyLight);
  }
});

3. 孔明灯顶点着色器详解

3.1 顶点着色器代码

precision lowp float;

varying vec4 vPosition;
varying vec4 gPosition;

void main(){
    vec4 modelPosition = modelMatrix * vec4( position, 1.0 );

    vPosition = modelPosition;
    gPosition = vec4( position, 1.0 );
    gl_Position =  projectionMatrix * viewMatrix * modelPosition;
}

3.2 变量传递机制

  • vPosition:传递模型空间中的位置信息
  • gPosition:传递原始几何位置信息
  • 这些变量将在片元着色器中使用

4. 孔明灯片元着色器详解

4.1 片元着色器代码

precision lowp float;
varying vec4 vPosition;
varying vec4 gPosition;

void main(){
    vec4 redColor = vec4(1,0,0,1);      // 红色
    vec4 yellowColor = vec4(1,1,0.5,1); // 黄色
    vec4 mixColor = mix(yellowColor,redColor,gPosition.y/3.0); // 基于Y坐标的颜色混合

    // 根据面朝向设置不同颜色
    if(gl_FrontFacing){
        // 正面朝向相机的像素,添加高度影响和亮度调整
        gl_FragColor = vec4(mixColor.xyz-(vPosition.y-20.0)/80.0-0.1,1);
    }else{
        // 背面像素使用基础颜色
        gl_FragColor = vec4(mixColor.xyz,1);
    }
}

4.2 颜色混合技术

// 基于Y坐标的颜色插值
vec4 mixColor = mix(yellowColor,redColor,gPosition.y/3.0);

4.3 面朝向判断

// 使用gl_FrontFacing区分正面和背面
if(gl_FrontFacing){
    // 正面:添加高度影响和亮度调整
    gl_FragColor = vec4(mixColor.xyz-(vPosition.y-20.0)/80.0-0.1,1);
}else{
    // 背面:使用基础颜色
    gl_FrontFacing = vec4(mixColor.xyz,1);
}

5. 自定义着色器材质创建

5.1 着色器材质初始化

// 创建孔明灯专用着色器材质
const shaderMaterial = new THREE.ShaderMaterial({
  vertexShader: vertexShader,      // 顶点着色器
  fragmentShader: fragmentShader,  // 片元着色器
  uniforms: {},                   // uniform变量
  side: THREE.DoubleSide,         // 双面渲染
  // transparent: true,           // 透明渲染(可选)
});

6. 相机控制与视角设置

6.1 高级相机控制

const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;           // 启用阻尼效果
controls.autoRotate = true;              // 自动旋转
controls.autoRotateSpeed = 0.1;          // 自动旋转速度
controls.maxPolarAngle = (Math.PI / 3) * 2;  // 最大极角限制
controls.minPolarAngle = (Math.PI / 3) * 2;  // 最小极角限制

6.2 相机角度限制

// 限制相機垂直旋转范围,防止过度俯仰
controls.maxPolarAngle = (Math.PI / 3) * 2;
controls.minPolarAngle = (Math.PI / 3) * 2;

7. 动画系统集成

7.1 GSAP动画控制

// 旋转动画
gsap.to(flyLight.rotation, {
  y: 2 * Math.PI,                    // 旋转一圈
  duration: 10 + Math.random() * 30, // 随机持续时间
  repeat: -1,                        // 无限重复
});

// 位置动画
gsap.to(flyLight.position, {
  x: "+=" + Math.random() * 5,       // X方向随机移动
  y: "+=" + Math.random() * 20,      // Y方向随机移动
  yoyo: true,                        // 往返运动
  duration: 5 + Math.random() * 10,  // 随机持续时间
  repeat: -1,                        // 无限重复
});

8. 性能优化策略

8.1 批量渲染优化

// 使用克隆技术批量创建对象
for (let i = 0; i < 150; i++) {
  let flyLight = gltf.scene.clone(true);
  // ...
  scene.add(flyLight);
}

8.2 减少Draw Call

  • 使用相同的着色器材质
  • 合理组织渲染批次
  • 利用实例化渲染技术

9. 着色器参数化设计

9.1 Uniform变量扩展

虽然当前示例中uniforms为空,但在实际应用中可以扩展:

const shaderMaterial = new THREE.ShaderMaterial({
  vertexShader: vertexShader,
  fragmentShader: fragmentShader,
  uniforms: {
    uTime: { value: 0 },              // 时间
    uBrightness: { value: 1.0 },      // 亮度
    uColorMix: { value: 0.5 },        // 颜色混合系数
  },
  side: THREE.DoubleSide,
});

10. 故障排除与调试

10.1 常见问题

  1. 着色器编译错误:检查GLSL语法
  2. 性能问题:简化着色器计算
  3. 光照问题:检查环境贴图设置
  4. 动画卡顿:检查动画循环和更新频率

10.2 调试技巧

  1. 逐行注释:定位问题代码
  2. 变量输出:将中间值输出到颜色
  3. 简化模型:使用简单几何体测试着色器

总结

本章展示了Three.js中高级着色器的实际应用,以孔明灯特效为例:

  1. 环境贴图和高级渲染设置
  2. GLTF模型加载和着色器应用
  3. 复杂顶点和片元着色器的编写
  4. 动画系统与着色器的集成
  5. 相机控制和性能优化
  6. 批量对象管理和渲染优化

通过这种综合应用,可以创建出具有专业品质的3D视觉效果。

听说你很熟悉 flex

作者 Carry345
2026年1月23日 10:20

听说你很熟悉 flex,看以下几种情况说一下最终渲染结果

问题一

.container {
  display:flex;
  width: 100px;
}

.item {
  flex-grow: 1;
}
.item1 {
  width: 30px;
}
.item2 {
  width: 40px;
}

问题二

.container {
  display:flex;
  width: 150px;
}

.item {
  flex-grow: 1;
  margin: 10px;
}
.item1 {
  width: 30px;
}
.item2 {
  width: 40px;
}

问题三

.container {
  display:flex;
  width: 150px;
}

.item {
  margin: 10px;
}
.item1 {
  flex-grow: 1;
  width: 30px;
}
.item2 {
  flex-grow: 3;
  width: 40px;
}

问题四

.container {
  display:flex;
  width: 280px;
}

.item {
  margin: 10px;
}
.item1 {
  flex-shrink: 1;
  width: 100px;
}
.item2 {
  flex-shrink: 1;
  width: 200px;
}

问题一答案:

问题二答案:

问题三答案

问题四答案

tip

  • flex-grow 的情况先假设全部放入,自由空间按照 flex-grow 的比例分
  • flex-shrink 的情况也是先假设全部放入,还差多少?按照 flex-shrink * flex-basis的比例匀出来

按照这个规则我们看问题三和问题四

问题三详解

先假设全部放入,还多了 40px,flex-grow 分别是 13,即分别占10px 30px

  • item1 = 30px + 10px = 40px
  • item2 = 40 + 30px = 70px
问题四详解

先假设全部放入,差了40+100+200-280=60px,flex-shrink * flex-basis的比例即100:200=1:2,分别匀出来20px 40px

  • item1: 100px - 20px = 80px
  • item2: 200px - 40px = 160px

都不是刁难的问题,平时开发中很常见,但脑子里没能获取到明确又自信的渲染结果,补缺🤌

详解AOP 架构

作者 前端付豪
2026年1月23日 10:03

后端框架多用 MVC 架构

MVC 是 Model View Controller。MVC 架构下,请求会先发送给 Controller,由它调度 Model 层的 Service 来完成业务逻辑,然后返回对应的 View

image.png

在这个流程中,Nest 还提供了 AOP (Aspect Oriented Programming)的能力,也就是面向切面编程的能力

如果想在这个调用链路里加入一些通用逻辑该怎么加呢?比如日志记录、权限控制、异常处理

image.png

横向扩展点就叫做切面,这种透明的加入一些切面逻辑的编程方式就叫做 AOP (面向切面编程)

AOP 的好处是可以把一些通用逻辑分离到切面中,保持业务逻辑的纯粹性,这样切面逻辑可以复用,还可以动态的增删

Express 的中间件的洋葱模型也是一种 AOP 的实现,因为你可以透明的在外面包一层,加入一些逻辑,内层感知不到

Nest 实现 AOP 一共有五种,包括 Middleware、Guard、Pipe、Interceptor、ExceptionFilter

中间件 Middleware

中间件是 Express 里的概念,Nest 的底层是 Express,所以自然也可以使用中间件,但是做了进一步的细分,分为了全局中间件和路由中间件

image.png

image.png

image.png

添加两个路由

image.png

可以在多个 handler 之间复用中间件的逻辑

在 handler 前后动态增加一些可复用的逻辑,就是 AOP 的切面编程的思想

除了全局中间件,Nest 还支持路由中间件

nest g middleware log --no-spec --flat

image.png

在 AppModule 里启用:

image.png

image.png

只有 aaa 的路由,中间件生效了

Guard 路由守卫

用于在调用某个 Controller 之前判断权限,返回 true 或者 false :

image.png

nest g guard login --no-spec --flat

image.png

在 AppController 里启用 image.png aaa 没有权限,返回了 403

image.png

image.png

Controller上可以直接挂上权限判断

Guard能不能全局启用 ?

image.png

image.png

还可以在 AppModule 中用

image.png

image.png

用 provider 的方式声明的 Guard 是在 IoC 容器里的,可以注入别的 provider

注入下 AppService 试试

image.png

注入的 AppService 生效 image.png

Interceptor 拦截器

在目标 Controller 方法前后加入一些逻辑

image.png

nest g interceptor time --no-spec --flat

image.png

Interceptor 要实现 NestInterceptor 接口,实现 intercept 方法,调用 next.handle() 就会调用目标 Controller,可以在之前和之后加入一些处理逻辑

image.png

image.png

interceptor 可以拿到调用的 controller 和 handler 中间件不行

image.png

Interceptor 支持每个路由单独启用,只作用于某个 handler

image.png

也可以在 controller 级别启动,作用于下面的全部 handler

image.png

image.png

也同样支持全局启用,作用于全部 controller

image.png

image.png

也可以的

image.png

Pipe 管道

对参数做一些检验和转换

image.png

nest g pipe validate --no-spec --flat

image.png

Pipe 要实现 PipeTransform 接口,实现 transform 方法,里面可以对传入的参数值 value 做参数验证,比如格式、类型是否正确,不正确就抛出异常。也可以做转换,返回转换后的值

这里的 value 就是传入的参数,如果不能转成数字,就返回参数错误,否则乘 10 再传入 handler

在 AppController 添加一个 handler,然后应用这个 pipe

image.png

image.png

image.png

Nest 内置了一些 Pipe

  • ValidationPipe
  • ParseIntPipe
  • ParseBoolPipe
  • ParseArrayPipe
  • ParseUUIDPipe
  • DefaultValuePipe
  • ParseEnumPipe
  • ParseFloatPipe
  • ParseFilePipe

也可以对 整个 Controller 都生效

image.png

也可以全局生效

image.png

image.png

ExceptionFilter 对异常处理

image.png

image.png

这里的错误 就是 它处理的

image.png

nest g filter test --no-spec --flat

image.png

实现 ExceptionFilter 接口,实现 catch 方法,就可以拦截异常了。

拦截什么异常用 @Catch 装饰器来声明,然后在 catch 方法返回对应的响应,给用户更友好的提示

image.png

image.png

Nest 内置了很多 http 相关的异常,都是 HttpException 的子类:

  • BadRequestException
  • UnauthorizedException
  • NotFoundException
  • ForbiddenException
  • NotAcceptableException
  • RequestTimeoutException
  • ConflictException
  • GoneException
  • PayloadTooLargeException
  • UnsupportedMediaTypeException
  • UnprocessableException
  • InternalServerErrorException
  • NotImplementedException
  • BadGatewayException
  • ServiceUnavailableException
  • GatewayTimeoutException

可以自定义

image.png

Nest 通过这样的方式实现了异常到响应的对应关系,代码里只要抛出不同的异常,就会返回对应的响应

同样,ExceptionFilter 也可以选择全局生效或者某个路由生效

某个 handler

image.png

整个 controller

image.png

全局

image.png

image.png

几种 AOP 机制的顺序

Middleware、Guard、Pipe、Interceptor、ExceptionFilter 都可以透明的添加某种处理逻辑到某个路由或者全部路由,这就是 AOP 的好处

它们之间的顺序关系是什么呢?

请求进入
  ↓
Middleware
  ↓
Guard
  ↓
Interceptor (before)
  ↓
Pipe
  ↓
Controller 方法
  ↓
Interceptor (after)
  ↓
Exception Filter(仅异常时)
  ↓
响应返回

image.png

❌
❌