沙雕爹爹

Stay foolish, stay humble.


  • Home

  • Archives

如何用js实现“高亮划词”的在线笔记功能(转)

Posted on 2019-04-25

原文链接

需要解决的核心问题:

  • 加高亮背景。即如何根据用户在网页上的选取,为响应的文本添加高亮背景
  • 高亮区域的持久化与还原。即如何保存用户高亮信息,并在下次浏览时准确还原。

如何加高亮背景

基本思路如下:

  1. 获取选中的文本节点:通过用户选择的区域信息,获取所有被选中的所有文本节点
  2. 为文本节点添加背景色:给这些文本节点包裹一层新的元素,该元素具有指定的背景颜色。

如何获取选中的文本节点?

Selection API

兼容性还不错,如果需要支持更低版本,需要polyfill。

Selection API

Selection API可以返回一系列关于用户选区的信息。那么是不是可以通过它直接获取选取中的所有DOM元素呢?

很遗憾并不能。但好在它可以返回选区但首尾节点信息:

1
2
3
4
5
6
7
8
9
const range = window.getSelection().getRangeAt(0);
const start = {
node: range.startContainer,
offset: range.startOffset
};
const end = {
node: range.endContainer,
offset: range.endOffset
}

Range对象包含了选区但开始与结束信息,其中包括节点(node)与文本偏移量(offset)。节点信息不用多说,这里解释一下offset是指什么:例如,标签<p>This is a paragraph.</p>,用户选取但部分是a paragraph这两个词,这是首尾的node均为p元素内的文本节点(Text Node),而startOffset和endOffset分别为2和6。

为了获取用户当前选中的文本,可以使用Window.getSelection()或者Document.getSelection()方法。它们返回一个[Selection][]对象,你可以用一个变量存储它。
你可以对你存储的selection执行很多操作,如调用Selection.toString()方法拷贝一份文本字符串,或者使用Selection.addRange()/Selection.removeRange()给这个selection添加/删除一个范围(正如标准Rnage对象展示的),又或者使用Selection.selectAllChildren()修改selection为这个DOM节点的全部内容。
当selection有变化,或者新的selection被创建时,使用GlobalEventHandlers.onselectionchange和GlobalEventHandlers.onselectstart事件处理函数里可以执行你的代码。

首尾文本节点拆分

理解了offset的概念后,自然就会发现有个问题需要解决。由于用户选区(selection)可能只包含一个文本节点的一部分(即offset不为0),所以我们最后得到的用户选区所包含的节点里,也只希望有首尾文本节点的这一部分。对此,我们可以使用.splitText()拆分文本节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 首节点
if (curNode === $startNode) {
if (curNode.nodeType === 3) {
curNode.splitText(startOffset);
const node = curNode.nextSibling;
selectedNodes.push(node);
}
}

// 尾节点
if (curNode === $endNode) {
if (curNode.nodeType === 3) {
const node = curNode;
node.splitText(endOffset);
selectedNodes.push(node);
}
}

以上代码会依据offset对文本节点进行拆分。对于开始节点,只需要收集它的后半部分;而对于结束节点则是前半部分。

遍历DOM树

到目前为止,我们准确找到了首尾节点,所以下一步就是找出“中间”所有的文本节点。这就需要遍历DOM树。

“中间”加上引号是因为,在视觉上,这些节点是位于首尾之间的,但由于DOM不是线性结构而是树形结构,所以这个“中间”换成程序语言,就是指深度优先遍历时,位于首尾节点之间的所有文本节点。DFS的方法有很多,可以递归,也可以用栈+循环,这里就不赘述了。

需要提一下的是,由于我们是要为文本节点添加高亮背景,因此在遍历时只会收集文本节点。

1
2
3
if (curNode.nodeType === 3) {
selectedNodes.push(curNode);
}

如何为文本节点添加背景色?

一个最直接的方法就是为选中的文本节点(包括分拆后的首尾节点)“包裹上”一个带背景样式的元素。

具体的,我们可以给每个文本节点加上一个class为highlight的span元素;而背景样式则通过.highlight选择器设置。

1
2
3
4
5
6
7
8
9
// 使用上面封装的方法
const nodes = getSelectedNodes(start, end);

nodes.forEach(node => {
const wrap = document.createElement('span');
wrap.setAttribute('class', 'highlight');
wrap.appendChild(node.cloneNode(false));
node.parentNode.replaceChild(wrap);
});
1
2
3
.highlight {
background: #ff9;
}

p.s. 选区的重合问题

举个例子:

选区的重合问题

具体解决后面再说。

如何实现高亮选取的持久化与还原?

持久化高亮选区的核心是找到一种合适的DOM节点序列化方法。

通过第三部分可以知道,当确定了首尾节点与文本偏移(offset)信息后,即可为其间文本节点添加背景色。其中,offset 是数值类型,要在服务器保存它自然没有问题;但是 DOM 节点不同,在浏览器中保存它只需要赋值给一个变量,但想在后端保存所谓的 DOM 则不那么直接了。

序列化DOM节点知识

所以这里的核心点就是找到一种方法,能够定位DOM节点,同时可以被保存成普通的JSON,用以传给后端保存,这个过程在本文中被称为DOM标识的“序列化”。而下次用户访问时,又可以从后端取回,然后“反序列化”为对应的DOM节点。

有几种常见的方式来标识DOM节点:

  • 使用xPath
  • 使用CSS Selector语法
  • 使用tagName + index

这里选择第三种方法。

需要注意的一点,我们通过Selection API取到的首尾节点一般是文本节点,而这里要记录的tagName和index都是该文本节点的父元素节点(Element Node)的,而childIndex表示该文本节点是其父节点的第几个子节点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const serialize = (textNode, root = document) => {
const node = textNode.parentElement;
let childIndex = -1;
for (let i = 0, len = node.childNodes.length; i < len; i++) {
if (textNode === node.childNodes[i]) {
childIndex = i;
break;
}
}
const tagName = node.tagName;
const list = root.getElementsByTagName(tagName);
for (let index = 0, len = list.length; index < len; index++) {
if (node === list[index]) {
return {tagName, index, childIndex};
}
}
return {tagName, index: -1, childIndex};
}

通过该方法返回的信息,再加上offset的信息,即定位选取的起始位置,同时也完全可以发送给后端进行保存了。

反序列化DOM节点

1
2
3
4
5
const deSerialize = (meta, root = document) => {
const {tagName, index, childIndex} = meta;
const parent = root.getElementsByTagName(tagName)[index];
return parent.childNodes[childIndex];
}

至此,我们已经解决了两个核心问题,这似乎已经是一个可用版本了。但其实不然,根据实践经验,如果仅仅是上面这些处理,往往是无法应对实际需求的,存在一些“致命问题”。

如何实现一个生产环境可用的“划词高亮”?

上面的方案有什么问题?

首先来看看上面的方案会有什么问题。

当我们需要高亮文本时,会为文本节点包裹span元素,这就改动了页面的 DOM 结构。它可能会导致后续高亮的首尾节点与其 offset 信息其实是基于被改动后的 DOM 结构的。带来的结果有两个:

  • 下次访问时,程序必须按上次用户高亮的顺序还原。
  • 用户不能随意取消(删除)高亮区域,只能按添加顺序从后往前删。

否则,就会有部分的高亮选区在还原时无法定位到正确的元素。

文字可能不好理解,下面我举个例子来直观解释下这个问题。

1
2
3
<p>
非常高兴今天能够在这里和大家分享一下文本高亮(在线笔记)的实现方式。
</p>

对于上面这段 HTML,用户分别按顺序高亮了两个部分:“高兴”和“文本高亮”。那么按照上面的实现方式,这段 HTML 变成了下面这样:

1
2
3
4
5
6
7
<p>
非常
<span class="highlight">高兴</span>
今天能够在这里和大家分享一下
<span class="highlight">文本高亮</span>
(在线笔记)的实现方式。
</p>

对应的两个序列化数据分别为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// “高兴”两个字被高亮时获取的序列化信息
{
start: {
tagName: 'p',
index: 0,
childIndex: 0,
offset: 2
},
end: {
tagName: 'p',
index: 0,
childIndex: 0,
offset: 4
}
}

// “文本高亮”四个字被高亮时获取的序列化信息。
// 这时候由于p下面已经存在了一个高亮信息(即“高兴”)。
// 所以其内部 HTML 结构已被修改,直观来说就是 childNodes 改变了。
// 进而,childIndex属性由于前一个 span 元素的加入,变为了 2。
{
start: {
tagName: 'p',
index: 0,
childIndex: 2,
offset: 14
},
end: {
tagName: 'p',
index: 0,
childIndex: 2,
offset: 18
}
}

可以看到,“文本高亮”这四个字的首尾节点的 childIndex 都被记为 2,这是由于前一个高亮区域改变了p元素下的DOM结构。如果此时“高兴”选区的高亮被用户取消,那么下次再访问页面就无法还原高亮了 —— “高兴”选区的高亮被取消了,p下自然就不会出现第三个 childNode,那么 childIndex 为 2 就找不到对应的节点了。这就导致存储的数据在还原高亮选区时出现问题。

此外,还记得在第三部分末尾提到的高亮选取重合问题么?支持选取重合很容易出现如下的包裹元素嵌套情况:

1
2
3
4
5
6
7
8
9
10
<p>
非常
<span class="highlight">高兴</span>
今天能够在这里和大家分享一下
<span class="highlight">
文本
<span class="highlight">高亮</span>
</span>
(在线笔记)的实现方式。
</p>

这也使得某个文本区域经过多次高亮、取消高亮后,会出现与原 HTML 页面不同的复杂嵌套结构。可以预见,当我们使用 xpath 或 CSS selector 作为 DOM 标识时,上面提到的问题也会出现,同时也使其他需求的实现更加复杂。

到这里可以提一下其他开源库或产品是如何处理选区重合问题的:

  • 开源库 Rangy 有一个 Highlighter 模块可以实现文本高亮,但其对于选区重合的情况是将两个选区直接合并了,这是不合符我们业务需求的。
  • 付费产品 Diigo 直接不允许选区的重合。
  • Medium.com 是支持选区重合的,体验非常不错,这也是我们产品的目标。但它页面的内容区结构相较我面对的情况会更简单与更可控。

所以如何解决这些问题呢?

另一种序列化/反序列化方式

我会对第四部分提到的序列化方式进行改进。仍然记录文本节点的父节点 tagName 与 index,但不再记录文本节点在 childNodes 中的 index 与 offset,而是记录开始(结束)位置在整个父元素节点中的文本偏移量。

例如下面这段 HTML:

1
2
3
4
5
6
7
<p>
非常
<span class="highlight">高兴</span>
今天能够在这里和大家分享一下
<span class="highlight">文本高亮</span>
(在线笔记)的实现方式。
</p>

对于“文本高亮”这个高亮选区,之前用于标识文本起始位置的信息为childIndex = 2,offset = 14。而现在变为offset = 18(从p元素下第一个文本“非”开始计算,经过18个字符后是“文”)。可以看出,这样表示的优点是,不管p内部原有的文本节点被span(包裹)节点如何分割,都不会影响高亮选区还原时的节点定位。

据此,在序列化时,我们需要一个方法来将文本节点内偏移量“翻译”为其对应的父节点内部的总体文本偏移量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function getTextPreOffset(root, text) {
const nodeStack = [root];
let curNode = null;
let offset = 0;
while (curNode = nodeStack.pop()) {
const children = curNode.childNodes;
for (let i = children.length - 1; i >= 0; i--) {
nodeStack.push(children[i]);
}

if (curNode.nodeType === 3 && curNode !== text) {
offset += curNode.textContent.length;
}
else if (curNode.nodeType === 3) {
break;
}
}

return offset;
}

而还原高亮选区时,需要一个对应的逆过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function getTextChildByOffset(parent, offset) {
const nodeStack = [parent];
let curNode = null;
let curOffset = 0;
let startOffset = 0;
while (curNode = nodeStack.pop()) {
const children = curNode.childNodes;
for (let i = children.length - 1; i >= 0; i--) {
nodeStack.push(children[i]);
}
if (curNode.nodeType === 3) {
startOffset = offset - curOffset;
curOffset += curNode.textContent.length;
if (curOffset >= offset) {
break;
}
}
}
if (!curNode) {
curNode = parent;
}
return {node: curNode, offset: startOffset};
}

支持高亮选区的重合

重合的高亮选区带来的一个问题就是高亮包裹元素的嵌套,从而使得 DOM 结构会有较复杂的变动,增加了其他功能(交互)实现与问题排查的复杂度。因此,我在前面提到的包裹高亮元素时,会再进行一些稍复杂的处理(尤其是重合选区),以保证尽量复用已有的包裹元素,避免元素的嵌套。

在处理时,将需要包裹的各个文本片段(Text Node)分为三类情况:

  1. 完全未被包裹,则直接包裹该部分。
  2. 属于被包裹过的文本节点的一部分,则使用.splitText()将其拆分。
  3. 是一段完全被包裹的文本段,不需要对节点进行处理。

与此同时,为每个选区生成唯一 ID,将该段文本几点多对应的 ID、以及其由于选区重合所涉及到的其他 ID,都附加包裹元素上。因此像上面的第三种情况,不需要变更 DOM 结构,只用更新包裹元素两类 ID 所对应的 dataset 属性即可。

其他问题

解决以上的一些问题后,“文本划词高亮”就基本可用了。还剩下一些“小修补”,简单提一下。

高亮选区的交互事件,例如 click、hover

首先,可以为每个高亮选区生成一个唯一 ID,然后在该选区内所有的包裹元素上记录该 ID 信息,例如用data-highlight-id属性。而对于选取重合的部分可以在data-highlight-extra-id属性中记录重合的其他选区的 ID。

而监听到包裹元素的 click、hover 后,则触发 highlighter 的相应事件,并带上高亮 ID。

取消高亮(高亮背景的删除)

由于在包裹时支持选区重合(对应会有上面提到的三种情况需要处理),因此,在删除选取高亮时,也会有三种情况需要分别处理:

  • 直接删除包裹元素。即不存在选区重合。
  • 更新data-highlight-id属性和data-highlight-extra-id属性。即删除的高亮 ID 与 data-highlight-id 相同。
  • 只更新data-highlight-extra-id属性。即删除的高亮 ID 只在 data-highlight-extra-id 中。

对于前端生成的动态页面怎么办?

不难发现,这种非耦合的文本高亮功能很依赖于页面的 DOM 结构,需要保证做高亮时的 DOM 结构和还原时的一致,否则无法正确还原出选区的起始节点位置。据此,对“划词”高亮最友好的应该是纯后端渲染的页面,在onload监听中触发高亮选区还原的方法即可。但目前越来越多的页面(或页面的一部分)是前端动态生成的,针对这个问题该怎么处理呢?

