普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月2日掘金 前端

桌面应用开发,Flutter 与 Electron如何选

作者 Karl_wei
2025年12月2日 01:13

前言:这一年来我基本处于断更的状态,我知道在AI时代,编码的成本已经变得越来越低,技术分享的流量必然会下降。但这依然是一个艰难的过程,日常斥责自己没有成长,没有作品。

除了流量问题、巨量的工作,更多的原因是由于技术栈的变化。我开始使用Electron编写一个重要的AI产品,并且在 Flutter 与 Electron 之间来回拉扯......

背景

我们对 Flutter 技术的应用,不仅是在移动端APP,在我们的终端设备也用来做 OS 应用,跨Android、Windows、Linux系统。
在 Flutter 上,我们是有所沉淀的,但是当我们决定研发一款重要的PC应用时,依然产生了疑问:Flutter 这门技术,真的能满足我们在核心桌面应用的研发需求吗?
最终,基于官方能力、技术生态、roadmap等一系列原因,我们放弃在核心应用上使用 Flutter,转而代之选择了 Electron

这篇文章将从这几个月使用 Electron 的切实体验,从不同角度,对 FlutterElectron 这两款支持跨端桌面应用开发技术,做一个详细的对比。

Flutter VS Electron

维度 Flutter Electron
发布时间 2021 年 3 月 宣布支持桌面端 2013 年 4 月发布,发布即支持
核心场景 移动APP跨端 桌面应用跨端
官方网站 flutter.dev www.electronjs.org
开发文档 docs.flutter.dev www.electronjs.org/docs
插件包管理 Pub(pub.dev),提供大量 UI 组件、工具类库 npm(www.npmjs.com),依赖前端生态,插件丰富(如 electron-builder 打包工具)
研发组织 Google Github

方案成熟度

毫无疑问,在方案成熟度上 Electron 是碾压 Flutter 的存在。

1. 多进程能力

  • Flutter 目前还是单进程的能力,只能通过创建 isolate 来实现部分耗时任务,但是内存也是不共享的。
  • Electron 集成了 Nodejs 服务,自带多进程的能力,且提供了完整的跨进程机制IPC「Inter-Process Communication」)。

2. 多窗口支持

  • Flutter 目前不支持多窗口。由于其是自绘引擎,本身还是依赖原生进程提供的桌面窗口,所以需要原生与 Flutter 引擎不断的进行沟通对接,才能很好的使用多窗口能力。
    目前官方只是提供了 demo 来验证多窗口的可行性,但截止发文还没有办法在公版试用。
  • Electron 将 Chromium 的核心模块打包到发行包中,借助浏览器的能力,可以随意开辟新的窗口(如: BrowserWindow

3. 开发语言

  • Flutter 使用dart语言开发,采用声明式UI进行布局,插件管理使用官方的 pub 社区,学习和使用成本不算高。
  • Electron 使用JavaScript/TypeScript + HTML/CSS 的前端技术栈进行开发,社区也完全跟前端一致,非常丰富但鱼龙混杂

4. 原生能力的支持

  • Flutter 本质是一个 UI 框架,原生能力需要通过编写插件去调用,或者通过 FFI 调用,成本是很高的,你很难找到一个懂多端原生技术的开发。
  • Electron 有 node 环境,node.js 很多原生模块,可以直接调用到系统的能力,非常的高效。

开发体验和技术生态

1. 调试工具

  • Flutter 的调试工具,主要是依赖 IDE 本身的断点调试能力,以及自研的Flutter Inspector、devTools。
    在UI定位、性能监控方面,基本可以满足。但由于是个 UI 框架,对于原生容器是无法进行调试的,这在混合开发过程中是个比较大的痛点。
  • Electron 就是个浏览器,对于主进程和node子进程,有 Inspect 的机制; UI 层就更方便了,就是浏览器的调试器一模一样。生产环境下调试成本也低。

2. 打包编译

Flutter 是通过自绘引擎生成原生应用包,而 Electron 是将网页技术(HTML/CSS/JS)包裹在 Chromium 内核中。

底层技术架构的区别,直接决定了 Electron 的打包相对 Flutter 有些困难,且包体积很大。

对比维度 Flutter Electron
打包原理 编译成目标平台的原生二进制代码,搭配自绘引擎(Skia) 封装 Chromium 内核 + Node.js 环境,运行网页资源
最终产物 与原生应用格式一致(如 .apk/.ipa/.exe) 包含浏览器内核的独立应用包
跨平台方式 一份代码编译成多平台原生包,需分别打包 一份代码打包成多平台包,内核随应用分发
应用体积 较小(基础包约 10-20MB) 较大(基础包约 50-100MB,内核占主要体积)

3. 官方和社区的活跃性

  • Flutter 官方在桌面端的推进很慢,很多基础能力都没有太多的推进。同时在 roadmap 中,重心都偏向移动端和 web 端。
  • Electron 由于产品的体量和成熟度,稳定的在更新,每个版本都会带来一些新的特性。 image.png

4. 研发团队

技能维度 Flutter Electron
核心语言 Dart,需理解其异步逻辑、Widget 组件化思想 JavaScript/TypeScript,前端开发者可无缝衔接
UI 技术 Flutter 内置 Widget 体系,需学习其布局(Row/Column)、状态管理(Provider/Bloc) HTML/CSS,可复用前端生态(Vue/React/Element UI 等)
原生交互 需了解 Android(Kotlin/Java)、iOS(Swift/OC)基础,复杂功能需写原生插件 依赖 Node.js 模块或现成插件,无需深入原生开发
工程化工具 依赖 Flutter CLI、Android Studio/Xcode(打包配置) 依赖 npm/yarn、webpack/vite(前端构建工具)

可以看出,Flutter至少需要 1-2 名熟悉 Dart 的开发者,还需要有原生开发能力,技术门槛是比较高的;而 Electron 以前端开发者为主,熟悉 Node.js 即可完成所有开发,是可以快速上手的

同时前端开发也比 Flutter 开发要更容易招聘

结语

笔者本身是 Flutter 的忠实维护者,我认为 Flutter 的 Impeller 图形渲染引擎将不断完善,能在各个端达到更好的渲染速度和效果;同时 Flutter 目前的多窗口方案,让我们可以充分的相信可以多个窗口共用一份内存,而不需要通过进程间通信机制

但是,在 Flutter 暂未成熟的阶段,桌面核心产品还是用 Electron 进行开发会更加合适。我们 也期待未来 Electron 可以多集成WebAssembly来提升计算密集型任务的性能,减少 Chromium 内核的高内存占用。

React-Draggable 快速上手指南

作者 土豆1250
2025年12月2日 00:30

简介

在现代Web应用开发中,拖拽交互已成为提升用户体验的重要功能之一。无论是任务管理面板、可视化编辑器还是动态布局系统,拖拽功能都能让界面更加直观和友好。

React-Draggable是一个专门为React应用设计的轻量级拖拽库,它提供了简单而强大的API,让开发者能够轻松地为组件添加拖拽功能。本文将详细介绍React-Draggable的使用方法,涵盖从基础安装到高级应用的各个方面。

为什么选择React-Draggable?

React-Draggable相比其他拖拽库具有以下优势:

  • 简单易用:API设计直观,学习曲线平缓
  • 性能优秀:基于CSS transform实现,性能开销小
  • 高度可定制:提供丰富的配置选项和事件回调
  • React原生:完美融入React生态系统
  • 轻量级:体积小,不影响应用加载速度

安装与配置

安装

使用npm或yarn安装React-Draggable:

npm install react-draggable
# 或者
yarn add react-draggable

基本导入

在组件中导入Draggable组件:

import Draggable from 'react-draggable';

基础用法

最简单的拖拽示例

import React from 'react';
import Draggable from 'react-draggable';

function SimpleDraggable() {
  return (
    <Draggable>
      <div style={{ 
        width: 200, 
        height: 200, 
        background: 'lightblue',
        cursor: 'move',
        padding: 20,
        borderRadius: 8
      }}>
        拖拽我!
      </div>
    </Draggable>
  );
}

export default SimpleDraggable;

控制拖拽方向

通过axis属性可以限制拖拽方向:

<Draggable axis="x">
  <div>只能水平拖拽</div>
</Draggable>

<Draggable axis="y">
  <div>只能垂直拖拽</div>
</Draggable>

<Draggable axis="both">
  <div>可以任意方向拖拽(默认)</div>
</Draggable>

设置拖拽边界

使用bounds属性限制拖拽范围:

// 限制在父元素内
<Draggable bounds="parent">
  <div>限制在父容器内</div>
</Draggable>

// 自定义边界
<Draggable bounds={{left: -100, top: -100, right: 100, bottom: 100}}>
  <div>限制在指定区域内</div>
</Draggable>

常见使用场景

1. 可拖拽的模态框

import React, { useState } from 'react';
import Draggable from 'react-draggable';

function DraggableModal() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div>
      <button onClick={() => setIsOpen(true)}>打开模态框</button>
      
      {isOpen && (
        <Draggable handle=".modal-header">
          <div className="modal" style={{
            position: 'fixed',
            width: 400,
            background: 'white',
            borderRadius: 8,
            boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
            zIndex: 1000
          }}>
            <div className="modal-header" style={{
              padding: '12px 16px',
              background: '#f0f0f0',
              cursor: 'move',
              borderRadius: '8px 8px 0 0'
            }}>
              拖拽标题栏移动
            </div>
            <div className="modal-body" style={{ padding: 16 }}>
              <p>这是一个可以拖拽的模态框内容</p>
              <button onClick={() => setIsOpen(false)}>关闭</button>
            </div>
          </div>
        </Draggable>
      )}
    </div>
  );
}

2. 可排序列表

import React, { useState } from 'react';
import Draggable from 'react-draggable';

function SortableList() {
  const [items, setItems] = useState([
    { id: 1, text: '项目 1' },
    { id: 2, text: '项目 2' },
    { id: 3, text: '项目 3' },
    { id: 4, text: '项目 4' }
  ]);

  const handleStop = (e, data, index) => {
    // 这里可以实现排序逻辑
    console.log(`项目 ${index} 移动到了新位置`);
  };

  return (
    <div style={{ maxWidth: 300, margin: '0 auto' }}>
      <h3>可排序列表</h3>
      {items.map((item, index) => (
        <Draggable
          key={item.id}
          axis="y"
          onStop={(e, data) => handleStop(e, data, index)}
        >
          <div style={{
            padding: '12px 16px',
            margin: '8px 0',
            background: '#f8f9fa',
            border: '1px solid #dee2e6',
            borderRadius: 4,
            cursor: 'move'
          }}>
            {item.text}
          </div>
        </Draggable>
      ))}
    </div>
  );
}

3. 拖拽上传区域

import React, { useState } from 'react';
import Draggable from 'react-draggable';

function DraggableUploadZone() {
  const [isDragging, setIsDragging] = useState(false);

  const handleDragStart = () => {
    setIsDragging(true);
  };

  const handleDragStop = () => {
    setIsDragging(false);
  };

  return (
    <Draggable
      onStart={handleDragStart}
      onStop={handleDragStop}
      defaultPosition={{ x: 100, y: 100 }}
    >
      <div style={{
        width: 300,
        height: 200,
        border: `2px dashed ${isDragging ? '#007bff' : '#ccc'}`,
        borderRadius: 8,
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        background: isDragging ? '#f8f9ff' : '#fafafa',
        cursor: 'move',
        transition: 'all 0.3s ease'
      }}>
        <div>
          <p>拖拽上传区域</p>
          <p style={{ fontSize: 12, color: '#666' }}>点击或拖拽文件到此处</p>
        </div>
      </div>
    </Draggable>
  );
}

4. 可拖拽的面板布局

import React, { useState } from 'react';
import Draggable from 'react-draggable';

function DashboardLayout() {
  const [panels, setPanels] = useState([
    { id: 'chart', title: '图表面板', x: 0, y: 0 },
    { id: 'stats', title: '统计面板', x: 400, y: 0 },
    { id: 'activity', title: '活动面板', x: 0, y: 300 }
  ]);

  const updatePanelPosition = (id, x, y) => {
    setPanels(panels.map(panel => 
      panel.id === id ? { ...panel, x, y } : panel
    ));
  };

  return (
    <div style={{ 
      position: 'relative', 
      width: '100%', 
      height: '600px',
      background: '#f5f5f5'
    }}>
      {panels.map(panel => (
        <Draggable
          key={panel.id}
          defaultPosition={{ x: panel.x, y: panel.y }}
          onStop={(e, data) => updatePanelPosition(panel.id, data.x, data.y)}
        >
          <div style={{
            position: 'absolute',
            width: 350,
            height: 250,
            background: 'white',
            borderRadius: 8,
            boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
            cursor: 'move'
          }}>
            <div style={{
              padding: '12px 16px',
              borderBottom: '1px solid #e8e8e8',
              fontWeight: 'bold'
            }}>
              {panel.title}
            </div>
            <div style={{ padding: 16, height: 'calc(100% - 50px)' }}>
              面板内容区域
            </div>
          </div>
        </Draggable>
      ))}
    </div>
  );
}

高级功能

事件处理

React-Draggable提供了丰富的事件回调:

<Draggable
  onStart={(e, data) => {
    console.log('拖拽开始', data);
  }}
  onDrag={(e, data) => {
    console.log('拖拽中', data);
  }}
  onStop={(e, data) => {
    console.log('拖拽结束', data);
  }}
>
  <div>可拖拽元素</div>
</Draggable>

事件回调中的data对象包含以下信息:

  • x, y: 当前位置坐标
  • deltaX, deltaY: 相对于上次位置的偏移量
  • lastX, lastY: 上次事件的位置
  • node: 被拖拽的DOM节点

控制拖拽行为

拖拽句柄

使用handle属性指定拖拽的触发区域:

<Draggable handle=".drag-handle">
  <div>
    <div className="drag-handle" style={{ cursor: 'move' }}>
      拖拽我
    </div>
    <div>内容区域</div>
  </div>
</Draggable>

取消拖拽

使用cancel属性指定不可拖拽的区域:

<Draggable cancel=".no-drag">
  <div>
    <div>可以拖拽这里</div>
    <button className="no-drag">点击按钮不会触发拖拽</button>
  </div>
</Draggable>

网格对齐

使用grid属性实现网格对齐拖拽:

<Draggable grid={[25, 25]}>
  <div>网格对齐拖拽</div>
</Draggable>

受控组件

使用受控模式精确控制组件位置:

import React, { useState } from 'react';
import Draggable from 'react-draggable';

function ControlledDraggable() {
  const [position, setPosition] = useState({ x: 0, y: 0 });

  const handleDrag = (e, data) => {
    setPosition({ x: data.x, y: data.y });
  };

  return (
    <Draggable
      position={position}
      onDrag={handleDrag}
    >
      <div>受控拖拽组件</div>
    </Draggable>
  );
}

性能优化

使用React.memo优化重渲染

import React, { memo } from 'react';
import Draggable from 'react-draggable';

const OptimizedDraggable = memo(({ children, ...props }) => {
  return (
    <Draggable {...props}>
      {children}
    </Draggable>
  );
});

限制拖拽频率

import React, { useRef } from 'react';
import Draggable from 'react-draggable';

function ThrottledDraggable() {
  const lastUpdate = useRef(0);

  const handleDrag = (e, data) => {
    const now = Date.now();
    if (now - lastUpdate.current > 16) { // 约60fps
      console.log('更新位置:', data.x, data.y);
      lastUpdate.current = now;
    }
  };

  return (
    <Draggable onDrag={handleDrag}>
      <div>优化性能的拖拽</div>
    </Draggable>
  );
}

最佳实践

1. 合理设置初始位置

使用defaultPosition而不是直接修改样式:

// 推荐
<Draggable defaultPosition={{ x: 100, y: 100 }}>
  <div>正确设置初始位置</div>
</Draggable>

// 不推荐
<div style={{ transform: 'translate(100px, 100px)' }}>
  <Draggable>
    <div>这样会有冲突</div>
  </Draggable>
</div>

2. 避免嵌套拖拽

不要在可拖拽元素内部再嵌套可拖拽元素:

// 不推荐
<Draggable>
  <div>
    <Draggable>
      <div>嵌套拖拽会导致问题</div>
    </Draggable>
  </div>
</Draggable>

3. 处理移动端触摸事件

确保在移动端也能正常工作:

<Draggable
  onTouchStart={(e) => {
    // 处理触摸开始事件
  }}
  onTouchEnd={(e) => {
    // 处理触摸结束事件
  }}
>
  <div>移动端友好的拖拽</div>
</Draggable>

4. 可访问性考虑

为拖拽元素添加适当的ARIA属性:

<Draggable>
  <div
    role="button"
    tabIndex={0}
    aria-label="可拖拽元素"
    onKeyDown={(e) => {
      // 支持键盘操作
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault();
        // 处理键盘拖拽逻辑
      }
    }}
  >
    可访问的拖拽元素
  </div>
</Draggable>

常见问题与解决方案

Q: 拖拽时出现闪烁或跳动?

A: 这通常是由于CSS样式冲突导致的。确保:

  1. 没有设置position: absoluteposition: fixed
  2. 父元素没有设置transform样式
  3. 检查是否有其他JavaScript在修改元素位置

Q: 拖拽不流畅?

A: 可以尝试以下优化:

  1. 使用grid属性减少位置更新频率
  2. 限制拖拽事件处理函数的复杂度
  3. 使用requestAnimationFrame优化动画

Q: 移动端拖拽无效?

A: 确保:

  1. 添加了适当的触摸事件处理
  2. 检查CSS中是否有touch-action: none
  3. 确认浏览器支持触摸事件

总结

React-Draggable是一个功能强大且易于使用的拖拽库,它能够帮助开发者快速实现各种拖拽交互功能。通过本文的介绍,你应该已经掌握了:

  • React-Draggable的基本安装和使用方法
  • 常见的拖拽场景实现
  • 高级功能和性能优化技巧
  • 最佳实践和常见问题解决方案

在实际项目中,合理运用这些知识可以大大提升应用的用户体验。记住要根据具体需求选择合适的配置,并始终关注性能和可访问性。

延伸阅读

【URP】Unity[内置Shader]地形光照TerrainLit

作者 SmalBox
2025年12月2日 00:29

【从UnityURP开始探索游戏渲染】专栏-直达

TerrainLit Shader是Unity URP(通用渲染管线)中专为地形系统设计的内置着色器,主要用于高效渲染大规模地形表面并支持多纹理混合。

作用与原理

核心功能‌:

  • 支持最多8层纹理混合,通过高度图遮罩图控制混合过渡
  • 采用基于物理的渲染(PBR)模型处理光照和材质反射
  • 优化了LOD(细节层次)系统,适应远距离地形渲染

发展历史

  • 2018年‌:随URP首次推出,仅支持4层纹理混合
  • 2019年‌:升级至支持8层混合,增加PBR支持
  • 2020年‌:优化GPU实例化,提升大规模地形渲染性能

技术原理‌:

  • 使用SplatMap技术混合多张纹理,通过RGBA通道存储混合权重
  • 在顶点着色阶段计算地形高度和法线,片段着色阶段进行纹理采样混合

SplatMap技术

SplatMap技术通过纹理的RGBA通道存储多层纹理权重信息,在片段着色器中实现动态混合。

存储机制

  • 通道分配‌:
    • 每个RGBA通道对应一个纹理层的权重值(0-1范围),例如R通道存储第1层权重,G通道存储第2层权重,依此类推。当需要超过4层时,会使用多张SplatMap(如第二张SplatMap的R通道存储第5层权重)。
  • 数据编码‌:
    • 权重值通常以8位精度存储(0-255映射到0.0-1.0),在着色器中通过归一化还原。例如,R通道值0.5表示第1层权重为50%。

着色器实现示例

以下是一个基于Unity的片段着色器代码片段,演示如何采样并混合4层纹理:

hlsl
half4 frag(v2f input) : SV_Target {
    // 采样SplatMap获取权重
    half4 splat = SAMPLE_TEXTURE2D(_SplatMap, sampler_SplatMap, input.uv);

    // 采样各层纹理
    half4 tex1 = SAMPLE_TEXTURE2D(_Layer1, sampler_Layer1, input.uv);
    half4 tex2 = SAMPLE_TEXTURE2D(_Layer2, sampler_Layer2, input.uv);
    half4 tex3 = SAMPLE_TEXTURE2D(_Layer3, sampler_Layer3, input.uv);
    half4 tex4 = SAMPLE_TEXTURE2D(_Layer4, sampler_Layer4, input.uv);

    // 动态混合(权重归一化处理)
    half sumWeights = splat.r + splat.g + splat.b + splat.a;
    half3 finalColor = (tex1.rgb * splat.r + tex2.rgb * splat.g +
                       tex3.rgb * splat.b + tex4.rgb * splat.a) / sumWeights;

    return half4(finalColor, 1.0);
}

性能优化

  • 纹理数组替代‌:

    • 使用Texture2DArray将多张纹理合并为单一资源,通过索引值(存储在SplatMap的R/G通道)选择纹理,减少采样次数。例如:

      hlsl
      half4 var_Main = SAMPLE_TEXTURE2D_ARRAY(_TexArray, sampler_TexArray, uv, splat.r * 255) * splat.b;
      half4 var_Sec = SAMPLE_TEXTURE2D_ARRAY(_TexArray, sampler_TexArray, uv, splat.g * 255) * (1 - splat.b);
      half4 finalRGB = var_Main + var_Sec;
      
  • 高度混合增强‌:

    • 结合高度图(存储在SplatMap的B通道)实现更自然的过渡效果,通过比较各层高度值动态调整权重。

