<< 戻る

index.tsxのときはフォルダ名を、それ以外の場合はファイル名をスニペットで利用したい[With VSCode]

ハローワールド。

本題に入る前に今回の背景について説明。

Reactでコードを書いていると色々と困るのがファイル構成。Reactの公式の見解としては特に無いとのことです。

React はファイルをどのようにフォルダ分けするかについての意見を持っていません

それぞれプロジェクトにより千差万別と言えますが、本題に入る前に今回採用したファイル構成を紹介します。

Reactのファイル構成

以前、私はComponent.tsxを作ったら同じ階層にComponent.scssComponent.test.tsxComponent.stoires.tsxを作成するようなルールにしていました(Testing、Storybookを採用した場合)。

上記ルールの問題は、一つの階層にコンポーネント数 * 4のファイルが出来上がるのですごく見通しが悪くなります。
テストファイルやStorybookファイルを別フォルダに分け、更にCSS in JSを採用すれば実質解決しますが、どれも一長一短ではあるのでここでは上記の方法を利用したいと考えました。

そのため、今回はフォルダをそれぞれ作って、その中にindex.tsxindex.scss…を作るルールにしました。Webpackのルールではフォルダ名を指定すると、自動的にindex.jsを探してくれるので[1]Component/index.jsComponent指定でimportできるので名前の指定自体は以前と変わりません
またその中でしか利用しない子コンポーネントは同じ階層に入れる用にもしています。

例えば、LayoutHeaderは中にTitleLogoコンポーネントを利用しています。今回はLayoutHeaderコンポーネント内でしか使われないと仮定します。
そうした場合、下記のようなフォルダ構成になります。

LayoutHeader/ |-index.scss |-index.stories.tsx |-index.test.tsx |-index.tsx |-TitleLogo.scss |-TitleLogo.stories.tsx |-TitleLogo.test.tsx |-TitleLogo.tsx

スニペット

上記のファイル構成で今回アプリケーションを作成していきました。

ここでReact、Testing、Storybookを新しいコンポーネントを作るたびに作成し、更に既存のファイルからコピペしていることに気づきました。
流石に作業の時間が無駄なので、スニペットを使おうと思いましたが、今あるスニペットのライブラリは後術の理由であまりお気に召さなかったので、新しく作ろうと考えました。

スニペットで作成したいファイル内容

今回はワンコマンド(ワンスニペット)で下記のファイルを作成したいのです(例えばReact関数コンポーネントの場合)。

Component/index.tsx
import React from 'react'; import './index.scss'; interface Props {} export const Component: React.FC<Props> = (props) => { return <div></div>; };

ファイル内容に関しては人それぞれだと思いますが、特に欲しいのはコンポーネントをnamed exportにする部分です[2]。default exportでは別に名前なんてなんでも良いですが、名前付きエクスポートではコンポーネントを表す名前を付けたいです(Buttonとか、Calendarとか)。そうすることでVSCodeの自動インポートが機能するのですごくDeveloper Experienceが高いです。
まぁ、import { Component } from '@/src/Component'みたいな気持ち悪い感じになっちゃいますけどね。

ではここで、Componentの中だけで使うOtherコンポーネントを作りたくなりました。

OtherComponentはスニペットをバーンとやるだけでこうなってほしいですね。

Component/Other.tsx
import React from 'react'; import './Other.scss'; interface Props {} export const Other: React.FC<Props> = (props) => { return <div></div>; }

大きな違いは、named exportの部分です。先程は「フォルダ名」を利用していましたが、今回は「ファイル名」をとっています。

つまり、ファイル名がindex.tsxであれば「フォルダ名を」、それ以外の場合は「ファイル名」から拡張子をとった名前にしてあげる必要があります。

完成品

結論から言うと下記のスニペットで可能です。

typescriptreact.json
{ "React Functional Component": { "prefix": "reafunc", "body": [ "import React from 'react';", "import './${2:$TM_FILENAME_BASE}.scss';", "", "interface Props {}", "", "export const ${1:${TM_FILEPATH/.*[\\/\\\\]([^\\/\\\\]+)[\\/\\\\]index\\.tsx$|.*[\\/\\\\](.*?)(?:\\.[^.]*)$/$1$2/}}: React.FC<Props> = (props) => {", " return <div></div>;", "}", "", ] }, }

実際に動作しているのはこんな感じ。

React用のスニペット、indexバージョン

React用のスニペット、Otherバージョン

上側はindex.tsx、下側はOther.tsxで行っていますが、エクスポートの部分がちゃんと想定通りになっていますね。

解説

VSCodeのスニペットでは様々な変数が利用可能です。具体的には公式の変数一覧ページを参考にしていただければいいですが、今回は$TM_FILEPATHが活躍します。これはファイルパスが格納されています。

また、${変数名/from/to/g}正規表現を利用した置換が可能です。今回はこの正規表現を利用して置換します。

今回はindex.tsxの場合はフォルダ名を、それ以外の場合はフォルダ名を取得する正規表現を作成することで解決できそうです。
なので、今回は下記のような正規表現を作成しました。

.*[\/\\]([^\/\\]+)[\/\\]index\.tsx$|.*[\/\\](.*?)(?:\.[^.]*)$

