浏览器原理之跨域?跨站?你真的不懂我!

时间:2022-10-02 18:06:43

  跨域这个东西,额……抱歉,跨域不是个东西。大家一定都接触过,甚至解决过因跨域引起的访问问题,无非就是本地代理,服务器开白名单。但是,但是!你真的知道跨域背后的原理么?嗯……不就是同源策略么?我知道啊。但是你知道为什么要有同源策略么?同源策略限制了哪些内容?又有哪些内容不受同源策略的限制呢?那么,这篇文章,带你搞透、搞懂跨域。

  其实很多东西本质上来说,没有难与不难的标签,只不过,看你是否愿意花心思,时间,精力去总结整理。嗯……我知道你或许没时间,想休息,那么我来帮你。

  花点时间,看完这篇史上最完整的关于跨域的讲解。超过了一万字,而且你要跟着写代码的话,会花更多的时间,除非你做好准备了,否则,随你吧~

  这一部分,我们先来看理论,不涉及任何代码。主要是讲清楚什么是跨域,同源策略的定义和产生的原因,以及什么是站点,站点与域又有啥区别?当我们理解了基本的概念之后,我会带大家梳理浏览器允许HTML加载、引用哪些资源,以及哪些资源会导致跨域,哪些不会。

  开始吧~又要开始长篇~大论了。

一、URL到底是什么?

  嗯?你不是要讲跨域么?你说URL干啥?嗯……因为后面的理解离不开URL。所以我们花点时间,先来理解下前置知识。

  URL,统一资源定位符,Uniform Resource Locator,它是URI的一种子分类,在URI的下面还有一种更罕见的资源使用方式,叫做URN。OK,我们单纯的聊URL又牵扯出来了额外的知识概念,我们一起梳理下。

  URI(Uniform Resource Identifier),叫做统一资源标志符,在电脑术语中是用于标志某一互联网资源名称的字符串。该种标志允许用户对网络中(一般指万维网)的资源通过特定的协议进行交互操作。URI的最常见的形式是统一资源定位符(URL),经常指定为非正式的网址。更罕见的用法是统一资源名称(URN),其目的是通过提供一种途径,用在特定的名字空间资源的标志,以补充网址。

  URN我们很少使用,最常用的一种URI的形式就是URL,所以我们着重分析下什么是URL。通常URI的格式如下:

[协议名]://[用户名]:[密码]@[主机名]:[端口]/[路径]?[查询参数]#[片段ID]

  举个例子:

                    hierarchical part
        ┌───────────────────┴─────────────────────┐
                    authority               path
        ┌───────────────┴───────────────┐┌───┴────┐
  abc://username:password@example.com:123/path/data?key=value&key2=value2#fragid1
  └┬┘   └───────┬───────┘ └────┬────┘ └┬┘           └─────────┬─────────┘ └──┬──┘
scheme  user information     host     port                  query         fragment

  urn:example:mammal:monotreme:echidna
  └┬┘ └──────────────┬───────────────┘
scheme              path

   嗯~我们可以看到URL代表的可不仅仅是http或者https这样的应用层协议,它是一种统一资源的定位方式,并不仅仅局限于超文本传输协议。更加详细的内容可以查看文末链接。

二、域名到底是什么?

  域名,是由一串用点分隔的字符组成的互联网上某一台计算机或计算机组的名称,用于在数据传输时标识计算机的电子方位。域名可以说是一个IP地址的代称,目的是为了便于记忆后者。当我们使用域名的时候,会通过DNS去查找对应的ip,从而找到对应的计算机电子方位。

  域名有一套复杂的定义规则,我们简单了解下。以www.baidu.com为例,其中.com就是*域名,.baidu则是二级域名,www是主机名。

  额~主机名是啥?如果你在服务器手动放置静态HTML资源的时候,会不会发现一般都是放在www文件夹下?这就是主机名,它一般被附在域名系统(DNS)的域名之后,形成完整域名。当然,主机名也不一定非得是www,你可以随便定义。

  *域名的分类有很多,我们要理解它的一些区别:

  1. TLD:即Top-Level Domain,*域名,它是一个因特网域名的最后部分,也就是任何域名的最后一个点后面的字母组成的部分。比如:.com、.net、.edu等。
  2. gTLD:即Generic top-level domain,通用*域名,是供一些特定组织使用的*域,以其代表组织英文名称的头几个英文字母代表,如.com代表商业机构。
  3. ccTLD:即Country Code Top Level Domain,国家*域名,嗯,只供国家使用的,比如.cn。
  4. eTLD:即Effective Top-Level Domain,有效*域名。

  了解这些关于*域名的区别,目前来说足够了。

  我们得详细解释下什么是有效*域名,这是说清楚站点的重点。*域名也就是TLD一般是指域名中的最后一个"."后面的内容,TLD会记录在一个叫做Root Zone Database的列表中,它记录了所有的*域名,*域名并不一定只有一级,也不一定都是短单词。

  有效*域名eTLD,存储在Public Suffix List中,因为*域名并不一定可以被所有需要注册域名的用户所使用,所以用户可以根据*域名注册自己想要的二级域名,比如example.com这样。所以有效*域名的存在根本的原因是让域名的控制权在使用者手中。比如.com.cn或者.github.io就是一个eTLD。而eTLD+1则表示eTLD再加一级域名,也就是a.github.io或者baidu.com.cn。

  为什么要这样搞呢?为了区分用户,隔离数据,这里的用户并不是指域名的注册者,而是指eTLD的用户,比如每一个github用户都会有一个自己的域名,比如xiaoba.github.io,并不需要用户去申请域名,只是用户注册,github会根据你的信息为你注册一个eTLD+1的域名。

  eTLD的主要作用就是为了避免写入太高权限的cookie。

  我觉得上面的内容基本上解释清楚了我们后面所要涉及到核心概念,如果有些不清楚的地方,请去文末链接自行深入了解了。

三、跨域(cross-origin)与跨站(cross-site)

  前两个小节,我们理解了URL,以及域名的概念,无论是跨域还是跨站,其实都是基于这两部分内容展开的。我们本小节就来了解下这俩玩意到底有啥不同。

