蓝色的秋风

蓝色的秋风 查看完整档案

杭州编辑  |  填写毕业院校美团  |  web前端 编辑 qiufeng.blue 编辑
编辑

JavaScript开发爱好者。全栈工程师。

📬微信公众号:秋风的笔记
📘博客主页:https://qiufeng.blue

个人动态

蓝色的秋风 发布了文章 · 9月22日

⚡️前端多线程大文件下载实践,提速10倍,拿捏百度云盘

背景

没错,你没有看错,是前端多线程,而不是Node。这一次的探索起源于最近开发中,有遇到视频流相关的开发需求发现了一个特殊的状态码,他的名字叫做 206~

屏幕快照 2020-09-21 23.21.05

为了防止本文的枯燥,先上效果图镇文。(以一张3.7M 大小的图片为例)。

动画效果对比(单线程-左 VS 10个线程-右)

single-vs-multiple-donwload

时间对比(单线程 VS 10个线程)

image-20200915235421355

看到这里是不是有点心动,那么请你继续听我道来,那我们先抓个包来看看整个过程是怎么发生的。

`GET /360_0388.jpg HTTP/1.1
Host: limit.qiufeng.com
Connection: keep-alive
...
Range: bytes=0-102399

HTTP/1.1 206 Partial Content
Server: openresty/1.13.6.2
Date: Sat, 19 Sep 2020 06:31:11 GMT
Content-Type: image/jpeg
Content-Length: 102400
....
Content-Range: bytes 0-102399/3670627

...(这里是文件流)` 

可以看到请求这里多出一个字段 Range: bytes=0-102399 ,服务端也多出一个字段Content-Range: bytes 0-102399/3670627,以及返回的 状态码为 206.

那么Range是什么呢?还记得前几天写过一篇文章,是关于文件下载的,其中有提到大文件的下载方式,有个叫 Range的东西,但是上一篇作为系统性地介绍文件下载的概览,因此没有对range 进行详细介绍。

以下所有代码均在 https://github.com/hua1995116/node-demo/tree/master/file-download/example/download-multiple

Range 基本介绍

Range的起源

Range是在 HTTP/1.1 中新增的一个字段,这个特性也是我们使用的迅雷等支持多线程下载以及断点下载的核心机制。(介绍性的文案,摘录了一下)

首先客户端会发起一个带有Range: bytes=0-xxx的请求,如果服务端支持 Range,则会在响应头中添加Accept-Ranges: bytes来表示支持 Range 的请求,之后客户端才可能发起带 Range 的请求。

服务端通过请求头中的Range: bytes=0-xxx 来判断是否是进行 Range 处理,如果这个值存在而且有效,则只发回请求的那部分文件内容,响应的状态码变成206,表示Partial Content,并设置Content-Range。如果无效,则返回416状态码,表明Request Range Not Satisfiable。如果请求头中不带 Range,那么服务端则正常响应,也不会设置 Content-Range 等。

image.png

Range的格式为:

Range:(unit=first byte pos)-[last byte pos]

Range: 单位(如bytes)= 开始字节位置-结束字节位置

我们来举个例子,假设我们开启了多线程下载,需要把一个5000byte的文件分为4个线程进行下载。

  • Range: bytes=0-1199 头1200个字节
  • Range: bytes=1200-2399 第二个1200字节
  • Range: bytes=2400-3599 第三个1200字节
  • Range: bytes=3600-5000 最后的1400字节

服务器给出响应:

第1个响应

  • Content-Length:1200
  • Content-Range:bytes 0-1199/5000

第2个响应

  • Content-Length:1200
  • Content-Range:bytes 1200-2399/5000

第3个响应

  • Content-Length:1200
  • Content-Range:bytes 2400-3599/5000

第4个响应

  • Content-Length:1400
  • Content-Range:bytes 3600-5000/5000

如果每个请求都成功了,服务端返回的response头中有一个 Content-Range 的字段域,Content-Range 用于响应头,告诉了客户端发送了多少数据,它描述了响应覆盖的范围和整个实体长度。一般格式:

Content-Range: bytes (unit first byte pos) - [last byte pos]/[entity length]Content-Range:字节 开始字节位置-结束字节位置/文件大小

浏览器支持情况

主流浏览器目前都支持这个特性。

image-20200916002624861

服务器支持

Nginx

在版本nginx版本 1.9.8 后,(加上 ngx_http_slice_module)默认自动支持,可以将 max_ranges 设置为 0的来取消这个设置。

Node

Node 默认不提供 对 Range 方法的处理,需要自己写代码进行处理。

router.get('/api/rangeFile', async(ctx) => {
    const { filename } = ctx.query;
    const { size } = fs.statSync(path.join(__dirname, './static/', filename));
    const range = ctx.headers['range'];
    if (!range) {
        ctx.set('Accept-Ranges', 'bytes');
        ctx.body = fs.readFileSync(path.join(__dirname, './static/', filename));
        return;
    }
    const { start, end } = getRange(range);
    if (start >= size || end >= size) {
        ctx.response.status = 416;
        ctx.body = '';
        return;
    }
    ctx.response.status = 206;
    ctx.set('Accept-Ranges', 'bytes');
    ctx.set('Content-Range', `bytes ${start}-${end ? end : size - 1}/${size}`);
    ctx.body = fs.createReadStream(path.join(__dirname, './static/', filename), { start, end });
}) 

或者你可以使用 koa-send 这个库。

https://github.com/pillarjs/send/blob/0.17.1/index.js#L680

Range实践

架构总览

