普通视图

发现新文章,点击刷新页面。
昨天 — 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),描述物体在整个场景中的绝对位置
    • 局部/本地坐标,基准点为物体自身的几何中心(中心点),描述子物体相对于父物体的位置
  • 子对象相对于父对象的坐标是怎么计算的?
  • 为什么把 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);

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 实现渐变圆角边框有所帮助!如果你有任何问题或想法,欢迎在评论区留言讨论。

参考资料

判断dom元素是否在可视区域的常规方式

2025年12月1日 17:08

1. Intersection Observer API(推荐)

这是现代浏览器推荐的方法,性能最好,异步执行,不会阻塞主线程。

基础用法

// 创建观察器
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('元素进入可视区域', entry.target);
      // 可以在这里执行懒加载等操作
      entry.target.classList.add('visible');
    } else {
      console.log('元素离开可视区域', entry.target);
      entry.target.classList.remove('visible');
    }
  });
});

// 观察元素
const elements = document.querySelectorAll('.watch-element');
elements.forEach(el => observer.observe(el));

高级配置

const options = {
  // root: 指定根元素,默认为浏览器视窗
  root: null, // 或者指定特定元素,如 document.querySelector('.container')
  
  // rootMargin: 根的外边距,可以扩大或缩小根的边界框
  rootMargin: '10px 0px -100px 0px', // 上右下左,类似CSS margin
  
  // threshold: 触发回调的可见比例
  threshold: [0, 0.25, 0.5, 0.75, 1] // 在0%, 25%, 50%, 75%, 100%可见时触发
};

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    const visiblePercentage = Math.round(entry.intersectionRatio * 100);
    console.log(`元素可见 ${visiblePercentage}%`);
    
    // 根据可见比例执行不同操作
    if (entry.intersectionRatio > 0.5) {
      // 超过50%可见
      entry.target.classList.add('mostly-visible');
    }
  });
}, options);

实用工具函数

// 封装的工具函数
function createVisibilityObserver(options = {}) {
  const defaultOptions = {
    root: null,
    rootMargin: '0px',
    threshold: 0.1
  };
  
  const finalOptions = { ...defaultOptions, ...options };
  
  return new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      const element = entry.target;
      const isVisible = entry.isIntersecting;
      
      // 触发自定义事件
      element.dispatchEvent(new CustomEvent('visibilityChange', {
        detail: { isVisible, entry }
      }));
    });
  }, finalOptions);
}

// 使用示例
const observer = createVisibilityObserver({ threshold: 0.5 });

document.querySelectorAll('.lazy-load').forEach(element => {
  observer.observe(element);
  
  element.addEventListener('visibilityChange', (e) => {
    if (e.detail.isVisible) {
      // 执行懒加载
      const img = element.querySelector('img[data-src]');
      if (img) {
        img.src = img.dataset.src;
        img.removeAttribute('data-src');
      }
    }
  });
});

2. getBoundingClientRect() 方法

传统方法,同步执行,需要手动调用。

基础用法

function isInViewport(element) {
  const rect = element.getBoundingClientRect();
  const windowHeight = window.innerHeight || document.documentElement.clientHeight;
  const windowWidth = window.innerWidth || document.documentElement.clientWidth;
  
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <= windowHeight &&
    rect.right <= windowWidth
  );
}

// 使用示例
const element = document.querySelector('.target');
if (isInViewport(element)) {
  console.log('元素完全在视窗内');
}

部分可见判断

function isPartiallyInViewport(element) {
  const rect = element.getBoundingClientRect();
  const windowHeight = window.innerHeight || document.documentElement.clientHeight;
  const windowWidth = window.innerWidth || document.documentElement.clientWidth;
  
  return (
    rect.bottom > 0 &&
    rect.top < windowHeight &&
    rect.right > 0 &&
    rect.left < windowWidth
  );
}

// 更详细的可见性信息
function getVisibilityInfo(element) {
  const rect = element.getBoundingClientRect();
  const windowHeight = window.innerHeight;
  const windowWidth = window.innerWidth;
  
  // 计算可见区域
  const visibleTop = Math.max(0, rect.top);
  const visibleLeft = Math.max(0, rect.left);
  const visibleBottom = Math.min(windowHeight, rect.bottom);
  const visibleRight = Math.min(windowWidth, rect.right);
  
  const visibleWidth = Math.max(0, visibleRight - visibleLeft);
  const visibleHeight = Math.max(0, visibleBottom - visibleTop);
  const visibleArea = visibleWidth * visibleHeight;
  const totalArea = rect.width * rect.height;
  
  return {
    isVisible: visibleArea > 0,
    isFullyVisible: isInViewport(element),
    visibilityRatio: totalArea > 0 ? visibleArea / totalArea : 0,
    rect: rect,
    visibleArea: { width: visibleWidth, height: visibleHeight }
  };
}

// 使用示例
const element = document.querySelector('.target');
const info = getVisibilityInfo(element);
console.log(`可见比例: ${(info.visibilityRatio * 100).toFixed(2)}%`);

滚动监听版本

class ScrollVisibilityTracker {
  constructor(options = {}) {
    this.elements = new Map();
    this.threshold = options.threshold || 0.1;
    this.throttleDelay = options.throttleDelay || 100;
    
    this.checkVisibility = this.throttle(this.checkVisibility.bind(this), this.throttleDelay);
    this.bindEvents();
  }
  
  observe(element, callback) {
    this.elements.set(element, {
      callback,
      wasVisible: false
    });
    
    // 初始检查
    this.checkElement(element);
  }
  
  unobserve(element) {
    this.elements.delete(element);
  }
  
  checkVisibility() {
    this.elements.forEach((data, element) => {
      this.checkElement(element);
    });
  }
  
  checkElement(element) {
    const data = this.elements.get(element);
    if (!data) return;
    
    const info = getVisibilityInfo(element);
    const isVisible = info.visibilityRatio >= this.threshold;
    
    if (isVisible !== data.wasVisible) {
      data.wasVisible = isVisible;
      data.callback(isVisible, info);
    }
  }
  
  bindEvents() {
    window.addEventListener('scroll', this.checkVisibility, { passive: true });
    window.addEventListener('resize', this.checkVisibility);
  }
  
  destroy() {
    window.removeEventListener('scroll', this.checkVisibility);
    window.removeEventListener('resize', this.checkVisibility);
    this.elements.clear();
  }
  
