Giter Site home page Giter Site logo

blogs's Introduction

blogs's People

Contributors

mrlmx avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

blogs's Issues

script 标签的 defer 与 async 属性

image.png

前言

在面试的时候,经常会遇到一道经典的面试题:

如何优化网页加载速度?

常规的回答中总会有一条:

把 css 文件放在页面顶部,把 js 文件放在页面底部。

那么,为什么要把 js 文件放在页面的最底部呢?

我们先来看下这段代码:

<!DOCTYPE html>
<html lang="zh">
  <head>
    <title>Hi</title>
    <script>
        console.log("Howdy ~");
    </script>
    <script src="https://unpkg.com/[email protected]/dist/vue.global.js"></script>
    <script src="https://unpkg.com/[email protected]/dist/vue-router.global.js"></script>
  </head>
  <body>
    Hello 👋🏻 ~
  </body>
</html>

他的执行顺序是:

  • 在控制台打印:Howdy ~
  • 请求并执行 vue.global.js
  • 请求并执行 vue-router.global.js
  • 在页面中展示:Hello 👋🏻 ~
  • 触发 DOMContentLoaded 事件

image.png

浏览器的解析规则是:如果遇到 script 标签,则暂停构建 DOM,转而开始执行 script 标签,如果是外部 script,那么浏览器还需要一直等待其「下载」并「执行」后,再继续解析后面的 HTML。

如果请求并执行「vue.global.js」需要 3 秒,「vue-router.global.js」需要 2 秒,那么页面中的 Hello 👋🏻 ~,则至少需要 5 秒以上才会展示出来。

可以看到,script 标签会阻塞浏览器解析 HTML,如果把 script 都放在 head 中,在网络不佳的情况下,就会导致页面长期处于白屏状态。

在很久以前,一般都是将这些外联脚本,放在 body 标签的最后面,确保先解析展示 body 中的内容,然后再一个个请求执行这些外联脚本。

那有没有其他更优雅的解决方案呢?

答案是肯定的,现在 script 标签新增了 2 个属性:deferasync,就是为了解决此类问题,提升页面性能的。

<script defer>

先看一下 MDN 上的解释:

这个布尔属性被设定用来通知浏览器该脚本将在文档完成解析后,触发 DOMContentLoaded 事件前执行。
有 defer 属性的脚本会阻止 DOMContentLoaded 事件,直到脚本被加载并且解析完成。

文档是直接总结了他的特性,我们先看看下面的代码,展开说说细节,加深一下理解。

<!DOCTYPE html>
<html lang="zh">
  <head>
    <title>Hi</title>
    <script>
      console.log("Howdy ~");
    </script>
    <script defer src="https://unpkg.com/[email protected]/dist/vue.global.js"></script>
    <script defer src="https://unpkg.com/[email protected]/dist/vue-router.global.js"></script>
  </head>
  <body>
    Hello 👋🏻 ~
  </body>
</html>

他的执行顺序是:

  • 在控制台打印:Howdy ~
  • 在页面中展示:Hello 👋🏻 ~
  • 请求并执行 vue.global.js
  • 请求并执行 vue-router.global.js
  • 触发 DOMContentLoaded 事件

image.png

如果在 script 标签上设置了 defer 属性,那么在浏览器解析到这里时,会默默的在后台开始下载此脚本,并继续解析后面的 HTML,并不会阻塞解析操作。

等到 HTML 解析完成之后,浏览器会立即执行后台下载的脚本,脚本执行完成之后,才会触发 DOMContentLoaded 事件。

看起来还是蛮好理解的吧?咱们再来讨论 2 个小细节:

Q1: 如果 HTML 解析完成之后,设置了 defer 属性的脚本还没下载完成,会怎样?
A1: 浏览器会等脚本下载完成之后,再执行此脚本,执行完成之后,再触发 DOMContentLoaded 事件。

Q2: 如果有多个设置了 defer 属性的脚本,那浏览器会如何处理?
A2: 浏览器会并行的在后台下载这些脚本,等 HTML 解析完成,并且所有脚本下载完成之后,再按照他们在 HTML 中出现的相对顺序执行,等所有脚本执行完成之后,再触发 DOMContentLoaded 事件。

最佳实践:

建议所有的外联脚本都默认设置此属性,因为他不会阻塞 HTML 解析,可以并行下载 JavaScript 资源,还可以按照他们在 HTML 中的相对顺序执行,确保有依赖关系的脚本运行时,不会缺少依赖。

在 SPA 的应用中,可以考虑把所有的 script 标签加上 defer 属性,并且放到 body 的最后面。在现代浏览器中,可以并行下载提升速度,也可以确保在老浏览器中,不阻塞浏览器解析 HTML,起到降级的作用。

注意:

  • defer 属性仅适用于外部脚本,如果 script 脚本没有 src,则会忽略 defer 特性。
  • defer 属性对模块脚本(<script type='module'></script>)无效,因为模块脚本就是以 defer 的形式加载的。

<script async>

按照惯例,先看一下 MDN 上的解释:

对于普通脚本,如果存在 async 属性,那么普通脚本会被并行请求,并尽快解析和执行。
对于模块脚本,如果存在 async 属性,那么脚本及其所有依赖都会在延缓队列中执行,因此它们会被并行请求,并尽快解析和执行。
该属性能够消除解析阻塞的 Javascript。
解析阻塞的 Javascript 会导致浏览器必须加载并且执行脚本,之后才能继续解析。

感觉这段描述的已经蛮清晰了,不过咱们还是先看看下面的代码,展开说说细节,加深一下理解。

<!DOCTYPE html>
<html lang="zh">
  <head>
    <title>Hi</title>
    <script>
      console.log("Howdy ~");
    </script>
    <script async src="https://google-analytics.com/analytics.js"></script>
    <script async src="https://ads.google.cn/ad.js"></script>
  </head>
  <body>
    Hello 👋🏻 ~
  </body>
</html>

他的执行顺序是:

  • 在控制台打印:Howdy ~
  • 并行请求 analytics.jsad.js
  • 在页面中展示:Hello 👋🏻 ~
  • 根据网络的实际情况,以下几项会无序执行
    • 执行 analytics.js (下载完后,立即执行)
    • 执行 ad.js (下载完后,立即执行)
    • 触发 DOMContentLoaded 事件(可能在在上面 2 个脚本之前,之间,之后触发)

