babel-preset-env を試す

2017年3月29日 0 開発 , , ,

npm として配布するものは純粋な Node 機能のみで構成したいため脱 Babelしたが、Web フロントエンドや Electron では最新の ECMAScript 機能を利用したい。

というわけで、これまでは Babel + babel-preset-latest で JavaScript を変換してきた。しかし latest だと Web ブラウザーや Electron が最新規格に対応しても個別に変換を無効化するのが難しい。

例えば ES2015 Classes は大半の Web ブラウザーが対応済みにも関わらず

var Sample = function () {
  function Sample() {
    _classCallCheck(this, Sample);
  }
}

のように変換されてしまう。一方、機能単位で変換を無効にできるとしても Web ブラウザー毎の対応状況を調べるのは実に面倒。ECMAScript 6 compatibility table をマメにチェックしながら下限となる環境を決める必要がある。

こうした悩みを解決するツールとして babel-preset-env が提供されている。用途を latest と比較すると以下のようになる。

preset 用途
latest 最新 ECMAScript を常時 ES5 相当に変換。
env 最新 ECMAScript を指定された環境に基づき最小限 ES5 に変換。

対象環境を考えるのが面倒で変換コストや品質を気にしないなら latest、なるべく無駄な変換をなくしたいなら env を採用することになるだろう。

試してみる

babel-preset-env を試してみる。

Web フロントエンドや Electron だと Babel 単体よりも Browserify + babelifywebpack + babel-loader のように bundler と組み合わせて利用する機会が多い。しかし bundler 部分も含めると Babel が変換した結果に限定してチェックするのが難しいため実行には babel-cli を採用することにした。

変換対象として ES2015 と ES2016 の代表的な機能を使用した 2 種類の JavaScript を定義。まずは sample.js

// ES2015
export default class Sample {
  message (text) {
    console.log(text)
  }

  async asyncFunc () {
    const wait = (n) => {
      return new Promise((resolve) => setTimeout(() => resolve(n), n))
    }

    await wait(1000)
    console.log('finish!!')
  }

  static func (text = 'sample') {
    console.log(text)
  }
}

// ES2016: Exponentiation Operator
export function pow (a = 0, b = 0) {
  return a ** b
}

// ES2016: Array.prototype.includes
export function includes (arr = [], value) {
  return Array.isArray(arr) ? arr.includes(value) : false
}

sample.js を参照する index.js

import Sample, { pow, includes } from './sample.js'

{
  const sample = new Sample()
  sample.message('Message1')
}

Sample.func('Message2')

console.log(pow(2, 3))
console.log(includes([1, 2, 3], 2))

これらを babel-preset-env の README に掲載されている Examples と現時点で最新の Electron v1.6 向け設定で変換する npm-scripts を定義した。Babel の設定も含めて package.json で完結しているため長いけれど全掲載する。

{
  "name": "using-babel-preset-env",
  "version": "1.0.0",
  "description": "",
  "author": "akabeko",
  "license": "MIT",
  "main": "index.js",
  "keywords": [
    "babel-preset-env"
  ],
  "repository": {
    "type": "git",
    "url": "https://github.com/akabekobeko/examples-web-app.git"
  },
  "babel": {
    "env": {
      "default": {
        "presets": [["env"]]
      },
      "chrome52": {
        "presets": [
          ["env", {
            "targets": {
              "chrome": 52
            }
          }]
        ]
      },
      "chrome52webpack_loose": {
        "presets": [
          ["env", {
            "targets": {
              "chrome": 52
            },
            "modules": false,
            "loose": true
          }]
        ]
      },
      "browserslist": {
        "presets": [
          ["env", {
            "targets": {
              "chrome": 52,
              "browsers": ["last 2 versions", "safari 7"]
            }
          }]
        ]
      },
      "node_current": {
        "presets": [
          ["env", {
            "targets": {
              "node": "current"
            }
          }]
        ]
      },
      "debug_output": {
        "presets": [
          ["env", {
            "targets": {
              "safari": 10
            },
            "modules": false,
            "useBuiltIns": true,
            "debug": true
          }]
        ]
      },
      "include_exclude_plugins_buildin": {
        "presets": [
          ["env", {
            "targets": {
              "browsers": ["last 2 versions", "safari >= 7"]
            },
            "include": ["transform-es2015-arrow-functions", "es6.map"],
            "exclude": ["transform-regenerator", "es6.set"]
          }]
        ]
      },
      "electron": {
        "presets": [
          ["env", {
            "targets": {
              "electron": 1.6
            }
          }]
        ]
      }
    }
  },
  "scripts": {
    "start": "run-s build",
    "build:default": "cross-env NODE_ENV=default babel ./src --out-dir ./dist/default",
    "build:chrome52": "cross-env NODE_ENV=chrome52 babel ./src --out-dir ./dist/chrome52",
    "build:chrome52webpack_loose": "cross-env NODE_ENV=chrome52webpack_loose babel ./src --out-dir ./dist/chrome52webpack_loose",
    "build:browserslist": "cross-env NODE_ENV=browserslist babel ./src --out-dir ./dist/browserslist",
    "build:node_current": "cross-env NODE_ENV=node_current babel ./src --out-dir ./dist/node_current",
    "build:debug_output": "cross-env NODE_ENV=debug_output babel ./src --out-dir ./dist/debug_output",
    "build:include_exclude_plugins_buildin": "cross-env NODE_ENV=include_exclude_plugins_buildin babel ./src --out-dir ./dist/include_exclude_plugins_buildin",
    "build:electron": "cross-env NODE_ENV=electron babel ./src --out-dir ./dist/electron",
    "build": "run-s build:*"
  },
  "devDependencies": {
    "babel-cli": "^6.24.0",
    "babel-preset-env": "^1.2.2",
    "cross-env": "^3.2.4",
    "npm-run-all": "^4.0.2"
  }
}

