普通视图

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

Sentry 错误监控系统

2026年1月28日 14:30

Vue2 项目从零搭建免费 Sentry 错误监控系统完整指南

老 Vue2 项目搭建免费的 Sentry 错误监控系统,从注册账号到最终使用的全部步骤。


第一部分:注册 Sentry 账号

1. 访问 Sentry 官网

打开浏览器,访问 sentry.io/signup/

2. 注册账号

有以下几种注册方式:

  • GitHub 账号登录(推荐,最快捷)
  • Google 账号登录
  • 邮箱注册

💡 免费套餐说明:Sentry 免费版每月支持 5,000 个错误事件,对于个人项目或小型团队完全够用。

3. 完成注册流程

  1. 选择登录/注册方式
  2. 填写组织名称(Organization Name),例如:my-company
  3. 确认邮箱(如果是邮箱注册)

第二部分:创建 Sentry 项目并获取 DSN

1. 创建新项目

登录后,进入 Sentry 控制台:

  1. 点击左侧菜单 "Projects"
  2. 点击右上角 "Create Project" 按钮
  3. 在平台列表中选择 "Vue"
  4. 设置告警规则(Alert Settings):
    • 选择 "Alert me on every new issue"(推荐新手)
    • 或者选择自定义规则
  5. 输入项目名称,例如:my-vue2-app
  6. 选择团队(如果有多个团队)
  7. 点击 "Create Project"

2. 获取 DSN(数据源名称)

项目创建成功后,系统会自动跳转到配置页面,显示你的 DSN。

如果错过了这个页面,可以通过以下方式找到 DSN:

  1. 进入项目 → Settings(设置)
  2. 左侧菜单选择 Client Keys (DSN)
  3. 复制 DSN,格式类似:
    https://xxxxxxxxxxxxxxxx@o123456.ingest.sentry.io/1234567
    

⚠️ 重要:DSN 是你项目的唯一标识,请妥善保管,不要泄露到公开的代码仓库中。


第三部分:在 Vue2 项目中安装 Sentry SDK

1. 安装依赖包

在你的 Vue2 项目根目录下执行:

# 使用 npm
npm install @sentry/vue@5 @sentry/tracing@5 --save

# 或使用 yarn
yarn add @sentry/vue@5 @sentry/tracing@5

⚠️ Vue2 必须使用 @sentry/vue@5 版本,@sentry/vue@7+ 版本只支持 Vue3。

2. 确认安装成功

查看 package.json,确保依赖已添加:

{
  "dependencies": {
    "@sentry/vue": "^5.x.x",
    "@sentry/tracing": "^5.x.x"
  }
}

第四部分:配置 Sentry 初始化代码

1. 修改入口文件 src/main.js

import Vue from 'vue'
import App from './App.vue'
import router from './router' // 如果你使用了 vue-router

// 引入 Sentry
import * as Sentry from '@sentry/vue'
import { Integrations } from '@sentry/tracing'

// 初始化 Sentry(必须在 new Vue() 之前)
Sentry.init({
  Vue,
  dsn: 'https://你的DSN@o123456.ingest.sentry.io/1234567', // 替换为你的 DSN
  integrations: [
    new Integrations.BrowserTracing({
      routingInstrumentation: Sentry.vueRouterInstrumentation(router),
      tracingOrigins: ['localhost', 'your-domain.com', /^\//]
    })
  ],
  // 性能监控采样率(1.0 = 100%,生产环境建议设置 0.1-0.2)
  tracesSampleRate: 1.0,
  // 环境标识
  environment: process.env.NODE_ENV || 'development',
  // 在控制台也输出错误(开发时有用)
  logErrors: true
})

Vue.config.productionTip = false

new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

2. 如果没有使用 vue-router 的简化配置

import Vue from 'vue'
import App from './App.vue'
import * as Sentry from '@sentry/vue'

Sentry.init({
  Vue,
  dsn: 'https://你的DSN@o123456.ingest.sentry.io/1234567',
  tracesSampleRate: 1.0,
  environment: process.env.NODE_ENV || 'development',
  logErrors: true
})

new Vue({
  render: h => h(App)
}).$mount('#app')

第五部分:使用环境变量管理 DSN(推荐)

为了安全起见,建议使用环境变量管理 DSN。

1. 创建环境变量文件

在项目根目录创建 .env 文件:

# .env.development(开发环境)
VUE_APP_SENTRY_DSN=https://开发环境的DSN

# .env.production(生产环境)
VUE_APP_SENTRY_DSN=https://生产环境的DSN

2. 修改 main.js 使用环境变量

Sentry.init({
  Vue,
  dsn: process.env.VUE_APP_SENTRY_DSN,
  // ... 其他配置
})

3. 将 .env 文件添加到 .gitignore

# .gitignore
.env
.env.local
.env.*.local

第六部分:测试 Sentry 是否正常工作

1. 添加测试按钮

在任意组件中添加一个测试按钮,例如 App.vue

<template>
  <div id="app">
    <h1>Sentry 测试</h1>
    <button @click="throwTestError">触发测试错误</button>
    <button @click="throwTypeError">触发类型错误</button>
  </div>
</template>

<script>
export default {
  name: 'App',
  methods: {
    throwTestError() {
      throw new Error('这是一个 Sentry 测试错误!')
    },
    throwTypeError() {
      // 故意访问未定义变量的属性
      const obj = undefined
      console.log(obj.property)
    }
  }
}
</script>

2. 运行项目并测试

npm run serve
  1. 打开浏览器访问项目
  2. 点击测试按钮触发错误
  3. 打开浏览器开发者工具(F12),查看 Network 标签
  4. 你应该能看到发送到 sentry.io 的请求

3. 在 Sentry 控制台查看错误

  1. 登录 sentry.io
  2. 进入你的项目
  3. 点击左侧 "Issues" 菜单
  4. 你应该能看到刚才触发的测试错误

💡 错误通常会在几秒钟内出现在 Sentry 控制台中。


第七部分:高级配置(可选但推荐)

1. 捕获 API 请求错误

如果你使用 axios,可以添加拦截器:

// src/utils/request.js
import axios from 'axios'
import * as Sentry from '@sentry/vue'

const service = axios.create({
  baseURL: process.env.VUE_APP_API_URL,
  timeout: 10000
})

// 响应拦截器
service.interceptors.response.use(
  response => response,
  error => {
    // 将 API 错误发送到 Sentry
    Sentry.captureException(error)
    
    // 添加额外上下文信息
    Sentry.setContext('api_error', {
      url: error.config?.url,
      method: error.config?.method,
      status: error.response?.status,
      data: error.response?.data
    })
    
    return Promise.reject(error)
  }
)

export default service

2. 添加用户信息(用于追踪特定用户的问题)

// 用户登录成功后调用
import * as Sentry from '@sentry/vue'

function onUserLogin(user) {
  Sentry.setUser({
    id: user.id,
    username: user.username,
    email: user.email // 可选,注意隐私保护
  })
}

// 用户登出时清除
function onUserLogout() {
  Sentry.setUser(null)
}

3. 使用 Vue 错误边界捕获组件错误

// 在 main.js 中添加全局错误处理
Vue.config.errorHandler = (err, vm, info) => {
  Sentry.captureException(err, {
    extra: {
      componentName: vm?.$options?.name,
      propsData: vm?.$options?.propsData,
      lifecycleHook: info
    }
  })
}

4. 手动捕获错误和消息

import * as Sentry from '@sentry/vue'

// 捕获异常
try {
  riskyOperation()
} catch (error) {
  Sentry.captureException(error)
}

// 发送自定义消息
Sentry.captureMessage('用户完成了重要操作', 'info')

// 添加面包屑(用于追踪用户操作路径)
Sentry.addBreadcrumb({
  category: 'user-action',
  message: '用户点击了购买按钮',
  level: 'info'
})

5. 配置不同环境的采样率

// src/config/sentry.js
export const sentryConfig = {
  development: {
    tracesSampleRate: 1.0,  // 开发环境 100% 采样
    debug: true
  },
  production: {
    tracesSampleRate: 0.2,  // 生产环境 20% 采样(节省配额)
    debug: false
  }
}

第八部分:Source Maps 配置(用于定位压缩代码)

生产环境的代码经过压缩后,错误堆栈很难阅读。上传 Source Maps 可以让 Sentry 显示原始代码位置。

1. 安装 Sentry CLI

npm install @sentry/cli --save-dev

2. 创建 .sentryclirc 配置文件

在项目根目录创建 .sentryclirc

[defaults]
url = https://sentry.io
org = 你的组织名
project = 你的项目名

[auth]
token = 你的AuthToken

3. 获取 Auth Token

  1. 登录 Sentry
  2. 进入 SettingsAccountAPIAuth Tokens
  3. 点击 Create New Token
  4. 勾选 project:releasesorg:read 权限
  5. 复制生成的 Token

4. 在构建时上传 Source Maps

修改 package.json

{
  "scripts": {
    "build": "vue-cli-service build",
    "postbuild": "sentry-cli releases files $npm_package_version upload-sourcemaps ./dist/js --url-prefix '~/js'"
  }
}

第九部分:Sentry 控制台功能介绍

Issues(问题列表)

  • 查看所有捕获的错误
  • 按频率、影响用户数排序
  • 标记问题状态(已解决/忽略)

Performance(性能监控)

  • 页面加载时间
  • API 请求耗时
  • 性能瓶颈分析

Alerts(告警设置)

  • 设置错误阈值告警
  • 配置邮件/Slack/钉钉通知

Releases(版本管理)

  • 关联代码版本
  • 追踪哪个版本引入了问题

第十部分:常见问题排查

问题 解决方案
控制台没有收到错误 检查 DSN 是否正确;确保网络能访问 sentry.io
错误信息不完整 确认 SDK 版本正确(Vue2 用 v5)
Source Map 不生效 检查 release 版本是否匹配;确认上传成功
免费额度用完 降低 tracesSampleRate 采样率;或升级套餐

完整配置示例

最终的 src/main.js 完整代码:

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import * as Sentry from '@sentry/vue'
import { Integrations } from '@sentry/tracing'

// 仅在有 DSN 时初始化 Sentry
if (process.env.VUE_APP_SENTRY_DSN) {
  Sentry.init({
    Vue,
    dsn: process.env.VUE_APP_SENTRY_DSN,
    integrations: [
      new Integrations.BrowserTracing({
        routingInstrumentation: Sentry.vueRouterInstrumentation(router),
        tracingOrigins: ['localhost', /^\//]
      })
    ],
    tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.2 : 1.0,
    environment: process.env.NODE_ENV,
    release: process.env.VUE_APP_VERSION || '1.0.0',
    logErrors: process.env.NODE_ENV === 'development'
  })
}

// 全局错误处理
Vue.config.errorHandler = (err, vm, info) => {
  console.error('Vue Error:', err)
  Sentry.captureException(err, {
    extra: { componentName: vm?.$options?.name, info }
  })
}

Vue.config.productionTip = false

new Vue({
  router,
  render: h => h(App)
}).$mount('#app')

总结

完成以上步骤后,你的 Vue2 项目就已经成功接入了 Sentry 错误监控系统。当用户在使用你的应用时遇到错误,你可以:

  1. 实时收到错误通知
  2. 查看完整的错误堆栈和上下文
  3. 了解错误影响的用户数量
  4. 追踪用户操作路径(面包屑)
  5. 关联代码版本定位问题

数据工程决策:自研 vs 采购 NoETL 自动化指标平台的深度分析

2026年1月28日 14:21

本文首发于 Aloudata 官方技术博客:《自研指标平台是大坑?80%企业选择采购NoETL自动化指标平台》转载请注明出处。

摘要:本文深入剖析了企业自研指标平台面临的三大核心技术挑战:统一语义层构建、智能物化加速与开放生态适配。通过对比传统静态指标字典与 NoETL 动态语义引擎的架构差异,并结合总拥有成本(TCO)分析,论证了对于绝大多数企业而言,采购成熟的 NoETL 自动化指标平台是实现数据敏捷、降低长期成本、规避技术风险的理性选择。

认知误区:你以为在做“字典”,实际需要打造“引擎”

企业启动自研指标平台的初衷通常是解决“口径乱”的问题,希望建立一个统一的指标目录或“字典”。然而,在 AI 驱动的数智化运营时代,业务对数据的灵活性要求呈指数级增长。一个简单的指标目录,无法支撑业务人员“任意维度、任意筛选”的自助分析,更无法让 AI 智能体(Agent)理解并调用。

“传统 ETL 通过宽表和汇总表交付指标的模式,导致了大量指标的重复开发,造成企业在存储和计算上的巨大浪费。” —— Aloudata CAN 产品白皮书

问题的本质在于,支撑现代数据分析的并非一个静态的“字典”,而是一个能实时工作的“引擎”。这个引擎需要具备:

  • 语义解析能力:理解业务术语(如“有效销售额”)背后的复杂计算逻辑(SUM(订单金额) - SUM(退款金额))。
  • 动态计算能力:在不预建物理宽表的前提下,实时关联多张明细表,生成“虚拟业务事实网络”。
  • 性能保障能力:通过智能化的物化加速,确保对海量明细数据的查询也能获得秒级响应。

自研项目往往始于对“统一口径”的朴素追求,却最终陷入构建一个企业级“语义计算引擎”的深水区,其技术复杂度和资源需求远超初期规划。

自研模式.png

挑战一:语义解析——从“静态表”到“动态虚拟宽表”

这是自研面临的第一道技术鸿沟。传统方式通过 ETL 工程师编写 SQL,将业务逻辑固化在物理宽表(DWS/ADS)中。而 NoETL 语义编织要求平台构建一个“统一语义层”,在不进行物理打宽的前提下,通过声明式建模,让系统能实时理解并关联跨多张明细表(DWD)的业务逻辑,形成“虚拟业务事实网络”。

这要求自研团队具备编译原理、查询优化和复杂业务抽象能力,而非简单的 SQL 封装。具体挑战包括:

  1. 逻辑关联的动态解析:如何让系统理解“订单表”的“客户 ID”与“客户维度表”的“客户 ID”在业务上等价,并能处理多通路等复杂场景。这需要设计一套元数据模型来声明和管理表间关联关系。
  2. 复杂指标的函数化封装:如何将“近 30 天消费金额 >5000 的客户数”这类业务需求,配置化为可复用的语义函数(如跨表限定、指标维度化、二次聚合),而无需为每个需求手写数百行 SQL。这本质上是构建一个面向业务人员的“高级查询语言”及其编译器。
  3. NL2Metrics 的意图理解:若想对接 AI,还需构建让大模型能理解的“语义知识图谱”,实现从自然语言到指标调用的精准转换(NL2Metrics),从根源上根治数据幻觉。这需要将业务指标、维度、限定条件等语义元数据结构化,并提供标准的 Function Calling 接口。

自研团队需要从“SQL 脚本执行者”转变为“语义编译器设计者”,这是一个质的飞跃。

挑战二:智能物化——从“人工运维”到“系统自治”

即使解决了语义解析,面对企业百亿级的明细数据,如何保障查询的秒级响应?传统做法是数据工程师基于经验,手动创建和维护大量的物化视图(加速表)。但这种方式成本高昂、响应滞后,且极易形成新的数据冗余。

NoETL 平台的智能物化加速,其核心并非取消 ETL,而是将其升级为一种由“声明式策略”驱动的自动化性能服务。自研实现这一能力的难点在于:

  1. 物化策略的自动生成与优化:如何基于用户对指标和维度的“加速声明”,结合数据分布和查询历史,自动设计出存储成本与查询性能最优的物化方案,并支持去重计数、比率类等复杂指标的上卷。
  2. 查询的透明改写与路由:如何让用户的查询请求(无论是来自 BI 拖拽还是 AI 调用)无感知地自动路由到最优的物化结果上,并完成底层 SQL 的透明改写,这对查询优化器的要求极高。
  3. 口径变更影响的全面分析:如何在指标口径变更时自动识别并提示所有下游影响,辅助用户根据变更影响告警进行物化任务重建和数据回刷操作,这对数据血缘解析有着极高的要求。

“通过智能物化加速确保十亿、百亿级明细数据的秒级查询响应。” —— NoETL 指标平台白皮书

这要求自研团队不仅精通数据库内核优化,还需具备平台级的资源调度与成本管控(FinOps)能力。

挑战三:生态适配——从“孤岛工具”到“中立基座”

指标平台的终极价值在于被消费。企业内往往存在多种 BI 工具(如 Tableau、Power BI)、业务系统和新兴的 AI 应用。自研平台极易陷入为某个特定前端(如某个自研报表系统)深度定制的陷阱,成为一个新的“数据孤岛”。

真正的指标平台必须是中立的“数据中枢”,其挑战在于:

  1. 标准化接口设计与实现:提供稳定、高性能的 Restful API 和成熟的 JDBC 驱动,确保下游各类应用能无差别、高性能地调用指标服务。
  2. 治理规则的内嵌与强制执行:将企业的数据安全策略(行列级权限)、审批流程等治理要求,平台化、内嵌化到指标的生产和消费链路中,从技术上保障“One Truth”的落地,而非依赖人工监督。
  3. 与现有数据湖仓的平滑集成:无需推翻重来,能通过标准连接器对接企业已有的各类数据湖仓,实现对存量宽表的“挂载”与新需求的“原生”建模混合策略,保护既有投资。

生态适配能力决定了平台是企业长期演进的“基石”还是又一个短命的“项目”。

TCO分析:自研与采购的总拥有成本对比

决策必须超越初始采购费用,基于总拥有成本(TCO)进行理性分析。自研的初始开发成本只是冰山一角,后续高昂的持续维护、升级、扩容成本,以及因效率低下导致的业务机会成本,构成了“隐形高利贷”。

成本维度 自研模式 (典型问题) 采购 NoETL 平台 (典型收益)
人力成本 组建并长期供养一支精通数据架构、编译原理、分布式系统的顶尖团队,招聘难、流失风险高。 将数据工程师从重复 ETL 开发中解放,转向高价值的语义建模与业务赋能,人力结构优化。
开发与运维成本 语义解析能力、动态查询能力、只能物化能力、查询命中与上卷等复杂功能需人工持续设计、开发、调试、运维,复杂度线性攀升。 成熟平台实现自动化指标生产、智能物化与查询路由,运维复杂度大幅降低,实现“以销定产”。
机会成本 需求响应慢(周/天级),压抑业务探索,错失市场机会;数据口径混乱,引发决策风险。传统方案探索性分析准确率仅 40%。 需求分钟级响应,激活业务自助分析;口径 100% 一致,构建决策信任基石。复杂任务准确率可达 98.75%。

根据第三方测试数据,采用成熟的 NoETL 架构平台,可实现 3 年 TCO 降低 45%,需求平均响应时间缩短 90.71%,从“成本中心”转变为“效率引擎”。(来源:相关技术评测报告)

NoETL 模式.png

决策矩阵:何时该自研,何时该果断采购?

企业不应一概而论。通过以下决策矩阵,可以清晰判断自身情况:

应果断采购,若:

  • 核心目标是快速实现业务数据化运营与敏捷决策。
  • 缺乏构建并长期维护复杂数据计算引擎(语义引擎、智能物化)的核心技术团队。
  • 需要对接多种 BI 工具和 AI 应用,避免厂商锁定。
  • 希望控制长期 TCO,避免技术债务失控,追求确定性回报。

可谨慎评估自研,若:

  • 拥有极其特殊、封闭且稳定的业务场景,市面产品完全无法满足。
  • 具备世界级的数据系统工程团队,且将自研平台作为核心战略产品投入。
  • 不计较时间与金钱成本,旨在技术积累。

对于绝大多数追求数据敏捷、希望快速获得业务价值的企业,采购成熟的 NoETL 自动化指标平台是明确的最优解。

常见问题 (FAQ)

Q1: 自研指标平台,初期投入大概需要多少人和多长时间?

初期投入严重低估是常见陷阱。要打造一个具备基本语义解析和查询能力的原型,至少需要一个 5-8 人的资深团队(含架构、前后端、数据开发),耗时 6-12 个月。而这仅能达到“可用”水平,距离支撑企业级复杂分析、智能物化和 AI 对接的“好用”阶段,还需持续投入 2-3 年及更多资源进行迭代和运维,总成本远超预期。

Q2: 采购 NoETL 指标平台,如何与我们现有的数据仓库集成?

成熟的 NoETL 平台设计为中立的数据基座。它通过标准连接器直接读取您现有数据仓库的公共明细层(DWD)数据,无需数据搬迁。平台在逻辑层构建语义模型和虚拟宽表,对下游提供统一 API 服务。现有 BI 报表和 ETL 任务可以逐步迁移至新平台消费,实现平滑演进,保护既有投资。

Q3: 如果未来业务变化很大,采购的平台会不会不够灵活?

这正是 NoETL 平台的核心优势——应对变化。其“语义模型驱动”的架构,将易变的业务逻辑(指标口径、维度关联)上浮至可配置的语义层,而将稳定的物理存储与计算下放。当业务变化时,只需在语义层修改或新增配置,无需改动底层 ETL 和物理表。这种解耦设计使平台天生具备极强的业务适应性。

Q4: 如何验证平台真能解决“口径不一致”和“响应慢”的问题?

要求在 POC(概念验证)中设置真实业务场景:1) 口径验证:在平台中统一定义一个核心指标(如“有效销售额”),并确保通过 API 在不同测试报表中调用结果完全一致。2) 性能验证:针对一个涉及多表关联和复杂筛选的灵活分析需求,测试从发起查询到获取结果的端到端响应时间,要求达到秒级。同时,核查厂商提供的同类客户案例中的量化收益数据。

核心要点

  1. 本质是引擎,而非字典:自研指标平台的核心挑战是构建具备实时语义解析与智能物化能力的“动态计算引擎”,技术复杂度远超一个静态的指标目录。
  2. 三大挑战难以逾越:语义解析(构建虚拟宽表)、智能物化(系统自治的性能服务)、生态适配(中立的数据中枢)是自研工程实现上的核心难点,需要顶尖的架构与工程团队。
  3. TCO 揭示真实成本:自研的隐性成本(长期维护、机会成本)极高,而采购成熟平台能获得开发提效 10 倍、存算成本降 70%、分钟级响应的确定性回报。
  4. 采购是理性决策:对于绝大多数追求数据敏捷与业务价值的企业,采购经过大规模复杂场景验证的 NoETL 自动化指标平台,是规避风险、加速见效的最优路径。

本文首发于 Aloudata 官方技术博客,查看更多技术细节与高清架构图,请访问原文链接:ai.noetl.cn/knowledge_b…

【flex学习】图解flex的三个属性:grow、shrink、basis

作者 熬夜仙人
2026年1月28日 14:15

最近在准备年后的面试,趁现在有时间把css部分好好补一下,就先从flex的三个属性开始吧。

前置知识:了解flex的基本使用,了解盒模型。

flex-grow

默认值:0

作用:当元素无法充满父元素时,定义剩余的空间怎么分配(添加到子元素上)。

使用方法如图所示

image.png

image.png

image.png

image.png 源码:

flex-shrink

默认值:1

作用:与grow相反,shrink用于定义在父元素宽度不够的情况下,子元素如何分配空间,shrink越大,代表需要承担的越多。

计算方法

  1. 先计算出溢出的宽度
  2. 计算出子元素各自的shrink比例(个体÷所有)
  3. 计算出每块应该承担的宽度(溢出的宽度*每一块所占比例),在每一块中删除

例子

前提:父元素宽度为120,gap为10,因此实际可以放元素的区域为110; 子元素width都为50。

1. shrink为默认值1

则每一个子元素的实际宽度都相同,都为:50 - (3*50 - 110) * (50 / 150)≈ 36.66

2. 第一个的shrink为0.5,其他为默认值1

则计算方法为:50 - (150 - 110) * (0.5 / 2.5) = 42

但实际上并不是42,而是43.6,这是为什么?

因为此处的子元素有边框,而盒模型使用的是标准盒模型。width不包括borderpadding,实际占用的宽度为50 + 2 * 2 = 54,计算的结果为:54 - (543 - 110) * [540.5 / (54*2.5)] = 43.6

shrink为1时,浏览器为了简化计算,忽略了border直接取width值。

3. 第一个shrink为0.5,第二个shrink为0.3,第三个shrink为0.2

则计算方法为:
第一个:54 - (543 - 110) * [(540.5) / (540.5 + 540.3 + 54*0.2)] = 28

第二个:54 - (543 - 110) * [(540.3) / (540.5 + 540.3 + 54*0.2)] = 38.4

