普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月26日首页

前端性能优化实战指南:从原理到落地的全方位解决方案

2026年4月25日 16:40

🚀 前端性能优化实战指南:从原理到落地的全方位解决方案

"让页面飞起来"不仅是一句口号,更是用户体验的基石。本文将从实际监控数据出发,系统讲解前端性能优化的核心策略,包括分包加载、缓存策略、预加载预连接、JS 异步加载等关键技术,并附上 Chrome DevTools 性能分析的完整步骤。

📊 前言:为什么需要性能优化?

根据 Google 的研究数据:

  • 页面加载时间超过 3 秒,53% 的用户会放弃访问
  • 页面加载延迟每增加 1 秒,转化率下降 7%
  • Core Web Vitals 已成为 Google 搜索排名的重要因素

在实际项目中,我们通过自研的 webSdk 监控系统发现:

// 监控数据示例(来自 webSdk)
{
  type: 'performance',
  subType: 'lcp',
  startTime: 4832.5,  // LCP 时间: 4.8 秒(超过 Google 建议的 2.5 秒)
  pageUrl: 'https://example.com/product-list',
  // ...
}

这个 LCP 数据表明页面存在严重的性能问题。接下来,我们将通过实际代码和案例,系统讲解如何优化。

不知道这个sdk的项目的请移步前一篇文章:从零实现一个前端监控系统:性能、错误与用户行为全方位监控


🎯 一、性能指标体系:我们需要关注什么?

1.1 Core Web Vitals 核心指标

Google 推出的 Core Web Vitals 是衡量用户体验的三大核心指标:

指标 全称 含义 良好标准 需改进
LCP Largest Contentful Paint 最大内容绘制时间 ≤ 2.5s 2.5s - 4s > 4s
INP Interaction to Next Paint 交互到下一次绘制 ≤ 200ms 200ms - 500ms > 500ms
CLS Cumulative Layout Shift 累积布局偏移 ≤ 0.1 0.1 - 0.25 > 0.25

1.2 如何采集性能指标?

通过 webSdk 的性能监控模块,我们可以自动采集这些指标:

// src/performance/observeLCP.js - LCP 监控实现
import { lazyReportBatch } from '../report';

export default function observerLCP() {
    const entryHandler = (list) => {
        if (observer) {
            observer.disconnect(); // 只采集最终值
        }

        for (const entry of list.getEntries()) {
            const reportData = {
                ...entry.toJSON(),
                type: 'performance',
                subType: 'lcp',
                pageUrl: window.location.href,
            };
            lazyReportBatch(reportData);
        }
    };

    const observer = new PerformanceObserver(entryHandler);
    observer.observe({ type: 'largest-contentful-paint', buffered: true });
}

关键点解析:

  • 使用 PerformanceObserver API 监听性能事件
  • buffered: true 确保能观察到页面加载过程中已发生的性能事件
  • LCP 可能会多次触发,需要监听最终值
  • 通过 lazyReportBatch 批量上报,减少网络请求

💾 二、缓存策略:让资源"常住"浏览器

2.1 浏览器缓存机制全景图

┌─────────────────────────────────────────────────────────┐
│                  浏览器缓存查找流程                       │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  用户请求 → Service Worker Cache?                       │
│                 ↓ 否                                    │
│            Memory Cache?                                │
│                 ↓ 否                                    │
│            Disk Cache?                                  │
│                 ↓ 否                                    │
│            网络请求 → 响应缓存策略                       │
│                                                         │
└─────────────────────────────────────────────────────────┘

2.2 HTTP 缓存头配置实战

2.2.1 强缓存:资源不发请求
# nginx.conf - 静态资源强缓存配置
location ~* \.(js|css|png|jpg|jpeg|gif|ico|woff|woff2|ttf|eot|svg)$ {
    expires 1y;                    # 过期时间 1 年
    add_header Cache-Control "public, immutable";
    # public: 可以被任何缓存存储(包括 CDN)
    # immutable: 资源永不变化,浏览器不会发送条件请求
}

效果:

  • 浏览器在缓存有效期内完全不发送请求,直接从本地读取
  • 配合文件名 hash(app.abc123.js),实现永久缓存
2.2.2 协商缓存:节省带宽
# nginx.conf - HTML 文件协商缓存
location ~* \.html$ {
    add_header Cache-Control "no-cache";
    # 每次都发送请求,但可通过 304 节省带宽
    etag on;                      # 开启 ETag
    if_modified_since_exact on;   # 精确匹配 Last-Modified
}

工作原理:

  1. 首次请求:服务器返回 ETag: "abc123"Last-Modified: Wed, 21 Oct 2025 07:28:00 GMT
  2. 再次请求:浏览器发送 If-None-Match: "abc123"If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT
  3. 服务器检查资源未变化,返回 304 Not Modified(节省带宽,浏览器使用缓存)

2.3 Service Worker 缓存:离线也能访问

// sw.js - Service Worker 缓存策略
const CACHE_NAME = 'app-v1';
const ASSETS = [
    '/',
    '/index.html',
    '/static/js/app.js',
    '/static/css/style.css'
];

// 安装阶段:预缓存关键资源
self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_NAME)
            .then(cache => cache.addAll(ASSETS))
            .then(() => self.skipWaiting())
    );
});

// 请求拦截:缓存优先策略
self.addEventListener('fetch', (event) => {
    event.respondWith(
        caches.match(event.request)
            .then(cached => {
                // 缓存命中,直接返回
                if (cached) return cached;

                // 缓存未命中,从网络获取并缓存
                return fetch(event.request)
                    .then(response => {
                        // 只缓存成功响应
                        if (!response || response.status !== 200) {
                            return response;
                        }

                        // 克隆响应用于缓存(响应流只能使用一次)
                        const responseToCache = response.clone();
                        caches.open(CACHE_NAME)
                            .then(cache => cache.put(event.request, responseToCache));

                        return response;
                    });
            })
    );
});

缓存策略选择指南:

策略 适用场景 示例
Cache First 静态资源(JS/CSS/图片) CDN 上的第三方库
Network First 需要实时性的数据 API 请求
Stale While Revalidate 可接受短暂过期 用户信息、配置数据
Network Only 必须最新 支付、订单状态
Cache Only 永不更新 字体文件、离线页面

2.4 实战效果对比

通过 webSdk 监控的数据:

// 优化前:无缓存策略
{
  type: 'performance',
  subType: 'xhr',
  url: '/api/user-info',
  duration: 320,      // 320ms
  status: 200,
  // 每次请求都需要等待服务器响应
}

// 优化后:Service Worker 缓存
{
  type: 'performance',
  subType: 'xhr',
  url: '/api/user-info',
  duration: 12,       // 12ms(从 Cache Storage 读取)
  status: 200,
  // 速度提升 26 倍!
}

📦 三、代码分包:告别"巨无霸"JS 文件

3.1 为什么需要分包?

一个未优化的 React 应用打包结果:

dist/
└── app.js  (3.5MB 😱)
    ├── React 核心代码 (150KB)
    ├── React DOM (800KB)
    ├── 业务代码 (500KB)
    ├── 第三方库 (2MB)
    └── 其他依赖 (50KB)

问题:

  • 用户访问首页,却要下载整个应用的代码
  • 首屏加载时间过长,影响 LCP 指标
  • 修改一行代码,用户需要重新下载 3.5MB

3.2 Webpack 分包配置实战

