普通视图

发现新文章,点击刷新页面。
今天 — 2025年11月19日首页

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

作者 Moment
2025年11月19日 09:13

最近在使用 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⭐️⭐️⭐️

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

🐒🐒🐒

昨天 — 2025年11月18日首页

Angular v21 无 Zone 模式前瞻:新特性、性能提升与迁移方案

作者 Moment
2025年11月18日 06:57

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

如果说 Signals 是 Angular 的"顿悟"时刻,那么无 Zone(Zoneless)则是"这感觉真流畅"的时刻。在 Angular v21 中,新应用默认使用无 Zone 的变更检测方式,这意味着不再有 Zone.js 在幕后运行——只有通过 Signals 实现的明确、可预测的响应式编程。

简而言之:告别 Zone.js,迎来更小的包体积、更清晰的堆栈追踪,以及仅在您指定时(通过 Signals 和事件)更新的变更检测。

什么是无 Zone 模式?

从历史上看,Angular 使用 Zone.js 来修补异步 API(如定时器、事件、Promises)并自动触发变更检测。这种方式虽然方便,但有时会过于积极,导致不必要的检查。

无 Zone 模式完全移除了 Zone.js,转而依赖 Signals 和模板事件来确定何时更新 UI。这意味着:

  • 变更检测只在明确需要时触发(Signals 变化、输入变化、模板事件)
  • 不再有全局的异步操作监听
  • 更少的运行时开销,更好的性能表现

结果是更少的工作量、更少的意外情况,以及更好的性能。

v21 的新特性

新项目默认采用无 Zone 模式

v20.2 稳定了该 API(provideZonelessChangeDetection()),而 PR #63382 使其成为 ng new 的默认设置。现有应用可以通过一行提供者代码选择加入。

更清洁的构建

在无 Zone 模式下,您的包中不再包含 Zone.js,这意味着:

  • 更小的包体积(减少约 30KB)
  • 更清晰的堆栈追踪
  • 更少的运行时开销

SSR 和错误处理已就绪

在迈向无 Zone 的过程中,Angular 添加了强大的错误处理程序和 SSR 钩子,因此默认设置已可用于生产环境。

注意:v20.2 正式将无 Zone 提升为稳定版。v21 是第一个将其作为新应用默认设置的主要版本。

是否需要迁移现有应用?

简短回答:是的,建议计划迁移。您可能会看到更少的不必要检查和更流畅的思维模型——特别是如果您已经在使用 Signals。

迁移带来的收益

  • 可预测性:变更检测在输入、Signals 或事件更改时运行,而不是"每当某处发生任何异步操作时"。您可以清楚地知道何时会触发变更检测。

  • 性能提升:减少过度检查,减少修补操作带来的开销。应用运行更流畅,响应更快。

  • 开发体验:更清晰的堆栈追踪,减少"这个变更检测从哪里来的?"这类困惑。调试更容易,问题定位更准确。

迁移指南

步骤 1:移除 Zone.js

angular.json 的 polyfills(构建和测试)中删除 Zone.js(包括 zone.js/testing)。如果您有 polyfills.ts 文件,请移除 import 'zone.js' 这一行。

步骤 2:启用无 Zone 的变更检测

在应用启动时添加无 Zone 变更检测提供者:

import {
  bootstrapApplication,
  provideZonelessChangeDetection,
} from "@angular/core";
import { AppComponent } from "./app/app.component";

bootstrapApplication(AppComponent, {
  providers: [provideZonelessChangeDetection()],
});

步骤 3:添加浏览器错误监听器(推荐)

为了更好的错误处理,建议同时添加浏览器全局错误监听器:

import { provideBrowserGlobalErrorListeners } from "@angular/core";

bootstrapApplication(AppComponent, {
  providers: [
    provideZonelessChangeDetection(),
    provideBrowserGlobalErrorListeners(),
  ],
});

步骤 4:运行测试并移除警告

运行应用和测试,如果看到 NG0914: using zoneless but still loading Zone.js 警告,说明您遗漏了一个 polyfill。清理后重新运行即可。

无 Zone 模式下的变更检测

仍然会触发变更检测的情况

是的,完全没问题。以下情况仍会触发变更检测:

  • 模板事件(如 (click)(input) 等)
  • Signals 值的变化
  • 组件输入(@Input)的变化

不再自动触发的情况

您失去的是"所有异步操作都会触发变更检测"的全局行为。例如:

  • setTimeoutsetInterval 不会自动触发
  • Promise 的 then 回调不会自动触发
  • 第三方库的异步操作不会自动触发

如果您之前隐式依赖于此,现在需要显式地进行——这对性能和代码清晰度都是有益的。

