阅读视图

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

# 深度解析 ECharts:从零到一构建企业级数据可视化看板

在数据驱动决策的时代,数据可视化(Data Visualization)已成为连接原始数据与商业洞察的桥梁。它能将复杂的数字转化为直观的图表,帮助老板、客户和决策者快速理解业务状况、发现趋势与异常。在众多可视化库中,ECharts 凭借其强大的功能、丰富的图表类型和出色的交互性,成为国内企业级报表和数据看板的首选工具。本文将深入剖析 ECharts 的核心机制、TypeScript 集成原理,并完整演示其使用流程。


一、ECharts:企业级数据可视化的“瑞士军刀”

1. 什么是 ECharts?

ECharts(Enterprise Charts)是由百度开源的一款纯 JavaScript 的可视化图表库。它专注于:

  • 2D 图表:柱状图、折线图、饼图、散点图、雷达图、地图、热力图、关系图等。
  • 高性能渲染:基于 Canvas 或 SVG,支持海量数据的流畅渲染。
  • 高度可定制:提供数千个配置项,满足复杂报表需求。
  • 交互性强:支持缩放、拖拽、数据筛选、图例切换、数据下钻等。
  • 跨平台:可在 PC、移动端、小程序中使用。

对比:若需 3D 可视化(如三维地球、立体建筑),则需使用 Three.js 等 WebGL 库。ECharts 专注于 2D 领域,是“2D 数据可视化”的标杆。

2. 为什么选择 ECharts?

  • 老板/客户视角:生成直观、美观、专业的报表,提升汇报说服力。
  • 开发者视角:API 设计清晰,文档详尽,社区活跃,问题易解决。
  • 企业视角:开源免费,可私有化部署,安全性高。

二、TypeScript 深度集成:@types/echarts 的奥秘

现代前端开发广泛使用 TypeScript(TS)以提升代码健壮性和开发体验。ECharts 原生是用 JavaScript 编写的,因此其类型声明是独立维护的。

1. 为什么需要 @types/echarts

  • 原生 JS 库:ECharts 的核心库 echarts.js 文件,不包含类型信息。
  • 类型声明分离:社区通过 @types/echarts 包提供了完整的 TypeScript 类型定义(.d.ts 文件)。
  • 安装方式
    npm install echarts
    npm install --save-dev @types/echarts # 开发依赖
    

2. 为什么 react 不需要单独安装类型声明?

这是一个关键对比,揭示了不同项目的类型管理策略:

项目 类型声明方式 原因
ECharts @types/echarts (分离) 原生是 JS 项目,类型声明由 DefinitelyTyped 社区维护。
React 内置 (@types/react 已包含) React 核心库本身就是用 TypeScript 编写的,其源码中直接包含 .ts.tsx 文件和类型定义。当你安装 react 时,类型信息已随包一同下载。

结论:一个库是否需要独立的 @types/xxx 包,取决于它是否原生支持 TypeScript。原生 TS 项目(如 React, Vue 3, Redux Toolkit)通常内置类型;JS 项目则依赖社区维护的类型声明。


三、ECharts 核心使用流程:四步构建图表

使用 ECharts 绘制图表遵循一个清晰的流程:安装 -> 实例化 -> 配置 -> 渲染

1. 安装依赖

npm install echarts
npm install --save-dev @types/echarts

2. 创建图表容器(DOM 挂载点)

在 React 组件中,需要一个 div 元素作为 ECharts 的画布。使用 useRef 获取其 DOM 引用。

import React, { useRef, useEffect } from 'react';
import * as echarts from 'echarts';

const ChartComponent = () => {
  // 创建 ref,类型为 HTMLDivElement | null
  const chartRef = useRef<HTMLDivElement>(null);
  // chartRef.current 是联合类型:null | HTMLDivElement
  // 初始为 null,挂载后指向 div 元素

  useEffect(() => {
    // DOM 操作逻辑
  }, []);

  return (
    <div>
      <h2>销售数据统计</h2>
      {/* 图表将渲染在此 div 中 */}
      <div 
        ref={chartRef} 
        style={{ width: '600px', height: '400px' }}
      />
    </div>
  );
};

export default ChartComponent;

