阅读视图

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

从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑实录

背景

上个月,我接手了一个新的 DeFi 项目前端开发。第一个核心功能就是用户钱包连接。团队技术栈是 React + TypeScript,对于 Web3 交互,我们选择了老牌且功能强大的 ethers.js 库。我心想:“连接 MetaMask 嘛,官方文档例子那么多,还不是手到擒来?” 于是,我复制了一段最常见的示例代码,准备十分钟搞定这个功能。然而,现实很快给了我一个教训——从“连接”到“稳定可用”之间,隔着一整条满是坑的沟。

问题分析

我最开始的思路非常简单:检查 window.ethereum 是否存在,如果存在,就用 ethers.providers.Web3Provider 包装它,然后调用 provider.send('eth_requestAccounts', []) 来触发 MetaMask 的授权弹窗。代码跑起来,在已经安装了 MetaMask 的浏览器里,第一次点击确实弹窗了,连接成功了。

但问题接踵而至:

  1. 刷新页面后,连接状态丢失:用户需要重新点击连接并授权,体验极差。
  2. 用户切换了 MetaMask 账户:前端界面上的地址没有自动更新。
  3. 用户切换了网络(比如从以太坊主网切换到 Goerli 测试网):我们的 DApp 需要感知到这个变化,并可能提示用户切换回目标网络。
  4. 用户根本没有安装 MetaMask:页面直接报错,白屏。

最初的代码只处理了“发起连接”这个单一动作,完全没考虑 Web3 应用是“动态的”、“有状态的”。我意识到,我需要构建的不是一个“连接按钮”,而是一个完整的“钱包连接状态管理器”。它需要监听区块链提供者的各种事件,并将状态同步到 React 组件中。

核心实现

第一步:封装一个健壮的钱包连接钩子

我决定创建一个自定义 React Hook useWallet 来集中管理所有钱包状态。首先,要安全地获取 window.ethereum 对象。这里就有第一个坑:TypeScript 不知道 window.ethereum 的类型

// types/global.d.ts
interface Window {
  ethereum?: any; // 为了快速开发,可以先设为 any,更严谨的做法是导入 MetaMask 的 EIP-1193 类型
}

// hooks/useWallet.ts
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

declare global {
  interface Window {
    ethereum?: any;
  }
}

export const useWallet = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  // 初始化:检查是否已授权
  useEffect(() => {
    if (!window.ethereum) {
      setError('请安装 MetaMask 钱包扩展');
      return;
    }
    const initProvider = new ethers.providers.Web3Provider(window.ethereum, 'any'); // 'any' 允许任何网络
    setProvider(initProvider);

    // 尝试获取已连接的账户
    initProvider.listAccounts().then((accounts) => {
      if (accounts.length > 0) {
        setAccount(accounts[0]);
        setSigner(initProvider.getSigner());
      }
    });

    // 获取当前网络链 ID
    initProvider.getNetwork().then((network) => {
      setChainId(network.chainId);
    });
  }, []);
}

注意new Web3Provider(window.ethereum, 'any') 中的 'any' 参数很重要,它告诉 ethers 我们接受任何网络,这样在监听网络切换时不会抛出错误。

第二步:实现连接与断开连接

连接函数需要处理用户交互和可能的拒绝。

// 在 useWallet 钩子内
const connectWallet = useCallback(async () => {
  if (!provider) {
    setError('Provider 未初始化');
    return;
  }
  setIsConnecting(true);
  setError('');
  try {
    // 这会触发 MetaMask 弹窗
    const accounts = await provider.send('eth_requestAccounts', []);
    const currentAccount = accounts[0];
    setAccount(currentAccount);
    setSigner(provider.getSigner());
    // 连接成功后,再获取一次最新的网络信息
    const network = await provider.getNetwork();
    setChainId(network.chainId);
  } catch (err: any) {
    console.error('连接钱包失败:', err);
    // 用户拒绝连接是最常见的错误
    setError(err.code === 4001 ? '用户拒绝了连接请求' : `连接失败: ${err.message}`);
  } finally {
    setIsConnecting(false);
  }
}, [provider]);

const disconnectWallet = useCallback(() => {
  // 注意:MetaMask 没有真正的“断开连接”API,这里只是清除本地状态
  setAccount('');
  setSigner(null);
  setChainId(0);
  // 在实际项目中,你可能还需要清除相关的应用状态(如用户余额、NFT等)
}, []);

这里有个坑disconnectWallet 并不能让 MetaMask 忘记你的网站授权。真正的“断开”需要用户在 MetaMask 界面手动操作。我们只是在前端清除了状态。

第三步:监听账户与网络变化

这是实现“状态同步”的核心。我们需要监听 window.ethereum 发出的事件。

// 在 useWallet 钩子的 useEffect 中,初始化之后
useEffect(() => {
  if (!window.ethereum) return;

  // 监听账户变更
  const handleAccountsChanged = (accounts: string[]) => {
    console.log('accountsChanged', accounts);
    if (accounts.length === 0) {
      // MetaMask 被锁定或用户主动断开连接了所有账户
      disconnectWallet();
    } else if (accounts[0] !== account) {
      // 用户切换了账户
      setAccount(accounts[0]);
      if (provider) {
        setSigner(provider.getSigner());
      }
    }
  };

  // 监听链 ID 变更(网络切换)
  const handleChainChanged = (_chainId: string) => {
    // 注意:MetaMask 文档建议在链变更时刷新页面,但现代 DApp 通常不这样做
    // 我们只是更新 chainId 状态,组件可以根据新 chainId 做出反应(如提示切换网络)
    console.log('chainChanged', _chainId);
    // chainId 是十六进制字符串,需要转换
    setChainId(parseInt(_chainId, 16));
    // 网络变了,provider 和 signer 实例其实还能用,但某些场景可能需要重置
    if (provider) {
      provider.getNetwork().then(network => setChainId(network.chainId));
    }
  };

  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  // 组件卸载时清除监听
  return () => {
    window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
    window.ethereum?.removeListener('chainChanged', handleChainChanged);
  };
}, [provider, account, disconnectWallet]); // 依赖项要小心,避免重复绑定

关键细节chainChanged 事件回调的参数是十六进制字符串,而 etherschainId 是数字,需要转换。另外,监听器一定要在组件卸载时移除,防止内存泄漏。

第四步:在组件中使用并处理网络不匹配

最后,在组件中集成这个 Hook,并处理一个常见业务逻辑:如果用户不在我们支持的网络上,提示他切换。

// components/WalletConnector.tsx
import React from 'react';
import { useWallet } from '../hooks/useWallet';
import { shortenAddress } from '../utils/address'; // 一个格式化地址的辅助函数

const SUPPORTED_CHAIN_ID = 1; // 假设我们只支持以太坊主网

export const WalletConnector: React.FC = () => {
  const {
    account,
    chainId,
    isConnecting,
    error,
    connectWallet,
    disconnectWallet,
  } = useWallet();

  const isOnSupportedNetwork = chainId === SUPPORTED_CHAIN_ID;

  const handleSwitchNetwork = async () => {
    if (!window.ethereum) return;
    try {
      // 尝试切换到以太坊主网
      await window.ethereum.request({
        method: 'wallet_switchEthereumChain',
        params: [{ chainId: '0x1' }], // 主网的十六进制链ID
      });
    } catch (switchError: any) {
      // 如果用户没有添加该网络,可以尝试添加它
      if (switchError.code === 4902) {
        try {
          await window.ethereum.request({
            method: 'wallet_addEthereumChain',
            params: [{
              chainId: '0x1',
              chainName: 'Ethereum Mainnet',
              nativeCurrency: { name: 'ETH', symbol: 'ETH', decimals: 18 },
              rpcUrls: ['https://mainnet.infura.io/v3/YOUR_INFURA_KEY'],
              blockExplorerUrls: ['https://etherscan.io'],
            }],
          });
        } catch (addError) {
          console.error('添加网络失败', addError);
        }
      }
      console.error('切换网络失败', switchError);
    }
  };

  if (error && !window.ethereum) {
    return <div className="error">未检测到钱包,请安装 MetaMask。</div>;
  }

  return (
    <div className="wallet-connector">
      {!account ? (
        <button onClick={connectWallet} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接钱包'}
        </button>
      ) : (
        <div className="wallet-info">
          {!isOnSupportedNetwork && (
            <div className="network-warning">
              当前网络不受支持。
              <button onClick={handleSwitchNetwork}>切换到主网</button>
            </div>
          )}
          <span className="address">{shortenAddress(account)}</span>
          <button onClick={disconnectWallet} className="disconnect-btn">
            断开
          </button>
        </div>
      )}
      {error && <div className="error">{error}</div>}
    </div>
  );
};

完整代码

考虑到篇幅,这里提供一个整合后的 hooks/useWallet.ts 核心代码概览,以及一个简单的 utils/address.ts

// hooks/useWallet.ts
import { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';

declare global {
  interface Window {
    ethereum?: any;
  }
}

export const useWallet = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [signer, setSigner] = useState<ethers.Signer | null>(null);
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<number>(0);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  const disconnectWallet = useCallback(() => {
    setAccount('');
    setSigner(null);
    setChainId(0);
  }, []);

  const connectWallet = useCallback(async () => {
    if (!provider) {
      setError('Provider 未初始化');
      return;
    }
    setIsConnecting(true);
    setError('');
    try {
      const accounts = await provider.send('eth_requestAccounts', []);
      const currentAccount = accounts[0];
      setAccount(currentAccount);
      setSigner(provider.getSigner());
      const network = await provider.getNetwork();
      setChainId(network.chainId);
    } catch (err: any) {
      console.error('连接钱包失败:', err);
      setError(err.code === 4001 ? '用户拒绝了连接请求' : `连接失败: ${err.message}`);
    } finally {
      setIsConnecting(false);
    }
  }, [provider]);

  useEffect(() => {
    if (!window.ethereum) {
      setError('请安装 MetaMask 钱包扩展');
      return;
    }
    const initProvider = new ethers.providers.Web3Provider(window.ethereum, 'any');
    setProvider(initProvider);

    initProvider.listAccounts().then((accounts) => {
      if (accounts.length > 0) {
        setAccount(accounts[0]);
        setSigner(initProvider.getSigner());
      }
    });

    initProvider.getNetwork().then((network) => {
      setChainId(network.chainId);
    });

    const handleAccountsChanged = (accounts: string[]) => {
      if (accounts.length === 0) {
        disconnectWallet();
      } else if (accounts[0] !== account) {
        setAccount(accounts[0]);
        if (initProvider) {
          setSigner(initProvider.getSigner());
        }
      }
    };

    const handleChainChanged = (_chainId: string) => {
      setChainId(parseInt(_chainId, 16));
    };

    window.ethereum.on('accountsChanged', handleAccountsChanged);
    window.ethereum.on('chainChanged', handleChainChanged);

    return () => {
      window.ethereum?.removeListener('accountsChanged', handleAccountsChanged);
      window.ethereum?.removeListener('chainChanged', handleChainChanged);
    };
  }, [disconnectWallet]); // 注意依赖,这里只依赖了稳定的 disconnectWallet

  return {
    provider,
    signer,
    account,
    chainId,
    isConnecting,
    error,
    connectWallet,
    disconnectWallet,
  };
};

// utils/address.ts
export const shortenAddress = (address: string, chars = 4): string => {
  if (!address) return '';
  return `${address.substring(0, chars + 2)}...${address.substring(42 - chars)}`;
};

踩坑记录

  1. Provider 未初始化错误:在 connectWallet 函数中直接使用 provider,但 provider 的初始化在 useEffect 中,是异步的。在用户快速点击连接按钮时,provider 可能还是 null解决:在函数开始处增加 if (!provider) return; 的判断。
  2. 重复监听事件导致内存泄漏:最初我把事件监听写在了一个没有依赖数组的 useEffect 里,导致组件每次渲染都绑定新监听器,旧监听器未移除。解决:确保 useEffect 有正确的依赖数组,并在清理函数中 removeListener
  3. 网络切换后 signer 失效的错觉:用户切换网络后,我最初错误地认为需要重新创建 providersigner。实际上,ethersWeb3Provider 实例在传入 'any' 参数后,可以跨网络工作,signer 仍然有效。需要更新的只是 chainId 状态。解决:在 handleChainChanged 中只更新 chainId,除非有特定业务需求,否则不重置 provider/signer
  4. chainId 类型不一致ethersgetNetwork() 返回的 chainIdnumber,而 window.ethereumchainChanged 事件返回的是十六进制 string。直接比较会导致判断失败。解决:统一转换为数字类型再比较,使用 parseInt(_chainId, 16)

小结

通过这次实践,我深刻体会到 Web3 前端连接钱包不仅仅是弹出一个授权窗口,更是一个需要持续监听和同步外部状态(账户、网络)的复杂功能。封装一个自定义 Hook 来集中管理这些状态和副作用,是让代码保持清晰、可维护的关键。下一步,可以在此基础上集成钱包余额查询、交易发送监听、以及多钱包提供商(如 WalletConnect)的支持。

InheritedWidget 原理与性能

一、核心定位

InheritedWidget 是 Flutter 框架中用于实现跨组件、跨层级高效状态共享的核心机制,本质是一种依赖注入方案,用于解决 Widget 树中深层组件获取上层数据的问题,避免通过构造函数逐层传值的繁琐操作(即“prop drilling”),也是 Provider、Riverpod 等主流状态管理框架的底层实现基础。

常见应用场景:Theme、MediaQuery、Localizations 等 Flutter 内置功能,均通过 InheritedWidget 实现全局状态共享,例如 Theme.of(context).primaryColor 就是典型的 InheritedWidget 用法。

二、核心原理

2.1 核心特性

  • 不可变性(Immutable) :InheritedWidget 本身是不可变组件,其内部存储的数据通常标记为 final,无法直接修改,需配合 StatefulWidget、StateNotifier 等组件管理数据变更,通过重建 InheritedWidget 实现状态更新。
  • 依赖注册与通知机制:子组件通过特定方法获取 InheritedWidget 数据时,会自动注册为依赖者;当 InheritedWidget 数据变化且满足通知条件时,仅通知所有依赖它的子组件重建,而非整个子树重建,保证更新效率。
  • 树内传递特性:数据仅在当前 Widget 树内共享,无法跨树使用,依赖 BuildContext 实现上层查找,脱离当前上下文无法获取数据。

2.2 底层实现机制(源码级简化)

2.2.1 核心类与方法

InheritedWidget 继承自 ProxyWidget,核心方法与关联类如下:

  • createElement():返回 InheritedElement 实例,作为 InheritedWidget 在 Element 树中的对应节点,负责管理依赖关系。
  • updateShouldNotify(oldWidget):抽象方法,用于判断 InheritedWidget 重建时,是否需要通知依赖它的子组件。返回 true 则通知,返回 false 则不通知,是控制性能的关键方法。
  • InheritedElement:继承自 ProxyElement,内部维护 _dependents 集合(Map<Element, Object>),用于存储所有依赖当前 InheritedWidget 的子 Element,是依赖关系的核心载体。

2.2.2 依赖建立与更新流程(4步)

  1. 数据共享初始化:将 InheritedWidget 嵌入 Widget 树上层,其对应的 InheritedElement 会在挂载(mount)和激活(active)阶段,通过 _updateInheritance() 方法,将自身信息写入所有子 Element 的 _inheritedWidgets映射中,实现数据向下传递的基础。
  2. 依赖注册:子组件通过 context.dependOnInheritedWidgetOfExactType<T>() 方法(通常封装为 of(context) 简化调用),向上查找最近的指定类型 T 的 InheritedWidget。此时,当前子 Element 会被注册到 InheritedElement 的 _dependents 集合中,正式建立依赖关系。
  3. 数据更新触发:通过 setState 等方式修改 InheritedWidget 中的数据,触发 InheritedWidget 重建,生成新的实例。
  4. 依赖通知与重建:框架调用 updateShouldNotify(oldWidget) 方法,若返回 true,InheritedElement 会遍历 _dependents 集合,调用所有依赖 Element 的 markNeedsBuild() 方法,触发这些子组件重建;若返回 false,则不进行任何通知,避免无效重建。

2.2.3 关键补充:两种查找方式的区别

子组件获取 InheritedWidget 数据有两种核心方式,直接影响是否建立依赖关系:

  • dependOnInheritedWidgetOfExactType:建立依赖关系,当 InheritedWidget 数据变化且满足通知条件时,当前子组件会被重建(最常用方式)。
  • getElementForInheritedWidgetOfExactType:仅获取 InheritedWidget 数据,不建立依赖关系,数据变化时不会触发当前子组件重建,适用于仅读取数据、不依赖数据更新的场景。

三、性能分析

3.1 优势:高效的状态共享

  • 精准更新,避免冗余重建:仅通知依赖的子组件重建,而非整个 Widget 树,相比全局状态(如全局变量)的“一刀切”更新,大幅减少不必要的重建操作,提升渲染性能。
  • 查找效率接近 O(1) :每个 Element 都维护 _inheritedWidgets 映射,存储所有祖先 InheritedElement 的引用,子组件查找时无需逐层遍历 Widget 树,直接通过映射获取,查找效率极高。
  • 无侵入式集成:无需修改子组件结构,仅通过 of(context) 即可获取数据,代码侵入性低,易于维护和扩展,适配 Flutter 响应式架构。

3.2 潜在性能隐患

  • 过度依赖导致大面积重建:若多个无关子组件均依赖同一个 InheritedWidget,当数据变化时,所有依赖组件都会重建,可能引发性能瓶颈(例如将全局状态都放入一个 InheritedWidget 中)。
  • updateShouldNotify 滥用:若该方法始终返回 true,即使数据未发生实际变化,也会通知所有依赖组件重建,造成无效渲染;若返回 false 时机不当,会导致数据更新后子组件无法同步刷新。
  • 不必要的依赖注册:子组件仅需读取数据、无需响应更新时,仍使用 dependOnInheritedWidgetOfExactType 建立依赖,导致多余的重建触发。
  • 数据粒度粗放:InheritedWidget 本身不支持细粒度数据监听,若存储的是复杂对象,即使仅其中一个字段变化,也会触发所有依赖组件重建,无法精准控制更新范围。

四、性能优化实践

4.1 精准控制通知时机

优化 updateShouldNotify 方法,仅在数据发生实际变化时返回 true,避免无效通知。示例:

class MyInherited extends InheritedWidget {
  final int count;

  const MyInherited({super.key, required super.child, required this.count});

  // 仅当count发生变化时,通知依赖组件
  @override
  bool updateShouldNotify(covariant MyInherited oldWidget) {
    return count != oldWidget.count; // 精准对比核心数据
  }

  static MyInherited of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<MyInherited>()!;
  }
}

注意:避免在该方法中执行复杂计算、网络请求等耗时操作,否则会影响渲染性能。

4.2 拆分 InheritedWidget,细化数据粒度

将不同类型的状态拆分到多个独立的 InheritedWidget 中,使子组件仅依赖自身需要的状态,避免“一个 InheritedWidget 管理所有状态”导致的大面积重建。例如:将主题状态、用户信息状态、计数器状态分别封装为独立的 InheritedWidget,子组件按需依赖,数据变化时仅影响对应依赖者。

4.3 合理选择查找方式,避免多余依赖

仅读取数据、无需响应数据更新的子组件,使用 getElementForInheritedWidgetOfExactType 替代 dependOnInheritedWidgetOfExactType,避免建立不必要的依赖关系,减少无效重建。示例:

// 仅读取数据,不依赖更新
final myInherited = context.getElementForInheritedWidgetOfExactType<MyInherited>()?.widget as MyInherited;
final count = myInherited.count;

4.4 使用 InheritedModel 实现细粒度更新

InheritedModel 是 InheritedWidget 的增强版,支持按“维度(aspect)”控制更新范围,允许子组件仅监听特定字段的变化,适用于复杂状态场景。示例:

// 定义支持多维度的InheritedModel
class MyModel extends InheritedModel<String> {
  final int countA;
  final int countB;

  const MyModel({super.key, required super.child, required this.countA, required this.countB});

  @override
  bool updateShouldNotify(covariant MyModel oldWidget) {
    return countA != oldWidget.countA || countB != oldWidget.countB;
  }

  // 仅当依赖的维度发生变化时,通知子组件
  @override
  bool updateShouldNotifyDependent(MyModel oldWidget, Set<String> dependencies) {
    if (dependencies.contains('countA')) return countA != oldWidget.countA;
    if (dependencies.contains('countB')) return countB != oldWidget.countB;
    return false;
  }

  // 子组件按需监听指定维度
  static MyModel ofA(BuildContext context) {
    return InheritedModel.inheritFrom<String>(context, aspect: 'countA')!;
  }

  static MyModel ofB(BuildContext context) {
    return InheritedModel.inheritFrom<String>(context, aspect: 'countB')!;
  }
}

此时,仅监听“countA”的子组件,仅在 countA 变化时重建;监听“countB”的子组件,仅在 countB 变化时重建,大幅提升性能。

4.5 拆分依赖子树,隔离静态组件

将依赖 InheritedWidget 的组件拆分为独立子树,非依赖组件使用 const 构造函数,避免因 InheritedWidget 变化导致非依赖组件重建。示例:

Widget build(BuildContext context) {
  final theme = Theme.of(context); // 获取依赖数据
  return Column(
    children: [
      // 依赖子树:仅当theme变化时重建
      _ThemeDependentPart(theme: theme),
      // 静态组件:使用const,不会因theme变化重建
      const ExpensiveStaticWidget(),
    ],
  );
}

// 独立依赖子组件
class _ThemeDependentPart extends StatelessWidget {
  final ThemeData theme;
  const _ThemeDependentPart({required this.theme});

  @override
  Widget build(BuildContext context) {
    return Text("依赖主题的文本", style: theme.textTheme.titleLarge);
  }
}

4.6 结合 DevTools 定位性能问题

通过 Flutter DevTools 的 Performance 面板,启用“Track widget rebuilds”功能,定位因 InheritedWidget 导致的过度重建:

  1. 运行应用:flutter run --profile
  2. 在 DevTools 中查看 Widget 重建次数,识别频繁重建的依赖组件;
  3. 跳转至源码,检查依赖注册方式和 updateShouldNotify 实现,针对性优化。

五、常见误区

  • 误区1:认为 InheritedWidget 可以直接修改状态:InheritedWidget 本身是不可变的,无法直接修改内部数据,需配合 StatefulWidget 或状态管理工具(如 StateNotifier)管理数据变更,通过重建 InheritedWidget 实现状态更新。
  • 误区2:滥用 InheritedWidget 管理所有状态:将全局所有状态放入一个 InheritedWidget 中,会导致数据变化时大面积组件重建,应按功能拆分多个 InheritedWidget,细化数据粒度。
  • updateShouldNotify 误区3:忽略 的优化:始终返回 true 会导致无效重建,始终返回 false 会导致数据更新无法同步,需根据实际数据变化逻辑精准实现该方法

遗嘱、水管与抢救室:TS 切入 Go 的流程控制、接口与并发

🚀 省流助手(速通结论)

  • Defer 扫尾:它是函数的“遗愿”,锁定的是函数作用域而非代码块。后进先出(LIFO)执行。
  • 接口纯粹性:Go 接口严禁包含变量。它只定义行为,且实现是隐式的(不需要 implements)。
  • 切片不是数组:Slice 是底层内存的窗口。扩容会触发“搬家”,不注意 copy 会导致数据人格分裂。
  • 并发解耦:WaitGroup 负责同步,Channel 负责传球。不要通过共享内存来通信。

1. Defer 扫尾机制:它是“遗嘱”而非 finally

在 TS 中,finally 紧跟在 try 代码块之后。但在 Go 中,defer 是函数级的延迟调用。

TypeScript(代码块级收尾)

async function writeInfo() {
    try {
        const file = await openFile();
        // ... 逻辑 A
    } finally {
        file.close(); // 块结束立刻执行
    }
    // ... 逻辑 B (此时文件已关闭)
}

Go(函数级遗嘱)

func writeInfo() {
    file, _ := os.Open("test.txt")
    // defer 锁死的是整个函数。即使逻辑 B 还在跑,file 也不会关
    defer file.Close() 

    // 如果逻辑多,必须包装成匿名函数并显式调用 ()
    defer func() {
        fmt.Println("开始清理多项资源")
        // 复杂收尾逻辑...
    }() 
}

🪝 思维钩子:defer 像是在函数出口处“埋雷”。多个 defer 会像堆盘子一样后进先出(最后声明的先执行)。


2. 接口的行为契约:严禁携带“私货”

在 TS 中,interface 既可以定义方法也可以定义属性(变量)。但在 Go 中,接口是纯粹的行为契约。

TypeScript(混合定义)

interface ReadWriter {
    readonly id: number; // ✅ 合法:可以包含属性
    read(): void;
}

Go(纯粹行为)

type ReadWriter interface {
    // b int // ❌ 编译报错:接口不能包含数据字段
    Read()
    Write()
}

🪝 思维钩子:Go 接口只关心“你能做什么”,而不关心“你长什么样”。想定义属性?请回 struct。这种纯粹性让 Go 的隐式实现(只要方法对上,就自动实现接口)变得异常强大。


3. 切片的动态魔术:小心“窗口”背后的陷阱

TS 开发者常把 Slice 当成普通 Array。实际上,它是指向底层内存的一个带容量描述的窗口。

TypeScript(切片即副本)

const original = [1, 2, 3];
const sub = original.slice(0, 2); 
sub[0] = 99;
console.log(original[0]); // 1 (原数组不受影响)

Go(切片即视图)

original := []int{1, 2, 3}
sub := original[0:2] // sub 是原内存的“窗口”
sub[0] = 99
fmt.Println(original[0]) // ⚠️ 99 (原数组被同步修改了!)

// 💡 只有执行 copy() 才是真正的“深拷贝”

🪝 思维钩子:append 操作是分水岭。如果容量(Cap)够,它改原件;如果容量不够触发扩容,它会偷偷“搬家”并断开与原数组的联系。


4. 并发等待的范式:从 Promise 到通道

TS 靠 Promise.all 监听状态,Go 靠 sync.WaitGroup 计数或 Channel 传球。

TypeScript(状态监听)

// 并行运行,主线程通过 Promise 状态获知结束
await Promise.all([task1(), task2()]);

Go(计数同步)

var wg sync.WaitGroup

// 任务抽离为独立函数时,必须传递指针 *sync.WaitGroup
func doTask(i int, wg *sync.WaitGroup) {
    defer wg.Done() // 任务完成,计数减一
    fmt.Println(i)
}

func main() {
    wg.Add(2) // 显式计数
    go doTask(1, &wg) // 开启独立协程
    go doTask(2, &wg)
    wg.Wait() // 阻塞直到计数归零
}

🪝 思维钩子:go 关键字开启的是一个并行时空。WaitGroup 是你的“考勤表”,而 Channel 是不同时空间互通有无的“输油管”。


结语:
通过这三篇的“直觉对手戏”,你已经完成了从 TS 到 Go 的底层思维重构。Go 的魅力不在于语法糖,而在于那份极致的确定性。

祝你在 Gopher 的世界里,写出像水一样清澈的代码。

从 Demo 到生产:为什么你的 AI 功能一上线就成了不可控的“黑盒”?

做 AI 功能时,痛苦的通常不是“完全不会写”,而是另一种更微妙的状态:功能大概有了,接口也通了,但只要一出问题,整个人就开始陷入循环:是前端没发出去吗?后端没收到吗?模型调用失败了吗?还是解析挂了?

最难受的地方不是失败,而是失败以后你几乎看不见它是怎么失败的。在 AI 应用这种长链路场景下,“可观测性”不是附属品,而是核心竞争力


💡 省流助手

  • 黑盒困境:AI 链路极长(前端 -> 后端 -> 模型服务 -> 解析 -> 返回),单点失败会全线崩溃。
  • 核心方案:建立“可观测性”。引入 Request ID 串联全链路,并使用结构化日志替代零散的 print
  • 实战动作:记录原始 Response、监控 Token 消耗、量化 Latency(耗时统计)。

为什么“能跑”和“能维护”之间差了一层“可见性”?

很多人初学 AI 功能的目标是“先把它跑通”。但在真实生产环境下,你需要的不仅是它能跑,而是:

  • 失败时可定位:一眼看出哪层断了。
  • 异常时可复现:拿到当时的上下文。
  • 排错不靠猜:前后端别再互相甩锅。

在并发/分布式场景下,单点调试已经彻底失效。你必须通过日志,让系统“开口说话”。


代码范式:从“盲目打印”到“全链路追踪”

❌ 错误做法:随缘 print(日志碎了一地,根本接不起来)

当并发超过 2 个时,你根本分不清控制台里的输出属于哪个用户,哪些是成功的,哪些是重试的。

# 典型的“碎地式”打印
def call_ai(text):
    print(f"开始调用模型: {text}")
    res = model.invoke(text)
    print(f"模型返回了: {res}")
    return parse(res)

✅ 正确做法:结构化日志 + Request ID(全链路定责)

给每次请求发一张“身份证”,让所有关联日志都打上这个标记。

import uuid
import logging

# 1. 结构化日志配置(通常在全局初始化)
logger = logging.getLogger("AI-Service")

def ai_handler(text):
    # 2. 为每次请求生成唯一“身份证”
    request_id = str(uuid.uuid4())
    extra = {"request_id": request_id}
    
    logger.info(f"Step 1: 收到前端请求 | Input: {text[:20]}...", extra=extra)
    
    try:
        # 3. 记录耗时和原始输出
        res = model.invoke(text)
        logger.info("Step 2: 模型调用成功 | Response: {res[:50]}", extra=extra)
        
        result = parse(res)
        logger.info("Step 3: 结果解析完成", extra=extra)
        return result
    except Exception as e:
        # 4. 异常捕获必须带上下文,否则你永远不知道是哪个输入触发了报错
        logger.error(f"全链路崩溃 | 原因: {str(e)}", extra=extra, exc_info=True)
        raise e

生产环境避坑指南

1. JSON 沉默失败陷阱

模型有时会返回包含 markdown 代码块的 JSON。如果解析器没处理好,会直接报错,导致前端拿到一个空对象。

  • 对策:日志中必须记录原始 Response。当你发现解析报错时,能回看模型到底吐了什么“怪东西”。

2. Token 溢出与隐形杀手

上下文过长时,模型会直接截断输出。

  • 对策:日志记录中必须包含 usage(Token 消耗)。如果输出只说了一半,看一眼日志里的 Token 限制你就全明白了。

3. Latency(耗时)是排查性能瓶颈的唯一手段

分清是后端没收到请求,还是模型在“憋大招”超时。

  • 对策:在日志中量化每一段的耗时(网络时间 vs 模型生成时间)。

协作效率:日志是最低成本的沟通工具

在团队里,“看不见问题”会迅速变成摩擦点:

  • 前端说:“我页面转圈,接口挂了。”
  • 后端说:“我不确定请求到我这没。”

全链路追踪 (Tracing) 建立后,讨论会变样: “看这个 request_id: a1b2...,请求 10:05 进来的,模型在 10:06 返回了 500,是 Prompt 触发了敏感词拦截。”

这就叫确定性线索


给前端开发者的建议

下次写 AI 功能,除了逻辑实现,请强制自己问这 3 个问题:

  1. 如果这次请求失败了,我能在 30 秒内定位是哪层挂了吗?
  2. 如果用户说“刚刚那条不对”,我能通过日志找到原始输入和模型输出吗?
  3. 我的日志是否具备系统可观测性 (Observability)

结语

很多 AI 功能做不下去,不是因为开发者不会写,而是因为每次出问题系统都“失声”了。结构化日志 (Structured Logging) 不仅是调试工具,更是你作为工程开发者的专业护城河。


标签:AI、前端、架构、Python、工程化

📄 第三篇:Vue 3 命令式弹窗 Provide 污染与关闭动画修复

问题背景

在使用 useCommandComponent 封装命令式弹窗时,遇到了两个典型的底层机制问题:

  1. Provide/Inject 数据污染:多次打开弹窗后,子组件 inject 到了上一次残留的旧数据。
  2. 关闭动画丢失:弹窗关闭时 DOM 被过早销毁,导致过渡动画无法完整播放。

修复点1:AppContext 上下文污染

1. 问题复现

测试场景

<!-- TestModal.vue -->
<script setup>
import { provide, inject } from 'vue'

// 每次打开都 provide 一个随机值
provide('modalConfig', Math.random())

// 尝试 inject 同一个 key
const config = inject('modalConfig')
console.log('inject 结果:', config)
</script>
// App.vue
const showModal = useCommandComponent(TestModal)

操作流程与现象

操作 provide 的值 inject 预期 inject 实际 状态
第1次打开 0.123456 undefined undefined ✅ 正常
关闭弹窗 - - - -
第2次打开 0.789012 undefined 0.123456 ❌ 污染

关键特征:

  • 首次运行正常,第2次才出问题。
  • 不报错,只是数据不对(最难排查的 bug 类型)。
  • 拿到的不是本次 provide 的值,而是上一次的残留。

2. 根本原因分析

错误代码