第三个:54 - (543 - 110) * [(540.2 / (540.5 + 540.3 + 54*0.2)] = 43.6

源码:

flex-basis

此处参考了张鑫旭老师的文章,有时间的同学推荐阅读一下,还有很多细节的东西我没有写:www.zhangxinxu.com/wordpress/2…

默认值:auto

作用:设定元素在flex布局下的基础尺寸,可以完全替代width。当basisauto时,才会去取width的值。

注意

  1. 当内容超出basis的范围时,元素尺寸会自动拉伸。但此时如果存在width,元素尺寸会以width为准。因此尽量不要同时使用width和basis。

  1. 关于flex布局中尺寸的生效优先级:min-width/max-width > basis > width

结语

用了两天上班摸鱼的时间,总算是把这三个属性基本搞明白了。接下来打算去学习flex: 1、flex: 0等都有什么区别,以及flex的其他特性,如果评论区有小伙伴需要的话我可以将这部分知识也整理出来

最后再次推荐张鑫旭老师的博客,这些年来大佬一直在坚持分享,真的很佩服:www.zhangxinxu.com/wordpress/c…

h5移动端项目总结

2026年1月28日 14:14

移动端项目总结

项目名称:金贝移动端(fe-banner-pub-mobile)
项目定位:面向经纪人的商机购买与管理 H5 平台


📋 项目概述

金贝移动端是一个面向房产经纪人的综合性商机管理平台,提供潜客包、准客包、钻展、商机直通车、CPL/CPT、金贝会员等多种商机产品的购买、管理和跟进服务。项目采用前后端分离架构,前端使用 React 技术栈,后端使用 Node.js 中间层,支持多端(APP 内嵌、H5)访问。

核心业务模块

  • 潜客包/准客包管理:商机包的购买、查看、跟进、转委托、退名额等全流程管理
  • 钻展(抢购、报买):广告位购买与管理
  • 商机直通车:CPA 商机购买
  • CPL/CPT:按线索/按时间计费的商机包
  • 金贝会员:会员权益管理
  • 订单管理:订单列表、详情、支付
  • 数据看板:商机数据统计与分析
  • 预算池:预算管理与分配

🛠️ 技术栈

前端技术栈

技术 版本 用途
React ^16.8.6 UI 框架
Redux ^4.0.1 状态管理
React Router ^5.1.0 路由管理
antd-mobile ^2.3.1 / ^5.34.0 UI 组件库(v2 + v5 混用)
dayjs ^1.8.8 日期时间处理
axios ^0.19.2 HTTP 请求库
webpack ^5.75.0 构建工具
less ^4.1.3 CSS 预处理器
react-hot-loader ^4.3.12 热更新
@antv/f2 ^3.8.6 移动端图表库

后端技术栈

  • Node.js (v12)
  • TypeScript
  • Express/Koa (中间层)
  • Redis (缓存)

开发工具

  • Babel:ES6+ 转译
  • ESLint:代码规范
  • PostCSS:CSS 后处理(px2rem、px-to-viewport)
  • vconsole:移动端调试工具

第三方服务集成


✨ 项目亮点

1. 完整的商机处理闭环设计

业务价值:实现了从商机领取到关闭的全流程管理,提升经纪人工作效率。

技术实现

  • 统一的交互入口:通过 bottomAreaClick 方法统一处理所有商机操作
  • 多场景适配:根据商机来源(opptySource)、类型(新房/二手/租赁)、状态(opptyStatus)动态展示操作按钮
  • 操作类型包括:
    • 加私/转委托:根据 conversionType 动态展示,支持预校验和跳转
    • 联系:区分电话(contactType === 1)和 IM(contactType === 2
    • 记录跟进:弹窗录入,支持局部数据更新
    • 申请退名额:复杂的时间规则和风控校验
    • 查看工单:跳转司南工单系统

代码示例

bottomAreaClick = (e, item, desc, callBack) => {
  const { opptyId, opptyProcessVo, custId } = item
  const { contactType, conversionType } = opptyProcessVo
  e.stopPropagation()
  switch (desc) {
    case '加私':
      this.openXinfang(conversionPageScheme, opptyId)
      break
    case '联系':
      contactType == 2 ? this.imCustomer(custId, opptyId) : this.callTel(opptyId)
      break
    // ... 其他操作
  }
}

2. 复杂状态标签体系的可视化呈现

业务价值:通过标签系统清晰展示商机状态,降低业务理解成本。

技术实现

  • 动态标签生成:根据 conventionLevelisGivenisWeihupan 等状态动态拼装标签数组
  • 交互式标签:点击特定标签(赠送、已联系、未联系、已委托)显示业务提示
  • 样式差异化:不同标签使用不同颜色和样式(如 赠送 红色、维护盘商机 金色背景)

代码示例

// 标签动态生成
if (isGiven && Array.isArray(tags)) {
  tags.unshift('赠送')
}
if (+conventionLevel === -1) {
  tags.push('待委托')
}
if (+conventionLevel === 0) {
  tags.push('已联系')
}
if (+conventionLevel === 1) {
  tags.push('已委托')
}

// 标签点击交互
isGivenClick = (evt, item, shangjiItem) => {
  if (item === '已联系') {
    if (shangjiItem.opptySource === '400') {
      Toast.info('您已拨打过客户电话', 2)
    } else {
      Toast.info('您已回复过客户消息', 2)
    }
  }
}

3. 多端跳转与埋点闭环

业务价值:打通 APP、H5、工单系统等多个平台,实现数据追踪和运营分析。

技术实现

  • 统一埋点封装sendDig(clickId, opptyId) 方法统一上报点击事件
  • 多端跳转封装:通过 Utils 工具类封装拨打电话、IM 联系、跳转客户端等功能
  • 环境区分:根据 window.location.host 区分测试和正式环境,使用不同的跳转链接
  • 埋点数据:包含 opptyIdagent_ucidclick_idc_uicode 等关键字段

代码示例

sendDig(clickId, opptyId) {
  if (window.$ULOG) {
    window.$ULOG.send(this.evtId, {
      event: 'mModuleClick',
      action: {
        opptyId,
        c_uicode: 'qiankebao_qiankebaoliebiao',
        click_id: clickId,
        agent_ucid: window._GLOBAL_DATA.userInfo.id,
      },
    })
  }
}

4. 列表性能与体验优化

业务价值:提升移动端加载速度和用户体验,降低服务器压力。

技术实现

  • 无限滚动:使用 antd-mobile-v5InfiniteScroll 组件实现分页加载
  • 滚动位置保持:记录跟进后恢复滚动位置,避免页面跳回顶部
  • 局部数据更新:通过回调函数 addTempRecordData 更新列表中的单条数据,避免全量刷新
  • 按需插入数据:根据权限和数据返回情况动态插入数据概览卡片

代码示例

// 无限滚动
<InfiniteScroll
  loadMore={() => {
    this.getPackages()
  }}
  hasMore={this.state.packages.pageNum < this.state.packages.totalPage}
  threshold={120}
/>

// 滚动位置保持
record = (data) => {
  this.setState({ textareaModal: false })
  KeFetch(api.saveRecord, { method: 'post', data }).then(() => {
    document.documentElement.scrollTo(0, this.ScroolTop)
    this.genjiCallBack && this.genjiCallBack(data)
  })
}

5. 统一请求封装与错误处理

业务价值:统一 API 调用规范,提升代码可维护性和错误处理能力。

技术实现

  • KeFetch 封装:基于 Ketch 库封装统一请求方法
  • 多环境适配:支持 Node 层(SUCCESS_CODE_NODE: 100000)和 H5 层(SUCCESS_CODE_H5: 1)不同的成功码
  • 统一错误提示:自动处理错误码并展示 Toast 提示
  • 超时控制:默认 20s 超时

🎯 项目难点

难点一:多状态、多来源商机的分支逻辑复杂

问题描述

  • 不同商机来源(OPPTY_POOLSSCCUSTOMER_CLUE400 等)有不同的处理逻辑
  • 不同商机类型(新房/二手/租赁)需要不同的跳转路径
  • 不同场景(OPPTYZHUN_KE_BAOB_PLUSCPS)影响功能展示

解决方案

  1. 统一入口函数:通过 bottomAreaClick 方法统一处理所有操作,内部根据 desc 参数分支处理
  2. 预校验封装:将复杂的校验逻辑抽离成独立方法(如 delegatePrecheckrefundPrecheck
  3. 状态机模式:使用 switch-case 清晰表达不同操作的处理流程
  4. 配置化:通过 getOpptyType() 方法统一获取场景类型,避免重复判断

代码示例

getOpptyType = () => {
  const { type } = Utils.getUrlParams()
  if (type === ZHUN_KE_BAO) return ZHUN_KE_BAO
  else if (type === B_PLUS) return B_PLUS
  else if (type === CPS) return CPS
  else return OPPTY
}

// 转委托预校验
inputCustomer = async (d, conversionPageScheme) => {
  if (d.opptySource == 'OPPTY_POOL' || d.opptySource == 'SSC') {
    KeFetch(api.delegatePrecheck, { data: { opptyId: d.opptyId } })
      .then((data) => {
        if (data.resultCode == 1) {
          // 继续转委托流程
        } else {
          Modal.alert('提示', data.tip || '')
        }
      })
  }
}

收获:通过统一入口和预校验封装,将复杂的业务逻辑集中管理,提升了代码的可维护性和可扩展性。


难点二:时间规则与风控、退款规则耦合

问题描述

  • 申请退名额需要满足多个条件:
    • 商机下发时间需满 25 小时
    • 风控拦截:14 天内提交不通过工单超过 3 次
    • 后端预校验:refundPrecheck 接口返回是否允许退款
  • 时间计算和展示需要精确到秒

解决方案

  1. 时间库统一:使用 dayjs 统一处理所有时间计算和格式化
  2. 状态分离:将不同场景拆解成独立的 Modal 状态(antiCheatModalrefundModal
  3. 链式调用:使用 dayjs(opptyTime).add(25, 'hour') 等链式 API 提升可读性
  4. 用户提示:在弹窗中明确展示截止时间,提升用户体验

代码示例

case '申请退名额':
  const { opptyStatus, opptyTime } = item
  const deadline = dayjs(opptyTime).add(25, 'hour')
  
  if (opptyStatus === 4) {
    // 风控拦截
    this.setState({ antiCheatModal: true })
  } else if (!dayjs().isAfter(deadline)) {
    // 时间未到
    this.setState({
      refundModal: true,
      deadline: deadline.format('YYYY-MM-DD HH:mm:ss'),
    })
  } else {
    // 后端预校验
    KeFetch(api.refundPrecheck, { data: { opptyId } }).then((res) => {
      const { refund, remark } = res
      if (remark) {
        Modal.alert('提示', remark, [/* ... */])
      } else if (!refund) {
        this.onNext(item) // 跳转司南
      }
    })
  }
  break

收获:通过时间库统一和状态分离,将复杂的业务规则清晰地表达出来,便于后续根据运营策略调整。


难点三:列表内局部数据更新与用户滚动位置保持

问题描述

  • 用户在列表中点击「记录跟进」后,需要更新对应商机的 opptyBrief 数据
  • 更新后页面不能跳回顶部,需要保持用户当前的滚动位置
  • 需要支持新增和更新两种场景

解决方案

  1. 滚动位置缓存:在打开弹窗前记录当前滚动位置 this.ScroolTop
  2. 回调函数传递:通过 bottomAreaClickcallBack 参数传递更新函数
  3. 局部状态更新:在子组件中通过 useState 维护列表状态,通过 addTempRecordData 方法更新单条数据
  4. 恢复滚动位置:接口成功后调用 document.documentElement.scrollTo(0, this.ScroolTop)

代码示例

// 父组件:记录滚动位置
case '记录跟进':
  this.genjiCallBack = callBack
  this.ScroolTop = document.body.scrollTop || document.documentElement.scrollTop
  this.genji(opptyId, opptyRemarkVo || {})
  break

// 父组件:恢复滚动位置
record = (data) => {
  this.setState({ textareaModal: false })
  KeFetch(api.saveRecord, { method: 'post', data }).then(() => {
    document.documentElement.scrollTo(0, this.ScroolTop)
    this.genjiCallBack && this.genjiCallBack(data)
  })
}

// 子组件:局部数据更新
const addTempRecordData = recordData => {
  const newData = opptyListState.map(item => {
    if (item.opptyId === recordData.opptyId) {
      if (Array.isArray(item.opptyBrief)) {
        const result = item.opptyBrief.filter(item2 => item2.name === '跟进反馈')
        if (result.length <= 0) {
          item.opptyBrief.push({
            name: '跟进反馈',
            desc: `${recordData.opptyFlagName},${recordData.remark}`,
          })
        } else {
          item.opptyBrief.map(item2 => {
            if (item2.name === '跟进反馈') {
              item2.desc = `${recordData.opptyFlagName},${recordData.remark}`
            }
            return item2
          })
        }
      }
    }
    return item
  })
  setOpptyListState(newData)
}

收获:通过滚动位置缓存和回调函数机制,实现了局部数据更新和用户体验的平衡,避免了全量刷新带来的性能问题。


难点四:老项目技术栈混用带来的兼容问题

问题描述

  • 项目中同时使用 antd-mobile v2 和 v5 两个版本
  • 类组件和函数组件混用(QianKe 是类组件,waitDeal 是函数组件)
  • 需要兼容不同版本的 API 和组件特性

解决方案

  1. 渐进式升级:新功能使用新版本(如 InfiniteScroll 使用 v5),老功能保持原样
  2. 统一接口设计:通过 props 和回调函数统一组件间的通信接口
  3. 工具函数封装:将通用逻辑抽离成工具函数,避免重复代码
  4. 文档记录:在代码注释中标注版本差异和注意事项

代码示例

// 使用 v5 的 InfiniteScroll
import { InfiniteScroll } from 'antd-mobile-v5'

// 使用 v2 的 Modal、Toast
import { Modal, Toast } from 'antd-mobile'

// 统一回调接口
<QkbOrder
  data={item}
  onClick={this.itemClick}
  onSpeedClick={this.executeSpeed}
  bottomAreaClick={this.bottomAreaClick}
  purposeValiageClick={this.purposeValiageClick}
/>

收获:通过渐进式升级和统一接口设计,在保证项目稳定性的同时,逐步引入新技术,为后续整体重构打下基础。


📁 项目结构

fe-banner-pub-mobile/
├── client/                    # 前端代码
│   ├── src/
│   │   ├── components/        # 公共组件
│   │   │   └── QkbOrder/      # 潜客包订单组件
│   │   │       └── component/
│   │   │           └── waitDeal.js  # 待处理商机列表
│   │   ├── containers/        # 页面容器
│   │   │   └── ShangJi/       # 商机模块
│   │   │       ├── Package/   # 我的商品
│   │   │       │   └── Qianke/  # 潜客包页面
│   │   │       ├── Home/      # 首页
│   │   │       ├── Data/      # 数据看板
│   │   │       └── Order/     # 订单管理
│   │   ├── config/            # 配置文件
│   │   │   ├── apiConfig.js   # API 配置
│   │   │   └── digConfig.js   # 埋点配置
│   │   ├── utils/             # 工具函数
│   │   │   ├── keFetch.js     # 请求封装
│   │   │   └── storage.js     # 本地存储
│   │   ├── router/            # 路由配置
│   │   ├── store/             # Redux store
│   │   └── App.js             # 根组件
│   ├── webpack/               # Webpack 配置
│   └── package.json
├── server/                     # Node.js 中间层
│   ├── src/
│   │   ├── apis/              # API 接口
│   │   ├── actions/           # 业务逻辑
│   │   └── configs/           # 配置文件
│   └── package.json
└── README.md

🔧 核心功能实现

1. 潜客包列表加载

getPackages(Kdata) {
  const pageNum = (this.state.packages.pageNum || 0) + 1
  const scene = this.getOpptyType()
  
  KeFetch(api.getPackages, {
    data: {
      cityCode: workCity(),
      pageSize: 10,
      pageNum,
      scene,
    },
  }).then((data) => {
    // 根据权限动态插入数据概览
    if (data.statisticsShow && Kdata && scene === OPPTY) {
      const hasDataOverviewPermission = (window._GLOBAL_DATA.userInfo.perms || [])
        .includes('BRAND_M_qianke_dataOverview')
      if (!this.hasInsert && hasDataOverviewPermission) {
        data.list.unshift({ ...Kdata, type: 'qkDataOverview' })
        this.hasInsert = true
      }
    }
    
    data.list = [...(this.state.packages.list || []), ...data.list]
    this.setState({
      packages: data,
      loading: false,
    })
  })
}

2. 虚拟号码拨打

callTel = (opptyId) => {
  this.sendDig(10016, opptyId)
  KeFetch(api.getShangjiVirtualPhone, { data: { opptyId } })
    .then((data) => {
      if (data.virtualPhone) {
        Modal.alert(
          `客户电话${data.virtualPhone}`,
          '此号码为虚拟号码,非客户真实号码',
          [
            { text: '取消', onPress: () => {} },
            { text: '立即拨打', onPress: () => Utils.callTelphone(data.virtualPhone) },
          ]
        )
      }
    })
}

3. 转委托流程

inputCustomer = async (d, conversionPageScheme) => {
  // 预校验
  if (d.opptySource == 'OPPTY_POOL' || d.opptySource == 'SSC') {
    const precheckData = await KeFetch(api.delegatePrecheck, {
      data: { opptyId: d.opptyId },
    })
    if (precheckData.resultCode !== 1) {
      Modal.alert('提示', precheckData.tip || '')
      return
    }
  }
  
  // 执行转委托
  const res = await KeFetch(api.delegateQiankeOpportunity, {
    data: { opptyId: d.opptyId },
  })
  
  if (res.result) {
    Modal.alert('提示', res.resultDesc || '', [
      {
        text: '去编辑委托',
        onPress: () => {
          Utils.inputCustomer(() => {
            Modal.alert('请先将客户端升级至最新版本', '', [
              { text: '取消' },
              { text: '立即升级', onPress: () => Utils.openAbout() },
            ])
          }, res.conversionPageScheme)
        },
      },
    ])
  }
}

📊 性能优化

  1. 代码分割:使用 Webpack 的 code splitting 按路由分割代码
  2. 图片优化:使用 url-loaderfile-loader 处理图片资源
  3. CSS 优化:使用 mini-css-extract-plugin 提取 CSS,使用 px2rem 适配移动端
  4. 无限滚动:使用 InfiniteScroll 实现分页加载,避免一次性加载大量数据
  5. 局部更新:通过回调函数更新局部数据,避免全量刷新

🐛 常见问题与解决方案

1. 鸿蒙系统 Picker 组件滑动穿透

问题:antd-mobile v2 的 Picker 组件在鸿蒙系统滑动选择时会有滑动穿透 bug。

解决方案:使用 components/WithTouchMoveControlHoc 高阶组件包裹 Picker 组件。

2. Node 版本兼容

问题:client 端使用 Node 14,server 端使用 Node 12。

解决方案:使用 nvm 进行 Node 版本切换,或在启动脚本中自动切换。

3. VPN 冲突

问题:Node 层加了 Redis 之后,挂了外网 VPN 项目启动不起来。

解决方案:关闭 VPN 后再启动项目。


🚀 部署流程

  1. 开发环境
    npm install
    npm start  # 自动切换 Node 版本并启动前后端
    

📝 总结

技术收获

  1. 复杂业务逻辑处理:通过统一入口函数和预校验封装,将复杂的业务规则清晰地表达出来
  2. 用户体验优化:通过滚动位置保持、局部数据更新等技术手段,提升用户操作体验
  3. 多端适配:通过 JSBridge 和工具函数封装,实现 APP 和 H5 的统一适配
  4. 性能优化:通过无限滚动、代码分割等技术,提升页面加载速度和运行性能

业务理解

  1. 商机管理全流程:深入理解了从商机产生到关闭的完整业务流程
  2. 风控规则:理解了时间规则、风控拦截等业务规则的设计思路
  3. 数据埋点:理解了埋点数据对运营分析和产品优化的重要性

项目价值

  1. 提升效率:通过完整的商机处理闭环,大幅提升经纪人的工作效率
  2. 规范操作:通过统一的交互入口和校验逻辑,规范了业务操作流程
  3. 数据支撑:通过完善的埋点体系,为后续的运营分析和策略优化提供了数据支撑

📚 相关文档


最后更新:2024年

老项目 Vue2 函数式打开弹层【附源码】

2026年1月28日 13:59

大家好,我是前端架构师,关注微信公众号【@程序员大卫】免费领取前端精品资料。

前言

在老项目(Vue2 + ElementUI)里写弹层,大家一定很熟悉下面这种写法:

<dialog-test
  v-if="dialogVisible"
  :text="text"
  @confirm="handleConfirm"
  @close="dialogVisible = false"
/>

问题是:

  • 每个页面都要写一堆 visible
  • 关闭弹层还得手动销毁组件
  • 多个弹层时,代码又臭又长
  • 只是想 “点个按钮弹个框”,却要改一堆模板

所以我在老项目里封装了一个 函数式打开弹层 的方法:

  • 不用写 template,不用定义变量 dialogVisible
  • 直接 JS 调用
  • 自动创建 / 销毁
  • 支持缓存,不销毁反复用

最终效果

像这样直接调用即可:

dialogOpen(
  DialogTest,
  {
    text: "Hello World!",
    onConfirm: (text) => {
      console.log(text);
    },
  },
  { context: this },
);

一句话总结:

把“写组件”这件事,变成“调函数”

一、弹层组件本身(DialogTest.vue)

这个组件本身非常普通,没有任何黑魔法。

<template>
  <el-dialog title="提示" visible width="30%" @close="doClose" append-to-body>
    <span>{{ text }}</span>

    <span slot="footer" class="dialog-footer">
      <el-button type="primary" @click="doCancle">取消</el-button>
      <el-button type="primary" @click="doConfirm">确 定</el-button>
    </span>
  </el-dialog>
</template>

关键点说明

  • visible 由外部控制(函数式传入)
  • 所有行为通过 $emit 往外抛
export default {
  name: "DialogTest", // ⚠️ 必须有 name,后面做缓存用
  props: {
    text: String,
  },
  methods: {
    doClose() {
      this.$emit("close");
    },
    doCancle() {
      this.doClose();
    },
    doConfirm() {
      this.$emit("confirm", "点击确认按钮");
      this.doClose();
    },
  },
};

记住一句话:组件只负责展示和抛事件,不管怎么被创建。

二、核心思路:函数式弹层是怎么实现的?

一句话概括实现原理:

new Vue() 在 JS 里动态创建组件,并手动挂载到 body 上

也就是说,我们不再依赖 template,而是:

  1. JS 创建 Vue 实例
  2. render 一个 Dialog 组件
  3. 挂载到 DOM
  4. 关闭时销毁或隐藏

三、dialogOpen.js 整体结构说明

我们先看函数签名:

dialogOpen(Component, props = {}, options = {})

三个参数分别是:

参数 说明
Component 弹层组件
props 传给组件的 props + 事件
options.context 当前页面的 this
options.destroyOnClose 关闭是否销毁(默认 true)
options.key 缓存 key

四、为什么必须传 context?

dialogOpen(DialogTest, {...}, { context: this })

这是很多人第一次看不懂的地方。

原因只有一个:

让弹层组件继承当前页面的 $router$store 等上下文

new Vue({
  parent: context
})

否则:

  • $router 可能是 undefined
  • $store 用不了
  • inject / provide 失效

五、props 和事件是如何区分的?

调用时我们这样写:

dialogOpen(DialogTest, {
  text: "Hello World",
  onConfirm: () => {},
  onClose: () => {}
})

那问题来了:

  • 哪些是 props?
  • 哪些是事件?

解决方案:统一约定

onXxx => 事件
其它 => props

对应代码:

const splitPropsAndListeners = (input = {}) => {
  const props = {};
  const on = {};

  Object.keys(input).forEach(key => {
    if (/^on[A-Z]/.test(key) && typeof input[key] === 'function') {
      const event = key.slice(2).replace(/^[A-Z]/, s => s.toLowerCase());
      on[event] = input[key];
    } else {
      props[key] = input[key];
    }
  });

  return { props, on };
};

最终会变成:

h(Component, {
  props: { text },
  on: { confirm, close }
})

六、真正创建弹层的地方(核心代码)

let wrapperVm = new Vue({
  parent: context,
  data() {
    return {
      rawProps: initialProps
    };
  },
  computed: {
    vnodeData() {
      const { props, on } = splitPropsAndListeners(this.rawProps);
      return { props, on };
    }
  },
  render(h) {
    return h(Component, {
      props: this.vnodeData.props,
      on: {
        ...this.vnodeData.on,
        close: (...args) => {
          this.vnodeData.on.close?.(...args);
          closeHandler();
        }
      }
    });
  }
});

这里发生了什么?

  1. rawProps 保存所有传入参数
  2. computed 动态拆分 props / 事件
  3. render 手动渲染组件
  4. 拦截 close,统一处理销毁逻辑

七、弹层是怎么挂载到页面上的?

let container = document.createElement('div');
document.body.appendChild(container);
wrapperVm.$mount(container);

👉 这一步相当于:

<body>
  <div>
    <!-- Dialog 组件 -->
  </div>
</body>

八、关闭时销毁 vs 不销毁(缓存机制)

默认行为(destroyOnClose = true)

wrapperVm.$destroy();
removeElement(wrapperVm.$el);

好处:

  • 不占内存
  • 最安全

开启缓存(destroyOnClose = false)

const instanceCache = new Map();
  • 同一个 key 只创建一次
  • 关闭时只是 visible = false
  • 再次打开直接复用
if (!destroyOnClose && instanceCache.has(key)) {
  const cacheWrapperVm = instanceCache.get(key);
  cacheWrapperVm.updateProps(initialProps);
  return cacheWrapperVm.triggerClose;
}

适合:

  • 表单弹层
  • 频繁打开的弹窗

九、为什么要监听页面销毁?

context.$once('hook:beforeDestroy', destroy);

防止:

  • 页面销毁了
  • 弹层还留在 body
  • 造成内存泄漏

十、总结

这个方案适合:

  • Vue2 老项目
  • ElementUI 弹层
  • 不想每个页面都写 dialog

附源码

github.com/zm8/wechat-…

不想上班,所以我写了个能搞钱的工具

2026年1月28日 13:53

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。


还记得钉钉提示音让心脏漏跳半拍的感觉?还记得周五火锅吃到一半,弹出那句"不急,周一给我"的绝望?

每个月总有那么几个瞬间,手指悬在"离职申请"上,想把那些"抓手赋能""链路闭环"的黑话统统砸回老板脸上,拉着姑娘直奔海边,让海风把KPI、OKR统统吹散。

但深夜算完银行卡、账单和退休政策后,现实很骨感:按现在的攒钱速度,我得打工打到退休。

盯着满桌演算草稿,我突然开窍:我是个开发者啊! 既然距离自由还有十万八千里,为什么不开发个产品赚睡后收入,让代码替我加速跑路?

之前做自媒体剪视频,最烦的就是找素材——好不容易从平台扒下来的视频,中间总挂着碍眼的水印,关键帧根本没法用。

与其到处求无水印资源,或是开着 PS 一帧帧抠图,我干脆一拍键盘:不如自己写个工具,一键把水印抹干净。

调研

有了想法肯定得调研可不可行,找找竞品有哪些问题。

总结下来,竞品蛮多,当然问题也多。

调研完发现竞品问题很集中:

  1. 个人开发居多,打开就是输入框+按钮,用户不知道怎么用
  2. 企业级产品强制开会员,成本就上来了
  3. 大部分为小程序,对于PC端并不支持
  4. 扎堆传统平台,AI视频/图片的新平台完全空白

这四点全是机会,不同质化竞争,做差异化。

设计+开发

首先,我不是设计师,UI 这块没什么专业见解。具体做法是先扒一遍竞品,在巨人肩膀上修修补补。

技术栈没折腾,直接上熟练工:Vue3 + Tailwind + UniApp + Node.js。本职前端,后端找了个 24 小时在线的「赛博同事」——AI 负责写,我负责 Review 和改 Bug(毕竟底子还在,只是生锈了)。

坚持能简则简,什么高并发、什么数据库等选择能省则省。比如其中有个 IP 限流的功能,直接本地 JSON 文件临时存储,足够用了。

主页面 UI 如下:

11.png

上线与收益

因 wx 平台审核导致小程序名称与 PC 端品牌名被迫不一致,更繁琐的是整个上线前的各种审核——备案、公安备案、企业认证,层层审核确实消耗了大量耐心,这步不详细描述跳过。

上线后,我把它分享给了我的朋友,也得到了很多人的认可。

gh_cb56a387b645_258.jpg

聊聊收益,其实我有三条来钱路子。今天先聊最「躺」的那个——流量主。

流量主有个「新手村」门槛:500 个累计用户。跨过去之后倒是省心,平台把广告组件都打包好了,接起来贼方便。 PC 端本来也想挂 Google Ads 赚点美刀,但一看要填表、申请、过审……算了,拖延症犯了,至今没搞。主要是嫌烦,先放着吧。

image.png

收入不多,但每天一根火腿肠是没问题的。推广到位单靠流量主一个月也能有300,每年只需要支付认证费用30再加服务器成本,对于我来说还是赚的。

最后

当然,这个工具还不足以让我实现不想上班的愿望,还在努力中。

至少现在我每天能多吃一根火腿肠了!!!

前端向架构突围系列 - 浏览器网络 [5 - 5]:Web Vitals 性能指标体系与全链路

2026年1月28日 13:42

写在前面

很久以前,我们用 window.onload 和“白屏时间”来衡量性能。但在单页应用(SPA)和骨架屏盛行的今天,这些老指标已经失效了。页面“加载完”了(Spinning loader 消失),但内容可能还没出来;内容出来了,可能点不动;点得动了,广告突然弹出来把你正在看的文章挤跑了。

2020 年,Google 推出了 Web Vitals,重新定义了用户体验的度量衡。

这一节,我们将手中的“秒表”换成精密的“心电图机”,不仅要让页面跑得快(LCP),还要跑得稳(CLS),更要反应灵敏(INP)。

image.png


一、 核心指标三巨头:LCP、INP 与 CLS

Google 在几十个性能指标中,钦点了三个作为 Core Web Vitals (CWV) 。这不仅关乎用户体验,还直接影响 SEO 排名

1.1 LCP (Largest Contentful Paint) —— 视网膜的愉悦

  • 含义: 视口内最大的那块内容(通常是大图或 H1 标题)渲染完成的时间。
  • 为什么不用 load 事件? 因为 load 触发时,屏幕可能还是白的,或者只有一个 Loading 圈。用户不在乎 Loading 圈,用户在乎的是看到正文。
  • 及格线: 2.5 秒以内。

1.2 INP (Interaction to Next Paint) —— 指尖的快感

  • 注意: 以前叫 FID (First Input Delay),2024 年 3 月起已被 INP 正式取代。架构师必须更新知识库!
  • 含义: 并不是测你第一次点击有多快,而是测全生命周期内,页面对用户操作(点击、按键)的响应延时(从点击到下一帧绘制的时间)。
  • 及格线: 200 毫秒以内。
  • 底层逻辑: 如果 INP 高,说明主线程被长任务(Long Task)堵死了(回顾第四篇 Event Loop)。

1.3 CLS (Cumulative Layout Shift) —— 视觉的稳定

  • 含义: 累积布局偏移。通俗说就是“页面跳不跳”。
  • 场景: 你正要点“取消”,突然顶部加载出来一张广告图,把你挤到了下面的“确认”按钮上。这是最让用户抓狂的体验。
  • 及格线: 0.1 分以下。

二、 实验室数据 vs 真实用户数据:你被 Lighthouse 骗了吗?

很多开发者在本地跑 Lighthouse 拿了 100 分,上线后用户却骂声一片。为什么?

2.1 Lab Data (实验室数据 / 合成监控)

  • 工具: Lighthouse, Chrome DevTools Performance。
  • 环境: 你的高配 MacBook Pro + 公司千兆光纤。
  • 特点: 环境可控,适合调试,但不代表真实体验

2.2 Field Data (现场数据 / 真实用户监控 RUM)

  • 工具: Chrome UX Report (CrUX), 埋点上报。
  • 环境: 用户的红米手机 + 地铁里的弱网 4G。
  • 特点: 这才是真相

架构师策略: “用 Lab Data 治未病,用 Field Data 治已病。” 你需要在 CI/CD 流水线中跑 Lighthouse 守住底线,同时在生产环境接入 RUM (Real User Monitoring) 收集真实用户的 Web Vitals。

// 生产环境监控实战:使用 web-vitals 库
import { onLCP, onINP, onCLS } from 'web-vitals';

function sendToAnalytics(metric) {
  const body = JSON.stringify(metric);
  // 使用 navigator.sendBeacon 保证页面关闭时也能发送
  navigator.sendBeacon('/analytics', body);
}

onLCP(sendToAnalytics);
onINP(sendToAnalytics);
onCLS(sendToAnalytics);

三、 全链路优化实战:串联前四篇的知识

现在,我们拿着这三个指标,回顾前四节的内容,看看如何对症下药。

3.1 优化 LCP (加载速度) —— 考验“管道”与“守门”

LCP 慢,通常是因为资源下不来,或者渲染被阻塞。

  • 回顾第一篇 (网络): 升级 HTTP/3 (QUIC) ,消除队头阻塞,加速握手。

  • 回顾第二篇 (资源):

    • CDN 预连接: <link rel="preconnect" ...>
    • 关键资源预加载: 对 LCP 图片使用 <link rel="preload" as="image" ...>
    • Fetch Priority: <img src="hero.jpg" fetchpriority="high">

3.2 优化 INP (交互响应) —— 考验“心脏”

INP 差,说明主线程太忙,Event Loop 转不动了。

  • 回顾第四篇 (Event Loop):

    • 切片: 只有把长任务切碎(Time Slicing),主线程才有空隙去响应用户的点击。
    • Web Workers: 把繁重的计算(如加密、大文件解析)扔出主线程。
    • 避免 Layout Thrashing: 别在点击事件里强制读取 offsetWidth 导致同步重排。

3.3 优化 CLS (视觉稳定) —— 考验“画师”

CLS 高,是因为画师在画布上反复涂改。

  • 回顾第三篇 (渲染):

    • 定尺寸: 所有的 <img><video> 必须写死 widthheight 属性(或 CSS aspect-ratio),先占位,后加载。
    • 字体抖动: 使用 font-display: swapoptional,避免 FOIT (Flash of Invisible Text)。
    • 动画合成: 坚持使用 transform 做动画,避免触发布局变化。

四、 性能文化的建设:从“突击队”到“正规军”

作为架构师,最难的不是自己修 Bug,而是防止别人写 Bug。

4.1 性能预算 (Performance Budget)

在 Webpack/Vite 配置中设置阈值:

  • JS Bundle 体积不得超过 200KB。
  • 关键 CSS 不得超过 50KB。 一旦超标,构建直接失败。

4.2 自动化守门员

在 GitHub Actions 或 Jenkins 中集成 Lighthouse CI。 如果是 PR 导致 Performance 分数下降了 5 分,禁止 Merge。


结语:快,是做出来的,更是守出来的

性能优化不是一种“魔法”,而是一门“工程学”。 它需要你理解从 TCP 包的发送,到 CPU 的调度,再到像素合成的每一个环节。Web Vitals 就是这套复杂系统的仪表盘。

至此,第五阶段《浏览器运行原理 + 前端网络协议与请求模型》 圆满结束。 我们深入了管道,拜访了守门员,观摩了画师,解剖了心脏,最后拿起了度量尺。

现在,你的前端应用已经跑得飞快了。但是,代码写得快、跑得快就够了吗?如果代码乱得像一团麻,或者上线就崩,再快也是徒劳。

反应力测试应用

作者 Younglina
2026年1月28日 13:34

项目概述

这是一个基于 Nuxt 3 构建的认知能力测试应用,旨在通过两个经典的实验测试用户的反应时间和注意力控制能力。应用采用现代化的 Vue 3 技术栈开发,所有数据均本地存储,保护用户隐私。

image.png

image.png

image.png

在线体验:younglina.wang/reaction

核心特性

  • 🎨 现代化UI设计 - 提供优雅的视觉体验
  • 双重测试模式 - 包含颜色反应测试和 Simon Task 两套完整的认知测试
  • 📊 数据可视化 - 使用折线图实现反应时间趋势图表和统计分布
  • 💾 本地数据存储 - 基于 localStorage 的客户端数据持久化
  • 📱 响应式设计 - 完美适配桌面和移动设备
  • 🔒 隐私保护 - 所有数据保存在用户本地设备,不上传服务器

技术架构

技术栈

  • 前端框架: Nuxt 3.14.0
  • UI框架: Vue 3.5.0
  • 开发语言: TypeScript 5.0.0
  • 样式方案: Tailwind CSS 3.4.0
  • 图表库: Chart.js 4.4.0 + vue-chartjs 5.3.0
  • 构建工具: Vite (内置于 Nuxt 3)
  • 包管理器: npm

功能详解

1. 颜色反应测试 (Color Test)

颜色反应测试是评估简单视觉反应时间的经典实验。

测试原理

  1. 屏幕初始显示蓝色背景
  2. 随机延迟一定时间后变为红色
  3. 用户点击屏幕
  4. 系统记录反应时间(毫秒)

核心实现

const startTest = () => {

  // 设置随机延迟 (1-4秒)
  const delay = Math.floor(Math.random() * 3000) + 1000

  changeTimer = setTimeout(() => {

    changeTimestamp = performance.now()

    backgroundColor.value = 'bg-red-500'

    displayStatus.value = '点击!'

  }, delay)

}

const handleClick = () => {

  if (!isRunning) return

  // 计算反应时间
  const rt = Math.round(performance.now() - changeTimestamp)

  reactionTimes.push(rt)

  recalcStats()

  saveStats()

}

数据统计

  • 测试次数: 累计测试总数
  • 平均反应时间: 所有测试的平均值
  • 最快反应时间: 历史最佳成绩
  • 趋势图表: 折线图显示反应时间变化趋势

2. Simon Task

Simon Task 是测试认知控制和注意力分配的经典实验。

测试原理

  • 规则: 出现红色点击左边,出现绿色点击右边
  • 干扰: 颜色可能出现在屏幕左侧或右侧
  • 目标: 测试用户是否能克服空间位置干扰,正确执行颜色-动作映射

统计指标

  • 正确率: 正确反应百分比
  • 平均反应时间: 正确反应的平均时间
  • 错误次数: 测试过程中的错误反应
  • 进度跟踪: 当前测试进度(12次试验)

数据可视化

项目使用 Chart.js 的折线图与柱状图实现数据可视化。

许可证

MIT License 源码地址:github.com/Younglina/r…

HTTP-深度拆解 UDP 与 TCP 的核心差异

2026年1月28日 12:45

前言

在网络传输层,TCP 像是一位严谨的“快递员”,确保包裹万无一失;而 UDP 更像是一位“投递员”,只管快速把信件投进邮筒。在 HTTP/3.0(QUIC)全面转向 UDP 的今天,重新理解这两个协议的底层逻辑显得尤为重要。

一、 UDP:为了速度而生的“轻骑兵”

1. 核心概念

UDP(User Datagram Protocol)提供的是无连接、不可靠的传输服务。它不关心对方是否收到,只负责把报文“推”出去。

2. UDP为什么轻量级?

TCP 的首部至少 20 字节,而 UDP 只有固定的 8 字节,它没有复杂的序列号和窗口调节,这意味着 CPU 处理 UDP 包的速度远高于 TCP。这 8 个字节被平分为四个字段,每个字段 2 字节:

  1. 源端口号 (Source Port) :发送端的端口,在不需要对方回信时可全为 0。
  2. 目的端口号 (Destination Port) :接收端的端口,必须指定。
  3. 长度 (Length) :UDP 报文段的长度(包含首部和数据),最小值为 8。
  4. 校验和 (Checksum) :检测 UDP 报文在传输中是否有错,有错就丢弃。

2. 核心特点

  • 无连接时延:发送数据前不需要三次握手,拿起就发,响应极快。
  • 无拥塞控制:即便网络环境恶劣,UDP 也会保持恒定的发送速率(所以直播才会有“卡顿”而不是“暂停”)。
  • 面向报文:应用层给多少,它就传多少。不合并、不拆分,保留了报文的边界。
  • 低开销首部:首部仅占 8 字节,相比 TCP 的 20 字节起步,传输效率极高。
  • 灵活的多播性:天然支持一对一、一对多、多对多的交互通信。

3. 应用场景

  • 实时性要求高:视频会议、语音通话、在线直播。
  • 数据量小且频繁:DNS 查询(一次往返即结束)。
  • 新兴协议基础:HTTP/3.0 (QUIC 协议)。

二、 TCP 与 UDP:全维度对比

为了方便记忆,我们可以通过下表快速对比两者的差异:

维度 TCP (传输控制协议) UDP (用户数据报协议)
连接性 面向连接(需三次握手) 无连接(即发即收)
可靠性 可靠(保证不丢、不重、有序) 不可靠(尽力而为,不保结果)
传输效率 较慢(因各种控制机制) 极快(协议简单,实时性强)
资源消耗 较高 较低
通信模式 仅限点对点(1对1) 支持单播、多播、广播
首部开销 20 ~ 60 字节 固定 8 字节
数据流控制 流量控制、拥塞控制

三、 如何选择?(深度思考)

  • 选 TCP 的理由

    当你需要确保数据的完整性时。比如:发送一封邮件、下载一个安装包、或者是浏览网页。如果中间丢了一个字符,整个文件可能就失效了。

  • 选 UDP 的理由

    当你的应用场景对延迟敏感,且能容忍少量数据丢失时。比如:直播里少了一帧画面,人眼几乎察觉不到,但如果为了等这一帧而导致视频卡住 2 秒,用户体验会极差。

HTTP-深入浅出 TCP 协议:从报文结构到三次握手与四次挥手

2026年1月28日 12:25

前言

在计算机网络中,TCP (Transmission Control Protocol) 就像是一位极其负责的“快递员”。它不追求极速(那是 UDP 的事),但它保证每一份数据都能不丢、不重、按序地送达目的地。

一、 TCP 核心特性:为什么它如此可靠?

  • 面向连接:通信前必须“点对点”建立连接。
  • 可靠传输:通过序列号、确认应答、超时重传机制实现无差错传输。
  • 全双工通信:连接建立后,双方可以同时发送和接收数据。
  • 面向字节流:数据被视为无结构的字节流,由 TCP 根据窗口大小进行分段。

二、 读懂 TCP 报文:那些关键的“信号灯”

在理解握手之前,必须先看懂报文首部中的核心字段:

1. 序列号与确认

  • Sequence Number (seq) :报文段发送的第一个字节的序号,保证了数据的有序性
  • Acknowledgement Number (ack) :指期望收到对方下一个报文段的第一个字节序号,代表此前的序号已成功接收。

2. 控制位(标志符)

这些 1 bit 的标志位决定了报文的“性格”:

  • SYN=1:请求建立连接。
  • ACK=1:确认号有效。TCP 规定连接建立后所有报文 ACK 必须置 1。
  • FIN=1:请求释放连接。
  • RST=1:连接出现严重问题,强制重置。
  • PSH=1:尽快交付应用层,不要等缓冲区满。
  • URG=1:紧急数据优先处理。

三、 三次握手:如何安全地“握住”对方?

三次握手的核心目的:确认双方的收发能力均正常,并同步初始序列号。

  1. 第一次握手:客户端发送 SYN=1, seq=x。客户端进入 SYN-SENT 状态。

  2. 第二次握手:服务端返回 SYN=1, ACK=1, seq=y, ack=x+1。服务端进入 SYN-RECEIVED 状态。

  3. 第三次握手:客户端返回 ACK=1, seq=x+1, ack=y+1

    • 关键点:双方此后均进入 ESTABLISHED(已建立连接)状态。

image.png

💡 深度思考:为什么不是两次?

为了防止“已失效的连接请求”突然到达服务端导致资源浪费。

想象一下:。客户端发送了⼀个连接请求 A,但是因为⽹络原因造成了超时,这时 TCP 会启动超时重传的机制再次发送⼀个连接请求 B。此时请求顺利到达服务端,服务端应答完就建⽴了请求。如果连接请求 A 在两端关闭后终于抵达了服务端,那么这时服务端会认为客户端⼜需要建⽴ TCP 连接,从⽽应答了该请求并进⼊ ESTABLISHED 状态。此时客户端其实是 CLOSED 状 态,那么就会导致服务端⼀直等待,造成资源的浪费。


四、 四次挥手:体面的“分手”流程

TCP 连接是双向的,因此断开连接时,每一端都要单独关闭。

  1. 第一次挥手:主动方发送 FIN=1,表示“我不再发数据了”。
  2. 第二次挥手:被动方返回 ACK=1。此时连接处于半关闭状态,被动方可能还有没发完的数据要继续传。
  3. 第三次挥手:被动方发送完数据后,发送 FIN=1,表示“我也可以关了”。
  4. 第四次挥手:主动方返回 ACK=1。经过 2*MSL 时间后,连接彻底关闭。

💡 深度思考:为什么挥手比握手多一次?

  • 握手时:服务端可以把 SYN(连接请求)和 ACK(确认)放在一个包里发回去。
  • 挥手时:当服务端收到 FIN 时,它可能还有数据没发完。所以它先回一个 ACK 表示收到了,等数据发完了才发自己的 FIN这就导致了 ACKFIN 分开发送。

五、 总结:TCP 与流量控制

除了握手挥手,TCP 还通过 Window Size(窗口大小) 实现了流量控制:

  • 接收端根据自己的处理能力告诉发送端:“我还能收多少”。
  • 这样可以防止发送端发得太快,导致接收端缓冲区溢出。

vue3+vite+ts创建项目-企业级

2026年1月28日 12:23

Vue3+Vite+TS 企业级项目搭建完整指南(含多环境配置)

本文基于 Vue3、Vite4+、TypeScript 构建企业级项目,整合多环境配置、代码规范、自动导入、样式处理等核心功能,配置结构清晰可扩展,适配生产、开发、测试多场景需求。

一、初始化 Vue3 项目

采用 Vite 构建工具(比 Webpack 启动更快、热更新更高效,是 Vue3 官方推荐方案),快速初始化项目并集成 TypeScript。

步骤 1:执行初始化命令

# 使用 npm/cnpm/pnpm 均可,这里以 cnpm 为例
cnpm create vite@latest

# 若需指定版本,可执行:cnpm create vite

步骤 2:交互式配置项目

  1. 输入项目名:如 vite-vue3-ts-enterprise(建议英文,避免特殊字符)
  2. 选择框架:上下键切换至 Vue(默认适配 Vue3)
  3. 选择变体:切换至 TypeScript(集成 TS 类型校验)

步骤 3:安装依赖并启动项目

# 进入项目目录
cd vite-vue3-ts-enterprise

# 安装依赖(优先用项目包管理器,避免版本冲突)
cnpm install

# 启动开发环境
cnpm run dev

启动成功后,访问 http://localhost:5173 即可看到 Vue3 初始页面。Vite 默认端口为 5173,后续可在配置中修改。

二、基础配置(环境、别名、类型)

完善项目基础配置,解决路径别名、Node 类型、环境变量加载等核心问题,适配企业级开发习惯。

步骤 1:安装 Node 类型依赖

为 TS 提供 Node 环境类型定义,避免路径处理等操作时 TS 报错。

cnpm i @types/node --save-dev

步骤 2:配置 tsconfig.json

优化 TS 编译规则,添加路径别名、类型目录等配置,确保 TS 语法兼容 Vue3 单文件组件(SFC)。

{
  "compilerOptions": {
    "typeRoots": [
      "node_modules/@types", // 默认类型目录
      "src/types" // 自定义类型目录(后续可存放全局类型)
    ],
    "target": "ESNext", // 目标 ES 版本
    "useDefineForClassFields": true, // 适配 Vue3 类组件
    "module": "ESNext", // 模块规范
    "moduleResolution": "Node", // 模块解析方式
    "strict": true, // 开启严格模式(强制类型校验)
    "jsx": "preserve", // 保留 JSX 语法(适配 Vue3 JSX/TSX)
    "resolveJsonModule": true, // 允许导入 JSON 文件
    "isolatedModules": true, // 确保每个文件都是独立模块(Vite 要求)
    "esModuleInterop": true, // 兼容 CommonJS 模块
    "lib": ["ESNext", "DOM"], // 引入 ES 特性和 DOM 类型
    "skipLibCheck": true, // 跳过第三方库类型校验(提升编译速度)
    "noEmit": true, // 不生成编译产物(Vite 负责构建)
    "baseUrl": "./", // 基础路径
    "paths": { // 路径别名(简化导入,避免相对路径嵌套)
      "@": ["src"],
      "@/*": ["src/*"]
    }
  },
  "include": [ // 需要 TS 校验的文件
    "src/**/*.ts",
    "src/**/*.d.ts",
    "src/**/*.tsx",
    "src/**/*.vue",
    "build/**/*" // 新增:让 TS 识别 build 文件夹下的配置文件
  ],
  "references": [
    { "path": "./tsconfig.node.json" } // 关联 Node 环境配置
  ]
}

步骤 3:配置多环境变量(env 文件夹)

创建独立 env 文件夹管理不同环境变量,实现开发、测试、生产环境隔离,避免硬编码。

1. 创建 env 文件夹及配置文件

# 根目录创建 env 文件夹
mkdir env

# 新建 3 个环境配置文件(对应开发、测试、生产)
touch env/.env.development
touch env/.env.test
touch env/.env.production

2. 编写各环境变量

Vite 环境变量需以 VITE_ 为前缀,否则无法在业务代码中访问。

  • env/.env.development(开发环境) # 开发环境 API 基础地址 `` VITE_API_BASE_URL=http://localhost:3000/api `` # 环境标识 `` VITE_ENV=development `` # 调试模式(开发环境开启) ``VITE_DEBUG=true
  • env/.env.test(测试环境) VITE_API_BASE_URL=https://test.api.example.com `` VITE_ENV=test ``VITE_DEBUG=false
  • env/.env.production(生产环境) VITE_API_BASE_URL=https://api.example.com `` VITE_ENV=production ``VITE_DEBUG=false

步骤 4:拆分多环境 Vite 配置(build 文件夹)

将 Vite 配置拆分为「基础配置+环境专属配置」,统一放入 build 文件夹,提升可维护性,后续新增环境可快速扩展。

1. 创建 build 文件夹及配置文件

# 根目录创建 build 文件夹(集中管理所有配置)
mkdir build

# 新建 3 个配置文件
touch build/vite.base.ts # 基础通用配置
touch build/vite.dev.ts  # 开发环境配置
touch build/vite.prod.ts # 生产环境配置

调整后根目录核心结构:

vite-vue3-ts-enterprise/
├── build/               # 配置文件目录
│   ├── vite.base.ts     # 基础配置(通用逻辑)
│   ├── vite.dev.ts      # 开发环境专属配置
│   └── vite.prod.ts     # 生产环境专属配置
├── env/                 # 环境变量目录
├── src/                 # 业务代码目录
├── public/              # 静态资源目录
├── package.json
├── tsconfig.json
└── tsconfig.node.json

2. 编写 build 文件夹下的配置文件

(1)build/vite.base.ts(基础通用配置)

抽取所有环境共用逻辑,如插件、别名、环境变量目录、静态资源处理等。

import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue'; // Vue 单文件组件插件
import vueJsx from '@vitejs/plugin-vue-jsx'; // 支持 Vue3 JSX/TSX
import path from 'path';

export default defineConfig({
  // 环境变量目录(指定 env 文件夹,而非默认根目录)
  envDir: path.resolve(__dirname, '../env'),
  // 插件配置(所有环境共用插件)
  plugins: [
    vue(), // 解析 .vue 文件
    vueJsx() // 解析 .jsx/.tsx 文件
  ],
  // 路径解析配置
  resolve: {
    alias: {
      // 别名 @ 指向 src 目录(与 tsconfig.json 保持一致)
      '@': path.resolve(__dirname, '../src')
    }
  },
  // 基础构建配置
  build: {
    outDir: path.resolve(__dirname, '../dist'), // 打包输出目录
    assetsDir: 'assets', // 静态资源存放目录
    rollupOptions: {
      // 静态资源分类打包(按后缀名分组)
      output: {
        assetFileNames: (assetInfo) => {
          if (assetInfo.name?.endsWith('.css')) {
            return 'css/[name].[hash:8].[ext]';
          }
          if (assetInfo.name?.match(/.(png|jpg|jpeg|gif|svg)$/i)) {
            return 'images/[name].[hash:8].[ext]';
          }
          return 'assets/[name].[hash:8].[ext]';
        },
        // JS 文件分类打包
        chunkFileNames: 'js/chunks/[name].[hash:8].js',
        entryFileNames: 'js/[name].[hash:8].js'
      }
    }
  }
});
(2)build/vite.dev.ts(开发环境配置)

补充开发环境独有逻辑,如热更新、代理、端口配置等,优化开发体验。

import { defineConfig ,mergeConfig} from 'vite';
import path from 'path';
import baseConfig from './vite.base';

export default mergeConfig(
  baseConfig,
  defineConfig({
    mode: 'development', // 开发模式
    server: {
      port: 8080, // 自定义开发端口(替换默认 5173)
      open: true, // 启动后自动打开浏览器
      hmr: { // 热模块替换(提升热更新速度)
        host: 'localhost',
        port: 8080
      },
      proxy: { // 接口代理(解决跨域问题)
        '/api': {
          target: process.env.VITE_API_BASE_URL, // 读取环境变量中的 API 地址
          changeOrigin: true, // 开启跨域代理
          rewrite: (path) => path.replace(/^/api/, '') // 重写路径(移除前缀 /api)
        }
      }
    },
    css: {
      devSourcemap: true // 开发环境生成 CSS SourceMap(便于调试样式)
    }
  })
);
(3)build/vite.prod.ts(生产环境配置)

补充生产环境优化逻辑,如压缩、清除控制台、SourceMap 控制等,提升打包产物性能。

import { defineConfig ,mergeConfig} from 'vite';
import { visualizer } from 'rollup-plugin-visualizer'; // 打包分析插件
import baseConfig from './vite.base';

export default mergeConfig(
  baseConfig,
  defineConfig({
    mode: 'production', // 生产模式
    build: {
      minify: 'terser', // 使用 terser 压缩代码(比默认 esbuild 压缩更彻底)
      sourcemap: false, // 生产环境关闭 SourceMap(保护源码,减小包体积)
      terserOptions: {
        compress: {
          drop_console: true, // 清除控制台打印(生产环境可选)
          drop_debugger: true // 清除 debugger 语句
        }
      }
    },
    plugins: [
      // 打包分析插件(可选,生成可视化报告,优化包体积)
      visualizer({
        open: false, // 不自动打开报告
        filename: path.resolve(__dirname, '../dist/analysis.html')
      })
    ]
  })
);

4. 更新 package.json 脚本

修改启动、打包脚本,指向 build 文件夹下的对应配置文件,实现按环境加载配置。

"scripts": {
  "dev": "vite --config build/vite.dev.ts", // 启动开发环境
  "build:dev": "vue-tsc -b && vite build --config build/vite.dev.ts", // 开发环境打包(测试用)
  "build:test": "vue-tsc -b && vite build --config build/vite.prod.ts --mode test", // 测试环境打包
  "build:prod": "vue-tsc -b && vite build --config build/vite.prod.ts", // 生产环境打包
  "type-check": "vue-tsc --noEmit", // TS 类型校验(不生成产物)
  "preview": "vite preview" // 预览打包产物
}

vue-tsc -b 用于在打包前执行 TS 类型校验,若存在类型错误则终止打包,避免带错上线;--mode 参数用于指定环境,匹配 env 文件夹下的配置文件。

三、集成代码规范工具(ESLint + Prettier)

统一代码风格,减少团队协作冲突,自动修复格式问题,确保代码质量。需先在 VS Code 安装 ESLintPrettier 插件。

步骤 1:安装依赖

# ESLint 核心及 Vue/TS 适配依赖
cnpm i eslint @eslint/js typescript-eslint @typescript-eslint/parser @typescript-eslint/plugin eslint-plugin-vue vue-eslint-parser -D

# Prettier 及 ESLint 兼容依赖(解决两者规则冲突)
cnpm i prettier eslint-config-prettier eslint-plugin-prettier -D

# Vite ESLint 插件(开发时实时校验)
cnpm i vite-plugin-eslint -D

步骤 2:配置 ESLint(eslint.config.js)

ESLint 8.21.0+ 支持扁平配置文件,适配 Vue3+TS 语法,集成 Prettier 规则。

import globals from "globals";
import pluginJs from "@eslint/js";
import tseslint from "typescript-eslint";
import pluginVue from "eslint-plugin-vue";
import prettier from "eslint-plugin-prettier";
import prettierConfig from "eslint-config-prettier";
import eslintParser from "vue-eslint-parser";

export default [
  // 配置忽略文件(替代传统 .eslintignore)
  {
    ignores: [
      "node_modules/**",
      "dist/**",
      "public/**",
      "build/**",
      "src/assets/**",
      "*.config.js",
      "*.config.ts"
    ]
  },
  // 基础配置(适配 Vue/TS 文件)
  {
    files: ["**/*.{js,mjs,cjs,vue,ts,tsx}"],
    languageOptions: {
      parser: eslintParser, // 解析 Vue 单文件组件
      parserOptions: {
        parser: "@typescript-eslint/parser", // 解析 TS 语法
        ecmaVersion: 2020,
        sourceType: "module"
      },
      globals: { ...globals.browser, ...globals.node } // 全局变量
    },
    plugins: {
      vue: pluginVue,
      "@typescript-eslint": tseslint.plugin,
      prettier: prettier // 集成 Prettier
    },
    rules: {
      // 基础规则
      "no-var": "error", // 禁止使用 var
      "no-console": process.env.NODE_ENV === "production" ? "error" : "off", // 生产环境禁止 console
      "no-multiple-empty-lines": ["warn", { max: 1 }], // 最多允许 1 行空行
      
      // Vue 规则
      "vue/multi-word-component-names": "off", // 关闭组件名多单词校验(灵活命名)
      "vue/valid-template-root": "off", // 允许模板根节点多元素
      
      // TS 规则
      "@typescript-eslint/no-explicit-any": "off", // 允许使用 any(可选,根据团队规范调整)
      "no-unused-vars": ["error", { "varsIgnorePattern": "Vue" }], // 忽略 Vue 未使用警告
      
      // Prettier 规则(将 Prettier 错误作为 ESLint 错误提示)
      "prettier/prettier": "error"
    }
  },
  // 集成推荐规则
  pluginJs.configs.recommended,
  ...tseslint.configs.recommended,
  ...pluginVue.configs["flat/essential"],
  ...pluginVue.configs["flat/recommended"],
  prettierConfig // 覆盖 ESLint 与 Prettier 冲突的规则
];

步骤 3:配置 Prettier(.prettierrc.js)

定义代码格式化规则,与 ESLint 规则兼容,统一团队代码风格。

module.exports = {
  printWidth: 80, // 一行最多 80 字符
  tabWidth: 2, // 2 个空格缩进(与 Vue 官方一致)
  useTabs: false, // 不使用 Tab 缩进
  semi: true, // 行尾添加分号
  singleQuote: true, // 使用单引号
  quoteProps: "as-needed", // 对象 key 仅必要时加引号
  jsxSingleQuote: false, // JSX 中使用双引号
  trailingComma: "all", // 对象/数组末尾添加逗号(便于 diff)
  bracketSpacing: true, // 大括号内保留空格 { foo: bar }
  jsxBracketSameLine: false, // JSX 闭合标签换行
  arrowParens: "always", // 箭头函数单参数也加括号 (x) => x
  endOfLine: "auto" // 自动适配系统换行符
};

步骤 4:配置 VS Code 自动格式化(.vscode/settings.json)

实现保存时自动修复 ESLint 错误并执行 Prettier 格式化,提升开发效率。

{
  "eslint.enable": true,
  "eslint.format.enable": true,
  "editor.quickSuggestions": true,
  "eslint.validate": ["javascript", "javascriptreact", "vue-html", "typescript", "html", "vue"],
  "eslint.options": {
    "extensions": [".js", ".jsx", ".ts", ".tsx", ".vue"]
  },
  "editor.formatOnSave": true, // 保存时格式化
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit" // 保存时自动修复 ESLint 错误
  },
  // 不同文件指定默认格式化工具
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "[vue]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  },
  "[typescript]": {
    "editor.defaultFormatter": "esbenp.prettier-vscode"
  }
}

步骤 5:添加脚本命令

"scripts": {
  // 新增 ESLint/Prettier 命令
  "lint": "eslint "src/**/*.{js,ts,vue,tsx}" --fix", // 自动修复 ESLint 错误
  "prettier": "prettier --write "src/**/*.{js,ts,vue,tsx,json,css}"" // 自动格式化
}

四、集成 Git 提交规范(Husky + Lint-Staged + CommitLint)

约束 Git 提交信息,在提交前校验代码规范,避免不合格代码入库,保障代码仓库整洁。

步骤 1:初始化 Git 仓库(若未初始化)

git init

步骤 2:安装依赖

# Husky(Git Hook 工具)、Lint-Staged(暂存区代码校验)
cnpm i husky@9.1.2 lint-staged@^15.2.7 -D

# CommitLint(提交信息校验)
cnpm i @commitlint/cli@^19.3.0 @commitlint/config-conventional@^19.2.2 -D

步骤 3:配置 Husky

# 生成 .husky 文件夹(存储 Git Hook 脚本)
npx husky init

# 手动创建 pre-commit(提交前校验)和 commit-msg(提交信息校验)脚本
touch .husky/pre-commit
touch .husky/commit-msg

步骤 4:编写 Hook 脚本

  • .husky/pre-commit(提交前校验暂存区代码) #!/bin/sh `` . "$(dirname -- "$0")/_/husky.sh" ```` echo -e "\033[33m ------------- 正在校验暂存区代码规范 ---------------- \033[0m" ``npx --no-install lint-staged
  • .husky/commit-msg(校验提交信息格式) #!/bin/sh `` . "$(dirname -- "$0")/_/husky.sh" ```` echo -e "\033[33m ------------- 正在校验提交信息格式 ---------------- \033[0m" ``npx --no-install commitlint --edit "$1"

步骤 5:配置 Lint-Staged(package.json)

仅对暂存区代码执行校验和格式化,提升效率(避免全量校验)。

"lint-staged": {
  "src/**/*.{vue,js,jsx,ts,tsx}": [
    "eslint --fix", // 自动修复 ESLint 错误
    "prettier --write" // 格式化代码
  ],
  "src/**/*.{cjs,json,css}": [
    "prettier --write" // 格式化配置文件和样式文件
  ]
}

步骤 6:配置 CommitLint(commitlint.config.cjs)

由于 package.json 默认为 ES 模块,需用 .cjs 后缀声明 CommonJS 格式。

module.exports = {
  ignores: [commit => commit.includes('init')], // 忽略 init 初始化提交
  extends: ['@commitlint/config-conventional'], // 基础规范
  rules: {
    'body-leading-blank': [2, 'always'], // 提交描述主体前空行
    'footer-leading-blank': [1, 'always'], // 底部说明前空行
    'header-max-length': [2, 'always', 108], // 标题最大长度 108
    'subject-empty': [2, 'never'], // 标题不可为空
    'type-empty': [2, 'never'], // 类型不可为空
    'type-enum': [ // 允许的提交类型(规范提交场景)
      2,
      'always',
      [
        'wip', // 开发中
        'feat', // 新增功能
        'fix', // 修复 Bug
        'test', // 测试相关
        'refactor', // 代码重构
        'build', // 构建配置(如依赖、打包)
        'docs', // 文档更新
        'perf', // 性能优化
        'style', // 代码风格(不影响逻辑)
        'ci', // 持续集成配置
        'chore', // 琐事(如配置文件修改)
        'revert', // 回滚代码
        'types', // 类型声明更新
        'release' // 版本发布
      ]
    ]
  }
};

提交格式规范

git commit -m "<type>[optional scope]: <description>"

# 示例
git commit -m "feat[user]: 新增用户登录功能"
git commit -m "fix[api]: 修复用户列表接口跨域问题"

说明:type 为提交类型(必填),optional scope 为涉及模块(可选),description 为提交描述(必填,简洁明了)。

五、Vue3 专属插件集成(提升开发效率)

步骤 1:自动导入(API/组件)

无需手动导入 Vue 内置 API(如 ref、reactive)和全局组件,减少模板代码。

# 安装自动导入插件
cnpm i unplugin-auto-import unplugin-vue-components -D

# 若使用 UI 库(如 Ant Design Vue),需安装对应解析器
cnpm i unplugin-vue-components/resolvers -D

更新 build/vite.base.ts,添加插件配置:

import AutoImport from 'unplugin-auto-import/vite';
import Components from 'unplugin-vue-components/vite';
import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'; // AntD Vue 解析器

export default defineConfig({
  plugins: [
    // ... 原有插件
    // 自动导入 Vue API
    AutoImport({
      imports: ['vue', 'vue-router', 'pinia'], // 自动导入的库
      dts: path.resolve(__dirname, '../src/auto-imports.d.ts'), // 生成类型声明文件
      eslintrc: {
        enabled: true // 生成 ESLint 配置,避免未导入警告
      }
    }),
    // 自动导入组件
    Components({
      dirs: [path.resolve(__dirname, '../src/components')], // 自定义组件目录
      extensions: ['vue', 'tsx'], // 组件后缀
      dts: path.resolve(__dirname, '../src/components.d.ts'), // 生成组件类型声明
      resolvers: [AntDesignVueResolver({ importStyle: false })], // UI 库组件自动导入
    })
  ]
});

步骤 2:PX 转 REM(适配多端)

实现移动端自适应,将 PX 自动转为 REM,替代手动计算。

cnpm i postcss @minko-fe/postcss-pxtorem autoprefixer -D

根目录创建 postcss.config.js

import pxtorem from '@minko-fe/postcss-pxtorem';
import autoprefixer from 'autoprefixer';

export default {
  plugins: [
    pxtorem({
      rootValue: 16, // 基准值(1rem = 16px,可根据设计稿调整)
      unitPrecision: 5, // 转换精度(保留 5 位小数)
      propList: ['*'], // 所有属性都转换
      selectorBlackList: ['no-rem'], // 类名含 no-rem 的不转换
      atRules: ['media'], // 媒体查询中的 PX 也转换
      exclude: /node_modules/ // 排除第三方库
    }),
    autoprefixer() // 自动添加 CSS 前缀(适配低版本浏览器)
  ]
};

步骤 3:SVG 组件化

将 SVG 图片转为 Vue 组件,支持按需引入和样式修改。

cnpm i vite-svg-loader -D

更新 build/vite.base.ts

import svgLoader from 'vite-svg-loader';

export default defineConfig({
  plugins: [
    // ... 原有插件
    svgLoader() // 解析 SVG 为 Vue 组件
  ]
});

使用方式:import Logo from '@/assets/logo.svg';,直接作为组件使用 <Logo />

六、项目测试与验证

  1. 开发环境启动cnpm run dev,验证热更新、接口代理、自动导入是否正常。
  2. 类型校验cnpm run type-check,确保无 TS 类型错误。
  3. 代码规范校验cnpm run lint,自动修复格式错误。
  4. 生产环境打包cnpm run build:prod,验证打包产物是否正常,体积是否合理。
  5. 提交测试:修改代码后执行 git add .git commit -m "test: 测试提交规范",验证 Husky 校验是否生效。

七、总结

本指南构建了一套企业级 Vue3+Vite+TS 项目架构,核心亮点:

  • 多环境配置拆分,环境隔离清晰,可快速扩展测试环境。
  • 完整代码规范体系,从开发到提交全流程约束,保障代码质量。
  • Vue3 专属插件集成,自动导入、自适应等功能提升开发效率。
  • 配置结构清晰,build 文件夹集中管理配置,便于后期维护。

可根据项目需求扩展 Pinia(状态管理)、Vue Router(路由)、单元测试等功能,适配更复杂的业务场景。

Three.js CSS2D渲染器实现3D标签效果

2026年1月28日 12:20

概述

本文将详细介绍如何使用 Three.js 的 CSS2DRenderer 来在 3D 场景中添加 HTML 标签。CSS2DRenderer 是 Three.js 提供的一种特殊的渲染器,它允许我们在 3D 对象上叠加 HTML 元素,非常适合创建标签、标注和信息展示等效果。

screenshot_2026-01-28_12-18-09.gif

准备工作

首先,我们需要引入必要的 Three.js 库和 CSS2D 渲染器模块:

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import {
  CSS2DRenderer,
  CSS2DObject,
} from "three/examples/jsm/renderers/CSS2DRenderer.js";

场景初始化

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

let camera, scene, renderer, labelRenderer;

const clock = new THREE.Clock();
const textureLoader = new THREE.TextureLoader();

let moon;
let chinaLabel;
const raycaster = new THREE.Raycaster();

function init() {
  const EARTH_RADIUS = 1;
  const MOON_RADIUS = 0.27;

  camera = new THREE.PerspectiveCamera(
    45,
    window.innerWidth / window.innerHeight,
    0.1,
    200
  );
  camera.position.set(0, 5, -10);

  scene = new THREE.Scene();
}

光照设置

添加适当的光照使场景更真实:

const dirLight = new THREE.DirectionalLight(0xffffff);
dirLight.position.set(0, 0, 1);
scene.add(dirLight);

const light = new THREE.AmbientLight(0xffffff, 0.5); // soft white light
scene.add(light);

创建地球模型

使用纹理贴图创建地球模型:

const earthGeometry = new THREE.SphereGeometry(EARTH_RADIUS, 16, 16);
const earthMaterial = new THREE.MeshPhongMaterial({
  specular: 0x333333,
  shininess: 5,
  map: textureLoader.load("textures/planets/earth_atmos_2048.jpg"),
  specularMap: textureLoader.load("textures/planets/earth_specular_2048.jpg"),
  normalMap: textureLoader.load("textures/planets/earth_normal_2048.jpg"),
  normalScale: new THREE.Vector2(0.85, 0.85),
});

const earth = new THREE.Mesh(earthGeometry, earthMaterial);
scene.add(earth);

创建月球模型

同样地,创建月球模型:

const moonGeometry = new THREE.SphereGeometry(MOON_RADIUS, 16, 16);
const moonMaterial = new THREE.MeshPhongMaterial({
  shininess: 5,
  map: textureLoader.load("textures/planets/moon_1024.jpg"),
});
moon = new THREE.Mesh(moonGeometry, moonMaterial);
scene.add(moon);

创建CSS2D标签

这是关键部分,我们使用 CSS2DObject 来创建可以附加到 3D 对象上的 HTML 标签:

// 创建地球标签
const earthDiv = document.createElement('div');
earthDiv.className = "label";
earthDiv.innerHTML = "地球";
const earthLabel = new CSS2DObject(earthDiv);
earthLabel.position.set(0, 1, 0);
earth.add(earthLabel);

// 创建中国标签
const chinaDiv = document.createElement('div');
chinaDiv.className = "label1";
chinaDiv.innerHTML = "中国";
chinaLabel = new CSS2DObject(chinaDiv);
chinaLabel.position.set(-0.3, 0.5, -0.9);
earth.add(chinaLabel);

// 创建月球标签
const moonDiv = document.createElement('div');
moonDiv.className = "label";
moonDiv.innerHTML = "月球";
const moonLabel = new CSS2DObject(moonDiv);
moonLabel.position.set(0, 0.3, 0);
moon.add(moonLabel);

CSS2D渲染器设置

实例化并配置 CSS2D 渲染器:

// 实例化css2d的渲染器
labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(labelRenderer.domElement);
labelRenderer.domElement.style.position = 'fixed';
labelRenderer.domElement.style.top = '0px';
labelRenderer.domElement.style.left = '0px';
labelRenderer.domElement.style.zIndex = '10';

WebGL渲染器设置

设置标准的 WebGL 渲染器:

renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

控制器设置

配置轨道控制器以便用户可以交互:

const controls = new OrbitControls(camera, labelRenderer.domElement);
controls.minDistance = 5;
controls.maxDistance = 100;

窗口大小调整

处理窗口大小变化:

window.addEventListener("resize", onWindowResize);

function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  labelRenderer.setSize(window.innerWidth, window.innerHeight);
}

动画循环与标签隐藏检测

在动画循环中,我们不仅移动月球,还实现了标签的智能显示/隐藏逻辑:

function animate() {
  requestAnimationFrame(animate);

  const elapsed = clock.getElapsedTime();

  // 移动月球
  moon.position.set(Math.sin(elapsed) * 5, 0, Math.cos(elapsed) * 5);

  // 检测中国标签是否被遮挡
  const chinaPosition = chinaLabel.position.clone();
  // 计算出标签跟摄像机的距离
  const labelDistance = chinaPosition.distanceTo(camera.position);
  // 检测射线的碰撞
  // 向量(坐标)从世界空间投影到相机的标准化设备坐标 (NDC) 空间。
  chinaPosition.project(camera);
  raycaster.setFromCamera(chinaPosition, camera);

  const intersects = raycaster.intersectObjects(scene.children, true);

  // 如果没有碰撞到任何物体,那么让标签显示
  if(intersects.length == 0){
    chinaLabel.element.classList.add('visible');
  } else {
    const minDistance = intersects[0].distance;
    if(minDistance < labelDistance){
      chinaLabel.element.classList.remove('visible');
    } else {
      chinaLabel.element.classList.add('visible');
    }
  }

  // 标签渲染器渲染
  labelRenderer.render(scene, camera);

  // WebGL渲染器渲染
  renderer.render(scene, camera);
}

CSS样式

为了让标签正确显示,我们需要适当的 CSS 样式:

.label {
  color: #fff;
  font-size: 1rem;
}

.label1 {
  color: #fff;
  display: none;
  font-size: 1rem;
}

.label1.visible {
  display: block;
}

CSS2DRenderer 工作原理

CSS2DRenderer 的工作原理是:

  1. 它不会渲染 3D 几何体,而是将附加到 3D 对象上的 HTML 元素定位到相应的位置
  2. 它会根据相机视角自动调整 HTML 元素的位置和大小
  3. HTML 元素始终保持面向相机,提供良好的可读性

标签遮挡检测

在这个示例中,我们实现了智能的标签遮挡检测:

  1. 使用 Raycaster 计算从相机到标签的射线
  2. 检查射线上是否有其他物体会遮挡标签
  3. 如果有遮挡,则隐藏标签;如果没有遮挡,则显示标签

优势与应用场景

CSS2DRenderer 的优势包括:

  1. 可以使用完整的 HTML 和 CSS 功能
  2. 支持复杂的布局和交互
  3. 文字渲染质量高
  4. 易于集成现有的 UI 组件

典型应用场景包括:

  • 3D 场景中的标注和信息展示
  • 地图应用中的地点标签
  • 3D 模型的部件说明
  • 数据可视化中的标签显示

总结

通过这个项目,我们学习了如何使用 Three.js 的 CSS2DRenderer:

  1. 如何创建和配置 CSS2DRenderer
  2. 如何使用 CSS2DObject 将 HTML 元素附加到 3D 对象
  3. 如何处理两个渲染器的协调工作
  4. 如何实现智能的标签遮挡检测

CSS2DRenderer 是一个强大的工具,它可以让我们在 3D 场景中轻松添加富文本标签和其他 HTML 元素,极大地增强了 3D 应用的信息展示能力。

Three.js 后期处理效果合成详解

2026年1月28日 12:12

概述

本文将详细介绍如何使用 Three.js 的后期处理系统来创建各种视觉效果。后期处理是在场景渲染完成后,对最终图像进行额外处理的技术,可以用来实现发光、模糊、故障效果等多种视觉增强效果。

screenshot_2026-01-28_12-08-21.gif

screenshot_2026-01-28_12-09-32.gif

准备工作

首先,我们需要引入必要的 Three.js 库和后期处理模块:

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import gsap from "gsap";
import * as dat from "dat.gui";
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";

// 导入后期效果合成器
import {EffectComposer} from 'three/examples/jsm/postprocessing/EffectComposer';

// three框架本身自带效果
import {RenderPass} from 'three/examples/jsm/postprocessing/RenderPass';
import {DotScreenPass} from 'three/examples/jsm/postprocessing/DotScreenPass';
import {SMAAPass} from 'three/examples/jsm/postprocessing/SMAAPass';
import {SSAARenderPass} from 'three/examples/jsm/postprocessing/SSAARenderPass';
import {GlitchPass} from 'three/examples/jsm/postprocessing/GlitchPass';
import {UnrealBloomPass} from 'three/examples/jsm/postprocessing/UnrealBloomPass';
import {ShaderPass} from 'three/examples/jsm/postprocessing/ShaderPass';

场景初始化

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

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

// 创建透视相机
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerHeight / window.innerHeight,
  1,
  50
);

// 设置相机位置
camera.position.set(0, 0, 3);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
scene.add(camera);

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

环境设置

设置环境纹理和光照:

// 加载纹理
const textureLoader = new THREE.TextureLoader();

// 添加环境纹理
const cubeTextureLoader = new THREE.CubeTextureLoader();
const envMapTexture = cubeTextureLoader.load([
  "textures/environmentMaps/0/px.jpg",
  "textures/environmentMaps/0/nx.jpg",
  "textures/environmentMaps/0/py.jpg",
  "textures/environmentMaps/0/ny.jpg",
  "textures/environmentMaps/0/pz.jpg",
  "textures/environmentMaps/0/nz.jpg",
]);
scene.background = envMapTexture;
scene.environment = envMapTexture;

// 添加方向光
const directionLight = new THREE.DirectionalLight('#ffffff', 1);
directionLight.castShadow = true;
directionLight.position.set(0, 0, 200);
scene.add(directionLight);

模型加载

加载 3D 模型:

// 模型加载
const gltfLoader = new GLTFLoader();
gltfLoader.load('./models/DamagedHelmet/glTF/DamagedHelmet.gltf', (gltf) => {
  console.log(gltf);
  const mesh = gltf.scene.children[0];
  scene.add(mesh);
});

后期处理合成器设置

这是后期处理的核心部分,创建效果合成器并添加各种通道:

// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;

// 合成效果
const effectComposer = new EffectComposer(renderer);
effectComposer.setSize(window.innerWidth, window.innerHeight);

// 添加渲染通道
const renderPass = new RenderPass(scene, camera);
effectComposer.addPass(renderPass);

// 点效果
const dotScreenPass = new DotScreenPass();
dotScreenPass.enabled = false;
effectComposer.addPass(dotScreenPass);

// 抗锯齿
const smaaPass = new SMAAPass();
effectComposer.addPass(smaaPass);

// 发光效果
const unrealBloomPass = new UnrealBloomPass();
effectComposer.addPass(unrealBloomPass);

// 屏幕闪动
// const glitchPass = new GlitchPass();
// effectComposer.addPass(glitchPass)

发光效果参数调节

设置发光效果的参数并添加 GUI 控制:

renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
unrealBloomPass.strength = 1;
unrealBloomPass.radius = 0;
unrealBloomPass.threshold = 1;

// 添加GUI控制
gui.add(renderer,'toneMappingExposure').min(0).max(2).step(0.01);
gui.add(unrealBloomPass,'strength').min(0).max(2).step(0.01);
gui.add(unrealBloomPass,'radius').min(0).max(2).step(0.01);
gui.add(unrealBloomPass,'threshold').min(0).max(2).step(0.01);

自定义着色器后期处理

创建自定义着色器效果:

// 着色器写渲染通道
const shaderPass = new ShaderPass(
  {
    uniforms:{
      tDiffuse:{
        value:null
      },
      uColor:{
        value:new THREE.Color(colorParams.r,colorParams.g,colorParams.b)
      }
    },
    vertexShader:`
      varying vec2 vUv;
      void main(){
        vUv = uv;
        gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);
      }
    `,
    fragmentShader:`
      varying vec2 vUv;
      uniform sampler2D tDiffuse;
      uniform vec3 uColor;
      void main(){
        vec4 color = texture2D(tDiffuse,vUv);
        // gl_FragColor = vec4(vUv,0.0,1.0);
        color.xyz+=uColor;
        gl_FragColor = color;
      }
    `
  }
);

effectComposer.addPass(shaderPass);

// 颜色参数控制
const colorParams = {
  r:0,
  g:0,
  b:0
}

gui.add(colorParams,'r').min(-1).max(1).step(0.01).onChange((value)=>{
  shaderPass.uniforms.uColor.value.r = value;
});
gui.add(colorParams,'g').min(-1).max(1).step(0.01).onChange((value)=>{
  shaderPass.uniforms.uColor.value.g = value;
});
gui.add(colorParams,'b').min(-1).max(1).step(0.01).onChange((value)=>{
  shaderPass.uniforms.uColor.value.b = value;
});

技术效果着色器

添加技术感的后期处理效果:

const normalTexture = textureLoader.load('./textures/interfaceNormalMap.png');

const techPass = new ShaderPass({
  uniforms:{
    tDiffuse:{
      value:null
    },
    uNormalMap:{
      value:null
    },
    uTime:{
      value:0
    }
  },
  vertexShader:`
    varying vec2 vUv;
    void main(){
      vUv = uv;
      gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);
    }
  `,
  fragmentShader:`
    varying vec2 vUv;
    uniform sampler2D tDiffuse;
    uniform sampler2D uNormalMap;
    uniform float uTime;
    void main(){

      vec2 newUv = vUv;
      newUv += sin(newUv.x*10.0+uTime*0.5)*0.03;

      vec4 color = texture2D(tDiffuse,newUv);
      // gl_FragColor = vec4(vUv,0.0,1.0);
      vec4 normalColor = texture2D(uNormalMap,vUv);
      // 设置光线的角度
      vec3 lightDirection = normalize(vec3(-5,5,2)) ;

      float lightness = clamp(dot(normalColor.xyz,lightDirection),0.0,1.0) ;
      color.xyz+=lightness;
      gl_FragColor = color;
    }
  `
})
techPass.material.uniforms.uNormalMap.value = normalTexture;
effectComposer.addPass(techPass);

渲染循环

设置渲染循环并应用后期处理:

const clock = new THREE.Clock();

function animate(t) {
  controls.update();
  const time = clock.getElapsedTime();
  requestAnimationFrame(animate);
  // 使用渲染器渲染相机看这个场景的内容渲染出来
  // renderer.render(scene, camera);
  techPass.material.uniforms.uTime.value = time;
  effectComposer.render();
}

animate();

窗口大小调整

处理窗口大小变化:

// 监听屏幕大小改变的变化,设置渲染的尺寸
window.addEventListener("resize", () => {
  // 更新摄像头
  camera.aspect = window.innerWidth / window.innerHeight;
  // 更新摄像机的投影矩阵
  camera.updateProjectionMatrix();

  // 更新渲染器
  renderer.setSize(window.innerWidth, window.innerHeight);
  // 设置渲染器的像素比例
  renderer.setPixelRatio(window.devicePixelRatio);

  effectComposer.setSize(window.innerWidth, window.innerHeight);
  effectComposer.setPixelRatio(window.devicePixelRatio);
});

各种后期处理效果介绍

  1. RenderPass: 基础渲染通道,负责渲染原始场景
  2. DotScreenPass: 点阵屏幕效果,产生类似扫描线的视觉效果
  3. SMAAPass: 智能抗锯齿处理,提高画面质量
  4. UnrealBloomPass: 虚幻引擎风格的发光效果,使明亮区域产生光晕
  5. GlitchPass: 故障效果,模拟信号干扰的视觉效果
  6. ShaderPass: 自定义着色器效果,可实现任意的后期处理效果

总结

通过这个项目,我们学习了如何使用 Three.js 的后期处理系统:

  1. 创建 EffectComposer 作为后期处理的核心
  2. 添加不同类型的 Pass 来实现各种效果
  3. 通过参数调节控制效果强度
  4. 实现自定义着色器后期处理
  5. 在渲染循环中使用 composer.render() 替代传统的 renderer.render()

后期处理技术是提升三维场景视觉效果的重要手段,能够显著增强画面的表现力和沉浸感。掌握这些技术可以让你的 Three.js 应用更具视觉冲击力。

数据接口改造设计文档(前端)

作者 爱吃香菜i
2026年1月28日 11:57

前端数据接口组件改造设计文档

1. 需求分析

根据 数据接口文档调整动态表单 文档,需要对现有的数据接口相关组件进行改造,主要包括以下几个方面:

  1. 接口类型名称调整:将查询区的接口类型备选项"DB表输入"更名为"SQL输入"
  2. 数据接口查询逻辑调整:对于接口类型为"SQL输入"的数据接口,只查询动作类型为"查询"的记录
  3. 列显示调整:确保使用范围可以正常显示"聚合视图",接口类型可以正常显示"SQL输入"
  4. 入参判断逻辑调整:只展示"参数用途"包含"默认条件"的参数
  5. 系统变量扩展:增加SQL输入专用系统变量,包括当前用户ID、所属部门、所属单位等

2. 数据接口配置完整流程图

flowchart TD
    A[进入数据接口配置页面] --> B[点击新增按钮]
    B --> C[填写接口基本信息]
    C --> D[选择接口类型]
    D --> E{接口类型选择}
    E -->|SQL输入| F[展示SqlSetting组件]
    F --> G[选择动作类型]
    G --> H[选择数据库类型]
    H --> I[调用接口获取数据库连接列表]
    I --> J[选择数据库连接]
    J --> K[调用接口获取对应数据库列表]
    K --> L[选择需要的数据库]
    L --> M[填写SQL语句]
    M --> N{是否需要插入变量}
    N -->|是| O[点击插入变量按钮]
    O --> P[调用接口获取可用变量列表]
    P --> Q[选择变量并插入]
    Q --> M
    N -->|否| R[点击获取返回结果按钮]
    R --> S[调用接口执行SQL语句]
    S --> T[系统返回SQL执行结果]
    T --> U[查看返回字段和预览结果]
    U --> V[新增自定义参数]
    V --> W[展示ParamsEdit组件]
    W --> X[选择参数用途和筛选排序字段]
    X --> Y[保存参数]
    Y --> Z[查看系统变量]
    Z --> AA[点击确定按钮]
    AA --> AB[调用接口保存数据接口配置]
    AB --> AC[完成配置]
    E -->|API输入| AD[展示ApiSetting组件]
    AD --> AE[配置API参数]
    AE --> AF[填写API地址]
    AF --> AG[选择请求方法]
    AG --> AH[填写请求参数]
    AH --> AI[配置请求头]
    AI --> AJ[点击测试按钮]
    AJ --> AK[调用接口发送测试请求]
    AK --> AL[查看测试结果]
    AL --> V

3. 现有组件分析

3.1 InterfaceTable.vue

image.png

功能:数据接口列表页面,包含查询表单和数据表格

主要结构

  • 查询表单:包含接口名称、使用范围、接口类型等筛选条件
  • 数据表格:展示数据接口列表,包含接口名称、使用范围、接口类型、状态等字段
  • 分页组件:用于分页显示数据

需要改造的部分

  1. 接口类型选项中的"DB表输入"更名为"SQL输入"
  2. 在表格的接口类型显示中,将"DB"改为"SQL"
  3. 添加动作类型查询条件,对于SQL输入类型,只查询动作类型为"查询"的记录

3.2 DataEdit.vue

功能:数据接口新增/编辑页面,包含接口基本信息和参数配置

image.png主要结构

  • 接口基本信息表单:包含接口名称、接口分类、使用范围、接口类型等字段
  • API设置组件:用于配置API输入类型的数据接口
  • 参数列表组件:用于管理数据接口的参数

需要改造的部分

  1. 接口类型选项中的"DB表输入"更名为"SQL输入"
  2. 启用"SQL输入"选项,移除"DB表输入暂不支持~"的提示
  3. 添加SQL输入类型的配置界面

3.3 ParamsList.vue

功能:参数列表组件,用于展示和管理自定义参数和系统变量

image.png主要结构

  • 自定义参数表格:展示和管理自定义参数
  • 系统变量表格:展示系统变量

需要改造的部分

  1. 添加参数用途的显示列
  2. 添加参数用途的过滤逻辑,只展示"默认条件"参数

3.4 ParamsEdit.vue

功能:参数编辑组件,用于新增和编辑自定义参数

image.png现有字段

  • 参数名称
  • 参数类型
  • 默认值
  • 闭包符
  • 必填
  • 参数说明

需要新增的字段

  1. 参数用途(多选)
  2. 筛选排序字段

4. 组件改造设计

4.1 InterfaceTable.vue 改造设计

4.1.1 参数配置表格
参数名称 类型 默认值 说明
name String '' 接口名称查询条件
useRange String '' 使用范围查询条件
type String '' 接口类型查询条件(0: SQL输入, 1: API输入)

4.2 DataEdit.vue 改造设计

4.2.1 参数配置表格
参数名称 类型 默认值 说明
dataInterfaceName String '' 接口名称
categoryId String '' 接口分类ID
useRange Array [] 使用范围
type Number 0 接口类型(0: SQL输入, 1: API输入)
sqlConfig.actionType Number 0 默认动作类型为"查询"
sqlConfig.dbType String 'MySQL' 默认数据库类型为MySQL
sqlConfig.searchDb String '' 搜索数据库名称关键字
sqlConfig.sqlStatement String '' SQL语句
sqlConfig.dataConnection String 'local' 默认使用本地连接
sqlConfig.databases Array ['crfsdi-build', 'crfsdi-dynamic', 'crfsdi-data', 'crfsdi-form', 'crfsdi-flow'] 默认选中的数据库

4.3 ParamsList.vue 改造设计

4.3.1 参数配置表格
参数名称 类型 默认值 说明
dataInterfaceId String '' 数据接口ID(props) ©
moduleId String '' 模块ID
paramsList Array [] 参数列表
systemVariables Array [] 系统变量列表
loading Boolean false 加载状态

4.4 ParamsEdit.vue 组件改造设计

4.4.1 参数配置表格
参数名称 类型 默认值 说明
variableName String '' 参数名称
variableType Number 1 参数类型
defaultValue String '' 默认值
closureSign Boolean false 闭包符
mustFill Boolean false 必填
description String '' 参数说明
paramPurpose Array ['默认条件'] 参数用途,支持多选:默认条件、列头筛选、列头排序、列表分页
sortField String '' 筛选排序字段,用于关联返回结果的字段
showSortField Boolean false 是否显示筛选/排序字段(计算属性)
fields Array [] 筛选排序字段列表
row Object null 编辑的参数行数据(props)
4.4.5 组件参数说明
参数名称 类型 默认值 说明
paramPurpose Array ['默认条件'] 参数用途,支持多选:默认条件、列头筛选、列头排序、列表分页
sortField String '' 筛选排序字段,用于关联返回结果的字段
4.4.6 注意事项
  1. 参数用途为多选,至少需要选择一个用途
  2. 筛选排序字段需要从已获取的返回结果字段列表中选择
  3. 在保存参数时,需要将参数用途数组转换为字符串格式存储
  4. 在编辑参数时,需要将字符串格式的参数用途转换为数组格式回显
  5. 字段列表数据需要从 SqlSetting 组件或父组件获取

4.5 新增组件设计

4.5.1 SqlSetting.vue 组件

功能:SQL输入类型的数据接口配置组件

image.pngimage.png

4.5.1.1 参数配置表格
参数名称 类型 默认值 说明
params Object {} 从父组件传入的参数对象(props)
sqlConfig.actionType Number 0 动作类型(0: 查询, 1: 新增, 2: 修改, 3: 删除)
sqlConfig.dbType String 'MySQL' 数据库类型
sqlConfig.sqlStatement String '' SQL语句
sqlConfig.dataConnection String 'local' 数据库连接
sqlConfig.databases Array ['crfsdi-build', 'crfsdi-dynamic', 'crfsdi-data', 'crfsdi-form', 'crfsdi-flow'] 选中的数据库
databaseTree Array [] 数据库和表树结构
treeProps Object { children: 'children', label: 'label' } 树结构配置
searchDb String '' 搜索数据库的关键词
leftPanelWidth Number 300 左侧面板宽度
isResizing Boolean false 是否正在调整宽度
showResultTabs Boolean false 是否显示返回结果标签页
activeTab String 'fields' 当前激活的标签页
returnFields Array [] 返回字段数据
previewData Array [] 预览返回结果数据
previewColumns Array [] 预览返回结果的列配置
currentPage Number 1 当前页码
pageSize Number 20 每页条数
total Number 0 总条数
showVariableDialog Boolean false 是否显示变量对话框
variableTab String 'custom' 当前变量标签页
selectedVariable String '' 选中的变量
VariablesList Array [] 自定义变量列表
getSqlPlaceholder String '' SQL占位符(计算属性)

主要结构

  • 动作类型选择(单选按钮组)
  • 数据库类型选择(下拉菜单)
  • 数据库连接选择(下拉菜单,带搜索功能)
  • SQL语句配置(左侧数据库表选择 + 右侧SQL输入框,带插入变量按钮)
  • 获取返回结果按钮(仅在动作类型为查询时显示)
  • 返回结果展示(返回字段和预览返回结果标签页,仅在动作类型为查询时显示)

4.6 系统变量扩展

4.6.1 SQL输入专用系统变量

在系统变量中增加以下变量,仅用于接口类型在"SQL输入"时使用,即插入变量时,仅SQL输入才显示如下变量:

变量名称 变量标识 说明
当前用户ID ${currentUserId} 获取当前登录用户的ID
当前用户所属部门 ${currentUserDept} 获取当前登录用户所属部门
当前用户所属部门及下级 ${currentUserDeptAndSub} 获取当前登录用户所属部门及其所有下级部门
当前用户所属单位 ${currentUserUnit} 获取当前登录用户所属单位
当前用户所属单位及下级 ${currentUserUnitAndSub} 获取当前登录用户所属单位及其所有下级单位

4.6.2 系统变量使用示例

在SQL语句中使用系统变量:

-- 查询当前用户所属部门的所有员工
SELECT * FROM 'crfsdi-build'.'sys_user' WHERE dept_id = ${currentUserDept}

-- 查询当前用户所属部门及下级部门的所有员工
SELECT * FROM 'crfsdi-build'.'sys_user' WHERE dept_id IN (${currentUserDeptAndSub})

5. 动态表单调用逻辑实现

5.1 实现思路

  1. 读取数据接口配置:通过API获取数据接口的SQL语句和数据连接
  2. 执行SQL:使用数据连接执行SQL语句
  3. 处理结果:处理SQL执行结果,兼容平台字段逻辑
  4. 返回结果:将处理后的结果返回给调用方

6. 工时表

任务名称 任务描述 预估工时
InterfaceTable.vue 改造 接口类型选项名称调整、表格接口类型显示调整、添加动作类型查询条件、调整查询逻辑 1
DataEdit.vue 改造 接口类型选项名称调整、导入SQL设置组件、调整默认参数配置 1
ParamsList.vue 改造 添加参数用途列、添加参数用途过滤逻辑 2
ParamsEdit.vue 改造 新增参数用途字段(多选)、新增筛选/排序字段、添加验证规则、处理数据回显、调整参数用途和筛选/排序字段显示逻辑 4
SqlSetting.vue 组件开发 新增完整的SQL输入类型数据接口配置组件,包括数据库表选择功能、SQL输入框、拖动调整宽度、点击表名自动插入到SQL语句,接口联调等功能 16
系统变量扩展 新增SQL输入专用系统变量,包括当前用户ID、所属部门、所属部门及下级、所属单位、所属单位及下级等 1
动态表单调用逻辑实现 实现SQL输入类型数据接口的调用逻辑 4
审批人节点配置数据接口 支持SQL输入类型数据接口的交互 1
动态流程节点配置数据接口 支持SQL输入类型数据接口的交互 1
RPA机器人配置数据接口 支持SQL输入类型数据接口的交互 1
代码审查和测试 审查代码质量、自测主要流程、修复问题 2
总计 34

完整的白屏检测 SDK

2026年1月28日 11:32

前端白屏检测完整方案

白屏检测是前端监控体系中非常重要的一环,用于检测页面是否正常渲染,及时发现并上报白屏异常。

一、白屏检测的核心原理

白屏检测主要有以下几种实现思路:

  1. 采样点检测法 - 在页面关键位置采样,判断是否有有效内容
  2. DOM 元素检测法 - 检测页面关键 DOM 元素是否存在
  3. MutationObserver 监听法 - 监听 DOM 变化判断页面渲染状态
  4. 骨架屏检测法 - 检测骨架屏是否被替换为实际内容
  5. 截图对比法 - 通过 Canvas 截图分析页面内容
  6. Performance API 检测法 - 利用浏览器性能 API 判断渲染状态

二、完整的白屏检测 SDK 实现

// ==================== 类型定义 ====================

/**
 * 白屏检测配置接口
 */
interface WhiteScreenConfig {
  // 采样点数量(水平和垂直方向)
  samplingPoints?: number;
  // 检测延迟时间(毫秒)
  delay?: number;
  // 检测超时时间(毫秒)
  timeout?: number;
  // 白屏阈值(0-1之间,超过该比例认为是白屏)
  threshold?: number;
  // 是否启用 DOM 检测
  enableDOMDetection?: boolean;
  // 是否启用采样点检测
  enableSamplingDetection?: boolean;
  // 是否启用 MutationObserver 检测
  enableMutationDetection?: boolean;
  // 是否启用截图检测
  enableScreenshotDetection?: boolean;
  // 是否启用骨架屏检测
  enableSkeletonDetection?: boolean;
  // 骨架屏容器选择器
  skeletonSelector?: string;
  // 关键元素选择器列表
  keyElementSelectors?: string[];
  // 需要忽略的元素选择器
  ignoreSelectors?: string[];
  // 容器元素(默认为 document.body)
  container?: HTMLElement | null;
  // 上报回调函数
  onReport?: (data: WhiteScreenReport) => void;
  // 检测完成回调
  onDetectionComplete?: (result: DetectionResult) => void;
  // 是否在开发环境启用
  enableInDev?: boolean;
  // 最大重试次数
  maxRetries?: number;
  // 重试间隔(毫秒)
  retryInterval?: number;
  // 自定义白屏判断函数
  customDetector?: () => boolean | Promise<boolean>;
}

/**
 * 白屏检测报告接口
 */
interface WhiteScreenReport {
  // 是否白屏
  isWhiteScreen: boolean;
  // 检测时间戳
  timestamp: number;
  // 页面 URL
  url: string;
  // 检测方法
  detectionMethod: DetectionMethod;
  // 采样点结果
  samplingResult?: SamplingResult;
  // DOM 检测结果
  domResult?: DOMDetectionResult;
  // 截图检测结果
  screenshotResult?: ScreenshotResult;
  // 骨架屏检测结果
  skeletonResult?: SkeletonResult;
  // 页面性能数据
  performanceData?: PerformanceData;
  // 用户代理信息
  userAgent: string;
  // 视口尺寸
  viewport: ViewportSize;
  // 设备像素比
  devicePixelRatio: number;
  // 网络信息
  networkInfo?: NetworkInfo;
  // 错误信息
  errorInfo?: ErrorInfo;
  // 自定义数据
  customData?: Record<string, unknown>;
}

/**
 * 检测方法枚举
 */
enum DetectionMethod {
  SAMPLING = 'sampling',
  DOM = 'dom',
  MUTATION = 'mutation',
  SCREENSHOT = 'screenshot',
  SKELETON = 'skeleton',
  PERFORMANCE = 'performance',
  CUSTOM = 'custom',
  COMBINED = 'combined'
}

/**
 * 采样点结果接口
 */
interface SamplingResult {
  // 总采样点数
  totalPoints: number;
  // 空白点数
  emptyPoints: number;
  // 空白比例
  emptyRatio: number;
  // 采样点详情
  pointDetails: SamplingPointDetail[];
}

/**
 * 采样点详情
 */
interface SamplingPointDetail {
  // X 坐标
  x: number;
  // Y 坐标
  y: number;
  // 元素标签名
  tagName: string | null;
  // 是否为空白点
  isEmpty: boolean;
  // 元素类名
  className?: string;
  // 元素 ID
  id?: string;
}

/**
 * DOM 检测结果接口
 */
interface DOMDetectionResult {
  // 是否通过检测
  passed: boolean;
  // 检测到的关键元素数量
  foundElements: number;
  // 期望的关键元素数量
  expectedElements: number;
  // 缺失的元素选择器
  missingSelectors: string[];
  // 元素详情
  elementDetails: ElementDetail[];
}

/**
 * 元素详情
 */
interface ElementDetail {
  // 选择器
  selector: string;
  // 是否存在
  exists: boolean;
  // 是否可见
  isVisible?: boolean;
  // 元素尺寸
  dimensions?: {
    width: number;
    height: number;
  };
}

/**
 * 截图检测结果
 */
interface ScreenshotResult {
  // 是否白屏
  isWhiteScreen: boolean;
  // 白色像素比例
  whitePixelRatio: number;
  // 总像素数
  totalPixels: number;
  // 白色像素数
  whitePixels: number;
  // 颜色分布
  colorDistribution?: ColorDistribution;
}

/**
 * 颜色分布
 */
interface ColorDistribution {
  white: number;
  black: number;
  gray: number;
  colored: number;
}

/**
 * 骨架屏检测结果
 */
interface SkeletonResult {
  // 骨架屏是否存在
  skeletonExists: boolean;
  // 骨架屏是否已移除
  skeletonRemoved: boolean;
  // 检测时间
  detectionTime: number;
}

/**
 * 性能数据
 */
interface PerformanceData {
  // DOM 加载完成时间
  domContentLoaded?: number;
  // 页面完全加载时间
  loadComplete?: number;
  // 首次内容绘制时间
  firstContentfulPaint?: number;
  // 最大内容绘制时间
  largestContentfulPaint?: number;
  // 首次输入延迟
  firstInputDelay?: number;
  // 累计布局偏移
  cumulativeLayoutShift?: number;
  // 可交互时间
  timeToInteractive?: number;
}

/**
 * 视口尺寸
 */
interface ViewportSize {
  width: number;
  height: number;
}

/**
 * 网络信息
 */
interface NetworkInfo {
  // 网络类型
  effectiveType?: string;
  // 下行带宽
  downlink?: number;
  // RTT
  rtt?: number;
  // 是否在线
  online: boolean;
}

/**
 * 错误信息
 */
interface ErrorInfo {
  // 错误消息
  message: string;
  // 错误堆栈
  stack?: string;
  // 错误类型
  type: string;
}

/**
 * 检测结果
 */
interface DetectionResult {
  // 是否白屏
  isWhiteScreen: boolean;
  // 置信度(0-1)
  confidence: number;
  // 检测方法
  methods: DetectionMethod[];
  // 各方法结果
  methodResults: Map<DetectionMethod, boolean>;
  // 最终判定依据
  basis: string;
}

// ==================== 工具函数 ====================

/**
 * 防抖函数
 */
function debounce<T extends (...args: unknown[]) => unknown>(
  func: T,
  wait: number
): (...args: Parameters<T>) => void {
  let timeoutId: ReturnType<typeof setTimeout> | null = null;
  
  return function (this: unknown, ...args: Parameters<T>) {
    if (timeoutId !== null) {
      clearTimeout(timeoutId);
    }
    
    timeoutId = setTimeout(() => {
      func.apply(this, args);
      timeoutId = null;
    }, wait);
  };
}

/**
 * 节流函数
 */
function throttle<T extends (...args: unknown[]) => unknown>(
  func: T,
  limit: number
): (...args: Parameters<T>) => void {
  let inThrottle = false;
  
  return function (this: unknown, ...args: Parameters<T>) {
    if (!inThrottle) {
      func.apply(this, args);
      inThrottle = true;
      setTimeout(() => {
        inThrottle = false;
      }, limit);
    }
  };
}

/**
 * 延迟执行
 */
function delay(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}

/**
 * 带超时的 Promise
 */
function withTimeout<T>(
  promise: Promise<T>,
  ms: number,
  errorMessage = 'Operation timed out'
): Promise<T> {
  const timeout = new Promise<never>((_, reject) => {
    setTimeout(() => reject(new Error(errorMessage)), ms);
  });
  
  return Promise.race([promise, timeout]);
}

/**
 * 重试函数
 */
async function retry<T>(
  fn: () => Promise<T>,
  maxRetries: number,
  retryInterval: number
): Promise<T> {
  let lastError: Error | null = null;
  
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await fn();
    } catch (error) {
      lastError = error as Error;
      if (i < maxRetries - 1) {
        await delay(retryInterval);
      }
    }
  }
  
  throw lastError;
}

