阅读视图

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

🧭 前端周刊第440期(2025年11月10日–11月16日)

每周更新国外论坛的前端热门文章,推荐大家阅读/翻译,紧跟时事,掌握前端技术动态,也为写作或突破新领域提供灵感~

📢 宣言

我已经计划并开始实践:每周逐期翻译《前端周刊》内的每篇文章,并将其整理发布到 GitHub 仓库中,持续更深度的分享。

欢迎大家访问:github.com/TUARAN/fron…

顺手点个 ⭐ star 支持,是我持续输出的续航电池🔋✨!

💬 推荐语

如果说今年前端的底层趋势是什么,那本周周刊基本把答案摊开了:AI 前端化、本地化、组件结构重建、浏览器新能力普及、以及 React/Vue 的生态反思。

从 ONNX 本地推理,到 MCP 设计规范;从 Baseline 的统一标准,到 CSS 的奇点进化;再到 React useEffect 的全网“审判现场”。

这一期信息密度非常高,特别适合想提前布局 2025–2026 技术路线的同学认真读读。

🧠 博主点评

看完本期,我最大的感受是:前端已经走出了“写 UI”时代,而是全面迈入“智能化交互系统构建者”的角色。

Baseline 成为事实标准,兼容性焦虑进一步减轻

AI 模型开始在浏览器本地跑,前端工程师掌握“模型加载与推理”将成常态

CSS 不再是样式语言,而是“交互描述语言”,甚至被用来做 tracking 和调试

React 社区的痛点(尤其是 useEffect)被系统性检讨

Vue 开始原生拥抱 MCP,这意味着设计→代码→服务将形成“自动流水线”

前端的未来是越来越“系统化”的,谁能理解这些结构化趋势,谁就能站在下一波浪潮的上游。

🗂 本期精选目录

Web 开发

🔹Simple One-Time Passcode Inputs:如何设计体验优秀的验证码输入框,解决自动跳格、粘贴识别与可访问性细节。

🔹Programming principles for self taught front-end developers:写给自学前端开发者的编码原则指南,强调结构化和可维护性。

🔹Why Headings Are Important in HTML:说明标题语义在结构、可访问与 SEO 中的重要性。

Baseline

🔹Browserslist & Baseline:解读 Baseline 如何影响前端兼容策略,并与 Browserslist 协同工作。

🔹Perfecting Baseline:W3C WebDX 成员谈 Baseline 演进及其长远意义。

🔹October 2025 Baseline monthly digest:10 月 Baseline 月报总结新特性落地情况。

工具

🔹Why the Frontend Should Run AI Models Locally With ONNX:分析为何前端本地运行 ONNX 模型能降低延迟、保护隐私并提升交互体验。

🔹How to structure Figma files for MCP and AI-powered code generation:教你如何组织 Figma 文件结构,使之适配 MCP 与 AI 代码生成流程。

🔹ESLint Plugin for Baseline JavaScript:针对 Baseline 规范的 ESLint 插件,帮助团队统一 JS 特性使用范围。

性能

🔹The Web Animation Performance Tier List:不同动画实现方式的性能排名,给出更科学的选型建议。

🔹Effectively Monitoring Web Performance:前端性能监控体系与指标全解析。

动画

🔹Building a 3D Infinite Carousel…:手把手教你实现 3D 无限轮播,并用背景渐变增强沉浸感。

🔹View Transitions API:用原生 View Transitions 让 DOM 状态切换更顺滑。

CSS

🔹Invisible tracking in the browser:揭露 CSS 如何被滥用进行浏览器隐形追踪。

🔹Animating width/height no longer forces Main Thread:Chrome 条件支持避免 width/height 动画导致主线程阻塞。

🔹Range Syntax for Style Queries:介绍全新范围查询语法,让样式逻辑更灵活。

🔹Responsive Stacked Images:用 CSS 实现响应式的叠图/重叠布局。

🔹CSS Gamepad Debugging With Layers:利用 CSS 层可视化调试 Gamepad API 输入。

🔹:interest-invoker & :interest-target:两个适用于“兴趣触点”交互设计的新伪类。

🔹Headings: Semantics, Fluidity, and Styling:从语义、流动性到样式的一站式标题系统解析。

🔹Perfectly Pointed Tooltips:创建“尖角完美”提示框的几何技巧。

🔹Crafting Generative CSS Worlds:使用 CSS 创造生成式视觉世界,充满创意灵感。

JavaScript

理论

🔹Guide to Blobs, File APIs, and Memory Optimization:深入理解 Blob 机制、文件 API 和浏览器内存优化策略。

🔹Error chaining with Error.cause:Error.cause 如何提升调试体验与错误链路表达力。

🔹Intl.Segmenter:用 Intl.Segmenter 正确处理 Unicode 字符分词与国际化场景。

React

🔹Fixing React routing loopholes:用 React Router 中间件修复路由漏洞与跳转缺口。

🔹15 useEffect mistakes:React 开发中最常见的 useEffect 误用合集。

🔹Is SSR React’s Holy Grail?:探讨 SSR 是否真的是 React 性能终极方案。

🔹UseEffect Is a Crime Scene:一篇发人深省的 useEffect 反思文。

Vue

🔹Building an MCP Server for Nuxt:教你为 Nuxt 构建 MCP Server,直接连接 AI 生态。

🔹Using Vite with Vue and Django:如何将 Vite + Vue 集成到 Django 全栈环境中。

小结

本期内容有两个核心趋势值得关注:

① 前端逐渐成为 AI 系统的一部分

本地推理(ONNX)、MCP 服务器、设计到代码自动链路,都在快速成熟。

② 浏览器与语言层不断增强底层能力

新 CSS 语法、动画模型、Baseline 标准化,使前端工程变得“更可依赖、更像平台”。

这意味着未来的前端不止写页面,而是负责“交互智能 + 结构系统 + 多端一致性”的整体设计。

OK,以上就是本次分享,欢迎加我威 atar24,备注「前端周刊」,我会邀请你进交流群👇

🚀 每周分享技术干货

🎁 不定期抽奖福利

💬 有问必答,群友互助

专为 LLM 设计的数据格式 TOON,可节省 60% Token

最近在使用 NestJs 和 NextJs 在做一个协同文档 DocFlow,如果感兴趣,欢迎 star,有任何疑问,欢迎加我微信进行咨询 yunmz777

随着社交媒体的深度渗透,朋友圈、微博、Instagram 等平台已成为用户展示生活、分享瞬间的核心场景。其中,"九宫格"排版形式凭借其规整的视觉美感和内容叙事性,成为年轻用户(尤其是女性群体)高频使用的图片发布方式。

在接下来的内容中,我们将使用 NextJs 结合 sharp 来实现图片裁剪的功能。

编写基本页面

首先我们将编写一个图片裁剪的功能,以支持用户来生成不同规格的图片,例如 3x3、2x2 等格式。

如下代码所示:

"use client";

import { useState, useRef } from "react";

const Home = () => {
  const [rows, setRows] = useState(3);
  const [columns, setColumns] = useState(3);
  const [image, setImage] = useState<string | null>(null);
  const [splitImages, setSplitImages] = useState<string[]>([]);
  const [isProcessing, setIsProcessing] = useState(false);
  const [imageLoaded, setImageLoaded] = useState(false);
  const imageRef = useRef<HTMLImageElement>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);

  const handleFileUpload = (file: File) => {
    if (!file.type.startsWith("image/")) {
      alert("请上传图片文件");

      return;
    }

    // 先重置状态
    setImage(null);
    setSplitImages([]);
    setImageLoaded(false);

    const reader = new FileReader();

    reader.onload = (e) => {
      if (e.target?.result) {
        setImage(e.target.result as string);
      }
    };

    reader.readAsDataURL(file);
  };

  // 修改图片加载完成的处理函数
  const handleImageLoad = () => {
    console.log("Image loaded"); // 添加日志以便调试
    setImageLoaded(true);
  };

  // 渲染切割辅助线
  const renderGuideLines = () => {
    if (!image || !imageRef.current || !imageLoaded) return null;

    const commonLineStyles = "bg-blue-500/50 absolute pointer-events-none";
    const imgRect = imageRef.current.getBoundingClientRect();
    const containerRect =
      imageRef.current.parentElement?.getBoundingClientRect();

    if (!containerRect) return null;

    const imgStyle = {
      left: `${imgRect.left - containerRect.left}px`,
      top: `${imgRect.top - containerRect.top}px`,
      width: `${imgRect.width}px`,
      height: `${imgRect.height}px`,
    };

    return (
      <div className="absolute pointer-events-none" style={imgStyle}>
        {/* 垂直线 */}
        {Array.from({ length: Math.max(0, columns - 1) }).map((_, i) => (
          <div
            key={`v-${i}`}
            className={`${commonLineStyles} top-0 bottom-0 w-[1px] md:w-[2px] backdrop-blur-sm`}
            style={{
              left: `${((i + 1) * 100) / columns}%`,
              transform: "translateX(-50%)",
            }}
          />
        ))}
        {/* 水平线 */}
        {Array.from({ length: Math.max(0, rows - 1) }).map((_, i) => (
          <div
            key={`h-${i}`}
            className={`${commonLineStyles} left-0 right-0 h-[1px] md:h-[2px] backdrop-blur-sm`}
            style={{
              top: `${((i + 1) * 100) / rows}%`,
              transform: "translateY(-50%)",
            }}
          />
        ))}
      </div>
    );
  };

  // 处理图片切割
  const handleSplitImage = async () => {
    if (!image) return;

    setIsProcessing(true);

    try {
      const response = await fetch(image);
      const blob = await response.blob();
      const file = new File([blob], "image.jpg", { type: blob.type });

      const formData = new FormData();
      formData.append("image", file);
      formData.append("rows", rows.toString());
      formData.append("columns", columns.toString());

      const res = await fetch("/api/split-image", {
        method: "POST",
        body: formData,
      });

      const data = await res.json();

      if (data.error) {
        throw new Error(data.error);
      }

      setSplitImages(data.pieces);
    } catch (error) {
      console.error("Failed to split image:", error);
      alert("图片切割失败,请重试");
    } finally {
      setIsProcessing(false);
    }
  };

  // 添加下载单个图片的函数
  const handleDownloadSingle = async (imageUrl: string, index: number) => {
    try {
      const response = await fetch(imageUrl);
      const blob = await response.blob();
      const url = window.URL.createObjectURL(blob);
      const link = document.createElement("a");
      link.href = url;
      link.download = `piece_${index + 1}.png`;
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url);
    } catch (error) {
      console.error("下载失败:", error);
      alert("下载失败,请重试");
    }
  };

  // 添加打包下载所有图片的函数
  const handleDownloadAll = async () => {
    try {
      // 如果没有 JSZip,需要先动态导入
      const JSZip = (await import("jszip")).default;
      const zip = new JSZip();

      // 添加所有图片到 zip
      const promises = splitImages.map(async (imageUrl, index) => {
        const response = await fetch(imageUrl);
        const blob = await response.blob();
        zip.file(`piece_${index + 1}.png`, blob);
      });

      await Promise.all(promises);

      // 生成并下载 zip 文件
      const content = await zip.generateAsync({ type: "blob" });
      const url = window.URL.createObjectURL(content);
      const link = document.createElement("a");
      link.href = url;
      link.download = "split_images.zip";
      document.body.appendChild(link);
      link.click();
      document.body.removeChild(link);
      window.URL.revokeObjectURL(url);
    } catch (error) {
      console.error("打包下载失败:", error);
      alert("打包下载失败,请重试");
    }
  };

  // 修改预览区域的渲染函数
  const renderPreview = () => {
    if (!image) {
      return <p className="text-gray-400">切割后的图片预览</p>;
    }

    if (isProcessing) {
      return <p className="text-gray-400">正在处理中...</p>;
    }

    if (splitImages.length > 0) {
      return (
        <div className="relative w-full h-full flex items-center justify-center">
          <div
            className="grid gap-[3px] bg-[#242c3e]"
            style={{
              gridTemplateColumns: `repeat(${columns}, 1fr)`,
              gridTemplateRows: `repeat(${rows}, 1fr)`,
              width: imageRef.current?.width || "100%",
              height: imageRef.current?.height || "100%",
              maxWidth: "100%",
              maxHeight: "100%",
            }}
          >
            {splitImages.map((src, index) => (
              <div key={index} className="relative group">
                <img
                  src={src}
                  alt={`切片 ${index + 1}`}
                  className="w-full h-full object-cover"
                />
                <button
                  onClick={() => handleDownloadSingle(src, index)}
                  className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity"
                >
                  <svg
                    className="w-6 h-6 text-white"
                    fill="none"
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={2}
                      d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
                    />
                  </svg>
                </button>
              </div>
            ))}
          </div>
        </div>
      );
    }

    return <p className="text-gray-400">点击切割按钮开始处理</p>;
  };

  return (
    <>
      <div className="fixed inset-0 bg-[#0B1120] -z-10" />
      <main className="min-h-screen w-full py-16 md:py-20">
        <div className="container mx-auto px-4 sm:px-6 max-w-7xl">
          {/* 标题区域 */}
          <div className="text-center mb-12 md:mb-16">
            <h1 className="text-3xl md:text-4xl lg:text-5xl font-bold mb-4 animate-fade-in">
              <span className="bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 text-transparent bg-clip-text bg-[size:400%] animate-gradient">
                图片切割工具
              </span>
            </h1>
            <p className="text-gray-400 text-base md:text-lg max-w-2xl mx-auto animate-fade-in-up">
              上传一张图片,快速将其切割成网格布局,支持自定义行列数。
            </p>
          </div>

          {/* 图片区域 - 调整高度和响应式布局 */}
          <div className="grid grid-cols-1 lg:grid-cols-2 gap-6 md:gap-8 mb-12 md:mb-16">
            {/* 上传区域 - 调整高度 */}
            <div className="relative h-[400px] md:h-[500px] lg:h-[600px] bg-[#1a2234] rounded-xl overflow-hidden">
              <input
                ref={fileInputRef}
                type="file"
                accept="image/*"
                className="hidden"
                onChange={(e) => {
                  const file = e.target.files?.[0];
                  if (file) handleFileUpload(file);
                }}
                id="imageUpload"
              />
              <label
                htmlFor="imageUpload"
                className="absolute inset-0 flex flex-col items-center justify-center cursor-pointer"
              >
                {image ? (
                  <div className="relative w-full h-full flex items-center justify-center">
                    <img
                      ref={imageRef}
                      src={image}
                      alt="上传的图片"
                      className="max-w-full max-h-full object-contain"
                      onLoad={handleImageLoad}
                      key={image}
                    />
                    {renderGuideLines()}
                  </div>
                ) : (
                  <>
                    <div className="p-4 rounded-full bg-[#242c3e] mb-4">
                      <svg
                        className="w-8 h-8 text-gray-400"
                        fill="none"
                        stroke="currentColor"
                        viewBox="0 0 24 24"
                      >
                        <path
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          strokeWidth={1.5}
                          d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
                        />
                      </svg>
                    </div>
                    <p className="text-gray-400">点击或拖拽图片到这里上传</p>
                  </>
                )}
              </label>
            </div>

            {/* 预览区域 - 调整高度 */}
            <div className="h-[400px] md:h-[500px] lg:h-[600px] bg-[#1a2234] rounded-xl flex items-center justify-center">
              {renderPreview()}
            </div>
          </div>

          {/* 控制器 - 添加上下边距的容器 */}
          <div className="py-4 md:py-6">
            <div className="flex flex-col sm:flex-row items-center justify-center gap-4 md:gap-6">
              <div className="flex flex-col sm:flex-row items-center gap-4 w-full sm:w-auto">
                <div className="bg-[#1a2234] rounded-xl px-5 py-2.5 flex items-center gap-4 border border-[#242c3e] w-full sm:w-auto">
                  <span className="text-gray-400 font-medium min-w-[40px]">
                    行数
                  </span>
                  <div className="flex items-center gap-2 bg-[#242c3e] rounded-lg p-1 flex-1 sm:flex-none justify-center">
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setRows(Math.max(1, rows - 1))}
                    >
                      -
                    </button>
                    <span className="text-white min-w-[32px] text-center font-medium">
                      {rows}
                    </span>
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setRows(rows + 1)}
                    >
                      +
                    </button>
                  </div>
                </div>

                <div className="bg-[#1a2234] rounded-xl px-5 py-2.5 flex items-center gap-4 border border-[#242c3e] w-full sm:w-auto">
                  <span className="text-gray-400 font-medium min-w-[40px]">
                    列数
                  </span>
                  <div className="flex items-center gap-2 bg-[#242c3e] rounded-lg p-1 flex-1 sm:flex-none justify-center">
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setColumns(Math.max(1, columns - 1))}
                    >
                      -
                    </button>
                    <span className="text-white min-w-[32px] text-center font-medium">
                      {columns}
                    </span>
                    <button
                      className="w-8 h-8 flex items-center justify-center text-white rounded-lg hover:bg-blue-500 transition-colors"
                      onClick={() => setColumns(columns + 1)}
                    >
                      +
                    </button>
                  </div>
                </div>

                <button
                  className="px-5 py-2.5 bg-[#1a2234] text-gray-400 rounded-xl hover:bg-[#242c3e] hover:text-white transition-all font-medium border border-[#242c3e] w-full sm:w-auto"
                  onClick={() => {
                    setRows(3);
                    setColumns(3);
                  }}
                >
                  重置
                </button>
              </div>

              <div className="flex flex-col sm:flex-row gap-4 w-full sm:w-auto">
                <button
                  className="px-6 py-3 bg-gradient-to-r from-blue-500 to-blue-600 text-white rounded-xl hover:from-blue-600 hover:to-blue-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 font-medium shadow-lg shadow-blue-500/20 w-full sm:w-auto"
                  disabled={!image || isProcessing}
                  onClick={handleSplitImage}
                >
                  {isProcessing ? "处理中..." : "切割图片"}
                </button>
                <button
                  className="px-6 py-3 bg-gradient-to-r from-red-500 to-red-600 text-white rounded-xl hover:from-red-600 hover:to-red-700 transition-all disabled:opacity-50 disabled:cursor-not-allowed font-medium shadow-lg shadow-red-500/20 w-full sm:w-auto"
                  onClick={() => {
                    setImage(null);
                    setSplitImages([]);
                    setImageLoaded(false);

                    if (fileInputRef.current) {
                      fileInputRef.current.value = "";
                    }
                  }}
                  disabled={!image}
                >
                  清除
                </button>
              </div>

              {/* 下载按钮 */}
              {splitImages.length > 0 && (
                <button
                  onClick={handleDownloadAll}
                  className="px-6 py-3 bg-gradient-to-r from-green-500 to-green-600 text-white rounded-xl hover:from-green-600 hover:to-green-700 transition-all font-medium shadow-lg shadow-green-500/20 flex items-center justify-center gap-2 w-full sm:w-auto"
                >
                  <svg
                    className="w-5 h-5"
                    fill="none"
                    stroke="currentColor"
                    viewBox="0 0 24 24"
                  >
                    <path
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      strokeWidth={2}
                      d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
                    />
                  </svg>
                  打包下载
                </button>
              )}
            </div>
          </div>

          {/* 底部留白 */}
          <div className="h-16 md:h-20"></div>
        </div>
      </main>
    </>
  );
};

export default Home;