  throttle(func, delay) {
    let timeoutId;
    let lastExecTime = 0;
    
    return function (...args) {
      const currentTime = Date.now();
      
      if (currentTime - lastExecTime > delay) {
        func.apply(this, args);
        lastExecTime = currentTime;
      } else {
        clearTimeout(timeoutId);
        timeoutId = setTimeout(() => {
          func.apply(this, args);
          lastExecTime = Date.now();
        }, delay - (currentTime - lastExecTime));
      }
    };
  }
}

// 使用示例
const tracker = new ScrollVisibilityTracker({ threshold: 0.5 });

document.querySelectorAll('.track-element').forEach(element => {
  tracker.observe(element, (isVisible, info) => {
    if (isVisible) {
      element.classList.add('in-view');
      console.log('元素进入视窗', info);
    } else {
      element.classList.remove('in-view');
    }
  });
});

3. 特殊场景的解决方案

在滚动容器中的元素

function isInScrollContainer(element, container) {
  const elementRect = element.getBoundingClientRect();
  const containerRect = container.getBoundingClientRect();
  
  return (
    elementRect.top >= containerRect.top &&
    elementRect.left >= containerRect.left &&
    elementRect.bottom <= containerRect.bottom &&
    elementRect.right <= containerRect.right
  );
}

// 使用Intersection Observer观察滚动容器
function createContainerObserver(container) {
  return new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      console.log('容器内元素可见性变化', entry.isIntersecting);
    });
  }, {
    root: container, // 指定容器为根元素
    threshold: 0.1
  });
}

考虑CSS Transform的情况

function getTransformedBounds(element) {
  const rect = element.getBoundingClientRect();
  
  // 如果元素有CSS transform,getBoundingClientRect已经包含了变换后的位置
  // 不需要额外计算
  return rect;
}

// 对于复杂的3D变换,可能需要更精确的计算
function isTransformedElementVisible(element) {
  const rect = element.getBoundingClientRect();
  
  // 检查元素是否因为transform: scale(0)等而不可见
  const computedStyle = getComputedStyle(element);
  const transform = computedStyle.transform;
  
  if (transform === 'none') {
    return isPartiallyInViewport(element);
  }
  
  // 检查是否有scale(0)或类似的变换
  if (rect.width === 0 || rect.height === 0) {
    return false;
  }
  
  return isPartiallyInViewport(element);
}

4. 性能优化技巧

虚拟滚动场景

class VirtualScrollObserver {
  constructor(container, options = {}) {
    this.container = container;
    this.itemHeight = options.itemHeight || 100;
    this.buffer = options.buffer || 5; // 缓冲区项目数量
    this.items = [];
    this.visibleRange = { start: 0, end: 0 };
    
    this.handleScroll = this.throttle(this.calculateVisibleRange.bind(this), 16);
    this.container.addEventListener('scroll', this.handleScroll);
  }
  
  calculateVisibleRange() {
    const scrollTop = this.container.scrollTop;
    const containerHeight = this.container.clientHeight;
    
    const start = Math.max(0, Math.floor(scrollTop / this.itemHeight) - this.buffer);
    const end = Math.min(
      this.items.length - 1,
      Math.ceil((scrollTop + containerHeight) / this.itemHeight) + this.buffer
    );
    
    if (start !== this.visibleRange.start || end !== this.visibleRange.end) {
      this.visibleRange = { start, end };
      this.onVisibleRangeChange(this.visibleRange);
    }
  }
  
  onVisibleRangeChange(range) {
    // 子类实现或通过回调处理
    console.log('可见范围变化:', range);
  }
  
  throttle(func, delay) {
    let lastTime = 0;
    return function(...args) {
      const now = Date.now();
      if (now - lastTime >= delay) {
        func.apply(this, args);
        lastTime = now;
      }
    };
  }
}

懒加载图片完整实现

class LazyImageLoader {
  constructor(options = {}) {
    this.options = {
      rootMargin: '50px',
      threshold: 0.1,
      loadingClass: 'lazy-loading',
      loadedClass: 'lazy-loaded',
      errorClass: 'lazy-error',
      ...options
    };
    
    this.observer = new IntersectionObserver(
      this.handleIntersection.bind(this),
      {
        rootMargin: this.options.rootMargin,
        threshold: this.options.threshold
      }
    );
    
    this.loadingImages = new Set();
  }
  
  observe(img) {
    if (!(img instanceof HTMLImageElement)) {
      console.warn('LazyImageLoader: 只能观察img元素');
      return;
    }
    
    if (!img.dataset.src && !img.dataset.srcset) {
      console.warn('LazyImageLoader: 图片缺少data-src或data-srcset属性');
      return;
    }
    
    this.observer.observe(img);
  }
  
  handleIntersection(entries) {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        this.loadImage(entry.target);
        this.observer.unobserve(entry.target);
      }
    });
  }
  
  loadImage(img) {
    if (this.loadingImages.has(img)) return;
    
    this.loadingImages.add(img);
    img.classList.add(this.options.loadingClass);
    
    const tempImg = new Image();
    
    tempImg.onload = () => {
      this.applyImage(img, tempImg);
      img.classList.remove(this.options.loadingClass);
      img.classList.add(this.options.loadedClass);
      this.loadingImages.delete(img);
    };
    
    tempImg.onerror = () => {
      img.classList.remove(this.options.loadingClass);
      img.classList.add(this.options.errorClass);
      this.loadingImages.delete(img);
    };
    
    // 支持srcset
    if (img.dataset.srcset) {
      tempImg.srcset = img.dataset.srcset;
    }
    tempImg.src = img.dataset.src;
  }
  
  applyImage(img, tempImg) {
    if (img.dataset.srcset) {
      img.srcset = img.dataset.srcset;
      delete img.dataset.srcset;
    }
    img.src = tempImg.src;
    delete img.dataset.src;
  }
  
  destroy() {
    this.observer.disconnect();
    this.loadingImages.clear();
  }
}

// 使用示例
const lazyLoader = new LazyImageLoader({
  rootMargin: '100px',
  threshold: 0.1
});

document.querySelectorAll('img[data-src]').forEach(img => {
  lazyLoader.observe(img);
});

5. 兼容性处理

// Intersection Observer polyfill检查
function createCompatibleObserver(callback, options) {
  if ('IntersectionObserver' in window) {
    return new IntersectionObserver(callback, options);
  } else {
    console.warn('IntersectionObserver not supported, falling back to scroll listener');
    return createScrollBasedObserver(callback, options);
  }
}

