如何優雅的移植JavaScript組件到Blazor

Blazor作為一個新興的互動式 Web UI 的框架,有其自身的優缺點,如果現有的 JavaScript 組件能移植到 Blazor,無疑讓 Blazor 如虎添翼,本文就介紹一下自己在開發 BulmaRazor 組件庫的時,封裝現有的 JavaScript 組件的方法,文中以 TuiEditor 為例。

開始

首先找到現有 TuiEditor 的主頁或者文檔,這一步很簡單,我們找到官網 //ui.toast.com/tui-editor/ ,分析一下組件的使用方法,一般都是有樣式文件,有 JavaScript 文件,有一個 options 對象來初始化一個主對象,主對象上有方法和事件,大概就是這些了,我們先下載所需的文件,然後一步一步處理。

樣式部分

該組件需要兩個樣式 codemirror.min.css 和 toastui-editor.min.css ,由於一個組件庫不只這一個組件,為了引用方便,我們需要使用 BuildBundlerMinifier 合併文件,不知道 BuildBundlerMinifier 的同學網上查一下。

在網站的根目錄需要有 BuildBundlerMinifier 所需的配置文件 bundleconfig.json,對應的配置如下 :

  {
    "outputFileName": "wwwroot/bulmarazor.min.css",
    "inputFiles": [
      "wwwroot/css/tuieditor/codemirror.min.css",
      "wwwroot/css/tuieditor/toastui-editor.min.css"
    ]
  },

項目中很可能還有其他的樣式文件,一起合併就好了,引用的時候我們只需要一個樣式文件,這裡就是 bulmarazor.min.css。

腳本部分

tuieditor 的 JavaScript 文件只有一個,當然一般 JavaScript 組件的腳本文件都是一個,如果是普通的 web 開發的話直接引入就可以了,但是在 Blazor 中有些麻煩,需要使用 JavaScript 互操作,互操作是指 C# 程式碼可調用到 JavaScript 程式碼,而 JavaScript 程式碼也可調用到 C# 程式碼。

C# 調用 JavaScript 程式碼有兩種方法,一種是使用 IJSRuntime 調用掛載到 window 對象上的方法,另一種是使用模組隔離的方式調用,這裡我們需要模組隔離,因為有以下優點:

  • 導入的 JavaScript 不再污染全局命名空間。
  • 庫和組件的使用者不需要引用相關的 JavaScript。

關於 JavaScript 模組,可以參考這裡 這裡 ,使用 JavaScript 模組依賴於 import 和 export,而一般的 JavaScript 類庫並不支援,所以我們需要些一些導出的程式碼,文件結構如下:

我們忽視紅色標註,先來看一下 toastui-editor-export.js 這個文件:

export function initEditor(options) {
    options.el = document.getElementById(options.elid);
    let editor = new toastui.Editor.factory(options);
    return editor;
}

toastui-editor-all.min.JavaScript 這個文件就是 JavaScript 組件文件,我們不用去改它,也不應該去改它,因為後續升級了我們可以直接覆蓋的,toastui-editor-export.js 就是我們專門寫的一個導出類庫中所需功能的導出文件。為了引用方便我們還是需要合併一下,就是圖片示現的那樣,合併配置如下:

  {
    "outputFileName": "wwwroot/js/tuieditor.min.js",
    "inputFiles": [
      "wwwroot/jsplugin/tuieditor/toastui-editor-all.min.js",
      "wwwroot/jsplugin/tuieditor/toastui-editor-export.js"
    ]
  }

現在我們使用隔離的方式引用 wwwroot/js/tuieditor.min.js 就可以了。當我們新建一個Razor組件項目的時候,會帶有調用的例子,我們比貓畫虎搞定:

using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;

namespace BulmaRazor.Components
{
    public class BulmaRazorJsInterop : IAsyncDisposable
    {
        private readonly Lazy<Task<IJSObjectReference>> tuiEditorModuleTask;

        public BulmaRazorJsInterop(IJSRuntime jsRuntime)
        {
            tuiEditorModuleTask = new(() => jsRuntime.InvokeAsync<IJSObjectReference>(
                "import", "./_content/BulmaRazor/js/tuieditor.min.js").AsTask());
        }

        public async ValueTask<IJSObjectReference> TuiEditorInit(TuiEditorOptions options)
        {
            var module = await tuiEditorModuleTask.Value;
            return await module.InvokeAsync<IJSObjectReference>("initEditor", options.ToParams());
        }