// webpack.config.js
module.exports = {
  optimization: {
    splitChunks: {
      chunks: 'all',              // 对所有模块进行分包
      minSize: 20000,             // 最小 20KB 才分包
      minChunks: 1,               // 至少被引用 1 次
      maxAsyncRequests: 30,       // 按需加载最大并行请求数
      maxInitialRequests: 30,     // 入口点最大并行请求数
      cacheGroups: {
        // 第三方库单独打包
        vendors: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          chunks: 'all',
          priority: 10            // 优先级
        },
        // React 生态单独打包
        react: {
          test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
          name: 'react',
          chunks: 'all',
          priority: 20            // 更高优先级
        },
        // 公共模块提取
        common: {
          name: 'common',
          minChunks: 2,           // 至少被 2 个 chunk 引用
          chunks: 'all',
          priority: 5,
          reuseExistingChunk: true // 复用已存在的 chunk
        }
      }
    },
    // 运行时代码单独打包
    runtimeChunk: {
      name: 'runtime'
    }
  }
};

分包结果:

dist/
├── runtime.js        (5KB)    ← Webpack 运行时
├── react.js          (300KB)  ← React 核心
├── vendors.js        (800KB)  ← 第三方库
├── common.js         (150KB)  ← 公共模块
├── home.js           (50KB)   ← 首页业务代码
├── product.js        (80KB)   ← 产品页业务代码
└── user.js           (30KB)   ← 用户页业务代码

3.3 路由懒加载:按需加载页面

// router.js - React 路由懒加载
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Loading from './components/Loading';

// 懒加载页面组件
const Home = lazy(() => import('./pages/Home'));
const Product = lazy(() => import('./pages/Product'));
const User = lazy(() => import('./pages/User'));
const About = lazy(() => import('./pages/About'));

function Router() {
  return (
    <BrowserRouter>
      <Suspense fallback={<Loading />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/product" element={<Product />} />
          <Route path="/user" element={<User />} />
          <Route path="/about" element={<About />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  );
}

加载流程:

用户访问首页
  ↓
加载: runtime.js + react.js + vendors.js + common.js + home.js
  ↓
用户点击"产品"页面
  ↓
动态加载: product.js (其他 chunk 已缓存)
  ↓
几乎瞬间完成!

3.4 Vite 分包配置(Vue 项目实战)

// vite.config.js - webSdk 测试项目配置
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  build: {
    rollupOptions: {
      output: {
        // 分包策略
        manualChunks: {
          // Vue 生态
          'vue-vendor': ['vue', 'vue-router', 'vuex'],
          // UI 库
          'ui-vendor': ['element-plus', 'ant-design-vue'],
          // 工具库
          'utils': ['lodash-es', 'dayjs', 'axios']
        }
      }
    },
    // 代码分割阈值
    chunkSizeWarningLimit: 500  // 超过 500KB 警告
  }
});

3.5 分包效果监控

通过 webSdk 监控资源加载:

// src/performance/observerEntries.js - 资源加载监控
import { lazyReportBatch } from '../report';

export default function observerEntries() {
    if (PerformanceObserver) {
        const observer = new PerformanceObserver((list) => {
            for (const entry of list.getEntries()) {
                if (entry.entryType === 'resource') {
                    const reportData = {
                        type: 'performance',
                        subType: 'resource',
                        name: entry.name,           // 资源 URL
                        duration: entry.duration,   // 加载耗时
                        size: entry.transferSize,   // 传输大小
                        initiatorType: entry.initiatorType,  // 资源类型
                        pageUrl: window.location.href
                    };
                    lazyReportBatch(reportData);
                }
            }
        });

        observer.observe({ entryTypes: ['resource'] });
    }
}

优化效果对比:

指标 优化前 优化后 提升
首屏 JS 大小 3.5MB 450KB 87%↓
首屏加载时间 4.8s 1.2s 75%↓
LCP 5.2s 1.8s 65%↓
二次访问 1.2s 0.3s 75%↓

⚡ 四、预加载与预连接:抢占先机

4.1 Preload:预加载关键资源

<link rel="preload"> 告诉浏览器当前页面一定会用到的资源,需要优先加载。

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>电商首页</title>

    <!-- 预加载关键字体 -->
    <link rel="preload" href="/fonts/roboto.woff2" as="font" type="font/woff2" crossorigin>

    <!-- 预加载首屏渲染必需的 CSS -->
    <link rel="preload" href="/css/critical.css" as="style">

    <!-- 预加载关键 JS -->
    <link rel="preload" href="/js/app.js" as="script">

    <!-- 预加载首屏大图 -->
    <link rel="preload" href="/images/hero-banner.jpg" as="image">
</head>
<body>
    <!-- 页面内容 -->
</body>
</html>

关键属性解析:

  • as: 指定资源类型,浏览器会设置正确的优先级
  • crossorigin: 字体资源必需,否则会二次加载
  • type: 指定 MIME 类型,浏览器可提前判断是否支持

使用场景:

资源类型 是否推荐 Preload 原因
字体 ✅ 强烈推荐 避免文字闪烁(FOIT/FOUT)
关键 CSS ✅ 推荐 加速首屏渲染
首屏图片 ✅ 推荐 加速 LCP
非首屏 JS ❌ 不推荐 可能阻塞其他资源
第三方库 ❌ 不推荐 使用 Preconnect 更合适

4.2 Prefetch:预加载未来可能需要的资源

<link rel="prefetch"> 告诉浏览器下一页可能用到的资源,在空闲时加载。

<!-- 用户很可能访问产品页 -->
<link rel="prefetch" href="/js/product.js" as="script">

<!-- 预加载产品页数据 -->
<link rel="prefetch" href="/api/product-list" as="fetch" crossorigin>

动态 Prefetch(智能预加载):

// 智能预加载:鼠标悬停时预加载
document.querySelectorAll('a[href^="/product"]').forEach(link => {
    link.addEventListener('mouseenter', () => {
        const prefetchLink = document.createElement('link');
        prefetchLink.rel = 'prefetch';
        prefetchLink.href = '/js/product.js';
        document.head.appendChild(prefetchLink);
    }, { once: true });  // 只触发一次
});

Preload vs Prefetch 对比:

特性 Preload Prefetch
作用范围 当前页面 未来页面
优先级 低(空闲时加载)
缓存位置 内存缓存 磁盘缓存
使用场景 首屏关键资源 路由预加载
不使用后果 阻塞渲染 无影响

4.3 Preconnect:预建立连接

第三方域名(如 CDN、API 服务器)需要 DNS 查询、TCP 握手、TLS 协商,耗时可能超过 500ms

<head>
    <!-- 预连接到 CDN -->
    <link rel="preconnect" href="https://cdn.example.com">

    <!-- 预连接到 API 服务器 -->
    <link rel="preconnect" href="https://api.example.com">

    <!-- 预连接到第三方字体服务 -->
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
</head>

DNS Prefetch:仅预解析 DNS

<!-- 仅解析 DNS,不建立连接(优先级更低) -->
<link rel="dns-prefetch" href="https://analytics.google.com">
<link rel="dns-prefetch" href="https://tracking.example.com">

Preconnect vs DNS Prefetch:

特性 Preconnect DNS Prefetch
DNS 解析
TCP 握手
TLS 协商
耗时 较高(立即执行) 较低
适用场景 关键第三方 非关键第三方

4.4 实战案例:电商首页优化

优化前:

<!-- 无任何预加载策略 -->
<!DOCTYPE html>
<html>
<head>
    <title>电商首页</title>
    <link rel="stylesheet" href="/css/app.css">
</head>
<body>
    <script src="/js/app.js"></script>
</body>
</html>

性能问题:

  • 字体加载导致文字闪烁(FOIT)
  • 图片加载慢,影响 LCP
  • 首屏渲染被阻塞
  • API 请求延迟高

优化后:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>电商首页</title>

    <!-- 1. 预连接到关键域名(立即执行) -->
    <link rel="preconnect" href="https://cdn.example.com">
    <link rel="preconnect" href="https://api.example.com">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

    <!-- 2. 预加载关键资源(高优先级) -->
    <link rel="preload" href="/fonts/roboto.woff2" as="font" type="font/woff2" crossorigin>
    <link rel="preload" href="/css/critical.css" as="style">
    <link rel="preload" href="/images/hero-banner.webp" as="image" imagesrcset="/images/hero-banner-mobile.webp 480w, /images/hero-banner.webp 1920w">

    <!-- 3. 内联关键 CSS(首屏渲染必需) -->
    <style>
        /* 首屏渲染必需的 CSS(约 14KB) */
        .header { /* ... */ }
        .hero-banner { /* ... */ }
    </style>

    <!-- 4. 异步加载非关键 CSS -->
    <link rel="stylesheet" href="/css/app.css" media="print" onload="this.media='all'">

    <!-- 5. 预加载未来可能访问的页面 -->
    <link rel="prefetch" href="/js/product.js" as="script">
</head>
<body>
    <!-- 6. 预加载关键 JS(使用 defer) -->
    <script src="/js/app.js" defer></script>

    <!-- 7. 预获取数据(低优先级) -->
    <script>
        // 页面加载完成后预获取产品数据
        window.addEventListener('load', () => {
            fetch('/api/product-list')
                .then(res => res.json())
                .then(data => window.__prefetchedData__ = data);
        });
    </script>
</body>
</html>

优化效果:

指标 优化前 优化后 提升
DNS + TCP + TLS 580ms 0ms -580ms
字体加载时间 420ms 85ms 80%↓
LCP 3.2s 1.4s 56%↓
首屏渲染 2.8s 0.9s 68%↓

🔄 五、JS 加载策略:async vs defer

5.1 三种 JS 加载方式对比

<!-- 1. 普通 script:阻塞渲染 -->
<script src="/js/app.js"></script>

<!-- 2. async:异步加载,加载完立即执行 -->
<script src="/js/analytics.js" async></script>

<!-- 3. defer:异步加载,HTML 解析完成后按顺序执行 -->
<script src="/js/app.js" defer></script>

执行时机对比图:

┌─────────────────────────────────────────────────────────────┐
│                        普通 script                           │
├─────────────────────────────────────────────────────────────┤
│  HTML 解析 ────┐                                             │
│                ↓                                             │
│         暂停解析,下载 JS                                      │
│                ↓                                             │
│            执行 JS                                           │
│                ↓                                             │
│         继续解析 HTML ────┐                                   │
│                          ↓                                   │
│                    DOMContentLoaded                          │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                        async script                          │
├─────────────────────────────────────────────────────────────┤
│  HTML 解析 ────────────────────────┐                         │
│         ↑                          ↓                         │
│    异步下载 JS ────┐         下载完成                         │
│                    ↓               ↓                         │
│               暂停解析,执行 JS ────┘                          │
│                    ↓                                         │
│              继续解析 HTML                                   │
│                    ↓                                         │
│              DOMContentLoaded                                │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│                        defer script                          │
├─────────────────────────────────────────────────────────────┤
│  HTML 解析 ────────────────────────────────────┐             │
│         ↑                                      ↓             │
│    异步下载 JS ────┐                      解析完成           │
│                    ↓                           ↓             │
│               (等待中)                    执行 JS            │
│                                              ↓               │
│                                        DOMContentLoaded      │
└─────────────────────────────────────────────────────────────┘

5.2 async:适合独立脚本

<!-- 统计脚本:不依赖 DOM,不影响页面功能 -->
<script src="https://tongji-example.com/js" async></script>

<!-- 广告脚本:独立运行,不阻塞页面 -->
<script src="https://guanggao-example.com/pagead/js" async></script>

<!-- 第三方 SDK:不依赖页面结构 -->
<script src="https://cdn.jsdelivr.net/npm/sdk@latest/dist/sdk.min.js" async></script>

适用场景:

  • 页面访问统计(Google Analytics、百度统计)
  • 广告脚本
  • 社交分享按钮
  • 第三方 SDK(不依赖 DOM)

特点:

  • 异步加载,不阻塞 HTML 解析
  • 下载完成立即执行,可能中断 HTML 解析
  • 执行顺序不确定(谁先下载完谁先执行)
  • 会在 window.onload 之前执行

5.3 defer:适合依赖 DOM 的脚本

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>电商应用</title>

    <!-- 多个 defer script 按顺序执行 -->
    <script src="/js/jquery.js" defer></script>
    <script src="/js/vue.js" defer></script>
    <script src="/js/app.js" defer></script>
</head>
<body>
    <div id="app">
        <!-- 应用内容 -->
    </div>

    <!-- defer script 可放在任意位置,都会在 DOMContentLoaded 前执行 -->
    <script src="/js/feature.js" defer></script>
</body>
</html>

适用场景:

  • 应用主逻辑(需要操作 DOM)
  • 依赖其他库的脚本
  • 需要按顺序执行的脚本
  • 初始化代码

特点:

  • 异步加载,不阻塞 HTML 解析
  • HTML 解析完成后才执行(在 DOMContentLoaded 之前)
  • 多个 defer script 按书写顺序执行
  • 可以放在 <head> 中,不用等 DOM 加载完

5.4 实战配置方案

方案一:关键 JS 使用 defer
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>应用</title>

    <!-- 关键 CSS 内联 -->
    <style>
        /* 首屏渲染必需的 CSS(约 14KB) */
        .header { /* ... */ }
        .main { /* ... */ }
    </style>

    <!-- 关键 JS 使用 defer -->
    <script src="/js/runtime.js" defer></script>
    <script src="/js/vendors.js" defer></script>
    <script src="/js/app.js" defer></script>
</head>
<body>
    <div id="app"></div>
</body>
</html>
方案二:非关键 JS 使用 async
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>应用</title>

    <!-- 关键 JS 使用 defer -->
    <script src="/js/app.js" defer></script>

    <!-- 非关键 JS 使用 async -->
    <script src="/js/analytics.js" async></script>
    <script src="/js/ads.js" async></script>
</head>
<body>
    <!-- 页面内容 -->
</body>
</html>
方案三:动态加载非关键 JS
// 动态加载非关键脚本
function loadScript(src, async = true) {
    return new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = src;
        script.async = async;
        script.onload = resolve;
        script.onerror = reject;
        document.head.appendChild(script);
    });
}

// 页面加载完成后加载非关键脚本
window.addEventListener('load', async () => {
    // 延迟加载统计脚本
    await loadScript('/js/analytics.js');

    // 延迟加载广告脚本
    await loadScript('/js/ads.js');

    // 延迟加载聊天插件
    await loadScript('/js/chat.js');
});

5.5 async vs defer 选择指南

