머리말
vue + webpack 시작에 대한 내 기사를 읽어보신 적이 있다면, 이 기사에서 전통적인 MVC 프레임워크(Rails, ASP.NET MVC) 등과 결합하기 쉽다고 언급했습니다. 이 글은 주로 그것이 어떻게 이루어지는지, 그리고 내가 이 말을 하는 이유를 소개하는 데 사용됩니다.
먼저 인정하겠습니다! SPA의 아키텍처를 항상 사용할 필요는 없지만 많은 소규모 프로젝트의 경우 또는 UI/UX 디자인 자체가 매우 간단한 프로젝트라고 해야 할까요? 시간 낭비입니다. 따라서 이 문서의 접근 방식은 API 아키텍처와 함께 SPA를 사용하는 프로젝트가 아니라 기존 MVC 프레임워크가 개발 요구 사항에 충분하다고 생각하고 이러한 프레임워크에서 제공하는 대부분의 기능을 유지하려는 프로젝트를 대상으로 합니다. Rails에만 소개된 기사).
인터넷에서 Rails와 Webpack을 통합하는 방법은 여러 가지가 있으며 실제로는 매우 좋습니다. 이 기사에서는 내가 옳다고 생각하는 통합 방법 중 하나만 소개합니다. 많은 개념은 새로운 것이 아니며 단지 과거의 학습 경험에서 수집된 것입니다.
요구 사항
먼저 우리의 요구 사항과 자산 파이프라인을 사용하지 않는 이유에 대해 이야기해 보겠습니다. 실제로는 단순히 다음 목록입니다.
새롭게 사용하고 싶습니다. ES2015와 같은 지원되는 구문에는 Babel이 필요합니다.
bower에 컴파일된 많은 패키지는 불완전하거나 지원되지 않거나 npm을 사용하게 만드는 다른 문제가 있습니다.
모듈을 통해 종속성을 관리하고 싶습니다.
assets-org와 gem은 프런트엔드 에셋 번들을 관리하는 데 그다지 적합하지 않습니다.
배포가 최대한 간단했으면 좋겠습니다.
준비
일반적으로 이 통합 아키텍처는 npm(yarn도 사용할 수 있음)을 사용하여 프런트엔드 리소스를 관리하는 반면 gem은 백엔드 부분만 담당합니다.
nodejs 및 npm 설치
Ruby 및 Rails 설치
Postgre SQL 설치(데모에서 Heroku에 배포되므로 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의 기본 관련 기능을 유지하기로 결정했습니다. 대략적인 구조는 다음과 같습니다. 모든 프런트엔드 리소스를 클라이언트 디렉터리 아래에 배치합니다. 물론 이름을 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 可能暫時是不錯的方式。當然如果您有更優秀的作法也歡迎您給小弟一些建議。