普通视图

发现新文章,点击刷新页面。
昨天 — 2025年9月18日掘金 前端

Three.js 材质(Material)详解 —— 区别、原理、场景与示例

作者 excel
2025年9月18日 23:05

一、概念

Three.js 中,材质(Material)决定了几何体表面的外观——包括颜色、光照反应、透明度、反射率、甚至物理属性。
简单来说,几何体是形状,材质是皮肤
不同的材质会模拟不同的现实渲染效果,从低成本的“纯色贴图”,到高质量的 PBR(基于物理的渲染)


二、原理

材质的核心是 着色器(Shader) ,即 GPU 上运行的小程序。
Three.js 提供了两类材质:

  1. 内置材质:如 MeshPhongMaterialMeshStandardMaterial,直接调用,简单易用。
  2. 自定义材质:如 ShaderMaterialRawShaderMaterial,需要自己写 GLSL。

不同材质的底层实现基于不同的 光照模型

  • Lambert(漫反射) → 粗糙表面,无镜面高光。
  • Phong(冯氏模型) → 漆面、陶瓷,带镜面反射。
  • Standard / Physical(PBR 模型) → 金属、玻璃、水,逼真模拟。

三、对比

材质 是否受光照影响 主要参数 适用场景 性能开销
SpriteMaterial map, color 2D 图标、UI、特效
ShadowMaterial 特殊 opacity 阴影接收
ShaderMaterial 自定义 uniforms, vertex/fragmentShader 特效、波纹、火焰 ★★★★
RawShaderMaterial 自定义 完全自写 GLSL 高级渲染 ★★★★★
PointsMaterial size, map 粒子系统、星空、点云
MeshToonMaterial gradientMap 卡通渲染、漫画风 ★★
MeshStandardMaterial metalness, roughness, map 木材、金属、石头 ★★★
MeshPhysicalMaterial transmission, clearcoat 玻璃、水、宝石 ★★★★
MeshPhongMaterial shininess, specular 漆面、塑料 ★★
MeshNormalMaterial 无需参数 法线调试
MeshLambertMaterial color 布料、纸张
MeshMatcapMaterial matcap 纹理 ZBrush 风格快速预览
MeshDistanceMaterial 特殊 near, far 阴影、深度编码

四、实践(示例代码)

import * as THREE from 'three';

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

// 相机
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(0, 2, 10);

// 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
document.body.appendChild(renderer.domElement);

// 灯光
const light = new THREE.DirectionalLight(0xffffff, 1);
light.position.set(5, 5, 5);
light.castShadow = true;
scene.add(light);

// 地面接收阴影
const ground = new THREE.Mesh(
  new THREE.PlaneGeometry(20, 20),
  new THREE.ShadowMaterial({ opacity: 0.3 })
);
ground.rotation.x = -Math.PI / 2;
ground.receiveShadow = true;
scene.add(ground);

// 常见几何体
const geometry = new THREE.SphereGeometry(0.7, 32, 32);

// 各种材质
const materials = [
  new THREE.MeshBasicMaterial({ color: 0xff0000 }),
  new THREE.MeshLambertMaterial({ color: 0x00ff00 }),
  new THREE.MeshPhongMaterial({ color: 0x0000ff, shininess: 80 }),
  new THREE.MeshStandardMaterial({ color: 0xffff00, metalness: 0.8, roughness: 0.2 }),
  new THREE.MeshPhysicalMaterial({ color: 0x00ffff, transmission: 0.9, thickness: 0.5 }),
  new THREE.MeshToonMaterial({ color: 0xff00ff }),
  new THREE.MeshNormalMaterial(),
  new THREE.MeshMatcapMaterial({ matcap: new THREE.TextureLoader().load('matcap.png') })
];

// 逐个摆放材质球
materials.forEach((mat, i) => {
  const mesh = new THREE.Mesh(geometry, mat);
  mesh.position.x = (i - 3.5) * 2;
  mesh.castShadow = true;
  scene.add(mesh);
});

// 粒子系统
const particlesGeo = new THREE.BufferGeometry();
const count = 500;
const positions = new Float32Array(count * 3);
for (let i = 0; i < count * 3; i++) positions[i] = (Math.random() - 0.5) * 10;
particlesGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const particles = new THREE.Points(particlesGeo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.05 }));
scene.add(particles);

// 精灵
const spriteTex = new THREE.TextureLoader().load('https://threejs.org/examples/textures/sprite.png');
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: spriteTex }));
sprite.position.set(0, 3, 0);
scene.add(sprite);

// 渲染循环
function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}
animate();

五、拓展

  1. 性能优化:移动端尽量避免 PhysicalMaterial,优先 LambertMatcap
  2. 项目应用:建筑可视化用 StandardMaterial,游戏用 ToonMaterial,展示产品用 PhysicalMaterial
  3. 调试工具NormalMaterialDistanceMaterial 特别适合调试几何体和阴影效果。

六、潜在问题

  • 性能瓶颈:PBR 在低端设备上容易掉帧。
  • 阴影丢失:如果 renderer.shadowMap 没启用,ShadowMaterial 不生效。
  • 光照依赖:忘记添加光源时,LambertPhongStandard 会显示黑色。
  • 兼容性RawShaderMaterial 在不同浏览器可能表现差异。

AI 生成内容声明

本文由人工智能生成,仅供参考与学习,不构成专业建议。

抛弃自定义模态框:原生Dialog的实力

2025年9月18日 22:41

原文:Ditch Custom Modals: The Power of the Native

嘿嘿

前端周刊项目进群

自定义模态框的问题

模态窗口是现代 UI 的标准特性,但从零开始创建它们通常会导致一堆混乱的代码。我们都见过这种情况:用一堆 div 拼出来的层级,混乱的 z-index,糟糕的焦点管理,无法关闭的背景,以及缺少像 Esc 键这样的键盘快捷操作。这些问题中的每一个都可能显著降低用户体验。

幸运的是,原生的 <dialog> 元素几乎不需要多少代码,就能解决所有这些问题。它是一个专门为此任务设计的强大、语义化的 HTML 元素。

简单的 HTML 结构

你不再需要一堆纠缠不清的 div 来模拟模态框的结构。原生对话框的 HTML 既简单又清晰。

<button class="open-modal-btn">打开 Modal</button>

<dialog class="my-modal">
  <header class="modal-header">
    <h2>对话框标题</h2>
    <button class="close-modal-btn">×</button>
  </header>
  <div class="modal-body">
    <p>这是模态框的主要内容。</p>
    <p>试试按下 Tab 键,焦点会停留在对话框内部。你也可以通过 Esc 键关闭它。</p>
  </div>
  <footer class="modal-footer">
    <button class="confirm-button">确认</button>
  </footer>
</dialog>

在这个结构中,<header><body><footer> 元素仅用于样式和组织,但核心功能来自 <dialog> 标签。

核心功能:用 JavaScript 控制

我们将使用一个简单的 JavaScript 类来控制模态框的行为,使其可以在应用程序的其他部分复用。

class ModalController {
  constructor(dialogElement) {
    if (!dialogElement || dialogElement.tagName !== 'DIALOG') {
      console.error('需要一个 <dialog> 元素。');
      return;
    }
    this.modal = dialogElement;
    this.closeButton = this.modal.querySelector('.close-modal-btn');
    this.handleBackdropClick = this.handleBackdropClick.bind(this);
    this.init();
  }

  init() {
    this.closeButton?.addEventListener('click', () => this.close());
    this.modal.addEventListener('click', this.handleBackdropClick);
  }

  open() {
    this.modal.showModal();
  }

  close() {
    this.modal.close();
  }

  handleBackdropClick(event) {
    const rect = this.modal.getBoundingClientRect();
    const isClickInsideDialog = (
      rect.top <= event.clientY && event.clientY <= rect.top + rect.height &&
      rect.left <= event.clientX && event.clientX <= rect.left + rect.width
    );

    if (!isClickInsideDialog) {
      this.close();
    }
  }
}

// 使用示例:
const myModal = document.querySelector('.my-modal');
const openButton = document.querySelector('.open-modal-btn');
const modalController = new ModalController(myModal);

openButton.addEventListener('click', () => modalController.open());

代码解析:

  • dialog.showModal():这是最关键的方法。浏览器会自动将 <dialog> 元素置于页面内容的最上层,处理默认的遮罩层,并让页面背景“失效”。
  • dialog.close():该方法会简单地关闭弹窗。
  • 点击背景关闭<dialog> 元素默认没有这个特性,但实现起来很简单。我们监听对话框上的点击事件,并检查点击坐标是否在矩形区域内。如果不在,就意味着用户点击了背景,此时我们可以调用 close()

样式与动画

虽然核心功能由浏览器处理,但你可以通过 CSS 让模态框更美观。::backdrop 伪元素 是定义遮罩层样式的关键。

.my-modal {
  width: min(90vw, 500px);
  border: none;
  border-radius: 8px;
  box-shadow: 0 4px 20px rgba(0,0,0,0.2);
  padding: 0;
}

.modal-header, .modal-body, .modal-footer {
  padding: 1rem 1.5rem;
}

.modal-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  border-bottom: 1px solid #eee;
}

.close-modal-btn {
  background: none;
  border: none;
  font-size: 1.5rem;
  cursor: pointer;
}

/* 关键:使用 ::backdrop 伪元素定义遮罩层样式 */
.my-modal::backdrop {
  background-color: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(3px);
}

默认情况下,模态框会立即出现或消失,这会显得很突兀。为了解决这个问题,你可以添加简单的过渡动画,让体验更顺滑。

.my-modal {
  transition: opacity 0.3s, transform 0.3s;
}

/* 当未打开时隐藏模态框 */
.my-modal:not([open]) {
  opacity: 0;
  transform: translateY(30px);
}

.my-modal::backdrop {
  transition: backdrop-filter 0.3s, background-color 0.3s;
}

/* 当未打开时隐藏遮罩层 */
.my-modal:not([open])::backdrop {
  backdrop-filter: blur(0);
  background-color: rgba(0, 0, 0, 0);
}

然而,原生 <dialog> 元素的 close() 方法会立即从 DOM 中移除它,从而中断关闭动画。要实现完美的关闭动画,需要稍微调整一下 JavaScript。

// 在 ModalController 类中,更新 close 方法:
close() {
  this.modal.classList.add('is-closing');
  this.modal.addEventListener('animationend', () => {
    this.modal.classList.remove('is-closing');
    this.modal.close();
  }, { once: true });
}
@keyframes slide-out {
  from { opacity: 1; transform: translateY(0); }
  to { opacity: 0; transform: translateY(30px); }
}

.my-modal.is-closing {
  animation: slide-out 0.3s ease-out forwards;
}

这种方式稍微复杂一点,但能保证关闭时的动画效果无缝衔接。大多数情况下,关闭动画并非必需,但作为增强体验,它非常不错。


兼容性

原生 <dialog> 元素在几乎所有现代浏览器中都有广泛支持。需要注意的是,Safari 支持得稍晚一些,大约在 2022 年之后才出现。对于旧浏览器或特定场景,可以使用 polyfill 来提供可靠的降级支持。推荐使用 Google Chrome 官方的 dialog-polyfill,它能确保更强的兼容性。

<dialog> 元素是一个创建可访问、可维护模态组件的强大工具。通过利用它,你可以摆脱大量常见的前端烦恼,写出更简洁、更高效的代码。祝你编码愉快!

React Native DApp 开发全栈实战·从 0 到 1 系列(兑换-前端部分)

作者 木西
2025年9月18日 20:00

前言

基于《 React Native DApp 开发全栈实战·从 0 到 1 系列(兑换-合约部分)》,本文进入“前端交互”环节,用 React Native + ethers.js 完成一次“1 ETH 换 BTK”的完整用户旅程:唤起钱包 → 读取实时报价 → 一键兑换 → 余额即时刷新。

前期准备

  • hardhat启动网络节点:npx hardhat node
  • 合约编译:npx hardhat compile 生成对应的xxx.json用获取abi等相关信息
  • 合约部署:npx hardhat deploy --tags token4,MockV3Aggregator,SwapToken 获取合约地址(资产代币、喂价和兑换合约地址)
  • 节点的私钥导入钱包:用来与合约交互时支付对应的gas费

核心代码

  • 公共代码

import { abi as MockV3AggregatorABI } from '@/abi/MockV3Aggregator.json';
import { abi as MyTokenABI } from '@/abi/MyToken4.json';
import { abi as SwapTokenABI } from '@/abi/SwapToken.json';
import * as ethers from 'ethers';
  • 铸造兑换流程

    • 说明:“预言机读价 → 给 swap 合约打币 → 用户把 1 ETH 打进去 → 合约按价放 BTK → 用户余额增加” 。
const SwapTokenFn=async ()=>{
        const provider = new ethers.providers.Web3Provider(window.ethereum);
        await provider.send('eth_requestAccounts', []); // 唤起钱包
        const signer = await provider.getSigner();
        const userAddr = await signer.getAddress();//当前用户地址
        console.log('当前用户地址',userAddr)
        const MyTokenAddress="0x99bbA657f2BbC93c02D617f8bA121cB8Fc104Acf" //代币
        const MockV3AggregatorAddress="0x8f86403A4DE0BB5791fa46B8e795C547942fE4Cf"//MockV3Aggregator地址
        const SwapTokenAddress="0x9d4454B023096f34B160D6B654540c56A1F81688"//兑换地址
        
        const MyTokenContract = new ethers.Contract(MyTokenAddress, MyTokenABI, signer);
        const MockV3AggregatorContract = new ethers.Contract(MockV3AggregatorAddress, MockV3AggregatorABI, signer);
        const SwapTokenContract = new ethers.Contract(SwapTokenAddress, SwapTokenABI, signer);
        //验证
        const price = await MockV3AggregatorContract.latestAnswer();
        console.log("ETH/USD price", price.toString());   // 期望 2000 * 1e8
        const usdEquiv = await SwapTokenContract.getEthInUsd(ethers.utils.parseEther("1"));
        console.log("1 ETH 对应 USD 数量", usdEquiv.toString()); // 期望 2000
        //铸造代币
        const tx = await MyTokenContract.mint(SwapTokenAddress, ethers.utils.parseEther("4000000"));
        const rcpt = await tx.wait();
        console.log('mint status', rcpt.status);
        console.log("兑换余额",ethers.utils.formatEther(await MyTokenContract.balanceOf(SwapTokenAddress)))
        const usd = await SwapTokenContract.getEthInUsd(ethers.utils.parseEther("1")); // 2000
        const btkWei = usd.mul(1000).mul(ethers.utils.parseEther("1")); // 2000*1000*1e18
        console.log("应得 BTK(wei)", btkWei.toString());
        /* 6. 发起兑换:1 ETH -> BTK */
        const ethAmount = ethers.utils.parseEther("1");
        const txSwap = await SwapTokenContract.swap({ value: ethAmount });
        await txSwap.wait();

        /* 7. 查询结果 */
        const btkBalance = await MyTokenContract.balanceOf(userAddr);
        console.log(btkBalance)
        const dec = await MyTokenContract.decimals();
        console.log("💰 兑换后 BTK 余额:", ethers.utils.formatUnits(btkBalance,0));
    }

效果图

图1转存失败,建议直接上传图片文件图1转存失败,建议直接上传图片文件

总结

  1. 三步跑通移动端 swap

    • 本地链准备:npx hardhat node 一键启动,私钥导入 MetaMask 即可秒连。
    • 合约地址注入:通过 npx hardhat deploy --tags token4,MockV3Aggregator,SwapToken 拿到三个核心地址,前端直接引用。
    • 前端调用:借助 ethers.js 的 Web3Provider 把 MetaMask 变成签名器,完成合约实例化,完成 swap。
  2. 关键路径拆解
    预言机读价 → 预铸流动性 → 用户输入 1 ETH → 合约按价放 BTK → 余额实时回显,整条链路在 10 秒内闭环

  3. 下一步优化

    • latestAnswer 换成 latestRoundData 并加价格过期校验,满足生产级安全。
    • useContractEvent 监听 Swap 事件,实现“交易成功即弹 Toast + 自动刷新余额”。
    • 把 Hardhat 节点替换成 Alchemy/Infura,即可直接上线 TestFlight 让好友体验。

至此,合约 + 前端 + 钱包 的完整 DeFi 小闭环已经在你的手机里跑通。