1、跨域

  我们了解了域名是啥,域是啥。那如果要问你跨域的原因是啥?我相信你,肯定知道,哎呀,不就是同源策略么?嗯……没错,就是同源策略,但是,你知道为什么要有同源策略?为什么同源策略是协议+域名+端口号?我只是域名不行么?我只有协议和域名不行么?为啥偏偏就是这三个加在一起相同才行?这一小小节,我们就来剖析到底什么是跨域,为什么要有同源策略。

  从跨域的字面意思上来讲,再结合我们上一小小节所理解的对与域的定义,可以这样来解释跨域:不同域名之间的访问。但是实际上来说,却远远不止如此。我们注意,同源策略是导致跨域的原因,但是只有不同源的URL才会导致跨域。

  OK,注意我上面这句话加进来的新的概念,首先,我们要确定的是,跨域的定义,并不是指域名不同,或者域不同,而是不同源。其次,同源的定义则是需要协议、域名、端口号三者都相同的URL才行

  所以你看,虽然跨域叫做跨“域”,但是真正的“域”在跨域中只是一部分,虽然这部分很重要,但也只是一部分。

  那么我们到现在为止,终于知道了什么是跨域,并且跨域到底跨了啥。跨域的根本原因就是同源策略,但是为什么要有同源策略呢?搞的这么麻烦,我靠~嗯……都是为了安全。

  举个栗子你想一下噢,假设陌生人可以随便进你家,拿你的东西,还打你的孩子,当然,你也可以进别人家,拿别人家的东西,打别人家的孩子,顺便还在别人家的墙上乱涂乱画,还装修。这他妈不乱套了。嗯,在网络世界,你就可以把你访问的URL下的内容当作某一个人的家,如果主人不允许,你只能在房子外面看看,如果主人允许,那你就可以在别人的房子里装修,拿东西还有打孩子。注意一个重点,要主人的允许。

  总结一下下,浏览器默认两个相同的源之间是可以相互访问资源和操作 DOM 的。两个不同的源之间若想要相互访问资源或者操作 DOM,那么会有一套基础的安全策略的制约,我们把这称为同源策略(same-origin policy)。

  那么问题来了,这套基础的安全策略,也既同源策略,到底限制了两个源之间的那些资源?哪些资源不会限制?嗯……后面会说~

  最后,最后,我好像漏了点东西,就是为什么同源的源必须是协议、域名、端口号的组合才算作是源呢?嗯,因为规范这么定义的。哎呀!别打我~

  额……那我换种方式解释,这个解释纯属我个人的理解。因为互联网中有各种各样的协议,你必须要有统一的协议才能互相通信,这是最大的前提,比如你说英语我说汉语,彼此又都不理解对方说的话,那你俩咋沟通呢?然后,域名,那么有了通信的规则,还需要有通信的人,也就是计算机,域名就是用来确定是哪两台机器要建立通信的。最后,由于一台计算机上可能有很多的软件,或者应用。为了隔离应用之间的权限,那你A应用可以访问B应用的数据,我相信B应用肯定很不开心,我的数据全泄漏了,所以,就有了端口号。

  我们分析后发现,同源策略中的源,是最小可以确定彼此的一种定义

2)跨站

  不知道站点这个概念大家是否有过接触,相比于域,在我们的工作中接触到站点这个名词其实并不多。但是由于我们要聊跨域,就不得不带一点站点,让大家搞清楚什么是站点,以及跨站点又是怎么跨的。

  有了前两个小节的基础,同站点的概念实际上要比同源的概念更好理解一些,因为站点的定义并不涉及到协议和端口号,只要eTLD+1是相同的就视为同站点。我们已经解释过什么是eTLD+1了哦。

  大家理解了什么是eTLD+1,那么也就理解了什么是站点。个人理解,站点是最小化隔离用户的方案。

  理解了站点,实际上跨站点也很好理解了。就是不同的eTLD+1。

  你看,这个小节,好像很简单。哈哈哈哈,那是因为我们做足了准备。

四、不仅仅是同源策略

  前面啊,我们铺垫了很多,卧槽?我读了这么久才都是铺垫~~嗯……好像是的。这一小节,我们要基于前三个小节的内容,聊一聊在一个网页,或者说一个页面通信的限制和策略。嗯,这一小节只讲限制或者策略,不讲解决方案,解决方案我们放到实践里。

  所有的限制和策略,都是为了在绝对的安全和相对的*中做权衡,我既不能让你没有规则,又不能限制你的*。这就是一切的前提。

一、同源策略到底限制了什么?

  我们先想思考一个问题,一个HTML页面,可以引入或者使用哪些资源?嗯~大概有script的src,link的href,a标签的href等等关于具备引入外部链接能力的DOM,再有就是XMLHttpRequest请求,嗯……最后还有cookie、localStorage、sessionStorage。大概可以分为这三种场景或者说类型。

  我们可以把这三种情况做下分类:

  • DOM层面,同源的页面可以互相操作DOM。
  • 数据层面,同源策略限制了不同源的站点读取当前站点的 Cookie、IndexDB、LocalStorage 等数据。
  • 网络层面,同源策略限制了通过 XMLHttpRequest 等方式将站点的数据发送给不同源的站点。

  诶?你上面提到的可以引入外部链接的DOM你没说啊?嗯……同源策略对于外部引用的链接开了一个口子,让其可以任意引用外部资源。这就导致了一个问题,早期的浏览器可以随意饮用外部链接,于是引入的内容就很可能存在不安全的脚本。于是浏览器引入了内容安全策略,即CSP,CSP 的核心思想是让服务器决定浏览器能够加载哪些资源,让服务器决定浏览器是否能够执行内联 JavaScript 代码。

  另外,在很多场景下或者服务器设计上,都可能需要跨域发起ajax请求,如果不能跨域请求,将大大限制网站的设计能力,所以,又有了跨域资源共享(CORS),使用该机制可以进行跨域访问控制,从而使跨域数据传输得以安全进行。

  最后,我们在实际应用中,不同源的两个页面,互相操作DOM的需求也并不是不常见,于是,浏览器中又引入了跨文档消息机制,我们可以通过 window.postMessage 的 JavaScript 接口来和不同源的 DOM 进行通信。

第二部分 实践

  本质上来说,跨域问题前端是解决不了的,或者说能解决的范围很小,只有因域名不同所引起的跨域才可以通过前端的能力去解决,协议和端口号引起的跨域只有前端是不行的。因为我们前面其实说过,很多资源,要由服务器来决定,你一个浏览器还要什么自行车。

  根据前面所说的同源策略限制的三种情况,我们也依次给出各情况的各种解决方案。

一、我们先来说说XMLHttpRequest

  ajax请求的跨域,是我们最常见,也最需要去理解的。所以,我们先来看看如何解决ajax的跨域。也就是网络层面的解决方案。

1、JSONP

只支持Get请求

需要服务器和客户端