/**
 * 生成唯一 ID
 */
function generateUniqueId(): string {
  return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}

/**
 * 深度合并对象
 */
function deepMerge<T extends Record<string, unknown>>(
  target: T,
  source: Partial<T>
): T {
  const result = { ...target };
  
  for (const key in source) {
    if (Object.prototype.hasOwnProperty.call(source, key)) {
      const sourceValue = source[key];
      const targetValue = result[key];
      
      if (
        typeof sourceValue === 'object' &&
        sourceValue !== null &&
        !Array.isArray(sourceValue) &&
        typeof targetValue === 'object' &&
        targetValue !== null &&
        !Array.isArray(targetValue)
      ) {
        result[key] = deepMerge(
          targetValue as Record<string, unknown>,
          sourceValue as Record<string, unknown>
        ) as T[Extract<keyof T, string>];
      } else if (sourceValue !== undefined) {
        result[key] = sourceValue as T[Extract<keyof T, string>];
      }
    }
  }
  
  return result;
}

/**
 * 检查元素是否可见
 */
function isElementVisible(element: Element): boolean {
  if (!element) return false;
  
  const style = window.getComputedStyle(element);
  
  if (style.display === 'none') return false;
  if (style.visibility === 'hidden') return false;
  if (style.opacity === '0') return false;
  
  const rect = element.getBoundingClientRect();
  if (rect.width === 0 || rect.height === 0) return false;
  
  return true;
}

