day1
fetch("http://localhost:3000");
然后发现 console 出现了错误讯息:

帮 fetch 加上了 no-cors 的 mode:
fetch("http://localhost:3000", {
mode: "no-cors",
}).then((res) => console.log(res));
改完之后重新整理,发现没有错误了,可是印出来的 response 长得特别奇怪:

没有任何资料,而且 status 居然是 0。
no-cors 是个很容易误导初学者的参数,他的意思并不是「绕过 cors 拿到资料」,而是「我知道它过不了 cors,但我没差,所以不要给我错误也不要给我 response」
这里需要后端加上了一个 header:Access-Control-Allow-Origin: *,代表来自任何 origin 的网站都可以用 AJAX 存取这个资源。
后端程式码:
app.get("/", (req, res) => {
res.header("Access-Control-Allow-Origin", "*");
res.json({
data: db.getFormOptions(),
});
});
小明把原本的 mode 拿掉,改成:
fetch("http://localhost:3000")
.then((res) => res.json())
.then((res) => console.log(res));
正常拿到了数据
Day2
后端又多出了一个 API:POST /form,而且这次后端已经把 Access-Control-Allow-Origin 的 header 加上去了:
前端要统一改成用 JSON 当作资料格式,而不是 urlencoded 的资料
fetch(" http://localhost:3000/form ", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
.then((res) => res.json())
.then((res) => console.log(res));
改成用 JSON 的方式传资料到后端。改完之后小明再测试了一遍,发现这一次居然挂掉了,而且出现错误讯息:

切到 network tab 去看 request 的状况,发现除了原本预期的 POST 以外,还多了一个 OPTIONS 的 request:

原来之前发送的那些请求都叫做「简单请求」,只要 method 是 GET、POST 或是 HEAD 然后不要带自订的 header,Content-Type 也不要超出:application/x-www-form-urlencoded、multipart/form-data 或是 text/plain 这三种,基本上就可以被视为是「简单请求」。
一开始串 API 的时候没有碰到错误,是因为 Content-Type 是 application/x-www-form-urlencoded,所以被视为是简单请求。后来改成 application/json 就不符合简单请求的定义了,就变成是「非简单请求」。
那非简单请求会怎么样呢?会多送出一个东西,叫做 preflight request,中文翻作「预检请求」。这个请求就是小明在 network tab 看到的那个 OPTIONS 的 request,针对这个 request,浏览器会帮忙带上两个 header:
- Access-Control-Request-Headers
- Access-Control-Request-Method
以刚刚我们看到的/form 的 preflight request 来说,内容是:
- Access-Control-Request-Headers: content-type
- Access-Control-Request-Method: POST
前者会带上不属于简单请求的 header,后者会带上 HTTP Method,让后端对前端想送出的 request 有更多的资讯。
如果后端愿意放行,就跟之前一样,回一个 Access-Control-Allow-Origin 就好了。知道这点以后,小明马上请后端同事补了一下,后端程式码变成:
app.post("/form", (req, res) => {
res.header("Access-Control-Allow-Origin", "*");
res.json({
success: true,
});
});
//多加这个,让preflight通过
app.options("/form", (req, res) => {
res.header("Access-Control-Allow-Origin", "*");
res.end();
});
改好以后小明重新试了一下,发现居然还是有错误:
Access to fetch at ‘ http://localhost:3000/form ‘ from origin ‘null’ has been blocked by CORS policy: Request header field content-type is not allowed by Access-Control-Allow-Headers in preflight response.
当你的 CORS request 含有自订的 header 的时候,preflight response 需要明确用 Access-Control-Allow-Headers 来表明:「我愿意接受这个 header」,浏览器才会判断预检通过。
而在这个案例中,content-type 就属于自订 header,所以后端必须明确表示愿意接受这个 header:
app.options("/form", (req, res) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", " content-type");
res.end();
});
如此一来,小明那边就可以顺利通过 preflight request,只有在通过 preflight 之后,真正的那个 request 才会发出。
流程会像是这样:
- 我们要送出 POST 的 request 到 http://localhost:3000/form
- 浏览器发现是非简单请求,因此先发出一个 preflight request
- 检查 response,preflight 通过
- 送出 POST 的 request 到 http://localhost:3000/form
所以如果 preflight 没有过,第一个步骤的 request 是不会被送出的。
除此之外,有些产品可能会想要送一些自订的 header,例如说 X-App-Version 好了,带上目前网站的版本,这样后端可以做个纪录:
fetch(" http://localhost:3000/form", {
method: "POST",
headers: { "X-App-Version": "v0.1", "Content-Type": "application/json" },
body: JSON.stringify(data),
})
.then((res) => res.json())
.then((res) => console.log(res));
当你这样做以后,后端也必须新增 Access-Control-Allow-Headers,才能通过 preflight:
app.options("/form", (req, res) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", " X-App-Version, content-type");
res.end();
});
简单来说,preflight 就是一个验证机制,确保后端知道前端要送出的 request 是预期的,浏览器才会放行。我之前所说的「跨来源请求挡的是 response 而不是 request」,只适用于简单请求。对于有 preflight 的非简单请求来说,你真正想送出的 request 确实会被挡下来。
那为什么会需要 preflight request 呢?这边可以从两个角度去思考:
- 相容性
- 安全性
针对第一点,你可能有发现如果一个请求是非简单请求,那你绝对不可能用 HTML 的 form 元素做出一样的 request,反之亦然。举例来说, <form>的 enctype 不支援 application/json,所以这个 content type 是非简单请求;enctype 支援 multipart/form,所以这个 content type 属于简单请求。
对于那些古老的网站,甚至于是在 XMLHttpRequest 出现之前就存在的网站,他们的后端没有预期到浏览器能够发出 method 是 DELETE 或是 PATCH 的 request,也没有预期到浏览器会发出 content-type 是 application/json 的 request,因为在那个时代 <form>跟 等等的元素是唯一能发出 request 的方法。
那时候根本没有 fetch,甚至连 XMLHttpRequest 都没有。所以为了不让这些后端接收到预期外的 request,就先发一个 preflight request 出去,古老的后端没有针对这个 preflight 做处理,因此就不会通过,浏览器就不会把真正的 request 给送出去。
这就是我所说的相容性,通过预检请求,让早期的网站不受到伤害,不接收到预期外的 request。
而第二点安全性的话,还记得在第一篇问过大家的问题吗?送出 POST request 删除文章的那个问题。删除的 API 一般来说会用 DELETE 这个 HTTP method,如果没有 preflight request 先挡住的话,浏览器就会真的直接送这个 request 出去,就有可能对后端造成未预期的行为(没有想到浏览器会送这个出来)。
所以才需要 preflight request,确保后端知道待会要送的这个 request 是合法的,才把真正的 request 送出去。
Day3:带上 Cookie
为什么这些 request 都没有 cookie?我们需要使用者的 cookie 来做分析,请把这些 cookie 带上
此时小明才突然想起来:「对欸,跨来源的请求,预设是不会带 cookie 的」,查了一下 MDN 之后,发现只要带:credentials: ‘include’应该就行了
fetch(" http://localhost:3000/form", {
method: "POST",
credentials: "include",
//新增这个
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
.then((res) => res.json())
.then((res) => console.log(res));
可是没想到前端却出现了错误讯息:

错误讯息其实已经解释得很清楚了,如果要带上 cookie 的话,那 Access-Control-Allow-Origin 不能是*,一定要明确指定 origin。
为什么会这样呢?因为如果没有这个限制的话,那代表任何网站(任何 origin)都可以发 request 到这个 API,并且带上使用者的 cookie,这样就会有安全性的问题产生,大概就跟 CSRF 有异曲同工之妙。
所以因为安全性的关系,强制你如果要带上 cookie,后端一定要明确指定是哪个 origin 有权限。除此之外,后端还要额外带上 Access-Control-Allow-Credentials: true 这个 header。
const VALID_ORIGIN = "http://localhost: 8080";
app.post("/form", (req, res) => {
res.header("Access-Control-Allow-Origin", VALID_ORIGIN); //明确指定
res.header("Access-Control-Allow-Credentials", true); //新增这个
res.json({ success: true });
});
app.options("/form", (req, res) => {
res.header("Access-Control-Allow-Origin", VALID_ORIGIN); //明确指定
res.header("Access-Control-Allow-Credentials", true); //新增这个
res.header("Access-Control-Allow-Headers", "content-type, X-App-Version");
res.end();
});
如果你需要在发送 request 的时候带上 cookie,那必须满足三个条件:
- 后端 Response header 有 Access-Control-Allow-Credentials: true
- 后端 Response header 的 Access-Control-Allow-Origin 不能是*,要明确指定
- 前端 fetch 加上 credentials: ‘include’
这三个条件任何一个不满足的话,都是没办法带上 cookie 的。
除了这个之外还有一件事情要特别注意,那就是不只带上 cookie,连设置 cookie 也是一样的。后端可以用 Set-Cookie 这个 header 让浏览器设置 cookie,但一样要满足上面这三个条件。如果这三个条件没有同时满足,那尽管有 Set-Cookie 这个 header,浏览器也不会帮你设置,这点要特别注意。
事实上呢,无论有没有想要存取 Cookie,都会建议 Access-Control-Allow-Origin 不要设定成*而是明确指定 origin,避免预期之外的 origin 跨站存取资源。若是你有多个 origin 的话,建议在后端有一个 origin 的清单,判断 request header 内的 origin 有没有在清单中,有的话就设定 Access-Control-Allow-Origin,没有的话就不管它。
存取自订 header
新的需求:对 API 的内容做版本控制,后端会在 response header 里面多带上一个 header:X-List-Version,来让前端知道这个选项的清单是哪一个版本。
而前端则是要拿到这个版本,并且把值放到表单里面一起送出。
app.get("/", (req, res) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("X-List-Version", "1.3");
res.json({
data: [
{ name: "1/10活动", id: 1 },
{ name: "2/14特别活动", id: 2 },
],
});
});
由于这一个 API 的内容本来就是公开的,所以没有允许特定的 origin 也没有关系,可以安心使用 wildcard。
小明把之前的程式码改了一下,试着把 header 先列印出来看看:
fetch(" http://localhost:3000")
.then((res) => {
console.log(res.headers.get("X-List-Version"));
return res.json();
})
.then((res) => console.log(res));
此时,神奇的事情发生了。明明从 network tab 去看,确实有我们要的 response header,但是在程式里面却拿不到,输出 null。小明检查了几遍,确定字没打错,而且没有任何错误讯息,但就是拿不到。

如果你要存取 CORS response 的 header,尤其是这种自定义的 header 的话,后端要多带一个 Access-Control-Expose-Headers 的 header 喔,这样前端才拿得到
app.get("/", (req, res) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Expose-Headers", "X -List-Version"); //加这个
res.header("X-List-Version", "1.3");
res.json({
data: [
{ name: "1/10活动", id: 1 },
{ name: "2/14特别活动", id: 2 },
],
});
});
Day5:编辑资料
在送出表单之后后端会给一个 token,前端只要带着这个 token 去打 PATCH /form 这个 API,就能够编辑刚刚表单的内容。
后端
const VALID_ORIGIN = " http :// localhost : 8080 ";
app.patch("/form", (req, res) => {
res.header("Access-Control-Allow-Origin", VALID_ORIGIN);
res.header(" Access-Control-Allow-Credentials", true); //省略编辑的部分
res.json({ success: true });
});
app.options("/form", (req, res) => {
res.header("Access-Control-Allow-Origin", VALID_ORIGIN);
res.header("Access-Control-Allow-Credentials", true);
res.header("Access-Control-Allow-Headers", "content-type, X-App-Version");
res.end();
});
前端
fetch("http://localhost:3000/form", {
method: "PATCH",
credentials: "include",
headers: { "X-App-Version": "v0.1", "Content-Type": "application/json" },
body: JSON.stringify({ token: "test_token", content: "new content" }),
})
.then((res) => res.json())
.then((res) => console.log(res));
然而,在测试的时候,浏览器又跳出错误了:
Access to fetch at ‘ http://localhost:3000/form ‘ from origin ‘ http://localhost:8080 ‘ has been blocked by CORS policy: Method PATCH is not allowed by Access-Control-Allow-Methods in preflight response.
跨来源的请求只接受三种 HTTP Method:GET、HEAD 以及 POST,除了这三种之外,都必须由后端回传一个 Access-Control-Allow-Methods,让后端决定有哪些 method 可以用。
因此后端要改成这样:
// preflight
app.options("/form", (req, res) => {
res.header("Access-Control-Allow-Origin", VALID_ORIGIN);
res.header("Access-Control-Allow-Credentials", true);
res.header("Access-Control-Allow-Methods", "PATCH"); //多这个
res.header("Access-Control-Allow-Headers", "content-type, X-App-Version");
res.end();
});
如此一来,浏览器就知道前端能够使用 PATCH 这个 method,就不会把后续的 request 给挡下来了。
Day6:快取 preflight request
好不容易满足了公司各个大头的需求,没想到在上线前夕,技术这端出问题了。小明原本以为解掉了所有跨来源的问题就行了,可是却忽略了一个地方。在 QA 对网站做压测的时候,发现 preflight request 的数量实在是太多了,而且就算同一个使用者已经预检过了,每次都还是需要再检查,其实满浪费效能的。
于是 QA 那边希望后端可以把这个东西快取住,这样如果同一个浏览器重复发送 request,就不用再做预检。
header:Access-Control-Max-Age,可以跟浏览器说这个 preflight response 能够快取几秒。
app.options("/form", (req, res) => {
res.header("Access-Control-Allow-Origin", VALID_ORIGIN);
res.header("Access-Control-Allow-Credentials", true);
res.header("Access-Control-Allow-Headers", "content-type, X-App-Version");
res.header("Access-Control-Max-Age", 300);
res.end();
});
这样 preflight response 就会被浏览器快取 300 秒,在 300 秒内对同一个资源都不会再打到后端去做 preflight,而是会直接沿用快取的资料。
总结
整串故事看下来,其实你会发现根本没什么前端的事情。前端在整个故事中担任的角色就是:写 code => 发现错误=> 回报后端=> 后端修正=> 完成功能。这也呼应了我之前一再强调的:「CORS 的问题,通常都不是前端能解决的」。
说穿了,CORS 就是藉由一堆的 response header 来跟浏览器讲说哪些东西是前端有权限存取的。如果没有后端给的这些 header,那前端根本什么也做不了。因此无论是前端还是后端,都有必要知道这些 header,未来碰到相关问题的时候才知道怎么解决。
林秀栋的技术博客