普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月9日掘金 前端

到底是用nuxt的public还是assets?一篇文章开悟

作者 江湖文人
2026年1月9日 11:03

assets

Nuxt为你的资产提供了两种处理方式。

Nuxt使用两个目录来处理样式表、字体或图片等资产:

  • public/目录的内容会直接以服务器根路径的形式提供。
  • assets/目录按惯例包含你希望构建工具(Vitewebpack)处理的所有资产。

公共目录

public/目录用作静态资产的公共服务器,这些资产可以在你的应用定义的URL下公开访问。

你可以通过应用的代码或浏览器使用根URL/来获取public/目录中的文件。

示例

例如,引用位于public/img/目录中的图像文件,可通过静态URL/img/nuxt.png访问:

// app.vue
<template>
  <img src="/img/nuxt.png" alt="探索 Nuxt" />
</template>

资产目录

Nuxt使用Vite(默认)或webpack来构建和打包你的应用。这些构建工具的主要功能是处理JavaScript文件,但可以通过插件(用于Vite)或加载器(用于webpack)扩展,以处理其他类型的资产,例如样式表、字体或SVG。这一过程主要为了性能或缓存目的转换原始文件(例如样式表压缩或浏览器缓存失败)。

按照惯例,Nuxt使用assets/目录来存储这些文件,但该目录没有自动扫描功能,你可以为它使用任何其他名称。

在应用代码中,可以通过~/assets/路径引用位于assets/目录中的文件。

示例

例如,引用一个图像文件,如果构建工具配置为处理此文件扩展名,该文件将被处理:

// app.vue
<template>
  <img src="~/assets/img/nuxt.png" alt="探索 Nuxt" />
</template>

Nuxt不会以静态URL(如/assets/my-file.png)提供assets/目录中的文件。如果你需要静态URL,请使用public/目录。

区别 —— 纠结用哪个可以看这张表

特性 public/目录 assets/目录
目的 静态资源服务器 构建时处理的资源
URL访问 直接通过根路径/访问 需通过~/assets/路径引用
构建处理 不经过构建工具处理 经过Vite/webpack处理(压缩、优化等)
更新方式 直接替换文件 构建后生成新文件(带哈希)
适用场景 不常更改的资源(如favicon、robots.txt 需要构建优化的资源(图片、样式、字体)

public/

// vue
<!-- 适用于: -->
<!-- 不常更新的静态文件 -->
<img src="/favicon.ico" alt="网站图标" />

<a href="/brochure.pdf">下载手册</a>

assets/

// vue
<!-- 适用于: -->
<!-- 需要优化处理的图片 -->
<img src="~/assets/images/hero.jpg" alt="英雄" />
<!-- 2. 样式文件(SCSS/SASS/LESS) -->
<style>
@import '~/assets/styles/main.scss';
</style>
<!-- 3. 字体文件 -->
<style>
@font-face {
  font-family: 'CustomFont';
  src: url('~/assets/fonts/custom.woff2') format('woff2');
}
</style>

实战目录结构

my-nuxt-app/
├── public/
│   ├── favicon.ico          # 直接通过 /favicon.ico 访问
│   ├── robots.txt          # 直接通过 /robots.txt 访问
│   └── downloads/
│       └── brochure.pdf    # 直接通过 /downloads/brochure.pdf 访问
├── assets/
│   ├── images/
│   │   ├── logo.png        # 构建优化后的图片
│   │   └── background.jpg
│   ├── styles/
│   │   ├── main.scss       # 编译处理的SCSS文件
│   │   └── variables.scss
│   └── fonts/
│       └── custom.woff2    # 字体文件
└── components/
    └── MyComponent.vue
<!-- 优先使用assets/以获得构建优化  -->
<img src="~/assets/images/product.jpg" alt="产品" />

<!-- 大尺寸或不需要优化的图片可以放在public -->
<img src="/documentation/large.png" alt="架构图" >

动态

<script setup>
// 使用 import 获取 assets 资源(会经过构建处理)
import logo from '~/assets/images/logo.png'

// 使用相对路径引用 public 资源
const publicImage = '/images/banner.png'
</script>

css中用

/* 在 CSS 中引用 assets 资源 */
.hero {
  background-image: url('~/assets/images/bg.jpg');
}

/* 引用 public 资源 */
.external {
  background-image: url('/external-bg.png');
}

提醒

  1. 缓存策略

    • assets/ 中的文件通常会添加哈希值,便于缓存管理
    • public/ 中的文件使用原始名称,需手动管理缓存
  2. 部署考虑

    • public/ 目录内容会原样复制到构建输出
    • assets/ 目录内容会被处理并打包
  3. 性能优化

    • 小图标建议使用 assets/ 以便打包优化
    • 大文件(如视频)建议使用 public/ 避免构建过程变慢

通用语法校验器tree-sitter——C++语法校验实践

2026年1月9日 10:44

tree-sitter介绍

以下内容来自于官方文档:tree-sitter.github.io/tree-sitter…

Tree-sitter 是一个解析器生成工具和增量解析库,用于为源代码文件构建具体的语法树,并在源文件编辑时高效更新语法树。它旨在提供一个通用、快速且鲁棒的解决方案,用于解析编程语言,即使在存在语法错误的情况下也能正常工作。

主要特点:

  • 通用性:能够解析任何编程语言。
  • 高效性:足够快,可以在文本编辑器中每按一个键就进行解析。
  • 鲁棒性:即使有语法错误,也能提供有用的结果。
  • 无依赖:运行时库使用纯 C11 编写,可以嵌入到任何应用程序中。

工作原理:

Tree-sitter 生成解析器并维护一个增量解析库,随着源文件的编辑实时更新语法树,从而支持如文本编辑器中的实时解析。

支持的语言:

  • 语言绑定:支持 C# (.NET)、C++、Crystal、D、Delphi、ELisp、Go、Guile、Janet、Java (JDK 8+ 和 11+)、Julia、Lua、OCaml、Odin、Perl、Pharo、PHP、R 和 Ruby 等语言的绑定(部分绑定可能不完整或过时)。

tree-sitter的缺点

Tree-sitter 不是利用编程语言(如 C++、JavaScript 等)的现有或官方解析器来进行解析的。它是一个独立的解析器生成工具,使用自己的框架和语法定义来为各种语言生成专属的解析器。这些解析器基于 GLR(广义 LR)算法构建,并通过 Tree-sitter 的工具包预先编译或运行时生成,而不是依赖语言的内置运行时环境(如 V8 对于 JavaScript)。这种设计允许 Tree-sitter 在编辑器中实现高效的增量解析,但也可能导致与官方解析器在某些边缘情况下的不一致。

因此,如果需要高质量的语法解析,请不要用tree-sitter。 虽然tree-sitter提供了api让开发者编写更精细的解析,但不如考虑其他wasm方案或Language Server Protocol (LSP)

测例

以下c++代码中有4处错误,包括使用了关键字/错误的声明/使用了未定义变量/错误语法(a+++),但只识别到了最后一个。

int main()  
{  
    int int = 1;  
    inb a = 1;  
    int a = 0, b = 1, c = 2, d = 3, e = 4;

    if (x > 1)  
    {  
    }  
    a++ + ;  
    if (a || (b < c && e >= d))  
    { /* ... */  
    }

    return 0;  
}

playground

tree-sitter.github.io/tree-sitter…

在浏览器环境中使用tree-sitter

tree-sitter支持在浏览器环境中使用,方法也很简单。

安装依赖

npm install web-tree-sitter

生成wasm

语法校验需要对应语言的wasm,生成步骤如下:

  1. 安装依赖
npm install --save-dev tree-sitter-cli tree-sitter-cpp

将node_modules中的web-tree-sitter.wasm文件复制到public目录下 2. 执行命令,在当前目录下生成tree-sitter-cpp.wasm

npx tree-sitter build --wasm node_modules/tree-sitter-cpp

3. 将生成的tree-sitter-cpp.wasm放入public目录下

实践demo

代码如下:

import { code } from "./code";
import { Parser, Language, Query } from "web-tree-sitter";
async function main() {
  await Parser.init({
    locateFile(scriptName: string, scriptDirectory: string) {
      return scriptName;
    },
  });
  const cpp = await Language.load("tree-sitter-cpp.wasm");

  const parser = new Parser();
  parser.setLanguage(cpp);
  const tree = parser.parse(code);
  console.log(tree);
  console.log(tree?.rootNode);
  if (!tree!.rootNode.hasError) {
    console.log("没有错误");
    return;
  } else {
    console.log("存在错误");
  }
  // 异常查询
  const queryString = "(ERROR) @error-node (MISSING) @missing-node"; 

  const language = parser.language;
  const query = new Query(language!, queryString);
  const root = tree!.rootNode;
  // Execute query and get matches
  const matches = query.matches(root!);

  const errorNodes = [];
  for (const match of matches) {
    for (const capture of match.captures) {
      errorNodes.push(capture.node);
    }
  }

  console.log(errorNodes);
  if (errorNodes.length) {
    const { row, column } = errorNodes[0]!.startPosition;
    console.log(`${row + 1}行,${column + 1}列存在错误`);
  }
}
main();

错误节点捕获:

image.png

[ECharts] Instance ec_1234567890 has been disposed

2026年1月9日 10:30

📋 目录


🔍 问题背景

在 Vue 3 项目中使用 ECharts 时,经常会遇到以下控制台警告:

[ECharts] Instance ec_1234567890 has been disposed

这个警告虽然不会影响功能,但表明存在潜在的内存泄漏问题。

问题原因

  1. 图表实例已销毁,但事件监听器仍在运行

    • 调用 chart.dispose() 销毁图表后
    • window.resize 事件监听器仍然存在
    • 监听器尝试调用已销毁实例的 chart.resize() 方法
    • 导致 ECharts 输出警告信息
  2. 重复添加事件监听器

    • 每次重新渲染图表时都添加新的 resize 监听器
    • 旧的监听器没有被清理
    • 导致内存泄漏和事件堆积

⚠️ 常见问题

问题 1:直接销毁图表实例

// ❌ 错误做法
if (chartInstance) {
  chartInstance.dispose(); // 直接销毁,但 resize 监听器还在
}

const chart = echarts.init(container);
window.addEventListener("resize", () => {
  chart.resize(); // 监听器引用了图表实例
});

问题

  • 销毁图表后,resize 监听器仍然存在
  • 监听器尝试调用已销毁实例的方法
  • 产生 "has been disposed" 警告

问题 2:重复添加监听器

// ❌ 错误做法
function renderChart() {
  const chart = echarts.init(container);

  // 每次调用都添加新监听器
  window.addEventListener("resize", () => {
    chart.resize();
  });
}

// 多次调用导致监听器堆积
renderChart(); // 添加第 1 个监听器
renderChart(); // 添加第 2 个监听器
renderChart(); // 添加第 3 个监听器

问题

  • 每次渲染都添加新的监听器
  • 旧的监听器没有被清理
  • 导致内存泄漏

✅ 解决方案

核心思路

在销毁图表实例前,先移除所有相关的事件监听器

实现步骤

1. 存储图表实例和监听器

import { ref } from "vue";

// 存储图表实例
const chartInstances = ref({});

// 存储每个图表的 resize 处理函数
const resizeHandlers = ref({});

2. 渲染图表时正确管理监听器

const renderChart = (data, containerId) => {
  nextTick(() => {
    const container = document.getElementById(containerId);
    if (!container) return;

    // ✅ 步骤 1:清理旧实例
    if (chartInstances.value[containerId]) {
      // 先移除旧的 resize 监听器
      if (resizeHandlers.value[containerId]) {
        window.removeEventListener("resize", resizeHandlers.value[containerId]);
      }
      // 再销毁图表实例
      chartInstances.value[containerId].dispose();
    }

    // ✅ 步骤 2:创建新实例
    const chartInstance = echarts.init(container);
    chartInstances.value[containerId] = chartInstance;

    // ✅ 步骤 3:配置并渲染图表
    const option = {
      // ... 图表配置
    };
    chartInstance.setOption(option);

    // ✅ 步骤 4:添加 resize 监听器并存储
    const resizeHandler = () => {
      chartInstance.resize();
    };
    resizeHandlers.value[containerId] = resizeHandler;
    window.addEventListener("resize", resizeHandler);
  });
};

3. 组件卸载时完整清理

import { onBeforeUnmount } from "vue";

onBeforeUnmount(() => {
  // ✅ 步骤 1:移除所有 resize 监听器
  Object.entries(resizeHandlers.value).forEach(([containerId, handler]) => {
    if (handler) {
      window.removeEventListener("resize", handler);
    }
  });

  // ✅ 步骤 2:销毁所有图表实例
  Object.values(chartInstances.value).forEach(chart => {
    if (chart) {
      chart.dispose();
    }
  });

  // ✅ 步骤 3:清空引用
  chartInstances.value = {};
  resizeHandlers.value = {};
});

💻 完整代码示例

Vue 3 组件示例

<script setup>
import { ref, watch, nextTick, onBeforeUnmount } from "vue";
import * as echarts from "echarts";

const props = defineProps({
  data: { type: Array, default: () => [] },
  loading: { type: Boolean, default: false },
});

// 存储图表实例
const chartInstances = ref({});

// 存储每个图表的 resize 处理函数
const resizeHandlers = ref({});

// 渲染图表
const renderChart = (chartData, containerId) => {
  nextTick(() => {
    const container = document.getElementById(containerId);
    if (!container) return;

    // 如果已存在图表实例,先清除监听器再销毁
    if (chartInstances.value[containerId]) {
      // 移除旧的 resize 监听器
      if (resizeHandlers.value[containerId]) {
        window.removeEventListener("resize", resizeHandlers.value[containerId]);
      }
      // 销毁图表实例
      chartInstances.value[containerId].dispose();
    }

    // 初始化 ECharts 实例
    const chartInstance = echarts.init(container);
    chartInstances.value[containerId] = chartInstance;

    // 配置图表选项
    const option = {
      title: { text: "示例图表" },
      tooltip: { trigger: "axis" },
      xAxis: { type: "category", data: chartData.map(item => item.name) },
      yAxis: { type: "value" },
      series: [
        {
          type: "bar",
          data: chartData.map(item => item.value),
        },
      ],
    };

    // 渲染图表
    chartInstance.setOption(option);

    // 监听窗口大小变化,自动调整图表大小
    const resizeHandler = () => {
      chartInstance.resize();
    };
    // 存储 resize 处理函数,以便后续清理
    resizeHandlers.value[containerId] = resizeHandler;
    window.addEventListener("resize", resizeHandler);
  });
};

// 渲染所有图表
const renderAllCharts = () => {
  props.data.forEach((chartData, index) => {
    renderChart(chartData, `chart-${index}`);
  });
};

// 监听数据变化
watch(
  () => [props.data, props.loading],
  ([newData, newLoading]) => {
    if (!newLoading && newData.length > 0) {
      renderAllCharts();
    }
  },
  { deep: true, immediate: true },
);

// 组件卸载时销毁所有图表实例
onBeforeUnmount(() => {
  // 先移除所有 resize 监听器
  Object.entries(resizeHandlers.value).forEach(([containerId, handler]) => {
    if (handler) {
      window.removeEventListener("resize", handler);
    }
  });

  // 再销毁所有图表实例
  Object.values(chartInstances.value).forEach(chart => {
    if (chart) {
      chart.dispose();
    }
  });

  // 清空引用
  chartInstances.value = {};
  resizeHandlers.value = {};
});
</script>

<template>
  <div class="chart-container">
    <div v-for="(chartData, index) in data" :key="index" :id="`chart-${index}`" class="chart"></div>
  </div>
</template>

<style scoped>
.chart-container {
  padding: 20px;
}

.chart {
  width: 100%;
  height: 400px;
  margin-bottom: 20px;
}
</style>

📊 对比分析

错误做法 vs 正确做法

方面 ❌ 错误做法 ✅ 正确做法
监听器管理 直接添加,不存储引用 存储监听器函数引用
销毁顺序 直接销毁图表实例 先移除监听器,再销毁实例
重复渲染 监听器堆积 清理旧监听器后再添加新的
组件卸载 只销毁图表实例 先清理监听器,再销毁实例
内存泄漏 ⚠️ 存在 ✅ 无
控制台警告 ⚠️ 有警告 ✅ 无警告

🎯 最佳实践总结

1. 使用对象存储多个图表实例

// ✅ 推荐:使用对象存储,支持多个图表
const chartInstances = ref({});
const resizeHandlers = ref({});

// ❌ 不推荐:单个变量,不支持多图表
const chartInstance = ref(null);

2. 销毁顺序很重要

// ✅ 正确顺序
// 1. 移除事件监听器
window.removeEventListener("resize", resizeHandler);
// 2. 销毁图表实例
chart.dispose();

// ❌ 错误顺序
// 1. 销毁图表实例
chart.dispose();
// 2. 移除事件监听器(此时监听器可能已经触发)
window.removeEventListener("resize", resizeHandler);

3. 存储监听器函数引用

// ✅ 正确:存储函数引用
const resizeHandler = () => {
  chart.resize();
};
resizeHandlers.value[containerId] = resizeHandler;
window.addEventListener("resize", resizeHandler);

// 后续可以精确移除
window.removeEventListener("resize", resizeHandlers.value[containerId]);

// ❌ 错误:匿名函数无法移除
window.addEventListener("resize", () => {
  chart.resize();
});
// 无法移除这个监听器!

4. 组件卸载时完整清理

onBeforeUnmount(() => {
  // ✅ 完整的清理流程
  // 1. 移除所有监听器
  Object.entries(resizeHandlers.value).forEach(([id, handler]) => {
    if (handler) {
      window.removeEventListener("resize", handler);
    }
  });

  // 2. 销毁所有图表
  Object.values(chartInstances.value).forEach(chart => {
    if (chart) {
      chart.dispose();
    }
  });

  // 3. 清空引用
  chartInstances.value = {};
  resizeHandlers.value = {};
});

5. 使用 nextTick 确保 DOM 已渲染

// ✅ 推荐:使用 nextTick
const renderChart = (data, containerId) => {
  nextTick(() => {
    const container = document.getElementById(containerId);
    if (!container) return;
    // ... 渲染图表
  });
};

// ❌ 不推荐:直接渲染可能找不到 DOM
const renderChart = (data, containerId) => {
  const container = document.getElementById(containerId);
  // container 可能为 null
};

🔧 其他解决方案

方案 1:禁用 ECharts 警告(不推荐)

// ⚠️ 治标不治本,不推荐
echarts.warn = function () {};

缺点

  • 只是隐藏警告,没有解决根本问题
  • 内存泄漏依然存在
  • 失去了 ECharts 的其他有用警告

方案 2:使用 try-catch 静默处理(不推荐)

// ⚠️ 不推荐
try {
  chart.dispose();
} catch (e) {
  // 忽略错误
}

缺点

  • 没有解决监听器泄漏问题
  • 可能隐藏其他真正的错误

方案 3:正确管理监听器(✅ 推荐)

// ✅ 推荐:本文介绍的方案
// 1. 存储监听器引用
// 2. 销毁前先移除监听器
// 3. 组件卸载时完整清理

📚 参考资料


💡 总结

  1. 核心原则:在销毁图表实例前,先移除所有相关的事件监听器
  2. 存储引用:使用对象存储图表实例和监听器函数引用
  3. 正确顺序:先移除监听器 → 再销毁图表 → 最后清空引用
  4. 完整清理:组件卸载时确保所有资源都被正确释放
  5. 避免泄漏:每次重新渲染前清理旧的监听器

遵循这些最佳实践,可以完全避免 ECharts 的 "has been disposed" 警告,并确保没有内存泄漏问题。

基于PDF.js的安全PDF预览组件实现:从虚拟滚动到水印渲染

作者 大鸡爪
2026年1月9日 10:26

基于PDF.js的安全PDF预览组件实现:从虚拟滚动到水印渲染

本文将详细介绍如何基于Mozilla PDF.js实现一个功能完善、安全可靠的PDF预览组件,重点讲解虚拟滚动、双模式渲染、水印实现等核心技术。

前言

在Web应用中实现PDF预览功能是常见需求,尤其是在线教育、文档管理等场景。然而,简单的PDF预览往往无法满足实际业务需求,特别是在安全性方面。本文将介绍如何基于PDF.js实现一个功能完善的PDF预览组件,并重点讲解如何添加自定义防下载和水印功能,为文档安全提供保障。

功能概览

我们的PDF预览组件实现了以下核心功能:

  1. 基础功能:PDF文件加载与渲染、自定义尺寸控制、页面缩放规则配置、主题切换
  2. 安全增强:动态水印添加、防下载功能、右键菜单禁用、打印控制
  3. 用户体验:页面渲染事件通知、响应式布局适配、加载状态反馈

技术实现

1. 虚拟滚动加载

对于大型PDF文件,一次性渲染所有页面会导致严重的性能问题。我们通过虚拟滚动技术优化大文档的加载性能,只渲染当前可见区域和附近的页面:

// 页面缓存管理
class PDFPageViewBuffer {
  #buf = new Set();
  #size = 0;

  constructor(size) {
    this.#size = size;  // 缓存页面数量限制
  }

  push(view) {
    const buf = this.#buf;
    if (buf.has(view)) {
      buf.delete(view);
    }
    buf.add(view);
    if (buf.size > this.#size) {
      this.#destroyFirstView();  // 超出限制时销毁最早的页面
    }
  }
}

优势

  • 内存优化:只保留有限数量的页面在内存中
  • 性能提升:减少不必要的渲染操作
  • 流畅体验:滚动时动态加载页面

2. 双模式渲染:Canvas与HTML

PDF.js支持两种渲染模式,可根据不同需求选择。两种渲染方式在视觉效果和性能上有明显差异:

在这里插入图片描述

图:HTML渲染模式下的PDF显示效果

在这里插入图片描述

图:Canvas渲染模式下的PDF显示效果

Canvas渲染(默认)
// 创建Canvas元素
const canvas = document.createElement("canvas");
canvas.setAttribute("role", "presentation");

// 获取2D渲染上下文
const ctx = canvas.getContext("2d", {
  alpha: false,           // 禁用透明度通道,提高性能
  willReadFrequently: !this.#enableHWA  // 根据硬件加速设置优化
});

// 渲染PDF页面到Canvas
const renderContext = {
  canvasContext: ctx,
  transform,
  viewport,
  // 其他参数...
};
const renderTask = pdfPage.render(renderContext);
HTML渲染
// HTML渲染模式(文本层)
if (!this.textLayer && this.#textLayerMode !== TextLayerMode.DISABLE) {
  this.textLayer = new TextLayerBuilder({
    pdfPage,
    highlighter: this._textHighlighter,
    accessibilityManager: this._accessibilityManager,
    enablePermissions: this.#textLayerMode === TextLayerMode.ENABLE_PERMISSIONS,
    onAppend: (textLayerDiv) => {
      this.#addLayer(textLayerDiv, "textLayer");
    }
  });
}

两种模式对比

特性 Canvas渲染 HTML渲染
性能 中等
文本选择 不支持 支持
缩放质量 中等
内存使用
兼容性 极好

3. 水印渲染实现

水印是保护文档版权的重要手段。我们在PDF页面渲染完成后,直接在Canvas上添加水印,确保水印与内容融为一体:

// 在渲染完成后添加水印
const resultPromise = renderTask.promise.then(async () => {
  showCanvas?.(true);
  await this.#finishRenderTask(renderTask);

  // 添加水印
  createWaterMark({ fontText: warterMark, canvas, ctx });

  // 其他处理...
});

// 水印绘制函数
function createWaterMark({
  ctx,
  canvas,
  fontText = '默认水印',
  fontFamily = 'microsoft yahei',
  fontSize = 30,
  fontcolor = 'rgba(218, 218, 218, 0.5)',
  rotate = 30,
  textAlign = 'left'
}) {
  // 保存当前状态
  ctx.save();

  // 计算响应式字体大小
  const canvasW = canvas.width;
  const calfontSize = (fontSize * canvasW) / 800;
  ctx.font = `${calfontSize}px ${fontFamily}`;
  ctx.fillStyle = fontcolor;
  ctx.textAlign = textAlign;
  ctx.textBaseline = 'Middle';

  // 添加多个水印
  const pH = canvas.height / 4;
  const pW = canvas.width / 4;
  const positions = [
    { x: pW, y: pH },
    { x: 3 * pW, y: pH },
    { x: pW * 1.3, y: 3 * pH },
    { x: 3 * pW, y: 3 * pH }
  ];

  positions.forEach((pos) => {
    ctx.save();
    ctx.translate(pos.x, pos.y);
    ctx.rotate(-rotate * Math.PI / 180);
    ctx.fillText(fontText, 0, 0);
    ctx.restore();
  });

  // 恢复状态
  ctx.restore();
}

水印技术亮点

  • 响应式设计:根据Canvas宽度自动调整水印尺寸
  • 多点布局:四个位置分布水印,覆盖整个页面
  • 旋转效果:每个水印独立旋转30度,增加覆盖范围
  • 透明度处理:使用半透明颜色,不影响内容可读性

4. 防下载与打印控制

为了增强文档安全性,我们实现了全面的防下载和打印控制功能:

// 禁用右键菜单
document.addEventListener('contextmenu', function(e) {
  e.preventDefault();
  return false;
});

// 禁用文本选择
document.addEventListener('selectstart', function(e) {
  e.preventDefault();
  return false;
});

// 禁用拖拽
document.addEventListener('dragstart', function(e) {
  e.preventDefault();
  return false;
});

// 拦截Ctrl+P打印快捷键
window.addEventListener("keydown", function (event) {
  if (event.keyCode === 80 && (event.ctrlKey || event.metaKey) && 
      !event.altKey && (!event.shiftKey || window.chrome || window.opera)) {
    // 自定义打印行为或完全禁用
    event.preventDefault();
    event.stopImmediatePropagation();
  }
}, true);

Vue组件实现

基于以上技术,我们实现了一个功能完善的Vue3 PDF预览组件:

<template>
  <iframe
    :width="viewerWidth"
    :height="viewerHeight"
    id="ifra"
    frameborder="0"
    :src="`/pdfJs/web/viewer.html?file=${src}&waterMark=${waterMark}`"
    @load="pagesRendered"
  />
</template>

<script setup>
import { computed } from 'vue'
import { useUserStore } from '~/store/user'

const props = defineProps({
  src: String,
  width: [String, Number],
  height: [String, Number],
  pageScale: [String, Number],
  theme: String,
  fileName: String
})

const emit = defineEmits(['loaded'])

// 默认值设置
const propsWithDefaults = withDefaults(props, {
  width: '100%',
  height: '100vh',
  pageScale: 'page-width',
  theme: 'dark',
  fileName: ''
})

// 尺寸计算
const viewerWidth = computed(() => {
  if (typeof props.width === 'number') {
    return props.width + 'px'
  } else {
    return props.width
  }
})

const viewerHeight = computed(() => {
  if (typeof props.height === 'number') {
    return props.height + 'px'
  } else {
    return props.height
  }
})

// 用户信息和水印
const userStore = useUserStore()
const userInfo = computed(() => userStore.userInfo)

const waterMark = computed(() => {
  const { userName, phoneNum } = userInfo.value
  const phoneSuffix = phoneNum && phoneNum.substring(phoneNum.length - 4)
  return userName + phoneSuffix
})

// 页面渲染事件
function pagesRendered(pdfApp) {
  emit('loaded', pdfApp)
}
</script>

<style scoped>
#ifra {
  max-width: 100%;
  height: 100%;
  margin-left: 50%;
  transform: translateX(-50%);
}
</style>