/**
 * 获取元素在指定坐标处
 */
function getElementAtPoint(x: number, y: number): Element | null {
  try {
    return document.elementFromPoint(x, y);
  } catch {
    return null;
  }
}

/**
 * 判断是否为包装元素(通常用于布局的空元素)
 */
function isWrapperElement(element: Element | null): boolean {
  if (!element) return true;
  
  const wrapperTags = [
    'HTML', 'BODY', 'DIV', 'SECTION', 'ARTICLE', 'MAIN',
    'HEADER', 'FOOTER', 'NAV', 'ASIDE', 'SPAN'
  ];
  
  const tagName = element.tagName.toUpperCase();
  
  if (!wrapperTags.includes(tagName)) {
    return false;
  }
  
  // 检查是否有实际内容
  const hasText = element.textContent?.trim().length ?? 0 > 0;
  const hasChildren = element.children.length > 0;
  const hasBackground = hasBackgroundContent(element);
  
  // 如果只是空的包装元素,认为是包装元素
  if (!hasText && !hasBackground && element === document.body) {
    return true;
  }
  
  return false;
}

/**
 * 检查元素是否有背景内容
 */
function hasBackgroundContent(element: Element): boolean {
  const style = window.getComputedStyle(element);
  
  // 检查背景颜色(排除白色和透明)
  const bgColor = style.backgroundColor;
  if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
    // 解析颜色值判断是否为白色
    const isWhite = isWhiteColor(bgColor);
    if (!isWhite) return true;
  }
  
  // 检查背景图片
  const bgImage = style.backgroundImage;
  if (bgImage && bgImage !== 'none') {
    return true;
  }
  
  return false;
}

