检测浏览器何时接收文件下载

577
我有一个页面,允许用户下载动态生成的文件。它需要很长时间来生成,因此我想显示一个“等待”指示器。问题是,我无法确定浏览器何时接收到文件,以便我可以隐藏指示器。
我请求一个隐藏表单,POST到服务器,并针对其结果定位到一个隐藏的iframe。这样,我就不会用结果替换整个浏览器窗口。我监听iframe上的“load”事件,希望在下载完成时触发它。
我返回一个带有文件的“Content-Disposition: attachment”头,这会导致浏览器显示“保存”对话框。但浏览器不会在iframe中触发“load”事件。
我尝试的一种方法是使用多部分响应。因此,它将发送一个空的HTML文件以及附加的可下载文件。
例如:
Content-type: multipart/x-mixed-replace;boundary="abcde"

--abcde
Content-type: text/html

--abcde
Content-type: application/vnd.fdf
Content-Disposition: attachment; filename=foo.fdf

file-content
--abcde

这在Firefox中可行;它接收空的HTML文件,触发“load”事件,然后显示可下载文件的“保存”对话框。但在Internet ExplorerSafari上失败了;Internet Explorer触发“load”事件,但不会下载文件,Safari下载文件(带有错误的名称和内容类型),并且不会触发“load”事件。
另一种方法可能是调用以开始创建文件,轮询服务器直到准备就绪,然后下载已经创建的文件。但我宁愿避免在服务器上创建临时文件。
我该怎么办?

4
IE的任何版本都不支持multipart/x-mixed-replace格式。 - EricLaw
2
这是一个简单的解决方案:http://www.bennadel.com/blog/2533-tracking-file-download-events-using-javascript-and-coldfusion.htm - Mateen
我希望浏览器制造商能够更明显地显示请求正在进行中。 - Matthew Lock
1
@mateen 谢谢兄弟!它真的很简单。 - Fai Zal Dong
如果我想使用多部分方法,那么客户端是否需要特殊处理?还是HTML部分将进入表单,附件将由浏览器下载? - Mark
显示剩余3条评论
24个回答

506

一种可能的解决方案是在客户端使用JavaScript。

客户端算法:

  1. 生成一个随机独特的令牌。
  2. 提交下载请求,并在GET / POST字段中包含令牌。
  3. 显示“等待”指示器。
  4. 启动计时器,每秒钟左右查找名为“fileDownloadToken”(或您决定的任何名称)的cookie。
  5. 如果cookie存在且其值与令牌匹配,则隐藏“等待”指示器。

服务器算法:

  1. 查找请求中的GET / POST字段。
  2. 如果它具有非空值,请删除cookie(例如“fileDownloadToken”),并将其值设置为令牌的值。

客户端源代码(JavaScript):
function getCookie( name ) {
  var parts = document.cookie.split(name + "=");
  if (parts.length == 2) return parts.pop().split(";").shift();
}

function expireCookie( cName ) {
    document.cookie = 
        encodeURIComponent(cName) + "=deleted; expires=" + new Date( 0 ).toUTCString();
}

function setCursor( docStyle, buttonStyle ) {
    document.getElementById( "doc" ).style.cursor = docStyle;
    document.getElementById( "button-id" ).style.cursor = buttonStyle;
}

function setFormToken() {
    var downloadToken = new Date().getTime();
    document.getElementById( "downloadToken" ).value = downloadToken;
    return downloadToken;
}

var downloadTimer;
var attempts = 30;

// Prevents double-submits by waiting for a cookie from the server.
function blockResubmit() {
    var downloadToken = setFormToken();
    setCursor( "wait", "wait" );

    downloadTimer = window.setInterval( function() {
        var token = getCookie( "downloadToken" );

        if( (token == downloadToken) || (attempts == 0) ) {
            unblockSubmit();
        }

        attempts--;
    }, 1000 );
}

function unblockSubmit() {
  setCursor( "auto", "pointer" );
  window.clearInterval( downloadTimer );
  expireCookie( "downloadToken" );
  attempts = 30;
}

示例服务器代码(PHP):

$TOKEN = "downloadToken";

// Sets a cookie so that when the download begins the browser can
// unblock the submit button (thus helping to prevent multiple clicks).
// The false parameter allows the cookie to be exposed to JavaScript.
$this->setCookieToken( $TOKEN, $_GET[ $TOKEN ], false );

$result = $this->sendFile();

在哪里:

public function setCookieToken(
    $cookieName, $cookieValue, $httpOnly = true, $secure = false ) {

    // See: https://dev59.com/RnM_5IYBdhLWcg3wQQpd#1459794
    // See: http://shiflett.org/blog/2006/mar/server-name-versus-http-host
    // See: https://dev59.com/zHA75IYBdhLWcg3wdIp0#3290474
    setcookie(
        $cookieName,
        $cookieValue,
        2147483647,            // expires January 1, 2038
        "/",                   // your path
        $_SERVER["HTTP_HOST"], // your domain
        $secure,               // Use true over HTTPS
        $httpOnly              // Set true for $AUTH_COOKIE_NAME
    );
}

6
很棒的想法,我把它作为基本框架用于这个答案,关于使用jQuery/C#下载多个文件。 - Greg
8
提醒其他人注意:如果document.cookies中不包含downloadToken,请检查cookie路径。在我的情况下,即使默认情况下路径为空,我也必须在服务器端将路径设置为“ /”(例如,在Java中使用cookie.setPath(“ /”))。有一段时间我认为问题是特殊的“localhost”域cookie处理(https://dev59.com/anNA5IYBdhLWcg3wAItP),但最终并非如此。对于其他人可能存在这个问题,因此值得一读。 - jlpp
2
@bulltorious 在深入探讨你的解决方案之前,我想知道它是否适用于跨域文件下载请求。你认为它会成功吗?还是Cookie限制会影响它? - kiks73
5
太棒了 - 如果没有你的提示,我一百年也想不到可以将cookie包含在文件下载中。谢谢!! - freefaller
13
正如其他人指出的那样,这个解决方案只解决了问题的一部分,即等待服务器准备文件的时间。问题的另一部分取决于文件大小和连接速度可能相当严重,即实际上需要多长时间才能将整个文件传输到客户端,而这并没有通过这个解决方案得到解决。 - AsGoodAsItGets
显示剩余16条评论

29

一个非常简单(且不那么好的)一行解决方案是使用 window.onblur() 事件来关闭加载对话框。当然,如果加载时间过长,用户决定做其他事情(比如读邮件),加载对话框也会关闭。


1
这是一种简单的方法,非常适合用于消除使用 onbeforeunload 触发的文件下载的加载覆盖。谢谢。 - wf4
7
这在所有浏览器中都不能正常工作(有些浏览器在下载过程中不会离开/模糊当前窗口,例如Safari、某些IE版本等)。 - hiattp
9
Chrome和其他类似的浏览器会自动下载文件,这种情况下该条件将失败。 - Lucky
@Lucky 这只是默认设置。Chrome 的用户完全可以指定下载文件的保存位置,因此会看到对话框。 - ESR
4
不好的想法,因为你在选项卡更改或窗口外执行任何操作时会激活模糊效果。 - Michael

27
核心问题在于Web浏览器没有在页面导航取消时触发事件,但却有一个在页面完成加载时触发的事件。除了直接浏览器事件外,任何其他方法都将成为一种利弊权衡的黑客行为。
目前已知有四种方法来检测浏览器下载开始:
1. 调用fetch(),检索整个响应,附加一个带有download属性的a标签,并触发单击事件。现代Web浏览器将向用户提供保存已检索文件的选项。这种方法有几个缺点:
- 整个数据块存储在RAM中,因此如果文件很大,它将消耗那么多的RAM。对于小文件,这可能不是致命的。 - 用户必须等待整个文件下载完成才能保存它。他们也不能离开页面,直到它完成。 - 不使用内置的Web浏览器文件下载器。 - 跨域抓取可能会失败,除非设置CORS头。
2. 使用iframe + 服务器端cookie。如果在iframe中加载页面而不是开始下载,则iframe会触发load事件,但如果下载开始,则不会触发任何事件。然后可以通过在循环中的JavaScript检测与Web服务器设置的cookie。这种方法有几个缺点:
  • 服务器和客户端必须协同工作。服务器必须设置cookie,客户端必须检测cookie。
  • 跨域请求无法设置cookie。
  • 每个域名下可以设置的cookie数量有限制。
  • 无法发送自定义HTTP头。
  1. 使用带有URL重定向的iframe。iframe发起请求,一旦服务器准备好文件,就会转储一个执行元刷新到新URL的HTML文档,1秒后触发下载。当HTML文档加载时,iframe上的load事件会发生。这种方法有几个缺点:
  • 服务器必须维护正在下载的内容的存储。需要cron作业或类似的作业定期清理目录。
  • 服务器必须在文件准备好时输出特殊的HTML内容。
  • 客户端必须猜测iframe何时实际上已经向服务器发出了第二个请求,并且何时实际上已经开始下载才能将iframe从DOM中移除。这可以通过只保留iframe在DOM中来克服。
  • 无法发送自定义HTTP头。
4. 使用iframe和XHR。 iframe触发下载请求。一旦通过iframe进行请求,就会通过XHR进行相同的请求。如果在iframe上触发了load事件,则发生错误,中止XHR请求并删除iframe。如果XHR progress事件触发,则可能已经在iframe中开始下载,中止XHR请求,等待几秒钟,然后删除iframe。这样可以下载更大的文件,而不需要依赖服务器端cookie。但是,此方法存在以下几个缺点:
- 为相同的信息进行了两次独立的请求。服务器可以通过检查传入的标头来区分XHR和iframe。 - 跨域XHR请求可能会失败,除非设置CORS头。但是,浏览器在服务器发送HTTP标头之前不知道是否允许CORS。如果服务器等到文件数据准备好才发送标头,则即使没有CORS,XHR也可以粗略地检测到iframe何时开始下载。 - 客户端必须猜测何时实际开始下载以从DOM中删除iframe。这可以通过只保留iframe在DOM中来克服。 - 无法在iframe上发送自定义标头。

如果没有适当的内置Web浏览器事件,这里就没有完美的解决方案。但是,根据您的用例,上述四种方法中的一种可能更适合。

尽可能实时地将响应流式传输到客户端,而不是先在服务器上生成所有内容,然后再发送响应。可以流式传输各种文件格式,例如CSV、JSON、XML、ZIP等。这实际上取决于找到支持流式传输内容的库。当请求开始时立即流式传输响应时,检测下载的开始并不那么重要,因为它几乎会立即开始。

另一个选择是仅在前面输出下载标头,而不是等待所有内容首先生成。然后生成内容,最后开始向客户端发送。用户的内置下载器将耐心等待数据开始到达。缺点是底层网络连接可能会超时等待数据开始流动(无论是客户端还是服务器端)。


2
非常好的答案,伙计,感谢您清楚地列出了每个解决方案的所有缺点,表述得非常好。 - kigiri

21

这个解决方案非常简单,但可靠。它使得显示实际进度消息成为可能(并且可以轻松地插入到现有的流程中):

处理脚本(我的问题是:通过HTTP检索文件并将它们作为ZIP交付)将状态写入会话。

每秒轮询并显示状态。就是这样(好吧,不完全是。您必须注意很多细节(例如,并发下载),但这是一个很好的起点。

下载页面:

<a href="download.php?id=1" class="download">DOWNLOAD 1</a>
<a href="download.php?id=2" class="download">DOWNLOAD 2</a>

...

<div id="wait">
    Please wait...
    <div id="statusmessage"></div>
</div>

<script>

    // This is jQuery
    $('a.download').each(function()
    {
        $(this).click(
            function() {
                $('#statusmessage').html('prepare loading...');
                $('#wait').show();
                setTimeout('getstatus()', 1000);
            }
            );
        });
    });

    function getstatus() {
        $.ajax({
            url: "/getstatus.php",
            type: "POST",
            dataType: 'json',
            success: function(data) {
                $('#statusmessage').html(data.message);
                if(data.status == "pending")
                    setTimeout('getstatus()', 1000);
                else
                    $('#wait').hide();
                }
        });
    }
