我有一个基于jquery的单页web应用程序。它通过AJAX调用与基于rest的web服务通信。

我正在努力实现以下目标:

向REST url提交包含JSON数据的POST。 如果请求指定了JSON响应,则返回JSON。 如果请求指定PDF/XLS/etc响应,则返回可下载的二进制文件。

我有1和2工作现在,和客户端jquery应用程序通过创建基于JSON数据的DOM元素在网页中显示返回的数据。我还让#3从web服务的角度工作,这意味着如果给定正确的JSON参数,它将创建并返回二进制文件。但我不确定在客户端javascript代码中处理#3的最佳方法。

是否有可能从这样的ajax调用中获得一个可下载的文件?如何让浏览器下载并保存文件?

$.ajax({
    type: "POST",
    url: "/services/test",
    contentType: "application/json",
    data: JSON.stringify({category: 42, sort: 3, type: "pdf"}),
    dataType: "json",
    success: function(json, status){
        if (status != "success") {
            log("Error loading data");
            return;
        }
        log("Data loaded!");
    },
    error: function(result, status, err) {
        log("Error loading data");
        return;
    }
});

服务器响应以下报头:

Content-Disposition:attachment; filename=export-1282022272283.pdf
Content-Length:5120
Content-Type:application/pdf
Server:Jetty(6.1.11)

另一种想法是生成PDF并将其存储在服务器上,然后返回包含文件URL的JSON。然后,在ajax成功处理程序中发出另一个调用,执行如下操作:

success: function(json,status) {
    window.location.href = json.url;
}

但这样做意味着我需要对服务器进行多次调用,我的服务器需要构建可下载的文件,将它们存储在某个地方,然后定期清理存储区域。

一定有更简单的方法。想法吗?


编辑:在检查文档$。ajax,我看到响应dataType只能是xml, html,脚本,json, jsonp,文本之一,所以我猜没有办法直接下载一个文件使用ajax请求,除非我嵌入二进制文件使用数据URI方案建议在@VinayC答案(这不是我想做的事情)。

所以我想我的选择是:

Not use ajax and instead submit a form post and embed my JSON data into the form values. Would probably need to mess with hidden iframes and such. Not use ajax and instead convert my JSON data into a query string to build a standard GET request and set window.location.href to this URL. May need to use event.preventDefault() in my click handler to keep browser from changing from the application URL. Use my other idea above, but enhanced with suggestions from the @naikus answer. Submit AJAX request with some parameter that lets web-service know this is being called via an ajax call. If the web service is called from an ajax call, simply return JSON with a URL to the generated resource. If the resource is called directly, then return the actual binary file.

我越想越喜欢最后一个选项。通过这种方式,我可以获得关于请求的信息(生成时间、文件大小、错误消息等),并且可以在开始下载之前对这些信息采取行动。缺点是服务器上需要额外的文件管理。

还有其他方法吗?我应该知道这些方法的优缺点吗?


当前回答

制作自己的事件的问题

本文提出的许多解决方案都让JavaScript异步运行,并创建一个link元素,然后调用

const a = documet.createElement("a") 
a.click()

或者创建一个鼠标事件

new MouseEvent({/* ...some config */})

这看起来很好,对吧?这有什么错呢?

什么是事件源?

事件源在整个计算领域有很多含义,比如基于云架构的发布订阅系统,或者浏览器api EventSource。在浏览器的上下文中 所有事件都有一个源,该源有一个隐藏属性,说明是谁发起了这个事件(用户或站点)。

了解了这一点,我们就可以开始理解为什么两次点击事件不能被同等对待

user click*          new MouseEvent()
-----------            -----------
| Event 1 |            | Event 2 |
-----------            -----------
     |                      |     
     |----------------------|
                 |
                 |
      ----------------------
      | Permissions Policy |    Available in chrome allows the server to control
      ----------------------    what features are going to be used by the JS
                 |
                 |
   ----------------------------
   | Browser Fraud Protection | The Browser REALLY doesnt like being told to pretend
   ---------------------------- to be a user. If you will remember back to the early
                 |              2000s when one click spun off 2000 pop ups. Well here
                 |              is where popups are blocked, fraudulent ad clicks are
                \ /             thrown out, and most importantly for our case stops 
                 v              fishy downloads
      JavaScript Event Fires

所以我不能下载一个帖子,这是愚蠢的

不,你当然可以。您只需要给用户一个创建事件的机会。这里有一些模式,您可以使用它们来创建明显且常规的用户流,并且不会被标记为欺诈。(使用JSX抱歉,不抱歉)

表单可以使用post操作导航到url。

