ESLintの力で秩序あるAtomic Designを後世に残す

Retty Advent Calendar 2018 12日目の記事です。
昨日は @yongyu-li さんの Go interfaceの理解しにくいところ でした。

ちょうど現場でGolang触りはじめたばかりの自分には割とタイムリーな話題でした。

TL;DR

めちゃくちゃ大げさなタイトルですが、単に Atomic Design を導入しようとした現場で

  • 抜け漏れ
  • 運用忘れて形骸化するリスク
  • 運用メンバーがレビューの権威化する

これらの課題を ESLint のルールを作ることで解決しようとした話です
完成した eslint-plugin-atomic-design はこちら

これは「このプロジェクトから俺が居なくなったあと、俺のコードは一片も残らなくていいから、秩序だけでも残ってくれ……!駆動」で開発しました。

前提条件: よくある現場の話

Rettyのweb版は2011/6にリリースされました。
2011年というと MS が IE6 Countdown をリリース したことに象徴されるように、 1
HTML/CSS/JSをなんとか頑張ってたデザイナーやエンジニアたちは
ブラウザの実装や仕様の多様性に苦しんだりしていたような時代でした。

このようなRenderingやJSの実装の多様性を吸収してくれるPolyfillライブラリが
皆さんご存知の、$ すなわち jQuery です。
当時フロントエンド実装の主な課題であったブラウザ実装の多様性は $ によって吸収・解決された一方で、
激しく $ に依存したコードを大量に生み出しました。

時が経ち、「世間一般のWeb application」が日進月歩に進化し、
複雑な要求を満たす「フロントエンド」なる領域が成熟する中で
これら $ 依存プロダクトは運用の限界を迎え、停滞します。

そもそも、 標準化が進んで Polyfill なしの実装でも要件を満たせるようになったりで
$ は、ガッツの首筋に刻まれた 生贄の烙印 よろしく、夜ごとに魔を呼び寄せては
血を流し痛みに耐える、ここまでが 2011年〜2017年 の現場のお話です。


その後フロントエンドチームが勝手に発足し
1年かけてようやく $('.js-element').data()var state に突っ込んで $.toggle していた jQuery をやめて Declarative な DataBinding を用いて VDOM を renderする SPAwebpack して、おまけに SSR な世界に片足突っ込むくらいにまで来たのが現在の現場です。

その過程で、好き勝手に育ちまくった各人の俺流コーディングスタイルや、
特定のコンテキストにのみ適用される即物的なオレオレルールから生み出される混沌と、
それを防ぐための、 "些末な" レビューはreviewer/reviewee双方を疲弊させました。

もはや何が正しいか、誰もわからない
答えなき世界に答えを探さなくてはならない
成長するものと生きるもの
いつも美しさで終わるもの

それは Prettier であり、そして ESLint でした

ルールを作る

課題と手法

そこそこ育ったプロダクトのよくある現場の一つで、
コンポーネント管理がハチャメチャになってきたので、何らかの指針を決めよう、
それじゃあ Atomic Design をベースにルールを作って分割・管理しようという運びになりました。

このときチームメンバーと慎重に協議した結果、 依存を単方向に制限する 必要があることがわかりました。

AtomicDesign

Atomic design is atoms, molecules, organisms, templates, and pages concurrently working together to create effective interface design systems. http://atomicdesign.bradfrost.com/chapter-2/#the-atomic-design-methodology

Atomic Design、また、運用している拡張概念に関しては割愛しますが、
例としては Atoms, Moleculesのような下位コンポーネントの再利用性を守るために、

  • 「Atom(分割できない最小単位)」 が 他のコンポーネントを内包する
  • 「Molecules(再利用前提の単位)」 が 「Organisms(複数の機能を持つ単位)」 を内包する

上記のような、コンポーネント運用を破壊させかねないイレギュラー防ぐ、という狙いがありました。

また、基本的には 下位コンポーネントのみ の利用を許可する、という現場の運用方針だったのですが、
別チームのメンバーに「依存元が依存先と同レベルのコンポーネントを含める場合はどうする」という指摘を受け Atomic Design 本家の定義を見直したところ

Molecules

In interfaces, molecules are relatively simple groups of UI elements functioning together as a unit. http://atomicdesign.bradfrost.com/chapter-2/#molecules

Templates

..., which provides context for these relatively abstract molecules and organisms. http://atomicdesign.bradfrost.com/chapter-2/#templates

Molecules Templates は下位方向への依存のみ定義されている一方で

Organisms

Organisms are relatively complex UI components composed of groups of molecules and/or atoms and/or other organisms. http://atomicdesign.bradfrost.com/chapter-2/#organisms

このように、Organismsother organisms さえ含めることができる定義になっています。 そのため下位方向だけではなく、同レベルの依存も定義できるようにする必要がありました。

