「Ajax」タグアーカイブ

【JavaScript】pushStateとAjaxを使って非同期通信

HTML5のHistoryAPIとAjaxを使って非同期通信をしながらURLを変えるいわゆるpjaxっぽいことができたので、大まかな流れと気をつけたい点について書いていく。

Ajaxを使ってページを遷移する時、URLは変わらないし履歴も追加されない。しかし、HTML5のHistoryAPIと組み合わせると、URLを変え履歴も追加でき、通常どおりのページ移動と同じような動作を非同期通信で実現できる。

HistoryAPIについて

JavaScriptにはhistoryという履歴を管理するオブジェクトがありHTML5から履歴を追加したり変更したり検知したりできるようになった。pjaxの頭文字pであるpushStateはhistoryオブジェクトのメソッドで

history.pushState(state, title, url);

で呼び出せる。pushStateは履歴を追加するメソッドである。

例えば、するとURLが変わる。そして、履歴も追加されたのでブラウザの戻るボタンを押すことができ、押せばURLは元に戻る。ちなみに今実行されたコードはこれ。

history.pushState('testButton', null, '/test');

pushStateの第3引数にURLを指定することによってURLが変わり、履歴が追加される。URLは相対パスでもいい。ちなみに、第1引数は履歴に情報を付加するオブジェクトで、history.stateに値がセットされる。これはどういう時に使うかというと、履歴が移動した時に発生するpopStateイベントからこのstateを元に処理したりする時に使える。後で具体的に使いながら見ていく。第2引数はその履歴のタイトルでページのタイトルを変えるためにあるようだが、実装されているブラウザが殆どないためnullでいい。他にもhistoryオブジェクトにはreplaceStateというメソッドがあり、使い方はpushStateと同じだが、replaceStateメソッドは現在の履歴を書き換えるだけで履歴は追加しない。

ここまで、Historyオブジェクトについて簡単に見てきたが、pushStateで履歴を追加しURLを変えても実際にそのURLにアクセスされるわけではない。そこでAjaxを使い、ページを遷移さるというわけである。

pjaxの流れと気をつける点

今回作ったサンプルを元に流れを見ていく。

大まかな流れとしては以下のような感じである。

  1. ユーザーがリンクからページを移動しようとする。
  2. pushStateを使って移動先のURLに変え、AjaxでそのURLのサーバーへリクエストする。
  3. サーバーから返ってきたデータを表示する。

流れは殆どAjaxで遷移する時と変わらない。しかし、pjaxにはいくらかの気をつけるべき点がある。気をつける点は以下。

  1. ユーザーが戻る進むボタンなどで履歴を移動した時もサーバーにリクエストする。
  2. pjaxを使わない通常のアクセスでも問題なくページを表示できるようにする。
  3. URLが変わるため、遷移しない部分のa要素のhref属性は絶対パスで指定する。
  4. ページ毎のscript要素を動的に実行する。

1については、popStateイベントを使う。2についてはそのままの意味で、リンク先に直接アクセスしたらテキストしか表示されないというのではユーザービリティに欠けるので、全部HTMLを用意する。3もそのままの意味で、もし非同期遷移しない部分のa要素を相対パスで指定しているとpjaxでURLが変わるので想定していないリンクを指すことになる。2、3については設計の問題で、pjaxを想定したHTMLを書かなければいけないためプログラム自体の汎用性が低くなる。今回は自分の力量不足もあってHTMLに頼っている部分も多い。サーバーサイドと組み合わせたりしてプログラムで制御するような設計にすればある程度汎用性は高くなると思う。4については、非同期遷移する際の問題点で、非同期遷移で得たscript要素は自動的に発火しないので、自分で実行する必要がある。これら流れと気をつける点を以下コードを見ながら具体的に書いていく。

実際のコード

ひとまず、サンプルのpjax.jsのコードを以下に表示する。