/**
 * 判断颜色是否为白色
 */
function isWhiteColor(color: string): boolean {
  // 处理 rgb/rgba 格式
  const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (rgbMatch) {
    const [, r, g, b] = rgbMatch.map(Number);
    // 接近白色的阈值
    return r > 250 && g > 250 && b > 250;
  }
  
  // 处理十六进制格式
  if (color.startsWith('#')) {
    const hex = color.slice(1);
    const r = parseInt(hex.slice(0, 2), 16);
    const g = parseInt(hex.slice(2, 4), 16);
    const b = parseInt(hex.slice(4, 6), 16);
    return r > 250 && g > 250 && b > 250;
  }
  
  return color === 'white' || color === '#fff' || color === '#ffffff';
}

/**
 * 获取网络信息
 */
function getNetworkInfo(): NetworkInfo {
  const connection = (navigator as Navigator & {
    connection?: {
      effectiveType?: string;
      downlink?: number;
      rtt?: number;
    };
  }).connection;
  
  return {
    effectiveType: connection?.effectiveType,
    downlink: connection?.downlink,
    rtt: connection?.rtt,
    online: navigator.onLine
  };
}

/**
 * 获取性能数据
 */
function getPerformanceData(): PerformanceData {
  const performanceData: PerformanceData = {};
  
  // 获取导航性能数据
  const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
  if (navigation) {
    performanceData.domContentLoaded = navigation.domContentLoadedEventEnd - navigation.startTime;
    performanceData.loadComplete = navigation.loadEventEnd - navigation.startTime;
  }
  
  // 获取绘制性能数据
  const paintEntries = performance.getEntriesByType('paint');
  paintEntries.forEach(entry => {
    if (entry.name === 'first-contentful-paint') {
      performanceData.firstContentfulPaint = entry.startTime;
    }
  });
  
  // 获取 LCP
  try {
    const lcpEntries = performance.getEntriesByType('largest-contentful-paint');
    if (lcpEntries.length > 0) {
      const lastLcp = lcpEntries[lcpEntries.length - 1] as PerformanceEntry & { startTime: number };
      performanceData.largestContentfulPaint = lastLcp.startTime;
    }
  } catch {
    // LCP 可能不被支持
  }
  
  return performanceData;
}

// ==================== 采样点检测器 ====================

/**
 * 采样点检测器类
 */
class SamplingDetector {
  private config: Required<Pick<WhiteScreenConfig, 
    'samplingPoints' | 'threshold' | 'ignoreSelectors' | 'container'
  >>;
  
  constructor(config: Partial<WhiteScreenConfig> = {}) {
    this.config = {
      samplingPoints: config.samplingPoints ?? 17,
      threshold: config.threshold ?? 0.95,
      ignoreSelectors: config.ignoreSelectors ?? [],
      container: config.container ?? document.body
    };
  }
  
  /**
   * 执行采样检测
   */
  detect(): SamplingResult {
    const { samplingPoints, ignoreSelectors, container } = this.config;
    const points: SamplingPointDetail[] = [];
    
    // 获取视口尺寸
    const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
    const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
    
    // 计算采样间隔
    const horizontalStep = viewportWidth / (samplingPoints + 1);
    const verticalStep = viewportHeight / (samplingPoints + 1);
    
    let emptyPoints = 0;
    
    // 生成采样点矩阵
    for (let i = 1; i <= samplingPoints; i++) {
      for (let j = 1; j <= samplingPoints; j++) {
        const x = Math.floor(horizontalStep * i);
        const y = Math.floor(verticalStep * j);
        
        const pointResult = this.checkPoint(x, y, ignoreSelectors);
        points.push(pointResult);
        
        if (pointResult.isEmpty) {
          emptyPoints++;
        }
      }
    }
    
    // 添加中心点检测(权重更高)
    const centerX = Math.floor(viewportWidth / 2);
    const centerY = Math.floor(viewportHeight / 2);
    const centerPoints = this.checkCenterRegion(centerX, centerY, ignoreSelectors);
    points.push(...centerPoints);
    
    centerPoints.forEach(point => {
      if (point.isEmpty) emptyPoints++;
    });
    
    const totalPoints = points.length;
    const emptyRatio = totalPoints > 0 ? emptyPoints / totalPoints : 1;
    
    return {
      totalPoints,
      emptyPoints,
      emptyRatio,
      pointDetails: points
    };
  }
  
  /**
   * 检查单个采样点
   */
  private checkPoint(
    x: number,
    y: number,
    ignoreSelectors: string[]
  ): SamplingPointDetail {
    const element = getElementAtPoint(x, y);
    
    const detail: SamplingPointDetail = {
      x,
      y,
      tagName: element?.tagName ?? null,
      isEmpty: true,
      className: element?.className?.toString(),
      id: element?.id
    };
    
    if (!element) {
      return detail;
    }
    
    // 检查是否在忽略列表中
    if (this.shouldIgnoreElement(element, ignoreSelectors)) {
      return detail;
    }
    
    // 检查是否为有效内容元素
    detail.isEmpty = !this.isContentElement(element);
    
    return detail;
  }
  
  /**
   * 检查中心区域(九宫格)
   */
  private checkCenterRegion(
    centerX: number,
    centerY: number,
    ignoreSelectors: string[]
  ): SamplingPointDetail[] {
    const points: SamplingPointDetail[] = [];
    const offsets = [-50, 0, 50];
    
    for (const offsetX of offsets) {
      for (const offsetY of offsets) {
        if (offsetX === 0 && offsetY === 0) continue; // 跳过正中心,已在主循环中处理
        
        const x = centerX + offsetX;
        const y = centerY + offsetY;
        
        if (x > 0 && y > 0) {
          points.push(this.checkPoint(x, y, ignoreSelectors));
        }
      }
    }
    
    return points;
  }
  
  /**
   * 判断元素是否应被忽略
   */
  private shouldIgnoreElement(element: Element, ignoreSelectors: string[]): boolean {
    for (const selector of ignoreSelectors) {
      try {
        if (element.matches(selector) || element.closest(selector)) {
          return true;
        }
      } catch {
        // 选择器无效,忽略
      }
    }
    return false;
  }
  
  /**
   * 判断是否为有内容的元素
   */
  private isContentElement(element: Element): boolean {
    const tagName = element.tagName.toUpperCase();
    
    // 明确的内容元素
    const contentTags = [
      'IMG', 'VIDEO', 'AUDIO', 'CANVAS', 'SVG', 'IFRAME',
      'INPUT', 'TEXTAREA', 'SELECT', 'BUTTON',
      'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
      'P', 'A', 'SPAN', 'LABEL', 'LI', 'TD', 'TH',
      'STRONG', 'EM', 'B', 'I', 'U', 'CODE', 'PRE'
    ];
    
    if (contentTags.includes(tagName)) {
      return isElementVisible(element);
    }
    
    // 检查是否有文本内容
    const textContent = element.textContent?.trim();
    if (textContent && textContent.length > 0) {
      return isElementVisible(element);
    }
    
    // 检查是否有背景内容
    if (hasBackgroundContent(element)) {
      return isElementVisible(element);
    }
    
    // 检查是否为包装元素
    if (isWrapperElement(element)) {
      return false;
    }
    
    return isElementVisible(element);
  }
  
  /**
   * 判断是否为白屏
   */
  isWhiteScreen(result: SamplingResult): boolean {
    return result.emptyRatio >= this.config.threshold;
  }
}

// ==================== DOM 检测器 ====================

/**
 * DOM 检测器类
 */
class DOMDetector {
  private config: Required<Pick<WhiteScreenConfig, 
    'keyElementSelectors' | 'container'
  >>;
  
  constructor(config: Partial<WhiteScreenConfig> = {}) {
    this.config = {
      keyElementSelectors: config.keyElementSelectors ?? [
        '#app', '#root', '.app', '.main-content',
        'main', '[data-page]', '.page-container'
      ],
      container: config.container ?? document.body
    };
  }
  
  /**
   * 执行 DOM 检测
   */
  detect(): DOMDetectionResult {
    const { keyElementSelectors, container } = this.config;
    const elementDetails: ElementDetail[] = [];
    const missingSelectors: string[] = [];
    let foundElements = 0;
    
    for (const selector of keyElementSelectors) {
      const result = this.checkElement(selector, container);
      elementDetails.push(result);
      
      if (result.exists && result.isVisible) {
        foundElements++;
      } else {
        missingSelectors.push(selector);
      }
    }
    
    const expectedElements = keyElementSelectors.length;
    const passed = foundElements > 0;
    
    return {
      passed,
      foundElements,
      expectedElements,
      missingSelectors,
      elementDetails
    };
  }
  
  /**
   * 检查单个元素
   */
  private checkElement(
    selector: string,
    container: HTMLElement | null
  ): ElementDetail {
    const searchRoot = container ?? document;
    
    try {
      const element = searchRoot.querySelector(selector);
      
      if (!element) {
        return {
          selector,
          exists: false,
          isVisible: false
        };
      }
      
      const isVisible = isElementVisible(element);
      const rect = element.getBoundingClientRect();
      
      return {
        selector,
        exists: true,
        isVisible,
        dimensions: {
          width: rect.width,
          height: rect.height
        }
      };
    } catch {
      return {
        selector,
        exists: false,
        isVisible: false
      };
    }
  }
  