在上面的代码中,当用户上传图片兵当图片加载完成之后,renderGuideLines 方法会绘制图片的切割网格,显示垂直和水平的分隔线,便于用户预览切割后的效果。

它会根据用户设置的行数(rows)和列数(columns),这些切割线将被动态渲染。用户设置完行列数后,点击 "切割图片" 按钮,会触发 handleSplitImage 函数,将图片传递到后端进行切割。图片会被发送到后端接口 /api/split-image,并返回切割后的图片数据(即每个小图的 URL)。

最终 ui 效果如下图所示:

20250218163115

设计 API

前面的内容中,我们已经编写了前端的 ui,接下来我们要设计我们的 api 接口以支持前端页面调用。

首先我们要先知道一个概念,sharp 是一个高性能的 Node.js 图像处理库,支持各种常见的图像操作,如裁剪、调整大小、旋转、转换格式等。它基于 libvips(一个高效的图像处理库),与其他一些图像处理库相比,它的处理速度更快,内存消耗也更低。

而 Next.js 是一个服务器端渲染(SSR)的框架,可以通过 API 路由来处理用户上传的文件。在 API 路由中使用 sharp 可以确保图像在服务器端得到处理,而不是在客户端进行,这样可以减轻客户端的负担,并且保证图像在服务器上处理完成后再发送到客户端,从而提高页面加载速度。

如下代码所示:

import sharp from "sharp";
import { NextResponse } from "next/server";

export async function POST(request: Request) {
  try {
    const data = await request.formData();
    const file = data.get("image") as File;
    const rows = Number(data.get("rows"));
    const columns = Number(data.get("columns"));

    if (!file || !rows || !columns) {
      return NextResponse.json(
        { error: "Missing required parameters" },
        { status: 400 }
      );
    }

    if (!file.type.startsWith("image/")) {
      return NextResponse.json({ error: "Invalid file type" }, { status: 400 });
    }

    const buffer = Buffer.from(await file.arrayBuffer());

    if (!buffer || buffer.length === 0) {
      return NextResponse.json(
        { error: "Invalid image buffer" },
        { status: 400 }
      );
    }

    const image = sharp(buffer);
    const metadata = await image.metadata();

    if (!metadata.width || !metadata.height) {
      return NextResponse.json(
        { error: "Invalid image metadata" },
        { status: 400 }
      );
    }

    console.log("Processing image:", {
      width: metadata.width,
      height: metadata.height,
      format: metadata.format,
      rows,
      columns,
    });

    const pieces: string[] = [];
    const width = metadata.width;
    const height = metadata.height;
    const pieceWidth = Math.floor(width / columns);
    const pieceHeight = Math.floor(height / rows);

    if (pieceWidth <= 0 || pieceHeight <= 0) {
      return NextResponse.json(
        { error: "Invalid piece dimensions" },
        { status: 400 }
      );
    }

    for (let i = 0; i < rows; i++) {
      for (let j = 0; j < columns; j++) {
        const left = j * pieceWidth;
        const top = i * pieceHeight;
        const currentWidth = j === columns - 1 ? width - left : pieceWidth;
        const currentHeight = i === rows - 1 ? height - top : pieceHeight;

        try {
          const piece = await image
            .clone()
            .extract({
              left,
              top,
              width: currentWidth,
              height: currentHeight,
            })
            .toBuffer();

          pieces.push(
            `data:image/${metadata.format};base64,${piece.toString("base64")}`
          );
        } catch (err) {
          console.error("Error processing piece:", { i, j, err });
          throw err;
        }
      }
    }

    return NextResponse.json({ pieces });
  } catch (error) {
    console.error("Error processing image:", error);

    return NextResponse.json(
      {
        error: "Failed to process image",
        details: error instanceof Error ? error.message : "Unknown error",
      },
      { status: 500 }
    );
  }
}

export const config = {
  api: {
    bodyParser: {
      sizeLimit: "10mb",
    },
  },
};

在上面的这些代码中,它的具体流程如下:

  1. 接收请求:首先通过 POST 方法接收包含图片和切割参数(行数和列数)的表单数据。request.formData() 用来解析表单数据并提取文件和参数。

  2. 参数验证:检查文件类型是否为图片,确保上传数据完整且有效。如果缺少必需的参数或上传的不是图片,返回相应的错误信息。

  3. 图片处理:通过 sharp 库将图片数据转换为可操作的 Buffer,然后获取图片的元数据(如宽度、高度、格式)。如果图片的元数据无效或获取不到,返回错误。

  4. 计算切割尺寸:根据用户输入的行数和列数计算每个小块的宽高,并检查计算出来的尺寸是否有效。如果计算出的尺寸不合适,返回错误。

  5. 图片切割:使用 sharp 的 extract 方法对图片进行切割。每次提取一个小块后,将其转换为 base64 编码的字符串,并保存到 pieces 数组中。

  6. 返回结果:成功处理后,将切割后的图片数据作为 JSON 响应返回,每个图片切块以 base64 编码形式存储。若遇到错误,捕获并返回详细的错误信息。

  7. 配置:通过 config 配置,设置请求体的最大大小限制为 10MB,防止上传过大的文件导致请求失败。

这里的代码完成之后,我们还要设计一下 next.config.mjs 文件,如下:

/** @type {import('next').NextConfig} */
const nextConfig = {
  webpack: (config) => {
    config.externals = [...config.externals, "sharp"];
    return config;
  },
};

export default nextConfig;

当我们点击切割图片的时候,最终生成的效果如下图所示:

20250218164009

总结

完整项目地址

如果你想参与进来开发或者想进群学习,可以添加我微信 yunmz777,后面还会有很多需求,等这个项目完成之后还会有很多新的并且很有趣的开源项目等着你。

如果该项目对你有帮助或者对这个项目感兴趣,欢迎 Star⭐️⭐️⭐️

最后再来提一些这两个开源项目,它们都是我们目前正在维护的开源项目:

🐒🐒🐒

VS Code 使用国产大模型 MiniMax M2 教程

一、

上周,我写了一篇 Claude Code 接入国产大模型的教程,就有同学问我,为什么不用 VS Code?

问得好。我习惯命令行了,确实忽略了 VS Code。

今天就补上,我来介绍,如何不用插件在 VS Code 里面使用 Claude Code。

我真心觉得,Claude Code 比插件好用,所以试试看跳过插件,直接在 VS Code 里面使用它。

跟以前一样,这一次 Claude Code 也是接入国产大模型,我选的是 MiniMax M2。它的质量很不错,而且这周有活动。

二、

这次评测的题目很有趣,我自己都很喜欢。

它是一个老外程序员最近想出来的,已经在国外引起了轰动。

他用了九个著名模型,生成网页时钟的动画,然后把这些动画放在网站上,网站标题就叫"AI 时钟"。

说来奇怪,这个测试看上去不难,但是大多数模型生成的效果并不好,有些甚至很差。

举例来说,下图是 OpenAI 公司 GPT-5 模型生成的时钟,让人无语。

这个时钟的提示词如下,大家可以拿来自己测。

Create HTML/CSS of an analog clock showing ${time}. Include numbers (or numerals) if you wish, and have a CSS animated second hand. Make it responsive and use a white background. Return ONLY the HTML/CSS code with no markdown formatting.

翻译成中文就是:

"创建一个显示时间 ${time} 的模拟时钟的 HTML/CSS 代码。如果需要,可以包含数字,并添加 CSS 动画秒针。使其具有响应式设计,并使用白色背景。仅返回 HTML/CSS 代码,不要包含任何 Markdown 格式。"

我也拿它来测试,看看 MiniMax M2 的效果如何。

三、

进入正题之前,我想强调一下,VS Code 与 Claude Code 是两种截然不同的工具。

VS Code 是 IDE,你是在 IDE 里面使用 AI。

Claude Code 是命令行工具,你是在终端窗口使用 AI。

它们的特点完全不同。IDE 支持智能感知(intellisense)和自动补全,而命令行支持调用系统工具和脚本,还能自动化集成,以及并行执行。

所以,它们两个不是替代关系,而是互补关系。你应该根据需要,选择最合适的工具。

我要演示的方法,正是将 IDE 和命令行结合起来,让你具有最大的灵活性。

四、

我用来测试的模型是 MiniMax M2,说一下为什么选择它。

它是上月底(10月27日)发布的,很多评测显示它是编程能力最强的开源模型之一,而且在 OpenRouter 平台上,它是 Token 调用量最大的国产模型。

我当时对它做了评测,大家还有印象吗?结论是,它的编程表现超出了我的预期。

但是那个时候,它没有包月套餐,只能按 API 使用量计费,就让人不敢多用。

现在不一样了,上个周末,它突然推出了 Coding Plan 包月套餐,有三档资费。

最低一档针对普通强度的使用,首月只要9.9元,(续费29元/月),这就很划算了。

除了这个优惠活动,它还有两个特点,很适合这篇教程。

(1)兼容性好,接入外部工具很容易。我用它接入 Claude Code 和 VS Code 都非常顺利。

官网文档给出接口示例的工具非常全,包括 Cursor、Cline、Codex、Kilo Code、Droid、Trae、Grok、OpenCode、iFlow 等等。

(2)响应速度、生成速度快。它的 API 服务器,在国内的响应时间一般是几十毫秒,每秒生成 Token 的数量(即 TPS 指标)超过100,比国外模型快得多。

五、

现在进入正题,首先是一些准备工作,要将 MiniMax M2 接入 Claude Code。

具体步骤就不详述了,大家按照前一篇教程就可以了。

简单说,就是新建一个claude-minimax 脚本(下图),将从 MiniMax M2 官网获取的接口参数填入。

完成后,可以测试一下,看看能否正常运行。


$ claude-minimax --version

六、

下面就是在 VS Code 使用 Claude Code 生成网页时钟的测试。

第一步,新建一个本地目录作为项目目录,比如ai-clock


$ mkdir ai-clock 

然后,在 VS Code 里面打开这个目录 ai-clock,作为工作区。

第二步,打开 VS Code 的菜单"终端/新建终端",在这个终端窗口里面,输入 claude-minimax


$ claude-minimax

这时,窗口会提示你授予权限,同意后,就会进入主界面,大概就是下面这样。

现在,我们就能在 VS Code 里面使用命令行的 Claude Code 了。

这时,你既可以使用 IDE 编写代码,又可以通过命令行使用 AI 模型,兼得两者的优势。

第三步,在 Claude Code 的提示符后面,输入/init命令,用来在仓库里面生成一个 CLAUDE.md 文件,记录 AI 对这个仓库操作。


/init

生成结束后,你可以打开看一下 CLAUDE.md 文件(下图)。

因为我们这个示例仓库是空的,所以文件里面没什么内容。如果是有现成代码的仓库,文件里面会有 AI 对代码库的详细解析。

这个文件的作用是当作上下文,每次查询模型时,都会自动附上这个文件,以便模型了解代码库。

如果在提示框输入反斜杠,Claude Code 就会显示所有可用的命令(下图)。

通过这些命令,我们就能使用 Claude Code 的强大功能,完成各种 AI 操作了。

这一步是 Claude Code 的基础用法,对所有项目都是通用的。

第四步,在提示框输入前面的提示词(下图),让模型生成网页时钟。

MiniMax M2 思考了不到一分钟,就生成完毕了(下图),并且自动把这些代码写入文件 index.html

打开网页就是下面的效果。

真的很不错,第一次就能有这样的效果。钟面的形状正确,秒钟跳动的动画十分流畅,每秒都在刷新,显示当前时间。

大家可以使用这个提示词,自己去生成看看,如果手边没有 Claude Code,可以在官网上执行。

也可以查看我得到的完整代码,复制保存成 HTML 文件,在浏览器打开。

七、

从这个测试结果来看,MiniMax M2 的生成结果,无论是横向对比,还是实际效果,都是令人满意的。

结合它现在的价格,性价比很高,我认为值得推荐给大家上手尝试。

最后,转发一下他们的 Coding Plan 活动的海报,首月9.9元,一杯咖啡的钱,包月使用最新的 AI 编程模型,需要者自取。

(完)

文档信息

  • 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证
  • 发表日期: 2025年11月19日

React的useRef的深度解析与应用指南

1. 前言

在React的函数式组件开发中,useRef是一个非常实用的钩子,它为我们提供了在组件渲染周期之间持久化存储值的能力,同时不会触发组件的重新渲染。本文将深入探讨useRef的工作原理、常见应用场景以及使用时的注意事项。

2. 基本概念与工作原理

useRef是React提供的一个钩子函数,其主要功能是创建一个可变的ref对象。这个对象在组件的整个生命周期内保持不变,无论组件渲染多少次,ref对象始终指向同一个值。其基本语法如下:

const refContainer = useRef(initialValue);

useRef返回的ref对象有一个特殊的current属性,用于存储和访问值。初始时,current属性被设置为传入的initialValue。需要注意的是,修改current属性不会触发组件的重新渲染,这是useRef与状态管理(如useState)的重要区别。

3. 核心应用场景

下面是一些常见的使用场景:

3.1. 访问DOM元素

useRef最常见的用途之一是获取DOM元素的引用,从而可以直接操作DOM。例如:

import React, { useRef } from 'react';

function TextInputWithFocusButton() {
  const inputEl = useRef(null);
  const onButtonClick = () => {
    // `current` 指向已挂载到DOM上的文本输入元素
    inputEl.current.focus();
  };
  
  return (
    <>
      <input ref={inputEl} type="text" />
      <button onClick={onButtonClick}>聚焦输入框</button>
    </>
  );
}

在这个例子中,useRef创建了一个ref对象inputEl,并将其绑定到input元素的ref属性上。当按钮被点击时,我们可以通过inputEl.current访问到实际的DOM元素,然后调用其focus()方法。

3.2. 存储组件间持久化数据

useRef还可以用于在组件的多次渲染之间存储数据,而不会触发重新渲染。这对于需要在组件生命周期内保持某些值的场景非常有用。例如:

import React, { useRef, useEffect } from 'react';

function Timer() {
  const intervalRef = useRef();
  const [count, setCount] = useState(0);

  useEffect(() => {
    intervalRef.current = setInterval(() => {
      setCount(prevCount => prevCount + 1);
    }, 1000);
    
    return () => {
      clearInterval(intervalRef.current);
    };
  }, []);

  return <div>计时: {count} 秒</div>;
}

在这个计时器组件中,我们使用useRef存储了setInterval返回的计时器ID。这样在组件卸载时,我们可以通过intervalRef.current访问到这个ID,并清除计时器,避免内存泄漏。

3.3. 避免不必要的重新渲染

在某些情况下,我们可能需要在组件内部存储一些值,但又不希望这些值的变化触发组件的重新渲染。这时,useRef是一个理想的选择。例如:

import React, { useRef, useState, useEffect } from 'react';

function DataFetcher() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const isMounted = useRef(true);

  useEffect(() => {
    fetchData().then(result => {
      // 只有当组件仍然挂载时才更新状态
      if (isMounted.current) {
        setData(result);
        setLoading(false);
      }
    });
    
    return () => {
      // 组件卸载时更新标记
      isMounted.current = false;
    };
  }, []);

  return loading ? <div>加载中...</div> : <div>{data}</div>;
}

在这个数据获取组件中,我们使用useRef创建了一个isMounted标记,用于跟踪组件是否仍然挂载。在异步操作完成后,我们首先检查这个标记,只有在组件仍然挂载的情况下才更新状态,从而避免在组件卸载后更新状态导致的警告。

4. useRef与useState的对比

虽然useRefuseState都可以用于存储数据,但它们有本质的区别:

  • 触发渲染:修改useState存储的值会触发组件重新渲染,而修改useRefcurrent属性不会。
  • 数据持久性useState的值在每次渲染时都是固定的,而useRefcurrent属性可以随时修改。
  • 适用场景useState适用于需要根据数据变化更新UI的场景,而useRef适用于存储不影响UI的数据或访问DOM元素。

5. 使用useRef的注意事项

  1. 避免在渲染过程中修改current属性:修改useRefcurrent属性不会触发重新渲染,但如果在渲染过程中修改它,可能会导致难以调试的问题。

  2. 注意内存泄漏:当使用useRef存储DOM元素或其他资源时,要确保在组件卸载时正确清理这些资源,避免内存泄漏。

  3. 不要滥用useRef:虽然useRef很强大,但应该谨慎使用。过度使用useRef可能会导致代码变得难以理解和维护,应该优先使用状态管理和数据流模式。

6. 总结

useRef是React中一个非常有用的钩子,它为我们提供了在组件渲染周期之间持久化存储值的能力,同时不会触发重新渲染。通过合理使用useRef,我们可以更方便地访问DOM元素、存储组件间持久化数据以及优化性能。但在使用过程中,我们也需要注意避免常见的陷阱,确保代码的健壮性和可维护性。


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

Vue表单组件进阶:打造属于你的自定义v-model

从基础到精通:掌握组件数据流的核心

每次写表单组件,你是不是还在用 props 传值、$emit 触发事件的老套路?面对复杂表单需求时,代码就像一团乱麻,维护起来让人头疼不已。今天我要带你彻底掌握自定义 v-model 的奥秘,让你的表单组件既优雅又强大。

读完本文,你将学会如何为任何组件实现自定义的 v-model,理解 Vue 3 中 v-model 的进化,并掌握在实际项目中的最佳实践。准备好了吗?让我们开始这段精彩的组件开发之旅!

重新认识 v-model:不只是语法糖

在深入自定义之前,我们先来回顾一下 v-model 的本质。很多人以为 v-model 是 Vue 的魔法,其实它只是一个语法糖。

让我们看一个基础示例:

// 原生 input 的 v-model 等价于:
<input 
  :value="searchText" 
  @input="searchText = $event.target.value"
>

// 这就是 v-model 的真相!

在 Vue 3 中,v-model 迎来了重大升级。现在你可以在同一个组件上使用多个 v-model,这让我们的表单组件开发更加灵活。

自定义 v-model 的核心原理

自定义 v-model 的核心就是实现一个协议:组件内部管理自己的状态,同时在状态变化时通知父组件。

在 Vue 3 中,这变得异常简单。我们来看看如何为一个自定义输入框实现 v-model:

// CustomInput.vue
<template>
  <div class="custom-input">
    <input
      :value="modelValue"
      @input="$emit('update:modelValue', $event.target.value)"
      class="input-field"
    >
  </div>
</template>

<script setup>
// 定义 props - 默认的 modelValue
defineProps({
  modelValue: {
    type: String,
    default: ''
  }
})

// 定义 emits - 必须声明 update:modelValue
defineEmits(['update:modelValue'])
</script>

使用这个组件时,我们可以这样写:

<template>
  <CustomInput v-model="username" />
</template>

看到这里你可能要问:为什么是 modelValueupdate:modelValue?这就是 Vue 3 的约定。默认情况下,v-model 使用 modelValue 作为 prop,update:modelValue 作为事件。

实战:打造一个功能丰富的搜索框

让我们来实战一个更复杂的例子——一个带有清空按钮和搜索图标的搜索框组件。

// SearchInput.vue
<template>
  <div class="search-input-wrapper">
    <div class="search-icon">🔍</div>
    <input
      :value="modelValue"
      @input="handleInput"
      @keyup.enter="handleSearch"
      :placeholder="placeholder"
      class="search-input"
    />
    <button 
      v-if="modelValue" 
      @click="clearInput"
      class="clear-button"
    >
      ×
    </button>
  </div>