image.png
浏览器在解析到带有 async 属性的 script 标签时,也不会阻塞页面,同样是在后台默默下载此脚本。当他下载完后,浏览器会暂停解析 HTML,立马执行此脚本。

看起来还是蛮好理解的吧?咱们再来讨论 2 个小细节:

Q1: 如果设置了 async 属性的 script 下载完之后,浏览器还没解析完 HTML,会怎样?
A1: 浏览器会暂停解析 HTML,立马执行此脚本,等执行完之后,再继续解析 HTML。

Q2: 如果有多个 async 属性的 script 标签,那等他们下载完成之后,会按照代码顺序执行吗?
A2: 不会。执行顺序是:谁先下载完成,谁先执行。async 的特点是「完全独立」,不依赖其他内容。

最佳实践:

当我们的项目,需要集成其他独立的第三方库时,可以使用此属性,他们不依赖我们,我们也不依赖于他们。
通过设置此属性,让浏览器异步下载并执行他,是个不错的优化方案。

注意:

  • async 特性仅适用于外部脚本,如果 script 脚本没有 src,则会忽略 async 特性。

总结

defer

  • 不阻塞浏览器解析 HTML,等解析完 HTML 之后,才会执行 script
  • 会并行下载 JavaScript 资源。
  • 会按照 HTML 中的相对顺序执行脚本。
  • 会在脚本下载并执行完成之后,才会触发 DOMContentLoaded 事件。
  • 在脚本执行过程中,一定可以获取到 HTML 中已有的元素。
  • defer 属性对模块脚本无效。
  • 适用于:所有外部脚本(通过 src 引用的 script)。

async

  • 不阻塞浏览器解析 HTML,但是 script 下载完成后,会立即中断浏览器解析 HTML,并执行此 script
  • 会并行下载 JavaScript 资源。
  • 互相独立,谁先下载完,谁先执行,没有固定的先后顺序,不可控。
  • 由于没有确定的执行时机,所以在脚本里面可能会获取不到 HTML 中已有的元素。
  • DOMContentLoaded 事件和 script 脚本无相关性,无法确定他们的先后顺序。
  • 适用于:独立的第三方脚本。

另外:asyncdefer 之间最大的区别在于它们的执行时机。

One More Thing

你有没有想过,如果一个 script 标签同时设置 deferasync,浏览器会如何处理?

先说结论:从表现形式上来说,async 的优先级比 defer 高,也就是如果同时存在这 2 个属性,那么浏览器将会以 async 的特性去加载此脚本。

这主要分 2 种情况:

如果是「普通脚本」,浏览器会优先判断async属性是否存在,如果存在,则以async特性去加载此脚本,如果不存在,再去判断是否存在defer属性。

如果是「模块脚本」,浏览器会判断async属性是否存在:

  • 如果存在,浏览器会并行下载此模块和他的所有依赖模块,等全部下载完成之后,会立刻执行此脚本。
  • 如果不存在,浏览器也会并行下载此模块和他的所有依赖模块,然后等浏览器解析完 HTML 之后,再执行此脚本。
  • 另外需要注意的是:在模块脚本上设置 defer 属性是无效的。

一图胜千言

最后,用一张图概括一下这两个属性的加载模式吧:
image.png

思考题 🤔

  • 为什么浏览器在解析到普通的 script 标签时,必须先执行他?
  • 普通的 script 标签会阻塞浏览器解析 HTML,这会导致什么问题?

参考

随手给 Safari 提了一个Bug,让我意外收获了这些新知识

前言

前段时间,项目在进行 JWT 改造,用户的身份认证从 cookie 改成了 token

上线之后,用户反馈了一个 Bug:

我打开 A 页面之后,就自动跳转到登录页面了,但是打开其他页面是正常的。

询问了一些基本信息之后,发现他用的浏览器是 Safari,然后我按照他的操作路径模拟了一遍,果然复现了。

当时心想:既然能稳定复现,那就问题不大,应该很好解决 🤔 ~

你可以打开这个在线地址,点击按钮,在 Chrome 中会正常展示数据,在 Safari 中会提示 request error

image.png

现象描述

P.S.:为避免泄露信息,以下都是虚假内容,但不影响阅读。

经过调试之后发现,是因为有一个接口由于请求地址不对,接口返回了 301,需要重定向到新的接口:

  • 前端请求的地址:/api/user/list
  • 后端需要的地址:/api/user/list-new

在 Safari 中具体请求如下(Safari 自动将原请求和重定向合并为了 1 个请求记录):

image.png

当浏览器收到 3XX 的重定向状态码后,会自动对新的地址发起请求(也就是响应体中 Location 的地址)。

然而 Safari 浏览器在自动发起新的请求时,没有携带自定义的 Authorization 请求头,所以导致接口鉴权失败,返回了 401Unauthorized)。

前端在收到接口响应后,由于响应体里面也返回了未登录的业务 code,就自动跳转到了登录页面。

这里还发现了一个有意思的细节:Safari 在发起重定向请求时,虽然没有带上 Authorization 请求头,但是会带上 cookie,这也说明了为什么在改造为 JWT 之前,Safari 能正常使用的原因(图中没有携带 cookie 是因为 Demo 中没有 set-cookie)。

然后我又在 Chrome 中进行了相同的测试,发现 Chrome 在发起重定向请求时,会携带 Authorization 请求头,所以能够正常使用。

在 Chrome 中,具体请求如下(Chrome 中请求和重定向是 2 条独立的记录):

image.png

image.png

猜测可能

我当时的场景,后端返回的状态码是 301,开始以为是各浏览器针对 301 响应码的处理逻辑不一样。

当时脑子里有个印象是:浏览器没有按照规范处理 301302,所以后续规范新增了 307308

尽管标准要求浏览器在收到该响应并进行重定向时不应该修改 http method 和 body,但是有一些浏览器可能会有问题。所以最好是在应对 GETHEAD 方法时使用 301,其他情况使用 308 来替代 301
--- MDN:301 Moved Permanently

