Generating and efficiently exporting a file in a Blazor WebAssembly application
In a Blazor WebAssembly application, I needed to export data to a file. In a web browser, you cannot write the file directly to the file system (not exactly true anymore). Instead, you need to create a valid URL, create a <a>
element, and trigger a click on it. There are 2 ways to create a valid URL:
- Using a base64 data URL (e.g.
data:text/plain;base64,SGVsbG8sIFdvcmxkIQ==
), but there are a few limitations included a limited file size (Common problems) - Using a Blob and
URL.createObjectURL(blob)
So, the best solution is to create a Blob
, so there is no limitation in size. Using WebAssembly this is also much faster!
Let's create the razor page with 2 buttons to generate a file and download it:
@page "/"
@inject IJSRuntime JSRuntime
<button @onclick="DownloadBinary">Download binary file</button>
<button @onclick="DownloadText">Download text file</button>
@code{
async Task DownloadBinary()
{
// Generate a file
byte[] file = Enumerable.Range(0, 100).Select(value => (byte)value).ToArray();
// Send the data to JS to actually download the file
await JSRuntime.InvokeVoidAsync("BlazorDownloadFile", "file.bin", "application/octet-stream", file);
}
async Task DownloadText()
{
// Generate a text file
byte[] file = System.Text.Encoding.UTF8.GetBytes("Hello world!");
await JSRuntime.InvokeVoidAsync("BlazorDownloadFile", "file.txt", "text/plain", file);
}
}
Now we have to implement theHere's the corresponding JavaScript function BlazorDownloadFile
. ASP.NET Core Blazor 6 introduce a new feature to make it easier to implement this function.
#.NET 6: BlazorDownloadFile JS function
Blazor now supports optimized byte-array interop, which avoids encoding and decoding byte-arrays into Base64 and facilitates a more efficient interop process. This applies to both Blazor Server and Blazor WebAssembly.
// Use it for .NET 6+
function BlazorDownloadFile(filename, contentType, content) {
// Create the URL
const file = new File([content], filename, { type: contentType });
const exportUrl = URL.createObjectURL(file);
// Create the <a> element and click on it
const a = document.createElement("a");
document.body.appendChild(a);
a.href = exportUrl;
a.download = filename;
a.target = "_self";
a.click();
// We don't need to keep the object URL, let's release the memory
// On older versions of Safari, it seems you need to comment this line...
URL.revokeObjectURL(exportUrl);
}
#NET Core 3.1 or .NET 5: BlazorDownloadFile JS function
// Use it for .NET Core 3.1 or .NET 5
function BlazorDownloadFile(filename, contentType, content) {
// Blazor marshall byte[] to a base64 string, so we first need to convert
// the string (content) to a Uint8Array to create the File
const data = base64DecToArr(content);
// Create the URL
const file = new File([data], filename, { type: contentType });
const exportUrl = URL.createObjectURL(file);
// Create the <a> element and click on it
const a = document.createElement("a");
document.body.appendChild(a);
a.href = exportUrl;
a.download = filename;
a.target = "_self";
a.click();
// We don't need to keep the object URL, let's release the memory
// On older versions of Safari, it seems you need to comment this line...
URL.revokeObjectURL(exportUrl);
}
// Convert a base64 string to a Uint8Array. This is needed to create a blob object from the base64 string.
// The code comes from: https://developer.mozilla.org/fr/docs/Web/API/WindowBase64/D%C3%A9coder_encoder_en_base64
function b64ToUint6(nChr) {
return nChr > 64 && nChr < 91 ? nChr - 65 : nChr > 96 && nChr < 123 ? nChr - 71 : nChr > 47 && nChr < 58 ? nChr + 4 : nChr === 43 ? 62 : nChr === 47 ? 63 : 0;
}
function base64DecToArr(sBase64, nBlocksSize) {
var
sB64Enc = sBase64.replace(/[^A-Za-z0-9\+\/]/g, ""),
nInLen = sB64Enc.length,
nOutLen = nBlocksSize ? Math.ceil((nInLen * 3 + 1 >> 2) / nBlocksSize) * nBlocksSize : nInLen * 3 + 1 >> 2,
taBytes = new Uint8Array(nOutLen);
for (var nMod3, nMod4, nUint24 = 0, nOutIdx = 0, nInIdx = 0; nInIdx < nInLen; nInIdx++) {
nMod4 = nInIdx & 3;
nUint24 |= b64ToUint6(sB64Enc.charCodeAt(nInIdx)) << 18 - 6 * nMod4;
if (nMod4 === 3 || nInLen - nInIdx === 1) {
for (nMod3 = 0; nMod3 < 3 && nOutIdx < nOutLen; nMod3++, nOutIdx++) {
taBytes[nOutIdx] = nUint24 >>> (16 >>> nMod3 & 24) & 255;
}
nUint24 = 0;
}
}
return taBytes;
}
This works pretty well! The file is generated and the user can download it. However, when the file is large (a few MB), it takes seconds before downloading the file. This is because converting the base64 string to the Uint8Array
is slow. Let's see how we can improve that by using low-level methods in Blazor WebAssembly!
#Blazor WebAssembly optimization (.NET Core 3.1 and .NET 5)
This conversion looks useless as a byte[]
is the same as an Uint8Array
. So, there may be a way to avoid the conversion. In the previous post about optimizing JS Interop in a Blazor WebAssembly application, I explained how to use the Blazor WebAssembly specific methods to call a JS function. One of them is to call the function without marshaling the parameters. In this case, mono doesn't convert the parameters to the JS native types. In this case, the values are just handled you need to manipulate.
void DownloadBinaryOptim()
{
byte[] file = Enumerable.Range(0, 100).Select(v => (byte)v).ToArray();
string fileName = "file.bin";
string contentType = "application/octet-stream";
// Check if the IJSRuntime is the WebAssembly implementation of the JSRuntime
if (JSRuntime is IJSUnmarshalledRuntime webAssemblyJSRuntime)
{
webAssemblyJSRuntime.InvokeUnmarshalled<string, string, byte[], bool>("BlazorDownloadFileFast", fileName, contentType, file);
}
else
{
// Fall back to the slow method if not in WebAssembly
await JSRuntime.InvokeVoidAsync("BlazorDownloadFile", fileName, contentType, file);
}
}
The JS function is similar to the previous implementation. However, you need to marshal the parameter manually. When using unmarshalled calls, the parameter values are just handled. To convert these handles to actual JS types, you can use the functions provided by Mono and some Blazor helpers.
function BlazorDownloadFileFast(name, contentType, content) {
// Convert the parameters to actual JS types
const nameStr = BINDING.conv_string(name);
const contentTypeStr = BINDING.conv_string(contentType);
const contentArray = Blazor.platform.toUint8Array(content);
// Create the URL
const file = new File([contentArray], nameStr, { type: contentTypeStr });
const exportUrl = URL.createObjectURL(file);
// Create the <a> element and click on it
const a = document.createElement("a");
document.body.appendChild(a);
a.href = exportUrl;
a.download = nameStr;
a.target = "_self";
a.click();
// We don't need to keep the url, let's release the memory
// On Safari it seems you need to comment this line... (please let me know if you know why)
URL.revokeObjectURL(exportUrl);
}
Of course, the code is a little harder to understand, and the functions in BINDING
or Blazor.platform
are not documented. This means it can break in a future version of Blazor WebAssembly. However, the performance improvement is so huge that it is worth it! Using the InvokeAsync
method, it takes a few seconds and it increases linearly with the file size. This method is almost instant and the performance is quite constant whatever the file size is.
#Additional resources
Do you have a question or a suggestion about this post? Contact me!