Rails 源代码 100 天(2)

现在,我们已经大致了解了link_to方法的内部,但有几个细节需要继续探索:

  1. link_to内部调用的方法convert_options_to_data_attributes到底在做什么?
  2. 为什么link_to方法中要freeze一些字符串?
  3. contant_tag方法最终是如何生成<a>标签的?

整理 html 属性

首先,从convert_options_to_data_attributes 的命名可以看出,这个方法是为了把options中的一些相关字段转化为页面元素的属性,而且从下面的详细代码中可以看出,这个 options 包括 options 和 html_options。另外,这个方法在button_to等方法中也有调用,目的都是整理页面元素的属性。那么,Rails 想用这个方法来整理哪些属性呢?

这个方法内部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def convert_options_to_data_attributes(options, html_options)
if html_options
html_options = html_options.stringify_keys
html_options["data-remote"] = "true".freeze if link_to_remote_options?(options) || link_to_remote_options?(html_options)

method = html_options.delete("method".freeze)

add_method_to_attributes!(html_options, method) if method

html_options
else
link_to_remote_options?(options) ? { "data-remote" => "true".freeze } : {}
end
end

我们可以看到,因为从link_to内部传入的 optionshtml_options 参数都可能是 nil,所以会有很多不同情况的判断条件。

这个方法处理的第一个属性是data-remote,而这个属性的设置方法可以是link_to 'a link', products_path, method: :post, remote: true,经过几行代码的处理之后,remote字段会被以{"data-remote" => "true"}的形式表示在 html_options 这个变量中。

另外,我们可以看到,Rails 其实考虑了remote字段不在 html_options 中,而在 options 参数中传入的情况。也就是说,如果你写出这样的代码,Rails 能巧合的生成正确的结果:

1
2
3
4
link_to 'a strange syntax', controller: 'products', action: 'index', remote: true
link_to 'a stranger syntax', {controller: 'products', action: 'index', remote: true}, class: 'my-class'
# the above 2 are similar to:
link_to 'a more ordinary syntax', {controller: 'products', action: 'index'}, remote: true

他们都会生成类似下面的元素:

<a data-remote="true" href="/products">a ... syntax</a>

这在你使用 ajax 去 GET 远端的资源时或许用的上,3种语法任意选,不过正常来看,选择把remote: true放在 html_options 中更好,也就是上面的最后一种语法。

整理了 remote字段后,Rails 会整理 method 字段。不过method就不像remote这么特殊了,它只能被写在 html_options 这个参数中。而在整理 method 字段时,Rails 又用到一个封装的方法add_method_to_attributes!

1
2
3
4
5
6
def add_method_to_attributes!(html_options, method)
if method && method.to_s.downcase != "get".freeze && html_options["rel".freeze] !~ /nofollow/
html_options["rel".freeze] = "#{html_options["rel".freeze]} nofollow".lstrip
end
html_options["data-method".freeze] = method
end

这个方法做的事情就是根据一定规则创建rel字段(如果不是get方法,如果rel字段没有被明确地写为带nofollow的 link type,则修改 rel 字段)和data-method字段。

到此为止,html_options 中剩下的字段只可能是data-remotedata-methodrel以及class等其他常用或自定义的属性。

而 options 中一定不会存在 remote 字段了。

为什么freeze

到这里,我们其实可以解释一下,为什么要 freeze

freeze其实是两年前(i.e. 2016 年)才被 merge 进 Rails 的源代码的,而且整个 url_helper 模块的freeze都是一个人的贡献,他 commit 时留下的解释是:

reduce string allocation

因为 url_helper 这个方法经常被用到,所以如果减少这里面使用字符串时的内存重新分配,可以提高性能。

其实,当人们在调用freeze的时候,通常想让他做的事可能是

  1. 防止对象被更改
  2. 检查内存的重新分配

第一种情况通常发生在人们把一个对象放在变量中时,比如a = {key: 'value'}。如果a不可修改,但别人有很难从变量的命名或者代码的其他地方看出a不可修改,那么这个时候可以通过a = {key: 'value'}.freeze把这个 Hash 对象锁住,让所有尝试改动此对象的人得到一个异常。

第二种情况才是 url_helper 这个 module 中要使用freeze的原因。url_helper 中都没有把那些 String 对象赋值给一个变量,谈不上防止别人在别处修改变量中的对象。而使用 freeze 真的能减少内存分配么?这是真的,我们可以用object_id方法做个检验:

1
2
3
4
5
def no_allocation
'a string'.freeze.object_id
end

no_allocation == no_allocation # true

object_id 方法是对象的唯一标示,如果object_id 不同,则代表是不同的对象,存储位置也不一样。但是上面的实验显示两次使用'a string'.freeze得到的是同一个对象,Ruby 没有重新分配一块内存来存储新的 String 对象。

但如果不用freeze的话,情况就不一样了:

1
2
3
4
5
def yes_allocation
'a string'.object_id
end

yes_allocation == yes_allocation # false

每次调用yes_allocation方法,内存都会新分配一个对方存储新的'a string'

生成<a>

现在的局面是跟 url 有关的字段都在 options 这个变量中,而跟元素其他属性相关的字段都以字典的形式存在 html_options 中,除了href属性。

Rails 会用url_for(options)生成href属性需要的字符串,然后也存放到html_options中,就是这两行代码

1
2
url = url_for(options)
html_options["href".freeze] ||= url

至于url_for方法是怎么生成 url 的,我们以后再讨论。

更重要的是当所有这些<a>的元素都整理好之后,Rails 会用content_tag方法来生成<a>,怎么做的呢:

1
content_tag("a".freeze, name || url, html_options, &block)

而在 content_tag 方法中又发生了什么呢?这可以是独立的一节来描述,所以我们放到下一节。而且,可以想见,如果了解了 content_tag方法,其他类似的 tag 方法都可以类似的理解,比如image_tag

Recap

到这里,我们可以小小的总结一下,我们从link_to这个在 ActionView 模块中定义的方法中看到了什么:

  1. 利用含有默认值的 positional parameters 和接受 code block 类型的参数,使得调用方法的语法特别多样。
  2. 在 block_given? 的情况下轮换参数顺序,使得在外部调用时感觉不到传参上的不便,比如出现link_to(nil, 'http://example.com') { '<span>Example</span>' }这样必须传一个 nil 来站位的情况。
  3. 封装方法,处理有很多个字段的字典,而不是在方法内部直接处理。在link_to 的外部,只需要使用remote等很简单的字段。转换成 html 元素属性的工作交给内部封装的方法。
  4. 对于经常调用的方法中需要不断使用的同一字符串进行 freeze,减少 ruby 重新分别内存的次数,提高性能。这样的字符串也不适合复制给常量,因为使用的时候不够直接,所以就地freeze