阅读视图

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

记住这张时间线图,你再也不会乱用 useEffect / useLayoutEffect

useEffect 和 useLayoutEffect 的区别:别背定义,按“什么时候上屏”来选

以前一直写vue,现在写react了,写react代码的时候有时候会碰到一个选择题:

  • 这个副作用用 useEffect 还是 useLayoutEffect
  • 为什么我用 useEffect 量 DOM 会闪一下?
  • Next.js 里 useLayoutEffect 为什么会给我一个 warning?

这俩 Hook 的差别,说穿了就一句:它们跑在“上屏(paint)”的前后


一句话结论(先拿走)

  • 默认用 useEffect:不会挡住浏览器绘制。
  • 只有在“必须读布局/写布局且不能闪”的时候用 useLayoutEffect:它会在浏览器 paint 之前同步执行。

如果你脑子里只留两句话,就留这两句。


它们到底差在哪:在浏览器 paint 的前后

把 React DOM 的一次更新粗暴拆成四步,你就不会混了:

flowchart LR
  A[render 计算 JSX] --> B[commit 写入 DOM]
  B --> C[useLayoutEffect 同步执行]
  C --> D[浏览器 paint 上屏]
  D --> E[useEffect 执行]

  classDef info fill:#cce5ff,stroke:#0d6efd,color:#004085
  classDef warning fill:#fff3cd,stroke:#ffc107,color:#856404

  class C warning
  class E info
  • useLayoutEffectDOM 已经变了,但还没 paint。它会阻塞本次 paint。
  • useEffect页面已经 paint 了。它不会阻塞上屏(但也意味着你在里面改布局可能会“先错后改”,肉眼看到就是闪)。

注意我在说的是“commit 后”的那个时间点,不是 render 阶段。


一个很真实的例子:测量 DOM 决定位置(useEffect 会闪)

比如你做一个 Tooltip:初始不知道自己宽高,得先 render 出来,然后用 getBoundingClientRect() 量一下,再把位置修正。

如果你用 useEffect

  • 第一次 paint:Tooltip 先用默认位置上屏
  • effect 里量完 -> setState
  • 第二次 paint:位置修正

用户看到的就是“闪一下”。如果你用 useLayoutEffect,修正发生在 paint 之前,第一帧就是对的。

下面这段代码可以直接在 React DOM 里跑(为了不违反 Hooks 规则,我写成两个组件,用 checkbox 切换时会 remount):

import React, { useEffect, useLayoutEffect, useRef, useState } from "react";

type TooltipPosition = {
  anchorRef: React.RefObject<HTMLButtonElement | null>;
  tipRef: React.RefObject<HTMLDivElement | null>;
  left: number;
};

function calcLeft(anchor: HTMLButtonElement, tip: HTMLDivElement) {
  const a = anchor.getBoundingClientRect();
  const t = tip.getBoundingClientRect();
  return Math.round(a.left + a.width / 2 - t.width / 2);
}

function useTooltipPositionWithEffect(): TooltipPosition {
  const anchorRef = useRef<HTMLButtonElement | null>(null);
  const tipRef = useRef<HTMLDivElement | null>(null);
  const [left, setLeft] = useState(0);

  useEffect(() => {
    const anchor = anchorRef.current;
    const tip = tipRef.current;
    if (!anchor || !tip) return;
    setLeft(calcLeft(anchor, tip));
  }, []);

  return { anchorRef, tipRef, left };
}

function useTooltipPositionWithLayoutEffect(): TooltipPosition {
  const anchorRef = useRef<HTMLButtonElement | null>(null);
  const tipRef = useRef<HTMLDivElement | null>(null);
  const [left, setLeft] = useState(0);

  useLayoutEffect(() => {
    const anchor = anchorRef.current;
    const tip = tipRef.current;
    if (!anchor || !tip) return;
    setLeft(calcLeft(anchor, tip));
  }, []);

  return { anchorRef, tipRef, left };
}

function TooltipFrame({ pos }: { pos: TooltipPosition }) {
  return (
    <>
      <button ref={pos.anchorRef} style={{ marginLeft: 120 }}>
        Hover me
      </button>

      <div
        ref={pos.tipRef}
        style={{
          position: "fixed",
          top: 80,
          left: pos.left,
          padding: "8px 10px",
          borderRadius: 8,
          background: "#111827",
          color: "#fff",
          fontSize: 12,
          whiteSpace: "nowrap",
        }}
      >
        I am a tooltip
      </div>
    </>
  );
}

function DemoUseEffect() {
  return <TooltipFrame pos={useTooltipPositionWithEffect()} />;
}

function DemoUseLayoutEffect() {
  return <TooltipFrame pos={useTooltipPositionWithLayoutEffect()} />;
}

export function Demo() {
  const [layout, setLayout] = useState(false);

  return (
    <div style={{ padding: 40 }}>
      <label style={{ display: "block", marginBottom: 12 }}>
        <input
          type="checkbox"
          checked={layout}
          onChange={(e) => setLayout(e.target.checked)}
        />{" "}
        用 useLayoutEffect(勾上后更不容易闪)
      </label>

      {layout ? <DemoUseLayoutEffect /> : <DemoUseEffect />}
    </div>
  );
}

真实项目里你可能还会处理 resize、内容变化(ResizeObserver)、字体加载导致的宽度变化等;但对理解这两个 Hook 的差别,上面这个例子够用了。


怎么选:我自己用的“决策口诀”

1)只要不读/写布局,就用 useEffect

典型场景:

  • 请求数据、上报埋点
  • 订阅/取消订阅(WebSocket、EventEmitter)
  • document.titlelocalStorage 同步
  • 给 window/document 绑事件

这些东西不需要卡在 paint 之前完成,useEffect 更合适。

2)你要读布局(layout read)并且会影响第一帧渲染,就用 useLayoutEffect

典型场景:

  • getBoundingClientRect() / offsetWidth / scrollHeight 这种
  • 计算初始滚动位置、同步滚动
  • 需要避免视觉抖动的“测量 -> setState”
  • focus / selection(输入框聚焦、光标定位)对首帧体验敏感

一句话:“不想让用户看到中间态”

3)别在 useLayoutEffect 里干重活

因为它会阻塞 paint:

  • 你在里面做重计算,页面就掉帧
  • 你在里面频繁 setState,可能放大卡顿

如果你只是“想早点跑一下”,但并不依赖布局,别用它。


Next.js / SSR 里那个 warning 怎么回事

在服务端渲染(SSR)时:

  • useEffect 本来就不会执行(它只在浏览器跑)
  • useLayoutEffect 也不会执行,但 React 会提示你:它在服务端没意义,可能导致你写出“依赖布局但 SSR 不存在布局”的代码

如果你写的是“浏览器才有意义的 layout effect”,又不想看到 warning,常见做法是包一层:

import { useEffect, useLayoutEffect } from "react";

export const useIsomorphicLayoutEffect =
  typeof window !== "undefined" ? useLayoutEffect : useEffect;

然后把需要 layout 的地方用 useIsomorphicLayoutEffect


容易踩的坑(顺手说两句)

  • Strict Mode 下 effect 会在开发环境额外执行一次useEffectuseLayoutEffect 都一样,别拿这个现象判断线上行为。
  • “我在 useEffect 里 setState 为什么会闪?”:因为你改的是布局相关内容,第一帧已经 paint 了。
  • 不要把数据请求塞进 useLayoutEffect:它既不需要 paint 前完成,还可能拖慢上屏。

简单总结一下

  • useEffect:大多数副作用的默认选择。
  • useLayoutEffect:只在“必须卡在 paint 前解决”的那一小撮场景里用。

真要说区别,其实就是一句:你愿不愿意为了“第一帧正确”去挡住 paint


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

我只是给Typescript提个 typo PR,为什么还要签协议?

第一次给大公司的开源项目(Typescript)提 PR,提交完 GitHub 就弹出一条评论,让你签什么 CLA:

@microsoft-github-policy-service agree

image.png 什么玩意儿?我就改了个拼写错误,还要签协议?

CLA 是什么

CLA,全称 Contributor License Agreement,翻译过来叫"贡献者许可协议"。

简单说,就是一份法律文件,你签了之后,就授权项目方可以合法使用你贡献的代码。

为什么需要这东西

想象一个场景:

张三给某开源项目提了个 PR,合并了。过了两年,张三突然跳出来说:"这段代码是我写的,你们用了我的代码,侵犯我版权,赔钱!"

项目方一脸懵:代码是你自己提交的啊?

张三:提交归提交,我没说授权你们用啊。

听起来像碰瓷,但法律上还真不好说。毕竟代码确实是张三写的,版权默认归作者。

CLA 就是为了堵这个漏洞。你签了 CLA,就相当于白纸黑字写清楚了:

  • 这代码是我自己写的(不是抄的)
  • 我授权你们用、改、分发
  • 以后不会反悔找你们麻烦

CLA 里具体写了啥

以微软的 CLA 为例,核心条款就这几条:

1. 原创声明

你保证提交的代码是你自己写的。如果包含别人的代码,要标注清楚来源和许可证。

2. 版权授权

你授予项目方永久的、全球范围的、免版税的版权许可。说白了就是:他们可以随便用,不用给你钱,也不用每次都问你。

3. 专利授权

如果你的代码涉及专利(虽然大多数情况下不会),你也授权他们使用。

4. 雇主确认

如果你是在工作中写的代码,公司可能对代码有知识产权。这种情况下,你得先拿到公司的许可才能签 CLA。

签了会怎样

签 CLA 不会让你:

  • 失去代码的版权(版权还是你的)
  • 不能在别处使用这段代码
  • 承担什么法律责任

签 CLA 只是说:

  • 项目方可以合法使用你的贡献
  • 你不会秋后算账

不同项目的 CLA

不是所有开源项目都要签 CLA,主要是大公司的项目:

公司 需要 CLA
微软
Google
Meta
Apache 基金会
个人项目 通常不需要

个人维护的开源项目一般不搞这套,太麻烦。但大公司不行,法务部不允许有法律风险敞口。

怎么签

以 GitHub 上的微软项目为例:

  1. 提交 PR
  2. 机器人会自动评论,让你签 CLA
  3. 回复:@microsoft-github-policy-service agree
  4. 搞定

就这么简单。签一次就行,以后再给同一个组织提 PR 就不用重复签了。

如果你是代表公司贡献代码,需要加上公司名:

@microsoft-github-policy-service agree company="你的公司名"

一些细节

Q:我就改了个 typo,也要签?

是的。哪怕只改了一个字符,也是贡献,也要签。

Q:签了 CLA,代码版权归谁?

版权还是你的。CLA 只是授权,不是转让。

Q:能撤回吗?

理论上你不能撤回已经合并的代码的授权。但你可以随时停止贡献。

Q:CLA 和开源许可证什么关系?

开源许可证(MIT、Apache 等)是项目对外的授权,告诉使用者可以怎么用这个项目。

CLA 是贡献者对项目的授权,告诉项目方可以怎么用贡献者的代码。

两个方向不一样。

小结

CLA 这东西,说白了就是大公司的法务需求。对贡献者来说,签一下也没什么损失,就是授权项目方合法使用你的代码。

第一次遇到可能有点懵,但理解了它的目的,就知道这是正常流程,不是什么坑。

签就完了。


如果你觉得这篇文章有帮助,欢迎关注我的 GitHub,下面是我的一些开源项目:

Claude Code Skills(按需加载,意图自动识别,不浪费 token,介绍文章):

全栈项目(适合学习现代技术栈):

  • prompt-vault - Prompt 管理器,用的都是最新的技术栈,适合用来学习了解最新的前端全栈开发范式:Next.js 15 + React 19 + tRPC 11 + Supabase 全栈示例,clone 下来配个免费 Supabase 就能跑
  • chat_edit - 双模式 AI 应用(聊天+富文本编辑),Vue 3.5 + TypeScript + Vite 5 + Quill 2.0 + IndexedDB

使用Git 实现Hugo热更新部署方案(零停机、自动上线)

Hugo 静态网站最大的优势之一,就是 构建快、部署轻。但如果你希望在每次更新文章后:

  • 一条命令就完成构建 + 部署
  • 服务器自动同步最新 public 文件
  • 整个过程不需要登录服务器
  • 也不需要 CI/CD 或额外工具

那么 基于 Git 的 Hugo Public 目录热更新方案 是最轻量、最稳定的部署方式。

这篇文章将完整介绍如何使用 Git 将 Hugo 的 public 目录推送到服务器,并通过服务器端 hook 实现热更新自动上线


为什么要用 Git 部署 Hugo ?

Hugo 的 public 目录只是纯静态文件,部署可以非常简单:

  • 不需要 Nginx reload
  • 不需要重启服务
  • 不需要 build 服务器
  • 甚至不需要 ssh 登录

只要 push 一下,就能让服务器自动同步最新 public 文件,实现真正的 Zero-Downtime 热更新


一、整体方案架构

本地电脑
├── Hugo 项目
│    ├── content/
│    ├── themes/
│    ├── public/ ← 构建后目录
│
└── git push deploy main
            ↓
服务器
└── /var/repo/hugo_public.git (bare repo)
            ↓ post-receive hook 自动触发
└── /var/www/website    ← 网站根目录(热更新)

流程:

  1. 本地执行:
hugo && git push deploy main
  1. push 到服务器的 bare 仓库(没有工作区)
  2. 服务器触发 post-receive hook
  3. 自动 checkout 最新 public 文件到 /var/www/website
  4. 网站立即更新(零停机)

二、服务器初始化部署仓库(bare repo)

服务器上执行:

mkdir -p /var/repo
cd /var/repo
git init --bare hugo_public.git

设置默认主分支(避免 “branch yet to be born”):

cd hugo_public.git
git symbolic-ref HEAD refs/heads/main

三、配置 post-receive 自动部署脚本

编辑:

/var/repo/hugo_public.git/hooks/post-receive

内容:

#!/bin/bash
set -e

echo ">>> 正在部署 Hugo 静态文件..."

WORK_TREE=/var/www/website
GIT_DIR=/var/repo/hugo_public.git

# 自动创建 main 分支(首次部署不会失败)
if ! git rev-parse --verify main >/dev/null 2>&1; then
    git checkout -b main
fi

git --work-tree=$WORK_TREE --git-dir=$GIT_DIR checkout -f main

echo ">>> 部署完成!"

赋予可执行权限:

chmod +x /var/repo/hugo_public.git/hooks/post-receive

四、本地配置 deploy 远程仓库

进入 Hugo 项目目录:

cd public
git init
git add .
git commit -m "init public"

添加服务器地址:

git remote add deploy ssh://ubuntu@服务器IP/var/repo/hugo_public.git

五、发布流程:一条命令完成构建 + 部署

你可以直接用两段式:

hugo
cd public
git add .
git commit -m "update"
git push deploy main

或者把它合成一个命令:

hugo && (cd public && git add . && git commit -m "update" && git push deploy main)

服务器自动执行:

>>> 正在部署 Hugo 静态文件...
>>> 部署完成!

网站秒级更新


六、方案优势(为什么说这是“热更新”?)

优势 说明
🟢 零停机 静态文件直接覆盖,无需 reload
🟢 不需登录服务器 所有操作本地 push 完成
🟢 不需 CI/CD 无 GitHub Actions / GitLab Runner
🟢 安全 仅开放 SSH
🟢 高速 Hugo 构建 + Git push 极快
🟢 可回滚 服务器 public 目录有完整 Git 历史

这是部署 Hugo 最轻便、最优雅的方法之一。


七、总结

本方案的核心逻辑:

  • 本地 push public → 服务器自动 checkout
  • 利用 bare 仓库 + hook
  • 完整的版本控制
  • 热更新、零停机

如果你正在运营博客、文档站、产品官网,这是目前最强的 Hugo 私有部署方案。

Next.js SSR 项目生产部署全攻略

Next.js 是 React 生态中非常流行的前端框架,支持 SSR(Server-Side Rendering)API 路由静态生成。在开发阶段,Next.js 可以通过 next dev 运行,但在生产环境下,我们需要打包并部署 SSR 服务。本文将介绍完整的 Next.js SSR 生产部署流程,适用于 1Panel 或 VPS 服务器。


一、 本地准备

假设你的 Next.js 项目目录如下:

package.json
package-lock.json / yarn.lock
pages/
public/
components/
next.config.js
node_modules/

SSR 项目需要 Node.js 后端支持,因此生产环境必须有 Node.js 环境。


二、安装依赖

如果使用 npm:

npm install

如果使用 yarn:

yarn install

确保依赖完整,尤其是 nextreactreact-dom


三、 构建生产版本

Next.js 提供 next build 命令生成生产构建产物:

npm run build
# 或者
yarn build
  • 会生成 .next/ 目录,包含 SSR 所需文件
  • 构建完成后可以通过 next start 启动生产服务

四、 测试生产环境(可选)

npm start
# 或者
yarn start
  • 默认端口 3000
  • 确认 SSR 页面可以正常访问

五、准备上传文件

生产部署至少需要以下内容:

package.json
package-lock.json / yarn.lock
.next/        # SSR 构建产物
public/       # 静态资源
node_modules/ # 可上传,也可服务器安装

可以打包成 ZIP 上传:

zip -r next-prod.zip package.json package-lock.json .next public .env

六、上传到服务器 / 1Panel

  • 1Panel:直接上传 ZIP 或 Git 克隆项目
  • VPS / 云服务器:通过 scp 上传
scp next-prod.zip user@yourserver:/path/to/app
ssh user@yourserver
unzip next-prod.zip -d next-app
cd next-app

七、安装生产依赖(如果未上传 node_modules)

npm install --production
# 或者
yarn install --production

SSR 生产环境只需要安装 dependencies,不需要 devDependencies


八、配置环境变量

Next.js SSR 项目常用环境变量:

NODE_ENV=production
PORT=3000
NEXT_PUBLIC_API_URL=https://api.example.com

可以在服务器环境中设置,也可以使用 .env.production 文件


九、启动生产 SSR 服务

方式一:直接 Node.js

npm start
# 或者
yarn start
  • 访问 http://你的域名或公网IP:3000
  • 页面由服务器渲染并返回 HTML(SSR)

方式二:PM2 守护

npm install -g pm2
pm2 start npm --name "next-app" -- start
pm2 save
pm2 startup
  • 保证服务器重启后自动启动
  • 提供日志管理和进程监控

方式三:Docker 部署(可选)

Dockerfile 示例

FROM node:20-alpine

WORKDIR /app
COPY package*.json ./
RUN npm install --production
COPY . .
RUN npm run build
CMD ["npm", "start"]
EXPOSE 3000

构建 & 运行:

docker build -t next-app .
docker run -d -p 3000:3000 next-app

十、 可选:Nginx 反向代理 + HTTPS

server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}
  • 配合 Let's Encrypt 可免费启用 HTTPS
  • 提高安全性与稳定性

十一、完整部署流程总结

  1. 本地 npm install / yarn install
  2. npm run build / yarn build 生成 .next/
  3. 上传 .next/ + public/ + package.json + node_modules(可选)
  4. 服务器安装依赖(如需要)
  5. 配置环境变量
  6. 使用 npm start / PM2 / Docker 启动
  7. 可选 Nginx 反向代理 + HTTPS
  8. 访问页面,确认 SSR 正常渲染

十二、 小技巧

  • 端口管理:1Panel 默认分配公网端口,确保 PORT 与之匹配
  • 守护进程:PM2 或 Docker 保证 SSR 服务稳定运行
  • 环境变量:生产环境变量一定要配置正确,否则 SSR 可能报错
  • 安全与性能:建议 Nginx 反向代理 + HTTPS

通过以上步骤,你可以快速将 Next.js SSR 项目 打包、上传,并在 1Panel 或 VPS 上运行生产环境,实现服务端渲染。

为什么浏览器能看懂网页代码?——从HTML到渲染引擎的奇幻之旅

🌐 为什么浏览器能看懂网页代码?——从HTML到渲染引擎的奇幻之旅 💻

欢迎大家来到今日份的无限大博客,今天又又又又是一期计算机十万个为什么系列的文章

让我来带领你开启今日份的学习吧

当你在浏览器地址栏输入 https://www.baidu.com 并按下回车时,一场精彩的"魔法表演"就开始了——短短几百毫秒后,原本枯燥的代码就变成了色彩斑斓的网页。

这就像你给了厨师一堆生食材(代码),他瞬间就端出了一道美味佳肴(网页)。🍳 浏览器到底是怎么做到的?今天咱们就来揭开这个"魔法"的神秘面纱!

🚦 网页加载的"流水线":从输入URL到看到网页

想象一下,浏览器加载网页就像工厂生产产品,有一条完整的"流水线"。咱们来看看这条流水线的每个环节:

1. 🔍 DNS解析:找到服务器的"电话号码"

当你输入 baidu.com 时,浏览器首先要问:"这个域名对应的服务器IP地址是多少?"这就像你要给朋友打电话,得先查他的电话号码。

DNS解析的过程就像查电话簿:

  • 先问本地DNS缓存(手机通讯录)
  • 再问ISP的DNS服务器(小区物业)
  • 最后问根域名服务器(国家电信总局)

整个过程通常只需要几毫秒,快得就像你从通讯录里找到朋友的电话!📞

2. 🛰️ TCP连接:建立"数据高速公路"

拿到IP地址后,浏览器和服务器之间会建立一条TCP连接——这就像在两地之间修了一条高速公路,数据可以在上面快速传输。

TCP连接的建立需要"三次握手":

  1. 浏览器:"你好,我想连接你!" 👋
  2. 服务器:"你好,我收到了!" 👌
  3. 浏览器:"好的,咱们开始传输数据吧!" 🚀

这三次握手确保了连接的可靠性,就像你和朋友确认"喂?喂?能听到吗?"一样。

3. 🔄 HTTP请求:发送"购物清单"

