普通视图

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

从renderToString到hydrate,从0~1手写一个SSR框架

作者 sorryhc
2025年10月24日 17:45

一、前言

上一篇文章,我们从ice.js源码学习了SSR的底层框架运行过程。

开源的SSR框架都是怎么实现的?

我们梳理了SSR从服务端渲染 -> 前端可交互的过程主要有如下几个阶段:

  1. 服务端匹配到前端请求;
  2. 基于路由匹配实际需要渲染的React组件(from cjs产物);
  3. 组装App全局上下文和前端路由(react-router-dom);
  4. 服务端执行渲染,产出html string
  5. 前端水合,执行hydrate逻辑;
  6. 用户可交互;

基于这整个过程,你有没有思考过?SSR框架是如何把我们本地的组件(页面 - pages、组件 - components等等)串联成这个渲染链路的?

本文我们基于上述的渲染流程和主流的SSR框架技术实现原理,实现一个mini版本可跑通的SSR框架,从而深入理解SSR的全链路系统原理。

二、0~1分阶段实现SSR框架

2.1、设计先行

作为框架,那必然需要前端构建,传统CSR很简单,基于webpack单入口分析所有模块,打出js、css、html

SSR构建出一个应用,最基本的需要哪些能力呢?首先最大的区别:CSR部署相对静态,而SSR部署相对动态,如:服务端执行渲染、读配置前端水合,都是框架层面的rumtime code,因此需要前端和服务端的运行时产物。

而运行时核心的做的事情和路由 -> 组件有关,而服务端node环境只能识别cjs模块;浏览器环境识别esm模块,因此需要将项目中所有的组件按统一源码,cjsesm不同模块分别打出一份供服务端前端使用。

就像这样:

image.png

2.2、项目初始化

我们新建一个项目,并初始化。

mkdir ssr-demo
cd ssr-demo
npm init -y

然后分析下需要的依赖。

  • 构建,需要webpack
  • 底层框架,需要reactreact-domreact-router-dom
  • SSR服务,需要express
  • 源码构建编译,需要@babel/core@babel/preset-reactbabel-loader

因此,执行:

npm i webpack react react-dom react-router-dom express @babel/core @babel/preset-react babel-loader

然后我们先配置下webpack基础构建能力。

核心是给前端水合的runtime、服务端渲染的runtime打包。

因此拆两个webpack配置文件。

webpack.client.config.js

const path = require("path");

module.exports = {
  mode: "development",
  entry: "./src/entry/client-entry.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "client-bundle.js",
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: "babel-loader",
        options: {
          presets: [
            [
              "@babel/preset-react",
              {
                runtime: "automatic",
              },
            ],
          ],
        },
      },
    ],
  },
};

webpack.server.config.js

const path = require("path");

module.exports = {
  mode: "development",
  target: "node",
  entry: "./src/entry/server-entry.js",
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "server-bundle.js",
    libraryTarget: "commonjs2",
  },
  externals: {
    react: "commonjs react",
    "react-dom/server": "commonjs react-dom/server",
  },
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        loader: "babel-loader",
        options: {
          presets: [
            [
              "@babel/preset-react",
              {
                runtime: "automatic",
              },
            ],
          ],
        },
      },
    ],
  },
};

就是两个最基础的配置,只是把代码打包了一下,没有特别复杂的能力。

后面如果要对框架做扩展,我们继续扩展就行。

然后我们再配置下打包和运行。

build就是把两个入口打一下。

start就是把服务跑起来。

package.json

{
    // ...
    "scripts": {
        "build": "npx webpack --config webpack.client.config.js && npx webpack --config webpack.server.config.js",
        "start": "node server.js"
    }
}

2.3、服务端核心

首先我们基于express跑一个服务,同时匹配路由。

const express = require("express");
const fs = require("fs");
const path = require("path");
const { render } = require("./dist/server-bundle.js");

const app = express();

// 静态文件(客户端 bundle)
app.use(express.static(path.join(__dirname, "dist")));

app.use(async (req, res) => {
  const { html, data, routePath } = await render(req.path);

  let template = fs.readFileSync(
    path.join(__dirname, "src", "template", "template.html"),
    "utf8"
  );
  template = template.replace("<!--SSR_CONTENT-->", html);
  template = template.replace("<!--INITIAL_DATA-->", JSON.stringify(data));
  template = template.replace("<!--ROUTE_PATH-->", routePath);

  res.send(template);
});

app.listen(3000, () => {
  console.log("SSR server running at http://localhost:3000");
});

这是一个基础框架,匹配到路由后做的事情很简单:

  1. 暴露dist
  2. 传入请求路径,执行render核心函数,解析对应服务端组件;
  3. 基于解析完成的html string运行时App上下文写入模板;
  4. 返回前端;

render核心函数的处理呢?

server-entry.js

import React from "react";
import ReactDOMServer from "react-dom/server";
import { Route, Routes } from "react-router-dom";
import AppProviders from "../context/AppProviders.jsx";
import routes from "../routes/routes.js";

export async function render(url) {
  let matchedRoute = routes.find((r) => r.path === url);

  let routeData = {};
  let appData = { appName: "SSR Demo" };
  let Component = matchedRoute?.element?.type;
  if (Component && Component.getServerData) {
    routeData = await Component.getServerData();
  }
  const appContext = { appData, routeData, routePath: url };

  const element = (
    <AppProviders appContext={appContext} location={url} isServer>
      <Routes>
        {routes.map((r, idx) => {
          // 只给匹配到的那个路由传数据
          if (r.path === url) {
            return (
              <Route
                key={idx}
                path={r.path}
                element={React.cloneElement(r.element, { data: routeData })}
              />
            );
          }
          // 其它路由照常渲染,data 可以传 undefined 或保持原样
          return <Route key={idx} path={r.path} element={r.element} />;
        })}
      </Routes>
    </AppProviders>
  );

  const html = ReactDOMServer.renderToString(element);

  return { html, data: appContext, routePath: url };
}

服务端渲染核心函数做了这些事情:

  1. 基于前端请求路径匹配路由组件;
  2. 读取组件服务端请求函数,用于在服务端初始化首屏动态数据;
  3. 创建App全局上下文
  4. 创建路由

那继续逐个来看,基于前端请求路由,我们先看下routes文件,看完你就明白了。

// 用 React Router v6 的形式配置路由
import About from "../components/About.jsx";
import Home from "../components/Home.jsx";
import NotFound from "../components/NotFound.jsx";

export default [
  { path: "/", element: <Home /> },
  { path: "/about", element: <About /> },
  { path: "*", element: <NotFound /> },
];

这里实际就是拿express req url来约定式路由中匹配,找到对应的组件。

ssr框架都支持在组件中暴露页面数据请求函数,用于初始化首屏数据,从props中传入。

因此Home组件会是这样的:

import { Link } from "react-router-dom";

function Home({ data }) {
  return (
    <div>
      <h1>Home Page</h1>
      <p>Data: {data?.message}</p>
      <Link to="/about">Go to About</Link>
    </div>
  );
}

Home.getServerData = async () => {
  const data = { message: "Hello from Home Page API" };
  return data;
};

export default Home;

拿到请求函数在服务端执行下,最后传入路由去就行。

服务端的工作就完成了。

AppProvider组件是干啥的?

通常一些服务端、前端的共用数据、逻辑都会在这里。

比如路由嵌套,因为两端的组件源码是一致的,项目也是同一份,只需要区分Router类型即可。

import { BrowserRouter, StaticRouter } from "react-router-dom";
import { createContext, useContext } from "react";

const AppContext = createContext(null);

export const AppContextProvider = ({ value, children }) => {
  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
};

export const useAppContext = () => {
  return useContext(AppContext);
};

export default function AppProviders({
  appContext,
  children,
  location,
  isServer,
}) {
  const Router = isServer ? StaticRouter : BrowserRouter;

  return (
    <AppContextProvider value={appContext}>
      <Router location={location}>{children}</Router>
    </AppContextProvider>
  );
}

同时支持了isServer参数,这样组件在服务端、前端运行时都可以用。

统一了全局数据。

服务端在生成代码的时候将appContext赋值。

然后将appContext注入到html window中。

ssr 前端运行时再将appContext透传中应用中。

这样业务组件也可以获取到ssr的配置信息。

这样流程就串起来了。

OK,至此,服务端渲染部分讲完了,最后server.js再将DOM、appContext注入到模板中,返回给前端。

  const { html, data, routePath } = await render(req.path);

  let template = fs.readFileSync(
    path.join(__dirname, "src", "template", "template.html"),
    "utf8"
  );
  template = template.replace("<!--SSR_CONTENT-->", html);
  template = template.replace("<!--INITIAL_DATA-->", JSON.stringify(data));
  template = template.replace("<!--ROUTE_PATH-->", routePath);

  res.send(template);

至此,服务端部分就讲完了。

2.4 前端核心

前端部分比较简单。

回顾一下:前端在ssr中的角色核心是水合hydrate

然后服务端返回的DOM可交互。

CSR中,我们基于react renderRoot来渲染组件。

SSR中,服务端已经返回了当前页面所有的DOM,因此我们基于react hydrateRoot来水合(复用不渲染)组件。

前端运行时代码如下:

import React from "react";
import { hydrateRoot } from "react-dom/client";
import { Route, Routes } from "react-router-dom";
import AppProviders from "../context/AppProviders.jsx";
import routes from "../routes/routes.js";

function RootApp({ appContext }) {
  return (
    <Routes>
      {routes.map((r, idx) => (
        <Route
          key={idx}
          path={r.path}
          element={React.cloneElement(r.element, {
            data: appContext.routeData,
          })}
        />
      ))}
    </Routes>
  );
}

async function run() {
  const appContext = window.__INITIAL_DATA__;
  const element = (
    <AppProviders appContext={appContext} isServer={false}>
      <RootApp appContext={appContext} />
    </AppProviders>
  );
  hydrateRoot(document.getElementById("root"), element);
}

run();

将这段代码注入到html中,就会将服务端返回的DOM开始水合。

SSR有个非常关键的点,如果前端和服务端的dom不同,则会水合失败,执行渲染流程。

在服务端设计的部分,我们实现了通用的AppProviders,在这里就派上用处了。

import AppProviders from "../context/AppProviders.jsx";

前端运行时沿用这个组件。

并且将window.__INITIAL_DATA__继续作为上下文透传到前端所有组件中。

这样既保持了组件双端统一性。

也保证了数据统一性(框架数据从后端流到了前端)。

2.5 打包 -> 运行 -> 验证

至此框架的所有代码都编写完了。

我们跑下框架。

npm run build
npm run start

image.png

先后成功打包了服务端代码和前端代码。

最后把ssr服务跑起来了,运行在3000端口。

我们访问下localhost:3000

image.png

请求直接返回了首屏DOM元素。

有动态数据直接渲染。

ssr client运行时脚本执行。

符合预期。

我们再测试下应用是否可以正常用,点击Link执行下路由跳转。

image.png

可以看到About组件的动态数据没有渲染,原因很简单。

因为目前的设计是首屏的服务端组件,会在express执行getServerData注入动态数据。

而后续跳转时,组件没有在服务端执行,这时候就需要在前端执行一遍了。

怎么设计呢?

我们在框架层前端runtime加一段逻辑即可。

给非双屏的<Route />包装一层,如果是次屏组件,则请求一次数据再传入就行。

就像这样:

import React, { useEffect, useState } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { Routes, Route, useLocation } from 'react-router-dom';
import AppProviders from '../context/AppProviders.jsx';
import routes from '../routes/routes.js';

// 页面容器组件:处理首次加载的数据和路由切换时的数据获取
function DataLoader({ route }) {
  const location = useLocation();
  const [data, setData] = useState(() => window.__INITIAL_DATA__?.routeData);

  useEffect(() => {
    let active = true;

    async function fetchData() {
      const Component = route.element.type;
      if (Component.getServerData) {
        const newData = await Component.getServerData();
        if (active) {
          setData(newData);
        }
      }
    }

    // 首屏不请求(数据由 SSR 注入),后续路由切换才请求
    if (location.pathname !== window.__ROUTE_PATH__) {
      fetchData();
    }

    return () => { active = false; };
  }, [location.pathname, route.element.type]);

  const ElementWithData = React.cloneElement(route.element, { data });
  return ElementWithData;
}

function RootApp({ appContext }) {
  return (
    <Routes>
      {routes.map((route, idx) => (
        <Route
          key={idx}
          path={route.path}
          element={<DataLoader route={route} />}
        />
      ))}
    </Routes>
  );
}

export default function run() {
  const appContext = window.__INITIAL_DATA__ || { routeData: {} };

  const element = (
    <AppProviders appContext={appContext} isServer={false}>
      <RootApp appContext={appContext} />
    </AppProviders>
  );

  hydrateRoot(document.getElementById('root'), element);
}

这样一个可用、具备基础功能的SSR就完成了。

三、结尾

至此,从0~1手写一个ssr框架,不就搞定了么?

基于这个思路再去看Next.jsIce.js,你会发现实现原理都很类似。

都是服务端渲染 -> 前端水合,结合双端运行时代码和约定式路由。

为什么现代ssr框架这么热门?

因为react支持了水合,让更完美的渲染方案问世了,即首屏SSR+前端接管

如果你以前对于ssr的理解只停留在后端返回页面,页面跳转不好处理的阶段。

那对你的帮助应该很大!

一个函数超过20行? 聊聊我的函数式代码洁癖

作者 ErpanOmer
2025年10月23日 10:28

pankaj-patel-_SgRNwAVNKw-unsplash.jpg

大家好,我又来了😁

我得承认,我有个毛病,或者说洁癖吧。

在Code Review的时候,当我点开一个*.js / *.ts文件,看到一个函数洋洋洒洒地写了50行、80行,甚至更多时,我的第一反应不是去读它的逻辑,而是生理性地发慌😖。

我会下意识地在评论区留下一句:这个函数是不是太长了?能不能拆一下?

20行这个数字,是我给自己设的一个 代码量阈值。它不绝对,但足够灵敏。

我知道,很多人会觉得我这是小题大做、形式主义。但今天我想聊聊,这个洁癖背后,隐藏的是一个被函数式思想洗礼过的、关于代码可维护性、可测试性和认知成本的严肃思考。


为什么长函数让人如此发慌?😒

一个超过20行的函数,对我来说,通常意味着三场灾难:

1. 阅读成本极高

// 这是一个超过 50 行的函数
// 目的:根据用户数据生成报告并发送邮件(其实做了三件事)
function handleUserReport(users, sendEmail, isAdmin) {
  let result = [];
  let flag = false;

  console.log("开始处理用户数据...");

  for (let i = 0; i < users.length; i++) {
    let u = users[i];
    if (u.age > 18) {
      if (u.active) {
        if (u.score > 80) {
          result.push({ name: u.name, status: "优秀" });
          flag = true;
        } else if (u.score > 60) {
          result.push({ name: u.name, status: "良好" });
        } else {
          result.push({ name: u.name, status: "待提升" });
        }
      } else {
        if (isAdmin) {
          result.push({ name: u.name, status: "非活跃但保留" });
        } else {
          result.push({ name: u.name, status: "非活跃" });
        }
      }
    } else {
      if (u.active) {
        result.push({ name: u.name, status: "未成年用户" });
      }
    }
  }

  console.log("用户数据处理完毕");
  console.log("生成报告中...");

  let report = "用户报告:\n";
  for (let i = 0; i < result.length; i++) {
    report += `${result[i].name} - ${result[i].status}\n`;
  }

  if (flag) {
    console.log("存在优秀用户!");
  }

  if (sendEmail) {
    console.log("准备发送邮件...");
    // 模拟邮件发送逻辑
    for (let i = 0; i < result.length; i++) {
      if (result[i].status === "优秀") {
        console.log(`已发送邮件给:${result[i].name}`);
      }
    }
  }

  console.log("处理完成。");
  return report;
}

上面👆这个50多行的函数,就像一篇没有分段的短文。你必须从头到尾把它加载到你的大脑里,才能理解它到底在干嘛。

  • 第5行定义的一个flag变量,在第15行被修改了。

  • 中间夹杂着三层if/else嵌套。

  • 它到底做了几件事?天知道🤷‍♂️。

    这种函数,是可写,不可读的。写的人洋洋得意,几个月后他自己回来维护,一样骂娘😠。

2. 根本无法单元测试

我们来谈谈单元测试。你怎么去测试一个50行的、混合了数据请求、数据格式化和UI状态更新的函数?

先看代码👇:

// 一个50行的混合函数:既请求接口、又格式化数据、还更新UI状态
async function loadUserProfile(userId) {
  setLoading(true);

  try {
    // 1️⃣ 请求数据
    const response = await fetch(`/api/user/${userId}`);
    const data = await response.json();

    // 2️⃣ 本地缓存
    localStorage.setItem('lastUserId', userId);

    // 3️⃣ 格式化数据
    const displayName = data.firstName + ' ' + data.lastName;
    const ageText = data.age ? `${data.age}岁` : '未知年龄';

    // 4️⃣ UI状态更新
    setUser({
      name: displayName,
      age: ageText,
      hobbies: data.hobbies?.join('、') || '无'
    });

    // 5️⃣ 额外副作用
    if (data.isVIP) {
      trackEvent('vip_user_loaded');
      showVIPBadge();
    }

    setLoading(false);
  } catch (error) {
    console.error('加载失败', error);
    setError('加载用户信息失败');
    setLoading(false);
  }
}

测试代码:

// 测试代码(伪代码)
test('loadUserProfile should set formatted user data', async () => {
  // Mock 一堆外部依赖
  global.fetch = jest.fn().mockResolvedValue({
    json: () => Promise.resolve({ firstName: 'Tom', lastName: 'Lee', age: 28, isVIP: true })
  });
  localStorage.setItem = jest.fn();
  const setUser = jest.fn();
  const setLoading = jest.fn();
  const setError = jest.fn();
  const trackEvent = jest.fn();
  const showVIPBadge = jest.fn();

  // 还要通过依赖注入或hook替换上下文...
  await loadUserProfile(123);

  // 然后验证每一步是否被正确调用
  expect(fetch).toHaveBeenCalledWith('/api/user/123');
  expect(localStorage.setItem).toHaveBeenCalledWith('lastUserId', 123);
  expect(setUser).toHaveBeenCalledWith({
    name: 'Tom Lee',
    age: '28岁',
    hobbies: '无'
  });
  expect(trackEvent).toHaveBeenCalledWith('vip_user_loaded');
  expect(showVIPBadge).toHaveBeenCalled();
  expect(setLoading).toHaveBeenLastCalledWith(false);
});

你根本没法测试。你只能去集成测试。

为了测试它,你不得不mock掉fetch、localStorage、useState... 你会发现,你的测试代码,比你的业务代码还长、还复杂。

3. 你看不见的地雷

函数越长,它顺手去干点脏活的概率就越大。

举个例子👇:

// 名字看起来挺纯洁的 —— 获取用户配置
// 实际上它干了很多事没人知道...
function getUserConfig(userId) {
  console.log('开始获取用户配置...');

  // 1️⃣ 顺手改了全局变量
  globalCache.lastRequestTime = Date.now();

  try {
    // 2️⃣ 发起网络请求
    const res = fetch(`/api/config/${userId}`);
    const data = res.json();

    // 3️⃣ 顺手改了一下全局设置
    window.__APP_MODE__ = data.isAdmin ? 'admin' : 'user';

    // 4️⃣ 顺手写了一点 localStorage
    localStorage.setItem('lastConfigUser', userId);

    // 5️⃣ 格式化返回数据
    const config = {
      theme: data.theme || 'light',
      lang: data.lang || 'en-US'
    };

    return config;
  } catch (err) {
    console.error('获取配置出错', err);

    // 6️⃣ 顺手派发了一个事件
    window.dispatchEvent(new CustomEvent('config_load_failed', { detail: { userId } }));

    // 7️⃣ 顺手清空了一个全局标记
    globalCache.lastRequestTime = null;

    return { theme: 'light', lang: 'en-US' }; // 假装有个默认值
  }
}

调用者根本不知道它干了些什么 😵‍💫

const config = getUserConfig(42);
console.log(config.theme); // 看起来很正常

// 但此时:
// window.__APP_MODE__ 已被改动
// localStorage 里写入了 lastConfigUser
// globalCache.lastRequestTime 已变化
// 如果请求失败,还会触发一个全局事件

  • 它在函数的中间,顺手改了一个全局变量。

  • 它在catch块里,顺手dispatch了一个event

  • 它顺手往window上挂了个东西。

    这种充满隐形副作用的函数,是系统中最不可预测的地雷。你根本不知道你调用它,会影响到哪里。


谈一谈 函数式思想

我的洁癖,其实是来源于函数式编程思想。

我并不追求写出高阶组合子那些高深的东西。我只坚守两个最朴素的原则:

函数必须小,且只做一件事

这是 单一职责原则 的终极体现。一个函数,就只做一件事。

  • getUserData就只负责fetch

  • formatUserData就只负责格式化。

  • setUserState就只负责更新状态。

    一个函数超过20行,对我来说,往往就是它至少做了两件以上的事情的强烈信号。

追求纯函数,隔离掉它的一切副作用

一个纯函数:给它什么(入参),它就吐出什么(返回),绝不搞小动作。

我追求的目标,就是把所有的业务逻辑和计算,都抽成纯函数。而那些不得不做的脏活(比如API请求、DOM操作),则被我隔离在最外层的协调函数里。


重构一个函数

我们来看一个在React项目里,极其常见的函数(绝对超过20行):

// 场景:一个提交用户注册的函数
async function handleRegister(formData) {
  setLoading(true);
  
  // 1. 业务逻辑:验证
  if (!formData.username) {
    showToast('用户名不能为空');
    setLoading(false);
    return;
  }
  if (formData.password.length < 6) {
    showToast('密码不能少于6位');
    setLoading(false);
    return;
  }
  
  // 2. 业务逻辑:数据转换
  const apiPayload = {
    user: formData.username,
    pass: btoa(formData.password + 'my_salt'), // 假设的加密
    source: 'web',
    registerTime: new Date().toISOString(),
  };

  // 3. 副作用:API请求
  try {
    const result = await api.post('/register', apiPayload);
    
    // 4. 副作用:更新UI状态
    if (result.code === 200) {
      setUserData(result.data.user);
      trackEvent('register_success');
      showToast('注册成功!');
      router.push('/dashboard');
    } else {
      showToast(result.message);
    }
  } catch (err) {
    showToast(err.message);
    trackEvent('register_fail', { msg: err.message });
  } finally {
    setLoading(false);
  }
}

这个函数,就是一场灾难。它混合了4-5种职责,你根本没法测试它。

重构过程如下👇:

1.先分离纯业务逻辑(可测试)

// 纯函数1:验证逻辑 (可独立测试)
// (5行)
export function validateRegistration(formData) {
  if (!formData.username) return '用户名不能为空';
  if (formData.password.length < 6) return '密码不能少于6位';
  return null; // 验证通过
}

// 纯函数2:数据转换 (可独立测试)
// (7行)
export function createRegisterPayload(formData) {
  return {
    user: formData.username,
    pass: btoa(formData.password + 'my_salt'),
    source: 'web',
    registerTime: new Date().toISOString(),
  };
}

2.再分离它的副作用

// 副作用函数1:API调用
// (3行)
export async function postRegistration(payload) {
  return api.post('/register', payload);
}

// 副作用函数2:处理成功后的UI逻辑
// (6行)
function handleRegisterSuccess(userData) {
  setUserData(userData);
  trackEvent('register_success');
  showToast('注册成功!');
  router.push('/dashboard');
}

// 副作用函数3:处理失败后的UI逻辑
// (3行)
function handleRegisterFail(error) {
  showToast(error.message);
  trackEvent('register_fail', { msg: error.message });
}

3.最后重组函数

现在,我们原来的handleRegister函数,变成了一个清晰的调用者:

// (18行)
async function handleRegister(formData) {
  // 1. 验证
  const validationError = validateRegistration(formData);
  if (validationError) {
    showToast(validationError);
    return;
  }
  
  setLoading(true);
  try {
    // 2. 转换
    const payload = createRegisterPayload(formData);
    // 3. 执行
    const result = await postRegistration(payload);
    // 4. 响应
    if (result.code === 200) {
      handleRegisterSuccess(result.data.user);
    } else {
      handleRegisterFail(new Error(result.message));
    }
  } catch (err) {
    handleRegisterFail(err);
  } finally {
    setLoading(false);
  }
}

等等!你这个handleRegister函数,不还是快20行了吗?😂

是的,但你发现区别了吗?这个函数,几乎没有任何逻辑 ,它只负责调用其他小函数。它像一个流程图,清晰得一目了然。

而所有的业务逻辑(validatecreatePayload),都被我拆分到了可独立测试、可复用、可预测的纯函数里。这,就是这次的重构的价值。


20行代码的标准 不是一个KPI,它是一个预警

它在提醒我们,这个函数的 负载 可能已经超标了,它在 单一职责 的路上可能已经走偏了。

这种洁癖,不是为了追求代码的短小,而是为了追求代码的简单可预测

在一个由几十万行代码构成的、需要长期维护的系统里,简单和可预测,是比炫技(屎代码💩),要宝贵一百倍😁。

Sentry 都不想接,这锅还让我背?这xx工作我不要了!

作者 洛卡卡了
2025年10月23日 09:53

前端出了问题,但总是“查无此人”

之前在一家体量不算大的公司,我们团队负责维护一个面向 C 端用户的 SaaS 系统
产品双周迭代,每次上线后,我们也会定期从客服那边收集用户的反馈。

但很快,我们就遇到一个反复出现、却又怎么也搞不定的“无语问题”。

有客户反馈说:页面点不动了,卡死了。
还有的说:点按钮没反应,像是前端死机了。
甚至有的说:页面直接报错,看不见内容。

于是我们第一时间去翻后端接口日志,结果却显示一切正常,没有报错、没有异常,连一个 500 都没有。
这时候锅自然就甩给了前端。

但前端同学也很无语:

  • 用户只说“打不开”,但没有截图、没有步骤,连系统版本都不清楚;
  • 再加上这类问题是个例居多,重现概率几乎为零;
  • 我们能做的,只剩下“老三样”:让用户清缓存、刷新页面、重新登录......
    但没办法,大多数时候,这些操作也解决不了问题。

所以就变成了前端同学每天加班查代码、调兼容性、测不同浏览器,
问题有没有解决不知道,但人是越来越累了。

终于,前端同学提了建议:

“要不我们接个前端监控吧?
比如现在很流行的Sentry,能自动上报 JS 报错的那种,定位也方便很多。”

大家一听,也确实觉得挺不错的。

但现实很快泼了冷水......


前端想接监控,运维说“没必要”

虽然sentry有云系统,但是由于项目涉及一些私有化部署和用户数据,安全层面考虑,我们必须 自建 Sentry 服务

但当前端去找运维申请服务器时,运维那边的反馈是这样的:

“公司不是已经有监控系统了吗?
用的是专门给后端接入的那套,也不是 Sentry,
前端那点问题都是个别用户的,没必要再单独整一套吧?”

再加上自建 Sentry 的门槛也不低,
至少得有一台 4 核 8G 的独立服务器,部署起来还得专人维护。
对我们这样的小团队来说,单纯为了前端监控去上这么大资源,确实没必要呀。
更何况前端监控也不像后端那样要“每天盯着看”,很多时候就是偶尔排查用一下,
这样专门搭一整套服务常驻着,确实有点浪费资源。

所以这个提议,第一次就被驳回了。
前端同学一听,也是很无奈。

但问题依旧在那:
用户报错没头绪,前端无法复现定位全靠猜。
每次出问题复现不了就让做向下兼容......
甚至要远程帮客户操作——这效率也太低了叭。

后来前端负责人出面找运维进行了友好的交流,互相问候了一下,突出了前端监控的重要性和必要性。 最终这件事才得以推进,Sentry 的前端私有化监控系统正式落地


从后端写前端,才真正理解“监控到底有多重要”

那前端到底有没有必要接入监控系统呢?

我一直是做后端的,对 Sentry 并不陌生,
接口报错、服务异常,基本都有监控能第一时间看出来。

那时候我对“前端要不要接监控”这事,其实也没啥感觉。
总觉得前端不就是报个错、页面卡一下,只要不影响数据就刷新好了。

直到后来我开始写前端,特别是做面向 C 端用户的系统之后......
这才体会到什么叫做“靠猜解决问题”。

总是有一些无语用户 拿着已经淘汰的机型 浏览器来给我提bug。
关键我还总是复现不了......

而且偏偏这些问题,总爱挑在下班时间冒出来,
刚放松一点,就又得重新打开代码,翻 log、翻源码、翻历史版本,
越查越烦躁。

也是在这种时候我我才体会到做后端的美好 有监控是真提莫好啊。


Sentry 介绍

Sentry 是一个用来监控应用错误的系统,简单来说,它能在我们代码出问题的时候第一时间记录下详细的异常信息。

Sentry主要能做哪些事

最重要的是它能帮我们做这三件事:错误上报、性能监控、自定义埋点。

第一,错误上报。这是我们最需要的功能。当前端页面报错时,比如用户打开页面出现白屏、控制台有 JS 异常、按钮点击崩溃等,Sentry 能自动把这些错误采集上来,并记录报错信息、文件名、报错堆栈、用户的操作路径、操作系统、浏览器版本等信息。更重要的是,如果我们配置了 sourcemap,还能还原成报错的源代码位置,方便我们来精准定位 bug。

第二,性能监控。Sentry 也能采集页面的关键性能指标(比如首屏加载时间、路由切换耗时、资源加载耗时等),帮助我们了解页面是否存在性能瓶颈。特别是对于 C 端项目来说,前端性能有时候影响的不只是用户体验,甚至可能直接导致功能失败。

第三,自定义埋点。除了系统自动采集的错误或性能数据,我们当然也可以手动埋点上报一些业务相关的异常,比如用户下单失败、登录异常、接口超时等场景。通过自定义事件上报,我们就可以把监控系统和我们的业务场景更紧密地结合起来,提升排查问题的效率。

Sentry部署方式

Sentry 的部署方式主要有两种:

第一种是 SaaS 模式,也就是使用官方提供的托管服务sentry.io 。这个最方便,注册账号后就可以用,不用自己部署服务器。不过它有免费额度限制,比如每天只支持最多5000 个事件(event),超了就得升级套餐,适合用来做功能验证或者小量使用。

第二种是 私有化部署,就是我们自己搭建一套 Sentry 服务,所有的数据都存在自己服务器里,安全性更高,也没有事件数的限制。但相应地,就需要占用自己的服务器资源,官方推荐至少 4 核心 8G 内存起步,还要配置 Redis、PostgreSQL、Cron 等配套组件,整体部署成本相对较高。