仍然有效的常见模式

以下模式在无 Zone 模式下依然有效:

Signals 驱动 UI

// model signal
const count = signal(0);
const double = computed(() => count() * 2);

// event updates the signal
increment() { count.set(count() + 1); }

HTTP + Signals

将 HTTP 请求的结果分配给一个 signal(或 resource),并在模板中绑定它;当 signal 更改时,UI 会自动更新。

markForCheck

调用 markForCheck 会触发应用渲染,因此有时不需要重构为 signals,使用 markForCheck 也能正常工作。

迁移注意事项

在迁移过程中,请注意以下几点:

第三方库兼容性

大多数主要库已支持无 Zone,但如果某些库修补了定时器或直接使用了 Zone API,您可能需要更新或进行小的调整。好消息是:Angular CDK 和 Angular Material 已完全支持无 Zone。

避免隐式依赖

如果您之前依赖于随机的 setTimeout 来"触发"变更检测,请切换到 signals 或分派适当的事件。例如:

// 不推荐:依赖 setTimeout 触发变更检测
setTimeout(() => {
  this.data = newData;
}, 100);

// 推荐:使用 signal 或显式触发
this.dataSignal.set(newData);
// 或
this.data = newData;
this.cdr.markForCheck();

常见问题解答

我需要将所有内容重写为 Signals 吗?

不需要。但您的状态越多地由 signals 驱动,您就越能感受到其优势。已经使用 Inputs 和事件的组件将顺利迁移。您可以逐步迁移,不需要一次性重写所有代码。

SSR/水合(hydration)怎么办?

无 Zone 已通过 SSR 错误处理和刷新时机进行了强化。如果您使用的是 v20+ 的 SSR,迁移到 v21 没有问题。Angular 团队已经确保 SSR 场景下的稳定性。

无 Zone 实际上稳定吗?

是的。v20.2 将其提升为稳定版;v21 使其成为新应用的默认设置。这意味着它已经过充分测试,可以安全地用于生产环境。

总结

Angular v21 中的无 Zone 不仅仅是内部的清理——它是一个新的默认设置,使 Angular 感觉更轻盈、更敏捷、更有目的性。您保留了良好的开发体验(事件、Inputs、Signals),同时减少了运行时开销。如果您一直在犹豫,v21 是您迈出这一步的推动力。

团队行动计划

  1. 先在开发模式下尝试无 Zone,确认无误后,在生产环境中保持启用。

  2. 验证第三方库的兼容性,确保所有依赖都能正常工作。

  3. 全应用范围内推广,并享受更平静、可预测的渲染体验。

祝您升级顺利——并享受 Zone.js 的沉默。🥂

昨天以前首页

LangChain 1.0 发布:agent 框架正式迈入生产级

作者 Moment
2025年11月17日 09:03

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

随着大型语言模型(LLM)逐渐从「实验」走向「生产可用」,越来越多开发团队意识到:模型本身已不是唯一挑战,更关键的是 agent、流程编排、工具调用、人机  in‑the‑loop 及持久状态管理等机制的落地。 在这样的背景下,开源框架  LangChain 和 LangGraph 同步迈入  v1.0  版本——这不只是版本号的更新,而是“从实验架构迈向生产级 agent 系统”的关键一步。

核心更新概览

LangChain 1.0

  • LangChain 一直定位于“与 LLM  交互 + 构建 agent” 的高层框架。它通过标准的模型抽象、预构建的 agent 模式,帮助开发者快速上线 AI  能力。

  • 在过去几年里,社区反馈主要集中在:抽象过重、包的 surface area(命名空间、模块)过大、希望对 agent loop 有更多控制但又不想回归调用原始 LLM。

  • 为此,1.0  版重点做了:

    1. 新的 create_agent(或在 JS  里 createAgent)抽象 — 最快构建 agent 的方法。
    2. “标准内容块”(standard content blocks) — 提供跨模型/提供商的一致输出规范。
    3. 精简包的内容(streamlined surface area) — 将不常用或遗留功能移到 langchain‑classic(或类似)以简化主包。

LangGraph 1.0

  • LangGraph 定位较低层,主要用于"状态持久化 + 可控流程 + 人机介入"的场景。换句话说,当 agent 不只是"输入 → 模型 → 工具 → 输出"那么简单,而是需要"多节点、可暂停、可恢复、多人协作"的复杂流程时,LangGraph 在背后支撑底层运行。

  • 核心特性包括:

    • 可耐久状态(durable state)– agent 执行状态自动保存、服务器中断后可恢复。
    • 内置持久化机制 – 无需开发者为数据库状态管理写很多 boilerplate。
    • 图(graph)执行模型 – 支持复杂流程、分支、循环,而不是简单线性流程。
  • 值得注意的是:LangChain 1.0 的 agent 实际上是构建在 LangGraph 的运行时之上。这意味着从高层使用 LangChain  时,确实获得了底层更强的支持。

