お知らせ

現在サイトのリニューアル作業中のため、全体的にページの表示が乱れています。
投稿日:
言語::TypeScriptNode.js::npmNode.js::JestNode.js::ESLint

ローカルで動作するNode.jsのライブラリ(node_modules)欲しくないですか?欲しいですよね?という訳で作ってみました。

要件としてはTypeScriptで実装出来、Jestでテスト可能で、ESLintでLintが可能というところです。

確認環境

Env Ver
@swc/cli 0.1.62
@swc/core 1.3.93
@swc/jest 0.2.29
@types/jest 29.5.5
@types/node 20.8.6
@typescript-eslint/eslint-plugin 6.7.5
@typescript-eslint/parser 6.7.5
eslint 8.51.0
eslint-config-prettier 9.0.0
eslint-plugin-jest 27.4.2
jest 29.7.0
jest-watch-typeahead 2.2.2
prettier 3.0.3
ts-jest 29.1.1
typescript 5.2.2
Node.js 20.8.0
npm 10.1.0

サンプル実装

https://github.com/Lycolia/ts-library-example

実装について

フォルダ構成

monorepoでmainからlibrary/singleやlibrary/multi/hoge, piyoを参照するような構成です。

root
└─packages
    ├─library // ライブラリ側
    │  └─packages
    │      ├─multi // 複数ファイルのライブラリ
    │      │  └─src
    │      │      └─utils
    │      │          ├─hoge
    │      │          └─piyo
    │      └─single // 単一ファイルのライブラリ
    │          └─src
    └─main // ライブラリを使う側
        └─src
            └─libs

実装時のポイント

これが全てというわけではないと思いますが、一旦今回作ったもののポイントを解説していきます。利用側がtscを利用しない場合、ライブラリ側と利用側で構成は別々になります。

またこの実装はエディタにVSCodeを利用し、importするパスが相対パスであることを前提に説明しています。

ライブラリ側のポイント
ビルドに関して

まずビルドに関してはtscでやります。

これはTypeScriptで開発するためにはビルド成果物として.d.tsファイルが必要になるのと、Jestを通すためにビルド成果物がCommonJS形式(以下CJS)である必要があるためです。CJSが吐けるなら何でもいいとは思いますが、.d.tsも必要になるので、tscを使うのが無難な選択肢だと思います。

テストファイルを出力したくないので、tsconfig.jsonはビルドと開発で分けます。

またtsconfig.jsonは各ワークスペースのルートに置いて置く必要があります。これはTypeScriptがtsconfig.jsonのパスを起点として動作するためです。

ビルド用の設定ポイントとしては以下の通りです。

  • "compilerOptions"
    • "module": "NodeNext"
      • これがないとパス解決が上手く行きません
    • "moduleResolution": "NodeNext"
      • これがないとパス解決が上手く行きません
    • "declaration": true,
      • .d.tsの出力に使います
    • "outDir": "./dist",
      • ビルド成果物の出力先です
  • "exclude": ["src/**/*.spec.ts"]
    • ビルド時にテストファイルは不要なので無視します
  • "include": ["src/**/*"]
    • 参照するソースコードです
Jestに関して

ビルドにtscを使うため、Jestのローダーとしてもts-jestを利用します。公式ではbabelが推奨されているようですが、構成方法が不明だったので諦めました。BabelはJest公式の案内通りにやっても多分上手く行きません。

jest.config.jsには以下の設定を追加します。

preset: 'ts-jest'
開発用の設定について

開発とビルドでtsconfig.jsonを分けるので、開発用のも必要です。これはビルド用から"exclude": ["src/**/*.spec.ts"]を抜くだけです。

ライブラリを外部参照させる方法

基本的にはpackage.jsonに外部参照させるための定義を書くことによって行います。

この辺りの仕様はdocs.npmjs.comにはなく、nodejs.orgにあります。

単一ファイルの外部参照

単一ファイルを外部参照(import出来るように)するときに使う手法です。