function createScrollBasedObserver(callback, options = {}) {
  const elements = new Set();
  const threshold = options.threshold || 0;
  
  function checkElements() {
    const entries = [];
    
    elements.forEach(element => {
      const info = getVisibilityInfo(element);
      const isIntersecting = info.visibilityRatio >= threshold;
      
      entries.push({
        target: element,
        isIntersecting,
        intersectionRatio: info.visibilityRatio,
        boundingClientRect: info.rect
      });
    });
    
    if (entries.length > 0) {
      callback(entries);
    }
  }
  
  const throttledCheck = throttle(checkElements, 100);
  
  window.addEventListener('scroll', throttledCheck, { passive: true });
  window.addEventListener('resize', throttledCheck);
  
  return {
    observe(element) {
      elements.add(element);
      // 立即检查一次
      setTimeout(() => {
        const info = getVisibilityInfo(element);
        const isIntersecting = info.visibilityRatio >= threshold;
        callback([{
          target: element,
          isIntersecting,
          intersectionRatio: info.visibilityRatio,
          boundingClientRect: info.rect
        }]);
      }, 0);
    },
    
    unobserve(element) {
      elements.delete(element);
    },
    
    disconnect() {
      window.removeEventListener('scroll', throttledCheck);
      window.removeEventListener('resize', throttledCheck);
      elements.clear();
    }
  };
}

总结

选择合适的方法:

  1. Intersection Observer API - 现代浏览器首选,性能最佳
  2. getBoundingClientRect + 滚动监听 - 需要兼容老浏览器时使用
  3. 虚拟滚动 - 处理大量元素时的特殊优化

关键考虑因素:

  • 性能:Intersection Observer > 节流的滚动监听 > 频繁的滚动监听
  • 精确度:getBoundingClientRect更精确,但需要手动触发
  • 兼容性:getBoundingClientRect支持更老的浏览器
  • 功能需求:是否需要部分可见、可见比例等详细信息

写着写着,就踩进了 JavaScript 的小坑

作者 T___T
2025年12月1日 17:02

很多人学 JS,是从“能把需求写出来”开始的。
但真正在面试或者写复杂业务时,常见的几个小坑——数组的 mapNaN 与 Infinity、字符串的包装类和长度问题——经常一起出来“围殴”你。

这篇文章不讲大而全,只用几组小实验,把它们串成一条线:
从数组遍历,到数字解析,再到字符串与 emoji 的长度。

一、数组不只是 formap 才是更现代的写法

最传统的遍历数组,往往是这样:

const arr = [1, 2, 3, 4, 5, 6];
const result = [];
for (let i = 0; i < arr.length; i++) {
  result.push(arr[i] * arr[i]);
}
console.log(result); // [1, 4, 9, 16, 25, 36]

而在更现代的写法里,我们会让数组自带的方法来负责“遍历 + 映射”:

const arr = [1, 2, 3, 4, 5, 6];
console.log(arr.map(item => item * item)); 
// [1, 4, 9, 16, 25, 36]

特点:

  • 原数组不变,返回一个新数组
  • 更符合“声明式”的风格:告诉它“要做什么”,而不是“怎么做”

理解 map 的回调函数签名也很重要:

array.map(function (item, index, arr) {
  // item  当前元素
  // index 当前索引
  // arr   整个原数组
});

只要记住这三个参数的顺序,你在后面就能看懂更多“骚操作”。

二、当 map 遇上数字解析:NaN 是怎么溜进来的?

有了上面的铺垫,再看这一类代码就不陌生了:

const arr = [1, 2, 3];

arr.map(function (item, index, source) {
  console.log(item, index, source);
  return item;
});

这一段可以帮助你记住参数顺序。
然后,在实际项目或面试题中,很容易有人写出类似:

[1, 2, 3].map(parseInt);

这个例子本身已经很有名了,这里只强调两点:

  • 回调函数拿到的是 (item, index, arr)
  • 数字解析函数的签名是 (string, radix):要解析的字符串 + 进制

当两边的参数顺序撞在一起时,就有了进制被错误传入的问题,于是 NaN 出现了。

关键不是记住“这题的答案”,
而是要记住:数组方法的回调长什么样,别乱用现成函数往上一丢就图省事。

三、NaN 和 Infinity:数字类型里的“异类”

在处理数字时,还有两个非常容易被忽视的存在:

console.log(0 / 0);   // NaN
console.log(6 / 0);   // Infinity
console.log(-6 / 0);  // -Infinity

以及:

console.log(parseInt("108"));       // 108
console.log(parseInt("八百108"));   // NaN
console.log(parseInt("108八百"));   // 108
console.log(parseInt(1314.520));    // 1314

可以总结出几个有用的直觉:

  • NaN
    表示“这不是一个合法的数字结果”,典型场景是:

    • 0 / 0
    • 完全看不懂的字符串解析(比如一上来就是汉字)
  • Infinity / -Infinity
    表示正负无穷大,例如除以 0。

  • parseInt 的解析习性

    • 从左往右看,一开始就看不懂 → 整体 NaN
    • 先看懂了一段,中途遇到不认识的字符 → 前面合法的部分照算
    • 遇到小数 → 小数点后面直接不要

再加上一句经典但反直觉的事实:

typeof NaN === "number"; // true

这就是为什么很多 JS 教程会专门开一小节来讲“特殊数字类型”。

四、看起来“一切皆对象”,其实 JS 在背后帮你擦屁股

有一个常被忽略、但又极其常用的能力:

"hello".length;         // 5
(520.1314).toFixed(2);  // "520.13"

从传统面向对象的思路看:

  • 字符串字面量、数字字面量都属于原始值
  • 原始值照理说不是对象,不能随便点属性、调方法

但在这门语言里,你天天在这么写,而且完全没报错。
原因是引擎偷偷给你做了**“包装”**:

  • 原始字符串 → 临时变成 String 对象
  • 原始数字 → 临时变成 Number 对象
  • 原始布尔值 → 临时变成 Boolean 对象

可以用一组简化版的伪操作来理解:

var str = "hello";

str.length; 

// 底层会做类似下面的事:
var strObj = new String(str);
console.log(strObj.length); // 5
strObj = null;              // 用完扔掉

console.log(typeof str);    // "string"(原始类型没改变)

也就是说:

  • 表面上是“统一风格,一切皆能点属性、调方法”
  • 实际上是引擎在背后帮你 new 来 new 去

这也解释了为什么这门语言经常被说“很傻瓜化”:
为了让你写起来爽,它会帮你兜很多底。