所以我想用 Charles 把请求的响应码改为 308 试试效果,搜了一些关于 Charles 修改 Status Code 的教程,找到了这个方法 is-it-possible-to-rewrite-a-status-code-with-charles-proxy,还有另一篇更为详细的教程 rewrite-modify-the-response,但是设置的流程比较繁琐。😫

不过好在,发现了一个另外的网络调试软件 Proxyman,看到他之后,有一种春天来了的感觉。

使用起来非常方便,甚至可以直接通过 JavaScript 动态修改 Request / Response 的任何内容,这对于前端来说实在是太友好了,而且免费版就足以使用,强烈推荐大家下载体验一下 ~

可惜的是,通过 Proxyman 将请求的响应码改为 308 后,发现 Safari 依旧不会携带 Authorization 请求头。😭

搜索问题

既然猜想的方向不对,那就只能请教万能的 Google 了。

首先是在 stackoverflow 找到了这个问题 safari-does-not-persist-the-authorization-header-on-redirect,但是并没有解决。(在我写这篇文章的时候,发现 @sideshowbarker 已经给了最新回复:已在 Safari 15.4 修复)。

不过,提问者给出了自己项目的解决方案:最终改为了使用 cookie 来做身份验证。🤣

We have implemented other workaround for this. We have implemented cookie based authentication later on.

后面又找到了另外一个相关问题 How to prevent Safari from dropping the Authorization header when following a same-origin redirect?,当时也是没有解决。

不过,我看到里面有一个评论说,他准备去 https://bugs.webkit.org/ 提 Bug,但是我在这个网站里面搜了一圈,没发现相关的问题。

所以,我就去注册了账号,新建了一个 Bug:Safari does not persist the Authorization header on redirect,并且在那个帖子里同步了一下:我已经创建过 Bug 了,后续可以在那个链接里面跟进(主要也是方便后续有其他小伙伴遇到这个问题,可以追踪后续进展)。

由于我的账号声望不足,没办法直接在问题中追加评论,所以只能新建一个回答。

楼主收到回答之后,说这个不是解决问题的方案,所以帮我把链接贴到了评论区,然后把那个回答给删了。

image.png

跟进处理

给 webkit 团队提完 Bug 之后,大概过了 2 周,官方回复说:他在技术预览版的 Safari中没有复现,并且给了一个他用来测试的 Demo,希望我也能够提供一个我这边复现的 Demo。

收到回复之后没多想,就立马着手准备代码。想着以最简单的方式复现,所以就用 Koa 来处理请求。

里面有一段逻辑是通过 ctx.headers 获取 Authorization 的值,但是 ctx.headers.Authorization 竟然是 undefined,后续将 ctx.headers 对象打印出来才发现,里面所有的 key 都是小写的。

// html
// 省略其他代码 ...

fetch("/api/user/list", {
  headers: { Authorization: `Bearer xxxxxxxxx` },
})

// 省略其他代码 ...

------

// koa server
app.use((ctx) => {
  // 省略其他代码 ...
  
  // koa 会自动将请求头的所有 key 转为小写
  if (ctx.headers.authorization !== "Bearer xxxxxxxxx") {
    ctx.status = 401;
  }
  
  // 省略其他代码 ...
});

开始以为是 Koa 自动把 request.header 中的 key 转为了小写,感到很疑惑。所以在 Koa 的 issues 中搜了一下,找到了这个问题,才知道这是 Node.js 的 http 模块做的处理。

Node.js 将 header 的字段统一转为小写的原因是 rfc2616#section-4.2 中规定 header 字段是大小写不敏感的:

Each header field consists of a name followed by a colon (":") and the field value. Field names are case-insensitive.

所以 Node.js 统一转为小写,避免使用方再重复做大小写转换的处理逻辑[参考]。

the thing is that since you need to operate on them and they are case-insensitive you need to either upper or lower case them. The latter is the case of nodejs. Someone as to do it, in this case nodejs is saving you that step IMO. Also applications shouldn't rely in the case of those fields since, again, they are case-insensitive.

不过统一处理也存在一些不合理的场景,比如使用 Node.js 做 HTTP 代理服务时,转发后的请求头都自动变为小写了,那么会导致下层服务获取不到原始的请求头字段,这样会在传输的过程中破坏原始数据[参考]。