(function() {

    var id = 'main'; //非同期遷移する要素のid;

    var transitionElement = document.getElementById(id);
    var display = new Display(transitionElement);
    var xhr = new XMLHttpRequest();

    //履歴を上書き;
    (function() {
        var url = location.href.replace(/index\..+/, '');
        history.replaceState({url: url}, null, url);    
    })();

    //対象のアンカー要素に非同期通信するよう設定;
    function setRequest() {
        var anchors = document.querySelectorAll('a[class *= "async"]');
        for(var i = 0; i < anchors.length; i++) {
            anchors[i].onclick = (function(i){
                return function(e) {
                    e.preventDefault();
                    var url = anchors[i].href;
                    url = url.replace(/index\..+/, '');
                    display.hide(function() {
                        //リンク先と現在のURLが一緒じゃないなら履歴を追加する
                        if(url !== location.href) {
                            history.pushState({url: url}, null, url);
                        }
                        request(url);
                    });
                }
            })(i);
        }
    }
    setRequest();

    //履歴を移動した時の処理;
    window.addEventListener('popstate', function(e) {
        if(e.state) {
            display.hide(function() {
                request(e.state.url);
            });
        }
    });

    function request(url) {
        xhr.abort();
        xhr.open('GET', url);
        xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
        xhr.responseType = 'document';
        xhr.overrideMimeType('text/html; charset=utf-8'); //文字化け対策;
        xhr.timeout = '10000';
        xhr.send(null);
    }

    xhr.addEventListener('loadend', function() {
        if(xhr.status === 200) {
            var response = xhr.response;
            var responseElement = response.getElementById(id);

            //必要な要素を書き換える;
            document.title = response.title;
            document.body.id = response.body.id;
            transitionElement.innerHTML = responseElement.innerHTML;

            //実行したいスクリプト要素を取得して実行;
            var scripts = response.querySelectorAll('script[class *= "async"]');
            for(var i = 0; i < scripts.length; i++) {
                var script = cloneScript(scripts[i]);
                transitionElement.appendChild(script);
            }

            setRequest();
            display.show();
            return;
        }
        //読み込みできなかった時の処理;
        transitionElement.innerHTML = '読み込みに失敗しました。';
        display.show();
    });

    //script要素をコピー;
    function cloneScript(element) {
        var script = document.createElement('script');
        var attrs = element.attributes;
        for(var i = 0, len = attrs.length; i < len; i++) {
            var attr = attrs[i];
            script.setAttribute(attr.name, attr.value);
        }
        script.innerHTML = element.innerHTML;
        return script;
    }

    //アニメーション;
    function Display(element) {
        this.element = element;
        this.opacity = Number(document.defaultView.getComputedStyle(element, null).opacity);
        this.hideTimer = null;
        this.showTimer = null;
        this.hide = function(func) {
            clearTimeout(this.showTimer);
            var style = this.element.style;
            var css = document.defaultView.getComputedStyle(this.element, null);
            style.opacity = css.opacity;
            var that = this;
            (function recursion() {
                if(style.opacity <= 0) {
                    style.display = 'none';
                    if(typeof func === 'function') {
                        func();
                    }
                    return;
                }
                style.opacity = (Number(style.opacity) - 0.1).toFixed(1);
                that.hideTimer = setTimeout(recursion, 15);
            })();
        };
        this.show = function(func) {
            clearTimeout(this.hideTimer);
            var style = this.element.style;
            style.opacity = 0;
            style.display = 'block';
            var that = this;
            (function recursion() {
                if(style.opacity >= Number(that.opacity.toFixed(1)) || style.opacity >= 1) {
                    style.opacity = that.opacity;
                    if(typeof func === 'function') {
                        func();
                    }
                    return;
                }
                style.opacity = (Number(style.opacity) + 0.1).toFixed(1);
                that.showTimer = setTimeout(recursion, 15);
            })();
        };
    }
})();

始めにHTMLの設計段階で非同期遷移する要素を決める。ここでは、「var id = ‘main’」とあるように「id=”main”」の要素を非同期遷移する。つまり、「id=”main”」の要素とレスポンスで返ってきた「id=”main”」の要素のinnerHTMLを交換するというわけである。基本的に現在のHTMLとレスポンスで返ってくるHTMLの構造は同じだと想定している。では、処理を見ていく。

var url = location.href.replace(/index\..+/, '');
history.replaceState({url: url}, null, url);

まず、初回アクセス時の履歴を書き換える必要がある。これはpopStateイベントの処理の時に詳しく説明するが、後々、ユーザーが戻るボタンを押して現在のページに戻ってきた時にサーバーに適切なリクエストを送るためである。ここでは、index.htmlなど「index.~」の文字は表示しないよう文字列処理を施している。

次に非同期通信したいa要素にクリックイベントをセットする。