  /**
   * 检测页面是否有有效内容
   */
  hasValidContent(): boolean {
    const body = document.body;
    if (!body) return false;
    
    // 检查 body 是否有子元素
    if (body.children.length === 0) return false;
    
    // 检查是否有可见的子元素
    const children = Array.from(body.children);
    const visibleChildren = children.filter(child => isElementVisible(child));
    
    if (visibleChildren.length === 0) return false;
    
    // 检查是否有实际内容(文本或媒体)
    const hasContent = visibleChildren.some(child => {
      // 检查文本内容
      const text = child.textContent?.trim();
      if (text && text.length > 0) return true;
      
      // 检查媒体元素
      const mediaElements = child.querySelectorAll('img, video, canvas, svg, iframe');
      if (mediaElements.length > 0) return true;
      
      // 检查背景
      if (hasBackgroundContent(child)) return true;
      
      return false;
    });
    
    return hasContent;
  }
  
  /**
   * 获取页面 DOM 统计信息
   */
  getDOMStats(): {
    totalElements: number;
    visibleElements: number;
    textNodes: number;
    mediaElements: number;
    interactiveElements: number;
  } {
    const allElements = document.querySelectorAll('*');
    let visibleElements = 0;
    let textNodes = 0;
    let mediaElements = 0;
    let interactiveElements = 0;
    
    allElements.forEach(element => {
      if (isElementVisible(element)) {
        visibleElements++;
      }
      
      const tagName = element.tagName.toUpperCase();
      
      if (['IMG', 'VIDEO', 'AUDIO', 'CANVAS', 'SVG', 'IFRAME'].includes(tagName)) {
        mediaElements++;
      }
      
      if (['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA', 'A'].includes(tagName)) {
        interactiveElements++;
      }
    });
    
    // 统计文本节点
    const walker = document.createTreeWalker(
      document.body,
      NodeFilter.SHOW_TEXT,
      null
    );
    
    while (walker.nextNode()) {
      const node = walker.currentNode;
      if (node.textContent?.trim()) {
        textNodes++;
      }
    }
    
    return {
      totalElements: allElements.length,
      visibleElements,
      textNodes,
      mediaElements,
      interactiveElements
    };
  }
}

// ==================== MutationObserver 检测器 ====================

/**
 * MutationObserver 检测器类
 */
class MutationDetector {
  private observer: MutationObserver | null = null;
  private mutations: MutationRecord[] = [];
  private startTime: number = 0;
  private isObserving: boolean = false;
  private config: {
    timeout: number;
    minMutations: number;
    stableTime: number;
  };
  
  private resolvePromise: ((value: boolean) => void) | null = null;
  private timeoutId: ReturnType<typeof setTimeout> | null = null;
  private stableTimeoutId: ReturnType<typeof setTimeout> | null = null;
  
  constructor(config: Partial<WhiteScreenConfig> = {}) {
    this.config = {
      timeout: config.timeout ?? 10000,
      minMutations: 10,
      stableTime: 1000
    };
  }
  
  /**
   * 开始观察
   */
  observe(): Promise<boolean> {
    return new Promise((resolve) => {
      this.resolvePromise = resolve;
      this.startTime = Date.now();
      this.mutations = [];
      this.isObserving = true;
      
      // 创建 MutationObserver
      this.observer = new MutationObserver((mutations) => {
        this.handleMutations(mutations);
      });
      
      // 配置观察选项
      const observerConfig: MutationObserverInit = {
        childList: true,
        subtree: true,
        attributes: true,
        characterData: true,
        attributeOldValue: false,
        characterDataOldValue: false
      };
      
      // 开始观察
      this.observer.observe(document.body, observerConfig);
      
      // 设置超时
      this.timeoutId = setTimeout(() => {
        this.complete(false);
      }, this.config.timeout);
    });
  }
  
  /**
   * 处理 mutation 记录
   */
  private handleMutations(mutations: MutationRecord[]): void {
    if (!this.isObserving) return;
    
    this.mutations.push(...mutations);
    
    // 重置稳定计时器
    if (this.stableTimeoutId) {
      clearTimeout(this.stableTimeoutId);
    }
    
    // 设置新的稳定计时器
    this.stableTimeoutId = setTimeout(() => {
      this.checkStability();
    }, this.config.stableTime);
  }
  
  /**
   * 检查页面稳定性
   */
  private checkStability(): void {
    if (!this.isObserving) return;
    
    // 检查是否有足够的 mutations(表示页面有渲染活动)
    const hasSufficientMutations = this.mutations.length >= this.config.minMutations;
    
    // 检查是否有有意义的内容变化
    const hasContentChanges = this.hasContentMutations();
    
    if (hasSufficientMutations && hasContentChanges) {
      this.complete(true);
    } else if (Date.now() - this.startTime > this.config.timeout / 2) {
      // 如果已经过了一半的超时时间,且没有足够的活动,可能是白屏
      this.complete(false);
    }
  }
  
  /**
   * 检查是否有内容相关的 mutations
   */
  private hasContentMutations(): boolean {
    let contentMutations = 0;
    
    for (const mutation of this.mutations) {
      if (mutation.type === 'childList') {
        // 检查是否添加了有意义的节点
        const addedNodes = Array.from(mutation.addedNodes);
        for (const node of addedNodes) {
          if (node.nodeType === Node.ELEMENT_NODE) {
            const element = node as Element;
            if (isElementVisible(element)) {
              contentMutations++;
            }
          } else if (node.nodeType === Node.TEXT_NODE) {
            if (node.textContent?.trim()) {
              contentMutations++;
            }
          }
        }
      }
    }
    
    return contentMutations >= 5;
  }
  
  /**
   * 完成检测
   */
  private complete(hasContent: boolean): void {
    if (!this.isObserving) return;
    
    this.isObserving = false;
    
    if (this.observer) {
      this.observer.disconnect();
      this.observer = null;
    }
    
    if (this.timeoutId) {
      clearTimeout(this.timeoutId);
      this.timeoutId = null;
    }
    
    if (this.stableTimeoutId) {
      clearTimeout(this.stableTimeoutId);
      this.stableTimeoutId = null;
    }
    
    if (this.resolvePromise) {
      this.resolvePromise(hasContent);
      this.resolvePromise = null;
    }
  }
  
  /**
   * 获取 mutation 统计
   */
  getMutationStats(): {
    totalMutations: number;
    childListMutations: number;
    attributeMutations: number;
    characterDataMutations: number;
    duration: number;
  } {
    let childListMutations = 0;
    let attributeMutations = 0;
    let characterDataMutations = 0;
    
    for (const mutation of this.mutations) {
      switch (mutation.type) {
        case 'childList':
          childListMutations++;
          break;
        case 'attributes':
          attributeMutations++;
          break;
        case 'characterData':
          characterDataMutations++;
          break;
      }
    }
    
    return {
      totalMutations: this.mutations.length,
      childListMutations,
      attributeMutations,
      characterDataMutations,
      duration: Date.now() - this.startTime
    };
  }
  
  /**
   * 停止观察
   */
  stop(): void {
    this.complete(false);
  }
}

// ==================== 截图检测器 ====================

/**
 * 截图检测器类
 */
class ScreenshotDetector {
  private canvas: HTMLCanvasElement | null = null;
  private ctx: CanvasRenderingContext2D | null = null;
  private config: {
    sampleWidth: number;
    sampleHeight: number;
    whiteThreshold: number;
    brightnessThreshold: number;
  };
  
  constructor(config: Partial<WhiteScreenConfig> = {}) {
    this.config = {
      sampleWidth: 100,
      sampleHeight: 100,
      whiteThreshold: config.threshold ?? 0.95,
      brightnessThreshold: 250
    };
  }
  
  /**
   * 初始化 Canvas
   */
  private initCanvas(): boolean {
    try {
      this.canvas = document.createElement('canvas');
      this.canvas.width = this.config.sampleWidth;
      this.canvas.height = this.config.sampleHeight;
      this.ctx = this.canvas.getContext('2d', { willReadFrequently: true });
      return this.ctx !== null;
    } catch {
      return false;
    }
  }
  
  /**
   * 执行截图检测
   */
  async detect(): Promise<ScreenshotResult> {
    if (!this.initCanvas() || !this.canvas || !this.ctx) {
      return {
        isWhiteScreen: false,
        whitePixelRatio: 0,
        totalPixels: 0,
        whitePixels: 0
      };
    }
    
    try {
      // 使用 html2canvas 或原生方法截图
      await this.captureScreen();
      
      // 分析图像
      return this.analyzeImage();
    } catch (error) {
      console.error('Screenshot detection failed:', error);
      return {
        isWhiteScreen: false,
        whitePixelRatio: 0,
        totalPixels: 0,
        whitePixels: 0
      };
    }
  }
  
  /**
   * 捕获屏幕
   */
  private async captureScreen(): Promise<void> {
    if (!this.canvas || !this.ctx) return;
    
    const { sampleWidth, sampleHeight } = this.config;
    
    // 方案1: 使用 html2canvas(需要引入库)
    // 这里使用简化的方案:遍历可见元素并绘制
    
    // 先填充白色背景
    this.ctx.fillStyle = '#ffffff';
    this.ctx.fillRect(0, 0, sampleWidth, sampleHeight);
    
    // 获取 body 的背景色
    const bodyStyle = window.getComputedStyle(document.body);
    const bodyBgColor = bodyStyle.backgroundColor;
    if (bodyBgColor && bodyBgColor !== 'rgba(0, 0, 0, 0)') {
      this.ctx.fillStyle = bodyBgColor;
      this.ctx.fillRect(0, 0, sampleWidth, sampleHeight);
    }
    
    // 绘制可见元素的简化表示
    const elements = document.querySelectorAll('*');
    const viewportWidth = window.innerWidth;
    const viewportHeight = window.innerHeight;
    const scaleX = sampleWidth / viewportWidth;
    const scaleY = sampleHeight / viewportHeight;
    
    elements.forEach(element => {
      if (!isElementVisible(element)) return;
      
      const rect = element.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) return;
      
      // 检查是否在视口内
      if (rect.right < 0 || rect.bottom < 0) return;
      if (rect.left > viewportWidth || rect.top > viewportHeight) return;
      
      const style = window.getComputedStyle(element);
      const bgColor = style.backgroundColor;
      
      // 只绘制有背景色的元素
      if (bgColor && bgColor !== 'rgba(0, 0, 0, 0)' && bgColor !== 'transparent') {
        const x = rect.left * scaleX;
        const y = rect.top * scaleY;
        const width = rect.width * scaleX;
        const height = rect.height * scaleY;
        
        this.ctx!.fillStyle = bgColor;
        this.ctx!.fillRect(x, y, width, height);
      }
      
      // 绘制文本区域
      const text = element.textContent?.trim();
      if (text && text.length > 0 && element.children.length === 0) {
        const x = rect.left * scaleX;
        const y = rect.top * scaleY;
        const width = rect.width * scaleX;
        const height = rect.height * scaleY;
        
        // 用深色表示文本区域
        this.ctx!.fillStyle = style.color || '#000000';
        this.ctx!.fillRect(x, y, Math.max(width, 2), Math.max(height, 2));
      }
    });
    
    // 绘制图片元素
    const images = document.querySelectorAll('img');
    for (const img of images) {
      if (!isElementVisible(img)) continue;
      
      const rect = img.getBoundingClientRect();
      if (rect.width === 0 || rect.height === 0) continue;
      
      try {
        const x = rect.left * scaleX;
        const y = rect.top * scaleY;
        const width = rect.width * scaleX;
        const height = rect.height * scaleY;
        
        // 用灰色表示图片区域
        this.ctx!.fillStyle = '#808080';
        this.ctx!.fillRect(x, y, width, height);
      } catch {
        // 跨域图片无法绘制
      }
    }
  }
  
  /**
   * 分析图像
   */
  private analyzeImage(): ScreenshotResult {
    if (!this.canvas || !this.ctx) {
      return {
        isWhiteScreen: false,
        whitePixelRatio: 0,
        totalPixels: 0,
        whitePixels: 0
      };
    }
    
    const { sampleWidth, sampleHeight, brightnessThreshold } = this.config;
    const imageData = this.ctx.getImageData(0, 0, sampleWidth, sampleHeight);
    const data = imageData.data;
    
    let whitePixels = 0;
    let blackPixels = 0;
    let grayPixels = 0;
    let coloredPixels = 0;
    const totalPixels = (sampleWidth * sampleHeight);
    
    // 遍历像素
    for (let i = 0; i < data.length; i += 4) {
      const r = data[i];
      const g = data[i + 1];
      const b = data[i + 2];
      
      // 计算亮度
      const brightness = (r + g + b) / 3;
      
      // 判断像素颜色类型
      if (brightness >= brightnessThreshold) {
        whitePixels++;
      } else if (brightness <= 10) {
        blackPixels++;
      } else if (Math.abs(r - g) <= 20 && Math.abs(g - b) <= 20 && Math.abs(r - b) <= 20) {
        grayPixels++;
      } else {
        coloredPixels++;
      }
    }
    
    const whitePixelRatio = whitePixels / totalPixels;
    const isWhiteScreen = whitePixelRatio >= this.config.whiteThreshold;
    
    return {
      isWhiteScreen,
      whitePixelRatio,
      totalPixels,
      whitePixels,
      colorDistribution: {
        white: whitePixels / totalPixels,
        black: blackPixels / totalPixels,
        gray: grayPixels / totalPixels,
        colored: coloredPixels / totalPixels
      }
    };
  }
  
  /**
   * 释放资源
   */
  dispose(): void {
    this.canvas = null;
    this.ctx = null;
  }
}

// ==================== 骨架屏检测器 ====================

/**
 * 骨架屏检测器类
 */
class SkeletonDetector {
  private config: {
    skeletonSelector: string;
    timeout: number;
    checkInterval: number;
  };
  
  private intervalId: ReturnType<typeof setInterval> | null = null;
  private startTime: number = 0;
  
  constructor(config: Partial<WhiteScreenConfig> = {}) {
    this.config = {
      skeletonSelector: config.skeletonSelector ?? '.skeleton, [data-skeleton], .loading-skeleton',
      timeout: config.timeout ?? 10000,
      checkInterval: 500
    };
  }
  
  /**
   * 检测骨架屏状态
   */
  detect(): Promise<SkeletonResult> {
    return new Promise((resolve) => {
      this.startTime = Date.now();
      
      // 首先检查骨架屏是否存在
      const skeletonExists = this.isSkeletonPresent();
      
      if (!skeletonExists) {
        // 骨架屏不存在,可能已经加载完成或根本没有骨架屏
        resolve({
          skeletonExists: false,
          skeletonRemoved: true,
          detectionTime: 0
        });
        return;
      }
      
      // 开始定期检查骨架屏是否消失
      this.intervalId = setInterval(() => {
        const elapsed = Date.now() - this.startTime;
        
        if (!this.isSkeletonPresent()) {
          // 骨架屏已消失
          this.stop();
          resolve({
            skeletonExists: true,
            skeletonRemoved: true,
            detectionTime: elapsed
          });
          return;
        }
        
        if (elapsed >= this.config.timeout) {
          // 超时,骨架屏仍然存在
          this.stop();
          resolve({
            skeletonExists: true,
            skeletonRemoved: false,
            detectionTime: elapsed
          });
        }
      }, this.config.checkInterval);
    });
  }
  
  /**
   * 检查骨架屏是否存在
   */
  private isSkeletonPresent(): boolean {
    const { skeletonSelector } = this.config;
    
    try {
      const selectors = skeletonSelector.split(',').map(s => s.trim());
      
      for (const selector of selectors) {
        const elements = document.querySelectorAll(selector);
        
        for (const element of elements) {
          if (isElementVisible(element)) {
            return true;
          }
        }
      }
      
      return false;
    } catch {
      return false;
    }
  }
  
  /**
   * 停止检测
   */
  stop(): void {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
  }
  
  /**
   * 获取所有骨架屏元素
   */
  getSkeletonElements(): Element[] {
    const { skeletonSelector } = this.config;
    const elements: Element[] = [];
    
    try {
      const selectors = skeletonSelector.split(',').map(s => s.trim());
      
      for (const selector of selectors) {
        const found = document.querySelectorAll(selector);
        elements.push(...Array.from(found));
      }
    } catch {
      // 选择器无效
    }
    
    return elements;
  }
}

// ==================== Performance API 检测器 ====================

/**
 * Performance API 检测器类
 */
class PerformanceDetector {
  private config: {
    fcpThreshold: number;
    lcpThreshold: number;
  };
  
  private lcpObserver: PerformanceObserver | null = null;
  private lcpValue: number = 0;
  
  constructor(config: Partial<WhiteScreenConfig> = {}) {
    this.config = {
      fcpThreshold: 3000,
      lcpThreshold: 4000
    };
  }
  
  /**
   * 初始化 LCP 观察器
   */
  initLCPObserver(): void {
    try {
      this.lcpObserver = new PerformanceObserver((entryList) => {
        const entries = entryList.getEntries();
        if (entries.length > 0) {
          const lastEntry = entries[entries.length - 1] as PerformanceEntry & { startTime: number };
          this.lcpValue = lastEntry.startTime;
        }
      });
      
      this.lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });
    } catch {
      // LCP 观察器可能不被支持
    }
  }
  
  /**
   * 获取性能指标
   */
  getMetrics(): PerformanceData {
    const data = getPerformanceData();
    
    // 添加 LCP
    if (this.lcpValue > 0) {
      data.largestContentfulPaint = this.lcpValue;
    }
    
    return data;
  }
  
  /**
   * 判断是否可能白屏(基于性能指标)
   */
  isPotentialWhiteScreen(): boolean {
    const metrics = this.getMetrics();
    
    // 如果 FCP 超过阈值,可能是白屏
    if (metrics.firstContentfulPaint && metrics.firstContentfulPaint > this.config.fcpThreshold) {
      return true;
    }
    
    // 如果 LCP 超过阈值,可能有问题
    if (metrics.largestContentfulPaint && metrics.largestContentfulPaint > this.config.lcpThreshold) {
      return true;
    }
    
    return false;
  }
  
  /**
   * 获取资源加载统计
   */
  getResourceStats(): {
    totalResources: number;
    failedResources: number;
    slowResources: number;
    resourceDetails: {
      name: string;
      type: string;
      duration: number;
      size: number;
      failed: boolean;
    }[];
  } {
    const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
    const resourceDetails: {
      name: string;
      type: string;
      duration: number;
      size: number;
      failed: boolean;
    }[] = [];
    
    let failedResources = 0;
    let slowResources = 0;
    
    for (const resource of resources) {
      const failed = resource.transferSize === 0 && resource.decodedBodySize === 0;
      const slow = resource.duration > 2000;
      
      if (failed) failedResources++;
      if (slow) slowResources++;
      
      resourceDetails.push({
        name: resource.name,
        type: resource.initiatorType,
        duration: resource.duration,
        size: resource.transferSize || 0,
        failed
      });
    }
    
    return {
      totalResources: resources.length,
      failedResources,
      slowResources,
      resourceDetails
    };
  }
  
  /**
   * 停止观察
   */
  stop(): void {
    if (this.lcpObserver) {
      this.lcpObserver.disconnect();
      this.lcpObserver = null;
    }
  }
}

// ==================== 错误监听器 ====================

/**
 * 错误监听器类
 */
class ErrorMonitor {
  private errors: ErrorInfo[] = [];
  private maxErrors: number = 50;
  private listeners: {
    error: (event: ErrorEvent) => void;
    unhandledrejection: (event: PromiseRejectionEvent) => void;
  };
  
  constructor() {
    this.listeners = {
      error: this.handleError.bind(this),
      unhandledrejection: this.handleUnhandledRejection.bind(this)
    };
  }
  
  /**
   * 开始监听
   */
  start(): void {
    window.addEventListener('error', this.listeners.error, true);
    window.addEventListener('unhandledrejection', this.listeners.unhandledrejection);
  }
  
  /**
   * 停止监听
   */
  stop(): void {
    window.removeEventListener('error', this.listeners.error, true);
    window.removeEventListener('unhandledrejection', this.listeners.unhandledrejection);
  }
  
  /**
   * 处理错误事件
   */
  private handleError(event: ErrorEvent): void {
    if (this.errors.length >= this.maxErrors) return;
    
    this.errors.push({
      message: event.message || 'Unknown error',
      stack: event.error?.stack,
      type: 'error'
    });
  }
  
  /**
   * 处理未处理的 Promise 拒绝
   */
  private handleUnhandledRejection(event: PromiseRejectionEvent): void {
    if (this.errors.length >= this.maxErrors) return;
    
    let message = 'Unhandled Promise rejection';
    let stack: string | undefined;
    
    if (event.reason instanceof Error) {
      message = event.reason.message;
      stack = event.reason.stack;
    } else if (typeof event.reason === 'string') {
      message = event.reason;
    }
    
    this.errors.push({
      message,
      stack,
      type: 'unhandledrejection'
    });
  }
  
  /**
   * 获取所有错误
   */
  getErrors(): ErrorInfo[] {
    return [...this.errors];
  }
  
  /**
   * 判断是否有关键错误
   */
  hasCriticalErrors(): boolean {
    // 检查是否有可能导致白屏的错误
    const criticalPatterns = [
      /chunk.*failed/i,
      /loading.*chunk/i,
      /script.*error/i,
      /syntaxerror/i,
      /referenceerror/i,
      /cannot read/i,
      /is not defined/i,
      /unexpected token/i
    ];
    
    for (const error of this.errors) {
      for (const pattern of criticalPatterns) {
        if (pattern.test(error.message)) {
          return true;
        }
      }
    }
    
    return false;
  }
  
  /**
   * 清除错误
   */
  clear(): void {
    this.errors = [];
  }
}

// ==================== 数据上报器 ====================

/**
 * 数据上报器类
 */
class Reporter {
  private config: {
    endpoint?: string;
    enableConsole: boolean;
    enableBeacon: boolean;
    sampleRate: number;
    customHeaders?: Record<string, string>;
  };
  
  private queue: WhiteScreenReport[] = [];
  private maxQueueSize: number = 10;
  private isFlushing: boolean = false;
  
  constructor(config: {
    endpoint?: string;
    enableConsole?: boolean;
    enableBeacon?: boolean;
    sampleRate?: number;
    customHeaders?: Record<string, string>;
  } = {}) {
    this.config = {
      endpoint: config.endpoint,
      enableConsole: config.enableConsole ?? true,
      enableBeacon: config.enableBeacon ?? true,
      sampleRate: config.sampleRate ?? 1,
      customHeaders: config.customHeaders
    };
    
    // 页面卸载时发送队列中的数据
    window.addEventListener('beforeunload', () => {
      this.flush();
    });
    
    window.addEventListener('pagehide', () => {
      this.flush();
    });
  }
  
  /**
   * 上报数据
   */
  report(data: WhiteScreenReport): void {
    // 采样
    if (Math.random() > this.config.sampleRate) {
      return;
    }
    
    // 控制台输出
    if (this.config.enableConsole) {
      this.logToConsole(data);
    }
    
    // 加入队列
    this.queue.push(data);
    
    // 队列满了就发送
    if (this.queue.length >= this.maxQueueSize) {
      this.flush();
    }
    
    // 也可以立即发送(针对白屏这种重要事件)
    if (data.isWhiteScreen) {
      this.flush();
    }
  }
  
  /**
   * 发送队列中的数据
   */
  flush(): void {
    if (this.isFlushing || this.queue.length === 0) return;
    
    this.isFlushing = true;
    const dataToSend = [...this.queue];
    this.queue = [];
    
    if (this.config.endpoint) {
      this.sendData(dataToSend);
    }
    
    this.isFlushing = false;
  }
  
  /**
   * 发送数据
   */
  private sendData(data: WhiteScreenReport[]): void {
    const payload = JSON.stringify(data);
    
    // 优先使用 Beacon API
    if (this.config.enableBeacon && navigator.sendBeacon) {
      try {
        const blob = new Blob([payload], { type: 'application/json' });
        const success = navigator.sendBeacon(this.config.endpoint!, blob);
        if (success) return;
      } catch {
        // Beacon 失败,降级到 fetch
      }
    }
    
    // 降级使用 fetch
    this.sendWithFetch(payload);
  }
  
  /**
   * 使用 fetch 发送
   */
  private sendWithFetch(payload: string): void {
    if (!this.config.endpoint) return;
    
    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
      ...this.config.customHeaders
    };
    
    fetch(this.config.endpoint, {
      method: 'POST',
      headers,
      body: payload,
      keepalive: true
    }).catch(() => {
      // 静默失败
    });
  }
  
  /**
   * 控制台输出
   */
  private logToConsole(data: WhiteScreenReport): void {
    const prefix = '[WhiteScreen]';
    
    if (data.isWhiteScreen) {
      console.warn(
        `${prefix} 检测到白屏!`,
        '\n方法:', data.detectionMethod,
        '\nURL:', data.url,
        '\n时间:', new Date(data.timestamp).toISOString(),
        '\n详情:', data
      );
    } else {
      console.log(
        `${prefix} 页面正常`,
        '\n方法:', data.detectionMethod,
        '\n耗时:', Date.now() - data.timestamp, 'ms'
      );
    }
  }
  
  /**
   * 设置上报端点
   */
  setEndpoint(endpoint: string): void {
    this.config.endpoint = endpoint;
  }
  
  /**
   * 设置采样率
   */
  setSampleRate(rate: number): void {
    this.config.sampleRate = Math.max(0, Math.min(1, rate));
  }
}

// ==================== 主 SDK 类 ====================

/**
 * 白屏检测 SDK 主类
 */
class WhiteScreenSDK {
  private config: Required<WhiteScreenConfig>;
  private samplingDetector: SamplingDetector;
  private domDetector: DOMDetector;
  private mutationDetector: MutationDetector;
  private screenshotDetector: ScreenshotDetector;
  private skeletonDetector: SkeletonDetector;
  private performanceDetector: PerformanceDetector;
  private errorMonitor: ErrorMonitor;
  private reporter: Reporter;
  
  private isInitialized: boolean = false;
  private isDetecting: boolean = false;
  private detectionCount: number = 0;
  private lastDetectionTime: number = 0;
  
  // 默认配置
  private static defaultConfig: Required<WhiteScreenConfig> = {
    samplingPoints: 17,
    delay: 1000,
    timeout: 10000,
    threshold: 0.95,
    enableDOMDetection: true,
    enableSamplingDetection: true,
    enableMutationDetection: true,
    enableScreenshotDetection: false,
    enableSkeletonDetection: true,
    skeletonSelector: '.skeleton, [data-skeleton], .loading-skeleton',
    keyElementSelectors: ['#app', '#root', '.app', 'main'],
    ignoreSelectors: ['script', 'style', 'link', 'meta'],
    container: null,
    onReport: () => {},
    onDetectionComplete: () => {},
    enableInDev: false,
    maxRetries: 3,
    retryInterval: 1000,
    customDetector: undefined as unknown as () => boolean
  };
  
  constructor(config: Partial<WhiteScreenConfig> = {}) {
    // 合并配置
    this.config = deepMerge(WhiteScreenSDK.defaultConfig, config);
    
    // 初始化各个检测器
    this.samplingDetector = new SamplingDetector(this.config);
    this.domDetector = new DOMDetector(this.config);
    this.mutationDetector = new MutationDetector(this.config);
    this.screenshotDetector = new ScreenshotDetector(this.config);
    this.skeletonDetector = new SkeletonDetector(this.config);
    this.performanceDetector = new PerformanceDetector(this.config);
    this.errorMonitor = new ErrorMonitor();
    
    // 初始化上报器
    this.reporter = new Reporter({
      enableConsole: true,
      enableBeacon: true
    });
  }
  
  /**
   * 初始化 SDK
   */
  init(): WhiteScreenSDK {
    if (this.isInitialized) {
      console.warn('[WhiteScreenSDK] Already initialized');
      return this;
    }
    
    // 检查是否在开发环境
    if (!this.config.enableInDev && this.isDevelopment()) {
      console.log('[WhiteScreenSDK] Disabled in development environment');
      return this;
    }
    
    // 开始错误监听
    this.errorMonitor.start();
    
    // 初始化性能检测器
    this.performanceDetector.initLCPObserver();
    
    // 在页面加载完成后开始检测
    if (document.readyState === 'complete') {
      this.scheduleDetection();
    } else {
      window.addEventListener('load', () => {
        this.scheduleDetection();
      });
    }
    
    // 监听页面可见性变化
    document.addEventListener('visibilitychange', () => {
      if (document.visibilityState === 'visible' && !this.isDetecting) {
        this.scheduleDetection();
      }
    });
    
    this.isInitialized = true;
    console.log('[WhiteScreenSDK] Initialized');
    
    return this;
  }
  
