Rails、Webpack、Vuejs を統合する

高洛峰
リリース: 2016-11-08 14:33:41
オリジナル
1407 人が閲覧しました

前書き

vue + webpack の入門に関する私の記事を読んだことがある方は、その中で従来の MVC フレームワーク (Rails、ASP.NET MVC) などと簡単に組み合わせることができると述べました。この記事は主に、その方法と私がなぜそう言うのかを紹介するために使用されます。

まず認めさせてください!必ずしも SPA のアーキテクチャを使用する必要があるわけではありませんが、多くの小規模なプロジェクト、つまり UI/UX デザイン自体が非常にシンプルなプロジェクトの場合は、時間の無駄です。したがって、この記事のアプローチは、プロジェクトで API アーキテクチャを使用した SPA を使用しないが、開発ニーズには既存の MVC フレームワークで十分だと感じており、これらのフレームワークによって提供される機能のほとんどを保持したいと考えているユーザーを対象としています (この記事Rails のみに導入された記事)。

インターネット上には Rails と Webpack を統合する方法がたくさんあり、実際には非常に優れています。この記事では、私が適切だと思う統合方法の 1 つだけを紹介します。多くの概念は新しいものではなく、過去の学習経験からまとめられたものです。

要件

まず要件と、アセット パイプラインを使用しない理由について説明します。実際、それは単純に次のリストです:

Babel を必要とする ES2015 など、新しくサポートされた構文を使用したいと考えています。

bower でコンパイルされたパッケージの多くは不完全、サポートされていない、または npm の使用を引き起こすその他の問題を抱えています。

私たちはモジュールを通じて依存関係の問題を管理したいと考えています。

assets-org と gem は、フロントエンド アセット バンドルの管理にはあまり適していません。

展開ができるだけ簡単であることを願っています。

準備

一般に、この統合アーキテクチャは npm (yarn も利用可能) を使用してフロントエンド リソースを管理し、gem はバックエンド部分のみを担当します。

nodejsとnpmをインストールする

rubyとRailsをインストールする

Postgre SQLをインストールする(Herakuへのデプロイメントをデモンストレーションするため、Postgre SQLを使用します)

ステップ1 - プロジェクトを作成する

# 由於示範部署至 Heroku
# 因此無法使用 sqlite3
$ rails _5.0.0.1_ new [project_name] --database postgresql
$ cd [project_name]
$ bundle install
$ rails db:create

$ rails g controller pages index # 建立測試頁面
$ rails server # 完成建立一個 Rails 專案
# 開啟 http://localhost:3000/pages/index
ログイン後にコピー

npmをインポートする

$ npm init --yes

# 過程中,如果需要測試一下 webpack 指令則需要安裝全域
$ npm i -g webpack webpack-dev-server

# 安裝所需的前端函式庫
$ npm i jquery@^2.1.4 -S
$ npm i jquery-ujs@~1.1.0-1 -S
$ npm i lodash@~3.0.0 -S
$ npm i vue -S

# 安裝開發環境所需的套件與函式庫
$ npm i webpack \
    webpack-dev-server \
    webpack-manifest-plugin \
    extract-text-webpack-plugin -D

$ npm i babel-core \
    babel-loader babel-runtime \
    babel-plugin-transform-runtime \
    babel-preset-es2015 -D # Babel 相關

$ npm i coffee-loader coffee-script -D

$ npm i css-loader \
    style-loader \
    node-sass \
    sass-loader -D

$ npm i exports-loader -D # 匯出檔案
$ npm i expose-loader -D # 將物件加到全域(Javascript)
$ npm i file-loader url-loader -D
$ npm i imports-loader -D # 使用的模組可相依於全域
ログイン後にコピー

迅速なインストールを容易にするために、Package.json が以下に提供されています