Brainf*ckかな?

正規表現の可視化サイトを利用してわかりやすくしてみましょう。

今回利用したサイトはRegexperです。

Regexperの結果

まぁ簡単に行ってしまえば、末尾がindex.tsxであれば上に、そうでなければ下にマッチする正規表現です。

ここで、groupを見てみると、上のgroup#1はindex.tsxの手前の\か/以外の文字列の連続にマッチします。すなわちindex.tsxの親フォルダが取得できます。
下のgroup#2は拡張子より前のファイルの名前にマッチします。

VSCodeではグループを$1$2という感じで利用できます。そして、group1がマッチしたとき$2はマッチしていないので空文字、逆も同様なので$1$2とやると欲しい物が得られます。

更に、VSCodeでは\をエスケープしてやらないといけません。

これを踏まえて再度上記のスニペットを見てみましょう(一部抜粋です)。

"import React from 'react';", "import './${2:$TM_FILENAME_BASE}.scss';", "", "interface Props {}", "", "export const ${1:${TM_FILEPATH/.*[\\/\\\\]([^\\/\\\\]+)[\\/\\\\]index\\.tsx$|.*[\\/\\\\](.*?)(?:\\.[^.]*)$/$1$2/}}: React.FC = (props) => {", " return
;", "}", "",

また、これを活用すると、storybookや

Other.stories.tsx
import React from 'react'; import { Other as Component } from './Other'; export default { title: '', }; export const Other = () => <Component />;

testなんかも

Other.test.tsx
import React from 'react'; import { shallow } from 'enzyme'; import toJson from 'enzyme-to-json'; import { Other } from './Other'; describe('Other', () => { test('is rendered', () => { const wrapper = shallow(<Other />); expect(wrapper).toBeTruthy(); expect(toJson(wrapper)).toMatchSnapshot(); }); });

スニペット一つで生成できます。

上記設定は100%私がプロジェクトで利用している設定ですが、一応上記のスニペットも下記に記載しておきます。

typescriptreact.json
{ "React Functional Component": { "prefix": "reafunc", "body": [ "import React from 'react';", "import './${2:$TM_FILENAME_BASE}.scss';", "", "interface Props {}", "", "export const ${1:${TM_FILEPATH/.*[\\/\\\\]([^\\/\\\\]+)[\\/\\\\]index\\.tsx$|.*[\\/\\\\](.*?)(?:\\.[^.]*)$/$1$2/}}: React.FC<Props> = (props) => {", " return <div></div>;", "}", "", ] }, "React Test with jest and enzyme": { "prefix": "jest", "body": [ "import React from 'react';", "import { shallow } from 'enzyme';", "import toJson from 'enzyme-to-json';", "import { ${2:${TM_FILEPATH/.*[\\/\\\\]([^\\/\\\\]+)[\\/\\\\]index\\.test\\.tsx$|.*[\\/\\\\](.*?)(?:\\.test\\.[^.]*)$/$1$2/}} } from './${1:${TM_FILENAME_BASE/\\.test//g}}';", "", "describe('${2:${TM_FILEPATH/.*[\\/\\\\]([^\\/\\\\]+)[\\/\\\\]index\\.test\\.tsx$|.*[\\/\\\\](.*?)(?:\\.test\\.[^.]*)$/$1$2/}}', () => {", " test('is rendered', () => {", " const wrapper = shallow(<${2:${TM_FILEPATH/.*[\\/\\\\]([^\\/\\\\]+)[\\/\\\\]index\\.test\\.tsx$|.*[\\/\\\\](.*?)(?:\\.test\\.[^.]*)$/$1$2/}} />);", " expect(wrapper).toBeTruthy();", " expect(toJson(wrapper)).toMatchSnapshot();", " });", "});", "", ] }, "React Story book": { "prefix": "story", "body": [ "import React from 'react';", "import { ${2:${TM_FILEPATH/.*[\\/\\\\]([^\\/\\\\]+)[\\/\\\\]index\\.stories\\.tsx$|.*[\\/\\\\](.*?)(?:\\.stories\\.[^.]*)$/$1$2/}} as Component } from './${3:${TM_FILENAME_BASE/\\.stories//g}}';", "export default {", " title: '$1',", "};", "export const ${2:${TM_FILEPATH/.*[\\/\\\\]([^\\/\\\\]+)[\\/\\\\]index\\.stories\\.tsx$|.*[\\/\\\\](.*?)(?:\\.stories\\.[^.]*)$/$1$2/}} = () => <Component />;", "", ] } }
  1. 実際は色々ルールが存在するようです。またECMAScriptの仕様ではありません。ちなみにですが、近年のWebサーバーではディレクトリを指定すると勝手にindex.htmlへリダイレクト(参照)してくれますが、index.htmlをディレクトリのデフォルトにしたのは1993年頃が最初らしいです↩︎

  2. javascriptではエクスポートの方法がデフォルトエクスポートと名前付きエクスポートの2つあります。Reactコンポーネントは大体の場合デフォルトエクスポートになっていると思いますが、VSCodeのデフォルトで名前付きエクスポートのみが自動インポートの対象になっているようなので名前付きエクスポートを利用しています。 ↩︎