javascript基础- 函数中 this 指向、call、apply、bind

2025年9月18日 18:55

前言

本文简单讲解函数中this的指向问题,及涉及主动改变 this 指向的 call apply bind三个方法的实现原理。

函数内this的指向

在函数内部,this 的值取决于函数如何被调用。

我们先定义两个函数,分别使用和不使用严格模式,返回this,以供后续使用。

function fnReturnThis() {
  return this
}
function fnReturnThisStrict() {
  "use strict"
  return this
}
console.log(this === window) // true

典型函数调用

console.log(fnReturnThisStrict() === undefined) // true

const obj = { 
  fnReturnThis, 
  fnReturnThisStrict 
}
console.log(obj.fnReturnThis()) // obj
console.log(obj.fnReturnThisStrict()) // obj

const obj2 = {
  fnReturnThis: obj.fnReturnThis,
  fnReturnThisStrict: obj.fnReturnThisStrict,
  obj,
}
console.log(obj2.fnReturnThis()) // obj2
console.log(obj2.fnReturnThisStrict()) // obj2

console.log(obj2.obj.fnReturnThis()) // obj
console.log(obj2.obj.fnReturnThisStrict()) // obj

const fnReturnObj = {
  fnReturnWrapper: function() { 
    return fnReturnThisStrict()
  }
}
console.log(fnReturnObj.fnReturnWrapper()) // undefined
  • 可以看到,通常情况下,函数内的this都指向当前函数的调用对象(.前面的对象)
  • 部分特殊场景,在非严格模式下存在如下差异化

非严格模式差异化

console.log(fnReturnThis()) // window
console.log(fnReturnThisStrict()) // undefined

console.log(fnReturnThis.call(null)) // window
console.log(fnReturnThisStrict.call(null)) // null

console.log(fnReturnThis.call(undefined)) // window
console.log(fnReturnThisStrict.call(undefined)) // undefined

console.log(fnReturnThis.call(false)) // Boolean {false}
console.log(fnReturnThisStrict.call(false)) // false

console.log(fnReturnThis.call(0)) // Number {0}
console.log(fnReturnThisStrict.call(0)) // 0

console.log(fnReturnThis.call(BigInt(0))) // BigInt {0}
console.log(fnReturnThisStrict.call(BigInt(0))) // 0n

const symbol = Symbol()
console.log(fnReturnThis.call(symbol)) // Symbol {Symbol()}
console.log(fnReturnThisStrict.call(symbol) === symbol) // true

console.log(fnReturnThis.call('0')) // String {'0'}
console.log(fnReturnThisStrict.call('0')) // '0'

const fnReturnObj = {
  fnReturnWrapper: function() { 
    console.log(
      this,
      fnReturnThis(),
      fnReturnThisStrict()
    )
  }
}
fnReturnObj.fnReturnWrapper() // fnReturnObj  window  undefined
  • 非严格模式下,一个特殊的过程称为 this 替换,确保 this 的值总是一个对象
    • 如果一个函数被调用时 this 被设置为 undefined 或 nullthis 会被替换为 globalThis
    • 如果函数被调用时 this 被设置为一个原始值,this 会被替换为原始值的包装对象。
  • 而严格模式下,this的值自始自终都遵循规范,若未指定值,则指向当前函数的调用对象,否则为指定值。

通过修改原型链,也符合上述标准

Number.prototype.fnReturnThis = fnReturnThis
Number.prototype.fnReturnThisStrict = fnReturnThisStrict
const num = 1
console.log(num.fnReturnThis()) // Number {1}
console.log(num.fnReturnThisStrict()) // num

作为回调函数执行,同样也符合上述标准

function callbackFn(fnReturnThis) {
  return fnReturnThis()
}
console.log(callbackFn(fnReturnThis)) // window
console.log(callbackFn(fnReturnThisStrict)) // undefined

const objUnStrict = { fnReturnThis }
const objStrict = { fnReturnThis: fnReturnThisStrict }
function callbackFn2(obj) {
  return obj.fnReturnThis()
}
console.log(callbackFn2(objStrict)) // objStrict
console.log(callbackFn2(objUnStrict)) // objUnStrict

console.log(Array.from(new Array(1)).map(fnReturnThis)[0]) // window
console.log(Array.from(new Array(1)).map(fnReturnThisStrict)[0]) // undefined

console.log(Array.from(new Array(1)).map(fnReturnThis, null)[0]) // window
console.log(Array.from(new Array(1)).map(fnReturnThisStrict, null)[0]) // null

console.log(Array.from(new Array(1)).map(fnReturnThis, 0)[0]) // Number {0}
console.log(Array.from(new Array(1)).map(fnReturnThisStrict, 0)[0]) // 0

所以要确定函数中this,只需按以下步骤:

  • 找到调用的这个函数(一定要找准确),调用this就是
    • 严格模式下,this就是调用对象
    • 非严格模式下,this一定为一个对象
      • 如果为undefined 或 null,则会被替换为 globalThis
      • 如果为其它原始值,则会被替换为这个原始值的包装对象。

主动修改this指向,call applybind

apply<T, R>(this: (this: T) => R, thisArg: T): R;
apply<T, A extends any[], R>(this: (this: T, ...args: A) => R, thisArg: T, args: A): R;

call<T, A extends any[], R>(this: (this: T, ...args: A) => R, thisArg: T, ...args: A): R;

bind<T>(this: T, thisArg: ThisParameterType<T>): OmitThisParameter<T>;
bind<T, A extends any[], B extends any[], R>(this: (this: T, ...args: [...A, ...B]) => R, thisArg: T, ...args: A): (...args: B) => R;

由于函数中的this指向是在函数执行前确定的,所以同一个函数以不同方式执行会导致函数内部this指向不可预测(只看函数,你永远不会知道下一次这个函数是被谁调用),所以,在必要的时候,需要用call applybind来指定函数内部this

function PlusN(n = 1) { 
  this.a = this.a + n
}
const obj = {
  a: 1,
  plusN: PlusN,
}
obj.plusN(1)
console.log(obj.a) // 2

const plus = obj.plusN

plus.call(obj, 1)
console.log(obj.a) // 3

plus.apply(obj, [1])
console.log(obj.a) // 4

const plusBind = PlusN.bind(obj)
plusBind(1)
console.log(obj.a) // 5

plus(1) // TypeError Cannot read properties of undefined (reading 'a')

// 作为构建函数
console.log(new PlusN()) // PlusN {a: 1}
const PlusNBind = PlusN.bind(obj)
console.log(new PlusNBind()) // PlusN {a: 1}
console.log(obj.a) // 5 不会影响 obj

从原理实现 call apply

call apply都是接受第一个参数作为函数内的this,其它入参为函数入参。返回函数执行结果。

也就是需要用第一个入参来调用函数,后续入参作为函数入参。并返回函数执行结果。

Function.prototype.myCall = function() {
  // 获取传入的数组参数
  let [context, ...args] = arguments

  // if myApply
  // args = args[0] 

  // 这里我们只做原理解析,注入原始值的情况不做考虑
  if (!(context instanceof Object)) throw new TypeError('context must be an object')
  
  // 将函数挂载到传入的context对象上
  let fnSymbolKey = Symbol()
  context[fnSymbolKey] = this

  // 记录返回值
  let res = args.length === 0
    ? context[fnSymbolKey]()
    : context[fnSymbolKey](...args)
  
  // 从上下文中删除函数引用
  delete context[fnSymbolKey]

  // 返回返回值
  return res
}

从原理实现 bind

从目标函数上调用,接受第一个参数作为函数内的this,其它入参为函数入参,返回一个函数,执行函数返回绑定this后的函数执行结果,支持追加函数入参(偏函数)。

Function.prototype.myBind = function(...params) {
  if (typeof this !== "function") throw new TypeError("what is trying to be bound is not a function")
  let [oThis, ...args] = params
  
  const Obj = { [this.name]: this }
  let functionToBind = this
  let FunctionBound = function(...boundFunctionParams) {
    return functionToBind.myCall(
      // 作为构建函数使用
      this instanceof FunctionBound ? this : oThis,
      // 偏函数功能
      ...args.concat(...boundFunctionParams)
    )
  }
  // 作为构建函数,将原型对象赋给新的函数
  FunctionBound.prototype = this.prototype
  return Obj[this.name]
}

都2025年了,我们还有必要为了兼容性,去写那么多polyfill吗?

作者 ErpanOmer
2025年9月18日 18:45

image.png

最近在Code Review里,我看到一个新同学在一个vite.config.js里,习惯性地加上了@vitejs/plugin-legacy,用来支持旧版浏览器。

我问他:“我们的目标用户里,真的还有人用那些浏览器吗?”

他愣了一下,说:“不知道,但加上总比不加好吧?万一呢?”

这个回答,让我陷入了沉思。“加上总比不加好”,这句听起来非常正确的话,真的是对的吗?

要做好兼容性,这句话,就像一句咒语,刻在了我们这代前端工程师的骨子里。从当年大战IE6,到后来ES6普及时,为各种新语法打补丁,polyfillbabel就是我们的救命稻草。在入口文件顶部写下一行import 'core-js/stable',仿佛是一种兼容标准。

但现在都2025年了,IE早已入土为安,主流浏览器都已现代化。我越来越觉得,盲目地、无差别地为项目引入全量polyfill,已经从一个最佳实践,变成了一种技术债。


过度追求兼容性,让我们付出了哪些代价?

我们先来算一笔账。当我们无脑地引入一个完整的polyfill方案时,我们付出的代价是什么?

打包体积的代价

这是最直接的代价,最终由我们的用户来买单。

我随手在一个Vite + Vue 3的项目里,

import 'core-js/stable'
import 'regenerator-runtime/runtime'

只是import 'core-js/stable',然后用rollup-plugin-visualizer分析一下打包体积:

image.png

一个完整的core-js,会给你的JS包增加几百KB的大小,而且占用整个项目78%的代码!!!这些代码,对于你项目中99%的、使用现代浏览器的用户来说,是完全用不上、不会被执行的死代码。他们却要为这部分代码,付出实实在在的流量和加载时间。

运行时性能的代价

Polyfill不只是下载下来就完事了,它还需要在浏览器里被解析和执行。虽然这个开销对于单个polyfill来说很小,但当一个庞大的polyfill集合在你的应用启动时就开始注入和执行,这无疑会增加主线程的负担,对首次可交互时间(TTI)和总阻塞时间(TBT)产生负面影响。


都2025年了,我们到底在兼容什么?

既然代价如此之大,那我们回头看看,我们如此大费周章,到底是在兼容什么?

首先,我们必须明确一点:IE已经死了!

image.png

在2025年,任何还在要求兼容IE的项目,要么是预算给够的古董维护项目,要么就应该重新评估它的商业价值。对于绝大多数面向公众的互联网产品,我们可以、也应该,大胆地和IE说再见。

其次,主流浏览器都是常青的。

Chrome, Firefox, Edge都具备自动更新能力,这意味着你绝大部分的用户,使用的都是最新或次新的浏览器版本,它们对ES2020+的特性支持都非常好。

image.png

那么,我们现在做兼容,主要目标是谁?

答案是:那些无法更新操作系统的、旧款设备上的浏览器。

所以,问题的关键就变成了:你的用户里,到底有多少人在用这些过时的浏览器?

根据 Statcounter的数据 ,全球浏览器市场份额👇:

image.png

Chrome 仍然是全球用户的首选,其次是 Safari,成为第二受欢迎的选择。Edge、Firefox 和 Samsung Internet 也占有相当大的份额,而 Opera 和其他小众浏览器则占到只有 5% 的市场份额

作为技术组长,我要求我们团队做任何关于兼容性的决策前,必须拿数据说话。打开你们自己项目的统计后台看一看,那个小于1%的others,真的值得我们让99%的用户去承担性能代价吗?


我的一些建议,Polyfill使用策略

我不是在鼓吹完全放弃兼容性,而是主张一种更智能、更精准、代价更小的策略。

明确定义 - 你的浏览器支持基线

和你的团队、产品经理一起,根据你的用户数据,明确定义出你们产品需要支持的最低浏览器版本。比如,我们可以定义为:“所有主流浏览器最近两个大版本”。把它写进团队的开发规范文档里。这是一个契约,也是我们后续做决策的依据。

放弃全量引入,按需分析

请立刻从你的项目入口文件里,删掉 import 'core-js/stable'或import 'babel-polyfill' 这种一刀🔪切的写法。

我们应该完全信赖构建工具的静态分析能力。比如,在babel.config.js里这样配置:

presets: [
  ['@babel/preset-env', {
    useBuiltIns: 'usage', // 关键配置
    corejs: 3
  }]
]

useBuiltIns: 'usage' 这个选项,意味着Babel会自动检测你的代码,只把你代码中用到了的、并且在你目标浏览器中不支持的那些新特性,才引入对应的polyfill。这能极大地减小polyfill的体积。

考虑使用Polyfill后端服务

对于一些大型应用,可以考虑使用Polyfill服务(比如polyfill.io的自建替代品,因为官方服务曾出现过稳定性问题, 现在好像都无法访问了😒)。

它的原理是:服务器根据请求的User-Agent头,判断出用户的浏览器版本,然后只下发这个浏览器所需要的polyfill脚本。这是最高效、最精准的方案。

拥抱渐进增强,接受优雅降级

(这是一个说烂了的话题, 说白了就是 摆烂🤔)

对于一些非核心的、锦上添花的新API(比如View Transitions API),我们可以不提供polyfill。在支持的浏览器上,用户能体验到炫酷的页面切换动画;在不支持的浏览器上,它就是一个普通的页面跳转。

不是所有功能,都值得我们用增加全体用户性能负担的方式,去强行兼容。


作为开发者,我们的职责不只是实现功能,还包括控制我们产品的开发成本——这其中就包括了用户需要付出的加载成本。

为那1%的、甚至在你的用户数据里根本不存在的旧浏览器用户,让99%的现代浏览器用户去承担额外的加载负担,在2025年,这已经是一笔不划算的买卖了。

是时候检查一下你的项目了。打开你的打包分析报告,看看core-js占了多大。

然后,问问你自己:“这些代码,真的有必要吗?”

欢迎大家讨论😎

低代码编辑器项目设计与实现:以JSON为核心的数据驱动架构

2025年9月18日 18:33

一、低代码/零代码:开发效率革命

随着数字化转型的加速,低代码/零代码平台正成为企业快速构建应用的新范式。低代码通过可视化拖拽替代手写代码,帮助开发者提升效率;零代码则让非技术人员也能搭建简单应用,快速满足业务需求。这种开发模式在表单、审批流程、数据看板等场景中展现出巨大价值,能够显著降低开发成本、缩短交付周期。

在实践中,我们发现低代码平台的核心其实是一个结构化的JSON数据。本文将深入剖析我们基于React和TypeScript开发的低代码编辑器项目,揭示其架构设计、技术实现以及开发过程中的思考。

二、项目技术栈与架构设计

1. 技术选型分析

我们的低代码编辑器项目采用了现代前端技术栈,主要包括:

  • 基础框架:React 18.2.0 + TypeScript 5.8.3
  • 构建工具:Vite 6.3.5(提供极速的开发体验)
  • 样式方案:TailwindCSS 4.1.13(原子化CSS,提高样式开发效率)
  • 状态管理:Zustand 5.0.8(轻量级状态管理库)
  • 布局组件:Allotment 1.20.4(可调整的分栏布局)
  • 拖拽功能:React DnD 16.0.1 + React DnD HTML5 Backend
  • UI组件库:Ant Design 5.27.3(提供丰富的UI组件)

项目初始化通过Vite脚手架创建,使用命令 npx create-vite lowcode-editor --template react-ts,并采用pnpm作为包管理器,保证依赖的一致性和安装速度。

2. 项目结构设计

低代码编辑器采用模块化开发方式,主要包含以下核心组件:

lowcode-editor/
├── components/
│   ├── Material/      # 左侧物料区域组件
│   ├── EditArea/      # 中间编辑区域组件
│   ├── Setting/       # 右侧配置区域组件
│   └── Header/        # 顶部导航组件
├── store/            # Zustand状态管理
└── utils/            # 工具函数

这种模块化设计使得各功能区域职责清晰,便于维护和扩展。通过三栏式布局(物料区、编辑区、配置区),为用户提供直观的操作界面。