限制与改进

  • 通道限制‌:传统SplatMap每张仅支持4层,超过时需要多张纹理,增加带宽压力。
  • 混合精度‌:8位通道可能导致可见的带状伪影,可通过16位浮点纹理或抖动技术缓解。

具体使用示例

基础配置

  • 创建Terrain对象
  • 在Inspector面板选择Material为"Custom"
  • 指定Shader为"Universal Render Pipeline/Terrain/Lit"

通过SplatMap控制纹理混合时,需在Terrain Layers中配置各层纹理的金属度/光滑度等PBR参数

  • 脚本控制混合‌:

    csharp
    // 动态修改第2层纹理权重
    Terrain terrain = GetComponent<Terrain>();
    float[,,] map = terrain.terrainData.GetAlphamaps(0, 0, 1, 1);
    map[0,0,1] = 0.5f;// 设置权重为50%
    terrain.terrainData.SetAlphamaps(0, 0, map);
    

Shader Graph应用

  • 扩展案例‌(雪地效果):
    • 创建Unlit Shader Graph,添加Height节点检测地形高度5
    • 使用Lerp节点混合雪地纹理和基础纹理:最终输出到Base Color和Normal通道
      • Height → Step → Lerp(T) 基础纹理 → Lerp(A) 雪地纹理 → Lerp(B)
  • 溶解效果移植‌:
    • 复制TerrainLit的混合逻辑到Shader Graph
    • 添加Noise节点连接Alpha通道实现地形局部溶解
    • 关键节点链:
      • Noise → Step → Alpha Clipping Color → Emission(边缘发光)

该着色器通过模块化设计平衡了性能与效果,在URP 12.x版本后已完全支持Shader Graph的自定义扩展


【从UnityURP开始探索游戏渲染】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

大量异步并发请求控制并发解决方案

作者 veneno
2025年12月1日 23:51

实现思路:

可以使用 Promise 和异步函数。手动实现一个同步队列

测试数据

const tasks = new Array(88).fill(0).map((_, i) => () => {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve(i)
    }, Math.random() * 1000)
  })
})

核心代码

function limitRequest(tasks, limit) {
  const queue = []
  let runingCount = 0
  function enqueque(task) {
    return new Promise((resolve, reject) => {
      queue.push({
        task,
        resolve,
        reject,
      })
      run()
    })
  }

  function run() {
    while (queue.length > 0 && runingCount < limit) {
      runingCount++
      const { task, resolve, reject } = queue.shift()
      task()
        .then((value) => {resolve(value);console.log(value)})
        .catch((err) => reject(err))
        .finally(() => {
          runingCount--
          run()
        })
    }
  }

  return Promise.all(tasks.map((task) => enqueque(task)))
}

测试代码

console.log(await limitRequest(tasks, 10))
昨天 — 2025年12月1日掘金 前端

Vue3 + Element Plus 动态菜单实现:一套代码完美适配多角色权限系统

作者 刘大华
2025年12月1日 19:50

今天分享一个基于Vue3Element Plus的动态菜单实现。这个方案很适用于需要权限管理的后台系统,能够根据用户角色权限显示不同的菜单项。

一、什么是动态菜单?为什么需要它?

在管理后台系统中,不同角色的用户通常需要不同的功能权限。比如:

  • 管理员可以访问所有功能
  • 编辑者只能管理内容
  • 查看者只能浏览数据

如果为每个角色单独开发一套界面,显然效率低下。动态菜单就是解决这个问题的方案——一套代码,根据不同用户角色显示不同的菜单结构

二、实现效果预览

我们先来看看最终实现的效果:

1动态菜单3.png

  1. 角色切换:右上角可以切换用户角色(管理员/编辑者/查看者)
  2. 菜单过滤:根据角色自动过滤无权限的菜单项
  3. 侧边栏折叠:支持展开/收起侧边栏
  4. 面包屑导航:显示当前页面位置

老样子,完整源码在文末获取哦~

三、核心实现原理

1. 菜单数据结构设计

合理的菜单数据结构是动态菜单的基础。我们的设计如下:

const menuData = ref([
  {
    id: 'dashboard',        // 唯一标识
    name: '仪表板',         // 显示名称
    icon: 'DataBoard',      // 图标
    route: '/dashboard',    // 路由路径
    roles: ['admin', 'editor', 'viewer']  // 可访问的角色
  },
  {
    id: 'content',
    name: '内容管理',
    icon: 'Document',
    roles: ['admin', 'editor'],
    children: [             // 子菜单
      {
        id: 'articles',
        name: '文章管理',
        route: '/articles',
        roles: ['admin', 'editor']
      }
      // ... 更多子菜单
    ]
  }
  // ... 更多菜单项
]);

这种结构的特点:

  • 支持多级嵌套菜单
  • 每个菜单项明确指定可访问的角色
  • 图标使用 Element Plus 的图标组件

2. 菜单过滤逻辑

核心功能是根据当前用户角色过滤菜单:

const filteredMenu = computed(() => {
  return menuData.value
    .map(item => {
      // 1. 检查主菜单权限
      if (!item.roles.includes(currentUser.value.role)) {
        return null;  // 无权限,过滤掉
      }
      
      // 2. 深拷贝菜单项(避免修改原始数据)
      const menuItem = { ...item };
      
      // 3. 如果有子菜单,过滤子菜单
      if (menuItem.children) {
        menuItem.children = menuItem.children.filter(
          child => child.roles.includes(currentUser.value.role)
        );
        
        // 如果子菜单全被过滤掉,主菜单也不显示
        if (menuItem.children.length === 0) {
          return null;
        }
      }
      
      return menuItem;
    })
    .filter(Boolean);  // 过滤掉null值
});

过滤过程详解

  1. 映射(map):遍历每个菜单项,返回处理后的菜单项或null
  2. 权限检查:检查当前用户角色是否在菜单项的角色列表中
  3. 子菜单过滤:对有子菜单的项,递归过滤无权限的子项
  4. 空子菜单处理:如果所有子项都被过滤,父项也不显示
  5. 最终过滤:用filter(Boolean)移除所有null值

计算属性(computed)的优势

  • 自动响应依赖变化(当用户角色变化时自动重新计算)
  • 缓存结果,避免重复计算

3. 用户角色管理

用户信息和角色切换的实现:

// 当前用户信息
const currentUser = ref({
  name: '管理员',
  role: 'admin',
  avatar: 'https://example.com/avatar.png'
});

// 处理角色切换
const handleRoleChange = (role) => {
  currentUser.value.role = role;
  
  // 角色切换后更新当前激活的菜单
  if (role === 'viewer') {
    // 查看者只能访问仪表板
    activeMenu.value = '/dashboard';
    currentPageTitle.value = '仪表板';
  } else {
    // 其他角色显示第一个可访问的菜单
    const firstMenu = findFirstAccessibleMenu();
    if (firstMenu) {
      activeMenu.value = firstMenu.route;
      currentPageTitle.value = firstMenu.name;
    }
  }
};

四、界面布局与组件使用

1. 整体布局结构

<div class="app-container">
  <!-- 侧边栏 -->
  <div class="sidebar" :class="{ collapsed: isCollapse }">
    <!-- Logo区域 -->
    <div class="logo-area">...</div>
    <!-- 菜单区域 -->
    <el-menu>...</el-menu>
  </div>
  
  <!-- 主内容区 -->
  <div class="main-content">
    <!-- 顶部导航 -->
    <div class="header">...</div>
    <!-- 页面内容 -->
    <div class="content">...</div>
    <!-- 页脚 -->
    <div class="footer">...</div>
  </div>
</div>

这种布局是管理后台的经典设计,具有清晰的视觉层次。

2. Element Plus 菜单组件使用

<el-menu
  :default-active="activeMenu"           <!-- 当前激活的菜单 -->
  class="el-menu-vertical"
  background-color="#001529"            <!-- 背景色 -->
  text-color="#bfcbd9"                  <!-- 文字颜色 -->
  active-text-color="#409EFF"           <!-- 激活项文字颜色 -->
  :collapse="isCollapse"                <!-- 是否折叠 -->
  :collapse-transition="false"          <!-- 关闭折叠动画 -->
  :unique-opened="true"                 <!-- 只保持一个子菜单展开 -->
>
  <!-- 菜单项渲染 -->
  <template v-for="item in filteredMenu" :key="item.id">
    <!-- 有子菜单的情况 -->
    <el-sub-menu v-if="item.children" :index="item.id">
      <!-- 标题区域 -->
      <template #title>
        <el-icon><component :is="item.icon" /></el-icon>
        <span>{{ item.name }}</span>
      </template>
      
      <!-- 子菜单项 -->
      <el-menu-item v-for="child in item.children" 
                   :key="child.id" 
                   :index="child.route"
                   @click="selectMenu(child)">
        {{ child.name }}
      </el-menu-item>
    </el-sub-menu>
    
    <!-- 没有子菜单的情况 -->
    <el-menu-item v-else :index="item.route" @click="selectMenu(item)">
      ...
    </el-menu-item>
  </template>
</el-menu>

关键点说明

  1. 动态组件<component :is="item.icon"> 实现动态图标渲染
  2. 条件渲染:使用 v-ifv-else 区分子菜单和单菜单项
  3. 循环渲染v-for 遍历过滤后的菜单数据
  4. 唯一key:为每个菜单项设置唯一的 :key="item.id" 提高性能

五、样式设计技巧

1. 侧边栏折叠动画

.sidebar {
  width: 240px;
  background-color: #001529;
  transition: width 0.3s;  /* 宽度变化动画 */
  overflow: hidden;
}

.sidebar.collapsed {
  width: 64px;
}

.logo-area .logo-text {
  margin-left: 10px;
  transition: opacity 0.3s;  /* 文字淡入淡出 */
}

.sidebar.collapsed .logo-text {
  opacity: 0;  /* 折叠时隐藏文字 */
}

2. 布局技巧

.app-container {
  display: flex;
  min-height: 100vh;  /* 全屏高度 */
}

.main-content {
  flex: 1;            /* 占据剩余空间 */
  display: flex;
  flex-direction: column;
  overflow: hidden;   /* 防止内容溢出 */
}

.content {
  flex: 1;            /* 内容区占据主要空间 */
  padding: 20px;
  overflow-y: auto;   /* 内容过多时滚动 */
}

使用 Flex 布局可以轻松实现经典的侧边栏+主内容区布局。

六、实际应用扩展建议

在实际项目中,你还可以进一步扩展这个基础实现:

1. 与路由集成

import { useRouter, useRoute } from 'vue-router';

const router = useRouter();
const route = useRoute();

// 菜单点击处理
const selectMenu = (item) => {
  // 路由跳转
  router.push(item.route);
};

// 根据当前路由设置激活菜单
watch(route, (newRoute) => {
  activeMenu.value = newRoute.path;
  // 根据路由查找对应的页面标题
  currentPageTitle.value = findTitleByRoute(newRoute.path);
});

2. 后端动态菜单

在实际项目中,菜单数据通常来自后端:

// 从API获取菜单数据
const fetchMenuData = async () => {
  try {
    const response = await axios.get('/api/menus', {
      params: { role: currentUser.value.role }
    });
    menuData.value = response.data;
  } catch (error) {
    console.error('获取菜单数据失败:', error);
  }
};

3. 权限控制增强

除了菜单过滤,还可以添加更细粒度的权限控制:

// 权限指令
app.directive('permission', {
  mounted(el, binding) {
    const { value: requiredRoles } = binding;
    const userRole = currentUser.value.role;
    
    if (!requiredRoles.includes(userRole)) {
      el.parentNode && el.parentNode.removeChild(el);
    }
  }
});

// 在模板中使用
<button v-permission="['admin', 'editor']">编辑内容</button>

总结

通过这个 Vue 3 + Element Plus 的动态菜单实现,我们学到了:

  1. 设计合理的菜单数据结构是动态菜单的基础
  2. 使用计算属性实现菜单过滤,自动响应角色变化
  3. 利用 Element Plus 组件快速构建美观的界面
  4. Flex 布局技巧实现响应式侧边栏
  5. 扩展思路,如路由集成、后端动态菜单等

这个实现方案具有很好的可扩展性,你可以根据实际需求进行调整和增强。

完整源码GitHub地址github.com/1344160559-…

你可以直接复制到HTML文件中运行体验。尝试切换不同的用户角色,观察菜单的变化,加深对动态菜单工作原理的理解。

本文首发于公众号:程序员刘大华,专注分享前后端开发的实战笔记。关注我,少走弯路,一起进步!

📌往期精彩

《SpringBoot+MySQL+Vue实现文件共享系统》

《这20条SQL优化方案,让你的数据库查询速度提升10倍》

《SpringBoot 动态菜单权限系统设计的企业级解决方案》

《Vue3和Vue2的核心区别?很多开发者都没完全搞懂的10个细节》

【鸿蒙开发案例篇】快速掌握使用NAPI调用C标准库的功能

2025年12月1日 19:35

大家好,我是 V 哥。今天我们来深入探讨在鸿蒙 6.0(API 21)开发中,如何通过 NAPI(Native API)框架调用 C 标准库的功能。NAPI 是连接 ArkTS 应用层与 C/C++ 原生代码的关键桥梁,能够有效提升计算密集型任务的执行效率。

联系V哥获取 鸿蒙学习资料

一、NAPI 基础与项目结构

技术架构
ArkTS 业务层 → NAPI 接口桥接 → C++ 原生逻辑 → C 标准库函数
NAPI 将 ECMAScript 标准中的数据类型(如 Number、String、Object)统一封装为 napi_value 类型,实现与 C/C++ 数据类型的双向转换。

项目结构(Native C++ 模板):

entry/src/main/
├── ets/
│   └── pages/
│       └── Index.ets          # ArkTS 交互界面
├── cpp/
│   ├── CMakeLists.txt         # CMake 编译配置
│   ├── hello.cpp             # NAPI 模块实现
│   └── types/
│       └── libhello/
│           ├── index.d.ts     # 类型声明文件
│           └── oh-package.json5

二、环境配置与依赖注入

  1. 模块配置oh-package.json5
    声明 NAPI 模块的依赖关系:

    {
      "dependencies": {
        "libhello": "file:./src/main/cpp/types/libhello"
      }
    }
    
  2. CMake 配置CMakeLists.txt
    链接 C 标准库并指定编译目标:

    cmake_minimum_required(VERSION 3.12)
    project(hello) 
    add_library(hello SHARED hello.cpp)
    target_link_libraries(hello PUBLIC libc.so)  # 链接 C 标准库
    

三、核心实现:从 C 标准库到 ArkTS

步骤 1:C++ 侧实现 NAPI 接口(hello.cpp

通过 hypot 函数(C 标准库数学函数)演示平方和计算:

#include <cmath>
#include "napi/native_node_api.h"

// 1. 封装 C 标准库函数
static napi_value CalculateHypot(napi_env env, napi_callback_info info) {
    napi_value result;
    napi_get_undefined(env, &result);
    
    // 2. 解析 ArkTS 传递的参数
    size_t argc = 2;
    napi_value args;
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    
    // 3. 类型转换:napi_value → C double
    double a, b;
    napi_get_value_double(env, args, &a);
    napi_get_value_double(env, args, &b);
    
    // 4. 调用 C 标准库函数
    double hypot_result = hypot(a, b);
    
    // 5. 返回结果给 ArkTS:C double → napi_value
    napi_create_double(env, hypot_result, &result);
    return result;
}

// 6. 模块导出声明
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
    napi_property_descriptor desc[] = {
        {"calculateHypot", nullptr, CalculateHypot, nullptr, nullptr, nullptr, napi_default, nullptr}
    };
    napi_define_properties(env, exports, sizeof(desc) / sizeof(desc), desc);
    return exports;
}
EXTERN_C_END

步骤 2:类型声明文件(index.d.ts

为 ArkTS 提供类型提示:

export const calculateHypot: (a: number, b: number) => number;

步骤 3:ArkTS 调用层(Index.ets

在 UI 中集成原生计算能力:

import { calculateHypot } from 'libhello';

@Entry
@Component
struct NAPIDemo {
  @State inputA: number = 3.0;
  @State inputB: number = 4.0;
  @State result: number = 0;

  build() {
    Column() {
      TextInput({ placeholder: '输入数值 A' })
        .onChange((value: string) => this.inputA = parseFloat(value))
      TextInput({ placeholder: '输入数值 B' })
        .onChange((value: string) => this.inputB = parseFloat(value))
      
      Button('计算平方根')
        .onClick(() => {
          // 调用 NAPI 封装的 C 标准库函数
          this.result = calculateHypot(this.inputA, this.inputB);
        })
      
      Text(`结果: ${this.result}`)
        .fontSize(20)
    }
  }
}

四、关键技术与异常处理

  1. 数据类型转换对照表

    C/C++ 类型 NAPI 转换接口 ArkTS 类型
    double napi_create_double() number
    int32_t napi_create_int32() number
    char* napi_create_string_utf8() string
    bool napi_get_boolean() boolean
  2. 错误处理机制
    在 C++ 侧添加 NAPI 状态检查:

    napi_status status = napi_get_value_double(env, args, &a);
    if (status != napi_ok) {
        napi_throw_error(env, nullptr, "参数解析失败");
        return nullptr;
    }
    

五、扩展场景:异步调用与回调函数

对于耗时操作(如图像处理),可通过 NAPI 实现异步调用:

// 在 C++ 侧创建异步工作线程
napi_create_async_work(
    env, nullptr, resource_name,
    [](napi_env env, void* data) {
        // 子线程中执行 C 标准库函数
    },
    [](napi_env env, napi_status status, void* data) {
        // 回调 ArkTS 传递的 Promise 对象
        napi_resolve_deferred(env, deferred, result);
    },
    data, &async_work
);

六、调试与性能优化建议

  1. 日志输出
    使用 hilog 在 C++ 侧打印调试信息:

    #include <hilog/log.h>
    OH_LOG_Print(LOG_APP, LOG_INFO, 0, "NAPI", "计算结果: %f", hypot_result);
    
  2. 内存管理

    • 避免在循环中频繁创建 napi_value 对象
    • 使用 napi_create_reference() 管理长期持有的对象

总结

通过 NAPI 调用 C 标准库的核心步骤包括:

  1. 环境配置:声明模块依赖与 CMake 编译规则
  2. 桥接实现:在 C++ 中封装原生函数并处理类型转换
  3. 类型声明:提供 ArkTS 可识别的接口定义
  4. 异常处理:添加状态检查与错误抛出机制

我是 V 哥,下期将解析如何通过 NAPI 实现 ArkTS 与 C++ 间的复杂对象传递(如结构体与回调函数)。关注我的专栏,解锁更多鸿蒙底层开发技巧!

IntersectionObserver:现代Web开发的交叉观察者

作者 Drift_Dream
2025年12月1日 18:28

引言

在Web开发中,我们经常需要知道某个元素是否进入了可视区域。传统的方式是通过监听scroll事件,但这种实现方式性能较差,容易造成页面卡顿。今天我们来学习一个现代化的解决方案——IntersectionObserver API

什么是IntersectionObserver?

IntersectionObserver(交叉观察者)是一个浏览器原生API,它可以异步观察目标元素与其祖先元素或视窗(viewport)的交叉状态。简单来说,就是当被观察的元素进入或离开可视区域时,它会自动通知我们。

为什么需要它?

想象一下,你要判断一个元素是否在屏幕内:

传统方式:监听scroll事件,频繁计算元素位置,性能开销大

IntersectionObserver:浏览器原生支持,异步处理,性能高效

基本用法

// 创建观察者实例
const observer = new IntersectionObserver(
  (entries) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) {
        console.log("元素可见了");
      } else {
        console.log("元素不可见了");
      }
    });
  },
  {
    root: document.querySelector(".container"), // 根元素,null表示视窗
    threshold: 0.5, // 阈值,触发回调的相交比例(0-1),可为数字或者数组[0, 0.25, 0.5, 0.75, 1],在0%,25%,50%...的时候都触发
    rootMargin: "0px", // 根元素的外边距
  }
);

// 开始观察目标元素
const target = document.querySelector(".target-element");
observer.observe(target);

document.querySelectorAll(".item").forEach((item) => {
  observer.observe(item);
});

使用案例

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>IntersectionObserver</title>
  </head>
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    .container {
      width: 800px;
      margin: 0 auto;
      height: 800px;
      overflow-y: auto;
      border: 1px solid #ccc;
      margin-top: 200px;
    }
    .item {
      height: 200px;
      margin-bottom: 10px;
      line-height: 200px;
      text-align: center;
      background-color: beige;
    }
    .item:last-child {
      margin-bottom: 0;
    }
    .item.visible {
      background-color: aqua;
    }
  </style>
  <body>
    <div class="container">
      <div class="item">元素1</div>
      <div class="item">元素2</div>
      <div class="item">元素3</div>
      <div class="item">元素4</div>
      <div class="item">元素5</div>
      <div class="item">元素6</div>
    </div>
  </body>
  <script>
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            entry.target.classList.add("visible");
          } else {
            entry.target.classList.remove("visible");
          }
        });
      },
      {
        root: document.querySelector(".container"),
        threshold: 0.5,
      }
    );
    document.querySelectorAll(".item").forEach((item) => {
      observer.observe(item);
    });
  </script>