利用想定としては、運用するプロジェクトやそれらのフェーズによって、

  • ミニマルな定義として、本家Atomic Designの定義をそのままを使う場合
  • Alternative Atomic Designをさがして にもあるように、現場独自の定義で派生していく場合

どちらも柔軟に対応するニーズが現場にすでにあったのでこれらにも対応する必要があります。


現場では Atomic Design(あるいはその派生) という枠組みをどう運用し、どういう方向で洗練させていくか
実務の Pull Request(以降、PR) を叩きに頻繁に議論されてきました。

というのも、その PR がルールを守っているのかどうかはコードレビューを以て判断されていたからです。
些末なレビューを避け、本質的なものにするためには、機械的に排除できる要因を Linter で処理しておくべきです。

Atomic Design(あるいはその派生)の各コンポーネントは JavaScript としてParseできる形で扱われていることが大半だと思うので、今回 ESLint のルールを作成するに至りました。

ESLintルールとしてコードにする

雛形を生成する

コードベースは古いのですが、ディレクトリ構成や決まりきった書き方が多いので
Create a Plugin に倣って eslint/generator-eslint を使って scaffold します。

  1. 作者名
  2. プラグイン名
  3. プラグイン名の概要
  4. ルールが必要かどうか

    • 大抵の場合はルールを作るために使うはずなので Yes でしょう
  5. (必要があれば) 非JSを処理するためのpre processorや、
    そこからエラー位置を実ファイルにmappingし直すのに使うようなpostプロセッサが必要かどうか

を聞かれるので準備しておきましょう。

$ npm i -g yo generator-eslint

$ yo eslint:plugin

? What is your name? ryonkmr
? What is the plugin ID? atomic-design
? Type a short description of this plugin: ESLint rules for Atomic Designed projects
? Does this plugin contain custom ESLint rules? Yes
? Does this plugin contain one or more processors? No

仕様を検討する

要件は決まっているので、詳細な仕様を決めます。

  • 予め設定でプロジェクトで登場するコンポーネントレベルを定義する

    • デフォルトでは本家の atoms, molecules, organisms, templates, pages
    • 現場の事情的にこの定義が変更可能であることが必須
  • (Atomic Design なディレクトリ構成を採用している前提で) filepath からコンポーネントのレベルを決定する

    • e.g. ./src/components/atoms/Hoge.** なら atoms が決定する
  • import文 (または require() )2 で他のコンポーネントを使用するときにvalidateする

specs さえ決まってしまえば、あとは実装するだけなので簡単です。eslint.RuleTester で UnitTest を実行しながら実装を進めていきます。

ただし今回は仕様が filepath 依存してたりで specs の書き方がまるでわからなかったので、最初は実行できる環境を devDependencies 扱いで作ってしまって、都度実行しながら実装を進めました。

実装する

現在実行中のcontextから依存元のpathを得る

これは簡単で、create 時の引数に渡ってくる context から
context.getFilename() を叩くと処理中のファイルの fullpath が取れます。

  create(context) {
    const currentPath = context.getFilename();
    // ...
  },

AST(ESTree) から依存先のpathを得る

import は文なので ImportDeclaration として Node の特定と依存pathの値の取得が容易な一方で、

parseImport
const parseImportSource = node =>
  (node && node.source && node.source.value) || null;

require() は 単なる関数呼び出しなので、 CallExpression から Node の特定を行った上で
引数として依存pathを得るため少し複雑になります。

parseRequire
const parseRequireSource = node =>
  (
    node
    && node.callee.name === 'require'
    && node.arguments.length
    && node.arguments[0].type === 'Literal'
    && node.arguments[0].value
  ) || null;

Tree構造は AST Explorer と睨めっこしながら戦いました。

AST Explorer

※画像の補足: 当初 require()VariableDeclarator (変数宣言) と勝手に思い込んでいて、
そこから Nodeを 特定するのに困難を極めたのですが CallExpression (関数呼び出し式) を直接取れるのに気づいて、3
完全に徒労ですありがとうございました 😞

eslint-plugin-importalias に対応する

現場の環境では eslint-plugin-importeslint-import-resolver-alias を使用しているため、
これらに対応する必要がありました。

eslint-plugin-importのresolver をそのまま使いたかったので、 別パッケージとして提供されている eslint-module-utils を使います。

eslint-module-utils/resolve
// https://github.com/benmosher/eslint-plugin-import/blob/1cd82eb27df85768fbd076e4ff6b7f36d6f652ce/utils/resolve.js#L181-L189
/**
 * Given
 * @param  {string} p - module path
 * @param  {object} context - ESLint context
 * @return {string} - the full module filesystem path;
 *                    null if package is core;
 *                    undefined if not found
 */