//対象のアンカー要素に非同期通信するよう設定;
function setRequest() {
    var anchors = document.querySelectorAll('a[class *= "async"]');
    for(var i = 0; i < anchors.length; i++) {
        anchors[i].onclick = (function(i){
            return function(e) {
                e.preventDefault();
                var url = anchors[i].href;
                url = url.replace(/index\..+/, '');
                display.hide(function() {
                    //リンク先と現在のURLが一緒じゃないなら履歴を追加する
                    if(url !== location.href) {
                        history.pushState({url: url}, null, url);
                    }
                    request(url);
                });
            }
        })(i);
    }
}
setRequest();

function request(url) {
    xhr.abort();
    xhr.open('GET', url);
    xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
    xhr.responseType = 'document';
    xhr.overrideMimeType('text/html; charset=utf-8'); //文字化け対策;
    xhr.timeout = '10000';
    xhr.send(null);
}

ここではclass属性に「async」というclass名が含まれているa要素にイベントをセットしている。あらかじめHTML側で非同期通信したいa要素にasyncというclass属性を付加している。a要素がクリックされるとpushStateでそのa要素のリンク先のURLに変えて履歴を追加し、リンク先にXMLHttpRequestでリクエストを送る。もし、リンク先が現在のURLと同じであれば履歴は追加しない。ここでは、簡単なアニメーションを使っているのでdisplay.hideというメソッドの中に処理を書いているが、pjaxの流れとはあまり関係がないので無視していい。XMLHttpRequestでリクエストする時は文字化け対策としてxhr.overrideMimeType('text/html; charset=utf-8')でレスポンスで返ってくるHTMLのMIMEタイプを上書きする処理を書く。このsetRequest関数は非同期遷移後、毎回実行する。そのためonclickでイベントを定義しているが、addEventListenerでイベントを定義したい場合「addEventListenerの無名関数をremoveEventListenerで消す方法」を参照。

次にレスポンス時の処理を見る前に履歴移動を検知するpopStateイベントの処理を見る。

window.addEventListener('popstate', function(e) {
    if(e.state) {
        display.hide(function() {
            request(e.state.url);
        });
    }
});

このpopStateイベントのイベントオブジェクトにはstateというプロパティがある。これはhistory.stateと同じものを指している。このstateというのはpushStateやreplaceStateの第一引数にセットしたもので、履歴に付加している情報である。先ほど見たpushStateやreplaceStateの第一引数には「{url: url}」というオブジェクトをセットした。つまり、stateはurlというプロパティを持っていて値はその履歴のURLであるので、このURLにリクエストを送る。そうすれば、そのURLに相応しいドキュメントを非同期で得ることが出来る。pushStateやreplaceStateの第一引数にurl情報を付加すれば、popStateイベントでその情報を元にサーバーにリクエストできるというわけである。

最後にレスポンスが返ってきた時の処理を見る。

xhr.addEventListener('loadend', function() {
    if(xhr.status === 200) {
        var response = xhr.response;
        var responseElement = response.getElementById(id);

        //必要な要素を書き換える;
        document.title = response.title;
        document.body.id = response.body.id;
        transitionElement.innerHTML = responseElement.innerHTML;

        //実行したいスクリプト要素を取得して実行;
        var scripts = response.querySelectorAll('script[class *= "async"]');
        for(var i = 0; i < scripts.length; i++) {
            var script = cloneScript(scripts[i]);
            transitionElement.appendChild(script);
        }

        setRequest();
        display.show();
        return;
    }
    //読み込みできなかった時の処理;
    transitionElement.innerHTML = '読み込みに失敗しました。';
    display.show();
});

//script要素をコピー;
function cloneScript(element) {
    var script = document.createElement('script');
    var attrs = element.attributes;
    for(var i = 0, len = attrs.length; i < len; i++) {
        var attr = attrs[i];
        script.setAttribute(attr.name, attr.value);
    }
    script.innerHTML = element.innerHTML;
    return script;
}