Babel の設定は NODE_ENV 単位に定義できることを利用して cross-env により分岐している。npm start を実行すると dist/ 配下に設定ごとの変換結果が出力される。普通に指定するなら presets 配下の内容を Babel 設定のルートに記述すればよい。例えば .babelrc に Electron 用の設定を定義するなら以下のようにする。

{
  "presets": [
    ["env", {
      "targets": {
        "electron": 1.6
      }
    }]
  ]
}

サンプルの出力結果を比較すると、例えば default なら全変換され chrome52webpack_loose だと async/await 以外はそのままであることが分かる。また chrome52 では async/await 変換があり electron だと v1.6.0 から Chromium 56.0.2924.87 を採用しているため async/await はそのままに Modules は共通して変換されていた。

babel-preset-env の README によれば、変換の基準は前述の ECMAScript 6 compatibility table を基準として判断しているのだという。つまり機能と対象環境の組み合わせ管理を babel-preset-env に丸投げできるわけだ。本当に ECMAScript 6 compatibility table や Electron のバージョンをチェックしているのか?と targets にデタラメな値を設定してみたら実行時にエラーとなった。Electron に関しては 1.0 以降が対象で最新の 1.6 系まで指定可能。

ただし targets のバージョンは JSON の number になるため小数点第一位までしか対応しておらず 1.6.2 のように semver 形式は受け付けていない。また "node": "current" は可能だが "electron": "current" を指定したらエラーになった。Electron も browsers のように相対値で指定したいものだ。

とはいえ babel-preset-latest を使用しつつ「将来 ES2015 変換をやめたくなったらどうしよう?」などと考えていたので、そのような処理を動作環境の指定へ抽象化して変換してくれる babel-preset-env は実にありがたい。

babel-preset-env 設定により想定している動作環境が明示される点もメリット。これはアプリの仕様を外部へ説明するのに役立つ。また設定を Git リポジトリなどで管理しているなら動作環境の変遷も記録される。例えば IE サポートを打ち切るとして、その履歴をきちんと残せるのだ。

最後に動作確認で使用したサンプル プロジェクトを公開しておく。

JavaScript Standard Style を試す

2017年2月17日 0 開発 , ,

話題の JavaScript Standard Style を試してみた。

背景

以前、以下の記事とはてブで JavaScript Standard Style を知った。

はてブではセミコロンの省略に抵抗感のある人が多く、私もそうだった。しかし同はてブで id:mysticatea さんが指摘されているように ESLint の no-unexpected-multiline でセミコロン省略時に問題のおきるコードを検出できる。また、

  • 昨年末に Swift 入門してセミコロンのないコードに慣れた
  • Electron の JavaScript コードがセミコロンなしルールで読みやすかった

という理由もあり、セミコロンなしも案外よいものじゃないかと考えるようになった。

