iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 30
0
Modern Web

向 Rails 致敬!30天寫一個網頁框架,再拿來做一個 Todo List系列 第 30

[DAY 30] 復刻 Rails - View 威力加強版 - 2

終於到最後一天了,那就不囉嗦直接進入正題吧!

關於 rendering.rb

之前我們的做法是把 render 寫在 Controller 裡面,畢竟 render 是 Controller 的 mehtod 這樣的寫法也沒錯,可是如果要做到單一職責的話,還是需要讓 ActionView 來做這件事情,所以我們先修改一下 metal.rb

# mavericks/lib/action_controller/metal.rb

module ActionController
  class Metal
    attr_accessor :request, :response

    def process(action)
      send action
    end

    def params
      request.params
    end
  end
end

還有 route_set.rb

# mavericks/lib/action_dispatch/routing/route_set.rb

def dispatch(request)
  controller = controller_class.new
  controller.request = request
  controller.response = Rack::Response.new
  controller.process(action)
  controller.response.finish
end

將原先 render 相關的程式碼都做移除,接著在 ActionController 我們做 include render 的動作

# mavericks/lib/action_controller/base.rb

module ActionController
  class Base < Metal
    include Callbacks
    include ActionView::Rendering
  end
end

這裡還記得 ActionController 做些什麼事嗎?,忘記了可以翻前面的文章複習一下

include 完後我們就要開始實作 rendering.rb 裡面的程式碼,這裡我們分一個個 method 來看

# mavericks/lib/action_view/rendering.rb

module ActionView
  module Rendering
    def render(action)
      context = Base.new(view_assigns)
      path = template_path(action)

      content = Template.find(path).render(context)
      body = Template.find(layout_path).render(context) do
        content
      end

      response.body = [body]
    end

    def view_assigns
      assigns = {}
      instance_variables.each do |name|
        assigns[name[1..-1]] = instance_variable_get(name)
      end
      assigns
    end

    def template_path(action)
      "#{Mavericks.root}/app/views/#{controller_name}/#{action}.html.erb"
    end

    def layout_path
      "#{Mavericks.root}/app/views/layouts/application.html.erb"
    end

    def controller_name
      self.class.name.chomp("Controller").to_underscore
    end
  end
end

首先是 render,會傳一個 Action 進來,告訴我們要 render 那個 view,接著我們要 new 一個 Base 的物件,透過 view_assigns 來取得 Controller 裡面的 實體變數,接著帶著這些 實體變數 new 一個物件出來我們叫 context

這裡做的就是我們昨天提到的第一步驟,另外我們實作另一個 Template 來尋找並且解析 .erb 檔案的內容,最後將處理完後的 .erb 得出的頁面內容寫入到 response.body 做回應

不知道有沒有注意到 layout 那段程式碼?我們透過在 View 那邊使用 yield 來將內容當成 block 的技巧,來處理 layout 的問題

def render(action)
  context = Base.new(view_assigns)
  path = template_path(action)

  content = Template.find(path).render(context)
  body = Template.find(layout_path).render(context) do
    content
  end

  response.body = [body]
end

view_assigns 就是將 Controller 裡面的實體變數,一個一個的取出並且轉成 Hash

def view_assigns
  assigns = {}
  instance_variables.each do |name|
    assigns[name[1..-1]] = instance_variable_get(name)
  end
  assigns
end

最後 template_pathlayout_pathcontroller_name 這些 method 就是檔案位置的取得和字串轉換,前面應該都有類似的實作就不多說明

關於 Base.rb

剛剛有提到將 Controller 裡面的 實體變數 取出,Base 基本上就是在處理將 實體變數 的值「帶到」View 裡面,搭配昨天提到的將 .erb 的 Template 程式碼轉成 實體方法,而這些 實體方法 擺放的位子就是 CompiledTemplates

# mavericks/lib/action_view/base.rb

module ActionView
  class Base
    include CompiledTemplates

    def initialize(assigns = {})
      assigns.each_pair do |name, value|
        instance_variable_set "@#{name}", value
      end
    end
  end
end

關於 template.rb

還記得我們之前處理 template 是用 erubi 這個套件嗎?但其實 Ruby 也有內建 erb 可以來處理 template,這裡我們也分一個個 method 來講解

require 'erb'

module ActionView
  class Template
    CACHE = Hash.new do |cache, file|
      cache[file] = Template.new(File.read(file), file)
    end

    def initialize(source, name)
      @source = source
      @name = name
    end

    def self.find(file)
      CACHE[file]
    end

    def render(context, &block)
      compile
      context.send(method_name, &block)
    end

    def method_name
      @name.gsub(/[^\w]/, '_')
    end

    def compile
      return if @compiled
      code = ERB.new(@source).src

      CompiledTemplates.module_eval <<-CODE
        def #{method_name}
          #{code}
        end
      CODE

      @compiled = true
    end
  end
