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

拉克斯文發表於2021-03-10

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

開始

首先找到現有 TuiEditor 的主頁或者文件,這一步很簡單,我們找到官網 https://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年的春節假期開始,一個人做真心的累和耗時,您的鼓勵是我堅持下去的最大動力!

相關文章