五、字符串长度与 emoji:肉眼看到的“一个”不等于 length === 1

再来看一组和字符串相关的实验:

js
console.log("a".length);   // 1
console.log("中".length);  // 1
console.log("𝄞".length);   // 2(看起来一个符号,却占了两个长度单位)

在这门语言里,字符串底层使用 UTF-16 编码

  • 大部分常见字符用 一个 16 位单元 表示 → length 加 1
  • 某些生僻字和 emoji 用 两个 16 位单元 表示 → length 加 2

再配合一段稍微综合一点的示例:

const str = " Hello, 世界! 👋  ";

console.log(str.length);                      // 包含空格、中文、emoji 在内的长度
console.log(str[1]);                          // 访问第二个 UTF-16 单元
console.log(str.charAt(1), str.charAt(1) == str[1]); // 在常规字符上二者表现一致
console.log(str.slice(1, 6));                 // 截取 [1, 6) 区间
console.log(str.substring(1, 6));             // 在这个用法下和 slice 表现一样
```](cascade:incomplete-link)

你可以得到两个非常有用的结论:

  • length 表示的是UTF-16 单元数量,不是“肉眼看到的字符个数”
  • 对英文、常见汉字,大多数时候可以“假装没区别”
  • 一旦大量使用 emoji 或特殊字符,索引和截取就可能和视觉表现错位

在做以下需求时,一定要记住这一点:

  • 限制“输入最多 N 个字符”
  • 截断字符串用于展示(例如列表项缩略显示)
  • 按“字符数”计费、统计、对齐

否则,emoji 往往会成为你 UI 中最顽皮的那一块。

六、把这些零散知识点连成一条线

回头看看前面的几个小实验,它们其实在回答同一类问题:

“这门语言,为了让你写起来看起来简单,到底在底层帮你做了多少事?”

  • 数组方法
    map 不只是简化了 for 循环,还规定了固定的回调参数顺序,
    一不留神用错现成函数,就会引入莫名其妙的 NaN
  • 数字解析与特殊值
    parseInt 的解析规则、NaN 和 Infinity 的存在,都在提醒你:
    “看起来是数字,其实里面有很多状态要区分”。
  • 包装类
    "hello".length(520.1314).toFixed(2) 这种写法之所以成立,
    是因为底层在帮你悄悄构造临时对象。
  • 字符串与编码
    length 和字符视觉上的“个数”不总是对得上的,
    emoji 是检验你有没有意识到这一点的最好测试用例。

当你愿意停下来,用几分钟时间亲手敲一遍这些代码,
并且追问每一行输出背后的“为什么”,
你就已经不只是“会写这门语言”,
而是在慢慢“理解这门语言”。

反转字符串与两数之和:两道简单题背后的 JavaScript 思维深度

作者 San30
2025年12月1日 16:59

面试官的屏幕上跳出一个简单的问题:“写一个函数,反转字符串。”候选人微微一笑,这太简单了。但当他开始思考时,才发现这简单的题目下藏着 JavaScript 语言特性的深海。

从直觉开始:为何要多种解法?

如果你认为字符串反转只是一个 reverse() 的事,那么你可能错过了面试官真正想看到的东西。在一个真实的技术面试中,面试官关心的往往不是你能否写出代码,而是你如何思考代码

“请反转字符串‘hello’。”面试官说。

第一个闪现的思路自然是 JavaScript 的内置方法:

function reverseStr(str) {
    return str.split('').reverse().join('');
}

“很好,”面试官点头,“但如果不让你用 reverse() 呢?”

思维的转换:从 API 到算法

这时,面试进入了一个微妙的阶段。候选人必须展示他不仅知道如何使用工具,还理解工具背后的原理。

循环方法是最直接的替代方案:

function reverseStr(str) {
    let reversed = '';
    for (let i = str.length - 1; i >= 0; i--) {
        reversed += str[i];
    }
    return reversed;
}

这个解法朴素、直接,像一位诚实的工匠,一步一步地完成工作。但现代 JavaScript 提供了更优雅的表达方式。ES6 的 for...of 让代码读起来像散文:

function reverseStr(str) {
    let reversed = '';
    for (const char of str) {
        reversed = char + reversed; // 注意这里的顺序
    }
    return reversed;
}

顺序!这里藏着一个微妙的点:char + reversed 而不是 reversed + char。这个小小的细节区分了理解与背诵。

深入语言特性:JavaScript 的表达力

随着对话深入,面试官想看看候选人对 JavaScript 现代特性的掌握程度。

“试试用函数式的方法?”面试官提议。

reduce 登场了,这个看似复杂的数组方法在这里找到了完美的应用场景:

function reverseStr(str) {
    return [...str].reduce((acc, char) => char + acc, '');
}

短短一行代码,浓缩了 JavaScript 的精华:展开运算符、箭头函数、reduce 的累积逻辑。这种解法不仅展示了技术能力,更展示了代码的品味

“有趣的是,”候选人补充道,“这里用 [...str] 代替 str.split(''),不仅能正确处理大多数 Unicode 字符,代码也更加简洁。”

思维的抽象:数据结构视角

真正区分初级和高级开发者的,往往是对数据结构的理解。字符串反转可以被看作一个栈操作

function reverseStr(str) {
    const stack = [];
    for (let char of str) {
        stack.push(char);
    }
    
    let reversed = '';
    while (stack.length > 0) {
        reversed += stack.pop(); // 后进先出,自然反转
    }
    return reversed;
}

栈是后进先出(LIFO)的,这恰好是反转的天然特性。同样,用队列也能解决,但需要一点技巧:

function reverseStr(str) {
    const queue = str.split('');
    let reversed = '';
    while (queue.length > 0) {
        reversed = queue.shift() + reversed; // 每次都加到前面
    }
    return reversed;
}

“不过,”候选人诚实地说,“队列解法的性能不如栈,因为 shift() 操作是 O(n) 复杂度。”

这种诚实的技术判断,往往比单纯展示知识更重要。

算法的优化:双指针思维

当字符串很长时,效率变得重要。双指针法展示了算法优化的思维:

function reverseStr(str) {
    const arr = str.split('');
    let left = 0;
    let right = arr.length - 1;

    while (left < right) {
        [arr[left], arr[right]] = [arr[right], arr[left]];
        left++;
        right--;
    }

    return arr.join('');
}

这里最精妙的是那行 ES6 的解构赋值交换。在 JavaScript 中,字符串是不可变的,但我们可以先转为数组,在数组中“原地”操作,最后再转回字符串。

“这种解法的好处是,”候选人解释道,“它只需要遍历一半的数组,并且交换操作是常量时间复杂度。”

思维的边界:递归的风险与美

在技术面试中,提到递归往往能引发深入的讨论:

function reverseStr(str) {
    if (str.length <= 1) {
        return str;
    }
    return reverseStr(str.slice(1)) + str[0];
}

递归的美在于它的声明式表达:反转一个字符串等于反转它的子串加上第一个字符。但美中不足的是风险——JavaScript 的调用栈是有限的。

“在实际项目中,”候选人说,“我只会对确定很短的字符串使用递归,或者使用尾递归优化——虽然 JavaScript 引擎的尾调用优化支持还不够普遍。”

这种对技术限制的清醒认识,是经验丰富的标志。

回归现实:在面试中如何选择

“那么,”面试官最后问,“在实际编码中,你会选择哪种方法?”

最佳答案可能是:“看情况。”

  1. 日常开发中,str.split('').reverse().join('')[...str].reverse().join('') 是最佳选择:可读、简洁、性能足够。
  2. 代码审查时,可能会讨论用展开运算符处理 Unicode 字符更准确。
  3. 性能敏感场景,考虑双指针法减少操作次数。
  4. 教学场景中,展示多种解法可以帮助理解不同编程范式。

这时,面试官突然转换了话题:“让我们看另一个问题——两数之和。给定一个数组和目标和,找出数组中和为目标的两个数的索引。”

两数之和:从暴力到优化

第一个冲动是暴力解法:

function twoSum(nums, target) {
    for(let i = 0; i < nums.length; i++) {
        for(let j = i + 1; j < nums.length; j++) {
            if(nums[i] + nums[j] === target) {
                return [i, j];
            }
        }
    }
}

“O(n²)的时间复杂度,”你承认,“对于小数组可以,但数据量大时就不行了。”

“如何优化?”面试官追问。

你停顿了一下,想到了关键思维转换:“把求和问题变成求差问题。”

思维的跃迁:从数学转换到数据结构

“与其遍历所有组合寻找两个数之和等于目标,不如遍历一次,对于每个数,计算它与目标的差值,然后检查这个差值是否出现过。”

你开始在白板上画图:“这就需要一种数据结构,能快速查找——哈希表。”

ES5风格的哈希实现

function twoSum(nums, target) {
    const diffs = {}; // 简单的对象作为哈希表
    for(let i = 0; i < nums.length; i++) {
        const complement = target - nums[i];
        if(diffs[complement] !== undefined) {
            return [diffs[complement], i];
        }
        diffs[nums[i]] = i;
    }
}

“这是O(n)的时间复杂度,”你解释,“我们用空间换时间。对象存储键值对,键是数组值,值是索引。”

但你还没说完:“不过,ES6提供了更专业的工具——”

两数之和的现代实现

function twoSum(nums, target) {
    const diffs = new Map();
    for (let i = 0; i < nums.length; i++) {
        const complement = target - nums[i];
        if (diffs.has(complement)) {
            return [diffs.get(complement), i];
        }
        diffs.set(nums[i], i);
    }
}

“Map比普通对象有几个优势,”你详细说明,“键可以是任意类型,而对象只能是字符串或Symbol;Map保持插入顺序;有更清晰的API如has()get()set()。”

更大的图景:两道题背后的思维方式

反转字符串与两数之和之所以成为经典的面试题,是因为它们像一对互补的棱镜,共同折射出开发者完整的能力光谱:

两道题目共同考察的能力维度

  1. 语言掌握深度:从反转字符串的展开运算符[...str]到两数之和的Map数据结构,表面是考察API熟悉度,实则是测试对语言特性演进的理解——为何ES6的Map比普通对象更适合作为哈希表?为何展开运算符比split()更现代?

  2. 算法思维层次:反转字符串让我们思考“顺序逆变换”的多种实现路径;两数之和则挑战我们将“求和问题”转化为“查找问题”的抽象能力。前者考察同一问题的多解视角,后者测试问题本质的重构能力。

  3. 代码品味与工程权衡:在反转字符串中,简洁的API调用与手写双指针之间如何选择?在两数之和中,何时用对象作为哈希表,何时必须用Map?这些选择背后,是对可读性、性能、兼容性、维护成本的多维度权衡。

  4. 思维过程的透明化:面试官不在乎你是否瞬间给出最优解,而在乎你能否清晰陈述从暴力解法到优化方案的思考路径——为何想到用哈希表?为何选择空间换时间?这种将内在思维外化的能力,才是团队协作的核心。

系统化思维的完整呈现

最终,面试官通过这两道题观察的,是你是否建立了解决问题的系统化思维框架。当你能从反转字符串的多种解法中,识别出“数据结构视角”与“算法优化视角”的差异;当你能在两数之和的优化过程中,明确解释“问题转化”与“数据结构选择”的逻辑链条——你已经证明了自己不只是记忆解决方案的编码员,而是能够分析问题本质、设计解决路径、评估方案优劣的工程思考者。

反转字符串让你展示思维的深度——对一个简单问题能想到多深;两数之和让你展示思维的转化力——如何将复杂问题转化为已知模式。二者的结合,恰好构成了技术思维的两个关键维度:垂直的专业深度与水平的问题抽象能力。

下次当你面对看似简单的面试题时,记住:题目只是载体,真正被测试的是你构建思考框架、进行技术决策、清晰表达逻辑的综合能力。在技术的世界里,这种系统化思考的能力,远比记忆任何特定解法都更能定义你的长期价值。

前端角度学 AI - 15 分钟入门 Python

作者 孟祥_成都
2025年12月1日 16:55

前言

本文只是很基础的 python 最精华的语法入门,帮助前端同学使用 python 实现一些 ai 工具。有了这些基础,遇到不会的,问 ai 也能看懂 ai 给的答案,这是这篇文章的目的:结合 javascript,对比 python的基础用法,用最快的速度,上手 python,快速进入到实战,而不是纠结于基础语法!

最后我们会写一个用 python 调用 deepseek 大模型的微型 demo。

承接之前的前端学习 ai 系列文章:

首先需要安装 python,大家可以直接问 ai。 因为我的 macOS 自带了 python,我就没有单独安装了(建议使用版本管理工具,例如 python 管理多版本)。

ai agent 中, python 毕竟是最主流的语言,大家可以熟悉一下基本语法,实在太简单了, 大概花费时间如下:

  • 基本语法:5分钟
  • 控制结果:2分钟
  • 数据结构:5分钟
  • 案例:3分钟

基本语法

变量的定义和命令规则

因为实在太简单了,大家看注释就知道意思

# 变量的定义
name = "Alice"
age = 25
height = 1.68
is_student = True

# 打印变量值
print(name)
print(age)
print(height)
print(is_student)

name = "张三"
print(name)

# 变量命名规则
# 简单理解跟 javascript 的差不多就行了
# 1. 变量名只能包含字母、数字、下划线
# 2. 变量名不能以数字开头
# 3. 变量名不能是 python 关键字

数据类型

# 数字类型
num1 = 10 # 整数(int)
num2 = 3.14 # 浮点数 (float)

# 字符串类型 (str)
name = "Alice"
message = 'Hello, world!'

# 布尔类型 (bool)
is_student = True
is_working = Fasle

这里注意:

  • python 中的浮点数跟 javascript 的一致,都是双精度浮点数,占用 8字节,也就是 64 的内存。但同样的问题也有,就是都不精确,也就是会出现 0.1 + 0.2 不等于 0.3 的问题
  • 然后字符串单引号很双引号是一样的效果,跟 javascript 一致
  • 布尔类型不一样的是 python 中首字母大写。

如何获取数据类型, 使用 type,跟 javascript typeof 操作符类似。

num1 = 10 # 整数(int)
print(type(num1))

类型转换

跟javascript 中,使用 Number(xx) 转数字,使用 String() 转字符串类似。

# 将字符串转换为整数
num_str = "123"
num_int = int(num_str)
print(num_int)

# 将整数转为字符串
num_int = 456
num_str = str(num_int)
print(num_str)

# 将浮点数转换为整数(会丢失小数部分)
num_float = 3.14
num_int = int(num_float)
print(num_int)

算数运算符

# 加法
result = 10 + 5
print(result)

# 减法
result = 10 - 5
print(result)

# 乘法
result = 10 * 5
print(result)

# 除法
result = 10 / 5
print(result)

# 取整除
result = 10 // 3
print(result)

# 取余
result = 10 % 3
print(result)

# 幂计算
result = 2 ** 3
print(result)

跟 javascript 在取整除语法上有区别

  • python 是 10 // 3
  • 在 javascript 对应的是 Math.trunc(10 / 3)

比较运算符

这个没什么好说的,跟 javascript 基本一样,例如

  • 10 == 5, 10 != 5, 10 > 5, 10 >= 5 等等
  • 注意 python 没有 === 和 !==

逻辑运算符

这个区别有点大,我们细细来讲:

逻辑与 (AND)

python 用 and

# Python
x = 5
y = 10
if x > 0 and y < 20:
    print("两个条件都满足")  # 会执行

javascript

// JavaScript
let x = 5;
let y = 10;
if (x > 0 && y < 20) {
    console.log("两个条件都满足");  // 会执行
}

逻辑或 (OR)

python 用 or

# Python
name = ""
default_name = "Guest"
display_name = name or default_name
print(display_name)  # 输出: Guest

javascript

// JavaScript
let name = "";
let defaultName = "Guest";
let displayName = name || defaultName;
console.log(displayName);  // 输出: Guest

逻辑非 (NOT)

python 用 not,也就是没有 javascript 的 ! 操作符。

# Python
is_logged_in = False
if not is_logged_in:
    print("请先登录")  # 会执行

javascript

// JavaScript
let isLoggedIn = false;
if (!isLoggedIn) {
    console.log("请先登录");  // 会执行
}

真假值判断差异

Python 的假值:

  • False
  • None
  • 0 (整数)
  • 0.0 (浮点数)
  • "" (空字符串)
  • [] (空列表)
  • () (空元组)
  • {} (空字典)
  • set() (空集合)

JavaScript 的假值:

  • false
  • 0 (数字)
  • "" (空字符串)
  • null
  • undefined
  • NaN
  • 0n (BigInt)

赋值运算符

跟 javascript 一样,但注意没有 ++, -- 这种运算符。

# 简单赋值
x = 10

# 加法赋值
x += 5

# 减法赋值
x -= 3

# 乘法赋值
x *= 2

# 除法赋值

x /= 4

输入输出

input 函数获取用户输入

name = input("请输入你的名字")
print("你好," + name + "!")

print() 函数打印内容

print("hello word!")

格式化输出,在 python 3.6 版本支持,跟前端的 `` 类似符号类似,python 使用的是 f。

  • javascript 使用的是 ${} 嵌入变量, python 是 {} 嵌入变量
name = "Charlie"
age = 35
height = 1.75

# 直接嵌入变量
print(f"Hello, {name}!") 
# 输出:Hello, Charlie!

if else 语句

跟 javascript 有区别,但也很简单,请看示例

score = 75

if score >= 90:
    print("优秀")
elif score >= 70:
    print("良好")
elif score >= 60:
     print("及格")
else:
    print("需努力");
  • 条件 是一个会产生布尔值(True 或 False)的表达式。
  • 冒号 :  是必须的,表示一个代码块的开始。
  • 缩进(通常是 4 个空格)是 Python 标识代码块的方式,绝对不能省略或混用

嵌套条件语句

age = 20
has_license = True

if age >= 18:
    if has_license:
        print("可以开车")
    else:
        print("不能开车")
else:
    print("不能开车");

循环语句

跟 javascript 区别还挺大,没有 javascript 的中括号。

for 循环语法:

for 变量 in 可迭代对象:
    # 执行的代码块

常用场景与示例:

遍历数组:

fruits = ["apple", "banana", "cherry"]
for fruit in fruits: 
    `print(fruit)