如果团队对数据隐私比较敏感,或者希望做更深入的自定义,那就适合选私有化部署;但如果只是前期简单接入体验功能,直接用 SaaS 模式就足够了哈。


接入 Sentry

我们以一个 Vue3 项目为例,来讲讲前端怎么接入 Sentry。

如果用的是其他前端框架,比如 React、Angular、小程序,或者是后端语言(Java、Python、Go 等),也都可以参考官方文档(docs.sentry.io)找到对应接入方式,这里就不展开讲了。

我们接下来的内容,以 Vue3 + Vite 项目为例,演示如何接入 Sentry,包括 SDK 配置、SourceMap 上传、前端错误定位等完整流程。

本次我们以 Sentry 官网的免费版本为例进行演示。

第一步 注册账号并设置语言

首先,访问 sentry.io 注册账号。注册完成后,点击页面左下角头像,进入 User Settings

在这个页面里,可以根据自己习惯调整一些基础设置,比如语言、时区、界面主题(深色 / 浅色模式)等。设置好之后,后续在查看错误信息时会更清晰,也方便排查问题。

image.png

第二步 创建项目

基础信息设置好之后,我们就可以开始创建项目了。

点击左上角的头像,选择「项目」,进入项目管理页。点击「创建项目」后,会进入如下界面:

image.png

  1. 在平台选择里,选择 VUE
  2. 设置告警频率(默认即可,后面也可以再改);
  3. 填写项目名称、分配到对应团队,最后点击「创建项目」即可。

这一步完成后,Sentry 会为我们生成一份接入代码,包含 DSN 地址、初始化方式等内容,稍后我们会用到。

image.png

第三步 接入 Sentry 到 Vue3 项目中

我们现在已经创建好项目,接下来就是把 Sentry 接入到 Vue 应用了。

1. 安装依赖

我们以 pnpm 为例(也可以用 npm 或 yarn):

pnpm add @sentry/vue

2. 新建 sentry.ts 文件src 目录下新建一个 sentry.ts 文件,用于统一初始化配置:

// src/sentry.ts
import * as Sentry from "@sentry/vue";
import type { App } from "vue";

export function setupSentry(app: App) {
    Sentry.init({
        app,

        // Sentry 项目的 DSN 地址(在项目创建页可以看到)
        dsn: import.meta.env.VITE_SENTRY_DSN,

        // 当前环境(如 dev、test、prod)
        environment: import.meta.env.MODE || 'development',

        // 版本号信息,用于错误定位时区分版本差异,使用统一注入的版本号
        release: __RELEASE__,

        // 是否开启调试(开发阶段建议为 true,线上建议关闭)
        debug: true,

        // 性能监控采样率(建议开发阶段设为 1.0)
        tracesSampleRate: 1.0,
    });
}

3. 在入口文件中初始化main.ts(或 main.js)中引入并调用 setupSentry

// main.js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { setupSentry } from './sentry'

const app = createApp(App)

// 初始化 Sentry
setupSentry(app)

app.mount('#app')

通过上面代码可以看到我们没有直接在代码里写死 DSN 和环境,而是通过 import.meta.env.env 配置中读取,原因主要有两个:

  • 方便按环境区分配置:不同的部署环境(开发、测试、生产)通常用不同的 DSN、不同的环境名,通过 .env.development.env.production 文件分别设置,就不用每次改代码。
  • 提升安全性与灵活性:DSN 属于敏感信息,不建议直接写死在源码中。通过环境变量注入,只在打包阶段读一次,既安全又灵活,也符合前端项目的最佳实践。

这样配置完之后,Sentry 就已经接入成功了。只要页面上有 JS 报错,Sentry 就会自动帮我们捕获并上报。

为了确认是否真的生效,我们可以先写个小 demo 来验证一下。比如在某个页面或者组件里故意抛个错误,看看能不能在 Sentry 后台看到报错信息。

第三步:写个小 demo 测试一下

Sentry 配置好了,当然要测试一下它到底有没有生效。

我们可以随便找一个组件,比如首页的 Home.vue,在 onMounted 里手动抛个错:

<script setup lang="ts">
import { onMounted } from 'vue';

onMounted(() => {
  // 故意抛出一个错误,测试 Sentry 是否能捕获
  throw new Error('这是一个用于测试 Sentry 的前端错误');
});
</script>

页面一加载,就会抛出错误。刷新页面后,稍等几秒,我们就可以在 Sentry 控制台看到这条报错了(如果设置了中文,会显示为“未处理的异常”等字样)。

image.png

在 Sentry 控制台的 Issues 页面中,我们能看到刚刚上报的错误项:

页面左上方可以选择项目(如 sentry_vue3),中间能看到报错的标题和出现时间。

我们点击进去可以查看详细的错误信息。

进入错误详情页后,可以看到这次异常的基本信息,例如:

  • 错误类型:Error
  • 报错内容:这是一个用于测试 Sentry 的前端错误
  • 出错文件:src/pages/demo.vue 第 8 行
  • 浏览器、系统、设备等信息
  • 跟踪堆栈:包括错误抛出的具体位置及调用路径

image.png

往下滚动还能看到更多上下文信息,包括:

  • 请求信息:错误发生在哪个页面(比如 localhost:5174)
  • 标签信息:操作系统、浏览器、环境(我们配置的 environment 字段会显示在这里)
  • 设备信息:品牌型号、地理位置、User-Agent 等
  • 版本信息:我们在初始化时传入的 release 字段也会出现在这里

image.png

整体来看,Sentry 会自动帮我们收集并整理这次错误的上下文环境,非常方便用于问题定位,尤其是线上问题,哪怕用户无法复现,我们也能第一时间拿到关键信息。


增强 Sentry 错误捕获能力:三类常见未被默认捕获的场景补全

在前面我们已经完成了 Sentry 的接入,并通过一个简单的报错验证了它的基础功能可以正常工作。但在真实项目中,仅靠默认配置并不能捕获所有类型的前端异常。有些报错是不会自动被 Sentry 感知和上报的,如果我们不手动处理,就很容易漏掉关键错误,影响排查效率。

接下来,我们补充三种最常见的“漏网之鱼”场景,并提供对应的解决方案,让 Sentry 的异常捕获能力更完整。

场景一:Vue 组件内部报错,Sentry 没收到

常见例子:

// setup() 中写错了变量名
const a = b.c; // b 根本不存在

为什么会漏掉?
这类错误发生在 Vue 组件内部(尤其是 <script setup> 语法中),有时不会触发 Sentry 的全局监听机制。Vue 会自己处理这些错误,但如果我们没有配置 app.config.errorHandler,Sentry 是无法感知的。

解决方法:

app.config.errorHandler = (err, vm, info) => {
    console.error("[Vue Error]", err, info);
    Sentry.captureException(err);
};

这段代码放在我们的 sentry.tsSentry.init(...) 之后即可。它能确保组件中发生的报错也能正常被上报。

场景二:Promise 异常没有 catch,被悄悄吞掉

常见例子:

// 忘了写 catch
fetch('/api/data').then(res => res.json());

或者:

Promise.reject("请求失败了");

为什么会漏掉?
这些异步异常不会触发 window.onerror,也不会被 Vue 捕获。它们属于 Promise 的“未处理拒绝(unhandledrejection)”,需要手动监听。

解决方法:

window.addEventListener("unhandledrejection", (event) => {
    console.error("[Unhandled Promise Rejection]", event.reason);
    Sentry.captureException(event.reason);
});

加上这个监听后,任何未 catch 的 Promise 错误都会被补上报。

场景三:JS 同步错误没有被捕捉

常见例子:

// 直接抛出异常
throw new Error("代码报错了");

// 访问不存在的变量
console.log(notDefinedVar);

为什么会漏掉?
这种运行期错误虽然在控制台会有报错,但默认并不会进入 Vue 的错误处理流程,也不会触发 Sentry 的内部机制。

解决方法:

window.addEventListener("error", (event) => {
    console.error("[Global Error]", event.error || event.message);
    Sentry.captureException(event.error || event.message);
});

通过这个监听,我们就可以捕获诸如 throw new Error(...)、运行时访问空对象、空方法等同步错误。

最终效果:

把这三类监听逻辑补充进 sentry.ts,放在初始化之后,我们就可实现一个更完整、更稳定的前端异常捕获系统:

// src/sentry.ts
import * as Sentry from "@sentry/vue";
import type { App } from "vue";

export function setupSentry(app: App) {
    Sentry.init({
        // Vue 应用实例,用于自动捕获 Vue 组件错误(必须传)
        app,

        // Sentry 项目 DSN 地址,用于上报事件
        dsn: import.meta.env.VITE_SENTRY_DSN,

        // 当前运行环境(用于在 Sentry 中区分 dev / test / prod)
        environment: import.meta.env.MODE || 'development',

        // 版本号信息,用于错误定位时区分版本差异
        release: __RELEASE__,

        // 开启调试模式,开发阶段建议开启,生产建议关闭
        debug: true,

        // 性能采样率,建议开发阶段为 1.0,生产为 0.1 或更低
        tracesSampleRate: 1.0,
    });


    /**
     * Vue 组件级错误捕获(setup() / template 中的报错)
     */
    app.config.errorHandler = (err, vm, info) => {
        console.error("[Vue Error]", err, info);
        Sentry.captureException(err);
    };

    /**
     * 全局 Promise 异常(async/await 未 catch / new Promise 报错)
     * 比如:Promise.reject("失败"),或者接口请求异常未处理
     */
    window.addEventListener("unhandledrejection", (event) => {
        console.error("[Unhandled Promise Rejection]", event.reason);
        Sentry.captureException(event.reason);
    });

    /**
     * 全局同步错误(JS 报错 / try-catch 漏掉的错误)
     * 比如:throw new Error("xx"),或运行期 ReferenceError 等
     */
    window.addEventListener("error", (event) => {
        console.error("[Global Error]", event.error || event.message);
        Sentry.captureException(event.error || event.message);
    });
}

主动上报错误:捕获那些不会自动抛出的异常

虽然我们已经通过自动监听覆盖了大多数前端异常,但实际开发中还有很多“业务逻辑错误”并不会抛异常,比如:

  • 某接口返回了错误码(但没报错)
  • 登录失败、权限不足等场景
  • 某第三方 SDK 内部 silent fail
  • 某些组件逻辑执行失败,但 catch 掉了没抛

这种情况下,程序表面看起来没问题,控制台也没报错,但我们大前端其实已经背锅了!!!。要想让这些问题也被 Sentry 收到,就要靠主动上报

所以我们可以在 sentry.ts 中新增两个工具函数:

/**
 * 主动上报错误(可用于 catch 中或逻辑异常手动触发)
 * @param error 异常对象
 * @param context 可选的上下文标签(如 "登录失败")
 */
export function reportError(error: unknown, context?: string) {
    console.error("[Manual Error]", error, context);
    Sentry.captureException(error, {
        tags: context ? { context } : undefined,
    });
}

/**
 * 安全执行函数:用于包装可能抛出异常的逻辑,避免中断流程
 * @param fn 要执行的函数
 * @param context 错误发生时附加的上下文信息
 */
export function safeExecute(fn: () => void, context?: string) {
    try {
        fn();
    } catch (err) {
        reportError(err, context);
    }
}

使用示例:

场景一:接口错误但没有抛异常

const res = await fetch('/api/login');
const json = await res.json();
if (json.code !== 0) {
    reportError(new Error("登录失败"), "登录接口返回错误");
}

场景二:包一层逻辑避免程序中断

safeExecute(() => {
    // 某些不稳定逻辑
    riskyFunction();
}, "支付模块逻辑异常");

为什么我们推荐这样做呢?

  • 业务异常不一定是技术异常,但同样需要排查
  • 报错信息中带有 context 标签,可以帮助我们快速定位问题来源(登录?支付?加载首页?)
  • safeExecute 可以在保底兜错的同时确保错误不会悄无声息地被吞掉
  • 最最最重要的是防止后端甩锅!!!

补充用户上下文信息:让错误背后的“人”和“设备”清清楚楚

前面我们讲了如何捕获错误、主动上报、加行为记录等等,但我们在实际用 Sentry 看报错详情时,很可能会发现一个问题:

“虽然报错内容我看懂了,但……这是谁的错?是在什么设备上报的?他从哪里进来的?

默认情况下,Sentry 只会收集一些非常基础的信息,比如文件堆栈、报错文件、代码行号,但对于业务人员和开发来说,这些技术信息远远不够还原问题现场

比如以下这些关键字段,往往都是空的:

  • 当前用户 ID / 手机号
  • 来源渠道(扫码进入?分享页面?哪个渠道?)
  • 设备信息(iPhone 还是 Android?哪个浏览器?网络情况?)
  • 用户行为路径(点了什么?进入了哪个页面?)

所以我们需要在用户登录后或页面初始化时,手动补充这些上下文信息,帮助我们更快地定位问题。

第一步:识别设备信息(device info)

我们可以在 src/utils/deviceInfo.ts 中封装一个方法,用来识别用户使用的设备、系统、浏览器等基础信息。

export function getDeviceBrand(): string {
  const ua = navigator.userAgent.toLowerCase();
  if (ua.includes("iphone")) return "Apple";
  if (ua.includes("huawei")) return "Huawei";
  if (ua.includes("xiaomi")) return "Xiaomi";
  if (ua.includes("oppo")) return "OPPO";
  if (ua.includes("vivo")) return "Vivo";
  if (ua.includes("samsung")) return "Samsung";
  return "Unknown";
}

export function getDeviceModel(): string {
  return navigator.userAgent;
}

export function getOS(): string {
  const platform = navigator.platform.toLowerCase();
  const ua = navigator.userAgent.toLowerCase();
  if (platform.includes("win")) return "Windows";
  if (platform.includes("mac")) return "macOS";
  if (/android/.test(ua)) return "Android";
  if (/iphone|ipad|ipod/.test(ua)) return "iOS";
  if (platform.includes("linux")) return "Linux";
  return "Unknown";
}

export function getBrowser(): string {
  const ua = navigator.userAgent;
  if (ua.includes("Chrome") && !ua.includes("Edg")) return "Chrome";
  if (ua.includes("Safari") && !ua.includes("Chrome")) return "Safari";
  if (ua.includes("Firefox")) return "Firefox";
  if (ua.includes("Edg")) return "Edge";
  return "Unknown";
}

export function getNetworkType(): string {
  const nav = navigator as any;
  return nav.connection?.effectiveType || "unknown";
}

第二步:在 sentry.ts 中设置用户、设备、行为等上文

/**
 * 设置当前用户信息(在用户登录后调用)
 */
export function setSentryUserInfo(user: {
  id: string;
  username?: string;
  email?: string;
  level?: string;
  channel?: string;
  phone?: string; // 已脱敏,如 138****5678
}) {
  Sentry.setUser({
    id: user.id,
    username: user.username,
    email: user.email,
    phone: user.phone,
  });

  if (user.channel) {
    Sentry.setTag("channel", user.channel);
  }
  if (user.level) {
    Sentry.setTag("user_level", user.level);
  }
}

/**
 * 设置设备上下文信息
 */
export function setDeviceContext() {
  Sentry.setContext("device", {
    brand: getDeviceBrand(),
    model: getDeviceModel(),
    os: getOS(),
    browser: getBrowser(),
    screen: `${window.screen.width}x${window.screen.height}`,
    network: getNetworkType(),
  });
}

/**
 * 设置其他自定义标签信息
 */
export function setSentryTags(tags: Record<string, string>) {
  Object.entries(tags).forEach(([key, value]) => {
    Sentry.setTag(key, value);
  });
}

/**
 * 添加用户行为记录(Breadcrumb)
 */
export function addSentryBreadcrumb(info: {
  category: string;
  message: string;
  level?: "info" | "warning" | "error";
  data?: Record<string, any>;
}) {
  Sentry.addBreadcrumb({
    category: info.category,
    message: info.message,
    level: info.level || "info",
    data: info.data,
    timestamp: Date.now() / 1000,
  });
}

第三步:在登录成功或页面初始化时调用这些方法

// 设置模拟用户信息
setSentryUserInfo({
  id: "1000000",
  username: "中秋游客",
  channel: "midautumn-h5",
  level: "guest",
  phone: "138****5678", // 已脱敏
});

// 设置页面标签(可筛选、聚合用)
setSentryTags({
  page: "midautumn-event",
  platform: "h5",
  env: import.meta.env.MODE || "development",
});

// 设置设备上下文信息
setDeviceContext();

可选:记录用户行为路径(面包屑)

面包屑的作用,就是帮我们还原“出错前用户都干了啥”。
比如用户进入了哪个页面、点了什么按钮、提交了哪个表单,这些都可以通过 addSentryBreadcrumb() 主动记录下来。

// 用户点击“进入活动页”
addSentryBreadcrumb({
  category: "navigation",
  message: "进入订单页",
});

或者使用全局路由守卫自动记录所有页面跳转:

router.afterEach((to) => {
  addSentryBreadcrumb({
    category: "navigation",
    message: `用户进入页面:${to.name || "unknown"}`,
    data: { path: to.fullPath }, // 可在 data 里加自定义参数,比如页面路径、来源等
  });
});

第四步:验证上下文信息是否成功

比如我们写一段简单的函数,故意抛出一个错误,用来测试:

function throwError() {
  throw new Error("这是一个测试错误,用于验证 Sentry上下文 错误捕获功能。");
}

执行完后,Sentry 控制台就会收到一条错误。

image.png

我们打开错误详情页面就可以在事件顶部清晰看到:

  • 用户 ID:test_user_001
  • 浏览器、系统、环境等基础信息

image.png

再往下展开,就会看到更详细的信息

  • 用户名、手机号、地域定位
  • 浏览器版本、系统版本、网络类型等

image.png

这些信息都能帮我们快速还原出问题用户的设备和环境。

加上这些后 我们这边收到的错误报警邮件有关用户信息也清晰可见:

image.png

image.png

我们还可以加上一些“用户干了什么”的记录,比如:

addSentryBreadcrumb({
  category: "navigation",
  message: "进入中秋活动页",
});

这样在 Sentry 中就能看到这条导航事件方便我们追踪用户在报错之前点了什么、跳转了哪儿。

image.png

大概总结下

虽然设置上下文信息看似繁琐,但带给我们的价值很直接:

  • 报错信息中能看到哪个用户、在哪个页面、使用什么设备出了问题
  • 可以根据渠道、环境、等级等进行错误聚合和筛选
  • 加入用户行为记录(Breadcrumb)可以还原问题发生前的操作路径
  • 日志也能跟业务人员“对得上话”了,不再只是开发自己看懂的异常栈

那什么是 SourceMap呢,为什么我们需要它?

我们先回顾下前面测试的那个例子:

当我们在项目中手动触发一个错误,比如:

function throwError() {
  throw new Error("这是一个测试错误,用于验证 Sentry 上下文捕获功能。");
}

在本地运行时,我们Sentry 报错详情里能准确显示是哪一行、哪一段代码出了问题,甚至堆栈信息都非常清晰。

image.png

但是别忘了这只是因为我们还没打包,也就是在「开发模式」下运行,代码结构是完整的。

但是一旦上线,情况就变了

我们实际部署项目时,都会执行类似这样的构建命令:

pnpm build

这一步会把所有 JS 文件压缩、混淆,删除注释、缩短变量名、合并文件,生成的代码会变成这种形式:

function a(t){try{r(t)}catch(n){console.error(n)}}

这是浏览器喜欢的格式,但对人来说几乎没法看懂。

如果这时候线上用户触发了一个错误,Sentry 捕获的堆栈信息也会变成这样:

at chunk-abc123.js:1:1735

我们就根本不知道这段报错到底是哪个文件、哪一行,甚至连哪个函数都不知道。

这时候就需要 SourceMap 来救场了,SourceMap 就是用来建立「压缩后代码」和「原始代码」之间映射关系的文件。

只要我们在打包之后把 .map 文件上传到 Sentry,它就能根据这些映射文件,把上面那种看不懂的堆栈信息,自动还原回我们写的源码,准确标注是哪一个文件、函数、哪一行代码出了问题。

简单来说:

打包后代码压缩了,看不懂了。
我们要想让 Sentry 继续帮我们还原出错位置,必须上传对应的 .map 文件。

哪可能会问上传 SourceMap 会不会把源码暴露出去?

这个问题简单来说:

默认情况下,肯定是会暴露的。

为什么这么说呢?

因为我们每次执行 vite buildnpm run build 时,生成的 .js 文件旁边都会有一个 .js.map 文件。如果我们把整个 dist 目录原封不动部署到线上服务器,那用户只要打开浏览器、F12 控制台一看,就能直接访问:

https://我们的域名/assets/app.js.map

点开之后就是我们项目的源码结构,变量名、注释、函数逻辑一清二楚。
这就相当于:我们把项目源码白白送出去了。

那我们需要怎么做呢?

我们真正需要的,其实只是把这些 .map 文件上传给 Sentry 用于还原堆栈,而不是暴露给所有人访问。

推荐的流程是:

  1. 本地或 CI 构建时生成 .map 文件;
  2. 使用 Sentry CLI 或插件上传 .map 到 Sentry;
  3. 上传成功后,立刻删除本地的 .map 文件
  4. 最终部署时,只发布 .js 文件,不包含 .map 文件。

这样一来:

  • Sentry 能还原报错堆栈;
  • 用户访问不到 .map
  • 项目源码就不会被轻易扒走了。

总之记住一句话:SourceMap 是给 Sentry 用的,不是给别人看的。
上传它,用完就删,不要留在线上。

接下来我们就来讲讲这个上传流程怎么做:包括怎么配置、怎么自动上传、怎么验证效果。


如何配置 SourceMap 上传到 Sentry

接下来我们就开始配置一下,把前端项目打包后的 .map 文件上传到 Sentry,用于错误堆栈还原。

1. 安装依赖

我们先安装 Sentry 提供的插件和命令行工具:

pnpm add -D @sentry/vite-plugin @sentry/cli

2. 配置环境变量

为了让上传工具知道我们是谁、我们的项目在哪、发的是哪个版本,我们需要配置几个环境变量。
我们只需要在项目根目录下创建一个 .env.production 文件,把 Sentry 所需的配置写在里面即可:

# 从 Sentry 设置页面获取
VITE_SENTRY_AUTH_TOKEN=你的AuthToken
VITE_SENTRY_ORG=你的组织名
VITE_SENTRY_PROJECT=你的项目名

# 如果我们使用的是私有化部署(比如自建的 Sentry 服务器)默认就是https://sentry.io
VITE_SENTRY_URL=https://sentry.io/

# 可选:设置当前的 release 版本号,可以是 1.0.0,也可以是 git commit hash
VITE_SENTRY_RELEASE=your-project@1.0.0

这些配置只会在打包构建时(vite build)被加载,开发环境下不会生效,也不需要在 .env.development.env.local 中重复配置

其实我们可以把 VITE_SENTRY_RELEASE 设置为当前 Git 提交版本(git rev-parse --short HEAD),这样上传的 SourceMap 文件可以精准匹配线上版本,后面我们会演示如何自动设置。

3.修改 vite.config.ts

我们需要在 Vite 配置中引入 Sentry 插件,并做一些初始化设置:

import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import pkg from './package.json';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import { execSync } from 'child_process';

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
    const env = loadEnv(mode, process.cwd())

    const project = env.VITE_SENTRY_PROJECT || pkg.name
    const version = execSync('git rev-parse --short HEAD').toString().trim()
    const release = `${project}@${version}`

    return {
        plugins: [
            vue(),
            sentryVitePlugin({
                url: env.VITE_SENTRY_URL, // 如果用的是官方 sentry.io,也可以省略
                org: env.VITE_SENTRY_ORG,
                project: env.VITE_SENTRY_PROJECT,
                authToken: env.VITE_SENTRY_AUTH_TOKEN,
                release: release,
                include: './dist',
                urlPrefix: '~/',
                deleteAfterCompile: true, // 上传后删除 .map 文件
            }),
        ],
        resolve: {
            alias: {
                '@': path.resolve(__dirname, './src'),
            },
        },
        define: {
            __RELEASE__: JSON.stringify(release), // 注入全局常量
            __APP_VERSION__: JSON.stringify(pkg.version),
        },
        build: {
            sourcemap: true, // 必须开启才能生成 .map
        },
    }
})

4. 修改构建命令,删除残留 .map 文件(可选)

虽然我们配置了 deleteAfterCompile: true,但有些场景下我们可能还想手动确保 .map 不被部署,可以在 package.json 的构建命令里加上:

{
  "scripts": {
    "build": "vite build && find ./dist -name '*.map' -delete"
  }
}

这个命令会先构建项目,再扫描 dist 目录,把所有 .map 文件都删除。

这样就能确保我们部署上线时不会把 SourceMap 文件一并带上,只上传给 Sentry,确保安全

5.如何获取 Sentry 的 Auth Token?

为了让 sentry-cli 或插件能识别我们是谁,并授权上传 SourceMap,我们需要生成一个 Sentry 的 Token。下面是获取步骤:

第一步:进入 Sentry 设置页面

在左侧菜单栏,点击左下角的齿轮图标(Settings)进入设置界面。

image.png

第二步:创建新的 Token

在 Organization Tokens 页面:

  1. 点击右上角的「创建新的令牌」按钮;

  2. 会弹出一个创建表单:

    • 姓名(Name) :填一个方便识别的名字就行,比如项目名 sentry_vue3
    • 作用域(Scopes) :选择 org:ci,这个包含了我们上传 SourceMap 所需的权限(Release Creation 和 Source Map Upload);

image.png

  1. 然后点击「创建令牌」。

创建成功后,会看到类似这样的 Token:

sntrys_************Yt8k

这个 Token 就是我们要填到 .env.production 文件里的 VITE_SENTRY_AUTH_TOKEN

一点点建议

  • 这个 Token 只显示一次,请复制保存好;
  • 不要提交到 Git 仓库,建议通过 CI 环境变量注入;
  • 权限只勾选 org:ci 就够用,不建议勾选太多;

6.执行打包并验证上传效果

前面的配置完成之后,我们就可以正式打包项目,并将 .map 文件上传到 Sentry 了。

在项目根目录执行打包命令:

pnpm build

如果一切配置正确,我们会在控制台中看到类似下面的提示:

Source Map Upload Report

  Scripts
    ~/67e49c15-590c-4e25-8b79-388f91742a8e-0.js (sourcemap at index-ByQNq1yw.js.map, debug id 67e49c15-590c-4e25-8b79-388f91742a8e)

  Source Maps
    ~/67e49c15-590c-4e25-8b79-388f91742a8e-0.js.map (debug id 67e49c15-590c-4e25-8b79-388f91742a8e)

[sentry-vite-plugin] Info: Successfully uploaded source maps to Sentry

这说明:SourceMap 上传成功,Sentry 已经接收了我们打包后的 .map 文件,并关联到了对应的 release。

如果我们配置了:

deleteAfterCompile: true

或者在构建命令后手动加了 .map 清理命令,那么构建完成后,.map 文件会被删除,防止误部署到线上。

我们可以执行以下命令检查:

ls dist/**/*.map

如果终端提示为空(或者没有任何输出 / 提示文件找不到),说明 .map 文件已经被自动清理干净了。

这样,当我们的项目打包上线后,如果线上出现错误,再去 Sentry 查看报错详情时,堆栈信息就会像本地开发时一样清晰。我们就能直接看到具体的文件名、函数名和代码行号,而不会再只看到那些压缩后的文件路径和混淆变量。

有关release的说明,sourcemap 能不能生效就看它了

在使用 Sentry 的 SourceMap 功能时,有一个非常关键但又容易被忽略的前提:上传 SourceMap 时指定的 release,必须和我们代码里 Sentry SDK 初始化时的 release 完全一致。

我们可以把 release 理解为我们项目的版本号。每一次打包部署,都是一次 release。
而 Sentry 正是通过这个 release 来定位错误属于哪一次部署,以及匹配该版本下上传的 SourceMap。

如果我们打包时用了一个 release,结果初始化 SDK 时用了另一个,那抱歉,即使我们成功上传了 .map 文件,Sentry 也没法把错误堆栈还原成源码,只能告诉我们:

chunk-abc123.js:1:1729

所以,我们必须确保这两个地方的 release 保持一致。

为了防止这类问题,我采用了构建时统一生成 release 的方式,并在代码中注入一个全局变量 __RELEASE__,确保 Sentry 插件上传 SourceMap 和 SDK 初始化用的是同一个版本号。

第一步:在 vite.config.ts 中构造 release 并注入

我们读取 VITE_SENTRY_PROJECT 作为项目名,配合当前 Git 提交的哈希值,组合成一个 release,例如:

sentry_demo_vue@a1b2c3d

然后通过 define 注入到全局变量中:

define: {
  __RELEASE__: JSON.stringify(`${project}@${version}`),
}

并同时用于配置 sentryVitePlugin 插件上传:

sentryVitePlugin({
  release: `${project}@${version}`,
  ...
})

第二步:在 Sentry.init() 中使用 __RELEASE__

初始化 SDK 时,我们不再手动拼 release,而是直接使用刚才注入的变量:

Sentry.init({
  release: __RELEASE__,
  ...
})

这样无论我们在哪个环境构建,版本号都自动带上了当前的 Git 版本,既统一又不容易出错。

第三步:在 env.d.ts 中声明变量

为了让 TypeScript 识别这个全局变量,我们加了一行类型声明:

declare const __RELEASE__: string;

构建后的项目在上传 SourceMap 时自动使用当前 git 版本,Sentry SDK 上报时也使用同样的版本号。
最终在 Sentry 后台查看错误堆栈时,源码路径、函数名、行号都能完整还原。

总结一句话:Sourcemap 能不能生效,release 一致是前提。


Sentry埋点

在实际项目中,我们做埋点往往不是为了凑功能或者“形式上有就行”,而是为了更好地还原用户行为轨迹、分析问题来源、辅助产品决策、提升整体体验

我们可以从几个常见的场景来看,哪些地方用得上埋点:

1. 用户行为异常分析

有时候我们只知道某个页面报错了,但不知道用户是怎么操作的才触发这个错误

比如:
用户说“我点完某个按钮之后页面就出错了”,但后台日志只显示某个接口 500,没有更多上下文。
这种情况下,就很难还原他是从哪里点进来的、是不是页面跳转顺序有问题、是不是某个按钮点了两次才出的问题。

如果我们在关键操作、页面跳转等地方都加了埋点,那就能清楚地知道:

  • 用户先打开了哪个页面
  • 之后点了哪些按钮
  • 最后在什么操作后出现了异常

这在做线上问题定位、还原用户操作路径时非常重要,特别是配合 Sentry 这类错误监控工具中的「面包屑」功能,效果更明显。