我在实际工作中也遇到了类似问题 —— 页面的很多区域是 ajax 请求后前端渲染的。我的处理方式包括如下:

  • 隔离变化范围。将上述代码中的“根节点”从documentElement换为另一个更具体的容器元素。例如我面对的业务会在 id 为 article-container 的div内加载动态内容,那么我就会指定这个 article-container 为“根节点”。这样可以最大程度防止外部的 DOM 变动影响到高亮位置的定位,尤其是页面改版。
  • 确定高亮选区的还原时机。由于内容可能是动态生成,所以需要等到该部分的 DOM 渲染完成后再调用还原方法。如果有暴露的监听事件可以在监听内处理;或者通过 MutationObserver 监听标志性元素来判断该部分是否加载完成。
  • 记录业务内容信息,应对内容区改版。内容区的 DOM 结构更改算是“毁灭性打击”。如何确实有该类情况,可以尝试让业务内容展示方将段落信息等具体的内容信息绑定在 DOM 元素上,而我在高亮时取出这些信息来冗余存储,改版后可以通过这些内容信息“刷”一遍存储的数据。

其他

篇幅问题,还有其他细节的问题就不在这篇文章里分享了。详细内容可以参考 web-highlighter 这个仓库里的实现。

总结

本文先从“划词高亮”功能的两个核心问题(如何高亮用户选区的文本、如何将高亮选区还原)切入,基于 Selection API、深度优先遍历和 DOM 节点标识的序列化这些手段实现了“划词高亮”的核心功能。然而,该方案仍然存在一些实际问题,因此在第 5 部分进一步给出了相应的解决方案。

基于实际开发的经验,我发现解决上述几个“划词高亮”核心问题的代码具有一定通用性,因此把核心部分的源码封装成了独立的库 web-highlighter,托管在 github,也可以通过 npm 安装。

web-highlighter

其已服务于线上产品业务,基本的高亮功能一行代码即可开启:

1
(new Highlighter()).run();

兼容IE 10/11、Edge、Firefox 52+、Chrome 15+、Safari 5.1+、Opera 15+。

感兴趣的小伙伴可以 star 一下。感谢支持,欢迎交流 😊

JS异步捕获二三事(转)

Posted on 2019-04-25

原文链接

跨域资源共享 CORS 详解(转)

Posted on 2019-04-25

原文链接

跨域cookie同步方案(转)

Posted on 2019-04-25

原文链接

跨域传递cookie(转)

Posted on 2019-04-25

原文链接

你真的会使用XMLHttpRequest吗?(转)

Posted on 2019-04-25

原文链接

看到标题时,有些同学可能会想:“我已经用xhr成功地发过很多个Ajax请求了,对它的基本操作已经算挺熟练了。” 我之前的想法和你们一样,直到最近我使用xhr时踩了不少坑儿,我才突然发现其实自己并不够了解xhr,我知道的只是最最基本的使用。

于是我决定好好地研究一番xhr的真面目,可拜读了不少博客后都不甚满意,于是我决定认真阅读一遍W3C的XMLHttpRequest标准。看完标准后我如同醍醐灌顶一般,感觉到了从未有过的清澈。这篇文章就是参考W3C的XMLHttpRequest标准和结合一些实践验证总结而来的。

AJAX和XMLHttpRequest

我们通常将Ajax等同于XMLHttpRequest,但细究起来它们两个是属于不同维度的2个概念。

以下是我认为对Ajax较为准确的解释:(摘自what is Ajax)
AJAX stands for Asynchronous JavaScript and XML. AJAX is a new technique for creating better, faster, and more interactive web applications with the help of XML, HTML, CSS, and Java Script.

AJAX is based on the following open standards:

Browser-based presentation using HTML and Cascading Style Sheets (CSS).

Data is stored in XML format and fetched from the server.

Behind-the-scenes data fetches using XMLHttpRequest objects in the browser.

JavaScript to make everything happen.

从上面的解释中可以知道:ajax是一种技术方案,但并不是一种新技术。它依赖的是现有的CSS/HTML/Javascript,而其中最核心的依赖是浏览器提供的XMLHttpRequest对象,是这个对象使得浏览器可以发出HTTP请求与接收HTTP响应。

所以我用一句话来总结两者的关系:我们使用XMLHttpRequest对象来发送一个Ajax请求。

XMLHttpRequest的发展历程
XMLHttpRequest一开始只是微软浏览器提供的一个接口,后来各大浏览器纷纷效仿也提供了这个接口,再后来W3C对它进行了标准化,提出了XMLHttpRequest标准。XMLHttpRequest标准又分为Level 1和Level 2。
XMLHttpRequest Level 1主要存在以下缺点:

受同源策略的限制,不能发送跨域请求;

不能发送二进制文件(如图片、视频、音频等),只能发送纯文本数据;

在发送和获取数据的过程中,无法实时获取进度信息,只能判断是否完成;

那么Level 2对Level 1 进行了改进,XMLHttpRequest Level 2中新增了以下功能:

可以发送跨域请求,在服务端允许的情况下;

支持发送和接收二进制数据;

新增formData对象,支持发送表单数据;

发送和获取数据时,可以获取进度信息;

可以设置请求的超时时间;

当然更详细的对比介绍,可以参考阮老师的这篇文章,文章中对新增的功能都有具体代码示例。

XMLHttpRequest兼容性
关于xhr的浏览器兼容性,大家可以直接查看“Can I use”这个网站提供的结果XMLHttpRequest兼容性,下面提供一个截图。

XMLHttpRequest兼容性

从图中可以看到:

IE8/IE9、Opera Mini 完全不支持xhr对象

IE10/IE11部分支持,不支持 xhr.responseType为json

部分浏览器不支持设置请求超时,即无法使用xhr.timeout

部分浏览器不支持xhr.responseType为blob

细说XMLHttpRequest如何使用
先来看一段使用XMLHttpRequest发送Ajax请求的简单示例代码。

function sendAjax() {
//构造表单数据
var formData = new FormData();
formData.append(‘username’, ‘johndoe’);
formData.append(‘id’, 123456);
//创建xhr对象
var xhr = new XMLHttpRequest();
//设置xhr请求的超时时间
xhr.timeout = 3000;
//设置响应返回的数据格式
xhr.responseType = “text”;
//创建一个 post 请求,采用异步
xhr.open(‘POST’, ‘/server’, true);
//注册相关事件回调处理函数
xhr.onload = function(e) {
if(this.status == 200||this.status == 304){
alert(this.responseText);
}
};
xhr.ontimeout = function(e) { … };
xhr.onerror = function(e) { … };
xhr.upload.onprogress = function(e) { … };

//发送数据
xhr.send(formData);
}
上面是一个使用xhr发送表单数据的示例,整个流程可以参考注释。

接下来我将站在使用者的角度,以问题的形式介绍xhr的基本使用。
我对每一个问题涉及到的知识点都会进行比较细致地介绍,有些知识点可能是你平时忽略关注的。

如何设置request header
在发送Ajax请求(实质是一个HTTP请求)时,我们可能需要设置一些请求头部信息,比如content-type、connection、cookie、accept-xxx等。xhr提供了setRequestHeader来允许我们修改请求 header。

void setRequestHeader(DOMString header, DOMString value);

注意点:

方法的第一个参数 header 大小写不敏感,即可以写成content-type,也可以写成Content-Type,甚至写成content-Type;

Content-Type的默认值与具体发送的数据类型有关,请参考本文【可以发送什么类型的数据】一节;

setRequestHeader必须在open()方法之后,send()方法之前调用,否则会抛错;

setRequestHeader可以调用多次,最终的值不会采用覆盖override的方式,而是采用追加append的方式。下面是一个示例代码:

var client = new XMLHttpRequest();
client.open(‘GET’, ‘demo.cgi’);
client.setRequestHeader(‘X-Test’, ‘one’);
client.setRequestHeader(‘X-Test’, ‘two’);
// 最终request header中”X-Test”为: one, two
client.send();
如何获取response header
xhr提供了2个用来获取响应头部的方法:getAllResponseHeaders和getResponseHeader。前者是获取 response 中的所有header 字段,后者只是获取某个指定 header 字段的值。另外,getResponseHeader(header)的header参数不区分大小写。