// useCommandComponent.js - 有问题的版本
export const useCommandComponent = (Component) => {
  const instance = getCurrentInstance()
  
  // ❌ 直接修改全局 appContext.provides
  const appContext = instance?.appContext
  const currentProvides = instance?.provides
  
  if (appContext && currentProvides) {
    Reflect.set(appContext, 'provides', currentProvides)
  }
  
  // ...
}

污染链路详解

初始状态:

App.appContext.provides = {}

第1次注册(App.vue 中调用):

const showModal = useCommandComponent(TestModal)

// instance = App 实例
// currentProvides = App.provides = {}

// 覆盖全局(此时是空对象,暂时没问题)
Reflect.set(App.appContext, 'provides', {})

第1次打开弹窗:

showModal()

// 创建 TestModal 实例1
TestModal实例1.provides = Object.create(App.appContext.provides)

// setup 执行
provide('modalConfig', 0.123456)
// TestModal实例1.provides = { modalConfig: 0.123456 }

const config = inject('modalConfig')
// 查询链:TestModal实例1.provides → App.appContext.provides
// 结果:undefined ✅(符合预期)

// ⚠️ 如果内部嵌套调用 useCommandComponent
const showChild = useCommandComponent(ChildComponent)
// currentProvides = TestModal实例1.provides
// Reflect.set(App.appContext, 'provides', TestModal实例1.provides)
// App.appContext.provides = { modalConfig: 0.123456 } ❌ 被污染!

关闭弹窗:

// unmount(TestModal实例1)

// ❌ 但 App.appContext.provides 没有被恢复
// 仍然是:{ modalConfig: 0.123456 }

第2次打开弹窗:

showModal()

// 创建 TestModal 实例2
TestModal实例2.provides = Object.create(App.appContext.provides)
// TestModal实例2.provides.__proto__ = { modalConfig: 0.123456 } ❌ 原型链指向旧数据

// setup 执行
provide('modalConfig', 0.789012)
// TestModal实例2.provides = { modalConfig: 0.789012 }

const config = inject('modalConfig')
// 查询链:TestModal实例2.provides → App.appContext.provides
// 结果:0.123456 ❌ 拿到了第1次的残留数据!

核心问题

嵌套调用 useCommandComponent 
  ↓
覆盖全局 appContext.provides
  ↓
关闭后未恢复
  ↓
新实例的原型链指向旧数据
  ↓
inject 通过原型链查到残留值

3. 修复方案

修复代码

// useCommandComponent.js - 修复版本(第30-35行)
export const useCommandComponent = (Component) => {
  const instance = getCurrentInstance()
  
  // ✅ 先复制 appContext,再独立设置 provides
  const appContext = { ...instance?.appContext }
  Reflect.set(appContext, 'provides', currentProvides)
  
  // ...
}

修复原理对比

修复前(有问题):

const appContext = instance?.appContext
Reflect.set(appContext, 'provides', currentProvides)
// ❌ 所有调用共用同一个 appContext,互相覆盖

修复后(正确):

const appContext = { ...instance?.appContext }
Reflect.set(appContext, 'provides', currentProvides)
// ✅ 两步操作:
//    1. 创建完全独立的新对象
//    2. 单独设置 provides 属性
//    彻底隔离,互不影响

关键点:

  • { ...instance?.appContext } 创建全新的独立对象。
  • 新对象与原始 appContext 没有任何关联。

修复点2:onClosed 回调与关闭动画

1. Element Plus 的关闭事件机制

Element Plus 的 <el-dialog> 有两个关闭相关的事件:

事件 触发时机 说明
@close 用户点击关闭按钮时 动画开始前立即触发
@closed 关闭动画播放完成后 动画结束后触发

完整执行流程:

用户点击关闭按钮
  ↓
① @close 触发(此时动画还没开始)
  ↓
② 播放关闭动画(约 300ms)
  ↓
③ @closed 触发(动画已完全结束)

2. 为什么要用 onClosed 而不是 onClose?

关键问题:DOM 清理时机

命令式弹窗需要在关闭后清理 DOM:

const closed = () => {
  render(null, container)        // 卸载组件
  container.remove()             // 移除 DOM
}

如果在 @close 时清理:

用户点击关闭
  ↓
@close 触发
  ↓
立即执行 closed() → render(null, container)
  ↓
❌ 组件被销毁,动画无法继续播放
❌ 用户看到弹窗"瞬间消失",没有过渡效果

如果在 @closed 时清理:

用户点击关闭
  ↓
@close 触发(动画开始)
  ↓
播放关闭动画(300ms)✅ 动画完整播放
  ↓
@closed 触发(动画结束)
  ↓
执行 closed() → render(null, container) ✅ 安全清理

3. Vue 属性透传的作用

当弹窗组件作为根元素且未在 defineProps 中声明 onClosed 时,Vue 会自动将其透传给内部的 <el-dialog>

<!-- TestModal.vue -->
<template>
  <!-- el-dialog 是根元素 -->
  <el-dialog v-model="visible">
    弹窗内容
  </el-dialog>
</template>

<script setup>
// 没有声明 onClosed,Vue 自动透传
</script>

外部调用:

showModal({
  onClosed: () => console.log('用户回调')
})

结果: onClosed 被透传给 <el-dialog>,相当于:

<el-dialog v-model="visible" @closed="onClosed">

4. 实现方案

核心逻辑

// useCommandComponent.js(第42-60行)

// 清理函数
const closed = () => {
  render(null, container)
  container.parentNode?.removeChild(container)
}

const CommandComponent = (options = {}) => {
  // ... 其他逻辑
  
  // ✅ 统一处理 onClosed,确保动画完整 + DOM 清理
  if (typeof options.onClosed !== 'function') {
    // 用户没提供 onClosed,使用默认清理函数
    options.onClosed = closed
  } else {
    // 用户提供了 onClosed,包裹一层确保能清理 DOM
    const originOnClosed = options.onClosed
    options.onClosed = (...args) => {
      originOnClosed(...args)  // 先执行用户回调
      closed()                 // 再执行 DOM 清理
    }
  }
  
  // ...
}

📄 第一篇:Vue 3 命令式弹窗使用指南

📄 第二篇:Vue 3 命令式弹窗 provide/inject 机制解析

📄 第三篇:Vue 3 命令式弹窗 Provide 污染与关闭动画修复

📄 第二篇:Vue 3 命令式弹窗 provide/inject 机制解析

1. 标准组件 vs 命令式组件

什么是标准组件?

通过模板声明,由 Vue 自动管理。

<!-- App.vue -->
<template>
  <!-- ✅ 标准组件:在模板中声明 -->
  <ChildComponent />
</template>

特点:

  • 写在 <template>
  • parent 指向父组件实例

什么是命令式组件?

通过函数调用创建,手动挂载到 DOM。

// useCommandComponent.js
export const useCommandComponent = (Component) => {
  const container = document.createElement('div')
  
  return (options = {}) => {
    const vNode = createVNode(Component, options)
    render(vNode, container)  // ← 手动渲染
    document.body.appendChild(container)
  }
}
<!-- App.vue - 使用 -->
<script setup>
const showModal = useCommandComponent(TestModal)

function open() {
  showModal({ title: '弹窗' })  // ← 函数调用
}
</script>

特点:

  • 不在模板中声明
  • 通过函数调用(如 showModal()
  • parent = null(没有父组件)

2. 为什么 parent = null?

标准组件的渲染流程

// Vue 内部
patch(parentVNode, childVNode, container, parentComponent)
//                                        ^^^^^^^^^^^^^^
//                                        传入父组件实例

结果: ChildComponent.parent = App实例


命令式组件的渲染流程

// useCommandComponent 内部
render(vNode, container)

// Vue 内部
patch(null, vNode, container, null, ...)
//                          ^^^^
//                          parent 传的是 null

结果: TestModal.parent = null

原因: 命令式组件不是通过父组件模板渲染的,而是直接 render 到 DOM,Vue 将其视为"根组件"。


3. provides 初始化逻辑

Vue 源码(简化版)

function createComponentInstance(vnode, parent, suspense) {
  const instance = {
    parent: parent,
    appContext: vnode.appContext,
    
    // 关键:provides 的初始化方式
    provides: parent 
      ? parent.provides  // 有 parent:直接引用父组件的 provides
      : Object.create(vnode.appContext.provides)  // 无 parent:创建新对象
  }
  return instance
}

两种情况的内存结构

标准组件(ChildComponent 的父组件是 App)

App实例.provides = { config: 'app数据' }

ChildComponent实例.provides = App实例.provides  // ← 同一个对象引用

特点: 父子共用同一个 provides 对象。


命令式组件(TestModal 在 App 中创建)

appContext.provides = { config: 'app数据' }

TestModal实例.provides = {}  // 新空对象
TestModal实例.provides.__proto__ → appContext.provides

特点:

  • provides 是独立空对象
  • 原型链指向 appContext.provides

4. provide/inject 的逻辑

provide 的行为

function provide(key, value) {
  const instance = getCurrentInstance()
  
  // 如果 provides 和 parent.provides 是同一个对象
  if (instance.parent && instance.provides === instance.parent.provides) {
    // 写时复制:创建新对象,避免污染父组件
    instance.provides = Object.create(instance.provides)
  }
  
  // 写入自己的 provides
  instance.provides[key] = value
}

关键点: provide 总是写入当前实例自己的 provides


inject 的行为(核心差异)

function inject(key) {
  const instance = getCurrentInstance()
  
  if (instance.parent == null) {
    // ⚠️ 命令式组件走这里
    const provides = instance.vnode.appContext.provides
    // 查的是 appContext.provides,不是 instance.provides
    if (key in provides) {
      return provides[key]
    }
  } else {
    // 标准组件走这里
    const provides = instance.parent.provides
    // 查的是父组件的 provides
    if (key in provides) {
      return provides[key]
    }
  }
}

关键差异:

  • 标准组件injectparent.provides
  • 命令式组件injectappContext.provides

5. 实际示例

场景设置

// App.vue
provide('config', { theme: 'dark' })
const showModal = useCommandComponent(TestModal)
<!-- TestModal.vue -->
<script setup>
provide('modalConfig', { title: '我是弹窗' })
const config = inject('config')  // ← 能拿到吗?
</script>

执行流程

1. 创建 TestModal 实例

TestModal实例.provides = {}
TestModal实例.provides.__proto__ → appContext.provides = { config: { theme: 'dark' } }

2. TestModal setup 执行

// provide
provide('modalConfig', { title: '我是弹窗' })
// TestModal实例.provides = { modalConfig: { title: '我是弹窗' } }

// inject
const config = inject('config')
// 因为 parent === null
// 查的是 appContext.provides
// appContext.provides.config → ✅ 找到 { theme: 'dark' }

结果: config = { theme: 'dark' }


6. 子组件的情况

ChildTestModal(TestModal 的子组件)

<!-- TestModal.vue 模板 -->
<template>
  <ChildTestModal />
</template>
// ChildTestModal 实例
ChildTestModal.parent = TestModal实例
ChildTestModal.provides = TestModal实例.provides  // 初始时是同一个对象

ChildTestModal 调用 provide

<!-- ChildTestModal.vue -->
<script setup>
provide('childData', '子组件数据')
</script>
// provide 内部检测到 provides === parent.provides
ChildTestModal.provides = Object.create(TestModal实例.provides)
// 现在是一个新对象
ChildTestModal.provides.__proto__ → TestModal实例.provides

ChildTestModal.provides.childData = '子组件数据'

结果: 子组件不调用 provide 函数他的 provides 就等于父组件的 provides, 调用 provide 函数子组件的 provides 就是一个原型链指向父组件 provides 的新对象。


7. 小结

核心要点

  1. 命令式组件 parent = null:因为是直接 render 挂载,没有父组件
  2. provides 初始化不同:命令式组件用 Object.create 创建独立对象
  3. inject 查找链不同:命令式组件查 appContext.provides,标准组件查 parent.provides
  4. 子组件有无 provide 时结果不同:避免污染父组件的 provides

所以这是命令式组件可以 inject 到 App provide 提供的数据的原理 ✅

📄 第一篇:Vue 3 命令式弹窗使用指南

📄 第二篇:Vue 3 命令式弹窗 provide/inject 机制解析

📄 第三篇:Vue 3 命令式弹窗 Provide 污染与关闭动画修复

📄 第一篇:Vue 3 命令式弹窗使用指南

1. 快速开始

通过 useCommandComponent,你可以像调用函数一样打开一个弹窗,而无需在模板中写 <Dialog /> 标签。

import { useCommandComponent } from './composables/useCommandComponent'
import MyDialog from './components/MyDialog.vue'

// 1. 创建弹窗构造函数
const showDialog = useCommandComponent(MyDialog)

// 2. 调用函数打开弹窗
showDialog({
  title: '提示',
  content: '这是一个命令式弹窗',
  onClosed: (result) => {
    console.log('弹窗关闭,返回结果:', result)
  }
})

2. 两种核心用法

模式 A:Props 驱动(推荐简单场景)

适用于表单提交、确认框等一次性交互。你只需要传入参数并监听关闭回调。

组件定义 (ConfirmDialog.vue):

<template>
  <el-dialog :model-value="visible" :title="title" @closed="handleClosed">
    <p>{{ content }}</p>
    <template #footer>
      <el-button @click="handleCancel">取消</el-button>
      <el-button type="primary" @click="handleConfirm">确定</el-button>
    </template>
  </el-dialog>
</template>

<script setup>
defineProps(['visible', 'title', 'content'])
const emit = defineEmits(['closed'])

const handleConfirm = () => emit('closed', { action: 'confirm' })
const handleCancel = () => emit('closed', { action: 'cancel' })
const handleClosed = () => emit('closed', { action: 'close' })
</script>

调用方式:

const ConfirmDialog = useCommandComponent(ConfirmDialog)

ConfirmDialog({
  title: '删除确认',
  content: '确定要删除这条数据吗?',
  onClosed: (res) => {
    if (res.action === 'confirm') deleteItem()
  }
})

模式 B:Expose 驱动(推荐复杂交互)

适用于多步骤向导、需要外部触发更新或获取内部状态的复杂弹窗。

组件定义 (WizardDialog.vue):

<template>
  <el-dialog v-model="internalVisible" title="向导">
    <div>当前步骤: {{ step }}</div>
  </el-dialog>
</template>

<script setup>
import { ref } from 'vue'
const internalVisible = ref(false)
const step = ref(1)

// 暴露方法给外部调用
const open = (options) => {
  internalVisible.value = true
  step.value = options.startStep || 1
}

defineExpose({ open })
</script>

调用方式:

const WizardDialog = useCommandComponent(WizardDialog)

const dialogInstance = WizardDialog() // 此时弹窗未显示
dialogInstance.open({ startStep: 2 }) // 手动控制打开并传参

3. 常用配置项

属性 类型 说明
visible Boolean 默认为 true,控制弹窗显隐
appendTo String/HTMLElement 挂载点,默认为 body
onClosed Function 弹窗完全关闭(动画结束)后的回调

4. 核心源码实现

你可以直接将以下代码保存为 useCommandComponent.js。它封装了 Vue 3 的底层渲染逻辑,支持自动挂载、上下文传递以及实例方法暴露。

import { createVNode, getCurrentInstance, render } from 'vue'

export const useCommandComponent = (Component) => {
  // 1. 获取当前实例,提取 appContext 和 provides
  const instance = getCurrentInstance()
  let appContext = null

  if (instance) {
    // 创建一个关联的上下文对象
    // 目的:把父组件的 provides 传进去,否则动态渲染的弹窗会读不到父级数据
    appContext = {...instance.appContext}
    appContext.provides = instance.provides
  }

  // 2. 确定弹窗应该挂载到哪个 DOM 节点(默认是 body)
  const getAppendToElement = (props) => {
    let appendTo = document.body
    if (props.appendTo) {
      if (typeof props.appendTo === 'string') appendTo = document.querySelector(props.appendTo)
      else if (props.appendTo instanceof HTMLElement) appendTo = props.appendTo
      if (!(appendTo instanceof HTMLElement)) appendTo = document.body
    }
    return appendTo
  }

  // 3. 创建虚拟节点并渲染到临时容器中
  const initInstance = (Component, props, container, appContext = null) => {
    const vNode = createVNode(Component, props)
    vNode.appContext = appContext // 将准备好的上下文传给组件
    render(vNode, container)
    getAppendToElement(props).appendChild(container)
    return vNode
  }

  const container = document.createElement('div')

  // 4. 关闭函数:卸载组件实例并从 DOM 中移除容器
  const closed = () => {
    render(null, container)
    container.parentNode?.removeChild(container)
  }

  const CommandComponent = (options = {}) => {
    // 默认设置弹窗为显示状态
    if (!Reflect.has(options, 'visible')) options.visible = true

    // 包装 onClosed 回调:确保动画结束后再执行 DOM 清理
    if (typeof options.onClosed !== 'function') {
      options.onClosed = closed
    } else {
      const originOnClosed = options.onClosed
      options.onClosed = (...args) => {
        originOnClosed(...args)
        closed()
      }
    }

    const vNode = initInstance(Component, options, container, appContext)

    // 5. 返回一个代理对象,实现对组件实例的灵活控制
    return new Proxy(vNode, {
      get(target, prop) {
        if (prop === 'closed') return closed // 允许外部调用 .closed()
        const exposed = target.component?.exposed
        if (exposed && Reflect.has(exposed, prop)) {
          return Reflect.get(exposed, prop) // 允许访问 defineExpose 暴露的方法
        }
        return Reflect.get(target, prop)
      },
      has(target, prop) {
        if (prop === 'closed') return true
        const exposed = target.component?.exposed
        if (exposed && Reflect.has(exposed, prop)) return true
        return Reflect.has(target, prop)
      }
    })
  }

  CommandComponent.closed = closed
  return CommandComponent
}

5. 源码分段解析

为了让你更清楚每一块代码的作用,我们将上面的源码拆分为三个核心部分:

第一部分:上下文准备

const instance = getCurrentInstance()
let appContext = null
if (instance) {
  appContext = {...instance.appContext}
  appContext.provides = instance.provides
}

说明: 这一步是为了拿到父组件的 provides。因为弹窗是动态创建的,如果不手动把父级的数据传给它,弹窗里的 inject 就会失效。

第二部分:DOM 挂载与清理

const initInstance = (Component, props, container, appContext = null) => {
  const vNode = createVNode(Component, props)
  vNode.appContext = appContext
  render(vNode, container)
  getAppendToElement(props).appendChild(container)
  return vNode
}

const closed = () => {
  render(null, container)
  container.parentNode?.removeChild(container)
}

说明: 这里利用 createVNoderender 手动把组件渲染到一个临时的 div 里,然后把这个 div 塞进页面。关闭时则反向操作,彻底销毁组件。

第三部分:代理与交互控制

return new Proxy(vNode, {
  get(target, prop) {
    if (prop === 'closed') return closed
    const exposed = target.component?.exposed
    if (exposed && Reflect.has(exposed, prop)) return Reflect.get(exposed, prop)
    return Reflect.get(target, prop)
  },
  // ... has 拦截器
})

说明: 使用 Proxy 是为了让返回值既能当普通对象用(访问 expose 的方法),又能直接调用 .closed() 来关闭弹窗,使用起来非常灵活。

📄 第一篇:Vue 3 命令式弹窗使用指南

📄 第二篇:Vue 3 命令式弹窗 provide/inject 机制解析

📄 第三篇:Vue 3 命令式弹窗 Provide 污染与关闭动画修复

📊 流式输出实现总结

📊 流式输出实现总结

通过 Server-Sent Events (SSE)  协议实现了 AI 回复的流式输出,整个流程如下:

1️⃣ 发起流式请求 (ChatInput.vue - fetchMessage 函数)

typescript
const response = await fetch("/api/chat/stream", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({
    message,
    session_id: sessionId,
  }),
});
  • 使用原生 fetch API 而不是 Axios(因为需要读取流)
  • 向后端 /api/chat/stream 接口发送 POST 请求
  • 携带用户消息和会话 ID

2️⃣ 获取可读流

typescript
const reader = response.body?.getReader();
const decoder = new TextDecoder();
  • 通过 response.body.getReader() 获取一个可读流阅读器
  • 创建 TextDecoder 用于将二进制数据解码为文本

3️⃣ 初始化 AI 消息占位符

typescript
messageStore.addAIMessage("");  // 添加一个空的 AI 消息
aiMessageIndex = messageStore.message.length - 1;  // 记录该消息的索引
  • 在消息列表中先添加一个空的 AI 消息对象
  • 保存其索引位置,后续用于实时更新内容

4️⃣ 循环读取流数据

typescript
while (true) {
  const { done, value } = await reader.read();
  if (done) break;  // 流结束时退出循环
  
  const text = decoder.decode(value, { stream: true });
  buffer += text;
  
  // 按换行符分割处理
  const lines = buffer.split("\n"); //分割成数组
  buffer = lines.pop() || "";  // 保留不完整的最后一行,pop()删除数组最后一个元素,并且返回他
  
  for (let line of lines) {
    if (line.startsWith("data: ")) {
      const jsonStr = line.slice(6); //字符串方法,从字符串索引6开始切割到末尾
      const data = JSON.parse(jsonStr); //jsonStr是{"content": "..."}这样的结构
      if (data.content) {
        fullContent += data.content;
        messageStore.updateAIMessageContent(aiMessageIndex, fullContent);// 更新状态仓库数组
      }
    }
  }
}

核心逻辑:

  • 使用 while(true) 循环持续读取数据块
  • 每次读取后检查 done 标志,判断是否完成
  • 将二进制数据解码为文本并追加到缓冲区
  • 按换行符 \n 分割,处理完整的 SSE 数据行
  • 保留最后一行(可能是不完整的数据片段)到下一次循环

5️⃣ 解析 SSE 格式数据

后端返回的数据格式遵循 SSE (Server-Sent Events)  规范:

data: {"content": "你"}
data: {"content": "好"}
data: {"content": ","}
data: {"content": "我"}
...

前端解析步骤:

  1. 检测行是否以 data: 开头
  2. 提取 data: 后面的 JSON 字符串
  3. 解析 JSON 获取 content 字段
  4. 累加到 fullContent 变量中

6️⃣ 实时更新 UI (message.ts - updateAIMessageContent)

typescript
function updateAIMessageContent(index: number, content: string) {
  const msgs = [...message.value];  // 创建新数组(不可变更新)
  if (msgs[index]) {
    msgs[index] = { ...msgs[index], content };  // 创建新对象
    message.value = msgs;
    triggerRef(message);  // 手动触发响应式更新
  }
}

关键点:

  • 采用不可变数据更新策略:创建新数组和新对象
  • 使用 triggerRef 确保 shallowRef 的响应式更新被触发
  • Vue 检测到变化后自动重新渲染组件

7️⃣ 视图渲染 (MessageItem.vue)

vue
<div v-else class="m-6 px-4 py-6">
  <div class="max-w-4xl mx-auto">
    <AIMarkdown :content="content" />
  </div>
</div>
  • AI 消息通过 AIMarkdown 组件渲染
  • 支持 Markdown 格式的实时显示
  • 每次 content 更新时,组件自动重新渲染

8️⃣ 处理残留数据

typescript
if (buffer.trim().startsWith("data: ")) {
  try {
    const data = JSON.parse(buffer.slice(6));
    if (data.content) {
      fullContent += data.content;
      messageStore.updateAIMessageContent(aiMessageIndex, fullContent);
    }
  } catch (e) {
    console.debug("解析最后一行失败");
  }
}
  • 循环结束后,检查缓冲区是否还有未处理的数据
  • 防止最后一行数据丢失

9️⃣ 释放资源

typescript
finally {
  reader.releaseLock();  // 释放阅读器锁
}

🎯 技术要点总结

技术点 说明
SSE 协议 服务器推送事件,单向流式数据传输
ReadableStream Web API,用于读取流式响应
TextDecoder 将二进制数据解码为 UTF-8 文本
缓冲区管理 处理不完整的数据片段,确保正确解析
不可变更新 创建新数组/对象而非修改原数据,确保响应式追踪
triggerRef 手动触发 shallowRef 的响应式更新
Vue 响应式 数据变化自动触发视图重新渲染

🔄 完整流程图

用户发送消息
    ↓
调用 fetchMessage()
    ↓
发起 POST 请求到 /api/chat/stream
    ↓
获取 ReadableStream Reader
    ↓
添加空 AI 消息占位符
    ↓
┌─→ 循环读取数据块 ──────────────┐
│   ↓                            │
│   解码二进制数据为文本          │
│   ↓                            │
│   按 \n 分割行                  │
│   ↓                            │
│   解析 "data: {...}" 格式      │
│   ↓                            │
│   累加 content 到 fullContent  │
│   ↓                            │
│   调用 updateAIMessageContent  │
│   ↓                            │
│   触发 Vue 响应式更新          │
│   ↓                            │
│   UI 自动重新渲染               │
│   ↓                            │
└── 直到 done === true ←─────────┘
    ↓
释放 Reader 锁
    ↓
完成

JS动画函数的封装(很实用)

假如说你需要写一个手风琴效果的菜单。