使用方法

基本使用

<template>
  <PDFViewer
    src="path/to/your/pdf/file.pdf"
    :width="800"
    :height="600"
    @loaded="handlePdfLoaded"
  />
</template>

<script setup>
import PDFViewer from '@/components/PDFViewer/index.vue'

function handlePdfLoaded(pdfApp) {
  console.log('PDF已加载完成', pdfApp)
}
</script>

高级配置

<template>
  <PDFViewer
    src="path/to/your/pdf/file.pdf"
    width="100%"
    height="90vh"
    page-scale="page-fit"
    theme="light"
    file-name="自定义文件名.pdf"
    @loaded="handlePdfLoaded"
  />
</template>

性能优化

1. 渲染性能优化

// 设置合理的maxCanvasPixels
const maxCanvasPixels = isHighEndDevice ? 
  16777216 * 4 :  // 4K显示器
  8388608 * 2;   // 普通显示器

const pdfViewer = new PDFViewer({
  container: document.getElementById('viewer'),
  maxCanvasPixels: maxCanvasPixels
});

2. 内存管理优化

// 限制缓存页面数量,防止内存溢出
pdfViewer.setDocument(pdfDocument);
pdfViewer.currentScaleValue = 'auto';

// 定期清理不可见页面
setInterval(() => {
  const visiblePages = pdfViewer._getVisiblePages();
  // 清理不可见页面的缓存
}, 30000);

3. 按需渲染

// 只渲染可见页面
pdfViewer.onPagesLoaded = () => {
  const visiblePages = pdfViewer._getVisiblePages();
  // 只渲染可见页面,延迟渲染其他页面
};

注意事项

  1. PDF.js版本:确保使用兼容的PDF.js版本,不同版本API可能有差异
  2. 跨域处理:PDF文件可能存在跨域问题,需确保服务器配置了正确的CORS头
  3. 大文件处理:对于大型PDF文件,考虑添加加载进度提示
  4. 移动端适配:在移动设备上可能需要额外的样式调整
  5. 安全限制:虽然实现了防下载和水印,但无法完全防止技术用户获取PDF内容

扩展功能建议

  1. 页面跳转:添加页面导航功能,支持直接跳转到指定页面
  2. 文本搜索:实现PDF内容搜索功能
  3. 注释工具:添加PDF注释、标记功能
  4. 水印样式自定义:支持更多水印样式和位置配置
  5. 访问控制:基于用户角色限制PDF访问权限

总结

本文介绍了如何基于Mozilla PDF.js实现一个功能完善的PDF预览组件,并重点讲解了如何添加自定义的防下载和水印功能。通过合理的技术选型和组件设计,我们实现了一个既美观又安全的PDF预览解决方案。

在实际应用中,您可以根据具体需求进一步扩展功能,如添加页面导航、文本搜索等高级特性,为用户提供更丰富的PDF阅读体验,同时确保文档内容的安全性。

希望本文对您在Vue3项目中实现安全PDF预览功能有所帮助!

需要源码的评论区回复6666

『NAS』中午煮什么?Cook

2026年1月9日 10:20

点赞 + 关注 + 收藏 = 学会了

整理了一个NAS小专栏,有兴趣的工友可以关注一下 👉 《NAS邪修》

cook(来做菜)是一款适合家里食材或厨具不多时用的免费菜谱工具,选好自己有的食材和厨具就能找到适配菜谱,还能随机挑菜谱,非常适合选择困难症用户。

选好菜式,点击会跳转到B站对应的做菜教学视频。

01.png

好,动手安装!

首先在 docker 目录下创建一个 cook 文件夹。

02.png

然后打开“Container Manager”,新增一个项目。

“路径”指向刚刚创建好的 cook 文件夹。

“来源”选择“创建 docker-compose.yml”。

03.png

接着输入以下代码。

services:
  cook:
    image: yunyoujun/cook:latest
    container_name: autopiano
    ports:
      - 8080:80
    restart: unless-stopped

在“网页门户设置”里启用“通过 Web Station 设置网页门户”。

04.png

接着打开“Web Station”,新增一个网络门户。

“服务“这项选择”cook“。

“端口”随便填,只要不跟其他项目冲突即可。

05.png

在浏览器输入你NAS的IP地址,再加上“cook”的端口号,比如本例设置的是 2342。就能看到 cook 的界面了。

01.png


以上就是本文的全部内容啦,想了解更多NAS玩法可以关注《NAS邪修》

点赞 + 关注 + 收藏 = 学会了

React基础框架搭建10-webpack配置:react+router+redux+axios+Tailwind+webpack

作者 Eadia
2026年1月9日 10:11

webpack配置

npm install --save-dev webpack webpack-cli webpack-dev-server
npm install --save-dev babel-loader @babel/core @babel/preset-env @babel/preset-react
npm install --save-dev html-webpack-plugin clean-webpack-plugin
npm install --save-dev css-loader style-loader
npm install --save-dev file-loader url-loader
npm install --save-dev mini-css-extract-plugin
npm install --save-dev dotenv-webpack

在根目录创建webpack.config.js

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const Dotenv = require('dotenv-webpack');

module.exports = {
    mode: 'development', // 开发模式
    entry: './src/index.js', // 入口文件
    output: {
        path: path.resolve(__dirname, 'dist'), // 输出目录
        filename: 'bundle.js', // 输出文件名
        publicPath: '/', // 公共路径
    },
    resolve: {
        extensions: ['.js', '.jsx'], // 解析的文件扩展名
        alias: {
            '@': path.resolve(__dirname, 'src'), // 设置路径别名
        },
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/, // 处理 JavaScript 和 JSX 文件
                exclude: /node_modules/,
                use: {
                    loader: 'babel-loader',
                    options: {
                        presets: ['@babel/preset-env', '@babel/preset-react'], // Babel 配置
                    },
                },
            },
            {
                test: /\.css$/, // 处理 CSS 文件
                use: ['style-loader', 'css-loader'],
            },
            {
                test: /\.(png|jpg|gif|svg)$/, // 处理图片文件
                use: [
                    {
                        loader: 'file-loader',
                        options: {
                            name: '[path][name].[ext]', // 保持原有路径和文件名
                        },
                    },
                ],
            },
        ],
    },
    devServer: {
        static: {
            directory: path.join(__dirname, 'dist'), // 更新为 static
        },
        compress: true, // 启用 gzip 压缩
        port: 3000, // 端口号
        historyApiFallback: true, // 支持 HTML5 History API
    },
    plugins: [
        new CleanWebpackPlugin(), // 清理输出目录
        new HtmlWebpackPlugin({
            template: './public/index.html', // HTML 模板
            filename: 'index.html', // 输出的 HTML 文件名
        }),
    ],
};

在 package.json 中添加 Webpack 的构建和开发脚本:

"scripts": {
    "start": "webpack serve --open", // 启动开发服务器
    "build": "webpack --mode production" // 构建生产版本
}

Zustand 、Jotai和Valtio源码探析

作者 清风乐鸣
2026年1月9日 10:11

一个核心的API:useSyncExternalStore

作用:安全地将React组件链接到外部状态管理库(如Redux、Zustand、浏览器storage),解决并发渲染下的撕裂问题

最核心的代码:


function useSyncExternalStore(subscribe, getSnapshot) {

  const [state, setState] = useState(getSnapshot());

  useEffect(() => {

    const handleStoreChange = () => {

      setState(getSnapshot());

    };

    // 1. 订阅状态变化(返回清理函数)

    const unsubscribe = subscribe(handleStoreChange);

    // 2. 返回清理函数(组件卸载时执行)

    return unsubscribe;

  }, [subscribe, getSnapshot]);

  return state;

}

演示Zustand的订阅过程

// 1. 这是一个极其迷你的 Store
const store = {
  // 这是那个名单本子 (Set)
  listeners: new Set(),
  // ✨重点在这里:订阅函数✨
  subscribe: function(callback) {
    // 动作:把你传进来的函数(联系方式),加到本子上
    this.listeners.add(callback);
    console.log(`✅ 成功追加一个监听!现在名单里有 ${this.listeners.size} 个人。`);
    // 返回一个函数,用来取消订阅(以后再说)
    return () => this.listeners.delete(callback);
  },
  // 假装数据变了,通知大家
  setState: function() {
    console.log("📢 只有一件事:数据变了!开始挨个通知...");
    // 遍历 Set,执行每个函数
    this.listeners.forEach(run => run());
  }
};

// ==========================================
// 场景开始:两个“组件”来订阅了
// ==========================================

// 模拟组件 A(比如是页面顶部的 Header)

const componentA_Update = () => console.log("   -> 组件A收到通知:我要检查下用户名变没变");

// 模拟组件 B(比如是页面底部的 Footer)

const componentB_Update = () => console.log("   -> 组件B收到通知:我要检查下版权年份变没变");

// 动作 1:组件 A 出生了,请求订阅

store.subscribe(componentA_Update);

// 👉 结果:Set 内部现在是 { componentA_Update }


// 动作 2:组件 B 出生了,请求订阅

store.subscribe(componentB_Update);

// 👉 结果:Set 内部现在是 { componentA_Update, componentB_Update }

// ==========================================
// 动作 3:数据变了!
// ==========================================

store.setState();

演示Jotai的订阅过程

Jotai的核心区别在于“订阅是跟着Atom走的,而不是跟着Store走的”。在 Zustand 里,是你跑到大厅(Store)里喊一嗓子,所有人都会听到。 在 Jotai 里,是你分别跑到不同的房间(Atom)门口去留小纸条。

// ==========================================
// 1. 模拟一个迷你的 Jotai Store (Provider)
// ==========================================

const jotaiStore = {
  // 这里的名单本子是【分门别类】的!
  // Key 是 atom 本身,Value 是这个 atom 专属的粉丝名单(Set)
  listeners: new Map(),
  // ✨重点在这里:订阅函数✨
  // 你必须告诉我:你要订阅【哪一个 Atom】?
  subscribe: function(atom, callback) {
    // 1. 如果这个 atom 还没人关注过,先给它建个新的空名单
    if (!this.listeners.has(atom)) {
      this.listeners.set(atom, new Set());
    }
    
    // 2. 拿到这个 atom 专属的名单
    const fans = this.listeners.get(atom);
    // 3. 把回调加上去
    fans.add(callback);
    console.log(`✅ 成功关注!Atom [${atom.key}] 现在有 ${fans.size} 个粉丝。`);
    return () => fans.delete(callback);

  },

  // 假装这一颗具体的 Atom 变了
  setAtom: function(atom, newValue) {
    console.log(`📢 只有一件事:Atom [${atom.key}] 的值变成了 ${newValue}!开始通知粉丝...`);
    // 1. 只找这个 Atom 的粉丝
    const fans = this.listeners.get(atom);
    if (fans) {
      // 2. 精准通知,闲杂人等根本不会被吵醒
      fans.forEach(run => run());
    } else {
      console.log("   (尴尬: 这个 atom 没有任何人订阅,无事发生)");
    }
  }
};