</template>

<script setup>
// 接收 modelValue 和 placeholder
const props = defineProps({
  modelValue: {
    type: String,
    default: ''
  },
  placeholder: {
    type: String,
    default: '请输入搜索内容...'
  }
})

// 定义可触发的事件
const emit = defineEmits(['update:modelValue', 'search'])

// 处理输入事件
const handleInput = (event) => {
  emit('update:modelValue', event.target.value)
}

// 处理清空操作
const clearInput = () => {
  emit('update:modelValue', '')
}

// 处理搜索事件(按回车时)
const handleSearch = () => {
  emit('search', props.modelValue)
}
</script>

<style scoped>
.search-input-wrapper {
  position: relative;
  display: inline-flex;
  align-items: center;
  border: 1px solid #dcdfe6;
  border-radius: 4px;
  padding: 8px 12px;
}

.search-icon {
  margin-right: 8px;
  color: #909399;
}

.search-input {
  border: none;
  outline: none;
  flex: 1;
  font-size: 14px;
}

.clear-button {
  background: none;
  border: none;
  font-size: 18px;
  cursor: pointer;
  color: #c0c4cc;
  margin-left: 8px;
}

.clear-button:hover {
  color: #909399;
}
</style>

使用这个搜索框组件:

<template>
  <div>
    <SearchInput 
      v-model="searchText"
      placeholder="搜索用户..."
      @search="handleSearch"
    />
    <p>当前搜索词:{{ searchText }}</p>
  </div>
</template>

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

const searchText = ref('')

const handleSearch = (value) => {
  console.log('执行搜索:', value)
  // 这里可以调用 API 进行搜索
}
</script>

进阶技巧:多个 v-model 绑定

Vue 3 最令人兴奋的特性之一就是支持多个 v-model。这在处理复杂表单时特别有用,比如一个用户信息编辑组件:

// UserForm.vue
<template>
  <div class="user-form">
    <div class="form-group">
      <label>姓名:</label>
      <input
        :value="name"
        @input="$emit('update:name', $event.target.value)"
      >
    </div>
    
    <div class="form-group">
      <label>邮箱:</label>
      <input
        :value="email"
        @input="$emit('update:email', $event.target.value)"
        type="email"
      >
    </div>
    
    <div class="form-group">
      <label>年龄:</label>
      <input
        :value="age"
        @input="$emit('update:age', $event.target.value)"
        type="number"
      >
    </div>
  </div>
</template>

<script setup>
defineProps({
  name: String,
  email: String,
  age: Number
})

defineEmits(['update:name', 'update:email', 'update:age'])
</script>

使用这个多 v-model 组件:

<template>
  <UserForm
    v-model:name="userInfo.name"
    v-model:email="userInfo.email"
    v-model:age="userInfo.age"
  />
</template>

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

const userInfo = reactive({
  name: '',
  email: '',
  age: null
})
</script>

处理复杂数据类型

有时候我们需要传递的不是简单的字符串,而是对象或数组。这时候自定义 v-model 同样能胜任:

// ColorPicker.vue
<template>
  <div class="color-picker">
    <div 
      v-for="color in colors" 
      :key="color"
      :class="['color-option', { active: isSelected(color) }]"
      :style="{ backgroundColor: color }"
      @click="selectColor(color)"
    ></div>
  </div>
</template>

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

const props = defineProps({
  modelValue: {
    type: [String, Array],
    default: ''
  },
  multiple: {
    type: Boolean,
    default: false
  },
  colors: {
    type: Array,
    default: () => ['#ff4757', '#2ed573', '#1e90ff', '#ffa502', '#747d8c']
  }
})

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

// 处理颜色选择
const selectColor = (color) => {
  if (props.multiple) {
    const currentSelection = Array.isArray(props.modelValue) 
      ? [...props.modelValue] 
      : []
    
    const index = currentSelection.indexOf(color)
    if (index > -1) {
      currentSelection.splice(index, 1)
    } else {
      currentSelection.push(color)
    }
    
    emit('update:modelValue', currentSelection)
  } else {
    emit('update:modelValue', color)
  }
}

// 检查颜色是否被选中
const isSelected = (color) => {
  if (props.multiple) {
    return Array.isArray(props.modelValue) && props.modelValue.includes(color)
  }
  return props.modelValue === color
}
</script>

<style scoped>
.color-picker {
  display: flex;
  gap: 8px;
}

.color-option {
  width: 30px;
  height: 30px;
  border-radius: 50%;
  cursor: pointer;
  border: 2px solid transparent;
  transition: all 0.3s ease;
}

.color-option.active {
  border-color: #333;
  transform: scale(1.1);
}

.color-option:hover {
  transform: scale(1.05);
}
</style>

使用这个颜色选择器:

<template>
  <div>
    <!-- 单选模式 -->
    <ColorPicker v-model="selectedColor" />
    <p>选中的颜色:{{ selectedColor }}</p>
    
    <!-- 多选模式 -->
    <ColorPicker 
      v-model="selectedColors" 
      :multiple="true" 
    />
    <p>选中的颜色:{{ selectedColors }}</p>
  </div>
</template>

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

const selectedColor = ref('#1e90ff')
const selectedColors = ref(['#ff4757', '#2ed573'])
</script>

性能优化与最佳实践

在实现自定义 v-model 时,我们还需要注意一些性能问题和最佳实践:

// 优化版本的表单组件
<template>
  <input
    :value="modelValue"
    @input="handleInput"
    v-bind="$attrs"
  >
</template>

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

const props = defineProps({
  modelValue: [String, Number],
  // 添加防抖功能
  debounce: {
    type: Number,
    default: 0
  }
})

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

let timeoutId = null

// 使用 toRef 确保响应性
const modelValueRef = toRef(props, 'modelValue')

// 监听外部对 modelValue 的更改
watch(modelValueRef, (newValue) => {
  // 这里可以执行一些副作用
  console.log('值发生变化:', newValue)
})

const handleInput = (event) => {
  const value = event.target.value
  
  // 防抖处理
  if (props.debounce > 0) {
    clearTimeout(timeoutId)
    timeoutId = setTimeout(() => {
      emit('update:modelValue', value)
    }, props.debounce)
  } else {
    emit('update:modelValue', value)
  }
}

// 组件卸载时清理定时器
import { onUnmounted } from 'vue'
onUnmounted(() => {
  clearTimeout(timeoutId)
})
</script>

常见问题与解决方案

在实际开发中,你可能会遇到这些问题:

问题1:为什么我的 v-model 不工作? 检查两点:是否正确定义了 update:modelValue 事件,以及是否在 emits 中声明了这个事件。

问题2:如何处理复杂的验证逻辑? 可以在组件内部实现验证,也可以通过额外的 prop 传递验证规则:

// 带有验证的表单组件
<template>
  <div class="validated-input">
    <input
      :value="modelValue"
      @input="handleInput"
      :class="{ error: hasError }"
    >
    <div v-if="hasError" class="error-message">
      {{ errorMessage }}
    </div>
  </div>
</template>

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

const props = defineProps({
  modelValue: String,
  rules: {
    type: Array,
    default: () => []
  }
})

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

// 计算验证状态
const validationResult = computed(() => {
  if (!props.rules.length) return { valid: true }
  
  for (const rule of props.rules) {
    const result = rule(props.modelValue)
    if (result !== true) {
      return { valid: false, message: result }
    }
  }
  
  return { valid: true }
})

const hasError = computed(() => !validationResult.value.valid)
const errorMessage = computed(() => validationResult.value.message)

const handleInput = (event) => {
  const value = event.target.value
  emit('update:modelValue', value)
  emit('validation', validationResult.value)
}
</script>

拥抱 Composition API 的强大能力

使用 Composition API,我们可以创建更加灵活的可复用逻辑:

// useVModel.js - 自定义 v-model 的 composable
import { computed } from 'vue'

export function useVModel(props, emit, name = 'modelValue') {
  return computed({
    get() {
      return props[name]
    },
    set(value) {
      emit(`update:${name}`, value)
    }
  })
}

在组件中使用:

// 使用 composable 的组件
<template>
  <input v-model="valueProxy">
</template>

<script setup>
import { useVModel } from './useVModel'

const props = defineProps({
  modelValue: String
})

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

// 使用 composable
const valueProxy = useVModel(props, emit)
</script>

总结与思考

通过今天的学习,我们深入掌握了 Vue 自定义 v-model 的方方面面。从基础原理到高级用法,从简单输入框到复杂表单组件,你现在应该能够自信地为任何场景创建自定义 v-model 组件了。

记住,自定义 v-model 的核心价值在于提供一致的用户体验。无论是在简单还是复杂的场景中,它都能让我们的组件使用起来更加直观和便捷。

现在,回顾一下你的项目,有哪些表单组件可以重构为自定义 v-model 的形式?这种重构会为你的代码库带来怎样的改善?欢迎在评论区分享你的想法和实践经验!

技术的进步永无止境,但掌握核心原理让我们能够从容应对各种变化。希望今天的分享能为你的 Vue 开发之路带来新的启发和思考。

【LM-PDF】一个大模型时代的 PDF 极速预览方案是如何实现的?

最终效果示例(测试文档:290 页)

Kapture 2025-11-18 at 23.45.35.gif

开源地址: github.com/chennlang/l… (如果觉得还不错,记得留下你的 star,这对我有很大帮助!)

背景

随着 AGI 的日益发展,多模态的大模型也逐渐成为常态,出现在大众视野中,不过对于要求较高的场景,识别效果还是缺点意思,主要还是因为文档解析是一个复杂的流程(layout 分析 + 表格、文字识别 + 切片 + 原文对比、段落划分等),所以传统的 RAG 流程还是主流的方式。

大模型要去 “看” 到世界,首先得理解图片、文档。在 RAG 的流程中,大模型需要去学习本地的文档,从而生成更加专业的回答。而这些存量的文档大多数是 pdf 格式的,或者是以图片存在的。所以我们第一步要解决的问题就是如何把文档中的信息完整、准确的提取出来。

PDF 渲染器其实是文档渲染的一个通用的文档展示方案,如果假设我们是在一个照相机后面看世界,所有图片、文档都能被看做是一张张图片,最终汇总起来就是一本 pdf 文档,所以理论上所有东西都能用 PDF 渲染器显示。

在 AGI 的前端项目中,无论是训练模型语料、还是模型回答原文查看、还是切片来源,都需要把原文档联系起来。所以都需要同时展示原文和回答的功能。

现有 PDF 预览方案

特性 pdf.js react-pdf react-pdf-viewer
类型 JavaScript 库 React 组件库 React 组件库
依赖 pdf.js pdf.js
UI 无(需手动实现) 基础(需手动实现工具栏等) 完整(提供工具栏、缩略图等)
功能 渲染、缩放、搜索等 渲染、页面懒加载等 渲染、搜索、缩放、插件支持等
定制性 高(底层 API) 中(组件化) 中高(插件和主题定制)
性能 取决于 PDF 复杂度和实现 取决于 PDF 复杂度和实现 取决于 PDF 复杂度和实现
学习成本 高(需处理 UI 和交互) 中(React 组件) 中高(API 和插件系统)
适用场景 高度自定义、非 React 环境 React 项目,基础预览 React 项目,功能齐全的查看器

目前主流开源的 pdf 渲染器都是基于 pdf.js 封装实现,其中比较有代表性的就是 react-pdf和 react-pdf-viewer,选型建议:

  • 如果你的项目基于 React,且需要一个开箱即用的 PDF 查看器,推荐选择 react-pdf-viewer
  • 如果你仅需在 React 中渲染 PDF 页面并希望自行设计 UI,建议使用 react-pdf
  • 如果你不在 React 环境中,或需要底层控制,建议直接使用 pdf.js

现存问题

一直以来,我都是使用比较成熟的开源库 react-pdf 渲染 pdf 文档。不过,随着使用的深入,各种问题也随之浮现。例如开源的产品没法满足高度定制化、字体兼容问题导致显示错误.... 而最大的问题,是性能!

  • 场景1: 500 页的 pdf 文档如果不做分页,市面上几乎没有一款 pdf 渲染器能做到流畅的滚动加载。
  • 场景2:加载时间长,100M的文档,需要下载完才能预览,网络差的用户需要等 20分钟后才能看到。

综上问题,本来原文档预览是一个方面使用者快速去对比分片、对比回答结果的快捷方式,却因为以上问题,使用起来特别难受。

react-pdf 兼容性问题可参考:全面解析 React-PDF 的浏览器兼容性及其解决策略背景 最近使用 react-pdf 进行 pdf 文件预览。上线 - 掘金

本文适用范围说明

本文探讨的技术方案基于以下核心需求:

  1. PDF 预览模式:采用无限滚动(Infinite Scroll)方式浏览文件内容,而非传统分页器(Pager)模式(逐页或固定页数翻页)。
  2. 性能要求:需实现 PDF 文件的秒级加载,确保流畅体验。

重点说明: 无限滚动模式更符合现代用户习惯,适用于大多数实际场景。本文内容不涉及分页器模式的实现逻辑。

想法萌生

就在我百思不得其解的时候,我看到了一款闭源的 canvas 实现的 pdf 渲染器。全文只有一个 canvas 元素!当然,简单体验了下,页数很多时依然会很卡,甚至不能用。

受此启发,所以我想,既然 pdf 文件在 OCR 识别之前的第一步,就一定是把每一页切成一张图片,那么基于这个场景下,我们完全可以使用图片来渲染呀,完全不用加载文件。

假设视图内只有一页,那么 canvas 中只会渲染 1 张图片,那速度岂不是秒开?

当然,pdf 文件流是二进制的,也能通过分段获取其中一部分文档。可是如何知道每一页的开始和结束符,这是一个问题。

lm-pdf

为了方便下文讲解,我将此方案先命名为 lm-pdf, 主要是为了体现其出色的加载速度。

技术选型:react-konva

有了以上思路,实现起来就是时间的问题了。我选用了 canvas 作为渲染底座,搜索一圈之后发现 konvajs在这个场景下非常适合。结合 react-konva, 在画布上渲染元素就非常简单了。

示例:在画布上渲染一张图片

import { Stage, Layer, Image } from 'react-konva';

class App extends Component {
  render() {
    return (
      <Stage width={window.innerWidth} height={window.innerHeight}>
        <Layer>
          <Image x={0} y={0} image={...}></ Image>
        </Layer>
      </Stage>
    );
  }
}

当然,使用 canvas 渲染还不够,既然要做到性能最好,我们还需要加上虚拟滚动。

核心功能:canvas 虚拟滚动

很多人会说,都使用 canvas 了还使用什么虚拟滚动?可是你要知道,如果大量的元素常驻在画布上,加上滚动时,所有位置都要偏移,也就是说所有元素的位置都会被重新计算一遍。canvas 是按帧渲染的,这样 GPU 渲染肯定错错有余,不过内存和 CPU 性能却吃不消了!所以,要做就做到最好的! 而虚拟滚动恰好就能解决这个问题,因为视窗内同时显示的元素最多不超过 5 个,那么最多就这 5 个元素的计算量,会非常低。

虚拟滚动是什么

虚拟滚动(Virtual Scrolling)是一种优化长列表渲染性能的技术。其基本原理是只渲染可视区域内的元素,而非整个列表,从而减少DOM节点的数量和提高页面性能。

虚拟滚动本身的原理说起来很简单,无非就是通过容器高度和滚动距离动态渲染子元素。不过,要实现一个基于 canvas 的虚拟滚动器,实现过程中,却有很多小细节值得分享。

传统实现方案

Canvas 的虚拟滚动方案和常规实现方案有所不同,也有相同之处。所以我们需要先了解下传统的滚动条方案是怎么实现的。方便理解文章后面的内容。

完整 Demo 如下:

import React, { useState, useEffect, useRef } from 'react';

// 虚拟滚动列表组件
const VirtualScrollList = ({ items, itemHeight }) => {
    // 状态:可见项的起始和结束索引
    const [startIndex, setStartIndex] = useState(0);
    const [endIndex, setEndIndex] = useState(10);

    // 引用:用于访问滚动容器
    const containerRef = useRef(null);

    const handleScroll = () => {
        // 获取当前滚动位置
        const scrollTop = containerRef.current.scrollTop;

        // 计算新的起始索引
        const newStartIndex = Math.floor(scrollTop / itemHeight);

        // 计算新的结束索引
        const newEndIndex = newStartIndex + Math.ceil(containerRef.current.clientHeight / itemHeight);

        // 更新可见项的索引
        setStartIndex(newStartIndex);
        setEndIndex(newEndIndex);
    };

    // 滚动事件监听
    useEffect(() => {
        const container = containerRef.current;
        container.addEventListener('scroll', handleScroll);

        return () => {
            container.removeEventListener('scroll', handleScroll);
        };
    }, []);

    // 可见项
    const visibleItems = items.slice(startIndex, endIndex);

    // 计算占位符高度
    const placeholderHeight = items.length * itemHeight;

    return (
        <div style={{ height: '300px', overflowY: 'auto' }} ref={containerRef}>
            <div style={{ height: `${placeholderHeight}px`, position: 'relative' }}>
                <div style={{ position: 'absolute', top: `${startIndex * itemHeight}px`, left: 0 }}>
                    {visibleItems.map((item, index) => (
                        // 渲染可见项
                        <div key={index} style={{ height: `${itemHeight}px` }}>
                            {item}
                        </div>
                    ))}
                </div>
            </div>
        </div>
    );
};

// 使用示例
const items = Array.from({ length: 1000 }, (_, i) => `Item ${i + 1}`);
const App = () => (
    <div>
        <h1>虚拟滚动列表</h1>
        <VirtualScrollList items={items} itemHeight={30} />
    </div>
);

export default App;

定义一个父容器,在容器中放一个占位符,占位符高度是所以 item 的总和,从而达到撑开父容器,出现滚动条。然后其子元素 item 使用 absolute 的方式悬浮在占位符 元素上。然后通过滚动的距离计算出要显示的子元素 visibleItems,渲染在页面上即可。

Canvas 虚拟滚动实现方案

下面将使用伪代码展示核心原理。

一、canvas 内并没有滚动条的概念,所以我们需要自己实现一个滚动条。

virtual-scroll-bar 组件

// Item
interface Item {
  id: string | number;
  height: number;
  [k: string]: any;
}

interface Props {
    items: Item [];
    onVisibleItemChange: (items: Item[]) => void;
    onScroll?: (scroll: { left: number; top: number }) => void;
}
const VirtualScrollBar = ({ items }: ) => {
    // 滚动触发
    function handleScroll (scroll) {
        updateVisibleItems(scroll)
        onScroll(scroll)
    }

    // 计算可视元素
    function updateVisibleItems (scroll) {
       const visibleItems = []
       // .....省略计算过程
       onVisibleItemChange(visibleItems)
    }

    return <div ref={divRef} className={`v-scroll-bar ${direction}`}>
        <div style={{
            height: items.reduce((sum, item) => (sum += item.height), 0),
        }}>
        </div>
    </div>
}

同样的方式,我们在 div 中加入一个占位符,高度是所以 item 的总和。然后通过监听 divRef 的滚动,计算出视图内出现的元素。

核心逻辑(伪代码)

// 当前显示元素
const [displayItems, setDisplayItems] = useState<PageItem[]>([]);

