JavaScriptでテンプレート文字列を作る

今つくっているプログラムで必要そうだったので書いた。

やりたいこと

"$0...$1の年収、$2..?"

みたいなテンプレートがあって、それに $0=うわっ, $1=私, $2=低すぎ を渡すと、「うわっ...私の年収、低すぎ..?」と変換したい。

案1: string.replace

template = "$0...$1の年収、$2..?";
$0 = "うわっ";
$1 = "私";
$2 = "低すぎ";

// incorrect
result = template.replace("$0", $0); 

// correct
result = template.replace(/\$0/g, $0); 
result = result.replace(/\$1/g, $1);
result = result.replace(/\$2/g, $2);

簡単にできる。JavaScript の replace は何故か1つしか置換してくれないので、最初の行のやり方は良くない。正規表現にグローバルオプションをつけて置換してあげれば良い。ただ、replaceする度に対象箇所を探しているので非効率な感じがする。テンプレートっていうのは置換したい場所は把握しているもんだ。毎回探すとかアホか。あと、言ってなかったけどバックスラッシュに続く $ はエスケープしたい。しかし、JavaScript正規表現 は何故か後読みしてくれないので、後の行のやり方も良くない。


案2: toString + join

JavaScript のオブジェクトには toString っていうメソッドがあって、文字列に変換したいときはこれが呼ばれる。そして配列には join っていうメソッドがあって、配列の中身を「文字列に変換して」くっつける。

つまり、こうできる。

$0 = {value:"ええっ"       , toString:function() {return this.value;}};
$1 = {value:"お前"         , toString:function() {return this.value;}};
$2 = {value:"そんなにあるの", toString:function() {return this.value;}};

template = [$0, "...", $1, "の年収、", $2, "..?"];

result = template.join("");

こっちのほうが早い。簡単に速度を計測したものを最後に載せておく。

コード

テンプレート文字列のクラスを作った。$0-$9まで使えて、エスケープもできる。

function TemplateString() {
    var str, defaultValue;
    
    if (arguments.length === 1) {
        str = arguments[0];
        defaultValue = [];
    } else if (arguments.length >= 2) {
        str = arguments[0];
        defaultValue = arguments[1];
    }
    
    this.vars = (function(n, defaultValue) {
        var list = [];
        var i, imax;
        for (i = 0; i < n; i++) {
            list[i] = {value:defaultValue[i]||"",
                       toString:function(){return this.value;}};
        }
        return list;
    }(10, defaultValue));
    
    this.set(str);
}

TemplateString.prototype.toString = function() {
    if (this.withT) {
        return this.value.join("");
    } else {
        return this.value;
    }
};

TemplateString.prototype.setvalue = function(index, value) {
    if (0 <= index && index < this.vars.length) {
        this.vars[index].value = value;
    }
};

TemplateString.prototype.set = function(str) {
    if (/[^\\]\$[0-9]/.test(" " + str)) {
        this.withT = true;
        this.value = this._make(str);
    } else {
        this.withT = false;
        this.value = str;
    }
};

TemplateString.prototype._make = function(str) {
    var list;
    var c, esc, dol, buffer;
    var i, imax;
    list = [];
    buffer = [];
    for (i = 0, imax = str.length; i < imax; i++) {
        c = str[i];
        if (c === "$" && !esc) {
            dol = true;
        } else if (c === "\\") {
            esc = true;
        } else {
            if (dol && "0" <= c && c <= "9") {
                if (buffer.length > 0) {
                    list.push(buffer.join(""));
                    buffer = [];
                }
                list.push(this.vars[c|0]);
            } else {
                buffer.push(c);
            }
            esc = false;
            dol = false;
        }
    }
    if (buffer.length > 0) list.push(buffer.join(""));
    return list;
};

速度比較

短い文字列 {うわっ}...{私}の年収、{低すぎ}..?

50000回 2500000回
replace 110msec 4330msec
toSting+join 40msec 2330msec
TemplateString 50msec 2540msec

長い文字列 走れメロスの冒頭677文字

500000回
replace 4550msec
TemplateString 1740msec
var limit = 50000;
(function(limit) {
    var template, lis, $0, $1, $2;
    var d, i, result;
    lis = ["うわっ", "私", "低すぎ"];
    d = +new Date();
    template = "$0...$1の年収、$2..?";
    for (i = 0; i < limit; i++) {
        $0 = lis[(i+0)%3];
        $1 = lis[(i+1)%3];
        $2 = lis[(i+2)%3];
        result = template.replace(/\$0/g, $0);
        result = result.replace(/\$1/g, $1);
        result = result.replace(/\$2/g, $2);
    }
    console.log("TIME", (+new Date() - d), result);
}(limit));

