阅读视图

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

万字长文:从零实现 JWT 鉴权

一、JWT 鉴权概述

今天来回顾一下之前做的 JWT 鉴权。

JWT(JSON Web Token)鉴权的核心不是加密,而是无状态协议下的身份校验。

在 Express 环境下,一个完整的 JWT 鉴权流程通常包含三个关键环节:

  1. 颁发(Issue):用户登录成功后,服务器生成 Token。
  2. 存储与传递(Storage & Transmission):前端如何保存,请求时如何携带。
  3. 拦截与校验(Middleware):后端如何识别并解析这个字符串。

官方文档(RFC 7519) EN: JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is used as the payload of a JSON Web Signature (JWS) structure or as the plaintext of a JSON Web Encryption (JWE) structure, enabling the claims to be digitally signed or integrity protected with a Message Authentication Code (MAC) and/or encrypted. 中文:JSON Web Token(JWT)是一种紧凑、URL 安全的表示声明的方式,用于在两方之间传输声明信息。JWT 中的声明被编码为 JSON 对象,用作 JSON Web Signature(JWS)结构的载荷或 JSON Web Encryption(JWE)结构的明文,使声明能够通过消息认证码(MAC)进行数字签名或完整性保护,和/或进行加密。

二、后端实现:中间件与 JWT 校验

2.1 中间件的概念与职责

Express 中间件是在请求进入路由处理、响应返回客户端之前,执行逻辑校验、数据加工、拦截等操作的函数。

官方文档(Express 官方文档) EN: Middleware functions are functions that have access to the request object (req), the response object (res), and the next middleware function in the application’s request-response cycle. The next middleware function is commonly denoted by a variable named next. 中文:中间件函数是可以访问应用请求-响应周期中请求对象(req)、响应对象(res)以及下一个中间件函数的函数。下一个中间件函数通常由名为 next 的变量表示。

在实现这个模块时,采用的是后端先行、接口先行的方式,先从后端 API 开始写。

这里有一件值得反思的工程实践:后端接口是否可用,不应该等到所有代码写完以后再去测试,效率较低。更合理的方式是在接口链路打通后,就在 Postman 里测一下是否可用。

在后端工程流程里,可以采用 JWT 官方提供的一些方法。它的调用方式是:中间件就像一道安检,校验是否携带了所需的 token,是否能从 JWT 中拿到需要的状态。

关于中间件的写法,我也是参考官方示例。有一个比较重要的函数是 nextnext 用于声明当前处理完成,然后交给下一个处理程序。next 必须显式调用,否则 request 会一直处于挂起状态,无法返回 response。

官方文档(Express 官方文档) EN: The next function is a callback function that invokes the next middleware function in the stack. If the current middleware function does not end the request-response cycle, it must call next() to pass control to the next middleware function. Otherwise, the request will be left hanging. 中文next 函数是一个回调函数,用于调用堆栈中的下一个中间件函数。如果当前中间件函数未结束请求-响应周期,则必须调用 next() 将控制权传递给下一个中间件函数,否则请求将处于挂起状态。

Express 中错误处理中间件必须定义 4 个参数 (err, req, res, next),只有这样才会被识别为错误捕获中间件;普通中间件/路由处理函数为 2–3 个参数,不存在“两个参数即为终止型中间件”的规则。

官方文档(Express 官方文档) EN: Error-handling middleware functions are defined the same way as other middleware functions, except with four arguments instead of three: (err, req, res, next). 中文:错误处理中间件函数的定义方式与其他中间件函数相同,区别在于需要传入四个参数而非三个:(err, req, res, next)

2.2 auth 中间件实现

express 中间件 auth,用于验证 JWT token 并将用户信息注入到请求对象中

import type { NextFunction, Request, Response } from "express";
import jwt from "jsonwebtoken";

export interface AuthPayload extends jwt.JwtPayload {
  userId: string;
  username: string;
}

export interface AuthRequest extends Request {
  user?: AuthPayload;
}
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
  throw new Error("FATAL ERROR: JWT_SECRET is not defined.");
}
export const auth = (req: AuthRequest, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.split(" ")[1];

  if (!token) return res.sendStatus(401);

  try {
    const decoded = jwt.verify(token, JWT_SECRET);

    if (typeof decoded === "string") {
      return res.status(403).json({ message: "Token invalid" });
    }

    if (!decoded.userId || !decoded.username) {
      return res.status(403).json({ message: "Token invalid" });
    }

    req.user = decoded as AuthPayload;

    next();
  } catch (_) {
    return res.status(403).json({ message: "Token invalid" });
  }
};

2.3 JWT 验证的两个核心问题

我记得这里重要的 api 是 verify,还有弄清楚 JWT 承载用户信息的部分是哪里,要回答两个问题:

1. 如何验证 JWT token?

const decoded = jwt.verify(token, JWT_SECRET);

官方文档(jsonwebtoken 官方文档) EN: The verify function takes a token, a secret or public key, and an optional callback function. It verifies the token's signature, checks if the token is expired, and decodes the payload. 中文verify 函数接收一个 token、一个密钥或公钥,以及一个可选的回调函数。它会验证 token 的签名、检查 token 是否过期,并对载荷进行解码。

jwt.verify() 的执行顺序:先对 Token 做 base64url 解码(无需密钥),再验证签名是否合法,最后检验 exp 等过期/时效声明;任何一步不通过都会抛出错误,解码一定会发生,只是非法结果不会被业务使用。

2. 如何把 JWT 内承载的用户信息注入到 req 里面?

注入发生在验证成功后:

req.user = decoded as AuthPayload;

requser 属性被解密后的明文赋值,注入数据,后续路由就可以通过调用这个中间件得到 token 里包含的信息。

其实也就是中间件在后端的作用。我认为中间件是在 request 和 response 之间进行逻辑校验或数据加工。

请求 → 中间件1 → 中间件2 → 路由处理 → 响应
         ↑
    这里做校验、加工、拦截

JWT 的核心是签名验证,而不是加密。payload 只是 base64url 编码,可以解码查看,但无法篡改,因为篡改后签名会失效。

官方文档(RFC 7519) EN: JSON Web Signature (JWS) is an integrated set of specifications for representing content secured with digital signatures or Message Authentication Codes (MACs) using JSON-based data structures. 中文:JSON Web Signature(JWS)是一套集成的规范集,用于使用基于 JSON 的数据结构表示通过数字签名或消息认证码(MAC)保护的内容。

三、数据层设计:MongoDB 与 Mongoose

把中间件处理、也就是 JWT 校验做好后,就开始设计数据库集合(Collection)的 schema,确定要存入哪些数据、登录需要哪些字段,把 schema 字段和加密逻辑配置好。

import mongoose, { Document, Schema } from "mongoose";
import bcrypt from "bcrypt";

export interface IUser extends Document {
  username: string;
  password: string;
  createdAt: Date;
  updatedAt: Date;
}

