はじめに
富士通アドベントカレンダー2018の10日目の記事です。公開が遅れてしまい大変申し訳ありません。🙇♂️
C, Java, Pythonの開発からクライアント/サーバサイド双方のJavaScriptでの開発に移ってここ2年間くらいで学んできて、これは知っておいた方がいいなと思ったことをざっくりとまとめてみます。 1 これ1つ読めばざっくりとわかるものになれば幸いです。
誤りや追加情報ありましたら優しいマサカリご指摘をコメントでいただけると助かります。
開発環境と実行環境
はじめに知っておいた方がいいと思うことに開発環境と実行環境があります。
単純なブラウザアプリやNode.jsアプリを作る時は単純ですが、Reactを使ったSPAなどを作る時はビルドが必要になってきて開発環境と実行環境がわかりづらくなります。 2 また、Babel, Webpack, npmなどの見慣れない用語も飛び交ってわかりづらいので整理して把握する必要があります。
実行環境
JavaScriptを動かす環境は主にブラウザかNode.jsがあります。
ブラウザはChrome, FireFox, Edge, Safari, Internet Explorer(以降IE)などたくさん種類があります。ブラウザの場合、htmlファイルの中でscript要素を使うとJavaScriptコードは実行できます。(コードはMDNの例を引用)
https://developer.mozilla.org/ja/docs/Web/HTML/Element/Script#Basic
<!-- HTML4 -->
<script type="text/javascript" src="javascript.js"></script>
<!-- HTML5 -->
<script src="javascript.js"></script>
Node.jsの場合、Node.jsをインストールした状態で以下コマンドを実行するとJavaScriptコードを実行できます。
node javascript.js
実行環境の種類・バージョンの組み合わせによってサポートされる言語仕様が変わってくるので、作るものを最終的にどこで動かす必要があるかは最初に考える必要があります。特にIEは他のChrome, FireFoxなどと違う挙動を示し、かつIEのバージョンによっても変わってくるので、どこまでサポートするかは考慮が必要です。
また、クライアントサイドのフロントエンドアプリを作るときにはサーバーサイド実行環境であるNode.jsが一見関係しないように思えますが、必要になってくることが最近は多いです。詳しくはビルドの項で書きます。
JavaScriptバージョン
JavaScriptの仕様の経緯は昔色々あったみたいですが、ECMAScript(ES)という標準仕様にまとめられています。
ECMAScriptのバージョンは主にES3, ES5, ES6(ES2015)があり、ES2015以降はES2016, ES2017と毎年更新されていくようです。
ES2015は特に仕様が大きく刷新され、使い心地がよくなったのでこれから新規に開発するならES2015以降がベターです。
ECMAScriptの仕様がどこまでブラウザやNode.jsでサポートされているかは以下に書かれています。
ES6で導入された構文には以下のようなものがあります。
代表的なES6の構文とES6以前との比較
// 変数宣言
a = 'aa'; // ES3: 何もつけないとグローバル変数!!
var b = 'bb'; // ES3: スコープは関数内(他言語のようなブロック内ではない)
let b = 'bb'; // ES6: スコープはブロック内
const c = 'cc'; // ES6: 再代入不可にする場合はletでなくconstを指定
// 関数宣言
function a() { return 'a'; } // ES3
const a = () => { return 'a'; }; // ES6: =>の書き方はアロー関数と呼ばれる
const a = () => 'a'; // ES6: 1行でreturnするときはreturnを省略可能
// 文字列連結
var s = 'Hello, ' + str + '!'; // ES3
const s = `Hello, ${str}!`; // ES6
// オブジェクトの分割代入(ES3)
var a = {foo: 'bar'};
var foo = a.foo;
// オブジェクトの分割代入(ES6)
const a = {foo: 'bar'};
const {foo} = a;
// 配列の分割代入(ES3)
var a = [0, 1];
var b = a[0];
var c = a[1];
// 配列の分割代入(ES6)
const a = [0, 1];
const [b, c] = a;
他にも後で説明するimport/export, Promise, classなどがあります。
require/module.exports, import/export構文による他モジュールの読み込み
JavaScriptはブラウザ上でHTMLの<script>
要素で読み込むことを考えられていたので、JavaScriptのコード上から他のJavaScriptを読み込むことが考えられていませんでした。規模が小さい内はまだしも規模が大きくなってくるとこの方法だけでモジュールの分割、外部モジュールの利用が大変になっていきます。そこで、これらをできるようにする動きがいくつか出てきました。
その中の1つのCommonJSは、元々ブラウザーだけで動くJavaScriptをサーバーサイドなど他の領域にも使えるように仕様を策定するもので、CommonJSでのモジュールの定義と読み込み方法はNode.jsに実装されています。Node.jsでモジュール読み込みを行うときは以下のように書きます。
// CommonJS: 読み込まれる側(mod.js)
module.exports.foo = 'foo';
exports.bar = 'bar'; // module.exportsとexportsは同じ
// CommonJS: 読み込む側(main.js)
const {foo, bar} = require('./mod'); // mod.jsを読み込む、ES6の分割代入の書き方も併用
console.log(foo); // 'foo'
console.log(bar); // 'bar'
ややここしいことに、CommonJSでの読み込み方法とは別にECMAScriptでモジュール読み込み方法が策定・実装されています。ECMAScriptでは以下のように書きます。
// ES6: 読み込まれる側(mod.js)
export const foo = 'foo';
export const bar = 'bar';
// ES6: 読み込む側(main.js)
import {foo, bar} from './mod'; // mod.jsを読み込む、ES6の分割代入の書き方も併用
console.log(foo); // 'foo'
console.log(bar); // 'bar'
Node.jsのバージョン6以降を使えばほとんどのES2015の機能をそのまま使えますが、モジュールの読み込み方法だけは基本的にCommonJSの書き方で書く必要があります。
なお、Node.jsのバージョン8以降だとESの書き方で動かす方法もあるようですが、現状拡張子をmjsにしてnodeの起動オプションを追加する必要あるそうです。
Node v9 で ES Module import を使ってみる
npm
Node.jsをインストールするとnpm(node package manager)と呼ばれるパッケージマネージャーもインストールされ、nodeコマンドと同時にnpmコマンドも実行できるようになります。これはJavaScriptで作られたモジュールの配布と入手などを行う管理ツールで、OSSの既存JSモジュールをインストールするときなどに使います。プロジェクトの情報やそこで使用するJSモジュールの一覧はpackage.json
というファイルに記述します。package.jsonの作成は以下コマンドで作成します。
npm init
例えば、ExpressというJSフレームワークを使いたいときは以下コマンドを使います。
npm install --save express
このコマンドを実行するとnode_modules
というディレクトリにExpressと依存モジュールがインストールされます。--save
オプションをつけることでpackage.jsonは以下のようにdependenciesのところにexpressが追加されます。
{
"name": "test",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"express": "^4.16.4"
}
}
node_modulesにインストールしたものはNode.jsで実行する場合、requireを使って以下の書き方でコード上で使用することができます。
const express = require('express');
const app = express();
app.get('/', (req, res) => {
res.send('Hello World');
})
app.listen(3000);
既存のJSプロジェクトにpackage.jsonがある場合、--saveオプションなしでnpm install
することでdependenciesの項目に書かれているJSモジュール群を全てnode_modulesにインストールして、そのプロジェクトを動かすのに必要なJSモジュールを揃えることができます。
ビルド
JavaScriptはインタプリタ言語なのでコンパイルなしで実行できますが、最近のフロントエンドはビルドを行うことが多いです。主な理由として次のようなものがあります。
- require/importを使ったモジュール読み込みを行いたい(豊富なNode.jsエコシステムのOSSも含めてブラウザで動かせるようにしたい)
- ブラウザ上JS+CSS+画像などのリソースをまとめてHTTPリクエストを減らし、高速化したい
- 実行するブラウザがサポートする機能より進んだ機能の言語で開発したい
- 最小化や難読化を行いたい
1.に関してはモジュールバンドラーと呼ばれるWebpackといったJSモジュールによって、import/requireを解消して結合したJSに変換して、ブラウザで実行できるコードにできます。 3 Webpackはモジュールバンドルすることがメインの機能ですが、他のビルドでやりたいことも設定や拡張で行えます。
2.のようなCSS/画像をまとめてJSの中に組み込むこともWebpackでは行えます。
3.に関しては最新のECMAScriptのコードやReactで使われるJSX記法、JS互換のあるTypeScriptといった言語で開発し、どのブラウザでもだいたい動くECMAScript5に変換(トランスパイル)することを行います。
最新のECMAScriptのコード->ECMAScript5の変換はBabelというJSモジュールで行うことができ、Webpackではbabel-loaderといったloaderを組み合わせることで、モジュールバンドルに加えてトランスパイルすることができます。
4.に関してはWebpackのプラグインや設定で最小化と難読化を行えます。
まとめると、ビルドでは以下のようなことを行います。
- ソースのトランスパイル
- リソースのバンドル化
- 最小化や難読化
npm-scripts
アプリの起動、テスト実行、ビルド実行などのタスクを実施することは頻繁にあると思います。これらを実行する仕組みがnpm-scriptsとしてnpmの機能に組み込まれています。
package.jsonの中の"scripts"の中で定義します。(下のpackage.jsonは関係ある部分のみ記載)
{
"scripts": {
"start": "node foo.js",
"test": "mocha foo.test.js",
"build": "webpack"
},
"devDependencies": {
"mocha": "^5.2.0",
"webpack": "^4.28.0",
"webpack-cli": "^3.1.2"
}
}
この状態でnpm start
をコマンド実行するとnode foo.js
が実行されます。
同様にnpm test
をコマンド実行するとmocha foo.test.js
が実行されます。mochaコマンドはnpm install
を実行した状態であればnpm-scriptsを通して実行可能になります。npm install
を実行するとnode_modulesの中にdevDependenciesに記載したモジュールもインストールされ、node_modules/.bin/
の中にmochaコマンドやwebpackコマンドの実体が存在します。
startやtestは予約語になっているのでnpm start
, npm test
で実行できますが、予約語以外の単語を記載した場合はnpm run build
のように実行します。
より複雑なタスクを実行させるためには、gulp, grunt, あるいは古くからあるmakeコマンドを使って行うことがあります。
非同期プログラミング
JavaScriptを扱う上でハマりやすいところとして、非同期プログラミングがあります。Java, Pythonなどでも非同期を扱う方法はあると思いますが、APIは基本的には同期的に動くものがほとんどです。それに対してJavaScriptでは基本的に非同期で実行されます。
const fs = require('fs');
// Node.jsのファイル読み込みAPIの違い
// Sync(同期)とわざわざ書いてあるAPIは同期的に動く
const data = fs.readFileSync('./a.js', 'utf-8');
console.log(data); // ファイル内容を表示
// 何も書いていなく、callback関数を受け取るものは基本非同期で実行される
fs.readFile('./a.js', 'utf-8', (err, data) => {
if (err) throw err;
console.log(data); // ファイル内容を表示
});
console.log('(ここはファイル読み込みが完了する前に実行される');
非同期で実行されるAPIは処理が完了するときに実行する関数(コールバック)を引数の一番最後に受け取ることが多いです。コールバックの変数名はcallback, cb, done, nextなどが使われます。慣習的にコールバックの第1引数はErrorオブジェクトを与えて、それがあるかどうかでエラーが起きたかどうかを判断します。(JavaScriptでもJavaのようにtry-catchの機構はあるものの非同期APIに対してはエラーを捕捉できません。)
コールバックを受け取る非同期関数の問題点はそれらを複数順次実行しようとするとコードのネストが深くなって可読性が悪くなるというものです。いわゆるコールバック地獄と呼ばれるものです。
const fs = require('fs');
// ファイルを4連続で順番に読み込む関数を定義
// 後続の処理になるにつれてネストが深くなる
const readFilesSerially = callback => {
fs.readFile('./a.js', 'utf-8', (err, data) => {
if (err) return callback(err); // エラーの場合、処理を中断して第1引数にエラーを渡す
console.log('=== a.js ===');
console.log(data);
fs.readFile('./b.js', 'utf-8', (err2, data2) => {
if (err2) return callback(err2);
console.log('=== b.js ===');
console.log(data2);
fs.readFile('./c.js', 'utf-8', (err3, data3) => {
if (err3) return callback(err3);
console.log('=== c.js ===');
console.log(data3);
fs.readFile('./d.js', 'utf-8', (err4, data4) => {
if (err4) return callback(err4);
console.log('=== d.js ===');
console.log(data4);
callback(); // 正常に完了した場合、何も渡さずに呼び出す
});
});
});
});
};
// 上記関数の呼び出しと完了時の処理を行う関数を渡す
readFilesSerially(err => {
if (err) throw err; // エラー処理をここで行う
console.log('Done.');
});
コールバック地獄を解消する方法は色々考えられていますが、次のようなものがあります。(他にもあるはず)
- async.js
- ES2015のPromise
- ES2015のgenerator
- ES2017のasync/await
一番書きやすいのはasync/await
と思いますが、それを使えるようにするためにはPromise
も知る必要があります。この2つについて紹介します。
// Promiseを使った例
const fs = require('fs');
// 自分でPromiseを返すコードに変換
function readFile(...args) {
return new Promise((resolve, reject) => {
fs.readFile(...args, (err, data) => {
if (err) {
reject(err);
return;
}
resolve(data);
});
});
}
// util.promisifyを使うと既存のコールバックを受け取る関数を
// Promsieを返す関数に変換できる
// const util = require('util');
// const readFile = util.promisify(fs.readFile);
// 後続の処理が増えてもネストが深くならない
readFile('./a.js', 'utf-8')
.then((data) => {
console.log('=== a.js ===');
console.log(data);
return readFile('./b.js', 'utf-8');
})
.then((data) => {
console.log('=== b.js ===');
console.log(data);
return readFile('./cc.js', 'utf-8');
})
.then((data) => {
console.log('=== c.js ===');
console.log(data);
return readFile('./d.js', 'utf-8');
})
.then((data) => {
console.log('=== d.js ===');
console.log(data);
return;
})
.then(() => {
console.log('Done.');
})
.catch((err) => {
console.error(err); // エラー処理をここで行う
});
// async/awaitを使った例
const fs = require('fs');
const util = require('util');
const readFile = util.promisify(fs.readFile);
// awaitは
// - asyncの関数内でのみ使用できる
// - Promiseを返す関数に対して使用できる
// 後続の処理が増えてもネストが深くならない
const main = async () => {
try {
const data1 = await readFile('./a.js', 'utf-8');
console.log('=== a.js ===');
console.log(data1);
const data2 = await readFile('./b.js', 'utf-8');
console.log('=== b.js ===');
console.log(data2);
const data3 = await readFile('./c.js', 'utf-8');
console.log('=== c.js ===');
console.log(data3);
const data4 = await readFile('./d.js', 'utf-8');
console.log('=== d.js ===');
console.log(data4);
console.log('Done.');
} catch(err) {
console.error(err);
}
};
main();
Promise, async/awaitのみ知ってればよさそうな感じもありますが、callbackを受け取るAPIに遭遇することもあるので現状一応知っておいた方がよいと思います。
その他
その他まとめきれないことを1つずつ述べていきます。
オブジェクト
オブジェクトと呼ばれるkey-value形式の集合が扱えます。
const obj = {
key1: 'value1',
key2: 'value2',
};
// 呼び出し方法は2種類
console.log(obj.key1); // value1
console.log(obj['key1']); // value1
オブジェクトは簡単に扱える一方、キー部分は文字列である必要があったり、列挙するときに順番が保証されないなどの問題があります。ES6からはMapというこれらの問題を解消して扱えるものも登場しています。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Map
ReferenceErrorとundefined
変数が宣言されていない時はReferenceError、宣言されているものの値が代入されていないときはundefinedとなります。
console.log(a); // ReferenceError: a is not defined
// 宣言されているが代入されてない場合にundefinedとなる
let a;
console.log(a); // undefined
// 関数の引数を与えずに実行した場合、その引数の変数はundefinedとなる
const f = (a) => { console.log(a); };
f(); // undefined
const obj = {
key: {
foo: 'foooo',
}
};
console.log(obj.key.foo); // foooo
// objのkey名をタイポすると悲惨なことになる
console.log(obj.kiy); // undefined
console.log(obj.kiy.foo); // TypeError: Cannot read property 'foo' of undefined
なお、明示的に存在しないことを表す時はundefinedではなく、nullを使います。
thisの扱い
thisが実行される文脈によって表わすものが変わります。詳しくは以下の記事がわかりやすいです。
インスタンス化とプロトタイプチェーン
JavaScriptはプロトタイプベースのオブジェクト指向の仕組み4を持っていて、Javaのようなクラスベースのプログラミング言語と比べるとその仕組みは一見独特に見えます。
詳しくはここに書ききれないですが、以下記事などが詳しいです。
ポイントだけ書くと以下の通りです。
- Javaのようなクラスと同じような定義を書くにはES6以前だと複雑な書き方になる
- インスタンスのプロパティの呼び出すときは継承元のprototypeオブジェクトをたどってそのプロパティを探しにいく(プロトタイプチェーンと呼ばれる)
- ES6で導入されたclass構文を使うとJavaと同じような書き方をできる
- ES6のclass構文の書き方をしても中身は旧来のプロトタイプチェーンのやり方のまま
- プライベートなプロパティは少し苦労しないと作れない(ECMAScriptで提案されてるようですが。。)
// method1, method2はA.prototypeに定義される
class A {
method1() {
console.log('A: method1');
}
method2() {
console.log('A: method2');
}
}
// method1, method3はB.prototypeに定義される
class B extends A {
method1() {
console.log('B: method1');
}
method3() {
console.log('B: method3');
}
}
// Bのインスタンスを作成
const b = new B();
// 以下のプロトタイプチェーンをたどって
// prototypeオブジェクトの中から指定のプロパティを探しにいく
// b.__proto__ -----------------> B.prototype
// B.prototype.__proto__ -------> A.prototype
// A.prototype.__proto__ -------> Object.prototype
// Object.prototype.__proto__ --> null
// それぞれのメソッドを呼び出す
b.method1(); // B.prototypeにmethod1を発見
b.method2(); // B.prototypeにmethod2がなく、A.prototypeにmethod2を発見
b.method3(); // B.prototypeにmethod3を発見
console.log(b.method4); // B, A, Objectのprototypeにmethod4は定義されてない
// 結果は以下の通り
// B: method1
// A: method2
// B: method3
// undefined
for文
for, for-in, for-ofの構文と配列のforEachメソッドがあります。
// よくある基本的なfor文
let sum = 0;
for (let i = 0; i < 5; i++) {
sum += i;
}
console.log(sum);
// for-inの書き方
// この例だと問題ないものの、prototypeのプロパティを列挙するので
// 使い方に注意が必要
sum = 0;
const array = [0, 1, 2, 3, 4];
for (let i in array) {
sum += array[i];
}
console.log(sum);
// forEachの書き方(ES5で導入)
sum = 0;
array.forEach((element) => {
sum += element;
});
console.log(sum);
// for-ofの書き方(ES6で導入)
// この例だとありがたみがわからないけれど、for-inで列挙されてしまう
// prototypeのプロパティを列挙しない
sum = 0;
for (let i of array) {
sum += array[i];
}
console.log(sum);
4種類のforがある一方、配列にmap, filter, sort, reduceなどがあるのでfor文を使わない方法もあります。
const array = [0, 1, 2, 3, 4];
// reduceを使うことで、そもそもfor文を使わない
sum = array.reduce((sum, current) => sum + current);
console.log(sum);
グローバルオブジェクトとグローバル変数
JavaScriptではグローバルオブジェクトというものが定義されていて、そのオブジェクトのプロパティに組み込みの変数・関数が定義されていたり、グローバル変数が定義されます。ブラウザだとwindow
で、Node.jsだとglobal
です。
// 以下ブラウザでの実行を想定
// windowのプロパティにあるものは`window.`をつけなくても最初から利用できる
console.log('Hello, JS!');
window.console.log('Hello, JS!');
// グローバル変数はwindowオブジェクトのプロパティとなる
myGlobalVariable = 'global';
console.log(myGlobalVariable === window.myGlobalVariable); // true
==と===の違い
厳密な型の違いを判定する場合==
ではなく===
を使います。===
の否定形は!==
です。
console.log(1 == '1') // true
console.log(1 === '1') // false
自分の場合、変な解釈をしてバグを作ってしまうのを避ける意味で===
を使うことが多いです。
$や_といった変数名のオブジェクト
慣習的に$はjQueryを参照するオブジェクトを表す、_はUnderscore.jsやLoadashを参照するオブジェクトを表すことが多いです。
即時関数
無名関数を定義すると共に即座に実行することができます。
// functionを使った場合の書き方2種類(丸かっこの位置が違う)
(function(){
console.log('Hello, World!');
})();
(function(){
console.log('Hello, World!');
}());
// アロー関数を使った書き方
(() => {
console.log('Hello, World!');
})();
// アロー関数だとこの書き方はSyntaxErrorとなる
(() => {
console.log('Hello, World!');
}());
// asyncとアロー関数を組み合わせた場合の書き方
(async () => {
// この部分でawait構文が使える
console.log('Hello, World!');
})();
最後に
JSがとっかかりづらいところは
- 開発環境と実行環境
- 非同期処理
- その他細かい独自の部分
また、学習するときにしんどいのは
- 移り変りが早く、新旧の情報が混在して惑わされる (微妙にcallbackとか古くても知っといた方がよいこともある)
- OSSのJSモジュールが豊富すぎて覚えるのが大変
と思いました。そのあたりを乗り越えればJavaScritをそこそこ扱えるようになるのではないかと思います。
最後に1年に1回実行したくなるコマンドで締めくくります。
2年前は最後の呼びかけがNoders!
だったのがnpmの6で実行するとJavaScripters!
になっています。フロントでもNodeが使われてるてことですね。