我们先来看下流程架构图总览。单线程很简单,正常下载就可以了,不懂的可以参看我上一篇文章。多线程的话,会比较麻烦一些,需要按片去下载,下载好后,需要进行合并再进行下载。(关于blob等下载方式依旧可以参看上一篇

1600705973008

服务端代码

很简单,就是对Range做了兼容。

router.get('/api/rangeFile', async(ctx) => {
    const { filename } = ctx.query;
    const { size } = fs.statSync(path.join(__dirname, './static/', filename));
    const range = ctx.headers['range'];
    if (!range) {
        ctx.set('Accept-Ranges', 'bytes');
        ctx.body = fs.readFileSync(path.join(__dirname, './static/', filename));
        return;
    }
    const { start, end } = getRange(range);
    if (start >= size || end >= size) {
        ctx.response.status = 416;
        ctx.body = '';
        return;
    }
    ctx.response.status = 206;
    ctx.set('Accept-Ranges', 'bytes');
    ctx.set('Content-Range', `bytes ${start}-${end ? end : size - 1}/${size}`);
    ctx.body = fs.createReadStream(path.join(__dirname, './static/', filename), { start, end });
}) 

html

然后来编写 html ,这没有什么好说的,写两个按钮来展示。

<!-- html -->
<button id="download1">串行下载</button>
<button id="download2">多线程下载</button>
<script data-original="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script> 

js公共参数

const m = 1024 * 520;  // 分片的大小
const url = 'http://localhost:8888/api/rangeFile?filename=360_0388.jpg'; // 要下载的地址 

单线程部分

单线程下载代码,直接去请求以blob方式获取,然后用blobURL 的方式下载。

download1.onclick = () => {
    console.time("直接下载");
    function download(url) {
        const req = new XMLHttpRequest();
        req.open("GET", url, true);
        req.responseType = "blob";
        req.onload = function (oEvent) {
            const content = req.response;
            const aTag = document.createElement('a');
            aTag.download = '360_0388.jpg';
            const blob = new Blob([content])
            const blobUrl = URL.createObjectURL(blob);
            aTag.href = blobUrl;
            aTag.click();
            URL.revokeObjectURL(blob);
            console.timeEnd("直接下载");
        };
        req.send();
    }
    download(url);
} 

多线程部分

首先发送一个 head 请求,来获取文件的大小,然后根据 length 以及设置的分片大小,来计算每个分片是滑动距离。通过Promise.all的回调中,用concatenate函数对分片 buffer 进行一个合并成一个 blob,然后用blobURL 的方式下载。

// script
function downloadRange(url, start, end, i) {
    return new Promise((resolve, reject) => {
        const req = new XMLHttpRequest();
        req.open("GET", url, true);
        req.setRequestHeader('range', `bytes=${start}-${end}`)
        req.responseType = "blob";
        req.onload = function (oEvent) {
            req.response.arrayBuffer().then(res => {
                resolve({
                    i,
                    buffer: res
                });
            })
        };
        req.send();
    })
}
// 合并buffer
function concatenate(resultConstructor, arrays) {
    let totalLength = 0;
    for (let arr of arrays) {
        totalLength += arr.length;
    }
    let result = new resultConstructor(totalLength);
    let offset = 0;
    for (let arr of arrays) {
        result.set(arr, offset);
        offset += arr.length;
    }
    return result;
}
download2.onclick = () => {
    axios({
        url,
        method: 'head',
    }).then((res) => {
        // 获取长度来进行分割块
        console.time("并发下载");
        const size = Number(res.headers['content-length']);
        const length = parseInt(size / m);
        const arr = []
        for (let i = 0; i < length; i++) {
            let start = i * m;
            let end = (i == length - 1) ?  size - 1  : (i + 1) * m - 1;
            arr.push(downloadRange(url, start, end, i))
        }
        Promise.all(arr).then(res => {
            const arrBufferList = res.sort(item => item.i - item.i).map(item => new Uint8Array(item.buffer));
            const allBuffer = concatenate(Uint8Array, arrBufferList);
            const blob = new Blob([allBuffer], {type: 'image/jpeg'});
            const blobUrl = URL.createObjectURL(blob);
            const aTag = document.createElement('a');
            aTag.download = '360_0388.jpg';
            aTag.href = blobUrl;
            aTag.click();
            URL.revokeObjectURL(blob);
            console.timeEnd("并发下载");
        })
    })
} 

完整示例

https://github.com/hua1995116/node-demo
`// 进入目录
cd file-download
// 启动
node server.js
// 打开 
http://localhost:8888/example/download-multiple/index.html` 

由于谷歌浏览器在 HTTP/1.1 对于单个域名有所限制,单个域名最大的并发量是 6.

这一点可以在源码以及官方人员的讨论中体现。

讨论地址

https://bugs.chromium.org/p/chromium/issues/detail?id=12066

Chromium 源码

// https://source.chromium.org/chromium/chromium/src/+/refs/tags/87.0.4268.1:net/socket/client_socket_pool_manager.cc;l=47
// Default to allow up to 6 connections per host. Experiment and tuning may
// try other values (greater than 0).  Too large may cause many problems, such
// as home routers blocking the connections!?!?  See http://crbug.com/12066.
//
// WebSocket connections are long-lived, and should be treated differently
// than normal other connections. Use a limit of 255, so the limit for wss will
// be the same as the limit for ws. Also note that Firefox uses a limit of 200.
// See http://crbug.com/486800
int g_max_sockets_per_group[] = {
    6,   // NORMAL_SOCKET_POOL
    255  // WEBSOCKET_SOCKET_POOL
}; 

因此为了配合这个特性我将文件分成6个片段,每个片段为520kb (没错,写个代码都要搞个爱你的数字),即开启6个线程进行下载。

我用单个线程和多个线程进行分别下载了6次,看上去速度是差不多的。那么为什么和我们预期的不一样呢?

image-20200919165242745

探索失败的原因

我开始仔细对比两个请求,观察这两个请求的速度。

6个线程并发

image-20200919170313455

单个线程

image-20200919170512650

我们按照3.7M 82ms 的速度来算的话,大约为 1ms 下载 46kb,而实际情况可以看到,533kb ,平均就要下载 20ms 左右(已经刨去了连接时间,纯 content 下载时间)。

我就去查找了一些资料,明白了有个叫做下行速度和上行速度的东西。

网络的实际传输速度要分上行速度和下行速度,上行速率就是发送出去数据的速度,下行就是收到数据的速度。ADSL是根据我们平时上网,发出数据的要求相对下载数据的较小这种习惯来实现的一种传输方式。我们说对于4M的宽带,那么我们的l理论最高下载速度就是512K/S,这就是所说的下行速度。 --百度百科

那我们现在的情况是怎么样的呢?

把服务器比作一根大水管,我来用图模拟一下我们单个线程和多个线程下载的情况。左侧为服务器端,右侧为客户端。(以下所有情况都是考虑理想情况下,只是为了模拟过程,不考虑其他一些程序的竞态影响。)

单线程

IMG_01

多线程

IMG_02

没错,由于我们的服务器是一根大水管,流速是一定的,并且我们客户端没有限制。如果是单线程跑的话,那么会跑满用户的最大的速度。如果是多线程呢,以3个线程为例子的话,相当于每个线程都跑了原先线程三分之一的速度。合起来的速度和单个线程是没有差别的。

下面我就分几种情况来讲解一下,什么样的情况才我们的多线程才会生效呢?

服务器带宽大于用户带宽,不做任何限制

这种情况其实我们遇到的情况差不多的。

服务器带宽远大于用户带宽,限制单连接网速

IMG_03

如果服务器限制了单个宽带的下载速度,大部分也是这种情况,例如百度云就是这样,例如明明你是 10M 的宽带,但是实际下载速度只有 100kb/s ,这种情况下,我们就可以开启多线程去下载,因为它往往限制的是单个TCP的下载,当然在线上环境不是说可以让用户开启无限多个线程,还是会有限制的,会限制你当前IP的最大TCP。这种情况下下载的上限往往是你的用户最大速度。按照上面的例子,如果你开10个线程已经达到了最大速度,因为再大,你的入口已经被限制死了,那么各个线程之间就会抢占速度,再多开线程也没有用了。

改进方案

由于 Node 我暂时没有找到比较简单地控制下载速度的方法,因此我就引入了 Nginx。

我们将每个TCP连接的速度控制在 1M/s。

加入配置 limit_rate 1M;

准备工作

1.nginx_conf

server {
    listen 80;
    server_name limit.qiufeng.com;
    access_log  /opt/logs/wwwlogs/limitqiufeng.access.log;
    error_log  /opt/logs/wwwlogs/limitqiufeng.error.log;

    add_header Cache-Control max-age=60;
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Methods 'GET, OPTIONS';
    add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,range,If-Range';
    if ($request_method = 'OPTIONS') {
        return 204;
    }
    limit_rate 1M;
    location / {
        root 你的静态目录;
        index index.html;
    }
} 

2.配置本地 host

`127.0.0.1 limit.qiufeng.com` 

查看效果,这下基本上速度已经是正常了,多线程下载比单线程快了速度。基本是 5-6 : 1 的速度,但是发现如果下载过程中快速点击数次后,使用Range下载会越来越快(此处怀疑是 Nginx 做了什么缓存,暂时没有深入研究)。

修改代码中的下载地址
const url = 'http://localhost:8888/api/rangeFile?filename=360_0388.jpg';
变成
const url = 'http://limit.qiufeng.com/360_0388.jpg'; 

测试下载速度

image-20200919201613507

还记得上面说的吗,关于 HTTP/1.1 同一站点只能并发 6 个请求,多余的请求会放到下一个批次。但是 HTTP/2.0 不受这个限制,多路复用代替了 HTTP/1.x序列和阻塞机制。让我们来升级 HTTP/2.0 来测试一下。

需要本地生成一个证书。(生成证书方法: https://juejin.im/post/6844903556722475021)

server {
    listen 443 ssl http2;
    ssl on;
    ssl_certificate /usr/local/openresty/nginx/conf/ssl/server.crt;
    ssl_certificate_key /usr/local/openresty/nginx/conf/ssl/server.key;
    ssl_session_cache shared:le_nginx_SSL:1m;
    ssl_session_timeout 1440m;

    ssl_protocols SSLv3 TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers RC4:HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers on;
    server_name limit.qiufeng.com;
 
    access_log  /opt/logs/wwwlogs/limitqiufeng2.access.log;
    error_log  /opt/logs/wwwlogs/limitqiufeng2.error.log;

    add_header Cache-Control max-age=60;
    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Methods 'GET, OPTIONS';
    add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization,range,If-Range';
    if ($request_method = 'OPTIONS') {
        return 204;
    }
    limit_rate 1M;
    location / {
        root 你存放项目的前缀路径/node-demo/file-download/;
        index index.html;
    }
} 

10个线程

`将单个下载大小进行修改
const m = 1024 * 400;` 

image-20200919200203877

12个线程

image-20200919202302096

24个线程

image-20200919202138838

当然线程不是越多越好,经过测试,发现线程达到一定数量的时候,反而速度会更加缓慢。以下是 36个并发请求的效果图。

image-20200919202427985

实际应用探索

那么多进程下载到底有啥用呢?没错,开头也说了,这个分片机制是迅雷等下载软件的核心机制。

网易云课堂

https://study.163.com/course/courseLearn.htm?courseId=1004500008#/learn/video?lessonId=1048954063&courseId=1004500008

我们打开控制台,很容易地发现这个下载 url,直接一个裸奔的 mp4 下载地址。

image-20200920222053726

把我们的测试脚本从控制台输入进行。

// 测试脚本,由于太长了,而且如果仔细看了上面的文章也应该能写出代码。实在写不出可以看以下代码。
https://github.com/hua1995116/node-demo/blob/master/file-download/example/download-multiple/script.js 

直接下载

image-20200920221657541

多线程下载

image-20200920221853959

可以看到由于网易云课堂对单个TCP的下载速度并没有什么限制没有那么严格,提升的速度不是那么明显。

百度云

我们就来测试一下网页版的百度云。

image-20200919210106839

以一个 16.6M的文件为例。

打开网页版百度云盘的界面,点击下载

image-20200920222309345

这个时候点击暂停, 打开 chrome -> 更多 -> 下载内容 -> 右键复制下载链接

image-20200922004619680

依旧用上述的网易云课程下载课程的脚本。只不过你需要改一下参数。

`url 改成对应百度云下载链接
m 改成 1024 * 1024 * 2 合适的分片大小~` 

直接下载

百度云多单个TCP连接的限速,真的是惨无人道,足足花了217秒!!!就一个17M的文件,平时我们饱受了它多少的折磨。(除了VIP玩家)

image-20200919211105023

多线程下载

image-20200919210516632

由于是HTTP/1.1 因此我们只要开启6个以及以上的线程下载就好了。以下是多线程下载的速度,约用时 46 秒。

image-20200919210550840

我们通过这个图再来切身感受一下速度差异。

image-20200922010911389

真香,免费且只靠我们前端自己实现了这个功能,太tm香了,你还不赶紧来试试??

方案缺陷

1.对于大文件的上限有一定的限制

由于 blob 在 各大浏览器有上限大小的限制,因此该方法还是存在一定的缺陷。

image.png

2. 服务器对单个TCP速度有所限制

一般情况下都会有限制,那么这个时候就看用户的宽度速度了。

结尾

文章写的比较仓促,表达可能不是特别精准,如有错误之处,欢迎各位大佬指出。

回头调研下,有没有网页版百度云加速的插件,如果没有就造一个网页版百度云下载的插件~。

系列文章

参考文献

Nginx带宽控制 : https://blog.huoding.com/2015/03/20/423

openresty 部署 https 并开启 http2 支持 : https://www.gryen.com/articles/show/5.html

聊一聊HTTP的Range : https://dabing1022.github.io/2016/12/24/聊一聊HTTP的Range, Content-Range/

最后

如果我的文章有帮助到你,希望你也能帮助我,欢迎关注我的微信公众号 秋风的笔记,回复好友 二次,可加微信并且加入交流群,秋风的笔记 将一直陪伴你的左右。

image

查看原文

赞 61 收藏 43 评论 6

蓝色的秋风 发布了文章 · 9月6日

谈谈一道字节前端原题(Add sumOf)

前言

最近学弟去面了字节跳动,但是由于面试经验少,面试的时候紧张了,一时之间没有写出来,之后来我交流了一下。那我就来分析分析这道题目。

image-20200906001022824

正文

这题的规则是这样的

给定有一个 Add 函数,要支持以下形式的调用

Add(1)(2)(3).sumOf(); // 输出 6  
Add(1,2)(3)(4).sumOf(); // 输出 10  
Add(1,2,...)(3)(4)(...).sumOf();  // ...

拿到这种题目,我先来说说我自己的做题流程,一般会去找它最简单的形态。我们一步一步来拆解。

先去掉 sumOf() 变成了以下形态

Add(1,2,...)(3)(4)(...)

嗯....有点熟悉...但是还是有点复杂,那我们再去掉无限调用这个限制。

Add(1,2,...)(3)(4)

唔,还是有点难呀...没关系,再砍, 不要传入多个参数。

Add(1)(2)(3)

有....有....有那味了....这....这不就是柯里化吗....

有些小朋友可能没有听过,对于大朋友而言耳熟能详,融会贯通。

我们还是来介绍一下。

在《javascript高级程序设计》这本书中有如下介绍:

与函数绑定紧密相关的主题是函数柯里化,它用于创建已经设置好的一个或者多个参数的函数。函数柯里化的基本方法和函数绑定是一样的:使用一个闭包返回一个函数。两者的区别在于,当函数被调用时,返回的函数还需要设置一些传入的参数。

我们来写写看:

function Add(x) {
    return function (y) {
        return return functio (z) {
            return x + y + z;
        }
    }
}
// 简洁写法  
const Add = x => y => z => x+y+z;

执行一下

Add(1)(2)(3) // 6 

是我们要的那味~

那么我们既然已经写出了这个形态,我们就一步一步反推。

这个时候千万别紧张,我们从最低级的形态出发,写出一个最基本的形态,能够有效地帮助我们建立自信心,吃下定心丸,按照这种方式,哪怕我们最终没有写出完美的结果,让面试官看到你思考解题的过程,也是一种加分。

好,接着说~

那我们接下来需要实现这个样子。

Add(1,2,...)(3)(4)

传入参数不止一个

我们知道,对于不确定参数个数,我们可以使用 arguments 这个对象来获取到所有的入参,但是 arguments 不是一个 Array,但是我们可以使用 ES6 中的 Spread syntax展开语法)去将他变成一个数组。表演继续。

function Add() {
    const nums = [...arguments];
    return function() {
        nums.push(...arguments);
        return function() {
            nums.push(...arguments);
            return nums.reduce((a, b) => a + b);
        }
    }
}

nice!已经离我们最终的形态越来越近了。接下来是这个函数能够无限的进行调用。

Add(1,2,...)(3)(4)(...)

那么怎么样才能无限调用呢?没错,用递归。

function Add() {
    const nums = [...arguments];
    function AddPro() {
        nums.push(...arguments);
    return AddPro;
    }
    return AddPro;
}

嗯,其实我们写到这里发现了... 由于是无限递归,我们没办法确定最后一次函数调用,因此我们需要最后显式调用一个结束的方法来打印出最后的数据。

很自然地,我们可以在 AddPro 添加一个方法 sumOf 来解决这个问题。

学弟就是卡在这里地方,被函数添加上一个方法搞懵了。你是否知道呢?
function Add() {
    const nums = [...arguments];
    function AddPro() {
        nums.push(...arguments);
    return AddPro;
    }
    AddPro.sumOf = () => {
        return nums.reduce((a, b) => a + b);
    }
    return AddPro;
}

好啦好啦,结束啦。

等等

在最后,我再来补充一种方案,function 不仅可以继续挂载 function ~ 还可以挂载变量哦~

function Add() {
    if (!Add.nums) {
      Add.nums = [];
  }
  Add.nums.push(...arguments);
  return Add;
}
Add.sumOf = () => {
    return Add.nums.reduce((a, b) => a + b);
}

如果上述回答有更优解,请公众号后台回复,留下你的微信,红包相送。

我们总结一下,小小的面试题涉及到的基础知识。

闭包、递归、作用域、函数与对象

基础就是基础,永远是你爸爸,掌握好基础,以不变应万变。

一个彩蛋

function Add() {
    const nums = [...arguments];
    return () => {
        nums.push(...arguments);
        return () => {
            nums.push(...arguments);
            return nums.reduce((a, b) => a + b);
        }
    }
}
// 如果我上述代码中间换成箭头函数又会怎么样呢~

后记

也许你觉得这题有点简单,通过简单的重复练习就能轻松记住,但是最主要的是思路,很多事情都是一样,掌握事情的方法和方向是最重要的。毕竟淘宝也不是一蹴而就的~ 但是只要方向正确了,都会好起来的。

最后

如果我的文章有帮助到你,希望你也能帮助我,欢迎关注我的微信公众号 秋风的笔记,回复好友 二次,可加微信并且加入交流群,秋风的笔记 将一直陪伴你的左右。

image

查看原文

赞 9 收藏 6 评论 1

蓝色的秋风 发布了文章 · 9月1日

一文带你层层解锁「文件下载」的奥秘

大家好我是秋风,今天带来的主题是关于文件下载,在我之前曾经发过一篇文件上传的文章(一文了解文件上传全过程(1.8w字深度解析,进阶必备 200+点赞),反响还不错,时隔多日,由于最近有研究一些媒体相关的工作,因此打算对下载做一个整理,因此他的兄弟篇诞生了,带你领略文件下载的奥秘。本文会花费你较长的时间阅读,建议先收藏/点赞,然后查看你感兴趣的部分,平时也可以充当当做字典的效果来查询。

:) 不整不知道,一整,居然整出这么多情况,我只是想简单地做个页面仔。

前言

一图览全文,可以先看看大纲适不适合自己,如果你喜欢则继续往下阅读。

一文了解文件下载

这一节呢,主要介绍一些前置知识,对一些基础知识的介绍,如果你觉得你是这个。⬇️⬇️⬇️,你可以跳过前言。

和荣耀王者说你嘛呢?_荣耀_王者表情

前端的文件下载主要是通过 <a> ,再加上 download属性,有了它们让我们的下载变得简单。

download此属性指示浏览器下载 URL 而不是导航到它,因此将提示用户将其保存为本地文件。如果属性有一个值,那么此值将在下载保存过程中作为预填充的文件名(如果用户需要,仍然可以更改文件名)。此属性对允许的值没有限制,但是 /\ 会被转换为下划线。大多数文件系统限制了文件名中的标点符号,故此,浏览器将相应地调整建议的文件名。( 摘自 https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/a)

注意:

  • 此属性仅适用于同源 URL
  • 尽管 HTTP URL 需要位于同一源中,但是可以使用 blob: URLdata: URL ,以方便用户下载使用 JavaScript 生成的内容(例如使用在线绘图 Web 应用程序创建的照片)。

因此下载 url 主要有三种方式。(本文大部分以 blob 的方式进行演示)

image-20200830153314861

兼容性

可以看到它的兼容性也非常的可观(https://www.caniuse.com/#search=download)

image-20200817232216749

为了避免很多代码的重复性,因为我抽离出了几个公共函数。(该部分可跳过,名字都比较可读,之后若是遇到不明白则可以在这里寻找)

export function downloadDirect(url) {
    const aTag = document.createElement('a');
    aTag.download = url.split('/').pop();
    aTag.href = url;
    aTag.click()
}
export function downloadByContent(content, filename, type) {
    const aTag = document.createElement('a');
    aTag.download = filename;
    const blob = new Blob([content], { type });
    const blobUrl = URL.createObjectURL(blob);
    aTag.href = blobUrl;
    aTag.click();
    URL.revokeObjectURL(blob);
}
export function downloadByDataURL(content, filename, type) {
    const aTag = document.createElement('a');
    aTag.download = filename;
    const dataUrl = `data:${type};base64,${window.btoa(unescape(encodeURIComponent(content)))}`;
    aTag.href = dataUrl;
    aTag.click();
}
export function downloadByBlob(blob, filename) {
    const aTag = document.createElement('a');
    aTag.download = filename;
    const blobUrl = URL.createObjectURL(blob);
    aTag.href = blobUrl;
    aTag.click();
    URL.revokeObjectURL(blob);
}
export function base64ToBlob(base64, type) {
    const byteCharacters = atob(base64);
    const byteNumbers = new Array(byteCharacters.length);
    for (let i = 0; i < byteCharacters.length; i++) {
        byteNumbers[i] = byteCharacters.charCodeAt(i);
    }
    const buffer = Uint8Array.from(byteNumbers);
    const blob = new Blob([buffer], { type });
    return blob;
}

🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅🚅

(手动给不看以上内容的大佬画分割线)

🇨🇳

所有示例Github地址:https://github.com/hua1995116/node-demo/tree/master/file-download

在线Demo: https://qiufeng.blue/demo/file-download/index.html

前端文件下载

后端

本文后端所有示例均以 koa / 原生 js 实现。

后端返回文件流

这种情况非常简单,我们只需要直接将后端返回的文件流以新的窗口打开,即可直接下载了。

// 前端代码
<button id="oBtnDownload">点击下载</button>
<script>
oBtnDownload.onclick = function(){
    window.open('http://localhost:8888/api/download?filename=1597375650384.jpg', '_blank')
}
</script>
// 后端代码
router.get('/api/download', async (ctx) => {
    const { filename } = ctx.query;
    const fStats = fs.statSync(path.join(__dirname, './static/', filename));
    ctx.set({
        'Content-Type': 'application/octet-stream',
        'Content-Disposition': `attachment; filename=${filename}`,
        'Content-Length': fStats.size
    });
    ctx.body = fs.readFileSync(path.join(__dirname, './static/', filename));
})

能够让浏览器自动下载文件,主要有两种情况:

一种为使用了Content-Disposition属性。

我们来看看该字段的描述。

在常规的HTTP应答中,Content-Disposition 响应头指示回复的内容该以何种形式展示,是以内联的形式(即网页或者页面的一部分),还是以附件的形式下载并保存到本地 --- 来源 MDN(https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Headers/Content-Disposition)

再来看看它的语法

Content-Disposition: inline
Content-Disposition: attachment
Content-Disposition: attachment; filename="filename.jpg"

很简单,只要设置成最后一种形态我就能成功让文件从后端进行下载了。

另一种为浏览器无法识别的类型

例如输入 http://localhost:8888/static/demo.sh,浏览器无法识别该类型,就会自动下载。

不知道小伙伴们有没有遇到过这样的一个情况,我们输入一个正确的静态 js 地址,没有配置Content-Disposition,但是却会被意外的下载。

例如像以下的情况。

2020-08-30-17.01.52

006r3PQBjw1fav4dsikh6j308c0g5gm1

这很可能是由于你的 nginx 少了这一行配置.

include mime.types;

导致默认走了 application/octet-stream,浏览器无法识别就下载了文件。

后端返回静态站点地址

通过静态站点下载,这里要分为两种情况,一种为可能该服务自带静态目录,即为同源情况,第二种情况为适用了第三方静态存储平台,例如阿里云、腾讯云之类的进行托管,即非同源(当然也有些平台直接会返回)。

同源

同源情况下是非常简单,先上代码,直接调用一下函数就能轻松实现下载。

import {downloadDirect} from '../js/utils.js';
axios.get('http://localhost:8888/api/downloadUrl').then(res => {
        if(res.data.code === 0) {
            downloadDirect(res.data.data.url);
        }
})

非同源

我们也可以从 MDN 上看到,虽然 download 限制了非同源的情况,但是!!但是!!但是可以使用 blob: URLdata: URL ,因此我们只要将文件内容进行下载转化成 blob 就可以了。

整个过程如下

image-20200830174735143

<button id="oBtnDownload">点击下载</button>
    <script type="module">
        import {downloadByBlob} from '../js/utils.js';
        function download(url) {
            axios({
                method: 'get',
                url,
                responseType: 'blob'
            }).then(res => {
                downloadByBlob(res.data, url.split('/').pop());
            }) 
        }
        oBtnDownload.onclick = function(){
           axios.get('http://localhost:8888/api/downloadUrl').then(res => {
                if(res.data.code === 0) {
                    download(res.data.data.url);
                }
            })
        }
    </script>

现在非同源的也可以愉快地下载啦。

后端返回字符串(base64)

有时候我们也会遇到一些新手后端返回字符串的情况,这种情况很少见,但是来了我们也不慌,顺便可以向后端小哥秀一波操作,不管啥数据,咱都能给你下载下来。

ps: 前提是安全无污染的资源 :) , 正经文章的招牌闪闪发光。

这种情况下,我需要模拟下后端小哥的骚操作,因此有后端代码。

994b6f2egy1fgryfevtpvj208c08cmxd

核心过程

image-20200830174752476

// node 端
router.get('/api/base64', async (ctx) => {
    const { filename } = ctx.query;
    const content = fs.readFileSync(path.join(__dirname, './static/', filename));
    const fStats = fs.statSync(path.join(__dirname, './static/', filename));
    console.log(fStats);
    ctx.body = {
        code: 0,
        data: {
            base64: content.toString('base64'),
            filename,
            type: mime.getType(filename)
        }
    }
})
// 前端
<button id="oBtnDownload">点击下载</button>
<script type="module">
import {base64ToBlob, downloadByBlob} from '../js/utils.js';
function download({ base64, filename, type }) {
    const blob = base64ToBlob(blob, type);
    downloadByBlob(blob, filename);
}
oBtnDownload.onclick = function(){
    axios.get('http://localhost:8888/api/base64?filename=1597375650384.jpg').then(res => {
        if(res.data.code === 0) {
            download(res.data.data);
        }
    })
}
</script>

思路其实还是利用了我们上面说的 <a> 标签。但是在这个步骤前,多了一个步骤就是,需要将我们的 base64 字符串转化为二进制流,这个东西,在我的前一篇文件上传中也常常提到,毕竟文件就是以二进制流的形式存在。不过也很简单,js 拥有内置函数 atob。 极大地提高了我们转换的效率。

纯前端

上面介绍借助后端来完成文件下载的相关方法,接下来我们来介绍介绍纯前端来完成文件下载的一些方法。

方法一: blob: URL

image-20200831230800538

方法二: data: URL

image-20200831230810963

由于 data:URL 会有长度的限制,因此下面的所有例子都会采用 blob 的方式来进行演示。

json/text

下载text和json非常的简单,可以直接构造一个 Blob。

Blob(blobParts[, options])
返回一个新创建的 Blob 对象,其内容由参数中给定的数组串联组成。
// html
<textarea name="" id="text" cols="30" rows="10"></textarea>
<button id="textBtn">下载文本</button>
<p></p>
<textarea name="" id="json" cols="30" rows="10" disabled>
{
    "name": "秋风的笔记"
}
</textarea>
<button id="jsonBtn">下载JSON</button>
//js
import {downloadByContent, downloadByDataURL} from '../js/utils.js';
textBtn.onclick = () => {
        const value = text.value;
        downloadByContent(value, 'hello.txt', 'text/plain');
          // downloadByDataURL(value, 'hello.txt', 'text/plain');
}
jsonBtn.onclick = () => {
        const value = json.value;
        downloadByContent(value, 'hello.json', 'application/json');
     // downloadByDataURL(value, 'hello.json', 'application/json');
}

效果图

2020-08-30-17.53.32

注释代码为 data:URL 的展示部分,由于是第一个例子,因此我讲展示代码,后面都省略了,但是你也可以通过调用 downloadByDataURL 方法,找不到该方法的定义请滑到文章开头哦~

excel

excel 可以说是我们部分前端打交道很深的一个场景,什么数据中台,天天需要导出各种报表。以前都是前端请求后端,来获取一个 excel 文件地址。现在让我们来展示下纯前端是如何实现下载excel。

简单excel

表格长这个模样,比较简陋的形式

image-20200829170347728

const template = '<html xmlns:o="urn:schemas-microsoft-com:office:office" '
            +'xmlns:x="urn:schemas-microsoft-com:office:excel" '
            +'xmlns="http://www.w3.org/TR/REC-html40">'
            +'<head>'
            +'</head>'
            +'<body><table border="1" >{table}</table><\/body>'
            +'<\/html>';
    const context = template.replace('{table}', document.getElementById('excel').innerHTML);
    downloadByContent(context, 'qiufengblue.xls', 'application/vnd.ms-excel');

但是编写并不复杂,依旧是和我们之前一样,通过构造出 excel 的格式,转化成 blob 来进行下载。

最终导出的效果

image-20200829170625763

element-ui 导出表格

没错,这个就是 element-ui 官方table 的例子。

image-20200829170543891

导出效果如下,可以说非常完美。

image-20200829170912128

这里我们用到了一个插件 https://github.com/SheetJS/sheetjs

使用起来非常简单。

<template>
      <el-table id="ele" border :data="tableData" style="width: 100%">
        <el-table-column prop="date" label="日期" width="180">
        </el-table-column>
        <el-table-column prop="name" label="姓名" width="180">
        </el-table-column>
        <el-table-column prop="address" label="地址">
        </el-table-column>
      </el-table>
      <button @click="exportExcel">导出excel</button>
</template>
<script>
...
methods: {
  exportExcel() {
     let wb = XLSX.utils.table_to_book(document.getElementById('ele'));
     XLSX.writeFile(wb, 'qiufeng.blue.xlsx');
    }
}
...
</script>

完美表情

word

讲完了 excel 我们再来讲讲 word 这可是 office 三剑客另外一大利器。这里我们依旧是利用上述的 blob 的方法进行下载。

简单示例

2020-08-29-20.13.25

代码展示

exportWord.onclick = () => {
    const template = '<html xmlns:o="urn:schemas-microsoft-com:office:office" '
            +'xmlns:x="urn:schemas-microsoft-com:office:word" '
            +'xmlns="http://www.w3.org/TR/REC-html40">'
            +'<head>'
            +'</head>'
            +'<body>{table}<\/body>'
            +'<\/html>';
    const context = template.replace('{table}', document.getElementById('word').innerHTML);
    downloadByContent(context, 'qiufeng.blue.doc', 'application/msword');
}

效果展示

image-20200830164208184

使用 docx.js 插件

如果你想有更高级的用法,可以使用 docx.js这个库。当然用上述方法也是可以高级定制的。

代码

<button type="button" onclick="generate()">下载word</button>

    <script>
        async function generate() {
            const res = await axios({
                method: 'get',
                url: 'http://localhost:8888/static/1597375650384.jpg',
                responseType: 'blob'
            })
            const doc = new docx.Document();
            const image1 = docx.Media.addImage(doc, res.data, 300, 400)
            doc.addSection({
                properties: {},
                children: [
                    new docx.Paragraph({
                        children: [
                            new docx.TextRun("欢迎关注[秋风的笔记]公众号").break(),
                            new docx.TextRun("").break(),
                            new docx.TextRun("定期发送优质文章").break(),
                            new docx.TextRun("").break(),
                            new docx.TextRun("美团点评2020校招-内推").break(),
                        ],
                    }),
                    new docx.Paragraph(image1),
                ],
            }); 

            docx.Packer.toBlob(doc).then(blob => {
                console.log(blob);
                saveAs(blob, "qiufeng.blue.docx");
                console.log("Document created successfully");
            });
        }
    </script>

效果(没有打广告...随便找了张图,强行不承认系列)

9150e4e5ly1fl8qavz6quj20hs0hsjvl

2020-08-30-18.32.09

zip下载

前端压缩还是非常有用的,在一定的场景下,可以节省流量。而这个场景比较使用于,例如前端打包图片下载、前端打包下载图标。

一开始我以为我 https://tinypng.com/ 就是用了这个,结果我发现我错了...仔细一想,因为它压缩好的图片是存在后端的,如果使用前端打包的话,反而要去请求所有压缩的图片从而来获取图片流。如果用后端压缩话,可以有效节省流量。嗯。。。失败例子告终。

后来又以为https://www.iconfont.cn/打包...,使用了这个方案....发现....我又错了...但是我们分析一下.

image-20200829204540440

它官网都是 svg 渲染的图标,对于 svg 下载的时候,完全可以使用前端打包下载。但是,它还支持 font 以及 jpg 格式,所以为了统一,采用了后端下载,能够理解。那我们就来实现这个它未完成的功能,当然我们还需要用到一个插件,就是 jszip

这里我从以上找了两个 svg 的图标。

image-20200829204937044

实现代码

download.onclick = () => {
        const zip = new JSZip();
        const svgList = [{
            id: 'demo1',
        }, {
            id: 'demo2',
        }]
        svgList.map(item => {
            zip.file(item.id + '.svg', document.getElementById(item.id).outerHTML);
        })
        zip.generateAsync({ 
            type: 'blob'
        }).then(function(content) {
            // 下载的文件名
            var filename = 'svg' + '.zip';
            // 创建隐藏的可下载链接
            var eleLink = document.createElement('a');
            eleLink.download = filename;
            // 下载内容转变成blob地址
            eleLink.href = URL.createObjectURL(content);
            // 触发点击
            eleLink.click();
            // 然后移除
        });
    }

2020-08-29-20.52.42

查看文件夹目录,已经将 SVG 打包下载完毕。

image-20200829205329532

浏览器文件系统(实验性)

image-20200817234129788

在我电脑上都有这么一个浏览器,用来学习和调试 chrome 的最新新特性, 如果你的电脑没有,建议你安装一个。

玩这个特性需要打开 chrome 的实验特性 chrome://flags => #native-file-system-api => enable, 因为实验特性都会伴随一些安全或者影响原本的渲染的行为,因此我再次强烈建议,下载一个金丝雀版本的 chrome 来进行玩耍。

<textarea name="" id="textarea" cols="30" rows="10"></textarea>
<p><button id="btn">下载</button></p>
<script>
    btn.onclick = async () => {
        const handler = await window.chooseFileSystemEntries({
            type: 'save-file',
            accepts: [{
                description: 'Text file',
                extensions: ['txt'],
                mimeTypes: ['text/plain'],
            }],
        });

        const writer = await handler.createWritable();
        await writer.write(textarea.value);
        await writer.close();
    }
</script>

实现起来非常简单。却飞一般的感觉。

2020-08-18-00.13.29

其他场景

H5文件下载

一般在 h5 下载比较多的是 pdf 或者是 apk 的下载。

Android

在安卓浏览器中,浏览器直接下载文件。

ios

由于ios的限制,无法进行下载,因此,可以使用复制 url ,来代替下载。

import {downloadDirect} from '../js/utils.js';
const btn = document.querySelector('#download-ios');
if (/(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent)) {
    const clipboard = new ClipboardJS(btn);
    clipboard.on('success', function () {
        alert('已复制链接,打开浏览器粘贴链接下载');
    });
    clipboard.on('error', function (e) {
        alert('系统版本过低,复制链接失败');
    });
} else {
    btn.onclick = () => {
        downloadDirect(btn.dataset.clipboardText)
    }
}

更多

对于 apk 等下载包可以使用这个包(本人暂时没有试验,接触不多,回头熟悉了再回来补充。)

https://github.com/jawidx/web-launch-app

image-20200830145258473

大文件的分片下载

最近在开发媒体流相关的工作的时候,发现在加载 mp4 文件的时候,发现了一个比较有意思的现象,视频流并不需要将整个 mp4 下载完才进行播放,并且伴随了很多状态码为 206 的请求,乍一看有点像流媒体(HLS等)的韵味。

2020-08-29-21.31.29

觉得这个现象非常的有意思,他能够分片地加载资源,这对于体验或者是流量的节省都是非常大的帮助。最终发现它带了一个名为 Range 的头。我们来看看 MDN 的解释。

The Range 是一个请求首部,告知服务器返回文件的哪一部分。在一个 Range首部中,可以一次性请求多个部分,服务器会以 multipart 文件的形式将其返回。如果服务器返回的是范围响应,需要使用 206Partial Content 状态码。 摘自 MDN

语法

Range: <unit>=<range-start>-
Range: <unit>=<range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>

Node实现

既然我们知道了它的原理,就来自己实现一下。

router.get('/api/rangeFile', async(ctx) => {
    const { filename } = ctx.query;
    const { size } = fs.statSync(path.join(__dirname, './static/', filename));
    const range = ctx.headers['range'];
    if (!range) {
        ctx.set('Accept-Ranges', 'bytes');
        ctx.body = fs.readFileSync(path.join(__dirname, './static/', filename));
        return;
    }
    const { start, end } = getRange(range);
    if (start >= size || end >= size) {
        ctx.response.status = 416;
        ctx.set('Content-Range', `bytes */${size}`);
        ctx.body = '';
        return;
    }
    ctx.response.status = 206;
    ctx.set('Accept-Ranges', 'bytes');
    ctx.set('Content-Range', `bytes ${start}-${end ? end : size - 1}/${size}`);
    ctx.body = fs.createReadStream(path.join(__dirname, './static/', filename), { start, end });
})

Nginx实现

发现 nginx 不需要写任何代码就默认支持了 range 头,想着我一定知道它到底是支持,还是加入了什么模块,或者是我默认开启了什么配置,找了半天没有找到什么额外的配置。

3630px-Nginx_logo-1

正当我准备放弃的时候,灵光一现,去看看源码吧,说不定会有发现,去查了 nginx 源码相关的内容,用了惯用的反推方式,才发现原来是max_ranges这个字段。

https://github.com/nginx/nginx/blob/release-1.13.6/src/http/modules/ngx_http_range_filter_module.c#L166

这也怪我一开始文档阅读不够仔细,浪费了大量的时间。

:) 其实我对 nginx 源码也不熟悉,这里可以用个小技巧,直接在源码库 搜索 206 然后 发现了一个宏命令