ここでは、レスポンスをdocumentで受け取っている。そして、そのdocumentの中から「id="main"」の要素を取得して、innerHTMLで書き換えている。innerHTMLではなく要素をそのまま代入することを試したところ、その要素内にscript要素があった時、chromeではscriptが実行され、IEやFireFoxではscriptは実行されないという結果になった(仕様的にはどちらが正しいんだろう・・・)。個人的には自動的にscript要素を実行してくれたほうが有り難いのだが、innerHTMLで書き換えて実行したいscriptは自分で実行する。ここでは、title要素やbodyのidも書き換えている。そして、実行したいscript要素をcloneScriptで複製して実行する。ここでは、実行したいscript要素はclass名で判別している。あらかじめHTMLで動的に実行したいscript要素のclass属性に「async」というclass名を書いておく。cloneScriptについては、「script要素<script>を動的に実行する」を参照。そして要素の書き換えが完了したら、再びsetRequest関数を実行して、対象のa要素にクリックイベントを定義する。これは、書き換えた要素内に非同期通信したいa要素が含まれているかもしれないからである。

最後に

今回はpjaxの流れを知るためにプログラムを書いてみたが、思った以上に気をつけないといけない点が多い。ここでは見てこなかったが、ソーシャルボタンの再読み込みやAdsenseやAnalyticsなどなど主にscript要素が絡んでいる処理を個別に扱わないといけなかったりする。【1】だからあまり実用的ではないかもしれないが、画面遷移しない、簡単に高速化できるなどメリットは大きい。正直自分はpjaxをバリバリ使っているサイトを見てJavaScriptを始めたほどpjaxの技術に感動した(というかAjaxが9割)。今後はAjaxよりかWebSocketを使って通信する手法が確立されそうだが、いずれにせよHistoryAPIを使った流れを知っておくのはいいかもしれない。pjaxの流れ自体は難しくないので、設計をしっかり考えてサーバーサイドと連携するとこの技術をもっと面白く使えると思う。

とりあえずまたまたGitHubに上げてみた。

【JavaScript】Ajaxを使ったメールフォーム

クライアントサイドとサーバーサイドを連携するうえでの基本的な流れを知っておきたかったので、Ajaxを使ってメールフォームを作ってみた。使った言語はJavaScriptとPHP。以下はコードの解説と気をつけたい点について。完成したものはこちら【1】

クライアントサイド

まず、クライアントサイドの流れとしては以下のような感じ。

  1. ユーザーが送信ボタンを押した時にXMLHttpRequestでフォームデータをサーバーに送信。
  2. メールが送信されたかされていないかサーバーが返してきた結果を元にユーザーに通知。
  3. 戻るボタンで再びフォーム画面を表示。

実際にコードを見ながら細かな流れを書いていく。

(function() {

	var server = ''; //送信先サーバーのURLを設定する;

	var xhr = new XMLHttpRequest();
	var form = document.getElementById('mail-form');
	var formHTML = form.innerHTML;
	var tempForm = {};

	form.onsubmit =  function(e){
		e.preventDefault();
		//多重送信を防止;
		var submit = form.querySelector('input[type="submit"]');
		submit.disabled = false;

		//formの内容を保存;
		(function() {
			var elements = form.elements;
			for(var i = 0; i < elements.length; i++) {
				tempForm[elements[i].name] = elements[i].value;
			}
		})();

		//サーバーにFormDataを送信;
		(function() {
			//FormDataを取得し、送信元サーバーを追加;
			var formData = new FormData(form);
			formData.append('from', location.host);

			xhr.open('POST', server);
			//カスタムヘッダーをつける;
			xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
			//結果はjsonだが、IEが対応していないのでtextで受け取る;
			xhr.responseType = 'text';
			xhr.timeout = '10000';
			xhr.send(formData);
		})();
	};

	xhr.addEventListener('loadend', function() {	
		//formのchildNodesを全て消す;
		(function removeAllChild(element) {
			var child;
			while(child = element.firstChild) {
				element.removeChild(child);
			}
		})(form);

		//送信後に表示する要素;
		var resultElements = (function createResultElements() {
			var elements = {};
			elements.df = document.createDocumentFragment();
			elements.p = document.createElement('p');
			elements.back = document.createElement('input');
			elements.back.type = 'button';
			elements.back.value = '戻る';
			elements.df.appendChild(elements.p);
			elements.df.appendChild(elements.back);

			return elements;
		})();
		form.appendChild(resultElements.df);

		if(xhr.status === 200) {
			var response = JSON.parse(xhr.response);
			if(response.result) {
				//メールが送信された時の処理;
				resultElements.p.textContent = '送信に成功しました。';
				resultElements.back.onclick = function() {
					form.innerHTML = formHTML;
				};
				return;
			}
		}
		//メールが送信されなかった時の処理;
		resultElements.p.textContent = '送信に失敗しました。再度送信してください。';
		resultElements.back.onclick = function() {
			form.innerHTML = formHTML;
			var elements = form.elements;
			for(var i = 0; i < elements.length; i++) {
				elements[i].value = tempForm[elements[i].name];
			}
		};
	}, false);
})();

