Multistep Form (Wizard) — Rails Recipes
Giải phẫu của một hình thức nhiều bước
Biểu mẫu nhiều bước là một biến thể của trình hướng dẫn. Trình hướng dẫn là một công cụ khả dụng. Nó thể hiện một nhiệm vụ dưới dạng một tập hợp các bước được sắp xếp theo thứ tự quy định . Người dùng phải hoàn thành từng bước theo thứ tự nhất định để đạt được mục tiêu.
Biểu mẫu nhiều bước chia các biểu mẫu phức tạp thành các phần nhỏ hơn, nhóm các trường của chúng theo các tiêu chí khác nhau . Điều này dẫn đến trải nghiệm điền biểu mẫu ít khó khăn hơn cho người dùng cuối. Khi được kết hợp với các hướng dẫn, biểu mẫu nhiều bước làm tăng đáng kể mức độ dễ sử dụng của các ứng dụng của chúng tôi .
Hạn chế chính của họ là hiệu quả . Việc điền vào một biểu mẫu nhiều bước thường mất nhiều thời gian hơn so với việc điền vào một biểu mẫu duy nhất (nếu bạn đã quen thuộc với quy trình này). Điều này là do điều hướng giữa các bước không tồn tại ở dạng tiêu chuẩn.
Cách bạn triển khai điều hướng này có thể tác động đáng kể đến trải nghiệm người dùng : bạn sẽ cho phép người dùng điều hướng tới và lui tới bất kỳ bước nào trong trình hướng dẫn hay chỉ tới các bước tiếp theo và trước đó? thông tin bước trước đó có hiển thị trong bước hiện tại không? Các lựa chọn thiết kế không phù hợp có thể dẫn đến trải nghiệm điền biểu mẫu chậm và khó chịu khiến người dùng không muốn điền vào biểu mẫu và thậm chí sử dụng hệ thống của chúng tôi.
Công thức này sẽ hướng dẫn bạn cách tạo các biểu mẫu nhiều bước có thể tùy chỉnh và tái sử dụng trong Rails.
Các thách thức
Trong ứng dụng của chúng tôi, chúng tôi quản lý Projects. Một dự án được xác định bởi a namevà a description. A Projectcó thể có nhiều Booksliên kết với nó. A Bookđược xác định bởi a titlevà a description. Chúng tôi muốn triển khai biểu mẫu nhiều bước để tạo tệp Projects. Bước đầu tiên sẽ yêu cầu thông tin cơ bản của dự án và bước thứ hai, tùy chọn, thông tin về một Bookliên kết với Project.
Công thức
điều kiện tiên quyết
- (Bắt buộc) Đã cài đặt Rails
ViewComponentgem :ViewComponentbọc Railsviewsthành các đối tượng. Điều này giúp kiểm tra chế độ xem của chúng tôi dễ dàng hơn và cung cấp các phương pháp bổ sung để mang lại trải nghiệm tốt hơn cho nhà phát triển khi làm việc với chúng. - (Tùy chọn) đã cài đặt plugin : khung này loại bỏ nhu cầu viết mã của chúng tôi bằng cách cung cấp một tập hợp các lớp mạnh mẽ.
TailwindCSSPostCSSCSSCSSCSS
Trong Rails, chúng tôi sử dụng biểu mẫu để tạo và cập nhật bản ghi. Các hoạt động này được thực hiện thông qua POST, PUTvà PATCH HTTPcác yêu cầu được gửi từ một biểu mẫu duy nhất. Các yêu cầu được xử lý theo quy trình tương tự:
- Lớp Rails
Controllerphân tích dữ liệu yêu cầu biểu mẫu và yêu cầuModellớp thực hiện thao tác được yêu cầu với thông tin được trích xuất. - Lớp Rails
Modelxử lý dữ liệu yêu cầu, xác thực nó và thực hiện thao tác được yêu cầu nếu nó hợp lệ. Kết quả của hoạt động được gửi lại từModeltệpController. ControllerLớp Rails yêu cầuViewlớp hiển thị phản hồi dựa trên kết quả hoạt động. Các hoạt động thành công hiển thị chế độ xem kết quả hoạt động. Các hoạt động không thành công hiển thị biểu mẫu đã gửi với dữ liệu yêu cầu biểu mẫu và thông báo lỗi.
Nhiệm vụ đầu tiên của chúng tôi là triển khai logic để xác thực xem tất cả các bước trong biểu mẫu nhiều bước đã được hoàn thành hay chưa.
Rails ActiveRecord::Validations API , được bao gồm trong ApplicationRecord Modelcác lớp, định nghĩa valid?phương thức. Phương pháp này được thực hiện trên mọi thao tác tạo và cập nhật . Nó đánh giá tất cả Modelcác điều kiện xác thực, trả lại truekhi tất cả các điều kiện xác thực vượt qua. Chỉ valid?các hoạt động có thể lưu giữ thông tin trong cơ sở dữ liệu.
Việc xác thực cho biểu mẫu nhiều bước của chúng tôi phải kiểm tra chỉ mục bước hiện tại trong yêu cầu biểu mẫu tương ứng với bước cuối cùng của nó . Chúng ta cần hai thuộc tính để theo dõi các biến này: current_stepđể lưu trữ chỉ số bước hiện tại và total_stepslưu trữ số bước ở dạng nhiều bước. Vì cả xác thực và thuộc tính đều giống nhau cho tất cả các mô hình, nên chúng ta có thể gói gọn chúng trong một cái concerncó thể được sử dụng lại trên Modelcác lớp của chúng ta:
# app/models/concerns/multistep_form_model.rb
module MultistepFormModel
ERROR_ATTRIBUTE = :current_step
ERROR_DETAIL = :incompleted_multistep_form
extend ActiveSupport::Concern
included do
# Defines two new attributes in including Model class
attr_accessor :current_step, :total_steps
# Adds new validation rule in including Model class
validate :all_multistep_form_steps_completed
end
private
def all_multistep_form_steps_completed? = current_step == total_steps
def all_multistep_form_steps_completed
errors.add(ERROR_ATTRIBUTE, ERROR_DETAIL) unless all_multistep_form_steps_completed?
end
end
# app/models/concerns/multistep_form_model.rb
module MultistepFormModel
...
included do
# Adds new validation rule in including Model class
validate :all_multistep_form_steps_completed,
if: -> { current_step.present? && total_steps.present? }
end
...
end
Cuối cùng, chúng tôi có thể cập nhật các mô hình của mình bằng cách đưa vào biểu mẫu nhiều bước concernđể chúng tôi có thể sử dụng chúng với biểu mẫu nhiều bước của mình:
# app/models/project.rb
class Project < ApplicationRecord
...
# Now our Project class has current_step and total_steps attributes, and the multistep validation rule
include MultistepFormModel
...
end
Controllerschịu trách nhiệm phân tích dữ liệu yêu cầu biểu mẫu và chuyển nó xuống lớp Model. Để làm việc với các biểu mẫu nhiều bước, chúng ta cần cập nhật các tham số mạnh của bộ điều khiển để đảm bảo các tham số curret_stepvà total_stepsđược phân tích cú pháp và chuyển đến Modellớp.
# app/controllers/projects_controller.rb
class ProjectsController < ApplicationController
...
private
...
def project_params
params
.require(:project)
.permit(:name, :description, :total_steps, :current_step, books_attributes: [:title, :description])
end
...
end
Hãy phân tách một biểu mẫu nhiều bước trong ViewComponents. Biểu mẫu nhiều bước là biểu mẫu được chia thành nhiều phần (các bước). Về mặt thành phần, điều này có thể được mô hình hóa như một MultistepFormComponentnội dung bao gồm nhiều tệp StepComponents.
Mỗi bước chứa một tập hợp các trường biểu mẫu. Một bước được hoàn thành khi tất cả các trường bắt buộc của bước được điền dữ liệu hợp lệ. A StepComponenthiển thị các trường tương ứng với một bước biểu mẫu gồm nhiều bước và cung cấp một giao diện đơn giản để biết, với dữ liệu biểu mẫu, nếu bước mà biểu mẫu đại diện đã hoàn tất .
Trước đây chúng ta đã nói về valid?phương thức và cách nó xác định xem dữ liệu biểu mẫu có hợp lệ hay không. Ngoài hành vi này, còn cập nhật đối tượng bằng từ điển chứa lỗi xác thực . Nó có thể được truy cập thông qua , trả về một đối tượng. valid?Modelmodel.errorsActiveModel::Errors
Để xác định xem a StepComponentđã hoàn thành hay chưa, chúng ta có thể sử dụng form.object.errorsđối tượng. Đưa ra một bước với một tập hợp các trường đầu vào, chúng tôi có thể xác nhận bước đã hoàn thành nếu đối form.object.errorstượng không bao gồm bất kỳ lỗi nào liên quan đến các trường bước.
# How to check whether a step is completed.
def completed?(form_object)
input_attributes.none? { |attr| form_object.errors.key?(attr) }
end
- Một
ActionView::Helpers::FormBuilderđối tượng để hiển thị các trường bước và tìm nạp dữ liệu biểu mẫu thông qua.objectphương thức. - Một danh sách các
input_attributestrường mà nó hiển thị. - Không được tiết lộ từ phương pháp nhưng rõ ràng, chúng ta cũng cần biết chỉ số của bước ở dạng nhiều bước.
# app/components/common/multistep_form_component/step_component.rb
module Common
class MultistepFormComponent::StepComponent < ApplicationComponent
class << self
def title = raise "StepComponent #{name} does not define title method"
def input_attributes = raise "StepComponent #{name} does not define list of input attributes"
def completed?(form_object)
input_attributes.none? { |attr| form_object.errors.key?(attr) }
end
end
attr_reader :multistep_component, :index, :form
def initialize(multistep_component:, index:, html_attributes: {})
@index = index
@form = multistep_component.form
@multistep_component = multistep_component
super(html_attributes:)
end
def call
raise "
#{self.class.name} is a StepComponent. StepComponents are abstract by default.
Components inheriting from it must define its own template or call method.
"
end
def current_step? = index == multistep_component.current_step
def completed? = self.class.completed?(multistep_component.form.object)
end
end
Đây StepComponentlà một thành phần trừu tượng . Nó không được thiết kế để hiển thị mà được mở rộng bởi StepComponentcác lớp khác. Đối với Projectcác bước biểu mẫu của chúng tôi, chúng tôi sẽ xác định hai thành phần mới, mỗi thành phần cho mỗi bước chúng tôi muốn kết xuất:
# app/components/common/projects/form_component/project_details_component.rb
module Common
class Projects::FormComponent::ProjectDetailsComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "Project Details"
def input_attributes = %w[name]
end
...
end
end
# app/components/common/projects/form_component/project_deliverables_component.rb
module Common
class Projects::FormComponent::ProjectDeliverablesComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "Project Deliverables"
# Example of a step component with nested attributes / nested form fields
def input_attributes = %w[books.title]
end
...
end
end
- Nơi
URLdữ liệu biểu mẫu sẽ được gửi. - Và chính dữ liệu biểu mẫu. Dữ liệu này đại diện cho bản ghi chúng tôi muốn tạo hoặc cập nhật. Trong mỗi lần lặp lại của chu kỳ biểu mẫu nhiều bước, dữ liệu sẽ đại diện cho thông tin do người dùng cung cấp trong lần gửi biểu mẫu trước đó.
# app/components/common/multistep_form_component.rb
module Common
class MultistepFormComponent < ApplicationComponent
class << self
def steps = raise "#{name} MultistepFormComponent does not define steps class model"
end
delegate :steps, to: :class
attr_reader :form_url, :back_url, :model, :form
def initialize(form_url:, back_url:, model:, html_attributes: {})
@form_url = form_url
@back_url = back_url
@model = model
super(html_attributes:)
end
def call
form_with(url:, model: model) do |form|
@form = form
concat(form.hidden_field(:total_steps, value: steps.count))
concat(form.hidden_field(:current_step, value: current_step_index))
steps.each_with_index do |step, index|
concat(render step.new(multistep_form: self, index:))
end
end
end
end
end
Bây giờ chúng ta có thể định nghĩa dự án của mình FormComponentbằng cách mở rộng lớp và chỉ định bước của StepComponentcác lớp xác định biểu mẫu của nó:
# app/components/common/projects/form_component.rb
module Common
class Projects::FormComponent < Common::MultistepFormComponent
class << self
def steps = [ProjectDetailsComponent, ProjectDeliverablesComponent]
end
end
end
Có nhiều cách để điều hướng giữa các bước: một số chỉ cho phép điều hướng về phía trước cho đến khi hoàn thành tất cả các bước; ở những người khác, có thể điều hướng qua lại từng bước một, và những người khác thậm chí còn cho phép hiển thị nhiều bước trước đó cùng một lúc. Biểu mẫu nhiều bước của chúng tôi phải đủ linh hoạt để thực hiện tất cả các chiến lược điều hướng khả thi . Tuy nhiên, nó không nên thực hiện bất kỳ chiến lược điều hướng nào. Chúng tôi sẽ ủy thác nó cho các thành phần mở rộng MultistepFormComponentlớp.
Chúng tôi có thể ẩn và hiển thị các bước bằng cách sử dụng đầu vào checkboxvà đơn giản radio HTML. A checkboxlà một hộp đơn giản có thể là checked. A radiolà một nhóm checkboxestrong đó chỉ có thể là một checked. CSS có thể nhắm mục tiêu trạng thái đã kiểm tra của các phần tử này thông qua :checkedbộ chọn. Điều này cho phép HTMLcác phần tử kiểu dáng dựa trên trạng thái của một checkboxhoặc radio.
Chúng ta có thể gán a radiovà checkboxđầu vào cho từng bước trong biểu mẫu của mình và tạo kiểu cho bước đó theo trạng thái của nó để nó chỉ hiển thị khi đầu vào tương ứng được chọn. Chúng tôi sẽ xác định một phương thức mới để bọc từng trường hợp bước trong một thẻ chứa radio và hộp kiểm để đạt được nó.
# app/components/common/multistep_form_component.rb
...
def call
...
steps.each_with_index do |step, index|
concat(step_wrapper(step, index) { render step.new(multistep_form: self, index:) })
end
...
end
def radio_id(step_index) = "#{object_id}_radio_#{step_index}"
def checkbox_id(step_index) = "#{object_id}_radio_#{step_index}"
def step_wrapper(step, index, html_attributes: {}, &content)
tag.div(**html_attributes) do
concat(
radio_button_tag(
radio_id(nil),
1,
current_step_index == index,
id: radio_id(index),
class: "hidden peer/radio"
)
)
concat(
check_box_tag(
checkbox_id(index),
1,
current_step_index == index,
id: checkbox_id(index),
class: "hidden peer/checkbox"
)
)
concat(capture(&content))
end
end
...
Thành phần mở rộng MultistepFormComponentcó thể cập nhật phương pháp này xác định các thành phần chung có trong tất cả các bước và sử dụng peersđể hiển thị và ẩn các bước dựa trên checkedtrạng thái.
# app/components/common/projects/form_component.rb
...
def step_wrapper(step, index, html_attributes: {}, &content)
selected = index == current_step_index
options = (selected ? { "aria-current": "step" } : {})
super(step, index, html_attributes: options) do
concat(
tag.label(
for: index <= current_step_index ? radio_id(index) : "",
class: "
w-full inline-flex items-center px-4 py-2 gap-4 border-2 text-zinc-500 border-zinc-500
peer-checked/radio:bg-zinc-500 peer-checked/radio:text-white
"
) do
concat(tag.p(index + 1, class: "h-6 w-6 text-center rounded-full text-white bg-zinc-500"))
concat(tag.p(step.title))
end
)
concat(
tag.div(
class: "
hidden px-4 py-6
peer-checked/radio:block
"
) do
concat(capture(&content))
concat(
tag.div(class: "mt-5 w-full inline-flex gap-2 items-center justify-end") do
concat(link_to("cancel", back_url, class: "px-4 py-2 bg-zinc-500 text-white")) if index == 0
concat(tag.label("previous", for: radio_id(index - 1), class: "px-4 py-2 bg-zinc-500 text-white")) if index > 0
concat(form.submit("next", class: "cursor-pointer px-4 py-2 bg-zinc-500 text-white")) if index < (steps.count - 1)
concat(form.submit("create project", class: "cursor-pointer px-4 py-2 bg-zinc-500 text-white")) if index == (steps.count - 1)
end
)
end
)
end
end
...
Trong dòng 31 có logic để điều hướng lùi lại một bước trong biểu mẫu. Chúng tôi sử dụng MultistepFormComponent radio_idphương pháp để ánh xạ nhãn tới radiođầu vào từ bước trước. Khi chúng tôi nhấp vào nhãn, radionút sẽ được kiểm tra. Thao tác này sẽ ẩn bước hiện tại và hiển thị bước trước đó.
Để điều hướng về phía trước, chúng tôi luôn cần gửi biểu mẫu. Nếu sau khi gửi biểu mẫu, tất cả các bước trước đó đã được hoàn thành, thì nhiều bước sẽ hiển thị bước tiếp theo. Nếu không, biểu mẫu sẽ quay lại bước chưa hoàn thành.
# app/components/common/multistep_form_component.rb
...
def first_incompleted_step_index
steps.find_index { |s| !s.completed?(model) }
end
def current_step_index
previous_step = model.current_step&.to_i
@current_step_index =
if model.current_step.nil?
0
else
next_step = previous_step + (steps[previous_step].completed?(model) ? 1 : 0)
[next_step, first_incompleted_step_index].compact.min
end
end
...
# app/components/common/multistep_form_component.rb
...
def step_wrapper(step, index, html_attributes: {}, &content)
tag.div(**html_attributes) do
concat(
radio_button_tag(
...
# If true radio is checked
current_step_index == index,
...
)
)
concat(
check_box_tag(
...
# If true checkbox is checked
current_step_index == index,
...
)
)
concat(capture(&content))
end
end
...
Chúng tôi sẽ thêm một phương thức mới chịu trách nhiệm xóa tất cả các thông báo lỗi liên quan đến các bước mà người dùng không truy cập và một phương thức khác để xóa lỗi current_step. Để đạt được điều này, chúng tôi sẽ thêm thuộc tính thứ ba chịu trách nhiệm theo dõi bước mới nhất trong biểu mẫu mà người dùng đã điền để chúng tôi có thể xác định bước nào chưa được truy cập.
# app/components/common/multistep_form_component.rb
...
def call
...
concat(form.hidden_field(:total_steps, value: steps.count))
concat(form.hidden_field(:latest_step, value: latest_step_index))
concat(form.hidden_field(:current_step, value: current_step_index))
...
end
end
...
def latest_step_index
@latest_step_index =
if model.latest_step.nil?
0
else
[current_step_index, model.latest_step].max
end
end
...
Có hai yếu tố chính để kiểm tra biểu mẫu nhiều bước của chúng tôi: Modelmối quan tâm của chúng tôi và MultistepFormComponent. Các thử nghiệm hệ thống và đơn vị bổ sung cho bộ điều khiển, thành phần dự án và toàn bộ luồng người dùng tạo dự án cũng rất cần thiết. Tuy nhiên, chúng không liên quan đến bất kỳ cấu hình đặc biệt nào như những cấu hình khác.
Để kiểm tra MultistepFormModelmối quan tâm, chúng tôi có thể thử kiểm tra current_stepcác xác nhận thông qua Projectmô hình. Đây không phải là một phương pháp hay vì nó có thể dẫn đến các bài kiểm tra không ổn định: chúng ta không được kiểm tra các mối quan tâm thông qua Modelsviệc có thể không bao gồm hoặc mở rộng chúng trong tương lai.
Thay vào đó, chúng ta có thể định nghĩa một Modellớp phụ trợ với mục đích duy nhất là kiểm tra mối quan tâm, sau đó chạy thử nghiệm với nó. Cách tiếp cận này có hai lợi ích: kiểm tra mối quan tâm mà không cần dựa vào Modelcác lớp từ logic nghiệp vụ của chúng tôi và ghi lại cách mối quan tâm có thể được sử dụng trong Modelcác lớp của chúng tôi.
class MultistepFormModelTest < ActiveSupport::TestCase
class ValidMultistepFormMode
include ActiveModel::Model
include MultistepFormModel
end
def setup
@model = ValidMultistepFormMode.new
end
# Validations
# -----------
test "MultistepFormModel does not apply step validation if total and current attibutes are not specified" do
assert_nil @model.current_step
assert_nil @model.total_steps
assert @model.valid?
end
test "MultistepFormModel is valid only when total and current steps are the same" do
@model.current_step = 1
@model.total_steps = 2
refute @model.valid?
@model.current_step = 3
@model.total_steps = 3
assert @model.valid?
end
test "MultistepFormModel sets custom error attribute and detail when total and current step attributes are invalid" do
@model.current_step = 1
@model.total_steps = 2
refute @model.valid?
assert_equal @model.errors.first.attribute, MultistepFormModel::ERROR_ATTRIBUTE
assert_equal @model.errors.first.detail, MultistepFormModel::ERROR_DETAIL
end
end
class Common::MultistepFormComponentTest < ViewComponent::TestCase
class SampleModel
include ActiveModel::Model
include MultistepFormModel
attr_accessor :attribute_1, :attribute_2, :attribute_3
validates_presence_of :attribute_1
validates_presence_of :attribute_2
validates_presence_of :attribute_3
end
class TestMultistepFormComponent < Common::MultistepFormComponent
class << self
def steps = [FirstTestStepComponent, SecondTestStepComponent, ThirdTestStepComponent]
end
end
class FirstTestStepComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "First Step"
def input_attributes = %i[attribute_1]
end
def call
tag.div(**wrapper_attributes)
end
end
class SecondTestStepComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "Second Step"
def input_attributes = %i[attribute_2]
end
def call
tag.div(**wrapper_attributes)
end
end
class ThirdTestStepComponent < Common::MultistepFormComponent::StepComponent
class << self
def title = "Third Step"
def input_attributes = %i[attribute_3]
end
def call
tag.div(**wrapper_attributes)
end
end
def setup
@component = TestMultistepFormComponent
end
test "Renders multistep form" do
render_inline(@component.new(form_url: "test", back_url: "test", model: SampleModel.new))
assert_selector(".#{@component.component_class}")
end
test "Renders multistep form steps" do
render_inline(@component.new(form_url: "test", back_url: "test", model: SampleModel.new))
assert_selector(".#{FirstTestStepComponent.component_class}", visible: :all)
assert_selector(".#{SecondTestStepComponent.component_class}", visible: :all)
assert_selector(".#{ThirdTestStepComponent.component_class}", visible: :all)
end
test "Selects first step as current step if current_step value is nil" do
model = SampleModel.new
model.valid?
result = @component.new(form_url: "test", back_url: "test", model:).current_step_index
assert_equal 0, result
end
...
end
Multisteps trong Rails được liên kết chặt chẽ với các lớp mô hình và bộ điều khiển. Để giữ cho logic kinh doanh của chúng tôi tách biệt nhất có thể với thành phần này, chúng tôi dựa vào concernsvà các thành phần trừu tượng. Các công cụ này hoạt động như một logic plug & play giúp sử dụng nhiều bước cực kỳ dễ dàng trong bất kỳ phần nào của ứng dụng của chúng tôi.
Bản chất trừu tượng của MultistepFormComponentđơn giản hóa nhiệm vụ triển khai các biểu mẫu với các logic điều hướng và thiết kế khác nhau, bằng cách ủy thác việc triển khai logic này cho các thành phần mở rộng nó.
Cuối cùng, bằng cách viết một số thử nghiệm chung dựa trên thử nghiệm bất khả tri Modelvà Componentcác lớp, chúng tôi đảm bảo hoạt động chính xác của nhiều bước trên tất cả các ứng dụng của chúng tôi và cung cấp một ví dụ chi tiết và được ghi chép đầy đủ về cách các nhà phát triển có thể sử dụng logic nhiều bước trong tương lai.
Mã trong Github
- 140 biểu mẫu nhiều bước của AlbertoHdezCerezo · Yêu cầu kéo #150 · Albert-Markdown-Editor/Albert
- Cách thực hiện: Tạo biểu mẫu có thể truy cập — Dự án A11Y
- Trong các biểu mẫu có nhiều bước, hãy xác định bước hiện tại bằng aria-current
Nếu bạn thấy mình bị lạc ở bất kỳ điểm nào trong bài viết hoặc cố gắng sao chép giải pháp của tôi mà không có kết quả, bạn có thể liên hệ với tôi trong phần bình luận. Tôi sẽ vui lòng cung cấp thêm thông tin và cố gắng làm rõ các câu hỏi mở. Tôi sẽ sớm triển khai các kênh bổ sung để chia sẻ phản hồi và câu hỏi.
Nếu bạn đã đạt đến điểm này, cảm ơn bạn đã dành thời gian và sự kiên nhẫn. Tôi hy vọng bạn tìm thấy hướng dẫn của tôi hữu ích!

![Dù sao thì một danh sách được liên kết là gì? [Phần 1]](https://post.nghiatu.com/assets/images/m/max/724/1*Xokk6XOjWyIGCBujkJsCzQ.jpeg)



