One example : Proxy.
If, as I was developing, you are proxying between a client and a server (two couchdb instances replicating in that case) for which case is important (it is probably wrong, but you can't modify the code) : the client didn't understand the answer if the headers' case was not the original one. In that case, you need to forward headers exactly as received.

不过好在,Node.js 后续提供了新的 API,可以通过 req.rawHeaders 获取原始数据,具体可以看这个文档

关于 Node.js http 模块自动将 header 字段转为小写的详细讨论可以看这个链接

P.S.:HTTP/2 的 rfc7540#section-8.1.2 规范,已明确规定 header 必须使用小写:

However, header field names MUST be converted to lowercase prior to their encoding in HTTP/2.

好了,弄明白了 header 小写的问题之后,就把准备好的 Demo 代码提交到了 GitHub。

完事之后,想着要不顺便部署一下吧,方便他们测试,我也可以趁这个机会体验一下 vercel

不过部署之后,访问一直是 404,看了官方文档才发现,处理请求的文件,需要在/api目录中才行。

可是我不太想修改文件目录,因为修改之后,访问的页面路径,也需要加上 /api 前缀。

既然这样的话,那顺便试试 Next.js ?

没想到,代码写完之后,部署非常丝滑,一行命令直接搞定,而且给了对应的访问域名,重点是完全免费。

https://safari-redirect-demo.vercel.app/,这个是部署后生成的域名,二级域名是我 GitHub Demo 仓库的名称。

好了,Demo 准备完毕之后,就去回帖了,最终得出的结论是:这个 Bug 已经在 macOS 12.3 中修好了。

image.png

让我没想到的是,之前已经有人提过一个 Authorization header lost on 30x redirects 的类似 bug 了,可是我当初怎么没有搜到这个 😂。

另外,让我震惊的是外国友人也这么卷,快 23:00 了还在工作 🤔。

解决方案

现在来聊聊,在这整个过程中,我整理的 3 种解决方案。

升级版本(不靠谱)

目前 Safari 15.4(iOS 15.4, macOS 12.3) 已经修复了此问题,所以升级版本即可解决。

如果是公司内部系统,则可以根据实际情况来决定是否通过升级版本来解决此问题。

如果是对外项目,那这个方法肯定是没戏了,毕竟我们没办法控制用户升级系统。

存储到 cookie(可行)

在前面搜索的过程中,也有人通过把 token 放到 cookie 中存储来解决这个问题的,因为 Safari 重定向时,虽然不会携带 Authorization,但是会把 cookie 带上。

但是这样需要后端配合,需要把鉴权的整个流程都改为从 cookie 中取值,这就要看你怎么说服后端大哥配合了。

那么话又说回来了,既然要把 token 储存到 cookie,如果没有什么特殊场景的话,那可以直接考虑放弃 JWT 这套方案了 😝。

手动处理(可行,Hack 思路)

Fetch

Fetch 有一个属性是 redirect,它目前支持 3 个属性(摘自javascript.info:fetch-api#redirect):

  • "follow" —— 默认值,遵循 HTTP 重定向,
  • "error" —— HTTP 重定向时报错,
  • "manual" —— 允许手动处理 HTTP 重定向。在重定向的情况下,我们将获得一个特殊的响应对象,其中包含 response.type="opaqueredirect" 和归零/空状态以及大多数其他属性。

当时看到 manual 属性的时候,虽然描述看起来有点懵,但是想着可以手动处理重定向的请求,那肯定没毛病。

当需要重定向时,我们从 header 中的 location 中获取到新地址,然后手动对新地址发起一个请求,并且把 Authorization 带上,这样总可以了吧 ~

于是我开心的写了如下代码:

fetch("/api/user/list", {
  headers: { Authorization: `Bearer xxxxxxxxx` },
  redirect: "manual",
})
.then((response) => {
  console.log("response", response);
  if (response.status === 307) {
    const newURL = response.headers.get("location");
    console.log(newURL);
  }
})
.catch((e) => {
  console.log(e);
});

可是执行之后,并没有打印 newURL,然后看了一下返回的 response 对象,里面没啥有用的信息。

status 的值是 0headers 是个空对象
image.png

感觉很奇怪,但是又看了看上面对 manual 的定义,好像明白了 归零/空状态 是什么意思 😂。

然后又开始了探索之路。

最终在 Fetch 规范仓库中搜到了一个 issue:Cannot get next URL for redirect="manual"

这位同学和我有一样的疑惑(再次验证了那句话,你遇到过的 90% 的问题,其实别人都早已遇到了🤔):

  • 问:设置了manual之后,获取不到 redirect_url。[参考]
  • 答:规范设计如此。[参考]
  • 问:是不是应该完善一下文档,说明一下这个情况,或者把manual 换个名称更好?否则会引起误解。[参考]
  • 答:你说的有道理,但是现在改名称已经为时过晚,因为浏览器都已经实现了这个功能。[参考]

好了,没戏了,万万没想到,manual的意思不是手动处理,而是让浏览器不做处理 😳。

难道这就是传说中的定义不规范,开发两行泪么 😭(这让我想起了请求头中 referer 字段拼写错误的问题,小声 BB 🤫)。

不过好消息是,社区已经意识到这个问题,并且在讨论解决方案了,不过,这个问题从 2017 年被提出,到现在已经 5 年过去了,还没有标准落地,具体讨论可以查看此链接跟进。

既然 Fetch 无法获取到重定向的 URL,那 XMLHttpRequest 呢?

XMLHttpRequest

用 XMLHttpRequest 写了一个 Demo,发现浏览器也是会自动对重定向做出处理,打印的是重定向后最终的状态码,值为 200,并不会打印 307,并且会获取到重定向后的返回值。

const xhr = new XMLHttpRequest();

xhr.open("GET", "/api/user/list");

xhr.setRequestHeader("Authorization", "Bearer xxxxxxxxx");

xhr.send();

xhr.onload = function () {
  console.log(`${xhr.status}: ${xhr.statusText}`);
  console.log(xhr.response);
};

xhr.onerror = function () {
  console.log("Request failed");
};

然后通过搜索之后,找到这 2 个很有价值的问题:

总结来说,按照规定 XMLHttpRequest 在收到重定向请求时,会自动对新 URL 发起请求,并且规范中没有提供阻止重定向的方法。

但是可以通过 responseURL 属性获取到重定向的 URL:

xhr.onreadystatechange = function () {
  if (this.readyState === this.DONE) {
    console.log("responseURL", this.responseURL);
  }
};

我试了一下,responseURL 这个属性有 2 种取值逻辑,当本次请求:

  • 没有触发重定向时,打印的是本次请求的 URL。
  • 触发重定向时,打印的是重定向的 URL。

但是规范中提到,他有可能是空字符串:

The responseURL getter steps are to return the empty string if this’s response’s URL is null; otherwise its serialization with the exclude fragment flag set.

所以,如果你一定要终止重定向请求,那么可以通过 responseURL 和原始的请求 URL 进行对比,如果不同,则表明存在重定向,但是不推荐使用这种逻辑判断,因为这不是官方标准。另外,这里的 status 取到的是重定向后的值,所以不能用它对比。

const xhr = new XMLHttpRequest();

const requestURL = "/api/user/list";

xhr.open("GET", requestURL);

xhr.setRequestHeader("Authorization", "Bearer xxxxxxxxx");

xhr.send();

xhr.onload = function () {
  // 如果 status 等于 0,则表明当前请求被终止
  if (xhr.status === 0) {
    console.log("当前请求被终止");
    return;
  }
  console.log(`${xhr.status}: ${xhr.statusText}`);
  console.log(xhr.response);
};

xhr.onreadystatechange = function () {
  if (this.readyState === this.DONE) {
    // 在这里判断 responseURL 是否 和 原始 URL 一致(this.responseURL 也有可能为空)
    if(this.responseURL && this.responseURL !== requestURL){
        console.log("重定向地址为:", this.responseURL);
        // 如果不一致,则终止请求
        this.abort();
    }
  }
};

xhr.onerror = function () {
  console.log("请求失败");
};

另外,通过这种逻辑进行重定向判断的,需要注意以下两点:

  1. 通过 abort 终止重定向请求后,需要在 onload 事件中做一层判断,因为 Safari 在请求终止后,还是会进入到 onload 事件中。可通过 status 进行判断,终止之后的请求,status 的值为 0
  2. 通过 abort 终止重定向请求后,浏览器还是会对重定向的新 URL 发起请求,服务器也会正常处理并响应,所以需要注意此请求是否有「副作用」。

到了这个时候就很有意思了,原来 XMLHttpRequest 不仅可以获取重定向的 URL,而且还可以通过 abort 终止重定向(不过并不推荐这种判断逻辑来终止请求)。

好了,现在开始我们的 Hack 思路 ~

Hack 思路

先来说一下基本逻辑:

  1. 通过 Fetch 阻止浏览器自动重定向。
  2. 通过 XMLHttpRequest 获取重定向的 URL。
  3. 自动对重定向的 URL 发起请求。

相关链接:

/**
 * 获取重定向的 URL
 */
const getRedirectURL = (url, options) => {
  return new Promise((resolve) => {
    const xhr = new XMLHttpRequest();
    xhr.open("GET", url);
    const { headers = {} } = options;
    // 设置请求头
    Reflect.ownKeys(headers).forEach((key) => {
      xhr.setRequestHeader(key, headers[key]);
    });
    xhr.send();
    xhr.onreadystatechange = function () {
      if (this.readyState === this.DONE) {
        // 在这里判断 responseURL 是否 和 原始 URL 一致(this.responseURL 也有可能为空)
        if (this.responseURL && this.responseURL !== url) {
          // 如果不一致,则终止请求
          resolve(this.responseURL);
          // 终止请求之后,this.responseURL 的值会被置空,所以需要在最后调用。
          this.abort();
          return;
        }
        console.log("未发生重定向,responseUR 的值为:", this.responseUR);
        resolve();
      }
    };
    xhr.onerror = function (e) {
      console.log("请求失败", e);
      resolve();
    };
  });
};

/**
 * 封装处理 重定向 的 Fetch
 */
const request = (url, options) => {
  return fetch(url, options).then(async (response) => {
    // 手动处理 HTTP 重定向时,type 的值为 "opaqueredirect"
    if (response.type === "opaqueredirect") {
      const redirectURL = await getRedirectURL(url, options);
      if (!redirectURL) {
        throw new Error("未获取到重定向 URL");
      }
      // 自动对重定向的 URL 发起请求
      return request(redirectURL, options);
    }
    return response.json();
  });
};


(async () => {
  // 发请求
  const result = await request("/api/user/list", {
    headers: { Authorization: `Bearer xxxxxxxxx`, foo: "bar" },
    redirect: "manual",
  });
  console.log(result);
})();

使用上述方法,虽然在 Safari 中可完美运行,但是控制台还是会打印 401 的错误,暂时还没有找到去除这个错误的方法,不过他并不会影响 JS 的运行逻辑,可暂时忽略。

image.png

另外一个需要注意的点是:最好根据浏览器做一层判断,如果是 Safari,则将 redirect 设置为 manual,否则不进行处理。这样可以避免 Chrome 发起过多的无用请求(Chrome 总共会发出 5 个请求)。

image.png

总结

这篇文章,前前后后总共写了 1 个多月,从最开始遇到这个问题,到 Safari 官方回复已在新版本中解决,再到写文章时梳理思路的整个过程,一直在刷新自己已有的认知,也使得这个过程变得非常有意思。

最开始遇到这个问题时,搜索了大量的资料,最终得出的结论是:可能是 Safari 的问题,只能等待官方解决,所以我给官方提了 Bug。

但是在搜索的过程中,我也发现了一些比较有意思的思路,所以就把那些链接记录了下来,准备空闲的时候整理一下。在这个过程中,其实大脑已经有了一个大概的解决方案和思路,就是通过 cookie 或者 Fetch 的 redirect 属性解决。

所以我在写这篇文章的时候,重点内容是 Fetch 解决方案,但是我在动手尝试的时候,发现redirectmanual 属性,不是手动处理的意思。

然后又开始搜索「如何获取重定向的 URL」。最终发现 XMLHttpRequest 可以获取到,所以就有了最后的 Hack 思路,也算是画了一个完满的句号。

那么,我采用的是哪个方案呢?🤔

答案是,我没有选择上述的任何一个方案。因为我的场景只是单纯的把请求地址写错了,导致后端重定向到正确的地址。所以只需要把 URL 改一下即可。😝

收获

虽然整个过程非常的曲折漫长,但是这也让我意外的有了这些收获:

  • 发现了一个非官方的 charles 文档,比较不错。
  • 发现了一个非常好用的网络调试软件:Proxyman
  • HTTP/1.x 的请求头字段大小不明感,并且 Node.js 自动将他们都转为了小写。
  • HTTP/2.0 的请求头字段明确要求小写,并且还会进行 HPACK 算法压缩。
  • 体验了 vercel 部署流程,部署 Next.js 应用真的很丝滑,而且还免费。
  • Fetch 的 redirect=manual 配置,并不是手动处理重定向的意思,而是让浏览器不处理重定向。
  • XMLHttpRequest 可以通过 responseURL 获取到重定向的 URL。

参考

如何在 antd Table 的选择框中添加 Tooltip?

Tips,如果你着急想要解决方案:


下面是实现这个功能的心路历程,个人觉得比较有意思,所以记录下来 😜。

前段时间,产品提了一个需求:想要给那些 Table 里面禁止选中的行中加上 Tooltip,提示为什么不允许选中,提升用户体验,就像下面这样 👇🏻:

image

我们知道 antd 的 Table 组件可以通过 rowSelection 属性,控制表格行是否可选择,所以我就直接去 rowSelection 这部分文档里面找相关的配置,可是上下 简单浏览 一番,发现并没有这方面的内容。

所以又直奔 issue,搜索关键字:rowSelection tooltip ,找到了下面几个相关的问题:

找到的唯一可行性方案是:自定义 Column

It's not a common requirement. You can customize the column by your own: https://codesandbox.io/s/1q2x7jx9nj

看这架势,意思是需要咱们自己手动实现一套完整的选择逻辑啊。这让时间本不宽裕的我们,更是雪上加霜 😫。

所以,我随即得出了结论:

这个需求暂时做不了。🤥

可是,这确实是个有意义需求啊 ~ 😭,既然是正经需求,要不,咱们再想想办法 🤔 ?

Hack 思路

因为偶尔会看 antd 的代码,所以本地保存的有他的代码,打开代码的第一步就是 pull 一下最新代码,antd 每周发一版,是在太厉害了(小声BB 🤫)

PS:以下代码的版本均为:[email protected]

再次经过一番 简单浏览 之后,找到了 渲染 CheckBox 的代码

// Record checked
return {
  node: (
    <Checkbox
      {...checkboxProps}
      indeterminate={mergedIndeterminate}
      checked={checked}
      skipGroup
      onClick={(e) => e.stopPropagation()}
      onChange={({ nativeEvent }) => {
        // 省略此部分源码内容
      }}
      />
  ),
  checked,
};

可以看到:{...checkboxProps} 这里是通过解构,直接把 checkboxProps 传给了 CheckBox 组件。

那是不是说,我可以在 checkboxProps 中添加任何 CheckBox 组件支持的属性呢 😏?

然后我在 getCheckboxProps 中返回了如下信息:

<Table
  rowSelection={{
    getCheckboxProps: (record) => {
      return {
        disabled: true,
        children: <span style={{ color: "red" }}>Test</span>,
      };
    },
  }}
  columns={columns}
  dataSource={data}
/>;

嘿嘿嘿,他出现了 😜。

image-20220426200913014.png

验证猜想之后,把代码进行如下改造:

  • 新增一个 span,设置为 CheckBox 的高宽
  • span 外面包上 Tooltip 组件
  • 通过 absolutelyspan 绝对定位到 CheckBox 的位置
  • 设置 z-index,将 span 放在 CheckBox 上方
<Table
  rowSelection={{
    getCheckboxProps: (record) => {
      const props = {};
      if (record.age < 18) {
        props.disabled = true;
        props.children = (
          <Tooltip title="小于 18 岁">
            <span
              style={{
                width: 16,
                height: 16,
                position: "absolute",
                top: 19,
                left: 8,
                zIndex: 1,
                border: "1px solid red",
              }}
            />
          </Tooltip>
        );
      }
      return props;
    },
  }}
  columns={columns}
  dataSource={data}
/>;

image-20220426210534716.png

但是还有些瑕疵:展示 Tooltip 的 CheckBox 纵向错位了,通过审查元素发现,是因为我们加了 children,把 CheckBox 给挤到旁边了。

image-20220429220100896.png

既然被挤走了,那把容器宽度缩小一点 🤔,有一个简单的方法是直接通过 css 覆盖修改这一列的宽度。

  • rowSelection 有一个控制选择框宽度的属性:columnWidth,但就算设置的很小,整列还是会被内容撑开
  • 下面的代码仅用来做演示,没有没有加前缀,注意不要把全局的 Table 列宽都修改了。
.ant-table-selection-column > .ant-checkbox-wrapper{
  width: 16px;
}

.ant-table-selection-column > .ant-radio-wrapper{
  width: 16px;
}

image-20220505193226535.png

好了,到了这一步,Table 的 CheckBox 上添加 Tooltip 已经完成了,Radio 思路也一致,只需要把 span 的坐标调整一下,然后加上圆角即可,完整代码可以见本文开头的链接。

发现原生支持

需求顺利上线之后,想着写篇文章记录一下解决方案,贴到上面几个 issue 中,这样可以在官方支持之前,让大家先临时实现这个功能。

所以想顺带看一下 antd Table 中选择列的渲染逻辑,避免有其他被我忽略的细节导致 Bug。没想到这一看,就把我看懵了,我发现了 renderSelectionCell 这个函数 😱。

const renderSelectionCell = (_: any, record: RecordType, index: number) => {
  const { node, checked } = renderCell(_, record, index);

  if (customizeRenderCell) {
    return customizeRenderCell(checked, record, index, node);
  }

  return node;
};

竟然有个调用 customizeRenderCell 函数的逻辑,一看这函数名,我就觉得有点不对劲🤔,而且还把相关的参数都传了进去。

通过一番顺藤摸瓜,发现了根源。

首先是从 rowSelection 中获取 renderCell 参数,并且重命名为 customizeRenderCell

export default function useSelection<RecordType>(
rowSelection: TableRowSelection<RecordType> | undefined,
 config: UseSelectionConfig<RecordType>
): [TransformColumns<RecordType>, Set<Key>] {
  const {
    // 省略此部分源码内容
    ...,
    renderCell: customizeRenderCell,
    // 省略此部分源码内容
    ...
  } = rowSelection || {};
}

然后在 Table 中找到了调用 useSelection代码片段

const [transformSelectionColumns, selectedKeySet] = useSelection<RecordType>(rowSelection, {
  prefixCls,
  data: mergedData,
  pageData,
  getRowKey,
  getRecordByKey,
  expandType,
  childrenColumnName,
  locale: tableLocale,
  getPopupContainer,
}
);

useSelection 的第一个参数 rowSelection,就是我们在 Table 中传入的 rowSelection 属性 😨。

所以,文档中的 rowSelection 的这个 renderCell 属性竟然被我完整忽略了 😰。。。

image-20220505201405914.png

好了,既然发现了这个秘密,那咱们就试一下效果吧 🙄。

<Table
  rowSelection={{
    renderCell(checked, record, index, node) {
      if (record.age < 18) {
        return <Tooltip title="未满 18 岁">{node}</Tooltip>;
      }
      return node;
    },
      getCheckboxProps: (record) => {
        let checkboxProps = {};
        if (record.age < 18) {
          checkboxProps.disabled = true;
        }
        return checkboxProps;
      },
  }}
  columns={columns}
  dataSource={data}
/>;

不得不说,简直完美 🤗。

image-20220505204447544.png

修改官方文档

鉴于在写这个需求之前,也问过其他同事,有没有做过在 Table 的可选列中展示 Tooltip 的需求,他们给我的答复都是官方文档中没有相关配置,暂时不支持这个功能,就把这类需求都拒绝了。

所以我感觉应该有很大一部分小伙伴会忽略掉 rowSelection 的这个 renderCell 属性,既然如此,就在官方文档中把这个场景提一下,可以让其他小伙伴少走些弯路,早点下班 😜。

写好相关示例并完善文档之后,就给官方提了一个 PR,又是美好的一天 😄。

如何使用 VS Code 调试 Vue.js 项目?

Yellow Duck

简介

此教程会以一个全新的 Vue.js 项目作为模板进行配置,你可以跟随教程一步步操作,也可以按照教程将配置添加到已有的项目中。

仓库地址:https://github.com/mrlmx/debug-vuejs-project-with-vscode

创建项目

通过 vue 提供的 create-vue 脚手架,创建一个 vue3 项目。

npm init vue@latest

注意:通过上述命令,创建的是基于 vite 的项目,而不是基于 webpack 的项目。

然后在 VS Code 中打开创建后的项目:

code ./debug-vuejs-project-with-vscode
  • code 是 VS Code 自带的命令,如果你运行时提示没有此命令,可以看这里设置一下。
  • debug-vuejs-project-with-vscode 是我的项目名称。

生成 sourcemap 文件

Vite

如果是通过 create-vue 创建的项目,则修改 vite.config.ts 配置文件,在开发环境生成 sourcemap 文件。

export default defineConfig({
  build: {
    sourcemap: true,
  },
  // other configs...
});

更多配置,请参考:https://vitejs.dev/config/build-options.html#build-sourcemap

Vue Cli

如果是通过 vue-cli 创建的项目,则修改 vue.config.js 配置文件,在开发环境生成 sourcemap 文件。

module.exports = {
  configureWebpack: {
    devtool: "source-map"
  }
  // other configs...
};

更多配置,请参考:https://cli.vuejs.org/guide/webpack.html

Webpack

如果是自己搭建的项目,则修改自己定义的 webpack 配置文件,在开发环境生成 sourcemap 文件。

module.exports = {
  devtool: "source-map",
  // other configs...
};

更多配置,请参考:https://webpack.js.org/configuration/devtool/#devtool

配置文件

launch.json

通过如下步骤,创建 launch.json 配置文件(如果你的项目中已经存在该文件,则可跳过此步骤)

  1. 选择左侧菜单中的 Debug icon,打开调试菜单。
  2. 点击 create a launch.json file,创建一个新的配置文件。
  3. 选择 Web App(Edge),当然,你也可以选择 Web App(Chrome)

image.png

生成的 launch.json 文件大致长这样(不同版本的 VS Code 可能略有不同):

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "type": "pwa-msedge",
            "request": "launch",
            "name": "Launch Edge against localhost",
            "url": "http://localhost:8080",
            "webRoot": "${workspaceFolder}"
        }
    ]
}