算是一种在特定场景下的解决方案,仅供学习,现代架构方案已无太大的实际意义。

  我相信这个解决方案大家可以随口说出来,利用script标签可以访问外部链接的机制,再结合服务器与客户端的配合,就可以访问一个跨域的资源,通过javascript脚本获取数据。

  那为啥偏偏是script标签,img、video、audio啥的标签都可以引用外部资源,这些标签不行么?能问出这个问题非常好,说明你在思考,但是问题是只有script标签可以执行脚本啊,你要是有其它HTML支持的可以执行脚本的标签,那不用script也行。

  那么,我们来看下实现代码吧,我们需要服务器和客户端两个部分。我们先来安装下依赖,需要express框架,嗯,这部分我就不多说了,直接贴上对应部分的代码,具体的demo大家可以在参考资料中我的github中查看。

  先来看下客户端的代码:

let fs = require("fs");
let path = require("path");
const express = require("express");
const app = express();
const port = 4000;
app.get("/", (req, res) => {
  let sourceCode = fs.readFileSync(
    path.resolve(__dirname, "static/index.html"),
    "utf8"
  );

  res.send(sourceCode);
});

app.listen(port);

  很简单哈,就是读取static目录下的文件并返回,监听4000端口,于是,我们的客户端url就是这样的:http://localhost:4000/。那么我们还要看下index.html的代码是什么样的:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body></body>
  <script>
    function jsonp({ url, params, callback }) {
      return new Promise((resolve, reject) => {
        let script = document.createElement("script");
        window[callback] = function (data) {
          resolve(data);
          document.body.removeChild(script);
        };
        params = { ...params, callback };
        let arrs = [];
        for (let key in params) {
          arrs.push(`${key}=${params[key]}`);
        }
        script.src = `${url}?${arrs.join("&")}`;
        document.body.appendChild(script);
      });
    }

    jsonp({
      url: "http://localhost:3000/api",
      params: { wd: "我是参数" },
      callback: "cb",
    }).then((data) => {
      console.log(data);
    });
  </script>
</html>

  完整的代码如上(代码是我抄的),我们要解释下这段代码,jsonp方法返回了一个Promise,这个不多说,我们首先会生成一个script标签,然后给window上绑定一个事件,这个事件名就是我们传入的callback的名字,等待服务器传回执行该方法的字符串。然后我们获取传入的参数,拼接成url的query,作为script标签的src参数,最后在body中插入标签。

  我们看下服务端的代码如何处理:

const express = require("express");
const app = express();
const port = 3000;
app.get("/api", function (req, res) {
  console.log(req.query);
  let { wd, callback } = req.query;
  res.end(`${callback}(${JSON.stringify({ wd: wd })})`);
});

app.listen(port);

  这个服务端的代码也十分简单,就是从路由中获取到query信息,拼接成一个函数执行并且传入参数的形式,在客户端调用的时候返回,也就是在把带拼接后的url的script标签插入到body中的时候,就会返回这个函数执行的字符串。于是就调用了绑定在window上的那个函数。

  其实一点都不复杂,并且,我们接口的地址是:http://localhost:3000。客户端地址我们之前说过了,是http://localhost:4000/,很明显这跨域了。我们实现了通过jsonp的方法来跨域访问的能力。虽然jsonp是通过script的url没有限制访问的方式,实现了跨域的get请求,但是在一些不大的项目场景下,get请求其实完全足够了。

  那么我还有个问题,get请求可以传递数组和对象么?答案请在上文代码中查找,哈哈哈。

  别急,还没完,我还有个问题,那既然,我通过jsonp的方法实现了跨域的ajax请求,那是不是意味着我可以操作DOM,访问cookie?那这一点安全性都没有了啊。嗯,首先我想说的是,其实这属于安全性问题了,但是其实跨域也是在安全策略的范畴内,所以我觉得也还是要说说。嗯,我不在这里说。先挖个坑,等后面再填。

2、CORS

支持各种请求

仅服务器

现代项目的跨域解决方案

  几乎现代所有项目的跨域解决方案都在应用CORS了,也就是跨域资源共享,CORS的本质哈,是新增了一组 HTTP 首部字段,允许服务器声明哪些源站通过浏览器有权限访问哪些资源。

  另外,规范要求,对那些可能对服务器数据产生副作用的 HTTP 请求方法(特别是 GET 以外的 HTTP 请求,或者搭配某些 MIME 类型 的 POST 请求),浏览器必须首先使用 OPTIONS 方法发起一个预检请求(preflight request),从而获知服务端是否允许该跨源请求。服务器确认允许之后,才发起实际的 HTTP 请求。在预检请求的返回中,服务器端也可以通知客户端,是否需要携带身份凭证(包括 Cookies 和 HTTP 认证 相关数据)。

  那么现在我们知道,CORS本质是一组HTTP首部字段,CORS分为简单请求和预检请求。

  详细的有关于简单请求和预检请求的可以查看这里:若干访问场景控制。我在本篇就简单解释下,满足后续的实验性代码需求即可。

  简单请求允许特定的HTTP Methods如:GET、HEAD、POST三个方法,并且允许认为设置的头字段为:Accept、Accept-Language、Content-Language以及有条件的Content-Type。这里条件是指,Content-Type只能是以下三者:

  • text/plain
  • multipart/form-data
  • application/x-www-form-urlencoded

  预检请求,就是超出上述场景的请求,会预先发起一个options请求来确定服务器是否允许客户端这样这样,那样那样的操作。那么我们来看个具体的例子吧,逼逼赖赖,show me the code。

一、CORS简单请求示例

  client的代码我们不用修改,就按照之前的那样就行,然后我们修改下index.html的代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body></body>
  <script>
    const xhr = new XMLHttpRequest();
    const url = "http://localhost:3000/api";

    xhr.open("GET", url);
    xhr.onreadystatechange = function () {
      if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
        console.log(xhr);
        console.log(xhr.responseText);
      }
    };
    xhr.send();
  </script>
</html>

  很简单哈,就是一个XMLHttpRequest,打印了一下结果。继续我们看下server端的代码,也只需要稍微修改一下:

const express = require("express");
const app = express();
const port = 3000;
app.get("/api", function (req, res) {
  res.end(
    JSON.stringify({
      type: "cors",
      message: "ok",
      code: 1,
      body: { content: [{ a: 1, b: 2 }], page: 1, total: 10 },
    })
  );
});

app.listen(port);

  也没啥哈,就是返回个JSON,然后,我们修改下package.json的script:

"scripts": {
  "jsonp-client": "node ./jsonp/client.js",
  "jsonp-server": "node ./jsonp/server.js",
  "jsonp": "concurrently \"yarn jsonp-client\" \"yarn jsonp-server\"",
  "cors-client": "node ./cors/client.js",
  "cors-server": "node ./cors/server.js",
  "cors": "concurrently \"yarn cors-client\" \"yarn cors-server\""
},

  嗯,就是加了cors的部分,然后我们yarn cors一下,我们看到了我们好像很熟悉的内容:

浏览器原理之跨域?跨站?你真的不懂我!

   诶?我忽然想起来一个问题,跨域的请求从浏览器发出,最后到达服务器了么?浏览器又接收到服务器的结果了么?答案是肯定的,实际上跨域的请求从浏览器发出,并被服务器接收,因为只有到了服务器才能知道是不是跨域啊,不然咋做后续的可能的额外的逻辑呢?而且,服务器返回的信息也被浏览器接受到了,只是浏览器认为这不安全,不给你罢了。

  额~跑题了,我们继续。那,咋解决跨域的问题呢?Access-Control-Allow-Origin?嗯~倒是也没错~我们在服务器端设置一下:

const express = require("express");
const app = express();
const port = 3000;
app.get("/api", function (req, res) {
  res.set("Access-Control-Allow-Origin", "*");
  res.end(
    JSON.stringify({
      type: "cors",
      message: "ok",
      code: 1,
      body: { content: [{ a: 1, b: 2 }], page: 1, total: 10 },
    })
  );
});

app.listen(port);

  然后,我们发现,请求可以啦~~~

浏览器原理之跨域?跨站?你真的不懂我!

   并且,我们拿到了返回的数据:

浏览器原理之跨域?跨站?你真的不懂我!

   完美~诶?为啥返回的是字符串啊,不应该是个对象么?那是因为你用的框架帮你处理了,比如axios。

  具体的响应头字段变化,我们可以看到:

浏览器原理之跨域?跨站?你真的不懂我!

   没问题吧?哈哈,我们看下一个复杂点的例子~

二、CORS预检请求示例

  简单请求的CORS很简单对吧?直接一个响应头就解决了如此让人烦躁的跨域问题,那我怎么试一下复杂请求,也就是预检请求的跨域?服务器的代码我们暂时不动,修改下客户端代码:

    const xhr = new XMLHttpRequest();
    const url = "http://localhost:3000/api";

    xhr.open("GET", url);
    xhr.setRequestHeader("X-NAME", "zaking");
    xhr.setRequestHeader("Content-Type", "application/xml");
    xhr.onreadystatechange = function () {
      if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
        console.log(xhr);
        console.log(xhr.responseText);
      }
    };
    xhr.send();

  我们只是多加了两个请求头,一个是Content-Type,但是它的值却不是我们知道的符合简单请求要求的值,另外我们还设置了一个自定义的请求头,不出意外的,肯定报错了:

浏览器原理之跨域?跨站?你真的不懂我!

   但是这个报错,我们仔细读一下,跟之前的那个报错还不一样,这里的报错说的是:预请求的响应没有通过检查,因为请求的资源没有提供跨域允许的头字段。我们再看下Network请求:

浏览器原理之跨域?跨站?你真的不懂我!

   有两个请求,一个预请求,一个真正的请求,我们看下预请求的请求头:

浏览器原理之跨域?跨站?你真的不懂我!

   没有我们设置的请求头字段,但是却多了这两个,当然,还有其他的,是浏览器根据你的请求默认设置的,因为是OPTIONS请求,所以你会发现啥参数都没带,实际上就是客户端与服务器的预先确认,防止无效的信息传递。

  那,要怎么解决呢?我们修改下服务器代码:

const express = require("express");
const app = express();
const port = 3000;
app.use(function (req, res, next) {
  res.set({
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Headers": "X-NAME,Content-Type",
  });
  if (req.method === "OPTIONS") {
    res.end(); // OPTIONS请求不做任何处理
  }
  next();
});
app.get("/api", function (req, res) {
  res.end(
    JSON.stringify({
      type: "cors",
      message: "ok",
      code: 1,
      body: { content: [{ a: 1, b: 2 }], page: 1, total: 10 },
    })
  );
});

app.listen(port);

  app.use接收一个函数,会在匹配路由的时候触发,我们增加了一个跨域允许的头字段,Access-Control-Allow-Headers,把我们需要传递的头字段加进去就可以了,也包括我们自定义的头字段,这样就可以了~

  具体的响应大家可以自己去写下代码体验下哦~

3、WebSocket

几乎不会作为跨域的解决方案

  WebSocket想必大家都有所了解,我想了又想,应不应该把WebSocket归属于XMLHttpRequest范畴下,但是我又想了想,不放在这里,放在哪个分类下都不太合适,嗯~就放这吧。

  先简单解释下WebSocket吧,WebSocket是由HTML5规范并定义的一种全双工通信通道,和HTTP一样,是基于TCP/IP协议的一种应用层通信协议,相较于经常需要使用推送实时数据到客户端甚至通过维护两个HTTP连接来模拟全双工连接的旧的轮询或长轮询(Comet)来说,这就极大的减少了不必要的网络流量与延迟。

  通过WebSocket协议,我们可以跨域访问,为啥WebSocket不受同源策略的限制呢?嗯~~我没研究过WebSocket协议,有兴趣大家可以自行研究下,这里不再展开,不过我觉得根本原因就是WebSocket不是HTTP协议,WebSocket协议允许跨域,或者说WebSocket就是允许这样搞嘛。

  OK,那么我们来实现下代码吧,页面结构都跟之前一样哈,我们先修改下客户端代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body></body>
  <script>
    // Create WebSocket connection.
    const socket = new WebSocket("ws://localhost:3000");

    // Connection opened
    socket.addEventListener("open", function (event) {
      socket.send("Hello Server!");
    });

    // Listen for messages
    socket.addEventListener("message", function (event) {
      console.log("Message from server ", event.data);
    });
  </script>
</html>

  然后是服务器代码,我们需要先安装下ws模块,嗯~就是一个node的WebSocket的模块,可以让我们更简单的使用WebSocket,服务器代码如下:

let WebSocket = require("ws"); //记得安装ws
let wss = new WebSocket.Server({ port: 3000 });
wss.on("connection", function (ws) {
  ws.on("message", function (data) {
    console.log(data);
    ws.send("Hello Client");
  });
});

  嗯~我们根本没用express,只要ws就可以了。

  然后,我们就可以看到结果了,由于WebSocket有一套完整的协议规则,与HTTP并不相同,这里仅作为HTTP跨域的一种解决方案,不多说了。

