如何在Ubuntu 20.04上設置帶有React前端的Ruby on Rails v7項目

作者選擇了電子前線基金會作為Write for DOnations計劃的捐贈對象。

介紹

Ruby on Rails 是一個流行的服務器端 Web 應用框架。它驅動了今天網絡上存在的許多熱門應用程序,如GitHubBasecampSoundCloudAirbnbTwitch。Ruby on Rails 強調程序員的體驗和周圍建立的熱情社區,將為您提供構建和維護現代 Web 應用程序所需的工具。

React 是一個用於創建前端用戶界面的 JavaScript 库。由 Facebook 支持,它是當今網絡上使用最廣泛的前端庫之一。React 提供了虛擬文檔對象模型(virtual Document Object Model (DOM))、組件架構狀態管理等功能,使前端開發過程更加組織化和高效。

隨著網頁前端向與服務器端代碼分離的框架發展,將Rails的優雅與React的效率結合起來,可以讓您建立強大且現代的應用程序,並受到當前趨勢的影響。通過在Rails視圖中使用React來渲染組件(而不是Rails模板引擎),您的應用程序將受益於JavaScript和前端開發的最新進展,同時利用Ruby on Rails的表達能力。

在本教程中,您將創建一個Ruby on Rails應用程序,該應用程序存儲您喜愛的食譜,然後使用React前端顯示它們。完成後,您將能夠使用React界面創建、查看和刪除食譜,並使用Bootstrap進行風格設置:

先決條件

要完成本教程,您需要:

  • Node.jsnpm已安裝在您的開發機器上。本教程使用Node.js版本16.14.0和npm版本8.3.1。Node.js是一個JavaScript運行時環境,允許您在瀏覽器之外運行代碼。它帶有一個預先安裝的包管理器,稱為npm,可讓您安裝和更新包。要在Ubuntu 20.04或macOS上安裝這些軟件,請按照《在Ubuntu 20.04上安裝Node.js的使用PPA部分》中的步驟或《如何在macOS上安裝Node.js並創建本地開發環境》中的步驟進行操作。

  • 在您的開發機器上安裝Yarn包管理器,這將允許您下載React框架。本教程在版本1.22.10上進行了測試;要安裝此依賴項,請按照官方Yarn安裝指南

  • 已安裝 Ruby on Rails。要獲取此項目,請按照我們的指南 在 Ubuntu 20.04 上使用 rbenv 安裝 Ruby on Rails 的方法。如果您想在 macOS 上開發此應用程序,可以使用 在 macOS 上使用 rbenv 安裝 Ruby on Rails 的方法。本教程在 Ruby 的版本 3.1.2 和 Rails 的版本 7.0.4 上進行了測試,因此在安裝過程中確保指定這些版本。

注意: Rails 版本 7 不向後兼容。如果您使用的是 Rails 版本 5,請訪問 在 Ubuntu 18.04 上設置帶有 React 前端的 Ruby on Rails v5 項目的教程

步驟1 — 創建新的Rails應用程式

在此步驟中,您將在Rails應用程式框架上構建您的食譜應用程式。首先,您將創建一個新的Rails應用程式,該應用程式將配置為與React一起使用。

Rails提供了幾個名為生成器的腳本,這些腳本創建了構建現代Web應用程序所需的一切。要查看完整的這些命令列表以及它們的功能,請在終端中運行以下命令:

  1. rails -h

這個指令將產生一個全面的選項清單,讓您設置應用程式的參數。列出的命令之一是 new 命令,它會創建一個新的 Rails 應用程式。

現在,您將使用 new 產生器來創建一個新的 Rails 應用程式。在終端中運行以下命令:

  1. rails new rails_react_recipe -d postgresql -j esbuild -c bootstrap -T

前述命令會在名為 rails_react_recipe 的目錄中創建一個新的 Rails 應用程式,安裝所需的 Ruby 和 JavaScript 依賴項,並配置 Webpack。與這個 new 產生器命令相關的標誌包括以下內容:

  • -d 標誌指定首選的資料庫引擎,本例中為 PostgreSQL。
  • -j 標誌指定應用程式的 JavaScript 方法。Rails 提供了幾種不同的處理 Rails 應用程式中 JavaScript 代碼的方法。傳遞給 -j 標誌的 esbuild 選項指示 Rails 預配置 esbuild 為首選的 JavaScript 打包程序。
  • -c 標誌指定應用程式的 CSS 處理器。在這種情況下,Bootstrap 是首選選項。
  • -T 標誌指示 Rails 跳過測試文件的生成,因為您不會為本教程撰寫測試。如果您想使用與 Rails 提供的不同的 Ruby 測試工具,也建議使用此命令。

當命令完成後,移至應用程式的根目錄 rails_react_recipe

  1. cd rails_react_recipe

接下來,列出目錄的內容:

  1. ls

Output
Gemfile README.md bin db node_modules storage yarn.lock Gemfile.lock Rakefile config lib package.json tmp Procfile.dev app config.ru log public vendor

這個根目錄有幾個自動生成的文件和文件夾,構成了 Rails 應用程序的結構,其中包括一個包含 React 應用程序依賴的 package.json 文件。

現在您已成功創建了一個新的 Rails 應用程序,下一步將是將其連接到數據庫。

第二步 — 設置數據庫

在運行新的 Rails 應用程序之前,您必須先將其連接到數據庫。在這一步中,您將把新創建的 Rails 應用程序連接到一個 PostgreSQL 數據庫,以便根據需要存儲和提取配方數據。

database.yml 文件位於 config/database.yml 中,其中包含不同開發環境的數據庫名稱等數據庫詳細信息。Rails 通過在環境名稱後添加下劃線(_)來為各種開發環境指定數據庫名稱。在本教程中,您將使用默認的數據庫配置值,但如果需要,您可以更改配置值。

注意: 此時,您可以更改 config/database.yml 以設定 Rails 要使用哪個 PostgreSQL 角色來創建您的數據庫。在先決條件中,您已經在 如何在 Ruby on Rails 應用中使用 PostgreSQL 教程中創建了一個由密碼保護的角色。如果您尚未設置用戶,現在可以按照同一先決教程中的 第4步 — 配置並創建您的數據庫 的說明進行操作。

Rails 提供了許多命令,使開發 Web 應用程序變得輕鬆,包括用於處理數據庫的命令,如 createdropreset。要為應用程序創建數據庫,請在終端中運行以下命令:

  1. rails db:create

此命令將創建一個 developmenttest 數據庫,生成以下輸出:

Output
Created database 'rails_react_recipe_development' Created database 'rails_react_recipe_test'

現在應用程序已連接到數據庫,請運行以下命令啟動應用程序:

  1. bin/dev

Rails 提供了一個替代的 bin/dev 腳本,通過使用應用程序根目錄中的 Procfile.dev 文件中的命令以及 Foreman gem 來啟動 Rails 應用程序。

運行此命令後,您的命令提示符將消失,並且以下輸出將顯示在其位置:

Output
started with pid 70099 started with pid 70100 started with pid 70101 yarn run v1.22.10 yarn run v1.22.10 $ esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets --watch $ sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules --watch => Booting Puma => Rails 7.0.4 application starting in development => Run `bin/rails server --help` for more startup options [watch] build finished, watching for changes... Puma starting in single mode... * Puma version: 5.6.5 (ruby 3.1.2-p20) ("Birdie's Version") * Min threads: 5 * Max threads: 5 * Environment: development * PID: 70099 * Listening on http://127.0.0.1:3000 * Listening on http://[::1]:3000 Use Ctrl-C to stop Sass is watching for changes. Press Ctrl-C to stop.