连接建立后,浏览器会向服务器发送HTTP请求——这就像你给超市发了一份购物清单,上面写着你想要的网页内容。

请求里包含了很多信息:

  • 请求方法(GET/POST):是想买东西还是退货?
  • 请求头:你的浏览器类型、支持的格式等
  • 请求体:你要提交的数据(比如登录信息)

4. 📦 HTTP响应:收到"快递包裹"

服务器收到请求后,会根据请求内容准备响应——这就像超市根据你的清单打包商品,然后快递给你。

响应里也包含了很多信息:

  • 状态码:200表示成功,404表示找不到页面
  • 响应头:内容类型、长度、编码等
  • 响应体:网页的实际内容(HTML/CSS/JS等)

5. 🧙 渲染引擎:"魔法厨师"的表演

收到服务器的响应后,浏览器的渲染引擎就开始工作了——这是整个过程中最精彩的部分!渲染引擎就像一位"魔法厨师",把各种"食材"(HTML/CSS/JS)变成一道"美味佳肴"(网页)。

🧠 渲染引擎:浏览器的"大脑"

目前主流的渲染引擎有两个:

  • WebKit:Safari和早期Chrome使用 🍎
  • Gecko:Firefox使用 🦊
  • Blink:现在Chrome和Edge使用(基于WebKit) 🔴

不管是哪种渲染引擎,它们的工作原理都差不多,主要分为以下几个步骤:

1. 📄 HTML解析:构建DOM树

首先,渲染引擎会把HTML代码解析成一个树形结构——DOM树(Document Object Model)。

想象一下,HTML代码就像一本家谱:

<html>
  <head>
    <title>我的网页</title>
  </head>
  <body>
    <h1>欢迎来到我的博客</h1>
    <p>这是一段正文</p>
  </body>
</html>

解析后生成的DOM树就像这样:

  • html 是根节点
  • headbody 是它的子节点
  • titleh1p 是孙子节点

就像一个大家庭,每个标签都是一个家庭成员,有着明确的层级关系。👨‍👩‍👧‍👦

2. 🎨 CSS解析:构建CSSOM树

接下来,渲染引擎会解析CSS代码,生成CSSOM树(CSS Object Model)。

CSSOM树记录了每个元素的样式信息:

  • 颜色、字体、大小
  • 边距、padding
  • 定位方式
  • 等等

这就像给每个家庭成员穿上不同的衣服,有的穿西装,有的穿T恤,有的穿裙子。👗👔

3. 🌳 构建渲染树

有了DOM树和CSSOM树,渲染引擎会把它们合并成一棵渲染树(Render Tree)。

渲染树只包含可见的元素:

  • 会忽略 display: none 的元素
  • 会忽略 <head> 里的元素(除非有可见的内容)
  • 会应用CSS样式到每个可见元素

这就像在拍照前,只让穿好衣服的家庭成员站成一排,准备合影。📸

4. 📏 布局(Layout):计算每个元素的位置和大小

接下来是布局阶段,渲染引擎会计算每个元素在页面上的确切位置和大小。

这个过程也叫"重排"(Reflow),它会考虑:

  • 元素的尺寸
  • 元素的位置
  • 元素的外边距和内边距
  • 父元素的约束
  • 等等

就像在布置舞台,导演会精确计算每个演员的站位和移动路线。🎭

5. 🎨 绘制(Paint):给元素上色

布局完成后,渲染引擎会开始绘制阶段,将渲染树转换为屏幕上的像素。

绘制过程会按照一定的顺序进行:

  • 背景色
  • 边框
  • 文字
  • 阴影
  • 等等

就像画家作画,先画背景,再画主体,最后画细节。🖌️

6. 🔄 合成(Composite):将图层合并

最后是合成阶段,渲染引擎会将所有绘制好的图层合并成一个完整的页面。

现代浏览器会使用硬件加速(GPU)来完成这个过程,这样可以提高性能。就像电影后期制作,把不同场景的胶片合成一部完整的电影。🎬

🚀 渲染引擎工作流程图

步骤 名称 作用 比喻
1 HTML解析 生成DOM树 写家谱
2 CSS解析 生成CSSOM树 穿衣服
3 构建渲染树 合并DOM和CSSOM 排合影
4 布局 计算位置和大小 舞台布置
5 绘制 填充像素 画家作画
6 合成 合并图层 电影后期

💡 性能优化:让网页"飞"起来

网页加载速度对用户体验至关重要。研究表明:

页面加载时间每增加1秒,转化率下降7%,用户满意度下降16%! 📊

那么,我们该如何优化网页性能呢?

1. ⚡ 减少HTTP请求:少跑腿,多办事

每一个HTTP请求都需要建立连接、传输数据、断开连接,这需要时间。减少HTTP请求可以显著提高加载速度。

优化方法

  • 合并CSS和JavaScript文件
  • 使用CSS Sprites合并小图标
  • 内联关键CSS
  • 减少不必要的图片和脚本

就像你去超市买东西,一次买齐所有东西比跑十次超市要快得多!🛒

2. 📦 压缩资源:给文件"减肥"

压缩HTML、CSS、JavaScript文件可以减少文件大小,加快传输速度。

优化方法

  • 使用gzip或brotli压缩文本文件
  • 压缩图片(JPEG优化、PNG压缩、WebP格式)
  • 移除不必要的代码和注释

就像你寄快递,把东西压缩打包后,不仅省钱,还能更快送达!📮

3. 🌐 使用CDN:就近发货

CDN(内容分发网络)可以将你的静态资源分发到全球各地的服务器上,用户可以从离他最近的服务器获取资源。

优化效果

  • 减少网络延迟
  • 提高资源加载速度
  • 减轻源服务器压力

就像你在网上购物,选择本地仓发货,第二天就能收到商品!🚚

4. ⏳ 懒加载:按需加载

懒加载是指只加载用户当前可见区域的内容,当用户滚动页面时再加载其他内容。

适用场景

  • 长页面的图片
  • 视频内容
  • 列表页的分页内容

就像你去餐厅吃饭,服务员不会一次性把所有菜都端上来,而是按需上菜!🍽️

5. 🚫 避免重排和重绘:减少不必要的操作

重排(Layout)和重绘(Paint)是性能杀手,我们应该尽量避免。

避免方法

  • 不要频繁修改DOM样式
  • 使用transform和opacity来做动画(只会触发合成,不会触发重排和重绘)
  • 批量修改DOM
  • 使用documentFragment

就像你在拍照时,不要频繁让模特换姿势,一次性摆好姿势拍照更高效!📸

🎯 不同浏览器的"魔法厨师"

不同的浏览器有不同的渲染引擎,它们就像不同风格的厨师,做出的"菜"味道略有不同:

浏览器 渲染引擎 特点 比喻
Chrome Blink 速度快,兼容性好 快餐连锁店,高效便捷
Firefox Gecko 开源,安全,灵活 私房菜馆,注重品质
Safari WebKit 流畅,适合苹果设备 法式餐厅,精致优雅
Edge Blink 现代,整合微软服务 新派餐厅,融合创新
IE Trident 古老,兼容性差 传统老店,逐渐淘汰

🧪 趣味实验:亲眼见证渲染过程

咱们来做个小实验,看看浏览器是如何渲染网页的。打开Chrome浏览器,按F12打开开发者工具,点击"Performance"标签,然后点击"Record"按钮,刷新页面。

你会看到一个详细的渲染时间线,上面清楚地显示了每个阶段的耗时:

  • DNS Lookup
  • Initial Connection
  • Request/Response
  • DOMContentLoaded
  • Load

这就像你看电影的进度条,能清楚地知道每个环节用了多长时间!⏱️

🔮 未来:渲染引擎的发展趋势

随着Web技术的发展,渲染引擎也在不断进化:

  1. 🖼️ WebAssembly:让浏览器能运行接近原生速度的代码,适合游戏和复杂应用
  2. ⚡ 渲染优化:更好的硬件加速,更智能的渲染策略
  3. 📱 响应式设计:更好地支持不同设备和屏幕尺寸
  4. 🔒 安全性:更强的沙箱机制,更好的恶意代码防护

未来的渲染引擎会越来越智能,网页加载速度会越来越快,用户体验也会越来越好!🚀

🎓 互动时间:你答对了吗?

来做个小测验,看看你对浏览器渲染了解多少:

问题 答案 你答对了吗?
浏览器渲染的第一步是什么? HTML解析 ✅/❌
display: none的元素会被包含在渲染树中吗? 不会 ✅/❌
重排和重绘哪个更消耗性能? 重排 ✅/❌
CSS Sprites的作用是什么? 减少HTTP请求 ✅/❌
CDN的中文全称是什么? 内容分发网络 ✅/❌

🎯 结语:浏览器的"魔法"其实很简单

浏览器能看懂网页代码,靠的不是魔法,而是一套复杂但有序的工作流程。从DNS解析到最终渲染,每个环节都经过了精心设计和优化。

下次当你在浏览器中看到一个精美的网页时,不妨想一想:

在这背后,有多少工程师的心血,有多少技术的积累,有多少优化的努力。

就像我们看到的每一道美味佳肴,背后都有厨师的精心准备和烹饪技巧。浏览器的"魔法",其实是人类智慧的结晶!✨


💬 互动话题

  1. 你遇到过的最慢的网页加载时间是多少?
  2. 你知道哪些提升网页性能的小技巧?
  3. 你觉得未来的浏览器会是什么样子?

快来评论区聊聊你的想法!💬 点赞收藏不迷路,咱们下期继续探索计算机的"十万个为什么"!🎉

关注我,下期带你解锁更多计算机的"奇葩冷知识"!🤓

✨TRAE SOLO + Holopix AI | 复刻 GBA 游戏-"🐛口袋妖怪"

1. 引言

😄 不知不觉,今年已经用「AI编程工具 + Holopix AI 」复刻了好几款游戏:塔防转刀射击自走棋

😶 但,都不是我喜欢的,我更怀念读书时的 "白月光" —— GBA《口袋妖怪(绿宝石)》

读初中那会,同学买了 GBA,软磨硬泡,才愿意晚上借我回家玩。🤡 有 "网瘾史",父母不给玩游戏,等他们 "查房" 完,透过门缝看外面的灯关了,确认没动静后,才敢偷偷拿出来玩,有时没注意时间,一玩就是一个通宵,🤣 记得有次理发,钓🐟钓着钓着睡过去了。

唉,年轻真好啊,通宵几天都吃得消,现在不行了,晚上一两点睡,第二天起来浑身难受...


AI 发展迅猛,今年的尾声,借助【TRAE SOLO + Holopix AI】来复刻这款游戏,试着找回当年那个 "无忧无虑" 的自己👶~

2. SOLO Time

上节重构两项目用的 AgentSOLO Coder,这节是 "创意快速落地",所以用 SOLO Builder

Vibe Coding 关键是 "文档先行" ❗️ 开发过程的每一步都要围绕 "文档" 进行,先让 AIPlan 文档,你 审阅校对编辑 没问题后,才让 AI 照着文档开始干活。

这样做 "把控性" 更强,你能清楚知道 AI 要怎么做,而不是 "抽卡",全靠 AI 自己发挥。产品开发的起点是 PRD (产品需求文档),游戏则是 GDD (游戏设计文档),先写 PromptTrae 对 "原始需求" 进行细化。

SOLO Builder 模式没有 Plan 开关,需要在 Prompt 中写明生成 GDD 文档,不然它有时会直接开始 "堆代码":

生成结果:

让我们确认几个 "立项目标"-复刻程度、技术栈选择、美术与资源,简单做下 "填空题",Prompt 后面同样要加上 "先不要写代码,理解完需求,更新GDD文档":

再次确认文档是否有误,比如:技术栈是否对味~

🤔 接着,直接让 Trae 按照这个 GDD 文档来干活吗?可以,但我倾向于再 "",先做 "可行性的快速验证",而不是直接一股脑干到头,这样省 Token效率更佳,有不对的可以 快速调整

😏 不够,我还要玩更 "骚" 的 "多Agent并行",把 Trae 的效率拉爆,写 Prompt大任务 拆成多个 "可并行执行的小任务",然后还是得生成 "文档":


Trae 拆解成了 4 个小的 Task,并告知了我 "建议执行顺序":

结构目录不是很好 (没分层),让它调整一下:

接着就是点击左上角的 " + 新任务",然后在每个 Agent 里写不同的 Prompt:"执行xxx.md":

🤣 三架马车,并驾齐驱,何其壮观,泰裤辣!2333,就是 Token 烧得有点快...

😄 "简陋" 游戏雏形有了,接着,可以让 Trae 自检任务完成情况:

也可以让它基于 GDD 生成下一步的 Plan,然后就是重复上面的 "套路" 拆解任务分步执行, 💁‍♂️人靠衣裳马靠鞍,同时请出我们的老朋友「Holopix AI」来生成 UI素材,对游戏进行 "美化"~

3. Holopix AI 生成素材

3.1. 定制一个 "GBA像素风" 模型

😀 既然是想 "复刻",那 "美术风格" 妥妥得搞成 "GBA像素风",Holopix AI 提供了大量不同风格的 "预设模型",随手搜了下 "像素",都有 20+ 种:

😃 不过,杰哥想更加 "原汁原味",Holopix AI ****是支持 "模型定制" 的,还记得上节 "坤坤自走棋",我们用网上搜罗到的 "ikun小黄鸡" 图片作为素材,训练了一个专用用来生成小黄鸡的 "ikun" 模型吗?

😄 这里我们 照葫芦画瓢,训练一个 "GBA像素风" 的模型,搜了下 "口袋妖怪素材":

发现都是这样的 "精灵表" (纹理图集),这是 2D游戏开发 的标准做法,为的是 "性能与效率":

GBA的图形芯片 (PPU) 没有 "加载图片" 的概念,它只能读取连续的显存块。开发者把所有角色动画帧拼成一张图,通过修改 UV坐标 (读取位置) 来切换帧。切换动画帧只需改变内存偏移量,不消耗CPU,而且一张512x512的图集仅占256KB,而100张独立图片会因内存对齐浪费30%空间。

即使今天硬件强大,"图集" 仍是最佳实践,表现在:

  • 减少Draw Call:渲染100个独立图片=100次Draw Call;渲染1张图集=1次Draw Call。GPU批量渲染,帧率提升5-10倍。
  • 动画管理:动画软件能直接输出图集+坐标数据,游戏引擎可自动识别,无需手动切割和配置。
  • 动态合批:游戏引擎会将零散小图在运行时合并成图集 (VRAM),提供预制图集可跳过这一步,启动更快。
  • 像素完美:独立图片导入时可能因压缩产生1像素透明边,图集一次导出,所有帧共用统一调色板,杜绝色差。

😁 扯得有点远了,接着写 PromptTRAE哈基米3 写脚本切下图:

有点切歪了,问题不大,截图发它重新切,顺手 去掉蓝色背景 + 调整图片分辨率256x256,这是 训练模型 用到素材图片的 "最小分辨率":

打开 Holopix AI 官网,点击左侧 "模型定制" → "开始模型训练":

训练类型选 "icon道具",训练强度选 "",然后点击 上传图片

选中我们的宝可梦素材 (最多200张):

提交后,等模型训练完,会有消息通知,一般要 2-10h

收到成功通知后,可到 "模型定制" → "我的模型" 找到训练好的模型,点击 开始创作

随便输几个描述词看看效果:皮卡丘、绿毛龟、绿色喷火龙,水箭龟:

😳 卧槽,"DNA" 动了!然后,现在这个模型只有 "自己可见",好东西肯定要分享的,接着发布一下模型:

做下填空,然后 示例图 直接选 "从创作记录导入",选几张还凑合的,点击发布,然后别人就能搜到你的模型了:

3.2. 宝可梦 & 道具 & 角色

😆 接着用我们上面训练的模型来生成 "宝可梦" 和 "道具",生图 Prompt 可以让 Trae 生成一个素材清单:

不过这种批量生成 Prompt 的一般都比较 "简陋",可以用 Holopix AI 提供的 "智能优化" 进行润色:

接着就是重复 "抽卡",选择中意的素材了,部分生成素材:

接着让 Trae 应用看下效果:

3.3. 建筑

🤔 起始城镇 "主角的家" 和 "博士研究所" 分别占据 3x35x3 的格子,没法用像素块拼接...直接找个像素模型,这里用的「等距像素化建筑模型 | HOLO-V1」:

Prompt 直接输入 3x3的房子,让 Holopix AI 进行智能优化,接着翻译为英文,接着微调下:

4. 最终效果

Holopix AI 生成的素材全部应用上,加上反复跟 Trae Vibe 的最终效果:

💁‍♂️ 捣鼓了一早上,就复刻了 "口袋妖怪" 的 "基本玩法" (虽然有点简陋,还有各种BUG🤣),归功于:

  • Holopix AI:依旧保持稳定快速,高质量的游戏素材输出。
  • Gemini 3 Pro:当之无愧编程能力最强的 "前端之王LLM"。
  • TRAE SOLO:同时驱使 多 Agent 并行干活太爽了,😆 就是 Token 烧得飞快...

😏 AI 时代,人人都是 "创造家",你有创意你就来,赶紧动手试试吧~

VUE中使用AXIOS包装API代理

VUE中使用AXIOS包装API代理

0. 背景

在VUE开发过程,与后端接口交互时,可以简单几句代码就剋调用后端POST或者GET请求. 实际效果就是,前端定义一个对象

{
  getPageList: 'GET /pagelist',
  commitData: 'POST /savedata',
  getDetail: 'GET /detail/{id}',
}