(function(limit) {
    var template, lis, $0, $1, $2;
    var d, i, result;
    lis = ["うわっ", "私", "低すぎ"];
    d = +new Date();
    $0 = {value:"", toString:function() {return this.value;}};
    $1 = {value:"", toString:function() {return this.value;}};
    $2 = {value:"", toString:function() {return this.value;}};
    template = [$0, "...", $1, "の年収、", $2, "..?"];
    for (i = 0; i < limit; i++) {
        $0.value = lis[(i+0)%3];
        $1.value = lis[(i+1)%3];
        $2.value = lis[(i+2)%3];
        result = template.join("");
    }
    console.log("TIME", (+new Date() - d), result);
}(limit));

(function(limit) {
    var template = "$0...$1の年収、$2..?";
    var lis;
    var d, i, tstr ,result;
    lis = ["うわっ", "私", "低すぎ"];
    d = +new Date();
    tstr = new TemplateString(template);
    for (i = 0; i < limit; i++) {
        tstr.setvalue(0, lis[(i+0)%3]);
        tstr.setvalue(1, lis[(i+1)%3]);
        tstr.setvalue(2, lis[(i+2)%3]);
        result = tstr.toString();
    }
    console.log("TIME", (+new Date() - d), result);
}(limit));

console.log("----------------------------------------");

(function() {
    var limit = 500000;
    var template = "$0は激怒した。必ず、かの邪智暴虐の王を除かなければならぬと決意した。$0には政治がわからぬ。$0は、村の牧人である。笛を吹き、羊と遊んで暮して来た。けれども邪悪に対しては、人一倍に敏感であった。きょう未明$0は村を出発し、野を越え山越え、十里はなれた此のシラクスの市にやって来た。$0には父も、母も無い。女房も無い。十六の、内気な$2と二人暮しだ。この$2は、村の或る律気な一牧人を、近々、花婿として迎える事になっていた。結婚式も間近かなのである。$0は、それゆえ、花嫁の衣裳やら祝宴の御馳走やらを買いに、はるばる市にやって来たのだ。先ず、その品々を買い集め、それから都の大路をぶらぶら歩いた。$0には竹馬の友があった。$1である。今は此のシラクスの市で、石工をしている。その友を、これから訪ねてみるつもりなのだ。久しく逢わなかったのだから、訪ねて行くのが楽しみである。歩いているうちに$0は、まちの様子を怪しく思った。ひっそりしている。もう既に日も落ちて、まちの暗いのは当りまえだが、けれども、なんだか、夜のせいばかりでは無く、市全体が、やけに寂しい。のんきな$0も、だんだん不安になって来た。路で逢った若い衆をつかまえて、何かあったのか、二年まえに此の市に来たときは、夜でも皆が歌をうたって、まちは賑やかであった筈だが、と質問した。若い衆は、首を振って答えなかった。しばらく歩いて老爺に逢い、こんどはもっと、語勢を強くして質問した。老爺は答えなかった。$0は両手で老爺のからだをゆすぶって質問を重ねた。老爺は、あたりをはばかる低声で、わずか答えた。";
    var $0 = "メロス";
    var $1 = "セリヌンティウス";
    var $2 = "妹";

    (function(limit, template, lis) {
        var d, i, result;
        d = +new Date();
        for (i = 0; i < limit; i++) {
            result = template.replace(/\$0/g, lis[(i+0)%3]);
            result = result.replace(/\$1/g, lis[(i+1)%3]);
            result = result.replace(/\$2/g, lis[(i+2)%3]);
        }
        console.log("TIME", (+new Date() - d), result.substr(0, 20));
    }(limit, template, [$0, $1, $2]));

    (function(limit, template, lis) {
        var d, i, tstr ,result;
        d = +new Date();
        tstr = new TemplateString(template);
        for (i = 0; i < limit; i++) {
            tstr.setvalue(0, lis[(i+0)%3]);
            tstr.setvalue(1, lis[(i+1)%3]);
            tstr.setvalue(2, lis[(i+2)%3]);
            result = tstr.toString();
        }
        console.log("TIME", (+new Date() - d), result.substr(0, 20));
    }(limit, template, [$0, $1, $2]));
}());