啥啥啥啥是手风琴效果?

  • 每个菜单里有子菜单,子菜单默认是都是收起的。
  • 某个菜单点击的时候展开子菜单,再次点击可收起。
  • 展开菜单时候收起其他展开的菜单。(最多只能有一项展开

这里面的功能,和动画有关的,就是展开、收起子菜单的交互。

  • 收起的效果,是通过让子菜单的高度设置为0。
  • 展开的效果,是通过让子菜单的高度从0,设置到目标高度。

但是收起和展开,不能是一瞬间完成,否则就会看起来很生硬,这就需要用到动画效果。

因此可以封装一个工具函数,让我们很方便的开发动画效果。

动画的本质,是让某个数据从初始值逐渐变化到目标值。

是让一个大变化拆分成很多个小步来执行,一次变化一点,从而形成连贯的感觉。

function animate(option) {
  var from = option.from //初始值
  var to = option.to //目标值
  var totalDuration = option.totalDuration || 500   //动画总持续时间
  var singleDuration = option.singleDuration || 10   //单步持续时间
  var onAnimate = option.onAnimate //单步动作内容(要干的事情)
  var onEnd = option.onEnd //动画结束回调
  
  var currentValue = from //当前值
  var totalSteps = Math.floor(totalDuration / singleDuration)   //总需步数
  var currentStep = 0   //当前步数
  var distance = to - from   //总变化值
  var singleStepDistance = distance / totalSteps   //单次变化值
  var timerId = setInterval(() => {
    currentStep++
    if (currentStep < totalSteps) {
      currentValue += singleStepDistance
    } else {
      currentValue = to
      clearInterval(timerId)
      onEnd()
    }
    onAnimate && onAnimate(currentValue)
  }, singleDuration);
}

封装好了,可以这样使用:

function openMenu(submenu) {
animate({
  from: 0,
  to: submenu.children[0].clientHeight * submenu.children.length,
  totalDuration: 200,
  singleDuration: 10,
  onAnimate: function (value) {
    submenu.style.height = value + 'px'
  },
  onEnd: function () {
    submenu.setAttribute('status', 'opened')
  }
})
}

代码写成一锅粥?这5种设计模式让你的项目“起死回生”

你是不是见过这样的代码:一个文件几千行,一个函数做了十件事,改一个地方崩三个地方。今天我们不背理论,直接用5种前端最常用的设计模式,把你从“面条代码”里捞出来。学完你会发现:原来代码可以像乐高一样,哪里坏了换哪里。

前言

设计模式不是“高大上”的面试题,而是前辈们踩过无数坑后总结的“套路”。就像下棋有定式,写代码也有常见问题的标准解法。今天我们从实际场景出发,不讲23种全部,只挑前端最常用的5种:单例、观察者、工厂、策略、装饰器。看完你就能立刻用在项目里。

一、单例模式:全局只有一个的“独生子”

场景:全局弹窗、登录框、Store、线程池。你希望整个应用只有一个实例,反复创建会浪费资源或导致状态冲突。

不用的痛:每次调用都new Modal(),结果页面上出现十几个重叠的弹窗。

实现

class Singleton {
  constructor() {
    if (!Singleton.instance) {
      this.data = [];
      Singleton.instance = this;
    }
    return Singleton.instance;
  }
  add(item) {
    this.data.push(item);
  }
}
const a = new Singleton();
const b = new Singleton();
console.log(a === b); // true

前端更常见的写法:用闭包或模块(ES6模块本身就是单例)。

// modal.js
let instance;
export function getModal() {
  if (!instance) {
    instance = new Modal();
  }
  return instance;
}

现代替代:直接导出对象字面量(export default { show() {} }),ES6模块天然单例。

二、观察者模式:让不相干的组件“悄悄对话”

场景:购物车更新后,导航栏的数字要变、价格要重算、埋点要上报。你不想让购物车直接调用导航栏的方法(耦合太紧)。

不用的痛:购物车里写header.updateCartCount()sidebar.recalculate()analytics.track()…每加一个模块,购物车代码就要改一次。

实现

class EventBus {
  constructor() {
    this.events = {};
  }
  on(event, callback) {
    if (!this.events[event]) this.events[event] = [];
    this.events[event].push(callback);
  }
  emit(event, data) {
    if (this.events[event]) {
      this.events[event].forEach(cb => cb(data));
    }
  }
  off(event, callback) {
    if (this.events[event]) {
      this.events[event] = this.events[event].filter(cb => cb !== callback);
    }
  }
}
const bus = new EventBus();
// 购物车模块
bus.emit('cartUpdated', { count: 3 });
// 导航栏模块
bus.on('cartUpdated', (data) => updateCount(data.count));
// 埋点模块
bus.on('cartUpdated', (data) => track('cart', data));

观察者模式是前端最常用的模式之一。Vue的响应式原理、React的事件系统、Node.js的EventEmitter都是它的变体。

三、工厂模式:不用自己 new,让“工厂”替你造

场景:根据不同参数创建不同类型的对象,但创建逻辑复杂(比如需要条件判断、依赖注入)。你不想在业务代码里到处写newif-else

不用的痛:每个用到按钮的地方都要写一堆if (type === 'primary') return new PrimaryButton()…重复代码爆炸。

实现

class ButtonFactory {
  createButton(type) {
    switch(type) {
      case 'primary':
        return new PrimaryButton();
      case 'danger':
        return new DangerButton();
      default:
        return new DefaultButton();
    }
  }
}
const factory = new ButtonFactory();
const btn = factory.createButton('primary');

工厂模式把创建对象的逻辑集中管理,业务代码只依赖工厂接口。

更简单的函数工厂

function createUser(role) {
  const base = { createdAt: Date.now() };
  if (role === 'admin') {
    return { ...base, permissions: ['read', 'write', 'delete'] };
  }
  return { ...base, permissions: ['read'] };
}

四、策略模式:消灭“if-else 毒瘤”

场景:表单校验:用户名规则、密码规则、邮箱规则各不相同。或者根据用户等级计算折扣:普通会员9折,黄金会员8折,钻石会员7折。你不想写一长串if-else

不用的痛:一个函数里十几个if-else,加一个新策略要改原有代码,还容易引入bug。

实现

// 策略对象
const discountStrategies = {
  normal: (price) => price * 0.9,
  gold: (price) => price * 0.8,
  diamond: (price) => price * 0.7,
};
function getDiscount(level, price) {
  return discountStrategies[level]?.(price) ?? price;
}
// 使用时
const finalPrice = getDiscount('gold', 100); // 80

校验示例:

const validators = {
  required: (val) => val.trim() !== '',
  minLength: (val, len) => val.length >= len,
  email: (val) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(val)
};
function validate(value, rules) {
  for (let rule of rules) {
    const [name, param] = rule.split(':');
    if (!validators[name]?.(value, param)) return false;
  }
  return true;
}

策略模式把算法(策略)提取成独立对象,可以动态替换、复用。

五、装饰器模式:给代码“贴金”而不改源码

场景:给现有函数添加日志、性能监控、权限校验、缓存功能,但不修改函数本身。

不用的痛:在每个函数内部手动加console.time,加完又删,污染业务逻辑。

实现(JavaScript高阶函数版本,TS装饰器已在之前文章讲过):

function withLog(fn) {
  return function(...args) {
    console.log(`调用 ${fn.name} 参数:`, args);
    const result = fn.apply(this, args);
    console.log(`返回值:`, result);
    return result;
  };
}
function add(a, b) { return a + b; }
const loggedAdd = withLog(add);
loggedAdd(2, 3); // 输出日志,返回5

更通用的装饰器组合:

function withTimer(fn) {
  return function(...args) {
    const start = performance.now();
    const result = fn.apply(this, args);
    const end = performance.now();
    console.log(`${fn.name} 耗时 ${end - start}ms`);
    return result;
  };
}
// 组合多个装饰器
const enhanced = withLog(withTimer(add));

装饰器模式让你能“叠加”功能,保持单一职责。

六、实际项目中的组合运用

比如一个用户登录模块:

  • 单例模式:全局唯一的UserStore
  • 工厂模式:根据角色创建不同的用户实例(createUser('admin'))。
  • 观察者模式:登录成功后,触发userLoggedIn事件,购物车、头像组件、权限菜单分别响应。
  • 策略模式:不同等级用户的权限校验策略。
  • 装饰器模式:给API请求函数加上缓存、重试、日志。

七、总结:设计模式是“招式”,不是“教条”

  • 单例:全局唯一,省资源。
  • 观察者:解耦事件发布和订阅。
  • 工厂:集中创建对象。
  • 策略:消灭if-else,算法可互换。
  • 装饰器:动态增强功能。

不要为了用模式而用模式。当你的代码出现重复、难维护、改一处动全身时,想想哪种模式能帮你解耦。写代码就像搭积木,模式是那些标准接口的积木块,让你搭得又快又稳。


如果你觉得今天的“招式”够实用,点个赞让更多人看到。明天我们将聊聊前端架构设计——从技术选型到目录结构,如何搭建一个能支撑三年迭代的项目骨架。我们明天见!

基于Babylonjs的WEBGPU渲染器源码架构

image.png

WebGPU 实时光线追踪渲染器实现原理

基于 BabylonJS + WebGPU 的浏览器端路径追踪渲染器,支持 PBR 材质、全局光照,无需安装任何插件。

编辑器演示视频:www.bilibili.com/video/BV1dZ…

编辑器在线地址:webgpu.moyunhe.com/


一、整体架构

渲染器分为五个核心层:

┌─────────────────────────────────────────┐
│           GPUEditor3D(对外接口)         │
├─────────────────────────────────────────┤
│     GPUEditor(编辑器逻辑 + 插件管理)    │
├─────────────────────────────────────────┤
│   WebgpuPreview(WebGPU 引擎 + 场景管理)│
├──────────────┬──────────────────────────┤
│ GPURayTrace  │  BVHBuilderPlugin        │
│ (路径追踪)  │  (加速结构构建)          │
├──────────────┴──────────────────────────┤
│         GPUAssetManager(资源管理)       │
└─────────────────────────────────────────┘
模块 职责
WebgpuPreview WebGPU 引擎初始化、场景管理、渲染循环
GPURayTrace 路径追踪计算着色器、采样、降噪调度
BVHBuilderPlugin BVH 加速结构构建(BLAS + TLAS)、材质打包
GPUAssetManager glTF 模型加载、HDR 环境贴图、纹理管理
TileRender 自适应分块渲染,防止 GPU 超时

二、WebGPU 引擎初始化

引擎使用 BabylonJS 的 WebGPUEngine,开启全部 WebGPU 特性:

new WebGPUEngine(canvas, {
  enableAllFeatures: true,   // 开启所有 WebGPU 扩展
  setMaximumLimits: true,    // 使用最大资源限制
  antialias: true,
  useHighPrecisionMatrix: true
})

用到的 WebGPU 特性:

  • Compute Shader — 路径追踪主管线
  • Storage Buffer — BVH 节点、三角形、法线、材质、光源数据
  • Storage Texture — 累积帧缓冲(ping-pong 双缓冲)
  • Texture Array — 材质纹理图集
  • Uniform Buffer — 相机矩阵、采样参数、光源数量
  • Command Encoder — GPU buffer 拷贝与后处理

三、BVH 加速结构

光线追踪的性能核心是 BVH(Bounding Volume Hierarchy,层次包围盒)。本渲染器采用两级 BVH

3.1 BLAS(Bottom-Level AS,网格级)

每个网格独立构建一棵 BVH,流程如下:

网格顶点数据
    │
    ▼
计算每个三角形的 AABB
    │
    ▼
计算 Morton Code(Z-order 曲线,空间编码)
    │
    ▼
GPU Radix Sort(按 Morton Code 排序)
    │
    ▼
构建 Radix Tree(确定父子关系)
    │
    ▼
自底向上合并 AABB(BVH 内部节点)
    │
    ▼
重排三角形数据(缓存友好布局)

Morton Code 将三维空间坐标编码为一维整数,排序后相邻三角形在空间上也相邻,BVH 遍历时缓存命中率更高。

GPU Radix Sort 是整个构建流程中最复杂的部分,通过多趟 GPU Compute Shader 完成,避免 CPU-GPU 数据回传。

3.2 TLAS(Top-Level AS,场景级)

将所有 BLAS 合并为场景级 BVH,每个节点存储:

  • 网格实例的世界矩阵 / 逆矩阵
  • 对应 BLAS 的索引
  • 材质 ID

光线先与 TLAS 求交,命中后再进入对应 BLAS 精确求交,大幅减少无效计算。


四、材质系统与纹理图集

4.1 纹理图集(Texture Atlas)

场景中所有材质的纹理被打包进 2D Texture Array,每层 1024×1024,按属性分组:

图集 内容
Albedo Atlas 基础色 + 透明度
Normal Atlas 法线贴图
Emission Atlas 自发光
MRCC Atlas 金属度 / 粗糙度 / 清漆
Sheen Atlas 光泽颜色 / 强度
Transmission Atlas 透射 / 次表面散射

这样在着色器中只需一次纹理绑定,通过材质 ID 和 UV 变换矩阵索引任意材质属性,避免了频繁切换纹理绑定的开销。

4.2 材质数据打包

每个材质的参数(粗糙度、金属度、IOR、各图集层索引等)被打包进 Storage Buffer,着色器通过材质 ID 直接读取,支持完整的 PBR 材质模型:

  • 漫反射 / 镜面反射
  • 清漆(Clearcoat)
  • 光泽(Sheen)
  • 透射(Transmission)
  • 次表面散射(Subsurface)

五、路径追踪核心

5.1 采样策略

渲染器使用分层采样(Stratified Sampling)结合抖动(Jitter)

每像素 → 预计算分层采样表 → 加噪声纹理抖动 → 低差异序列
  • 每次弹射使用 11 个采样维度(方向、材质、光源选择等)
  • 最大弹射深度:11 次
  • 深度 ≥ 2 后启用**俄罗斯轮盘赌(Russian Roulette)**随机终止路径,无偏估计能量

5.2 光照采样

直接光照(Next Event Estimation):

  • 支持最多 100 个光源
  • 按光源强度加权随机选择,减少方差

环境光照(Environment Map Importance Sampling):

  • 预计算环境贴图的 CDF(累积分布函数)
  • 采样时用二分查找 CDF,优先采样亮区,大幅降低噪声

5.3 Ping-Pong 累积缓冲

渲染器维护两张累积纹理(A / B),每帧交替作为读写目标:

第 N 帧:读 A,写 B(混合新采样)
第 N+1 帧:读 B,写 A

相机静止时,帧数不断累积,画面逐渐收敛到无噪声的最终结果。相机移动时立即重置累积。

5.4 Tone Mapping

最终输出使用 ACES Filmic 色调映射曲线,还原电影级色彩表现,并进行 sRGB Gamma 校正。


六、自适应分块渲染

WebGPU 的 GPU 超时机制(TDR)会在单次提交耗时过长时强制重置设备。为此,渲染器将每帧的计算任务拆分为多个小块(Tile)分批提交:

自适应算法:

目标:每块耗时 ≈ 21ms

实际耗时 > 21ms → 缩小 pixelsPerTile
实际耗时 < 21ms → 扩大 pixelsPerTile

调整公式:
pixelsPerTile += strength × sign(error) × √|error|
(strength = 5000,快速收敛)

范围限制:[8192, 全屏像素数]

这样在不同性能的 GPU 上都能保持流畅,既不会触发超时,也不会因块太小而浪费调度开销。


七、渲染流程总览

用户打开场景
    │
    ▼
GPUAssetManager 加载 glTF 模型
    │
    ▼
BVHBuilderPlugin 构建 BLAS(每个网格)
    │
    ▼
BVHBuilderPlugin 构建 TLAS(场景级)
    │
    ▼
打包材质纹理图集 → Storage Buffer
    │
    ▼
渲染循环开始
    │
    ├─ 相机移动?→ 重置累积缓冲
    │
    ▼
TileRender 分块调度
    │
    ▼
GPURayTrace Compute Shader(路径追踪)
    │
    ├─ BVH 求交
    ├─ 材质采样(PBR)
    ├─ 直接光照 + 环境光照
    └─ 写入累积缓冲
    │
    ▼
Tone Mapping(ACES)→ 输出到屏幕

八、关键技术亮点

  1. 纯 WebGPU 实现:所有计算(BVH 构建、路径追踪、后处理)全部在 GPU 上完成,CPU 只负责调度,无需 CPU-GPU 数据回传。

  2. GPU Radix Sort:BVH 构建中的排序完全在 GPU 上执行,避免了传统 CPU 排序的性能瓶颈。

  3. 自适应分块:动态调整每块像素数,在任意 GPU 上都能保持流畅,不触发 TDR 超时。

  4. CDF 环境光采样:预计算环境贴图亮度分布,采样时优先选择高亮区域,相比均匀采样噪声降低数倍。

  5. 完整 PBR 支持:支持清漆、光泽、透射、次表面散射等高级材质,与 glTF 2.0 扩展完全兼容。


对象模型与内存的“钥匙理论”:TS 切入的 Go 的结构体与指针

🚀 省流助手(速通结论)

  • Struct 不是类:它只存数据,不存逻辑。逻辑通过“接收者(Receiver)”外挂实现,类似 JS 的 prototype
  • 没有 this:Go 显式命名接收者(如 u),彻底解决了 TS 中 this 指向丢失的世纪难题。
  • 权限全靠“吼”:首字母大写 = public,首字母小写 = private。这是编译器级别的强制隔离。
  • 赋值即复印:a := b 是物理深拷贝。想要引用?必须显式使用 & 拿钥匙(指针)。
  • 契约严苛:函数要求指针(*User)时,你传值(User)会直接编译报错,Go 不玩隐式转换。

1. 结构体不是类:数据与逻辑的彻底解耦

在 TS 中,class 是高度内聚的。但在 Go 中,struct 被剥离得只剩下数据描述。

TypeScript(内聚模型)

class User {
    name: string;
    constructor(name: string) { this.name = name; }
    say() { console.log(this.name); } // 逻辑住在类里面
}

Go(数据模型)

type User struct {
    Name string // 仅定义数据字段
}

// 逻辑通过外部函数“外挂”到 User 上
func (u User) Say() {
    fmt.Println(u.Name)
}

🪝 思维钩子:Go 强迫你把“长什么样”和“能干什么”分开。你可以把 struct 看作是去掉了方法实现的“瘦身版” Class。


2. 方法的外挂艺术:再见了,迷之 this

TS 开发者最头疼的莫过于 this 绑定丢失。Go 根本不设 this 关键字,而是让你显式命名。

TypeScript(this 绑定焦虑)

const u = new User("Alice");
const say = u.say;
say(); // ❌ 报错:this 指向丢失(除非 bind 或用箭头函数)

Go(显式绑定)

// u 只是一个变量名,你可以叫它 self, me 或者 u
func (u User) Say() {
    fmt.Println(u.Name) // u 的指向在定义时就写死了,永远不会丢
}

🪝 思维钩子:Go 的方法绑定极像 User.prototype.say = ...。这种设计让逻辑复用变得极其简单,且没有运行时的上下文陷阱。


3. 权限的大小写命令:最简单的隐藏规则

在 TS 中,我们写 private。在 Go 中,你只需按下 Shift 键。

TypeScript(关键字控制)

class User {
    public name: string;
    private age: number; // 靠关键字限制访问
}

Go(首字母定生死)

type User struct {
    Name string // 首字母大写:外部包可见 (Public)
    age  int    // 首字母小写:仅限本包可见 (Private)
}

🪝 思维钩子:这是 Go 的“行政命令”。如果你在 A 包定义了 age,在 B 包里你连提示都敲不出来。调用处必须严格遵守定义处的大小写,没有模糊地带。


4. 指针、原件与复印机:a := b 的终极实验

这是 TS 程序员转 Go 时最容易产生 Bug 的地方。在 TS 里,对象赋值是引用;在 Go 里,一切赋值皆拷贝。

TypeScript(自动引用)

const u1 = { name: "Alice" };
const u2 = u1; 
u2.name = "Bob";
console.log(u1.name); // "Bob" (两人共用一个地址)

Go(默认复印机模式)

u1 := User{Name: "Alice"}
u2 := u1      // 发生物理拷贝!u2 是 u1 的一个完整副本
u2.Name = "Bob"
fmt.Println(u1.Name) // "Alice" (u1 根本没动)

u3 := &u1     // 这才是拿到了 u1 的钥匙(指针)
u3.Name = "Bob"
fmt.Println(u1.Name) // "Bob" (原件被修改了)

🪝 思维钩子:a := b 是盖了一座一模一样的房子;a := &b 是把房子的钥匙交出去。


5. 契约与传参:强指针约束

💡 注意:为了从 TS 视角更好理解 Go 的严谨性,请看下表。在 Go 中,类型契约是不可妥协的。

场景 Go 表现 思维入口
函数要求 *User(指针) 你传 User(值) ❌ 编译报错:类型不匹配
函数要求 User(值) 你传 *User(指针) ❌ 编译报错:原件不能直接塞进复印机

🪝 思维钩子:Go 不支持隐式地址转换。当你写下 & 的那一刻,你在提醒自己:“我要交出修改原件的权限了”。


6. 工程组织:go.mod 对标 package.json

Go 的依赖管理不再有臃肿的 node_modules,它更清爽,但也更严格。

  • 导入逻辑:Go 导入的是文件夹(Package),而不是单个文件。

  • 版本管理:

    • go.modpackage.json(记录主依赖版本)
    • go.sumpackage-lock.json(记录哈希,防止内容篡改)

🪝 思维钩子:运行 go mod tidy 就像 npm install。它会自动扫描代码中的 import 路径,下载缺失包并剔除冗余包。


下篇预告:
我们将进入 Go 语言最迷人的部分:并发(Goroutine) 与 通道(Channel)。为什么 Go 处理万级并发轻而易举?为什么接口里不能定义变量?我们下篇见。

运行时vs编译时:CSS in JS四种主流方案介绍和对比

CSS作为前端代码中的重要组成部分,在工程中一般是以独立CSS文件的形式存在的。而CSS in JS,顾名思义,是在JavaScript中写CSS代码。尤其是React框架的流行,JavaScript和HTML模板都在JavaScript文件中描述了,只有CSS代码的组织还比较疏离。因此出现了很多CSS in JS的开源库,帮助我们将CSS放到JavaScript代码中,实现React组件代码的耦合性。

React工程示例

首先我们先展示一下React工程中是如何使用CSS的,以及React本身有没有CSS in JS的能力。

不使用CSS in JS

首先使用Vite创建React工程,执行命令行:

# 选择React
npm create vite@latest
# 安装依赖
npm install
# 启动开发服务
npm run dev

然后将src/App.jsx文件修改为如下内容,这是一个React组件。

import "./App.css";
export default function App() {
  return <div className="class1">你好 jzplp</div>;
}

然后是对应的src/App.css内容:

.class1 {
  color: red;
  font-size: 15px;
}

打开浏览器,可以看到对应的组件样式是生效的。这就是普通React工程中CSS的使用方式,在独立文件中被引用。像CSS Modules, Less和SCSS等也都是类似这种使用模式。那如果CSS样式本身需要根据不同的场景变化呢?可以根据不同的场景提供不同的类名。这里修改下App.jsx作为示例,就不展示App.css文件了。

import "./App.css";
export default function App({ state }) {
  return <div className={state === 1 ? "class1" : "class2"}>你好 jzplp</div>;
}

内联样式

使用类名控制样式变化可以生效,但这算是间接控制样式。有没有直接可以在JavaScript代码中控制CSS的方式呢?有的,React提供了内联样式,可以让我们直接控制:

import "./App.css";
export default function App({ state }) {
  return (
    <div
      style={{
        color: state === 1 ? "red" : "blue",
        fontSize: "14px",
      }}
    >
      你好 jzplp
    </div>
  );
}

对style属性设置为对象,对象的key是CSS属性驼峰形式,可以实现对HTML中内联样式的直接控制。看起来这样挺好用的,但是它的限制还是非常大。例如不能使用伪类或者媒体查询这种CSS规则。

直接操作DOM可以使用CSS规则,但这不优雅也失去了使用React的优势。因此,如果想要实现真正的CSS in JS,还是要看专门的工具。

styled-components初步

styled-components是最知名的CSS in JS工具,可以在React中使用。

接入方式

首先安装依赖styled-components,然后删除App.css,我们不再需要独立的CSS文件了。修改App.tsx:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  font-size: 14px;
`;
const Button = styled.button`
  color: blue;
  font-size: 14px;
`;

export default function App() {
  return (
    <div>
      <Div>你好 jzplp</Div>
      <Button>你好 jzplp</Button>
    </div>
  );
}

此时在浏览器上可以看到生效的结果。通过代码可以看到,styled-components是利用了‌ECMAScript中模板字符串的“标签模板字符串”特性。因此我们提供的CSS字符串可以被styled-components对应的函数解析,最终生成样式。

实现方式

上面的代码是如何生效的呢?这里我们修改一下代码,增加不同状态:

import styled from "styled-components";
import { useState } from "react";

const Div0 = styled.div``;
const Div = styled.div`
  color: red;
  font-size: 14px;
`;
const Button = styled.button`
  color: blue;
  font-size: 14px;
`;

export default function App() {
  const [state, setState] = useState(0);

  return (
    <div>
      <Div0>1你好 jzplp</Div0>
      <Div>2你好 jzplp</Div>
      {state % 2 === 1 && <Button>3你好 jzplp</Button>}
      <div onClick={() => setState(state + 1)}>按下+1</div>
    </div>
  );
}

css-in-js-1.png

首先看下初始状态的效果。这里有Div0和Div两个组件,都是用styled-components生成的,因此都有一个sc-开头的类名,但这个类名上并不包含样式。Div因为有了样式,所以有另一个类名,这个类名的具体样式写在head中的style标签里面。Button组件因为没有展示,所以对应的样式也没有注入。我们再切换状态试试:

css-in-js-2.png

切换状态之后,Button组件展示并附带着样式。注意style标签中增加了一行,正是Button的样式。此时如果再次切换状态将Button组件销毁,style标签中对应的样式并不会删除。

通过对于浏览器现象的观察,我们发现了styled-components的实现方式:当组件被渲染时,将JavaScript中的CSS属性集合放到style标签中,同时动态提供hash类名。将类名提供给HTML标签作为属性渲染。这样就实现了JavaScript控制CSS代码,且在组件被渲染时才注入CSS。此时我们执行如下命令:

npm run build
npm run preview

然后查看打包后生产模式的效果,发现style标签中并没有CSS代码了,但样式还是生效的,也是通过hash类名。且在浏览器调试工具上点击类的来源,也能点到那个style标签,只不过浏览器上看不到其中的内容。

传参方式

前面的例子中CSS代码全是字符串,但使用模板字符串除了能换行之外,好像也没有什么优势。使用标签模板字符串的优势在于传参。组件可以根据不同的传参切换样式:

import styled from "styled-components";

interface DivProps {
  bgColor: string;
  lineHeight?: number;
}

const Div = styled.div<DivProps>`
  color: red;
  background: ${props => props.bgColor};
  font-size: 14px;
  line-height: ${props => props.lineHeight || 20}px;
`;

export default function App() {
  return (
    <div>
      <Div bgColor="blue" lineHeight={30}>2你好 jzplp</Div>
      <Div bgColor="yellow">2你好 jzplp</Div>
    </div>
  );
}

可以看到,首先我们在styled对应标签的函数中增加了props入参的泛型TypeScript类型。然后在模板字符串的插值中传入函数,函数的入参为props,返回对应场景下的CSS代码值。由于我们使用的“标签模板字符串”功能,因此标签函数可以读取并处理模板字符串中的插值,最后整合成完整的CSS代码。我们看下浏览器效果:

css-in-js-3.png

可以看到,不同的入参会生成不同的类名。这里我有一个疑问,如果我们的入参一直在变化,会不会一直生成类名?我们试一下:

import styled from "styled-components";
import { useState } from "react";

interface DivProps {
  color: number;
}

const Div = styled.div<DivProps>`
  color: #${(props) => props.color};
`;

export default function App() {
  const [state, setState] = useState(0);
  return (
    <div>
      <Div color={state}>你好 {state}</Div>
      <div onClick={() => setState(state + 1)}>按下+1</div>
    </div>
  );
}

在上面代码中,点击一下state值加1,同时Div中的入参也会变化,生成的CSS值也会不一样。我们多点几次看看效果:

css-in-js-4.png

通过浏览器效果可以看到,我们每点击一次,就会生成一个新的类名和CSS规则。旧的CSS规则虽然永远不会被使用到了,但依然保存在浏览器中。不过代码其实不知道我们的CSS规则今后会不会被使用到。

styled-components特性

前面我们引入了styled-components,简单介绍了实现方式和传参。这里再介绍一下更多特性。

组件继承

类似于面向对象,使用styled-components生成的组件也有继承特性,子组件可以继承父组件的样式。我们举个例子:

import styled from "styled-components";

const Div = styled.div`
  color: red;
`;
const DivChild = styled(Div)`
  background: yellow;
`;

export default function App() {
  return (
    <div>
      <Div>你好 jzplp</Div>
      <DivChild>你好 jzplp</DivChild>
      <Div as="p">你好 jzplp</Div>
    </div>
  );
}

我们定义了Div父组件,DivChild继承并提供了自己的样式,通过结果可以看到,两个样式都生效了。第三个组件我们使用了as属性,它可以在使用预定义styled组件的同时,修改标签名。

css-in-js-5.png

父组件的props参数,子组件也是继承的,同时子组件也可以有自己的参数。我们看下例子:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  font-size:${(props) => props.size}px;
`;
const DivChild = styled(Div)`
  background: yellow;
  line-height: ${(props) => props.size + 10}px;
  border: ${(props) => props.borderSize}px solid blue;
`;

export default function App() {
  return (
    <div>
      <Div size={20}>你好 jzplp</Div>
      <DivChild size={30} borderSize={2}>你好 jzplp</DivChild>
    </div>
  );
}

css-in-js-6.png

通过结果可以看到,组件的参数和子组件都有自己的参数,子组件也可以使用父组件的参数,可以同时生效。

与React组件继承

前面我们看到的是styled组件互相的继承关系。事实上,它与普通React组件也可以互相继承。首先是普通React组件作为父组件:

import styled from "styled-components";

function Comp({ state, className }) {
  return <div className={className}>{state}</div>;
}

const DivComp = styled(Comp)`
  color: ${(props) => props.color};
  font-size: 20px;
`;

export default function App() {
  return (
    <div>
      <Comp state={1} />
      <DivComp state={2} color='red' />
    </div>
  );
}

css-in-js-7.png

可以看到,作为父组件的Comp组件,需要提供className参数,并在组件内部恰当位置作为属性。这样子组件和父组件的props都可以正常使用,styled-components会将接收到的属性透传给父组件。然后我们再看下普通React组件作为子组件的场景:

import styled from "styled-components";

const Div = styled.div`
  color: ${(props) => props.color};
  font-size: 20px;
`;

function Comp(props = {}) {
  return <Div {...props}>{props.state}</Div>;
}

export default function App() {
  return (
    <div>
      <Div color="red">1</Div>
      <Comp state={2} color="yellow" />
    </div>
  );
}

css-in-js-8.png

当普通React组件作为子组件时,我们需要手动处理透传需要的prop到子组件中。

嵌套选择器

前面使用React内联样式的时候,我们提到内联样式并不支持嵌套选择器,这其实是直接用React做CSS in JS的最大阻碍。 styled-components引入了stylis工具来处理,支持使用嵌套选择器。这里举下例子:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  &:hover {
    background: yellow;
  }
  &.class1 {
    border: 2px solid blue;
  }
  .class2 & {
    border: 2px solid green;
  }
`;

export default function App() {
  return (
    <div>
      <Div>1 jzplp</Div>
      <Div>2 jzplp</Div>
      <Div className="class1">3 jzplp</Div>
      <div className="class2">
        <Div>4 jzplp</Div>
      </div>
    </div>
  );
}

css-in-js-9.png

通过例子可以看到,无论是伪类,还是各种选择器都可以,其中使用&标识本标签的选择器。也能生效到子元素中,即使这个元素非styled组件:

import styled from "styled-components";

const Div = styled.div`
  color: red;
  .class1 {
    border: 2px solid blue;
  }
`;

export default function App() {
  return (
    <div>
      <Div>
        <div className="class1">3 jzplp</div>
      </Div>
    </div>
  );
}

css-in-js-10.png

CSS片段

styled-components支持创建一个CSS片段,可以提供给组件使用,并且可以带参数,使用css方法即可。

import styled, { css } from "styled-components";

const jzCss = css`
  color: blue;
  font-size: ${props => props.size}px;
`

const Div = styled.div`
${
  props => {
    if(props.type === 1) return jzCss;
    else return `
      color: red;
      background: green;
    `;
  }
}
`;

export default function App() {
  return (
    <div>
      <Div type={1} size={20}>jzplp 1</Div>
      <Div type={1} size={30}>jzplp 2</Div>
      <Div type={2}>jzplp 3</Div>
    </div>
  );
}

css-in-js-11.png

可以看到代码中先创建了一个带参数的CSS片段,然后在组件字符串插值的返回中传入这个片段,参数可以正常生效渲染。else情况则直接返回了字符串CSS属性,看浏览器上也可以生效。那是不是说不需要css函数处理,直接返回模板字符串就可以了?这是肯定不行的,我们举个例子:

import styled from "styled-components";

const Div = styled.div`
  ${() => {
    return `
        color: blue;
        font-size: ${(props) => props.size}px;
      `;
  }}
`;

export default function App() {
  return (
    <div>
      <Div size={20}>jzplp 1</Div>
    </div>
  );
}

css-in-js-12.png

在浏览器中可以看到,模板字符串中的插值并没有被成功处理,而是被直接转为了普通字符串,最后成了错误的CSS代码。因此必须使用css函数创建CSS片段。

CSS动画

styled-components也支持创建@keyframes的CSS动画,同时在CSS中被引用。

import styled, { keyframes } from "styled-components";

const colorChange = keyframes`
  0% {
    color: red;
  }
  50% {
    color: blue;
  }
  100% {
    color: red;
  }
`;

const Div = styled.div`
  animation: ${colorChange} 2s infinite;
`;

export default function App() {
  return (
    <div>
      <Div>你好,jzplp</Div>
    </div>
  );
}

可以看到我们使用keyframes方法创建了一个keyframes动画,在需要的CSS位置中,当作animation-name属性插入动画即可。效果如下:

css-in-js-13.gif

这里只是简单介绍了styled-components的部分特性,如果希望深入了解请看styled-components相关文档。

@emotion/styled

下面来介绍一下Emotion这个库。这个库有好几种使用方式,首先我们从类似styled-components,即styled组件的使用方式开始介绍,主要使用@emotion/styled这个包。

接入方式

首先安装@emotion/styled依赖,然后修改src/App.jsx:

import styled from "@emotion/styled";

const Div = styled.div`
  color: red;
  background: ${(props) => props.bg};
`;

export default function App() {
  return (
    <div>
      <Div bg="blue">你好,jzplp</Div>
      <Div bg="yellow">你好,jzplp</Div>
    </div>
  );
}

上面代码的使用方式与styled-components一模一样,换个包名也能生效。效果也一样,区别在于开发模式下@emotion/styled有两个style标签,如下图。生产模式与styled-components一样,都是一个style标签且看不到内容。

css-in-js-14.png

CSS片段

不仅接入方式,@emotion/styled的大部分特性都和styled-components一样,包括继承,嵌套选择器等。但CSS片段有些不一样:

import styled from "@emotion/styled";
import { css } from '@emotion/react';

// 错误方式
const commonStyle1 = css`
  font-size: 20px;
  background: ${(props) => props.bg};
`;

const commonStyle2 = (props) => css`
  font-size: 20px;
  background: ${props.bg};
`;

const Div1 = styled.div`
  color: red;
  ${commonStyle1}
`;

const Div2 = styled.div`
  color: green;
  ${commonStyle2}
`;

export default function App() {
  return (
    <div>
      <Div1 bg="yellow">你好,jzplp1</Div1>
      <Div2 bg="yellow">你好,jzplp2</Div2>
    </div>
  );
}

css-in-js-15.png

首先CSS片段的函数是在另一个包@emotion/react中引入的。commonStyle1是用styled-components模式引入的,将函数直接放到CSS片段中,是不生效的。必须要将片段本身放到一个大函数内部才行,如commonStyle2的形式。

函数入参

@emotion/styled中的组件也支持函数作为入参,而非模板字符串。函数可以返回style对象,也能返回拼好的字符串。

import styled from "@emotion/styled";

const Div1 = styled.div((props) => {
  return {
    color: "red",
    background: props.bg,
  };
});

const Div2 = styled.div((props) => {
  return `
    color: pink;
    background: ${props.bg};
  `;
});

export default function App() {
  return (
    <div>
      <Div1 bg="yellow">你好,jzplp1</Div1>
      <Div1 bg="green">你好,jzplp2</Div1>
      <Div2 bg="yellow">你好,jzplp3</Div2>
      <Div2 bg="green">你好,jzplp4</Div2>
    </div>
  );
}

css-in-js-16.png

@emotion/react

接入css属性

Emotion除了styled组件使用方式之外,也可以使用组件css属性(css Prop)的使用方式。这种方式是在React中JSX的组件上增加一个css属性。因此这种方式需要修改React编译相关参数。这里以vite@8和@vitejs/plugin-react@6为例来说明。首先安装依赖@emotion/react。然后修改vite.config.js配置文件:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react({
      // 指定转换jsx语法的模块
      jsxImportSource: '@emotion/react',
    })],
})

然后就可以使用了。如果需要TypeScript类型正确提示,则需要修改tsconfig.json:

{
  "compilerOptions": {
    // types在原有基础上新增"@emotion/react/types/css-prop"
    "types": ["...", "@emotion/react/types/css-prop"],
    "jsxImportSource": "@emotion/react",
  },
}

然后我们修改App.jsx,内容如下:

export default function App() {
  return (
    <>
      <div
        css={{
          color: "red",
          "&:hover": {
            background: "green",
          },
        }}
      >
        你好 jzplp
      </div>
    </>
  );
}

可以看到,我们直接以对象的形式对组件JSX的css属性赋值,而且还包含hover伪类,最后在开发模式和生产模式都可以正常生效。

css-in-js-17.png

除了对象形式之外,css属性还支持CSS片段的形式:

import {css} from "@emotion/react";
export default function App() {
  return (
    <>
      <div
        css={css`
          color: red;
          &:hover {
            background: green;
          }
        `}
      >
        你好 jzplp
      </div>
    </>
  );
}

css属性继承

从前面的接入例子中我们看到,css属性还是通过class名的形式生效的。因此它的优先级低于style属性,即内联样式。如果父组件提供了css属性想让子组件生效,则需要传入className参数。那么如果父子组件都有css属性,他们的优先级如何呢?这里举个例子:

function Comp1({ className }) {
  return (
    <div
      css={{
        background: 'yellow',
        color: "red",
      }}
      className={className}
    >
      你好 jzplp
    </div>
  );
}

function Comp2() {
  return (
    <Comp1
      css={{
        fontSize: "20px",
        color: "blue",
      }}
    >
      你好 jzplp
    </Comp1>
  );
}

export default function App() {
  return (
    <>
      <Comp1 />
      <Comp2 />
    </>
  );
}

这里创建了两个组件,Comp1是子组件,Comp2是父组件。子组件和父组件中的CSS属性不冲突的可以同时生效,冲突属性以父组件的为准,例如这里的color。

css-in-js-18.png

样式对象

前面我们演示过,不管是styled组件,css属性还是CSS片段,都可以接收对象类型的样式数据。关于对象类型还有其它特性,这里我们一起描述一下。首先是数组类型,这里列举了两个例子:

import styled from "@emotion/styled";

const styleList = [
  {
    color: "red",
  },
  {
    fontSize: "20px",
  },
];

const Comp1 = styled.div(styleList);

function Comp2() {
  return <div css={styleList}>你好 jzplp2</div>;
}

export default function App() {
  return (
    <>
      <Comp1>你好 jzplp1</Comp1>
      <Comp2 />
    </>
  );
}

styled组件也同时支持多个入参:

import styled from "@emotion/styled";

const Comp1 = styled.div(
  {
    color: "red",
  },
  (props) => {
    return {
      fontSize: `${props.size}px`,
    };
  },
);

export default function App() {
  return <Comp1 size={20}>你好 jzplp1</Comp1>;
}

样式对象还支持回退值(默认值)。通过对属性值传入一个数组,最后面的值优先级最高,如果不存在则取前面的值:

function Div1({ color, children }) {
  return (
    <div
      css={{
        color: ["red", color],
      }}
    >{children}</div>
  );
}

export default function App() {
  return (
    <>
      <Div1>你好 jzplp1</Div1>
      <Div1 color="blue">你好 jzplp2</Div1>
    </>
  );
}

css-in-js-19.png

全局样式

@emotion/react支持创建全局样式,也是使用组件的形式。当组件被渲染时,全局样式生效,组件不被渲染时则不生效。

import { useState } from "react";
import { Global } from "@emotion/react";

export default function App() {
  const [state, setState] = useState(0);
  return (
    <>
      <Global
        styles={{
          ".class1": {
            color: "red",
          },
        }}
      />
      {!!(state % 2) && (
        <Global
          styles={{
            ".class2": {
              background: "yellow",
            },
          }}
        />
      )}
      <div className="class1 class2">你好 jzplp</div>
      <div onClick={() => setState(state + 1)}>按下变换</div>
    </>
  );
}

css-in-js-20.png

使用全局样式时,我们只要使用普通类名即可生效。当我们切换state的状态时,黄色的背景颜色也在变化。对应Global组件不渲染时,插入的CSS代码会被移除,渲染时会被引入。

@emotion/css

前面我们描述Emotion相关包,都是使用在React框架之内的。在React之外,还提供了@emotion/css包,用法和前面类似。

接入方式

首先我们创建一个工程接入@emotion/css。这里选用Vite。首先执行命令行:

npm init -y
npm add -D vite
npm add @emotion/css

然后在package.json的scripts中增加三个命令,分别是开发模式运行,打包和预览打包后的成果。

{
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
}

然后创建index.html,为浏览器入口文件,其中引入了index.js。

<html>
  <meta charset="UTF-8">
  <head>
    <title>jzplp的@emotion/css实验</title>
  </head>
  <body>
    <script src="./index.js" type="module"></script>
  </body>
</html>

然后是index.js的实现,引入了@emotion/css:

import { css } from '@emotion/css'

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const styles1 = css`
  color: red;
`;
const styles2 = css({
  color: 'blue',
});

console.log(styles1, styles2);
genEle('test1', styles1);
genEle('test2', styles2);

/* 输出结果
css-qm6gfa css-14ksm7b
*/

最后执行npm run dev命令,在浏览器查看结果。可以看到,我们和在React中使用一样,还是用模板字符串或者样式对象创建,但是创建后得到的结果是类名,我们直接放到DOM元素上即可。

css-in-js-21.png

样式数据

除了上面的模板字符串和样式对象之外,@emotion/css也支持数组类型以及嵌套选择器等属性,这里举例看一下。

import { css } from "@emotion/css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const styles1 = css`
  color: red;
  &:hover {
    background: yellow;
  }
`;
const styles2 = css([
  {
    color: "blue",
  },
  {
    "&:hover": {
      background: "yellow",
    },
  },
]);

console.log(styles1, styles2);
genEle("test1", styles1);
genEle("test2", styles2);

全局样式

@emotion/css也支持全局样式,但是引入方式不一样:

import { injectGlobal  } from "@emotion/css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

injectGlobal`
  .class1 {
    color: red;
    &:hover {
      background: yellow;
    } 
  }
`;

genEle("test1", 'class1');

cx优先级处理

@emotion/css提供了一个cx方法,它的作用和知名的classnames包一样,都是合并class类名的。但是这个cx提供了优先级功能,即后面的类中的样式优先级比前面的高:

import { cx, css } from "@emotion/css";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const cls1 = css`
  font-size: 20px;
  background: green;
`;
const cls2 = css`
  font-size: 20px;
  background: blue;
`;

genEle("test1", cx(cls1, cls2));
genEle("test2", cx(cls2, cls1));

这里我们创建了两个样式,其中背景颜色是冲突的。然后我创建了两个div,使用cx方法合并类名,但是连接各个div合并的顺序相反。在浏览器查看效果发现,实际展示的背景色也不一样,以后面的类名为准。

css-in-js-22.png

linaria与React

前面介绍的styled-components和@emotion,都是运行时CSS,即对应的生成样式的代码执行到的时候,这段CSS才会生成。因此这种库需要在打包后的代码中保留注入CSS的逻辑。还有另一类CSS in JS的库是零运行时的,即编译时生成CSS文件,在生产代码中直接引入即可,无需额外注入。这里先介绍linaria,它就是一个零运行时的库。

接入方式

我们依然以vite为例接入。linaria的背后依赖WyW-in-JS,它是一个零运行时CSS库的辅助工具包。首先执行命令行:

npm create vite@latest
npm add @linaria/core @linaria/react @wyw-in-js/vite

修改vite.config.ts,增加wyw配置:

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import wyw from '@wyw-in-js/vite';

export default defineConfig({
  plugins: [react(), wyw()],
})

然后修改App.tsx,接入linaria:

import { styled } from "@linaria/react";

const Div1 = styled.div`
  color: red;
`;

const Div2 = styled.div`
  color: blue;
  &:hover {
    background: yellow;
  }
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div2>jzplp2</Div2>
    </div>
  );
}

