温故知新,Blazor遇见大写人民币翻译机(ChineseYuanParser),践行WebAssembly SPA的实践之路
阅读原文时间:2022年05月16日阅读:1

在之前《温故知新,.Net Core遇见Blazor(FluentUI),属于未来的SPA框架》中我们已经初步了解了Blazor的相关概念,并且根据官方的指引完成了《创建我的第一个Blazor应用》《生成Blazor待办事项列表应用》《结合ASP.NET Core SignalR和Blazor实现聊天室应用》三个基础应用的实践探索,接下来我们继续探索如果通过Blazor的相关技术来完成一个独立的SPA应用。

大写人民币翻译机(ChineseYuanParser),是一款结合BlazorWebAssembly技术联合打造并且运行在.Net 5.0运行时的数字金额转大写人民币金额的应用,适用于差旅报销时填写报销单需要将阿拉伯数字报销金额翻译成大写人民币金额的场景。

https://github.com/CraigTaylor/ChineseYuanParser

该项目为一个演示项目,旨在实践和练习Blazor技术,其原版来自阿迪的RMBCapitalization-Blazor和技术文章《Blazor WASM 实现人民币大写转换器》

基于Azure静态网站应用服务(Azure Static Web Apps) 免费预览实现的一个临时发布:https://rmbcc.ledesign.org, 随时可能因为订阅原因失效。

如果想了解Azure静态网站应用服务(Azure Static Web Apps),可以查看另外一个文章:尝鲜一试,Azure静态网站应用服务(Azure Static Web Apps) 免费预览,协同Github自动发布静态SPA

接下来,我将一步步拆解实现该应用的细节,这个过程中,我们也可以收获很多关于BlazorWebAssembly.Net 6.0JavascriptBootstrap的知识点。

前置清单

创建名为"ChineseYuanParser"的Blazor WebAssembly应用

dotnet new blazorwasm -o ChineseYuanParser --no-https

或者

dotnet new blazorwasm -o ChineseYuanParser --no-https --pwa

选择好你要存档项目的根目录,然后在这个目录中右键打开Windows Terminal进入,通过DotNet-Cli命令new来创建一个模板类型为blazorwasm、输出名为ChineseYuanParser的应用,中文译为大写人民币翻译机,并且我们标记不需要强制https,还是添加--pwa来创建符合PWA规则的模板。

创建成功后,在终端中,可直接走命令打开Visual Studio Code加载当前项目

cd ChineseYuanParser


code .

接下来,我们找到wwwroot\index.html,修改主页面的Title,为中文的大写人民币翻译机

我们走通过DotNet-Cli命令run看下初始的效果,这里加一个watch参数,可以做到修改后自动热重载。

dotnet watch run

适当剪裁,搭建应用的基本面板

根据模板直接创建的项目会自带一些页面,这对我们要做的实际应用来说,其实是多余的,我们先来一波减法。

1. 删掉Pages目录中除Index以外的页面,我们只需要保留一个Index.razor即可。

2. 删掉Index.razor中除路由路径以外的信息,其他的都需要我们重新来过。

3. 在Index.razor的同级目录新建一个空白的Index.razor.css样式文件,根据命名规则,它会作用于Index页面,这为后续给这个页面增加定制化的样式提供一个基础。

4. 删掉Shared目录中除MainLayout以外的组件,我们只需要保留一个MainLayout.razor即可,这里多说一句,默认模板带的左侧导航就是存在于NavMenu.razor中,而对MainLayout.razor的引用其实是在App.razor中。

5. 删除MainLayout.razor中自带的html内容,添加我们应用需要的,这里因为内置Bootstrap的需要,基于Gird网格原理,需要将网格内容放在一个.container class内,以便获得对齐和内边距支持,所以这里我们在@Body的父级放一个带container classDiv元素。

Bootstrap提供了一套响应式、移动设备优先的流式网格系统,随着屏幕或视口(viewport)尺寸的增加,系统会自动分为最多12列。Bootstrap包含了一个响应式的、移动设备优先的、不固定的网格系统,可以随着设备或视口大小的增加而适当地扩展到12列。它包含了用于简单的布局选项的预定义类,也包含了用于生成更多语义布局的功能强大的混合类。Bootstrap网格系统(GridSystem)的工作原理:行必须放置在.container class内,以便获得适当的对齐(alignment)和内边距(padding)。