"dependencies": {
  "jquery": "^2.2.4",
  "jquery-ujs": "^1.1.0-1",
  "lodash": "^3.0.1",
  "vue": "^2.0.5"
},
"devDependencies": {
  "babel-core": "^6.18.2",
  "babel-loader": "^6.2.7",
  "babel-plugin-transform-runtime": "^6.15.0",
  "babel-preset-es2015": "^6.18.0",
  "babel-runtime": "^6.18.0",
  "coffee-loader": "^0.7.2",
  "coffee-script": "^1.11.1",
  "css-loader": "^0.25.0",
  "exports-loader": "^0.6.3",
  "expose-loader": "^0.7.1",
  "extract-text-webpack-plugin": "^1.0.1",
  "file-loader": "^0.9.0",
  "imports-loader": "^0.6.5",
  "node-sass": "^3.11.2",
  "sass-loader": "^4.0.2",
  "style-loader": "^0.13.1",
  "url-loader": "^0.5.7",
  "webpack": "^1.13.3",
  "webpack-dev-server": "^1.16.2",
  "webpack-manifest-plugin": "^1.1.0"
},
"babel": {
  "presets": [
    "es2015"
  ],
  "plugins": [
    [
      "transform-runtime",
      {
        "polyfill": false,
        "regenerator": true
      }
    ]
  ]
}
ログイン後にコピー

ステップ 2 - 組織構造

デプロイとその後の設定を簡単にするために、JavaScript と CSS リソース ファイルを元のディレクトリに抽出することにしました。 Rails のデフォルトの関連機能を保持します。大まかな構成は次のようになります。もちろん、フロントエンドのリソースをすべて client ディレクトリに配置します。名前を付けたい場合は、webpack またはfrontend を使用しても問題ありません。

.
├── /app
│   ├── /assets
│   ├── /controllers
│   ├── /views
│   └── ...
├── /bin
├── /config
├── /db
├── /public
├── ...
├── /client
│   ├── /fonts
│   ├── /images
│   ├── /javascripts
│   ├── /stylesheets
│   ├── development.config.js
│   ├── production.config.js
└── ...
ログイン後にコピー

コマンドで制御されていない限り、エディタを使用して関連するファイルやディレクトリを作成できます

$ mkdir -p client/javascripts
$ mkdir client/fonts
$ mkdir client/images
$ mkdir client/stylesheets
# 新增 Entry Point 檔案
$ touch client/javascripts/application.js
$ touch client/javascripts/home.coffee # 測試 coffee 使用
$ touch client/fonts/.keep
$ touch client/images/.keep
$ touch client/stylesheets/home.scss

$ touch client/development.config.js
$ touch client/production.config.js
ログイン後にコピー

このプロジェクトには Nodejs が混在しているため、関連する設定を .gitignore に追加する必要があることに注意してください。

# Node
node_modules
jspm_packages
.npm
.eslintcache
npm-debug.log*
pids
*.pid
*.seed
*.pid.lock
.nyc_output
.grunt
.lock-wscript
build/Release
ログイン後にコピー

簡単な検証例

javascripts/home.coffee

console.log "Hello, CoffeeScript!"
ログイン後にコピー

stylesheets/home.scss

/* 記得放一張圖片 */.home-banner { 
 background-image: url('../images/banner.png');
 }
ログイン後にコピー

javascripts/application.js

import styles from '../stylesheets/home.scss'
import Home from './home'
ログイン後にコピー

ステップ 3 - Webpack を構成する

development.config.js

var path = require('path')
var _ = require('lodash')
var webpack = require('webpack')
var assetsPath = path.join(__dirname, '..', 'public', 'assets')
var ExtractTextPlugin = require('extract-text-webpack-plugin')

