SegmentFault Frontend Magic最新的文章
2021-05-29T19:57:00+08:00
https://segmentfault.com/feeds/blogs
https://creativecommons.org/licenses/by-nc-nd/4.0/
【Pride】再谈风骚的跨源/域方案(今日篇)
https://segmentfault.com/a/1190000040086190
2021-05-29T19:57:00+08:00
2021-05-29T19:57:00+08:00
calimanco
https://segmentfault.com/u/calimanco
2
<h2>前言</h2><p>上接<a href="https://segmentfault.com/a/1190000040070036">《再谈风骚的跨源/域方案(昔日篇)》</a>,本篇聊聊现代标准(HTML5之后)的跨源方案。 <br>基础概念都在昔日篇中,初学者请务必先看完昔日篇。 <br>配套的演示案例<a href="https://link.segmentfault.com/?enc=NP%2BKyLK4B10zUw5ubIDH5Q%3D%3D.TpbGBUXSWLRkTCtTvkRF7XIqYZyuKNPEiafTmtt6yox1mXpNL7UEB5aLvFMHDdud" rel="nofollow">传送门</a>。 <br>本人个人能力有限,欢迎批评指正。</p><h2>PostMessage</h2><p>该方案使用了 HTML5 新的 <code>window.postMessage</code> 接口,该方法是专门为不同源页面通信设计的,是一个经典的“订阅-通知”模型。</p><h3>原理</h3><p>该方案原理与昔日篇的“子域代理”很相似,都是主页面用 iframe 内非同源子页面作为代理去跟服务端交互获取数据。不同之处在于,“子域代理”需要通过修改 <code>document.domain</code> 使主页面获取子页面 document 操作权限,而 <code>window.postMessage</code> 已经原生提供了主页面与子页面通信的办法,故仅需要主页面通过 <code>window.postMessage</code> 向子页面下命令,子页面请求完成后再以此通知主页面即可实现跨源通信,换句话说子页面变成了一个类似转发服务的存在。 <br>不需要修改 <code>document.domain</code> 也意味着摆脱了“子域代理”严格的域限制,可以更加自由的应用在第三方 API 上。 <br><code>window.postMessage</code> 是少有的不受同源限制的浏览器 API,准确来说是没有调用权限的限制而已,它对发送和接收的目标还是有严格限制的,这也是它安全性的体现。举个例子:</p><pre><code class="javascript">// 假设在 iframe 内页面进行订阅。
window.addEventListener('message', event => {
// 验证发送者,发送者不符合是可以不理会的。
if (event.origin !== 'http://demo.com') return
// 这就是发送过来的信息。
const data = event.data
// 这是发送者的 window 实例,可以调用上面的 postMessage 回传信息。
const source = event.source
})</code></pre><pre><code class="javascript">// 主页面通知。
// 第二个参数是接收者的源,需要源完全匹配的页面才会接收到信息。(“源”的定义见昔日篇)
// 设置为 * 可以实现广播,不过一般不推荐。
iframe.contentWindow.postMessage('hello there!', 'http://demo.com')</code></pre><h3>流程</h3><ol><li>API 所在的域部署一个代理页,设置好对 message 事件的监听,包含发送 Ajax 并将响应结果 postMessage 回主页面的功能;</li><li>主页面也设置对 message 事件的监听,并进行内容分发;</li><li>主页面新建 iframe 标签链接到代理页;</li><li>当 iframe 内的代理页就绪时,主页面就可以使用 <code>iframe.contentWindow.postMessage</code> 发送请求给代理页;</li><li>代理页接收到请求,后发起 Ajax 到服务端 API;</li><li>服务端处理并响应,代理接收到响应后再通过 <code>event.source.postMessage</code> 传递给主页面。</li></ol><p><img src="/img/bVcSlw6" alt="PostMessage" title="PostMessage"></p><h3>错误处理</h3><ul><li>通过 iframe 的 load 事件可以检查代理页是否被加载(非同源需要 hack 方法),以此间接判断是否有网络错误,但并不可知具体的错误原因,也就是说无法获取到服务器的响应状态码;</li><li>iframe 的 error 事件在大部分浏览器是无效的(默认),发送 Ajax 是在 iframe 中完成,如果发生错误只能通过 postMessage 转发给主页面,因此建议不要在 iframe 内处理错误,应统一交给主页面处理。</li></ul><h3>实践提示</h3><ul><li><p>前端</p><ul><li>加载代理页是需要耗时的,因此要注意发起请求的时机,免在代理页还未加载完的时候请求;</li><li>并不需要每次请求都加载新的代理页,强烈建议只保留一个,多个请求共享;</li><li>如果遵从上一条的建议,还需考虑代理页加载失败的情况,避免一次失败后后续均不可以;</li><li>可以使用预加载的方式提前加载代理页,以免增加请求的时间;</li><li>无论是接收方还是发送方,都应该设置和验证 postMessage 的目标(targetOrigin),以确保安全性;</li><li>没必要每次请求都去监听 message 事件,可以在初始化时设置一个统一事件处理器进行内容分发,用一个对象将每次请求的回调保存起来,分配唯一的 id ,通过统一的事件处理器按 id 调用回调;</li><li>如果遵从上一条的建议,全局对象内回调函数需要及时清理。</li></ul></li><li><p>服务端</p><ul><li>代理页的域必须与 API 的域是一致的;</li><li>代理页一般无需经常更新,可以进行长期缓存;</li><li>代理页应尽量精简,Ajax 请求的结果无论成功或失败都应 postMessage 给主页面。</li></ul></li></ul><p>共享 iframe 的设计思路请参考昔日篇的“子域代理”。 <br>前端“统一事件处理器”的设计思路:</p><pre><code class="javascript">function initMessageListener() {
// 保存回调对象的对象。
const cbStore = {}
// 设置监听,只需一个。
window.addEventListener('message', function (event) {
// 验证发送域。
if (event.origin !== targetOrigin) {
return
}
// ...
try {
// 运行失败分支。
if (...) {
cbStore[msgId].reject(new Error(...))
return
}
// 运行成功分支。
cbStore[msgId].resolve(...)
} finally {
// 执行清理。
delete cbStore[msgId]
}
})
// 这里形成了一个闭包,只能用特定方法操作 cbStore。
return {
// 设置回调对象的方法。
set: function (msgId, resolve, reject) {
// 回调对象包含成功和失败两个分支函数。
cbStore[msgId] = {
resolve,
reject
}
},
// 删除回调对象的方法。
del: function (msgId) {
delete cbStore[msgId]
}
}
}
// 初始化,每次请求都调用其 set 方法设置回调对象。
const messageListener = initMessageListener()</code></pre><p>配合上面的“统一事件处理器”,msgId 其实没必要传递到服务端,在代理页处理即可:</p><pre><code class="javascript">window.addEventListener('message', event => {
// 验证发送域。
if (event.origin !== targetOrigin) {
return
}
// 这是主页面 postMessage 的数据。
// 其中 msgId 与“统一事件处理器”有关,其他参数与 Ajax 有关,按实际需要传递即可。
const { msgId, method, url, data } = event.data
// 发送 Ajax。
xhr(...).then(res => {
// 将 msgId 加入回传数据,其余保留原样。
res.response.data = {
...res.response.data,
msgId
}
// 回传给主页面。
event.source.postMessage(res, targetOrigin)
})
})</code></pre><p>具体代码请参考<a href="https://link.segmentfault.com/?enc=H3jgHdci4bdlfhC%2FdIKUbg%3D%3D.ZF0o8bT8U%2BqNWCIoYWUql84%2BUlrzExMaxdItdTAqO%2FwNgtfWRX5GW2iQtH7qX3AYmYhFy1xRhme8B4FEPtlSQQWwD0Q9prjg6VxBW8cDzt22rpohHYtun%2FR9tzp4nIZO" rel="nofollow">演示案例 PostMessage 部分</a>源码。</p><h3>总结</h3><ul><li><p>优点</p><ul><li>可以发送任意类型的请求;</li><li>可以使用标准的 API 规范;</li><li>能提供与正常 Ajax 请求无差别的体验;</li><li>错误捕获方便准确(除了 iframe 的网络错误);</li><li>对域无要求,可用于第三方 API。</li></ul></li><li><p>缺点</p><ul><li>iframe 对浏览器性能影响较大;</li><li>实际测试, PostMessage 接口的转发有小延迟;</li><li>仅能用于现代浏览器。</li></ul></li></ul><h2>CORS(跨源资源分享)</h2><p>CORS 全称 Cross-origin resource sharing ,是 W3C 组织制订的标准跨源方案(<a href="https://link.segmentfault.com/?enc=VyhE8t%2BjVl%2B9Cj2S%2FWgveA%3D%3D.lIv%2B%2BuvdPZ5yAvsfosFzTgr8GCzeG5XC38SBlUlE5SU%3D" rel="nofollow">传送门</a>),也可以说是跨源的官方终极解决方案,它让现代的 web 开发方便不少。</p><h3>原理</h3><p>简单来说 CORS 是一套服务端与浏览器的协商机制,通过报文头实现,浏览器告知服务端来源(origin)和希望允许的方法,服务端返回“白名单”(也是一组报文头),浏览器依据“白名单”判断是否允许这次请求,可应用与 Ajax、canvas 等的跨源情况。 <br>CORS 分为 简单请求(simple) 和 复杂请求(complex),他们最主要的区别就是需不需要预检(preflight)。 <br>简单请求需要满足如下条件(只挑重点):</p><ul><li><p>方法(method)为如下之一</p><ul><li>GET</li><li>POST</li><li>HEAD</li></ul></li><li><p>只允许设置如下报文头(header)</p><ul><li>Accept</li><li>Accept-Language</li><li>Content-Language</li><li><p>Content-Type (只允许三个)</p><ul><li><code>text/plain</code></li><li><code>multipart/form-data</code></li><li><code>application/x-www-form-urlencoded</code></li></ul></li><li>DPR</li><li>Downlink</li><li>Save-Data</li><li>Viewport-Width</li><li>Width</li></ul></li></ul><p>不满足上面条件的都会被判定为复杂请求,就实际使用而言 form 发出的请求基本都是允许的,如果要使用 json 格式传递数据(即 <code>Content-Type: application/json</code>),那必定是复杂请求。 <br>复杂请求会先发出预检请求,也就是先问问看服务端,如果返回的“白名单”符合要求再会发起正式的请求。 <br>预检请求是方法(method)为 OPTION 的请求,它不需要携带任何业务数据,仅依照需要发送 CORS 相关请求报文头给服务端,服务端也不需要响应任何业务数据,仅返回“白名单”,完成协商即可。</p><ul><li><p>CORS 相关请求报文头</p><ul><li>Origin:发起请求页面的源,由浏览器自动添加,不允许手动设置;</li><li>Access-Control-Request-Method:希望服务端允许的方法,浏览器预检时依据正式请求的需要自动添加,不允许手动设置;</li><li>Access-Control-Request-Headers:希望服务端允许的请求报文头,浏览器预检时依据正式请求的需要自动添加,不允许手动设置。</li></ul></li><li><p>CORS 相关响应报文头(即“白名单”)</p><ul><li>Access-Control-Allow-Origin:允许访问该资源的域,这是开启 CORS 必定会返回的响应报文头,填写为 <em> 则表示允许来自所有域的请求,如果指定了非 </em> 的源,需要将源作为缓存判断依据,因此添加 <code>Vary: Origin</code> 以免当 API 给不同源页面返回不同数据时,被缓存搞混;</li><li>Access-Control-Expose-Headers:在跨域的情况下, XMLHttpRequest 对象的 getResponseHeader() 方法只能拿到一些最基本的响应头,如果要获取而外头部,需要进行指定;</li><li>Access-Control-Max-Age:本次预检的最长有效期(秒),在这段时间内浏览器将不需要再次预检,而是直接发送正式请求;</li><li>Access-Control-Allow-Credentials:是否允许携带 cookie ,默认为 false,当设置为 true 时,不允许 Access-Control-Allow-Origin 设为 * ;</li><li>Access-Control-Allow-Methods:允许使用的请求方法;</li><li>Access-Control-Allow-Headers:允许使用的请求报文头,常用于添加自定义报文头。</li></ul></li></ul><h3>流程</h3><p>简单请求与一般的 Ajax 流程完全相同,仅需浏览器发送 Origin 请求报文头,服务端返回 Access-Control-Allow-Origin 响应报文头即可。 <br>下面详讲复杂请求的情况。 <br>假设现在网页源为 <code>http://demo.com</code> ,服务端 API 源为 <code>http://api.demo.com</code> ,需求请求的方法为 POST ,数据类型是 json,自定义报文头 token 。</p><ol><li>浏览器检查到,将发起 Ajax 请求的 API 源与当前页面源不同,则进入 CORS 协商;</li><li>数据类型是 json,而已还要自定义报文头,判定本次是复杂请求;</li><li><p>发送预检 OPTION 请求,有关 CORS 的报文头设置如下:</p><ul><li>读取当前页面的源写入 <code>Origin: http://demo.com</code>;</li><li>由于需要 POST 请求,则 <code>Access-Control-Request-Method: POST</code>;</li><li>由于需要数据类型是 json,也就是默认三种 content-type 不符合要求,还有自定义报文头 token 则 <code>Access-Control-Request-Headers: content-type, token</code>;</li></ul></li><li><p>服务器接收到预检请求进行响应,有关 CORS 的报文头设置如下:</p><ul><li>写入允许的域 <code>Access-Control-Allow-Origin: http://demo.com</code>;</li><li>写入允许的方法 <code>Access-Control-Allow-Methods: POST, GET, OPTIONS</code>;</li><li>写入允许的报文头 <code>Access-Control-Allow-Headers: Content-Type, token</code>;</li><li>写入 <code>Vary: Origin</code>(上面有说明,它不属于 CORS 报文头,但必须)</li></ul></li><li>浏览器接收到响应,验证 CORS 响应报文头,验证通过则紧接着发送正式 POST 请求,仅需添加 <code>Origin: http://demo.com</code> ,其余与正常请求一致;</li><li>服务器接收正式请求,处理后进行响应,仅需添加 <code>Access-Control-Allow-Origin: http://demo.com</code> 和 <code>Vary: Origin</code> ,其余与正常响应一致;</li><li>浏览器接收到响应,验证 CORS 响应报文头,验证通过则完成请求。</li></ol><p><img src="/img/bVcSmoR" alt="CORS" title="CORS"></p><h3>错误处理</h3><ul><li>服务器错误可以像一般请求那样捕获,获得准确的状态码;</li><li>当发生跨源相关的错误时,可在 XMLHttpRequest 对象的 error 事件捕获到;</li><li><p>跨源相关的错误总体分两类。</p><ul><li>拦截响应的错误:比如简单请求的时候,接收到响应数据,但响应报文头验证未通过,这时候虽然从抓包上看已经完成请求,但浏览器依然会报错;</li><li>限制请求的错误:比如复杂请求的时候,预检返回的响应报文头验证未通过,则浏览器不会发起正式的请求,而是直接报错,这时候抓包是看不到正式请求的。</li></ul></li></ul><h3>实践提示</h3><ul><li><p>前端</p><ul><li>该方案对前端的影响是十分小的,几乎是浏览器自动完成,像一般请求那样发起即可;</li><li>错误处理部分有提到两类跨源相关的错误,这是在调试时需要注意的点。</li></ul></li><li><p>服务端</p><ul><li><p>不建议无脑添加 CORS 相关响应报文头,要按需添加,以免造成头部冗余,参考上面的流程,可以大致可分为两组。</p><ul><li>简单请求头部:Access-Control-Allow-Origin 和 Vary 两个即可;</li><li>预检请求头部:按需选择 CORS 的头部,外加 Vary。</li></ul></li><li>Access-Control-Max-Age 是一个有效的优化手段,它可以减少频繁的预检请求,节约资源。</li><li>除非是公共的第三方 API,不建议将 Access-Control-Allow-Origin 设为 * 号。</li><li>为了安全性,最好验证 Origin 请求报文头,而不是忽略它,当不符合要求时,可以返回 403 状态码。</li></ul></li></ul><p>具体代码请参考<a href="https://link.segmentfault.com/?enc=4SjZWVUcHzwYCXTdLapuyg%3D%3D.y%2BRP8o5mgZ%2FL9vV5U3Y54UUKNhqkNtO1FINNxz4cVDMjKeEDHcUIT54tDHlC6u3NnwNPauACAAylQbrIjI6rx68xcKjN8aphgXNscn%2BISZc%3D" rel="nofollow">演示案例 CORS 部分</a>源码。</p><h3>总结</h3><ul><li><p>优点</p><ul><li>可以发送任意类型的请求;</li><li>可以使用标准的 API 规范;</li><li>能提供与正常 Ajax 请求无差别的体验;</li><li>错误捕获方便准确;</li><li>对域无要求,可用于第三方 API。</li></ul></li><li><p>缺点</p><ul><li>仅能用于现代浏览器。</li></ul></li></ul>
【Pride】再谈风骚的跨源/域方案(昔日篇)
https://segmentfault.com/a/1190000040070036
2021-05-26T20:37:16+08:00
2021-05-26T20:37:16+08:00
calimanco
https://segmentfault.com/u/calimanco
3
<h2>前言</h2><p>本文是笔者于 2008 年写的<a href="https://segmentfault.com/a/1190000014223524">《当年那些风骚的跨域操作》</a>的重制威力加强版。 <br>古云温故而知新,重看当年的文章,还是感觉有颇多不足和疏漏,思考深度也还欠缺,故进行重制。 <br>本人个人能力有限,欢迎批评指正。</p><h2>正名与致歉</h2><p>开头先来个一鞠躬。 <br>吐槽一下翻译,浏览各类 Wiki 和 RFC 里英文名都是“cross-origin”,origin 应该翻译成“源”、“来源”,合起来准确的翻译是“跨源”。但不知怎么搞的,在中文区以讹传讹地变成了“跨域”(cross-domain),这也造成了很大的误解,下面有讲到 domian 和 origin 的关系,这糟心的翻译让多少萌新混淆了这两个概念(包括我)。 <br>因此,本文只会用”跨源“这个准确翻译,也为自己以前文章的错误致歉。</p><h2>演示案例</h2><p>本次重制最重磅的一点是笔者实现了一套完整的演示案例,前后端代码都有,前端无第三方依赖,服务端基于 Express,源码细节一览无余,理论和实践完美结合。可在本地演示下述的所有跨源方案,有 NodeJS 环境就能玩,无需复杂配置、编译和容器。 <a href="https://link.segmentfault.com/?enc=t1HtA5dWDcPaML%2F34cJ6wA%3D%3D.AgPUYcXvXVY4u6Q3dJvJX2aLoq7zcEBeb1zO7cZvnQtfspEdKnEXbIGAwhaXhQtM" rel="nofollow">传送门</a><br>首页截图:<br><img src="/img/bVcSeFU" alt="首页" title="首页"></p><h2>同源策略(Same-Origin Policy)</h2><p>1995年,同源策略由 Netscape 公司引入浏览器。目前,所有浏览器都实行这个安全策略。 <br>本文要讲的“跨源”,正是要在确保安全的前提下绕过这个策略的限制。</p><h3>核心概念</h3><p>同源策略的目的是确保不同源提供的文件(资源)之间是相互独立的,类似于沙盒的概念。换句话说,只有当不同的文件脚本是由相同的源提供时才没有限制。限制可以细分为两个方面:</p><ul><li><p>对象访问限制<br>主要体现在 iframe,如果父子页面属于不同的源,那将有下面的限制:</p><ul><li>不可以相互访问 DOM(Document Object Model),也就是无法取得 document 节点,document 下面挂载的方式和属性,包括其所有子节点都无法访问。这也是 Cookie 遵循同源策略的原因,因为 <code>document.cookie</code> 不可能访问。</li><li><p>对于 BOM 只能有少量权限,也就是说可以互相取得 window 对象,但全部方法和大部分属性都无法用(比如 <code>window.localStorage</code>、<code>window.name</code> 等),只有少量属性可以有限访问,比如下面两种:</p><ul><li>可读,<code>window.length</code>。</li><li>可写,<code>window.location.href</code>。</li></ul></li></ul></li><li><p>网络访问限制<br>主要体现在 Ajax 请求,如果发起的请求目标源与当前页面的源不同,浏览器就会有下面的限制:</p><ul><li>拦截响应:对于<a href="https://link.segmentfault.com/?enc=8zoivkEla%2BqimeJKynguMw%3D%3D.3JeL1u45UdOAGUuDMCgDgmDhanWZQ68r%2FAwIOs0Mc4HN3I%2FC%2BPNmmRFqCQQKJMBSmyMg%2BmDzSmQYGXtw6J5XQrFwNwuhX0b4EDrDOqeiiKJirqJH1al9GeF9hrglhUHs" rel="nofollow">简单请求</a>,浏览器会发起请求,服务器正常响应,但只要服务器返回的响应报文头不符合要求,就会忽略所有返回的数据,直接报错。</li><li>限制请求:对于非简单请求,现代浏览器都会先发起<a href="https://link.segmentfault.com/?enc=leVElgu9BiwQbn%2FF62ShaA%3D%3D.J2y8ocO%2BBOVNdUDoSDMz8AbetoerUdTap9kl0bX3uHr5yRxiNLFmf36Uy49dCmndiMakZKprmgWfFCnZDkHY4QKsKsW895M0R4YtJr1FjLvQAK1Ns%2B326wfebpAsTZx6" rel="nofollow">预检请求</a>,但只要服务器返回的响应报文头不符合要求,就直接报错,不会再发起正式请求了,换句话说这种情况下服务器是拿不到任何有关这次请求的数据的。</li></ul></li></ul><h3>何为同源(Same-Origin)</h3><p>origin 在 Web 领域是有严格定义的,包含三个部分:协议、域和端口。</p><pre><code>origin = scheme + domain + port</code></pre><p>也就是说这三者都完全相同,才能叫同源。 <br>举个例子,假设现在有一个源为 <code>http://example.com</code> 的页面,向如下源发起请求,结果如下:</p><table><thead><tr><th>origin(URL)</th><th>result</th><th>reason</th></tr></thead><tbody><tr><td><code>http://example.com</code></td><td>success</td><td>协议、域和端口号均相同(浏览器默认 80 端口)</td></tr><tr><td><code>http://example.com:8080</code></td><td>fail</td><td>端口不同</td></tr><tr><td><code>https://example.com</code></td><td>fail</td><td>协议不同</td></tr><tr><td><code>http://sub.example.com</code></td><td>fail</td><td>域名不同</td></tr></tbody></table><h2>跨源方案(Cross-Origin)</h2><p>同源策略提出的时代还是传统 MVC 架构(jsp,asp)盛行的年代,那时候的页面靠服务器渲染完成了大部分填充,开发者也不会维护独立的 API 服务,所以其实跨源的需求是比较少的。 <br>新时代前后端的分离和第三方 JSSDK 的兴起,我们才开始发现这个策略虽然大大提高了浏览器的安全性,但有时很不方便,合理的用途也受到影响。比如:</p><ul><li>独立的 API 服务为了方便管理使用了独立的域名;</li><li>前端开发者本地调试需要使用远程的 API;</li><li>第三方开发的 JSSDK 需要嵌入到别人的页面中使用;</li><li>公共平台的开放 API。</li></ul><p>于是乎,如何解决这些问题的跨源方案就被纷纷提出,可谓百家争鸣,其中不乏令人惊叹的骚操作,虽然现在已有标准的 CORS 方案,但对于深入理解浏览器与服务器的交互还是值得学习的。</p><h2>JSON-P(自填充JSON)</h2><p>JSON-P 是各类跨源方案中流行度较高的一个,现在在某些要兼容旧浏览器的环境下还会被使用,著名的 jQuery 也封装其方法。</p><h3>原理</h3><p>请勿见名知义,名字中的 P 是 padding,“填充”的意思,这个方法在通信过程中使用的并不是普通的 json 格式文本,而是“自带填充功能的 JavaScript 脚本”。 <br>如何理解“自带填充功能的 JavaScript 脚本”?看看下面的例子。 <br>假设全局(Window)上有这个 getAnimal 函数,然后通过 script 标签的方式,引入一个调用该函数并传入数据的脚本,就可以实现跨源通信。</p><pre><code class="javascript">// 全局上有这个函数
function getAnimal(data){
// 取得数据
var animal = data.name
// do someting
}</code></pre><p>另一个脚本:</p><pre><code class="javascript">// 调用函数
getAnimal({
name: 'cat'
})</code></pre><p>也就是说利用浏览器引入 JavaScript 脚本时会自动运行的特点,就可以用来给全局函数传递数据。如果把这段调用函数的脚本作为服务端 API 的输出,就可以以此实现跨源通信。这就是 JSON-P 方法的核心原理,它填充的是全局函数的数据。</p><h3>流程</h3><ol><li>在全局定义好回调函数,也就是服务端 API 输出的 js 脚本中要调用的函数;</li><li>新建 script 标签,src 即是 API 地址,将标签插入页面,浏览器便会发起 GET 请求;</li><li>服务器根据请求生成 js 脚本并返回;</li><li>页面等待 script 标签就绪,就会自动调用全局定义的回调函数,取得数据。</li></ol><blockquote>【PS】不只是 script 标签,所有可以使用 src 属性的标签都可以不受同源策略限制发起 GET 请求(CSP 未配置的情况),比如 img、object 等,但能自动运行 js 代码的只有 script 标签。</blockquote><p><img src="/img/bVcShDy" alt="JSONP" title="JSONP"></p><h3>错误处理</h3><ul><li>前端通过 script 标签的 error 事件可以捕获到网络错误,但并不可知具体的错误原因,也就是说无法获取到服务器的响应状态码;</li><li>服务端返回的脚本如果运行错误,前端只能通过全局 error 事件捕获。</li></ul><h3>实践提示</h3><ul><li><p>前端</p><ul><li>为了避免污染全局,不建议前端生成大量随机名字的全局函数,可以用一个对象保存所有回调函数,并给每一个回调函数唯一的 id,全局仅暴露统一的执行器,依靠 id 去调用回调函数;</li><li>如果遵从上一条的建议,全局对象内回调函数需要及时清理;</li><li>每次请求都要生成新的 script 标签,应该在完成后及时清理;</li><li>为了灵活性,还可与服务端约定将回调函数名作为参数传递,保留多个全局对象情况的扩展空间。</li></ul></li><li><p>服务端</p><ul><li>只需接收 GET 方法的请求,其他方法可判定为非法;</li><li>只能在请求的 URL 里获取参数,比如 query 或 path;</li><li>响应报文头 content-type 设为 text/javascript;</li><li>强烈建议关闭 HTTP 协议缓存,以免数据不一致,方法参考笔者有关 <a href="https://segmentfault.com/a/1190000022336086">HTTP的文章</a>;</li><li>返回的脚本以纯文本格式写入响应报文体,由于脚本是直接运行的,应特别注意 XSS 攻击。</li></ul></li></ul><p>前端“一个对象保存所有回调函数”的设计思路:</p><pre><code class="javascript">function initJSONPCallback() {
// 保存回调对象的对象
const cbStore = {}
// 这里形成了一个闭包,只能用特定方法操作 cbStore。
return {
// 统一执行器(函数)。
run: function (statusCode, data) {
const { callbackId, msg } = data
try {
// 运行失败分支。
if (...) {
cbStore[callbackId].reject(new Error(...))
return
}
// 运行成功分支。
cbStore[callbackId].resolve(...)
} finally {
// 执行清理。
delete cbStore[callbackId]
}
},
// 设置回调对象,发起请求时调用。
set: function (callbackId, resolve, reject) {
// 回调对象包含成功和失败两个分支函数。
cbStore[callbackId] = {
resolve,
reject
}
},
// 删除回调对象,清理时调用。
del: function (callbackId) {
delete cbStore[callbackId]
}
}
}
// 初始化
const JSONPCallback = initJSONPCallback()
// 全局暴露执行器,这也是 API 返回脚本调用的函数。
window.JSONPCb = JSONPCallback.run</code></pre><p>具体代码请参考<a href="https://link.segmentfault.com/?enc=xoG5886n39PL09bMPW1TPg%3D%3D.6718TdjskVFYdXjQWwGjFWo%2BSN6RjC%2BawU4IcBzkebW31djJakvWAFnOmRVxcwSujlmoOVgWotvY0QbcfsuUnAdBw9OIpNeZVufW0twn6wk%3D" rel="nofollow">演示案例 JSONP 部分</a>源码。</p><h3>总结</h3><ul><li><p>优点</p><ul><li>简单快速,相比需要 iframe 的方案确实快(演示案例里体验一下就知道);</li><li>支持上古级别的浏览器(IE8-);</li><li>对域无要求,可用于第三方 API。</li></ul></li><li><p>缺点</p><ul><li>只能是 GET 方法,无法自定义请求报文头,无法写入请求报文体;</li><li>请求数据量受 URL 最大长度限制(不同浏览器不一);</li><li>调试困难,服务器错误无法检测到具体原因;</li><li>需要特殊接口支持,不能使用标准的 API 规范。</li></ul></li></ul><h2>SubHostProxy(子域名代理)</h2><p>子域名代理在特定的环境条件下是很实用跨源方案,它能提供与正常 Ajax 请求无差别的体验。</p><h3>原理</h3><p>先搞清楚何为子域(domain)?域名的解析是从右往左的,我们去申请域名,就是申请最靠右的两段(以点为分段),而之后的部分是可以给所有者自定义的,你想多加几段都可以,这些衍生的域就是子域。举个例子,<code>api.demo.com</code> 就是 <code>demo.com</code> 的子域。 <br>理论上例子里的两个算是不同的域,依照上面提到的 domain 是 origin 的一部分,因此也算是不同的源,但浏览器允许将页面 document 的域改为当前域的父级,也就是在 <code>api.demo.com</code> 的页面运行如下代码就可以改为 <code>demo.com</code>,但这种修改只对 document 的权限有影响,对 Ajax 是无影响的。</p><pre><code class="javascript">// 在 api.demo.com 页面写如下代码
document.domain = 'demo.com'</code></pre><blockquote>【PS】<code>document.domain</code> 的特点:只能设置一次;只能更改域部分,不能修改页面的端口号和协议;会重置当前页面的端口为协议默认端口(即 80 或 433);仅对 document 起作用,不影响其他对象的同源策略。</blockquote><p>因此,该方案的原理就是通过这种方法使父级页面拥有子域页面 document 的访问权限,子域恰好又是 API 的域,进而通过子域页面代理发起请求,实现跨源通信。</p><h3>流程</h3><p>假设服务端 API 的域为 <code>api.demo.com</code> ,页面域为 <code>demo.com</code> ,共同运行在 http 协议,端口为 80。</p><ol><li>子域下部署一个代理页,设置其域为 <code>demo.com</code> ,并可以包含发起 Ajax 的工具(jQuery、Axios等);</li><li>主页面也设置域为 <code>demo.com</code>;</li><li>主页面新建 iframe 标签链接到代理页;</li><li>当 iframe 内的代理页就绪时,父页面就可以使用 <code>iframe.contentWindow</code> 取得代理页的控制权,使用其发起 Ajax 请求。</li></ol><p><img src="/img/bVcShDD" alt="SubHostProxy" title="SubHostProxy"></p><h3>错误处理</h3><ul><li>iframe 的 error 事件在大部分浏览器是无效的(默认),因此 iframe 内错误对主页面来说是不可知的;</li><li>通过 iframe 的 load 事件可以检查代理页是否被加载,以此间接判断是否有网络错误,但并不可知具体的错误原因,也就是说无法获取到服务器的响应状态码;</li><li>当主页面获取代理页的控制权后,错误处理与正常 Ajax 无异。</li></ul><h3>实践提示</h3><ul><li><p>前端</p><ul><li>加载代理页是需要耗时的(其实挺慢的),因此要注意发起请求的时机,免在代理页还未加载完的时候请求;</li><li>并不需要每次请求都加载新的代理页,强烈建议只保留一个,多个请求共享;</li><li>如果遵从上一条的建议,还需考虑代理页加载失败的情况,避免一次失败后后续均不可以;</li><li>可以使用预加载的方式提前加载代理页,以免增加请求的时间;</li><li>主页面必须要使用 <code>document.domain</code> 设置,即是当前域已经满足要求,也就是说当前页面虽然已经域是 <code>xxx</code>,但还是得调用一遍 <code>document.domain='xxx'</code> 。</li></ul></li><li><p>服务端</p><ul><li>只能使用标准的 80(http)或 443(https)端口部署(或使用反向代理);</li><li>代理页的域必须与 API 的域是一致的,并且与主页的域面有共同的父级(或主页面的域就是父级);</li><li>理论上代理页只要是执行了 <code>document.domain=xxx</code> 的 HTML 格式文件即可,因此可以尽量精简。</li></ul></li></ul><p>共享 iframe 的设计思路:</p><pre><code class="javascript">// 将创建 iframe 用 promise 封装,并保存起来。
let initSubHostProxyPromise = null
// 每次请求之前都应先调用这个函数。
function initSubHostProxy() {
if (initSubHostProxyPromise != null) {
// 如果 promise 已经存在,则直接返回,由于这个 promise 已经 resolve,其实就相当于返回了已有的 iframe。
return initSubHostProxyPromise
}
// 没有则重新创建。
initSubHostProxyPromise = new Promise((resolve, reject) => {
const iframe = document.createElement('iframe')
// 填入代理页地址。
iframe.src = '...'
iframe.onload = function (event) {
// 这是一种 hack 的检测错误的方法,见演示案例 README 。
if (event.target.contentWindow.length === 0) {
// 失败分支
reject(new Error(...))
setTimeout(() => {
// 清理掉失败的 promise,这样下次就会重新创建。
initSubHostProxyPromise = null
// 这里还需移除 iframe。
document.body.removeChild(iframe)
})
return
}
// 成功分支,返回 iframe DOM 对象。
resolve(iframe)
}
document.body.appendChild(iframe)
})
return initSubHostProxyPromise
}</code></pre><p>具体代码请参考<a href="https://link.segmentfault.com/?enc=c%2BrexPQs3DJIN2nGeUEqJg%3D%3D.pmlFO4np7HYuO6ohPQFEVqbJvZjUvzMoIEwg0glw8V%2FbLepxSGaZenBPEaBNku7FQt%2BrqB8VIUJroj%2FHOG%2FJXcN6NjNnP8SBtXKUkndSOBceWwdOR306AjAHsTV52WF2" rel="nofollow">演示案例 SubHostProxy 部分</a>源码。</p><h3>总结</h3><ul><li><p>优点</p><ul><li>可以发送任意类型的请求;</li><li>可以使用标准的 API 规范;</li><li>能提供与正常 Ajax 请求无差别的体验;</li><li>错误捕获方便准确(除了 iframe 的网络错误);</li><li>支持上古级别的浏览器(IE8-)。</li></ul></li><li><p>缺点</p><ul><li>对域有严格要求,不能用于第三方 API;</li><li>iframe 对浏览器性能影响较大;</li><li>无法使用非协议默认端口。</li></ul></li></ul><h2>HTML-P/MockForm(自填充HTML/模拟表单)</h2><p>网上一般称这种方案是“模拟表单”,但我觉得并不准确,使用表单发起请求并不是它的核心特征(后面也还有几种方案用到),它的核心应该是“自填充HTML”。</p><h3>原理</h3><p>我将它称为 HTML-P 是借鉴了 JSON-P 的叫法,它的思路也与 JSON-P 方案很像,服务端 API 返回一个 js 脚本可以自动运行进行数据填充,那直接返回整个 HTML 页面不也可以。 <br>但实际上 HTML 要实现数据填充还是有限制的,首先就是同源限制,父子页面如果不同源,就无法互相访问,解决办法自然是“子域代理”里提到的 <code>document.domain</code> 修改大法,但它的目的恰好与“子域代理”相反,通过修改 document 的域,使子页面获取主页面的访问权限,以此对主页面的数据填充,实现跨源通信。</p><pre><code class="javascript">// API 返回包含如下脚本的 HTML ,就可访问父级页面的全局函数进行数据填充。
document.domain = 'xxx'
window.parent.callbackFunction(...)</code></pre><p>至于表单的作用,其实是利用了表单的 target 的属性,当表单 submit 的时候它会使指定 name 的 iframe 进行跳转,跳转其实就是发起请求,因此浏览器表单组件原生支持的请求方法都可以使用,也正因为使用了表单发起请求,服务端 API 必须返回一个 HTML 格式的文本。</p><h3>流程</h3><p>假设服务端 API 的域为 <code>api.demo.com</code> ,页面域为 <code>demo.com</code> ,共同运行在 http 协议,端口为 80。</p><ol><li>在全局定义好回调函数,也就是服务端 API 输出的 HTML 中要调用的函数;</li><li>主页面设置域为 <code>demo.com</code>;</li><li>主页面新建 iframe 标签并指定 name ;</li><li>新建 form 标签,指定 target 为刚才的 iframe 的 name,并添加数据、配置请求;</li><li>提交表单,iframe 内跳转;</li><li>服务端接收到请求,依据请求参数生成 HTML 页面并返回,其域设为 <code>demo.com</code> ;</li><li>iframe 完成 HTML 的加载,子页面调用主页面全局定义的回调函数,主页面取得数据。</li></ol><p><img src="/img/bVcShDE" alt="MockForm" title="MockForm"></p><h3>错误处理</h3><ul><li>iframe 的 error 事件在大部分浏览器是无效的(默认),因此 iframe 内错误对主页面来说是不可知的;</li><li>通过 iframe 的 load 事件可以检查代理页是否被加载,以此间接判断是否有网络错误,但并不可知具体的错误原因,也就是说无法获取到服务器的响应状态码;</li><li>子页面调用主页面发生的错误属于 iframe 内错误,因此也是不可知的。</li></ul><h3>实践提示</h3><ul><li><p>前端</p><ul><li>为了避免污染全局,不建议前端生成大量随机名字的全局函数,可以用一个对象保存所有回调函数,这点可以参考上面 JSON-P ;</li><li>主页面必须要使用 <code>document.domain</code> 设置,即是当前域已经满足要求。</li><li>由于 iframe 内的页面每次请求都不同,因此可以复用 iframe 标签,但不可复用页面;</li><li>并发时会同时生成多个 iframe 页面,这将导致性能极度下降,并发场景并不适用该方案;</li><li>form 和 iframe 标签应该在完成后及时清理;</li></ul></li><li><p>服务端</p><ul><li>只能使用标准的 80(http)或 443(https)端口部署(或使用反向代理);</li><li>API 的域与主页的域面有共同的父级(或主页面的域就是父级);</li><li>响应报文头 content-type 设为 text/html;</li><li>强烈建议关闭 HTTP 协议缓存,以免数据不一致,方法参考笔者有关 <a href="https://segmentfault.com/a/1190000022336086">HTTP的文章</a>;</li><li>返回的 HTML 以纯文本格式写入响应报文体,由于其中的脚本是直接运行的,应特别注意 XSS 攻击;</li><li>生成的 HTML 应尽量精简。</li></ul></li></ul><p>具体代码请参考<a href="https://link.segmentfault.com/?enc=9CN4hK0N1KmS9RUujVM0Lg%3D%3D.ggD8NnIRvSeOuF48OXArqZvH0Y3QyLBJToEjkt%2B%2FLCa3HwkrlHO%2FF1e5ShXFI0dyW2FjibsnMrt%2Bq77OPtCVbyKeH40ovkVeET7PfOI%2BICM%3D" rel="nofollow">演示案例 MockForm 部分</a>源码。</p><h3>总结</h3><p>该方案可以说是“JSON-P”与“子域代理”的缝合版,优缺点均有继承。</p><ul><li><p>优点</p><ul><li>可以发送任意类型的请求(以浏览器 form 标签支持为准);</li><li>相比“子域代理”来说,无需代理页算是个优点,</li><li>支持上古级别的浏览器(IE8-)。</li></ul></li><li><p>缺点</p><ul><li>对域有严格要求,不能用于第三方 API;</li><li>iframe 对浏览器性能影响较大,并且并发需要多个 iframe ,基本不能用于需要并发的场景;</li><li>无法使用非协议默认端口。</li><li>错误捕获困难,服务器错误无法检测到具体原因,运行错误也无法捕获;</li><li>需要特殊接口支持,不能使用标准的 API 规范。</li></ul></li></ul><h2>WindowName</h2><p>这是一个以 window.name 特性为核心的方案。</p><h3>原理</h3><p>这方案利用了 window.name 的特性:一旦被赋值后,当窗口(iframe)被重定向到一个新的 url 时不会改变它的值。虽然 window.name 依然遵循同源策略,只有同源才能读取到值,但我们只要在非同源页面写入值,再重定向到同源页面读取值即可实现跨源通信。 <br>发起请求的方法与“HTML-P”相同,都是用 form 触发 iframe 跳转实现。</p><pre><code class="javascript">// 通过 iframe 的 load 事件取得 window.name 的值。
iframe.onload = function (event) {
const res = event.target.contentWindow.name
}</code></pre><h3>流程</h3><ol><li>主页面新建 iframe 标签并指定 name ;</li><li>新建 form 标签,指定 target 为刚才的 iframe 的 name,并添加数据、配置请求;</li><li>提交表单,iframe 内跳转;</li><li>服务端接收到请求,依据请求参数生成 HTML 页面并返回;</li><li>iframe 加载 HTML ,运行其中脚本将数据设置到 window.name ,并重定向;</li><li>iframe 再次加载 HTML ,完成时触发 load 事件;</li><li>主页面监听到 iframe 的 load 事件,获取其 window.name 的值。</li></ol><p><img src="/img/bVcShYO" alt="WindowName" title="WindowName"></p><h3>错误处理</h3><ul><li>iframe 的 error 事件在大部分浏览器是无效的(默认),因此 iframe 内错误对主页面来说是不可知的;</li><li>通过 iframe 的 load 事件可以检查代理页是否被加载(非同源需要 hack 方法),以此间接判断是否有网络错误,但并不可知具体的错误原因,也就是说无法获取到服务器的响应状态码;</li><li>其余错误可正常捕捉即可。</li></ul><h3>实践提示</h3><ul><li><p>前端</p><ul><li>form 和 iframe 相关注意点与“HTML-P”相同;</li><li>重定向到同域的页面理论上无需任何内容,只要有 HTML 格式即可,应尽量精简,而且由于无需改变,可进行长期缓存;</li><li>虽然理论上 iframe 的 load 事件会触发两次(一次非同源页、一次同源页),但实际上只要 load 触发前重定向,非同源页面的 load 事件是不会接收到的;</li><li>重定向应使用 <code>window.location.replace</code> ,这样才不会产生 history ,会影响主页面的后退操作;</li><li>为了灵活性,建议将重定向页面的 url 传递给服务端。</li></ul></li><li><p>服务端</p><ul><li>响应报文头 content-type 设为 text/html;</li><li>强烈建议关闭 HTTP 协议缓存,以免数据不一致,方法参考笔者有关 <a href="https://segmentfault.com/a/1190000022336086">HTTP的文章</a>;</li><li>返回的 HTML 以纯文本格式写入响应报文体;</li><li>生成的 HTML 应尽量精简。</li></ul></li></ul><p>具体代码请参考<a href="https://link.segmentfault.com/?enc=JCrcHQ5TCleE2p08EO0YHg%3D%3D.DbQ4vgIlH71%2BbYHuiPILIVT9iMVdzdz%2BMSzJGs%2FRn5pUtQzJUnV6Tk39soI0gI1U%2F2GE08kcvPYvK90jTcs91AfSBXl2nfr336zD0BirIy0lOrr1Vq5k4KOohF8B5Kuo" rel="nofollow">演示案例 WindowName 部分</a>源码。</p><h3>总结</h3><ul><li><p>优点</p><ul><li>可以发送任意类型的请求(以浏览器 form 标签支持为准);</li><li>对域无要求,可用于第三方 API ;</li><li>支持上古级别的浏览器(IE8-)。</li></ul></li><li><p>缺点</p><ul><li>iframe 对浏览器性能影响较大,两次跳转雪上加霜,并且并发需要多个 iframe ,基本不能用于需要并发的场景;</li><li>近乎是空白的同源重定向页,可以说是无意义的流量,影响流量统计;</li><li>错误捕获困难,服务器错误无法检测到具体原因,运行错误也无法捕获;</li><li>需要特殊接口支持,不能使用标准的 API 规范。</li></ul></li></ul><h2>WindowHash</h2><p>这是一个以 url 上 hash 部分为核心的方案。</p><h3>原理</h3><p>这个方案利用了 <code>window.location.hash</code> 的特性:不同域的页面,可以写不可读。而只改变哈希部分(井号后面)不会导致页面跳转。也就是可以让非同源的子页面写主页面 url 的 hash 部分,主页面通过监听 hash 变化,实现跨源通信。 <br>发起请求的方法与“HTML-P”相同,都是用 form 触发 iframe 跳转实现。</p><pre><code class="javascript">// 现代浏览器有 hashchange 事件可以监听。
window.addEventListener('hashchange', function () {
// 读取 hash
const hash = window.location.hash
// 清理 hash
if (hash && hash !== '#') {
location.replace(url + '#')
} else {
return
}
})</code></pre><pre><code class="javascript">// 降级方案,循环读取 hash 进行“监听”。
var listener = function(){
// 读取 hash
var hash = window.location.hash
// 清理 hash
if (hash && hash !== '#') {
location.replace(url + '#')
}
// 继续监听
setTimeout(listener, 100)
}
listener()</code></pre><h3>流程</h3><ol><li>主页面新建 iframe 标签并指定 name ;</li><li>新建 form 标签,指定 target 为刚才的 iframe 的 name,并添加数据、配置请求;</li><li>提交表单,iframe 内跳转;</li><li>服务端接收到请求,依据请求参数生成 HTML 页面并返回;</li><li>iframe 加载 HTML ,运行其中脚本修改主页面的 hash;</li><li>主页面监听 hash 的变化,每次获取 hash 值后清空 hash。</li></ol><p><img src="/img/bVcSh8U" alt="WindowHash" title="WindowHash"></p><h3>错误处理</h3><ul><li>iframe 的 error 事件在大部分浏览器是无效的(默认),因此 iframe 内错误对主页面来说是不可知的;</li><li>通过 iframe 的 load 事件可以检查代理页是否被加载(非同源需要 hack 方法),以此间接判断是否有网络错误,但并不可知具体的错误原因,也就是说无法获取到服务器的响应状态码;</li><li>其余错误可正常捕捉即可。</li></ul><h3>实践提示</h3><ul><li><p>前端</p><ul><li>form 和 iframe 相关注意点与“HTML-P”相同;</li><li>设置主页面 hash 应该用 <code>window.location.replace</code> ,这样才不会产生 history ,会影响主页面的后退操作;</li><li>每次 hash 设置都需要一定的冷却,并发可能发生错了;</li><li>没必要每次请求都去监听 hashchange 事件,可以在初始化时设置一个统一事件处理器,用一个对象将每次请求的回调保存起来,分配唯一的 id ,通过统一的事件处理器按 id 调用回调;</li><li>如果遵从上一条的建议,全局对象内回调函数需要及时清理;</li><li>由于 iframe 内是非同源页面(服务端生成),不可知主页面 url ,因此需要将 url 通过参数传递给服务端。</li></ul></li><li><p>服务端</p><ul><li>响应报文头 content-type 设为 text/html;</li><li>强烈建议关闭 HTTP 协议缓存,以免数据不一致,方法参考笔者有关 <a href="https://segmentfault.com/a/1190000022336086">HTTP的文章</a>;</li><li>返回的 HTML 以纯文本格式写入响应报文体;</li><li>生成的 HTML 应尽量精简。</li></ul></li></ul><p>前端“统一事件处理器”的设计思路:</p><pre><code class="javascript">function initHashListener() {
// 保存回调对象的对象
const cbStore = {}
// 设置监听,只需一个。
window.addEventListener('hashchange', function () {
// 处理 hash。
...
try {
// 运行失败分支。
if (...) {
cbStore[callbackId].reject(new Error(...))
return
}
// 运行成功分支。
cbStore[callbackId].resolve(...)
} finally {
// 执行清理。
delete cbStore[callbackId]
}
})
// 这里形成了一个闭包,只能用特定方法操作 cbStore。
return {
// 设置回调对象的方法。
set: function (callbackId, resolve, reject) {
// 回调对象包含成功和失败两个分支函数。
cbStore[callbackId] = {
resolve,
reject
}
},
// 删除回调对象的方法。
del: function (callbackId) {
delete cbStore[callbackId]
}
}
}
// 初始化,每次请求都调用其 set 方法设置回调对象。
const hashListener = initHashListener()</code></pre><p>具体代码请参考<a href="https://link.segmentfault.com/?enc=DFa2vF43QLijdWxjN%2FNDgw%3D%3D.h5SDY0v5EdRqo6jgtdEnyowmR5PFJx1Tb58cIy%2F0rYTzQOFGpd5%2By1iDXrtisjBigrHT83Tz%2Bl%2FeoU5OIV6%2BYm1hcgQC9TnZN%2BcWh2mErlIm2eIQJyEelBZi7YWeDL4V" rel="nofollow">演示案例 WindowHash 部分</a>源码。</p><h3>总结</h3><ul><li><p>优点</p><ul><li>可以发送任意类型的请求(以浏览器 form 标签支持为准);</li><li>对域无要求,可用于第三方 API ;</li><li>支持上古级别的浏览器(IE8-)。</li></ul></li><li><p>缺点</p><ul><li>iframe 对浏览器性能影响较大,并且并发需要多个 iframe ,基本不能用于需要并发的场景;</li><li>并发场景很容易出现 hash 操作撞车的问题,这个问题如果采用循环读取 hash 的方法监听则更加严重,除非有更加严密的防撞车机制,否则强烈不建议并发使用;</li><li>请求数据量受 URL 最大长度限制(不同浏览器不一);</li><li>错误捕获困难,服务器错误无法检测到具体原因,运行错误也无法捕获;</li><li>需要特殊接口支持,不能使用标准的 API 规范。</li></ul></li></ul><hr><p>下篇我们将探讨现代标准(HTML5)的跨源,<a href="https://segmentfault.com/a/1190000040086190">今日篇</a></p>
【Pride】深入解析 NodeJS 的微任务运行规则
https://segmentfault.com/a/1190000038829651
2021-01-05T17:29:43+08:00
2021-01-05T17:29:43+08:00
calimanco
https://segmentfault.com/u/calimanco
4
<h2>前言</h2><p>本文讨论的核心基本概念是 Event Loop,即事件循环,是 JavaScript 的执行模型,这是实现异步编程的核心。在不同的平台有不同的实现,浏览器基于 HTML5 的规范各自实现,而 NodeJS 基于 libuv 核心。他们虽然都是实现了异步通知的效果,但运行规则还是有些差别。<br>网络上关于“事件循环”的文章有很多,本文就不再多赘述,请阅读本文 前预备好这些背景知识,重点是 NodeJS 运行的 6 个阶段以及宏任务队列(Macrotask Queue)和微任务队列(Microtask Queue)的基本概念。<br>本人能力有限,不足之处还请批评指教。</p><h2>本文要解决的问题</h2><ol><li>在 NodeJS 中运行完全局同步代码后是先运行微任务,还是先运行宏任务?</li><li>不同版本的 NodeJS 运行微任务的时机有差异吗?</li><li>Next Tick Queue 和 Other Micro Queue 都是 NodeJS 中的微任务队列,他们的运行时机有什么差别?</li><li>微队列是一次清空,还是每一轮循环运行一个任务?</li></ol><h2>举个例子</h2><pre><code class="javascript">console.log("start")
setTimeout(() => {
console.log(1)
Promise.resolve().then(() => {
console.log(2)
})
setTimeout(() => {
console.log(3)
})
setImmediate(() => {
console.log(4)
})
process.nextTick(() => {
console.log(5)
})
})
setTimeout(() => {
console.log(6)
Promise.resolve().then(() => {
console.log(7)
})
setTimeout(() => {
console.log(8)
})
setImmediate(() => {
console.log(9)
})
process.nextTick(() => {
console.log(10)
})
})
process.nextTick(() => {
console.log(11)
})
console.log("end")</code></pre><h3>说明</h3><p>上面的例子基本可以解决上述的疑问,每一个回调函数都只会打印出一个数字,这个数字也相当于该函数的编号;全局的“start”和“end”标明了全局同步代码的运行开始与结束。</p><p>例子中属于宏任务的 API:</p><ul><li>setTimeout()</li><li>setImmediate()</li></ul><p>例子中属于微任务的 API:</p><ul><li>process.nextTick() 会进入 Next Tick Queue</li><li>Promise 会进入 Other Micro Queue</li></ul><h3>不同 NodeJS 版本的运行结果</h3><p>只选取稳定版本进行测试;为了节约篇幅,把原本竖的打印结果打横展示。<br>setTimeout 和 setImmediate 执行顺序在同模块中是不一定,因此“ 4 9 ”和“ 3 8 ”多次运行结果可能会互调,由于这是属于宏任务的内容,这里不展开讨论。</p><h4>v6.17.1</h4><pre><code>start end 11 1 6 5 10 2 7 4 9 3 8</code></pre><h4>v8.17.0</h4><pre><code>start end 11 1 6 5 10 2 7 4 9 3 8</code></pre><h4>v10.23.0</h4><pre><code>start end 11 1 6 5 10 2 7 4 9 3 8</code></pre><h4>v12.20.0</h4><pre><code>start end 11 1 5 2 6 10 7 4 9 3 8</code></pre><h4>v14.15.3</h4><pre><code>start end 11 1 5 2 6 10 7 4 9 3 8</code></pre><h3>结果分析</h3><p>由上面的结果,解答前面提出的问题。</p><ol><li>由于 11 是紧跟在全局代码执行的,因此可以得知,全局的同步代码运行完即会先开始微任务的运行。</li><li>不同版本的 NodeJS 运行是有差异的,以 v10.23.0 版本的结果为分界线,会有很明显的两个结果(经过进一步验证,这个变化从 v11.0.0 就开始)。至于产生这个不同的原因,将在下面详细分析。</li><li>以 v10.23.0 的结果来看,“ 5 10 ”是在“ 2 7 ”之前执行,因此可知 Next Tick Queue 是优先于 Other Micro Queue 的。</li><li>以 v10.23.0 的结果来看,“ 5 10 ”是在“ 2 7 ”是连续出现的,因此可知微队列是一次清空。由同样连续出现的“ 4 9 ”和“ 3 8 ”,也可知宏队列也有一次清空的性质,但他们不同 API 分属的队列不同。</li></ol><h2>分析新旧版本的差异</h2><p>以 v11.0.0 版本为分界线,高于或等于 v11.0.0 称为新版本;低于 v11.0.0 称为旧版本。<br>这里宏任务队列不是讨论重点进行了简化处理。</p><h3>旧版本运行过程</h3><ol><li><p>运行全局同步代码:</p><ol><li>打印“start”;</li><li>1 入宏任务队列;</li><li>6 入宏任务队列;</li><li>11 入微任务队列的 Next Tick Queue;</li><li>打印“end”。</li></ol><blockquote>宏任务队列:[1, 6]<br>微任务队列的 Next Tick Queue:[11]<br>微任务队列的 Other Micro Queue:[]</blockquote></li><li><p>运行微任务队列,输出 11。</p><blockquote>宏任务队列:[1, 6]<br>微任务队列的 Next Tick Queue:[]<br>微任务队列的 Other Micro Queue:[]</blockquote></li><li><p>从宏任务队列取出 1 运行:</p><ol><li>打印“1”;</li><li>2 入微任务队列的 Other Micro Queue;</li><li>3 入宏任务队列;</li><li>4 入宏任务队列;</li><li>5 入微任务队列的 Next Tick Queue。</li></ol><blockquote>宏任务队列:[6, 3, 4]<br>微任务队列的 Next Tick Queue:[5]<br>微任务队列的 Other Micro Queue:[2]</blockquote></li><li><p>从宏任务队列取出 6 运行:</p><ol><li>打印“6”;</li><li>7 入微任务队列的 Other Micro Queue;</li><li>8 入宏任务队列;</li><li>9 入宏任务队列;</li><li>10 入微任务队列的 Next Tick Queue。</li></ol><blockquote>宏任务队列:[3, 4, 8, 9]<br>微任务队列的 Next Tick Queue:[5, 10]<br>微任务队列的 Other Micro Queue:[2, 7]</blockquote></li><li><p>运行微任务队列:</p><ol><li>清空 Next Tick Queue,打印“5”,“10”;</li><li>清空 Other Micro Queue,打印“2”,“7”。</li></ol><blockquote>宏任务队列:[3, 4, 8, 9]<br>微任务队列的 Next Tick Queue:[]<br>微任务队列的 Other Micro Queue:[]</blockquote></li><li>后续运行宏队列,在这里省略。</li></ol><h3>新版本运行过程</h3><ol><li><p>运行全局同步代码:</p><ol><li>打印“start”;</li><li>1 入宏任务队列;</li><li>6 入宏任务队列;</li><li>11 入微任务队列的 Next Tick Queue;</li><li>打印“end”。</li></ol><blockquote>宏任务队列:[1, 6]<br>微任务队列的 Next Tick Queue:[11]<br>微任务队列的 Other Micro Queue:[]</blockquote></li><li><p>运行微任务队列,输出 11。</p><blockquote>宏任务队列:[1, 6]<br>微任务队列的 Next Tick Queue:[]<br>微任务队列的 Other Micro Queue:[]</blockquote></li><li><p>从宏任务队列取出 1 运行:</p><ol><li>打印“1”;</li><li>2 入微任务队列的 Other Micro Queue;</li><li>3 入宏任务队列;</li><li>4 入宏任务队列;</li><li>5 入微任务队列的 Next Tick Queue。</li></ol><blockquote>宏任务队列:[6, 3, 4]<br>微任务队列的 Next Tick Queue:[5]<br>微任务队列的 Other Micro Queue:[2]</blockquote></li><li><p>运行微队列:</p><ol><li>清空 Next Tick Queue,打印“5”;</li><li>清空 Other Micro Queue,打印“2”。</li></ol><blockquote>宏任务队列:[6, 3, 4]<br>微任务队列的 Next Tick Queue:[]<br>微任务队列的 Other Micro Queue:[]</blockquote></li><li><p>从宏任务队列取出 6 运行:</p><ol><li>打印“6”;</li><li>7 入微任务队列的 Other Micro Queue;</li><li>8 入宏任务队列;</li><li>9 入宏任务队列;</li><li>10 入微任务队列的 Next Tick Queue。</li></ol><blockquote>宏任务队列:[3, 4, 8, 9]<br>微任务队列的 Next Tick Queue:[10]<br>微任务队列的 Other Micro Queue:[7]</blockquote></li><li><p>运行微队列:</p><ol><li>清空 Next Tick Queue,打印“10”;</li><li>清空 Other Micro Queue,打印“7”。</li></ol><blockquote>宏任务队列:[3, 4, 8, 9]<br>微任务队列的 Next Tick Queue:[]<br>微任务队列的 Other Micro Queue:[]</blockquote></li><li>后续运行宏队列,在这里省略。</li></ol><h3>差异总结</h3><p>差异出现在第四步,旧版本是从宏队列取出任务执行,而新版本是处理微任务。<br>于是我们可以得到结论:旧版本会清空宏任务队列,再运行微任务;而新版本是每运行完一个宏任务,就去清空微任务队列。</p><h2>额外补充</h2><p>网上有流传微任务队列有深度限制的传说,好像说限制是 1000 ,在此验证一下。使用下面这个例子(一次性塞入 10000 个微任务):</p><pre><code class="javascript">console.log('start')
let a = 0
while (a < 10000) {
process.nextTick(() => {
console.log(111)
})
a += 1
}
setTimeout(()=>{
console.log(123)
})
console.log('end')</code></pre><p>在不同的版本(v6到v12)都能顺利运行,并得到相同结果:</p><pre><code>start
end
111 x 10000
123</code></pre><p>结论:微队列并不存在深度限制,但过多的微任务会导致系统一直在运行微任务而无法去运行其他任务,比如例子里处于宏任务的 123 将在 10000 个微任务运行完再运行,体验上有很明显的延迟,因此为了性能考量,不应泛滥地使用微任务。</p>
【Count】自动化为你的项目添加证明可靠性的 badge
https://segmentfault.com/a/1190000038761388
2020-12-31T15:06:58+08:00
2020-12-31T15:06:58+08:00
calimanco
https://segmentfault.com/u/calimanco
3
<h2>前言</h2><p>开源社区里,开源项目一般会将一排花花绿绿的 badge(徽章)摆在 README 最显眼的位置,它们一般可以起到一些说明和证明的作用。<br>比如下面的这个项目(<a href="https://link.segmentfault.com/?enc=d82A6jwDgS%2FsTj6OtFnnSg%3D%3D.QRyLkBwz%2BHBftUFsRUPmxYPkrRcdmn7TT4FIxC2uJqcc8O45UxlcN28GLKrTWvrgpkzCrrtGMts46UZZFeKz3A%3D%3D" rel="nofollow">传送门</a>):</p><ul><li>第一个 badge 证明其能正常构建,点击跳转至构建过程报告;</li><li>第二个 badge 证明其测试覆盖率达到100%,点击跳转至单元测试报告;</li><li>第三个 badge 说明其是 MIT 授权协议;</li><li>第四个 badge 说明其最小化后包的大小;</li></ul><p><img src="/img/bVcMEqv" alt="image" title="image"></p><p>别以为他们只是图片,正规的项目是随项目更新而更新的,点击不了或者点击进入的不是对应项目的报告都可以算作伪造,请远离这些项目。<br>上面提到的 badge 中前两个可以算是项目可靠性的证明,是比较有份量的 badge,接下来我们将指引大家如何自动化添加这两 badge。</p><p>注意:本文以 Github 为代码仓库,第三方帐号授权都以 Github 帐号进行,请确保自身网络环境能正常访问;不涉及项目本身的构建和测试。</p><h2>准备</h2><ul><li>编写一个项目,在 package.json 中以“build”为构建命令,dist 为打包后输出的目录;</li><li>编写好该项目的单元测试,在 package.json 中以“test:prod”为测试命令,并且会自动在 coverage 目录下生成覆盖率报告;</li><li><p>为项目安装 devDependencies(开发依赖):</p><ul><li>coveralls</li></ul></li><li>注册一个 Github 帐号,提交项目到一个仓库。</li></ul><h2>Travis 配置</h2><p>Travis CI 提供的是持续集成服务(Continuous Integration,简称 CI)。它绑定 Github 上面的项目,只要有新的代码,就会自动抓取。然后,提供一个运行环境(容器),执行测试,完成构建,还能自动部署到服务器。<br>本次的自动化就是依靠这个服务完成的,这里只展示相关的配置,更多的用法请自行查看文档。</p><h3>新建配置文件</h3><p>在项目根目录下新建 .travis.yml 文件,输入下面配置并保存。</p><pre><code class="yml">language: node_js
cache: npm
notifications:
email: false
node_js:
- '10'
script:
- npm run test:prod && npm run build
after_success:
- npm run report-coverage
branches:
except:
- /^v\d+\.\d+\.\d+$/</code></pre><p>配置说明:</p><ul><li>language:Travis 可以支持多种语言,这里是 node 项目,填“node_js”即可;</li><li>cache:缓存,可加快构建。配置为 npm 会缓存 $HOME/.npm 和 node_modules 目录;</li><li>notifications:默认情况下会邮件通知提交者和作者,如果不需要则设置为 false,还支持设置钩子、接口通知等方式,详细见<a href="https://link.segmentfault.com/?enc=atgsnwcMa0IZf23WnyERyg%3D%3D.u8L5guFVmk75rO%2BhP2AFquk1IfFfpp0YDzdYs1PEwqb%2BsoiBNEEfYhoo%2BaAOj%2BUtTYD8EQgQqpQCAMhLseObYhfY8XLRw7ULbeEtJuqnqi0%3D" rel="nofollow">文档</a>;</li><li>node_js:运行容器安装的 node 版本,这里是指构建和测试的环境,与实际运行环境是不同的,一般与本机相同即可,设置多个的话每次每一个都会运行一次,会增加构建时间;</li><li>script:要运行的命令,这里我们进行的就是单元测试和构建两步操作;</li><li>after_success:script 运行结束,且无错误的情况下运行的命令,这里我们进行单测覆盖率报告提交;</li><li>branches:需要运行的 git 分支,默认是只运行主分支,这里我们增加了对“vXX.XX.XX”分支的支持。</li></ul><h3>开启 Travis 监听</h3><ol><li>进入 Travis 官网:<a href="https://link.segmentfault.com/?enc=w9xNlQp%2BkAU6Jmz6H3nwIA%3D%3D.PXm8W4KcWq4eCgsHam%2B8WnpouFOYkExDx5dvzbKvrb0%3D" rel="nofollow">https://travis-ci.com/</a>;</li><li>用 Github 帐号授权登录;</li><li>点击左上角的加号,或者点击指引里的按钮,进入对 Github 仓库进行授权;<br><img src="/img/bVcMKpZ" alt="image" title="image"></li><li>可以选择授权当前 Github 帐号的部分仓库,也可以选择全部。<br><img src="/img/bVcMKq1" alt="image" title="image"></li></ol><h3>获取构建 badge 代码</h3><p>待仓库导入后,进入项目主页,右上角就能看到 badge,点击它会弹出一个选择代码类型的弹框,选择需要的类型复制粘贴到 README 就行了(一般选择 Markdown)。 </p><p><img src="/img/bVcMKsM" alt="image" title="image"></p><h2>Coveralls 配置</h2><p>Coveralls 是一个展示单元测试覆盖率报告的网站,它本身不会运行单测或生成报告,它只是提供用于提交标准单元测试覆盖率报告的包(也就是上面准备阶段安装的coveralls),可以配合测试套件使用。</p><h3>编写提交命令</h3><p>以 Jest 为例,默认运行<code>jest --coverage</code>后会在 coverage 目录下生成标准的单测报告。但我们不需要在本地跑,Travis 会帮我们完成,只需要确保可以目录正确即可。<br>上面 .travis.yml 中我们使用了“report-coverage”命令,这个是自定义的 scripts,在 package.json 里的 scripts 块中写入该命令,</p><pre><code class="json">"scripts": {
"report-coverage": "cat ./coverage/lcov.info | coveralls",
}</code></pre><h3>开启 Coveralls 监听</h3><ol><li>进入 Coveralls 官网:<a href="https://link.segmentfault.com/?enc=AScUIvMav8MRe0RGquY0lw%3D%3D.1QjjXWCJZ38SBMmhQCH6h4yFJSvvqr%2FYwQc2MYal5Dc%3D" rel="nofollow">https://coveralls.io/</a>;</li><li>用 Github 帐号授权登录;</li><li>点击右侧加号(ADD REPO);</li><li>把需要的项目的开关打开;<br><img src="/img/bVcMKUH" alt="image" title="image"></li></ol><h3>获取覆盖率 badge 代码</h3><p>点击 DETAILS 进入详情页,这时候你还未有报告,所以看到的是指引页,我们可以先在底部找到获取 badge 代码的入口,选择需要的类型复制粘贴到 README 就行了(一般选择 Markdown)。</p><p><img src="/img/bVcMKU6" alt="image" title="image"></p><h2>完成,启动 Travis</h2><p>将 .travis.yml、package.json 和 README 提交,Travis 监听到提交就会启动运行。偶尔第一次未成功 ,可以点击 Travis 的项目详情页右侧,点击“Trigger build”手动开启。</p><p><img src="/img/bVcMK1l" alt="image" title="image"></p><h2>额外配置(可选)</h2><p>因为 Coveralls 本身就已经能与 Travis 无缝配合,默认情况下它们会识别相同的仓库,并更新。<br>但如果你使用的是 Travis Pro(Travis 的付费版,一般免费的已经够用)和其他 CI 系统,或者需要非 git 主分支的结果时,需要进行写入环境变量告知系统。<br>Coveralls 提供三个必填项:</p><ul><li>COVERALLS_SERVICE_NAME:CI 系统名,比如 travis-pro;</li><li>COVERALLS_REPO_TOKEN:Coveralls 给每个项目的唯一标识,也是提交单测覆盖率报告的依据;</li><li>COVERALLS_GIT_BRANCH:提交报告是哪个 git 分支。</li></ul><h3>全局的环境变量</h3><p>如果是全局的变量,可以直接写到 .travis.yml 文件的 env 块。比如:</p><pre><code class="yml">env:
- DB=postgres
- SH=bash
- PACKAGE_VERSION="1.0.*"</code></pre><h3>局部的环境变量</h3><p>如果是每个命令独立使用的变量,可以直接写到 .travis.yml 文件的 script 块里的命令里。</p><pre><code class="yml">script:
- COVERALLS_GIT_BRANCH=test npm run test:prod && npm run build</code></pre><p>当然同理写到 package.json 文件的 script 也是可以的。</p><h3>一些不能公开的变量</h3><p>无论写到 .travis.yml 或 package.json 文件都需要提交的 git,这些内容都会公开(公共仓库),但类似 COVERALLS_REPO_TOKEN 这类数据是不能公开的。<br>因此我们可以将他们写到 Travis 上项目的设置里(这不是加密,如果要更加严格的加密,可以使用加密文件,详情看<a href="https://link.segmentfault.com/?enc=QZD4SM2Gpae3ZNNFKLK84g%3D%3D.VXv39m5cP4YooiNSfQuPdIJwhkJ1C2vIUqN8rTJjjACYz7v61u1BZ2XP%2B%2BTPvyQEWvFoV8kQ6gHRdCZkmpcw0g%3D%3D" rel="nofollow">Travis 文档</a>)。</p><ol><li>进入项目对应的 Travis 主页;</li><li>点击右上角的“More options”里“Settings”;</li><li>在“Environment Variables”块进行“ADD”操作。</li></ol><p><img src="/img/bVcMNGf" alt="image" title="image"></p><h2>结束</h2><p>教程到此已经完成,整体流程就是:通过 Travis 配置监听 Github 上对应仓库的提交,Travis 发现提交就拉取代码,在 Travis 提供的容器里完成构建和单元测试,完成后再自动提交单测覆盖率报告到 Coveralls,最终结果反映到 README 的 badge 上。<br>一劳永逸,还不赶紧试试。</p>
【Cause】你所应该知道的HTTP——优化篇
https://segmentfault.com/a/1190000022384920
2020-04-15T19:28:00+08:00
2020-04-15T19:28:00+08:00
calimanco
https://segmentfault.com/u/calimanco
2
<h2>握手延迟</h2><p>握手延迟是指开始 HTTP 通信之前所花费的时间,这里的影响因素是很多的。<br>握手延迟主要来自三方面:DNS 查询、TCP 三次握手和 TLS 四次握手。<br>DNS 查询也叫域名解析,是域名转换到IP地址的过程,现在基本上都是使用域名进行 URI 的表示,因此 DNS 查询是必须的步骤。<br>HTTP 是基于 TCP 的协议,因此 TCP 的三次握手也是不可避免的步骤。<br>TLS 四次握手的优化已经在 HTTPS 篇讲过,故此处不再重复。<br>我们优化的目标就是要尽量降低握手延迟,可用方案如下:</p><h3>DNS 查询优化</h3><ul><li>限制不同域名的数量:<br>每一个域名,就意味着可能需要一次 DNS 查询,减少域名数量自然有助于减少查询次数。</li><li>使用最近的DNS服务器:<br>将物理距离的影响尽量降低,可以优化 DNS 服务的建设部署或购买靠谱的第三方服务。</li><li>在主体页面 HTML 中使用 DNS 预取指令:<br>DNS-Prefetching 是让具有此属性的域名不需要用户点击链接就在后台解析,待用户真的点击链接,就可以少去 DNS 查询的步骤,从而提高用户体验。</li></ul><p>形如:</p><pre><code><link rel="dns-prefetch" href="//ajax.googleapis.com"></code></pre><h3>优化 TCP 连接</h3><ul><li>借助 CDN 网络尽早响应:<br>将物理距离的影响尽量降低,可以自行建设部署 CDN 网络或购买靠谱的第三方服务。</li><li>使用 connection:keep-alive:<br>这样能一定程度复用 TCP,减少 TCP 连接的建立。</li><li>在主体页面 HTML 中使用 preconnect 指令:<br>向浏览器提供提示,建议浏览器提前打开与链接网站的连接,以便在跟随链接时可以更快地获取链接内容。但不可滥用,同样会占用浏览器的连接数。</li></ul><p>形如:</p><pre><code><link rel="preconnect" href="//font.example.com" crossorigin></code></pre><h3>避免重定向</h3><p>重定向是需要在建立完 TCP 连接后,服务器才以 301 或 302 的状态码告知客户端,这时候通常需要重新建立 TCP 连接。<br>因此最好的解决方案当然是彻底不要重定向。当然,非要重定向也可以考虑使用下面两个变通办法。</p><ul><li>利用 CDN 代替客户端在云端实现重定向;</li><li>如果是同一个域名的重定向,使用 web 服务器的 rewrite 规则,避免浏览器跳转。</li></ul><h3>减少请求需要</h3><p>这是最直接的解决办法,不需要那么多的请求需求,自然就没有延迟。主要思路不外乎合并外联资源,但这要适度,因为如果合并的文件过大,反而会降低了加载速度。</p><ul><li>CSS Sprites(精灵图):<br>即将多张图片合并成一张,原本需要多个请求获取的图片,现在只需要一次请求就能获取到。</li><li>内联图片:<br>使用 data:URL 模式,就是把图片编码为字串直接嵌入网页。</li><li>合并或内联脚本和样式表:<br>减少外联的 js 和 css 文件自然会减少请求,但内联有助于缓存,这需要平衡考虑。</li><li>缓存:<br>参见缓存篇提到的 Expires 或 Cache-Control。</li></ul><h2>队头阻塞</h2><p>进阶篇中讲到 H1 的连接管理模型并未提供机制来同时请求多个资源。也就是说它需要发起请求、等待响应,之后才能发起下一个请求。资源将排队等待一问一答的加载,如果中间出现任何状况,都会导致剩下的工作被阻塞<br>我们优化的目标就是要尽量提高并发,减少队头阻塞的影响,可用方案如下:</p><h3>域名拆分</h3><p>现代浏览器为了解决这个问题会对单个域名开启 6 个左右的连接,通过各个连接分别发送请求。它实现了某种程度上的并行,但每个连接仍会受到“队头阻塞”的影响。<br>我们可以利用这一机制,将资源分布在多个域名下,这样一个域名6个请求,两个域名就能有 12 个请求。但这里也涉及到增加了 dns 查询、TCP 连接增加的问题,故需要达到一个最佳平衡,不可盲目。<br>Yahoo 研究表明,一个网站使用 2 个主机名进行资源加载可达到最优。</p><h2>低效的TCP利用</h2><p>TCP 的设计思路:对假设情况很保守,并能够公平对待同一网络的不同流量的应用。它的成功并不是因为传输速度快,而是因为它是最可靠的协议之一。<br>TCP 的通过慢启动,探索当前连接拥塞窗口的合适大小。即先发送少量数据包,如果接收到响应且无丢包,就在下一次发送多一倍的数据包,直到发包上限。也就是说说 TCP 的传输速度是逐步加快的,并不能一下子满速的。</p><blockquote>拥塞窗口(congestion window)<br>拥塞窗口是指,在接收方确认数据包之前,发送方可以发出的 TCP 包的数量。<br>例如:如果拥塞窗口为 1,则发送方发出 1 个数据包之后,只有接收方确认了那个包,才能发送下一个。</blockquote><p><img src="/img/bVbF5y4" alt="拥塞控制.png" title="拥塞控制.png"></p><p>而页面文件数据量本来就不大,建立 TCP 连接往往还没到最佳速度就结束了,即使多条连接并发也不能保证它们性能最优。<br>我们优化的目标就是要尽量复用 TCP 连接,可用方案如下:</p><h3>使用长连接</h3><p>使用 connection:keep-alive 是 H1 仅有的提高 TCP 使用率的办法。</p><h2>臃肿的消息首部</h2><p>H1 虽然提供了压缩请求内容(body)的机制,但是消息首部却无法压缩。特别是其中的 cookie 有时很大,这样就自然增加了每次重复的数据量传输,而且自定义头部的增加,这种情况越来越严重。<br>我们优化的目标就是要尽量减少消息首部,可用方案如下:</p><h3>减少cookie</h3><p>cookie 虽然保存在本地,但每次请求都会被发送到服务器,需要尽量减小cookie 大小。需要较大的信息存储时,可以考虑使用其他客户端的缓存,比如:WebStorage、WebDatabases 等。</p><h3>分离资源域名与ajax域名</h3><p>资源传输一般都不需要 cookie,故可以在这类域名上设置 cookie 禁用。</p><h2>受限的优先级设置</h2><p>H1 基本没有关于优先级的设计,单纯由浏览器决定,浏览器的有些解析过程还会阻塞资源的请求。<br>我们优化的目标就是使用浏览器的特性手动安排优先级,可用方案如下:</p><h3>合理安排资源加载</h3><ul><li>JS 放 HTML 文档末尾可以防止阻塞其他资源加载;</li><li>如果 JS 执行顺序无关紧要,并且必须在 onload 事件触发之前执行,可以设置 asyn 属性;</li><li>如果 JS 执行顺序很重要,并且允许脚本在 dom 加载完后执行,可以设置 defer 属性;</li><li>对不会影响页面初次渲染的 JS 脚本,可以在 onload 事件之后再通过动态新建标签请求加载。</li></ul><h2>升级到HTTP/2.0</h2><p>升级到 H2 可以解决大部分上面提到的有关 H1 的性能问题,上面提到的“队头阻塞”和“低效的 TCP 利用”会被 H2 的多路复用解决,“臃肿的消息首部”会被首部表和首部压缩解决,“受限的优先级设置”会被请求优先级解决。<br>那前面提到的一些优化方案是否还需要保留呢?答案是否定的,一些优化方案不单没有效果反而会成为反模式,这里需要注意:</p><h3>反模式:生成精灵图和资源合并/内联</h3><p>单个文件都是可以被缓存的,合并/内联实际上会失去缓存的特性。在 H1 的是时代牺牲缓存减少请求数是划算的,但 H2 时代所有资源都可并发,并且只有一个连接,所以缓存的优势会更大。</p><h3>反模式:域名拆分</h3><p>为了增加并发请求数,H1 时代会将资源分散到多个域名下,但 H2 时代只有一个连接,并且都可并行请求,所以多个域名只会增加 DNS 解析的代价和建立连接的耗时。</p><h3>反模式:禁用 cookie 的域名</h3><p>H1 时代一些不需要 cookie 的资源可以放在禁用 cookie 的域名下减少请求大小,但 H2 时代的头部是压缩处理的,所以将资源的域名都与主页面一致反而可以减少 DNS 解析。</p>
【Cause】你所应该知道的HTTP——拓展篇
https://segmentfault.com/a/1190000022374136
2020-04-15T02:00:13+08:00
2020-04-15T02:00:13+08:00
calimanco
https://segmentfault.com/u/calimanco
4
<h2>SPDY</h2><p>谷歌于 2012 年提出 SPDY(取自 SPeeDY,发音同 speedy) ,其开发目标旨在解决 HTTP 的性能瓶颈,缩短 Web 页面的加载时间 ,属于增强 HTTP 协议。SPDY 一直处于草案阶段,现在已经被 HTTP/2.0 取代,Chrome 51 已经移除对 SPDY 的支持。</p><h3>结构设计</h3><p>SPDY 官方定义为会话层协议(OSI 模型),在 TCP/IP 模型中可以归类为应用层,介于 TLS/SSL 与 HTTP 之间。类似于前面讲过的 HTTPS 设计(传送门),不会改变现有的 HTTP 的实现,对于实际应用几乎透明。</p><p>示意图:<br><img src="/img/bVcK3X0" alt="SPDY" title="SPDY"></p><blockquote>OSI 将计算机网络体系结构划分为七层:物理层、数据链路层、网络层、传输层、会话层、表示层、应用层。<br>其中会话层的作用:允许用户使用简单易记的名称建立连接 相当于公司中收寄信、写信封与拆信封的秘书。</blockquote><h3>SPDY 为 HTTP 提供的改进</h3><h4>多路复用</h4><p>通过单一的 TCP 连接,可以无限制处理多个 HTTP 请求。所有请求的处理都在一条 TCP 连接上完成,因此 TCP 的处理效率得到提高。</p><h4>赋予请求优先级</h4><p>SPDY 不仅可以无限制地并发处理请求,还可以给请求逐个分配优先级顺序。这样主要是为了在发送多个请求时,解决因带宽低而导致响应变慢的问题。</p><h4>压缩 HTTP 报文头</h4><p>解决了 HTTP 只能压缩报文体,不能压缩报文头的问题。通信产生的数据包数量和发送的字节数就更少了。</p><h4>推送功能</h4><p>支持服务器主动向客户端推送数据的功能。服务器可直接发送数据,而不必等待客户端的请求,打破了 HTTP 协议的客户/服务器模式。</p><h4>服务器提示功能</h4><p>服务器可以主动提示客户端请求所需的资源。由于在客户端发现资源之前就可以获知资源的存在,因此在资源已缓存等情况下,可以避免发送不必要的请求。</p><h4>强制使用 HTTPS</h4><p>为了更加安全,SPDY 强制要求建立 STL/SSL 连接。</p><h2>WebSocket 协议</h2><p>WebSocket,即 Web 浏览器与 Web 服务器之间全双工通信标准。其中,WebSocket 协议由 IETF 定为标准,WebSocket API 由 W3C 定为标准。作为基于 HTTP 的补充协议,可以实现双工通信,完全打破 HTTP 客户/服务器模式,如果把 Ajax 比做发电报,那 WebSocket 就好比打电话。</p><h3>结构设计</h3><p>WebSocket 是基于 HTTP 的渐进式升级,它依赖 HTTP 完成“握手”,如果一方不支持该协议,则可以由开发者选择降级方案。由于是建立在 HTTP 基础上的协议,因此连接的发起方仍是客户端,而一旦确立 WebSocket 通信连接,不论服务器还是客户端,任意一方都可直接向对方发送报文。</p><h3>WebSocket的“握手”</h3><h4>客户端请求</h4><p>请求报文头里新增:</p><pre><code>Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: <随机加密串>
Sec-WebSocket-Protocol: <自定义服务名>
Sec-WebSocket-Version: <协议版本号></code></pre><h4>服务器应答</h4><p>状态码:101 Switching Protocols<br>响应报文头里新增:</p><pre><code>Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: <加密key>
Sec-WebSocket-Protocol: <自定义服务名></code></pre><p>示意图:<br><img src="/img/bVcK31s" alt="WebSocket 握手" title="WebSocket 握手"></p><h3>WebSocket 为 HTTP 提供的改进</h3><h4>推送功能</h4><p>这是全双工通信带来的结果之一,支持由服务器向客户端推送数据的推送功能,也就是说服务器可以在客户端未请求的情况下,主动发送数据给客户端。这种需求在即时通讯等场景很常见。</p><h4>减少通信量</h4><p>理论上 WebSocket 就是为了长连接设计的,一旦建立连接就不会断开,也就是说不需要频繁“握手”。和 HTTP 相比,不但每次连接时的总开销减少,而且由于 WebSocket 的首部信息很小,通信量也相应减少了。</p><h3>降级方案</h3><p>Ajax 轮询和Ajax 长轮询(Long Poll),详见进阶篇的连接管理模型(<a href="https://segmentfault.com/a/1190000022322405">传送门</a>)。</p><h3>实践示例</h3><p>nodejs 可以使用 nodejs-websocket 包实现 WebSocket 服务。现代的浏览器均可以支持 WebSocket 的 API。<br>下面代码可以在本地演示了客户端报单数,服务器报双数的循环交互。</p><pre><code>// 服务器,会监听8001端口。
const ws = require('nodejs-websocket')
const port = 8001
const server = ws.createServer(function (conn) {
conn.on('text', function (str) {
// 接收客户端的消息
console.log('client:' + str)
if (str === 'what`s your name?') {
// 应答问候语
conn.sendText('My name is webSocket server!')
} else {
// 累加数字
conn.sendText(JSON.stringify(Number(str) + 1))
}
})
conn.on('close', function (code, reason) {
console.log('webSocket close')
})
conn.on('error', function (code, reason) {
console.log('webSocket error')
})
})
.listen(port)
console.log(`webSocket listen on ${port} successfully`)</code></pre><pre><code>// 客户端,将这代码引入一个html文件,用浏览器打开html即可。
if (!window.WebSocket) {
console.error('browser does not support WebSocket')
return
}
const ws = new WebSocket('ws://localhost:8001')
let count = 0
ws.onopen = function (e) {
console.log('connection success')
// 问候语
ws.send('what`s your name?')
ws.send(count)
}
ws.onclose = function (e) {
console.warn('connection close')
}
ws.onerror = function () {
console.error('connection error')
}
// 接收服务器的消息
ws.onmessage = function (e) {
let message = 'server:' + e.data + ''
console.log(message)
if (e.data !== 'My name is webSocket server!') {
// 累加数字
setTimeout(() => {
ws.send(Number(e.data) + 1)
}, 1000)
}
}</code></pre><h2>WebDAV</h2><p>WebDAV(Web-based Distributed Authoring and Versioning),即基于万维网的分布式创作和版本控制。是一个可对 Web 服务器上的内容直接进行文件复制、编辑等操作的分布式文件系统。它作为扩展 HTTP/1.1 的协议定义在 RFC4918。但因为对实时性和安全性的问题,实际上很少用,虽然类似于当今的流行的网盘,但他们本质是不同的。</p><h3>结构设计</h3><p>WebDAV 的实现是对 HTTP/1.1 的扩展,在原有的请求方法和状态码的基础上,针对服务器上的资源,新增加了一些概念。</p><ul><li>集合(Collection):是一种统一管理多个资源的概念。以集合为单位可进行各种操作。也可实现类似集合的集合这样的叠加。</li><li>资源(Resource):把文件或集合称为资源。</li><li>属性(Property):定义资源的属性。定义以“名称 = 值”的格式执行。</li><li>锁(Lock):把文件设置成无法编辑状态。多人同时编辑时,可防止在同一时间进行内容写入。</li></ul><h3>追加的请求方法</h3><table><thead><tr><th>方法</th><th>用途</th></tr></thead><tbody><tr><td>PROPFIND</td><td>获取属性</td></tr><tr><td>PROPPATCH</td><td>修改属性</td></tr><tr><td>MKCOL</td><td>创建集合</td></tr><tr><td>COPY</td><td>复制资源及属性</td></tr><tr><td>MOVE</td><td>移动资源</td></tr><tr><td>LOCK</td><td>资源加锁</td></tr><tr><td>UNLOCK</td><td>资源解锁</td></tr></tbody></table><h3>新增状态码</h3><table><thead><tr><th>状态码</th><th>含义</th></tr></thead><tbody><tr><td>102 Processing</td><td>可正常处理请求,但目前是处理中状态</td></tr><tr><td>207 Multi-Status</td><td>存在多种状态</td></tr><tr><td>422 UnprocessibleEntity</td><td>格式正常,内容有误</td></tr><tr><td>423 Locked</td><td>资源已被加锁</td></tr><tr><td>424 FailedDependency</td><td>处理与某请求关联的请求失败,因此不再维持依赖关系</td></tr><tr><td>507 InsufficientStorage</td><td>保存空间不足</td></tr></tbody></table><h2>HTTP/2.0</h2><h3>概述</h3><p>HTTP/2.0 是 HTTP 协议自 1999 年 HTTP/1.1 发布后的首个更新,主要基于 SPDY 协议。<br>HTTP/2.0 基本上都能解决上面提到的有关 HTTP/1.0 遇到的瓶颈。</p><h3>分层结构</h3><p>HTTP/2.0 在原有的 HTTP 层前加入了分帧层,类似与 SPDY 协议的结构,分帧层介于 STL/SSL 与 HTTP 之间。</p><ul><li>分帧层:HTTP/2.0 特性的核心部分;</li><li>数据/HTTP层:其中包含传统上被认为是 HTTP 及其关联数据的部分。</li></ul><p>结构如图:(图片来自网络)<br><img src="/img/bVbF2GX" alt="分层结构.png" title="分层结构.png"></p><h3>特点:</h3><ul><li>二进制分帧<br>HTTP/2.0 的分帧层是基于帧的二进制协议,最小消息单位是帧。这方便了机器解析,解析更加高效。每个数据流都以消息的形式发送,而消息由一个或多个帧组成。多个帧之间可以乱序发送,根据帧首部的流标识可以重新组装。</li><li>首部压缩<br>H2 在客户端和服务端使用“首部表”来跟踪和存储之前发送的头部键值对,每次只需要差量更新,不需要重复交换;<br>首部表在 H2 的连接存续期内始终存在,由客户端和服务器共同渐进地更新;<br>头部内容也会被压缩处理。</li><li>多路复用<br>基于二进制分帧的特性,所有请求通过一个 TCP 连接并发完成,不在依赖多开 TCP 连接去实现并行加载。</li><li>并行双向字节流的请求和响应<br>基于二进制分帧的特性,同一个 TCP 连接上同时有多个不同方向的数据流,因此客户端可以一边乱序发送,一边接收响应,服务器也如此。</li><li>加密传输<br>虽然协议并未严格限制,但现代浏览器都要求必须使用 HTTPS,已成为了事实上的强制标准。</li><li>服务器推送<br>服务器可以主动将 HTML 页面相关的 js 和 css 文件推送给浏览器,不需要等待浏览器解析 HTML 后再发起请求获取。遵循同源策略,浏览器可拒绝。</li><li>请求优先级<br>基于二进制分帧的特性,请求可以带上 32bit 的优先级值,0 表示最高优先级,数值越大优先级越低,可以制定策略优化传输。</li></ul><h3>存在的问题</h3><p>HTTP/2.0 虽然已经解决了 HTTP/1.0 的诸多瓶颈问题,但依然存在问题,这些问题基本都是由于TCP协议本身的限制导致的。</p><ul><li>队头阻塞:这是 TCP 协议本身可靠性的重传机制导致的问题,如果出现丢包,那就会不断重试。这时候又只有一条 TCP 连接,所以会导致性能还不如 HTTP1.1。</li><li>握手延迟大:TCP 协议的三次握手依然无法避免。</li></ul><h2>QUIC</h2><h3>概述</h3><p>QUIC 是 Quick UDP Internet Connections的缩写,读作 quick。由 Google 开发。<br>可以认为 QUIC 是为了解决 HTTP/2.0 在 TCP 遇到的瓶颈,而在 UDP 上做探索所设计的方案。(参考上面 HTTP2 的存在问题)</p><h3>特性</h3><ul><li>没有队头阻塞的多路复用。</li><li>UDP 协议依据 id 寻找目标设备,在多变的移动网络有优势。</li><li>前向纠错,每个包还包括其他包的部分信息,可以在丢包的情况下依靠已接受的包的冗余数据拼凑出丢失的部分,减少重传。</li><li>不需要多次握手,降低延迟。</li></ul>
【Cause】你所应该知道的HTTP——HTTPS篇
https://segmentfault.com/a/1190000022362157
2020-04-14T02:28:57+08:00
2020-04-14T02:28:57+08:00
calimanco
https://segmentfault.com/u/calimanco
47
<h2>HTTP 安全方面的缺点</h2><h3>容易被窃听</h3><p>HTTP 本身不具备加密的功能, 也就是说 HTTP 报文使用明文(指未经过加密的报文)方式发送。</p><h4>原理</h4><p>在基础篇(<a href="https://segmentfault.com/a/1190000022295229">传送门</a>)讲到 TCP/IP 协议族的工作机制,数据会经过多次转发才到达目的地,因此只需要收集在互联网上流动的数据包(帧),进行拼接和分析,就能进行窃听。并不是说只有明文才会被窥视,密文也会,但密文即使被窥视,在不知道解密方法的情况下也是不清楚内容的。</p><h4>解决的办法</h4><ul><li>通信的加密:通过增加一个加密层来实现,相对于形成一个安全的隧道,隧道内再进行 HTTP 通信,是一种免改造 HTTP 本身的做法,但会增加通信消耗。</li><li>内容的加密:服务器和客户端约定一种对通信内容的加密协议,每次发送加密、接收解密,但在 Web 上并不合适,因为 js 是动态脚本语言,分析客户端代码就能知道加解密方法。</li></ul><h3>不验证通信方的身份</h3><p>HTTP 协议中的请求和响应不会对通信方进行确认,因此很容易进行伪装。</p><h4>原理</h4><p>对客户端来说,一般依赖 DNS 寻找目标服务器,但如果 DNS 被污染,也无法确认请求是否发送给了预期的服务器;对服务器来说,基础篇(<a href="https://segmentfault.com/a/1190000022295229">传送门</a>)讲到 HTTP 的“无状态”的特性,会导致服务器“照单全收”,无法确认接收响应的客户端是否合法、是否是无意义的DoS攻击。</p><h4>解决的办法</h4><p>一般通过引入证书机制解决。证书由值得信任的第三方机构颁发,用以证明服务器确实是该域名合法的服务器,证书也作为建立加密隧道的凭证,只有合法的客户端才能正确解密响应信息。</p><h3>无法证明报文完整性</h3><p>所谓完整性是指信息的准确度。若无法证明其完整性,通常也就意味着内容很容易被篡改。</p><h4>原理</h4><p>HTTP 协议本身并没关于完整性验证的约定,原理与前面讲到的“容易被窃听”相同,也就是说如果在某次转发的过程中修改了数据,服务器和客户端都是不可知的。最常见的就是上游 ISP(互联网服务提供商)或路由器往网页上添加广告。</p><h4>解决的办法</h4><p>通过加密处理及摘要功能可以很好的解决,加密在前面已经讲过,这里不再重复;所谓摘要,就是发送方使用 MD5 或 SHA-1 等对数据敏感(数据改动会导致计算结果不同)的算法生成散列值,在信息中附带它,接收方采用相同算法如果能得到相同结果,就能证明数据没有被篡改过。</p><h2>HTTPS 基础知识</h2><p>为了解决上述中的 HTTP 在安全方面的缺点,于是诞生了 HTTPS。<br>HTTPS 全称 Secure HyperText Transfer Protocol(安全超文本传输协议)或 HTTP over SSL ,是一个安全通信通道,用于在客户计算机和服务器之间交换信息。它使用安全套接字层进行信息交换,简单来说它是 HTTP 的安全版,是使用 TLS/SSL 加密的 HTTP 协议。</p><pre><code>HTTPS = HTTP + TLS/SSL</code></pre><p>TLS 全称 Transport Layer Security(安全传输层协议), 前身是 SSL,故现在用 TLS/SSL 统称。是介于 TCP 和 HTTP 之间的一层安全协议,不影响原有的 TCP 协议和 HTTP 协议,所以使用 HTTPS 基本上不需要对 HTTP 页面进行太多的改造。</p><p>套用在TCP/IP四层模型里的结构如下:<br><img src="/img/bVcKWoe" alt="HTTPS 在四层模型" title="HTTPS 在四层模型"></p><h2>TLS/SSL 原理</h2><p>TLS/SSL 是独立于 HTTP 的协议,所以不光是 HTTP 协议,其他运行在应用层的 SMTP 和 Telnet 等协议均可配合 TLS/SSL 协议使用。可以说 TLS/SSL 是当今世界上应用最为广泛的网络安全技术。 <br>TLS/SSL 的功能实现主要依赖于三类基本算法:散列函数 Hash、对称加密和非对称加密,其利用非对称加密实现身份认证和密钥协商,对称加密算法采用协商的密钥对数据加密,基于散列函数验证信息的完整性。</p><pre><code>TLS/SSL = 非对称加密 + 对称加密 + 散列算法</code></pre><h3>非对称加密</h3><p>加密和解密使用不同密钥的加密算法,也称为公私钥加密。密钥成对出现,一般称为公钥(publickey)和私钥(privatekey),公钥加密的信息只能私钥解开,私钥加密的信息只能公钥解开。即服务器持有私钥,客户端持有公钥,客户端要发送的信息经过公钥加密后传递给服务器,服务器用私钥解密得到明文信息。</p><p>特点:</p><ul><li>可以实现 1 对多的通信;</li><li>保密性比较好,只有公钥需要被传递,故私钥被劫持的概率很低;</li><li>安全性高,保密性保证私钥安全,因此安全性仅依赖于算法本身;</li><li>计算复杂,加密速度慢。</li></ul><p>在 TLS/SSL 中,非对称加密仅用于“身份认证”和“密钥协商”,不在后续正文数据传输中使用,这是安全性与性能之间的平衡取舍。</p><h3>对称加密</h3><p>加密和解密使用相同密钥的加密算法。即客户端与服务器所持有的密钥是相同的,客户端要发送的信息经过密钥加密后传递给服务器,服务器用相同密钥解密得到明文信息。</p><p>特点:</p><ul><li>通信方式是 1 对 1,为了足够安全,服务器和 N 个客户端通信,需要维持 N 个密码记录;</li><li>安全性不仅取决于加密算法本身,密钥管理的安全性更是重要;</li><li>计算量小、加密速度快、加密效率高;</li><li>缺少吊销和修改密钥的机制。</li></ul><p>在 TLS/SSL 中,对称加密的密钥是通过非对称加密的“密钥协商”产生的,这样就最大限度的保证了密钥的安全。由于其效率高的特点,正文数据传输使用了该加密方式。</p><h3>散列函数(Hash)</h3><p>一种将任意长度的消息压缩到某一固定长度的消息摘要的函数,常用于防止信息篡改并验证数据的完整性。</p><p>特点:</p><ul><li>单向不可逆;</li><li>对输入非常敏感,即一点输入的改变都会导致结果不同;</li><li>输出长度固定。</li></ul><p>在信息传输过程中,散列函数不能单独实现信息防篡改。因为明文传输,中间人可以修改信息之后重新计算信息摘要,因此需要对传输的信息以及信息摘要进行加密。<br>在 TLS/SSL 中,“密钥协商”的最后步骤和传输正文信息都会附加一种叫做 MAC(Message Authentication Code)的报文摘要 ,由散列函数生成,用来验证完整性。</p><h2>PKI 体系</h2><h3>非对称加密的隐患</h3><p>前面讲到“身份验证”和“密钥协商”是 TLS/SSL 的基础功能,要求的前提是合法的服务器掌握着对应的私钥。但非对称加密算法无法确保服务器身份的合法性,因为公钥并不包含服务器的信息。</p><p>假定出现以下的情况:</p><ul><li>客户端 C 和服务器 S 进行通信,中间节点 M 截获了二者的通信;</li><li>节点 M 自己计算产生一对公钥 pub_M 和私钥 pri_M;</li><li>C 向 S 请求公钥时,M 把自己的公钥 pub_M 发给了 C;</li><li>C 使用公钥 pub_M 加密的数据能够被M解密,因为 M 掌握对应的私钥 pri_M,而 C 无法根据公钥信息判断服务器的身份,从而 C 和 M 之间建立了“可信”加密连接。</li></ul><p><img src="/img/bVcKWon" alt="中间人攻击" title="中间人攻击"></p><p>如图,中间节点 M 和服务器 S 之间再建立合法的连接,因此 C 和 S 之间通信被 M 完全掌握,M 可以进行信息的窃听、篡改等操作,这类攻击被称为“中间人攻击”。</p><h3>身份验证CA和证书</h3><p>为了解决上述的隐患,关键是确保获取公钥途径是合法的,能够验证服务器的身份信息,证明域名对应的服务器是合法的,为此需要引入权威的第三方机构 CA。<br>CA 全称 Certificate Authority(证书颁发机构),它负责核实公钥的拥有者的信息,并颁发认证"证书",同时能够为使用者提供证书验证和吊销服务。<br>CA 提供的整套服务也叫 PKI 体系, 全称 Public Key Infrastructure,即公钥基础设施。</p><pre><code>证书 = 公钥 + 申请者与颁发者信息 + 有效时间 + 域名信息 + 签名</code></pre><p>CA 认证流程如下:<br><img src="/img/bVcKWos" alt="PKI" title="PKI"></p><p>客户端会内置信任 CA 的证书信息(包含公钥),如果 CA 不被信任,则找不到对应 CA 的证书,证书也会被判定非法。<br>也可以这样理解,网站千千万,浏览器厂商没办法一家一家去认证,于是跟 CA 合作,通过维护一个 CA 列表,只要网站有经过这个列表里 CA 的认证,就可以信任该网站的证书。</p><h3>注意</h3><ul><li>申请证书不需要提供私钥,确保私钥永远只能服务器掌握;</li><li>证书的合法性仍然依赖于非对称加密算法,证书主要是增加了服务器信息以及签名;</li><li>服务器最终拿到的证书一般是经过多次派生后的证书,也就是说在服务器证书和根证书之间可能存在多个中间证书,这种证书链的设计有利于减少私钥泄露的风险并且方便吊销。(见图)</li><li>只要中间证书有相同的公钥和私钥,服务器证书就能同时可以被多条证书链验证。</li></ul><p><img src="/img/bVcKWov" alt="证书链" title="证书链"></p><h3>相关概念</h3><ul><li>根证书:颁发者和使用者相同,自己为自己签名,也叫自签名证书,一般为 CA 持有;</li><li>中间证书:根证书可以签发给二级机构,二级机构可以继续签发给三级机构,以此类推,这些介于根证书与服务器证书直接的证书称为中间证书。</li><li>证书链:服务器证书、中间证书与根证书在一起组合成的自下而上的信任传递链,就是一条合法的证书链。</li><li>证书吊销的方法:CRL(Certificate Revocation List)即证书吊销列表;OCSP(Online Certificate Status Protocol)即证书状态在线查询协议。</li></ul><h2>TLS/SSL 握手过程</h2><p>TLS/SSL握手过程也就是所谓的HTTPS四次握手(不含证书验证步骤)。</p><ol><li>客户端发起请求,以明文传输请求信息,包含版本信息,加密套件候选列表,压缩算法候选列表,随机数 random_C(明文),扩展字段等信息。</li><li>服务端返回协商的信息结果,随机数 random_S(明文),证书链等。</li><li>对证书进行验证,包括证书可信性、有效性等,可能需要联系 CA。</li><li><p>细分为四步:</p><ol><li>client_key_exchange:客户端计算产生随机数字 Pre-master,并用证书公钥加密,发送给服务器;</li><li>客户端根据 random_C、random_S 以及 Pre-master,计算得到协商密钥 enc_key(即对称加密用的密钥);</li><li>change_cipher_spec:客户端通知服务器后续的通信都采用协商的通信密钥和加密算法进行加密通信;</li><li>encrypted_handshake_message:结合之前所有通信参数的 hash 值与其它相关信息生成一段数据,采用协商密钥 enc_key 进行加密,然后发送给服务器用于数据与握手验证;</li></ol></li><li><p>细分为四步:</p><ol><li>服务器使用私钥解密 Pre-master,根据 random_C、random_S 以及 Pre-master,计算得到协商密钥 enc_key;</li><li>计算之前所有接收信息的 hash 值,然后解密客户端发送的 encrypted_handshake_message,验证数据和密钥正确性;</li><li>change_cipher_spec:验证通过之后,服务器同样发送change_cipher_spec 以告知客户端后续的通信都采用协商的密钥与算法进行加密通信;</li><li>encrypted_handshake_message:服务器也结合所有当前的通信参数信息生成一段数据并采用协商密钥 enc_key 加密并发送到客户端;</li></ol></li><li>握手结束,开始使用协商密钥 enc_key 进行对称加密通信(包含 hash 完整性验证)。</li></ol><p>示意图如下:<br><img src="/img/bVcKWoJ" alt="TLS握手" title="TLS握手"></p><h2>HTTPS 的使用成本</h2><ul><li>证书费用及维护更新<br>一般正规 CA 颁发的证书都是需要付费购买的,并且到期后还得续费。</li><li>增加了访问延迟<br>分析前面的握手过程,一次完整的握手至少需要两端依次来回两次通信,至少增加延时 2RTT,利用会话缓存从而复用连接,延时也至少 1RTT。</li><li>消耗较多 CPU 资源<br>加解密是需要消耗性能的,前面也有提到非对称加密的特点,因此会成为性能瓶颈。</li></ul><h2>HTTPS 的优化</h2><h3>TLS False Start</h3><p>在 TLS/SSL 协商第二阶段,也就是浏览器生成最后一个随机数并用公钥加密发送给服务器后,立即发送加密的应用层数据,而无需等待服务器的确认。</p><h3>Session Identifier(会话标识符)</h3><p>如果用户的一个业务请求包含了多条的加密流,客户端与服务器要反复握手,必定导致更多的时间损耗。或某些特殊情况导致会话中断,需要重新握手。<br>服务器为每一次的会话生成并记录一个 sessionId,发送给客户端,客户端重新连接只需要提供这个id,不需要重新握手。</p><h3>OCSP Stapling</h3><p>OCSP 全称 Online Certificate Status Protocol。由web服务器向 OCSP server周期性地查询证书状态,获得一个带有时间戳和签名的 OCSP response 并缓存它。当有客户端发起请求时,web 服务器会把这个 response 在 TLS/SSL 握手过程中发给客户端。<br>(谷歌浏览器默认只使用内置列表检查,故这个优化对谷歌无效)</p><h3>HSTS(HTTP Strict-Transport-Security)</h3><p>一个报文头部字段,告诉浏览器,接下来的一段时间内,当前域名(及其子域名)的后续通信应该强制性使用 HTTPS,直到超过有效期为止。</p><p>形如:</p><pre><code>Strict-Transport-Security: max-age=31536000;includeSubDomains</code></pre><h3>加解密计算分离</h3><p>由于最消耗服务器性能的部分是加解密,为了减少业务服务器的负担,可以将这部分工作分配给一个专门优化的高计算性能的服务器,并配备有硬件加速功能的硬件,以达到最佳性能。</p>
【Cause】你所应该知道的HTTP——缓存篇
https://segmentfault.com/a/1190000022336086
2020-04-11T01:40:47+08:00
2020-04-11T01:40:47+08:00
calimanco
https://segmentfault.com/u/calimanco
6
<p>HTTP 协议的缓存是通过 6 个报文头完成的,通过两层协商使 web 资源能够不那么频繁地在服务器与客户端之间传递,从而使服务器不必多次处理相同的请求,节约了流量,提高浏览速度。<br>以从客户端到服务器的顺序,第一层协商为 Cache-Control 与 Expires;第二层协商为 Last-Modified 与 Etag 。<br>本章不涉及与 HTTP 相关性不大的缓存代理(如 CDN)和客户端缓存(如 manifest)。</p><h2>相关的报文头</h2><h3>Cache-Control</h3><p>请求/响应报文头,缓存控制字段,也就是用于控制资源生命周期,是 http/1.1 引入的属性。它支持多值,值用逗号分隔。作用方不仅限于客户端,有些指令还将作用在中间的缓存服务器上。</p><p>例子:</p><pre><code>Cache-Control: private, max-age=0, no-transform </code></pre><h4>Cache-Control 的指令梳理</h4><p>代号:客户端(C)、缓存代理(P)、服务器(S)。<br>注:中括号表示可选项,尖括号表示参数。</p><table><thead><tr><th>属性名</th><th>发起方</th><th>作用方</th><th>说明</th></tr></thead><tbody><tr><td>public</td><td>S</td><td>C/P</td><td>任何一方都可以缓存该资源。</td></tr><tr><td>private[=<header>]</td><td>S</td><td>C/P</td><td>只允许客户端缓存,不允许缓存代理缓存。可选指定针对某些头部。</td></tr><tr><td>no-cache[=<header>]</td><td>C/S</td><td>C/P</td><td>缓存代理不缓存;客户端缓存该资源,但每次都要询问是否更新,可以等价 max-age=0 。当服务器发起时可选指定针对某些头部。</td></tr><tr><td>no-store</td><td>C/S</td><td>C/P</td><td>任何一方都不缓存该资源。</td></tr><tr><td>max-age=<second></td><td>C/S</td><td>C/P</td><td>设置缓存存储的最大周期,也就是说在这个秒数内不发起新请求。当客户端发送此指令给缓存代理时,代理能满足要求则直接返回给客户端,不必再次访问服务器。</td></tr><tr><td>no-transform</td><td>C/S</td><td>P</td><td>缓存代理不可更改媒体类型,这样做可防止代理执行压缩图片等类似操作。</td></tr><tr><td>s-maxage=<seconds></td><td>S</td><td>P</td><td>缓存代理可缓存的最长时间。</td></tr><tr><td>must-revalidate</td><td>S</td><td>C/P</td><td>可缓存但一旦资源过期必须再向源服务器进行确认,如果无法访问服务器则向客户端报 504 Gateway Timeout 。优先级高于 max-stale。</td></tr><tr><td>proxy-revalidate</td><td>S</td><td>P</td><td>与 must-revalidate 作用相同,但它仅适用于缓存代理。</td></tr><tr><td>max-stale[=<seconds>]</td><td>C</td><td>P</td><td>客户端要求缓存代理该时间内(默认不限时间)的资源无论缓存有没有过期都返回给客户端。</td></tr><tr><td>min-fresh=<seconds></td><td>C</td><td>P</td><td>客户端要求缓存代理返回至少还未过指定时间的缓存资源,可以理解成限定了资源的最小生命期,在这生命期内才算有效。</td></tr><tr><td>only-if-cached</td><td>C</td><td>P</td><td>客户端要求缓存代理只返回有效的缓存,不需要向服务器对有效性进行确认,如果没有缓存则报 504 Gateway Timeout 。</td></tr></tbody></table><h4>补充说明</h4><p>在 HTTP/1.1 中 Cache-Control 是优先级最高的缓存相关头部,部分决定是否缓存的指令(public、private、no-store)会起到直接决定的作用,也就是说如果决定不缓存了,那就不会再进行下一步关于是否过期的判断。在 HTTP/1.0 中 Cache-Control 会被忽略,降级为对 Expires 过期时间的判断。</p><h3>Expires</h3><p>响应报文头,代表资源过期时间,在过期之前缓存会一直保存,并且不会向服务器发起请求。由服务器返回提供,是 HTTP/1.0 的属性,在 HTTP/1.1 环境并且与 Cache-Control 共存的情况下,优先级要低。<br>Expires 的功能基本与 Cache-Control 的 max-age 指令相似,但它是指定一个过期时间点,而 Cache-Control 的 max-age 是指定了过期前的秒数。</p><p>例子:</p><pre><code>Expires: Wed, 04 Jul 2020 08:26:05 GMT</code></pre><h3>Last-Modified</h3><p>响应报文头,资源最终修改时间,由服务器告诉客户端。</p><p>例子:</p><pre><code>Last-Modified: Wed, 23 May 2020 09:59:55 GMT</code></pre><h3>If-Modified-Since</h3><p>请求报文头,与 Last-Modified 相对应,浏览器把服务器最后一次给的 Last-Modified 返回。服务器将以此进行对比,判断资源是否需要更新,如果请求的资源都没有过更新,则返回状态码 304 Not Modified 的响应。 </p><p>例子:</p><pre><code>If-Modified-Since: Thu, 15 Apr 2004 00:00:00 GMT</code></pre><h3>Etag</h3><p>响应报文头,ETag 是 HTTP/1.1 标准开始引入的,对 Last-Modified 的补充。它是一种可将资源以字符串形式做唯一性标识的方式。服务器会为每份资源分配对应的 ETag 值。当资源更新时,ETag 值也需要更新。</p><h4>ETag 的强弱之分</h4><ul><li>强 ETag:不论实体发生多么细微的变化都会改变其值。</li><li>弱 ETag:只用于提示资源是否相同。只有资源发生了根本改变,产 生差异时才会改变 ETag 值。这时,会在字段值最开始处附加 W/。</li></ul><p>例子:</p><pre><code>ETag: "usagi-1234"
ETag: W/"usagi-1234"</code></pre><h4>为什么需要 ETag</h4><ul><li>一些周期性修改的文件,修改时间变了但内容没变,此时不希望重新获取;</li><li>一些文件修改非常频繁,比如1秒内修改了多次,Last-Modified 只能精确到秒;</li><li>一些服务器不能得到文件修改的精确时间。</li></ul><h4>额外注意</h4><ul><li>ETag 没有规定生成的算法,每个服务器生成都可能不一样;</li><li>分布式系统里多台计算机间文件的 Last-Modified 必须一致,以免负载均衡到不同机器导致对比失败,因此分布式系统要统一 ETag 算法。</li></ul><h3>If-None-Match</h3><p>请求报文头,是一种客户端向服务器提条件的方法,它与报文头 If- Match 作用相反。一般客户端把服务器最后一次给的 ETag 值通过 If-None-Match 返回,服务器将以此进行对比,判断资源是否需要更新。</p><p>例子:</p><pre><code>If-None-Match:58b66ccbe349d0d931df877c00d8101d037243dc</code></pre><h2>协商流程</h2><p>以下假定资源已经获取过一次,并且运行在HTTP/1.1环境下,现在进行二次访问。</p><p>流程图如下:<br><img src="/img/bVcKRjE" alt="HTTP 缓存" title="HTTP 缓存"></p><p>说明:</p><ul><li>客户端是有可能因为缓存原因不向服务器发起任何请求的,图中 200 From Cache 就是这种情况。</li><li>服务器根据回传的 If-Modified-Since 与 Last-Modified 比对,如果不同则说明这个文件修改过,需要更新。但在这种判断精度是秒,如果是一秒内的改动,就需要进一步对比回传的 If-None-Match 与 ETag 的值。</li><li>服务器返回 304 Not Modified 的意思就是不需要重新获取新资源,直接使用本地缓存即可。</li></ul><h2>缓存多久合适</h2><p>生存时间(TTL)指令告诉浏览器应该缓存某个资源多久,也就是 Cache-Control 或 Expires 的值。<br>找到给定资源的最佳TTL值并没有完美的科学方法。</p><p>指导原则:</p><ul><li>纯静态内容,例如图片或带版本的数据,可以在客户端永久缓存;</li><li>CSS/JS 和个性化资源,缓存时间大约是会话(交互)平均时间的两倍;</li></ul><p>其他类型资源取决于新数据对旧数据的容忍极限。</p><h2>浏览器操作对 HTTP 缓存的影响</h2><table><thead><tr><th>用户操作</th><th>Expires/Cache-Control</th><th>Last-Modified/Etag</th></tr></thead><tbody><tr><td>地址栏回车</td><td>有效</td><td>有效</td></tr><tr><td>页面链接跳转</td><td>有效</td><td>有效</td></tr><tr><td>新开窗口</td><td>有效</td><td>有效</td></tr><tr><td>前进、后退</td><td>有效</td><td>有效</td></tr><tr><td>F5刷新</td><td>无效</td><td>有效</td></tr><tr><td>Ctrl+F5刷新</td><td>无效</td><td>无效</td></tr></tbody></table><h2>缓存改进方案</h2><h3>md5/hash 缓存</h3><p>通过不缓存 html,为静态文件添加 MD5 或者 hash 标识,解决浏览器无法跳过缓存过期时间主动感知文件变化的问题。</p><h3>CDN缓存(代理缓存)</h3><p>CDN 是构建在网络之上的内容分发网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。</p>
【Cause】你所应该知道的HTTP——进阶篇
https://segmentfault.com/a/1190000022322405
2020-04-10T01:58:02+08:00
2020-04-10T01:58:02+08:00
calimanco
https://segmentfault.com/u/calimanco
8
<h2>认证方式</h2><p>常见的认证方式可分为4种:</p><ul><li>BASIC 认证(基本认证)</li><li>DIGEST 认证(摘要认证)</li><li>SSL 客户端认证</li><li>FormBase 认证(基于表单认证)</li></ul><h3>BASIC 认证</h3><p>BASIC 认证(基本认证)是从 HTTP/1.0 就定义的认证方式。<br>BASIC 认证虽然采用 Base64 编码方式,但这不是加密处理,因此安全性不高,而且没有注销操作的设计,因此并不常用。</p><h4>步骤</h4><ol><li>客户端发送请求(未带认证信息);</li><li>服务器返回 401,告知需要验证。响应头部举例:<br><code>WWW-Authenticate: Basic realm="Input Your ID and Password." </code></li><li>客户端发送的字符串内容是由用户 ID 和密码构成,两者中间以冒号(:)连接后,再经过 Base64 编码处理。 请求头部举例:<br><code>Authorization: Basic Z3Vlc3Q6Z3Vlc3Q= </code><br>当用户代理为浏览器时,用户仅需输入用户 ID 和密码即可,浏览器会自动完成到 Base64 编码的转换工作。</li><li>服务器读取 Authorization 字段,验证通过返回 200,否则重复②。</li></ol><p>示意图如下:(来自MDN)<br><img src="/img/bVbFLPM" alt="HTTPAuth.png" title="HTTPAuth.png"></p><h4>实现</h4><pre><code>// 基于 express 实现 BASIC 认证
// 学习 express :https://github.com/expressjs/express/
// 将代码复制进一个 express 项目的路由部分,跑起来,用浏览器访问即可体验。
// 用户名:Yee,密码:123456
router.get('/more/login', function (req, res) {
const auth = req.headers.authorization
if (auth) {
const [type, credentials] = auth.split(' ')
const [username, password] = atob(credentials).split(':')
if (type === 'Basic' && username === 'Yee' && password === '123456') {
res.end('Authorization Succeeded')
return
}
}
res.setHeader('WWW-Authenticate', 'Basic realm="Input Your ID and Password."')
res.status(401)
res.end('Authorization Required')
})</code></pre><h3>DIGEST 认证</h3><p>为弥补 BASIC 认证存在的弱点,从 HTTP/1.1 起就有了 DIGEST 认证。<br>DIGEST 认证同样使用质询 / 响应的方式(challenge/response),但不会像 BASIC 认证那样直接发送明文密码。 过程类似于 BASIC 认证,示意图参考上面的。<br>虽然相比 BASIC 认证安全性有所提高,但还是没有注销操作的设计,也无法验证防止用户伪装,因此使用有限。</p><h4>步骤</h4><ol><li>客户端发送请求(未带认证信息);</li><li>服务器返回 401,告知需要验证,响应头部举例:<br><code>WWW-Authenticate: Digest realm="DIGEST", nonce="MOSQZ0itBAA=44abb6784cc9cbfc605a5b0893d36f23de 95fcff", algorithm=MD5, qop="auth"</code><br>首部字段 WWW-Authenticate 内必须包含 realm 和 nonce 这两个字段的信息,其他均为可选参数。客户端就是依靠向服务器回送这两个 值进行认证的。</li><li>客户端回传②中的所有信息,并加上 username、url、response 字段,response 就是经过用户名密码等信息加密而已的字符串;请求头部举例:<br><code>Authorization: Digest username="guest", realm="DIGEST", nonce="MOSQZ0itBAA=44abb6784cc9cbfc605a5b0893d36f23de95f cff", uri="/digest/", algorithm=MD5, response="df56389ba3f7c52e9d7551115d67472f", qop=auth, nc=00000001, cnonce="082c875dcb2ca740"</code></li><li>服务器使用相同的加密方法得到一个字符串,将它与 response 比较,相同就通过返回 200,否则重复②。</li></ol><h3>SSL 客户端认证</h3><p>借由 HTTPS 的客户端证书完成认证的方式。凭借客户端证书认证,服务器可确认访问是否来自已登录的客户端。具体过程会在 HTTPS 篇中讲到。<br>在多数情况下,SSL 客户端认证不会仅依靠证书完成认证,一般会和基于表单认证组合形成一种双因素认证(Two-factor authentication)来使用。 第一个认证因素的 SSL 客户端证书用来认证客户端计算机,另一个认证因素的密码则用来确定这是用户本人的行为。。</p><h3>基于表单的认证</h3><p>由于使用上的便利性及安全性问题,HTTP 协议标准提供的 BASIC 认证和 DIGEST 认证几乎不怎么使用。因此实际 Web 开发中,还是由开发者自行实现一套认证机制,安全性高低自然由开发者自己控制。<br>虽然基于表单的认证方式并不是在 HTTP 协议中定义,是由 Web 应用程序各自实现,但业内还是产生了一些通用解决方案,比如 session-cookie 机制(这部分将在“会话跟踪技术”中详讲)。</p><h2>会话跟踪技术</h2><h3>cookie</h3><p>cookie 实际上是服务器保存在客户端上的一小段的文本信息。以键值对的形式保存,并由客户端维护其有效期。服务器通过响应报文头 set-cookie 进行设置(种)。当客户端再次请求该源时,会在请求报文头里将有效的 cookie 提交给服务器。<br>cookie 遵循同源策略。cookie 这种保存并自动回传一定数据的特性,使基于无状态的 HTTP 协议记录稳定的状态信息成为了可能。</p><p>关于什么是同源策略,可以参考本人另一篇文章:<br><a href="https://segmentfault.com/a/1190000014223524">传送门</a></p><h4>响应头 set-cookie</h4><p>形如:</p><pre><code>set-cookie: <key>=<value>; Expires=<date>; Secure; HttpOnly</code></pre><p>key 为属性名,value 为值,一个 set-cookie 设置一个 key,如需设置多个 key,只需要同时返回多个 set-cookie,例子中 Expires、Secure、HttpOnly 为可选值。所有可选属性如下:</p><table><thead><tr><th>属性名</th><th>说明文字</th></tr></thead><tbody><tr><td>Expires</td><td>超时时间点,默认是 Session,即关闭浏览器时失效。(注1)</td></tr><tr><td>Max-Age</td><td>失效前的秒数。优先级高于 Expires。</td></tr><tr><td>Domain</td><td>可以使用这个 cookie 的域,二级域名可指定为一级,一级只能指定为一级。</td></tr><tr><td>Path</td><td>可以使用这个 cookie 的路径,默认为文档所在的文件目录,父级路径可用于所有子级(即设置为根路径“/”就可用于该域名的所有路径)。</td></tr><tr><td>Secure</td><td>限制该 cookie 只通过 HTTPS 传递。</td></tr><tr><td>HttpOnly</td><td>限制该 cookie 只由服务器读写,不能被 js 获取到。</td></tr><tr><td>SameSite</td><td>Chrome51 开始支持,可对跨域的 cookie 进行限制。优先级高于 CORS 的设置,不论是 src、form 发起的请求,还是 ajax 请求。(注2)</td></tr></tbody></table><p><strong>注1</strong>:现代浏览器多开选项卡和窗口都不会影响 Session 周期,即超时时间设置为 Session 的 cookie 会一直有效,除非彻底退出浏览器应用。 (即使关掉所有窗口,但应用还在后台也不影响)<br><strong>注2</strong>:SameSite可以指定为Strict、Lax、None三个值,默认为Lax:</p><ul><li>Strict 为最严格模式,将会完全禁止第三方 cookie,即不可能在跨域的情况下携带 cookie,不论 Access-Control-Allow-Credentials 是否设置;</li><li>Lax 只允许链接、预加载、GET 表单三种情况发送 cookie;</li><li>None 为无限制,但必须启用 Secure 属性,即只在 HTTPS 的环境下才发送 cookie。</li></ul><p><strong>注3</strong>:哪些情况下cookie会被认为是第三方的?</p><table><thead><tr><th>源(发起请求页面的域名)</th><th>cookie 域</th><th>是否被认为第三方</th></tr></thead><tbody><tr><td>publisher.com</td><td>publisher.com</td><td>否</td></tr><tr><td>publisher.com</td><td>demo.com</td><td>是</td></tr><tr><td>demo.com(通过publisher.com页面的iframe加载)</td><td>demo.com</td><td>是</td></tr></tbody></table><h4>请求头 cookie</h4><p>形如:</p><pre><code>cookie: <key>=<value>; <key>=<value>...</code></pre><p>key 为属性名,value 为值,会一次返回该源下的所有有效 key,以分号为分割。这个结果与在浏览器执行 document.cookie 获取到的值相同(无HttpOnly的时)。</p><h3>session</h3><p>session 是服务器记录客户状态的机制。客户端浏览器访问服务器的时候,服务器把客户端信息以某种形式记录在服务器上。客户端再次访问时只需要从该 session 中查找该用户的状态。</p><h3>session-cookie 机制</h3><p>一般 session 与 cookie 配合使用,构成会话跟踪技术,即 session-cookie 机制。<br>服务器生成 session 的 id(图上叫 sessionid)后,就将它通过 set-cookie 传递到客户端,客户端保存这个 sessionid,下次请求通过 cookie 回传到服务器,服务器即可通过 sessionid 查询到用户的 session,进而获得用户状态。</p><p>示意图:<br><img src="/img/bVcKRlJ" alt="session-cookie" title="session-cookie"></p><h3>如何理解“登录状态过期”?</h3><p>我们日常使用中经常会遇到登录状态过期的情况,这一般就是 session-cookie 机制引起的。它可分为三种情况的原因:</p><ol><li>保存在客户端的 cookie 过期或被移除了;</li><li>另一种是保存在服务器的 session 过期或被移除;</li><li>两边的登录信息不一致。</li></ol><h2>连接管理模型</h2><p>在 HTTP/1.x 里有多种模型:短连接,长连接,和 HTTP 管线化(pipelining)。<br>HTTP 管线化实用性不高,并没有浏览器支持,已经被 HTTP/2.0 的多路复用特性所取代,这里就不详细讨论了。<br>连接的长与短只是相对的概念,并没有严格意义上规定多久才算长。讨论 HTTP 的长连接与短连接,本质是讨论 TCP 连接的复用情况。<br>连接模型由请求报文头和响应报文头里的 Connection 字段决定,值为 close 则为短连接,值为 Keep-Alive 则为长连接(这里与 TCP 里的 keepalive 是不同的概念)。HTTP/1.0 默认是短连接,HTTP/1.1 默认是长连接。只有服务器与客户端协商一直才会进行长连接,并且双方在任意时刻都可以关闭,彼此都不受影响。</p><blockquote>扩展阅读:<br>TCP 的 KeepAlive 机制意图在于保活、心跳,检测连接错误。当一个 TCP 连接两端长时间没有数据传输时(通常默认配置是2小时),发送 keepalive 探针,探测链接是否存活。</blockquote><h3>短连接(Multiple Connection)</h3><p>也叫多重连接,客户端和服务器之间进行 HTTP 操作,每次建立一次 TCP 连接,结束就断开连接,下个请求要重新建立 TCP 连接。<br>基础篇(<a href="https://segmentfault.com/a/1190000022295229">传送门</a>)中讲到HTTP协议在 1.0 版本之前的一个特点是“无连接”,这个也就是短连接,只是在后续版本种变成了可选项。</p><p>示意图:</p><p><img src="/img/bVcKRlN" alt="短连接" title="短连接"></p><h3>长连接(Persistent Connection)</h3><p>也叫持续连接,客户端和服务器之间进行 HTTP 操作,只建立一次 TCP 连接,多次资源请求都复用该 TCP 连接,完成后再关闭。<br>当请求报文头有 Connection: Keep-Alive 就会告知服务器客户端支持长连接,服务器如果也支持长连接,则会返回带有 Keep-Alive 字段的响应报文头。<br>下面例子中,服务器告知客户端,连接超时时间为 10 秒,也就是 10 秒内必须有下一个请求,否则 TCP 就会关闭;然后最多维持 500 秒,也就是说 500 秒后会强制关闭 TCP。</p><pre><code>Connection: Keep-Alive
=> 服务器也支持长连接 =>
Keep-Alive: timeout=10, max=500
Connection: Keep-Alive</code></pre><p>示意图:<br><img src="/img/bVcKRlS" alt="长连接" title="长连接"></p><h3>缺陷及变通手段</h3><p>长连接虽然增加了 TCP 连接的复用率,但实质上还是基于请求/响应模式的,并不能实现及时更新、或是服务器主动向客户端发送信息。于是就有了“ajax 轮询”、“ajax 长轮询(Long Poll)”这两种变通手段。</p><ul><li>ajax轮询:原理很简单,就是每隔一段时间就发起一次请求,已到达及时更新的目的。</li><li>ajax长轮询:服务器接收到请求后如果没有更新内容就不响应,客户端也不做超时处理一直处于等待状态,一直等到有更新内容,服务器才响应,客户端接收到响应后又再发起请求,如此往复。</li></ul><p>这两种变通手段都是以消耗大量服务器资源为代价,因此为了根本解决这个问题,后来发展出了 WebSocket 协议,这就是后话了。</p><h2>与 HTTP 协作的 Web 服务</h2><h3>Web 代理(Web Proxy)</h3><p>代理扮演的是“中间人”角色,对于连接到它的客户端来说,它是服务端;对于要连接的服务端来说,它是客户端。它就负责在两端之间来回传送 HTTP 报文。代理可能不止一层。<br>HTTP 客户端向代理发送请求报文,代理服务器需要正确地处理请求和连接(例如正确处理 Connection: keep-alive);转发时,需要附加 Via 首部字段以标记出经过的主机信息;同时向服务器发送请求,并将收到的响应转发给客户端。</p><p>示意图:<br><img src="/img/bVcKRlY" alt="Web 代理" title="Web 代理"></p><h4>作用</h4><p>利用缓存技术减少网络带宽的流量,组织内部针对特定网站的访问控制,以获取访问日志为主要目的,等等。</p><h4>缓存代理</h4><p>最广泛的应用就是 CDN 网络。代理转发响应时,会预先将资源的副本(缓存)保存在代理服务器上。当代理再次接收到对相同资源的请求时,就可以不从源服务器那里获取资源,而是将之前缓存的资源作为响应返回。反之, 则叫“非缓存代理”。</p><h4>透明代理</h4><p>转发请求或响应时,不对报文做任何加工的代理类型被称为透明代理。反之,对报文内容进行加工的代理被称为“非透明代理”。</p><h3>网关(Gateway)</h3><p>网关的工作机制和代理十分相似。 他们的区别:代理是转发相同协议的数据,网关是转换不同协议的数据。<br>其实大部分的 API 服务器都可以视为网关,因为它需要连接 redis,mysql 等其他服务器以满足业务需求。</p><p>示意图:<br><img src="/img/bVcKRl5" alt="网关" title="网关"></p><h3>隧道(Tunnel)</h3><p>隧道的目的是确保客户端能与服务器进行安全的通信,但它对于双方都是透明的。隧道本身不会去解析 HTTP 请求。也就是说,请求保持原样(可能中间有转码和解码的过程)中转给之后的服务器。隧道会在通信双方断开连接时结束。<br>HTTPS 算是最常见的应用,我们为了访问外网使用的“那种工具”,部分也属于这类。</p><p>示意图:<br><img src="/img/bVcKRma" alt="隧道" title="隧道"></p><h2>内容协商机制</h2><p>指客户端和服务器就响应的资源内容进行交涉,然后提供给客户端最为合适的资源。内容协商会以响应资源的语言,字符集,编码方式等作为判断的基准。HTTP 中主要以“服务器驱动”的协商方式进行。</p><h3>协商方式及优缺点</h3><h4>客户端驱动</h4><p>客户端发起请求,服务器发送可选项列表,客户端作出选择后再发送第二次请求。</p><ul><li>优点:容易实现,给用户选择权。</li><li>缺点:增加访问次数和延迟。</li></ul><h4>服务器驱动</h4><p>服务器检查客户端的请求头部集并决定提供哪个版本的页面。(现在最普遍)</p><ul><li>优点:比较快,没有额外开销,支持优先级匹配。</li><li>缺点:头部集都不匹配的时候,服务器只能猜测。</li></ul><h4>透明协商</h4><p>某个中间设备(通常是缓存代理)代表客户端进行协商。</p><ul><li>优点:比较快。</li><li>缺点:需要中间设备,非 HTTP 标准。</li></ul><h3>服务器驱动内容协商</h3><p>客户端通过请求报文头传递告诉服务器支持情况,并且可以带有近似匹配的优先级,服务器回应的响应报文头里进行确认,就完成了内容协商。</p><h4>相关请求报文头</h4><table><thead><tr><th>字段名</th><th>说明文字</th></tr></thead><tbody><tr><td>Accept</td><td>告诉服务器自己能接受的媒体类型</td></tr><tr><td>Accept-Language</td><td>能接受的语言</td></tr><tr><td>Accept-Charset</td><td>能接受的字符集(如 unicode)</td></tr><tr><td>Accept-Encoding</td><td>能接受的编码方式(如 utf-8)</td></tr></tbody></table><h4>相关响应报文头</h4><table><thead><tr><th>字段名</th><th>说明文字</th></tr></thead><tbody><tr><td>Content-Type</td><td>对应 Accept</td></tr><tr><td>Content-Language</td><td>对应 Accept-Language</td></tr><tr><td>Content-Type</td><td>对应 Accept-Charset</td></tr><tr><td>Content-Encoding</td><td>对应 Accept-Encoding</td></tr></tbody></table><h3>近似匹配(优先级)</h3><p>比如:</p><pre><code>Accept-Encoding:en;q=0.5,fr;q=0.0,nl;q=1.0,tr;q=0.0</code></pre><p>这里nl的优先级是1.0,最高优先级,所以会优先返回。</p><h2>断点续传和多线程</h2><p>通过在报文头里两个参数实现的,客户端发请求时对应的是 Range,服务器响应时对应的是 Content-Range 。</p><h3>请求报文头</h3><p>Range用于请求头中,指定第一个字节的位置和最后一个字节的位置。</p><p>格式为:</p><pre><code>Range:(unit=first byte pos) - [last byte pos]</code></pre><h3>响应头</h3><p>HTTP/1.1 200 Ok(不使用断点续传方式)<br>HTTP/1.1 206 Partial Content(使用断点续传方式)<br>Content-Range用于响应头中,在发出带Range的请求后,服务器会在Content-Range头部返回当前接受的范围和文件总大小。</p><p>格式为:</p><pre><code>Content-Range:bytes(unit first byte pos) - [last byte pos]/[entity length](文件总大小)</code></pre><h3>例子</h3><ol><li>客户端下载一个 1024K 的文件 已经下载了其中 512K;</li><li>网络中断,客户端请求续传 因此需要在 HTTP 头申明本次需要续传的片段“Range:bytes=512000- ”这个头通知服务端从文件的 512K 位置开始传输文件;</li><li>服务端收到断点续传请求,从文件的 512K 位置开始传输,并且在 HTTP 头中增加“Content-Range:bytes 512000- /1024000”并且此时服务返回的 HTTP 状态码应该 206,而不是 200。</li></ol><h3>关于多线程</h3><p>断点续传是被动的增量下载,多线程是主动的分片下载,但使用都是 Range 模式。</p>
【Cause】你所应该知道的HTTP——基础篇
https://segmentfault.com/a/1190000022295229
2020-04-08T00:18:58+08:00
2020-04-08T00:18:58+08:00
calimanco
https://segmentfault.com/u/calimanco
21
<h2>前言</h2><p>力求以最简单和高效的语言说明问题,让大家快速掌握知识点。资料参考参考《图解HTTP》、维基百科和 MDN 等,可作为精华学习笔记使用。<br>本人能力有限,如有不正确之处请批评指正。</p><h2>概述</h2><p>HTTP 全称 Hypertext Transfer Protocol ,直译为“超文本转移协议”,但更多时候俗称“超文本传输协议”。<br>HTTP 字面意义上就是为了 HTML 的传输而发明的网络协议,但进过不断的完善、改进和发展后,它已经不再局限于此,比如现在css、js、图片也是通过这个协议传输的。因此 HTTP 已经成为了 Web 领域一种通用的传输协议。<br>我们通常使用的网络(包括互联网)是在 TCP/IP 协议族的基础上运作的。而 HTTP 属于它内部的一个子集。可以说 HTTP 是基于 TCP/IP 的协议,其在 TCP/IP 的四层结构中属于应用层。</p><h2>HTTP 与 TCP/IP 族的关系</h2><p>TCP/IP 协议族(簇)是可分为四层,从上到下分别是:应用层、传输层、网络层、链路层。客户端和服务端都是这样的结构,只是他们的数据流方向相反。每一层都会有不同的协议对经过的数据包进行封装(或解包)。<br>本文重点不在讨论 TCP/IP 协议族,我们这里只选取与 HTTP 关联性较强的三个进行介绍:IP、TCP 和 DNS 。<br>不想看过多理论性论述的朋友,理解下面的图就够了,可完美解答“输入一个网址经历了什么”这个常见问题:</p><p><img src="/img/bVcKRkc" alt="HTTP 与 TCP/IP 族的关系" title="HTTP 与 TCP/IP 族的关系"></p><h3>IP</h3><p>全称 Internet Protocol,即互联网协议。在 TCP/IP 的四层结构中位于网络层,作用是把各种数据包传送给对方。 <br>实际上很少有传输双方都在同一局域网中的情况,因而一般需要经过多次路由转发。<br>IP 间的通信依赖 MAC(Media Access Control Address)地址。 IP 地址可变换,但 MAC 地址基本上不会更改。<br>会采用 ARP(Address Resolution Protocol)协议对 IP 和 MAC 进行转换。 ARP 是一种用以解析地址的协议,根据通信方的 IP 地址就可以反查出对应的 MAC 地址。</p><h3>TCP</h3><p>全称 Transmission Control Protocol,即传输控制协议。在 TCP/IP 的四层结构中位于传输层,提供可靠的字节流服务。 <br>所谓的字节流服务是指,为了方便传输, 将大块数据分割成以报文段为单位的数据包进行管理。而可靠的传输服务是指,能够把数据准确可靠地传给对方。<br>为了准确无误地将数据送达目标处,TCP 协议采用了三次握手(three-way handshaking)策略,它是在 HTTP 数据发送前完成的,所以这里就不展开了。</p><h3>DNS</h3><p>全称 Domain Name System,即域名系统。在 TCP/IP 的四层结构中位于应用层,提供域名到 IP 地址之间的解析服务 。<br>这是我们可以通过域名访问网站的基础。DNS 协议提供通过域名查找 IP 地址,或逆向从 IP 地址反查域名的服务。</p><h2>发展简史</h2><ul><li>1990年10月<br>万维网之父 Tim Berners-Lee 最早提出了 HTTP 协议</li><li>1991年<br>HTTP/0.9 诞生(Tim 的文章)</li><li>1994年<br>成立 W3C 组织</li><li>1996年5月<br>HTTP/1.0 发布(RFC1945)</li><li>1997年1月<br>HTTP/1.1 发布(第一版 RFC2068,第二版 RFC2616)</li><li>2000年5月<br>HTTPS 发布(RFC2818)</li><li>2015年5月<br>HTTP/2.0(取代SPDY协议)发布(RFC7540)</li><li>未来<br>QUIC 协议,或 HTTP/3.0</li></ul><h2>特点</h2><h3>支持客户/服务器模式</h3><p>由客户端向服务器发出请求,服务器端响应请求,并进行相应服务。能够明确区分哪端是客户端,哪端是服务器端。 一般服务器不可以主动对客户端发起请求(扩展的 WebSocket 协议可以实现双工)。</p><h3>简单快速</h3><p>客户向服务器请求服务时,只需传送请求方法和路径。由于 HTTP 协议简单、使得 HTTP 服务器的程序规模小,因而通信速度很快。</p><h3>灵活</h3><p>HTTP 允许传输任意类型的数据类型,这得益于有 Content-Type 这个报文头的设计,发送方可以告知接收方实体主体的媒体类型,接收方能以此正确解析数据。HTTP 也允许自定义报文头,这使得客户端和服务器建立某种扩展约定很方便。</p><h3>无连接</h3><p>限制每次 TCP 连接只处理一个请求(限 HTTP/1.0 之前)。服务器处理完客户的请求,并应答后,即断开连接。采用这种方式可以节省服务器资源,在早期只有简单文本传输的时代是适用的。但随着网页的复杂度增大,这一限制反而降低了性能,HTTP/1.0 及之后的版本加入的 keep-alive 机制一定程度上打破了这一限制。</p><h3>无状态</h3><p>协议对于事务处理没有记忆能力。缺少状态意味着如果后续处理需要前面的信息,则它必须重传,这样可能导致每次连接传送的数据量增大。随着 Web 的不断发展,因无状态而导致业务处理变得棘手的情况增多了,为了实现期望的保持状态功能,于是引入了 cookie 技术。</p><h2>报文结构</h2><h3>请求部分</h3><ul><li>请求行(Request line)<br> 位与第一行;分为 Method(请求方法)、Path-to-resource(请求URI)、Http/Version-number(HTTP协议及版本)三部分。</li><li>请求报文头(Request headers)<br> 从第二行开始至第一个空行结束;向服务器传递附加信息,形式是<key>:<value>。</li><li>请求报文体(Request body)<br>从第一个空行之后的都是正文;可选;可以自定义格式的文本,比如json格式、表单格式、二进制数据。</li></ul><p><img src="/img/bVbFEyI" alt="请求结构图" title="请求结构图"></p><h3>响应部份</h3><ul><li>响应行(Response line)<br>位与第一行;分为 Http/Version-number(HTTP 协议及版本)、Statuscode(状态码)、message(状态描述)三部分;</li><li>响应报文头(Response headers)<br>从第二行开始至第一个空行结束;向客户端传递附加信息,形式是<key>:<value>。</li><li>响应报文体(Response body)<br>从第一个空行之后的都是正文;可选;可以自定义格式的文本,比如 json 格式、表单格式、二进制数据。</li></ul><p><img src="/img/bVbFEBa" alt="响应结构图" title="响应结构图"></p><h2>报文头(首部字段)</h2><h3>概述</h3><p>HTTP 的报文头大体可以分为四类:通用报文头、请求报文头、响应报文头和实体报文头(描述报文体)。<br>在 HTTP/1.1 里一共规范了47种报文头,除此之外还有 Cookie、Set-Cookie 和 Content-Disposition 等非正式的报文头,它们的使用频率也很高,这些被归纳在 RFC4229 中。报文头是可以自定义的,也就是说只要客户端和服务器约定好就可以使用,这类自定义的报文头旧时会加 X 前缀来区分,但 RFC6648 已经停止这种做法。<br>各种报文定义参见:<a href="https://link.segmentfault.com/?enc=O0eJqQejGtFxqb0XXQescQ%3D%3D.CaRBCbDGZi77sWx0LyuQELFSzyHdTctJIiqLraIfzEMkzz8a4kltqGvv5QnGLJEzcR1ASpm%2Fg7C0X0X0cytAJQ%3D%3D" rel="nofollow">报文列表</a></p><h3>格式</h3><p>HTTP 的报文头是由首部字段名和字段值构成的,中间用冒号“:”分隔;字段值可以是多值,用“,”分隔。</p><p>形如:</p><pre><code><headerName>: <key1> = <value1>, <key2> = <value2></code></pre><h3>“端到端”和“逐跳”</h3><p>根据在代理服务器的行为不同,可将报文头分为两种:</p><h4>端到端报文头(End-to-end)</h4><p>分在此类别中的报文头会转发给请求 / 响应对应的最终接收目标,且必须保存在由缓存生成的响应中,另外规定它必须被转发。</p><h4>逐跳报文头(Hop-by-hop)</h4><p>分在此类别中的报文头只对单次转发有效,会因通过缓存或代理而不再转发。HTTP/1.1 和之后版本中,如果要使用 Hop-by-hop 首部,需提供 Connection 首部字段。<br>下面列举了 HTTP/1.1 中的逐跳首部字段。除这8个首部字段之外,其他所有字段都属于端到端首部。</p><ul><li>Connection</li><li>Keep-Alive</li><li>Proxy-Authenticate</li><li>Proxy-Authorization</li><li>Trailer</li><li>TE</li><li>Transfer-Encoding</li><li>Upgrade</li></ul><p>比如:</p><pre><code>GET / HTTP/1.1
Upgrade: HTTP/1.1
Connection: Upgrade
=> 代理服务器移除 Connection 指定的字段,再转发 =>
GET / HTTP/1.1</code></pre><h2>请求方法</h2><p>请求方法使用在请求行中,是客户端告诉服务器该执行什么样的数据操作的标记,但也仅仅只是标记作用,并没有严格意义上限制服务器的行为。<br>能够严格遵循这套标准的服务,比如 RESTful 架构,有利于语义化并提供客户端一定的自主性,但在非标准实现的服务器上,你甚至可以用一个 POST 方法涵盖 GET、POST、PUT、DELETE 操作。<br>注:下面括号内是支持的版本;省略了 LINK 和 UNLINK 这两个已经废弃的方法。</p><ul><li>GET(1.0、1.1)<br>用来请求访问已被 URI 标识的资源,会把请求的数据挂在 URL 中;对用户隐私不友好;请求的字符长度有限制(IE 最短,只支持2083)。</li><li>POST(1.0、1.1)<br>一般用来传输实体的主体,目的不是获取响应主体内容;把数据放在报文体里传送。</li><li>PUT(1.0、1.1)<br>和 POST 一样,用来提交数据,不同的是,PUT 是幂等的,POST 不是幂等的。</li><li>HEAD(1.0、1.1)<br>和 GET 差不多,只不过是用于获取报头的,可以用来验证超链接的有效性。</li><li>DELETE(1.0、1.1)<br>请求服务器删除指定资源,和 PUT 一样没有验证机制,存在安全隐患。</li><li>TRACE(1.1)<br>回显服务器收到请求,用于测试或诊断。</li><li>CONNECT(1.1)<br>开启一个 C 端和所请求资源之间的双向沟通的通道,比如代理服务器 proxy 来访问网站。</li><li>OPTION(1.1)<br>用来查询针对请求 URI 指定的资源支持的方法,常见于发起复杂 CORS 请求的情况。</li></ul><blockquote><strong>等幂性</strong>:如果一个方法或功能执行一次或者多次,结果是一样的,那么就说这个方法或功能是等幂的。<br>例如,设置某个用户的性别为男性,这个方法无论执行一次还是多次,它的结果都是相同的。所以,该方法具有幂等性。<br>例如,某个账户充值100元,这个方法执行一次和执行多次的结果是不相同的。所以,该方法不具有幂等性。</blockquote><h2>响应状态码</h2><p>用以表示网页服务器超文本传输协议响应状态的3位数字代码。按首字母可分为以下五大类:</p><ul><li>1xx:表示消息。代表请求已被接受,需要继续处理;只包含状态行,几乎不用。</li><li>2xx:表示成功。代表请求已被服务器接收、理解、接受。</li><li>3xx:重定向。代表需要客户端采取进一步操作才能完成请求,后续的请求地址在本次的响应 location 域中指明。</li><li>4xx:请求错误。代表客户端看起来可能发生了错误。</li><li>5xx:表示服务器错误。</li></ul><p>完整列表请参考:<a href="https://link.segmentfault.com/?enc=cUSzzJX8qpETNHbb0iF8vQ%3D%3D.vT9rS37xQH%2BtWK%2F8Gtu8riU%2Bphct01vPKIJpf2qpEFeErj2tRvZBKJ%2FODl2FC6DgUdRjjLVBRZBgiZpHyDBkGA%3D%3D" rel="nofollow">状态码列表</a></p><h2>统一资源标识符</h2><h3>概述</h3><p>虽然统一资源标识符不是本文 HTTP 协议专有的概念,但其就是我们日常访问网站必须的东西,也就是输入在浏览器地址栏中的那个字符串。它可以是 http 开头,也可以是 ftp、mailto、telnet、file 等,通通都是“统一资源标识符”。<br>统一资源标识符,即 Uniform Resource Identifier,简称 URI。URI 就是由某个协议方案表示的资源的定位标识符。协议方案是指访问资源所使用的协议类型名称。</p><p>下面这些例子都是 URI:</p><pre><code>ftp://ftp.is.co.za/rfc/rfc1808.txt
http://www.ietf.org/rfc/rfc2396.txt
ldap://[2001:db8::7]/c=GB?objectClass?one
mailto:John.Doe@example.com
news:comp.infosystems.www.servers.unix
tel:+1-816-555-1212
telnet://192.0.2.16:80/
urn:oasis:names:specification:docbook:dtd:xml:4.1.2</code></pre><h3>格式</h3><pre><code>URI = scheme:[//authority]path[?query][#fragment]
其中 authority = [userinfo@]host[:port]</code></pre><p>图片来自维基百科:</p><p><img src="/img/bVcKHMN" alt="image.png" title="image.png"></p><h4>scheme(协议名)</h4><p>必选,指定使用的协议。由一系列字符组成,这些字符序列以字母开头,后跟字母,数字,加号(+),句点(.)或连字符(-)的任意组合。不区分字母大小写,最后附一个冒号。</p><h4>authority(授权)</h4><p>可选,其前面带有两个斜杠(//)。包含 userinfo,host,port 三部分。</p><ul><li>userinfo(用户信息)<br>可选,指定用户名和密码作为从服务器端获取资源时必要的登录信息。用分号(:)分割用户名和密码,以 @ 为结束。</li><li>host(服务器地址 )<br>必选,指定服务器的 IP 地址或域名,如果是 IPv6 的地址需要加中括号([ ])。</li><li>port(服务器端口号 )<br>可选,指定要访问的服务器端口。以冒号(:)为开头。http 协议默认为 80 端口。</li></ul><h4>path(带层次的文件路径 )</h4><p>必选,定义比较广泛,电话号码和email也归类在此,但一般是指定服务器上的文件路径来定位特指的资源,与 UNIX 系统的文件目录结构相似。作为路径时必须以斜杠(/)开头,用若干的斜杆(/)对层级进行分割。</p><h4>query(查询字符串 )</h4><p>可选,针对已指定的文件路径内的资源,可以使用查询字符串传入任意参数。以问号(?)为开头,以 & 符号作为分隔(非标准规定)键值对形式的参数。</p><h4>fragment(片段标识符 )</h4><p>可选,使用片段标识符通常可标记出已获取资源中的子资源,一般作为客户端辅助定位用,服务器不会使用该值。以井号(#)为开头。</p><h3>编码</h3><p>RFC3986 文档规定,Uri/Url 中只允许包含英文字母(a-zA-Z)、数字(0-9)、- _ . ~ 4个特殊字符以及所有保留字符。除了上述字符,其他字符都应进行百分号编码。编码的意义是避免歧义的产生,并且扩大可表示的字符集范围。</p><h4>百分号编码</h4><p>对保留字需要取其 ASCⅡ 内码,然后加上“%”前缀进行编码;对于非ASCⅡ字符需要取其 Unicode 内码,然后加上“%”前缀进行编码。比如问号(?)会被编码为“%3F”。<br>使用 js 原生的 encodeURIComponent 方法可以编码大部分字符,更加全面的编码可用 qs 包的 stringify 方法。</p><h4>主保留字</h4><p><code>: / ? # [ ] @</code></p><p>用于分隔不同组件,如果不是作为分隔符,则需要进行百分号编码。比如冒号(:)用于分隔 scheme 和其他组件;斜杠(/)用于分割 authority 和其他组件。</p><h3>副保留字</h3><p><code>! $ & ' ( ) * + , ; =</code></p><p>用于在每个组件中起到分隔作用的,如果不是作为分隔符,则需要进行百分号编码。如等于号(=)用于表示 query 中的键值对,& 符号用于分隔 query 中多个键值对。</p><h3>关于空格的处理</h3><p>RFC1738 中会将空格编码为加号(+),但这在 RFC3986 中百分号编码为 %20,一般 %20 可向下兼容。</p><h2>URI、URL与URN</h2><ul><li>URI(Uniform Resource Identifier)<br>统一资源标志符,一个紧凑的字符串用来标示抽象或物理资源。</li><li>URL(Uniform Resource Locator)<br>统一资源定位符,URI的子集,表示资源的地点(互联网上所处的位置)。</li><li>URN(Uniform Resource Name)<br>统一资源名称,URI的子集,定义某事物的身份,不关心其访问方式与位置。</li></ul><p>示意图:(来自维基百科)<br><img src="/img/bVbFJDB" alt="220px-URI_Euler_Diagram_no_lone_URIs.svg.png" title="220px-URI_Euler_Diagram_no_lone_URIs.svg.png"></p><p>例子:辨析<code>https://segmentfault.com/a/1190000022295229.html#intro</code></p><ol><li>https是访问方式;<code>segmentfault.com/a/1190000022295229.html</code>是存放位置;#intro是资源</li><li>URL即<code>https://segmentfault.com/a/1190000022295229.html</code></li><li>URN即<code>segmentfault.com/a/1190000022295229.html#intro</code></li><li>两者都是URI</li></ol>
【Fes】基于canvas的前端动画/游戏入门(七)
https://segmentfault.com/a/1190000014242621
2018-04-08T19:11:08+08:00
2018-04-08T19:11:08+08:00
calimanco
https://segmentfault.com/u/calimanco
4
<h2>动量与动量守恒</h2><blockquote>【科普】一般而言,一个物体的动量指的是这个物体在它运动方向上保持运动的趋势。动量实际上是牛顿第一定律的一个推论。</blockquote><p>动量即是“物体运动的量”,是物体的质量和速度的乘积,是矢量,能够反应出运动的效果,一般用 p 表示。举个例子,低速运动的重物,跟高速运动的子弹,拥有相同的威力。</p><pre><code>p = m * v</code></pre><blockquote>【科普】动量是守恒量。动量守恒定律表示为:一个系统不受外力或者所受外力之和为零,这个系统中所有物体的总动量保持不变。它的一个推论为:在没有外力干预的情况下,任何系统的质心都将保持匀速直线运动或静止状态不变。动量守恒定律可由机械能对空间平移对称性推出。</blockquote><p>动量守恒即系统在碰撞前的总动量等于系统在碰撞后的总动量。其中的系统简单理解就是物体的集合。在可以忽略碰撞以外的因素时,动量是守恒的。</p><pre><code>(m0 * v0) + (m1 * v1) = (m0 * v0Final) + (m1 * v1Final)</code></pre><p>这条公式是我们计算碰撞后速度的基础,因为我们假定我们的物体都是刚体,并且忽略外力做碰撞。现在只要推导出末速度 v0Final 和 v1Final 的公式,就可以应用到我们的模拟碰撞的编程动画中。推导过程如下:</p><p><img src="/img/bV7VeP" alt="推导末速度" title="推导末速度"></p><p>其实推导过程不重要,只要记得结论:</p><pre><code class="javascript">v1Final = (2 * m0 * v0) + v1 * (m1 - m0) / (m0 + m1)
v0Final = (2 * m1 * v1) - v0 * (m0 - m1) / (m0 + m1)
// 二者可直接转换
v1Final = (v0 - v1) + v0Final</code></pre><h2>单轴碰撞</h2><p>我们开始使用前面推导出的公式,先来个最简单的单轴碰撞例子,这里演示了两个球相撞的效果,mass 定义了他们的质量,由于他们初始速度相同,所以依据动量守恒碰撞后 ball0 的速度变为 -1/3,而 ball1 的速度变为 5/3。</p><blockquote>【PS】这里有个细节,碰撞时可能出现球已经重叠的情况,这个例子只是简单将末速度加给碰撞后的球,用以弹开他们,这是不严谨但有效的做法。</blockquote><p>完整示例:<a href="https://link.segmentfault.com/?enc=yty0vbwKdrOKuXwpEPYJzA%3D%3D.dUfw%2BtWn2OaEkmqo%2BIBx4EuXIMkBnX%2Fvh%2F7FNvpRGde3VCn6%2F0IvCQycf6CUZhdk4%2BasE%2FhsgRpeOkhJjI8fWAECRMQGKoPu1raok%2BLHDec%3D" rel="nofollow">单轴碰撞</a></p><p><img src="/img/bV7VeR" alt="单轴碰撞图" title="单轴碰撞图"></p><pre><code class="javascript">/**
* 单轴碰撞
* */
window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
const ball0 = new Ball();
const ball1 = new Ball();
// 定义ball0的属性
ball0.mass = 2;
ball0.x = 50;
ball0.y = canvas.height / 2;
ball0.vx = 1;
// 定义ball1的属性
ball1.mass = 1;
ball1.x = 300;
ball1.y = canvas.height / 2;
ball1.vx = -1;
(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
// 移动两个物体的位置
ball0.x += ball0.vx;
ball1.x += ball1.vx;
const dist = ball1.x - ball0.x;
// 碰撞检测
if (Math.abs(dist) < ball0.radius + ball1.radius) {
// 运用动量守恒计算碰撞后速度
const vxTotal = ball0.vx - ball1.vx;
ball0.vx = ((ball0.mass - ball1.mass) * ball0.vx + 2 * ball1.mass * ball1.vx) / (ball0.mass + ball1.mass);
ball1.vx = vxTotal + ball0.vx;
// 将速度加到两物体的位置上实现弹开
ball0.x += ball0.vx;
ball1.y += ball1.vx;
}
// 绘制两球
ball0.draw(context);
ball1.draw(context);
}());
};</code></pre><h2>双轴碰撞</h2><p>现实情况很少会出现单轴碰撞,如果两个轴上都有速度,处理起来会比较麻烦,把速度分解出来再代入动量守恒公式,这里运用到上一篇中关于坐标旋转的知识。 </p><p><img src="/img/bV7VgR" alt="双轴碰撞" title="双轴碰撞"> </p><p>基本思路:</p><ol><li>使用旋转公式,以其中一个物体为原点,旋转整个系统,将两物体的中心连线置为水平场景;</li><li>求出物体 x 轴上的速度;</li><li>使用动量守恒计算 x 轴上的碰撞后速度;</li><li>再旋转回来。</li></ol><p>示例是两个随机初始速度的球在空间内碰撞,碰到边界也会反弹,由于代码量较大,这里只截取部分核心代码:<br>注意:旋转是以 ball0 为原点进行的,也就是说旋转中的所有位置和速度都是相对于 ball0 的,所有回旋后的位置和速度需要转换成相对于相对区域位置。</p><p>完整示例:<a href="https://link.segmentfault.com/?enc=MKfgTMBNVuaUsxY3CwC6hw%3D%3D.CnjzUuVXJZyQJrFuG3j0V3j9xuGlgb37t8ve5qrv5Eb8yEGoT%2FqURRckoim%2FITJnnXSpoBiPDB82XW%2FRhQ%2FiZHzA3ZOgMINSUk9FmFNUTUo%3D" rel="nofollow">双轴碰撞</a></p><pre><code class="javascript">// 坐标旋转函数
function rotate(x, y, sin, cos, reverse) {
return {
x: (reverse) ? (x * cos + y * sin) : (x * cos - y * sin),
y: (reverse) ? (y * cos - x * sin) : (y * cos + x * sin),
};
}
// 检查碰撞
function checkCollision() {
const dx = ball1.x - ball0.x;
const dy = ball1.y - ball0.y;
const dist = Math.sqrt(dx ** 2 + dy ** 2);
// 基于距离的碰撞检测
if (dist < ball0.radius + ball1.radius) {
// 以ball0为中心点旋转
const angle = Math.atan2(dy, dx);
const sin = Math.sin(angle);
const cos = Math.cos(angle);
// ball0在中心点
const pos0 = {
x: 0,
y: 0,
};
// 依据ball1与ball0的相对距离计算旋转后的坐标(反向)
const pos1 = rotate(dx, dy, sin, cos, true);
// 旋转ball0的速度(反向)
const vel0 = rotate(ball0.vx, ball0.vy, sin, cos, true);
// 旋转ball1的速度(反向)
const vel1 = rotate(ball1.vx, ball1.vy, sin, cos, true);
// 计算相对速度
const vxTotal = vel0.x - vel1.x;
// 计算相撞后速度
vel0.x = ((ball0.mass - ball1.mass) * vel0.x + 2 * ball1.mass * vel1.x) / (ball0.mass + ball1.mass);
vel1.x = vxTotal + vel0.x;
// 计算相撞后位置
pos0.x += vel0.x;
pos1.x += vel1.x;
// 回旋位置
const pos0F = rotate(pos0.x, pos0.y, sin, cos, false);
const pos1F = rotate(pos1.x, pos1.y, sin, cos, false);
// 将相对ball0位置转换为相对区域位置
ball1.x = ball0.x + pos1F.x;
ball1.y = ball0.y + pos1F.y;
ball0.x += pos0F.x;
ball0.y += pos0F.y;
// 回旋速度
const vel0F = rotate(vel0.x, vel0.y, sin, cos, false);
const vel1F = rotate(vel1.x, vel1.y, sin, cos, false);
ball0.vx = vel0F.x;
ball0.vy = vel0F.y;
ball1.vx = vel1F.x;
ball1.vy = vel1F.y;
}
}</code></pre><h2>多物体碰撞</h2><p>加入多个物体,只是把两个物体的碰撞检测,改变成所有物体两两间做碰撞检测。</p><p>基本思路:</p><ol><li>先遍历一次物体集,让物体移动并处理边界碰撞;</li><li>再遍历一次物体集,两两物体做碰撞检测并求出碰撞后的速度和位置;</li><li>最后一次遍历物体集,绘制他们。</li></ol><p>依据这个思路我们得到了这样一个示例,球的质量、大小和初始速度都是随机的,碰撞代码基本和前面是一样的。 <br>完整示例:<a href="https://link.segmentfault.com/?enc=xl5zn9jC6A6Z2c7IUsb%2Fhw%3D%3D.gngW7WKAfl6wmYtlT0Ek4XyYlGQ6JK%2BEUNZVJQQNPeKxLfLKNeWSCcqnpIQAqB%2BIC6rnrGHcjosYdDZHGetJzvwE8aBMsSuknuEz0RA5xwg%3D" rel="nofollow">多物体碰撞(无处理重叠)</a> </p><p>仔细观察示例,会发现这里会出现一个问题:小球会重叠到一起并且无法分离。<br>这是由如下原因造成的:</p><ul><li>程序依照三个小球的速度移动他们;</li><li>程序检测 ball0 和 ball1,ball0 和 ball2,发现他们并没有碰撞;</li><li>程序检测 ball1 和 ball2。因为他们发生了碰撞,所以他们的速度和位置都要重新计算,然后弹开。但这不巧让 ball1 和 ball0 接触上了。然而,由于这一组合已经过检测,所以忽略了这一事实;</li><li>在下一轮循环中,程序依然按照他们的速度移动小球。这样就使得 ball0 和 ball1 更加靠近了;</li><li>现在程序检测到 ball0 和 ball1 碰撞了,重新计算速度和位置后,想要将他们分开,却会出现无法完全分开的情况,就卡到了一起。</li></ul><blockquote>【PS】为什么无法完全分开?因为我们分开两物体的做法是将新速度加到新位置上,如果旧位置已经重叠,那就永远无法分离了。</blockquote><p>改变分开两物体的处理办法就能解决这个问题,这里有个较为简单但不是很精确的办法:</p><ol><li>先求给出总速度绝对值;</li><li>再求出重叠部分的长度;</li><li>以相撞后速度在总速度的比例移开两个物体。</li></ol><p>完整示例:<a href="https://link.segmentfault.com/?enc=SoXX1LoxDuVhWunY9Gl3Xw%3D%3D.jQuNuSxS8AH%2BL82D6xbN26kuqpE8rXxIRmtWdrF3an%2BsCruWtRGvUY3nR0xeyLhYFE2mY4oB1ZHJKDaBzT0qi9VNyMh7JpMP3DT5ItcvjBg%3D" rel="nofollow">多物体碰撞</a><br>改造后核心代码如下:</p><pre><code class="javascript">function checkCollision(ball0, ball1) {
const dx = ball1.x - ball0.x;
const dy = ball1.y - ball0.y;
const dist = Math.sqrt(dx ** 2 + dy ** 2);
// 基于距离的碰撞检测
if (dist < ball0.radius + ball1.radius) {
// 以ball0为中心点旋转
const angle = Math.atan2(dy, dx);
const sin = Math.sin(angle);
const cos = Math.cos(angle);
// ball0在中心点
const pos0 = {
x: 0,
y: 0,
};
// 依据ball1与ball0的相对距离计算旋转后的坐标(反向)
const pos1 = rotate(dx, dy, sin, cos, true);
// 旋转ball0的速度(反向)
const vel0 = rotate(ball0.vx, ball0.vy, sin, cos, true);
// 旋转ball1的速度(反向)
const vel1 = rotate(ball1.vx, ball1.vy, sin, cos, true);
// 计算相对速度
const vxTotal = vel0.x - vel1.x;
// 计算相撞后速度
vel0.x = ((ball0.mass - ball1.mass) * vel0.x + 2 * ball1.mass * vel1.x) / (ball0.mass + ball1.mass);
vel1.x = vxTotal + vel0.x;
// 计算出绝对速度和重叠量,分离避免物体重叠
const absV = Math.abs(vel0.x) + Math.abs(vel1.x);
const overlap = (ball0.radius + ball1.radius) - Math.abs(pos0.x - pos1.x);
pos0.x += vel0.x / absV * overlap;
pos1.x += vel1.x / absV * overlap;
// 回旋位置
const pos0F = rotate(pos0.x, pos0.y, sin, cos, false);
const pos1F = rotate(pos1.x, pos1.y, sin, cos, false);
// 将相对ball0位置转换为相对区域位置
ball1.x = ball0.x + pos1F.x;
ball1.y = ball0.y + pos1F.y;
ball0.x += pos0F.x;
ball0.y += pos0F.y;
// 回旋速度
const vel0F = rotate(vel0.x, vel0.y, sin, cos, false);
const vel1F = rotate(vel1.x, vel1.y, sin, cos, false);
ball0.vx = vel0F.x;
ball0.vy = vel0F.y;
ball1.vx = vel1F.x;
ball1.vy = vel1F.y;
}
}</code></pre>
【Pride】当年那些风骚的跨域操作
https://segmentfault.com/a/1190000014223524
2018-04-07T22:38:27+08:00
2018-04-07T22:38:27+08:00
calimanco
https://segmentfault.com/u/calimanco
65
<h2>前言</h2><p>现在 cross-origin resource sharing(跨域资源共享,下简称 CORS)已经十分普及,算上 IE8 的不标准兼容(XDomainRequest),各大浏览器基本都已支持,当年为了前后端分离、iframe 交互和第三方插件开发而头疼跨域是时代已经过去,但当年为了跨域无所不用其极的风骚操作却依然值得学习。 <br>本篇文章不是从实用的角度考量这些旧时代的跨域手段,而是更偏向理论的阐述,并引发对浏览器安全的思考,因为跨域实际上也是各类攻击的核心。 <br>本人个人能力有限,欢迎大牛一起讨论,批评指正。</p><h2>同源策略</h2><p>1995年,同源政策由 Netscape 公司引入浏览器。目前,所有浏览器都实行这个安全策略。 <br>核心是确保不同源提供的文件(资源)之间是相互独立的。换句话说,只有当不同的文件脚本是由相同的域、端口、HTTP 协议提供时,才没有特殊的限制。特殊限制可以细分为两个方面:</p><ul><li>对象访问限制:主要体现在 iframe,如果父子页面的源是不同的,那就不可以访问对方的 DOM 方法和属性(包括 Cookie、LocalStorage 和 IndexDB 等)。不同来源便抛出异常。</li><li>网络访问限制:主要体现在 AJAX 请求,如果发起的请求目标源与当前页面不同,浏览器就会限制了发起跨站请求,或拦截返回的请求。</li></ul><p><strong>一个表格看懂什么是同源?</strong></p><table><thead><tr><th>origin(URL)</th><th>result</th><th>reason</th></tr></thead><tbody><tr><td><code>http://example.com</code></td><td>success</td><td>协议,域名和端口号80均相同</td></tr><tr><td><code>http://example.com:8080</code></td><td>fail</td><td>端口不同</td></tr><tr><td><code>https://example.com</code></td><td>fail</td><td>协议不同</td></tr><tr><td><code>http://sub.example.com</code></td><td>fail</td><td>域名不同</td></tr></tbody></table><p><strong>至于为什么说这是个安全策略?</strong> <br>这个就要提到 cookie-session 机制,详解 cookie-session 机制请见本人另一篇文章(<a href="https://segmentfault.com/a/1190000022322405">传送门</a>)。</p><p>如果让浏览器向不同源发起请求,会造成很大的危险。比如用户登录了银行的网站 A,也就是说 A 站已经在浏览器留下了 cookie,这时候用户又访问了 B 站,如果能在 B 站页面上发起A站的请求,就相当于 B 站可以冒充用户,在 A 站为所欲为。 <br>由此可见,"同源策略"是必需的,否则 cookie 可以共享,互联网就毫无安全可言了。</p><h2>跨域方案</h2><p>同源策略提出的时代还是传统 MVC 架构(jsp,asp)盛行的年代,那时候的页面靠服务器渲染完成了大部分填充,内容也比较简单,开发者也不会维护独立的 API 工程,所以其实跨域的需求是比较少的。 <br>新时代前后端的分离和第三方 JSSDK 的兴起,我们才开始发现这个策略虽然大大提高了浏览器的安全性,但有时很不方便,合理的用途也受到影响。比如:</p><ol><li>独立的 API 工程部署为了方便管理使用了独立的域名;</li><li>前端开发者本地调试需要使用远程的 API;</li><li>第三方开发的 JSSDK 需要嵌入到别人的页面中使用;</li><li>公共平台的开放 API。</li></ol><p>于是乎,在没有标准规范的时代,如何解决这些问题的跨域方案就被纷纷提出,可谓百家争鸣,其中不乏令人惊叹的骚操作,这样的极客精神依然值得我们敬佩和学习。</p><h3>JSON-P</h3><p>JSON-P 是各类跨域方案中流行度较高的一个,现在在某些要兼容旧浏览器的环境下还会被使用,著名的 jQuery 也封装其方法。请勿见名知义,名字中的 P 是 padding “带填充”的意思,这个方法在通信过程中使用的并不是普通的 json,而是“自带填充功能的 JavaScript 脚本”。 <br>如何理解“自带填充功能的 JavaScript 脚本”,看看下面的例子或许比较简单,如果一个 js 文件里这样写并被引入,则全局下就会有 data 对象,也就是说利用 js 脚本的引入和解析可以用来传递数据,如果把 js 脚本换成函数运行命令岂不是可以调用全局函数了。这就是 JSON-P 方法的核心思想,它填充的是全局函数的数据。</p><pre><code class="javascript">var data = {
a: 1,
b: 2
}</code></pre><blockquote>【PS】不只是<code><script></code>标签,所有可以使用 src 属性的标签都可以不受同源策略限制,但只能发起get请求。</blockquote><p><strong>原理及流程</strong></p><ol><li>先定义好回调函数,也就是引入的 js 脚本中要调用的函数;</li><li>新建<code><script></code>标签,将标签插入页面浏览器便会发起 get 请求;</li><li>服务器根据请求返回 js 脚本,其中调用了回调函数。</li></ol><p><img src="/img/bV7Qk6" alt="jsonp流程图" title="jsonp流程图"></p><pre><code class="javascript">// 定义回调函数
function getTheAnimal(data){
var myAnimal = data.animal;
}
// 新建标签
var script = document.createElement("script");
script.type = "text/javascript";
// 常用的在url参数部分跟服务器约定号回调函数名
script.src = "http://demo.com/animal.json?callback=getTheAnimal";
document.getElementByTagName('head')[0].appendChild(script);</code></pre><p><strong>总结</strong> </p><p>优点:</p><ul><li>简单,有现成的工具库(jQuery)支持;</li><li>支持上古级别的浏览器(IE8-)。</li></ul><p>缺点:</p><ul><li>只能是 GET 方法;</li><li>受浏览器 URL 最大长度 2083 字符限制;</li><li>无法调试,服务器错误无法检测到具体原因;</li><li>有 CSRF 的安全风险;</li><li>只能是异步,无法同步阻塞;</li><li>需要特殊接口支持,不能基于 REST 的 API 规范。</li></ul><h3>子域名代理</h3><p>这个方法实际上是利用浏览器允许 iframe 内的页面只要是跟父页面是同个一级域名下,就能被父页面修改和调用的特点。也许你会疑问,上面讲同源策略的表格中很明确二级域名不同也是算不同源,这岂不矛盾了? <br>这其实不矛盾,如果正常操作确实会被同源策略限制,但浏览器的<code>document.domain</code>允许网站将主机部分更改为原始值的后缀。这意味着,寄放在 sub.example.com 的页面可以将它的源设置为 example.com,但是并不能将其设置为 alt.example.com 或 google.com。</p><blockquote>【PS】这里有一个细节,父子页面均要设置<code>document.domain</code>才能被互相访问,单一一个是无法跨域的。<code>document.domain</code>的特点:只能设置一次;只能更改域名部分,不能修改端口号和协议;重置源的端口为协议默认端口。</blockquote><p><strong>原理及流程</strong></p><ol><li>新建一个子域,比如 api.demo.com(页面在主域名 demo.com 下);</li><li>子域下需要一个代理文件 proxy.html,设置其<code>document.domain = 'demo.com'</code>,并可以包含发起 ajax 的工具;</li><li>所有 API 地址都是在 api.demo.com;</li><li>把需要发请求的主域页面设置其<code>document.domain = 'demo.com'</code>;</li><li>新建 iframe 标签链接到代理页;</li><li>当 iframe 内的子页面就绪时,父页面就可以使用子页面发起 ajax 请求。</li></ol><p><img src="/img/bV7Qle" alt="子域名代理流程图" title="子域名代理流程图"></p><pre><code class="html">// 最简单的代理文件proxy.html
<!DOCTYPE html>
<html>
<script>
document.domain = 'demo.com';
</script>
<script src="jquery.min.js"></script>
</html></code></pre><pre><code class="javascript">// 新建iframe
var iframe = document.createElement('iframe');
// 链接到代理页
iframe.src = 'http://api.demo.com/proxy.html';
// 代理页就绪时触发
iframe.onload = function(){
// 由于代理页已经和父页设置了相同的源,父的脚本可以调用代理页的ajax工具;
// 由于是在子页面发起,其请求地址就跟子页面同源了。
iframe.contentWindow.jQuery.ajax({
method: 'POST',
url: 'http://api.demo.com/products',
data: {
product: id,
},
success: function(){
document.body.removeChild(iframe);
/*...*/
}
})
}
document.getElementsByTagName('head')[0].appendChild(iframe);</code></pre><p><strong>总结</strong> </p><p>优点:</p><ul><li>可以发送任意类型的请求;</li><li>可以使用基于 REST 的 API 规范。</li></ul><p>缺点:</p><ul><li>不太适合第三方 API,给第二方使用较麻烦;</li><li>iframe 对浏览器性能影响较大;</li><li>无法使用非协议默认端口的 API。</li></ul><h3>模拟 form 表单</h3><p>form 表单的 target 属性可以指定一个 iframe,使主页面不跳转,而 iframe 内跳转,所以这个方法的核心就是利用表单提交,并在 iframe 中获取数据。 <br>要访问 iframe 内外页面互访也是必须设置同源,这点与子域代理是相似的;而 iframe 内回调父页面,又与 JSON-P 相似,可以说是两个思路的合体版。 <br>form 表单提交后返回的是页面,所以与 JSON-P 不同的是,返回的是包含了自带填充功能的 JavaScript 脚本的页面,说起来有点绕,简单来说就是把 JSON-P 返回的脚本放到一个 html 页面里自运行。 <br>相比子域代理的方法,它不需要代理页。</p><blockquote>【PS】form 表单提交的特点就是会导致整个页面跳转,返回数据是在新的页面上,这样自然不会产生跨域的问题。</blockquote><p><strong>原理及流程</strong></p><ol><li>新建一个子域,比如 api.demo.com(页面在主域名 demo.com 下);</li><li>所有 API 地址都是在 api.demo.com;</li><li>把需要发请求的主域页面设置其<code>document.domain = 'demo.com'</code>;</li><li>先定义好父页面上的回调函数;</li><li>新建 iframe 标签并指定名字;</li><li>新建表单 form 标签,指定 target 为刚才的 iframe,并添加数据;</li><li>提交表单,iframe 内跳转,其中自运行脚本调用了父页面的回调函数。</li></ol><p><img src="/img/bV7Qlk" alt="模拟form表单流程图" title="模拟form表单流程图"></p><pre><code class="javascript">// 新建并隐藏iframe
var frame = document.createElement('iframe');
iframe.name = 'post-review';
frame.style.display = 'none';
// 新建表单
var form = document.createElement('form');
form.action = 'http://api.demo.com/products';
form.method = 'POST';
form.target = 'post-review';
// 添加数据
var score = document.createElement('input');
score.name = 'score';
score.value = '5';
// 添加数据
var message = document.createElement('input');
message.name = 'message';
message.value = 'hello world';
// 把数据加到表单
form.appendChild(score);
form.appendChild(message);
// 渲染iframe和表单
document.body.appendChild(frame);
document.body.appendChild(form);
// 提交表单发起请求
form.submit();
// 完成清理元素
document.body.removeChild(form);
document.body.removeChild(frame);</code></pre><pre><code class="html">// 最简单返回html
<!DOCTYPE html>
<html>
<script>
document.domain = 'demo.com';
window.parent.jsonpCallback('{"status":"success"}');
</script>
</html></code></pre><p><strong>总结</strong> </p><p>由于这个方法是 JSON-P 与子域名代理的结合版,可以说即拥有两者的优点,也保留了两者一些缺点。 </p><p>优点:</p><ul><li>可以发送任意类型的请求;</li><li>不需要代理页;</li><li>支持上古级别的浏览器(IE8-)。</li></ul><p>缺点:</p><ul><li>不太适合第三方 API,给第二方使用较麻烦;</li><li>iframe 对浏览器性能影响较大;</li><li>无法使用非协议默认端口的 API;</li><li>需要特殊接口支持,不能基于 REST 的 API 规范。</li></ul><h3>window.name</h3><p>这方法利用了<code>window.name</code>的特性:一旦被赋值后,当窗口被重定向到一个新的URL时不会改变它的值。这一行为使得不同域的特定文档可以读取该属性值,因此可以绕过同源策略并使跨域消息通信成为可能。</p><blockquote>【PS】例子里演示的是发起 get 请求,只要把请求地址直接写到 src 里就行了。如果想要发起其他类型的请求,可以类比采用模拟的 form 的方式进行改造。</blockquote><p><strong>原理及流程</strong></p><ol><li>新建 iframe,使用 iframe 访问一个非同源的地址(发请求);</li><li>当页面加载完成后,iframe 内脚本给 window.name 属性赋值,这时父页面还是不能读取到子页面的属性(因为不同源);</li><li>iframe 自身回调到一个同源的地址(可能只是个空白页),这时候 window.name 没有改变;</li><li>父页面顺利读取 window.name 的值。</li></ol><p><img src="/img/bV7Qln" alt="window.name流程图" title="window.name流程图"></p><pre><code class="javascript">// 新建iframe
var iframe = document.createElement('iframe');
var body = document.getElementByTagName('body');
// 隐藏iframe并链接地址
iframe.style.display = 'none';
iframe.src = 'http://api.demo.com/server.html?id=1';
// 因为需要两次跳转,这里有个完成标记
var done = fasle;
// 这里会触发至少两次,一次由于非同源是取不到值的。
iframe.onload = iframe.onreadystatechange = function(){
if(! this.readyState && (iframe.readyState !== 'complete' || done)){
return;
}
console.log('Listening');
var name = iframe.contentWindow.name;
if(name){
console.log(iframe.contentWindow.name);
done = true;
}
};
body.appendChild(iframe);</code></pre><pre><code class="html">// 最简单返回html
<!DOCTYPE html>
<html>
<script>
function init(){
window.name = 'hello';
window.location = 'http://demo.com/empty.html'
}
</script>
<body onload="init();"></body>
</html></code></pre><p><strong>总结</strong> </p><p>优点:</p><ul><li>可以发送任意类型的请求;</li><li>不需要设置子域名。</li></ul><p>缺点:</p><ul><li>iframe 对浏览器性能影响较大;</li><li>需要特殊接口支持,不能基于 REST 的 API 规范;</li><li>每当你想要获取一条新的消息时都不得不发起两次网络请求,网络成本大;</li><li>需要准备空白页,对它的访问是无意义的,影响流量统计。</li></ul><h3>window.hash</h3><p>这个方法利用了 location 的特性:不同域的页面,可以写不可读。而只改变哈希部分(井号后面)不会导致页面跳转。也就是可以让父、子页面互相写对方的 location 的哈希部分,进行通讯。</p><p><strong>原理及流程</strong></p><ol><li>新建 iframe,使用 iframe 访问一个非同源的地址(发请求),参数里带上父页面 url;</li><li>当页面加载完成后,iframe 内脚本设置父页面的 url 并在哈希部分带上数据;</li><li>父页面的脚本循环检查哈希值的变化,如果检查到有值就取值并清空哈希值;</li></ol><blockquote>【PS】父页面会循环检查哈希是否改变来读取值,因为这种降级方案的使用环境一般是不会有 hashchange 事件的。演示里是最简单的 get 方法,如果想要发起其他类型的请求,可以类比采用模拟的 form 的方式进行改造,但记住不要丢失父页面的 url。</blockquote><p><img src="/img/bV7Qls" alt="window.hash流程图" title="window.hash流程图"></p><pre><code class="javascript">// 获取当前url
var url = window.location.href;
// 新建iframe
var iframe = document.createElement('iframe');
// 隐藏iframe并设置链接,把当前url带上
iframe.style.display = 'none';
iframe.src = 'http://api.demo.com/server.html?id=1&url=' + encodeURIComponent(url);
var body = document.getElementByTagName('body')[0];
body.appendChild(iframe);
// 循环监听处理
var listener = function(){
// 读取
var hash = location.hash;
// 还原
if(hash && hash !== '#'){
console.log(hash.replace('#', ''));
window.loacation.href = url + '#';
}
// 继续监听
setTimeout(listener, 100);
};
listener();</code></pre><pre><code class="html">// 最简单返回html
<!DOCTYPE html>
<html>
<script>
function init(){
// 剪裁出父页面的url
var parentUrl = '';
var url = window.location.href;
var str = url.split('?')[1].replace('?', '');
strs = str.split("&");
for(var i = 0; i < strs.length; i ++) {
if(strs.split("=")[0] === 'url'){
parentUrl = strs.split("=")[1];
}
}
// 设置到父页面上
window.parent.location = decodeURIComponent(parentUrl) + '#helloworld';
}
</script>
<body onload="init();"></body>
</html></code></pre><p><strong>总结</strong> </p><p>优点:</p><ul><li>可以发送任意类型的请求;</li><li>不需要设置子域名。</li></ul><p>缺点:</p><ul><li>iframe 对浏览器性能影响较大;</li><li>需要特殊接口支持,不能基于 REST 的 API 规范;</li><li>循环检查哈希需要消耗性能;</li><li>返回数据受浏览器 URL 最大长度 2083 字符限制。</li></ul><h2>现代的标准</h2><p>W3C 的标准化跨域方案,让现代浏览器跨域已经不是什么复杂的事。这部分网上资料已经很多,这里就只是简单介绍。</p><h3>CORS</h3><p>CORS 是一个 W3C 标准,全称是"跨域资源共享"(Cross-origin resource sharing)。 <br>它允许浏览器向跨源服务器,发出 XMLHttpRequest 请求,从而克服了 AJAX 只能同源使用的限制。 </p><p><a href="https://link.segmentfault.com/?enc=CHT2uk7rHD%2F%2BMU6CxkPWsw%3D%3D.jsshACWpNGSNfkG3%2FjmsyNYsQo4Ycsmkubft9fJttH%2BqC%2FnSTW2GIZcXsQdL2ZszqnYaMLKBtyZ%2BpCzdIbiF7IrLJMeNjBZuywPzKPDY6XM%3D" rel="nofollow">CORS参考文档</a> <br><a href="https://link.segmentfault.com/?enc=dNjNBcbp%2FzNun2M4kf7R%2BA%3D%3D.Yx1beXmT2VjEL2d2CiMIRYIrjvR9FqVe%2FoElqBdI7PmKy98ZZTmYwY4wQW44qlXXjycisNPgNYrrS8GsTqNAEw%3D%3D" rel="nofollow">跨域资源共享 CORS 详解</a></p><h3>postMessage</h3><p>H5 的 window.postMessage 为浏览器带来了一个安全的。基于事件的消息api。 <br>只要是 window 对象,基本都可以使用这个方法,也就是说 window.name、window.hash 这类风骚的操作都已成为降级方案。 </p><p><a href="https://link.segmentfault.com/?enc=l2HXQTeiYIX5y3JWNgV73Q%3D%3D.W2nnl0Lo2wFJNy2m3dAgeN6SRAJGzJpX9T%2BwxkgD3hTAGkYJkmHdGSIJeHkxk%2FZB%2Br0ni0Jv%2BW9BWn0sXutCy2gQUtoYtk1a4knWikYqa4w%3D" rel="nofollow">postMessage参考文档</a></p><h2>安全问题</h2><p>上述的各类非标准的骚操作,都算是对同源策略的破解办法,在方便开发者完成跨域目的的同时,各类恶意的攻击者也自然会利用这些方案为非作歹。 <br>其中子域名代理的风险最低,因为需要服务器设置特定的子域名,也就是已经是两个源的协商结果,一般黑客是难以模拟的。 <br>风险最高的要算 JSON-P 的方案,因为这是任何客户端都可随意使用的办法,CSRF 攻击的核心也是利用了特定标签的跨域性发起请求,所以 JSON-P 最好用在无用户状态的低安全性 API 上。</p>
【Fes】基于canvas的前端动画/游戏入门(六)
https://segmentfault.com/a/1190000014113090
2018-04-01T00:51:24+08:00
2018-04-01T00:51:24+08:00
calimanco
https://segmentfault.com/u/calimanco
4
<h2>坐标旋转</h2><p>模拟场景:已知一个中心点(centerX, centerY),旋转前物体ball(x1, y1),旋转弧度(rotation);求旋转后物体(x2, y2)。(如下图)</p><p><img src="/img/bV7nCv" alt="场景图" title="场景图"></p><p>坐标旋转就是说围绕某个点旋转坐标,我们要依据旋转的角度(弧度),计算出物体旋转前后的坐标,一般有两种方法:</p><h3>简单坐标旋转</h3><p>灵活运用前章节的三角函数知识可以很容易解决,基本思路:</p><ol><li>计算物体初始相对于中心点的位置;</li><li>使用 atan2 计算弧度 angle;</li><li>使用勾股定理计算半径 radius;</li><li>angle+rotation 后使用 cos 计算旋转后 x 轴位置,用 sin 计算旋转后 y 轴位置。</li></ol><p>下面是示例是采用这种方法的圆周运动,其中 vr 为 ball 相对于中心点的弧度变化速度,由于旋转半径是固定的,所以没有在动画循环里每次都获取。<br>完整示例:<a href="https://link.segmentfault.com/?enc=NHbAtge76UGKRsNaLmWMAQ%3D%3D.GzP4DckcC0DVr5jfPDN40kfj%2BlJYPtbyWsGLYRYW5a%2B7abk2uh1BiVyq4CPgHD4FmFgHQ31tXtEZIjN1AXtHPqs9XB3arvvGnO8lEnH4k%2FQ%3D" rel="nofollow">简单坐标旋转演示</a></p><pre><code class="javascript">/**
* 简单坐标旋转演示
* */
window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
const ball = new Ball();
ball.x = 300;
ball.y = 200;
// 弧度变化速度
const vr = 0.05;
// 中心点位置设定在画布中心
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
// ball相对与中心点的距离
const dx = ball.x - centerX;
const dy = ball.y - centerY;
// ball相对与中心点的弧度
let angle = Math.atan2(dy, dx);
// 旋转半径
const radius = Math.sqrt(dx ** 2 + dy ** 2);
(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
ball.x = centerX + Math.cos(angle) * radius;
ball.y = centerY + Math.sin(angle) * radius;
angle += vr;
ball.draw(context);
}());
};</code></pre><h3>坐标旋转公式</h3><p>上面的方法对于单个物体来说是很合适的,特别是角度和半径只需计算一次的情况。但是在更动态的场景中,可能需要旋转多个物体,而他们相对于中心点的位置各不相同。所以每一帧都要计算每个物体的距离、角度和半径,然后把 vr 累加在角度上,最后计算物体新的坐标。这样显然不会是优雅的做法。<br>理想的做法是用数学方法推导出旋转角度与位置的关系,直接每次代入计算即可。推导过程如下图:</p><p><img src="/img/bV7nCC" alt="推导过程" title="推导过程"></p><p>其实推导过程不重要,我们只需要记住如下两组公式,其中 dx2 和 dy2 是 ball 结束点相对于中心点的距离,所以得到物体结束点,还要分别加上中心点坐标。</p><pre><code class="javascript">// 正向旋转
dx2 = (x1 - centerX) * cos(rotation) - (y1 - centerY) * sin(rotation)
dy2 = (y1 - centerY) * cos(rotation) + (x1 - centerX) * sin(rotation)
// 反向旋转
dx2 = (x1 - centerX) * cos(rotation) + (y1 - centerY) * sin(rotation)
dy2 = (y1 - centerY) * cos(rotation) - (x1 - centerX) * sin(rotation)</code></pre><p>下面是示例是采用这种方法的圆周运动,其中 dx1 和 dy1 是 ball 起始点相对于中心点的距离,dx2 和 dy2 是 ball 结束点相对于中心点的距离。<br>完整示例:<a href="https://link.segmentfault.com/?enc=V%2Fg22j3EbBBJv5VYvZtGIQ%3D%3D.QS5NN8kxxo20TQ9cy35Q6Fy4bFNHgF1ah0OCM9j96Flyg5qGKAJSDGryOehhwtdU2cbjHzEDEQjRKFTiL4gESO8OOuAk41lPagihx56pnlE%3D" rel="nofollow">高级坐标旋转演示</a></p><pre><code class="javascript">/**
* 高级坐标旋转演示
* */
window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
const ball = new Ball();
ball.x = 300;
ball.y = 200;
// 弧度变化速度
const vr = 0.05;
// 中心点位置设定在画布中心
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
// 由于vr是固定的可以先计算正弦和余弦
const cos = Math.cos(vr);
const sin = Math.sin(vr);
(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
// ball相对与中心点的距离
const dx1 = ball.x - centerX;
const dy1 = ball.y - centerY;
// 代入公式求出ball在结束相对与中心点的距离
const dx2 = dx1 * cos - dy1 * sin;
const dy2 = dy1 * cos + dx1 * sin;
// 求出x2,y2
ball.x = centerX + dx2;
ball.y = centerY + dy2;
ball.draw(context);
}());
};</code></pre><h2>斜面反弹</h2><p>前面的章节中我们介绍过越界的一种处理办法是反弹,由于边界是矩形,反弹面垂直或水平,所以可以直接将对应轴的速度取反即可,但对于非垂直或水平的反弹面这种方法是不适用的。<br>坐标旋转常见的应用就是处理这种情况,将不规律方向的复杂问题简单化。<br>基本思路:(旋转前后如图)</p><ol><li>使用旋转公式,旋转整个系统,将斜面场景转变为水平场景;</li><li>在水平场景中处理反弹;</li><li>再旋转回来。</li></ol><p><img src="/img/bV7nCD" alt="旋转前后系统" title="旋转前后系统"></p><p>示例是一个球掉落到一条线上,球受到重力加速度影响下落,碰到斜面就会反弹,每次反弹都会损耗速度。 <br>完整示例:<a href="https://link.segmentfault.com/?enc=VpyQ6SBlvqJJ2OjREr08hw%3D%3D.61wOfjJ7JZaok%2F3A7L3ZsfquKf8I7gvbt8PsjHBW9BgrNaL1GE5q%2BLyefRPfWB%2B9sooocWav3GRZVAfmxfSZwNc3WDFpKpZQQZIK5S2GRDomb%2BBslygMRD3cQ8YVLhMt" rel="nofollow">斜面反弹示例</a></p><pre><code class="javascript">window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
const ball = new Ball();
// line类构造函数参数(开始点x轴坐标,开始点y轴坐标,结束点x轴坐标,结束点y轴坐标)
const line = new Line(0, 0, 500, 0);
// 设置重力加速度
const gravity = 0.2;
// 设置反弹系数
const bounce = -0.6;
ball.x = 100;
ball.y = 100;
line.x = 0;
line.y = 200;
line.rotation = 10 * Math.PI / 180;
const cos = Math.cos(line.rotation);
const sin = Math.sin(line.rotation);
(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
ball.vy += gravity;
ball.x += ball.vx;
ball.y += ball.vy;
// 获取ball与line的相对位置
let x1 = ball.x - line.x;
let y1 = ball.y - line.y;
// 旋转坐标系(反向)
let y2 = y1 * cos - x1 * sin;
// 依据旋转值执行反弹
if (y2 > -ball.radius) {
// 旋转坐标系(反向)
const x2 = x1 * cos + y1 * sin;
// 旋转速度(反向)
const vx1 = ball.vx * cos + ball.vy * sin;
let vy1 = ball.vy * cos - ball.vx * sin;
y2 = -ball.radius;
vy1 *= bounce;
// 将所有东西回转(正向)
x1 = x2 * cos - y2 * sin;
y1 = y2 * cos + x2 * sin;
ball.vx = vx1 * cos - vy1 * sin;
ball.vy = vy1 * cos + vx1 * sin;
ball.x = line.x + x1;
ball.y = line.y + y1;
}
ball.draw(context);
line.draw(context);
}());
};</code></pre>
【Fes】基于canvas的前端动画/游戏入门(五)
https://segmentfault.com/a/1190000014019499
2018-03-27T16:49:33+08:00
2018-03-27T16:49:33+08:00
calimanco
https://segmentfault.com/u/calimanco
4
<h2>重力加速度</h2><blockquote>【科普】重力加速度是一个物体受重力作用的情况下所具有的加速度。也叫自由落体加速度,用 g 表示。方向竖直向下。通常指地面附近物体受地球引力作用在真空中下落的加速度,记为 g。为了便于计算,其近似标准值通常取为 980 厘米/秒的二次方或 9.8 米/秒的二次方。</blockquote><p>真实的物体是有质量的,所以其重力加速度是由于重力产生,而我们计算机中的抽象物体并没有质量,所有也不存在重力一说,我们这里说的重力加速度只是借用了物理上的概念,实际上是人为定义的一个方向指向 y 轴正半轴的加速度。<br>其实实现起来很简单,就是设定一个为正的加速度,每次绘制都加到物体的 y 轴速度上。<br>下面的示例是一个 ball,它会受重力加速度 gravity 而自动下落,你可以使用键盘的上、下、左、右改变其四个方向上的加速度。核心代码如下: <br>完整示例:<a href="https://link.segmentfault.com/?enc=T69Gh4yT18BZLFzjoToN1w%3D%3D.tUDSjwODxvb23ZV8Vw6Yc8f3I%2F1aRecFiH%2FG8s8h3%2FtupKNITJHspVcJl2wQHZ1O6%2FsDEGrHU%2F8781axhviy8l5q%2Fz5IIaDggdGo%2FSxiTKM%3D" rel="nofollow">重力加速度演示</a></p><pre><code class="javascipt">(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
vx += ax;
vy += ay;
vy += gravity;
ball.x += vx;
ball.y += vy;
ball.draw(context);
}());</code></pre><h2>模拟摩擦力</h2><blockquote>【科普】阻碍物体相对运动(或相对运动趋势)的力叫做摩擦力。摩擦力的方向与物体相对运动(或相对运动趋势)的方向相反。一个物体在另一个物体表面发生滑动时,接触面间产生阻碍它们相对运动的摩擦,称为滑动摩擦。滑动摩擦力的大小与接触面的粗糙程度的大小和压力大小有关。压力越大,物体接触面越粗糙,产生的滑动摩擦力就越大。</blockquote><p>之前的例子中有一些非常不自然的场景,比如<a href="https://link.segmentfault.com/?enc=%2BeDHNtXJG%2FABdyLFLYk8lw%3D%3D.wYVp26zeJnwCh8KubN86X6WNsToIUdfL4c977fRNNL9n6qmJ9VIqUMyj3gH0Xmpbdp%2F1Px628Z5YeECnOmmHFCCXTVNncRH1ZE2oocgIP4Y%3D" rel="nofollow">跟随鼠标的箭头</a>,由于加速度始终存在,导致运动永远不可能停止,而在现实中(太空例外),由于存在各种摩擦力的关系,这是不可能发生的情况。 <br>计算机中没有摩擦力,我们只是借鉴物理中的概念模拟一个模拟摩擦力,请记住这个并不是物理意义的力。</p><blockquote>【定义】模拟摩擦力是人为规定的值,定义和滑动摩擦力相似都与运动方向相反的量,将物体速度削减到 0 为止,不会改变运动方向。</blockquote><p>注意:根据定义只能将物体的速率减去与一定大小的值,而不能分别在 x、y 轴上减小速度向量。如果物体正沿着某个角度运动,就会出现物体在某条轴的速度降为零,而继续在另一条轴上运动的奇怪现象。</p><h3>正确做法</h3><p>我们将模拟摩擦力用变量 friction 表示,示例会演示随机速度的 ball 从运动到停止的过程,核心代码如下,基本思路:</p><blockquote>【科普】速度和速率是两个不同的概念。速度是矢量,具有大小和方向;速率则纯粹指物体运动的快慢,是标量,没有方向。</blockquote><ol><li>将 vx 与 vy 平方后求和,再开方求出速率;通过计算 Math.atan2(vy, vx) 获得角度;</li><li>从速率减去模拟摩擦力,但不要让速率变为负数;</li><li>通过正余弦函数将和速率分解为 x 轴和 y 轴上的速度。</li></ol><p>完整示例:<a href="https://link.segmentfault.com/?enc=GDAu0xJ%2BRAv%2FvbELbbDh%2BA%3D%3D.qW0sMrnyiJM3bKvlwSNLdujLTJDzlQlI00La08PlT9MtVc%2BPTVeXeBu3j6qHGByUOHw7Z1kCBO%2Ft%2F3Cl7z%2B1zFmxZUIYgufUX7SwQLoEpLA%3D" rel="nofollow">模拟摩擦力正确计算</a></p><pre><code class="javascript">(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
// 先求速率
let speed = Math.sqrt(vx ** 2 + vy ** 2);
// 算出角度
const angle = Math.atan2(vy, vx);
// 判断运动是否停止
if (speed > friction) {
// 没有停止则减去模拟摩擦力
speed -= friction;
} else {
speed = 0;
}
// 重新分解为x轴和y轴上的速度
vx = Math.cos(angle) * speed;
vy = Math.sin(angle) * speed;
ball.x += vx;
ball.y += vy;
ball.draw(context);
}());</code></pre><h3>简便做法</h3><p>正确的做法十分繁琐,是个合成分解再合成的过程,这样对计算资源的消耗是比较大的,但我们也许并不需要这么精确的做法,只要每次将各个方向的速度乘以一个 0~1 之间的数就能简单模拟出摩擦力的效果。因此我们定义了模拟摩擦力系数。</p><blockquote>【定义】模拟摩擦力系数是人为规定的值,会在物体运动时不断比例减少各个方向上的速度,使各个方向的速度无限接近于 0。</blockquote><p>示例由上面的正确做法改造而来,friction 被定义为模拟摩擦力系数,指为 0.9,只要运动都将 x 轴和 y 轴方向的速度乘以这个值即可,减少了大量操作。核心代码如下: <br>完整示例:<a href="https://link.segmentfault.com/?enc=VLEBb9QvJBu5DvZX0K6hBw%3D%3D.dvrVuYIyTKTJ09j1xd45%2Fae2eLiNa8hyflng4CiTOAA8lrFNObXuXQxlIF6ePXU3lmE%2FbWawtDXY6jlwPWj%2FIbE7HP1ryFmCl30RgsGiSRQ%3D" rel="nofollow">模拟摩擦力正确计算</a> <br>注意:这里有一个细节,速度不断乘以系数会导致速度无限接近但不等于 0,为了避免做无意义的计算,可以先判断速度是否已经小到肉眼不可见的值,以提高性能。</p><pre><code class="javascript">(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
// 判断速度大小以减少不必要的计算
if (Math.abs(vx) > 0.001) {
// 减少速度
vx *= friction;
ball.x += vx;
}
if (Math.abs(vy) > 0.001) {
vy *= friction;
ball.y += vy;
}
ball.draw(context);
}());</code></pre><h2>回顾前面的示例</h2><ul><li><a href="https://link.segmentfault.com/?enc=gCrxXUK%2FMhbV5BY4h7jHZQ%3D%3D.WxbfmJFHItvVhnOiaeDcjP8Dqsn%2BCygC4oojn3dGJ6VxJGr9Gg7kRsea3q%2F9SoPRae%2Bgv1Ir0Ut06YBrL95s20JIy8ATzQ9gg3gwK1chz1Y%3D" rel="nofollow">彩色喷泉</a></li><li><a href="https://link.segmentfault.com/?enc=tevXTA%2BKwkV4MR2B71QEQA%3D%3D.W8IAoaLQPOhEj7%2BQ8yGol6CWK8QkQi8xdBkZ5ijUDIxqFKxC59AwIRPOjvN2u%2B620x5fH32cmfLDNaGpHM5CPrup3BVXWcLXzVva%2BIuqs1E%3D" rel="nofollow">往鼠标方向弹动的箭头</a></li></ul>
【Fes】基于canvas的前端动画/游戏入门(四)
https://segmentfault.com/a/1190000014002724
2018-03-27T01:48:41+08:00
2018-03-27T01:48:41+08:00
calimanco
https://segmentfault.com/u/calimanco
25
<h2>越界检测</h2><p>假定物体是个圆形,如图其圆心坐标即是物体的 x 轴和 y 轴坐标。 <br>越界是常见的场景,一般会有两种场景的越界:一是整个物体移出区域;二是物体接触到区域边界。<br>我们以画布边界为例进行讨论,示例中矩形边界即是:</p><pre><code class="javascript">let top = 0;
let bottom = canvas.height;
let left = 0;
let right = canvas.width;</code></pre><p><img src="/img/bV6UUr" alt="边界" title="边界"></p><h3>整个物体移出区域</h3><p>要整个物体离开范围才算越界,则可得越界条件如下,以下任何一项为 true 即可判定越界。</p><pre><code class="javascript">// 右侧越界
object.x - object.width/2 > right
// 左侧越界
object.x + object.width/2 < left
// 上部越界
object.y + object.height/2 < top
// 下部越界
object.y - object.height/2 > bottom</code></pre><h3>物体接触到区域边界</h3><p>物体接触到区域边界就算越界,则可得越界条件如下,以下任何一项为 true 即可判定越界。</p><pre><code class="javascript">// 右侧越界
object.x + object.width/2 > right
// 左侧越界
object.x - object.width/2 < left
// 上部越界
object.y - object.height/2 < top
// 下部越界
object.y + object.height/2 > bottom</code></pre><h2>越界了该怎么办</h2><p>搞明白越界条件后,接下来讨论越界之后的处理办法,一般是一下四种。</p><h3>将物体移除</h3><p>这是最简单的处理办法,属于整个物体移出区域才算越界的情况。<br>下面的例子会先批量创建 ball,保存在 balls 数组里,每次动画循环都会遍历这个数组,依次输入 draw() 函数,改变 ball 的位置并检测是否越界。下面只列出 draw() 函数的代码。<br>完整示例:<a href="https://link.segmentfault.com/?enc=Q%2B301deWhi6PSBlQ%2BnASAw%3D%3D.%2F5n1XGDbPO2z7qFBCs4iCQmz%2FgpzC8E67JGD04GZpO386H30af0Sw%2BBWzpkGhQewebsKPmAYPQfPOsAmV6Dsc0OV7NW5LS8Kc3pQoAToSAY%3D" rel="nofollow">清除越界圆</a></p><pre><code class="javascript">function draw(ball, pos) {
// 依据球的速度改变球的位置
ball.x += ball.vx;
ball.y += ball.vy;
// 检查是否越界
if (ball.x - ball.radius > canvas.width || ball.x + ball.radius < 0 || ball.y - ball.radius > canvas.height || ball.y + ball.radius < 0) {
// 在数组中清除越界的球
balls.splice(pos, 1);
// 打印提示
if (balls.length > 0) {
log.value += `Removed ${ball.id}\n`;
log.scrollTop = log.scrollHeight;
} else {
log.value += 'All gone!\n';
}
}
// 画球
ball.draw(context);
}</code></pre><h3>将其物体置回边界内</h3><p>这是属于整个物体移出区域才算越界的情况。<br>下面的例子也是把创建的 ball 保存在 balls 数组里,但 ball 的初始位置都是画布中间的下部,如果检测到有 ball 越界,则会重置 ball 的位置。下面只列出 draw() 函数的代码。<br>完整示例:<a href="https://link.segmentfault.com/?enc=9xfcdtuRFVrBXoZzDuKp9A%3D%3D.BsJGS31aKJdtqlnU1Vz0AupFf6FrIQuETWiS5uVxLGJTpyJlXo7bLDBOYP2eSBg52hYZfWcCs1myLkvU1KajIn0j4hrZOsTmiwACVfYGmrA%3D" rel="nofollow">彩色喷泉</a></p><pre><code class="javascript">function draw(ball) {
// 依据球的速度改变球的位置,这里包含了伪重力
ball.vy += gravity;
ball.x += ball.vx;
ball.y += ball.vy;
// 检测是否越界
if (ball.x - ball.radius > canvas.width || ball.x + ball.radius < 0 || ball.y - ball.radius > canvas.height || ball.y + ball.radius < 0) {
// 重置ball的位置
ball.x = canvas.width / 2;
ball.y = canvas.height;
// 重置ball的速度
ball.vx = Math.random() * 6 - 3;
ball.vy = Math.random() * -10 - 10;
// 打印提示
log.value = `Reset ${ball.id}\n`;
}
// 画球
ball.draw(context);
}</code></pre><h3>屏幕环绕</h3><p>这是属于整个物体移出区域才算越界的情况。<br>屏幕环绕就是让同一个物体出现在边界内的另一个位置,如果一个物体从屏幕左侧移出,它就会在屏幕右侧再次出现,反之亦然,上下也是同理。<br>这里比前面的要稍微复杂的判断物体跃的是那边的界,伪代码如下:</p><pre><code class="javascript">if(object.x - object.width/2 > right){
object.x = left - object.widht/2;
}else if(object.x + object.width/2 < left){
object.x = right + object.width/2;
}
if(object.y - object.height/2 > bottom){
object.y = top - object.height/2;
}else if(object.y + object.height/2 < top){
obejct.y = bottom + object.height/2;
}</code></pre><h3>反弹(粗略版)</h3><p>这是较复杂的一种情况,属于物体接触到区域边界就算越界的情况。基本思路:</p><ol><li>检查物体是否越过任意边界;</li><li>如果发生越界, 立即将物体置回边界;</li><li>反转物体的速度向量的方向。</li></ol><p>下面的示例是一个 ball 在画布内移动,撞到边界就反弹,反弹核心代码如下。<br>完整示例:<a href="https://link.segmentfault.com/?enc=Ibvjfr48d6H8kiq9Ip1i7Q%3D%3D.WmdH%2ByJlOS5Vvnq1FFWBqSIk2JW49rVvUgKwgtqZNDlm2uIMQ7ZNQOWelzeJtjv0mzPXBMYKHUrbcdVyHftW%2BK3crNGEQickyKpRDs%2FS1qc%3D" rel="nofollow">反弹球(粗略版)</a></p><pre><code class="javascript">if (ball.x + ball.radius > right) {
ball.x = right - ball.radius;
vx *= -1;
} else if (ball.x - ball.radius < left) {
ball.x = left + ball.radius;
vx *= -1;
}
if (ball.y + ball.radius > bottom) {
ball.y = bottom - ball.radius;
vy *= -1;
} else if (ball.y - ball.radius < top) {
ball.y = top + ball.radius;
vy *= -1;
}</code></pre><h3>反弹(完美版)</h3><p>咋看似乎效果不错,但仔细想想,我们这样将物体置回边界的做法是准确的吗? <br>答案是否定的,理想反弹与实际反弹是不同的,如下图: </p><p><img src="/img/bV6UUs" alt="理想反弹与实际反弹" title="理想反弹与实际反弹"> </p><p>从图中我们可以清除的知道,ball 实际上是不太可能会在理想反弹点反弹的,因为如果速度过大,计算位置时 ball 已经越过“理想反弹点”到达“实际反弹点”,而我们如果只是将 ball 的 x 轴坐标简单粗暴移到边界上,那还是不可能是“理想反弹点”,也就是说这种反弹方法不准确。<br>那么,完美反弹的思路就明确了,我们需要找到“理想反弹点”,并将 ball 置到该点。如果是左右边越界,则算出“理想反弹点”与“实际反弹点”在 y 轴上的距离;如果是上下边越界,则算出“理想反弹点”与“实际反弹点”在 x 轴上的距离。如图,思路以左右边越界为例:</p><p><img src="/img/bV6UUt" alt="求理想反弹点" title="求理想反弹点"></p><ol><li>由速度可求得物体的方向弧度 angle;</li><li>算出“实际反弹点”和“理想反弹点”在 x 轴上的距离;</li><li>依据正切求“实际反弹点”和“理想反弹点”在 y 轴上的距离;</li><li>“理想反弹点”的 y 轴坐标即是“实际反弹点”加上这段距离。</li></ol><p>改造后的核心代码如下,至于有没有必要多做这么多运算,这就要权衡性能和精密性了。<br>完整示例:<a href="https://link.segmentfault.com/?enc=BznNYcXdPOEq7%2FykAdY7ZQ%3D%3D.pnllrQ5XH6oAb0xxIxz%2BwYagBZunQLpHri7r9ccwHz9yK4uHgkofPLGfnm0GraZ0XO0xhA5MBHPr6IVUmHlUUCCHCBiSyKY54wRk8oVr53Q%3D" rel="nofollow">反弹球(完美版)</a></p><pre><code class="javascript">if (ball.x + ball.radius > right) {
const dx = ball.x - (right - ball.radius);
const dy = Math.tan(angle) * dx;
ball.x = right - ball.radius;
ball.y += dy;
vx *= bounce;
} else if (ball.x - ball.radius < left) {
const dx = ball.x - (left + ball.radius);
const dy = Math.tan(angle) * dx;
ball.x = left + ball.radius;
ball.y += dy;
vx *= bounce;
}
if (ball.y + ball.radius > bottom) {
const dy = ball.y - (bottom - ball.radius);
const dx = dy / Math.tan(angle);
ball.y = bottom - ball.radius;
ball.x += dx;
vy *= bounce;
} else if (ball.y - ball.radius < top) {
const dy = ball.y - (top + ball.radius);
const dx = dy / Math.tan(angle);
ball.y = top + ball.radius;
ball.x += dx;
vy *= bounce;
}</code></pre><h2>碰撞检测</h2><p>和越界检查很像,我们扩展到两个物体间的碰撞检测,一般常用的有如下两种办法。</p><h3>基于几何图形的碰撞检测</h3><p>一般是用在检测矩形的碰撞,原理就是判断一个物体是否和另一个物体有重叠。<br>下面直接给出两个检测的工具函数。完整示例:</p><ul><li><a href="https://link.segmentfault.com/?enc=i5DUwBNXbma1gr6nio8c3A%3D%3D.L3fv0i0dPRBTnL%2FXI%2F3Alxk9so0bImRM5mDT0dplZHmC5mIMKysJ2DCFiNLON1i8TQxlevN0KPadg9%2F1aJojuq91fhmc%2BqnprYb%2B4l3RIytRGzbIp7YJp%2FJMd5E8oyQc" rel="nofollow">两个矩形碰撞检测演示</a></li><li><a href="https://link.segmentfault.com/?enc=YFGP3KO4BBzQiZrqeH9w4Q%3D%3D.cRsJkW%2Fa1Aza6mWdlt%2F1B2sEZu6vLrrvpMnAfOAzMF4t%2B1FSQfFjH4qA5Za2j8PezA%2Fa0xUUQKsnvRoPDyLUo6iWFb%2FF%2Ft%2Fzdg9RBQCQ2WYqx8O4OkBVPyubayu2CkkH" rel="nofollow">矩形与点碰撞检测演示</a></li></ul><pre><code class="javascript">// 两个矩形碰撞检测
function intersects(rectA, rectB) {
return !(rectA.x + rectA.width < rectB.x ||
rectB.x + rectB.width < rectA.x ||
rectA.y + rectA.height < rectB.y ||
rectB.y + rectB.height < rectA.y);
};</code></pre><pre><code class="javascript">// 矩形与点碰撞检测
function containsPoint(rect, x, y) {
return !(x < rect.x || x > rect.x + rect.width || y < rect.y || y > rect.y + rect.height);
};</code></pre><h3>基于距离的碰撞检测</h3><p>一般是用在检测圆形的碰撞,原理就是判断两个物体是否足够近到发生碰撞。<br>对于圆来说,只要两个圆心距离小于两圆半径之和,那我们就可判定为碰撞。圆心距离可通过勾股定理求得。核心代码如下:<br>完整示例:<a href="https://link.segmentfault.com/?enc=Z%2F5Pi%2Fbk1Qsq4WJ%2FyZbdjQ%3D%3D.WygBMX0%2FF4sS%2F7cumNTjExeNJtP35ZkihSvtn2OWYbrnvgvhk0AxRFg8mIMTNEerHfmX9YvapHPrtiFG54ymiIgflQvD9A3UE5pwpQIqvWU%3D" rel="nofollow">两圆基于距离的碰撞演示</a></p><pre><code class="javascript">const dx = ballB.x - ballA.x;
const dy = ballB.y - ballA.y;
const dist = Math.sqrt(dx ** 2 + dy ** 2);
if (dist < ballA.radius + ballB.radius) {
log.value = 'Hit!';
} else {
log.value = '';
}</code></pre>
【Fes】基于canvas的前端动画/游戏入门(三)
https://segmentfault.com/a/1190000013994486
2018-03-26T17:10:31+08:00
2018-03-26T17:10:31+08:00
calimanco
https://segmentfault.com/u/calimanco
6
<h2>速度</h2><blockquote>【科普】速度是描述物体运动快慢和方向的物理量。物理学中提到物体的速度通常是指其瞬时速度。速度在国际单位制中的单位是米每秒,国际符号是 m/s,中文符号是米/秒。相对论框架中,物体的速度上限是光速。</blockquote><p>在动画编程中,速度是最基础的要素,在本系列教程的第二篇讲到三角函数时就有所体现。</p><h3>动画编程中的速度</h3><p>见下图,我们这里要讨论的计算机动画中的速度跟物理学上概念相似,都是矢量,也就是既有大小又有方向,而方向的体现就是其值的正负,回顾系列第一篇中讲到的坐标系,沿着正半轴运动速度就是正,沿着负半轴运动速度就是负。<br>另外一点不同就是单位,不一定会以时间为单位,可能是以帧为单位,比如“像素/帧”。<br>也正因为速度是矢量,那任何一个速度都可以分解为x轴和y轴上的速度,这就是编程动画基本思想。</p><p><img src="/img/bV6SKX" alt="速度分解图" title="速度分解图"></p><h3>实例应用</h3><p>将系列第二篇的“一个会跟踪鼠标位置的箭头”改造成“跟随鼠标的箭头”。代码很基础,看注释就行,基本思路:</p><ol><li>计算目标点与物体的夹角;</li><li>依据夹角分解速度到 x 轴和 y 轴;</li><li>分别将每条轴上的速度与物体的位置坐标相加。</li></ol><p>特别说明,因为这个例子中的动画循环是基于帧,所以速度单位是像素每帧。<br>完整例子:<a href="https://link.segmentfault.com/?enc=jiz6bmCqDqkwhTaghgQ3Yw%3D%3D.4%2B1ycW4T9tfydQv4oAO3S9p3sCm3B051zl0Qi%2BzMz0uM8kaX97fKKbYAzwaje6%2Bkp29y%2FF78J6%2BGge8OTBVKZqLb5HtyGrlO7C%2F0SG0R%2F7w%3D" rel="nofollow">跟随鼠标的箭头</a></p><pre><code class="javascript">/**
* 跟随鼠标的箭头
* */
window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
const mouse = utils.captureMouse(canvas);
const arrow = new Arrow();
// 设定速度
const speed = 3;
(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
// 计算鼠标与箭头的相对距离
const dx = mouse.x - arrow.x;
const dy = mouse.y - arrow.y;
// 求箭头指向鼠标的夹角
const angle = Math.atan2(dy, dx);
// 将速度分解到x轴和y轴
const vx = Math.cos(angle) * speed;
const vy = Math.sin(angle) * speed;
// 设置箭头的角度
arrow.rotation = angle;
// 将分解后的速度加到箭头的两轴位置上
arrow.x += vx;
arrow.y += vy;
// 重新绘制箭头
arrow.draw(context);
}());
};</code></pre><h2>加速度</h2><blockquote>【科普】加速度是物理学中的一个物理量,是一个矢量,主要应用于经典物理当中,一般用字母 a 表示,在国际单位制中的单位为米每二次方秒。加速度是速度矢量对于时间的变化率,描述速度的方向和大小变化的快慢。</blockquote><h3>动画编程中的加速度</h3><p>计算机动画中的加速度就是速度的变化量,跟前面的速度一样是矢量,也可以分解为 x 轴和 y 轴上的加速度,方法同上。单位可以是像素每二次方帧,与“力学”有很大联系。<br>加速度可以让运动更加自然,在计算机动画中模拟真实运动是必要的基础。<br>请明确加速度是速度的变化量,也就是加速度的方向与速度相同即加速,方向相反即减速,如果加速度为零,速度将恒定,物体做匀速直线运动。</p><h3>实例应用</h3><p>继续改造前面的例子<a href="https://link.segmentfault.com/?enc=92%2Bn%2B2oP3KjyK5mHLG9SlQ%3D%3D.UT5MVMbJ%2BDfmvVESrjx9uPYWE0yhPAI8Y8s%2FreHgUox%2FeEzIrgWo60cgOBLqX1V4p1KjpAMFioFPnm8%2FnDBCjPpp3s6l1tBoChNaT9%2FR%2Fhc%3D" rel="nofollow">跟随鼠标的箭头</a>为“往鼠标方向加速的箭头”。改造量不大,就是把加速度分解后叠加给速度,基本思路:</p><ol><li>计算目标点与物体的夹角;</li><li>将加速度同样分解到 x,y 轴上;</li><li>分别将每条轴上的加速度与速度相加;</li><li>再分别将每条轴上的速度与物体的位置坐标相加。</li></ol><p>完整例子:<a href="https://link.segmentfault.com/?enc=4kjtw0vspZaB%2B8tC5t7JjA%3D%3D.gIsa%2FsT1JHRdhIlBotEdrurrfZzXVb5EytxyozFvKrnPXNs0gNtoM2oLjQVXGaRFW3r3N2DXDfct3ei3XQ%2BTR7Z%2BjlSiivxgln1kl0AnQTeF8Vb1llbiifZi8CEPQjXM" rel="nofollow">往鼠标方向加速的箭头</a> <br>观察实例,你会发现这个箭头虽然运动比前面例子自然了不少,但却永远都不会停下,这是由于这里的加速度不变的,而现实中由于摩擦力等因素加速度是会被削减的。</p><pre><code class="javascript">/**
* 往鼠标方向加速的箭头
* */
window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
const mouse = utils.captureMouse(canvas);
const arrow = new Arrow();
// 初始化速度
let vx = 0;
let vy = 0;
// 设定加速度
const force = 0.02;
(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
// 计算鼠标与箭头的相对距离
const dx = mouse.x - arrow.x;
const dy = mouse.y - arrow.y;
// 求箭头指向鼠标的夹角
const angle = Math.atan2(dy, dx);
// 将加速度分解到x轴和y轴
const ax = Math.cos(angle) * force;
const ay = Math.sin(angle) * force;
// 设置箭头的角度
arrow.rotation = angle;
// 将分解后的加速度加到箭头的两轴速度上
vx += ax;
vy += ay;
// 将分解后的速度加到箭头的两轴位置上
arrow.x += vx;
arrow.y += vy;
// 重新绘制箭头
arrow.draw(context);
}());
};</code></pre><h2>比例运动</h2><p>这里会介绍两个较高级的常见技术,缓动和弹动。<br>所谓比例运动,就是运动程度与目标点距离是成正比,简单来说就是“距离越远,运动程度越大”,这里的运动程度是指但不局限于速度和加速度的与运动有关的变量。</p><h3>缓动</h3><p>缓动是指物体的速度与它到目标点的距离成比例,即基于距离的比例速度,这个比例会影响速度的大小。<br>缓动的运动特质不止一种,你可以先快后慢,也可以先慢后快,还可以先慢后快再慢等,我们这里只以最简单的先快后慢为例,即距离越大,速度越大,距离缩进到 0,速度也为 0。</p><p>还是改造前面的<a href="https://link.segmentfault.com/?enc=8azkHiTXktNKAB08fwKXyQ%3D%3D.M7S%2FA%2BGwXUVOVfoQ2WX6hKRb27xLaJQfXchx4zgjXnm%2F2yxWZWuOrOO7Oyk84HPJCEHQX1s5bGiUlYq5Xhxh6Izha63Jq268kcKEuis3q%2BI%3D" rel="nofollow">跟随鼠标的箭头</a>,代码如下,基本思路:</p><ol><li>确定一个比例系数,这是一个 0~1 之间的小数;</li><li>确定目标点,并计算相对距离;</li><li>计算速度,速度=距离×比例系数;</li><li>用当前位置加上速度来计算新的位置;</li><li>重复第 2~4 步,直到物体到达目标点。</li></ol><p>完整例子:<a href="https://link.segmentfault.com/?enc=N49x10J552VA4h7%2F4KbEnQ%3D%3D.IAOC%2Bug%2Bg%2B5j9fwjI7%2FWyKmh6DQgS2n8nFxffYx8ohOt37VBikhtodql5dn64KmUuojlkv9T5CO9mJCExxKJTjP6%2FqyxOkxjRjh7cWianRA%3D" rel="nofollow">缓动到鼠标位置的箭头</a></p><pre><code class="javascript">/**
* 往鼠标方向缓动的箭头
* */
window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
const mouse = utils.captureMouse(canvas);
const arrow = new Arrow();
// 比例系数
const easing = 0.05;
(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
// 计算鼠标与箭头的相对距离
const dx = mouse.x - arrow.x;
const dy = mouse.y - arrow.y;
// 求箭头指向鼠标的夹角
const angle = Math.atan2(dy, dx);
// 根据距离缓动
const vx = dx * easing;
const vy = dy * easing;
// 设置箭头的角度
arrow.rotation = angle;
// 将分解后的速度加到箭头的两轴位置上
arrow.x += vx;
arrow.y += vy;
// 重新绘制箭头
arrow.draw(context);
}());
};</code></pre><h3>弹动</h3><p>弹动是指物体的加速度与它到目标点的距离成比例,即基于距离的比例加速度,这个比例会影响加速度的大小。<br>弹动会使运动自然且有灵性,你会发现物体会冲过目标点,然后开始回弹,往复。这样就能模拟出弹簧或橡皮筋的效果。<br>特别注意,距离为 0 时加速度也为 0,但速度不一定为 0。</p><p>还是改造前面的<a href="https://link.segmentfault.com/?enc=g8dqKvaXeAZVHHhtDqB%2FMA%3D%3D.BkN17FZB8vi72R%2BdwROC9Yosji1rbBjHV8anjZtKAujQEqjorg2PF%2FVlyZVCSQfgV4I8tZ%2B9XHtojc0hwJdBBbpEa1tdeSxez6x0zAnPeP6%2BxnMOLC0UmJgUNXKX819l" rel="nofollow">往鼠标方向加速的箭头</a>,代码如下,你会发现这个跟前面的加速度例子很像,都是不断的往复运动,其原因都是加速度和速度很难同时为 0 导致的,这里我们加了削减系数让它停下来,基本思路:</p><ol><li>确定一个比例系数,这是一个 0~1 之间的小数;</li><li>确定目标点,并计算相对距离;</li><li>计算速度,加速度=距离×比例系数;</li><li>用当前速度加上加速度;</li><li>用当前位置加上速度来计算新的位置;</li><li>重复第 2~5 步。</li></ol><p>完整例子:<a href="https://link.segmentfault.com/?enc=qXKl6zoR6A6iZnMEbb%2FdZA%3D%3D.azl7TkP%2Buq1NlPv37Z51xekzi3ATQuIr8Mfdt%2BrNPRqCElaXgy1FM1S812vnFSt938AUDZ7Vyz%2Bphz%2FYDHYU%2BbSbKgPPKYLz5%2Bzh%2ByTjDjQ%3D" rel="nofollow">往鼠标方向弹动的箭头</a></p><pre><code class="javascript">/**
* 往鼠标方向弹动的箭头
* */
window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
const mouse = utils.captureMouse(canvas);
const arrow = new Arrow();
// 设定弹动系数
const spring = 0.02;
// 初始化速度
let vx = 0;
let vy = 0;
// 削减系数
const friction = 0.95;
(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
// 计算鼠标与箭头的相对距离
const dx = mouse.x - arrow.x;
const dy = mouse.y - arrow.y;
// 求箭头指向鼠标的夹角
const angle = Math.atan2(dy, dx);
// 根据距离弹动
const ax = dx * spring;
const ay = dy * spring;
// 设置箭头的角度
arrow.rotation = angle;
// 将分解后的加速度加到箭头的两轴速度上
vx += ax;
vy += ay;
// 削减速度
vx *= friction;
vy *= friction;
// 将分解后的速度加到箭头的两轴位置上
arrow.x += vx;
arrow.y += vy;
// 重新绘制箭头
arrow.draw(context);
}());
};</code></pre>
【Fes】基于canvas的前端动画/游戏入门(番外二)
https://segmentfault.com/a/1190000013971943
2018-03-25T17:34:54+08:00
2018-03-25T17:34:54+08:00
calimanco
https://segmentfault.com/u/calimanco
3
<h2>canvas操纵像素</h2><p>你如果认为 canvas 只是画图工具,那接下来的操作会颠覆你的认知。 canvas 提供 api 可以获取画布上任何一个像素,并可以自由的操作他们。</p><h3>获取像素</h3><p>直接访问像素的功能由 canvas 上下文中的 ImageData 对象提供,它提供了以下一组方法,都会返回 ImageData 对象。</p><ul><li>getImageData() 接受 x 轴坐标、y 轴坐标、宽度、高度四个参数,获取画布上这个矩形区域的像素数据;</li><li>createImageData() 可凭空创建指定宽高的矩形区域,初始是黑色,也可以输入一个 ImageData 对象用于创建一个同样大小的区域,但注意不会复制像素数据。</li></ul><pre><code class="javascript">context.getImageData(x, y, width, height);
context.createImageData(width, height);
context.createImageData(anothorImageData);</code></pre><p>获取到的 ImageData 对象中 data 属性是一个一维数组,乍看乱糟糟的,但细看你会发现其实这就是 RGBA 的颜色数据,也就是数组中每个四位就是一个像素的颜色数据,这里注意一下透明度 A 也是 0~255,不是 CSS 里简化过的 0~1。</p><hr><p><strong>举个例子</strong><br>现在假定在一个纯红色区域取一块<code>2*2</code>的矩形,我们得到的像素数据是:</p><pre><code class="javascript">let pixels = [255, 0, 0, 255, 255, 0, 0, 255,
255, 0, 0, 255, 255, 0, 0, 255]</code></pre><p>他们与图像的对应关系是从左到右,从上到下,大概就像上面代码格式化这样,如图所示: </p><p><img src="/img/bV6Qad" alt="像素对应位置" title="像素对应位置"></p><p>根据 4 对 1 的对应关系,我们很容易就能写出遍历的办法,offset 就相当于指针,每次移动 4 位,代码如下:</p><pre><code class="javascript">for (let offset = 0, len = pixels.length; offset < len; offset += 4) {
r = pixels[offset];
g = pixels[offset + 1];
b = pixels[offset + 2];
a = pixels[offset + 3];
}</code></pre><p>当需要访问特定坐标的像素时,可以使用如下公式,其中 xpos 是像素点在该区域的 x 坐标;ypos 是像素点在该区域的 y 坐标,imagedata.width 是指区域横向有多少像素。</p><pre><code class="javascript">let offset = (xpos + ypos * imagedata.width) * 4;
let r = pixels[offset];
let g = pixels[offset + 1];
let b = pixels[offset + 2];
let a = pixels[offset + 3];</code></pre><h3>绘制像素</h3><p>可以将修改过的 ImageData 对象重新用上下文的 putImageData() 方法绘制到指定区域,该方法接受三个参数:ImageData 对象、x 轴坐标、y 轴坐标。绘制指定的位置绘制 ImageData 对象的内容。直接看下面例子的核心代码。 <br>先绘制铺满画布的色块,点击按钮触发 change 事件处理器可改变颜色,过程见注释。 <br>完整例子:<a href="https://link.segmentfault.com/?enc=MeSLKjAczbs8oOg8F7AQTQ%3D%3D.Uy6NtFeNcrl79JS8Zmwi9PqI0zFwrxafCspHLOJYHaYre%2BlYW27SOvV8ktmKq2PC6HYQeSjSi2kaUjlKQGlnjOsGhqWJDD2CwiFv%2FslNS60%3D" rel="nofollow">演示反色变化</a></p><blockquote>【PS】对 js 了解不深的朋友可能会有疑问,遍历过程操作的是 pixels,imageData 怎么会改变呢? <br>这是因为 js 中对象都是地址传递的特点,也就是 pixels = imageData.data 操作只是将 pixels 变量的指向到 imageData.data 所指向的内存空间,所以操作 pixels 就是操作 imageData.data。</blockquote><pre><code class="javascript">window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
// 绘制色块,每个色块宽10像素,高等于画布高,铺满画布
for (let i = 0; i < canvas.width; i += 10) {
context.fillStyle = (i % 20 === 0) ? '#f00' : ((i % 30 === 0) ? '#0f0' : '#00f');
context.fillRect(i, 0, 10, canvas.height);
}
};
function change() {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
// 获取整个画布的ImageData对象
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
// 取出颜色数据
const pixels = imageData.data;
// 遍历颜色数据求每个颜色的反色
for (let offset = 0, len = pixels.length; offset < len; offset += 4) {
pixels[offset] = 255 - pixels[offset];
pixels[offset + 1] = 255 - pixels[offset + 1];
pixels[offset + 2] = 255 - pixels[offset + 2];
// 这里没有操作透明度
}
// 将ImageData重新绘制到画布上
context.putImageData(imageData, 0, 0);
}</code></pre><h3>更多有趣的例子</h3><p>canvas 强大的像素操作可以给我们带来更多的可能,也许你会想开始做一个网页版的美图工具了吧(笑)。 <br>这里还有一些有趣的 demo 可以玩玩:</p><ul><li><a href="https://link.segmentfault.com/?enc=qshEWp%2F5AwB7HPwzJlYO4A%3D%3D.rMsRKWxFQGtjC5rxG%2B4kOXQL3V5eCt9xDCpOm7UxX68Gfvy%2BhB4NX4OPINjszG8RV8AtOiEWr9Eu03cSopcFlbIZZtV6%2F0VbG0EgRuYgSMc%3D" rel="nofollow">演示灰度变化</a></li><li><a href="https://link.segmentfault.com/?enc=3vBjyckJp3VKSf%2BE%2BbfteQ%3D%3D.gAJnPgoNnsZFGB%2BBGQNRIf45uz7fTBCwqqQGHLQTtsRhEKV0yxwvpgnKwV4Uqa5FKgPzsPISakejNN90GO%2FVzuFxYGVxCG324JEBpPkpLwU%3D" rel="nofollow">有趣的色彩波纹</a></li></ul><h2>综合案例</h2><p>有关颜色的番外部分到这里就基本完结了,最后有个综合题,会应用这些技术。 <br>将系列第二篇中的<a href="https://link.segmentfault.com/?enc=QPr9BR5%2B5om7vvRrj94Fwg%3D%3D.a1eMK3NIdwq71i65ZfqaO31IwLcXK1bcIFlaNj3j0zuXQjK0gKblJQq5wY%2FB36i8banEAndCRzsd9qvoE1vO%2BQAX0v3oRlTOYEX6%2F%2FWI8mc%3D" rel="nofollow">鼠标画图工具</a>改造成鼠标喷漆工具,这里建议自己动手实践一下。 <br>下面例子基本思路就是取得画布像素数据,每当鼠标点下并移动(执行 onMouseMove)就随机改变鼠标周围一定范围的像素点的颜色。 <br>完整案例:<a href="https://link.segmentfault.com/?enc=%2F9T6h8U6%2FqZtuZffCcMHdQ%3D%3D.8mfTHS39vhvkFfVN56bbv2M4b4P4PVbzdL4kg%2BMnZ8wQOxGvc04jDxZO2o4dTPbw%2BJIiGGh5gx3LXfvw8rTDr2kg8c%2BkzSZqds%2Bqsa2ln8w%3D" rel="nofollow">鼠标喷漆工具</a></p><pre><code class="javascript">window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
// 获得整个画布区域的ImageData对象
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
// 取出像素数据
const pixels = imageData.data;
// 设定笔刷大小
const brush_size = 25;
// 设定笔刷密度
const brush_density = 80;
// 笔刷的颜色变量
let brush_color;
function onMouseMove() {
// 根据设定的笔刷密度生成随机像素点
for (let i = 0; i < brush_density; i++) {
// 随机像素点角度相对于鼠标的角度
const angle = Math.random() * Math.PI * 2;
// 根据设定的笔刷大小,随机像素点以鼠标为圆心的半径
const radius = Math.random() * brush_size;
// 计算出像素点的x轴相对坐标
const xpos = (mouse.x + Math.cos(angle) * radius) | 0;
// 计算出像素点的y轴相对坐标
const ypos = (mouse.y + Math.sin(angle) * radius) | 0;
// 算出该像素点在pixels中的偏移量
const offset = (xpos + ypos * imageData.width) * 4;
// 对这个像素点的颜色数据进行操作,将颜色分解成三基色
pixels[offset] = brush_color >> 16 & 0xff;
pixels[offset + 1] = brush_color >> 8 & 0xff;
pixels[offset + 2] = brush_color & 0xff;
pixels[offset + 3] = 255;
}
// 重新绘制区域
context.putImageData(imageData, 0, 0);
}
canvas.addEventListener('mousedown', () => {
// 随机一个颜色
brush_color = utils.parseColor(Math.random() * 0xffffff, true);
canvas.addEventListener('mousemove', onMouseMove, false);
}, false);
canvas.addEventListener('mouseup', () => {
canvas.removeEventListener('mousemove', onMouseMove, false);
}, false);
};</code></pre>
【Fes】基于canvas的前端动画/游戏入门(番外一)
https://segmentfault.com/a/1190000013970255
2018-03-25T15:16:20+08:00
2018-03-25T15:16:20+08:00
calimanco
https://segmentfault.com/u/calimanco
5
<h2>先从老朋友 CSS 讲起</h2><p>我们熟悉的 CSS 风格颜色表示方式,大体有下面几种,canvas 大体是直接沿用这些写法的,但最后包含透明度的写法有些许不同。</p><ul><li><code>#RRGGBB</code>:十六进制格式,红绿蓝分别用两位十六进制数表示。</li><li><code>#RGB</code>:简写的十六进制格式,转换成 6 位格式时会重复三原色,例如<code>#fb0</code>-><code>#ffbb00</code>。</li><li><code>rgb(R,G,B)</code>:函数表达式,三原色分别由 0~255 的整数值表示。</li><li><code>rgba(R,G,B,A)</code>:包含透明度的函数表达式,其中 alpha 参数为 0~1,需要指定透明度的颜色必须使用该格式。</li></ul><p>作为前端人员平时用得很多,但你可能会一脸懵逼之前自己写的颜色字符串居然是十六进制? <br>待我细细道来。这里的 R 即是红色(red),G 即是绿色(green),B 即是蓝色(blue),这三个是显示器普遍使用的三基色,属于叠加型原色,百科摘录如下。</p><blockquote>【科普】原色是指不能透过其他颜色的混合调配而得出的“基本色”。 <br>以不同比例将原色混合,可以产生出其他的新颜色。以数学的向量空间来解释色彩系统,则原色在空间内可作为一组基底向量,并且能组合出一个“色彩空间”。由于人类肉眼有三种不同颜色的感光体,因此所见的色彩空间通常可以由三种基本色所表达,这三种颜色被称为“三原色”。一般来说叠加型的三原色是红色、绿色、蓝色(又称三基色,用于电视机、投影仪等显示设备);而消减型的三原色是品红色、黄色、青色(用于书本、杂志等的印刷)。</blockquote><h2>解密颜色值</h2><p>每一个颜色都是由三基色叠加合成,所以我们需要告诉计算机这各个基色的比例(浓度),将这个比例量化就是一个 0~255 的整数,也可说是 256 个级别,越大即表示各种原色更多(更浓)。</p><blockquote>【PS】至于为什么是 256 个级别? <br>是因为计算机中每个原色用 8 位二进制(0 或 1)表示,也就是 2 的 8 次方共 256。</blockquote><p>每个颜色都是 256 个级别,那它的组合的可能就有<code>256*256*256=16777216</code>,换句话说,一个颜色用 24 位二进制表示,换算成十进制就是 0~16777215。 <br>这里你应该可以看懂上面 CSS 颜色表示方式前三个的含义了吧,至于<code>rgba(R,G,B,A)</code>多加入了 A,表示透明度,这个是扩展版的 32 位颜色系统,多了一个额外的 8 位二进制表示透明度的级别,CSS 将它简化成 0~1 表示。</p><hr><p><strong>举个例子吧!</strong> <br>以<code>#FF55F3</code>这个颜色为例进行讲解。(0x 开头表示十六进制数,js 中不区分大小写,至于不知道什么是十六进制的,请自行百度) <br>红色是<code>0xFF</code>,绿色是<code>0x55</code>,蓝色<code>0xF3</code>。 <br>转换成十进制:红色是 255,绿色是 85,蓝色是 243。也就是说这个数值和<code>rgb(255,85,243)</code>写法是等价的。</p><blockquote>【PS】简便的转换方法,直接在控制台打印即可,比如<code>console.log(0xF3);</code>,js 默认输出十进制表示的字符串。</blockquote><h2>颜色合成</h2><p>颜色理论学得差不多了,现在来看看合成,已知三原色的值,要如何用代码合成一个颜色呢?<br>以上面说的<code>#FF55F3</code>为例,现在已知的是各个颜色值,下面提供两种做法:</p><h3>得到<code>rgb(R,G,B)</code>格式</h3><p>直接利用 js 数字转换为字符串时默认是十进制的特性。</p><pre><code class="javascript">let r = 0xFF;
let g = 0x55;
let b = 0xF3;
let color = `rgb(${r},${g},${b})`;</code></pre><h3>得到<code>#RRGGBB</code>格式</h3><p>一个 24 位的颜色值,二进制即:RRRRRRRRGGGGGGGGBBBBBBBB <br>红色值左移 16 位,绿色左移 8 位,将三者做“或”就能得到合成的 24 位颜色值,再转成 16 进制字符串即可。</p><pre><code>0xFF << 16 = 111111110000000000000000
0x55 << 08 = 000000000101010100000000
0xF3 = 000000000000000011110011
OR = 111111110101010111110011
</code></pre><pre><code class="javascript">//省略跟前面一样的...
let color = `#${(r << 16 | g << 8 | b).toString(16)}`;</code></pre><h2>颜色分解</h2><p>合成学完了,现在考虑一下如何用代码分解颜色,也就是把一个颜色分离出红、绿、蓝。<br><code>rgb(R,G,B)</code>格式就说了,切字符串就能得到。 <br>重点讨论<code>#RRGGBB</code>格式,其实就是第二种合成方法的逆过程,右移后“与“操作,简单来说就是把想要的颜色值所在的位置移动到末尾,再用“与”<code>0xFF</code>剔除其他颜色。 <br>还是以<code>#FF55F3</code>为例,现已知这个字符串,要求分解出三基色的值。</p><ol><li>切除“#”号得到 16 进制字符串;</li><li>红色:右移 16 位,与 0xFF 做“与”操作;</li><li>绿色:右移 8 位,与 0xFF 做“与”操作;</li><li>蓝色:直接与 0xFF 做“与”操作。</li></ol><pre><code class="javascript">let color = parseInt('#FF55F3'.slice(1), 16);
let r = color >> 16 & 0xFF
let g = color >> 8 & 0xFF
let b = color & 0xFF</code></pre><p>以绿色提取过程为例:</p><pre><code>0xFF55F3 = 111111110101010111110011
0xFF55F3 >> 8 = 000000001111111101010101
0xFF = 000000000000000011111111
AND = 000000000000000001010101
</code></pre><h2>封装颜色工具</h2><p>当然,上面的合成、分解代码都是基本理论的应用,实际项目中使用会为了健壮性封装成更加合理的工具,可以参考我们工具类 <a href="https://link.segmentfault.com/?enc=GL8Zor2O3AL4YyabHnotww%3D%3D.3SMQpHiy9FNmt3dcgT0SnO5wc2qOvwZZjCBDeNYcbYtVkC1uJ4gfoHyunyuSA4muQ4JMJOtjU6rh1KYVd%2Fyoh4gLvwD9G6%2BwkWCaDgGabt%2B0sfSzPPF3SGGnqSgFX9Ma" rel="nofollow">utils.js</a> 中的 colorToRGB() 和 parseColor() 两个函数。</p><ul><li>colorToRGB() 用于将<code>#RRGGBB</code>格式或任意数字,转换成<code>rgb(R,G,B)</code>或<code>rgba(R,G,B,A)</code>;</li><li>parseColor() 用于将<code>#RRGGBB</code>格式转成数字,将数字转成<code>#RRGGBB</code>格式。</li></ul>
【Fes】基于canvas的前端动画/游戏入门(二)
https://segmentfault.com/a/1190000013965650
2018-03-25T01:16:33+08:00
2018-03-25T01:16:33+08:00
calimanco
https://segmentfault.com/u/calimanco
11
<h2>一起来画画吧</h2><p>canvas 的 API 有很多,如果一一列举 30 分钟你是绝对看不完的,而且怎么流水账还不如自己去看文档呢(笑),本教程的思路是用实例一步一步从无到有讲解基础用法。<br><a href="https://link.segmentfault.com/?enc=ebQSHoF5S9CnqD5uZcX5Ng%3D%3D.SjAdhvKpheJXXU6L1clrPBXfloS8YnBsYw7%2BItnDhGebVzQz8%2B145MZv1nI9cp%2B%2BYNk2Juw5pP82fp6mj0R3WA%3D%3D" rel="nofollow">canvas相关文档</a></p><h3>准备工作</h3><ol><li>布置画布:通过添加 <canvas> 标签,添加 canvas 元素;</li><li>获取画布:通过 <canvas> 标签的 id,获得 canvas 对象;</li><li>获得画笔:通过 canvas 对象的 getContext("2d") 方法,获得 2D 环境。</li></ol><p>代码示例:</p><pre><code class="html"><canvas id="canvas" width="400" height="400"></canvas></code></pre><pre><code class="javascript">const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');</code></pre><h3>画个箭头</h3><p>首先我们来画个红边黄底的箭头,使用面向对象的代码组织方式,全部代码如下。<br>类名为 Arrow。它拥有 x 轴坐标、y 轴坐标、底的颜色 color、旋转弧度 rotation 四个属性。<br>实例方法是 draw(),它需要一个 context 对象作为参数,就是准备工作里的 context,它就相当于是画笔,这里其实是类似依赖注入的过程,将 canvas 的画笔交给实例的 draw() 方法,实例用这个画笔去画出箭头,绘画过程见代码注释。特别注意以下几点:</p><ul><li>beginPath() 方法调用后 moveTo() 和 lineTo 移动坐标是相对与 beginPath() 时画笔的坐标的,可以理解成画笔自带一个坐标系,它可以旋转和在画布上移动,绘制工作的坐标都是属于这个坐标系的;</li><li>beginPath() 是绘制设置状态的起始点,它之后代码设置的绘制状态的作用域结束于绘制方法 stroke()、fill() 或者 closePath();</li><li>save() 的作用是保存笔的状态,因为一个画布的笔只有一支,会在不同对象中传递,为了不污染后续的画就应该先保存,画完再 restore() 还原;</li><li><canvas> 本身是透明的,可以使用 CSS 给它个背景,例子中普遍使用白色背景。</li></ul><pre><code class="javascript">/**
* 箭头类
* @class Representing a arrow.
*/
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "Arrow" }] */
class Arrow {
/**
* Create a arrow.
*/
constructor() {
this.x = 0;
this.y = 0;
this.color = '#ffff00';
this.rotation = 0;
}
/**
* Draw the arrow.
* @param {Object} _context - The canvas context.
*/
draw(_context) {
const context = _context;
// 会先保存画笔状态
context.save();
// 移动画笔
context.translate(this.x, this.y);
// 旋转画笔
context.rotate(this.rotation);
// 设置线条宽度
context.lineWidth = 2;
// 设置线条颜色
context.strokeStyle = '#ff0000';
// 设置填充颜色
context.fillStyle = this.color;
// 开始路径
context.beginPath();
// 将笔移动到相对位置
context.moveTo(-50, -25);
// 画线到相对位置
context.lineTo(0, -25);
context.lineTo(0, -50);
context.lineTo(50, 0);
context.lineTo(0, 50);
context.lineTo(0, 25);
context.lineTo(-50, 25);
context.lineTo(-50, -25);
// 闭合路径
context.closePath();
// 填充路径包围区
context.fill();
// 绘制路径
context.stroke();
// 载入保存的笔信息
context.restore();
}
}</code></pre><p>同理我们还可以画点其他的,比如一个圆 <a href="https://link.segmentfault.com/?enc=rPGC28bBIr7Hgeby0ySxUA%3D%3D.yENSzhNcCUtH8GeMki9l%2BcpKGx1tBCkJb8BOM2VA700TycISOu%2Bap%2FIxnaEfzAatg6CSRD%2FQHzxt3owIqOcVpogUSY1NS8CiGpyuvtQ4j5OdQO2eAWCypFyeViRs7sjs" rel="nofollow">ball.js</a>,稍微多些参数,慢慢理解。 <br>成品的效果可以先看这个(稍微剧透):<a href="https://link.segmentfault.com/?enc=jWgNKYF%2F7G2RBlwD7JSdMQ%3D%3D.jnTTKmt1iAZduDHKWHiF7uRyhDPQclZuGEE5g02YRJfSGQaR48oLSsn%2BmwM2pR0Cn97fK2kIhy%2FMzyxthr6AAMknCjwbgODMFLYRTH3Yt7S2sUSc4n7Lp87sTQXZXCbA" rel="nofollow">一个会跟踪鼠标位置的箭头</a></p><h3>加入循环动起来</h3><p>现在我们已经掌握了画画的基本功,并且可以画箭头 <a href="https://link.segmentfault.com/?enc=04kXwUqtm16aURMgYp2QsQ%3D%3D.oRtDNpMdYykPMre0c%2FQIeVQwFTyUjUXcwhs8dDVNqO5p%2F012SrAHAsDcqUas0wQ8oZRm5GFxIXvL9Bxb2j0rK%2FiVxCJd%2BRfSrerKXeme6QiHwBgV1FzrsLHoTQuPpJTd" rel="nofollow">arrow.js</a> 和圆 <a href="https://link.segmentfault.com/?enc=oN2EE8BuvyUCjS1S7w6Eaw%3D%3D.FEOHSTNerrkOl0Tijny9zzwtweeHoQs3h6flIttO9X1NYCeMlILRtiD0uiEiC78GYqRmPigjZX5cyM1OlqdWlQA9Fv7SgKEd7nJVuK%2BY0n666ht3u6Jc5e67oH2%2Ff5di" rel="nofollow">ball.js</a>,然而这样只是静止画,接下来我们需要一个循环,不断的执行擦除和重画的工作才能实现帧动画。<br>下面这段代码的中绘图函数 drawFrame 被立即执行,并递归调用自身,你将会在大部分例子中看到。(见代码)</p><pre><code class="javascript">(function drawFrame() {
// 类似setTimeout的操作
window.requestAnimationFrame(drawFrame, canvas);
// 将画布擦干净
context.clearRect(0, 0, canvas.width, canvas.height);
// ...继续你的作画
}());</code></pre><p>循环原理上一篇已经说明,不再重复。这里要说明的是 clearRect(),这个函数接受一个矩形坐标,也就是(x 轴坐标,y 轴坐标,矩形宽度,矩形高度),用于清除矩形区域内的画。 <br>例子里直接是清除了整个画布,但这不是绝对的,刷不刷新,是局部刷新还是全部刷新,都需要灵活处理。 <br>这里有个不刷新的例子:<a href="https://link.segmentfault.com/?enc=FUNwGsu6D%2FqihPIiOmSHLQ%3D%3D.YMwU%2BuuEiLzOpPIYcQUyk3N%2B%2BlwtqtnP8NOWY%2BSr8VGAHNj4beOANbYXSUSmmUzIc2VamIumeDHXvuyGsTd3HK%2B9Gh80M%2FSQrzxUQgoSPXU%3D" rel="nofollow">鼠标画图工具</a></p><h3>给它点动力</h3><p>现在画面已经是在不断的重绘,但为什么还是静止的呢?因为每一次刷新都没有改变要画的内容。<br>那我们就给它一个目标吧,这样它才能动起来,比如就让箭头始终指向鼠标。<br>下面是核心代码,主要目的就是求出每帧 arrow 的旋转角度,这里使用的工具类 mouse 会实时返回鼠标的 x,y 轴坐标,封装原理上一篇已经讲过,根据这鼠标的坐标和 arrow 的坐标,即可得到鼠标的相对于 arrow 的距离 dx 和 dy。(如下图)</p><p><img src="/img/bV6Lbn" alt="箭头角度演示" title="箭头角度演示"></p><p>而 arrow 的旋转角度即可以通过 dx 和 dy 使用反正切函数得到,这里需要注意几点:</p><ul><li>仔细看上面代码中 arrow 的绘制过程,可知其原点是在中心位置的,所以刚好旋转角度就是画笔的旋转角度;</li><li>dx 和 dy 是鼠标相对与 arrow 的坐标,所以图中把坐标系挪动箭头中心是没毛病的;</li><li>用 atan2,而不是 atan,是因为 tan 值本来就可能是重复的,比如 -1/2 和 1/(-2) 两个都是 -0.5,无法区分象限,而 atan2 就可以区分开。</li></ul><p>完整实例:<a href="https://link.segmentfault.com/?enc=BRZpiUV1SvAm9W3iUrbDdQ%3D%3D.htVMgTlJgpkynxVZD0OHO963trQM5Ve%2FCDUDdZRPcO9BgX1WpxHp3GXFH7KcehUqyhFnOSxpIaaXwSaYtvOiSWFtvLrx2eDKYfEAbdutPZ1F9UdQbcgEfDRuliVs6yAl" rel="nofollow">一个会跟踪鼠标位置的箭头</a> (代码见下)</p><pre><code class="javascript">window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
const mouse = utils.captureMouse(canvas);
const arrow = new Arrow();
arrow.x = canvas.width / 2;
arrow.y = canvas.height / 2;
(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
const dx = mouse.x - arrow.x;
const dy = mouse.y - arrow.y;
arrow.rotation = Math.atan2(dy, dx);
arrow.draw(context);
}());
};</code></pre><h2>三角函数</h2><h3>上下运动</h3><p>终于顺利过渡到三角函数的话题(笑)。三角函数不止有反正切一个应用,下面再看一个例子。<br>下面是一个 ball 在上下运动的核心代码,重点就是 ball 的 y 轴坐标改变,就是这句:</p><pre><code class="javascript">ball.y = clientY + Math.sin(angle) * range;</code></pre><p>利用 Math.sin(angle) 的取值范围是 -1 到 1,并且会随着 angle 增大而反复,使 ball 在一定范围上下运动。<br>完整例子:<a href="https://link.segmentfault.com/?enc=nJ7jpxM7tAl%2BtDC5vh6hNQ%3D%3D.%2FY%2Br7oYsI6c1ZSx8%2BZ66B812X1RbXxCiuYvod1Xh9AYmB09SGgagc%2F6%2F8Y%2BDLJZcQoWvTTyN9hanDDlnkdHGSQbWX4mJx0qnP2GzZb1twpE%3D" rel="nofollow">一个上下运动的球(可调参数版)</a></p><pre><code class="javascript">window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
const ball = new Ball();
let angle = 0;
// 运动中心
const clientY = 200;
// 范围
const range = 50;
// 速度
const speed = 0.05;
ball.x = canvas.width / 2;
ball.y = canvas.height / 2;
(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
ball.y = clientY + Math.sin(angle) * range;
angle += speed;
ball.draw(context);
}());
};</code></pre><h3>向前运动</h3><p>只是上下运动不过瘾,那就让圆前进吧,其实就是每帧改变 x 轴的位置。<br>核心代码如下,相比前面的上下运动,多了 x 轴的速度,每帧移动一点就形成了波浪前进的效果。<br>完整实例:<a href="https://link.segmentfault.com/?enc=gFBBKtjcSatfjOQqh%2BKWmg%3D%3D.RepYu4BCOYfR33ejFoWVH1KeByTq%2BGZ6jYFgKSVampH%2Bz4HmYvg6JMgLs8B73OF1uvMEy8DN%2FuMkwTCHDpqj1goeflaSgr8%2Fsf%2FZWVgjXvI%3D" rel="nofollow">一个波浪运动的球</a> (代码见下)</p><pre><code class="javascript">window.onload = function () {
const canvas = document.getElementById('canvas');
const context = canvas.getContext('2d');
const ball = new Ball();
let angle = 0;
const centerY = 200;
const range = 50;
const xspeed = 1;
const yspeed = 0.05;
ball.x = 0;
(function drawFrame() {
window.requestAnimationFrame(drawFrame, canvas);
context.clearRect(0, 0, canvas.width, canvas.height);
ball.x += xspeed;
ball.y = centerY + Math.sin(angle) * range;
angle += yspeed;
ball.draw(context);
}());
};</code></pre><h3>其他示例</h3><p>其他的应用就不一一讲解,罗列出来一些:</p><ul><li><a href="https://link.segmentfault.com/?enc=pVIYWmhEKv1MYk1JcIe8PA%3D%3D.YHCHn1Y17cgJduKB8P5qBGbGDr4CCMI%2BVR4WbNO%2BTqYHDPTcefvhHdDbOktdR9ZVPizpoDGtYw2S6HDkiy8kaqJ7HQLzBYvReWTxsH9puqI%3D" rel="nofollow">不断缩放的球</a></li><li><a href="https://link.segmentfault.com/?enc=1QyZWdGNF4wTVM%2F%2BI6v7mQ%3D%3D.6B%2Fa3VpTAcfdLEfUGCExWBUD1ot86Rn2Lrrxhi0rWHVYcg3iZWKi89SwmLXrtbZwAZBp9dMmEjCxHGQDa65hd9GfFokJfnaCH1woOoc%2Bkpc%3D" rel="nofollow">两轴同时改变的圆</a></li><li><a href="https://link.segmentfault.com/?enc=VDtNxDLObrs4fwDibx0KzA%3D%3D.j3dw5EubJeQIMNcIUgrUUmWyRajUE9Fjm4lTrUDzE0bDSd1NKAiE4dWwJEYvLOFTkWbaozl6bPxa%2FqIJpbhsD8MvyIckD%2Fi6pQVpokFYMXk%3D" rel="nofollow">绘制波</a></li><li><a href="https://link.segmentfault.com/?enc=pjhGy6jiVOwfhr3NfoZRWw%3D%3D.QFEgurJ91EuVM%2BEDFth4P8nATvqbFs2seAaPa81vplpr7PkjUH4vCuXrL4SGeeQcdLvsNUnJS2wh6715mt9wN6kP7TDrP7gPpD2ed%2Bx5skE%3D" rel="nofollow">一个做圆周运动的圆</a></li><li><a href="https://link.segmentfault.com/?enc=J%2Fk170zAYTYVU9lvULPyOg%3D%3D.5uKvnl2yWR1kcb4VTGyZJkku3RzBLVXRPGW5JzYqPQ1WSRZ9jf3gEwO%2FmdweCqKgKeY2IYIQ1cabddLtPWaUw7f4QXKU6V3%2Fk4Y%2Fok1dRkw%3D" rel="nofollow">一个做椭圆形运动的圆</a></li><li><a href="https://link.segmentfault.com/?enc=cCoiVthN2vjReH67xhpAoA%3D%3D.5%2FycpGaDW%2FztYf3%2Bd6YXcOWjqqkkQlWA%2Fek9CY%2FUfA4kn6M278cIfj64f%2FFoMvjGggTOmwVtvv%2F%2F74ofb2YW5zOR%2FdO4VdmirL9fDnawYU0%3D" rel="nofollow">计算两个随机块的距离</a></li><li><a href="https://link.segmentfault.com/?enc=a3bWm55%2BChNA0pDhak9nFQ%3D%3D.gkl7RCOjSle%2FdS5ki%2FFb%2Fca%2FToS7hNTboXDodwurzUGTpgIcbYs7PSLD%2FzEQrvDl8kP5e4F5PhlHd41GwaDzGneqDeq0ds4GjoHsBQkcmZF56yzKYqJPc1HZxIgnyEg1" rel="nofollow">中心点到鼠标的距离</a></li></ul>
【Fes】基于canvas的前端动画/游戏入门(一)
https://segmentfault.com/a/1190000013957160
2018-03-24T12:32:31+08:00
2018-03-24T12:32:31+08:00
calimanco
https://segmentfault.com/u/calimanco
21
<h2>前言</h2><p>本系列虽说是基础教程,但这是相对动画/游戏领域来说,在前端领域算是中级教程了,不适合前端小白或萌新。阅读前请确保自己对前端三大件(JavaScript+CSS+HTML)的基础已经十分熟悉,而且有高中水平的数学和物理知识。demo 采用 ES6 编写,遵循 Airbnb 规范,不依赖第三方框架或库,请在现代浏览器里运行。<br>大部分例子来自《Foundation HTML5 Animation with JavaScript》,感谢这本书作者的辛劳和启发。本教程也可以算是该书的精(tian)简(you)优(jia)化(cu)版,既是我的个人读书笔记,也是经验总结,方便没空看书的忙人阅读。<br>本人能力有限,欢迎牛人共同讨论,批评指正。</p><h2>何为动画/游戏</h2><blockquote>【科普】动画是指由许多帧静止的画面,以一定的速度(如每秒 16 张)连续播放时,肉眼因视觉残象产生错觉,而误以为画面活动的作品。为了得到活动的画面,每个画面之间都会有细微的改变。而画面的制作方式,最常见的是手绘在纸张或赛璐珞片上,其它的方式还包含了运用黏土、模型、纸偶、沙画等。</blockquote><p>使用 H5 技术实现动画原理跟传统动画是一样的,都是利用“视觉暂留”现象,计算机通过一定的规则运算得到一个画面(像素数据),然后以一定速度连续播放就形成动画。但也有些许不同,传统动画的重点是绘画技法的表现,也就是每张图画得漂亮,而计算机动画更关心的是如何确立运算规则,这也是数学和物理知识的运用。<br>在计算机领域,动画和游戏界限并不明显,他们的差别就是是否有交互性,如果玩家有一定的改变动画的操作,再加上一些游戏规则,那就可以称得上是游戏了。</p><blockquote>【PS】顺便一提,常有疑问为什么电脑玩游戏卡,看电影不卡呢?<br>因为所谓的数字版电影,不论是三维动画还是二维动画,都是已渲染好的画面,也就是数据是一帧帧的图片,而计算机只需要按顺序换图片就能播放。但游戏的画面是实时计算出来的,如果计算机性能不行,也就是无法在下一帧完成渲染,画面自然就会卡顿。在 PC 和主机性能低下的年代,将部分游戏画面预渲染后放入游戏也是常见的提高性能的做法。</blockquote><h2>H5 相关技术概述</h2><h3>canvas</h3><blockquote>【科普】<canvas> 是 HTML5 新增的元素,可用于通过使用 JS 中的脚本来绘制图形。例如,它可以用于绘制图形,制作照片,创建动画,甚至可以进行实时视频处理或渲染。</blockquote><p>作为上世代 flash 的升级替代品,简单来说就是浏览器提供一个画布,你的工作就是用js操作画笔在上面画画,不断重复画画和擦除的工作,就可以实现动画。<br><a href="https://link.segmentfault.com/?enc=QNGzoM4G9xEq10G73pzZvg%3D%3D.SVA5hpWjLNyLnBk9oaRAF3OyA7ZKyizPMWeitMcF0nB2w%2FqNQ7HPY6aBJm9yeZ%2BfN8KPtx6wD8K9EbeTVF9OsQ%3D%3D" rel="nofollow">canvas相关文档</a></p><h3>用户交互</h3><p>交互是游戏的根本,H5 上的交互不外乎鼠标、触摸和键盘这几种,其实就是 DOM 标准的事件流。我们在事件中拿到屏幕上的坐标或键盘的键位代号,执行相应的操作。这不是本教程重点,不懂的右转 HTML 相关基础,这里就不细说了。</p><h3>用户交互的示例</h3><p>触摸事件需要打开浏览器的模拟功能才能有效果;打印内容需要在控制台查看。</p><ul><li><a href="https://link.segmentfault.com/?enc=HjQzjkyQFsxgPV0aJlZoEQ%3D%3D.%2BmsMAwnWSsnC%2BLbsDKU7x8tt3FEwijXd0v9TEiujqAqcML1N0MolPIT1hH9Ao8XfrVWr2ZFJWhNUu%2B3Hvbj8cB3ucAvvUwOrzDNva0VjzFs%3D" rel="nofollow">演示所有鼠标事件</a></li><li><a href="https://link.segmentfault.com/?enc=jCah7ylROHCZHW2tSfN0Fw%3D%3D.51xD8cHisw1IH%2F6PQo%2FVVSo2jdn5l2m7nRAWBdBpqny4ZrekJ%2F0J5O6sothXkxeUKs8PKQ5UPqh9VGEOf8AYC8eUym86XWfixt%2FSZY5ANNEf0RMPedy21q06WGt7oZsl" rel="nofollow">演示鼠标按下坐标获取</a></li><li><a href="https://link.segmentfault.com/?enc=fhfNWWk5VLFNXyiIPnagqA%3D%3D.x3PXafAfyJ6EJh2axrS9wq37gOncjOj86jfbykAUfegNe4fVHEhdgPYb4t8dgP4KEtodk%2FLlal7KWw6jehkCq7E7ydsvEjNX7QSzXMZcXCA%3D" rel="nofollow">演示触摸事件</a></li><li><a href="https://link.segmentfault.com/?enc=NTvj%2BAmMWlSsibqhgLr3tQ%3D%3D.vebpac9pfbN6uVP5n6LfbV4TvURKUrqdxbS%2BdKEA44RImygRznRXvV3RZYbNKqYN%2FG1aA89oZckYonXWjKhNr4nxOmRVY7%2Bz378Soxya%2F5ExV3H7INvNcNFJO5JurpTi" rel="nofollow">演示键盘事件</a></li><li><a href="https://link.segmentfault.com/?enc=gPDg3nILB7WIankNIXv1Sg%3D%3D.fy5E08D2y0CM0IkdRhqJog30PgQ%2B5xRQcwiwggstsgFgP0Dcfj2n5Mq2nOPSpgS4Iej7gX7JecqYC1ca9deUOlXv0LU%2FKxG0Tvg%2F8EEuRAQ%3D" rel="nofollow">演示键盘上、下、左、右</a></li></ul><h3>交互模块封装</h3><p>demo 中为了方便使用封装了这些交互方法,放在工具库 <a href="https://link.segmentfault.com/?enc=R5qzjMMY%2Fr%2F1Mugg5Kp7pA%3D%3D.sDY5l6VlweRWc1P3ABcJxIgAHoZtZk77bWV4Lyn1UNDoktF9W%2FD9L0aTw4qhYKmtVG6R21imq4%2FY7E8kvWhWFBACXkX6ILwLRebA4o3wipSq54ao4H2O7JNjO6JDvLuB" rel="nofollow">utils.js</a> 里,这里以获取鼠标事件,触摸事件同理为例。(见下)</p><pre><code class="javascript">utils.captureMouse = function captureMouse(element) {
const mouse = {
x: 0,
y: 0,
};
element.addEventListener('mousemove', (event) => {
let x;
let y;
if (event.pageX || event.pageY) {
x = event.pageX;
y = event.pageY;
} else {
x = event.clientX + document.body.scrollLeft + document.documentElement.scrollLeft;
y = event.clientY + document.body.scrollTop + document.documentElement.scrollTop;
}
x -= element.offsetLeft;
y -= element.offsetTop;
mouse.x = x;
mouse.y = y;
}, false);
return mouse;
};</code></pre><h2>动画循环</h2><p>这是计算机动画在代码层面的核心,简单来说就是一个循环调用(不断递归)的过程,每次循环都是一帧画面的产生,实现方式大体可以归纳为三类。</p><h3>基于帧的动画(requestAnimationFrame)</h3><blockquote>【科普】在视频领域,电影、电视、数字视频等可视为随时间连续变换的许多张画面,而帧是指每一张画面。</blockquote><p>一般来说 1 秒 15 帧就可让人眼不发觉黑暗的间隔,25 帧就可感觉流畅,每秒钟帧数(fps)愈多,所显示的动作就会愈流畅。W3C 所建议的刷新率是 1 秒 60 帧,大部分浏览器是遵循这一标准的。<br>requestAnimationFrame 是 H5 加入的函数,用法类似于 setTimeout,用于告诉浏览器下一帧的时候该干什么,比如一个物体每 1 帧要移动多少距离。它提供基于浏览器的优化实现,是实现 H5 动画的首选,所有 demo 都有使用。至于它优化了什么,下面会提到。<br><a href="https://link.segmentfault.com/?enc=14v8KjMAz0OHiu3p6svyPA%3D%3D.4kDWOLWTrw%2Br2r3cznEpW5wQEoCltZ0ClCOfX9qZ0vygbVHwSxqtvi7Dunh8dZlp1FxjYu%2FyRz7cBKwkl%2FCJKHItM4wWSB2u4BTlcUr6Rfo%3D" rel="nofollow">requestAnimationFrame文档链接</a></p><h3>基于定时器的动画</h3><p>在 requestAnimationFrame 还未出现的时代,一般是用 setTimeout 和 setInterval 实现动画,也可以使用他们模拟 requestAnimationFrame,只需把时间间隔设为 1000/60 毫秒,也就是大约 16.7 毫秒执行下一个循环,当然你也可以定义自己需要的帧率,达到游戏中常见的锁帧效果。<br>所谓模拟,另一层意思就是不可能相同,我们的程序在浏览器沙盒中运行是不知道显卡和显示器硬件实际是否在刷新的,但浏览器是可以知道的,所以浏览器才可以真正的知道什么时候屏幕会刷新,更好的配合硬件工作,这也是 requestAnimationFrame 优于定时器的原因。<br>基于此我们可以创造一个 polyfill 放到工具库 <a href="https://link.segmentfault.com/?enc=Mu%2FFzJPmswHx0kNCEVilTg%3D%3D.U0DJ38yLXjpE55z%2FeVHbEHuIQ3Qys0wb7MOzrufxjOHswUPu7fzmCDxp23D%2BTwvqct8JC%2F8w3LIIC5whV2itFDiBit6PwdyRaLTCBqYRCBt8695BUhK%2FSlzrLJABLgH2" rel="nofollow">utils.js</a> 里。</p><pre><code class="javascript">if (!window.requestAnimationFrame) {
window.requestAnimationFrame = (window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function timeout(callback) {
return window.setTimeout(callback, 1000 / 60);
});
}</code></pre><p>特别注意:js 中给定时器规定的时间间隔仅仅表示最少的时间,而非确切的时间,对于过复杂需要超过时间间隔才执行完的程序,执行时间就会被延后。</p><h3>基于时间的动画</h3><p>其实无论是 requestAnimationFrame 还是定时器,都不能保证以特定速率播放。也就是说复杂的动画在性能较差的计算机上播放,会比它设计速度慢。这个在游戏的体验上是十分不友好的,所以就有了游戏里常见的“跳帧”做法。<br>其实就是使用真实的时间来度量每个物体的运动变化,而不是依靠每帧的变化。将物体每帧移动距离,转变为物体每秒移动距离。<br>由于 demo 都是简单动画,所以暂时不会使用这个操作。</p><h2>计算机中一些数学概念与标准的差异</h2><h3>弧度(radian)与角度(degree)</h3><p>日常我们使用角度会比较多,应该没有人不知道一个圆是 360 度吧(笑)。但计算机中不使用角度概念而是使用弧度。学校也有说过,这里就不讲解他们的关系了,只要明确一圆周是2π弧度,两者的转换用代码表示就是:</p><pre><code class="javascript">let radians = degrees * Math.PI / 180;
let degrees = radians * 180 / Math.PI;</code></pre><h3>坐标系</h3><p>计算机里的坐标系也不是日常使用的标准坐标系,可以说是标准坐标系的颠倒版本,如下图,越往右 x 轴值越大,越往下 y 轴值越大,反之亦然。</p><blockquote>【科普】这个坐标系有一定的历史背景,因为“大屁股”显示器里的电子枪是从左往右,从上往下扫描屏幕的。</blockquote><p><img src="/img/bV6Jz5" alt="坐标系" title="坐标系"></p><p>这个坐标系还导致了另一个问题,就是<strong>角度的正负值是与标准坐标系相反的</strong>,如下图,顺时针角度才是正值,逆时针为负值。 </p><p><img src="/img/bV6JDn" alt="角度" title="角度"></p>
【Count】小程序踩坑记录(Deprecated)
https://segmentfault.com/a/1190000009024985
2017-04-11T15:53:26+08:00
2017-04-11T15:53:26+08:00
calimanco
https://segmentfault.com/u/calimanco
9
<h2>小程序踩坑记录</h2><p>小程序现阶段缺陷还很多,在安卓手机上的性能也是很糟糕,估计实用性还不高。<br><br>一步一步都是坑,这里作为个人踩坑收集用(内含吐槽),也是经验分享,欢迎issues讨论。</p><h3>框架部分</h3><h4>1、残念的数据绑定</h4><p>要实现页面数据响应必须通过setData设定值,如果直接设定data里的值则无页面响应。<br><br>不能像其他MVVM框架那样自动响应,无语也无解。</p><h4>2、setData()无法进行动态数组操作</h4><p>这也是由于js对象的key部分一定是字符串造成的。<br><br>setDate只支持对静态key的解析,无法传入参数实现动态遍历。</p><h5>比如:</h5><p>有一个数组需要更改其中的值,循环传入i将无效的,只能是固定数字。</p><pre><code class="javascript">for(var i=0; i<3; i++){
this.setData({
array[i]:‘hello’
}
})
}</code></pre><p>如果你照上面这么写的话就会报下面的错误。</p><p><img src="/img/bVL1UV" alt="动态数组设置报错" title="动态数组设置报错"></p><p>官方的意思就是只能这样写:</p><pre><code class="javascript">this.setData({
'array[1]':‘hello’
}
})</code></pre><h5>解决办法:</h5><p>不在遍历中使用setData,可以先遍历修改完后再用setData完整赋值完成响应。<br><br>因为js里数组是地址传递,也就是说实际上已经修改了原数组,用setData只是为了响应页面。</p><h4>3、路由设置必须有序</h4><p>小程序的页面都必须在app.json注册,但这不是随便登记一下就行了,页面登记的顺序一定是有层级关系的。<br><br>如果你把首页放在了某二级页面后面,那就会报错,这个文档没写清楚真心坑爹。</p><pre><code>"pages": [
"pages/index/index", //一级页面
"pages/list/list", //二级页面
"pages/detail/detail", //三级页面
"pages/msg/msg" //额外页面
],
</code></pre><h5>建议:</h5><p>设计时页面分级要明确,排列按顺序,额外页面(比如提示成功或失败的页面)放最后。</p><h4>4、wx.redirectTo(OBJECT)不可跳一级页</h4><p>这个是关闭当前页跳转到指定页的功能,跳到一级页会导致导航栏消失,并且该一级页会被当成一次跳转。<br><br>小程序最多五层跳转,正常一级页不会算入,但如果一级页也被当成一次跳转,那使用几次后就不能动了,因为五次满了,<strong><em>非常危险</em></strong>。<br><br>这点在新的官方文档已经说明,并提供wx.switchTab(OBJECT)跳转到一级页面,不过由于wx.switchTab(OBJECT)不能传参,使用还是比较有限的。</p><h4>5、发起POST请求必须改默认参数</h4><p>默认header['content-type'] 为 'application/json',在get请求中没有问题。<br><br>但如果想要发起POST就必须将header['content-type'] 为 'application/x-www-form-urlencoded',否则就收不到返回数据。</p><pre><code>wx.request({
url: 'test.php', //仅为示例,并非真实的接口地址
data: yourData,
header: {
'content-type': 'application/x-www-form-urlencoded' //这里必须改
},
success: function(res) {
console.log(res.data)
}
})
</code></pre><h4>6、wx.setNavigationBarTitle(OBJECT)的调用时机</h4><p>这个是改变页面标题的接口,必须在onShow触发时才调用。<br><br>如果在onLoad触发时调用,只会一闪而过,然后又变成页面配置json里的名字或全局配置json里的名字。</p><h5>建议:</h5><p>小程序这样的设计体验不是很好,每次都会一闪而过的改名字,如果要避免这种情况就只能在配置json中设置了,不过这样是静态的。</p><h3>样式部分</h3><h4>1、不支持部分选择器</h4><p>样式部分的缺陷是比较严重的,不支持相邻兄弟选择器,不支持级联选择器。。。</p><h5>解决办法:</h5><p>这个暂时无解,只能说改变一下样式命名的习惯,使用横杠连接体现层次,虽然这样盒子多起来会变得很长。<br><br>如果使用预处理,比如我用SASS可以这样写,稍微省点力:</p><pre><code>.list {
padding: 20rpx;
&-name {
color: red;
&-number {
color: blue;
&-info {
font-size: 16rpx;
}
}
}
}
// 编译结果
.list {
padding: 20rpx;
}
.list-name {
color: red;
}
.list-name-number {
color: blue;
}
.list-name-number-info {
font-size: 16rpx;
}
</code></pre><h4>2、button无法正常改样式</h4><p>使用button标签默认是无法更改样式,加上类名也会因为优先级问题不能覆盖原样式,搞不懂这样设计的用意,十分不便。</p><h5>解决办法:</h5><ol><li>可以通过!important提升优先级强行覆盖,不推荐,因为会影响其他默认样式;</li><li>也可以仿照默认样式写法,进行覆盖,基本需要覆盖的样式如下(以primary为例,其他的以此类推),加上[plain]或[size="min"]即是其他镂空版和缩小版的样式;</li><li><strong><em>推荐做法</em></strong>,尽量不破坏原有样式,可以自定义一个type,然后仿照默认样式的写法,就可以自定义button了;</li><li>使用view仿照一个button,把默认的样式复制一份即可,会增加无意义的代码量,而且没有默认的交互事件(active)。</li></ol><table><thead><tr><th>类名</th><th>触发时机</th></tr></thead><tbody><tr><td><code>button[type="primary"]</code></td><td>一般样式</td></tr><tr><td><code>button[type="primary"].button-hover</code></td><td>按下(弹起)瞬间样式</td></tr><tr><td><code>button[type="primary"]:not([disabled]):active</code></td><td>按下样式(可选,没有则使用上面的作为按下样式,[plain]默认有,需覆盖)</td></tr><tr><td><code>button[disabled][type=" primary"]</code></td><td>禁用样式</td></tr></tbody></table><p>按下操作触发顺序是:</p><pre><code>button[type="primary"] > button[type="primary"].button-hover > button[type="primary"][plain]:not([disabled]):active
</code></pre><h4>3、button的默认边框</h4><p>button的默认边框是使用after伪类,新建了一个2倍大小的空白content,设置了border,再缩小为0.5倍,刚好盖在元素上面,下面就是默认按钮的样式。</p><p><img src="/img/bVL1XK" alt="默认button的样式" title="默认button的样式"></p><p>这是一种为了在不同设备实现1px的做法,但本身小程序就有rpx啊,还用这鸡肋的办法让人不解(笑)。 <br>也给更改button样式一点阻碍,需要把after设置display:none才能去掉边框。</p><h4>4、button不同设备上表现差异</h4><ol><li>真机上会出现button内文字高度偏高的问题(安卓机,iOS未测),而模拟器上表现正常(居中),尝试覆盖默认样式进行修正(改为padding撑开盒子的方法代替原来的line-height),并没有效果。所以暂无解决办法;</li><li>min按钮在真机上会出现左右边框消失的情况,暂无解决办法。</li></ol><h4>5、rem在真机设备上的表现有差异</h4><p>即使在根元素page上设置了字体大小,rem在不同设备上的表现还是无法统一。</p><h5>建议:</h5><p>使用rpx作为响应式字体的单位,效果比较好,rpx作为小程序的特性还是在不同设备的表现上还是很实用的。</p>