DOMString getAllResponseHeaders();
DOMString getResponseHeader(DOMString header);

这2个方法看起来简单,但却处处是坑儿。

你是否遇到过下面的坑儿?——反正我是遇到了。。。

使用getAllResponseHeaders()看到的所有response header与实际在控制台 Network 中看到的 response header 不一样

使用getResponseHeader()获取某个 header 的值时,浏览器抛错Refused to get unsafe header “XXX”

经过一番寻找最终在 Stack Overflow找到了答案。

原因1:W3C的 xhr 标准中做了限制,规定客户端无法获取 response 中的 Set-Cookie、Set-Cookie2这2个字段,无论是同域还是跨域请求;

原因2:W3C 的 cors 标准对于跨域请求也做了限制,规定对于跨域请求,客户端允许获取的response header字段只限于“simple response header”和“Access-Control-Expose-Headers” (两个名词的解释见下方)。

“simple response header”包括的 header 字段有:Cache-Control,Content-Language,Content-Type,Expires,Last-Modified,Pragma;
“Access-Control-Expose-Headers”:首先得注意是”Access-Control-Expose-Headers”进行跨域请求时响应头部中的一个字段,对于同域请求,响应头部是没有这个字段的。这个字段中列举的 header 字段就是服务器允许暴露给客户端访问的字段。

所以getAllResponseHeaders()只能拿到限制以外(即被视为safe)的header字段,而不是全部字段;而调用getResponseHeader(header)方法时,header参数必须是限制以外的header字段,否则调用就会报Refused to get unsafe header的错误。

如何指定xhr.response的数据类型
有些时候我们希望xhr.response返回的就是我们想要的数据类型。比如:响应返回的数据是纯JSON字符串,但我们期望最终通过xhr.response拿到的直接就是一个 js 对象,我们该怎么实现呢?
有2种方法可以实现,一个是level 1就提供的overrideMimeType()方法,另一个是level 2才提供的xhr.responseType属性。

xhr.overrideMimeType()
overrideMimeType是xhr level 1就有的方法,所以浏览器兼容性良好。这个方法的作用就是用来重写response的content-type,这样做有什么意义呢?比如:server 端给客户端返回了一份document或者是 xml文档,我们希望最终通过xhr.response拿到的就是一个DOM对象,那么就可以用xhr.overrideMimeType(‘text/xml; charset = utf-8’)来实现。

再举一个使用场景,我们都知道xhr level 1不支持直接传输blob二进制数据,那如果真要传输 blob 该怎么办呢?当时就是利用overrideMimeType方法来解决这个问题的。

下面是一个获取图片文件的代码示例:

var xhr = new XMLHttpRequest();
//向 server 端获取一张图片
xhr.open(‘GET’, ‘/path/to/image.png’, true);

// 这行是关键!
//将响应数据按照纯文本格式来解析,字符集替换为用户自己定义的字符集
xhr.overrideMimeType(‘text/plain; charset=x-user-defined’);

xhr.onreadystatechange = function(e) {
if (this.readyState == 4 && this.status == 200) {
//通过 responseText 来获取图片文件对应的二进制字符串
var binStr = this.responseText;
//然后自己再想方法将逐个字节还原为二进制数据
for (var i = 0, len = binStr.length; i < len; ++i) {
var c = binStr.charCodeAt(i);
//String.fromCharCode(c & 0xff);
var byte = c & 0xff;
}
}
};

xhr.send();
代码示例中xhr请求的是一张图片,通过将 response 的 content-type 改为’text/plain; charset=x-user-defined’,使得 xhr 以纯文本格式来解析接收到的blob 数据,最终用户通过this.responseText拿到的就是图片文件对应的二进制字符串,最后再将其转换为 blob 数据。

xhr.responseType
responseType是xhr level 2新增的属性,用来指定xhr.response的数据类型,目前还存在些兼容性问题,可以参考本文的【XMLHttpRequest的兼容性】这一小节。那么responseType可以设置为哪些格式呢,我简单做了一个表,如下:

值 xhr.response 数据类型 说明
“” String字符串 默认值(在不设置responseType时)
“text” String字符串
“document” Document对象 希望返回 XML 格式数据时使用
“json” javascript 对象 存在兼容性问题,IE10/IE11不支持
“blob” Blob对象
“arrayBuffer” ArrayBuffer对象
下面是同样是获取一张图片的代码示例,相比xhr.overrideMimeType,用xhr.response来实现简单得多。

var xhr = new XMLHttpRequest();
xhr.open(‘GET’, ‘/path/to/image.png’, true);
//可以将xhr.responseType设置为"blob"也可以设置为" arrayBuffer"
//xhr.responseType = ‘arrayBuffer’;
xhr.responseType = ‘blob’;

xhr.onload = function(e) {
if (this.status == 200) {
var blob = this.response;
…
}
};

xhr.send();
小结
虽然在xhr level 2中,2者是共同存在的。但其实不难发现,xhr.responseType就是用来取代xhr.overrideMimeType()的,xhr.responseType功能强大的多,xhr.overrideMimeType()能做到的xhr.responseType都能做到。所以我们现在完全可以摒弃使用xhr.overrideMimeType()了。

如何获取response数据
xhr提供了3个属性来获取请求返回的数据,分别是:xhr.response、xhr.responseText、xhr.responseXML

xhr.response

默认值:空字符串””

当请求完成时,此属性才有正确的值

请求未完成时,此属性的值可能是””或者 null,具体与 xhr.responseType有关:当responseType为””或”text”时,值为””;responseType为其他值时,值为 null

xhr.responseText

默认值为空字符串””

只有当 responseType 为”text”、””时,xhr对象上才有此属性,此时才能调用xhr.responseText,否则抛错

只有当请求成功时,才能拿到正确值。以下2种情况下值都为空字符串””:请求未完成、请求失败

xhr.responseXML

默认值为 null

只有当 responseType 为”text”、””、”document”时,xhr对象上才有此属性,此时才能调用xhr.responseXML,否则抛错

只有当请求成功且返回数据被正确解析时,才能拿到正确值。以下3种情况下值都为null:请求未完成、请求失败、请求成功但返回数据无法被正确解析时

如何追踪ajax请求的当前状态
在发一个ajax请求后,如果想追踪请求当前处于哪种状态,该怎么做呢?

用xhr.readyState这个属性即可追踪到。这个属性是只读属性,总共有5种可能值,分别对应xhr不同的不同阶段。每次xhr.readyState的值发生变化时,都会触发xhr.onreadystatechange事件,我们可以在这个事件中进行相关状态判断。