var config = {
  context: path.join(__dirname, '..'),
  entry: {
    /* 定義進入點與其檔案名稱 */
    application: [
      path.join(__dirname, '/javascripts/application.js')
    ]
  },
  output: {
    path: assetsPath,
    filename: '[name]-bundle.js',
    publicPath: '/assets/'
  },
  resolve: {
    extensions: ['', '.js', '.coffee', '.json']
  },
  debug: true,
  displayErrorDetails: true,
  outputPathinfo: true,
  devtool: 'cheap-module-eval-source-map',
  module: {
    loaders: [
      {
        test: require.resolve('jquery'),
        loader: 'expose?jQuery'
      },
      {
        test: require.resolve('jquery'),
        loader: 'expose?$'
      },
      {
        test: /\.js$/,
        loader: 'babel',
        exclude: /node_modules/
      },
      {
        test: /\.coffee$/,
        loader: 'coffee'
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)\??.*$/,
        loader: 'url?limit=8192&name=[name].[ext]'
      },
      {
        test: /\.(jpe?g|png|gif|svg)\??.*$/,
        loader: 'url?limit=8192&name=[name].[ext]'
      },
      {
        test: /\.css$/,
        loader: ExtractTextPlugin.extract('style', 'css')
      },
      {
        test: /\.scss$/,
        loader: ExtractTextPlugin.extract('style', 'css!sass')
      }
    ]
  },
  plugins: [
    new webpack.ProvidePlugin({
      $: 'jquery',
      jQuery: 'jquery'
    }),
    new ExtractTextPlugin('[name]-bundle.css', {
      allChunks: true
    })
  ]
}

module.exports = config
ログイン後にコピー

production.config.js

var path = require('path')
var _ = require('lodash')
var webpack = require('webpack')
var assetsPath = path.join(__dirname, '..', 'public', 'assets')
var ExtractTextPlugin = require('extract-text-webpack-plugin')
var ManifestPlugin = require('webpack-manifest-plugin')

var config = {
  context: path.join(__dirname, '..'),
  entry: {
    application: path.join(__dirname, '/javascripts/application.js')
  },
  output: {
    path: assetsPath,
    filename: '[name]-bundle-[chunkhash].js',
    publicPath: '/assets/'
  },
  resolve: {
    extensions: ['', '.js', '.coffee', '.json']
  },
  debug: true,
  displayErrorDetails: true,
  outputPathinfo: true,
  devtool: 'cheap-module-eval-source-map',
  module: {
    loaders: [
      {
        test: require.resolve('jquery'),
        loader: 'expose?jQuery'
      },
      {
        test: require.resolve('jquery'),
        loader: 'expose?$'
      },
      {
        test: /\.js$/,
        loader: 'babel',
        exclude: /node_modules/
      },
      {
        test: /\.coffee$/,
        loader: 'coffee'
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)\??.*$/,
        loader: 'url?limit=8192&name=[name]-[hash].[ext]'
      },
      {
        test: /\.(jpe?g|png|gif|svg)\??.*$/,
        loader: 'url?limit=8192&name=[name]-[hash].[ext]'
      },
      {
        test: /\.css$/,
        loader: ExtractTextPlugin.extract('style', 'css')
      },
      {
        test: /\.scss$/,
        loader: ExtractTextPlugin.extract('style', 'css!sass')
      }
    ]
  },
  plugins: [
    new webpack.ProvidePlugin({
      $: 'jquery',
      jQuery: 'jquery'
    }),
    new ExtractTextPlugin('[name]-bundle-[chunkhash].css', {
      allChunks: true
    }),
    new ManifestPlugin({
      fileName: 'client_manifest.json'
    })
  ]
}

module.exports = config
ログイン後にコピー

完成之後執行,測試看看是否有地方錯誤。如果您對於 webpack 並不陌生可以閱讀設定檔理解一下。

$ webpack --config client/development.config.js
$ webpack --config client/production.config.js
ログイン後にコピー

查看 public/assets 目錄底下看看編譯的結果。

第四步 - 整合 Rails

到此您應該已經理解,我們就是將前端的部份交給 webpack 處理,然後遵循 Rails 的架構將最終的資源檔編譯輸出到 public/assets。

現在的問題是,我們該如何讀取編譯好的資源呢?

由於我們希望能夠盡可能的遵循 Rails 的慣例,因此下一步我們在 app/views/layouts/application.html.erb 放入

<%= client_stylesheet_link_tag &#39;application&#39; %>
<%= client_javascript_include_tag &#39;application&#39; %>
ログイン後にコピー

上面是我們希望的作法(看起來就像是預設支援),所以接著下來我們需要做一些修改好讓 Rails 支援上面兩個 helpers。

首先因為 webpack 加上 hash 的編譯結果,Rails 並無法得知對應的檔案。於是我們需要在 production.config.js 使用ManifestPlugin 匯出 manifest 讓 Rails 得知如何對應檔案。

config/application.rb