        public async ValueTask DisposeAsync()
        {
            if (tuiEditorModuleTask.IsValueCreated)
            {
                var module = await tuiEditorModuleTask.Value;
                await module.DisposeAsync();
            }
        }
    }
}

Blazor 組件部分

組件文件是 TuiEditor.razor,UI程式碼是非常簡單的,就是一個帶有 id 屬性的 div 容器,id 很重要,是我們互操作的基礎,這裡我們使用GUID生成唯一的id。
我們需要在 blazor 組件呈現之後調用 JavaScript 程式碼來初始化我們的 JavaScript 組件,調用 JavaScript 程式碼之後返回了js 對象的引用editor,注意editor和上述 var module = await tuiEditorModuleTask.Value; 中的 module 是一樣的,都是 JavaScript 對象引用。大致的程式碼如下:

@inject BulmaRazorJsInterop JsInterop

<div id="@Id"></div>

@code {

    readonly string Id = "tuiEditor_" + Guid.NewGuid().ToString("N");
    IJSObjectReference editor;

    [Parameter]
    public TuiEditorOptions Options { get; set; }

    protected override void OnInitialized()
    {
        if (Options == null)
            Options = new TuiEditorOptions();
        Options.elid = Id;

        base.OnInitialized();
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        await base.OnAfterRenderAsync(firstRender);
        editor = await JsInterop.TuiEditorInit(Options);
    }
}

Options選項部分

TuiEditor 組件中有個參數 TuiEditorOptions ,是要對應 JavaScript 中的 options 參數的,我們需要自己定義一個,這裡我們使用兩個類來使用,一個是針對 JavaScript 的 JsParams 類似字典的對象,一個是針對使用者的 TuiEditorOptions 。
JsParams 就是一個Dictionary<string,object>,為了方便,我們過濾了空值:

    internal class JsParams:Dictionary<string,object>
    {
        public void AddNotNull(string key, object value)
        {
            if (value != null)
            {
                base.Add(key,value);
            }
        }
    }

TuiEditorOptions 類除了參數之外,包含一個 ToParams() 的方法把自己轉換成 JsParams:


public class TuiEditorOptions
{
    internal string elid { get; set; }

    /// <summary>
    /// Editor's height style value. Height is applied as border-box ex) '300px', '100%', 'auto'
    /// </summary>
    public string Height { get; set; }
    
    /// <summary>
    /// 是否是查看器
    /// </summary>
    public bool? Viewer { get; set; }
    
    //...其他參數
    internal JsParams ToParams()
    {
        JsParams ps = new JsParams();
        var def = BulmaRazorOptions.DefaultOptions.TuiEditorOptions;
        ps.AddNotNull("elid", elid);
        ps.AddNotNull("viewer",Viewer);
        ps.AddNotNull("height", Height ?? def.Height);
        //...其他參數
        return ps;
    }
}

有幾個原因使用 JsParams :

  • null值可以不傳遞,因為js的options一般都用默認值,減少傳輸;
  • 可以使用默認設置,如上有個BulmaRazorOptions.DefaultOptions.TuiEditorOptions;
  • 可以靈活的手動處理參數,上面例子沒有提現出來,不過組件寫多了肯定會遇到這種情況;

對象的方法

JavaScript 組件一般也會公開許多實例方法,比如獲得焦點,設置內容,獲取內容等等,在在前面我們一直保存了 JavaScript 組件實例的引用,也就是在 TuiEditor 中的 editor 對象,向公開哪些方法在 TuiEditor.razor 中添加就是了:

    public void Focus()
    {
        editor?.InvokeVoidAsync("focus");
    }

    public ValueTask<string> GetMarkdown()
    {
        return editor?.InvokeAsync<string>("getMarkdown") ?? new ValueTask<string>("");
    }

    public void InsertText(string text)
    {
        editor?.InvokeVoidAsync("insertText", text);
    }

    public ValueTask<bool> IsViewer()
    {
        return editor?.InvokeAsync<bool>("isViewer") ?? new ValueTask<bool>(false);
    }
    //...其他需要的方法

對象事件

