<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet type="text/xsl" href="/rss/atom-styles.xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Dnzzk2</title>
  <subtitle>Dnzzk2 的个人网站 - 前端开发者与 UI 设计爱好者，分享前端技术、设计思考、开源项目与生活感悟。探索技术之美，记录成长点滴。ጿ ኈ ቼ ዽ ጿ ኈ ቼ ዽ ጿ ！</subtitle>
  <link href="https://dnzzk2.me/atom.xml" rel="self" type="application/atom+xml"/>
  <link href="https://dnzzk2.me" rel="alternate" type="text/html"/>
  <updated>2026-04-18T15:08:02.396Z</updated>
  <language>zh-CN</language>
  <id>https://dnzzk2.me/</id>
  <author>
    <name>Dnzzk2</name>
    <uri>https://dnzzk2.me</uri>
  </author>
  <generator uri="https://github.com/Dnzzk2/Litos" version="5.0">Astro Litos Theme</generator>
  <rights>Copyright © 2026 Dnzzk2</rights>
  
  <entry>
    <title>Event Loop 事件循环</title>
    <link href="https://dnzzk2.me/posts/event-loop" rel="alternate" type="text/html"/>
    <id>https://dnzzk2.me/posts/event-loop</id>
    <updated>2026-03-31T00:00:00.000Z</updated>
    <published>2026-03-31T00:00:00.000Z</published>
    <author>
      <name>Dnzzk2</name>
    </author>
    <summary type="text">事件循环是前端开发中极其重要的基础内容，它是让 JavaScript 这种单线程语言能够执行非阻塞异步操作的秘密武器。</summary>
    <content type="html"><![CDATA[<img src="https://dnzzk2.me/_astro/og.Bgtk9UV2_FP98k.webp" alt="Event Loop 事件循环" style="width: 100%; height: auto; margin-bottom: 1em;" />
<p>事件循环（Event Loop）是前端开发中极其重要的基础内容，作为前端开发者必须要掌握的内容。同时在日常的工作或者面试中也是高频的话题。</p>
<p>在讲事件循环之前，先了解一下计算机科学中的两个基本执行单元：进程和线程。</p>
<h2>进程</h2>
<p>进程是操作系统中资源分配的基本单位。它是程序的一次执行过程，拥有自己独立的内存空间、代码、数据等资源。一个进程可以包含多个线程，它们共享进程的资源，但是每个线程有自己的执行路径。在Windows系统中，一个运行的应用程序，如xx.exe，就是一个进程。</p>
<h2>线程</h2>
<p>线程是进程中的一个执行任务，是处理器任务调度和执行的基本单位。它是比进程更小的能独立运行的基本单位。线程共享其所属进程的地址空间和资源，但拥有自己的程序计数器、虚拟机栈和本地方法栈。线程之间的切换开销小于进程，因此被称为轻量级进程。</p>
<hr />
<figure><img src="https://dnzzk2.me/_astro/process-thread.BO9_ITyK_Z7RTQ4.webp" alt="process-thread" style="width:450px" /><figcaption>应用程序、进程与线程示意图</figcaption></figure>
<p>通过示意图，我们可以更好的理解他们的关系。每个应用程序，至少有一个进程，进程之间相互独立，如果要通信，也需要双方同意。每个进程至少有一个线程，所以在进程开启后会自动创建一个线程来运行代码，称之为主线程。如果要同时执行多块代码，主线程就会启动更多的线程来执行代码，所以一个进程中可以包含多个线程。</p>
<h2>浏览器</h2>
<p>浏览器是一个多进程多线程的应用程序。浏览器内部工作极其复杂，为了减少连环崩坏的几率，它会自动启动多个进程。</p>
<p>以 Chrome 为例，通过快捷键 <code>shift</code> + <code>esc</code>，可以快速打开浏览器的任务管理器，可以看见有多个进程在运行。</p>
<figure><img src="https://dnzzk2.me/_astro/browser-task-manager.mungh2Pj_iWn2F.webp" alt="browser-task-manager" style="width:450px" /><figcaption>浏览器任务管理器</figcaption></figure>
<p>前端开发最关注的是渲染进程，每开启一个 Tab 页，就会有一个渲染进程。渲染进程下面一般是，主线程、合成线程、栅格线程、IO线程、Workder相关线程。
其中有一个误解就是会认为有一个独立的JS引擎线程，但实际上，JS引擎是运行在主线程中的，它与HTML/CSS解析、样式计算、布局、绘制串行执行，会与 GUI 渲染互斥，所以JS会阻塞渲染。
参考<a href="https://github.com/chromium/chromium/blob/main/docs/threading_and_tasks.md" rel="noopener noreferrer" target="_blank">官方核心文档-Threading and Tasks in Chrome</a>。</p>
<h2>浏览器中的事件循环机制</h2>
<p>事件循环是单线程 JS 支持异步的核心机制。当执行栈中的同步任务执行完毕并清空后，事件循环会按照特定规则，从任务队列中提取并执行异步任务的回调函数。本质上，它是“事件驱动”模式在 JS 运行环境中的具体调度实现。</p>
<h3>事件驱动</h3>
<p>浏览器异步任务的执行原理背后其实是一套事件驱动的机制。事件触发、任务选择和任务执行都是由事件驱动机制来完成的。NodeJS 和浏览器的设计都是基于事件驱动的，简而言之就是由特定的事件来触发特定的任务，这里的事件可以是用户的操作触发的，如 click 事件；也可以是程序自动触发的，比如浏览器中定时器线程在计时结束后会触发定时器事件。</p>
<p>在事件驱动中，当有事件触发后，被触发的事件（上面所说的回调函数）会按顺序暂时存在一个队列中，待 JS 的同步任务执行完成后，会从这个队列中取出要处理的事件并进行处理。那么具体什么时候取任务、优先取哪些任务，这就由事件循环流程来控制了。</p>
<h3>执行栈与任务队列</h3>
<p>JS 在解析一段代码时，会把同步代码按照顺序排在执行栈中，然后依次执行里面的函数。当遇到异步任务就会交给其他线程处理，待当前执行栈中的所有同步代码执行完成后，会从任务队列中去取出早已完成的异步任务的回调函数加入执行栈继续执行，遇到异步任务又交给其他线程处理,…如此循环往复。</p>
<p>JS 按顺序执行执行栈中的方法，每次执行一个方法时，会为这个方法生成独有的执行环境（上下文 context)，待这个方法执行完成后，销毁当前的执行环境，并从栈中弹出此方法（即消费完成），然后继续下一个方法。</p>
<figure><img src="https://dnzzk2.me/_astro/event-loop.BYQJzivJ_jSnBQ.webp" alt="event-loop" style="width:450px" /><figcaption>事件循环示意图</figcaption></figure>
<p>可见，在事件驱动的模式下，至少包含了一个执行循环来检测任务队列是否有新的任务。通过不断循环去取出异步回调来执行，这个过程就是事件循环，而每一次循环就是一个事件周期或称为一次 tick。</p>
<h3>任务分类</h3>
<p>任务没有优先级，但是消息队列有优先级，消息队列不止一个。</p>
<p>每一个任务都有任务类型，同一个类型的任务必须在同一个队列。浏览器必须准备一个微队列，微队列的优先级优先所有其他队列。</p>
<p>在以前，只有宏任务队列和微任务队列。但是随着浏览器的复杂度急剧发展，W3C不再使用宏队列的说法。</p>
<p>在目前的 chrome的实现中，至少包含了下面的队列：</p>
<ul>
<li>延时队列：用于存放定时器到达后的回调函数，优先级「中」</li>
<li>交互队列：用于存放用户操作后的事件处理任务，优先级「高」</li>
<li>微队列：用于存在需要最快执行的任务，优先级「最高」</li>
</ul>
<p>那么有什么办法可以添加任务到微队列呢？</p>
<ul>
<li>Promise：当 Promise 决议后，其 <code>.then</code>、<code>.catch</code>、<code>.finally</code> 的回调会进入微队列。</li>
<li>queueMicrotask：提供了专门用于添加微任务的原生 API（推荐）。</li>
<li>MutationObserver：浏览器中用于监听 DOM 变化触发的回调，也属于微任务。</li>
<li>process.nextTick（Node.js）：虽不是标准微任务，但执行时机甚至比微任务更早。</li>
</ul>
<h2>定时器误差</h2>
<p>事件循环中，总是先执行同步代码之后，才会去任务队列中取任务执行。那么如果同步代码执行时间过长，就会导致定时器不准时执行。
同步代码耗时越长，定时器误差就越大。不仅仅是同步代码，由于微任务的优先级最高，所以微任务也会影响计时，假设同步代码中有一个死循环或者微任务中递归不断在启动其他微任务，那么宏任务里面的代码可能永远得不到执行。所以主线程代码的执行效率提升是一件很重要的事情。</p>
<h2>总结</h2>
<p>通篇来看，我们可以得出以下核心结论：</p>
<ol>
<li>单线程是基石：JavaScript 是一门单线程语言，其引擎运行在浏览器的渲染进程主线程中，执行过程与 GUI 渲染互斥。</li>
<li>事件驱动是模式：浏览器通过事件驱动机制管理异步任务，事件触发后产生回调任务，并按类型进入不同的任务队列（如微队列、延时队列、交互队列）。</li>
<li>事件循环是核心：它是连接“同步执行栈”与“异步任务队列”的调度官。基本逻辑是：执行同步代码（清空栈） -&gt; 执行并清空所有微任务 -&gt; 必要时进行 UI 渲染 -&gt; 提取下一个异步回调，如此往复。</li>
<li>性能导向：理解事件循环不仅是为了应付面试，更是为了在开发中意识到主线程的宝贵。避免长时间同步阻塞和微任务堆积，是确保定时器精准、页面丝滑响应的关键。</li>
</ol>
<p>掌握了事件循环，就掌握了 JavaScript 在浏览器中运行的“心跳”。</p>]]></content>
    <category term="技术" />
    <category term="JavaScript" />
    <category term="事件循环" />
  </entry>
  <entry>
    <title>前端 PDF 导出：下载、打印与自动分页</title>
    <link href="https://dnzzk2.me/posts/pdf" rel="alternate" type="text/html"/>
    <id>https://dnzzk2.me/posts/pdf</id>
    <updated>2026-03-17T00:00:00.000Z</updated>
    <published>2026-03-17T00:00:00.000Z</published>
    <author>
      <name>Dnzzk2</name>
    </author>
    <summary type="text">深度解析 Web 项目中生成 PDF 的多种方案。涵盖后端流下载、window.print() 技巧，以及 html2canvas-pro + jsPDF 实现的自动分页导出逻辑与多模板架构设计。</summary>
    <content type="html"><![CDATA[<img src="https://dnzzk2.me/_astro/og.D9ERlgOV_Z1hWytQ.webp" alt="前端 PDF 导出：下载、打印与自动分页" style="width: 100%; height: auto; margin-bottom: 1em;" />
<p>在工作中，我们经常会遇到需要生成PDF的业务，比如合同、报告等。</p>
<h2>后端生成 PDF</h2>
<p>对于前端来说，最省事的就是后端生成 PDF 文件，前端根据返回的 URL 地址进行下载。</p>
<h3>URL 下载</h3>
<p>如果后端直接返回一个可访问的 URL 地址，我们可以通过以下几种方式进行下载：</p>
<h4>window.open / href</h4>
<p>这是最简单的方式，但缺点是无法控制下载后的文件名，且受浏览器拦截政策影响。</p>
<pre><code>const downloadByUrl = (url: string) =&gt; {
  window.open(url, '_blank')
}
</code></pre>
<h4>a 标签</h4>
<p>通过创建虚拟锚点并利用 <code>download</code> 属性，可以更好地控制下载行为。</p>
<pre><code>/**
 * 通过 URL 下载文件
 * @param url 文件地址
 * @param fileName 自定义文件名
 */
export const downloadFileByUrl = (url: string, fileName?: string) =&gt; {
  const link = document.createElement('a')
  link.href = url

  // 如果提供了文件名，则设置 download 属性
  if (fileName) {
    link.download = fileName
  }

  link.target = '_blank'
  link.style.display = 'none'
  document.body.appendChild(link)

  link.click()

  // 清理
  document.body.removeChild(link)
}
</code></pre>
<h3>文件流下载</h3>
<p>如果后端返回的是文件流（Blob），由于浏览器无法直接解析这种数据格式作为下载源，我们需要通过 <code>URL.createObjectURL</code> 将其转换为一个临时的 <code>blob:URL</code>，然后利用 <code>&lt;a&gt;</code> 标签触发下载。</p>
<pre><code>/**
 * 通过文件流下载文件
 * @param data 文件流数据 (Blob | ArrayBuffer | string)
 * @param fileName 下载后的文件名
 * @param mimeType 文件的 MIME 类型 (可选，如果不传则尝试从 data 中获取或使用默认值)
 */
export const downloadFileByStream = (data: any, fileName: string, mimeType?: string) =&gt; {
  // 1. 优先获取数据的类型
  const type = mimeType || (data instanceof Blob ? data.type : 'application/octet-stream')

  // 2. 将数据封装为 Blob 对象
  const blob = data instanceof Blob ? data : new Blob([data], { type })

  // 3. 创建一个临时的 URL 指向该 Blob 对象
  const blobURL = window.URL.createObjectURL(blob)

  // 4. 创建虚拟锚点触发下载
  const link = document.createElement('a')
  link.href = blobURL
  link.download = fileName
  link.style.display = 'none'
  document.body.appendChild(link)

  link.click()

  // 5. 下载执行后释放 URL 对象和 DOM 节点
  document.body.removeChild(link)
  // 不释放可能导致内存泄露，过早释放可能会导致下载失败，可以延迟触发
  window.URL.revokeObjectURL(blobURL)
}
</code></pre>
<h2>window.print()</h2>
<p>在有些业务上，需要纯前端生成 PDF。</p>
<p>这是调用浏览器原生打印功能最简单的方法。它会将当前页面的内容渲染到打印预览窗口中，用户可以选择保存为 PDF。</p>
<p>其实并不推荐，因为在很多复杂的结构中，需要做很多工作，才能达到理想的效果。
并且会有打印预览弹窗，无法实现无感打印。</p>
<pre><code>const handlePrint = () =&gt; {
  window.print()
}
</code></pre>
<h3>CSS 控制</h3>
<p>为了让打印出来的效果更好，我们通常需要使用 <code>@media print</code> 查询来控制打印时的样式。</p>
<pre><code>@media print {
  /* 隐藏不需要打印的元素，如导航栏、侧边栏、按钮 */
  .no-print {
    display: none !important;
  }

  /* 调整打印区域的宽度 */
  .print-container {
    width: 100%;
    margin: 0;
    padding: 0;
  }

  /* 强制分页 */
  .page-break {
    page-break-after: always;
  }
}
</code></pre>
<h2>html2canvas-pro 与 jsPDF</h2>
<p><code>html2canvas</code> 可以将网页内容转换为图片，然后 <code>jsPDF</code> 可以将图片转换为 PDF。</p>
<div><div><div></div><div>TIP</div></div><div><p><strong>html2canvas-pro</strong> 是 <code>html2canvas</code> 的加强版分叉，<strong>完全兼容原版 API</strong>。它可以作为<strong>无缝替代品</strong>直接安装并导入（只需将 <code>import html2canvas from 'html2canvas'</code> 改为 <code>import html2canvas from 'html2canvas-pro'</code>）。它修复了原版在处理现代 CSS（如 <code>object-fit</code>、<code>clip-path</code>）时的许多渲染 Bug。</p></div></div>
<p>下面是通用的代码，可用于 95% 的场景，该方法会自动分页，且不会切断元素。</p>
<pre><code>import html2canvas from 'html2canvas-pro' // 推荐使用 pro 版本无缝替代
import jsPDF from 'jspdf'

/**
 * 将指定 DOM 导出为 PDF
 * @param domId 目标 DOM 元素的 ID
 * @param title 导出的文件名
 */
export const exportPdf = async (domId: string, title?: string): Promise&lt;void&gt; =&gt; {
  const ele = document.getElementById(domId)
  if (!ele) throw new Error('未找到目标元素')

  const scale = window.devicePixelRatio &gt; 1 ? window.devicePixelRatio : 2

  // 获取所有防截断元素（防止元素被分页切开，如表格行、标题、段落等）
  const nodes = ele.querySelectorAll('tr, h2, h3, h4, h5, p, img')
  const containerRect = ele.getBoundingClientRect()

  // 【优化1】同时收集元素的 top 和 bottom 坐标
  const breakPointsPx = Array.from(nodes).map((node) =&gt; {
    const rect = node.getBoundingClientRect()
    return {
      top: rect.top - containerRect.top,
      bottom: rect.bottom - containerRect.top,
    }
  })

  // 生成画布
  const canvas = await html2canvas(ele, {
    scale,
    useCORS: true, // 允许图片跨域
    backgroundColor: '#ffffff',
  })

  const imgDataUrl = canvas.toDataURL('image/jpeg', 1.0)

  // 初始化 PDF 对象：p-竖向，pt-点(单位)，a4-纸张规格
  const pdf = new jsPDF('p', 'pt', 'a4')
  const a4Width = pdf.internal.pageSize.getWidth()
  const a4Height = pdf.internal.pageSize.getHeight()

  // 计算图片缩放比例：根据宽度适配 A4
  const ratio = a4Width / canvas.width
  const imgWidth = a4Width
  const imgHeight = canvas.height * ratio

  // 将坐标单位从 px 转换为 pt (符合 PDF 内部计算)
  const breakPointsPt = breakPointsPx.map((bp) =&gt; ({
    top: bp.top * ratio,
    bottom: bp.bottom * ratio,
  }))

  const topMargin = 30 // 页眉预留
  const bottomMargin = 30 // 页脚预留
  const pageContentHeight = a4Height - topMargin - bottomMargin

  let currentRenderY = 0 // 已完成渲染的 Y 轴偏移

  while (currentRenderY &lt; imgHeight) {
    let expectedPageBottom = currentRenderY + pageContentHeight
    let actualPageBottom = expectedPageBottom

    // 【优化2】判断是不是最后一页
    if (expectedPageBottom &gt;= imgHeight) {
      actualPageBottom = imgHeight
    } else {
      // 只有不是最后一页，才去遍历判断是否被截断
      for (let i = 0; i &lt; breakPointsPt.length; i++) {
        const { top, bottom } = breakPointsPt[i]

        // 【优化3】核心判断：元素的头在当前页，但尾巴超出了当前页的底部，说明被“腰斩”了
        if (top &gt; currentRenderY &amp;&amp; top &lt; expectedPageBottom &amp;&amp; bottom &gt; expectedPageBottom) {
          actualPageBottom = top // 在被截断元素的顶部切一刀，将其整体推到下一页
          break
        }
      }
    }

    if (actualPageBottom === currentRenderY) actualPageBottom = expectedPageBottom

    // 1. 渲染当前页图像（利用负偏移显示指定区域）
    pdf.addImage(imgDataUrl, 'JPEG', 0, topMargin - currentRenderY, imgWidth, imgHeight)

    // 2. 顶部遮罩（覆盖负偏移区域产生的重叠部分）
    if (currentRenderY &gt; 0) {
      pdf.setFillColor(255, 255, 255)
      pdf.rect(0, 0, a4Width, topMargin, 'F')
    }

    // 3. 底部遮罩（留白并遮挡截断处的残影）
    const currentRenderBottomY = topMargin + (actualPageBottom - currentRenderY)
    pdf.setFillColor(255, 255, 255)
    pdf.rect(0, currentRenderBottomY, a4Width, a4Height - currentRenderBottomY, 'F')

    currentRenderY = actualPageBottom

    // 如果还没画完，添加新的一页
    if (currentRenderY + 5 &lt; imgHeight) {
      pdf.addPage()
    }
  }
  const fileName = title ? `${title}_${Date.now()}` : Date.now().toString()
  pdf.save(`${fileName}.pdf`)
}
</code></pre>
<h3>用法案例</h3>
<p>在 React 中使用该方案：</p>
<pre><code>import { exportPdf } from './utils/pdf'

const ReportPage = () =&gt; {
  const handleDownload = async () =&gt; {
    try {
      // 传入容器 ID 和文件名
      await exportPdf('pdf-content', '月度分析报告')
    } catch (error) {
      console.error('生成 PDF 失败:', error)
    }
  }

  return (
    &lt;div&gt;
      &lt;button onClick={handleDownload}&gt;下载报告&lt;/button&gt;

      {/* 这里的 ID 必须与 exportPdf 传入的一致 */}
      &lt;div id="pdf-content" style={{ padding: '20px', background: '#fff' }}&gt;
        &lt;h2&gt;报表标题&lt;/h2&gt;
        &lt;p&gt;这里是很长很长的内容，可能会跨页...&lt;/p&gt;
        &lt;table&gt;
          &lt;tbody&gt;
            &lt;tr&gt;
              &lt;td&gt;数据行 1&lt;/td&gt;
            &lt;/tr&gt;
            {/* 这里的 tr 会被防截断逻辑自动推送到下一页容器中 */}
            &lt;tr&gt;
              &lt;td&gt;数据行 2&lt;/td&gt;
            &lt;/tr&gt;
          &lt;/tbody&gt;
        &lt;/table&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<h2>PDF 模板架构</h2>
<p>当项目中需要管理多个 PDF 模板时，建议采用“<strong>容器与显示分离</strong>”的架构，这样可以保证模板的纯净度（只负责 UI），同时方便在后台静默生成 PDF。</p>
<h3>1. 目录结构</h3>
<pre><code>src/
  ├── components/
  │   └── pdf-templates/      # 所有的 PDF UI 模板
  │       ├── Contract.tsx    # 合同模板
  │       ├── Invoice.tsx     # 发票模板
  │       └── index.ts        # 统一导出
  └── utils/
      └── pdf.ts              # 核心 exportPdf 方法
</code></pre>
<h3>2. 模板编写 (Pure UI)</h3>
<p>模板组件应该只接收 <code>data</code> Props，不处理任何业务逻辑。</p>
<pre><code>// src/components/pdf-templates/ContractTemplate.tsx
interface IProps {
  data: any
}

export const ContractTemplate = ({ data }: IProps) =&gt; (
  &lt;div id="pdf-render-target" style={{ width: '800px', padding: '40px' }}&gt;
    &lt;h1&gt;{data.title}&lt;/h1&gt;
    {/* 自由编写复杂的 PDF 样式 */}
  &lt;/div&gt;
)
</code></pre>
<h3>3. 数据与导出流程</h3>
<p>推荐在需要导出 PDF 的页面中，通过一个隐藏的“渲染容器”来实现。这样可以在不影响主页面 UI 的情况下，获取最新的业务数据并生成 PDF。</p>
<pre><code>// src/pages/OrderDetails.tsx
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { exportPdf } from '../utils/pdf'
import { ContractTemplate } from '../components/pdf-templates'

const OrderDetails = () =&gt; {
  const [isExporting, setIsExporting] = useState(false)
  const [data, setData] = useState(null)

  const startExport = async () =&gt; {
    setIsExporting(true)

    // 1. 获取业务数据 (如从 API 获取)
    const res = await fetchOrderData()
    setData(res)

    // 2. 等待 React 渲染 DOM (利用 setTimeout 确保渲染完成)
    setTimeout(async () =&gt; {
      try {
        await exportPdf('pdf-render-target', '业务合同')
      } finally {
        setIsExporting(false)
      }
    }, 100)
  }

  return (
    &lt;div&gt;
      &lt;button onClick={startExport} disabled={isExporting}&gt;
        {isExporting ? '正在生成...' : '下载 PDF'}
      &lt;/button&gt;

      {/* 通过 Portal 将模板渲染在屏幕外，实现“无感”生成 */}
      {isExporting &amp;&amp;
        data &amp;&amp;
        createPortal(
          &lt;div style={{ position: 'absolute', left: '-9999px', top: 0 }}&gt;
            &lt;ContractTemplate data={data} /&gt;
          &lt;/div&gt;,
          document.body
        )}
    &lt;/div&gt;
  )
}
</code></pre>
<h3>4. 架构优势</h3>
<ul>
<li><strong>关注点分离</strong>：页面只管触发，模板只管绘制，<code>utils</code> 只管转换。</li>
<li><strong>数据解耦</strong>：PDF 模板的数据可以由父页面统一注入，也可以在 <code>exportPdf</code> 调用前按需加载。</li>
<li><strong>用户无感</strong>：通过 <code>createPortal</code> 将渲染目标移出可视区域，用户在页面上感知不到“截图”的过程。</li>
</ul>
<h2>总结</h2>
<p>前端里的 PDF 导出，本质上是三类方案：后端生成后前端下载、浏览器原生打印、前端自行截图并分页导出。不同场景没有绝对最优，关键是先判断你更看重的是开发成本、版式控制，还是最终体验。</p>
<p>如果只是拿到文件就下载，优先走后端；如果页面本来就接近打印稿，可以试试 <code>window.print()</code>；如果需要更强的模板控制和自动分页能力，<code>html2canvas-pro + jsPDF</code> 会更稳。项目一旦涉及多套 PDF 模板，最好尽早把模板、导出逻辑和业务页面拆开。</p>]]></content>
    <category term="业务" />
    <category term="PDF" />
    <category term="html2canvas" />
  </entry>
  <entry>
    <title>数组方法</title>
    <link href="https://dnzzk2.me/posts/array" rel="alternate" type="text/html"/>
    <id>https://dnzzk2.me/posts/array</id>
    <updated>2026-03-11T00:00:00.000Z</updated>
    <published>2026-03-11T00:00:00.000Z</published>
    <author>
      <name>Dnzzk2</name>
    </author>
    <summary type="text">在工作中，因为我是写React的，大部分时间都是在写map，其他的数组方法很容易弄混，做一下备忘。</summary>
    <content type="html"><![CDATA[<img src="https://dnzzk2.me/_astro/og.LVcQYTNW_Z1Td132.webp" alt="数组方法" style="width: 100%; height: auto; margin-bottom: 1em;" />
<p>import Playground from ’../../../components/Playground.tsx’</p>
<p>在写 React 的时候，用的最多的就是 <code>map</code> 方法。这很经典，就不再记录了。</p>
<h3>数组方法速查表</h3>






























<table><thead><tr><th>维度</th><th>方法列表</th><th>核心提醒</th></tr></thead><tbody><tr><td>修改原数组</td><td>push/pop/shift/unshift/splice/sort/reverse/fill</td><td>慎用！需保留原数组则先拷贝（[…arr]）</td></tr><tr><td>返回新数组</td><td>slice/map/filter/concat/flat</td><td>原数组不变，可链式调用（如arr.filter().map()）</td></tr><tr><td>无返回值</td><td>forEach</td><td>仅遍历，不能链式调用</td></tr><tr><td>返回单个值</td><td>find/findIndex/every/some/indexOf/includes/reduce</td><td>按需选择（找元素用 find，找索引用 findIndex，累加用 reduce）</td></tr></tbody></table>
<p>对数组的操作，有影响原数组的，也有不影响原数组的。</p>
<h3>会修改原数组的方法</h3>
<p>这类方法直接改变原数组，是新手最容易出错的点，记录时重点标注 “修改原数组”：</p>


















































<table><thead><tr><th>方法</th><th>功能</th><th>易错点</th></tr></thead><tbody><tr><td><code>push()</code></td><td>向数组末尾添加元素</td><td>返回值是新数组长度，不是新数组</td></tr><tr><td><code>pop()</code></td><td>删除数组最后一个元素</td><td>返回值是被删除的元素，不是剩余数组</td></tr><tr><td><code>shift()</code></td><td>删除数组第一个元素</td><td>返回值是被删除的元素，且性能比 <code>pop()</code> 差（数组会重新索引）</td></tr><tr><td><code>unshift()</code></td><td>向数组开头添加元素</td><td>返回值是新数组长度，且性能差（数组重新索引）</td></tr><tr><td><code>splice(start, deleteCount, ...items)</code></td><td>增 / 删 / 改数组元素</td><td>① <code>start</code> 为负数时从末尾计数；② 返回值是被删除的元素数组；③ 第二个参数是 “删除数量” 不是 “结束索引”</td></tr><tr><td><code>sort((a,b) =&gt; {})</code></td><td>数组排序</td><td>① 默认按字符串 Unicode 排序（数字需自定义比较函数）；② 比较函数返回 <code>a-b</code> 升序，<code>b-a</code> 降序</td></tr><tr><td><code>reverse()</code></td><td>反转数组</td><td>无参数，直接反转原数组，返回反转后的数组（和原数组指向同一内存）</td></tr><tr><td><code>fill(value, start, end)</code></td><td>填充数组</td><td><code>start</code>/<code>end</code> 可选，左闭右开，填充覆盖原数组元素</td></tr></tbody></table>
<h4>splice 用法</h4>
<p>语法：<code>array.splice(start, deleteCount, item1, item2, ...)</code></p>
<ul>
<li><code>start</code>：开始的位置下标</li>
<li><code>deleteCount</code>：删除的数量</li>
<li><code>item1, item2, ...</code>：要添加的元素</li>
</ul>
<p>&lt;Playground client=“react” code={`let team = [‘Alice’, ‘Bob’, ‘Charlie’, ‘Diana’]</p>
<p>// 场景 1：单纯插入（在 Bob 和 Charlie 之,间插入 Eve）
const removed1 = team.splice(2, 0, ‘Eve’)
console.log(‘team:’, team) // [‘Alice’, ‘Bob’, ‘Eve’, ‘Charlie’, ‘Diana’]
console.log(‘removed1:’, removed1) // [] (没有删除任何元素)</p>
<p>// 场景 2：单纯删除（删除最后一个成员 Diana）
// -1 表示从倒数第一个元素开始，删除 1 个
const removed2 = team.splice(-1, 1)
console.log(‘team:’, team) // [‘Alice’, ‘Bob’, ‘Eve’, ‘Charlie’]
console.log(‘removed2:’, removed2) // [‘Diana’]</p>
<p>// 场景 3：替换（将 Bob 替换为 Frank 和 Grace）
const removed3 = team.splice(1, 1, ‘Frank’, ‘Grace’)
console.log(‘team:’, team) // [‘Alice’, ‘Frank’, ‘Grace’, ‘Eve’, ‘Charlie’]
console.log(‘removed3:’, removed3) // [‘Bob’] (Bob 被替换出去了)
`} /&gt;</p>
<div><div><div></div><div>NOTE</div></div><div><p>① start为负数时从末尾计数；
② 返回值是被删除的元素数组；
③ 第二个参数是 “删除数量” 不是 “结束索引”</p></div></div>
<hr />
<h4>fill 用法</h4>
<p>语法：<code>array.fill(value, start, end)</code></p>
<ul>
<li><code>value</code>：要填充的值</li>
<li><code>start</code>：开始的位置下标</li>
<li><code>end</code>：结束的位置下标</li>
</ul>
<p>&lt;Playground client=“react” code={`let arr = [1, 2, 3, 4, 5]</p>
<p>// 它的意思是：用 0 填充从索引 1 开始，到索引 3 之前的位置
arr.fill(0, 1, 3)</p>
<p>console.log(arr) // [1, 0, 0, 4, 5]
`} /&gt;</p>
<div><div><div></div><div>NOTE</div></div><div><p>start/end可选，左闭右开，填充覆盖原数组元素</p></div></div>
<h3>不会修改原数组的方法</h3>
<p>这类方法返回新结果，原数组不变，易和 “修改原数组” 的方法混淆：</p>



































<table><thead><tr><th>方法</th><th>功能</th><th>易错点</th></tr></thead><tbody><tr><td><code>slice(start, end)</code></td><td>截取数组片段</td><td>① 左闭右开；② <code>start</code>/<code>end</code> 为负数从末尾计数；③ 返回新数组，原数组不变</td></tr><tr><td><code>map((item) =&gt; {})</code></td><td>遍历并返回新数组</td><td>① 必须有返回值，否则新数组全为 <code>undefined</code>；② 不会过滤元素（长度与原数组一致）</td></tr><tr><td><code>filter((item) =&gt; {})</code></td><td>过滤并返回新数组</td><td>返回值为 <code>true</code> 的元素保留，新数组长度可能小于原数组</td></tr><tr><td><code>concat(arr1, arr2)</code></td><td>拼接数组</td><td>① 可拼接多个数组或值；② 返回新数组，原数组不变（ES6 推荐使用 <code>[...arr1, ...arr2]</code>）</td></tr><tr><td><code>flat(depth)</code></td><td>扁平化数组</td><td>① <code>depth</code> 默认 1，<code>Infinity</code> 可展平任意深度；② 返回新数组，原数组不变</td></tr></tbody></table>
<h3>遍历与归并方法</h3>
<p>无返回值 / 返回特定值，这类方法重点在 “遍历逻辑”，返回值易混淆:</p>








































<table><thead><tr><th>方法</th><th>功能</th><th>易错点</th></tr></thead><tbody><tr><td><code>forEach((item) =&gt; {})</code></td><td>遍历数组</td><td>① 无返回值（默认 <code>undefined</code>）；② 无法用 <code>break</code>/<code>continue</code> 中断遍历</td></tr><tr><td><code>find((item) =&gt; {})</code></td><td>找第一个符合条件的元素</td><td>① 返回找到的元素，无则 <code>undefined</code>；② 找到后立即停止遍历</td></tr><tr><td><code>findIndex((item) =&gt; {})</code></td><td>找第一个符合条件的索引</td><td>① 返回索引，无则 <code>-1</code>；② 注意和 <code>indexOf</code> 的区别（<code>indexOf</code> 找值，<code>findIndex</code> 找条件）</td></tr><tr><td><code>every((item) =&gt; {})</code></td><td>判断所有元素是否符合条件</td><td>① 全满足返回 <code>true</code>，否则 <code>false</code>；② 有一个不满足立即停止遍历</td></tr><tr><td><code>some((item) =&gt; {})</code></td><td>判断是否有元素符合条件</td><td>① 有一个满足返回 <code>true</code>，否则 <code>false</code>；② 找到后立即停止遍历</td></tr><tr><td><code>reduce((acc, cur) =&gt; {}, init)</code></td><td>累加 / 归并数组</td><td>① <code>acc</code> 是累计值，<code>cur</code> 是当前元素；② 无初始值时，<code>acc</code> 默认第一个元素，<code>cur</code> 从第二个开始（易出错）</td></tr></tbody></table>
<h4>reduce 用法</h4>
<p>语法：<code>array.reduce(callback(accumulator, currentValue, currentIndex, array), initialValue)</code></p>
<ul>
<li><code>callback</code>：回调函数，接收四个参数：
<ul>
<li><code>accumulator</code>：累计值</li>
<li><code>currentValue</code>：当前元素</li>
<li><code>currentIndex</code>：当前索引</li>
<li><code>array</code>：原数组</li>
</ul>
</li>
<li><code>initialValue</code>：初始值（可选）</li>
</ul>
<p>&lt;Playground
client=“react”
code={<code>const arr = [1,2,3]; // 有初始值（推荐）：acc从0开始，cur依次为1、2、3 const sum1 = arr.reduce((acc, cur) =&gt; acc + cur, 0); // 6 console.log(sum1) // 无初始值：acc=1，cur=2 → acc=3，cur=3 → 最终6（空数组会报错） const sum2 = arr.reduce((acc, cur) =&gt; acc + cur); // 6 console.log(sum2) // 空数组无初始值会报错 const emptySum = [].reduce((acc, cur) =&gt; acc + cur); // TypeError</code>}
/&gt;</p>
<h3>查找与判断方法</h3>

























<table><thead><tr><th>方法</th><th>功能</th><th>易错点</th></tr></thead><tbody><tr><td><code>indexOf(value, start)</code></td><td>找元素首次出现的索引</td><td>① 返回索引，无则-1；② 无法查找NaN（[NaN].indexOf(NaN) → -1）</td></tr><tr><td><code>includes(value, start)</code></td><td>判断数组是否包含元素</td><td>① 返回布尔值；② 能识别NaN（[NaN].includes(NaN) → true）</td></tr><tr><td><code>lastIndexOf(value)</code></td><td>找元素最后出现的索引</td><td>① 从后往前找，返回索引，无则-1；② 无法查找NaN（[NaN].indexOf(NaN) → -1）</td></tr></tbody></table>]]></content>
    <category term="笔记" />
    <category term="Array" />
    <category term="JavaScript" />
  </entry>
  <entry>
    <title>React useRef 笔记</title>
    <link href="https://dnzzk2.me/posts/ref" rel="alternate" type="text/html"/>
    <id>https://dnzzk2.me/posts/ref</id>
    <updated>2025-12-05T00:00:00.000Z</updated>
    <published>2025-12-05T00:00:00.000Z</published>
    <author>
      <name>Dnzzk2</name>
    </author>
    <summary type="text">虽然写了很久的React，但是对 useRef 的认识还是不够深入，所以写一篇笔记，记录一下。</summary>
    <content type="html"><![CDATA[<img src="https://dnzzk2.me/_astro/og.za4jgzCC_Z112BOu.webp" alt="React useRef 笔记" style="width: 100%; height: auto; margin-bottom: 1em;" />
<p>本文记录我在学习 React 的过程中，所认识到的一些与 useRef 相关的知识，如果其中有什么不对的地方，可以给我<a href="mailto:dnzzk2@126.com">发邮件</a>指正，或者在评论区留言，谢谢。</p>
<h2>什么是 useRef</h2>
<p><code>useRef</code> 是一个 React Hook，它允许你引用一个不需要用于视图渲染的值。你可以把它生动地看作是一个在组件的整个生命周期内始终保持持久化的小盒子。</p>
<p>每一次调用，它都会返回一个包含 <code>current</code> 属性的可变对象。</p>
<pre><code>const ref = useRef(initialValue)
// ref 的结构：{ current: initialValue }
</code></pre>
<h2>核心特性</h2>
<ul>
<li>持久化存储：在组件的后续渲染中，<code>useRef</code> 将始终返回同一个对象引用。</li>
<li>不触发重渲染：更改 <code>ref.current</code> 属性不会触发组件的重新渲染。这使得它非常适合存储那些不影响视图的数据（如定时器 ID）。</li>
<li>DOM 访问：最常见的用法是将其作为 <code>ref</code> 属性传递给 JSX 节点，React 会自动将 DOM 节点赋值给 <code>current</code>。</li>
</ul>
<div><div><div></div><div>NOTE</div></div><div><p>这里的介绍仅作为简要概览，更详尽的 API 说明建议直接查阅 <a href="https://zh-hans.react.dev/reference/react/useRef" rel="noopener noreferrer" target="_blank">React 官方文档</a>。</p></div></div>
<h2>常见用法</h2>
<p>在了解了 <code>useRef</code> 的基础之后，下面进入几个在实际业务里更常见的用法。</p>
<h3>存储定时器 ID</h3>
<p>因为 <code>useRef</code> 的变化绝不会触发重渲染，所以它天生就适合用来“藏”那些不需要影响 UI 显示的内部状态数据，比如定时器的 ID。</p>
<pre><code>import { useRef } from 'react'

function MyComponent() {
  const intervalRef = useRef(null)

  useEffect(() =&gt; {
    intervalRef.current = setInterval(() =&gt; {
      console.log('设置定时器')
    }, 1000)

    // 清理函数
    return () =&gt; {
      clearInterval(intervalRef.current)
    }
  }, []) // 空依赖数组表示只在组件挂载时执行

  return &lt;div&gt;{/* 组件内容 */}&lt;/div&gt;
}
</code></pre>
<h3>获取列表 DOM</h3>
<p>在 React 中，<code>useRef</code> 通常用于引用单个 DOM 元素。如果你需要获取一个列表（通过 <code>.map()</code> 渲染）中所有子元素的 DOM，不能简单地把同一个 <code>ref</code> 赋给它们，因为后面的元素会覆盖前面的。</p>
<p>最佳实践是使用 <code>useRef</code> 存储一个 <code>Map</code> 对象，并通过回调 <code>Ref (Callback Ref) </code> 将每个 DOM 节点存入这个 Map 中。</p>
<p>这种方法最稳健，因为它可以通过唯一的 ID 准确找到对应的 DOM，即使列表发生排序或增删，引用关系也不会乱。</p>
<div><div><div></div><div>最佳实践</div></div><div><p>推荐使用 <code>useRef</code> 存储一个 <code>Map</code> 对象，并通过回调 Ref (Callback Ref) 将每个 DOM 节点动态存入这个 Map 中。</p></div></div>
<pre><code>import { useRef } from 'react'

function CatList() {
  const itemsRef = useRef(null)

  // 惰性初始化
  if (itemsRef.current === null) {
    itemsRef.current = new Map()
  }

  const cats = [
    { id: 1, name: 'Tom' },
    { id: 2, name: 'Jerry' },
    { id: 3, name: 'Garfield' },
  ]

  function scrollToCat(catId) {
    // 3. 使用 Map 获取指定 ID 的 DOM 节点
    const map = itemsRef.current
    const node = map.get(catId)

    if (node) {
      node.scrollIntoView({
        behavior: 'smooth',
        block: 'nearest',
        inline: 'center',
      })

      // 也可以做其他操作，比如改变样式
      node.style.backgroundColor = 'yellow'
      setTimeout(() =&gt; (node.style.backgroundColor = ''), 1000)
    }
  }

  return (
    &lt;div&gt;
      &lt;nav&gt;
        &lt;button onClick={() =&gt; scrollToCat(1)}&gt;找 Tom&lt;/button&gt;
        &lt;button onClick={() =&gt; scrollToCat(2)}&gt;找 Jerry&lt;/button&gt;
        &lt;button onClick={() =&gt; scrollToCat(3)}&gt;找 Garfield&lt;/button&gt;
      &lt;/nav&gt;

      &lt;ul&gt;
        {cats.map((cat) =&gt; (
          &lt;li
            key={cat.id}
            // 2. 使用回调 Ref
            ref={(node) =&gt; {
              const map = itemsRef.current
              if (node) {
                // 挂载时：存入 Map
                map.set(cat.id, node)
              } else {
                // React18.x 卸载时：node 为 null，从 Map 中移除
                map.delete(cat.id)
              }

              // React 19 会在卸载时执行这个函数
              return () =&gt; {
                map.delete(cat)
              }
            }}
          &gt;
            {cat.name}
          &lt;/li&gt;
        ))}
      &lt;/ul&gt;
    &lt;/div&gt;
  )
}

export default CatList
</code></pre>
<h4>自定义组件场景</h4>
<p>如果你的列表项不是原生的 HTML 标签（如 <code>&lt;li&gt;</code>），而是自定义组件（如 <code>&lt;MyListItem /&gt;</code>），你需要确保子组件使用了 <code>forwardRef</code>，否则 <code>ref</code> 无法透传到内部的 DOM 节点。</p>
<ol>
<li>子组件须使用 forwardRef 暴露 DOM：</li>
</ol>
<pre><code>import { forwardRef } from 'react'

const MyListItem = forwardRef((props, ref) =&gt; {
  return &lt;li ref={ref}&gt;{props.children}&lt;/li&gt;
})

export default MyListItem
</code></pre>
<ol>
<li>父组件使用回调分配 Map 保持不变：</li>
</ol>
<pre><code>// ... map 循环中，将 ref 传递给自定义组件
&lt;MyListItem
  key={cat.id}
  ref={(node) =&gt; {
    /* 和上面原生标签相同的 Map 存取逻辑 */
  }}
&gt;
  {cat.name}
&lt;/MyListItem&gt;
</code></pre>
<div><div><div></div><div>小结与规范</div></div><div><ul>
<li>不要尝试创建一个 Ref 的数组（如 <code>[ref1, ref2]</code>），这在 Hook 中很容易引发引用过期的问题，管理起来也十分棘手。</li>
<li>永远推荐使用 <code>useRef(new Map())</code> 配合回调 Ref：<code>ref={node =&gt; map.set(id, node)}</code>。</li>
<li>这种模式在处理各种动态复杂列表（例如：无限滚动 Infinite Scroll、拖拽排序、虚拟列表）时，既高效又符合 React 最佳实践标准。</li>
</ul></div></div>
<h3>解决“闭包陷阱”</h3>
<p>在 <code>setTimeout</code>、<code>setInterval</code> 或是原生事件监听器中，我们常常会遭遇一个令人头疼的问题：总是读取到“旧”的 State 值（也就是经典的闭包陷阱）。</p>
<div><div><div></div><div>典型痛点场景</div></div><div><p>假设你需要做一个“发送消息”的功能，用户点击发送后，系统会倒数“3秒后发送”。如果用户在这 3 秒内又急忙修改了输入框的消息内容，系统期望发送的应该是修改后的最新内容，而不是点击发送那一刻的旧内容。</p></div></div>
<pre><code>import { useState, useRef, useEffect } from 'react'

function MessageSender() {
  const [message, setMessage] = useState('')

  // 关键：创建一个 Ref 来“镜像”最新的 message
  const latestMessageRef = useRef('')

  // 每次渲染，都把最新的 state 同步给 ref
  // 这步操作是同步的，且不会触发副作用
  useEffect(() =&gt; {
    latestMessageRef.current = message
  }, [message])

  const handleSend = () =&gt; {
    setTimeout(() =&gt; {
      // 错误写法：console.log(message); // 这里永远是3秒前的值（闭包陷阱）

      // 正确写法：读取 Ref
      alert(`发送消息: ${latestMessageRef.current}`)
    }, 3000)
  }

  return (
    &lt;div className="p-4 border rounded"&gt;
      &lt;h3&gt;场景：解决异步闭包问题&lt;/h3&gt;
      &lt;input value={message} onChange={(e) =&gt; setMessage(e.target.value)} placeholder="输入消息..." /&gt;
      &lt;button onClick={handleSend}&gt;3秒后发送&lt;/button&gt;
      &lt;p className="text-sm text-gray-500"&gt;点击发送后，试着立刻修改输入框内容&lt;/p&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<h3>usePrevious：记录上次值</h3>
<p>随着重新渲染，React 的机制只负责告诉你现在的 State 是多少，却不会主动保留“上一次”是多少。</p>
<div><div><div></div><div>场景需求</div></div><div><p>设想我们正在开发一个股票趋势或数字看板。当最新数字变大时，我们需要显示带有绿色箭头的上涨特效，变小时显示红色的下跌特效。这就必须对比 <code>current</code> (当前值) 和 <code>prev</code> (上一次的值)。</p></div></div>
<pre><code>import { useState, useEffect, useRef } from 'react'

// 封装成一个通用的 Hook
function usePrevious(value) {
  const ref = useRef()

  useEffect(() =&gt; {
    ref.current = value // 在渲染完成后，记录当前值，供下一次渲染使用
  }, [value])

  return ref.current // 返回的是“上一次”的值
}

function StockTicker() {
  const [price, setPrice] = useState(100)
  const prevPrice = usePrevious(price) // 获取上一次的价格

  // 计算趋势
  let trend = ''
  if (prevPrice &amp;&amp; price &gt; prevPrice) trend = '涨了'
  if (prevPrice &amp;&amp; price &lt; prevPrice) trend = '跌了'

  return (
    &lt;div className="p-4 border rounded mt-4"&gt;
      &lt;h3&gt;场景：记录上一次的值&lt;/h3&gt;
      &lt;p&gt;当前价格: ${price}&lt;/p&gt;
      &lt;p&gt;上次价格: ${prevPrice}&lt;/p&gt;
      &lt;p&gt;趋势: {trend}&lt;/p&gt;

      &lt;button onClick={() =&gt; setPrice((p) =&gt; p + 10)}&gt;加价&lt;/button&gt;
      &lt;button onClick={() =&gt; setPrice((p) =&gt; p - 10)}&gt;降价&lt;/button&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<h3>useUpdateEffect：跳过首渲染</h3>
<p>众所周知，<code>useEffect</code> 默认在组件挂载（Mount）时也会执行一次。但很多时候，我们只想在数据更新（Update）时执行逻辑，也就是要静默跳过首次加载的触发。</p>
<div><div><div></div><div>场景需求</div></div><div><p>配置自动保存草稿功能。当用户刚进入页面时（首次渲染），表单往往是空的或包含默认值，你绝不想此时去触发一次无用的“保存草稿” API 请求；只有当用户真正修改了内容（后续渲染）后，才应该触发保存逻辑。</p></div></div>
<pre><code>import { useState, useEffect, useRef } from 'react'

function AutoSaveForm() {
  const [text, setText] = useState('')
  const isMountedRef = useRef(false) // 标记是否已经挂载

  useEffect(() =&gt; {
    // 如果是第一次渲染，将标记设为 true，然后直接结束
    if (!isMountedRef.current) {
      isMountedRef.current = true
      return
    }

    // 从第二次渲染开始，才会执行下面的逻辑
    console.log('正在保存草稿到服务器...', text)
  }, [text])

  return (
    &lt;div className="p-4 border rounded mt-4"&gt;
      &lt;h3&gt;场景：跳过首次渲染 (AutoSave)&lt;/h3&gt;
      &lt;textarea value={text} onChange={(e) =&gt; setText(e.target.value)} placeholder="开始打字以触发自动保存..." /&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<h4>useUpdateEffect Hook 写法</h4>
<pre><code>import { useState, useEffect, useRef } from 'react'

function useUpdateEffect(effect, deps) {
  const isMountedRef = useRef(false)

  useEffect(() =&gt; {
    if (!isMountedRef.current) {
      isMountedRef.current = true
      return
    }

    return effect()
  }, deps)
}

function MyComponent() {
  const [count, setCount] = useState(0)

  // 只在 count 更新时打印日志，首次渲染时不打印
  useUpdateEffect(() =&gt; {
    console.log('Count updated:', count)
  }, [count])

  return (
    &lt;div&gt;
      &lt;p&gt;Count: {count}&lt;/p&gt;
      &lt;button onClick={() =&gt; setCount(count + 1)}&gt;Increment&lt;/button&gt;
    &lt;/div&gt;
  )
}
</code></pre>
<h2>总结</h2>
<p><code>useRef</code> 的核心价值，不是“替代 state”，而是保存那些需要跨渲染保留、但又不该触发视图更新的数据。</p>
<p>如果只是操作 DOM，<code>useRef</code> 可以让你拿到元素实例；如果是处理定时器、异步回调、上一轮数据这类状态外信息，它又能提供一个稳定、可变的容器。把它用在合适的地方，代码会比“硬塞进 state”更自然，也更符合 React 的数据流设计。</p>]]></content>
    <category term="笔记" />
    <category term="React" />
    <category term="useRef" />
  </entry>
  <entry>
    <title>博客自动化部署</title>
    <link href="https://dnzzk2.me/posts/webhook" rel="alternate" type="text/html"/>
    <id>https://dnzzk2.me/posts/webhook</id>
    <updated>2025-12-02T11:20:00.000Z</updated>
    <published>2025-06-18T00:00:00.000Z</published>
    <author>
      <name>Dnzzk2</name>
    </author>
    <summary type="text">在Ubuntu服务器上配置GitHub Webhook，实现代码推送后自动拉取、构建和部署博客，告别手动登录服务器更新的繁琐流程。</summary>
    <content type="html"><![CDATA[<img src="https://dnzzk2.me/_astro/og.D0kvKFOb_1R6FSx.webp" alt="博客自动化部署" style="width: 100%; height: auto; margin-bottom: 1em;" />
<h2>序言</h2>
<p>本博客使用的是 <a href="https://astro.build/" rel="noopener noreferrer" target="_blank">Astro</a> 框架，使用静态模式生成的静态博客，这意味着博客内容，在构建的时候就已经固定了，需要更新内容的话，要重新构建。</p>
<p>如果使用的是 <a href="https://vercel.com/" rel="noopener noreferrer" target="_blank">Vercel</a> 或者 <a href="https://www.netlify.com/" rel="noopener noreferrer" target="_blank">Netlify</a> 部署，那么每次更新 Github 仓库的时候，都会自动触发一次构建，从而更新博客。</p>
<p>但是我把博客部署在了轻量云服务器上，这样就会出现一个问题，就是每次修改博客内容，都需要重新登录服务器，然后执行代码拉取、构建博客的，这样效率非常低，流程过于长。</p>
<p>这个时候就要请出 Github 的利器 <a href="https://docs.github.com/en/webhooks" rel="noopener noreferrer" target="_blank">Webhooks</a> 来解决这个问题。</p>
<h2>Webhooks</h2>
<p>Webhooks 是 Github 提供的自动化服务，支持在仓库内容变动时，向指定 URL 实时推送 <code>POST</code> 请求，实现无需人工干预的自动处理。</p>
<p>这是一种“被动推送”机制，这种推送机制的核心特点：</p>
<ul>
<li>实时：事件发生后几乎立刻推送，无需轮询。</li>
<li>自动：无需人工干预，自动处理。</li>
<li>灵活：可以自定义接收端的处理逻辑。</li>
</ul>
<h2>流程</h2>
<figure><img src="https://dnzzk2.me/_astro/webhook-light.BrzQMg0r_krebg.webp" alt="webhook-light" class="img-light" /><figcaption>流程自动化部署示意图</figcaption></figure>
<p>通过上面的流程图，可以看到自动化部署的核心步骤：</p>
<ol>
<li>Github 仓库内容变更（如 push、merge 等）；</li>
<li>Github Webhook 触发，向服务器发送 POST 请求；</li>
<li>服务器接收 Webhook，执行自动化脚本（如拉取代码、安装依赖、构建、重启服务等）；</li>
<li>部署完成，网站自动更新。</li>
</ol>
<h2>起飞</h2>
<p>先从服务器开始，再到 Github 配置 webhook，先有服务，才能更好的配置。</p>
<h3>准备工作</h3>
<p>以下两种安装 <code>nodejs</code> 和 <code>npm</code>（已安装，可以忽略）的方法，选择其一即可。</p>
<ul>
<li>普通安装方式：</li>
</ul>
<pre><code>sudo apt install nodejs npm -y
</code></pre>
<p>安装完后可以用下面命令检查版本：</p>
<pre><code>node -v
npm -v
</code></pre>
<ul>
<li>使用nvm安装：</li>
</ul>
<pre><code># 安装 nvm
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
# 重新加载 shell
source ~/.bashrc
# 安装最新版 node（会自带 npm）
nvm install node
</code></pre>
<h3>建目录</h3>
<p>目录都在放 <code>/var/www</code> 中，你可以用自己的目录替代，以下都以此目录为基准。</p>
<pre><code>www
├── project
├── webhooks
│   ├── log
│   ├── hookMain
│   └── sh
</code></pre>
<p>创建一个跟项目目录同级的 <code>/var/www/webhooks</code> 目录，用于放置 webhook 服务相关的文件和脚本。</p>
<pre><code>cd /var/www
mkdir webhooks
</code></pre>
<p>创建三个子目录， <code>log</code>、<code>hookMain</code>、<code>sh</code>，log 用于保存日志，hookMain 用于存放 express 服务，sh 用于存放 bash 脚本。</p>
<pre><code>cd webhooks
mkdir log
mkdir hookMain
mkdir sh
</code></pre>
<h3>写脚本</h3>
<p>进入 <code>sh</code> 目录，创建 <code>deploy.sh</code>:</p>
<pre><code>cd sh
nano deploy.sh
</code></pre>
<p>写入以下内容：</p>
<pre><code>#!/bin/bash
set -e

# 日志输出到指定目录
exec &gt;&gt; /var/www/webhooks/log/webhook.log 2&gt;&amp;1

echo "Starting deployment at $(date)"


cd /var/www/project
git pull origin master
npm install
npm run build

echo "Deployment completed at $(date)"
</code></pre>
<p>保存后赋予执行权限：</p>
<pre><code>chmod +x /var/www/webhooks/sh/deploy.sh
</code></pre>
<h3>写服务</h3>
<p>进入 <code>hookMain</code> 目录，初始化 npm，并安装 express、dotenv：</p>
<pre><code>cd hookMain
npm init -y
npm install express dotenv
</code></pre>
<p>创建 <code>.env</code> 文件，写入你的 GitHub Webhook 密钥（自定义，在创建 Webhook 时填写）</p>
<pre><code>echo "WEBHOOK_SECRET=你的github密钥" &gt; .env
</code></pre>
<p>创建 <code>index.js</code>，并写入以下内容：</p>
<pre><code>require('dotenv').config()
const express = require('express')
const { exec } = require('child_process')
const crypto = require('crypto')
const app = express()

// 你在 GitHub 设置的密钥
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET

app.use(express.raw({ type: 'application/json' }))

// 验证签名函数
function verifySignature(signature, payload) {
  if (!signature) return false

  const expectedSignature = 'sha256=' + crypto.createHmac('sha256', WEBHOOK_SECRET).update(payload).digest('hex')

  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))
}

app.post('/API', (req, res) =&gt; {
  const signature = req.headers['x-hub-signature-256']
  const payload = req.body

  // 验证签名
  if (!verifySignature(signature, payload)) {
    console.log('Invalid signature, rejecting webhook')
    return res.status(401).send('Unauthorized')
  }

  console.log('Webhook verified and received at:', new Date())

  // 解析 JSON 数据
  const body = JSON.parse(payload.toString())
  console.log('Repository:', body.repository?.name)
  console.log('Pusher:', body.pusher?.name)

  exec('/var/www/webhooks/sh/deploy.sh', (error, stdout, stderr) =&gt; {
    if (error) {
      console.error('Deploy error:', error)
    } else {
      console.log('Deploy success:', stdout)
    }
  })

  res.send('Deployment triggered')
})

app.listen(9001, '127.0.0.1', () =&gt; {
  console.log('Webhook server running on port 9001')
})
</code></pre>
<h3>启动服务</h3>
<p>安装pm2，并启动服务，设置开机自启动：</p>
<pre><code>npm install -g pm2

pm2 start index.js --name webhook-server
pm2 save
pm2 startup
</code></pre>
<h3>Nginx 反向代理</h3>
<pre><code>server {
    # 其他Nginx配置


    # 监听github的webhooks
    location /API {
    proxy_pass http://127.0.0.1:9001;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    }

    # 其他Nginx配置
}
</code></pre>
<h3>设置 Webhooks</h3>
<figure><img src="https://dnzzk2.me/_astro/github-light.CJsOJe2Q_Z20FSqh.webp" alt="github-light" class="img-light" /><figcaption>webhooks</figcaption></figure>
<ol>
<li>登录 GitHub，进入项目，点击 Settings，找到 Webhooks，点击 Add webhook。</li>
<li>填写 Webhook URL，选择 Content type 为 application/json，输入 Secret（之前在 <code>.env</code> 中设置的），点击 Add webhook。</li>
</ol>
<h2>结尾</h2>
<p>到这一步，使用webhooks实现自动化更新项目的流程全部完成。</p>]]></content>
    <category term="技术" />
    <category term="Webhook" />
    <category term="Ubuntu" />
  </entry>
</feed>