三、核心功能实现详解

1. 可调整的分栏布局

分栏布局是低代码编辑器的基础,我们使用Allotment库实现了可调整宽度的三栏布局:

import { Allotment } from 'allotment';
import 'allotment/dist/style.css';

function EditorLayout() {
  return (
    <Allotment>
      {/* 左侧物料区域 */}
      <Allotment.Pane minSize={200} maxSize={400}>
        <MaterialPanel />
      </Allotment.Pane>
      
      {/* 中间编辑区域 */}
      <Allotment.Pane minSize={400}>
        <EditArea />
      </Allotment.Pane>
      
      {/* 右侧配置区域 */}
      <Allotment.Pane minSize={300} maxSize={500}>
        <SettingPanel />
      </Allotment.Pane>
    </Allotment>
  );
}

这种设计让用户可以根据自己的需求调整各个区域的宽度,提供更灵活的操作体验。

2. 基于Zustand的状态管理

低代码编辑器的核心是其状态管理机制,我们使用Zustand实现了简洁高效的状态管理方案:

import { create } from 'zustand';

// 定义组件类型
interface ComponentType {
  id: string;
  type: string;
  props: Record<string, any>;
  children?: ComponentType[];
}

// 定义编辑器状态接口
interface EditorState {
  components: ComponentType[];
  selectedComponentId: string | null;
  addComponent: (component: Omit<ComponentType, 'id'>) => void;
  updateComponent: (id: string, props: Record<string, any>) => void;
  selectComponent: (id: string | null) => void;
  // 其他状态方法...
}

// 创建状态管理store
const useEditorStore = create<EditorState>((set) => ({
  components: [],
  selectedComponentId: null,
  addComponent: (component) => set((state) => ({
    components: [...state.components, { ...component, id: `component-${Date.now()}` }]
  })),
  updateComponent: (id, props) => set((state) => ({
    components: state.components.map(comp => 
      comp.id === id ? { ...comp, props: { ...comp.props, ...props } } : comp
    )
  })),
  selectComponent: (id) => set({ selectedComponentId: id })
  // 其他状态方法实现...
}));

通过这种方式,我们将编辑器的核心数据(组件树结构)存储在Zustand的store中,确保各个组件之间的数据同步和一致性。

3. 拖拽功能的实现

拖拽是低代码编辑器最核心的交互方式,我们使用React DnD库实现了组件的拖拽功能:

import { useDrag, useDrop, DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';

// 物料区组件 - 可拖拽
function MaterialItem({ type, name }) {
  const [{ isDragging }, drag] = useDrag({
    type: 'COMPONENT',
    item: { type },
    collect: (monitor) => ({
      isDragging: monitor.isDragging()
    })
  });
  
  return (
    <div ref={drag} className={`p-2 border ${isDragging ? 'opacity-50' : ''}`}>
      {name}
    </div>
  );
}

// 编辑区组件 - 可放置
function EditArea() {
  const [{ isOver }, drop] = useDrop({
    accept: 'COMPONENT',
    drop: (item) => {
      // 调用状态管理方法添加组件
      useEditorStore.getState().addComponent({
        type: item.type,
        props: {}
      });
    },
    collect: (monitor) => ({
      isOver: monitor.isOver()
    })
  });
  
  return (
    <div ref={drop} className={`min-h-screen ${isOver ? 'border-dashed border-2 border-blue-500' : ''}`}>
      {/* 渲染组件树 */}
    </div>
  );
}

// 根组件中包裹DndProvider
function App() {
  return (
    <DndProvider backend={HTML5Backend}>
      <EditorLayout />
    </DndProvider>
  );
}

在实现过程中,我们遇到了一些挑战,比如useDrop钩子会导致多次插入组件、代码重复违反DRY原则等问题。针对这些问题,我们通过封装自定义钩子和统一的拖拽处理逻辑来解决。

4. JSON数据结构:低代码的本质

低代码编辑器的本质是操作JSON数据结构。所有的组件拖拽、属性修改最终都会转化为对JSON数据的操作:

{
  "components": [
    {
      "id": "component-123456",
      "type": "Container",
      "props": {
        "style": {"padding": "20px"},
        "className": "bg-gray-100"
      },
      "children": [
        {
          "id": "component-123457",
          "type": "Text",
          "props": {
            "content": "Hello World",
            "style": {"fontSize": "18px"}
          }
        },
        {
          "id": "component-123458",
          "type": "Button",
          "props": {
            "text": "Click Me",
            "onClick": "handleClick"
          }
        }
      ]
    }
  ]
}

这种树形结构的JSON数据,通过children属性串联起整个组件树,是低代码编辑器的核心。当用户在编辑器中进行操作时,本质上是在修改这棵JSON树。

四、开发过程中的挑战与解决方案

1. 组件拖拽多次触发问题

在使用React DnD的过程中,我们发现useDrop钩子有时会导致组件被多次插入。这是因为拖拽事件在某些情况下会被重复触发。

解决方案:我们通过防抖处理和状态标记来避免重复插入,确保每个拖拽操作只创建一个组件实例。

2. 代码重复与可维护性

随着组件数量的增加,我们发现很多拖拽和放置相关的逻辑在不同组件中重复出现,违反了DRY(Don't Repeat Yourself)原则。

解决方案:我们封装了自定义的拖拽相关hooks,如useComponentDraguseComponentDrop,将通用逻辑抽取到这些hooks中,提高代码的复用性和可维护性。

3. 复杂组件属性的编辑

对于包含复杂属性(如嵌套对象、数组等)的组件,如何提供友好的编辑界面是一个挑战。

解决方案:我们开发了动态表单生成器,可以根据组件属性的类型自动生成对应的编辑控件,支持嵌套对象和数组的编辑。

五、总结与技术亮点

通过这个低代码编辑器项目,我们实现了一个功能完整、交互友好的低代码开发工具。项目的主要技术亮点包括:

  1. 简洁高效的架构设计:采用模块化设计和状态管理分离,使代码结构清晰、易于维护。

  2. 灵活的布局系统:使用Allotment库实现可调整的分栏布局,提供良好的用户体验。

  3. 强大的拖拽交互:基于React DnD实现的拖拽系统,支持组件的拖拽、调整位置等操作。

  4. 以JSON为核心的数据驱动:所有操作最终都转化为对JSON数据的修改,体现了低代码的本质。

  5. 优雅的状态管理:使用Zustand实现轻量级的状态管理,避免了复杂的Context嵌套。

低代码开发平台代表了软件开发的一种新趋势,它能够显著提升开发效率、降低开发门槛。通过这个项目,我们不仅实现了一个可用的低代码编辑器,也深入理解了低代码平台的核心原理和实现方式。

在未来,我们计划进一步完善这个低代码编辑器,增加更多组件类型、支持更复杂的交互逻辑、提供更丰富的主题定制能力,并探索AI辅助低代码开发的可能性,为开发者提供更强大、更智能的开发工具。

【js篇】call() 与 apply()深度对比

作者 LuckySusu
2025年9月18日 18:28

在 JavaScript 中,call()apply() 是两个功能极其相似的方法,它们都能显式绑定函数执行时的 this 指向,并调用函数。你提到的“作用一样,区别仅在参数形式”是完全正确的,但我们可以更深入地理解它们的使用场景和细微差异。

本文将全面解析 callapply 的异同、使用技巧和最佳实践。


一、核心定义回顾

方法 语法 说明
call() func.call(thisArg, arg1, arg2, ...) 第一个参数指定 this,其余参数逐个传入
apply() func.apply(thisArg, [argsArray]) 第一个参数指定 this,第二个参数是数组或类数组

共同点

  • 立即执行函数;
  • 改变 this 指向;
  • 参数都会传递给目标函数。

二、参数传递方式的对比

✅ 示例:一个简单的加法函数

function add(a, b, c) {
  console.log(`${this.name}: ${a + b + c}`);
}

const context = { name: 'Calculator' };

使用 call()

add.call(context, 1, 2, 3);
// 输出: Calculator: 6
// 参数逐个列出

使用 apply()

add.apply(context, [1, 2, 3]);
// 输出: Calculator: 6
// 参数以数组形式传入

三、何时使用 call?何时使用 apply

✅ 推荐使用 call() 的场景:

  • 参数数量固定且已知
  • 参数是独立变量,不需要数组结构。
// 例如:格式化日期
function formatDate(prefix, year, month, day) {
  return `${prefix}: ${year}-${month}-${day}`;
}

const user = { name: 'Alice' };
const result = formatDate.call(user, 'Birthday', 1990, 5, 15);
// "Birthday: 1990-5-15"

✅ 代码更清晰,参数一目了然。


✅ 推荐使用 apply() 的场景:

  • 参数以数组形式存在
  • 参数数量不确定(可变参数);
  • 需要“展开”数组作为参数

场景1:求数组最大值

const numbers = [1, 5, 3, 9, 2];
const max = Math.max.apply(null, numbers);
// 等价于 Math.max(1, 5, 3, 9, 2)
console.log(max); // 9

❌ 无法使用 call 直接传数组,必须:

Math.max.call(null, 1, 5, 3, 9, 2); // 需手动展开

场景2:处理类数组对象(如 arguments

function logArgs() {
  // arguments 是类数组,不能直接用 forEach
  console.log('Arguments:');
  Array.prototype.forEach.apply(arguments, [function(arg, i) {
    console.log(`[${i}]: ${arg}`);
  }]);
}

logArgs('a', 'b', 'c');
// Arguments:
// [0]: a
// [1]: b
// [2]: c

四、现代 JavaScript 的替代方案:扩展运算符(Spread Operator)

ES6 引入的扩展运算符 ...apply 的使用场景大大减少。

✅ 使用 ... 替代 apply

const numbers = [1, 5, 3, 9, 2];

// 旧方式
Math.max.apply(null, numbers);

// 新方式(推荐)
Math.max(...numbers); // 更简洁!

✅ 使用 ...args 替代 apply 处理 arguments

function sumAll(...args) {
  return args.reduce((sum, num) => sum + num, 0);
}

// 而不是
function sumAll() {
  return Array.prototype.reduce.apply(arguments, [function(sum, num) {
    return sum + num;
  }, 0]);
}

五、性能对比(现代引擎已优化)

在早期 JavaScript 引擎中,call 通常比 apply 稍快,因为 apply 需要处理数组解析。

但在现代 V8 引擎(Chrome、Node.js)中,两者的性能差异几乎可以忽略不计。选择哪个方法应基于代码可读性和参数结构,而非性能。


六、常见错误与注意事项

❌ 错误1:apply 的第二个参数不是数组

add.apply(context, 1, 2, 3); // ❌ 报错!第二个参数应为数组

❌ 错误2:call 传入数组未展开

add.call(context, [1, 2, 3]); // ❌ 相当于 add([1,2,3], undefined, undefined)

✅ 正确做法:

// 使用 apply
add.apply(context, [1, 2, 3]);

// 或使用 call + 展开
add.call(context, ...[1, 2, 3]);

七、总结:call vs apply 一览表

特性 call() apply()
this 绑定 ✅ 支持 ✅ 支持
立即执行 ✅ 是 ✅ 是
参数形式 逐个传参 数组/类数组
适用场景 参数固定、独立变量 参数为数组、类数组、可变参数
现代替代 无直接替代 ... 扩展运算符
性能 略优(已不明显) 略低(已不明显)

💡 结语

callapply 是一对孪生兄弟,选择谁取决于你手里的‘食材’。”

  • 如果你有独立的参数,用 call
  • 如果你有一个数组,用 apply...
  • 在现代开发中,优先使用扩展运算符 ... 来替代 apply 的常见用法。

📌 记住

  • call(thisArg, a, b, c)
  • apply(thisArg, [a, b, c])
  • modern(thisArg, ...[a, b, c])

掌握它们的区别,能让你写出更优雅、更高效的代码!

【js篇】深入理解 JavaScript 作用域与作用域链

作者 LuckySusu
2025年9月18日 18:27

在 JavaScript 开发中,作用域(Scope)和作用域链(Scope Chain) 是理解变量生命周期、函数执行、闭包机制的核心基础。它们决定了“在哪里可以访问到哪些变量”。

本文将系统性地讲解 全局作用域、函数作用域、块级作用域,并深入剖析 作用域链的形成与查找机制,帮你构建清晰的执行环境认知。


一、什么是作用域?—— 变量的“可见范围”

作用域是指代码中变量和函数的可访问性范围。它决定了一个变量“在哪些地方可以被使用”。

JavaScript 中主要有三种作用域:

类型 出现场景 特点
全局作用域 最外层 所有代码都能访问
函数作用域 function 内部 仅函数内部可访问
块级作用域 {} 内部(let/const 仅代码块内可访问

二、全局作用域:处处可见的“公共区域”

✅ 哪些变量属于全局作用域?

  1. 最外层定义的变量和函数

    var globalVar = 'I am global';
    function globalFunc() { }
    
  2. 未声明直接赋值的变量(不推荐!)

    function badFunc() {
      badVar = 'I am accidentally global';
    }
    badFunc();
    console.log(badVar); // 'I am accidentally global'
    
  3. window 对象的属性

    window.appName = 'MyApp';
    console.log(appName); // 'MyApp' —— 全局变量
    

⚠️ 全局作用域的弊端

  • 命名冲突:多个脚本可能定义同名变量,导致覆盖;
  • 内存泄漏:全局变量不会被垃圾回收;
  • 安全性差:任何代码都可以修改全局状态。

📌 最佳实践:尽量减少全局变量,使用模块化或 IIFE 封装。


三、函数作用域:函数内部的“私有空间”

✅ 函数作用域的特点

  • function 内部声明的变量,只能在该函数内部访问
  • 每次函数调用都会创建一个新的作用域;
  • 内层作用域可以访问外层作用域的变量,反之不行。
function outer() {
  var outerVar = 'outer';

  function inner() {
    var innerVar = 'inner';
    console.log(outerVar); // ✅ 可以访问外层变量
    console.log(innerVar); // ✅ 自身变量
  }

  inner();
  console.log(innerVar); // ❌ 报错:innerVar is not defined
}

outer();

📌 关键点inner 可以访问 outerVar,但 outer 无法访问 innerVar


四、块级作用域:ES6 的“精细控制”

✅ 什么是块级作用域?

{} 包裹的代码块(如 ifforwhile)中,使用 letconst 声明的变量,只能在该代码块内访问

{
  let blockVar = 'block';
  const blockConst = 'const';
  console.log(blockVar); // ✅
}
console.log(blockVar);   // ❌ 报错
console.log(blockConst); // ❌ 报错

✅ 块级作用域的优势

特性 var let/const
变量提升 ✅ 有(值为 undefined ❌ 无(存在“暂时性死区”)
重复声明 ✅ 允许(会覆盖) ❌ 不允许
作用域 函数作用域 块级作用域
循环中使用 ❌ 容易出错 ✅ 推荐

🔧 经典案例:循环中的 let

// 使用 var —— 问题代码
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100); // 输出 3 3 3
}

// 使用 let —— 正确
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100); // 输出 0 1 2 ✅
}

let 在每次循环中创建一个新的绑定,避免了变量共享问题。


五、作用域链:变量查找的“寻宝地图”

✅ 什么是作用域链?

当 JavaScript 引擎查找一个变量时,它会按照以下顺序搜索:

  1. 当前作用域(当前执行上下文);
  2. 外层函数作用域;
  3. 再外层函数作用域;
  4. ……
  5. 全局作用域;
  6. 如果仍未找到,抛出 ReferenceError

这个逐层向上查找的路径,就是 作用域链

🔍 作用域链示例

var global = 'global';

function func1() {
  var a = 'a';
  
  function func2() {
    var b = 'b';
    
    function func3() {
      var c = 'c';
      console.log(global, a, b, c); // 都能找到
    }
    func3();
  }
  func2();
}

func1();

作用域链结构:

func3 的作用域链:
1. func3 的变量对象(含 c)
2. func2 的变量对象(含 b)
3. func1 的变量对象(含 a)
4. 全局变量对象(含 global, func1)

✅ 作用域链的本质

  • 是一个指向变量对象的指针列表
  • 前端始终是当前执行上下文的变量对象
  • 末端始终是全局对象(window/global)
  • 保证了变量的有序访问。

✅ 自由变量(Free Variable)

在当前作用域中未定义,但被使用的变量,称为自由变量

function outer() {
  var x = 1;
  function inner() {
    console.log(x); // x 是自由变量
  }
  return inner;
}

inner 中的 x 不是 inner 自身定义的,所以是自由变量,需要通过作用域链向上查找。


六、图解:执行上下文与作用域链

执行 func3() 时:

[ 当前执行上下文: func3 ]
  ↓ 变量对象: { c: 'c' }
  ↓ 作用域链:
      → func3 VO
      → func2 VO  { b: 'b' }
      → func1 VO  { a: 'a' }
      → 全局 VO   { global: 'global', func1: function }

查找 x 的过程:

  1. func3 VO 中找 → 无;
  2. func2 VO 中找 → 无;
  3. func1 VO 中找 → 无;
  4. 在全局 VO 中找 → 找到!

七、总结:核心要点一览

概念 关键点
全局作用域 最外层,易污染,避免滥用
函数作用域 function 内部,var 声明
块级作用域 {} 内部,let/const 声明,无变量提升
作用域链 从内到外的变量查找路径
自由变量 非自身定义,需通过作用域链查找的变量
最佳实践 let/const 替代 var,减少全局变量

💡 结语

“作用域是变量的家,作用域链是回家的路。”

理解作用域和作用域链,是掌握闭包、this、模块化等高级特性的前提。无论你是初学者还是资深开发者,都应该定期回顾这些基础概念。

📌 记住:

  • 内层可访问外层,外层不可访问内层;
  • let/const 提供了更安全的块级作用域;
  • 作用域链是变量查找的唯一路径。

【js篇】如何准确获取对象自身的属性?hasOwnProperty深度解析

作者 LuckySusu
2025年9月18日 18:27

在 JavaScript 开发中,我们经常需要遍历对象的属性。但你是否遇到过这样的问题:

“为什么遍历一个简单对象时,会多出一些意想不到的方法?”

这是因为 for...in 循环会遍历对象自身 + 原型链上所有可枚举的属性。如果我们只想获取对象“自己”的属性,就必须使用 hasOwnProperty() 方法进行过滤。

本文将深入讲解如何准确获取对象非原型链上的属性(即“自身属性”),并结合你提供的代码,给出最佳实践。


一、问题背景:for...in 的“陷阱”

看一个经典例子:

function Person(name) {
  this.name = name;
}

Person.prototype.sayHello = function () {
  console.log(`Hello, I'm ${this.name}`);
};

const person = new Person('Alice');

// 直接使用 for...in
for (let key in person) {
  console.log(key);
}
// 输出:
// name
// sayHello  ← 这是原型上的方法,但我们可能不想要它!

❌ 问题:sayHello 是从原型链继承来的,并非 person 实例自身的属性。


二、解决方案:使用 hasOwnProperty() 过滤

✅ 正确做法:使用 Object.prototype.hasOwnProperty() 方法判断属性是否属于对象自身。

function iterate(obj) {
  const res = [];
  for (let key in obj) {
    // ✅ 只保留对象自身的属性
    if (obj.hasOwnProperty(key)) {
      res.push(key + ': ' + obj[key]);
    }
  }
  return res;
}

const result = iterate(person);
console.log(result);
// 输出: ["name: Alice"]
// ✅ 成功过滤掉了原型上的 sayHello

三、hasOwnProperty 原理详解

✅ 什么是“自身属性”(Own Property)?

  • 自身属性:直接定义在对象实例上的属性,如 this.name = 'Alice'
  • 继承属性:通过原型链从父级继承来的属性或方法,如 sayHello

hasOwnProperty() 的作用

  • 检查某个属性是否是对象的直接属性
  • 返回 true 表示该属性是自身的;
  • 返回 false 表示该属性来自原型链或不存在。
person.hasOwnProperty('name');     // true  ← 自身属性
person.hasOwnProperty('sayHello'); // false ← 来自原型
person.hasOwnProperty('toString'); // false ← 来自 Object.prototype

四、更现代的替代方案

虽然 hasOwnProperty 非常经典,但现代 JavaScript 提供了更多选择:

✅ 方法1:Object.keys() —— 获取所有自身可枚举属性

const ownKeys = Object.keys(person);
console.log(ownKeys); // ['name']

// 结合 map 处理
const result = Object.keys(person).map(key => {
  return `${key}: ${person[key]}`;
});

✅ 优点:简洁,无需手动过滤; ❌ 缺点:只包含可枚举属性。


✅ 方法2:Object.getOwnPropertyNames() —— 包括不可枚举属性

// 添加一个不可枚举属性
Object.defineProperty(person, 'age', {
  value: 25,
  enumerable: false
});

console.log(Object.keys(person));           // ['name']
console.log(Object.getOwnPropertyNames(person)); // ['name', 'age']

✅ 适用场景:需要获取 configurable: falseenumerable: false 的属性。


✅ 方法3:Reflect.ownKeys() —— 最全的自身属性列表

const obj = { a: 1 };
Object.defineProperty(obj, 'b', { value: 2, enumerable: false });
obj[Symbol('c')] = 3;

console.log(Reflect.ownKeys(obj)); // ['a', 'b', Symbol(c)]

✅ 包含:字符串键、Symbol 键、可枚举和不可枚举属性。


五、hasOwnProperty 的潜在风险与规避

⚠️ 风险:对象可能重写了 hasOwnProperty

const badObj = {
  name: 'Test',
  hasOwnProperty: function () {
    return false; // 恶意重写
  }
};

badObj.hasOwnProperty('name'); // false ❌ 错误结果!

✅ 安全调用方式

使用 callObject.prototype.hasOwnProperty.call()

Object.prototype.hasOwnProperty.call(badObj, 'name'); // true ✅
// 或
{}.hasOwnProperty.call(badObj, 'name'); // true ✅

📌 推荐在库或通用代码中使用这种写法,确保健壮性。


六、完整工具函数推荐

结合你提供的 iterate 函数,我们可以优化为更健壮的版本:

function getOwnProperties(obj) {
  const res = [];
  
  // 使用安全的 hasOwnProperty 调用
  for (let key in obj) {
    if (Object.prototype.hasOwnProperty.call(obj, key)) {
      res.push(`${key}: ${obj[key]}`);
    }
  }
  
  return res;
}

// 或者使用现代 API
function getOwnPropertiesModern(obj) {
  return Object.keys(obj).map(key => `${key}: ${obj[key]}`);
}

七、总结:获取对象自身属性的方法对比

方法 是否包含原型 是否包含不可枚举 是否包含 Symbol 推荐场景
for...in + hasOwnProperty ❌ 否 ❌ 否 ❌ 否 兼容旧环境
Object.keys() ❌ 否 ❌ 否 ❌ 否 日常开发,简洁
Object.getOwnPropertyNames() ❌ 否 ✅ 是 ❌ 否 需要不可枚举属性
Reflect.ownKeys() ❌ 否 ✅ 是 ✅ 是 全面获取所有键

💡 结语

“遍历对象时,for...in 是‘广撒网’,hasOwnProperty 是‘精准捕捞’。”

掌握如何区分自身属性继承属性,是写出高质量 JavaScript 代码的基本功。无论你是做数据处理、对象克隆,还是开发类库,这个知识点都至关重要。

📌 记住:

  • hasOwnProperty 过滤原型属性;
  • 优先使用 Object.keys() 等现代 API;
  • 在通用代码中使用 Object.prototype.hasOwnProperty.call() 保证安全。

【js篇】JavaScript 原型修改 vs 重写:深入理解 constructor的指向问题

作者 LuckySusu
2025年9月18日 18:26

在 JavaScript 中,原型(prototype)的修改与重写是两个看似相似但行为差异极大的操作。尤其是在重写原型对象时,容易引发 constructor 指向丢失的问题,导致逻辑错误或继承链混乱。

本文将通过代码实例,深入剖析 “原型修改”与“原型重写”的区别,并教你如何正确处理 constructor 的指向,避免常见陷阱。


一、原型的“修改”:动态添加属性/方法

当我们通过 点语法或中括号语法prototype 添加方法时,称为“原型修改”。此时,原型对象的引用地址不变,所有已创建和后续创建的实例都能正确访问新方法。

function Person(name) {
  this.name = name;
}

// ✅ 修改原型:添加方法
Person.prototype.getName = function () {
  return this.name;
};

const p = new Person('Alice');
console.log(p.__proto__ === Person.prototype);           // true
console.log(p.__proto__ === p.constructor.prototype);    // true

特点:

  • 不改变 Person.prototype 的引用;
  • 所有实例共享更新;
  • constructor 指向依然正确:p.constructor === Person

二、原型的“重写”:用新对象替换原型

当我们 直接给 Person.prototype 赋值一个新对象 时,就发生了“原型重写”。这会创建一个全新的对象,导致原有引用关系断裂。

// ❌ 重写原型:用对象字面量替换
Person.prototype = {
  getName: function () {
    return this.name;
  }
};

const p2 = new Person('Bob');
console.log(p2.__proto__ === Person.prototype);          // true
console.log(p2.__proto__ === p2.constructor.prototype);  // false ❌
console.log(p2.constructor === Person);                  // false
console.log(p2.constructor === Object);                  // true

🔍 问题分析:

  1. 原始的 Person.prototype 是一个自动创建的对象,其内部有:

    Person.prototype.constructor === Person
    
  2. 但当我们重写为:

    Person.prototype = {
      getName: function() {}
    }
    

    这个新对象的 constructor 默认指向 Object,因为它是通过对象字面量创建的,等价于:

    const obj = new Object();
    obj.getName = function() {};
    
  3. 因此,p2.constructor === Object构造函数指向丢失!


三、修复方案:手动恢复 constructor 指向

为了保持原型链的完整性,我们需要在重写原型后,手动将 constructor 指回原构造函数

Person.prototype = {
  getName: function () {
    return this.name;
  }
};

// ✅ 修复 constructor 指向
Person.prototype.constructor = Person;

const p3 = new Person('Charlie');
console.log(p3.__proto__ === Person.prototype);           // true
console.log(p3.__proto__ === p3.constructor.prototype);   // true ✅
console.log(p3.constructor === Person);                   // true ✅

四、更优雅的写法:定义时一并设置 constructor

为了避免遗漏,推荐在重写原型时直接在对象字面量中定义 constructor

Person.prototype = {
  constructor: Person, // ✅ 显式指定
  getName: function () {
    return this.name;
  },
  sayHello: function () {
    console.log(`Hello, I'm ${this.name}`);
  }
};

这样从一开始就保证了 constructor 的正确性。


五、使用 Object.defineProperty 防止被枚举

如果你希望 constructor 不被 for...in 遍历到,可以使用 Object.defineProperty 定义它为不可枚举属性:

Person.prototype = {
  getName: function () {
    return this.name;
  }
};

Object.defineProperty(Person.prototype, 'constructor', {
  value: Person,
  enumerable: false,     // 不可枚举
  writable: true,        // 可修改
  configurable: true     // 可配置
});

六、最佳实践建议

场景 推荐做法
小量扩展原型 使用 Person.prototype.method = function() {}
大量方法定义 重写原型对象,但必须包含 constructor
构建类库/框架 使用 Object.defineProperty 控制属性特性
避免 直接重写原型而不修复 constructor

七、总结:关键对比表

操作 是否改变 prototype 引用 constructor 是否丢失 是否推荐
修改原型(添加方法) ❌ 否 ❌ 否 ✅ 推荐
重写原型(无 constructor) ✅ 是 ✅ 是 ⚠️ 不推荐
重写原型(含 constructor) ✅ 是 ❌ 否 ✅ 推荐

💡 结语

“重写原型而不修复 constructor,就像换了身份证却不改名字。”

理解原型的修改与重写,尤其是 constructor 的指向问题,是掌握 JavaScript 面向对象编程的关键一步。无论你是手写类库,还是阅读框架源码(如 Vue、React 的某些底层实现),这些知识都至关重要。

📌 记住:

  • 修改原型 → 安全,无需额外操作;
  • 重写原型 → 必须手动设置 constructor

【js篇】深入理解 JavaScript 原型与原型链

作者 LuckySusu
2025年9月18日 18:26

在 JavaScript 的世界中,原型(Prototype)和原型链(Prototype Chain) 是每个前端开发者都必须掌握的核心概念。它们不仅是 JavaScript 实现面向对象编程的基础,更是理解函数、对象、继承机制的关键所在。

今天,我们就来系统地梳理一下原型与原型链的底层原理,帮助你彻底搞懂这个“看似复杂、实则清晰”的机制。


一、什么是原型?构造函数与 prototype 的关系

在 JavaScript 中,我们通常使用构造函数来创建对象实例。例如:

function Person(name) {
  this.name = name;
}

const person1 = new Person("Alice");
const person2 = new Person("Bob");

每个构造函数都有一个内置属性:prototype。这个属性指向一个对象,该对象包含了所有实例可以共享的属性和方法。

Person.prototype.sayHello = function() {
  console.log(`Hello, I'm ${this.name}`);
};

person1.sayHello(); // 输出: Hello, I'm Alice
person2.sayHello(); // 输出: Hello, I'm Bob

关键点:

  • Person.prototype 是一个对象。
  • 所有通过 new Person() 创建的实例,都可以访问 prototype 上定义的方法和属性。
  • 这种方式避免了在每个实例中重复定义相同的方法,节省内存,提升性能。

二、对象的原型:__proto__Object.getPrototypeOf()

当我们使用构造函数创建一个对象时,这个对象内部会自动包含一个指向其构造函数 prototype 的指针,这个指针就是对象的原型

在 ES5 之前,虽然这个指针是内部机制,但现代浏览器普遍实现了 __proto__ 属性来访问它:

console.log(person1.__proto__ === Person.prototype); // true

⚠️ 但请注意:

  • __proto__ 不是标准推荐使用的属性,尽管大多数环境支持它。
  • ES5 引入了标准方法 Object.getPrototypeOf() 来安全获取对象的原型:
console.log(Object.getPrototypeOf(person1) === Person.prototype); // true ✅ 推荐使用

📌 小贴士:始终优先使用 Object.getPrototypeOf(obj) 而不是 obj.__proto__,以保证代码的规范性和可移植性。


三、原型链:属性查找的“向上追溯”机制

当你访问一个对象的属性时,JavaScript 引擎会按以下顺序查找:

  1. 先在对象自身查找;
  2. 如果找不到,就去它的原型对象中查找;
  3. 如果原型中也没有,就继续查找原型的原型
  4. 一直向上追溯,直到找到属性或原型链结束。

这个逐层向上查找的过程,就是 原型链(Prototype Chain)

🔗 原型链的终点:Object.prototype

所有对象的原型链最终都会指向 Object.prototype,而 Object.prototype.__proto__null,表示链的终点。

console.log(Object.getPrototypeOf(Object.prototype)); // null

这也是为什么我们创建的任意对象都能调用 toString()hasOwnProperty() 等方法的原因:

person1.toString(); // 继承自 Object.prototype

🧩 原型链示意图

person1
  ↓ __proto__
Person.prototype
  ↓ __proto__
Object.prototype
  ↓ __proto__
null

四、原型的引用特性:共享与动态继承

JavaScript 中的对象是通过引用传递的。这意味着:

  • 所有实例共享同一个原型对象;
  • 原型对象不会被每个实例复制一份;
  • 当你修改原型时,所有实例都会“动态”继承这些变化。

✅ 动态修改原型的示例

// 先创建实例
const person = new Person("Charlie");

// 后添加方法到原型
Person.prototype.greet = function() {
  console.log("Hi there!");
};

// 实例仍然可以访问新方法
person.greet(); // 输出: Hi there!

💡 这种“动态性”是 JavaScript 灵活性的体现,但也需谨慎使用,避免在生产环境中随意修改原生对象的原型。


五、常见误区与最佳实践

❌ 误区1:混淆 prototype__proto__

  • prototype构造函数的属性;
  • __proto__实例对象的原型指针;
  • 两者指向的是同一个对象(对于实例而言)。

❌ 误区2:认为原型是“副本”

原型是引用共享,不是复制。修改原型会影响所有实例。

✅ 最佳实践建议

  1. 使用 Object.create(null) 创建无原型链的对象(用于纯字典场景);
  2. 优先使用 Object.getPrototypeOf()Object.setPrototypeOf()
  3. 避免修改原生对象(如 Array.prototype)的原型;
  4. 理解 class 语法糖背后的原型机制(ES6 的 class 本质仍是基于原型);

六、总结:原型与原型链的核心要点

概念 说明
prototype 构造函数的属性,存储共享方法和属性
__proto__ 实例的内部原型指针(不推荐直接使用)
Object.getPrototypeOf() 标准方法,获取对象的原型 ✅
原型链 属性查找时逐层向上追溯的机制
终点 Object.prototype,其 __proto__null
特性 引用共享、动态继承、内存高效

结语

原型与原型链是 JavaScript 的“灵魂”之一。理解它,不仅能帮助你写出更高效的代码,还能在面试、调试、框架源码阅读中游刃有余。

🔥 记住一句话:
“每个对象都有一个原型,查找属性时会沿着原型链一路向上,直到 null。”

【js篇】addEventListener()方法的参数和使用

作者 LuckySusu
2025年9月18日 18:26

在现代 Web 开发中,事件处理是实现用户交互的核心机制。addEventListener() 是 JavaScript 中用于绑定事件监听器的标准方法,它比传统的 onclick 等内联事件属性更加灵活、强大且符合现代开发规范。

本文将深入讲解 addEventListener() 的语法、参数、使用场景以及最佳实践。


✅ 一句话总结

addEventListener() 用于为元素绑定事件处理函数,支持多个监听器、事件捕获/冒泡控制,并避免了传统事件绑定的覆盖问题。


✅ 一、基本语法

element.addEventListener(event, handler, options);
// 或
element.addEventListener(event, handler, useCapture);

🔹 参数说明

参数 类型 必填 说明
event String 事件类型,如 'click''mouseover''keydown'
handler Function 事件触发时执行的回调函数
options / useCapture ObjectBoolean 可选配置,控制事件行为

✅ 二、参数详解

1. event:事件类型

常见的事件类型包括:

  • 鼠标事件:clickdblclickmousedownmouseupmousemovemouseovermouseout
  • 键盘事件:keydownkeyupkeypress
  • 表单事件:submitchangeinputfocusblur
  • 页面加载:loadDOMContentLoadedbeforeunload
  • 自定义事件:通过 CustomEvent 创建
button.addEventListener('click', handleClick);
input.addEventListener('input', handleInput);
window.addEventListener('scroll', handleScroll);

2. handler:事件处理函数

事件处理函数接收一个 事件对象(Event Object) 作为参数,包含事件的详细信息。

function handleClick(event) {
  console.log('点击了元素');
  console.log('事件类型:', event.type);
  console.log('目标元素:', event.target);
  console.log('当前元素:', event.currentTarget);
  
  // 阻止默认行为(如链接跳转)
  event.preventDefault();
  
  // 阻止事件冒泡
  event.stopPropagation();
}

推荐使用具名函数或箭头函数

// 使用箭头函数
button.addEventListener('click', (e) => {
  console.log('按钮被点击');
});

3. 第三个参数:optionsuseCapture

这是 addEventListener 最灵活的部分,有两种写法:

方式一:布尔值 useCapture

  • false(默认):在冒泡阶段执行监听器
  • true:在捕获阶段执行监听器
// 冒泡阶段(推荐大多数情况)
elem.addEventListener('click', handler, false);

// 捕获阶段
elem.addEventListener('click', handler, true);

方式二:配置对象 options

elem.addEventListener('click', handler, {
  capture: false,     // 是否在捕获阶段执行
  once: false,        // 是否只执行一次
  passive: false      // 是否为“被动”监听器(不能调用 preventDefault)
});
🔹 once: true

监听器只执行一次,执行后自动移除。

button.addEventListener('click', () => {
  alert('这只会弹出一次!');
}, { once: true });

✅ 适用于一次性操作,如首次引导、防重复提交等。

🔹 passive: true

告诉浏览器该监听器不会调用 preventDefault(),从而提升滚动等事件的性能。

// 提升滚动性能(尤其在移动端)
window.addEventListener('touchstart', handleTouch, { passive: true });

⚠️ 如果在 passive 监听器中调用 preventDefault(),浏览器会发出警告。


✅ 三、与传统事件绑定的对比

特性 addEventListener onclick 等属性
可绑定多个监听器 ✅ 是 ❌ 否(会覆盖)
支持事件捕获 ✅ 是 ❌ 否
可配置 oncepassive ✅ 是 ❌ 否
更清晰的解绑方式 removeEventListener onclick = null
符合现代标准 ✅ 推荐 ❌ 不推荐
// ❌ 传统方式(会覆盖)
button.onclick = () => console.log('第一次');
button.onclick = () => console.log('第二次'); // 覆盖了第一次

// ✅ addEventListener(可共存)
button.addEventListener('click', fn1);
button.addEventListener('click', fn2); // 两个都会执行

✅ 四、如何移除事件监听器?

使用 removeEventListener()必须传入相同的函数引用

function handleClick() {
  console.log('点击');
}

button.addEventListener('click', handleClick);
// 移除
button.removeEventListener('click', handleClick);

⚠️ 下面写法无法移除

// ❌ 匿名函数无法移除
button.addEventListener('click', () => console.log('hello'));
button.removeEventListener('click', () => console.log('hello')); // ❌ 不生效

✅ 解决方案:使用具名函数或变量保存引用。


✅ 五、一句话总结

addEventListener 是现代事件绑定的标准方式,支持灵活的参数配置(如 oncepassive),避免事件覆盖,推荐在所有项目中使用。


💡 最佳实践

  1. 优先使用 addEventListener,避免 onclick 等内联属性;
  2. 为监听器命名或保存引用,便于后续移除;
  3. 合理使用 once:用于一次性事件(如引导、防抖);
  4. 在滚动/触摸事件中使用 passive: true,提升性能;
  5. 注意 this 指向:使用箭头函数或 .bind() 控制上下文;
  6. 及时清理事件:在组件销毁或页面跳转前移除监听器,防止内存泄漏;
// 示例:组件卸载时清理
window.addEventListener('beforeunload', cleanup, { once: true });

function cleanup() {
  button.removeEventListener('click', handleClick);
  window.removeEventListener('scroll', handleScroll);
}

如何给Three.js中ExtrudeGeometry的不同面设置不同材质

作者 刘皇叔code
2025年9月18日 18:19

前言

Three.js有很多基础的几何体,对于一些简单的图形形状,可以不用每次都劳烦建模的同学去提供一个模型,常常这些基础几何体就可以绘制了。ExtrudeGeometry是在开发中非常常用的一种几何体,它非常灵活,可以先用一系列二维顶点绘制一个二维图形,然后沿着一定的方向将这个二维图形拉成一个三维几何体,这在很多场景下非常有用,比如隧道、建筑大楼,都可以用这种方式生成一个简单的示意性的几何体。
但是,如果需要给这个几何体设置材质时,又会感觉不够灵活了,要么只能全部表面都用一直材质;要么就是两种材质:顶面底面一种,侧面一种。这在对于很多场景就不够用了,很多场景下,我们希望给不同的面使用不同的材质,甚至我们想自己设置顶点的uv属性。
其实从WebGL的角度来说,一个几何体的形状无非就是设置顶点,给几何体贴纹理无非就是传入一张纹理贴图然后给每个顶点设置正确的uv值。所以想实现灵活的给ExtrudeGeometry使用材质,还是要探究ExtrudeGeometry的顶点是如何生成的。这又来到了我们的传统艺能——阅读源码,这篇文章的就是通过阅读ExtrudeGeometry的源码,来探究ExtrudeGeometry的顶点是怎样生成的,然后我们可以将顶点设置不同的group,然后就可以为每一组设置不同的材质了。
这里还是要提一句,我们给场景中添加一个mesh时,一般要传入一个geometry和一个material,分别代表这个mesh的几何形态和外观材质,material通常是一个Material的子类对象,比如MeshLambertMaterial、MeshPhongMaterial、MeshPhysicalMaterial等。但也可以是一个数组,在这种情况下,这个数组的index就对应geometry的顶点的group,geometry上记录这所有group的起始顶点start、顶点数量count和材质索引materialIndex。所以只要我们能给ExtrudeGeometry对象中的的顶点设置group,我们可以任意给不同的面设置不同的材质。

ExtrudeGeometry绘制原理

1.绘制二维平面

首先绘制一个二维平面,我们这里就以官方示例为例,也就是最简单的矩形,长宽都是1。然后获取这个二维平面的二维顶点vertices,这里就是四边形的四个顶点(0,0),(0,1),(1,1),(1,0),可以注意到,这里的顶点是顺时针排列的。如果是逆时针,还得将其转为顺时针。这里可以看到这个shape中间可能还会有空洞(holes),我们这篇文章就不考虑这种比较复杂的情况了(其实原理上也相差不大,就是要增加很多顶点和表面)。将vertices赋值给contour,如果有holes,要将holes的顶点写入vertices中,所以这里有一行注释,意思是contour是外圈的顶点,vetices还有holes的顶点。对于本文这种没有holes的实例,contour和vertices的值就是一模一样的。

const shapePoints = shape.extractPoints( curveSegments );

let vertices = shapePoints.shape;
const holes = shapePoints.holes;

const reverse = ! ShapeUtils.isClockWise( vertices );

if ( reverse ) {

        vertices = vertices.reverse();

        // Maybe we should also check if holes are in the opposite direction, just to be safe ...

        for ( let h = 0, hl = holes.length; h < hl; h ++ ) {

                const ahole = holes[ h ];

                if ( ShapeUtils.isClockWise( ahole ) ) {

                        holes[ h ] = ahole.reverse();

                }

        }

}
................
const contour = vertices; // vertices has all points but contour has only points of circumference
for ( let h = 0; h < numHoles; h ++ ) {

        const ahole = holes[ h ];

        vertices = vertices.concat( ahole );

}

2.求顶点向外扩的向量

ExtrudeGeometry是可以设置斜面倒角的,这里牵扯到三个参数,bevelEnabled、bevelThickness、bevelSize、bevelOffset、bevelSegments。这里bevelEnabled表示是否有斜面倒角,bevelSegments表示斜面倒角分为几段。 bevelThickness表示这个斜面倒角在纵线(z方向)上的长度。bevelSize和bevelOffset就得结合图来看了。bevelOffset表示shape还需要在其基础上向外扩大多少,bevelSize表示倒角顶面和底面的高度差。
绘图-1.png 要将shape的顶点外扩,也就是求每个顶点向外扩后得到的新顶点的坐标,那就要求每个顶点外扩的方向向量。这一节主要就是介绍如何求这个外扩向量。

设某个顶点的坐标是P(xv,yv)P(x_v,y_v),这个点的前一个点为Pprev(xprev,yprev)P_{prev}(x_{prev},y_{prev}),后一个点为Pnext(xnext,ynext)P_{next}(x_{next},y_{next})。这三个点可以构成两个向量,分别是:

Vprev(xvprev,yvprev)=PPprevV_{prev}(x_{vprev},y_{vprev})=P-P_{prev}
Vnext(xvnext,yvnext)=PnextPV_{next}(x_{vnext},y_{vnext})=P_{next}-P

VprevV_{prev}垂直,指向shape外的显然向量为Vprev(yvprev,xvprev)V_{prev\bot}(-y_{vprev},x_{vprev});同理与VnextV_{next}垂直,指向shape外的向量为Vnext(yvnext,xvnext)V_{next\bot}(-y_{vnext},x_{vnext})。 将VprevV_{prev}垂直边向外扩1个单位,得到一个点:

Pprevout(xprevout,yprevout)=Pprev+VprevVprevP_{prevout}(x_{prevout},y_{prevout})=P_{prev}+\frac{V_{prev\bot}}{|V_{prev\bot}|}

同理,将VnextV_{next}垂直边向外扩1个单位,得到一个点:

Pnextout(xnextout,ynextout)=Pnext+VnextVnextP_{nextout}(x_{nextout},y_{nextout})=P_{next}+\frac{V_{next\bot}}{|V_{next\bot}|}

然后以PprevoutP_{prevout}为起点,以VprevV_{prev}为方向画一条直线,表达式为:

P=Pprevout+tVprevP=P_{prevout}+tV_{prev}

同理,以PnextoutP_{nextout}为起点,以VnextV_{next}为方向也可以画一条直线,表达式为:

P=Pnextout+sVnextP=P_{nextout}+sV_{next}

这两条直线的交点就是P点向外扩后的点(特殊的三点共线的情况就先不考虑了),解方程:

Pprevout+tVprev=Pnextout+sVnextP_{prevout}+tV_{prev}=P_{nextout}+sV_{next}

就可以将交点位置的t和s都解出来。解方程的过程这里就省略了,感兴趣的可以手动解一下,最终:

ti=(xnextoutxprevout)yvnext(ynextoutyprevout)xvnextxvprevyvnextyvprevxvnextt_i=\frac{(x_{nextout}-x_{prevout})y_{vnext}-(y_{nextout}-y_{prevout})x_{vnext}}{x_{vprev}y_{vnext}-y_{vprev}x_{vnext}}

顶点PP向外扩后的点为:

Pout=Pprevout+tiVprevP_{out}=P_{prevout}+t_iV_{prev}

外扩向量为:

Vout=PoutPV_{out}=P_{out}-P

如图所示:

绘图.png

这里注意一下,这个外扩的向量不是单位向量,所以也不用做归一化,除非原来的两个向量的夹角非常尖锐,需要做一定的缩短处理,可以看源码。
在ExtrudeGeometry的源码中,上文的过程被写为一个函数getBevelVec,这个函数就是专门用来求每个点的外扩向量的。代码基本上和我上文介绍的一致。要注意就两点:一是对三点共线做了判断;二是如果外扩的向量模大于2,就将它的长度归一化到2。
将每个点的外扩向量写入到oneHoleMovements和verticesMovements这两个数组中。(和上节一样,本文中的示例没有holes,所以这两个是一模一样的。)

function getBevelVec( inPt, inPrev, inNext ) {

        // computes for inPt the corresponding point inPt' on a new contour
        //   shifted by 1 unit (length of normalized vector) to the left
        // if we walk along contour clockwise, this new contour is outside the old one
        //
        // inPt' is the intersection of the two lines parallel to the two
        //  adjacent edges of inPt at a distance of 1 unit on the left side.

        let v_trans_x, v_trans_y, shrink_by; // resulting translation vector for inPt

        // good reading for geometry algorithms (here: line-line intersection)
        // http://geomalgorithms.com/a05-_intersect-1.html

        const v_prev_x = inPt.x - inPrev.x,
                v_prev_y = inPt.y - inPrev.y;
        const v_next_x = inNext.x - inPt.x,
                v_next_y = inNext.y - inPt.y;

        const v_prev_lensq = ( v_prev_x * v_prev_x + v_prev_y * v_prev_y );

        // check for collinear edges
        const collinear0 = ( v_prev_x * v_next_y - v_prev_y * v_next_x );
        //判断三点是否贡献
        if ( Math.abs( collinear0 ) > Number.EPSILON ) {

                // not collinear

                // length of vectors for normalizing

                const v_prev_len = Math.sqrt( v_prev_lensq );
                const v_next_len = Math.sqrt( v_next_x * v_next_x + v_next_y * v_next_y );

                // shift adjacent points by unit vectors to the left

                const ptPrevShift_x = ( inPrev.x - v_prev_y / v_prev_len );
                const ptPrevShift_y = ( inPrev.y + v_prev_x / v_prev_len );

                const ptNextShift_x = ( inNext.x - v_next_y / v_next_len );
                const ptNextShift_y = ( inNext.y + v_next_x / v_next_len );

                // scaling factor for v_prev to intersection point

                const sf = ( ( ptNextShift_x - ptPrevShift_x ) * v_next_y -
                                ( ptNextShift_y - ptPrevShift_y ) * v_next_x ) /
                        ( v_prev_x * v_next_y - v_prev_y * v_next_x );

                // vector from inPt to intersection point

                v_trans_x = ( ptPrevShift_x + v_prev_x * sf - inPt.x );
                v_trans_y = ( ptPrevShift_y + v_prev_y * sf - inPt.y );

                // Don't normalize!, otherwise sharp corners become ugly
                //  but prevent crazy spikes
                const v_trans_lensq = ( v_trans_x * v_trans_x + v_trans_y * v_trans_y );
                // 如果外扩向量的模大于2,就将它归一化到2,避免太尖锐的夹角。
                if ( v_trans_lensq <= 2 ) {

                        return new Vector2( v_trans_x, v_trans_y );

                } else {

                        shrink_by = Math.sqrt( v_trans_lensq / 2 );

                }

        } else {

                // handle special case of collinear edges

                let direction_eq = false; // assumes: opposite

                if ( v_prev_x > Number.EPSILON ) {

                        if ( v_next_x > Number.EPSILON ) {

                                direction_eq = true;

                        }

                } else {

                        if ( v_prev_x < - Number.EPSILON ) {

                                if ( v_next_x < - Number.EPSILON ) {

                                        direction_eq = true;

                                }

                        } else {

                                if ( Math.sign( v_prev_y ) === Math.sign( v_next_y ) ) {

                                        direction_eq = true;

                                }

                        }

                }

                if ( direction_eq ) {

                        // console.log("Warning: lines are a straight sequence");
                        v_trans_x = - v_prev_y;
                        v_trans_y = v_prev_x;
                        shrink_by = Math.sqrt( v_prev_lensq );

                } else {

                        // console.log("Warning: lines are a straight spike");
                        v_trans_x = v_prev_x;
                        v_trans_y = v_prev_y;
                        shrink_by = Math.sqrt( v_prev_lensq / 2 );

                }

        }

        return new Vector2( v_trans_x / shrink_by, v_trans_y / shrink_by );

}


const contourMovements = [];

for ( let i = 0, il = contour.length, j = il - 1, k = i + 1; i < il; i ++, j ++, k ++ ) {

        if ( j === il ) j = 0;
        if ( k === il ) k = 0;

        //  (j)---(i)---(k)
        console.log('i,j,k', contour[ i ], contour[ j ], contour[ k ])

        contourMovements[ i ] = getBevelVec( contour[ i ], contour[ j ], contour[ k ] );

}

const holesMovements = [];
let oneHoleMovements, verticesMovements = contourMovements.concat();

for ( let h = 0, hl = numHoles; h < hl; h ++ ) {

        const ahole = holes[ h ];

        oneHoleMovements = [];

        for ( let i = 0, il = ahole.length, j = il - 1, k = i + 1; i < il; i ++, j ++, k ++ ) {

                if ( j === il ) j = 0;
                if ( k === il ) k = 0;

                //  (j)---(i)---(k)
                oneHoleMovements[ i ] = getBevelVec( ahole[ i ], ahole[ j ], ahole[ k ] );

        }

        holesMovements.push( oneHoleMovements );
        verticesMovements = verticesMovements.concat( oneHoleMovements );

}

3.生成顶点

之前我们所介绍的都是生成二维顶点,接下来就是生成三维顶点了。三维顶点就是二维顶点作为x和y,再增加一个z方向上的值就行了。所以三维顶点的生成就是给原来的二维顶点增加z值。具体的顺序是沿着拉长的方向从z值依次增大。具体z值是多少,是和bevelSegments、bevelThickness、steps、depth这些参数有关,分别代表倒角斜面的分段和在z方向上的长度,主体的分段和在z方向上的长度。

绘图-2.png 先生成底面的倒角斜面,可以看到,根据bevelSegments的大小,有几个bevelSegments就分为几段,每一段都是contour.length个顶点。每一段中,会计算一个z值和bs值,底面倒角的z值是负值,bs值是contour中顶点在xy平面上向外扩时,加上节求的外扩向量时,向量乘的系数。可以看到,底面的系数为0,就是一点都不外扩,后面越扩越大,形成一个斜面。scalePt2函数的功能是给一个二维顶点加上一个向量乘系数,正好用作计算外扩的顶点坐标。这里注意生成了一个faces数组,这个数组存着顶面和底面每个三角面的顶点索引,下一节会用到。

let faces;

if ( bevelSegments === 0 ) {

        faces = ShapeUtils.triangulateShape( contour, holes );

} else {

        const contractedContourVertices = [];
        const expandedHoleVertices = [];

        // Loop bevelSegments, 1 for the front, 1 for the back

        for ( let b = 0; b < bevelSegments; b ++ ) {

                //for ( b = bevelSegments; b > 0; b -- ) {

                const t = b / bevelSegments;
                const z = bevelThickness * Math.cos( t * Math.PI / 2 );
                const bs = bevelSize * Math.sin( t * Math.PI / 2 ) + bevelOffset;

                // contract shape

                for ( let i = 0, il = contour.length; i < il; i ++ ) {

                        const vert = scalePt2( contour[ i ], contourMovements[ i ], bs );

                        v( vert.x, vert.y, - z );
                        if ( t === 0 ) contractedContourVertices.push( vert );

                }

                // expand holes

                for ( let h = 0, hl = numHoles; h < hl; h ++ ) {

                        const ahole = holes[ h ];
                        oneHoleMovements = holesMovements[ h ];
                        const oneHoleVertices = [];
                        for ( let i = 0, il = ahole.length; i < il; i ++ ) {

                                const vert = scalePt2( ahole[ i ], oneHoleMovements[ i ], bs );

                                v( vert.x, vert.y, - z );
                                if ( t === 0 ) oneHoleVertices.push( vert );

                        }

                        if ( t === 0 ) expandedHoleVertices.push( oneHoleVertices );

                }

        }

        faces = ShapeUtils.triangulateShape( contractedContourVertices, expandedHoleVertices );

}

const flen = faces.length;

接下来生成主体部分,主体部分的bs值为bevelSize加bevelOffset。第一段的z值为0,接下来每一段的z值与段号s和长度depth有关,为depth/steps×sdepth/steps×s

const bs = bevelSize + bevelOffset;

// Back facing vertices

for ( let i = 0; i < vlen; i ++ ) {

        const vert = bevelEnabled ? scalePt2( vertices[ i ], verticesMovements[ i ], bs ) : vertices[ i ];

        if ( ! extrudeByPath ) {

                v( vert.x, vert.y, 0 );

        } else {

                // v( vert.x, vert.y + extrudePts[ 0 ].y, extrudePts[ 0 ].x );

                normal.copy( splineTube.normals[ 0 ] ).multiplyScalar( vert.x );
                binormal.copy( splineTube.binormals[ 0 ] ).multiplyScalar( vert.y );

                position2.copy( extrudePts[ 0 ] ).add( normal ).add( binormal );

                v( position2.x, position2.y, position2.z );

        }

}

// Add stepped vertices...
// Including front facing vertices

for ( let s = 1; s <= steps; s ++ ) {

        for ( let i = 0; i < vlen; i ++ ) {

                const vert = bevelEnabled ? scalePt2( vertices[ i ], verticesMovements[ i ], bs ) : vertices[ i ];

                if ( ! extrudeByPath ) {

                        v( vert.x, vert.y, depth / steps * s );

                } else {

                        // v( vert.x, vert.y + extrudePts[ s - 1 ].y, extrudePts[ s - 1 ].x );

                        normal.copy( splineTube.normals[ s ] ).multiplyScalar( vert.x );
                        binormal.copy( splineTube.binormals[ s ] ).multiplyScalar( vert.y );

                        position2.copy( extrudePts[ s ] ).add( normal ).add( binormal );

                        v( position2.x, position2.y, position2.z );

                }

        }

}

最后是顶面倒角斜面,和底面类似,不过多介绍了。

for ( let b = bevelSegments - 1; b >= 0; b -- ) {

        const t = b / bevelSegments;
        const z = bevelThickness * Math.cos( t * Math.PI / 2 );
        const bs = bevelSize * Math.sin( t * Math.PI / 2 ) + bevelOffset;

        // contract shape

        for ( let i = 0, il = contour.length; i < il; i ++ ) {

                const vert = scalePt2( contour[ i ], contourMovements[ i ], bs );
                v( vert.x, vert.y, depth + z );

        }

        // expand holes

        for ( let h = 0, hl = holes.length; h < hl; h ++ ) {

                const ahole = holes[ h ];
                oneHoleMovements = holesMovements[ h ];

                for ( let i = 0, il = ahole.length; i < il; i ++ ) {

                        const vert = scalePt2( ahole[ i ], oneHoleMovements[ i ], bs );

                        if ( ! extrudeByPath ) {

                                v( vert.x, vert.y, depth + z );

                        } else {

                                v( vert.x, vert.y + extrudePts[ steps - 1 ].y, extrudePts[ steps - 1 ].x + z );

                        }

                }

        }

}

生成的顶点的x,y,z值依次由v函数加入到placeholder数组中:

function v( x, y, z ) {

        placeholder.push( x );
        placeholder.push( y );
        placeholder.push( z );

}

生成三角面

有了顶点,下来就是根据顶点生成三角面了,这里的所说的三角面,其实就对应的顶点着色器中vec3类型的position属性。一个三角面有三个顶点,每个顶点是一个三维坐标。
所以,一个三角面可以用三个顶点索引来表示,顶点索引乘3就可以找到顶点坐标值的索引,坐标值的索引+0,+1,+2分别就对应顶点x,y,z的坐标值,可以用这三个索引去placeholder中查询。
先生成顶面和底面,上文中生成的faces正好派上用场,这里底面的顶点索引就是face中的值,顶面的顶点由于在队尾,需要加上偏移量。f3函数就是通过三个顶点索引将顶点数据加入到verticesArray数组中,这个数组最终要传给顶点着色器的position属性的。

function buildLidFaces() {

        const start = verticesArray.length / 3;

        if ( bevelEnabled ) {

                let layer = 0; // steps + 1
                let offset = vlen * layer;

                // Bottom faces

                for ( let i = 0; i < flen; i ++ ) {

                        const face = faces[ i ];
                        f3( face[ 2 ] + offset, face[ 1 ] + offset, face[ 0 ] + offset );

                }

                layer = steps + bevelSegments * 2;
                offset = vlen * layer;

                // Top faces

                for ( let i = 0; i < flen; i ++ ) {

                        const face = faces[ i ];
                        f3( face[ 0 ] + offset, face[ 1 ] + offset, face[ 2 ] + offset );

                }

        } else {

                // Bottom faces

                for ( let i = 0; i < flen; i ++ ) {

                        const face = faces[ i ];
                        f3( face[ 2 ], face[ 1 ], face[ 0 ] );

                }

                // Top faces

                for ( let i = 0; i < flen; i ++ ) {

                        const face = faces[ i ];
                        f3( face[ 0 ] + vlen * steps, face[ 1 ] + vlen * steps, face[ 2 ] + vlen * steps );

                }

        }

        scope.addGroup( start, verticesArray.length / 3 - start, 0 );

}

再生成四个侧面,四个侧面依次生成,每个侧面都根据分段生成一系列四边形,每个四边形由两个三角形组成。具体的四边形的四个顶点为:VisV_{is}Vi+1sV_{i+1s}Vis+1V_{is+1}Vi+1s+1V_{i+1s+1},i代表侧面的棱号,i+1代表下一条棱,s代表段号,s+1代表下一段。f4函数就是通过四个顶点索引将两个三角面的顶点数据加入到verticesArray数组中。

function buildSideFaces() {

        const start = verticesArray.length / 3;
        let layeroffset = 0;
        sidewalls( contour, layeroffset );
        layeroffset += contour.length;

        for ( let h = 0, hl = holes.length; h < hl; h ++ ) {

                const ahole = holes[ h ];
                sidewalls( ahole, layeroffset );

                //, true
                layeroffset += ahole.length;

        }


        scope.addGroup( start, verticesArray.length / 3 - start, 1 );


}

function sidewalls( contour, layeroffset ) {

        let i = contour.length;

        while ( -- i >= 0 ) {

                const j = i;
                let k = i - 1;
                if ( k < 0 ) k = contour.length - 1;

                //console.log('b', i,j, i-1, k,vertices.length);

                for ( let s = 0, sl = ( steps + bevelSegments * 2 ); s < sl; s ++ ) {

                        const slen1 = vlen * s;
                        const slen2 = vlen * ( s + 1 );

                        const a = layeroffset + j + slen1,
                                b = layeroffset + k + slen1,
                                c = layeroffset + k + slen2,
                                d = layeroffset + j + slen2;

                        f4( a, b, c, d );

                }

        }

}

这里注意,在生成上下底面和生成侧面后各调用了一下addGroup,这就是给这两部分分别设置group,这也就是为什么在Mesh的Material属性为数组时,数组中的两种Material对象分别设置给顶底面和侧面。

给不同面设置不同材质

生成顶点和三角面介绍完了,可以看到,ExtrudeGeometry中只设置了两个group,一种是顶底面,一种是侧面。那么其实给每个面都设置一个group值,就可以给每个面设置不同的材质了。这个只需要将源码略作修改就能做到。将原来的buildLidFaces函数拆成两个函数buildBottomFaces和buildTopFaces,分别生成底面和顶面,然后分别设置group的索引为0和1。

function buildBottomFaces() {

        const start = verticesArray.length / 3;

        if (bevelEnabled) {

                let layer = 0; // steps + 1
                let offset = vlen * layer;

                // Bottom faces

                for (let i = 0; i < flen; i++) {

                        const face = faces[i];
                        f3(face[2] + offset, face[1] + offset, face[0] + offset);

                }

        } else {

                // Bottom faces

                for (let i = 0; i < flen; i++) {

                        const face = faces[i];
                        f3(face[2], face[1], face[0]);

                }

        }

        scope.addGroup(start, verticesArray.length / 3 - start, 0);

}

function buildTopFaces() {

        const start = verticesArray.length / 3;

        if (bevelEnabled) {
                let layer = steps + bevelSegments * 2;
                let offset = vlen * layer;

                // Top faces

                for (let i = 0; i < flen; i++) {

                        const face = faces[i];
                        f3(face[0] + offset, face[1] + offset, face[2] + offset);

                }

        } else {

                // Top faces

                for (let i = 0; i < flen; i++) {

                        const face = faces[i];
                        f3(face[0] + vlen * steps, face[1] + vlen * steps, face[2] + vlen * steps);

                }

        }

        scope.addGroup(start, verticesArray.length / 3 - start, 1);

}

生成侧面时,修改buildSideFaces和sidewalls每个侧面都设置一次group。

function buildSideFaces() {

        // const start = verticesArray.length / 3;
        let layeroffset = 0;
        sidewalls(contour, layeroffset);
        layeroffset += contour.length;

        for (let h = 0, hl = holes.length; h < hl; h++) {

                const ahole = holes[h];
                sidewalls(ahole, layeroffset);

                //, true
                layeroffset += ahole.length;

        }


        // scope.addGroup(start, verticesArray.length / 3 - start, 2);


}

function sidewalls(contour, layeroffset) {

        let i = contour.length;
        let group = 2
        while (--i >= 0) {
                const start = verticesArray.length / 3;
                const j = i;
                let k = i - 1;
                if (k < 0) k = contour.length - 1;

                //console.log('b', i,j, i-1, k,vertices.length);

                for (let s = 0, sl = (steps + bevelSegments * 2); s < sl; s++) {

                        const slen1 = vlen * s;
                        const slen2 = vlen * (s + 1);

                        const a = layeroffset + j + slen1,
                                b = layeroffset + k + slen1,
                                c = layeroffset + k + slen2,
                                d = layeroffset + j + slen2;

                        f4(a, b, c, d);

                }
                //每个侧面都设置一个group值
                scope.addGroup(start, verticesArray.length / 3 - start, group);
                group++;
        }

}

对比

绘制了一个底面为正方形的ExtrudeGeometry,Material设置了一个数组,这个数组中包含六种不同颜色的material,未改源码之前只显示前两种,分别设置给顶底面和四个侧面。修改后六个面就设置成六个不同的material。

import { useEffect, useRef } from "react"
import * as THREE from './threeSource/Three.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RGBELoader } from 'three/addons/loaders/RGBELoader.js';
let camera
let scene
let renderer
let controls
let requestId

function ThreeContainer() {
  const threeContainer = useRef(null);

  useEffect(() => {
    if (threeContainer.current) {
      scene = new THREE.Scene();
      scene.background = new THREE.Color(0x333333);
      scene.environment = new RGBELoader().load('/venice_sunset_1k.hdr');
      scene.environment.mapping = THREE.EquirectangularReflectionMapping;
      camera = new THREE.PerspectiveCamera(40, window.innerWidth / window.innerHeight, 0.1, 100);
      camera.position.set(4.25, 1.4, - 4.5);
      renderer = new THREE.WebGLRenderer({ antialias: true });
      renderer.toneMapping = THREE.ReinhardToneMapping;
      renderer.setSize(threeContainer.current.clientWidth, threeContainer.current.clientHeight);
      renderer.setPixelRatio(window.devicePixelRatio);

      controls = new OrbitControls(camera, renderer.domElement);
      threeContainer.current.appendChild(renderer.domElement);
      const length = 1, width = 1;

      const shape = new THREE.Shape();
      shape.moveTo(0, 0);
      shape.lineTo(0, width);
      shape.lineTo(length, width);
      shape.lineTo(length, 0);
      shape.lineTo(0, 0);

      const extrudeSettings = {
        steps: 2,
        depth: 4,
        bevelEnabled: true,
      };

      const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
      const material1 = new THREE.MeshBasicMaterial({ color: 0xFF0000,  });
      const material2 = new THREE.MeshBasicMaterial({ color: 0xFFA500,  });
      const material3 = new THREE.MeshBasicMaterial({ color: 0xFFFF00, });
      const material4 = new THREE.MeshBasicMaterial({ color: 0x00FF00, });
      const material5 = new THREE.MeshBasicMaterial({ color: 0x00FFFF, });
      const material6 = new THREE.MeshBasicMaterial({ color: 0x0000FF, });
      const mesh = new THREE.Mesh(geometry, [material1, material2, material3, material4, material5, material6]);
      scene.add(mesh);

      requestId = requestAnimationFrame(animate);
    }
    return () => {
      destroyRenderer()
    };
  }, [])

  function destroyRenderer() {
    if (requestId) {
      window.cancelAnimationFrame(requestId)
    }
    if (scene) {
      scene.traverse(obj => {
        if (obj instanceof THREE.Mesh) {
          if (obj.isMesh) {
            obj.geometry?.dispose();
            if (Array.isArray(obj.material)) {
              obj.material.forEach(m => disposeMaterial(m));
            } else {
              disposeMaterial(obj.material);
            }
          }
        }
      });
    }

    if (renderer) {
      renderer.domElement.remove();
      renderer.dispose();
      renderer.forceContextLoss();
      renderer = null
    }
  }
  function disposeMaterial(material) {
    material.dispose();
    Object.keys(material).forEach(key => {
      const val = material[key];
      if (val && val instanceof THREE.Texture) {
        // 类型断言后安全调用 dispose 方法
        val.dispose();
      }
    });
  }
  function animate() {
    // renderer?.clear()
    renderer?.render(scene, camera);
    if (controls) {
      controls.update()
    }
    requestAnimationFrame(animate);
  }

  return (
    <div ref={threeContainer} className="w-[100%] h-[100%]" id="three-container"></div>
  )
}

export default ThreeContainer

修改前:

11dd9128-73b8-4651-be3f-113882dcd396.png

修改后:

fcee88b2-6d38-4ae4-88a6-a769782e96e7.png

d3c96979-4472-44dc-9ef8-4ca118c977ed.png

手写Promise-构造函数

作者 云枫晖
2025年9月18日 17:29

在上篇手写Promise-什么是Promise中,我们已经了解了Promise的基本概念和用法。本文将开始用ES5的语法手写Promise的实现,首先从构造函数部分开始讲解。

基础结构搭建

在上篇手写Promise-什么是Promise已经了解到Promise是一个构造函数,接收一个executor参数并立即执行。executor接收两个参数:resolvereject,都是函数类型。可以写成如下代码:

(function () {
  // 开启严格模式
  "use strict";
  function Promise(executor) {
    // 存储this,方便后续使用
    var self = this;
    // resolve函数接收一个操作成功后的值
    var resolve = function (value) {};
    // reject函数接收一个操作失败的原因
    var reject = function (reason) {};
    executor(resolve, reject);
    // 暴露给浏览器
    if (typeof window !== "undefined") {
      window.Promise = Promise;
    }
    // 暴露给Node.js环境
    if (typeof module === "object" && typeof module.exports === "object") {
      module.exports = Promise;
    }
  }
})();

状态管理

Promise需要记录当前操作的状态(pendingfulfilledrejected)以及操作结果。状态一旦改变就不可逆转,这是Promise的重要特性。还需要存储操作成功时的值或操作失败的原因。为result

(function () {
  "use strict";
  function Promise(executor) {
    // 存储this,方便后续使用
    var self = this;
    // 状态常量
    var PENDING = "pending";
    var FULFILLED = "fulfilled";
    var REJECTED = "rejected";
    self.state = PENDING; // 初始状态
    self.result = void 0;
    // resolve函数接收一个操作成功后的值
    var resolve = function (value) {};
    // reject函数接收一个操作失败的原因
    var reject = function (reason) {};
    executor(resolve, reject);
  }
})();

异常处理

executor可能出现异常,需要捕获并将状态改为rejected。所以捕获到需要执行reject函数。

(function () {
  // 开启严格模式
  "use strict";
  function Promise(executor) {
    /* 代码省略 */
    // resolve函数接收一个操作成功后的值
    var resolve = function (value) {};
    // reject函数接收一个操作失败的原因
    var reject = function (reason) {};
    try {
      executor(resolve, reject);
    } catch(error) {
      reject(error);
    }
  }
})();

特别注意:try-catch这里无法捕获异步操作的异常。例如以下代码:

var p = new Promise((resolve) => {
    setTimeout(() => {
        throw new Error(111)
    })
})

image.png

参数合法性校验

为了代码健壮性,对参数executor进行校验。

(function () {
  // 开启严格模式
  "use strict";
  function Promise(executor) {
    // 存储this,方便后续使用
    var self = this;
    /* 代码省略 */
    if (typeof executor !== "function") {
      // 不是函数但是一个对象的报错
      if (typeof executor === "object" && executor !== null) {
        throw new TypeError(
          "TypeError: Promise resolver #<Object> is not a function"
        );
      }
      // 不是一个函数的报错
      throw new TypeError(`Promise resolver ${executor} is not a function`);
    }
    // 构造函数调用检查 - 必须通过new方式调用
    if (!(self instanceof Promise)) {
      throw new TypeError(
        `Promise constructor cannot be invoked without 'new'`
      );
    }
    /* 其余代码 */
  }
})();

初步实现resolve和reject函数

resolve和reject函数分别是成功和失败时调用的。同时当状态是pending时进行修改成对应的状态。

var resolve = function (value) {
  // 防止后续继续调用resolve或reject
  if (self.state !== PENDING) return;
  self.state = FULFILLED;
  self.result = value;
};
var reject = function (reason) {
  // 防止后续继续调用resolve或reject
  if (self.state !== PENDING) return;
  self.state = REJECTED;
  self.result = reason;
};

小结

本文实现的Promise构造函数具有以下特点:

✅ 已实现功能

  1. 基本结构:完整的构造函数框架
  2. 状态管理:三种状态(pending/fulfilled/rejected)的管理
  3. 状态不可逆:确保状态只能改变一次
  4. 异常处理:同步错误的捕获机制
  5. 参数校验:严格的类型和调用方式检查
  6. 环境适配:浏览器和Node.js环境支持

🎯 下一阶段目标

下一篇文章将实现Promise的核心——then方法,包括:

  • 回调函数的异步执行
  • 链式调用支持
  • 值穿透机制 希望对你有些许帮助!┏(^0^)┛

基于 Vue3@3.5+跟Ant Design of Vue 的二次封装的 Form跟搜索Table

2025年9月18日 17:02

二次封装的Form跟搜索Table

为啥想二次封装组件

作为一名刚入职新公司的前端开发者,我开局就面临 “双重挑战”:一方面,公司此前所有子系统基于 Vue2 开发,而团队希望我用 Vue3 搭建新业务模块,但给到的只有一个 “裸奔” 的种子工程 —— 没有任何封装好的通用组件,所有功能都得从零手撸;另一方面,我此前都是 React 技术栈,面试也是面的React的,说来也是干React,结果一拉gitlab仓库干的是vue的活,哈哈,对 Vue 生态只能算 “入门级玩家”,不是太深入的了解跟使用。

不过,受原公司主管理念的影响,原来一直吃的都是细糠,现在轮到自己起灶了,做好不好吃那也得吃。我还是决定先从高频场景入手:封装基于 ant-design-vue 的 JsonForm(JSON 配置化表单) 和 可搜索 Table。这么做有三个核心原因:

  1. 提效减重复:分页、表单校验、状态收集这些逻辑,不用每次开发都写一遍,后续维护也不用逐个模块改;

  2. 练手 Vue3:借封装过程熟悉 Vue3 的响应式、组合式 API、组件通信等核心能力,快速补齐技术短板;

  3. 公司水太深,适合摸鱼!?

JsonForm

网上其实有很多 JSON 配置化表单的二次封装方案,核心思路都大同小异 ——通过 “组件类型枚举匹配” 实现动态渲染。我这套方案也基于这个思路,但针对 Vue3 特性和实际业务场景做了适配优化。

核心实现:组件映射与增强(componentsMap)

JsonForm 的核心是 componentsMap 对象:它定义了 “配置类型” 与 “ant-design-vue 组件” 的映射关系,同时对部分组件做了默认属性注入和功能增强(比如支持异步加载选项、适配 Vue3 v-model 绑定)。

  1. 部分 componentsMap 代码
export const componentsMap = {
  Text,
  Time,
  Textarea,
  InputNumber,
  DatePicker,
  Input,
  RangePicker,
  // 扩展options可以支持异步获取,返回一个promise进行处理options数据结构
  Cascader: extendComponentsOptions(Cascader, {
    allowClear: true,
    showSearch: true,
    getPopupContainer: (triggerNode: HTMLElement) => triggerNode.parentNode,
  }),
  // 扩展options可以支持异步获取,返回一个promise进行处理options数据结构
  TreeSelect: extendComponentsOptions(TreeSelect, {
    allowClear: true,
    showSearch: true,
    getPopupContainer: (triggerNode: HTMLElement) => triggerNode.parentNode,
    filterTreeNode: (inputValue: string, { label }: any) =>
      label.indexOf(inputValue) !== -1,
  }),
  // 扩展options可以支持异步获取,返回一个promise进行处理options数据结构
  Select: extendComponentsOptions(Select, {
    allowClear: true,
    showSearch: true,
    getPopupContainer: (triggerNode: HTMLElement) => triggerNode.parentNode,
  }),
  CheckboxGroup: extendComponentsOptions(CheckboxGroup),
  RadioGroup: extendComponentsOptions(RadioGroup),
  Checkbox: transformBinding(Checkbox), // 处理 v-model:checked 绑定
  Switch: transformBinding(Switch), // 处理 v-model:checked 绑定
  Radio: transformBinding(Radio), // 处理 v-model:checked 绑定
}

  1. 关键设计亮点
  • 默认属性注入:比如所有选择类组件(Select/TreeSelect/Cascader)默认开启 showSearch(搜索)和 allowClear(清空),不用每次配置都写这两个属性;
  • 异步选项支持:getOptions: () => Promise(如从接口拉取部门列表),组件内部自动处理加载状态;
  • 依赖项显隐:isShow可以支持自定义场景下显示隐藏,也可以支持高级配置根据表单项内依赖值进行显示隐藏

配套资源:在线文档与示例

为了方便团队使用,用vitepress框架搭建了配套的在线文档,包含所有配置项说明、组件用法示例和常见问题解答:

image.png

在线文档地址:wangxuelina.github.io/general_ant…

四、后续规划

  1. 新增组件:支持在表单上传按钮组件、富文本编辑器等

  2. 上线npm包进行引用

Element UI 2.X 主题定制完整指南:解决官方工具失效的实战方案

作者 前端大鱼
2025年9月18日 16:44

基于Vue2+Element UI 2.X项目的主题换肤实战经验分享

背景与需求

最近在开发Vue2项目的换肤功能时,遇到了一个典型需求:除了变更自定义vue组件的主题相关CSS变量外,还需要同步更改Element UI 2.X组件的主题色。然而在实际操作过程中,发现官方提供的主题定制工具存在各种问题,给开发工作带来了不小困扰。

本文将分享两种经过验证的有效方法,帮助你顺利生成完整的Element UI 2.X CSS主题文件,实现完美的主题换肤效果。

问题分析

1. 官方在线主题编辑器服务不可用

Element UI官方主题编辑器(element.eleme.io/#/zh-CN/the…

2. 命令行工具问题频出

使用element-theme命令行工具时,各种安装报错和依赖问题屡见不鲜,即使更换Node版本也难以解决。

解决方案一:使用在线编辑器替代方案

这种方法适合需要快速生成主题的开发者,无需搭建本地环境。

操作步骤:

  1. 访问在线主题生成工具

    打开浏览器,访问 elementui.github.io/theme-chalk…

  1. 定制主题颜色

    • 点击右上角的"切换主题色"按钮
    • 选择或输入你需要的主题主色调
    • 实时预览效果,确保满足需求
  2. 获取主题文件

    • 点击"下载主题"按钮,获取ZIP压缩包
    • 解压后发现只有字体文件,没有CSS文件
  3. 提取CSS代码

    • 按F12打开开发者工具
    • 切换到"元素"标签页
    • <head>标签下找到<style>元素
    • 复制其中的压缩CSS代码

  1. 创建CSS文件
    • 将复制的代码保存为CSS文件(如element-ui.theme.css
    • 注意调整字体文件的引用路径,确保与实际文件位置匹配

解决方案二:通过源码编译定制主题(推荐)

这种方法适合需要深度定制的项目,可以修改所有样式变量,也是更稳定的方案。

环境准备与操作步骤:

  1. 获取Element UI源码

    # 克隆Element UI官方仓库
    git clone https://github.com/ElemeFE/element.git  
    # 进入项目目录
    cd element
    
  2. 安装项目依赖

    # 安装所有必要依赖
    npm install
    
    # 如果遇到node-sass问题,可以尝试使用镜像源
    npm install --sass-binary-site=https://npm.taobao.org/mirrors/node-sass
    
  3. 自定义主题变量

    • 打开文件:packages/theme-chalk/src/common/var.scss
    • 修改主要颜色变量:
    // 修改为主题色
    $--color-primary: #0080fe !default;
    
    // 可以继续修改其他变量
    $--color-success: #67c23a !default;
    $--color-warning: #e6a23c !default;
    $--color-danger: #f56c6c !default;
    $--color-text-primary: #303133 !default;
    $--color-text-regular: #606266 !default;
    
  4. 编译主题

    # 执行编译命令
    npm run build:theme
    
  5. 获取生成的文件

    • 编译完成后,在packages/theme-chalk/lib/目录下:
      • index.css - 完整的CSS样式文件
      • fonts/ - 相关字体文件
    • 将CSS文件和字体文件一并复制到你的项目中

高级定制技巧

除了修改主色调,你还可以定制其他样式变量,实现更精细的主题控制:

// 修改字体路径
$--font-path: 'element-ui/fonts' !default;

// 修改边框颜色和圆角
$--border-color-base: #dcdfe6 !default;
$--border-color-light: #e4e7ed !default;
$--border-color-lighter: #ebeef5 !default;
$--border-radius-base: 4px !default;
$--border-radius-small: 2px !default;

// 修改背景色
$--background-color-base: #f5f7fa !default;

// 修改尺寸变量
$--size-base: 14px !default;
$--size-large: 16px !default;
$--size-small: 13px !default;

// 修改按钮样式
$--button-font-size: $--size-base !default;
$--button-border-radius: $--border-radius-base !default;
$--button-padding-vertical: 12px !default;
$--button-padding-horizontal: 20px !default;

// 修改输入框样式
$--input-font-size: $--size-base !default;
$--input-border-radius: $--border-radius-base !default;
$--input-border-color: $--border-color-base !default;
$--input-background-color: #FFFFFF !default;

注意事项与最佳实践

  1. 字体路径问题

    • 确保CSS中的字体路径与实际存放路径一致
    • 如果字体加载失败,图标将无法正常显示
    • 建议使用相对路径或CDN地址
  2. 版本兼容性

    • 确保使用的Element UI版本与主题版本匹配
    • 不同版本的变量名称可能有所差异
  3. 生产环境部署

    • 对CSS文件进行压缩,减少体积
    • 使用CDN加速字体文件的加载
    • 考虑将主题文件与主应用代码分离部署
  4. 性能优化

    • 实现主题懒加载,避免初始加载时间过长
    • 考虑使用CSS变量实现部分动态样式,减少CSS文件大小

总结

本文介绍了两种解决Element UI 2.X主题定制问题的方法:

  1. 在线编辑器替代方案 - 适合快速生成基本主题
  2. 源码编译方式 - 适合需要深度定制的项目(推荐)

希望本文能帮助你顺利完成Element UI的主题定制工作。如果你有任何问题或更好的解决方案,欢迎在评论区分享交流!


欢迎关注我的微信公众号【大前端历险记】,获取更多开发实用技巧和解决方案!

Git 大小写敏感性问题:一次组件重命名引发的CI构建失败

作者 eason_fan
2025年9月17日 16:14

问题背景

最近在项目中推进代码规范整改,其中一项重要工作是统一组件和文件夹的命名规范。团队决定将所有组件文件夹和文件从原来的小写或混合命名改为大驼峰命名(PascalCase),这样可以更好地与React的最佳实践保持一致,也能提高代码的可读性。

在实施过程中,我负责将components目录下的一些文件和文件夹进行重命名。例如:

  • common文件夹重命名为Common
  • nameRender.tsx文件重命名为NameRender.tsx

问题出现

完成本地重命名后,我进行了以下操作:

  1. 运行了本地测试,所有测试用例都通过了
  2. 执行了本地构建命令,构建也成功完成
  3. 提交了代码并推送到GitLab仓库

然而,当代码进入CI/CD流程后,问题出现了:所有CI任务都失败了,错误信息一致指向找不到NameRender.tsx文件。这让我非常困惑,因为在本地环境一切正常。

排查过程

我开始了漫长的排查过程:

  1. 首先检查了我的本地文件,确认文件确实已经重命名为NameRender.tsx
  2. 查看了GitLab上的代码,发现远程仓库中的文件仍然显示为nameRender.tsx
  3. 尝试重新推送代码,但问题依然存在
  4. 我让同事拉取我的代码,他们的环境中确实是nameRender.tsx,构建也失败了
  5. 最奇怪的是,我自己重新拉取代码后,本地文件依然显示为NameRender.tsx,构建也正常,但CI构建依然失败

问题原因

经过深入研究,我终于找到了问题的根源:Git默认忽略文件名大小写变化

在macOS和Windows等操作系统中,文件系统通常是大小写不敏感的(虽然可以配置为大小写敏感)。当我在本地重命名文件时,操作系统会正确显示新的大小写名称,但Git在没有特别配置的情况下,不会将这种纯大小写变化识别为文件修改。

这就导致了一个奇怪的现象:

  • 在我自己的电脑上,文件看起来已经重命名了(操作系统层面)
  • 但在Git的视角里,文件名并没有改变
  • 当推送到远程仓库时,Git没有记录这个大小写变化
  • 当其他人拉取代码时,他们得到的是原始的小写文件名
  • 当我自己切换分支再切回来时,Git会根据它的记录覆盖本地文件,导致构建失败

解决方案

找到原因后,我采用了以下解决方案来正确地让Git识别文件名的大小写变化:

  1. 临时重命名法

    • 先将文件重命名为一个完全不同的名称,例如将nameRender.tsx改为nameRender_temp.tsx
    • 执行git add .命令,让Git检测到这个变化并暂存
    • 然后再将文件重命名为最终的大驼峰名称NameRender.tsx
    • 再次执行git add .,这时Git就能正确检测到文件名的变化了
  2. 配置Git大小写敏感(可选):

    • 可以通过运行git config core.ignorecase false来配置Git区分大小写
    • 但这种方法可能会导致其他问题,特别是在跨平台协作的项目中
  3. 批量处理脚本(针对大量文件):

    • 对于需要批量修改的场景,可以编写简单的脚本自动化上述临时重命名过程

实施效果

采用临时重命名法后,我成功地让Git识别了文件名的大小写变化。推送到远程仓库后,CI构建也顺利通过了。团队成员拉取最新代码后,都能看到正确的大驼峰命名文件,构建也不会出现问题。

经验总结

这次经历给我带来了以下几点宝贵经验:

  1. 了解Git的特性:Git在处理文件名大小写变化方面有其特殊性,特别是在不同操作系统上的表现可能不同

  2. 验证变更有效性:在进行文件名大小写修改后,应该通过切换分支或重新克隆仓库来验证变更是否真正被Git识别

  3. 制定规范时考虑工具限制:在制定代码规范时,需要考虑到所使用工具的特性和限制

  4. 团队协作中的注意事项:在多人协作的项目中,任何文件结构的变更都需要确保所有团队成员都能正确获取变更

  5. CI/CD的重要性:这次问题能被及时发现,得益于我们完善的CI/CD流程,它能在早期就发现本地环境可能无法暴露的问题

写在最后

Git作为目前最流行的版本控制系统,虽然强大,但也有一些容易被忽视的特性和细节。了解这些细节,不仅能帮助我们避免类似的问题,也能让我们更加高效地使用Git进行团队协作。希望我的这次经历能对遇到类似问题的开发者有所帮助!

AIGC 模型部署到 Web 端的技术选型:TensorFlow.js vs 「PyTorch.js」🏗️🧠

作者 LeonGao
2025年9月17日 09:56

在这个 “AI 生成内容(AIGC)” 盛行的时代,模型不像以前一样只在实验室里喝电、在服务器里冒烟了。它们迫切希望“上网冲浪”,直接驻扎在用户的浏览器里,为 Web 应用增添一点点灵性。问题来了:我们该用哪门“武功秘籍”把这些模型请进浏览器?

今天的擂台,就摆在两位“明星”之间:

  • TensorFlow.js:正统科班出身,浏览器原生支持,工具链齐全。
  • PyTorch.js:名字听起来像“正牌”,但实则是多方社区的组合拳(torchscript + ONNX.js + WASM/ WebGPU 支持),还在走江湖的探索阶段。

1. 为何要部署到 Web 端?🌐

先问自己一个灵魂拷问:我们为什么要“拖家带口”把模型弄到浏览器?

  • 零后端依赖:用户打开网页 -> 模型直接在本地跑 -> 节省服务器成本。💰
  • 实时交互:小到文本提示,大到图像风格迁移,都能免去网络延迟。
  • 隐私安全:数据不出本地浏览器,某些敏感任务更安心。
  • 跨平台:只要有浏览器——无论是 Win、Mac、Linux,甚至冰箱里的安卓系统,都能跑。

当然,代价就是浏览器要“爆肝”:CPU、GPU 资源有限,模型太大就会让风扇尖叫。


2. 技术底层原理初探 🧐🔬

TensorFlow.js 的武学秘籍

  • 底层加速:通过 WebGL、WebGPU,把矩阵运算丢到显卡中批量处理。
  • 模型格式:支持直接加载 .json + 二进制权重,或者从 TF SavedModel/ Keras 转换。
  • 生态:官方维护,模型 Zoo 丰富,社区教程多。

👉 打个比方:TensorFlow.js 就像是一条修得很直的高速公路,收费站多,但也有服务区和油站。

PyTorch.js 的江湖现状

严格地说,没有一个官方的“PyTorch.js” 。目前社区常见的做法是:

  1. PyTorch → TorchScript:把模型转成可序列化的 IR(中间表示)。
  2. TorchScript → ONNX:导出成开放格式。
  3. ONNX.js (或 WebNN / WebGPU runtime) :在浏览器解析并运行。

👉 所以,“PyTorch.js”更像是一支“联合战队”,需要几步转化才能落地。就像走川藏线:路美,但要翻山越岭、修车补胎。


3. Hello World 对比示例 🤹‍♂️

3.1 TensorFlow.js:一句话加载模型

import * as tf from '@tensorflow/tfjs'

// 加载已经训练好的模型
const model = await tf.loadLayersModel('/models/my_model.json')

// 输入张量推理
const input = tf.tensor([0.1, 0.2, 0.3, 0.4])
const output = model.predict(input)
output.print()

👉 清清爽爽,浏览器 GPU 一键加速。


3.2 PyTorch.js 路线:间接转 ONNX

  1. 在 PyTorch 里导出模型(Python):

    import torch
    dummy_input = torch.randn(1, 3, 224, 224)
    torch.onnx.export(model, dummy_input, "model.onnx")
    
  2. 在 Web 端用 ONNX Runtime Web 或 ONNX.js 加载(JS):

    import * as ort from 'onnxruntime-web'
    
    const session = await ort.InferenceSession.create('/models/model.onnx')
    const tensor = new ort.Tensor('float32', new Float32Array([0.1, 0.2]), [1, 2])
    const results = await session.run({ input: tensor })
    console.log(results.output.data)
    

👉 这就是现实:PyTorch 部署到 Web,需要绕个大弯。


4. TensorFlow.js vs PyTorch.js:武力值对比 ⚔️

项目 TensorFlow.js 🚀 PyTorch.js(ONNX 路线) 🛤️
成熟度 官方维护,更新频繁 非官方方案,碎片化社区支持
模型转换 原生支持 TF 格式 需 PyTorch → ONNX 转换
运行时 WebGL / WebGPU / WASM ONNX.js / WebNN Runtime
易用性 一行 tf.loadLayersModel 多步导出 + 配置
生态 丰富预训练模型、教程、demo 模块化,学习曲线更陡
适合人群 希望快速上线 Web AI 功能的前端/全栈 坚守 PyTorch 训练全流程的深度学习研究员

5. 性能上的一些“人话” 📊

  • TensorFlow.js:对小模型(<几十 MB),无压力;对复杂 CNN、Transformer,性能会受限,常需蒸馏或量化。
  • PyTorch.js:依赖 ONNX Runtime 的优化程度;WebGPU 来临之后可能弯道超车,但目前体验上还有点像“踩着独轮车上高速”。

🎵 就像在咖啡馆点单:

  • TensorFlow.js:直接菜单点菜,上桌快。
  • PyTorch.js:你先得写张条子,然后送去厨房翻译,再请厨师做,最后才端上来。

6. 总结:该选谁?🤔

  • 如果你前端优先,想快速跑 Demo:直接选 TensorFlow.js,生态和文档不用愁。
  • 如果你训练全靠 PyTorch,又懒得切换框架:只能走 “PyTorch → ONNX → Web” 的路线,虽然绕,但能保证训练-推理链条一致。
  • 如果你在意 未来趋势:WebGPU、WebNN 规范逐渐成熟,PyTorch 社区可能会形成更统一的 Web 解决方案,值得观望。

7. 一点 ASCII 艺术的收尾 🎨

      ┌───────────┐          ┌───────────┐
      │TensorFlow │          │ PyTorch   │
      │   (TF.js) │          │   (ONNX)  │
      └─────┬─────┘          └─────┬─────┘
            │                        │
   快速加载模型 🚀             转换导出 🛤️
            │                        │
           浏览器端 AI ✨ (AIGC Everywhere!)

彩蛋总结 🥚

  • TensorFlow.js:像速溶咖啡,方便,口味稳定。
  • PyTorch.js 路线:像手冲咖啡,步骤繁琐,但风味原始、专业感强。
  • 两者都是 “AIGC 上 Web” 的魔法钥匙,只是打开的方式不太一样。

所以,下次用户问你: “这个 AIGC 能在浏览器里跑吗?”
你就可以带着坏笑回答:
“能!只不过……你想要速溶的还是手冲的?”☕😏

Next 全栈之 API 测试:Supertest 与 MSW 双雄记 🥷⚔️

作者 LeonGao
2025年9月17日 09:53

在软件工程的江湖里,API 测试向来是一门必修的武学。它的地位就如同武侠小说里的“轻功”:平时看似不炫技,但一旦失手,整套招式都会摔个狗吃屎。今天我们就来聊聊 Next.js 全栈开发中的 API 测试,并请出测试江湖的两位重量级人物——SupertestMSW


1. 为什么要在 Next.js 中做 API 测试? 🔍

Next 作为一门“全栈一体机”,不仅能写页面,还能写 API 路由。但凡有 API,就离不开测试,原因如下:

  • 验证逻辑正确性:比如 /api/login 不能随便放人进来,不然你家服务器可成了免费宾馆。
  • 保障协作:前后端分离开发时,API 是两拨程序员之间的“血书契约”,必须验证它说到做到。
  • 防回归:上线后改了一行不起眼的代码,可能昨天还能跑通的接口,今天返回的就是“无情的 500 错误”。

一句话总结:不写测试,等同于把 Bug 当彩蛋送给用户


2. Supertest:一拳打在 API 的命门上 👊

Supertest 是测试 Node.js HTTP 服务器的利器,它的定位是“拳头”,直接出击后端的 API 路由。

使用场景

  • 测试 Next.js API Route
  • 不依赖前端,只用最纯粹的请求与响应来检验后端逻辑。

安装

npm install --save-dev supertest jest

一个小例子

假设你在 /pages/api/hello.js 写了一个很官方的路由:

export default function handler(req, res) {
  res.status(200).json({ message: "Hello from API" })
}

对应的测试可以这样写:

import request from "supertest"
import handler from "../pages/api/hello"  // 引入你的 handler
import { createServer } from "http"

describe("API /hello", () => {
  let server

  beforeAll(() => {
    server = createServer((req, res) => handler(req, res))
  })

  it("should return a hello message", async () => {
    const response = await request(server).get("/api/hello")
    expect(response.status).toBe(200)
    expect(response.body).toEqual({ message: "Hello from API" })
  })
})

🧐 本质上,Supertest 就是偷偷扮演用户,发起 HTTP 请求,把 API 的底裤扒拉个干净。


3. MSW:江湖中的幻术师 🪄

如果 Supertest 是拳头,那 MSW(Mock Service Worker) 更像是“幻术师”。

它的核心思想是:在浏览器或 Node 测试环境里,劫持请求,给前端“画个饼”
这样就算后端 API 还没有 ready,你依然能在前端愉快地开发或测试。

使用场景

  • 前端测试:测试 React 组件时,不依赖真实 API。
  • 模拟网络交互:构建假响应,以保证 UI 的可测试性。

安装

npm install msw --save-dev

例子:假装有个用户接口

拦截 /api/user 请求:

// mocks/handlers.js
import { rest } from "msw"

export const handlers = [
  rest.get("/api/user", (req, res, ctx) => {
    return res(
      ctx.status(200),
      ctx.json({ username: "Neo", role: "Chosen One" })
    )
  })
]

测试 React 组件时,你的组件就能收到假的用户数据,而不用真的等后端上线。


4. Supertest vs MSW:双雄对比 🐉🐅

特点 Supertest 🥊 MSW 🎭
目标对象 后端 API handler 前端请求 (fetch/axios)
运行环境 Node.js 测试 浏览器 / Node 测试
使用方式 直接发请求到 API 拦截 HTTP 请求并返回假数据
最适合的场景 验证 API 路由逻辑 测试 UI 在不同数据下的表现

一句话总结:

  • Supertest 是“武力值”担当:打 API 逻辑的真功夫。
  • MSW 是“心灵幻术”专家:前端世界里,虚拟真实感。

5. 如何在 Next 全栈项目中融会贯通? 🧘‍♂️

很多开发者会这么搭配:

  1. 后端 API 路由 → 用 Supertest 保证逻辑健壮。
  2. 前端 UI 测试 → 用 MSW 模拟接口响应,保障 UI 正常显示。

就像金庸小说里练“左右互搏术”,两只手各练一套武功,最后合体无敌。


6. 给灵魂一点图画 🎨

   Supertest (强硬派)         MSW (幻术派)
          🥊 ----------------- 🎭
             \               /
              \             /
               \           /
              Next.js 全栈测试

7. 彩蛋小结 🥚

  • Supertest:直捣黄龙,测试 API 的血肉之躯。
  • MSW:虚拟真实,前端世界的“假数据艺术家”。
  • 双剑合璧:Next.js 全栈项目里,你既能保证 API 的逻辑靠谱,又能确保 UI 面对风吹草动依然淡定从容。

所以,下次写代码的时候,不妨把你的 Supertest 和 MSW 当成左右手。就像玩《街头霸王》,一边波动拳,一边升龙拳,什么 Bug 都挡不住你啦! 🚀

❌
❌