2. 活动页面点击统计 / 转化分析

在活动运营中,埋点更是刚需。

比如一个节日活动页面上线了,运营可能会问:

  • 有多少人打开了这个页面?
  • 弹窗展示了多少次?有多少人点了“立即参与”按钮?
  • 最终提交表单的人有多少?和点击的人比,转化率是多少?

这些数据平时并不会自动记录在系统里,需要我们在页面中通过埋点记录:

  • 页面曝光
  • 按钮点击
  • 表单提交

最终才能做出转化漏斗分析,判断活动效果。如果没有埋点,就等于活动做完了,但不知道效果如何,下一次也无从优化。

3. 功能使用率评估

有一些功能上线后,看起来“做完了”,但实际有没有人用、用得多不多,其实系统本身不会告诉我们的。

比如我们上线了一个“收藏”功能、一键生成配置功能等,那我们可能会好奇:

  • 有多少用户点过这个功能?
  • 他们点的时候是在哪个页面?
  • 是不是位置太隐蔽了,大家都没发现?

这种情况下,如果我们事先加了埋点,就能清晰看到使用情况,如果发现点击量非常少,就能反过来推动:

  • 改位置
  • 加引导
  • 甚至考虑是否下线这个功能

所以很多时候,埋点也起到了“帮助产品做决策”的作用。

4. 页面性能与路径优化

更进一步的埋点,我们还可以配合页面性能分析。

比如:

  • 记录用户从首页点击“立即购买”到真正进入支付页,一共用了多久?
  • 是不是在中间某个页面加载得特别慢?

通过在关键页面加载完成时打点,再记录时间差,我们就可以发现瓶颈,进行页面或接口的性能优化。


示例:用户行为异常埋点分析

在前面的内容中,我们提到了可以通过在路由中埋点的方式,记录用户的行为路径,方便后续定位问题。比如下面这段代码:

router.afterEach((to, from) => {
  const toTitle = to.meta.title || to.name || to.fullPath
  const fromTitle = from.meta?.title || from.name || from.fullPath || '(无来源)'

  addSentryBreadcrumb({
    category: 'navigation',
    message: `从【${fromTitle}】进入【${toTitle}】`,
    data: {
      from: from.fullPath,
      to: to.fullPath,
    }
  })

  document.title = `${toTitle} - MyApp`
})

这段代码的作用很简单:每当用户路由跳转时,就自动添加一条导航相关的面包屑信息,包括来源页面和目标页面。这条信息会被 Sentry 记录下来,作为用户行为轨迹的一部分。

模拟一次异常流程

我们啦做一个简单的测试:

  1. 用户先从首页进入“关于我们”页面;
  2. 然后点击跳转到“错误页面”;
  3. 在错误页面中主动抛出一个异常。

这时候我们再打开 Sentry 后台,查看错误详情,可以看到下图中记录的错误信息:

image.png

  • 第一条是抛出的异常信息;
  • 再往下就是用户触发异常之前的行为记录,比如从“关于我们”进入“错误页面”。

查看完整的用户行为链路

为了进一步分析问题,我们可以点击 View 6 more 展开完整的面包屑日志:

image.png

在这个面板中,我们能清晰看到整个操作链路:

  1. 用户从首页进入了“关于我们”;
  2. 然后从“关于我们”跳转到了“错误页面”;
  3. 最终触发了异常。

通过这样的导航面包屑,我们就能非常直观地还原用户的操作过程,判断异常是否与某一步操作有关,从而帮助我们快速复现并定位问题。这也是“用户行为异常埋点”的一个实际应用场景。


开启用户行为录制:还原错误发生前的真实场景

虽然我们在上一节中已经通过 addSentryBreadcrumb() 记录了用户的一些关键行为路径,比如用户点击了哪些按钮、跳转了哪些页面等等,这些信息已经可以帮助我们初步还原用户操作链路

但在实际排查中,我们有时仍然会遇到这种情况:

用户反馈某个操作卡住了,但没有明确报错日志,甚至连 Sentry 都没捕捉到异常。

我们看到的面包屑记录是:“进入页面 -> 点击按钮”,中间过程缺失,还是无法判断究竟是哪一步出了问题。

这时候,如果我们能把用户当时的页面操作录像下来,就能更精准地还原整个流程,更快速定位问题。这正是 Sentry 提供的 Replay 录屏功能 的作用。

一、安装依赖

要使用 Sentry 的屏幕录制功能(Replay),我们需要安装两个包:

pnpm add @sentry/vue @sentry/replay

二、如何开启 Sentry Replay 录制功能?

我们可以通过配置 @sentry/vue 提供的 replayIntegration() 模块,来快速启用该功能。核心逻辑如下:

修改 src/sentry.ts 中的初始化代码

import * as Sentry from "@sentry/vue";
import { browserTracingIntegration, replayIntegration } from "@sentry/vue";
import type { App } from "vue";
import router from "./router";

export function setupSentry(app: App) {
  Sentry.init({
    app,
    dsn: import.meta.env.VITE_SENTRY_DSN,
    environment: import.meta.env.MODE || "development",
    release: __RELEASE__,
    debug: true,

    integrations: [
      browserTracingIntegration({ router }),
      replayIntegration({
        maskAllText: false,     // 是否对所有文本打码(false 表示原样录入)
        blockAllMedia: false    // 是否屏蔽图像、视频、SVG 等(false 表示保留媒体)
      }),
    ],

    // 性能采样设置
    tracesSampleRate: 1.0,

    // Replay 录像设置
    replaysSessionSampleRate: 0.0,   // 普通会话是否录像(设为 0 表示不录像)
    replaysOnErrorSampleRate: 1.0,   // 错误发生时是否录像(设为 1 表示100%录像)
  });

  // 省略:全局 errorHandler、Promise rejection、主动上报等逻辑...
}

三、录制策略说明

  • replaysSessionSampleRate: 控制普通用户访问页面时是否录像,建议在生产环境设为 0.0,避免过多无用录像。
  • replaysOnErrorSampleRate: 控制发生 JS 报错、Promise 拒绝等错误时是否开启录制。建议设为 1.0,即每次出错都能录像。

这样可以有效地将录像资源集中在真正出现问题的会话上,提高定位效率。

四、如何验证是否成功开启?

重启项目 → 打开控制台 → 手动触发一个 JS 报错,比如:

throw new Error("这是一个测试错误");

然后我们会在 Sentry 控制台中看到新的报错事件,这时候:

  1. 页面右侧出现一个【Replay】按钮。

image.png 2. 点击后即可播放用户在该报错发生前后的操作录像。

record-ezgif.com-optimize.gif

  1. 右下角还会有一个【See full replay】按钮,点击可以切换到完整录像页面。

image.png 同时我们也会看到报错发生前后的【Breadcrumb】面包屑操作记录,比如页面跳转、按钮点击等行为。这样就可以帮助我们从“用户视角”真正还原问题现场。

五、更多高级配置项(可选)

Sentry 提供了更丰富的配置能力,比如:

配置项 说明
maskAllText 是否对页面所有文本内容打码(防止敏感数据泄露)
blockAllMedia 是否屏蔽页面中的图片、视频、canvas 等内容
networkDetailAllowUrls 可选:采集请求详情(如 API 请求)
identifyUser() 推荐结合 Sentry.setUser(...) 在登录后设置用户 ID,方便后续排查是谁遇到了问题
Sentry.addBreadcrumb() 可选:在关键行为处手动添加操作记录(行为日志)

通过启用 @sentry/vue 提供的 Replay 功能,我们可以在出错时自动录制用户行为,大幅提升排查效率。结合已有的日志上报、用户 ID、标签与面包屑操作记录,我们能更完整地还原真实使用场景,做到“看得见问题”。


页面性能监控:不仅能看到错误,还能看到哪里慢了

我们前面已经实现了错误上报、面包屑埋点和屏幕录制,基本能定位大部分异常情况。

但有时候用户并不会报错,只是觉得页面加载慢、跳转卡顿或者某个页面总是半天才出来。这类“没报错但体验不好”的问题,如果我们没有性能监控,是很难发现的。

这个时候,我们可以启用 Sentry 的页面性能监控功能,来帮助我们记录:

  • 页面加载时间(比如首屏渲染用了多久)
  • 路由跳转耗时
  • 请求接口的耗时
  • 页面初始化过程中每一段逻辑的时间消耗

只要在初始化的时候加上 browserTracingIntegration 插件,就能自动采集这些信息。

安装依赖

如果还没安装性能监控相关的依赖,需要先补一下:

pnpm add @sentry/vue @sentry/tracing

添加性能监控配置

打开 setupSentry() 初始化方法,在 integrations 数组里加上:

import { browserTracingIntegration } from '@sentry/vue'

Sentry.init({
  // ...其他配置省略
  integrations: [
    browserTracingIntegration({
      router, // 配置 vue-router 实例,自动记录路由跳转耗时
    }),
  ],

  // 设置性能采样率(开发环境建议 1.0,生产建议 0.1)
  tracesSampleRate: 1.0,
})

这样配置之后,Sentry 就会自动帮我们记录每一次页面加载和跳转的耗时信息。

在哪里能看到这些数据?

配置好之后,进入 Sentry 控制台,点击左边导航的 “Performance” 或 “性能” 菜单,我们会看到每一次页面加载都被记录成了一条“事务(Transaction)”。

每条事务会显示页面加载过程中各个阶段的耗时情况,比如:

  • DOM 渲染用了多久
  • 路由跳转用了多久
  • 图片 / 视频 / 接口加载花了多长时间
  • 哪些任务是最耗时的

我们可以直接点进来查看详细的耗时分析图,定位“到底慢在哪里”。

上面实操部分我用的不多就不举例了,加上性能监控之后,我们就能做到:

  • 不光知道“哪里出错了”,还能知道“哪里慢了”
  • 能从页面加载细节里找到性能瓶颈
  • 帮助前端在没有用户投诉的情况下,提前发现体验问题

到这一步,整个前端监控体系就比较完整了。我们不仅能看到错误、知道用户做了什么、还能还原他们的操作流程,甚至还能判断性能好不好。


关于Sentry报警

除了错误上报、性能监控、用户行为录屏这些能力,我们还可以借助 Sentry 配置「报警通知」。

Sentry 支持我们设置一些规则,比如:某个错误首次出现、在短时间内重复出现多次、或影响的用户数量较多等情况时,自动触发告警。

目前默认是通过邮件来发送通知,配置起来也比较简单。如果我们想把报警信息同步到团队使用的工具,比如 Slack、飞书、Discord、企业微信等,也可以在后台的集成中心中,安装并配置对应的集成插件。

不过需要注意的是,部分通知渠道(比如 Webhook 或企业应用)可能需要更高的权限或私有化部署支持。如果我们只是用默认的云服务版本,那通常只支持部分渠道(比如邮件、Slack)直接接入。

总的来说,Sentry 的告警通知功能,适合和日常的监控流程搭配使用,帮助我们在异常发生的第一时间就收到提醒,快速定位并响应问题。


关于Sentry部署

前面我们演示的 Sentry 接入、错误上报、录屏、性能监控等功能,都是基于官方提供的云端版本(sentry.io)来进行的。

这种方式适合快速试用,不需要我们自己搭建,也省去了维护服务器、数据库的麻烦。但也有一些限制,比如:

  • 有些功能(如完整的 Webhook 通知、自定义数据保留时长)只有付费套餐才支持;
  • 数据存在 Sentry 的服务器上,可能不太适合对数据安全要求高的项目;
  • 无法根据我们自己的业务场景做一些深度定制。

如果项目对隐私、权限或者功能控制有更高要求,Sentry 也支持“私有化部署”。我们可以自己部署一个 Sentry 服务,所有数据保存在自己的服务器上。