</script>

文件 getstatus.php

<?php
    session_start();
    echo json_encode($_SESSION['downloadstatus']);
?>

文件 download.php

<?php
    session_start();
    $processing = true;
    while($processing) {
        $_SESSION['downloadstatus'] = array("status" =>"pending", "message" => "Processing".$someinfo);
        session_write_close();
        $processing = do_what_has_2Bdone();
        session_start();
    }

    $_SESSION['downloadstatus'] = array("status" => "finished", "message" => "Done");
    // And spit the generated file to the browser
?>

3
但如果用户打开了多个窗口或下载?此外,此处会对服务器进行冗余调用。 - Yuki
3
如果一个用户有多个连接,它们会全部等待其它连接结束,因为session_start()会锁定该用户的会话并防止所有其他进程访问它。 - Honza Kuchař
2
在事件注册中,您不需要使用.each()。只需使用$('a.download').click()即可。 - robisrob
1
不要在 setTimeout('getstatus()', 1000); 中评估代码。直接使用函数:setTimeout(getstatus, 1000); - Roko C. Buljan
session_start(): 当头信息已经发送时无法启动会话 - rua.kr

19

基于Elmer的例子,我准备了自己的解决方案。 点击具有定义为“download”类的项目后,在浏览器窗口中显示自定义消息。 我使用了focus触发器来隐藏消息。我已经使用focus触发器来隐藏消息。

JavaScript

$(function(){$('.download').click(function() { ShowDownloadMessage(); }); })