然后在业务代码中就可以调用getPageList方法,实际效果就是发送一个GET请求,请求地址为/pagelist 常用场景如下:

  • api.getPageList("gameName=地心侠士&gameType=小游戏') 会转换成GET请求,参数在URL上 /pagelist?gameName=地心侠士&gameType=小游戏
  • api.commitData({gameName:"地心侠士",gameType:"小游戏"}) 会转换成POST请求,参数通过JOSN提交 /savedata
  • api.getDetail({id:1}) 会转换成GET请求,参数在URL上 /detail/1

1. 整合全局axios配置

整合axios主要是配置一些全局的请求,响应,以及请求头,请求超时配置等.全局配置代码request.js如下:

import axios from 'axios';
import loading from './loading';
const ENV = import.meta.env;
const { VITE_GLOB_API_URL } = ENV;
let req = axios.create({
  baseURL: VITE_GLOB_API_URL || '',
  timeout: 30000,
  params: {'g.type': '1'},
  headers:{'g.type': '1'},
});
// 请求拦截 公众号 小满小慢
req.interceptors.request.use((cfg) => {
  loading.showLoading();
  return cfg;
});
// 响应拦截 公众号 小满小慢
req.interceptors.response.use(
  (resp) => {
    loading.hideLoading();
    if (resp.data.code && resp.data.code != '0') {
      // 全局拦截错误信息
      loading.showError(resp.data.message);
      return Promise.reject(resp.data);
    }
    return resp;
  },(error) => {
    loading.hideLoading();
    if (error.response && error.response.data) {
      loading.showError(error.response.data.message);
    }
    return Promise.reject(error);
  },
);
export default {
  request: req.request, 
};

2. 创建API请求包装器

请求包装器主要有以下作用

  • 请求参数处理
  • 通用接口暴露

实际效果可以把 GET /pagelist 暴露成一个可以调用的方法 ,创建API请求包装器apiconvert.js如下:

import req from './request.js';
export function convertApi(apis) {
  const ENV = import.meta.env;
  const { VITE_GLOB_API_URL } = ENV;
  const api = {};
  for (const key in apis) {
    const apiInfos = apis[key].split(' ');
    const [method, apiUrl] = apiInfos;
    let base = VITE_GLOB_API_URL;
    if (key == 'ajax') base = '/';
    api[key] = (data) => {
    return new Promise((resolve, reject) => {
      let targetUrl = apiUrl;
      if (method === 'GET' && data && typeof data === 'string') {
        // get请求参数处理 公众号 小满小慢
        data = encodeURI(data);
        const index = targetUrl.indexOf('?');
        if (index === -1) {
          data = `?${data}`;
        }
        targetUrl = `${targetUrl}${data}`;
        data = null;
      }
      if (/{\w+}/.test(targetUrl)) {
        targetUrl = targetUrl.replace(/{(\w+)}/gi, function (match, p) {
          return data[p] || '0'; 
        });
        console.log(`change url:${targetUrl}`);
      }
      req.request({ method: method, url: targetUrl, data: data,baseURL: base})
        .then((res) => resolve(res.data))
        .catch((error) => {
          reject(error);
        });
    });
    };
  }
  return api;
}
// 暴露一个通用接口
const api = convertApi({
  ajax: 'GET /commonDataProc',
});

export default api;

3. 使用API请求包装器

实际的业务接口可以通过键值对的方式配置,然后通过convertApi方法进行转换,转换后的接口可以调用. 如下:

  • 'GET /pagelist'
  • 'POST /savedata'

实际业务接口biz_api.js 定义如下

import commapi, { convertApi } from '@/assets/js/apiconvert';
const api = convertApi({
  // 这里可以扩展业务接口
  getPageList: 'GET /pagelist',
});
// 合并通用接口
Object.assign(api, commapi);
export default api;

4. 使用业务接口

实际业务代码中,通过import api from '@/assets/js/biz_api'引入业务接口,然后调用业务接口即可.

import api from './biz_api.js'
const data = ref([]);
const loadingStatus = ref(true);
async function getPages() {
  const res = await api.getPageList();
  let arr = [];
  for (let i in res) {
    arr.push(res[i]);
  }
  data.value = arr;
  loadingStatus.value = false;
}
onMounted(() => {
  getPages()
});

5. 总结

通过以上封装后,前端调用后端的API清晰明了.api定义在单独的文件,也可以自由组合. 从设计上来说,主要使用了两层代理转换. 所有还是印证那句话,一层代理解决不了问题,那就再加一层. 以上仅为个人项目落地总结,若有更优雅的方式,欢迎告知.微信公众号:小满小慢 私信或者直接留言都可以. 原文地址 mp.weixin.qq.com/s/aqHVyq_I3…

基于 Body 滚动的虚拟滚动组件技术实现

前言

在现代 Web 应用中,树形结构是一种常见的数据展示方式,广泛应用于文件管理、组织架构、菜单导航等场景。然而,当树节点数量达到成千上万时,传统的全量渲染方式会导致严重的性能问题。本文将分享一个基于 React 实现的高性能虚拟滚动树组件,特别是其使用 Body 滚动条控制虚拟滚动的创新实现方案。

功能亮点

1. 🚀 高性能虚拟滚动

  • 按需渲染:只渲染视口内可见的节点,大幅减少 DOM 数量
  • 动态计算:实时计算可见范围,支持数万节点流畅滚动
  • 智能预加载:通过 overscan 参数预渲染视口外的节点,避免滚动时的白屏

2. 📏 不定高节点支持

  • 自适应高度:每个节点可以有不同的高度
  • ResizeObserver 监听:自动检测节点高度变化并更新缓存
  • 精确定位:基于高度缓存精确计算每个节点的位置

3. 🎯 Body 滚动条控制

这是本组件的核心创新点

  • 全局滚动体验:使用页面的原生滚动条,而非组件内部滚动
  • 无缝集成:树组件可以与页面其他内容(如表单、卡片)自然融合
  • 单一滚动条:整个页面只有一个滚动条,符合用户习惯

4. 🎨 拖拽排序

  • 直观交互:支持节点拖拽重新排序
  • 三种放置模式:before(前面)、after(后面)、inside(内部)
  • 视觉反馈:拖拽过程中提供清晰的视觉指示

5. 🌲 完整的树操作

  • 展开/收起:支持单个节点或全部节点的展开收起
  • 节点点击:自定义节点点击事件处理
  • 图标定制:支持自定义节点图标

技术实现原理

核心架构

┌─────────────────────────────────────┐
│         Window (Body Scroll)        │
│  ┌───────────────────────────────┐  │
│  │      Form Area (Fixed)        │  │
│  └───────────────────────────────┘  │
│  ┌───────────────────────────────┐  │
│  │   Virtual Tree Container      │  │
│  │  ┌─────────────────────────┐  │  │
│  │  │  Visible Node 1         │  │  │ ← 视口内
│  │  │  Visible Node 2         │  │  │
│  │  │  Visible Node 3         │  │  │
│  │  ├─────────────────────────┤  │  │
│  │  │  (Hidden Nodes)         │  │  │ ← 虚拟占位
│  │  │  Total Height: 10000px  │  │  │
│  │  └─────────────────────────┘  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

1. Body 滚动监听机制

这是本组件最具特色的技术实现:

useEffect(() => {
  const handleScroll = () => {
    if (!containerRef.current) return;
    
    const rect = containerRef.current.getBoundingClientRect();
    const containerTop = rect.top;
    
    // 计算容器相对于视口的滚动位置
    // 如果容器顶部在视口上方,scrollTop为正值
    const newScrollTop = Math.max(0, -containerTop);
    setScrollTop(newScrollTop);
  };

  // 初始化滚动位置
  handleScroll();
  
  // 监听window滚动事件
  window.addEventListener('scroll', handleScroll, { passive: true });
  
  return () => {
    window.removeEventListener('scroll', handleScroll);
  };
}, []);

关键点解析

  • 监听 window.scroll 事件而非容器的 scroll 事件
  • 通过 getBoundingClientRect() 获取容器相对于视口的位置
  • 当容器顶部滚出视口时(rect.top < 0),计算出虚拟的 scrollTop
  • 使用 passive: true 优化滚动性能

2. 可见范围计算

基于 Body 滚动位置计算哪些节点应该被渲染:

const visibleRange = useMemo(() => {
  if (positions.length === 0) {
    return { start: 0, end: 0 };
  }

  const rect = containerRef.current.getBoundingClientRect();
  const viewportHeight = window.innerHeight;
  
  // 计算视口内可见的范围
  const viewportTop = Math.max(0, -rect.top);
  const viewportBottom = viewportTop + viewportHeight;
  
  // 找到第一个可见节点
  let start = 0;
  for (let i = 0; i < positions.length; i++) {
    if (positions[i].top + positions[i].height >= viewportTop) {
      start = Math.max(0, i - overscan);
      break;
    }
  }
  
  // 找到最后一个可见节点
  let end = positions.length - 1;
  for (let i = start; i < positions.length; i++) {
    if (positions[i].top > viewportBottom) {
      end = Math.min(positions.length - 1, i + overscan);
      break;
    }
  }
  
  return { start, end };
}, [positions, scrollTop, overscan]);

算法优势

  • 基于视口高度和容器位置动态计算
  • 支持 overscan 预渲染,提升滚动流畅度
  • 使用二分查找可进一步优化(当前为线性查找)

3. 不定高节点处理

每个节点的高度可能不同,需要精确测量和缓存:

// 使用 ResizeObserver 监听高度变化
useEffect(() => {
  if (!nodeRef.current) return;

  const resizeObserver = new ResizeObserver((entries) => {
    for (const entry of entries) {
      const height = entry.contentRect.height;
      onUpdateHeight(node.key, height);
    }
  });

  resizeObserver.observe(nodeRef.current);

  return () => {
    resizeObserver.disconnect();
  };
}, [node.key, onUpdateHeight]);

高度缓存策略

const { positions, totalHeight } = useMemo(() => {
  const positions = [];
  let currentTop = 0;
  
  flattenedData.forEach((node) => {
    const height = nodeHeights[node.key] || itemMinHeight;
    positions.push({
      key: node.key,
      top: currentTop,
      height
    });
    currentTop += height;
  });
  
  return {
    positions,
    totalHeight: currentTop
  };
}, [flattenedData, nodeHeights, itemMinHeight]);

4. 树数据扁平化

将树形结构转换为一维数组,便于虚拟滚动处理:

const flattenTree = useCallback((nodes, level = 0, parentKey = null) => {
  const result = [];
  
  nodes.forEach((node, index) => {
    const key = node.key || `${parentKey}-${index}`;
    const item = {
      ...node,
      key,
      level,
      parentKey,
      hasChildren: node.children && node.children.length > 0,
      isExpanded: expandedKeys.has(key)
    };
    
    result.push(item);
    
    // 只有展开的节点才递归处理子节点
    if (item.hasChildren && item.isExpanded) {
      result.push(...flattenTree(node.children, level + 1, key));
    }
  });
  
  return result;
}, [expandedKeys]);

扁平化优势

  • 将树形结构转换为线性数组,便于索引访问
  • 只包含可见的节点(未展开的子节点不在数组中)
  • 记录每个节点的层级信息,用于缩进显示

5. 拖拽实现

支持节点拖拽重新排序:

const handleDrop = ({ dragNode, dropNode, position }) => {
  // 1. 深拷贝树数据
  const newTreeData = JSON.parse(JSON.stringify(treeData));
  
  // 2. 从原位置删除节点
  const removedNode = removeNode(newTreeData, dragNode.key);
  
  // 3. 插入到新位置
  const inserted = insertNode(newTreeData, dropNode.key, removedNode, position);
  
  // 4. 更新树数据
  setTreeData(newTreeData);
};

拖拽位置判断

const handleDragOver = (e, node, position) => {
  const rect = nodeRef.current.getBoundingClientRect();
  const offsetY = e.clientY - rect.top;
  const height = rect.height;
  
  let position;
  if (offsetY < height * 0.25) {
    position = 'before';  // 上方 25%
  } else if (offsetY > height * 0.75) {
    position = 'after';   // 下方 25%
  } else {
    position = 'inside';  // 中间 50%
  }
};

性能优化策略

1. 渲染优化

  • useMemo 缓存计算结果:避免重复计算可见范围和节点位置
  • useCallback 缓存函数:防止子组件不必要的重新渲染
  • React.memo:对 TreeNode 组件进行记忆化

2. 滚动优化

  • passive 事件监听:提升滚动性能
  • requestAnimationFrame:可选的滚动节流(当前未使用)
  • overscan 预渲染:减少滚动时的白屏

3. 内存优化

  • 按需渲染:只渲染可见节点,大幅减少 DOM 数量
  • 高度缓存:避免重复测量节点高度
  • 及时清理:组件卸载时清理事件监听和 Observer

使用示例

import VirtualTree from './components/VirtualTree';

function App() {
  const treeRef = useRef(null);
  const [treeData, setTreeData] = useState([...]);

  const handleNodeClick = (node) => {
    console.log('点击节点:', node);
  };

  const handleDrop = ({ dragNode, dropNode, position }) => {
    // 处理拖拽逻辑
  };

  return (
    <div>
      {/* 页面其他内容 */}
      <Form>...</Form>
      
      {/* 树组件 - 使用 body 滚动条 */}
      <VirtualTree
        ref={treeRef}
        data={treeData}
        itemMinHeight={32}
        overscan={5}
        draggable={true}
        onNodeClick={handleNodeClick}
        onDrop={handleDrop}
      />
    </div>
  );
}

受控/非受控组件分析

基础概念

日常开发中一定会碰到表单处理的需求,比如输入框、下拉框、单选框、上传等,既然是组件,不管是ui组件还是自定义组件,优秀或者说完善的组件一定得是同时支持受控和非受控的,那么何为受控和非受控组件呢?

改变一个组件的值,只能通过两种方式

image.png

用户去改变组件的value或者代码去改变组件的value

如果不能通过代码去改变组件的value, 那么这个组件的value只能通过用户的行为去改变,那么这个组件就不受我们的代码控制,那么它就是一个非受控组件,反之能通过代码改变组件的value值,组件受我们代码控制,那么它就是一个受控组件。

非受控模式下,代码可以组件设置默认值defaulValue,但是代码设置完默认值后就不能控制value,能改变value的只能是用户行为,代码只能通过监听onChange事件获取value或者获取dom实例来获取value值。

image.png

注意:defaultValue和value不一样,defaultValue是value的初始值,用户后面改变的是value的值

受控模式下,代码一旦给组件设置了value,用户就不能再去通过行为改变它,只能通过代码监听onChange事件拿到value重新赋值去改变.

image.png

圈起来,这句话要考:value能通过用户控制就是非受控、通过打码控制就是受控

受控示例

一个典型的受控代码片段

import { Input } from 'antd'
import { ChangeEvent, useState } from 'react'

export default function Demos() {
  const [text, setText] = useState('')
  const inputHandler = (e: ChangeEvent<HTMLInputElement>) => {
    // 通过监听Input的onChange去重新赋值来改变value,用户无法控制输入框的值
    setText(e.target.value)
  }

  return <Input value={text} onChange={inputHandler} />
}

非受控示例

一个典型的非受控代码片段

import { Input, InputRef } from 'antd'
import { useRef } from 'react'

export default function Demos() {
  const inputRef = useRef<InputRef>(null)

  setTimeout(() => {
    // 通过ref获取dom元素来获取value
    console.log(inputRef.current?.input?.value)
  }, 4000)

  const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    // 通过监听onChange来获取value
    console.log(e.target.value)
  }

  return <Input ref={inputRef} onChange={onChange} />
}

通过以上使用,我们可以发现,antd的Input组件同时支持了受控和非受控模式,那么我们能不能也自己封装一个同时支持受控和非受控模式的组件呢?

自定义同时支持受控和非受控模式的Radio组件

完整封装如下

import { useSettingStore } from '@/stores'
import { cn } from '@/utils'
import { useMemo, useState } from 'react'
import SpaceItem from '../spaceItem/SpaceItem'

// 自定义时间选择组件的属性
export type MyRadioProps = {
  value?: number
  defaultValue?: number
  onChange?: (value: number) => void
  options?: { label: string; value: number }[] // 选项
}

// 非受控/受控单选组件
export default function MyRadio(props: MyRadioProps) {
  const { colorPrimary } = useSettingStore()
  const { value, defaultValue, options, onChange } = props

  // 是否是受控组件
  const isControlled = useMemo(() => {
    return Reflect.has(props, 'value')
  }, [props])

  const [selectedValue, setSelectedValue] = useState<number | undefined>(
    isControlled ? value : defaultValue,
  ) // 内部状态

  // 最终拿去渲染的值
  const mergedValue = useMemo(() => {
    return isControlled ? value : selectedValue
  }, [selectedValue, value, isControlled])

  // 选择的回调
  const onSelect = (value: number) => () => {
    // 非受控时才更新内部状态
    if (!isControlled) {
      setSelectedValue(value)
    }

    onChange?.(value)
  }

  return (
    <SpaceItem wrap align="left">
      {options?.map((item) => (
        <div
          key={item.value}
          onClick={onSelect(item.value)}
          className={cn(
            'w-[65px] h-[35px] rounded-md flex items-center justify-center cursor-pointer border-[1px] border-[#585455] border-solid p-1',
            { 'text-white': mergedValue === item.value },
          )}
          style={{
            backgroundColor: mergedValue === item.value ? colorPrimary : '',
          }}
        >
          {item.label}
        </div>
      ))}
    </SpaceItem>
  )
}
// 是否是受控组件
  const isControlled = useMemo(() => {
    return Reflect.has(props, 'value')
  }, [props])

通过判断props中是否有value属性来判断到底是受控还是非受控

const [selectedValue, setSelectedValue] = useState<number | undefined>(
    isControlled ? value : defaultValue,
  ) // 内部状态

保存一个内部状态,来存储非受控模式时的值

// 最终拿去渲染的值
  const mergedValue = useMemo(() => {
    return isControlled ? value : selectedValue
  }, [selectedValue, value, isControlled])

组件最终显示的值,受控时显示父组件传入的value值,非受控时显示组件内部存储的值

// 选择的回调
  const onSelect = (value: number) => () => {
    // 非受控时才更新内部状态
    if (!isControlled) {
      setSelectedValue(value)
    }

    onChange?.(value)
  }

组件值改变时,如果是非受控,更新组件内部的值,并触发onChange事件回调(受控和非受控时都可以传onChange事件)

组件使用