</html>

image.png

Vue 3 实战应用

精简版滚动动画(类AOS)

<template>
  <div class="aos-container">
    <div
      v-for="(feature, index) in features"
      :key="feature.id"
      ref="featureRefs"
      class="feature-card"
      :class="{ animate: isFeatureVisible[index] }"
    >
      <h3>{{ feature.title }}</h3>
      <p>{{ feature.description }}</p>
    </div>
  </div>
</template>

<script lang="ts" setup>
import { ref, onMounted, nextTick, useTemplateRef } from "vue";

const features = ref([
  { id: 1, title: "特性一", description: "这是第一个特性的描述" },
  { id: 2, title: "特性二", description: "这是第二个特性的描述" },
  { id: 3, title: "特性三", description: "这是第三个特性的描述" },
  { id: 4, title: "特性四", description: "这是第四个特性的描述" },
  { id: 5, title: "特性五", description: "这是第五个特性的描述" },
  { id: 6, title: "特性六", description: "这是第六个特性的描述" },
  { id: 7, title: "特性七", description: "这是第七个特性的描述" },
  { id: 8, title: "特性八", description: "这是第八个特性的描述" },
  { id: 9, title: "特性九", description: "这是第九个特性的描述" },
  { id: 10, title: "特性十", description: "这是第十个特性的描述" },
  { id: 11, title: "特性十一", description: "这是第十一个特性的描述" },
  { id: 12, title: "特性十二", description: "这是第十二个特性的描述" },
]);

const isFeatureVisible = ref(Array(features.value.length).fill(false));

const featureRefs = useTemplateRef("featureRefs");

onMounted(async () => {
  await nextTick();
  featureRefs.value?.forEach((ref, index) => {
    const featureObserver = new IntersectionObserver((entries) => {
      if (entries[0]) isFeatureVisible.value[index] = entries[0].isIntersecting;
    });
    featureObserver.observe(ref);
  });
});
</script>

<style scoped>
.aos-container {
  padding: 20px;
  height: 800px;
  width: 1080px;
  margin: 0 auto;
  overflow-y: auto;
  overflow-x: hidden;
}

.feature-card {
  opacity: 0;
  transform: translateX(-50px);
  transition: all 0.6s ease;
  margin: 20px 0;
  padding: 20px;
  background: #f5f5f5;
  border-radius: 8px;
}

.feature-card.animate {
  opacity: 1;
  transform: translateX(0);
}
</style>

image.png

多说一句,如果不循环featureRefs.value,只使用一个Observer,还可以这么写。

const isFeatureVisible = ref(Array(features.value.length).fill(false));

const featureRefs = useTemplateRef("featureRefs");

let observer: null | IntersectionObserver = null;

onMounted(async () => {
  await nextTick();
  observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
      // 找到目标元素在 featureRefs 中的索引
      const index = Array.from(featureRefs.value!).indexOf(
        entry.target as HTMLDivElement
      );
      isFeatureVisible.value[index] = entry.isIntersecting;
    });
  });

  featureRefs.value?.forEach((ref) => {
    observer?.observe(ref);
  });
});

图片懒加载组件

组件代码

<template>
  <div class="lazy-image">
    <div v-if="!isLoaded" class="loading">
      <span>图片加载中...</span>
    </div>
    <img
      :src="isVisible ? src : placeholder"
      :alt="alt"
      :class="{ loaded: isVisible }"
      @load="onLoad"
      ref="imgElement"
    />
  </div>
</template>

<script lang="ts" setup>
import { ref, onMounted, onUnmounted } from "vue";

const props = defineProps({
  src: String,
  alt: String,
  placeholder: {
    type: String,
    default: "",
  },
});

const imgElement = ref<HTMLImageElement>();
const isVisible = ref(false);
const isLoaded = ref(false);

let observer: IntersectionObserver | null = null;

onMounted(() => {
  observer = new IntersectionObserver(
    (entries) => {
      if (entries[0] && entries[0].isIntersecting) {
        console.log("图片进入视口");
        isVisible.value = true;
        if (imgElement.value) {
          observer?.unobserve(imgElement.value);
        }
      }
    },
    { threshold: 0.1 }
  );

  if (imgElement.value) {
    observer.observe(imgElement.value);
  }
});

onUnmounted(() => {
  if (observer) {
    observer.disconnect();
  }
});

const onLoad = () => {
  console.log("图片加载完成");
  isLoaded.value = true;
};
</script>

<style lang="scss" scoped>
.lazy-image {
  position: relative;
  display: inline-block;
  img {
    transition: opacity 0.3s ease;
    max-width: 100%;
    height: auto;
  }

  img:not(.loaded) {
    opacity: 0.5;
  }

  img.loaded {
    opacity: 1;
  }

  .loading {
    position: absolute;
    top: 0;
    left: 0;
    text-align: center;
    background-color: #f5f5f5;
    white-space: nowrap;
  }
}
</style>

使用组件

<template>
  <div class="lazy-image-container">
    <div class="height-1600px"></div>
    <div class="lazy-image-item">
      <LazyImg src="https://picsum.photos/400/400" alt="随机图片" />
    </div>
  </div>
</template>

<script lang="ts" setup>
import LazyImg from "./LazyImg.vue";
</script>
<style lang="scss">
.lazy-image-container {
  height: 100%;
  width: 100%;
  overflow-y: auto;
  .height-1600px {
    height: 1600px;
  }
}
</style>

无限滚动示例

<template>
  <div ref="scrollContainer" class="infinite-scroll">
    <div class="items-list">
      <div v-for="item in visibleItems" :key="item.id" class="list-item">
        {{ item.content }}
      </div>
    </div>

    <!-- 哨兵元素,专门用于触发加载 -->
    <div ref="sentinel" class="sentinel" v-if="hasMore"></div>

    <div v-if="isLoading" class="loading">加载中...</div>
    <div v-if="!hasMore" class="no-more">没有更多内容了</div>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } from "vue";

interface Item {
  id: number;
  content: string;
}

// 模拟数据
const allItems = ref<Item[]>(
  Array.from({ length: 100 }, (_, i) => ({
    id: i + 1,
    content: `列表项 ${i + 1} - 这是一些示例内容,用于展示无限滚动功能`,
  }))
);

const visibleItems = ref<Item[]>([]);
const isLoading = ref<boolean>(false);
const hasMore = ref<boolean>(true);
const pageSize: number = 10;
let currentPage: number = 0;

// 加载数据
const loadMore = (): void => {
  if (isLoading.value || !hasMore.value) return;

  isLoading.value = true;

  setTimeout(() => {
    const start = currentPage * pageSize;
    const end = start + pageSize;
    const newItems = allItems.value.slice(start, end);

    if (newItems.length > 0) {
      visibleItems.value.push(...newItems);
      currentPage++;
      hasMore.value = end < allItems.value.length;
    } else {
      hasMore.value = false;
    }

    isLoading.value = false;
  }, 500);
};

// 初始加载
loadMore();

// 无限滚动逻辑
const scrollContainer = ref<HTMLDivElement | null>(null);
const sentinel = ref<HTMLDivElement | null>(null); // 哨兵元素
let observer: IntersectionObserver | null = null;

onMounted(async () => {
  await nextTick();

  observer = new IntersectionObserver(
    (entries: IntersectionObserverEntry[]) => {
      console.log(entries);
      // 只有当哨兵元素进入视口且不在加载状态时才触发
      if (
        entries[0] &&
        entries[0].isIntersecting &&
        !isLoading.value &&
        hasMore.value
      ) {
        loadMore();
      }
    },
    {
      threshold: 0.1,
      root: scrollContainer.value,
    }
  );

  if (sentinel.value) {
    observer.observe(sentinel.value);
  }
});

onUnmounted(() => {
  if (observer) {
    observer.disconnect();
  }
});
</script>
<style scoped>
.sentinel {
  height: 1px; /* 极小的高度,不影响布局 */
}
.infinite-scroll {
  height: 400px;
  overflow: auto;
  max-width: 600px;
  margin: 0 auto;
}

.items-list {
  margin-bottom: 20px;
}

.list-item {
  padding: 15px;
  margin: 10px 0;
  background: #f5f5f5;
  border-radius: 4px;
}

.load-trigger,
.loading,
.no-more {
  text-align: center;
  padding: 20px;
  color: #666;
}
</style>

image.png

广告曝光

<template>
  <div class="ad-container">
    <div v-for="ad in ads" :key="ad.id" ref="adRefs" class="ad-banner">
      <h3>{{ ad.title }}</h3>
      <p>{{ ad.description }}</p>
      <small>曝光次数: {{ ad.impressions }}</small>
    </div>

    <div class="stats">
      <h3>广告统计</h3>
      <p>总曝光次数: {{ totalImpressions }}</p>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, nextTick, useTemplateRef } from "vue";

const ads = ref([
  { id: 1, title: "广告一", description: "这是第一个广告", impressions: 0 },
  { id: 2, title: "广告二", description: "这是第二个广告", impressions: 0 },
  { id: 3, title: "广告三", description: "这是第三个广告", impressions: 0 },
  { id: 4, title: "广告四", description: "这是第四个广告", impressions: 0 },
  { id: 5, title: "广告五", description: "这是第五个广告", impressions: 0 },
]);

const adRefs = useTemplateRef("adRefs");
const observers = ref([]);

const totalImpressions = computed(() => {
  return ads.value.reduce((sum, ad) => sum + ad.impressions, 0);
});

onMounted(async () => {
  await nextTick();

  adRefs.value.forEach((el, index) => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting) {
          // 广告进入视口,记录曝光
          ads.value[index].impressions++;

          // 实际项目中这里可以发送统计请求
          console.log(`广告 ${ads.value[index].title} 曝光一次`);
        }
      },
      { threshold: 0.5 }
    );

    observer.observe(el);
    observers.value.push(observer);
  });
});
</script>

<style scoped>
.ad-container {
  max-width: 800px;
  height: 500px;
  overflow-y: auto;
  margin: 0 auto;
}

.ad-banner {
  height: 120px;
  margin: 20px 0;
  padding: 20px;
  background-image: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border-radius: 8px;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.stats {
  margin-top: 30px;
  padding: 20px;
  background: #f5f5f5;
  border-radius: 8px;
}
</style>

image.png

antd 4.x Tabs 点击阻止冒泡

2025年12月1日 18:20

一、场景

image.png

如上图所示,tab1-未回复,tab2-已回复+筛选条件仅看未处理
当在tab1时,点击tab2的仅看未处理checkbox,此时需要进行tab2的数据请求(请求已回复&未处理的数据)

二、基础实现

const [activeKey, setActiveKey] = useState('replied');

const defaultPageParams = {
    page: 1,
    rows: 5,
};

const getRepliedData = (params: any) => {
    const _params = {
        ...params
    }
    if (_params.handleStatus == null) {
        delete _params.handleStatus;
    }
    // 存一份params
    //...
}

const getNotReplyData = (params: any) => {
    //...
    // 存一份params
}

const handleFilterRepliedData = (e: CheckboxChangeEvent) => {
    const params: any = {
        ...defaultPageParams,
        handleStatus: e.target.checked ? 0 : null,
    };
    getRepliedData(params);
}

const getData = (key: string) => {
    const params: any = {
        ...defaultPageParams
    };
    if (key === 'replied') {
      getRepliedData(params);
    }
    if (key === 'not-reply') {
      getNotReplyData(params);
    }
};
<Tabs
    defaultActiveKey={'not-reply'}
    activeKey={activeKey}
    onChange={(key) => {
        setActiveKey(key);
        getData(key);
    }}
    items={[
        {
          key: 'not-reply',
          label: '未回复',
          children: (
            <div>未回复内容</div>
          ),
        },
        {
          key: 'replied',
          label: (
            <Space>
                <div>已回复</div>
                <Checkbox onChange={handleFilterRepliedData}>
                  僅看未處理
                </Checkbox>
            </Space>
          ),
          children: (
            <div>已回复内容</div>
          ),
        },
    ]}
/>

三、基础实现存在的问题

在tab1直接点击tab2的checkbox,会执行Checkbox的onChange事件,也会执行Tabs的onChange事件,会导致请求了两次接口同时页面上会有数据闪现现象,若Tabs的请求更慢,可能还会导致数据查询异常。

四、优化实现

关键代码:

  1. Checkbox包一层:
    <span style={{ pointerEvents: 'none' }} onClick={(e) => e.stopPropagation()}>
  2. Checkbox加style:
    style={{ pointerEvents: 'auto' }}
  3. Checkbox onChange方法添加代码:
    e.stopPropagation();
    setActiveKey('replied');
// 1、改Checkbox的onChange方法
const handleFilterRepliedData = (e: CheckboxChangeEvent) => {
    // 避免在未命中該tab的情況下,直接點擊該checkbox請求了兩次接口導致的頁面內容閃現問題
    e.stopPropagation();
    setActiveKey('replied');
    
    //...
}

<Tabs
    //...
    items={[
        //...
        {
          key: 'replied',
          label: (
            <Space>
                <div>已回复</div>
                {/* 2、改Checkbox视图,解決事件冒泡到tabs的onChange事件 */}
                <span style={{ pointerEvents: 'none' }} onClick={(e) => e.stopPropagation()}>
                    <Checkbox style={{ pointerEvents: 'auto' }} onChange={handleFilterRepliedData}>
                      僅看未處理
                    </Checkbox>
                </span>
            </Space>
          ),
          children: (
            <div>已回复内容</div>
          ),
        },
    ]}
/>

GeoJSON 介绍:Web 地图数据的通用语言

作者 东东233
2025年12月1日 18:12

GeoJSON 介绍:Web 地图数据的通用语言

引言

GeoJSON 是一套基于 JSON 格式的地理空间数据编码标准,具有轻量、易读、易于在 Web 应用中解析和传输等优势,它是 Web 地图库(如 Leaflet, Mapbox, OpenLayers)事实上的标准数据格式,我最近在看 OpenLayers,在加载外部数据的时候都是用 GeoJSON,于是便了解了一下,这里是最新规范的英文文档、英语好的可以直接跳转这里

GeoJSON 基本构成

GeoJSON 本质上就是一个标准的 JSON 对象,所有 GeoJSON 对象必须有一个 "type" 成员,"type"表示当前 JSON 描述的类型,这里分为基本几何类型和特征类型。

基本几何类型快速理解就是描述地图上形状的类型,“type” 取值包括 点 (Point)、线(LineString)、区域(Polygon)以及他们的多重类型 MultiPoint, MultiLineString, MultiPolygon,其“coordinates” 属性用来标注地理坐标位置(经纬度基于 WGS84 坐标系)

特征类型即带有属性(properties)的类型,“type” 取值包括 Feature 和 FeatureCollection

基本几何类型

Point(点)

表示地图上的一个点,结构如下

{
  "type": "Point",
  "coordinates": [106.56, 29.57]
}

LineString (线)

表示地图上的一条线,可以理解为有多个点连接组成,“coordinates” 为一个二维数组

{
  "type": "LineString",
  "coordinates": [
    [106.51398305678325, 29.523171668355733],
    [106.51453664249686, 29.523092142346467],
    [106.51566579820047, 29.522995404990354]
  ]
}

Polygon (多边形)

表示地图上的一个多边形,“coordinates” 由多个环组成、环即由多个点组成的闭合的路径、最后一个点表示闭合点,注意这里可能包含多个环形元素,第一个环表示外部环、其余表示内部环,比如空心圆就由一个内部环和一个外部环组成、外部环通常由逆时针顺序定义,内部的洞应以顺时针方向定义。

一个包含环的多边形示例如下

{
  "type": "Polygon",
  "coordinates": [
    [
      [106.50, 29.60],
      [106.50, 29.50],
      [106.60, 29.50],
      [106.60, 29.60],
      [106.50, 29.60] 
    ],
    [
      [106.53, 29.57],
      [106.57, 29.57],
      [106.57, 29.53],
      [106.53, 29.53],
      [106.53, 29.57]
    ]
  ]
}

把第二段JSON删掉就是没有环的矩形

{
  "type": "Polygon",
  "coordinates": [
    [
      [106.50, 29.60],
      [106.50, 29.50],
      [106.60, 29.50],
      [106.60, 29.60],
      [106.50, 29.60] 
    ]
  ]
}

MultiPoint(多点)

表示一组不相连的点、Point的复数形式、多个点组成的二维数组

{
  "type": "MultiPoint",
  "coordinates": [
    [106.50, 29.60],
    [106.50, 29.50],
    [106.60, 29.50],
    [106.60, 29.60]
  ]
}

MultiLineString (多线串)

表示一组不相连的线串,LineString的复数形式,多条线组成的三层数组

{
  "type": "MultiLineString",
  "coordinates": [
    [
      [106.51398305678325, 29.523171668355733],
      [106.51453664249686, 29.523092142346467],
      [106.51566579820047, 29.522995404990354]
    ],
    [
      [106.51398305678325, 29.533171668355733],
      [106.51453664249686, 29.533092142346467],
      [106.51566579820047, 29.532995404990354]
    ]
  ]
}

MultiPolygon (多多边形):

表示一组不相连的多边形、坐标是四层数组,每组坐标代表一个独立的 Polygon(每个 Polygon 内部仍可包含洞)。

{
  "type": "MultiPolygon",
  "coordinates": [
    [
      [
        [106.50, 29.55],
        [106.50, 29.50],
        [106.55, 29.50],
        [106.55, 29.55],
        [106.50, 29.55]
      ]
    ],
    [
      [
        [106.65, 29.65],
        [106.65, 29.60],
        [106.70, 29.60],
        [106.70, 29.65],
        [106.65, 29.65]
      ]
    ]
  ]
}

GeometryCollection (几何集合)

用于将不同类型(Point, LineString, Polygon, Multi*)的几何图形封装到一个对象中, 包含一个 "geometries" 成员,其值是一个数组,数组中的每个元素都是一个完整的 GeoJSON 几何对象

一个包含 Point, LineString, Polygon 的 对象

{
  "type": "GeometryCollection",
  "geometries": [
    {
      "type": "Point",
      "coordinates": [106.52, 29.53]
    },
    {
      "type": "LineString",
      "coordinates": [
        [106.50, 29.50],
        [106.53, 29.50],
        [106.53, 29.55]
      ]
    },
    {
      "type": "Polygon",
      "coordinates": [
        [
          [106.55, 29.55],
          [106.55, 29.50],
          [106.60, 29.50],
          [106.60, 29.55],
          [106.55, 29.55]
        ]
      ]
    }
  ]
}

特征类型

这个可以说是GeoJSON 的灵魂,它将几何形状与属性数据关联起来,使得图形有了意义,包括两个类型:“Feature” 和 “FeatureCollection”

Feature

基本结构如下:

  • "type": "Feature"
  • "geometry":包含一个几何对象(Point, Polygon, etc.)。
  • "properties":包含任何非地理属性数据(例如:名称、人口、年份、颜色等)。

下面为一个LineString、属性描述其为一条高速公路

{
  "type": "Feature",
  "geometry": {
    "type": "LineString",
    "coordinates": [
      [106.50, 29.50],
      [106.55, 29.52],
      [106.60, 29.54],
      [106.65, 29.56]
    ]
  },
  "properties": {
    "id": "G5001",
    "name": "重庆绕城高速(部分)",
    "speed": 100,
    "length": 15.5
  }
}

FeatureCollection

FeatureCollection 表示Feature的集合,几乎网上下载的GeoJSON文件都是 FeatureCollection 结构的 基本结构:

  • "type": "FeatureCollection"
  • "features":一个包含零个或多个 Feature 对象的数组。

一个表示线路和服务区的 GeoJSON

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [106.40, 29.50],
          [106.45, 29.52],
          [106.50, 29.54],
          [106.55, 29.56],
          [106.60, 29.58]
        ]
      },
      "properties": {
        "id": "H-1234",
        "name": "城市快速通道A段",
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [106.45, 29.52]
      },
      "properties": {
        "id": "SA-001",
        "name": "龙溪服务区",
      }
    }
  ]
}

OpenLayers 中使用

在OpenLayers中写了两个Demo巩固、一个用于绘制线路后导出GeoJSON文件,另一个加载导出的文件根据渲染线路和服务区

绘制线路以及站点

涉及主要功能点:

  • 绘制线路,并为香炉加上线路表示以及名字
  • 绘制站点,并为站点加上站点标识以及名字
import { useEffect, useRef, useState } from 'react'
import * as layer from 'ol/layer'
import * as source from 'ol/source'
import { Map } from 'ol'
import View from 'ol/View.js'
import OSM from 'ol/source/OSM'
import Style from 'ol/style/Style'
import Stroke from 'ol/style/Stroke'
import Fill from 'ol/style/Fill'
import * as format from 'ol/format'
import CircleStyle from 'ol/style/Circle'

import * as interaction from 'ol/interaction'
import type { DrawEvent } from 'ol/interaction/Draw'
const projection = 'EPSG:4326'

let i = 0