要訪問應用程式,請打開瀏覽器窗口,並導航至 http://localhost:3000。 Rails 默認的歡迎頁面將加載,這意味著您已正確設置了 Rails 應用程式:

要停止 Web 伺服器,請在執行伺服器的終端中按下 CTRL+C。 您將收到 Puma 的再見消息:

Output
^C SIGINT received, starting shutdown - Gracefully stopping, waiting for requests to finish === puma shutdown: 2019-07-31 14:21:24 -0400 === - Goodbye! Exiting sending SIGTERM to all processes terminated by SIGINT terminated by SIGINT exited with code 0

然後您的終端提示將重新出現。

您已成功為您的食譜應用程式設置了數據庫。 在下一步中,您將安裝所需的 JavaScript 依賴項,以組建您的 React 前端。

第 3 步 — 安裝前端依賴項

在此步驟中,您將安裝食譜應用程式前端所需的 JavaScript 依賴項。 它們包括:

  • React 用於構建用戶界面。
  • React DOM 以使 React 能夠與瀏覽器 DOM 進行交互。
  • React Router 用於處理 React 應用程式中的導航。

使用 Yarn 套件管理器運行以下命令來安裝這些套件:

  1. yarn add react react-dom react-router-dom

這個指令使用 Yarn 安裝指定的套件並將它們添加到 `package.json` 文件中。要驗證這一點,請打開項目根目錄中位於的 `package.json` 文件:

  1. nano package.json

安裝的套件將列在 `dependencies` 鍵下:

~/rails_react_recipe/package.json
{
  "name": "app",
  "private": "true",
  "dependencies": {
    "@hotwired/stimulus": "^3.1.0",
    "@hotwired/turbo-rails": "^7.1.3",
    "@popperjs/core": "^2.11.6",
    "bootstrap": "^5.2.1",
    "bootstrap-icons": "^1.9.1",
    "esbuild": "^0.15.7",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    "react-router-dom": "^6.3.0",
    "sass": "^1.54.9"
  },
  "scripts": {
    "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=assets",
    "build:css": "sass ./app/assets/stylesheets/application.bootstrap.scss:./app/assets/builds/application.css --no-source-map --load-path=node_modules"
  }
}

CTRL+X 關閉文件。

你已經為應用程式安裝了一些前端依賴項。接下來,你將為你的食譜應用程式設置一個首頁。

步驟 4 — 設置首頁

安裝所需的依賴項後,現在你將為應用程式創建一個首頁,作為用戶首次訪問應用程式時的登錄頁面。

Rails 遵循 Model-View-Controller (模型-視圖-控制器) 架構模式進行應用程式開發。在 MVC 模式中,控制器的目的是接收特定的請求並將它們傳遞給適當的模型或視圖。當在瀏覽器中加載根 URL 時,應用程式目前會顯示 Rails 歡迎頁面。要更改這一點,你將創建一個控制器和視圖用於首頁,然後將其配對到一個路由。

Rails 提供了一個 controller 生成器來創建控制器。 controller 生成器接收控制器名稱和相應的操作。有關詳情,請查閱 Rails 文檔

本教程將命名控制器為 Homepage。運行以下命令創建一個具有 index 操作的 Homepage 控制器:

  1. rails g controller Homepage index

注意:
在 Linux 上,錯誤 FATAL: Listen error: unable to monitor directories for changes. 可能是由於系統對您的機器可以監視的文件數量設置的限制。運行以下命令修復它:

  1. echo fs.inotify.max_user_watches=524288 | sudo tee -a /etc/sysctl.conf && sudo sysctl -p

此命令將永久增加您可以使用 Listen 監視的目錄數量為 524288。您可以通過運行相同的命令並將 524288 替換為所需的數字來再次更改這個值。

運行 controller 命令會生成以下文件:

  • A homepage_controller.rb file for receiving all homepage-related requests. This file contains the index action you specified in the command.
  • A homepage_helper.rb file for adding helper methods related to the Homepage controller.
  • 一個 index.html.erb 文件,作為呈現與主頁相關的任何內容的視圖頁面。

除了運行 Rails 命令創建的這些新頁面外,Rails 還會更新位於 config/routes.rb 的路由文件,為主頁添加一個 get 路由,您將將其修改為根路由。

A root route in Rails specifies what will show up when users visit the root URL of your application. In this case, you want your users to see your homepage. Open the routes file located at config/routes.rb in your favorite editor:

  1. nano config/routes.rb

在此文件中,將 get 'homepage/index' 替換為 root 'homepage#index',以使文件與以下內容匹配:

~/rails_react_recipe/config/routes.rb
Rails.application.routes.draw do
  root 'homepage#index'
  #有關此文件中可用的DSL的詳細信息,請參閱http://guides.rubyonrails.org/routing.html
end

此修改指示Rails將應用程序的根映射到首頁控制器的index操作,該控制器進而在瀏覽器中呈現位於app/views/homepage/index.html.erbindex.html.erb文件中的內容。

保存並關閉文件。

為了驗證這是否有效,請啟動應用程序:

  1. bin/dev

當您在瀏覽器中打開或刷新應用程序時,將加載應用程序的新登錄頁面:

一旦您驗證了應用程序正在運行,請按CTRL+C停止服務器。

接下來,打開〜/rails_react_recipe/app/views/homepage/index.html.erb文件:

  1. nano ~/rails_react_recipe/app/views/homepage/index.html.erb

刪除文件內部的代碼,然後將文件保存為空。 通過這樣做,您可以確保index.html.erb的內容不會干擾您前端的React渲染。

現在,您已經為應用程序的首頁進行了設置,您可以轉到下一節,在該節中您將配置應用程序的前端以使用React。

步驟5 —— 配置React作為Rails前端

在此步驟中,您將配置Rails,以在應用程序的前端使用React,而不是其模板引擎。這個新的配置將允許您使用React創建一個外觀更加吸引人的主頁。

在生成Rails應用程序時指定esbuild選項的幫助下,使JavaScript與Rails無縫配合所需的大部分設置已經就位。現在剩下的就是將React應用程序的入口點加載到JavaScript文件的esbuild入口點中。為此,首先在app/javascript目錄中創建一個components目錄:

  1. mkdir ~/rails_react_recipe/app/javascript/components

components目錄將存放主頁的組件,以及應用程序中的其他React組件,包括進入React應用程序的入口文件。

接下來,打開位於app/javascript/application.jsapplication.js文件:

  1. nano ~/rails_react_recipe/app/javascript/application.js

將下面的代碼添加到文件中:

~/rails_react_recipe/app/javascript/application.js
// 包含在package.json中構建腳本的入口點
import "@hotwired/turbo-rails"
import "./controllers"
import * as bootstrap from "bootstrap"
import "./components"

添加到application.js文件的代碼行將導入入口index.jsx文件中的代碼,使其可用於esbuild進行打包。將/components目錄導入Rails應用程序的JavaScript入口點後,您可以為主頁創建一個React組件。主頁將包含一些文本和一個調用操作按鈕以查看所有食譜。

保存並關閉文件。

然後,在components目錄中創建一個Home.jsx文件:

  1. nano ~/rails_react_recipe/app/javascript/components/Home.jsx

將以下代碼添加到文件中:

~/rails_react_recipe/app/javascript/components/Home.jsx
import React from "react";
import { Link } from "react-router-dom";