实际中我们最常见的部署方式有:

  • Docker:官方提供了基于 Docker 的部署方案(develop.sentry.dev/self-hosted… Docker,就可以一键拉起整个服务;
  • 手动部署:适用于对环境要求更细的公司,比如手动安装 PostgreSQL、Redis、Kafka、Symbolicator 等组件,然后运行 Sentry;
  • 云服务商镜像:也可以从一些云平台的镜像市场上获取现成的 Sentry 部署包,比如 AWS、GCP 上可能会有官方或第三方的镜像。

不过部署 Sentry 的门槛相对还是偏高一些,对运维资源有一定要求。所以如果只是中小型项目、团队人手不多,优先使用云端版本会更加方便。

这里由于写的太多了我就不再一步一步来部署一遍了。不会部署的同学可以看下其他有关的文章跟着搞一下 其实也不难的。


其他:部署在公网时的一点小建议:加一层 Nginx + HTTPS 反向代理更稳妥

一般我们在部署 Sentry 到公网时,都会单独配置一个二级域名(比如 sentry.xxx.com),然后通过 Nginx 做一层反向代理,并加上 HTTPS 证书,确保访问安全。

如果我们只是通过 IP 地址访问,比如 http://123.123.123.123:9000,不仅会被浏览器提示“不安全连接”,而且线上项目调用时也可能因为协议不一致(HTTP 和 HTTPS 混用)被浏览器拦截,甚至影响 Sentry 的上报。

所以更推荐的做法是:

  • 配一个好记的二级域名,比如 sentry.mycompany.com
  • 用 Nginx 做一层反向代理,把外部请求转发到 Sentry 实际运行的 localhost:9000
  • 再配一个 HTTPS 证书(可以使用 Let’s Encrypt 免费证书);
  • 开启 80 → 443 自动跳转,确保用户始终走 HTTPS。

这样做不仅更安全,浏览器和 SDK 的请求也更顺畅,还能防止接口报 mixed content 错误。这个我也不讲具体操作了。反正也不难,我这篇写的太多了就不细讲了。ip部署有问题的可以看下其他相关文章 写的很棒的。


结语

回到开头,其实我们一开始其实就是在思考这个问题:
前端有没有必要接入 Sentry 这类监控平台?

其实很多团队对前端监控这块的投入确实不多,常见理由无非是“没什么错误”、"出了问题也能看到控制台"、“又不是后端服务挂了影响业务”……
但真到了线上环境,事情往往不是这么简单。

但是我们这篇内容通过实际接入和配置,大概也已经看到了 Sentry 的这些能力:

  • 可以记录详细的 JS 报错信息,堆栈定位非常清晰;
  • 通过 Source Map 还原源码,准确找到是哪一行代码报错;
  • 面包屑功能可以帮我们分析用户触发错误前的操作链路;
  • 录屏功能能完整还原用户操作过程,方便我们复现 bug;
  • 能设置错误报警通知,第一时间知道哪里出问题了;
  • 如果部署在自有服务器上,还能满足企业内部的合规需求。

这么一看,其实前端接入 Sentry 不仅“有必要”,而且是非常值得做的一件事。它不仅能提升前端排查问题的效率,还能让团队整体对线上问题的掌控力大大增强。

虽然我们一直强调用技术实现“降本增效”,能节省的就尽量省,但前端监控这类影响线上稳定性和用户体验的能力,是不能省的

很多时候,一个难复现的前端 bug,可能会花掉开发、测试、运营三方大量时间。与其靠人力去定位和还原,不如一开始就接入好监控工具,把排查和追踪的成本降下来。

如果我们是个人开发者,Sentry 提供的免费额度已经够用;如果是企业团队,用 Docker 自建也不复杂。

与其被动应对报错,不如主动掌握问题的第一现场。这,就是前端接入 Sentry 的价值所在。

昨天以前首页

Sentry 都不想接,这锅还让我背?这xx工作我不要了!

作者 洛卡卡了
2025年10月22日 14:02

前端出了问题,但总是“查无此人”

之前在一家体量不算大的公司,我们团队负责维护一个面向 C 端用户的 SaaS 系统
产品双周迭代,每次上线后,我们也会定期从客服那边收集用户的反馈。

但很快,我们就遇到一个反复出现、却又怎么也搞不定的“无语问题”。

有客户反馈说:页面点不动了,卡死了。
还有的说:点按钮没反应,像是前端死机了。
甚至有的说:页面直接报错,看不见内容。

于是我们第一时间去翻后端接口日志,结果却显示一切正常,没有报错、没有异常,连一个 500 都没有。
这时候锅自然就甩给了前端。

但前端同学也很无语:

  • 用户只说“打不开”,但没有截图、没有步骤,连系统版本都不清楚;
  • 再加上这类问题是个例居多,重现概率几乎为零;
  • 我们能做的,只剩下“老三样”:让用户清缓存、刷新页面、重新登录......
    但没办法,大多数时候,这些操作也解决不了问题。

所以就变成了前端同学每天加班查代码、调兼容性、测不同浏览器,
问题有没有解决不知道,但人是越来越累了。

终于,前端同学提了建议:

“要不我们接个前端监控吧?
比如现在很流行的Sentry,能自动上报 JS 报错的那种,定位也方便很多。”

大家一听,也确实觉得挺不错的。

但现实很快泼了冷水......


前端想接监控,运维说“没必要”

虽然sentry有云系统,但是由于项目涉及一些私有化部署和用户数据,安全层面考虑,我们必须 自建 Sentry 服务

但当前端去找运维申请服务器时,运维那边的反馈是这样的:

“公司不是已经有监控系统了吗?
用的是专门给后端接入的那套,也不是 Sentry,
前端那点问题都是个别用户的,没必要再单独整一套吧?”

再加上自建 Sentry 的门槛也不低,
至少得有一台 4 核 8G 的独立服务器,部署起来还得专人维护。
对我们这样的小团队来说,单纯为了前端监控去上这么大资源,确实没必要呀。
更何况前端监控也不像后端那样要“每天盯着看”,很多时候就是偶尔排查用一下,
这样专门搭一整套服务常驻着,确实有点浪费资源。

所以这个提议,第一次就被驳回了。
前端同学一听,也是很无奈。

但问题依旧在那:
用户报错没头绪,前端无法复现定位全靠猜。
每次出问题复现不了就让做向下兼容......
甚至要远程帮客户操作——这效率也太低了叭。

后来前端负责人出面找运维进行了友好的交流,互相问候了一下,突出了前端监控的重要性和必要性。 最终这件事才得以推进,Sentry 的前端私有化监控系统正式落地


从后端写前端,才真正理解“监控到底有多重要”

那前端到底有没有必要接入监控系统呢?

我一直是做后端的,对 Sentry 并不陌生,
接口报错、服务异常,基本都有监控能第一时间看出来。

那时候我对“前端要不要接监控”这事,其实也没啥感觉。
总觉得前端不就是报个错、页面卡一下,只要不影响数据就刷新好了。

直到后来我开始写前端,特别是做面向 C 端用户的系统之后......
这才体会到什么叫做“靠猜解决问题”。

总是有一些无语用户 拿着已经淘汰的机型 浏览器来给我提bug。
关键我还总是复现不了......

而且偏偏这些问题,总爱挑在下班时间冒出来,
刚放松一点,就又得重新打开代码,翻 log、翻源码、翻历史版本,
越查越烦躁。

也是在这种时候我我才体会到做后端的美好 有监控是真提莫好啊。


Sentry 介绍

Sentry 是一个用来监控应用错误的系统,简单来说,它能在我们代码出问题的时候第一时间记录下详细的异常信息。

Sentry主要能做哪些事

最重要的是它能帮我们做这三件事:错误上报、性能监控、自定义埋点。

第一,错误上报。这是我们最需要的功能。当前端页面报错时,比如用户打开页面出现白屏、控制台有 JS 异常、按钮点击崩溃等,Sentry 能自动把这些错误采集上来,并记录报错信息、文件名、报错堆栈、用户的操作路径、操作系统、浏览器版本等信息。更重要的是,如果我们配置了 sourcemap,还能还原成报错的源代码位置,方便我们来精准定位 bug。

第二,性能监控。Sentry 也能采集页面的关键性能指标(比如首屏加载时间、路由切换耗时、资源加载耗时等),帮助我们了解页面是否存在性能瓶颈。特别是对于 C 端项目来说,前端性能有时候影响的不只是用户体验,甚至可能直接导致功能失败。

第三,自定义埋点。除了系统自动采集的错误或性能数据,我们当然也可以手动埋点上报一些业务相关的异常,比如用户下单失败、登录异常、接口超时等场景。通过自定义事件上报,我们就可以把监控系统和我们的业务场景更紧密地结合起来,提升排查问题的效率。

Sentry部署方式

Sentry 的部署方式主要有两种:

第一种是 SaaS 模式,也就是使用官方提供的托管服务sentry.io 。这个最方便,注册账号后就可以用,不用自己部署服务器。不过它有免费额度限制,比如每天只支持最多5000 个事件(event),超了就得升级套餐,适合用来做功能验证或者小量使用。

第二种是 私有化部署,就是我们自己搭建一套 Sentry 服务,所有的数据都存在自己服务器里,安全性更高,也没有事件数的限制。但相应地,就需要占用自己的服务器资源,官方推荐至少 4 核心 8G 内存起步,还要配置 Redis、PostgreSQL、Cron 等配套组件,整体部署成本相对较高。

如果团队对数据隐私比较敏感,或者希望做更深入的自定义,那就适合选私有化部署;但如果只是前期简单接入体验功能,直接用 SaaS 模式就足够了哈。


接入 Sentry

我们以一个 Vue3 项目为例,来讲讲前端怎么接入 Sentry。

如果用的是其他前端框架,比如 React、Angular、小程序,或者是后端语言(Java、Python、Go 等),也都可以参考官方文档(docs.sentry.io)找到对应接入方式,这里就不展开讲了。

我们接下来的内容,以 Vue3 + Vite 项目为例,演示如何接入 Sentry,包括 SDK 配置、SourceMap 上传、前端错误定位等完整流程。

本次我们以 Sentry 官网的免费版本为例进行演示。

第一步 注册账号并设置语言

首先,访问 sentry.io 注册账号。注册完成后,点击页面左下角头像,进入 User Settings

在这个页面里,可以根据自己习惯调整一些基础设置,比如语言、时区、界面主题(深色 / 浅色模式)等。设置好之后,后续在查看错误信息时会更清晰,也方便排查问题。

image.png

第二步 创建项目

基础信息设置好之后,我们就可以开始创建项目了。

点击左上角的头像,选择「项目」,进入项目管理页。点击「创建项目」后,会进入如下界面:

image.png

  1. 在平台选择里,选择 VUE
  2. 设置告警频率(默认即可,后面也可以再改);
  3. 填写项目名称、分配到对应团队,最后点击「创建项目」即可。

这一步完成后,Sentry 会为我们生成一份接入代码,包含 DSN 地址、初始化方式等内容,稍后我们会用到。

image.png

第三步 接入 Sentry 到 Vue3 项目中

我们现在已经创建好项目,接下来就是把 Sentry 接入到 Vue 应用了。

1. 安装依赖

我们以 pnpm 为例(也可以用 npm 或 yarn):

pnpm add @sentry/vue

2. 新建 sentry.ts 文件src 目录下新建一个 sentry.ts 文件,用于统一初始化配置:

// src/sentry.ts
import * as Sentry from "@sentry/vue";
import type { App } from "vue";

export function setupSentry(app: App) {
    Sentry.init({
        app,

        // Sentry 项目的 DSN 地址(在项目创建页可以看到)
        dsn: import.meta.env.VITE_SENTRY_DSN,

        // 当前环境(如 dev、test、prod)
        environment: import.meta.env.MODE || 'development',

        // 版本号信息,用于错误定位时区分版本差异,使用统一注入的版本号
        release: __RELEASE__,

        // 是否开启调试(开发阶段建议为 true,线上建议关闭)
        debug: true,

        // 性能监控采样率(建议开发阶段设为 1.0)
        tracesSampleRate: 1.0,
    });
}

3. 在入口文件中初始化main.ts(或 main.js)中引入并调用 setupSentry

// main.js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import { setupSentry } from './sentry'

const app = createApp(App)

// 初始化 Sentry
setupSentry(app)

app.mount('#app')

通过上面代码可以看到我们没有直接在代码里写死 DSN 和环境,而是通过 import.meta.env.env 配置中读取,原因主要有两个:

  • 方便按环境区分配置:不同的部署环境(开发、测试、生产)通常用不同的 DSN、不同的环境名,通过 .env.development.env.production 文件分别设置,就不用每次改代码。
  • 提升安全性与灵活性:DSN 属于敏感信息,不建议直接写死在源码中。通过环境变量注入,只在打包阶段读一次,既安全又灵活,也符合前端项目的最佳实践。

这样配置完之后,Sentry 就已经接入成功了。只要页面上有 JS 报错,Sentry 就会自动帮我们捕获并上报。

为了确认是否真的生效,我们可以先写个小 demo 来验证一下。比如在某个页面或者组件里故意抛个错误,看看能不能在 Sentry 后台看到报错信息。

第三步:写个小 demo 测试一下

Sentry 配置好了,当然要测试一下它到底有没有生效。

我们可以随便找一个组件,比如首页的 Home.vue,在 onMounted 里手动抛个错:

<script setup lang="ts">
import { onMounted } from 'vue';

onMounted(() => {
  // 故意抛出一个错误,测试 Sentry 是否能捕获
  throw new Error('这是一个用于测试 Sentry 的前端错误');
});
</script>

页面一加载,就会抛出错误。刷新页面后,稍等几秒,我们就可以在 Sentry 控制台看到这条报错了(如果设置了中文,会显示为“未处理的异常”等字样)。

image.png

在 Sentry 控制台的 Issues 页面中,我们能看到刚刚上报的错误项:

页面左上方可以选择项目(如 sentry_vue3),中间能看到报错的标题和出现时间。

我们点击进去可以查看详细的错误信息。

进入错误详情页后,可以看到这次异常的基本信息,例如:

  • 错误类型:Error
  • 报错内容:这是一个用于测试 Sentry 的前端错误
  • 出错文件:src/pages/demo.vue 第 8 行
  • 浏览器、系统、设备等信息
  • 跟踪堆栈:包括错误抛出的具体位置及调用路径

image.png

往下滚动还能看到更多上下文信息,包括:

  • 请求信息:错误发生在哪个页面(比如 localhost:5174)
  • 标签信息:操作系统、浏览器、环境(我们配置的 environment 字段会显示在这里)
  • 设备信息:品牌型号、地理位置、User-Agent 等
  • 版本信息:我们在初始化时传入的 release 字段也会出现在这里

image.png

整体来看,Sentry 会自动帮我们收集并整理这次错误的上下文环境,非常方便用于问题定位,尤其是线上问题,哪怕用户无法复现,我们也能第一时间拿到关键信息。


增强 Sentry 错误捕获能力:三类常见未被默认捕获的场景补全

在前面我们已经完成了 Sentry 的接入,并通过一个简单的报错验证了它的基础功能可以正常工作。但在真实项目中,仅靠默认配置并不能捕获所有类型的前端异常。有些报错是不会自动被 Sentry 感知和上报的,如果我们不手动处理,就很容易漏掉关键错误,影响排查效率。

接下来,我们补充三种最常见的“漏网之鱼”场景,并提供对应的解决方案,让 Sentry 的异常捕获能力更完整。

场景一:Vue 组件内部报错,Sentry 没收到

常见例子:

// setup() 中写错了变量名
const a = b.c; // b 根本不存在

为什么会漏掉?
这类错误发生在 Vue 组件内部(尤其是 <script setup> 语法中),有时不会触发 Sentry 的全局监听机制。Vue 会自己处理这些错误,但如果我们没有配置 app.config.errorHandler,Sentry 是无法感知的。

解决方法:

app.config.errorHandler = (err, vm, info) => {
    console.error("[Vue Error]", err, info);
    Sentry.captureException(err);
};

这段代码放在我们的 sentry.tsSentry.init(...) 之后即可。它能确保组件中发生的报错也能正常被上报。

场景二:Promise 异常没有 catch,被悄悄吞掉

常见例子:

// 忘了写 catch
fetch('/api/data').then(res => res.json());

或者:

Promise.reject("请求失败了");

为什么会漏掉?
这些异步异常不会触发 window.onerror,也不会被 Vue 捕获。它们属于 Promise 的“未处理拒绝(unhandledrejection)”,需要手动监听。

解决方法:

window.addEventListener("unhandledrejection", (event) => {
    console.error("[Unhandled Promise Rejection]", event.reason);
    Sentry.captureException(event.reason);
});

加上这个监听后,任何未 catch 的 Promise 错误都会被补上报。

场景三:JS 同步错误没有被捕捉

常见例子:

// 直接抛出异常
throw new Error("代码报错了");

// 访问不存在的变量
console.log(notDefinedVar);

为什么会漏掉?
这种运行期错误虽然在控制台会有报错,但默认并不会进入 Vue 的错误处理流程,也不会触发 Sentry 的内部机制。

解决方法:

window.addEventListener("error", (event) => {
    console.error("[Global Error]", event.error || event.message);
    Sentry.captureException(event.error || event.message);
});

通过这个监听,我们就可以捕获诸如 throw new Error(...)、运行时访问空对象、空方法等同步错误。

最终效果:

把这三类监听逻辑补充进 sentry.ts,放在初始化之后,我们就可实现一个更完整、更稳定的前端异常捕获系统:

// src/sentry.ts
import * as Sentry from "@sentry/vue";
import type { App } from "vue";

export function setupSentry(app: App) {
    Sentry.init({
        // Vue 应用实例,用于自动捕获 Vue 组件错误(必须传)
        app,

        // Sentry 项目 DSN 地址,用于上报事件
        dsn: import.meta.env.VITE_SENTRY_DSN,

        // 当前运行环境(用于在 Sentry 中区分 dev / test / prod)
        environment: import.meta.env.MODE || 'development',

        // 版本号信息,用于错误定位时区分版本差异
        release: __RELEASE__,

        // 开启调试模式,开发阶段建议开启,生产建议关闭
        debug: true,

        // 性能采样率,建议开发阶段为 1.0,生产为 0.1 或更低
        tracesSampleRate: 1.0,
    });


    /**
     * Vue 组件级错误捕获(setup() / template 中的报错)
     */
    app.config.errorHandler = (err, vm, info) => {
        console.error("[Vue Error]", err, info);
        Sentry.captureException(err);
    };

    /**
     * 全局 Promise 异常(async/await 未 catch / new Promise 报错)
     * 比如:Promise.reject("失败"),或者接口请求异常未处理
     */
    window.addEventListener("unhandledrejection", (event) => {
        console.error("[Unhandled Promise Rejection]", event.reason);
        Sentry.captureException(event.reason);
    });

    /**
     * 全局同步错误(JS 报错 / try-catch 漏掉的错误)
     * 比如:throw new Error("xx"),或运行期 ReferenceError 等
     */
    window.addEventListener("error", (event) => {
        console.error("[Global Error]", event.error || event.message);
        Sentry.captureException(event.error || event.message);
    });
}

主动上报错误:捕获那些不会自动抛出的异常

虽然我们已经通过自动监听覆盖了大多数前端异常,但实际开发中还有很多“业务逻辑错误”并不会抛异常,比如:

  • 某接口返回了错误码(但没报错)
  • 登录失败、权限不足等场景
  • 某第三方 SDK 内部 silent fail
  • 某些组件逻辑执行失败,但 catch 掉了没抛

这种情况下,程序表面看起来没问题,控制台也没报错,但我们大前端其实已经背锅了!!!。要想让这些问题也被 Sentry 收到,就要靠主动上报

所以我们可以在 sentry.ts 中新增两个工具函数:

/**
 * 主动上报错误(可用于 catch 中或逻辑异常手动触发)
 * @param error 异常对象
 * @param context 可选的上下文标签(如 "登录失败")
 */
export function reportError(error: unknown, context?: string) {
    console.error("[Manual Error]", error, context);
    Sentry.captureException(error, {
        tags: context ? { context } : undefined,
    });
}

/**
 * 安全执行函数:用于包装可能抛出异常的逻辑,避免中断流程
 * @param fn 要执行的函数
 * @param context 错误发生时附加的上下文信息
 */
export function safeExecute(fn: () => void, context?: string) {
    try {
        fn();
    } catch (err) {
        reportError(err, context);
    }
}

使用示例:

场景一:接口错误但没有抛异常

const res = await fetch('/api/login');
const json = await res.json();
if (json.code !== 0) {
    reportError(new Error("登录失败"), "登录接口返回错误");
}

场景二:包一层逻辑避免程序中断

safeExecute(() => {
    // 某些不稳定逻辑
    riskyFunction();
}, "支付模块逻辑异常");

为什么我们推荐这样做呢?

  • 业务异常不一定是技术异常,但同样需要排查
  • 报错信息中带有 context 标签,可以帮助我们快速定位问题来源(登录?支付?加载首页?)
  • safeExecute 可以在保底兜错的同时确保错误不会悄无声息地被吞掉
  • 最最最重要的是防止后端甩锅!!!

补充用户上下文信息:让错误背后的“人”和“设备”清清楚楚

前面我们讲了如何捕获错误、主动上报、加行为记录等等,但我们在实际用 Sentry 看报错详情时,很可能会发现一个问题:

“虽然报错内容我看懂了,但……这是谁的错?是在什么设备上报的?他从哪里进来的?

默认情况下,Sentry 只会收集一些非常基础的信息,比如文件堆栈、报错文件、代码行号,但对于业务人员和开发来说,这些技术信息远远不够还原问题现场

比如以下这些关键字段,往往都是空的:

  • 当前用户 ID / 手机号
  • 来源渠道(扫码进入?分享页面?哪个渠道?)
  • 设备信息(iPhone 还是 Android?哪个浏览器?网络情况?)
  • 用户行为路径(点了什么?进入了哪个页面?)

所以我们需要在用户登录后或页面初始化时,手动补充这些上下文信息,帮助我们更快地定位问题。

第一步:识别设备信息(device info)

我们可以在 src/utils/deviceInfo.ts 中封装一个方法,用来识别用户使用的设备、系统、浏览器等基础信息。

export function getDeviceBrand(): string {
  const ua = navigator.userAgent.toLowerCase();
  if (ua.includes("iphone")) return "Apple";
  if (ua.includes("huawei")) return "Huawei";
  if (ua.includes("xiaomi")) return "Xiaomi";
  if (ua.includes("oppo")) return "OPPO";
  if (ua.includes("vivo")) return "Vivo";
  if (ua.includes("samsung")) return "Samsung";
  return "Unknown";
}

export function getDeviceModel(): string {
  return navigator.userAgent;
}

export function getOS(): string {
  const platform = navigator.platform.toLowerCase();
  const ua = navigator.userAgent.toLowerCase();
  if (platform.includes("win")) return "Windows";
  if (platform.includes("mac")) return "macOS";
  if (/android/.test(ua)) return "Android";
  if (/iphone|ipad|ipod/.test(ua)) return "iOS";
  if (platform.includes("linux")) return "Linux";
  return "Unknown";
}

export function getBrowser(): string {
  const ua = navigator.userAgent;
  if (ua.includes("Chrome") && !ua.includes("Edg")) return "Chrome";
  if (ua.includes("Safari") && !ua.includes("Chrome")) return "Safari";
  if (ua.includes("Firefox")) return "Firefox";
  if (ua.includes("Edg")) return "Edge";
  return "Unknown";
}

export function getNetworkType(): string {
  const nav = navigator as any;
  return nav.connection?.effectiveType || "unknown";
}

第二步:在 sentry.ts 中设置用户、设备、行为等上文

/**
 * 设置当前用户信息(在用户登录后调用)
 */
export function setSentryUserInfo(user: {
  id: string;
  username?: string;
  email?: string;
  level?: string;
  channel?: string;
  phone?: string; // 已脱敏,如 138****5678
}) {
  Sentry.setUser({
    id: user.id,
    username: user.username,
    email: user.email,
    phone: user.phone,
  });

  if (user.channel) {
    Sentry.setTag("channel", user.channel);
  }
  if (user.level) {
    Sentry.setTag("user_level", user.level);
  }
}

/**
 * 设置设备上下文信息
 */
export function setDeviceContext() {
  Sentry.setContext("device", {
    brand: getDeviceBrand(),
    model: getDeviceModel(),
    os: getOS(),
    browser: getBrowser(),
    screen: `${window.screen.width}x${window.screen.height}`,
    network: getNetworkType(),
  });
}

/**
 * 设置其他自定义标签信息
 */
export function setSentryTags(tags: Record<string, string>) {
  Object.entries(tags).forEach(([key, value]) => {
    Sentry.setTag(key, value);
  });
}

/**
 * 添加用户行为记录(Breadcrumb)
 */
export function addSentryBreadcrumb(info: {
  category: string;
  message: string;
  level?: "info" | "warning" | "error";
  data?: Record<string, any>;
}) {
  Sentry.addBreadcrumb({
    category: info.category,
    message: info.message,
    level: info.level || "info",
    data: info.data,
    timestamp: Date.now() / 1000,
  });
}

第三步:在登录成功或页面初始化时调用这些方法

// 设置模拟用户信息
setSentryUserInfo({
  id: "1000000",
  username: "中秋游客",
  channel: "midautumn-h5",
  level: "guest",
  phone: "138****5678", // 已脱敏
});

// 设置页面标签(可筛选、聚合用)
setSentryTags({
  page: "midautumn-event",
  platform: "h5",
  env: import.meta.env.MODE || "development",
});

// 设置设备上下文信息
setDeviceContext();

可选:记录用户行为路径(面包屑)

面包屑的作用,就是帮我们还原“出错前用户都干了啥”。
比如用户进入了哪个页面、点了什么按钮、提交了哪个表单,这些都可以通过 addSentryBreadcrumb() 主动记录下来。

// 用户点击“进入活动页”
addSentryBreadcrumb({
  category: "navigation",
  message: "进入订单页",
});

或者使用全局路由守卫自动记录所有页面跳转:

router.afterEach((to) => {
  addSentryBreadcrumb({
    category: "navigation",
    message: `用户进入页面:${to.name || "unknown"}`,
    data: { path: to.fullPath }, // 可在 data 里加自定义参数,比如页面路径、来源等
  });
});

第四步:验证上下文信息是否成功

比如我们写一段简单的函数,故意抛出一个错误,用来测试:

function throwError() {
  throw new Error("这是一个测试错误,用于验证 Sentry上下文 错误捕获功能。");
}

执行完后,Sentry 控制台就会收到一条错误。

image.png

我们打开错误详情页面就可以在事件顶部清晰看到:

  • 用户 ID:test_user_001
  • 浏览器、系统、环境等基础信息

image.png

再往下展开,就会看到更详细的信息

  • 用户名、手机号、地域定位
  • 浏览器版本、系统版本、网络类型等

image.png

这些信息都能帮我们快速还原出问题用户的设备和环境。

加上这些后 我们这边收到的错误报警邮件有关用户信息也清晰可见:

image.png

image.png

我们还可以加上一些“用户干了什么”的记录,比如:

addSentryBreadcrumb({
  category: "navigation",
  message: "进入中秋活动页",
});

这样在 Sentry 中就能看到这条导航事件方便我们追踪用户在报错之前点了什么、跳转了哪儿。

image.png

大概总结下

虽然设置上下文信息看似繁琐,但带给我们的价值很直接:

  • 报错信息中能看到哪个用户、在哪个页面、使用什么设备出了问题
  • 可以根据渠道、环境、等级等进行错误聚合和筛选
  • 加入用户行为记录(Breadcrumb)可以还原问题发生前的操作路径
  • 日志也能跟业务人员“对得上话”了,不再只是开发自己看懂的异常栈

那什么是 SourceMap呢,为什么我们需要它?

我们先回顾下前面测试的那个例子:

当我们在项目中手动触发一个错误,比如:

function throwError() {
  throw new Error("这是一个测试错误,用于验证 Sentry 上下文捕获功能。");
}

在本地运行时,我们Sentry 报错详情里能准确显示是哪一行、哪一段代码出了问题,甚至堆栈信息都非常清晰。

image.png

但是别忘了这只是因为我们还没打包,也就是在「开发模式」下运行,代码结构是完整的。

但是一旦上线,情况就变了

我们实际部署项目时,都会执行类似这样的构建命令:

pnpm build

这一步会把所有 JS 文件压缩、混淆,删除注释、缩短变量名、合并文件,生成的代码会变成这种形式:

function a(t){try{r(t)}catch(n){console.error(n)}}

这是浏览器喜欢的格式,但对人来说几乎没法看懂。

如果这时候线上用户触发了一个错误,Sentry 捕获的堆栈信息也会变成这样:

at chunk-abc123.js:1:1735

我们就根本不知道这段报错到底是哪个文件、哪一行,甚至连哪个函数都不知道。

这时候就需要 SourceMap 来救场了,SourceMap 就是用来建立「压缩后代码」和「原始代码」之间映射关系的文件。

只要我们在打包之后把 .map 文件上传到 Sentry,它就能根据这些映射文件,把上面那种看不懂的堆栈信息,自动还原回我们写的源码,准确标注是哪一个文件、函数、哪一行代码出了问题。

简单来说:

打包后代码压缩了,看不懂了。
我们要想让 Sentry 继续帮我们还原出错位置,必须上传对应的 .map 文件。

哪可能会问上传 SourceMap 会不会把源码暴露出去?

这个问题简单来说:

默认情况下,肯定是会暴露的。

为什么这么说呢?

因为我们每次执行 vite buildnpm run build 时,生成的 .js 文件旁边都会有一个 .js.map 文件。如果我们把整个 dist 目录原封不动部署到线上服务器,那用户只要打开浏览器、F12 控制台一看,就能直接访问:

https://我们的域名/assets/app.js.map

点开之后就是我们项目的源码结构,变量名、注释、函数逻辑一清二楚。
这就相当于:我们把项目源码白白送出去了。

那我们需要怎么做呢?

我们真正需要的,其实只是把这些 .map 文件上传给 Sentry 用于还原堆栈,而不是暴露给所有人访问。

推荐的流程是:

  1. 本地或 CI 构建时生成 .map 文件;
  2. 使用 Sentry CLI 或插件上传 .map 到 Sentry;
  3. 上传成功后,立刻删除本地的 .map 文件
  4. 最终部署时,只发布 .js 文件,不包含 .map 文件。

这样一来:

  • Sentry 能还原报错堆栈;
  • 用户访问不到 .map
  • 项目源码就不会被轻易扒走了。

总之记住一句话:SourceMap 是给 Sentry 用的,不是给别人看的。
上传它,用完就删,不要留在线上。

接下来我们就来讲讲这个上传流程怎么做:包括怎么配置、怎么自动上传、怎么验证效果。


如何配置 SourceMap 上传到 Sentry

接下来我们就开始配置一下,把前端项目打包后的 .map 文件上传到 Sentry,用于错误堆栈还原。

1. 安装依赖

我们先安装 Sentry 提供的插件和命令行工具:

pnpm add -D @sentry/vite-plugin @sentry/cli

2. 配置环境变量

为了让上传工具知道我们是谁、我们的项目在哪、发的是哪个版本,我们需要配置几个环境变量。
我们只需要在项目根目录下创建一个 .env.production 文件,把 Sentry 所需的配置写在里面即可:

# 从 Sentry 设置页面获取
VITE_SENTRY_AUTH_TOKEN=你的AuthToken
VITE_SENTRY_ORG=你的组织名
VITE_SENTRY_PROJECT=你的项目名

# 如果我们使用的是私有化部署(比如自建的 Sentry 服务器)默认就是https://sentry.io
VITE_SENTRY_URL=https://sentry.io/

# 可选:设置当前的 release 版本号,可以是 1.0.0,也可以是 git commit hash
VITE_SENTRY_RELEASE=your-project@1.0.0

这些配置只会在打包构建时(vite build)被加载,开发环境下不会生效,也不需要在 .env.development.env.local 中重复配置

其实我们可以把 VITE_SENTRY_RELEASE 设置为当前 Git 提交版本(git rev-parse --short HEAD),这样上传的 SourceMap 文件可以精准匹配线上版本,后面我们会演示如何自动设置。

3.修改 vite.config.ts

我们需要在 Vite 配置中引入 Sentry 插件,并做一些初始化设置:

import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import pkg from './package.json';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import { execSync } from 'child_process';

// https://vitejs.dev/config/
export default defineConfig(({ mode }) => {
    const env = loadEnv(mode, process.cwd())

    const project = env.VITE_SENTRY_PROJECT || pkg.name
    const version = execSync('git rev-parse --short HEAD').toString().trim()
    const release = `${project}@${version}`

    return {
        plugins: [
            vue(),
            sentryVitePlugin({
                url: env.VITE_SENTRY_URL, // 如果用的是官方 sentry.io,也可以省略
                org: env.VITE_SENTRY_ORG,
                project: env.VITE_SENTRY_PROJECT,
                authToken: env.VITE_SENTRY_AUTH_TOKEN,
                release: release,
                include: './dist',
                urlPrefix: '~/',
                deleteAfterCompile: true, // 上传后删除 .map 文件
            }),
        ],
        resolve: {
            alias: {
                '@': path.resolve(__dirname, './src'),
            },
        },
        define: {
            __RELEASE__: JSON.stringify(release), // 注入全局常量
            __APP_VERSION__: JSON.stringify(pkg.version),
        },
        build: {
            sourcemap: true, // 必须开启才能生成 .map
        },
    }
})

4. 修改构建命令,删除残留 .map 文件(可选)

虽然我们配置了 deleteAfterCompile: true,但有些场景下我们可能还想手动确保 .map 不被部署,可以在 package.json 的构建命令里加上:

{
  "scripts": {
    "build": "vite build && find ./dist -name '*.map' -delete"
  }
}

这个命令会先构建项目,再扫描 dist 目录,把所有 .map 文件都删除。

这样就能确保我们部署上线时不会把 SourceMap 文件一并带上,只上传给 Sentry,确保安全

5.如何获取 Sentry 的 Auth Token?

为了让 sentry-cli 或插件能识别我们是谁,并授权上传 SourceMap,我们需要生成一个 Sentry 的 Token。下面是获取步骤:

第一步:进入 Sentry 设置页面

在左侧菜单栏,点击左下角的齿轮图标(Settings)进入设置界面。

image.png

第二步:创建新的 Token

在 Organization Tokens 页面:

  1. 点击右上角的「创建新的令牌」按钮;

  2. 会弹出一个创建表单:

    • 姓名(Name) :填一个方便识别的名字就行,比如项目名 sentry_vue3
    • 作用域(Scopes) :选择 org:ci,这个包含了我们上传 SourceMap 所需的权限(Release Creation 和 Source Map Upload);

image.png

  1. 然后点击「创建令牌」。

创建成功后,会看到类似这样的 Token:

sntrys_************Yt8k

这个 Token 就是我们要填到 .env.production 文件里的 VITE_SENTRY_AUTH_TOKEN

一点点建议

  • 这个 Token 只显示一次,请复制保存好;
  • 不要提交到 Git 仓库,建议通过 CI 环境变量注入;
  • 权限只勾选 org:ci 就够用,不建议勾选太多;

6.执行打包并验证上传效果

前面的配置完成之后,我们就可以正式打包项目,并将 .map 文件上传到 Sentry 了。

在项目根目录执行打包命令:

pnpm build

如果一切配置正确,我们会在控制台中看到类似下面的提示:

Source Map Upload Report

  Scripts
    ~/67e49c15-590c-4e25-8b79-388f91742a8e-0.js (sourcemap at index-ByQNq1yw.js.map, debug id 67e49c15-590c-4e25-8b79-388f91742a8e)

  Source Maps
    ~/67e49c15-590c-4e25-8b79-388f91742a8e-0.js.map (debug id 67e49c15-590c-4e25-8b79-388f91742a8e)

[sentry-vite-plugin] Info: Successfully uploaded source maps to Sentry

这说明:SourceMap 上传成功,Sentry 已经接收了我们打包后的 .map 文件,并关联到了对应的 release。

如果我们配置了:

deleteAfterCompile: true

或者在构建命令后手动加了 .map 清理命令,那么构建完成后,.map 文件会被删除,防止误部署到线上。

我们可以执行以下命令检查:

ls dist/**/*.map

如果终端提示为空(或者没有任何输出 / 提示文件找不到),说明 .map 文件已经被自动清理干净了。

这样,当我们的项目打包上线后,如果线上出现错误,再去 Sentry 查看报错详情时,堆栈信息就会像本地开发时一样清晰。我们就能直接看到具体的文件名、函数名和代码行号,而不会再只看到那些压缩后的文件路径和混淆变量。

有关release的说明,sourcemap 能不能生效就看它了

在使用 Sentry 的 SourceMap 功能时,有一个非常关键但又容易被忽略的前提:上传 SourceMap 时指定的 release,必须和我们代码里 Sentry SDK 初始化时的 release 完全一致。

我们可以把 release 理解为我们项目的版本号。每一次打包部署,都是一次 release。
而 Sentry 正是通过这个 release 来定位错误属于哪一次部署,以及匹配该版本下上传的 SourceMap。

如果我们打包时用了一个 release,结果初始化 SDK 时用了另一个,那抱歉,即使我们成功上传了 .map 文件,Sentry 也没法把错误堆栈还原成源码,只能告诉我们:

chunk-abc123.js:1:1729

所以,我们必须确保这两个地方的 release 保持一致。

为了防止这类问题,我采用了构建时统一生成 release 的方式,并在代码中注入一个全局变量 __RELEASE__,确保 Sentry 插件上传 SourceMap 和 SDK 初始化用的是同一个版本号。

第一步:在 vite.config.ts 中构造 release 并注入

我们读取 VITE_SENTRY_PROJECT 作为项目名,配合当前 Git 提交的哈希值,组合成一个 release,例如:

sentry_demo_vue@a1b2c3d

然后通过 define 注入到全局变量中:

define: {
  __RELEASE__: JSON.stringify(`${project}@${version}`),
}

并同时用于配置 sentryVitePlugin 插件上传:

sentryVitePlugin({
  release: `${project}@${version}`,
  ...
})

第二步:在 Sentry.init() 中使用 __RELEASE__

初始化 SDK 时,我们不再手动拼 release,而是直接使用刚才注入的变量:

Sentry.init({
  release: __RELEASE__,
  ...
})

这样无论我们在哪个环境构建,版本号都自动带上了当前的 Git 版本,既统一又不容易出错。

第三步:在 env.d.ts 中声明变量

为了让 TypeScript 识别这个全局变量,我们加了一行类型声明:

declare const __RELEASE__: string;

构建后的项目在上传 SourceMap 时自动使用当前 git 版本,Sentry SDK 上报时也使用同样的版本号。
最终在 Sentry 后台查看错误堆栈时,源码路径、函数名、行号都能完整还原。

总结一句话:Sourcemap 能不能生效,release 一致是前提。


Sentry埋点

在实际项目中,我们做埋点往往不是为了凑功能或者“形式上有就行”,而是为了更好地还原用户行为轨迹、分析问题来源、辅助产品决策、提升整体体验

我们可以从几个常见的场景来看,哪些地方用得上埋点:

1. 用户行为异常分析

有时候我们只知道某个页面报错了,但不知道用户是怎么操作的才触发这个错误

比如:
用户说“我点完某个按钮之后页面就出错了”,但后台日志只显示某个接口 500,没有更多上下文。
这种情况下,就很难还原他是从哪里点进来的、是不是页面跳转顺序有问题、是不是某个按钮点了两次才出的问题。

如果我们在关键操作、页面跳转等地方都加了埋点,那就能清楚地知道:

  • 用户先打开了哪个页面
  • 之后点了哪些按钮
  • 最后在什么操作后出现了异常

这在做线上问题定位、还原用户操作路径时非常重要,特别是配合 Sentry 这类错误监控工具中的「面包屑」功能,效果更明显。

2. 活动页面点击统计 / 转化分析

在活动运营中,埋点更是刚需。

比如一个节日活动页面上线了,运营可能会问:

  • 有多少人打开了这个页面?
  • 弹窗展示了多少次?有多少人点了“立即参与”按钮?
  • 最终提交表单的人有多少?和点击的人比,转化率是多少?

这些数据平时并不会自动记录在系统里,需要我们在页面中通过埋点记录:

  • 页面曝光
  • 按钮点击
  • 表单提交

最终才能做出转化漏斗分析,判断活动效果。如果没有埋点,就等于活动做完了,但不知道效果如何,下一次也无从优化。

3. 功能使用率评估

有一些功能上线后,看起来“做完了”,但实际有没有人用、用得多不多,其实系统本身不会告诉我们的。

比如我们上线了一个“收藏”功能、一键生成配置功能等,那我们可能会好奇:

  • 有多少用户点过这个功能?
  • 他们点的时候是在哪个页面?
  • 是不是位置太隐蔽了,大家都没发现?

这种情况下,如果我们事先加了埋点,就能清晰看到使用情况,如果发现点击量非常少,就能反过来推动:

  • 改位置
  • 加引导
  • 甚至考虑是否下线这个功能

所以很多时候,埋点也起到了“帮助产品做决策”的作用。

4. 页面性能与路径优化

更进一步的埋点,我们还可以配合页面性能分析。

比如:

  • 记录用户从首页点击“立即购买”到真正进入支付页,一共用了多久?
  • 是不是在中间某个页面加载得特别慢?

通过在关键页面加载完成时打点,再记录时间差,我们就可以发现瓶颈,进行页面或接口的性能优化。


示例:用户行为异常埋点分析

在前面的内容中,我们提到了可以通过在路由中埋点的方式,记录用户的行为路径,方便后续定位问题。比如下面这段代码:

router.afterEach((to, from) => {
  const toTitle = to.meta.title || to.name || to.fullPath
  const fromTitle = from.meta?.title || from.name || from.fullPath || '(无来源)'

  addSentryBreadcrumb({
    category: 'navigation',
    message: `从【${fromTitle}】进入【${toTitle}】`,
    data: {
      from: from.fullPath,
      to: to.fullPath,
    }
  })

  document.title = `${toTitle} - MyApp`
})

这段代码的作用很简单:每当用户路由跳转时,就自动添加一条导航相关的面包屑信息,包括来源页面和目标页面。这条信息会被 Sentry 记录下来,作为用户行为轨迹的一部分。

模拟一次异常流程

我们啦做一个简单的测试:

  1. 用户先从首页进入“关于我们”页面;
  2. 然后点击跳转到“错误页面”;
  3. 在错误页面中主动抛出一个异常。

这时候我们再打开 Sentry 后台,查看错误详情,可以看到下图中记录的错误信息:

image.png

  • 第一条是抛出的异常信息;
  • 再往下就是用户触发异常之前的行为记录,比如从“关于我们”进入“错误页面”。

查看完整的用户行为链路

为了进一步分析问题,我们可以点击 View 6 more 展开完整的面包屑日志:

image.png

在这个面板中,我们能清晰看到整个操作链路:

  1. 用户从首页进入了“关于我们”;
  2. 然后从“关于我们”跳转到了“错误页面”;
  3. 最终触发了异常。

通过这样的导航面包屑,我们就能非常直观地还原用户的操作过程,判断异常是否与某一步操作有关,从而帮助我们快速复现并定位问题。这也是“用户行为异常埋点”的一个实际应用场景。


开启用户行为录制:还原错误发生前的真实场景

虽然我们在上一节中已经通过 addSentryBreadcrumb() 记录了用户的一些关键行为路径,比如用户点击了哪些按钮、跳转了哪些页面等等,这些信息已经可以帮助我们初步还原用户操作链路

但在实际排查中,我们有时仍然会遇到这种情况:

用户反馈某个操作卡住了,但没有明确报错日志,甚至连 Sentry 都没捕捉到异常。

我们看到的面包屑记录是:“进入页面 -> 点击按钮”,中间过程缺失,还是无法判断究竟是哪一步出了问题。

这时候,如果我们能把用户当时的页面操作录像下来,就能更精准地还原整个流程,更快速定位问题。这正是 Sentry 提供的 Replay 录屏功能 的作用。

一、安装依赖

要使用 Sentry 的屏幕录制功能(Replay),我们需要安装两个包:

pnpm add @sentry/vue @sentry/replay

二、如何开启 Sentry Replay 录制功能?

我们可以通过配置 @sentry/vue 提供的 replayIntegration() 模块,来快速启用该功能。核心逻辑如下:

修改 src/sentry.ts 中的初始化代码

import * as Sentry from "@sentry/vue";
import { browserTracingIntegration, replayIntegration } from "@sentry/vue";
import type { App } from "vue";
import router from "./router";

export function setupSentry(app: App) {
  Sentry.init({
    app,
    dsn: import.meta.env.VITE_SENTRY_DSN,
    environment: import.meta.env.MODE || "development",
    release: __RELEASE__,
    debug: true,

    integrations: [
      browserTracingIntegration({ router }),
      replayIntegration({
        maskAllText: false,     // 是否对所有文本打码(false 表示原样录入)
        blockAllMedia: false    // 是否屏蔽图像、视频、SVG 等(false 表示保留媒体)
      }),
    ],

    // 性能采样设置
    tracesSampleRate: 1.0,

    // Replay 录像设置
    replaysSessionSampleRate: 0.0,   // 普通会话是否录像(设为 0 表示不录像)
    replaysOnErrorSampleRate: 1.0,   // 错误发生时是否录像(设为 1 表示100%录像)
  });

  // 省略:全局 errorHandler、Promise rejection、主动上报等逻辑...
}

三、录制策略说明

  • replaysSessionSampleRate: 控制普通用户访问页面时是否录像,建议在生产环境设为 0.0,避免过多无用录像。
  • replaysOnErrorSampleRate: 控制发生 JS 报错、Promise 拒绝等错误时是否开启录制。建议设为 1.0,即每次出错都能录像。

这样可以有效地将录像资源集中在真正出现问题的会话上,提高定位效率。

四、如何验证是否成功开启?

重启项目 → 打开控制台 → 手动触发一个 JS 报错,比如:

throw new Error("这是一个测试错误");

然后我们会在 Sentry 控制台中看到新的报错事件,这时候:

  1. 页面右侧出现一个【Replay】按钮。

image.png 2. 点击后即可播放用户在该报错发生前后的操作录像。

record-ezgif.com-optimize.gif

  1. 右下角还会有一个【See full replay】按钮,点击可以切换到完整录像页面。

image.png 同时我们也会看到报错发生前后的【Breadcrumb】面包屑操作记录,比如页面跳转、按钮点击等行为。这样就可以帮助我们从“用户视角”真正还原问题现场。

五、更多高级配置项(可选)

Sentry 提供了更丰富的配置能力,比如:

配置项 说明
maskAllText 是否对页面所有文本内容打码(防止敏感数据泄露)
blockAllMedia 是否屏蔽页面中的图片、视频、canvas 等内容
networkDetailAllowUrls 可选:采集请求详情(如 API 请求)
identifyUser() 推荐结合 Sentry.setUser(...) 在登录后设置用户 ID,方便后续排查是谁遇到了问题
Sentry.addBreadcrumb() 可选:在关键行为处手动添加操作记录(行为日志)

通过启用 @sentry/vue 提供的 Replay 功能,我们可以在出错时自动录制用户行为,大幅提升排查效率。结合已有的日志上报、用户 ID、标签与面包屑操作记录,我们能更完整地还原真实使用场景,做到“看得见问题”。


页面性能监控:不仅能看到错误,还能看到哪里慢了

我们前面已经实现了错误上报、面包屑埋点和屏幕录制,基本能定位大部分异常情况。

但有时候用户并不会报错,只是觉得页面加载慢、跳转卡顿或者某个页面总是半天才出来。这类“没报错但体验不好”的问题,如果我们没有性能监控,是很难发现的。

这个时候,我们可以启用 Sentry 的页面性能监控功能,来帮助我们记录:

  • 页面加载时间(比如首屏渲染用了多久)
  • 路由跳转耗时
  • 请求接口的耗时
  • 页面初始化过程中每一段逻辑的时间消耗

只要在初始化的时候加上 browserTracingIntegration 插件,就能自动采集这些信息。

安装依赖

如果还没安装性能监控相关的依赖,需要先补一下:

pnpm add @sentry/vue @sentry/tracing

添加性能监控配置

打开 setupSentry() 初始化方法,在 integrations 数组里加上:

import { browserTracingIntegration } from '@sentry/vue'

Sentry.init({
  // ...其他配置省略
  integrations: [
    browserTracingIntegration({
      router, // 配置 vue-router 实例,自动记录路由跳转耗时
    }),
  ],

  // 设置性能采样率(开发环境建议 1.0,生产建议 0.1)
  tracesSampleRate: 1.0,
})

这样配置之后,Sentry 就会自动帮我们记录每一次页面加载和跳转的耗时信息。

在哪里能看到这些数据?

配置好之后,进入 Sentry 控制台,点击左边导航的 “Performance” 或 “性能” 菜单,我们会看到每一次页面加载都被记录成了一条“事务(Transaction)”。

每条事务会显示页面加载过程中各个阶段的耗时情况,比如:

  • DOM 渲染用了多久
  • 路由跳转用了多久
  • 图片 / 视频 / 接口加载花了多长时间
  • 哪些任务是最耗时的

我们可以直接点进来查看详细的耗时分析图,定位“到底慢在哪里”。

上面实操部分我用的不多就不举例了,加上性能监控之后,我们就能做到:

  • 不光知道“哪里出错了”,还能知道“哪里慢了”
  • 能从页面加载细节里找到性能瓶颈
  • 帮助前端在没有用户投诉的情况下,提前发现体验问题

到这一步,整个前端监控体系就比较完整了。我们不仅能看到错误、知道用户做了什么、还能还原他们的操作流程,甚至还能判断性能好不好。


关于Sentry报警

除了错误上报、性能监控、用户行为录屏这些能力,我们还可以借助 Sentry 配置「报警通知」。

Sentry 支持我们设置一些规则,比如:某个错误首次出现、在短时间内重复出现多次、或影响的用户数量较多等情况时,自动触发告警。

目前默认是通过邮件来发送通知,配置起来也比较简单。如果我们想把报警信息同步到团队使用的工具,比如 Slack、飞书、Discord、企业微信等,也可以在后台的集成中心中,安装并配置对应的集成插件。

不过需要注意的是,部分通知渠道(比如 Webhook 或企业应用)可能需要更高的权限或私有化部署支持。如果我们只是用默认的云服务版本,那通常只支持部分渠道(比如邮件、Slack)直接接入。

总的来说,Sentry 的告警通知功能,适合和日常的监控流程搭配使用,帮助我们在异常发生的第一时间就收到提醒,快速定位并响应问题。


关于Sentry部署

前面我们演示的 Sentry 接入、错误上报、录屏、性能监控等功能,都是基于官方提供的云端版本(sentry.io)来进行的。

这种方式适合快速试用,不需要我们自己搭建,也省去了维护服务器、数据库的麻烦。但也有一些限制,比如:

  • 有些功能(如完整的 Webhook 通知、自定义数据保留时长)只有付费套餐才支持;
  • 数据存在 Sentry 的服务器上,可能不太适合对数据安全要求高的项目;
  • 无法根据我们自己的业务场景做一些深度定制。

如果项目对隐私、权限或者功能控制有更高要求,Sentry 也支持“私有化部署”。我们可以自己部署一个 Sentry 服务,所有数据保存在自己的服务器上。

实际中我们最常见的部署方式有:

  • Docker:官方提供了基于 Docker 的部署方案(develop.sentry.dev/self-hosted… Docker,就可以一键拉起整个服务;
  • 手动部署:适用于对环境要求更细的公司,比如手动安装 PostgreSQL、Redis、Kafka、Symbolicator 等组件,然后运行 Sentry;
  • 云服务商镜像:也可以从一些云平台的镜像市场上获取现成的 Sentry 部署包,比如 AWS、GCP 上可能会有官方或第三方的镜像。

不过部署 Sentry 的门槛相对还是偏高一些,对运维资源有一定要求。所以如果只是中小型项目、团队人手不多,优先使用云端版本会更加方便。

这里由于写的太多了我就不再一步一步来部署一遍了。不会部署的同学可以看下其他有关的文章跟着搞一下 其实也不难的。


其他:部署在公网时的一点小建议:加一层 Nginx + HTTPS 反向代理更稳妥

一般我们在部署 Sentry 到公网时,都会单独配置一个二级域名(比如 sentry.xxx.com),然后通过 Nginx 做一层反向代理,并加上 HTTPS 证书,确保访问安全。

如果我们只是通过 IP 地址访问,比如 http://123.123.123.123:9000,不仅会被浏览器提示“不安全连接”,而且线上项目调用时也可能因为协议不一致(HTTP 和 HTTPS 混用)被浏览器拦截,甚至影响 Sentry 的上报。

所以更推荐的做法是:

  • 配一个好记的二级域名,比如 sentry.mycompany.com
  • 用 Nginx 做一层反向代理,把外部请求转发到 Sentry 实际运行的 localhost:9000
  • 再配一个 HTTPS 证书(可以使用 Let’s Encrypt 免费证书);
  • 开启 80 → 443 自动跳转,确保用户始终走 HTTPS。

这样做不仅更安全,浏览器和 SDK 的请求也更顺畅,还能防止接口报 mixed content 错误。这个我也不讲具体操作了。反正也不难,我这篇写的太多了就不细讲了。ip部署有问题的可以看下其他相关文章 写的很棒的。


结语

回到开头,其实我们一开始其实就是在思考这个问题:
前端有没有必要接入 Sentry 这类监控平台?

其实很多团队对前端监控这块的投入确实不多,常见理由无非是“没什么错误”、"出了问题也能看到控制台"、“又不是后端服务挂了影响业务”……
但真到了线上环境,事情往往不是这么简单。

但是我们这篇内容通过实际接入和配置,大概也已经看到了 Sentry 的这些能力:

  • 可以记录详细的 JS 报错信息,堆栈定位非常清晰;
  • 通过 Source Map 还原源码,准确找到是哪一行代码报错;
  • 面包屑功能可以帮我们分析用户触发错误前的操作链路;
  • 录屏功能能完整还原用户操作过程,方便我们复现 bug;
  • 能设置错误报警通知,第一时间知道哪里出问题了;
  • 如果部署在自有服务器上,还能满足企业内部的合规需求。

这么一看,其实前端接入 Sentry 不仅“有必要”,而且是非常值得做的一件事。它不仅能提升前端排查问题的效率,还能让团队整体对线上问题的掌控力大大增强。

虽然我们一直强调用技术实现“降本增效”,能节省的就尽量省,但前端监控这类影响线上稳定性和用户体验的能力,是不能省的

很多时候,一个难复现的前端 bug,可能会花掉开发、测试、运营三方大量时间。与其靠人力去定位和还原,不如一开始就接入好监控工具,把排查和追踪的成本降下来。

如果我们是个人开发者,Sentry 提供的免费额度已经够用;如果是企业团队,用 Docker 自建也不复杂。

与其被动应对报错,不如主动掌握问题的第一现场。这,就是前端接入 Sentry 的价值所在。

VitePress 文档站点:打造专业级组件文档(含交互式示例)

2025年10月20日 16:54

本文是《从零到一:构建现代化企业级 Monorepo 项目实战》系列的第六篇。这篇文章将教你如何搭建一个媲美官方文档的专业文档站点。

🎯 本文目标

  • VitePress 在 Monorepo 中的集成
  • 组件示例自动导入和渲染
  • 自定义主题和样式
  • 文档站点性能优化
  • 部署配置

📖 为什么选择 VitePress?

文档工具对比

工具 性能 Vue 支持 Markdown 扩展 学习成本 推荐指数
VuePress ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Docusaurus ⭐⭐⭐ ❌ React ⭐⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Docsify ⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐
VitePress ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

VitePress 优势:

  • ⚡ 基于 Vite,开发和构建速度极快
  • 🎨 Vue 组件可以直接在 Markdown 中使用
  • 📝 Markdown 扩展能力强大
  • 🎯 专为 Vue 生态设计

🏗️ 项目结构设计

docs/
├── .vitepress/
│   ├── config.ts              # 配置文件
│   ├── theme/
│   │   ├── index.ts           # 主题入口
│   │   ├── components/
│   │   │   └── DemoBlock.vue  # 示例容器组件
│   │   └── custom.css         # 自定义样式
│   └── plugins/
│       └── demo-container.ts  # 自定义插件
├── components/
│   ├── button/
│   │   ├── index.md           # Button 文档
│   │   └── basic.vue          # 基础示例
│   └── index.md
├── guide/
│   ├── index.md
│   └── getting-started.md
├── index.md                   # 首页
└── package.json

🚀 第一步:安装和配置

1.1 安装 VitePress

cd docs
pnpm init
pnpm add -D vitepress vue

1.2 基础配置

// docs/.vitepress/config.ts
import { defineConfig } from 'vitepress'
import { resolve } from 'path'

export default defineConfig({
  title: 'GDU Common',
  description: 'GDU 前端通用组件库和工具集',
  lang: 'zh-CN',

  // 主题配置
  themeConfig: {
    logo: '/logo.svg',

    nav: [
      { text: '指南', link: '/guide/' },
      { text: '组件', link: '/components/' },
      { text: '工具', link: '/utils/' },
    ],

    sidebar: {
      '/guide/': [
        {
          text: '开始',
          items: [
            { text: '介绍', link: '/guide/' },
            { text: '快速开始', link: '/guide/getting-started' },
          ],
        },
      ],
      '/components/': [
        {
          text: '组件',
          items: [{ text: 'Button 按钮', link: '/components/button' }],
        },
      ],
    },

    socialLinks: [{ icon: 'github', link: 'https://github.com/your-org/gdu-common' }],

    search: {
      provider: 'local', // 本地搜索
    },
  },

  // Vite 配置
  vite: {
    resolve: {
      alias: {
        '@gdu-common/ui': resolve(__dirname, '../../packages/ui/src'),
        '@gdu-common/utils': resolve(__dirname, '../../packages/utils/src'),
        '@gdu-common/shared': resolve(__dirname, '../../packages/shared/src'),
      },
    },
  },
})

1.3 添加脚本

// docs/package.json
{
  "scripts": {
    "dev": "vitepress dev",
    "build": "vitepress build",
    "preview": "vitepress preview"
  }
}

🎨 第二步:自定义主题

2.1 创建自定义样式

/* docs/.vitepress/theme/custom.css */

/* 首页渐变背景 */
:root {
  --vp-home-hero-name-color: transparent;
  --vp-home-hero-name-background: linear-gradient(120deg, #bd34fe 30%, #41d1ff);
}

/* 自定义品牌色 */
:root {
  --vp-c-brand-1: #42b983;
  --vp-c-brand-2: #35a069;
  --vp-c-brand-3: #299e5c;
}

/* 代码块样式优化 */
.vp-code-group {
  margin: 16px 0;
}

/* 表格样式 */
.vp-doc table {
  display: block;
  overflow-x: auto;
}

2.2 注册全局组件

// docs/.vitepress/theme/index.ts
import DefaultTheme from 'vitepress/theme'
import './custom.css'

export default {
  extends: DefaultTheme,
  enhanceApp({ app }) {
    // 自动注册所有示例组件
    const examples = import.meta.glob('../../components/**/*.vue', {
      eager: true,
    })

    for (const path in examples) {
      const component = examples[path].default
      const name = path.match(/\/([^/]+)\.vue$/)?.[1]
      if (name) {
        app.component(name, component)
      }
    }
  },
}

🎭 第三步:交互式组件示例

3.1 创建 Demo 容器组件

<!-- docs/.vitepress/theme/components/DemoBlock.vue -->
<template>
  <div class="demo-block">
    <div class="demo-preview">
      <slot name="demo" />
    </div>

    <div class="demo-actions">
      <button @click="toggleCode" class="toggle-code-btn">
        {{ showCode ? '隐藏代码' : '查看代码' }}
      </button>
    </div>

    <div v-show="showCode" class="demo-code">
      <slot name="code" />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'

const showCode = ref(false)
const toggleCode = () => {
  showCode.value = !showCode.value
}
</script>

<style scoped>
.demo-block {
  border: 1px solid var(--vp-c-divider);
  border-radius: 8px;
  margin: 16px 0;
}

.demo-preview {
  padding: 24px;
  background: var(--vp-c-bg-soft);
}

.demo-actions {
  padding: 12px 16px;
  border-top: 1px solid var(--vp-c-divider);
  display: flex;
  justify-content: flex-end;
}

.toggle-code-btn {
  padding: 4px 12px;
  font-size: 14px;
  border: 1px solid var(--vp-c-brand-1);
  color: var(--vp-c-brand-1);
  background: transparent;
  border-radius: 4px;
  cursor: pointer;
  transition: all 0.3s;
}

.toggle-code-btn:hover {
  background: var(--vp-c-brand-1);
  color: white;
}

.demo-code {
  border-top: 1px solid var(--vp-c-divider);
}
</style>

3.2 Markdown 自定义容器插件

// docs/.vitepress/plugins/demo-container.ts
import MarkdownIt from 'markdown-it'
import container from 'markdown-it-container'
import { readFileSync } from 'fs'
import { resolve } from 'path'

export default (md: MarkdownIt) => {
  md.use(container, 'demo', {
    validate(params: string) {
      return params.trim().match(/^demo\s+(.*)$/)
    },

    render(tokens: any[], idx: number) {
      const token = tokens[idx]
      const info = token.info.trim().match(/^demo\s+(.*)$/)

      if (tokens[idx].nesting === 1) {
        // 开始标签
        const demoPath = info?.[1] || ''
        const filePath = resolve(__dirname, '../../components', `${demoPath}.vue`)
        const source = readFileSync(filePath, 'utf-8')

        return `
          <DemoBlock>
            <template #demo>
              <${demoPath.split('/').pop()} />
            </template>
            <template #code>
              
\`\`\`vue
${source}
\`\`\`

            </template>
          </DemoBlock>
          <div>
        `
      } else {
        // 结束标签
        return '</div>\n'
      }
    },
  })
}

3.3 在 config.ts 中注册插件

import demoContainer from './plugins/demo-container'

export default defineConfig({
  markdown: {
    config: md => {
      md.use(demoContainer)
    },
  },
})

📝 第四步:编写组件文档

4.1 创建示例组件

<!-- docs/components/button/basic.vue -->
<template>
  <div class="demo">
    <Button>默认按钮</Button>
    <Button type="primary">主要按钮</Button>
    <Button type="success">成功按钮</Button>
  </div>
</template>

<script setup lang="ts">
import { Button } from '@gdu-common/ui'
</script>

<style scoped>
.demo {
  display: flex;
  gap: 12px;
}
</style>

4.2 编写文档

<!-- docs/components/button.md -->

# Button 按钮

常用的操作按钮。

## 基础用法

最简单的按钮用法。

:::demo button/basic
:::

## API

### Props

| 属性     | 说明     | 类型                                  | 默认值      |
| -------- | -------- | ------------------------------------- | ----------- |
| type     | 按钮类型 | `'default' \| 'primary' \| 'success'` | `'default'` |
| size     | 按钮尺寸 | `'small' \| 'medium' \| 'large'`      | `'medium'`  |
| loading  | 加载状态 | `boolean`                             | `false`     |
| disabled | 禁用状态 | `boolean`                             | `false`     |

### Events

| 事件名 | 说明           | 回调参数                      |
| ------ | -------------- | ----------------------------- |
| click  | 点击按钮时触发 | `(event: MouseEvent) => void` |

### Slots

| 插槽名  | 说明       |
| ------- | ---------- |
| default | 按钮内容   |
| icon    | 自定义图标 |

效果:

  • 📖 文档和代码在一起,易于维护
  • 🎨 实时渲染的组件示例
  • 💻 可以查看源代码
  • 📱 响应式设计

🎨 第五步:首页定制

5.1 英雄区域

## <!-- docs/index.md -->

layout: home

hero:
name: GDU Common
text: 企业级前端通用组件库
tagline: 基于 Vue 3 + TypeScript + Vite
image:
src: /logo.svg
alt: GDU Common
actions: - theme: brand
text: 快速开始
link: /guide/getting-started - theme: alt
text: 查看组件
link: /components/ - theme: alt  
 text: GitHub
link: https://github.com/your-org/gdu-common

---

5.2 特性展示

features:

- icon: 🎨
  title: Vue 3 组件库
  details: 基于 Vue 3 Composition API 开发,提供丰富且高质量的 UI 组件
  link: /components/
  linkText: 查看组件
- icon: ⚡
  title: Vite & Turborepo
  details: 使用 Vite 极速构建,Turborepo 智能缓存,构建速度提升 19 倍
- icon: 🔧
  title: TypeScript 优先
  details: 完整的类型定义和智能提示,提供一流的开发体验
- icon: 📦
  title: Monorepo 架构
  details: 使用 pnpm workspace + Turborepo 管理,支持多包开发和发布

5.3 自定义样式

<!-- docs/index.md -->
<style>
:root {
  --vp-home-hero-name-color: transparent;
  --vp-home-hero-name-background: linear-gradient(-45deg, #bd34fe 30%, #41d1ff);

  --vp-home-hero-image-background-image: linear-gradient(-45deg, #bd34fe50 50%, #47caff50);
  --vp-home-hero-image-filter: blur(44px);
}

.VPFeature {
  transition: all 0.3s;
}

.VPFeature:hover {
  transform: translateY(-4px);
  box-shadow: 0 12px 24px rgba(0, 0, 0, 0.1);
}
</style>

📱 第六步:高级功能

6.1 组件 Playground

<!-- docs/.vitepress/theme/components/Playground.vue -->
<template>
  <div class="playground">
    <div class="playground-preview">
      <component :is="currentComponent" v-bind="props" />
    </div>

    <div class="playground-controls">
      <h4>属性配置</h4>
      <div v-for="(value, key) in props" :key="key" class="control-item">
        <label>{{ key }}</label>
        <input v-model="props[key]" />
      </div>
    </div>

    <div class="playground-code">
      <pre><code>{{ generatedCode }}</code></pre>
    </div>
  </div>
</template>

<script setup lang="ts">
import { computed, reactive } from 'vue'
import { Button } from '@gdu-common/ui'

const props = reactive({
  type: 'primary',
  size: 'medium',
  loading: false,
})

const currentComponent = Button

const generatedCode = computed(() => {
  const propsStr = Object.entries(props)
    .map(([key, val]) => `${key}="${val}"`)
    .join(' ')
  return `<Button ${propsStr}>点击我</Button>`
})
</script>

6.2 API 表格自动生成

// docs/.vitepress/plugins/api-table.ts
import { readFileSync } from 'fs'
import { parse } from 'vue/compiler-sfc'

export function extractProps(filePath: string) {
  const source = readFileSync(filePath, 'utf-8')
  const { descriptor } = parse(source)

  // 解析 <script setup> 中的 defineProps
  const scriptContent = descriptor.script?.content || ''
  const propsMatch = scriptContent.match(/defineProps<(.+)>/)

  if (propsMatch) {
    // 提取 Props 类型定义
    return parsePropsType(propsMatch[1])
  }

  return []
}

6.3 暗黑模式支持

// config.ts
export default defineConfig({
  appearance: true, // 启用暗黑模式切换

  themeConfig: {
    // 暗黑模式下的logo
    logo: {
      light: '/logo-light.svg',
      dark: '/logo-dark.svg',
    },
  },
})
/* 暗黑模式样式 */
.dark .demo-block {
  border-color: var(--vp-c-divider);
  background: var(--vp-c-bg-soft);
}

🔧 第七步:Markdown 扩展

7.1 自定义容器

::: tip 提示
这是一个提示信息
:::

::: warning 警告
这是一个警告信息
:::

::: danger 危险
这是一个危险警告
:::

::: details 点击查看详情
这是详细内容
:::

7.2 代码组

::: code-group

\`\`\`bash [pnpm]
pnpm add @gdu-common/ui
\`\`\`

\`\`\`bash [npm]
npm install @gdu-common/ui
\`\`\`

\`\`\`bash [yarn]
yarn add @gdu-common/ui
\`\`\`

:::

7.3 代码高亮行

// 高亮特定行
\`\`\`typescript {2,4-6}
function hello() {
  const name = 'world'  // [!code highlight]

  console.log('line 4')  // [!code highlight]
  console.log('line 5')  // [!code highlight]
  console.log('line 6')  // [!code highlight]
}
\`\`\`

// 标记添加/删除
\`\`\`typescript
function hello() {
  const name = 'world'   // [!code --]
  const name = 'Vue 3'   // [!code ++]
}
\`\`\`

📊 第八步:SEO 优化

8.1 Meta 标签配置

export default defineConfig({
  head: [
    // SEO
    ['meta', { name: 'keywords', content: 'Vue3, 组件库, TypeScript, Monorepo' }],
    ['meta', { name: 'author', content: 'GDU Team' }],

    // Open Graph
    ['meta', { property: 'og:type', content: 'website' }],
    ['meta', { property: 'og:title', content: 'GDU Common' }],
    ['meta', { property: 'og:description', content: '企业级前端组件库' }],
    ['meta', { property: 'og:image', content: '/og-image.png' }],

    // Favicon
    ['link', { rel: 'icon', href: '/favicon.ico' }],
    ['link', { rel: 'apple-touch-icon', href: '/apple-touch-icon.png' }],
  ],

  // 最后更新时间
  lastUpdated: true,

  // 干净的 URL
  cleanUrls: true,
})

8.2 站点地图生成

// docs/.vitepress/config.ts
export default defineConfig({
  sitemap: {
    hostname: 'https://gdu-common.example.com',
  },
})

🚀 第九步:性能优化

9.1 构建优化

export default defineConfig({
  vite: {
    build: {
      // 代码压缩
      minify: 'terser',

      // 分包策略
      rollupOptions: {
        output: {
          manualChunks: {
            'vue-vendor': ['vue'],
            'vitepress-vendor': ['vitepress'],
          },
        },
      },

      // Chunk 大小警告
      chunkSizeWarningLimit: 1000,
    },
  },
})

9.2 图片优化

# 使用 webp 格式
docs/public/
├── logo.svg
├── hero.webp          # 首页图片
└── og-image.webp      # 社交分享图片

9.3 代码分割

// 异步加载组件
export default {
  async enhanceApp({ app }) {
    if (!import.meta.env.SSR) {
      const HeavyComponent = await import('./components/HeavyComponent.vue')
      app.component('HeavyComponent', HeavyComponent.default)
    }
  },
}

📦 第十步:部署配置

10.1 静态站点部署

export default defineConfig({
  base: '/', // 根目录部署
  // 或
  base: '/docs/', // 子目录部署

  outDir: '.vitepress/dist',
  cacheDir: '.vitepress/cache',
})

10.2 Nginx 配置

server {
    listen 80;
    server_name docs.gdu-common.com;
    root /var/www/docs/.vitepress/dist;
    index index.html;

    # SPA 路由支持
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 静态资源缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

10.3 Docker 部署

# docs/Dockerfile
FROM node:20-alpine

WORKDIR /app

# 复制文件
COPY package.json pnpm-lock.yaml ./
COPY docs ./docs
COPY packages ./packages

# 安装依赖
RUN npm install -g pnpm
RUN pnpm install --frozen-lockfile

# 构建文档
RUN pnpm --filter @gdu-common/docs build

# 使用 nginx 服务
FROM nginx:alpine
COPY --from=0 /app/docs/.vitepress/dist /usr/share/nginx/html
COPY docs/nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

💡 最佳实践

1. 文档组织结构

docs/
├── guide/                 # 指南(给新手)
│   ├── index.md          # 介绍
│   ├── getting-started.md # 快速开始
│   └── installation.md   # 安装
├── components/            # 组件文档(给使用者)
│   ├── index.md
│   └── button.md
├── utils/                 # 工具文档
│   ├── index.md
│   └── common.md
└── advanced/              # 高级用法(给进阶用户)
    ├── customization.md
    └── theme.md

2. 示例代码规范

<!-- ✅ 好的示例 -->
<template>
  <!-- 清晰的HTML结构 -->
  <div class="demo">
    <Button @click="handleClick"> 点击次数:{{ count }} </Button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Button } from '@gdu-common/ui'

// 简洁的逻辑
const count = ref(0)
const handleClick = () => {
  count.value++
}
</script>

<style scoped>
/* 必要的样式 */
.demo {
  padding: 20px;
}
</style>

3. 文档写作规范

# ✅ 清晰的标题层级

# 组件名

## 基础用法

### 示例标题

#### 细节说明

# ✅ 代码示例在前,文字说明在后

:::demo button/basic
:::

按钮组件支持多种类型...

# ✅ 完整的 API 文档

## Props

## Events

## Slots

## Methods

📈 效果展示

构建性能

# 文档构建速度
pnpm --filter @gdu-common/docs build

vitepress v1.6.4
✓ building client + server bundles...
✓ rendering pages...

build complete in 4.3s

访问性能

指标 数值 说明
FCP 0.8s 首次内容绘制
LCP 1.2s 最大内容绘制
TTI 1.5s 可交互时间
总分 95/100 Lighthouse 性能分数

用户体验

  • ✅ 本地搜索(无需服务器)
  • ✅ 暗黑模式切换
  • ✅ 响应式设计
  • ✅ 代码一键复制
  • ✅ 交互式示例

🤔 常见问题

Q1: 如何在文档中引用组件?

// 方式1:配置 alias
vite: {
  resolve: {
    alias: {
      '@gdu-common/ui': resolve(__dirname, '../../packages/ui/src')
    }
  }
}

// 方式2:直接导入
import { Button } from '@gdu-common/ui'

Q2: 如何实现组件的在线编辑?

// 集成 @vue/repl
import { Repl } from '@vue/repl'

// 提供在线编辑环境
<Repl :store="store" />

Q3: 如何自动生成 API 文档?

# 使用 vue-docgen-api
pnpm add -D vue-docgen-api

# 解析组件生成 JSON
# 渲染成 Markdown 表格

Q4: 文档部署后 404 怎么办?

// 配置正确的 base
export default defineConfig({
  base: '/', // 根目录
  // 或
  base: '/docs/', // 子目录

  cleanUrls: true, // 干净的 URL
})

🎁 完整示例

目录结构

docs/
├── .vitepress/
│   ├── config.ts
│   ├── theme/
│   │   ├── index.ts
│   │   ├── components/
│   │   │   ├── DemoBlock.vue
│   │   │   └── Playground.vue
│   │   └── custom.css
│   └── plugins/
│       └── demo-container.ts
├── components/
│   ├── button/
│   │   ├── index.md
│   │   ├── basic.vue
│   │   ├── types.vue
│   │   └── loading.vue
│   └── index.md
├── guide/
│   ├── index.md
│   └── getting-started.md
├── public/
│   ├── logo.svg
│   └── favicon.ico
├── index.md
└── package.json

🎉 总结

VitePress 文档站点的核心价值:

技术实现

  • ✅ VitePress 配置和自定义主题
  • ✅ 交互式组件示例
  • ✅ 自动代码提取和高亮
  • ✅ SEO 和性能优化

实际效果

  • 📖 专业级文档体验
  • ⚡ 4.3s 构建,1.2s 加载
  • 🎨 美观的 UI 设计
  • 📱 完美的响应式

关键配置

pnpm doc:dev     # 开发
pnpm doc:build   # 构建
pnpm doc:preview # 预览

下一篇文章,我将分享 CI/CD 自动化流程,包括:

  • GitLab CI 配置
  • 自动化测试
  • 自动化发布
  • Docker 镜像构建

🔗 系列文章


文档站点搭建完成!觉得有帮助的话点个赞支持一下! 👍

你的文档站点用的什么工具?有什么优化技巧?评论区交流! 💬

AI 时代的工程师:懂,却非懂的时代

作者 落言
2025年10月19日 12:04

作者:一名在AI时代继续写代码、继续思考的工程师
时间:2025年

文章产生于以自己的思考AI的对话


前言

以前,我们学习一个框架,总是从阅读源码开始。
逐行分析它的实现细节,找出关键设计模式,再动手模拟实现一遍。
这个过程虽然漫长,但那种“终于看懂了”的成就感,真实又深刻。
理解,不仅仅在脑中,更刻进了手指的肌肉记忆里。

但如今,一切似乎都变了。

AI 能在几秒钟内为我们剖析一个框架的架构逻辑,
总结出设计思想,甚至给出可运行的简化版本。
看似我们“更快理解”了,
却在某种意义上——更难真正理解了


懂,却非懂

AI 的解析能力极强,
当我让它帮我分析一段源码时,它能清晰地告诉我:

“这里用了观察者模式,这是为了实现解耦。”
“这部分逻辑对应的是依赖注入,用于模块扩展。”

听起来无懈可击。
可奇怪的是,我看懂了,却并没有真的懂

因为我跳过了那个最有价值的过程——
探索、验证、怀疑、推翻、再重建。
那种在迷雾中摸索的挣扎,恰恰是理解真正发生的地方。

AI 帮我节省了时间,但也剥夺了“推理路径”的体验。
于是,我的学习变成了“摄取结论”,
而不是“构建模型”。


实践的断层

过去读源码,最后一定会“自己实现一次”。
那是一个痛苦但充满乐趣的过程。
我会在调试中发现细节,在失败中加深理解。

而现在,因为AI讲得太透彻,
我常常止步于“明白了”,却没有继续去“做一遍”。
结果知识就停留在了记忆层,
没有沉到直觉层。

于是我发现——
实践能力在退化
思维变得轻浮,
知识变得像腐木一样飘浮不定。


理解源码还有意义吗?

在AI几乎可以“瞬间解释一切”的今天,
工程师还需要阅读源码吗?

我想的是:仍然需要,但目的已经变了。

我们不再需要通过阅读源码来证明“我也能写一个”。
而是要通过它来理解:

  • 为什么要这么设计?
  • 它在解决什么问题?
  • 它牺牲了什么,又换来了什么?

AI 可以告诉你“是什么”,
但只有人类能体会“为什么”。

理解源码的真正价值,
从“实现能力”转变为“判断能力”。
它让我们能看清一个系统的边界,
知道何时该用,何时该改,何时该放弃。


AI 与人类的分工:定义 vs 探索

AI 是擅长探索的存在。
它能在巨大的信息空间中,快速找出模式与路径。
但它不会定义问题——不会问“这件事是否值得做”。

人类的力量在于定义与判断
我们能在混沌的现实中抽象出目标,
能感受到什么是“优雅的架构”、什么是“合适的复杂度”。
这种基于经验、直觉与美感的决策,
是AI暂时无法替代的。

我觉得未来最强的协作方式,不是“AI替你写代码”,
而是——AI探索空间,人类定义边界。

你告诉AI:“我想要一个高可维护性的编辑器架构。”
AI 会帮你生成多种方案;
但由你来决定:

  • 哪一种最适合团队;
  • 哪一种未来更易演化;
  • 哪一种最符合你对“优雅”的理解。

这,就是新的分工。


从手艺人到系统思考者

AI 让“懂代码”这件事变得廉价。
但它反而让“懂系统”变得稀缺。

真正优秀的工程师,将从“手艺人”进化为“系统思考者”:

  • 你不必记住每个API,但要能看出抽象层的设计意图;
  • 你不必实现算法,但要能判断算法与场景的匹配度;
  • 你不必理解所有源码细节,但要能定义系统的边界与价值。

换句话说:
未来的工程师,不再是“写代码的人”,而是“设计智能系统的人”。


结语

AI 时代不是工程师的终结,
而是思考方式的重生。

我们不再是代码的搬运工,
而是智能系统的架构者。
AI 是我们的加速器,不是替代品。
它负责“多”,我们负责“对”。

理解的深度,不在于AI解释得多清楚,
而在于——我们是否还愿意亲手去验证、去怀疑、去创造。


懂代码很容易,
懂系统很难,
而真正懂自己为什么要写代码,
这,才是AI时代工程师的新修行。

Monorepo 工具大比拼:为什么我最终选择了 pnpm + Turborepo?

2025年10月17日 15:38

本文是《从零到一:构建现代化企业级 Monorepo 项目实战》系列的第二篇。上一篇我们深入分析了 Monorepo 的核心概念,这篇文章将聚焦于工具选型,带你了解主流 Monorepo 工具的优劣,以及我的选择理由。

🎯 本文目标

读完这篇文章,你将了解:

  • 主流 Monorepo 工具的对比分析
  • pnpm workspace 的核心优势
  • Turborepo 为什么这么快
  • 如何根据项目规模选择合适的工具

📖 Monorepo 工具全景图

工具分类

Monorepo 工具链
├── 包管理器层
│   ├── npm workspaces
│   ├── yarn workspaces  
│   └── pnpm workspace ⭐ (我的选择)
│
├── 构建编排层
│   ├── Lerna
│   ├── Rush
│   ├── Nx
│   └── Turborepo ⭐ (我的选择)
│
└── 一体化方案
    ├── Nx (包管理 + 构建)
    └── Rush (包管理 + 构建)

🔍 包管理器对比

npm workspaces vs yarn workspaces vs pnpm workspace

特性 npm yarn pnpm 推荐指数
安装速度 ⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐⭐ 🏆 pnpm
磁盘空间 ⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ 🏆 pnpm
依赖隔离 ⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐⭐ 🏆 pnpm
幽灵依赖 ❌ 有 ❌ 有 ✅ 无 🏆 pnpm
生态成熟度 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ 🏆 npm
学习成本 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ 🏆 npm

pnpm 的杀手级特性

1. 节省磁盘空间(最多节省 75%)

传统 npm/yarn:

# 每个项目都复制一份依赖
~/projects/
├── project-a/node_modules/lodash/  # 1MB
├── project-b/node_modules/lodash/  # 1MB  
├── project-c/node_modules/lodash/  # 1MB
└── project-d/node_modules/lodash/  # 1MB
# 总共 4MB

pnpm 的硬链接:

# 所有项目共享同一份依赖
~/.pnpm-store/
└── lodash@4.17.21/     # 1MB(只存一份)

~/projects/
├── project-a/node_modules/lodash/  → 硬链接
├── project-b/node_modules/lodash/  → 硬链接
├── project-c/node_modules/lodash/  → 硬链接
└── project-d/node_modules/lodash/  → 硬链接
# 总共只占用 1MB!

实际效果:

# 我的项目数据
npm:  1.2 GB node_modules
pnpm: 350 MB node_modules

# 节省空间:70.8%!

2. 杜绝幽灵依赖

什么是幽灵依赖?

// package.json 中没有声明 lodash
{
  "dependencies": {
    "some-package": "^1.0.0"  // some-package 依赖了 lodash
  }
}

// 但你居然可以直接用!这就是幽灵依赖
import _ from 'lodash'  // 😱 能用,但不安全!

pnpm 的严格模式:

# pnpm 会报错
Error: Cannot find module 'lodash'
# 必须显式声明依赖才能使用 ✅

3. 更快的安装速度

性能对比(安装 1000+ 依赖):

npm:  45s  ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
yarn: 32s  ▓▓▓▓▓▓▓▓▓▓▓▓
pnpm: 12s  ▓▓▓▓ ⚡

速度提升:
- 比 npm 快 3.75 倍
- 比 yarn 快 2.67 倍

🚀 构建编排工具对比

Lerna vs Rush vs Nx vs Turborepo

📊 综合对比

工具 学习曲线 性能 功能丰富度 配置复杂度 社区活跃度 推荐指数
Lerna ⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐
Rush ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐ ⭐⭐ ⭐⭐⭐
Nx ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐
Turborepo ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐

1️⃣ Lerna:老牌工具,渐显疲态

优势:

# 简单易用
lerna init
lerna bootstrap
lerna publish

劣势:

  • ❌ 性能较差(没有缓存机制)
  • ❌ 功能有限(主要是版本管理)
  • ❌ 维护不活跃(已转交给 Nx 团队)

适用场景: 小型项目,简单的版本管理需求

2️⃣ Rush:微软出品,企业级方案

优势:

// rush.json - 强大的配置能力
{
  "projects": [
    { "packageName": "ui-lib", "projectFolder": "packages/ui" }
  ],
  "pnpmOptions": {
    "strictPeerDependencies": true
  }
}

特点:

  • ✅ 严格的依赖管理
  • ✅ 企业级特性完善
  • ✅ 支持 pnpm

劣势:

  • ❌ 学习曲线陡峭
  • ❌ 配置复杂
  • ❌ 社区相对小众

适用场景: 大型企业项目,需要严格管理

3️⃣ Nx:功能最强大的方案

优势:

# 强大的代码生成
nx generate @nx/react:component Button

# 智能的依赖图分析
nx graph

# 高效的缓存
nx run-many --target=build --all

特点:

  • ✅ 功能最丰富(代码生成、依赖图、插件系统)
  • ✅ 性能优秀(智能缓存)
  • ✅ 支持多种框架(React、Vue、Angular)

劣势:

  • ❌ 学习成本高
  • ❌ 配置复杂
  • ❌ 上手门槛高

适用场景: 大型项目,需要完整的工具链支持

4️⃣ Turborepo:我的最终选择 🏆

核心优势:

📈 极致的性能

# 真实项目数据对比
                无缓存    有缓存    提升倍数
Lerna:         45s       45s       1x
Rush:          38s       12s       3.2x
Nx:            35s       2.5s      14x
Turborepo:     9s        0.45s     20x ⚡

# Turborepo 在缓存命中时快了 19-20 倍!

🎯 极简的配置

Turborepo 配置:

// turbo.json - 仅需 76 行配置
{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"]
    }
  }
}

对比 Nx 配置:

// nx.json + workspace.json + project.json
// 需要 200+ 行配置

🔥 零配置开箱即用

# 3 步搞定
npx create-turbo@latest
cd my-turborepo
pnpm install

# 就这么简单!

⚡ 智能缓存机制

# 第一次构建
pnpm build
✓ @company/utils built in 2.1s
✓ @company/ui built in 3.4s

# 代码没变化,再次构建
pnpm build
✓ @company/utils cached ⚡
✓ @company/ui cached ⚡
# 完成时间:0.3s!

🎨 我的技术选型过程

项目需求分析

GDU Common 项目特点:
✓ 4 个包(ui、utils、shared、controls-sdk)
✓ 都使用 Vue 3 + TypeScript
✓ 需要频繁联调
✓ 团队 5-8 人
✓ 需要快速迭代

决策树

开始
  ↓
需要 Monorepo? → 是
  ↓
团队规模? → 5-8人(中小型)
  ↓
是否需要代码生成? → 否
  ↓
是否需要多框架支持? → 否(只用 Vue)
  ↓
最看重什么? → 性能 + 简单
  ↓
选择:pnpm + Turborepo ✅

选型理由

1️⃣ pnpm workspace

为什么不选 npm/yarn?

# npm workspaces 的问题
npm install
# 幽灵依赖问题
# 速度较慢

# yarn workspaces 的问题  
yarn install
# 依赖提升导致的版本冲突
# PnP 模式不够成熟

# pnpm workspace 的优势
pnpm install
# ✅ 快速
# ✅ 严格
# ✅ 节省空间

pnpm-workspace.yaml 配置:

packages:
  - packages/*
  - docs
  - build

就这么简单!

2️⃣ Turborepo

为什么不选 Nx?

# Nx 的问题
- 配置复杂(3-4 个配置文件)
- 学习曲线陡
- 功能过于丰富(我们用不上)

# Turborepo 的优势
- 配置简单(1 个 turbo.json)
- 性能极致(Go 语言编写)
- 专注于构建(做好一件事)

为什么不选 Lerna?

# Lerna 的问题
- 性能差(无缓存机制)
- 功能有限
- 维护不活跃

# 数据对比
Lerna:     45s 构建
Turborepo: 9s 构建(无缓存)
          0.45s 构建(有缓存)

# 差距太明显了!

🛠️ pnpm 深度解析

核心原理:基于符号链接的依赖管理

传统的 node_modules 结构(npm/yarn)

node_modules/
├── package-a/
│   ├── index.js
│   └── node_modules/
│       └── package-b/  # 依赖被提升到顶层
├── package-b/          # 重复了!
└── package-c/

问题:

  • 依赖提升导致幽灵依赖
  • 重复的依赖占用空间

pnpm 的 content-addressable 存储

node_modules/
├── .pnpm/
│   ├── package-a@1.0.0/
│   │   └── node_modules/
│   │       ├── package-a/ → ~//.pnpm-store/...
│   │       └── package-b/ → .pnpm/package-b@1.0.0/...
│   └── package-b@1.0.0/
│       └── node_modules/
│           └── package-b/ → ~/.pnpm-store/...
└── package-a/ → .pnpm/package-a@1.0.0/...

优势:

  • ✅ 扁平的 node_modules,但严格的依赖隔离
  • ✅ 全局存储,硬链接复用
  • ✅ 避免幽灵依赖

实战配置

package.json:

{
  "name": "gdu-common",
  "private": true,
  "scripts": {
    "install": "pnpm install"
  }
}

pnpm-workspace.yaml:

packages:
  - packages/*
  - docs
  - build

.npmrc 配置:

# 使用严格的 peer 依赖检查
strict-peer-dependencies=true

# 不要幽灵依赖
shamefully-hoist=false

# 使用硬链接
link-workspace-packages=true

常用命令

# 安装依赖
pnpm install

# 添加依赖到根目录
pnpm add -w lodash-es

# 添加依赖到特定包
pnpm add vue --filter @gdu-common/ui

# 运行所有包的脚本
pnpm -r build

# 只运行特定包
pnpm --filter @gdu-common/ui build

⚡ Turborepo 深度解析

核心概念

1. 任务编排(Task Orchestration)

// turbo.json
{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],  // ^ 表示依赖包的 build
      "outputs": ["dist/**"]
    },
    "test": {
      "dependsOn": ["build"],   // 先 build 再 test
      "outputs": ["coverage/**"]
    }
  }
}

依赖图:

@gdu-common/ui:build
  ↓ 依赖
@gdu-common/utils:build
  ↓ 依赖
@gdu-common/shared:build

# Turborepo 会自动计算正确的执行顺序

2. 智能缓存(Smart Caching)

缓存键计算:

# Turborepo 会基于这些内容计算缓存键
- 源代码的哈希值
- 依赖的哈希值
- 环境变量
- 任务配置

# 任何一个变化,缓存失效

缓存命中示例:

$ pnpm build

Tasks:    4 successful, 4 total
Cached:   4 cached, 4 total ⚡
  Time:   450ms >>> FULL TURBO

# 4 个包全部命中缓存,只用了 450ms!

3. 并行执行(Parallel Execution)

# Turborepo 自动分析依赖关系,最大化并行
                时间轴 →
shared:build    ▓▓▓
                  ↓
utils:build       ▓▓▓▓
                    ↓
ui:build            ▓▓▓▓▓
docs:build        ▓▓▓▓▓▓▓▓

# shared 和 docs 可以并行
# utils 等待 shared 完成
# ui 等待 utils 完成

真实性能数据

我的项目构建性能:

场景 时间 缓存命中 说明
首次构建 9.2s 0/4 无缓存
完全缓存 450ms 4/4 ⚡ FULL TURBO
修改 1 个包 2.3s 3/4 增量构建
修改配置文件 9.1s 0/4 配置变化,缓存失效

效率提升:

  • 完全缓存时提升 20.4 倍 🚀
  • 日常开发平均提升 4-5 倍

Turborepo 配置实战

基础配置:

{
  "$schema": "https://turbo.build/schema.json",
  "ui": "tui",  // 使用终端 UI
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**", ".vitepress/dist/**"],
      "cache": true
    },
    "lint": {
      "cache": true
    },
    "lint:fix": {
      "cache": false  // 修改文件的任务不缓存
    },
    "clean": {
      "cache": false
    }
  }
}

高级配置:

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": [
        "$TURBO_DEFAULT$",
        "!{dist,build,coverage,.turbo}/**",
        "!**/*.md",
        "!**/*.test.{ts,tsx}"
      ],
      "outputs": ["dist/**"],
      "cache": true
    }
  },
  "globalDependencies": [
    ".env",
    "tsconfig.json",
    "eslint.config.js"
  ],
  "globalEnv": ["NODE_ENV", "CI"]
}

🎯 最终选择:pnpm + Turborepo

组合优势

pnpm workspace (包管理)
    +
Turborepo (构建编排)
    =
完美组合 🎉

1. pnpm 负责依赖管理

  • 快速安装
  • 节省空间
  • 严格隔离

2. Turborepo 负责构建编排

  • 智能缓存
  • 并行执行
  • 增量构建

3. 强强联合

# pnpm 快速安装依赖
pnpm install  # 12s

# Turborepo 快速构建
pnpm build    # 9s(首次) / 0.45s(缓存)

# 总时间:13s(首次) / 12.5s(缓存)

实际效果

开发效率提升:

# 传统 Multirepo 工作流
修改共享函数 → 发布 → 更新依赖 → 重新安装 → 测试
总耗时:5-10 分钟 😫

# Monorepo + Turborepo 工作流
修改共享函数 → 保存 → 自动重建 → 热更新
总耗时:2-3 秒 ⚡

# 效率提升:100-200 倍!

CI/CD 性能:

# .gitlab-ci.yml
build:
  script:
    - pnpm install          # 3s
    - pnpm build            # 9s (首次)
    # 后续 pipeline 只需 0.5s!

💡 选型建议

根据项目规模选择

小型项目(2-3 个包)

✅ 推荐:pnpm workspace
❌ 不需要:Turborepo

# 理由:包少,构建快,不需要复杂的编排

中型项目(4-10 个包)

✅ 推荐:pnpm + Turborepo
⭐ 最佳组合!

# 理由:缓存和并行构建带来明显收益

大型项目(10+ 个包)

✅ 推荐:pnpm + Turborepo
或
✅ 推荐:pnpm + Nx

# Nx 提供更多功能(代码生成、依赖图)
# Turborepo 更简单,性能更好
# 根据团队技术储备选择

根据团队特点选择

团队特点 推荐方案
前端团队,技术栈统一 pnpm + Turborepo
全栈团队,多技术栈 pnpm + Nx
大型企业,严格管理 pnpm + Rush
简单项目,快速上手 pnpm workspace

🚀 快速体验

创建一个 Turborepo 项目

# 使用官方脚手架
npx create-turbo@latest my-monorepo

# 选择 pnpm
? Which package manager do you want to use? › pnpm

# 项目结构
my-monorepo/
├── apps/
│   ├── web/
│   └── docs/
├── packages/
│   ├── ui/
│   └── eslint-config/
├── turbo.json
└── package.json

运行命令

# 安装依赖
pnpm install

# 构建所有包
pnpm build

# 查看缓存效果
pnpm build  # 第二次运行,体验闪电般的速度 ⚡

📊 成本收益分析

迁移成本

项目 学习成本 迁移时间 配置复杂度
Lerna 1 天 2-3 天
Rush 3-5 天 1-2 周
Nx 5-7 天 1-2 周
Turborepo 半天 1-2 天

长期收益

开发效率:

  • 跨包重构时间减少 80%
  • 本地构建时间减少 90%(缓存命中)
  • CI/CD 时间减少 70%

维护成本:

  • 配置文件减少 75%(统一管理)
  • 依赖冲突减少 90%
  • 版本管理复杂度降低 80%

团队协作:

  • 代码审查效率提升 50%
  • 跨项目问题定位快 3 倍
  • 新人上手时间减少 60%

🎉 总结

经过详细的对比和实践,我选择了 pnpm + Turborepo 组合,理由是:

pnpm 的三大优势

  1. - 安装速度比 npm 快 3.75 倍
  2. - 节省 70% 磁盘空间
  3. - 杜绝幽灵依赖,依赖管理更安全

Turborepo 的三大优势

  1. 极致性能 - 缓存命中时快 20 倍
  2. 🎯 极简配置 - 一个 turbo.json 搞定
  3. 🚀 零学习成本 - 半天上手,开箱即用

实际收益

  • 📈 构建速度提升 20 倍(缓存命中)
  • 💾 磁盘空间节省 70%
  • ⏱️ 开发效率提升 100 倍(跨包修改)

在下一篇文章中,我将手把手带你从零搭建一个完整的 pnpm + Turborepo 项目,包括:

  • 项目初始化
  • 包结构设计
  • 配置文件详解
  • 第一个 Hello World 包

🔗 系列文章


你的项目用的是什么 Monorepo 工具?效果如何?欢迎在评论区分享! 🙏

觉得 pnpm + Turborepo 组合不错?点个赞收藏一下,下篇文章将实战搭建! 👍

2025年了,你还在用传统的多仓库管理吗?Monorepo 架构深度解析

2025年10月17日 15:36

本文是《从零到一:构建现代化企业级 Monorepo 项目实战》系列的第一篇,将带你深入了解 Monorepo 架构的核心概念、优势劣势,以及在什么场景下应该选择它。 结尾会附上源码,开箱即用

微信图片_20251017154022_41_35.png

🎯 本文目标

读完这篇文章,你将了解:

  • 什么是 Monorepo,它解决了什么问题
  • Monorepo vs Multirepo 的详细对比
  • 如何判断你的项目是否适合 Monorepo
  • 业界大厂的 Monorepo 实践案例

📖 背景:多仓库管理的痛点

想象一下这个场景:你的团队维护着一个前端项目生态系统,包含:

my-company/
├── ui-components/     # UI 组件库
├── utils-library/     # 工具函数库
├── shared-types/      # 共享类型定义
├── mobile-app/        # 移动端应用
├── admin-dashboard/   # 管理后台
└── marketing-site/    # 官网

每个项目都有自己的 Git 仓库,看起来很整洁,但实际开发中你会遇到这些问题:

😫 依赖地狱

# 在 mobile-app 中更新 ui-components
cd ui-components
git pull
npm version patch
npm publish

cd ../mobile-app
npm update ui-components  # 😱 版本不匹配!
npm install              # 😱 又要重新安装!

🔄 重复配置

每个仓库都需要:

  • package.json 配置
  • ESLint、Prettier、TypeScript 配置
  • CI/CD 流水线配置
  • Git hooks 配置

6个仓库 = 6套重复配置 = 维护噩梦!

🚫 跨仓库重构困难

// 想要重构一个在多个包中使用的接口?
interface UserInfo {
  id: number
  name: string
  email: string // 想改成 emailAddress
}

你需要:

  1. shared-types 中修改接口
  2. 发布新版本
  3. ui-components 中更新依赖
  4. mobile-app 中更新依赖
  5. admin-dashboard 中更新依赖
  6. 测试所有项目...

一个简单的重构变成了跨仓库的大工程!

🏗️ Monorepo:一个仓库管理所有项目

什么是 Monorepo?

Monorepo(单一仓库)是一种项目管理策略,将多个相关的项目或包存储在同一个 Git 仓库中。

my-company-monorepo/
├── packages/
│   ├── ui-components/
│   ├── utils-library/
│   ├── shared-types/
│   ├── mobile-app/
│   ├── admin-dashboard/
│   └── marketing-site/
├── tools/              # 共享工具
├── docs/               # 统一文档
└── package.json        # 根配置

核心理念

"一个仓库,多个项目,统一管理,独立发布"

⚖️ Monorepo vs Multirepo 深度对比

📊 对比表格

维度 Monorepo Multirepo 胜者
代码共享 ✅ 直接引用,实时同步 ❌ 需要发布-安装流程 🏆 Monorepo
依赖管理 ✅ 统一版本,避免冲突 ❌ 版本碎片化 🏆 Monorepo
重构效率 ✅ 原子性操作,一次完成 ❌ 跨仓库协调复杂 🏆 Monorepo
构建速度 ✅ 增量构建,智能缓存 ❌ 重复构建 🏆 Monorepo
代码审查 ✅ 跨项目变更一个 PR ❌ 多个 PR 难以关联 🏆 Monorepo
工具配置 ✅ 一套配置,全局生效 ❌ 每个仓库重复配置 🏆 Monorepo
权限管理 ❌ 粒度较粗 ✅ 精细化权限控制 🏆 Multirepo
仓库大小 ❌ 单个仓库较大 ✅ 仓库小,克隆快 🏆 Multirepo
团队独立性 ❌ 需要协调 ✅ 团队完全独立 🏆 Multirepo

🎯 详细分析

1. 代码共享:Monorepo 的最大优势

Multirepo 的痛苦:

# 修改共享组件需要 4 步
cd shared-components
# 1. 修改代码
# 2. 发布新版本
npm version patch && npm publish

cd ../main-app
# 3. 更新依赖
npm update shared-components
# 4. 测试验证
npm test

Monorepo 的优雅:

// 直接引用,实时生效
import { Button } from '../shared-components/src/Button'
// 修改 Button 组件,所有引用立即生效!

2. 依赖管理:版本统一的威力

Multirepo 的版本地狱:

// mobile-app/package.json
"dependencies": {
  "lodash": "^4.17.20",
  "shared-utils": "^1.2.3"
}

// admin-dashboard/package.json
"dependencies": {
  "lodash": "^4.17.21",  // 😱 版本不一致!
  "shared-utils": "^1.2.1"  // 😱 版本落后!
}

Monorepo 的统一管理:

// 根 package.json
"devDependencies": {
  "lodash": "^4.17.21"  // ✅ 全局统一版本
}

3. 重构效率:原子性操作

场景: 重命名一个在多个包中使用的函数

Multirepo:

# 需要跨多个仓库协调
1. 在 utils 仓库中重命名函数
2. 发布新版本
3. 在 app-a 仓库中更新引用
4. 在 app-b 仓库中更新引用
5. 在 app-c 仓库中更新引用
# 如果某个步骤出错,整个系统可能不一致!

Monorepo:

# 一次性重构,原子操作
1. 全局搜索替换函数名
2. 一次 commit 完成所有修改
3. 所有包保持一致性

🏢 业界实践案例

Google:Monorepo 的鼻祖

  • 规模: 20亿行代码,9万个文件
  • 工具: 自研的 Blaze/Bazel
  • 效果: 统一的构建系统,高效的代码共享

Facebook/Meta:React 生态

facebook/react/
├── packages/
│   ├── react/
│   ├── react-dom/
│   ├── react-reconciler/
│   ├── scheduler/
│   └── shared/
  • 工具: Yarn Workspaces + Lerna
  • 效果: React 各个包版本同步,开发效率极高

Microsoft:TypeScript + VS Code

microsoft/vscode/
├── src/
├── extensions/
├── build/
└── test/
  • 规模: 100+ 扩展,统一管理
  • 效果: 扩展之间高度集成,用户体验一致

Vercel:现代化工具链

vercel/turbo/
├── crates/          # Rust 代码
├── packages/        # JavaScript 包
│   ├── turbo/
│   ├── eslint-config-turbo/
│   └── create-turbo/
  • 工具: 自研 Turborepo
  • 效果: 极致的构建性能优化

🤔 Monorepo 适合你吗?

✅ 适合 Monorepo 的场景

1. 组件库 + 应用项目

project/
├── packages/
│   ├── ui-components/    # 组件库
│   ├── utils/           # 工具库
│   ├── web-app/         # Web 应用
│   └── mobile-app/      # 移动应用

2. 微前端架构

micro-frontend/
├── packages/
│   ├── shell/           # 主应用
│   ├── module-user/     # 用户模块
│   ├── module-order/    # 订单模块
│   └── shared/          # 共享资源

3. 全栈项目

fullstack/
├── packages/
│   ├── frontend/        # 前端应用
│   ├── backend/         # 后端 API
│   ├── shared-types/    # 共享类型
│   └── database/        # 数据库脚本

❌ 不适合 Monorepo 的场景

1. 完全独立的项目

# 这些项目没有任何关联,强行放在一起没有意义
company/
├── e-commerce-site/     # 电商网站
├── blog-system/         # 博客系统
└── game-platform/       # 游戏平台

2. 不同技术栈的项目

# 技术栈差异太大,共享价值有限
mixed-tech/
├── react-web/           # React 项目
├── vue-admin/           # Vue 项目
├── flutter-mobile/      # Flutter 项目
└── python-api/          # Python 后端

3. 大型团队,严格权限控制

  • 团队规模 > 50人
  • 需要严格的代码访问权限
  • 不同项目的发布周期差异巨大

📊 决策矩阵

用这个表格来评估你的项目:

评估维度 权重 你的项目得分 (1-5) 加权得分
代码共享需求 25% ? ?
团队协作紧密度 20% ? ?
技术栈一致性 15% ? ?
发布周期同步性 15% ? ?
项目关联度 15% ? ?
团队规模适中 10% ? ?

评分标准:

  • 4-5分:强烈推荐 Monorepo
  • 3-4分:可以考虑 Monorepo
  • 1-3分:建议继续使用 Multirepo

🚀 我的项目:GDU Common 的选择

项目背景

我们团队需要开发一套企业级前端解决方案:

  • UI 组件库(给多个项目使用)
  • 工具函数库(通用工具)
  • 飞控 SDK(专业领域)
  • 文档站点(统一文档)

为什么选择 Monorepo?

✅ 强关联性

// UI 组件库依赖工具库
import { formatDate } from '@gdu-common/utils'

// 飞控 SDK 使用共享类型
import { BaseResponse } from '@gdu-common/shared'

✅ 统一发布

# 一个命令发布所有相关包
pnpm changeset publish

✅ 开发效率

# 修改工具函数,UI 组件立即生效,无需发布-安装流程

评估结果

维度 得分 说明
代码共享需求 5/5 UI 组件大量使用工具函数
团队协作紧密度 4/5 同一个前端团队维护
技术栈一致性 5/5 都是 Vue 3 + TypeScript
发布周期同步性 4/5 需要协调发布
项目关联度 5/5 高度关联
团队规模适中 5/5 5-10人团队

总分:4.6/5 → 非常适合 Monorepo!

🎭 Monorepo 的两面性

🌟 优势详解

1. 代码复用最大化

// 在 Monorepo 中,这样的复用变得非常简单
// packages/ui/src/Button.vue
import { throttle } from '@company/utils'
import { theme } from '@company/shared'

export default {
  setup() {
    const handleClick = throttle(() => {
      // 使用共享的节流函数
    }, 300)

    return { handleClick }
  },
}

2. 原子性提交

git log --oneline
abc1234 feat: 添加用户头像组件,更新相关工具函数和类型定义
# 一个 commit 包含了跨多个包的完整功能

3. 统一的工具链

// 根目录的 package.json
{
  "devDependencies": {
    "eslint": "^9.0.0", // 所有包共享
    "prettier": "^3.0.0", // 所有包共享
    "typescript": "^5.0.0" // 所有包共享
  }
}

4. 更好的可见性

# 一个命令查看所有项目状态
pnpm list -r --depth=0

# 一个命令运行所有测试
pnpm test -r

⚠️ 挑战和解决方案

1. 仓库体积大

问题: 单个仓库包含所有代码,克隆时间长

解决方案:

# 使用 Git 的部分克隆
git clone --filter=blob:none <repo-url>

# 或者使用 sparse-checkout 只检出需要的目录
git sparse-checkout set packages/ui-components

2. 构建时间长

问题: 需要构建多个项目

解决方案:

# 使用 Turborepo 的智能缓存
pnpm build  # 只构建变化的包

# 并行构建
turbo run build --parallel

3. 权限控制复杂

问题: 无法对不同包设置不同权限

解决方案:

  • 使用 GitHub 的 CODEOWNERS 文件
  • 配置分支保护规则
  • 使用 CI/CD 控制发布权限

4. CI/CD 复杂度

问题: 需要检测哪些包发生了变化

解决方案:

# 使用 Turborepo 的变更检测
- name: Build changed packages
  run: turbo run build --filter=[HEAD^1]

🌍 业界最佳实践

Google 的经验

"我们发现,当项目之间有共享代码时,Monorepo 能显著提高开发效率。但关键是要有好的工具支持。"

核心实践:

  • 统一的构建系统(Bazel)
  • 严格的代码审查流程
  • 自动化测试覆盖

Facebook 的教训

"早期我们也尝试过 Multirepo,但跨仓库的依赖管理成为了开发效率的瓶颈。"

关键改进:

  • 引入 Yarn Workspaces
  • 开发 Lerna 工具
  • 建立统一的发布流程

Microsoft 的平衡

"我们在 VS Code 项目中使用 Monorepo,但 Office 套件仍然使用 Multirepo。选择取决于项目特性。"

决策因素:

  • 项目关联度
  • 团队结构
  • 发布频率

🎯 实际案例:我的决策过程

项目需求分析

我们的 GDU Common 项目需要:

  1. UI 组件库 - 给多个业务项目使用
  2. 工具函数库 - 通用工具,组件库会使用
  3. 飞控 SDK - 专业领域,相对独立
  4. 文档站点 - 展示所有包的使用方法

关键决策点

✅ 为什么选择 Monorepo?

  1. 高度关联

    // UI 组件大量使用工具函数
    import { formatDate, debounce } from '@gdu-common/utils'
    
  2. 统一技术栈

    • 都是 Vue 3 + TypeScript
    • 都使用 Vite 构建
    • 共享相同的代码规范
  3. 同步发布需求

    # 组件库更新时,工具库也可能需要更新
    # 一次发布,保证版本一致性
    
  4. 团队规模适中

    • 5-8人的前端团队
    • 紧密协作,沟通成本低

❌ 为什么不选择 Multirepo?

  1. 跨仓库重构成本高

    • 修改一个共享接口需要更新多个仓库
    • 版本同步复杂
  2. 重复配置维护成本

    • 4个包 = 4套 ESLint/Prettier 配置
    • CI/CD 配置重复
  3. 开发体验差

    • 本地开发需要 npm link
    • 调试跨包问题困难

🔮 Monorepo 的未来趋势

1. 工具生态成熟

  • Turborepo、Nx、Rush 等专业工具
  • 各大云平台的 Monorepo 支持
  • IDE 的原生支持越来越好

2. 大厂推动

  • Google、Facebook、Microsoft 的成功实践
  • 开源项目的广泛采用
  • 最佳实践的不断完善

3. 开发体验优化

  • 更智能的缓存策略
  • 更快的增量构建
  • 更好的 IDE 集成

💡 关键建议

1. 从小开始

# 不要一开始就搭建复杂的 Monorepo
# 从 2-3 个相关包开始
my-monorepo/
├── packages/
│   ├── core/
│   ├── ui/
│   └── utils/

2. 选择合适的工具

  • 小型项目:pnpm workspace 就够了
  • 中型项目:+ Turborepo 加速构建
  • 大型项目:+ Nx 提供更多功能

3. 建立规范

  • 包命名规范
  • 版本管理策略
  • 代码审查流程
  • 发布流程规范

4. 渐进式迁移

# 不要一次性迁移所有项目
# 先迁移关联度最高的 2-3 个包
# 验证效果后再逐步扩展

🎉 总结

Monorepo 不是银弹,但在合适的场景下,它能显著提高开发效率和代码质量。关键是:

  1. 正确评估项目特性 - 使用决策矩阵
  2. 选择合适的工具 - 根据项目规模选择
  3. 建立完善的规范 - 避免混乱
  4. 渐进式实施 - 降低迁移风险

在下一篇文章中,我将详细对比各种 Monorepo 工具,分享我为什么最终选择了 pnpm + Turborepo 的组合,以及这个选择带来的实际效果。


🔗 系列文章


如果这篇文章对你有帮助,请点赞收藏,也欢迎在评论区分享你的 Monorepo 实践经验! 🙏

你的项目适合 Monorepo 吗?欢迎使用文中的决策矩阵评估一下,并在评论区分享你的评估结果!

Nx带来极致的前端开发体验——任务缓存

作者 西陵
2025年10月15日 23:37

前言

前面我们讲过,为了提高项目的构建速度,社区将大部分的精力放到构建工具上,例如rspack、esbuild、swc等,利用语言优势提升构建速度。而像 webpack 这种老牌构建工具则将优化方向放在缓存上,但是他缓存的是构建流程中的中间结果,例如每个文件经过 loader 转换后的产物。

而本章节要介绍的任务缓存是指缓存任务执行之后的产物,例如构建或者测试任务,对于一个 package 来说,如果他的代码没发生改变,下一次执行 build 命令时可以直接读取上一次的构建产物,而无需再次进行构建,因为每次重复构建或者测试同一段代码的成本是非常昂贵的。

下面我们将专注于 Nx 的任务缓存机制,一起学习它的功能使用和原理实现。

定义缓存任务

使用 nx 创建项目时默认启用了任务缓存,开发者也可以在根目录的 nx.json 中全局配置任务缓存,也可以在每个 package 的 package.json 中单独配置。

// nx.json
{
  "targetDefaults": {
        "build": {
            "cache": true
        }
        "test": {
            "cache": true
        }
    }
}

// package.json
{
    "name": "myreactapp",
    ...
    "targets": {
        "build": {
            "cache": true
        }
        "test": {
            "cache": true
        }
    }
}

以下面这个项目为例:

image.png

我们有一个 cache-test 项目,其依赖了 card 和 shop 这两个 package,当首次执行 cache-test 的 build 命令时,输出如下:

image.png

当修改 shop 中的代码再次执行cache-test 的 build 命令时,输出如下:

image.png

可以发现 card 并没有重新构建,而是读取的上一次构建的产物。除此之外我们分别在 shop 和 card 中添加了单测逻辑,并使用的 vitest,我们可以看下 test 任务的缓存逻辑是否生效(nx run-many --target=test),首次运行如下:

image.png

修改 card 的代码之后再次运行:

image.png

从输出结果可以看出,shop 读取的是缓存的结果。

缓存原理

在执行任何可缓存任务之前,比如 nx build myappnx test myapp,Nx 都会预先计算一段哈希值,这个哈希值表示了:**如果输入完全一样,那么输出也会完全相同。**也就是说,只要任务的所有输入条件都没变,Nx 就可以直接用之前的缓存结果(跳过实际执行),来极大加速构建或测试。

默认情况下,对于 nx test myapp 这种任务其计算哈希的输入会包括:

  • myapp 的源码以及其依赖项的源码。
  • 全局配置,比如 nx.json、tsconfig.base.json。
  • 第三方依赖版本。
  • 运行时环境,例如 node 版本。
  • 命令行参数

image.png

目前大部分 monorepo 项目管理工具都采用类似的策略实现任务缓存,例如rush.jsturborepo等。

配置输入条件

Nx 默认配置的输入条件是非常保守的,默认会把比较多的可能影响输出的内容算进去,避免漏掉导致复用过时结果,下面是 Nx 为 build 任务生成的默认输入条件:

//nx.json

{
    "namedInputs": {
        "default": [
            "{projectRoot}/**/*",
            "sharedGlobals"
        ],
    "production": [
        "default",
        "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
        "!{projectRoot}/tsconfig.spec.json",
        "!{projectRoot}/src/test-setup.[jt]s"
    ],
    "sharedGlobals": []
    },
    // ...
    "targetDefaults": {
        "build": {
            "inputs": ["production", "^production"],
            "cache": true
        }
    }
}

namedInputs 中定义的是一些通用的输入集,nx 默认定义了两个输入集 defaultproduction ,每个输入集中会通过特定的语法定义文件的匹配规则。而每个规则中的projectRoot 表示当前 package 的根目录。

targetDefaults 中配置了 build 任务的输入条件,第一个条件 production 很好理解,表示符合 production 输入集下的所有文件都作为输入内容。第二个 ^production 则表示其依赖的 package 中所有符合 production 输入集的所有文件都作为输入内容。

举个例子,我们希望将 *.md 文件从输入中排除,像 build 和 test 都不需要依赖 *.md 文件,为了实现这一点可以添加以下配置:

//nx.json
{
"namedInputs": {
        "production": [
            "default",
            "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
            "!{projectRoot}/tsconfig.spec.json",
            "!{projectRoot}/src/test-setup.[jt]s",
+           "!{projectRoot}/**/*.md"
        ]
    }
}

总结

在本章节中我们介绍了 Nx 的一个核心功能——任务缓存,任务缓存是通过直接缓存任务的最终输出结果来提升效率,这与构建工具的缓存机制有所不同,像 webpack 这种构建工具的缓存功能缓存的是构建过程的中间结果。

接着我们介绍了如何在项目中定义任务缓存,当我们使用 Nx 的插件生成代码时,Nx 会默认给任务自动配置缓存,并设置默认的缓存输入条件。除此之外 Nx 还允许用户配置缓存的输入条件,灵活控制缓存的实效性。

如何设计一个架构良好的前端请求库?

作者 sorryhc
2025年10月11日 22:36

一、写在前面(抛出思考题)

过去的时间,你刷遍了面试题,在公司中工作了很多年,基于axios二次封装单项目级别的请求文件手到擒来。

你有没有想过?你是一个前端团队的资深老人,随着公司业务不断发展,各种各样的前端项目用着不同的请求技术方案。

A项目比较老,用的xhr

B项目用了axios,拥抱开源;

C项目因为小王拥抱原生的理念,使用了fetch

现在团队需要技术标准化,统一前端请求方案,将所有的请求集成到一个包里,如何设计技术方案,可以让这个包很健壮,未来很好维护?

这其实和大公司业务面广的背景很相似,比如美团外卖业务,在微信小程序端,你需要使用wx.request;在其他小程序,你需要使用不同的DSL去请求;在H5,你需要用到fetch/xhr/axios;在APP,你需要用到端上bridge能力,发出请求是计算机网络统一标准成熟的事情,如何基于不同的客户端环境来解耦并集成统一的前端解决方案?

二、方案设计

比较快速地实现是拆两层,直接在请求库中判断传入的配置,如axiosfetchxhr,去执行不同的函数。

image.png

这样很简单,也能实现,但是会有很严重的问题:

“请求”这件事不变的点和变的点,耦合在了一起,比如埋点、异常上报、拦截器等共性的功能全部都耦合在了请求库本身中,随着接入能力越来越多,代码会变得更加混乱。

那不耦合在一起呢?放在对应的执行请求文件里?重复代码会很多,相同的“事情”会在多个文件中重复多次。

那有什么比较好的方案呢?

有,开源项目中比较多的技术方案,如axios、umi、openai,通常会引入client请求器+adapter适配器概念,什么意思呢?

  1. 首先整个请求库需要设定标准统一的入参和出参,如入参就是url、options.....,出参就是success、data、code......
  2. client负责处理所有的通用逻辑,如埋点、异常上报、拦截器;
  3. adapter负责保存特定的请求能力,如wx.request、fetch、xhr,并且基于透传过来的标准化入参先做一层transform,比如微信小程序的入参命名叫params,那这里就需要将标准化的入参转成这个命名,响应也同理,将不同请求方案返回的结构体标准化处理再回传给client,最后client进行埋点、响应拦截、异常上报等处理,再透传给业务侧;

用图来说即为三层架构:

image.png

一句话总结:

在用户和发出请求之间增加一层适配层,负责对用户定制一套标准统一的入参类型、一套统一标准的出参类型,但只需要这一套参数类型标准即可满足所有请求方案。

而前面提到的“耦合”问题,直接在client层执行即可,因此client层做的是 执行请求 + 执行通用事件;adapter层做的是参数转换 + 实际请求逻辑处理。

说到这里光看两张图的前后对比,可能感知没那么明显,接下来我基于xhr + fetch适配器+client来手写一个请求库感受一下这个架构设计吧。

三、代码实现

3.1、类型设计

入参标准化:

{
  url: string,
  method: 'GET' | 'POST' | ...,
  headers: Record<string,string>,
  body?: any, // 自动 JSON.stringify 或 FormData 等
}

出参标准化:

{
  status: number,
  statusText: string,
  headers: Record<string,string>,
  data: any, // 已解析完成的响应数据
  rawResponse: any // 原始 Response 或 XHR 对象
}

这样在用户调用层 -> 请求库Client层核心的类型就设计好了。

3.2、核心代码设计

// Adapter 抽象基类
class Adapter {
  request(config) {
    throw new Error("Adapter.request must be implemented");
  }

  // 入参标准化(可在基类内定义通用处理)
  normalizeConfig(config) {
    return {
      url: config.url,
      method: (config.method || 'GET').toUpperCase(),
      headers: config.headers || {},
      body: config.body || null
    };
  }

  // 出参标准化
  normalizeResponse({ status, statusText, headers, data, rawResponse }) {
    return {
      status,
      statusText,
      headers,
      data,
      rawResponse
    };
  }
}

// Fetch Adapter
class FetchAdapter extends Adapter {
  async request(config) {
    const cfg = this.normalizeConfig(config);

    const response = await fetch(cfg.url, {
      method: cfg.method,
      headers: cfg.headers,
      body: cfg.body ? JSON.stringify(cfg.body) : null
    });

    // 解析 headers
    const headersObj = {};
    response.headers.forEach((v, k) => { headersObj[k] = v; });

    // 自动解析 json / text
    let data;
    const contentType = response.headers.get('content-type') || '';
    if (contentType.includes('application/json')) {
      data = await response.json();
    } else {
      data = await response.text();
    }

    return this.normalizeResponse({
      status: response.status,
      statusText: response.statusText,
      headers: headersObj,
      data,
      rawResponse: response
    });
  }
}

// XHR Adapter
class XHRAdapter extends Adapter {
  request(config) {
    const cfg = this.normalizeConfig(config);

    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open(cfg.method, cfg.url, true);

      // 设置 headers
      Object.entries(cfg.headers).forEach(([key, value]) => {
        xhr.setRequestHeader(key, value);
      });

      xhr.onreadystatechange = () => {
        if (xhr.readyState === 4) {
          const headersObj = this.parseHeaders(xhr.getAllResponseHeaders());
          let data;
          try {
            const contentType = headersObj['content-type'] || '';
            if (contentType.includes('application/json')) {
              data = JSON.parse(xhr.responseText);
            } else {
              data = xhr.responseText;
            }
          } catch (err) {
            reject(err);
            return;
          }

          resolve(this.normalizeResponse({
            status: xhr.status,
            statusText: xhr.statusText,
            headers: headersObj,
            data,
            rawResponse: xhr
          }));
        }
      };

      xhr.onerror = () => {
        reject(new Error('XHR network error'));
      };

      xhr.send(cfg.body ? JSON.stringify(cfg.body) : null);
    });
  }

  // 辅助方法解析 headers 字符串
  parseHeaders(rawHeaders) {
    const headers = {};
    rawHeaders.trim().split(/[\r\n]+/).forEach(line => {
      const parts = line.split(': ');
      const header = parts.shift();
      const value = parts.join(': ');
      if (header) headers[header.toLowerCase()] = value;
    });
    return headers;
  }
}

// Client 类
class Client {
  constructor(adapter, options = {}) {
    this.adapter = adapter;
    this.baseURL = options.baseURL || '';
    this.defaultHeaders = options.defaultHeaders || {};
  }

  async request(config) {
    try {
      const finalConfig = {
        ...config,
        url: this.baseURL + config.url,
        headers: { ...this.defaultHeaders, ...(config.headers || {}) }
      };
      console.log(`-----请求开始${finalConfig}-----`);
      const res = await this.adapter.request(finalConfig);
      console.log(`-----请求成功${res}-----`);
    } catch(e) {
      console.log(`-----请求失败${e}-----`);
      throw(e);
    }
  }

  get(url, headers) {
    return this.request({ url, method: 'GET', headers });
  }

  post(url, body, headers) {
    return this.request({ url, method: 'POST', body, headers });
  }
}

// 使用示例
const fetchClient = new Client(new FetchAdapter(), {
  baseURL: 'https://jsonplaceholder.typicode.com',
  defaultHeaders: { 'Content-Type': 'application/json' }
});

const xhrClient = new Client(new XHRAdapter(), {
  baseURL: 'https://jsonplaceholder.typicode.com',
  defaultHeaders: { 'Content-Type': 'application/json' }
});

fetchClient.get('/todos/1').then(res => console.log('FetchAdapter normalized:', res));
xhrClient.get('/todos/2').then(res => console.log('XHRAdapter normalized:', res));

162行代码,核心的代码就实现完毕了(实际开发可以拆分文件处理)

主要干了这几件事情:

  1. 定义BaseAdapter适配器抽象类,负责初始化请求、入参统一、出参统一三个核心能力;
  2. 定义XHRAdapterFetchAdapter,实现各自的请求函数,基于原生不同的参数标准化处理响应结构,返回给Client层;
  3. 定义Client请求实例,调用传入的Adapter,透传对应配置,中转请求发出和响应;

这样 在每个 Adapter 内部,对 入参 和 出参 做一层 标准化处理,这样无论使用什么请求方式,Client 都能拿到统一结构的返回值,同时向 Adapter 传入的 config 也会有统一的字段,这也是很多通用库(axios 等)会做的事。

结尾

基于这个思路和模板,你非常容易的扩展很多能力,当你的公司业务需要接入京东小程序,请求相关的迭代很简单,新建一个适配器,把京东相关的DSL开发一下,把请求库更新个版本就搞定了。

请求监控、稳定性,你都不用管,因为这些早就成熟稳定地在多个项目跑了很久了。(自从你的请求库投产后)。

很多开源项目都是基于这个思路来工作的,包括你最熟悉的axios

如果这篇文章对你有帮助,欢迎和我一起讨论。

if 语句对程序性能的影响

作者 韩非
2025年10月11日 17:25

力扣关联题目:3494. 酿造药水需要的最少总时间

1. 一组代码对比

针对背景涉及的力扣题目,我使用了一种简单的方式去求解:

  1. 计算第一瓶药水的开始处理时间以及每名巫师处理完第一瓶药水的结束时间
  2. 假设从第 0 秒处理第 n 瓶药水,需要保证药水处理不阻塞的条件是,药水在第 k 名巫师处理完后,其时间不得早于第 k + 1 名巫师处理完 n-1 瓶药水的时间
  3. 根据条件2遍历出第 n 瓶药水的最早可以开始处理的时间

其中代码的20-23行就是在计算每瓶药水的最早可以开始处理的时间。

程序一:

class Solution:
    def minTime(self, skill: List[int], mana: List[int]) -> int:
        n = len(skill)
        m = len(mana)
        
        prev_row = [0] * (n + 1)
        curr_row = [0] * (n + 1)
        
        # 初始化第一行
        for j in range(1, n+1):
            prev_row[j] = prev_row[j-1] + skill[j-1] * mana[0]
        
        for i in range(1, m):
            # 计算当前行
            for j in range(1, n+1):
                curr_row[j] = curr_row[j-1] + skill[j-1] * mana[i]
            
            # 计算最大差值
            maxDelta = 0
            for j in range(n):
                delta = prev_row[j+1] - curr_row[j]
                if delta > 0:
                    maxDelta = max(maxDelta,delta)
            
            # 调整当前行
            if maxDelta > 0:
                for j in range(n+1):
                    curr_row[j] += maxDelta
            
            # 滚动数组
            prev_row, curr_row = curr_row, prev_row
        
        return prev_row[n]

程序二

class Solution:
    def minTime(self, skill: List[int], mana: List[int]) -> int:
        n = len(skill)
        m = len(mana)
        
        prev_row = [0] * (n + 1)
        curr_row = [0] * (n + 1)
        
        # 初始化第一行
        for j in range(1, n+1):
            prev_row[j] = prev_row[j-1] + skill[j-1] * mana[0]
        
        for i in range(1, m):
            # 计算当前行
            for j in range(1, n+1):
                curr_row[j] = curr_row[j-1] + skill[j-1] * mana[i]
            
            # 计算最大差值
            maxDelta = 0
            for j in range(n):
                delta = prev_row[j+1] - curr_row[j]
                if delta > maxDelta:
                    maxDelta = delta
            
            # 调整当前行
            if maxDelta > 0:
                for j in range(n+1):
                    curr_row[j] += maxDelta
            
            # 滚动数组
            prev_row, curr_row = curr_row, prev_row
        
        return prev_row[n]

上面的两组程序是针对背景题目的同样的解法,唯一的区别在于第20行到第23行部分的条件判断语句的不同。
程序一在执行测试用例时,当数据量较大时,会出现超时的错误,程序二在同样条件下,可以在时间限制内执行完全部的测试用例。

将其中有差别的代码片段摘录出来如下
片段一:

...
maxDelta = 0
for j in range(n):
    delta = prev_row[j+1] - curr_row[j]
    if delta > 0:
        maxDelta = max(maxDelta,delta)
...

片段二:

...
maxDelta = 0
for j in range(n):
    delta = prev_row[j+1] - curr_row[j]
    if delta > maxDelta:
        maxDelta = delta
...

这两个代码片段的差异主要体现在以下方面:

  1. 条件判断的不同,片段一使用的是差值与 0 的对比,片段二使用的是差值与maxDelta的对比
  2. 片段一执行多一次 max 函数调用,其中 max 函数涉及到一次判断和一次赋值操作

可以看到,其中的核心区别在于条件判断的次数和条件判断的条件。

由条件判断导致的程序性能问题,其核心原因在于:

CPU流水线中,存在对分支的预测行为,预测的分支指令会占用CPU时钟,预测失败时,流水线清除,造成时钟浪费

2. CPU 中的流水线

在计算机中,流水线的存在可以让单个的处理器并行的执行多层级的指令。如下,是一个基础的五阶段流水线示意图。

image.png

流水线中各阶段操作如下:

阶段 缩写 功能
IF Instruction Fetch 从指令缓存中取指令
ID Instruction Decode 解码指令,读取寄存器
EX Execute 执行运算(ALU操作)
MEM Memory Access 访问数据内存(加载/存储)
WB Write Back 将结果写回寄存器

对于上图,可以参考下面的示意图来进行理解

image.png

时间轴从左至右:

  • 当第一个时钟周期到达后:
    • CPU 执行 IF 操作,取缓存中的第一条指令,在第一个时钟周期结束后,将 IF 的结果放入 IF 寄存器
  • 当第二个时钟周期到达后
    • CPU 执行 IF 操作,取缓存中的第二条指令,在第二个时钟周期结束后,将 IF 的结果放回 IF 寄存器
    • CPU 执行 ID 操作,从 IF 寄存器中取第一条 IF 指令的结果,并执行解码,将结果存入 ID 寄存器
  • 当第三个时钟周期到达后
    • CPU 执行 IF 操作,取缓存中的第三条指令,在第三个时钟周期结束后,将 IF 的结果放回 IF 寄存器
    • CPU 执行 ID 操作,从 IF 寄存器中取第二条 IF 指令的结果,并执行解码,将结果存入 ID 寄存器
    • CPU 执行 EX 操作,从 ID 寄存器中取第一条 ID 指令的结果,执行操作,将结果存入 EX 寄存器
  • 当第四个时钟周期到达后
    • CPU 执行 IF 操作,取缓存中的第四条指令,在第四个时钟周期结束后,将 IF 的结果放回 IF 寄存器
    • CPU 执行 ID 操作,从 IF 寄存器中取第三条 IF 指令的结果,并执行解码,将结果存入 ID 寄存器
    • CPU 执行 EX 操作,从 ID 寄存器中取第二条 ID 指令的结果,执行操作,将结果存入 EX 寄存器
    • CPU 执行 ME 操作,从 EX 寄存器中取第一条 EX 指令的结果,执行内存数据读取,将结果存入 EX 寄存器
  • 当第五个时钟周期到达后
    • CPU 执行 IF 操作,取缓存中的第四条指令,在第四个时钟周期结束后,将 IF 的结果放回 IF 寄存器
    • CPU 执行 ID 操作,从 IF 寄存器中取第四条 IF 指令的结果,并执行解码,将结果存入 ID 寄存器
    • CPU 执行 EX 操作,从 ID 寄存器中取第三条 ID 指令的结果,执行操作,将结果存入 EX 寄存器
    • CPU 执行 ME 操作,从 EX 寄存器中取第二条 EX 指令的结果,执行内存数据读取,将结果存入 EX 寄存器
    • CPU 执行 WB 操作,从 ME 寄存器中取第一条指令的结果,将结果存入寄存器

注意:
由于信号传输时间、功耗等物理条件的限制和均衡考虑,无法在一个时钟内执行完同一条指令的所有操作,时钟与时钟之间,CPU需要进行流水线寄存器的读写操作,以此在时钟之间传递数据。

在上面的五个时钟周期过后,缓存中的第一条指令执行完成,第二条指令还差最后一个阶段,使用五阶段流水线执行指令的耗时如下:

指令序号 1 2 3 4 5
流水线耗时 5时钟 6时钟 7时钟 8时钟 9时钟
串行耗时 5时钟 10时钟 15时钟 20时钟 25时钟

由此说明,在CPU中流水线并行操作,对于计算资源利用的必要性。

为了充分的利用CPU中的各操作单元,需要尽可能的让单个时钟周期内各个操作单元处于忙碌的状态,但是对于if...else... 类的分支逻辑,在指令运行到条件判断前,无法得知后续的指令内容,如果闲置操作单元,会造成资源的浪费,为了让计算机资源尽可能的被利用,CPU中就产生了分支预测行为。

3. CPU 中的分支预测

image.png

上图是一个4阶段流水线的执行示意图,其中红、蓝、紫、绿四个正方形表示的是一条指令,指令经过流水线从上向下执行,在指令等待区中就包括明确要执行的指令和CPU猜测可能需要执行的指令。

双向分支(if-else)通常通过条件跳转指令实现。条件跳转可以是"被采纳(taken)"并跳转到程序内存中的不同位置,也可以是"不被采纳"并继续紧接在条件跳转之后执行。在条件被计算出来并且条件跳转指令通过指令流水线中的执行阶段之前,无法确定条件跳转是会被采纳还是不被采纳(见图1)。

如果没有分支预测,处理器将不得不等待条件跳转指令通过执行阶段后,下一条指令才能进入流水线中的取指阶段。分支预测器试图通过猜测条件跳转最有可能被采纳还是不被采纳来避免这种时间浪费。然后,被猜测为最有可能的分支会被取指并被推测性地执行。如果之后检测到猜测错误,那么这些被推测性执行或部分执行的指令将被丢弃,流水线会从正确的分支重新开始,从而产生延迟。

在分支预测错误的情况下,所浪费的时间等于从取指阶段到执行阶段之间流水线的级数。现代微处理器往往具有相当长的流水线,因此预测错误导致的延迟在 10 到 20 个时钟周期之间。

第一次遇到条件跳转指令时,没有太多信息可以作为预测的基础。然而,分支预测器会记录分支是否被采纳的历史,因此当它遇到之前出现过多次的条件跳转时,它可以基于记录的历史进行预测。例如,分支预测器可以识别出该条件跳转"被采纳"的情况多于"不被采纳",或者它是每隔一次被采纳。

CPU中有专门的分支预测器及算法,负责相关的分支预测工作。

预测准确率 = (正确预测的分支数 / 总分支数) × 100%

不同预测器的表现:

  • 简单预测器:70-85%
  • 现代高级预测器:95-99%
  • 完美预测:100%

准确率每提升1%,整体性能可能提升1-2%

4. 编码风格分支预测器的影响

条件语句的特性对分支预测准确率有决定性影响。不同的条件语句模式会导致预测准确率从接近100%到接近50%的巨大差异。

4.1. 条件语句的模式分类

  • 高预测性模式(准确率 > 95%)

    • 高度偏向性条件
    // 模式:几乎总是成立或总是不成立
    if (array_size > 0) {        // 99.9% 成立
        // 处理数组
    }
    
    if (debug_mode_enabled) {    // 99.9% 不成立
        // 调试代码
    }
    

    预测效果:简单预测器就能达到极高准确率。

    • 循环终止条件
    // 模式:前N-1次成立,最后1次不成立
    for (int i = 0; i < 100; i++) {
        // 循环体
        // 条件 i < 100: 99次成立,1次不成立
    }
    

    预测效果:2位饱和计数器能完美预测,准确率接近100%。

    • 规律性交替
    // 模式:固定周期交替
    for (int i = 0; i < 100; i++) {
        if (i % 4 == 0) {        // 规律:每4次成立1次
            // 特殊处理
        }
    }
    

    预测效果:局部历史预测器能学习模式,准确率高。

  • 低预测性模式(准确率 50-80%)

    • 数据依赖性条件
    // 模式:依赖输入数据,难以预测
    if (user_input > threshold) {
        // 处理
    }
    
    if (data[i] % 2 == 0) {      // 奇偶随机分布
        // 偶数处理
    }
    

    预测效果:准确率接近随机猜测(50%)。

    • 哈希表/缓存查找
    // 模式:依赖哈希冲突率
    if (hash_table[hash(key)] != NULL) {
        // 键存在
    }
    

    预测效果:取决于数据分布,通常60-80%。

    • 随机性条件
    // 模式:基于随机数
    if (random() < 0.3) {        // 30% 概率成立
        // 随机事件
    }
    

    预测效果:准确率约70%(预测总选概率高的方向)。

4.2. 条件复杂度的影响

简单条件 vs 复杂条件

// 简单条件 - 易于预测
if (x > 0) { ... }

// 复杂条件 - 预测困难
if ((x > 0 && y < 10) || (z == 5 && !flag)) { ... }

影响

  • 简单条件:模式清晰,预测器容易学习
  • 复杂条件:多个变量的组合导致模式混乱

4.3. 数据局部性的影响

数据排序的威力

// 未排序数据 - 预测困难
int data[] = {3, -1, 8, -5, 2, -9, 7, -2};
for (int i = 0; i < 8; i++) {
    if (data[i] > 0) {   // 模式: T, F, T, F, T, F, T, F
        sum += data[i];
    }
}
// 预测准确率: ~50%

// 排序后数据 - 预测容易
int sorted_data[] = {-9, -5, -2, -1, 2, 3, 7, 8};
for (int i = 0; i < 8; i++) {
    if (sorted_data[i] > 0) { // 模式: F, F, F, F, T, T, T, T
        sum += sorted_data[i];
    }
}
// 预测准确率: ~100%

4.4 编程习惯的影响

  • 可预测的代码风格

    // 好的写法:创造可预测的模式
    // 1. 将大概率路径放在前面
    if (likely_success) {    // 使用likely宏提示编译器
        // 常见路径
    } else {
        // 罕见路径
    }
    
    // 2. 循环展开减少分支频率
    for (int i = 0; i < n; i += 4) {
        // 一次处理4个元素,减少循环条件判断
    }
    
    // 3. 使用查表代替复杂条件
    static const int action_table[] = {ACTION_A, ACTION_B, ...};
    action = action_table[condition1 * 4 + condition2 * 2 + condition3];
    
  • 难以预测的代码风格

    // 差的写法:引入随机性
    // 1. 过度使用小函数
    if (is_valid(input) && should_process(input) && can_retry(input)) {
        // 每个函数调用都可能隐藏分支
    }
    
    // 2. 复杂的状态机
    switch (get_complex_state()) {
        case STATE_A: ... break;
        case STATE_B: ... break; // 多个case分支难以预测
    }
    

5. 结论

回到一开始的问题:

  • 片段一:

    ...
    maxDelta = 0
    for j in range(n):
        delta = prev_row[j+1] - curr_row[j]
        if delta > 0: # 数据随机,预测成功率接近随机 50%
            maxDelta = max(maxDelta,delta) # 函数调用中存在内置的条件判断 ,增加分支预测难度
    ...
    
  • 片段二:

    ...
    maxDelta = 0
    for j in range(n):
        delta = prev_row[j+1] - curr_row[j]
        if delta > maxDelta: # delta 大概率比 maxDelta 小,分支可预测程度高,且判断条件简单
            maxDelta = delta
    ...
    

参考链接

  1. CPU流水线
  2. 分支预测

并行SSR,SSR并行加载

作者 芒果茶叶
2025年10月9日 17:56

并行SSR

前端实现 SSR(服务器端渲染)的并行加载,核心思想是避免服务器端的数据请求瀑布(Waterfall),将页面分块、流式(Streaming)地发送给浏览器,从而显著优化 Time To First Byte (TTFB) 和用户感知性能。

传统的 SSR 是一个“串行”过程:

  1. 服务器接收请求。
  2. 等待所有数据(API、数据库查询等)全部获取完毕。
  3. 将所有数据渲染成一个完整的 HTML 字符串。
  4. 将完整的 HTML 响应给浏览器。

这种模式的瓶颈在于第二步:如果任何一个数据请求很慢,整个页面都会被阻塞,用户会看到长时间的白屏。

而“并行加载”的 SSR,我们称之为 流式 SSR (Streaming SSR),其过程如下:

  1. 服务器接收请求。
  2. 立即发送页面的基本骨架(Shell),比如 <html>, <head>, 和不需要异步数据的静态部分。浏览器收到后可以立即开始解析和加载 CSS。
  3. 服务器并行地发起多个数据请求。
  4. 每当一个数据块准备好,服务器就将其渲染成 HTML 片段,并流式地推送到已经建立的连接中。
  5. 浏览器接收到这些 HTML 片段后,逐步将其渲染出来。通常,这些片段会带有内联的 <script> 标签,用于将 HTML 插入到正确的位置。

下面我们来探讨如何在主流框架中实现这一模式。


核心技术:React 的实现方式 (Suspense 和 React Server Components)

React 是流式 SSR 的引领者,主要通过 Suspense 和 React Server Components (RSC) 来实现。

1. 使用 Suspense for SSR

Suspense 是实现流式渲染的关键。在服务器端,当 React 遇到一个 <Suspense> 组件时,它不会等待 Suspense 内部的异步操作完成。

工作流程:

  1. 发送 Fallback 内容:服务器会先将 Suspensefallback prop(例如一个加载中的 UI)作为占位符,连同页面的其他同步内容一起发送出去。
  2. 并行获取数据:同时,服务器会继续处理 Suspense 内部组件的数据获取。
  3. 流式发送真实内容:当内部组件的数据准备好并渲染完成后,React 会将这部分 HTML 片段,连同一个小型的内联 <script> 标签,作为一个新的“块”(Chunk)流式地发送到浏览器。
  4. 客户端激活:浏览器执行这个内联脚本,它会找到对应的 Fallback UI,并用新的 HTML 内容替换它。

代码示例 (Next.js / React 18+):

假设你有一个获取用户数据的组件 UserData 和一个获取文章列表的组件 PostList,它们都很慢。

// components/SlowComponent.js
async function SlowComponent({ delay, children }) {
  await new Promise(res => setTimeout(res, delay));
  return <div>{children}</div>;
}

// app/page.js
import { Suspense } from 'react';
import SlowComponent from '../components/SlowComponent';

export default function Page() {
  return (
    <div>
      <h1>我的主页</h1>
      <p>这部分内容会立即显示。</p>

      {/* 用户信息部分 */}
      <Suspense fallback={<div>加载用户信息中...</div>}>
        <SlowComponent delay={2000}>
          <h2>用户信息</h2>
          <p>用户名: 张三</p>
        </SlowComponent>
      </Suspense>

      {/* 文章列表部分 */}
      <Suspense fallback={<div>加载文章列表中...</div>}>
        <SlowComponent delay={4000}>
          <h2>文章列表</h2>
          <ul>
            <li>文章一</li>
            <li>文章二</li>
          </ul>
        </SlowComponent>
      </Suspense>
    </div>
  );
}

发生了什么?

  1. 服务器立即发送包含 "我的主页"、"这部分内容会立即显示"、"加载用户信息中..." 和 "加载文章列表中..." 的 HTML 骨架。用户几乎瞬间就能看到这个结构。
  2. 服务器同时等待 SlowComponent 的两个实例。
  3. 2秒后,第一个 SlowComponent 完成。服务器流式发送用户信息部分的 HTML 和一个脚本,将其替换掉 "加载用户信息中..."。
  4. 再过2秒(总共4秒),第二个 SlowComponent 完成。服务器再次流式发送文章列表的 HTML 和脚本,将其替换掉 "加载文章列表中..."。

这样,用户不会等待4秒钟的白屏,而是在内容准备好时逐步看到它们。

2. React Server Components (RSC)

RSC 将并行数据获取提升到了一个新的层次。RSC 自身就可以是 async 函数,它在服务器上运行,并将渲染结果(一种特殊的 JSON 格式,不是 HTML)流式传输到客户端。

RSC 与 Suspense 结合使用,是目前最强大的并行加载模型。

工作流程:

  • 根组件(通常是 Server Component)开始渲染。
  • 它可以直接 await 数据获取,或者渲染其他 Server Components。
  • 当它 await 一个异步操作时,渲染会暂停,但不会阻塞整个响应。
  • React 可以将已渲染的部分先流式传输出去,并用 Suspense 包裹那些仍在等待数据的子树。

代码示例 (Next.js App Router):

// lib/api.js
export async function fetchUserData() {
  await new Promise(res => setTimeout(res, 2000));
  return { name: '张三' };
}

export async function fetchPosts() {
  await new Promise(res => setTimeout(res, 4000));
  return [{ id: 1, title: '文章一' }];
}

// components/UserData.js (Server Component)
import { fetchUserData } from '../lib/api';

export default async function UserData() {
  const user = await fetchUserData(); // 直接在组件内 await
  return (
    <div>
      <h2>用户信息</h2>
      <p>用户名: {user.name}</p>
    </div>
  );
}

// components/PostList.js (Server Component)
import { fetchPosts } from '../lib/api';

export default async function PostList() {
  const posts = await fetchPosts(); // 直接在组件内 await
  return (
    <div>
      <h2>文章列表</h2>
      <ul>
        {posts.map(post => <li key={post.id}>{post.title}</li>)}
      </ul>
    </div>
  );
}


// app/page.js (Server Component)
import { Suspense } from 'react';
import UserData from '../components/UserData';
import PostList from '../components/PostList';

export default function Page() {
  return (
    <div>
      <h1>我的主页</h1>
      
      {/* UserData 和 PostList 的数据获取是并行的! */}
      <Suspense fallback={<div>加载用户信息...</div>}>
        <UserData />
      </Suspense>

      <Suspense fallback={<div>加载文章列表...</div>}>
        <PostList />
      </Suspense>
    </div>
  );
}

在这个例子中,fetchUserDatafetchPosts并行执行,因为 React 会同时开始渲染 UserDataPostList 这两个子树。Suspense 提供了加载期间的 UI,让体验更加平滑。


Vue.js 的实现方式

Vue 3 也支持流式 SSR,其原理与 React 的 Suspense 非常相似。

核心 API:

  • renderToNodeStream() (Node.js 环境) 或 renderToWebStream() (Web Streams API 环境)。
  • <Suspense> 组件。

工作流程:

  1. 在服务器入口文件中,使用 renderToNodeStreamrenderToWebStream 来代替 renderToString
  2. 在你的 Vue 组件中,使用 <Suspense> 来包裹需要异步加载数据的组件。
  3. 被包裹的组件可以在 setup 函数中返回一个 Promise (通过 async setup)。
  4. 服务器端渲染时,Vue 会先发送 <Suspense>#fallback 插槽内容。
  5. async setup 的 Promise resolve 后,Vue 会将 #default 插槽渲染的内容流式地发送到客户端,并附带脚本进行替换。

代码示例 (使用 Nuxt 3 或原生 Vue SSR):

<!-- components/AsyncUser.vue -->
<template>
  <div>
    <h2>用户信息</h2>
    <p>用户名: {{ user.name }}</p>
  </div>
</template>

<script setup>
const user = await new Promise(res => {
  setTimeout(() => {
    res({ name: '李四' });
  }, 2000);
});
</script>
<!-- pages/index.vue -->
<template>
  <div>
    <h1>我的主页</h1>
    <p>这部分内容立即显示。</p>

    <Suspense>
      <!-- 默认内容 -->
      <template #default>
        <AsyncUser />
      </template>
      
      <!-- 加载状态 -->
      <template #fallback>
        <div>加载用户信息中...</div>
      </template>
    </Suspense>
  </div>
</template>

<script setup>
import AsyncUser from '~/components/AsyncUser.vue';
</script>

这个 Vue 示例的行为和 React 的 Suspense 示例几乎完全一样,实现了同样的目标。


其他架构和思想

  1. Islands Architecture (群岛架构)

    • Astro 这样的框架是这种架构的典型代表。
    • 它默认输出零 JavaScript 的纯 HTML。页面上的交互部分被视为“岛屿”(Island),只有当这些岛屿进入视口或被用户交互时,才会加载它们的 JavaScript。
    • 在 SSR 阶段,Astro 可以在构建时或请求时并行获取不同组件的数据,然后生成一个高度优化的静态 HTML 页面。这本身就是一种并行化的体现。
  2. Edge-Side Rendering (边缘渲染)

    • 将 SSR 的逻辑部署到 CDN 的边缘节点(如 Vercel, Netlify, Cloudflare Workers)。
    • 虽然这不直接改变代码的并行逻辑,但它极大地减少了用户到服务器的物理延迟。
    • 当你的 SSR 服务器离数据源(数据库/API)很近时,在边缘并行获取数据可以获得极速的响应。

总结

实现前端 SSR 的并行加载,关键在于从“一次性完整响应”转向“分块流式响应”

策略 核心技术 优点 框架支持
流式渲染 Suspense 组件 逐步展现内容,改善用户感知性能,不阻塞首屏静态内容 React 18+, Vue 3+
组件级数据获取 React Server Components (async/await in components) 将数据获取逻辑内聚到组件中,代码更清晰,自动实现并行数据拉取 React (Next.js App Router)
架构模式 Islands Architecture 默认静态化,按需加载交互,性能极佳 Astro, Fresh
部署优化 Edge Computing 减少网络延迟,让并行获取更快 Vercel, Netlify, Cloudflare 等平台

对于现代前端开发,强烈推荐使用支持流式 SSR 的框架(如 Next.js 13+ 或 Nuxt 3),并充分利用 Suspense 和异步组件,这是实现高性能 SSR 并行加载的最直接、最强大的方式。

从最简单的 icon组件开始了解Element-Plus 源码

作者 Nayana
2025年10月9日 15:57

从基础的组件看看Element-plus实现组建的基本流程

目录 packages\components\icon

icon目录.png

因为我们分析源码的目的是学习如何构建组件库已经封装复用性强的组件所以跳过.tsx文件从src目录开始看。

icon.ts
import { buildProps, definePropType } from '@element-plus/utils'

buildProps,definePropType(export const definePropType = <T>(val: any): PropType<T> => val)。definePropType定义了泛型的props类型,buildProps是对 element 组件开发过程中,大家编码时的 Props 的设置,进行了统一格式化。

buildProps.webp

import type { ExtractPropTypes } from 'vue'
import type Icon from './icon.vue'

使用 import 的话,TypeScript 是无法判断你是想导出类型还是一个 JavaScript 的方法或者变量。
所以 TypeScript 提供了 import type or export type,用来明确表示我引入/导出的是一个类型,而不是一个变量或者方法。

定义组件需要的参数
export const iconProps = buildProps({

  /**
   * @description SVG icon size, size x size
   */
  size: {
    type: definePropType<number | string>([Number, String]),
  },
  /**
   * @description SVG tag's fill attribute
   */
  color: {
    type: String,
  },
} as const)

as const 则可以快速将一个对象变成只读类型,常量断言可以把一个值标记为一个不可篡改的常量,从而让 TS 以最严格的策略来进行类型推断。

export type IconProps = ExtractPropTypes<typeof iconProps>

vue3 string构造函数 在TS类型中是他的构造函数类型:StringConstructor 并不是我们想要的,我们希望string构造函数返回的是字符串类型string. vue3提供了prop类型申明ExtractPropTypes 用于接收一个类型用yu把接收的prop类型返回出来;也可以把构造函数类型转换为对应的类型如:StringConstructor 转换成 string

export type IconInstance = InstanceType<typeof Icon> & unknown

返回SFC 实例类型,InstanceType 函数:该函数返回(构造) 由某个构造函数构造出来的实例类型组成的类型

icon/index.ts
import { withInstall } from '@element-plus/utils'

import Icon from './src/icon.vue'

import type { SFCWithInstall } from '@element-plus/utils'
//组件类型引入
export const ElIcon: SFCWithInstall<typeof Icon> = withInstall(Icon)
// 通过 withInstall 方法给 Icon 添加了一个 install 方法
export default ElIcon
// 导出 iCON 组件

export * from './src/icon'

至此,对icon组件的分析就结束了。涉及的withInstall是组件库注册的公共方法。

具体目录是packages\utils\vue\install.ts


export const withInstall = <T, E extends Record<string, any>>(
  main: T,
  extra?: E
) => {
  ;(main as SFCWithInstall<T>).install = (app): void => {
    for (const comp of [main, ...Object.values(extra ?? {})]) {
      app.component(comp.name, comp)
    }
  }

  if (extra) {
    for (const [key, comp] of Object.entries(extra)) {
      ;(main as any)[key] = comp
    }
  }
  return main as SFCWithInstall<T> & E
}
// 组件注册的公共方法
 传递两个参数,main类型为泛型T,extra是一个对象,通过Object.values 将 extra 中的属性值提取为一个数组,并进行遍历进行 组件的注册.
 
 如果extra不为空则通过 迭代器遍历 Object.entries 转换后的 二维数组, 将extra所有属性和值 挂载到 main 对象下
 
 SFCWithInstall 通过泛型 将最后返回的 main 的类型 定义为 T & Plugin & E的交叉类型,为并且关系

2025年前端最新技术总结

2025年10月9日 15:32

1. 框架与库的发展

React 生态

  • React 19 已经正式发布,带来了以下重要特性:

    • use Hook 的稳定化,支持在组件顶层直接使用 Promise 和 Context
    • Actions 和 Transitions 的改进,提供更好的表单处理体验
    • 新的 react-dom/client API 更加稳定
    • 改进的 Server Components 支持

Vue 生态

  • Vue 3.5 成为最新稳定版本:

    • 更好的 TypeScript 支持和类型推断
    • Composition API 的性能优化
    • 更强的 SSR 支持

Angular

  • Angular 18 发布:

    • 更好的信号系统(Signals)
    • 控制流语法(@if, @for, @switch)
    • 改进的开发体验

2. 构建工具演进

Vite 的主导地位

  • Vite 5.x 成为默认选择,具备:

    • 极速的冷启动和热更新
    • 原生 ES 模块支持
    • 完善的插件生态系统

Webpack 5 的持续优化

  • 持续在企业级项目中使用,特别是在需要复杂配置的场景

新兴工具

  • Turbopack:由 Rust 编写,比 Vite 更快的构建工具
  • Bun:作为构建工具和运行时的替代方案

3. TypeScript 的发展

TypeScript 5.4+

  • 更智能的类型推断
  • 改进的泛型约束
  • 更好的装饰器支持(与 ECMAScript 装饰器标准对齐)

4. 状态管理方案

新兴方案

  • Zustand:轻量级状态管理库,逐渐替代 Redux
  • Jotai:原子化状态管理
  • Valtio:代理式状态管理

传统方案演进

  • Redux Toolkit 持续更新,简化了 Redux 的使用
  • Pinia 成为 Vue 官方推荐的状态管理库

5. CSS 和样式方案

现代 CSS 特性普及

  • Container Queries:容器查询支持响应式设计
  • :has()  选择器:父选择器功能
  • @layer:CSS 层叠控制
  • Subgrid:网格布局的嵌套支持

CSS-in-JS 演变

  • Vanilla Extract:编译时 CSS-in-JS 解决方案
  • Stitches:高性能 CSS-in-JS 库

6. 测试技术发展

测试工具

  • Vitest:基于 Vite 的测试框架,成为 Jest 的现代替代品
  • Playwright:端到端测试工具的首选
  • Cypress 继续演进,支持组件测试

测试策略演进

  • 更重视单元测试和集成测试的平衡
  • 可视化回归测试工具普及

7. 性能优化技术

Core Web Vitals 优化

  • 更注重实际用户体验指标
  • 服务器组件和流式渲染的普及

新的优化技术

  • Partial Prerendering (PPR) :结合静态和动态渲染
  • React Compiler:自动优化 React 组件
  • Island Architecture:部分水合技术

8. 新兴技术趋势

AI 集成

  • AI 辅助开发工具:GitHub Copilot、Amazon CodeWhisperer 等
  • 低代码/无代码平台 的进一步发展
  • 自然语言到代码 的转换工具

WebAssembly (WASM)

  • 更广泛的应用场景
  • 与 JavaScript 更好的互操作性

Web Components 标准化

  • 更好的框架间互操作性
  • 原生组件库的发展

9. 开发体验提升

开发工具

  • VS Code 插件生态持续丰富
  • Language Server Protocol (LSP)  的普及
  • 更智能的代码补全和错误检测

协作工具

  • 实时协作编码工具普及
  • 更好的远程开发支持

10. 部署和运维

边缘计算

  • Edge Functions 的普及
  • CDN 和边缘计算的结合

Serverless 和微前端

  • Serverless 架构 更加成熟
  • 微前端架构 在大型项目中广泛应用

总结

2025年的前端技术生态呈现出以下特点:

  1. 性能优先:工具和框架都在追求更快的构建速度和运行时性能
  2. 开发体验:更加注重开发者的体验,提供更好的工具链
  3. 标准化:Web 标准的推进使得跨框架解决方案成为可能
  4. 智能化:AI 辅助开发工具逐渐成为标配
  5. 生态整合:各技术栈之间的界限逐渐模糊,互操作性增强

建议前端开发者关注这些趋势,选择适合自己项目的技术栈,并持续学习新的工具和方法。

UniApp 微信小程序流水线发布全流程

2025年10月9日 15:27

1. 准备工作

开发环境配置

bash
# 安装 HBuilderX 或使用 CLI
npm install -g @vue/cli
npm install -g @dcloudio/uni-cli

# 创建项目
vue create -p dcloudio/uni-preset-vue#vue3 my-project

微信开发者工具配置

  1. 下载安装微信开发者工具
  2. 在设置中开启"安全端口"
  3. 获取 AppID(在微信公众平台获取)

2. 项目配置

manifest.json 配置

json
{
    "name": "小程序名称",
    "appid": "your-app-id",
    "description": "",
    "versionName": "1.0.0",
    "versionCode": "100",
    "transformPx": false,
    "app-plus": {
        "usingComponents": true
    },
    "mp-weixin": {
        "appid": "your-app-id",
        "setting": {
            "urlCheck": false,
            "es6": true,
            "enhance": true,
            "postcss": true,
            "preloadBackgroundData": false,
            "minified": true,
            "newFeature": false,
            "coverView": true,
            "nodeModules": false,
            "autoAudits": false,
            "showShadowRootInWxmlPanel": true,
            "scopeDataCheck": false,
            "uglifyFileName": false,
            "checkInvalidKey": true,
            "checkSiteMap": true,
            "uploadWithSourceMap": true,
            "compileHotReLoad": false,
            "useMultiFrameRuntime": true,
            "useApiHook": true,
            "babelSetting": {
                "ignore": [],
                "disablePlugins": [],
                "outputPath": ""
            },
            "enableEngineNative": false,
            "bundle": false,
            "useIsolateContext": true,
            "useCompilerModule": true,
            "userConfirmedUseCompilerModuleSwitch": false,
            "userConfirmedBundleSwitch": false,
            "packNpmManually": false,
            "packNpmRelationList": [],
            "minifyWXSS": true
        },
        "usingComponents": true,
        "permission": {
            "scope.userLocation": {
                "desc": "你的位置信息将用于小程序位置接口的效果展示"
            }
        }
    }
}

pages.json 页面配置

json
{
    "pages": [
        {
            "path": "pages/index/index",
            "style": {
                "navigationBarTitleText": "首页"
            }
        }
    ],
    "globalStyle": {
        "navigationBarTextStyle": "black",
        "navigationBarTitleText": "uni-app",
        "navigationBarBackgroundColor": "#F8F8F8",
        "backgroundColor": "#F8F8F8"
    },
    "condition": {
        "current": 0,
        "list": [
            {
                "name": "首页",
                "path": "pages/index/index"
            }
        ]
    }
}

3. CI/CD 流水线配置

GitHub Actions 示例

yaml
# .github/workflows/deploy.yml
name: Deploy to WeChat Mini Program

on:
  push:
    branches:
      - main
  release:
    types: [published]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    
    steps:
    - name: Checkout code
      uses: actions/checkout@v3
      
    - name: Setup Node.js
      uses: actions/setup-node@v3
      with:
        node-version: '16'
        
    - name: Install dependencies
      run: npm install
      
    - name: Build for MP-Weixin
      run: npm run build:mp-wechat
      
    - name: Upload to WeChat DevTools
      uses: actions/upload-artifact@v3
      with:
        name: mp-wechat-dist
        path: dist/dev/mp-weixin/
        
    - name: Deploy to WeChat Mini Program
      if: github.event_name == 'release'
      env:
        WECHAT_APP_ID: ${{ secrets.WECHAT_APP_ID }}
        WECHAT_PRIVATE_KEY: ${{ secrets.WECHAT_PRIVATE_KEY }}
      run: |
        # 使用微信官方CLI工具上传代码
        npx miniprogram-ci upload \
          --project-path ./dist/build/mp-weixin \
          --appid $WECHAT_APP_ID \
          --private-key "$WECHAT_PRIVATE_KEY" \
          --desc "CI自动部署 ${{ github.sha }}"

GitLab CI 示例

yaml
# .gitlab-ci.yml
stages:
  - build
  - deploy

variables:
  NODE_VERSION: "16"

before_script:
  - node --version
  - npm --version

build_mp:
  stage: build
  image: node:$NODE_VERSION
  script:
    - npm install
    - npm run build:mp-weixin
  artifacts:
    paths:
      - dist/build/mp-weixin/
    expire_in: 1 week
  only:
    - main

deploy_mp:
  stage: deploy
  image: node:$NODE_VERSION
  script:
    - npm install miniprogram-ci
    - npx miniprogram-ci upload \
        --project-path ./dist/build/mp-weixin \
        --appid $WECHAT_APP_ID \
        --private-key "$WECHAT_PRIVATE_KEY" \
        --desc "GitLab CI部署 $CI_COMMIT_SHA"
  environment:
    name: production
  only:
    - tags
  dependencies:
    - build_mp

4. 微信小程序 CI 工具配置

安装微信小程序 CI

bash
npm install miniprogram-ci --save-dev

创建上传脚本

javascript
// scripts/upload.js
const ci = require('miniprogram-ci')
const path = require('path')

const project = new ci.Project({
  appid: process.env.WECHAT_APP_ID,
  type: 'miniProgram',
  projectPath: path.resolve('./dist/build/mp-weixin'),
  privateKeyPath: path.resolve('./private.key'),
  ignores: ['node_modules/**/*']
})

;(async () => {
  try {
    const uploadResult = await ci.upload({
      project,
      version: process.env.VERSION || '1.0.0',
      desc: process.env.DESC || '自动构建部署',
      setting: {
        es6: true,
        es7: true,
        minify: true,
        minifyJS: true,
        minifyWXML: true,
        minifyWXSS: true
      },
      onProgressUpdate: console.log
    })
    
    console.log('上传成功:', uploadResult)
  } catch (error) {
    console.error('上传失败:', error)
    process.exit(1)
  }
})()

5. 版本管理策略

package.json 版本控制

json
{
  "name": "my-mini-program",
  "version": "1.0.0",
  "scripts": {
    "dev:mp": "uni -p mp-weixin",
    "build:mp": "uni build -p mp-weixin",
    "build:mp:test": "cross-env NODE_ENV=production UNI_PLATFORM=mp-weixin vue-cli-service uni-build --mode test",
    "build:mp:prod": "cross-env NODE_ENV=production UNI_PLATFORM=mp-weixin vue-cli-service uni-build --mode production",
    "upload:mp": "node scripts/upload.js"
  }
}

环境变量配置

bash
# .env.development
VUE_APP_API_BASE_URL=https://dev-api.example.com

# .env.test
VUE_APP_API_BASE_URL=https://test-api.example.com

# .env.production
VUE_APP_API_BASE_URL=https://api.example.com

6. 自动化测试集成

单元测试配置

javascript
// jest.config.js
module.exports = {
  testEnvironment: 'jsdom',
  moduleFileExtensions: ['js', 'ts', 'vue'],
  transform: {
    '^.+\.vue$': '@vue/vue3-jest',
    '^.+\.(t|j)sx?$': 'babel-jest'
  },
  testMatch: ['**/tests/unit/**/*.spec.(js|ts)|**/__tests__/*.(js|ts)'],
  collectCoverageFrom: [
    'src/**/*.{js,ts,vue}',
    '!src/main.js',
    '!src/App.vue'
  ]
}

E2E 测试配置

javascript
// tests/e2e/specs/test.js
describe('微信小程序测试', () => {
  beforeAll(async () => {
    // 启动微信开发者工具
  })

  it('应该能够正常启动', async () => {
    // 测试逻辑
  })
})

7. 发布流程详解

手动发布步骤

  1. 代码检查

    bash
    npm run lint
    npm run test
    
  2. 构建打包

    bash
    npm run build:mp:prod
    
  3. 本地预览

    bash
    # 使用微信开发者工具打开 dist/build/mp-weixin 目录
    
  4. 上传代码

    bash
    npm run upload:mp
    
  5. 提交审核

    • 登录微信公众平台
    • 进入小程序管理后台
    • 选择刚上传的版本
    • 填写版本说明和更新内容
    • 提交审核

自动化发布流程

mermaid
graph TD
    A[代码提交] --> B{是否为Release分支}
    B -->|是| C[触发CI/CD]
    B -->|否| D[仅构建测试]
    C --> E[安装依赖]
    E --> F[构建小程序]
    F --> G[运行测试]
    G --> H[上传到微信]
    H --> I[发送通知]
    I --> J[等待审核]

8. 监控与回滚

错误监控

javascript
// utils/errorHandler.js
export function initErrorHandler() {
  // 全局错误捕获
  uni.onError((error) => {
    console.error('小程序错误:', error)
    // 上报错误到监控平台
  })
  
  // 页面不存在监听
  uni.onPageNotFound((res) => {
    console.warn('页面未找到:', res.path)
  })
}

版本回滚策略

  1. 保留最近5个历史版本
  2. 记录每个版本的变更日志
  3. 出现严重问题时快速回滚到上一稳定版本

9. 最佳实践建议

代码规范

  1. 使用 ESLint + Prettier 统一代码风格
  2. 遵循 Git Commit 规范
  3. 定期进行 Code Review

性能优化

  1. 图片压缩和懒加载
  2. 分包加载策略
  3. 减少 setData 调用次数
  4. 合理使用缓存

安全考虑

  1. 敏感信息通过环境变量管理
  2. 接口请求使用 HTTPS
  3. 用户隐私数据保护

这套完整的流水线可以帮助你自动化构建、测试和发布 UniApp 微信小程序,提高开发效率和发布质量。

elpis之动态组件机制

作者 aklry
2025年10月8日 12:00

elpis之动态组件机制

动态组件设计

经过上文对于DSL领域模型的理解,我们可以通过一份DSL配置渲染出我们所需要的组件,具体配置如下:

{
    "schemaConfig": {
        "schema": {
            "type": "",
            "properties": {
                "key": {
                    "createFormOption": {
                        "comType": ""
                    },
                    "editFormOption": {
                        "comType": ""
                    },
                    "detailPanelOption": {
                        "comType": ""
                    }
                }
            }
        }
    },
    "componentConfig": {
        "createForm": {
            "title": "",
            "saveText": ""
        },
        "editForm": {
            "title": "",
            "saveText": ""
        },
        "detailPanel": {
            "mainKey": "",
            "title": ""
        }
    }
}

通过上文,我们可以拿到一份完成的schemaConfig配置对象,因为动态组件用于当菜单的moduleType=schema时,页面渲染出对应的schemaView,因此我们在schemaView这个页面结合自定义hook完成对于动态组件DSL->配置对象的转换,具体代码如下:

import { ref, watch, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { useMenuStore } from '$elpisStore/menu'

export const useSchema = () => {
    const route = useRoute()
    const menuStore = useMenuStore()
    const api = ref('')
    const tableSchema = ref({})
    const tableConfig = ref()

    const searchConfig = ref()
    const searchSchema = ref({})

    const components = ref({})

    // 通用构建方法(清除噪音)
    const buildDtoSchema = (_schema, comName) => {
        if (!_schema?.properties) return
        const dtoSchema = {
            type: 'object',
            properties: {}
        }

        // 提取有效 schema 字段信息

        for (const key in _schema.properties) {
            const props = _schema.properties[key]
            const option = props[`${comName}Option`]
            if (option) {
                let dtoProps = {}
                // 提取props中非 option 的部分, 存放到dtoProps
                for (const pKey in props) {
                    if (pKey.indexOf('Option') < 0) {
                        dtoProps[pKey] = props[pKey]
                    }
                }
                // 处理 comName Option
                dtoProps = Object.assign({}, dtoProps, { option })
                // 处理 required
                const { required } = _schema
                const isRequired = required && required.find(pKey => pKey === key)
                if (isRequired) {
                    dtoProps.option.required = true
                }
                dtoSchema.properties[key] = dtoProps
            }
        }

        return dtoSchema
    }

    // 构造schemaConfig 相关配置,传输给schemaView
    const buildData = () => {
        const { key, sider_key: siderKey } = route.query
        const mItem = menuStore.findMenuItem({
            key: 'key',
            value: siderKey ?? key
        })
        if (mItem && mItem.schemaConfig) {
            const { schemaConfig: sConfig } = mItem
            const configSchema = JSON.parse(JSON.stringify(sConfig.schema))
            api.value = sConfig.api ?? ''

            tableSchema.value = {}
            tableConfig.value = undefined
            searchConfig.value = undefined
            searchSchema.value = {}
            components.value = {}

            nextTick(() => {
                // 构建tableSchema 和 tableConfig
                tableSchema.value = buildDtoSchema(configSchema, 'table')
                tableConfig.value = sConfig.tableConfig
                // 构建searchConfig 和 searchSchema
                searchConfig.value = sConfig.searchConfig
                const dtoSearchSchema = buildDtoSchema(configSchema, 'search')
                for (const key in dtoSearchSchema.properties) {
                    if (route.query[key] !== undefined) {
                        dtoSearchSchema.properties[key].option.default = route.query[key]
                    }
                }
                searchSchema.value = dtoSearchSchema
                // 构造components = { comKey: { schema: {}, config: {} } }
                const { componentConfig } = sConfig
                if (componentConfig && Object.keys(componentConfig).length > 0) {
                    const dtoComponents = {}
                    for (const comName in componentConfig) {
                        dtoComponents[comName] = {
                            schema: buildDtoSchema(configSchema, comName),
                            config: componentConfig[comName]
                        }
                    }
                    components.value = dtoComponents
                }
            })
        }
    }

    onMounted(() => {
        buildData()
    })

    watch(
        [() => route.query.key, () => route.query.sider_key, () => menuStore.menuList],
        () => {
            buildData()
        },
        { deep: true }
    )

    return {
        api,
        tableSchema,
        tableConfig,
        searchSchema,
        searchConfig,
        components
    }
}

通过以上代码,我们可以构造出动态组件{ comKey: { schema: {}, config: {} } }的数据结构,comKey表示组件名称

动态组件实现示例

秉持着数据驱动试图的理念,动态组件的实现有如下步骤:

  1. 生成一份组件的配置
  2. 利用配置对象+<component />动态组件完成组件的渲染工作
import CreateForm from './create-form/create-form.vue'
// 编写动态组件配置
const ComponentConfig = {
    createForm: {
        component: CreateForm
    }
}
export default ComponentConfig
<script setup>
import ComponentConfig from './components/component-config'
// 获取动态组件对应的配置对象
const { components } = useSchema()
</script>
<template>
<!-- 完成动态组件的渲染 -->
<component
    :is="ComponentConfig[key].component"
    v-for="(item, key) in components"
    :key="key"
/>
</template>

以上就是动态组件渲染机制的全部内容,在此感谢**《哲玄课堂-大前端全栈实践课》**的大力支持!

❌
❌