まず、ユーザーがフォームを送信したらpreventDefaultで通常の動作を止めて、多重送信を防止するために送信ボタンのdisabled属性をfalseに設定する。次にユーザーが書いたフォームの内容を保存する。これは、メール送信に失敗した後、再びフォーム画面に戻った時に復元するためである。そして次に、フォームデータをXMLHttpRequestで送る。フォームの内容はHTML5からFormDataオブジェクトでそのまま渡せるようになった。FormDataコンストラクタの引数にform要素を入れてnewすればいい。ここでは一応、どのサイトからフォーム送信されたのかわかるように自分のサーバーのホスト名もフォームデータに追加して送る。

最後にXMLHttpRequestオブジェクトの設定をする。まず、リクエストはPOSTで、送り先はsend_mail.phpファイルがあるサーバーである。最初のserver変数にurlをいれる。そして、ヘッダーをセットする。

xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest')

というのは、サーバーにXMLHttpRequestを使って通信をしているということを示すカスタムヘッダーである。カスタムなので自由に設定できるが、他のライブラリはこのカスタムヘッダーを使用しているので同じようにする。そして、送る情報はフォームデータだが、FormDataオブジェクトを使えば、Content-type: multipart/form-dataというヘッダーを付加しなくていい。FormDataオブジェクト万歳!!。次に、responseTypeで返ってきたデータをどの形式で受け取るか指定する。今回は勉強がてらjsonで結果を返してもらうことにした。IEはresponseTypeにjsonを指定できないようなので、結果はtextで受け取ってJSON.parseでjsonに変換することにした。準備ができたのでsendメソッドの引数にフォームデータをいれて送信する。

そして、loadendイベントで結果を処理する。XMLHttpRequestのloadendイベントは結果がエラーであっても、タイムアウトであっても発火する。ここでは、xhr.statusが200で返ってきたresponseのresultプロパティの結果がtrueであれば、メールが送信されていて、それ以外はメール送信失敗したということがわかる。このresultプロパティはサーバー側で設定する。メールが送信されたかどうかの旨を表示して、戻るボタンで元のフォーム画面を再び表示する。もしメールの送信が失敗したのであれば戻るボタンでフォーム画面を再び表示するとともに、先ほど保存したフォームの内容を復元する。ここらへんの処理は実際に使ってみるとわかりやすい。

サーバーサイド

次にサーバー側の処理を見てみる。短いコードだが、あまりサーバーサイド言語を書いたことがなかったので色々と学べることが多かった。再びコードを見ながら細かく流れを書いていく。

<?php

$mail_address = ''; //送信先メールアドレスを設定する;

//通信を許可する設定;
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: POST, OPTIONS');
header('Access-Control-Allow-Headers: X-Requested-With');
//結果はjsonで返す;
header('Content-Type: application/json; charset=UTF-8');
header('X-Content-Type-Options: nosniff');

mb_language('japanese');
mb_internal_encoding('UTF-8');

//XMLHttpRequest以外からのアクセスの処理;
if(!isset($_SERVER['HTTP_X_REQUESTED_WITH']) || $_SERVER['HTTP_X_REQUESTED_WITH'] !== 'XMLHttpRequest') {
	die(json_encode(array('result' => false), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP));
}

$name = trim(rm_indention($_POST['name']));
$email = trim(rm_indention($_POST['email']));
$from = trim(rm_indention($_POST['from']));
$message = trim($_POST['message']);

//nameとmessageがあれば。メール送信;
if($name !== '' && $message !== '') {
	$to = $mail_address;
	$subject = rm_indention(trim($_POST['subject']));
	$body = 'Referer: ' . $_SERVER['HTTP_REFERER'] . "\n";
	$body .= 'Name: ' . $name . "\n";
	$body .= 'Email: ' . $email . "\n";
	$body .= "\n" . $message;
	$header = 'From: ' . 'mail@' . $from . "\n";
	if($email !== '') {
		$header .= 'Reply-To: ' . $email . "\n";
	}
	$header .= 'MIME-Version: 1.0' . "\n";
	$parameters = "-f" . $mail_address;

	//メール送信;
	$success = mb_send_mail($to, $subject, $body, $header, $parameters);
}else {
	$success = false;
}
$response = array('result' => $success);
echo json_encode($response, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);