场景 推荐方案 原因
应用主逻辑 defer 需要 DOM,需按顺序执行
第三方库(jQuery、Vue) defer 应用代码依赖,需先执行
统计脚本 async 独立运行,不依赖 DOM
广告脚本 async 独立运行,不阻塞页面
A/B 测试脚本 async 尽早执行,不影响页面
社交分享按钮 async 非关键功能,独立运行
聊天插件 动态加载 非关键功能,页面加载后加载

🎨 六、CSS 优化:消除渲染阻塞

6.1 Critical CSS:内联首屏样式

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>应用</title>

    <!-- 内联关键 CSS(首屏渲染必需) -->
    <style>
        /* 首屏渲染必需的 CSS(约 14KB) */
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
        .header { height: 60px; background: #fff; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
        .hero { height: 500px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); }
        .main { max-width: 1200px; margin: 0 auto; padding: 20px; }
        /* ... 其他首屏样式 */
    </style>

    <!-- 异步加载非关键 CSS -->
    <link rel="preload" href="/css/app.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
    <noscript><link rel="stylesheet" href="/css/app.css"></noscript>
</head>
<body>
    <!-- 页面内容 -->
</body>
</html>

关键 CSS 提取工具:

# 使用 Penthouse 提取关键 CSS
npm install penthouse --save-dev

# 或使用 Critical
npm install critical --save-dev
// critical.config.js
const critical = require('critical');

critical.generate({
    inline: true,
    base: 'dist/',
    src: 'index.html',
    target: {
        html: 'index-critical.html',
        css: 'critical.css'
    },
    width: 1300,
    height: 900
});

6.2 字体优化:避免文字闪烁

<head>
    <!-- 1. 预连接到字体服务 -->
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>

    <!-- 2. 预加载关键字体 -->
    <link rel="preload" href="/fonts/roboto-regular.woff2" as="font" type="font/woff2" crossorigin>

    <!-- 3. 使用 font-display: swap -->
    <style>
        @font-face {
            font-family: 'Roboto';
            font-style: normal;
            font-weight: 400;
            font-display: swap;  /* 关键! */
            src: url('/fonts/roboto-regular.woff2') format('woff2');
        }

        /* 系统字体回退方案 */
        body {
            font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
        }
    </style>
</head>

font-display 选项对比:

行为 适用场景
swap 立即显示系统字体,字体加载后替换 推荐(最佳用户体验)
block 隐藏文字,最多等待 3 秒 不推荐(导致 FOIT)
fallback 隐藏文字 100ms,之后显示系统字体 可接受
optional 浏览器智能决定是否使用自定义字体 网络较差时推荐

字体加载监控(使用 webSdk):

// 监控字体加载性能
if (document.fonts) {
    document.fonts.ready.then(() => {
        const fontLoadTime = performance.now();
        console.log(`所有字体加载完成: ${fontLoadTime.toFixed(2)}ms`);

        // 上报字体加载时间
        lazyReportBatch({
            type: 'performance',
            subType: 'font-load',
            duration: fontLoadTime,
            pageUrl: window.location.href
        });
    });
}

🔍 七、Chrome DevTools 性能分析实战

7.1 Performance 面板:全面分析页面性能

步骤一:打开 Performance 面板
  1. F12 打开开发者工具
  2. 切换到 Performance 标签
  3. 点击录制按钮(圆点图标)或按 Ctrl/Cmd + E
  4. 操作页面或刷新页面
  5. 点击停止录制
步骤二:查看 Timeline 时间轴
┌─────────────────────────────────────────────────────────────────┐
│  Performance Timeline                                           │
├─────────────────────────────────────────────────────────────────┤
│  FPS   ▂▂▂▂▁▁▁▁▂▂▂  ← 帧率,低点表示卡顿                        │
│  CPU   ████████▓▓▓▓  ← CPU 使用率                               │
│  NET   ────████────  ← 网络请求                                 │
│  Heap  ▲▲▲▲▼▼▼▼▲▲▲  ← 堆内存变化                                │
├─────────────────────────────────────────────────────────────────┤
│  Main Thread                                                     │
│  ├─ Task (橙色)          ← JavaScript 执行                      │
│  ├─ (GC) (蓝色条纹)      ← 垃圾回收                              │
│  ├─ Layout (紫色)        ← 布局计算                              │
│  ├─ Paint (绿色)         ← 绘制                                  │
│  └─ Composite (绿色)     ← 合成                                  │
└─────────────────────────────────────────────────────────────────┘
步骤三:分析性能瓶颈

长任务识别:

长任务(Long Task):超过 50ms 的任务
├─ 红色标记:严重影响用户体验
├─ 橙色标记:需要优化
└─ 绿色标记:可接受

7.2 Network 面板:分析网络请求

关键指标
┌────────────────────────────────────────────────────────────┐
│  Network Waterfall                                          │
├────────────────────────────────────────────────────────────┤
│  Resource        | Size | Time | Waterfall                  │
│  ─────────────────────────────────────────────────────────│
│  index.html      | 2KB  | 45ms | ██                         │
│  app.js          | 1.2MB| 1.2s | ████████████               │
│  style.css       | 150KB| 320ms| ████                       │
│  hero.jpg        | 800KB| 890ms| ████████                   │
│  analytics.js    | 50KB | 450ms| █████                      │
└────────────────────────────────────────────────────────────┘

瀑布流颜色含义:

  • 白色: Waiting (TTFB) - 服务器响应时间
  • 浅绿色: Content Download - 内容下载时间
  • 深绿色: Initial connection - 建立连接
  • 橙色: SSL/TLS - 安全连接协商
  • 灰色: Stalled - 等待(浏览器限制并发连接数)

优化建议:

问题 优化方案
TTFB 过长 服务器优化、CDN 加速、缓存策略
下载时间长 资源压缩、代码分割、Gzip/Brotli
等待时间长 减少并发请求、使用 HTTP/2
连接时间长 Preconnect、减少第三方域名

7.3 Coverage 面板:查找未使用的代码

打开方式
  1. F12 打开开发者工具
  2. Ctrl/Cmd + Shift + P 打开命令面板
  3. 输入 Coverage,选择 Show Coverage
使用步骤
  1. 点击录制按钮(开始抓取覆盖率)
  2. 刷新页面或操作页面
  3. 查看结果
┌─────────────────────────────────────────────────────────────┐
│  Coverage Report                                             │
├─────────────────────────────────────────────────────────────┤
│  URL                 | Type | Total | Used | Unused | %     │
│  ──────────────────────────────────────────────────────────│
│  app.js              | JS   | 1.2MB | 450KB| 750KB  | 62.5%│
│  style.css           | CSS  | 150KB | 80KB | 70KB   | 46.7%│
│  vendor.js           | JS   | 800KB | 300KB| 500KB  | 62.5%│
│  main.css            | CSS  | 200KB | 120KB| 80KB   | 40%  │
└─────────────────────────────────────────────────────────────┘

优化建议:

  • 未使用的 JS:代码分割、Tree Shaking
  • 未使用的 CSS:删除无用样式、使用 CSS Modules

7.4 Lighthouse:全面性能审计

运行 Lighthouse
  1. F12 打开开发者工具
  2. 切换到 Lighthouse 标签
  3. 选择审计类别(Performance、Accessibility、Best Practices、SEO)
  4. 点击 Analyze page load
性能报告解读
┌─────────────────────────────────────────────────────────────┐
  Performance Score: 78/100                                   
├─────────────────────────────────────────────────────────────┤
  Core Web Vitals                                             
  ├─ LCP (Largest Contentful Paint): 2.8s 🟡                
  ├─ INP (Interaction to Next Paint): 180ms 🟢              
  └─ CLS (Cumulative Layout Shift): 0.05 🟢                 