将生成的 launch.json 文件内容,替换为下方配置:

{
  "version": "0.2.0",
  "configurations": [
    {
      // 使用 Edge 浏览器调试
      "type": "msedge",
      // 使用 Chrome 浏览器调试
      // "type": "chrome",

      "request": "launch",
      "name": "vuejs: msedge",

      // 项目的访问地址(需要改成你项目开发环境对应的地址和端口号)
      "url": "http://localhost:5173",

      "webRoot": "${workspaceFolder}",
      "pathMapping": {
        "/_karma_webpack_": "${workspaceFolder}"
      },
      "sourceMapPathOverrides": {
        "webpack:/*": "${webRoot}/*",
        "/./*": "${webRoot}/*",
        "/src/*": "${webRoot}/*",
        "/*": "*",
        "/./~/*": "${webRoot}/node_modules/*"
      },

      // 设置进入 debug 环境之前需要执行的任务。
      // 此名称对应项目中 .vscode 目录下 tasks.json 文件中的 label 属性)
      "preLaunchTask": "vuejs: start"
    }
  ]
}

上面的配置中,有以下几点需要注意:

  • type:VS Code 的 Debug 类型。
    • msedge 的意思是使用 Edge 浏览器进行调试。
    • chrome 的意思是使用 Chrome 浏览器进行调试。
  • url:浏览器启动时访问的地址。
    • 需要改为你项目的开发环境地址,如果一致则无需修改。
  • preLaunchTask:设置进入 debug 环境之前需要执行的任务。
    • 此名称对应项目中 .vscode 目录下 tasks.json 文件中的 label 属性。
    • tasks.json 文件下面会创建。