3. 实例化 ECharts(echarts.init

useEffect 中,当 DOM 就绪后,调用 echarts.init 创建图表实例。

useEffect(() => {
  // 1. 检查 ref 是否已挂载
  if (!chartRef.current) return;

  // 2. 实例化 ECharts
  // 将 chartRef.current (HTMLDivElement) 作为挂载点
  const myChart = echarts.init(chartRef.current);

  // 后续配置和事件绑定...

  // 4. 清理:组件卸载时销毁图表实例,防止内存泄漏
  return () => {
    myChart.dispose(); // 释放资源
  };
}, []);

4. 配置与渲染(setOption

这是最核心的一步,通过 setOption 方法传入一个配置项对象option),定义图表的外观和数据。

useEffect(() => {
  if (!chartRef.current) return;
  const myChart = echarts.init(chartRef.current);

  // 定义图表配置项
  const option = {
    // 标题
    title: {
      text: '月度销售额',
      left: 'center'
    },
    // 工具提示(鼠标悬停)
    tooltip: {
      trigger: 'axis' // 轴触发
    },
    // 图例(Legend)
    legend: {
      data: ['销售额']
    },
    // X 轴
    xAxis: {
      type: 'category',
      data: ['1月', '2月', '3月', '4月', '5月', '6月']
    },
    // Y 轴
    yAxis: {
      type: 'value',
      name: '金额 (万元)'
    },
    // **核心:数据系列 (Series)**
    series: [
      {
        name: '销售额',
        type: 'bar', // 图表类型:柱状图
        data: [120, 132, 101, 134, 90, 230], // 实际数据
        // 可进一步配置柱子颜色、宽度等
        itemStyle: {
          color: '#5470C6'
        }
      }
      // 可添加更多 series,如折线图叠加
    ]
  };

  // 3. 应用配置,渲染图表
  myChart.setOption(option);

  // 清理
  return () => {
    myChart.dispose();
  };
}, []);

四、深度解析 setOptionseries

setOption 是 ECharts 的“大脑”,它接收一个庞大的 JSON 配置对象。其中,series(系列)是数据的载体,决定了图表的类型和内容。

series 的关键属性

属性 说明
name 系列名称,用于图例和提示框。
type 图表类型'line'(折线), 'bar'(柱状), 'pie'(饼图), 'scatter'(散点)等。
data 核心数据数组。格式取决于图表类型:
- 柱状/折线:[120, 132, ...][{name: '1月', value: 120}, ...]
- 饼图:[{name: 'A', value: 30}, {name: 'B', value: 70}]
encode (高级) 定义数据到坐标轴的映射。
itemStyle 定义数据项的样式(颜色、边框等)。
label 定义数据标签(是否显示、位置、格式)。

示例:饼图

series: [
  {
    name: '市场份额',
    type: 'pie',
    data: [
      { value: 40, name: '品牌A' },
      { value: 30, name: '品牌B' },
      { value: 20, name: '品牌C' },
      { value: 10, name: '其他' }
    ],
    label: {
      formatter: '{b}: {d}%' // 显示名称和百分比
    }
  }
]

五、最佳实践与注意事项

  1. 响应式:监听窗口大小变化,调用 myChart.resize()
  2. 性能:大数据量时启用 progressive 渐进渲染。
  3. 主题:使用 echarts.registerTheme 定义公司主题色。
  4. 错误处理:检查 init 是否成功,setOption 是否报错。
  5. 内存管理:务必在组件卸载时调用 dispose()

六、总结

ECharts 是企业级数据可视化的强大工具。通过:

  1. 安装 echarts@types/echarts
  2. 使用 useRef 获取 DOM 挂载点
  3. 调用 echarts.init 实例化
  4. 通过 setOption 配置包含 series选项对象

你就能将枯燥的数据转化为洞察力十足的图表。理解其与 TypeScript 的集成方式(分离声明 vs. 内置类型),以及 series 作为数据核心的概念,是高效使用 ECharts 的关键。结合 React 的函数组件和 useEffect,可以构建出动态、可复用的可视化组件,为您的数据产品赋能。

TailWind CSS

tailwindcss

官方链接tailwind.nodejs.cn/docs/instal…

为了更好的使用TailWindCss可以安装TailWind Css IntelliSense插件

使用 Tailwind CSS 实现高效原子化样式开发

Tailwind CSS 是一个功能优先的 CSS 框架,它通过提供低级别的实用类来帮助开发者快速构建自定义设计。本文将介绍如何安装、配置和使用 Tailwind CSS,特别适合低代码平台开发需求。

什么是 Tailwind CSS?

Tailwind CSS 采用"原子化 CSS"方法,提供了大量小型、单一目的的类,可以直接在 HTML 中组合使用。与传统的 CSS 框架(如 Bootstrap)不同,Tailwind 不会提供预定义的组件,而是提供构建块让开发者创建完全自定义的设计。

原子化 CSS 的优势

  • 更小的 CSS 文件大小:只生成实际使用的样式
  • 更快的开发速度:无需在 HTML 和 CSS 文件之间切换
  • 一致的设计系统:使用预定义的设计标记保持一致性
  • 无命名困难:不再为类名苦恼

安装与配置

1. 安装依赖

首先,使用 npm 安装 Tailwind CSS 及其依赖:

npm install tailwindcss postcss autoprefixer

2. 初始化配置文件

运行以下命令生成配置文件:

npx tailwindcss init -p

这将创建两个文件:

  • tailwind.config.js - Tailwind CSS 配置文件
  • postcss.config.js - PostCSS 配置文件

3. 配置内容路径

tailwind.config.js 中,指定模板文件路径:

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

4. 引入 Tailwind 指令

在您的 CSS 文件(通常是 src/index.csssrc/styles.css)中添加:

@tailwind base;
@tailwind components;
@tailwind utilities;

开发工具增强

为了获得更好的开发体验,建议安装 Tailwind CSS IntelliSense 插件(适用于 VS Code)。该插件提供:

  • 自动完成建议:输入时显示可用的 Tailwind 类
  • 语法高亮:使 Tailwind 类在代码中更易识别
  • 悬停预览:查看类对应的实际 CSS 规则
  • linting 功能:检测不存在的 Tailwind 类

实际应用示例

下面是一个使用 Tailwind CSS 构建的简单卡片组件:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Tailwind CSS 示例</title>
  <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-100 min-h-screen flex items-center justify-center p-4">
  <div class="max-w-md w-full bg-white rounded-xl shadow-md overflow-hidden">
    <div class="h-48 bg-[url('https://images.unsplash.com/photo-1550745165-9bc0b252726f?ixlib=rb-4.0.3')] bg-cover bg-center"></div>
    <div class="p-8">
      <div class="uppercase tracking-wide text-sm text-indigo-500 font-semibold">案例研究</div>
      <h2 class="mt-2 text-xl font-bold text-gray-900">使用 Tailwind CSS 提高开发效率</h2>
      <p class="mt-2 text-gray-600">Tailwind CSS 是一个功能类优先的 CSS 框架,它使您能够快速构建自定义用户界面。</p>
      <div class="mt-4 flex items-center">
        <img class="h-10 w-10 rounded-full" src="https://images.unsplash.com/photo-1580489944761-15a19d654956?ixlib=rb-4.0.3" alt="作者头像">
        <div class="ml-3">
          <p class="text-sm font-medium text-gray-900">张三</p>
          <p class="text-sm text-gray-500">前端开发工程师</p>
        </div>
      </div>
      <button class="mt-6 px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
        了解更多
      </button>
    </div>
  </div>
</body>
</html>

在低代码平台中的应用

对于低代码平台,Tailwind CSS 特别有价值:

  1. 可视化构建:平台可以通过拖拽组件并自动生成相应的 Tailwind 类
  2. 样式定制:提供直观的样式面板,映射到 Tailwind 的实用类
  3. 一致性保证:使用 Tailwind 的设计标记系统保持整体一致性
  4. 性能优化:通过 PurgeCSS 仅保留使用的类,减少最终文件大小

总结

Tailwind CSS 通过其原子化的方法,为开发者提供了极大的灵活性和控制力,同时保持了开发效率。无论是传统开发还是低代码平台,它都能显著改善样式开发体验。通过合理的配置和工具增强,可以充分利用这个强大框架的优势。

开始使用 Tailwind CSS,享受更高效、更一致的样式开发过程吧!

TypeScript 的“读心术”:让类型在代码中“流动”起来

一、什么是流动的类型?

从一个已知的变量或者对象通过typeof或者keyof获得自己的类型,类型推导的进阶版本。

二、流动类型的实现

2.1 捕获变量的类型typeof

typeof获取变量或者对象中的某个key的类型,用于定义新变量的类型或者常量字符串

let foo = 123;
let bar: typeof foo; // 'bar' 类型与 'foo' 类型相同(在这里是: 'number')

bar = 456; // ok
bar = '789'; // Error: 'string' 不能分配给 'number' 类型
const c_foo = 123;
let c_bar: typeof c_foo;
c_bar = 123; // ok
c_bar = 456; // Error: 不能将类型“456”分配给类型“123”

2.2 捕获键的名称keyof

keyoftypeof结合使用,能让你捕获对象的的key的常量字符串。

enum colors {
    RED = 'red',
    BLUE = 'blue',
}

type Color = keyof typeof colors;

let color: Color;
color = 'RED';
color = 'BLUE';
// color = 'anythingElse'; // Error:不能将类型“"anythingElse"”分配给类型“"RED" | "BLUE"”

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript/TypeScript开发干货

毕业一年了,分享一下我的四个开源项目!😊😊😊

毕业一年多了,好像也一点没有改变,依然是有在坚持开源,依然有在坚持发掘金,目前掘金也快有五千关注了:

20250825200428

一路走来确实不易,在掘金这个平台上应该有一些机遇的成分,平台也有推荐吧,目前来看流量还是挺不错的,发的这些文章流量也还可以。

说到社群,公众号这边的流量也是挺不错的:

00c9f7667600e47fe900332174448c03

如果觉得可以的可以关注一下~~~

接下来就来分享一下我的四个开源项目吧:

20250825200844

主要是前面四个,你可能会看到下面的提交记录这么猛,你别慌,因为我写文章有用 GitHub 做图床的,所以没上传一张图片都算一次提交的。

前端脚手架

在我刚开始学习 React 的时候,使用的是 create-react-app 这个官方脚手架来创建和启动项目。但在实际开发中,当我需要扩展或自定义 Webpack 配置时,就不得不通过执行 eject 操作将内置配置完全暴露出来。然而,eject 是不可逆的,一旦执行就无法再享受脚手架带来的版本升级和优化。

为了避免 eject,我也尝试过使用一些第三方工具,例如 react-app-rewired 和 customize-cra,通过它们可以在不执行 eject 的情况下修改配置。但问题在于,这些工具与不同版本的 create-react-app 或 React 之间存在兼容性问题。尤其是 customize-cra,在不同 Node 或 React 版本中常常出现兼容性错误,让我耗费了大量时间去排查和解决。

正是这些折腾的经历,让我萌生了自己实现一个更灵活、更可控的脚手架工具的想法。

20250825201928

目前采用的方案就是在 create-react-app 的基础上支持直接读取项目根目录的 Webpack 配置,如果要支持 Vue 的方式,那么我完全可以像 Vite 那样,抽离 Vue 和 React 公共的 Webpack 配置通过插件的方式来引入,这种通过插件的方式来扩展不同的框架。

例如这些配置与你是用 Vue、React 还是其他库无关,是构建一个现代前端应用所共有的:

  1. 入口 (Entry)

  2. 输出 (Output)

  3. 模块处理 (Module - Rules for non-JS)

  4. 插件:很多插件是框架无关的,例如 HtmlWebpackPlugin、MiniCssExtractPlugin、DefinePlugin、CopyWebpackPlugin、ESLintWebpackPlugin / ForkTsCheckerWebpackPlugin 等

  5. 开发服务器 (DevServer)

等等

对于另外一些和框架紧密相关的,我们就需要来单独处理并由各自的插件来提供和覆盖公共配置:

  1. 热更新 (HMR):虽然 devServer.hot = true 是公共的,但针对不同框架的 HMR 支持可能需要特定的 loaderbabel 插件(如 react-refresh 所需的 react-refresh/babelReactRefreshWebpackPlugin)。这部分应由框架插件来配置。

如果你对这个项目感兴趣,可以查看 Github 地址。

如果你想学习整个项目是如何设计并最终如何发版的,你可以参与我写的这个课程:

20250825203118

在线代码编辑器

在线代码编辑器主要是因为之前想到找实习,只有一个脚手架的项目还不够用,于是便有了写这个在线代码编辑器的想法。

这个项目一开始使用的是 React 的,后面又用了 NextJs 进行重构了一遍,那会也想着 NextJS 会对远程比较友好,但是事实证明也确实如此。结合社群的上的一些成员来一起花了两三个月完成了一个最终的版本了。

接下来我就和大家分享一下他的一些功能,以便大家对这个项目有一个全面的了解:

首页

20241029221609

20241029221634

首页的话就一个流星的动画,加上后面的内容,整体还算协调。

控制面板

点击控制面板之后会进入到 dashboard,如果没有登录的话会跳转到登录页面:

20241029221905

这里不需要额外注册,直接获取验证码,没有账号的话会直接注册一个新的。

20241029222039

进入到控制面板,这里我们可以选择创建项目或者创建一个协同文档:

20241029222128

在这里提供了多种不同的框架来进行初始化,除了使用原有的模板之外,我们也可以直接导入本地的代码来进行开发和编辑:

20241029222441

这里有个头像还挺好看的,我很喜欢:

20241029222526

代码编辑

点击创建之后我们会进入到这样的一个页面,首先左边是一个文件栏,整体布局跟 vscode 一样,下面的是控制台,在这里我们可以直接执行 npm 和 pnpm 的一些命令,还有一些 NodeJs 的命令,

20241029222747

现在我们就是给这个项目执行 pnpm 来安装了相关的依赖包,并执行了 pnpm dev 来把这些项目启动了起来:

20241029223422

文件搜索

除了文件树之外,我们还提供了跟 Vscode 差不多的功能,文件搜索:

20241029223613

还可以分屏编写:

20241029223745

切换编辑器主题

在这里我们还可以切换编辑器的主题,在这里提供了多个主题可以选择:

20241029224822

协同编辑

这回我们要回到了我们的核心功能:协同编辑上了,首先要到 dashboard 控制面板上创建一个文档:

20241029225117

创建完成之后你会看到这样的效果:

20241029225214

点击分享文档,可以分享该文档给其他的朋友来一起编辑:

20241029225453

最终协同编辑的效果:

output.gif

协同如何实现的?

关于协同编辑这块,就来分享一下这前后端所涉及到的技术栈吧

  1. 前端

    1. y-monaco: 将 Yjs 的实时协作功能与 Monaco Editor 集成,提供了默认的协同编辑数据同步与协同 ui 效果.

    2. y-websocket: Yjs 的 WebSocket 适配器,提供实时数据同步功能,允许多个客户端通过 WebSocket 进行协作编辑。

    3. yjs: 高性能的 CRDT 框架,支持实时协作和离线编辑,通过共享类型自动合并变更处理冲突,适用于大型文档和无限用户的场景。

    4. perfect-cursors: 提供平滑的鼠标移动效果.

  2. 后端

    1. y-websocket:yjs 封装了协同逻辑

    2. y-mongodb-provider:持久化存储

可以帮你润色成几种不同风格,你看喜欢哪种:

项目开源在 GitHub,如果觉得有用,欢迎 Star 收藏。

AI 开发工具

create-ai-toolkit 是一个由人工智能驱动的前端开发工具,旨在通过自动化和智能化的方式显著提升开发效率。该工具集成了组件和 hooks 生成、提交信息自动化以及代码审查等功能,帮助开发者在保持高代码质量的同时,简化和加速开发流程。

这个项目在之前 AI 编辑器还没有出现的时候如果愿意用 key 还是能有点用的,但是现在就根本没啥用了。

之前有朋友说到,你那会都在写这些编辑器了,结合一下做一个这样的编辑器出来说不定就发了。

目前根据 git 的暂存区的修改来做生成 commit 信息,目前应该 cursor 这类的编辑器都能实现了,目前这项目都没啥用了。

协同文档 DocFlow

目前主要在推的是 DocFlow,它的一个基于 Tiptap 和 Next.js 构建的现代化协同文档编辑器,集成了丰富的编辑能力与多人实时协作功能,支持插件扩展、主题切换与持久化存储。适合团队写作、教育笔记、在线文档平台等场景。

这里我现在就不再做介绍了,晚点我会写一篇文章详细介绍。目前这些技术栈都是采用最新的,包括 Tiptap 都是使用 3 版本的:

技术 说明
Next.js 构建基础框架,支持 SSR / SSG
Tiptap 富文本编辑器,基于 ProseMirror
Yjs 协同编辑核心,CRDT 数据结构
@hocuspocus Yjs 的服务端与客户端 Provider
React 19 UI 框架,支持 Suspense 等新特性
Tailwind CSS 原子化 CSS,集成动画、表单样式等
Socket.io 协同通信通道
Prettier/ESLint 代码风格统一
Vitest/Playwright 单元测试与端到端测试支持

下面是一些页面的截图:

20250825204718

20250825204754

20250825204816

具体可以去 DocFlow 里面去体验,目前线上只支持邮箱登录,为了方便我开发,Github 登录目前只实现了本地的 URL 跳转,如果你在线上使用 Github 登录,你会看到它跳转的路径是跳转到本地的。

这个项目目前是在准备接入组织的功能,跟飞书那样,能聊天什么之类的,再下一个计划是接入 RAG。

如果你对这个项目感兴趣,欢迎点个 ⭐ 支持 👉 GitHub 地址。如果你想参与贡献,也非常欢迎联系我。

总结

开源最大的好处就是它为所有人提供了一个公平的舞台,不论学历高低,只要你愿意投入时间和精力,就能通过代码贡献获得认可。很多时候,一份真实可见的开源作品,比一纸学历更能体现一个人的实力和价值。参与开源还能让你快速学习,接触到行业里最前沿的技术和最佳实践。你写的每一次提交、修复的每一个 bug、参与的每一次讨论,都会成为你成长的印记。与此同时,你还能结识来自全球的开发者,拓展人脉,积累经验。对于学历不突出的人来说,开源是一条非常好的“逆袭”路径,它能让你的能力被真实地看到。很多公司在招聘时也很看重开源经历,它甚至可能直接帮你打开职场的第一扇门。

例如我之前发的沸点:

20250825210756

如果你也对开源感兴趣,欢迎加我微信 yunmz777,一起进群交流、学习和成长。期待在开源的道路上和你并肩前行!

七夕快到了,看看Trae老师怎么实现浪漫的烟花

前言

时间过得真快,又是一年七夕情人节,想不想给女朋友一个浪漫的惊喜呢?Trae老师为你打造了一个炫酷的烟花特效,让你的情人节更加浪漫!

先向Trae提问,让Trae帮我们打造一个能打动女孩子的炫酷烟花~

image.png

哦豁还有三个按钮,Trae还挺有浪漫的艺术细菌嘛,我们来看看Trae老师给我们带来啥惊喜 image.png

第一版的效果

自动随机烟花,浪漫啊,Trae老师

image.png 心形烟花,还是Trae老师会玩

image.png 玫瑰烟花效果也

image.png

Trae 核心代码解读

1. 烟花系统架构

// 烟花系统核心类
class Fireworks {
    constructor() {
        this.canvas = document.getElementById('fireworks');
        this.ctx = this.canvas.getContext('2d');
        this.particles = [];    // 粒子数组
        this.fireworks = [];    // 烟花数组
        this.autoMode = false;  // 自动模式
    }
}

这些变量用于构建整个烟花系统:

  • canvas:绘制烟花的画布
  • particles:存储爆炸后的粒子效果
  • fireworks:存储上升的烟花火箭
  • autoMode:控制是否自动发射烟花

2. 烟花发射逻辑

// 创建烟花火箭
createFirework(x, y) {
    const colors = [
        '#ff6b6b', '#ff8e8e', '#ffa500', '#ff69b4', 
        '#ff1493', '#ff00ff', '#da70d6', '#ee82ee'
    ];
    
    const firework = {
        x: x || Math.random() * this.canvas.width,
        y: this.canvas.height,
        targetX: x || Math.random() * this.canvas.width,
        targetY: Math.random() * this.canvas.height * 0.5,
        speed: 5 + Math.random() * 5,
        color: colors[Math.floor(Math.random() * colors.length)]
    };
    
    this.fireworks.push(firework);
}

这个函数负责创建烟花火箭:

  • 随机选择浪漫的粉色系颜色
  • 计算从底部到目标位置的轨迹
  • 设置随机的上升速度
  • 将烟花添加到待发射列表

3. 心形烟花算法

// 心形烟花数学公式
createHeartFirework() {
    const centerX = this.canvas.width / 2;
    const centerY = this.canvas.height / 2;
    
    for (let i = 0; i < 20; i++) {
        const angle = (i / 20) * Math.PI * 2;
        // 心形参数方程
        const heartX = centerX + 16 * Math.pow(Math.sin(angle), 3) * 10;
        const heartY = centerY - (13 * Math.cos(angle) - 5 * Math.cos(2 * angle) - 2 * Math.cos(3 * angle) - Math.cos(4 * angle)) * 10;
        
        this.createSpecialFirework(heartX, heartY, '#ff1744');
    }
}

这个函数实现了浪漫的心形烟花:

  • 使用心形参数方程计算坐标
  • 在屏幕中央形成完美的爱心图案
  • 延迟发射营造心跳效果

4. 粒子爆炸效果

// 烟花爆炸生成粒子
explode(firework) {
    const particleCount = firework.special ? 50 : 30;
    
    for (let i = 0; i < particleCount; i++) {
        const angle = (Math.PI * 2 * i) / particleCount;
        const velocity = 2 + Math.random() * 3;
        
        this.particles.push({
            x: firework.x,
            y: firework.y,
            vx: Math.cos(angle) * velocity,
            vy: Math.sin(angle) * velocity,
            color: firework.color,
            alpha: 1,
            size: 2 + Math.random() * 3,
            gravity: 0.05,
            decay: 0.02
        });
    }
}

这个函数负责烟花爆炸效果:

  • 根据角度均匀分布粒子
  • 添加重力效果模拟真实物理
  • 设置透明度衰减营造消失效果
  • 粒子大小随机变化增加层次感

5. 动画循环系统

// 主动画循环
animate() {
    this.updateFireworks();   // 更新烟花位置
    this.updateParticles();   // 更新粒子状态
    this.draw();              // 绘制当前帧
    requestAnimationFrame(() => this.animate());
}

这个循环是整个系统的核心:

  • 使用requestAnimationFrame确保流畅动画
  • 分离更新和绘制逻辑,提高性能
  • 持续循环创造连续动画效果

Trae老师这次的特色功能

心形烟花

点击"心形烟花"按钮,在屏幕中央绽放一个完美的爱心,数学公式计算的心形曲线,保证每个角度都完美对称!

玫瑰烟花

使用玫瑰线方程r = cos(4θ),创造出浪漫的玫瑰花图案,每个花瓣都精确计算,让女孩子感受到你的用心!

自动模式

开启自动模式后,烟花会持续随机绽放,营造浪漫氛围,适合表白时的背景效果!

最终效果展示

打开浏览器访问http://localhost:8083,就能看到浪漫的七夕烟花特效了!

点击屏幕,烟花绽放的瞬间,就是表白成功的开始~

总结

这个七夕情人节烟花项目,不仅展示了前端技术的魅力,更重要的是传递了浪漫的情感。Trae老师通过数学公式计算的心形和玫瑰图案,让技术也变得有温度,让她感受到浪漫的气氛吧!

无论是用来表白,还是单纯享受编程的乐趣,这个项目都能让你感受到代码与浪漫的完美结合!Trae老师太浪漫了

七夕快到了,提前祝大家七夕快乐,天下有情人终成眷属!

记忆卡牌,锻炼你的瞬间记忆力,让Trae帮你完成这个小游戏的开发

你是否想要锻炼一下记忆力,但是要自己手敲代码又很懒,那就让Trae来帮我们实现一下,记忆卡牌,日常没事可以锻炼一下自己的记忆力,让自己记忆力更加好

先向Trae提问,新建一个文件夹,帮我实现一个记忆翻牌小游戏,分为三个难度,不同难度对应的牌数量不一样,界面要现代化,最后在解读一下关键代码

看看Trae的理解会生成怎样的界面以及游戏交互

image.png

项目结构

  • index.html - 现代化界面设计
  • style.css - 渐变背景、毛玻璃效果、响应式布局
  • script.js - 完整的游戏逻辑和交互
  • server.js - 本地服务器(端口8081)
  • README.md - 详细文档和关键代码解读

游戏特色

三个难度级别

  • 简单 :4×4 网格,8对卡片

image.png

  • 中等 :4×6 网格,12对卡片

image.png

  • 困难 :6×6 网格,18对卡片

image.png

现代化界面

  • 优雅的紫色渐变背景
  • 毛玻璃效果的面板和卡片
  • 流畅的卡片翻转动画(3D效果)
  • 响应式设计,完美适配所有设备

刚开始也是无法玩,经过再一次的提问,就可以了,还是挺厉害的

image.png

Trae代码解读

1. 卡片创建

createCards() {
  // 想象你在做一副特殊扑克牌
  // 比如要做8对,就需要16张牌(每对2张一样的)
  const symbols = ['🎯','🎨','🎭'// 表情符号当图
  案
  this.cards = [
    {symbol'🎯'id1matchedfalse}, // 第
    一张🎯
    {symbol'🎯'id2matchedfalse}, // 第
    二张🎯
    {symbol'🎨'id3matchedfalse}, // 第
    一张🎨
    {symbol'🎨'id4matchedfalse}, // 第
    二张🎨
    // ... 以此类推
  ]
  this.shuffleCards() // 洗牌,像真正打牌前洗牌一样
}

2. 翻牌逻辑 - "翻牌检查"

flipCard(index) {
  // 就像翻扑克牌,一次只能翻两张
  if (this.flippedCards.length >= 2return // 已
  经有两张了,不能再翻
  
  const card = this.cards[index]
  if (card.matched) return // 已经配对的不能再翻
  
  // 翻牌动画:给卡片加上'flipped'类,CSS会自动旋转
  cardElement.classList.add('flipped')
  
  // 记住翻过的牌
  this.flippedCards.push(index)
  
  // 翻了两张后,检查是否一样
  if (this.flippedCards.length === 2) {
    setTimeout(() => this.checkMatch(), 600// 
    等0.6秒再检查
  }
}

3. 配对检查 - "对对碰"

checkMatch() {
  const [第一张, 第二张] = this.flippedCards
  
  if (this.cards[第一张].symbol === this.cards[第
  二张].symbol) {
    // 配对了!标记为已匹配,加绿色边框
    card.classList.add('matched')
    this.matchedPairs++ // 配对数+1
  } else {
    // 没配对,翻回去
    card.classList.remove('flipped')
  }
  
  this.flippedCards = [] // 清空记录,准备下一轮
}

翻转动画原理

CSS 3D翻转

.card {
  transform-style: preserve-3d; /* 开启3D空间 */
  transition: transform 0.6s/* 动画持续0.6秒 */
}

.card.flipped {
  transformrotateY(180deg); /* 像翻书一样旋转180
  度 */
}

.card-face {
  backface-visibility: hidden; /* 背面隐藏,像真牌
  一样 */
}

.card-front {
  transformrotateY(180deg); /* 正面初始状态是反
  的 */
}

总结

通过这次记忆翻牌游戏的开发,我们看到了AI编程的巨大潜力:它不仅提高了开发效率,更重要的是让我们能够专注于创意和用户体验,而不是被繁琐的语法细节所困扰,这次就是Trae老师全程编码,我们只要了两次提问就完成了,太强了。

未来,AI将成为每个开发者的"超级助手",让编程变得更加高效和愉悦,快去使用Trae来提升编程效率吧。

coze娱乐ai换脸

coze应用最近很火,在浏览其他人做的丰富多彩的ai应用楼主也不禁想做个有趣的,不过大家知道现在ai都能用梅花易数算卦像了不,真是震惊我前段时间花100大洋请大师算命,落泪。欧克废话不多说我们来看看楼主这次做的ai换脸

扣子这是体验地址大家可以试一试

image.png

楼主这里是把男脸换在女身,为了对比换脸效果,大家可以自己想想,比如把你的脸换成彭于晏(狗头),把你的姐妹换成刘亦菲,ok废话不多说我们来看看效果

首先我们请出我们的好兄弟

R-C.jpg

再请出美女

b89ec6ad-ec8a-4723-853f-4e62e8ef05b3.png

效果

ai设计小小架构

59a7324f-6529-4dd9-a658-c9a29c3ac9d9.png

这时候可能有人说不像,这里楼主用的都是ai生成的图片,看着感觉怪怪的,(不过换脸的插件是官方提供的勉强用用吧),其实楼主用自己的证件照试过了,效果还是不错的,大家可以把好兄弟,说错了,自己的证件照试试看(狗头)

其实coze的ai应用主体大致也是分为三个方向-工作流(后端),ui用户界面(前端),知识库(数据库) 我们先来看看ui界面,coze提供了低代码开发方式,提供了组件与一些模板,让一些 小白也能快速开发出一些简单的界面,楼主这次时间匆匆,前端先做个简单的效果

小前端

image.png

我们来看看前端简单的交互逻辑,首先一个全局容器装了三个盒子,对于面向用户的三个功能区,左边第一列有一个图片生成的逻辑交互,这里楼主用的是表单的形式交互,我们来看看这里的小细节,首先前端是非常需要用户体验的,所以我们一定要把自己编辑的组件交互逻辑说清楚,表单里的text文本提醒的是用户的使用注意事项与功能(要是coze画图再强大那真是太好了,楼主看看下个版本这里的工作流能不能调用sd的api),然后把三个区域简单地用阴影区分一下区域

image.png

这里的表单我们绑定一个后端逻辑,即-当用户在输入框中输入文本后,把对应的promote传給后端生成图像的工作流实现(这里coze叫业务逻辑,反正差不多)我们来看看coze这里是怎么实现的

image.png 这里绑定的是表单里的promote,注意这个工作流调用的入参配置,这就是传给后端的数据

下图是换脸的前端展示 image.png 这里有个小细节,在按钮绑定提交后端处理时间的变量,也就是这两张图片一定要在value后边加上[0]{{ ImageUpload1.value [0]}},楼主一开始没注意这种小细节,如果不加的话,这两上本地是上传的图片无法传输至换脸工作流,弹出格式不兼容错误

中间没什么好说,就是简单的用户成果展示区和ai图片生成区提醒用户细节上面ai生成,下面是用户的杰作就行了

右边的一列盒子是展示之前的楼主小成果的地方(狗头)

小后端(业务逻辑)

第一个工作流

图片生成,用户的promote质量越高,生成的图片越优秀,楼主后续版本会对这里的promote的正向关键词和反向关键词进行优化,这次版本算个dome,速度也是这里能优化的空间,多个工作流实现也是能更快 image.png

很简单的一个图片生成逻辑,这里也是以后能主要优化的地方之一,楼主想的就是能不能用sd来实现对图片的生成,这样出土质量会大大提高,无论是你的底图,还是你的脸图

第二个工作流 换脸,这里调用的的是官方的插件,后续在换脸这里也许有速度的优化空间

image.png

GeoTools 开发合集(全)

^ 关注我,带你一起学GIS ^

前言

GeoTools 开发系列到现在为止,已经断断续续写了20多篇文章,从基础概念到数据查询以及空间分析,GIS开发知识都有涉及。虽然不是很全面,但也是一个完整的学习体系。希望对读者朋友有一定的帮助。

1. GeoTools 基础概念

GIS 开发库 GeoTools 介绍[1]

GeoTools 开发环境搭建[2]

GeoTools 数据模型[3]

GeoTools 工厂设计模式[4]

GeoTools 基础概念解析[5]

2. 导入数据到空间数据库

PostGIS Bundle 导入 Shp 到 PostGIS 空间数据库[6]

shp2pgsql 导入 Shp 到 PostGIS 空间数据库[7]

ogr2ogr 导入 Shp 到PostGIS空间数据库[8]

QGIS DB Manager 导入 Shp 到 PostGIS 空间数据库[9]

GeoTools 将 Shp 导入PostGIS 空间数据库[10]

将 Shp 导入 PostGIS 空间数据的五种方式(全)[11]

3. GeoTools 读取空间数据

GeoTools 读取Shapefile文件[12]

GeoTools 读取影像元数据[14]

4. GeoTools 数据转换

GIS 数据转换:将 CSV 转换为 Shp 数据[15]

GIS 数据转换:将 Txt 转换为 Shp 数据[16]

GIS 数据转换:将 GeoJSON 转换为 Shp 数据(点)[17]

GIS 数据转换:将 GeoJSON 转换为 Shp 数据(面)[18]

GIS 坐标转换:Shp 数据重投影[19]

GeoTools 自定义坐标系[20]

5. GeoTools 数据验证

GIS 数据质检:验证 Geometry 有效性[21]

6. GeoTools 数据查询

GeoTools 结合 OpenLayers 实现属性查询[22]

GeoTools 结合 OpenLayers 实现属性查询(二)[23]

GeoTools 结合 OpenLayers 实现空间查询[24]

7. GeoTools 数据分析

GeoTools 结合 OpenLayers 实现缓冲区分析[25]

GeoTools 结合 OpenLayers 实现叠加分析

鉴于本人知识水平有限,文中不当或者错误之处在所难免,望大家多多包涵。

是时候开启下一步计划了:

最近正在思考下一个系列该写哪方面的主题?也许过几天就会陆续更新。

OpenLayers示例数据下载,请在公众号后台回复:ol数据

全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试

GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。

GeoTools 结合 OpenLayers 实现叠加分析

GeoTools 结合 OpenLayers 实现缓冲区分析

GeoTools 自定义坐标系

GeoTools 基础概念解析

GeoTools 工厂设计模式

GeoTools 数据模型

OGC:开放地理空间联盟简介

GeoTools 结合 OpenLayers 实现空间查询

GIS 空间关系:维度拓展九交模型

GIS 空间关系:九交模型

GeoTools 结合 OpenLayers 实现属性查询(二)

GeoTools 结合 OpenLayers 实现属性查询

GIS 坐标转换:Shp 数据重投影

GIS 数据质检:验证 Geometry 有效性

GIS 数据转换:将 GeoJSON 转换为 Shp 数据(面)

GIS 数据转换:将 GeoJSON 转换为 Shp 数据(点)

GIS 数据转换:将 Txt 转换为 Shp 数据

将 CSV 转换为 Shp 数据

OpenLayers 从后端服务加载 GeoJSON 数据

将 Shp 导入 PostGIS 空间数据库的五种方式(全)

GeoTools 将 Shp 导入PostGIS 空间数据库

使用现代 <img> 元素实现完美图片效果(2025 深度实战版)

图片优化不是“可有可无的小技巧”,而是首屏速度、视觉稳定与交互响应的主战场。2024–2025 年,Web 平台在指标与 API 上有重要变化:INP 取代 FID 成为核心指标Fetch Priority 更易用、响应式预加载更精细。本文系统梳理 2025 年如何正确使用 <img>,并给出可复制的代码模板、避坑清单上线前检查表

为什么图片优化是必须项

  • 图片是网页上最常见、体量最大的一类资源,直接影响最大内容绘制 LCP累计布局偏移 CLS交互响应 INP
  • 合理的尺寸、格式与加载顺序,可以显著降低字节、减少竞争、提升稳定性与响应速度。

img 图片优化思维导图(2025 版)


2025 年需要先知道的三件事(重要变更)

  1. INP 取代 FID 成为核心指标

    2024-03-12 起,INPInteraction to Next Paint 正式替代 FID 成为 Core Web Vitals 的响应性指标;Chrome 等工具也相应弃用 FIDINP200 ms 视为“良好”。这意味着你既要管好 LCP/CLS,也要关注交互阶段的脚本与渲染开销。

  2. 优先级提示(Fetch Priority)已普及

    为关键图片(通常是 LCP 图片)增加 fetchpriority="high",可显著提前其抓取相对次要资源(如下文脚本、非首屏图片)。该属性已在 ChromeSafariFirefox 获得支持。

  3. 预加载响应式图片:用 imagesrcset / imagesizes

    如果你要预加载带 srcset 的图片,应在 <link rel="preload"> 上同时指定 imagesrcsetimagesizes,否则浏览器可能预加载到错误尺寸;且不要同时预加载多种格式(如 AVIFWebP),否则可能两者都被下载。


核心指标与目标值

  • LCP(最大内容绘制) :衡量加载性能。为了提供良好的用户体验,请尽力在网页开始加载的 2.5 秒内完成 LCP
  • CLS(累计布局偏移) :衡量视觉稳定性。为了提供良好的用户体验,请尽力使 CLS 得分低于 0.1
  • INP(交互到下一次绘制) :衡量响应速度。为了提供良好的用户体验,请尽力将 INP 控制在 200 毫秒以内。

CLS:从“不会抖”开始

1. 给图片固有尺寸(最重要的一条)

设置固有尺寸能让浏览器在图片到来之前就预留空间,几乎杜绝 CLS

<!-- 固有宽高:让浏览器能预留空间,避免抖动 -->
<img
  src="/images/keyboard.jpg"
  alt="粉色键盘的近景照片"
  width="1200" height="483" />

现代浏览器会根据 width/height图片尚未下载前推导出纵横比,提前占位,几乎杜绝因图片导致的 CLS。布局再怎么缩放(例如 width: 100%),高度都能按比例计算。

CLS-W-H

2. 尺寸难预知时:用 aspect-ratio 或“占位尺寸”

不知道实际像素,但知道大致长宽比:

/* 知道大致宽高比时 */
.thumb {
  aspect-ratio: 4 / 3;
  width: 100%;
}
.thumb > img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

长页/瀑布流里,延迟渲染区域配合:

section {
  content-visibility: auto;
  contain-intrinsic-size: auto 500px; /* 未渲染前的占位尺寸,防抖动 */
}

content-visibility 让视口外内容跳过布局/绘制,contain-intrinsic-size 负责预留空间,避免滚动到该区域时发生位移。


LCP:把“最大内容”尽快送到眼前

LCP 往往就是首屏主视觉图(Hero 。要赢在毫秒级:

1. 不要LCP 图加 loading="lazy"

懒加载会推迟“是否在视口”与“发起请求”的时机,必然拖慢 LCP。首屏图请移除 loading="lazy"

2. 用 Fetch Priority 提升优先级

<!-- 关键:提升抓取优先级 -->
<img
  src="/img/hero-1600.jpg"
  srcset="/img/hero-1200.jpg 1200w, /img/hero-1600.jpg 1600w"
  sizes="(max-width: 900px) 100vw, 70vw"
  width="1600" height="1000"
  fetchpriority="high"
  alt="新品主视觉" />

这会把 LCP 图尽早纳入“第一阶段”关键下载序列,这一提示已被主流浏览器采纳。

3. 必要时做响应式预加载

<!-- 与 imagesrcset 配合使用 -->
<link
  rel="preload" as="image"
  href="/img/hero-1600.jpg"
  imagesrcset="/img/hero-1200.jpg 1200w, /img/hero-1600.jpg 1600w"
  imagesizes="(max-width: 900px) 100vw, 70vw"
  fetchpriority="high" />

注意:

  • 不要同时预加载 AVIFWebP,否则可能两者都被下载,浪费带宽。
  • 如果 LCP 图在 HTML 中位置已很靠前,未必需要预加载(避免“抢带宽”)。

响应式图片:w + sizes 是首选

最佳实践是使用宽度描述符800w/1200w/1600w)配合 sizes,告诉浏览器在不同条件下的实际渲染宽度。

<img
  src="/img/post-800.jpg"
  srcset="/img/post-800.jpg 800w, /img/post-1200.jpg 1200w, /img/post-1600.jpg 1600w"
  sizes="(max-width: 768px) 100vw, 70vw"
  width="1600" height="1067"
  alt="活动现场" />

要点:

  • 准确书写 sizes(按布局真实占宽);避免始终 100vw 导致过大图浪费。

  • 背景图可用 image-set() 做密度切换:

    .banner {
      background-image: image-set(url(/img/banner.avif) 1x, url(/img/banner@2x.avif) 2x);
      background-size: cover;
      background-position: center;
    }
    

    让浏览器按 DPR 选择合适资源。


格式策略:AVIFWebPJPEG

  • AVIF:高压缩率且主流浏览器广泛支持;优先推荐
  • WebP:兼容面最广,作为 AVIF回退非常合适。
  • JPEG:最终兜底。

可用 <picture> 同时宣告多个格式并提供回退:

<picture>
  <source type="image/avif" srcset="/img/photo.avif">
  <source type="image/webp" srcset="/img/photo.webp">
  <img src="/img/photo.jpg" alt="合影" width="1600" height="1067">
</picture>

AVIF-WebP-JPEG

仍要预加载时,只预加载最可能被采用的那个格式


懒加载与解码:只“懒”看不见的图

  • 首屏外图片使用:

    <img
      src="/img/gallery-1.webp"
      loading="lazy" decoding="async"
      width="800" height="600"
      alt="画廊图片" />
    
  • loading="lazy" 让视口外图片延后请求,缓解首屏竞争;decoding="async" 避免同步解码阻塞渲染。

  • 切记LCP 图不要懒加载。


渲染级优化:长列表/长文档的隐藏功臣

当页面包含评论区、长目录、商品瀑布流等大量视口外内容时:

.section {
  content-visibility: auto;        /* 视口外跳过布局/绘制 */
  contain-intrinsic-size: auto 500px; /* 先给一个合理占位,避免滚动到时位移 */
}

这组属性能有效减少首屏工作量并避免后续 CLS


高分屏与 DPR:何时用 x、何时用 w

  • 布局宽度已知:优先 w + sizes(下载更精准、带宽利用更好)。
  • 只想做密度切换:可用 1x/2x/3xx 描述符)。
  • 背景图:用 image-set(),结合 background-size: cover/contain

DPR


无障碍与语义

  • 始终写好 alt:传达图片语义内容;纯装饰性图片可用空 alt="" 或使用 CSS 背景。
  • 关键图片可结合 elementtiming="hero",方便用 Performance Element Timing 观测关键渲染(进阶)。

动图与视频:别再用重量级 GIF

  • 体量大、压缩差的 GIF 会极大拖慢页面。
  • 建议改为 MP4/WebMAVIF/WebP 动图;必要时用 <video> 控制播放与无障碍。

GIFToMP4


数据节省与用户偏好(可选)

需要面向弱网/昂贵网络的站点,可根据用户偏好减少图片资源:

  • CSS

    @media (prefers-reduced-data: reduce) {
      /* 降级背景图、关闭自动播放等 */
    }
    
  • HTTP Client Hints(服务器侧协商):Sec-CH-Save-Data / Sec-CH-Prefers-Reduced-Data 等(属进阶能力,兼容性需评估)。


场景化模板(复制即可用)

首屏主视觉(LCP

<!-- head(可选):确有必要时做响应式预加载 -->
<link rel="preload" as="image"
      href="/img/hero-1600.jpg"
      imagesrcset="/img/hero-1200.jpg 1200w, /img/hero-1600.jpg 1600w"
      imagesizes="(max-width: 900px) 100vw, 70vw"
      fetchpriority="high">

<!-- body:真正渲染 -->
<picture>
  <source type="image/avif" srcset="/img/hero-1200.avif 1200w, /img/hero-1600.avif 1600w" sizes="(max-width: 900px) 100vw, 70vw">
  <source type="image/webp" srcset="/img/hero-1200.webp 1200w, /img/hero-1600.webp 1600w" sizes="(max-width: 900px) 100vw, 70vw">
  <img
    src="/img/hero-1600.jpg"
    srcset="/img/hero-1200.jpg 1200w, /img/hero-1600.jpg 1600w"
    sizes="(max-width: 900px) 100vw, 70vw"
    width="1600" height="1000"
    fetchpriority="high"   <!-- 关键 lazy -->
    alt="新品主视觉">
</picture>

要点回顾:不要LCP 图加 loading="lazy";必要时再考虑预加载并配合 imagesrcset/imagesizes

非首屏图(卡片/列表/瀑布流)

<img
  src="/img/card-400.avif"
  srcset="/img/card-400.avif 400w, /img/card-800.avif 800w"
  sizes="(max-width: 600px) 50vw, 25vw"
  width="800" height="600"
  loading="lazy" decoding="async"
  alt="卡片缩略图">

CSS 背景(横幅/装饰)

.banner {
  background-image: image-set(url(/img/banner.avif) 1x, url(/img/banner@2x.avif) 2x);
  background-size: cover;
  background-position: center;
  min-block-size: 40vh;
}

CMS / 未知宽高比的缩略图(防抖动)

<figure class="thumb">
  <img src="/cms/auto.webp" width="1200" height="800" alt="图文摘要">
  <figcaption>……</figcaption>
</figure>
.thumb { aspect-ratio: 3 / 2; }
.thumb > img { width: 100%; height: 100%; object-fit: cover; }

即使图片比例波动,仍能保证不抖动。


常见误区与修复

  • 把首屏图懒加载 → 直接移除 loading="lazy",并考虑 fetchpriority="high";必要时配合预加载。
  • 预加载多个格式AVIF+WebP)→ 只预加载最终最可能使用的那个。
  • sizes 过大导致浪费 → 用真实占宽(可借助 DevTools “渲染宽度”调试)。
  • CLS 抖动 → 添加 width/heightaspect-ratio;对延迟渲染区域用 contain-intrinsic-size
  • INP 偏高 → 交互时减少长任务、降低同步 JS、延迟非关键图片与第三方脚本加载。

速查清单(Checklist

  • 尺寸:所有 <img> 具备 width/height 或可推导的 aspect-ratio(防 CLS)。
  • LCP:不懒加载,设 fetchpriority="high";必要时响应式预加载
  • 响应式:以 w + sizes 为主;sizes 与真实占宽匹配。
  • 格式AVIF 优先,WebP 兜底,JPEG 再兜底;避免双格式同时预加载
  • 懒加载:首屏外图片一律 loading="lazy",并考虑 decoding="async"
  • 渲染:大量折叠内容使用 content-visibility: auto + contain-intrinsic-size
  • 动图: 尽量用 视频AVIF/WebP 动图 替代 GIF
  • 指标LCP ≤ 2.5sCLS ≤ 0.1INP ≤ 200ms(以第 75 分位、真实用户数据为准)。

FAQ:常见问题解答

Q1:我用 <picture> 了,还需要写 width/height 吗? 需要。width/height 写在最终的 <img> 上,用于推导纵横比并占位,防止 CLS

Q2:主视觉图到底要不要预加载? 视情况。若它在 HTML 里位置已有靠前且无强竞争,fetchpriority="high" 即可;若抓取确实偏晚,再考虑“响应式预加载”(imagesrcset/imagesizes),避免双格式并发。

Q3:sizes 很难写对,有没有经验值? 先按布局估算,例如:移动端 100vw,桌面端主列 70vw;然后用 DevTools 检查实际渲染宽度,微调到更贴合的表达。

Q4:电商类长列表如何降低 CLSINP 给所有缩略图加固有尺寸;列表容器使用 content-visibility: auto + contain-intrinsic-size;首屏外图片全部懒加载;交互时减少同步 JS 与第三方阻塞。


结语

2025 年的图片优化要点并不复杂:把该给浏览器的信息给全(尺寸、占宽、优先级),把该延后的资源延后(非首屏图、长列表),把会出问题的环节前置处理好(格式策略、预加载规范) 。照本文模板与清单落地,你的首屏更快、页面更稳、交互更流畅。

🚀🚀🚀 告别复制粘贴,这个高效的 Vite 插件让我摸鱼🐟时间更充足了!

前言

最近我开发了 vite-plugin-swagger-mcp 插件,可以通过精确投喂 Swagger 数据给大模型,生成准确的请求函数、参数和返回值类型。它避免了上下文过长导致的幻觉问题,让开发更高效!

仓库地址:github.com/mmdctjj/vit…

往期精彩推荐

正文

在日常前端开发中,Swagger 文档虽详尽,但实际使用时存在这些问题:

  1. 手动复制繁琐:需要从 Swagger 复制路径、参数、请求体和响应类型到代码中,添加 Axios/Fetch 函数和 TypeScript 类型。重复操作易出错,尤其在接口众多时。
  2. 大模型辅助的限制:想用 Vscode 或 Trae 生成代码时,因提示词长度限制,需多次复制接口细节,效率低下。
  3. 全文档投喂的幻觉:直接将 Swagger JSON 文件传给大模型,上下文过长(数千行),易产生幻觉:参数类型错乱、函数生成不准,甚至忽略边缘情况。

痛定思痛,我开发了 vite-plugin-swagger-mcp 这个插件,

插件通过生成 MCP 服务器,让大模型按需访问 Swagger 数据,实现精确投喂:

插件启动后,在编辑器里添加 MCP 服务,

{
  "mcpServers": {
    "swagger": {
      "url": "http://localhost:5173/_mcp/sse/swagger"
    }
  }
}

Trae 添加之后

之后就可以使用大模型直接查询:“生成 /xxx 接口的 GET 请求函数和类型”,插件会投喂精确数据,生成准确代码。

示例1

示例2

即使同时生成一个模块下所有接口,都可以做到精确、无幻觉。

如何使用

  1. 安装
pnpm add vite-plugin-swagger-mcp -D

2. 配置

在 vite.config.ts 中添加插件,如下。

示例配置(vite.config.ts)

import { defineConfig } from 'vite';
import swaggerMcp from 'vite-plugin-swagger-mcp';

export default defineConfig({
  plugins: [
    swaggerMcp({
      swaggerUrl'http://ip:port/path/v2/api-docs',
      token'xxxx'// 可选 Bearer Token
    }),
  ],
});

3. 启动

npm run dev

启动成功后可以在日志里看到具体的地址:

MCP server connected: http://localhost:5173/_mcp/sse/swagger

xxxx@0.0.0 dev
vite

MCP server connected: http://localhost:5173/_mcp/sse/swagger

  VITE v7.0.4  ready in 470 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

然后取对应的编辑器添加 MCP 工具即可!

详细用法见 github.com/mmdctjj/vit…

另外,大家不用担心对生产环境有影响,因为只使用了 Vite 插件的 configureServer 函数,仅开发时生效!

最后

这个插件解决了使用 Swagger 文档生成接口和类型的种种痛点,通过精确投喂让大模型生成更准确的接口代码。同时也让自己有更多的时间做有意义的事了!

快来试试这个插件,为自己赢取更多的摸鱼时间吧!

今天的分享就这些了,感谢大家的阅读!如果文章中存在错误的地方欢迎指正!

往期精彩推荐

node文章生成器

前言

这个文章生成器模块通过组合预定义的文本片段,按照特定规则随机生成连贯的文章内容。它可以用于测试数据生成、创意写作辅助或任何需要大量文本内容的场景。当然先要有一个语料库。

语料库包含多个分类:

  1. 标题(title) : 7个可能的文章标题,如"一天掉多少根头发"、"中午吃什么"等
  2. 名人名言(famous) : 100条名人名言,每条包含{{said}}{{conclude}}占位符
  3. 开头句(bosh_before) : 14种文章开头方式
  4. 正文句(bosh) : 22种正文表达方式,多数包含{{title}}占位符
  5. 结论句(conclude) : 8种结尾方式
  6. 引用方式(said) : 5种不同的引用表达

核心功能

该模块主要提供三个核心功能:

  1. 从JSON文件加载语料库
  2. 创建随机选择函数
  3. 根据语料库生成随机文章

实现解析

1. 语料库加载

export function loadCorpus(path) {
  const path1 = resolve(__dirname, '..', path);
  const data = readFileSync(path1, { encoding: 'utf-8' });
  return JSON.parse(data);
}

这个函数负责加载外部JSON语料库文件,将其解析为JavaScript对象。语料库应包含不同类别的文本片段,如名人名言、普通段落、开头和结尾语句等。

2. 随机选择器

export function createrandPick(arr) {
  arr = [...arr]; // 创建数组副本
  function randomPick() {
    const len = arr.length - 1;
    const index = randomInt(0, len);
    const picked = arr[index];
    [arr[index], arr[len]] = [arr[len], arr[index]]; // 交换元素位置
    return picked;
  }
  randomPick(); // 抛弃第一次执行的结果
  return randomPick;
}

这个函数实现了"无放回随机选择"算法,确保每个元素被选中后不会被重复选择,直到所有元素都被选过一遍。这种算法通过交换数组元素的位置来实现高效随机选择。

3. 句子生成

function sentence(pick, replacer) {
  let ret = pick();
  for (const key in replacer) {
    ret = ret.replace(new RegExp(`{${key}}`, "g"), replacer[key]);
  }
  return ret;
}

这个函数处理文本模板中的占位符替换,例如将{title}替换为实际的文章标题。

4. 文章生成

export function generate(title, { corpus, min = 1000, max = 3000 }) {
  // 初始化各种文本片段的随机选择器
  const [pickFamous, pickBoshBefore, pickBosh, pickSaid, pickConclude] = [
    famous, bosh_before, bosh, said, conclude
  ].map(createrandPick);
  
  // 生成文章段落
  while (totalLength < articleLength) {
    // 随机组合不同类型的句子
    const n = randomInt(0, 100);
    if (n < 20) {
      // 插入名人名言
    } else if (n < 50) {
      // 使用引导句+普通句子
    } else {
      // 使用普通句子
    }
  }
  return article;
}

文章生成算法按照以下规则工作:

  • 随机决定文章总长度(在min和max之间)
  • 将文章划分为多个段落,每段长度随机
  • 以不同概率组合不同类型的句子:
    • 20%的概率插入名人名言
    • 30%的概率使用引导句+普通句子
    • 50%的概率使用普通句子

image.png

结语

你是否也曾想过,机器的"创作"能否触动人心?或许答案就藏在下一次生成的文章中。为什么不亲自运行这段代码,看看人工智能与人类智慧碰撞出的火花?谁知道呢——也许下一段让你会心一笑的文字,就来自这个不起眼的生成器。快来试试吧

前端必懂的 Cache 缓存机制详解

在前端开发中,性能优化是绕不开的话题,而 缓存(Cache) 则是性能优化中最具性价比的一环。本文从基础概念出发,带你深入理解 浏览器缓存的工作机制,尤其是 强缓存协商缓存 的原理、应用场景与配置方法。


一、什么是 Cache?

Cache,简单来说就是“存副本的地方”。

  • 副本:不是源数据本身,而是拷贝或计算结果。
  • 临时性:有有效期或失效策略。
  • 高效性:读取速度更快,或减少网络请求和计算资源。

Cache 无处不在,比如浏览器缓存、CDN 缓存、服务器端缓存、应用层缓存,甚至硬件层缓存。本文我们聚焦前端最常打交道的 浏览器缓存


二、浏览器缓存的三种类型

浏览器缓存主要分为三类:

  • 强缓存Cache-Control / Expires
  • 协商缓存ETag / Last-Modified
  • Service Worker 缓存(适用于 PWA)

我们先从最基础的 强缓存协商缓存 入手。


三、强缓存(Strong Cache)

1. 核心特点

在缓存有效期内,浏览器直接使用本地缓存文件,不会请求服务器。这让页面加载飞快,并且大幅减少服务器压力。

2. 实现原理

当浏览器请求某个资源时,若响应头中带有 ExpiresCache-Control,浏览器会记住缓存策略,在有效期内直接使用本地副本。

(1) Expires(HTTP/1.0)

Expires: Wed, 25 Aug 2025 08:30:00 GMT
  • 表示资源的绝对过期时间。
  • 缺点:依赖客户端时间,时间不准会导致缓存失效。

(2) Cache-Control(HTTP/1.1 推荐)

Cache-Control: public, max-age=600
  • 表示资源在 600 秒内走强缓存。

  • 常见指令:

    • max-age=xxx:资源缓存时长(秒)
    • no-store:不缓存
    • no-cache:强制协商缓存
    • public / private:是否允许代理服务器缓存

3. 工作流程

  1. 浏览器请求资源
  2. 服务器返回带缓存头的响应
  3. 下次访问时,直接读取本地缓存,不请求服务器

4. 优缺点

优点 缺点
加载速度最快 内容更新不及时,可能命中过期资源
减少服务器压力 需配合版本控制才能避免脏缓存

四、协商缓存(Negotiated Cache)

1. 核心特点

每次请求都会问服务器:“文件有更新吗?”

  • 没更新 → 服务器返回 304 Not Modified,浏览器用本地缓存。
  • 有更新 → 返回新文件并更新缓存。

2. 实现方式

方式一:Last-Modified / If-Modified-Since

首次请求:

Last-Modified: Sun, 24 Aug 2025 10:00:00 GMT

二次请求:

If-Modified-Since: Sun, 24 Aug 2025 10:00:00 GMT

缺点:

  • 精度是秒,秒级内多次更新无法区分。
  • 动态文件更新时间变化容易导致缓存失效。

方式二:ETag / If-None-Match

首次请求:

ETag: "abc123xyz"

二次请求:

If-None-Match: "abc123xyz"

优点:

  • 精确到字节,命中率高。
    缺点:
  • 生成 ETag 有一定计算开销。

3. 工作流程

  1. 浏览器带标识请求资源。
  2. 服务器检查标识是否匹配。
  3. 返回 304200,并更新缓存。

五、强缓存 vs 协商缓存

特性 强缓存 协商缓存
是否请求服务器
加载速度 极快 略慢
更新实时性 较差 更好
适用场景 静态资源 实时更新数据

六、最佳实践

  1. 静态资源文件:加 hash 文件名 + 强缓存

    /static/js/app.abc123.js
    
  2. 频繁更新的接口数据:使用协商缓存

  3. 结合 CDN:进一步提升加载速度


七、总结

  • 强缓存:速度最快,但要注意失效策略。
  • 协商缓存:更实时,适合更新频繁的内容。
  • 混合策略:强缓存 + 协商缓存 + 文件版本控制,是目前最常见的最佳实践方案。

掌握这些知识,不仅能帮你优化前端性能,还能降低服务器成本,打造更加丝滑的用户体验。

从代码到屏幕,浏览器渲染网页做了什么❓

当我们打开一个网页时,背后发生了什么?浏览器如何将HTMLCSSJavaScript代码转换成我们看到的精美页面?

概述:五步渲染流程

浏览器渲染网页可以简化为五个关键步骤:

  1. 解析HTML,构建DOM树 - 将HTML标签转换为文档对象模型
  2. 解析CSS,构建CSSOM树 - 处理样式信息形成CSS对象模型
  3. 合并成渲染树 - 将DOMCSSOM结合,排除不可见元素
  4. 布局计算 - 计算每个元素在屏幕上的确切位置和大小
  5. 绘制显示 - 将布局好的内容绘制到屏幕上,最终呈现给用户

其中第四步和第五步是最耗时的部分,这两步合起来就是我们通常所说的"渲染"。


深入渲染的五个阶段

第一步:解析HTML,构建DOM树

当浏览器接收到HTML文档时,它会立即开始解析工作。解析器会将HTML标签转换为一个树状结构——DOM(文档对象模型)树。

DOM树有两个主要作用:

  • 作为下一阶段渲染树的输入
  • 提供JavaScript与网页交互的接口(如常用的getElementById等方法)

遇到脚本时的处理: 当解析器遇到<script>标签时,会发生四件事:

  1. HTML解析暂停
  2. 如果是外部脚本,从网络获取代码
  3. 将控制权交给JavaScript引擎执行代码
  4. 执行完毕后恢复HTML解析

优化建议

  • <script>标签放在页面底部,加速首屏渲染
  • 使用defer(延迟执行,DOM构建完成后执行)或async(异步加载和执行)属性优化脚本加载

第二步:解析CSS,构建CSSOM树

与此同时,浏览器也会处理CSS样式,构建CSSOMCSS对象模型)树。虽然CSS不会修改文档结构,但CSSOM树的构建仍然是阻塞性的——浏览器会等待CSSOM构建完成后才进行渲染。

优化建议

  • 尽快加载CSS样式表
  • 使用media typemedia query区分不同样式表,将非关键CSS标记为非阻塞资源

第三步:合并DOM和CSSOM形成渲染树

渲染树只包含可见内容——不会包含如<head>display: none元素等不可见节点。这一步将DOM的內容和CSSOM的样式信息结合,确定每个节点应该如何显示。

第四步:布局计算

也称为"重排"(reflow),浏览器计算渲染树中每个节点的确切位置和大小。这个过程考虑各种布局因素,如定位方式、浮动、边距等。

第五步:绘制与合成

最后一步,浏览器将布局计算后的节点转换为实际像素,绘制到屏幕上。现代浏览器会使用GPU加速绘制过程,特别是对于动画和复杂视觉效果。


性能优化关键点

理解Load和DOMContentLoaded事件

  • DOMContentLoaded:HTML文档完全加载和解析后触发,无需等待样式表、图像和子框架完成加载
  • Load:整个页面及所有依赖资源(如CSS、图片)完全加载完成后触发

利用图层提升性能

浏览器可以将特定元素提升为单独图层,独立渲染这些图层可以提高性能。以下属性可以创建新图层:

  • 3D变换属性:translate3dtranslateZ
  • will-change属性(提前告知浏览器元素可能的变化)
  • <video>iframe>元素
  • 使用CSS动画实现透明度变化
  • position: fixed定位元素

注意:图层不是越多越好,过多图层反而会降低性能。

重绘(Repaint)与回流(Reflow)

  • 重绘:元素外观改变但不影响布局(如修改颜色、背景)
  • 回流:元素的布局或几何属性改变(如修改宽度、位置)

回流成本远高于重绘,而且一个元素回流可能导致父元素及周围元素也需要回流。

减少重绘和回流的实用技巧

  1. 使用transform替代定位属性(如用translate代替top/left
  2. 使用visibility: hidden替代display: none(前者只引起重绘,后者引起回流)
  3. 批量修改DOM:先使元素脱离文档流(display: none),进行多次修改,再带回文档流
  4. 避免在循环中读取布局属性(如offsetTopoffsetWidth
  5. 避免使用table布局 - 小小改动可能导致整个表格重新布局
  6. 优化CSS选择器 - 从右向左匹配,避免过于复杂的选择器
  7. **使用requestAnimationFrame**优化动画性能

现代浏览器的渲染调度

现代浏览器会智能调度渲染任务:

  • 每秒60帧(每16ms一帧)的刷新率
  • resizescroll事件自带节流(至少16ms触发一次)
  • 使用requestIdleCallback在空闲时间执行非紧急任务

结语

理解浏览器渲染原理对于前端开发者至关重要。通过优化HTML结构、合理使用CSS和JavaScript,我们可以显著提升网页性能,为用户提供更流畅的浏览体验。

记住关键点:减少重绘回流、合理利用图层、优化资源加载顺序,你的网页将会飞起来!

React Native 中的 useRef 介绍

在 React Native 中,useRef 是 React 提供的一个非常有用的 Hook,它的作用是在函数组件中保持对某些数据或是组件的持久引用。并且它的改变不会触发组件重新渲染。 在之前的类组件开发模式下,我们通常会用 this 关键字来保存对组件或者数据的引用。而在函数组件中没有 this,这时,useRef 就应运而生了。

在这篇文章中,我会介绍 useRef 的使用方式、原理、常见场景,希望可以帮助到大家。

首先,我们来看一下 useRef 在代码中是如何使用的。

基本使用

// 声明一个属性
const refContainer = useRef(initialValue);
// 访问属性的值
console.log(refContainer.current);
// 修改属性的值
refContainer.current = 10;

可以看到,useRef 的使用分以下三步:

  • 第一步,声明并且赋值一个初始值;
  • 第二步,在对应逻辑中修改它的值。需要注意的是:我们需要修改 .current 而不是直接修改声明的变量;
  • 第三步,通过 refContainer.current 访问数据即可。

它的使用还是比较简单的。下面来一下具体的有哪些常用的使用场景。

useRef 使用场景举例

引用组件实例

在 React Native 的日常中,我们经常需要获取 TextInputScrollViewFlatList 等组件的实例来调用方法。

import React, { useRef } from 'react';
import { View, TextInput, Button } from 'react-native';

export default function UseRefExample() {
  const inputRef = useRef<TextInput>(null);

  const focusInput = () => {
    inputRef.current?.focus(); // 调用 TextInput 的 focus 方法
  };

  return (
    <View>
      <TextInput ref={inputRef} placeholder="请输入内容" style={{ borderWidth: 1, padding: 8 }} />
      <Button title="聚焦输入框" onPress={focusInput} />
    </View>
  );
}

在这个例子中,我们通过 inputRef.current.focus() 可以让输入框获得焦点,而不需要重新渲染组件。

存储可变值

useRef 也可以用来存储组件生命周期中需要的可变值,例如定时器 ID、计数器等。

import React, { useRef, useEffect, useState } from 'react';
import { View, Text, Button } from 'react-native';

export default function TimerExample() {
  const [count, setCount] = useState(0);
  const intervalRef = useRef<NodeJS.Timeout | null>(null);

  const startTimer = () => {
    if (!intervalRef.current) {
      intervalRef.current = setInterval(() => {
        setCount(prev => prev + 1);
      }, 1000);
    }
  };

  const stopTimer = () => {
    if (intervalRef.current) {
      clearInterval(intervalRef.current);
      intervalRef.current = null;
    }
  };

  useEffect(() => {
    return () => stopTimer(); // 组件卸载时清理定时器
  }, []);

  return (
    <View>
      <Text>计数: {count}</Text>
      <Button title="开始" onPress={startTimer} />
      <Button title="停止" onPress={stopTimer} />
    </View>
  );
}

这里的 intervalRef 不会触发组件重新渲染,它只是存储一个定时器 ID,以便后续清理。

与 useEffect 结合使用

当我们需要访问最新的状态或 props,但又不想在依赖数组中引发无限循环时,可以借助 useRef

import React, { useEffect, useRef, useState } from 'react';
import { View, Button, Text } from 'react-native';

export default function LatestValueExample() {
  const [count, setCount] = useState(0);
  const latestCount = useRef(count);

  useEffect(() => {
    latestCount.current = count; // 同步最新值
  }, [count]);

  const showLatestCount = () => {
    setTimeout(() => {
      console.log('最新计数值:', latestCount.current);
    }, 3000);
  };

  return (
    <View>
      <Text>计数: {count}</Text>
      <Button title="增加" onPress={() => setCount(count + 1)} />
      <Button title="3秒后打印最新值" onPress={showLatestCount} />
    </View>
  );
}

在这个例子中,即使 setTimeout 在 3 秒后才执行,也能拿到最新的 count 值,而不需要在 useEffect 或依赖数组中做复杂处理。

如果 latestCount 没有使用 useRef ,而是普通的变量的话,那么每次组件重新渲染时,它的值会被重置。当你点击「增加」按钮,count 会更新,而 latestCount 并不会持久化。你预期在 3 秒后打印正确的 count 值,但是由于 latestCount 被重置,所以打印的始终是第一次渲染时的 count 值。

控制动画或滑动位置

在 React Native 中,useRef 对配合 Animated.Value 或 ScrollView 的 scrollTo 也非常有用。

import React, { useRef } from 'react';
import { View, ScrollView, Button, Text, StyleSheet } from 'react-native';

export default function ScrollExample() {
  const scrollRef = useRef<ScrollView>(null);

  const scrollToBottom = () => {
    scrollRef.current?.scrollToEnd({ animated: true });
  };

  return (
    <View style={{ flex: 1 }}>
      <ScrollView ref={scrollRef} style={styles.scrollView}>
        {Array.from({ length: 50 }).map((_, index) => (
          <Text key={index}>第 {index + 1} 行</Text>
        ))}
      </ScrollView>
      <Button title="滚动到底部" onPress={scrollToBottom} />
    </View>
  );
}

const styles = StyleSheet.create({
  scrollView: { flex: 1, padding: 10 },
});

这里 scrollRef.current.scrollToEnd() 可以控制 ScrollView 滚动到最后。

总结

useRef 是 React Native 中非常实用的 Hook,既能替代类组件中的 this,又能在函数组件中保存可变数据,同时保持性能优势。useRef 常用于在函数组件中创建可变引用,并且这种引用的变化不会触发组件的重新渲染。它既可以用来引用组件实例,也能用来存储一些可变的值,例如定时器的 ID 或者最新的状态,非常适合在动画、ScrollView 控制、延迟获取最新状态等场景下使用。我们额外需要注意的是:useRef 并不能用来进行管理 UI 状态,否则可能会导致逻辑混乱。

【performance面试考点】让面试官眼前一亮的performance性能优化

本文面向前端面试,覆盖浏览器渲染机制、重排重绘优化、资源与网络优化、执行与框架层优化、缓存与首屏、度量与工具,并配有可直接落地的代码示例与“高分回答模板”。


一、核心指标与目标(先说度量,展现方法论)

  • 指标家族(建议至少说出 5 个并解释场景)
    • FP/FCP: 首次/首次内容绘制,用户看到内容的时间。
    • LCP: 最大内容绘制,衡量主内容出现速度(目标 ≤ 2.5s)。
    • CLS: 累积布局偏移,衡量页面稳定性(目标 ≤ 0.1)。
    • TBT/INP/TTI: 交互堵塞/交互响应/可交互时间,衡量主线程繁忙程度与交互体验。
  • 目标制定
    • 制定“性能预算”(Performance Budget):LCP ≤ 2.5s、CLS ≤ 0.1、首页 JS ≤ 200KB(gzip) 等。
    • 建议在 CI/Lighthouse CI 中守护预算,超标直接报警。

二、浏览器渲染原理与重绘重排(高频考点)

  • 流程:HTML 解析为 DOM → CSS 解析为 CSSOM → 生成 Render Tree → Layout(重排)Paint(重绘) → Composite。
  • 概念
    • 重绘:样式变化但不影响布局,如颜色、背景、阴影。
    • 重排:几何属性变化导致布局计算,如大小、位置、显示/隐藏、插入/删除节点。
    • 关系:重排一定引发重绘,重绘不一定重排。

高分实践

  • 批量合并样式更新,避免布局抖动(Layout Thrashing)
// 反例:多次读写交错,易触发多次重排
const el = document.getElementById('el');
el.style.width = '100px';
el.style.height = '100px';
el.style.display = 'block';

// 正例1:使用 cssText 或 class 切换(一次性批量变更)
el.style.cssText = 'width:100px;height:100px;display:block;';
// 或
el.className = 'card card--expanded';
  • 使用文档碎片一次性插入,减少回流
const fragment = document.createDocumentFragment();
for (let i = 0; i < 100; i++) {
  const div = document.createElement('div');
  fragment.appendChild(div); // 不触发布局
}
document.body.appendChild(fragment); // 一次性更新
  • 读写分离,缓存布局信息
const el = document.getElementById('el');

// 反例:循环中每次读取布局 + 写入样式,强制同步布局
for (let i = 0; i < 100; i++) {
  el.style.top = el.offsetTop + 1 + 'px';
}

// 正例:先读后写,避免多次强制布局
let top = el.offsetTop;
for (let i = 0; i < 100; i++) {
  top += 1;
}
el.style.top = top + 'px';
  • 使用 transform/opacity 做动画(只触发合成,不走布局与绘制)
// 反例:left/top 会引发布局
el.style.left = '100px';

// 正例:GPU 友好的属性
el.style.transform = 'translateX(100px)'; // 或配合 transition/WAAPI
el.style.opacity = '0.8';
  • 在动画/频繁更新中使用 requestAnimationFrame,与浏览器帧同步
  • 大量 DOM 操作前可“下线”节点(合理使用 display:none 或离屏容器)后批量更新再上线
  • 避免频繁读取以下属性导致强制同步布局:offsetTop/Left/Width/HeightgetComputedStylescrollTop 等;如需读取,批量读取后再统一写入

三、资源加载优化(提速“下载+执行”)

  • 代码分割与路由懒加载(减少首屏体积)
    • Webpack/Rollup/Vite 动态导入:import('...')
    • React 路由懒加载:React.lazy + Suspense
  • 资源提示(Resource Hints)
    • preload:当前页面“关键资源”,立刻加载
      <link rel="preload" as="style" href="/critical.css">
    • prefetch:未来可能用到的资源,空闲加载
      <link rel="prefetch" href="/next-page.js">
    • preconnect:提前建立连接(DNS、TLS、TCP)
      <link rel="preconnect" href="https://cdn.example.com" crossorigin>
    • dns-prefetch:提前 DNS 解析
      <link rel="dns-prefetch" href="//cdn.example.com">
  • 脚本加载策略
    • defer:并行下载,按文档顺序执行,DOMContentLoaded 前执行
    • async:并行下载,下载完成立刻执行(顺序不确定)
    • type="module":天然 defer 行为,支持按需与 Tree-shaking
  • 图片优化
    • 新格式:优先 AVIF/WebP,并提供回退
    • 响应式:srcset + sizes,按视口和 DPR 提供最佳图
    • 懒加载:<img loading="lazy">;解码:decoding="async";优先级:fetchpriority="high|low"
    • 雪碧图/IconFont 已不再主流,优先 SVG symbolIcon 组件
  • 样式优化
    • Critical CSS 内联,非关键样式 media/preload 延后
    • 减少阻塞渲染资源(阻塞 CSS/同步 JS)

四、JS 执行与主线程优化(降低卡顿)

  • 减少长任务(>50ms),拆分任务,利用切片
  • 防抖/节流:输入、滚动、窗口变更等高频事件
const throttle = (fn, wait) => {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last > wait) {
      last = now;
      fn(...args);
    }
  };
};
window.addEventListener('scroll', throttle(handleScroll, 100));
  • requestAnimationFrame:动画写入;requestIdleCallback:空闲期任务(兜底逻辑)
  • Web Workers:把密集计算从主线程挪走;极致可考虑 WASM
  • 大列表优化:虚拟列表(windowing)、分页、占位骨架

五、框架层优化(React/前端框架)

  • React 重渲染治理
    • memo/useMemo/useCallback 控制子组件重渲染
    • 合理设置 key,避免 Diff 错误带来的 DOM 抖动
    • 避免在 render 中创建新对象/函数;减少 Context 滥用带来的联动渲染
    • React 18 并发特性 + startTransition 提升交互流畅度
  • 构建优化
    • 按需引入组件库(如 shadcn-ui)与 Tree-shaking
    • 生产构建压缩:Terser/ESBuild,移除 dead code、console、debug
    • 预编译依赖(Vite deps prebundle),SSR 构建分离

六、缓存策略(命中缓存=最快)

  • 强缓存
    • Cache-Control: max-age=31536000, immutable
    • 避免使用 Expires(受客户端时间影响)
  • 协商缓存
    • Last-Modified/If-Modified-Since
    • ETag/If-None-Match(更精准,文件指纹强制更新)
  • 版本化与回滚
    • 文件名指纹(hash)+ CDN 缓存:更新即失效
  • 存储
    • localStorage/sessionStorage 存读小数据
    • IndexedDB/Cache Storage(配合 Service Worker 缓存接口/静态资源)
  • Service Worker/PWA
    • 离线缓存、预缓存关键静态资源、离线落地页

七、网络与后端协同(系统视角)

  • CDN:就近访问、缓存静态资源、智能压缩(Brotli/Gzip)
  • HTTP/2/3:多路复用、头部压缩、Server Push(H2 已不鼓励滥用 Push)
    • 现代场景中不再建议为“多路复用”而“域名分片”(HTTP/1.1 时代产物)
  • 压缩:Brotli > Gzip;开启静态资源压缩与按类型压缩
  • 预渲染与直出
    • SSR/SSG/ISR:首屏直出、SEO 友好、结合边缘节点(Edge Runtime)
    • Critical Rendering Path 精简:Critical CSS、延迟 Hydration、Streaming SSR(React 18/Suspense)
    • 图片与数据预取(结合路由/埋点预测)

八、首屏优化(大厂最关注)

  • SSR/SSG/ISR 与流式渲染:组件在服务端渲染,客户端接管更少的 JS
  • 骨架屏:避免白屏与布局跳动,降低 CLS
  • 渲染顺序控制
    • 首屏关键模块优先,低优先级资源 prefetch/lazy
    • 组件“按需 Hydration”(岛屿架构/Partial/延迟激活)
  • 数据就近:边缘缓存 + CDN KV/Edge DB,降低 RTT

九、易被忽视的“锦上添花”

  • 使用 will-change/contain 控制合成与布局影响范围(谨慎使用,避免内存增加)
  • 字体优化:font-display: swap、子集化字体、preload 字体并设置 crossorigin
  • 图片占位(LQIP/BlurHash):避免 CLS
  • 监控与回归
    • RUM(真实用户监控):收集 LCP/CLS/INP 与地理、设备维度
    • A/B 性能实验:验证优化是否真正有效

十、常见面试题“高分回答模板”

  • 问:如何系统性优化首屏?

    • 答:明确指标(LCP/CLS/TBT),落预算;SSR/SSG 输出 HTML,Critical CSS 内联;主 JS 分割 + 路由懒加载;关键图片 WebP/AVIF + preload 首图;脚本 defer/module;CDN + Brotli;通过 RUM 监控与 A/B 验证优化闭环。
  • 问:如何减少重排重绘?

    • 答:批量 DOM 更新(文档碎片/class 切换)、读写分离缓存布局、动画用 transform/opacity + rAF、避免强制同步布局(如频繁读取 offset*)、必要时下线节点后批量操作。
  • 问:asyncdefermodule 区别?

    • 答:async 并行下载立即执行(顺序不定);defer 并行下载按顺序、在 DOMContentLoaded 前执行;module 天生 defer,支持依赖与 Tree-shaking。
  • 问:HTTP 缓存怎么设计?

    • 答:静态资源走强缓存(指纹 + Cache-Control: max-age=31536000, immutable),变更即换名;HTML 不缓存或短缓存 + 协商缓存;配合 ETag 精准回源;CDN 边缘缓存命中。
  • 问:长列表如何优化?

    • 答:虚拟列表(windowing)、分页与分块渲染、占位骨架、避免复杂 item 渲染(memo)、滚动事件节流。

十一、示例清单(提及即加分)

  • 批量样式变更优先 className/cssText
  • 大量插入用 DocumentFragment
  • 读写分离、缓存 offsetTop 等布局信息
  • 动画优先 transform/opacity + rAF
  • 代码分割、路由懒加载、组件按需
  • 资源提示:preload/prefetch/preconnect/dns-prefetch
  • 图片:AVIF/WebP + srcset/sizes + loading="lazy" + fetchpriority
  • 字体:preload + font-display: swap
  • 缓存:强缓存 + 协商缓存 + 文件指纹 + Service Worker
  • 网络:CDN + Brotli + HTTP/2/3,避免域名分片
  • 首屏:SSR/SSG/ISR + Critical CSS + 流式渲染 + 骨架屏
  • 监控:Lighthouse/Chrome DevTools/WebPageTest + RUM

给不支持摇树的三方库(phaser) tree-shake?

为 Phaser 打造一个 Vite 自动 Tree-shaking 插件

前言

Phaser 是一款功能强大的 HTML5 游戏开发引擎,深受广大 Web 游戏开发者的喜爱。然而,它有一个众所周知的痛点:不支持 Tree-shaking。这意味着即使你的游戏只用到了引擎的一小部分功能,最终打包时也必须引入整个 Phaser 库,导致项目体积异常臃肿。对于追求极致加载速度的小游戏而言,这无疑是难以接受的。

本文将分享如何利用 ViteBabel,从零开始打造一个插件,实现对 Phaser 的自动化、智能化 Tree-shaking,让我们的项目彻底告别不必要的代码,大幅优化加载性能。

为什么 Phaser 不支持 Tree-shaking?

现代前端打包工具(如 Vite、Webpack)的 Tree-shaking 功能依赖于 ES6 模块的静态结构。然而,Phaser 的架构使其无法从中受益。

// Phaser 的 API 设计方式 (不支持 tree-shaking)
import Phaser from 'phaser'
const sprite = new Phaser.GameObjects.Sprite()  // ❌ 整个 Phaser 对象都被构建和包含

// 理想中支持 tree-shaking 的方式:
// import { Sprite } from 'phaser/gameobjects'  // ✅ 只导入 Sprite 模块
// const sprite = new Sprite()

// 即使你尝试解构导入,也无济于事:
import { Game } from 'phaser'
// 这背后,整个 Phaser 对象仍然被完整构建,Game 只是从中取出的一个属性。

官方的“自定义构建”方案及其痛点

Phaser 官方提供了一种手动剔除模块的自定义构建方案。其原理是在构建时,通过修改入口文件,手动注释掉不需要的大模块。 楼主之前也进行了实践:自定义构建方案实践

// phaser/src/phaser.js (入口文件简化示例)
var Phaser = {
    Actions: require('./actions'),
    Animations: require('./animations'),
    // ... 其他模块
//  手动注释掉物理引擎模块
//  Physics: require('./physics'),
//  手动注释掉插件管理模块
//  Plugins: require('./plugins'),
    // ...
};

这种方式虽然能减少体积,但操作起来却非常痛苦,有以下几个致命缺陷:

  1. 维护成本高:每次升级 Phaser 版本,都需要重新手动构建一次。
  2. 复用性差:不同项目用到的模块不同,就需要为每个项目维护一个定制版引擎。
  3. 开发流程繁琐:开发过程中新增了某个模块的功能,就必须重新构建,打断开发心流。
  4. 依赖关系复杂:Phaser 内部模块间存在复杂的耦合。手动剔除时,很容易误删被其他模块隐式依赖的模块,导致项目在运行时崩溃,需要大量试错才能找到最优组合。

这样的方式不仅效率低下,而且极易出错。当团队同时维护多款游戏时,很容易陷入不断重新构建引擎、不断排查运行时错误的泥潭。

那么,有没有一种一劳永逸、通用且自动化的解决方案呢?有的兄弟,有的。

解决方案:Vite + Babel 自动化 Tree-shaking 插件

我们的核心思路是:通过编写一个 Vite 插件,在项目构建时自动分析源码,找出项目中实际使用到的 Phaser API,然后动态生成一个定制版的 Phaser 入口文件,最后让 Vite 使用这个定制版文件来打包。

这样,我们就能在不侵入项目代码、不改变开发习惯的前提下,实现完美的 Tree-shaking。

本文环境

  • "vite": "^5.4.8"
  • "phaser": "3.86.0"

如何找到我们使用的模块?

要实现自动化,关键在于如何精确地找出代码中使用了哪些 Phaser 模块。答案就是大名鼎鼎的 Babel

Babel 可以将代码解析为AST,通过分析和遍历这棵树,我们可以拿到我们想要的信息。

代码 -> AST

我们使用 @babel/parser 将代码字符串解析为 AST。

import { parse } from '@babel/parser';

const ast = parse(code, {
  sourceType: 'module',
  plugins: ['typescript', 'jsx'], // 支持 TS 和 JSX 语法
});

例如,对于这样一段 Phaser 代码:

class MyScene extends Phaser.Scene {
  create() {
    this.add.sprite(100, 100, 'player');
  }
}

它对应的 AST 结构(简化后)大致如下:

Program
└── ClassDeclaration (class MyScene...)
    ├── Identifier (MyScene)
    ├── Super (Phaser.Scene)
    └── ClassBody
        └── MethodDefinition (create)
            └── BlockStatement
                └── ExpressionStatement
                    └── CallExpression (this.add.sprite(...))
                        └── MemberExpression (this.add.sprite)
                            ├── MemberExpression (this.add)
                            │   ├── ThisExpression (this)
                            │   └── Identifier (add)
                            └── Identifier (sprite)

遍历 AST,捕获 Phaser API

有了 AST,我们就可以使用 @babel/traverse 遍历它,找出所有 Phaser 相关的 API 调用。我们的分析主要关注以下三种AST节点:

  1. ClassDeclaration:识别继承自 Phaser.Scene 的类,这是我们分析的起点和主要上下文。
  2. MemberExpression:捕获所有属性访问,例如 this.add.spritePhaser.GameObjects.Text,这是最常见的 API 使用方式。
  3. NewExpression:捕获构造函数调用,如 new Phaser.Game()

下面是分析过程的伪代码:

// traverse(ast, visitors)

const visitors = {
  // 1. 识别 Phaser 场景
  ClassDeclaration: (classPath) => {
    const superClass = classPath.node.superClass;
    // 检查父类是否是 Phaser.Scene
    if (isPhaserScene(superClass)) {
      recordUsage('Scene'); // 记录 Scene 模块被使用
      isInPhaserScene = true; // 标记我们进入了 Phaser 场景的上下文
      // 继续遍历该类的内部
      classPath.traverse(sceneVisitors);
      isInPhaserScene = false; // 退出时恢复标记
    }
  },
  
  // 2. 在全局捕获 new Phaser.Game()
  NewExpression: (path) => {
    if (isNewPhaserGame(path.node)) {
      recordUsage('Game');
    }
  },
  
  // 3. 在全局捕获 Phaser.Math.Between() 等静态调用
  MemberExpression: (path) => {
    // 将节点转换为字符串路径,如 "Phaser.Math.Between"
    const memberPath = nodeToPath(path.node);
    if (memberPath.startsWith('Phaser.')) {
      recordUsage(memberPath);
    }
  }
};

const sceneVisitors = {
  // 4. 在场景内部,捕获 this.add.sprite() 等调用
  MemberExpression: (path) => {
    // 检查是否是 this.xxx 的形式
    if (isInPhaserScene && path.node.object.type === 'ThisExpression') {
      analyzeThisChain(path.node); // 分析 this 调用链
    }
  }
};

对于 this.add.sprite 这样的链式调用,我们会:

  1. 识别到基础属性是 add。通过预设的 SCENE_CONTEXT_MAP 映射表,我们知道 this.add 对应 GameObjects.GameObjectFactory 模块。
  2. 识别到调用的方法是 sprite。我们约定 add.sprite 对应 GameObjects.Sprite 这个游戏对象本身,以及 GameObjects.Factories.Sprite 这个工厂类。

通过以上步骤,我们就能将源码中所有对 Phaser 的使用,精确地映射到其内部的模块路径上,例如 Scene, Game, GameObjects.Sprite 等等。

插件集成:Hook 调用顺序踩坑

image.png

在 Vite (Rollup) 插件中,标准的处理流程是 resolveId -> load -> transform。但这个流程在这里会遇到一个“鸡生蛋还是蛋生鸡”的悖论:

  • 我们必须在 load('phaser') 钩子中返回定制版的 Phaser 代码。
  • 但生成这段代码,需要先分析完整个项目(transform 阶段的工作),才能知道哪些模块被用到了。

为了解决这个问题,我们需要调整工作流,利用 buildStart 这个钩子:

正确的工作流: buildStart -> resolveId -> load

  1. buildStart 钩子:在构建开始时,这个钩子会最先被触发。我们在这里遍历项目所有源文件,一次性完成对 Phaser 使用情况的全局分析,并将结果缓存起来。
  2. resolveId 钩子:当 Vite 遇到 import 'phaser' 时,我们拦截它,并返回一个自定义的虚拟模块 ID,例如 \0phaser-optimized
  3. load 钩子:当 Vite 请求加载 \0phaser-optimized 这个虚拟模块时,我们根据 buildStart 阶段的分析结果,动态生成定制的 Phaser 入口文件内容,并将其作为代码返回。

这样,我们就完美地解决了时序问题。

踩坑细节:处理模块依赖与边界情况

这样的方式看起来很美好,实际上phaser内部子模块之间互相依赖,会有很多报错。我已经将某些必要模块、以及模块之间的依赖关系收集清楚,并且排除导致生产环境报错的phaser webgl debug依赖,如果有需要可以自取代码 之所以没有发布一个插件到npm,是因为我还没有大规模地验证过,只在自己使用到的phaser模块中做了适配。

不过我也写了一个顶级模块过滤版本,这个版本粒度会更粗,所以shake的效果会比较差,但是也更通用,更不容易报错,有需要的小伙伴可以自取。

成果

经过插件优化后,我们的示例项目构建产物体积对比非常显著:

  • 优化前 (全量引入): Vendor chunk Gzip前体积约为 1188KB+

img_v3_02pc_be05ba7b-388f-46e6-a1c1-b87ef78bbc4g.jpg

  • 优化后 (自动剔除): Vendor chunk Gzip前体积降至 690KB,节约~500KB (具体取决于项目复杂度)。

image.png

总结

通过 Babel AST 分析Vite 自定义插件,实现了一个非侵入式、全自动的 Phaser Tree-shaking 插件。这个方案不仅解决了官方手动构建方式的所有痛点,还让我们能更专注于游戏业务逻辑的开发,而无需为引擎的体积而烦恼。

如果文章有任何疏漏或错误之处,欢迎在评论区交流指正!

代码

代码1(更通用,shake能力较差,在我的场景下只shake了300kb gzip前)

import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import * as t from '@babel/types'
import { Plugin } from 'vite'
import { glob } from 'glob'
import * as fs from 'fs/promises'
import * as path from 'path'

class PhaserUsageAnalyzer {
  usage: Map<string, Map<string, Set<string>>>

  constructor() {
    this.usage = new Map()
  }
  analyzeCode(code: string, filePath: string) {
    try {
      const ast = parse(code, {
        sourceType: 'module',
        plugins: ['typescript', 'jsx'],
      })

      traverse(ast, {
        ImportDeclaration: (p) => {
          if (p.node.source.value === 'phaser') {
            this.analyzeImportDeclaration(p.node)
          }
        },
        MemberExpression: (p) => {
          this.analyzeMemberExpression(p.node)
        },
        NewExpression: (p) => {
          this.analyzeNewExpression(p.node)
        },
        CallExpression: (p) => {
          this.analyzeCallExpression(p.node)
        },
      })
    } catch (error) {
      console.error(`[PhaserOptimizer] Error analyzing ${filePath}:`, error)
    }
  }

  analyzeImportDeclaration(node: t.ImportDeclaration) {
    node.specifiers.forEach((spec) => {
      if (t.isImportDefaultSpecifier(spec)) this.recordUsage('core', 'Phaser', 'default-import')
      else if (t.isImportSpecifier(spec)) {
        const importedName = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value
        this.recordUsage('named-import', importedName, 'direct')
      }
    })
  }

  analyzeMemberExpression(node: t.MemberExpression) {
    const code = this.nodeToCode(node)

    const rendererMatch = code.match(/^Phaser.(WEBGL|CANVAS|AUTO)$/)
    if (rendererMatch) {
      this.recordUsage('config', rendererMatch[1].toLowerCase(), 'direct-access')
      return
    }

    const phaserStaticMatch = code.match(/^Phaser.(\w+).\w+/)
    if (phaserStaticMatch) {
      this.recordUsage('static', phaserStaticMatch[1].toLowerCase(), 'member-access')
      return
    }

    const thisPropertyMatch = code.match(/^this.(\w+)/)
    if (thisPropertyMatch) {
      const mainProp = thisPropertyMatch[1]
      if (mainProp === 'constructor') return
      this.recordUsage('property', mainProp, 'member-access')
    }
  }

  analyzeNewExpression(node: t.NewExpression) {
    const callee = this.nodeToCode(node.callee)
    if (callee === 'Phaser.Game') {
      this.recordUsage('core', 'Phaser', 'new-game')
    }
  }

  analyzeCallExpression(node: t.CallExpression) {
    const code = this.nodeToCode(node.callee)
    const chainCallMatch = code.match(/^this.(\w+).(\w+)/)
    if (chainCallMatch) {
      this.recordUsage('property', chainCallMatch[1], 'call')
    }
  }

  recordUsage(category: string, feature: string, context: string) {
    if (!this.usage.has(category)) this.usage.set(category, new Map())
    const categoryMap = this.usage.get(category)!
    if (!categoryMap.has(feature)) categoryMap.set(feature, new Set())
    categoryMap.get(feature)!.add(context)
  }

  getUsage() {
    const result: Record<string, Record<string, string[]>> = {}
    this.usage.forEach((categoryMap, category) => {
      result[category] = {}
      categoryMap.forEach((contexts, feature) => {
        result[category][feature] = Array.from(contexts)
      })
    })
    return {
      features: result,
    }
  }

  nodeToCode(node: t.Node): string {
    if (t.isMemberExpression(node)) {
      const object = this.nodeToCode(node.object)
      const property = t.isIdentifier(node.property) ? node.property.name : 'computed'
      return `${object}.${property}`
    }
    if (t.isIdentifier(node)) return node.name
    if (t.isThisExpression(node)) return 'this'
    return 'unknown'
  }
}

export function phaserOptimizer(): Plugin {
  let usageAnalyzer: PhaserUsageAnalyzer
  let cachedOptimizedModule: string | null = null

  // Strategy: Module-level tree shaking inspired by phaser.js
  const TOP_LEVEL_MODULES = {
    Actions: 'actions/index.js',
    Animations: 'animations/index.js',
    BlendModes: 'renderer/BlendModes.js',
    Cache: 'cache/index.js',
    Cameras: 'cameras/index.js',
    Core: 'core/index.js',
    Class: 'utils/Class.js',
    Create: 'create/index.js',
    Curves: 'curves/index.js',
    Data: 'data/index.js',
    Display: 'display/index.js',
    DOM: 'dom/index.js',
    Events: 'events/index.js',
    FX: 'fx/index.js',
    Game: 'core/Game.js',
    GameObjects: 'gameobjects/index.js',
    Geom: 'geom/index.js',
    Input: 'input/index.js',
    Loader: 'loader/index.js',
    Math: 'math/index.js',
    Physics: 'physics/index.js',
    Plugins: 'plugins/index.js',
    Renderer: 'renderer/index.js',
    Scale: 'scale/index.js',
    ScaleModes: 'renderer/ScaleModes.js',
    Scene: 'scene/Scene.js',
    Scenes: 'scene/index.js',
    Structs: 'structs/index.js',
    Textures: 'textures/index.js',
    Tilemaps: 'tilemaps/index.js',
    Time: 'time/index.js',
    Tweens: 'tweens/index.js',
    Utils: 'utils/index.js',
    Sound: 'sound/index.js',
  }

  const USAGE_TO_MODULE_MAP = {
    property: {
      add: 'GameObjects',
      make: 'GameObjects',
      tweens: 'Tweens',
      time: 'Time',
      load: 'Loader',
      input: 'Input',
      physics: 'Physics',
      sound: 'Sound',
      cameras: 'Cameras',
      anims: 'Animations',
      plugins: 'Plugins',
      scale: 'Scale',
      cache: 'Cache',
      textures: 'Textures',
      events: 'Events',
      data: 'Data',
      renderer: 'Renderer',
    },
    static: {
      math: 'Math',
      geom: 'Geom',
      // ... Can be extended if other static properties are used
    },
    'named-import': {
      Scene: 'Scene',
      Game: 'Game',
    },
  }

  // Core modules that are almost always necessary for a Phaser game to run
  const ALWAYS_INCLUDE = new Set([
    'Class',
    'Core',
    'Game',
    'Events',
    'Scenes',
    'Scene',
    'Utils',
    'GameObjects',
    'Cameras',
  ])

  const generateOptimizedPhaserModule = () => {
    const usage = usageAnalyzer.getUsage()
    const requiredModules = new Set<string>(ALWAYS_INCLUDE)

    // Analyze properties (e.g., this.tweens)
    const props = usage.features.property || {}
    // eslint-disable-next-line
    for (const p of Object.keys(props)) {
      // @ts-ignore
      if (USAGE_TO_MODULE_MAP.property[p]) {
        // @ts-ignore
        requiredModules.add(USAGE_TO_MODULE_MAP.property[p])
      }
    }

    // Analyze static access (e.g., Phaser.Math)
    const statics = usage.features.static || {}
    // eslint-disable-next-line
    for (const s of Object.keys(statics)) {
      // @ts-ignore
      if (USAGE_TO_MODULE_MAP.static[s]) {
        // @ts-ignore
        requiredModules.add(USAGE_TO_MODULE_MAP.static[s])
      }
    }

    // Analyze named imports (e.g., import { Scene })
    const namedImports = usage.features['named-import'] || {}
    // eslint-disable-next-line
    for (const i of Object.keys(namedImports)) {
      // @ts-ignore
      if (USAGE_TO_MODULE_MAP['named-import'][i]) {
        // @ts-ignore
        requiredModules.add(USAGE_TO_MODULE_MAP['named-import'][i])
      }
    }

    // The 'type' in game config implies renderer and scale modes
    if (usage.features.config) {
      requiredModules.add('Renderer')
      requiredModules.add('ScaleModes')
    }

    console.log('\n--- Phaser Optimizer (New Strategy) ---')
    console.log('[+] Required Modules:', Array.from(requiredModules).sort())

    const allModules = Object.keys(TOP_LEVEL_MODULES)
    const excludedModules = allModules.filter((m) => !requiredModules.has(m))
    console.log('[-] Excluded Modules:', excludedModules.sort())
    console.log('-------------------------------------\n')

    const includedEntries = Object.entries(TOP_LEVEL_MODULES).filter(([name]) => requiredModules.has(name))

    const imports = includedEntries.map(([name, p]) => `import ${name} from 'phaser/src/${p}';`).join('\n')
    const phaserObjectProperties = includedEntries.map(([name]) => `  ${name}`).join(',\n')

    const moduleContent = `
// === Optimised Phaser Module (Generated by vite-plugin-phaser-optimizer) ===
${imports}
import CONST from 'phaser/src/const.js';
import Extend from 'phaser/src/utils/object/Extend.js';

var Phaser = {
${phaserObjectProperties}
};

// Merge in the consts
Phaser = Extend(false, Phaser, CONST);

export default Phaser;
`
    return moduleContent
  }

  return {
    name: 'vite-plugin-phaser-optimizer-new',
    enforce: 'pre',

    config() {
      // 告诉 Vite 如何解析我们生成的深度导入
      return {
        resolve: {
          alias: {
            'phaser/src': path.resolve(process.cwd(), 'node_modules/phaser/src'),
          },
        },
      }
    },

    async buildStart() {
      usageAnalyzer = new PhaserUsageAnalyzer()
      cachedOptimizedModule = null
      console.log('🎮 Phaser Optimizer: Analyzing project with new strategy...')

      const files = await glob('src/**/*.{ts,tsx,js,jsx}', {
        ignore: 'node_modules/**',
      })

      await Promise.all(
        files.map(async (id: string) => {
          try {
            const code = await fs.readFile(id, 'utf-8')
            if (code.includes('phaser') || code.includes('Phaser')) {
              usageAnalyzer.analyzeCode(code, id)
            }
          } catch (e) {
            // ...
          }
        }),
      )

      cachedOptimizedModule = generateOptimizedPhaserModule()
    },

    resolveId(id) {
      if (id === 'phaser') {
        return '\0phaser-optimized'
      }
      return null
    },

    load(id) {
      if (id === '\0phaser-optimized') {
        return cachedOptimizedModule
      }
      return null
    },

    transform(code, id) {
      if (id.includes('renderer/webgl/WebGLRenderer.js')) {
        const pattern = /if\s*(typeof WEBGL_DEBUG)\s*{[\s\S]*?require('phaser3spectorjs')[\s\S]*?}/g
        return {
          code: code.replace(pattern, ''),
          map: null,
        }
      }
      return null
    },
  }
}

代码2,更细粒度的shake,可能需要对map做一些额外的适配,避免生产环境中的空指针

import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import * as t from '@babel/types'
import { Plugin } from 'vite'
import { glob } from 'glob'
import * as fs from 'fs/promises'
import * as path from 'path'

// Maps Phaser Scene properties (e.g., this.add) to their corresponding Phaser modules.
const SCENE_CONTEXT_MAP: Record<string, string> = {
  add: 'GameObjects.GameObjectFactory',
  make: 'GameObjects.GameObjectCreator',
  events: 'Events',
  game: 'Game',
  input: 'Input',
  load: 'Loader.LoaderPlugin',
  plugins: 'Plugins.PluginManager',
  registry: 'Data.DataManager',
  scale: 'Scale.ScaleManager',
  sound: 'Sound',
  textures: 'Textures.TextureManager',
  time: 'Time.Clock',
  tweens: 'Tweens.TweenManager',
  anims: 'Animations.AnimationManager',
  cameras: 'Cameras.Scene2D.CameraManager',
  data: 'Data.DataManager',
  sys: 'Scenes.Systems',
}

class PhaserUsageAnalyzer {
  usage: Set<string>
  private isInPhaserScene: boolean

  constructor() {
    this.usage = new Set()
    this.isInPhaserScene = false
  }
  analyzeCode(code: string, filePath: string) {
    try {
      const ast = parse(code, {
        sourceType: 'module',
        plugins: ['typescript', 'jsx'],
      })

      traverse(ast, {
        ClassDeclaration: (classPath) => {
          const superClass = classPath.node.superClass ? this.nodeToCode(classPath.node.superClass) : null
          const wasInScene = this.isInPhaserScene
          // Check for `extends Phaser.Scene` or `extends Scene` (if imported)
          if (superClass && superClass.endsWith('Scene')) {
            this.recordUsage('Scene')
            this.isInPhaserScene = true
          }
          classPath.traverse(this.visitors)
          this.isInPhaserScene = wasInScene
        },
      })
    } catch (error) {
      console.error(`[PhaserOptimizer] Error analyzing ${filePath}:`, error)
    }
  }

  // Define visitors for traversal inside a class
  private visitors = {
    MemberExpression: (p: any) => {
      this.analyzeMemberExpression(p.node)
    },
    NewExpression: (p: any) => {
      const callee = this.nodeToCode(p.node.callee)
      if (callee === 'Phaser.Game') {
        this.recordUsage('Game')
      }
    },
  }

  analyzeMemberExpression(node: t.MemberExpression) {
    const memberPath = this.getPhaserPath(node)
    if (memberPath) {
      // New: if it's a math/geom path, just record the parent as they are complex objects
      if (memberPath.startsWith('Phaser.Math.')) {
        this.recordUsage('Phaser.Math')
      } else if (memberPath.startsWith('Phaser.Geom.')) {
        this.recordUsage('Phaser.Geom')
      } else {
        this.recordUsage(memberPath)
      }
      return
    }

    if (this.isInPhaserScene) {
      // New: Smartly analyze chains like `this.add.rectangle`
      let currentNode: t.Node = node
      const chain: string[] = []
      while (t.isMemberExpression(currentNode) && t.isIdentifier(currentNode.property)) {
        chain.unshift(currentNode.property.name)
        currentNode = currentNode.object
      }

      if (t.isThisExpression(currentNode)) {
        const baseProp = chain[0]
        if (baseProp && SCENE_CONTEXT_MAP[baseProp]) {
          this.recordUsage(SCENE_CONTEXT_MAP[baseProp])
        }

        if ((baseProp === 'add' || baseProp === 'make') && chain.length > 1) {
          const goName = chain[1]
          // Capitalize the first letter, e.g., "rectangle" -> "Rectangle"
          const capitalizedGoName = goName.charAt(0).toUpperCase() + goName.slice(1)

          this.recordUsage(`GameObjects.${capitalizedGoName}`)

          if (baseProp === 'add') {
            this.recordUsage(`GameObjects.Factories.${capitalizedGoName}`)
          }

          if (baseProp === 'make') {
            this.recordUsage(`GameObjects.Creators.${capitalizedGoName}`)
          }
        }
      }
    }
  }

  getPhaserPath(node: t.Node): string | null {
    if (t.isMemberExpression(node)) {
      const propertyName = t.isIdentifier(node.property) ? node.property.name : null
      if (!propertyName) return null

      const parentPath = this.getPhaserPath(node.object)
      if (parentPath) {
        return `${parentPath}.${propertyName}`
      }
    } else if (t.isIdentifier(node) && node.name === 'Phaser') {
      return 'Phaser'
    }
    return null
  }

  recordUsage(p: string) {
    // We only care about the path from Phaser, e.g., "GameObjects.Sprite" from "Phaser.GameObjects.Sprite"
    const cleanedPath = p.replace(/^Phaser./, '')
    this.usage.add(cleanedPath)
  }

  getUsage() {
    return Array.from(this.usage)
  }

  nodeToCode(node: t.Node): string {
    if (t.isMemberExpression(node)) {
      const object = this.nodeToCode(node.object)
      const property = t.isIdentifier(node.property) ? node.property.name : 'computed'
      return `${object}.${property}`
    }
    if (t.isIdentifier(node)) return node.name
    if (t.isThisExpression(node)) return 'this'
    return 'unknown'
  }
}

export function phaserOptimizer(): Plugin {
  let usageAnalyzer: PhaserUsageAnalyzer
  let cachedOptimizedModule: string | null = null

  // A detailed, nested map based on the official phaser.js structure
  const PHASER_MODULE_MAP = {
    Animations: 'animations/index.js',
    BlendModes: 'renderer/BlendModes.js',
    Cache: 'cache/index.js',
    Cameras: { Scene2D: 'cameras/2d/index.js' },
    Core: 'core/index.js',
    Class: 'utils/Class.js',
    Data: 'data/index.js',
    Display: { Masks: 'display/mask/index.js' },
    DOM: 'dom/index.js',
    Events: {
      EventEmitter: 'events/EventEmitter.js',
    },
    FX: 'fx/index.js',
    Game: 'core/Game.js',
    GameObjects: {
      DisplayList: 'gameobjects/DisplayList.js',
      GameObjectCreator: 'gameobjects/GameObjectCreator.js',
      GameObjectFactory: 'gameobjects/GameObjectFactory.js',
      UpdateList: 'gameobjects/UpdateList.js',
      Components: 'gameobjects/components/index.js',
      BuildGameObject: 'gameobjects/BuildGameObject.js',
      BuildGameObjectAnimation: 'gameobjects/BuildGameObjectAnimation.js',
      GameObject: 'gameobjects/GameObject.js',
      Graphics: 'gameobjects/graphics/Graphics.js',
      Image: 'gameobjects/image/Image.js',
      Layer: 'gameobjects/layer/Layer.js',
      Container: 'gameobjects/container/Container.js',
      Rectangle: 'gameobjects/shape/rectangle/Rectangle.js',
      Sprite: 'gameobjects/sprite/Sprite.js',
      Text: 'gameobjects/text/Text.js',
      Factories: {
        Graphics: 'gameobjects/graphics/GraphicsFactory.js',
        Image: 'gameobjects/image/ImageFactory.js',
        Layer: 'gameobjects/layer/LayerFactory.js',
        Container: 'gameobjects/container/ContainerFactory.js',
        Rectangle: 'gameobjects/shape/rectangle/RectangleFactory.js',
        Sprite: 'gameobjects/sprite/SpriteFactory.js',
        Text: 'gameobjects/text/TextFactory.js',
      },
      Creators: {
        Graphics: 'gameobjects/graphics/GraphicsCreator.js',
        Image: 'gameobjects/image/ImageCreator.js',
        Layer: 'gameobjects/layer/LayerCreator.js',
        Container: 'gameobjects/container/ContainerCreator.js',
        Rectangle: 'gameobjects/shape/rectangle/RectangleCreator.js',
        Sprite: 'gameobjects/sprite/SpriteCreator.js',
        Text: 'gameobjects/text/TextCreator.js',
      },
    },
    Geom: 'geom/index.js',
    Input: 'input/index.js',
    Loader: {
      LoaderPlugin: 'loader/LoaderPlugin.js',
      FileTypes: {
        AnimationJSONFile: 'loader/filetypes/AnimationJSONFile.js',
        AtlasJSONFile: 'loader/filetypes/AtlasJSONFile.js',
        AudioFile: 'loader/filetypes/AudioFile.js',
        AudioSpriteFile: 'loader/filetypes/AudioSpriteFile.js',
        HTML5AudioFile: 'loader/filetypes/HTML5AudioFile.js',
        ImageFile: 'loader/filetypes/ImageFile.js',
        JSONFile: 'loader/filetypes/JSONFile.js',
        MultiAtlasFile: 'loader/filetypes/MultiAtlasFile.js',
        PluginFile: 'loader/filetypes/PluginFile.js',
        ScriptFile: 'loader/filetypes/ScriptFile.js',
        SpriteSheetFile: 'loader/filetypes/SpriteSheetFile.js',
        TextFile: 'loader/filetypes/TextFile.js',
        XMLFile: 'loader/filetypes/XMLFile.js',
      },
      File: 'loader/File.js',
      FileTypesManager: 'loader/FileTypesManager.js',
      GetURL: 'loader/GetURL.js',
      MergeXHRSettings: 'loader/MergeXHRSettings.js',
      MultiFile: 'loader/MultiFile.js',
      XHRLoader: 'loader/XHRLoader.js',
      XHRSettings: 'loader/XHRSettings.js',
    },
    Math: 'math/index.js',
    Plugins: 'plugins/index.js',
    Renderer: 'renderer/index.js',
    Scale: 'scale/index.js',
    ScaleModes: 'renderer/ScaleModes.js',
    Scene: 'scene/Scene.js',
    Scenes: {
      ScenePlugin: 'scene/ScenePlugin.js',
    },
    Structs: 'structs/index.js',
    Textures: 'textures/index.js',
    Time: {
      Clock: 'time/Clock.js',
    },
    Tweens: {
      TweenManager: 'tweens/TweenManager.js',
    },
    Sound: 'sound/index.js', // Added based on conditional require
  }

  // Core modules that are almost always necessary for a Phaser game to run
  const ALWAYS_INCLUDE = new Set([
    'Game',
    'Core',
    'Events',
    'Scenes.Systems',
    'Scenes.ScenePlugin',
    'Scene',
    'GameObjects.Components',
    'GameObjects.GameObjectFactory',
    'GameObjects.UpdateList',
    'GameObjects.DisplayList',
    'Loader.LoaderPlugin',
    'Loader.FileTypes.AnimationJSONFile',
    'Loader.FileTypes.AtlasJSONFile',
    'Loader.FileTypes.AudioFile',
    'Loader.FileTypes.AudioSpriteFile',
    'Loader.FileTypes.HTML5AudioFile',
    'Loader.FileTypes.ImageFile',
    'Loader.FileTypes.JSONFile',
    'Loader.FileTypes.MultiAtlasFile',
    'Loader.FileTypes.PluginFile',
    'Loader.FileTypes.ScriptFile',
    'Loader.FileTypes.SpriteSheetFile',
    'Loader.FileTypes.TextFile',
    'Loader.FileTypes.XMLFile',
  ])

  const generateOptimizedPhaserModule = () => {
    const detectedPaths = usageAnalyzer.getUsage()
    const requiredPaths = new Set<string>(ALWAYS_INCLUDE)
    detectedPaths.forEach((p) => requiredPaths.add(p))

    console.log('\n--- Phaser Optimizer ---')
    console.log('[+] Detected Usage Paths:', detectedPaths.sort())

    const imports: string[] = []
    const phaserStructure: any = {}

    // Function to traverse the map and find the corresponding path
    const findPathInMap = (map: any, pathParts: string[]): string | null => {
      const result = pathParts.reduce((acc, part) => {
        if (acc === null) return null
        return acc[part] !== undefined ? acc[part] : null
      }, map)
      return typeof result === 'string' ? result : null
    }

    // Function to build the nested structure for the final Phaser object
    const buildNestedObject = (obj: any, pathParts: string[], moduleName: string) => {
      let current = obj
      for (let i = 0; i < pathParts.length - 1; i++) {
        const part = pathParts[i]
        if (!current[part]) {
          current[part] = {}
        }
        current = current[part]
      }
      current[pathParts[pathParts.length - 1]] = moduleName
    }

    const importedModules = new Map<string, string>()

    requiredPaths.forEach((modulePath) => {
      const parts = modulePath.split('.')
      const resolvedModulePath = findPathInMap(PHASER_MODULE_MAP, parts)

      if (resolvedModulePath) {
        // Create a unique, valid variable name for the import
        const moduleName = `Phaser_${parts.join('_')}`
        if (!importedModules.has(resolvedModulePath)) {
          // No more path guessing, use the explicit path from the map
          imports.push(`import ${moduleName} from 'phaser/src/${resolvedModulePath}';`)
          importedModules.set(resolvedModulePath, moduleName)
        }
        buildNestedObject(phaserStructure, parts, importedModules.get(resolvedModulePath)!)
      }
    })

    const includedModulePaths = Array.from(importedModules.keys())
    console.log('[+] Included Modules:', includedModulePaths.sort())

    // New logic for excluded modules
    const allPossibleModulePaths = new Set<string>()
    const flatten = (obj: any) => {
      Object.values(obj).forEach((value) => {
        if (typeof value === 'string') {
          allPossibleModulePaths.add(value)
        } else if (typeof value === 'object' && value !== null) {
          flatten(value)
        }
      })
    }
    flatten(PHASER_MODULE_MAP)

    const excludedModulePaths = [...allPossibleModulePaths].filter((p) => !includedModulePaths.includes(p))
    console.log('[-] Excluded Modules:', excludedModulePaths.sort())

    // Function to recursively generate the Phaser object string
    const generateObjectString = (obj: any, indent = '  '): string => {
      const entries: string[] = Object.entries(obj).map(([key, value]) => {
        if (typeof value === 'string') {
          return `${indent}${key}: ${value}`
        }
        return `${indent}${key}: {\n${generateObjectString(value, `${indent}  `)}\n${indent}}`
      })
      return entries.join(',\n')
    }

    const moduleContent = `
// === Optimised Phaser Module (Generated by vite-plugin-phaser-optimizer) ===
${imports.join('\n')}
import CONST from 'phaser/src/const.js';
import Extend from 'phaser/src/utils/object/Extend.js';

var Phaser = {
${generateObjectString(phaserStructure)}
};

// Merge in the consts
Phaser = Extend(false, Phaser, CONST);

export default Phaser;
globalThis.Phaser = Phaser;
`
    console.log('------------------------\n')
    return moduleContent
  }

  return {
    name: 'vite-plugin-phaser-optimizer',
    enforce: 'pre',

    config() {
      // 告诉 Vite 如何解析我们生成的深度导入
      return {
        resolve: {
          alias: {
            'phaser/src': path.resolve(process.cwd(), 'node_modules/phaser/src'),
          },
        },
      }
    },

    async buildStart() {
      usageAnalyzer = new PhaserUsageAnalyzer()
      cachedOptimizedModule = null
      console.log('🎮 Phaser Optimizer: Analyzing project...')

      const files = await glob('src/**/*.{ts,tsx,js,jsx}', {
        ignore: 'node_modules/**',
      })

      await Promise.all(
        files.map(async (id: string) => {
          try {
            const code = await fs.readFile(id, 'utf-8')
            if (code.includes('Phaser')) {
              usageAnalyzer.analyzeCode(code, id)
            }
          } catch (e) {
            // ...
          }
        }),
      )

      cachedOptimizedModule = generateOptimizedPhaserModule()
    },

    resolveId(id) {
      if (id === 'phaser') {
        return '\0phaser-optimized'
      }
      return null
    },

    load(id) {
      if (id === '\0phaser-optimized') {
        return cachedOptimizedModule
      }
      return null
    },

    transform(code, id) {
      if (id.includes('renderer/webgl/WebGLRenderer.js')) {
        const pattern = /if\s*(typeof WEBGL_DEBUG)\s*{[\s\S]*?require('phaser3spectorjs')[\s\S]*?}/g
        return {
          code: code.replace(pattern, ''),
          map: null,
        }
      }
      return null
    },
  }
}

node-sass

出现的问题: 切换各种node版本,node-sass出现下边问题

gyp ERR! cwd /Users/liuna/Desktop/关税宝/engine-web/node_modules/node-sass gyp ERR! node -v v14.21.3 gyp ERR! node-gyp -v v3.8.0 gyp ERR! not ok Build failed with error code: 1 npm ERR! code ELIFECYCLE npm ERR! errno 1 npm ERR! node-sass@4.14.1 postinstall: node scripts/build.js``

解决办法:

  1. 卸载node-sass
npm uninstall node-sass
rm -rf node_modules package-lock.json
  1. 安装 sass(兼容 node-sass API)
npm install sass@1.32.13 --save-dev
  1. 重新安装依赖
npm install

说明:

  • node-sass 已停止维护(官方在 2020 年后不再更新),且依赖 Python、Xcode 等编译环境,在现代系统(尤其是 M1/M2 Mac)上容易出现兼容性问题。
  • sass 是官方推荐的替代品,纯 JS 实现,无需任何编译工具,安装即能用,且与 node-sass 的 API 完全兼容(项目中 import 'node-sass' 可直接改为 import 'sass',若有使用的话)。

让el-table长个小脑袋,记住我的滚动位置

需求来源:
需求师:我说,老牧你这个页面再调整一下呗,我希望这个滚动条从详情页返回来的时候还能保持再当时的位置,你看这老是给我刷新置顶好麻烦。
我:行都行的,你是老大你说的算。(等着等我骑到你头上)【心理活动】

首先,我去网上查了一下(没做过先看看有没有现成的案例)。看了一圈大概都是keep-alive+data中的字段进行辅助。ok搞起!!!!!

第一步:keep-alive配置

<template>
    <div>
        <keep-alive>
            <router-view v-if="alivePath.includes($route.path)"></router-view>
        </keep-alive>
        <router-view v-if="!alivePath.includes($route.path)"></router-view>
    </div>
</template>
data(){
    return{
        alivePath:["/projectMaintenance"],
    }
}

第二步:对应页面位置记录

data(){
  return{
    scrollTop:0,
  }
},
methods:{
  restoreScroll() {
    if (this.scrollTop > 0) {
      setTimeout(() => {
        this.$refs.multipleTable.bodyWrapper.scrollTop=this.scrollTop
      }, 100);
    }
  },
},
activated() {
  // 当组件被重新激活时,恢复滚动位置
  this.$nextTick(() => {
    this.restoreScroll();
  });
},
beforeRouteLeave(to, from, next) {
  // 在离开前保存滚动位置
  if (this.$refs.multipleTable) {
    this.scrollTop = this.$refs.multipleTable.bodyWrapper.scrollTop;
  }
  next();
}

就这么点代码,你没看错哈哈哈哈。
有些地方我知道可能我写的不够好,所以我想说以上内容仅供参考

vue3高德地图api整合封装(自定义撒点、轨迹等)

背景

近期接到了一个关于地图的需求,大致分为:自定义地图撒点、撒点点击、聚合、地理逆解析、轨迹(轨迹回放/暂停动画/倍速/进度条拖拽)、输入提示、点位拾取等。基本上涵盖了大部分常见的高德地图api。在开发过程中,踩了不少坑,在求助chatGPT和查看官网API手册后,也算稀稀拉拉完成的1.0版本的开发。 注:自定义撒点的样式是根据点位的type来决定的,不同的类型对应的文字颜色和icon不同。icon图片放到项目目录中,只要api能访问到就行。 在此做一个总结,封装了所有用的的api。

截图

撒点、聚合

image.png

弹窗、显示车牌

image.png

轨迹回放

image.png

代码

类型约束

import "@amap/amap-jsapi-types"

export interface InitMapParams extends AMap.MapOptions {
/** 容器ID */
elId: string
}

export interface Markers {
id: string
position: [number, number]
type: "online" | "charging" | "stop" // 类型: 在线、充电
clickCallBack?: (params: any) => void
showCarId: boolean
}

1、起手一个class

export default class MapLoader {}

2、地图初始化、插件注入、地图销毁

这块就比较简单了,大概写一下

export default class MapLoader {
    private mapLoader: any
    private trafficLayer: any // 实时交通图层
    private labelMarkerMap: Map<string, any> = new Map() // 标记集合
    private infoWindow: any // 信息窗体
    private geocoder: any // 地理编码
    private autoComplete: any
    private defaultMarker: any // 默认点标记
    private overviewPolyline: any // 全览轨迹线
    private singleMarker: any // 单个点标记
    private cluster: any = null
    private clusterData: Array<{ lnglat: [number, number]; data: Markers }> = []

    public initMap(params: InitMapParams) {
if (!params.elId) {
console.error("elId is required")
return
}
;(window as any)._AMapSecurityConfig = {
securityJsCode: ""
}
AMapLoader.load({
key: "",
version: "2.0"
})
.then((AMap) => {
this.mapLoader = new AMap.Map(params.elId, {
// 设置地图容器id
viewMode: "2D", // 是否为3D地图模式
zoom: params.zoom ?? 11, // 初始化地图级别
center: params.center || [116.397428, 39.90923] // 初始化地图中心点位置
})
AMap.plugin(
[
"AMap.Scale",
"AMap.ToolBar",
"AMap.Geocoder",
"AMap.AutoComplete",
"AMap.MoveAnimation",
"AMap.MarkerCluster"
],
() => {
const scale = new AMap.Scale({
position: {
right: "20px",
top: "85vh"
}
})
this.mapLoader.addControl(scale)
const toolBar = new AMap.ToolBar({
//地图缩放插件
position: {
right: "25px",
top: "190px"
}
})
this.mapLoader.addControl(toolBar)
this.geocoder = new AMap.Geocoder({
city: "010" // city 指定进行编码查询的城市,支持传入城市名、adcode 和 citycode
})
const opt = {
city: "010"
}
this.autoComplete = new AMap.Autocomplete(opt)
}
)
})
.catch((e) => {
console.error(e)
})
}
        
        public destroyMap() {
            this.hasInitMap()
            this.mapLoader.destroy()
        }
}

3、交通图添加和移除

export default class MapLoader {
    // 加载实时交通图层
public loadTrafficLayer() {
this.hasInitMap()
if (this.trafficLayer) {
return
}
this.trafficLayer = new AMap.TileLayer.Traffic({
autoRefresh: true, //是否自动刷新
interval: 180 //刷新间隔,默认180s
})
this.mapLoader.add(this.trafficLayer)
}
// 移除实时交通图层
public removeTrafficLayer() {
this.hasInitMap()
this.mapLoader.remove(this.trafficLayer)
this.trafficLayer = null
}
}

4、批量添加点标记

这块需要注意的是:点标记是根据type来决定icon的;因为有聚合的需求,所以撒点是通过聚合的非聚合状态字段来完成的(最开始的版本是没有聚合的,所以直接遍历数据生成的海量点位)。代码中也有一些其他关于点位的方法(移除,获取),仅仅给大家提供个思路。
点位添加时可以传入一个回调,当点位点击时,会将相关的点位信息给回调,用于完成后续的流程,比如弹窗

// 用于生成单个点 DOM(根据 online/charging/stop 显示对应图标 & 车牌文字)
private buildSingleMarkerDOM(item: Markers): HTMLElement {
const wrap = document.createElement("div")
wrap.style.width = "80px"
wrap.style.height = "80px"
wrap.style.position = "relative"
wrap.style.transform = "translate(-35px,-40px)" // 居中
wrap.style.display = "flex"
wrap.style.justifyContent = "center"
const img = document.createElement("img")
img.src =
item.type === "online"
? "/green.png"
: item.type === "charging"
? "/purple.png"
: "/blue.png"
img.style.width = "70px"
img.style.height = "80px"
img.style.display = "block"
wrap.appendChild(img)

if (item.showCarId) {
const tag = document.createElement("div")
tag.innerText = item.id
tag.style.width = "100%"
tag.style.textAlign = "center"
tag.style.position = "absolute"
tag.style.left = "50%"
tag.style.top = "66px"
tag.style.transform = "translateX(-50%)"
tag.style.fontSize = "14px"
tag.style.color = "#fff"
tag.style.padding = "2px 6px"
tag.style.borderRadius = "4px"
tag.style.background =
item.type === "online"
? "rgba(36,152,48,0.80)"
: item.type === "charging"
? "rgba(125,36,152,0.80)"
: "rgba(57,90,192,0.80)"
wrap.appendChild(tag)
}
return wrap
}
// 创建/更新聚合(聚合时显示气泡,散开时显示自定义单点)
private createOrUpdateCluster() {
this.mapLoader.plugin(["AMap.MarkerCluster"], () => {
const options = {
gridSize: 80,
maxZoom: 18,
// 非聚合点(散开状态)
renderMarker: (ctx: any) => {
const item: Markers = ctx.data[0].data
const dom = this.buildSingleMarkerDOM(item)
ctx.marker.setContent(dom)
// 单击回调
ctx.marker.off("click") // 避免重复绑定
ctx.marker.on("click", () => item.clickCallBack?.(item.id))
},
// 聚合点
renderClusterMarker: (ctx: any) => {
const count = ctx.count
const div = document.createElement("div")
const size = count > 100 ? 56 : count > 50 ? 48 : 40
div.style.width = `${size}px`
div.style.height = `${size}px`
div.style.borderRadius = "50%"
div.style.display = "flex"
div.style.alignItems = "center"
div.style.justifyContent = "center"
div.style.color = "#fff"
div.style.fontSize = "14px"
div.style.boxShadow = "0 2px 8px rgba(0,0,0,.2)"
div.style.background =
count > 100
? "rgba(220,53,69,.85)"
: count > 50
? "rgba(255,153,0,.8)"
: "rgba(51,136,255,.75)"
div.innerText = String(count)
ctx.marker.setContent(div)
}
}

if (this.cluster) {
// 已有聚合 → 直接更新数据
this.cluster.setMap && this.cluster.setMap(null)
this.cluster = new (window as any).AMap.MarkerCluster(
this.mapLoader,
this.clusterData,
options
)
} else {
this.cluster = new (AMap as any).MarkerCluster(
this.mapLoader,
this.clusterData,
options
)
}
})
}

// 批量添加标记点
public addMarkers(markersParams: Markers[]) {
this.hasInitMap()
// 保存“点数据”(注意:这里是 dataOptions 数组,而不是 Marker 实例)
this.clusterData = markersParams.map((item) => ({
lnglat: item.position as [number, number],
data: item
}))
this.createOrUpdateCluster()
}
public removeAllMarkers() {
this.hasInitMap()
if (this.cluster) this.cluster.setData([])
this.clusterData = []
this.labelMarkerMap.clear()
}

public removeMarker(id: string) {
this.hasInitMap()
this.clusterData = this.clusterData.filter((p) => p.data.id !== id)
if (this.cluster) this.cluster.setData(this.clusterData)
this.labelMarkerMap.delete(id)
}

public getAllMarkers() {
this.hasInitMap()
return this.clusterData.map((p) => p.data)
}

5、展示弹窗

这个需求主要的是,因为涉及一些动态传参,如果直接使用html模版+模版字符串是没办法做的,所以这块是通过h函数渲染了vue组件

public showInfoWindow(params: any, closeWindow: Function) {
        if (this.infoWindow) {
                const { _originOpts } = this.infoWindow
                const oldCarData = _originOpts.content._vnode.props.carData
                if (oldCarData.id === params.id) {
                        return
                }
                this.closeInfoWindow()
        }
        const element = document.createElement("div")
        const _infoWindow = h(infoWIndow, {
                carData: params,
                closeInfoWindow: closeWindow
        })
        const app = createApp(_infoWindow)
        app.mount(element)
        this.infoWindow = new AMap.InfoWindow({
                isCustom: true, //使用自定义窗体
                content: element,
                offset: new AMap.Pixel(-230, -270)
        })
        this.infoWindow.open(this.mapLoader, [
                Number(params.lng),
                Number(params.lat)
        ])
}
public closeInfoWindow() {
        if (this.infoWindow) {
                this.infoWindow.close()
                this.infoWindow = null
        }
}

6、逆编码、点位拾取

这个就比较简单了

// 逆编码
public geocoderAddress(lng: string, lat: string) {
const lnglat = [Number(lng), Number(lat)]
return new Promise((resolve, reject) => {
this.geocoder.getAddress(lnglat, function (status: any, result: any) {
if (status === "complete" && result.info === "OK") {
// result为对应的地理位置详细信息
resolve(result.regeocode.formattedAddress)
} else {
reject(result)
}
})
})
}
// 输入提示
public placeSearch(keyword: string, callback: Function) {
this.autoComplete.search(keyword, function (status: any, result: any) {
callback(status, result)
})
}
// 地图点击,拾取点位,出参中通过e.lnglat.getLng()和e.lnglat.getLat()取经纬度
public mapClick(callback: Function) {
this.mapLoader.on("click", (e: any) => {
if (this.defaultMarker) {
this.mapLoader.remove(this.defaultMarker)
this.defaultMarker = null
}
const lng = e.lnglat.getLng()
const lat = e.lnglat.getLat()
this.defaultMarker = new AMap.Marker({
position: new AMap.LngLat(lng, lat)
})
this.mapLoader.add(this.defaultMarker)
callback(e)
})
}
// 销毁地图点击事件
public destroyMapClick() {
this.mapLoader.off("click", () => {})
}

7、轨迹回放、总览

这个是我写的比较久的,主要是进度发生变化,如何合并过去和未来的轨迹。移动时如何标记已经走过的路线。 思路是过去和未来分别存储两个字段,最后将两个字段同时渲染。其实整个轨迹回放用的只有高德动画api,只是处理动画过程中的进度,合并等问题。
在调用drawPolyline方法时,可以传入moving回调和moveEnd回调,用来获取动画过程中和动画结束。调用drawPolyline方法后会返回总距离、动画相关方法。用于外部控制动画
因为还有总览的需求,总览和轨迹回放还不一样,总览直接渲染路线就行,需要注意的是,总览和轨迹回放是互斥的,都需要将对方的变量置为初始值

/**
 * @description 轨迹回放
 * @param points 轨迹点数组
 * @param movingFn 轨迹移动时的回调函数
 * @param moveEnd 地图移动事件结束回调
 * @returns {totalDistance: number,startAnimation: Function,pauseAnimation: Function,continueAnimation: Function,moveToIndex: Function}
 */
public drawPolyline(points: any[], movingFn: Function, moveEnd: Function) {
this.hasInitMap()
this.mapLoader.on("moveend", () => {
moveEnd && moveEnd()
})
const marker: any = new AMap.Marker({
map: this.mapLoader,
position: points[0],
icon: "/car.png",
offset: new AMap.Pixel(-13, -26)
})
const polyline = new AMap.Polyline({
path: points,
showDir: true,
strokeColor: "#28F", //线颜色
// strokeOpacity: 1,     //线透明度
strokeWeight: 6, //线宽
strokeStyle: "solid" //线样式
})
const passedPolyline = new AMap.Polyline({
strokeColor: "red", //线颜色
strokeWeight: 6 //线宽
})
const totalDistance = AMap.GeometryUtil.distanceOfLine(points)
let currentPassedPolyline: any = []
marker.on("moving", (e: any) => {
const passedDistance = AMap.GeometryUtil.distanceOfLine([
...currentPassedPolyline,
...e.passedPath
])
const percent = Math.round((passedDistance / totalDistance) * 100)
// 返回两个参数,第一个参数为当前轨迹点,第二个参数为百分比
movingFn && movingFn({ movingData: e, percent })
passedPolyline.setPath([...currentPassedPolyline, ...e.passedPath])
this.mapLoader.setCenter(e.target.getPosition(), true)
})
this.mapLoader.add([polyline, passedPolyline])
this.mapLoader.setFitView()
return {
// 轨迹总距离
totalDistance,
// 开始轨迹动画,入参为:速度,起始索引
startAnimation: (speed = 1, fromIndex = 0) => {
marker.moveAlong(points.slice(fromIndex), {
duration: 500 / speed, //可根据实际采集时间间隔设置
// JSAPI2.0 是否延道路自动设置角度在 moveAlong 里设置
autoRotation: true
})
},
// 暂停动画
pauseAnimation: () => {
marker.pauseMove()
},
// 继续动画
continueAnimation: () => {
marker.resumeMove()
},
// 跳转到指定索引
moveToIndex: (index: number) => {
marker.setPosition(points[index])
currentPassedPolyline = points.slice(0, index + 1)
passedPolyline.setPath(currentPassedPolyline)
},
removePolyine: () => {
marker.pauseMove()
this.mapLoader.remove([polyline, passedPolyline])
this.mapLoader.remove(marker)
}
}
}
// 渲染所有轨迹-总览轨迹
public renderAllPolyline(points: any[]) {
this.hasInitMap()
this.overviewPolyline = new AMap.Polyline({
path: points,
showDir: true,
strokeColor: "#28F", //线颜色
// strokeOpacity: 1,     //线透明度
strokeWeight: 6, //线宽
strokeStyle: "solid" //线样式
})
this.overviewPolyline.setPath(points)
this.mapLoader.add(this.overviewPolyline)
this.mapLoader.setFitView()
}
// 移除总览轨迹
public removeOverviewPolyline() {
if (this.overviewPolyline) {
this.mapLoader.remove(this.overviewPolyline)
this.overviewPolyline = null
}
}

大体的MapLoader类就是这样,下边主要写一下轨迹回放

轨迹回放

template:

<div class="container>
        <div id="trajectory-map"></div>
<div class="video-tools" v-if="hasStart && !isAllPolyline">
<div class="play-icon">
<SvgPlay v-if="isPlay" @click="onPause" />
<SvgPause v-if="!isPlay" @click="onPlay" />
</div>
<el-slider v-model="sliderValue" @change="onSliderChange" size="large" />
<div class="slider-text">速度</div>
<el-select
v-model="speedValue"
@change="onSpeedChange"
style="width: 200px"
>
<el-option label="1倍" :value="1" />
<el-option label="3倍" :value="3" />
<el-option label="5倍" :value="5" />
</el-select>
<el-button type="danger" @click="onCancel">关闭</el-button>
</div>
</div>

script:
这块需要注意的是,请求数据后自动开始播放、进度条控制、速度控制三者之间的互斥关系以及理清进度Index和所有点位数据的关系。虽然使用的都是drawPolyline方法返回的方法。我的思路是: 1、请求数据后自动开始播放调用startAnimation 2、进度条变化时,根据滑动条百分比,对应到原始数据中,再调用moveToIndex方法,将原始数据分割,这样已走过的路和未走的路会有颜色上的区分

<script lang="ts" setup>
const mapLoader = ref<any>(null)
const isPlay = ref<boolean>(false)
const hasStart = ref<boolean>(false)
const sliderValue = ref<number>(0)
const speedValue = ref<number>(1)
const points = ref<any[]>([])
const mapPolylineFn = ref<any>({})
const currentIndex = ref<number>(0)
const needContinuePlay = ref<boolean>(false) // 暂停后,如果没有拖动进度条,则继续播放,否则执行startAnimation
const isAllPolyline = ref<boolean>(false)
const mapReady = ref<boolean>(false)
let pendingAction: (() => void) | null = null


function initMap() {
mapLoader.value = new MapLoader()
mapLoader.value.initMap({
elId: "trajectory-map",
zoom: 12
})
}

function getPointsById() {
return new Promise<void>((resolve, reject) => {
const params = {
// 请求参数
}
getCarHistory(params)
.then((res: any) => {
if (res.length === 0) {
reject()
}
const tempRes = JSON.parse(JSON.stringify(res))
tempRes.forEach((item: any) => {
item[0] = Number(item[0])
item[1] = Number(item[1])
})
points.value = tempRes
resolve()
})
.catch(() => {
reject()
})
})
}

function drawMoving({ percent }: any) {
sliderValue.value = percent
if (sliderValue.value === 100) {
isPlay.value = false
return
}
// 根据百分比更新当前索引
const targetIndex = Math.floor(points.value.length * percent)
currentIndex.value = targetIndex
}

function onPlay() {
isPlay.value = !isPlay.value
// 播放完后,重置数据
if (sliderValue.value >= 100) {
sliderValue.value = 0
currentIndex.value = 0
needContinuePlay.value = false
mapPolylineFn.value.moveToIndex(0)
}
if (needContinuePlay.value) {
mapPolylineFn.value.continueAnimation()
return
}
mapPolylineFn.value.startAnimation(speedValue.value, currentIndex.value)
}
function onPause() {
needContinuePlay.value = true
isPlay.value = !isPlay.value
mapPolylineFn.value.pauseAnimation()
}

function onSpeedChange() {
if (isPlay.value) {
isPlay.value = false
needContinuePlay.value = false
mapPolylineFn.value.pauseAnimation()
const targetIndex = Math.round(
(sliderValue.value / 100) * points.value.length
)
currentIndex.value = targetIndex
mapPolylineFn.value.moveToIndex(targetIndex)
}
}
/**
 * 滑块拖动时,暂停动画,并根据滑块值更新当前轨迹索引
 * 同时不需要继续执行动画,而是重新开始动画
 */
function onSliderChange(val: any) {
needContinuePlay.value = false
isPlay.value = false
mapPolylineFn.value.pauseAnimation()
const targetIndex = Math.round((val / 100) * points.value.length)
currentIndex.value = targetIndex
mapPolylineFn.value.moveToIndex(targetIndex)
}

// 全览
function renderAllPolyline() {
isAllPolyline.value = true
if (points.value.length === 0) {
goStart("fn")
} else {
hasStart.value = false
needContinuePlay.value = false
isPlay.value = false
mapPolylineFn.value && mapPolylineFn.value.removePolyine()
mapLoader.value.renderAllPolyline(points.value)
}
}
// 地图移动结束后重新执行轨迹绘制
function mapReadyFn() {
pendingAction && pendingAction()
pendingAction = null
}
// 开始,需要区分是总览函数调用还是按钮点击事件触发。如果是按钮触发,则需要将重置
function goStart(type: "btn" | "fn") {
if (type === "btn") {
mapPolylineFn.value && mapPolylineFn.value?.removePolyine?.()
isAllPolyline.value = false
speedValue.value = 1
currentIndex.value = 0
isPlay.value = false
}
if (searchForm.id === "" || searchForm.time.length === 0) {
ElMessage.error("请输入查询条件")
return
}
getPointsById().then(() => {
if (isAllPolyline.value) {
mapLoader.value.removeOverviewPolyline()
mapLoader.value.renderAllPolyline(points.value)
} else {
hasStart.value = true
mapPolylineFn.value = mapLoader.value.drawPolyline(
points.value,
drawMoving,
mapReadyFn
)
// 如果地图移动事件未完成,则缓存play方法,等待地图移动完成后再执行
if (mapReady.value) {
onPlay()
} else {
pendingAction = () => onPlay()
}
}
})
}
// 关闭
function onCancel() {
hasStart.value = false
isPlay.value = false
isAllPolyline.value = false
speedValue.value = 1
currentIndex.value = 0
mapPolylineFn.value.removePolyine()
}
// 下载
function downloadFn() {
// 下载。。。
}

onMounted(() => {
initMap()
})

watch(
() => route.query,
(query: any) => {
if (query.id) {
searchForm.id = query.id
searchForm.imei = query.imei ?? ""
}
},
{ immediate: true }
)
</script>

结束语

轨迹回放的相关代码如上。mapLoader中其他的方法在此就不做调用的示例了。代码写的还是不完善,请大家多多指教。这一篇纯是一个记录(以后不用重复造轮子了,能省则省) 完结

❌