  /**
   * 判断是否为开发环境
   */
  private isDevelopment(): boolean {
    return (
      window.location.hostname === 'localhost' ||
      window.location.hostname === '127.0.0.1' ||
      window.location.hostname.includes('.local') ||
      window.location.port !== ''
    );
  }
  
  /**
   * 调度检测
   */
  private scheduleDetection(): void {
    setTimeout(() => {
      this.detect();
    }, this.config.delay);
  }
  
  /**
   * 执行检测
   */
  async detect(): Promise<DetectionResult> {
    if (this.isDetecting) {
      return {
        isWhiteScreen: false,
        confidence: 0,
        methods: [],
        methodResults: new Map(),
        basis: 'Already detecting'
      };
    }
    
    this.isDetecting = true;
    this.detectionCount++;
    this.lastDetectionTime = Date.now();
    
    const methodResults = new Map<DetectionMethod, boolean>();
    const usedMethods: DetectionMethod[] = [];
    
    try {
      // 执行各种检测方法
      
      // 1. 采样点检测
      if (this.config.enableSamplingDetection) {
        const samplingResult = this.samplingDetector.detect();
        const isWhite = this.samplingDetector.isWhiteScreen(samplingResult);
        methodResults.set(DetectionMethod.SAMPLING, isWhite);
        usedMethods.push(DetectionMethod.SAMPLING);
      }
      
      // 2. DOM 检测
      if (this.config.enableDOMDetection) {
        const domResult = this.domDetector.detect();
        const isWhite = !domResult.passed || !this.domDetector.hasValidContent();
        methodResults.set(DetectionMethod.DOM, isWhite);
        usedMethods.push(DetectionMethod.DOM);
      }
      
      // 3. 骨架屏检测
      if (this.config.enableSkeletonDetection) {
        const skeletonResult = await this.skeletonDetector.detect();
        const isWhite = skeletonResult.skeletonExists && !skeletonResult.skeletonRemoved;
        methodResults.set(DetectionMethod.SKELETON, isWhite);
        usedMethods.push(DetectionMethod.SKELETON);
      }
      
      // 4. 截图检测(较慢,可选)
      if (this.config.enableScreenshotDetection) {
        const screenshotResult = await this.screenshotDetector.detect();
        methodResults.set(DetectionMethod.SCREENSHOT, screenshotResult.isWhiteScreen);
        usedMethods.push(DetectionMethod.SCREENSHOT);
      }
      
      // 5. 自定义检测器
      if (this.config.customDetector) {
        try {
          const customResult = await this.config.customDetector();
          methodResults.set(DetectionMethod.CUSTOM, customResult);
          usedMethods.push(DetectionMethod.CUSTOM);
        } catch {
          // 自定义检测器失败
        }
      }
      
      // 综合判断
      const result = this.combineResults(methodResults, usedMethods);
      
      // 生成报告
      const report = this.generateReport(result, methodResults);
      
      // 上报
      this.reporter.report(report);
      this.config.onReport(report);
      this.config.onDetectionComplete(result);
      
      // 如果检测到白屏,可能需要重试
      if (result.isWhiteScreen && this.detectionCount < this.config.maxRetries) {
        setTimeout(() => {
          this.isDetecting = false;
          this.detect();
        }, this.config.retryInterval);
      }
      
      return result;
    } catch (error) {
      console.error('[WhiteScreenSDK] Detection error:', error);
      return {
        isWhiteScreen: false,
        confidence: 0,
        methods: usedMethods,
        methodResults,
        basis: `Error: ${(error as Error).message}`
      };
    } finally {
      this.isDetecting = false;
    }
  }
  
  /**
   * 综合各方法结果
   */
  private combineResults(
    methodResults: Map<DetectionMethod, boolean>,
    usedMethods: DetectionMethod[]
  ): DetectionResult {
    if (usedMethods.length === 0) {
      return {
        isWhiteScreen: false,
        confidence: 0,
        methods: [],
        methodResults,
        basis: 'No detection methods enabled'
      };
    }
    
    // 计算白屏票数
    let whiteVotes = 0;
    let totalVotes = 0;
    
    // 不同方法的权重
    const weights: Record<DetectionMethod, number> = {
      [DetectionMethod.SAMPLING]: 3,
      [DetectionMethod.DOM]: 2,
      [DetectionMethod.MUTATION]: 2,
      [DetectionMethod.SCREENSHOT]: 2,
      [DetectionMethod.SKELETON]: 1,
      [DetectionMethod.PERFORMANCE]: 1,
      [DetectionMethod.CUSTOM]: 2,
      [DetectionMethod.COMBINED]: 1
    };
    
    for (const method of usedMethods) {
      const isWhite = methodResults.get(method);
      const weight = weights[method] || 1;
      
      totalVotes += weight;
      if (isWhite) {
        whiteVotes += weight;
      }
    }
    
    // 计算置信度
    const confidence = totalVotes > 0 ? Math.abs(whiteVotes - totalVotes / 2) / (totalVotes / 2) : 0;
    
    // 判断是否白屏(超过半数加权投票)
    const isWhiteScreen = whiteVotes > totalVotes / 2;
    
    // 确定判定依据
    let basis = isWhiteScreen ? 'Multiple methods indicate white screen' : 'Page appears normal';
    
    // 检查是否有关键错误
    if (this.errorMonitor.hasCriticalErrors()) {
      basis += ' (Critical JS errors detected)';
    }
    
    return {
      isWhiteScreen,
      confidence,
      methods: usedMethods,
      methodResults,
      basis
    };
  }
  
  /**
   * 生成报告
   */
  private generateReport(
    result: DetectionResult,
    methodResults: Map<DetectionMethod, boolean>
  ): WhiteScreenReport {
    const report: WhiteScreenReport = {
      isWhiteScreen: result.isWhiteScreen,
      timestamp: Date.now(),
      url: window.location.href,
      detectionMethod: result.methods.length > 1 ? DetectionMethod.COMBINED : result.methods[0],
      userAgent: navigator.userAgent,
      viewport: {
        width: window.innerWidth,
        height: window.innerHeight
      },
      devicePixelRatio: window.devicePixelRatio,
      networkInfo: getNetworkInfo(),
      performanceData: this.performanceDetector.getMetrics()
    };
    
    // 添加采样结果
    if (methodResults.has(DetectionMethod.SAMPLING)) {
      report.samplingResult = this.samplingDetector.detect();
    }
    
    // 添加 DOM 检测结果
    if (methodResults.has(DetectionMethod.DOM)) {
      report.domResult = this.domDetector.detect();
    }
    
    // 添加错误信息
    const errors = this.errorMonitor.getErrors();
    if (errors.length > 0) {
      report.errorInfo = errors[0];
    }
    
    return report;
  }
  
  /**
   * 手动触发检测
   */
  manualDetect(): Promise<DetectionResult> {
    return this.detect();
  }
  
  /**
   * 设置上报回调
   */
  setReportCallback(callback: (data: WhiteScreenReport) => void): void {
    this.config.onReport = callback;
  }
  
  /**
   * 设置检测完成回调
   */
  setDetectionCompleteCallback(callback: (result: DetectionResult) => void): void {
    this.config.onDetectionComplete = callback;
  }
  
  /**
   * 获取检测统计
   */
  getStats(): {
    detectionCount: number;
    lastDetectionTime: number;
    errors: ErrorInfo[];
    performance: PerformanceData;
  } {
    return {
      detectionCount: this.detectionCount,
      lastDetectionTime: this.lastDetectionTime,
      errors: this.errorMonitor.getErrors(),
      performance: this.performanceDetector.getMetrics()
    };
  }
  
  /**
   * 销毁 SDK
   */
  destroy(): void {
    this.errorMonitor.stop();
    this.performanceDetector.stop();
    this.skeletonDetector.stop();
    this.screenshotDetector.dispose();
    this.isInitialized = false;
    console.log('[WhiteScreenSDK] Destroyed');
  }
}

// ==================== 导出 ====================

// 创建单例
let sdkInstance: WhiteScreenSDK | null = null;

/**
 * 获取 SDK 实例(单例模式)
 */
function getWhiteScreenSDK(config?: Partial<WhiteScreenConfig>): WhiteScreenSDK {
  if (!sdkInstance) {
    sdkInstance = new WhiteScreenSDK(config);
  }
  return sdkInstance;
}

/**
 * 快速初始化并返回实例
 */
function initWhiteScreenDetection(config?: Partial<WhiteScreenConfig>): WhiteScreenSDK {
  const sdk = getWhiteScreenSDK(config);
  sdk.init();
  return sdk;
}

// ES Module 导出
export {
  WhiteScreenSDK,
  WhiteScreenConfig,
  WhiteScreenReport,
  DetectionMethod,
  DetectionResult,
  SamplingDetector,
  DOMDetector,
  MutationDetector,
  ScreenshotDetector,
  SkeletonDetector,
  PerformanceDetector,
  ErrorMonitor,
  Reporter,
  getWhiteScreenSDK,
  initWhiteScreenDetection
};

// UMD 导出(兼容浏览器直接使用)
if (typeof window !== 'undefined') {
  (window as Window & { WhiteScreenSDK: typeof WhiteScreenSDK; initWhiteScreenDetection: typeof initWhiteScreenDetection }).WhiteScreenSDK = WhiteScreenSDK;
  (window as Window & { initWhiteScreenDetection: typeof initWhiteScreenDetection }).initWhiteScreenDetection = initWhiteScreenDetection;
}

三、使用示例

1. 基础使用

// 最简单的使用方式
import { initWhiteScreenDetection } from './white-screen-sdk';

// 初始化并开始检测
const sdk = initWhiteScreenDetection({
  threshold: 0.9,
  delay: 2000,
  onReport: (report) => {
    if (report.isWhiteScreen) {
      // 发送到监控平台
      sendToMonitor(report);
    }
  }
});

2. 完整配置示例

import { WhiteScreenSDK, WhiteScreenReport, DetectionResult } from './white-screen-sdk';

// 创建 SDK 实例,完整配置
const sdk = new WhiteScreenSDK({
  // 采样配置
  samplingPoints: 20,
  threshold: 0.9,
  
  // 时间配置
  delay: 1500,
  timeout: 15000,
  
  // 功能开关
  enableDOMDetection: true,
  enableSamplingDetection: true,
  enableMutationDetection: true,
  enableScreenshotDetection: false,
  enableSkeletonDetection: true,
  
  // 选择器配置
  keyElementSelectors: [
    '#app',
    '#root',
    '.main-content',
    '[data-page-ready]'
  ],
  skeletonSelector: '.skeleton, .loading-placeholder',
  ignoreSelectors: [
    'script',
    'style',
    '.loading-indicator',
    '.modal-backdrop'
  ],
  
  // 重试配置
  maxRetries: 3,
  retryInterval: 2000,
  
  // 环境配置
  enableInDev: false,
  
  // 回调函数
  onReport: (report: WhiteScreenReport) => {
    console.log('检测报告:', report);
    
    if (report.isWhiteScreen) {
      // 上报到监控系统
      reportToMonitoringSystem(report);
      
      // 可选:尝试恢复页面
      attemptPageRecovery();
    }
  },
  
  onDetectionComplete: (result: DetectionResult) => {
    console.log('检测完成:', result);
    console.log('置信度:', result.confidence);
    console.log('判定依据:', result.basis);
  },
  
  // 自定义检测逻辑
  customDetector: () => {
    // 自定义白屏判断逻辑
    const appElement = document.getElementById('app');
    if (!appElement) return true;
    
    // 检查是否有实际内容
    const hasContent = appElement.children.length > 0;
    const hasText = (appElement.textContent?.trim().length ?? 0) > 100;
    
    return !hasContent && !hasText;
  }
});

// 初始化
sdk.init();

// 手动触发检测
document.getElementById('checkBtn')?.addEventListener('click', async () => {
  const result = await sdk.manualDetect();
  console.log('手动检测结果:', result);
});

// 上报函数
function reportToMonitoringSystem(report: WhiteScreenReport): void {
  // 发送到监控后端
  fetch('/api/monitor/white-screen', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(report)
  }).catch(console.error);
}

// 页面恢复尝试
function attemptPageRecovery(): void {
  // 尝试重新加载关键资源
  const failedScripts = document.querySelectorAll('script[data-retry]');
  failedScripts.forEach(script => {
    const newScript = document.createElement('script');
    newScript.src = (script as HTMLScriptElement).src;
    document.body.appendChild(newScript);
  });
  
  // 或者刷新页面
  // window.location.reload();
}

3. Vue 3 集成示例

// white-screen-plugin.ts
import { App, Plugin } from 'vue';
import { WhiteScreenSDK, WhiteScreenConfig } from './white-screen-sdk';

export interface WhiteScreenPluginOptions extends Partial<WhiteScreenConfig> {
  reportEndpoint?: string;
}

export const WhiteScreenPlugin: Plugin = {
  install(app: App, options: WhiteScreenPluginOptions = {}) {
    // 创建 SDK 实例
    const sdk = new WhiteScreenSDK({
      // 默认配置
      keyElementSelectors: ['#app', '[data-v-app]'],
      enableInDev: false,
      
      // 合并用户配置
      ...options,
      
      // 设置上报回调
      onReport: (report) => {
        // 调用用户提供的回调
        options.onReport?.(report);
        
        // 如果提供了上报端点,自动上报
        if (options.reportEndpoint && report.isWhiteScreen) {
          fetch(options.reportEndpoint, {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
              ...report,
              framework: 'vue',
              version: app.version
            })
          }).catch(console.error);
        }
      }
    });
    
    // 注册全局属性
    app.config.globalProperties.$whiteScreen = sdk;
    
    // 提供给 Composition API 使用
    app.provide('whiteScreenSDK', sdk);
    
    // 在应用挂载后初始化
    const originalMount = app.mount.bind(app);
    app.mount = (rootContainer) => {
      const vm = originalMount(rootContainer);
      
      // 延迟初始化,等待 Vue 渲染完成
      setTimeout(() => {
        sdk.init();
      }, 100);
      
      return vm;
    };
    
    // 在应用卸载时销毁
    const originalUnmount = app.unmount.bind(app);
    app.unmount = () => {
      sdk.destroy();
      originalUnmount();
    };
  }
};

// 类型声明
declare module '@vue/runtime-core' {
  interface ComponentCustomProperties {
    $whiteScreen: WhiteScreenSDK;
  }
}

// 组合式 API Hook
import { inject } from 'vue';

export function useWhiteScreen(): WhiteScreenSDK | undefined {
  return inject<WhiteScreenSDK>('whiteScreenSDK');
}
// main.ts
import { createApp } from 'vue';
import App from './App.vue';
import { WhiteScreenPlugin } from './plugins/white-screen-plugin';

const app = createApp(App);

app.use(WhiteScreenPlugin, {
  threshold: 0.9,
  delay: 2000,
  reportEndpoint: '/api/monitor/white-screen',
  enableInDev: false,
  keyElementSelectors: ['#app', '.main-layout', '[data-page-content]'],
  onDetectionComplete: (result) => {
    if (result.isWhiteScreen) {
      // 可以在这里触发重试逻辑或显示友好提示
      console.warn('页面可能出现白屏,正在尝试恢复...');
    }
  }
});

app.mount('#app');
<!-- 在组件中使用 -->
<template>
  <div class="page">
    <h1>示例页面</h1>
    <button @click="checkWhiteScreen">手动检测白屏</button>
  </div>
</template>

<script setup lang="ts">
import { useWhiteScreen } from '@/plugins/white-screen-plugin';

const whiteScreenSDK = useWhiteScreen();

async function checkWhiteScreen() {
  if (whiteScreenSDK) {
    const result = await whiteScreenSDK.manualDetect();
    console.log('检测结果:', result);
    
    if (result.isWhiteScreen) {
      alert('检测到白屏问题!');
    } else {
      alert('页面正常');
    }
  }
}
</script>

4. React 集成示例

// WhiteScreenContext.tsx
import React, { createContext, useContext, useEffect, useRef, ReactNode } from 'react';
import { WhiteScreenSDK, WhiteScreenConfig, DetectionResult } from './white-screen-sdk';

interface WhiteScreenContextValue {
  sdk: WhiteScreenSDK | null;
  manualDetect: () => Promise<DetectionResult | null>;
  getStats: () => ReturnType<WhiteScreenSDK['getStats']> | null;
}

const WhiteScreenContext = createContext<WhiteScreenContextValue>({
  sdk: null,
  manualDetect: async () => null,
  getStats: () => null
});

export interface WhiteScreenProviderProps {
  children: ReactNode;
  config?: Partial<WhiteScreenConfig>;
  onWhiteScreen?: (result: DetectionResult) => void;
}

export function WhiteScreenProvider({ 
  children, 
  config = {},
  onWhiteScreen 
}: WhiteScreenProviderProps) {
  const sdkRef = useRef<WhiteScreenSDK | null>(null);
  
  useEffect(() => {
    // 创建 SDK 实例
    const sdk = new WhiteScreenSDK({
      keyElementSelectors: ['#root', '[data-reactroot]', '.app-container'],
      enableInDev: false,
      ...config,
      onDetectionComplete: (result) => {
        config.onDetectionComplete?.(result);
        
        if (result.isWhiteScreen && onWhiteScreen) {
          onWhiteScreen(result);
        }
      }
    });
    
    sdkRef.current = sdk;
    
    // 初始化
    sdk.init();
    
    // 清理
    return () => {
      sdk.destroy();
      sdkRef.current = null;
    };
  }, []);
  
  const manualDetect = async () => {
    if (sdkRef.current) {
      return sdkRef.current.manualDetect();
    }
    return null;
  };
  
  const getStats = () => {
    if (sdkRef.current) {
      return sdkRef.current.getStats();
    }
    return null;
  };
  
  return (
    <WhiteScreenContext.Provider value={{ 
      sdk: sdkRef.current, 
      manualDetect, 
      getStats 
    }}>
      {children}
    </WhiteScreenContext.Provider>
  );
}

// Hook
export function useWhiteScreen() {
  return useContext(WhiteScreenContext);
}

// HOC
export function withWhiteScreenDetection<P extends object>(
  WrappedComponent: React.ComponentType<P>,
  config?: Partial<WhiteScreenConfig>
) {
  return function WithWhiteScreenComponent(props: P) {
    return (
      <WhiteScreenProvider config={config}>
        <WrappedComponent {...props} />
      </WhiteScreenProvider>
    );
  };
}
// App.tsx
import React from 'react';
import { WhiteScreenProvider } from './contexts/WhiteScreenContext';

function App() {
  const handleWhiteScreen = (result) => {
    // 白屏时的处理逻辑
    console.error('检测到白屏:', result);
    
    // 可以显示错误边界或重试按钮
    // setShowErrorBoundary(true);
  };

  return (
    <WhiteScreenProvider 
      config={{
        threshold: 0.85,
        delay: 2000,
        enableScreenshotDetection: false
      }}
      onWhiteScreen={handleWhiteScreen}
    >
      <div className="app">
        {/* 应用内容 */}
      </div>
    </WhiteScreenProvider>
  );
}

export default App;
// 在组件中使用
import React from 'react';
import { useWhiteScreen } from './contexts/WhiteScreenContext';

function DebugPanel() {
  const { manualDetect, getStats } = useWhiteScreen();
  
  const handleCheck = async () => {
    const result = await manualDetect();
    if (result) {
      console.log('检测结果:', result);
      alert(result.isWhiteScreen ? '检测到白屏' : '页面正常');
    }
  };
  
  const handleShowStats = () => {
    const stats = getStats();
    console.log('统计信息:', stats);
  };
  
  return (
    <div className="debug-panel">
      <button onClick={handleCheck}>手动检测白屏</button>
      <button onClick={handleShowStats}>查看统计</button>
    </div>
  );
}

四、服务端数据处理示例

// server/white-screen-handler.ts
import { Request, Response } from 'express';

interface WhiteScreenReport {
  isWhiteScreen: boolean;
  timestamp: number;
  url: string;
  detectionMethod: string;
  userAgent: string;
  viewport: { width: number; height: number };
  performanceData?: Record<string, number>;
  errorInfo?: { message: string; stack?: string; type: string };
  // ... 其他字段
}

interface WhiteScreenStats {
  totalReports: number;
  whiteScreenCount: number;
  whiteScreenRate: number;
  topUrls: { url: string; count: number }[];
  topErrors: { message: string; count: number }[];
  hourlyDistribution: { hour: number; count: number }[];
}

class WhiteScreenAnalyzer {
  private reports: WhiteScreenReport[] = [];
  private maxReports: number = 10000;
  
  /**
   * 添加报告
   */
  addReport(report: WhiteScreenReport): void {
    this.reports.push(report);
    
    // 保持报告数量在限制内
    if (this.reports.length > this.maxReports) {
      this.reports = this.reports.slice(-this.maxReports);
    }
  }
  
  /**
   * 获取统计数据
   */
  getStats(timeRange?: { start: number; end: number }): WhiteScreenStats {
    let filteredReports = this.reports;
    
    if (timeRange) {
      filteredReports = this.reports.filter(
        r => r.timestamp >= timeRange.start && r.timestamp <= timeRange.end
      );
    }
    
    const totalReports = filteredReports.length;
    const whiteScreenReports = filteredReports.filter(r => r.isWhiteScreen);
    const whiteScreenCount = whiteScreenReports.length;
    const whiteScreenRate = totalReports > 0 ? whiteScreenCount / totalReports : 0;
    
    // 统计 Top URLs
    const urlCounts = new Map<string, number>();
    whiteScreenReports.forEach(r => {
      const url = new URL(r.url).pathname;
      urlCounts.set(url, (urlCounts.get(url) || 0) + 1);
    });
    
    const topUrls = Array.from(urlCounts.entries())
      .map(([url, count]) => ({ url, count }))
      .sort((a, b) => b.count - a.count)
      .slice(0, 10);
    
    // 统计 Top Errors
    const errorCounts = new Map<string, number>();
    whiteScreenReports.forEach(r => {
      if (r.errorInfo?.message) {
        const msg = r.errorInfo.message.slice(0, 100);
        errorCounts.set(msg, (errorCounts.get(msg) || 0) + 1);
      }
    });
    
    const topErrors = Array.from(errorCounts.entries())
      .map(([message, count]) => ({ message, count }))
      .sort((a, b) => b.count - a.count)
      .slice(0, 10);
    
    // 小时分布
    const hourCounts = new Array(24).fill(0);
    whiteScreenReports.forEach(r => {
      const hour = new Date(r.timestamp).getHours();
      hourCounts[hour]++;
    });
    
    const hourlyDistribution = hourCounts.map((count, hour) => ({ hour, count }));
    
    return {
      totalReports,
      whiteScreenCount,
      whiteScreenRate,
      topUrls,
      topErrors,
      hourlyDistribution
    };
  }
  
  /**
   * 检查是否需要告警
   */
  shouldAlert(): { alert: boolean; reason: string } {
    const recentReports = this.reports.filter(
      r => Date.now() - r.timestamp < 5 * 60 * 1000 // 最近5分钟
    );
    
    if (recentReports.length === 0) {
      return { alert: false, reason: '' };
    }
    
    const whiteScreenRate = recentReports.filter(r => r.isWhiteScreen).length / recentReports.length;
    
    if (whiteScreenRate > 0.1) {
      return { 
        alert: true, 
        reason: `白屏率过高: ${(whiteScreenRate * 100).toFixed(1)}%` 
      };
    }
    
    return { alert: false, reason: '' };
  }
}

// Express 路由处理
const analyzer = new WhiteScreenAnalyzer();

export function handleWhiteScreenReport(req: Request, res: Response): void {
  try {
    const reports: WhiteScreenReport[] = Array.isArray(req.body) ? req.body : [req.body];
    
    reports.forEach(report => {
      analyzer.addReport(report);
    });
    
    // 检查是否需要告警
    const alertStatus = analyzer.shouldAlert();
    if (alertStatus.alert) {
      // 发送告警(例如:发送邮件、短信、钉钉通知等)
      sendAlert(alertStatus.reason);
    }
    
    res.status(200).json({ success: true, received: reports.length });
  } catch (error) {
    console.error('处理白屏报告失败:', error);
    res.status(500).json({ success: false, error: (error as Error).message });
  }
}

export function getWhiteScreenStats(req: Request, res: Response): void {
  try {
    const { start, end } = req.query;
    
    const timeRange = start && end 
      ? { start: Number(start), end: Number(end) }
      : undefined;
    
    const stats = analyzer.getStats(timeRange);
    res.json(stats);
  } catch (error) {
    res.status(500).json({ error: (error as Error).message });
  }
}

function sendAlert(reason: string): void {
  // 实现告警逻辑
  console.warn('[白屏告警]', reason);
  // 可以集成各种告警渠道:邮件、短信、钉钉、企业微信等
}

五、总结

以上代码实现了一个完整的前端白屏检测 SDK,包含以下核心功能:

  1. 多种检测方法

    • 采样点检测 - 在页面关键位置采样判断
    • DOM 检测 - 检查关键 DOM 元素是否存在和可见
    • MutationObserver 检测 - 监听 DOM 变化
    • 截图检测 - 通过 Canvas 分析页面内容
    • 骨架屏检测 - 检测 loading 状态
  2. 完善的上报机制

    • 支持 Beacon API 和 fetch 降级
    • 队列批量上报
    • 页面卸载时发送
  3. 框架集成

    • Vue 3 插件和 Composition API Hook
    • React Context 和 Hook
  4. 服务端处理

    • 数据统计分析
    • 告警机制

实际使用时,可以根据项目需求选择合适的检测方法组合,并配置合理的阈值和延迟时间,以达到最佳的检测效果。

Browserslist 配置说明文档

作者 果然_
2026年1月28日 11:21

什么是 Browserslist

Browserslist 是一个在不同前端工具之间共享目标浏览器和 Node.js 版本的配置工具。它允许你通过一个统一的配置来指定项目需要支持的浏览器范围。

核心概念

{
  "browserslist": [
    "> 1%",           // 全球使用率超过 1% 的浏览器
    "last 2 versions", // 每个浏览器的最后 2 个版本
    "not dead"        // 排除官方不再支持的浏览器
  ]
}

这个配置会被多个工具读取,确保整个构建流程使用相同的浏览器兼容性目标。

Browserslist 的作用

  1. 统一浏览器兼容性标准

在一个项目中,多个工具可能需要知道目标浏览器:

  • Autoprefixer 需要知道哪些 CSS 属性需要添加前缀
  • Babel 需要知道哪些 JavaScript 语法需要转译
  • ESLint 需要知道哪些 API 可以使用

Browserslist 提供了一个单一配置源,避免在多个地方重复配置。

  1. 自动化浏览器兼容性处理

  1. JavaScript 转译

// 源代码
const value = obj?.property ?? 'default';

// 如果目标浏览器不支持 ES2020
// Babel 会自动转译为:
var _a;
const value = (_a = obj === null || obj === void 0 ? void 0 : obj.property) !== null && _a !== void 0 ? _a : 'default';
  1. CSS 前缀自动添加

/* 源代码 */
.box {
  display: flex;
  user-select: none;
}

/* 如果目标包含旧浏览器,Autoprefixer 会自动添加前缀 */
.box {
  display: -webkit-box;
  display: -ms-flexbox;
  display: flex;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}
  1. 优化构建产物大小

通过精确指定目标浏览器,可以:

  • 减少不必要的 polyfill
  • 避免过度转译
  • 生成更小的构建产物
// 如果目标浏览器都支持 ES2020
// 就不需要转译,保持原样,代码更小
const value = obj?.property ?? 'default';

常用配置示例

  1. 配置位置

Browserslist 配置可以放在以下位置(按优先级排序):

  1. package.json 中的 browserslist 字段(推荐)

  2. .browserslistrc 文件

  3. browserslist 配置文件

  1. 现代浏览器配置(推荐)

{
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version",
      "last 1 safari version"
    ]
  }
}