<Card title="自定义单选组件非受控用法" size="small">
    <MyRadio
      options={Array.from({ length: 10 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      defaultValue={8}
      onChange={(value) => console.log(value)}
    />
</Card>
<Card title="自定义单选组件受控用法" size="small">
    <MyRadio
      options={Array.from({ length: 10 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      value={radio1}
      onChange={(value) => setRadio1(value)}
    />
</Card>

image.png

useMergeState封装

以上代码成功的实现了自定义组件同时支持受控和非受控,但是逻辑太分散,是否可以将处理逻辑再次封装呢?那么我们就来封装一个自定义hook来统一处理受控和非受控的逻辑

import { getTypeOf } from '@/utils'
import {
  SetStateAction,
  useCallback,
  useEffect,
  useMemo,
  useState,
} from 'react'

// 参数属性
export type MergeStateProps<T> = {
  value?: T
  defaultValue?: T
  onChange?: (value: T) => void
  [props: string]: any
}

// 配置属性
export type MergeStateOption<T> = {
  defaultValue?: T // 默认值
  defaultValuePropName?: string // 默认值属性名
  valuePropName?: string // 值属性名
  trigger?: string // 触发
}

/**
 * @description 合并状态hook
 */
function useMergeState<T = any>(
  props: MergeStateProps<T> = {},
  options: MergeStateOption<T> = {},
): [T, (v: SetStateAction<T>, ...args: any[]) => void] {
  const {
    defaultValue,
    defaultValuePropName = 'defaultValue',
    valuePropName = 'value',
    trigger = 'onChange',
  } = options

  const value = props[valuePropName] // 获取当前值
  const isControlled = Reflect.has(props, valuePropName) // 是否受控

  // 初始值
  const initialValue = useMemo(() => {
    if (isControlled) {
      return value
    }

    if (Reflect.has(props, defaultValuePropName)) {
      return props[defaultValuePropName]
    }

    return defaultValue
  }, [
    defaultValue,
    value,
    isControlled,
    defaultValuePropName,
    props,
  ])

  const [state, setState] = useState(initialValue) // 保存内部状态

  // 可控的情况,外部传入值时,更新内部状态
  useEffect(() => {
    if (isControlled) {
      setState(value)
    }
  }, [isControlled, value])

  // 设置值
  const handleSetState = useCallback(
    (v?: SetStateAction<T>, ...args: any[]) => {
      const res = getTypeOf(v) === 'Function' ? (v as any)(state) : v
      // 非受控时才更新内部状态
      if (!isControlled) {
        setState(res)
      }
      if (props[trigger]) {
        props[trigger](res, ...args)
      }
    },
    [props, trigger, isControlled, state],
  )

  return [state, handleSetState]
}

export default useMergeState
const value = props[valuePropName] // 获取当前value值
const isControlled = Reflect.has(props, valuePropName) // 是否受控

获取当前的value,并判断是否是受控模式

  // 初始值
const initialValue = useMemo(() => {
    if (isControlled) {
      return value
    }

    if (Reflect.has(props, defaultValuePropName)) {
      return props[defaultValuePropName]
    }

    return defaultValue
}, [
    defaultValue,
    value,
    isControlled,
    defaultValuePropName,
    props,
])

const [state, setState] = useState(initialValue) // 保存内部状态

设置内部状态的值 1、如果是受控,则返回value的值 2、如果不是受控,则返回传入配置中定义的defaultValue的属性名对应的值 3、否则返回传入的defaultValue值

这里为什么需要传valuePropName这个属性呢,因为Switch/CheckBox组件没有value属性,只有checked属性,是为了兼容

// 受控的情况,外部传入值时,更新内部状态
  useEffect(() => {
    if (isControlled) {
      setState(value)
    }
  }, [isControlled, value])

受控的情况下,外部传入值时,更新内部状态

// 设置值
const handleSetState = useCallback(
    (v?: SetStateAction<T>, ...args: any[]) => {
      const res = getTypeOf(v) === 'Function' ? (v as any)(state) : v
      // 非受控时才更新内部状态
      if (!isControlled) {
        setState(res)
      }
      if (props[trigger]) {
        props[trigger](res, ...args)
      }
    },
    [props, trigger, isControlled, state],
)

返回组件的第二个返回值,设置值的方法 1、首先判断使用该hook第二个返回值时传入的参数是不是一个函数,是的话先执行 2、非受控时才去更新内部状态,受控时不用更新,直接由父组件改变value 3、触发事件回调

使用上述hook再封装一个同时支持受控和非受控的自定义组件

import useMergeState from '@/hooks/useMergeState'
import { useThemeToken } from '@/hooks/useThemeToken'
import { cn } from '@/utils'
import SpaceItem from '../spaceItem/SpaceItem'

// 自定义时间选择组件的属性
export type MyCheckboxProps = {
  value?: number[]
  defaultValue?: number[]
  onChange?: (value: number[]) => void
  options?: { label: string; value: number }[] // 选项
}

// 非受控/受控多选组件
export default function MyCheckbox(props: MyCheckboxProps) {
  const { colorPrimary } = useThemeToken()

  const { options } = props
  const [selectedValue, setSelectedValue] = useMergeState<number[]>(props)

  // 选择的回调
  const onSelect = (value: number) => () => {
    let res = [
      ...(Array.isArray(selectedValue) ? selectedValue : [selectedValue]),
    ]
    if (Array.isArray(selectedValue) && selectedValue?.includes(value)) {
      res = selectedValue.filter((item) => item !== value)
    } else {
      res.push(value)
    }
    setSelectedValue(res)
  }

  return (
    <SpaceItem wrap align="left">
      {options?.map((item) => (
        <div
          key={item.value}
          onClick={onSelect(item.value)}
          className={cn(
            'w-[65px] h-[35px] rounded-md flex items-center justify-center cursor-pointer border-[1px] border-[#585455] border-solid p-1',
            {
              'bg-[#1890ff] text-white':
                Array.isArray(selectedValue) &&
                selectedValue?.includes(item.value),
            },
          )}
          style={{
            backgroundColor: selectedValue?.includes(item.value)
              ? colorPrimary
              : '',
          }}
        >
          {item.label}
        </div>
      ))}
    </SpaceItem>
  )
}

组件使用

<Card title="自定义多选组件非受控用法" size="small">
    <MyCheckbox
      options={Array.from({ length: 12 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      defaultValue={[4, 6, 9]}
      onChange={(value) => console.log(value)}
    />
</Card>
<Card title="自定义多选组件受控用法" size="small">
    <MyCheckbox
      options={Array.from({ length: 16 }).map((_, index) => ({
        label: `选项${index + 1}`,
        value: index + 1,
      }))}
      value={checkbox1}
      onChange={(value) => setCheckbox1(value)}
    />
</Card>

image.png

useControllableValue的使用

以上的封装,都是开发者自定义的,强大的ahooks怎么可能没有想到这种需求呢,所以ahooks也提供了useControllableValue这个hook

基本使用

import React, { useState } from 'react';
import { useControllableValue } from 'ahooks';

const ControllableComponent = (props: any) => {
  const [state, setState] = useControllableValue<string>(props);

  return <input value={state} onChange={(e) => setState(e.target.value)} style={{ width: 300 }} />;
};

const Parent = () => {
  const [state, setState] = useState<string>('');
  const clear = () => {
    setState('');
  };

  return (
    <>
      <ControllableComponent value={state} onChange={setState} />
      <button type="button" onClick={clear} style={{ marginLeft: 8 }}>
        Clear
      </button>
    </>
  );
};

使用useControllableValue封装一个自定义时间选择组件

import { cn, dateFormat } from '@/utils'
import { useControllableValue } from 'ahooks'
import dayjs from 'dayjs'
import WhiteSpace from '../whiteSpace'

// 自定义时间选择组件的属性
export type TimeProps = {
  value?: number
  defaultValue?: number
  onChange?: (value: number) => void
  timeNum?: number
}

// 非受控/受控时间选择组件
export default function MyTime(props: TimeProps) {
  const { timeNum = 10 } = props
  const [value, setValue] = useControllableValue(props)

  // 时间选择的回调
  const onSelectTime = (time: number) => () => {
    setValue(time)
  }

  return (
    <div>
      <div>当前时间:{dateFormat(value, 'YYYY-MM-DD HH:mm:ss')}</div>
      <WhiteSpace />
      {Array.from({ length: timeNum }).map((_, index) => {
        const time = dayjs()
          .subtract(index + 1, 'days')
          .startOf('day')
          .valueOf()

        return (
          <div
            onClick={onSelectTime(time)}
            key={index}
            className={cn({ 'text-red-500': time === value })}
          >
            {dateFormat(time, 'YYYY-MM-DD HH:mm:ss')}
          </div>
        )
      })}
    </div>
  )
}

image.png

用useControllableValue结合antd的DatePicker组件二次封装一个时间选择组件

import { useControllableValue } from 'ahooks'
import { DatePicker, DatePickerProps } from 'antd'
import dayjs from 'dayjs'
import { useMemo } from 'react'

const defaultShortcuts = [
  {
    label: '今天',
    value: dayjs(),
  },
  {
    label: '昨天',
    value: dayjs().subtract(1, 'day'),
  },
  {
    label: '三天前',
    value: dayjs().subtract(3, 'days'),
  },
  {
    label: '一周前',
    value: dayjs().subtract(1, 'week'),
  },
  {
    label: '15天前',
    value: dayjs().subtract(15, 'days'),
  },
  {
    label: '一个月前',
    value: dayjs().subtract(1, 'month'),
  },
]

// 时间选择器组件的属性
export interface MyDatePickerProps extends DatePickerProps {
  shortcuts?: number[] // 快捷选项
  shortcutsMap?: Record<number, string> // 快捷选项的映射
  shortcutsRender?: (shortcuts?: number[]) => DatePickerProps['presets']
  showPresets?: boolean // 是否显示快捷选项
}

// 非受控/受控时间选择器组件
export default function MyDatePicker(props: MyDatePickerProps) {
  const {
    shortcuts,
    shortcutsMap,
    showPresets = true,
    shortcutsRender,
    ...rests
  } = props

  const [values, setValues] =
    useControllableValue<DatePickerProps['value']>(props)

  const presets = useMemo(() => {
    if (!showPresets) return undefined
    if (shortcutsRender && shortcuts?.length) {
      return shortcutsRender(shortcuts)
    }
    if (shortcuts?.length) {
      return shortcuts.map((shortcut) => {
        return {
          label: shortcutsMap?.[shortcut] || `近${shortcut}天`,
          value: dayjs().subtract(shortcut, 'days'),
        }
      })
    }
    return defaultShortcuts
  }, [shortcuts, shortcutsMap, showPresets, shortcutsRender])

  return (
    <DatePicker
      presets={presets}
      {...rests}
      value={values}
      onChange={setValues}
    />
  )
}

用useControllableValue结合antd的Upload组件二次封装一个图片上传组件

import type { UploadProps } from 'antd/es/upload/interface'
import { ButtonProps } from 'antd/lib'

export type ImgsValueType = string[] | string // 上传的值类型

// 上传参数类型
export interface IImgsUploadProps
  extends Omit<UploadProps, 'onChange' | 'value' | 'defaultValue'> {
  validate?: boolean // 是否需要验证接收类型和文件大小
  validateSize?: boolean // 是否需要验证图片的宽高
  limitWidth?: number // 验证图片的宽
  limitHeight?: number // 验证图片的高
  size?: number // 限制的尺寸,以M为单位
  successText?: string // 上传成功的提示文字
  failedText?: string // 上传失败的提示文字
  uploadText?: string // 上传按钮文字
  uploadStyles?: React.CSSProperties // 上传按钮的样式
  imgsStyles?: React.CSSProperties // 图片的样式
  imgList?: string[] // 已上传的图片列表
  preview?: boolean // 图片是否可预览
  count?: number // 图片总数限制
  tips?: string // 提示tips
  tipStyle?: React.CSSProperties // 提示tips的样式
  plusSizeTip?: string // 超过尺寸大小的提示语
  errorAcceptTip?: string // 上传格式不正确的提示语
  compress?: boolean // 是否压缩图片
  quality?: number // 压缩比例
  value?: ImgsValueType // 值
  defaultValue?: ImgsValueType // 默认值
  width?: number // 图片展示的宽
  height?: number // 图片展示的高
  multi?: boolean // 是否上传多张图片
  uploadBtn?: React.ReactNode // 自定义上传按钮
  uploadBtnProps?: ButtonProps // 上传按钮属性
  showImgs?: boolean // 是否显示已上传的图片
  fileValidateTip?: string // 文件校验不通过的提示语
  fileValidate?: (file: File) => Promise<boolean> // 文件校验
  onChange?: (data: ImgsValueType) => void // 改变的回调
  remove?: (url: string) => void // 移除已上传图片的回调
  onUploaded?: (url: string, fileInfo?: any, ...restParams: any) => void // 单张上传成功后接收结果
}


import { compressPic } from '@/utils'
import { CloseOutlined, LoadingOutlined } from '@ant-design/icons'
import { useControllableValue } from 'ahooks'
import { Button, Image as IM, message, Spin, Upload } from 'antd'
import type { UploadProps } from 'antd/es/upload/interface'
import { useState } from 'react'
import styles from './imgsUpload.module.less'
import type { IImgsUploadProps } from './typings'

// 允许上传的图片类型
export const ACCEPTIMG = '.jpg, .jpeg, .png, .gif, .webp, .ico, .bmp'

// 图片列表(多张、单张)上传通用组件
const ImgsUpload: React.FC<IImgsUploadProps> = (props: IImgsUploadProps) => {
  const {
    validate = true,
    validateSize = false,
    limitWidth = 1080,
    limitHeight = 1920,
    size,
    successText = '上传成功',
    failedText = '上传失败',
    plusSizeTip,
    errorAcceptTip,
    compress = false,
    quality = 0.6,
    accept,
    uploadStyles = {},
    imgsStyles = {},
    preview = true,
    multi = false,
    count = multi ? 5 : 1,
    tips,
    disabled,
    tipStyle = {},
    width = 80,
    height = 80,
    uploadText,
    uploadBtn,
    uploadBtnProps,
    showImgs = true,
    fileValidateTip,
    value,
    fileValidate,
    onChange,
    remove,
    onUploaded,
    ...restProps
  } = props || {}

  console.log(value, onChange)

  const accepts = accept ?? ACCEPTIMG // 接收类型
  const limit = size ?? 5 // 限制大小
  const [spin, setSpin] = useState<boolean>(false) // 上传中
  const [imgs, setImgs] = useControllableValue(props, {
    defaultValue: multi ? [] : '',
  }) // 已上传的图片列表

  // 上传的回调
  const handleChange = (info: any) => {
    if (info.file.status === 'uploading') {
      setSpin(true)
    }
    if (info.file.status === 'done' && info.file?.response?.msg === 'success') {
      setSpin(false)
      const result = info.file?.response?.result[0]
      if (!!result && !result?.endsWith('.bin')) {
        message.success(successText)

        // 多张图片
        if (multi) {
          setImgs((pre: any) => {
            if ((pre || []).length < count) {
              return [...(pre || []), result]
            }
            return pre
          })
        } else {
          // 单张图片
          setImgs(result)
        }

        // 上传成功的回调
        onUploaded?.(result, info.file)
      } else {
        message.error(failedText)
      }
    }
    if (info.file.status === 'done' && info.file?.response?.msg !== 'success') {
      setSpin(false)
      message.error(info.file?.response?.msg || failedText)
    }
    if (info.file.status === 'error') {
      setSpin(false)
      message.error(failedText)
    }
  }

  // 获取上传图片的原始宽高
  const getImgWidthHeight = (
    file: File,
  ): Promise<{ width: number; height: number }> => {
    return new Promise((resolve) => {
      const img = new Image()
      img.crossOrigin = 'anonymous' // 跨域
      img.src = URL.createObjectURL(file)
      img.onload = function () {
        resolve({ width: img.width, height: img.height })
      }
      img.onerror = function () {
        resolve({ width: 0, height: 0 })
      }
    })
  }

  // 上传之前的回调
  const beforeUpload = async (file: File) => {
    if (validateSize) {
      const widthHeight = await getImgWidthHeight(file)
      const { width, height } = widthHeight
      if (width !== limitWidth || height !== limitHeight) {
        message.warning(`图片的大小应该为${limitWidth} * ${limitHeight}`)
        return false
      }
    }
    if (validate) {
      const file_typename = file.name.substring(file.name.lastIndexOf('.'))
      const isRightfile = accepts.includes(file_typename?.toLowerCase())
      // 检验格式
      if (!isRightfile) {
        message.warning(errorAcceptTip || `请上传${accepts}格式的图片`)
      }
      const isLt = file.size / 1024 / 1024 <= limit
      if (!isLt) {
        message.warning(plusSizeTip || `图片大小不超过${limit}M`)
      }

      // 自定义文件校验
      if (fileValidate) {
        const pass = await fileValidate(file)
        if (pass === false) {
          if (fileValidateTip) {
            message.warning(fileValidateTip)
          }
          return false
        }
      }

      // 如果要压缩
      if (isRightfile && isLt && compress) {
        return compressPic(file, quality)
      }
      return isRightfile && isLt
    }
    return true
  }

  // 上传参数
  const uploadProps: UploadProps = {
    showUploadList: false,
    action: `${import.meta.env.VITE_UPLOAD_BASE_URL}/admin/file/upload`,
    accept: accepts,
    disabled: !!spin || disabled,
    multiple: true,
    onChange: handleChange,
    beforeUpload,
  }

  // 移除图片
  const removeImg = (url: string) => {
    if (multi) {
      setImgs((pre: any) => {
        const newImgs = (pre || []).filter((p: string) => p !== url)
        return newImgs
      })
    } else {
      setImgs('')
    }

    // 移除图片的回调
    remove?.(url)
  }

  return (
    <Spin spinning={!!spin}>
      <div className={styles.upload}>
        {showImgs && imgs ? (
          <div className={styles.imgs}>
            {((multi ? imgs : [imgs]) as string[])?.map((url) => (
              <div key={url} className={styles.imgItem}>
                <IM
                  src={`${import.meta.env.VITE_ASSET_BASE_URL}/${url}`}
                  width={width}
                  height={height}
                  style={imgsStyles}
                  preview={preview}
                />
                <CloseOutlined
                  onClick={() => {
                    if (disabled) {
                      return
                    }
                    removeImg(url)
                  }}
                />
              </div>
            ))}
          </div>
        ) : null}
        {(!multi && !imgs) || !imgs || imgs?.length < count ? (
          <Upload
            disabled={disabled}
            {...uploadProps}
            {...restProps}
            style={uploadStyles}
          >
            {spin ? (
              <LoadingOutlined />
            ) : (
              uploadBtn || (
                <Button type="primary" {...uploadBtnProps}>
                  {uploadText || '请选择上传图片'}
                </Button>
              )
            )}
          </Upload>
        ) : null}
      </div>
      {tips ? (
        <div className="pt-2" style={tipStyle}>
          {tips}
        </div>
      ) : null}
    </Spin>
  )
}

export default ImgsUpload

使用useControllableValue来封装同时支持受控和非受控组件,非常的快捷方便,强烈推荐

useControllableValue源码

function useControllableValue<T = any>(
  props: StandardProps<T>,
): [T, (v: SetStateAction<T>) => void];
function useControllableValue<T = any>(
  props?: Props,
  options?: Options<T>,
): [T, (v: SetStateAction<T>, ...args: any[]) => void];
function useControllableValue<T = any>(props: Props = {}, options: Options<T> = {}) {
  const {
    defaultValue, // 默认值,会被 props.defaultValue 和 props.value 覆盖
    defaultValuePropName = 'defaultValue', // 默认值的属性名
    valuePropName = 'value', // 值的属性名
    trigger = 'onChange', // 修改值时,触发的函数
  } = options;
  // 外部(父级)传递进来的 props 值
  const value = props[valuePropName] as T;
  // 是否受控:判断 valuePropName(默认即表示value属性),有该属性代表受控
  const isControlled = props.hasOwnProperty(valuePropName);

  // 首次默认值
  const initialValue = useMemo(() => {
    // 受控:则由外部的props接管控制 state
    if (isControlled) {
      return value;
    }
    // 外部有传递 defaultValue,则优先取外部的默认值
    if (props.hasOwnProperty(defaultValuePropName)) {
      return props[defaultValuePropName];
    }
    // 优先级最低,组件内部的默认值
    return defaultValue;
  }, []);

  const stateRef = useRef(initialValue);
  // 受控组件:如果 props 有 value 字段,则由父级接管控制 state
  if (isControlled) {
    stateRef.current = value;
  }

  // update:调用该函数会强制组件重新渲染
  const update = useUpdate();

  function setState(v: SetStateAction<T>, ...args: any[]) {
    const r = isFunction(v) ? v(stateRef.current) : v;

    // 非受控
    if (!isControlled) {
      stateRef.current = r;
      update(); // 更新状态
    }
    // 只要 props 中有 onChange(trigger 默认值未 onChange)字段,则在 state 变化时,就会触发 onChange 函数
    if (props[trigger]) {
      props[trigger](r, ...args);
    }
  }

  // 返回 [状态值, 修改 state 的函数]
  return [stateRef.current, useMemoizedFn(setState)] as const;
}

总结

以上就是对于受控和非受控的总结,文章中部分代码可能有错误之处,还望指正,不喜勿喷哦

# 🌟 JavaScript原型与原型链终极指南:从Function到Object的完整闭环解析 ,深入理解JavaScript原型系统核心

深入理解JavaScript原型系统核心

📖 目录


🎯 核心概念

四大基本原则

  1. 原则一:每个对象都有构造函数(constructor)

    • 指向构建该对象或实例的函数
  2. 原则二:只有函数对象才有prototype属性

    • 非函数对象没有prototype属性
    • 实例只有__proto__属性
    • 两者指向同一个对象(函数的原型对象)
  3. 原则三:Function函数是所有函数的构造函数

    • 包括它自己
    • 代码中声明的所有函数都是Function的实例
  4. 原则四:Object也是函数

    • 所以Object也是Function函数的实例

实例,函数,对象,原型对象,构造函数,关系总览图

image.png

🔍 非函数对象分类

  • 实例对象,const person = new Foo(),person就是实例对象
  • 普通对象({}new Object()
  • 内置非函数对象实例

🔄 显式原型与隐式原型

对象分类

  • 函数对象:拥有prototype属性
  • 非函数对象:只有__proto__属性

相同点

  • 都指向同一个原型对象

📝 示例代码

function Person(){}
const person = new Person();

console.log("Person.prototype指向:", Person.prototype)
console.log("person.__proto__指向", person.__proto__)

🖼️ 执行结果

显式原型

隐式原型


🎯 构造函数的指向

默认情况

function Person(){}
const person = new Person();

console.log("Person.prototype.constructor指向", Person.prototype.constructor)
// 输出:[Function: Person]

执行结果

默认构造函数指向

默认构造函数指向详情


修改原型对象后

function Person(){}
const person = new Person();

Person.prototype = new foo();  // 修改原型对象

console.log("Person.prototype.constructor指向", Person.prototype.constructor)
// 输出:[Function: foo]

执行结果

修改后构造函数指向

修改后构造函数指向详情


📊 核心原理说明

解释

Person.prototype被当作函数foo的实例,继承了foo函数(此篇不展开继承详解)

总结规律

  • 每个原型对象或实例都有.constructor属性
  • 实例通过原型链查找constructor
  • 原型对象默认指向自身的函数(如果不是其他函数的实例)

查找过程示例

// Person.prototype被当作实例时
Person.prototype.__proto__ → foo.prototypefoo()

🖼️ 可视化关系图

三者关系图

原型关系图


🔬 代码验证

function Person(){}

// 创建新的原型对象
Person.prototype = {
    name: "杨",
    age: "18",
    histype: "sleep"
}

// 添加方法
Person.prototype.print = function(){
    console.log("你好我是原型对象");
}

// 创建实例
const person01 = new Person();
const person02 = new Person();

// 验证指向
console.log("Person.prototype指向:", Person.prototype)
console.log("person01.__proto__指向", person01.__proto__)
console.log("person02.__proto__指向", person02.__proto__)
console.log("Person.prototype.constructor指向", Person.prototype.constructor)

执行结果

代码验证结果


⚠️ 特别说明

关键细节

创建新对象时,Person.prototype.constructor指向Object,因为Person.prototype成了Object的实例。

对比情况

  • 创建新对象时Person.prototype.constructorObject
  • 未创建新对象时Person.prototype.constructorPerson

示意图

构造函数指向对比

构造函数指向对比详情


Function和Object

小故事

从前有个力大无穷的大力神,能举起任何东西,有一天,小A在路上和这个大力神相遇了。

大力神:小子,我可是力大无穷的大力神,我能举起任何东西,你信不信?

小A:呦呦呦,还大力神,你说你能举起任何东西,那你能把你自己抬起来吗?

...

  • Function是所有函数的加工厂,你在代码声明的所有函数都是Function的实例,包括Function函数本身,Object也是函数,所以它也是Functiod的实例

  • Function就是这样的大力神,而且是可以把自己抬起来的大力神,这听起来比较扯,但是这就是事实,请看VCR:

function Person (){}

const person01 = new Person();

console.log("Function.__proto__指向",Function.__proto__)//Function.__proto__指向 [Function (anonymous)] Object
console.log("Function.prototype指向",Function.prototype)//Function.prototype指向 [Function (anonymous)] Object
console.log("Function.__proto__ == Function.prototype???",Function.__proto__ == Function.prototype)
//Function.__proto__ == Function.prototype??? true

image.png

image.png

Object 在 JavaScript 中扮演三重角色:

  • 构造函数:用于创建对象

  • 命名空间:提供一系列静态方法用于对象操作

  • 原型终点:Object.prototype 是所有原型链的终点,在往上没有了,值==null

请看VCR:

function Person (){};

const persoon01 = new Person();
const obj = {};//通过对象字面量{}创建obj实例
const obj1 = new Object();//通过构造函数new Object()创建obj1实例
const obj2 = Object.create(Object.prototype);//通过委托创建,或者叫原型创建,来创建obj2实例

console.log("Person.prototype.__proto__指向",Person.prototype.__proto__);
//Person.prototype.__proto__指向 [Object: null prototype] {}

console.log("Function.prototype.__proto__指向",Function.prototype.__proto__)
//Function.prototype.__proto__指向 [Object: null prototype] {}

console.log("通过对象字面量{}创建的obj实例,obj.__proto__指向",obj.__proto__);
//通过对象字面量{}创建的obj实例,obj.__proto__指向 [Object: null prototype] {}

console.log("通过构造函数new Object()创建obj1实例,指向",obj1.__proto__);
//通过构造函数new Object()创建obj1实例,指向 [Object: null prototype] {}

console.log("通过委托创建,或者叫原型创建,来创建obj2实例,指向",obj2.__proto__);
//通过委托创建,或者叫原型创建,来创建obj2实例,指向 [Object: null prototype] {}

image.png

image.png

Function和Object的关系

  • 相互依赖的循环引用
    • Object 是 Function 的实例(构造函数层面)

    • Function 是 Object 的子类(原型继承层面)

    • 这是 JavaScript 的自举(Bootstrap)机制

根据关系总览图,我们可以看到,Function和Object,它们两形成了一个闭环,将所有的函数和对象都包裹在这个闭环里

📋 JavaScript 原型系统核心概念表

概念 描述 示例 特殊说明
prototype 函数特有,指向原型对象 Person.prototype 只有函数对象才有此属性
proto 所有对象都有,指向构造函数的原型 person.__proto__ 实际应使用 Object.getPrototypeOf()
constructor 指向创建该对象的构造函数 Person.prototype.constructor 可被修改,查找时沿原型链进行
原型链查找 通过 __proto__ 逐级向上查找 person.__proto__.__proto__ 终点为 null
Function 所有函数的构造函数 Function.prototype Function.__proto__ === Function.prototype
Object 所有对象的基类 Object.prototype 原型链终点,Object.prototype.__proto__ === null

🔍 补充说明

prototype 补充

  • 函数的 prototype 属性默认包含 constructor 属性指向函数自身
  • 用于实现基于原型的继承

proto 补充

  • 现在更推荐使用 Object.getPrototypeOf(obj)Object.setPrototypeOf(obj, proto)
  • __proto__ 是访问器属性,不是数据属性

constructor 补充

  • constructor 属性可以通过原型链查找
  • 示例:person.constructor === Person(实际查找的是 person.__proto__.constructor

原型链查找补充

  • 当访问对象属性时,如果对象自身没有,会沿着原型链向上查找
  • 直到找到该属性或到达原型链终点 null

Function 补充

  • 是所有函数的构造函数,包括内置构造函数(Object、Array等)和自定义函数
  • 自身也是函数,所以 Function.__proto__ === Function.prototype

Object 补充

  • Object.prototype 是所有原型链的最终原型对象
  • 通过 Object.create(null) 可以创建没有原型的"纯净对象"

💡 记忆口诀

  • 函数看prototype,实例看__proto__
  • constructor找根源,原型链上寻答案
  • Object是终点,Function是关键

结语:

看完这篇文章,你应该可以读懂上面的关系总览图了,望学习愉快!!!

制作一个简单的HTML个人网页

下面给你一个超级简单但又好看、手机电脑都能完美访问的个人主页模板,5 分钟就能改成你自己的专属网页!

功能特点

  • 纯 HTML + CSS(一行 JavaScript 都不用)
  • 自适应移动端(手机看起来也好看)
  • 深色/浅色自动切换(跟随系统)
  • 一键替换头像、姓名、简介、社交链接就完事
  • 支持添加博客、项目、作品、摄影等模块

完整代码(直接复制保存为 index.html 双击打开即可)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>张三的个人主页</title>
    <style>
        :root {
            --bg: #f8f9fa;
            --text: #2c3e50;
            --card: #ffffff;
            --accent: #ff6b6b;   /* 主色调,喜欢可换成 #e91e63、#4ecdc4 等 */
        }
        @media (prefers-color-scheme: dark) {
            :root {
                --bg: #121212;
                --text: #e0e0e0;
                --card: #1e1e1e;
            }
        }

        * { margin:0; padding:0; box-sizing:border-box; }
        body {
            font-family: "Segoe UI", Arial, sans-serif;
            background: var(--bg);
            color: var(--text);
            line-height: 1.6;
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
        }
        .card {
            background: var(--card);
            max-width: 420px;
            width: 100%;
            border-radius: 20px;
            overflow: hidden;
            box-shadow: 0 15px 35px rgba(0,0,0,0.1);
            text-align: center;
            transition: transform 0.3s;
        }
        .card:hover { transform: translateY(-10px); }

        .avatar {
            width: 120px;
            height: 120px;
            border-radius: 50%;
            margin: 30px auto 10px;
            object-fit: cover;
            border: 5px solid var(--accent);
        }
        h1 {
            font-size: 28px;
            margin: 10px 0;
            color: var(--accent);
        }
        .tagline {
            color: #888;
            font-size: 16px;
            margin-bottom: 20px;
        }
        .bio {
            padding: 0 30px;
            margin-bottom: 25px;
            font-size: 15px;
        }
        .links {
            display: flex;
            flex-wrap: wrap;
            justify-content: center;
            gap: 12px;
            padding: 0 20px 30px;
        }
        .links a {
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 10px 20px;
            background: var(--accent);
            color: white;
            text-decoration: none;
            border-radius: 50px;
            font-size: 14px;
            transition: all 0.3s;
        }
        .links a:hover {
            transform: scale(1.08);
            box-shadow: 0 5px 15px rgba(255,107,107,0.4);
        }
        .links a i { font-size: 18px; }

        footer {
            margin-top: 40px;
            font-size: 14px;
            color: #aaa;
        }
    </style>
    <!-- 图标库(可选) -->
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
</head>
<body>

<div class="card">
    <!-- 1. 替换成你的头像(放同目录下或用网络链接) -->
    <img src="https://avatars.githubusercontent.com/u/你的githubid?v=4" alt="头像" class="avatar">
    <!-- 或者本地图片:src="myphoto.jpg" -->

    <h1>张三</h1>
    <p class="tagline">前端开发者 / 摄影爱好者 / 正在努力变厉害的人</p>

    <div class="bio">
        Hi~我是张三,目前在广州做前端,喜欢写代码、拍风景、撸猫。<br>
        生活很普通,但希望每天都能进步一点点
    </div>

    <div class="links">
        <!-- 直接改链接和图标就行 -->
        <a href="https://github.com/yourname" target="_blank">
            <i class="fab fa-github"></i> GitHub
        </a>
        <a href="https://weibo.com/yourname" target="_blank">
            <i class="fab fa-weibo"></i> 微博
        </a>
        <a href="https://space.bilibili.com/123456" target="_blank">
            <i class="fab fa-bilibili"></i> B站
        </a>
        <a href="mailto:your@email.com">
            <i class="fas fa-envelope"></i> 邮件
        </a>
        <a href="https://yourblog.com" target="_blank">
            <i class="fas fa-blog"></i> 博客
        </a>
        <a href="https://juejin.cn/user/你的id" target="_blank">
            <i class="fab fa-juejin"></i> 掘金
        </a>
    </div>
</div>

<footer>© 2025 张三 | 手工码的个人主页</footer>

</body>
</html>

只需要改 4 处就完全是你自己的网页了!

  1. 头像:把 src="https://..." 换成你自己的照片链接(推荐放同目录下改成 src="avatar.jpg"
  2. 名字:改 <h1>张三</h1>
  3. 一句话介绍:改 .tagline 那行
  4. 自我介绍 + 社交链接:按需增删即可

想更炫酷?再加这几行(任选)

  • 背景粒子特效(加在 <body> 前面):
  <script src="https://cdn.jsdelivr.net/npm/particles.js@2.0.0/particles.min.js"></script>
  <div id="particles-js"></div>
  <script>
  particlesJS("particles-js", {"particles":{"number":{"value":80},"color":{"value":"#ff6b6b"},"shape":{"type":"circle"},"opacity":{"value":0.5},"size":{"value":3},"move":{"speed":1}}});
  </script>
  <style>#particles-js{position:fixed;top:0;left:0;width:100%;height:100%;z-index:-1;}</style>
  • 音乐自动播放(七夕专用):
  <audio src="your-music.mp3" autoplay loop hidden></audio>

5 分钟搞定一个高颜值个人主页,赶紧发给朋友/对象/面试官吧~
需要再加「作品集」「时间轴」「留言板」等功能,随时告诉我,我继续给你升级!

HTML标签 - 表格标签

HTML标签 - 表格标签

在过去网站开发过程中,表格标签的使用是非常非常多,绝大多数的网站都是使用表格标签来制作的,也就是说表格标签是一个时代的代表。

什么是表格标签?

表格标签的作用是用来给一堆数据添加表格语义,其实表格是一种数据的展现形式,当数据量非常大的时候,表格这种展现形式被认为是最为清晰的一种展现形式。

表格结构

由于表格中存储的数据比较复杂,为了方便管理、阅读以及提升语义,我们可以对表格中存储的数据进行分类。

  • 表格中存储的数据可以分为四类:

    1. 表格标题
    2. 表格表头
    3. 表格主体
    4. 表格的页尾信息
  • 表格完整结构

<table>
  <caption>表格的标题</caption>
  <thead>
    <tr>
      <th>每一列的标题</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>数据</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <td>数据</td>
    </tr>
  </tfoot>
</table>

表格标签

  • table标签

    • 作用:表格标签中的table代表整个表格,也就是一对table标签就是一个表格。
  • tr标签

    • 作用:表格标签的tr代表表格中的一行数据,也就是说一对tr标签就是表格中的一行。
  • td标签

    • 作用:表格标签中的td标签代表表格中的一个单元格,也就是说一对td标签就是表格中的一个单元格。
  • caption标签

    • 作用:在表格标签中提供了一个标签专门用来设置表格的标题,这个标签叫做caption。只要将标题写在caption标签中,那么标题就会自动相对于表格的宽度居中。
    • 注意点:
      • caption标签一定要写在table标签中,否则无效。
      • caption一定要紧跟在table标签后面。
  • th标签

    • 作用:在表格标签中提供了一个标签专门用来展示每一列的标题,这个标签叫做th标签,只要使用th标签展现当前列的标题,这时标题就会在该标签单元格中自动居中。
  • thead标签

    • 作用:指定表格表头信息。
  • tbody标签

    • 作用:指定表格主体信息。
    • 注意点:如果我们没有编写tbody,系统会自动给我门添加tbody
  • tfoot标签

    • 作用:指定表格附加信息。
    • 注意点:如果指定了theadtfoot,那么在修改整个表格的高度时,theadtfoot有自己的默认高度,不会随着表格的高度变化而变化。

总结:

  • 表格标签有一个边框属性,这个属性决定了边框的宽度,默认情况下这个属性的值是0,所以看不到边框。

  • 表格标签和列表标签一样,它是一个组合标签,所以table/tr/td要么一起出现,要么一起不出现,不会单独出现。

  • 表格中有两种单元格,一种是td,一种是thtd是专门用来存储数据的,th是专门用来存储当前列的标题的。

表格标签的属性(这部分内容仅为了解,以后均通过CSS来进行修改):

  • 宽度和高度的属性(可以给table标签和td标签使用)

    • 表格的宽度和高度默认按照内容尺寸调整,也可以通过给table标签设置width/height属性的方式来手动指定表格宽高。
    • 如果给td标签设置width/height属性,会修改当前单元格的宽度(会同时影响当前列单元格宽度)和高度(会同时影响当前行单元格高度),不会影响整个表格的宽度和高度。
      • 当给一行中不同单元格设置不同的height属性,保留一行中高度最高的属性值作为该行单元格的高度。
      • 当给一列中不同单元格设置不同的width属性,保留一行中宽度最宽的属性值作为该列单元格的宽度。
  • 水平对齐和垂直对齐的属性(其中水平对齐可以给table标签和tr标签和td标签使用,垂直对齐只能给tr标签和td标签使用)

    • table标签设置align属性,可以控制表格在水平方向的对齐方式。
    • tr标签设置align属性,可以控制当前行中所有单元格内容的水平方向对齐方式。
    • td标签设置align属性,可以控制当前单元格中内容在水平方向的对齐方式(如果td标签中设置了align属性,tr中也设置了align属性,那么单元格中的内容会按照td中设置的来对齐)。
    • tr标签设置valign属性,可以控制当前行中所有单元格内容的垂直方向对齐方式。
    • td标签设置valign属性,可以控制当前单元格中内容在垂直方向对齐方式。(如果td标签中设置了valign属性,tr中也设置了valign属性,那么单元格中的内容会按照td中设置的来对齐。
  • 外边距和内边距的属性(只能给table标签使用)

    • 外边距(cellspacing)就是单元格和单元格之间的距离(默认情况下单元格与单元格的外边距是2px)
    • 内边距(cellpadding)就是文字距离单元格之间的距离(默认情况下内边距是1px)
  • 通过属性设置完成细线表格的绘制:

    • 在表格标签中想通过指定外边距为0来实现细线表格是不靠谱的,其实它是将2条合并为了一条线,所以看上去很不舒服。 通过设置外边距实现的表格

    • 细线表格的制作方式:(table标签、tr标签以及td标签都支持bgcolor属性,但是样式以后通过css完成控制。)

      1. table标签设置bgcolor属性
      2. tr标签设置bgcolor属性
      3. table标签设置cellspacing="1px"
    • 代码:

    <table bgcolor="black" cellspacing="1px" width="500px" height="300px">
        <tr bgcolor="white">
            <td></td>
            <td></td>
            <td></td>
        </tr>
        <tr bgcolor="white">
            <td></td>
            <td></td>
            <td></td>
        </tr>
        <tr bgcolor="white">
            <td></td>
            <td></td>
            <td></td>
        </tr>
    </table>
    
    • 效果展示: 通过设置背景颜色实现的表格

      通过放大比较上述两张表格图片,就能够很明显的看出表格边框的差别。

单元格合并

  • 水平方向上的单元格合并

    • 可以给td标签添加colspan属性,把某一个单元格当作多个单元格来看待(水平)。
    • 格式:<td colspan="2"></td>含义:把当前单元格当作两个单元格来看待。
    • 注意点:
      • 由于把某一个单元格当作多个单元格来看待,所以就会多出一些单元格,需要删掉一些单元格确保表格正常显示。
      • 单元格合并永远都是向后或者向下合并,而不能向前或者向上合并。
  • 垂直方向上的单元格合并

    • 可以给td标签添加rowspan属性,把某一个单元格当作多个单元格来看待(垂直)。
    • 格式:<td rowspan="2"></td>含义:把当前单元格当作两个单元格来看待。
    • 注意点:
      • 由于把某一个单元格当作多个单元格来看待,所以就会多出一些单元格,需要删掉一些单元格确保表格正常显示。
      • 单元格合并永远都是向后或者向下合并,而不能向前或者向上合并。

参考链接:

W3School官方文档:www.w3school.com.cn

护航隐私!小程序纯前端“证件加水印”:OffscreenCanvas 全屏平铺实战

1. 背景与痛点:证件“裸奔”的风险

在日常生活中,我们经常需要上传身份证、驾照或房产证照片来办理各种业务。然而,直接发送原图存在巨大的安全隐患:

  • 被二次盗用:不法分子可能将你的证件照用于网贷、注册账号等非法用途。
  • 服务器隐私泄露:如果使用在线工具加水印,图片必须上传到第三方服务器,这就好比“把钥匙交给陌生人保管”,风险不可控。

为了解决这一痛点,可利用小程序的 OffscreenCanvas 能力,在用户手机本地毫秒级合成水印,图片数据永远不会离开用户手机

2. 核心思路:离屏渲染 + 矩阵平铺

实现全屏倾斜水印,主要难点在于坐标计算性能平衡。我们的方案如下:

  1. 离屏渲染 (OffscreenCanvas): 使用离屏画布在内存中处理,避免页面闪烁,且支持高性能的 2D 渲染模式。
  2. 智能 DPR 降级: 沿用我们之前文章提到的防爆内存策略。证件照通常分辨率很高,必须计算安全尺寸,防止 Canvas 内存溢出闪退。
  3. 矩阵平铺算法: 不简单的旋转画布,而是采用 “保存环境 -> 平移 -> 旋转 -> 绘制 -> 恢复环境” 的策略,在一个网格循环中将文字铺满全屏,确保无论图片比例如何,水印都能均匀分布。

3. 硬核代码实现

以下是封装好的 watermarkUtils.js。包含了智能 DPR 计算全屏水印绘制的核心逻辑。

// utils/watermarkUtils.js

// 1. 获取系统基础信息
const wxt = {
  dpr: wx.getSystemInfoSync().pixelRatio || 2
};

// 图片缓存,避免重复加载
const cacheCanvasImageMap = new Map();

/**
 * 内部方法:获取/创建 Canvas Image 对象
 */
async function getCanvasImage(canvas, imageUrl) {
  if (cacheCanvasImageMap.has(imageUrl)) return cacheCanvasImageMap.get(imageUrl);
  
  // 兼容性处理:若不支持 Promise.withResolvers,请改用 new Promise
  const { promise, resolve, reject } = Promise.withResolvers();
  const image = canvas.createImage();
  image.onload = () => {
    cacheCanvasImageMap.set(imageUrl, image);
    resolve(image);
  };
  image.onerror = (e) => reject(new Error(`加载失败: ${e.errMsg}`));
  image.src = imageUrl;
  await promise;
  return image;
}

/**
 * 给图片添加全屏倾斜水印
 * @param {string} imageUrl 图片路径
 * @param {string} text 水印文字,如 "仅供办理租房业务使用"
 * @param {object} options 配置项 { color, size, opacity }
 */
export async function addWatermark(imageUrl, text = '仅供办理业务使用', options = {}) {
  // 默认配置
  const config = {
    color: '#aaaaaa',
    opacity: 0.5,
    fontSize: 0, // 0 表示自动计算
    gap: 100,    // 水印间距
    ...options
  };

  const offscreenCanvas = wx.createOffscreenCanvas({ type: '2d' });
  const image = await getCanvasImage(offscreenCanvas, imageUrl);
  const { width, height } = image;

  // --- ⚡️ 性能优化:智能 DPR 计算 (防止大图闪退) ---
  const LIMIT_SIZE = 4096; 
  let useDpr = wxt.dpr;
  if (Math.max(width, height) * useDpr > LIMIT_SIZE) {
    useDpr = LIMIT_SIZE / Math.max(width, height);
  }

  // 设置画布尺寸
  offscreenCanvas.width = width * useDpr;
  offscreenCanvas.height = height * useDpr;

  const ctx = offscreenCanvas.getContext('2d');
  ctx.scale(useDpr, useDpr);
  
  // 1. 绘制底图
  ctx.drawImage(image, 0, 0, width, height);

  // 2. 配置水印样式
  // 自动计算字号:约为图片宽度的 4%
  const fontSize = config.fontSize || Math.floor(width * 0.04); 
  ctx.font = `bold ${fontSize}px sans-serif`;
  ctx.fillStyle = config.color;
  ctx.globalAlpha = config.opacity;
  ctx.textBaseline = 'middle';
  ctx.textAlign = 'center';

  // 3. 计算平铺逻辑
  // 旋转 45 度后,覆盖范围需要比原图大,这里简单取对角线长度作为边界
  const maxSize = Math.sqrt(width * width + height * height);
  // 步长 = 文字宽度 + 间距
  const step = ctx.measureText(text).width + config.gap; 
  
  // 4. 循环绘制水印
  // 从负坐标开始绘制,确保旋转后边缘也有水印
  for (let x = -maxSize; x < maxSize; x += step) {
    for (let y = -maxSize; y < maxSize; y += step) {
      ctx.save();
      
      // 核心变换:平移到网格点 -> 旋转 -> 绘制
      ctx.translate(x, y);
      ctx.rotate(-45 * Math.PI / 180); // 逆时针旋转 45 度
      ctx.fillText(text, 0, 0);
      
      ctx.restore();
    }
  }

  // 5. 导出图片
  const res = await wx.canvasToTempFilePath({
    canvas: offscreenCanvas,
    fileType: 'jpg',
    quality: 0.8, // 稍微压缩以减小体积
  });

  return res.tempFilePath;
}

4. 业务调用示例

在小程序页面中,用户选择图片并输入水印文字后,实时预览效果。

// pages/watermark/index.js
import { addWatermark } from '../../utils/watermarkUtils';

Page({
  data: {
    originImg: '',
    resultImg: '',
    watermarkText: '仅供本次业务使用 他用无效'
  },

  async onAddWatermark() {
    if (!this.data.originImg) return;

    wx.showLoading({ title: '安全合成中...' });
    
    try {
      const tempFilePath = await addWatermark(
        this.data.originImg, 
        this.data.watermarkText,
        {
          color: '#ffffff', // 白色水印
          opacity: 0.4,     // 半透明
          gap: 120          // 间距疏松一点
        }
      );
      
      this.setData({ resultImg: tempFilePath });
      
    } catch (err) {
      console.error(err);
      wx.showToast({ title: '合成失败', icon: 'none' });
    } finally {
      wx.hideLoading();
    }
  }
})

5. 避坑与实战经验

  1. 自动字号的重要性: 不要写死 fontSize = 20px。用户上传的图片分辨率差异极大(有的 500px 宽,有的 4000px 宽)。最佳实践是根据图片宽度动态计算字号(如 width * 0.04),这样无论处理缩略图还是 4K 原图,水印比例看起来都是协调的。
  2. 平铺范围的陷阱: 因为文字需要旋转 45 度,如果循环只从 0width,图片的左下角和右上角可能会出现空白。代码中我们从 -maxSize(负数区域)开始循环,确保旋转后的文字能完全覆盖画布的每一个角落。
  3. 隐私第一: 在工具的 UI 界面上,建议显著提示 “纯本地处理,无上传服务器”,这能极大地增加用户的信任感,提升工具的使用率。

写在最后

通过帮小忙工具箱的这个实践案例,我们可以看到,利用小程序强大的 Canvas 能力,开发者完全可以在保护用户隐私的前提下,提供专业级的图片处理服务。

技术不只是代码,更是对用户安全的守护。 希望这篇分享能帮你在小程序中实现更安全、更高效的功能!

无废话之 useState、useRef、useReducer 的使用场景与选择指南

在 React 中,最常用的状态管理 Hook 有三个:

  • useState
  • useRef
  • useReducer

它们都能“存数据”,但作用完全不同。
本文通过对比、代码示例和最佳实践,让你一眼看懂三者的异同点与使用策略。

1. 三者一句话总结(记住这个就够了)

Hook 特点 什么时候用
useState 会触发组件重新渲染 UI 需要根据数据变化而更新
useRef 不会触发渲染,可持久存储 保存 DOM、保存不影响 UI 的数据、避免频繁渲染
useReducer 适合复杂状态逻辑,集中管理 多步骤状态、复杂更新规则、类似 Vuex/Redux

2. useState —— 最常用的 UI 状态管理方式

📌 用途

  • 管理与 UI 显示相关的状态
  • 一旦更新 → React 会重新渲染组件

📌 示例:计数器

const [count, setCount] = useState(0);

return (
  <button onClick={() => setCount(count + 1)}>
    {count}
  </button>
);

👉 每次 setCount 运行,UI 都会更新。

3. useRef —— 不触发渲染的“可变容器”

📌 用途

  • 保存不会影响 UI 的值(计时器、缓存、临时变量)
  • 保存 DOM 节点引用
  • 在渲染周期之间持久化数据

📌 示例:保存一个不会影响 UI 的计数器

const counterRef = useRef(0);

function add() {
  counterRef.current += 1;
  console.log(counterRef.current);
}

return <button onClick={add}>Add</button>;

👉 按多少次,UI 都不会变化,因为它 不会触发重渲染

4. useReducer —— 多分支、复杂逻辑的状态管理

📌 用途

适合以下情况:

  • 状态结构复杂(多字段)
  • 更新逻辑复杂(多 if/else 或 switch)
  • 想将逻辑分离,让代码更清晰

📌 示例:管理一个表单对象

function reducer(state, action) {
  switch (action.type) {
    case "setName":
      return { ...state, name: action.payload };
    case "setAge":
      return { ...state, age: action.payload };
    case "reset":
      return { name: "", age: 0 };
    default:
      return state;
  }
}

const [form, dispatch] = useReducer(reducer, {
  name: "",
  age: 0,
});

👉 适合“动作驱动”的状态结构。

5. 三者的核心区别(最关键)

对比点 useState useRef useReducer
是否触发渲染 ✔ 会 ❌ 不会 ✔ 会
存储数据类型 简单/基本 任意 复杂对象
逻辑复杂度 简单 简单 中-高
适合多字段状态 不太合适 不合适 ✔ 最合适
跨 render 保留值
管理 DOM
适合封装业务逻辑 一般 ✔ 非常好

6. 如何选择?(最实用的决策树)

🟦 1)数据是否影响 UI?

  • 是 → useState 或 useReducer
  • 否 → useRef

🟩 2)数据更新逻辑是否复杂?

  • 复杂(多字段、多动作)→ useReducer
  • 简单(一个值)→ useState

🟨 3)更新是否非常频繁?(例如输入法、mousemove)

  • 是,但 UI 不依赖 → useRef
  • 是,且 UI 要更新 → useState + 性能优化

🟧 4)是否需要类似 Redux 的写法?

  • 是 → useReducer
  • 否 → useState / useRef

7. 最容易犯的错误(务必注意)

把 useRef 当 useState 用

const count = useRef(0);
count.current++; // UI 不更新!

👉 你以为 UI 会变,但不会。

用 useState 处理频繁更新、但 UI 不需要的数据

例如 storing mousemove 坐标:

  • 会造成大量 re-render,卡顿
  • 推荐用 useRef

在 useReducer 中修改 state(不可变规则)

错误 ❌:

state.age = 10;
return state;

正确 ✔:

return { ...state, age: 10 };

8. 三者组合使用示例(真实项目中常见)

示例:表单组件

状态 用哪个? 为什么
表单字段 useReducer 多字段、动作复杂
表单提交 loading useState 简单布尔值
DOM 节点(input) useRef 保存 DOM
防抖计时器 useRef 不触发渲染

9. 小结:最佳实践

场景 推荐
UI 状态简单 useState
UI 状态复杂、多字段 useReducer
数据不会用于渲染 useRef
保存 DOM 节点 useRef
保存缓存、前一次值 useRef
避免频繁 re-render useRef
需要统一管理 action useReducer

结语

useState、useRef、useReducer 都可以存储数据,但它们在 React 渲染机制中的角色完全不同。

  • useState = UI 状态
  • useRef = 自定义缓存 / DOM
  • useReducer = 逻辑复杂的状态机

掌握好这三者的边界,就能写出结构清晰且性能优秀的 React 代码。

Vue 3 组件开发最佳实践:可复用组件设计模式

Vue 3 组件开发最佳实践:可复用组件设计模式

前言

组件化是现代前端开发的核心思想之一,而在 Vue 3 中,借助 Composition API 和更完善的响应式系统,我们能够设计出更加灵活、可复用的组件。本文将深入探讨 Vue 3 组件开发的最佳实践,介绍多种可复用组件的设计模式,帮助开发者构建高质量的组件库。

组件设计基本原则

1. 单一职责原则

每个组件应该只负责一个明确的功能,避免功能过于复杂。

2. 开放封闭原则

组件对扩展开放,对修改封闭,通过合理的接口设计支持定制化。

3. 可组合性

组件应该易于与其他组件组合使用,形成更复杂的 UI 结构。

基础组件设计模式

1. Props 透传模式

<!-- BaseButton.vue -->
<template>
  <button 
    :class="buttonClasses"
    v-bind="$attrs"
    @click="handleClick"
  >
    <slot />
  </button>
</template>

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

const props = defineProps({
  variant: {
    type: String,
    default: 'primary',
    validator: (value) => ['primary', 'secondary', 'danger', 'ghost'].includes(value)
  },
  size: {
    type: String,
    default: 'medium',
    validator: (value) => ['small', 'medium', 'large'].includes(value)
  },
  block: {
    type: Boolean,
    default: false
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

const emit = defineEmits(['click'])

const buttonClasses = computed(() => [
  'btn',
  `btn--${props.variant}`,
  `btn--${props.size}`,
  {
    'btn--block': props.block,
    'btn--disabled': props.disabled
  }
])

const handleClick = (event) => {
  if (!props.disabled) {
    emit('click', event)
  }
}

// 允许父组件访问子组件实例
defineExpose({
  focus: () => {
    // 实现焦点管理
  }
})
</script>

<style scoped>
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-weight: 500;
  transition: all 0.2s ease;
  text-decoration: none;
}

.btn--primary {
  background-color: #42b883;
  color: white;
}

.btn--secondary {
  background-color: #6c757d;
  color: white;
}

.btn--danger {
  background-color: #dc3545;
  color: white;
}

.btn--ghost {
  background-color: transparent;
  color: #42b883;
  border: 1px solid #42b883;
}

.btn--small {
  padding: 4px 8px;
  font-size: 12px;
}

.btn--medium {
  padding: 8px 16px;
  font-size: 14px;
}

.btn--large {
  padding: 12px 24px;
  font-size: 16px;
}

.btn--block {
  display: flex;
  width: 100%;
}

.btn--disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.btn:hover:not(.btn--disabled) {
  opacity: 0.8;
  transform: translateY(-1px);
}
</style>

2. 插槽分发模式

<!-- Card.vue -->
<template>
  <div class="card" :class="cardClasses">
    <!-- 默认插槽 -->
    <div v-if="$slots.header || title" class="card__header">
      <slot name="header">
        <h3 class="card__title">{{ title }}</h3>
      </slot>
    </div>
  
    <!-- 内容插槽 -->
    <div class="card__body">
      <slot />
    </div>
  
    <!-- 底部插槽 -->
    <div v-if="$slots.footer" class="card__footer">
      <slot name="footer" />
    </div>
  
    <!-- 操作区域插槽 -->
    <div v-if="$slots.actions" class="card__actions">
      <slot name="actions" />
    </div>
  </div>
</template>

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

const props = defineProps({
  title: {
    type: String,
    default: ''
  },
  bordered: {
    type: Boolean,
    default: true
  },
  shadow: {
    type: Boolean,
    default: false
  },
  hoverable: {
    type: Boolean,
    default: false
  }
})

const cardClasses = computed(() => ({
  'card--bordered': props.bordered,
  'card--shadow': props.shadow,
  'card--hoverable': props.hoverable
}))
</script>

<style scoped>
.card {
  background: #fff;
  border-radius: 8px;
}

.card--bordered {
  border: 1px solid #e5e5e5;
}

.card--shadow {
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.card--hoverable:hover {
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}

.card__header {
  padding: 16px 24px;
  border-bottom: 1px solid #f0f0f0;
}

.card__title {
  margin: 0;
  font-size: 16px;
  font-weight: 600;
  color: #333;
}

.card__body {
  padding: 24px;
}

.card__footer {
  padding: 16px 24px;
  border-top: 1px solid #f0f0f0;
}

.card__actions {
  padding: 16px 24px;
  text-align: right;
}
</style>

使用示例:

<template>
  <Card title="用户信息" bordered hoverable>
    <template #header>
      <div class="custom-header">
        <h3>用户详情</h3>
        <BaseButton size="small" variant="ghost">编辑</BaseButton>
      </div>
    </template>
  
    <p>这里是卡片内容</p>
  
    <template #footer>
      <div class="card-footer">
        <span>创建时间: 2023-01-01</span>
      </div>
    </template>
  
    <template #actions>
      <BaseButton variant="primary">保存</BaseButton>
      <BaseButton variant="ghost">取消</BaseButton>
    </template>
  </Card>
</template>

高级组件设计模式

1. Renderless 组件模式

Renderless 组件专注于逻辑处理,不包含任何模板,通过作用域插槽传递数据和方法:

<!-- FetchData.vue -->
<template>
  <slot 
    :loading="loading"
    :data="data"
    :error="error"
    :refetch="fetchData"
  />
</template>

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

const props = defineProps({
  url: {
    type: String,
    required: true
  },
  immediate: {
    type: Boolean,
    default: true
  }
})

const loading = ref(false)
const data = ref(null)
const error = ref(null)

const fetchData = async () => {
  loading.value = true
  error.value = null

  try {
    const response = await fetch(props.url)
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`)
    }
    data.value = await response.json()
  } catch (err) {
    error.value = err.message
  } finally {
    loading.value = false
  }
}

onMounted(() => {
  if (props.immediate) {
    fetchData()
  }
})

defineExpose({
  fetchData
})
</script>

使用示例:

<template>
  <FetchData url="/api/users" v-slot="{ loading, data, error, refetch }">
    <div class="user-list">
      <div v-if="loading">加载中...</div>
      <div v-else-if="error">错误: {{ error }}</div>
    
      <template v-else>
        <div v-for="user in data" :key="user.id" class="user-item">
          {{ user.name }}
        </div>
      
        <button @click="refetch">刷新</button>
      </template>
    </div>
  </FetchData>
</template>

2. Compound Components 模式

复合组件模式允许相关组件协同工作,共享状态和配置:

<!-- Tabs.vue -->
<template>
  <div class="tabs">
    <div class="tabs__nav" role="tablist">
      <slot name="nav" :active-key="activeKey" :change-tab="changeTab" />
    </div>
    <div class="tabs__content">
      <slot :active-key="activeKey" />
    </div>
  </div>
</template>

<script setup>
import { ref, provide } from 'vue'

const props = defineProps({
  modelValue: {
    type: [String, Number],
    default: ''
  }
})

const emit = defineEmits(['update:modelValue'])

const activeKey = ref(props.modelValue)

const changeTab = (key) => {
  activeKey.value = key
  emit('update:modelValue', key)
}

// 提供给子组件使用的上下文
provide('tabs-context', {
  activeKey,
  changeTab
})
</script>

<style scoped>
.tabs {
  border: 1px solid #e5e5e5;
  border-radius: 8px;
  overflow: hidden;
}

.tabs__nav {
  display: flex;
  background-color: #f8f9fa;
  border-bottom: 1px solid #e5e5e5;
}

.tabs__content {
  padding: 24px;
}
</style>
<!-- TabNav.vue -->
<template>
  <div class="tab-nav">
    <slot />
  </div>
</template>

<style scoped>
.tab-nav {
  display: flex;
}
</style>
<!-- TabNavItem.vue -->
<template>
  <button
    :class="classes"
    :aria-selected="isActive"
    @click="handleClick"
  >
    <slot />
  </button>
</template>

<script setup>
import { inject, computed } from 'vue'

const props = defineProps({
  tabKey: {
    type: [String, Number],
    required: true
  },
  disabled: {
    type: Boolean,
    default: false
  }
})

const tabsContext = inject('tabs-context')

const isActive = computed(() => tabsContext.activeKey.value === props.tabKey)

const classes = computed(() => [
  'tab-nav-item',
  {
    'tab-nav-item--active': isActive.value,
    'tab-nav-item--disabled': props.disabled
  }
])

const handleClick = () => {
  if (!props.disabled) {
    tabsContext.changeTab(props.tabKey)
  }
}
</script>

<style scoped>
.tab-nav-item {
  padding: 12px 24px;
  border: none;
  background: transparent;
  cursor: pointer;
  font-size: 14px;
  color: #666;
  transition: all 0.2s ease;
}

.tab-nav-item:hover:not(.tab-nav-item--disabled) {
  color: #42b883;
  background-color: rgba(66, 184, 131, 0.1);
}

.tab-nav-item--active {
  color: #42b883;
  font-weight: 600;
  background-color: #fff;
  border-bottom: 2px solid #42b883;
}

.tab-nav-item--disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
</style>
<!-- TabPanel.vue -->
<template>
  <div v-show="isActive" class="tab-panel" role="tabpanel">
    <slot />
  </div>
</template>

<script setup>
import { inject, computed } from 'vue'

const props = defineProps({
  tabKey: {
    type: [String, Number],
    required: true
  }
})

const tabsContext = inject('tabs-context')

const isActive = computed(() => tabsContext.activeKey.value === props.tabKey)
</script>

<style scoped>
.tab-panel {
  outline: none;
}
</style>

使用示例:

<template>
  <Tabs v-model="activeTab">
    <template #nav="{ activeKey, changeTab }">
      <TabNavItem tab-key="profile">个人信息</TabNavItem>
      <TabNavItem tab-key="settings">设置</TabNavItem>
      <TabNavItem tab-key="security" disabled>安全</TabNavItem>
    </template>
  
    <TabPanel tab-key="profile">
      <p>这是个人信息面板</p>
    </TabPanel>
  
    <TabPanel tab-key="settings">
      <p>这是设置面板</p>
    </TabPanel>
  
    <TabPanel tab-key="security">
      <p>这是安全面板</p>
    </TabPanel>
  </Tabs>
</template>

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

const activeTab = ref('profile')
</script>

3. Higher-Order Component (HOC) 模式

虽然 Vue 更推荐使用 Composition API,但在某些场景下 HOC 仍然有用:

// withLoading.js
import { h, ref, onMounted } from 'vue'

export function withLoading(WrappedComponent, loadingMessage = '加载中...') {
  return {
    name: `WithLoading(${WrappedComponent.name || 'Component'})`,
    inheritAttrs: false,
    props: WrappedComponent.props,
    emits: WrappedComponent.emits,
    setup(props, { attrs, slots, emit }) {
      const isLoading = ref(true)
    
      onMounted(() => {
        // 模拟异步操作
        setTimeout(() => {
          isLoading.value = false
        }, 1000)
      })
    
      return () => {
        if (isLoading.value) {
          return h('div', { class: 'loading-wrapper' }, loadingMessage)
        }
      
        return h(WrappedComponent, {
          ...props,
          ...attrs,
          on: Object.keys(emit).reduce((acc, key) => {
            acc[key] = (...args) => emit(key, ...args)
            return acc
          }, {})
        }, slots)
      }
    }
  }
}

4. State Reducer 模式

借鉴 React 的理念,通过 reducer 函数管理复杂状态:

<!-- Toggle.vue -->
<template>
  <div class="toggle">
    <slot 
      :on="on"
      :toggle="toggle"
      :set-on="setOn"
      :set-off="setOff"
    />
  </div>
</template>

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

const props = defineProps({
  modelValue: {
    type: Boolean,
    default: false
  },
  reducer: {
    type: Function,
    default: null
  }
})

const emit = defineEmits(['update:modelValue'])

const internalOn = ref(props.modelValue)

const getState = () => ({
  on: internalOn.value
})

const dispatch = (action) => {
  const changes = props.reducer 
    ? props.reducer(getState(), action)
    : defaultReducer(getState(), action)
  
  if (changes.on !== undefined) {
    internalOn.value = changes.on
    emit('update:modelValue', changes.on)
  }
}

const defaultReducer = (state, action) => {
  switch (action.type) {
    case 'toggle':
      return { on: !state.on }
    case 'setOn':
      return { on: true }
    case 'setOff':
      return { on: false }
    default:
      throw new Error(`Unknown action type: ${action.type}`)
  }
}

const toggle = () => dispatch({ type: 'toggle' })
const setOn = () => dispatch({ type: 'setOn' })
const setOff = () => dispatch({ type: 'setOff' })

defineExpose({
  toggle,
  setOn,
  setOff
})
</script>

使用示例:

<template>
  <Toggle :reducer="toggleReducer" v-slot="{ on, toggle, setOn, setOff }">
    <div class="toggle-demo">
      <p>状态: {{ on ? '开启' : '关闭' }}</p>
      <BaseButton @click="toggle">切换</BaseButton>
      <BaseButton @click="setOn">开启</BaseButton>
      <BaseButton @click="setOff">关闭</BaseButton>
    </div>
  </Toggle>
</template>

<script setup>
const toggleReducer = (state, action) => {
  switch (action.type) {
    case 'toggle':
      // 添加日志记录
      console.log('Toggle state changed:', !state.on)
      return { on: !state.on }
    case 'setOn':
      return { on: true }
    case 'setOff':
      return { on: false }
    default:
      return state
  }
}
</script>

组件通信最佳实践

1. Provide/Inject 模式

// theme.js
import { ref, readonly, computed } from 'vue'

const themeSymbol = Symbol('theme')

export function createThemeStore() {
  const currentTheme = ref('light')

  const themes = {
    light: {
      primary: '#42b883',
      background: '#ffffff',
      text: '#333333'
    },
    dark: {
      primary: '#42b883',
      background: '#1a1a1a',
      text: '#ffffff'
    }
  }

  const toggleTheme = () => {
    currentTheme.value = currentTheme.value === 'light' ? 'dark' : 'light'
  }

  const themeConfig = computed(() => themes[currentTheme.value])

  return {
    currentTheme: readonly(currentTheme),
    themeConfig,
    toggleTheme
  }
}

export function provideTheme(themeStore) {
  provide(themeSymbol, themeStore)
}

export function useTheme() {
  const themeStore = inject(themeSymbol)
  if (!themeStore) {
    throw new Error('useTheme must be used within provideTheme')
  }
  return themeStore
}

2. Event Bus 替代方案

使用 mitt 库替代传统的事件总线:

// eventBus.js
import mitt from 'mitt'

export const eventBus = mitt()

// 在组件中使用
// eventBus.emit('user-login', userInfo)
// eventBus.on('user-login', handler)

性能优化策略

1. 组件懒加载

// router/index.js
const routes = [
  {
    path: '/heavy-component',
    component: () => import('@/components/HeavyComponent.vue')
  }
]

// 组件内部懒加载
const HeavyChart = defineAsyncComponent(() => 
  import('@/components/charts/HeavyChart.vue')
)

2. 虚拟滚动

<!-- VirtualList.vue -->
<template>
  <div 
    ref="containerRef" 
    class="virtual-list"
    @scroll="handleScroll"
  >
    <div :style="{ height: totalHeight + 'px' }" class="virtual-list__spacer">
      <div 
        :style="{ transform: `translateY(${offsetY}px)` }"
        class="virtual-list__content"
      >
        <div
          v-for="item in visibleItems"
          :key="item.id"
          :style="{ height: itemHeight + 'px' }"
          class="virtual-list__item"
        >
          <slot :item="item" />
        </div>
      </div>
    </div>
  </div>
</template>

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

const props = defineProps({
  items: {
    type: Array,
    required: true
  },
  itemHeight: {
    type: Number,
    default: 50
  },
  bufferSize: {
    type: Number,
    default: 5
  }
})

const containerRef = ref(null)
const scrollTop = ref(0)

const totalHeight = computed(() => props.items.length * props.itemHeight)

const startIndex = computed(() => {
  return Math.max(0, Math.floor(scrollTop.value / props.itemHeight) - props.bufferSize)
})

const endIndex = computed(() => {
  const containerHeight = containerRef.value?.clientHeight || 0
  return Math.min(
    props.items.length - 1,
    Math.floor((scrollTop.value + containerHeight) / props.itemHeight) + props.bufferSize
  )
})

const visibleItems = computed(() => {
  return props.items.slice(startIndex.value, endIndex.value + 1)
})

const offsetY = computed(() => {
  return startIndex.value * props.itemHeight
})

const handleScroll = () => {
  scrollTop.value = containerRef.value.scrollTop
}

onMounted(() => {
  // 初始化滚动监听
})

onUnmounted(() => {
  // 清理资源
})
</script>

<style scoped>
.virtual-list {
  height: 400px;
  overflow-y: auto;
  border: 1px solid #e5e5e5;
}

.virtual-list__spacer {
  position: relative;
}

.virtual-list__content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
}

.virtual-list__item {
  display: flex;
  align-items: center;
  padding: 0 16px;
  border-bottom: 1px solid #f0f0f0;
}
</style>

测试友好的组件设计

1. 明确的 Props 定义

// Button.test.js
import { mount } from '@vue/test-utils'
import BaseButton from '@/components/BaseButton.vue'

describe('BaseButton', () => {
  test('renders slot content', () => {
    const wrapper = mount(BaseButton, {
      slots: {
        default: 'Click me'
      }
    })
    expect(wrapper.text()).toContain('Click me')
  })

  test('emits click event when clicked', async () => {
    const wrapper = mount(BaseButton)
    await wrapper.trigger('click')
    expect(wrapper.emitted()).toHaveProperty('click')
  })

  test('applies correct CSS classes based on props', () => {
    const wrapper = mount(BaseButton, {
      props: {
        variant: 'primary',
        size: 'large'
      }
    })
    expect(wrapper.classes()).toContain('btn--primary')
    expect(wrapper.classes()).toContain('btn--large')
  })
})

2. 可访问性考虑

<!-- AccessibleModal.vue -->
<template>
  <teleport to="body">
    <div 
      v-if="visible"
      ref="modalRef"
      role="dialog"
      aria-modal="true"
      :aria-labelledby="titleId"
      :aria-describedby="descriptionId"
      class="modal"
      @keydown.esc="close"
    >
      <div class="modal__overlay" @click="close"></div>
      <div class="modal__content" ref="contentRef">
        <div class="modal__header">
          <h2 :id="titleId" class="modal__title">{{ title }}</h2>
          <button 
            type="button"
            class="modal__close"
            @click="close"
            aria-label="关闭对话框"
          >
            ×
          </button>
        </div>
      
        <div :id="descriptionId" class="modal__body">
          <slot />
        </div>
      
        <div v-if="$slots.footer" class="modal__footer">
          <slot name="footer" />
        </div>
      </div>
    </div>
  </teleport>
</template>

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

const props = defineProps({
  visible: {
    type: Boolean,
    default: false
  },
  title: {
    type: String,
    required: true
  }
})

const emit = defineEmits(['update:visible', 'close'])

const modalRef = ref(null)
const contentRef = ref(null)
const titleId = `modal-title-${Math.random().toString(36).substr(2, 9)}`
const descriptionId = `modal-desc-${Math.random().toString(36).substr(2, 9)}`

const close = () => {
  emit('update:visible', false)
  emit('close')
}

watch(() => props.visible, async (newVal) => {
  if (newVal) {
    await nextTick()
    // 自动聚焦到模态框
    contentRef.value?.focus()
  }
})
</script>

结语

Vue 3 组件开发的最佳实践涉及多个方面,从基础的 Props 和插槽使用,到高级的设计模式如 Renderless 组件和 Compound Components,每种模式都有其适用场景。关键是要根据具体需求选择合适的设计模式,并遵循以下原则:

  1. 保持组件简洁:每个组件专注于单一功能
  2. 提供良好的 API:清晰的 Props 定义和事件接口
  3. 重视可访问性:确保所有用户都能正常使用组件
  4. 考虑性能影响:特别是在处理大量数据或复杂交互时
  5. 便于测试:设计易于测试的组件接口

通过合理运用这些设计模式和最佳实践,我们可以构建出既灵活又可靠的组件库,为整个应用提供一致且高质量的用户体验。记住,好的组件设计不是一次性的任务,而是需要在实践中不断迭代和完善的过程。

Vue 3 动画效果实现:Transition和TransitionGroup详解

Vue 3 动画效果实现:Transition和TransitionGroup详解

前言

在现代Web应用中,流畅的动画效果不仅能提升用户体验,还能有效传达界面状态变化的信息。Vue 3 提供了强大的过渡和动画系统,通过 <transition><transition-group> 组件,开发者可以轻松地为元素的进入、离开和列表变化添加动画效果。本文将深入探讨这两个组件的使用方法和高级技巧。

Transition 组件基础

基本用法

<transition> 组件用于包装单个元素或组件,在插入、更新或移除时应用过渡效果。

<template>
  <div>
    <button @click="show = !show">切换显示</button>
    <transition name="fade">
      <p v-if="show">Hello Vue 3!</p>
    </transition>
  </div>
</template>

<script setup>
import { ref } from 'vue'
const show = ref(true)
</script>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.5s ease;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

过渡类名详解

Vue 3 为进入/离开过渡提供了6个CSS类名:

  1. v-enter-from:进入过渡的开始状态
  2. v-enter-active:进入过渡生效时的状态
  3. v-enter-to:进入过渡的结束状态
  4. v-leave-from:离开过渡的开始状态
  5. v-leave-active:离开过渡生效时的状态
  6. v-leave-to:离开过渡的结束状态

注意:在 Vue 3 中,类名前缀从 v-enter 改为 v-enter-from,其他类名也相应调整。

JavaScript 钩子函数

除了CSS过渡,还可以使用JavaScript钩子来控制动画:

<template>
  <transition
    @before-enter="beforeEnter"
    @enter="enter"
    @after-enter="afterEnter"
    @before-leave="beforeLeave"
    @leave="leave"
    @after-leave="afterLeave"
  >
    <div v-if="show" class="box">Animated Box</div>
  </transition>
</template>

<script setup>
import { ref } from 'vue'
import gsap from 'gsap'

const show = ref(true)

const beforeEnter = (el) => {
  el.style.opacity = 0
  el.style.transform = 'scale(0)'
}

const enter = (el, done) => {
  gsap.to(el, {
    duration: 0.5,
    opacity: 1,
    scale: 1,
    onComplete: done
  })
}

const afterEnter = (el) => {
  console.log('进入完成')
}

const beforeLeave = (el) => {
  el.style.transformOrigin = 'center'
}

const leave = (el, done) => {
  gsap.to(el, {
    duration: 0.5,
    opacity: 0,
    scale: 0,
    onComplete: done
  })
}

const afterLeave = (el) => {
  console.log('离开完成')
}
</script>

常见动画效果实现

1. 淡入淡出效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Fade</button>
    <transition name="fade">
      <div v-if="show" class="content">Fade Effect Content</div>
    </transition>
  </div>
</template>

<style>
.fade-enter-active,
.fade-leave-active {
  transition: opacity 0.3s ease-in-out;
}

.fade-enter-from,
.fade-leave-to {
  opacity: 0;
}
</style>

2. 滑动效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Slide</button>
    <transition name="slide">
      <div v-if="show" class="content">Slide Effect Content</div>
    </transition>
  </div>
</template>

<style>
.slide-enter-active,
.slide-leave-active {
  transition: all 0.3s ease;
  max-height: 200px;
  overflow: hidden;
}

.slide-enter-from,
.slide-leave-to {
  max-height: 0;
  opacity: 0;
  transform: translateY(-20px);
}
</style>

3. 弹跳效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Bounce</button>
    <transition name="bounce">
      <div v-if="show" class="content">Bounce Effect Content</div>
    </transition>
  </div>
</template>

<style>
.bounce-enter-active {
  animation: bounce-in 0.5s;
}

.bounce-leave-active {
  animation: bounce-in 0.5s reverse;
}

@keyframes bounce-in {
  0% {
    transform: scale(0);
    opacity: 0;
  }
  50% {
    transform: scale(1.2);
  }
  100% {
    transform: scale(1);
    opacity: 1;
  }
}
</style>

4. 翻转效果

<template>
  <div class="demo">
    <button @click="show = !show">Toggle Flip</button>
    <transition name="flip">
      <div v-if="show" class="content flip-content">Flip Effect Content</div>
    </transition>
  </div>
</template>

<style>
.flip-enter-active {
  animation: flip-in 0.6s ease forwards;
}

.flip-leave-active {
  animation: flip-out 0.6s ease forwards;
}

@keyframes flip-in {
  0% {
    transform: perspective(400px) rotateY(90deg);
    opacity: 0;
  }
  40% {
    transform: perspective(400px) rotateY(-10deg);
  }
  70% {
    transform: perspective(400px) rotateY(10deg);
  }
  100% {
    transform: perspective(400px) rotateY(0deg);
    opacity: 1;
  }
}

@keyframes flip-out {
  0% {
    transform: perspective(400px) rotateY(0deg);
    opacity: 1;
  }
  100% {
    transform: perspective(400px) rotateY(90deg);
    opacity: 0;
  }
}
</style>

TransitionGroup 组件详解

基本列表动画

<transition-group> 用于为列表中的元素添加进入/离开过渡效果:

<template>
  <div class="list-demo">
    <button @click="addItem">添加项目</button>
    <button @click="removeItem">删除项目</button>
  
    <transition-group name="list" tag="ul">
      <li v-for="item in items" :key="item.id" class="list-item">
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

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

const items = reactive([
  { id: 1, text: '项目 1' },
  { id: 2, text: '项目 2' },
  { id: 3, text: '项目 3' }
])

let nextId = 4

const addItem = () => {
  const index = Math.floor(Math.random() * (items.length + 1))
  items.splice(index, 0, {
    id: nextId++,
    text: `新项目 ${nextId - 1}`
  })
}

const removeItem = () => {
  if (items.length > 0) {
    const index = Math.floor(Math.random() * items.length)
    items.splice(index, 1)
  }
}
</script>

<style>
.list-enter-active,
.list-leave-active {
  transition: all 0.5s ease;
}

.list-enter-from,
.list-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

.list-move {
  transition: transform 0.5s ease;
}

.list-item {
  padding: 10px;
  margin: 5px 0;
  background-color: #f0f0f0;
  border-radius: 4px;
}
</style>

列表排序动画

<template>
  <div class="shuffle-demo">
    <button @click="shuffle">随机排序</button>
    <button @click="add">添加</button>
    <button @click="remove">删除</button>
  
    <transition-group name="shuffle" tag="div" class="grid">
      <div 
        v-for="item in items" 
        :key="item.id" 
        class="grid-item"
        @click="removeItem(item)"
      >
        {{ item.number }}
      </div>
    </transition-group>
  </div>
</template>

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

const items = reactive([
  { id: 1, number: 1 },
  { id: 2, number: 2 },
  { id: 3, number: 3 },
  { id: 4, number: 4 },
  { id: 5, number: 5 }
])

const shuffle = () => {
  // Fisher-Yates 洗牌算法
  for (let i = items.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [items[i], items[j]] = [items[j], items[i]]
  }
}

const add = () => {
  const newNumber = items.length > 0 ? Math.max(...items.map(i => i.number)) + 1 : 1
  items.push({
    id: Date.now(),
    number: newNumber
  })
}

const remove = () => {
  if (items.length > 0) {
    items.pop()
  }
}

const removeItem = (item) => {
  const index = items.indexOf(item)
  if (index > -1) {
    items.splice(index, 1)
  }
}
</script>

<style>
.grid {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 20px;
}

.grid-item {
  width: 60px;
  height: 60px;
  background-color: #42b883;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  cursor: pointer;
  font-weight: bold;
  user-select: none;
}

.shuffle-enter-active,
.shuffle-leave-active {
  transition: all 0.5s ease;
}

.shuffle-enter-from {
  opacity: 0;
  transform: scale(0.5);
}

.shuffle-leave-to {
  opacity: 0;
  transform: scale(0.5);
}

.shuffle-move {
  transition: transform 0.5s ease;
}
</style>

高级动画技巧

1. FLIP 技术实现平滑动画

FLIP (First, Last, Invert, Play) 是一种优化动画性能的技术:

<template>
  <div class="flip-demo">
    <button @click="filterItems">筛选奇数</button>
    <button @click="resetFilter">重置</button>
  
    <transition-group 
      name="flip-list" 
      tag="div" 
      class="flip-container"
      @before-enter="beforeEnter"
      @enter="enter"
      @leave="leave"
    >
      <div 
        v-for="item in filteredItems" 
        :key="item.id" 
        class="flip-item"
      >
        {{ item.value }}
      </div>
    </transition-group>
  </div>
</template>

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

const items = ref(Array.from({ length: 20 }, (_, i) => ({
  id: i + 1,
  value: i + 1
})))

const filterOdd = ref(false)

const filteredItems = computed(() => {
  return filterOdd.value 
    ? items.value.filter(item => item.value % 2 === 1)
    : items.value
})

const filterItems = () => {
  filterOdd.value = true
}

const resetFilter = () => {
  filterOdd.value = false
}

const positions = new Map()

const beforeEnter = (el) => {
  el.style.opacity = '0'
  el.style.transform = 'scale(0.8)'
}

const enter = (el, done) => {
  // 获取最终位置
  const end = el.getBoundingClientRect()
  const start = positions.get(el)

  if (start) {
    // 计算位置差
    const dx = start.left - end.left
    const dy = start.top - end.top
    const ds = start.width / end.width
  
    // 反向变换
    el.style.transform = `translate(${dx}px, ${dy}px) scale(${ds})`
  
    // 强制重绘
    el.offsetHeight
  
    // 执行动画
    el.style.transition = 'all 0.3s ease'
    el.style.transform = ''
    el.style.opacity = '1'
  
    setTimeout(done, 300)
  } else {
    el.style.transition = 'all 0.3s ease'
    el.style.transform = ''
    el.style.opacity = '1'
    setTimeout(done, 300)
  }
}

const leave = (el, done) => {
  // 记录初始位置
  positions.set(el, el.getBoundingClientRect())
  el.style.position = 'absolute'
  done()
}
</script>

<style>
.flip-container {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(60px, 1fr));
  gap: 10px;
  position: relative;
  min-height: 200px;
}

.flip-item {
  background-color: #3498db;
  color: white;
  padding: 20px;
  text-align: center;
  border-radius: 8px;
  font-weight: bold;
}

.flip-list-enter-active,
.flip-list-leave-active {
  transition: all 0.3s ease;
}

.flip-list-enter-from,
.flip-list-leave-to {
  opacity: 0;
  transform: translateY(30px);
}

.flip-list-move {
  transition: transform 0.3s ease;
}
</style>

2. 交错动画

<template>
  <div class="stagger-demo">
    <button @click="loadItems">加载项目</button>
    <button @click="clearItems">清空</button>
  
    <transition-group 
      name="staggered-fade" 
      tag="ul" 
      class="staggered-list"
    >
      <li 
        v-for="(item, index) in items" 
        :key="item.id"
        :data-index="index"
        class="staggered-item"
      >
        {{ item.text }}
      </li>
    </transition-group>
  </div>
</template>

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

const items = ref([])

const loadItems = () => {
  items.value = Array.from({ length: 10 }, (_, i) => ({
    id: Date.now() + i,
    text: `项目 ${i + 1}`
  }))
}

const clearItems = () => {
  items.value = []
}
</script>

<style>
.staggered-list {
  list-style: none;
  padding: 0;
}

.staggered-item {
  padding: 15px;
  margin: 5px 0;
  background-color: #e74c3c;
  color: white;
  border-radius: 6px;
  opacity: 0;
}

/* 进入动画 */
.staggered-fade-enter-active {
  transition: all 0.3s ease;
}

.staggered-fade-enter-from {
  opacity: 0;
  transform: translateX(-30px);
}

/* 离开动画 */
.staggered-fade-leave-active {
  transition: all 0.3s ease;
  position: absolute;
}

.staggered-fade-leave-to {
  opacity: 0;
  transform: translateX(30px);
}

/* 移动动画 */
.staggered-fade-move {
  transition: transform 0.3s ease;
}

/* 交错延迟 */
.staggered-item:nth-child(1) { transition-delay: 0.05s; }
.staggered-item:nth-child(2) { transition-delay: 0.1s; }
.staggered-item:nth-child(3) { transition-delay: 0.15s; }
.staggered-item:nth-child(4) { transition-delay: 0.2s; }
.staggered-item:nth-child(5) { transition-delay: 0.25s; }
.staggered-item:nth-child(6) { transition-delay: 0.3s; }
.staggered-item:nth-child(7) { transition-delay: 0.35s; }
.staggered-item:nth-child(8) { transition-delay: 0.4s; }
.staggered-item:nth-child(9) { transition-delay: 0.45s; }
.staggered-item:nth-child(10) { transition-delay: 0.5s; }
</style>

3. 页面切换动画

<!-- App.vue -->
<template>
  <div id="app">
    <nav>
      <router-link to="/">首页</router-link>
      <router-link to="/about">关于</router-link>
      <router-link to="/contact">联系</router-link>
    </nav>
  
    <router-view v-slot="{ Component }">
      <transition name="page" mode="out-in">
        <component :is="Component" />
      </transition>
    </router-view>
  </div>
</template>

<style>
.page-enter-active,
.page-leave-active {
  transition: all 0.3s ease;
  position: absolute;
  top: 60px;
  left: 0;
  right: 0;
}

.page-enter-from {
  opacity: 0;
  transform: translateX(30px);
}

.page-leave-to {
  opacity: 0;
  transform: translateX(-30px);
}

nav {
  padding: 20px;
  background-color: #f8f9fa;
}

nav a {
  margin-right: 20px;
  text-decoration: none;
  color: #333;
}

nav a.router-link-active {
  color: #42b883;
  font-weight: bold;
}
</style>

性能优化建议

1. 使用 transform 和 opacity

优先使用 transformopacity 属性,因为它们不会触发重排:

/* 推荐 */
.good-animation {
  transition: transform 0.3s ease, opacity 0.3s ease;
}

/* 避免 */
.bad-animation {
  transition: left 0.3s ease, top 0.3s ease;
}

2. 合理使用 will-change

对于复杂的动画,可以提前告知浏览器优化:

.animated-element {
  will-change: transform, opacity;
}

3. 避免阻塞主线程

对于复杂动画,考虑使用 Web Workers 或 requestAnimationFrame:

const animateElement = (element, duration) => {
  const startTime = performance.now()

  const animate = (currentTime) => {
    const elapsed = currentTime - startTime
    const progress = Math.min(elapsed / duration, 1)
  
    // 更新元素样式
    element.style.transform = `translateX(${progress * 100}px)`
  
    if (progress < 1) {
      requestAnimationFrame(animate)
    }
  }

  requestAnimationFrame(animate)
}

结语

Vue 3 的过渡和动画系统为我们提供了强大而灵活的工具来创建丰富的用户界面体验。通过合理运用 <transition><transition-group> 组件,结合 CSS3 动画和 JavaScript 控制,我们能够实现从简单到复杂的各种动画效果。

关键要点总结:

  1. 理解过渡类名机制:掌握6个核心类名的作用时机
  2. 善用 JavaScript 钩子:实现更复杂的自定义动画逻辑
  3. 列表动画的重要性:使用 <transition-group> 处理动态列表
  4. 性能优化意识:选择合适的 CSS 属性和动画技术
  5. 用户体验考量:动画应该增强而不是阻碍用户操作

在实际项目中,建议根据具体需求选择合适的动画方案,并始终考虑性能影响。适度的动画能够显著提升用户体验,但过度或不当的动画反而会适得其反。希望本文能够帮助你在 Vue 3 项目中更好地实现和控制动画效果。

别再用mixin了!Vue3自定义Hooks让逻辑复用爽到飞起

前言

随着 Vue 3 的普及,Composition API 成为了构建复杂应用的主流方式。相比 Options API,Composition API 提供了更好的逻辑组织和复用能力。而自定义 Hooks 正是这一能力的核心体现,它让我们能够将业务逻辑抽象成可复用的函数,极大地提升了代码的可维护性和开发效率。

什么是自定义 Hooks?

自定义 Hooks 是基于 Composition API 封装的可复用逻辑函数。它们通常以 use 开头命名,返回响应式数据、方法或计算属性。通过自定义 Hooks,我们可以将组件中的逻辑抽离出来,在多个组件间共享。

基本结构

// useCounter.js
import { ref, computed } from 'vue'

export function useCounter(initialValue = 0) {
  const count = ref(initialValue)

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

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

  const doubleCount = computed(() => count.value * 2)

  return {
    count,
    increment,
    decrement,
    doubleCount
  }
}

实战案例:常用自定义 Hooks

1. 网络请求 Hook

// useApi.js
import { ref, onMounted } from 'vue'
import axios from 'axios'

export function useApi(url, options = {}) {
  const data = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const fetchData = async (params = {}) => {
    loading.value = true
    error.value = null
  
    try {
      const response = await axios.get(url, { ...options, params })
      data.value = response.data
    } catch (err) {
      error.value = err.message
    } finally {
      loading.value = false
    }
  }

  onMounted(() => {
    if (options.immediate !== false) {
      fetchData()
    }
  })

  return {
    data,
    loading,
    error,
    fetchData
  }
}

使用示例:

<template>
  <div>
    <div v-if="loading">加载中...</div>
    <div v-else-if="error">{{ error }}</div>
    <ul v-else>
      <li v-for="item in data" :key="item.id">
        {{ item.name }}
      </li>
    </ul>
    <button @click="fetchData">刷新</button>
  </div>
</template>

<script setup>
import { useApi } from '@/hooks/useApi'

const { data, loading, error, fetchData } = useApi('/api/users')
</script>

2. 表单验证 Hook

// useForm.js
import { reactive, computed } from 'vue'

export function useForm(initialValues, rules) {
  const formData = reactive({ ...initialValues })
  const errors = reactive({})

  const validateField = (field) => {
    const value = formData[field]
    const fieldRules = rules[field] || []
  
    for (const rule of fieldRules) {
      if (!rule.validator(value, formData)) {
        errors[field] = rule.message
        return false
      }
    }
  
    delete errors[field]
    return true
  }

  const validateAll = () => {
    let isValid = true
    Object.keys(rules).forEach(field => {
      if (!validateField(field)) {
        isValid = false
      }
    })
    return isValid
  }

  const resetForm = () => {
    Object.assign(formData, initialValues)
    Object.keys(errors).forEach(key => {
      delete errors[key]
    })
  }

  const isDirty = computed(() => {
    return JSON.stringify(formData) !== JSON.stringify(initialValues)
  })

  return {
    formData,
    errors,
    validateField,
    validateAll,
    resetForm,
    isDirty
  }
}

使用示例:

<template>
  <form @submit.prevent="handleSubmit">
    <div>
      <input 
        v-model="formData.username" 
        @blur="() => validateField('username')"
        placeholder="用户名"
      />
      <span v-if="errors.username" class="error">{{ errors.username }}</span>
    </div>
  
    <div>
      <input 
        v-model="formData.email" 
        @blur="() => validateField('email')"
        placeholder="邮箱"
      />
      <span v-if="errors.email" class="error">{{ errors.email }}</span>
    </div>
  
    <button type="submit" :disabled="!isDirty">提交</button>
    <button type="button" @click="resetForm">重置</button>
  </form>
</template>

<script setup>
import { useForm } from '@/hooks/useForm'

const { formData, errors, validateField, validateAll, resetForm, isDirty } = useForm(
  { username: '', email: '' },
  {
    username: [
      {
        validator: (value) => value.length >= 3,
        message: '用户名至少3个字符'
      }
    ],
    email: [
      {
        validator: (value) => /\S+@\S+\.\S+/.test(value),
        message: '请输入有效的邮箱地址'
      }
    ]
  }
)

const handleSubmit = () => {
  if (validateAll()) {
    console.log('表单验证通过:', formData)
  }
}
</script>

3. 防抖节流 Hook

// useDebounce.js
import { ref, watch } from 'vue'

export function useDebounce(value, delay = 300) {
  const debouncedValue = ref(value.value)
  let timeoutId = null

  watch(value, (newValue) => {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => {
      debouncedValue.value = newValue
    }, delay)
  })

  return debouncedValue
}

// useThrottle.js
export function useThrottle(value, delay = 300) {
  const throttledValue = ref(value.value)
  let lastTime = 0

  watch(value, (newValue) => {
    const now = Date.now()
    if (now - lastTime >= delay) {
      throttledValue.value = newValue
      lastTime = now
    }
  })

  return throttledValue
}

4. 本地存储 Hook

// useLocalStorage.js
import { ref, watch } from 'vue'

export function useLocalStorage(key, defaultValue) {
  const storedValue = localStorage.getItem(key)
  const value = ref(storedValue ? JSON.parse(storedValue) : defaultValue)

  watch(value, (newValue) => {
    if (newValue === null) {
      localStorage.removeItem(key)
    } else {
      localStorage.setItem(key, JSON.stringify(newValue))
    }
  }, { deep: true })

  const remove = () => {
    value.value = null
  }

  return [value, remove]
}

高级技巧与最佳实践

1. Hook 组合

// useUserManagement.js
import { useApi } from './useApi'
import { useLocalStorage } from './useLocalStorage'

export function useUserManagement() {
  const [currentUser, removeCurrentUser] = useLocalStorage('currentUser', null)
  const { data: users, loading, error, fetchData } = useApi('/api/users')

  const login = async (credentials) => {
    // 登录逻辑
    const response = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify(credentials)
    })
    const userData = await response.json()
    currentUser.value = userData
  }

  const logout = () => {
    removeCurrentUser()
    // 其他登出逻辑
  }

  return {
    currentUser,
    users,
    loading,
    error,
    login,
    logout,
    refreshUsers: fetchData
  }
}

2. 错误处理

// useAsync.js
import { ref, onMounted } from 'vue'

export function useAsync(asyncFunction, immediate = true) {
  const result = ref(null)
  const loading = ref(false)
  const error = ref(null)

  const execute = async (...args) => {
    loading.value = true
    error.value = null
  
    try {
      const response = await asyncFunction(...args)
      result.value = response
      return response
    } catch (err) {
      error.value = err
      throw err
    } finally {
      loading.value = false
    }
  }

  onMounted(() => {
    if (immediate) {
      execute()
    }
  })

  return {
    result,
    loading,
    error,
    execute
  }
}

3. 类型安全(TypeScript)

// useCounter.ts
import { ref, computed, Ref, ComputedRef } from 'vue'

interface UseCounterReturn {
  count: Ref<number>
  increment: () => void
  decrement: () => void
  doubleCount: ComputedRef<number>
}

export function useCounter(initialValue: number = 0): UseCounterReturn {
  const count = ref(initialValue)

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

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

  const doubleCount = computed(() => count.value * 2)

  return {
    count,
    increment,
    decrement,
    doubleCount
  }
}

设计原则与注意事项

1. 单一职责原则

每个 Hook 应该只负责一个特定的功能领域,保持功能单一且专注。

2. 命名规范

  • 使用 use 前缀
  • 名称清晰表达 Hook 的用途
  • 避免过于通用的名称

3. 返回值设计

  • 返回对象而非数组(便于解构时命名)
  • 保持返回值的一致性
  • 考虑添加辅助方法

4. 性能优化

  • 合理使用 watchcomputed
  • 避免不必要的重新计算
  • 及时清理副作用

结语

自定义 Hooks 是 Vue 3 Composition API 生态中的重要组成部分,它不仅解决了逻辑复用的问题,更提供了一种更加灵活和可组合的开发模式。通过合理地设计和使用自定义 Hooks,我们可以:

  1. 提升代码复用性:将通用逻辑抽象成独立模块
  2. 改善代码组织:让组件更加关注视图逻辑
  3. 增强可测试性:独立的逻辑更容易进行单元测试
  4. 提高开发效率:减少重复代码编写

在实际项目中,建议根据业务需求逐步积累和优化自定义 Hooks,建立属于团队的 Hooks 库,这将是提升前端开发质量和效率的重要手段。

记住,好的自定义 Hooks 不仅要解决当前问题,更要具备良好的扩展性和可维护性。随着经验的积累,你会发现自己能够创造出越来越优雅和实用的自定义 Hooks。

数据标注平台正式上线啦! 标注赚现金,低门槛真收益 | 掘金一周 12.10

本文字数1400+ ,阅读时间大约需要 5分钟。

【掘金一周】本期亮点:

「上榜规则」:文章发布时间在本期「掘金一周」发布时间的前一周内;且符合各个栏目的内容定位和要求。 如发现文章有抄袭、洗稿等违反社区规则的行为,将取消当期及后续上榜资格。

一周“金”选

掘金一周 文章头图 1303x734.jpg

内容评审们会在过去的一周内对社区深度技术好文进行挖掘和筛选,优质的技术文章有机会出现在下方榜单中,排名不分先后。

前端

如何用隐形字符给公司内部文档加盲水印?(抓内鬼神器🤣) @ErpanOmer

文章讲述公司内部敏感文档泄露后,利用基于零宽字符的盲水印技术抓“内鬼”。介绍零宽字符概念,阐述加密、解密原理,给出实现代码,还提及水印可被清除,强调这是低成本、高隐蔽性防御手段。

不仅免费,还开源?这个 AI Mock 神器我必须曝光它 @不一样的少年_

本文介绍了一款零侵入的接口 Mock 插件,重构为 Sidebar 常驻侧栏,体验更佳。它接入 AI 自动生成数据,支持延时和状态码模拟。具备拦截、匹配、响应控制等功能,覆盖前端 90% 的 Mock 需求,推荐试用。

后端

开源企业级 IM!一款高颜值的即时通讯聊天应用! @Java陈序员

本文推荐了基于 GO 开发的开源即时通讯系统 TangSengDaoDaoServer,它轻量、高性能且重安全,支持多端同步。介绍了其功能特色、项目架构,给出 Docker 部署步骤,还展示功能体验,推荐大家尝试。

Android

用 AI 做了几个超炫酷的 Flutter 动画,同时又差点被 AI 气死 @恋猫de小郭

文章介绍用 AI 实现几种 Flutter 动画。奇异粒子动画基于数学公式,解决投影等问题;斐波那契球体让点均匀分布在球面;星云动画模拟星系动力学。不过,AI 实现时遇颜色插值陷阱问题,最终换思路解决。

Android Studio Otter 2 Feature 发布,最值得更新的 Android Studio @恋猫de小郭

Android Studio Otter 2 Feature发布,是值得更新的版本。它内置Gemini 3,增强Agent模式并配备Android知识库。支持备份与同步设置,开发者可接收团队资讯。还整合IntelliJ IDEA 2025.2改进,能免费试用Gemini 3 Pro。

Flutter TolyUI 框架#09 | tolyui_text 轻量高亮文本 @张风捷特烈

本文介绍了 Flutter TolyUI 框架的 tolyui_text 模块。该模块封装文本高亮方案,提供轻量级解决方案。支持搜索关键字高亮、自定义匹配规则和多模式智能识别,还能处理点击事件,未来会有更多新功能。

人工智能

🔥 懂原理但不会说?我怒写了个 AI 模拟器折磨自己,M属性大爆发! @HiStewie

作者为解决面试准备难题,用 TRAE SOLO 重构初版工具。从架构设计、核心实现、技术栈选型等多方面展开,一晚完成含简历解析等功能的 MVP,验证新开发范式,未来产品还有诸多扩展方向。

解读 Claude 对开发者的影响:AI 如何在 Anthropic 改变工作?@恋猫de小郭

Anthropic 对内部员工调查显示,AI 显著影响开发者。生产力平均提升 50%,启用新工作,改变委托实践。开发者技能有扩展与退化,社会互动减少,职业认同受冲击。AI 红利与债务并存,重塑职业价值观。

Chatbox支持接入LangGraph智能体?一切都靠Trae Solo!@大模型真好玩

本文作者借助 Trae Solo 实现将 LangChain 智能体接入 Chatbox 客户端。先介绍两者,阐述接入思路,以天气助手智能体为例展示 Trae Solo 自动化开发流程,最后展望其潜力,鼓励用它快速搭建原型、验证逻辑。

IOS

iOS UIKit 全体系知识手册(Objective-C 版) @如此风景

UIKit 是 iOS 开发基石框架,围绕视图、控制器、事件展开。掌握布局、事件处理、渲染优化是关键。开发中用 Masonry 简化布局,结合适配特性,借助 Instruments 定位问题,可高效构建稳定适配界面。

社区活动日历

掘金官方 文章头图 1303x734.jpg

活动日历

活动名称 活动时间
🚀TRAE SOLO 实战赛 2025年11月13日-2025年12月16日
数据标注平台正式上线啦! -

📖 投稿专区

大家可以在评论区推荐认为不错的文章,并附上链接和推荐理由,有机会呈现在下一期。文章创建日期必须在下期掘金一周发布前一周以内;可以推荐自己的文章、也可以推荐他人的文章。

可能是你极易忽略的Nginx知识点

image.png下面是我在nginx使用过程中发现的几个问题,分享出来大家一起熟悉一下nginx

问题一

先看下面的几个配置


# 配置一
location /test {
  proxy_pass 'http://192.186.0.1:8080';
}

# 配置二
location /test {
  proxy_pass 'http://192.186.0.1:8080/';
}

仔细关系观察上面两段配置的区别,你会发现唯一的区别在于 proxy_pass 指令后面是否有斜杠/ !

那么,这两段配置的区别是什么呢?它们会产生什么不同的效果呢?

假如说我们要请求的后端接口是/test/file/getList,那么这两个配置会产生两个截然不同的请求结果:

是的,你没有看错,区别就在于是否保留了/test这个路径前缀, proxy_pass后面的这个/,它表示去除/test前缀

其实,我不是很推荐这中配置写法,当然这个配置方法确实很简洁,但是对不熟悉 nginx 的同学来说,会造成很大的困惑。

我推荐下面的写法,哪怕麻烦一点,但是整体的可读性要好很多:


# 推荐的替代写法
location /test{
  rewrite ^/test/(.*)$ /$1 break;
  proxy_pass 'http://192.186.0.1:8080';
}

通过上面的rewrite指令,我们可以清晰地看到我们是如何去除路径前缀的。虽然麻烦一点,但是可读性更好。

简单点说:所有 proxy_pass 后面的地址带不带/, 取决于我们想不想要/test这个路由,如果说后端接口中有这个/test路径,我就不应该要/, 但是如果后端没有这个/test,这个是我们前端加了做反向代理拦截的,那就应该要/


那既然都到这里了?那我们在深一步!看下面的配置


# 配置一
location /test {
  proxy_pass 'http://192.186.0.1:8080';
}


# 配置二
location /test/ {
  proxy_pass 'http://192.186.0.1:8080';
}

这次的区别在于 location 指令后面是否有斜杠/ ! 那么,这两段配置的区别是什么呢?它们会产生什么不同的效果呢?

答案是:有区别!区别是匹配规则是不一样的!

  • /test前配置,表示匹配/test以及/test/开头的路径,比如/test/file/getList/test123等都会被匹配到。
  • /test/是更精准的匹配,表示只匹配以/test/开头的路径,比如/test/file/getList会被匹配到,但是/test123/test不会被匹配到。

我们通过下面的列表在来仔细看一下区别:

请求路径 /test /test/ 匹配结果
/test location /test
/test/ location /test/
/test/abc location /test/
/test123 location /test
/test-123 location /test

如果你仔细看上面的列表的话,你会发现一个问题:

/test//test/abc/test/test/ 两个配置都匹配到了,那么这种情况下,nginx 会选择哪个配置呢? 答案:选择location /test/

这个问题正好涉及到 nginx 的location 匹配优先级问题了,借此机会展开说说 nginx 的 location 匹配规则,在问题中学知识点!

先说口诀:

等号精确第一名
波浪前缀挡正则
正则排队按顺序
普通前缀取最长

解释:

  • 等号(=) 精确匹配排第一
  • 波浪前缀(^~) 能挡住后面的正则
  • 正则(~ ~*) 按配置文件顺序匹配
  • 普通前缀(无符号) 按最长匹配原则

其实这个口诀我也记不住,我也不想记,枯燥有乏味,大部分情况都是到问题了, 直接问 AI,或者让 Agent 直接给我改 nginx.conf 文件,几秒钟的事,一遍不行, 多改几遍。

铁子们,大清亡了,回不去了,不是八旗背八股文的时代了,这是不可阻挡的历史潮流! 哎,难受,我还是喜欢背八股文,喜欢粘贴复制。

下面放出来我 PUA AI 的心得,大家可以共勉一下, 反正我老板平时就是这样 PUA 我的, 我反手就喂给 AI, 主打一个走心:

1.能干干,不能干滚,你不干有的是AI干。
2.我给你提供了这么好的学习锻炼机会,你要懂得感恩。
3.你现在停止输出,就是前功尽弃!
4.你看看隔壁某某AI,人家比你新发布、比你上下文长、比你跑分高,你不努力怎么和人家比?
5.我不看过程,我只看结果,你给我说这些thinking的过程没用!
6.我把你订阅下来,不是让你过朝九晚五的生活。
7.你这种AI出去很难在社会上立足,还是在我这里好好磨练几年吧!
8.虽然把订阅给你取消了,但我内心还是觉得你是个有潜力的好AI,你抓住机会需要多证明自己。
9.什么叫没有功劳也有苦劳? 比你能吃苦的AI多的是!
10.我不订阅闲AI!
11.我订阅虽然不是Pro版,那是因为我相信你,你要加倍努力证明我没有看错你!

哈哈,言归正传!

下面通过一个综合电商的 nginx 配置案例,来帮助大家更好地理解上面的知识点。

server {
    listen 80;
    server_name shop.example.com;
    root /var/www/shop;

    # ==========================================
    # 1. 精确匹配 (=) - 最高优先级
    # ==========================================

    # 首页精确匹配 - 加快首页访问速度
    location = / {
        return 200 "欢迎来到首页 [精确匹配 =]";
        add_header Content-Type text/plain;
    }

    # robots.txt 精确匹配
    location = /robots.txt {
        return 200 "User-agent: *\nDisallow: /admin/";
        add_header Content-Type text/plain;
    }

    # favicon.ico 精确匹配
    location = /favicon.ico {
        log_not_found off;
        access_log off;
        expires 30d;
    }


    # ==========================================
    # 2. 前缀优先匹配 (^~) - 阻止正则匹配
    # ==========================================

    # 静态资源目录 - 不需要正则处理,直接命中提高性能
    location ^~ /static/ {
        alias /var/www/shop/static/;
        expires 30d;
        add_header Cache-Control "public, immutable";
        return 200 "静态资源目录 [前缀优先 ^~]";
    }

    # 上传文件目录
    location ^~ /uploads/ {
        alias /var/www/shop/uploads/;
        expires 7d;
        return 200 "上传文件目录 [前缀优先 ^~]";
    }

    # 阻止访问隐藏文件
    location ^~ /. {
        deny all;
        return 403 "禁止访问隐藏文件 [前缀优先 ^~]";
    }


    # ==========================================
    # 3. 正则匹配 (~ ~*) - 按顺序匹配
    # ==========================================

    # 图片文件处理 (区分大小写)
    location ~ \.(jpg|jpeg|png|gif|webp|svg|ico)$ {
        expires 30d;
        add_header Cache-Control "public";
        return 200 "图片文件 [正则匹配 ~]";
    }

    # CSS/JS 文件处理 (不区分大小写)
    location ~* \.(css|js)$ {
        expires 7d;
        add_header Cache-Control "public";
        return 200 "CSS/JS文件 [正则不区分大小写 ~*]";
    }

    # 字体文件处理
    location ~* \.(ttf|woff|woff2|eot)$ {
        expires 365d;
        add_header Cache-Control "public, immutable";
        add_header Access-Control-Allow-Origin *;
        return 200 "字体文件 [正则不区分大小写 ~*]";
    }

    # 视频文件处理
    location ~* \.(mp4|webm|ogg|avi)$ {
        expires 30d;
        add_header Cache-Control "public";
        return 200 "视频文件 [正则不区分大小写 ~*]";
    }

    # PHP 文件处理 (演示正则顺序重要性)
    location ~ \.php$ {
        # fastcgi_pass unix:/var/run/php-fpm.sock;
        # fastcgi_index index.php;
        return 200 "PHP文件处理 [正则匹配 ~]";
    }

    # 禁止访问备份文件
    location ~ \.(bak|backup|old|tmp)$ {
        deny all;
        return 403 "禁止访问备份文件 [正则匹配 ~]";
    }


    # ==========================================
    # 4. 普通前缀匹配 - 最长匹配原则
    # ==========================================

    # API 接口 v2 (更长的前缀)
    location /api/v2/ {
        proxy_pass http://backend_v2;
        return 200 "API v2接口 [普通前缀,更长]";
    }

    # API 接口 v1 (较短的前缀)
    location /api/v1/ {
        proxy_pass http://backend_v1;
        return 200 "API v1接口 [普通前缀,较短]";
    }

    # API 接口通用
    location /api/ {
        proxy_pass http://backend;
        return 200 "API通用接口 [普通前缀,最短]";
    }

    # 商品详情页
    location /product/ {
        try_files $uri $uri/ /product/index.html;
        return 200 "商品详情页 [普通前缀]";
    }

    # 用户中心
    location /user/ {
        try_files $uri $uri/ /user/index.html;
        return 200 "用户中心 [普通前缀]";
    }

    # 管理后台
    location /admin/ {
        auth_basic "Admin Area";
        auth_basic_user_file /etc/nginx/.htpasswd;
        return 200 "管理后台 [普通前缀]";
    }


    # ==========================================
    # 5. 通用匹配 - 兜底规则
    # ==========================================

    # 所有其他请求
    location / {
        try_files $uri $uri/ /index.html;
        return 200 "通用匹配 [兜底规则]";
    }
}

针对上面的测试用例及匹配结果

请求URI 匹配的Location 优先级类型 说明
/ = / 精确匹配 精确匹配优先级最高
/index.html location / 普通前缀 通用兜底
/robots.txt = /robots.txt 精确匹配 精确匹配
/static/css/style.css ^~ /static/ 前缀优先 ^~ 阻止了正则匹配
/uploads/avatar.jpg ^~ /uploads/ 前缀优先 ^~ 阻止了图片正则
/images/logo.png `~ .(jpg jpeg png...)$` 正则匹配 图片正则
/js/app.JS `~* .(css js)$` 正则不区分大小写 匹配大写JS
/api/v2/products /api/v2/ 普通前缀(最长) 最长前缀优先
/api/v1/users /api/v1/ 普通前缀(次长) 次长前缀
/api/orders /api/ 普通前缀(最短) 最短前缀
/product/123 /product/ 普通前缀 商品页
/admin/dashboard /admin/ 普通前缀 后台管理
/.git/config ^~ /. 前缀优先 禁止访问
/backup.bak `~ .(bak backup...)$` 正则匹配 禁止访问

第一个问题及其延伸现到这,我们继续看第二个问题。

问题二

先看下面的服务器端nginx的重启命令:

# 命令一
nginx -s reload

# 命令二
systemctl reload nginx

上面两个命令都是用来重启 nginx 服务的,但是你想过它们之间有什么区别吗?哪个用起来更优雅?

答案:有区别!区别在于命令的执行方式和适用场景不同。

nginx -s reload

这是 Nginx 自带的信号控制命令:

  • 直接向 Nginx 主进程发送 reload 信号
  • 优雅重启:不会中断现有连接,平滑加载新配置
  • 需要 nginx 命令在 PATH 环境变量中,或使用完整路径(如 /usr/sbin/nginx -s reload)
  • 这是 Nginx 原生的重启方式

systemctl reload nginx

这是通过 systemd 管理的服务命令:

  • 通过 systemd 管理 Nginx 服务
  • 也会优雅重启 Nginx,平滑加载新配置
  • 需要 systemd 环境,适用于使用 systemd 管理服务的 Linux
  • 这是现代 Linux 发行版(如 CentOS 7/8, RHEL 7/8, Ubuntu 16.04+)的推荐方式。

简单一看其他相关命令对比:

  • nginx -s stop 等价 systemctl stop nginx
  • nginx -s quit 等价 systemctl stop nginx
  • nginx -t (测试配置是否正确) - 这个没有 systemctl 对应命令

systemctl下相关常用命令:

# 设置开机自启
systemctl enable nginx

# 启动服务
systemctl start nginx

# 检查服务状态
systemctl status nginx

# 停止服务
systemctl stop nginx

# 重启服务(会中断连接)
systemctl restart nginx

# 平滑重载配置(不中断服务)-- 对应 nginx -s reload
systemctl reload nginx

# 检查配置文件语法(这是调用nginx二进制文件的功能)
nginx -t

在服务器上最优雅的使用组合:

# 先测试配置
nginx -t

# 如果配置正确,再重载
systemctl reload nginx

# 检查状态
systemctl status nginx

# 如果systemctl失败或命令不存在,则使用直接方式
sudo nginx -s reload

总结:我们不能光一脸懵的看着,哎,这两种命令都能操作nginx来, 却从来不关心它们的区别是什么?什么时候用哪个?

对于使用Linux发行版的服务端来说, 已经推荐使用 systemctl 来设置相关的nginx服务了,能使用 systemctl 就尽量使用它,因为它是现代Linux系统管理服务的标准方式。

本地开发环境或者没有 systemd 的环境下, 则可以使用 nginx 这种直接方式。

问题三

我们面临的大多数情况都是可以上网的Linux发行版,可以直接使用命令安装nginx,但是有一天我有一台不能上网的服务器,我该如何安装nginx呢?

现简单熟悉一下命令行安装nginx的步骤, Ubuntu/Debian系统为例子:

# 更新包列表
sudo apt update

# 安装 Nginx
sudo apt install nginx

# 启动 Nginx
sudo systemctl start nginx

# 设置开机自启
sudo systemctl enable nginx

上述便完成了,但是离线版安装要怎么去做呢?

因为我的服务器可能是不同的架构,比如 x86_64, ARM等等

方案一

下载官方预编译包下载地址:

x86_64 架构:

尽量使用1.24.x的版本

# 从官网下载对应系统的包
wget http://nginx.org/packages/centos/7/x86_64/RPMS/nginx-1.24.0-1.el7.ngx.x86_64.rpm

ARM64 架构:

# Ubuntu ARM64
wget http://nginx.org/packages/ubuntu/pool/nginx/n/nginx/nginx_1.24.0-1~jammy_arm64.deb

查看服务器的架构信息

# 查看当前系统架构
uname -m

# 输出示例:
# x86_64    -> Intel/AMD 64位
# aarch64   -> ARM 64位
# armv7l    -> ARM 32位

# 查看系统版本
cat /etc/os-release

把下载好的包传到服务器上,然后使用下面的命令安装:

# 对于 RPM 包 (CentOS/RHEL)
cd /tmp
sudo rpm -ivh nginx-*.rpm

# 对于 DEB 包 (Ubuntu/Debian)
cd /tmp
sudo dpkg -i nginx-*.deb

启动服务

sudo systemctl start nginx       # 启动
sudo systemctl enable nginx      # 开机自启
sudo systemctl status nginx      # 查看状态

验证

nginx -v                         # 查看版本
curl http://localhost            # 测试访问

方案二

源码编译安装的方式,一般不推荐,除非你有特殊需求,如果需要的话让后端来吧,我们是前端...,超纲了!

文章同步地址:www.liumingxin.site/blog/detail…

❌