xhr.onreadystatechange = function () {
switch(xhr.readyState){
case 1://OPENED
//do something
break;
case 2://HEADERS_RECEIVED
//do something
break;
case 3://LOADING
//do something
break;
case 4://DONE
//do something
break;
}
值 状态 描述
0 UNSENT (初始状态,未打开) 此时xhr对象被成功构造,open()方法还未被调用
1 OPENED (已打开,未发送) open()方法已被成功调用,send()方法还未被调用。注意:只有xhr处于OPENED状态,才能调用xhr.setRequestHeader()和xhr.send(),否则会报错
2 HEADERS_RECEIVED (已获取响应头) send()方法已经被调用, 响应头和响应状态已经返回
3 LOADING (正在下载响应体) 响应体(response entity body)正在下载中,此状态下通过xhr.response可能已经有了响应数据
4 DONE (整个数据传输过程结束) 整个数据传输过程结束,不管本次请求是成功还是失败
如何设置请求的超时时间
如果请求过了很久还没有成功,为了不会白白占用的网络资源,我们一般会主动终止请求。XMLHttpRequest提供了timeout属性来允许设置请求的超时时间。

xhr.timeout

单位:milliseconds 毫秒
默认值:0,即不设置超时

很多同学都知道:从请求开始 算起,若超过 timeout 时间请求还没有结束(包括成功/失败),则会触发ontimeout事件,主动结束该请求。

【那么到底什么时候才算是请求开始 ?】
——xhr.onloadstart事件触发的时候,也就是你调用xhr.send()方法的时候。
因为xhr.open()只是创建了一个连接,但并没有真正开始数据的传输,而xhr.send()才是真正开始了数据的传输过程。只有调用了xhr.send(),才会触发xhr.onloadstart 。

【那么什么时候才算是请求结束 ?】
—— xhr.loadend事件触发的时候。

另外,还有2个需要注意的坑儿:

可以在 send()之后再设置此xhr.timeout,但计时起始点仍为调用xhr.send()方法的时刻。

当xhr为一个sync同步请求时,xhr.timeout必须置为0,否则会抛错。原因可以参考本文的【如何发一个同步请求】一节。

如何发一个同步请求
xhr默认发的是异步请求,但也支持发同步请求(当然实际开发中应该尽量避免使用)。到底是异步还是同步请求,由xhr.open()传入的async参数决定。

open(method, url [, async = true [, username = null [, password = null]]])

method: 请求的方式,如GET/POST/HEADER等,这个参数不区分大小写

url: 请求的地址,可以是相对地址如example.php,这个相对是相对于当前网页的url路径;也可以是绝对地址如http://www.example.com/example.php

async: 默认值为true,即为异步请求,若async=false,则为同步请求

在我认真研读W3C 的 xhr 标准前,我总以为同步请求和异步请求只是阻塞和非阻塞的区别,其他什么事件触发、参数设置应该是一样的,事实证明我错了。

W3C 的 xhr标准中关于open()方法有这样一段说明:

Throws an “InvalidAccessError” exception if async is false, the JavaScript global environment is a document environment, and either the timeout attribute is not zero, the withCredentials attribute is true, or the responseType attribute is not the empty string.

从上面一段说明可以知道,当xhr为同步请求时,有如下限制:

xhr.timeout必须为0

xhr.withCredentials必须为 false

xhr.responseType必须为””(注意置为”text”也不允许)

若上面任何一个限制不满足,都会抛错,而对于异步请求,则没有这些参数设置上的限制。

之前说过页面中应该尽量避免使用sync同步请求,为什么呢?
因为我们无法设置请求超时时间(xhr.timeout为0,即不限时)。在不限制超时的情况下,有可能同步请求一直处于pending状态,服务端迟迟不返回响应,这样整个页面就会一直阻塞,无法响应用户的其他交互。

另外,标准中并没有提及同步请求时事件触发的限制,但实际开发中我确实遇到过部分应该触发的事件并没有触发的现象。如在 chrome中,当xhr为同步请求时,在xhr.readyState由2变成3时,并不会触发 onreadystatechange事件,xhr.upload.onprogress和 xhr.onprogress事件也不会触发。

如何获取上传、下载的进度
在上传或者下载比较大的文件时,实时显示当前的上传、下载进度是很普遍的产品需求。
我们可以通过onprogress事件来实时显示进度,默认情况下这个事件每50ms触发一次。需要注意的是,上传过程和下载过程触发的是不同对象的onprogress事件:

上传触发的是xhr.upload对象的 onprogress事件

下载触发的是xhr对象的onprogress事件

xhr.onprogress = updateProgress;
xhr.upload.onprogress = updateProgress;
function updateProgress(event) {
if (event.lengthComputable) {
var completedPercent = event.loaded / event.total;
}
}
可以发送什么类型的数据
void send(data);

xhr.send(data)的参数data可以是以下几种类型:

ArrayBuffer

Blob

Document

DOMString

FormData

null

如果是 GET/HEAD请求,send()方法一般不传参或传 null。不过即使你真传入了参数,参数也最终被忽略,xhr.send(data)中的data会被置为 null.

xhr.send(data)中data参数的数据类型会影响请求头部content-type的默认值:

如果data是 Document 类型,同时也是HTML Document类型,则content-type默认值为text/html;charset=UTF-8;否则为application/xml;charset=UTF-8;

如果data是 DOMString 类型,content-type默认值为text/plain;charset=UTF-8;

如果data是 FormData 类型,content-type默认值为multipart/form-data; boundary=[xxx]

如果data是其他类型,则不会设置content-type的默认值

当然这些只是content-type的默认值,但如果用xhr.setRequestHeader()手动设置了中content-type的值,以上默认值就会被覆盖。

另外需要注意的是,若在断网状态下调用xhr.send(data)方法,则会抛错:Uncaught NetworkError: Failed to execute ‘send’ on ‘XMLHttpRequest’。一旦程序抛出错误,如果不 catch 就无法继续执行后面的代码,所以调用 xhr.send(data)方法时,应该用 try-catch捕捉错误。

try{
xhr.send(data)
}catch(e) {
//doSomething…
};
xhr.withCredentials与 CORS 什么关系
我们都知道,在发同域请求时,浏览器会将cookie自动加在request header中。但大家是否遇到过这样的场景:在发送跨域请求时,cookie并没有自动加在request header中。

造成这个问题的原因是:在CORS标准中做了规定,默认情况下,浏览器在发送跨域请求时,不能发送任何认证信息(credentials)如”cookies”和”HTTP authentication schemes”。除非xhr.withCredentials为true(xhr对象有一个属性叫withCredentials,默认值为false)。

所以根本原因是cookies也是一种认证信息,在跨域请求中,client端必须手动设置xhr.withCredentials=true,且server端也必须允许request能携带认证信息(即response header中包含Access-Control-Allow-Credentials:true),这样浏览器才会自动将cookie加在request header中。

另外,要特别注意一点,一旦跨域request能够携带认证信息,server端一定不能将Access-Control-Allow-Origin设置为*,而必须设置为请求页面的域名。

xhr相关事件
事件分类
xhr相关事件有很多,有时记起来还挺容易混乱。但当我了解了具体代码实现后,就容易理清楚了。下面是XMLHttpRequest的部分实现代码:

interface XMLHttpRequestEventTarget : EventTarget {
// event handlers
attribute EventHandler onloadstart;
attribute EventHandler onprogress;
attribute EventHandler onabort;
attribute EventHandler onerror;
attribute EventHandler onload;
attribute EventHandler ontimeout;
attribute EventHandler onloadend;
};

interface XMLHttpRequestUpload : XMLHttpRequestEventTarget {

};

interface XMLHttpRequest : XMLHttpRequestEventTarget {
// event handler
attribute EventHandler onreadystatechange;
readonly attribute XMLHttpRequestUpload upload;
};
从代码中我们可以看出:

XMLHttpRequestEventTarget接口定义了7个事件:

onloadstart

onprogress

onabort

ontimeout

onerror

onload

onloadend

每一个XMLHttpRequest里面都有一个upload属性,而upload是一个XMLHttpRequestUpload对象

XMLHttpRequest和XMLHttpRequestUpload都继承了同一个XMLHttpRequestEventTarget接口,所以xhr和xhr.upload都有第一条列举的7个事件

onreadystatechange是XMLHttpRequest独有的事件

所以这么一看就很清晰了:
xhr一共有8个相关事件:7个XMLHttpRequestEventTarget事件+1个独有的onreadystatechange事件;而xhr.upload只有7个XMLHttpRequestEventTarget事件。

事件触发条件
下面是我自己整理的一张xhr相关事件触发条件表,其中最需要注意的是 onerror 事件的触发条件。

事件 触发条件
onreadystatechange 每当xhr.readyState改变时触发;但xhr.readyState由非0值变为0时不触发。
onloadstart 调用xhr.send()方法后立即触发,若xhr.send()未被调用则不会触发此事件。
onprogress xhr.upload.onprogress在上传阶段(即xhr.send()之后,xhr.readystate=2之前)触发,每50ms触发一次;xhr.onprogress在下载阶段(即xhr.readystate=3时)触发,每50ms触发一次。
onload 当请求成功完成时触发,此时xhr.readystate=4
onloadend 当请求结束(包括请求成功和请求失败)时触发
onabort 当调用xhr.abort()后触发
ontimeout xhr.timeout不等于0,由请求开始即onloadstart开始算起,当到达xhr.timeout所设置时间请求还未结束即onloadend,则触发此事件。
onerror 在请求过程中,若发生Network error则会触发此事件(若发生Network error时,上传还没有结束,则会先触发xhr.upload.onerror,再触发xhr.onerror;若发生Network error时,上传已经结束,则只会触发xhr.onerror)。注意,只有发生了网络层级别的异常才会触发此事件,对于应用层级别的异常,如响应返回的xhr.statusCode是4xx时,并不属于Network error,所以不会触发onerror事件,而是会触发onload事件。
事件触发顺序
当请求一切正常时,相关的事件触发顺序如下:

触发xhr.onreadystatechange(之后每次readyState变化时,都会触发一次)

触发xhr.onloadstart
//上传阶段开始:

触发xhr.upload.onloadstart

触发xhr.upload.onprogress

触发xhr.upload.onload

触发xhr.upload.onloadend
//上传结束,下载阶段开始:

触发xhr.onprogress

触发xhr.onload

触发xhr.onloadend

发生abort/timeout/error异常的处理
在请求的过程中,有可能发生 abort/timeout/error这3种异常。那么一旦发生这些异常,xhr后续会进行哪些处理呢?后续处理如下:

一旦发生abort或timeout或error异常,先立即中止当前请求

将 readystate 置为4,并触发 xhr.onreadystatechange事件

如果上传阶段还没有结束,则依次触发以下事件:

xhr.upload.onprogress

xhr.upload.[onabort或ontimeout或onerror]

xhr.upload.onloadend

触发 xhr.onprogress事件

触发 xhr.[onabort或ontimeout或onerror]事件

触发xhr.onloadend 事件

在哪个xhr事件中注册成功回调?
从上面介绍的事件中,可以知道若xhr请求成功,就会触发xhr.onreadystatechange和xhr.onload两个事件。 那么我们到底要将成功回调注册在哪个事件中呢?我倾向于 xhr.onload事件,因为xhr.onreadystatechange是每次xhr.readyState变化时都会触发,而不是xhr.readyState=4时才触发。

xhr.onload = function () {
//如果请求成功
if(xhr.status == 200){
//do successCallback
}
}
上面的示例代码是很常见的写法:先判断http状态码是否是200,如果是,则认为请求是成功的,接着执行成功回调。这样的判断是有坑儿的,比如当返回的http状态码不是200,而是201时,请求虽然也是成功的,但并没有执行成功回调逻辑。所以更靠谱的判断方法应该是:当http状态码为2xx或304时才认为成功。

xhr.onload = function () {
//如果请求成功
if((xhr.status >= 200 && xhr.status < 300) || xhr.status == 304){
//do successCallback
}
}
结语
终于写完了……
看完那一篇长长的W3C的xhr 标准,我眼睛都花了……
希望这篇总结能帮助刚开始接触XMLHttpRequest的你。

最后给点扩展学习资料,如果你:

想真正搞懂XMLHttpRequest,最靠谱的方法还是看 W3C的xhr 标准;

想结合代码学习如何用XMLHttpRequest发各种类型的数据,可以参考html5rocks上的这篇文章

想粗略的了解XMLHttpRequest的基本使用,可以参考MDN的XMLHttpRequest介绍;

想了解XMLHttpRequest 的发展历程,可以参考阮老师的文章;

想了解Ajax的基本介绍,可以参考AJAX Tutorial;

想了解跨域请求,则可以参考W3C的 cors 标准;

想了解http协议,则可以参考HTTP Tutorial;

聊聊cookie(转)

Posted on 2019-04-25

参考链接1
参考链接2

cookie是什么

这个讲起来很简单,了解http的同学,肯定知道,http是一个不保存状态的协议,什么叫不保存状态,就是一个服务器是不清楚是不是同一个浏览器在访问他,在cookie之前,有另外的技术是可以解决,这里简单讲一下,就是在请求中插入一个token,然后在发送请求的时候,把这个东西带给服务器,这种方式是易出错,所以有了cookie的出现

cookie是什么

cookie原理

cookie原理

第一次访问网站的时候,浏览器发出请求,服务器响应请求后,会将cookie放入到响应请求中,在浏览器第二次发请求的时候,会把cookie带过去,服务端会辨别用户身份,当然服务器也可以修改cookie内容

cookie不可跨域

我就几个例子你就懂了,当我打开百度的网页,我要设置一个cookie的时候,我的指令如下:

1
2
document.cookie='myname=laihuamin;path=/;domain=.baidu.com';
document.cookie='myname=huaminlai;path=/;domain=.google.com';

当我将这两个语句都放到浏览器控制台运行的时候,你会发现一点,注意,上面两个cookie的值是不相同的,看清楚:

cookie不可跨域

显而易见的是,真正能把cookie设置上去的只有domain是.baidu.com的cookie绑定到了域名上,所以上面所说的不可跨域性,就是不能在不同的域名下用,每个cookie都会绑定单一的域名。

cookie的格式

JS原生的API提供了获取cookie的方法:document.cookie(注意,这个方法只能获取非HttpOnly类型的cookie)。在console中执行这段代码可以看到结果如下图:

cookie格式

cookie属性

每个cookie都有一定的属性,如什么时候失效,要发送到哪个域名,哪个路径等等。这些属性是通过cookie选项来设置的,cookie选项包括:expires、domain、path、secure、HttpOnly。在设置任一个cookie时都可以设置相关的这些属性,当然也可以不设置,这时会使用这些属性的默认值。在设置这些属性时,属性之间由一个分号和一个空格隔开。代码示例如下:

1
"key=name; expires=Thu, 25 Feb 2016 04:18:00 GMT; domain=ppsc.sankuai.com; path=/; secure; HttpOnly"

cookie属性

  • name
  • value
  • domain
  • path
  • expires
  • secure
  • HttpOnly

name

这个显而易见,就是代表cookie的名字的意思,一个域名下绑定的cookie,name不能相同,相同的name的值会被覆盖掉,有兴趣的同学可以试一试,我在项目中切实用到过。

value

这个就是每个cookie拥有的一个属性,它表示cookie的值,但是我在这里想说的不是这个,因为我在网上看到两种说法,如下:

  1. cookie的值必须被URL编码
  2. 对cookie的值进行编码不是必须的,还举了原始文档中所说的,仅对三种符号必须进行编码:分号、逗号和空格

这个东西得一分为二来看,先看下面的图:

value

我在网上看到那么一种说法:

由于cookie规定是名称/值是不允许包含分号,逗号,空格的,所以为了不给用户到来麻烦,考虑服务器的兼容性,任何存储cookie的数据都应该被编码。

domain和path

domain是域名,path是路径,两者加起来就构成了 URL,domain和path一起来限制cookie能被哪些 URL 访问。

一句话概括:某cookie的domain为“baidu.com”, path为“/ ”,若请求的URL(URL可以是js/html/img/css资源请求,但不包括XHR请求)的域名是“baidu.com”或其子域如“api.baidu.com”、“dev.api.baidu.com”,且 URL 的路径是“/ ”或子路径“/home”、“/home/login”,则浏览器会将此 cookie 添加到该请求的 cookie 头部中。

所以domain和path2个选项共同决定了cookie何时被浏览器自动添加到请求头部中发送出去。如果没有设置这两个选项,则会使用默认值。domain的默认值为设置该cookie的网页所在的域名,path默认值为设置该cookie的网页所在的目录。

特别说明1:
发生跨域xhr请求时,即使请求URL的域名和路径都满足 cookie 的 domain 和 path,默认情况下cookie也不会自动被添加到请求头部中。若想知道原因请阅读本文最后一节)

