Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby on Rails: Asset Pipelineを使わないwebpackなRailsアプリケーションをHerokuでデプロイする

個人アプリの開発ではHerokuを使うことが多いのですが、最近webpack + Railsの環境を使っていてデプロイまわりで少しハマったので、デプロイ方法をメモしておきます📝

Herokuのデプロイついて

使用する Heroku buildpack によって、アプリケーションの開発プロセスや、コードの実行時に用意すべきアセットやランタイムが決まります。複雑なアプリケーションで複数の言語を実行する場合は、1 つのアプリケーションで複数の buildpack を使用することもできます。

Herokuのデプロイというのはbuildpackという仕組みを使って行われているようです。

devcenter.heroku.com

buildpackは様々な言語をサポートしていて、これらによってpushするだけでデプロイされるようなheroku のシンプルなしくみが実現出来ているんですね。

Herokuによる標準のRailsデプロイ

Railsアプリケーションにおいて、デフォルトでは以下のようなbuildpackな設定になっています。

$ heroku buildpacks
=== app-name Buildpack URLs
1. heroku/ruby

RubyのbuildpackではRailsのサポートが入っているので、assets:precompileがデプロイ時に実行されsproketswebpackerで管理しているassets等のファイルのbuildが走ります。

devcenter.heroku.com

しかし、sproketswebpackerも使わずwebpackのみで管理しているいる場合、assets:precompileは使わないのでフロントエンドまわりのファイルのbuildが走りません。。。

WeboackなRailsをHerokuにデプロイする

ではどうするかというと、nodebuildpackを追加して、yarn install及びyarn buildが実行されるようにします。

$ heroku buildpacks:add --index 1 heroku/nodejs
$ heroku buildpacks
=== app-name Buildpack URLs
1. heroku/nodejs
2. heroku/ruby

devcenter.heroku.com

nodebuildpackでは、buildコマンドが実行されます。そのためpackage.jsonscripts内のbuildにデプロイ時に実行するコマンドを定義しておきます。