遍历字符串:

for char in "Hello":  
    print(char)

遍历 range 。range(n) 生成一个从 0 到 n-1 的整数序列。

for i in range(5):
    print(i) // 输出:0 1 2 3 4

遍历字典(理解为javascript 的 {} 对象)

person = {"name": "Alice", "age": 25}
for key in person:
     print(key, person[key])

while 循环语法

while 条件:
    # 执行的代码块

基本案例:

count = 0

while count < 5:
    print(count)
    count += 1

循环控制语句

跟 javascript 一样。

# break 语句
for i in range(10):
    if i == 5:
        break
    print(i) // 0 1 2 3 4
# break 语句
for i in range(10):
    if i % 2 == 0:
        continue
    print(i) // 1 3 5 7 9

数据结构

Python 常见的数据结构有四种:

  • 列表 List: python语法:[1, 2, 'a']
    • 相当于 javascript 的数组: [1, 2, 'a']

增加元素:

  • 通过 append 函方法,实现在尾部增加新元素。
    • javascript 是通过 push 方法。
l = [1,2,3,4]
l.append(5)
print(l)

删除元素:

  • 通过 del 关键字可以删除列表某个位置的元素
    • javascript 是通过 splice 方法
l = [1,2,3,4]
## 删除首个
del l[0]
## 删除最后一个(也就是倒数第一的意思)
del l[-1]
print(l)