更多信息:

tasks.json

在项目的 .vscode 目录创建 tasks.json 文件,然后将下方内容粘贴进去:

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "vuejs: start",
      "type": "npm",
      // 需要执行的命令(对应于 package.json 中的 scripts 命令)
      "script": "dev",
      "isBackground": true
    }
  ]
}

上面的配置在执行时,运行的命令是:npm run dev,如果你的项目是其他的启动命令,那么修改为对应的 script 名称即可。

注意:type 的其他可选值是 shell 或者 process,可不要傻乎乎的改成 yarn

type:任务的类型。对于自定义任务,可以设置为 shellprocess

  • 如果设置为 shell,则该命令将被解释为 shell 命令(例如:bash、cmd 或 PowerShell)。
  • 如果设置为 process,则该命令将被解释为要执行的进程。

更多信息:

打断点

咱们将 src/views/AboutView.vue 文件的内容稍微改一下,然后打两个断点。

<script lang="ts" setup>
import { reactive, ref } from "vue";

const other = reactive([
  { name: "lmx", age: 18 },
  { name: "foo", age: 20 },
  { name: "bar", age: 12 },
]);
const count = ref(0);

const handlePlus = () => {
  console.log("plus before", count.value);
  count.value++;
  console.log("plus after", count.value);
};