可以看到,这个使用方式就是styled组件的使用方式,和前面的库基本一致。以开发模式在浏览器中运行效果如下:

css-in-js-23.png

零运行时特性

在前面的接入方式中,我们并没有看到它与其它CSS in JS库的不同点。这一节我们专门看一下零运行时和前面的库究竟有什么不一样。首先修改App.tsx:

import { useState } from "react";
import { styled } from "@linaria/react";

const Div1 = styled.div`
  color: red;
`;

const Div2 = styled.div`
  color: blue;
  &:hover {
    background: yellow;
  }
`;

export default function App() {
  const [state, setState] = useState(0);

  return (
    <div>
      <Div1>jzplp1</Div1>
      {state % 2 === 1 && <Div2>jzplp2</Div2>}
      <div onClick={() => setState(state + 1)}>按下切换</div>
      <div>当前state {state}</div>
    </div>
  );
}

在代码中我们设置了变化的state状态,一开始为0时不展示Div2,当按下切换时,Div2才被执行和创建。注意当一开始Div2没被执行的时候,运行时的CSS in JS库是不会创建Div2对应的CSS代码的(因为代码都没执行到那里)。但linaria却会将CSS代码全都创建和引入,即使这些代码没有被使用。我们看下初始化时,linaria的效果:

css-in-js-24.png

注意看浏览器网络请求中有一个CSS请求,它的内容为当前引入的CSS代码,包括没被创建的元素的CSS代码:

css-in-js-25.png

然后我们将代码打包(npm run build)后,查看一下打包文件。可以看到我们在JavaScript中写的CSS代码,已经被编译成一个独立的CSS文件被引入到HTML中了,变成了普通CSS的形式。

css-in-js-26.png

零运行时中的参数

前面我们说过零运行时的库,会把CSS提前编译好放到文件中。对于固定的CSS代码来说非常容易,但当CSS中出现变量参数时,即CSS代码不确定时,应该怎么实现呢?这里我们举个例子看一下:

import { styled } from "@linaria/react";

const Div1 = styled.div`
  color: ${(props) => props.color || "yellow"};
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div1 color="red">jzplp1</Div1>
      <Div1 color="blue">jzplp1</Div1>
    </div>
  );
}

通过代码可以看到,传参方式和其它库基本一致。但是查看生成的代码,却发现不一致之处。运行时的库会直接生成完整的CSS代码提供,并且有一个自己专属的类名。但零运行时的库却将参数作为一个CSS变量引用。同时在对应组件的style属性中提供对应的CSS属性值,以此实现代码的零运行时特性。

css-in-js-27.png

打包后查看生成代码,也可以看到生成的带变量的CSS规则代码,以及我们根据处理props属性生成CSS变量的函数。这个函数只能在运行时处理。

css-in-js-28.png

CSS变量参数的限制

CSS变量虽然有效,但它的逻辑并不是字符串匹配,因此使用方式还是和运行时库有区别。我们先举一个普通的CSS代码作为例子,尝试了三种变量组合的场景。

.class1 {
  --classVar1: 21px;
  font-size:var(--classVar1);
}
.class2 {
  --classVar1: 21;
  font-size:var(--classVar1)px;
}
.class3 {
  --classVar1: 1px;
  font-size: 2var(--classVar1);
}

然后是对应的React代码:

import "./App.css";

export default function App() {
  return (
    <div>
      <div className="class1">jzplp1</div>
      <div className="class2">jzplp2</div>
      <div className="class3">jzplp3</div>
    </div>
  );
}

css-in-js-29.png

看可以看到只有第一个例子生效了,是21px整个作为CSS变量值。将这个值拆开是不能生效的,更不能像字符串那样随意拼合。那利用CSS变量作为参数方案的linaria呢?我们试一下:

import { styled } from "@linaria/react";

const Div1 = styled.div`
  font-size: ${(props) => props.size + "px"};
`;

const Div2 = styled.div`
  font-size: ${(props) => props.size}px;
`;

const Div3 = styled.div`
  font-size: ${(props) => props.size}1px;
`;

const Div4 = styled.div`
  font-size: 2${(props) => props.size}px;
`;

export default function App() {
  return (
    <div>
      <Div1 size={21}>jzplp1</Div1>
      <Div2 size={21}>jzplp2</Div2>
      <Div3 size={2}>jzplp3</Div3>
      <Div4 size={1}>jzplp4</Div4>
    </div>
  );
}

css-in-js-30.png

通过例子可以看到,21px整个作为参数与21作为参数都是可以的,2或者1单独作为参数就不行了,会造成CSS代码解析错误。这里21生效应该是linaria特殊处理过,把后面的px拼接上了。

CSS声明块作为参数

linaria也可以接收CSS声明块作为模板参数:

import { styled } from "@linaria/react";

const cssObj = {
  color: "red",
  fontSize: "20px",
}
const cssStr = `
  color: red;
  font-size: 20px;
`;

const Div1 = styled.div`
  background: yellow;
  ${cssObj}
`;
const Div2 = styled.div`
  background: yellow;
  ${cssStr}
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div2>jzplp2</Div2>
    </div>
  );
}

css-in-js-31.png

可以看到,不管是字符串模板还是对象形式都是支持的。但整个块对象不能是由函数返回的,即只能在编译时处理完成,不能有运行时特性。这还是由于前面CSS变量作为实现方案的原因,导致无法生效。这里举个例子:

import { styled } from "@linaria/react";

const cssObj = {
  color: "red",
  fontSize: "20px",
}
const cssStr = `
  color: red;
  font-size: 20px;
`;

const Div1 = styled.div`
  background: yellow;
  ${() => cssObj}
`;
const Div2 = styled.div`
  background: yellow;
  ${() => cssStr}
`;

export default function App() {
  return (
    <div>
      <Div1>jzplp1</Div1>
      <Div2>jzplp2</Div2>
    </div>
  );
}

css-in-js-32.png

通过结果看到,用函数包裹起来(假设它是接收props之后运行时计算的CSS代码)并未生效。这里为了方便查看效果,我们打个包看一下构建成果:

css-in-js-33.png

通过生产代码可以看到,这个函数逻辑以及返回值被原封不动的保留下来返回了,但是linaria却无法识别整个CSS声明,因此不能生效。

linaria中的css片段

linaria中也有css方法,使用方式与其它库类似,而且支持不在React框架中使用。

纯JavaScript接入方式

这里我们抛弃React,使用纯JavaScript的方式接入linaria。使用linaria必须要编译,这里依然选用Vite。首先执行命令行:

npm init -y
npm add -D vite @wyw-in-js/vite 
npm add @linaria/core

然后在package.json的scripts中增加三个命令,分别是开发模式运行,打包和预览打包后的成果。

{
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
}

然后创建index.html,为浏览器入口文件,其中引入了index.js。

<html>
  <meta charset="UTF-8">
  <head>
    <title>jzplp的linaria实验</title>
  </head>
  <body>
    <script src="./index.js" type="module"></script>
  </body>
</html>

然后是index.js的实现:

import { css } from '@linaria/core';

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

const cssData = css`
  color: red;
  font-size: 20px;
`;

console.log(data);
genEle('jzplp', cssData);

/* 输出结果
cf71da1
*/

可以看到,css函数也是使用模板字符串来写CSS规则,返回值是一个类名,可以直接用在元素上。使用linaria还要修改打包配置,创建vite.config.js:

import { defineConfig } from "vite";
import wyw from "@wyw-in-js/vite";

export default defineConfig({
  plugins: [wyw()],
});

然后执行npm run dev,开发模式下正常生效:

css-in-js-34.png

然后我们执行npm run build,查看打包后的生成代码:

css-in-js-35.png

可以看到,我们用css函数生成的CSS代码,已经被放到独立的CSS文件中,css函数使用的位置,已经直接变成了class类名。这也是零运行时库的效果,在编译时就将CSS代码生成完毕。

全局样式

linaria也支持创建全局样式,这里我们试一下。

import { css } from "@linaria/core";

function genEle(test, className) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

css`
  :global() {
    div {
      color: red;
    }
    .class1 {
      background: yellow;
    }
  }
`;

const cssData = css`
  font-size: 20px;
`;

console.log(cssData);
genEle("jzplp1", "class1");
genEle("jzplp2", cssData);

css-in-js-36.png

将全局特性包裹在:global()中,即可生效。这段CSS甚至都不需要被哪个标签引用。我们再看看打包后的结果:

css-in-js-37.png

可以看到,对应的这段css函数代码没有了,全局特性转移到了CSS文件中。

vanilla-extract

vanilla-extract是另一个CSS in JS库,正如它的名字,vanilla表示不使用框架的纯JavaScript。vanilla-extract这个库是框架无关的,同样他也是一个零运行时库。

接入方式

我们还是使用Vite接入,但这次试一下Vite提供的vanilla模板。执行命令行:

npm create vite@latest
# 选择 Vanilla + TypeScript模板
npm add @vanilla-extract/css @vanilla-extract/vite-plugin

创建vite.config.js文件,内容如下:

import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';

export default {
  plugins: [vanillaExtractPlugin()]
};

创建src/styles.css.ts文件,内容为创建样式,并导出。

import { style } from '@vanilla-extract/css';

export const cls1 = style({
  color: 'red'
});

然后删除无用的文件,修改src/main.js的内容为引入创建的样式,并作为类名放到HTML标签上。

import { cls1 } from "./styles.css.ts";

function genEle(test: string, className: string) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("jzplp", cls1);

css-in-js-38.png

在开发模式运行,通过浏览器可以看到,也是在head中插入了style标签提供样式。我们再打包看看生成文件:

css-in-js-39.png

通过生成文件可以看到,vanilla-extract也是在编译时就生成独立的样式文件引入,不需要运行时处理。

独立.css.ts文件

vanilla-extract与其它CSS in JS方案不同点在于,虽然它确实是用JavaScript写CSS代码,但却要求独立的文件类型“.css.ts”。如果在其它文件中写入会造成错误。这里我们试一试,修改前面的src/main.js:

import { style } from '@vanilla-extract/css';

const cls1 = style({
  color: 'red'
});

function genEle(test: string, className: string) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("jzplp", cls1);

这时候开发模式打开浏览器,会看到报错,元素也没有正常展示:

css-in-js-40.png

回想起使用CSS in JS方案的重要原因就是希望CSS代码与组件的联系更紧密。这样强制的独立.css.ts文件,看起来没有增加紧密感。

生成style

vanilla-extract使用style创建样式,返回对应的类名。style方法接收对象形式的CSS规则,但是与常规CSS写法有点不同,这里介绍部分不同点。

px单位

首先是如果属性值的单位是px,可以省略,写成数字形式。这里举个例子:

export const cls1 = style({
  fontSize: 10,
  margin: 20,
  padding: "10px",
  flex: 1,
});

/* 生成结果
.r9osg00 {
  flex: 1;
  margin: 20px;
  padding: 10px;
  font-size: 10px;
}
*/

我们直接对代码打包,观察生成的CSS文件。可以看到vanilla-extract并不是所有数字都会转换,那些没有单位的CSS属性并不会被转换。

浏览器引擎前缀写法

在对象中写CSS属性需要以camelCase驼峰命名法,但对于浏览器引擎前缀这种最前面带中划线的形式,vanilla-extract要求使用PascalCase帕斯卡命名法,即最前面大写。

export const cls1 = style({
  WebkitTapHighlightColor: "rgba(0, 0, 0, 0)",
});

/* 生成结果
.r9osg00 {
  -webkit-tap-highlight-color: #0000;
}
*/

媒体查询/容器查询/@layer等

它们的写法多了一层嵌套:

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  "@container": {
    "(min-width: 768px)": {
      padding: 10,
    },
  },
  "@media": {
    "screen and (min-width: 768px)": {
      padding: 10,
    },
    "(prefers-reduced-motion)": {
      transitionProperty: "color",
    },
  },
  "@layer": {
    typography: {
      fontSize: "1rem",
    },
  },
  "@supports": {
    "(display: grid)": {
      display: "grid",
    },
  },
});

/* 生成结果
@layer typography {
  .r9osg00 {
    font-size: 1rem;
  }
}
@media screen and (width>=768px) {
  .r9osg00 {
    padding: 10px;
  }
}
@media (prefers-reduced-motion) {
  .r9osg00 {
    transition-property: color;
  }
}
@supports (display: grid) {
  .r9osg00 {
    display: grid;
  }
}
@container (width>=768px) {
  .r9osg00 {
    padding: 10px;
  }
}
*/

后备值

在之前讲PostCSS中postcss-custom-properties插件的时候,我们提到过当浏览器读取到一个不支持的CSS属性值时,如果这个属性前面已经有一个后备值了,那就使用那个后备值,不会应用不支持的属性值。

但以对象的形式写CSS属性,key同一个的情况下,没办法写两个值。这里vanilla-extract接收一个值数组,实现后备值功能:

export const cls1 = style({
  overflow: ['auto', 'overlay']
});

/* 生成结果
.r9osg00 {
  overflow: auto;
  overflow: overlay;
}
*/

CSS变量

vanilla-extract支持使用CSS变量,需要在vars属性内部。创建后的CSS变量可以在任意位置使用,但需要满足选择器的条件。

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  vars: {
    "--jzplp1": "red",
  },
  color: "var(--jzplp1)",
});

export const cls2 = style({
  color: "var(--jzplp1)",
  fontSize: 20,
});

/* 生成结果
.r9osg00 {
  --jzplp1: red;
  color: var(--jzplp1);
}
.r9osg01 {
  color: var(--jzplp1);
  font-size: 20px;
}
*/

还可以通过createVar方法创建带有哈希的模块化CSS变量,在使用的位置引用即可。

import { style, createVar } from "@vanilla-extract/css";

const cssVar = createVar();

export const cls1 = style({
  vars: {
    [cssVar]: "red",
  },
  color: cssVar,
});

export const cls2 = style({
  color: cssVar,
  fontSize: 20,
});

/* 生成结果
.r9osg01 {
  --r9osg00: red;
  color: var(--r9osg00);
}
.r9osg02 {
  color: var(--r9osg00);
  font-size: 20px;
}
*/

嵌套选择器

vanilla-extract支持使用嵌套选择器,但是有一些特殊的规则。

顶层使用

对于简单的无参数的伪类或者伪元素选择器,可以与其它CSS属性放在一起顶层使用。

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  ":hover": {
    background: "yellow",
  },
  "::before": {
    content: "jzplp",
  },
});

/* 生成结果
.r9osg00 {
  color: red;
}
.r9osg00:hover {
  background: #ff0;
}
.r9osg00:before {
  content: "jzplp";
}
*/

可以看到,与属性一同使用时可以省略&符号。但是这里不能添加带参数或者复杂的组合选择器,否则会编译报错:

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  "&:hover": {
    background: "yellow",
  },
  ":not(.cls)": {
    content: "jzplp",
  },
});

/* 分别报错
error TS2353: Object literal may only specify known properties, and '"&:hover"' does not exist in type 'ComplexStyleRule'.
error TS2353: Object literal may only specify known properties, and '":not(.cls)"' does not exist in type 'ComplexStyleRule'.
*/

selectors中使用

在selectors属性中可以编写复杂的选择器,但要自己写&符号。

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  selectors: {
    "&:hover": {
      background: "yellow",
    },
    "&:not(.cls)": {
      content: "jzplp",
    },
    ".abc &": {
      fontSize: 20
    },
  },
});

/* 生成结果
.r9osg00 {
  color: red;
}
.r9osg00:hover {
  background: #ff0;
}
.r9osg00:not(.cls) {
  content: "jzplp";
}
.abc .r9osg00 {
  font-size: 20px;
}
*/

禁止场景

选择器也不是随便写都行的,vanilla-extract要求选择器必须作用于当前类。如果作用于其它类,那么编译时也会报错:

import { style } from "@vanilla-extract/css";

export const cls1 = style({
  color: "red",
  selectors: {
    ":hover": {
      background: "yellow",
    },
    "& .abc": {
      fontSize: 20
    },
  },
});

/* 分别报错
Error: Invalid selector: :hover
Error: Invalid selector: & .abc
Style selectors must target the '&' character (along with any modifiers), e.g. `${parent} &` or `${parent} &:hover`.
*/

它会实际分析CSS规则,并不是把&写在什么位置就能避开的。

传入类名

嵌套选择器也支持传入其它style函数生成的类名,同样需要遵守选择器必须作用于当前类。

import { style } from "@vanilla-extract/css";

const cls = style({
  color: 'red',
})

export const cls1 = style({
  selectors: {
    [`.${cls} &`]: {
      background: "yellow",
    },
    [`&:not(.${cls})`]: {
      fontSize: 20
    },
  },
});

/* 生成结果
.r9osg00 {
  color: red;
}
.r9osg00 .r9osg01 {
  background: #ff0;
}
.r9osg01:not(.r9osg00) {
  font-size: 20px;
}
*/

全局样式

vanilla-extract可通过globalStyle函数创建全局样式,第一个参数是选择器,第二是样式对象。

import { globalStyle } from "@vanilla-extract/css";

globalStyle("body", {
  vars: {
    "--jzplp": "10px",
  },
  margin: 0,
});

globalStyle(".abc .bcd:hover", {
  color: "red",
});

/* 生成结果
body {
  --jzplp: 10px;
  margin: 0;
}
.abc .bcd:hover {
  color: red;
}
*/

全局样式中也能包含使用style函数生成的模块化类名,这时候就没有选择器作用限制了:

import { globalStyle, style } from "@vanilla-extract/css";

const cls = style({
  color: "red",
});

globalStyle(`body .${cls}`, {
  margin: 0,
});

globalStyle(`.${cls} :not(div)`, {
  fontSize: 10,
});

/* 生成结果
.r9osg00 {
  color: red;
}
body .r9osg00 {
  margin: 0;
}
.r9osg00 :not(div) {
  font-size: 10px;
}
*/

主题

创建主题

vanilla-extract支持使用createTheme方法创建主题,主题实际上就是一组预设CSS变量。首先我们创建主题并直接打包,看下输出结果:

import { createTheme } from "@vanilla-extract/css";