@inherits LayoutComponentBase

<div class="container">
    @Body
</div>

6. 删除wwwroot下的sample-data演示数据,这是模板创建自带的,已经完全没用了。

7. 移除wwwroot\css\app.css中的多余自带样式,只保留blazor-error-ui相关的部分,其余的都可以删掉了,同时添加我们需要的样式效果,这里添加的样式主要是为了打造一个灰色背景,应用区域为白色悬浮的效果。

:root {
    --transparent-dark-1: rgba(0,0,0,.108);
    --transparent-dark-2: rgba(0,0,0,.125);
    --transparent-dark-3: rgba(0,0,0,.132);
    --transparent-dark-4: rgba(0,0,0,.175);
    --gray-1: #f2f2f2;
    --gray-2: #eee;
}

body {
    background: #F2F2F2;
}

.box {
    background-color: white;
    padding: 1.8rem;
    box-shadow: 0 1.6px 3.6px 0 var(--transparent-dark-3),0 .3px .9px 0 var(--transparent-dark-1);
    border-radius: 3px;
}

8. 在Pages\Index.razor中,添加我们承载应用内容的主体骨架,它有一个点睛之笔,也就是标题,把我们的应用名字突出来,接着我们把我们基于的技术栈表达出来,这里用到了一个@Environment.Version用来读取当前.Net Core的版本号信息,在尾部,我们打上自己的作者链接和名称。

对于.NETCore 2.x.NET5+Environment.Version属性返回.net运行时版本号。

@page "/"

<div class="main box mt-4">
    <h1 class="text-center">
        大写人民币翻译机
    </h1>
    <div class="text-center">
        <small>Blazor WASM By .Net @Environment.Version</small>
    </div>

    <hr />

    <div>

    </div>
</div>

<div class="mt-3 text-center author">
    <a href="https://www.cnblogs.com/taylorshi" target="_blank">Taylor Shi</a>
</div>

9. 查看基础效果,一个应用的基本底子就出来,这就像女孩子化妆一样,我们先要打个好底子。

按需组合,搭建应用的功能面板

1. 构建翻译机左侧顶部翻译结果和按键功能区面板。

在前面的div块上添加class="row",然后基于我们的视觉规划效果,构建左侧顶部翻译结果和按键功能区。

<!-- 左侧功能区 -->
<div class="col-md-8">

    <!-- 展示及动作 -->
    <section>

        <!-- 转换结果展示 -->
        <div class="cap-result border bg-light mb-2 p-3">
            <h3>
                @ParseResult
            </h3>
        </div>

        <!-- 数字金额输入框 -->
        <div class="row">
            <div class="col-md-8" style="margin-bottom: 10px;">
                <input type="text" class="form-control" placeholder="请输入数字金额" @bind-value="DigitalAmount" @bind-value:event="oninput" />
            </div>
            <div class="col-md-4" style="margin-bottom: 10px;">
                <div class="row">
                    <button class="col-md-3 btn btn-success myButton" @onclick="CopyResult" >复制</button>
                    <button class="col-md-3 btn btn-primary myButton" @onclick="ReadResult" >朗读</button>
                    <button class="col-md-3 btn btn-danger myButton" @onclick="ClearResult" >清除</button>
                </div>
            </div>
        </div>

    </section>
</div>


.myButton {
    margin-left: 10px;
    max-height: 38px;
    min-width: 79px;
    max-width: 147px;
}

在转换结果展示区域,我们直接展示ParseResult结果,还拥有一个输入框,输入框绑定了变量DigitalAmount,这里有个技巧是,因为输入框输入内容我们需要及时的做出翻译结果,所以我们需要一个监听输入的方式,这里采用了@bind-value:event="oninput"来做,同时我们设计了三个按钮在输入框旁边,分别是:复制按钮,绑定事件CopyResult、朗读按钮,绑定事件ReadResult、清除按钮,绑定事件ClearResult