├─────────────────────────────────────────────────────────────┤
  Opportunities                                               
  ├─ Eliminate render-blocking resources: Save 1.2s         
  ├─ Properly size images: Save 0.8s                         
  ├─ Minify JavaScript: Save 0.4s                            
  └─ Remove unused CSS: Save 0.3s                            
├─────────────────────────────────────────────────────────────┤
  Diagnostics                                                 
  ├─ Avoid enormous network payloads: 2.5MB                  
  ├─ Minimize main-thread work: 2.1s                         
  └─ Reduce JavaScript execution time: 1.5s                  
└─────────────────────────────────────────────────────────────┘

7.5 Memory 面板:内存泄漏排查

步骤一:拍摄堆快照
  1. F12 打开开发者工具
  2. 切换到 Memory 标签
  3. 选择 Heap snapshot
  4. 点击 Take snapshot
步骤二:对比快照
┌─────────────────────────────────────────────────────────────┐
│  Heap Snapshot Comparison                                    │
├─────────────────────────────────────────────────────────────┤
│  Constructor      | Retained Size | # New | # Deleted      │
│  ──────────────────────────────────────────────────────────│
│  Window           | 12.5MB        | 2     | 0              │
│  EventListener    | 8.3MB         | 156   | 12             │
│  Detached DOM     | 5.2MB         | 45    | 0  ← 内存泄漏! │
│  Closure          | 3.1MB         | 89    | 15             │
│  Array            | 2.8MB         | 234   | 180            │
└─────────────────────────────────────────────────────────────┘

常见内存泄漏模式:

// ❌ 错误:未清理的事件监听器
class Component {
    constructor() {
        window.addEventListener('resize', this.handleResize);
    }

    handleResize = () => {
        // ...
    }

    // 缺少销毁方法!
}

// ✅ 正确:清理事件监听器
class Component {
    constructor() {
        window.addEventListener('resize', this.handleResize);
    }

    handleResize = () => {
        // ...
    }

    destroy() {
        window.removeEventListener('resize', this.handleResize);
    }
}
// ❌ 错误:闭包导致的内存泄漏
function createHandler() {
    const largeData = new Array(1000000).fill('x');

    return function() {
        console.log(largeData.length);  // 持有 largeData 引用
    };
}

const handlers = [];
for (let i = 0; i < 100; i++) {
    handlers.push(createHandler());  // 每个闭包都持有 largeData
}

// ✅ 正确:避免不必要的闭包
function createHandler() {
    const length = 1000000;  // 只保存需要的值

    return function() {
        console.log(length);
    };
}

📈 八、监控与持续优化

8.1 使用 webSdk 建立性能监控体系

webSdk 是我们自研的前端监控系统,可以自动采集性能指标、错误信息和用户行为。

初始化 SDK:

// 安装
import monitor from './dist/monitor.js';

// 初始化
monitor.init({
    url: 'https://your-api.com/report',  // 上报接口
    appId: 'your-app-id',                // 应用 ID
    userId: 'user-123',                  // 用户 ID
    batchSize: 10,                       // 批量上报阈值
    isImageUpload: false                 // 是否使用图片上报
});

自动采集的性能指标:

// SDK 自动采集的数据示例
[
    {
        type: 'performance',
        subType: 'lcp',
        startTime: 1234.56,
        duration: 1234.56,
        pageUrl: 'https://example.com/'
    },
    {
        type: 'performance',
        subType: 'fcp',
        startTime: 856.23,
        duration: 856.23,
        pageUrl: 'https://example.com/'
    },
    {
        type: 'performance',
        subType: 'xhr',
        url: '/api/user-info',
        method: 'GET',
        status: 200,
        duration: 320,
        startTime: 1500.12,
        pageUrl: 'https://example.com/'
    }
]

8.2 性能预算与告警

// 性能预算配置
const PERFORMANCE_BUDGETS = {
    lcp: 2500,      // LCP ≤ 2.5s
    fcp: 1800,      // FCP ≤ 1.8s
    inp: 200,       // INP ≤ 200ms
    cls: 0.1,       // CLS ≤ 0.1
    tti: 3800,      // TTI ≤ 3.8s
    bundleSize: {
        js: 500000,     // JS 包 ≤ 500KB
        css: 100000,    // CSS 包 ≤ 100KB
        images: 2000000 // 图片总大小 ≤ 2MB
    }
};

// 性能监控与告警
function checkPerformanceBudget(metrics) {
    const violations = [];

    if (metrics.lcp > PERFORMANCE_BUDGETS.lcp) {
        violations.push({
            metric: 'LCP',
            value: metrics.lcp,
            budget: PERFORMANCE_BUDGETS.lcp,
            message: `LCP ${metrics.lcp}ms 超过预算 ${PERFORMANCE_BUDGETS.lcp}ms`
        });
    }

    // ... 检查其他指标

    if (violations.length > 0) {
        // 上报警告
        reportPerformanceViolation(violations);

        // 发送通知
        sendAlertToSlack(violations);
    }
}

8.3 持续监控看板

┌─────────────────────────────────────────────────────────────┐
  性能监控看板                              Last Updated: Now 
├─────────────────────────────────────────────────────────────┤
  Core Web Vitals (P95)                                       
  ┌─────────┬─────────┬─────────┐                           
   LCP      INP      CLS                                
   2.3s 🟢  150ms 🟢│ 0.08 🟢│                           
  └─────────┴─────────┴─────────┘                           
├─────────────────────────────────────────────────────────────┤
  资源加载耗时                                                
  ┌──────────────────────────────────────────────────────┐  
   JS Bundle: 1.2MB (-15% vs last week)                   
   CSS: 150KB (stable)                                    
   Images: 800KB (+5% vs last week)                       
  └──────────────────────────────────────────────────────┘  
├─────────────────────────────────────────────────────────────┤
  性能趋势 (最近 7 天)                                        
  LCP  ─────────────────────────────────────────────         
  2.5s        ╭───╮                                        
  2.0s     ╭──╯   ╰──╮                                     
  1.5s ┤────╯        ╰──────                                
       └──────────────────────────────────────────────      
└─────────────────────────────────────────────────────────────┘

🎯 九、优化效果总结

通过以上优化策略,我们在实际项目中取得了显著成效:

优化前后对比

指标 优化前 优化后 提升
LCP 5.2s 1.8s 65%↓
FCP 3.5s 0.9s 74%↓
INP 450ms 120ms 73%↓
CLS 0.25 0.05 80%↓
首屏 JS 大小 3.5MB 450KB 87%↓
首屏加载时间 4.8s 1.2s 75%↓
TTI 6.2s 2.1s 66%↓

优化措施清单

  • 代码分包:将 3.5MB 巨型 JS 拆分为多个小包,按需加载
  • 路由懒加载:用户访问页面时才加载对应代码
  • 缓存策略:Service Worker + HTTP 缓存,二次访问速度提升 75%
  • Preload/Prefetch:预加载关键资源,预加载下一页资源
  • Preconnect:预建立连接,节省 580ms 连接时间
  • JS defer:消除 JS 阻塞,首屏渲染提前 2.3s
  • Critical CSS:内联关键 CSS,首屏渲染提前 1.1s
  • 字体优化:使用 font-display: swap,避免文字闪烁
  • 图片优化:WebP 格式 + 响应式图片,图片大小减少 60%
  • 性能监控:webSdk 实时监控,持续优化