说明:

  • >0.2%:全球使用率超过 0.2% 的浏览器
  • not dead:排除官方不再支持的浏览器(24 个月内无更新)
  • not op_mini all:排除 Opera Mini(不支持现代特性)
  • 开发环境只针对最新版本,加快构建速度
  1. 企业级应用配置

{
  "browserslist": [
    "Chrome >= 80",
    "Firefox >= 72",
    "Safari >= 13",
    "Edge >= 80",
    "iOS >= 13",
    "Android >= 8"
  ]
}

适用场景: 企业内部应用,可以控制用户浏览器版本

  1. 兼容旧浏览器配置

{
  "browserslist": [
    "> 0.5%",
    "last 2 versions",
    "Firefox ESR",
    "not dead",
    "IE 11"
  ]
}

说明:

  • last 2 versions:每个浏览器的最后 2 个版本
  • Firefox ESR:Firefox 长期支持版
  • IE 11:支持 IE 11(需要大量 polyfill)
  1. 移动端优先配置

{
  "browserslist": [
    "iOS >= 12",
    "Android >= 6",
    "ChromeAndroid >= 80",
    "Samsung >= 10"
  ]
}
  1. 查询语法参考

查询语法 说明 示例
> 5% 全球使用率 > 5% > 1%, > 0.5%
>= 5% 全球使用率 >= 5% >= 1%
last 2 versions 最后 N 个版本 last 1 version
Chrome > 90 特定浏览器版本 Firefox >= 78
not dead 排除不再维护的浏览器 -
not IE 11 排除特定浏览器 not op_mini all
since 2020 2020 年后发布的浏览器 since 2019
Firefox ESR Firefox 长期支持版 -
iOS >= 12 iOS Safari 版本 iOS >= 13
Android >= 6 Android WebView 版本 Android >= 8

需要配合的依赖

Browserslist 本身只是一个配置工具,需要配合其他工具才能发挥作用。

核心依赖关系图

browserslist (配置)
    ↓
    ├─→ Autoprefixer (CSS 前缀)
    ├─→ @babel/preset-env (JS 转译)
    ├─→ postcss-preset-env (CSS 新特性)
    ├─→ ESLint (代码检查)
    └─→ Stylelint (样式检查)
  1. Autoprefixer(CSS 自动前缀)

作用: 根据 browserslist 自动为 CSS 属性添加浏览器前缀

安装:

pnpm add -D autoprefixer postcss

配置:

// postcss.config.js
module.exports = {
  plugins: {
    autoprefixer: {}
  }
}

效果:

/* 输入 */
.box {
  display: flex;
  user-select: none;
}

/* 输出(根据 browserslist) */
.box {
  display: -webkit-box;
  display: -ms-flexbox;
  display: flex;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
}
  1. @babel/preset-env(JavaScript 转译)

作用: 根据 browserslist 自动转译 JavaScript 语法和添加 polyfill

安装:

pnpm add -D @babel/core @babel/preset-env core-js

配置:

// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      useBuiltIns: 'usage',  // 按需引入 polyfill
      corejs: 3,             // core-js 版本
      modules: false         // 保留 ES Modules
    }]
  ]
}

效果:

// 输入
const value = obj?.property ?? 'default';
const arr = [1, 2, 3];
const last = arr.at(-1);

// 输出(如果目标浏览器不支持)
var _obj$property;
const value = (_obj$property = obj === null || obj === void 0 ? void 0 : obj.property) !== null && _obj$property !== void 0 ? _obj$property : 'default';
const arr = [1, 2, 3];
const last = arr[arr.length - 1]; // at() 被转译
  1. postcss-preset-env(CSS 新特性转译)

作用: 根据 browserslist 转译现代 CSS 特性

安装:

pnpm add -D postcss-preset-env

配置:

// postcss.config.js
module.exports = {
  plugins: {
    'postcss-preset-env': {
      stage: 3,  // 使用 stage 3 及以上的特性
      features: {
        'nesting-rules': true
      }
    }
  }
}
  1. ESLint 插件

作用: 根据 browserslist 检查代码中使用的 API 是否兼容

安装:

pnpm add -D eslint-plugin-compat

配置:

// .eslintrc.js
module.exports = {
  plugins: ['compat'],
  rules: {
    'compat/compat': 'error'
  }
}

使用方式详解

在 Vite 项目中使用

方案 1:配置 PostCSS(CSS 自动前缀)

// vite.config.ts
import { defineConfig } from 'vite';
import autoprefixer from 'autoprefixer';

export default defineConfig({
  css: {
    postcss: {
      plugins: [
        autoprefixer()
      ]
    }
  }
});
// package.json
{
  "browserslist": [
    "> 1%",
    "last 2 versions",
    "not dead"
  ]
}

方案 2:配置 Babel(JavaScript 转译)

// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [
    react({
      babel: {
        presets: [
          ['@babel/preset-env', {
            useBuiltIns: 'usage',
            corejs: 3
          }]
        ]
      }
    })
  ]
});

方案 3:使用 @vitejs/plugin-legacy(兼容旧浏览器)

pnpm add -D @vitejs/plugin-legacy terser
// vite.config.ts
import { defineConfig } from 'vite';
import legacy from '@vitejs/plugin-legacy';

export default defineConfig({
  plugins: [
    legacy({
      targets: ['defaults', 'not IE 11']
    })
  ]
});

效果:

  • 生成现代浏览器版本(ES2020+)
  • 生成旧浏览器版本(ES5 + polyfill)
  • 自动根据浏览器加载对应版本

在 Webpack 项目中使用

// webpack.config.js
module.exports = {
  module: {
    rules: [
      {
        test: /.js$/,
        use: {
          loader: 'babel-loader',
          options: {
            presets: [
              ['@babel/preset-env', {
                useBuiltIns: 'usage',
                corejs: 3
              }]
            ]
          }
        }
      },
      {
        test: /.css$/,
        use: [
          'style-loader',
          'css-loader',
          {
            loader: 'postcss-loader',
            options: {
              postcssOptions: {
                plugins: [
                  'autoprefixer'
                ]
              }
            }
          }
        ]
      }
    ]
  }
};

验证配置是否生效

  1. 查看目标浏览器列表

# 安装 browserslist CLI
pnpm add -D browserslist

# 查看当前配置对应的浏览器
npx browserslist

# 查看特定查询
npx browserslist "> 1%, last 2 versions"
  1. 检查构建产物

# 构建项目
pnpm build

# 检查 CSS 是否有浏览器前缀
grep -r "webkit" dist/assets/css/*.css

# 检查 JS 是否被转译
grep -r "??" dist/assets/js/*.js  # 如果找到,说明没有转译

实际案例

案例 1:现代 Web 应用(推荐配置)

{
  "name": "modern-web-app",
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead",
      "not op_mini all"
    ],
    "development": [
      "last 1 chrome version",
      "last 1 firefox version"
    ]
  },
  "devDependencies": {
    "autoprefixer": "^10.4.14",
    "postcss": "^8.4.24"
  }
}
// vite.config.ts
import { defineConfig } from 'vite';
import autoprefixer from 'autoprefixer';

export default defineConfig({
  css: {
    postcss: {
      plugins: [autoprefixer()]
    }
  }
});

结果:

  • Chrome 87+, Firefox 78+, Safari 14+, Edge 88+
  • 不需要过多转译,构建产物小
  • 开发环境构建快

案例 2:企业内部应用(精确控制)

{
  "browserslist": [
    "Chrome >= 90",
    "Firefox >= 88",
    "Safari >= 14",
    "Edge >= 90"
  ]
}

适用场景:

  • 企业内部系统
  • 可以要求用户使用特定浏览器
  • 追求最小构建产物

案例 3:兼容旧浏览器(最大兼容性)

{
  "browserslist": [
    "> 0.5%",
    "last 2 versions",
    "Firefox ESR",
    "not dead"
  ],
  "devDependencies": {
    "@babel/core": "^7.22.0",
    "@babel/preset-env": "^7.22.0",
    "core-js": "^3.31.0",
    "autoprefixer": "^10.4.14"
  }
}
// babel.config.js
module.exports = {
  presets: [
    ['@babel/preset-env', {
      useBuiltIns: 'usage',
      corejs: 3
    }]
  ]
};

结果:

  • 支持更多浏览器
  • 构建产物较大(包含 polyfill)
  • 构建时间较长

参考资料

官方文档

工具文档

在线工具

常见问题

Q1: browserslist 配置了但没有生效?

检查清单:

  1. ✅ 是否安装了相关依赖(autoprefixer、@babel/preset-env)
  2. ✅ 是否配置了 PostCSS 或 Babel
  3. ✅ 配置文件位置是否正确
  4. ✅ 构建产物是否真的没有变化

验证方法:

# 查看目标浏览器
npx browserslist

# 检查构建产物
grep -r "webkit" dist/**/*.css

Q2: 如何选择合适的 browserslist 配置?

决策树:

是否需要支持 IE 11?
├─ 是 → 使用 ["IE 11", "> 0.5%", "last 2 versions"]
└─ 否 → 是否是企业内部应用?
    ├─ 是 → 精确指定版本 ["Chrome >= 90", "Firefox >= 88"]
    └─ 否 → 使用默认配置 [">0.2%", "not dead", "not op_mini all"]

Q3: browserslist 会影响 Vite 的 build.target 吗?

答案:不会。

  • Vite 的 build.target 是独立配置
  • 如果需要 Vite 使用 browserslist,需要手动读取或使用插件
  • 推荐使用 @vitejs/plugin-legacy 来实现

Q4: 开发环境和生产环境应该用不同配置吗?

推荐:是的。

{
  "browserslist": {
    "production": [
      ">0.2%",
      "not dead"
    ],
    "development": [
      "last 1 chrome version"  // 开发环境只针对最新浏览器,加快构建
    ]
  }
}

总结

Browserslist 的价值

  1. 统一配置源 - 多个工具共享同一份浏览器兼容性配置

  2. 自动化处理 - 自动添加 CSS 前缀、转译 JS 语法

  3. 优化构建 - 根据目标浏览器生成最优代码

  4. 团队协作 - 明确项目的浏览器支持范围

使用建议

  • ✅ 在 package.json 中配置 browserslist
  • ✅ 安装并配置 autoprefixer(CSS 前缀)
  • ✅ 根据项目需求选择合适的浏览器范围
  • ✅ 定期更新配置,移除不再支持的旧浏览器
  • ✅ 使用 npx browserslist 验证配置

注意事项

  • ⚠️ browserslist 本身不做任何转译,需要配合工具使用
  • ⚠️ 配置过于宽松会导致构建产物过大
  • ⚠️ 配置过于严格可能导致部分用户无法访问
  • ⚠️ 定期检查构建产物,确保配置生效

Module 和 Provider 循环依赖怎么办?

作者 前端付豪
2026年1月28日 11:13

A 引入B B引入A

新建个项目看下

nest new circular-reference -p npm

image.png

nest g module aaa 
nest g module bbb

image.png

image.png

启动后报错

image.png

如何解决这个问题? 先单独创建这两个 Module,然后再让两者关联起来,使用 forwardRef

image.png

image.png

再次启动不会报错

image.png

原因就在于 nest 会单独创建两个 Module,之后再把 Module 的引用转发过去

除了 Module ,provider 和 Service 也会存在 循环引用


nest g service ccc --no-spec --flat 
nest g service ddd --no-spec --flat

并且互相引用下


import { Injectable } from '@nestjs/common';
import { DddService } from './ddd.service';

@Injectable()
export class CccService {
  constructor(private dddService: DddService) {}

  ccc() {
    return 'ccc';
  }

  eee() {
    return this.dddService.ddd() + 'eee';
  }
}


import { Injectable } from '@nestjs/common';
import { CccService } from './ccc.service';

@Injectable()
export class DddService {
  constructor(private cccService: CccService) {}

  ddd() {
    return this.cccService.ccc() + 'ddd';
  }
}

app.service.ts 使用

import { Injectable } from '@nestjs/common';
import { CccService } from './ccc.service';
import { DddService } from './ddd.service';

@Injectable()
export class AppService {
  constructor(
    private cccService: CccService,
    private dddService: DddService,
  ) {}

  getHello(): string {
    return this.dddService.ddd() + this.cccService.eee();
  }
}

image.png

使用 forwardRef 解决

image.png

image.png

再次启动服务就没问题了

image.png

数据工程新范式:NoETL 统一语义层破解跨境电商 ROI 统筹与数据孤岛难题

2026年1月28日 11:11

本文首发于 Aloudata 官方技术博客:《跨境电商 ROI 统筹难?NoETL 统一语义层破解亚马逊、Shopify 与广告数据孤岛》转载请注明出处。

摘要:跨境电商企业普遍面临亚马逊、Shopify、广告平台等多源数据孤岛问题,导致跨平台 ROI 计算不准、决策滞后。本文深入探讨传统ETL与物理宽表模式的局限性,并介绍如何通过 NoETL 指标平台构建统一语义层,实现业务逻辑与物理存储的解耦,从而自动化整合数据、保障指标口径一致,并实现秒级分析响应,为数据工程与敏捷分析提供新范式。

跨境电商的 ROI 统筹困境:三大痛点表现

跨境电商的日常运营是典型的多平台、高频次、强时效的“敏态”业务。企业普遍在亚马逊、Shopify/独立站、Google/Facebook/TikTok 广告平台等多条战线同时作战。然而,这种业务模式天然带来了数据割裂的顽疾,导致核心的 ROI(投资回报率)计算与统筹陷入困境。

  1. 数据割裂,全局洞察缺失

    • 平台壁垒:亚马逊的 A9 算法数据、Shopify 的店铺运营数据、各广告平台的投放与转化数据,分散在不同系统中。这些平台的 API 接口标准不一、数据格式各异,形成天然的技术壁垒。
    • 业务盲区:企业无法准确计算“全渠道 ROI”。例如,无法将 Facebook 广告的点击成本与最终在亚马逊产生的订单收入精准关联,导致营销预算分配如同“盲人摸象”,错失销售机会或造成资源浪费。
  2. 响应迟缓,错失市场时机

    • 冗长链路:传统模式下,从业务提出一个跨平台的 ROI 分析需求(如“对比 TikTok 和 Google Ads 对某新品在北美的引流效果”),到数据工程师排期、开发 ETL 脚本、物理打宽、测试上线,周期往往以“周”为单位。
    • 决策滞后:面对直播带货、节日大促等产生的“脉冲式”销售数据(可占订单总量 23% 以上),传统架构无法实现分钟级的策略调整,库存积压与断货风险并存,直接侵蚀利润。
  3. 口径混乱,信任危机凸显

    • 分散定义:为快速响应临时需求,不同分析师在不同 BI 工具或报表中自行定义“净利润”、“广告ROI”等指标,计算逻辑存在微小差异。
    • 报表打架:管理层常发现销售报表与财务报表中的同一核心指标数据对不上,IT 需要耗费大量时间排查口径差异。业务部门陷入“数据不好找、找了不敢用”的窘境,严重阻碍数据驱动文化的形成。

根因分析:传统“宽表模式”在敏态业务下的必然失效

上述痛点并非偶然,而是传统数据架构与跨境电商业务本质矛盾激化的必然结果。这一矛盾集中体现为 “数据分析的不可能三角”:业务追求极致灵活的分析,管理层要求绝对统一的口径,而工程团队需要在有限成本下保障查询性能。为了平衡,企业不得不依赖“人工预计算”的宽表模式,但这在敏态业务下已走向终结。

  1. 人工预计算的数学极限:试图通过预建物理宽表来应对 AI 智能体(Agent)或业务人员提出的发散性、非预设的分析需求(如“对比北美和欧洲市场,TikTok 与 Facebook 广告对 A 品类新客的 ROI 贡献”),物理表的数量将随维度组合呈指数级爆炸。这在工程和维护上是不可持续的穷举法。
  2. 逻辑与物理的紧耦合之殇:业务语义(如“有效订单”)被硬编码在 ETL 脚本和固化的物理宽表(DWS/ADS)中。任何业务口径的微调,都需要底层数据链路的重新开发、数据回刷和任务调度,变更成本高昂,且极易在多个宽表间产生不一致,形成沉重的“技术债务”。
  3. 人才与成本的双重压力:专业数据人才缺口巨大,而数据团队大量精力消耗在重复的宽表开发与运维中。同时,冗余的宽表加工导致企业湖仓数据平均冗余 5 倍以上,造成巨大的存储与计算资源浪费。

新范式解法:NoETL 统一语义层如何重构数据供应链

要根治数据孤岛,必须从架构层面进行范式重构。NoETL 语义编织的核心在于 将业务逻辑(逻辑定义)与物理存储和计算(物理执行)彻底解耦,在企业明细数据层(DWD)之上,构建一个统一、中立、智能的语义层。

对比维度 传统宽表模式 NoETL 语义编织模式
核心架构 ODS -> DWD -> DWS/ADS(物理宽表) -> BI ODS -> DWD -> 统一语义层(逻辑虚拟) -> BI/AI
开发方式 手动编写 ETL 脚本,物理打宽 声明式定义指标、维度与关联关系
灵活性 维度固定,新需求需重新开发宽表(响应以周计) 一个指标支持任意维度组合分析(响应以分钟计)
一致性 口径分散在不同宽表,易“打架” 一次定义,处处消费,口径 100% 一致
性能保障 依赖预计算的宽表,无法应对发散查询 基于声明式策略的智能物化加速,实现百亿明细秒级响应
总拥有成本 高(重复加工、冗余存储、人力密集) 低(架构简化、按需加速、自动化运维)

具体实现机制:

  1. 声明式定义,虚拟关联:数据工程师无需编写 JOIN 的 ETL 脚本,直接在平台界面声明“亚马逊订单表”与“Facebook 广告点击表”的逻辑关联关系。平台据此构建一个覆盖全域的 “虚拟业务事实网络” ,业务人员面对的是一个已逻辑关联的清晰数据视图,无需关心底层物理表结构。

  2. 自动化生产,智能加速:

    • 查询生成:当业务人员拖拽指标进行 ROI 分析时,平台语义引擎自动将操作翻译为高效、优化的 SQL。
    • 性能服务:管理员可声明式地指定需要加速的指标和维度组合(如“北美区广告 ROI”),平台智能物化引擎根据声明自动创建、运维物化视图(加速表),并在查询时实现透明的智能路由与 SQL 改写,在保障极致灵活性的同时,做到对业务透明的秒级响应。该引擎支持对去重计数、比率类等不可累加指标进行物化上卷。
  3. 统一服务,一次定义处处消费:通过标准化的 Restful API 和 JDBC 接口,将经过严格治理的指标(如“跨境综合 ROI”)同时提供给:

    • BI工具:如深度融合的 FineBI、Quick BI,或通过 JDBC 对接的其他 BI 工具。
    • 业务系统:CRM、ERP 等。
    • AI数据分析助手(Agent):提供结构化的语义 API。
    • 办公软件:通过专用插件在 WPS 表格中直接调用。
      确保全公司消费同一份“数字真理”。

四步实践路径:从数据孤岛到敏捷洞察

引入 NoETL 新范式并非一场“推倒重来”的革命,而应采用渐进式策略,平滑演进,价值驱动。

  1. 存量挂载(统一出口):将现有稳定、性能尚可的物理宽表快速接入平台,映射为逻辑视图。价值:零开发成本,迅速建立统一的指标服务出口,解决取数混乱的燃眉之急,保护历史投资。
  2. 增量原生(敏捷响应):所有新产生的分析需求,尤其是跨平台 ROI 归因等复杂场景,直接基于 DWD 明细数据在语义层进行声明式定义,由平台自动化生产。价值:实现 T+0 敏捷响应,从源头遏制新债产生,验证平台价值。
  3. 存量替旧(降本增效):识别并逐步下线那些高耗能、难维护、逻辑变更频繁的“包袱型”旧宽表 ETL 任务,用语义层模型替代。价值:释放昂贵的计算与存储资源,降低总拥有成本(TCO),将“死逻辑”盘活。
  4. 生态融合(深化价值):将语义层指标服务通过 API 广泛赋能给 BI 报表、业务运营系统及 AI 应用,构建企业级数据中枢。价值:培育数据驱动文化,实现数据价值的最大化。

案例验证:NoETL 如何驱动跨境电商与零售巨头提效

NoETL 范式并非理论空想,已在金融、零售等复杂数据场景的头部企业中得到成功验证,其解决数据整合与敏捷分析问题的能力具有普适性。

  • 某头部券商:基于 Aloudata CAN 构建指标“管研用”一体化体系,替代传统 ETL 开发,实现开发提效 50%,分析提速 10 倍,指标口径 100% 一致,为智能决策奠定了坚实的可信数据底座。
  • 麦当劳中国:构建“管研用”一体的 NoETL 指标中台,沉淀上千个标准指标,统一 API 服务覆盖 30+ 业务场景,日均支撑百万级 API 调用,驱动全域数字化运营,并为 AI 应用提供就绪的数据底座。
  • 普遍价值:据众多案例验证,实施 NoETL 指标平台可将指标上线周期从数周缩短到小时,跨部门数据争议率降低 90% 以上,从技术层面保障了战略目标的统一拆解与高效执行。

行动建议:启动你的数据架构升级

面对数据孤岛和 ROI 统筹难题,观望和修补已无法应对未来的竞争。企业应主动评估并引入 NoETL 新范式,选择一个真正具备核心能力的指标平台作为转型基座。

  1. 明确评估维度:在选型 POC 中,重点考察平台是否具备:

    • 基于明细数据的“虚拟宽表”构建能力(能否声明逻辑关联,拒绝物理打宽)。
    • 复杂指标的表达力(是否支持跨表聚合、二次聚合、动态维度筛选等)。
    • 声明式智能物化加速机制(是否基于管理员声明自动运维加速,而非全自动或全手动)。
    • 标准的开放接口(JDBC/API)和生态融合能力。
  2. 启动灯塔项目:选择一条业务价值清晰、痛点明确的业务线(如 “北美市场全渠道广告效果分析” )作为试点。聚焦于解决跨平台数据整合与实时 ROI 分析的具体问题,快速验证平台能力与业务价值。

  3. 规划渐进路线:采用上述 “四步实践路径” ,从统一数据出口开始,逐步实现新需求的敏捷响应和旧债务的清理,最终构建企业级智能数据基座,从容应对 AI 时代的挑战。

FAQ

Q1: NoETL 和传统 ETL 最大的区别是什么?

传统 ETL 需要数据工程师手动编写脚本,将数据加工成固化的物理宽表,业务分析被限制在预建的维度组合内。NoETL 通过统一语义层,将业务逻辑(指标、维度、关联)与物理存储解耦。业务人员在语义层通过声明式、界面化的方式定义分析需求,由平台自动生成最优查询并利用智能物化加速保障性能,实现了从“人工铺路”到“系统自动驾驶”的转变。

Q2: NoETL 如何保证跨平台数据整合时的查询性能?

NoETL 并非取消所有计算,而是通过智能物化引擎将预计算升级为一种自动化性能服务。平台会根据管理员声明的加速策略,自动创建并运维最优的物化视图。当用户进行复杂 ROI 分析时,查询会被自动、透明地路由到最合适的物化结果上,从而实现对十亿级明细数据的秒级响应,同时避免人工管理物化视图的复杂度和浪费。

Q3: 引入 NoETL 指标平台,对我们现有的数据仓库和 BI 工具有何影响?

NoETL 平台设计为中立、开放的基座,旨在增强而非取代现有投资。它可以无缝对接企业已有的数据湖/仓(直接读取 DWD 层),并通过标准 API/JDBC 接口与各类 BI 工具以及业务系统集成。平台成为统一的指标定义、计算和服务出口,下游 BI 工具回归为纯粹的“可视化渲染引擎”,从而打破厂商锁定,实现“一个指标,处处消费”。

Q4: NoETL 如何支持 AI 数据分析助手(Agent)?

NoETL 统一语义层为 AI 提供了结构化的、无歧义的“业务语言”和“工具”。AI Agent 不再需要直接面对复杂的物理表生成易错的 SQL,而是通过调用语义层的标准 API,传入指标、维度等参数,由平台负责精确计算并返回结果。这从根本上消除了 AI 的数据幻觉,并使其能够基于确定性的指标进行深度归因与洞察。

Key Takeaways(核心要点)

  1. 架构解耦是根本:跨境电商的 ROI 统筹难题,根源于传统“宽表模式”下业务逻辑与物理实现的紧耦合。NoETL 通过构建统一语义层,实现彻底解耦,是治本之策。
  2. 声明式驱动自动化:NoETL 的核心不是取消计算,而是通过 “声明式策略” 驱动智能物化加速与查询生成,在保障百亿数据秒级响应的同时,赋予业务前所未有的分析灵活性。
  3. 统一口径释放价值:通过 “一次定义,处处消费” 的标准化指标服务,NoETL 平台能终结数据口径混乱,建立公司级“数字真理”,为精准决策和 AI 应用提供可信底座,真正释放数据生产力。

本文首发于 Aloudata 官方技术博客,查看更多技术细节与高清图表,请访问原文链接:ai.noetl.cn/knowledge-b…

HTTP-协议溯源:深度解析 HTTP/3.0 与 QUIC 协议的底层革命

2026年1月28日 10:56

前言

HTTP/2 开启了多路复用的时代,但它依然跑在 TCP 这条“老旧”的铁轨上。只要 TCP 的设计初衷(顺序传输、字节流可靠性)不改变,队头阻塞(HOL Blocking) 的幽灵就永远无法彻底散去。于是,Google 另辟蹊径,基于 UDP 打造了 QUIC,并最终成为了 HTTP/3 的核心。

一、 HTTP/2 的遗憾:TCP 层的队头阻塞

虽然 HTTP/2 过多路复用解决了 HTTP 在应用层 的队头阻塞,使应用层发生的请求不再需要排队等候,解决了 HTTP/1.1 的队头阻塞。,但由于它底层依然依赖 TCP,产生了一个新的瓶颈

  • 传输层队头阻塞问题 :HTTP/2 建立在 TCP 之上。TCP 要求字节必须按顺序交付。如果网络发生丢包,TCP 无法分辨哪些字节属于哪个请求,只能暂停整个连接等待重传。

  • 弱网环境下的尴尬:在丢包率较高的移动网络下,HTTP/2 的表现有时甚至不如开启 6 个连接的 HTTP/1.1,因为“一荣俱荣,一损俱损”。

  1. 连接建立的开销:TCP 三次握手 + TLS 握手,在弱网环境下 RTT(往返时延)非常高。

image.png

image.png


二、 HTTP/3 的逆袭:基于 UDP 的 QUIC 协议

由于 TCP 协议深植于操作系统内核和路由器固件中,修改极其困难。HTTP/3 索性“推倒重来”,在 UDP 之上实现了可靠传输,这便是 QUIC(Quick UDP Internet Connections)

1. QUIC 的四大核心魔法

① 彻底解决队头阻塞

QUIC 在传输层实现了多路复用。每个流(Stream)之间是相互独立的。

  • 效果:如果 Stream A 丢包,只会阻塞 Stream A 的重组,Stream B 和 Stream C 可以继续不受影响地传输数据

② 极速握手(0-RTT / 1-RTT)

TCP 需要先建立连接再建立加密通道。

  • 效果:QUIC 将传输握手加密握手合并。初次连接只需 1 个 RTT,再次连接甚至可以实现 0-RTT 极速启动。

③ 连接迁移 (Connection Migration)

TCP 依靠“四元组”(源IP、源端口、目标IP、目标端口)识别连接。

  • 痛点:当你从 Wi-Fi 切换到 4G,IP 变了,TCP 连接必断。
  • 改进:QUIC 使用 Connection ID 识别连接。即便 IP 切换,只要 ID 不变,连接就能无缝延续。

④ 改进的头部压缩:QPACK

HTTP/2 使用的 HPACK 在乱序传输时会产生队头阻塞,HTTP/3 升级为 QPACK,专门优化了多流并发下的压缩效率。


三、 总结:从“排队”到“并发”

特性 HTTP/2 (TCP) HTTP/3 (UDP/QUIC)
传输层阻塞 存在 TCP 队头阻塞 不存在(流与流独立)
握手时延 2~3 RTT (TCP+TLS) 0~1 RTT
网络切换 连接断开,需重新握手 无缝迁移
可靠性 操作系统内核实现 QUIC 在应用层实现

四、 HTTP3.0现状

目前HTTP3.0并没有普及,你可以随便打开网站查看请求相应的HTTP版本,会发现大多数请求都是HTTP2.0的!

❌
❌