function resolve(p, context) {

このように、 resolveしたい pathStringcontext を渡すだけで import/resolve 周辺の設定込みでpath resolutionしてくれるので楽です。

const resolvedPath = require('eslint-module-utils/resolve').default(sourcePath, context);
if (
  !resolvedPath ||
  excludeRegExps.some(regexp => regexp.test(resolvedPath))
) {
  return;
}

上記コードでは、 resolveできない、もしくは resolve後のpathがexclude対象に含まれる場合に早期リターンしてます。

ルール設定の型を定義する

ルール設定自体は仕様検討したものから煩雑にならないように決定しました。

  • levels: コンポーネントレベルの定義

    • 同レベルに複数の定義をするために: Array<String|String[]>

      • ['element', 'context', ['something', 'anything']]
    • 更に、 organisms のように同レベルを含めることができるレベルの表現に: prefixでoption

      • ['atom', 'molecules', '=organisms', 'templates', 'pages']
  • excludes:

    • ['node_modules\/\\w']

      • atoms みたいなライブラリ使うときに引っかかったら嫌だから除外
      • 逆に空配列(明示的なEmpty表現)を設定することで、Atomic Designed な UI libraryとの連携なども想定
  • pathPatterns:

    • 設定なしの場合: fileのfullpath末尾から機械的にmatchするmatcherを用意

      • 基本はこれで十分なはず
    • 任意の正規表現テキスト: String[]

      • e.g. ['src/components/(\\w+)/', 'lib/ui-components/(\\w+)/']
      • 明示的に設定したいとき用:
        アトムズさんが /Users/atoms/<PROJECT>/src/... で作業したときに、
        デフォルトの挙動によって無関係のファイルまで atoms になったら嫌だから……

よくありそうなシンプルな例:

{
  "levels": ["grand-children", "children", ["=men", "=women"], "gods"]
}

複雑なプロジェクトの例:

{
  "levels": ["element", "context", ["something", "anything", "container"], "pages"],
  "excludes": [],
  "pathPatterns": [
    "src/components/(\\w+)/", 
    "node_modules/ui-components/components/(\\w+)/"
  ]
}

levelsObject にしてしまうのも、拡張性の面では有利なのですが、
いかんせん煩雑になりやすいので、今回は文字列配列前提で、二次元にできるくらいが
(個人的感覚で)視覚的にもわかりやすい限度かな、という判断をしました。

ただ、このあたり、一度決めてしまうと中々動かせない4という
互換性の問題がついて回るので慎重に検討しています。

設定内容さえ決まってしまえば、型が決定するので
そこから JSON Schema で定義してます。

options
  schema: [
    {
      type: 'object',
      properties: {
        excludes: {
          type: 'array',
          default: DEFAULT_EXCLUDES,
          items: { type: 'string' },
          uniqueItems: true,
        },
        levels: {
          type: 'array',
          default: DEFAULT_LEVELS,
          uniqueItems: true,
          items: {
            anyOf: [
              { type: 'string' },
              {
                type: 'array',
                items: { type: 'string' },
                uniqueItems: true,
              },
            ],
          },
        },
        pathPatterns: {
          type: 'array',
          items: { type: 'string' },
          uniqueItems: true,
        },
      },
    },
  ],

これも rule.schema の値になる部分を AST 同じく JSON Schema Validator に突っ込んで確認しながら作りました。

JSON Schema Validator

ちょっとTricky気味なのはStringとArrayを許容するArrayの表現くらいですね

完成

こうして ESLintプラグインである eslint-plugin-atomic-design と、
ルールの実装 eslint-plugin-atomic-design/hierarchical-import が完成しました。

Node.js ES2015 Support をざっと眺めた感じ、v8.x 以降では
Array.prototype.values に気をつければ transpiler かます必要もなく ES6 できそうだったので、素の cjs として書きました。

そもそもコードベースが小さいのでBuild時間は気にならないとは思いますが、
開発初期から babel, babel-plugin について何も気にしなくて良い分
快適・手軽で楽しく開発できた、というのが率直な感想です。

ESLintの公式DocはAPIリファレンス的な側面が強く、
いざ開発するぞとなったときの取っ掛かりを見つけるのが難しかったです。
これは、実装したいことが似てそうだったり、ある程度複雑そうな既存のルールにあたりをつけて、
ESLintのDoc内で検索しては、各ルール下のResourcesからGitHubのsourceを見るのが一番参考になりました。

現在はルールがたった1つのみですが、今後もし追加できそうなルールがあれば随時更新します。
よかったら是非 npm i -D eslint-plugin-atomic-design して
みなさまの現場にも秩序あるAtomic Design(あるいはその派生)を後世に残してみてください。

現場からは以上です。


  1. IEの中身であるTrident(または MSHTML)、その後継のEdgeHTMLが開発終了となり Mozilla から悼まれる 現在とは対照的ですね

  2. ここまで書いてて気づいたのですが、 Dynamic import() 対応を忘れてました……いつか需要が出たらやります

  3. ESLint自体がAST辿っている、ということはすべてのNodeを知ってるわけで、そりゃそうですよね

  4. Breaking Changeにしてしまってmajor version upするのも手だけど、たかが設定で流石にやりすぎなので