{
  "scripts": {
    "build": "webpack --mode=production",

これにより以下のようにwebpackbuild後にRailsdeployが走るようになっています✨

-----> Node.js app detected
       
-----> Creating runtime environment
       
       NPM_CONFIG_LOGLEVEL=error
       USE_YARN_CACHE=true
       NODE_ENV=production
       NODE_MODULES_CACHE=true
       NODE_VERBOSE=false
       
-----> Installing binaries
       engines.node (package.json):  unspecified
       engines.npm (package.json):   unspecified (use default)
       engines.yarn (package.json):  unspecified (use default)
       
       Resolving node version 12.x...
       Downloading and installing node 12.19.0...
       Using default npm version: 6.14.8
       Resolving yarn version 1.22.x...
       Downloading and installing yarn (1.22.10)
       Installed yarn 1.22.10
       
-----> Restoring cache
       - yarn cache
       
-----> Installing dependencies
       Installing node modules (yarn.lock)
       yarn install v1.22.10
       Done in 12.10s.
       
-----> Build
       Running build (yarn)
       yarn run v1.22.10
       $ webpack --mode=production
       
       Done in 19.50s.
       
-----> Pruning devDependencies
       yarn install v1.22.10
       warning Ignored scripts due to flag.
       Done in 5.04s.
       
-----> Caching build
       - yarn cache
       
-----> Build succeeded!

-----> Ruby app detected
-----> Installing bundler 2.1.4
-----> Removing BUNDLED WITH version in the Gemfile.lock
-----> Compiling Ruby/Rails
-----> Using Ruby version: ruby-2.7.1
-----> Installing dependencies using bundler 2.1.4
       Bundle complete! 20 Gemfile dependencies, 55 gems now installed.
-----> Detecting rake tasks
-----> Detecting rails configuration
       Released v15

参考

www.bokukoko.info

JavaScript: setTimeoutを使ってイベント発生時に実行する処理を抑制するMEMO

スクロールやテキストエリアの入力等、比較的大量になりやすいイベントをJavaScriptを使ってハンドリングして、さらにサーバーサイドにリクエストを投げたりしているとクライアントサイドだけでなくバックエンドの負荷が高まるのでイベント発火の頻度を抑制したいと思うこともあるかと思います。

そんなときにsetTimeoutを使うとイベントの発火頻度を抑制出来ると知ったので、そのへんのやり方をメモしておきます📝

やりかた

今回はscrollイベントを検知するような以下のコードで考えていこうと思います。

document.addEventListener('scroll', () => { console.log('scroll!!') })

現状だと非常に大量のイベントが発火してしまいます🔥

ではsetTimeoutを使ってイベント発火を抑制してみます。

まずはイベント発火の抑制の仕組みを持つ以下のような処理を用意します。

let timer = null
export const suppression = (callback, interval) => {
  if (timer) clearTimeout(timer);
  timer = window.setTimeout(callback, interval);
}

流れを説明するとsetTimeoutを管理するtimerという変数を持ちます。 suppressionという名前で関数を公開し、その中ではtimerがあればtimerで管理しているsetTimeoutをクリアして、なければsetTimeoutでcallbackで渡された処理を引数で渡されたinteval分遅延して実行しています。

つまりcallbackで渡された処理が遅延されている場合はsetTimeoutがクリアされるので実行がキャンセルされ、最後に発生したsetTimeoutのみが実行されるわけですね👩‍🚒

以下が実際に使ってみたサンプルです。scrollイベントはスクロール中断続的に発火するものの、addEventListenerで指定したcallbackは最後に発生したscrollイベントでしか実行されません。

import { suppression } from './suppression.js'
const INTERVAL_MILLISECOND = 200
document.addEventListener('scroll', () => { 
  suppression(() => { console.log('scroll!!') }, INTERVAL_MILLISECOND) 
})

おわりに

イベント抑制の仕組みを使うと、ハンドリングして外部のサービスを利用しているような処理を実行しているようなケースがあった場合に、

なんかしらの理由から悪意のあるユーザーにハンドリングしているイベントが知られたときにconsoleから大量実行されて外部サービスのRate Limitに引っかかってサービス全体が利用出来なくなるようなことにも多少は対策になるような気もするので、イベントに依存した処理の実行時には注意したいですね・・・!

また、setTimeout以外にもLodashにはthrottleというメソッドがあったりといろいろな方法があるみたいです👀

参考

stackoverflow.com

qiita.com

Ruby: ActiveSupportコア拡張を使って任意の定数を持たない特定のクラスを継承したクラスを見つける

ライブラリのバージョンアップ等で親クラスが子クラスが持つ特定の定数に依存するようになりエラーが発生することがあり。。。

特定のクラスを継承したクラスの中で特定の定数を持たないクラスを探すコードを書いたのメモしておきます📝

サンプルコード

例えば、以下のような子クラスに定義されたATTRIBUTESを参照して、hashを作るようなメソッドが親クラスに実装されていて、一部の子クラスにATTRIBUTESが定義されていないケースをサンプルにしています。

今回のケースではEnglishNameATTRIBUTESを持たないのでEnglishName.new.attributes実行時にuninitialized constant EnglishName::ATTRIBUTESが発生しています。

class BaseName
  def attributes
    self.class::ATTRIBUTES.inject({}) do |result, attr|
      result.tap { result[attr] = public_send(attr) }
    end
  end
end

class JapaneseName < BaseName
  ATTRIBUTES = [:first_name, :last_name]

  def first_name
    'Taro'
  end

  def last_name
    'Yamada'
  end
end

class EnglishName< BaseName
  def first_name
    'John'
  end

  def middle_name
    'Fitzgerald'
  end

  def last_name
    'Smith'
  end
end

JapaneseName.new.attributes
#=> {:first_name=>"Taro", :last_name=>"Yamada"}
EnglishName.new.attributes
#=> `attributes': uninitialized constant EnglishName::ATTRIBUTES (NameError)

任意の定数を持たない特定のクラスを継承したクラスを見つけるコード

今回はActiveSupportのコア拡張を使ってコードを作成します🚃

railsguides.jp

以下がエラーが発生する、BaseNameを継承していて定数ATTRIBUTESを持たないクラスを取得するスクリプトです。

parent_class = Base
target_const = :ATTRIBUTES

puts parent_class.subclasses.select do |sub|
  sub.constants.exclude?(target_const)
end
#=> EnglishName

subclassesで子クラスの配列を取得してconstantsでそれぞれの子クラスの持つ定数を取得して、ATTRIBUTESを持たないクラスを取得しています💻

おわりに

RubyActiveSupportのコア拡張を使うと割と自由にクラスを探索出来て便利ですね✨

JavaScript: CustomEventを使って密結合な親子要素をイベント通じた疎結合な実装にするMEMO

以下のような親子関係のような仕様を持つCheckBoxの機能を作りたいときに、

  • 全選択のCheckBoxをチェックすると、各要素のCheckBoxをすべてON/OFFにする
  • 各要素のCheckBoxがすべてONの時は全選択のCheckBoxはON、それ以外の場合はOFFになる

イメージ

f:id:madogiwa0124:20201004122134g:plain

各レコードの項目はサーバーサイド側のライブラリが自動でHTMLのコードを生成しているので、 すべてJavaScriptで扱ってしまうと大変なケースがあり、、、

そういうわけで、ちょっとニッチですが今回はサーバーサイドが生成したHTMLを活かしつつ、 ピュアなJavaScriptでこのへんを実装していくような形で考えていきます。

またそれを考えていくなかで、タイトルにあるようなJavaScriptののCustomEventを使うと、 親子関係の密結合な要素をイベントを通じた疎結合な実装に出来るかなと思ったので、 そのへんを書いていきます。

developer.mozilla.org

HTML部分

HTMLは以下のような形です、シンプルなTODOリスト的な構成です。

全選択のCheckBoxはcustom-all-checkboxというclassが設定されていて、 各レコードのCheckBoxはcustom-checkboxというclassが設定されています。

<!DOCTYPE html>
<html>
  <head>
    <title>Sample App</title>
    <meta charset="UTF-8">
    <script src="https://unpkg.com/vue@2.5.17"></script>
  </head>
  <body>
    <div id="app">
      <table class="todo-list">
        <tr>
          <th><input type="checkbox" class="custom-all-checkbox"></th>
          <th>title</th>
          <th>description</th>
        </tr>
        <tr>
          <td><input type="checkbox" class="custom-checkbox"></td>
          <td>title 1</td>
          <td>description 1</td>
        </tr>
        <tr>
          <td><input type="checkbox" class="custom-checkbox"></td>
          <td>title 2</td>
          <td>description 2</td>
        </tr>
      </table>
    </div>
    <script src="./sample.js"></script>
  </body>
</html>

最初に考えた普通に実装した案

以下が最初に考えてみた案です、そんなに悪くなさそうに見えますが。。。

allCheckBoxElement.addEventListenerの中で直接checkBoxElementsを呼び出している、checkBoxElements.forEachの中でallCheckBoxElementを呼び出しているので、それぞれがそれぞれに依存している密結合な状態になってしまっています。

const allCheckBoxElement = document.querySelector('.custom-all-checkbox')
const checkBoxElements = document.querySelectorAll('.custom-checkbox')

const allChecked = checkBoxes => {
  const unChecked = Array.from(checkBoxes).filter(checkBox => !checkBox.checked)
  return unChecked.length === 0
}

allCheckBoxElement.addEventListener('click', () => {
  checkBoxElements.forEach(checkbox => checkbox.checked = allCheckBoxElement.checked)
})

checkBoxElements.forEach(checkBox => {
  checkBox.addEventListener('click', () => {
    allCheckBoxElement.checked = allChecked(checkBoxElements)
  })
})

そのため、処理を分割しずらく、おそらく今後修正を重ねるたびにコードが多くなっていきますし、全選択だけ他で使いたいような要望があっても使うことは出来ないので、同様のコードのコピペを生みやすくなっていそうです。。。

CustomEventを使って疎結合にする

以下がCustomEventを使って疎結合にしてみた実装です。 先程よりもコード自体は増えてしまっていますが、以下の点がメリットかなと。

  • 各レコードのチェックボックスと全選択のチェックボックス、それぞれが独立しているので、全選択だけ他で使いたいといったことに対応出来る。
  • 処理が各CheckBoxに閉じているのでファイル分割等が容易で、コード量の増加に対応しやすい。
// 各レコードのチェックボックス
const checkBoxElements = document.querySelectorAll('.custom-checkbox')

const allChecked = checkBoxes => {
  const unChecked = Array.from(checkBoxes).filter(checkBox => !checkBox.checked)
  return unChecked.length === 0
}

checkBoxElements.forEach(checkBox => {
  checkBox.addEventListener('click', () => {
    const updateAllCheckedEvent = new CustomEvent('child-checked',
      { detail: { allCheked: allChecked(checkBoxElements) } }
    )
    document.dispatchEvent(updateAllCheckedEvent)
  })
})

document.addEventListener('all-checked', event => {
  checkBoxElements.forEach(checkbox => checkbox.checked = event.detail.allCheked)
})

// 全選択のチェックボックス
const allCheckBoxElement = document.querySelector('.custom-all-checkbox')

allCheckBoxElement.addEventListener('click', () => {
  const allCheckedEvent = new CustomEvent('all-checked',
    { detail: { allCheked: allCheckBoxElement.checked } }
  )
  document.dispatchEvent(allCheckedEvent)
})

document.addEventListener('child-checked', event => {
  allCheckBoxElement.checked = event.detail.allCheked
})

デメリットとしては、以下かなと。。。

  • documentというグローバルな領域にイベントをディスパッチしている
  • 直接値を変更するよりもイベントの発火、補足を経由している分パフォーマンスが良くない(?)

今回は制約事項をなるべくへらすということでdocumentにイベントをディスパッチしましたが、 多少制約が増やしても良いような場合はdocumentではなく、

親/子を含んだ上位の要素(今回のケースだと.todo-list)に、dispatchするような形にして、それを補足するようなコードにすると親/子要素がそれぞれ上位の要素に依存するという制約の中で、疎結合になるかなと思います。

// 各レコードのチェックボックス
const dispatchTo = document.querySelector('.todo-list')
const checkBoxElements = document.querySelectorAll('.custom-checkbox')

const allChecked = checkBoxes => {
  const unChecked = Array.from(checkBoxes).filter(checkBox => !checkBox.checked)
  return unChecked.length === 0
}

checkBoxElements.forEach(checkBox => {
  checkBox.addEventListener('click', () => {
    const updateAllCheckedEvent = new CustomEvent('child-checked',
      { detail: { allCheked: allChecked(checkBoxElements) } }
    )
    dispatchTo.dispatchEvent(updateAllCheckedEvent)
  })
})

dispatchTo.addEventListener('all-checked', event => {
  checkBoxElements.forEach(checkbox => checkbox.checked = event.detail.allCheked)
})

// 全選択のチェックボックス
const dispatchTo = document.querySelector('.todo-list')
const allCheckBoxElement = document.querySelector('.custom-all-checkbox')

allCheckBoxElement.addEventListener('click', () => {
  const allCheckedEvent = new CustomEvent('all-checked',
    { detail: { allCheked: allCheckBoxElement.checked } }
  )
  dispatchTo.dispatchEvent(allCheckedEvent)
})

dispatchTo.addEventListener('child-checked', event => {
  allCheckBoxElement.checked = event.detail.allCheked
})

※パフォーマンスについては測定してないので、なんとも言えない・・・!

おわりに

親子関係を持つような要素をなんかしらの理由から、まとめてJavaScriptで扱えないときにmCustomEventを通じて連携するようにすると疎結合に実装出来そうだなという話でした🙇‍♂️

参考

developer.mozilla.org

CircleCIのorbsを使って設定ファイルを整理するMEMO

CircleCIのorbsをあまり使ってことなかったのですが、使ってみるとだいぶ便利だったので使い方をMEMOしておきます📝

CircleCI orbsとは?

CircleCI Orbs は、ジョブ、コマンド、Executor などの構成要素をまとめた共有可能なパッケージです。 CircleCI では承認済み Orbs に加え、CircleCI パートナーによってオーサリングされたサードパーティ製の Orbs も提供しています。 https://circleci.com/docs/ja/2.0/orb-intro/

CircleCIでの処理をまとめたパッケージがorbsという感じのようです。

実際に使ってみる

まずorbsを使うには設定ファイルでorbs配下に使用するorbsを羅列します。

version: 2.1

orbs:
  ruby: circleci/ruby@1.1.1

これでcircleci/ruby@1.1.1rubyという名前で設定ファイル上にて使用できるようになりました。

circleci/ruby@1.1.1は何が出来るのかというのは下記のドキュメントに記載があります📘

https://circleci.com/orbs/registry/orb/circleci/ruby

このorbsinstall-depsを使用して依存関係を解決するようにしてみます💎

今回は以下のようなcommandsを用意してorbsの機能を利用するようにしてみました。

commands:
  install_ruby_deps:
    steps:
      - ruby/install-deps:
          key: v1-dependencies-{{ checksum "Gemfile.lock" }}
          path: ./vendor/bundle
          with-cache: true

keyにcacheのkey、pathにcacheするパス、with-cacheにcacheするかどうかを渡しています。

orbsを使わないとcacheのrestoreやbundlerのinstall等を自分で書かないといけないと思うのですが、orbsを使うと便利ですね✨

commands:
 configure_bundler:
    steps:
      - run:
          name: Configure Bundler
          command: |
            echo 'export BUNDLER_VERSION=$(cat Gemfile.lock | tail -1 | tr -d " ")' >> $BASH_ENV
            source $BASH_ENV
            gem install bundler -v $BUNDLER_VERSION
  install_ruby_deps:
    steps:
      - run:
          name: install dependencies
          command: bundle install --jobs=4 --clean --path ./vendor/bundle
  cache_ruby_deps:
    steps:
      - save_cache:
          name: Cache ruby dependencies
          paths:
            - ./vendor/bundle
          key: v1-dependencies-{{ checksum "Gemfile.lock" }}
  restore_ruby_deps:
    steps:
      - restore_cache:
          name: Restore ruby dependencies
          keys:
            - v1-dependencies-{{ checksum "Gemfile.lock" }}
            - v1-dependencies-

※commandsについては以前使い方をまとめたので、よろしければこちらも参考にしてください。

madogiwa0124.hatenablog.com

以下にrubocoprspecを実行するサンプルを書いてみました。orbsを使うと非常にスッキリかけますね✨

version: 2.1

orbs:
  ruby: circleci/ruby@1.1.1

web: &web
  - image: circleci/ruby:2.7.1-node-browsers

executors:
  web:
    docker:
      - <<: *web

commands:
  attach_current:
    steps:
      - attach_workspace:
          at: .
  install_ruby_deps:
    steps:
      - ruby/install-deps:
          key: v1-dependencies-{{ checksum "Gemfile.lock" }}
          path: ./vendor/bundle
          with-cache: true

jobs:
  build:
    executor:
      name: web
    steps:
      - checkout
      - persist_to_workspace:
          root: .
          paths:
            - .
  ruby_build:
    executor:
      name: web
    steps:
      - attach_current
      - install_ruby_deps
  ruby_lint:
    executor:
      name: web
    steps:
      - attach_current
      - install_ruby_deps
      - run:
          name: run rubocop
          command: bundle exec rubocop --parallel
  ruby_test:
    executor:
      name: web
    steps:
      - attach_current
      - install_ruby_deps
      - run:
          name: run tests
          command: bundle exec rspec spec/
workflows:
  version: 2
  build:
    jobs:
      - build
      - ruby_build:
          requires:
            - build
      - ruby_lint:
          requires:
            - ruby_build
      - ruby_test:
          requires:
            - ruby_build

今回は使っていませんが、circleci/rubyにはrubocoprspecを実行する機能もあるので、気になった方はそちらも使ってみてはいかがでしょうか💎

それでは。

参考

https://circleci.com/docs/ja/2.0/orb-intro/

https://circleci.com/orbs/registry/orb/circleci/ruby

JavaScript: クロージャーを使ってレキシカル環境ごと関数を渡して処理を共通化するメモ📝

JavaScriptを使った非同期処理を実装するときに、 try-catchで囲うとか、isLoadingsuccessといったstateを用意して、 状態を管理すようなロジックが個人的にそれぞれのメソッドに書きがちになっていて、 毎回実装するのが手間、また実行漏れがあったりと共通化したいなぁと思っていたのですが、 JavaScriptクロージャーを使うをいい感じに共通化出来た気がしたのでメモしておきます📝

クロージャーとは?

MDNのドキュメントを見てみるとクロージャーとは下記とのことです。

クロージャは、関数と、その関数が宣言されたレキシカル環境の組み合わせです。 https://developer.mozilla.org/ja/docs/Web/JavaScript/Closures

例えば以下のような関数があった場合、

function parent(parentName) {
  const childName = 'child name'
  function child() {
    console.log(parentName)
    console.log(childName)
  }
  child()
}

parent('parent name')

結果は下記のようなものになり、childからparentで定義した変数が呼び出せることがわかります。

parent name
child name

つまりクロージャーとは関数(今回で言えばchild)とレキシカル環境(今回で言えばparentで定義された変数群)がセットになったものという理解で良いのかなと思っています。

普通に実装したサンプル

クロージャーについて理解が深まったところでサンプルを見てみます。

以下のようなドキュメントを何かしらの形式に変換するメソッドとドキュメントを保存するメソッドがあるとします。

非同期リクエストを投げるときにはtry-catchで囲んだり、ロード中や成功を管理する変数を操作したりする必要があります。

現状はそれぞれのメソッドに記載しているので、手間がかかるのと実装もれの恐れがあります😢

let state = { isLoading: false, success: false, converted: "" }

const handleOnDocumentConvert = async (to, markdown) => {
  if (state.isLoading) return;
  state.isLoading = true;

  try {
    const response = await convertDocument(to, markdown);
    const responseJson = await response.json();
    state.converted = responseJson.result;
    state.success = true;
  } catch (err) {
    console.error(err);
  } finally {
    state.isLoading = false;
  }
};

const handleOnDocumentSave = async (markdown) => {
  if (state.isLoading) return;
  state.isLoading = true;

  try {
    const response = await saveDocument(markdown, state.documentId);
    const responseJson = await response.json();
    if (!state.documentId) state.documentId = responseJson.documentId;
  } catch (err) {
    console.error(err);
  } finally {
    state.isLoading = false;
  }
};

クロージャーを使って共通化してみる

非同期リクエストを送信するときに諸々必要な処理をsendRequestで行うようにしてみた例が下記です。

sendRequestは諸々必要な処理とともに引数で受け取ったrequestFunctionを実行しています。

requestFunctionは呼び出し時に渡されたクロージャーとなっているため、 呼び出し元の親メソッドの定義された変数等を含んだレキシカル環境とともに関数が渡されます✨

そのため一見定義されてない変数が参照されてエラーになるかと思いきや実行出来るわけですね。

諸々の処理も共通化出来たのでスッキリ + 実装漏れも防げそうです。

const sendRequest = async (requestFunction) => {
  if (state.isLoading) return;
  state.isLoading = true;
  try {
    await requestFunction();
    state.success = true;
  } catch (err) {
    console.error(err);
  } finally {
    state.isLoading = false;
  }
};

const handleOnDocumentConvert = async (to, markdown) => {
  const convertRequestFunction = async () => {
    const response = await convertDocument(to, markdown);
    const responseJson = await response.json();
    state.converted = responseJson.result;
  };
  sendRequest(convertRequestFunction);
};

const handleOnDocumentSave = async (markdown) => {
  const saveRequestFunction = async () => {
    const response = await saveDocument(markdown, state.documentId);
    const responseJson = await response.json();
    if (!state.documentId) state.documentId = responseJson.documentId;
  };
  sendRequest(saveRequestFunction);
};

おわりに

クロージャーを使って関数だけじゃなくてレキシカル環境も渡すことによって、 外部の変数に依存するような関数でも処理を共通化出来るのは便利ですね。

先程下記のように書いたとおり、一見エラーになりそうと思ってしまうのは慣れてないせいもありますが、使いすぎると逆に見にくいコードになってしまうかもですね💦

その関数が実行されるので一見定義されてない変数が参照されてエラーになるかと思いきや実行出来るわけですね。

参考

developer.mozilla.org

Ruby on Rails: WebpackerからピュアなWebpack環境に置き換えるメモ📝

最近個人のRailsアプリケーションをWebpackerからSimpackerを使ったピュアなWebpack環境に切り替えたので、その手順をメモしておきます📝

今回はWebpackerを導入しときのデフォルトなjs/scssなbuild環境を削除して、Webpackで置き換えていきます。

Webpackerを辞める

まずは既存のRailsアプリケーションからWebpackerを外していきます。

新規でRailsアプリケーション作る場合は当該手順は不要で単にrails new –skip-javascriptを実行して、js環境をrails new時に作るのをやめてあげると良いかと思います。
※Webpackで管理するならsproketsturbolinksもいらない気がするので、上記に加えて--skip-sprockets--skip-turbolinksもつけておくと良さそうです。

Webpackerをuninstallする

Gemfileからwebpackerpackage.jsonから@rails/webpackerを削除します。 ※webpack --watchで十分な場合はwebpack-dev-serverpackage.jsonから削除します。

その後bundle installyarn installを実行してlockファイルからも消します。

Webpacker関連ファイルを削除する

unistallが完了したあと、Webpackerが作成した各種ファイルを削除します。

  • .browserslistrc
  • babel.config.js
  • bin/webpack
  • postcss.config.js
  • config/webpack配下の各種ファイル

webpack-dev-serverをuninstallした場合はbin/webpack-dev-serverも削除します。

これでWebpackerを辞めることが出来ました。

ピュアなWebpack環境を作る

Webpackの導入

Webpackの導入については、過去に基本的な使い方をまとめた記事を書いているので、そちらを参考にしてください。

madogiwa0124.hatenablog.com

一応私が作ったjs/scssのbuild環境のwebpack.config.jsをサンプルとして記載しておきます。 ポイントは、Rails側で読み込むべきbuild後のjs及びcssを判定するためにWebpackAssetsManifestで結果をjsonファイルで出力する必要があるところです👷‍♀️

const glob = require("glob");
const path = require("path");

function entryName(RootPath, filePath) {
  const dirName = filePath
    .replace(RootPath, "")
    .replace(path.basename(filePath), "");
  return `${dirName}${path.basename(filePath, path.extname(filePath))}`;
}

/**
 * 指定したentryのルートディレクトリ配下のjsまたはtsファイルのファイル名とパスのobjectを取得
 * 例)
 *  - /entries/foo.ts => { foo: "/entries/foo.ts" }
 *  - /entries/bars/bar.ts => { "bars/bar": "/entries/bars/bar.ts" }
 * @param {string} entryRoot entryのルートディレクトリ
 */
const getEntries = function getEntries(entryRoot) {
  const ret = {};
  const filePaths = glob.sync(`${entryRoot}/**/*.{js,ts}`);
  filePaths.forEach((filePath) => {
    ret[entryName(entryRoot, filePath)] = filePath;
  });
  return ret;
};

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const WebpackAssetsManifest = require("webpack-assets-manifest");

const JAVASCRIPT_ENTRY_PATH = "./app/javascript/packs/";
const entries = getEntries(JAVASCRIPT_ENTRY_PATH);
const { NODE_ENV } = process.env;
const isProd = NODE_ENV === "production";

module.exports = {
  mode: isProd ? "production" : "development",
  entry: entries,
  output: {
    path: `${__dirname}/public/packs`,
    publicPath: "/packs/",
    filename: "[name]-[hash].js",
  },
  module: {
    rules: [
      {
        test: /\.scss|\.css/,
        use: [MiniCssExtractPlugin.loader, "css-loader", "sass-loader"],
      },
      {
        test: /\.m?js$/,
        exclude: /(node_modules|bower_components)/,
        use: {
          loader: "babel-loader",
          options: {
            presets: ["@babel/preset-env"],
            plugins: ["@babel/plugin-transform-runtime"],
          },
        },
      },
    ],
  },
  resolve: {
    alias: {
      "@js": `${__dirname}/app/javascript`,
      "@css": `${__dirname}/app/javascript/stylesheets`,
    },
  },
  plugins: [
    new MiniCssExtractPlugin({ filename: "[name]-[hash].css" }),
    new CleanWebpackPlugin(),
    new WebpackAssetsManifest({ publicPath: true })
  ],
};

またbuild用のscriptもpackage.json内に以下のような感じで定義しました。

  "scripts": {
    "webpack": "webpack",
    "webpack-prod": "webpack --mode=production",
    "webpack-watch": "webpack --watch",
  },

ビルドしたjsを読み込むhelperを作る

Webpackerを使うとapplication_pack_tagのようなhelperが自動で使えるようになりますが、ピュアなWebpack環境では自身でWebpackのbuild時に作成されたmanifestを読み込んでファイルパスを返すようなhelperを独自で作る必要があります。 ※またアクセス時のbuildチェックもして、自動でbuildを走らせるような処理も行われません。

そのため今回はSimpackerというgemを使ってapplication_pack_tagを再現します。

github.com

Simpackerの導入は、 Gemfileに下記を追加してbundle installします。

gem 'simpacker'

まだwebpack環境を作っていない人はrails simpacker:installを実行するとSimpackerがシンプルな環境を作ってくれます。

作成される環境はこちらを参照

simpacker/lib/install at master · hokaccha/simpacker · GitHub

yarnではなくnpmで環境が作成されるので注意してください。

すでにWebpack環境を作成済みの人は、下記のsimpacker.ymlconfig配下に配置して自身の設定に合わせて編集すれば問題ないと思います🙆‍♂️

simpacker/simpacker.yml at master · hokaccha/simpacker · GitHub

これでapplication_pack_tagが定義されて、Webpackでbuildしたjs/cssrails側で読み込めるようになりました🙌

おわりに

今回はWebpackerからSimpackerを使ってピュアなWebpack環境に置き換える手順をメモしました📝

素のWebpackerのまま使う時は非常に導入も楽なのですが、ちょっとカスタマイズが必要になってくるとカオスになってくるので、自分もまだフロントエンドまわりは分からないことが多いですが、複雑になったらWebpackerからWebpackに乗り換えるといったことや、最初からWebpackを選択するようなことが出来るようになっておきたいですね💦(実際のサービスを置き換えるときには、こんなにすんなりとはいかないと思いますが。。。)

参考

inside.pixiv.blog

techlife.cookpad.com