4、小小的总结

  我们稍微回顾一下上面的三种解决方案,Jsonp,CORS,WebSocket,我们发现这些解决方案都离不开服务器,换句话说,服务器才是真正解决问题的源头,浏览器能做的事情其实是十分有限的。那,为什么浏览器在真正解决跨域的方案上并不重要呢?嗯~~~你一个浏览器想要多大的权限?浏览器在理论上讲,仅仅只是数据展示的形式,你想要随随便便就能去别人家里拿银行卡取钱么?显然是不现实的,嗯~~~这就是一切的原因。浏览器只能是,也必然是有限的访问权限。

  那么,上面的三种解决方案,都是基于应用层协议的解决方案,无论是JSONP、CORS还是WebSocket都是基于应用层协议,这是他们的一个共通点,并且,这些权限仅限于数据的获取,也即通过应用层协议与服务器交互数据

  诶?我既然可以和服务器交互数据了,那我能不能在www.a.com/index.html获取www.b.com的服务器的数据,然后再让www.c.com/index.html获取数据做修改?等等等等这样,反正就是通过某个浏览器粗行口修改服务器数据再让另一个浏览器窗口获取修改后的数据从而通过javascript脚本来修改页面DOM。额~~肯定可以,但是这好像有点脱裤子放屁?!

  当然,上面说的这种乱码七糟的方式不是不行,但是它所涉及到的问题就与跨域无关了,我个人觉得,它算是服务器架构设计了,哇~~~这个词好高大上,我真的不会。

二、代理(其实我应该算做“一”)

  代理想必大家很熟悉啦,我们用vue-cli生成一个项目默认就安装了代理模块,简单看下文档,配置几个参数就能实现本地代理,从而实现本地开发环境的数据远程访问,嗯~~是数据,也就是HTTP,所以我在标题才说应该算是“一”嘛。

  代理的更多内容我不多说了,大家有兴趣可以看我的前端运维系列的一篇文章,那里有很详细的解释。我简单解释下什么是代理,代理在这里的全称叫做代理服务器,服务器?嗯~没错,代理服务器也是一种服务器,换句话说,因为同源策略是客户端(或者说浏览器)的,是为了限制客户端访问权限,而服务器则压根没有这个什么垃圾同源策略,所以,我们搞一个服务器,假装与你的客户端同源,然后代理服务器在中间帮你转一下,这不就可以了嘛。

  就好像你想去银行取钱,结果发现不是本人,哪怕你拿着银行卡,银行肯定也不给你钱啊,但是你可以假装成本人,呢~在现实中假装一个人可没那么容易,但是在互联网络中,或者再小一点,在同源策略下,假装一个“人“还是挺容易的。

1、正向代理

  正向代理,就是指代理服务器为客户端代理,真实服务器接收到的是代理服务器发起的请求,真实服务器并不知道真实的客户端到底是谁。

  那么在本地环境的Node正向代理的场景下,整个请求的流转大致是这样的,在index.html(举个例子)向远程服务器发起请求的时候,Node代理服务器会拦截这个请求,并把该请求转发给远程服务器,当Node代理服务器接收到远程真实服务器的响应后,再次把结果响应给本地的客户端。

  核心在于本地Node代理服务器是如何接收和发送以及返回响应的,我们来看下代码,基本的代码,我们就用cors那部分作为基础修改就好了,还是4000端口的页面去访问3000端口的api,只不过之前cors的时候并没有经过转发。

  首先,我们先修改下端口号3000的server代码,让它作为中间的代理服务器,并且把名字修改成prxoy.js:

const http = require("http");
const server = http.createServer((request, response) => {
  response.writeHead(200, {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "*",
    "Access-Control-Allow-Headers": "Content-Type",
  });
  http
    .request(
      {
        host: "127.0.0.1",
        port: 6000,
        url: "/api",
        method: request.method,
        headers: request.headers,
      },
      (serverResponse) => {
        var body = "";
        serverResponse.on("data", (chunk) => {
          body += chunk;
        });
        serverResponse.on("end", () => {
          console.log(body);
          response.end(body);
        });
      }
    )
    .end();
});
server.listen(3000);

  我们看下这段代码,跟之前的不太一样了,之前我们利用express,但是现在我们直接用Node的HTTP模块,生成了一个HTTP服务,并且这个服务设置了响应头,也就是我们之前允许跨域的那些响应头,然后呢直接通过http模块的request向http://localhost:6000/api,发起了请求,并把得到的响应结果拼凑和返回,并不复杂,对吧,然后我们再创建一个server.js,代码类似:

const http = require("http");
const server = http.createServer((request, response) => {
  response.end(
    JSON.stringify({
      type: "cors",
      message: "ok",
      code: 1,
      body: { content: [{ a: 1, b: 2 }], page: 1, total: 10 },
    })
  );
});
server.listen(6000);

  代码很简单,就是返回个数据罢了。那么OK,我们现在核心的都做完了,我们在package.json中加上几句话:

"node-proxy-client": "node ./node-proxy/client.js",
"node-proxy-proxy": "node ./node-proxy/proxy.js",
"node-proxy-server": "node ./node-proxy/server.js",
"node-proxy": "concurrently \"yarn node-proxy-client\" \"yarn node-proxy-server\" \"yarn node-proxy-proxy\""

  都知道啥意思吧,然后,我们打开两个命令行工具,分别启动3000的代理服务器和6000的远程服务器,最后在打开浏览器,访问本地3000端口,你看看啥效果:

浏览器原理之跨域?跨站?你真的不懂我! 

   直接页面中就显示了返回的结果,注意,我们现在仅仅是服务端的交流,跟跨域没关系的对吧?然后,我们跑一下yarn node-proxy再看下结果?

浏览器原理之跨域?跨站?你真的不懂我!

   完美~

  我额外要多说两句,上面代理的代码仅是例子,最小化证明我们方案的可行性,就本地代理来说,你可以使用node插件,可以使用webpack,等等等等,解决本地代理的手段和方法非常之多,但是其核心原理无非就是让浏览器与服务器的通信,变成服务器与服务器之间的通信。

2、反向代理

  反向代理,简单来说就是代理服务器代理的是真实服务器,客户端并不知道真正的服务器是什么。

  当我们发起请求的时候,是经过反向代理服务器来拦截过滤一遍,是否允许转发给真实的服务器,基本上现代的服务器架构都会使用Nginx作为代理服务器去处理网络请求,在现在的场景下大家有所了解就好。

  既然我们要实现反向代理,那么我们需要在本地安装下nginx,至于安装方法,大家自行百度吧(文末或许有惊喜哦,先百度再看)。

  安装完成之后,就可以在命令工具中试一下安装成功没有:

naginx -v

  出现版本号,说明我们安装OK啦。然后,理论上讲,你的nginx配置文件在这里:/usr/local/etc/nginx/nginx.conf。我们需要修改这个nginx.conf配置文件:

server {
    listen       8080;
    server_name  localhost;

    location / {
        proxy_pass  http://localhost:3000;
        add_header Access-Control-Allow-Origin http://localhost:4000;  
        root   html;
        index  index.html index.htm;
    }
}

  其他的当我们安装的时候就存在了,重点就是这两行代码。一个是需要代理的服务器,一个是我们设置的响应头。当然,注意,我们只设置了一个跨域的响应头,所以目前只支持简单请求。我们修改下客户端代码,删除掉额外设置的请求头,然后,我们还需要删除之前cors的时候在服务器设置的允许跨域请求的那部分代码,我相信你知道我说的是什么,就不贴代码了哦,有问题我相信你也可以自行解决了。

  然后,我们在package.json中按照惯例的再加上点脚本:

"nginx-proxy-client": "node ./nginx-proxy/client.js",
"nginx-proxy-server": "node ./nginx-proxy/server.js",
"nginx-proxy": "concurrently \"yarn nginx-proxy-client\" \"yarn nginx-proxy-server\""

  最后,我们需要启动下:

yarn nginx-proxy

  还没完,我们还需要启动nginx:

sudo brew services restart nginx   

  那么~~见证奇迹的时刻:

浏览器原理之跨域?跨站?你真的不懂我!

  又一次~完美~  

三、DOM操作?算是吧(其实我觉得这不算是跨域操作DOM,就这样吧)

  前面第一二章哈,都与服务器有关,没了服务器毛都干不了,那客户端就啥也干不了了?浏览器就这么垃圾?嗯,那肯定不是(其实我是骗你往下看),这一大章,我们就来看看浏览器有哪些能力来解决跨域的问题。

  解析来的内容我们需要另外一套代码,这回与服务器没关系了,好像与ajax请求也没关系了,只是跨域的页面在浏览器中想要干点什么。

  嗯~~我们先来看代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index.html
  </body>
</html>

  这是其中一个html的代码,然后是启动服务的client1.js:

let fs = require("fs");
let path = require("path");
const express = require("express");
const app = express();
const port = 3001;
app.get("/", (req, res) => {
  let sourceCode = fs.readFileSync(
    path.resolve(__dirname, "static/index1.html"),
    "utf8"
  );

  res.send(sourceCode);
});

app.listen(port);

  不复杂,就是我们之前的代码,还没完事,你要复制出来一份,index2.html,以及client2.js,不过client2.js中的端口号是3002。然后我们加上package.json的脚本:

"postmessage-client1": "node ./postmessage/client1.js",
"postmessage-client2": "node ./postmessage/client2.js",
"postmessage": "concurrently \"yarn postmessage-client1\" \"yarn postmessage-client2\""

  然后,我们启动服务,分别访问3001和3002,一点毛病没有,可以看到我们的页面内容。那么基本的框架我们完事了哦。下面要看我们的核心内容了。

1、postMessage

特别重要

很有用

与服务器无关

  在我们之前代码的基础上,我们来写一下postMessage,哦对,在写之前我得先简单说下什么是postMessage。

  window.postMessage() 方法可以安全地实现跨源通信。通常,对于两个不同页面的脚本,只有当执行它们的页面位于具有相同的协议(通常为 https),端口号(443 为 https 的默认值),以及主机 (两个页面的 Document.domain设置为相同的值) 时,这两个脚本才能相互通信。window.postMessage() 方法提供了一种受控机制来规避此限制,只要正确的使用,这种方法就很安全。也即只有同源的两个页面脚本才可以互相通信。

  一个窗口可以获得对另一个窗口的引用(比如 targetWindow = window.opener),然后在窗口上调用 targetWindow.postMessage() 方法分发一个 MessageEvent 消息。OK,我们来写下代码吧,在index1.html中添加点javascript脚本:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index.html
  </body>
  <script>
    window.postMessage("hello index2", "*");
    window.addEventListener("message", (e) => {
      console.log(e, "I am from index1");
    });
  </script>
</html>

  看起来不错,然后同样的,在给index2.html加点脚本:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index2.html
  </body>
  <script>
    window.postMessage("hello index1", "*");
    window.addEventListener("message", (e) => {
      console.log(e, "I am from index2");
    });
  </script>
</html>

  看起来也很不错。我们启动下,看下控制台:

浏览器原理之跨域?跨站?你真的不懂我!

  嗯~~~嗯?好像不太对,我们在看另外一个:

浏览器原理之跨域?跨站?你真的不懂我!

   嗯~~~草,不对啊,你这不对啊,3001端口应该打印hello index1,3002应该打印hello index2。你这只在自己和自己玩呢啊?

  嗯哼……我承认你是最强的。额……我承认你说的对,为啥会这样,这也没通信成功啊。因为我们缺少了尤为关键的一点。

  一个窗口可以获得对另一个窗口的引用(比如 targetWindow = window.opener),然后在窗口上调用 targetWindow.postMessage() 方法分发一个消息。

  必须!必须!获得另外一个窗口引用才可以!换句话说,两个单独的,独立的,互相没有跳转关系的页面,postMessage也不行!那?想一想,我有哪些方法可以解决这个引用才可以呢?

1)通过a标签获取window.opener

  window.opener会返回打开当前窗口的那个窗口的引用,例如:在 window A 中打开了 window B,B.opener 返回 A。那么,我们需要加点代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index.html
    <a target="_blank" rel="opener" href="http://localhost:3002/"
      >打开index2.html</a
    >
  </body>
  <script>
    window.addEventListener("message", function (e) {
      console.log(e);
    });
  </script>
</html>

  这代码很简单没啥好说的,先让程序跑起来,等会再说重点,我们再来看一下,index2.html咋写:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index2.html
  </body>
  <script>
    const target = window.opener;
    target.postMessage("hello I am from 3002", "http://localhost:3001/");
  </script>
</html>

  也很简单。我们启动下服务。打开localhost:3001。然后点击a标签,跳转到3002,然后回到3001,我们可以看到打印的结果:

浏览器原理之跨域?跨站?你真的不懂我!

   那,我想要实现双向通信咋整?嗯。。继续加点代码,在index1.html中,可以这样:

window.addEventListener("message", function (e) {
  console.log(e);
  e.source.postMessage("hello index2 I am Index1", e.origin);
});

  通过返回的事件的source可以获取到对方window得引用,然后e.origin就是对方的源地址。这样就可以把信息再传递过去,然后index2.html中接收一下即可:

const target = window.opener;
target.postMessage("hello I am from 3002", "http://localhost:3001/");
window.addEventListener("message", (e) => {
  console.log(e);
});

  OK,我们就这样实现了postMessage跨域的双向通信。但是,其实在我写实验性的代码中遇到了很多问题,这些问题很重要,我简单总结下:

  1. a标签打开另外一个窗口时,必须携带rel="opener"才可以让被打开的页面通过window.opener获取到父页面的应用。我之前以为只要HTTP的请求头中带了referer,那么就可以通过window.opener获取到,但是实验后发现这是两回事。
  2. 只有在双方页面都加载完毕后postMesaage才会生效!
  3. 基于第二点,如果你不是新打开一个标签页,也就是target不是_blank的话,也不行!

  其实,我觉得现在有点跑题了,真的,很多内容其实与跨域的关系并不大了。但是没办法,讲到这了,就得说清楚。

  那么以上是通过a标签打开的新窗口,下面我们看下另外一种方式,我写这篇文章的时候真没想到会写这么多东西,早知道我就分两三篇了,算了,懒得再开一个,要是看不下去就看不下去吧。

  为了更清晰一点,我们把当前的postmessage文件夹下的代码放到a-tag文件夹下,并且修改下启动脚本,不多说了。