特别说明2:
domain是可以设置为页面本身的域名(本域),或页面本身域名的父域,但不能是公共后缀public suffix。举例说明下:如果页面域名为www.baidu.com, domain可以设置为“www.baidu.com”,也可以设置为“baidu.com”,但不能设置为“.com”或“com”。

expires

expires

expires选项用来设置“cookie什么时间内有效”。

expires其实是cookie失效日期,expires必须是GMT格式的时间(可以通过new Date().toGMTString()或者new Date().toUTCString()来获得)。如expires=Thu, 25 Feb 2016 04:18:00 GMT表示cookie讲在2016年2月25日4:18分之后失效,对于失效的cookie浏览器会清空。

如果没有设置该选项,则默认有效期为session,即会话cookie。这种cookie在浏览器关闭后就没有了。

expires 是 http/1.0协议中的选项,在新的http/1.1协议中expires已经由 max-age 选项代替,两者的作用都是限制cookie 的有效时间。expires的值是一个时间点(cookie失效时刻= expires),而max-age 的值是一个以秒为单位时间段(cookie失效时刻= 创建时刻+ max-age)。
另外,max-age 的默认值是 -1(即有效期为 session );若max-age有三种可能值:负数、0、正数。负数:有效期session;0:删除cookie;正数:有效期为创建时刻+ max-age

