かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

Gulp + Rollup で webサイト用の JS をビルドしたい

10年くらい前に作った WEB サイトの改修案件が発生して、構成が古すぎて手に負えなくなっていたのでコレを気にビルド環境を見直しました。
元のものが Grunt で JS を結合したり SCSS をビルドしている構成だったので Grunt を Gulp に置き換え、SCSS のビルドはそのまま JS は順番を決めて結合するのではなくバンドルするようにしたい。Vite のビルドに Rollup が使われていたので webpack ではなく Rollup を使う構成にしてみました。(JS だけなら Gulp を使う必然性がないのですが SCSS の微都度と一緒に watch して JS, CSS どりらでもビルドできるという理由で導入しました)

Rollup で JS をビルドできる環境を作る

install packages

$ npm i -D rollup
# Babel 関連
$ npm i -D @babel/core @babel/preset-env @rollup/plugin-babel
# npm install したパッケージをバンドルするためのプラグイン
$ npm i -D @rollup/plugin-node-resolve
note

設定ファイル

.babelrc.json

{
  "presets": [
    [
      "@babel/preset-env",
      {
        "loose": true,
        "targets": "> 1%, not dead",
        "modules": false
      }
    ]
  ]
}

module: false でないと Rollup のエラーになる

rollup.config.js

// npm modules を bundle するためのプラグイン
import { nodeResolve } from '@rollup/plugin-node-resolve';
// babel
import { babel } from '@rollup/plugin-babel';

const config = {
  input: './src/index.js'
  output: {
    file: './build/assets/js/main.js',
    // Web サイトでの利用なので即時関数で出力される iife を指定
    format: 'iife',
  }
  plugins: [
    nodeResolve(),
    babel({ babelHelpers: 'bundled' }),
  ],
};

export default config;

cf. format に関して こちらの記事 がわかりやすかったです

Rollup でバンドルできているかのチェック

$ npx rollup --config

上記コマンドを実行しビルドされた JS ファイルが出力されていれば OK

Gulp から Rollup を実行する

$ npm i -D gulp
$ touch gulpfile.js

gulp の設定ファイルを作成

gulpfile.js

'use strict';
const { watch, parallel, series } = require('rollup');
const { rollup } = require('rollup');
const config = require('./rollup.config');

const buildJS = async (cb) => {
  await rollup(config)
    .then(async (bundle) => {
      // ファイルの生成も非同期なので生成完了まで await する
      await bundle.write(config.output);
      Promise.resolve();
    })
    .catch((error) => {
      if (cb) {
        cb();
      }
      console.error(error);
    });

  cb();
};

const buildJSTask = series(buildJS);

exports.default = () => {
  watch(['./src/**/*.js'], buildJSTask);
};
exports.build = parallel(buildJSTask);

直接 Rollup を呼び出す方法を採用しました。
npx gulp でファイル監視, npx gulp build でビルドコマンドが実行されます。

cf. Webフロントエンド - GulpでRollupを使う / rollup-stream、rollup | ブログ | STUDIO BUS STOP | 香川県高松市

rollup.config.js を CommonJS の形式に修正する

gulp が CommonJS の形式なので rollup.config.js 内で import / export を使用しているとエラーになってしまいます。 gulpfile 内で import / export を使えるようにする方法もあるが、gulp のプラグインなど対応してないものがあると面倒そうなので rollup.config.js を CommonJS 形式に書き換えます。

rollup.config.js

- import { nodeResolve } from '@rollup/plugin-node-resolve';
+ const { nodeResolve } = require('@rollup/plugin-node-resolve');
- import { babel } from '@rollup/plugin-babel';
+ const { babel } = require('@rollup/plugin-babel');

const config = {
  input: './src/index.js'
  output: {
    file: './build/assets/js/main.js',
    format: 'iife',
  }
  plugins: [
    nodeResolve(),
    babel({ babelHelpers: 'bundled' }),
  ],
};

- export default config;
+ module.exports = config;

npm script の作成

gulp を実行する npm script を作成

package.json

{
  "scripts": {
    "gulp:build": "npx gulp build",
    "gulp:dev": "npx gulp"
  },
}

npm run gulp:build を実行して JS のビルドができれば OK


Options

minifiy