//改行を消す(メールヘッダ・インジェクション対策);
function rm_indention($str) {
	if(isset($str)) {
		str_replace(array("\r\n", "\r", "\n"), '', $str);
	}
	return $str;
}

まず始めにヘッダーについて。Ajaxを使ってクロスオリジン通信する時はHTTPヘッダーについて多少知らなければならない。Ajaxを使う上で知っておかなければならないHTTPヘッダーについてはMDNのHTTP access control (CORS)にわかりやすく書いてある。ここでは簡単に、使っているヘッダーについて説明する。

header('Access-Control-Allow-Origin: *');

このヘッダーは、どのOriginからのアクセスを許可するかというものだ。HTTPのOriginというのは、「プロトコル + ドメイン + ポート番号」のことで、例えばこのサイトだと単に「http://webkatu.com」というのがOriginに当たる。ここで使用している「*」はどのOriginからのアクセスも許可するという設定で、どこからでもAjaxを使ってアクセスできるようになる。セキュリティなどを考慮するならメールフォームを使うサイトのOriginを指定しておけばいい。スペースで区切ることによって複数のOriginを指定できる。

header('Access-Control-Allow-Methods: POST, OPTIONS');

このヘッダーは、どのリクエストメソッドからのアクセスを許可するかというものだ。ここでは、POSTとOPTIONSのアクセスメソッドを許可している。OPTIONSというのはプリフライトリクエストといって、実際にリクエストを送ってもいいかを確かめる時に使われるメソッドで、今回はあってもなくてもどちらでもいいが、基本的にPOSTと一緒に指定する。Access-Control-Allow-Methodsはカンマで区切ることによって複数指定できる。

header('Access-Control-Allow-Headers: X-Requested-With');

このヘッダーはクライアントサイドのカスタムヘッダーを許可するもので、ここではクライアントサイドで書いたxhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest')のヘッダーを許可している。このカスタムヘッダーにより、XMLHttpRequstを使ったアクセスか否かの処理を分けることができる。

header('Content-Type: application/json; charset=UTF-8');
header('X-Content-Type-Options: nosniff');

このヘッダーは結果をjsonで返すということを表している。Ajax通信に限らず結果をjsonで出力する時はこのヘッダーを使う。

if(!isset($_SERVER['HTTP_X_REQUESTED_WITH']) || $_SERVER['HTTP_X_REQUESTED_WITH'] !== 'XMLHttpRequest') {
	die(json_encode(array('result' => false), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP));
}

カスタムヘッダーを見てXMLHttpRequestを使ってアクセスされたのかどうかを確かめる。例えば、send_mail.phpに直接アクセスした場合やカスタムヘッダーを付けないでアクセスした場合はここで処理を終了させる。

前準備はこんな感じで次は実際にメールを送る処理について。メールはメールヘッダーを適切に設定して送信しなければいけない。メールヘッダーについてはメールヘッダ情報 - メールヘッダの意味・見方・調べ方にわかりやすく書いてある。

ここではメール送信するのにPHPのmb_send_mail関数を使う。mb_send_mailは第一引数に宛先、第二引数に件名、第三引数にメール本文、第四引数にその他のヘッダー、第五引数はメールデーモンにパラメーターを与えることができる。実際にコードはこんな感じ。

$name = trim(rm_indention($_POST['name']));
$email = trim(rm_indention($_POST['email']));
$from = trim(rm_indention($_POST['from']));
$message = trim($_POST['message']);

//nameとmessageがあれば。メール送信;
if($name !== '' && $message !== '') {
	$to = $mail_address;
	$subject = rm_indention(trim($_POST['subject']));
	$body = 'Referer: ' . $_SERVER['HTTP_REFERER'] . "\n";
	$body .= 'Name: ' . $name . "\n";
	$body .= 'Email: ' . $email . "\n";
	$body .= "\n" . $message;
	$header = 'From: ' . 'mail@' . $from . "\n";
	if($email !== '') {
		$header .= 'Reply-To: ' . $email . "\n";
	}
	$header .= 'MIME-Version: 1.0' . "\n";
	$parameters = "-f" . $mail_address;

	//メール送信;
	$success = mb_send_mail($to, $subject, $body, $header, $parameters);
}else {
	$success = false;
}
//改行を消す(メールヘッダ・インジェクション対策);
function rm_indention($str) {
	if(isset($str)) {
		str_replace(array("\r\n", "\r", "\n"), '', $str);
	}
	return $str;
}