最后一个元素在 python 可以用 -1 表示。

查找元素:

l = [1,2,3,4]
## 查找首个
print(l[0])
## 查找最后一个
print(l[-1])
## 查找下标是 1 到 下标 2 的元素(不包括 3 所以到 2)
print(l[1:3]) # [2, 3]

修改元素

l = [1,2,3,4]

l[0] = -1
print(l)

接下来看第二个数据类型:元祖

  • 元祖 Tuple: (1, 2, 'a')
    • 可以理解为就是列表,但是不能改。
    • javascript没有对应的,JS 中通常用数组代替,通过 Object.freeze() 模拟
t = (1,2,3)

t[0] = 1 # 报错,不能改

但可以循环遍历元祖。

接下来看第三个数据类型:字典

  • 字典 Dict:{'a': 1, 'b': 2}
    • 相当于 javascript 的 Object {a: 1, b: 2}
    • Python 键可以是任意不可变类型;JS 键会自动转为字符串。
    • Python 用 dict[‘a’];JS 可用 . 或 []
# key, 键,比如 First 和 Second 就是键
# value,值,比如 1 和 2 就是值
ab = {
    "First": 1,
    "Second": 2
}


print(ab)

字典支持以下操作:

  • 增:通过 [] 可以向字典内增加元素