私のコーディング スタイルは世間の標準からみると独特で、これは過去に在籍していたプロジェクトのルールを踏襲している。主な特徴としては

  • 括弧の内側にスペースを入れる
  • ifwhile などのキーワードと関数名の後にはスペースを入れない

というもの。C 言語や JavaScript でよく見られる K&R 系だとこんな感じのコードも

function isArray (arg) {
  if (Array.isArray) {
    return Array.isArray(arg);
  }

  return Object.prototype.toString.call(arg) === '[object Array]';
}

私のスタイルだとこうなる。

function isArray( arg ) {
  if( Array.isArray ) {
    return Array.isArray( arg );
  }

  return Object.prototype.toString.call( arg ) === '[object Array]';
}

これはこれで気に入っていたのだが GitHub で OSS を運用にするようになり、第三者からの PR は一般的な方のスタイルでくるため扱いに困っていた。スタイルの違いを理由に断るとか直すのも面倒なので今はそのまま merge しているけれど、そもそも自分の好みより世に迎合するほうがよいんじゃないか?と思い始めた。

あと Xcode の Editor におけるコーディング スタイル設定が貧弱というのもある。他の IDE だと私のスタイルを再現するのに十分な設定があるためそうしてきた。しかし Xcode の Text Editing は驚くほど設定がない。まともにいじれるのは Indentation ぐらいである。

これまでは仕方なく根性で手動整形してきたが、Swift 入門を機にあきらめた。iOS で SQLite – FMDB の使い方 2017のサンプルでは Xcode の提示するスニペットそのままに書いている。

この経験を経て、自分のスタイルへ固執することをやめることにした。プラットフォーム標準があればそれに従い、IDE や Editor、Linter の補助を最大限に享受する方針へ転換する。

というわけで、まずは公私ともに書く機会の多い JavaScript のコーディング スタイルから変更してみる。

JavaScript Standard Style

JavaScript のコーディング スタイルとしては

あたりが有名どころらしい。どれを選ぶか迷ったが Electron のようなセミコロンなしスタイルを採用している JavaScript Standard Style にしてみた。Standard と銘打つ度胸と GitHub の star 数も判断材料である。セミコロン以外はよく見るスタイルなので、ここを受け入れられるかが重要。

JavaScript Standard Style への準拠にあたり、それを保証する仕組みがほしいので ESLint を利用。今回は akabekobeko/npm-wpxml2md プロジェクトで試す。

feross/eslint-config-standard を参考にプロジェクトのローカルに必要な npm をインストール。

$ npm i -D eslint-config-standard eslint-plugin-standard eslint-plugin-promise

次にプロジェクトのルートで .eslintrc を定義。

{
  "extends": "standard",
  "env": {
    "mocha": true
  },
  "rules": {
    "no-multi-spaces": 0,
    "yoda": 0
  }
}

JavaScript Standard Style を使用するだけなら "extends": "standard" だけでよい。しかし mocha で書いたユニット テストも対象にしたいのと、

  • 連続した複数行の変数宣言などで縦位置をスペースで揃えたい
  • if 文で不等号による範囲チェックを if (0 <= value && value < max) のように書きたい

のでそれらの設定を追加した。JavaScript Standard Style はスタイルに準拠していることを示す証として

Standard - JavaScript Style Guide

というバッヂを提供している。ルール緩和した場合でもこれをつけてよいものか迷ったけれど緩和は極小なので README へ掲示することにした。第三者が README をながめたとき、基本となるコーディング スタイルを視認できるのはよいことだ。

私は JavaScript のコーディングに Atom を使用しており、ESLint によるリアル タイムなチェックのため

を採用している。これまで linter-eslint はグローバルにインストールした ESLint とプラグインを使用して設定も ~/.eslintrc を参照するようにしていたが、このプラグインはプロジェクトのローカルに ESLint と .eslintrc を検出するとそちらを優先してくれる。

そのため既存プロジェクトは現行のスタイルを維持しつつ、個別に JavaScript Standard Style を採用する運用が可能である。いきなりグローバルを書き換えてもよいけど、少しずつ移行するほうが安全だろう。

スタイルのチェックは基本的に Atom 上で確認 & 修正するのだがファイル単位で個別に作業していると抜けも出やすいため、一括チェック可能な仕組みも用意する。私は npm-scripts に

{
  "scripts": {
    "eslint": "eslint ./src"
  }
}

を定義して

$ npm run eslint

を実行している。これは AltJS/AltCSS の transpile のようにバックグラウンドでファイル変更の検出と自動チェックさせるほうがよいのかもしれない。