2. 响应左侧顶部实时翻译和翻译结果展示的实现。

先看最简单的解析结果。

/// <summary>
/// 解析结果
/// </summary>
/// <value></value>
public string ParseResult { get; set; }

接下来,看DigitalAmount的实现,这是整个应用功能的关键,因为我们希望在它值变更的时候,马上得出翻译结果,那么这里主要是采用它的set方法来实现。

/// <summary>
/// 数字金额
/// </summary>
private string _digitalAmount;
public string DigitalAmount
{
    get => _digitalAmount;
    set
    {
        _digitalAmount = value;

        // 输入值为空不做处理
        if(!string.IsNullOrEmpty(value) && !string.IsNullOrWhiteSpace(value))
        {
            // 最大值:99999999999.99
            if(!Equals(value, ".") && double.Parse(value) > 99999999999.99)
            {
                return;
            }

            // 小数点后最多两位
            if(value.Contains("."))
            {
                // 如果只有一个点,特殊处理成0.
                if(!Equals(value, "."))
                {
                    // 按.拆分,且只允许存在一个.
                    var spiltValues = value.Split('.');
                    if(spiltValues.Length != 2)
                    {
                        return;
                    }

                    // 小数部分长度不能超过2位
                    var decimalValue = spiltValues.LastOrDefault();
                    if(!string.IsNullOrEmpty(decimalValue) && decimalValue.Length > 2)
                    {
                        return;
                    }
                }
                else
                {
                    value = "0.";
                    _digitalAmount = value;
                }
            }

            // 将01234 格式化成1234
            if(value.StartsWith("0") && !value.Contains("."))
            {
                var intValue = int.Parse(value);
                value = intValue.ToString();
                _digitalAmount = value;
            }

            // 转换
            ParseResult = RMBConverter.RMBToCap(_digitalAmount);
        }
        else
        {
            ParseResult = string.Empty;
        }
    }
}

DigitalAmount的set实现里面,我们先规避了空值和最大值,然后我们处理了小数位最多支持2位小数,接下来我们将首位是0的情况做了处理。

最终,才迎来关键的一个动作,也就是ParseResult = RMBConverter.RMBToCap(_digitalAmount)代码,说白了,就是我们把拿到的金额丢进这个方法中,最终翻译得到我们要的大写人民币值,这也就是整个翻译机核心功能。

关于RMBConverter.RMBToCap的实现,这个不用多说,都是借鉴了网上已经很成熟的代码实现,所以就直接贴代码了。

