実践JavaScript JavaScript特有の問題・クロスブラウザなど

「プログラミング経験はそこそこだけど、JavaScriptはあんまり」な人向け社内勉強会資料vol.4

Shogo Ohta, 2009-07-21

JavaScriptらしさとは

真偽判定と型変換

JavaScriptは暗黙的に型変換を行います。

var falsy = [false, null, undefined, 0, ''];
falsy
.forEach(function(a){
    falsy
.forEach(function(b){
        console
.log('"' + a + '" == "' + b + '" ' + (a==b));
   
});
});
偽として評価される値同士の==による等価演算
=="false""null""undefined""0"""
"false"truefalsefalsetruetrue
"null"falsetruetruefalsefalse
"undefined"falsetruetruefalsefalse
"0"truefalsefalsetruetrue
""truefalsefalsetruetrue
偽として評価される値同士の===による厳密等価演算
==="false""null""undefined""0"""
"false"truefalsefalsefalsefalse
"null"falsetruefalsefalsefalse
"undefined"falsefalsetruefalsefalse
"0"falsefalsefalsetruefalse
""falsefalsefalsefalsetrue

このように==での比較は暗黙的な型変換のため、ミスが発生しやすいです。そのため、===を使用することが推奨されます。パフォーマンスの面でも===のほうが有利です。

ただし、そもそも==も===も使用しないこともあります。nullやundefinedはどちらも条件式では"偽"に変換される点に注目して暗黙的な変換に任せてしまう方法です。

if (document.addEventListener) {
} else if (document.attachEvent) {
}

このように、そのメソッドが使用可能であれば真偽値に型変換した際にtrueになるような値であると期待して、if (document.addEventListener)のような書き方にするのが一般的です。

なお、addEventListenerは関数なので、もっと厳密に判定したという場合はtypeofで型を調べる方法もあります。

if (typeof document.addEventListener === 'function') {
} else if (typeof document.attachEvent === 'function') {
}

明示的型変換

暗黙的な型変換に続いて、明示的な型変換について。まず、数値への変換です。

console.log(Number("1.1"));//1.1
console
.log("1.1"-0);//1.1
console
.log("1.1"*1);//1.1
console
.log("x"+"1.1"-0);//NaN
console
.log("x"+"1.1"*1);//x1.1
console
.log(parseInt("1.1"));//1
console
.log(parseInt("0xff"));//255
console
.log(parseInt("012",10));//12
console
.log(parseInt("12x"));//12
console
.log(parseFloat("1.1"));//1.1
console
.log(parseFloat("1.1x"));//1.1

まず、Numberでキャストする方法ですが、これはあまり使用しません。実質関数を呼び出すことになるので、(主にIEでの)パフォーマンスに難があります。その代わりとして、-0や*1が使用されことが多くなります。演算子の優先順位から、*1が使いやすいです。

parseIntは名前の通り文字列を数値(Int)に変換します。0xで始まる場合は16進数、0で始まる場合は8進数として評価します。第二引数で基数を指定することもでき、数字で始まっていれば、後ろに数字以外の文字があってもその部分は無視してくれるなど、使い勝手が良いので頻繁に使用されます。ただし、先述のとおり関数呼び出しのパフォーマンスから、*1などを使用したほうが良い場合もあります。

parseFloatも名前の通り文字列を数値(Float)に変換します。parseIntと違い、第二引数はありません。

element.style.left = '12px';
console
.log(element.style.left, parseFloat(element.style.left));
// "12px", 12

このように、単位付きの値を数値に変換する際に重宝します。

クロスブラウザなDOMサイズ取得

クロスブラウザで問題になるのはDOM関連がほとんどなので、その部分をjQueryやPrototype.jsなどのライブラリに任せれば大抵のケースはなんとかなります。

ただし、ライブラリ任せではいつまでたってもライブラリユーザー止まりなので、本格的に勉強したいならPrototype.jsのソースを読むのがオススメです。(Prototype.jsは他のスクリプトと競合しやすい問題児なので、実際に使うのはあまりお勧めできませんが、コードリーディングには適していると思います)。

DOMの中でもEventについては第1回で触れたので、今回はDOMのサイズ取得周りについて。まずは結論から見てみます。