function ShowDownloadMessage()
{
     $('#message-text').text('Your report is creating. Please wait...');
     $('#message').show();
     window.addEventListener('focus', HideDownloadMessage, false);
}

function HideDownloadMessage(){
    window.removeEventListener('focus', HideDownloadMessage, false);                   
    $('#message').hide();
}

HTML

<div id="message" style="display: none">
    <div id="message-screen-mask" class="ui-widget-overlay ui-front"></div>
    <div id="message-text" class="ui-dialog ui-widget ui-widget-content ui-corner-all ui-front ui-draggable ui-resizable waitmessage">please wait...</div>
</div>

现在您应该实现任何元素以下载:

<a class="download" href="file://www.ocelot.com.pl/prepare-report">Download report</a>
或者
<input class="download" type="submit" value="Download" name="actionType">

每次 下载 点击后,您将看到以下消息:
正在生成您的报告。请耐心等待...


3
如果用户点击窗口会发生什么? - Tom Roggero
这正是我正在寻找的,非常感谢!! - Sergio
1
在我的情况下,hide() 没有被调用。 - Prashant Pimpale
1
我的情况是在JSP上工作,并单击下载CSV文件。它有效。谢谢。 - KenyKeny
1
如果我从JS代码触发点击事件,则hide()部分在我的情况下无效。 - Eli Zatlawy
显示剩余3条评论

12

我使用以下方法下载Blob,并在下载后撤销对象URL。它可以在Chrome和Firefox中工作!

function download(blob){
    var url = URL.createObjectURL(blob);
    console.log('create ' + url);

    window.addEventListener('focus', window_focus, false);
    function window_focus(){
        window.removeEventListener('focus', window_focus, false);
        URL.revokeObjectURL(url);
        console.log('revoke ' + url);
    }
    location.href = url;
}

文件下载对话框关闭后,窗口恢复焦点,因此触发了焦点事件。


仍然存在切换窗口和返回的问题,这将导致模态框隐藏。 - dudeNumber4
19
Chrome这样下载到底部托盘的浏览器从不会使窗口模糊/重新聚焦。 - Coleman

10

从其他地方得到的解决方案:

/**
 *  download file, show modal
 *
 * @param uri link
 * @param name file name
 */
function downloadURI(uri, name) {
// <------------------------------------------       Do something (show loading)
    fetch(uri)
        .then(resp => resp.blob())
        .then(blob => {
            const url = window.URL.createObjectURL(blob);
            const a = document.createElement('a');
            a.style.display = 'none';
            a.href = url;
            // the filename you want
            a.download = name;
            document.body.appendChild(a);
            a.click();
            window.URL.revokeObjectURL(url);
            // <----------------------------------------  Detect here (hide loading)
            alert('File detected');
            a.remove(); // remove element
        })
        .catch(() => alert('An error sorry'));
}

你可以使用它:

downloadURI("www.linkToFile.com", "file.name");


1
工作正常,但基本上是在内存中将数据转换为Base64,然后重新转换为二进制并下载。不建议用于大文件。 - Erdal G.
1
我该如何将最终下载的文件名设置为从“url”获取的文件名? - Harun Or Rashid

9

我遇到了与这个配置相同的问题:

我的解决方案是使用cookie:

客户端:

在提交表单时,调用JavaScript函数隐藏页面并加载等待动画。

function loadWaitingSpinner() {
    ... hide your page and show your spinner ...
}

然后,调用一个函数,每500毫秒检查是否有来自服务器的cookie。
function checkCookie() {
    var verif = setInterval(isWaitingCookie, 500, verif);
}

如果找到 cookie,停止每 500 毫秒检查,让 cookie 过期并调用您的函数返回到您的页面并删除等待图标(removeWaitingSpinner())。如果您想要再次下载另一个文件,重要的是要让 cookie 过期!
function isWaitingCookie(verif) {
    var loadState = getCookie("waitingCookie");
    if (loadState == "done") {
        clearInterval(verif);
        document.cookie = "attenteCookie=done; expires=Tue, 31 Dec 1985 21:00:00 UTC;";
        removeWaitingSpinner();
    }
}

function getCookie(cookieName) {
    var name = cookieName + "=";
    var cookies = document.cookie
    var cs = cookies.split(';');
    for (var i = 0; i < cs.length; i++) {
        var c = cs[i];
        while(c.charAt(0) == ' ') {
            c = c.substring(1);
        }
        if (c.indexOf(name) == 0) {
            return c.substring(name.length, c.length);
        }
    }
    return "";
}

function removeWaitingSpinner() {
    ... come back to your page and remove your spinner ...
}

服务器端:

在服务器处理结束时,向响应中添加一个cookie。该cookie将在文件准备好下载时发送给客户端。

Cookie waitCookie = new Cookie("waitingCookie", "done");
response.addCookie(waitCookie);

这行代码 document.cookie = "attenteCookie=done; expires=Tue, 31 Dec 1985 21:00:00 UTC;"; 中,用于设置 cookie 过期的部分,应该将 attenteCookie 改为 waitingCookie 吗? - Jemma

8
我编写了一个简单的JavaScript类,实现了与bulltorious' answer中描述的技术类似的技术。我希望它能对这里的某些人有所帮助。
GitHub项目名为response-monitor.js
默认情况下,它使用spin.js作为等待指示器,但它还提供了一组回调函数,用于实现自定义指示器。
支持jQuery,但不是必需的。
值得注意的功能:
- 简单集成 - 无依赖性 - jQuery插件(可选) - Spin.js集成(可选) - 可配置的监视事件回调 - 处理多个同时请求 - 服务器端错误检测 - 超时检测 - 跨浏览器
示例用法:
HTML
<!-- The response monitor implementation -->
<script src="response-monitor.js"></script>

<!-- Optional jQuery plug-in -->
<script src="response-monitor.jquery.js"></script>

<a class="my_anchors" href="/report?criteria1=a&criteria2=b#30">Link 1 (Timeout: 30s)</a>
<a class="my_anchors" href="/report?criteria1=b&criteria2=d#10">Link 2 (Timeout: 10s)</a>

<form id="my_form" method="POST">
    <input type="text" name="criteria1">
    <input type="text" name="criteria2">
    <input type="submit" value="Download Report">
</form>

客户端(纯JavaScript)

// Registering multiple anchors at once
var my_anchors = document.getElementsByClassName('my_anchors');
ResponseMonitor.register(my_anchors); // Clicking on the links initiates monitoring

// Registering a single form
var my_form = document.getElementById('my_form');
ResponseMonitor.register(my_form); // The submit event will be intercepted and monitored

客户端(jQuery)
$('.my_anchors').ResponseMonitor();
$('#my_form').ResponseMonitor({timeout: 20});

带有回调函数的客户端(jQuery)

// When options are defined, the default spin.js integration is bypassed
var options = {
    onRequest: function(token) {
        $('#cookie').html(token);
        $('#outcome').html('');
        $('#duration').html('');
    },
    onMonitor: function(countdown) {
        $('#duration').html(countdown);
    },
    onResponse: function(status) {
        $('#outcome').html(status==1 ? 'success' : 'failure');
    },
    onTimeout: function() {
        $('#outcome').html('timeout');
    }
};

// Monitor all anchors in the document
$('a').ResponseMonitor(options);

服务器(PHP)

$cookiePrefix = 'response-monitor'; // Must match the one set on the client options
$tokenValue = $_GET[$cookiePrefix];
$cookieName = $cookiePrefix.'_'.$tokenValue; // Example: response-monitor_1419642741528

// This value is passed to the client through the ResponseMonitor.onResponse callback
$cookieValue = 1; // For example, "1" can interpret as success and "0" as failure

setcookie(
    $cookieName,
    $cookieValue,
    time() + 300,          // Expire in 5 minutes
    "/",
    $_SERVER["HTTP_HOST"],
    true,
    false
);

header('Content-Type: text/plain');
header("Content-Disposition: attachment; filename=\"Response.txt\"");

sleep(5); // Simulate whatever delays the response
print_r($_REQUEST); // Dump the request in the text file

如果需要更多示例,请查看存储库中的示例文件夹


8
如果您正在流式传输您动态生成的文件,并且已经实现了实时服务器到客户端消息库,那么您可以很容易地通知客户端。我喜欢并推荐的服务器到客户端消息库是 Socket.io(通过 Node.js)。在您的服务器脚本完成生成下载流式传输文件后,该脚本的最后一行可以向 Socket.io 发送一个消息,该消息向客户端发送通知。在客户端上,Socket.io 监听从服务器发出的传入消息,并允许您对其进行操作。使用此方法的好处是,您能够在流式传输完成后检测到“真正”的完成事件。
例如,您可以在单击下载链接后显示繁忙指示器、流式传输文件、从流式传输脚本的最后一行向 Socket.io 发送一个消息、在客户端上监听通知、接收通知并通过隐藏繁忙指示器来更新 UI。
我意识到大多数阅读此问题答案的人可能没有这种类型的设置,但我已经在自己的项目中使用了这个确切的解决方案,并取得了很好的效果。
Socket.io 安装和使用非常简单。详情请见:http://socket.io/

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接