Node.js 示例

下面以 Node.js 演示两种场景:

  1. 快速构建一个天气 agent(使用  LangChain  高层抽象)
  2. 增强为结构化输出 + 工具调用示例

安装

npm install @langchain/langchain@latest
npm install @langchain/langgraph@latest

注:请根据实际包名/版本确认,因为官方可能更新命名空间或路径。

示例  1:快速 agent

import { createAgent } from "@langchain/langchain/agents";

async function runWeatherAgent() {
  // 定义一个工具函数,假设已实现
  const getWeatherTool = {
    name: "getWeather",
    description: "获取指定城市天气",
    async call(input) {
      // 这里是工具调用逻辑,例如调用天气 API
      const { city } = input;
      // 模拟返回
      return { temperature: 26, condition: "Sunny", city };
    },
  };

  const weatherAgent = createAgent({
    model: "openai:gpt‑5", // 根据实际模型提供者调整
    tools: [getWeatherTool],
    systemPrompt: "Help the user by fetching the weather in their city.",
  });

  const result = await weatherAgent.invoke({
    role: "user",
    content: "What's the weather in San Francisco?",
  });

  console.log("Result:", result);
}

runWeatherAgent().catch(console.error);

示例  2:结构化输出 + 工具调用

import { createAgent } from "@langchain/langchain/agents";
import { ToolStrategy } from "@langchain/langchain/agents/structured_output";

// 定义结构化输出类型(用 TypeScript 更佳)
class WeatherReport {
  constructor(temperature, condition) {
    this.temperature = temperature;
    this.condition = condition;
  }
}

async function runStructuredWeatherAgent() {
  const weatherTool = {
    name: "weatherTool",
    description: "Fetch the weather for a city",
    async call({ city }) {
      // 调用外部天气API
      return { temperature: 20.5, condition: "Cloudy", city };
    },
  };

  const agent = createAgent({
    model: "openai:gpt‑4o‑mini",
    tools: [weatherTool],
    responseFormat: ToolStrategy(WeatherReport),
    prompt: "Help the user by fetching the weather in their city.",
  });

  const output = await agent.invoke({
    role: "user",
    content: "What’s the weather in Tokyo today?",
  });

  console.log("Structured output:", output);
}

runStructuredWeatherAgent().catch(console.error);

实践注意事项

  • 模型提供者(如  OpenAI、Anthropic、Azure  等)具体接入方式、身份认证、费用控制,需要在项目中自行配置。
  • 工具(tools)需要自行定义:名称、描述、调用逻辑、输入/输出格式。务必做好错误处理与超时控制。
  • 结构化输出(如  ToolStrategy)可提升模型结果的一致性、安全性,但需定义好对应的类/接口/类型。上面示例仅为简化版。
  • 当流程更复杂(例如多步、环节审核、人机交互、长期挂起)时,建议使用  LangGraph  底层能力。

何时选用  LangChain vs LangGraph?

虽然二者紧密相关,但从实用视角来看,有以下建议:

场景 推荐框架 理由
快速构建、标准 agent 流程(模型 → 工具 → 响应)、不需要复杂流程控制 LangChain 1.0 高层抽象快上手,已封装常用模式。
需要流程编排、状态持久化、长流程运行、人工介入、分支逻辑 LangGraph 1.0 支持图执行、持久状态、人机互动,适合生产级 agent。

换句话说,如果你需要“快速构建一个 agent”去实验或上线 MVP,用  LangChain  足够。如果你要做“真正可用、需运维、需被监控、流程可暂停可恢复”的 agent 系统,那  LangGraph  是更合适的底层框架。官方也指出,LangChain 的 agent 是构建在 LangGraph 之上的。

总结

LangChain 1.0 标志着 agent 框架从实验阶段正式迈入生产级应用。本次更新重点解决了抽象过重、包体积过大等问题,推出了 createAgent 这一快速构建 agent 的核心 API,并引入标准内容块以实现跨模型的一致输出。更重要的是,LangChain 1.0 的 agent 运行在 LangGraph 运行时之上,为开发者提供了更强的底层支持。对于需要快速构建标准 agent 流程的场景,LangChain 1.0 提供了简洁的高层抽象;而当需求涉及复杂流程编排、状态持久化时,可以深入使用底层的 LangGraph 能力。

❌
❌