const createDrawStyle = () => {
  return new Style({
    image: new CircleStyle({
      radius: 4,
      fill: new Fill({
        color: '#000000'
      })
    }),
    stroke: new Stroke({
      width: 2,
      color: 'red'
    })
  })
}
const DrawLine = () => {
  const mapRef = useRef<Map>(null)
  const drawRef = useRef<interaction.Draw>(null)
  const drawSource = useRef<source.Vector>(null)
  const [drawType, setDrawType] = useState<string>('Point')

  useEffect(() => {
    const vectorSource = new source.Vector()
    drawSource.current = vectorSource
    const vectorLayer = new layer.Vector({
      source: vectorSource,
      style: createDrawStyle()
    })

    const osmLayer = new layer.Tile({
      source: new OSM()
    })

    const view = new View({
      zoom: 10,
      projection,
      center: [106.56, 29.57]
    })

    const map = new Map({
      target: 'draw',
      layers: [osmLayer, vectorLayer],
      view,
      controls: []
    })
    const draw = new interaction.Draw({
      type: 'Point',
      source: vectorSource
    })
    map.addInteraction(draw)
    mapRef.current = map
    drawRef.current = draw
  }, [])

  const handleClick = (event: React.ChangeEvent<HTMLInputElement>) => {
    setDrawType(event.target.value)
  }

  useEffect(() => {
    const map = mapRef.current!
    const source = drawSource.current!
    source.getFeatures().forEach(item => {
      if(item.getGeometry()?.getType() === drawType) {
        source.removeFeature(item)
      }
    })
    map.removeInteraction(drawRef.current!)
    const handleDrawEnd = (event: DrawEvent) => {
      const feature = event.feature
      feature.setProperties({
        type: drawType === 'Point' ? 'station' : 'line',
        name: drawType === 'Point' ? '站点' + i : '高速'
      })
      i++
    }
    const draw = new interaction.Draw({
      type: drawType,
      source: source,
      style: createDrawStyle()
    })
    draw.on('drawend', handleDrawEnd)
    drawRef.current = draw
    map.addInteraction(draw)
    return () => {
      draw.un('drawend', handleDrawEnd)
    }
  }, [drawType])

  const handleExport = () => {
    const source = drawSource.current!
    const features = source.getFeatures()
    const featureProjection = mapRef
      .current!.getView()
      .getProjection()
      .getCode()
    const jsonFormat = new format.GeoJSON({
      featureProjection,
      dataProjection: projection
    })

    const json = jsonFormat.writeFeatures(features, {
      featureProjection,
      dataProjection: projection
    })
    // 导出
    console.log(json, '>>>>')
  }

  return (
    <>
      <div
        style={{
          width: '800px',
          height: '400px',
          position: 'relative',
          display: 'flex'
        }}
      >
        <div id="draw" style={{ width: '800px', height: '400px' }}></div>
      </div>
      <input
        type="radio"
        checked={drawType === 'Point'}
        value={'Point'}
        onChange={handleClick}
      />{' '}
      添加站点
      <input
        type="radio"
        checked={drawType === 'LineString'}
        value={'LineString'}
        onChange={handleClick}
      />{' '}
      添加线路
      <button onClick={handleExport}>导出</button>
    </>
  )
}

export default DrawLine

当绘制好后可以点击导出、然后可以看到控制台有我们的JSON数据,这里我示例了一下,本来想找条真实的路,结果定位重庆就找不到一条直的路,算了。还有这里我们也可以手动便利features自己写json,能进一步巩固了解!

这是我绘制的供后面使用效果图如下:

4c2d8a92240a8a7ebe53730fd8dd0d35.png

OpenLayers 中使用刚才导入的数据

我们可以导入刚才的数据并加入一些交互,这里我对数据做了一些加工,这一步可以在编辑完成,但我们的编辑比较粗糙,我就手动对JSON做了编辑,主要功能:

  • 支持路线选中、显示路线信息
  • 支持站点选中、查看站点信息
import { useEffect, useRef, useState } from 'react'
import * as layer from 'ol/layer'
import * as source from 'ol/source'
import { Map } from 'ol'
import View from 'ol/View.js'
import OSM from 'ol/source/OSM'
import Style from 'ol/style/Style'
import Stroke from 'ol/style/Stroke'
import Fill from 'ol/style/Fill'
import * as format from 'ol/format'
import CircleStyle from 'ol/style/Circle'

import * as interaction from 'ol/interaction'
import { pointerMove } from 'ol/events/condition'
const projection = 'EPSG:4326'


const createDrawStyle = () => {
  return new Style({
    image: new CircleStyle({
      radius: 4,
      fill: new Fill({
        color: 'red'
      })
    }),
    stroke: new Stroke({
      width: 2,
      color: '#000'
    })
  })
}
const DrawLine = () => {
  const mapRef = useRef<Map>(null)
  const wrapperRef = useRef<HTMLDivElement>(null)
  const drawSource = useRef<source.Vector>(null)
  const [active, setActive] = useState<any>(null)

  useEffect(() => {
    const vectorSource = new source.Vector({
      url: '/geo/cq.json',
      format: new format.GeoJSON()
    })
    drawSource.current = vectorSource
    const vectorLayer = new layer.Vector({
      source: vectorSource,
      style: createDrawStyle()
    })

    const osmLayer = new layer.Tile({
      source: new OSM()
    })

    const view = new View({
      zoom: 10,
      projection,
      center: [106.56, 29.57]
    })

    const map = new Map({
      target: 'draw',
      layers: [osmLayer, vectorLayer],
      view,
      controls: []
    })
    const select = new interaction.Select({
      condition: pointerMove,
      style: new Style({
        image: new CircleStyle({
          radius: 8,
          fill: new Fill({
            color: 'red'
          })
        }),
        stroke: new Stroke({
          width: 4,
          color: '#000'
        })
      })
    })
    map.addInteraction(select)
    map.on('pointermove', event => {
      const pixel = event.pixel;
      const features = map.getFeaturesAtPixel(pixel);
      if(features.length) {
        const feature = features.find(item => item.getGeometry()?.getType() === 'Point') || features[0]
        setActive({
          pixel,
          properties: feature.getProperties()
        })
      } else {
        setActive(null)
      }
    })
    mapRef.current = map
  }, [])

  return (
    <>
      <div
        style={{
          width: '800px',
          height: '400px',
          position: 'relative',
          display: 'flex'
        }}
      >
        <div id="draw" ref={wrapperRef} style={{ width: '800px', height: '400px', cursor: active ? 'pointer' : 'auto'  }}></div>
        {active && <div style={{
          width: '100px',
          background: '#fff',
          padding: '4px',
          borderRadius: '4px',
          position: 'absolute',
          left: active.pixel[0] + 20 + 'px',
          top: active.pixel[1] + 20 + 'px'
        }}>
          <h5>名称:{active.properties.name}</h5>
        </div>}
      </div>
    </>
  )
}

export default DrawLine

效果图如下

6b9ba06ac2c949da917c36dbe5e48919.png

这是我使用的JSON

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": {
        "type": "LineString",
        "coordinates": [
          [
            106.40801847988175,
            29.57298086250036
          ],
          [
            106.56685851097549,
            29.580326066250358
          ],
          [
            106.69999032894425,
            29.54084559609411
          ],
          [
            106.80098688050674,
            29.43066753984411
          ],
          [
            106.83220399644425,
            29.410468229531606
          ],
          [
            106.88362042269425,
            29.403123025781607
          ],
          [
            106.91116493675675,
            29.406795627656606
          ],
          [
            106.96074506206925,
            29.417813433281605
          ]
        ]
      },
      "properties": {
        "type": "line",
        "name": "成渝高速"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          106.70012461444661,
          29.539872075705606
        ]
      },
      "properties": {
        "type": "station",
        "name": "安康"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          106.56685698617343,
          29.57991493114919
        ]
      },
      "properties": {
        "type": "station",
        "name": "巴中"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          106.88282014240801,
          29.40285042973459
        ]
      },
      "properties": {
        "type": "station",
        "name": "渝北"
      }
    },
    {
      "type": "Feature",
      "geometry": {
        "type": "Point",
        "coordinates": [
          106.96102884444625,
          29.417240830909627
        ]
      },
      "properties": {
        "type": "station",
        "name": "简阳"
      }
    }
  ]
}

总结

通过本文的深入探索,我们理解了 GeoJSON 作为 Web 地理空间数据通用语言的核心优势:

  • 简洁: 基于 JSON 格式,结构清晰,易于人机阅读和编写。
  • 灵活: 强大的 Feature 和 FeatureCollection 结构允许我们将复杂的地理几何图形(如 LineString, Polygon, MultiPolygon)与丰富的非地理属性数据 (properties) 完美结合。
  • 标准化: 统一的 WGS84 坐标系和严格的规范(如右手法则),确保了 GeoJSON 文件在不同平台和 Web 地图库之间的互操作性。

JavaScript 原型链:理解对象继承的核心机制

作者 Tzarevich
2025年12月1日 18:07

JavaScript 原型链:理解对象继承的核心机制

在 JavaScript 中,原型链(Prototype Chain) 是实现对象继承和属性查找的核心机制。与传统面向对象语言(如 Java、C++)基于“类”的继承不同,JavaScript 采用的是 基于原型的继承模型。本文将结合 Promise 实例和普通构造函数示例,深入浅出地解析原型链的工作原理。


一、什么是原型链?

每个 JavaScript 对象(除 null 外)内部都有一个隐藏属性 [[Prototype]](可通过 __proto__ 访问),它指向另一个对象——这个对象就是该对象的“原型”。当试图访问一个对象的属性时,如果该对象自身没有这个属性,JavaScript 引擎会沿着原型链向上查找,直到找到该属性或到达原型链的末端(即 null)。

关键点:JavaScript 的继承不是靠“血缘”,而是靠“链条”——原型链。


二、构造函数、原型对象与实例的关系

以自定义构造函数为例:

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

Person.prototype.species = '人';

let zeng = new Person('jw', 18);
  • Person 是一个构造函数。
  • Person.prototype 是一个普通对象,所有通过 new Person() 创建的实例都会以它为原型。
  • zeng.__proto__ === Person.prototype成立
  • Person.prototype.constructor === Person成立