📚 十、参考资料与工具

官方文档

性能分析工具

  • Chrome DevTools: Performance、Network、Coverage、Memory、Lighthouse
  • Lighthouse: 综合性能审计
  • WebPageTest: 多地点真实浏览器测试
  • Google PageSpeed Insights: 在线性能分析
  • webSdk: 自研前端监控系统

推荐阅读


🎉 总结

前端性能优化是一个系统工程,需要从多个维度入手:

  1. 监控先行:建立性能监控体系,用数据驱动优化
  2. 缓存为王:充分利用浏览器缓存和 Service Worker
  3. 分包加载:代码分割 + 路由懒加载,减少首屏负担
  4. 预加载策略:Preload/Prefetch/Preconnect,抢占先机
  5. 异步加载:合理使用 async/defer,消除阻塞
  6. 持续优化:建立性能预算,持续监控与改进

性能优化不是一次性工作,而是需要持续关注和改进的过程。通过 webSdk 这样的监控系统,我们可以实时了解应用性能,及时发现和解决问题。


如果觉得本文有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!

昨天以前首页

从零实现一个前端监控系统:性能、错误与用户行为全方位监控

2026年4月19日 17:03

从零实现一个前端监控系统:性能、错误与用户行为全方位监控

深入探索前端监控 SDK 的实现原理,从性能指标采集、错误捕获到用户行为追踪,手把手教你打造一个企业级的前端监控方案。

前言

在现代 Web 应用中,前端监控是保障产品质量和用户体验的重要基石。一个完善的前端监控系统应该具备以下能力:

  • 性能监控:采集页面加载性能、接口请求耗时等关键指标
  • 错误监控:捕获 JS 错误、资源加载失败、Promise 异常等问题
  • 行为监控:追踪用户点击、页面跳转、PV/UV 等行为数据
  • 数据上报:高效、可靠地将数据发送到服务端

本文将基于 webEyeSDK 项目,详细讲解如何从零实现一个前端监控 SDK。

架构设计

整体架构

webEyeSDK
├── src/
│   ├── webEyeSDK.js        # SDK 入口文件
│   ├── config.js            # 配置管理
│   ├── report.js            # 数据上报
│   ├── cache.js             # 数据缓存
│   ├── utils.js             # 工具函数
│   ├── performance/         # 性能监控
│   │   ├── index.js
│   │   ├── observeLCP.js
│   │   ├── observerFCP.js
│   │   ├── observerLoad.js
│   │   ├── observerPaint.js
│   │   ├── observerEntries.js
│   │   ├── fetch.js
│   │   └── xhr.js
│   ├── error/               # 错误监控
│   │   └── index.js
│   └── behavior/            # 行为监控
│       ├── index.js
│       ├── pv.js
│       ├── onClick.js
│       └── pageChange.js

模块职责

模块 职责
Performance 采集页面性能指标(FCP、LCP、Load 等)
Error 捕获 JS 错误、资源错误、Promise 错误
Behavior 追踪用户行为(点击、页面跳转、PV)
Report 数据上报(支持 sendBeacon、XHR、Image)
Cache 数据缓存与批量上报

核心功能实现

一、性能监控

性能监控是前端监控的核心模块,主要通过 Performance APIPerformanceObserver 来采集关键指标。

1. FCP(首次内容绘制)

FCP(First Contentful Paint)测量页面首次渲染任何文本、图像等内容的时间。

import { lazyReportBatch } from '../report';

export default function observerFCP() {
    const entryHandler = (list) => {
        for (const entry of list.getEntries()) {
            if (entry.name === 'first-contentful-paint') {
                observer.disconnect();
                const json = entry.toJSON();
                const reportData = {
                    ...json,
                    type: 'performance',
                    subType: entry.name,
                    pageUrl: window.location.href,
                };
                lazyReportBatch(reportData);
            }
        }
    };

    // 统计和计算 FCP 的时间
    const observer = new PerformanceObserver(entryHandler);
    // buffered: true 确保观察到所有 paint 事件
    observer.observe({ type: 'paint', buffered: true });
}

核心要点

  • 使用 PerformanceObserver 监听 paint 类型的性能条目
  • buffered: true 确保能观察到页面加载过程中已发生的性能事件
  • 找到 first-contentful-paint 后立即断开监听,避免重复上报
2. LCP(最大内容绘制)

LCP(Largest Contentful Paint)测量视口内最大内容元素渲染的时间,是 Core Web Vitals 的重要指标。

import { lazyReportBatch } from '../report';

export default function observerLCP() {
    const entryHandler = (list) => {
        if (observer) {
            observer.disconnect();
        }
        for (const entry of list.getEntries()) {
            const json = entry.toJSON();
            const reportData = {
                ...json,
                type: 'performance',
                subType: entry.name,
                pageUrl: window.location.href,
            };
            lazyReportBatch(reportData);
        }
    };

    // 统计和计算 LCP 的时间
    const observer = new PerformanceObserver(entryHandler);
    observer.observe({ type: 'largest-contentful-paint', buffered: true });
}

LCP 特点

  • LCP 可能会多次触发(如最大内容元素改变),需要持续监听
  • 通常在用户交互或页面加载完成后才上报最终值
  • Google 建议 LCP 应在 2.5 秒以内
3. XHR/Fetch 请求监控

通过重写 XMLHttpRequest 原型方法,实现接口请求的性能监控。

import { lazyReportBatch } from '../report';

export const originalProto = XMLHttpRequest.prototype;
export const originalSend = originalProto.send;
export const originalOpen = originalProto.open;

function overwriteOpenAndSend() {
    originalProto.open = function newOpen(...args) {
        this.url = args[1];
        this.method = args[0];
        originalOpen.apply(this, args);
    };

    originalProto.send = function newSend(...args) {
        this.startTime = Date.now();
        const onLoaded = () => {
            this.endTime = Date.now();
            this.duration = this.endTime - this.startTime;

            const { url, method, startTime, endTime, duration, status } = this;
            const reportData = {
                status,
                duration,
                startTime,
                endTime,
                url,
                method: method.toUpperCase(),
                type: 'performance',
                success: status >= 200 && status < 300,
                subType: 'xhr'
            };

            lazyReportBatch(reportData);
            this.removeEventListener('loadend', onLoaded, true);
        };

        this.addEventListener('loadend', onLoaded, true);
        originalSend.apply(this, args);
    };
}

export default function xhr() {
    overwriteOpenAndSend();
}

实现原理

  • 重写 XMLHttpRequest.prototype.open 方法,记录请求 URL 和方法
  • 重写 XMLHttpRequest.prototype.send 方法,记录请求开始时间
  • 监听 loadend 事件,计算请求耗时并上报
4. 性能监控入口
import fetch from "./fetch";
import observerEntries from "./observerEntries";
import observerLCP from "./observeLCP";
import observerFCP from "./observerFCP";
import observerLoad from "./observerLoad";
import observerPaint from "./observerPaint";
import xhr from "./xhr";

export default function performance() {
    fetch();
    observerEntries();
    observerLCP();
    observerFCP();
    observerLoad();
    observerPaint();
    xhr();
}

二、错误监控

错误监控帮助开发者及时发现和定位线上问题,是保障应用稳定性的关键。

1. JS 运行时错误

通过 window.onerror 捕获 JavaScript 运行时错误。