const handleMinus = () => {
  console.log("minus before", count.value);
  count.value--;
  console.log("minus after", count.value);
};
</script>

<template>
  <div class="about">
    <h1>This is an about page</h1>
    <div>
      <p>{{ count }}</p>
      <button @click="handlePlus">plus</button>
      <button @click="handleMinus">minus</button>
      <hr style="margin: 20px 0" />
      <p v-for="item of other" :key="item.name">
        {{ item.name }}: {{ item.age }}
      </p>
    </div>
  </div>
</template>

在第 13 行和 第 19 行,分别打了 2 个断点(在对应行号左边,点击鼠标左键即可打断点):
image.png

注意事项

需要注意的是:一定要在启动 Debug 前打好断点,否则你将无法匹配到断点。

启动之后,在源文件中添加新的断点是无效的,运行中的编译文件无法匹配到新的断点,除非修改源文件的代码触发编译,这样新生成的编译文件才会映射到新断点。

我猜测的原因是:因为 *.vue 这种 SFC 格式的文件,需要将scripttemplatestyle这 3 个模块拆分编译,实际运行的是编译后的 js 文件,而且每次文件修改或者重启项目之后,都会编译出新的文件。

如果不提前打断点,那么源文件和编译后的文件将不会关联上。

弹窗提示