export default () => (
  <div className="vw-100 vh-100 primary-color d-flex align-items-center justify-content-center">
    <div className="jumbotron jumbotron-fluid bg-transparent">
      <div className="container secondary-color">
        <h1 className="display-4">Food Recipes</h1>
        <p className="lead">
          A curated list of recipes for the best homemade meal and delicacies.
        </p>
        <hr className="my-4" />
        <Link
          to="/recipes"
          className="btn btn-lg custom-button"
          role="button"
        >
          View Recipes
        </Link>
      </div>
    </div>
  </div>
);

在這段程式碼中,您導入了 React 和 React Router 中的 Link 元件。 Link 元件創建了一個超連結,用於從一個頁面導航到另一個頁面。 然後,您創建並導出了一個包含一些標記語言的功能性組件,用 Bootstrap 類進行樣式設定,用於首頁。

保存並關閉文件。

有了您的 Home 組件設置好後,現在您將使用 React Router 設置路由。 在 app/javascript 目錄下創建一個 routes 目錄:

  1. mkdir ~/rails_react_recipe/app/javascript/routes

routes 目錄將包含一些路由及其對應的組件。 每當加載任何指定的路由時,它將將其對應的組件呈現到瀏覽器中。

routes 目錄中,創建一個 index.jsx 文件:

  1. nano ~/rails_react_recipe/app/javascript/routes/index.jsx

將以下代碼添加到其中:

~/rails_react_recipe/app/javascript/routes/index.jsx
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from "../components/Home";

export default (
  <Router>
    <Routes>
      <Route path="/" element={<Home />} />
    </Routes>
  </Router>
);

在這個 index.jsx 路由文件中,您導入了以下模塊:允許您使用 React 的 React 模塊,以及來自 React Router 的 BrowserRouterRoutesRoute 模塊,這些模塊一起幫助您從一個路由導航到另一個路由。 最後,您導入您的 Home 組件,每當請求與根路由(/)匹配時,它將被呈現。 當您想要向應用程序添加更多頁面時,您可以在此文件中聲明一個路由,並將其匹配到您想要為該頁面呈現的組件。

保存並退出文件。

你現在已經使用 React Router 設定了路由。為了讓 React 知道可用的路由並使用它們,這些路由必須在應用程式的入口點處可用。為了實現這一點,你將在一個組件中呈現你的路由,React 將在你的入口文件中呈現這個組件。

app/javascript/components 目錄下創建一個 App.jsx 文件:

  1. nano ~/rails_react_recipe/app/javascript/components/App.jsx

將以下代碼添加到 App.jsx 文件中:

~/rails_react_recipe/app/javascript/components/App.jsx
import React from "react";
import Routes from "../routes";

export default props => <>{Routes}</>;

App.jsx 文件中,你導入了 React 和剛剛創建的路由文件。然後導出一個組件來呈現這些路由在 fragments 內。這個組件將在應用程式的入口點呈現,使得當應用程式加載時這些路由可用。

保存並關閉文件。

現在你已經設置好了你的 App.jsx,你可以在你的入口文件中呈現它。在 components 目錄下創建一個 index.jsx 文件:

  1. nano ~/rails_react_recipe/app/javascript/components/index.jsx

將以下代碼添加到 index.js 文件中:

~/rails_react_recipe/app/javascript/components/index.jsx
import React from "react";
import { createRoot } from "react-dom/client";
import App from "./App";

document.addEventListener("turbo:load", () => {
  const root = createRoot(
    document.body.appendChild(document.createElement("div"))
  );
  root.render(<App />);
});

import 行中,你導入了 React 函式庫,從 ReactDOM 導入了 createRoot 函式,以及你的 App 組件。使用 ReactDOM 的 createRoot 函式,你創建了一個根元素作為一個附加到頁面的 div 元素,並在其中渲染了你的 App 組件。當應用程式加載時,React 將在頁面上的 div 元素內渲染 App 組件的內容。

保存並退出文件。

最後,你將為你的首頁添加一些 CSS 樣式。

打開你的application.bootstrap.scss文件在你的~/rails_react_recipe/app/assets/stylesheets/application.bootstrap.scss目錄中:

  1. nano ~/rails_react_recipe/app/assets/stylesheets/application.bootstrap.scss

接下來,將application.bootstrap.scss文件的內容替換為以下代碼:

~/rails_react_recipe/app/assets/stylesheets/application.bootstrap.scss
@import 'bootstrap/scss/bootstrap';
@import 'bootstrap-icons/font/bootstrap-icons';

.bg_primary-color {
  background-color: #FFFFFF;
}
.primary-color {
  background-color: #FFFFFF;
}
.bg_secondary-color {
  background-color: #293241;
}
.secondary-color {
  color: #293241;
}
.custom-button.btn {
  background-color: #293241;
  color: #FFF;
  border: none;
}
.hero {
  width: 100vw;
  height: 50vh;
}
.hero img {
  object-fit: cover;
  object-position: top;
  height: 100%;
  width: 100%;
}
.overlay {
  height: 100%;
  width: 100%;
  opacity: 0.4;
}

你設置了一些自定義顏色給頁面。 .hero部分將為您的網站首頁上的一個大型網絡橫幅或英雄圖像創建框架,稍後您將添加。此外,custom-button.btn樣式化用戶將用於進入應用程序的按鈕。

樣式表設置完成後,保存並退出文件。

接下來,重新啟動應用程序的Web服務器:

  1. bin/dev

然後重新加載瀏覽器中的應用程序。一個全新的主頁將加載:

使用CTRL+C停止Web服務器。

在這一步中,你配置了你的應用程序使用React作為它的前端。在下一步中,你將創建模型和控制器,使你能夠創建、讀取、更新和刪除食譜。

第6步 — 創建食譜控制器和模型

現在,您已經為應用程式設置了 React 前端,接下來,您將創建一個食譜模型和控制器。食譜模型將代表包含使用者食譜信息的數據庫表,而控制器將接收並處理有關創建、讀取、更新或刪除食譜的請求。當使用者請求一個食譜時,食譜控制器接收此請求並將其傳遞給食譜模型,後者從數據庫檢索所需的數據。然後,模型將食譜數據作為回應返回給控制器。最後,這些信息顯示在瀏覽器中。

首先,通過使用 Rails 提供的 `generate model` 子命令並指定模型的名稱以及其列和數據類型,創建一個 Recipe 模型。執行以下命令:

  1. rails generate model Recipe name:string ingredients:text instruction:text image:string

上述命令指示 Rails 創建一個包含名為 Recipe 的模型,以及一個名為 name 的列(類型為 string)、一個名為 ingredientsinstruction 的列(類型為 text)和一個名為 image 的列(類型為 string)。本教程將模型命名為 Recipe,因為在 Rails 中,模型使用單數名稱,而相應的數據庫表使用複數名稱。

執行 generate model 命令將創建兩個文件並打印以下輸出:

Output
invoke active_record create db/migrate/20221017220817_create_recipes.rb create app/models/recipe.rb

創建的兩個文件是:

  • A recipe.rb file that holds all the model-related logic.
  • A 20221017220817_create_recipes.rb file (the number at the beginning of the file may differ depending on the date when you run the command). This migration file contains the instruction for creating the database structure.

接下來,您將編輯食譜模型文件,以確保僅將有效數據保存到數據庫。通過向模型添加一些數據庫驗證,可以實現這一點。

打開位於 app/models/recipe.rb 的食譜模型:

  1. nano ~/rails_react_recipe/app/models/recipe.rb

將以下的程式碼行添加到文件中:

~/rails_react_recipe/app/models/recipe.rb
class Recipe < ApplicationRecord
  validates :name, presence: true
  validates :ingredients, presence: true
  validates :instruction, presence: true
end

在這段程式碼中,你要新增模型驗證,檢查是否有 nameingredientsinstruction 欄位。沒有這三個欄位的食譜將被視為無效,並且不會被儲存到資料庫中。

儲存並關閉文件。

為了讓 Rails 在你的資料庫中建立 recipes 表,你必須運行一個 遷移,這是一種以程式方式對資料庫進行更改的方法。為了確保遷移與你設置的資料庫配合,你必須對 20221017220817_create_recipes.rb 文件進行更改。

在你的編輯器中打開這個文件:

  1. nano ~/rails_react_recipe/db/migrate/20221017220817_create_recipes.rb

添加這些突出顯示的內容,使你的文件與以下內容相匹配:

db/migrate/20221017220817_create_recipes.rb
class CreateRecipes < ActiveRecord::Migration[5.2]
  def change
    create_table :recipes do |t|
      t.string :name, null: false
      t.text :ingredients, null: false
      t.text :instruction, null: false
      t.string :image, default: 'https://raw.githubusercontent.com/do-community/react_rails_recipe/master/app/assets/images/Sammy_Meal.jpg'

      t.timestamps
    end
  end
end

這個遷移文件包含了一個 Ruby 類,其中有一個 change 方法和一個命令來創建一個名為 recipes 的表,以及列和其數據類型。你還通過添加 null: falsenameingredientsinstruction 列上添加了 NOT NULL 約束,確保在更改資料庫之前這些列都有值。最後,你還為圖片列添加了一個默認的圖片 URL;如果你想使用不同的圖片,這可以是另一個 URL。

保存並退出文件。現在你已經準備好運行遷移並創建表了。在終端中執行以下命令:

  1. rails db:migrate

你可以使用數據庫遷移命令運行遷移文件中的指令。一旦命令成功運行,您將收到類似以下的輸出:

Output
== 20190407161357 CreateRecipes: migrating ==================================== -- create_table(:recipes) -> 0.0140s == 20190407161357 CreateRecipes: migrated (0.0141s) ===========================

有了你的食譜模型之後,接下來你將創建你的食譜控制器來添加創建、讀取和刪除食譜的邏輯。運行以下命令:

  1. rails generate controller api/v1/Recipes index create show destroy --skip-template-engine --no-helper

在這個命令中,你會在一個 api/v1 目錄下創建一個 Recipes 控制器,並添加一個 indexcreateshowdestroy 操作。 index 操作將處理獲取所有食譜的請求;create 操作將負責創建新食譜;show 操作將檢索單個食譜,而 destroy 操作將處理刪除食譜的邏輯。

您還可以傳遞一些標誌來使控制器更輕量級,包括:

  • --skip-template-engine,這個標誌告訴Rails跳過生成Rails視圖文件,因為React處理你的前端需求。
  • --no-helper,這個標誌告訴Rails跳過為您的控制器生成輔助文件。

運行這個命令還會更新您的路由文件,為 Recipes 控制器的每個操作添加一個路由。

當命令運行時,它將打印出如下輸出:

Output
create app/controllers/api/v1/recipes_controller.rb route namespace :api do namespace :v1 do get 'recipes/index' get 'recipes/create' get 'recipes/show' get 'recipes/destroy' end end

為了使用這些路由,您需要對您的 config/routes.rb 文件進行更改。在您的文本編輯器中打開 routes.rb 文件:

  1. nano ~/rails_react_recipe/config/routes.rb

更新此文件,使其看起來像以下代碼,更改或添加高亮顯示的行:

~/rails_react_recipe/config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      get 'recipes/index'
      post 'recipes/create'
      get '/show/:id', to: 'recipes#show'
      delete '/destroy/:id', to: 'recipes#destroy'
    end
  end
  root 'homepage#index'
  get '/*path' => 'homepage#index'
  # 根據 https://guides.rubyonrails.org/routing.html 中的 DSL 定義應用程式路由

  # 定義根路徑 ("/") 的路由
  # root "articles#index"
end

在此路由文件中,您修改 createdestroy 路由的 HTTP 動詞,以便可以 postdelete 數據。您還修改了 showdestroy 操作的路由,通過向路由添加 :id 參數來執行。 :id 將保存您要閱讀或刪除的食譜的識別號。

您添加了一個捕獲所有路由的路由,使用 get '/*path' 來將不匹配現有路由的任何其他請求重定向到 homepage 控制器的 index 操作。前端路由將處理與創建、閱讀或刪除食譜無關的請求。

保存並退出文件。

要評估應用程序中可用的路由列表,請運行以下命令:

  1. rails routes

運行此命令將顯示一個冗長的 URI 模式、動詞和與您項目相匹配的控制器或操作的列表。

接下來,您將添加獲取所有食譜的邏輯。Rails 使用 ActiveRecord 库來處理此類與數據庫相關的任務。ActiveRecord 將類連接到關係型數據庫表,並提供了豐富的 API 來處理它們。

要獲取所有食譜,您將使用 ActiveRecord 查詢食譜表並從數據庫中獲取所有食譜。

使用以下命令開啟recipes_controller.rb文件:

  1. nano ~/rails_react_recipe/app/controllers/api/v1/recipes_controller.rb

將突出顯示的行添加到食譜控制器中:

~/rails_react_recipe/app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApplicationController
  def index
    recipe = Recipe.all.order(created_at: :desc)
    render json: recipe
  end

  def create
  end

  def show
  end

  def destroy
  end
end

在你的index動作中,你使用ActiveRecord的all方法來獲取資料庫中的所有食譜。使用order方法,按創建日期降序排列它們,這將使最新的食譜排在前面。最後,你將食譜列表以JSON響應的形式通過render發送。

接下來,你將添加創建新食譜的邏輯。就像獲取所有食譜一樣,你將依靠ActiveRecord來驗證和保存提供的食譜細節。用以下突出顯示的代碼行更新你的食譜控制器:

~/rails_react_recipe/app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApplicationController
  def index
    recipe = Recipe.all.order(created_at: :desc)
    render json: recipe
  end

  def create
    recipe = Recipe.create!(recipe_params)
    if recipe
      render json: recipe
    else
      render json: recipe.errors
    end
  end

  def show
  end

  def destroy
  end

  private

  def recipe_params
    params.permit(:name, :image, :ingredients, :instruction)
  end
end

create動作中,您可以使用ActiveRecord的create方法來創建新的食譜。create方法可以一次將所有控制器參數分配到模型中。這個方法使得創建記錄變得容易,但也打開了惡意使用的可能性。您可以使用Rails提供的強參數功能來防止惡意使用。這樣,除非已經允許,否則無法分配參數。您在代碼中將recipe_params參數傳遞給create方法。 recipe_params是一個private方法,您可以在其中允許控制器參數,以防止錯誤或惡意內容進入您的數據庫。在這種情況下,您允許nameimageingredientsinstruction參數以正確使用create方法。

您的食譜控制器現在可以讀取和創建食譜。現在剩下的是閱讀和刪除單個食譜的邏輯。請使用以下突出顯示的代碼更新您的食譜控制器:

~/rails_react_recipe/app/controllers/api/v1/recipes_controller.rb
class Api::V1::RecipesController < ApplicationController
  before_action :set_recipe, only: %i[show destroy]

  def index
    recipe = Recipe.all.order(created_at: :desc)
    render json: recipe
  end

  def create
    recipe = Recipe.create!(recipe_params)
    if recipe
      render json: recipe
    else
      render json: recipe.errors
    end
  end

  def show
    render json: @recipe
  end

  def destroy
    @recipe&.destroy
    render json: { message: 'Recipe deleted!' }
  end

  private

  def recipe_params
    params.permit(:name, :image, :ingredients, :instruction)
  end

  def set_recipe
    @recipe = Recipe.find(params[:id])
  end
end

在新的程式碼行中,您會創建一個名為 set_recipe 的私有方法,該方法由一個 before_action 調用,僅在 show delete 動作與請求匹配時調用。 set_recipe 方法使用ActiveRecord的 find 方法查找 params 中提供的 id 匹配的配方,並將其分配給一個實例變量 @recipe 。在 show 動作中,您將由 set_recipe 方法設置的 @recipe 對象作為JSON響應返回。

destroy 動作中,您使用了類似的方法,使用了Ruby的安全導航運算子。這樣做可以避免在調用方法時出現 nil 錯誤。這個添加讓您只能在配方存在時刪除配方,然後作為響應發送一條消息。

將這些更改保存到 recipes_controller.rb 中後,保存並關閉文件。

在此步驟中,您創建了一個模型和控制器來管理您的食譜。您已經編寫了所有處理後端食譜所需的邏輯。在下一節中,您將創建用於查看您的食譜的組件。

第7步 – 查看食譜

在本節中,您將創建用於查看食譜的組件。您將創建兩個頁面:一個用於查看所有現有食譜,另一個用於查看個別食譜。

您將開始創建一個頁面來查看所有的食譜。在創建頁面之前,由於您的數據庫目前是空的,您需要一些食譜來進行操作。Rails 提供了一種為應用程序創建種子數據的方法。

打開名為 seeds.rb 的種子文件進行編輯:

  1. nano ~/rails_react_recipe/db/seeds.rb

用以下代碼替換種子文件的初始內容:

~/rails_react_recipe/db/seeds.rb
9.times do |i|
  Recipe.create(
    name: "Recipe #{i + 1}",
    ingredients: '227g tub clotted cream, 25g butter, 1 tsp cornflour,100g parmesan, grated nutmeg, 250g fresh fettuccine or tagliatelle, snipped chives or chopped parsley to serve (optional)',
    instruction: 'In a medium saucepan, stir the clotted cream, butter, and cornflour over a low-ish heat and bring to a low simmer. Turn off the heat and keep warm.'
  )
end

在此代碼中,您使用一個循環指示 Rails 創建九個帶有 nameingredientsinstruction 部分的食譜。保存並退出文件。

為了使用這些數據填充數據庫,請在終端中運行以下命令:

  1. rails db:seed

運行此命令將向您的數據庫中添加九個食譜。現在,您可以檢索它們並在前端呈現它們。

查看所有食譜的組件將向 RecipesController 中的 index 操作發送 HTTP 請求,以獲取所有食譜的列表。然後,這些食譜將顯示在頁面上的卡片中。

app/javascript/components 目錄中創建一個名為 Recipes.jsx 的文件:

  1. nano ~/rails_react_recipe/app/javascript/components/Recipes.jsx

一旦文件打開,通過添加以下行來導入 ReactuseStateuseEffectLinkuseNavigate 模塊:

~/rails_react_recipe/app/javascript/components/Recipes.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";

接下來,添加突出顯示的行來創建並導出一個名為 Recipes 的功能性 React 組件:

~/rails_react_recipe/app/javascript/components/Recipes.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";

const Recipes = () => {
  const navigate = useNavigate();
  const [recipes, setRecipes] = useState([]);
};

export default Recipes;

Recipe 組件內部,React Router的導航API將調用 useNavigate 鉤子。 React的 useState 鉤子將初始化 recipes 狀態,這是一個空數組( [] ),以及一個用於更新 recipes 狀態的 setRecipes 函數。

接下來,在 useEffect 鉤子中,您將發送一個HTTP請求來獲取所有您的食譜。為此,請添加以下突出顯示的行:

~/rails_react_recipe/app/javascript/components/Recipes.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";

