はじめての npm package

こんにちは。エンジニアの濱田鈴木塚原です。この記事はモブプログラミングならぬ、モブブロギングにより3人により書かれています。

今回は、複数レポジトリにコードのコピペにより同様のものが書かれていた処理を、 npm パッケージとして切り出した話です。

ライブラリ概要

作ったライブラリはこちらです。

github.com

inflow source は直訳すると「流入源」です。ユーザーがサイトに流入した経路を localStorage に保存しておくことで、後で何らかの CV が発生した時にその localStorage の情報をサーバー側に一緒に POST するために利用される想定です。「ユーザーがサイトに流入した経路」は例えばリファラ、ランディングページ、UTMパラメータ、CV前最終閲覧ページ、デバイス情報(pc/mobile)などです。

このパッケージは以下のようなインターフェースを持ちます(※TypeScriptの型情報から自動生成された index.d.ts です。本記事において瑣末な部分は一部省略されています)。

export declare type InflowSourceParams = {
    referer: string | null;
    landingPageUrl: string | null;
    utmSource: string | null;
    utmMedium: string | null;
    utmCampaign: string | null;
    utmContent: string | null;
    gclid: string | null;
    lastPageUrl: string | null;
    device: string;
};

export declare const useInflowSource: (storage: Storage, baseUrl: URL) => {
    set: (rawCurrentDate: Date | CustomDate, referer?: URL, currentUrl?: URL) => void;
    setLastUrl: (currentUrl: URL | undefined, ignorePathRegexpList: string[]) => void;
    getAllParams: () => InflowSourceParams;
};

メインは useInflowSource の中のメソッドで、軽くだけ説明します(本記事はパッケージ化のところが本流なので、詳細は割愛します)。

  • setメソッドを呼び出すと、ランディング判定(UTMパラメータがあるか、最終閲覧から30分以上経っているか)をした上で、 localStorage に流入源の情報を保存してくれます。
  • setLastUrlメソッドを呼び出すと、 ignorePathRegexpList 引数で設定した CV ページ以外のページを localStorage に保存しておいてくれます。
  • getAllParamesメソッドを呼び出すと、localStorageから保存したものをオブジェクトの形式で取得できます。 CV ページで POST するときに localStorage に保存しておいたものを一緒に body に入れるために使用できます。

背景

このパッケージを作ろうとした背景は、まず同じような処理が弊社内の複数のレポジトリに散っていたことでした。

弊社には、Laravel と生の JavaScript で書かれたいわゆる ”旧front” のレポジトリと、 Nuxt.js と TypeScript で書かれたいわゆる ”新front” のレポジトリがあります。サイト内の古いページは ”旧front" で書かれているが、最近書き直されたページは "新front" で書かれている混在状態です。ユーザがランディングしうるページはどちらで作られたものもあり得るので、「inflow source(流入源)保存」はどちらのレポジトリにも同様のロジックを記述しなければなりませんでした。

このロジック重複により、まずは新frontで TypeScript で実装し、動いたら旧frontに JavaScript で移植するという作業が発生します。対応するテストも重複します。 Jest で書かれたテストを、漏れがないように目視で確認しながら移植しなければいけません。この形だと将来の拡張の時にまた同じ辛さを抱えることになるので、同じ処理を npm package として切り出すことにしました。

構成

ここからは TypeScript のプロジェクトを npm package として公開する観点で構成を紹介します。

現在のバージョン情報は以下です。

  • TypeScript: 4.6.3
  • Node.js: 16.13.2

TypeScript で実装されており、以下のようなディレクトリ構成になります。

.
├── README.md
├── dist
├── example
│   └── index.js
├── jest.config.cjs
├── package-lock.json
├── package.json
├── src
│   ├── date.ts
│   ├── index.ts
│   └── user-agent.ts
├── test
│   ├── date.spec.ts
│   ├── index.spec.ts
│   └── user-agent.spec.ts
└── tsconfig.json

npm package として公開するにあたって重要なのは tsconfig.json と package.json になるので、これらの設定について説明します。まず tsconfig.json は以下の通りです。

{
  "compilerOptions": {
    "outDir": "dist",
    "noImplicitAny": true,
    "lib": ["ES2016", "DOM"],
    "target": "es5",
    "jsx": "react",
    "allowJs": true,
    "moduleResolution": "node",
    "declaration": true,
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "paths": {
      "~~/*": [
        "./*"
      ],
      "@@/*": [
        "./*"
      ],
      "~/*": [
        "./src/*"
      ],
      "@/*": [
        "./src/*"
      ]
    }
  },
  "include": [
    "src"
  ]
}