#define NGX_HTTP_PARTIAL_CONTENT           206

然后顺藤摸瓜,直接找到这个宏命令NGX_HTTP_PARTIAL_CONTENT用到的地方,这样一步一步就慢慢能找到我们想要的。

默认 nginx 是自动开启 range 头的, 如果不需要配置,则配置 max_range: 0;

Nginx 配置文档 http://nginx.org/en/docs/http/ngx_http_core_module.html#max_ranges

总结

我们可以来总结一下,其实全文主要讲了(xbb)两个核心的知识,一个是 blob 一个 a标签,另外还要注意对于大文件,服务器的优化策略,可以通过 Range 来分片加载。

image-20200830181216353

参考资料

https://github.com/dolanmiu/docx

https://github.com/SheetJS/sheetjs

https://juejin.im/post/6844903763359039501

最后

如果我的文章有帮助到你,希望你也能帮助我,欢迎关注我的微信公众号 秋风的笔记,回复好友 二次,可加微信并且加入交流群,秋风的笔记 将一直陪伴你的左右。

查看原文

赞 43 收藏 36 评论 11

蓝色的秋风 发布了文章 · 8月14日

React Hooks中取消HTTP请求来避免内存泄漏

大家好 ! 👋

今天,让我们看一下在 React Hooks 中使用 fetchAbort Controller取消Web请求从而来避免内存泄露! 🤗