gulp で行なうほうが十分に枯れている気もしますが、Rollup のプラグインは minify したものとして無いものを別々に出力できそうだったのと後述の console.log の削除が Rollup で行った方が良さそうだったので Rollup のプラグインを採用しました。

$ npm i -D rollup-plugin-terser

rollup.config.js

const { nodeResolve } = require('@rollup/plugin-node-resolve');
const { babel } = require('@rollup/plugin-babel');
const  { terser } = require('rollup-plugin-terser');

const config = {
  input: './src/index.js'
  output: [
    {
      file: './build/assets/js/main.js',
      format: 'iife',
    }, 
    {
      file: '/build/assets/js/main.min.js',
      format: 'iife',
      plugins: [terser()],
    },
  ]
  plugins: [
    nodeResolve(),
    babel({ babelHelpers: 'bundled' }),
  ],
};

module.exports = config;

rollup-plugin-terser プラグインは sourcemap を出力できない cf. Allow configurable sourcemaps. · Issue #64 · TrySound/rollup-plugin-terser · GitHub


console.log の削除

min 化したファイルからは console.log を取り除きたい

gulp の方は開発がかなり前に止まっているし Star も少ないので Rollup のプラグインを使うのが良さそうです。

@rollup/plugin-strip プラグインは output 内の plugin として使えないようです

rollup.config.js

const { nodeResolve } = require('@rollup/plugin-node-resolve');
const { babel } = require('@rollup/plugin-babel');
const  { terser } = require('rollup-plugin-terser');
+ const strip = require('@rollup/plugin-strip');

const config = {
  input: './src/index.js'
  output: [
    {
      file: './build/assets/js/main.js',
      format: 'iife',
    }, 
    {
      file: '/build/assets/js/main.min.js',
      format: 'iife',
-     plugins: [terser()],
+     plugins: [terser(), strip()],
    },
  ]
  plugins: [
    nodeResolve(),
    babel({ babelHelpers: 'bundled' }),
  ],
};

module.exports = config;

上記の様に output 内の plugin で使用しようとしても正しく動作しません 👇

$ npx npx rollup --config
./src/index.js → ./build/assets/js/main.js, ./build/assets/js/main.min.js...
(!) The "transform" hook used by the output plugin strip is a build time hook and will not be run for that plugin. Either this plugin cannot be used as an output plugin, or it should have an option to configure it as an output plugin.
created ./build/assets/js/main.js, ./build/assets/js/main.min.js

出力された main.min.jsconsole.log は削除されず残ったまま…
どうやら @rollup/plugin-strip は全体の plugin としてしか動作しないようです。

production / development で場合分けをする

Rollup は --environment <values>環境変数を渡せるので、これを利用する

rollup.config.js

const { nodeResolve } = require('@rollup/plugin-node-resolve');
const { babel } = require('@rollup/plugin-babel');
const  { terser } = require('rollup-plugin-terser');
const strip = require('@rollup/plugin-strip');

const isProduction = process.env.MODE === 'production' || false;
const input = './src/index.js';
const outputFile = isProduction ? '/build/assets/js/main.min.js' : './build/assets/js/main.js';
const format = 'iife';
const plugins = [
  nodeResolve(),
  babel({ babelHelpers: 'bundled' }),
];

const config = () => {
  if (isProduction) {
    return {
      input,
      output: {
        file: outputFile,
        format,
        plugins: [terser()],
      },
      plugins: [...plugins, strip()],
    };
  } else {
    return {
      input,
      output: {
        file: outputFile,
        format,
      },
      plugins: [...plugins],
    };
  }
};

module.exports = config();

npm script

{
  "scripts": {
    "build:dev": "npx rollup --config",
    "build:prod": "npx rollup --config --environment MODE:production",
    "build": "npm run build:dev && npm run build:prod"
  },
}

👇

$ npm run build
> npx rollup --config
./src/index.js → ./build/assets/js/main.js...
created ./build/assets/js/main.js
> npx rollup --config --environment MODE:production
./src/index.js → ./build/assets/js/main.min.js...
created ./build/assets/js/main.min.js

build が 2回実行されてしまいますが、minify する際のみ console.log を取り除くことができました!

gulp からの呼び出し

gulp から呼び出す場合 Rollup の --environment <values> をコマンドから渡すことができません。また gulp 内で使っている rollup メソッドは設定ファイルを引数に取るので環境変数を渡すことはできなそうです。