表示領域の取得標準モード互換モード
縦幅document.documentElement.clientHeightdocument.body.clientHeight
横幅document.documentElement.clientWidthdocument.body.clientWidth
ページ領域の取得標準モード互換モード
縦幅document.documentElement.scrollHeightdocument.body.scrollHeight
横幅document.documentElement.scrollWidthdocument.body.scrollWidth
スクロール量の取得(Safari/Chrome以外)標準モード互換モード
縦スクロールdocument.documentElement.scrollTopdocument.body.scrollTop
横スクロールdocument.documentElement.scrollLeftdocument.body.scrollLeft
スクロール量の取得(IE以外)標準モード互換モード
縦スクロールwindow.pageYOffset
横スクロールwindow.pageXOffset
スクロール量の取得document.documentElement.scrollTopdocument.body.scrollTopwindow.scrollXwindow.pageXOffset
IE6/7/8 OKNGNGOKNGNG
Firefox2/3 OKNGNGOKOKOK
Safari3/4 NG OK OKOK
Chrome2/3 NG OK OKOK
Opera9.6/10OKNGNGOKOKOK

このように、領域の取得はものすごく面倒なことになっています。これでもSafari2やOpera9.2など少し古いブラウザを切った状態です。

基本的には、互換モードならdocument.bodyを、標準モードならdocument.documentElementをルートとして、そこからclientHeight, scrollHeight, scrollTopを取ればOKと言いたいところですが、スクロール量はWebKit(Safari,Chrome)の問題からwindow.pageYOffsetを優先して使う必要があります。

また、標準モードだとdocument.body.scrollHeightの値が取れないのかというとそういうわけではなく、確かにbodyのscrollHeightは取れるのですが、CSSの影響による誤差を含んだ値になるため、見た目上の値と微妙に異なる結果になります。Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);のようなコードが使われていることが多いですが、見た目上の高さに反することもありえます。極端な例ですが、標準モードでページの本来の高さが1500pxなページで、bodyに-500pxの(ネガティブな)トップマージンが設定されていると、見た目では1000pxの高さのページですが、Math.max(document.documentElement.scrollHeight, document.body.scrollHeight);の結果は1000ではなく1500になります。

コードにまとめると、下記の通りです。なお、compatModeによる標準モード・互換モードの判定はdocument.compatModeを使用しているので、IE5.5やSafari2.0などの古いブラウザには対応していません(BackCompatは正規表現で大文字小文字などの細かい差を許容するようにしていますが、おそらくその必要はないと思われます。確証が取れないのでやや曖昧さを残しています。)。

var Root = /BackCompat/i.test(document.compatMode) ?
    document
.body : document.documentElement;
console
.log([
Root.clientHeight,//表示領域の高さ
Root.clientWidth,//表示領域の幅
Root.scrollHeight,//ページ全体の高さ
Root.scrollWidth,//ページ全体の幅
window
.pageYOffset || Root.scrollTop,//現在の縦スクロール量
window
.pageXOffset || Root.scrollLeft,//現在の横スクロール量
]);

コールバック

ここでいうコールバックとは、ある処理の後に別の処理を行うための手続きのことです。ちょっと高機能なJavaScriptではコールバックを多用することになります。(それを簡潔に書くためのライブラリとしてJSDeferredというライブラリもあります。)

ボタンがクリックされたときに、何か処理をしたい場合、シンプルに書くと下記のようになります。

button.onclick = function(){
        alert
('クリックされました');
}

この後、XMLHttpRequestでデータを取得し、レスポンスを受け取ったらその値を元にさらに別の処理をし、と処理を繋げていくと。

button.onclick = function(){
       
var x = window.XMLHttpRequest ?
               
new XMLHttpRequest() :
                window
.ActiveXObject('Msxml2.XMLHTTP');
        x
.open('GET', 'api?p=1', true);
        x
.onreadystatechange = function(){
               
if (x.readyState === 4 && 200 === x.status) {
                        alert
(x.responseText);
               
}
       
}
        x
.send(null);
}

この時、下記のようにalertを挟んでみると、1, 2, responseText, 3の順番で表示されます。