如果你想要cookie存在一段时间,那么你可以通过设置Expires属性为未来的一个时间节点,Expires这个是代表当前时间的,这个属性已经逐渐被我们下面这个主人公所取代——Max-Age。

Max-Age,是以秒为单位的。

  • Max-Age为正数时,cookie会在Max-Age秒之后,被删除。
  • 当Max-Age为负数时,表示的是临时储存,不会生出cookie文件,只会存在浏览器内存中,且只会在打开的浏览器窗口或者子窗口有效,一旦浏览器关闭,cookie就会消失。
  • 当Max-Age为0时,又会发生什么呢,删除cookie,因为cookie机制本身没有设置删除cookie,失效的cookie会被浏览器自动从内存中删除,所以,它实现的就是让cookie失效。

secure

secure

这个属性译为安全,http不仅是无状态的,还是不安全的协议,容易被劫持,打个比方,你在手机端浏览网页的时候,有没有中国移动图标跳出来过,闲言少叙,当这个属性设置为true时,此cookie只会在https和ssl等安全协议下传输

提示: 这个属性并不能对客户端的cookie进行加密,不能保证绝对的安全性。

HttpOnly

这个属性是面试的时候常考的,如果这个属性设置为true,就不能通过js脚本来获取cookie的值,能有效的防止xss攻击,看MDN的官方文档:

HttpOnly

——httpOnly与安全

从上面介绍中,大家是否会有这样的疑问:为什么我们要限制客户端去访问cookie?其实这样做是为了保障安全。

试想:如果任何 cookie 都能被客户端通过document.cookie获取会发生什么可怕的事情。当我们的网页遭受了 XSS 攻击,有一段恶意的script脚本插到了网页中。这段script脚本做的事情是:通过document.cookie读取了用户身份验证相关的 cookie,并将这些 cookie 发送到了攻击者的服务器。攻击者轻而易举就拿到了用户身份验证信息,于是就可以摇摇大摆地冒充此用户访问你的服务器了(因为攻击者有合法的用户身份验证信息,所以会通过你服务器的验证)。

关于js操作cookie

document.cookie可以对cookie进行读写,看一下两条指令:

1
2
3
4
//读取浏览器中的cookie
console.log(document.cookie);
//写入cookie
document.cookie="age=12; expires=Thu, 26 Feb 2116 11:50:25 GMT; domain=sankuai.com; path=/";

注意:

  • 客户端可以设置cookie 的下列选项:expires、domain、path、secure(有条件:只有在https协议的网页中,客户端设置secure类型的cookie才能成功),但无法设置HttpOnly选项。

设置多个cookie

当要设置多个cookie时, js代码很自然地我们会这么写:

1
document.cookie = "name=Jonh; age=12; class=111";

但你会发现这样写只是添加了第一个cookie`“name=John”,后面的所有cookie都没有添加成功。所以最简单的设置多个cookie的方法就在重复执行document.cookie = “key=name”`,如下:

1
2
3
document.cookie = "name=Jonh";
document.cookie = "age=12";
document.cookie = "class=111";

服务端如何设置cookie

关于怎么设置cookie,我们只要打开控制台,看一个http的请求头和响应头中的东西即可明白:

服务端如何设置cookie

不管你是请求一个资源文件(如html/js/css/image),还是发送一个ajax请求,服务端都会返回response。而response header中有一项叫set-cookie,是服务端专门用来设置cookie的。如下图所示,服务端返回的response header中有5个set-cookie字段,每个字段对应一个cookie(注意不能将多个cookie放在一个set-cookie字段中),set-cookie字段的值就是普通的字符串,每个cookie还设置了相关属性选项。

注意:

  • 一个Set-Cookie字段只能设置一个cookie,当你要想设置多个 cookie,需要添加同样多的Set-Cookie字段。
  • 服务端可以设置cookie 的所有选项:expires、domain、path、secure、HttpOnly

我们看到的cookie

发送一个AJAX请求,header如下图:

我们看到的cookie

从上图中我们会看到request header中自动添加了Cookie字段(我并没有手动添加这个字段哦~),Cookie字段的值其实就是我设置的那4个cookie。这个请求最终会发送到http://ppsc.sankuai.com这个服务器上,这个服务器就能从接收到的request header中提取那4个cookie。

上图展示了cookie的基本通信流程:设置cookie => cookie被自动添加到request header中 => 服务端接收到cookie。这个流程中有几个问题需要好好研究:

  1. 什么样的数据适合放在cookie中?
  2. cookie是怎么设置的?
  3. cookie为什么会自动加到request header中?
  4. cookie怎么增删查改?

我们要带着这几个问题继续往下阅读。

cookie是怎么工作的?

首先必须明确一点,存储cookie是浏览器提供的功能。cookie其实是存储在浏览器中的纯文本,浏览器的安装目录下会专门有一个cookie文件夹来存放各个域下设置的cookie。

当网页要发http请求时,浏览器会先检查是否有相应的cookie,有则自动添加在request header中的cookie字段中。这些是浏览器自动帮我们做的,而且每一次http请求浏览器都会自动帮我们做。这个特点很重要,因为这关系到“什么样的数据适合存储在cookie中”。

存储在cookie中的数据,每次都会被浏览器自动放在http请求中,如果这些数据并不是每个请求都需要发给服务端的数据,浏览器这设置自动处理无疑增加了网络开销;但如果这些数据是每个请求都需要发给服务端的数据(比如身份认证信息),浏览器这设置自动处理就大大免去了重复添加操作。所以对于那设置“每次请求都要携带的信息(最典型的就是身份认证信息)”就特别适合放在cookie中,其他类型的数据就不适合了。

但在localStorage出现之前,cookie被滥用当做了存储工具。什么数据都放在cookie中,即使这些数据只在页面中使用而不需要随请求传送到服务端。当然cookie标准还是做了一些限制的:每个域名下的cookie的大小最大为4KB,每个域名下的cookie数量最多为20个(但很多浏览器厂商在具体实现时支持大于20个)。

