Trong bài viết này, tôi sẽ chỉ cho bạn cách chạy .NET trong trình duyệt mà không sử dụng Blazor, thay vào đó chỉ dựa vào cơ sở hạ tầng WASM mà Blazor xây dựng trên đó. Tôi cũng sẽ xem xét một số cải tiến sắp tới trong .NET 10, chủ yếu liên quan đến vân tay tệp ở phía máy khách.
Mục lục
Background:
WebAssembly (WASM) xuất hiện trên sân khấu .NET vào năm 2017 khi Steve Sanderson giới thiệu một bản demo công nghệ của những gì cuối cùng sẽ trở thành Blazor. Blazor là một framework web dựa trên thành phần hoàn chỉnh để xây dựng các ứng dụng web sử dụng HTML và C#. Nó có thể chạy ở nhiều chế độ kết xuất, với chế độ WebAssembly Tương tác chạy hoàn toàn trong trình duyệt bằng sức mạnh của WASM.
Khi bạn nói về .NET và WASM, hầu hết mọi người sẽ ngay lập nghĩ đến Blazor, nhưng có một số cách khác để kết hợp .NET với WASM:
- Chạy .NET trên WASM trong trình duyệt mà không sử dụng Blazor.
- Chạy .NET trên WASM bên trong một tiến trình Node.js trên máy chủ.
- Viết các thành phần .NET tương thích với WebAssembly System Interface (WASI) (và gọi các thành phần WASI khác được viết bằng các ngôn ngữ khác).
Ngoài ra, bạn có thể tích hợp các thành phần Blazor vào các framework JavaScript khác như Vue hoặc React, với những cải tiến cho quy trình này trong .NET 10.
Trong bài viết này, tôi đang xem xét cách tiếp cận đầu tiên, chạy .NET sử dụng WASM trong trình duyệt, nhưng không sử dụng các thành phần Blazor.
Tính năng này có vẻ đã có sẵn từ .NET 7. Trong bài viết này, tôi đang sử dụng phiên bản bản xem trước 6 của workload và templates cho .NET 10, nhưng chúng không thay đổi đáng kể.
Cài đặt các template WASM thử nghiệm
Các template để xây dựng một ứng dụng .NET có thể chạy từ JavaScript không được đóng gói cùng với SDK mặc định. Chúng là thử nghiệm, vì vậy bạn cần cài đặt chúng một cách rõ ràng. Gói NuGet nào cần cài đặt phụ thuộc vào phiên bản template bạn muốn:
- .NET 8:
Microsoft.NET.Runtime.WebAssembly.Templates
- .NET 9:
Microsoft.NET.Runtime.WebAssembly.Templates.net9
- .NET 10:
Microsoft.NET.Runtime.WebAssembly.Templates.net10
Chúng ta muốn các template mới nhất, vì vậy chúng ta sẽ cài đặt template .NET 10 (bản xem trước 6 tại thời điểm viết bài này)
dotnet new install Microsoft.NET.Runtime.WebAssembly.Templates.net10
Điều này cài đặt ba template:
Success: Microsoft.NET.Runtime.WebAssembly.Templates.net10::10.0.0-preview.6.25358.103 installed the following templates:
Template Name Short Name Language Tags
----------------------- ----------- -------- -----------------------
Wasi Console App wasiconsole [C#] Wasi/WasiConsole
WebAssembly Browser App wasmbrowser [C#] Web/WebAssembly/Browser
WebAssembly Console App wasmconsole [C#] Web/WebAssembly/Console
Hoặc bạn có thể cài đặt workload wasm-experimental
, bao gồm các template và cũng… một loạt thứ đồ 😅 Tôi không thực sự chắc chắn thứ đồ bổ sung đó thực sự được sử dụng cho cái gì, vì như tôi có thể thấy, không có thứ gì trong số đó là bắt buộc cho điều này 🤷♂️
dotnet workload install wasm-experimental
Lưu ý rằng nếu bạn muốn biên dịch ứng dụng kết quả ở chế độ AOT thì bạn cũng sẽ cần cài đặt workload wasm-tools
. Điều này sẽ mang lại hiệu suất tốt hơn, nhưng nó sẽ làm tăng đáng kể kích thước tệp (vì vậy thời gian khởi động sẽ lâu hơn), vì vậy bạn sẽ cần quyết định những sự đánh đổi nào cần thực hiện ở đó.
Tạo một ứng dụng .NET WASM
Với template đã cài đặt, chúng ta có thể tạo một ứng dụng mới:
dotnet new wasmbrowser
Template tạo ra các tệp sau:
Chúng ta sẽ xem xét hầu hết các tệp này trong thời gian ngắn, nhưng trước tiên chúng ta sẽ chạy ứng dụng. Bạn có thể chạy nó với một lệnh dotnet run
đơn giản:
WasmAppHost --use-staticwebassets --runtime-config D:\repos\temp\bin\Debug\net10.0\temp.runtimeconfig.json
App url: http://localhost:5156/
App url: https://localhost:7048/
Debug at url: http://localhost:5156/_framework/debug
Debug at url: https://localhost:7048/_framework/debug
Nếu bạn mở ứng dụng trong trình duyệt, bạn sẽ thấy template là một ứng dụng bấm giờ đơn giản. Nó bắt đầu ngay khi bạn mở trang, sau đó bạn có thể tạm dừng, đặt lại và bắt đầu bộ đếm thời gian:
Vậy điều này hoạt động như thế nào? Phần còn lại của bài viết, chúng ta sẽ xem xét template và cách nó hoạt động.
Khám phá template
Chúng ta sẽ bắt đầu bằng cách xem xét Program.cs
, đây là một chương trình cấp cao chứa một kiểu trợ giúp gọi là StopwatchSample
. “Chương trình” chính rất đơn giản, như hiển thị bên dưới. Đầu tiên nó ghi vào console (sẽ xuất hiện trong cửa sổ console của trình duyệt) và sau đó tùy chọn gọi phương thức tĩnh StopwatchSample.Start()
nếu các đối số đúng được truyền cho chương trình. Sau đó nó vào một vòng lặp vô hạn, gọi Render()
mỗi giây.
Console.WriteLine("Hello, Browser!");
if (args.Length == 1 && args[0] == "start")
StopwatchSample.Start();
while (true)
{
StopwatchSample.Render();
await Task.Delay(1000);
}
Phần lớn triển khai được định nghĩa trong kiểu StopwatchSample
, như hiển thị bên dưới. Nói chung, kiểu này là một trình bao bọc đơn giản xung quanh một phiên bản tĩnh của System.Diagnostics.Stopwatch. Những phần thú vị là phương thức Render()
gọi SetInnerText
(được trang trí với thuộc tính [JSImport]
), và các phương thức khác được trang trí với các thuộc tính [JSExport]
.
partial class StopwatchSample
{
private static Stopwatch stopwatch = new();
public static void Start() => stopwatch.Start();
public static void Render() => SetInnerText("#time", stopwatch.Elapsed.ToString(@"mm\:ss"));
[JSImport("dom.setInnerText", "main.js")]
internal static partial void SetInnerText(string selector, string content);
[JSExport]
internal static bool Toggle()
{
if (stopwatch.IsRunning)
{
stopwatch.Stop();
return false;
}
else
{
stopwatch.Start();
return true;
}
}
[JSExport]
internal static void Reset()
{
if (stopwatch.IsRunning)
stopwatch.Restart();
else
stopwatch.Reset();
Render();
}
[JSExport]
internal static bool IsRunning() => stopwatch.IsRunning;
}
Như bạn có thể đoán, [JSImport]
và [JSExport]
cung cấp phương tiện để tương tác với JavaScript trong trình duyệt từ mã .NET của bạn. Các thuộc tính này được sử dụng để điều khiển hai trình tạo nguồn, JSImportGenerator
và JSExportGenerator
tương ứng, cả hai đều trong Microsoft.Interop.JavaScript. Vì vậy, bạn có thể nhấn F12 để xem nguồn được tạo trong IDE của mình và xem nó đang làm chính xác điều gì.
Thực tế, nó là một đoạn code khá khó đọc, vì vậy tôi sẽ không đi vào chi tiết ở đây, nhưng về cơ bản nó chỉ là việc chuyển tiếp giữa thế giới .NET (WASM) và thế giới JavaScript, liên kết các hàm JavaScript hiện có (trong trường hợp [JSImport]
), hoặc mô tả hình dạng của các phương thức để hiển thị cho JavaScript gọi.
p>Để hiểu code được tạo này đang tương tác với cái gì, chúng ta sẽ tiếp theo xem xét mã HTML và JavaScript. HTML rất đơn giản:
<!DOCTYPE html>
<html>
<head>
<title>temp78</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 👇 These are updated during dotnet run and dotnet publish -->
<link rel="preload" id="webassembly" />
<script type="importmap"></script>
<script type='module' src="main#[.{fingerprint}].js"></script>
</head>
<body>
<h1>Stopwatch</h1>
<p>
Time elapsed in .NET is <span id="time"><i>loading...</i></span>
</p>
<p>
<button id="pause">Pause</button>
<button id="reset">Reset</button>
</p>
</body>
</html>
HTML ở đây cho thấy dàn ý rộng của ứng dụng mà chúng ta đã thấy trước đó. Nó bao gồm một số phần tử link
và script
, cần thiết để kết nối các thành phần .NET WASM, và nó bao gồm cấu trúc phần tử cơ bản cho ứng dụng, bao gồm một số phần tử có id
rõ ràng.
Tiếp theo chúng ta xem xét tệp main.js, đây là điểm nhập cho ứng dụng, vì nó được liên kết trực tiếp trong tệp index.html ở trên. Tôi đã thêm các bình luận vào tệp này để giải thích từng bước đang làm gì:
// Import the .NET runtime support
import { dotnet } from './_framework/dotnet.js'
const { setModuleImports, getAssemblyExports, getConfig, runMain } = await dotnet
.withApplicationArguments("start") // these are the args passed to the program
.create(); // Set up the .NET WASM runtime
// setModuleImports associates a set of imports (dom.setInnerText) with an
// associated module (main.js). This pair must match the values provided in the
// [JSImport] attribute to connect everything up correctly
setModuleImports('main.js', {
dom: {
setInnerText: (selector, time) => document.querySelector(selector).innerText = time
}
});
// Return information about the environment and app. e.g. Environment variables (very few)
// runtimeConfig, assembly name, referenced assemblies etc
const config = getConfig();
// get all the functions exposed in the main assembly by [JSExport], so that they
// can be invoked from JavaScript
const exports = await getAssemblyExports(config.mainAssemblyName);
// attach a click handler to the reset button and invoke the exported
// StopwatchSample.Reset() function
document.getElementById('reset').addEventListener('click', e => {
exports.StopwatchSample.Reset();
e.preventDefault();
});
// attach a click handler to the pause button and invoke the exported
// StopwatchSample.Toggle() function
const pauseButton = document.getElementById('pause');
pauseButton.addEventListener('click', e => {
const isRunning = exports.StopwatchSample.Toggle();
pauseButton.innerText = isRunning ? 'Pause' : 'Start';
e.preventDefault();
});
// run the C# Main() method and keep the runtime process running and executing further API calls
await runMain();
Điều này bao gồm gần như tất cả những gì cần thiết. Tóm lại:
[JSExport]
và[JSImport]
tạo ra mã C# xử lý việc chuyển tiếp đến và từ các kiểu JavaScript.- Index.html tham chiếu đến runtime WASM .NET được đóng gói và ứng dụng được biên dịch của bạn.
- main.js xử việc khởi động runtime .NET, cung cấp các yêu cầu nhập cho nơi ứng dụng của bạn cần gọi vào JavaScript, và chạy ứng dụng .NET.
Một phần tốt của công cụ xung quanh các tính năng này là bạn có thể chỉ cần dotnet run
hoặc nhấn F5 để chạy ứng dụng trong trình duyệt, nhưng cuối cùng bạn sẽ muốn xuất bản dự án khi chạy nó trong môi trường sản xuất.
Xuất bản ứng dụng WASM của bạn
Bạn có thể xuất bản ứng dụng của mình bằng một lệnh dotnet publish -c Release
đơn giản và theo mặc định, công cụ sẽ biên dịch ứng dụng, xuất bản và cắt bớt các tham chiếu framework, và nén đầu ra cả bằng gzip và brotli.
Một điểm thú vị khác là vân tay tệp ở phía máy khách của các tài sản này. .NET 9 đã giới thiệu vân tay phía máy chủ của tài sản tĩnh (với MapStaticAssets()
) và trong .NET 10 bạn có thể chọn tham gia vào việc vân tay tương tự cho các tài sản vừa cho các ứng dụng Blazor WebAssembly vừa cho các ứng dụng WASM không dùng Blazor (như chúng ta đang thảo luận).
Để kích hoạt hành vi này, bạn cần làm một số việc:
- Thêm
<script type="importmap"></script>
vào tệp index.html. - Thêm
#[{.fingerprint}]
vào các tham chiếu script trong index.html. - Đặt
OverrideHtmlAssetPlaceholders=true
. - Chọn tham gia cho các tài sản của bạn sử dụng
<StaticWebAssetFingerprintPattern>
.
Tất cả các điểm này đều được thực hiện theo mặc định trong template, tuy nhiên có một lỗi với điểm cuối cùng, nên được sửa trong bản cập nhật. Đọc tiếp để biết thêm chi tiết.
Hai điểm đầu tiên được bao gồm bởi các bổ sung mới vào template trong .NET 10, thêm importmap
và vân tay vào main
:
<link rel="preload" id="webassembly" />
<script type="importmap"></script>
<script type='module' src="main#[.{fingerprint}].js"></script>
Template cũng thêm <link rel="preload" id="webassembly" />
cho phép tải trước các tệp webassembly, với mục đích cải thiện thời gian khởi động lạnh. Khi bạn chạy ứng dụng, các phần tử này được viết lại để trông tương tự như sau, với vân tay của tất cả các tệp:
<link href="_framework/dotnet.y5zm2li12l.js" rel="preload" as="script" fetchpriority="high" crossorigin="anonymous" integrity="sha256-eo4p7mQEfnCQ6TQ0N72uJX+t0QX8QmikrPGJjyy3QLQ=" />
<script type="importmap">{
"imports": {
"./_framework/dotnet.native.js": "./_framework/dotnet.native.hwglpvp32y.js",
"./_framework/dotnet.runtime.js": "./_framework/dotnet.runtime.0t78nptbqi.js",
"./_framework/dotnet.js": "./_framework/dotnet.y5zm2li12l.js",
"./main.js": "./main.ofkecrt505.js"
},
"scopes": {},
"integrity": {
"./_framework/dotnet.js": "sha256-eo4p7mQEfnCQ6TQ0N72uJX+t0QX8QmikrPGJjyy3QLQ=",
"./_framework/dotnet.native.hwglpvp32y.js": "sha256-0S3nkr+7+aZ+9tFRQOEYkmozryFXfrzgl+nv+qz71QM=",
"./_framework/dotnet.native.js": "sha256-0S3nkr+7+aZ+9tFRQOEYkmozryFXfrzgl+nv+qz71QM=",
"./_framework/dotnet.runtime.0t78nptbqi.js": "sha256-fBs/I1SdlqDOQOqxGF+LdElB3o5/FirA8fyIHRUy9cE=",
"./_framework/dotnet.runtime.js": "sha256-fBs/I1SdlqDOQOqxGF+LdElB3o5/FirA8fyIHRUy9cE=",
"./_framework/dotnet.y5zm2li12l.js": "sha256-eo4p7mQEfnCQ6TQ0N72uJX+t0QX8QmikrPGJjyy3QLQ=",
"./main.js": "sha256-9EZteoeGyecFbFTmMweXxx9ItCAClDZ8n+6lXEEulSU=",
"./main.ofkecrt505.js": "sha256-9EZteoeGyecFbFTmMweXxx9ItCAClDZ8n+6lXEEulSU="
}
}</script>
<script type='module' src="main.ofkecrt505.js"></script>
</head>
Trong tệp _.csproj_, template cũng thêm các mục OverrideHtmlAssetPlaceholders
và StaticWebAssetFingerprintPattern
cần thiết, cho phép hành vi trên:
<Project Sdk="Microsoft.NET.Sdk.WebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<!-- 👇 Required for fingerprinting -->
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
</PropertyGroup>
<ItemGroup>
<!-- 👇 Required for fingerprinting -->
<StaticWebAssetFingerprintPattern Include="JS" Pattern="*.js" />
</ItemGroup>
</Project>
Tuy nhiên. Nếu bạn xuất bản ứng dụng và kiểm tra tệp index.html
, bạn sẽ thấy vân tay main.js
một cách đáng chú ý:
<!-- No fingerprint 👇 -->
<script type='module' src="main.js"></script>
Vậy đang xảy ra điều gì🤔 Hóa ra đây là một lỗi trong template, nhưng bạn có thể làm việc xung quanh nó bằng cách thay đổi template của mình:
- <StaticWebAssetFingerprintPattern Include="JS" Pattern="*.js" />
+ <StaticWebAssetFingerprintPattern Include="JS" Pattern="*.js" Expression="#[{.fingerprint}]!" />
Thêm thuộc tính Expression="#[{.fingerprint}]!"
(được tham chiếu trong tài liệu) giải quyết vấn đề khi xuất bản, và đảm bảo vân tay được thêm vào các tệp script.
Giảm kích thước ứng dụng được xuất bản
Theo sở thích, tôi đã kiểm tra kích thước xuất bản của ứng dụng mẫu này (ở chế độ phát hành) và nó trông xấp xỉ như sau:
- 6.8MB chưa nén
- 2.5MB đã nén (gzip)
- 2.0MB đã nén (brotli)
Điều đó bao gồm tất cả các tệp, bao gồm cả runtime .NET, vì vậy không tệ. Runtime rõ ràng đã được cắt giảm đáng kể để đạt được các kích thước này, nhưng chúng ta có thể nhỏ hơn nữa? Một điểm nổi bật rõ ràng là các tập tin icu
, vì tôi tự hỏi việc kích hoạt chế độ toàn cầu bất biến có thể giảm thêm hay không. Tôi đã thêm điều sau vào tệp dự án:
<InvariantGlobalization>true</InvariantGlobalization>
và chạy dotnet publish -c Release
một lần nữa. Và chắc chắn, có một số lợi ích đáng có:
- 4.3MB chưa nén—giảm 2.5MB
- 1.7MB đã nén (gzip)—giảm 0.8MB
- 1.4MB đã nén (brotli)—giảm 0.6MB
Điều đó mang lại sự giảm 30-37% trong tổng kích thước ứng dụng, là một sự giảm khá tốt! Rõ ràng điều này phụ thuộc vào ứng dụng của bạn về việc chế độ toàn cầu bất biến có khả thi hay không, nhưng đó là một công cụ hữu ích nếu có.
Và đó là tất cả những gì cần thiết. Cách tiếp cận này để chạy mã .NET trong JavaScript thấp cấp hơn nhiều so với việc sử dụng Blazor hoặc tương tác với các framework web khác, vì vậy khả năng cao bạn sẽ thấy giá trị lớn khi kết nối ở lớp này. Tuy nhiên, nếu bạn không cần Blazor, thì đây có thể chính là những gì bạn cần!
Tóm tắt
Trong bài viết này, tôi đã mô tả các cách khác nhau mà mã .NET có thể chạy sử dụng WebAssembly (WASM), tập trung vào việc chạy mã .NET trong trình duyệt không sử dụng framework thành phần web Blazor. Tôi đã đi qua template cơ bản để chạy .NET trong trình duyệt sử dụng WASM, xem xét cả mã .NET và JavaScript để hiểu chúng hoạt động cùng nhau như thế nào. Cuối cùng, tôi đã xem xét một số thay đổi đối với việc vân tay ở phía máy khách trong .NET 10 cho phép vân tay chống cache cho các tài sản được xuất bản của bạn.