logo

Vanilla Rails - 让 Web 开发回归本质

最近一直在做一个纯 Rails 项目,纯度是 rails new xx -c tailwind -d mysql。项目虽然还没完成,但已经有很多感受想记录一下。

1. 项目介绍

项目本身类似于一个后台管理系统,大致需求是:

似乎不复杂的需求,但做的过程中还是有很多取舍,也探索到了一些有趣的知识。

2. 难搞的前端

前端是我花时间很多的地方,因为后端功能实现了就 OK,而前端样式调不好就不好看。

前天我花了一整天的时间才把登录页的前端写好,即便如此,背景图的位置比起设计图也不太精确。

另一个难点是登录框左右侧有两个 Rotation,虽然我对 Flex/Grid 布局甚至有点熟悉,但旋转、动画这些操作却是有点棘手。一开始我把设计图截图丢给 DeepSeek,效果并不好,后来我找了一个网站 https://css-transform.moro.es 在上面操作旋转,然后把它的 CSS 转成 Tailwind,最后经过精心调制,终于差强人意。

还有一个问题是在还原设计图上,原本我是追求“像素级还原”的,但实操下来发现问题很多:比如设计图尺寸是 1500x800,这在我 27 寸的显示器上,右侧就会有一片空白,这样 Flex 布局的 Gap 就要增加。但如果要把网页收窄,写死的 px 又会把布局弄乱。

因为暂时没有响应式的需求,所以也没有响应式的设计图,所以大部分情况下我都是用设计图上的固定 px 来做尺寸。

另一个问题是字号,Tailwind 的默认字体大小是 16px,其 text-xs 是 12px,行高为 1。但我们有很多 12px 行高 14px 大小的字,所以我就有很多 text-[12px] leading-[14px] 这样的代码。虽然我确实应该在 tailwind.config.js 里设置好字体、间距等信息,但由于字体大小没有一个阶梯值信息,所以我暂时没有去统计并做这样的规范化。

凡此种种,一一列举并没有什么意义。总的来说,我在前端上的欠缺很大,要走的路也才刚开始。不过我初步的感受是:

这并非是在宣扬样式无用论,只是说和功能比起来,它的重要性显然逊一筹。

3. 我的前端思路

3.1 All in Vanilla

出于可维护性的目的和总是想尽力搞明白一切的心态,我更喜欢 Vanilla 之类的技术,尤其是在前端领域。这另一方面也是因为如果开始跳入“现代前端”的生态圈,这种刨根问底的心态往往会让人筋疲力尽、狼狈不堪。

比如我看到 React Icons 有这样的一个图标:

import { FiAirplay } from "react-icons/fi"

<FiAirplay />

我会想知道 <FiAirplay /> 背后怎么写的,是个 SVG 吗?还能接受其它参数吗?那就可能要去找找它的文档、甚至源码。而如果我使用 Hero Icons 这样的图标,只需要复制粘贴 SVG 就行了,把它保存成 SVG 图片也行,总之我知道它就是个图片,就不会再去深究它了。

图标这种小东西还好,如果碰到 shadcn/ui 这样的组件:

export function AccordionDemo() {
  return (
    <Accordion type="single" collapsible className="w-full">
      <AccordionItem value="item-1">
        <AccordionTrigger>Is it accessible?</AccordionTrigger>
        <AccordionContent>
          Yes. It adheres to the WAI-ARIA design pattern.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="item-2">
        <AccordionTrigger>Is it styled?</AccordionTrigger>
        <AccordionContent>
          Yes. It comes with default styles that matches the other
          components&apos; aesthetic.
        </AccordionContent>
      </AccordionItem>
      <AccordionItem value="item-3">
        <AccordionTrigger>Is it animated?</AccordionTrigger>
        <AccordionContent>
          Yes. It's animated by default, but you can disable it if you prefer.
        </AccordionContent>
      </AccordionItem>
    </Accordion>
  )
}

不知道别人什么感受,我自己是挺害怕的。这还只是一个 Accordion 小功能组件。事实上这种折叠功能用原生的 Web API <details><summary> 就可以实现,再花点时间调样式,甚至比去弄懂这里每一个 JSX 的时间要少很多,而它的可复用性显然也更加长远。

比起所有这些,我更在乎的是作为开发者,那种了解这个技术来龙去脉的“明镜”似的感觉,令人畅快通透。

所以相比 shadcn/ui,我更喜欢 HyperUI 这种只需要复制粘贴的组件,或者 CSS-Zero 这种基本属于 Native 的组件。

虽然我比较青睐上面那种组件,但涉及到非私人项目时就不能从心所欲了。目前在做的这个项目,让我觉得在真正了解 Rails 之前,不要轻易说 Rails 不可以。比如我的登录页、注册页和忘记密码页面几乎一样,但有一些文字上的区别,所以我写了一个这样的 layout:

<div class="flex items-center pl-[160px] pr-[300px] md:pr-[160px] justify-between min-h-screen">
  <%= render "shared/auth_bg_logo" %>

  <div class="bg-white rounded-lg shadow-lg p-8 w-[350px] h-[<%= request.path.include?("registration") ? "460px" : "444px" %>] relative border-[2px] border-[#F1F1F4]">
    <%= render "shared/auth_thumbnails" %>

    <h1 class="text-center text-[16px] leading-[14px] text-[#071437] mb-4 font-semibold"><%= title %></h1>
    <p class="text-center text-[#4B5675] text-[12px] leading-[14px]">
      <%= if request.path == new_session_path
            "没有账号?"
          else
            request.path == new_registration_path ? "已有账号?" : ""
          end %><%= link_to back_text, back_path, class: "text-[#1B84FF]" %>
    </p>

    <%= yield %>
  </div>

</div>