// ==========================================
// 场景开始:定义两个独立的 Atom
// ==========================================
const countAtom = { key: 'CountAtom', init: 0 }; // 房间 A
const textAtom  = { key: 'TextAtom'init: 'hi' }; // 房间 B
// ==========================================
// 模拟组件
// ==========================================

// 模拟组件 A:只关心数字

// 对应代码: useAtom(countAtom)

const componentA_Update = () => console.log("   -> 组件A收到通知:我订阅的 Count 变了,我要重渲染!");

  


// 模拟组件 B:只关心文字

// 对应代码: useAtom(textAtom)

const componentB_Update = () => console.log("   -> 组件B收到通知:我订阅的 Text 变了,我要重渲染!");

  


// 动作 1:组件 A 订阅 countAtom

jotaiStore.subscribe(countAtom, componentA_Update);

  


// 动作 2:组件 B 订阅 textAtom

jotaiStore.subscribe(textAtom, componentB_Update);

  


// ==========================================

// 动作 3:修改 TextAtom (比如输入框打字)

// ==========================================

jotaiStore.setAtom(textAtom, 'hello world'); 

  


// 👉 结果:

// 只有组件 B 会打印日志。

// 组件 A 正在睡大觉,根本不知道发生了什么。这就是“原子化订阅”的威力。

演示Valtio的订阅过程

对于 Valtio,它的核心在于 “间谍 (Proxy)” 和 “快照 (Snapshot)”。它的订阅既不是去大厅喊(Zustand),也不是去房间留条(Jotai),而是 “给对象装个窃听器”。你以为你在随意修改对象 state.count++,其实你改的是一个装了窃听器的 Proxy。这个窃听器会自动通知 React:“嘿,版本号变了,快来拿新照片(Snapshot)”。


// ==========================================

// 1. 模拟一个迷你的 Valtio Proxy

// ==========================================

  


// 这是我们的“窃听器中心”

// Key 是 proxy 对象本身,Value 是订阅者名单

const listenersMap = new WeakMap();

  


// 这是我们的“版本记录中心”

const versionMap = new WeakMap();

  


// ✨ 造一个带窃听器的对象

function proxy(initialObj) {

  // 初始版本号 0

  let version = 0;

  

  // 真正的核心:拦截器

  const p = new Proxy(initialObj, {

    

    // 拦截写入:你以为只有赋值,其实还触发了通知

    set(target, prop, value) {

      target[prop] = value;

      

      // 1. 升级版本号 (Version Increment)

      version++;

      versionMap.set(p, version);

      

      console.log(`📢 监测到写入:${prop} = ${value} (当前版本: v${version})`);

      

      // 2. 只有在此刻,才通知订阅者

      notify(p);

      return true;

    }

  });

  


  // 初始化记录

  listenersMap.set(p, new Set());

  versionMap.set(p, version);

  

  return p;

}

  


// 辅助函数:通知

function notify(p) {

  const fans = listenersMap.get(p);

  fans.forEach(cb => cb());

}

  


// ==========================================

// 场景开始:创建一个可变状态

// ==========================================

const state = proxy({ count: 0, text: 'hello' });

  


// ==========================================

// 模拟组件 (使用 useSnapshot)

// ==========================================

  


// 模拟组件 A

const componentA_Update = () => {

    // 每次组件渲染,都会检查版本号

    const currentVer = versionMap.get(state);

    

    // 如果版本变了,React 就会拿到一个新的 snapshot 从而更新

    console.log(`   -> 组件A收到通知:版本变成 v${currentVer} 了,我要去拉取新快照!`);

};

  


// 动作 1:组件订阅

// 在 Valtio 里,这一步通常发生在 useSnapshot 内部

const fans = listenersMap.get(state);

fans.add(componentA_Update);

  


// ==========================================

// 动作 2:直接修改属性 (Mutable!)

// ==========================================

console.log("--- 准备修改 count ---");

state.count++; 

// 👉 结果:控制台打印 "监测到写入..." -> "组件A收到通知..."

  


console.log("--- 准备修改 text ---");

state.text = 'world';

// 👉 结果:同样触发通知。注意:这里是对象级别的通知。

// (真实的 Valtio 还有更高级的属性级优化,但原理就是这个 Loop)

  


核心对比

  • Zustand: store.subscribe(cb)

• 比喻:大喇叭广播。

• 机制:所有变更都会触发 cb,必须由 CB 内部自己决定是不是真的要更新 (Selector)。

• 适用:粗粒度、低频、全局状态。

  • Jotai: store.subscribe(atom, cb)

• 比喻:房间门口留条。

• 机制:只有 指定 Atom 变更才会触发 cb,不需要 Selector,天然精准。

• 适用:细粒度、高频、复杂依赖图(如节点编辑器)。

  • Valtio: subscribe(proxy, cb)

• 比喻:装了窃听器。

• 机制:写的时候自动触发通知,读的时候检查版本号 (Version Check)。哪怕你改的是深层嵌套属性 state.a.b.c = 1,也会通过递归 Proxy 冒泡上来触发更新。

• 适用:极高频交互、深层嵌套数据、游戏/3D开发(喜欢 Mutable 写法的场景)。

Flutter 零基础入门(八):Dart 类(Class)与对象(Object)

作者 LawrenceLan
2026年1月9日 10:08

📘Flutter 零基础入门(八):Dart 类(Class)与对象(Object)

公众号版

在前面的学习中,我们已经学会了:

  • 使用 List 存储一组数据
  • 使用 Map 描述一条结构化数据
  • 使用函数封装逻辑

你现在可能已经写过类似这样的代码:

Map<String, dynamic> user = {
  'name': 'Tom',
  'age': 18,
};

这在学习阶段完全没问题,但在真实项目中,很快会暴露一些问题:

  • key 写错了,编译器发现不了
  • 数据结构不清晰
  • 不利于维护和扩展

为了解决这些问题,Dart 提供了更强大的工具: 👉 类(Class)与对象(Object)


一、什么是类(Class)?

类可以理解为:

一个“模板”或“蓝图”,用来描述一类事物

例如:

  • 用户
  • 商品
  • 订单

它描述的是:

  • 这个事物有什么属性
  • 这个事物能做什么事情

二、什么是对象(Object)?

对象是:

根据类创建出来的具体实例

类 ≈ 图纸 对象 ≈ 根据图纸造出来的房子


三、为什么要使用类?

相比 Map,类的优势非常明显:

  • 结构清晰
  • 有类型约束
  • 编译期可检查错误
  • 更符合真实业务建模

📌 Flutter 项目中几乎一定会用到类


四、定义一个最简单的类

class User {
  String name;
  int age;

  User(this.name, this.age);
}

拆解理解:

  • class User:定义一个类
  • nameage:类的属性
  • User(...):构造函数,用于创建对象

五、创建对象(实例化)

User user = User('Tom', 18);

print(user.name);
print(user.age);

这里:

  • User 是类
  • user 是对象

📌 对象通过 . 访问属性


六、类中的方法(行为)

类不仅可以有属性,还可以有方法。

class User {
  String name;
  int age;

  User(this.name, this.age);

  void introduce() {
    print('我叫$name,今年$age岁');
  }
}

调用方法:

User user = User('Tom', 18);
user.introduce();

📌 方法本质上就是:

属于这个类的函数


七、类 vs Map(对比理解)

对比项 Map Class
结构清晰度 一般 非常清晰
类型检查
自动补全
适合项目

📌 结论:

Map 用于临时数据,Class 用于项目结构


八、List + Class(真实项目结构)

List<User> users = [
  User('Tom', 18),
  User('Lucy', 20),
];

遍历:

for (var user in users) {
  user.introduce();
}

📌 这已经是 Flutter 项目中非常常见的写法了。


九、类是 Flutter 的核心基础

在 Flutter 中:

  • 页面是 Widget 类
  • StatelessWidget / StatefulWidget 是类
  • 页面状态、数据模型都是类

📌 你现在学的内容,将直接用于:

页面开发、数据模型、业务封装


十、总结

本篇你已经学会了:

  • 什么是类(Class)
  • 什么是对象(Object)
  • 如何定义和使用类
  • 为什么类比 Map 更适合项目

你已经完成了从:

“数据结构” → “业务建模” 的关键跃迁


🔜 下一篇预告

《Flutter 零基础入门(九):构造函数、命名构造函数与 this 关键字》

下一篇我们将学习:

  • 构造函数的更多写法
  • 命名构造函数的作用
  • this 的真正含义
  • 更规范地创建对象

从下一篇开始,你写的 Dart 代码将越来越像:

专业 Flutter 项目中的代码

从"请求地狱"到"请求天堂":alovajs 如何用 20+ 高级特性拯救前端开发者

2026年1月9日 10:00

写在前面:你可能每天都在重复这些工作

// 场景 1:基础请求
const [loading, setLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);

useEffect(() => {
  setLoading(true);
  fetch('/api/users')
    .then(res => res.json())
    .then(data => {
      setData(data);
      setLoading(false);
    })
    .catch(err => {
      setError(err);
      setLoading(false);
    });
}, []);

// 场景 2:带重试的请求(你已经写了 50 行,还在考虑要不要加重试)
// 场景 3:分页加载(数据要拼接、缓存要管理、预加载要考虑...)
// 场景 4:表单提交(验证、持久化、提交后重置...)

如果你觉得这些场景似曾相识,那么这篇文章可能改变你对前端请求的认知。


alova 是什么?不是什么

❌ 不是简单的 axios/fetch 封装

很多人第一反应:"这不就是封装了 axios 吗?"

错。

alova 采用的是适配器模式,你可以选择任何底层请求库:axios、fetch、XHR、SuperAgent,甚至 Taro/UniApp 的跨平台适配器。它只是把不同库的接口转换成统一规范,核心是请求策略编排

✅ 是一个请求策略引擎

alova 的野心更大:它要解决所有请求相关的痛点,让你像搭积木一样组合各种高级特性,用最少的代码实现最复杂的功能。


20+ 高级特性全景图

🎯 核心请求策略(3大基石)

1. useRequest - 请求状态自动化

// 传统写法:你需要手动管理 loading、data、error
// alova 写法:一行搞定
const { loading, data, error, send } = useRequest(getUserList);

魔法在哪里?

  • 自动管理 loading 状态
  • 自动响应式更新数据
  • 自动错误处理
  • 支持事件订阅(onSuccess、onError、onComplete)

2. useWatcher - 智能响应请求

// 搜索框防抖请求,传统写法:useEffect + 定时器
// alova 写法:
const { data } = useWatcher(
  (keyword) => searchApi(keyword),
  [keyword], // 监听 keyword 变化
  { debounce: 300 } // 内置防抖
);

3. useFetcher - 无组件数据获取

// 预加载数据,但不更新当前组件状态
const { fetch } = useFetcher();
useEffect(() => {
  // 鼠标悬停时预加载详情页数据
  fetch(getDetailApi(id));
}, []);

🚀 高级业务 Hooks(解决 80% 的复杂场景)

场景 1:分页列表(你写过 500 行,它用 5 行)

const { 
  data, 
  page, 
  pageCount, 
  isLastPage,
  insert,   // 插入列表项
  remove,   // 删除列表项
  replace,  // 替换列表项
  refresh   // 刷新指定页
} = usePagination(
  (page, pageSize) => getUserList({ page, pageSize }),
  {
    initialPage: 1,
    initialPageSize: 20,
    preloadPreviousPage: true,  // 自动预加载上一页
    preloadNextPage: true       // 自动预加载下一页
  }
);

// 删除用户,自动更新列表、总数、预加载缓存
remove(userId);

它帮你做了什么:

  • ✅ 自动拼接数据(下拉加载/翻页模式切换)
  • ✅ 自动预加载上一页/下一页
  • ✅ 智能缓存管理(删除某项,自动调整下一页缓存)
  • ✅ 虚拟列表优化(只请求需要的数据)
  • ✅ 跨页面状态同步

场景 2:表单管理(持久化、验证、提交流程)

const { 
  form, 
  updateForm, 
  reset,
  send 
} = useForm(
  (formData) => submitForm(formData),
  {
    initialForm: { username: '', email: '' },
    store: true,           // 自动持久化到本地存储
    resetAfterSubmiting: true, // 提交后自动重置
    immediate: false
  }
);

// 用户刷新页面,表单数据自动恢复
// 提交后自动重置,无需手动调用 reset()

场景 3:智能重试(指数退避、条件重试)

const { send, stop, onRetry, onFail } = useRetriableRequest(
  unstableApi,
  {
    retry: 3,                           // 最多重试 3 次
    backoff: {                          // 指数退避策略
      delay: 1000,
      multiplier: 2
    }
  }
);

// 重试时触发
onRetry(event => {
  console.log(`第 ${event.retryTimes} 次重试,延迟 ${event.delay}ms`);
});

// 最终失败时触发
onFail(event => {
  console.error('重试失败,原因:', event.error);
});

// 手动停止重试
stop();

场景 4:静默队列请求(断网重发)

const { send } = useSQRequest(
  (data) => reportAnalytics(data),
  {
    maxQueue: 100,      // 最多缓存 100 个请求
    queueWhenDisconnected: true  // 断网时入队
  }
);

// 即使网络断开,请求也会缓存到队列中
// 网络恢复后自动按顺序发送

场景 5:串行请求(确保执行顺序)

const { send } = useSerialRequest(
  (taskId) => {
    return getTaskStatus(taskId);
  },
  {
    // 确保每次只有一个请求在执行
    // 新请求会排队等待
  }
);

场景 6:实时推送(Server-Sent Events)

const { 
  data, 
  send, 
  onMessage, 
  onClose 
} = useSSE(
  () => new EventSource('/api/events'),
  {
    intercept: true,  // 拦截消息,自定义处理
    reconnect: true   // 断线自动重连
  }
);

onMessage((event) => {
  console.log('实时消息:', event.data);
});

🛠️ 底层高级能力(隐形超级英雄)

1. 请求共享(避免重复请求)

// 组件 A
const { data: data1 } = useRequest(getUserList);

// 组件 B(同时渲染)
const { data: data2 } = useRequest(getUserList);

// 只会发送一个请求,两个组件共享响应

原理: 通过请求指纹识别,同一时间相同请求只发一次,后续请求等待或复用结果。

2. 多级缓存系统

createAlova({
  cacheFor: {
    getUserList: { mode: 'memory', expire: 60000 },
    getDetail: { mode: 'storage', expire: 3600000 }
  }
});

缓存模式:

  • MEMORY:内存缓存,适合临时数据
  • STORAGE:本地存储,适合持久化
  • STORAGE_RESTORE:刷新页面后恢复

3. Token 自动认证

import { createTokenAuthentication } from 'alova/client';

createAlova({
  // ... 配置
  beforeRequest(method) {
    // 自动注入 Token
    tokenAuth.addTokenToHeader(method);
  }
});

const tokenAuth = createTokenAuthentication({
  login: (username, password) => loginApi(username, password),
  logout: () => logoutApi(),
  assignToken: (response) => response.token,
  tokenRefresher: (refreshToken) => refreshApi(refreshToken)
});

自动处理:

  • Token 过期自动刷新
  • 多个请求同时过期只刷新一次
  • 刷新失败自动重试登录

4. 中间件系统

useRequest(getUserList, {
  middleware: (context, next) => {
    // 请求前
    console.log('开始请求');
    
    return next().then(response => {
      // 响应后
      console.log('请求完成');
      return response;
    });
  }
});

5. OpenAPI 自动生成接口

# 一键生成类型安全的接口代码
npx alova-codegen --url http://api.example.com/openapi.json

生成结果:

  • 完整的 TypeScript 类型定义
  • 自动化的接口调用方法
  • 请求/响应类型推导

6. 跨标签页状态共享

// 标签页 A
const { data } = useRequest(getUserList);

// 标签页 B(用户在 A 中刷新数据)
// B 中自动同步最新数据,无需手动刷新

📊 对比:传统方案 vs alova

场景 axios + React Query alova 减少代码量
基础请求 30 行 3 行 90%
分页列表 200 行 20 行 90%
表单管理 150 行 15 行 90%
智能重试 100 行 10 行 90%
Token 认证 80 行 15 行 81%
总计 560 行 63 行 89%

💡 为什么选择 alova?

1. 开箱即用的高级特性

其他库需要你自己写中间件、插件,alova 已经帮你写好了。

2. 真正的跨框架

React/Vue/Svelte/Solid/Nuxt 同一套 API,技能复用率 100%。

3. 类型安全优先

从请求参数到响应数据,完整的 TypeScript 类型推导。

4. 性能极致优化

请求共享、多级缓存、智能预加载,这些都是内置的。

5. 开发效率提升 10 倍

从 560 行代码到 63 行代码,这就是差距。


🎬 快速上手

npm install alova @alova/client
import { createAlova } from 'alova';
import { useRequest } from '@alova/client';
import adapterFetch from 'alova/fetch';

const alova = createAlova({
  baseURL: 'https://api.example.com',
  requestAdapter: adapterFetch()
});

// 只需要这样
const { data, loading } = useRequest(alova.Get('/users'));

结语

前端开发不应该把时间浪费在重复的请求管理上。

alova 的 20+ 高级特性,本质上是对前端请求场景的深度抽象。它不是要替代 axios 或 fetch,而是要解决这些库无法解决的业务痛点。

你的时间应该花在产品逻辑上,而不是请求的加载、缓存、重试、错误处理这些重复工作中。


准备好了吗?从"请求地狱"到"请求天堂",只差一个 alova。

Vue以及ElementPlus学习

2026年1月9日 09:55

Vue常用指令

指令:HTML标签上带有v-前缀的特殊属性,不同的指令具有不同的含义,可以实现不同的功能

v-for

作用:列表渲染,遍历容器的元素或者对象的属性

语法:

<tr v-for="(item,index) in items":key="item.id">{{item}}</tr>

items:要遍历的数组

item:为遍历出来的元素

index:索引/下标,从0开始;

key:

作用:为元素添加唯一标识,便于vue进行列表项的正确排序复用,提升渲染性能

推荐使用id作为key(唯一)

v-bind

作用:动态为HTML标签绑定属性值,如设置href,src,style样式等

语法:v-bind:属性名="属性值"

简化::属性名=“属性值”

v-if &v-show

作用:这两类指令,都是用来控制元素的显示与隐藏的

v-if

  • 语法v-if="表达式",表达式的值为true,显示:false,隐藏
  • 原理:基于条件判断,来控制创建或移除元素节点
  • 场景:要么显示,要么不显示, 不频繁切换的场景

v-show

  • 语法:v-show="表达式",表达式的值为true,显示:false,隐藏
  • 原理:基于CSS样式display来控制显示与隐藏
  • 场景:频繁切换显示隐藏的场景

v-on

作用:为html标签绑定事件(添加事件监听)

语法:

v-on:事件名=“方法名”

简写为 @事件名="..."

v-model

  • v-model指令可以在表单 input、textarea以及select元素上创建双向数据绑定;
  • 它会根据控件类型自动选取正确的方法来更新元素;
  • 尽管如此, v-model 本质上是语法糖,它负责监听用户的输入事件来更新数据,并在某种极端场景下进行一些特殊处理;