另外,我发现:不管是否提前打了断点,在启动时都会提示:

The task 'xxx' cannot be tracked. Make sure to have a problem matcher defined。

image.png
我搜了一下,暂时没有找到特别完美的解决方案,这里提供两种蹩脚的方法:

方案一:

如果你不在意这个提示的话,可以每次都点击一下「Debug Anyway」按钮,或者勾选一下「Remember my choice for this task」,以后每次运行的时候就不会提示了,所谓眼不见心不烦。

方案二:

把 launch.json 文件中的 preLaunchTask 属性去掉,Debug 之前自己手动启动项目,反正配置 preLaunchTask 的目的就是自动帮你把项目启动起来,所谓自己动手丰衣足食。

启动 Debug

经过上述配置之后,就可以通过 Debug 模式启动项目了,咱们来分别介绍一下「快捷键」和「手动启动」这 2 种启动方式。

快捷键:F5

如果你的项目只有 1 个 Debug 配置的话,可以直接通过 F5 快捷键启动 Debug 模式,非常的简单方便,推荐日常使用。

image222.jpg

手动启动

如果你的项目有多个 Debug 配置,launch.json 文件的 configurations 数组有多个配置对象。

这个时候 F5 快捷键启动的就是第一个配置,如果你想要启动其他 Debug 配置,就需要通过手动选择了。

可以看到,点击「下拉菜单」之后,展示了 2 个配置选项:vuejs: msedgevuejs: chrome
截屏2022-08-30 00.43.56(2).png
示例中 launch.json 配置文件的内容是这样的:

{
  "version": "0.2.0",
  "configurations": [
    {
      "type": "msedge",
      "request": "launch",
      "name": "vuejs: msedge",
      "url": "http://localhost:5173",
      "webRoot": "${workspaceFolder}",
      "pathMapping": {
        "/_karma_webpack_": "${workspaceFolder}"
      },
      "sourceMapPathOverrides": {
        "webpack:/*": "${webRoot}/*",
        "/./*": "${webRoot}/*",
        "/src/*": "${webRoot}/*",
        "/*": "*",
        "/./~/*": "${webRoot}/node_modules/*"
      },
      "preLaunchTask": "vuejs: start"
    },
    {
      "type": "chrome",
      "request": "launch",
      "name": "vuejs: chrome",
      "url": "http://localhost:5173",
      "webRoot": "${workspaceFolder}",
      "pathMapping": {
        "/_karma_webpack_": "${workspaceFolder}"
      },
      "sourceMapPathOverrides": {
        "webpack:/*": "${webRoot}/*",
        "/./*": "${webRoot}/*",
        "/src/*": "${webRoot}/*",
        "/*": "*",
        "/./~/*": "${webRoot}/node_modules/*"
      },
      "preLaunchTask": "vuejs: start"
    }
  ]
}

看到这里,你应该已经把 debug 的环境配置好了,现在可以开始愉快的调试了。

一些问题

在我写这篇文章的过程中,也发现了几个让我头痛的问题,这里顺带提一下。

在开始说这些问题之前,咱们先看一下这张图:
iShot2022-09-12 11.51.16.png

  • 标注 1:是咱们的代码源文件。
  • 标注 2:是运行时命中断点后,VS Code 自动打开的编译后的文件。
    • 他的名字也有可能是:AboutView.vue?t=1661699383436
    • 有一个t参数,是一个毫秒时间戳,应该是避免缓存的。
  • 标注 3:是第一个断点,行号是 13。
  • 标注 4:是第二个断点,行号是 19。

1. 必须先打断点

我们可以看到,在运行过程中,其实断点命中是编译后的文件。

前面我们提到,在运行 Debug 模式之后再去源文件中添加新断点,正常情况下是无法匹配的。

那如果我在调试过程中,想要添加新断点该怎么办呢?

方法 1: 直接在「编译后的文件」中打新断点。

此方法的弊端是:他是个一次性的断点。

因为新断点是针对这个编译文件的,如果源文件改动后,会重新编译出新的文件,那么这个断点将会失效,后续将不会被匹配到。

方法 2: 直接在「源文件」中打新断点。

此方法的弊端是:需要手动触发编译。

前面也提到过,在源文件中添加新断点之后,运行中的编译文件是无法感知到的,所以必须让源文件触发重新编译,生成新的编译文件,这样源文件的所有断点就会同步映射到新的编译文件中了。

我每次触发重新编译的方式是,随便在某个地方添加一行 console.log(""),然后每次直接修改打印的内容即可。

2. 断点位置不一致

源文件和编译后的文件断点的行号一致,但是对应的行号却是不同的代码,和我们预期的断点位置不一致:
IMG_6354.JPEG
对比之后可以看出,@vue/compiler-sfc 自动将 <script setup> 语法糖转换为了 Composition API 风格代码,并且添加了一些辅助代码。

这就导致单纯的根据行号映射断点位置会有些问题,出现了无法预知的错位情况。

我目前的解决方案是,手动在对应的位置加上 debugger,确保能够一定命中在我想要的位置上。

然后在命中断点后的编译文件中,加上其他想要的新断点。

image.png

3. 改动后会编译出新文件

前面已经提到过,在每次修改完源文件后,都会编译出新的文件。这就导致之前在编译文件中的断点全部失效。

一顿调试之后,VS Code 中会有大量的无效断点标记,虽说不影响使用,但是看起来有点头大。

我暂时没有什么好的解决方案,所以只能在每次调试结束之后,点击「Remove All Breakpoints」按钮,移除所有断点。

image.png

以上就是我遇到的一些问题,如果你有好的解决方案,可以在评论区告知一下,比心 ❤️。

如果喜欢,记得点个赞哦~ 👍

参考

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.