静态页面
在浏览器脚本的概念没有出现之前, 所有的网页都是静态的. 我们知道浏览器的工作模式是:
浏览器向网站服务器发起请求
网站接受浏览器的请求, 返回一些字符串(比如一些组成页面的 html 字符串)
浏览器接收到网站返回的用于组成页面的字符串后, 就可以关闭连接了
浏览器将组成页面的字符串渲染到屏幕上, 使得用户可以看到一个可视化的结果
看起来就像下面这样:
- Client Request
- +-------------+ +--------+
- +------+ | User Agent | +--------------------------------> | |
- | User +------> | | Server |
- +--^---+ | (Browser) | <--------------------------------+ | |
- | +-------+-----+ +--------+
- | | Server Response
- | |
- | |
- | +---------v--------+
- | | Close Connection |
- | +---------+--------+
- | |
- | |
- | +--------v--------+
- ^---------+ Render response |
- +-----------------+
我们看到, 一旦用户代理 (浏览器) 关闭了和服务器之间的链接之后, 客户端和服务器之间将不能继续通信.
动态页面
为了让页面可以给用户带来更多的交互, 浏览器开发厂商们制造出了名为浏览器脚本的东西. 比如你在浏览一个页面的时候, 你觉得页面的字体太小了. 在静态页面的时候, 页面制作者在右上角给你提供了名为 "放大字体" 的按钮, 你点击那个按钮, 然后开启一轮新的请求, 显著的说就是说你感觉到浏览器刷新了. 这其实是浏览器重新从服务器加载页面的资源, 只不过这一次的资源是用于显示字体放大后的页面.(尽管这个例子现在看来有些奇怪, 因为如果仅仅是改变页面字体大小的话, 似乎直接操作 element.style.fontSize 就可以了, 不过请求另外一个包含更大字体的内联 CSS 页面在最终效果上也是说得通的, 所以这里还请多多包含了.)
浏览器脚本就是一小段由浏览器执行的代码, 页面制作者将这一小段代码, 和网页面的内容 (比如一篇优美的散文, 和它右上角的 "放大字体" 按钮) 一起返回给浏览器. 浏览器接收到页面资源后, 首先就是先将散文和 "放大字体" 按钮显示出来. 注意到返回的内容实际上还有一段由浏览器执行的代码, 页面制作者在这段带代码中告诉浏览器: 如果用户点击了 "放大字体" 按钮, 那么你就将页面的字体放大. 于是, 当你点击 "放大字体" 按钮之后, 浏览器严格执行页面制作者在脚本中撰写的内容 - 将页面的字体放大.
异步加载
注意在静态页面中浏览器和服务器之间的通信过程. 浏览器在向服务器发起了对页面的请求之后, 在服务器没有将页面的内容返回之前, 页面是无法被显示出来的, 最显著的特征就是我们在点击了浏览器的 "刷新" 按钮之后, 页面会 "白屏" 一小段时间.
起初浏览器脚本是没有网络通信的功能的, 只能做一些页面的特效, 比如 "点击按钮放大了字体". 不过浏览器厂商发现, 如果给脚本赋予网络通信的功能, 将使得页面制作者可以给用户提供更好的页面交互体验. 于是在早期的 IE 浏览器中, 首先赋予了浏览器脚本的通信功能.
浏览器脚本可以和服务器进行网络通信之后, 页面制作者可以做出具有更好体验的页面. 比如你现在需要搜索商品, 假设是要买一本编程的书, 你在网页的搜索框中输入了 "编程的数", 很明显是输错了, 你将 "书" 错输成了 "数". 在你点击了 "搜索" 按钮之后, 进过短暂的白屏之后, 页面中显示了:
找不到关于 "编程的数" 的产品, 你是不是要找 "编程的书"
很不错, 网站给了我们一个提示, 这样我们就可以发现自己的输入错误. 不过这个体验还是有待提高的, 因为每一次的搜索都会有一个短暂的 "白屏", 在白屏期间用户只能等待. 在浏览器脚本可以通信之后, 搜索就可以以一个异步的方式进行:
用户在浏览器中输入搜索页面的地址 "http://search.shop.com"
浏览器会向网站请求搜索页面的内容, 用于显示这个页面
网站在返回页面的显示内容的同时, 包含了一小段脚本, 脚本的内容是告诉浏览器 "用户在点击了搜索之后, 你给用户一个提示, 让用户知道服务器正在紧张的搜索用户所需的资源, 然后你显示了提示后, 你再向服务器请求搜索的结果, 当得到搜索结果后, 你再把搜索结果显示给用户"
这样的话, 用户不必在搜索时面对页面的刷新时的 "白屏" 了, 有一个提示框告诉用户稍等片刻.
同源策略
为了定位网络上的资源, 我们采用了统一资源定位符 URL, 就像是一个门牌号一样, URL 标识出资源在网络上的位置. 我们浏览的网页, 其中的内容可能会来自不同的提供者, 比如散文来自一位作家, 而其中的配图来自一位美术家. 散文的 URL 是 http://writer.com/new-world, 配图的 URL 是 http://artist.com/new-world.
我们需要有一种方式将网络上的资源 (比如散文和图画) 标识出来, 区别它们是来自于不同的作者. 如果我们将颗粒度定位到每一个独立的资源, 理论上是可行的, 但是我们知道作家不可能只有一篇散文, 而美术家也不会只有一幅画. 于是我们选择了使用: 通信协议, 完整的域名, 以及端口号去描述一个源, 只有三者都相同, 才标识两个资源是同源的.
下面的几个资源是同源的:
- http://example.com/
- http://example.com:80/
- http://example.com/path/file
下面的资源是不同源的:
- http://example.com/
- http://example.com:8080/
- http://www.example.com/
- https://example.com:80/
- https://example.com/
- http://example.org/
- http://ietf.org/
现在知道了同源, 那么同源策略是什么意思呢? 同源策略就是, 两个不同源的资源相互是不能访问对方的资源的. 同源策略主要就是限制脚本的网络访问.
比如我们打开了一个页面 http://example.com, 这个页面有两段脚本, 一个段使用的内联的方式称为 A, 它主要就是在用户点击了按钮之后显示一段文字, 告诉用户点击了按钮; 另一段作为外部资源进行加载称为 B,B 是 A 的基础代码, 比如 B 是 jQuery, 它被放在了 http://cdn.jquery.com 上. 首先我们知道, 这两段代码如果按照同源的定义, 肯定是不同源的. 也就是说我们在 http://example.com 的页面上是不能加载 http://cdn.jquery.com 上的资源的.
好像与现实情况有点矛盾. 之所以现在可以, 是因为浏览器为了适应实际的生产情况, 放宽了对同源策略的检查, 因为我们知道, 不可能将所有的资源都放在同一台机器上. 那么在页面完全加载好之后, 页面中的脚本 (内联的和外部引入) 的都被浏览器归纳到了和当前页面相同的源, 都属于 http://example.com 了. 这么做的意思就是, 脚本无法访问与之不同源的资源, 也就是此时的脚本 (内联的和外部引入的) 无法访问资源 https://example.com/user-info.
绕过同源策略
有时比如上面的例子, 我们确实需要在脚本中加载和当前页面不同源的资源, 比如在 http://example.com 页面中使用脚本加载 https://example.com/user-info 中的内容. 那么如何绕过浏览器的同源策略呢?
我们知道直接在页面中载入不同源的外部资源是可以的, 那么我们就可以动态的载入一段外部的脚本.
首先, 我们的 http://example.com 中有这么一段脚本:
- (function () {
- Windows['showNickname'] = function (JSON) {
- alert(JSON['nickname']);
- };
- var userInfoServiceUrl = 'https://example.com/user-info';
- var doCrossSiteRequest = function (url, callback) {
- var script = document.createElement('script');
- script.src = url + '?callback=' + callback;
- var head = document.getElementsByTagName('head');
- if (head[0]) {
- head.append(script);
- }
- };
- document.querySelector('#btnShowNickName').addEventListener('click', function () {
- doCrossSiteRequest(userInfoServiceUrl, 'showNickname');
- });
- })();
而 https://example.com/user-info 的服务端内容为:
- <?PHP
- $callback = isset($_GET['callback']) ? $_GET['callback'] : null;
- if ($callback === null) die('invalid request');
- $userInfo = [
- 'nickname' => 'net-user'
- ];
- $JSON = json_encode($userInfo);
- echo "{$callback}({$json});";
那么在浏览器加载了 https://example.com/user-info 的脚本为, 得到的是:
showNickname({"nickname":"net-user"});
这就和我们最先在 http://example.com 留下的 Windows['showNickname'] 对接上了.
来源: https://juejin.im/post/5c3ffb0251882523ea6df9e5