require_relative &#39;boot&#39;

require &#39;rails/all&#39;

Bundler.require(*Rails.groups)

module RailsWepback
  class Application < Rails::Application
    config.webpack = {
      asset_manifest: {}
    }
  end
end
ログイン後にコピー

新增 config/initializers/webpack.rb

asset_manifest = Rails.root.join(&#39;public&#39;, &#39;assets&#39;, &#39;client_manifest.json&#39;)

if File.exist?(asset_manifest)
  Rails.configuration.webpack[:manifest] = JSON.parse(
    File.read(asset_manifest)
  ).with_indifferent_access
end
ログイン後にコピー

完成這些前置作業之後我們可以讀取到 manifest 了,但您知道的;原生的 Rails 並沒有剛剛那兩個 helpers ,我們需要在app/helplers/application_helper.rb 加上

def client_javascript_include_tag(name)
  filename = "#{name}-bundle.js"
  asset_url = Rails.application.config.asset_host
  src = "#{asset_url}/assets/#{filename}"

  if Rails.env.development?
  elsif Rails.configuration.webpack[:manifest]
    asset_name = Rails.configuration.webpack[:manifest]["#{name}.js"]
    if asset_name
      src = "#{asset_url}/assets/#{asset_name}"
    end
  end
  "<script src=\"#{src}\"></script>".html_safe
end

def client_stylesheet_link_tag(name)
  filename = "#{name}-bundle.css"
  asset_url = Rails.application.config.asset_host
  src = "#{asset_url}/assets/#{filename}"

  if Rails.env.development?
  elsif Rails.configuration.webpack[:manifest]
    asset_name = Rails.configuration.webpack[:manifest]["#{name}.css"]
    if asset_name
      src = "#{asset_url}/assets/#{asset_name}"
    end
  end
  "<link rel=\"stylesheet\" href=\"#{src}\">".html_safe
end
ログイン後にコピー

這上面的 asset_host 是為了讓事情單純一點,我們選擇在 config/environments/development.rb 和 production.rb 加上

# 路徑請依據實際的狀況調整
config.action_controller.asset_host = &#39;127.0.0.1:3000&#39;
ログイン後にコピー

您可以採取任何您覺得更好的方式取得路徑。

完成第一階段

到這一步,其實我們已經完成最上面我們列出的需求了。

先在 terminal 中執行

$ webpack --config client/development.config.js --watch

開啟另一個 session 執行

$ rails server

最後在 views/pages/index.html.erb 中的任一 tag 補上 class="home-banner" 您就可以觀察到變化。webpack 在背後一直觀察client 底下檔案的變化,Rails 的開發伺服器則負責原來的工作,載入那些編譯後的檔案。

只是這樣每一次要測試都要打兩條指令很麻煩。

優化開發指令

新增 npm scripts

再往下走之前,我們可以先把 webpack 的指令與其單配的參數先整理到 npm script

"scripts": {
  "build:dev": "webpack --config=client/development.config.js --display-reasons --display-chunks --progress --color --watch",
  "build": "webpack --config=client/production.config.js -p"
}
ログイン後にコピー

接著為了讓我們往後只使用一道指令就能夠輕鬆寫意的開發。最簡單的方式就是使用 foreman

$ gem install foreman

安裝完 foreman 之後我們需要設定 Procfile 讓其為我們同時啟動兩道指令。

新增 Procfile.dev

Procfile support 類型程式預設使用 Procfile 為設定檔,如果直接使用該檔案可能會遇到問題,例如:當我們要使用 Heroku 的話,後續可能在部署的時候產生問題,主要是目前的設定僅限於開發階段使用,於是我們改使用其他的檔名 Procfile.dev。

在專案跟目錄下新增 Procfile.dev

web: bundle exec rails server -p 3000
webpack: npm run build:dev
ログイン後にコピー

使用 foreman

$ foreman s -f Procfile.dev

啟動後您應該看到類似的訊息:

18:30:56 web.1     | started with pid 16693
18:30:56 webpack.1 | started with pid 16694
18:30:57 webpack.1 |
18:30:57 webpack.1 | > example@1.0.0 build:dev /Users/andyyou/Workspace/sandbox/rails_vuejs_integrate_1/example
18:30:57 webpack.1 | > webpack --config=client/development.config.js --display-reasons --display-chunks --progress --color --watch
18:30:57 webpack.1 |
Hash: 2fdcbe01c557b347442e
18:31:00 web.1     | => Booting Puma
18:31:00 webpack.1 | Version: webpack 1.13.3
18:31:00 web.1     | => Rails 5.0.0.1 application starting in development on http://localhost:3000
18:31:00 webpack.1 | Time: 2893ms
ログイン後にコピー

支援 Hot Reload

基本上到了上一步就已經可以滿足大多數的開發情境,不過您可能也聽多了關於 Hot Replacement Mode (HRM) 的優點,如果我們也想支援呢?

原理上很單純,我們只需要讓前端資源檔換成是由 webpack-dev-server 所提供即可。

新增 devserver.config.js

注意到基本上這邊只有加入 webpack/hot/dev-server 和 publicPath 不同的差異,可以有更精簡的方式,不過這邊為了讓之後維護比較明顯直覺一點所以將其獨立一個檔案:

var path = require(&#39;path&#39;)
var _ = require(&#39;lodash&#39;)
var webpack = require(&#39;webpack&#39;)
var assetsPath = path.join(__dirname, &#39;..&#39;, &#39;public&#39;, &#39;assets&#39;)
var ExtractTextPlugin = require(&#39;extract-text-webpack-plugin&#39;)

var config = {
  context: path.join(__dirname, &#39;..&#39;),
  entry: {
    application: [
      &#39;webpack/hot/dev-server&#39;,
      path.join(__dirname, &#39;/javascripts/application.js&#39;)
    ]
  },
  output: {
    path: assetsPath,
    filename: &#39;[name]-bundle.js&#39;,
    publicPath: &#39;http://localhost:8080/assets/&#39;
    /* publicPath: &#39;/assets/&#39; */
  },
  resolve: {
    extensions: [&#39;&#39;, &#39;.js&#39;, &#39;.coffee&#39;, &#39;.json&#39;]
  },
  debug: true,
  displayErrorDetails: true,
  outputPathinfo: true,
  devtool: &#39;cheap-module-eval-source-map&#39;,
  module: {
    loaders: [
      {
        test: require.resolve(&#39;jquery&#39;),
        loader: &#39;expose?jQuery&#39;
      },
      {
        test: require.resolve(&#39;jquery&#39;),
        loader: &#39;expose?$&#39;
      },
      {
        test: /\.js$/,
        loader: &#39;babel&#39;,
        exclude: /node_modules/
      },
      {
        test: /\.coffee$/,
        loader: &#39;coffee&#39;
      },
      {
        test: /\.(woff|woff2|eot|ttf|otf)\??.*$/,
        loader: &#39;url?limit=8192&name=[name].[ext]&#39;
      },
      {
        test: /\.(jpe?g|png|gif|svg)\??.*$/,
        loader: &#39;url?limit=8192&name=[name].[ext]&#39;
      },
      {
        test: /\.css$/,
        loader: ExtractTextPlugin.extract(&#39;style&#39;, &#39;css&#39;)
      },
      {
        test: /\.scss$/,
        loader: ExtractTextPlugin.extract(&#39;style&#39;, &#39;css!sass&#39;)
      }
    ]
  },
  plugins: [
    new webpack.ProvidePlugin({
      $: &#39;jquery&#39;,
      jQuery: &#39;jquery&#39;
    }),
    new ExtractTextPlugin(&#39;[name]-bundle.css&#39;, {
      allChunks: true
    })
  ]
}

module.exports = config
ログイン後にコピー

增加 npm scripts

"dev": "webpack-dev-server --config=client/devserver.config.js --inline --hot --no-info"
ログイン後にコピー

更新 app/helpers/application.rb

因為目前有兩種開發模式,所以我們透過環境變數 HRM=true 來區分支不支援 HRM 模式。這邊遭遇的問題就單純是路徑不一樣,您絕對可自行調整優化,而這篇文章旨在記錄這個流程與概念。

def client_javascript_include_tag(name)
  filename = "#{name}-bundle.js"
  asset_url = Rails.application.config.asset_host
  src = "#{asset_url}/assets/#{filename}"

  if Rails.env.development?
    if ENV["HRM"]
      src = "http://localhost:8080/assets/#{filename}"
    else
      src = src
    end
  elsif Rails.configuration.webpack[:manifest]
    asset_name = Rails.configuration.webpack[:manifest]["#{name}.js"]
    if asset_name
      src = "#{asset_url}/assets/#{asset_name}"
    end
  end
  "<script src=\"#{src}\"></script>".html_safe
end

def client_stylesheet_link_tag(name)
  filename = "#{name}-bundle.css"
  asset_url = Rails.application.config.asset_host
  src = "#{asset_url}/assets/#{filename}"

  if Rails.env.development?
    if ENV["HRM"]
      src = "http://localhost:8080/assets/#{filename}"
    else
      src = src
    end
  elsif Rails.configuration.webpack[:manifest]
    asset_name = Rails.configuration.webpack[:manifest]["#{name}.css"]
    if asset_name
      src = "#{asset_url}/assets/#{asset_name}"
    end
  end
  "<link rel=\"stylesheet\" href=\"#{src}\">".html_safe
end
ログイン後にコピー

Procfile.devserver

web: bundle exec rails server -p 3000
webpack: npm run dev
ログイン後にコピー

使用指令

# 平常開發模式
$ foreman s -f Procfile.dev

# 支援 Hot Reload
$ HRM=true foreman start -f Procfile.devserver

# Ctrl + C 中止
ログイン後にコピー

漸進式的 Vue.js 是 MVC 框架的好朋友

在 Javascript 當道的今天我想您很容易找到很多關於 SPA 的主流作法。但這篇文章要說明的是把 JS 當作配角的作法。

首先我們需要更新設定檔,使其支援主角 Vue.js v2

$ npm i -g vue-loader vue-hot-reload-api -D
ログイン後にコピー

webpack 所有的 config loader 的部分補上:

{
  test: /\.vue$/,
  loader: &#39;vue&#39;}
ログイン後にコピー

另外 resolve 的部分,因為我們需要 standalone 版本的功能所以需要下面設定:

resolve: {
  extensions: [&#39;&#39;, &#39;.js&#39;, &#39;.coffee&#39;, &#39;.json&#39;],  /**
   * Vue v2.x 之後 NPM Package 預設只會匯出 runtime-only 版本,若要使用 standalone 功能則需下列設定
   */
  alias: {
    vue: &#39;vue/dist/vue.js&#39;
  }
}
ログイン後にコピー

首先新增元件

$ mkdir client/javascripts/components
$ touch client/javascripts/components/Car.vue
ログイン後にコピー

Car.vue 範例程式如下

<script>
export default {
  data () {
    return {
      brand: &#39;BMW 3 Series&#39;,
      mileage: 0
    }
  },

  mounted () {
    this.handle = setInterval(() => {
      this.mileage++
    }, 1000)
  },

  destroyed () {
    clearInterval(this.handle)
  }
}
</script>

<style scoped>
$pink: pink;

.brand {
  color: $pink;
  font-size: 1.4em;
}
</style>
ログイン後にコピー

更新 application.js

import styles from &#39;../stylesheets/home.scss&#39;
import Home from &#39;./home&#39;
import Vue from 'vue'
import Car from './components/Car.vue'

document.addEventListener('DOMContentLoaded', function () {
  new Vue({
    el: '#app',
    data: {
      message: 'Hello, Rails with Vue.js'
    },
    components: {
      car: Car
    }
  })
})
ログイン後にコピー

app/views/pages/index.html.erb

<div id="app" v-cloak>
  <h1 class="home-banner">{{ message }}</h1>
  <car inline-template>
    <div>
      I am <span class="brand">{{ brand }}</span>! I runned {{ mileage }}.
      The important thing is the variable from controller#action wheel is <%= @wheel %>
    </div>
  </car>
</div>
ログイン後にコピー

如果您曾使用過 Vue.js 可能會覺得這樣好奇怪,為什麼 component 裡面沒有