const Recipes = () => {
  const navigate = useNavigate();
  const [recipes, setRecipes] = useState([]);

  useEffect(() => {
    const url = "/api/v1/recipes/index";
    fetch(url)
      .then((res) => {
        if (res.ok) {
          return res.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((res) => setRecipes(res))
      .catch(() => navigate("/"));
  }, []);
};

export default Recipes;

在您的 useEffect 鉤子中,使用 Fetch API 發送HTTP請求來獲取所有食譜。如果響應成功,應用程序將數組食譜保存到 recipes 狀態中。如果發生錯誤,它將將用戶重定向到主頁。

最後,返回在渲染組件時將被評估並顯示在瀏覽器頁面上的元素的標記。在這種情況下,組件將從 recipes 狀態渲染一個食譜卡。將以下突出顯示的行添加到 Recipes.jsx

~/rails_react_recipe/app/javascript/components/Recipes.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";

const Recipes = () => {
  const navigate = useNavigate();
  const [recipes, setRecipes] = useState([]);

  useEffect(() => {
    const url = "/api/v1/recipes/index";
    fetch(url)
      .then((res) => {
        if (res.ok) {
          return res.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((res) => setRecipes(res))
      .catch(() => navigate("/"));
  }, []);

  const allRecipes = recipes.map((recipe, index) => (
    <div key={index} className="col-md-6 col-lg-4">
      <div className="card mb-4">
        <img
          src={recipe.image}
          className="card-img-top"
          alt={`${recipe.name} image`}
        />
        <div className="card-body">
          <h5 className="card-title">{recipe.name}</h5>
          <Link to={`/recipe/${recipe.id}`} className="btn custom-button">
            View Recipe
          </Link>
        </div>
      </div>
    </div>
  ));
  const noRecipe = (
    <div className="vw-100 vh-50 d-flex align-items-center justify-content-center">
      <h4>
        No recipes yet. Why not <Link to="/new_recipe">create one</Link>
      </h4>
    </div>
  );

  return (
    <>
      <section className="jumbotron jumbotron-fluid text-center">
        <div className="container py-5">
          <h1 className="display-4">Recipes for every occasion</h1>
          <p className="lead text-muted">
            We’ve pulled together our most popular recipes, our latest
            additions, and our editor’s picks, so there’s sure to be something
            tempting for you to try.
          </p>
        </div>
      </section>
      <div className="py-5">
        <main className="container">
          <div className="text-end mb-3">
            <Link to="/recipe" className="btn custom-button">
              Create New Recipe
            </Link>
          </div>
          <div className="row">
            {recipes.length > 0 ? allRecipes : noRecipe}
          </div>
          <Link to="/" className="btn btn-link">
            Home
          </Link>
        </main>
      </div>
    </>
  );
};

export default Recipes;

保存並退出 Recipes.jsx

現在您已經創建了一個顯示所有食譜的組件,您將為其創建一個路由。打開前端路由文件 app/javascript/routes/index.jsx

  1. nano app/javascript/routes/index.jsx

將以下突出顯示的行添加到文件中:

~/rails_react_recipe/app/javascript/routes/index.jsx
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from "../components/Home";
import Recipes from "../components/Recipes";

export default (
  <Router>
    <Routes>
      <Route path="/" exact component={Home} />
      <Route path="/recipes" element={<Recipes />} />
    </Routes>
  </Router>
);

保存並退出文件。

在这一点上,验证代码是否按预期工作是个好主意。与之前一样,使用以下命令启动服务器:

  1. bin/dev

然后在浏览器中打开应用程序。在主页上按下查看菜谱按钮,访问一个显示您的种子菜谱的页面:

在终端中使用CTRL+C停止服务器并返回提示符:

现在,您可以查看应用程序中的所有菜谱,是时候创建第二个组件以查看单个菜谱了。在app/javascript/components目录中创建一个Recipe.jsx文件:

  1. nano app/javascript/components/Recipe.jsx

Recipes组件一样,通过添加以下行导入ReactuseStateuseEffectLinkuseNavigateuseParam模块:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

接下来,添加突出显示的行以创建并导出名为Recipe的功能性React组件:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

const Recipe = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [recipe, setRecipe] = useState({ ingredients: "" });
};

export default Recipe;

Recipes组件一样,使用useNavigate钩子初始化React Router导航。使用useState钩子初始化recipe状态和setRecipe函数更新状态。此外,调用useParams钩子,它返回一个键/值对为URL参数的对象:

为了找到特定的菜谱,您的应用程序需要知道菜谱的id,这意味着Recipe组件期望在URL中有一个idparam。您可以通过params对象访问此对象,该对象保存useParams钩子的返回值。

接下來,宣告一個useEffect hook,你將在其中從params物件中訪問id param。一旦獲取到食譜的id param,你將發送一個HTTP請求以獲取該食譜。將下面的突出顯示的程式碼添加到你的檔案中:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

const Recipe = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [recipe, setRecipe] = useState({ ingredients: "" });

  useEffect(() => {
    const url = `/api/v1/show/${params.id}`;
    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => setRecipe(response))
      .catch(() => navigate("/recipes"));
  }, [params.id]);
};

export default Recipe;

useEffect hook中,你使用params.id值來發送GET HTTP請求以獲取擁有該id的食譜,然後使用setRecipe函式將其保存到組件狀態中。如果該食譜不存在,應用程序將用戶重定向到食譜頁面。

接下來,添加一個addHtmlEntities函式,用於將字符實體替換為HTML實體addHtmlEntities函式將接受一個字符串,並將所有轉義的開放和關閉括號替換為它們的HTML實體。此函式將幫助你轉換食譜指示中保存的任何轉義字符。添加下面的突出顯示的程式碼:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

const Recipe = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [recipe, setRecipe] = useState({ ingredients: "" });

  useEffect(() => {
    const url = `/api/v1/show/${params.id}`;
    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => setRecipe(response))
      .catch(() => navigate("/recipes"));
  }, [params.id]);

  const addHtmlEntities = (str) => {
    return String(str).replace(/&lt;/g, "<").replace(/&gt;/g, ">");
  };
};

export default Recipe;

最後,通過添加下面的突出顯示的程式碼,返回用於在頁面上呈現組件狀態中的食譜的標記:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

const Recipe = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [recipe, setRecipe] = useState({ ingredients: "" });

  useEffect(() => {
    const url = `/api/v1/show/${params.id}`;
    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => setRecipe(response))
      .catch(() => navigate("/recipes"));
  }, [params.id]);

  const addHtmlEntities = (str) => {
    return String(str).replace(/&lt;/g, "<").replace(/&gt;/g, ">");
  };

  const ingredientList = () => {
    let ingredientList = "No ingredients available";

    if (recipe.ingredients.length > 0) {
      ingredientList = recipe.ingredients
        .split(",")
        .map((ingredient, index) => (
          <li key={index} className="list-group-item">
            {ingredient}
          </li>
        ));
    }

    return ingredientList;
  };

  const recipeInstruction = addHtmlEntities(recipe.instruction);
  
  return (
    <div className="">
      <div className="hero position-relative d-flex align-items-center justify-content-center">
        <img
          src={recipe.image}
          alt={`${recipe.name} image`}
          className="img-fluid position-absolute"
        />
        <div className="overlay bg-dark position-absolute" />
        <h1 className="display-4 position-relative text-white">
          {recipe.name}
        </h1>
      </div>
      <div className="container py-5">
        <div className="row">
          <div className="col-sm-12 col-lg-3">
            <ul className="list-group">
              <h5 className="mb-2">Ingredients</h5>
              {ingredientList()}
            </ul>
          </div>
          <div className="col-sm-12 col-lg-7">
            <h5 className="mb-2">Preparation Instructions</h5>
            <div
              dangerouslySetInnerHTML={{
                __html: `${recipeInstruction}`,
              }}
            />
          </div>
          <div className="col-sm-12 col-lg-2">
            <button
              type="button"
              className="btn btn-danger"
            >
              Delete Recipe
            </button>
          </div>
        </div>
        <Link to="/recipes" className="btn btn-link">
          Back to recipes
        </Link>
      </div>
    </div>
  );
};

export default Recipe;

使用ingredientList函数,您可以将逗号分隔的食谱配料拆分为数组,并在其上进行映射,以创建配料列表。如果没有配料,应用程序将显示一条消息,显示无可用配料。您还可以通过将其传递给addHtmlEntities函数来替换食谱说明中的所有开放和关闭括号。最后,代码将食谱图片显示为主图片,并在食谱说明旁边添加一个删除食谱按钮,并添加一个链接回食谱页面的按钮。

注意:使用React的dangerouslySetInnerHTML属性存在风险,因为它会使您的应用程序暴露于跨站脚本攻击。通过确保在创建食谱时输入的特殊字符使用NewRecipe组件中声明的stripHtmlEntities函数替换,可以减少此风险。

保存并退出文件。

要在页面上查看Recipe组件,您将其添加到路由文件中。打开要编辑的路由文件:

  1. nano app/javascript/routes/index.jsx

将以下突出显示的行添加到文件中:

~/rails_react_recipe/app/javascript/routes/index.jsx
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from "../components/Home";
import Recipes from "../components/Recipes";
import Recipe from "../components/Recipe";

export default (
  <Router>
    <Routes>
      <Route path="/" exact component={Home} />
      <Route path="/recipes" exact component={Recipes} />
      <Route path="/recipe/:id" element={<Recipe />} />
    </Routes>
  </Router>
);

在此路由文件中导入您的Recipe组件并添加一个路由。其路由具有一个:idparam,该参数将被您想要查看的食谱的id替换。

保存并关闭文件。

使用bin/dev腳本重新啟動您的伺服器,然後在瀏覽器中訪問http://localhost:3000。點擊查看食譜按鈕以導航到食譜頁面。在食譜頁面上,通過點擊其查看食譜按鈕訪問任何食譜。您將看到一個用您的數據填充的頁面:

您可以使用CTRL+C停止伺服器。

在這一步中,您將九個食譜添加到您的數據庫並創建了查看這些食譜的組件,既可以單獨查看,也可以作為集合查看。在下一步中,您將添加一個組件以創建食譜。

第8步 — 創建食譜

擁有可用的食譜應用程序的下一步是能夠創建新的食譜。在這一步中,您將為此功能創建一個組件。該組件將包含一個表單,用於從用戶那裡收集所需的食譜詳細信息,然後發送請求到Recipe控制器中的create動作以保存食譜數據。

app/javascript/components目錄中創建一個NewRecipe.jsx文件:

  1. nano app/javascript/components/NewRecipe.jsx

在新文件中,導入您在其他組件中使用的ReactuseStateLinkuseNavigate模塊:

~/rails_react_recipe/app/javascript/components/NewRecipe.jsx
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";

接下來,通過添加突出顯示的行創建並導出一個功能性的NewRecipe組件:

~/rails_react_recipe/app/javascript/components/NewRecipe.jsx
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";

const NewRecipe = () => {
  const navigate = useNavigate();
  const [name, setName] = useState("");
  const [ingredients, setIngredients] = useState("");
  const [instruction, setInstruction] = useState("");
};

export default NewRecipe;

與先前的組件一樣,您使用useNavigate鉤子初始化React路由導航,然後使用useState鉤子初始化nameingredientsinstruction狀態,每個都帶有其各自的更新函數。這些是您需要創建有效食譜的字段。

接下來,創建一個stripHtmlEntities函數,將特殊字符(如<)轉換為它們的轉義/編碼值(如&lt;)。為此,將以下突出顯示的行添加到NewRecipe組件中:

~/rails_react_recipe/app/javascript/components/NewRecipe.jsx
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";

const NewRecipe = () => {
  const navigate = useNavigate();
  const [name, setName] = useState("");
  const [ingredients, setIngredients] = useState("");
  const [instruction, setInstruction] = useState("");

  const stripHtmlEntities = (str) => {
    return String(str)
      .replace(/\n/g, "<br> <br>")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;");
  };
};

export default NewRecipe;

stripHtmlEntities函數中,將<>字符替換為它們的轉義值。這樣,您就不會在數據庫中存儲原始HTML。

接下來,將以下突出顯示的行添加到NewRecipe組件以添加onChangeonSubmit函數,以處理表單的編輯和提交:

~/rails_react_recipe/app/javascript/components/NewRecipe.jsx
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";

const NewRecipe = () => {
  const navigate = useNavigate();
  const [name, setName] = useState("");
  const [ingredients, setIngredients] = useState("");
  const [instruction, setInstruction] = useState("");

  const stripHtmlEntities = (str) => {
    return String(str)
      .replace(/\n/g, "<br> <br>")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;");
  };

  const onChange = (event, setFunction) => {
    setFunction(event.target.value);
  };

  const onSubmit = (event) => {
    event.preventDefault();
    const url = "/api/v1/recipes/create";

    if (name.length == 0 || ingredients.length == 0 || instruction.length == 0)
      return;

    const body = {
      name,
      ingredients,
      instruction: stripHtmlEntities(instruction),
    };

    const token = document.querySelector('meta[name="csrf-token"]').content;
    fetch(url, {
      method: "POST",
      headers: {
        "X-CSRF-Token": token,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    })
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => navigate(`/recipe/${response.id}`))
      .catch((error) => console.log(error.message));
  };
};

export default NewRecipe;

onChange 函數接受使用者輸入的 event 和狀態設定函數,然後使用使用者輸入的值更新狀態。在 onSubmit 函數中,您檢查所有必填輸入是否為空。然後建立一個包含建立新食譜所需參數的物件。使用 stripHtmlEntities 函數,將食譜指示中的 <> 字元替換為其轉義值,並將每個新行字元替換為斷行標籤,從而保留使用者輸入的文字格式。最後,根據成功的響應進行 POST HTTP 請求以創建新食譜並重定向到其頁面。

為防止跨站請求偽造(CSRF)攻擊,Rails 將 CSRF 安全令牌附加到 HTML 文件上。每當進行非 GET 請求時,都需要此令牌。通過前面程式中的 token 常量,您的應用程序在伺服器上驗證令牌,如果安全令牌與預期不符,則拋出異常。在 onSubmit 函數中,應用程序擷取由 Rails 嵌入在 HTML 文件中的 CSRF 令牌,然後使用 JSON 字串進行 HTTP 請求。如果成功創建了食譜,應用程序會將使用者重定向到食譜頁面,以查看其新創建的食譜。

最後,返回呈現表單的標記,供使用者輸入希望創建的食譜詳細資訊。添加突顯顯示的行:

~/rails_react_recipe/app/javascript/components/NewRecipe.jsx
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";

const NewRecipe = () => {
  const navigate = useNavigate();
  const [name, setName] = useState("");
  const [ingredients, setIngredients] = useState("");
  const [instruction, setInstruction] = useState("");

  const stripHtmlEntities = (str) => {
    return String(str)
      .replace(/\n/g, "<br> <br>")
      .replace(/</g, "&lt;")
      .replace(/>/g, "&gt;");
  };

  const onChange = (event, setFunction) => {
    setFunction(event.target.value);
  };

  const onSubmit = (event) => {
    event.preventDefault();
    const url = "/api/v1/recipes/create";

    if (name.length == 0 || ingredients.length == 0 || instruction.length == 0)
      return;

    const body = {
      name,
      ingredients,
      instruction: stripHtmlEntities(instruction),
    };

    const token = document.querySelector('meta[name="csrf-token"]').content;
    fetch(url, {
      method: "POST",
      headers: {
        "X-CSRF-Token": token,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(body),
    })
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => navigate(`/recipe/${response.id}`))
      .catch((error) => console.log(error.message));
  };

  return (
    <div className="container mt-5">
      <div className="row">
        <div className="col-sm-12 col-lg-6 offset-lg-3">
          <h1 className="font-weight-normal mb-5">
            Add a new recipe to our awesome recipe collection.
          </h1>
          <form onSubmit={onSubmit}>
            <div className="form-group">
              <label htmlFor="recipeName">Recipe name</label>
              <input
                type="text"
                name="name"
                id="recipeName"
                className="form-control"
                required
                onChange={(event) => onChange(event, setName)}
              />
            </div>
            <div className="form-group">
              <label htmlFor="recipeIngredients">Ingredients</label>
              <input
                type="text"
                name="ingredients"
                id="recipeIngredients"
                className="form-control"
                required
                onChange={(event) => onChange(event, setIngredients)}
              />
              <small id="ingredientsHelp" className="form-text text-muted">
                Separate each ingredient with a comma.
              </small>
            </div>
            <label htmlFor="instruction">Preparation Instructions</label>
            <textarea
              className="form-control"
              id="instruction"
              name="instruction"
              rows="5"
              required
              onChange={(event) => onChange(event, setInstruction)}
            />
            <button type="submit" className="btn custom-button mt-3">
              Create Recipe
            </button>
            <Link to="/recipes" className="btn btn-link mt-3">
              Back to recipes
            </Link>
          </form>
        </div>
      </div>
    </div>
  );
};

export default NewRecipe;

返回的标记包含一个表单,其中包含三个输入字段;分别用于 recipeNamerecipeIngredientsinstruction。 每个输入字段都有一个 onChange 事件处理程序,调用 onChange 函数。 还将 onSubmit 事件处理程序附加到提交按钮,并调用提交表单数据的 onSubmit 函数。

保存并退出文件。

要在浏览器中访问此组件,请更新您的路由文件以包含其路由:

  1. nano app/javascript/routes/index.jsx

更新路由文件以包含这些突出显示的行:

~/rails_react_recipe/app/javascript/routes/index.jsx
import React from "react";
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
import Home from "../components/Home";
import Recipes from "../components/Recipes";
import Recipe from "../components/Recipe";
import NewRecipe from "../components/NewRecipe";

export default (
  <Router>
    <Routes>
      <Route path="/" exact component={Home} />
      <Route path="/recipes" exact component={Recipes} />
      <Route path="/recipe/:id" exact component={Recipe} />
      <Route path="/recipe" element={<NewRecipe />} />
    </Routes>
  </Router>
);

有了路由后,保存并退出文件。

重新启动开发服务器并在浏览器中访问 http://localhost:3000。 转到食谱页面并点击 创建新食谱 按钮。 您将找到一个包含向数据库添加食谱的表单的页面:

输入所需的食谱详细信息,然后点击 创建食谱 按钮。 然后新创建的食谱将显示在页面上。 准备好后,关闭服务器。

在这一步中,您添加了向您的食谱应用程序中创建食谱的功能。 在下一步中,您将添加删除食谱的功能。

第9步 — 删除食谱

在本部分中,您將修改您的食譜組件以包含刪除食譜的選項。單擊食譜頁面上的刪除按鈕時,應用程序將發送一個刪除食譜的請求到數據庫。

首先,打開您的Recipe.jsx文件進行編輯:

  1. nano app/javascript/components/Recipe.jsx

Recipe組件中,添加一個帶有突出顯示行的deleteRecipe函數:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

const Recipe = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [recipe, setRecipe] = useState({ ingredients: "" });

  useEffect(() => {
    const url = `/api/v1/show/${params.id}`;
    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => setRecipe(response))
      .catch(() => navigate("/recipes"));
  }, [params.id]);

  const addHtmlEntities = (str) => {
    return String(str).replace(/&lt;/g, "<").replace(/&gt;/g, ">");
  };

  const deleteRecipe = () => {
    const url = `/api/v1/destroy/${params.id}`;
    const token = document.querySelector('meta[name="csrf-token"]').content;

    fetch(url, {
      method: "DELETE",
      headers: {
        "X-CSRF-Token": token,
        "Content-Type": "application/json",
      },
    })
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then(() => navigate("/recipes"))
      .catch((error) => console.log(error.message));
  };

  const ingredientList = () => {
    let ingredientList = "No ingredients available";

    if (recipe.ingredients.length > 0) {
      ingredientList = recipe.ingredients
        .split(",")
        .map((ingredient, index) => (
          <li key={index} className="list-group-item">
            {ingredient}
          </li>
        ));
    }

    return ingredientList;
  };

  const recipeInstruction = addHtmlEntities(recipe.instruction);

  return (
    <div className="">
...

deleteRecipe函數中,獲取要刪除的食譜的id,然後構建您的URL並獲取CSRF令牌。接下來,發送一個DELETE請求到Recipes控制器以刪除食譜。如果成功刪除食譜,應用程序將重定向用戶到食譜頁面。

為了在單擊刪除按鈕時運行deleteRecipe函數中的代碼,將其作為單擊事件處理程序傳遞給按鈕。在組件中的刪除按鈕元素上添加一個onClick事件:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
...
return (
    <div className="">
      <div className="hero position-relative d-flex align-items-center justify-content-center">
        <img
          src={recipe.image}
          alt={`${recipe.name} image`}
          className="img-fluid position-absolute"
        />
        <div className="overlay bg-dark position-absolute" />
        <h1 className="display-4 position-relative text-white">
          {recipe.name}
        </h1>
      </div>
      <div className="container py-5">
        <div className="row">
          <div className="col-sm-12 col-lg-3">
            <ul className="list-group">
              <h5 className="mb-2">Ingredients</h5>
              {ingredientList()}
            </ul>
          </div>
          <div className="col-sm-12 col-lg-7">
            <h5 className="mb-2">Preparation Instructions</h5>
            <div
              dangerouslySetInnerHTML={{
                __html: `${recipeInstruction}`,
              }}
            />
          </div>
          <div className="col-sm-12 col-lg-2">
            <button
              type="button"
              className="btn btn-danger"
              onClick={deleteRecipe}
            >
              Delete Recipe
            </button>
          </div>
        </div>
        <Link to="/recipes" className="btn btn-link">
          Back to recipes
        </Link>
      </div>
    </div>
  );
...

在本教程的這一點上,您完整的Recipe.jsx文件應該與此文件相匹配:

~/rails_react_recipe/app/javascript/components/Recipe.jsx
import React, { useState, useEffect } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";

const Recipe = () => {
  const params = useParams();
  const navigate = useNavigate();
  const [recipe, setRecipe] = useState({ ingredients: "" });

  useEffect(() => {
    const url = `/api/v1/show/${params.id}`;
    fetch(url)
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then((response) => setRecipe(response))
      .catch(() => navigate("/recipes"));
  }, [params.id]);

  const addHtmlEntities = (str) => {
    return String(str).replace(/&lt;/g, "<").replace(/&gt;/g, ">");
  };

  const deleteRecipe = () => {
    const url = `/api/v1/destroy/${params.id}`;
    const token = document.querySelector('meta[name="csrf-token"]').content;

    fetch(url, {
      method: "DELETE",
      headers: {
        "X-CSRF-Token": token,
        "Content-Type": "application/json",
      },
    })
      .then((response) => {
        if (response.ok) {
          return response.json();
        }
        throw new Error("Network response was not ok.");
      })
      .then(() => navigate("/recipes"))
      .catch((error) => console.log(error.message));
  };

  const ingredientList = () => {
    let ingredientList = "No ingredients available";

    if (recipe.ingredients.length > 0) {
      ingredientList = recipe.ingredients
        .split(",")
        .map((ingredient, index) => (
          <li key={index} className="list-group-item">
            {ingredient}
          </li>
        ));
    }

    return ingredientList;
  };

  const recipeInstruction = addHtmlEntities(recipe.instruction);

  return (
    <div className="">
      <div className="hero position-relative d-flex align-items-center justify-content-center">
        <img
          src={recipe.image}
          alt={`${recipe.name} image`}
          className="img-fluid position-absolute"
        />
        <div className="overlay bg-dark position-absolute" />
        <h1 className="display-4 position-relative text-white">
          {recipe.name}
        </h1>
      </div>
      <div className="container py-5">
        <div className="row">
          <div className="col-sm-12 col-lg-3">
            <ul className="list-group">
              <h5 className="mb-2">Ingredients</h5>
              {ingredientList()}
            </ul>
          </div>
          <div className="col-sm-12 col-lg-7">
            <h5 className="mb-2">Preparation Instructions</h5>
            <div
              dangerouslySetInnerHTML={{
                __html: `${recipeInstruction}`,
              }}
            />
          </div>
          <div className="col-sm-12 col-lg-2">
            <button
              type="button"
              className="btn btn-danger"
              onClick={deleteRecipe}
            >
              Delete Recipe
            </button>
          </div>
        </div>
        <Link to="/recipes" className="btn btn-link">
          Back to recipes
        </Link>
      </div>
    </div>
  );
};

export default Recipe;

保存並退出文件。

重新啟動應用程序服務器,然後導航到主頁。單擊查看食譜按鈕以訪問所有現有的食譜,然後打開任何特定的食譜,單擊頁面上的刪除食譜按鈕以刪除文章。您將被重定向到食譜頁面,已刪除的食譜將不再存在。

隨著刪除按鈕的運作,您現在擁有一個完全功能的食譜應用程序!

結論

在這個教程中,您使用Ruby on Rails和React前端創建了一個食譜應用程序,使用PostgreSQL作為您的數據庫,並使用Bootstrap進行樣式設計。如果您想繼續使用Ruby on Rails進行開發,考慮參考我們的使用SSH隧道保護三層Rails應用程序中的通信教程,或者訪問我們的如何在Ruby中編碼系列來恢復您的Ruby技能。如果您想深入了解React,請嘗試如何使用React顯示來自DigitalOcean API的數據

Source:
https://www.digitalocean.com/community/tutorials/how-to-set-up-a-ruby-on-rails-v7-project-with-a-react-frontend-on-ubuntu-20-04