const example = () => (
  <form
   method="POST"
   action="/super-api/stuff"
   onSubmit={(e) => {/* mutably change e form data but don't e.preventDetfault() */}}
  >
    {/* relevant input fields of your download */}
  </form>
)

如果你的下载是不可配置的,你可能想要考虑将下载预加载到respb . Blob()或new Blob(resp),这会告诉浏览器这是一个文件,我们不会对它做任何字符串操作。与其他答案一样,你可以使用window.URL.createObjectURL,这里没有提到的是

createObjectURL可以在javascript源代码中产生内存泄漏

如果你不想让c++恶霸来取笑你,你必须释放这个内存。但我只是个爱好,喜欢收垃圾的人。不用担心,这很简单,如果你在大多数框架中工作(对我来说),你只需要在你的组件和你的权利上注册某种清理效果。

const preload = () => {
  const [payload, setPayload] = useState("")
  
  useEffect(() => {
    fetch("/super-api/stuff")
      .then((f) => f.blob())
      .then(window.URL.createObjectURL)
      .then(setPayload)

    return () => window.URL.revokeObjectURL(payload)
  }, [])


  return (<a href={payload} download disabled={payload === ""}>Download Me</a>)
}

其他回答

有一种更简单的方法,创建一个表单并发布它,如果返回的mime类型是浏览器会打开的,则会有重置页面的风险,但对于csv等来说,这是完美的

示例需要下划线和jquery

var postData = {
    filename:filename,
    filecontent:filecontent
};
var fakeFormHtmlFragment = "<form style='display: none;' method='POST' action='"+SAVEAS_PHP_MODE_URL+"'>";
_.each(postData, function(postValue, postKey){
    var escapedKey = postKey.replace("\\", "\\\\").replace("'", "\'");
    var escapedValue = postValue.replace("\\", "\\\\").replace("'", "\'");
    fakeFormHtmlFragment += "<input type='hidden' name='"+escapedKey+"' value='"+escapedValue+"'>";
});
fakeFormHtmlFragment += "</form>";
$fakeFormDom = $(fakeFormHtmlFragment);
$("body").append($fakeFormDom);
$fakeFormDom.submit();

对于html、text等,确保mimetype是application/octet-stream之类的东西

php代码

<?php
/**
 * get HTTP POST variable which is a string ?foo=bar
 * @param string $param
 * @param bool $required
 * @return string
 */
function getHTTPPostString ($param, $required = false) {
    if(!isset($_POST[$param])) {
        if($required) {
            echo "required POST param '$param' missing";
            exit 1;
        } else {
            return "";
        }
    }
    return trim($_POST[$param]);
}

$filename = getHTTPPostString("filename", true);
$filecontent = getHTTPPostString("filecontent", true);

header("Content-type: application/octet-stream");
header("Content-Disposition: attachment; filename=\"$filename\"");
echo $filecontent;

制作自己的事件的问题

本文提出的许多解决方案都让JavaScript异步运行,并创建一个link元素,然后调用

const a = documet.createElement("a") 
a.click()

或者创建一个鼠标事件

new MouseEvent({/* ...some config */})

这看起来很好,对吧?这有什么错呢?

什么是事件源?

事件源在整个计算领域有很多含义,比如基于云架构的发布订阅系统,或者浏览器api EventSource。在浏览器的上下文中 所有事件都有一个源,该源有一个隐藏属性,说明是谁发起了这个事件(用户或站点)。

了解了这一点,我们就可以开始理解为什么两次点击事件不能被同等对待

user click*          new MouseEvent()
-----------            -----------
| Event 1 |            | Event 2 |
-----------            -----------
     |                      |     
     |----------------------|
                 |
                 |
      ----------------------
      | Permissions Policy |    Available in chrome allows the server to control
      ----------------------    what features are going to be used by the JS
                 |
                 |
   ----------------------------
   | Browser Fraud Protection | The Browser REALLY doesnt like being told to pretend
   ---------------------------- to be a user. If you will remember back to the early
                 |              2000s when one click spun off 2000 pop ups. Well here
                 |              is where popups are blocked, fraudulent ad clicks are
                \ /             thrown out, and most importantly for our case stops 
                 v              fishy downloads
      JavaScript Event Fires

所以我不能下载一个帖子,这是愚蠢的

不,你当然可以。您只需要给用户一个创建事件的机会。这里有一些模式,您可以使用它们来创建明显且常规的用户流,并且不会被标记为欺诈。(使用JSX抱歉,不抱歉)

表单可以使用post操作导航到url。