Ajax

作用:

数据交换:通过Ajax可以给服务器发送请求,并获取服务器响应的数据

异步交互:可以在不重新加载整个页面的情况下,与服务器交换数据并更新部分网页的技术,如:搜索联想,用户名是否可用的校验等等

同步与异步

同步:客户端发起请求服务器,服务器处理,客户端等待,处理后返回客户端,客户端解除等待

异步:客户端发出请求后可以执行其他操作,服务器处理后返回

Axios

对原生的Ajax进行了封装,简化书写,快速开发

Ajax
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
    //发送GET请求
    document.querySelector('#btnGet').addEventListener('click',()=>{
        //axios发布异步请求
        axios({
            url:'https://mock.apifox.cn/m1/3083103-0-default/emps/list',
            method:'GET'
        }).then((result)=>{//成功回调函数
            console.log(result);
        }).catch((err)=>{//失败回调函数
            console.log(err);
        })

    })
    //发送POST请求
    document.querySelector('#btnPost').addEventListener('click',()=>{
        axios({
            url:'https://mock.apifox.cn/m1/3083103-0-default/emps/upda',
            method:'POST',
            data:{id:1}//Post请求方式
        }).then((result)=>{//成功回调函数
            console.log(result);
        }).catch((err)=>{//失败回调函数
            console.log(err);
        })
    })
</script>

项目结构

根组件

<script setup>

</script>

<template>
  <ElementDemo></ElementDemo>
  
</template>

<style scoped>

</style>

index.html

    • 这是项目的入口HTML文件
    • 包含基本的HTML结构和元信息
    • 通过 <script type="module" src="/src/main.js"></script> 引入了 main.js 文件
    • 提供了一个挂载点 <div id="app"></div> 用于渲染Vue应用
  1. src/main.js
    • 这是Vue应用的入口JavaScript文件
    • 使用 createApp 创建Vue应用实例
    • 导入并挂载 App.vue 组件到 #app 元素上
    • 导入全局样式文件 ./assets/main.css
  2. src/App.vue
    • 这是Vue应用的根组件
    • 使用 <script setup> 语法定义组件逻辑
    • 包含一个响应式数据 message
    • <template> 中显示 message 的值
    • 通过 main.js 被挂载到页面上

整体流程:index.html 加载 main.jsmain.js 创建Vue应用并挂载 App.vueApp.vue 组件被渲染到 index.html#app 容器中。

只有在需要字符串插值或换行时才会使用反引号(模板字符串)。

API

组合式API

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ doubledCount }}</p>
    <button @click="increment">增加</button>
    <button @click="decrement">减少</button>
  </div>
</template>

<script setup>
import { ref, computed, watch, onMounted } from 'vue'

// 响应式数据
const count = ref(0)
const message = ref('Hello')

// 计算属性
const doubledCount = computed(() => count.value * 2)

// 方法
const increment = () => {
  count.value++
}

const decrement = () => {
  count.value--
}

// 生命周期
onMounted(() => {
  console.log('组件已挂载')
})

// 监听器
watch(count, (newVal, oldVal) => {
  console.log(`count从${oldVal}变为${newVal}`)
})
</script>

选项式API

<template>
  <div>
    <p>{{ count }}</p>
    <p>{{ doubledCount }}</p>
    <button @click="increment">增加</button>
    <button @click="decrement">减少</button>
  </div>
</template>

<script>
export default {
  // 数据选项
  data() {
    return {
      count: 0,
      message: 'Hello'
    }
  },
  
  // 计算属性
  computed: {
    doubledCount() {
      return this.count * 2
    }
  },
  
  // 方法
  methods: {
    increment() {
      this.count++
    },
    decrement() {
      this.count--
    }
  },
  
  // 生命周期
  mounted() {
    console.log('组件已挂载')
  },
  
  // 监听器
  watch: {
    count(newVal, oldVal) {
      console.log(`count从${oldVal}变为${newVal}`)
    }
  }
}
</script>

为了避免出现域名问题,需要在配置文件中指定访问的IP和端口号,且需要在请求路径前加前缀,避免访问到静态资源

server: {
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        secure: false,
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      }
    }
  }

发送请求和响应请求的逻辑以及结构

  1. 请求发送流程

  • 使用 axios 创建 request 实例,设置基础URL为 /api
  • 通过 request 实例的 HTTP 方法(get/post/put/delete)发送请求
  • 请求会自动加上 /api 前缀,例如 request.get('/depts') 实际访问 /api/depts
  1. 响应处理流程

  • request.interceptors.response.use() 设置了响应拦截器
  • 成功响应时,拦截器直接返回 response.data,即只返回实际数据部分
  • 失败响应时,拦截器将错误通过 Promise.reject(error) 向上抛出
  1. API 调用示例

queryAllApi 为例:

  • 调用 request.get('/depts') 发送 GET 请求

  • 请求地址实际为 /api/depts

  • 响应拦截器处理后,只返回数据部分给调用方

    处理数据部分时,在代码中采用async和await进行数据接收,对接受过来的数据进行处理,如果返回状态吗正确,输出相应提示信息

ElementPlus组件

参考文档

一个 Vue 3 UI 框架 | Element Plus

表格组件

<el-table :data="tableData" border style="width: 100%">
    <el-table-column prop="date" label="Date" width="180" align="center" />
    <el-table-column prop="name" label="Name" width="180" align="center" />
    <el-table-column prop="address" label="Address" align="center"/>
  </el-table>

prop为列属性,label为标签名字

弹窗表格

<div class="button-row">
    <el-button plain @click="dialogVisible = true"> Click to open the Dialog</el-button>
    <el-dialog v-model="dialogVisible" title="收获表格" width="800">
    <el-table :data="tableData">
      <el-table-column property="date" label="Date" width="150" />
      <el-table-column property="name" label="Name" width="200" />
      <el-table-column property="address" label="Address" />
    </el-table>
  </el-dialog>

分页组件

<div class="button-row">
    <el-pagination
      v-model:current-page="currentPage4"
      v-model:page-size="pageSize4"
      :page-sizes="[100, 200, 300, 400]"
      
      :background="background"
      layout="total, sizes, prev, pager, next, jumper"
      :total="400"
      @size-change="handleSizeChange"
      @current-change="handleCurrentChange"
    />
   </div>

Vue Router

Vue Router 是 Vue 官方的客户端路由解决方案。

客户端路由的作用是在单页应用 (SPA) 中将浏览器的 URL 和用户看到的内容绑定起来。当用户在应用中浏览不同页面时,URL 会随之更新,但页面不需要从服务器重新加载。

Vue Router 基于 Vue 的组件系统构建,你可以通过配置路由来告诉 Vue Router 为每个 URL 路径显示哪些组件。

  1. Route
    • route 指的是单个路由规则,即你在 routes 数组里定义的对象
    • 每个 route 包含 path(路径)、name(名称)、component(对应渲染的组件) 等属性
    • 例如 {path: '/login', name: 'login', component: LoginView} 就是一个具体的 route 配置
  2. Router View
    • <router-view> 是一个 Vue 组件,作为路由出口,用来显示当前路由匹配到的组件
    • 当 URL 改变时,<router-view> 会自动更新为对应的组件内容
    • 在嵌套路由中(如你配置中的 children),子组件也会渲染在父级的 <router-view>
  3. Router Link
    • <router-link> 是一个特殊的组件,用于创建导航链接
    • 使用它可以在不重新加载整个页面的情况下切换不同的路由
    • 典型用法是设置 to 属性指向目标路由的路径或命名路由,例如 <router-link to="/login">Login</router-link>

watch

Watch监听函数主要用于:

  • 在数据变化时执行异步操作
  • 在数据变化时执行开销较大的操作
  • 监听特定数据的变化并执行相应逻辑
  • 实现数据验证、数据联动等复杂业务逻辑

基本语法

// 选项式API
watch: {
  // 简单监听
  被监听的数据(newValue, oldValue) {
    // 响应数据变化的逻辑
  },
  
  // 深度监听
  被监听的数据: {
    handler(newValue, oldValue) {
      // 响应数据变化的逻辑
    },
    deep: true, // 深度监听对象内部值的变化
    immediate: true // 立即执行一次handler
  }
}

// 组合式API
import { watch } from 'vue'

watch(
  被监听的数据,
  (newValue, oldValue) => {
    // 响应数据变化的逻辑
  },
  {
    deep: true,
    immediate: true
  }
)

1. Vue 3 Composition API 核心概念

setup 语法糖

javascript

<script setup>
// 所有内容都在setup中,无需return
import { ref, watch, onMounted } from 'vue'
  • 原理<script setup> 是编译时语法糖,内部声明的变量、函数自动暴露给模板
  • 优势:代码更简洁,无需手动返回响应式数据

响应式系统

javascript

const empList = ref([])
const searchEmp = ref({ name: '', gender: '' })

原理分析

  • ref() 将基本类型包装为响应式对象,通过 .value 访问
  • 在模板中自动解包,无需 .value
  • Vue 3 使用 Proxy 实现响应式,比 Vue 2 的 Object.defineProperty 更强大

2. 生命周期管理

javascript

onMounted(() => {
  search()           // 初始化数据
  queryAllDepts()    // 加载部门数据
  getToken()         // 获取认证token
})

生命周期流程

  1. onMounted → 组件挂载完成后执行
  2. 异步加载初始数据
  3. 确保DOM已渲染,可以安全操作DOM

3. 数据侦听器 (Watch)

简单侦听

javascript

watch(() => searchEmp.value.date, (newValue, oldValue) => {
  // 处理日期范围变化
})

深度侦听

javascript

watch(() => employee.value.exprList, (newValue, oldValue) => {
  // deep: true 启用深度侦听
  employee.value.exprList.forEach(item => {
    item.begin = item.exprDate[0]
    item.end = item.exprDate[1]
  })
}, { deep: true })

侦听器原理

  • 第一个参数:要侦听的响应式数据
  • 第二个参数:回调函数,数据变化时执行
  • 第三个参数:配置选项(deep, immediate等)

4. 异步编程与 API 调用

async/await 模式

javascript

const search = async () => {
  const result = await queryPageApi(
    searchEmp.value.name, 
    searchEmp.value.gender,
    searchEmp.value.begin,
    searchEmp.value.end,
    currentPage.value,
    pageSize.value
  )
  if (result.code) {
    empList.value = result.data.rows
    total.value = result.data.total
  }
}

异步编程知识点

  • async 函数返回 Promise
  • await 暂停异步函数执行,等待 Promise 完成
  • 错误处理通过 try-catch 或条件判断

5. 数组操作与函数式编程

数组方法应用

javascript

// 1. map - 数据转换
selectIds.value = val.map((item) => item.id)

// 2. forEach - 遍历操作
employee.value.exprList.forEach(item => {
  item.begin = item.exprDate[0]
  item.end = item.exprDate[1]
})

// 3. splice - 删除数组元素
employee.value.exprList.splice(index, 1)

// 4. push - 添加数组元素
employee.value.exprList.push({company:'', job:'', begin:'', end:'', exprDate:[]})

6. 表单处理与验证

Element Plus 表单验证

javascript

const rules = ref({
  username: [
    { required: true, message: '用户名是必填项', trigger: 'blur' },
    { min: 2, max: 10, message: '用户名的长度应该在2-10位之间', trigger: 'blur' },
  ],
  phone: [
    { required: true, message: '请输入手机号', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '请输入有效的手机号', trigger: 'blur' },
  ]
})

验证规则详解

  • required: 必填字段
  • min/max: 长度限制
  • pattern: 正则表达式验证
  • trigger: 触发时机(blur、change)

表单提交验证

javascript

const save = async () => {
  empFormRef.value.validate(async (valid) => {
    if (valid) {
      // 验证通过,提交数据
      let result = employee.value.id 
        ? await updateApi(employee.value)
        : await addApi(employee.value)
      
      if (result.code) {
        ElMessage.success('保存成功')
        dialogVisible.value = false
        search()
      }
    } else {
      ElMessage.error('请填写必要的表单数据!')
    }
  })
}

7. 文件上传处理

javascript

// 上传成功回调
const handleAvatarSuccess = (response) => {
  employee.value.image = response.data
}

// 上传前验证
const beforeAvatarUpload = (rawFile) => {
  // 文件类型验证
  if (rawFile.type !== 'image/jpeg' && rawFile.type !== 'image/png') {
    ElMessage.error('只支持上传图片')
    return false
  }
  // 文件大小验证
  else if (rawFile.size / 1024 / 1024 > 10) {
    ElMessage.error('只能上传10M以内图片')
    return false
  }
  return true
}

8. 条件渲染与列表渲染

动态样式类

vue

<el-icon class="avatar-uploader-icon">
  <Plus />
</el-icon>

条件渲染

vue

<img v-if="employee.image" :src="employee.image" class="avatar">
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>

列表渲染

vue

<el-option v-for="g in genders" :key="g.value" :label="g.name" :value="g.value"></el-option>

<el-row v-for="(expr, index) in employee.exprList" :key="index">
  <!-- 动态生成工作经历表单项 -->
</el-row>

9. 事件处理

方法定义与调用

javascript

// 方法定义
const remove = (index) => {
  employee.value.exprList.splice(index, 1)
}

// 事件绑定
<el-button @click="remove(index)">- 删除</el-button>

事件修饰符

  • @click - 点击事件
  • @change - 值变化事件
  • @success - 成功事件(上传组件)

10. 组件通信与引用

模板引用

javascript

const empFormRef = ref()  // 创建引用

<el-form ref="empFormRef">  // 绑定引用

通过 ref 操作子组件

javascript

empFormRef.value.validate((valid) => {
  // 调用子组件方法
})

11. 本地存储操作

javascript

const getToken = async () => { 
  const loginToken = JSON.parse(localStorage.getItem('loginUser'))
  if (loginToken && loginToken.token) { 
    token.value = loginToken.token
  }
}

localStorage 操作

  • getItem(key) - 获取存储数据
  • setItem(key, value) - 设置存储数据
  • JSON.parse() - 解析JSON字符串
  • JSON.stringify() - 转换为JSON字符串

12. 弹窗与用户交互

确认对话框

javascript

const deleteID = async (id) => { 
  ElMessageBox.confirm(
    '您确认删除该部门吗?',
    '提示',
    {
      confirmButtonText: 'OK',
      cancelButtonText: 'Cancel',
      type: 'warning',
    }
  ).then(async () => {
    // 用户确认
    const result = await deleteApi(id)
    if (result.code) {
      ElMessage.success("删除成功")
      search()
    }
  }).catch(() => {
    // 用户取消
    ElMessage.info('您已经取消删除')
  })
}

关键 JavaScript 知识点总结

  1. ES6+ 语法:箭头函数、解构赋值、模板字符串
  2. 模块化:import/export 模块导入导出
  3. Promise 和异步编程:async/await 错误处理
  4. 数组方法:map、forEach、splice、push
  5. 对象操作:属性访问、方法调用
  6. 条件判断:if/else、三元运算符
  7. 函数作用域:闭包、this 指向
  8. 事件循环:宏任务、微任务执行顺序

这个组件展示了现代前端开发的典型模式:响应式数据绑定、组件化开发、异步数据流、表单处理等核心概念。

vxe-table 个性化列自定义列弹出层修改高度、修改最大高度不自动适应表格高度的方法

2026年1月9日 09:46

vxe-table 个性化列自定义列弹出层修改高度、修改最大高度不自动适应表格高度的方法

默认情况下,在表格设置高度或最小高度的情况下个性化列弹出层默认内部模式(自适应表格高度),表格多高就最大多高;未设置高度情况下默认外部模式(不跟随表格高度)

vxetable.cn

自适应高度时

当 custom-config.poupuOptions.mode='auto' 时,且同时设置高度时

image

不设置高度时

image

<template>
  <div>
    <vxe-radio-group v-model="gridOptions.height">
      <vxe-radio-button checked-value="200" content="高度200"></vxe-radio-button>
      <vxe-radio-button checked-value="" content="不设置高度"></vxe-radio-button>
    </vxe-radio-group>
    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const gridOptions = reactive({
  border: true,
  height: '',
  columnConfig: {
    resizable: true
  },
  toolbarConfig: {
    custom: true
  },
  columns: [
    { type: 'seq', width: 70 },
    { field: 'name', title: 'Name' },
    { field: 'role', title: 'Role' },
    { field: 'sex', title: 'Sex' },
    { field: 'age', title: 'Age' },
    { field: 'attr1', title: 'Attr1' },
    { field: 'attr2', title: 'Attr2' },
    { field: 'attr3', title: 'Attr3' },
    { field: 'attr4', title: 'Attr4' },
    { field: 'address', title: 'Address' }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'test abc' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' }
  ]
})
</script>

强制渲染弹出层为外部模式

强制渲染弹出层为外部模式,可以通过 custom-config.poupuOptions.mode='outside' 来设置,不管有没有设置高度都能超出表格显示,再配置 maxHeight 自定义最大高度

image

<template>
  <div>
    <vxe-radio-group v-model="gridOptions.height">
      <vxe-radio-button checked-value="200" content="高度200"></vxe-radio-button>
      <vxe-radio-button checked-value="" content="不设置高度"></vxe-radio-button>
    </vxe-radio-group>
    <vxe-grid v-bind="gridOptions"></vxe-grid>
  </div>
</template>

<script setup>
import { reactive } from 'vue'

const gridOptions = reactive({
  border: true,
  height: '',
  columnConfig: {
    resizable: true
  },
  customConfig: {
    popupOptions: {
      mode: 'outside'
    }
  },
  toolbarConfig: {
    custom: true
  },
  columns: [
    { type: 'seq', width: 70 },
    { field: 'name', title: 'Name' },
    { field: 'role', title: 'Role' },
    { field: 'sex', title: 'Sex' },
    { field: 'age', title: 'Age' },
    { field: 'attr1', title: 'Attr1' },
    { field: 'attr2', title: 'Attr2' },
    { field: 'attr3', title: 'Attr3' },
    { field: 'attr4', title: 'Attr4' },
    { field: 'address', title: 'Address' }
  ],
  data: [
    { id: 10001, name: 'Test1', role: 'Develop', sex: 'Man', age: 28, address: 'test abc' },
    { id: 10002, name: 'Test2', role: 'Test', sex: 'Women', age: 22, address: 'Guangzhou' },
    { id: 10003, name: 'Test3', role: 'PM', sex: 'Man', age: 32, address: 'Shanghai' }
  ]
})
</script>

gitee.com/x-extends/v…

React Native 邪修秘籍:在崩溃边缘疯狂试探的艺术

2026年1月9日 09:43

"RN 开发就像谈恋爱,你永远不知道下一秒会遇到什么 bug。" —— 某 RN 开发者,在第 N 次 npm install 失败后的感悟

前言:为什么 RN 开发需要"邪修"?

React Native 是一个神奇的框架:

  • 它让你用 JavaScript 写原生应用 —— 听起来很美好
  • 它让你一套代码跑两端 —— 理论上是这样
  • 它让你体验"Learn once, write anywhere" —— 实际上是"Learn once, debug everywhere"