importimport { hoge } from '@my-lib/example'みたいな書き方をしたい時に必要になるやつです。

以下の様にビルド成果物の.jsのパスをpackage.jsonに追加してやると出来るようになります。

"main": "./dist/index.js",

以下の書き方でも同様に可能です。

"exports": {
  ".": {
    "default": "./dist/index.js"
  }
},

上記には他にtypesというフィールドがあり、本来ここに.d.tsを追加するのですが、TypeScriptが解決してくれるので、なくても動きます。一応ない場合はファイル探索を行うようなので、あった方が少しだけパフォーマンスが上がるかもしれません。

複数ファイルの外部参照

複数ファイルを外部参照(import出来るように)するときに使う手法です。

基本的に何もしなくてよいですが、import { hoge } from '@my-lib/example'みたいな書き方もしたい場合は以下の記述が必要です。

"main": "./dist/core/index.js"

ここで指定していないものはimport { hoge } from '@my-lic/example/dist/hoge'みたいにして参照します。distがダサくて嫌な場合は適当な名前に変えます。

参考までに@actions/githubはバージョン6.0.0時点でdistに相当する部分をlibにしており、import { Context } from '@actions/github/lib/context';の様にして参照するようになっています。

exportsを使ってdistを隠すことも出来るとは思うのですが面倒なので試してません。

ライブラリ側を利用する側のポイント

今回利用する側はビルドにswcを使う想定ですが、たぶんなんでも動くと思います。参考までにswc-loader + webpackでも動きました。

開発用の設定について

tsconfig.jsonに以下の設定があれば恐らく最低限大丈夫だと思います。

  • "compilerOptions"
    • "module": "NodeNext"
      • これがないとパス解決が上手く行きません
    • "moduleResolution": "NodeNext"
      • これがないとパス解決が上手く行きません
  • "include": ["src/**/*"]
    • 参照するソースコードです
ライブラリのインストール方法について

以下のように相対パスを指定するとインストールできます。

npm i ../package/library/package/single

消すときはパッケージ名を指定すれば消せます。

npm un @lycolia/library-example-single

試したけどダメだったやつら

色々してる過程で試行錯誤した名残。

ライブラリ側のJestでbabelを使おうとやったこと

以下の二通りの設定は試しましたが、どっちもダメだったので諦めました。何よりtscを使うならts-jestの方が楽なのは確定的に明らかですし…。

presets: [
  ['@babel/preset-env', {targets: {node: 'current'}}],
  '@babel/preset-typescript',
],
presets: [
  ['@babel/preset-env', {targets: {node: 'current'}, modules: 'commonjs'}],
  '@babel/preset-typescript',
],

ライブラリ側をswcでビルド

.swcrcを以下の設定にしても

"module": {
  "type": "commonjs"
},