まず、受信したフォームデータを処理する。rm_indentionという関数は自分で定義した関数で、フォームデータの不必要な改行コードを消す。メールヘッダーは改行を使って色々と設定できるため、改行コードを消すことによってフォーム送信するユーザーがCcやBccを使ってSPAMの踏み台にしてくる余地を潰す。そして、必須な情報が受信したフォームデータに入っていればメールを送る。$toにはこのプログラムを使う人が自分で送り先を決める。一番上の$mail_address変数にメールアドレスを入れればいい。$subjectにはユーザーが入力した件名を入れる。$bodyにはメール本文を入れる。ここでは、メールの冒頭にフォーム送信元がわかるリファラーと名前とメールアドレスを表示している。$headerには他に追加したいヘッダーを入れる。Fromヘッダーは送信者のメールアドレスを入れる。このヘッダーは必須で、適切にいれなければいけない。ここでは、クライアントサイドのドメインでメールを送っている。他にも色々とヘッダーを追加して送っているが、適切に書けるヘッダーはできるだけ書いておいた方がいいかもしれない。

$parameters = "-f" . $mail_address;

この$parametersに-fというパラメーターを使ってReturn-Pathというヘッダーを設定している。Return-Pathというのはメール送信に失敗した時、どのメールにエラーを通知するかというもので、ここでは$toと同じにしている。このヘッダーをつけることによって迷惑メールとして扱われる可能性が下がるようだ。本当は-fというパラメーターはFromヘッダーを設定するものだそうだ。通常であれば送信元にエラーメールを返すので送信元と同じである方が迷惑メールとして扱われる可能性は下がると思うが、ここでは宛先にエラーメールを返すように設定して、もし何かエラーが発生して宛先に送れなくてもその旨が宛先に伝わるようにしている。もし迷惑メールとして扱われるならFromと一緒のメールアドレスを指定した方がいいかもしれない。そして、この第五引数はPHPのメールデーモンの設定をするため、レンタルサーバーによっては動かないところもあるそうだ。とりあえず頭の片隅にいれておきたい。この第五引数のパラメーターについてはLinuxコマンドのsendmailを参照。

最後にメールが送られたかどうかの結果をjson形式で返す。mb_send_mail関数の戻り値は真偽値なので、そのまま変数に入れて結果を返せばいい。

$response = array('result' => $success);
echo json_encode($response, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP);

あまり詳しく調べていないが、とりあえずjson形式で返す時はjson_encode関数の第二引数にこのように指定すれば安全にデータを送れるようだ。ajax json セキュリティとGoogleさんに聞くといいかもしれない。

作った感想

コード自体は短いが、HTTPヘッダーについてメールヘッダーについてjsonについてセキュリティについてなどなどネットワークが絡むので思った以上に学べることが多かった。ある程度サーバーサイドに慣れるときっと定石みたいなものが見えてくると思うのでこれを気にどんどん使っていきたい。そして、ネットワークが絡んだプログラムが動くと感動が大きい。特にAjaxを使ってサーバー側のプログラムを動かすのが凄い楽しい。そもそも最初からサーバー側でやれよという話だが、実はAjaxを使えば広告なし無料のレンタルサーバーであれこれできるのであった。Herokuやサーバーサイド言語やデーターベースが動く無料で広告なし独自ドメインが使えるレンタルーサーバーもあってそれだけでも充分ありがたいが、当然ながら色々と制限があって面倒くさい。しかし、静的サイトだけのサーバーであれば制限があまりないところが多い。そして、広告付きでサーバーサイドやデーターベースが動くサーバーも多い。この2つのサーバーとAjaxを使って広告なし無料独自ドメインの動的サイトを作れる!JavaScript万歳!節約万歳!・・・でも実際のところAjaxはブラウザ毎の対応などなど色々と面倒くさい。

さっそくGitHubに上げてみたので興味のある方は使ってみてください。