然后这样调用:

<%= render layout: "shared/auth_layout", locals: {
  title: "登录",
  back_text: "点此注册",
  back_path: new_registration_path,
} do %>

  <%= form_with(url: session_path) do |f| %>
    <%# ... %>
  <% end %>

<% end %>

虽然远非完美,还有很多可调优的空间,但对于一个小型项目来说,清晰度和可维护性已然足够了。对于更复杂的即便是中型项目,ViewComponent 也应该可以把控 (很多人喜欢 Phlex,但我还没欣赏到它的语法)。

我很欣赏 Erb。就像我一开始看到有人攻击 DHH 时,感觉他们说的似乎有道理,但深入了解之后,发现至少是在技术水平和视野上,没有一个攻击者能达到他的水平。如果你没有达到一定的高度就去批判更高维度的人,那结果几乎就是你错了

3.2 精进原生能力

很多年前我学 CSS 时,被 Float 布局折磨的身心俱疲,之后再也不碰前端了。但如今现代 CSS、JavaScript 以及越来越强大的 Web API 竟然进化的如此耀眼、令人爱不释手,以前要花很大力气实现的一些布局和效果,现在用原生技术就可以轻而易举地做到:

虽然我还只了解了一些皮毛,但我仿佛已经看见星辰大海。对我来说,这远比“为啥 React 19 中 useXXX hook 变了”之类的问题更有意义。

顺便提一句 Tailwind,一开始我很反感它,原本整洁的 HTML 标签被弄得混乱不堪,尤其是要在一大堆 class 中找到目标标签时,更令人厌恶。但慢慢地,我发现它有两个突出的优点:

CSS 之外,Rails 也让我找到了写 JavaScript 的乐趣。每当我在 Erb 写下 data-controller,再去写 Controller 时,我甚至有一种兴奋: 这不就是 JavaScript 原本的使命和意义吗?为网页提供交互

比如我的注册页和忘记密码页面都需要发送手机验证码,发送之后有一个倒计时:

// verification_code_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = { endpoint: String }

  async send(event) {
    const button = event.currentTarget
    const phone = this.element.querySelector('input[name*="phone"]').value

    try {
      const response = await fetch(this.endpointValue, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          "X-CSRF-Token": document.querySelector("meta[name='csrf-token']")
            .content,
        },
        body: JSON.stringify({ phone }),
      })

      const data = await response.json()

      if (!response.ok) throw new Error(data.error)

      if (data.error) {
        this.#showErrorMessage(data.error)
        return
      }

      this.#startCountdown(button)
    } catch (error) {
      this.#showErrorMessage(error.message)
    }
  }

  #startCountdown(button) {
    let seconds = 60
    button.disabled = true

    const interval = setInterval(() => {
      seconds--
      button.textContent = `${seconds}s后重新发送`
      button.classList.add("text-[#99A1B7]")

      if (seconds <= 0) {
        clearInterval(interval)
        button.disabled = false
        button.textContent = "发送验证码"
        button.classList.remove("text-[#99A1B7]")
      }
    }, 1000)
  }

  #showErrorMessage(message) {
    document.getElementById(
      "flash-message"
    ).innerHTML = `<span class="text-red-500">${message}</span>`
  }
}

这就是全部的代码,加上空格一共 59 行,提供了所有发送验证码需要的交互逻辑,任何有点 JavaScript 基础的人、甚至不需要了解 Stimulus 都能看懂这种代码,如果让他们多看两个例子,很难保证他们不会写。

3.3 不可小觑的 Hotwire

Hotwire 刚出来时我就开始学习,但我一直没学懂,Handbook 里的句子都能读懂,但用起来就手足无措。不过这次结合项目,之前学习的知识就豁然开朗了。

一开始为了赶时间,有些功能实现的比较粗糙:

这个实现简单粗暴,直接用 Rails Partial 就行。

但我不太喜欢,因为我把图表的数据放到 data controller 的 value 里,初次加载时带过来,而且之后每次切换日期更新图表时都是从 data-xx-value 里获取值,这让我觉得很内疚,我让 HTML 承担了太多原本不属于它的责任;另一方面整体替换图表我个人觉得在视觉上页面有点抖动,图表更新不自然。

后来我花了点时间重构它,从侧边栏进入时用 Turbo Frame,在切换日期时用 Json 更新数据,效果好多了:

async update(event) {
  if (!event.target?.value) return

  const date = event.target.value
  console.log(`Target date: ${date}`)

  try {
    const response = await fetch(`${this.urlValue}?target_date=${date}`, {
      headers: {
        Accept: "application/json",
        "X-Requested-With": "XMLHttpRequest",
      },
    })

    if (!response.ok)
      throw new Error(`HTTP error! status: ${response.status}`)

    const { chart_data, metrics } = await response.json()

    this.#updateChart(chart_data)
    this.#updateMetrics(metrics)
  } catch (error) {
    console.error(`Failed to update chart: ${error}`)
  }
}

这是部分代码,由于有多个图表多个页面,所以写了一个通用的 chart_controller.js,之后的效果可以这样描述“甚至感觉不到图表的更新”。

4. 总结

原本是做项目的间隙休息时想的文章,断断续续写到了十一点半,有点乱,但就这样吧。

我并非对微服务、Kubernetes、高可用一无所知,也亲手搭建过 EKS 集群。但我越来越觉得,如果想要一个人做点什么,或是一个精英小队想做成点什么,还是 Rails 这种技术更实在,更能回归本质:

它可能不如用 NextJS 一把梭,npx xx add xx 来的快,但它的确会让你即便一个人也能走的很远、很踏实,如果你来自 BAT 或者 FANNG,你当然可以对我的想法不屑一顾,但我也并不打算用 Rails 建立下一个 Google。