Rails Action Text 全面指南:富文本编辑、附件管理与安全渲染
Action Text 是 Ruby on Rails 内置的富文本处理框架,它把所见即所得(WYSIWYG)编辑器 Trix、ActionText::RichText 数据模型与 Active Storage 附件系统整合在一起,让你可以用一个 has_rich_text 声明为任意 Active Record 模型添加格式化正文(粗体、斜体、链接、图片、引用等)能力,并在保存、渲染、安全消毒与附件展示全链路上获得开箱即用的实现。读完本指南,你将掌握 Action Text 的安装配置、富文本创建与渲染、Trix 编辑器样式定制,以及 Active Storage 直传与 Signed GlobalID 两种附件处理方案的完整实战方法。本指南以仓库文档 action_text_overview.md 为核心骨架,并结合本仓库中 actiontext 组件的源码实现进行纵深佐证。
什么是 Action Text?
Action Text 用于便捷地创建、存储与展示富文本内容。所谓富文本,是指带有格式元素(如粗体、斜体、颜色、超链接)的文本,相比纯文本拥有更强的视觉表现与结构化信息。它允许我们把富文本内容创建出来、存入数据表,再把它挂到任意模型上。
Action Text 内置了一个名为 Trix 的 WYSIWYG 编辑器,用于在 Web 应用中给用户提供友好、易用的富文本创建与编辑界面。Trix 负责从文本格式化、添加链接或引用,到嵌入图片等一系列丰富的编辑能力。
由 Trix 编辑器产出的富文本会保存在独立的 RichText 模型中,该模型可与应用中的任意 Active Record 模型建立关联。与此同时,正文中嵌入的图片(或其他附件)会自动通过 Active Storage(Action Text 将其作为依赖引入)存储,并与这条 RichText 记录关联。渲染时,Action Text 会先对内容做安全消毒(sanitizing),使其能够安全地直接嵌入页面 HTML——这正是富文本内容可直接输出的关键前提。
为什么是 Trix,而不是 contenteditable?
大多数 WYSIWYG 编辑器都只是对 HTML 的
contenteditable与execCommandAPI 的封装。这两个 API 最初由微软为 Internet Explorer 5.5 的网页实时编辑设计,之后被其他浏览器逆向并复制。它们从未被完整地规范与文档化,而 WYSIWYG HTML 编辑器的范围又极其庞大,因此每个浏览器的实现都各有各的 bug 与怪癖,JavaScript 开发者常常不得不手工处理这些不一致。
Trix 规避这些不一致的方式是:把 contenteditable 当作一个 I/O 设备——当输入进入编辑器时,Trix 将其转换为对自己内部文档模型的一次编辑操作,然后再把该文档重新渲染回编辑器。这让 Trix 能够完全掌控每一次按键之后发生的一切,从而彻底绕开 execCommand 及其带来的一系列浏览器差异问题。
安装与配置 Action Text
运行安装命令
要安装 Action Text 并开始使用富文本,在应用根目录执行:
$ bin/rails action_text:install
以当前仓库中的安装生成器 install_generator.rb 为参考,该命令会完成以下几件事:
- 安装 JS 依赖并接入打包器:安装
trix与@rails/actiontext两个 JavaScript 包,并自动把它们import进application.js(若项目使用 importmap,则会在config/importmap.rb中追加pin声明)。仓库中生成器还支持--editor选项(默认"trix"),Action Text 已做成可插拔的编辑器体系(见 engine.rb 中config.action_text.editors与config.action_text.editor配置)。 - 添加
image_processinggem:用于对嵌入图片及其他附件执行 Active Storage 的分析与变换。更多细节参见 Active Storage Overview。 - 添加迁移:创建存储富文本与附件的表——
action_text_rich_texts、active_storage_blobs、active_storage_attachments、active_storage_variant_records。 - 创建
actiontext.css:包含全部 Trix 样式及 Action Text 所需的覆盖样式。 - 添加默认视图 partial:生成渲染 Action Text 内容与 Active Storage 附件(即 blob)的默认 partial
_content.html与_blob.html。
之后执行数据库迁移,新的 action_text_* 与 active_storage_* 表就会进入你的应用:
$ bin/rails db:migrate
action_text_rich_texts 表与多态关联
当 Action Text 安装创建 action_text_rich_texts 表时,它使用了多态关联(polymorphic association),以便多个模型都能添加富文本属性。表结构中的 record_type 与 record_id 两列分别存存储拥有富文本的模型的类名(ClassName)与记录 ID。
借助多态关联,一个模型可以通过单条关联同时属于多个其他模型。可参见仓库中实际的迁移文件 20180528164100_create_action_text_tables.rb:
create_table :action_text_rich_texts, id: primary_key_type do |t|
t.string :name, null: false
t.text :body, size: :long
t.references :record, null: false, polymorphic: true, index: false, type: foreign_key_type
t.timestamps
t.index [ :record_type, :record_id, :name ], name: "index_action_text_rich_texts_uniqueness", unique: true
end
除多态列外,name 列记录该富文本所属的 has_rich_text 属性名,body 列以 long 文本保存 Trix 序列化后的正文,而 (record_type, record_id, name) 上的唯一索引保证每个模型实例的每个富文本属性只有一条记录。需要指出的是,仓库迁移中的主键/外键类型并非写死:primary_and_foreign_key_types 会读取 Rails.configuration.generators 下 ORM 的 primary_key_type 配置(默认主键 primary_key、外键 bigint),若应用整体使用 UUID 主键,该配置会一并生效。
使用 UUID 主键时的注意事项
如果包含 Action Text 内容的模型使用 UUID 作为标识符,那么所有使用 Action Text 属性的模型都必须统一使用 UUID 主键。同时,由于多态外键类型跟随上述全局配置推导,生成的迁移可能仍按 bigint 处理 record 引用;为保证一致,通常仍需手动修改 Action Text 生成的迁移,把 references 行明确为 type: :uuid:
t.references :record, null: false, polymorphic: true, index: false, type: :uuid
创建富文本内容
本节介绍为模型添加富文本所需的配置步骤。
核心机制:RichText 记录与 has_rich_text
RichText 记录把 Trix 编辑器产出的内容保存在一个经过序列化的 body 属性中,同时持有所有通过 Active Storage 存储的嵌入文件的引用。这条记录会与"想要富文本内容的 Active Record 模型"关联起来,关联方式就是在该模型上调用 has_rich_text 类方法:
# app/models/article.rb
class Article < ApplicationRecord
has_rich_text :content
end
注意:不需要在 Article 表中添加
content列。has_rich_text会把content关联到已创建的action_text_rich_texts表并回链到你的模型。属性名也可以自定义为content以外的任意名字。
从仓库实现 attribute.rb 看,has_rich_text 会在模型上动态生成一组方法:
content——懒加载并返回(必要时构建)对应的RichText记录,例如article.content.to_s;content?——判断是否存在非空的富文本正文(rich_text_content.present?);content=——接收来自 Trix 的 HTML 字符串写入正文。
其底层通过一条多态 has_one 关联实现:has_one :rich_text_content, as: :record, inverse_of: :record, autosave: true, dependent: :destroy,并绑定 where(name: name) 过滤,使同一模型可持有多个不同名字的富文本字段。除了文档中常规用法,该方法签名还支持(默认值取自源码 attribute.rb):
encrypted: false——置为true时改用ActionText::EncryptedRichText(非确定性加密,依赖 Active Record 加密能力);strict_loading: strict_loading_by_default——是否强制 strict loading;store_if_blank: true——置为false时,若写入空白值则不再为其创建RichText记录,而是标记销毁已有空记录。
在表单中使用 rich_textarea
为模型加上 has_rich_text 之后,就可以在视图中让该字段使用富文本编辑器(Trix)。做法是把表单字段声明为 rich_textarea:
<%# app/views/articles/_form.html.erb %>
<%= form_with model: article do |form| %>
<div class="field">
<%= form.label :content %>
<%= form.rich_textarea :content %>
</div>
<% end %>
这会显示一个 Trix 编辑器,用于创建与更新富文本。rich_textarea 渲染出的是一对元素:一个真正的 <trix-editor> 可编辑区,加上一个隐藏的 <input>——Trix 在用户编辑时会把 HTML 写入该隐藏域,从而随表单一起正常提交。其实现位于 tag_helper.rb:rich_textarea_tag / rich_textarea 默认会把编辑器容器带上 class="trix-content"(保证默认样式生效),并自动注入 data-direct-upload-url(默认 rails_direct_uploads_url)与 data-blob-url-template(默认 rails_service_blob_url(":signed_id", ":filename"))两个 data 属性,用于编辑器内的图片直传与预览;传入块时还可设置默认编辑内容。
编辑器样式的更新方式,稍后会在「移除或添加 Trix 样式」一节详述。
控制器参数白名单
最后,为了保证能接收来自编辑器的更新,需要在对应控制器中把该属性加入允许参数:
class ArticlesController < ApplicationController
def create
article = Article.create! params.expect(article: [:title, :content])
redirect_to article
end
end
重命名模型类时的数据同步
一旦有需要重命名使用 has_rich_text 的类(例如把 Article 改名),也必须同步更新 action_text_rich_texts 表中对应行的多态类型列 record_type。由于 Action Text 依赖多态关联,而多态关联会把类名存进数据库,保持库中数据与 Ruby 代码中的类名一致至关重要,否则既存的富文本将无法正确解析回模型。这一点在仓库源码 attribute.rb 的注释中也被明确提醒。
渲染富文本内容
ActionText::RichText 实例可以直接嵌入页面,因为其内容在保存/输出阶段已做过安全消毒:
<%= @article.content %>
这里实际触发的是 ActionText::RichText#to_s:它把富文本安全地转换为 HTML 字符串(经由 content.rb 的 to_s → to_rendered_html_with_layout 链路,最终套上默认布局 partial 输出)。在 rich_text.rb 中可以看到,RichText 通过 serialize :body, coder: ActionText::Content 把 body 序列化为 ActionText::Content 对象,并委托 to_s 等行为给它;Content 内部用 Nokogiri 解析 HTML fragment 并做标准化处理。
与之相对,ActionText::RichText#to_plain_text 返回的是去掉标签但保留 HTML 实体编码的纯文本,该字符串不是 HTML safe 的,未经额外消毒不应直接在浏览器中渲染:
message = Message.create!(content: "<h1>Funny times!</h1>")
message.content.to_s # => "<h1>Funny times!</h1>"
message.content.to_plain_text # => "Funny times!"
同文件还提供了 to_markdown(attachment_links: false),可把正文转换为 Markdown;在需要编辑态预览时还可用 to_editor_html(旧名 to_trix_html 已弃用)获得在编辑器中可回填的 HTML。
安全消毒
消毒发生在保存与渲染过程中。仓库中 engine.rb 通过 config.action_text.sanitizer_vendor 允许应用替换消毒器实现,默认使用 Action View 的 safe-list sanitizer(ActionText::ContentHelper.sanitizer),据此剥离 onclick、<script> 之类的危险片段,这正是 to_s 结果可以放心直接输出的原因。
注意:如果
content字段中存在附件资源,而本机尚未安装 Active Storage 所需的第三方软件依赖,附件可能无法正常显示。
定制富文本编辑器(Trix)
当需要按自己的风格要求调整编辑器的呈现时,可以按下面的方式定制。
移除或添加 Trix 样式
默认情况下,Action Text 会把富文本内容渲染进一个带 .trix-content 类的元素中,这一行为由 app/views/layouts/action_text/contents/_content.html.erb 决定(仓库内置的默认版本见 layouts/action_text/contents/_content.html.erb,仅一行 <div class="trix-content"><%= yield %></div>),带有该类的元素再由 trix 样式表进行样式化。
如果你想调整任何 trix 样式,可在 app/assets/stylesheets/actiontext.css 中添加自定义样式——这个文件由安装器生成,同时包含 Trix 的整套样式与 Action Text 所需的覆盖(override)。
定制内容容器
想要定制富文本内容外层包裹的 HTML 容器元素,编辑安装器生成的 app/views/layouts/action_text/contents/_content.html.erb 布局文件:
<%# app/views/layouts/action_text/contents/_content.html.erb %>
<div class="trix-content">
<%= yield %>
</div>
定制嵌入图片与附件的 HTML
要定制嵌入图片及其他附件(即 blob)渲染出的 HTML,编辑安装器生成的 app/views/active_storage/blobs/_blob.html.erb 模板:
<%# app/views/active_storage/blobs/_blob.html.erb %>
<figure class="attachment attachment--<%= blob.representable? ? "preview" : "file" %> attachment--<%= blob.filename.extension %>">
<% if blob.representable? %>
<%= image_tag blob.representation(resize_to_limit: local_assigns[:in_gallery] ? [ 800, 600 ] : [ 1024, 768 ]) %>
<% end %>
<figcaption class="attachment__caption">
<% if caption = blob.try(:caption) %>
<%= caption %>
<% else %>
<span class="attachment__name"><%= blob.filename %></span>
<span class="attachment__size"><%= number_to_human_size blob.byte_size %></span>
<% end %>
</figcaption>
</figure>
仓库中该默认模板位于 actiontext/app/views/active_storage/blobs/_blob.html.erb。它演示了几个关键点:可通过 blob.representable? 区分"图片类可预览 blob"与"普通文件 blob",从而为 <figure> 施加不同的 attachment--preview / attachment--file 及按扩展名命名的 CSS 类;可预览的 blob 用 image_tag blob.representation(...) 生成自适应缩略图,图库场景(in_gallery 为真)下缩略上限更小(800×600);图注部分优先显示 blob 的 caption,否则展示文件名与人类可读的文件大小。
附件处理
目前 Action Text 支持两类附件:通过 Active Storage 上传的附件,以及通过 Signed GlobalID 关联的附件。
通过 Active Storage 上传附件
在富文本编辑器中上传图片时,动作由 Action Text 发起,底层则使用 Active Storage。不过 Active Storage 有若干第三方依赖 并不由 Rails 提供,要使用内置的预览(preview)能力,需要安装这些库。这些库并非全部必需,具体取决于你预期在编辑器中接收的上传类型。
用户在使用 Action Text 与 Active Storage 时最常遇到的一个问题是:图片在编辑器中无法正确渲染。这通常是因为系统没有安装 libvips 依赖。
附件直传的 JavaScript 事件
Action Text 在整个文件附件生命周期内都会派发 Active Storage 的 Direct Upload 事件。除常规的 event.detail 属性之外,Action Text 额外派发的事件还会携带 event.detail.attachment 属性(对应本次文件插入所创建的 Trix attachment)。
| 事件名 | 事件目标 | 事件数据(event.detail) |
描述 |
|---|---|---|---|
direct-upload:initialize |
<trix-editor> |
{id, file, attachment} |
表单提交后对每个文件派发。 |
direct-upload:start |
<trix-editor> |
{id, file, attachment} |
一次直传开始。 |
direct-upload:before-blob-request |
<trix-editor> |
{id, file, xhr, attachment} |
在向应用请求直传元数据之前。 |
direct-upload:before-storage-request |
<trix-editor> |
{id, file, xhr, attachment} |
在请求存储文件之前。 |
direct-upload:progress |
<trix-editor> |
{id, file, progress, attachment} |
文件存储请求进行中。 |
direct-upload:error |
<trix-editor> |
{id, file, error, attachment} |
发生错误。若不取消该事件,将弹出 alert 提示。 |
direct-upload:end |
<trix-editor> |
{id, file, attachment} |
一次直传结束。 |
经 Action Text 通过 Active Storage 直传的文件有可能最终并未被嵌入任何富文本内容。建议定期清理这些"无主上传"(purging unattached uploads)。相关做法同样见 Active Storage Overview。
通过 Signed GlobalID 关联附件
除上传到 Active Storage 的附件之外,Action Text 还可以嵌入任何能通过 Signed GlobalID 解析的对象。
Global ID 是应用级的统一 URI,用于唯一标识一个模型实例,形如 gid://YourApp/Some::Model/id。当你需要用一个标识符去引用不同类型的对象时,它非常有用。
使用这种方式时,Action Text 要求附件具有签名全局 ID(sgid)。默认情况下,Rails 应用中的全部 Active Record 模型都混入了 GlobalID::Identification concern,因此它们都可以被 sgid 解析,从而天然兼容 ActionText::Attachable。
Action Text 会在保存时记录你所插入的 HTML 引用,以便之后用最新的内容重新渲染——也就是说,你可以引用某个模型,并在记录变化后始终展示其当前内容。渲染时,Action Text 会先从 global ID 加载出模型,再用默认 partial 路径渲染它。
一个 Action Text Attachment 看起来是这样的:
<action-text-attachment sgid="BAh7CEkiCG…"></action-text-attachment>
Action Text 渲染内嵌的 <action-text-attachment> 元素时,会先解析其 sgid 属性得到对象实例,再把实例交给渲染 helper;渲染出的 HTML 作为 <action-text-attachment> 元素的后代嵌入。要让对象能作为 Attachment 渲染,需要 include ActionText::Attachable 模块,该模块通过 GlobalID::Identification 实现了 #to_sgid(**options):
class Person < ApplicationRecord
include ActionText::Attachable
end
person = Person.create! name: "Javan"
html = %Q(<action-text-attachment sgid="#{person.attachable_sgid}"></action-text-attachment>)
content = ActionText::Content.new(html)
content.attachables # => [person]
从仓库实现 attachable.rb 看,attachable_sgid 会生成一个限定用途(purpose 为 "attachable")且不过期的 sgid:to_sgid(expires_in: nil, for: LOCATOR_NAME),LOCATOR_NAME = "attachable"。也就是说,这个签名 ID 只允许被 Action Text 的附件定位器使用,无法被用于其他业务目的,是一种安全上的隔离。同时,ActionText::Content#attachables 在解析时会依次尝试 sgid 解析、ContentAttachment、RemoteImage 三种来源,全部失败则返回一个 MissingAttachable 占位对象,为"记录已删除"的场景兜底。
渲染一个 Action Text Attachment
<action-text-attachment> 的默认渲染方式是默认路径 partial。下面以 User 模型为例:
# app/models/user.rb
class User < ApplicationRecord
has_one_attached :avatar
end
user = User.find(1)
user.to_global_id.to_s #=> gid://MyRailsApp/User/1
user.to_signed_global_id.to_s #=> BAh7CEkiCG…
我们可以把
GlobalID::Identification混入任何带.find(id)类方法的模型;Active Record 模型默认自带该能力。
上述代码得到唯一标识该模型实例的 ID。接着,看一段嵌入了引用 User 实例 sgid 的 <action-text-attachment> 的富文本:
<p>Hello, <action-text-attachment sgid="BAh7CEkiCG…"></action-text-attachment>.</p>
Action Text 用 "BAh7CEkiCG…" 解析出 User 实例,然后在渲染内容时按默认 partial 路径渲染它。此处的默认 partial 就是 users/user:
<%# app/views/users/_user.html.erb %>
<span><%= image_tag user.avatar %> <%= user.name %></span>
于是 Action Text 渲染出的最终 HTML 大致如下:
<p>Hello, <action-text-attachment sgid="BAh7CEkiCG…"><span><img src="..."> Jane Doe</span></action-text-attachment>.</p>
为 action-text-attachment 渲染不同的 partial
如果想为某个可附件对象渲染不同的 partial,可以定义 to_attachable_partial_path(实例方法,其默认值是 to_partial_path,见 attachable.rb):
class User < ApplicationRecord
def to_attachable_partial_path
"users/attachable"
end
end
然后声明该 partial,User 实例将作为 user 局部变量可用:
<%# app/views/users/_attachable.html.erb %>
<span><%= image_tag user.avatar %> <%= user.name %></span>
为无法解析或缺失的 action-text-attachment 渲染 partial
如果 Action Text 无法解析出 User 实例(例如记录已被删除),默认会渲染一个回退 partial。仓库中默认回退视图为 actiontext/app/views/action_text/attachables/_missing_attachable.html.erb,对应默认类方法 to_missing_attachable_partial_path(见 attachable.rb)。
想渲染不同的"缺失附件" partial,定义类级方法 to_missing_attachable_partial_path:
class User < ApplicationRecord
def self.to_missing_attachable_partial_path
"users/missing_attachable"
end
end
然后声明该 partial:
<%# app/views/users/missing_attachable.html.erb %>
<span>Deleted user</span>
通过 API 使用 Attachable
如果你的架构并不遵循传统的 Rails 服务端渲染模式,而是一个后端 API(例如返回 JSON),那么你需要一个独立的文件上传端点。该端点负责创建一个 ActiveStorage::Blob,并返回它的 attachable_sgid:
{
"attachable_sgid": "BAh7CEkiCG…"
}
之后在前端代码中把 attachable_sgid 放进 <action-text-attachment> 标签,即可把它插入富文本内容:
<action-text-attachment sgid="BAh7CEkiCG…"></action-text-attachment>
其他建议:避免 N+1 查询
如果你希望预加载关联的 ActionText::RichText 模型(假设富文本字段名为 content),可使用 has_rich_text 自动生成的具名 scope(仓库实现见 attribute.rb,with_all_rich_text 见同文件 rich_text_association_names 相关方法):
Article.all.with_rich_text_content # 仅预加载 body,不含附件。
Article.all.with_rich_text_content_and_embeds # 同时预加载 body 与附件(含附件需连表 includes embeds_attachments: :blob)。
若模型持有多个富文本字段,还可以用 Article.all.with_all_rich_text 一次性预加载所有 rich_text_* 关联。这类预加载能显著降低渲染列表页时的查询数量。
小结
Action Text 把富文本编辑(Trix)、结构化存储(action_text_rich_texts 多态表)与文件托管(Active Storage)三件事编排为开箱即用的一条龙方案:模型侧一条 has_rich_text 即完成接线,表单侧一个 rich_textarea 即获得完整 WYSIWYG 输入,输出侧通过消毒后的 to_s 即可安全渲染;对图片等附件,Active Storage 直传 + 事件钩子承担存储,Signed GlobalID + ActionText::Attachable 则打通"在正文中引用任意模型"的高级玩法。无论是追求快速落地的常规博客/内容场景,还是需要深度定制编辑器样式、自定义附件 partial、接入纯 API 前端的架构,都可以顺着上文各节的代码路径在仓库源码中继续深入钻研(入口可从 actiontext 的 lib/action_text、app/models/action_text、app/helpers/action_text 与 app/views 各目录展开)。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python08
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00