# key, 键,比如 First 和 Second 就是键
# value,值,比如 1 和 2 就是值
ab = {
    "First": 1,
    "Second": 2
}

ab["Third"] = 3
print(ab)
  • 删:通过 del 可以向字典内删除元素
# key, 键,比如 First 和 Second 就是键
# value,值,比如 1 和 2 就是值
ab = {
    "First": 1,
    "Second": 2
}

del ab["Second"]
print(ab)
  • 查:通过 [] 可以向字典内查询元素
# key, 键,比如 First 和 Second 就是键
# value,值,比如 1 和 2 就是值
ab = {
    "First": 1,
    "Second": 2
}

print(ab["Second"])

还可以通过 in 关键字查找某个键是否在字典中

# key, 键,比如 First 和 Second 就是键
# value,值,比如 1 和 2 就是值
ab = {
    "First": 1,
    "Second": 2
}

print("Second" in ab) // True
  • 改:通过赋值符号 = 可以修改某个位置元素
# key, 键,比如 First 和 Second 就是键
# value,值,比如 1 和 2 就是值
ab = {
    "First": 1,
    "Second": 2
}

ab["First"] = 3
print(ab) // True
  • 集合:Set:{1, 2, 3}
    • 相当于 javascript 中的 Set, new Set([1, 2, 3]),也就是不存在重复的元素

注意集合是没有顺序,所以不能像列表那样通过下标查找

增加元素:

s = {1,2,3}
# 增加新的元素到集合中
s.add(6)
print(s)

删除元素:

s = {1,2,3}
# 增加新的元素到集合中
s.remove(3)
print(s)

查找是否元素在集合中:

s = {1,2,3}
# 增加新的元素到集合中
print(3 in s)  # True

我们还可以求集合的交集,并集,差集

s1 = {1,2,3}
s2 = {2,3,4}

print(s1 & s2) # 交集 {2,3}
s1 = {1,2,3}
s2 = {2,3,4}

print(s1 | s2) # 并集 {1,2,3,4}
s1 = {1,2,3}
s2 = {2,3,4}

print(s1 ^ s2) # 差集 {1,4}

函数

使用 def 关键字定义,javascript 是使用 function 关键字。

示例:

def greet(name):
    return f"Hello, {name}"

print(greet('ZhangSan')) # Hello, ZhangSan

注意 python 能返回多个值,我们举例

def add_and_subtract(x, y):
    return x+y, x-y

sum,diff = add_and_subtract(10, 5)

print(sum, diff) # 15 5

同样 python 有作用域的概念,在函数里的属于局部变量。

匿名函数:Lambda 函数

有点像 javascript 中的箭头函数,是一种语法糖,能够快速定义函数

add = lambda x, y: x + y # 定义一个传参是 x,y 结果是和的函数

print(add(1,1)) # 2

上面的 lambda 函数相当于

def add(a, b):
    return a+b

print(add(1,1)) # 2

函数作为参数传递

python 也支持将函数作为参数传给一个函数

def apply_function(func, value):
    return func(value)

print(apply_function(lambda x: x * x, 5)) # 25

Python 模块介绍

  • 模块是包含代码的文件,以单独命名空间组织逻辑,便于复用与维护
  • 一个 .py 文件就是一个模块,模块名为文件名(不含扩展名)
  • 导入后通过模块命名空间访问其函数、类、变量,避免命名冲突

例如,我们创建一个模块:

# utils.py
def add(a, b):
    return a + b
PI = 3.14159

使用

import utils
print(utils.add(1, 2))
print(plus(1, 2))

我们还可以使用模块别名,跟 javascript 类似, 也是 as 关键字

import utils as util # utils 别名改为 util
print(util.add(1, 2))

还有一种 from ... import 可以导入模块语法如下

# 导入整个模块(不建议这么做)
from module_name import *

# 导入特定函数/类/变量
from module_name import function_name, ClassName, variable_name

# 导入并重命名
from module_name import original_name as alias_name

示例:

import math

# 使用时需要前缀
result = math.sqrt(25)
print(math.pi)
print(math.sin(math.radians(90)))
# 导入多个特定项目
from math import sqrt, pi, sin, cos

print(sqrt(9))      # 3.0
print(pi)           # 3.141592653589793
print(sin(pi/2))    # 1.0

python 内置模块

  • os 模块:文件和目录操作

    • 类似于 node.js 的 fs 模块
  • random 模块:生成随机数

    • javascript 原生只有 Math,random 方法

例如:

import os
print(os.getcwd()) # 获取当前命令执行的目录
import random
print(random.randint(1,10)) # 获取包括1到10中的一个随机整数

python 面相对象编程

类与对象

废话不多说,直接上一个 javascript 的类和 python 相同的类,你就明白了