button.onclick = function(){
       
var x = window.XMLHttpRequest ?
               
new XMLHttpRequest() :
                window
.ActiveXObject('Msxml2.XMLHTTP');
        x
.open('GET', 'api?p=1', true);
        x
.onreadystatechange = function(){
               
if (x.readyState === 4 && 200 === x.status) {
                        setTimeout
(function(){
                                alert
(3);
                       
},0);
                        alert
(x.responseText);
               
}
       
}
        x
.send(null);
        alert
(2);
}
alert
(1);

もう少し具体的に書くと、1はScriptが実行されたタイミングでalertする、2はクリックしたときにalertする、responseTextはXMLHttpRequestの読み込みが成功したときに、3はresponseTextの後に表示される、となっています。

このように、クリックなどのイベント、XMLHttpRequestの非同期通信、setTimeout/setIntervalなどの非同期処理があるときの実行順を目で追えるようになるとソースを読んだだけで動きを把握できるようになるのでデバッグ効率が上がります。

そのほか

Date#monthは0オリジン

var now = new Date();
alert
(now.getMonth());//6

scriptタグは閉じタグを省略できません。<script src="hoge.js" />のように書くとFirefoxでページが真っ白になります。

var 忘れによるグローバル汚染もよくあるミスのひとつです。

function log(obj){
       
var r = [];
       
for (k in obj) {
                r
.push(k + ' : ' + obj[k]);
       
}
        alert
(r.join('\n'));
}
log
(location);

for (k in obj) はfor (var k in obj)としなくてはいけません。

サンプルコード

var box = document.createElement('div');
box
.className = 'box';
document
.body.appendChild(box);
box
.style.left = '12px';
var Root = /BackCompat/.test(document.compatMode) ?
                document
.body : document.documentElement;
var Width = Root.clientWidth;
var box2Left = function(x){
       
var left = parseFloat(box.style.left) || 0;
        box
.style.left = (left + x) + 'px';
       
if (left < Width) {
                setTimeout
(function(){
                        box2Left
(x);
               
}, 0);
       
}
}
box2Left
(5);
var box = document.createElement('div');
box
.className = 'box';
document
.body.appendChild(box);
var _x = 12;
var Root = /BackCompat/.test(document.compatMode) ?
                document
.body : document.documentElement;
var Width = Root.clientWidth;
var box2Left = function(x){
        _x
+= x;
        box
.style.left = _x + 'px';
       
if (_x < Width) {
                setTimeout
(function(){
                        box2Left
(x);
               
}, 0);
       
}
}
box2Left
(5);
(function(){
       
var ParentBox = document.createElement('div');
        document
.body.appendChild(ParentBox);
       
var boxes = [];
       
var Root = /BackCompat/.test(document.compatMode) ?
                        document
.body : document.documentElement;
       
var Width = Root.clientWidth;
       
for (var i = 0;i < 20;i++) {
               
var box = document.createElement('div'), style = box.style;
                style
.position = 'absolute';
                style
.width = '10px';
                style
.height = Root.clientHeight + 'px';
                style
.top = (window.pageYOffset || Root.scrollTop) + 'px';
                style
.opacity = 0.2;
                style
.filter = 'alpha(opacity=20)';
               
var rgb = [
                        parseInt
(0xff*Math.random()),
                        parseInt
(0xff*Math.random()),
                        parseInt
(0xff*Math.random())
               
].join(',');
                style
.background = 'rgb(' + rgb + ')';
               
ParentBox.appendChild(box);
                boxes
.push({style:style,b:box,speed:Math.random()*7+3,x:0,w:10})
       
}
       
var dustbox = [], stop = false;
       
var boxesMove = function(){
               
for (var l = boxes.length - 1; 0 <= l; l--) {
                       
var b = boxes[l];
                        b
.x += b.speed;
                        b
.w += (10 - b.speed) * 0.1;
                        b
.style.width = b.w + 'px';
                        b
.style.left = b.x + 'px';
                       
if (b.x > Width-b.w) {
                                b
.style.left = (Width-b.w) + 'px';
                                dustbox
.push(b.b);
                                boxes
.splice(l, 1);
                       
}
               
}
               
if (boxes.length) {
                        setTimeout
(function(){
                                boxesMove
();
                       
}, 0);
               
}
       
}
        boxesMove
();
       
ParentBox.onclick = function(){
                boxes
= [];
                dustbox
= [];
                document
.body.removeChild(ParentBox);
       
}
})();