const userSchema = new Schema<IUser>(
  {
    username: {
      type: String,
      required: [true, "用户名不能为空"],
      unique: true,
      trim: true,
      minlength: [3, "用户名至少 3 个字符"],
      maxlength: [20, "用户名最多 20 个字符"],
    },
    password: {
      type: String,
      required: [true, "密码不能为空"],
      minlength: [6, "密码至少 6 个字符"],
      select: false,
    },
  },
  {
    timestamps: true,
  },
);
userSchema.pre("save", async function (next) {
  if (!this.isModified("password")) {
    return next();
  }

  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

const User = mongoose.model<IUser>("User", userSchema);

export default User;

其中,密码加密的 pre("save") 中间件负责密码哈希。

官方文档(Mongoose 官方文档) EN: Mongoose schemas support pre and post hooks for middleware functions. These hooks are functions that are executed before or after a certain event (like save, find, etc.) occurs. 中文:Mongoose 的 Schema 支持用于中间件函数的 pre 和 post 钩子。这些钩子是在特定事件(如 savefind 等)发生之前或之后执行的函数。

3.1 关于 MongoDB 和 Mongoose

MongoDB 本身是 schemaless(无模式) 的,意思是:

  • 你可以往同一个集合里存完全不同的结构
  • 没有强制的字段类型、必填校验
  • 没有自动的钩子(如密码加密)

这很灵活,但大型项目里容易造成:

  • 数据混乱(有的文档有 username,有的没有)
  • 业务逻辑散落各处
  • 难以维护

官方文档(MongoDB 官方文档) EN: MongoDB is a document-oriented database program. Classified as a NoSQL database program, MongoDB uses JSON-like documents with optional schemas. 中文:MongoDB 是一个面向文档的数据库程序。作为 NoSQL 数据库程序,MongoDB 使用具有可选模式的类 JSON 文档。

Mongoose 的作用就是给 MongoDB 加上“规矩”:

  • 定义数据结构(Schema)
  • 自动验证类型、必填、长度等
  • 提供钩子(pre/post)自动处理逻辑(如密码加密)
  • 封装常用的 CRUD 方法

官方文档(Mongoose 官方文档) EN: Mongoose is a MongoDB object modeling tool designed to work in an asynchronous environment. Mongoose provides a straight-forward, schema-based solution to model your application data. 中文:Mongoose 是一个设计用于异步环境的 MongoDB 对象建模工具。Mongoose 提供了一种直观的、基于模式的解决方案来为你的应用数据建模。

前端 → 后端控制器(Controller) → 服务层(Service) → Model → MongoDB
  • Model 是数据层(Data Layer):它封装了所有与数据库直接交互的逻辑
  • 业务逻辑层(Service) 调用 Model 的方法来读写数据
  • 控制器(Controller) 处理 HTTP 请求,调用 Service
  • 这样分层的好处:替换数据库时只需改动 Model 层,业务逻辑不变

Model 设计的必要性

  • 集中管理数据规则(验证、加密、默认值)
  • 避免在多个地方重复写密码加密、字段校验的代码
  • 保证数据一致性

四、业务逻辑层:Controller 实现

于是在 model 定义好以后,我们可以写好 controller,对应业务逻辑。这一块比较核心,代码也比较多,我贴出示例的完整代码参考思路:

// ============================================
// authController.ts - 业务逻辑
// ============================================

import jwt from "jsonwebtoken";
import bcrypt from "bcrypt";
import { Request, Response } from "express";
import User from "../models/userModel.js";
import { AuthRequest } from "../middleware/auth.js";

const JWT_SECRET = process.env.JWT_SECRET!;

// 辅助函数:生成 JWT 并组装返回数据
const buildAuthPayload = (user: any) => {
  const token = jwt.sign(
    { userId: String(user._id), username: user.username },
    JWT_SECRET,
    { expiresIn: "24h" }
  );
  
  return {
    token: token,
    user: {
      id: String(user._id),
      username: user.username
    }
  };
};

// ========== 登录 ==========
export const login = async (req: Request, res: Response) => {
  const { username, password } = req.body;
  
  if (!username || !password) {
    return res.status(400).json({ message: "用户名和密码不能为空" });
  }
  
  const user = await User.findOne({ username }).select("+password");
  
  if (!user) {
    return res.status(401).json({ message: "用户名或密码错误" });
  }
  
  const isMatch = await bcrypt.compare(password, user.password);
  
  if (!isMatch) {
    return res.status(401).json({ message: "用户名或密码错误" });
  }
  
  return res.status(200).json(buildAuthPayload(user));
};

// ========== 注册 ==========
export const register = async (req: Request, res: Response) => {
  const { username, password } = req.body ?? {};
  
  if (!username || !password) {
    return res.status(400).json({ message: "用户名和密码不能为空" });
  }
  
  if (password.length < 6) {
    return res.status(400).json({ message: "密码至少6个字符" });
  }
  
  try {
    const existingUser = await User.findOne({ username });
    if (existingUser) {
      return res.status(409).json({ message: "用户名已存在" });
    }
    
    const user = await User.create({ username, password });
    // ↑ 保存时自动触发 pre("save") 钩子加密密码
    
    return res.status(201).json({
      message: "注册成功",
      ...buildAuthPayload(user)
    });
  } catch (error) {
    if (error instanceof Error) {
      return res.status(400).json({ message: error.message });
    }
    return res.status(500).json({ message: "服务器错误" });
  }
};

// ========== 获取当前用户 ==========
export const me = async (req: AuthRequest, res: Response) => {
  try {
    const userId = req.user?.userId;
    
    if (!userId) {
      return res.sendStatus(401);
    }
    
    const user = await User.findById(userId).select("_id username");
    
    if (!user) {
      return res.sendStatus(401);
    }
    
    return res.status(200).json({
      user: {
        id: String(user._id),
        username: user.username
      }
    });
  } catch (error) {
    return res.status(500).json({ message: "服务器错误" });
  }
};

五、路由层:接口注册与请求流程

最后编写注册、登录相关路由。

1. 主应用挂载路由模块
   app.use("/api/auth", authRoutes)
              ↓
2. 请求进入,匹配前缀 "/api/auth"
              ↓
3. 进入 authRoutes 模块,匹配具体路径
   router.post("/login", login)
              ↓
4. 完整路径 = "/api/auth/login"
              ↓
5. 执行对应的控制器函数
import "dotenv/config";
import { Router } from "express";
import { login, me, register } from "../controllers/authController.js";
import { auth } from "../middleware/auth.js";

const router: Router = Router();

router.post("/login", login);
router.post("/register", register);
router.get("/me", auth, me);

export default router;

官方文档(Express 官方文档) EN: A router is an isolated instance of middleware and routes. You can use a router to group related routes together and apply middleware to a subset of your application's routes. 中文:Router 是中间件和路由的独立实例。你可以使用 Router 将相关路由分组,并将中间件应用到应用程序路由的子集上。

导入依赖以后,创建全局 Router 实例,定义路由并进行后端注册,目的是之后前端路由请求可以匹配到后端,后端也就调用相关的 controller 处理数据。

router.get("/me", auth, me) 为例:

  • 方法:GET
  • 路径/me
  • 中间件链authme
  • 场景:获取当前登录用户的信息
  • 执行流程
    1. 请求先进入 auth 中间件
    2. auth 验证 token,把用户信息挂到 req.user
    3. 验证通过后调用 next(),进入 me 控制器
    4. me 控制器从 req.user 读取信息返回

5.1 后端完整请求流程图

以上可以得到后端的完整请求流程图:

┌─────────────────────────────────────────────────────────────────┐
│                        注册流程                                  │
├─────────────────────────────────────────────────────────────────┤
│ POST /api/auth/register { username, password }                  │
│                           ↓                                     │
│ authRoutes → router.post("/register", register)                 │
│                           ↓                                     │
│ register 控制器                                                  │
│   ├── 验证 username/password 存在                                │
│   ├── 验证密码长度 ≥ 6                                           │
│   ├── 检查用户名是否已存在                                       │
│   ├── User.create({ username, password })                       │
│   │        ↓                                                    │
│   │   pre("save") 钩子: bcrypt 哈希密码                          │
│   │        ↓                                                    │
│   │   存入 MongoDB                                               │
│   ├── buildAuthPayload() → jwt.sign() 生成 token                 │
│   └── 返回 { token, user }                                       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                        登录流程                                 │
├─────────────────────────────────────────────────────────────────┤
│ POST /api/auth/login { username, password }                     │
│                           ↓                                     │
│ authRoutes → router.post("/login", login)                       │
│                           ↓                                     │
│ login 控制器                                                     │
│   ├── 验证 username/password 存在                                │
│   ├── User.findOne({ username }).select("+password")             │
│   ├── bcrypt.compare(明文密码, 哈希密码)                          │
│   ├── buildAuthPayload() → jwt.sign() 生成 token                 │
│   └── 返回 { token, user }                                       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                      获取当前用户流程                            │
├─────────────────────────────────────────────────────────────────┤
│ GET /api/auth/me                                                │
│ Header: Authorization: Bearer <token>                           │
│                           ↓                                     │
│ authRoutes → router.get("/me", auth, me)                        │
│                           ↓                                     │
│ auth 中间件                                                      │
│   ├── 提取 token                                                 │
│   ├── jwt.verify(token, JWT_SECRET)                             │
│   └── req.user = { userId, username }                           │
│                           ↓                                     │
│ me 控制器                                                        │
│   ├── userId = req.user?.userId                                 │
│   ├── User.findById(userId).select("_id username")              │
│   └── 返回 { user: { id, username } }                            │
└─────────────────────────────────────────────────────────────────┘

5.2 加密与验证对照表

关于加密与验证:

环节 位置 方法 目的
密码加密 userModel.ts bcrypt.hash() 注册时把明文密码转成哈希存储
密码比对 login 控制器 bcrypt.compare() 登录时验证用户输入的密码
JWT 签发 buildAuthPayload() jwt.sign() 登录/注册成功后生成 token
JWT 验证 auth 中间件 jwt.verify() 后续请求验证 token 有效性

实际上,这个时候应该可以测一测后端接口了,用 Postman 测试一下后端已启动服务时是否能接通。我之前是前端也写了才去测,觉得效率很低。


六、前端实现:路由与状态管理

我来看一下前端的 router 导航

import React from "react";
import { Navigate, Route, Routes } from "react-router-dom";
import App from "../App";
import { LoginPage } from "../components/Login";
import { useAuth } from "../contexts/authContext";

const RequireAuth = ({ children }: { children: React.ReactElement }) => {
  const { user, isLoading } = useAuth();

  if (isLoading) return <div>Loading...</div>;
  if (!user) return <Navigate to="/login" replace />;

  return children;
};

const RedirectIfAuthenticated = ({
  children,
}: {
  children: React.ReactElement;
}) => {
  const { user, isLoading } = useAuth();

  if (isLoading) return <div>Loading...</div>;
  if (user) return <Navigate to="/wiki" replace />;

  return children;
};

const AppRoutes: React.FC = () => {
  return (
    <Routes>
      <Route path="/" element={<Navigate to="/login" replace />} />
      <Route
        path="/login"
        element={
          <RedirectIfAuthenticated>
            <LoginPage />
          </RedirectIfAuthenticated>
        }
      />
      <Route
        path="/wiki"
        element={
          <RequireAuth>
            <App />
          </RequireAuth>
        }
      />
      <Route
        path="/wiki/:docId"
        element={
          <RequireAuth>
            <App />
          </RequireAuth>
        }
      />
      <Route path="*" element={<Navigate to="/login" replace />} />
    </Routes>
  );
};

export default AppRoutes;

官方文档(React Router 官方文档) EN: React Router enables “client side routing” for React apps. It allows you to build single-page applications with navigation that doesn’t require a page refresh. 中文:React Router 为 React 应用提供“客户端路由”能力,允许你构建具有导航功能且无需页面刷新的单页应用。

后端已经通了,前端的作用是发起请求,这里的路由导航只是跳转页面,并且前端服务要遵循所有业务功能都在用户认证通过后才能使用的原则,也就是 service 的服务调用逻辑,这些是前端要做的事情。

6.1 前端 API 服务层

前端 service 封装好 API 调用层,封装与后端认证接口的通信逻辑。

以 auth 为例

import apiClient from "./client";
import type { AuthUser } from "../contexts/authContext";

interface AuthResponse {
  token: string;
  user: AuthUser;
}

export const loginApi = (data: { username: string; password: string }) =>
  apiClient.post<AuthResponse>("/api/auth/login", data);

export const registerApi = (data: { username: string; password: string }) =>
  apiClient.post<AuthResponse>("/api/auth/register", data);

export const meApi = () => apiClient.get<{ user: AuthUser }>("/api/auth/me");

6.2 前后端数据流

完整的数据流

业务代码调用 apiClient.post("/api/auth/me")
                ↓
        【请求拦截器】
   从 localStorage 读取 token
   添加 Authorization: Bearer <token>
                ↓
        发送请求到后端
                ↓
       后端验证 token
                ↓
┌───────────────────────────────────────┐
│ token 有效 → 返回 200 + 用户数据       │
│ token 无效/过期 → 返回 401             │
└───────────────────────────────────────┘
                ↓
        【响应拦截器】
                ↓
┌───────────────────────────────────────┐
│ 200 → 直接返回 response               │
│ 401 → 清除 token,跳转 /login         │
└───────────────────────────────────────┘
                ↓
        业务代码拿到结果

6.3 全局认证状态管理

之前我在想项目逻辑要求登录后才能使用相关功能,也就是原来无登录状态的所有路由,都要在登录路由保护下才能访问。怎样让这些接口自动带上鉴权,认为实现起来比较难。

其实也不是很难,可以用一个 authContext 登录状态的全局状态分发。

import React, { createContext, useEffect, useState } from "react";
import { meApi } from "../services/auth";

export interface AuthUser {
  id: string;
  username: string;
}

export interface AuthContextType {
  // 1. 核心状态:当前用户是谁?
  user: AuthUser | null;

  // 2. 状态:是否正在初始化(从 LocalStorage 加载中)?
  // 提示:这能防止页面在检查 Token 时闪现“未登录”状态
  isLoading: boolean;

  // 3. 方法:登录成功后调用的函数
  // 它需要接收后端给的 token 和 user 对象
  login: (token: string, user: AuthUser) => void;

  // 4. 方法:退出登录
  // 它需要清理 LocalStorage 和 context 状态
  logout: () => void;
}
export const AuthContext = createContext<AuthContextType | undefined>(
  undefined,
);
export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const [user, setUser] = useState<AuthUser | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const initAuth = async () => {
      try {
        const token = localStorage.getItem("token");
        if (!token) {
          return;
        }

        const res = await meApi();
        setUser(res.data.user);
      } catch (_) {
        localStorage.removeItem("token");
        setUser(null);
      } finally {
        // 保证无论成功/失败都结束 loading
        setIsLoading(false);
      }
    };

    initAuth();
  }, []);

  const login = (token: string, user: AuthUser) => {
    localStorage.setItem("token", token);
    setUser(user);
  };

  const logout = () => {
    localStorage.removeItem("token");
    setUser(null);
    window.location.href = "/login";
  };

  return (
    <AuthContext.Provider value={{ user, isLoading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};
export const useAuth = () => {
  const context = React.useContext(AuthContext);
  if (!context) throw new Error("useAuth must be used within an AuthProvider");
  return context;
};

存储方案上选择了 localStorage,因为 sessionStorage 在会话关闭、页面关闭后重新打开需要重新登录,使用起来比较麻烦。

但需要注意:localStorage 易受到 XSS 攻击,生产环境更推荐使用 httpOnly Cookie 存储 JWT。(这一块后续我要了解一下)

这里涉及请求头、axios 实例配置(client.ts)、前端请求拦截器(axios)、前端注册接口、文档相关接口、全局状态管理、登录状态管理等等。

最后在前端导航要做重定向,默认定向到登录页,实现UX上的交互。

六、小结

其实这里的内容是前天做的,中间耽搁了一会儿,当时实现的时候觉得困难重重——主要是因为之前没有建立好后端实现的思路,具体实现的细节,并且也没有相对应的概念。

从项目的角度来讲,我更深体会到了前后端协作,也就是之前看到的前端要懂业务,虽说这里有关前端的细节写得不是太多,但是前端要能知道后端要做什么,从产品的角度来讲每一个开发者都要有全栈能力,但是从公司的角度来说业务体系庞大才拆分的前端与后端等等岗位。

从面试的角度,这里也涉及到很多JWT鉴权细节上的考量,随便深挖就会揪出更多底层原理,例如我随意问一问:什么是 JWT?为什么要用 JWT?JWT 和 Session 的区别?登录流程:如何颁发 Token?请求流程:如何校验 Token?前端怎么存?怎么带?中间件在 JWT 里做什么?

啊哈......现在一看又觉得自己不懂了,只是没有把这些表达再深入内化一下,先慢慢来,慢慢深挖学习更多内容。

坚持学习,坚持反思,加油!

特别声明:本次代码实现仅仅是能跑通功能,并不是优雅的做法,存在设计等层面的缺陷,还请见谅。限于个人经验,文中若有疏漏,还请不吝赐教。

AJAX vs Fetch API:Promise 与异步 JavaScript 怎么用?

今天在学习promise的时候,看到一些比较早的教程,其中提到有一个重要的概念就是AJAX

尽管也许现代的做法更常见的是用Fetch API ,但是我也可以了解一下旧版实现里的做法,也能够帮助理解早期的异步 API,理解老项目的代码是如何做的。

关于异步JS(Promise)的前置知识,有关细节补充可阅读文档:异步 JavaScript 简介

我理解为promise的出现是异步编程中防止传统回调嵌套函数写法(回调地狱)。promise是现代 JavaScript 异步编程的基础。

常常见到的await async等其实是一种语法糖,使得写法简洁易读,并且有关try catch 错误异常的捕获和管理会比较方便(对比于原先采用catch统一管理错误的办法...)。这样的写法看起来是同步代码的长相,其实底层是异步编程。

早期异步Web API: XMLHttpRequest(AJAX)

AJAX全称为Asynchronous JavaScript and XML(异步JavaScript和XML),是一种在无需重新加载整个网页的情况下,能够更新部分网页的技术。

它通过在后台与服务器进行少量数据交换,使得网页可以实现异步更新。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。

示例:

const log = document.querySelector(".event-log");
document.querySelector("#xhr").addEventListener("click", () => {
  log.textContent = "";
  const xhr = new XMLHttpRequest();
  xhr.addEventListener("loadend", () => {
    log.textContent = `${log.textContent}完成!状态码:${xhr.status}`;
  });
  xhr.open(
    "GET",
    "https://raw.githubusercontent.com/mdn/content/main/files/en-us/_wikihistory.json",
  );
  xhr.send();
  log.textContent = `${log.textContent}请求已发起\n`;
});
document.querySelector("#reload").addEventListener("click", () => {
  log.textContent = "";
  document.location.reload();
});
<button id="xhr">点击发起请求</button>
<button id="reload">重载</button>

<pre readonly class="event-log"></pre>

点击“点击发起请求”按钮来发送一个请求。我们将创建一个新的 XMLHttpRequest 并监听它的 loadend 事件。loadend 事件在请求完成时总会触发,无论成功还是失败。如果需要区分成功和失败,可以分别监听 load(成功)和 error(失败)事件。

而我们的事件处理程序则会在控制台中输出一个“完成!”的消息和请求的状态代码。

AJAX的工作原理基于一系列现有的互联网标准,主要包括以下几个方面:

  • XMLHttpRequest对象:这是AJAX的核心,它提供了在网页加载后从服务器请求数据的能力。
  • JavaScript/DOM:用于动态显示和交互的信息。
  • CSS:用于定义数据的样式。
  • XML:作为数据传输的格式,尽管现在JSON格式更为常用。

XMLHttpRequest

XMLHttpRequest API 使 web 应用能够通过 JavaScript 向 web 服务器发起 HTTP 请求并接收响应。这使得网站能够仅更新页面中的部分内容(使用服务器返回的数据),而无需跳转至全新页面。这种做法有时也被称为 AJAX

Fetch API 是取代 XMLHttpRequest API 的更灵活、更强大的方案。

Fetch API 使用 promise 替代事件机制处理异步响应,对 service worker 支持良好,并支持 HTTP 的高级特性,如跨源资源共享控制

基于这些优势,现代 web 应用通常采用 Fetch API 替代 XMLHttpRequest

XMLHttpRequest 用于在后台与服务器交换数据。这意味着可以在不重新加载整个网页的情况下,对网页的某部分进行更新。XMLHttpRequest(XHR)对象用于与服务器交互。通过 XMLHttpRequest 可以在不刷新页面的情况下请求特定 URL,获取数据。这允许网页在不影响用户操作的情况下,更新页面的局部内容。

AJAX能允许网页在不影响用户操作的情况下,与服务器进行数据交换和更新。例如Google地图、新浪微博等,依托核心还是XMLHttpRequest。

实现AJAX

通常需要以下几个步骤:

  1. 创建XMLHttpRequest对象:这是所有AJAX请求的起点。

  2. 发送请求到服务器:使用*open()send()*方法,可以指定请求的类型(如GET或POST),URL以及是否异步。

  3. 处理服务器响应:通过监听onreadystatechange事件,可以在请求的不同阶段执行不同的操作。当readyState属性变为4,且status属性表示请求成功时,可以处理响应数据。

  4. 更新网页内容:使用JavaScript操作DOM,可以根据服务器的响应更新网页的特定部分。

跨域问题和解决方法

在使用AJAX时,可能会遇到跨域问题,即浏览器出于安全考虑,限制了来自不同源的HTTP请求。解决跨域问题的方法包括:

CORS(Cross-Origin Resource Sharing):通过服务器设置适当的HTTP响应头,可以允许特定的外部域访问资源。

JSONP(JSON with Padding):通过动态创建*

AJAX的优势和注意事项

AJAX的主要优势在于提高了用户体验,通过异步更新可以减少等待时间,使得Web应用程序更加快速和响应。然而,也需要注意一些问题,例如:

浏览器兼容性:不同浏览器对AJAX的支持程度可能不同,需要进行充分的测试。

用户体验:需要合理设计用户界面,以便在数据加载过程中给予用户适当的反馈。

网络延迟:应考虑到网络延迟对用户体验的影响,并采取相应的优化措施。

总的来说,AJAX技术使得Web开发进入了一个新的阶段,它允许开发者创建出更加动态和交互性强的网页应用。


使用Fetch API与Promise

如何使用 Promise

MDN的教程已经讲解的非常好了,我们一起来跟着学一学,现代使用Fetch API 的做法。

在基于 Promise 的 API 中,异步函数会启动操作并返回一个 Promise 对象。

首先,Promise 有三种状态:

  • 待定(pending):初始状态,既没有被兑现,也没有被拒绝。这是调用 fetch() 返回 Promise 时的状态,此时请求还在进行中。
  • 已兑现(fulfilled):意味着操作成功完成。当 Promise 完成时,它的 then() 处理函数被调用。
  • 已拒绝(rejected):意味着操作失败。当一个 Promise 失败时,它的 catch() 处理函数被调用。

注意,这里的“成功”或“失败”的含义取决于所使用的 API:例如,fetch() 认为服务器返回一个错误(如 404 Not Found)时请求成功,但如果网络错误阻止请求被发送,则认为请求失败。

有时我们用已敲定(settled)这个词来同时表示已兑现(fulfilled)和已拒绝(rejected)两种情况。

如果一个 Promise 已敲定,或者如果它被“锁定”以跟随另一个 Promise 的状态,那么它就是已解决(resolved)的。

(关于术语:Let's talk about how to talk about promises


然后,你可以将处理函数附加到 Promise 对象上,当操作完成时(成功或失败),这些处理函数将被执行。

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

console.log(fetchPromise);

fetchPromise.then((response) => {
  console.log(`已收到响应:${response.status}`);
});

console.log("已发送请求……");
  1. 调用 fetch() API,并将返回值赋给 fetchPromise 变量。
  2. 紧接着,输出 fetchPromise 变量,输出结果应该像这样:Promise { <state>: "pending" }。这告诉我们有一个 Promise 对象,它有一个 state属性,值是 "pending""pending" 状态意味着操作仍在进行中。
  3. 将一个处理函数传递给 Promise 的 then() 方法。当(如果)获取操作成功时,Promise 将调用我们的处理函数,传入一个包含服务器的响应的 Response 对象。
  4. 输出一条信息,说明我们已经发送了这个请求。
Promise { <state>: "pending" }
已发送请求……
已收到响应:200

与之前的 XMLHttpRequest 不同的是,事件处理程序并不是添加在 XMLHttpRequest 的对象中,我们这一次将处理程序传递到返回的promise对象的then方法里面。

Promise链

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise.then((response) => {
  const jsonPromise = response.json();
  jsonPromise.then((json) => {
    console.log(json[0].name);
  });
});

等等!还记得上一篇文章吗?我们好像说过,**在回调中调用另一个回调会出现多层嵌套的情况?我们是不是还说过,这种“回调地狱”使我们的代码难以理解?**这不是也一样吗,只不过变成了用 then() 调用而已?

当然如此。但 Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态

官方教程划重点:Promise 的优雅之处在于 then() 本身也会返回一个 Promise,这个 Promise 将指示 then() 中调用的异步函数的完成状态

所以以上代码等价于:

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => response.json())
  .then((data) => {
    console.log(data[0].name);
  });

我们需要在尝试读取请求之前检查服务器是否接受并处理了该请求。我们将通过检查响应中的状态码来做到这一点,如果状态码不是“OK”,就抛出一个错误:

const fetchPromise = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    console.log(json[0].name);
  });