セミコロンなき世界

旧スタイルから JavaScript Standard Style へこのように書き換えてみた。これらの中で比較的、短めのコードを引用する。

#!/usr/bin/env node

'use strict'

const CLI = require('./cli.js').CLI
const WpXml2Md = require('../lib/index.js')

/**
 * Entry point of the CLI.
 *
 * @param {Array.<String>} argv   Arguments of the command line.
 * @param {WritableStream} stdout Standard output.
 *
 * @return {Promise} Promise object.
 */
function main (argv, stdout) {
  return new Promise((resolve, reject) => {
    const options = CLI.parseArgv(argv)
    if (options.help) {
      CLI.printHelp(stdout)
      return resolve()
    }

    if (options.version) {
      CLI.printVersion(stdout)
      return resolve()
    }

    if (!(options.input)) {
      return reject(new Error('"-i" or "--input" has not been specified. This parameter is required.'))
    }

    if (!(options.output)) {
      return reject(new Error('"-o" or "--output" has not been specified. This parameter is required.'))
    }

    return WpXml2Md(options.input, options.output, {
      noGFM: options.noGFM,
      noMELink: options.noMELink,
      report: options.report
    })
  })
}

main(process.argv.slice(2), process.stdout)
.then()
.catch((err) => {
  console.error(err)
})

実にスッキリ。見慣れるまでは JavaScript に見えないかもしれない。

これまで C 言語系の構文をもつプログラミング言語に慣れ親しんできたためセミコロン入力は手癖になっていたけど、いざ不要になるとこれがどれだけ負担だったかを認識させられる。

はじめは、ほんの 1 文字だしプログラミングでは書くより考える時間のほうが長いのだから気にするほどのことか?と考えていた。しかし ; + EnterEnter に置き換わることは、実際に体験してみると実に大きい。正確に構文の末尾へセミコロンを置くことと、単に改行するだけというのはかなり違う。セミコロンなし派が一定数いる意味を身をもって知った。

なおセミコロン省略により起き得る問題は前述のように no-unexpected-multiline が検出してくれる。実際の eslint-config-standard/eslintrc.json でも "no-unexpected-multiline": "error" と設定されているため安心だ。

所感

JavaScript Standard Style 導入の所感をまとめる。

  • 一般的な JavaScript と自身のコードを交互にながめても違和感をおぼえにくくなった
  • Atom のスニペットをそのまま利用できるようになった
  • 括弧のスペースを詰めてもそれなりに読める
  • まともな Editor なら構文強調のおかげで括弧とそれ以外を区別しやすいので困らない
  • セミコロンなしはスッキリしてかなり読みやすい
  • セミコロンを入力するのがどれだけ手間だったか実感できる

結論。JavaScript Standard Style は素晴らしかった。今後、他のプロジェクトでも採用する予定。

ESDoc の設定を package.json に定義する

2017年1月9日 0 開発 ,

これまで ESDoc の設定は esdoc.json に定義していたが昨年末にリリースされた v0.5.0 から他の形式もサポートされるようになった。CHANGELOG.md には

  • .esdoc.json in current directory
  • .esdoc.js in current directory
  • esdoc property in package.json

とある。これらの内、とくに嬉しいのは 3 番目の package.json。私はプロジェクト設定をこのファイルへ集約する派であり Babel や Browserify もそうしている。

実際に ESDoc 設定を package.json へ定義して npm-scripts から呼ぶ場合は以下のように記述する。

{
  "esdoc": {
    "source": "./src/js",
    "destination": "./esdoc",
    "test": {
      "type": "mocha",
      "source": "./test"
    }
  },
  "scripts": {
    "esdoc": "esdoc"
  }
}

既に esdoc.json を利用しているならその内容を package.jsonesdoc プロパティにコピペして元ファイルを削除すればよい。esdoc コマンドの引数を省略して実行すると自動的にそれを読んで処理してくれる。

試しに以下のプロジェクトへ設定を反映し、動作することを確認済み。

なんでも package.json にまとめると肥大化して見通しが悪くなるという意見もあるだろう。しかしこのファイルを直に編集する機会は滅多にないし、設定が分散するよりも集約したほうが個人的には便利だと思う。

10 行ぐらいに収まる設定ならそうしたい。例えば ESDoc や Babel などの設定は大抵、短いからそうする。逆に ESLint は長くなりがちだから分離しておきたい。