python

# Python 类定义
class Person:
    # 构造函数
    # self 相当于 javascript 中的 this
    def __init__(self, name, age):
        self.name = name
        self.age = age
        self._private_var = "internal"  # 约定为私有(实际仍可访问)
    
    # 实例方法
    def greet(self):
        return f"Hello, I'm {self.name}"
    
    # 类方法
    @classmethod
    def from_birth_year(cls, name, birth_year):
        import datetime
        age = datetime.datetime.now().year - birth_year
        return cls(name, age)
    
    # 静态方法
    @staticmethod
    def is_adult(age):
        return age >= 18
    
    # 属性装饰器
    @property
    def adult(self):
        return self.age >= 18
    
    @adult.setter
    def adult(self, value):
        print("Cannot directly set adult status")

javascript

// JavaScript 类定义
class Person {
    class_attr = "类属性"  # 类属性
  
    // 构造函数
    constructor(name, age) {
        this.name = name;
        this.age = age;
        this._privateVar = "internal";  // 约定为私有
    }
    
    // 实例方法
    greet() {
        return `Hello, I'm ${this.name}`;
    }
    
    // 静态方法
    static fromBirthYear(name, birthYear) {
        const age = new Date().getFullYear() - birthYear;
        return new Person(name, age);
    }
    
    // Getter
    get adult() {
        return this.age >= 18;
    }
    
    // Setter
    set adult(value) {
        console.log("Cannot directly set adult status");
    }
}

如何实例化呢,python 不用 new,就像函数一样调用

person1 = Person("张三", 30)
person1.greet()

这里需要注意:

  • person1._privateVar 是得不到值 "internal"的,这是 python 内部的一种名称修饰机制,下划线属性或者函数,实例是不能直接访问到的,只能通过 实例名._类名._属性名访问,也就是 person1._Person._privateVar 访问到
  • @classmethod 在 python 中用来定义类方法,这里不能访问 self。 也就是 javascript 中的 this
  • @staticmethod 在 python 中用来静态方法,不能访问实例(self)或者类,跟javascript 一致。
  • 通过@property 装饰器定义 get 方法,通过 @属性名.setter 定义 setter 方法。这个跟javascript 区别还是挺大的。

接下来看下面相对象的 3 个特征,在 python 中如何实现的

第一个是封装:

封装是将数据和操作数据的方法绑定在一起,隐藏内部实现细节,只暴露必要的接口。

对于 python 我们知道如何定义私有属性,共有属性即可

实现一个简单的示例

class BankAccount:
    def __init__(self, balance):
        self._balance = balance # _balance 是私有属性
    
    def deposit(self, amount):
        self._balance += amount
        
    def withdraw(self, amount):
        if self._balance >= amount:
            selef._balance -= amount
        else:
            print("not enough")
           
    def get_balance(self):
        return self._balance
 
account = BankAccount(100)
account.deposit(50)
print(account.get_balance()) // 150

接着看继承特性

感觉很有 javascript 的既视感。。。。,也是调用父类的构造函数,完成子类的属性和方法的继承。

# 父类(基类)
class Animal:
    def __init__(self, name):
        self.name = name
    
    def eat(self):
        print(f"{self.name} 正在吃东西")
    
    def sleep(self):
        print(f"{self.name} 正在睡觉")
    
    def make_sound(self):
        print(f"{self.name} 发出声音")

# 子类(派生类)
class Dog(Animal):  # 继承Animal类
    def __init__(self, name, breed):
        super().__init__(name)  # 调用父类的构造函数
        self.breed = breed  # 子类特有的属性
    
    # 重写父类方法(多态)
    def make_sound(self):
        print(f"{self.name} 汪汪叫!")
    
    # 子类特有的方法
    def fetch(self):
        print(f"{self.name} 正在接飞盘")

# 使用
dog = Dog("旺财", "金毛")
dog.eat()         # 继承自父类
dog.sleep()       # 继承自父类
dog.make_sound()  # 重写的方法
dog.fetch()       # 子类特有的方法
print(f"品种: {dog.breed}")

最后就是多态了,多态允许不同类的对象调用相同的方法时,表现不同的行为。python 通过方法重写来实现多态。

其实这个不用太关心,跟 javascript 类似, 也是通过方法重写来实现多态。实际上多态一般是在有类型的语言里比较适合,比如 typescript 可以实现对传参类型的不同(重载),声明不同的函数类型来实现多态。

class Animal:
    def make_sound(self):
        pass  # 抽象方法,由子类实现

class Dog(Animal):
    def make_sound(self):
        return "汪汪汪!"

class Cat(Animal):
    def make_sound(self):
        return "喵喵喵!"

class Bird(Animal):
    def make_sound(self):
        return "啾啾啾!"

# 多态:同一接口,不同行为
def animal_sound(animal):
    """这个函数不知道具体的动物类型,只知道它会叫"""
    return animal.make_sound()

# 创建不同的动物对象
animals = [Dog(), Cat(), Bird()]

# 统一调用相同的方法
for animal in animals:
    print(animal_sound(animal))
    
# 输出:
# 汪汪汪!
# 喵喵喵!
# 啾啾啾!

调用 deepSeek 案例

1. 安装必要的库

在命令行/终端中输入:

bash

pip install openai

2.申请 apikey

注意,要充值。。。可以自己充值 10 元,对于我们学来说,可以用很久很久了,当然你也可以充值 openai(chatgpt),用法是一模一样的,都兼容 openai 的 api,

image.png

创建好后,复制这个 key, 后面要用

image.png

3. 最简单的代码(复制粘贴就能用)

# 文件名:simple_deepseek.py
from openai import OpenAI

# 1. 替换成你的DeepSeek API密钥
# 获取地址:https://platform.deepseek.com/api_keys
api_key = "sk-你的API密钥在这里"

# 2. 连接到DeepSeek
client = OpenAI(
    api_key=api_key,
    base_url="https://api.deepseek.com"
)

# 3. 问一个问题
response = client.chat.completions.create(
    model="deepseek-chat",  # 使用免费模型
    messages=[
        {"role": "user", "content": "你好,用一句话介绍你自己"}
    ]
)

# 4. 打印回答
print("AI回答:", response.choices[0].message.content)

4.运行脚本

python3 simple_deepseek.py

以下是我调用的结果:

image.png

最后如果你也学习跟前端相关的 ai 知识,还有学习 headless 组件库教程,node.js全栈(next.js, nest.js)欢迎加入我们交流群。

❌
❌