前書き
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 'application' %> <%= client_javascript_include_tag 'application' %>
上面是我們希望的作法(看起來就像是預設支援),所以接著下來我們需要做一些修改好讓 Rails 支援上面兩個 helpers。
首先因為 webpack 加上 hash 的編譯結果,Rails 並無法得知對應的檔案。於是我們需要在 production.config.js 使用ManifestPlugin 匯出 manifest 讓 Rails 得知如何對應檔案。
config/application.rb
require_relative 'boot' require 'rails/all' 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('public', 'assets', 'client_manifest.json') 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 = '127.0.0.1:3000'
您可以採取任何您覺得更好的方式取得路徑。
完成第一階段
到這一步,其實我們已經完成最上面我們列出的需求了。
先在 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('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: [ 'webpack/hot/dev-server', path.join(__dirname, '/javascripts/application.js') ] }, output: { path: assetsPath, filename: '[name]-bundle.js', publicPath: 'http://localhost:8080/assets/' /* 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
增加 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: 'vue'}
另外 resolve 的部分,因為我們需要 standalone 版本的功能所以需要下面設定:
resolve: { extensions: ['', '.js', '.coffee', '.json'], /** * Vue v2.x 之後 NPM Package 預設只會匯出 runtime-only 版本,若要使用 standalone 功能則需下列設定 */ alias: { vue: 'vue/dist/vue.js' } }
首先新增元件
$ mkdir client/javascripts/components $ touch client/javascripts/components/Car.vue
Car.vue 範例程式如下
<script> export default { data () { return { brand: 'BMW 3 Series', 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 '../stylesheets/home.scss' import Home from './home' 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 裡面沒有 。 主要因為 Vue 有支援 inline-template 這種作法,這讓我們還可以延續使用從 controller action 來的資料,像是您想繼續使用 Rails 的 i18n 機制。
當然有人可能會說 template 抽到 views 那怎麼重複使用元件,使用 Rails 的 partial 就好了。
重點是這裡不是試圖提出一種萬靈丹,而是提供另一種思路,該怎麼樣使用取決於您的需求。
另外您也可以繼續使用 slim
div id="app" v-cloak="" car inline-template="" div | {{ message }} = @wheel
部署至 Heroku
很高興您能看到這一步,這個過程其實挺累人的。最後當我們完成這一系列的修改,我們還是希望能夠部署到一些方便的服務上,這邊就舉 Heroku 來示範。
第一步我們需要先在 package.json 補上一些設定,好讓 Heroku 在安裝完 Node 環境之後可以幫我們執行 webpack
"scripts": { ..., "heroku-postbuild": "npm run build" }
部署
$ heroku login $ heroku create --app [your_app_name] $ heroku buildpacks:clear $ heroku buildpacks:set heroku/nodejs $ heroku buildpacks:add heroku/ruby --index 2 # 安裝 package.json devDependencies 的部分 $ heroku config:set NPM_CONFIG_PRODUCTION=false $ git push heroku master
整體來說如果您有這些前端複雜的需求,使用 webpack 可能暫時是不錯的方式。當然如果您有更優秀的作法也歡迎您給小弟一些建議。