C#の匿名メソッドをJavaScriptにコールバックさせる

前回のネタ↓になんの反応も無かったので、だぶん誰も興味がないんだろうけど・・・orz、まだ続ける。

SilverlightからJavaScriptの関数が呼び出せる事は前回まででご承知の通り。

例えば以下のようなJavaScript関数がSilverlightコントロールをホストするHTMLで定義されている(もしくはincludeされている)とする。

function print(msg) {
    alert(msg);
}

この関数をSilverlight側から呼び出す場合、以下のようにする。

HtmlPage.Window.Invoke("print", "Hello, World");

こうすると「print」というJavaScript関数に「Hello, World」という文字列が引数として渡されて呼び出される。

このInvokeメソッドには第一引数としてメソッド名を、第二引数にそのメソッドに渡す引数を指定する。第二引数は可変長引数になっているので、いくつでも指定する事ができる。

なんらかの返り値が返ってくる場合は、Invokeメソッドの返り値としてobject型が返ってくるので、そこから取得できる。

プリミティブな型ならば.NETの型に自動的に変換してくれるので、戻り値をそのままキャストして使うことができるが、配列や連想配列などのオブジェクトではそうはいかない。
こういったオブジェクトは「ScriptObject」という型で返ってくる。ScriptObjectにもInvokeメソッドが定義されているので、オブジェクトのメソッドや属性にアクセスするのにも同様の方法が使える。

JavaScript配列へのアクセス

例えば戻り値として配列が返ってきた場合、各要素にアクセスするには以下のようにすればいい。

// 配列を返すJavaScript関数を呼び出した。
var array = (ScriptObject)HtmlPage.Window.Invoke("getArray");

// 配列の長さを取得。
// 何故かdouble型で返ってくるのでdouble型にキャストしてからint型にキャストしている。
var length = (int)((double)array.GetProperty("length"));

for(var i=0; i<length; i++) {
    // 各要素にアクセスする
    var obj = (ScriptObject)array.GetProperty(i.ToString());
    
    // do something
}

逆に.NETの配列をJavaScript関数に渡す場合はどうすればいいのか?もちろんそのまま.NET配列をInvokeメソッドに引数として渡せば例外が出る。

では、どうすればいいかというと以下のようにして、JavaScript配列を作ってやればいい。

// JavaScript配列を生成
var jsArray = HtmlPage.Window.CreateInstance("Array");
jsArray.Invoke("push", 1);
jsArray.Invoke("push", 2);

// [1, 2]
HtmlPage.Window.Alert((string)jsArray.Invoke("toSource"));

こうすればJavaScriptの配列を生成できるので、JavaScript関数に渡することができる。ほかの連想配列やオブジェクトなんかも同様の方法で処理できる。

しかし、関数オブジェクトだけはこうはいかない。

JavaScript関数へのアクセス

仮にJavaScript関数を返す以下のようなJavaScript関数があるとする。

function getFunc() {
    return function(msg) {
        alert(msg);
    };
}

これを呼び出し、取得した関数オブジェクトをSilverlight側で呼び出す場合は以下のようにする。

var func = (ScriptObject)HtmlPage.Window.Invoke("getFunc");
// 自分自身を呼び出す。
func.InvokeSelf("Hello, World");

自分自身を呼び出すためのInvokeSelfというメソッドが用意されているので、JavaScriptからSilverlightへはうまく動作する。

しかし、これが逆の場合難しくなる。Silverlight側で定義した匿名メソッドをJavaScript側に渡してコールバックさせるわけだが、普通に考えてそんな事ができるわけがない。

JavaScript関数オブジェクト自体を生成するのは難しくない。

// Function型を生成
var func = HtmlPage.Window.CreateInstance("Function", "msg", "alert(msg);");

先程のCreateInstanceメソッドを使ってFunction型を生成してやればいい。Function型の引数には一つ目に引数リストを二つ目には関数の定義を文字列で指定する。

そう、文字列で関数の定義を指定する方法しかないのだ。試しにラムダ式を突っ込んだら例外が出た。

この辺はJavaScriptのリファレンスを見ればわかることだけど、Function型を明示的に指定してインスタンス化した場合は式がevalで処理されるらしく、文字列でしか指定できないらしい。

// これと
var func1 = new Function("msg", "alert(msg);")
// これの違い
var func2 = function(msg) {
    alert(msg);
}

