Ruby [转载] 元编程之重写 will_paginate

tkvern · 2016年05月08日 · 最后由 dongli1985 回复于 2016年09月30日 · 4591 次阅读

转载地址:https://tkvern.com/20160507/元编程之重写will_paginate/

will-paginate-bg

为什么重写 will_paginate

相信很多同学在使用will_paginate的时候都会遇到这样一个问题: 自带分页样式太 LOW 了,有木有好看一点的,能不能自己定制呢。于是我们在RubyGems搜索 will_paginate 的主题 gem 包。发现有各种各样主题的,但却找不到你想要的,怎么办?

本着自己动手丰衣足食的理念,我们开始动手改造will_paginate。 (注:笔者使用的是Materialize的前端框架,下文将以Materialize的分页为例)

预览效果

先来看看will_paginate默认的效果是怎么样?为了方便后续区分,默认效果叫Old,修改后效果叫New will-paginate-pagelist 上图中的Old分页稍显简陋。

下图是修改后需要New的效果 will-paginate-materiaizepg

分析结构

Old代码结构

<div class="pagination">
    <a class="previous_page" rel="prev" href="/admins/admins?page=5">← Previous</a>
    <a rel="start" href="/admins/admins?page=1">1</a>
    <a href="/admins/admins?page=2">2</a>
    <a href="/admins/admins?page=3">3</a> 
    <a href="/admins/admins?page=4">4</a>
    <a rel="prev" href="/admins/admins?page=5">5</a>
    <em class="current">6</em>
    <a rel="next" href="/admins/admins?page=7">7</a>
    <a href="/admins/admins?page=8">8</a>
    <a href="/admins/admins?page=9">9</a>
    <a href="/admins/admins?page=10">10</a>
    <span class="gap"></span>
    <a href="/admins/admins?page=24">24</a>
    <a href="/admins/admins?page=25">25</a>
    <a class="next_page" rel="next" href="/admins/admins?page=7">Next →</a>
</div>

从代码结构中可以知道,共有 5 种形式 DOM:

  1. previous_page
  2. next_page
  3. current
  4. gap
  5. default

了解结构后,需要将Old修改成下面的结构才能有New的效果

 <ul class="pagination">
    <li class="disabled"><a href="#!"><i class="material-icons">chevron_left</i></a></li>
    <li class="active"><a href="#!">1</a></li>
    <li class="waves-effect"><a href="#!">2</a></li>
    <li class="waves-effect"><a href="#!">3</a></li>
    <li class="waves-effect"><a href="#!">4</a></li>
    <li class="waves-effect"><a href="#!">5</a></li>
    <li class="waves-effect"><a href="#!"><i class="material-icons">chevron_right</i></a></li>
</ul>

分析will_paginate源码

will_paginate的源码Clone到本地。 进入lib目录下,这里就不介绍will_paginate到源码结构了,有时间自己看看。我们直奔主题,打开link_renderer.rb文件。我在里面添加了部分代码中文解释,对于修改结构已经够用了 lib/will_paginate/view_helpers/link_renderer.rb

require 'cgi'
require 'will_paginate/core_ext'
require 'will_paginate/view_helpers'
require 'will_paginate/view_helpers/link_renderer_base'