如何修改、删除

修改cookie

要想修改一个cookie,只需要重新赋值就行,旧的值会被新的值覆盖。但要注意一点,在设置新cookie时,path/domain这几个选项一定要和旧cookie保持一样。否则不会修改旧值,而是添加了一个新的 cookie。

删除 cookie

删除一个cookie也挺简单,也是重新赋值,只要将这个新cookie的expires选项设置为一个过去的时间点就行了。但同样要注意,path/domain这几个选项一定要旧cookie保持一样。

cookie 编码

cookie其实是个字符串,但这个字符串中逗号、分号、空格被当做了特殊符号。所以当cookie的key和value中含有这3个特殊字符时,需要对其进行额外编码,一般会用escape进行编码,读取时用unescape进行解码;当然也可以用encodeURIComponent/decodeURIComponent或者encodeURI/decodeURI(三者的区别可以参考这篇文章)。

1
2
3
var key = escape("name;value");
var value = escape("this is a value contain , and ;");
document.cookie= key + "=" + value + "; expires=Thu, 26 Feb 2116 11:50:25 GMT; domain=sankuai.com; path=/";

跨域请求中 cookie

之前在介绍 XHR 的一篇文章里面提过:默认情况下,在发生跨域时,cookie作为一种credential信息是不会被传送到服务端的。必须要进行额外设置才可以。具体原因和如何设置可以参考我的这篇文章:你真的会使用XMLHttpRequest吗?

另外,关于跨域资源共享 CORS极力推荐大家阅读阮一峰老师的这篇 跨域资源共享 CORS 详解。

其他补充

  1. 什么时候 cookie 会被覆盖:name/domain/path这3个字段都相同的时候;
  2. 关于domain的补充说明(参考1/参考2):
    1. 如果显式设置了 domain,则设置成什么,浏览器就存成什么;但如果没有显式设置,则浏览器会自动取 url 的 host 作为 domain 值;
    2. 新的规范中,显式设置 domain 时,如果 value 最前面带点,则浏览器处理时会将这个点去掉,所以最后浏览器存的就是没有点的(注意:但目前大多数浏览器并未全部这么实现)
    3. 前面带点‘.’和不带点‘.’有啥区别:
      • 带点:任何 subdomain 都可以访问,包括父 domain
      • 不带点:只有完全一样的域名才能访问,subdomain 不能(但在 IE 下比较特殊,它支持 subdomain 访问)

总结

咱们今天就聊到这里,若有不对之处欢迎各位指正~~
最后附上一些参考资料:

  • http://www.quirksmode.org/js/cookies.html
  • http://www.tutorialspoint.com/javascript/javascript_cookies.htm
  • http://www.allaboutcookies.org/cookies/cookies-the-same.html
  • http://bubkoo.com/2014/04/21/http-cookies-explained/

浏览器同源政策及其规避方法(转)

Posted on 2019-04-25

原文地址

最初,同源策略的含义是指,网页A设置的cookie,网页B不能打开,除非这两个网页“同源”,如下:

  • 协议相同
  • 域名相同
  • 端口相同

举例来说,http://www.example.com/dir/page.html的同源情况如下:

  • http://www.example.com/dir2/other.html:同源
  • http://example.com/dir/other.html:不同源(域名不同)
  • http://v2.www.example.com/dir/other.html:不同源(域名不同)
  • http://www.example.com:81/dir/other.html:不同源(端口不同)

同源策略的目的是为了保证用户信息的安全,防止恶意的网站窃取数据。

随着互联网的发展,同源策略越来越严格,目前共有三种行为收到限制:

  1. Cookie、LocalStorage和IndexedDB无法读取;
  2. DOM无法获得;
  3. AJAX请求不能发送。

如何规避

Cookie

Cookie是服务器写入浏览器的一小段信息,只有同源的网页才能共享。但是,两个网页一级域名相同,只是二级域名不同,浏览器允许通过设置document.domain共享cookie。

举例来说,网页A是http://w1.example.com/a.html,网页B是http://w2.example.com/b.html。那么只要设置相同的document.domain,两个网页就可以共享cookie。

注意,这种方法只适用于cookie和iframe窗口,LocalStorage和IndexedDB是不行的,而要使用PostMessageAPI。

此外,服务器可以在设置cookie的时候,指定cookie所属域名为一级域名,比如.example.com,这样,二级域名和三级域名不用做任何设置,都可以读取这个cookie。

iframe

如果两个网页不同源,就无法拿到对方的DOM。典型的例子是iframe和window.open()打开的窗口与父窗口无法通信。

如果两个窗口一级域名相同,只是二级域名不同,那么设置document.domain属性就可以。

对于完全不同源的网站,目前有三种方法:

  • 片段识别符(fragment identifier)
  • window.name
  • 跨文档通信API(cross-document messageing)

片段识别符

片段识别符(fragment identifier)是指URL的#后面的部分,如果只是改变片段标识符,页面不会重新刷新。

父窗口可以把信息写入子窗口的片段标识符,子窗口监听hashchange事件得到通知。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 父
const src = `${originURL}#${data}`;
document.getElementById('myIframe').src = src;

// 子
window.onhashchange = checkMessage;
function checkMessage() {
const message = window.location.hash;
// ...
}

// 子窗口也可以改变父窗口的片段标识符
parent.location.href = `${target}#${hash}`;

window.name

无论是否同源,只要在一个窗口里,前一个网页设置的window.name,后一个网页就可以读取它。

优点是容量大,可以放置很长的字符串;缺点是必须监听属性变化的事件,影响性能。

postMessage

HTML5提供了跨文档通信API(Cross-document messaging)postMessage,允许跨窗口通信,不论这两个窗口是否同源。

1
2
3
4
5
6
7
8
9
// 父窗口向子窗口
const popup = window.open('http://b.com', 'title');
popup.postMessage('hello', 'http://b.com');

// 子窗口向父窗口
window.opener.postMessage('hello', 'http://a.com');

// 接收数据
window.addEventListener('message', (e) => console.log(e.data), false);

event对象的三个属性:

  • event.source:发送消息的窗口
  • event.origin:消息发向的网址
  • event.data:消息内容

LocalStorage

通过window.postMessage可以读写其他窗口的LocalStorage。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 子窗口接收父窗口消息
window.onmessage = (e) => {
if (e.origin !== 'http://bbb.com') return;
const payload = JSON.parse(e.data);
switch (payload.method) {
case 'set':
localStorage.setItem(payload.key, JSON.stringify(payload.data));
break;
case 'get':
const parent = window.parent;
const data = localStorage.getItem(payload.key);
parent.postMessage(data, 'http://aaa.com');
break;
case 'remove':
localStorage.removeItem(payload.key);
break;
}
};

// 父窗口发消息
const win = document.getElementsByTagName('iframe')[0].contentWindow;
const obj = { name: 'Jack' };
// 存入对象
win.postMessage(JSON.stringify({key: 'storage', method: 'set', data: obj}), 'http://bbb.com');
// 读取对象
win.postMessage(JSON.stringify({key: 'storage', method: "get"}), "*");
window.onmessage = (e) => {
if (e.origin != 'http://aaa.com') return;
// "Jack"
console.log(JSON.parse(e.data).name);
};

AJAX

AJAX请求只能发给同源网址,有三种方法规避这个限制:

  • JSONP
  • WebSocket
  • CORS

@提及 实现(转)

Posted on 2019-04-24

原文链接

我的华丽的Zsh提词My Extravagant Zsh Prompt(译)

Posted on 2019-04-24

原文地址

1…3456

沙雕爹爹

Personal tech blog.

55 posts
45 tags
© 2019 沙雕爹爹
Powered by Hexo
|
Theme — NexT.Muse v5.1.4