public class RMBConverter
{
    public static string RMBToCap(string input)
    {
        // Constants:
        var MAXIMUM_NUMBER = 99999999999.99;

        // Predefine the radix characters and currency symbols for output:
        var CN_ZERO = "零";
        var CN_ONE = "壹";
        var CN_TWO = "贰";
        var CN_THREE = "叁";
        var CN_FOUR = "肆";
        var CN_FIVE = "伍";
        var CN_SIX = "陆";
        var CN_SEVEN = "柒";
        var CN_EIGHT = "捌";
        var CN_NINE = "玖";
        var CN_TEN = "拾";
        var CN_HUNDRED = "佰";
        var CN_THOUSAND = "仟";
        var CN_TEN_THOUSAND = "万";
        var CN_HUNDRED_MILLION = "亿";
        var CN_SYMBOL = "人民币";
        var CN_DOLLAR = "元";
        var CN_TEN_CENT = "角";
        var CN_CENT = "分";
        var CN_INTEGER = "整";

        if (double.Parse(input) > MAXIMUM_NUMBER)
        {
            throw new ArgumentOutOfRangeException(nameof(input), "金额必须小于一百亿元");
        }

        string integral;
        string decimalPart;

        var parts = input.Split('.');
        if (parts.Length > 1)
        {
            integral = parts[0];
            decimalPart = parts[1];

            if (decimalPart == string.Empty)
            {
                decimalPart = "00";
            }

            if (decimalPart.Length == 1)
            {
                decimalPart += "0";
            }

            // Cut down redundant decimal digits that are after the second.
            decimalPart = decimalPart.Substring(0, 2);
        }
        else
        {
            integral = parts[0];
            decimalPart = string.Empty;
        }

        // Prepare the characters corresponding to the digits:
        var digits = new[] { CN_ZERO, CN_ONE, CN_TWO, CN_THREE, CN_FOUR, CN_FIVE, CN_SIX, CN_SEVEN, CN_EIGHT, CN_NINE };
        var radices = new[] { "", CN_TEN, CN_HUNDRED, CN_THOUSAND };
        var bigRadices = new[] { "", CN_TEN_THOUSAND, CN_HUNDRED_MILLION };
        var decimals = new[] { CN_TEN_CENT, CN_CENT };

        string outputCharacters = string.Empty;
        if (long.Parse(integral) > 0)
        {
            var zeroCount = 0;
            for (int i = 0; i < integral.Length; i++)
            {
                var p = integral.Length - i - 1;
                var d = integral.Substring(i, 1);
                var quotient = p / 4;
                var modulus = p % 4;
                if (d == "0")
                {
                    zeroCount++;
                }
                else
                {
                    if (zeroCount > 0)
                    {
                        outputCharacters += digits[0];
                    }
                    zeroCount = 0;
                    outputCharacters += digits[int.Parse(d)] + radices[modulus];
                }
                if (modulus == 0 && zeroCount < 4)
                {
                    outputCharacters += bigRadices[quotient];
                    zeroCount = 0;
                }
            }
            outputCharacters += CN_DOLLAR;
        }

        // Process decimal part if there is:
        if (decimalPart != string.Empty)
        {
            for (int i = 0; i < decimalPart.Length; i++)
            {
                var d = decimalPart.Substring(i, 1);
                if (d != "0")
                {
                    outputCharacters += digits[int.Parse(d)] + decimals[i];
                }
            }
        }

        // Confirm and return the final output string:
        if (outputCharacters == string.Empty)
        {
            outputCharacters = CN_ZERO + CN_DOLLAR;
        }
        if (decimalPart == string.Empty)
        {
            outputCharacters += CN_INTEGER;
        }

        return outputCharacters;
    }
}

3. 响应左侧顶部三个功能按键:复制、朗读、清除。

先说最简单的清除吧,这个很简单,清除就是直接清空DigitalAmount的值就行了,因为我们在DigitalAmount的set里面也设计了,如果set为空,那么我们也会把ParseResult设置为空,所以这样就达到了双清的目的。

/// <summary>
/// 清除结果
/// </summary>
private void ClearResult()
{
    DigitalAmount = string.Empty;
}

接下来,复制和朗读功能就比较特殊了,要知道这两个工作,毕竟我们现在是在wasm的里面,也就是应用实际上是跑在浏览器了,所以我们需要借助JS原生的支持来完成。

我们需要在Index.razor的顶部添加@inject IJSRuntime JavaScriptRuntime来引入从Blazor对原生Js的调用。

然后我们将朗读和复制的JS写在首页的Index.html中,这里借助Clipboard.writeText方法来实现对复制功能的实现,借助speechSynthesis.speak方法来实现对指定文本的朗读功能。

<script>
    window.clipboardCopy = {
        copyText: function (text) {
            navigator.clipboard.writeText(text).then(function () {
                console.log(text);
            })
                .catch(function (error) {
                    alert(error);
                });
        }
    };

    window.readAloud = {
        readText: function (text) {
            let utterance = new SpeechSynthesisUtterance(text);
            utterance.lang = 'zh-CN';
            speechSynthesis.speak(utterance);
        }
    }
</script>

完成Index.html中的JS对应函数支持后,我们回到Index.razor来通过调用JS来实现对复制和朗读功能的支持,这里用到JavaScriptRuntime.InvokeVoidAsync的方式来调用JS方法。

/// <summary>
/// 复制结果
/// </summary>
private async Task CopyResult()
{
    if (!string.IsNullOrEmpty(ParseResult))
    {
        await JavaScriptRuntime.InvokeVoidAsync("clipboardCopy.copyText", ParseResult);
    }
}