module WillPaginate
  module ViewHelpers
    # This class does the heavy lifting of actually building the pagination
    # links. It is used by +will_paginate+ helper internally.
    class LinkRenderer < LinkRendererBase

      # * +collection+ is a WillPaginate::Collection instance or any other object
      #   that conforms to that API
      # * +options+ are forwarded from +will_paginate+ view helper
      # * +template+ is the reference to the template being rendered
      def prepare(collection, options, template)
        super(collection, options)
        @template = template
        @container_attributes = @base_url_params = nil
      end

      # Process it! This method returns the complete HTML string which contains
      # pagination links. Feel free to subclass LinkRenderer and change this
      # method as you see fit.
      def to_html
        html = pagination.map do |item|
          item.is_a?(Fixnum) ?
            page_number(item) :
            send(item)
        end.join(@options[:link_separator])

        @options[:container] ? html_container(html) : html
      end

      # Returns the subset of +options+ this instance was initialized with that
      # represent HTML attributes for the container element of pagination links.
      def container_attributes
        @container_attributes ||= @options.except(*(ViewHelpers.pagination_options.keys + [:renderer] - [:class]))
      end

    protected

      # page_number方法显示分页元素
      def page_number(page)
        unless page == current_page
          link(page, page, :rel => rel_value(page))
        else
          tag(:em, page, :class => 'current')
        end
      end

      # gap方法在页数超过设定值时用...代替
      def gap
        text = @template.will_paginate_translate(:page_gap) { '&hellip;' }
        %(<span class="gap">#{text}</span>)
      end

      # previous_page方法显示上一页
      def previous_page
        num = @collection.current_page > 1 && @collection.current_page - 1
        previous_or_next_page(num, @options[:previous_label], 'previous_page')
      end

      # next_page方法显示下一页
      def next_page
        num = @collection.current_page < total_pages && @collection.current_page + 1
        previous_or_next_page(num, @options[:next_label], 'next_page')
      end

      # 边界值按钮
      def previous_or_next_page(page, text, classname)
        if page
          link(text, page, :class => classname)
        else
          tag(:span, text, :class => classname + ' disabled')
        end
      end

      def html_container(html)
        tag(:div, html, container_attributes)
      end

      # Returns URL params for +page_link_or_span+, taking the current GET params
      # and <tt>:params</tt> option into account.
      def url(page)
        raise NotImplementedError
      end

    private

      def param_name
        @options[:param_name].to_s
      end

      # 私有方法, 构建a标签
      def link(text, target, attributes = {})
        if target.is_a? Fixnum
          attributes[:rel] = rel_value(target)
          target = url(target)
        end
        attributes[:href] = target
        tag(:a, text, attributes)
      end

      # 私有方法, 包裹标签
      def tag(name, value, attributes = {})
        string_attributes = attributes.inject('') do |attrs, pair|
          unless pair.last.nil?
            attrs << %( #{pair.first}="#{CGI::escapeHTML(pair.last.to_s)}")
          end
          attrs
        end
        "<#{name}#{string_attributes}>#{value}</#{name}>"
      end

      def rel_value(page)
        case page
        when @collection.current_page - 1; 'prev' + (page == 1 ? ' start' : '')
        when @collection.current_page + 1; 'next'
        when 1; 'start'
        end
      end

      def symbolized_update(target, other)
        other.each do |key, value|
          key = key.to_sym
          existing = target[key]

          if value.is_a?(Hash) and (existing.is_a?(Hash) or existing.nil?)
            symbolized_update(existing || (target[key] = {}), value)
          else
            target[key] = value
          end
        end
      end
    end
  end
end

打开类

通过分析我们已经了解需要修改哪些方法

  1. page_number
  2. previous_page
  3. next_page
  4. previous_or_next_page

同时我们还将使用两个私有方法

  1. link(text, target, attributes = {})
  2. tag(name, value, attributes = {})

回到工作项目,新建文件。下面使用了元编程的法术——打开类。这也是作为动态语言的优点。修改过的地方我加了注释。 lib/materialize_renderer.rb

require 'cgi'
require 'will_paginate/core_ext'
require 'will_paginate/view_helpers'
require 'will_paginate/view_helpers/link_renderer_base'

module WillPaginate
  module ViewHelpers
    # This class does the heavy lifting of actually building the pagination
    # links. It is used by +will_paginate+ helper internally.
    class LinkRenderer < LinkRendererBase

      # * +collection+ is a WillPaginate::Collection instance or any other object
      #   that conforms to that API
      # * +options+ are forwarded from +will_paginate+ view helper
      # * +template+ is the reference to the template being rendered
      def prepare(collection, options, template)
        super(collection, options)
        @template = template
        @container_attributes = @base_url_params = nil
      end

      # Process it! This method returns the complete HTML string which contains
      # pagination links. Feel free to subclass LinkRenderer and change this
      # method as you see fit.
      def to_html
        html = pagination.map do |item|
          item.is_a?(Fixnum) ?
            page_number(item) :
            send(item)
        end.join(@options[:link_separator])

        @options[:container] ? html_container(html) : html
      end

      # Returns the subset of +options+ this instance was initialized with that
      # represent HTML attributes for the container element of pagination links.
      def container_attributes
        @container_attributes ||= @options.except(*(ViewHelpers.pagination_options.keys + [:renderer] - [:class]))
      end

    protected

      # 修改后,我使用私有方法tag,在link外面套了一层li,同时修改了class属性
      def page_number(page)
        unless page == current_page
          # link(page, page, :rel => rel_value(page))
          tag :li, link(page, page, :rel => rel_value(page)), :class => 'waves-effect'
        else
          # tag(:em, page, :class => 'current')
          tag(:li, link(page, '#!', :rel => rel_value(page)), :class => 'active')
        end
      end

      def gap
        text = @template.will_paginate_translate(:page_gap) { '&hellip;' }
        %(<span class="gap">#{text}</span>)
      end

      # 这里没有修改全局变量@options,使用打开类最好不要修改全局变量。所以直接改了icon
      def previous_page
        num = @collection.current_page > 1 && @collection.current_page - 1
        # previous_or_next_page(num, @options[:previous_label], 'previous_page')
        previous_or_next_page(num, 'chevron_left', 'previous_page')
      end


      # 这里没有修改全局变量@options,使用打开类最好不要修改全局变量。所以直接改了icon
      def next_page
        num = @collection.current_page < total_pages && @collection.current_page + 1
        # previous_or_next_page(num, @options[:next_label], 'next_page')
        previous_or_next_page(num, 'chevron_right', 'next_page')
      end

      # 修改了边界值的按钮,增加了局部变量icon_item用于google icon
      def previous_or_next_page(page, text, classname)
        icon_item = tag :i, text, :class => 'material-icons' 
        if page
          # link(text, page, :class => classname)
          tag(:li, link(icon_item, page), :class => 'waves-effect')
        else
          # tag(:span, text, :class => classname + ' disabled')
          tag(:li, link(icon_item, '#!'), :class => 'disabled')
        end
      end

      def html_container(html)
        tag(:div, html, container_attributes)
      end

      # Returns URL params for +page_link_or_span+, taking the current GET params
      # and <tt>:params</tt> option into account.
      def url(page)
        raise NotImplementedError
      end

    private

      def param_name
        @options[:param_name].to_s
      end

      def link(text, target, attributes = {})
        if target.is_a? Fixnum
          attributes[:rel] = rel_value(target)
          target = url(target)
        end
        attributes[:href] = target
        tag(:a, text, attributes)
      end

      def tag(name, value, attributes = {})
        string_attributes = attributes.inject('') do |attrs, pair|
          unless pair.last.nil?
            attrs << %( #{pair.first}="#{CGI::escapeHTML(pair.last.to_s)}")
          end
          attrs
        end
        "<#{name}#{string_attributes}>#{value}</#{name}>"
      end

      def rel_value(page)
        case page
        when @collection.current_page - 1; 'prev' + (page == 1 ? ' start' : '')
        when @collection.current_page + 1; 'next'
        when 1; 'start'
        end
      end

      def symbolized_update(target, other)
        other.each do |key, value|
          key = key.to_sym
          existing = target[key]

          if value.is_a?(Hash) and (existing.is_a?(Hash) or existing.nil?)
            symbolized_update(existing || (target[key] = {}), value)
          else
            target[key] = value
          end
        end
      end
    end
  end
end

加入到initializers

完成上面的修改后,是不起作用的,还需要加入到initializers中,才会加载我们的打开类,新建文件 config/initializers/will_pagination_materialize.rb

require 'materialize_renderer'

完成

完成这些操作之后,重启服务器。恭喜你,完成了对will_paginate的修改。看看Newwill-paginate-done

纯 CSS 就能达到目的,你要费这么大劲

#1 楼 @libuchao 纯 CSS 是一种实现方式,但对于 Ruby 程序员来说,是不是太偷懒了。几乎大部分 Rails 的项目都会用到 will-paginate,不研究下源码,是不是无法满足好奇心呢。

#2 楼 @liamzhao 达到目的的方式很多种,打开黑盒子会更有趣。

自己造个轮子更好

#6 楼 @hooopo 老玩家怎么写呢?请教下。。。

研究、体验当然可以,这么写到项目里是不适当的。

Monkey patch 本来就丑,还要 patch 这么多就更丑了。先不说什么编程的原则,小小的一个升级就可能使你的 patch 全线崩溃。

首选就是一二楼说的 CSS 或者库自身提供的 API。如果库目前不能满足需求,想想你的需求是否通用合理,如果是,提一个 issue 或者 PR。如果真的需要改很多,而且估计维护者不会接受,那么还是 fork 一下改个名字,自己使用自己维护吧。

纯 css 就可以~~~~~

项目不一定用,但是研究源码值的👍

源码值得学习研究

#9 楼 @dddx 之所有这么改,是为了跟所使用框架的 paginate 结构一致。css 改不了 DOM 结构吧

#12 楼 @tkvern 过时啦,正确的做法是把 css 用 import 模块化引入到 scss 里,你可以有自己的 dom,但是也能用到框架的样式

#12 楼 @tkvern 我不知道真正的老玩家是怎么写的,但是碰到特殊需求(比如我想在 will_paginate 中用罗马数字代替阿拉伯数字来表示页数)时。一般的思路是自己调用 will_paginate 的一些 Api,结合一些代码写一个自己的 helper,然后在 view 里用<%= my_will_paginate @posts %>替换原来的 helper 方法。

在能够不用元编程的时候,尽量避免使用它。像上面的例子违背了一个我们在构建程序结构时的原则,即把经常需要变化的代码和不会变动的代码分写在两个不同的容器里。

为学习精神点赞,祖国需要你这样的人,^_^

学习 Ruby 元编程绝佳范例!👍

#16 楼 @mukewells 学习本就是一个不断尝试的过程。在编程中,即使大多人说某种方案违背了 XXX 特性或什么的,如果自己不尝试永远只会是语言的巨人。相信你也在踩坑中。

Kaminari 完完全全可以实现好看的分页。。

需要 登录 后方可回复, 如果你还没有账号请 注册新账号