每个 RN 开发者都经历过:

  • node_modules 删了重装,重装了再删
  • iOS 能跑 Android 崩,Android 能跑 iOS 白屏
  • 升级个版本,整个项目原地爆炸

所以,我们需要一些..."非常规手段"来生存。

免责声明:本文技巧可能导致代码审查者当场去世,请谨慎使用。


第一章:环境配置的玄学

1.1 问题:环境配置是一门玄学

# RN 开发者的日常
npm install
# 失败

rm -rf node_modules && npm install
# 还是失败

rm -rf node_modules package-lock.json && npm install
# 依然失败

# 终极大招
rm -rf node_modules package-lock.json
rm -rf ios/Pods ios/Podfile.lock
rm -rf android/.gradle android/app/build
watchman watch-del-all
npm cache clean --force
npm install
cd ios && pod install --repo-update && cd ..

# 如果还是失败,重启电脑
# 如果重启还是失败,重装系统
# 如果重装还是失败,换电脑

1.2 邪修技巧:一键清理脚本

#!/bin/bash
# 邪修秘籍第一式:核弹级清理脚本
# 保存为 nuke.sh,chmod +x nuke.sh

echo "🔥 开始核弹级清理..."

# 清理 node_modules
echo "💣 清理 node_modules..."
rm -rf node_modules
rm -rf package-lock.json
rm -rf yarn.lock

# 清理 iOS
echo "💣 清理 iOS..."
rm -rf ios/Pods
rm -rf ios/Podfile.lock
rm -rf ios/build
rm -rf ~/Library/Developer/Xcode/DerivedData

# 清理 Android
echo "💣 清理 Android..."
rm -rf android/.gradle
rm -rf android/app/build
rm -rf android/build

# 清理缓存
echo "💣 清理缓存..."
watchman watch-del-all 2>/dev/null || true
rm -rf $TMPDIR/react-* 2>/dev/null || true
rm -rf $TMPDIR/metro-* 2>/dev/null || true
rm -rf $TMPDIR/haste-* 2>/dev/null || true
npm cache clean --force

# 重新安装
echo "📦 重新安装依赖..."
npm install

# iOS Pod 安装
echo "📦 安装 iOS Pods..."
cd ios && pod install --repo-update && cd ..

echo "✅ 清理完成!试试 npm run ios 或 npm run android"
// package.json 里加个快捷命令
{
  "scripts": {
    "nuke": "bash nuke.sh",
    "ios": "react-native run-ios",
    "android": "react-native run-android",
    "clean-ios": "cd ios && rm -rf Pods Podfile.lock build && pod install && cd ..",
    "clean-android": "cd android && ./gradlew clean && cd .."
  }
}

使用方法:当一切都不工作的时候,npm run nuke


第二章:样式的骚操作

2.1 问题:StyleSheet 写到手抽筋

// 正常人写的样式
const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#fff",
    paddingHorizontal: 16,
    paddingTop: 20,
  },
  title: {
    fontSize: 24,
    fontWeight: "bold",
    color: "#333",
    marginBottom: 12,
  },
  subtitle: {
    fontSize: 16,
    color: "#666",
    marginBottom: 8,
  },
  // 还有 100 个样式...
})

2.2 邪修技巧:工具函数一把梭

// 邪修秘籍第二式:样式工具函数

import { StyleSheet, Dimensions, Platform } from "react-native"

const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window")

// 响应式尺寸(以 375 宽度为基准)
const scale = SCREEN_WIDTH / 375
export const s = (size: number) => Math.round(size * scale)

// 快速生成间距
export const spacing = {
  xs: s(4),
  sm: s(8),
  md: s(16),
  lg: s(24),
  xl: s(32),
}

// 快速生成字体样式
export const typography = {
  h1: { fontSize: s(32), fontWeight: "bold" as const, color: "#333" },
  h2: { fontSize: s(24), fontWeight: "bold" as const, color: "#333" },
  h3: { fontSize: s(20), fontWeight: "600" as const, color: "#333" },
  body: { fontSize: s(16), color: "#333" },
  caption: { fontSize: s(14), color: "#666" },
  small: { fontSize: s(12), color: "#999" },
}

// 快速生成 Flex 布局
export const flex = {
  row: { flexDirection: "row" as const },
  col: { flexDirection: "column" as const },
  center: { justifyContent: "center" as const, alignItems: "center" as const },
  between: { justifyContent: "space-between" as const },
  around: { justifyContent: "space-around" as const },
  start: { alignItems: "flex-start" as const },
  end: { alignItems: "flex-end" as const },
  wrap: { flexWrap: "wrap" as const },
  grow: { flex: 1 },
}

// 快速生成阴影(iOS 和 Android 统一)
export const shadow = (elevation: number = 4) => ({
  ...Platform.select({
    ios: {
      shadowColor: "#000",
      shadowOffset: { width: 0, height: elevation / 2 },
      shadowOpacity: 0.1,
      shadowRadius: elevation,
    },
    android: {
      elevation,
    },
  }),
})

// 快速生成圆角
export const rounded = {
  sm: { borderRadius: s(4) },
  md: { borderRadius: s(8) },
  lg: { borderRadius: s(16) },
  full: { borderRadius: 9999 },
}

// 使用示例 const styles = StyleSheet.create({ container: { ...flex.grow, ...flex.col, backgroundColor: '#fff', padding: spacing.md, }, card: { ...rounded.md, ...shadow(4), backgroundColor: '#fff', padding: spacing.md, marginBottom: spacing.sm, }, title: { ...typography.h2, marginBottom: spacing.xs, }, });


### 2.3 更邪的技巧:内联样式生成器