2) 通过window.open获取window.opener

  这部分的代码,我们先在postmessage的文件夹下新建一个open-fun文件夹,然后,把上一小节的a-tag文件夹下的内容复制过来,然后,修改script脚本,具体的去文末的demo地址看吧,不赘述了。

  我们直接上代码吧,首先是index1.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index.html
    <button id="btn">打开index2.html</button>
  </body>
  <script>
    const btn = document.getElementById("btn");
    btn.addEventListener("click", function () {
      window.open("http://localhost:3002/");
    });
    window.addEventListener("message", function (e) {
      console.log(e);
    });
  </script>
</html>

  嗯,然后是index2.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index2.html
  </body>
  <script>
    window.opener.postMessage("hello I am from 3002", "http://localhost:3001/");
  </script>
</html>

  这代码没啥好说的,但是这几行代码我写了一个小时,你猜是为什么?通过a标签来打开新窗口的时候,实际上,是在B页面(被打开的页面)率先发起的,在A页面(打开的页面)接收到消息后才能把数据传回去。所以我就想,为什么不能在打开的时候就获取到呢?然后,我就可以主动在A页面传输数据了,不用再来一个来回。但是我试了下不行。为什么我试了这么久呢,因为我一直记得我在第一遍写的时候是可以的。

  至于再怎么从A页面传到B页面,参考1),我歇歇~~~~。

3) 通过iframe获取window.opener

  iframe方式的的话,其实都类似,都是要获取到对方的window才可以。说实话我不太想写这个,百度一大堆,一百度就是这个,一百度就是这个。还是贴一下代码吧,就不多说了。复制的过程略了啊。

  首先是index1.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index.html
    <iframe
      src="http://localhost:3002/"
      id="iframe"
      onload="load()"
      frameborder="0"
    ></iframe>
  </body>
  <script>
    function load() {
      const frame = document.getElementById("iframe");
      frame.contentWindow.postMessage(
        "hello index2 , I am index1",
        "http://localhost:3002/"
      );
    }
  </script>
</html>

  然后是index2:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index2.html
  </body>
  <script>
    window.addEventListener("message", function (e) {
      console.log(e);
    });
  </script>
</html>

  肯定有效果,我们发现,通过iframe的onload方法,可以获取到加载完成后的B页面,这样就可以主动发起请求,而不会像之前两个那样,在A页面获取不到B页面得加载状态。而iframe之所以能获取到onload的状态(以下纯属我个人猜测,没有任何考证)是因为iframe算是一个元素,我在父页面有很高的操作权限,但是你额外打开一个页面,可能没那么简单。

  所以,至此,我们可以简单总结一下,通过postMessage通信的核心是:双方必须都加载完毕。其次就是:要能获取到来源页面的referrer。没了。

  补充,HTTP请求头的referer是历史原因把referrer写错了,又没法改,至于现在是否出了修正我也不知道,大家可以自己查找。

  再补充,我在查资料中还看到说postMessage跨站是不能通信的,说实话我不确定,但是我个人觉得postMessage在跨站的情况下也是可以通信的,因为跨域本身就包含跨站,另外,我们可以发现,postMessage的本质是在双方的客户端页面都需要识别彼此的必要信息,这样的前提下就意味着双方可以确定身份,传递信息并不是不安全的。

2、window.name

  window.name,在使用它解决跨域之前哈,我们先了解下它是什么。window.name其实就是指窗口的名称,默认是一个空字符串,我们可以给window.name设置一个字符串,在跳转后的窗口中获取这个window.name。

  我们先看下代码,还是之前的结构,不多说,index1.html是这样的:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index1.html
    <a href="http://localhost:3002" onClick="window.name='zaking-1';"
      >点击我看看目标页面的window.name-zaking1</a
    >
    <a href="http://localhost:3002" onClick="window.name='zaking-2';"
      >点击我看看目标页面的window.name-zaking2</a
    >
  </body>
</html>

  然后,index2.html:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index2.html
    <p>window.name值是:<output id="output"></output></p>
  </body>
  <script>
    output.textContent = window.name;
  </script>
</html>

  这样就可以了,你启动服务后会发现,是在当前窗口替换了,如果是新打开一个窗口,比如a标签的target设置为_blank,则无效,因为不是之前的窗口了。

  通过这样的方式再加上iframe,就可以实现跨域传递数据。要注意,window.name是浏览器窗口的能力,其实与跨域无关,只不过在古老的时代,跨域解决方案太少,可以通过这种能力hack一下,实现跨域场景传递数据的需要罢了。现代浏览器已经无需如此,大家了解下就行了。

  额外要提的一点是,如果你要用iframe,那么iframe和当前窗口的window并不是同一个window。至于怎么验证,我相信你肯定知道。所以,如果你要想用window.name + iframe做跨域通信,就需要一个中间的iframe作为转接,利用其同一个iframe的window.name。

  我也说了,没啥意义,大家自行了解吧。现代浏览器用这玩意,我怕你是会被主管骂。

  既然没意义,那你写个毛?嗯~~作为极个别特殊场景下的极特别方案选型。虽然最后也可能被筛掉。

3、location.hash

  这个东西肯定很熟悉了,url嘛,url的一部分嘛,没错。我就简单提一下吧,跟上面的name一样,没啥实际的生产意义。因为你要用这个东西作为跨域的方案,就意味着你要舍弃url本身的一些能力,比如,我传了一个hash,在Vue-Router的hash模式下怎么办?能力重合,且为了解决跨域反而覆盖了location本身的能力,你还要为了弥补而添加额外的不稳定且不安全的代码。付出的代价太大。

  location.hash本身也并不是为了跨域而存在的,它设计的目的其实就是为了锚点定位,现代UI框架用它来作为路由的一种处理方案。

  不多说了,例子可以自行去demo代码中查看。

  额外多说两句的是,hash可以传数据,query呢?params呢?答案是都可以,前提是不要覆盖它本身的应用场景,因为本身就是个url跳转,就是个get请求的url地址,肯定可以获取到。