/// <summary>
/// 朗读结果
/// </summary>
private async Task ReadResult()
{
    if (!string.IsNullOrEmpty(ParseResult))
    {
        await JavaScriptRuntime.InvokeVoidAsync("readAloud.readText", ParseResult);
    }
}

4. 构建翻译机左侧快捷数字输入面板

先贴代码再解读,这里主要是界面处理的逻辑。

<!-- 快捷键 -->
<section>

    <!-- 快捷键1-9 -->
    <div class="row">
        @for (var i = 1; i <= 9; i++)
        {
            var num = i;
            <div class="col-4">
                <button class="btn btn-light border key" @onclick="() => ShortCutInvoked(num.ToString())">@num</button>
            </div>
        }
    </div>

    <!-- 快捷键0和. -->
    <div class="row">
        <div class="col-8">
            <button class="btn btn-light border key" @onclick='() => ShortCutInvoked("0")'>0</button>
        </div>
        <div class="col-4">
            <button class="btn btn-light border key" @onclick='() => ShortCutInvoked(".")'>.</button>
        </div>
    </div>

</section>

针对数字1-9,我们很好处理,构建一个Gird网格结构就行,每行支持3个按钮,这里我们需要用到for的写法,需要注意的就是var num = i;推荐这个写法。

我们在所有数字按键中统一绑定@onclick="() => ShortCutInvoked(num.ToString())",来支撑按键动作,其实逻辑也很简单,就是把新输入的数值,追加到原来的字符串后面就行了。

/// <summary>
/// 快捷键触发事件
/// </summary>
/// <param name="num"></param>
private void ShortCutInvoked(string num)
{
    DigitalAmount += num;
}


.key {
    font-family: "Consolas";
    font-size: 250%;
    width: 100%;
    min-height: 90px;
    margin-bottom: 10px;
}

针对数字0.按键需要特殊处理,因为这里只有两个元素了,所以采用2:1的比例来分配Gird的空间。

5. 构建翻译机右侧翻译参考表面板

在翻译机中,为了更加直观的表达每一个数字会被翻译成什么样的大写人民币,这里我们在整个界面的右侧放一个参考表。

这个看起来不难,首先我们需要准备一个字典ReferList

/// <summary>
/// 参照列表
/// </summary>
/// <value></value>
public Dictionary<string, string> ReferList { get; set; } = new Dictionary<string, string>
{
    {
        "零","0"
    },
    {
        "壹","1"
    },
    {
        "贰","2"
    },
    {
        "叁","3"
    },
    {
        "肆","4"
    },
    {
        "伍","5"
    },
    {
        "陆","6"
    },
    {
        "柒","7"
    },
    {
        "捌","8"
    },
    {
        "玖","9"
    },
    {
        "拾","10"
    },
    {
        "佰","百"
    },
    {
        "仟","千"
    },
    {
        "万","万"
    },
    {
        "亿","亿"
    },
};

然后我们遍历这个字典,来构建我们的参考表,这里我们可以用foreach来遍历ReferList,以便可以取到它的keyvalue

<!-- 右侧参照表 -->
<div class="col-md-4">

    <div class="card">
        <div class="card-header">
            参照表
        </div>

        <div class="card-body">

            <!-- 快捷键1-9 -->
            <div class="row">
                @foreach (var item in ReferList)
                {
                    <div class="col-4">
                        <button class="btn btn-light border refer">
                            <div class="text-bottom">
                                @item.Key<small>(@item.Value)</small>
                            </div>
                        </button>
                    </div>
                }
            </div>
        </div>
    </div>
</div>


.refer {
    font-family: "Consolas";
    font-size: 150%;
    width: 100%;
    min-height: 80px;
    min-width: 60px;
    margin-bottom: 10px;
}

其实到这里呢,我们的主要是任务已经完成了,完成了整个左侧和右侧功能区的实现,啦啦啦。

看下效果吧。

定制启动页

Blazor的启动页默认是个很简单的Loading字样,这要是应用写大了或者用户的网络慢一点,就真的挺难看的,好在我们其实可以也很容易在wwwroot\index.html找到它,并且对它完成自己的定制,当然效果这东西取决于你的审美和前端技术功底了。