```tsx
// 邪修秘籍第三式:链式样式生成器

class StyleBuilder {
  private style: Record<string, any> = {};

  // 布局
  flex(value: number = 1) { this.style.flex = value; return this; }
  row() { this.style.flexDirection = 'row'; return this; }
  col() { this.style.flexDirection = 'column'; return this; }
  center() {
    this.style.justifyContent = 'center';
    this.style.alignItems = 'center';
    return this;
  }
  between() { this.style.justifyContent = 'space-between'; return this; }

  // 间距
  p(value: number) { this.style.padding = s(value); return this; }
  px(value: number) { this.style.paddingHorizontal = s(value); return this; }
  py(value: number) { this.style.paddingVertical = s(value); return this; }
  m(value: number) { this.style.margin = s(value); return this; }
  mx(value: number) { this.style.marginHorizontal = s(value); return this; }
  my(value: number) { this.style.marginVertical = s(value); return this; }
  mb(value: number) { this.style.marginBottom = s(value); return this; }
  mt(value: number) { this.style.marginTop = s(value); return this; }

  // 尺寸
  w(value: number | string) {
    this.style.width = typeof value === 'number' ? s(value) : value;
    return this;
  }
  h(value: number | string) {
    this.style.height = typeof value === 'number' ? s(value) : value;
    return this;
  }
  size(w: number, h?: number) {
    this.style.width = s(w);
    this.style.height = s(h ?? w);
    return this;
  }

  // 背景和边框
  bg(color: string) { this.style.backgroundColor = color; return this; }
  rounded(value: number = 8) { this.style.borderRadius = s(value); return this; }
  border(width: number = 1, color: string = '#ddd') {
    this.style.borderWidth = width;
    this.style.borderColor = color;
    return this;
  }

  // 文字
  text(size: number, color: string = '#333') {
    this.style.fontSize = s(size);
    this.style.color = color;
    return this;
  }
  bold() { this.style.fontWeight = 'bold'; return this; }

  // 阴影
  shadow(elevation: number = 4) {
    Object.assign(this.style, shadow(elevation));
    return this;
  }

  // 构建
  build() { return this.style; }
}

// 快捷函数
export const $ = () => new StyleBuilder();

// 使用:像写 Tailwind 一样爽
<View style={$().flex().col().bg('#fff').p(16).build()}>
  <View style={$().row().between().mb(12).build()}>
    <Text style={$().text(18).bold().build()}>标题</Text>
    <Text style={$().text(14, '#999').build()}>更多</Text>
  </View>
  <View style={$().bg('#f5f5f5').rounded(8).p(12).shadow(2).build()}>
    <Text style={$().text(16).build()}>内容</Text>
  </View>
</View>

代码审查者:这什么鬼写法? :这叫"声明式样式构建器",Tailwind 同款思路,懂?


第三章:状态管理的野路子

3.1 问题:Redux 写到怀疑人生

// Redux 经典三件套
// actions.ts
export const SET_USER = "SET_USER"
export const setUser = (user) => ({ type: SET_USER, payload: user })

// reducer.ts
const initialState = { user: null }
export default function userReducer(state = initialState, action) {
  switch (action.type) {
    case SET_USER:
      return { ...state, user: action.payload }
    default:
      return state
  }
}

// 使用
dispatch(setUser({ name: "test" }))

// 就为了存个用户信息,写了三个文件...

3.2 邪修技巧:Zustand 一把梭

// 邪修秘籍第四式:Zustand 极简状态管理

import { create } from "zustand"
import { persist, createJSONStorage } from "zustand/middleware"
import AsyncStorage from "@react-native-async-storage/async-storage"

// 用户状态
interface UserState {
  user: User | null
  token: string | null
  setUser: (user: User | null) => void
  setToken: (token: string | null) => void
  logout: () => void
}

export const useUserStore = create<UserState>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      setUser: (user) => set({ user }),
      setToken: (token) => set({ token }),
      logout: () => set({ user: null, token: null }),
    }),
    {
      name: "user-storage",
      storage: createJSONStorage(() => AsyncStorage),
    }
  )
)

// 应用状态
interface AppState {
  theme: "light" | "dark"
  language: string
  isLoading: boolean
  setTheme: (theme: "light" | "dark") => void
  setLanguage: (lang: string) => void
  setLoading: (loading: boolean) => void
}

export const useAppStore = create<AppState>((set) => ({
  theme: "light",
  language: "zh",
  isLoading: false,
  setTheme: (theme) => set({ theme }),
  setLanguage: (language) => set({ language }),
  setLoading: (isLoading) => set({ isLoading }),
}))

// 使用:简单到哭
function ProfileScreen() {
  const { user, logout } = useUserStore()
  const { theme, setTheme } = useAppStore()

  return (
    <View>
      <Text>{user?.name}</Text>
      <Button
        title='切换主题'
        onPress={() => setTheme(theme === "light" ? "dark" : "light")}
      />
      <Button title='退出登录' onPress={logout} />
    </View>
  )
}

3.3 更简单的方案:useContext + useReducer

// 邪修秘籍第五式:原生 Hook 也能很香

import React, { createContext, useContext, useReducer, ReactNode } from "react"

// 定义状态和动作
type State = {
  user: User | null
  token: string | null
  theme: "light" | "dark"
}

type Action =
  | { type: "SET_USER"; payload: User | null }
  | { type: "SET_TOKEN"; payload: string | null }
  | { type: "SET_THEME"; payload: "light" | "dark" }
  | { type: "LOGOUT" }

const initialState: State = {
  user: null,
  token: null,
  theme: "light",
}

function reducer(state: State, action: Action): State {
  switch (action.type) {
    case "SET_USER":
      return { ...state, user: action.payload }
    case "SET_TOKEN":
      return { ...state, token: action.payload }
    case "SET_THEME":
      return { ...state, theme: action.payload }
    case "LOGOUT":
      return { ...state, user: null, token: null }
    default:
      return state
  }
}

// 创建 Context
const AppContext = createContext<{
  state: State
  dispatch: React.Dispatch<Action>
} | null>(null)

// Provider
export function AppProvider({ children }: { children: ReactNode }) {
  const [state, dispatch] = useReducer(reducer, initialState)
  return (
    <AppContext.Provider value={{ state, dispatch }}>
      {children}
    </AppContext.Provider>
  )
}

// 自定义 Hook
export function useApp() {
  const context = useContext(AppContext)
  if (!context) throw new Error("useApp must be used within AppProvider")
  return context
}

// 更方便的 Hook
export function useUser() {
  const { state, dispatch } = useApp()
  return {
    user: state.user,
    setUser: (user: User | null) =>
      dispatch({ type: "SET_USER", payload: user }),
    logout: () => dispatch({ type: "LOGOUT" }),
  }
}

第四章:性能优化的黑魔法

4.1 问题:列表卡成 PPT

// 性能杀手写法
<FlatList
  data={items}
  renderItem={({ item }) => (
    // 每次渲染都创建新函数
    <TouchableOpacity onPress={() => handlePress(item)}>
      <View style={{ padding: 16 }}>
        {" "}
        {/* 内联样式 */}
        <Image source={{ uri: item.image }} style={{ width: 50, height: 50 }} />
        <Text>{item.title}</Text>
      </View>
    </TouchableOpacity>
  )}
/>

4.2 邪修技巧:性能优化三板斧

// 邪修秘籍第六式:FlatList 性能优化

import React, { memo, useCallback, useMemo } from "react"
import {
  FlatList,
  View,
  Text,
  Image,
  TouchableOpacity,
  StyleSheet,
} from "react-native"

// 1. 使用 memo 包裹列表项
const ListItem = memo(
  ({ item, onPress }: { item: Item; onPress: (item: Item) => void }) => {
    return (
      <TouchableOpacity onPress={() => onPress(item)} activeOpacity={0.7}>
        <View style={styles.itemContainer}>
          <Image source={{ uri: item.image }} style={styles.itemImage} />
          <View style={styles.itemContent}>
            <Text style={styles.itemTitle}>{item.title}</Text>
            <Text style={styles.itemSubtitle}>{item.subtitle}</Text>
          </View>
        </View>
      </TouchableOpacity>
    )
  }
)

// 2. 优化后的列表
function OptimizedList({ items }: { items: Item[] }) {
  // 使用 useCallback 缓存函数
  const handlePress = useCallback((item: Item) => {
    console.log("Pressed:", item.id)
  }, [])

  // 使用 useCallback 缓存 renderItem
  const renderItem = useCallback(
    ({ item }: { item: Item }) => (
      <ListItem item={item} onPress={handlePress} />
    ),
    [handlePress]
  )

  // 使用 useCallback 缓存 keyExtractor
  const keyExtractor = useCallback((item: Item) => item.id.toString(), [])

  // 使用 useMemo 缓存 getItemLayout(如果高度固定)
  const getItemLayout = useMemo(
    () => (_: any, index: number) => ({
      length: ITEM_HEIGHT,
      offset: ITEM_HEIGHT * index,
      index,
    }),
    []
  )

  return (
    <FlatList
      data={items}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      getItemLayout={getItemLayout}
      // 性能优化配置
      removeClippedSubviews={true}
      maxToRenderPerBatch={10}
      windowSize={5}
      initialNumToRender={10}
      // 避免不必要的重渲染
      extraData={null}
    />
  )
}

const ITEM_HEIGHT = 80

const styles = StyleSheet.create({
  itemContainer: {
    flexDirection: "row",
    padding: 16,
    height: ITEM_HEIGHT,
    backgroundColor: "#fff",
    borderBottomWidth: StyleSheet.hairlineWidth,
    borderBottomColor: "#eee",
  },
  itemImage: {
    width: 48,
    height: 48,
    borderRadius: 24,
  },
  itemContent: {
    flex: 1,
    marginLeft: 12,
    justifyContent: "center",
  },
  itemTitle: {
    fontSize: 16,
    fontWeight: "600",
    color: "#333",
  },
  itemSubtitle: {
    fontSize: 14,
    color: "#666",
    marginTop: 4,
  },
})

4.3 图片优化

// 邪修秘籍第七式:图片加载优化

import FastImage from "react-native-fast-image"

// 使用 FastImage 替代 Image
;<FastImage
  source={{
    uri: imageUrl,
    priority: FastImage.priority.normal,
    cache: FastImage.cacheControl.immutable,
  }}
  style={styles.image}
  resizeMode={FastImage.resizeMode.cover}
/>

// 预加载图片
FastImage.preload([
  { uri: "https://example.com/image1.jpg" },
  { uri: "https://example.com/image2.jpg" },
])

// 清理缓存
FastImage.clearMemoryCache()
FastImage.clearDiskCache()

第五章:原生模块的求生指南

5.1 问题:需要调用原生功能

当产品经理说"这个功能很简单"的时候,你就知道要写原生代码了。

5.2 邪修技巧:能用库就用库

// 邪修秘籍第八式:能不写原生就不写

// 常用原生功能的库(2026年还在维护的)
const essentialLibraries = {
  // 相机
  camera: "react-native-vision-camera",
  // 图片选择
  imagePicker: "react-native-image-picker",
  // 文件系统
  fs: "react-native-fs",
  // 设备信息
  deviceInfo: "react-native-device-info",
  // 权限
  permissions: "react-native-permissions",
  // 推送通知
  push: "@react-native-firebase/messaging",
  // 本地存储
  storage: "@react-native-async-storage/async-storage",
  // 加密存储
  secureStorage: "react-native-keychain",
  // 网络状态
  netInfo: "@react-native-community/netinfo",
  // 剪贴板
  clipboard: "@react-native-clipboard/clipboard",
  // 分享
  share: "react-native-share",
  // 二维码
  qrcode: "react-native-qrcode-scanner",
  // 地图
  maps: "react-native-maps",
  // 定位
  geolocation: "react-native-geolocation-service",
  // 生物识别
  biometrics: "react-native-biometrics",
  // WebView
  webview: "react-native-webview",
  // 视频播放
  video: "react-native-video",
  // 动画
  animation: "react-native-reanimated",
  // 手势
  gesture: "react-native-gesture-handler",
}

// 安装命令生成器
function generateInstallCommand(libs: string[]) {
  const packages = libs.map((lib) => essentialLibraries[lib]).filter(Boolean)
  console.log(`npm install ${packages.join(" ")}`)
  console.log("\n# iOS 还需要:")
  console.log("cd ios && pod install && cd ..")
}

5.3 实在要写原生代码

// 邪修秘籍第九式:最简原生模块模板

// === iOS (MyModule.m) ===
/*
#import <React/RCTBridgeModule.h>

@interface MyModule : NSObject <RCTBridgeModule>
@end

@implementation MyModule

RCT_EXPORT_MODULE();

RCT_EXPORT_METHOD(doSomething:(NSString *)param
                  resolver:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject)
{
  @try {
    // 你的原生代码
    NSString *result = [NSString stringWithFormat:@"Result: %@", param];
    resolve(result);
  } @catch (NSException *exception) {
    reject(@"error", exception.reason, nil);
  }
}

@end
*/

// === Android (MyModule.kt) ===
/*
package com.yourapp

import com.facebook.react.bridge.*

class MyModule(reactContext: ReactApplicationContext) : 
    ReactContextBaseJavaModule(reactContext) {
    
    override fun getName() = "MyModule"
    
    @ReactMethod
    fun doSomething(param: String, promise: Promise) {
        try {
            val result = "Result: $param"
            promise.resolve(result)
        } catch (e: Exception) {
            promise.reject("error", e.message)
        }
    }
}
*/

// === JS 调用 ===
import { NativeModules } from "react-native"

const { MyModule } = NativeModules

async function callNative() {
  try {
    const result = await MyModule.doSomething("test")
    console.log(result)
  } catch (error) {
    console.error(error)
  }
}

第六章:调试的野路子

6.1 Console 大法

// 邪修秘籍第十式:调试工具集

// 带颜色的 console(在 Chrome DevTools 中有效)
const log = {
  info: (msg: string, ...args: any[]) =>
    console.log(`%c[INFO] ${msg}`, "color: #2196F3", ...args),
  success: (msg: string, ...args: any[]) =>
    console.log(`%c[SUCCESS] ${msg}`, "color: #4CAF50", ...args),
  warn: (msg: string, ...args: any[]) =>
    console.log(`%c[WARN] ${msg}`, "color: #FF9800", ...args),
  error: (msg: string, ...args: any[]) =>
    console.log(`%c[ERROR] ${msg}`, "color: #F44336", ...args),
}

// 性能计时
const perf = {
  start: (label: string) => console.time(label),
  end: (label: string) => console.timeEnd(label),
}

// 打印组件渲染
function useRenderLog(componentName: string) {
  const renderCount = React.useRef(0)
  renderCount.current++
  console.log(`[Render] ${componentName}: ${renderCount.current}`)
}

// 打印 Props 变化
function usePropsLog(props: Record<string, any>, componentName: string) {
  const prevProps = React.useRef(props)

  React.useEffect(() => {
    const changes: string[] = []
    Object.keys(props).forEach((key) => {
      if (prevProps.current[key] !== props[key]) {
        changes.push(key)
      }
    })
    if (changes.length > 0) {
      console.log(`[Props Changed] ${componentName}:`, changes)
    }
    prevProps.current = props
  })
}

6.2 临时 UI 调试

// 给任何组件加边框
const debugStyle = __DEV__ ? { borderWidth: 1, borderColor: "red" } : {}

// 调试组件
function DebugView({
  children,
  label,
}: {
  children: ReactNode
  label?: string
}) {
  if (!__DEV__) return <>{children}</>

  return (
    <View style={{ borderWidth: 1, borderColor: "red" }}>
      {label && (
        <Text
          style={{
            position: "absolute",
            top: -10,
            left: 4,
            backgroundColor: "red",
            color: "white",
            fontSize: 10,
            paddingHorizontal: 4,
          }}
        >
          {label}
        </Text>
      )}
      {children}
    </View>
  )
}

// 使用
;<DebugView label='Header'>
  <Header />
</DebugView>

写在最后:RN 开发的生存法则

  1. 环境问题先清缓存 —— 90% 的问题都能解决
  2. 能用库就用库 —— 不要重复造轮子
  3. 性能优化要趁早 —— 别等卡了再优化
  4. 原生代码能不写就不写 —— 写了就是坑
  5. 保持版本更新 —— 但不要第一时间更新

记住:能跑就是胜利


互动话题

  1. 你遇到过最离谱的 RN bug 是什么?
  2. 你有什么 RN 开发的独门秘籍?
  3. RN vs Flutter,你站哪边?

欢迎在评论区分享你的"邪修"经验!


本文仅供娱乐和学习参考。如因使用本文技巧导致项目爆炸,作者概不负责。

node-sass 迁移 sass(dart-sass) 后样式报错?用 loader 先把构建救回来

作者 庞贝_
2026年1月9日 09:22

Vue CLI 老项目迁移 dart-sass:用一个插件兼容 /deep/>>>calc(100%-16px)

仓库:vue-cli-plugin-sass-compat
GitHub:github.com/myltx/vue-c…

老的 Vue CLI 项目升级 Node/依赖后,经常会被迫从 node-sass(libsass) 迁移到 sass(dart-sass)。真正卡人的往往不是“装上 sass 就完事”,而是项目里存在大量历史写法,导致迁移后构建直接报错或样式编译不符合预期。

这篇笔记记录一个“过渡期方案”:通过 vue-cli-plugin-sass-compatsass-loader 后插入一个轻量 loader,对源码做字符串级兼容替换,让你先把构建跑起来,再逐步治理样式。

你可能遇到的两类典型坑

1) 深度选择器旧写法:/deep/>>>

在一些链路/组合下,旧写法可能触发解析问题(例如 dart-sass 报 Expected selector),或者在升级过程中需要统一成 Vue 推荐的写法。

目标:将这些旧写法尽量自动转换为 ::v-deep

2) calc() 运算符空格:calc(100%-16px)

sass(dart-sass)calc() 表达式更严格,常见历史写法例如:

.a { width: calc(100%-16px); }

可能需要改成:

.a { width: calc(100% - 16px); }

目标:在迁移过渡期,自动补上二元运算符(+/-)两侧空格,避免全仓库手工替换造成巨大 diff。

方案:vue-cli-plugin-sass-compat

这个插件做的事情很克制:

  • 作为 Vue CLI Service 插件,通过 chainWebpacksass-loader 后插入一个 loader
  • 只处理你项目内的 .scss/.sass 文件(默认跳过 node_modules
  • 以“迁移过渡”为目标做最小替换:
    • /deep/>>>::v-deep
    • calc(...) 中的二元 +/- 自动补空格(尽量避开一元运算等场景)

使用前置:先完成依赖迁移(必做)

本插件不负责替你替换依赖。使用前请先把项目从 node-sass 迁移到 sass(dart-sass)

npm rm node-sass
npm i -D sass

然后正常跑一遍安装:

rm -rf node_modules
npm i

安装与使用

方式 A:已发布到 npm(推荐)

npm i -D vue-cli-plugin-sass-compat

可选:迁移检查命令(doctor)

插件在 serve/build 首次执行时会做一次轻量检查:如果检测到仍存在 node-sass 或尚未安装 sass,会打印提示。

也可以手动运行:

vue-cli-service sass-compat:doctor

可选配置(vue.config.js

默认两项修复都开启(true)。需要精细控制时可以这样写:

module.exports = {
  pluginOptions: {
    sassCompat: {
      fixDeep: true,
      fixCalc: true
    }
  }
}
  • fixDeep:是否将 /deep/>>> 等旧写法转换为 ::v-deep
  • fixCalc:是否修复 calc(100%-16px)calc()+/- 运算符空格

转换示例

深度选择器

输入:

.a /deep/ .b {}
.a >>> .b {}

输出(示意):

.a ::v-deep .b {}
.a ::v-deep .b {}

calc() 空格

输入:

.a { width: calc(100%-16px); }

输出:

.a { width: calc(100% - 16px); }

工作原理(简述)

  • index.js:通过 api.chainWebpack,在 sass/scss 规则里找到 sass-loader,并在其后插入 sass-compat-loader
  • sass-compat-loader.js:拿到每个样式文件的源码做字符串替换
    • 跳过 node_modules
    • /deep/ 直接替换为 ::v-deep
    • >>> 替换为 ::v-deep,并尽量补齐必要空格
    • calc(...) 做一次括号配对扫描,只在 calc() 内尝试给二元 +/- 补空格

边界与建议

  • 这是“迁移过渡”工具:建议你在构建恢复稳定后,逐步把业务代码里真正的历史写法治理掉,最终可以移除该插件
  • calc() 修复目前只处理二元 +/-,不会尝试覆盖 */ 等更复杂场景
  • 如果你项目里对 ::v-deep 的使用有更严格的团队规范,建议在过渡期结束后统一做一次规范化替换

最后

如果你也在做 Vue CLI 老项目的 node-sass -> sass(dart-sass) 迁移,欢迎直接试用这个插件;也欢迎提 issue 描述你遇到的“历史写法”,我会优先考虑把高频场景纳入兼容范围。

🔥🔥🔥 React18 源码学习 - Fiber 架构

作者 yyyao
2026年1月9日 08:37

前言

本文的React代码版本为18.2.0

可调试的代码仓库为:GitHub - yyyao-hh/react-debug at master-pure

React16开始,引入了Fiber架构。它彻底重构了React的调和机制,将不可中断的递归遍历,革新为可暂停、可恢复的链表遍历。这一根本性变革为React带来了时间切片、并发模式等能力,使其从“同步渲染”迈入了“异步渲染”的新纪元。本文将揭示这场革命的内核原理。

为什么使用 Fiber 架构?

React 核心思想

作为一个构建用户界面的JavaScript库,React的核心思想可以概括为:

内存中维护一颗虚拟DOM树,数据变化时自动更新虚拟DOM,然后通过Diff算法对比新旧虚拟DOM树,找出变化的部分并批量更新到真实DOM 。在React16之前,这个过程被分为两个阶段:调和阶段(Reconciler)和渲染阶段(Renderer )。

旧架构的不足

React16之前,在调和阶段(Reconciler)使用递归方式同步遍历虚拟DOM树,对比每个组件以确定需要更新的部分。这个过程React团队称为Stack Reconciler,因为它依赖于JavaScript内置的调用栈。这种递归模型虽然直观,但有一个致命缺陷:一旦开始就无法中断。如果组件树层级很深,递归调用会长时间占用JavaScript主线程,导致用户交互、动画等任务无法及时响应,造成页面卡顿。

浏览器渲染机制

要理解为什么长时间占用JavaScript线程会导致卡顿,我们需要了解浏览器的渲染机制。页面是一帧一帧绘制出来的,一般浏览器的刷新频率为60hz,即每秒绘制的60帧(FPS),这意味着每一帧只有约16.7ms的时间来执行所有任务。

在一帧之内,浏览器需要完成多个步骤:处理用户交互、JavaScript解析执行、requestAnimationFrame调用、布局、绘制等。如果JavaScript执行时间过长,超过了16ms,就会延迟后续的布局和绘制,导致帧率下降,用户自然会感觉到卡顿。

Fiber 的解决方案

为了解决Stack Reconciler的瓶颈,React团队从16 年开始重构协调算法,最终在React16中引入了Fiber Reconciler架构。

Fiber架构是用于实现虚拟DOM和组件协调的新的架构。核心理念是将渲染任务拆分为多个小的工作单元,而不是一次性同步处理整个组件树。旨在优化渲染过程、实现异步渲染,并提高应用的性能和用户体验。

Fiber架构通过实现以下目标来解决性能问题:

  • 暂停工作,稍后再回来:可以中断渲染过程处理高优先级任务
  • 为不同类型的工作分配优先级:确保用户交互和动画优先执行
  • 复用以前完成的工作:提高渲染效率
  • 如果不再需要,则中止工作:避免不必要的渲染

这种增量渲染的方式使得React能够在时间分片中处理更新,充分利用浏览器的空闲期执行任务,从而避免阻塞关键的用户交互。

Fiber新老架构.png

React官方以漫画的方式形象的展示了两种架构运行区别。来源:Fiber Reconciler

Fiber 架构深度剖析

什么是 Fiber

Fiber可以从两个互补的角度来理解:执行单元和数据结构

  • 作为一个执行单元,Fiber代表一个可以拆分的工作块。React将整个视图的更新过程分解为多个小的Fiber任务,每个任务通常只处理一个组件。与一次性完成整个更新不同,Fiber使React能够将工作分成小块,在浏览器空闲时执行它们。
  • 作为一种数据结构,Fiber是一个JavaScript对象,它由对应的React Element生成,包含了组件的详细信息及其输入和输出。从代码层面看,每个Fiber节点对应一个React元素,存储了组件的类型、状态、副作用等信息。

下面是Fiber节点的核心属性结构:

/* src/react/packages/react-reconciler/src/ReactFiber.old.js */

export type Fiber = {|
  
  /* ************ 实例的静态属性 ************ */
  
  tag: WorkTag,       // 节点类型
  key: null | string, // 用于协调算法的唯一标识
  elementType: any,   // 原始元素类型 (函数/类本身)
  type: any,          // 解析后的元素类型 (可能被babel处理过)
  stateNode: any,     // 关联的真实实例 (DOM节点/类组件实例)

  /* ************ Fiber 链表结构 ************ */
  
  return: Fiber | null,  // 指向父节点
  child: Fiber | null,   // 指向第一个子节点
  sibling: Fiber | null, // 指向兄弟节点
  index: number,         // 在子节点中的索引
  
  ref:
    | null
    | (((handle: mixed) => void) & {_stringRef: ?string, ...})
    | RefObject,

  /* ************ 属性 & 状态 ************ */
  
  pendingProps: any,
  memoizedProps: any,
  updateQueue: mixed,
  memoizedState: any,
  dependencies: Dependencies | null,

  mode: TypeOfMode,

  /* ************ 副作用与更新标记 ************ */
      
  flags: Flags,
  subtreeFlags: Flags,
  deletions: Array<Fiber> | null,

  nextEffect: Fiber | null,

  firstEffect: Fiber | null,
  lastEffect: Fiber | null,

  /* ************ 调度优先级 ************ */
  lanes: Lanes,
  childLanes: Lanes,

  /* ************ 双缓冲机制 ************ */
  alternate: Fiber | null,
|};

协调机制

Fiber架构最核心的改变之一是用链表遍历替代了递归遍历。传统的递归遍历依赖于JavaScript的调用栈,而Fiber实现了自己的堆栈帧管理,通过childsiblingreturn三个指针构建了一个树形链表结构。

return: Fiber | null,   // 指向父节点
child: Fiber | null,    // 指向第一个子节点
sibling: Fiber | null,  // 指向兄弟节点
index: number,          // 在兄弟节点中的位。

如果当前的App组件如下:

function App() {
  return (
    <div>
      Study
      <span>React</span>
    </div>
  );
}

则对应的Fiber结构如下图:每个节点只保存它的第一个子节点,其他子节点通过sibling指针单向存储,所有子节点都会通过return指针指向父节点。

App的Fiber树.png

这种链表结构使得React可以手动控制遍历过程,而不依赖于JavaScript的调用栈。遍历算法基于深度优先原则,但不再是递归实现。具体遍历相关的内容,将在下一节详细展开。

双缓存技术

Fiber架构采用双缓存技术,在内存中维护着两棵Fiber树:current treeworkInProgress tree

  • current tree:当前屏幕上显示内容对应的Fiber
  • workInProgress tree:正在内存中构建的新的Fiber

而应用的根节点(FiberRootNode)会通过current指针完成current tree的切换。

当开始一次更新时,React会从current树的根节点开始,为每个节点创建一个alternate(替代)节点,这些alternate节点就构成了workInProgress树。如果节点没有更新,React会复用previous fiber(前一个fiber)来最小化工作量。

初始化阶段 mount

  1. 容器挂载阶段

上节讲过,首先会通过ReactDOM.createRoot创建FiberRootNodeRootFiberNode

  • FiberRootNode:整个应用的根容器
  • RootFiberNodeFiber树的根节点(根组件<App/>的父节点)

由于是首屏渲染,页面中没有任何DOM,所以RootFiber没有任何子节点

初始化根容器.png

  1. Render阶段

会根据组件的React Element在内存中构建wip tree。构建时会尝试复用current tree中的Fiber节点(具体的复用过程,会在diff算法章节详细展开)。

首次构建fiber树.png

初始化渲染时,wip tree中只有RootFiberNode存在对应的alternate节点。

  1. Commit阶段

此时页面DOM更新为右侧tree对应的数据。current指针指向wip tree,使其变为current tree

首次构建切换fiber树.png

更新阶段 update

  1. 触发更新:Render阶段

构建一棵新的wip tree

更新时候的fiber树.png

  1. 触发更新:Commit阶段

此时页面DOM更新为左侧tree对应的数据。current指针指向wip tree,使其变为current tree

更新时候切换fiber树.png

这种双缓存机制有两个主要优点:

  1. 减少内存分配:通过节点复用,减少不必要的垃圾回收
  2. 无缝切换:当workInProgress tree构建完成时,直接将它作为新的current tree,避免页面闪烁

后面再提到workInProgress多简称为wip

渲染流程

Fiber架构将渲染过程分为两个截然不同的阶段:Render阶段和Commit阶段。

Render阶段是可中断的异步过程,负责处理组件的渲染和Diff计算。这个阶段React执行以下操作:

  • 更新状态和属性
  • 调用生命周期方法(如getDerivedStateFromProps
  • 执行渲染函数获取子元素
  • 对比新旧子元素(Diff算法)
  • 标记需要更新的副作用(effect

由于Render阶段可能被高优先级任务中断,React使用副作用列表(effect list)来跟踪哪些节点需要更新。这是一个线性链表,包含了所有有副作用的Fiber节点,大大提高了commit阶段的效率。

Commit阶段是同步不可中断的,负责将Render阶段计算的结果应用到DOM上。这个阶段主要包括:

  • 执行DOM的增删改操作
  • 更新refs
  • 调用生命周期方法(如componentDidMountcomponentDidUpdate
  • 执行useEffectuseLayoutEffect的清理和触发函数

将这两个阶段分离确保了用户界面的稳定性:Render阶段可以多次中断和重试,但Commit阶段总是快速且同步的,确保DOM更新的一致性和原子性。

此处略做总结,实现原理都将在后续的文章中详细展开

总结

Fiber架构是React的一次重大革新,它通过重新实现协调算法,解决了大型应用场景下的性能瓶颈和用户体验问题。Fiber的核心价值可以总结为:

  1. 可中断的异步渲染:将渲染工作拆分为小任务,避免长时间阻塞主线程
  2. 基于优先级的调度:确保高优先级任务(如用户交互)快速响应
  3. 更流畅的用户体验:通过时间分片技术减少页面卡顿

下一章我们将了解Fiber树的构建过程:Render阶段

Markdown 宽表格突破容器边界滚动方案

2026年1月9日 01:38

在聊天/文档类应用中,实现宽表格突破内容区域限制,利用更多屏幕空间进行水平滚动的技术方案。

背景与问题

在开发类似 ChatGPT、DeepSeek 等 AI 对话应用时,Markdown 渲染是核心功能之一。当用户或 AI 生成包含多列的宽表格时,会遇到一个常见问题:

内容区域通常有最大宽度限制(如 800px),以保证文字阅读体验。但宽表格在这个限制内显示时,要么被截断,要么需要在很小的区域内滚动,用户体验很差。

理想效果

观察 DeepSeek 等产品的实现,可以发现一个优雅的解决方案:

  1. 普通内容:保持在限宽区域内(如 800px)
  2. 窄表格:和普通内容一样左对齐,不做特殊处理
  3. 宽表格:突破内容区域限制,可以利用整个视口宽度进行滚动

image.png

技术挑战

挑战 1:overflow 冲突

最直观的想法是让表格容器突破父级宽度。但如果父级有垂直滚动(overflow-y: auto),根据 CSS 规范,overflow-x: visible 会被强制转为 auto,导致无法突破。

/* 这样不行! */
.chat-messages {
  overflow-y: auto;    /* 垂直滚动 */
  overflow-x: visible; /* 会被强制转为 auto */
}

挑战 2:负 margin 与居中布局

常见的居中方式是 margin: 0 auto,但这种方式下,子元素使用负 margin 无法有效突破。

挑战 3:表格初始位置对齐

如果表格容器扩展到整个视口宽度,表格会从视口最左边开始显示,而不是和内容区域对齐。

解决方案

核心思路

  1. 用 padding 代替 margin 实现居中:这样子元素可以用负 margin 突破 padding
  2. 条件性突破:只有宽表格才突破,窄表格正常显示
  3. 初始滚动位置:设置 scrollLeft 让表格初始位置对齐内容区域

布局结构设计

┌─────────────────────────────────────────────────────────────┐
│ .chat-page (100vw, overflow-x: hidden)                      │
│  ┌───────────────────────────────────────────────────────┐  │
│  │ .chat-scroll-area (overflow-y: auto)                  │  │
│  │  ┌─────────────────────────────────────────────────┐  │  │
│  │  │ .chat-content (padding 居中,而非 margin)        │  │  │
│  │  │                                                 │  │  │
│  │  │   .message                                      │  │  │
│  │  │     └─ .table-breakout-wrapper (负 margin 突破)  │  │  │
│  │  │                                                 │  │  │
│  │  └─────────────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────┘

实现代码

1. 容器布局(ChatBox.vue)

<template>
  <div class="chat-page">
    <div class="chat-scroll-area">
      <div class="chat-content">
        <ChatMessage v-for="msg in messages" :key="msg.id" :message="msg" />
      </div>
    </div>
  </div>
</template>

<style scoped>
/* 页面容器 - 防止水平滚动条 */
.chat-page {
  display: flex;
  flex-direction: column;
  height: 100vh;
  width: 100vw;
  overflow-x: hidden;
}

/* 滚动区域 - 处理垂直滚动 */
.chat-scroll-area {
  flex: 1;
  width: 100vw;
  overflow-y: auto;
  overflow-x: hidden;
}

/* 关键:用 padding 居中,而不是 margin */
.chat-content {
  --content-max-width: 800px;
  --content-padding: max(16px, calc((100vw - var(--content-max-width)) / 2));
  width: 100%;
  padding-left: var(--content-padding);
  padding-right: var(--content-padding);
  box-sizing: border-box;
}
</style>

要点

  • .chat-content 使用 padding 而不是 margin: 0 auto 居中
  • 使用 CSS max() 函数确保小屏幕下有最小 padding
  • 父级 overflow-x: hidden 防止出现水平滚动条

2. 表格渲染(marked 自定义 renderer)

import { marked } from 'marked'

const renderer = new marked.Renderer()

renderer.table = function(table) {
  // 构建表格 HTML...
  const tableHtml = `<table>...</table>`

  // 包裹容器结构
  return `
    <div class="table-breakout-wrapper">
      <div class="table-scroll-box">
        <div class="table-scroll-content">${tableHtml}</div>
        <div class="table-scroll-gutter">
          <div class="table-scroll-bar"></div>
        </div>
      </div>
    </div>
  `
}

marked.use({ renderer })

3. 突破边界逻辑(核心 JS)

// 计算突破边界的偏移量
const calculateBreakoutOffsets = () => {
  const messageRect = messageRef.value.getBoundingClientRect()
  const viewportWidth = window.innerWidth
  const pagePadding = 16 // 保留边距

  return {
    // 消息区域左边到视口左边的距离
    leftOffset: Math.max(0, messageRect.left - pagePadding),
    // 视口右边到消息区域右边的距离
    rightOffset: Math.max(0, viewportWidth - messageRect.right - pagePadding)
  }
}

// 应用突破样式
const applyBreakoutStyles = (wrapper, content) => {
  const { leftOffset, rightOffset } = calculateBreakoutOffsets()

  // 获取表格实际宽度
  const table = content.querySelector('table')
  const tableWidth = table.scrollWidth
  const containerWidth = messageRef.value.getBoundingClientRect().width

  // 关键判断:表格没超出容器,不需要突破
  if (tableWidth <= containerWidth) {
    wrapper.style.marginLeft = ''
    wrapper.style.marginRight = ''
    content.scrollLeft = 0
    return
  }

  // 表格超出容器,应用突破样式
  wrapper.style.marginLeft = `-${leftOffset}px`
  wrapper.style.marginRight = `-${rightOffset}px`

  // 设置初始滚动位置,让表格左边对齐内容区域
  if (!wrapper.dataset.scrollInitialized) {
    wrapper.dataset.scrollInitialized = 'true'
    content.scrollLeft = leftOffset
  }
}

核心逻辑

  1. 条件判断tableWidth <= containerWidth 时不做任何处理
  2. 负 margin 突破marginLeft = -leftOffset 抵消父级的 padding-left
  3. 初始滚动位置scrollLeft = leftOffset 让表格视觉上对齐内容区域

4. 样式定义

/* 突破容器 */
.table-breakout-wrapper {
  position: relative;
  margin-top: 16px;
  margin-bottom: 16px;
  box-sizing: border-box;
}

/* 滚动内容区域 */
.table-scroll-content {
  overflow-x: auto;
  overflow-y: hidden;
  /* 隐藏原生滚动条 */
  scrollbar-width: none;
  -ms-overflow-style: none;
}

.table-scroll-content::-webkit-scrollbar {
  display: none;
}

/* 表格样式 */
table {
  border-collapse: collapse;
  width: max-content; /* 关键:宽度由内容决定 */
  font-size: 14px;
}

th, td {
  padding: 12px 16px;
  white-space: nowrap;
  border-bottom: 1px solid #e8e8e8;
}

要点

  • width: max-content 让表格宽度由内容决定,不会被压缩
  • white-space: nowrap 防止单元格内容换行

image.png

5. 自定义滚动条(可选)

const initScrollBar = (content, gutter, bar) => {
  const updateBar = () => {
    const scrollWidth = content.scrollWidth
    const clientWidth = content.clientWidth
    const maxScroll = scrollWidth - clientWidth

    if (scrollWidth <= clientWidth) {
      gutter.style.display = 'none'
      return
    }

    gutter.style.display = 'block'

    // 滚动条宽度
    const ratio = clientWidth / scrollWidth
    const barWidth = Math.max(clientWidth * ratio, 40)
    bar.style.width = barWidth + 'px'

    // 滚动条位置
    const maxBarLeft = clientWidth - barWidth
    const scrollRatio = maxScroll > 0 ? content.scrollLeft / maxScroll : 0
    bar.style.left = (scrollRatio * maxBarLeft) + 'px'
  }

  content.addEventListener('scroll', updateBar)
  window.addEventListener('resize', updateBar)
  updateBar()
}

原理图解

负 margin 突破原理

正常状态(margin 居中):
┌──────────────────────────────────────────┐
│          ┌────────────────┐              │
│  margincontent 800pxmargin      │
│          └────────────────┘              │
│          子元素无法突破 margin            │
└──────────────────────────────────────────┘

padding 居中 + 负 margin:
┌──────────────────────────────────────────┐
│ padding  ┌────────────────┐  padding     │
│ ←──────  │  content 800px │  ──────→     │
│          └────────────────┘              │
│                                          │
│ ┌────────────────────────────────────┐   │
│ │  子元素 margin-left: -padding       │   │
│ │  成功突破到视口边缘                   │   │
│ └────────────────────────────────────┘   │
└──────────────────────────────────────────┘

初始滚动位置对齐

容器突破后,表格从最左边开始:
│ leftOffset │    content    │ rightOffset │
│←──────────→│               │←───────────→│
┌────────────┬───────────────┬─────────────┐
│[表格从这开始...]                          │
└──────────────────────────────────────────┘
            ↑ 但我们希望表格从这里开始

设置 scrollLeft = leftOffset 后:
┌────────────┬───────────────┬─────────────┐
│  滚动隐藏   │[表格对齐这里]  │  可继续滚动  │
└────────────┴───────────────┴─────────────┘
             ↑ 视觉上对齐内容区域

关键技术点总结

技术点 说明
padding 居中 使用 padding 而非 margin: 0 auto,让子元素可以突破
负 margin 子元素 margin-left: -padding 突破到视口边缘
条件判断 只有 tableWidth > containerWidth 时才突破
scrollLeft 对齐 设置初始滚动位置让表格视觉上对齐内容区域
overflow-x: hidden 最外层容器防止出现水平滚动条
width: max-content 表格宽度由内容决定,不被压缩

兼容性

  • 现代浏览器完全支持
  • CSS max() 函数需要 Chrome 79+、Firefox 75+、Safari 11.1+
  • 可使用 calc() 配合媒体查询作为降级方案

应用场景

  • AI 对话应用(ChatGPT、Claude、DeepSeek 等)
  • 在线文档工具(Notion、语雀、飞书文档)
  • Markdown 编辑器/预览器
  • 任何需要展示宽表格的内容型应用

参考

  • CSS Overflow Module Level 3
  • CSS Box Model Module Level 3
  • marked.js 自定义渲染器文档

本方案在 Vue 3 + Vite + marked.js 环境下实现和测试。

section与article的区别与使用场景

2026年1月8日 23:55

H5中的<section><article>都是用于文档结构化的语义化标签,但它们有不同的用途。下面我将详细解释它们的区别、使用场景以及如何避免混淆。

如何区分?

关键问题:这段内容在脱离上下文后是否还有意义?

  • 如果一段内容可以被单独拆分并在其他网站或平台上使用,那么它很可能是一个<article>
  • 如果一段内容只是文档中的一个部分,它和文档的其他部分共同组成一个整体,那么它可能是一个<section>

另外,<article>内部可以有多个<section>,而<section>内部也可以有多个<article>。这取决于你的内容结构。

代码示例

示例1:博客文章页面

<article>
  <header>
    <h1>博客文章标题</h1>
    <p>发布日期:<time datetime="2023-10-01">2023年10月1日</time></p>
  </header>
  <section>
    <h2>引言</h2>
    <p>这是文章的引言部分。</p>
  </section>
  <section>
    <h2>第一章</h2>
    <p>第一章的内容。</p>
  </section>
  <section>
    <h2>第二章</h2>
    <p>第二章的内容。</p>
  </section>
  <footer>
    <p>文章标签:HTML,语义化</p>
  </footer>
</article>

示例2:新闻网站首页

<main>
  <section>
    <h2>国内新闻</h2>
    <article>
      <h3>新闻标题1</h3>
      <p>新闻摘要1</p>
    </article>
    <article>
      <h3>新闻标题2</h3>
      <p>新闻摘要2</p>
    </article>
  </section>
  <section>
    <h2>国际新闻</h2>
    <article>
      <h3>新闻标题3</h3>
      <p>新闻摘要3</p>
    </article>
  </section>
</main>

容易混淆的情况

一个独立的、可重用的内容块,但不是一个完整的文章?

  • 例如:一个用户评论。虽然它可能不长,但它是一个独立的内容单元,应该用<article>
  • 注意:<article>不一定很长,只要它是独立的。

注意

  • 对于<sectio>,规范建议包含一个标题。但如果你不想显示标题,也可以使用隐藏标题(例如用CSS隐藏)。
  • 对于<article>,虽然标题不是强制的,但最好包含一个标题(可以是隐式的,通过aria-label等属性描述)。

总结

<article>是一个独立的整体,内部包含这个整体中的各个部分,如:<header><footer><section>就是这个整体中的一个部分。

告别全局污染:深入解析现代前端的模块化 CSS 演进之路

作者 San30
2026年1月8日 23:44

在前端开发的蛮荒时代,CSS(层叠样式表)就像一匹脱缰的野马。它的“层叠”特性既是强大的武器,也是无数 Bug 的根源。每个前端工程师可能都经历过这样的噩梦:当你为了修复一个按钮的样式而修改了 .btn 类,结果却发现隔壁页面的导航栏莫名其妙地崩了。

这就是全局命名空间污染

随着现代前端工程化的发展,React 和 Vue 等框架的兴起,组件化成为了主流。既然 HTML 和 JavaScript 都可以封装在组件里,为什么 CSS 还要流落在外,互相打架呢?今天,我们就结合实际代码,深入探讨前端界是如何通过模块化 CSS 来彻底解决“样式冲突”这一世纪难题的。

一、 从 Bug 说起:为什么我们需要模块化?

在传统的开发模式中,CSS 是没有“作用域”(Scope)概念的。所有的类名都暴露在全局环境下。

1.1 命名冲突的灾难

想象一下,在一个大型多人协作的项目中。

  • 开发 A 负责写一个通用的提交按钮,他给按钮起名叫 .button,设置了蓝底白字。
  • 开发 B 负责写一个侧边栏的开关按钮,他也随手起名叫 .button,设置了红底黑字。

当这两个组件被引入到同一个页面(App)时,CSS 的“层叠”规则(Cascading)就会生效。谁的样式在最后加载,或者谁的优先级(Specificity)更高,谁就会赢。结果就是:要么 A 的按钮变红了,要么 B 的按钮变蓝了。

1.2 传统的妥协:BEM 命名法

为了解决这个问题,以前我们发明了 BEM(Block Element Modifier)命名法,比如写成 .article__button--primary。这种方法虽然有效,但它本质上是靠开发者的自觉冗长的命名来模拟作用域。这并不是真正的技术约束,而是一种君子协定。

我们需要更硬核的手段:让工具帮我们生成独一无二的名字

二、 React 中的解决方案:CSS Modules

React 社区对于这个问题的标准答案之一是 CSS Modules。它的核心思想非常简单粗暴:既然人取名字会重复,那就让机器来取名字。

2.1 什么是 CSS Modules?

在你的项目中,你可能看到过后缀为 .module.css 的文件。这不仅仅是一个命名约定,更是构建工具(如 Webpack 或 Vite)识别 CSS Module 的标志。

让我们看一个实际的例子。假设我们需要两个不同的按钮组件:ButtonAnotherButton

Button.module.css:

.button {
    background-color: lightblue;
    color: black;
    padding: 10px 20px;
}

.txt {
    color: red;
}

AnotherButton.module.css:

.button {
    background-color: #008c8c;
    color: white;
    padding: 10px 20px;
}

请注意,这两个文件中都定义了 .button 类。在传统 CSS 中,这绝对会冲突。但在 CSS Modules 中,这两个 .button 是完全隔离的。

2.2 编译原理:哈希(Hash)魔法

当我们在 React 组件中引入这些文件时,并没有直接引入 CSS 字符串,而是引入了一个对象

Button.jsx:

// module.css 是 css module 的文件
// react 将 css 文件 编译成 js 对象
import styles from './Button.module.css';

console.log(styles); // 让我们看看这里打印了什么

export default function Button() {
    return (
        <>
            <h1 className={styles.txt}>你好,世界!!!</h1>
            <button className={styles.button}>My Button</button>
        </>
    )
}

如果你在浏览器控制台查看 console.log(styles),你会发现输出的是类似这样的对象:

{
  button: "Button_button__3a8f",
  txt: "Button_txt__5g9d"
}

核心机制:

  1. 编译转换:构建工具读取 CSS 文件,将类名作为 Key。
  2. 哈希生成:工具会根据文件名、类名和文件内容,生成一个唯一的 Hash 字符串(例如 3a8f),将其拼接成新的类名作为 Value。
  3. 替换引用:在 JSX 中,我们使用 {styles.button},实际上渲染到 HTML 上的是 <button class="Button_button__3a8f">

2.3 真正的样式隔离

现在我们再看看 AnotherButton.jsx

import styles from './antherButton.module.css';

export default function AnotherButton() {
    // 这里的 styles.button 对应的是完全不同的哈希值
    return <button className={styles.button}>Another Button</button>
}

App.jsx 中同时引入这两个组件:

import Button from './components/Button.jsx';
import AnotherButton from './components/AnotherButton.jsx';

export default function App() {
  return (
    <>
      {/* 这里的样式互不干扰,因为它们的最终类名完全不同 */}
      <Button />
      <AnotherButton />
    </>
  )
}

总结 CSS Modules 的优势:

  • 安全性:彻底杜绝了全局污染,每个组件的样式都是私有的。
  • 零冲突:多人协作时,你完全不需要担心你的类名和同事的重复。
  • 自动化:不需要人工维护复杂的 BEM 命名,构建工具自动处理。

三、 Vue 中的解决方案:Scoped CSS

Vue 采用了另一种更符合直觉的策略。Vue 的设计哲学是“单文件组件”(SFC),即 HTML、JS、CSS 全部写在一个 .vue 文件中。为了实现样式隔离,Vue 提供了 scoped 属性。

3.1 scoped 的工作原理

看看这个 HelloWorld.vue 组件:

<template>
  <h1 class="txt">你好,世界!!!</h1>
  <h2 class="txt2">一点点</h2>
</template>

<style scoped>
.txt {
  color: pink;
}
.txt2 {
  color: palevioletred;
}
</style>

当你给 <style> 标签加上 scoped 属性时,Vue 的编译器(通常是 vue-loader@vitejs/plugin-vue)会做两件事:

  1. HTML 标记:给模板中的每个 DOM 元素添加一个独一无二的自定义属性,通常以 data-v- 开头,例如 data-v-7ba5bd90
  2. CSS 重写:利用 CSS 的属性选择器,将样式规则重写。

编译后的 CSS 变成了这样:

.txt[data-v-7ba5bd90] {
  color: pink;
}
.txt2[data-v-7ba5bd90] {
  color: palevioletred;
}

编译后的 HTML 变成了这样:

<h1 class="txt" data-v-7ba5bd90>你好,世界!!!</h1>

3.2 样式穿透与父子组件

Vue 的 Scoped 样式有一个有趣的特性。看 App.vue 的例子:

<template>
<div>
  <h1 class="txt">Hello world in App</h1>
  <HelloWorld />
</div>
</template>

<style scoped>
.txt {
  color: #008c8c;
}
</style>

这里 App.vue 也有一个 .txt 类。但是,由于 App.vue 会生成一个不同的 data-v-hash ID,它的 CSS 选择器会变成 .txt[data-v-app-hash],而 HelloWorld 组件内部的 .txt 只有 .txt[data-v-helloworld-hash] 才能匹配。

这意味着:父组件的样式默认不会泄露给子组件,子组件的样式也不会影响父组件。

Vue Scoped 的优势:

  • 可读性好:类名在开发工具中依然保持原样(.txt),只是多了一个属性,调试起来比 CSS Modules 的乱码类名更友好。
  • 性能:只生成一次 Hash ID,利用浏览器原生的属性选择器,性能开销极低。
  • 开发体验:无需像 React 那样 import styles,直接写类名即可,符合传统 HTML/CSS 开发习惯。

四、 进阶玩法:CSS-in-JS (Styled-Components)

如果我们再激进一点呢?既然 JavaScript 统治了世界,为什么不把 CSS 也变成 JavaScript 的一部分?这就诞生了 CSS-in-JS,其中最著名的库就是 styled-components

这种方案在 React 社区非常流行,它将“组件”和“样式”彻底融合了。

4.1 万物皆组件

在提供的 APP.jsx (Styled-components 版本) 示例中,我们不再写 .css 文件,而是直接定义带样式的组件:

import styled from 'styled-components';

// 创建一个名为 Button 的样式组件
// 这是一个包含了样式的 React 组件
const Button = styled.button`
  background: ${props => props.primary ? 'blue' : 'white'};
  color: ${props => props.primary ? 'white' : 'blue'};
  border: 1px solid blue;
  padding: 8px 16px;
  border-radius: 4px;