JavaScript 組件對象有自己的事件,在 JavaScript 中直接設置 JavaScript 函數就可以了,但是並不能把 C# 方法或者委託傳遞給 js,這裡就需要用到 JavaScript 調用C#方法了。
Blazor 框架中 JavaScript 只能調用靜態方法,而我們實際中是基於對象來寫邏輯的,所有我專門寫了一個類來處理js的調用,JSCallbackManager:

    public static class JSCallbackManager
    {
        private static ConcurrentDictionary<string, Dictionary<string, Delegate>> eventHandlerDict = new();

        public static void AddEventHandler(string objId, string eventKey, Delegate @delegate)
        {
            var eventHandlerList = eventHandlerDict.GetOrAdd(objId, (key) => new Dictionary<string, Delegate>());
            eventHandlerList[eventKey]= @delegate;
        }
 
        public static void DisposeObject(string objId)
        {
            if (eventHandlerDict.Remove(objId, out Dictionary<string, Delegate> handlers))
            {
                handlers.Clear();
            }
        }

        [JSInvokable]
        public static object JSCallback(string objId, string eventKey)
        {
            if (eventHandlerDict.TryGetValue(objId, out Dictionary<string, Delegate> handlers))
            {
                if (handlers.TryGetValue(eventKey, out Delegate d))
                {
                    var obj = d.DynamicInvoke();
                    return obj;
                }
            }
            return null;
        }
    }

我們使用一個嵌套的字典來保存了Blazor組件的回調委託,每一個組件對象都有一個唯一的Id,每一個組件類型都可以有不同名稱的 JavaScript 事件回調。
比如我們想訂閱 JavaScript 組件實例的 load 事件,我們需要改兩個地方,第一個是 toastui-editor-export.js 導出文件:

export function initEditor(options) {
    options.el = document.getElementById(options.elid);
    options.events = {
        load: function () {
            DotNet.invokeMethodAsync("BulmaRazor", "JSCallback", options.elid, "load");
        }
    }
    let editor = new toastui.Editor.factory(options);
    return editor;
}

JavaScript 的事件還是需要用 js來做,然後在js方法內部調用 C# 方法。第二個是需要在 TuiEditor 中添加回調委託:

    [Parameter]
    public EventCallback<TuiEditor> OnLoad { get; set; }

    protected override void OnInitialized()
    {
        if (Options == null)
            Options = new TuiEditorOptions();
        Options.elid = Id;
        //這裡添加回調委託,並把js事件公開成了Blazor組件事件
        JSCallbackManager.AddEventHandler(Id, "load", new Func<Task>(() => OnLoad.InvokeAsync(this)));
        
        base.OnInitialized();
    }

    protected override ValueTask DisposeAsync(bool disposing)
    {
        //移除對象的所有回調委託
        JSCallbackManager.DisposeObject(Id);
        return base.DisposeAsync(disposing);
    }

這樣我們就把 JavaScript 組件事件移植到了 Blazor 組件。

修整

經過上述不知,組件基本移植完了,但還不能很好的使用,第一,因為介面是 js在操作,所以我們應該禁用 Blazor組件的渲染:

    protected override bool ShouldRender()
    {
        return false;
    }

在js的options中有個initialValue屬性,是初始化內容的,我們改成Blazor的形式,最好是可以綁定:

    [Parameter]
    public EventCallback<TuiEditor> OnBlur { get; set; }

    protected override void OnInitialized()
    {
        if (Options == null)
            Options = new TuiEditorOptions();
        Options.InitialValue = _value;
        Options.elid = Id;
        //這裡也是通過js事件觸發
        JSCallbackManager.AddEventHandler(Id, "blur", new Func<Task>(async () =>
        {
            await setValue();
            await OnBlur.InvokeAsync(this);
        }));
        base.OnInitialized();
    }

    private string _value;

    [Parameter]
    public string Value
    {
        get { return _value; }
        set
        {
            _value = value;
            SetMarkdown(value, true);
        }
    }

    [Parameter]
    public EventCallback<string> ValueChanged { get; set; }

    private async Task setValue()
    {
        _value = await GetMarkdown();
        await ValueChanged.InvokeAsync(_value);
    }

    public void SetMarkdown(string markdown, bool cursorToEnd = true)
    {
        editor?.InvokeVoidAsync("setMarkdown", markdown, cursorToEnd);
    }

這樣我們就可以使用 Blazor 綁定語法了:

<TuiEditor @bind-Value="markdown"></TuiEditor>
@code{
    private string markdown = "# Init Title";
}

效果如下:

在線效果點擊這裡

源程式碼

希望喜歡 Blazor 和 BulmaRazor 的朋友給個Star鼓勵一下!該項目從2021年的春節假期開始,一個人做真心的累和耗時,您的鼓勵是我堅持下午的最大動力!