LoginSignup
6
7

More than 3 years have passed since last update.

GAになったBlazor WebAssemblyでReactで書かれたTodoMVCのアプリを移植してみた

Last updated at Posted at 2020-06-07

はじめに

普段 C# を書いてるわけではないのですが、近年の Microsoft のオープンソースへの取り組みや、発表するプロダクトに面白さを感じていて、そろそろ食わず嫌いは辞めて、Microsoft な技術スタックも積極的に触っていこうと思っている今日この頃です。

そんな中、キワモノ感があるけど面白そうだなと思っていた Blazor WebAssembly が GA になったというニュースを見かけたので、遅ればせながらどんな感じでアプリを書けるのか触ったみることにしました。

作ってみたもの

特に作りたいものがあるわけではなかったので、題材は http://todomvc.com/ の ToDo アプリにしました。

今回はクライアントサイドのみで完結するようにしたので、パブリッシュしたコンテンツを GitHub Pages に SPA としてホスティングしています。

アプリの内容

Blazor は React のようなコンポーネント指向なフレームワークなので、おそらく React と似たように書けるんだろうと思って、下記のソースコードを参考に Blazor に移植してみました。

React 版の書き方は古い感がありますが、Blazor はクラスコンポーネントのコードの方が移植しやすそう。

細かい部分は端折ったりしていて、完全に挙動が一致するようには作れていないのですが、概ね似たような雰囲気で書けることはわかりました。

起動すると dll がたくさん落ちてきて WebAssembly で TODO アプリが動きます。

ソース構成