`;

注意到了吗?这里的 CSS 是写在反引号(` `)里的,这在 ES6 中叫做标签模板字符串(Tagged Template Literals)

4.2 动态样式的威力

CSS Modules 和 Vue Scoped 虽然解决了作用域问题,但它们本质上还是静态的 CSS 文件。如果你想根据组件的状态(比如 primarydisabledactive)来改变样式,通常需要动态拼接类名。

但在 styled-components 中,CSS 变成了逻辑

background: ${props => props.primary ? 'blue' : 'white'};

这行代码意味着:如果在使用组件时传递了 primary 属性,背景就是蓝色,否则是白色。

function App() {
  return (
    <>
      <Button>默认按钮</Button>
      <Button primary>主要按钮</Button>
    </>
  )
}

当 React 渲染这两个按钮时,styled-components 会动态生成两个不同的 CSS 类名,并将对应的样式注入到页面的 <style> 标签中。

CSS-in-JS 的优势:

  • 动态性:样式可以像 JS 变量一样灵活,直接访问组件的 Props。
  • 删除无用代码:既然样式是绑定在组件上的,如果组件没被使用,样式也不会被打包。
  • 维护性:你永远不用去寻找“这个类名定义在哪里”,因为它就在组件的代码里。

五、 总结:如何选择?

在现代前端开发中,我们有多种武器来对抗样式冲突:

  1. CSS Modules (React 推荐)

    • 适用场景:大型 React 项目,团队习惯传统的 CSS/SCSS 编写方式,追求极致的性能(编译时处理)。
    • 特点:通过 Hash 类名实现隔离,输出 JS 对象。
    • 关键词.module.css, import styles, 安全, 零冲突。
  2. Vue Scoped Styles (Vue 默认)

    • 适用场景:绝大多数 Vue 项目。
    • 特点:通过 data-v- 属性选择器实现隔离,代码更简洁,可读性更高。
    • 关键词<style scoped>, 属性选择器, 简单易用。
  3. CSS-in-JS (Styled-components / Emotion)

    • 适用场景:需要高度动态主题、复杂的交互样式,或者团队偏好“All in JS”的 React 项目。
    • 特点:样式即逻辑,运行时生成 CSS。
    • 关键词styled.div, 动态 Props, 逻辑复用。

回到开头的问题:

不管是 CSS Modules 的哈希乱码,还是 Vue 的属性标记,或者是 Styled-components 的动态注入,它们的终极目标都是一样的——让样式为组件服务,而不是让组件去迁就样式。

在你的下一个项目中,请务必抛弃全局 CSS,拥抱模块化。这不仅是为了避免 Bug,更是为了写出更优雅、更健壮、更易于维护的代码。

希望这篇文章能帮你彻底理解前端样式的模块化演进! Happy Coding!

# Vue 事件系统核心:createInvoker 函数深度解析

作者 如果你好
2026年1月8日 23:38

Vue 事件系统核心:createInvoker 函数深度解析

🔥 用过 Vue 的都知道,写 @click、@input 这种事件绑定很简单,但你有没有想过:背后 Vue 是怎么处理这些事件的?尤其是当事件回调需要动态变化时,它是怎么做到不频繁绑定/解绑 DOM 事件,还能保证性能的?

答案就藏在 createInvoker 这个函数里。它是 Vue(特别是 Vue3)事件系统里的“事件调用器工厂”,核心作用就是创建一个能灵活更新逻辑的调用器。本文从代码结构开始,一步步把它扒明白。

一、先看核心代码:极简但藏玄机

先上 createInvoker 的核心实现(简化版,保留最关键的逻辑),我们逐行看它到底在做什么:

function createInvoker(value) { 
  // 1. 定义一个调用器函数,用箭头函数写的
  const invoker = (e) => { 
    invoker.value(e)  // 调用器内部,会去执行自己身上的 value 属性
  } 

  // 2. 给这个调用器函数挂个 value 属性,指向传入的事件回调
  invoker.value = value 

  // 3. 把调用器返回出去(函数末尾没写 return ,默认返回这个 invoker)
}

这段代码看着特别简单,但其实就做了三件核心事,理解了这三件事,就懂了一半:

  • 造一个“中间层”:invoker 是个箭头函数,后续 DOM 事件实际绑的就是它;
  • 存真实逻辑:把我们写的事件回调(比如 onClick 里的 handleClick),挂在 invoker 的 value 属性上;
  • 返回中间层:把这个 invoker 返回出去,用于后续的 DOM 事件绑定。

二、三个关键设计:为啥这函数这么好用?

createInvoker 之所以能成为 Vue 事件系统的核心,全靠三个特别巧妙的设计。这些设计不是凭空来的,都是为了解决实际开发中的问题。

1. 函数居然也是对象?这是基础

首先要明确一个 JavaScript 里的核心知识点:函数本质上也是对象。正因为函数是对象,我们才能给它“挂属性”——就像上面代码里,给 invoker 挂了个 value 属性。

所以在 createInvoker 里,invoker 其实有两个身份:

  • 作为“函数”:它是 DOM 事件的回调入口,点击、输入这些事件触发时,第一个被执行的就是它;
  • 作为“对象”:它身上能存东西,这里的 value 就是用来存我们真正要执行的业务回调(比如 handleClick);
  • 这个设计的妙处在于:把“事件触发的入口”和“真实的处理逻辑”分开了。后面要改逻辑的时候,不用动入口,只改存的逻辑就行。

2. 箭头函数:解决 this 乱指的坑

invoker 用箭头函数定义,而不是普通函数,核心目的就是保证 this 能正确指向组件实例。

用过普通函数当事件回调的同学都知道,this 很容易乱指——比如绑在 DOM 上的普通函数,this 会指向触发事件的 DOM 元素,而不是我们的 Vue 组件。但箭头函数没有自己的 this,它会“继承”外层作用域的 this。

在 Vue 里,这个外层作用域的 this 就是组件实例。所以用箭头函数写 invoker,就能确保事件触发时,this 刚好指向我们的组件,不用再手动用 bind 绑定,也不用在业务代码里额外处理 this 问题。

举个反例:如果 invoker 是普通函数,点击 DOM 时 this 会指向那个 DOM 元素,这时候在回调里想访问 this.data、this.methods 都会报错,完全不符合我们的开发预期。

3. 闭包 + 动态更新:不用反复操作 DOM

这是 createInvoker 最核心的优势——支持动态更新事件逻辑,还不用频繁绑解绑 DOM 事件。

我们知道,DOM 操作是前端性能的大瓶颈。如果每次事件回调变了,都要先 removeEventListener 解绑旧的,再 addEventListener 绑定新的,频繁操作下来性能会很差。

而 createInvoker 用了个巧招:因为 invoker 是闭包(内部引用了自身的 value 属性),当我们需要更新事件逻辑时,直接改 invoker.value 的指向就行,不用动 DOM 上的事件绑定。

比如原来 invoker.value 指向 handleClick1,现在要改成 handleClick2,直接写 invoker.value = handleClick2 就搞定了。后续事件触发时,invoker 会自动执行新的 handleClick2,全程不用碰 addEventListener 和 removeEventListener。

三、实际执行流程:从创建到更新全梳理

  1. 创建调用器:Vue 解析模板里的 @click="handleClick" 时,调用 createInvoker 传入 handleClick,生成 invoker,此时 invoker.value = handleClick;
  2. 绑定到 DOM:Vue 将 invoker 通过 addEventListener 绑定到对应的 DOM 元素上(DOM 绑定的是 invoker,而非直接绑定 handleClick);
  3. 事件触发执行:用户触发事件时,invoker 被执行,内部调用 invoker.value(e),最终执行我们写的 handleClick(e);
  4. 动态更新逻辑:需要修改事件回调时,直接修改 invoker.value = 新回调函数即可,无需重新绑定 DOM 事件。

四、简单实用案例:看完就能上手

不用搞复杂的源码场景,这两个简单案例,帮你快速理解 createInvoker 在实际开发中的用法:

案例 1:按钮点击逻辑动态切换

这是最基础的用法,模拟 Vue 里动态改事件回调的场景:

// 先实现 createInvoker 函数
function createInvoker(value) {
  const invoker = (e) => {
    invoker.value(e)
  }
  invoker.value = value
  return invoker
}

// 准备两个不同的点击逻辑
const clickLogic1 = (e) => {
  alert('点击逻辑1:你点了按钮')
}
const clickLogic2 = (e) => {
  alert('点击逻辑2:按钮被点击啦')
}

// 给按钮绑事件
const btn = document.querySelector('#myBtn')
// 创建调用器,初始用逻辑1
const btnInvoker = createInvoker(clickLogic1)
btn.addEventListener('click', btnInvoker)

// 2秒后自动切换成逻辑2(不用解绑事件)
setTimeout(() => {
  btnInvoker.value = clickLogic2
  console.log('已切换点击逻辑,再点按钮试试')
}, 2000)

效果:页面加载后点按钮弹“逻辑1”,2秒后点按钮弹“逻辑2”,全程只绑了一次点击事件。

案例 2:开关控制滚动监听

高频事件(比如 scroll)用这个方式优化特别香,不用反复绑解绑:

function createInvoker(value) {
  const invoker = (e) => {
    invoker.value(e)
  }
  invoker.value = value
  return invoker
}

// 滚动监听逻辑:打印滚动位置
const scrollLogic = () => {
  console.log('滚动位置:', window.scrollY)
}
// 空逻辑:暂停监听时用
const emptyLogic = () => {}

// 创建调用器,初始监听滚动
const scrollInvoker = createInvoker(scrollLogic)
window.addEventListener('scroll', scrollInvoker)

// 开关按钮:点一下暂停/恢复监听
const toggleBtn = document.querySelector('#toggleScroll')
let isListening = true
toggleBtn.onclick = () => {
  isListening = !isListening
  toggleBtn.textContent = isListening ? '暂停滚动监听' : '恢复滚动监听'
  // 只改 invoker.value 就行
  scrollInvoker.value = isListening ? scrollLogic : emptyLogic
}

效果:默认滚动页面会打印位置,点按钮就能暂停,再点恢复,不用动 scroll 事件的绑定状态。

五、最后总结一下

createInvoker 函数看着简单,但核心是三个设计巧思:利用“函数是对象”存逻辑、用箭头函数保 this、靠闭包实现动态更新。最终实现了“高效、灵活、低性能损耗”的事件处理机制,这也是 Vue 事件系统的灵魂。

记住三个关键点,就算真的懂了:

  • invoker 既是事件回调入口(函数),也是逻辑存储容器(对象);
  • 更新事件逻辑,直接改 invoker.value 就行,不用碰 DOM;
  • 箭头函数确保 this 指向组件实例,不用额外处理 this 问题。

理解了 createInvoker 之后,再去看 Vue 源码里和事件相关的部分(比如 patchEvent),就会觉得豁然开朗。




六、最后总结一下

createInvoker 函数看着简单,但核心是三个设计巧思:利用“函数是对象”存逻辑、用箭头函数保 this、靠闭包实现动态更新。最终实现了“高效、灵活、低性能损耗”的事件处理机制,这也是 Vue 事件系统的灵魂。

记住三个关键点,就算真的懂了:

- invoker 既是事件回调入口(函数),也是逻辑存储容器(对象);

- 更新事件逻辑,直接改 invoker.value 就行,不用碰 DOM;

- 箭头函数确保 this 指向组件实例,不用额外处理 this 问题。

理解了 createInvoker 之后,再去看 Vue 源码里和事件相关的部分(比如 patchEvent),就会觉得豁然开朗。

Vue 数据响应式探秘:如何让数组变化无所遁形?

作者 北辰alk
2026年1月8日 21:29

一、问题的由来:为什么数组这么特殊?

让我们先来看一个常见的“坑”:

// 假设我们有一个 Vue 实例
new Vue({
  data() {
    return {
      items: ['苹果''香蕉''橙子']
    }
  },
  created() {
    // 这种修改方式,视图不会更新!
    this.items[0] = '芒果';
    this.items.length = 0;
    
    // 这种修改方式,视图才会更新
    this.items.push('葡萄');
  }
})

问题来了:为什么同样是修改数组,有的方式能触发更新,有的却不能?

二、Vue 2.x 的解决方案:拦截数组方法

1. 核心原理:方法拦截

Vue 2.x 中,通过重写数组原型上的 7 个方法来监听数组变化:

// Vue 2.x 的数组响应式核心实现(简化版)
const arrayProto = Array.prototype;
const arrayMethods = Object.create(arrayProto);

const methodsToPatch = [
  'push''pop''shift''unshift',
  'splice''sort''reverse'
];

methodsToPatch.forEach(function(method) {
  const original = arrayProto[method];
  
  def(arrayMethods, method, function mutator(...args) {
    // 1. 先执行原始方法
    const result = original.apply(this, args);
    
    // 2. 获取数组的 __ob__ 属性(Observer 实例)
    const ob = this.__ob__;
    
    // 3. 处理新增的元素(如果是 push/unshift/splice 添加了新元素)
    let inserted;
    switch (method) {
      case 'push':
      case 'unshift':
        inserted = args;
        break;
      case 'splice':
        inserted = args.slice(2);
        break;
    }
    
    // 4. 对新元素进行响应式处理
    if (inserted) ob.observeArray(inserted);
    
    // 5. 通知依赖更新
    ob.dep.notify();
    
    return result;
  });
});

// 在 Observer 类中
class Observer {
  constructor(value) {
    this.value = value;
    this.dep = new Dep();
    
    if (Array.isArray(value)) {
      // 如果是数组,修改其原型指向
      value.__proto__ = arrayMethods;
      this.observeArray(value);
    } else {
      this.walk(value);
    }
  }
  
  observeArray(items) {
    for (let i = 0, l = items.length; i < l; i++) {
      observe(items[i]);
    }
  }
}

2. 支持的数组方法

Vue 能够检测变化的数组操作:

// 以下操作都能被 Vue 检测到
this.items.push('新元素')          // 末尾添加
this.items.pop()                  // 删除最后一个
this.items.shift()                // 删除第一个
this.items.unshift('新元素')       // 开头添加
this.items.splice(01'替换值'// 替换元素
this.items.sort()                 // 排序
this.items.reverse()              // 反转

3. 无法检测的变化

// 以下操作无法被检测到
this.items[index] = '新值';        // 直接设置索引
this.items.length = 0;            // 修改长度

// 解决方案:使用 Vue.set 或 splice
Vue.set(this.items, index, '新值');
this.items.splice(index, 1'新值');

三、实战代码示例

让我们通过一个完整的例子来理解:

<template>
  <div>
    <h3>购物清单</h3>
    <ul>
      <li v-for="(item, index) in shoppingList" :key="index">
        {{ item }}
        <button @click="removeItem(index)">删除</button>
        <button @click="updateItem(index)">更新</button>
      </li>
    </ul>
    
    <input v-model="newItem" placeholder="输入新商品">
    <button @click="addItem">添加商品</button>
    
    <button @click="badUpdate">错误更新方式</button>
    <button @click="goodUpdate">正确更新方式</button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      shoppingList: ['牛奶''面包''鸡蛋'],
      newItem''
    }
  },
  methods: {
    addItem() {
      if (this.newItem) {
        // 正确方式:使用 push
        this.shoppingList.push(this.newItem);
        this.newItem = '';
      }
    },
    
    removeItem(index) {
      // 正确方式:使用 splice
      this.shoppingList.splice(index, 1);
    },
    
    updateItem(index) {
      const newName = prompt('请输入新的商品名:');
      if (newName) {
        // 正确方式:使用 Vue.set 或 splice
        this.$set(this.shoppingList, index, newName);
        // 或者:this.shoppingList.splice(index, 1, newName);
      }
    },
    
    badUpdate() {
      // 错误方式:直接通过索引修改
      this.shoppingList[0] = '直接修改的值';
      console.log('数据变了,但视图不会更新!');
    },
    
    goodUpdate() {
      // 正确方式
      this.$set(this.shoppingList0'正确修改的值');
      console.log('数据和视图都会更新!');
    }
  }
}
</script>

四、流程图解:Vue 数组响应式原理

开始
  │
  ▼
初始化数据
  │
  ▼
Observer 处理数组
  │
  ▼
修改数组原型链
  │
  ├─────────────────┬─────────────────┐
  ▼                 ▼                 ▼
设置 __ob__ 属性   重写7个方法     建立依赖收集
  │                 │                 │
  ▼                 ▼                 ▼
当数组方法被调用时
  │
  ├───────────────┐
  ▼               ▼
执行原始方法     收集新元素
  │               │
  ▼               ▼
新元素响应式处理
  │
  ▼
通知所有依赖更新
  │
  ▼
触发视图重新渲染
  │
  ▼
结束

五、Vue 3 的进步:Proxy 的魔力

Vue 3 使用 Proxy 重写了响应式系统,完美解决了数组检测问题:

// Vue 3 的响应式实现(简化版)
function reactive(target) {
  return new Proxy(target, {
    get(target, key, receiver) {
      const res = Reflect.get(target, key, receiver);
      // 依赖收集
      track(target, key);
      // 如果获取的是数组或对象,继续代理
      if (typeof res === 'object' && res !== null) {
        return reactive(res);
      }
      return res;
    },
    
    set(target, key, value, receiver) {
      const oldValue = target[key];
      const result = Reflect.set(target, key, value, receiver);
      
      // 判断是新增属性还是修改属性
      const type = Array.isArray(target)
        ? Number(key) < target.length ? 'SET' : 'ADD'
        : Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD';
      
      // 触发更新
      trigger(target, key, type, value, oldValue);
      
      return result;
    }
  });
}

// 现在这些操作都能被检测到了!
const arr = reactive(['a''b''c']);
arr[0] = 'x';      // ✅ 可以被检测
arr.length = 0;    // ✅ 可以被检测
arr[3] = 'd';      // ✅ 新增索引可以被检测

六、最佳实践总结

在 Vue 2 中:

  1. 1. 使用变异方法:push、pop、shift、unshift、splice、sort、reverse
  2. 2. 修改特定索引:使用 Vue.set() 或 vm.$set()
  3. 3. 修改数组长度:使用 splice

在 Vue 3 中:

由于使用了 Proxy,几乎所有数组操作都能被自动检测,无需特殊处理。

实用工具函数:

// 创建一个数组修改工具集
const arrayHelper = {
  // 安全更新数组元素
  update(array, index, value) {
    if (Array.isArray(array)) {
      if (this.isVue2) {
        Vue.set(array, index, value);
      } else {
        array[index] = value;
      }
    }
  },
  
  // 安全删除数组元素
  remove(array, index) {
    if (Array.isArray(array)) {
      array.splice(index, 1);
    }
  },
  
  // 清空数组
  clear(array) {
    if (Array.isArray(array)) {
      array.splice(0, array.length);
    }
  }
};

七、常见问题解答

Q:为什么 Vue 2 不直接监听数组索引变化?
A:主要是性能考虑。ES5 的 Object.defineProperty 无法监听数组索引变化,需要通过重写方法实现。

Q:Vue.set 内部是怎么实现的?
A:Vue.set 在遇到数组时,本质上还是调用 splice 方法。

Q:为什么直接修改 length 不生效?
A:因为 length 属性本身是可写的,但改变 length 不会触发 setter。

结语

理解 Vue 如何检测数组变化,是掌握 Vue 响应式系统的关键一步。从 Vue 2 的方法拦截到 Vue 3 的 Proxy 代理,技术的进步让开发者体验越来越好。记住核心原则:在 Vue 2 中,始终使用变异方法修改数组;在 Vue 3 中,你可以更自由地操作数组。

希望这篇文章能帮助你彻底理解 Vue 的数组响应式原理!如果你有更多问题,欢迎在评论区留言讨论。

❌
❌