以下の出力がされるため、jest.spyOn()が上手く動かない(getが邪魔でhelloが見れない

"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
Object.defineProperty(exports, "hello", {
  enumerable: true,
  get: function() {
    return hello;
  }
});
const hello = (param)=>{
  console.log(param);
};

どの道これでは.d.tsが出せないので、あんま意味ないなと…。

参考にしたもの

実装方法は@actions/githubが一番単純で参考になると思います。

実装はビルド成果物とビルド前のコード、package.jsontsconfig.json辺りがどうなっているのかを参考にしました。

ESM化が叫ばれて久しいですが、未だにJestはESMとCJSが混在したコードを処理してくれません。

Getting StartedにもSWCに対する言及がないので、きっともう忘れられているのでしょう。swc-project/jestの方も特にやる気はなさそうだし、やりたければ自分でPR書きましょうって感じだと思います。きっと。

確認環境

node_modules配下にESMで作られたモジュールが存在し、コードはTypeScript、トランスパイルにはSWCを利用する。

Env Ver
@swc/cli 0.1.62
@swc/core 1.3.92
@swc/jest 0.2.29
@swc/register 0.1.10
jest 29.7.0
typescript 5.2.2

やったけど意味がなかったこと

  • package.jsontypemoduleにする
  • jest.config.jstransformIgnorePatternsにESMモジュールのパスだけ除外する設定を書く
  • 上記に加えてtransform.jsc.pathpkg-name: ['node_modules/pkg-name']を追記する
  • node --experimental-vm-modules node_modules/jest/bin/jest.jsで実行する
    • 多少マシになったがコケるものはコケる
  • ESMで書かれたモジュールを丸ごとモックする
    • 一切効果なし

所感

多分Webpackでバンドルしてnode_modulesの中身も外も関係ない状態にするのが一番無難なのではないかと思いました。

Node.jsの組み込みテストランナーにすれば解決するかな?と思ったものの、こちらは現状SWCでは使えそうにないので諦めました。
参考までに以下のコマンドで走らさせられます。

node --require @swc/register --test ./src/**/*.spec.ts

取り敢えずESMに引っかったモジュールはCJS時のバージョンを維持しておくことにしましたが、このままだとSWC使えないし、なんとかなって欲しいですね。Webpack使えば解決できるのはわかるんですが、このために使いたくないので、テストを重視する場合、Vitestを持つViteが有力候補になって来そうです。

2024-02-17追記

esbuild + Node.js built-in test runnerの組み合わせであればテストはできるが肝心の実行ができず無意味だった

undefinedの判定方法が複数あるということでundefined判定の処理速度比較をしてみたのでその結果。

端的に言うと、hoge === undefinedtypeof hoge === 'undefined'の二方式がある。後者は原則考慮不要だが、言語仕様上存在しているので比較したが、現実的に見た場合、どちらで記述した場合でも処理速度に有意な差はないように感じた。

確認環境

Env Ver
Node.js 20.1.0
TypeScript 4.9.5
@swc/core 1.3.8

比較結果

hoge === undefinedの方が早く見えるが実行するタイミングで変わるので誤差の範疇だと思う。

方式 ms
hoge === undefined 4,514
typeof hoge === 'undefined' 4,515

確認コード

const tyof = (param?: string) => {
  return typeof param === 'undefined';
};

const undef = (param?: string) => {
  return param === undefined;
};

const tyStart = +new Date();
for (let i = 0; i < 10000000000; i++) {
  tyof();
}
console.log('typeof', +new Date() - tyStart);

const unStart = +new Date();
for (let i = 0; i < 10000000000; i++) {
  undef();
}
console.log('undefined', +new Date() - unStart);

TSから生成されたJS

"use strict";
Object.defineProperty(exports, "__esModule", {
    value: true
});
const tyof = (param)=>{
    return typeof param === 'undefined';
};
const undef = (param)=>{
    return param === undefined;
};
const tyStart = +new Date();
for(let i = 0; i < 10000000000; i++){
    tyof();
}
console.log('typeof', +new Date() - tyStart);
const unStart = +new Date();
for(let i = 0; i < 10000000000; i++){
    undef();
}
console.log('undefined', +new Date() - unStart);

あとがき

MDNを読む限りtypeof hoge === 'undefined'は該当変数が存在しない場合に有用なようであるが、TypeScriptで書いている場合、通常このようなコードが生まれることがなく、仮に起きるとした場合、次のようなコードになるため現実的に考慮する必要はない。なおMDNにも「こんなことはしないこと」と書いてあるので、一般的なコードでないことは客観的にも伺えるだろう。

(() => {
  const undefined = 123;
  const hoge = undefined;

  if (typeof hoge === 'undefined') {
    console.log('hoge is undefined');
  } else {
    console.log('hoge is not undefined');
  }
})();

上記コードの実行結果としてはhoge is not undefinedが出力される。

このコードの主な問題点

  1. const undefined = 123;というコードは予約語を変数名にしているため、混乱を招くコードであり、書かないことが好ましい
    1. MDNには予約語ではないとあるが、一般的には予約語の一つとして解釈して支障ないと考える
  2. このコードはESLintのeslint:recommendedで検知されるため、通常であれば書かれることはない
    1. no-shadow-restricted-namesに引っかかる

なお、このコードは例示のために即時実行関数形式で記述しているが、必要がない限りこの形式での実装は避けたほうが問題が少なくなると思う。これは不必要なネストが生まれたり、スコープの混乱を生むためである。

投稿日:
言語::MarkdownNode.js::Prettier

2023-07-05にPrettier 3.0がリリースされて大分経ったので個人的に気になったところのまとめ。Highlights~Breaking Changesの部分だけ書いてます。Other Changesまで書くと長すぎるので…(Other Changesはざっと見た感じ概ね可読性が向上する感じの内容だと思いましたが、行が動くので書き方によってはコードや、行ベースで動作しているCIの検査が壊れる可能性が場合によってはありそうです。)

変更点

気になったところしか書いてないのでちゃんと見たい人は本家のリリースノートを見てください

日本語と西洋文字の間に半角スペースを挿入しない様に

Stop inserting spaces between Chinese or Japanese and Western characters

恐らく日本人的にはこれが今回最大の変更点になると思います。かなり賛否両論があると思いますが、私は肯定派です。むしろ半角スペースが入るようになって以来、これが来る日をずっと待ちわびていた。

今までは全角文字と半角文字の間に半角スペースが挿入されていましたが、これがなくなります。これは可読性の観点では悪くない処理だとは思うのですが、ドキュメントとしてみた場合に検索性が非常に悪く個人的に好きではありませんでした。

<!-- 入力値 -->
漢字
Alphabetsひらがな12345カタカナ67890

<!-- Prettier 2.8 -->
漢字 Alphabets ひらがな 12345 カタカナ 67890

<!-- Prettier 3.0 -->
漢字Alphabetsひらがな12345カタカナ67890

なお、この変更には後方互換性がなく、今までのように半角スペースを挿入することはできなくなります。

また、新しい方式に既存のドキュメントを合わせる場合、Prettierが挿入したこの半角スペースを自動で綺麗に除去するのは難しいと思います。探せばあるかもしれませんが、恐らくtextlintの除去ルールでは不完全だと思います。私が試した限りでは見出しやリンクタイトル辺りでは上手く機能してくれませんでした。ただ、ある程度はtextlintが解決してくれるので、大まかにはtextlintを使い、細かい部分は手修正していくのが無難かなと思います。

設定ファイルでESM形式をサポート

https://prettier.io/blog/2023/07/05/3.0.0.html#support-config-files-in-esm-13130httpsgithubcomprettierprettierpull13130-by-fiskerhttpsgithubcomfisker

今まではCJS形式で書く必要があったconfigファイルでESM形式がサポートされたようです。但し拡張子が.jsである場合はpackage.json{"type": "module"}を指定する必要があります。

<!-- Prettier 2.8 -->
module.exports = {
  // ...
}

<!-- Prettier 3.0 -->
export default {
  // ...
};

trailingComma設定の初期値をes5からallに変更

https://prettier.io/blog/2023/07/05/3.0.0.html#change-the-default-value-for-trailingcomma-to-all-11479httpsgithubcomprettierprettierpull11479-by-fiskerhttpsgithubcomfisker-13143httpsgithubcomprettierprettierpull13143-by-sosukesuzukihttpsgithubcomsosukesuzuki

これは正直あまり良く解っていないのですが、Trailing Commasを読んだ感じ、恐らく関数の仮引数の最後にカンマが入るかどうかだと思われます。つまり以下のような感じです。

<!-- 入力値 -->
function HelloWorld(greeting = "hello", greeted = '"World"', silent = false, onMouseOver) {
}

<!-- trailingComma: es5 -->
function HelloWorld(
  greeting = "hello",
  greeted = '"World"',
  silent = false,
  onMouseOver // カンマが入らない
) {}

<!-- trailingComma: all -->
function HelloWorld(
  greeting = "hello",
  greeted = '"World"',
  silent = false,
  onMouseOver, // カンマが入る
) {}

上記の結果はこちらのPlaygroundで確認できます。(--trailing-commaの設定値をいじると比較できます)

個人的にはこの修正は余り歓迎できないですね…。というのも不要なカンマが入っていると引数の数を見間違えたり、ケアレスミスの原因になると思いますし、いたずらにコードが長くなるからです。

今まで深く考えたことがありませんでしたが、trailingCommanoneが例外がなく、統一感があり、コードが短くなり、ケアレスミスが減ると思うため無難だと思いました。noneにしている場合、単純に一番最後の要素にカンマが入らなくなります。

BabelパーサーからFlow構文のサポートを削除

https://prettier.io/blog/2023/07/05/3.0.0.html#remove-flow-syntax-support-from-babel-parser-14314httpsgithubcomprettierprettierpull14314-by-fiskerhttpsgithubcomfisker-thorn0httpsgithubcomthorn0

これによりPrettierの動作パフォーマンスが上がったようです。

デフォルトで.gitignoreのファイルを無視するように変更

https://prettier.io/blog/2023/07/05/3.0.0.html#ignore-gitignored-files-by-default-14731httpsgithubcomprettierprettierpull14731-by-fiskerhttpsgithubcomfisker

.prettierignoreではなく.gitignoreを見るようになったようです。これもなんとも微妙な変更だと思います…。どっちかといえば.eslintignoreを見てもらいたいような気もするような…。従前のようにしたい場合はCLIのオプションに--ignore-path=.prettierignoreを渡してやると行けるようです。

関連記事

投稿日:
Node.js::Prettier言語::Markdown

Prettier の次期 Major release である v3 がそろそろ出そうなので、今回含まれる大きな変更の一つについての記事です。

内容としてはMarkdown文書の全角文字と半角英数の間にスペースが挿入されなくなります。個人的にこれは相当賛否が分かれると思うので、v3が出た後の対処方法も併せて書いておきます。

変更点

以下は漢字Alphabetsひらがな12345カタカナ67890という文字列が渡された時の変化例です。

v3 より前 v3 以降
漢字 Alphabets ひらがな 12345 カタカナ 67890 漢字Alphabetsひらがな12345カタカナ67890

多分スペースが空いたときのほうが好みといった人が一定数居ると思います。個人的には検索性が破滅的に低下するためこの機能には否定的でしたが、今回のアップデートで遂にスペースが入らなくなったので、割と喜んでいます。ぶっちゃけこのブログの過去記事ににある余計なスペースも現在全て除去する作業をしてるくらいです。

発端としては次のIssueにあり、これをずっと追っていた人もきっといると思います。私もそのうちの一人です。

この問題は次のPullRequestで対応され、現在v3 用のブランチに取り込まれています。

  • [Markdown[next branch]: Do not insert spaces between Chinese/Japanese & latin letters #11597](https://github.com/prettier/prettier/pull/11597)

この機能を試したい場合、以下のようにインストールすることで試すことが出来ます。

npm i -D https://github.com/prettier/prettier.git#next

なお、PullRequestにある通り既にあるものには影響しないので、既にあるスペースを詰めることは出来ません。

これまで通りスペースを入れたい場合

引き続き漢字Alphabetsひらがな12345カタカナ67890漢字 Alphabets ひらがな 12345 カタカナ 67890にしたいケース

前述のPullRequestの中でも言及されていますが、textlintを利用することで対応できます。ただPrettierと併用するのは難しいと思います。

導入方法

textlintにtextlint-rule-preset-ja-spacingを組み合わせて対応します。

npm i -D textlint textlint-rule-preset-ja-spacing
使用方法

まず.textlintrcを作成し、次の設定を追加します。

{
    "rules": {
        "preset-ja-spacing": {
            "ja-space-between-half-and-full-width": {
                "space": "always"
            }
        }
    }
}

CLIを用いて修正する場合

npx textlint --fix <path>

VSCodeを用いる場合、vscode-textlintをインストールし、settings.jsonに以下の設定を追加することで利用することが出来ます。Prettierと併用する場合、Prettierと競合し、textlintが負けるため、Markdownの整形に関してはtextlintに任せるのが無難だと思います。

{
    "textlint.autoFixOnSave": true
}

一応Custom Local Formattersを使えば近いことは出来なくもないのですが、ファイルへの書き込み同期がズレてるのか上手く動かない気がします。一応以下の設定で動くことには動きますが、あんま期待しないほうがいいかも…。拡張からコマンドを蹴ること自体セキュリティ的に微妙な気がするので、この方法は使わないのが無難な気がします。

{
    "[markdown]": {
        "editor.formatOnSave": true,
        "editor.defaultFormatter": "jkillian.custom-local-formatters"
    },
    "customLocalFormatters.formatters": [
        {
            "command": "npx prettier ${file} | npx textlint --stdin --stdin-filename ${file} --fix --format fixed-result",
            "languages": ["markdown"]
        }
    ]
}

これはPrettierとtextlintを呼ぶ時点で二重処理になって重いので、Markdownのフォーマットはどっちか片方にやらせたほうが良いでしょう。ただtextlintはデフォルトだと表の整形とかいい感じにしてくれないようなのが困りものです。textlintにmarkdownプラグインを入れると改善されるかもしれませんが試してないです。

これまでのスペースを消したい場合

既にある漢字 Alphabets ひらがな 12345 カタカナ 67890漢字Alphabetsひらがな12345カタカナ67890にしたいケース

前項の「これまで通りスペースを入れたい場合」で紹介した環境を導入し、.textlintrcを以下のように作成すればある程度は対応できます。但し完全には除去しきれません。

{
    "rules": {
        "preset-ja-spacing": {
            "ja-space-between-half-and-full-width": {
                "space": "never"
            }
        }
    }
}

この設定によるtextlintの整形結果は以下のとおりです。記号の前後の半角スペースや見出しやリンク文章にある半角スペースは除去しきれないようです。

 ## 123 あああ abc

-ユーザー ID は Slack アプリから
+ユーザー IDはSlackアプリから
 やよいの ○ 色申告
 [Web サイトの example](https://example.com)

余談

多分大抵の人はPrettierをv3にしてからこの事に気が付くと思います。当然です。一々 OSSのアップデートを追っている人は恐らくメンテナか物好きだからです。

現在Prettierのリポジトリでは似たような議論として、デフォルトでインデントにスペースを使っているところをタブに置き換えようという動きがあります。幸いv3では採用に至っていませんが、今後採用される可能性があり、メンテナであるSosuke Suzuki氏もこの事について以下の記事で憂慮されています。

ある日突然インデントのスペースがタブに変われば、苦情が出るのは明白です。設定一つ直すだけとは言え、影響は大きいでしょう。CIで全体にPrettierを掛けていないプロジェクトでは一時的にタブインデントのファイルとスペースインデントのファイルが混在するケースもあると思います。空白文字の差分表示や検出を無効化していると気づかないまま、そのような状態になるケースもあると思います。

個人的にはMarkdownに落とした時にどの環境でも同じように見えるという理由でスペースインデントが好みですが、A11yの観点からタブにせよという考えも理解は出来ます。でもブラウザで.mdファイル見たときとかに見づらいんですよね…。PythonPHPYAML の様に標準がスペースインデントの言語もありますし、個人的には標準はスペースで構わないのではと思ったりもします。(その上で必要に応じてタブを使うという選択肢は出来ると思います。Google JavaScript Style Guideでも、これが標準です。Prettierは代表的なフォーマッタで事実上のコーディング標準であるとも言えるため、社会的責務でそうする必要があるかもしれませんが、個人的な思いとしては標準はスペースのままであってほしいなぁと思います。どうしても標準が変わると、世間はそこに引きずられざれ得ないと思いますから。

多分今回の変更も、上記のuseTab同様に一部で非難が上がる気がしていて、余り大事にならなければいいなぁ…とか考えています。(タブと比べると影響範囲が狭いので、ひょっとしたら今回はあまり何も起きないかもですが…)