細かいところは抜きにして、Blazor には(Razor にはと書くのが正しいかもしれませんが、以後全部 Blazor と書きます)ページという概念あるので、ルーティングのための記述は不要でした。
Pages ディレクトリ以下に各ページとページで使うコンポーネントを置きました。

  • Pages (https://github.com/taiju/BlazorTodoMVC/tree/master/Pages)
    • Active.razor
      • アクティブな TODO 一覧を表示するページ
    • Completed.razor
      • 完了した TODO 一覧を表示するページ
    • Index.razor
      • すべての TODO 一覧を表示するページ
    • TodoFooter.razor
      • フッターコンポーネント
    • TodoItem.razor
      • TODO コンポーネント
    • TodoPage.razor
      • TODO 一覧コンポーネント

ページは Active, Completed, Index の3ページあって、それぞれの中身は @page ディレクティブと TodoPage コンポーネントを記述しているだけです。

Filter パラメータに TODO 一覧の抽出条件を渡して、NowShowing パラメータ表示しているページ名を渡すようにしています。

また、各ページで使用するモデルを Models にディレクトリ以下に置きました。

主に上記が今回書いたコードの置き場所で、他は Visual Studio なり dotnet コマンドなりが吐き出してくれた雛形をちょっと書き換えたくらいです。

ページのサンプルコード

一例として未完了(アクティブ)な TODO だけを表示するページのコードが下記です。
(Razor 記法用のシンタックスハイライトがない...)

@page "/active"

<TodoPage Filter="@(todo => !todo.Completed)" NowShowing="active" />

コンポーネントのサンプルコード

TodoPage.razor は https://github.com/tastejs/todomvc/blob/gh-pages/examples/react/js/app.jsx の app.jsx に相当するファイルで、このファイルを React と Blazor で見比べたら雰囲気わかるかなと思います。

React のソース

/*jshint quotmark:false */
/*jshint white:false */
/*jshint trailing:false */
/*jshint newcap:false */
/*global React, Router*/
var app = app || {};

(function () {
    'use strict';

    app.ALL_TODOS = 'all';
    app.ACTIVE_TODOS = 'active';
    app.COMPLETED_TODOS = 'completed';
    var TodoFooter = app.TodoFooter;
    var TodoItem = app.TodoItem;

    var ENTER_KEY = 13;

    var TodoApp = React.createClass({
        getInitialState: function () {
            return {
                nowShowing: app.ALL_TODOS,
                editing: null,
                newTodo: ''
            };
        },

        componentDidMount: function () {
            var setState = this.setState;
            var router = Router({
                '/': setState.bind(this, {nowShowing: app.ALL_TODOS}),
                '/active': setState.bind(this, {nowShowing: app.ACTIVE_TODOS}),
                '/completed': setState.bind(this, {nowShowing: app.COMPLETED_TODOS})
            });
            router.init('/');
        },

        handleChange: function (event) {
            this.setState({newTodo: event.target.value});
        },

        handleNewTodoKeyDown: function (event) {
            if (event.keyCode !== ENTER_KEY) {
                return;
            }

            event.preventDefault();

            var val = this.state.newTodo.trim();

            if (val) {
                this.props.model.addTodo(val);
                this.setState({newTodo: ''});
            }
        },

        toggleAll: function (event) {
            var checked = event.target.checked;
            this.props.model.toggleAll(checked);
        },

        toggle: function (todoToToggle) {
            this.props.model.toggle(todoToToggle);
        },

        destroy: function (todo) {
            this.props.model.destroy(todo);
        },

        edit: function (todo) {
            this.setState({editing: todo.id});
        },

        save: function (todoToSave, text) {
            this.props.model.save(todoToSave, text);
            this.setState({editing: null});
        },

        cancel: function () {
            this.setState({editing: null});
        },

        clearCompleted: function () {
            this.props.model.clearCompleted();
        },

        render: function () {
            var footer;
            var main;
            var todos = this.props.model.todos;

            var shownTodos = todos.filter(function (todo) {
                switch (this.state.nowShowing) {
                case app.ACTIVE_TODOS:
                    return !todo.completed;
                case app.COMPLETED_TODOS:
                    return todo.completed;
                default:
                    return true;
                }
            }, this);

            var todoItems = shownTodos.map(function (todo) {
                return (
                    <TodoItem
                        key={todo.id}
                        todo={todo}
                        onToggle={this.toggle.bind(this, todo)}
                        onDestroy={this.destroy.bind(this, todo)}
                        onEdit={this.edit.bind(this, todo)}
                        editing={this.state.editing === todo.id}
                        onSave={this.save.bind(this, todo)}
                        onCancel={this.cancel}
                    />
                );
            }, this);

            var activeTodoCount = todos.reduce(function (accum, todo) {
                return todo.completed ? accum : accum + 1;
            }, 0);

            var completedCount = todos.length - activeTodoCount;

            if (activeTodoCount || completedCount) {
                footer =
                    <TodoFooter
                        count={activeTodoCount}
                        completedCount={completedCount}
                        nowShowing={this.state.nowShowing}
                        onClearCompleted={this.clearCompleted}
                    />;
            }

            if (todos.length) {
                main = (
                    <section className="main">
                        <input
                            id="toggle-all"
                            className="toggle-all"
                            type="checkbox"
                            onChange={this.toggleAll}
                            checked={activeTodoCount === 0}
                        />
                        <label
                            htmlFor="toggle-all"
                        />
                        <ul className="todo-list">
                            {todoItems}
                        </ul>
                    </section>
                );
            }

            return (
                <div>
                    <header className="header">
                        <h1>todos</h1>
                        <input
                            className="new-todo"
                            placeholder="What needs to be done?"
                            value={this.state.newTodo}
                            onKeyDown={this.handleNewTodoKeyDown}
                            onChange={this.handleChange}
                            autoFocus={true}
                        />
                    </header>
                    {main}
                    {footer}
                </div>
            );
        }
    });

    var model = new app.TodoModel('react-todos');

    function render() {
        React.render(
            <TodoApp model={model}/>,
            document.getElementsByClassName('todoapp')[0]
        );
    }

    model.subscribe(render);
    render();
})();

Blazor のソース

Razor 構文のシンタックスハイライトがないので、とりあえず C# のシンタックスハイライトを当てています。

@inject Blazored.LocalStorage.ILocalStorageService localStorage

<header class="header">
    <h1>todos</h1>
    <input class="new-todo"
           placeholder="What needs to be done?"
           @bind="NewTodo"
           @bind:event="oninput"
           @onkeydown="HandleNewTodoKeyDown"
           autofocus />
</header>
@if (AllCount > 0)
{
    <section class="main">
        <input id="toggle-all"
               class="toggle-all"
               type="checkbox"
               @onchange="ToggleAll"
               checked="@ToggleAllChecked" />
        <label for="toggle-all" />
        <ul class="todo-list">
            @foreach (var todo in TodoModel.Todos.Where(Filter))
            {
                <TodoItem Todo="@todo"
                          OnToggle="@(() => Toggle(todo))"
                          OnDestroy="@(() => Destroy(todo))"
                          OnEdit="@(() => Edit(todo))"
                          Editing="@(Editing?.Equals(todo.Id) ?? false)"
                          OnSave="@(text => Save(todo, text))"
                          OnCancel="@Cancel" />
            }
        </ul>
    </section>
    <TodoFooter Count="@ActiveToDoCount"
                CompletedCount="@CompletedCount"
                NowShowing="@NowShowing"
                OnClearCompleted="@ClearCompleted" />
}

@code {
    [Parameter]
    public Func<Todo, bool> Filter { get; set; }

    [Parameter]
    public string NowShowing { get; set; }

    private const string ENTER_KEY = "Enter";

    public TodoModel TodoModel { get; set; }

    public Guid? Editing { get; set; }

    public string NewTodo { get; set; }

    public int AllCount => TodoModel.Todos?.Count() ?? 0;

    public int CompletedCount => TodoModel.Todos?.Count(todo => todo.Completed) ?? 0;

    public int ActiveToDoCount => TodoModel.Todos?.Count(todo => !todo.Completed) ?? 0;

    public bool ToggleAllChecked => ActiveToDoCount == 0;

    protected override async Task OnInitializedAsync()
    {
        TodoModel = new TodoModel(localStorage);
        await TodoModel.Fetch();
        TodoModel.Subscribe(StateHasChanged);
    }

    public async Task HandleNewTodoKeyDown(KeyboardEventArgs e)
    {
        if (!e.Key.Equals(ENTER_KEY))
        {
            return;
        }
        var val = NewTodo.Trim();
        if (val != string.Empty)
        {
            await TodoModel.AddTodo(val);
            NewTodo = "";
        }
    }

    public async Task ToggleAll(ChangeEventArgs e)
    {
        await TodoModel.ToggleAll((bool)e.Value);
    }

    public async Task ClearCompleted()
    {
        await TodoModel.ClearCompleted();
    }

    public async Task Toggle(Todo todo)
    {
        await TodoModel.Toggle(todo);
    }

    public async Task Destroy(Todo todo)
    {
        await TodoModel.Destroy(todo);
    }

    public void Edit(Todo todo)
    {
        Editing = todo.Id;
        StateHasChanged();
    }

    public async Task Save(Todo todoToSave, string text)
    {
        Editing = null;
        await TodoModel.Save(todoToSave, text);
    }

    public void Cancel()
    {
        Editing = null;
        StateHasChanged();
    }
}

概ね似たような感じで書けている気がします。

JSX を返す render メソッドのようなものがない分、Blazor の方がスッキリしているかもしれないです。

後は https://github.com/tastejs/todomvc/blob/gh-pages/examples/react/js/todoItem.jsx に相当する TodoItem.razorhttps://github.com/tastejs/todomvc/blob/gh-pages/examples/react/js/footer.jsx に相当する TodoFooter.razor というコンポーネントを用意しましたが、概ね React のコードをそのまま移植できたように思います。

パブリッシュ

サンプルアプリを GitHub Pages に置いたのですが、GitHub Pages に SPA をホストするのは意外とノウハウが必要な感じで苦労しました。

下記のページや記事などを参考に作業しました。

まとめ

まとめというか Blazor に触れてみた感想です。

デバッグが難しかった

そもそも C# や .Net Core のアーキテクチャに不慣れだってのと、生まれて初めて Visual Studio を使ってみたってのもあって、デバッグが下手くそなだけかもしれませんが、クライアント側で実行時エラーが発生した場合などに _framework/blazor.webassembly.js などから吐かれるエラーメッセージからは、どこに問題があるのかよくわからないし、Blazor のコードにブレイクポイント貼ったところで非同期に実行されるフレームワーク内のソースコード内でエラーが発生しているため、うまくデバッグができずハマってる時間が結構ありました。
正直今も未解決の不具合があって、もう少し小さなサンプルで再現できるかを確認した上で開発者に質問を投げようかなと思っていたりします。。。

使うか?

食わず嫌いを克服して C# 愛が高まってきたらプライベートで使う可能性はあるかもしれませんが、今時点では TypeScript で React を書いた方が手が早いし、困った時の助けになる情報も多いし、エコシステムも充実しているし、C# のシャレオツな言語仕様の多くは TypeScript にすでに取り込まれていたりするので、バックエンドに C# を選択しない限りはなかなか手が出ない感じがします。

ただ、C#での生産性が高い人やチームにとってはとても魅力的な技術だとは思いました。

Blazor の今後のロードマップにデスクトップアプリケーションを開発するための Blazor Hybrid、ネイティブアプリケーションを開発するための Blazor Native が控えているらしいので、その動向次第ではまた再評価のタイミングが来そうです。

言語は変わりますが、F# で Blazor する Bolero は後日試してみたいなと思っています。(こっちの方が個人的には本命だったりする)

以上です。

6
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
7