错误捕获

const fetchPromise = fetch(
  "bad-scheme://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);

fetchPromise
  .then((response) => {
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    return response.json();
  })
  .then((json) => {
    console.log(json[0].name);
  })
  .catch((error) => {
    console.error(`无法获取产品列表:${error}`);
  });

catch处理函数的输出错误。

  • 注意fetch() 只有在网络层面失败时才会进入 catch。服务器返回 404 或 500 状态码时,Promise 依然是 fulfilled 状态,需要通过 response.ok 手动判断。

合并使用多个promise

有时你需要所有的 Promise 都得到实现,但它们并不相互依赖。在这种情况下,将它们一起启动然后在它们全部被兑现后得到通知会更有效率。这里需要 Promise.all() 方法。它接收一个 Promise 数组,并返回一个单一的 Promise。

Promise.all()

Promise.all()返回的 Promise:

  • 当且仅当数组中所有的 Promise 都被兑现时,才会通知 then() 处理函数并提供一个包含所有响应的数组,数组中响应的顺序与被传入 all() 的 Promise 的顺序相同。
  • 会被拒绝——如果数组中有任何一个 Promise 被拒绝。此时,catch() 处理函数被调用,并提供被拒绝的 Promise 所抛出的错误。
const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.all([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((responses) => {
    for (const response of responses) {
      console.log(`${response.url}${response.status}`);
    }
  })
  .catch((error) => {
    console.error(`获取失败:${error}`);
  });

promise.all用于批量处理不是相互依赖的promise,这样提高了效率,但是弊端是只有全部成功才会成功,如果有一个失败(rejected)则所有all包含在内的promise都不能被兑现。此时错误会用catch抛出。

Promise.any()

有时,你可能需要一组 Promise 中的某一个 Promise 的兑现,而不关心是哪一个。在这种情况下,你需要 Promise.any()

这就像 Promise.all(),不过在 Promise 数组中的任何一个被兑现时它就会被兑现,如果所有的 Promise 都被拒绝,它也会被拒绝。

在这种情况下,我们无法预测哪个获取请求会先被兑现。

const fetchPromise1 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
);
const fetchPromise2 = fetch(
  "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/not-found",
);
const fetchPromise3 = fetch(
  "https://mdn.github.io/learning-area/javascript/oojs/json/superheroes.json",
);

Promise.any([fetchPromise1, fetchPromise2, fetchPromise3])
  .then((response) => {
    console.log(`${response.url}${response.status}`);
  })
  .catch((error) => {
    console.error(`获取失败:${error}`);
  });

async 和 await

async function fetchProducts() {
  try {
    const response = await fetch(
      "https://mdn.github.io/learning-area/javascript/apis/fetching-data/can-store/products.json",
    );
    if (!response.ok) {
      throw new Error(`HTTP 请求错误:${response.status}`);
    }
    const data = await response.json();
    return data;
  } catch (error) {
    console.error(`无法获取产品列表:${error}`);
  }
}

const promise = fetchProducts();
promise.then((data) => console.log(data[0].name));

这里我们调用 await fetch(),我们的调用者得到的并不是 Promise,而是一个完整的 Response 对象,就好像 fetch() 是一个同步函数一样。

我们甚至可以使用 try...catch 块来处理错误,就像我们在写同步代码时一样。

但请注意,这个写法只在异步函数中起作用。异步函数总是返回一个 Promise。也就意味着async 函数总是返回一个 Promise。即使你返回一个普通值,它也会被自动包装成 Promise。

小结与更多Promise

Promise 是现代 JavaScript 异步编程的基础。它避免了深度嵌套回调,使表达和理解异步操作序列变得更加容易,并且它们还支持一种类似于同步编程中 try...catch 语句的错误处理方式。

asyncawait 关键字使得从一系列连续的异步函数调用中建立一个操作变得更加容易,避免了创建显式 Promise 链,并允许你像编写同步代码那样编写异步代码。

Promise 在所有现代浏览器的最新版本中都可以使用;唯一会出现支持问题的地方是 Opera Mini 和 IE11 及更早的版本。

在这篇文章中,我们没有涉及到所有的 Promise 功能,只是介绍了最有趣和最有用的那一部分。随着你开始学习更多关于 Promise 的知识,你会遇到更多有趣的特性。

许多现代 Web API 是基于 Promise 的,包括 WebRTCWeb Audio API媒体捕捉与媒体流等等。

❌