const [theme1, vars] = createTheme({
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

console.log(theme1);
console.log(vars);

/* 打包命令行输出
r9osg00
{
  color: { banner: 'var(--r9osg01)', font: 'var(--r9osg02)' },
  size: { margin: 'var(--r9osg03)' }
}
*/

/* 打包后CSS文件内容
.r9osg00 {
  --r9osg01: red;
  --r9osg02: blue;
  --r9osg03: 20px;
}
*/

上面创建了一个主题。其中vars表示这个主题模式的对象,其中包含变量的引用。theme1是类名,使用这个类即可对这些CSS变量提供值。

使用主题

下面我们再来看使用方式。首先是styles.css.ts,除了创建主题之外还创建了样式,其中引用了vars中的变量。

import { createTheme, style } from "@vanilla-extract/css";

const [theme1, vars] = createTheme({
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

const cls1 = style({
  color: vars.color.font,
});

export { theme1, cls1 };

/* 生成结果
.r9osg00 {
  --r9osg01: red;
  --r9osg02: blue;
  --r9osg03: 20px;
}
.r9osg04 {
  color: var(--r9osg02);
}
*/

然后是main.js,将主题类放到body中,再将应用主题的类放到div上。这样div会使用body上的变量,使主题生效。

import { theme1, cls1 } from './styles.css';

document.body.className = theme1;

function genEle(test: string, className: string) {
  const div = document.createElement("div");
  div.className = className;
  div.textContent = test;
  document.body.appendChild(div);
}

genEle("jzplp", cls1);

css-in-js-41.png

复用主题

既然是主题,那么应该不会只有一个,主题应该是同变量但是值不同的一组结构,这时候可以复用vars继续创建主题。

import { createTheme } from "@vanilla-extract/css";

const [theme1, vars] = createTheme({
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

const theme2 = createTheme(vars, {
  color: {
    banner: "yellow",
    font: "pink",
  },
  size: {
    margin: "30px",
  },
});

export { theme1, theme2 };

/* 生成结果
.r9osg00 {
  --r9osg01: red;
  --r9osg02: blue;
  --r9osg03: 20px;
}
.r9osg04 {
  --r9osg01: yellow;
  --r9osg02: pink;
  --r9osg03: 30px;
}
*/

可以看到,创建的theme2应该遵守同样的结构,但是值不同。theme2仅生成类名,在对应的标签上赋值即可实现主题切换。

仅创建vars

通过上面的例子可以看到,vars表示主题的结构和引用,生成的类名表示实际的主题值。但是现在创建主题结构和创建主题值合二为一了,如果希望分开生成,vanilla-extract提供了createThemeContract方法。可以将上面的代码改下如下,效果不变。

import { createTheme, createThemeContract } from "@vanilla-extract/css";

const vars = createThemeContract({
  color: {
    banner: "",
    font: "",
  },
  size: {
    margin: "",
  },
});

const theme1 = createTheme(vars, {
  color: {
    banner: "red",
    font: "blue",
  },
  size: {
    margin: "20px",
  },
});

const theme2 = createTheme(vars, {
  color: {
    banner: "yellow",
    font: "pink",
  },
  size: {
    margin: "30px",
  },
});

export { theme1, theme2 };

总结

库列表

前面我们介绍了四个CSS in JS的库,但这仅仅是九牛一毛。社区中CSS in JS的库非常非常多,这里用表格列举一些知名度较高的:

库名称 零运行时 纯JS 适配React框架 备注
styled-components 不支持 支持 本文已介绍
Emotion 支持 支持 本文已介绍
linaria 支持 支持 本文已介绍
vanilla-extract 支持 不支持 本文已介绍
Panda CSS 支持 支持 -
Compiled 不支持 支持 -
Radium 不支持 支持 已停止维护
JSS 插件支持 支持 支持 已停止维护

特点总结

本文只是简单介绍了几个CSS in JS库的使用方式和特点,并未详细探究原理和区别。事实上关于CSS in JS库还有很多值得探讨的主题,例如服务端渲染,性能优化等。根据介绍的几个CSS in JS库以及网络上相关分析,这里我们总结一下CSS in JS的特点,其中有优点,也有缺点:

  • 使用方式和API各有特色,但也有很多相似之处
    • 可以看到很多API设计都是类似的,例如styled组件、css的props、模板字符串或对象作为CSS规则表示,这些API在很多库中都以类似的方式出现
    • 但很多库的设计都有自己的特点和使用方式,并没有千篇一律
  • 虽然CSS in JS的设计各有特色,但还是可以大致分成 运行时库和零运行时库两类
    • 运行时库对于CSS传参的灵活性高,但是运行时生成CSS,有额外的性能损失
    • 零运行时库在编译时生成CSS文件,没有运行时性能损失,但对于CSS参数等灵活性低
  • CSS in JS库对于类型检查和提示更友好,支持TypeScript更完善
  • 由于CSS是在JavaScript中撰写的,因此控制和切换CSS要更方便
  • CSS in JS的类名时根据hash生成的,自带模块化类名,省去了起名的烦恼,也防止了CSS污染
  • 类名是自动生成的,因此有些开发者认为比较难看,不方便查找元素等。相比较CSS Modules可以配置原类名+hash,可以轻松识别类名
  • 在JavaScript中写CSS,更方便组织代码,例如将CSS与HTML和JS放在一起,以组件化的形式组织
  • 对于运行时CSS,CSS代码只有在需要的时候才加载,对于部分页面场景,具有更小的首屏文件体积和其它优势
  • 使用独立的CSS文件我们不清楚哪些样式是真正使用的,哪些样式没有被用到。而CSS in JS的CSS规则引用关系明显,我们可以轻松找到未被使用的样式,也可以利用tree-shaking等技术编译时去掉不需要的样式
  • 有些人很喜欢CSS in JS来组织CSS代码,但是有些人却觉得多此一举。萝卜青菜,各有所爱
  • CSS in JS在React框架使用居多,Vue框架有自己的方案(组件作用域CSS,我们之前介绍过),基本不需要CSS in JS

参考

深入浅出 Tree Shaking:Rollup 是如何“摇”掉死代码的?

前言

在前端性能优化中,减小 JS 包体积是重中之重。Tree Shaking(摇树优化) 就像它的名字一样:通过摇晃代码这棵大树,让那些无用的“枯叶”(死代码)掉落。本文将带你揭秘 Rollup 实现 Tree Shaking 的底层原理。

一、 核心基石:为什么是基于ESM?

Tree Shaking 的实现并非偶然,它深度依赖于 ESM (ES Module) 规范。

  • 静态分析:ESM 要求 importexport 必须在代码顶层,不能出现在 if 块或函数内部。
  • 编译时确定:这意味着 Rollup 不需要执行代码,只需扫描一遍源码,就能在编译阶段清晰地知道模块间的依赖关系。
  • 对比 CommonJSrequire 是动态加载的,只有运行到那一行才知道加载了什么,因此 CJS 无法进行彻底的 Tree Shaking。

二、Rollup Tree Shaking 实现原理:从扫描到删除的四步曲

Rollup 的“摇树”过程可以分为以下四个精密步骤:

1. 递归扫描与依赖图构建

从入口文件(如 main.js)开始,递归扫描所有 import/export 语句。Rollup 会记录:

  • 每个模块导出了哪些变量/函数。
  • 每个模块导入了哪些内容。
  • 模块间的引用链路(A 引用了 B 的哪个具体成员)。
  • 基于这些信息,Rollup 会构建出一个完整的模块依赖图,清晰呈现整个项目的代码引用链路。

步骤 2:标记活代码与死代码

在模块依赖图的基础上,Rollup 会从入口文件出发,反向追踪所有被引用的内容,标记出活代码和死代码:

  • 首先标记出哪些导出项被外部(其他模块或入口文件)引用;
  • 接着判断这些被引用的导出项,是否真的在代码中被使用(而非仅导入未使用),若被使用则标记为活代码,未被使用则标记为死代码

步骤 3:AST 分析优化(补充细节)

在标记活代码的过程中,Rollup 会深入分析每个模块的 AST(抽象语法树) ,精准追踪变量、函数的定义和引用关系。这里有一个容易被忽略的点:

  • 即使一个变量、函数在模块内定义了,但既没有被 export 导出,也没有在模块内部被引用,它依然会被判定为死代码,被 Tree Shaking 摇掉——也就是说,Tree Shaking 不仅会处理“导出未使用”的代码,也会清理模块内部“定义未使用”的冗余代码。

步骤 4:删除死代码,生成最终产物

最后,Rollup 会遍历所有模块,只保留标记为活代码的内容,直接删除所有死代码(未被引用的导出项、模块内部未使用的定义等),最终生成精简、无冗余的打包产物。

Rollup 的 Tree Shaking 是原生支持的,无需额外配置,打包时会自动执行上述流程,且输出的代码更接近手写风格,无多余的运行时代码,优化效果直观可见。


三、 实战:不同导出方式的“招魂”效果

Tree Shaking 的效果,很大程度上取决于代码的导出方式——只有静态导出才能被 Rollup 精准分析,动态导出则无法实现 Tree Shaking。以下是常见的导出方式对比:

导出方式 是否支持树摇 深度原因分析
export const a = 1 完美支持 静态导出,引用关系明确。
export function b() {} 完美支持 未被调用时可被精准识别并删除。
export default { a:1 } 不支持/效果差 默认导出是一个对象,工具难以判断你是否会动态访问对象的某个 Key。
export * from './x.js' 支持 按需转发,只会转发那些被下游真正引用的成员。

四、 Tree Shaking 避坑指南

避坑点 1:CommonJS 模块会导致 Tree Shaking 罢工

  • 如果项目中引入了使用 require/module.exports 的第三方库,Tree Shaking 会直接失效。原因如下:

    • CommonJS 模块是动态模块,require 可以接收变量(如 require(./${name}.js)),导入导出关系只能在运行时确定,Rollup 无法在打包前进行静态分析,因此无法识别死代码,Tree Shaking 自然无法生效。

    实战建议:优先使用支持 ESM 规范的第三方库(如 lodash-es 替代 lodash),避免在 ESM 项目中混用 CommonJS 模块。

避坑点 2:副作用代码会干扰 Tree Shaking

  • 如果模块中存在“副作用代码”(即执行后会影响全局环境、修改外部变量、执行 DOM 操作等的代码,如顶层的 console.log、window.xxx = xxx),即使这些代码未被引用,Rollup 也会保守地保留它们,避免影响项目运行逻辑,从而导致部分死代码无法被摇掉。

    解决方案:如果确认模块无副作用,可在 package.json 中添加 "sideEffects": false,告诉 Rollup 该模块可安全删除未引用代码;若有部分文件有副作用(如 CSS 文件),可显式声明:"sideEffects": ["./src/style.css"]

避坑点 3:动态访问会导致 Tree Shaking 失效

  • 如果代码中存在动态访问导出项的情况(如 import * as utils from './utils.js'; utils[dynamicKey]()),Rollup 无法在静态分析阶段确定哪些导出项被使用,会保留整个模块的所有导出项,导致 Tree Shaking 失效。

    实战建议:尽量使用具名导入(import { func } from './utils.js'),避免动态访问导出项。


五、 总结

Rollup Tree Shaking 的核心是“基于 ESM 静态规范,通过静态分析识别并删除死代码”,其流程简洁高效,且原生支持无需额外配置。想要用好 Tree Shaking,关键记住 3 点:

  • 坚持使用 ESM 规范(import/export),避免混用 CommonJS;
  • 优先使用静态具名导出,避免默认导出对象、动态导出;
  • 处理好副作用代码,必要时通过 package.json 的 sideEffects 字段声明。

大屏开发必读:Scale/VW/Rem/流式/断点/混合方案全解析(附完整demo)

大屏适配终极指南:6 种主流方案实战对比

数据可视化大屏开发中,屏幕适配是最令人头疼的问题之一。本文通过 6 个完整 Demo,深度对比 Scale、VW/VH、Rem、流式布局、响应式断点、混合方案这 6 种主流方案的优缺点与适用场景。

前言

在开发数据可视化大屏时,你是否遇到过这些问题:

  • 设计稿是 1920×1080,实际大屏是 3840×2160,怎么适配?
  • 大屏比例不是 16:9,出现黑边或拉伸怎么办?
  • 小屏幕上字体太小看不清,大屏幕上内容太空旷
  • 图表、地图、视频等组件在不同尺寸下表现不一致

本文将通过 6 个完整的可交互 Demo,带你逐一攻克这些难题。

方案一:Scale 等比例缩放

核心原理

通过 CSS transform: scale() 将整个页面按屏幕比例缩放,是最简单直观的方案。

const DESIGN_WIDTH = 1920;
const DESIGN_HEIGHT = 1080;

function setScale() {
  const scaleX = window.innerWidth / DESIGN_WIDTH;
  const scaleY = window.innerHeight / DESIGN_HEIGHT;
  const scale = Math.min(scaleX, scaleY);

  screen.style.transform = `scale(${scale})`;

  // 居中显示
  const left = (window.innerWidth - DESIGN_WIDTH * scale) / 2;
  const top = (window.innerHeight - DESIGN_HEIGHT * scale) / 2;
  screen.style.left = left + 'px';
  screen.style.top = top + 'px';
}

优缺点分析

优点:

  • 完美还原设计稿比例
  • 实现简单,几行代码搞定
  • 字体、图表自动缩放,无需额外处理
  • 兼容性好,不需要关注浏览器兼容性

缺点:

  • 屏幕比例不符时出现黑边(如 4:3 屏幕)
  • 小屏幕上字体会缩得很小,可能看不清
  • 无法利用多余空间,浪费屏幕资源

适用场景

数据可视化大屏、监控中心大屏等需要精确还原设计稿的场景。

方案二:VW/VH 视口单位

核心原理

使用 CSS viewport 单位(vw/vh)代替 px,让元素根据视口尺寸自适应。

.container {
  width: 50vw;      /* 视口宽度的 50% */
  height: 30vh;     /* 视口高度的 30% */
  font-size: 2vw;   /* 字体随视口宽度变化 */
  padding: 2vh 3vw; /* 内边距也自适应 */
}

优缺点分析

优点:

  • 纯 CSS 方案,无 JS 依赖
  • 充分利用全部屏幕空间,无黑边
  • 无缩放导致的模糊问题

缺点:

  • 计算公式复杂,需要手动换算
  • 宽高难以协调,容易出现变形
  • 单位换算易出错,维护成本高

适用场景

内容型页面、需要充分利用屏幕空间的场景。

方案三:Rem 动态计算

核心原理

根据视口宽度动态计算根字体大小,所有尺寸使用 rem 单位。

function setRem() {
  const designWidth = 1920;
  const rem = (window.innerWidth / designWidth) * 100;
  document.documentElement.style.fontSize = rem + 'px';
}

// CSS 中使用 rem
.card {
  width: 4rem;      /* 设计稿 400px */
  height: 2rem;     /* 设计稿 200px */
  font-size: 0.24rem; /* 设计稿 24px */
}

优缺点分析

优点:

  • 计算相对直观(设计稿 px / 100 = rem)
  • 兼容性最好,支持 IE9+
  • 无缩放模糊问题

缺点:

  • 依赖 JS 初始化,页面可能闪烁
  • 高度处理困难,只能基于宽度适配
  • 需要手动或使用工具转换单位

适用场景

移动端 H5 页面、PC 端后台系统。

方案四:流式/弹性布局

核心原理

使用百分比、Flexbox、Grid 等 CSS 原生能力实现响应式布局。

.dashboard {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 20px;
}

.card {
  display: flex;
  flex-direction: column;
  padding: 2%;
}

.chart {
  flex: 1;
  min-height: 200px;
}

优缺点分析

优点:

  • 纯 CSS 方案,无任何依赖
  • 自然响应式,适配各种屏幕
  • 内容自适应,信息密度可变

缺点:

  • 设计稿还原度低,无法精确控制
  • 图表比例容易失控
  • 空间利用率低,不够美观

适用场景

B 端后台系统、管理平台等以内容为主的页面。

方案五:响应式断点

核心原理

使用 @media 查询针对不同屏幕尺寸写多套样式规则。

/* 1920×1080 大屏 */
@media (min-width: 1920px) {
  .container { width: 1800px; font-size: 24px; }
  .grid { grid-template-columns: repeat(4, 1fr); }
}

/* 3840×2160 4K 屏 */
@media (min-width: 3840px) {
  .container { width: 3600px; font-size: 48px; }
  .grid { grid-template-columns: repeat(6, 1fr); }
}

/* 1366×768 小屏 */
@media (max-width: 1366px) {
  .container { width: 1300px; font-size: 18px; }
  .grid { grid-template-columns: repeat(3, 1fr); }
}

优缺点分析

优点:

  • 精确控制各个分辨率的显示效果
  • 字体可读性可控
  • 适合有固定规格的大屏群

缺点:

  • 代码量爆炸,维护极其困难
  • 无法覆盖所有可能的分辨率
  • 新增规格需要大量修改

适用场景

有固定规格的大屏群、展厅多屏展示系统。

方案六:混合方案(推荐)

核心原理

结合 Scale + Rem 的优点,既保证设计稿比例,又确保字体在小屏可读。

const DESIGN_WIDTH = 1920;
const DESIGN_HEIGHT = 1080;
const MIN_SCALE = 0.6; // 最小缩放限制

function adapt() {
  const winW = window.innerWidth;
  const winH = window.innerHeight;

  // Scale 计算 - 保证整体比例
  const scaleX = winW / DESIGN_WIDTH;
  const scaleY = winH / DESIGN_HEIGHT;
  const scale = Math.max(Math.min(scaleX, scaleY), MIN_SCALE);

  // 应用 scale
  screen.style.transform = `scale(${scale})`;

  // Rem 计算 - 根据缩放比例调整根字体
  // 当 scale < 1 时,增加根字体补偿
  const baseRem = 100;
  const fontScale = Math.max(scale, MIN_SCALE);
  const rem = baseRem * fontScale;
  document.documentElement.style.fontSize = rem + 'px';
}

优缺点分析

优点:

  • 等比例缩放保证布局一致性
  • 字体最小值保护,防止过小不可读
  • 大屏清晰、小屏可读
  • 兼顾视觉和体验

缺点:

  • 需要 JS 支持
  • 计算逻辑稍复杂

适用场景

通用推荐方案,适合绝大多数大屏开发场景。

方案对比一览表

方案 实现难度 设计稿还原度 响应式表现 小屏可读性 维护成本 推荐场景
Scale 简单 极高 一般 数据可视化大屏
VW/VH 中等 中等 中等 中等 内容型页面
Rem 中等 一般 中等 中等 移动端 H5
流式布局 简单 极好 B 端后台系统
断点方案 复杂 中-高 极高 固定规格大屏群
混合方案 中等 中等 通用推荐

场景推荐速查

🖥️ 数据可视化大屏 / 监控中心

需要精确还原设计稿,图表比例严格保持,像素级对齐。

推荐: Scale 等比例缩放(接受黑边)或 混合方案

示例: 企业展厅大屏、运营监控看板

📊 B 端后台 / 管理系统

内容为主,需要充分利用屏幕空间,信息密度要高。

推荐: 流式布局 或 VW/VH 方案

示例: CRM 系统、数据管理平台

🌐 多端适配 / 响应式网站

需要覆盖手机、平板、电脑、大屏等多种设备。

推荐: 响应式断点 + 流式布局

示例: 企业官网、数据门户

完整 Demo 代码

以下是 6 种大屏适配方案的完整可运行代码,保存为 HTML 文件后可直接在浏览器中打开体验。

Demo 1: Scale 等比例缩放

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>方案1: Scale 等比例缩放 - 大屏适配方案对比</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }

        /* 信息面板 - 不参与缩放 */
        .info-panel {
            position: fixed;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.85);
            color: #fff;
            padding: 20px;
            border-radius: 8px;
            z-index: 9999;
            width: 320px;
            font-size: 14px;
        }

        .info-panel h3 {
            color: #4fc3f7;
            margin-bottom: 12px;
            font-size: 16px;
        }

        .info-panel .pros-cons {
            margin-bottom: 15px;
        }

        .info-panel .pros {
            color: #81c784;
        }

        .info-panel .cons {
            color: #e57373;
        }

        .info-panel .current-scale {
            background: #ff9800;
            color: #000;
            padding: 8px 12px;
            border-radius: 4px;
            font-weight: bold;
            margin-top: 10px;
        }

        /* 大屏容器 - 按 1920*1080 设计 */
        .screen-container {
            width: 1920px;
            height: 1080px;
            transform-origin: 0 0;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            position: relative;
            overflow: hidden;
        }

        /* 大屏内容样式 */
        .header {
            height: 100px;
            background: linear-gradient(90deg, #0f3460 0%, #533483 50%, #0f3460 100%);
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 48px;
            color: #fff;
            text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
            letter-spacing: 8px;
        }

        .main-content {
            display: flex;
            padding: 30px;
            gap: 20px;
            height: calc(100% - 100px);
        }

        .sidebar {
            width: 400px;
            background: rgba(15, 52, 96, 0.3);
            border-radius: 16px;
            padding: 20px;
            border: 1px solid rgba(79, 195, 247, 0.2);
        }

        .chart-area {
            flex: 1;
            display: grid;
            grid-template-columns: repeat(2, 1fr);
            gap: 20px;
        }

        .card {
            background: rgba(15, 52, 96, 0.3);
            border-radius: 16px;
            padding: 20px;
            border: 1px solid rgba(79, 195, 247, 0.2);
            display: flex;
            flex-direction: column;
        }

        .card-title {
            color: #4fc3f7;
            font-size: 24px;
            margin-bottom: 15px;
        }

        .card-value {
            color: #fff;
            font-size: 56px;
            font-weight: bold;
        }

        .mini-chart {
            flex: 1;
            margin-top: 15px;
            border-radius: 8px;
            min-height: 180px;
        }

        .notice {
            position: absolute;
            bottom: 20px;
            left: 20px;
            right: 20px;
            background: rgba(255, 152, 0, 0.2);
            border: 1px solid #ff9800;
            color: #ff9800;
            padding: 15px 20px;
            border-radius: 8px;
            font-size: 18px;
        }

        .grid-line {
            position: absolute;
            top: 50%;
            left: 0;
            right: 0;
            height: 1px;
            border-top: 2px dashed rgba(79, 195, 247, 0.1);
        }

        .grid-line::before {
            content: '设计稿中心线 (960px)';
            position: absolute;
            left: 50%;
            transform: translateX(-50%);
            color: rgba(79, 195, 247, 0.3);
            font-size: 14px;
        }
    </style>
</head>
<body>
    <!-- 信息面板 -->
    <div class="info-panel">
        <h3>方案1: Scale 等比例缩放</h3>
        <div class="pros-cons">
            <div class="pros">✓ 优点:</div>
            • 完美还原设计稿比例<br>
            • 实现简单直观<br>
            • 字体/图表自动缩放<br><br>
            <div class="cons">✗ 缺点:</div>
            • 屏幕比例不符时出现黑边<br>
            • 字体过小可能看不清<br>
            • 无法利用多余空间
        </div>
        <div class="current-scale" id="scaleInfo">
            缩放比例: 1.0<br>
            窗口尺寸: 1920×1080
        </div>
    </div>

    <!-- 大屏容器 -->
    <div class="screen-container" id="screen">
        <div class="header">SCALE 方案演示 - 1920×1080 设计稿</div>

        <div class="main-content">
            <div class="sidebar">
                <div class="card-title">左侧信息面板</div>
                <p style="color: rgba(255,255,255,0.7); font-size: 18px; line-height: 1.8;">
                    这是基于 1920×1080 设计稿开发的页面。<br><br>
                    修改浏览器窗口大小,观察整个页面如何等比例缩放。注意两侧的空白区域(当屏幕比例不是 16:9 时)。
                </p>
            </div>

            <div class="chart-area">
                <div class="card">
                    <div class="card-title">实时用户数</div>
                    <div class="card-value" id="value1">128,456</div>
                    <div class="mini-chart" id="chart1"></div>
                </div>
                <div class="card">
                    <div class="card-title">交易金额</div>
                    <div class="card-value" id="value2">¥2.3M</div>
                    <div class="mini-chart" id="chart2"></div>
                </div>
                <div class="card">
                    <div class="card-title">系统负载</div>
                    <div class="card-value" id="value3">68%</div>
                    <div class="mini-chart" id="chart3"></div>
                </div>
                <div class="card">
                    <div class="card-title">响应时间</div>
                    <div class="card-value" id="value4">23ms</div>
                    <div class="mini-chart" id="chart4"></div>
                </div>
            </div>
        </div>

        <div class="grid-line"></div>

        <div class="notice">
            💡 提示:调整浏览器窗口为 4:3 比例或手机尺寸,观察两侧的黑边/留白。这是 Scale 方案的典型特征。
        </div>
    </div>

    <script>
        const screen = document.getElementById('screen');
        const scaleInfo = document.getElementById('scaleInfo');

        // 设计稿尺寸
        const DESIGN_WIDTH = 1920;
        const DESIGN_HEIGHT = 1080;

        function setScale() {
            const winW = window.innerWidth;
            const winH = window.innerHeight;

            // 计算宽高缩放比例,取较小值保持完整显示
            const scaleX = winW / DESIGN_WIDTH;
            const scaleY = winH / DESIGN_HEIGHT;
            const scale = Math.min(scaleX, scaleY);

            // 应用缩放
            screen.style.transform = `scale(${scale})`;

            // 可选:居中显示
            const left = (winW - DESIGN_WIDTH * scale) / 2;
            const top = (winH - DESIGN_HEIGHT * scale) / 2;
            screen.style.position = 'absolute';
            screen.style.left = left + 'px';
            screen.style.top = top + 'px';

            // 更新信息
            scaleInfo.innerHTML = `
                缩放比例: ${scale.toFixed(3)}<br>
                窗口尺寸: ${winW}×${winH}<br>
                设计稿: 1920×1080<br>
                空白区域: ${Math.round((winW - DESIGN_WIDTH * scale))}×${Math.round((winH - DESIGN_HEIGHT * scale))}
            `;
        }

        setScale();

        // ===== ECharts 图表配置 =====
        const chart1 = echarts.init(document.getElementById('chart1'), 'dark');
        chart1.setOption({
            backgroundColor: 'transparent',
            grid: { top: 30, right: 20, bottom: 25, left: 50 },
            xAxis: {
                type: 'category',
                data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'],
                axisLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 12 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.1)' } },
                axisLabel: { color: '#fff' }
            },
            series: [{
                type: 'bar',
                data: [32000, 28000, 85000, 120000, 98000, 128456],
                itemStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: '#4fc3f7' },
                        { offset: 1, color: '#2196f3' }
                    ]),
                    borderRadius: [4, 4, 0, 0]
                }
            }]
        });

        const chart2 = echarts.init(document.getElementById('chart2'), 'dark');
        chart2.setOption({
            backgroundColor: 'transparent',
            grid: { top: 30, right: 20, bottom: 25, left: 50 },
            xAxis: {
                type: 'category',
                data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                axisLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 12 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.1)' } },
                axisLabel: { color: '#fff', formatter: '¥{value}万' }
            },
            series: [{
                type: 'line',
                data: [1.2, 1.5, 1.8, 2.1, 1.9, 2.5, 2.3],
                smooth: true,
                lineStyle: { color: '#e91e63', width: 3 },
                itemStyle: { color: '#e91e63' },
                areaStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: 'rgba(233, 30, 99, 0.4)' },
                        { offset: 1, color: 'rgba(233, 30, 99, 0.05)' }
                    ])
                }
            }]
        });

        const chart3 = echarts.init(document.getElementById('chart3'), 'dark');
        chart3.setOption({
            backgroundColor: 'transparent',
            series: [{
                type: 'pie',
                radius: ['40%', '70%'],
                center: ['50%', '55%'],
                data: [
                    { value: 35, name: 'CPU', itemStyle: { color: '#4caf50' } },
                    { value: 28, name: '内存', itemStyle: { color: '#2196f3' } },
                    { value: 20, name: '磁盘', itemStyle: { color: '#ff9800' } },
                    { value: 17, name: '网络', itemStyle: { color: '#9c27b0' } }
                ],
                label: { color: '#fff', fontSize: 11 }
            }]
        });

        const chart4 = echarts.init(document.getElementById('chart4'), 'dark');
        chart4.setOption({
            backgroundColor: 'transparent',
            series: [{
                type: 'gauge',
                radius: '80%',
                center: ['50%', '55%'],
                min: 0,
                max: 100,
                splitNumber: 10,
                axisLine: {
                    lineStyle: {
                        width: 10,
                        color: [[0.3, '#4caf50'], [0.7, '#2196f3'], [1, '#f44336']]
                    }
                },
                pointer: { itemStyle: { color: '#4fc3f7' } },
                detail: {
                    formatter: '{value}ms',
                    color: '#4fc3f7',
                    fontSize: 20,
                    offsetCenter: [0, '70%']
                },
                data: [{ value: 23 }]
            }]
        });

        // 图表引用数组用于resize
        const charts = [chart1, chart2, chart3, chart4];

        // 防抖处理 resize
        let timer;
        window.addEventListener('resize', () => {
            clearTimeout(timer);
            timer = setTimeout(() => {
                setScale();
                charts.forEach(chart => chart.resize());
            }, 100);
        });

        // 模拟数据更新
        setInterval(() => {
            const newValue = Math.floor(Math.random() * 20) + 15;
            chart4.setOption({ series: [{ data: [{ value: newValue }] }] });
            document.getElementById('value4').textContent = newValue + 'ms';
        }, 3000);
    </script>
</body>
</html>

使用说明:将以上代码保存为 1-scale-demo.html,直接在浏览器中打开即可体验。调整窗口大小观察等比例缩放效果,注意两侧的黑边。

Demo 2: VW/VH 视口单位方案

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>方案2: VW/VH 方案 - 大屏适配方案对比</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            min-height: 100vh;
            overflow-x: hidden;
        }

        /* 信息面板 */
        .info-panel {
            position: fixed;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.85);
            color: #fff;
            padding: 20px;
            border-radius: 8px;
            z-index: 9999;
            width: 320px;
            font-size: 14px;
        }

        .info-panel h3 {
            color: #81c784;
            margin-bottom: 12px;
            font-size: 16px;
        }

        .info-panel .pros-cons {
            margin-bottom: 15px;
        }

        .info-panel .pros {
            color: #81c784;
        }

        .info-panel .cons {
            color: #e57373;
        }

        .info-panel .formula {
            background: #333;
            padding: 10px;
            border-radius: 4px;
            font-family: 'Courier New', monospace;
            font-size: 12px;
            margin-top: 10px;
            color: #ff9800;
        }

        /* VW/VH 布局 - 直接使用视口单位 */
        .header {
            /* 设计稿 100px / 1080px * 100vh */
            height: 9.259vh;
            background: linear-gradient(90deg, #0f3460 0%, #533483 50%, #0f3460 100%);
            display: flex;
            align-items: center;
            justify-content: center;
            /* 设计稿 48px / 1080px * 100vh */
            font-size: 4.444vh;
            color: #fff;
            text-shadow: 0 0 2vh rgba(79, 195, 247, 0.5);
            letter-spacing: 0.7vw;
        }

        .main-content {
            display: flex;
            /* 设计稿 30px / 1080px * 100vh */
            padding: 2.778vh 1.562vw;
            /* 设计稿 20px / 1080px * 100vh */
            gap: 1.852vh 1.042vw;
            /* 总高度 100vh - header 高度 */
            height: 90.741vh;
        }

        .sidebar {
            /* 设计稿 400px / 1920px * 100vw */
            width: 20.833vw;
            background: rgba(15, 52, 96, 0.3);
            /* 设计稿 16px / 1080px * 100vh */
            border-radius: 1.481vh;
            /* 设计稿 20px / 1080px * 100vh */
            padding: 1.852vh;
            border: 0.093vh solid rgba(79, 195, 247, 0.2);
        }

        .chart-area {
            flex: 1;
            display: grid;
            grid-template-columns: repeat(2, 1fr);
            gap: 1.852vh 1.042vw;
        }

        .card {
            background: rgba(15, 52, 96, 0.3);
            border-radius: 1.481vh;
            padding: 1.852vh;
            border: 0.093vh solid rgba(79, 195, 247, 0.2);
            display: flex;
            flex-direction: column;
        }

        .card-title {
            color: #4fc3f7;
            /* 设计稿 24px / 1080px * 100vh */
            font-size: 2.222vh;
            margin-bottom: 1.389vh;
        }

        .card-value {
            color: #fff;
            /* 设计稿 56px / 1080px * 100vh */
            font-size: 5.185vh;
            font-weight: bold;
        }

        .mini-chart {
            flex: 1;
            min-height: 13.889vh;
            margin-top: 1.5vh;
            border-radius: 0.741vh;
        }

        /* 代码展示区域 */
        .code-panel {
            margin-top: 2vh;
            background: rgba(0, 0, 0, 0.5);
            padding: 1.5vh;
            border-radius: 1vh;
            font-family: 'Courier New', monospace;
            font-size: 1.3vh;
            color: #a5d6a7;
            overflow: hidden;
        }

        .notice {
            position: fixed;
            bottom: 2vh;
            left: 2vw;
            right: 22vw;
            background: rgba(244, 67, 54, 0.2);
            border: 1px solid #f44336;
            color: #f44336;
            padding: 1.5vh 2vw;
            border-radius: 0.8vh;
            font-size: 1.6vh;
        }

        /* 问题演示:文字溢出 */
        .overflow-demo {
            background: rgba(244, 67, 54, 0.1);
            border: 1px dashed #f44336;
            padding: 1vh;
            margin-top: 1vh;
            font-size: 1.5vh;
        }

        /* 使用 CSS 变量简化计算 */
        :root {
            --vh: 1vh;
            --vw: 1vw;
        }

        /* 但这不是完美的解决方案 */
        .sidebar p {
            color: rgba(255,255,255,0.7);
            font-size: 1.667vh;
            line-height: 1.8;
        }
    </style>
</head>
<body>
    <!-- 信息面板 -->
    <div class="info-panel">
        <h3>方案2: VW/VH 方案</h3>
        <div class="pros-cons">
            <div class="pros">✓ 优点:</div>
            • 无 JS 依赖<br>
            • 利用全部视口空间<br>
            • 无黑边/留白<br><br>
            <div class="cons">✗ 缺点:</div>
            • 计算公式复杂<br>
            • 单位换算容易出错<br>
            • 字体可能过大/过小<br>
            • 宽高比例难以协调
        </div>
        <div class="formula">
            计算公式:<br>
            100px / 1920px * 100vw<br>
            = 5.208vw
        </div>
    </div>

    <!-- 页面内容 -->
    <div class="header">VW/VH 方案演示 - 满屏无黑边</div>

    <div class="main-content">
        <div class="sidebar">
            <div class="card-title">左侧信息面板</div>
            <p>
                此方案使用 vw/vh 单位代替 px。<br><br>
                虽然页面始终铺满屏幕,但计算复杂。注意右侧卡牌区域在小屏幕上文字可能显得过大。
            </p>

            <div class="code-panel">
                /* 实际开发中的混乱 */<br>
                width: 20.833vw;<br>
                height: 13.889vh;<br>
                font-size: 4.444vh;<br>
                padding: 1.852vh 1.562vw;<br>
                /* 这些数字是怎么来的? */
            </div>

            <div class="overflow-demo">
                <strong>⚠️ 问题演示:</strong><br>
                当屏幕很宽但很矮时,文字按 vh 计算变得极小,而容器按 vw 计算保持宽大,导致内容稀疏。
            </div>
        </div>

        <div class="chart-area">
            <div class="card">
                <div class="card-title">实时用户数</div>
                <div class="card-value">128,456</div>
                <div class="mini-chart" id="chart1"></div>
            </div>
            <div class="card">
                <div class="card-title">交易金额</div>
                <div class="card-value">¥2.3M</div>
                <div class="mini-chart" id="chart2"></div>
            </div>
            <div class="card">
                <div class="card-title">系统负载</div>
                <div class="card-value">68%</div>
                <div class="mini-chart" id="chart3"></div>
            </div>
            <div class="card">
                <div class="card-title">响应时间</div>
                <div class="card-value">23ms</div>
                <div class="mini-chart" id="chart4"></div>
            </div>
        </div>
    </div>

    <div class="notice">
        ⚠️ 缺点演示:调整浏览器窗口为超宽矮屏(如 2560×600),观察字体与容器比例的失调。对比 Scale 方案在这个场景的表现。
    </div>

    <script>
        // ===== ECharts 图表配置 =====
        // 图表1: 柱状图 - 实时用户数
        const chart1 = echarts.init(document.getElementById('chart1'), 'dark');
        const option1 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 45 },
            xAxis: {
                type: 'category',
                data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'],
                axisLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            series: [{
                type: 'bar',
                data: [32000, 28000, 85000, 120000, 98000, 128456],
                itemStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: '#4fc3f7' },
                        { offset: 1, color: '#2196f3' }
                    ]),
                    borderRadius: [4, 4, 0, 0]
                },
                animationDuration: 1500
            }]
        };
        chart1.setOption(option1);

        // 图表2: 折线图 - 交易金额趋势
        const chart2 = echarts.init(document.getElementById('chart2'), 'dark');
        const option2 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 50 },
            xAxis: {
                type: 'category',
                data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                axisLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10, formatter: '¥{value}' }
            },
            series: [{
                type: 'line',
                data: [1.2, 1.5, 1.8, 2.1, 1.9, 2.5, 2.3],
                smooth: true,
                symbol: 'circle',
                symbolSize: 6,
                lineStyle: { color: '#e91e63', width: 3 },
                itemStyle: { color: '#e91e63' },
                areaStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: 'rgba(233, 30, 99, 0.4)' },
                        { offset: 1, color: 'rgba(233, 30, 99, 0.05)' }
                    ])
                },
                animationDuration: 1500
            }]
        };
        chart2.setOption(option2);

        // 图表3: 饼图 - 系统负载分布
        const chart3 = echarts.init(document.getElementById('chart3'), 'dark');
        const option3 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'pie',
                radius: ['35%', '65%'],
                center: ['50%', '55%'],
                data: [
                    { value: 35, name: 'CPU', itemStyle: { color: '#4caf50' } },
                    { value: 28, name: '内存', itemStyle: { color: '#2196f3' } },
                    { value: 20, name: '磁盘', itemStyle: { color: '#ff9800' } },
                    { value: 17, name: '网络', itemStyle: { color: '#9c27b0' } }
                ],
                label: { color: '#fff', fontSize: 10 },
                labelLine: { lineStyle: { color: 'rgba(255,255,255,0.5)' } },
                animationDuration: 1500
            }]
        };
        chart3.setOption(option3);

        // 图表4: 仪表盘 - 响应时间
        const chart4 = echarts.init(document.getElementById('chart4'), 'dark');
        const option4 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'gauge',
                radius: '75%',
                center: ['50%', '55%'],
                min: 0,
                max: 100,
                splitNumber: 10,
                axisLine: {
                    lineStyle: {
                        width: 8,
                        color: [[0.3, '#4caf50'], [0.7, '#2196f3'], [1, '#f44336']]
                    }
                },
                pointer: { itemStyle: { color: '#4fc3f7' }, width: 4 },
                axisTick: { distance: -8, length: 4, lineStyle: { color: '#fff' } },
                splitLine: { distance: -8, length: 10, lineStyle: { color: '#fff' } },
                axisLabel: { color: '#fff', distance: -20, fontSize: 9 },
                detail: {
                    valueAnimation: true,
                    formatter: '{value}ms',
                    color: '#4fc3f7',
                    fontSize: 16,
                    offsetCenter: [0, '65%']
                },
                data: [{ value: 23 }],
                animationDuration: 2000
            }]
        };
        chart4.setOption(option4);

        // 响应式调整
        const charts = [chart1, chart2, chart3, chart4];
        window.addEventListener('resize', () => {
            charts.forEach(chart => chart.resize());
        });

        // 模拟数据更新
        setInterval(() => {
            const newValue = Math.floor(Math.random() * 20) + 15;
            chart4.setOption({ series: [{ data: [{ value: newValue }] }] });
        }, 3000);
    </script>
</body>
</html>