<body>
    <div id="app">Loading...</div>

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss"></a>
    </div>
    <script src="_framework/blazor.webassembly.js"></script>
</body>

如果你需要定制,你只需要替换掉divLoading...这个部分就行了,我从github搜了下,找到了一个凑着用的效果。

https://github.com/BlazorPlus/BlazorDemoWasmLoading

替换后的效果是:

替换后的代码如下:

<body>
    <div id="app">
        <div
            style="position:fixed;left:0;top:0;right:0;bottom:0;display:flex;flex-direction:column;align-items:center;justify-content:center;">

            <svg version='1.1' xmlns='http://www.w3.org/2000/svg' x='0px' y='0px' width='80px' height='80px'
                viewBox='0 0 40 40' enable-background='new 0 0 40 40' xml:space='preserve'>
                <path opacity='0.2' fill='#000'
                    d='M20.201,5.169c-8.254,0-14.946,6.692-14.946,14.946c0,8.255,6.692,14.946,14.946,14.946 s14.946-6.691,14.946-14.946C35.146,11.861,28.455,5.169,20.201,5.169z M20.201,31.749c-6.425,0-11.634-5.208-11.634-11.634 c0-6.425,5.209-11.634,11.634-11.634c6.425,0,11.633,5.209,11.633,11.634C31.834,26.541,26.626,31.749,20.201,31.749z'>
                </path>
                <path fill='#000'
                    d='M26.013,10.047l1.654-2.866c-2.198-1.272-4.743-2.012-7.466-2.012h0v3.312h0 C22.32,8.481,24.301,9.057,26.013,10.047z'
                    transform='rotate(228 20 20)'>
                    <animateTransform attributeType='xml' attributeName='transform' type='rotate' from='0 20 20'
                        to='360 20 20' dur='0.5s' repeatCount='indefinite'></animateTransform>
                </path>
            </svg>

            <div style="height:30px">
                Loading..
            </div>
            <div id="progressbar"
                style="display: inline-block; width: 260px; height: 12px; border: solid 1px gray; border-radius:6px; position: relative;">
            </div>
        </div>
    </div>

    <script type="text/javascript">
        new function () {
            var preLoadTime = 0;
            var preLoadCount = 0;
            var preLoadError = 0;
            var preLoadFinish = 0;
            var preLoadPercent = 0;
            var preLoadStart = 0;
            var preLoadTotal = 0;
            var preLoadLoaded = 0;
            var preLoadCLength = 0;
            var preLoadSampleLoaded = 0;
            var preLoadSampleCLength = 0;
            function preLoadUpdateUI() {
                var progressbar = document.getElementById("progressbar");
                if (progressbar) {
                    var p = preLoadFinish / preLoadCount;
                    if (preLoadTotal) {
                        p = preLoadLoaded / preLoadTotal;
                    }
                    else if (preLoadSampleLoaded) {
                        var ratio = preLoadSampleCLength / preLoadSampleLoaded;
                        var p2 = Math.min(1, (preLoadLoaded * ratio / preLoadCLength) * (preLoadStart / preLoadCount));
                        p = (p + p2) / 2;
                    }
                    preLoadPercent = Math.max(preLoadPercent, p);
                    progressbar.innerHTML = "<span style='position:absolute;left:0;background-color:darkgreen;height:10px;border-radius:5px;width:" + (progressbar.offsetWidth * preLoadPercent) + "px'></span>";
                }
            }
            function preLoadResource(dllname) {
                preLoadCount++;
                var xh = new XMLHttpRequest();
                xh.open("GET", dllname, true);
                var loaded = 0;
                var total = 0;
                var clength = 0;
                xh.onprogress = function (e) {
                    if (!e.loaded) return;
                    if (loaded == 0) {
                        preLoadStart++;
                        clength = parseInt(xh.getResponseHeader("Content-Length"));
                        total = e.total;
                        preLoadCLength += clength;
                        preLoadTotal += total;
                    }
                    preLoadLoaded += e.loaded - loaded;
                    loaded = e.loaded;
                    preLoadUpdateUI();
                }
                xh.onload = function () {
                    if (loaded && clength) {
                        preLoadSampleLoaded += loaded;
                        preLoadSampleCLength += clength;
                    }
                    preLoadFinish++;
                    if (xh.status != 200) preLoadError++;
                    //console.log(preLoadFinish + "/" + preLoadCount, clength / loaded, dllname);
                    if (preLoadFinish == preLoadCount) {
                        var span = new Date().getTime() - preLoadTime;
                        console.log("All Done In " + span + " ms , " + preLoadError + " errors");
                    }
                }
                xh.send("");
            }
            function preLoadAll() {
                preLoadTime = new Date().getTime();
                var xh = new XMLHttpRequest();
                xh.open("GET", "_framework/blazor.boot.json", true);
                xh.onload = function () {
                    var res = JSON.parse(xh.responseText);
                    console.log(res);
                    var arr = [];
                    function moveFront(part) {
                        for (var i = 0; i < arr.length; i++) {
                            if (arr[i].indexOf(part) != -1) {
                                arr.unshift(arr.splice(i, 1)[0]);
                                break;
                            }
                        }
                    }

                    arr.push("_framework/blazor.webassembly.js");
                    for (var p in res.resources.runtime)
                        arr.push("_framework/wasm/" + p);
                    for (var p in res.resources.assembly)
                        arr.push("_framework/_bin/" + p);

                    moveFront("System.Core.dll");
                    moveFront("System.Data.dll");
                    moveFront("System.dll");
                    moveFront("System.Xml.dll");
                    moveFront("mscorlib");
                    moveFront("dotnet.wasm");
                    arr.forEach(preLoadResource);
                    //console.log(arr);
                }
                xh.send("");
            }
            preLoadAll();
        }
    </script>

    <div id="blazor-error-ui">
        An unhandled error has occurred.
        <a href="" class="reload">Reload</a>
        <a class="dismiss"></a>
    </div>
    <script src="_framework/blazor.webassembly.js"></script>