lib には ES2016 に加えて DOM を記述しています。今回はブラウザ上で document.localStorage を使用しているため、この設定がないとトランスパイル時に Storage の型チェックに失敗します。

declaration には true を設定しています。弊社のフロントエンドでは TypeScript を利用しているため、型定義ファイルを作成して inflow-source の提供する API の型を参照できるようにしています。

esModuleInterop には true に設定しています。元々は未設定(デフォルトは false)でしたが、 日時の取得・加工に使用している Day.js のメソッド呼び出し時に TypeError が発生してしまいました。 esModuleInteropfalse の場合、 CommonJS で書かれたコードを ES Module のコードベースから import しようとする際に問題が生じる場合があるようです。 Day.js のエンドポイントに指定された JS ファイルを読むと、確かに CommonJS でビルドされていたので、 esModuleInteroptrue に設定してこの問題を回避しています。

また module の設定は書いていないため、暗黙的に CommonJS としてビルドされるようになっています。

続いて package.json は以下のように記述しています。

{
  "name": "@macloud-developer/inflow-source",
  "version": "0.1.0",
  "description": "inflow source を front 側で保存するためのライブラリ",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "scripts": {
    "build": "tsc"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/macloud-developers/inflow-source.git"
  },
  "author": "",
  "license": "ISC",
  "bugs": {
    "url": "https://github.com/macloud-developers/inflow-source/issues"
  },
  "homepage": "https://github.com/macloud-developers/inflow-source#readme",
  "devDependencies": {
    "@types/jest": "^28.1.1",
    "jest": "^28.1.1",
    "ts-jest": "^28.0.4",
    "typescript": "^4.6.3"
  },
  "dependencies": {
    "@types/ua-parser-js": "^0.7.36",
    "dayjs": "^1.11.3",
    "ua-parser-js": "^1.0.2"
  }
}

パッケージ名で @macloud-developer/ のように記述することで組織名を表すことができます。

パッケージのビルドは単純に tsc コマンドで実行しています。社内ではビルドに webpack を用いるプロジェクトが多いため、最初は訳も分からず webpack でビルドしようとしていました(爆)が、特に出力ファイルをバンドルする理由もなかったので外しました。

main および types はそれぞれパッケージのエントリーポイントおよび型定義ファイルのパスを指定します。基本的に tsconfig.jsonoutDir で指定したディレクトリ上の index.js と index.d.ts が参照される形に設定すれば良いと思います。

また明示的に書いていませんが、 type: module は外しておき、暗黙的に CommonJS としてビルドされていることをパッケージ使用者側に示しています。

パッケージの publish

npm publish コマンドを用いて npmjsレジストリに登録します。詳細はドキュメントに譲るとして、ざっくり以下の方法で行います。

  • npmjs にユーザー登録。organization を作り、他の開発者を招待しておく。
  • ビルドして dist ディレクトリに JS ファイルと TS の型定義ファイルがある状態で npm publish --access public コマンドを実行

ライブラリの読み込み

public なパッケージとして公開できたので、あとは他の通常のライブラリと同様に npm install してコードベース上で import して読み込むだけです。例えば Nuxt.js であれば、以下のように plugin 上で router.afterEach() のコールバックから useInflowSource().set() を呼び出すことで、ページ表示・遷移のたびに流入源の情報が localStorage に保存されます。

import { Context } from '@nuxt/types'
import { useInflowSource } from '~/compositions/common/inflow-source'
import useDate from '~/compositions/libs/date'

export default (ctx: Context) => {
  if (process.server) {
    return
  }

  const inflowSource = useInflowSource(window.localStorage)

  const getUrl = (url: string): URL | undefined => {
    try {
      return new URL(url)
    } catch (e) {
      return undefined
    }
  }

  if (ctx.app.router === undefined) {
    return
  }
  ctx.app.router.afterEach(() => {
    inflowSource.set(
      useDate().current(), getUrl(window.document.referrer), getUrl(location.href)
    )
  })
}

まとめ

今回、旧frontと新frontの共通機能を npm package 化したことによって、双方の環境にコピペされたコードは共通化され、保守性、拡張性に強い実装にすることができました。以前までは重複したコードが旧frontと新frontでそれぞれ300行近くありましたが、対応後はパッケージを読み出すだけのシンプルなコードで100行程度にまとめることができました。また今後機能追加が必要になっても、 package 側のコードを修正して、旧frontと新frontでパッケージのバージョンを上げるだけで済むようになりました。

README は現在進行形で自分たち以外の方が読んでも分かるようにアップデート中です。インストール方法から動かし方をまとめていきますので、もしご興味のある方はダウンロードしてみてください。

M&Aクラウドではエンジニアを積極採用しています!

www.wantedly.com

www.wantedly.com