一応Silverlightには.NETで定義したメソッドをJavaScript側から呼ぶための機構が用意されている。

ここ↓に詳しく解説されているので詳細は割愛する。

この機構を利用して以下のクラスを作った。

JsFunction.cs

using System;
using System.Linq;
using System.Windows.Browser;
using System.Collections.Generic;
using System.Diagnostics;

namespace Silverlight.JavaScript {
    /// <summary>
    /// JavaScriptの関数を定義するためのラッパークラス
    /// </summary>
    /// <typeparam name="TDelegate">実行するデリゲートの型</typeparam>
    [ScriptableType]
    [DebuggerStepThrough]
    public class JsFunction<TDelegate> {
        private static int counter = 0;

        private string name;
        private TDelegate func;

        /// <summary>
        /// JavaScript側から呼び出す関数を取得します。
        /// </summary>
        [ScriptableMember(ScriptAlias = "invoke")]
        public TDelegate Invoke {
            get { return func; }
        }

        /// <summary>
        /// 一意なオブジェクト名を生成するコンストラクタ
        /// </summary>
        private JsFunction() {
            name = string.Format("_slObj_{0}", counter++);
        }
        /// <summary>
        /// 指定したデリゲートを呼び出すJavaScript関数を作成します。
        /// </summary>
        /// <param name="func">デリゲート</param>
        public JsFunction(TDelegate func)
            : this() {
            this.func = func;

            HtmlPage.RegisterScriptableObject(name, this);
        }

        /// <summary>
        /// JavaScript関数オブジェクトを生成します。
        /// </summary>
        /// <returns>関数オブジェクト</returns>
        public ScriptObject Create() {
            var args = new List<string>();
            var argsLen = typeof(TDelegate).GetGenericArguments().Length;

            for(int i = 0; i < argsLen; i++) {
                args.Add(string.Format("args{0}", i));
            }
            var argsList = string.Join(",", args.ToArray());

            return (ScriptObject)HtmlPage.Window.CreateInstance(
                "Function", // 関数を生成
                argsList,   // 引数リスト
                string.Format("return ajaxCtl.Content.{0}.invoke({1});", name, argsList)
            );
        }
    }
}

これを使うには、まず以下のような引数として渡された関数を呼び出すJavaScript関数があるとする。

function invokeFunc(func) {
    func("Hello, World");
}

これをSilverliht側から呼び出す場合は以下のようにする。

// JavaScript側からコールバックさせたいラムダ式を引数として渡す。
var jsFunc = new JsFunction(msg => HtmlPage.Window.Alert(msg));
// CreateメソッドでJavaScript関数オブジェクトを生成
HtmlPage.Window.Invoke("invokeFunc", jsFunc.Create());

こうすれば、JavaScript側からSilverlight側で定義した匿名メソッドやラムダ式をコールバックさせる事ができる。

これを動作せるにはASP.NETWebフォームでSilverlightコントロールを貼り付けて、このコントロールの「OnPluginLoaded」イベントに以下のJavaScript関数を関連付けておく必要がある。

function onPluginLoaded(sender) {
    ajaxCtl = sender.get_element();
}

以下Webフォームのソース

index.aspx
<%@ Page Language="C#" AutoEventWireup="true" %>

<%@ Register Assembly="System.Web.Silverlight" Namespace="System.Web.UI.SilverlightControls"
    TagPrefix="asp" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title>Test Page For slQuery</title>
    <style type="text/css">
        body {
            padding: 4px;
        }
        #content {
            width: 300px;
            height: 200px;
            border: solid 2px silver;
            margin-top: 5px;
            overflow: scroll;
        }
    </style>
    <script type="text/javascript" src="js/jquery-1.2.3.pack.js"></script>
    <script type="text/javascript">
        function onPluginLoaded(sender) {
            ajaxCtl = sender.get_element();
        }
    </script>
</head>
<body>
    <form id="form1" runat="server">
        <asp:ScriptManager ID="ScriptManager1" runat="server"></asp:ScriptManager>
        <div>
            <asp:Silverlight ID="Xaml1" runat="server"
            Source="~/ClientBin/Silverlight.JQuery.Test.xap" Version="2.0"
            OnPluginLoaded="onPluginLoaded" />
        </div>
    </form>
    
    <input type="button" value="button1" id="button1" />
    <input type="button" value="button2" id="button2" />
    
    <div id="content">
    </div>
    
</body>
</html>

ソース