Demo 3: Rem 动态计算方案

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>方案3: Rem 方案 - 大屏适配方案对比</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <script>
        // Rem 计算逻辑
        (function() {
            const designWidth = 1920;

            function setRem() {
                const winWidth = window.innerWidth;
                // 以设计稿宽度为基准,100rem = 设计稿宽度
                const rem = winWidth / designWidth * 100;
                document.documentElement.style.fontSize = rem + 'px';
            }

            setRem();

            let timer;
            window.addEventListener('resize', function() {
                clearTimeout(timer);
                timer = setTimeout(setRem, 100);
            });
        })();
    </script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            min-height: 100vh;
            overflow-x: hidden;
        }

        /* 信息面板 */
        .info-panel {
            position: fixed;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.85);
            color: #fff;
            padding: 0.2rem;
            border-radius: 0.08rem;
            z-index: 9999;
            width: 3.3rem;
            font-size: 0.073rem;
        }

        .info-panel h3 {
            color: #4fc3f7;
            margin-bottom: 0.062rem;
            font-size: 0.083rem;
        }

        .info-panel .pros-cons {
            margin-bottom: 0.078rem;
        }

        .info-panel .pros {
            color: #81c784;
        }

        .info-panel .cons {
            color: #e57373;
        }

        .info-panel .current-rem {
            background: #4caf50;
            color: #000;
            padding: 0.05rem;
            border-radius: 0.04rem;
            font-weight: bold;
            margin-top: 0.05rem;
        }

        /* Rem 布局 - 1rem = 设计稿中 100px (在 1920px 宽度下) */
        .header {
            /* 设计稿 100px = 1rem */
            height: 1rem;
            background: linear-gradient(90deg, #0f3460 0%, #533483 50%, #0f3460 100%);
            display: flex;
            align-items: center;
            justify-content: center;
            /* 设计稿 48px = 0.48rem */
            font-size: 0.48rem;
            color: #fff;
            text-shadow: 0 0 0.1rem rgba(79, 195, 247, 0.5);
            letter-spacing: 0.08rem;
        }

        .main-content {
            display: flex;
            /* 设计稿 30px = 0.3rem */
            padding: 0.3rem;
            /* 设计稿 20px = 0.2rem */
            gap: 0.2rem;
            /* 总高度 100vh - header */
            min-height: calc(100vh - 1rem);
        }

        .sidebar {
            /* 设计稿 400px = 4rem */
            width: 4rem;
            background: rgba(15, 52, 96, 0.3);
            /* 设计稿 16px = 0.16rem */
            border-radius: 0.16rem;
            padding: 0.2rem;
            border: 0.01rem solid rgba(79, 195, 247, 0.2);
        }

        .sidebar p {
            color: rgba(255,255,255,0.7);
            font-size: 0.18rem;
            line-height: 1.8;
        }

        .chart-area {
            flex: 1;
            display: grid;
            grid-template-columns: repeat(2, 1fr);
            gap: 0.2rem;
        }

        .card {
            background: rgba(15, 52, 96, 0.3);
            border-radius: 0.16rem;
            padding: 0.2rem;
            border: 0.01rem solid rgba(79, 195, 247, 0.2);
            display: flex;
            flex-direction: column;
        }

        .card-title {
            color: #4fc3f7;
            font-size: 0.22rem;
            margin-bottom: 0.12rem;
        }

        .card-value {
            color: #fff;
            font-size: 0.56rem;
            font-weight: bold;
        }

        .mini-chart {
            flex: 1;
            min-height: 1.5rem;
            margin-top: 0.15rem;
            border-radius: 0.08rem;
        }

        /* 代码展示 */
        .code-panel {
            margin-top: 0.25rem;
            background: rgba(0, 0, 0, 0.5);
            padding: 0.15rem;
            border-radius: 0.1rem;
            font-family: 'Courier New', monospace;
            font-size: 0.13rem;
            color: #a5d6a7;
        }

        /* 闪烁问题演示 */
        .flash-demo {
            margin-top: 0.2rem;
            padding: 0.15rem;
            background: rgba(255, 152, 0, 0.1);
            border: 1px dashed #ff9800;
            color: #ff9800;
            font-size: 0.15rem;
        }

        .fouc-warning {
            background: rgba(244, 67, 54, 0.2);
            border: 1px solid #f44336;
            color: #f44336;
            padding: 0.15rem;
            margin-top: 0.2rem;
            border-radius: 0.08rem;
            font-size: 0.15rem;
        }

        .notice {
            position: fixed;
            bottom: 0.2rem;
            left: 0.3rem;
            right: 3.6rem;
            background: rgba(255, 152, 0, 0.2);
            border: 1px solid #ff9800;
            color: #ff9800;
            padding: 0.15rem 0.2rem;
            border-radius: 0.08rem;
            font-size: 0.16rem;
        }

        /* FOUC 模拟 - 页面加载时的闪烁 */
        .no-js-fallback {
            display: none;
        }
    </style>
</head>
<body>
    <!-- 信息面板 -->
    <div class="info-panel">
        <h3>方案3: Rem 方案</h3>
        <div class="pros-cons">
            <div class="pros">✓ 优点:</div>
            • 计算相对直观 (设计稿/100)<br>
            • 兼容性好 (支持 IE)<br>
            • 宽高等比缩放<br><br>
            <div class="cons">✗ 缺点:</div>
            • 依赖 JS 设置根字体<br>
            • 页面加载可能闪烁<br>
            • 高度仍需要特殊处理<br>
            • 设计稿转换工作量大
        </div>
        <div class="current-rem" id="remInfo">
            根字体: 100px<br>
            (1920px 宽度下)
        </div>
    </div>

    <!-- 页面内容 -->
    <div class="header">REM 方案演示 - JS 动态计算</div>

    <div class="main-content">
        <div class="sidebar">
            <div class="card-title">左侧信息面板</div>
            <p>
                1rem = 设计稿的 100px<br><br>
                在 1920px 宽度的屏幕上,根字体大小为 100px,便于计算转换。
            </p>

            <div class="code-panel">
                // JS 计算根字体 (head 中)<br>
                const rem = winWidth / 1920 * 100;<br>
                html.style.fontSize = rem + 'px';<br><br>
                // CSS 使用<br>
                width: 4rem;  /* = 400px */
            </div>

            <div class="flash-demo">
                <strong>⚠️ 闪烁问题 (FOUC):</strong><br>
                如果 JS 在 head 末尾执行,页面会先按默认 16px 渲染,然后跳动到计算值。
            </div>

            <div class="fouc-warning">
                💡 解决方案:将 rem 计算脚本放在 &lt;head&gt; 最前面,或使用内联 style。
            </div>
        </div>

        <div class="chart-area">
            <div class="card">
                <div class="card-title">实时用户数</div>
                <div class="card-value">128,456</div>
                <div class="mini-chart" id="chart1"></div>
            </div>
            <div class="card">
                <div class="card-title">交易金额</div>
                <div class="card-value">¥2.3M</div>
                <div class="mini-chart" id="chart2"></div>
            </div>
            <div class="card">
                <div class="card-title">系统负载</div>
                <div class="card-value">68%</div>
                <div class="mini-chart" id="chart3"></div>
            </div>
            <div class="card">
                <div class="card-title">响应时间</div>
                <div class="card-value">23ms</div>
                <div class="mini-chart" id="chart4"></div>
            </div>
        </div>
    </div>

    <div class="notice">
        💡 修改窗口大小观察变化。注意:此方案主要处理宽度适配,高度方向元素可能会超出屏幕(尝试将窗口压得很矮)。
    </div>

    <script>
        // 更新 rem 信息
        function updateRemInfo() {
            const rem = parseFloat(getComputedStyle(document.documentElement).fontSize);
            document.getElementById('remInfo').innerHTML = `
                根字体: ${rem.toFixed(2)}px<br>
                窗口宽度: ${window.innerWidth}px<br>
                1rem = 设计稿的 100px
            `;
        }

        updateRemInfo();

        let timer;
        window.addEventListener('resize', function() {
            clearTimeout(timer);
            timer = setTimeout(updateRemInfo, 100);
        });

        // ===== ECharts 图表配置 =====
        // 图表1: 柱状图 - 实时用户数
        const chart1 = echarts.init(document.getElementById('chart1'), 'dark');
        const option1 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 45 },
            xAxis: {
                type: 'category',
                data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'],
                axisLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            series: [{
                type: 'bar',
                data: [32000, 28000, 85000, 120000, 98000, 128456],
                itemStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: '#4fc3f7' },
                        { offset: 1, color: '#2196f3' }
                    ]),
                    borderRadius: [4, 4, 0, 0]
                },
                animationDuration: 1500
            }]
        };
        chart1.setOption(option1);

        // 图表2: 折线图 - 交易金额趋势
        const chart2 = echarts.init(document.getElementById('chart2'), 'dark');
        const option2 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 50 },
            xAxis: {
                type: 'category',
                data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                axisLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10, formatter: '¥{value}' }
            },
            series: [{
                type: 'line',
                data: [1.2, 1.5, 1.8, 2.1, 1.9, 2.5, 2.3],
                smooth: true,
                symbol: 'circle',
                symbolSize: 6,
                lineStyle: { color: '#e91e63', width: 3 },
                itemStyle: { color: '#e91e63' },
                areaStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: 'rgba(233, 30, 99, 0.4)' },
                        { offset: 1, color: 'rgba(233, 30, 99, 0.05)' }
                    ])
                },
                animationDuration: 1500
            }]
        };
        chart2.setOption(option2);

        // 图表3: 饼图 - 系统负载分布
        const chart3 = echarts.init(document.getElementById('chart3'), 'dark');
        const option3 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'pie',
                radius: ['35%', '65%'],
                center: ['50%', '55%'],
                data: [
                    { value: 35, name: 'CPU', itemStyle: { color: '#4caf50' } },
                    { value: 28, name: '内存', itemStyle: { color: '#2196f3' } },
                    { value: 20, name: '磁盘', itemStyle: { color: '#ff9800' } },
                    { value: 17, name: '网络', itemStyle: { color: '#9c27b0' } }
                ],
                label: { color: '#fff', fontSize: 10 },
                labelLine: { lineStyle: { color: 'rgba(255,255,255,0.5)' } },
                animationDuration: 1500
            }]
        };
        chart3.setOption(option3);

        // 图表4: 仪表盘 - 响应时间
        const chart4 = echarts.init(document.getElementById('chart4'), 'dark');
        const option4 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'gauge',
                radius: '75%',
                center: ['50%', '55%'],
                min: 0,
                max: 100,
                splitNumber: 10,
                axisLine: {
                    lineStyle: {
                        width: 8,
                        color: [[0.3, '#4caf50'], [0.7, '#2196f3'], [1, '#f44336']]
                    }
                },
                pointer: { itemStyle: { color: '#4fc3f7' }, width: 4 },
                axisTick: { distance: -8, length: 4, lineStyle: { color: '#fff' } },
                splitLine: { distance: -8, length: 10, lineStyle: { color: '#fff' } },
                axisLabel: { color: '#fff', distance: -20, fontSize: 9 },
                detail: {
                    valueAnimation: true,
                    formatter: '{value}ms',
                    color: '#4fc3f7',
                    fontSize: 16,
                    offsetCenter: [0, '65%']
                },
                data: [{ value: 23 }],
                animationDuration: 2000
            }]
        };
        chart4.setOption(option4);

        // 响应式调整
        const charts = [chart1, chart2, chart3, chart4];
        window.addEventListener('resize', () => {
            charts.forEach(chart => chart.resize());
        });

        // 模拟数据更新
        setInterval(() => {
            const newValue = Math.floor(Math.random() * 20) + 15;
            chart4.setOption({ series: [{ data: [{ value: newValue }] }] });
        }, 3000);
    </script>
</body>
</html>

Demo 4: 流式/弹性布局方案

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>方案4: 流式布局 - 大屏适配方案对比</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            min-height: 100vh;
            overflow-x: hidden;
        }

        /* 信息面板 - 使用 px 保持固定大小参考 */
        .info-panel {
            position: fixed;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.85);
            color: #fff;
            padding: 20px;
            border-radius: 8px;
            z-index: 9999;
            width: 320px;
            font-size: 14px;
        }

        .info-panel h3 {
            color: #ff9800;
            margin-bottom: 12px;
            font-size: 16px;
        }

        .info-panel .pros-cons {
            margin-bottom: 15px;
        }

        .info-panel .pros {
            color: #81c784;
        }

        .info-panel .cons {
            color: #e57373;
        }

        /* 流式布局 - 使用 % fr auto 等弹性单位 */
        .header {
            height: 10%; /* 百分比高度 */
            min-height: 60px;
            max-height: 120px;
            background: linear-gradient(90deg, #0f3460 0%, #533483 50%, #0f3460 100%);
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: clamp(24px, 4vw, 48px); /* 流体字体 */
            color: #fff;
            text-shadow: 0 0 20px rgba(79, 195, 247, 0.5);
            letter-spacing: 0.5vw;
            padding: 0 5%;
        }

        .main-content {
            display: flex;
            padding: 3%;
            gap: 2%;
            min-height: calc(90% - 60px);
        }

        .sidebar {
            width: 25%; /* 百分比宽度 */
            min-width: 200px;
            max-width: 400px;
            background: rgba(15, 52, 96, 0.3);
            border-radius: 16px;
            padding: 20px;
            border: 1px solid rgba(79, 195, 247, 0.2);
        }

        .sidebar p {
            color: rgba(255,255,255,0.7);
            font-size: clamp(14px, 1.5vw, 18px);
            line-height: 1.8;
        }

        /* CSS Grid 布局 */
        .chart-area {
            flex: 1;
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
            gap: 20px;
        }

        .card {
            background: rgba(15, 52, 96, 0.3);
            border-radius: 16px;
            padding: 20px;
            border: 1px solid rgba(79, 195, 247, 0.2);
            display: flex;
            flex-direction: column;
            min-height: 150px;
        }

        .card-title {
            color: #4fc3f7;
            font-size: clamp(16px, 2vw, 24px);
            margin-bottom: 10px;
        }

        .card-value {
            color: #fff;
            font-size: clamp(28px, 4vw, 56px);
            font-weight: bold;
            white-space: nowrap;
        }

        .mini-chart {
            flex: 1;
            min-height: 100px;
            margin-top: 10px;
            border-radius: 8px;
        }

        /* 问题展示:数据大屏布局崩坏 */
        .problem-demo {
            margin-top: 20px;
            padding: 15px;
            background: rgba(244, 67, 54, 0.1);
            border: 1px dashed #f44336;
            border-radius: 8px;
        }

        .problem-demo h4 {
            color: #f44336;
            margin-bottom: 10px;
        }

        .problem-demo p {
            color: #f44336;
            font-size: 14px;
        }

        /* 视觉偏差对比: */
        .comparison-box {
            display: flex;
            gap: 20px;
            margin-top: 15px;
        }

        .fixed-ratio {
            width: 100px;
            height: 60px;
            background: #4fc3f7;
            display: flex;
            align-items: center;
            justify-content: center;
            color: #000;
            font-size: 12px;
        }

        .fluid-shape {
            width: 20%;
            padding-bottom: 12%;
            background: #ff9800;
            position: relative;
        }

        .fluid-shape span {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            color: #000;
            font-size: 12px;
            white-space: nowrap;
        }

        .notice {
            position: fixed;
            bottom: 20px;
            left: 20px;
            right: 360px;
            background: rgba(244, 67, 54, 0.2);
            border: 1px solid #f44336;
            color: #f44336;
            padding: 15px 20px;
            border-radius: 8px;
            font-size: 14px;
        }

        .label-tag {
            display: inline-block;
            background: #4caf50;
            color: #fff;
            padding: 2px 8px;
            border-radius: 4px;
            font-size: 12px;
            margin-right: 5px;
        }

        .label-tag.warning {
            background: #ff9800;
        }
    </style>
</head>
<body>
    <!-- 信息面板 -->
    <div class="info-panel">
        <h3>方案4: 流式/弹性布局</h3>
        <div class="pros-cons">
            <div class="pros">✓ 优点:</div>
            • 纯 CSS,无依赖<br>
            • 自然响应式<br>
            • 内容自适应<br><br>
            <div class="cons">✗ 缺点:</div><strong>大屏空间利用率低</strong><br>
            • 图表比例难以控制<br>
            • 无法精确还原设计稿<br>
            • 文字/图形可能变形
        </div>
        <div style="margin-top: 10px; font-size: 12px; color: #999;">
            适合:后台管理系统<br>
            不适合:数据可视化大屏
        </div>
    </div>

    <!-- 页面内容 -->
    <div class="header">流式布局演示 - 自然伸缩</div>

    <div class="main-content">
        <div class="sidebar">
            <div style="color: #ff9800; font-size: 18px; margin-bottom: 15px;">左侧信息面板</div>
            <p>
                <span class="label-tag">%</span> 百分比宽度<br>
                <span class="label-tag">fr</span> Grid 弹性分配<br>
                <span class="label-tag warning">clamp</span> 流体字体<br><br>

                这个方案使用 CSS 的固有响应式能力。但请注意右侧卡片在宽屏上的变化——它们会无限拉宽!
            </p>

            <div class="problem-demo">
                <h4>⚠️ 大屏场景的问题</h4>
                <p>
                    <strong>问题1:</strong> 宽屏下卡片过度拉伸<br>
                    <strong>问题2:</strong> 图表比例失控<br>
                    <strong>问题3:</strong> 无法精确对齐设计稿像素
                </p>

                <div class="comparison-box">
                    <div class="fixed-ratio">固定比例</div>
                    <div class="fluid-shape"><span>20%宽度</span></div>
                </div>
                <p style="margin-top: 10px; font-size: 12px;">
                    调整窗口宽度,观察流体元素的宽高比例变化。
                </p>
            </div>
        </div>

        <div class="chart-area">
            <div class="card">
                <div class="card-title">实时用户数</div>
                <div class="card-value">128,456</div>
                <div class="mini-chart" id="chart1"></div>
            </div>
            <div class="card">
                <div class="card-title">交易金额</div>
                <div class="card-value">¥2.3M</div>
                <div class="mini-chart" id="chart2"></div>
            </div>
            <div class="card">
                <div class="card-title">系统负载</div>
                <div class="card-value">68%</div>
                <div class="mini-chart" id="chart3"></div>
            </div>
            <div class="card">
                <div class="card-title">响应时间</div>
                <div class="card-value">23ms</div>
                <div class="mini-chart" id="chart4"></div>
            </div>
        </div>
    </div>

    <div class="notice">
        ❌ 调整窗口到超宽屏(≥2560px),观察右侧卡片的变形:宽高比例完全失控,图表变成"矮胖"形状。对比 Scale 方案在这个场景的表现。
    </div>

    <script>
        // ===== ECharts 图表配置 =====
        // 图表1: 柱状图 - 实时用户数
        const chart1 = echarts.init(document.getElementById('chart1'), 'dark');
        const option1 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 45 },
            xAxis: {
                type: 'category',
                data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'],
                axisLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            series: [{
                type: 'bar',
                data: [32000, 28000, 85000, 120000, 98000, 128456],
                itemStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: '#4fc3f7' },
                        { offset: 1, color: '#2196f3' }
                    ]),
                    borderRadius: [4, 4, 0, 0]
                },
                animationDuration: 1500
            }]
        };
        chart1.setOption(option1);

        // 图表2: 折线图 - 交易金额趋势
        const chart2 = echarts.init(document.getElementById('chart2'), 'dark');
        const option2 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 50 },
            xAxis: {
                type: 'category',
                data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                axisLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10, formatter: '¥{value}' }
            },
            series: [{
                type: 'line',
                data: [1.2, 1.5, 1.8, 2.1, 1.9, 2.5, 2.3],
                smooth: true,
                symbol: 'circle',
                symbolSize: 6,
                lineStyle: { color: '#e91e63', width: 3 },
                itemStyle: { color: '#e91e63' },
                areaStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: 'rgba(233, 30, 99, 0.4)' },
                        { offset: 1, color: 'rgba(233, 30, 99, 0.05)' }
                    ])
                },
                animationDuration: 1500
            }]
        };
        chart2.setOption(option2);

        // 图表3: 饼图 - 系统负载分布
        const chart3 = echarts.init(document.getElementById('chart3'), 'dark');
        const option3 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'pie',
                radius: ['35%', '65%'],
                center: ['50%', '55%'],
                data: [
                    { value: 35, name: 'CPU', itemStyle: { color: '#4caf50' } },
                    { value: 28, name: '内存', itemStyle: { color: '#2196f3' } },
                    { value: 20, name: '磁盘', itemStyle: { color: '#ff9800' } },
                    { value: 17, name: '网络', itemStyle: { color: '#9c27b0' } }
                ],
                label: { color: '#fff', fontSize: 10 },
                labelLine: { lineStyle: { color: 'rgba(255,255,255,0.5)' } },
                animationDuration: 1500
            }]
        };
        chart3.setOption(option3);

        // 图表4: 仪表盘 - 响应时间
        const chart4 = echarts.init(document.getElementById('chart4'), 'dark');
        const option4 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'gauge',
                radius: '75%',
                center: ['50%', '55%'],
                min: 0,
                max: 100,
                splitNumber: 10,
                axisLine: {
                    lineStyle: {
                        width: 8,
                        color: [[0.3, '#4caf50'], [0.7, '#2196f3'], [1, '#f44336']]
                    }
                },
                pointer: { itemStyle: { color: '#4fc3f7' }, width: 4 },
                axisTick: { distance: -8, length: 4, lineStyle: { color: '#fff' } },
                splitLine: { distance: -8, length: 10, lineStyle: { color: '#fff' } },
                axisLabel: { color: '#fff', distance: -20, fontSize: 9 },
                detail: {
                    valueAnimation: true,
                    formatter: '{value}ms',
                    color: '#4fc3f7',
                    fontSize: 16,
                    offsetCenter: [0, '65%']
                },
                data: [{ value: 23 }],
                animationDuration: 2000
            }]
        };
        chart4.setOption(option4);

        // 响应式调整
        const charts = [chart1, chart2, chart3, chart4];
        window.addEventListener('resize', () => {
            charts.forEach(chart => chart.resize());
        });

        // 模拟数据更新
        setInterval(() => {
            const newValue = Math.floor(Math.random() * 20) + 15;
            chart4.setOption({ series: [{ data: [{ value: newValue }] }] });
        }, 3000);
    </script>
</body>
</html>

Demo 5: 响应式断点方案

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>方案5: 响应式断点 - 大屏适配方案对比</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            min-height: 100vh;
            overflow-x: hidden;
        }

        /* 信息面板 */
        .info-panel {
            position: fixed;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.85);
            color: #fff;
            padding: 20px;
            border-radius: 8px;
            z-index: 9999;
            width: 320px;
            font-size: 14px;
            max-height: 90vh;
            overflow-y: auto;
        }

        .info-panel h3 {
            color: #e91e63;
            margin-bottom: 12px;
            font-size: 16px;
        }

        .info-panel .pros-cons {
            margin-bottom: 15px;
        }

        .info-panel .pros {
            color: #81c784;
        }

        .info-panel .cons {
            color: #e57373;
        }

        .current-breakpoint {
            background: #e91e63;
            color: #fff;
            padding: 10px;
            border-radius: 4px;
            font-weight: bold;
            margin-top: 10px;
            font-size: 16px;
        }

        .breakpoint-list {
            margin-top: 10px;
            font-size: 12px;
        }

        .breakpoint-list div {
            padding: 4px 0;
        }

        .breakpoint-list .active {
            color: #e91e63;
            font-weight: bold;
        }

        /* 断点样式定义 */

        /* 默认/移动端: < 768px */
        .header {
            height: 50px;
            background: linear-gradient(90deg, #0f3460 0%, #533483 50%, #0f3460 100%);
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 16px;
            color: #fff;
            text-shadow: 0 0 10px rgba(79, 195, 247, 0.5);
            letter-spacing: 2px;
        }

        .main-content {
            display: flex;
            flex-direction: column;
            padding: 10px;
            gap: 15px;
        }

        .sidebar {
            width: 100%;
            background: rgba(15, 52, 96, 0.3);
            border-radius: 8px;
            padding: 15px;
            border: 1px solid rgba(79, 195, 247, 0.2);
        }

        .sidebar p {
            color: rgba(255,255,255,0.7);
            font-size: 14px;
            line-height: 1.6;
        }

        .chart-area {
            display: grid;
            grid-template-columns: 1fr;
            gap: 15px;
        }

        .card {
            background: rgba(15, 52, 96, 0.3);
            border-radius: 8px;
            padding: 15px;
            border: 1px solid rgba(79, 195, 247, 0.2);
        }

        .card-title {
            color: #4fc3f7;
            font-size: 16px;
            margin-bottom: 8px;
        }

        .card-value {
            color: #fff;
            font-size: 28px;
            font-weight: bold;
        }

        .mini-chart {
            height: 100px;
            margin-top: 10px;
            border-radius: 4px;
        }

        /* 平板: 768px - 1200px */
        @media (min-width: 768px) {
            .header {
                height: 70px;
                font-size: 24px;
                letter-spacing: 4px;
            }

            .main-content {
                flex-direction: row;
                padding: 20px;
            }

            .sidebar {
                width: 280px;
                padding: 20px;
            }

            .chart-area {
                grid-template-columns: repeat(2, 1fr);
                flex: 1;
            }

            .card-title {
                font-size: 18px;
            }

            .card-value {
                font-size: 32px;
            }

            .mini-chart {
                height: 100px;
            }
        }

        /* 代码体积问题展示 */
        @media (min-width: 1200px) {
            .header {
                height: 80px;
                font-size: 32px;
                letter-spacing: 6px;
            }

            .main-content {
                padding: 25px;
                gap: 20px;
            }

            .sidebar {
                width: 350px;
            }

            .card-title {
                font-size: 20px;
                margin-bottom: 10px;
            }

            .card-value {
                font-size: 40px;
            }

            .mini-chart {
                height: 120px;
                margin-top: 15px;
            }
        }

        /* 大屏幕: 1920px - 2560px */
        @media (min-width: 1920px) {
            .header {
                height: 100px;
                font-size: 40px;
                letter-spacing: 8px;
            }

            .main-content {
                padding: 30px;
            }

            .sidebar {
                width: 400px;
            }

            .sidebar p {
                font-size: 16px;
            }

            .card-value {
                font-size: 48px;
            }

            .mini-chart {
                height: 150px;
            }
        }

        /* 超大屏幕: > 2560px */
        @media (min-width: 2560px) {
            .header {
                height: 120px;
                font-size: 48px;
            }

            .chart-area {
                grid-template-columns: repeat(4, 1fr);
            }

            .card-value {
                font-size: 56px;
            }
        }

        /* 代码体积问题展示 */
        .code-stats {
            margin-top: 15px;
            padding: 10px;
            background: rgba(244, 67, 54, 0.1);
            border: 1px dashed #f44336;
            border-radius: 4px;
        }

        .code-stats h4 {
            color: #f44336;
            margin-bottom: 5px;
        }

        .code-stats .stat {
            display: flex;
            justify-content: space-between;
            font-size: 12px;
            padding: 3px 0;
        }

        .notice {
            position: fixed;
            bottom: 20px;
            left: 20px;
            right: 360px;
            background: rgba(233, 30, 99, 0.2);
            border: 1px solid #e91e63;
            color: #e91e63;
            padding: 15px 20px;
            border-radius: 8px;
            font-size: 14px;
        }
    </style>
</head>
<body>
    <!-- 信息面板 -->
    <div class="info-panel">
        <h3>方案5: 响应式断点</h3>
        <div class="pros-cons">
            <div class="pros">✓ 优点:</div>
            • 精确控制各分辨率<br>
            • 字体可读性可控<br>
            • 适合固定规格大屏<br><br>
            <div class="cons">✗ 缺点:</div><strong>代码量爆炸</strong><br>
            • 维护困难<br>
            • 无法覆盖所有尺寸<br>
            • CSS 文件体积大
        </div>

        <div class="current-breakpoint" id="currentBP">
            当前断点: 默认/移动端
        </div>

        <div class="breakpoint-list">
            <div data-bp="0">&lt; 768px: 移动端</div>
            <div data-bp="1">768px - 1200px: 平板</div>
            <div data-bp="2">1200px - 1920px: 桌面</div>
            <div data-bp="3">1920px - 2560px: 大屏</div>
            <div data-bp="4">&gt; 2560px: 超大屏</div>
        </div>

        <div class="code-stats">
            <h4>⚠️ 维护成本</h4>
            <div class="stat">
                <span>每个组件</span>
                <span>×5 套样式</span>
            </div>
            <div class="stat">
                <span>10个组件</span>
                <span>= 50套样式</span>
            </div>
            <div class="stat">
                <span>新增分辨率</span>
                <span>全文件修改</span>
            </div>
        </div>
    </div>

    <!-- 页面内容 -->
    <div class="header">响应式断点演示 - @media 查询</div>

    <div class="main-content">
        <div class="sidebar">
            <div style="color: #e91e63; font-size: 20px; margin-bottom: 15px;">左侧信息面板</div>
            <p>
                调整窗口宽度,观察布局在不同断点的变化。<br><br>

                <strong>可能遇到的问题:</strong><br>
                • 1366px 和 1440px 怎么办?<br>
                • 3840px(4K)应该如何显示?<br>
                • 修改一个组件要改 5 处?
            </p>
        </div>

        <div class="chart-area">
            <div class="card">
                <div class="card-title">实时用户数</div>
                <div class="card-value">128,456</div>
                <div class="mini-chart" id="chart1"></div>
            </div>
            <div class="card">
                <div class="card-title">交易金额</div>
                <div class="card-value">¥2.3M</div>
                <div class="mini-chart" id="chart2"></div>
            </div>
            <div class="card">
                <div class="card-title">系统负载</div>
                <div class="card-value">68%</div>
                <div class="mini-chart" id="chart3"></div>
            </div>
            <div class="card">
                <div class="card-title">响应时间</div>
                <div class="card-value">23ms</div>
                <div class="mini-chart" id="chart4"></div>
            </div>
        </div>
    </div>

    <div class="notice">
        🔧 拖动窗口宽度观察布局变化。注意:即使 "桌面" 断点(1920px)也不是精确还原设计稿,只是 "看起来差不多"。
    </div>

    <script>
        // 检测当前断点
        const breakpoints = [
            { name: '默认/移动端', max: 768 },
            { name: '平板', max: 1200 },
            { name: '桌面', max: 1920 },
            { name: '大屏', max: 2560 },
            { name: '超大屏', max: Infinity }
        ];

        function detectBreakpoint() {
            const width = window.innerWidth;
            let activeIndex = 0;

            for (let i = 0; i < breakpoints.length; i++) {
                if (width < breakpoints[i].max) {
                    activeIndex = i;
                    break;
                }
                activeIndex = i;
            }

            document.getElementById('currentBP').textContent =
                `当前断点: ${breakpoints[activeIndex].name} (${width}px)`;

            // 高亮断点列表
            document.querySelectorAll('.breakpoint-list div').forEach((div, index) => {
                div.classList.toggle('active', index === activeIndex);
            });
        }

        detectBreakpoint();

        let timer;
        window.addEventListener('resize', () => {
            clearTimeout(timer);
            timer = setTimeout(detectBreakpoint, 100);
        });

        // ===== ECharts 图表配置 =====
        // 图表1: 柱状图 - 实时用户数
        const chart1 = echarts.init(document.getElementById('chart1'), 'dark');
        const option1 = {
            backgroundColor: 'transparent',
            grid: { top: 25, right: 10, bottom: 20, left: 40 },
            xAxis: {
                type: 'category',
                data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'],
                axisLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 9 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 9 }
            },
            series: [{
                type: 'bar',
                data: [32000, 28000, 85000, 120000, 98000, 128456],
                itemStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: '#4fc3f7' },
                        { offset: 1, color: '#2196f3' }
                    ]),
                    borderRadius: [4, 4, 0, 0]
                },
                animationDuration: 1500
            }]
        };
        chart1.setOption(option1);

        // 图表2: 折线图 - 交易金额趋势
        const chart2 = echarts.init(document.getElementById('chart2'), 'dark');
        const option2 = {
            backgroundColor: 'transparent',
            grid: { top: 25, right: 10, bottom: 20, left: 45 },
            xAxis: {
                type: 'category',
                data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                axisLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 9 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 9, formatter: '¥{value}' }
            },
            series: [{
                type: 'line',
                data: [1.2, 1.5, 1.8, 2.1, 1.9, 2.5, 2.3],
                smooth: true,
                symbol: 'circle',
                symbolSize: 5,
                lineStyle: { color: '#e91e63', width: 2 },
                itemStyle: { color: '#e91e63' },
                areaStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: 'rgba(233, 30, 99, 0.4)' },
                        { offset: 1, color: 'rgba(233, 30, 99, 0.05)' }
                    ])
                },
                animationDuration: 1500
            }]
        };
        chart2.setOption(option2);

        // 图表3: 饼图 - 系统负载分布
        const chart3 = echarts.init(document.getElementById('chart3'), 'dark');
        const option3 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'pie',
                radius: ['30%', '60%'],
                center: ['50%', '55%'],
                data: [
                    { value: 35, name: 'CPU', itemStyle: { color: '#4caf50' } },
                    { value: 28, name: '内存', itemStyle: { color: '#2196f3' } },
                    { value: 20, name: '磁盘', itemStyle: { color: '#ff9800' } },
                    { value: 17, name: '网络', itemStyle: { color: '#9c27b0' } }
                ],
                label: { color: '#fff', fontSize: 9 },
                labelLine: { lineStyle: { color: 'rgba(255,255,255,0.5)' } },
                animationDuration: 1500
            }]
        };
        chart3.setOption(option3);

        // 图表4: 仪表盘 - 响应时间
        const chart4 = echarts.init(document.getElementById('chart4'), 'dark');
        const option4 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'gauge',
                radius: '70%',
                center: ['50%', '55%'],
                min: 0,
                max: 100,
                splitNumber: 10,
                axisLine: {
                    lineStyle: {
                        width: 6,
                        color: [[0.3, '#4caf50'], [0.7, '#2196f3'], [1, '#f44336']]
                    }
                },
                pointer: { itemStyle: { color: '#4fc3f7' }, width: 3 },
                axisTick: { distance: -6, length: 3, lineStyle: { color: '#fff' } },
                splitLine: { distance: -6, length: 8, lineStyle: { color: '#fff' } },
                axisLabel: { color: '#fff', distance: -15, fontSize: 8 },
                detail: {
                    valueAnimation: true,
                    formatter: '{value}ms',
                    color: '#4fc3f7',
                    fontSize: 12,
                    offsetCenter: [0, '60%']
                },
                data: [{ value: 23 }],
                animationDuration: 2000
            }]
        };
        chart4.setOption(option4);

        // 响应式调整
        const charts = [chart1, chart2, chart3, chart4];
        window.addEventListener('resize', () => {
            charts.forEach(chart => chart.resize());
        });

        // 模拟数据更新
        setInterval(() => {
            const newValue = Math.floor(Math.random() * 20) + 15;
            chart4.setOption({ series: [{ data: [{ value: newValue }] }] });
        }, 3000);
    </script>
</body>
</html>