</body>

添加项目对PWA的支持

渐进式Web应用(PWA)通常是一种单页应用程序(SPA),它使用新式浏览器API和功能以表现得如桌面应用。Blazor Web Assembly是基于标准的客户端Web应用平台,因此它可以使用任何浏览器API,包括以下功能所需的PWA API:

  • 脱机工作并即时加载(不受网络速度影响)。
  • 在自己的应用窗口中运行,而不仅仅是在浏览器窗口中运行。
  • 从主机操作系统的开始菜单、扩展坞或主屏幕启动。
  • 从后端服务器接收推送通知,即使用户没有在使用该应用。
  • 在后台自动更新。

使用“渐进式”一词来描述此类应用的原因如下:

  • 用户可能先是在其网络浏览器中发现应用并使用它,就像任何其他单页应用程序一样。
  • 过了一段时间后,用户进而将其安装到操作系统中并启用推送通知。

其实我们也可以通过命令行添加--pwa参数来创建PWA模板的应用。

dotnet new blazorwasm -o MyBlazorPwa --pwa

但是我们现在要做的是把当前项目转成PWA项目。

1. 在ChineseYuanParser.csproj文件中,做如下修改。

将以下ServiceWorkerAssetsManifest属性添加到PropertyGroup中。

<PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <ServiceWorkerAssetsManifest>service-worker-assets.js</ServiceWorkerAssetsManifest>
</PropertyGroup>

将以下ServiceWorker项添加到ItemGroup中。

<ItemGroup>
    <ServiceWorker Include="wwwroot\service-worker.js" PublishedContent="wwwroot\service-worker.published.js" />
</ItemGroup>

这里提到两个js文件,我们可以从Blazor WebAssembly 项目模板wwwroot文件夹(dotnet/aspnetcore GitHub 存储库 main 分支)来获取。

service-worker.published.js

// Caution! Be sure you understand the caveats before publishing an application with
// offline support. See https://aka.ms/blazor-offline-considerations