const example = () => (
  <form
   method="POST"
   action="/super-api/stuff"
   onSubmit={(e) => {/* mutably change e form data but don't e.preventDetfault() */}}
  >
    {/* relevant input fields of your download */}
  </form>
)

如果你的下载是不可配置的,你可能想要考虑将下载预加载到respb . Blob()或new Blob(resp),这会告诉浏览器这是一个文件,我们不会对它做任何字符串操作。与其他答案一样,你可以使用window.URL.createObjectURL,这里没有提到的是

createObjectURL可以在javascript源代码中产生内存泄漏

如果你不想让c++恶霸来取笑你,你必须释放这个内存。但我只是个爱好,喜欢收垃圾的人。不用担心,这很简单,如果你在大多数框架中工作(对我来说),你只需要在你的组件和你的权利上注册某种清理效果。

const preload = () => {
  const [payload, setPayload] = useState("")
  
  useEffect(() => {
    fetch("/super-api/stuff")
      .then((f) => f.blob())
      .then(window.URL.createObjectURL)
      .then(setPayload)

    return () => window.URL.revokeObjectURL(payload)
  }, [])


  return (<a href={payload} download disabled={payload === ""}>Download Me</a>)
}

我认为最好的方法是结合使用。您的第二种方法似乎是一个优雅的解决方案,其中涉及到浏览器。

这取决于调用的方式。(无论是浏览器还是web服务调用)您可以使用两者的组合,将URL发送到浏览器并将原始数据发送到任何其他web服务客户端。

这并不完全是对原始帖子的回答,而是一种快速而简单的解决方案,用于将json对象发布到服务器并动态生成下载。

客户端jQuery:

var download = function(resource, payload) {
     $("#downloadFormPoster").remove();
     $("<div id='downloadFormPoster' style='display: none;'><iframe name='downloadFormPosterIframe'></iframe></div>").appendTo('body');
     $("<form action='" + resource + "' target='downloadFormPosterIframe' method='post'>" +
      "<input type='hidden' name='jsonstring' value='" + JSON.stringify(payload) + "'/>" +
      "</form>")
      .appendTo("#downloadFormPoster")
      .submit();
}

..然后在服务器端解码json字符串并设置下载头(PHP示例):

$request = json_decode($_POST['jsonstring']), true);
header('Content-Type: application/csv');
header('Content-Disposition: attachment; filename=export.csv');
header('Pragma: no-cache');

I know this kind of old, but I think I have come up with a more elegant solution. I had the exact same problem. The issue I was having with the solutions suggested were that they all required the file being saved on the server, but I did not want to save the files on the server, because it introduced other problems (security: the file could then be accessed by non-authenticated users, cleanup: how and when do you get rid of the files). And like you, my data was complex, nested JSON objects that would be hard to put into a form.

What I did was create two server functions. The first validated the data. If there was an error, it would be returned. If it was not an error, I returned all of the parameters serialized/encoded as a base64 string. Then, on the client, I have a form that has only one hidden input and posts to a second server function. I set the hidden input to the base64 string and submit the format. The second server function decodes/deserializes the parameters and generates the file. The form could submit to a new window or an iframe on the page and the file will open up.

这涉及到更多的工作,可能还有更多的处理,但总的来说,使用这个解决方案我感觉好多了。

代码是c# /MVC

    public JsonResult Validate(int reportId, string format, ReportParamModel[] parameters)
    {
        // TODO: do validation

        if (valid)
        {
            GenerateParams generateParams = new GenerateParams(reportId, format, parameters);

            string data = new EntityBase64Converter<GenerateParams>().ToBase64(generateParams);

            return Json(new { State = "Success", Data = data });
        }

        return Json(new { State = "Error", Data = "Error message" });
    }

    public ActionResult Generate(string data)
    {
        GenerateParams generateParams = new EntityBase64Converter<GenerateParams>().ToEntity(data);

        // TODO: Generate file

        return File(bytes, mimeType);
    }

在客户端

    function generate(reportId, format, parameters)
    {
        var data = {
            reportId: reportId,
            format: format,
            params: params
        };

        $.ajax(
        {
            url: "/Validate",
            type: 'POST',
            data: JSON.stringify(data),
            dataType: 'json',
            contentType: 'application/json; charset=utf-8',
            success: generateComplete
        });
    }

    function generateComplete(result)
    {
        if (result.State == "Success")
        {
            // this could/should already be set in the HTML
            formGenerate.action = "/Generate";
            formGenerate.target = iframeFile;

            hidData = result.Data;
            formGenerate.submit();
        }
        else
            // TODO: display error messages
    }