window.onerror = function (msg, url, lineNo, columnNo, error) {
    const reportData = {
        type: 'error',
        subType: 'js',
        msg,
        url,
        lineNo,
        columnNo,
        stack: error.stack,
        pageUrl: window.location.href,
        startTime: performance.now(),
    };
    lazyReportBatch(reportData);
};

参数说明

  • msg:错误消息
  • url:发生错误的脚本 URL
  • lineNo:错误行号
  • columnNo:错误列号
  • error:Error 对象,包含堆栈信息
2. 资源加载错误

资源加载错误(如图片、CSS、JS 文件加载失败)需要通过事件捕获来监听。

window.addEventListener(
    'error',
    function (e) {
        const target = e.target;
        if (target.src || target.href) {
            const url = target.src || target.href;
            const reportData = {
                type: 'error',
                subType: 'resource',
                url,
                html: target.outerHTML,
                pageUrl: window.location.href,
                paths: e.path,
            };
            lazyReportBatch(reportData);
        }
    },
    true  // 使用捕获阶段
);

关键点

  • 必须在捕获阶段监听(第三个参数为 true),因为资源加载错误不会冒泡
  • 通过 e.target.srce.target.href 判断是否为资源错误
3. Promise 错误

Promise 中未被捕获的错误需要监听 unhandledrejection 事件。

window.addEventListener(
    'unhandledrejection',
    function (e) {
        const reportData = {
            type: 'error',
            subType: 'promise',
            reason: e.reason?.stack,
            pageUrl: window.location.href,
            startTime: e.timeStamp,
        };
        lazyReportBatch(reportData);
    },
    true
);
4. 框架错误捕获

Vue 错误捕获

export function install(Vue, options) {
    if (__webEyeSDK__.vue) return;
    __webEyeSDK__.vue = true;
    setConfig(options);

    const handler = Vue.config.errorHandler;
    Vue.config.errorHandler = function (err, vm, info) {
        const reportData = {
            info,
            error: err.stack,
            subType: 'vue',
            type: 'error',
            startTime: window.performance.now(),
            pageURL: window.location.href,
        };
        lazyReportBatch(reportData);

        if (handler) {
            handler.call(this, err, vm, info);
        }
    };
}

React 错误捕获

export function errorBoundary(err, info) {
    if (__webEyeSDK__.react) return;
    __webEyeSDK__.react = true;

    const reportData = {
        error: err?.stack,
        info,
        subType: 'react',
        type: 'error',
        startTime: window.performance.now(),
        pageURL: window.location.href,
    };
    lazyReportBatch(reportData);
}

使用方式

// Vue 项目
import webEyeSDK from './webEyeSDK';
Vue.use(webEyeSDK, { appId: 'xxx' });

// React 项目
import webEyeSDK from './webEyeSDK';
class ErrorBoundary extends React.Component {
    componentDidCatch(error, info) {
        webEyeSDK.errorBoundary(error, info);
    }
    render() {
        return this.props.children;
    }
}
5. 错误监控入口
import { lazyReportBatch } from '../report';

export default function error() {
    // 捕获资源加载失败的错误
    window.addEventListener('error', function (e) {
        const target = e.target;
        if (target.src || target.href) {
            const url = target.src || target.href;
            const reportData = {
                type: 'error',
                subType: 'resource',
                url,
                html: target.outerHTML,
                pageUrl: window.location.href,
                paths: e.path,
            };
            lazyReportBatch(reportData);
        }
    }, true);

    // 捕获 JS 错误
    window.onerror = function (msg, url, lineNo, columnNo, error) {
        const reportData = {
            type: 'error',
            subType: 'js',
            msg,
            url,
            lineNo,
            columnNo,
            stack: error.stack,
            pageUrl: window.location.href,
            startTime: performance.now(),
        };
        lazyReportBatch(reportData);
    };

    // 捕获 Promise 错误
    window.addEventListener('unhandledrejection', function (e) {
        const reportData = {
            type: 'error',
            subType: 'promise',
            reason: e.reason?.stack,
            pageUrl: window.location.href,
            startTime: e.timeStamp,
        };
        lazyReportBatch(reportData);
    }, true);
}

三、行为监控

用户行为监控帮助我们理解用户如何使用应用,为产品优化提供数据支持。

1. PV(页面浏览量)
import { lazyReportBatch } from '../report';
import { generateUniqueId } from '../utils';

export default function pv() {
    const reportData = {
        type: 'behavior',
        subType: 'pv',
        startTime: performance.now(),
        pageUrl: window.location.href,
        referrer: document.referrer,
        uuid: generateUniqueId(),
    };
    lazyReportBatch(reportData);
}

