Old-School Front-End · Frameworks / 2026 ・ No.07

古法前端入门 框架篇

第7讲 副作用与数据获取 ── useEffect、use 与 Suspense、真正的 Spring Boot

发布

浏览器画面。React 申请表单上方出现“请登录”栏。事由是大阪出差(10/14〜15),明细 4 行,第 4 行是出租车 35.20 美元。合计 47,953 日元。美元换算为 2026-10-14 为 1 美元 = 150.86 日元,2026-10-15 为 1 美元 = 151.23 日元(从 Spring Boot 取得)。收据文件和勾选都还在。下方是红色提示“登录已失效,未能发送”
登录失效后点击了“提交申请”。输入的内容一个都没消失(笔者于 2026 年 11 月 2 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

到第 6 讲为止,汇率写死为 151.23,发送目标是练习用服务器。第 7 讲连接后端篇做的 Spring Boot,用 useEffect 按日期获取汇率,用 use 和 Suspense 读取登录的人。
然后实测 5 件事:开发用时查询发出 2 次、回复顺序颠倒、被 CORS 拦下、登录后口令改变、登录失效。

一

不再写死

新井要取汇率的话,在 App 里 fetch 然后 setRate 就行了吧。

古井在组件函数里这么写,每次绘制都会查询,setRate 又引起绘制。停不下来。

全 15 讲路线图(现在:第 7 讲)

1 为什么要框架 2 jQuery→MVC 3 React 以后 4 React 第一步 5 状态与重绘 6 表单 7 获取数据 8 重绘的代价 9 状态放哪里 10 页面切换 11 乐观更新 12 Server Comp. 13 缓存 14 测试 15 发布与选择 当前位置 缘由与历史 React 基础 速度与位置 页面与服务器 Next.js 收尾

正如第 5 讲看到的,组件函数是“为了绘制的计算”,会被调用很多次。像查询这样对外部世界做些什么的事(副作用),要和绘制分开,写在 useEffect 里。这次连接的是后端篇第 15 讲的 Spring Boot(4.1.2,Spring Security 7.1.2)。登录用会话 cookie,写入需要口令(CSRF 令牌),沿用第 11 讲的规则。

二

给服务器加 2 个窗口

后端篇的应用里,缺少画面需要的 2 个窗口:当天的汇率,以及一次接收带明细的申请。用一个叫 fw7 的小包补上了。

    /** 那天 1 美元是多少日元。ms 是练习用:故意慢点返回(为了顺序颠倒的实验) */
    @GetMapping("/api/v11/rates")
    public Map<String, Object> rate(@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date,
                                    @RequestParam(defaultValue = "0") long ms) throws InterruptedException {
        BigDecimal rate = Rates.of(date);
        if (rate == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "no rate for " + date);
        Thread.sleep(Math.min(ms, 5000));
        return Map.of("date", date.toString(), "base", "USD", "quote", "JPY", "rate", rate);
    }

    /** 一次接收带明细的申请:申请内容是 JSON(claim),收据是文件(receipt,0 个以上) */
    @PostMapping("/api/v11/claims")
    public ResponseEntity<Map<String, Object>> create(@Valid @RequestPart("claim") ClaimRequest claim,
                                                      @RequestPart(name = "receipt", required = false) List<MultipartFile> receipts,
                                                      @AuthenticationPrincipal LoginUser u) throws Exception {
        ScreenClaimService.Created c = claims.create(claim, receipts == null ? List.of() : receipts, u);
        return ResponseEntity.created(URI.create("/api/v11/claims/" + c.id()))
                .body(Map.of("id", c.id(), "total", c.total().setScale(0, RoundingMode.DOWN)));
    }

汇率是练习用的值(以 2026-10-15 为 151.23,每天稍微变动)。申请的内容用 JSON、收据保持文件形式,用 1 次 multipart 接收。规则和第 6 讲的 validate.js 相同,用 Bean Validation 的标注来写;跨项目的规则(3 万日元以上要收据,超过 5 万日元要理由)在负责的类里检查。

三

用 useEffect 获取汇率

  // 美元行的日期(排好连成 1 个字符串)。只有它变化时才查询汇率
  const usdDates = [...new Set(rows.filter((r) => r.currency === 'USD' && r.date !== '').map((r) => r.date))].sort().join(',');
  useEffect(() => {
    if (usdDates === '') return;
    const ctrl = new AbortController();
    for (const date of usdDates.split(',')) {
      getRate(date, ctrl.signal)
        .then((rate) => setRates((prev) => ({ ...prev, [date]: rate }))) // 按日期存放,所以回复的顺序颠倒也没关系
        .catch((e) => {
          if (e instanceof SignedOut) recheck();
          // AbortError 是清理时停止的查询。什么都不做
        });
    }
    return () => ctrl.abort(); // 清理:日期变了・离开画面时,停止进行中的查询
  }, [usdDates, logins]);

汇率用“那一行日期的”汇率。把美元行的日期排成的字符串 usdDates 放进依赖数组,React 在绘制后和上次比较,只在变化时运行 effect。回复按日期放进 rates,所以不管以什么顺序到达,都只是放进那个日期的位置。

effect 运行的顺序

开发用(StrictMode) 绘制 显示到画面 effect ① 查询 A 清理 ① 停止 A effect ② 查询 B B 的回复 setRates 生产用 绘制 显示到画面 effect 查询 回复 setRates 之后,美元行的日期变化时 清理(停止之前的)→ effect(用新日期) effect 在“绘制之后”运行,清理在“下一次 effect 之前”和“卸下时”运行 StrictMode 只在开发用时故意卸下 1 次再挂上,让你发现忘写的清理
# 登录后打开申请表单(美元行是 10/15 的 1 行)
                              开发用(StrictMode)    生产用
  发出 /api/v11/rates2                       1
  其中中途停止(abort)1                       0
  发出 /api/v11/me1                       1
  名字出现前537 ms                  236 ms
  汇率出现前556 ms                  255 ms
# 时间从用 Playwright 打开时算起。开发用还包含 Vite 的转换

effect 返回的函数是清理。在日期变化时(下一次 effect 之前)和组件从画面上卸下时被调用。这里用 AbortController 停止进行中的查询。

四

开发用时发出 2 次

第三节的表里,只有开发用时汇率查询发出了 2 次,其中 1 次被停止。用一个小组件,改变清理的写法来比较。

# lab.html?lab=effect(StrictMode 中,开发用)。只取 1 次 2026-10-15 汇率的组件
  写法                      发出    setRate   发生顺序
  []・没有清理2       2         effect → cleanup → effect → apply → apply
  []・用 abort 清理2       1         effect → cleanup → effect → AbortError → apply
  没有数组(3 秒)289     288       effect → cleanup → effect → apply → cleanup …
# StrictMode 只在开发用时,把 effect “挂上 → 卸下 → 挂上” 运行 2 次。生产用是 1 次
# 忘写数组时,每次绘制都查询,setRate 又引起绘制。3 秒 289 次

<StrictMode> 只在开发用时把组件卸下一次再挂上,让 effect 运行 2 次。没有清理时,2 个回复都调用了 setRate。这次两边值相同所以无害,但如果是“创建 1 件申请”的 effect,就会产生 2 件。用清理停止第 1 次,结果就和生产用一样。StrictMode 的 2 次,是为了发现忘写清理的机关。

忘写依赖数组时,每次绘制都查询,setRate 又引起绘制,3 秒查询了 289 次。反过来,从数组里漏掉用到的值时,lint 会告诉你。

# 从 App.jsx 的依赖数组删掉 usdDates 后运行 npx oxlint
  src/App.jsx:59:9: warning react-hooks(exhaustive-deps): React Hook useEffect has a missing
  dependency: 'usdDates' help: Either include it or remove the dependency array.
五

回复的顺序会颠倒

查询不一定按发送的顺序返回。把日期做成 1 个状态,连续选择慢的日期和快的日期试试。

3 个小浏览器画面。都是选了 10/14 后马上改为 10/15。① 没有清理:日期是 2026-10-15,却是 1 美元 = 150.86 日元(2026-10-14 的汇率)。② ignore 标志和 ③ AbortController 是 151.23 日元(2026-10-15 的汇率)
只让 10/14 的回复故意晚 1.2 秒。①是迟到的回复后来覆盖了(笔者于 2026 年 11 月 2 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)
# lab.html?lab=race。选 10/14(要 1.2 秒),0.15 秒后选 10/15(0.1 秒)
  写法                    0.4 秒后          2 秒后                    中途停止的查询
  ① 没有清理151.23(15 日)150.86(14 日) 错了-
  ② ignore 标志151.23(15 日)151.23(15 日)-
  ③ AbortController151.23(15 日)151.23(15 日)13, 14
# 都选的是 10/15。①是迟到的 10/14 的回复后来覆盖了

①在 10/15 的汇率正确显示后,迟到的 10/14 的回复又覆盖了它。画面说着“10/15”,却用 10/14 的汇率计算。②在清理时打上“不需要了”的标记,丢掉迟到的回复。③直接停止查询本身,服务器的工作和网络都减少了。申请表单在③之外,还按日期存放,做成不受顺序影响的形式。

六

用 use 和 Suspense 等待

// 只查询 1 次登录的人并记住(用 use 读取的 Promise,不要每次绘制都重新创建)
import { getMe } from './api.js';

let mePromise = null;
export const meOnce = () => (mePromise ??= getMe());
export const forgetMe = () => { mePromise = null; }; // 重新登录后,下次 meOnce() 时重新查询

// App.jsx
<Suspense fallback={<p>{T.meLoading}</p>}>
  <Account key={checks} me={meOnce()} onLogin={onLogin} />
</Suspense>

// components/Account.jsx
/** 谁登录了。me 是 Promise,use 等待其内容(等待期间显示 Suspense 的 fallback) */
export default function Account({ me, onLogin }) {
  const user = use(me);
  if (user) return <p className="small text-body-secondary mb-3" id="who">{T.hello(user.displayName)}</p>;
  return <LoginCard onLogin={onLogin} />;
}

谁登录了,用了另一种写法。use(me) 等待 Promise 的内容,等待期间不绘制组件,而是显示最近的 <Suspense> 的 fallback(“正在确认登录…”)。不需要像 useEffect 那样“先空着绘制,之后再填”,等待的范围可以用 Suspense 决定。

# lab.html?lab=uncached(3 秒内)
  创建 Promise 的地方           /api/v11/me   画面
  组件里每次 getMe()317           一直“等待中…”
  外面 1 次(meOnce)1             新井
# use 等待时组件不被绘制,每次重绘都创建新的 Promise,又再等待。没有出现警告

不过,Promise 要在组件外面只创建 1 次。在组件里写 use(getMe()),每次等待都会引起重绘,每次又创建新的 Promise,再等待。3 秒查询了 317 次,名字始终没出现。也没有警告。这里用 meOnce() 记住 1 个,只在重新登录时忘掉。

七

CORS:拦下的是浏览器

发送目标有 2 种

浏览器(页面是 http://localhost:5173) cookie:JSESSIONID(登录) Vite(5173) 把 /api 转给 8080 OPTIONS(预检) 没有 Allow-Origin 就拦下 Spring Boot(8080) 口令(CSRF)按会话区分 fetch('/api/…') 同源 fetch('http://localhost:8080/…') 不同的源 开发时用左边(Vite 转发)。生产环境由 nginx 起同样的作用(第 15 讲) 要用右边,就在服务器上点名允许的源,并用 credentials: 'include' 发送
  server: {
    // 把以 /api 开头的调用转给 Spring Boot(8080)(从浏览器看是同一个地方。第 6 讲是转给练习用服务器)
    proxy: { '/api': process.env.KEIHI_API ?? 'http://localhost:8080' },
  },

和第 6 讲一样,开发时让 Vite 转发 /api(只是把目标改成 8080)。那么,从页面(5173)直接发到 8080 会怎样?

# lab.html?lab=cors。页面在 5173,只改变发送目标(用 arai 登录后取汇率)
  步骤                      直接发到 8080         直接到 8080+CORS 设置    /api(Vite 转发)
  GET /api/csrfTypeError             200                       200
  POST /api/loginTypeError             200                       200
  GET /api/csrf(第 2 次)TypeError             200                       200
  GET ratesTypeError             200 151.23                200 151.23
  GET rates(不带 cookie)TypeError             401                       200
# TypeError 是 “Failed to fetch”。控制台显示 blocked by CORS policy: No 'Access-Control-Allow-Origin' header
# 即使没有设置,服务器也对 GET 回了 200,连口令都返回了(用 curl 确认)。不让读的是浏览器
# 登录的 POST 会先发 OPTIONS(预检)。有设置时的回答:
  Access-Control-Allow-Origin: http://localhost:5173
  Access-Control-Allow-Methods: GET,POST
  Access-Control-Allow-Headers: x-csrf-token
  Access-Control-Allow-Credentials: true
# 来自其他源(http://evil.example)的预检返回 403

没有设置时,全都是 TypeError: Failed to fetch。有意思的是,服务器其实用 200 回答了。CORS 不是保护服务器的机制,而是由浏览器决定“能不能让页面读取别的源的回复”的机制。只有服务器用 Access-Control-Allow-Origin 允许时,页面才能读到回复。

/** 第 7 讲:允许从别的源(Vite 的 5173)直接调用(只在 --demo.cors-origin=http://localhost:5173 时) */
@Configuration
@ConditionalOnProperty("demo.cors-origin")
public class CorsDemoConfig {
    @Bean
    CorsConfigurationSource corsConfigurationSource(@Value("${demo.cors-origin}") String origin) {
        CorsConfiguration c = new CorsConfiguration();
        c.setAllowedOrigins(List.of(origin));                       // 不用 *,而是点名指定源
        c.setAllowedMethods(List.of("GET", "POST"));
        c.setAllowedHeaders(List.of("Content-Type", "X-CSRF-TOKEN"));
        c.setAllowCredentials(true);                                // 允许带 cookie(登录)
        UrlBasedCorsConfigurationSource s = new UrlBasedCorsConfigurationSource();
        s.registerCorsConfiguration("/api/**", c);
        return s;
    }
}

要带 cookie 发送,源不能用 *,要点名,allowCredentials 设为 true,画面用 credentials: 'include' 发送。不带的话,即使有设置也是 401。这个应用在生产环境也打算用 nginx 把画面和 API 合到同一个源(第 15 讲),所以不用 CORS。

八

CSRF:登录后口令会变

let csrf = null; // 写入用的口令 { header, token }。登录后会变,所以每次都重新领取

/** 通知登录已失效(401) */
export class SignedOut extends Error {}

async function csrfToken() {
  csrf ??= await fetch(BASE + '/api/csrf', { credentials: 'include' }).then((r) => r.json());
  return csrf;
}

async function call(path, { method = 'GET', body, signal } = {}, retried = false) {
  const headers = { 'Accept-Language': T.lang }; // 让服务器的文字(Bean Validation)和画面的语言一致
  if (method !== 'GET') {
    const c = await csrfToken();
    headers[c.header] = c.token; // GET 以外都附上口令(没有就是 403)
  }
  const res = await fetch(BASE + path, { method, body, headers, signal, credentials: 'include' });
  if (res.status === 401) throw new SignedOut();
  if (res.status === 403 && method !== 'GET' && !retried) { // 口令过期了(登录失效・重新登录)。重新领取后只重发 1 次
    csrf = null;
    return call(path, { method, body, signal }, true);
  }
  return res;
}

/** 那天 1 美元是多少日元。那天没有汇率就是 null */
export async function getRate(date, signal) {
  const res = await call(`/api/v11/rates?date=${date}`, { signal });
  return res.ok ? (await res.json()).rate : null;
}

GET 以外,都把从 /api/csrf 领到的口令放在 X-CSRF-TOKEN 里。即使从放在别的网站上的表单被擅自发送,那个网站也不知道口令,所以是 403,这是后端篇第 11 讲的防护。

终端画面。用 curl 在登录前领取口令(CSRF 令牌),用这个口令登录得到 200。用同一个口令发送申请得到 403。重新领取口令后发送得到 201,受理编号 EXP-2026-0012,合计 14720
登录后口令会变。用旧口令的申请被 403 拒绝了(笔者于 2026 年 11 月 2 日在云端 Linux 上拍摄。窗口上方的标题栏是为了说明而补画的)

容易出问题的是,登录后口令会变。用登录前领到的口令发送申请,得到 403(因为 Spring Security 在登录时会重新创建会话和口令)。所以 api.js 在登录后丢掉记住的口令,如果还是 403,就重新领取并只重发 1 次。

九

401:登录失效也不清空

# 不登录就打开 → 错误密码 → 正确密码
  GET /api/v11/me 401
  GET /api/v11/rates 401
  GET /api/v11/me 401
  GET /api/csrf 200
  POST /api/login 401
  POST /api/login 200
  GET /api/v11/me 200
  GET /api/v11/rates 200
# 输错后:用户名栏保留“arai”,密码栏为空

# 写好申请后删除 cookie(登录失效),点击“提交申请”
  GET /api/csrf 200
  POST /api/v11/claims 401
  GET /api/v11/me 401
# 画面:出现登录栏。事由・收据(1 个)都还在
# 重新登录后,再点一次“提交申请”
  POST /api/login 200
  GET /api/v11/rates 200
  GET /api/v11/me 200
  GET /api/csrf 200
  POST /api/v11/claims 201

不登录时,me 和汇率都是 401。getMe() 把 401 解读为“没有登录(null)”,Account 显示登录栏。登录栏保持 <form action>。输错后密码栏变空正合适,只有用户名用第 6 讲的②(把返回的值用 defaultValue 恢复)保留下来。

封面照片是写申请写到一半登录失效的场面。发送后返回 401,Action 让登录栏出现,并返回“输入的内容都还在”。因为第 6 讲已经改成从 onSubmit 发送,事由和收据文件都保留了。重新登录后再按,就是 201。

# 把第 2 行改成 25800,合计 55,643 円,备注为空
  画面的检查:没有发送
  ?check=off:GET /api/csrf 200 → POST /api/v11/claims 400
  合計が5万円を超えるので、備考に理由を書いてください。
# 服务器用 "reason required" 这样的短名字返回,画面换成同样的句子

第 6 讲留下的作业“内容最多 100 个字”和“超过 5 万日元要理由”,也在画面和服务器两边加上了。

# npm run build 的 JavaScript(第 6 讲 → 第 7 讲)
  index-*.js   239.35 kB → 246.10 kB   gzip 75.95 kB → 78.19 kB
十

在自己的电脑上

用 SEED=true docker compose up 启动加上第二节窗口的后端篇第 15 讲的应用,用这时放入的 arai(密码 arai-pass-2026)登录。在 React 项目里运行 npm run dev,在开发者工具的 Network 标签里看看:打开时汇率查询发出 2 次,其中 1 次是“(canceled)”。npm run build 和 npm run preview 时是 1 次。

第五节的实验是 /lab.html?lab=race&how=naive(还有 ignore・abort),第六节是 ?lab=uncached&how=inside。inside 不会停止查询,看完请马上关掉标签。登录失效的场面,可以在开发者工具的 Application 标签里删除 JSESSIONID 的 cookie 来制造。

结 — 一页总结与下一讲

  1. 查询写在 useEffect 里。只在依赖数组变化时运行,用清理停止进行中的查询。
  2. 开发用的 StrictMode 让 effect 运行 2 次(生产用 1 次)。有清理的话结果相同。
  3. 回复的顺序会颠倒。停止或丢掉,最好按日期存放,做成不受顺序影响的形式。
  4. use 和 Suspense 可以决定等待的范围。Promise 在组件外面只创建 1 次(在里面创建,3 秒 317 次)。
  5. CORS 是浏览器的规则。CSRF 口令在登录后会变。遇到 401 不清空输入,而是显示登录栏。

新井连上了!不过每输入 1 个字,组件就全部重绘,这一点还是老样子吧。

古井下一讲是重绘的成本。增加行数用 Profiler 测量,看看 memo 和 React Compiler 能减少多少。

参考

记录是笔者于 2026 年 11 月 2 日在云端 Linux 上取得的。把第 6 讲的项目(Vite 8.3.5、React 19.3.1)连接到后端篇第 15 讲的 Spring Boot 4.1.2(Spring Security 7.1.2、PostgreSQL 16,加上 fw7 的窗口)。开发用(npm run dev)和生产用(npm run build 后用 npm run preview 提供)都用 Playwright 驱动的 Chromium 153.0.8010.12 打开。汇率是练习用的值,不是真实行情。第四〜七节的实验在 lab.html 的另一个画面进行。

古法前端入门 框架篇 第7讲 — 副作用与数据获取 / 2026.11

← 返回文章列表