4、惯例:阶段性总结

  前面两大部分,实际上我并没有写跨域操作DOM的试验性代码,因为你既然能传递信息,就可以根据获取到的信息来修改DOM。而如果你想要直接修改DOM,比如targetDocument.getElementById什么的,说实话,我也不确定哪些场景可以,但是我们来发散思维,分析一下。

  首先,第一部分的浏览器与服务器的HTTP通信的解决方案,与DOM无关。PASS~

  第二部分,有三个解决方案,一个是获取opener,它可以么?我觉得可以,因为你已经获取到了目标窗口的引用,那么我猜是可以通过该引用来操作DOM的。而剩下两种则不可以直接操作,因为它们没有直接的关联关系或获取途径。

  当然,以上纯属我瞎猜的,有误导的可能性,大家理性参考。有兴趣可以自己试下哦~

四、跨站了

  哎呦,重点来了,比较核心且复杂的内容来了。 因为跨域的本地模拟其实很简单,localhost改个端口号就行了,但是跨站的模拟则要复杂很多,因为域名不好搞。还记得我们之前用的nginx做反向代理那部分不?嗯,我们要重新修改下nginx的配置:

server {
    listen       8080;
    server_name  index1.zaking.com;
}

  还有另外一个:

server {
    listen       8080;
    server_name  index2.zaking.com;
}

  这里仅作示例哈,还有一点,就是我们要配置一下代理的地址,具体的去demo里看吧(其实我是故意想让你去看demo的)。然后我们启动下nginx:

sudo brew services restart nginx   

  windows的启动方法,大家自己去找吧,这也不是讲nginx的文章,然后,还没完,我们还需要启动我们复制出来的本地server文件,跟之前的结构一样,不多说啦,嗯~~至少目前是一样的,页面里面啥也没有,就一点html就可以了,就像这样:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index2.html
  </body>
</html>

  我想尽量少说废话,但是又不得不说些废话,唉~~无所谓了。然后~~然后启动这两个本地服务,就像之前那样,还没完~~~

  你需要打开你本地的hosts文件,mac的话是在/etc下面,可以在命令行直接输入:

open /etc

  这样就可以打开该文件夹,然后找到hosts文件,添加两个host,就像这样:

##
# Host Database
#
# localhost is used to configure the loopback interface
# when the system is booting.  Do not change this entry.
##
127.0.0.1	localhost
255.255.255.255	broadcasthost
::1             localhost
127.0.0.1 index1.zaking.com
127.0.0.1 index2.zaking.com

  然后保存,可能在保存文件的时候需要你管理员的权限。嗯~~百度~~

  好啦,这样我们的准备工作就都做完了,我们就可以在浏览器里打开index1.zaking.com了。

浏览器原理之跨域?跨站?你真的不懂我!

   效果不错吧。index2也是一样的。那么准备工作做完了,我们要进入我们的重点了。就是跨站。我们在最开始理论的部分花了一定的篇幅聊了聊什么是跨站,并且有一个重点就是:跨站一定跨域,但是跨域不一定跨站。大家一定要记住,死记硬背不太好记,大概理解一下就是跨域的要求更多,且包含了跨站的部分,所以定义跨域的范围比跨站要大。那么既然如此,我们想象一下:

浏览器原理之跨域?跨站?你真的不懂我!

   跨站了,那么一定是在跨域的范围内,所以一定跨站一定跨域,但是我跨域了,可能不一定是属于跨站的范围。这样是不是就很好理解了?

  我记得啊,不好意思,这篇文章是我写的有史以来最长的又没法停下来的一篇文章,所以开始的东西有点不记得了,我记得最开始的部分我们好像说过,跨域会影响三部分的内容,我们稍稍回忆下,会影响HTTP、DOM还有本地存储比如cookie,localstorage啥的。我也是解释了下为啥不允许跨站访问这些数据,简单说就是为了隔离用户。那~~我们来实验一下吧。

  这是index1.html的代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index1.html
    <iframe
      src="http://index2.zaking.com:8080/"
      onload="load()"
      id="iframe"
      frameborder="0"
    ></iframe>
  </body>
  <script>
    function load() {
      const frame = document.getElementById("iframe");
      console.log(frame.contentWindow.name);
    }
  </script>
</html>

  然后index2.html很简单:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>
  <body>
    我是index2.html
  </body>
  <script>
    var name = "zaking";
  </script>
</html>

  然后我们重启下本地服务试下:

浏览器原理之跨域?跨站?你真的不懂我!

   完美,不出意外的报错了,那要怎么解决呢?在两个页面中加上:

document.domain = "zaking.com";

  就可以了,再试下?

浏览器原理之跨域?跨站?你真的不懂我! 

   完美~~结束了~

  讲道理,我觉得到现在就算是完事了,因为再讲其他的东西就要涉及到更多的关于HTTP以及浏览器的特性,所以会越写越多。所以,我纠结了10秒钟,决定这篇文章到此结束。感谢你能看到这里。如果你跟着我修改了本地的hosts和nginx,别忘了改回去~

  当然,更多的内容我应该会在我之后的系列博客中写,不过啥时候我也不知道。

  最后,这篇博客写的够长了,但是实际上还有很多问题是存疑或者未解决的,如果后面有机会的话,再针对各解决方案的知识点整理一篇更深入的解析。本文中也或许有些东西虽然我写出来了,但是理解方向并不正确,希望可以不吝指点。

  说实话我觉得有点虎头蛇尾,最后跨站的部分其实我还想写写cookie的,但是其实重点也说的差不多了,具体例子代码就暂时不写了吧。

  最后的最后的最后,感谢~~

  噢噢噢,还有,最后一点,就是不重要你也不需要知道也没啥意义的解决跨域的方式,就是修改浏览器对于跨域的拦截,从浏览器配置的层面修改,绝对不建议这么搞!!!!无论什么场景都不需要!!!!所以我不会告诉你怎么改。

  最后的总结,服务器与客户端跨域,用CORS,客户端与客户端的跨域,用postMessage。其他的,知道就行了。没了~这回真没了。

参考资料:

  1. 域名的含义
  2. 域名
  3. 统一资源标识符
  4. 极客时间《32 | 同源策略:为什么XMLHttpRequest不能跨域请求资源?》
  5. 什么叫TLD、gTLD、nTLD、ccTLD、iTLD以及几者之间的关系
  6. Public_Suffix_List
  7. 统一资源标志符
  8. 九种跨域方式实现原理(完整版)

  9. 跨源资源共享
  10. postMessage
  11. Referrer-Policy
  12. window opener
  13. window.name
  14. 所有示例代码地址
  15. homebrew镜像安装方法:
/bin/zsh -c "$(curl -fsSL https://gitee.com/cunkai/HomebrewCN/raw/master/Homebrew.sh)"