self.importScripts('./service-worker-assets.js');
self.addEventListener('install', event => event.waitUntil(onInstall(event)));
self.addEventListener('activate', event => event.waitUntil(onActivate(event)));
self.addEventListener('fetch', event => event.respondWith(onFetch(event)));

const cacheNamePrefix = 'offline-cache-';
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ];
const offlineAssetsExclude = [ /^service-worker\.js$/ ];

async function onInstall(event) {
    console.info('Service worker: Install');

    // Fetch and cache all matching items from the assets manifest
    const assetsRequests = self.assetsManifest.assets
        .filter(asset => offlineAssetsInclude.some(pattern => pattern.test(asset.url)))
        .filter(asset => !offlineAssetsExclude.some(pattern => pattern.test(asset.url)))
        .map(asset => new Request(asset.url, { integrity: asset.hash, cache: 'no-cache' }));
//#if(IndividualLocalAuth && Hosted)

    // Also cache authentication configuration
    assetsRequests.push(new Request('_configuration/ComponentsWebAssembly-CSharp.Client'));

//#endif
    await caches.open(cacheName).then(cache => cache.addAll(assetsRequests));
}

async function onActivate(event) {
    console.info('Service worker: Activate');

    // Delete unused caches
    const cacheKeys = await caches.keys();
    await Promise.all(cacheKeys
        .filter(key => key.startsWith(cacheNamePrefix) && key !== cacheName)
        .map(key => caches.delete(key)));
}

async function onFetch(event) {
    let cachedResponse = null;
    if (event.request.method === 'GET') {
        // For all navigation requests, try to serve index.html from cache
        // If you need some URLs to be server-rendered, edit the following check to exclude those URLs
//#if(IndividualLocalAuth && Hosted)
        const shouldServeIndexHtml = event.request.mode === 'navigate'
            && !event.request.url.includes('/connect/')
            && !event.request.url.includes('/Identity/');
//#else
        const shouldServeIndexHtml = event.request.mode === 'navigate';
//#endif

        const request = shouldServeIndexHtml ? 'index.html' : event.request;
        const cache = await caches.open(cacheName);
        cachedResponse = await cache.match(request);
    }

    return cachedResponse || fetch(event.request);
}

service-worker.js

// In development, always fetch from the network and do not enable offline support.
// This is because caching would make development more difficult (changes would not
// be reflected on the first load after each change).
self.addEventListener('fetch', () => { });

manifest.json

{
  "name": "大写人民币翻译机",
  "short_name": "大写人民币翻译机",
  "start_url": "./",
  "display": "standalone",
  "background_color": "#ffffff",
  "theme_color": "#03173d",
  "prefer_related_applications": false,
  "icons": [
    {
      "src": "icon-512.png",
      "type": "image/png",
      "sizes": "512x512"
    },
    {
      "src": "icon-192.png",
      "type": "image/png",
      "sizes": "192x192"
    }
  ]
}

我们还需要准备两个图标文件,分别是icon-512.pngicon-192.png

在应用的wwwroot/index.html文件中,为清单和应用图标添加<link>元素。

<head>
    <link href="manifest.json" rel="manifest" />
    <link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
</head>

在应用的wwwroot/index.html文件中,将以下<script>标记添加到紧跟在blazor.webassembly.js脚本标记后面的</body>结束标记中。

<script src="_framework/blazor.webassembly.js"></script>
<script>navigator.serviceWorker.register('service-worker.js');</script>

2. 安装和体验PWA应用

添加了PWA的支持,我们重新运行一次,记得Ctrl+F5刷新一次比较好。

在Edge中,我们可以在地址栏的尾部看到安装PWA应用的按钮。

安装后,我们可以在Windows10开始菜单中找到这个应用了。

并且可以右键添加到开始菜单磁贴区域。

在iOS上,访问者可以通过Safari的“共享”按钮和“添加到主屏幕”选项安装PWA 。在适用于Android的Chrome上,用户应该选择右上角的“菜单”按钮,然后选择“添加到主屏幕”。

若要自定义窗口的标题、配色方案、图标或其他详细信息,可以修改wwwroot目录中的manifest.json文件。

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