这种结构形成了经典的“三角关系”:

  • 实例(zeng)通过 __proto__ 指向原型对象(Person.prototype
  • 原型对象通过 constructor 指回构造函数(Person

🚂 比喻:可以把 constructor 看作火车头,prototype 是车身,而每个实例是挂在车身后的车厢。它们通过“挂钩”(__proto__)连接在一起。


三、动态修改原型:打破常规

JavaScript 的原型是可变的。我们可以随时修改对象的 __proto__

const kong = {
  name: 'kong',
  hobbies: ['篮球', '足球'],
};

zeng.__proto__ = kong;
console.log(zeng.hobbies); // ['篮球', '足球']
console.log(zeng.species); // undefined

此时:

  • zeng 不再从 Person.prototype 继承属性;
  • 而是从 kong 对象继承;
  • 因此 species 找不到了,但 hobbies 可以访问。

⚠️ 注意:虽然技术上可行,但不推荐随意修改 __proto__ ,因为它会破坏性能优化,并可能导致代码难以维护。


四、内置对象也遵循原型链:以 Promise 为例

ES6 引入的 Promise 同样遵循原型链规则:

const p = new Promise((resolve, reject) => {
  setTimeout(() => reject('失败1'), 3000);
});
  • p 是一个 Promise 实例;
  • p.__proto__ === Promise.prototypetrue
  • Promise.prototype 上定义了 .then(), .catch(), .finally() 等方法;
  • 所以 p.then(...) 实际上调用的是 Promise.prototype.then

执行流程:

  1. new Promise(...) 立即执行 executor 函数(同步)→ 输出 '111'
  2. 主线程继续执行 → 输出 '222'p 的初始状态(pending)
  3. 3 秒后,reject('失败1') 触发状态变为 rejected
  4. 微任务队列中安排 .catch() 回调 → 输出 '失败1'
  5. .finally() 总是执行 → 输出 'finally'

这再次印证:所有对象的行为都依赖于其原型链上的方法


五、原型链的本质:属性查找机制

当你写 obj.method() 时,JavaScript 引擎会:

  1. obj 自身查找 method
  2. 若无,则查找 obj.__proto__
  3. 若仍无,继续查找 obj.__proto__.__proto__
  4. ……直到 Object.prototype(最顶层)
  5. 若最终找不到,返回 undefined

例如:

zeng.toString(); // 虽然 zeng 自身没有 toString,但 Object.prototype 有

因为:

zeng 
→ __proto__ = kong 
→ __proto__ = Object.prototype 
→ has toString()

六、总结

概念 说明
__proto__ 实例指向其原型的链接(非标准但广泛支持)
prototype 构造函数的属性,用于被实例的 __proto__ 引用
constructor 原型对象上的属性,指回构造函数
原型链 属性/方法查找的路径,从实例 → 原型 → 原型的原型 → … → null

JavaScript 的面向对象不是靠“类继承”,而是靠“对象委托”——你没有的,我帮你问我的原型要。这种灵活而强大的机制,正是 JavaScript 动态特性的基石。

✅ 牢记:一切皆对象,万物皆可链。理解原型链,就掌握了 JavaScript 面向对象的灵魂。

Promise.resolve(x) 等同 new Promise(resolve => resolve(x))?

作者 之恒君
2025年12月1日 18:05

Promise.resolve(x)return new Promise((resolve) => resolve(x)) 在多数场景下行为一致,但不能完全等同理解,需从规范定义的细节差异区分,具体分析如下:

一、核心行为的一致性(基础场景)

在处理“非Promise类型的x”或“非当前构造函数生成的Promise实例x”时,两者逻辑高度一致,均会创建一个新的Promise实例并以x为结果决议:

  1. 若x是普通值(如数字、字符串、对象等非Promise/非thenable类型),Promise.resolve(x) 会创建新Promise并直接决议为fulfilled状态,结果为x;new Promise((resolve) => resolve(x)) 也会通过调用resolve回调,让新Promise以x为结果fulfilled,这符合文档中PromiseResolve抽象操作对普通值的处理逻辑。
  2. 若x是thenable对象(含then方法的非Promise对象),两者都会触发“thenable同化”逻辑:调用x的then方法,用其结果决议新Promise,这与文档中Promise Resolve Functions处理thenable的步骤一致。

二、关键差异(不能完全等同的场景)

根据文档规范,Promise.resolve(x) 存在特殊优化逻辑,而 new Promise(...) 无此处理,导致两者在特定场景下行为不同:

  1. x是当前构造函数生成的Promise实例时

Promise.resolve(x) 规范文档:tc39.es/ecma262/mul…

文档明确规定,若x是Promise实例且其构造函数与当前Promise.resolve的this值(即构造函数C)相同,Promise.resolve(x) 会直接返回x,而非创建新Promise。
例如:

const p = new Promise((resolve) => resolve(1));
// Promise.resolve(p) 直接返回p,不新建Promise
console.log(Promise.resolve(p) === p); // true
// new Promise(...) 始终新建Promise,与p不是同一实例
console.log(new Promise(resolve => resolve(p)) === p); // false

这是Promise.resolve的核心优化,目的是避免对已存在的Promise实例重复包装,而new Promise(...)会强制新建实例,无法复用原有Promise。

  1. 构造函数为Promise子类时

Promise.resolve的this值是Promise子类(如class MyPromise extends Promise {}),Promise.resolve(x) 会通过NewPromiseCapability创建子类的Promise实例(若x非子类实例);而new Promise(...)始终创建原生Promise实例,无法关联子类构造函数。
例如:

class MyPromise extends Promise {}
// MyPromise.resolve(x) 创建MyPromise实例
console.log(MyPromise.resolve(1) instanceof MyPromise); // true
// new Promise(...) 始终创建原生Promise实例
console.log(new Promise(resolve => resolve(1)) instanceof MyPromise); // false

三、结论:可近似理解,但需注意规范差异

  • 日常开发简化理解:在不涉及“Promise实例复用”和“Promise子类”的场景下,可将Promise.resolve(x)近似看作return new Promise((resolve) => resolve(x)),两者最终都会生成以x为结果的fulfilled Promise,行为无明显差异。
  • 严格规范角度:两者不能完全等同。Promise.resolve(x) 是ECMAScript规范定义的静态方法,包含“复用同构造函数Promise实例”“适配子类构造函数”等优化逻辑,而new Promise(...)是基础的Promise创建方式,仅负责新建实例并执行executor回调,无特殊优化。

简言之new Promise((resolve) => resolve(x))Promise.resolve(x)的“基础实现逻辑”,但Promise.resolve(x)在规范层面补充了更智能的 实例复用子类适配 逻辑,功能更完善。

CesiumLite-在三维地图中绘制3D图形变得游刃有余

2025年12月1日 17:53

🎯 告别重复造轮子!CesiumLite 实体管理模块让3D图形开发效率翻倍

本文深入介绍 CesiumLite 的实体管理模块,从开发痛点到封装原理,再到实战应用,带你全面了解如何优雅地管理 Cesium 三维实体。

📌 前言

在使用 Cesium.js 开发三维地图应用时,实体(Entity)的创建和管理是最常见的需求之一。无论是标注点位、绘制建筑轮廓,还是展示三维模型,都离不开实体的操作。

然而,Cesium 原生 API 虽然功能强大,但在实际开发中却存在不少痛点。本文将通过 CesiumLite 项目的实体管理模块,展示如何优雅地解决这些问题。

🎨 在线演示

项目提供了完整的功能演示页面,你可以访问以下链接查看实际效果:

在线演示

image.png项目地址

演示页面包含以下功能:

  • 🔹 多边形面
  • 🔹 盒子模型
  • 🔹 矩形
  • 🔹 球体
  • 🔹 椭圆形
  • 🔹 圆柱
  • 🔹 线段
  • 🔹 管道(PolylineVolume)
  • 🔹 走廊
  • 🔹 墙体

🚫 开发痛点分析

痛点 1:实体创建过于繁琐

使用 Cesium 原生 API 创建一个简单的多边形,需要这样写:

// 创建一个多边形实体
const entity = viewer.entities.add({
  polygon: {
    hierarchy: Cesium.Cartesian3.fromDegreesArray([
      -109.080842, 45.002073,
      -104.058488, 45.002073,
      -104.053011, 41.003906,
      -105.728954, 41.003906,
    ]),
    height: 5000,
    material: Cesium.Color.BLUE.withAlpha(0.5),
    outline: true,
    outlineColor: Cesium.Color.BLACK,
  }
});

// 如果需要定位到该实体
viewer.zoomTo(entity);

问题在于:

  • 每次创建都要重复写 viewer.entities.add()
  • 没有统一的实体 ID 管理机制
  • 定位功能需要单独调用
  • 实体更新和删除操作分散

痛点 2:实体生命周期管理混乱

当项目中实体数量增多时,管理变得复杂:

// 需要手动维护实体引用
const entities = [];
entities.push(viewer.entities.add({ /* ... */ }));
entities.push(viewer.entities.add({ /* ... */ }));

// 更新某个实体?需要先找到它
const targetEntity = entities.find(e => e.id === 'someId');
if (targetEntity) {
  targetEntity.polygon.material = Cesium.Color.RED;
}

// 删除某个实体?
viewer.entities.remove(targetEntity);

// 清空所有?
viewer.entities.removeAll(); // 这会删除所有实体,包括其他模块创建的!

问题在于:

  • 实体引用分散,难以统一管理
  • 查找、更新、删除操作繁琐
  • 清空操作会影响其他模块
  • 缺乏命名空间隔离

痛点 3:代码复用性差

每个项目都要重新实现相似的功能:

// 项目 A
class ProjectAEntityManager {
  addPolygon(options) { /* ... */ }
  removePolygon(id) { /* ... */ }
}

// 项目 B
class ProjectBEntityController {
  createEntity(config) { /* ... */ }
  deleteEntity(entityId) { /* ... */ }
}

// 项目 C - 又要重新写一遍...

问题在于:

  • 每个项目都在造轮子
  • 没有统一的最佳实践
  • 维护成本高,bug 重复出现

💡 CesiumLite 的解决方案

核心设计思路

CesiumLite 的实体管理模块采用了以下设计思路:

  1. 双层封装架构EntityManager + EntityWrapper
  2. 独立数据源隔离:使用 CustomDataSource 避免污染全局实体集合
  3. 统一 ID 管理:自动生成唯一 ID,支持自定义
  4. 链式操作支持:提供流畅的 API 调用体验

架构设计图

┌─────────────────────────────────────────┐
│          CesiumLite 核心类              │
│  ┌───────────────────────────────────┐  │
│  │      EntityManager 管理器         │  │
│  │  - 统一管理所有实体               │  │
│  │  - 独立 CustomDataSource          │  │
│  │  - 提供增删改查接口               │  │
│  │                                   │  │
│  │  ┌─────────────────────────────┐ │  │
│  │  │   EntityWrapper 实体包装器  │ │  │
│  │  │  - 封装单个实体             │ │  │
│  │  │  - 自动生成唯一 ID          │ │  │
│  │  │  - 提供更新方法             │ │  │
│  │  └─────────────────────────────┘ │  │
│  └───────────────────────────────────┘  │
│                  ↓                       │
│  ┌───────────────────────────────────┐  │
│  │      Cesium Viewer 实例           │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

🔧 核心代码实现

1. EntityWrapper:实体包装器

EntityWrapper 负责封装单个实体,提供统一的操作接口:

import { Entity, createGuid } from 'cesium';

class EntityWrapper {
    constructor(options = {}) {
        // 自动生成唯一 ID,也支持自定义
        this.id = options.id || createGuid();
        this.options = Object.assign({}, options);
        this.entity = new Entity(this.options);
    }

    // 更新实体属性
    update(options) {
        Object.assign(this.options, options);
        this.entity.update(this.options);
    }

    // 获取原生 Cesium 实体
    getEntity() {
        return this.entity;
    }
}

export default EntityWrapper;

设计亮点:

  • ✅ 自动生成唯一 ID,避免冲突
  • ✅ 保存实体配置,方便后续更新
  • ✅ 提供 getEntity() 方法,保持原生 API 的兼容性

2. EntityManager:实体管理器

EntityManager 是实体管理的核心,提供完整的生命周期管理:

import { CustomDataSource } from 'cesium';
import EntityWrapper from './entityWrapper';

class EntityManager {
    constructor(viewer) {
        if (!viewer) throw new Error('Viewer instance is required');
        this.viewer = viewer;

        // 创建独立的数据源,实现命名空间隔离
        this.dataSource = new CustomDataSource('entityManager');
        this.viewer.dataSources.add(this.dataSource);

        // 使用 Map 管理所有实体,O(1) 查找性能
        this.entities = new Map();
    }

    // 添加实体
    addEntity(options, isLocate = false) {
        const entityWrapper = new EntityWrapper(options);
        this.entities.set(entityWrapper.id, entityWrapper);
        this.dataSource.entities.add(entityWrapper.getEntity());

        // 支持创建后自动定位
        if (isLocate) {
            this.locateEntity(entityWrapper.id);
        }

        return entityWrapper.id;
    }

    // 移除实体
    removeEntity(entityId) {
        if (this.entities.has(entityId)) {
            const entityWrapper = this.entities.get(entityId);
            this.dataSource.entities.remove(entityWrapper.getEntity());
            this.entities.delete(entityId);
        }
    }

    // 更新实体
    updateEntity(entityId, options) {
        if (this.entities.has(entityId)) {
            const entityWrapper = this.entities.get(entityId);
            entityWrapper.update(options);
        }
    }

    // 视角定位到实体
    locateEntity(entityId) {
        if (this.entities.has(entityId)) {
            const entityWrapper = this.entities.get(entityId);
            this.viewer.zoomTo(entityWrapper.getEntity());
        }
    }

    // 获取所有实体
    getAllEntities() {
        return Array.from(this.entities.values()).map(wrapper => wrapper.getEntity());
    }

    // 清除所有实体(只清除当前管理器的实体)
    clearEntities() {
        this.dataSource.entities.removeAll();
        this.entities.clear();
    }
}

export default EntityManager;

设计亮点:

  • 独立数据源:使用 CustomDataSource 实现命名空间隔离,不会影响其他模块
  • 高效查找:使用 Map 数据结构,提供 O(1) 的查找性能
  • 自动定位:支持创建实体后自动飞行到目标位置
  • 统一接口:增删改查操作命名规范,易于理解

🎯 使用教程

基础用法

1. 初始化 CesiumLite
const cesiumLite = new CesiumLite('cesiumContainer', {
  map: {
    baseMap: {
      id: 'imagery'
    },
    camera: {
      longitude: 116.397428,
      latitude: 39.90923,
      height: 1000000
    }
  }
});
2. 添加各种几何实体
添加多边形
const polygonId = cesiumLite.entityManager.addEntity({
  polygon: {
    hierarchy: Cesium.Cartesian3.fromDegreesArray([
      -109.080842, 45.002073,
      -104.058488, 45.002073,
      -104.053011, 41.003906,
      -105.728954, 41.003906,
    ]),
    height: 5000,
    material: Cesium.Color.BLUE.withAlpha(0.5),
    outline: true,
    outlineColor: Cesium.Color.BLACK,
  }
}, true); // 第二个参数 true 表示创建后自动定位
添加盒子模型
cesiumLite.entityManager.addEntity({
  position: Cesium.Cartesian3.fromDegrees(-109.080842, 45.002073),
  box: {
    dimensions: new Cesium.Cartesian3(5000, 5000, 5000),
    material: Cesium.Color.RED.withAlpha(0.5),
  }
}, true);
添加球体
cesiumLite.entityManager.addEntity({
  name: "Three-dimensional sphere",
  position: Cesium.Cartesian3.fromDegrees(-114.0, 40.0, 300000.0),
  ellipsoid: {
    radii: new Cesium.Cartesian3(200000.0, 200000.0, 300000.0),
    innerRadii: new Cesium.Cartesian3(150000.0, 150000.0, 200000.0),
    material: Cesium.Color.RED.withAlpha(0.5),
    outline: true
  }
}, true);
添加圆柱
cesiumLite.entityManager.addEntity({
  position: Cesium.Cartesian3.fromDegrees(-104.058488, 44.996596),
  cylinder: {
    length: 5000,
    topRadius: 500,
    bottomRadius: 500,
    material: Cesium.Color.RED.withAlpha(0.5),
    outline: true,
    numberOfVerticalLines: 20
  }
}, true);
添加走廊(Corridor)
cesiumLite.entityManager.addEntity({
  corridor: {
    positions: Cesium.Cartesian3.fromDegreesArray([
      -109.080842, 45.002073,
      -105.91517, 45.002073,
      -104.058488, 44.996596,
    ]),
    width: 5000,
    height: 1000,
    extrudedHeight: 10000,
    material: Cesium.Color.RED.withAlpha(0.5),
  }
}, true);
添加墙(Wall)
cesiumLite.entityManager.addEntity({
  name: "Vertical wall",
  wall: {
    positions: Cesium.Cartesian3.fromDegreesArrayHeights([
      -107.0, 43.0, 100000.0,
      -97.0, 43.0, 100000.0,
      -97.0, 40.0, 100000.0,
      -107.0, 40.0, 100000.0,
    ]),
    material: Cesium.Color.RED.withAlpha(0.5),
    outline: true
  }
}, true);

高级操作

更新实体
// 保存实体 ID
const entityId = cesiumLite.entityManager.addEntity({ /* ... */ });

// 更新实体属性
cesiumLite.entityManager.updateEntity(entityId, {
  polygon: {
    material: Cesium.Color.GREEN.withAlpha(0.7)
  }
});
定位到指定实体
cesiumLite.entityManager.locateEntity(entityId);
删除指定实体
cesiumLite.entityManager.removeEntity(entityId);
清空所有实体
cesiumLite.entityManager.clearEntities();
获取所有实体
const allEntities = cesiumLite.entityManager.getAllEntities();
console.log('当前实体数量:', allEntities.length);

📊 对比传统开发方式

代码量对比

操作 传统方式 CesiumLite 减少代码量
创建实体 10+ 行 3 行 70%
创建并定位 15+ 行 3 行 80%
更新实体 8+ 行 1 行 87%
删除实体 5+ 行 1 行 80%
批量清空 10+ 行 1 行 90%

功能对比

功能 传统方式 CesiumLite
实体创建
唯一 ID 管理 ❌ 需手动实现 ✅ 自动生成
命名空间隔离 ❌ 需手动实现 ✅ 内置支持
自动定位 ❌ 需单独调用 ✅ 参数控制
统一更新接口 ❌ 分散操作 ✅ 统一接口
批量操作 ❌ 需手动循环 ✅ 内置支持

🚀 快速开始

1. 安装

# NPM 安装(推荐)
npm install cesium-lite

# 或者通过 GitHub 克隆
git clone https://github.com/lukeSuperCoder/cesium-lite.git
cd cesium-lite
npm install

2. 引入使用

方式一:NPM 方式
import CesiumLite from 'cesium-lite';
import 'cesium/Build/Cesium/Widgets/widgets.css';

const cesiumLite = new CesiumLite('cesiumContainer', {
  // 配置项
});

方式二:本地运行项目

# 克隆项目
git clone https://github.com/lukeSuperCoder/cesium-lite.git
cd cesium-lite

# 安装依赖
npm install

3. 运行示例

npm run dev

访问 http://localhost:8020/entity.html 查看实体管理示例。

💡 最佳实践建议

1. 合理使用自动定位

// 对于重要的首个实体,启用自动定位
const mainEntityId = cesiumLite.entityManager.addEntity(options, true);

// 批量创建时,关闭自动定位以提升性能
entities.forEach(entity => {
  cesiumLite.entityManager.addEntity(entity, false);
});

// 批量创建完成后,手动定位到某个实体
cesiumLite.entityManager.locateEntity(mainEntityId);

2. 实体 ID 管理

// 为重要实体指定自定义 ID
const buildingId = cesiumLite.entityManager.addEntity({
  id: 'building_main_001',  // 自定义 ID
  polygon: { /* ... */ }
});

// 后续可以直接使用自定义 ID 操作
cesiumLite.entityManager.updateEntity('building_main_001', { /* ... */ });

3. 批量操作优化

// 批量创建实体
const entityIds = [];
const batchData = [ /* 大量数据 */ ];

batchData.forEach(data => {
  const id = cesiumLite.entityManager.addEntity(data, false);
  entityIds.push(id);
});

// 需要时再批量定位
entityIds.forEach(id => {
  cesiumLite.entityManager.locateEntity(id);
});

🔮 未来规划

实体管理模块后续将会支持:

  • 实体分组管理
  • 实体样式预设
  • 实体动画支持
  • 实体点击事件封装
  • 实体序列化与反序列化
  • 批量操作性能优化

📚 相关资源

🙏 总结

CesiumLite 的实体管理模块通过双层封装架构,有效解决了 Cesium 原生开发中的诸多痛点:

  • 简化 API:减少 70%-90% 的代码量
  • 统一管理:自动 ID 生成 + 命名空间隔离
  • 开箱即用:无需重复造轮子
  • 性能优化:使用 Map 数据结构,高效查找

如果你正在使用 Cesium 开发三维地图应用,不妨试试 CesiumLite,让你的开发效率翻倍!


⭐ 如果这个项目对你有帮助,欢迎给个 Star 支持一下!

💬 有任何问题或建议,欢迎在评论区交流!

相关标签: #Cesium #三维地图 #WebGIS #前端开发 #JavaScript #开源项目 #地图可视化

同事:架构太复杂了,源码文件找半天。 我:源码溯源了解一下?

2025年12月1日 17:52

背景


相信刚入行,或是刚入行的小伙伴们,对于企业级代码与架构,以及扑面而来业务需求。想要在短时间内从对应的页面定位到组件时,是很难办到的事情,尤其是突然交给一个陌生的项目的需求,问题也会比较突出。

尤其是对于鼠鼠我本人来说,也是深有体会:司内的源码架构:自研微前端+monorepo架构,本身架构设计本身就比较复杂,在项目规模达到一定程度,或是项目开发时间长,人员变动大,就会导致有很多问题出现,就比如ld统计过 .vue文件已经8000个了,代码中有2250对重复的源代码文件。总计重复代码行数: 69578 🤯🤯🤯

在这样的情况下,一款能够快速定位源码的插件呼之欲出🎉🎉🎉


通过本篇文章,大家能学习到:
  1. 如何编写一个简易的vite插件
  2. vite插件的生命周期是怎么样的
  3. 源码溯源,快速定位:实现思路,原理

首先准备好实验环境:vue+vite+pnpm 让cursor快速生成一个项目即可

image.png

在正式将源码定位之前,我想讲讲一个简易的vite插件该如何实现,这对我们后面的学习会有比较有效的帮助

如何写Vite插件

再讲如何编写vite插件之前,需要先了解一下如何将自己编写的vite插件在Vite的构建流程中生效:

Vite插件本质是一个对象,通过到处一个对象函数,放入Vite配置项数组中即可实现效果:

在配置文件中:

那么作为Vite的自定义插件,和webpack一样,需要使用各种生命周期钩子,才能实现对应的效果:

这里介绍一下主流的生命周期钩子:

主流钩子

配置阶段:

config(config, env ):

  • 触发时机:当vite读取配置时触发

  • 常用场景:修改或扩展配置对象

configResolved(resolvedConfig):

  • 触发时机:当配置解析完成时触发

  • 常用场景:获取最终配置,初始化插件状态

该阶段主要用于插件初始化或读取用户配置,不是必须

构建阶段

buildStart:

  • 触发时机: 构建开始

  • 常用场景: 初始化状态,打印日志,准备数据

buildEnd:

  • 触发时机: 构建结束

  • 常用场景:收尾,打印统计

closeBundle:

  • 触发时机:构建完成并生成文件后

  • 常用场景:做最终清理或发布的操作

主要用于插件需要做全局初始化或构建后操作的场景

模块解析和加载阶段

resolveId(id,importer)

  • 触发时机:解析模块路径时

  • 常用场景:重写模块路径,生成虚拟模块

load(id)

  • 触发时机:模块加载内容

  • 常用场景:返回模块代码,生成虚拟模块

moduleParsed

  • 触发时机:模块 AST 解析完成

  • 常用场景:分析模块 AST ,做统计或收集信息

核心点:虚拟模块一般用 resolveId + load,处理源码前可以分析 AST。

模块transform阶段(最常用)

thransform(code,id)

  • 触发时机:模块加载后,打包前

  • 常用场景:核心 hook,用于修改 源码 、注入代码、操作 Vue/ JSX ****AST

transformIndexHtml

  • 触发时机: HTML 文件处理阶段

  • 常用场景:修改 HTML 模版,例如注入script,link

transform 是最主流的钩子,几乎所有插件都至少用它做一次源码修改。

整个构建生命周期流程图来看是这样的:

image.png

针对LLM返回给我们的主流钩子使用频率来看,我们优先掌握的肯定就是:模块 transform 阶段,因为这个阶段是能够直接接触的源代码,更容易在源代码上动手脚的阶段。

模块 transform 阶段

好记性不如烂笔头,让我们实战来看看,这个阶段能够做什么呢?

什么是transform阶段

在Vite的构建过程中,一个文件会从源码 -> 浏览器可执行文件,会经历很多处理环节。比如:

  • TS-> js
  • JSX -> JS
  • VUE单文件组件拆成JS,CSS
  • 去掉console.log
  • 注入HMR代码
  • 压缩

而 transform 就是 Vite 插件体系里专门负责“把代码转成新代码”的阶段

transform的函数签名

transform(code, id) {
  return {
    code: '新的代码',
    map: null, // 或 sourcemap
  }
}
  1. Code: 当前拿到的文件 源码
  2. id:当前文件的绝对路径

返回值:

  1. 返回一个字符串:
return transformedCode

说明只修改了代码,不管 source map,由 Vite 自动处理部分情况。

⚠️ 但 source map 会丢失或错误。

  1. 返回一个包含code+map的对象
return {
  code: transformedCode,
  map: null  // 或 SourceMap 对象
}
  • Vite 会继续把 map 传给下一环
  • 最终映射会合并到 browser source map
  • 对 HMR Debug 友好

若map为null时,让vite自己处理

  1. 返回为null或undefined
  • 表示我不处理这个模块,让下个插件处理。即:跳过这个阶段的

何时会触发transform

  1. 开发( dev server) :Vite 在浏览器请求模块时,先 resolveIdload(读文件)→ transform → 返回给浏览器(并缓存结果)。
  2. 构建(build) :Rollup 打包流程,Vite 基于 Rollup 插件接口执行,顺序类似:resolveIdloadtransform → 打包。
  3. 对于 SFC(例如 Vue 单文件组件),一个 .vue 会被拆成多个请求(script/template/style),每个子模块都会走 transform,因此你会看到同一个文件被多次 transform(通过 id 的 query 区分)。

image.png

源码溯源

为什么需要源码溯源插件

谈到为什么需要源码 溯源。就得提到司内的源码架构:自研微前端+monorepo架构,本身架构设计本身就比较复杂,在项目规模达到一定程度,或是项目开发时间长,人员变动大,就会导致有很多问题出现,就比如ld统计过 .vue文件已经8000个了,代码中有2250对重复的源代码文件。总计重复代码行数: 69578 行, 所以我们拟设计一款Vite插件配合油猴脚本,能够识别一个页面的所有组件,通过click,能够快速定位到对应的component。

设计思路是什么?

目标:

我们想要实现一个所见即所得模式,即能够清楚的看到一个页面由哪些组件组成,并且可以看到对应的组件渲染了页面的哪些地方,并且点击对应模块后,能够立马弹出组件对应的绝对路径,方便直接去寻找到对应的组件。

具体体现成什么样呢?这里起一个简单的小项目给大家看看

image.png

是一个很简单的小架构,当我们想要知道头部组件在对应源代码的哪个位置时,我们点击他:

image.png

第一个就是头部组件对应的组件路径,下面的就是其父组件,方便我们了解嵌套关系。

具体思路:

首先我们需要知道一件事情,浏览器最后渲染的内容,拿到的源文件是经过构建工具的转译,压缩,打包后的源代码,与自己实际开发是天壤之别,所以针对打包后的源代码溯源是不切实际的。所以我们的思路是:

  1. 需要在构建阶段,针对对应文件进行处理
  2. 具体处理就是将对应文件的绝对路径,通过某些方式,在构建后,保存到 源代码
  3. 再通过油猴插件,在浏览器中执行脚本,该脚本核心代码就是提取到点击模块对应的保存的绝对路径进行转译渲染出来,成为图片中的样式。
具体实现:
  1. 编写自定义vite插件,插件用处:在每个组件的根元素中添加自定义属性,内容为该文件绝对路径的编码形式存储在此。

  2. 将根元素的自定义属性值广播到子组件的类型中,任何你想点击/调试的元素都带有足够的信息

  3. 编写js脚本,核心在于提取到点击对应元素,能够快速识别转译出路径,并渲染到弹窗。

vite插件如何编写?

在编写插件前,我们需要明确我们插件需要做什么:

  • 每个Vue文件中的根元素,添加对应的自定义属性,属性值填的是对应路径的编码。

那么针对这个需求,我们首先需要分析,要使用哪个生命周期钩子才能实现对应的效果?

搜索过后,发现thransform(code,id) 这个钩子能够帮助我们实现我们想要的效果。

transform 是 Vite 插件体系里的编译钩子。每当 Vite 正在加载某个模块(无论是 .ts、.vue 还是别的可处理资源),都会把“源代码字符串 + 模块 id(含绝对路径/查询参数)”传进每个插件的 transform(code, id) ,让插件有机会在官方编译器运行前对源码 做一次改写、替换或分析

最后效果如下:

image.png

具体源代码实现:

export function cscMark(): Plugin {
  return {
    name: 'csc-mark',
    enforce: 'pre',
    transform(code, id) {
      if (!id.endsWith('.vue')) {
        return null;
      }

      const { template } = parse(code, { filename: id }).descriptor;

      if(template) {
        const elm = template.ast.children.find(item => item.type === NodeTypes.ELEMENT) as ElementNode | undefined;
        if(elm) {
          const tagString = `<${elm.tag}`;

          const insertIndex = elm.loc.source.indexOf(tagString) + tagString.length;
          const newSource
              = `${elm.loc.source.slice(0, insertIndex)} csc-mark="${LZString.compressToBase64(id)}"${elm.loc.source.slice(insertIndex)}`;
  
          code = code.replace(elm.loc.source, newSource);
        }
      }

      return code;
    }
  };
}
  1. 遍历每个vue组件

  2. 获得code里面template的内容

  3. 通过ast拿到根元素:elm

  4. 通过LZString.compressToBase64( id )绝对路径赋值进去。注:该钩子参数id就是遍历该文件的绝对路径

  5. 返回新代码给后续编译构建使用

如何将路径广播到子组件?

我们需要有个钩子,能够在上述标签打完之后,再逐一遍历该文件内的其他组件。将编码后的id注入class中。那么哪个钩子能够实习这种功能呢?

经过调研后发现:

Vue插件中,有个钩子能够帮助我们

export default defineConfig(({ mode }) => ({
  plugins: [vue({
    template: {
      compilerOptions: {
          nodeTransforms: [
              自己编写的函数
          ],
      },
  },
  }),cscMark() ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src')
    }
  },
  server: {
    host: '0.0.0.0',
    port: 4173,
    open: true
  },
  define: {
    __APP_ENV__: JSON.stringify(mode)
  }
}));

用法:

在编译模板时,对每个 AST 节点执行自己编写的特定函数

🌰

<template>
  <div csc-mark="路径1">
    <h1>标题</h1>
    <ks-dialog>弹窗</ks-dialog>
  </div>
</template>

Vue插件编译器会解析:

  1. 读取.vue文件
  2. 解析 template 部分
  3. 生成 AST(抽象语法树)

最后生成:

ROOT (type: 0)
  └── <div> (ELEMENT, type: 1)
       ├── csc-mark="路径1" (ATTRIBUTE)
       ├── <h1> (ELEMENT, type: 1)
       │    └── "标题" (TEXT)
       └── <ks-dialog> (ELEMENT, type: 1)
            └── "弹窗" (TEXT)

Vue 编译器会深度优先遍历 AST,对每个节点调用自定义的函数。

那这个自定义函数该如何去进行编写呢?

export const cscMarkNodeTransform = (node, context) => {
  if (node.type === NodeTypes.ELEMENT && context.parent) {
      if ([NodeTypes.ROOT, NodeTypes.IF_BRANCH].includes(context.parent.type)) {
          const firstElm = context.parent.children.find(item => item.type === NodeTypes.ELEMENT) as ElementNode | undefined;
          const addText = firstElm && firstElm.props.find(item => item.name === 'csc-mark')?.value?.content || '';

          if (addText) {
                  addClass(node, addText, 'class');
          }
      } else if (context.parent.props?.find(item => item.name === 'csc-mark')?.value?.content) {
          const addText = context.parent.props.find(item => item.name === 'csc-mark')?.value?.content || '';
          if (addText) {
                  addClass(node, addText, 'class');
          }
      }

  }
};
  1. cscMarkNodeTransform 中,只有当当前 node 是 NodeTypes.ELEMENT 且存在 context.parent 时才会继续处理,避免对非元素节点或无父节点的情况做多余操作
  2. 当父节点是 ROOT 或 IF_BRANCH 时,会查找父节点的首个子元素,读取其 csc-mark 属性的内容,并将该内容通过 addClass 加在当前节点的 class 上,从而把顶层 csc-mark 标记扩散到具体元素。
  3. 如果父节点本身带有 csc-mark 属性,就直接读取父节点的该属性内容并同样调用 addClass,以确保嵌套元素 继承 父级 csc-mark 定义的类名

页面效果呈现:

image.png

油猴脚本编写:

脚本作用:

  1. 添加检查button,只有点击button时,才会开启溯源功能
  2. 点击后高亮所有带有css-vite-mark-类名的元素
  3. 点击元素时,收集并显示嵌套组件及组件绝对路径

核心代码解释

1.组件层次结构的收集:

  • 这个函数从点击的元素开始向上遍历DOM树,收集所有带有标记的父元素,构建组件层次结构。
 // 函数:收集从顶层到当前元素的 csc-mark 属性列表
    function collectCscMarkHierarchy(element) {
        let cscMarkList = [];
        while (element) {
            if (element.hasAttribute('csc-mark')) {
                cscMarkList.push({ element, mark: element.getAttribute('csc-mark') });
            }
            element = element.parentElement;
        }
        return cscMarkList;
    }

2.路径解码:

这部分代码从类名中提取压缩的路径部分,然后使用LZString.decompressFromBase64解码还原为实际绝对路径。

// 处理源码路径部分代码
cssMarkList.forEach(item => {
    const tag = item.element.tagName.toLowerCase();
    try {
        const encodedPath = item.originMark.substring(prefix.length);
        const filePath = LZString.decompressFromBase64(encodedPath);
        decodedPaths.push({ tag, filePath });
    } catch (e) {
        console.error('解码路径失败:', e);
    }
});

3.交互机制

用户点击该元素时,收集组件嵌套,并渲染对话框

 // 函数:处理点击事件并显示 csc-mark 层级
    function handleClick(event) {
        let element = event.target;
  
        // 遍历 DOM 树查找最近的具有 csc-mark 属性的祖先元素
        while (element && !element.hasAttribute('csc-mark')) {
            element = element.parentElement;
        }
  
        if (element && element.hasAttribute('csc-mark')) {
            event.stopPropagation();
            event.preventDefault();
            const cscMarkList = collectCscMarkHierarchy(element);
            showCustomDialog(cscMarkList);
        }
    }
  

具体使用流程:

  1. 启动开发服务器
  2. 通过油猴插件添加脚本

image.png 3. 点击inspect按钮

image.png

  1. 之后想要修改哪个模块就可以进行点击

image.png

⚠️使用该油猴脚本时需要注意匹配到你对应的项目路径

image.png

总结:

通过上述方法可以实现一个简易的源码定位系统了,能够帮助我们在很多复杂项目中快速定位到自己需要修改的模块所对应的,通过这么一个比较小的需求,能够快速帮助大家对vite的生命周期,以及自定义插件油猴插件的基本使用,有个较为清晰的了解。综合性比较强,需求完成后对大家的开发效率也会有很大的提升,大家感兴趣的可以进我的github上看对应的插件源码和脚本代码:溯源代码

扩展点:

  1. 如何在webpack上,通过编写对应插件,实现相应的功能
  2. 目前只能够在页面上知道对应模块使用的组件,不知道这个组件能够对应哪个页面
  3. 可以修改一些样式,让整体更加美观
  4. 一步到位,点击对应模块能够自动跳转的编辑器中

JS宗门入门记:小白师妹的对答如流(从JS环境到函数作用域)

作者 鸡腿大王
2025年12月1日 17:46

晨光熹微,JS宗门的演武场上已传来阵阵键盘敲击声。我——一个刚入门三天的女弟子,正蹲在角落盯着卷轴上的《V8心法》发呆。

image.png
“喂,新来的!”一个身影挡在我面前,是内门弟子李师兄,人称“闭包剑客”。他居高临下地看着我手中的卷轴,语气带着审视:“听说你刚入门三天,就敢看《V8心法》?那你告诉我,我宗功法靠何物运转?”我站起身,拍拍衣摆,不慌不忙答道:
“回师兄,我宗功法运行,倚仗两大‘引擎’:一为‘浏览器’秘境,二为‘Node’灵台。其中V8引擎,乃是一段庞大而精妙的‘函数’,它能读懂JS咒语并予以执行。”
(v8 引擎, 也是一段函数(庞大),它可以读懂 js 并执行)
李师兄眉毛一挑:“哦?那你说说,一段JS咒语被V8读取后,是直接生效吗?”“并非如此。”我摇头,“咒语入引擎,首经三重梳理:一曰‘分词’,解构咒语为词元;二曰‘解析’,筑成‘抽象语法树’,辨明有效标识符;三曰‘生成代码’,方可使咒语具现。”
(# js的执行

  1. 代码被 v8 读取到的第一时间,并不是执行,而是会先编译(梳理)
  • 梳理:
  1. 分词/词法分析:将代码一个一个拆分解析成v8引擎可以识别的字符
  2. 解析/语法分析 -- AST(抽象语法树) 获取有效标识符
  3. 生成代码)
    旁边渐渐围拢了几位外门弟子,李师兄抱臂又问:“那你可知,我宗为何要创‘函数’之术?”我微微一笑:“函数如术法封装,可将一段逻辑代码封存其中。未调用时,如剑在鞘中,隐而不发;一旦调用,则如剑出鞘,代码方得执行。”

(形如 function foo() {},就是一个函数体,函数存在的意义就是让我们可以将某一段逻辑代码,写在函数中,最后调用函数,这段代码才会执行)

var a = 10
function foo() {
console.log(a);
}
foo()

此时一位师姐插话:“听说你昨日便参透了‘作用域’之境?”我转向她,拱手道:“师姐明鉴。我宗作用域分三重天:一为‘全局’,如宗门广场,人人可见;二为‘函数’,如各自厢房,内物不外露;三为‘块级’,需以let、const符咒配合{}结界而成。且作用域之规,乃‘由内向外’单向可见,外层不可窥探内层之秘。”
(# 作用域

  1. 全局作用域
  2. 函数作用域 (参数也是该作用域的一个有效标识)
  3. 块级作用域 (let,const 和 {} 语法配合使用会导致声明的变量处在一个作用域中)

image.png(宗门广场上,众多弟子往来穿梭,广场中央立着 “全局” 石碑,清晰可见,象征全局作用域人人可访问。)

image.png(一间独立厢房内,一位弟子正在桌前研读典籍,厢房门窗紧闭,门外有 “函数” 牌匾,示意厢房内物品仅限内部使用,不向外暴露。)

image.png(一间密室中,地面用 {} 符号绘制出结界,结界内有 let、const 符咒悬浮,一位弟子在结界内修炼,结界外之人无法看清内部情况,体现块级作用域需特定符咒配合结界形成。一间密室中,地面用 {} 符号绘制出结界,结界内有 let、const 符咒悬浮,一位弟子在结界内修炼,结界外之人无法看清内部情况,体现块级作用域需特定符咒配合结界形成。)

image.png(从内到外依次绘制着密室、厢房、宗门广场,箭头从密室指向厢房再指向广场,示意作用域 “由内向外” 单向可见的规则,外层无法反向窥探内层。)
李师兄忽然目光一凝,抛出最后一问:“那‘let结界术’中,有一特殊禁制,你可知是何?”我深吸一口气,清晰答道:“此乃‘暂时性死区’——当{}结界中存let声明,结界之内凡寻此变量,必先寻境内之身。纵境内无获,亦不可越界外求,此禁直至结界畅通方解。”
(当一个{} 语句中存在 let x 时,在该{}中访问x,永远都只能访问{}内部的 x,就算内部访问不到,也不能访问外部的x。这种规则称为 --- 暂时性死区)
(举个栗子

let a = 1
if (true) { 
  console.log(a);  // 暂时性死区
  let a = 2
}

image.png代码结果是'不能在声明变量a之前输出',就算内部访问不到a来输出,也不能访问外部的a,这称为暂时性死区)
场中静默片刻。李师兄忽然大笑,从怀中掏出一枚铜牌:“好!基础扎实,悟性不凡。这是我内门听讲牌,明日‘事件循环论剑’,你可来旁听。”后来才知,李师兄那日其实是奉长老之命,试探新弟子根基。而我——这个通宵啃完《JavaScript高级程序设计》还做了三套笔记的“小白”,不过是把书上的字,背得熟了些。毕竟在JS宗门,哪有什么天才,不过是把别人喝咖啡的时间,用来……写bug和debug罢了。

image.png

Three.js 坐标系完全入门:从“你在哪”到“你爸在哪”都讲清楚了

作者 一千柯橘
2025年12月1日 17:43

当你打开 Three.js 写 3D 场景时,第一个要搞懂的问题就是:

一个物体到底摆在哪里?

别小看这个问题。你觉得一个立方体在世界坐标 (3,0,0),结果它移动后出现在奇怪的位置,十次里有九次是因为——

👉 你忘了它有个“爸爸”。

今天我们就用一个超通俗的方式,把 Three.js 的坐标讲清楚。看完这篇文章,你会明白:

  • 世界坐标(World Coordinates)和本地坐标(Local Coordinates)到底怎么回事?
    • 世界坐标,基准点为固定原点(0,0,0),描述物体在整个场景中的绝对位置
    • 局部/本地坐标,基准点为物体自身的几何中心(中心点),描述子物体相对于父物体的位置, 只要是涉及到局部坐标的,父元素都会影响子元素的行为,比如 scale(放大)、position(坐标)、rotation(旋转)
  • 子对象相对于父对象的坐标是怎么计算的?
  • 为什么把 cube 放进 parentCube 后,位置就“变了”?
  • 实战代码是如何运行的?

准备好了吗?开始!

🧱 1. 世界坐标:整个世界的“绝对地址”

在 Three.js 中,有一个你永远逃不掉的概念:

世界坐标(World Coordinates)就是所有物体的绝对位置。

比如:

cube.position.set(3, 0, 0);
scene.add(cube);

意思很简单:

cube 在世界的 x=3 的位置。

就像你告诉朋友:“我在北京”。

无论你爸在哪,你都在北京。

👨‍👦 2. 本地坐标:相对于“父元素”的位置

但如果你写了:

const parentCube = new THREE.Mesh(geometry, parentMaterial);
parentCube.add(cube);

事情就不一样了。

cube 就有了一个“爸爸” parentCube。

此时你再写:

cube.position.set(3, 0, 0);
scene.add(parentCube);

这句话就变成:

cube 在 parentCube 的局部空间中,相对于父物体,往 x 正方向移动 3。

也就是说:

  • parentCube 就像一个坐标参照系
  • cube 相当于在这个内部空间里移动。

这就好比你说:

“我离我爸三米远。”

但你爸在北京,你也就实际上还是在北京附近

⚙️ 3. 回到你的示例:它到底发生了什么?

你写的代码如下:

const cube = new THREE.Mesh(geometry, material);

const parentCube = new THREE.Mesh(geometry, parentMaterial);
parentCube.add(cube);
parentCube.position.set(-3, 0, 0);

// cube.position.x = 1;
// cube 的坐标是相对于 parentCube 的,所以在页面上可以看到 cube 相对于 parentCube(父元素)向右移动到了原点坐标处
cube.position.set(3, 0, 0);

让我们逐步理解发生了什么。

🟦 第一步:parentCube 放在世界坐标 (-3,0,0)

这意味着:

“爸爸站在世界左边 3 单位的位置。”

🟦 第二步:cube 相对“爸爸”往右移动 3 个单位

cube.position.set(3,0,0) 的意思是:

cube 在父对象内部向右移动 3。

也就是说:

👉 cube 的世界坐标 = 父对象世界坐标 + 自己的本地坐标

计算一下:

  • 父对象在世界: (-3, 0, 0)
  • cube 在本地: (+3, 0, 0)

所以 cube 在世界中的真实位置:

世界位置 = (-3) + 3 = 0

——刚好回到了世界原点!🎯

这就是你看到:

cube “看起来回到了原点”。

🎯 4. 一个超形象的比喻:三.js 坐标 = 现实世界的“你”和“你爸”

  • 世界坐标:你住在北京就是北京,这是全世界都懂的绝对坐标。
  • 本地坐标:你说“我离我爸三米远”,那你得先知道你爸在哪。
  • 父对象移动时,子对象跟着被整体移动:因为你爸挪窝了,你当然也跟着挪了。

这就是 parent.add(child) 的意义:

你把 child 的命运交给了 parent。


🧪 5. 实战代码:一眼看懂坐标关系

完整例子如下:

// 父立方体
const parentCube = new THREE.Mesh(geometry, parentMaterial);
scene.add(parentCube);
parentCube.position.set(-3, 0, 0);

// 子立方体
const cube = new THREE.Mesh(geometry, material);
parentCube.add(cube);

// cube 不是在世界坐标移动,而是在父坐标系中移动
cube.position.set(3, 0, 0);


// 父元素放大了,子元素相对于父元素也会被放大 2 倍
cube.scale.set(2, 2, 2);
// 绕着 x 轴旋转, 相对于父元素旋转 45 度, 弧度制
cube.rotation.x = Math.PI / 4; 

parentCube.position.set(-3, 0, 0);
parentCube.rotation.x = Math.PI / 4 

image.png

运行后你会看到:

  • parentCube 在世界左边 (-3,0,0)
  • cube 在 parentCube 内部向右移动 3( cube 的参考点变成了 parentCube)
  • 所以 cube 的世界位置被“抵消掉”,回到原点

解决网页前端中文字体包过大的几种方案

作者 ChangYo
2025年12月1日 17:36

最近想给我的博客的网页换个字体,在修复了历史遗留的一些bug之后,去google fonts上找了自己喜欢的字体,本地测试和自己的设备发现没问题后,便以为OK了。

但是当我接朋友的设备打开时,发现网页依然是默认字体。这时候我才发现,我的设备能够翻墙,所以能够使用Google CDN服务,但是对于我的其他读者们,在大陆内是访问不了Google的,便也无法渲染字体了。

于是为了解决这个问题,我尝试了各种办法比如格式压缩,子集化(Subset),分包等等,最后考虑到本站的实际情况选用了一种比较邪门的方法,让字体压缩率达到了惊人的98.5%!于是,这篇文章就是对这个过程的总结。也希望这篇文章能够帮助到你。😊


想要自定义网站的字体,最重要的其实就是字体包的获取。大体上可以分为两种办法:在线获取网站本地部署

在线获取──利用 CDN 加速服务

CDN(Content Delivery Network) 意为内容配送网络。你可以简单理解为是一种“就近给你东西”的互联网加速服务。

传统不使用 CDN 服务的是这样的: User ←→ Server,如果相聚遥远,效果显然很差。

使用了 CDN 服务是这样的: User ←→ CDN Nodes ←→ Server,CDN 会提前把你的网站静态资源缓存到各个节点,但你需要时可以直接从最近的节点获取。

全球有多家CDN服务提供商,Google Fonts使用的CDN服务速度很快。所以如果在网络畅通的情况下,使用Google Fonts API是最简单省事的!

你可以直接在文件中导入Google fonts API:

@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&family=Merriweather:ital,opsz,wght@0,18..144,733;1,18..144,733&family=Noto+Serif+SC:wght@500&display=swap');

这样网站它便会自动向最近的Google CDN节点请求资源。

当然,这些都是建立在网络状态畅通无阻的情况下。大陆用户一般使用不了Google服务,但并不意味着无法使用CDN服务。国内的腾讯云,阿里云同样提供高效的服务,但具体的规则我并不了解,请自行阅读研究。

本地部署

既然用不了在线的,那就只能将字体包文件一并上传到服务器上了。

这种做法不需要依赖外部服务,但缺点是字体包的文件往往很大,从进入网站到彻底加载完成的时间会及其漫长!而且这种问题尤其在中日韩(CJK)字体上体现的十分明显。

以本站为例,我主要采用了三种字体:Merriweather, Inter, Noto Serif SC. 其中每种字体都包含了Bold和Regular两种格式。前面两种都属于西文字体,每种格式原始文件大小都在200kb-300kb,但是到了思源宋体这里,仅仅一种格式的字体包大小就达到了足足14M多。如果全部加载完,恐怕从进入网站到完全渲染成功,需要耽误个2分钟。所以将原始字体包文件上传是极不可取的做法!

为了解决这个问题,我在网上查阅资料,找到了三种做法。

字体格式转换(WOFF2)

WOFF2 (Web Open Font Format 2.0) 是一种专为 Web 设计的字体文件格式,旨在提供更高的压缩率和更快的加载速度,也是是目前在 Web 上部署自定义字体的推荐标准。它本质上是一种将 TTF 或 OTF 字体数据进行高度压缩后的格式,目前已经获得了所有主流浏览器的广泛支持。

我们可以找一个在线的字体格式转化网站来实现格式的转化。本文我们以NotoSerifSC-Bold.ttf为例,转换后的NotoSerifSC-Bold.woff2文件只有5.8M左右,压缩率达到了60%!

但是,这仍旧是不够的,仅两个中文字体包加起来也已经快12M,还没有算上其他字体。这对于一个网页来说依然是灾难性的。我们必须寻找另一种方法。

子集化处理(Subset)

中国人都知道,虽然中文的字符加起来有2万多个,但是我们平常交流基本只会用到3000多个,范围再大一点,6000多个字符已经可以覆盖99%的使用场景。这意味着:

我们根本不需要保留所有字符,而只需要保留常用的几千个汉字即可。

于是这就给了我们解决问题的思路了。

首先我们可以去寻找中文常用汉字字符表,这里我获取的资源是 All-Chinese-Character-Set。我们将文件下载解压后,可以在里面找到各种各样按照字频统计的官方文件。这里我们就以《通用规范汉字表》(2013年)一级字和二级字为例。我们创建一个文档char_set.txt并将一级字和二级字的内容全部复制进去。这份文档就是我们子集化的对照表。

接着我们需要下载一个字体子集化工具,这里使用的是Python中的fonttools库,它提供了许多工具(比如我们需要的pyftsubset)可以在命令行中执行子集化、字体转化字体操作。

我们安装一下这个库和对应的依赖(在这之前确保你的电脑上安装了Pythonpip,后者一般官方安装会自带)

pip install fonttools brotli zopfli

然后找到我们字体包对应的文件夹,将原来的char_set.txt复制到该文件夹内,在该文件下打开终端,然后以NotoSerifSC-Bold.ttf为例,输入以下命令:

pyftsubset NotoSerifSC-Bold.ttf --output-file=NotoSerifSC-Bold.subset.woff2 --flavor=woff2 --text-file=char_set.txt --no-hinting --with-zopfli

过一会就能看到会输出一个NotoSerifSC-Bold.subset.woff2的文件。

font-pic-1.png 我们欣喜的发现这个文件的大小竟然只有980KB。至此,我们已经已经将压缩率达到了93%!到这一步,其实直接部署也并没有十分大问题,不过从加载进去到完全渲染,可能依然需要近十秒左右,我们依然还有优化空间。

分包处理实现动态加载

这个方法是我阅读这篇文章了解到的,但是遗憾的是我并没有在自己的网站上实现,不过失败的尝试也让我去寻找其它的方法,最终找到适用本站的一种极限字体渲染的方法,比这三种的效果还要好。下面我依然简单介绍一下这个方法的原理,想更了解可以通过看到最后通过参考资料去进一步了解。

在2017年,Google Fonts团队提出切片字体,因为他们发现:绝大部分网站只需要加载CJK字体包的小部分内容即可覆盖大部分场景。基于适用频率统计,他们将字符分成多个切片,再按 Unicode 编码对剩余字符进行分类。

怎么理解呢?他其实就是把所有的字符分成许多个小集合,每个集合里面都包含一定数量的字符,在靠前的一些集合中,都是我们常用的汉字,越到后,字形越复杂,使用频率也越低。当网页需要加载字体文件时,它是以切片为单位加载的。这意味,只有当你需要用到某个片区的字符时,这个片区才会被加载。

这种方式的好处时,能够大大加快网站加载速率。我们不用每次都一次性把全部字符加载,而是按需加载。这项技术如今已经被Noto Sans字体全面采用。

但是我们需要本地部署的话,需要多费一点功夫。这里我们利用中文网字计划的在线分包网站来实现。

我们将需要的字体上传进行分包,可以观察到输出结果是一系列以哈希值命名的woff2文件。分包其实就是做切分,把每个切分后的区域都转化为一份体积极小的woff2文件。

font-pic-2.png 下载压缩包,然后可以将里面的文件夹导入你的项目,并引用文件夹下的result.css即可。理论上,当网站需要加载渲染某个字体时,它会根据css里面的规则去寻找到对应的分包再下载。每个包的体积极小,网站加载的速度应该提升的很明显。

font-pic-3.png

我的实践──将字符压缩到极限

我的方法可以理解为子集化的一种,只不过我的做法更加的极端一些──只保留文章出现的字符

根据统计结果,截止到这篇post发布,我的文章总共出现的所有字符数不到1200个(数据来源见下文),所以我们可以做的更激进一些,只需将文章出现的中文字符全部记录下来,制成一张专属于自己网站的字符表,然后在每次发布文章时动态更新,这样我们能够保证字体完整渲染,并且处于边界极限状态!

实现这个个性化字符表char_set.txt的核心是一个提取文章中文字符的算法。这部分我是通过Gemini生成了一个update_lists.cpp文件,他能够识别_posts/下面所有文章,并输出到根目录的char_set.txt中,你可以根据代码内容进行自定义的修改:

/**
 * @file update_lists.cpp
 * @brief Scans Markdown files in /_posts/ and updates char_set.txt in root.
 * @author Gemini
 * @date 2025-11-28
 */
#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <unordered_set>
#include <filesystem>
namespace fs = std::filesystem;
namespace char_collector {
const std::string kRegistryFilename = "char_set.txt";
const std::string kMarkdownExt = ".md";

const uint32_t kCJKStart = 0x4E00;
const uint32_t kCJKEnd = 0x9FFF;

bool NextUtf8Char(std::string::const_iterator& it, 
                  const std::string::const_iterator& end, 
                  uint32_t& out_codepoint,
                  std::string& out_bytes) {
  if (it == end) return false;
  unsigned char c1 = static_cast<unsigned char>(*it);
  out_bytes.clear();
  out_bytes += c1;
  if (c1 < 0x80) { out_codepoint = c1; it++; return true; }
  if ((c1 & 0xE0) == 0xC0) {
    if (std::distance(it, end) < 2) return false;
    unsigned char c2 = static_cast<unsigned char>(*(it + 1));
    out_codepoint = ((c1 & 0x1F) << 6) | (c2 & 0x3F);
    out_bytes += *(it + 1); it += 2; return true;
  }
  if ((c1 & 0xF0) == 0xE0) {
    if (std::distance(it, end) < 3) return false;
    unsigned char c2 = static_cast<unsigned char>(*(it + 1));
    unsigned char c3 = static_cast<unsigned char>(*(it + 2));
    out_codepoint = ((c1 & 0x0F) << 12) | ((c2 & 0x3F) << 6) | (c3 & 0x3F);
    out_bytes += *(it + 1); out_bytes += *(it + 2); it += 3; return true;
  }
  if ((c1 & 0xF8) == 0xF0) {
    if (std::distance(it, end) < 4) return false;
    unsigned char c2 = static_cast<unsigned char>(*(it + 1));
    unsigned char c3 = static_cast<unsigned char>(*(it + 2));
    unsigned char c4 = static_cast<unsigned char>(*(it + 3));
    out_codepoint = ((c1 & 0x07) << 18) | ((c2 & 0x3F) << 12) | 
                    ((c3 & 0x3F) << 6) | (c4 & 0x3F);
    out_bytes += *(it + 1); out_bytes += *(it + 2); out_bytes += *(it + 3); it += 4; return true;
  }
  it++; return false;
}

bool IsChineseChar(uint32_t codepoint) {
  return (codepoint >= kCJKStart && codepoint <= kCJKEnd);
}

class CharManager {
 public:
  CharManager() = default;

  void LoadExistingChars(const std::string& filepath) {
    std::ifstream infile(filepath);
    if (!infile.is_open()) {
      
      std::cout << "Info: " << filepath << " not found or empty. Starting fresh." << std::endl;
      return;
    }
    std::string line;
    while (std::getline(infile, line)) {
      ProcessString(line, false);
    }
    std::cout << "Loaded " << existing_chars_.size() 
              << " unique characters from " << filepath << "." << std::endl;
  }

  void ScanDirectory(const std::string& directory_path) {
    
    if (!fs::exists(directory_path)) {
        std::cerr << "Error: Directory '" << directory_path << "' does not exist." << std::endl;
        return;
    }
    
    for (const auto& entry : fs::directory_iterator(directory_path)) {
      if (entry.is_regular_file() && 
          entry.path().extension() == kMarkdownExt) {
        ProcessFile(entry.path().string());
      }
    }
  }

  void SaveNewChars(const std::string& filepath) {
    if (new_chars_list_.empty()) {
      std::cout << "No new Chinese characters found." << std::endl;
      return;
    }
    std::ofstream outfile(filepath, std::ios::app);
    if (!outfile.is_open()) {
      std::cerr << "Error: Could not open " << filepath << " for writing." << std::endl;
      return;
    }
    for (const auto& ch : new_chars_list_) {
      outfile << ch;
    }
    std::cout << "Successfully added " << new_chars_list_.size() 
              << " new characters to " << filepath << std::endl;
  }

 private:
  std::unordered_set<std::string> existing_chars_;
  std::vector<std::string> new_chars_list_;

  void ProcessFile(const std::string& filepath) {
    std::ifstream file(filepath);
    if (!file.is_open()) return;
    
    std::cout << "Scanning: " << fs::path(filepath).filename().string() << std::endl;
    std::string content((std::istreambuf_iterator<char>(file)), 
                         std::istreambuf_iterator<char>());
    ProcessString(content, true);
  }

  void ProcessString(const std::string& content, bool track_new) {
    auto it = content.begin();
    auto end = content.end();
    uint32_t codepoint;
    std::string bytes;

    while (NextUtf8Char(it, end, codepoint, bytes)) {
      if (IsChineseChar(codepoint)) {
        if (existing_chars_.find(bytes) == existing_chars_.end()) {
          existing_chars_.insert(bytes);
          if (track_new) {
            new_chars_list_.push_back(bytes);
          }
        }
      }
    }
  }
};

} 

int main() {
  char_collector::CharManager manager;
  manager.LoadExistingChars(char_collector::kRegistryFilename);
  manager.ScanDirectory("_posts");
  manager.SaveNewChars(char_collector::kRegistryFilename);
  return 0;
}

然后我们在终端编译一下再运行即可:

clang++ update_lists.cpp -o update_lists  && ./update_lists

然后我们就会发现这张独属于本站的字符表生成了!🥳 font-pic-6.png 为了方便操作,我们把原始的ttf文件放入仓库的/FontRepo/下(最后记得在.gitignore添加这个文件夹!),然后稍微修改一下之前子集化的命令就可以了:

pyftsubset /FontRepo/NotoSerifSC-Bold.ttf --output-file=/assets/fonts/noto-serif-sc/NotoSerifSC-Bold.subset.woff2 --flavor=woff2 --text-file=char_set.txt --no-hinting --with-zopfli

可以看到,最终输出的文件只有200K!压缩率达到了98.5%!

font-pic-4.png 但是这个方法就像前面说的,处于字体渲染的边界。但凡多出一个字符表中的符号,那么这个字符就无法渲染,会回退到系统字体,看起来格外别扭。所以,在每次更新文章前,我们都需要运行一下./update_lists。此外,还存在一个问题,每次更新产生新的子集化文件时,都需要把旧的子集化文件删除,防止旧文件堆积。

这些过程十分繁琐而且耗费时间,所以我们可以写一个bash脚本来实现这个过程的自动化。我这里同样是求助了Gemini,写了一个build_fonts.sh

#!/bin/bash
set -e  # 遇到错误立即停止执行

# ================= 配置区域 =================
# 字体源文件目录
SRC_DIR="FontRepo"
# 字体输出目录
OUT_DIR="assets/fonts/noto-serif-sc"
# 字符列表文件
CHAR_LIST="char_set.txt"
# C++ 更新工具
UPDATE_TOOL="./updateLists"

# 确保输出目录存在
if [ ! -d "$OUT_DIR" ]; then
    echo "创建输出目录: $OUT_DIR"
    mkdir -p "$OUT_DIR"
fi

# ================= 第一步:更新字符表 =================
echo "========================================"
echo ">> [1/3] 正在更新字符列表..."
if [ -x "$UPDATE_TOOL" ]; then
    $UPDATE_TOOL
else
    echo "错误: 找不到可执行文件 $UPDATE_TOOL 或者没有执行权限。"
    echo "请尝试运行: chmod +x updateLists"
    exit 1
fi
# 检查 char_set.txt 是否成功生成
if [ ! -f "$CHAR_LIST" ]; then
    echo "错误: $CHAR_LIST 未找到,字符表更新可能失败。"
    exit 1
fi
echo "字符列表更新完成。"
# ================= 定义子集化处理函数 =================
process_font() {
    local font_name="$1"    # 例如: NotoSerifSC-Regular
    local input_ttf="$SRC_DIR/${font_name}.ttf"
    local final_woff2="$OUT_DIR/${font_name}.woff2"
    local temp_woff2="$OUT_DIR/${font_name}.temp.woff2"

    echo "----------------------------------------"
    echo "正在处理字体: $font_name"
    # 检查源文件是否存在
    if [ ! -f "$input_ttf" ]; then
        echo "错误: 源文件 $input_ttf 不存在!"
        exit 1
    fi

    # 2. 调用 fonttools (pyftsubset) 生成临时子集文件
    # 使用 --obfuscate-names 可以进一步减小体积,但这里只用基础参数以保证稳定性
    echo "正在生成子集 (TTF -> WOFF2)..."
    pyftsubset "$input_ttf" \
        --flavor=woff2 \
        --text-file="$CHAR_LIST" \
        --output-file="$temp_woff2"
    # 3. & 4. 删除旧文件并重命名 (更新逻辑)
    if [ -f "$temp_woff2" ]; then
        if [ -f "$final_woff2" ]; then
            echo "删除旧文件: $final_woff2"
            rm "$final_woff2"
        fi
        
        echo "重命名新文件: $temp_woff2 -> $final_woff2"
        mv "$temp_woff2" "$final_woff2"
        echo ">>> $font_name 更新成功!"
    else
        echo "错误: 子集化失败,未生成目标文件。"
        exit 1
    fi
}
# ================= 第二步 & 第三步:执行转换 =================
echo "========================================"
echo ">> [2/3] 开始字体子集化处理..."
# 处理 Regular 字体
process_font "NotoSerifSC-Regular"
# 处理 Bold 字体
process_font "NotoSerifSC-Bold"
echo "========================================"
echo ">> [3/3] 所有任务圆满完成!"

如此一来,以后每次更新完文章,都只需要在终端输入./build_fonts.sh就可以完成字符提取、字体包子集化、清除旧字体包文件的过程了。

font-pic-5.png

一点感想

在这之前另外讲个小故事,我尝试更换字体之前发现自定义的字体样式根本没有用,后来检查了很久,发现竟然是2个月前AI在我代码里加的一句font-family:'Noto Serif SC',而刚好他修改的又是优先级最高的文件,所以后面怎么修改字体都没有用。所以有时候让AI写代码前最好先搞清除代码的地位i,并且做好为AI代码后果负全责的准备。

更改网站字体其实很多时候属于锦上添花的事情,因为很多读者其实并不会太在意网站的字体。但不幸的是我对细节比较在意,或者说有种敝帚自珍的感觉吧,想慢慢地把网站装饰得舒适一些,所以才总是花力气在一些细枝末节的事情上。更何况,我是懂一点点设计的,有时候看见一些非常丑的Interface心里是很难受的。尽管就像绝大部分人理解不了设计师在细节上的别有用心一样,绝大部分人也不会在意一个网站的字体如何,但是我自己的家,我想装饰地好看些,对我来说就满足了。

更不要说,如果不去折腾这些东西,怎么可能会有这篇文章呢?如果能够帮助到一些人,也算是在世界留下一点价值了。

参考资料及附录

  1. 参考资料

    a. 网页中文字体加载速度优化

    b. 缩减网页字体大小

    c. All-Chinese-Character-Set

  2. 让Gemini生成代码时的Prompt:

---Prompt 1---
# 任务名称:创建脚本实现对字符的收集
请利用C++来完成一下任务要求:
1. 该脚本能够读取项目目录下的markdown文件,并且能够识别当中所有的中文字符,将该中文字符与`/char_test/GeneralUsedChars.txt`的字符表进行查重比较:
   若该字在表中存在,则跳过,处理下一个字;
   若不存在,则将该字添加到表中,然后继续处理下一个字符
2. 请设计一个高效的算法,尤其是在字符查重的过程中,你需要设计一个高效且准确率高的算法
3. 请注意脚本的通用性,你需要考虑到这个项目以后可能会继续增加更多的markdown文件,所以你不应该仅仅只是处理现有的markdown文件,还需要考虑到以后的拓展性
4. 如果可以的话,尽可能使用C++来实现,因为效率更高

---Prompt 2---
可以了,现在我要求你编写一个脚本以实现自动化,要求如下:
1. 脚本运行时,首先会调用项目根目录下的updateLists可执行文件,更新char_set.txt
2. 接着,脚本会调用fonttools工具,对路径在`/FontRepo/`下的两个文件进行ttf到woff2的子集化转化,其中这两个字体文件的名字分别为`NotoSerifSC-Regular.ttf`和`NotoSerifSC-Bold.ttf`。
3. 转化好的子集文件应该输出到 `/assets/fonts/noto-serif-sc/`文件夹下。
4. 将`/assets/fonts/noto-serif-sc/`文件夹下原本已经存在的两个字体文件`NotoSerifSC-Bold.woff2`和`NotoSerifSC-Regular.woff2`删除,然后将新得到子集化文件重新命名为这两个删除了的文件的名字。这一步相当于完成了字体文件的更新

请注意文件的命名,尤其是不要搞错字号,新子集文件和旧子集文件。
请注意在子集化步骤的bash命令,环境已经安装好fonttools及其对应依赖,你可以参考下面这个命令来使用,或者使用更好更稳定的用法:
pyftsubset <path/to/ttf/file> --flavor=woff2 --text-file=<path/to/char_set.txt> --output-file=<the/subset/name>
(再次注意输出路径)
  1. 最终实践效果(以NotoSerifSC-Bold为例)
    处理方式 字体包体积 压缩率
    无处理 14.462M 0%
    格式转化 5.776M 60.06%
    子集化处理 981K 93.21%
    分包处理 依据动态加载量而定
    我的实践 216K 98.5%

现代 Nginx 优化实践:架构、配置与性能调优

作者 车前端
2025年12月1日 17:28

作者:王佳月(汽车之家:APP 架构前端工程师)

现代 Nginx 优化实践:架构、配置与性能调优

在当今高并发、高可用的 Web 架构中,Nginx 作为反向代理服务器扮演着至关重要的角色。本文将基于实际项目经验,深入探讨现代 Nginx 的优化策略,从基础配置、性能调优、安全加固到高级功能应用,为您提供一套全面且可落地的优化方案。

一、基础架构与核心配置优化

1.1 进程与连接优化

Nginx 的工作进程配置直接影响其并发处理能力。在项目中,我们采用了以下配置:

worker_processes  1;
worker_rlimit_nofile  65535;

events {
  multi_accept        on;
  worker_connections  65535;
}

优化说明:

  • worker_processes:通常设置为 CPU 核心数,但在容器化环境中需根据实际资源分配调整
  • worker_rlimit_nofile:提高单个进程可打开的最大文件数,解决高并发场景下的文件描述符限制
  • multi_accept on:允许 Nginx 同时接受多个连接,提高连接处理效率
  • worker_connections:每个工作进程可同时处理的最大连接数

实际效果: 在我们的项目中,这组配置使单实例 Nginx 能够稳定处理每秒上万级的请求量,CPU 使用率降低约 30%。

1.2 HTTP 核心模块优化

项目中的 HTTP 核心优化配置如下:

http {
  etag                   off;
  charset                utf-8;
  sendfile               on;
  tcp_nopush             on;
  server_tokens          off;
  log_not_found          off;
  keepalive_timeout      65;
  keepalive_requests     300;

  proxy_intercept_errors on;
  proxy_ignore_client_abort on;
  subrequest_output_buffer_size 3m;
}

关键优化点:

  1. 文件传输优化
  • sendfile on:启用零拷贝技术,减少内核与用户空间之间的数据拷贝
  • tcp_nopush on:与 sendfile 配合使用,在数据包积累到一定大小后再发送,提高网络效率
  1. 连接复用
  • keepalive_timeout 65:设置长连接超时时间
  • keepalive_requests 300:每个长连接最多处理的请求数,避免单个连接占用过久
  1. 性能与安全平衡
  • etag off:禁用 ETag,减少带宽消耗和服务器负载
  • server_tokens off:隐藏 Nginx 版本信息,提高安全性
  • log_not_found off:不记录 404 错误,减少磁盘 I/O 和日志体积
  1. 错误处理优化
  • proxy_intercept_errors on:允许 Nginx 拦截后端服务器的错误响应
  • proxy_ignore_client_abort on:忽略客户端中断连接,确保后端处理不受影响

二、缓存策略与加速优化

2.1 高效缓存配置

项目中实现了精细化的缓存策略,根据资源类型和版本信息应用不同的缓存规则:

proxy_cache_path  /var/cache/nginx/static_temp levels=1:2 keys_zone=static_cache:20m max_size=800m inactive=1d use_temp_path=off;

# 针对不同版本的缓存控制
header_filter_by_lua_block {
  local ver = ngx.var.ver
  local cache_ttl
  if ver and (ver == "latest" or ver:match("^%d+%.x$") or ver:match("^%d+%.%d+%.x$")) then
    cache_ttl = 300
  else
    cache_ttl = 31536000
  end

  ngx.header["Cache-Control"] = "public, max-age=" .. cache_ttl
}

# 缓存配置示例
proxy_cache static_cache;
proxy_cache_min_uses 3;
proxy_cache_valid 200 304 30s;
proxy_cache_valid 404 10s;
proxy_cache_valid any 30s;
proxy_cache_use_stale error timeout updating invalid_header http_500 http_502 http_503 http_504;

缓存优化策略:

  1. 缓存路径与键值设计
  • levels=1:2:创建两级目录结构,提高文件系统查找效率
  • keys_zone=static_cache:20m:分配 20MB 内存用于缓存键和元数据
  • max_size=800m:限制缓存大小,防止磁盘空间耗尽
  • inactive=1d:超过 1 天未访问的缓存项将被清理
  • use_temp_path=off:直接在缓存目录中写入,避免额外的文件移动开销
  1. 智能缓存时间策略
  • 对稳定版本资源设置长缓存时间(31536000 秒 = 1 年)
  • 对开发版、最新版和版本范围(如 1.x)设置短缓存时间(300 秒 = 5 分钟)
  • 根据 HTTP 状态码设置不同的缓存有效期
  1. 高可用性缓存
  • proxy_cache_use_stale:在后端错误、超时等情况下使用过期缓存,提高系统可用性
  • proxy_cache_min_uses 3:只有请求达到一定次数才会被缓存,避免缓存低频访问的资源

2.2 响应压缩与资源优化

项目中启用了 Gzip 压缩并进行了针对性优化:

gunzip on;
proxy_method GET;
proxy_pass_request_body off;
proxy_pass_request_headers off;
proxy_set_header Accept-Encoding "";

优化策略:

  1. 解压缩支持
  • gunzip on:自动解压缩来自后端的 gzip 压缩响应
  1. 请求优化
  • 对静态资源请求移除请求体和非必要请求头,减少数据传输量
  • 清除 Accept-Encoding 头,避免多层压缩带来的性能损耗

2.3 图片资源优化

在图片服务器配置中,实现了自动格式转换和异步裁切:

# 异步 AVIF 转换示例
access_by_lua_block {
  local is_match = "业务逻辑判断"

  if is_match then
    自定义业务逻辑
  end
}

# 异常回退机制
proxy_intercept_errors on;
recursive_error_pages on;
error_page 404 502 504 = @fallback;

# 回退到原始格式
location @fallback {
  rewrite ... break;
  # 其他配置...
}

图片优化技术要点:

  1. 现代图片格式自动转换
  • 根据请求路径自动启用格式转换,减少图片大小 30-50%
  • 实现异步裁切,提高响应速度
  1. 降级机制
  • 当转换失败时,自动回退到原始图片格式,确保服务可用性

三、高可用与负载均衡

3.1 多数据中心部署架构

项目采用了多数据中心部署策略,提高系统可用性:

# 集群A
upstream server_a {
  server xx.xx.x.xxx:xx max_fails=2 fail_timeout=2s;
  server xx.xx.x.xxx:xx max_fails=2 fail_timeout=2s;
  keepalive 320;
}

# 集群B
upstream server_b {
  server xx.xx.x.xxx:xx max_fails=2 fail_timeout=2s;
  server xx.xx.x.xxx:xx max_fails=2 fail_timeout=2s;
  keepalive 320;
}

高可用配置要点:

  1. 健康检查机制
  • max_fails=2:允许的失败次数
  • fail_timeout=2s:失败超时时间,超过该时间后将重新检查服务器健康状态
  1. 连接池复用
  • keepalive 320:为上游服务器维护的空闲连接数,减少频繁建立连接的开销
  1. 备份服务器
  • 部分 upstream 配置中添加了 backup 标记的服务器,作为灾难恢复使用

3.2 DNS 解析优化

针对微服务架构中的服务发现,项目进行了 DNS 解析优化:

resolver 127.0.0.11 10.33.3.5 10.33.3.6 10.41.0.254 valid=300s ipv6=off;
resolver_timeout 500ms;

DNS 优化策略:

  1. 多 DNS 服务器
  • 配置多个 DNS 服务器,提高解析可靠性
  • 包含 Docker 内部 DNS (127.0.0.11) 和企业内部 DNS 服务器
  1. 缓存与超时控制
  • valid=300s:DNS 解析结果缓存时间
  • resolver_timeout 500ms:限制 DNS 解析超时时间,避免长时间阻塞

四、安全加固与监控

4.1 安全头配置

项目中实现了全面的安全头设置,防止常见的 Web 攻击:

# 移除可能存在的不安全响应头
proxy_hide_header X-Frame-Options;
proxy_hide_header X-XSS-Protection;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Strict-Transport-Security;

# 添加安全头
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Strict-Transport-Security "max-age=31536000;" always;

安全头说明:

  1. XSS 防护
  • X-XSS-Protection "1; mode=block":启用浏览器内置的 XSS 过滤器,检测到攻击时阻止页面加载
  1. MIME 类型嗅探防护
  • X-Content-Type-Options "nosniff":防止浏览器对响应内容进行 MIME 类型嗅探,减少 XSS 攻击面
  1. HTTPS 强制
  • Strict-Transport-Security "max-age=31536000;":强制使用 HTTPS,防止中间人攻击

4.2 请求限制与访问控制

项目实现了请求方法限制和访问控制:

# 限制请求方法
if ($request_method !~ ^(GET|HEAD|POST)$) {
  return 403;
}

# 内部接口访问控制
location /get_versions/ {
  allow 127.0.0.1;
  deny all;
  # 其他配置...
}

安全访问控制:

  1. 请求方法限制:只允许 GET、HEAD、POST 方法,拒绝其他可能带来风险的方法
  2. 内部接口保护:通过 IP 白名单限制内部接口只能由本地访问

4.3 监控与健康检查

项目实现了完善的监控和健康检查机制:

# 健康检查接口
location = /nginx_health_check {
  access_log off;
  add_header Cache-Control "no-store";
  default_type text/plain;
  return 200 "ok";
}

# 状态监控接口
location = /nginx_basic_status {
  access_log off;
  add_header Cache-Control "no-store";
  stub_status on;
  auth_basic "NginxStatus";
  auth_basic_user_file config/ip_passwdfile;
}

监控功能说明:

  1. 健康检查:提供简单的 /nginx_health_check/ 接口,用于容器编排系统的健康检查
  2. 状态监控:启用 stub_status 模块,提供详细的 Nginx 运行状态统计
    • 包括活跃连接数、接受连接数、处理请求数等关键指标
    • 通过 HTTP 基本认证保护监控接口

五、高级功能与性能优化

5.1 客户端真实 IP 获取

项目实现了可靠的客户端真实 IP 获取机制:

# 获取客户端真实IP
map $http_x_forwarded_for $client_real_ip {
  ~^(?P<firstAddr>[\d\.\:A-f]+),?.*$  $firstAddr;
  ""  $remote_addr;
}

# 在 proxy.conf 中传递真实 IP
proxy_set_header X-Real-IP  $client_real_ip;
proxy_set_header X-Forwarded-For  $proxy_add_x_forwarded_for;

IP 获取策略:

  1. 多级代理支持:通过正则表达式从 X-Forwarded-For 头中提取第一个 IP 地址
  2. IPv4/IPv6 兼容:正则表达式支持匹配 IPv4 和 IPv6 地址格式
  3. 默认值处理:当没有 X-Forwarded-For 头时,回退到直接连接的 IP

5.2 日志优化

项目对日志记录进行了优化,减少磁盘 I/O 和提高性能:

# 不记录 HTTP 状态码为 2xx/3xx 的请求
map $status $loggable {
  default 1;
  ~^[23]  0;
}

# 使用条件日志
access_log /a-one/log/nginx/access.log alternate if=$loggable;

日志优化策略:

  1. 选择性日志记录:只记录错误和异常请求,减少正常请求的日志记录
  2. 自定义日志格式:通过 NJS 模块实现自定义日志格式,满足特定的日志分析需求

5.3 WebSocket 支持

项目中实现了 WebSocket 协议的支持,用于实时通信场景:

# WebSocket 连接头处理
map $http_upgrade $connection_upgrade {
  default upgrade;
  ""      close;
}

# 在 proxy.conf 中配置 WebSocket 支持
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;

WebSocket 优化:

  1. 协议升级支持:正确处理 HTTP 到 WebSocket 的协议升级
  2. 长连接维护:配置 HTTP/1.1 和相应的连接头,确保 WebSocket 连接稳定

六、容器化环境优化

在 Docker 环境中,项目进行了针对性的优化:

# Docker 容器优化
daemon off;  # 前台运行,便于容器管理

# 容器内部 DNS 优先
resolver 127.0.0.11 ...;

容器化优化要点:

  1. 前台运行模式daemon off 确保 Nginx 运行在前台,便于 Docker 管理进程生命周期
  2. 容器网络适配:优先使用 Docker 内部 DNS 解析服务,提高容器间通信效率

七、优化效果与最佳实践

7.1 性能提升效果

通过上述优化,项目在以下方面取得了显著提升:

优化维度 优化前 优化后 提升百分比
请求处理能力 5k QPS 20k QPS +300%
平均响应时间 100ms 35ms -65%
CPU 使用率 70% 40% -43%
内存占用 512MB 256MB -50%
缓存命中率 60% 90% +50%

7.2 高可用效果

  • 服务可用性:从 99.9% 提升到 99.99%,每年减少约 8.76 小时的潜在停机时间
  • 故障自动恢复:通过 proxy_cache_use_staleproxy_next_upstream 配置,实现了在后端服务异常时的自动恢复
  • 多活架构:公司异地双数据中心部署,确保单点故障不影响整体服务

7.3 安全加固效果

  • 安全扫描评分:从 75 分提升到 95 分
  • XSS 防护:通过安全头配置,有效防御常见的 XSS 攻击
  • 访问控制:内部接口访问控制有效阻止了未授权访问

八、总结与未来优化方向

本文结合实际项目,详细介绍了现代 Nginx 的多维度优化策略,包括基础配置优化、缓存策略、高可用架构、安全加固和高级功能应用。这些优化措施在实际生产环境中取得了显著的性能提升和稳定性改善。

未来的优化方向可以考虑:

  1. 引入 Nginx Plus 或 OpenResty:利用更高级的功能如动态配置、健康检查等
  2. 实现智能缓存预热:根据访问模式预测性地缓存热点资源
  3. 接入可观测性平台:整合 Prometheus 和 Grafana,实现更细粒度的监控和告警
  4. 探索 QUIC/HTTP3 协议:利用新一代网络协议进一步提升性能
  5. AI 驱动的自动优化:基于机器学习分析流量模式,自动调整优化参数

通过持续优化和监控,Nginx 服务器可以在不断变化的业务需求和技术环境中保持最佳性能和可靠性。

首字母模糊匹配

2025年12月1日 17:13

我们在日常开发中经常会用到筛选的功能,比如一个表格数据,需要根据其中的某一列去进行模糊匹配筛选,一般都是去判断字符串中是否包含某个子字符串,但是这样是不支持首字母模糊匹配的,所以我们可以使用一个第三方包pinyin去实现这种功能。

pinyin可以直接使用npm下载。

1.表格的筛选

export function searchedFilter(rows, searchValue, keyValue) {
  searchValue = searchValue.trim();
  if (searchValue) {
    const pathen = /^[\u4e00-\u9fa5]+$/;
    if (pathen.test(searchValue)) {
      return rows.filter((data) => {
        return Object.keys(data).some((key) => {
          if (key === keyValue) {
            return data[key].includes(searchValue);
          }
        });
      });
    } else {
      const searchValuePinyin = pinyin(searchValue, {
        style: pinyin.STYLE_FIRST_LETTER,
      }).join("");
      return rows.filter((data) => {
        return Object.keys(data).some((key) => {
          if (key === keyValue) {
            const dataPyArr = pinyin(data[key], {
              style: pinyin.STYLE_FIRST_LETTER,
            });
            const dataPy = dataPyArr.join("");
            return dataPy.includes(searchValuePinyin);
          }
        });
      });
    }
  }
  return rows;
}

该方法接收三个参数,rows是表格数据,searchValue是筛选字符串,keyValue是要匹配的表格的某一列的prop

下面举个例子:

<template>
  <div class="screen-view">
    <span>筛选字段:</span>
    <el-input v-model="filterText" style="width:120px"></el-input>
    <el-button @click="filterHandle" type="primary">筛选</el-button>
    <el-table :data="showTableData" stripe border height="500" :cell-style="{ textAlign: 'center' }"
      :header-cell-style="{ textAlign: 'center' }">
      <el-table-column label="姓名" prop="name"></el-table-column>
      <el-table-column label="地址" prop="address"></el-table-column>
      <el-table-column label="职位" prop="job"></el-table-column>
    </el-table>
  </div>
</template>

<script>
import { searchedFilter } from '@/utils/changePinyin'
export default {
  data() {
    return {
      tableData: [
        { name: '月亮', address: '江苏省南京市', job: '前端开发' },
        { name: '月亮1', address: '江苏省南京市', job: '前端开发' },
        { name: '月亮2', address: '江苏省南京市', job: '前端开发' },
        { name: '大傻', address: '安徽省合肥市', job: '后端开发' },
        { name: '大傻1', address: '安徽省合肥市', job: '后端开发' },
        { name: '大傻2', address: '安徽省合肥市', job: '后端开发' },
        { name: '二狗', address: '四川省成都市', job: '前端开发' },
        { name: '二狗1', address: '四川省成都市', job: '前端开发' },
        { name: '二狗2', address: '四川省成都市', job: '前端开发' },
        { name: '三驴子', address: '河南省郑州市', job: '后端开发' },
        { name: '三驴子1', address: '河南省郑州市', job: '后端开发' },
        { name: '三驴子2', address: '河南省郑州市', job: '后端开发' },
      ],
      showTableData: [],
      filterText: ''
    }
  },
  created() {
    this.showTableData = this.tableData
  },
  methods: {
    filterHandle() {
      this.showTableData = searchedFilter(this.tableData, this.filterText, 'name')
      // 这就是筛选表格的姓名字段,如果想筛选别的就把name换成对应的prop
      // 支持首字母匹配
    }
  }
}
</script>

<style lang="scss" scoped>
.screen-view {
  height: 100%;
  padding: 10px;
  box-sizing: border-box;
}
</style>

这个方法只能根据表格的一列进行筛选,如果想同时匹配多列的话,可以使用下面的方法。

export function searchedFilters(rows, searchValue, keyValues) {
  searchValue = searchValue.trim();
  if (searchValue) {
    const pathen = /^[\u4e00-\u9fa5]+$/;
    if (pathen.test(searchValue)) {
      return rows.filter((data) => {
        return Object.keys(data).some((key) => {
          return keyValues.some((k) => {
            if (key === k) {
              return data[key].includes(searchValue);
            }
          });
        });
      });
    } else {
      const searchValuePinyin = pinyin(searchValue, {
        style: pinyin.STYLE_FIRST_LETTER,
      }).join("");
      return rows.filter((data) => {
        return Object.keys(data).some((key) => {
          return keyValues.some((k) => {
            if (key === k) {
              const dataPyArr = pinyin(data[key], {
                style: pinyin.STYLE_FIRST_LETTER,
              });
              const dataPy = dataPyArr.join("");
              return dataPy.includes(searchValuePinyin);
            }
          });
        });
      });
    }
  }
  return rows;
}

还是接收三个参数,前面两个跟第一个方法一样,表格数据和筛选字符串,第三个是一个数组,里面存放的是你想匹配的列。

比如我们想同时筛选姓名和地址这两列:

    filterHandle() {
      this.showTableData = searchedFilters(this.tableData, this.filterText, ['name', 'address'])
    }

2.树结构的筛选

export function treeFilterPY(data, searchValue, keyValue) {
  searchValue = searchValue.trim();
  if (!searchValue) return true;
  const pathen = /^[\u4e00-\u9fa5]+$/;
  if (pathen.test(searchValue)) {
    return data[keyValue].indexOf(searchValue) !== -1;
  }
  // 匹配小写
  const labelValue = pinyin(data[keyValue], {
    style: pinyin.STYLE_FIRST_LETTER,
  }).join("");
  const searchValuePinyin = pinyin(searchValue, {
    style: pinyin.STYLE_FIRST_LETTER,
  }).join("");
  return labelValue.indexOf(searchValuePinyin) !== -1;
}

这个方法接收三个参数,分别是树结构数据,筛选字符串,对应的prop。

<template>
  <div class="screen-view">
    <span>筛选字段:</span>
    <el-input v-model="filterText" style="width:120px"></el-input>
    <el-button @click="filterHandle" type="primary">筛选</el-button>
    <el-tree ref="treeRef" :data="treeData" :filter-node-method="filterNode"></el-tree>
  </div>
</template>

<script>
import { treeFilterPY } from '@/utils/changePinyin'
export default {
  data() {
    return {
      defaultProps: {
        children: "children",
        label: "label",
      },
      treeData: [
        {
          label: '全部',
          id: -1,
          children: [
            {
              label: '月亮',
              id: 1
            },
            {
              label: '大傻',
              id: 2
            },
            {
              label: '二狗',
              id: 3
            },
            {
              label: '三驴子',
              id: 4
            },
          ]
        }
      ],
      filterText: ''
    }
  },
  created() {
    this.showTableData = this.tableData
  },
  methods: {
    filterHandle() {
      this.$refs.treeRef.filter(this.filterText);
    },
    filterNode(value, data) {
      return treeFilterPY(data, value, this.defaultProps.label);
    },
  }
}
</script>

<style lang="scss" scoped>
.screen-view {
  height: 100%;
  padding: 10px;
  box-sizing: border-box;
}
</style>

从border-image 到 mask + filer 实现圆角渐变边框

2025年12月1日 17:12

用 CSS Mask + Filter 实现高级渐变圆角边框

前言

故事开始于一张恶心人的设计UI稿开始,由于签了保密协议,只能切割设计稿,展示恶心的片段; 最近手头刚好有个大屏的项目,我们的设计师,于是乎,搞出了如下片段:

7784e481-facf-4d3b-a285-0a13b18f9101.png

90be2647-c1c8-450c-95c7-8684b83ecd8b.png

10c91969-8c93-4028-9036-f5a66479ec0a.png

650c9eaf-a76d-4502-a144-65d78a52aab2.png

cdc2af44-ae1d-43a9-90be-0f0c5001a10b.png

各位jym,你们想到哪些方案呢?评论区见! 最简单省事粗暴的方案,就是UI直接给切图,但俺们是有追求的(其实以前也干过),性能要有要求的于是乎采用以下方案实现!

前面2张图很好实现,border-image 渐变既可以很好实现; 后面三张设计图,是有圆角的,border-image 无法实现圆角,border-radius 可以实现圆角但无法实现渐变边框;

故事主角出现了mask + filter

图一:border-image:linear-gradient(90deg, #038AFE 0%, rgba(3, 138, 254, 0.3) 48%, #038AFE 100%) 0.5

图二:border-image: linear-gradient(90deg, #12c1ea 0%, rgba(3, 138, 254, .3) 50%, #12c1ea 100%) 1 1;

图三:

.mask{
    position: absolute;
    top: 0;
    right: 0;
    bottom: 0;
    left: 0;
    border: 1px solid transparent;
    border-radius: 10px;
    -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
    mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude;
    z-index: 0;
}

.mask::after{
    content: '';
    position: absolute;
    bottom: -5px;
    left: 0;
    right: 0;
    height: 100%;
    background: linear-gradient(180degrgba(01742550.250%#00AEFF 100%);
    filter: blur(10px);
    z-index: 0;
}

图四:跟图三其实一样的,只是伪类的高度设置不一样

.mask::after{
    content: '';
    position: absolute;
    bottom: -5px;
    left: 0;
    right: 0;
    height: 80%;
    background: linear-gradient(180degrgba(01742550.250%#00AEFF 100%);
    filter: blur(10px);
    z-index: 0;
}

图五:遮罩层都一样,不一样的是伪类,渐变色设置的技巧

.mask::after{
    content: '';
    position: absolute;
    bottom: -5px;
    left: 0;
    right: 0;
    height: 80%;
    background: linear-gradient(180deg#00AEFF 0%rgba(1179255019%rgba(1179255077%#00AEFF 96%);
    filter: blur(10px);
    z-index: 0;
}

核心概念

CSS Mask 属性

mask 属性允许我们使用图像、SVG 或渐变作为遮罩,控制元素的可见区域。它的工作原理类似于 Photoshop 中的遮罩层。

基本语法:

mask: <mask-source> <mask-mode> <mask-position> / <mask-size> <mask-repeat> <mask-origin> <mask-clip> <mask-composite>;

其中,mask-composite 属性定义了多个遮罩层如何组合。对于实现渐变边框,我们主要使用 exclude 值,它会显示两个遮罩层的非重叠区域。

daa70877-b139-4c1c-a697-43f66c387025.png

CSS Filter 属性

filter 属性用于对元素应用图形效果,如模糊、对比度、亮度等。我们可以结合 filter 来增强渐变边框的视觉效果。

常用 filter 函数:

函数名 描述 示例
blur() 模糊效果 blur(10px)
contrast() 对比度调整 contrast(150%)
brightness() 亮度调整 brightness(120%)
saturate() 饱和度调整 saturate(200%)
opacity() 透明度调整 opacity(0.8)

实现方法

方法一:基础 Mask 渐变边框

核心思路: 使用两层渐变遮罩,通过 mask-composite: exclude 实现边框效果。

7f260658-5a1a-4d1e-967b-93aba5445ceb.png

<div class="gradient-border basic">
    <h3>基础渐变边框</h3>
    <p>使用 mask-composite: exclude 实现</p>
</div>
.gradient-border.basic {
    /* 背景渐变 */
    background: linear-gradient(45deg, #96ceb4, #ffeead, #ff6b6b);
    
    /* 遮罩 */
    mask: 
        /* 内层遮罩:白色矩形,大小与元素相同,有圆角 */
        linear-gradient(#fff 0 0) content-box,
        /* 外层遮罩:白色矩形,大小与元素相同 */
        linear-gradient(#fff 0 0);
    /* 设置遮罩属性 */
    mask-composite: exclude;
    /* 内边距,控制边框宽度 */
    padding: 6px;
}

效果说明: 内层遮罩显示元素内容区域,外层遮罩显示整个元素,通过 exclude 组合,只显示两层遮罩的非重叠区域,即边框部分。

方法二:伪元素 + Mask

核心思路: 使用伪元素创建渐变背景,通过 mask 属性控制显示区域。

dfcf36b1-eb00-46f0-a482-472505e5dc0a.png

<div class='demo'>
    <div class="gradient-border pseudo-element">
        <h3>伪元素 + Mask</h3>
        <p>使用 ::before 伪元素创建渐变背景</p>
    </div>
</div>
.demo {
    position:relative;
}
.gradient-border.pseudo-element {
    position: absolute; 
    top: 0; 
    right: 0; 
    bottom: 0; 
    left: 0; 
    border: 1px solid transparent; 
    border-radius: 10px; -webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0); 
    mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
    -webkit-mask-composite: xor;
    mask-composite: exclude; z-index: 0;
}

.gradient-border.pseudo-element::before {
    content: ''; 
    position: absolute; 
    bottom: -1px; 
    left: -1px; 
    right: -1px; 
    top:-1px;
    background: linear-gradient(180degrgba(01742550.250%#00AEFF 100%); filter: blur(10px);
    z-index: 0;
}

效果对比

实现方式 特点 适用场景
基础 Mask 简洁、性能好 简单渐变边框需求
伪元素 + Mask 灵活、易于控制 需要复杂边框样式

浏览器兼容性

CSS Mask 属性的浏览器支持情况如下:

浏览器 版本
Chrome 85+
Firefox 70+
Safari 15+
Edge 85+

对于不支持 mask-composite: exclude 的浏览器,我们可以使用 -webkit-mask-composite: xor 作为替代:

.gradient-border {
    mask-composite: exclude;
    /* Safari 兼容性 */
    -webkit-mask-composite: xor;
}

最佳实践

  1. 选择合适的实现方式:根据需求和浏览器支持情况,选择最适合的实现方式。
  2. 性能优化:避免在大量元素上同时使用复杂的 mask 和 filter 效果,这可能会影响页面性能。
  3. 降级方案:为不支持 mask 属性的浏览器提供降级样式,例如使用传统的 border 或伪元素方法。
  4. 渐变颜色选择:选择对比度适中、和谐的渐变颜色,避免过于刺眼的颜色组合。
  5. 边框宽度:边框宽度不宜过宽,一般建议在 2-8px 之间,这样视觉效果最佳。

总结

使用 CSS 的 maskfilter 属性实现渐变圆角边框是一种高级且灵活的方法,它具有以下优点:

  1. 代码简洁:相比传统的嵌套元素或复杂伪元素方法,代码更加简洁和易于维护。
  2. 效果丰富:可以实现多种高级效果,如毛玻璃边框、动态渐变边框等。
  3. 灵活可控:可以通过调整 mask 属性和 filter 属性,精确控制边框的外观和效果。
  4. 性能优良:相比 JavaScript 实现的动态边框,CSS 实现的性能更好。

虽然 mask 属性的浏览器支持还不是 100%,但在现代浏览器中已经得到了很好的支持。通过提供适当的降级方案,我们可以在项目中安全地使用这种方法。

希望本文对你理解和使用 CSS Mask + Filter 实现渐变圆角边框有所帮助!如果你有任何问题或想法,欢迎在评论区留言讨论。

参考资料

❌
❌