const onScroll: VirtualScrollBarProps["onScroll"] = ({ left, top }) => {
    // 整体 y 方向偏移, 这里使用 setData 而不是 setData => old,
    // 因为滚动频繁,利用 setData 更新机制可以做到节流,提升性能
    setDisplayItems((old) =>
      old.map((m) => ({
        ...m,
        y: m.top - top,
      }))
)};


// 对比新旧值,更新 Y 的坐标
// 滚动的过程中,y 会偏移,新的 items 进来,如果有公共的 items ,要和旧的保持一致。
function diffAndUpdateY () {}

function onVisibleItemChange(originItems: VirtualScrollBarProps["items"]) {
    const items = originItems as PageItem[];
    // 这里要做一件事,新的 items 会把 y 的坐标全部重新排过,这会有问题,表现为突然弹跳位置。
    // 如果新的 items 中和旧的 items 中有共同的 item, 那么以旧的 item 的 y 为准,保持不变
    // 那么新出现的,排在旧的上面或下面
    startTransition(() => {
      startTransition(() => {
        setDisplayItems((old) => diffAndUpdateY(old, items));
      });
    });
}


<VirtualScrollBar
    items={pages}
    onVisibleItemChange={onVisibleItemChange}
    onScroll={onScroll}
></VirtualScrollBar>

核心功能:如何实现页面平滑切换

不过你会发现,页面是一卡一卡的,像是幻灯片,子元素没有随着滚动而移动的。只是会到达一定滚动距离后,就会全部替换成新的元素。因为我们还没有做偏移,元素要随着滚动在容器内上下移动,直到移动到容器外,才替换成新的元素。实现偏移:所以我们把整体元素 y 值随着滚动偏移, y = y + offsetY, 这样元素就会滚动效果 。

image.png

要想实现流畅滚动(平滑切换),还要实现新旧元素 Diff ,原理如下:

如上图,假设我们当前视图显示了 [元素1、元素2],子元素高度都是 200px,滚动了 30px 后,显示了 [元素1、元素2、元素 3]。

元素 1、元素 2 在是它们的交集。所以元素 1、元素 2 的位置要保持以前的位置不变(用户界面能看到的已有的元素,不能因为切换了新的元素而改变位置),而元素 3 应该在元素 2 后面。

元素 初始坐标(滚动前) 滚动后坐标(向下滚动 30px)
元素1 [0, 0] [0, -30]
元素2 [0, 200] [0, 170]
元素3 [0, 370]

做完 diff 后,从用户的角度就会发现是连续的滚动,而实际是不停的在切换新元素。

性能优化:异步加载,减少线程阻塞

因为滚动的过程中是连续的,例如从第1页滚到 100页,那么中间的 2-99 都会被渲染一遍,其实我只是想看第 100 页,这会严重拖慢页面的渲染性能。

// page.ts
useEffect(() => {
    if (!blocks.length) return;

    // 延迟渲染定时器
    const timer = setTimeout(() => {
      // 开始渲染
      setDisplayBlocks(blocks);
    }, 500);

    return () => {
      clearTimeout(timer);
    };
  }, [blocks]);

上面我用了一个定时器,完美解决了这个问题,只有等组件出现在可视区域,渲染后且 500 毫秒内没有消失,才会真正渲染。这样就能避免无效的渲染任务。

500 毫秒最终使用过程中被我改成了 200,不然会出现明显的等待渲染过程,影响体验。

性能对比

页面加载速度测试,我采用目前开源中用的最多的 react-pdflm-pdf 作对比:

  • 测试指标: 首页渲染时间
  • 测试网速:13.9 Mbps
PDF 测试文件页码 react-pdf lm-pdf
3页 3.5s 1s
50页 7s 1.5s
344页 109s 2.5s
1000页 240s 2.5s

react-pdf 的加载速度取决于文档的大小,下载的网速影响。而 lm-pdf 的优势在于无论多少页,都趋近于 2.5s,打开的速度取决于单页的图片大小。

lm-pdf 优缺点

优点:

  • 极快的首次加载速度(和文件大小、页数无关)
  • 丝滑的滚动体验
  • 极低的内存占用
  • 极少的页面 DOM

缺点:

  • 目前不支持复制 PDF 文本(研究中)
  • PDF 必须先被切成图片

持续优化的点:其实还可以在远端无损压缩图片,进一步提高渲染速度。

总结

如果你看重的是加速速度和性能,lm-pdf 绝对能满足你的需求,不过此方案还存在一些局限,例如强依赖后端生成 pdf 单页图片、不支持复制 PDF 文本等,不过目前已开源,后续还会持续完善,也希望感兴趣的同学一起 PR 共建。

一文搞懂:localhost和局域网 IP 的核心区别与使用场景

前端项目运行时给出的 localhost:3000 和 192.168.1.41:3000 本质上指向同一项目服务,但适用场景和访问范围不同,具体区别及选择建议如下:

一、核心区别

维度 localhost:3000 192.168.1.41:3000
指向对象 仅指向「当前运行项目的本机」(通过本地回环地址 127.0.0.1 实现) 指向本机在局域网中的 IP 地址(192.168.1.41 是本机在路由器分配的私有 IP)
访问范围 只能在「本机」上访问(其他设备无法通过 localhost 访问) 同一局域网内的所有设备(如手机、其他电脑、平板)均可访问
依赖条件 无需网络(断网也能访问),仅依赖本机服务是否启动 需保证本机和其他设备在同一局域网(如同一 WiFi / 网线),且本机防火墙允许端口访问

二、选择建议:根据场景决定

  1. 开发调试时优先用 localhost:3000

    • 优势:访问速度更快(本地回环不经过网络路由),且不受局域网波动影响(断网也能工作),更稳定。
    • 适用场景:自己在电脑上写代码、调试功能、修改样式等。
  2. 需要跨设备测试时用 192.168.1.41:3000

    • 优势:可以在手机、平板或同事的电脑上访问你的项目,验证响应式布局、多设备兼容性等。
    • 适用场景:测试移动端显示效果、让团队成员临时查看项目进度、跨设备联调(如手机扫码测试支付流程)。

三、注意事项

  • 若用局域网地址访问失败,可能是本机防火墙阻止了 3000 端口,或项目配置限制了仅本地访问(部分框架需手动开启局域网访问权限)。
  • 局域网 IP 可能会变化(路由器重启可能重新分配),若后续访问失败,可重新运行项目获取新的局域网地址。

总之,日常开发用 localhost 更高效,跨设备测试时再用局域网 IP 即可。

你支持游戏内显示电量、信号或时间吗?

点击上方亿元程序员+关注和★星标

素材源于网络

引言

哈喽大家好,周末的时候,我看到了一个非常有趣的话题,就是我们的游戏里面,应该/不应该显示电量、信号或时间?

不知道大家在玩王者荣耀的时候有没有这样类似的经历:当你打得正起劲的时候,勇冠三军,即将超神,突然间被一个弹框秒了。

素材源于网络

没错,这个弹框就是天下苦iOS久矣的电池电量不足弹框。

还剩10%的电量

这个和显不显示电量有什么关系,难道王者荣耀没有显示电量吗?--的确没什么关系,只是吐槽一下这个电量不足的弹框。

硬要说有关系的话,那就是电量没有显示数值,没办法提前预防弹框。

言归正传,今天一起来聊一聊游戏内应该/不应该显示电量、信号或时间?

本文源工程可在文末获取,小伙伴们自行前往。

沉浸式的体验

首先,我们来看看反对方(游戏内不应该显示电量、信号或时间)的观点:

1.游戏的核心价值在于提供沉浸式的体验

经常做游戏的小伙伴都知道,我们做游戏的目的,很多时候就是为了让玩家能够“沉迷”进去游戏,产生共鸣。

可以理解,玩家们为了得到放松,会暂时逃离现实世界,进入到游戏世界中,上演一个不一样的自己。

但是,游戏内的电量、信号或时间,这些现实中的元素就会不断提醒玩家还在现实,没办法沉浸式代入游戏。

2.时间焦虑、电量焦虑

图片由AI生成

往往游戏内不显示时间、电池的目的为了避免玩家不必要心理干扰和焦虑。

时间的显示,会让人产生“我玩了多久了?”的焦虑;电量的显示则会引发“我还能玩多久?”的担忧。

这些与游戏没太大关联的焦虑和担忧,很容易让玩家分心,送人头。

游戏应该是放松和享受的,而不是另一个焦虑的来源。

3.多此一举

如果玩家实在是想看看电池电量、当前时间或者网络信号,顶部轻轻下滑就能看到,何必多此一举的优化呢?

笔者的想法

我觉得游戏内显示电量、信号或时间挺好的:

1.提醒玩家

游戏内显示时间,目的就是为了方便提醒玩家。例如活动的开始和结束时间,某个Boss刷新的时间,或者副本的剩余时间,这些都是比较有效和玩家乐意收到的提醒。

2.转移压力

网络信号信息,当玩家,网络状态见红,或者高延迟时,那么玩家能够意识到自己的网络环境可能不太好,假如没有这个显示,那么玩家可能就对游戏开喷了,这是一种常见的转移压力手段。

素材源于网络

3.心理预防

正如开篇的例子,假如游戏内有电池电量预防,那么我会在将要没电的时候去充电,或者避免一些重要的操作,因电量不足造成损失。例如“等等再开团,等我先充个电!

聊着聊着又要上例子了

既然如此,我们在Cocos游戏开发中,如何显示电量、信号和时间信息呢?下面一起来看个例子。

1.相关API

  • 获取网络类型:常用于优化弱网体验以及网络调优,通过接口我们可以判断当前是是否连接wifi,是否是弱网环境。 来源于微信官方文档

  • 获取设备电池信息:常用于获取设备电池信息,通过接口我们可以获取当前电量,是否正在充电,是否处于省电模式。 来源于微信官方文档

2.资源准备

老规矩,先找AI搭子搞几个Wifi4G信号、电池资源。

然后在场景中简单拼接一下。

3.写代码

首先创建一个Main脚本,因为本次演示环境是微信小游戏,所以我们声明一下declare const wx: any; 使用微信的API

定时获取信息。

设置时间。

设置网络状态,由于延迟数据的判断涉及服务端,小伙伴们可以通过心跳包等方式计算,笔者这里不做详细演示。

甚至可以使用Math.random,简单快捷,建议不要学

设置电池信息。

4.效果演示

Wifi:

4G:

正在充电:

不在充电:

结语

看到这里的小伙伴们,你们支持游戏内显示电量、信号或时间吗?

本文源工程可通过私信发送 TopBar 获取。

我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐专栏:

知识付费专栏

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

100个Cocos实例

8年主程手把手打造Cocos独立游戏开发框架

和8年游戏主程一起学习设计模式

从零开始开发贪吃蛇小游戏到上线系列

点击下方绿色按钮+关注。

Qt6 QML 实现DateTimePicker组件

Qt6 QML 实现DateTimePicker组件

实现代码

基于QT6.10

// DateTimePicker.qml
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts

Popup {
    id: dateTimePicker

    width: 650
    height: 480

    modal: true
    closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
    padding: 0
    focus: true

    readonly property color colorBackground: "#2b2b2b"
    readonly property color colorSurface: "#1e1e1e"
    readonly property color colorSurfaceVariant: "#222"
    readonly property color colorBorder: "#444"
    readonly property color colorBorderLight: "#555"

    readonly property color colorPrimary: "#007AFF"
    readonly property color colorPrimaryHover: "#0066CC"

    readonly property color colorTextPrimary: "white"
    readonly property color colorTextSecondary: "#aaa"
    readonly property color colorTextTertiary: "#888"
    readonly property color colorTextDisabled: "#444"

    readonly property color colorHover: "#333"
    readonly property color colorHoverLight: "#444"
    readonly property color colorHoverDark: "#555"

    readonly property color colorScrollbar: "#555"
    readonly property color colorScrollbarHover: "#666"
    readonly property color colorScrollbarPressed: "#888"

    readonly property color colorDisabled: "#555"

    property string dateTime: Qt.formatDateTime(new Date(), "yyyy-MM-dd hh:mm:ss")
    property string dateFormat: "yyyy-MM-dd"
    property string timeFormat: "hh:mm:ss"
    property string minDateTime: ""
    property string maxDateTime: ""

    property date selectedDate: new Date()
    property int selectedHour: 0
    property int selectedMinute: 0
    property int selectedSecond: 0

    property int currentYear: new Date().getFullYear()
    property int currentMonth: new Date().getMonth()

    property bool hasSelection: false  // 选择状态标记

    // 焦点区域属性
    property int focusArea: 0  // 0: 日期, 1: 时, 2: 分, 3: 秒
    property int focusedDateIndex: 0  // 日历格子索引

    signal confirmed(date datetime)

    background: Rectangle {
        color: dateTimePicker.colorBackground
        radius: 8

        FocusScope {
            id: keyboardHandler
            anchors.fill: parent
            focus: true

            Keys.onPressed: function (event) {
                if (event.key === Qt.Key_Left) {
                    if (dateTimePicker.focusArea === 0) {
                        dateTimePicker.focusedDateIndex = Math.max(0, dateTimePicker.focusedDateIndex - 1)
                        dateTimePicker.selectedDate = getDateForCell(dateTimePicker.focusedDateIndex)
                        dateTimePicker.hasSelection = true
                    } else if (dateTimePicker.focusArea > 0) {
                        dateTimePicker.focusArea = Math.max(0, dateTimePicker.focusArea - 1)
                    }
                    event.accepted = true
                } else if (event.key === Qt.Key_Right) {
                    if (dateTimePicker.focusArea === 0) {
                        dateTimePicker.focusedDateIndex = Math.min(41, dateTimePicker.focusedDateIndex + 1)
                        dateTimePicker.selectedDate = getDateForCell(dateTimePicker.focusedDateIndex)
                        dateTimePicker.hasSelection = true
                    } else if (dateTimePicker.focusArea < 3) {
                        dateTimePicker.focusArea = Math.min(3, dateTimePicker.focusArea + 1)
                    }
                    event.accepted = true
                } else if (event.key === Qt.Key_Up) {
                    if (dateTimePicker.focusArea === 0) {
                        dateTimePicker.focusedDateIndex = Math.max(0, dateTimePicker.focusedDateIndex - 7)
                        dateTimePicker.selectedDate = getDateForCell(dateTimePicker.focusedDateIndex)
                        dateTimePicker.hasSelection = true
                    } else {
                        if (dateTimePicker.focusArea === 1) {
                            dateTimePicker.selectedHour = (dateTimePicker.selectedHour - 1 + 24) % 24
                        } else if (dateTimePicker.focusArea === 2) {
                            dateTimePicker.selectedMinute = (dateTimePicker.selectedMinute - 1 + 60) % 60
                        } else {
                            dateTimePicker.selectedSecond = (dateTimePicker.selectedSecond - 1 + 60) % 60
                        }
                        dateTimePicker.hasSelection = true
                    }
                    event.accepted = true
                } else if (event.key === Qt.Key_Down) {
                    if (dateTimePicker.focusArea === 0) {
                        dateTimePicker.focusedDateIndex = Math.min(41, dateTimePicker.focusedDateIndex + 7)
                        dateTimePicker.selectedDate = getDateForCell(dateTimePicker.focusedDateIndex)
                        dateTimePicker.hasSelection = true
                    } else {
                        if (dateTimePicker.focusArea === 1) {
                            dateTimePicker.selectedHour = (dateTimePicker.selectedHour + 1) % 24
                        } else if (dateTimePicker.focusArea === 2) {
                            dateTimePicker.selectedMinute = (dateTimePicker.selectedMinute + 1) % 60
                        } else {
                            dateTimePicker.selectedSecond = (dateTimePicker.selectedSecond + 1) % 60
                        }
                        dateTimePicker.hasSelection = true
                    }
                    event.accepted = true
                } else if (event.key === Qt.Key_Tab) {
                    dateTimePicker.focusArea = (dateTimePicker.focusArea + 1) % 4
                    event.accepted = true
                } else if (event.key === Qt.Key_Backtab) {
                    dateTimePicker.focusArea = (dateTimePicker.focusArea - 1 + 4) % 4
                    event.accepted = true
                } else if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter) {
                    if (dateTimePicker.hasSelection) {
                        let dt = new Date(dateTimePicker.selectedDate)
                        dt.setHours(dateTimePicker.selectedHour)
                        dt.setMinutes(dateTimePicker.selectedMinute)
                        dt.setSeconds(dateTimePicker.selectedSecond)
                        dateTimePicker.confirmed(dt)
                        dateTimePicker.close()
                    }
                    event.accepted = true
                } else if (event.key === Qt.Key_Escape) {
                    dateTimePicker.close()
                    event.accepted = true
                }
            }
        }
    }

    ColumnLayout {
        anchors.fill: parent
        spacing: 0

        // 顶部日期时间显示
        Rectangle {
            Layout.fillWidth: true
            Layout.preferredHeight: 50
            color: dateTimePicker.colorSurface
            radius: 8

            Rectangle {
                anchors.left: parent.left
                anchors.right: parent.right
                anchors.bottom: parent.bottom
                height: 8
                color: parent.color
            }

            Label {
                id: dateTimeLabel

                anchors.centerIn: parent
                color: dateTimePicker.colorPrimary
                font.pixelSize: 16
                font.bold: true
                text: Qt.formatDateTime(new Date(selectedDate.getFullYear(), selectedDate.getMonth(), selectedDate.getDate(), selectedHour, selectedMinute, selectedSecond), dateFormat + " " + timeFormat)
            }
        }

        // 主内容区域
        RowLayout {
            Layout.fillWidth: true
            Layout.fillHeight: true
            Layout.margins: 20
            spacing: 20

            // 左侧日历区域
            ColumnLayout {
                Layout.fillWidth: true
                Layout.fillHeight: true
                spacing: 12

                // 月份导航栏
                RowLayout {
                    Layout.fillWidth: true
                    spacing: 8

                    Button {
                        text: "<<"
                        Layout.preferredWidth: 40
                        Layout.preferredHeight: 32
                        onClicked: {
                            currentYear -= 1
                        }
                        background: Rectangle {
                            color: parent.hovered ? dateTimePicker.colorHoverDark : dateTimePicker.colorBorder
                            radius: 4
                        }
                        contentItem: Text {
                            text: parent.text
                            color: dateTimePicker.colorTextPrimary
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }
                    Button {
                        text: "<"
                        Layout.preferredWidth: 40
                        Layout.preferredHeight: 32
                        onClicked: {
                            currentMonth -= 1
                            if (currentMonth < 0) {
                                currentMonth = 11
                                currentYear -= 1
                            }
                        }
                        background: Rectangle {
                            color: parent.hovered ? dateTimePicker.colorHoverDark : dateTimePicker.colorBorder
                            radius: 4
                        }
                        contentItem: Text {
                            text: parent.text
                            color: dateTimePicker.colorTextPrimary
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }
                    Label {
                        text: Qt.formatDate(new Date(currentYear, currentMonth), "yyyy年 M月")
                        color: dateTimePicker.colorTextPrimary
                        font.pixelSize: 16
                        font.bold: true
                        Layout.fillWidth: true
                        horizontalAlignment: Text.AlignHCenter
                    }
                    Button {
                        text: ">"
                        Layout.preferredWidth: 40
                        Layout.preferredHeight: 32
                        onClicked: {
                            currentMonth += 1
                            if (currentMonth > 11) {
                                currentMonth = 0
                                currentYear += 1
                            }
                        }
                        background: Rectangle {
                            color: parent.hovered ? dateTimePicker.colorHoverDark : dateTimePicker.colorBorder
                            radius: 4
                        }
                        contentItem: Text {
                            text: parent.text
                            color: dateTimePicker.colorTextPrimary
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }
                    Button {
                        text: ">>"
                        Layout.preferredWidth: 40
                        Layout.preferredHeight: 32
                        onClicked: {
                            currentYear += 1
                        }
                        background: Rectangle {
                            color: parent.hovered ? dateTimePicker.colorHoverDark : dateTimePicker.colorBorder
                            radius: 4
                        }
                        contentItem: Text {
                            text: parent.text
                            color: dateTimePicker.colorTextPrimary
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                        }
                    }
                }

                // 星期标题行
                GridLayout {
                    Layout.fillWidth: true
                    columns: 7
                    rowSpacing: 0
                    columnSpacing: 0

                    Repeater {
                        model: ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
                        Label {
                            text: modelData
                            color: dateTimePicker.colorTextTertiary
                            font.pixelSize: 12
                            horizontalAlignment: Text.AlignHCenter
                            verticalAlignment: Text.AlignVCenter
                            Layout.fillWidth: true
                            Layout.preferredHeight: 30
                        }
                    }
                }

                // 日期网格
                GridLayout {
                    Layout.fillWidth: true
                    Layout.fillHeight: true
                    columns: 7
                    rowSpacing: 8
                    columnSpacing: 8

                    Repeater {
                        model: 42
                        Rectangle {
                            Layout.fillWidth: true
                            Layout.fillHeight: true
                            Layout.minimumHeight: 40
                            radius: 4

                            color: {
                                if (!isInRange) return dateTimePicker.colorSurfaceVariant

                                let date = getDateForCell(index)
                                if (date.getDate() === selectedDate.getDate() && date.getMonth() === selectedDate.getMonth() && date.getFullYear() === selectedDate.getFullYear()) {
                                    return dateTimePicker.colorPrimary
                                }
                                return dateMouseArea.containsMouse ? dateTimePicker.colorBorder : "transparent"
                            }

                            border.color: {
                                let date = cellDate
                                if (date.getDate() === selectedDate.getDate() && date.getMonth() === selectedDate.getMonth() && date.getFullYear() === selectedDate.getFullYear()) {
                                    return "white"
                                }
                                return "transparent"
                            }
                            border.width: 2

                            property var cellDate: getDateForCell(index)

                            property bool isInRange: {
                                let testDate = new Date(cellDate)
                                testDate.setHours(selectedHour, selectedMinute, selectedSecond)
                                return isDateTimeInRange(testDate)
                            }

                            Label {
                                anchors.centerIn: parent
                                text: parent.cellDate.getDate()
                                color: {
                                    if (!parent.isInRange) return dateTimePicker.colorTextDisabled
                                    return parent.cellDate.getMonth() === currentMonth ? dateTimePicker.colorTextPrimary : dateTimePicker.colorBorderLight
                                }
                                font.pixelSize: 14
                            }

                            MouseArea {
                                id: dateMouseArea
                                anchors.fill: parent
                                hoverEnabled: true
                                enabled: parent.isInRange
                                cursorShape: enabled ? Qt.PointingHandCursor : Qt.ForbiddenCursor
                                onClicked: {
                                    if (parent.isInRange) {
                                        let clickedDate = parent.cellDate
                                        selectedDate = clickedDate

                                        // 如果点击的日期不在当前月,自动切换到该月
                                        if (clickedDate.getMonth() !== currentMonth || clickedDate.getFullYear() !== currentYear) {
                                            currentMonth = clickedDate.getMonth()
                                            currentYear = clickedDate.getFullYear()
                                        }

                                        dateTimePicker.hasSelection = true
                                    }
                                }
                            }
                        }
                    }
                }
            }

            // 分隔线
            Rectangle {
                Layout.preferredWidth: 1
                Layout.fillHeight: true
                color: dateTimePicker.colorBorder
            }

            // 右侧时间选择区域
            RowLayout {
                Layout.preferredWidth: 210
                Layout.fillHeight: true
                spacing: 8

                // 时间列组件
                Repeater {
                    model: [{label: "时", value: selectedHour, max: 24}, {
                        label: "分", value: selectedMinute, max: 60
                    }, {label: "秒", value: selectedSecond, max: 60}]

                    ColumnLayout {
                        Layout.fillWidth: true
                        Layout.fillHeight: true
                        spacing: 8

                        property string timeLabel: modelData.label

                        Label {
                            text: timeLabel
                            color: dateTimePicker.colorTextTertiary
                            font.pixelSize: 12
                            Layout.alignment: Qt.AlignHCenter
                        }

                        Rectangle {
                            Layout.fillWidth: true
                            Layout.fillHeight: true
                            color: dateTimePicker.colorSurface
                            radius: 4
                            border.color: dateTimePicker.colorBorder
                            border.width: 1

                            ScrollView {
                                anchors.fill: parent
                                anchors.margins: 2
                                clip: true
                                ScrollBar.horizontal.policy: ScrollBar.AlwaysOff

                                ScrollBar.vertical: ScrollBar {
                                    policy: ScrollBar.AsNeeded
                                    width: 8

                                    contentItem: Rectangle {
                                        implicitWidth: 8
                                        radius: 4
                                        color: parent.pressed ? dateTimePicker.colorScrollbarPressed : (parent.hovered ? dateTimePicker.colorScrollbarHover : dateTimePicker.colorScrollbar)
                                    }

                                    background: Rectangle {
                                        implicitWidth: 6
                                        color: "transparent"
                                    }
                                }

                                ListView {
                                    id: timeListView

                                    model: modelData.max
                                    highlightFollowsCurrentItem: false
                                    highlightMoveDuration: 0
                                    snapMode: ListView.SnapToItem

                                    readonly property string timeType: timeLabel

                                    readonly property int currentValue: {
                                        if (timeType === "时") return dateTimePicker.selectedHour
                                        if (timeType === "分") return dateTimePicker.selectedMinute
                                        return dateTimePicker.selectedSecond
                                    }

                                    function selectTime(value) {
                                        console.log("Selected " + timeListView.timeType + ": " + value + ", hasSelection: " + dateTimePicker.hasSelection)

                                        dateTimePicker.hasSelection = true

                                        if (timeType === "时") {
                                            dateTimePicker.selectedHour = value
                                        } else if (timeType === "分") {
                                            dateTimePicker.selectedMinute = value
                                        } else {
                                            dateTimePicker.selectedSecond = value
                                        }

                                        // 滚动到列表顶部
                                        positionViewAtIndex(value, ListView.Beginning)
                                    }

                                    delegate: Rectangle {
                                        width: timeListView.width
                                        height: 44
                                        radius: 4

                                        color: {
                                            if (index === timeListView.currentValue) return dateTimePicker.colorPrimary
                                            return timeItemMouseArea.containsMouse ? dateTimePicker.colorHover : "transparent"
                                        }

                                        Label {
                                            anchors.centerIn: parent
                                            text: index.toString().padStart(2, '0')
                                            color: index === timeListView.currentValue ? dateTimePicker.colorTextPrimary : dateTimePicker.colorTextSecondary
                                            font.pixelSize: 16
                                            font.bold: index === timeListView.currentValue
                                        }

                                        MouseArea {
                                            id: timeItemMouseArea
                                            anchors.fill: parent
                                            hoverEnabled: true
                                            cursorShape: Qt.PointingHandCursor
                                            onClicked: {
                                                timeListView.selectTime(index)
                                            }
                                        }
                                    }

                                    Component.onCompleted: {
                                        positionViewAtIndex(modelData.value, ListView.Beginning)
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }

        // 底部按钮区域
        Rectangle {
            Layout.fillWidth: true
            Layout.preferredHeight: 60
            color: dateTimePicker.colorSurface

            Rectangle {
                anchors.left: parent.left
                anchors.right: parent.right
                anchors.top: parent.top
                height: 8
                color: parent.color
            }

            RowLayout {
                anchors.fill: parent
                anchors.margins: 15
                spacing: 10

                Button {
                    text: qsTr("今天")
                    Layout.preferredWidth: 80
                    Layout.preferredHeight: 36
                    onClicked: {
                        var now = new Date()
                        selectedDate = now
                        selectedHour = now.getHours()
                        selectedMinute = now.getMinutes()
                        selectedSecond = now.getSeconds()
                        currentYear = now.getFullYear()
                        currentMonth = now.getMonth()
                        hasSelection = true
                    }
                    background: Rectangle {
                        color: parent.hovered ? dateTimePicker.colorHoverDark : dateTimePicker.colorBorder
                        radius: 4
                        border.color: dateTimePicker.colorBorderLight
                        border.width: 1
                    }
                    contentItem: Text {
                        text: parent.text
                        color: dateTimePicker.colorTextSecondary
                        font.pixelSize: 14
                        horizontalAlignment: Text.AlignHCenter
                        verticalAlignment: Text.AlignVCenter
                    }
                }

                Item {
                    Layout.fillWidth: true
                }

                Button {
                    text: qsTr("取消")
                    Layout.preferredWidth: 80
                    Layout.preferredHeight: 36
                    onClicked: dateTimePicker.close()
                    background: Rectangle {
                        color: parent.hovered ? dateTimePicker.colorHoverDark : dateTimePicker.colorBorder
                        radius: 4
                        border.color: dateTimePicker.colorBorderLight
                        border.width: 1
                    }
                    contentItem: Text {
                        text: parent.text
                        color: dateTimePicker.colorTextSecondary
                        font.pixelSize: 14
                        horizontalAlignment: Text.AlignHCenter
                        verticalAlignment: Text.AlignVCenter
                    }
                }

                Button {
                    text: qsTr("确定")
                    Layout.preferredWidth: 80
                    Layout.preferredHeight: 36
                    enabled: dateTimePicker.hasSelection
                    onClicked: {
                        let dt = new Date(selectedDate)
                        dt.setHours(selectedHour)
                        dt.setMinutes(selectedMinute)
                        dt.setSeconds(selectedSecond)

                        if (isDateTimeInRange(dt)) {
                            dateTimePicker.dateTime = formatDateTime(dt)
                            confirmed(dt)
                            close()
                        }
                    }
                    background: Rectangle {
                        color: {
                            if (!parent.enabled) return dateTimePicker.colorDisabled
                            return parent.hovered ? dateTimePicker.colorPrimaryHover : dateTimePicker.colorPrimary
                        }
                        radius: 4
                    }
                    contentItem: Text {
                        text: parent.text
                        color: parent.enabled ? dateTimePicker.colorTextPrimary : dateTimePicker.colorTextTertiary
                        font.pixelSize: 14
                        font.bold: true
                        horizontalAlignment: Text.AlignHCenter
                        verticalAlignment: Text.AlignVCenter
                    }
                }
            }
        }
    }

    function getDateForCell(index) {
        let firstDay = new Date(currentYear, currentMonth, 1)
        let dayOfWeek = (firstDay.getDay() + 6) % 7
        let cellDate = new Date(currentYear, currentMonth, 1 - dayOfWeek + index)
        return cellDate
    }

    function isDateTimeInRange(date) {
        if (minDateTime) {
            let minDt = new Date(minDateTime)
            if (!isNaN(minDt.getTime()) && date < minDt) {
                return false
            }
        }
        if (maxDateTime) {
            let maxDt = new Date(maxDateTime)
            if (!isNaN(maxDt.getTime()) && date > maxDt) {
                return false
            }
        }
        return true
    }

    function formatDateTime(date) {
        return Qt.formatDateTime(date, dateFormat + " " + timeFormat)
    }

    onDateTimeChanged: {
        if (!hasSelection) {
            let dt = new Date(dateTime)
            if (!isNaN(dt.getTime())) {
                selectedDate = dt
                selectedHour = dt.getHours()
                selectedMinute = dt.getMinutes()
                selectedSecond = dt.getSeconds()
                currentYear = dt.getFullYear()
                currentMonth = dt.getMonth()
            }
        }
    }

    Component.onCompleted: {
        if (dateTime) {
            let dt = new Date(dateTime)
            if (!isNaN(dt.getTime())) {
                selectedDate = dt
                selectedHour = dt.getHours()
                selectedMinute = dt.getMinutes()
                selectedSecond = dt.getSeconds()
                currentYear = dt.getFullYear()
                currentMonth = dt.getMonth()

                hasSelection = true
            }
        }

        // 初始化焦点位置
        let firstDay = new Date(currentYear, currentMonth, 1)
        let dayOfWeek = (firstDay.getDay() + 6) % 7
        focusedDateIndex = dayOfWeek + selectedDate.getDate() - 1

        // 延迟设置焦点,确保 FocusScope 已完全初始化
        Qt.callLater(function () {
            keyboardHandler.forceActiveFocus()
        })
    }
}

使用代码

DateTimePicker {
    id: dateTimePicker

    width: 250
    height: 40

    // 初始值为当前日期时间
    dateTime: Qt.formatDateTime(new Date(), "yyyy-MM-dd hh:mm:ss")

    // 日期格式
    dateFormat: "yyyy-MM-dd"
    // 时间格式
    timeFormat: "hh:mm:ss"

    // 最小可选日期时间
    minDateTime: "2023-01-01 00:00:00"
    // 最大可选日期时间
    maxDateTime: "2024-12-31 23:59:59"

    onConfirmed: function (datetime) {
        startDateField.text = Qt.formatDateTime(datetime, "yyyy-MM-dd HH:mm")
    }
}

显示效果:

后台用户登录界面

Canvas学习笔记(一)

什么是 Canvas

Canvas 是 HTML5 中新增的一种标签,表示一个画布,只是图形容器,绘制功能必须使用js来实现。

<!doctype html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>基础的HTML5页面</title> 
</head>
<body>
<canvas id="canvas">
            这里是canvas标签,当你的浏览器不支持canvas时,会显示这行文字
</canvas>
</body> 

</html>

打开后会是一个完全空白的画面,因为canvas本意就是一块画布,画布在html5中是透明的,不可见的。

image.png


绘制前的准备

获取 Canvas 对象:

const canvas = document.getElementById('canvas');

获取画笔

const context = canvas.getContext(contextType, contextAttributes?);
const context = canvas.getContext('2d');
const ctx = canvas.getContext('2d', {
  alpha: true,          // 启用透明通道(默认就是true)
});

getContext方法用于获取 Canvas 元素的绘图上下文,来对 Canvas 元素进行绘制操作,通常是 2D 类型,用于二维绘图。第一个参数是必须传的,第二个参数代表配置上下文的行为,不同的上下文的配置属性不同。

  • 2d
属性 说明 默认值
alpha 是否包含alpha通道(透明度) true
colorSpace 指定渲染上下文的色彩空间(srgbdisplay-p3 srgb
desynchronized 是否将画布绘制周期与事件循环解耦以减少延迟 false
willReadFrequently 是否频繁读取像素(用于优化getImageData()性能) false
  • webgl
属性 作用 说明
alpha 是否包含alpha通道 和2D一样
antialias 是否开启抗锯齿 让图形边缘更平滑
depth 是否包含深度缓冲区 用于3D场景的深度检测
failIfMajorPerformanceCaveat 性能低时是否创建上下文 true:性能差时失败
powerPreference GPU电源偏好 default/high-performance/low-power
premultipliedAlpha 是否预混合alpha 用于图像合成
preserveDrawingBuffer 是否保存缓冲区 true:可以多次读取
stencil 是否包含模版缓冲区 用于复杂遮罩效果

小结一下:准备工作就分为三步:

  1. 布置画布:通过添加 <canvas> 标签,添加canvas元素
  2. 获取画布:通过 <canvas> 标签的id,获得canvas对象
  3. 获得画笔:通过canvas对象的getContext("2d") 方法,获得2D环境

绘制线段:

在Canvas中,是基于状态的绘制,所以前面几步都是在确定状态,直到最后一步才会具体绘制。

绘画步骤和现实中画画差不多,可以分为四步:

1. 首先移动画笔至绘画的起始位置

context.moveTo(x, y)
// 将笔画移至 x, y 这个位置,canvas中是以画布的左上角为坐标原点,x轴的正方向向右,y轴的正方向向下

image.png

2. 确定第一笔的停止点

context.lineTo(x, y)
// 从上一个笔的停止点,移动至x,y这里

3. 规划好路线后,选择画笔(粗细,颜色,线条等)

因为 Canvas 是基于状态的,所以我们在选择画笔粗细和颜色的同时,其实也是选择了线条的粗细和颜色。

属性 说明 默认值 示例
context.lineWidth 线条粗细(宽度) ,单位是像素 1.0 ctx.lineWidth = 5;
context.strokeStyle 描边颜色/样式(可为颜色、渐变、图案) #000000(黑色) ctx.strokeStyle = "#AA394C";``ctx.strokeStyle = "red";``ctx.strokeStyle = gradient;
context.lineCap 线段末端样式 "butt" "butt"(平头) "round"(圆头) "square"(方头)
context.lineJoin 两条线相交处的连接样式 "miter" "miter"(尖角) "round"(圆角) "bevel"(斜角)
context.miterLimit 尖角(miter)的最大长度限制(防止过长尖角) 10 ctx.miterLimit = 5;

4. 进行绘制

确定绘制有两种方法:

context.fill() // 填充
context.stroke() // 描边

5. 画一个线条!

<body>
    <canvas id="myCanvas" style="border: 1px solid black;"></canvas>
    <script>
        const canvas = document.getElementById('myCanvas');
        const ctx = canvas.getContext('2d');
        
        ctx.moveTo(0, 0);
        ctx.lineTo(100, 100);

        ctx.lineWidth = 10;
        ctx.strokeStyle = 'red';

        ctx.stroke();
    </script>
</body>

image.png

一条红色的线条就画好了。但是我们现在只给 Canvas 设置了边框,那它的宽高是从哪来的呢?

这是浏览器默认给 Canvas 分配的尺寸 300x150 像素的画布。如果我们要自己设置 Canvas 宽高的话有两种方法

  1. 通过标签内部设置
 <canvas id="myCanvas" style="border: 1px solid black;" width = "500" height = "500"></canvas>
  1. 通过 JS 设置
const canvas = document.getElementById('myCanvas');

canvas.width = 500;
canvas.height = 500;

这两种方式设置的宽高是等效的

image.png这里有一个特别需要注意的点:我们设置的是画布的物理宽高,也就是 Canvas 元素的真实宽高,并不是通过 Css 设置的样式宽高,如果我们通过 Css 设置了 Canvas 的宽高,效果其实是在物理宽高的基础上进行了等比的缩小或者拉伸

<canvas id="myCanvas" 
    style="border: 1px solid black; width: 100px; height: 100px;" width="500" height="500">
</canvas>

image.png

线条组成图形

绘制折线

当我们学会绘制线条后,就可以用线条来组成图形了,方法其实很简单,就是复用上文用过的lineTo() 方法即可:

    ctx.moveTo(0, 0);
    ctx.lineTo(50, 100);
    ctx.lineTo(100, 0);

    ctx.lineWidth = 10;
    ctx.strokeStyle = 'red';

    ctx.stroke();

image.png

绘制多条折线

如果需要绘制多条不连接的线条,只需在绘制完一次后,再重新移动画笔,重新绘制一次即可

        ctx.moveTo(0, 0);
        ctx.lineTo(50, 100);
        ctx.lineTo(100, 0);
        ctx.lineWidth = 10;
        ctx.strokeStyle = 'red';
        ctx.stroke();

        ctx.moveTo(100, 0);
        ctx.lineTo(150, 100);
        ctx.lineTo(200, 0);
        ctx.lineWidth = 10;
        ctx.strokeStyle = 'blue';
        ctx.stroke(); 

image.png 诶?这是不是很奇怪,明明是先红色再蓝色,为什么就全变为蓝色了呢?是因为上文说过的 Canvas 是基于状态绘制的。我们每次使用stroke() 时,它都会把之前设置的状态再次绘制一遍,第一次stroke时会绘制一条红色折线,第二次stroke时,会再次重新绘制之前那条红色的折线,但是画笔已经变为蓝色的了,所以画出的折线全是蓝色的

为了解决这个问题,我们需要在每次重新绘制时加上beginPath(),这个 API 代表下次绘制的起始处为beginPath()之后的代码。

        ctx.moveTo(0, 0);
        ctx.lineTo(50, 100);
        ctx.lineTo(100, 0);
        ctx.lineWidth = 10;
        ctx.strokeStyle = 'red';
        ctx.stroke();
        
        ctx.beginPath(); // 重新定义起始位置
        ctx.moveTo(100, 0);
        ctx.lineTo(150, 100);
        ctx.lineTo(200, 0);
        ctx.lineWidth = 10;
        ctx.strokeStyle = 'blue';
        ctx.stroke(); 

image.png

绘制矩形

我们先使用绘制线条的方式绘制一个矩形

        // 绘制一个矩形
        ctx.beginPath();
        ctx.moveTo(50,50);
        ctx.lineTo(100,50);
        ctx.lineTo(100,100);
        ctx.lineTo(50,100);
        ctx.lineTo(50,50);

        ctx.lineWidth = 5;
        ctx.strokeStyle = "black";

        ctx.stroke();   

image.png 会发现此时最后一笔画时有一个小缺口,这种情况是因为我们设置了lineWidth导致的,如果是默认笔触为1的话是不会有问题的,但是如果笔触越大,线条越宽,这个小缺口就会越明显。此时需要使用closePath()闭合图形。

        // 绘制一个矩形
        ctx.beginPath();
        ctx.moveTo(50,50);
        ctx.lineTo(100,50);
        ctx.lineTo(100,100);
        ctx.lineTo(50,100);
        // ctx.lineTo(50,50); // 最后一笔可以不画
        ctx.closePath();

image.png

所以我们在绘制图形时需要使用beginPath()closePath()包裹起来。

给矩形上色

上文中有提过绘制的两种方法分别是stroke()fill(),我们需要使用fill()方法给矩形上色。和使用stroke前相同,我们需要先给它设置好属性。

        ctx.beginPath();
        ctx.moveTo(50,50);
        ctx.lineTo(100,50);
        ctx.lineTo(100,100);
        ctx.lineTo(50,100);
        ctx.closePath();

        ctx.lineWidth = 5;
        ctx.strokeStyle = "black";

        ctx.fillStyle = "red";

        ctx.fill();
        ctx.stroke();   

image.png

使用rect()方法绘制矩形

由于矩形是常用的图形,所以 Canvas API 封装好了一个定义矩形位置信息的方法,这个方法接受四个参数,分别表示矩形的起点坐标和宽高。

        ctx.rect(x,y,width,height);

        ctx.beginPath();
        ctx.rect(50,50,100,100);
        
        ctx.lineWidth = 5;
        ctx.strokeStyle = "black";
        ctx.fillStyle = "red";

        ctx.fill();
        ctx.stroke();

image.png

今天 Canvas 的学习就到这啦

CSS奇技淫巧:用你意想不到的4种属性实现裁剪遮罩效果

背景

在公司开发图片裁剪功能时,需要实现一个裁剪框效果:选中区域清晰显示,而周围区域用半透明遮罩覆盖。正常我实现会用4个div分别盖住上、下、左、右四个区域用于裁剪框周围的遮罩。

本文将介绍几种纯CSS实现的巧妙实现方案,主要内容有:

  1. box-shadow 阴影方法
  2. outline 轮廓法
  3. clip-path 裁剪方法
  4. background 渐变方法

展示的效果如下:

image.png

下面分别介绍下每种方式的实现。

1. box-shadow 方法

原理是利用 box-shadow 的扩散半径特性,设置一个超大的阴影覆盖整个画布。 优点是:代码量最少,只需一行CSS,实现简单。

.crop-box {
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
}

2. outline 方法

原理和box-shadow 类似,使用超大的 outline 实现遮罩。 outline(轮廓)与border类似,但不占用文档流空间,且不会影响元素尺寸。通过超大outline覆盖非裁剪区域,实现遮罩效果。

.crop-box {
  outline: 9999px solid rgba(0, 0, 0, 0.5);
}

3. 伪元素 + clip-path 方法

原理是使用 ::before 伪元素创建遮罩,通过 clip-path 的 polygon 裁剪出中间镂空区域。

  .wrapper::before {
      content: '';
      background: rgba(0, 0, 0, 0.5);
      clip-path: polygon(
          0 0, 0 100%, 50px 100%, 50px 50px,
          250px 50px, 250px 200px, 50px 200px,
          50px 100%, 100% 100%, 100% 0
      );
  }

4. background 渐变方法

原理是使用 linear-gradient 创建十字交叉的渐变遮罩。利用线性渐变(linear-gradient)的“颜色断层”特性,生成四宫格状的遮罩效果,非裁剪区域用半透明色覆盖,目标区域为透明。

.wrapper {
    position: absolute;
    inset: 0;
    background-image:
        /* 上方遮罩 */
        linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)),
        /* 下方遮罩 */
        linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)),
        /* 左侧遮罩 */
        linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5)),
        /* 右侧遮罩 */
        linear-gradient(rgba(0,0,0,0.5), rgba(0,0,0,0.5));
    background-size:
        100% 50px,      /* 上方 */
        100% calc(100% - 200px), /* 下方 */
        50px 150px,     /* 左侧 */
        calc(100% - 250px) 150px; /* 右侧 */
    background-position:
        0 0,            /* 上方 */
        0 200px,        /* 下方 */
        0 50px,         /* 左侧 */
        250px 50px;     /* 右侧 */
    background-repeat: no-repeat;
    pointer-events: none;
}

总结

最后总结一下:实现裁剪框遮罩效果有多种方案,在实际项目中,推荐使用 box-shadow 方法,简单高效。

🔥 99%由 Trae AI 开发的 React KeepAlive 组件,竟然如此优雅!✨

🔥 99%由 Trae AI 开发的 React KeepAlive 组件,竟然如此优雅!✨

温馨提示:除本文第一行提示之外,本项目的代码 99%由 Trae AI 开发,如您在使用过程中遇到任何问题,欢迎提交 Issue 或 Pull Request。

—— 项目 README

Hey 各位前端小伙伴们~ ଘ(੭ˊᵕˋ)੭ ੈ✩

今天要给大家介绍一个超级有趣的 React 组件——react-activity-keep-alive!这个项目可不简单呢,它有 99% 的代码都是由 Trae AI 生成的,是不是很厉害?~ (。♡‿♡。)

🎯 为啥要做这个组件?

用过 Vue 的小伙伴们都知道,Vue 有个超好用的 <KeepAlive> 组件,可以在路由切换时缓存页面状态。但是 React 一直缺少这样的优雅解决方案,对不对?٩(˘◡˘)۶

直到 React 19.2 引入了 Activity API,这个局面才有所改变。而我们这个项目,就是基于这个新 API 打造的 React 版 KeepAlive!

🌟 项目亮点一览

让我来给大家介绍这个项目的几大亮点吧~ (≧∇≦)/

1. 🚀 99% AI 开发,质量还这么高!

没错,你没看错!这个项目的源代码几乎全部由 Trae AI 生成。从架构设计到代码实现,从类型定义到注释文档,都是 AI 的杰作!是不是很震撼? Σ(っ °Д °;)っ

2. 📱 完整的 KeepAlive 语义支持

import { KeepAlive } from "react-activity-keep-alive";

<KeepAlive
  activeKey={pathname}
  include={[/^\/demo/]} // 只缓存 /demo 开头的路由
  exclude={[/^\/admin/]} // 不缓存 /admin 开头的路由
  max={3} // 最多缓存 3 LRU 策略
>
  <Outlet />
</KeepAlive>;

是不是很眼熟?没错,它完美复刻了 Vue KeepAlive 的 API 设计!☆*:.。. o(≧▽≦)o .。.:*☆

3. 🎪 丰富的演示场景

项目内置了一个超棒的 playground,包含各种演示:

🌐 在线演示:你可以直接访问 liujiayii.github.io/react-keep-… 体验所有功能!不需要本地搭建~ ✨

  • LRU 缓存演示:观察缓存淘汰策略
  • 生命周期演示:看看激活/失活钩子的效果
  • 嵌套缓存演示:多层 KeepAlive 的复杂场景
  • 状态保持演示:输入框数据、滚动位置都能完美保持

4. 🎛️ 强大的控制能力

import { useAliveController } from "react-activity-keep-alive";

function CacheController() {
  const { drop, dropScope, refresh, refreshScope, clear } = useAliveController();

  return (
    <div>
      <button onClick={() => drop("/demo/a")}>删除缓存</button>
      <button onClick={() => clear()}>清空所有</button>
      <button onClick={() => refresh("/demo/b")}>刷新缓存</button>
      {/* 等等... */}
    </div>
  );
}

想要主动控制缓存?没问题!随意删除、刷新、清空,统统支持~ ( ◕‿◕ )

🔧 技术实现揭秘

现在来聊聊技术实现吧~ ✧(≖ ◡ ≖)

历史背景:致敬 react-activation

在 React 19.2 之前,社区里有一个非常优秀的库叫 react-activation!它为 React 生态带来了 KeepAlive 的概念,让无数开发者受益~ (´∀`)

react-activation 的贡献

  • ✅ 开创了 React KeepAlive 的先河,满足了无数开发者的需求
  • ✅ 提供了完整的生命周期管理和状态保持功能
  • ✅ 虽然使用了快照技术,但在当时的技术条件下是个很好的解决方案
  • ✅ API 设计清晰,学习成本可控

由于它基于 React 18 的技术栈设计,自然无法适配 React 19.2 的新特性。但正是它的存在,让我们看到了 KeepAlive 的巨大价值!

当 React 19.2 带来了原生的 Activity API 时,我们有了更好的技术基础来重新实现这个功能~ ✨ 这就是站在巨人肩膀上的力量!٩(˘◡˘)۶

基于 React 19.2 Activity

这个组件的核心在于 React 19.2 的 Activity API:

<Activity active={isActive}>
  <div style={{ display: isActive ? undefined : "none" }}>
    {/* 组件内容 */}
  </div>
</Activity>;

Activity 的巧妙之处在于:失活的组件不会 unmount,只会隐藏!这就为 KeepAlive 提供了技术基础~ (。◕‿◕。)

LRU 缓存策略实现

// LRU 队列:最旧在前,最新在后
const orderRef = useRef<string[]>([]);

// 淘汰超出容量的缓存
while (cache.size > max) {
  const victim = orderRef.current[0];
  if (!victim || victim === activeKey)
    break;
  orderRef.current.shift();
  cacheRef.current.delete(victim);
}

LRU 算法确保最近最少使用的缓存会被优先淘汰,保持缓存的高效利用~ ଘ(੭ˊᵕˋ)੭* ੈ

作用域嵌套管理

作用域嵌套管理

项目内部实现了作用域管理机制来支持多层 <KeepAlive> 的嵌套:

// 内部实现(用户无需关心)
<AliveScopeProvider name={key}>
  <AliveItemProvider active={isActive}>
    {node}
  </AliveItemProvider>
</AliveScopeProvider>;

这个机制让项目能够优雅地处理复杂的嵌套缓存场景,自动管理不同层级的作用域~ ヾ(≧∇≦*)ゝ

🎮 实际使用效果

让我给大家展示一下实际的使用效果吧!

假设我们有这样一个计数器页面:

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

  return (
    <div>
      <h1>
        计数器:
        {count}
      </h1>
      <button onClick={() => setCount(count + 1)}>
        点我增加
      </button>
      <p>切换路由再回来,看看数据还在不在~ (◕‿◕)</p>
    </div>
  );
}

当你点击按钮增加到 10,然后切换到其他页面,再回来的时候:

数据完全保持!还是 10!没有重新渲染!

这就是 KeepAlive 的魅力所在~ (≧∇≦)/

🏗️ 项目结构一览

react-keep-alive/
├── src/                    # 核心库代码
│   ├── KeepAlive.tsx      # 主组件(244 行精华代码)
│   ├── hooks.ts           # 生命周期钩子
│   └── context.tsx        # 作用域管理
├── playground/            # 演示应用
│   ├── src/pages/         # 各种演示页面
│   └── src/App.tsx        # 主应用
└── README.md              # 超详细的文档

整个项目只有 4 个主要文件,却实现了完整的 KeepAlive 功能,是不是很精简? Σ(っ °Д °;)っ

🛠️ 开发体验

开发这个项目的感觉真的很棒~ (。♡‿♡。)

  • TypeScript 支持:完整的类型定义,IDE 友好
  • ESLint 配置:代码质量有保障
  • Husky + lint-staged:提交前自动检查
  • Playground 演示:边开发边验证

最关键的是,有了 Trae AI 的加持,开发效率真的太高了!几乎所有的代码逻辑、类型定义、注释文档,都是 AI 自动生成的!

🎊 总结

这个 react-activity-keep-alive 项目真的很让人兴奋呢~ ✨

  1. 技术先进:基于 React 19.2 Activity,性能优秀
  2. API 友好:完全对齐 Vue KeepAlive,学习成本低
  3. 功能完整:LRU 缓存、生命周期、控制接口一应俱全
  4. AI 加持:99% 代码由 AI 生成,开发效率惊人
  5. 演示丰富:内置 playground,所见即所得

如果你的项目正在使用 React 19.2+,真的推荐试试这个组件!让你的路由切换体验更上一层楼~ ( ◕‿◕ )

🔗 相关链接


小贴士:项目要求 React 19.2+ 版本哦,如果你的项目还在用旧版本,建议升级一下~ ଘ(੭ˊᵕˋ)੭ ੈ✩

好啦,今天的介绍就到这里!希望这个由 Trae AI 开发的 KeepAlive 组件能给大家带来帮助~ 如果觉得有用的话,记得给项目点个 Star 哦!☆*:.。. o(≧▽≦)o .。.:*☆

(◕‿◕)ノbye bye~

Uniapp如何下载图片到本地相册

uniapp如何下载图片到本地相册?

在一些特定的情况下面,我们需要在uniapp开发的小程序中将图片保存到本地相册,比如壁纸小程序其他小程序,这个使用情况还是十分普遍的,现在我来分享一下我的实现方法和思路

实现方案

文档地址: saveImageToPhotosAlbum:uniapp.dcloud.net.cn/api/media/i… getImageInfo: uniapp.dcloud.net.cn/api/media/i…

我们通过查找uniapp官方文档可以发现里面有一个uni.saveImageToPhotosAlbum({})保存图片到系统相册的API接口可以让我们使用,其中最关键的就是filePath参数,这个参数不支持网络地址就是我们服务器返回的图片地址不可以使用

所以我们需要使用另外一个API接口来帮助我们生成一个临时的filePath路径这个api就是uni.getImageInfo 使用这个api就可以将我们接口返回的网络地址生成一个临时的file地址,这样我就可以配合uni.saveImageToPhotosAlbum({})来实现图片保存到本地相册的功能了

实例代码:

uni.getImageInfo({
src: currentInfo.value.picurl,
success: (res) => {
uni.saveImageToPhotosAlbum({
filePath: res.path,
success: (fileRes) => {
console.log(fileRes, "图片");
}
})
}
})

需要注意的问题

我们在小程序实现下载功能的时候我们还需要到小程序的配置当中将downloadFile合法域名行一个配置不然是无法完成下载的,同时还需要在小程序后台配置中进行一个授权不然也是无法实现保存到微信相册的。

1.downloadFile合法域名配置步骤

进入到小程序配置信息页面 点击开发管理 同时在显示中的页面里面下滑到服务器域名位置 可以看见downloadFile合法域名的位置信息 点击右上角修改 然后将我们的downloadFile域名添加就可以了

image.png

image.png

2. 进行授权配置

进入小程序后台设置 鼠标移入到头像上 根据你的小程序信息进行一个填写和备案 后面找到服务内容声明用户隐私保护指引 进入到里面根据信息提示就可以完成下载图片并且保存到手机本地相册的功能了!

学习React-DnD:实现多任务项拖动-维护多任务项数组

一、功能承接与需求概述

上一篇文档中,我们已完成单个任务项的拖动排序功能。本次迭代的核心需求是实现多个任务项的批量选中,具体需达成以下目标:

  • 扩展状态管理,支持存储选中的任务项集合
  • 新增选中、取消选中任务项的操作逻辑
  • 在任务项组件中关联单选框与选中状态的交互

二、核心状态扩展(TodoProvider)

要实现多任务选中与拖拽,首先需要在全局状态中新增“选中任务集合”字段,并同步更新上下文供组件调用。

2.1 初始化状态修改

initialState中添加selectedTodos数组,用于存储当前选中的任务项,同时确保任务项模型的完整性:

const initialState = {
  todos: [
    { id: 1, text: 'Learn React', completed: false, selected: false },
    { id: 2, text: 'Build a Todo App', completed: false, selected: false },
    { id: 3, text: 'Build a Demo', completed: true, selected: false },
    { id: 4, text: 'Fix a Bug', completed: false, selected: true },

  ],
  // 当前选中的任务数组
  selectedTodos: [{ id: 4, text: 'Fix a Bug', completed: false, selected: true }],
};

2.2 上下文同步更新

将新增的selectedTodos状态注入上下文,确保子组件能获取到选中任务集合:

// TodoProvider内部的上下文值配置
const contextValue = {
  todos: state.todos,
  selectedTodos: state.selectedTodos,
  ...actions,
};

三、核心操作逻辑实现(Reducer)

基于需求新增两个核心操作类型:ADD_SELECT_TODOS(添加选中任务)、REMOVE_SELECT_TODOS(移除选中任务),以下是完整实现逻辑。

3.1 操作类型常量定义(建议单独维护)

// ActionTypes.js
export const ActionTypes = {
  // 原有操作类型...
  ADD_SELECT_TODOS: 'ADD_SELECT_TODOS',
  REMOVE_SELECT_TODOS: 'REMOVE_SELECT_TODOS',
};

3.2 添加选中任务(ADD_SELECT_TODOS)

通过任务ID查找目标任务,确保任务存在且未被选中后,添加到selectedTodos集合中,避免重复选中:

// ADD_SELECT_TODOS
case ActionTypes.ADD_SELECT_TODOS:
  {
    // 找到要添加的todo对象
    const todoToAdd = state.todos.find(todo => todo.id === action.payload.id);
    // 确保todo存在且尚未被选中(避免重复添加)
    if (todoToAdd && !state.selectedTodos.some(todo => todo.id === todoToAdd.id)) {
      return {
        ...state,
        selectedTodos: [...state.selectedTodos, todoToAdd],
      };
    }
    return state;
  }

3.3 移除选中任务(REMOVE_SELECT_TODOS)

根据任务ID从selectedTodos集合中过滤掉目标任务:

// REMOVE_SELECT_TODOS
case ActionTypes.REMOVE_SELECT_TODOS:
  return {
    ...state,
    selectedTodos: state.selectedTodos.filter(todo => todo.id !== action.payload.id),
  };

四、任务项组件交互(TodoItem)

在TodoItem组件中,通过Ref绑定单选框,并在单选框状态变化时,触发选中/取消选中的操作,实现视图与状态的同步。

4.1 组件核心逻辑

import { useContext, useRef } from 'react';
import useTodoContext from '@/context/TodoContext/useTodoContext';

const TodoItem = ({ todo }) => {
  const { addSelectTodos, removeSelectTodos } = useTodoContext();
  // 用Ref绑定单选框元素,便于后续操作(如主动获取状态)
  const checkboxRef = useRef(null);

  // 单选框状态变化处理函数
  const handleCheckboxChange = (e) => {
    toggleSelected(todo.id);
    if (e.target.checked) {
      // 选中:调用添加选中任务的action
      addSelectTodos(todo.id);
    } else {
      // 取消选中:调用移除选中任务的action
      removeSelectTodos(todo.id);
    }
  };

  return (
    <div className={`todo-item${isDragging ? ' isDragging' : ''}`} ref={divRef}>
      <div className="todo-item-content">
        <input
          type="checkbox"
          id={`todo-${todo.id}`}
          checked={todo.selected}
          onChange={handleCheckboxChange}
          className="todo-checkbox"
          ref={checkboxRef}
        />
      </div>
      {/* 原有单个任务拖拽相关逻辑 */}
    </div>
  );
};

export default TodoItem;

注意:仅保留必需内容,不包含TodoItem组件的所有内容。

4.2 关键交互说明

  • Ref绑定:通过checkboxRef可在需要时主动控制单选框(如全选/取消全选功能),提升组件灵活性。
  • 状态双向绑定:单选框的checked属性直接绑定任务项的selected状态,确保视图与全局状态一致。
  • 操作触发:状态变化时通过上下文获取的addSelectTodosremoveSelectTodos方法更新全局状态,实现跨组件状态同步。

五、配套Action函数实现

为了让组件更便捷地调用上述操作,需在TodoProvider中定义对应的action函数,并注入上下文:

const actions = {
  // 原有action...
  
    // 添加选中任务
    addSelectTodos: (id) => {
      dispatch({
        type: ActionTypes.ADD_SELECT_TODOS,
        payload: { id },
      });
    },

    // 移除选中任务
    removeSelectTodos: (id) => {
      dispatch({
        type: ActionTypes.REMOVE_SELECT_TODOS,
        payload: { id },
      });
    },
    
};

六、测试要点

  1. 单个任务选中/取消:勾选单选框后,selectedTodos应同步增减,任务项selected状态正确。
  2. 多个任务选中:选中多个任务后,selectedTodos应包含所有选中项,无重复数据。

js深入之从原型到原型链

构造函数创建对象

function A {
}
let a=new A();
a.name="abc";
console.log(a.name);

在这个例子中A就是一个构造函数,我们使用new创建了一个实例对象a

prototype

每个函数都有一个prototype属性

function A {
}
A.prototype.name="张三"
let a1=new A();
let a2=new A();
console.log(a1.name,a2.name)

prototype指向的是调用该函数创建的实例的原型,也就是例子中a1,a2的原型。 每一个对象(null除外)创建的时候,都会关联另外一个对象,这个对象就是原型,每个对象都会从原型“继承”属性

3bdfe951-d6de-4e73-ae8b-8db05e5d54bf.jpeg

_proto_

每个对象(除了null)都有一个__proto__属性,这个属性指向该对象的原型

function A {
}
let a=new A();
console.log(a.__proto__===A.prototype)

a8d6b4f1-e328-44f6-8c7b-421bcfe8ac22.jpeg

constructor

每个原型都有一个constructor属性指向关联的构造函数

function A {
}
console.log(A.prototype.constructor===A)

4e507f00-af31-4942-bf0a-ffc0c89cd49c.jpeg

function A {
}
let a=new A();
console.log(A.prototype.constructor===A)
console.log(a.__proto__===A.prototype)
console.log(a.contructor===A.prototype.constructor)
// 顺便学习一个ES5的方法,可以获得对象的原型
console.log(Object.getPrototypeOf(a) === A.prototype)

当读取实例属性时,如果找不到就会从与对象关联的原型上查找,如果还查不到就会查找原型的原型,一直找到最顶层为止。

function A {
}
A.prototype.name="test";
let a=new A();
a.name="aaa";
console.log(a.name);//aaa
delete a.name;
console.log(a.name);//test

原型的原型,其实是有Object构造函数生成

a15399d7-b5ff-4fbc-afa3-9026ca9cddb7.jpeg

原型链

红色部分就代表了原型链的形成

dd4ff7f1-95d8-4007-9c1e-551d3ea83545.jpeg

element-ui:el-autocomplete实现滚动触底翻页

需求描述

element-ui的 el-autocomplete 组件支持远程搜索输入建议,不过不能翻页,只能搜索到首页内容

要求实现下滑到底部的时候,触发翻页请求,查看更多结果

实现思路

想办法监听到滚动事件,就能判断是否到达底部,然后进行page++ 搜索

通过自定义指令v-autocomplete-scroll 来监听滚动事件,需要注意事件的监听与移除监听

<template>
  <el-autocomplete
    popper-class="mo-autocomplete"
    v-model="keyword"
    style="width: 300px"
    :fetch-suggestions="querySearch"
    :debounce="800"
    ref="autocomplete"
    v-autocomplete-scroll="handleScroll"
    placeholder="搜索"
    @blur="handleBlur"
  >
  </el-autocomplete>
</template>

<script>
import apiData from './data.json'

export default {
  name: '',

  props: {},

  computed: {},

  data() {
    return {
      page: 1,
      size: 20,
      keyword: '',
    }
  },

  directives: {
    'autocomplete-scroll': {
      bind(el, binding, vnode) {
        console.log('bind')

        // 此处为了简单,直接判断触底了
        function handleScroll(e) {
          let isBottom =
            e.target.clientHeight + e.target.scrollTop == e.target.scrollHeight
          if (isBottom && !vnode.context.loading) {
            binding.value()
          }
        }

        // 监听滚动
        let wrapDom = el.querySelector('.el-autocomplete-suggestion__wrap')
        el.__handleScroll__ = handleScroll
        el.__wrapDom__ = wrapDom
        wrapDom.addEventListener('scroll', handleScroll, false)
      },

      unbind(el, binding, vnode) {
        console.log('unbind')
        // 解除事件监听
        el.__wrapDom__.removeEventListener('scroll', el.__handleScroll__, false)
      },
    },
  },

  methods: {
    // 分页搜索
    async search(keywords) {
      console.log('page', this.page)
      let start = (this.page - 1) * this.size
      let end = start + this.size
      return apiData.slice(start, end)
    },

    // 焦点触发搜索第一页
    async querySearch(queryString, cb) {
      this.page = 1
      let list = await this.search(queryString)

      // 调用 callback 返回建议列表的数据
      cb(list)
    },

    // 滚动触发翻页
    async handleScroll() {
      console.log('handleScroll')

      // 限制翻页限度
      if (this.page > 20) {
        return
      }

      this.page++

      let list = await this.search(this.keywords)

      this.$refs['autocomplete'].$data.suggestions.push(...list)
    },

    handleBlur() {
      console.log('handleBlur')
      // 避免上次数据影响
      this.$refs['autocomplete'].$data.suggestions.splice(
        0,
        this.$refs['autocomplete'].$data.suggestions.length
      )
    },
  },

  created() {},
}
</script>

<style lang="less"></style>



测试数据 data.json

[  { "value": "三全鲜食(北新泾店)", "address": "长宁区新渔路144号" },  {    "value": "Hot honey 首尔炸鸡(仙霞路)",    "address": "上海市长宁区淞虹路661号"  },  {    "value": "新旺角茶餐厅",    "address": "上海市普陀区真北路988号创邑金沙谷6号楼113"  },  { "value": "泷千家(天山西路店)", "address": "天山西路438号" },  {    "value": "胖仙女纸杯蛋糕(上海凌空店)",    "address": "上海市长宁区金钟路968号1幢18号楼一层商铺18-101"  },  { "value": "贡茶", "address": "上海市长宁区金钟路633号" },  {    "value": "豪大大香鸡排超级奶爸",    "address": "上海市嘉定区曹安公路曹安路1685号"  },  { "value": "茶芝兰(奶茶,手抓饼)", "address": "上海市普陀区同普路1435号" },  { "value": "十二泷町", "address": "上海市北翟路1444弄81号B幢-107" },  { "value": "星移浓缩咖啡", "address": "上海市嘉定区新郁路817号" },  { "value": "阿姨奶茶/豪大大", "address": "嘉定区曹安路1611号" },  { "value": "新麦甜四季甜品炸鸡", "address": "嘉定区曹安公路2383弄55号" },  {    "value": "Monica摩托主题咖啡店",    "address": "嘉定区江桥镇曹安公路2409号1F,2383弄62号1F"  },  {    "value": "浮生若茶(凌空soho店)",    "address": "上海长宁区金钟路968号9号楼地下一层"  },  { "value": "NONO JUICE  鲜榨果汁", "address": "上海市长宁区天山西路119号" },  { "value": "CoCo都可(北新泾店)", "address": "上海市长宁区仙霞西路" },  {    "value": "快乐柠檬(神州智慧店)",    "address": "上海市长宁区天山西路567号1层R117号店铺"  },  {    "value": "Merci Paul cafe",    "address": "上海市普陀区光复西路丹巴路28弄6号楼819"  },  {    "value": "猫山王(西郊百联店)",    "address": "上海市长宁区仙霞西路88号第一层G05-F01-1-306"  },  { "value": "枪会山", "address": "上海市普陀区棕榈路" },  { "value": "纵食", "address": "元丰天山花园(东门) 双流路267号" },  { "value": "钱记", "address": "上海市长宁区天山西路" },  { "value": "壹杯加", "address": "上海市长宁区通协路" },  {    "value": "唦哇嘀咖",    "address": "上海市长宁区新泾镇金钟路999号2幢(B幢)第01层第1-02A单元"  },  { "value": "爱茜茜里(西郊百联)", "address": "长宁区仙霞西路88号1305室" },  {    "value": "爱茜茜里(近铁广场)",    "address": "上海市普陀区真北路818号近铁城市广场北区地下二楼N-B2-O2-C商铺"  },  {    "value": "鲜果榨汁(金沙江路和美广店)",    "address": "普陀区金沙江路2239号金沙和美广场B1-10-6"  },  { "value": "开心丽果(缤谷店)", "address": "上海市长宁区威宁路天山路341号" },  { "value": "超级鸡车(丰庄路店)", "address": "上海市嘉定区丰庄路240号" },  { "value": "妙生活果园(北新泾店)", "address": "长宁区新渔路144号" },  { "value": "香宜度麻辣香锅", "address": "长宁区淞虹路148号" },  { "value": "凡仔汉堡(老真北路店)", "address": "上海市普陀区老真北路160号" },  { "value": "港式小铺", "address": "上海市长宁区金钟路968号15楼15-105室" },  { "value": "蜀香源麻辣香锅(剑河路店)", "address": "剑河路443-1" },  { "value": "北京饺子馆", "address": "长宁区北新泾街道天山西路490-1号" },  {    "value": "饭典*新简餐(凌空SOHO店)",    "address": "上海市长宁区金钟路968号9号楼地下一层9-83室"  },  {    "value": "焦耳·川式快餐(金钟路店)",    "address": "上海市金钟路633号地下一层甲部"  },  { "value": "动力鸡车", "address": "长宁区仙霞西路299弄3号101B" },  { "value": "浏阳蒸菜", "address": "天山西路430号" },  { "value": "四海游龙(天山西路店)", "address": "上海市长宁区天山西路" },  {    "value": "樱花食堂(凌空店)",    "address": "上海市长宁区金钟路968号15楼15-105室"  },  { "value": "壹分米客家传统调制米粉(天山店)", "address": "天山西路428号" },  {    "value": "福荣祥烧腊(平溪路店)",    "address": "上海市长宁区协和路福泉路255弄57-73号"  },  {    "value": "速记黄焖鸡米饭",    "address": "上海市长宁区北新泾街道金钟路180号1层01号摊位"  },  { "value": "红辣椒麻辣烫", "address": "上海市长宁区天山西路492号" },  { "value": "(小杨生煎)西郊百联餐厅", "address": "长宁区仙霞西路88号百联2楼" },  { "value": "阳阳麻辣烫", "address": "天山西路389号" },  {    "value": "南拳妈妈龙虾盖浇饭",    "address": "普陀区金沙江路1699号鑫乐惠美食广场A13"  }]


参考
el-autocomplete 滚动到底部加载数据
www.runoob.com/jsref/met-e…
vue 解除鼠标的监听事件的方法

Vue3 响应式原理:从零实现 Reactive

前言

还记得第一次使用 Vue 时的那种惊艳吗?数据变了,视图自动更新,就像魔法一样!但作为一名有追求的前端开发者,我们不能只停留在"会用"的层面,更要深入理解背后的原理。

今天,我将带你从零实现一个 Vue3 的响应式系统,手写代码不到 200 行,却能覆盖核心原理。读完本文,你将彻底明白:

  • 🤔 为什么 Vue3 放弃 Object.defineProperty 选择 Proxy?
  • 🔥 依赖收集和触发更新的精妙设计
  • 🎯 数组方法的重写背后隐藏的智慧
  • 💡 Vue3 响应式相比 Vue2 的性能优势

什么是响应式?

简单来说,响应式是当数据变化时,自动执行依赖数据的代码

const state = reactive({ count: 0 });
effect(() => {
  console.log(`count值变化:${state.count}`);
});
state.count++; // count值变化:1
state.count++; // count值变化:2

vue2和vue3响应式区别

特性 vue2(Object.defineProperty) vue3(proxy)
对象新增属性 $set api实现响应式 直接支持
对象删除属性 $delete api 实现响应式 直接支持
数组拦截 改写数组原型方法 原生支持,重新包装
性能 递归遍历所有属性 懒代理,访问时才代理

综上所述Proxy的优势非常的明显,这就是Vue3选择重构响应式系统的根本原因。

手写实现:从零构建响应式

1. 项目结构

├── reactive.js           // reactive 核心
├── effect.js             // 副作用管理
├── baseHandler.js        // Proxy 处理器
├── arrayInstrumentations.js // 数组方法重写
├── utils.js              // 工具函数
└── index.js              // 入口文件

响应式入口

我们先从reactive函数着手,使用过vue3应该对reactive并不陌生。此函数接收一个对象,然后返回一个代理对象。

// reactive.js
export function reactive(target) {
  // 判断target是否一个对象
  if (!isObject(target)) {
    return target;
  }
  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      // 后续收集依赖
    },
    set(target, key, value, receiver) {
      const oldValue = target[key];
      if (oldValue != value) {
        // 后续触发更新
      }
    }
  })
  return proxy;
}

目前已经搭建了reactive函数的框架,但是目前还有些问题:

  1. 同一个对象代理多次,会返回不同的代理对象,这样性能上带来不必要的开销。
const originalObj = { name: 'Vue', version: 3 }; // 第一次调用 reactive 
const proxy1 = reactive(originalObj); // 第二次调用 reactive(传入同一个对象)
const proxy2 = reactive(originalObj); // 验证两个代理是同一个实例
console.log(proxy1 === proxy2); // false

可以通过缓存代理对象解决此类问题,采用WeakMap来缓存代理对象,keytarget,value为代理对象。

// 缓存代理对象,避免重复代理
const reactiveMap = new WeakMap();
export function reactive(target) {
  // 判断target是否一个对象
  if (!isObject(target)) {
    return target;
  }
  // 将target是否已经代理过,如果代理则返回缓存的代理对象。
  const existsProxy = reactiveMap.get(target);
  if (existsProxy) {
    return existsProxy;
  }
  const proxy = new Proxy(target, {
    /* 此处暂时省略 */
  })
  // 缓存代理对象
  reactiveMap.set(target, proxy);
  return proxy;
}

💡 提示
在上述代码中之所以采用WeakMap主要考虑key是一个对象并且WeakMap可以当target不再引用时会自动清理。

  1. 当已经被reactive处理后,再次调用reactive时,又被代理。
const originalObj = { count: 1 }; // 第一次创建响应式对象 
const proxy1 = reactive(originalObj);
const proxy2 = reactive(proxy1); // 将代理对象再次代理

Vue3的源码中通过__v_isReactive标记来判断:

export const ReactiveFlags = {
  IS_REACTIVE: "__v_isReactive",
};
export function reactive(target) {
  // 判断target是否一个对象
  if (!isObject(target)) {
    return target;
  }
  // 避免重复代理
  if (target[ReactiveFlags.IS_REACTIVE]) {
    return target;
  }
  // 将target是否已经代理过,如果代理则返回缓存的代理对象。
  const existsProxy = reactiveMap.get(target);
  if (existsProxy) {
    return existsProxy;
  }
  const proxy = new Proxy(target, {
    get(target, key, receiver) {
      if (key === ReactiveFlags.IS_REACTIVE) {
        return true;
      }
    },
    /* 此处暂时省略 */
  })
  // 缓存代理对象
  reactiveProxy.set(target, proxy);
  return proxy;
}
  • 当第一次调用reactive时,检查target中是否已经存在__v_isReactive标记,正常情况下是undefined,返回一个Proxy代理对象。
  • 如果将返回的Proxy代理对象,再次调用reactive函数,再次检查__v_isReactive是否存在,将会进入Proxy代理对象的get方法中,进入判断返回true。从而达到无论将相同代理对象调用多少次reactive都不会产生多层代理对象嵌套。

Vue3getset包裹的对象是抽离到一个单独的文件baseHandlers中的,我们也进行相同调整:

// baseHandlers.js
import { ReactiveFlags } from "./reactive"; 
export const mutableHandlers = {
  get(target, key, receiver) {
    // 1. 响应式标识判断(Vue3 源码标准逻辑)
    if (key === ReactiveFlags.IS_REACTIVE) {
      return true;
    }
    /* 后续实现依赖收集 */
  },

  set(target, key, value, receiver) {
    const oldValue = target[key];
    if (oldValue !== value) {
     // 后续触发更新
    }
  },
};
// reactive.js
import { mutableHandlers } from "./baseHandler.js";
export const ReactiveFlags = {
  IS_REACTIVE: "__v_isReactive",
};
export function reactive(target) {
  // 判断target是否一个对象
  if (!isObject(target)) {
    return target;
  }
  // 避免重复代理
  if (target[ReactiveFlags.IS_REACTIVE]) {
    return target;
  }
  // 将target是否已经代理过,如果代理则返回缓存的代理对象。
  const existsProxy = reactiveMap.get(target);
  if (existsProxy) {
    return existsProxy;
  }
  const proxy = new Proxy(target, mutableHandlers)
  // 缓存代理对象
  reactiveProxy.set(target, proxy);
  return proxy;
}

副作用管理

Vue3中提供了一个effect函数,接收一个函数,提供给用户获取数据渲染视图,数据变化后再次调用该函数更新视图。effect具体实现如下:

// 当前响应器
export let activeEffect;
// 清理依赖
export function cleanupEffect(effect) {
  effect.deps.forEach((dep) => {
    dep.delete(effect);
  });
  effect.deps.length = 0;
}
class ReactiveEffect {
  active = true; // 是否激活状态
  deps = []; // 依赖集合数组
  parent = undefined; // 父级effect 处理嵌套effect
  constructor(fn, scheduler) {
    this.fn = fn; // 用户提供的函数
    this.scheduler = scheduler // 调度器(用于computed、watch)
  }
  run() {
    if (!this.active) {
      return this.fn();
    }
    try {
      // 建立effect的父子关系 确保依赖收集的准确性
      this.parent = activeEffect;
      activeEffect = this;
      // 清除旧依赖 避免不必要的更新
      cleanupEffect(this);
      return this.fn();
    } finally {
      // 恢复父级effect
      activeEffect = this.parent;
      this.parent = undefined;
    }
  }
}
export function effect(fn, options = {}) {
  const e = new ReactiveEffect(fn, options.scheduler);
  e.run();
  // 给到用户自行控制响应
  const runner = e.run.bind(e); // 确保this的指向
  runner.effect = e;
  return runner;
}
// 收集依赖函数
export function track(target, key) {}
// 触发依赖
export function trigger(target, key) {}

实现收集依赖

const state = reactive({ name: 'jim '});
effect(() => {
  document.getElementById('app').innerHTML = `${state.name}`;
})

当调用effect函数时,将会执行用户提供的函数逻辑,如上述代码执行state.name时将会进入代理对象的get方法,该方法中进行依赖收集。即调用track函数。

// baseHandler.js
import { isObject } from "./utils";
import { ReactiveFlags, reactive } from "./reactive";
import { track } from "./effect";
export const mutableHandlers = {
  get(target, key, receiver) {
    // 响应式标识判断(Vue3 源码标准逻辑)
    if (key === ReactiveFlags.IS_REACTIVE) {
      return true;
    }
    // 收集依赖(所有属性访问都需要追踪)
    track(target, key);
    // 执行原生 get 操作 
    const result = Reflect.get(target, key, receiver);
    // 深层响应式:嵌套对象/数组自动转为响应式(Vue3 懒代理特性)
    if (result && isObject(result)) {
      return reactive(result);
    }

    return result;
  },
  /* set方法在此省略 */
};

effect.jstrack函数中实现依赖收集

// 当前响应器
export let activeEffect;
export const targetMap = new WeakMap(); //  收集依赖
export function track(target, key) {
  if (!activeEffect) return;
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set())); // Vue3内部是一个Dep类
  }
  trackEffects(dep);
}
export function trackEffects(dep) {
  let shouldTrack = !dep.has(activeEffect);
  if (shouldTrack) {
    dep.add(activeEffect);
    activeEffect.deps.push(dep); // 双向记录
  }
}

收集完毕后的依赖关系结构:

WeakMap {
  target1: Map {
    key1: Set[effect1, effect2],
    key2: Set[effect3]
  },
  target2: Map { ... }
}

实现触发依赖

当用户对数据进行了修改时,需要根据收集的依赖自动对应执行effect的用户函数。

state.name = 'tom'

baseHandle.js中调用trigger函数。该函数实现具体的触发依赖

// baseHandle.js
import { isObject } from "./utils";
import { ReactiveFlags, reactive } from "./reactive";
import { track, trigger } from "./effect";
export const mutableHandlers = {
  /* get方法实现省略 */
  set(target, key, value, receiver) {
    const oldValue = target[key];
    const success = Reflect.set(target, key, value, receiver);
    // 7. 只有值变化且是自身属性时,才触发更新(避免原型链干扰)
    if (success && oldValue !== value) {
      trigger(target, key); // 触发依赖
    }
    return success;
  },
};

effect.js中实现trigger函数的实现

// 触发依赖
export function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);
  if (dep) triggerEffects(dep);
}
export function triggerEffects(dep) {
  const effects = [...dep]; // 避免在遍历 Set 过程中修改 Set 本身导致的迭代器异常问题
  effects.forEach((effect) => {
    // 避免无限递归:当前正在执行的effect不再次触发
    if (effect != activeEffect) {
      if (!effect.scheduler) {
        effect.run();
      } else {
        effect.scheduler();
      }
    }
  });
}

对数组响应式处理

Vue3源码中单独一个文件arrayInstrumentations对数组的方法重新包装了一下。我的处理与源码有点不同毕竟是简易版本,但是原理都是一样的

// arrayInstrumentations.js
import { reactive } from "./reactive";
import { trigger } from "./effect";
import { isArray } from "./utils";

// 需要特殊处理的数组修改方法(Vue3 源码中也是用 Set 存储)
export const arrayInstrumentations = new Set([
  "push",
  "pop",
  "shift",
  "unshift",
  "splice",
  "sort",
  "reverse",
]);

/**
 * 包装数组修改方法,添加响应式能力
 * @param {string} method - 数组方法名
 * @returns 包装后的函数
 */
function createArrayMethod(method) {
  // 获取原生数组方法
  const originalMethod = Array.prototype[method];

  return function (...args) {
    // 1. 执行原生数组方法(保证原有功能不变)
    const result = originalMethod.apply(this, args);

    // 2. 处理新增元素的响应式转换(push/unshift/splice 可能添加新元素)
    let inserted;
    switch (method) {
      case "push":
      case "unshift":
        inserted = args; // 这两个方法的参数就是新增元素
        break;
      case "splice":
        inserted = args.slice(2); // splice 第三个参数及以后是新增元素
        break;
    }
    // 新增元素转为响应式(递归处理对象/数组)
    if (inserted) {
      inserted.forEach((item) => {
        if (typeof item === "object" && item !== null) {
          reactive(item);
        }
      });
    }

    // 3. 触发依赖更新(Vue3 源码中会触发 length 和对应索引的更新)
    trigger(this, "length");
    return result;
  };
}

// 生成所有包装后的数组方法(键:方法名,值:包装函数)
export const arrayMethods = Object.create(null);
arrayInstrumentations.forEach((method) => {
  arrayMethods[method] = createArrayMethod(method);
});

/**
 * 判断是否是需要拦截的数组方法
 * @param {unknown} target - 目标对象
 * @param {string} key - 属性名/方法名
 * @returns boolean
 */
export function isArrayInstrumentation(target, key) {
  return isArray(target) && arrayInstrumentations.has(key);
}

然后在baseHandler中添加数组情况下的逻辑

// baseHandler.js
import { isObject } from "./utils";
import { ReactiveFlags, reactive } from "./reactive";
import { track, trigger } from "./effect";
// 引入抽离的数组工具
import { isArrayInstrumentation, arrayMethods } from "./arrayInstrumentations";

export const mutableHandlers = {
  get(target, key, receiver) {
    // 1. 响应式标识判断(Vue3 源码标准逻辑)
    if (key === ReactiveFlags.IS_REACTIVE) {
      return true;
    }
    // 2. 收集依赖(所有属性访问都需要追踪)
    track(target, key);
    // 3. 执行原生 get 操作
    const result = Reflect.get(target, key, receiver);
    // 4. 数组方法拦截:如果是需要处理的数组方法,返回包装后的函数
    if (isArrayInstrumentation(target, key)) {
      // 绑定 this 为目标数组,确保原生方法执行时上下文正确
      return arrayMethods[key].bind(target);
    }
    // 5. 深层响应式:嵌套对象/数组自动转为响应式(Vue3 懒代理特性)
    if (result && isObject(result)) {
      return reactive(result);
    }

    return result;
  },

  set(target, key, value, receiver) {
    const oldValue = target[key];
    const isArrayTarget = Array.isArray(target);
    // 6. 执行原生 set 操作
    const success = Reflect.set(target, key, value, receiver);
    // 7. 只有值变化且是自身属性时,才触发更新(避免原型链干扰)
    if (success && oldValue !== value) {
      // 数组索引设置:触发对应索引和 length 更新(Vue3 源码逻辑)
      if (isArrayTarget && key !== "length") {
        const index = Number(key);
        if (index >= 0 && index < target.length) {
          trigger(target, key); // 触发索引更新
          trigger(target, "length"); // 触发长度更新
          return success;
        }
      }
      // 普通对象/数组 length 设置:触发对应 key 更新
      trigger(target, key);
    }
    return success;
  },
};

完整代码使用示例

import { reactive, effect } from "./packages/index";
const state = reactive({
  name: "vue",
  version: "3.4.5",
  author: "vue team",
  friends: ["jake", "james"],
});
effect(() => {
  app.innerHTML = `
    <div> Welcome ${state.name} !</div>
    <div> ${state.friends} </div>
  `;
});
setTimeout(() => {
  state.name = "vue3"; 
  state.friends.push("jimmy");
}, 1000);
// 一开始显示:
//    Welcome vue 
//    'jake,james'
// 1秒钟后:
//    Welcome vue3
//    'jake,james,jimmy'

总结

通过这 200 行代码,我们实现了一个完整的 Vue3 响应式系统核心:

  • 响应式代理: 基于 Proxy 的懒代理机制
  • 依赖收集: 精准的 effect 追踪
  • 批量更新: 避免重复执行的调度机制
  • 数组处理: 重写数组方法保持响应性
  • 嵌套支持: 自动的深层响应式转换

完整代码和资源 本文所有代码已开源,包含详细注释和测试用例:

GitHub 仓库:github.com/gardenia83/…

这为我们理解 Vue3 的响应式原理提供了坚实的基础,也为学习更高级的特性如 computed、watch 等打下了基础。

React Native 自定义字体导致 Text / TextInput 文本垂直不居中的终极解决方案


📝 React Native 自定义字体导致 Text / TextInput 文本垂直不居中的终极解决方案

适用
✔ React Native(0.71+)
✔ Expo(所有版本)
✔ iOS + Android
✔ 自定义字体(TTF / OTF)


📌 一、问题背景

当项目使用自定义字体(从设计师那拿的 TTF/OTF)时,Text 和 TextInput 常会出现这些问题:

  • 文字偏上 / 偏下
  • placeholder 和输入的文字不对齐
  • lineHeight 设置了但不生效
  • iOS 看似正常,Android 完全偏
  • 同一个字体在不同组件间高度不一致

这些问题在系统字体下不会出现,但是换成自定义字体后几乎必现。


📌 二、为什么会发生?——关键因素是 字体度量(Font Metrics)

每个字体内部都带有一套关键数据:

  • ascent:字体向上的高度
  • descent:字体向下的高度
  • lineGap:字体推荐的行间距
  • unitsPerEm:字体的“单位体系”,决定比例
  • baseline:文字真实摆放的位置

React Native 的 Text / TextInput 会根据这些 metrics 来排版文字。

而自定义字体常常存在:

  • ascent 特别高
  • descent 太大
  • lineGap > 0(设计师字体常见)
  • unitsPerEm 不是系统常用的 1000 / 2048
  • baseline 偏移

🔍 结果:文字在组件内部的位置发生偏移

为什么系统字体没问题?
因为系统字体(San Francisco / Roboto)对移动端做过特殊优化,而自定义字体没有。


📌 三、平台差异(非常关键)

iOS:比较宽容

  • 会自动平滑 baseline
  • 会人为修正部分 ascent/descent
  • 所以问题不明显

Android:非常严格

  • 绝对使用字体真实 fontMetrics
  • lineHeight 必须由 dev 明确指定
  • 不自动居中 TextInput 文字

👉 所以大部分“文字偏上”的问题都出现在 Android。


📌 四、终极解决方案(按效果排序)


方案 1:为自定义字体设置合理的 lineHeight(最推荐)

<TextInput
  style={{
    fontFamily: "YourFont",
    fontSize: 16,
    lineHeight: 20, // 常用: fontSize * 1.25
  }}
/>

经验公式:

iOS:

lineHeight = fontSize * 1.2

Android:

lineHeight = fontSize * 1.3

👉 这是能解决 90% 自定义字体问题的方案。


方案 2:给 TextInput 外面套一层 View 并控制垂直居中

因为 TextInput 自己不擅长对齐 —— 尤其是 Android。

<View style={{ height: 48, justifyContent: "center" }}>
  <TextInput
    style={{
      fontFamily: "YourFont",
      fontSize: 16,
      lineHeight: 20,
      padding: 0,
      textAlignVertical: "center", // Android 必须加
    }}
  />
</View>

⚠️ 关键点:

  • padding 必须设为 0(否则 Android 会额外加)
  • textAlignVertical=center(Android 不加就会偏)

方案 3:统一 placeholder 与文字行高

React Native 的 placeholder 不跟随文字行高(尤其 Android)。

强制同步:

placeholderTextColor="#888"

并保持同样高度的 wrapper:

<View style={{ height: 48, justifyContent: "center" }}>
  <TextInput
    // same styles as above
  />
</View>

🔧 方案 4(重量但最彻底):修复字体文件的 metrics

如果一个字体怎么调都不对齐,那它就是 本身度量有问题

可以用字体编辑器修:

  • FontForge(免费)
  • Glyphs(macOS)
  • FontTools(CLI)

需要修的字段:

  1. ascent → 调整到合理比例
  2. descent → 调整到合理比例
  3. lineGap → 设为 0(移动端最佳实践)
  4. unitsPerEm → 1000 或 2048(常用规范)

导出后 RN 的 Text/TextInput 高度立即正常。


📌 五、最佳实践组件(可直接复制到项目)

import { Platform, View, TextInput } from "react-native";

export function Input(props) {
  const lineHeight = Platform.OS === "android" ? 22 : 20;

  return (
    <View style={{ height: 48, justifyContent: "center" }}>
      <TextInput
        {...props}
        style={[
          {
            fontFamily: "YourFont",
            fontSize: 16,
            lineHeight,
            padding: 0,
            textAlignVertical: "center",
          },
          props.style,
        ]}
      />
    </View>
  );
}

📌 六、快速判断是哪种问题(排查表)

现象 原因 解决方案
文字偏上 ascent 太大 增加 lineHeight
placeholder 偏位置 TextInput baseline 变化 包 wrapper
Android 完全不对齐 不支持自动 baseline 加 textAlignVertical
iOS 正常、Android 不正常 Android 用真实 metrics 统一 lineHeight
不同设备看起来不一样 字体 degree scaling 不一致 wrapper + lineHeight
怎么调都不对 字体文件本身错误 修字体 metrics

📌 七、总结

React Native 使用自定义字体后高度/对齐异常 不是 RN 的 bug,而是:

  • 自定义字体度量不规范
  • RN 必须遵守字体真实 metrics
  • Android 更严格
  • TextInput 本身对行高处理简单

最有效的办法是:

  1. 统一 lineHeight
  2. 外包 wrapper
  3. Android 加 textAlignVertical=center
  4. 不行就 修字体度量

跨域问题深度剖析:为何CORS设置了还是报错?

问题背景

最近在项目中遇到了一个棘手的跨域问题:B域名的网页被嵌入在A域名内部,B域名的接口请求也出现了跨域错误。尽管后端同学已经配置了Access-Control-Allow-Origin等CORS相关头部,但控制台仍然持续报跨域错误。

问题排查过程

初步排查

一开始我们按照常规跨域问题的排查思路:

  • 检查后端CORS配置是否正确
  • 验证Access-Control-Allow-Origin头是否包含A域名
  • 确认Access-Control-Allow-Credentials设置为true

但所有这些配置都正确,问题依然存在。

深入分析

经过仔细排查,发现了问题的本质:B域名没有成功携带A域名的Cookie,导致请求被服务端的passport权限拦截中间件拦截,从而触发了跨域错误。

这里的关键点是:即使后端正确配置了CORS,如果请求因为权限问题被前端中间件拦截,同样会表现为跨域错误。

解决方案

方案一:种植Cookie方案

javascript

// B页面中的请求处理
function makeRequest() {
  const xhr = new XMLHttpRequest();
  
  // 关键:添加x-requested-with头,阻止浏览器自动处理302重定向
  xhr.setRequestHeader('x-requested-with', 'XMLHttpRequest');
  
  xhr.onreadystatechange = function() {
    if (xhr.readyState === 4) {
      if (xhr.status === 302) {
        // 手动处理302重定向
        const redirectUrl = window.location.href;
        window.location.href = `/sso/logon?redirectUrl=${encodeURIComponent(redirectUrl)}`;
      } else if (xhr.status === 200) {
        // 正常处理响应
        console.log('请求成功', xhr.responseText);
      }
    }
  };
  
  xhr.open('GET', '/api/data', true);
  xhr.send();
}

后端配合调整:

java

// 当检测到x-requested-with头时,不自动重定向
if ("XMLHttpRequest".equals(request.getHeader("x-requested-with"))) {
    response.setStatus(302);
    response.setHeader("Location", ""); // 清空或不设置Location头
    // 通过其他方式传递重定向URL,如响应体
} else {
    response.sendRedirect("/sso/logon?redirectUrl=" + url);
}

方案缺陷:

  • 部分浏览器出于安全策略考虑,禁止跨域名种植Cookie
  • 实现相对复杂,需要前后端协同调整

方案二:同源部署方案(推荐)

将B页面的模板直接部署到A域名的服务器上,从根本上消除跨域问题。

实施步骤:

  1. 将B页面的前端资源打包部署到A域名服务
  2. 调整路由配置,确保B页面的访问路径在A域名下
  3. 更新所有相关的资源引用路径

nginx

# Nginx配置示例
server {
    listen 80;
    server_name a-domain.com;
    
    location /b-page/ {
        # 代理到B页面服务或直接服务静态资源
        proxy_pass http://b-service/;
        # 或者直接服务本地文件
        root /path/to/b-page-static-files;
    }
    
    location /api/ {
        proxy_pass http://b-api-service/;
    }
}

优势:

  • 彻底解决跨域问题
  • Cookie自然共享,无需特殊处理
  • 更好的性能和用户体验

技术要点总结

1. CORS与Cookie的关系

  • 跨域请求默认不携带Cookie
  • 需要设置withCredentials: true(前端)和Access-Control-Allow-Credentials: true(后端)
  • Access-Control-Allow-Origin不能为*,必须指定具体域名

2. 302重定向的Ajax处理

  • 默认情况下,浏览器会自动处理302重定向,前端无法拦截
  • 通过x-requested-with: XMLHttpRequest头告知后端需要特殊处理
  • 后端据此决定是否让前端手动处理重定向

3. 跨域问题的本质

跨域问题不仅仅是技术配置问题,更是安全策略的体现。浏览器的同源策略是为了保护用户数据安全。

经验教训

  1. 不要盲目相信错误信息:跨域错误可能是其他问题的表象
  2. 系统化排查:从前端到后端,从网络到安全策略全面排查
  3. 优先选择根本解决方案:相比各种workaround,从架构层面解决问题更可靠

结语

跨域问题是前端开发中的常见挑战,理解其背后的原理和浏览器安全策略至关重要。通过这次问题的解决,我们不仅修复了bug,更重要的是加深了对Web安全机制的理解。

❌