Demo 6: 混合方案(推荐)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>方案6: 混合方案(推荐) - 大屏适配方案对比</title>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    <script>
        // 混合方案:Scale + Rem
        (function() {
            const designWidth = 1920;
            const designHeight = 1080;
            const minScale = 0.6; // 最小缩放限制,防止过小

            function adapt() {
                const winW = window.innerWidth;
                const winH = window.innerHeight;

                // Scale 计算
                const scaleX = winW / designWidth;
                const scaleY = winH / designHeight;
                const scale = Math.max(Math.min(scaleX, scaleY), minScale);

                // 应用 scale
                const screen = document.getElementById('screen');
                if (screen) {
                    screen.style.transform = `scale(${scale})`;
                }

                // Rem 计算 - 根据缩放比例调整根字体
                // 当 scale < 1 时,增加根字体补偿
                const baseRem = 100;
                const fontScale = Math.max(scale, minScale);
                const rem = baseRem * fontScale;

                document.documentElement.style.fontSize = rem + 'px';

                // 更新信息面板
                updateInfo(scale, rem, winW, winH);
            }

            function updateInfo(scale, rem, winW, winH) {
                const info = document.getElementById('mixedInfo');
                if (info) {
                    info.innerHTML = `
                        Scale: ${scale.toFixed(3)}<br>
                        Rem: ${rem.toFixed(1)}px<br>
                        窗口: ${winW}×${winH}<br>
                        最小限制: ${minScale}
                    `;
                }
            }

            // 页面加载前执行
            adapt();

            // 防抖 resize
            let timer;
            window.addEventListener('resize', () => {
                clearTimeout(timer);
                timer = setTimeout(adapt, 100);
            });

            // 暴露全局供切换模式使用
            window.adaptMode = 'mixed'; // mixed, scale-only, rem-only

            window.setAdaptMode = function(mode) {
                window.adaptMode = mode;

                const screen = document.getElementById('screen');
                const designWidth = 1920;
                const designHeight = 1080;
                const winW = window.innerWidth;
                const winH = window.innerHeight;

                if (mode === 'scale-only') {
                    const scale = Math.min(winW / designWidth, winH / designHeight);
                    screen.style.transform = `scale(${scale})`;
                    document.documentElement.style.fontSize = '100px';
                } else if (mode === 'rem-only') {
                    screen.style.transform = 'none';
                    const rem = winW / designWidth * 100;
                    document.documentElement.style.fontSize = rem + 'px';
                } else {
                    adapt();
                }
            };

            // 初始化覆盖
            window.setAdaptMode('mixed');
        })();
    </script>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
            overflow: hidden;
            background: #0a0a1a;
        }

        /* 信息面板 - 固定不缩放 */
        .info-panel {
            position: fixed;
            top: 10px;
            right: 10px;
            background: rgba(0, 0, 0, 0.9);
            color: #fff;
            padding: 20px;
            border-radius: 8px;
            z-index: 9999;
            width: 340px;
            font-size: 14px;
            border: 1px solid #4caf50;
        }

        .info-panel h3 {
            color: #4caf50;
            margin-bottom: 12px;
            font-size: 16px;
        }

        .info-panel .recommend {
            background: #4caf50;
            color: #000;
            padding: 5px 10px;
            border-radius: 4px;
            font-size: 12px;
            font-weight: bold;
            display: inline-block;
            margin-bottom: 10px;
        }

        .info-panel .pros-cons {
            margin-bottom: 15px;
        }

        .info-panel .pros {
            color: #81c784;
        }

        .info-panel .cons {
            color: #fff176;
        }

        .mode-switcher {
            margin-top: 15px;
            padding-top: 15px;
            border-top: 1px solid #333;
        }

        .mode-switcher h4 {
            color: #4fc3f7;
            margin-bottom: 10px;
        }

        .mode-btn {
            display: block;
            width: 100%;
            padding: 8px 12px;
            margin-bottom: 6px;
            background: #333;
            color: #fff;
            border: 1px solid #555;
            border-radius: 4px;
            cursor: pointer;
            text-align: left;
            font-size: 12px;
        }

        .mode-btn:hover {
            background: #444;
        }

        .mode-btn.active {
            background: #4caf50;
            border-color: #4caf50;
            color: #000;
        }

        .info-value {
            background: #2196f3;
            color: #fff;
            padding: 10px;
            border-radius: 4px;
            margin-top: 10px;
            font-family: 'Courier New', monospace;
            font-size: 12px;
        }

        /* 大屏容器 */
        .screen-container {
            width: 1920px;
            height: 1080px;
            transform-origin: 0 0;
            background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
            position: absolute;
            overflow: hidden;
            top: 0;
            left: 0;
        }

        /* 居中显示 */
        .centered {
            transition: transform 0.3s ease, left 0.3s ease, top 0.3s ease;
        }

        /* 大屏内容样式 - 使用 rem */
        .header {
            height: 1rem;
            background: linear-gradient(90deg, #0f3460 0%, #533483 50%, #0f3460 100%);
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 0.5rem;
            color: #fff;
            text-shadow: 0 0 0.1rem rgba(79, 195, 247, 0.5);
            letter-spacing: 0.08rem;
        }

        .main-content {
            display: flex;
            padding: 0.3rem;
            gap: 0.2rem;
            height: calc(100% - 1rem);
        }

        .sidebar {
            width: 4rem;
            background: rgba(15, 52, 96, 0.3);
            border-radius: 0.16rem;
            padding: 0.2rem;
            border: 0.01rem solid rgba(79, 195, 247, 0.2);
        }

        .sidebar p {
            color: rgba(255,255,255,0.7);
            font-size: 0.18rem;
            line-height: 1.8;
        }

        .chart-area {
            flex: 1;
            display: grid;
            grid-template-columns: repeat(2, 1fr);
            gap: 0.2rem;
        }

        .card {
            background: rgba(15, 52, 96, 0.3);
            border-radius: 0.16rem;
            padding: 0.2rem;
            border: 0.01rem solid rgba(79, 195, 247, 0.2);
            display: flex;
            flex-direction: column;
        }

        .card-title {
            color: #4fc3f7;
            font-size: 0.22rem;
            margin-bottom: 0.08rem;
        }

        .card-value {
            color: #fff;
            font-size: 0.56rem;
            font-weight: bold;
        }

        .mini-chart {
            flex: 1;
            min-height: 1.5rem;
            margin-top: 0.15rem;
            border-radius: 0.08rem;
        }

        /* 特性说明卡片 */
        .feature-card {
            margin-top: 0.15rem;
            padding: 0.15rem;
            background: rgba(76, 175, 80, 0.1);
            border: 1px solid #4caf50;
            border-radius: 0.1rem;
        }

        .feature-card h4 {
            color: #4caf50;
            font-size: 0.18rem;
            margin-bottom: 0.05rem;
        }

        .feature-card p {
            font-size: 0.14rem;
            color: rgba(255,255,255,0.8);
        }

        /* 对比指示器 */
        .compare-indicator {
            position: fixed;
            bottom: 20px;
            left: 20px;
            background: rgba(0, 0, 0, 0.9);
            padding: 15px 20px;
            border-radius: 8px;
            color: #fff;
            border-left: 4px solid #4caf50;
        }

        .compare-indicator h4 {
            color: #4caf50;
            margin-bottom: 5px;
        }
    </style>
</head>
<body>
    <!-- 信息面板 -->
    <div class="info-panel">
        <div class="recommend">⭐ 生产环境推荐</div>
        <h3>方案6: 混合方案</h3>
        <div class="pros-cons">
            <div class="pros">✓ 结合 Scale + Rem 优点</div>
            • 等比例缩放保证布局<br>
            • 字体最小值防止过小<br>
            • 大屏清晰、小屏可读<br><br>
            <div class="cons">△ 注意:</div>
            • 需要 JS 支持<br>
            • 计算逻辑稍复杂
        </div>

        <div class="mode-switcher">
            <h4>模式切换对比:</h4>
            <button class="mode-btn active" onclick="switchMode('mixed', this)">
                🌟 混合方案 (推荐)
            </button>
            <button class="mode-btn" onclick="switchMode('scale-only', this)">
                📐 纯 Scale (字体过小)
            </button>
            <button class="mode-btn" onclick="switchMode('rem-only', this)">
                🔤 纯 Rem (可能变形)
            </button>
        </div>

        <div class="info-value" id="mixedInfo">
            Scale: 1.0<br>
            Rem: 100px<br>
            窗口: 1920×1080
        </div>
    </div>

    <!-- 对比指示器 -->
    <div class="compare-indicator">
        <h4>💡 对比技巧</h4>
        <p>1. 切换到"纯 Scale",缩小窗口,观察字体变小</p>
        <p>2. 切换回"混合方案",字体有最小值限制</p>
        <p>3. 调整到4K屏,观察布局比例保持与设计稿一致</p>
    </div>

    <!-- 大屏容器 -->
    <div class="screen-container centered" id="screen">
        <div class="header">混合方案演示 - Scale + Rem 双重保障</div>

        <div class="main-content">
            <div class="sidebar">
                <div style="color: #4caf50; font-size: 0.24rem; margin-bottom: 0.15rem;">混合方案说明</div>
                <p>
                    此方案结合 Scale 的视觉一致性 和 Rem 的灵活性。<br><br>

                    <strong>核心算法:</strong><br>
                    1. 计算 screen 的 scale 比例<br>
                    2. 根字体 = baseFont * max(scale, minLimit)<br>
                    3. 所有尺寸使用 rem 单位<br><br>

                    这样既保持设计稿比例,又确保文字可读。
                </p>

                <div class="feature-card">
                    <h4>🎯 最小字体保护</h4>
                    <p>当屏幕缩小时,字体不会无限缩小,保证基本可读性。</p>
                </div>

                <div class="feature-card">
                    <h4>📐 严格比例保持</h4>
                    <p>图表、卡片的宽高比例严格遵循设计稿,无变形。</p>
                </div>
            </div>

            <div class="chart-area">
                <div class="card">
                    <div class="card-title">实时用户数</div>
                    <div class="card-value">128,456</div>
                    <div class="mini-chart" id="chart1"></div>
                </div>
                <div class="card">
                    <div class="card-title">交易金额</div>
                    <div class="card-value">¥2.3M</div>
                    <div class="mini-chart" id="chart2"></div>
                </div>
                <div class="card">
                    <div class="card-title">系统负载</div>
                    <div class="card-value">68%</div>
                    <div class="mini-chart" id="chart3"></div>
                </div>
                <div class="card">
                    <div class="card-title">响应时间</div>
                    <div class="card-value">23ms</div>
                    <div class="mini-chart" id="chart4"></div>
                </div>
            </div>
        </div>
    </div>

    <script>
        function switchMode(mode, btn) {
            window.setAdaptMode(mode);

            // 更新按钮状态
            document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
            btn.classList.add('active');

            // 更新位置信息
            updatePosition();
        }

        function updatePosition() {
            const screen = document.getElementById('screen');
            const winW = window.innerWidth;
            const winH = window.innerHeight;
            const designW = 1920;
            const designH = 1080;

            // 获取当前 scale
            const transform = getComputedStyle(screen).transform;
            let scale = 1;
            if (transform && transform !== 'none') {
                const matrix = transform.match(/matrix\(([^)]+)\)/);
                if (matrix) {
                    const values = matrix[1].split(',').map(parseFloat);
                    scale = values[0];
                }
            }

            // 居中计算
            const left = (winW - designW * scale) / 2;
            const top = (winH - designH * scale) / 2;

            screen.style.left = Math.max(0, left) + 'px';
            screen.style.top = Math.max(0, top) + 'px';
        }

        // 初始化位置
        window.addEventListener('load', updatePosition);
        window.addEventListener('resize', updatePosition);

        // ===== ECharts 图表配置 =====
        // 图表1: 柱状图 - 实时用户数
        const chart1 = echarts.init(document.getElementById('chart1'), 'dark');
        const option1 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 45 },
            xAxis: {
                type: 'category',
                data: ['00:00', '04:00', '08:00', '12:00', '16:00', '20:00'],
                axisLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(79, 195, 247, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            series: [{
                type: 'bar',
                data: [32000, 28000, 85000, 120000, 98000, 128456],
                itemStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: '#4fc3f7' },
                        { offset: 1, color: '#2196f3' }
                    ]),
                    borderRadius: [4, 4, 0, 0]
                },
                animationDuration: 1500
            }]
        };
        chart1.setOption(option1);

        // 图表2: 折线图 - 交易金额趋势
        const chart2 = echarts.init(document.getElementById('chart2'), 'dark');
        const option2 = {
            backgroundColor: 'transparent',
            grid: { top: 30, right: 15, bottom: 25, left: 50 },
            xAxis: {
                type: 'category',
                data: ['周一', '周二', '周三', '周四', '周五', '周六', '周日'],
                axisLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.5)' } },
                axisLabel: { color: '#fff', fontSize: 10 }
            },
            yAxis: {
                type: 'value',
                splitLine: { lineStyle: { color: 'rgba(83, 52, 131, 0.1)' } },
                axisLabel: { color: '#fff', fontSize: 10, formatter: '¥{value}' }
            },
            series: [{
                type: 'line',
                data: [1.2, 1.5, 1.8, 2.1, 1.9, 2.5, 2.3],
                smooth: true,
                symbol: 'circle',
                symbolSize: 6,
                lineStyle: { color: '#e91e63', width: 3 },
                itemStyle: { color: '#e91e63' },
                areaStyle: {
                    color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
                        { offset: 0, color: 'rgba(233, 30, 99, 0.4)' },
                        { offset: 1, color: 'rgba(233, 30, 99, 0.05)' }
                    ])
                },
                animationDuration: 1500
            }]
        };
        chart2.setOption(option2);

        // 图表3: 饼图 - 系统负载分布
        const chart3 = echarts.init(document.getElementById('chart3'), 'dark');
        const option3 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'pie',
                radius: ['35%', '65%'],
                center: ['50%', '55%'],
                data: [
                    { value: 35, name: 'CPU', itemStyle: { color: '#4caf50' } },
                    { value: 28, name: '内存', itemStyle: { color: '#2196f3' } },
                    { value: 20, name: '磁盘', itemStyle: { color: '#ff9800' } },
                    { value: 17, name: '网络', itemStyle: { color: '#9c27b0' } }
                ],
                label: { color: '#fff', fontSize: 10 },
                labelLine: { lineStyle: { color: 'rgba(255,255,255,0.5)' } },
                animationDuration: 1500
            }]
        };
        chart3.setOption(option3);

        // 图表4: 仪表盘 - 响应时间
        const chart4 = echarts.init(document.getElementById('chart4'), 'dark');
        const option4 = {
            backgroundColor: 'transparent',
            series: [{
                type: 'gauge',
                radius: '75%',
                center: ['50%', '55%'],
                min: 0,
                max: 100,
                splitNumber: 10,
                axisLine: {
                    lineStyle: {
                        width: 8,
                        color: [[0.3, '#4caf50'], [0.7, '#2196f3'], [1, '#f44336']]
                    }
                },
                pointer: { itemStyle: { color: '#4fc3f7' }, width: 4 },
                axisTick: { distance: -8, length: 4, lineStyle: { color: '#fff' } },
                splitLine: { distance: -8, length: 10, lineStyle: { color: '#fff' } },
                axisLabel: { color: '#fff', distance: -20, fontSize: 9 },
                detail: {
                    valueAnimation: true,
                    formatter: '{value}ms',
                    color: '#4fc3f7',
                    fontSize: 16,
                    offsetCenter: [0, '65%']
                },
                data: [{ value: 23 }],
                animationDuration: 2000
            }]
        };
        chart4.setOption(option4);

        // 响应式调整
        const charts = [chart1, chart2, chart3, chart4];
        window.addEventListener('resize', () => {
            charts.forEach(chart => chart.resize());
        });

        // 模拟数据更新
        setInterval(() => {
            const newValue = Math.floor(Math.random() * 20) + 15;
            chart4.setOption({ series: [{ data: [{ value: newValue }] }] });
        }, 3000);
    </script>
</body>
</html>
/* 使用 rem 单位编写样式 */
.header {
    height: 1rem;           /* 设计稿 100px */
    font-size: 0.5rem;      /* 设计稿 50px */
    letter-spacing: 0.08rem;
}

.sidebar {
    width: 4rem;            /* 设计稿 400px */
    padding: 0.2rem;        /* 设计稿 20px */
}

.card-title {
    font-size: 0.22rem;     /* 设计稿 22px */
}

.card-value {
    font-size: 0.56rem;     /* 设计稿 56px */
}

完整 Demo 文件列表:

  • demo1 - Scale 等比例缩放(上方已提供完整代码)
  • demo2 - VW/VH 视口单位方案
  • demo3 - Rem 动态计算方案
  • demo4 - 流式/弹性布局方案
  • demo5 - 响应式断点方案
  • demo6 - 混合方案(推荐)

以上所有demo均可在本地环境中直接运行,建议按顺序体验对比各方案的表现差异

核心代码示例

Scale 方案核心代码

<!DOCTYPE html>
<html>
<head>
  <style>
    .screen-container {
      width: 1920px;
      height: 1080px;
      transform-origin: 0 0;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
    }
  </style>
</head>
<body>
  <div class="screen-container" id="screen">
    <!-- 大屏内容 -->
  </div>

  <script>
    const DESIGN_WIDTH = 1920;
    const DESIGN_HEIGHT = 1080;

    function setScale() {
      const scaleX = window.innerWidth / DESIGN_WIDTH;
      const scaleY = window.innerHeight / DESIGN_HEIGHT;
      const scale = Math.min(scaleX, scaleY);

      const screen = document.getElementById('screen');
      screen.style.transform = `scale(${scale})`;

      // 居中显示
      const left = (window.innerWidth - DESIGN_WIDTH * scale) / 2;
      const top = (window.innerHeight - DESIGN_HEIGHT * scale) / 2;
      screen.style.left = left + 'px';
      screen.style.top = top + 'px';
    }

    setScale();
    window.addEventListener('resize', setScale);
  </script>
</body>
</html>

混合方案核心代码

// 混合方案:Scale + Rem
const DESIGN_WIDTH = 1920;
const DESIGN_HEIGHT = 1080;
const MIN_SCALE = 0.6; // 最小缩放限制,防止过小

function adapt() {
  const winW = window.innerWidth;
  const winH = window.innerHeight;

  // Scale 计算
  const scaleX = winW / DESIGN_WIDTH;
  const scaleY = winH / DESIGN_HEIGHT;
  const scale = Math.max(Math.min(scaleX, scaleY), MIN_SCALE);

  // 应用 scale
  const screen = document.getElementById('screen');
  screen.style.transform = `scale(${scale})`;

  // Rem 计算 - 根据缩放比例调整根字体
  // 当 scale < 1 时,增加根字体补偿
  const baseRem = 100;
  const fontScale = Math.max(scale, MIN_SCALE);
  const rem = baseRem * fontScale;

  document.documentElement.style.fontSize = rem + 'px';
}

adapt();
window.addEventListener('resize', adapt);

总结

大屏适配没有银弹,每种方案都有其适用场景:

  1. 简单大屏项目:使用 Scale 方案,快速实现
  2. 内容管理系统:使用流式布局,灵活适配
  3. 移动端优先:使用 Rem 方案,成熟稳定
  4. 多端统一:使用混合方案,兼顾体验

选择方案时需要综合考虑:

  • 设计稿还原要求
  • 目标设备规格
  • 开发维护成本
  • 团队技术栈

希望本文能帮助你在大屏开发中游刃有余!


如果觉得有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!

手撕 Claude Code-4: TodoWrite 与任务系统

第 4 章:TodoWrite 与任务系统

源码位置:src/tools/TodoWriteTool/src/tasks/src/utils/tasks.ts


4.1 两个容易混淆的概念

Claude Code 中有两个相关但不同的"任务"概念:

概念 说明 存储位置
Todo(任务清单) Claude 在当前会话中规划的工作步骤 AppState.todos
Task(系统任务) 运行时管理的实体(子代理、Shell 命令、后台会话等) AppState.tasks

本章先讲 TodoWrite(任务清单),再讲 Task System(系统任务)。


4.2 TodoWrite:Claude 的工作计划本

4.2.1 数据模型

// src/utils/todo/types.ts
type TodoItem = {
  content: string                                    // 任务描述
  status: 'pending' | 'in_progress' | 'completed'
  activeForm: string                                 // 任务的主动形式(如 "Reading src/foo.ts")
}

type TodoList = TodoItem[]

4.2.2 工具实现

源码位置:src/tools/TodoWriteTool/TodoWriteTool.ts:65

async call({ todos }, context) {
  const appState = context.getAppState()
  
  // 每个代理有自己的 todo 列表(用 agentId 或 sessionId 区分)
  const todoKey = context.agentId ?? getSessionId()
  const oldTodos = appState.todos[todoKey] ?? []
  
  // 关键逻辑:如果所有任务都已完成,AppState 中清空为 []
  const allDone = todos.every(_ => _.status === 'completed')
  const newTodos = allDone ? [] : todos

  // 验证提醒:仅当 allDone && 3+ 项 && 无验证步骤时才触发
  let verificationNudgeNeeded = false
  if (
    feature('VERIFICATION_AGENT') &&
    getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false) &&
    !context.agentId &&   // 只在主线程触发,不在子代理
    allDone &&            // 必须是关闭列表的那一刻
    todos.length >= 3 &&
    !todos.some(t => /verif/i.test(t.content))
  ) {
    verificationNudgeNeeded = true
  }

  // 注意:API 是 setAppState(接收 prev → newState 函数),不是 updateAppState
  context.setAppState(prev => ({
    ...prev,
    todos: { ...prev.todos, [todoKey]: newTodos },
  }))

  // 返回值中 newTodos 是原始输入 todos(非清空后的 [])
  return {
    data: { oldTodos, newTodos: todos, verificationNudgeNeeded },
  }
}

4.2.3 关键设计点

1. 所有完成即清空

当所有 todo 都标记为 completed,AppState 中的列表被清空为 [] 而不是保留所有已完成项。这避免了 token 浪费(每次 API 调用都要序列化已完成的任务)。注意:工具返回值中的 newTodos 字段仍为原始 todos 输入,清空仅影响 AppState 存储层。

2. agentId 隔离

每个子代理有独立的 todo 列表(通过 agentId 作为 key)。主线程用 sessionId。这样并行运行的子代理不会互相干扰。

3. shouldDefer: true

TodoWrite 被标记为延迟披露,不在每次 API 请求中直接包含其 Schema。Claude 通过 ToolSearch 找到它。

4. 与 TodoV2(Task API)互斥

源码位置:src/tools/TodoWriteTool/TodoWriteTool.ts:53 中:

isEnabled() {
  return !isTodoV2Enabled()
}

源码位置:src/utils/tasks.ts:133 isTodoV2Enabled() 在以下情况返回 true(即 TodoWrite 被禁用):

  • 环境变量 CLAUDE_CODE_ENABLE_TASKS=1
  • 非交互式会话(SDK 模式)

这意味着 SDK 用户默认使用 Task API 而非 TodoWrite。

5. verificationNudgeNeeded

这是一个"行为引导"机制。触发条件(需同时满足):

  • Feature flag VERIFICATION_AGENT 已开启(编译时 gate)
  • GrowthBook flag tengu_hive_evidence 为 true
  • 当前在主线程(!context.agentId
  • 当前调用正在关闭整个列表(allDone === true
  • 任务数 ≥ 3
  • 没有任何任务内容匹配 /verif/i

满足时,工具结果会追加提示,要求 Claude 在写最终摘要前先 spawn 验证代理。


4.3 Task System:运行时实体管理

Task System 管理着 Claude Code 中所有的"运行中的实体"。

4.3.0 TaskStateBase:所有任务的公共基础

源码位置:src/Task.ts:45

type TaskStateBase = {
  id: string           // 任务 ID(带类型前缀,见下表)
  type: TaskType       // 'local_bash' | 'local_agent' | 'remote_agent' | 'in_process_teammate' | 'local_workflow' | 'monitor_mcp' | 'dream'
  status: TaskStatus   // 'pending' | 'running' | 'completed' | 'failed' | 'killed'
  description: string
  toolUseId?: string   // 触发此任务的 tool_use block ID(用于通知回调)
  startTime: number
  endTime?: number
  totalPausedMs?: number
  outputFile: string   // 磁盘输出文件路径(快照,/clear 后不变)
  outputOffset: number // 已读取字节偏移量(用于增量 delta 读取)
  notified: boolean    // 是否已发送完成通知(防止重复通知)
}

Task ID 前缀表(src/Task.ts:79):

类型 前缀 示例
local_bash b b3f9a2c1
local_agent a a7d8e4f2
remote_agent r r2a1b3c4
in_process_teammate t t5e6f7a8
local_workflow w w9b0c1d2
monitor_mcp m m3e4f5a6
dream d d7b8c9d0
主会话(特殊) s s1a2b3c4

local_agent 和主会话后台化共用 a 前缀(LocalAgentTaskState),但主会话后台化通过 LocalMainSessionTask.ts 中独立的 generateMainSessionTaskId() 使用 s 前缀,与普通子代理区分。

4.3.1 TaskState 联合类型

源码位置:src/tasks/types.ts

// 注意:类型名是 TaskState,不是 Task
type TaskState =
  | LocalShellTaskState        // 长运行 Shell 命令
  | LocalAgentTaskState        // 本地子代理(含后台化主会话)
  | RemoteAgentTaskState       // 远程代理
  | InProcessTeammateTaskState // in-process 队友(团队协作)
  | LocalWorkflowTaskState     // 工作流
  | MonitorMcpTaskState        // MCP 监控
  | DreamTaskState             // 记忆整合子代理(auto-dream)

注意LocalMainSessionTask 不是独立的联合类型成员。它是 LocalAgentTaskState 的子类型:

// src/tasks/LocalMainSessionTask.ts
type LocalMainSessionTaskState = LocalAgentTaskState & { agentType: 'main-session' }

这意味着后台化主会话与普通子代理共用同一个 type: 'local_agent' 标识符,通过 agentType 字段区分。

4.3.2 主会话后台化(LocalMainSessionTask)

这是一个特殊功能:用户按 Ctrl+B 两次可以将当前对话"后台化",然后开始新的对话。后台化后,原查询继续在后台独立运行。

源码位置:src/tasks/LocalMainSessionTask.ts

// LocalMainSessionTaskState 不是独立类型,而是 LocalAgentTaskState 的子类型
type LocalMainSessionTaskState = LocalAgentTaskState & {
  agentType: 'main-session'  // 唯一区分标识
}
// type: 'local_agent'(继承)
// taskId: 's' 前缀(区分普通代理的 'a' 前缀)
// messages?: Message[](继承,存储后台查询的消息历史)
// isBackgrounded: boolean(继承,true = 后台,false = 前台展示中)
// 无独立 transcript 字段

关键函数:

// 注册后台会话任务,返回 { taskId, abortSignal }
registerMainSessionTask(description, setAppState, agentDefinition?, abortController?)

// 启动真正的后台查询(wrap runWithAgentContext + query())
startBackgroundSession({ messages, queryParams, description, setAppState })

// 将后台任务切回前台展示
foregroundMainSessionTask(taskId, setAppState): Message[]

为什么需要隔离的转录路径? 后台任务使用 getAgentTranscriptPath(agentId) 而不是主会话的路径。若用同一路径,/clear 会意外覆盖后台会话数据。后台任务通过 initTaskOutputAsSymlink() 创建软链接,/clear 重链接时不影响后台任务的历史记录。

4.3.3 本地代理任务(LocalAgentTask)

每个子代理对应一个 LocalAgentTaskState

源码位置:src/tasks/LocalAgentTask/LocalAgentTask.tsx:116

type LocalAgentTaskState = TaskStateBase & {
  type: 'local_agent'
  agentId: string
  prompt: string
  selectedAgent?: AgentDefinition
  agentType: string          // 区分子类型,'main-session' = 后台化主会话
  model?: string
  abortController?: AbortController
  error?: string
  result?: AgentToolResult
  progress?: AgentProgress   // 进度信息(包含 toolUseCount、tokenCount)
  retrieved: boolean         // 结果是否已被取回
  messages?: Message[]       // 代理的消息历史(UI 展示用)
  lastReportedToolCount: number
  lastReportedTokenCount: number
  isBackgrounded: boolean    // false = 前台展示中,true = 后台运行
  pendingMessages: string[]  // SendMessage 排队的消息,在工具轮边界处理
  retain: boolean            // UI 持有此任务(阻止驱逐)
  diskLoaded: boolean        // 是否已从磁盘加载 sidechain JSONL
  evictAfter?: number        // 驱逐时间戳(任务完成后设置)
}

注意:文档中常见误写 toolUseCount 为 LocalAgentTaskState 的直接字段,实际它在 progress.toolUseCount 中。status 字段继承自 TaskStateBase'running' | 'completed' | 'failed' | 'stopped')。

进度追踪通过专门的辅助函数:

// src/tasks/LocalAgentTask/LocalAgentTask.tsx
createProgressTracker()           // 初始化 ProgressTracker(分别追踪 input/output tokens)
updateProgressFromMessage()       // 从 assistant message 累积 token 和工具调用数
getProgressUpdate()               // 生成 AgentProgress 快照(供 UI 消费)
createActivityDescriptionResolver() // 通过 tool.getActivityDescription() 生成人类可读描述

Token 追踪的精妙设计ProgressTracker 分开存储 latestInputTokens(Claude API 累积值,取最新)和 cumulativeOutputTokens(逐轮累加),避免重复计数。

4.3.4 in-process 队友任务(InProcessTeammateTask)

这是 Agent Teams 的核心数据结构:

src/tasks/InProcessTeammateTask/types.ts

type TeammateIdentity = {
  agentId: string        // e.g., "researcher@my-team"
  agentName: string      // e.g., "researcher"
  teamName: string
  color?: string
  planModeRequired: boolean
  parentSessionId: string  // Leader 的 sessionId
}

type InProcessTeammateTaskState = TaskStateBase & {
  type: 'in_process_teammate'
  identity: TeammateIdentity        // 队友身份(存储在 AppState 中的 plain data)
  prompt: string
  model?: string
  selectedAgent?: AgentDefinition
  abortController?: AbortController         // 终止整个队友
  currentWorkAbortController?: AbortController  // 终止当前轮次
  awaitingPlanApproval: boolean             // 是否等待 plan 审批
  permissionMode: PermissionMode            // 独立权限模式(Shift+Tab 切换)
  error?: string
  result?: AgentToolResult
  progress?: AgentProgress
  messages?: Message[]              // UI 展示用(上限 50 条,TEAMMATE_MESSAGES_UI_CAP)
  pendingUserMessages: string[]     // 查看该队友时用户输入的队列消息
  isIdle: boolean                   // 是否处于空闲(等待 leader 指令)
  shutdownRequested: boolean        // 是否已请求关闭
  lastReportedToolCount: number
  lastReportedTokenCount: number
}

常见误解mailbox 字段不存在于 InProcessTeammateTaskState。队友间的通信邮箱存储在运行时上下文 teamContext.inProcessMailboxes(AsyncLocalStorage 中),不在 AppState 里。pendingUserMessages 是用户从 UI 发给该队友的消息队列,与邮箱是两回事。

内存上限设计messages 字段上限 50 条(TEAMMATE_MESSAGES_UI_CAP),超出后从头部截断。原因是生产环境出现过单个 whale session 启动 292 个 agent、内存达 36.8GB 的情况,根本原因正是此字段持有第二份完整消息副本。

4.3.5 记忆整合任务(DreamTask)

源码位置:src/tasks/DreamTask/DreamTask.ts

type DreamTaskState = TaskStateBase & {
  type: 'dream'
  phase: 'starting' | 'updating'    // starting → updating(首个 Edit/Write 后翻转)
  sessionsReviewing: number          // 正在整理的会话数量
  filesTouched: string[]             // 被 Edit/Write 触碰的文件(不完整,仅 pattern-match 到的)
  turns: DreamTurn[]                 // assistant 轮次(工具调用折叠为计数)
  abortController?: AbortController
  priorMtime: number                 // 用于 kill 时回滚 consolidationLock 时间戳
}

DreamTask 是"auto-dream"记忆整合的 UI 表面层。它不改变子代理运行逻辑,只是让原本不可见的 fork agent 在 footer pill 和 Shift+Down 对话框中可见。子代理按 4 阶段 prompt 运行(orient → gather → consolidate → prune),但 DreamTask 不解析阶段,只通过工具调用类型推断 phase。


4.4 任务注册与生命周期框架

源码位置:src/utils/task/framework.ts

4.4.1 registerTask:注册与恢复

registerTask(task: TaskState, setAppState): void

注册一个新任务时,有两条路径:

新建existing === undefined):

  1. 将 task 写入 AppState.tasks[task.id]
  2. 向 SDK 事件队列发出 task_started 事件

恢复/替换existing !== undefined,如 resumeAgentBackground):

  1. 合并保留以下 UI 状态(避免用户正在查看的面板闪烁):
    • retain:UI 持有标记
    • startTime:面板排序稳定性
    • messages:用户刚发送的消息还未落盘
    • diskLoaded:避免重复加载 sidechain JSONL
    • pendingMessages:待处理消息队列
  2. 发出 task_started(防止 SDK 重复计数)

4.4.2 任务完成通知(XML 格式)

子代理/后台任务完成时,通过 enqueuePendingNotification 将 XML 推入消息队列:

<task_notification>
  <task_id>a7d8e4f2</task_id>
  <tool_use_id>toolu_01xxx</tool_use_id>  <!-- 可选 -->
  <output_file>/tmp/.../tasks/a7d8e4f2.output</output_file>
  <status>completed</status>
  <summary>Task "修复登录 bug" completed successfully</summary>
</task_notification>

这段 XML 在下一轮 API 调用前作为 user 消息被注入 messages,使 LLM 感知到后台任务完成。

4.4.3 evictTerminalTask:两级驱逐

任务完成后并不立即从 AppState.tasks 中删除,而是两级驱逐:

任务完成 → status='completed' + notified=true
  │
  ├─ 如果 retain=true 或 evictAfter > Date.now()
  │    → 保留(UI 正在展示,等待 30s grace period 后驱逐)
  │
  └─ 否则 → 立即从 AppState.tasks 中删除(eagerly evict)
               作为保底,generateTaskAttachments() 也会在下次 poll 时驱逐

PANEL_GRACE_MS = 30_000(30秒)是 coordinator panel 中 agent 任务的展示宽限期,确保用户能看到结果后再消失。


4.5 后台 API 任务工具

用户(通过 Claude)可以创建和管理后台任务:

// TaskCreateTool / TaskUpdateTool / TaskStopTool / TaskGetTool / TaskListTool

这些工具允许 Claude 自己创建和监控后台任务,实现真正的异步多任务处理。注意:这套 Task API 工具仅在 TodoV2 模式下启用(即 isTodoV2Enabled() === true),与 TodoWrite 互斥。


4.6 任务输出的磁盘管理

源码位置:src/utils/task/diskOutput.ts

4.6.1 输出文件路径

// 注意:路径不是 .claude/tasks/,而是项目临时目录下的会话子目录
getTaskOutputPath(taskId) → `{projectTempDir}/{sessionId}/tasks/{taskId}.output`

为什么包含 sessionId? 防止同一项目的并发 Claude Code 会话互相踩踏输出文件。路径在首次调用时被 memoize(let _taskOutputDir),/clear 触发 regenerateSessionId() 时不会重新计算,确保跨 /clear 存活的后台任务仍能找到自己的文件。

4.6.2 DiskTaskOutput 写队列

任务输出通过 DiskTaskOutput 类异步写入磁盘:

class DiskTaskOutput {
  append(content: string): void  // 入队,自动触发 drain
  flush(): Promise<void>         // 等待队列清空
  cancel(): void                 // 丢弃队列(任务被 kill 时)
}

核心设计要点

  1. 写队列#queue: string[] 平铺数组,单个 drain 循环消费,chunk 写入后立即可被 GC,避免 .then() 链持有引用导致内存膨胀

  2. 5GB 上限:超限后追加截断标记并停止写入:

    [output truncated: exceeded 5GB disk cap]
    
  3. O_NOFOLLOW 安全:Unix 上用 O_NOFOLLOW flag 打开文件,防止沙箱中的攻击者通过创建软链接将 Claude Code 写入任意宿主文件

  4. 事务追踪:所有 fire-and-forget 异步操作(initTaskOutputevictTaskOutput 等)注册到 _pendingOps: Set<Promise>,测试可通过 allSettled 等待全部完成,防止跨测试的 ENOENT 竞争

4.6.3 增量输出读取(OutputOffset)

TaskStateBase.outputOffset 记录已消费的字节偏移,实现增量读取:

// 仅读取 fromOffset 之后的新内容(最多 8MB)
getTaskOutputDelta(taskId, fromOffset): Promise<{ content: string; newOffset: number }>

framework.ts 中的 generateTaskAttachments() 在每次 poll 时调用 getTaskOutputDelta,将新增内容附加到 task_status attachment 中推送给 LLM,避免重复加载完整输出文件。

4.6.4 关键函数对比

函数 是否删磁盘文件 是否清内存 适用场景
evictTaskOutput(taskId) 是(flush 后清 Map) 任务完成,结果已消费
cleanupTaskOutput(taskId) 彻底清理(测试、取消)
flushTaskOutput(taskId) 读取前确保写入完成

4.7 Cron 定时任务

通过 Cron 工具,可以创建定期自动运行的任务:

// CronCreate / CronDelete / CronList 工具
// 底层通过 src/utils/cron.ts 管理

// 使用示例(Claude 会这样调用):
// CronCreate({ schedule: '0 9 * * 1', command: '/review-pr' })
// → 每周一早上 9 点自动运行 /review-pr

4.8 流程图:任务的完整生命周期

用户输入复杂任务
      │
      ▼
Claude 调用 TodoWrite         ← 规划工作步骤
  todos: [
    { content: '读取代码', status: 'pending' },
    { content: '修改功能', status: 'pending' },
    { content: '运行测试', status: 'pending' },
  ]
      │
      ▼
Claude 开始执行第一步
TodoWrite: { status: 'in_progress' }  ← 标记进行中
      │
      ├─ 如果需要并行工作:
      │    Agent({ subagent_type: 'general-purpose', ... })
      │         │
      │         ▼
      │    创建 LocalAgentTaskState(id: 'a-xxxxxxxx',agentId 同值)
      │    子代理在独立上下文中运行
      │         │
      │         ▼
      │    子代理有自己的 Todo 列表(隔离)
      │
      ▼
每步完成后:
TodoWrite: { status: 'completed' }    ← 标记完成
      │
      ▼
所有步骤完成:
TodoWrite: todos.every(done) → 清空列表 []
      │
      ▼
Agent Loop 检测到无工具调用 → 停止

小结

组件 职责 源码位置
TodoWriteTool 会话内工作计划追踪(交互模式) src/tools/TodoWriteTool/TodoWriteTool.ts
Task API 工具 后台任务创建与管理(SDK/非交互模式) TaskCreateTool / TaskStopTool
TaskStateBase 所有任务的公共字段(含 id、status、outputOffset) src/Task.ts
LocalMainSessionTask 主会话后台化(LocalAgentTaskState 子类型) src/tasks/LocalMainSessionTask.ts
LocalAgentTaskState 子代理生命周期管理 src/tasks/LocalAgentTask/LocalAgentTask.tsx
InProcessTeammateTaskState 团队队友状态(含内存上限 50 条) src/tasks/InProcessTeammateTask/types.ts
DreamTaskState 记忆整合子代理的 UI 表面层 src/tasks/DreamTask/DreamTask.ts
任务框架 registerTask / evictTerminalTask / 通知 XML src/utils/task/framework.ts
磁盘输出管理 DiskTaskOutput 写队列 / 增量读取 / 5GB 上限 src/utils/task/diskOutput.ts
Cron 定时任务 自动化周期任务 CronCreate/Delete/List 工具

关键设计约定总结

约定 说明
LocalMainSessionTask 不是独立类型 它是 LocalAgentTaskState & { agentType: 'main-session' }
TaskState(不是 Task) 联合类型的正确名称
setAppState(不是 updateAppState AppState 更新的实际 API
toolUseCountprogress 不是 LocalAgentTaskState 的直接字段
TodoWrite 与 Task API 互斥 通过 isTodoV2Enabled()isEnabled() 中切换

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

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

DDY 节点是 Unity URP Shader Graph 中一个重要的高级功能节点,它提供了在像素着色器阶段计算屏幕空间 Y 方向偏导数的能力。这个节点基于 GPU 的导数计算硬件,能够高效地获取相邻像素间的数值变化率,在计算机图形学中有着广泛的应用场景。

偏导数的概念源自微积分,在图形学上下文中,它表示某个值在屏幕空间相邻像素间的变化率。DDY 节点专门计算 Y 方向(垂直方向)的变化率,而与之对应的 DDX 节点则计算 X 方向(水平方向)的变化率。这两个节点共同构成了现代 GPU 并行架构中导数计算的核心功能。

在 Shader Graph 中使用 DDY 节点时,理解其工作原理和限制条件至关重要。由于该节点依赖于像素着色器中的片段并行处理特性,它只能在特定的渲染阶段使用,并且对硬件有一定的要求。掌握 DDY 节点的正确使用方法,能够为着色器开发带来更多可能性,实现各种高级视觉效果。

描述

DDY 节点的核心功能是计算输入值在屏幕空间 Y 坐标方向上的偏导数。从数学角度理解,偏导数描述了多变量函数沿某一坐标轴方向的变化率。在着色器编程的语境中,这意味着 DDY 节点能够测量某个着色器属性或计算值在垂直相邻像素之间的差异。

屏幕空间偏导数的计算基于 GPU 的硬件特性。现代 GPU 通常以 2x2 像素块为单位并行执行像素着色器,这种架构被称为"像素四边形"(Pixel Quad)。在这种结构中,DDY 节点通过比较当前像素与同一像素四边形中下方像素的数值差异来计算偏导数。这种硬件级的并行计算使得导数计算非常高效,不需要额外的复杂数学运算。

DDY 节点的一个重要限制是它只能在像素着色器阶段使用。这是因为导数计算依赖于片段着色器中的像素级并行处理。如果尝试在顶点着色器或其他渲染阶段使用 DDY 节点,将会导致编译错误或未定义的行为。在 Shader Graph 中,当将 DDY 节点连接到非像素着色器阶段的节点时,系统通常会发出警告或错误提示。

在实际应用中,DDY 节点最常见的用途包括:

  • 计算法线贴图的表面法线
  • 实现基于导数的边缘检测
  • 创建各向异性高光效果
  • 优化纹理采样和 mipmap 级别选择
  • 实现屏幕空间的环境光遮蔽

理解 DDY 节点的数学原理对于正确使用它至关重要。偏导数的计算可以近似表示为:ddy(p) ≈ p(x, y+1) - p(x, y),其中 p 是输入值,(x, y) 是当前像素的屏幕坐标。这个近似计算由 GPU 硬件在像素四边形级别自动完成,为着色器程序员提供了高效的导数访问方式。

端口

DDY 节点的端口设计遵循 Shader Graph 的标准约定,提供了清晰的输入输出接口,使得节点能够灵活地集成到各种着色器网络中。

输入端口

输入端口标记为 "In",是节点的唯一输入通道,接受动态矢量类型的数据。动态矢量意味着该端口可以接受各种维度的向量输入,包括:

  • float(标量值)
  • float2(二维向量)
  • float3(三维向量)
  • float4(四维向量)

这种灵活性使得 DDY 节点能够处理各种类型的数据,从简单的灰度值到完整的颜色信息。当输入多维向量时,DDY 节点会独立计算每个分量的偏导数,返回一个与输入维度相同的输出向量。

输入值的内容可以是任何在像素着色器中有效的表达式或节点输出,包括:

  • 纹理采样结果
  • 数学运算输出
  • 时间变量
  • 顶点数据插值
  • 其他自定义计算的结果

输出端口

输出端口标记为 "Out",提供计算得到的偏导数结果。与输入端口类似,输出也是动态矢量类型,其维度与输入保持一致。输出值的每个分量对应于输入向量相应分量的偏导数。

输出值的范围和特性取决于输入内容:

  • 当输入是连续平滑变化的值时,输出通常较小且变化平缓
  • 当输入在相邻像素间有剧烈变化时,输出值会相应增大
  • 在边缘或高对比度区域,输出值可能显著增加
  • 在平坦或均匀区域,输出值接近零

理解输出值的这些特性对于正确解释和使用 DDY 节点的结果至关重要。在实际应用中,通常需要对输出值进行适当的缩放、钳制或后续处理,以适应特定的视觉效果需求。

端口连接实践

在 Shader Graph 中连接 DDY 节点时,需要考虑数据类型和精度的匹配。虽然动态矢量端口提供了很大的灵活性,但最佳实践包括:

  • 确保输入数据的范围合理,避免极端值导致导数计算不稳定
  • 注意数据精度,在移动平台或性能受限环境下考虑使用半精度浮点数
  • 合理组织节点网络,避免不必要的复杂连接影响可读性
  • 使用适当的注释和分组,使包含 DDY 节点的复杂网络更易于理解和维护

生成的代码示例

DDY 节点在背后生成的代码揭示了其在底层着色器语言中的实现方式。理解这些生成的代码有助于深入掌握节点的行为特性,并在需要时进行自定义扩展或优化。

HLSL 代码实现

在大多数情况下,DDY 节点会生成类似于以下示例的 HLSL 代码:

void Unity_DDY_float4(float4 In, out float4 Out)
{
    Out = ddy(In);
}

这个简单的函数封装了 HLSL 内置的 ddy() 函数,该函数是 DirectX 着色器语言中用于计算屏幕空间 Y 方向偏导数的原生指令。函数接受一个 float4 类型的输入参数,并输出相应的偏导数结果。

对于不同维度的输入,生成的函数签名会相应调整:

  • 对于 float 输入:Unity_DDY_float(float In, out float Out)
  • 对于 float2 输入:Unity_DDY_float2(float2 In, out float2 Out)
  • 对于 float3 输入:Unity_DDY_float3(float3 In, out float3 Out)

底层硬件实现

虽然从代码层面看,DDY 节点的实现很简单,但它在硬件层面的执行却涉及 GPU 的并行架构特性。当 GPU 执行包含 ddy() 调用的像素着色器时:

  • 着色器单元以 2x2 像素块(像素四边形)为单位调度执行
  • 在每个像素四边形中,四个片段并行处理
  • 硬件自动比较同一四边形中垂直相邻像素的寄存器值
  • 计算得到的导数值用于所有四个像素的着色计算

这种实现方式意味着导数计算基本上没有额外的性能开销,因为 GPU 本来就需要并行处理像素四边形中的多个片段。这也是为什么导数计算只能在像素着色器中工作的原因——其他着色器阶段没有这种并行处理架构。

精度和性能考虑

在使用 DDY 节点时,了解其精度特性和性能影响很重要:

  • 导数计算基于实际执行的像素值,因此结果完全准确
  • 在几何边缘或遮挡边界处,导数可能不太可靠,因为相邻像素可能属于不同物体
  • 性能开销通常很小,但在低端移动设备上,复杂的导数计算网络仍可能影响性能
  • 在某些情况下,使用近似计算方法可能比直接使用 DDY 节点更高效

自定义扩展和应用

通过理解生成的代码模式,开发者可以创建自定义的导数计算函数,扩展 DDY 节点的功能:

// 自定义带缩放的导数计算
void Custom_DDY_Scaled(float4 In, float Scale, out float4 Out)
{
    Out = ddy(In) * Scale;
}

// 带钳制的导数计算,避免过大的导数值
void Custom_DDY_Clamped(float4 In, float MaxDerivative, out float4 Out)
{
    Out = clamp(ddy(In), -MaxDerivative, MaxDerivative);
}

// 计算导数的大小,用于边缘检测等应用
void Custom_DDY_Length(float4 In, out float Out)
{
    Out = length(ddy(In));
}

这些自定义函数可以在 Shader Graph 中通过 Custom Function 节点实现,为特定的应用场景提供更专门的导数计算功能。

实际应用案例

DDY 节点在着色器开发中有着广泛的应用,以下是一些典型的实际应用案例,展示了如何充分利用这个节点的特性。

法线贴图处理

在基于物理的渲染中,法线贴图是增强表面细节的关键技术。DDY 节点可以用于计算法线贴图的正确 mipmap 级别,或者在需要时重建世界空间法线:

// 使用 DDY 计算法线贴图的适当 LOD 级别
float CalculateNormalMapLOD(float2 uv)
{
    float2 deriv = float2(ddx(uv.x), ddy(uv.y));
    float lod = 0.5 * log2(max(dot(deriv, deriv), 1.0));
    return lod;
}

// 结合 DDX 和 DDY 重建世界空间法线
float3 ReconstructWorldNormal(float2 uv, float3 normalTS, float3x3 TBN)
{
    float3 ddx_normal = ddx(normalTS);
    float3 ddy_normal = ddy(normalTS);
    // 应用复杂的法线重建算法
    // ...
}

边缘检测效果

DDY 节点在屏幕后处理中常用于边缘检测,通过分析颜色或深度的变化来识别图像中的边缘:

// 基于颜色导数的简单边缘检测
float EdgeDetectionColor(float2 uv, sampler2D colorTexture)
{
    float3 color = tex2D(colorTexture, uv).rgb;
    float3 deriv_x = ddx(color);
    float3 deriv_y = ddy(color);

    float edge = length(deriv_x) + length(deriv_y);
    return saturate(edge * 10.0); // 调整灵敏度
}

// 结合深度和颜色的高级边缘检测
float AdvancedEdgeDetection(float2 uv, sampler2D colorTexture, sampler2D depthTexture)
{
    float depth = tex2D(depthTexture, uv).r;
    float3 color = tex2D(colorTexture, uv).rgb;

    float depth_edge = abs(ddy(depth)) * 100.0; // 深度边缘
    float color_edge = length(ddy(color)) * 10.0; // 颜色边缘

    return saturate(max(depth_edge, color_edge));
}

各向异性高光

各向异性高光效果模拟表面在特定方向反射光线的特性,如拉丝金属或头发材质。DDY 节点可以帮助确定高光的方向和强度:

// 简单的各向异性高光计算
float AnisotropicSpecular(float3 worldNormal, float3 viewDir, float2 uv)
{
    // 使用 UV 导数确定各向异性方向
    float2 deriv = float2(ddx(uv.x), ddy(uv.y));
    float anisotropy = length(deriv);

    // 基于导数方向调整高光
    float3 anisotropicDir = normalize(float3(deriv.x, deriv.y, 0));
    // 进一步的高光计算...

    return specular;
}

纹理采样优化

通过分析纹理坐标的导数,可以优化纹理采样,选择适当的 mipmap 级别,平衡质量和性能:

// 基于导数的自适应纹理采样
float4 AdaptiveTextureSample(sampler2D tex, float2 uv)
{
    // 计算纹理坐标的导数
    float2 duv_dx = ddx(uv);
    float2 duv_dy = ddy(uv);

    // 计算适当的 LOD 级别
    float lod = 0.5 * log2(max(dot(duv_dx, duv_dx), dot(duv_dy, duv_dy)));

    // 使用计算出的 LOD 进行采样
    return tex2Dlod(tex, float4(uv, 0, lod));
}

最佳实践和注意事项

为了确保 DDY 节点的正确使用和最佳性能,遵循一些最佳实践和注意事项非常重要。

平台兼容性

DDY 节点在不同平台和图形 API 上的支持程度可能有所差异:

  • 在所有现代桌面 GPU(DirectX 11+、Vulkan、Metal)上完全支持
  • 在移动平台上,需要 OpenGL ES 3.0+ 或 Vulkan 支持
  • 在较旧的硬件或图形 API 上可能有限制或性能问题
  • 在 WebGL 中,支持程度取决于浏览器和硬件能力

为了确保跨平台兼容性,建议:

  • 在图形设置中配置适当的回退方案
  • 使用 Shader Graph 的条件编译功能处理平台差异
  • 在移动平台上测试导数计算的性能影响

性能优化

虽然 DDY 节点本身很高效,但在复杂着色器中仍需注意性能:

  • 避免在循环或复杂控制流中过度使用 DDY 节点
  • 考虑复用导数计算结果,而不是重复计算
  • 对于简单的应用,考虑使用近似的分析方法代替精确的导数计算
  • 在性能敏感的平台,评估使用 DDY 节点的实际性能影响

数学精度考虑

导数计算对数值精度很敏感,特别是在 HDR 或高动态范围场景中:

  • 注意输入值的范围,过大的值可能导致导数计算不稳定
  • 在需要高精度的应用中,考虑使用更高精度的浮点数格式
  • 注意导数计算在 discontinuities(不连续点)处的行为可能不符合预期

调试和可视化

调试包含 DDY 节点的着色器可能具有挑战性,以下技巧可以帮助:

  • 使用 Color 节点将导数值可视化,检查其范围和分布
  • 创建调试视图,单独显示导数计算的结果
  • 使用适当的缩放和偏移,使导数值在可视范围内
  • 在简单测试案例中验证导数计算的行为

与其他节点的结合

DDY 节点通常与其他数学和工具节点结合使用,创建复杂的视觉效果:

  • 结合 DDX 节点获取完整的梯度信息
  • 使用数学节点对导数结果进行后处理
  • 与条件节点结合,创建基于导数阈值的效果
  • 在子图中封装复杂的导数计算逻辑,提高可重用性

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

深度起底 Vite:从打包流程到插件钩子执行时序的全链路解析

前言

Vite 之所以能颠覆 Webpack 的统治地位,不仅是因为它在开发阶段的“快”,更在于它巧妙地结合了 原生 ESMRollup 的生产构建能力。本文将带你拆解 Vite 打包的每一个步骤,并揭秘其插件系统的核心钩子。

一、 Vite 生产打包流水线

Vite 的生产构建完全基于 Rollup 实现,但在 Rollup 打包前后增加了 Vite 特有的预处理和后优化步骤,整个流程可以分为以下 6 个核心阶段:

步骤 1:加载并解析配置

Vite 会优先读取项目根目录的vite.config.js/ts(或vite.config.mjs)配置文件,同时合并命令行参数和默认配置,形成最终的运行配置。

  • 解析核心配置项:root(项目根目录)、base(公共基础路径)、build(打包配置,如输出目录、目标环境、代码分割等)、plugins(插件)、resolve(路径解析)等
  • 同时会读取package.json中的type: module等配置,确定项目的模块规范

配置合并优先级为:命令行参数 > 配置文件导出的配置 > Vite 内置默认配置

步骤 2:预构建与依赖分析

这是 Vite 区别于传统打包工具的关键步骤,主要为了解决第三方依赖的兼容性和打包性能问题。

  • Vite 会扫描项目中的所有依赖(主要是node_modules中的第三方包),对非 ES 模块的依赖(如 CommonJS、UMD 格式)进行预构建,统一转换为标准 ES 模块
  • 分析项目入口文件(默认是根目录的index.html),递归解析所有文件的依赖关系(包括.vue/.js/.ts/.css/.scss等各种类型的文件),构建完整的模块依赖图

补充:预构建的结果会被缓存到node_modules/.vite目录,只有当依赖发生变化时才会重新预构建,极大提升了后续打包速度

步骤 3:插件执行

Vite 会按照配置的顺序依次执行所有插件,并调用相应的插件生命周期钩子,在这个阶段完成各种文件转换、代码预处理和资源处理操作。

  • 插件会在不同的生命周期钩子中执行对应的逻辑,例如:

    • .vue/.jsx/.tsx等非原生 JS 文件进行编译转换
    • 小图片 / 字体等静态资源的 base64 转换(由 Vite 内置的 assets 插件处理)
    • CSS 预处理器编译、PostCSS 处理、CSS Modules 转换等

步骤 4:使用 Rollup 进行核心打包

这是生产构建的核心阶段,Vite 将完全委托给 Rollup 进行代码打包和优化。

  • 按入口文件拆分代码块(chunk),同时支持根据动态导入import()自动拆分代码块,还可以通过配置将第三方依赖单独拆分为 vendor chunk
  • 生成兼容目标环境的代码:默认打包为现代浏览器支持的 ES 模块格式,也可通过build.target配置兼容 ES5 及更低版本
  • 处理模块间的依赖引用:将代码中的相对路径替换为配置的base路径,确保生产环境下所有资源都能正确加载
  • 执行 Rollup 特有的优化:包括 Tree Shaking 移除无用代码、作用域提升(Scope Hoisting)减少代码体积等

步骤 5:Rollup 产物最终优化

在 Rollup 完成基础打包后,Vite 会对输出的产物进行最后一轮的生产环境优化。

  • 对 JavaScript 和 CSS 代码进行压缩混淆(默认使用 Terser 压缩 JS,esbuild 压缩 CSS)
  • 生成资源哈希文件名,实现静态资源的长效缓存
  • 可选生成 sourcemap 文件,方便生产环境调试
  • 可选生成manifest.json文件,记录资源文件名与哈希后的文件名的映射关系,用于后端集成

步骤 6:输出最终产物

将所有打包和优化后的产物输出到指定的目录(默认是dist目录)。

  • 静态资源(JS、CSS、图片、字体等)会输出到dist/assets目录
  • 入口 HTML 文件会输出到dist根目录
  • 其他配置的静态资源会按照原目录结构输出到dist目录

二、 揭秘 Vite 插件钩子 (Hooks)

Vite 的插件系统扩展自 Rollup 的插件系统,同时增加了一些 Vite 特有的钩子函数,以支持开发服务器和热更新等 Vite 独有的功能。

2.1 通用 Rollup 兼容钩子

Vite 在开发阶段会模拟 Rollup 的行为,调用一系列与 Rollup 兼容的钩子;而在生产环境下,由于 Vite 直接使用 Rollup 进行打包,所有 Rollup 插件钩子都会生效。

这些通用钩子主要分为三个阶段:

  1. 服务器启动阶段optionsbuildStart钩子会在开发服务启动时被调用
  2. 请求响应阶段:当浏览器发起请求时,Vite 内部依次调用resolveIdloadtransform钩子
  3. 服务器关闭阶段:Vite 会依次执行buildEndcloseBundle钩子

重要说明:除了以上钩子,其他 Rollup 插件钩子(如moduleParsedrenderChunk等)均不会在 Vite 开发阶段调用。这是因为开发阶段 Vite 采用按需编译的模式,不需要对整个项目进行完整打包。

2.2 Vite 独有钩子

Vite 提供了一些独有的钩子函数,这些钩子只会在 Vite 内部调用,放到纯 Rollup 环境中会被直接忽略。

钩子名称 调用时机 主要用途
config Vite 读取完配置文件之后,最终配置合并之前 对用户导出的配置对象进行自定义修改,例如注入默认值、根据环境动态调整配置、合并插件配置等
configResolved Vite 完成最终配置解析之后 此时配置已完全合并且不可再修改,常用于获取最终配置进行调试,或根据最终配置初始化插件内部状态
configureServer 仅在开发服务器启动时调用 扩展或拦截开发服务器行为,例如添加自定义中间件、模拟 API 接口、修改服务器配置、实现请求代理等
transformIndexHtml 转换原始 HTML 文件内容时调用 动态修改 index.html 内容,例如注入脚本标签、修改 meta 标签、添加全局变量、SSR 内容注入等
handleHotUpdate Vite 服务端处理热更新时调用 自定义热更新逻辑,例如过滤不需要热更新的模块、触发自定义的热更新行为、向客户端发送自定义消息等

三、 核心:钩子函数的执行顺序

理解执行顺序是编写高质量插件的前提。

1. 服务启动阶段

configconfigResolvedoptionsconfigureServerbuildStart

2. 请求响应阶段

  • HTML 请求:仅执行 transformIndexHtml
  • 非 HTML 请求 (JS/CSS等):resolveIdloadtransform

3. 热更新与关闭阶段

  • HMR 触发handleHotUpdate
  • 服务关闭buildEndcloseBundle


四、 知识扩展:开发与生产的差异

  • 开发阶段:Vite 利用浏览器原生 ESM,只有在文件被请求时才触发 transform 钩子,这是其“快”的底层逻辑。
  • 生产阶段:Vite 完完全全变成了一个“Rollup 配置封装器”,所有插件钩子都会遵循 Rollup 的打包逻辑执行。

Superpowers 从“调教提示词”转向“构建工程规范”

前言

最近留意到github上有个编程skills比较火爆superpowers , 很多人第一眼会觉得 superpowers 是一个“AI 编程插件”, 但我更倾向把它理解为一套约束 AI 行为的执行协议(Execution Protocol), 什么意思?在 Cursor 或 Codex 里:你输入 prompt, AI 自由发挥,本质是:无约束生成。而 superpowers 做的事情是:

  • 把整个过程拆成多个阶段
  • 每个阶段有明确输入 / 输出
  • 严格限制 AI 当前“能做什么”

一、 我们正在面临的“AI 协作困境”

在superpowers实现前,先聊聊目前 AI 编程里最扎心的三个痛点:

  1. 认知边界的迷失 (Context Drift): AI 是个典型的“局部主义者”。在几万行的代码库里,它往往只盯着你喂给它的那几个文件。它不知道你项目里已经封装好了 axios,于是自作聪明地又写了一个。结果就是:项目跑通了,但代码复用度降低了。
  2. 调试的“黑盒”陷阱: 当代码报错时,AI 最常见的反应是“复读机式修复”。它会说“抱歉,我漏了一个判空”,改完报错;再说“抱歉,可能是异步问题”,再改。这种 Trial-and-Error(盲碰)的模式,不仅浪费 Token,更是在消磨开发者的耐心。
  3. 审查负担 (Review Fatigue): 现在最累的不是写代码,是 Review。AI 甩给你 500 行改动,你敢合吗?因为缺乏可信的验证路径,你甚至觉得 Review 它的代码比自己重写一遍还累。

二、 核心哲学:工程纪律大于模型能力

superpowers 的核心逻辑是:与其期待模型更聪明,不如让它的行为更规范。

在没有 superpowers 之前,AI 编程助手(如 Cursor、Claude)更像是一个极速打字员,而有了它之后,AI 变成了一个资深工程师

2.1 强制性的“先思后行”(The Thinking Protocol)

  • 普通 AI: 看到需求直接开改,改完发现引出 3 个新 Bug。
  • Superpowers AI: 被禁止直接动代码。它必须先调用 research 技能查阅现有逻辑,再调用 plan 技能产出方案,最后才动手。这种认知顺序的强制化,极大地减少了“重构灾难”。

2.2 系统化调试(Systematic Debugging)

这是该项目最硬核的优势。它将调试从“碰运气”升级为“科学实验”:

  • 它要求 AI 必须通过 Observation(观察) -> Hypothesis(假设) -> Verification(验证) 的闭环。
  • AI 不能只说“修好了”,必须提供测试通过的证据。

2.3 环境隔离与安全(Isolation)

利用 git-worktree 技能,AI 的所有实验性修改都在平行空间进行。这让开发者敢于让 AI 进行大规模重构,因为一键即可回滚,主开发分支永远处于受保护状态。


三、 技术底层:它是如何“驯服”AI 的?

superpowers 的底层逻辑并非改变了 LLM(大模型)本身,而是通过工程抽象重新定义了人机交互的边界。

3.1 状态机模型(State Machine)

superpowers 将软件开发抽象成了一个有状态的流程。它定义了不同的“运行等级”:

  • Level 0: Context Gathering(只允许搜索和读取)

  • Level 1: Planning(产出文档,禁止改码)

  • Level 2: Execution(激活文件写入,配合 TDD)

    这种物理层面的权限截断,确保了 AI 不会在还没搞懂逻辑时就开始乱写。

3.2 Skill 作为“原子化执行协议”

在源码中,每一个 .md 文件(如 systematic-debugging.md)都是一个 Skill 定义。它的原理包括:

  • JSON Schema 绑定: 每个技能对应一组工具调用指令。当 AI 调用技能时,它必须填入符合规范的参数。
  • 反馈闭环: 技能执行后,结果会以标准格式返回给 AI。如果 AI 没有按照协议(协议要求先写测试),系统会拒绝执行后续动作。

3.3 基于“上下文修剪”的精准控制

加载全量技能会造成 Token 溢出且让 AI 产生干扰。superpowers 的实现逻辑是:

  • 动态注入: 根据当前任务阶段,动态地将相关的 SKILLS.md 内容注入到 System Message 中。
  • 少即是多: 限制 AI 在特定时刻能看到的“超能力”,反而提高了它的执行精度。

2.4 为什么它更稳定?

我们可以用一个公式来总结它的原理:

Stability=(LLM Reasoning)×(Engineering Constraints)nStability = (LLM\ Reasoning) \times (Engineering\ Constraints)^n

  • 模型(LLM): 提供推理基础。
  • 约束(Constraints):superpowers 提供。

它通过把软件工程的最佳实践(Best Practices)转化为模型必须遵守的原子化指令(Atomic Skills) ,实现了从“概率性代码生成”向“确定性工程闭环”的跨越。

一句话总结: superpowers 的核心不是给 AI 自由,而是给 AI 划定了一条“通往成功的狭窄路径”。

四、 实战:AI 视角下的“重构三部曲”

假设我们要重构一个逻辑混乱的老旧模块,在 superpowers 的加持下,AI 的执行路径是这样的:

4.1:Brainstorming(拒绝直接上手)

AI 接收任务后,第一步是调用 searchlist_files 摸清家底。它会先输出一个“重构提案”,问你: “我发现这里有三个依赖项,我打算先解耦 A 再改动 B,你觉得呢?” 这种先提问、出方案的习惯,像极了靠谱的架构师。

4.2 环境隔离 (Git Worktrees)

为了保证安全,它会自动创建一个 Git Worktree。这意味着 AI 的所有折腾都在一个隔离的平行空间进行。即便它把代码改得一塌糊涂,也不会影响你当前的工作区。这种自动化运维的能力,让 AI 真正具备了“独立作业”的条件。

4.3 根因追踪 (Systematic Debugging)

遇到一个深层 Bug 时,AI 不再盲目改代码,而是开始执行“系统化观测”。它会先插入 Trace 日志,观察数据流,确认假设后才落笔。这种逻辑闭环,让它的修复成功率呈指数级提升。


五、 总结:AI 时代,我们该学什么?

随着 superpowers 这类工具的成熟,开发者的角色正在发生深刻转变:

我们不再是写代码的人,而是定义“产品”和检查“实现”的人。

与其在提示词里求 AI “请写得好一点”,不如像 superpowers 这样,给它一套标准化的技能说明书。如果你所在的团队有特殊的开发规范(比如特定的内部库或部署流程),你完全可以基于它的框架,自定义一套私有的 Skills。

好的工具不应该给 AI 自由,而应该给 AI 边界。 这或许就是 AI 编程工程化的终极答案。

❌