export function rollup(options: RollupOptions): Promise<RollupBuild>;

export interface RollupOptions extends InputOptions {
  // This is included for compatibility with config files but ignored by rollup.rollup
  output?: OutputOptions | OutputOptions[];
}

gulp から呼び出す場合は process.env.NODE_ENV を gulp に渡して rollup メソッドに渡す設定ファイルを変更するのが良さそうです

方針
  1. NODE_ENV=production npx gulp build => minify する Rollup の設定を使う
  2. npx gulp build => minify しない Rollup の設定を使う
/root
  |- /rollup
  |    |- config.common.js # 共通設定
  |    |- config.dev.js # development用の設定
  |    |- config.prod.js # production用の共通設定
  |- gulpfile.js

Rollup の設定を分割する

config.common.js

require { nodeResolve } = require('@rollup/plugin-node-resolve');
require { babel }  = require('@rollup/plugin-babel');

exports.input = './src/index.js';
exports.outputDir = './build/assets/js';
const outputFile = `${outputDir}/main.js`;
exports.output = {
  file: outputFile,
  // esm, cjs, amd, system, iife, umd
  format: 'iife',
};
exports.plugins = [nodeResolve(), babel({ babelHelpers: 'bundled' })];

config.dev.js

const { input, output, plugins } = require('./config.common)';

const config = {
  input,
  output: {
    ...output,
  },
  plugins: [...plugins],
};

module.exports = config;

config.prod.js

const { terser } = require('rollup-plugin-terser');
const strip = require('@rollup/plugin-strip');
const { input, output, outputDir, plugins } = require('./config.common');

const config = {
  input,
  output: {
    ...output,
    file: `${outputDir}/main.min.js`,
    plugins: [terser()],
  },
  plugins: [
    ...plugins,
    strip({
      labels: ['unittest'],
    }),
  ],
};

module.exports = config;

gulpfile の設定を修正する

gulpfile.js

'use strict';
const { watch, parallel, series } = require('rollup');
const { rollup } = require('rollup');
- const config = require('./rollup.config');
+ // 場合分けして Dynamic import もできると思うが複雑になりそうなので試さない
+ const configDev = require('./rollup/config.dev.mjs');
+ const configProd = require('./rollup/config.prod.mjs');
+ const isProduction = process.env.NODE_ENV === 'production' || false;
+ const config = isProduction ? configProd : configDev;

const buildJS = async (cb) => {
  await rollup(config)
    .then((bundle) => {
      bundle.write(config.output);
    })
    .catch((error) => {
      if (cb) {
        cb();
      }
      console.error(error);
    });

  cb();
};

const buildJSTask = series(buildJS);

exports.default = () => {
  watch(['./src/**/*.js'], buildJSTask);
};
exports.build = parallel(buildJSTask);

gulp を実行する npm script

package.json

{
  "scripts": {
+   "gulp:build:dev": "npx gulp build",
+   "gulp:build:prod": "NODE_ENV=production npx gulp build",
+   "gulp:build": "npm run gulp:build:dev && npm run gulp:build:prod",
+   "gulp:dev": "npx gulp",
+   "gulp:dev:prod": "NODE_ENV=production npx gulp",
    "build:dev": "npx rollup --config",
    "build:prod": "npx rollup --config --environment MODE:production",
    "build": "npm run build:dev && npm run build:prod"
  },
}

npm run gulp:build を実行して main.js と minify され console.log が取り除かれた main.min.js ができていればOK


gizip

どっちもどっちと言う感じです。CSSgzip 化するのであれば gulp のプラグインにすればインストールするパッケージが 1 つで済みそうです

Rollup で gzip

$ npm i -D rollup-plugin-gzip
# Brotil 化
$ npm i -D zlib util

rollup.config.js

const { nodeResolve } = require('@rollup/plugin-node-resolve');
const { babel } = require('@rollup/plugin-babel');
const  { terser } = require('rollup-plugin-terser');
const strip = require('@rollup/plugin-strip');
+ const gzipPlugin = require('rollup-plugin-gzip');
+ const { brotliCompress } = require('zlib');
+ const { promisify } = require('util');

const isProduction = process.env.MODE === 'production' || false;
const input = './src/index.js';
const outputFile = isProduction ? '/build/assets/js/main.min.js' : './build/assets/js/main.js';
const format = 'iife';
const plugins = [
  nodeResolve(),
  babel({ babelHelpers: 'bundled' }),
];

const config = () => {
  if (isProduction) {
    return {
      input,
      output: {
        file: outputFile,
        format,
-       plugins: [terser()],
+       plugins: [
+         terser(),
+         gzipPlugin({
+           fileName: '.gz',
+         }),
+         // Brotil compression as .br files
+         gzipPlugin({
+           customCompression: (content) => brotliPromise(Buffer.from(content)),
+           fileName: '.br',
+         }),
+       ],
      },
      plugins: [...plugins, strip()],
    };
  } else {
    return {
      input,
      output: {
        file: outputFile,
        format,
      },
      plugins: [...plugins],
    };
  }
};

module.exports = config();

gulp で gzip

$ npm i -D gulp-gzip
# Brotli 化
$ npm i -D gulp-brotli zlib util

gulpfile.js

'use strict';
const { watch, parallel, series, src, dest } = require('rollup');
const { rollup } = require('rollup');
+ const gzip = require('gulp-gzip');
+ const  gulpBrotli = require('gulp-brotli');

const configDev = require('./rollup/config.dev.mjs');
const configProd = require('./rollup/config.prod.mjs');
const isProduction = process.env.NODE_ENV === 'production' || false;
const config = isProduction ? configProd : configDev;

const buildJS = async (cb) => {
 /// 略
};

+ const destDir = './build/assets/js';
+ const gzipJS = (cb) => {
+   return src(`${destDir}/*.min.js`)
+     .pipe(gzip())
+     .pipe(dest(`${destDir}`));
+ };

+ const brotliJS = (cb) => {
+   return src(`${destDir}/main.min.js`)
+     .pipe(
+       gulpBrotli.compress({
+         extension: 'br',
+       })
+     )
+     .pipe(dest(`${destDir}`));
+ };

- const buildJSTask = series(buildJS);
+ const buildJSTask = isProduction
+   ? series(buildJS, parallel(gzipJS, brotliJS))
+   : series(buildJS);

exports.default = () => {
  watch(['./src/**/*.js'], buildJSTask);
};
exports.build = parallel(buildJSTask);

npm run gulp:build (npm run NODE_ENV=production npx gulp) を実行して main.min.js.gzipmain.min.js.br が生成されていればOK


所管

ココまで書いてて何だけど、gulp は総じて開発が止まっている印象ですし、CommonJS とかで結構めんどくさいから Gulp を使わない方針にするか Rollup 諦めて webpack でゴリッとやってしまったほうが楽だったかも知れない… (cf. webpack 5 CSS だけコンパイルしたい - かもメモ) Rollup についての知見をつけることができたのでよし!


[参考]

React vite 環境変数を使うメモ

vite React プロジェクトで .env に書いた環境変数を使うメモ

環境変数VITE_ prefix を使う

VITE_ から始まる変数名でないと vite アプリからアクセスできない

環境変数が誤ってクライアントに漏れてしまうことを防ぐために、VITE_ から始まる変数のみが Vite で処理されたコードに公開されます。
cf. 環境変数とモード | Vite

.env

DB_PASSWORD=foobar
VITE_SOME_KEY=123

アプリからは import.meta.env.変数名 でアクセスする

const key = import.meta.env.VITE_SOME_KEY;
// => '123'
const dbPassword = import.meta.env.DB_PASSWORD;
// => undefined

VITE_ prefix の無い変数は undefined になる

VITE は dotenv とか入れなくても環境変数が読み込める仕組みが用意されていました。
create_react_app との違いはこんな感じ

環境 環境変数の prefix アプリでの読み込み方法
create_react_app REACT_APP_ process.env.REACT_APP_変数名
vite VITE_ import.meta.env.VITE_変数名

[参考]

基礎から学ぶ React/React Hooks

基礎から学ぶ React/React Hooks

Amazon

React.lazy でコンポーネントを Dynamic import してみる

環境 - react@18.1.0 - vite@2.9.9 - typescript@4.6.4

React.lazy

Reacy.lazy を使うと import() で読み込んだコンポーネントを通常のコンポーネントとして扱うことができる

下記の方法でコンポーネントを Dynamic import できる

// component
export default MyComponent: FC = () => {...};

// MyApp.tsx
const MyComponent = React.lazy(() => import('./path/to/MyComponent'));
// `<MyComponent />` として使用できる

React.lazy で Dynamic import したコンポーネント<Suspense> で囲う必要がある

遅延コンポーネントは、Suspense コンポーネント内でレンダーされる必要があります。これによって、遅延コンポーネントのローディングの待機中にフォールバック用のコンテンツ(ローディングインジケータなど)を表示できます。
cf. コード分割 – React

React.Susponse で囲ってないとエラーで React がクラッシュする

import 中にコンポーネントを表示しようとしてしまった場合エラーでアプリケーションがクラッシュします。動的な import なので常にクラッシュする訳ではないので気づきづらいので注意が必要です。

// MyComponent.tsx
export default MyComponent: FC = () => {...};

// MyApp.tsx
import { FC, lazy } from 'react';
const MyComponent = lazy(() => import('./MyComponent'));

const MyApp:FC = () => {
  return <MyComponent />
}

次のようなエラーが発生する

  • The above error occurred in one of your React components
  • Consider adding an error boundary to your tree to customize error handling behavior. Visit https://reactjs.org/link/error-boundaries to learn more about error boundaries.
  • A component suspended while responding to synchronous input. This will cause the UI to be replaced with a loading indicator. To fix, updates that suspend should be wrapped with startTransition.

👇 React.lazy で読み込んだコンポーネントは必ず <Suspense> で囲いインポート中の fallback を設定する

// MyApp.tsx
import { FC, lazy, Suspense } from 'react';
const MyComponent = lazy(() => import('./MyComponent'));

const MyApp:FC = () => {
  return (
    <Suspense fallback="loading…">
      <MyComponent />
    </Suspense>
  );
}

named export されたコンポーネントを読み込む方法

デフォルトでは React.lazy, import() を使った Dynamic import はデフォルトでは default import されたコンポーネントにしか対応されていません。

React.lazy は現在デフォルトエクスポートのみサポートしています。インポートしたいモジュールが名前付きエクスポートを使用している場合、それをデフォルトとして再エクスポートする中間モジュールを作成できます。これにより、tree shaking が機能し未使用のコンポーネントを取り込まず済むようにできます。
cf. コード分割 – React

1. default import する中間モジュールを別途作成する方法

公式に書かれている方法。named export されているコンポーネントを読み込み default import する中間もモジュールを作成し、それを Dynamic import する

Dynamic import したいコンポーネント

// ManyComponents.js
export const MyComponent = /* ... */;
export const MyUnusedComponent = /* ... */;

中間モジュール

// MyComponent.js
export { MyComponent as default } from "./ManyComponents.js";

中間モジュールを React.lazy で Dynamic import する

import { lazy } from 'react';
const MyComponent = lazy(() => import("./MyComponent.js"));

default import したオブジェクトの中から named export されたモジュールを default として返す

React.lazy の型はこんな風になっていました。

type LazyExoticComponent<T extends ComponentType<any>> = ExoticComponent<ComponentPropsWithRef<T>> & {
  readonly _result: T;
};

function lazy<T extends ComponentType<any>>(
  factory: () => Promise<{ default: T }>
): LazyExoticComponent<T>;

最終的に {default: ComponentType} の形が返却されれば React.lazy としては正確に動作しそうです。 React のコンポーネントは names export の場合も ESModule で export されていると思うので、全体を default インポートした Object には named export されたモジュールが含まれアクセスが可能なので、これを利用して default キーで必要なモジュール返却すれば中間モジュールを作成しなくても Dynami import が可能でした。

Dynamic import したいコンポーネント

// ManyComponents.js
export const MyComponent = /* ... */;
export const MyUnusedComponent = /* ... */;

React.lazy で Dynamic import

import { lazy } from 'react';
const MyComponent = lazy(() => 
  import("./MyComponent").then(({ MyComponent }) => ({
    default: MyComponent,
  }))
);

問題としては複数のモジュールをexport しているファイルだと一度全体を import してしまうので Tree shaking が効かなくなることかと思います。単体のモジュールしか export していない場合であれば中間モジュールのファイルを作成せず React.lazy で Dynamic import する方法として使えそうです。


[参考]

基礎から学ぶ React/React Hooks

基礎から学ぶ React/React Hooks

Amazon