end

我們將取出來的 .erb 交給 compile 做處理,並且將裡面的程式碼轉換成 實體方法 讓 View 做呼叫,在呼叫的同時就會帶入 實體變數,而為了不讓同個檔案重覆做 compile 我們用簡單的變數 @compiled 做紀錄,並且實作 CACHE 增加效能

修改完後別忘了在 all.rb 加上新增加的 action_view

# mavericks/lib/mavericks/all.rb

require 'erubi'
require 'yaml'
require "mavericks"
require "active_support"
require "active_record"
require "action_controller"
require "action_dispatch"
require 'action_view'

接著回到 just_do,因為我們還沒做省略 render 的寫法,所以要先回到 TasksController.rb 將 render 加回去

# just_do/app/controllers/tasks_controller.rb

class TasksController < ActionController::Base
  before_action :find_task, only:[:show]

  def index
    @tasks = Task.all
    render :index
  end

  def show; end

  private

  def find_task
    @task = Task.find(params['id'])
  end
end

然後別忘了我們現在用的是 yield,所以要將 layout 也修改一下

<!-- just_do/app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Index</title>
    <link rel="stylesheet" href="https://unpkg.com/@coreui/coreui/dist/css/coreui.min.css">
  </head>
  <body class="c-app">
    <div class="c-wrapper">
      <header class="c-header c-header-light c-header-fixed">
      </header>
      <div class="c-body">
        <main class="c-main">
          <div class="container-fluid">
            <%= yield %>
          </div>
        </main>
      </div>
    </div><!-- Optional JavaScript -->
    <!-- Popper.js first, then CoreUI JS -->
    <script src="https://unpkg.com/@popperjs/core@2"></script>
    <script src="https://unpkg.com/@coreui/coreui/dist/js/coreui.min.js"></script>
  </body>
</html>

接著就可以測試看看了!

關於 render 可以不用寫

一定會有人問,我們之前有實作 render 不用寫呀,那現在該怎麼加回去呢?其實實作方式不難

# mavericks/lib/action_controller/base.rb

module ActionController
  class Base < Metal
    include Callbacks
    include ActionView::Rendering
    include ImplicitRender
  end
end

我們在 ActionController::Base include 這個功能進來,裡面的實作方式也很簡單

# mavericks/lib/action_controller/implicit_render.rb

module ActionController
  module ImplicitRender
    def process(action)
      super
      render action if response.empty?
    end
  end
end

直接檢查有沒有 response,如果沒有,代表沒有做 render,那就幫開發者呼叫囉

別忘了每次新增 class 或 module 都要做 autoload

# mavericks/lib/action_controller.rb

module ActionController
  autoload :Base, "action_controller/base"
  autoload :Callbacks, 'action_controller/callbacks'
  autoload :ImplicitRender, 'action_controller/implicit_render'
  autoload :Metal, "action_controller/Metal"
end

最後回到 just_do 拿掉 render 試試看

感想

從一開始學習 gem 的建立,慢慢一步一步的構建出,一個小型的 MVC 框架,過程真的是充滿了困難和各種撞牆,一來是網路上相關的資源比較零碎,再來是很多觀念我需要看3次以上才能理解,整個系列文我參考了 metaprogramming 這本書,裡面有很多觀念是我一開始不太理解為什麼要這樣做,但在復刻 Rails 的過程我才了解到,阿~原來是這樣阿,算是蠻特別的一個收穫

另外我也參考了 Rebuilding Rails,整本內容老實說並不困難,算是淺顯易懂的入門書,但在整個架構上著墨的反而比較少,更多的是告訴你一些基本概念

幫助我最多的大概就是 Owning Rails 這個教學課程,也是我花最多時間在吸收理解的部分,但也因為這樣讓我更加理解一些平常碰不得的東西

寫完這個系列文章對我寫 Rails 有很大幫助嗎?其實我覺得收穫更多的是,訓練自己的毅力吧 XD,畢竟 30 天每天寫下來也是蠻累的,尤其中間還度過兩次連假...

不管如何,還是恭喜自己完賽啦!感謝大家!

最後附上程式碼
Mavericks github


上一篇
[DAY 29] 復刻 Rails - View 威力加強版 - 1
系列文
向 Rails 致敬!30天寫一個網頁框架,再拿來做一個 Todo List30

尚未有邦友留言

立即登入留言