当我们用 Fetch 来管理数据时,有时我们想取消请求(例如,当我们离开当前页面时,当我们关闭模态框,...)。

在👇下面的示例中,我们要在切换路由的时候获取并展示数据。 但是,我们在获取数据完毕之前就离开了路由/页面。

7p2coedr8hhtdltuzxu1

7p2coedr8hhtdltuzxu1

4uoij0o2qmdlppeykeln

4uoij0o2qmdlppeykeln

我们刚刚看到了一个内存泄漏!让我们看看为什么会出现这个错误,以及它的具体含义。

❓为什么有内存泄漏?:我们有一个执行异步fetch(url)任务的组件,然后更新该组件的状态来显示元素,但是我们在请求完成之前就卸载(unmounted)了该组件。 由于已卸载组件的状态(例如 setUserssetState)被更新, 所以造成了此次内存泄露

🚀让我们使用新的 AbortController API!

Abort Controller 允许您订阅一个或多个Web请求,并具有取消请求的能力。 🔥

fvipu2xkelip28hcfoqp

现在,我们可以访问controller.signal

“ 具有 read-only属性的 AbortController接口返回一个AbortSignal (https://developer.mozilla.org... 对象实例,该实例可用于根据需要与DOM请求通信/中止它。” 来自MDN(https://developer.mozilla.org...

让我们看看如何使用它💪

vlvi82bo5lk2nopqzn8z

最后,如果我们想取消当前请求,只需调用abort()。 另外,你可以获取controller.signal.aborted,它是一个只读属性,它返回一个Boolean表示与DOM通讯的信号是(true)否(false)已被放弃。

yswm5mktqv16v0tiio9e

❗️注意:调用abort()时,fetch() promise 会以名为AbortError 的 DOMException reject。

是的,你刚刚学习了如何取消Web请求! 👏

🤩让我们用React Hooks做到这一点!

改造之前

下面是一个组件示例,它请求数据并展示它们。

466wuql2ru1fgkrc2snx

如果我们离开页面的速度太快而导致请求未完成:MEMORY LEAK

daavdtgn3tvfeybcf3rq

改造之后

我们使用 useEffect 来订阅我们的 fetch 请求来避免内存泄漏。 当组件卸载(unmounted)时,我们使用useEffect的清理方法来调用abort()

zsr8g1ecnburui4rkje9

现在,不再有内存泄漏! 😍

wqa6uud2tnz90okxiy1e

你可以在 https://abort-with-react-hook... 上查看此演示。

可以在 https://github.com/hua1995116/node-demo/tree/master/react-abort
查看源码

干杯 🍻 🍻 🍻

译文来自 https://dev.to/somedood/best-...

译者: 秋风的笔记(github/hua1995116)

最后

欢迎关注公众号 「秋风的笔记」,主要记录日常中觉得有意思的工具以及分享开发实践,保持深度和专注度。

也可以扫码加我微信好友,进交流群。

查看原文

赞 9 收藏 6 评论 4

蓝色的秋风 发布了文章 · 6月28日

图解 webpack(模块化 - CommonJS)

在前面一篇文章中 《模块化系列》彻底理清 AMD,CommonJS,CMD,UMD,ES6,我们可以学到了各种模块化的机制。那么接下里我们就来分析一下 webpack 的模块化机制。(主要讲 JS 部分)

提到 webpack,可以说是与我们的开发工程非常密切的工具,不管是日常开发、进行面试还是对于自我的提高,都离不开它,因为它给我们的开发带了极大的便利以及学习的价值。但是由于webpack是一个非常庞大的工程体系,使得我们望之却步。本文想以这种图解的形式能够将它慢慢地剥开一层一层复杂的面纱,最终露出它的真面目。以下是我列出的关于 webpack 相关的体系。

webpack-2

webpack-2

本文讲得是 打包 - CommonJS 模块,主要分为两个部分

  • webpack 的作用
  • webpack 的模块化机制与实现

webpack 的作用

在我们前端多样化的今天,很多工具为了满足我们日益增长的开发需求,都变得非常的庞大,例如 webpack 。在我们的印象中,它似乎集成了所有关于开发的功能,模块打包,代码降级,文件优化,代码校验等等。正是因为面对如此庞大的一个工具,所以才让我们望而却步,当然了还有一点就是,webpack 的频繁升级,周边的生态插件配套版本混乱,也加剧我们对它的恐惧。

那么我们是不是应该思考一下,webpack 的出现究竟给我们带来了什么?我们为啥需要用它?而上面所有的一些代码降级(babel转化)、编译SCSS 、代码规范检测都是得益于它的插件系统和loader机制,并不是完完全全属于它。

所以在我看来,它的功能核心是「打包」,而打包则是能够让模块化的规范得以在浏览器直接执行。因此我们来看看打包后所带来的功能:

  • 模块隔离
  • 模块依赖加载

模块隔离

如果我们不用打包的方式,我们所有的模块都是直接暴露在全局,也就是挂载在 window/global 这个对象。也许代码量少的时候还可以接受,不会有那么多的问题。特别是在代码增多,多人协作的情况下,给全局空间带来的影响是不可预估的,如果你的每一次开发都得去一遍一遍查找是否有他们使用当前的变量名。

举个例子(仅仅为例子说明,实际工程会比以下复杂许多),一开始我们的 user1 写了一下几个模块,跑起来非常的顺畅。

image-20200626231748187

image-20200626231748187

├── bar.js    function bar(){}
├── baz.js    function baz(){}
└── foo.js function foo(){}

但是呢,随着业务迭代,工程的复杂性增加,来了一个 user2,这个时候 user2,需要开发一个 foo 业务,里面也有一个 baz 模块,代码也很快写好了,变成了下面这个样子。

├── bar.js    function bar(){}
├── baz.js    function baz(){}
├── foo
│   └── baz.js function baz(){}
└── foo.js function foo(){}

但是呢这个时候,老板来找 user2 了,为什么增加了新业务后,原来的业务出错了呢?这个时候发现原来是 user2 写的新模块覆盖了 user1 的模块,从而导致了这场事故。

image-20200626220806881

image-20200626220806881

因此,当我们开发的时候将所有的模块都暴露在全局的时候,想要避免错误,一切都得非常的小心翼翼,我们很容易在不知情的偷偷覆盖我们以前定义的函数,从而酿成错误。

因此 webpack 带来的第一个核心作用就是隔离,将每个模块通过闭包的形式包裹成一个个新的模块,将其放于局部作用域,所有的函数声明都不会直接暴露在全局。

image-20200626220851909

image-20200626220851909

原来我们调用的 是 foo 函数,但是 webpack 会帮我们生成独一无二的模块ID,完全不需要担心模块的冲突,现在可以愉快地书写代码啦。

baz.js
module.exports = function baz (){}

foo/baz.js
module.exports = function baz (){}

main.js
var baz = require('./baz.js');
var fooBaz = require('./foo/baz.js');

baz();
fooBaz();

可能你说会之前的方式也可以通过改变函数命名的方式,但是原来的作用范围是整个工程,你得保证,当前命名在整个工程中不冲突,现在,你只需要保证的是单个文件中命名不冲突。(对于顶层依赖也是非常容易发现冲突)

image-20200627140818771

image-20200627140818771

模块依赖加载

还有一种重要的功能就是模块依赖加载。这种方式带来的好处是什么?我们同样先来看例子,看原来的方式会产生什么问题?

User1 现在写了3个模块,其中 baz 是依赖于 bar 的。

image-20200627000240836

image-20200627000240836

写完后 user1 进行了上线,利用了顺序来指出了依赖关系。

<script data-original="./bar.js"></script>
<script data-original="./baz.js"></script>
<script data-original="./foo.js"></script>

可是过了不久 user2 又接手了这个业务。user 2 发现,他开发的 abc 模块,通过依赖 bar 模块,可以进行快速地开发。可是 粗心的 user2 不太明白依赖关系。竟然将 abc 的位置随意写了一下,这就导致 运行 abc 的时候,无法找到 bar 模块。

image-20200627000713100

image-20200627000713100

<script data-original="./abc.js"></script>
<script data-original="./bar.js"></script>
<script data-original="./baz.js"></script>
<script data-original="./foo.js"></script>

因此这里 webpack 利用 CommonJS/ ES Modules 规范进行了处理。使得各个模块之间相互引用无需考虑最终实际呈现的顺序。最终会被打包为一个 bunlde 模块,无需按照顺序手动引入。

baz.js
const bar = require('./bar.js');
module.exports = function baz (){
 ...
 bar();
 ...
}

abc.js
const bar = require('./bar.js');
module.exports = function baz (){
 ...
 bar();
 ...
}
<script data-original="./bundle.js"></script>

image-20200627003815071

image-20200627003815071

webpack 的模块化机制与实现

基于以上两项特性,模块的隔离以及模块的依赖聚合。我们现在可以非常清晰的知道了webpack所起的核心作用。

  • 为了尽可能降低编写的难度和理解成本,我没有使用 AST 的解析,(当然 AST 也不是什么很难的东西,以后的文章中我会讲解 AST是什么以及 AST 解析器的实现过程。
  • 仅实现了 CommonJS 的支持

bundle工作原理

为了能够实现 webpack, 我们可以通过反推的方法,先看webpack 打包后 bundle 是如何工作的。

「源文件」

// index.js
const b = require('./b');
b();
// b.js
module.exports = function () {
    console.log(11);
}

「build 后」(去除了一些干扰代码)

(function(modules) {
  var installedModules = {};
  function __webpack_require__(moduleId) {
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    var module = (installedModules[moduleId] = {
      i: moduleId,
      l: false,
      exports: {},
    });
    modules[moduleId].call(
      module.exports,
      module,
      module.exports,
      __webpack_require__
    );
    module.l = true;
    return module.exports;
  }
  return __webpack_require__((__webpack_require__.s = 0));
})([
  /* 0 */
  function(module, exports, __webpack_require__) {
    var b = __webpack_require__(1);
    b();
  },
  /* 1 */
  function(module, exports) {
    module.exports = function() {
      console.log(11);
    };
  },
]);

image-20200627135324956

image-20200627135324956

以上就是 bundle 的运作原理。通过上述的流程图我们可以看到,有四个关键点

  • 已注册模块(存放已经注册的模块)
  • 模块列表(用来存放所有的包装模块)
  • 模块查找(从原来的树形的模块依赖,变成了扁平查找)
  • 模块的包装(原有的模块都进行了一次包装)

webpack实现

通过 bundle 的分析,我们只需要做的就是 4 件事

  • 遍历出所有的模块
  • 模块包装
  • 提供注册模块、模块列表变量和导入函数
  • 持久化导出

模块的遍历

首先来介绍一下模块的结构,能使我们快速有所了解, 结构比较简单,由内容和模块id组成。

interface GraphStruct {
    context: string;
    moduleId: string;
}
{
 "context": `function(module, exports, require) {
    const bar = require('./bar.js');
  const foo = require('./foo.js');
  console.log(bar());
  foo();
  }`,
  "moduleId": "./example/index.js"
}

接下来我们以拿到一个入口文件来进行讲解,当拿到一个入口文件时,我们需要对其依赖进行分析。说简单点就是拿到 require 中的值,以便我们去寻找下一个模块。由于在这一部分不想引入额外的知识,开头也说了,一般采用的是 AST 解析的方式,来获取 require 的模块,在这里我们使用正则。

用来匹配全局的 require 
const REQUIRE_REG_GLOBAL = /require\(("|')(.+)("|')\)/g;
用来匹配 require 中的内容
const REQUIRE_REG_SINGLE = /require\(("|')(.+)("|')\)/;
const context = `
const bar = require('./bar.js');
const foo = require('./foo.js');
console.log(bar());
foo();
`;
console.log(context.match(REQUIRE_REG_GLOBAL));
// ["require('./bar.js')", "require('./foo.js')"]

image-20200627202427794

image-20200627202427794

由于模块的遍历并不是只有单纯的一层结构,一般为树形结构,因此在这里我采用了深度遍历。主要通过正则去匹配出require 中的依赖项,然后不断递归去获取模块,最后将通过深度遍历到的模块以数组形式存储。(不理解深度遍历,可以理解为递归获取模块)

image-20200627142130902

image-20200627142130902

以下是代码实现

...
private entryPath: string
private graph: GraphStruct[]
...
createGraph(rootPath: string, relativePath: string) {
    // 通过获取文件内容
    const context = fs.readFileSync(rootPath, 'utf-8');
    // 匹配出依赖关系
    const childrens = context.match(REQUIRE_REG_GLOBAL);
   // 将当前的模块存储下来
    this.graph.push({
        context,
        moduleId: relativePath,
    })
    const dirname = path.dirname(rootPath);
    if (childrens) {
       // 如有有依赖,就进行递归
        childrens.forEach(child => {
            const childPath = child.match(REQUIRE_REG_SINGLE)[2];
            this.createGraph(path.join(dirname, childPath), childPath);
        });
    }
}

模块包装

为了能够使得模块隔离,我们在外部封装一层函数, 然后传入对应的模拟 requiremodule使得模块能进行正常的注册以及导入 。

function (module, exports, require){
    ...
},

提供注册模块、模块列表变量和导入函数

这一步比较简单,只要按照我们分析的流程图提供已注册模块变量、模块列表变量、导入函数。

/* modules = {
  "./example/index.js": function (module, exports, require) {
    const a = require("./a.js");
    const b = require("./b.js");

    console.log(a());
    b();
  },
  ...
};*/
bundle(graph: GraphStruct[]) {
    let modules = '';
    graph.forEach(module => {
        modules += `"${module.moduleId}":function (module, exports, require){
        ${module.context}
        },`;
    });
    const bundleOutput = `
    (function(modules) {
        var installedModules = {};
        // 导入函数
        function require(moduleId) {
            // 检查是否已经注册该模块
            if (installedModules[moduleId]) {
                return installedModules[moduleId].exports;
            }
           // 没有注册则从模块列表获取模块进行注册
            var module = (installedModules[moduleId] = {
                i: moduleId,
                l: false,
                exports: {},
            });
           // 执行包装函数,执行后更新模块的内容
            modules[moduleId].call(
                module.exports,
                module,
                module.exports,
                require
            );
            // 设置标记已经注册
            module.l = true;
            // 返回实际模块
            return module.exports;
        }
        require("${graph[0].moduleId}");
    })({${modules}})
    `;
    return bundleOutput;
}

持久化导出

最后将生成的 bundle 持久写入到磁盘就大功告成。

fs.writeFileSync('bundle.js', this.bundle(this.graph))

完整代码100行 代码不到,详情可以查看以下完整示例。

github地址: https://github.com/hua1995116...

结尾

以上仅代表个人的理解,希望让你对webpack的理解有所帮助, 如有讲的不好的请多指出。

欢迎关注公众号 「「秋风的笔记」」,主要记录日常中觉得有意思的工具以及分享开发实践,保持深度和专注度。回复 webpack 获取概览图 xmind 原图

weixin-gongzhonghao

FAQ

Q: 为什么打算写这篇文章?

R: 其实主要是为了画图,纯粹比较新奇。

Q: 还会有下一篇吗?

R: 有的,下一篇暂定为 ES module 和 code splitting 相关。

查看原文

赞 11 收藏 9 评论 0

蓝色的秋风 赞了文章 · 6月3日

探讨不需要打包的构建工具 Snowpack

当谈到前端构建工具,就不得不提的功能强大的 Webpack, 尤其是在最新版本中提出了 Module Federation 功能,该功能进一步增强了 Webpack 的实际作用。我相信凭借 Webpack 这几年的积累,恐怕未来几年内没有哪一个打包工具能够与之媲美,但是这并不妨碍我们探讨一下新的工具 Snowpack

现代打包工具的问题

使用 Snowpack,当您构建现代的 Web 应用程序(使用React,Vue等),无需使用类似于 Webpack,Parcel 和 Rollup 等打包工具。每次您点击保存时,都无需等待打包程序重新构建。相反,所有的文件更改都会立即反映在浏览器中。

请关注立即这个词语, bundleless ? 这个究竟有什么意义?为什么我会这么重点关注这一个工具。

对于一些项目与开发者而言,可能对此没有太大的感触,但是对于我来说,开发中打包曾经是一个噩梦。几年前,我从头开发一个前端 Sass 项目,该项目开始时候就使用 Vue 以 Webpack 根据业务线构建多页面。几个月的时间过去,模块有了十几个。当我修改一个很小的地方时候,需要近一分钟的时候才能热更新完成。当时,我们只能在项目开启的时候设置那些页面需要编译。在忍受了一周后,我们不得不开始拆分项目,因为开发人员和项目开发效率的原因,我们不可能根据业务拆分项目。我们只能把项目中改动较少的的基础服务以及封装的业务组件给提取出去。利用 Webpack 的 externals 来辅助开发。这个方案很好的解决了项目当时的痛点。同时,这件事也让我对于模块划分和性能优化有了一定的认识和见解。

所以,在我看来,bundleless 解决了大部分前端开发者 的一大痛点,就是心流)。当一个程序员被打搅后,他需要 10 - 15 分钟的时间才能重新恢复到之前的编程状态。当你在完成一小块功能时候,想要去查看结果时候,发现当前的项目还在构建状态,这个时候很有可能就会中止心流。

当然,还有一些高明的程序员是完成整块功能再去查看结果。同时开发的 bug 也非常少。该工具对于此类程序员的帮助也不大。谈到这里,我很想要多说两句,虽然程序员在开发中需要学习和使用大量的工具辅助开发,事实上很多工具也确实能够提升效率,但是有时候需要逼自己一把,把自己置身于资源不那么足够的情况下,因为只有这样才能真正的提升能力。这里我推荐一篇博客 断点单步跟踪是一种低效的调试方法,不管大家是认可还是否认,我认为都可以从这篇博客中得到思考。

前端项目的演进

就目前成熟的前端框架来说,都会提供一套脚手架工具以便用户使用。之前我们也是把项目直接从 Webpack 转到了 Vue Cli 3。因为 Vue Cli 3 内部封装了 Webpack, 基本上在开发中已经不需要进行 Webpack 基础性配置,同时我们所需要的依赖项目也大大减少了,底层的依赖升级也转移到了 Vue Cli 上,这也变相的减少了很多的开发量。

在探讨 Snowpack 这个新的构建工具之前,我要抛出一个问题,我们究竟为什么要使用 Webpack?

一个当然是因为 Webpack 确实提供了很多高效有用的工程实践为前端开发者赋能。如果让我们自行研究与使用,需要投入的时间以及掌握的知识远远高于配置学习成本。第二个就是因为它本身是一个静态的 JavaScript 应用程序的静态模块打包工具,首要的重点就在于工具为前端提供了模块与打包。

当年的 JavaScript 设计之初为了保持简单,缺少其他通用语言都具有的模块特性。而该特性正是组织大型,复杂的项目所必须的。但当年的 JavaScript 的工作就是实现简单的提交表单,寥寥几行代码即可实现功能。即使 Ajax 标志 Web 2.0 时代的到来阶段,前端开发的重点还是放在减少全局变量上,于是我们开始使用 IIFE(立即调用函数表达式)来减少全局变量的污染, 而 IIFE 方法存在的问题是没有明确的依赖关系树。开发人员必须以准确的顺序来组织文件列表,以保证模块之间的依赖关系。由于当时网页短暂的生命周期以及后端渲染机制保证了网页的依赖是可控的。

在之后,Node.js 出世,让 JavaScript 可以开发后端,在 Node.js 的许多创新中,值得一提的是 CommonJS 模块系统,或者简称为CJS。 利用 Node.js 程序可以访问文件系统的事实,CommonJS 标准更具有传统的模块加载机制。 在 CommonJS 中,每个文件都是一个模块,具有自己的作用域和上下文。当然,虽然这种机制也底层来自也来自于 IIFE(立即调用函数表达式)。这种创新的爆发不仅来自创造力,还来自于自于必要性:当应用程序变得越来越复杂。 控制代码间的依赖关系越来遇难,可维护性也越来越差。我们需要模块系统以适应那些扩展以及变更的需求,然后围绕它们的生态系统将开发出更好的工具,更好的库,更好的编码实践,体系结构,标准以及模式。终于,伴随模块系统的实现,JavaScript 终于具有了开发大型复杂项目的可能性。

虽然在此之后开发了各式各样的模块加载机制,诸如 AMD UMD 等,但是等到 ES6 的到来。JavaScript 才正式拥有了自己的模块,通常称为ECMAScript模块(ESM)。也是 SnowPack 依赖的基础,这一点我们后面展开来说。

相比于CommonJS , ES 模块是官方标准,也是 JavaScript 语言明确的发展方向,而 CommonJS 模块是一种特殊的传统格式,在 ESM 被提出之前做为暂时的解决方案。 ESM 允许进行静态分析,从而实现像 tree-shaking 的优化,并提供诸如循环引用和动态绑定等高级功能。同时在 Node v8.5.0 之后也可以在实现标准的情况下使用 ESM,随着Node 13.9.0 版本的发布,ESM 已经可以在没有实验性标志的情况下使用。

Webpack 一方面解决了前端构建大型项目的问题,另一方面提供了插件优化 Web 前端性能。例如从资源合并以及压缩上解决了 HTTP 请求过程的问题,把小图片打包为 base64 以减少请求量,提供 Tree-Shaking 以及动态导入。伴随着 HTTP 协议的升级以及网速的不断加快,我也希望日后这些工作慢慢变成负担。

ESM (ecmascript module) 已经到来

SnowPack 是基于 ESM 的工具。想要通过一次安装来替换每次更改时候重建的构建步骤。ESM 现在可以在浏览器中使用了! 我们可以使用 [Can i use]()了解一下 ESM 的可以使用性。

ESM use.png

可以看到,基本上现代浏览器都已经提供了支持,事实上我们也不需要等到所有的浏览器提供支持。

我们不需要为所有浏览器提供一致性的体验,我们也做不到这一点。事实上,我们在这件事情上是可以做到渐进增强的。对于不支持的浏览器,完全无法理解该语句,也不会去 js 加载。例如可以提供页面预加载的前端库 instant.page。该库在不支持 ESM 的浏览器上压根不会执行。

同时对于暂时不支持的浏览器来说,依然可以加载 js ,唯一需要做的就是为不支持 <script type="module"> 的浏览器提供一个降级方案。。正如下面的代码,在现代浏览器中,会加载 module.mjs,同时现代浏览器会忽略携带 nomodule 的js。而在之前的浏览器会加载后面的 js 文件。如果你想进一步阅读的话,可以参考 ECMAScript modules in browsers

<script type="module" data-original="module.mjs"></script>
<script nomodule data-original="fallback.js"></script>

同时,我们可以通过 type="module" 来判断出现代的浏览器。而支持type="module" 的浏览器都支持你所熟知的大部分 ES6 语法,通过这个特性,我们可以打包出两种代码,为现代浏览器提供新的代码,而为不支持 ESM 的浏览器提供另一套代码, 具体可以参考 Phillip Walton 精彩的博文,这里也有翻译版本 如何在生产环境中部署ES2015+。如果当前项目已经开始从 webpack 阵营转到 Vue CLI 阵营的话,那么恭喜你,上述解决方案已经被内置到 Vue CLI 当中去了。只需要使用如下指令,项目便会产生两个版本的包。具体可以参考 Vue CLI 现代模式

Snowpack 解决了什么?

出于对主流浏览器的判断,SnowPack 大胆采用 ESM,其原理也很简单,内部帮助我们将 node_modules 的代码整理并且安装到 一个叫做 web_modules 的文件夹中,需要的时候直接到该文件夹中引入即可。其目标也是为了解决第三方代码的引入问题。(注明: 安装 Snowpack 需要 node v10.17.0 以上版本)

当然,如果仅仅只为了解决第三方引入的问题,事实上我们自己也可以手动解决,但是面对错综复杂的第三方库,我们自己通过 node 来构建未免有些过于复杂。同时,该工具依然会提供使用 TypeScript 以及 Babel 的方案 ,同时也为我们提供了少量的配置选项来帮助我们管理第三方依赖。同时,Snowpack 还可以通过 --nomodule 支持旧版浏览器。同时它也可以根据当前引入的模块来自动构建依赖。

当浏览器本身已经开始支持模块,如果网络速度已经不再是限制,那么我们是否应该离开复杂的构建环境转向简单的代码? 答案是肯定的,简单的方案一定会赢得开发者的青睐。

说到这里,我不禁想到当年刚刚学习软件时侯总是遇到 BS (浏览器与服务器)架构和 CS (客户端与服务器) 架构的问题与选择。历史的选择告诉我们,能够使用 BS 架构的软件,一定会用 BS 架构。如果桌面端的 CS 转向 BS 是一条漫长的探索之路,那么移动端的 CS 转向 BS 则是必经之路。

对于 Vue 这种渐进式的框架来说,即使没有打包工具,我们依然可以在 html 中直接引入开发,而对于 React, Svelte 这些需要编译才能够使用的库来说,SnowPack 也提供了方案来帮助我们协作解决。对于 css 图片这些,浏览器不支持用 js 导入,我们还需要为其改造,具体的实现可以直接学习 Snowpack 官网 以及 Snowpack 例子

说了 Snowpack 的优势,我们也必须聊一聊 Snowpack 的劣势。过于激进一定是劣势,毕竟不支持 ESM 的库还是很多,面对企业应用开发,我们还是需要稳定的工具。同时面对强大脚手架工具,过于捉襟见肘,需要付出更大的精力来维持系统的一致性。同时,在浏览器使用模块化之后,前端代码更容易被分析,这个是否会影响项目本身,事实上也有待商榷。没有模块热更新功能,这点令开发很痛苦。

题外话 vite

前两天,Vue3 beta 版本出世,在直播中,Vue 开发者尤雨溪也是顺带说了一下为 Vue 3 提供的“小”工具 vite ,我在闲暇之时也是去把玩了一下。该工具也是根据浏览器 ESM 结合 node 来针对每个更改模块进行即时编译后直接提供给浏览器。 也就是说,当你在开发中修改了某个 vue 文件之后,node 会编译该文件并且通过 HMR 提供给浏览器当前编译后的 vue 文件。

相对比 Snowpack 来说,该方案则更加优美 (个人感觉)。同时兼顾了开发和生产环境。虽然在当前阶段还不能投入生产,但是我相信 vite 在未来会大放光芒。

展望

前几年,我们需要 Webpack 配置工程师,随着 Parcel 的发布, Webpack 随后也提供了零配置,伴随着依赖升级的复杂度一步步变高,各个框架也是将 Webpack 作为自己的依赖提供更优的工具,伴随着 bundleless。我们可以看到,前端开发难度降低的同时体验也在提升,这是一件好事。

同时,云端 Serverless 架构也降低了创业公司对基础建设的需求。也许将来真的会有业务人员投入到开发中来。而我们也有更多的时间投入到业务场景与业务需求中去。

鼓励一下

如果你觉得这篇文章不错,希望可以给与我一些鼓励,在我的 github 博客下帮忙 star 一下。

博客地址

参考资料

Snowpack 官网

Vue CLI 现代模式

如何在生产环境中部署ES2015+

snowpack,提高10倍打包速度

断点单步跟踪是一种低效的调试方法

vite

查看原文

赞 11 收藏 6 评论 0

蓝色的秋风 发布了文章 · 5月20日

[重拾CSS]一道面试题来看伪元素、包含块和高度坍塌

前言

前几天某个群友在群里问了一道面试题,就是关于一个自适应的正方形布局的困惑,先贴上代码。我其实很长一段时间没有写 CSS 了,对于里面的一些细节也比较模糊了,因此决定重拾 CSS,来重新捋一捋这题目中的一些知识点。(本文大多采用的讲解方式为 w3 的 CSS 标准 + MDN,如果对标准比较熟悉的大神请跳过这篇文章)

通过标准分析有什么好处?最权威的解答,能够少走弯路,不会出错。

代码:

<style>
.square {
    width: 30%;
    overflow: hidden;
    background: yellow;
}
.square::after {
    content: "";
    display: block;
    margin-top: 100%;
}
</style>
<div class="square"></div>

效果(https://codepen.io/hua1995116...:

image-20200517160937926

上面的实现看似简单,但是我们去深究,大约会冒出以下三个问题。

  • ::after 伪元素有什么特殊的魔法吗?
  • margin-top:100% 为什么能够自适应宽度?
  • overflow:hidden 在这里是什么作用?

因此我们会按照上述疑问来逐一讲解。

本文所有 demo 都存放于 https://github.com/hua1995116...

::after 伪元素有什么特殊的魔法吗?

::after

说到 ::after 那就需要说到伪元素,我们先来看看伪元素的定义吧。

伪元素(Pseudo elements)表示文档的抽象元素,超出了文档语言明确创建的那些元素。

​ —— https://www.w3.org/TR/css-pse...

再来看看 ::after 的特性:

When their computed content value is not none, these pseudo-elements generate boxes as if they were immediate children of their originating element, and can be styled exactly like any normal document-sourced element in the document tree.

根据描述来看,当伪元素 content 不为 none 的时候,我们可以把他们当做正常的元素一样来看待。

因此我们的示例转化为更加通俗易懂的样子。

<style>
.square1 {
  width: 30%;
  background: red;
  overflow: hidden;
}
.square1-after {
  margin-top:100%;
}
</style>
<div class="square1">
  <div class="square1-after"></div>
</div>

既然说到了伪元素,我们也顺便回顾一下伪类(Pseudo classes),他们的语法非常类似,却是水和水银的关系。

伪类

是添加到选择器的关键字,指定要选择的元素的特殊状态。

: 单冒号开头的为伪类,代表形态为 :hover

/* 所有用户指针悬停的按钮 */
button:hover {
  color: blue;
}

伪元素

表示文档的抽象元素,超出了文档语言明确创建的那些元素。(因为它们并不局限于适应文档树,所以可以使用它们来选择和样式化文档中不一定映射到文档树结构的部分。)

:: 双冒号开头的为伪元素,代表形态为 ::after

伪元素是应用于元素

/* 每一个 <p> 元素的第一行。 */
p::first-line {
  color: blue;
  text-transform: uppercase;
}

伪类和伪元素区别

通过上述,我们可能大致理解了但是对于一些看上去用途很相似的伪类和伪元素还是有点迷糊。

关键区别: 是否创建了一个超出文档树以外的元素。

我们乍一看 ::first-line 的效果不是和 :first-child 一样嘛?来举一个例子。

// pseudo-elements.html
<style>
        p::first-line {
            color: blue;
        }
</style>
<div>
    <p>
        This is a somewhat long HTML
        paragraph that will be broken into several
        lines. 
    </p>
</div>
// pseudo-classes.html
<style>
        p:first-child {
            color: blue;
        }
</style>
<div>
    <p>
        This is a somewhat long HTML
        paragraph that will be broken into several
        lines.
    </p>
</div>

image-20200517185343290

像我刚才所说乍一看,两种效果是一样的。但是他们真的一样么?当然不是!我们给他们加上宽度。

p {
    width: 200px;
}

::first-line 的形态

image-20200517185652868

在真实的渲染中我们可以理解为

<p><p::first-line>This is a somewhat long</p::first-line> HTML paragraph that will be broken into several lines.</p>

但是我们在真实的 DOM Tree 是看不到的。这一点规范中也说明了,因为它们并不单单适用于文档树,所以使用它们来选择和样式化文档不一定映射到文档树。

Since they are not restricted to fitting into the document tree, they can be used to select and style portions of the document that do not necessarily map to the document’s tree structure.

:first-child的形态

image-20200517185714812

小结

至此我们搞清楚了我们的第一个问题, ::after没有魔法,在本题可以将它当成正常的元素,并且我们搞清楚了伪元素和伪类的区别。

margin-top:100% 为什么能够自适应宽度?

现在我们已经将这个示例转化成一个比较简单的形态,没有过多的知识。

<style>
.square1 {
  width: 30%;
  background: red;
  overflow: hidden;
}
.square1-after {
  margin-top:100%;
}
</style>
<div class="square1">
  <div class="square1-after"></div>
</div>

然后我们来看看这个 margin-top: 100%,看上去他相对于了父元素的 width 值来进行计算的。那么我们来看看 margin-top 到底是怎么计算的。

https://www.w3.org/TR/CSS22/b...

image-20200517192743042

可以看到 margin-top 主要有三种形态。第一种是固定值,第二种为百分比,第三种为 auto,这里我们主要来看下 百分比的计算。

通过上述的描述,可以知道margin-topmargin-bottommargin-leftmargin-right 百分比的长度是由当前元素的包含块的宽度来决定的。

包含块(Containing blocks)

那么什么是包含块(Containing blocks)呢?

The position and size of an element's box(es) are sometimes calculated relative to a certain rectangle, called the containing block of the element.

​ —— https://www.w3.org/TR/CSS22/v...

元素盒子的位置和大小有时是相对于某个矩形计算的,称为元素的包含块。

上述的描述有点拗口,我们大致只需要知道它就是一个矩形的块。下面重要的来了,包含块是怎么确定的?(https://developer.mozilla.org...

确定一个元素的包含块的过程完全依赖于这个元素的 position 属性:

  1. 如果 position 属性为 staticrelativesticky,包含块可能由它的最近的祖先块元素(比如说inline-block, block 或 list-item元素)的内容区的边缘组成,也可能会建立格式化上下文(比如说 a table container, flex container, grid container, 或者是 the block container 自身)。
  2. 如果 position 属性为 absolute ,包含块就是由它的最近的 position 的值不是 static (也就是值为fixed, absolute, relativesticky)的祖先元素的内边距区的边缘组成。
  3. 如果 position 属性是 fixed,在连续媒体的情况下(continuous media)包含块是 viewport ,在分页媒体(paged media)下的情况下包含块是分页区域(page area)。
  4. 如果 position 属性是absolute 或fixed,包含块也可能是由满足以下条件的最近父级元素的内边距区的边缘组成的:

    1. A transform or perspective value other than none
    2. A will-change value of transform or perspective
    3. A filter value other than none or a will-change value of filter(only works on Firefox).
    4. A contain value of paint (例如: contain: paint;)
注意,以下所有例子的视口宽度都为 594px

Case1

第一种情况,就是我们的例子的情况,当前元素的 position 没有填写,默认为 static 。因此满足第一种情况,取它最近的祖先元素,也就是包含块为 container.

<style>
.container {
  width: 30%;
}
.inner {
  margin-top:100%;
}
</style>
<div class="container">
  <div class="inner"></div>
</div>

因此innermargin-top = 父元素container = 窗口宽度(594px) * 30% = 178.188px。

Case2

当前元素为 position:absolute, 因此获取的最近的一个 positionstatic 的元素

<style>
.outer {
    width: 500px;
      position: relative;
}
.container {
    width: 30%;
}
.inner {
    position: absolute;
    margin-top: 100%;
}
</style>
<div class="outer">
  <div class="container">
    <div class="inner"></div>
  </div>
</div>

这个时候 innermargin-top = outer 的宽度(500px)* 100% = 500px。

Case3

当前元素为 position:fixed ,此时的包含块为视口。

<style>
.outer {
    width: 500px;
      position: relative;
}
.container {
    width: 30%;
}
.inner {
    position: absolute;
    margin-top: 100%;
}
</style>
<div class="outer">
  <div class="container">
    <div class="inner"></div>
  </div>
</div>

因此这个时候 margin-top = viewport 的宽度(594px)* 100% = 594px。此时是无关父元素,以及无关外层position 的设置的。

Case4

在 case2 和 case 3 的基础上,会有一些特例影响包含块的寻找。主要就以下4种情况

  1. A transform or perspective value other than none
  2. A will-change value of transform or perspective
  3. A filter value other than none or a will-change value of filter(only works on Firefox).
  4. A contain value of paint (例如: contain: paint;)

我举一个 transform 例子来讲解。

<style>
.outer {
    width: 500px;
      position: relative;
}
.container {
    width: 30%;
      transform: translate(0, 0);
}
.inner {
    position: fixed;
    margin-top: 100%;
}
</style>
<div class="outer">
  <div class="container">
    <div class="inner"></div>
  </div>
</div>

这个时候我们的计算又发生了变化,此时包含块又变成了 container .

margin-top = 父元素container = 窗口宽度(594px) * 30% = 178.188px。

小结

所以对于我们一开始的问题,就是我们的 Case1,采取的就是最近的父元素。所以 margin-top 就是 父元素 square1 的宽度,因此实现了一个自适应的正方形。

对于 position 的不同形态,对于布局状态的影响,一般在我们入门 css 的时候就学了,但是可能没有那么仔细去了解每种情况,也可能不知道他的名词,叫做包含块,这次我们对它进行了梳理,这一节就这样结束,继续看!

overflow:hidden 在这里是什么作用?

假如我们把 overflow:hidden 去了。

<style>
.square1 {
  width: 30%;
  background: red;
}
.square1-after {
  margin-top:100%;
}
</style>
<div class="square1">
  <div class="square1-after"></div>
</div>

我们可以看到以上执行完显示出现的画面为一篇空白。此时我们就要引出了我们的最后一个概念就是,边距坍塌(Collapsing margins) .

边距塌陷(Collapsing margins)

在CSS中,两个或多个框(可能是也可能不是兄弟)的相邻边距可以合并形成一个边距,称为边距塌陷。

不会发生边距坍塌的情况

  • 根节点元素
  • 水平边距(Horizontal margins)不会崩溃
  • 如果具有间隙的元素的顶部和底部相邻,他会与后续同级的元素边距一起坍塌,但是不会与父元素底部的一起坍塌(If the top and bottom margins of an element with clearance are adjoining, its margins collapse with the adjoining margins of following siblings but that resulting margin does not collapse with the bottom margin of the parent block.)
  • 父子元素,父元素有非0的 min-height且有autoheight,父子元素都含有 margin-bottom,此时 margin-bottom 不会发生边距坍塌。
  • 在不同BFC(块级格式上下文)

对于以上,可能对于情况3情况4会比较疑惑,所以举例子如下。

case3
<style>
.case {
            width: 200px;
            background-color: yellow;
        }

        .container {
            background-color: lightblue;
            margin-bottom: 70px;
            padding-top: 0.01px;
        }

        .preface {
            float: left;
            height: 58px;
            width: 100px;
            border: 1px solid red;
        }

        .one .intro {
            clear: left;
            margin-top: 60px;
        }

        .two .intro {
            clear: left;
            margin-top: 59px;
            margin-bottom: 20px;
        }
</style>
<div class="case one">
        <div class="container">
            <div class="preface">
                lorem ipsum
            </div>
            <div class="intro"></div>
        </div>
        after
    </div>
    <hr>
    <div class="case two">
        <div class="container">
            <div class="preface">
                lorem ipsum
            </div>
            <div class="intro"></div>
        </div>
        after
    </div>

在 Firefox 和 IE 下的效果(谷歌失效,原因可能和谷歌浏览器实现有关,暂未深追。)

image-20200519203941769

可以看到如果在在没有 clearance 的情况下,父元素底部是会随着子元素一起坍塌的,但是如果中间有 clearance 的情况下,父元素的底部则不会坍塌。

case4
<style>
  .case2 {
    min-height: 200px;
    height: auto;
    background: red;
    margin-bottom: 20px;
  }

  .case2-inner {
    margin-bottom: 50px;
  }
</style>
<div class="case2">
  <div class="case2-inner"></div>
</div>
<div>为了看间距效果</div>

效果:

image-20200518001513036

可以看到这种情况下,父子元素下边距并不会发生边距坍塌。

会发生边距坍塌

发生边距坍塌需要满足2个前提

1.是 block 盒子模型,在同一个 BFC。

2.两个元素之间没有行内元素,没有 clearance ,没有 padding,没有border。

然后以下几种情况会发生边距坍塌。

  • 盒子的上边距和第一个流入子元素的上边距
  • 盒子的下边距和同级后一个流入元素的上边距
  • 如果父元素高度为“auto”,最后一个流入子元素的底部距和其父元素的底部距
  • 某个元素没有建立新的 BFC,并且 min-height 和 height 都为 0,同时含有 margin-top 和 margin-bottom.
  • 如果'min-height'属性为零,并且框没有顶部或底部边框,也没有顶部或底部填充,并且框的'height'为0或'auto',并且框不包含边距,则框自身的边距会折叠 行框,其所有流入子页边距(如果有的话)都会崩溃。
补充: 如果'min-height'属性为零,并且框没有顶部或底部border,也没有顶部或底部padding,并且元素的'height'为0或'auto',并且没有行内元素,则元素自身的所有边距坍塌,包括其所有流入子元素的边距(如果有的话)都会坍塌。

这里有几个问题要解释一下 1.什么是流入子元素,2. 是什么 clearance

1.流入元素

流入元素需要用的反向来进行介绍,有流入元素,就有流出元素,以下情况为流出元素。

  • floated items。浮动的元素
  • items with position: absolute (including position: fixed which acts in the same way)。通过设置position属性为absolute或者fixed的元素
  • the root element (html)根元素

除了以上情况的元素,叫做流入元素。

<style>
body {
border: 1px solid #000;
}

.case2 {
width: 200px;
height: 50px;
background: red;
}

.case2-inner {
margin-top: 50px;
height: 0;
}

.float {
float: left;
}
</style>
<div class="case2">
<div class="float"></div>
<div class="case2-inner">看出了啥</div>
</div>

image-20200519013902840

2.clearance

当某个元素有clear 非 none 值 并且盒子实际向下移动时,它叫做 clearance。

case1
<style>
.case1 {
            height: 50px;
            background: red;
            margin-top: 100px;
        }

        .case1-inner {
            margin-top: 50px;
        }
</style>
<div class="case1">
        <div class="case1-inner">我直接从顶部开始了</div>
    </div>

image-20200519001450483

case2
<style>
.case2 {
            height: 150px;
            background: red;
        }

        .case2-inner1 {
            margin-bottom: 50px;
        }

        .case2-inner2 {
            margin-top: 20px;
        }
</style>
<div class="case2">
        <div class="case2-inner1">我和底下之间距离为50px</div>
        <div class="case2-inner2">我和顶上之间距离为50px</div>
    </div>

image-20200519001526280

case3
<style>
.case3 {
            height: auto;
            background: red;
            margin-bottom: 10px;
        }

        .case3-inner {
            margin-bottom: 50px;
        }
</style>
<div class="case3">
        <div class="case3-inner">底部和父元素被合并了</div>
    </div>
    <div>距离顶上50px</div>

image-20200519001635407

case4
<style>
.case4 {
            height: 200px;
            background: red;
        }

        .case4-inner {
            margin-top: 20px;
            margin-bottom: 30px;
        }
</style>
<div class="case4">
        <p>它把自己给合并了,距离底下30px</p>
        <div class="case4-inner"><span style="clear: both;"></span></div>
        <p>它把自己给合并了, 距离顶上30px</p>
    </div>

image-20200519001704179

边距塌陷如何解决

通用型

1.改变盒子模型(非 block 模型)

2.创建新的 BFC

限制型

查看刚才不会发生高度坍塌的情况

边距塌陷如何计算

1.当两个或更多边距坍塌时,当边距全为正数的时候,结果页边距宽度是边距塌陷宽度的最大值。

2.当边距全为负数的时候,取最小值。

3.在存在负边距的情况下,从正边距的最大值中减去负边距的绝对值的最大值。 (-13px 8px 100px叠在一起,则边距塌陷的值为 100px - 13px = 87px)

如果转为算法就是以下代码

// AllList 所有坍塌边距
function computed(AllList) {
  const PositiveList = AllList.filter((item) => item >= 0);
  const NegativeList = AllList.filter((item) => item <= 0);
  const AllPositive = AllList.every((item) => item >= 0);
  const AllNegative = AllList.every((item) => item <= 0);
  if (AllNegative) {
    return Math.min(...AllList);
  } else if (AllPositive) {
    return Math.max(...AllList);
  } else {
    const maxPositive = Math.max(...PositiveList);
    const minNegative = Math.min(...NegativeList);
    return maxPositive + minNegative;
  }
}

小结

通过上面对边距坍塌的理解,我们可以很快得出,我们的自适应正方形中的例子,子元素的 margin-top 和 父元素的 margin-top 发生了坍塌,因此可以新建一个 BFC 来消除这个问题。而 overflow:hidden 就是会形成一个 新的 BFC 。BFC详见 https://developer.mozilla.org...

总结

通过上面的解析,我们终于把这一道小小的面试题,进行了全方位的剖析。每一个问题都对应着一个知识块。

  • ::after 伪元素有什么特殊的魔法吗? -> 伪元素(Pseudo elements)
  • margin-top:100% 为什么能够自适应宽度? -> 包含块 (Containing blocks)
  • overflow:hidden 在这里是什么作用? -> 边距塌陷(Collapsing margins)

想不到小小的面试题,居然可以牵扯出这么多的知识,所以我们在面对一些面试题的时候,例如实现一个自适应的正方形布局,别单单看有几种方式能够实现,解决方法永远会随着时间的推进,变得越来越多,那我们能做的就是以不变应万变(当然规范也是相对的,也可能会变,只是概率低)去理解剖析这些方法背后的用到的知识。

相信如果你把以上搞懂了,面试官对你深层次的灵魂追问,你也能对答如流了。注意本文的一些专有名词,我都用英文多次标注,这也许未来会对你有所帮助。

稳住,我们能赢!嘻嘻嘻,最后,如果你对题目的理解一时间比较迷茫,欢迎加群提问,本文也是基于群友的问题,展开了一系列的讲解。

参考链接

https://stackoverflow.com/que...

https://www.w3.org/TR/css-dis...

https://stackoverflow.com/que...

https://developer.mozilla.org/zh-CN/docs/Web/CSS/CSS_Flow_Layout/%E5%9C%A8Flow%E4%B8%AD%E5%92%8CFlow%E4%B9%8B%E5%A4%96

关注

欢迎关注公众号 「秋风的笔记」,主要记录日常中觉得有意思的工具以及分享开发实践,保持深度和专注度。

也可以扫码加我微信好友,进交流群。

查看原文

赞 20 收藏 13 评论 1

蓝色的秋风 发布了文章 · 4月17日

10 种跨域解决方案(附终极方案)

写在前面

嗯。又来了,又说到跨域了,这是一个老生常谈的话题,以前我觉得这种基础文章没有什么好写的,会想着你去了解底层啊,不是很简单吗。但是最近在开发一个vscode 插件发现,当你刚入门一样东西的时候,你不会想这么多,因为你对他不熟悉,当你遇到不会的东西,你就是想先找到解决方案,然后通过这个解决方案再去深入理解。就比如跨域,新人或者刚接触的人对它并不是那么熟悉,所以说列出一些自己积累的方案,以及一些常用的场景来给他人带来一些解决问题的思路,这件事是有意义的。(写完之后还发现真香。以后忘了还能回来看看)

其实现在的环境对于刚入门的前端来说,非常的不友好,一方面吧,很多刚新人没有经历过工具的变更时代,另一方面框架的迭代更新速度很快。在以前你可能掌握几种常见的手法就好了。但是现在webpack-dev-servervue-cliparcel,这些脚手架都进行了一层封装,对于熟悉的人可能很简单,但是对于还未入门的人来说,简直就是一个黑盒,虽然用起来很方便,但是某一天遇到了问题,你对它不熟悉,你就会不知道所错。(但是别慌,主流 cli 的跨域方式我都会讲到)

讲着讲着有点偏离方向,可能我讲的也并不一定是正确的。下面切入正题。

本文会以「What-How-Why」的方式来进行讲解。而在在 How (如何解决跨域,将会提供标题的 11 种方案。)

重要的说明: 在文中,web 端地址为 localhost:8000 服务端地址为 localhost:8080,这一点希望你能记住,会贯穿全文,你也可以把此处的两端的地址代入你自己的地址。

cors

以下所有代码均在https://github.com/hua1995116/node-demo/tree/master/node-cors

image-20200413192431636

一、跨域是什么?

1.同源策略

跨域问题其实就是浏览器的同源策略所导致的。

同源策略是一个重要的安全策略,它用于限制一个origin的文档或者它加载的脚本如何能与另一个源的资源进行交互。它能帮助阻隔恶意文档,减少可能被攻击的媒介。

--来源 MDN

当跨域时会收到以下错误

image-20200413205559124

2.同源示例

那么如何才算是同源呢?先来看看 url 的组成部分

http://www.example.com:80/path/to/myfile.html?key1=value1&key2=value2#SomewhereInTheDocument

image-20200412171942421

只有当

protocol(协议)、domain(域名)、port(端口)三者一致。

protocol(协议)、domain(域名)、port(端口)三者一致。

protocol(协议)、domain(域名)、port(端口)三者一致。

才是同源。

以下协议、域名、端口一致。

http://www.example.com:80/a.js

http://www.example.com:80/b.js

以下这种看上去再相似也没有用,都不是同源。

http://www.example.com:8080

http://www2.example.com:80

在这里注意一下啊,这里是为了突出端口的区别才写上端口。在默认情况下 http 可以省略端口 80, https 省略 443。这别到时候闹笑话了,你和我说http://www.example.com:80http://www.example.com不是同源,他俩是一个东西。

http://www.example.com:80===http://www.example.com

https://www.example.com:443===https://www.example.com

唔,还是要说明一下。

二、如何解决跨域?

1.CORS

跨域资源共享(CORS) 是一种机制,它使用额外的HTTP头来告诉浏览器 让运行在一个 origin (domain) 上的 Web 应用被准许访问来自不同源服务器上的指定的资源。当一个资源从与该资源本身所在的服务器不同的域、协议或端口请求一个资源时,资源会发起一个跨域 HTTP 请求

而在 cors 中会有简单请求复杂请求的概念。

浏览器支持情况

当你使用 IE<=9, Opera<12, or Firefox<3.5 或者更加老的浏览器,这个时候请使用 JSONP 。

a.简单请求

不会触发CORS 预检请求。这样的请求为“简单请求”,请注意,该术语并不属于Fetch(其中定义了 CORS)规范。若请求满足所有下述条件,则该请求可视为“简单请求”:

情况一: 使用以下方法(意思就是以下请求意外的都是非简单请求)

情况二: 人为设置以下集合外的请求头

情况三:Content-Type的值仅限于下列三者之一:(例如 application/json 为非简单请求)

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

情况四:

请求中的任意XMLHttpRequestUpload对象均没有注册任何事件监听器;XMLHttpRequestUpload对象可以使用XMLHttpRequest.upload属性访问。

情况五:

请求中没有使用ReadableStream对象。

b.非简单请求

除以上情况外的。

c.Node 中的解决方案

原生方式

我们来看下后端部分的解决方案。NodeCORS的解决代码.

app.use(async (ctx, next) => {
  ctx.set("Access-Control-Allow-Origin", ctx.headers.origin);
  ctx.set("Access-Control-Allow-Credentials", true);
  ctx.set("Access-Control-Request-Method", "PUT,POST,GET,DELETE,OPTIONS");
  ctx.set(
    "Access-Control-Allow-Headers",
    "Origin, X-Requested-With, Content-Type, Accept, cc"
  );
  if (ctx.method === "OPTIONS") {
    ctx.status = 204;
    return;
  }
  await next();
});
第三方中间件

为了方便也可以直接使用中间件

const cors = require("koa-cors");

app.use(cors());
关于 cors 的 cookie 问题

想要传递cookie需要满足 3 个条件

1.web请求设置withCredentials

这里默认情况下在跨域请求,浏览器是不带 cookie 的。但是我们可以通过设置withCredentials来进行传递cookie.

// 原生 xml 的设置方式
var xhr = new XMLHttpRequest();
xhr.withCredentials = true;
// axios 设置方式
axios.defaults.withCredentials = true;

2.Access-Control-Allow-Credentialstrue

3.Access-Control-Allow-Origin为非*

这里请求的方式,在chrome中是能看到返回值的,但是只要不满足以上其一,浏览器会报错,获取不到返回值。

image-20200412201424024

Access to XMLHttpRequest at 'http://127.0.0.1:8080/api/corslist' from origin 'http://127.0.0.1:8000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Credentials' header in the response is '' which must be 'true' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

image-20200412201411481

Access to XMLHttpRequest at 'http://127.0.0.1:8080/api/corslist' from origin 'http://127.0.0.1:8000' has been blocked by CORS policy: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.

image-20200412201530087

d.前端示例

分别演示一下前端部分简单请求非简单请求

简单请求
<script data-original="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script>
  axios.get("http://127.0.0.1:8080/api/corslist");
</script>
非简单请求

这里我们加入了一个非集合内的headercc来达到非简单请求的目的。

<script data-original="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script>
  axios.get("http://127.0.0.1:8080/api/corslist", { header: { cc: "xxx" } });
</script>

image-20200412201158778

image-20200412195829232

小结

1、 在新版的 chrome 中,如果你发送了复杂请求,你却看不到options请求。可以在这里设置chrome://flags/#out-of-blink-cors设置成disbale,重启浏览器。对于非简单请求就能看到options请求了。

2、 一般情况下后端接口是不会开启这个跨域头的,除非是一些与用户无关的不太重要的接口。

2.Node 正向代理

代理的思路为,利用服务端请求不会跨域的特性,让接口和当前站点同域。

代理前

image-20200412202320482

代理后

image-20200412202358759

这样,所有的资源以及请求都在一个域名下了。

a.cli 工具中的代理

1) Webpack (4.x)

webpack中可以配置proxy来快速获得接口代理的能力。

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  entry: {
    index: "./index.js"
  },
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist")
  },
  devServer: {
    port: 8000,
    proxy: {
      "/api": {
        target: "http://localhost:8080"
      }
    }
  },
  plugins: [
    new HtmlWebpackPlugin({
      filename: "index.html",
      template: "webpack.html"
    })
  ]
};

修改前端接口请求方式,改为不带域名。(因为现在是同域请求了)

<button id="getlist">获取列表</button>
<button id="login">登录</button>
<script data-original="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script>
  axios.defaults.withCredentials = true;
  getlist.onclick = () => {
    axios.get("/api/corslist").then(res => {
      console.log(res.data);
    });
  };
  login.onclick = () => {
    axios.post("/api/login");
  };
</script>
2) Vue-cli 2.x
// config/index.js

...
proxyTable: {
  '/api': {
     target: 'http://localhost:8080',
  }
},
...
3) Vue-cli 3.x
// vue.config.js 如果没有就新建
module.exports = {
  devServer: {
    port: 8000,
    proxy: {
      "/api": {
        target: "http://localhost:8080"
      }
    }
  }
};
4) Parcel (2.x)
// .proxyrc
{
  "/api": {
    "target": "http://localhost:8080"
  }
}

看到这里,心里一句 xxx 就会脱口而出,一会写配置文件,一会 proxyTable ,一会 proxy,怎么这么多的形式?学不动了学不动了。。。不过也不用慌,还是有方法的。以上所有配置都是有着共同的底层包http-proxy-middleware.里面需要用到的各种websocketrewrite等功能,直接看这个库的配置就可以了。关于 http-proxy-middleware 这个库的原理可以看我这篇文章https://github.com/hua1995116/proxy当然了。。。对于配置的位置入口还是非统一的。教一个搜索的技巧吧,上面配置写哪里都不用记的,想要哪个框架的 直接 google 搜索 xxx proxy 就行了。

例如 vue-cli 2 proxy 、 webpack proxy 等等....基本会搜到有官网的配置答案,通用且 nice。

b.使用自己的代理工具

cors-anywhere

服务端

// Listen on a specific host via the HOST environment variable
var host = process.env.HOST || "0.0.0.0";
// Listen on a specific port via the PORT environment variable
var port = process.env.PORT || 7777;

var cors_proxy = require("cors-anywhere");
cors_proxy
  .createServer({
    originWhitelist: [], // Allow all origins
    requireHeader: ["origin", "x-requested-with"],
    removeHeaders: ["cookie", "cookie2"]
  })
  .listen(port, host, function() {
    console.log("Running CORS Anywhere on " + host + ":" + port);
  });

前端代码

<script data-original="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script>
  axios.defaults.withCredentials = true;
  getlist.onclick = () => {
    axios
      .get("http://127.0.0.1:7777/http://127.0.0.1:8080/api/corslist")
      .then(res => {
        console.log(res.data);
      });
  };
  login.onclick = () => {
    axios.post("http://127.0.0.1:7777/http://127.0.0.1:8080/api/login");
  };
</script>

效果展示

image-20200413161243734

缺点

无法专递 cookie,原因是因为这个是一个代理库,作者觉得中间传递 cookie 太不安全了。https://github.com/Rob--W/cors-anywhere/issues/208#issuecomment-575254153

c.charles

介绍

这是一个测试、开发的神器。介绍与使用

利用 charles 进行跨域,本质就是请求的拦截与代理。

tools/map remote中设置代理

image-20200412232733437

image-20200412232724518

前端代码
<button id="getlist">获取列表</button>
<button id="login">登录</button>
<script data-original="https://cdn.bootcss.com/axios/0.19.2/axios.min.js"></script>
<script>
  axios.defaults.withCredentials = true;
  getlist.onclick = () => {
    axios.get("/api/corslist").then(res => {
      console.log(res.data);
    });
  };
  login.onclick = () => {
    axios.post("/api/login");
  };
</script>
后端代码
router.get("/api/corslist", async ctx => {
  ctx.body = {
    data: [{ name: "秋风的笔记" }]
  };
});

router.post("/api/login", async ctx => {
  ctx.cookies.set("token", token, {
    expires: new Date(+new Date() + 1000 * 60 * 60 * 24 * 7)
  });
  ctx.body = {
    msg: "成功",
    code: 0
  };
});
效果

访问http://localhost:8000/charles

image-20200412232231554

image-20200412232752837

完美解决。

唔。这里又有一个注意点。在Mac mojave 10.14中会出现charles抓不到本地包的情况。这个时候需要自定义一个域名,然后配置hosts指定到127.0.0.1。然后修改访问方式http://localhost.charlesproxy.com:8000/charles

image-20200412233258107

image-20200412233317027

3.Nginx 反向代理

介绍

Nginx 则是通过反向代理的方式,(这里也需要自定义一个域名)这里就是保证我当前域,能获取到静态资源和接口,不关心是怎么获取的。nginx 安装教程

image-20200412233536522

配置下 hosts

127.0.0.1 local.test

配置 nginx

server {
        listen 80;
        server_name local.test;
        location /api {
            proxy_pass http://localhost:8080;
        }
        location / {
            proxy_pass http://localhost:8000;
        }
}

启动 nginx

sudo nginx

重启 nginx

sudo nginx -s reload

实现

前端代码

<script>
  axios.defaults.withCredentials = true;
  getlist.onclick = () => {
    axios.get("/api/corslist").then(res => {
      console.log(res.data);
    });
  };
  login.onclick = () => {
    axios.post("/api/login");
  };
</script>

后端代码

router.get("/api/corslist", async ctx => {
  ctx.body = {
    data: [{ name: "秋风的笔记" }]
  };
});

router.post("/api/login", async ctx => {
  ctx.cookies.set("token", token, {
    expires: new Date(+new Date() + 1000 * 60 * 60 * 24 * 7)
  });
  ctx.body = {
    msg: "成功",
    code: 0
  };
});
效果

访问http://local.test/charles

浏览器显示

image-20200413000229326

4.JSONP

JSONP主要就是利用了script标签没有跨域限制的这个特性来完成的。

使用限制

仅支持 GET 方法,如果想使用完整的 REST 接口,请使用 CORS 或者其他代理方式。

流程解析

1.前端定义解析函数(例如 jsonpCallback=function(){....})

2.通过 params 形式包装请求参数,并且声明执行函数(例如 cb=jsonpCallback)

3.后端获取前端声明的执行函数(jsonpCallback),并以带上参数并调用执行函数的方式传递给前端。

使用示例

后端实现

const Koa = require("koa");
const fs = require("fs");
const app = new Koa();

app.use(async (ctx, next) => {
  if (ctx.path === "/api/jsonp") {
    const { cb, msg } = ctx.query;
    ctx.body = `${cb}(${JSON.stringify({ msg })})`;
    return;
  }
});

app.listen(8080);

普通 js 示例

<script type="text/javascript">
  window.jsonpCallback = function(res) {
    console.log(res);
  };
</script>
<script
  data-original="http://localhost:8080/api/jsonp?msg=hello&cb=jsonpCallback"
  type="text/javascript"
></script>

JQuery Ajax 示例

<script data-original="https://cdn.bootcss.com/jquery/3.5.0/jquery.min.js"></script>
<script>
  $.ajax({
    url: "http://localhost:8080/api/jsonp",
    dataType: "jsonp",
    type: "get",
    data: {
      msg: "hello"
    },
    jsonp: "cb",
    success: function(data) {
      console.log(data);
    }
  });
</script>

原理解析

其实这就是 js 的魔法

我们先来看最简单的 js 调用。嗯,很自然的调用。

<script>
  window.jsonpCallback = function(res) {
    console.log(res);
  };
</script>
<script>
  jsonpCallback({ a: 1 });
</script>

我们稍稍改造一下,外链的形式。

<script>
  window.jsonpCallback = function(res) {
    console.log(res);
  };
</script>
<script data-original="http://localhost:8080/api/a.js"></script>

// http://localhost:8080/api/a.js jsonpCallback({a:1});

我们再改造一下,我们把这个外链的 js 就当做是一个动态的接口,因为本身资源和接口一样,是一个请求,也包含各种参数,也可以动态化返回。

<script>
  window.jsonpCallback = function(res) {
    console.log(res);
  };
</script>
<script data-original="http://localhost:8080/api/a.js?a=123&cb=sonpCallback"></script>

// http://localhost:8080/api/a.js jsonpCallback({a:123});

你仔细品,细细品,是不是 jsonp 有的优势就是 script 加载 js 的优势,加载的方式只不过换了一种说法。这也告诉我们一个道理,很多东西并没有那么神奇,是在你所学的知识范围内。就好比,桃树和柳树,如果你把他们当成很大跨度的东西去记忆理解,那么世上这么多树,你真的要累死了,你把他们都当成是树,哦吼?你会突然发现,你对世界上所有的树都有所了解,他们都会长叶子,光合作用....当然也有个例,但是你只需要去记忆这些细微的差别,抓住主干。。。嗯,反正就这么个道理。

5.Websocket

WebSocket规范定义了一种 API,可在网络浏览器和服务器之间建立“套接字”连接。简单地说:客户端和服务器之间存在持久的连接,而且双方都可以随时开始发送数据。详细教程可以看https://www.html5rocks.com/zh/tutorials/websockets/basics/

这种方式本质没有使用了 HTTP 的响应头, 因此也没有跨域的限制,没有什么过多的解释直接上代码吧。

前端部分

<script>
  let socket = new WebSocket("ws://localhost:8080");
  socket.onopen = function() {
    socket.send("秋风的笔记");
  };
  socket.onmessage = function(e) {
    console.log(e.data);
  };
</script>

后端部分

const WebSocket = require("ws");
const server = new WebSocket.Server({ port: 8080 });
server.on("connection", function(socket) {
  socket.on("message", function(data) {
    socket.send(data);
  });
});

6.window.postMessage

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

用途

1.页面和其打开的新窗口的数据传递

2.多窗口之间消息传递

3.页面与嵌套的 iframe 消息传递

用法

详细用法看https://developer.mozilla.org/zh-CN/docs/Web/API/Window/postMessage

otherWindow.postMessage(message, targetOrigin, [transfer]);

  • otherWindow: 其他窗口的一个引用,比如 iframe 的 contentWindow 属性、执行window.open返回的窗口对象、或者是命名过或数值索引的window.frames
  • message: 将要发送到其他 window 的数据。
  • targetOrigin: 通过窗口的 origin 属性来指定哪些窗口能接收到消息事件.
  • transfer(可选) : 是一串和 message 同时传递的Transferable对象. 这些对象的所有权将被转移给消息的接收方,而发送一方将不再保有所有权

示例

index.html

<iframe
  data-original="http://localhost:8080"
  frameborder="0"
  id="iframe"
  onload="load()"
></iframe>
<script>
  function load() {
    iframe.contentWindow.postMessage("秋风的笔记", "http://localhost:8080");
    window.onmessage = e => {
      console.log(e.data);
    };
  }
</script>

another.html

<div>hello</div>
<script>
  window.onmessage = e => {
    console.log(e.data); // 秋风的笔记
    e.source.postMessage(e.data, e.origin);
  };
</script>

7.document.domain + Iframe

从第 7 种到第 9 种方式,我觉得别人的写的已经很好了,为了完整性,我就拿别人的了。如有雷同....(不对,就是雷同....)不要说不出来。

该方式只能用于二级域名相同的情况下,比如a.test.comb.test.com适用于该方式。 只需要给页面添加document.domain ='test.com'表示二级域名都相同就可以实现跨域。

www.   baidu.  com     .
三级域  二级域   顶级域   根域
// a.test.com
<body>
  helloa
  <iframe
    data-original="http://b.test.com/b.html"
    frameborder="0"
    onload="load()"
    id="frame"
  ></iframe>
  <script>
    document.domain = "test.com";
    function load() {
      console.log(frame.contentWindow.a);
    }
  </script>
</body>
// b.test.com
<body>
  hellob
  <script>
    document.domain = "test.com";
    var a = 100;
  </script>
</body>

8.window.location.hash + Iframe

实现原理

原理就是通过 url 带 hash ,通过一个非跨域的中间页面来传递数据。

实现流程

一开始 a.html 给 c.html 传一个 hash 值,然后 c.html 收到 hash 值后,再把 hash 值传递给 b.html,最后 b.html 将结果放到 a.html 的 hash 值中。 同样的,a.html 和 b.htm l 是同域的,都是http://localhost:8000,而 c.html 是http://localhost:8080

// a.html
<iframe data-original="http://localhost:8080/hash/c.html#name1"></iframe>
<script>
  console.log(location.hash);
  window.onhashchange = function() {
    console.log(location.hash);
  };
</script>
// b.html
<script>
  window.parent.parent.location.hash = location.hash;
</script>
// c.html
<body></body>
<script>
  console.log(location.hash);
  const iframe = document.createElement("iframe");
  iframe.src = "http://localhost:8000/hash/b.html#name2";
  document.body.appendChild(iframe);
</script>

9.window.name+ Iframe

window 对象的 name 属性是一个很特别的属性,当该 window 的 location 变化,然后重新加载,它的 name 属性可以依然保持不变。

其中 a.html 和 b.html 是同域的,都是http://localhost:8000,而 c.html 是http://localhost:8080

// a.html
<iframe
  data-original="http://localhost:8080/name/c.html"
  frameborder="0"
  onload="load()"
  id="iframe"
></iframe>
<script>
  let first = true;
  // onload事件会触发2次,第1次加载跨域页,并留存数据于window.name
  function load() {
    if (first) {
      // 第1次onload(跨域页)成功后,切换到同域代理页面
      iframe.src = "http://localhost:8000/name/b.html";
      first = false;
    } else {
      // 第2次onload(同域b.html页)成功后,读取同域window.name中数据
      console.log(iframe.contentWindow.name);
    }
  }
</script>

b.html 为中间代理页,与 a.html 同域,内容为空。

// b.html
<div></div>
// c.html
<script>
  window.name = "秋风的笔记";
</script>

通过 iframe 的 src 属性由外域转向本地域,跨域数据即由 iframe 的window.name从外域传递到本地域。这个就巧妙地绕过了浏览器的跨域访问限制,但同时它又是安全操作。

10.浏览器开启跨域(终极方案)

其实讲下其实跨域问题是浏览器策略,源头是他,那么能否能关闭这个功能呢?

答案是肯定的。

注意事项: 因为浏览器是众多 web 页面入口。我们是否也可以像客户端那种,就是用一个单独的专门宿主浏览器,来打开调试我们的开发页面。例如这里以 chrome canary 为例,这个是我专门调试页面的浏览器,不会用它来访问其他 web url。因此它也相对于安全一些。当然这个方式,只限于当你真的被跨域折磨地崩溃的时候才建议使用以下。使用后,请以正常的方式将他打开,以免你不小心用这个模式干了其他的事。

Windows

找到你安装的目录
.\Google\Chrome\Application\chrome.exe --disable-web-security --user-data-dir=xxxx

Mac

~/Downloads/chrome-data这个目录可以自定义.

/Applications/Google\ Chrome\ Canary.app/Contents/MacOS/Google\ Chrome\ Canary  --disable-web-security --user-data-dir=~/Downloads/chrome-data

效果展示

image-20200413143102377

嗯,使用起来很香,但是再次提醒,一般情况千万别轻易使用这个方式,这个方式好比七伤拳,使用的好威力无比,使用不好,很容易伤到自己。

三、为什么需要跨域?

在最一开始,我们知道了,跨域只存在于浏览器端。而浏览器为 web 提供访问入口。我们在可以浏览器内打开很多页面。正是这样的开放形态,所以我们需要对他有所限制。就比如林子大了,什么鸟都有,我们需要有一个统一的规范来进行约定才能保障这个安全性。

1.限制不同源的请求

这里还是用最常用的方式来讲解,例如用户登录 a 网站,同时新开 tab 打开了 b 网站,如果不限制同源, b 可以像 a 网站发起任何请求,会让不法分子有机可趁。

2.限制 dom 操作

我举个例子吧, 你先登录下www.baidu.com,然后访问我这个网址。

https://zerolty.com/node-demo/index.html

image-20200413190413758

你会发现,这个和真实的百度一模一样,如果再把域名搞的相似一些,是不是很容易被骗,如果可以进行 dom 操作...那么大家的信息在这种钓鱼网站眼里都是一颗颗小白菜,等着被收割。

可以在 http 返回头 添加X-Frame-Options: SAMEORIGIN防止被别人添加至 iframe。

写在最后

以上最常用的就是前 4 种方式,特别是第 2 种非常常见,我里面也提到了多种示例,大家可以慢慢消化一下。希望未来有更加安全的方式来限制 web ,解决跨域的头疼,哈哈哈哈。

有一个不成熟的想法,可以搞这么一个浏览器,只让访问内网/本地网络,专门给开发者用来调试页面用,对于静态资源可以配置白名单,这样是不是就没有跨域问题了,23333。上述如有错误,请第一时间指出,我会进行修改,以免给大家来误导。

欢迎关注公众号「秋风的笔记」,主要记录日常中觉得有意思的工具以及分享开发实践,保持深度和专注度。

weixin-gongzhonghao

也可以扫码加我微信好友,拉你进交流群聊 5 毛钱的天。群里有很多大佬,解决问题也很积极,说实话,这篇文章就是一个例子,发现群里好多人常常遇到这相关的问题,干脆来一篇总结。

1581349909092

参考

https://stackoverflow.com/questions/12296910/so-jsonp-or-cors

https://juejin.im/post/5c23993de51d457b8c1f4ee1#heading-18

https://juejin.im/post/5a6320d56fb9a01cb64ee191

https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Access_control_CORS

https://developer.mozilla.org/zh-CN/docs/Web/Security/Same-origin_policy

查看原文

赞 44 收藏 36 评论 2

蓝色的秋风 回答了问题 · 3月20日

解决.babelrc在npm install时会被改变

其实是你的依赖package.json混入了 babel-env 这个库,他会将你 .babelrc 替换成他的模板文件。并且他是在 postinstall 钩子执行。所以每次下载都会被替换成这个。

https://github.com/epsitec-sa...

{

"presets": \[

"env",

"react",

"stage-0"

\],

"plugins": \[

"transform-class-properties",

"transform-decorators",

"transform-react-constant-elements",

"transform-react-inline-elements"

\]

}

关注 2 回答 1

蓝色的秋风 回答了问题 · 2019-07-24

解决chrome扩展如何在新标签页打开自己附带的一个html页面?

直接写就好了。
manifest.json

"browser_action": {
    "default_icon": "icon.png",
    "default_popup": "./options.html"
},
"background": {
    "page": "./dist/index.html"
},

options.html

...
<script scr="options.js"></script>
...

options.js

chrome.tabs.create({url: chrome.extension.getURL('./dist/index.html')});

dist/index.html

...
<div>123</div>
...

关注 5 回答 4

认证与成就

  • 获得 224 次点赞
  • 获得 2 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 2 枚铜徽章

擅长技能
编辑

开源项目 & 著作
编辑

  • webchat

    Websocket project based on vue(基于vue2.0的实时聊天项目)

注册于 2017-11-12
个人主页被 394 人浏览