PV 数据包含

  • 当前页面 URL
  • 来源页面(document.referrer
  • 访问时间
  • 唯一标识(用于关联用户行为链路)
2. 点击行为
import { lazyReportBatch } from '../report';

export default function onClick() {
    ['mousedown', 'touchstart'].forEach((eventType) => {
        window.addEventListener(eventType, (e) => {
            const target = e.target;
            if (target.tagName) {
                const reportData = {
                    type: 'behavior',
                    subType: 'click',
                    target: target.tagName,
                    startTime: e.timeStamp,
                    innerHtml: target.innerHTML,
                    outerHtml: target.outerHTML,
                    width: target.offsetWidth,
                    height: target.offsetHeight,
                    eventType,
                    path: e.path,
                };
                lazyReportBatch(reportData);
            }
        });
    });
}

点击数据用途

  • 分析用户交互热点
  • 绘制热力图
  • 检测异常点击行为
3. 页面跳转
import { lazyReportBatch } from '../report';
import { generateUniqueId } from '../utils';

export default function pageChange() {
    let oldUrl = '';

    // Hash 路由
    window.addEventListener('hashchange', function (event) {
        const newUrl = event.newURL;
        const reportData = {
            from: oldUrl,
            to: newUrl,
            type: 'behavior',
            subType: 'hashchange',
            startTime: performance.now(),
            uuid: generateUniqueId(),
        };
        lazyReportBatch(reportData);
        oldUrl = newUrl;
    }, true);

    let from = '';
    // History 路由
    window.addEventListener('popstate', function (event) {
        const to = window.location.href;
        const reportData = {
            from: from,
            to: to,
            type: 'behavior',
            subType: 'popstate',
            startTime: performance.now(),
            uuid: generateUniqueId(),
        };
        lazyReportBatch(reportData);
        from = to;
    }, true);
}

路由监听

  • 支持 Hash 路由(hashchange 事件)
  • 支持 History 路由(popstate 事件)
  • 记录跳转前后 URL,用于分析用户路径
4. 行为监控入口
import onClick from './onClick';
import pageChange from './pageChange';
import pv from './pv';

export default function behavior() {
    onClick();
    pageChange();
    pv();
}

四、数据上报

数据上报是前端监控的最后一步,需要保证数据可靠、高效地发送到服务端。

1. 上报策略
const config = {
    url: '',
    projectName: 'eyesdk',
    appId: '123456',
    userId: '123456',
    isImageUpload: false,
    batchSize: 5,
};

配置项说明

  • url:上报接口地址
  • appId:应用唯一标识
  • userId:用户标识
  • isImageUpload:是否使用图片方式上报
  • batchSize:批量上报阈值
2. 批量上报
import { addCache, getCache, clearCache } from './cache';

export function lazyReportBatch(data) {
    addCache(data);
    const dataCache = getCache();

    if (dataCache.length && dataCache.length > config.batchSize) {
        report(dataCache);
        clearCache();
    }
}

批量上报优势

  • 减少网络请求次数
  • 降低服务端压力
  • 提升性能
3. 多种上报方式
export function report(data) {
    if (!config.url) {
        console.error('请设置上传 url 地址');
    }

    const reportData = JSON.stringify({
        id: generateUniqueId(),
        data,
    });

    // 使用图片方式上报
    if (config.isImageUpload) {
        imgRequest(reportData);
    } else {
        // 优先使用 sendBeacon
        if (window.navigator.sendBeacon) {
            return beaconRequest(reportData);
        } else {
            xhrRequest(reportData);
        }
    }
}

上报方式对比

方式 优点 缺点 适用场景
sendBeacon 异步、不阻塞页面卸载、可靠 浏览器兼容性 页面关闭时上报
Image 简单、跨域友好 数据大小限制 简单数据上报
XHR 兼容性好、支持 POST 可能被阻塞 常规上报
4. sendBeacon 上报
export function beaconRequest(data) {
    if (window.requestIdleCallback) {
        window.requestIdleCallback(
            () => {
                window.navigator.sendBeacon(config.url, data);
            },
            { timeout: 3000 }
        );
    } else {
        setTimeout(() => {
            window.navigator.sendBeacon(config.url, data);
        });
    }
}

sendBeacon 特点

  • 浏览器在页面卸载时也能可靠发送
  • 异步执行,不阻塞页面关闭
  • 适合用于页面关闭前的数据上报
5. XHR 上报
export function xhrRequest(data) {
    if (window.requestIdleCallback) {
        window.requestIdleCallback(
            () => {
                const xhr = new XMLHttpRequest();
                originalOpen.call(xhr, 'post', config.url);
                originalSend.call(xhr, JSON.stringify(data));
            },
            { timeout: 3000 }
        );
    } else {
        setTimeout(() => {
            const xhr = new XMLHttpRequest();
            originalOpen.call(xhr, 'post', url);
            originalSend.call(xhr, JSON.stringify(data));
        });
    }
}

requestIdleCallback

  • 在浏览器空闲时执行上报
  • 避免阻塞关键渲染任务
  • 设置 timeout: 3000 确保最迟 3 秒后执行
6. 图片上报
export function imgRequest(data) {
    const img = new Image();
    img.src = `${config.url}?data=${encodeURIComponent(JSON.stringify(data))}`;
}

Image 上报优势

  • 实现简单
  • 天然支持跨域
  • 无需担心阻塞

五、数据缓存

import { deepCopy } from './utils.js';

const cache = [];

export function getCache() {
    return deepCopy(cache);
}

export function addCache(data) {
    cache.push(data);
}

export function clearCache() {
    cache.length = 0;
}

缓存机制

  • 使用数组缓存待上报数据
  • 达到阈值后批量上报
  • 上报后清空缓存

六、工具函数

// 深拷贝
export function deepCopy(target) {
    if (typeof target === 'object') {
        const result = Array.isArray(target) ? [] : {};
        for (const key in target) {
            if (typeof target[key] == 'object') {
                result[key] = deepCopy(target[key]);
            } else {
                result[key] = target[key];
            }
        }
        return result;
    }
    return target;
}

// 生成唯一 ID
export function generateUniqueId() {
    return 'id-' + Date.now() + '-' + Math.random().toString(36).substring(2, 9);
}

七、SDK 入口

import performance from './performance/index';
import error from './error/index';
import behavior from './behavior/index';
import { setConfig } from './config';
import { lazyReportBatch } from './report';

window.__webEyeSDK__ = {
    version: '0.0.1',
};

// 针对 Vue 项目的错误捕获
export function install(Vue, options) {
    if (__webEyeSDK__.vue) return;
    __webEyeSDK__.vue = true;
    setConfig(options);
    const handler = Vue.config.errorHandler;
    Vue.config.errorHandler = function (err, vm, info) {
        const reportData = {
            info,
            error: err.stack,
            subType: 'vue',
            type: 'error',
            startTime: window.performance.now(),
            pageURL: window.location.href,
        };
        lazyReportBatch(reportData);
        if (handler) {
            handler.call(this, err, vm, info);
        }
    };
}

// 针对 React 项目的错误捕获
export function errorBoundary(err, info) {
    if (__webEyeSDK__.react) return;
    __webEyeSDK__.react = true;
    const reportData = {
        error: err?.stack,
        info,
        subType: 'react',
        type: 'error',
        startTime: window.performance.now(),
        pageURL: window.location.href,
    };
    lazyReportBatch(reportData);
}

export function init(options) {
    setConfig(options);
    performance();
    error();
    behavior();
}

export default {
    install,
    errorBoundary,
    performance,
    error,
    behavior,
    init,
}

使用指南

安装

import webEyeSDK from './webEyeSDK';

// 初始化
webEyeSDK.init({
    url: 'http://your-server.com/report',
    appId: 'your-app-id',
    userId: 'user-123',
    batchSize: 10,
});

Vue 项目集成

import Vue from 'vue';
import webEyeSDK from './webEyeSDK';

Vue.use(webEyeSDK, {
    url: 'http://your-server.com/report',
    appId: 'your-app-id',
});

React 项目集成

import React from 'react';
import webEyeSDK from './webEyeSDK';

class ErrorBoundary extends React.Component {
    componentDidCatch(error, info) {
        webEyeSDK.errorBoundary(error, info);
    }

    render() {
        return this.props.children;
    }
}

// 初始化
webEyeSDK.init({
    url: 'http://your-server.com/report',
    appId: 'your-app-id',
});

核心特性总结

功能模块 监控内容 实现方式
性能监控 FCP、LCP、Load、XHR/Fetch PerformanceObserver、重写原型
错误监控 JS 错误、资源错误、Promise 错误 window.onerror、事件监听
行为监控 PV、点击、页面跳转 事件监听
数据上报 批量上报、多种方式 sendBeacon、XHR、Image

性能优化建议

  1. 使用 requestIdleCallback:在浏览器空闲时执行数据上报,避免阻塞关键渲染
  2. 批量上报:减少网络请求次数,降低服务端压力
  3. sendBeacon:页面关闭时使用 sendBeacon 保证数据可靠性
  4. 数据压缩:上报前压缩数据,减少传输体积
  5. 采样上报:对高频事件(如点击)进行采样,减少数据量

扩展方向

  1. SourceMap 解析:还原压缩后的错误堆栈
  2. 录屏回放:使用 rrweb 记录用户操作
  3. 白屏检测:检测页面白屏问题
  4. 性能评分:基于 Core Web Vitals 计算性能评分
  5. 告警系统:实时告警通知

参考资料

总结

本文从零实现了一个前端监控 SDK,涵盖了性能监控、错误监控、行为监控三大核心模块。通过 Performance API、事件监听、原型重写等技术,实现了全方位的前端监控能力。

掌握前端监控的实现原理,不仅能帮助你在工作中构建完善的监控系统,还能加深对浏览器性能、错误处理等底层机制的理解。


如果觉得本文有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!

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

2026年4月18日 16:35

大屏适配终极指南: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. 多端统一:使用混合方案,兼顾体验

选择方案时需要综合考虑:

  • 设计稿还原要求
  • 目标设备规格
  • 开发维护成本
  • 团队技术栈

希望本文能帮助你在大屏开发中游刃有余!


如果觉得有帮助,欢迎点赞收藏,有问题欢迎在评论区讨论!

❌
❌