Tổng quan & mental model
Đọc trang này theo thứ tự. Mỗi mục giả định mục trước. Mục cuối (cách tư duy) là phần đúc rút quan trọng nhất.
Mental model một câu
Người dùng kéo-thả block → một cây JSON do hệ thống sở hữu → một hàm renderTree duy nhất → MJML → HTML email. Cùng một cây, cùng một render path, chạy cho cả preview lẫn gửi thật.
Đây là bất biến trung tâm. Mọi quyết định kiến trúc còn lại trong tài liệu này tồn tại để bảo vệ câu trên khỏi rạn nứt.
Sơ đồ tổng
flowchart TB
UI["ui: Canvas / Tree / Inspector"]
Store["state: EditorStore (dispatch + undo)"]
Doc[("EmailDoc — block-tree JSON (source of truth)")]
RT["render-mjml: renderTree — preview & send"]
AST["MJML JSON-AST"]
HTML["HTML email"]
UI -- "1. dispatch(action)" --> Store
Store -- "2. treeOps (immutable)" --> Doc
Doc -. "3. renderEditor → canvas (React)" .-> UI
Doc == "4. renderTree" ==> RT
RT -- "5. block.toMjml" --> AST
AST -- "6. mjml2html" --> HTML
MT["merge-tags: preview=sample / send=keep {{tag}} raw"] -. "inject" .-> RT
PA["persistence: load / save / migrate"] -. " " .- Doc
Vòng EDIT (bước 1–3): mọi thay đổi qua dispatch → cây mới → canvas vẽ lại. Vòng RENDER (bước 4–6): cùng cây đó → MJML → HTML, dùng chung cho preview lẫn send.
1Bài toán & mental model
V1 đau ở đâu (vì sao cần V2)
Email thông báo loyalty (points earned, tier achieved, birthday reward…) ở V1 là một form cứng: merchant điền vài ô (subject, heading, nội dung HTML thô qua editor, link button…), cộng một map visibleBlocks bật/tắt vài vùng cố định. Hai vấn đề cốt tử:
V2 giải gì
Thay form bằng một visual block editor 3 pane: kéo-thả block (Heading, Text, Image, Button, Loyalty card, Product, Coupon…) vào canvas, chỉnh thuộc tính ở panel bên phải, xem preview giống hệt email thật. Cùng một "tài liệu" đó render ra HTML email an toàn cho mọi client (Gmail/Outlook) qua MJML.
Mental model: 4 danh từ + 1 động từ
Cả feature gói gọn trong một chuỗi:
EmailDoc (cây block JSON) --renderTree--> MJML JSON-AST --mjml2html--> HTML email
EmailDoc— tài liệu email: một cây block, cộng subject/preheader theo locale, theme mặc định. Đây là thứ hệ thống sở hữu và lưu trữ (xem mục 4).interface EmailDoc { schemaVersion: 1 | 2; notificationType?: string; // 'tier-achieved' | 'points-earned' | ... theme?: string; // theme mặc định doc-level subjectByLocale: LocalizedText; // { en: '...', vi: '...' } preheaderByLocale?: LocalizedText; root: BlockNode[]; // cây block }BlockNode— một block trong cây, tự mô tả:type,props(style/cấu hình dùng chung mọi locale),text(nội dung theo locale),children(chỉ container).interface BlockNode { id: string; // per-instance: scoped binding + sẵn sàng collab type: string; // key tra trong BlockRegistry version?: number; // version schema từng block → migrate được props: Record<string, unknown>; text?: LocalizedText; children?: BlockNode[]; }renderTree— động từ trung tâm: đi từ cây → MJML → HTML. Chỉ một hàm này, dùng cho cả preview lẫn gửi (mục 5).- MJML — ngôn ngữ trung gian: viết
mj-section/mj-column/mj-button(tư duy như component),mjml2html()biên dịch ra HTML table-based + inline CSS — thứ duy nhất Outlook/Gmail render ổn. Hệ thống không tự sinh HTML email bằng tay.
Hai vòng lặp, một cây ở giữa
Có đúng hai vòng quay quanh cùng một EmailDoc (xem sơ đồ tổng):
- Vòng EDIT (trình duyệt): UI gọi
dispatch(action)→ cây mới (immutable) → canvas vẽ lại bằngblock.renderEditor(React). Xem mục 8. - Vòng RENDER (preview + send):
renderTree(doc)→block.toMjml→ MJML → HTML.
Mỗi block vì thế có hai mặt: renderEditor (vẽ canvas, React) và toMjml (sinh email, không React). Việc tách hai mặt này là một quyết định lớn — mục 6.
2Kiến trúc phân lớp & deps một chiều
Vì sao không nhét hết vào một file?
Một email editor "làm nhanh" thường là một component React khổng lồ: state, drag-drop, sinh HTML, gọi API lưu — tất cả trộn lẫn. Nó chạy, nhưng không đổi được: muốn thay engine render, hay tách phần gửi mail ra Node, là phải mổ xẻ cả khối. Package email-editor đi hướng ngược lại — chẻ thành các layer trong src/, mỗi layer một trách nhiệm:
Layer (src/…) | Trách nhiệm một câu |
|---|---|
contracts.ts | Bản hợp đồng — mọi type dùng chung. Không có runtime dep. |
model/ | treeOps — phép toán cây immutable (insert/remove/move/update/validate). |
state/ | EditorStore — một cửa dispatch(action) + undo/redo. |
blocks/ | Định nghĩa 17 block: mỗi block = fields + renderEditor + toMjml. |
render-mjml/ | renderTree — cây → MJML → HTML. Một đường cho preview + send. |
merge-tags/ | Resolver biến {{tag}} thành sample (preview) hoặc giữ raw (send). |
inspector/ | Tự sinh panel thuộc tính từ fields của block. |
text/ | Rich-text (Tiptap) cho block Text, theo locale. |
dnd/ | Drag-drop context (dnd-kit) + collision. |
ui/ | Vỏ editor 3-pane (EmailEditor) + canvas, tree, palette, preview. |
persistence/ | load/save + migrate doc-version + migrate V1→V2. |
theme-resolve.ts, active-target.ts | Neutral leaf — tiện ích không phụ thuộc layer nào. |
model ≈ domain core thuần; render-mjml ≈ một adapter (cổng ra MJML); persistence ≈ repository; ui ≈ controller/view.Bất biến: deps một chiều CỐT LÕI
Đây là luật xương sống. Mũi tên phụ thuộc chỉ chảy về phía model/contracts, không bao giờ ngược:
flowchart RL
contracts["contracts.ts (types-only)"]
model["model"]
state["state"]
blocks["blocks"]
render["render-mjml"]
persist["persistence"]
inspector["inspector"]
ui["ui"]
model --> contracts
state --> model
blocks --> model
render --> model
persist --> model
inspector --> contracts
ui --> state
ui --> blocks
state --> contracts
blocks --> contracts
render --> contracts
modelkhông import gì từ các layer khác. Nó là đáy.state/blocks/render-mjml/persistenceđược importmodel+contracts, không bao giờui.ui↛render-mjml: UI không import layer MJML (vì sẽ kéo libmjmlNode vào bundle trình duyệt). UI nhận HTML preview qua một callbackrenderPreviewmà host wire.ui↛blocks(cho nội tại block): UI vẽ block quaBlockRegistry+renderEditor, không import module block cụ thể.
Vì sao một chiều lại quý
- Đổi được mà không vỡ. Vì
modelkhông biết UI tồn tại, đổi UI framework không đụngmodel. Vìmodel/rendertách bạch, swap render engine (MJML → react-email) không đụngmodel. Đây là "hai ranh giới quý nhất". - Test được tầng dưới một mình.
treeOpslà hàm thuần — test không cần render hay DOM. - Suy luận được. Đọc
modellà biết chắc nó không gọi ngược lênui— không có vòng ẩn để truy.
"Sự vắng mặt mới là điều đáng nói nhất"
Giá trị của kiến trúc này nằm ở những import không tồn tại: model không import ui → core sạch; render-mjml không import blocks/*.ts (chỉ import *.tomjml.ts) → bundle send không kéo React (mục 6); contracts.ts không có runtime dep → chạy y hệt trên browser lẫn server.
Neutral leaf — mẹo phá coupling
Có những tiện ích nhiều layer cùng cần: bảng màu theme (theme-resolve.ts) và "ô text đang focus" để chèn {{tag}} (active-target.ts). Đặt chúng làm leaf trung tính — không phụ thuộc layer nào, nên ai cũng import được mà không kéo theo coupling.
// theme-resolve.ts — peer của contracts, 0 layer dep → blocks + inspector + ui đều import được
export function resolveThemeColor(theme: string, role: string): string { … }
3contracts.ts — bản hợp đồng
Định nghĩa interface TRƯỚC, build mọi layer AGAINST nó
src/contracts.ts là file đầu tiên được viết và là file không layer nào được tự sửa — đổi nó = đổi hợp đồng. Mọi layer khác build dựa trên các type trong đây.
interface (hay một .proto, một OpenAPI spec) rồi nhiều phần code song song hai phía. Hợp đồng đứng yên thì hai bên không cần biết chi tiết của nhau.Vì sao contract phải "types-only, zero runtime deps"
contracts.ts chỉ chứa type/interface, không có code chạy. Lý do: cùng một file hợp đồng dùng được ở cả trình duyệt (editor) lẫn Node (gửi mail) vì nó không kéo theo lib nào. Đây là điều kiện để cú tách React-free (mục 6) khả thi.
Các interface chính
Model — BlockNode, EmailDoc (mục 1) và TreeOps:
interface TreeOps {
findById(doc, id): BlockNode | null;
insert(doc, parentId, index, node): EmailDoc; // trả về doc MỚI (immutable)
remove(doc, id): EmailDoc;
move(doc, id, toParentId, toIndex): EmailDoc;
update(doc, id, patch): EmailDoc;
cloneWithNewIds(node): BlockNode; // duplicate/paste, id mới toàn bộ
validate(doc, registry): ContractViolation[];
}
Blocks (registry) — interface đắt giá nhất, BlockDefinition:
interface BlockDefinition {
type: string;
category: 'layout' | 'content' | 'dynamic';
isContainer?: boolean;
allowedChildren?: string[]; // luật lồng nhau
allowedParents?: string[];
defaultProps: Record<string, unknown>;
fields: FieldSchema[]; // → inspector tự sinh panel
renderEditor: (args: BlockRenderArgs) => EditorElement; // canvas (React)
toMjml: (node, ctx: RenderContext) => MjmlNode | MjmlNode[]; // email (KHÔNG React)
}
Một block khai cả 3 mặt: form (fields → inspector tự sinh panel), canvas (renderEditor), email (toMjml). "Thêm block mới" = thêm một file, không sửa engine. Các nhóm còn lại: Render (MjmlNode, RenderContext, RenderResult, EmailRenderer), State (EditorAction, EditorStore), Merge-tags, Persistence.
Phần "Invariants" ở đầu file — đây là LUẬT
* Invariants:
* - One-way deps: model ← state/blocks/render/...; UI never leaks into model/render.
* - model + render are the two most valuable boundaries (swap render-engine / UI
* must not break model).
* - The block-tree JSON is the source of truth we own (anti-lock-in).
* - ONE renderTree drives both preview and send (only difference = merge-tag mode).
Vì sao "khoá" contract lại quan trọng
Các layer được phát triển độc lập, có thể song song. Nếu mỗi layer được sửa type dùng chung thì hai layer sẽ định nghĩa lệch nhau → vỡ tích hợp. Khoá contracts.ts (chỉ một nơi sửa) biến nó thành điểm đồng bộ duy nhất: layer A và layer B chưa từng "nói chuyện", nhưng vì cùng tuân một hợp đồng nên ghép vào là khớp. Đây là lý do contract-first cho phép làm việc song song mà không cần đồng bộ liên tục (quay lại ở mục 10).
Một chi tiết tinh tế: EditorElement = unknown
/** Returned JSX is typed `unknown` so contracts stay React-free. */
export type EditorElement = unknown;
renderEditor trả JSX (React), nhưng contract cố tình type nó là unknown thay vì React.ReactNode — để contracts.ts không phải import React → giữ được lời hứa "dùng được ở server". Một dòng nhỏ, nhưng là cái bản lề giữ cho cú tách React-free (mục 6) thành hiện thực.
4Cây block JSON là source of truth
Lưu CÂY, không lưu HTML
Khi merchant soạn email, thứ được cất vào DB không phải HTML output, mà là cây block JSON (EmailDoc). HTML chỉ được sinh ra về sau, mỗi lần preview hoặc gửi. Bất biến: "The block-tree JSON is the source of truth we own (anti-lock-in)."
Vì sao không lưu HTML
HTML là lossy — nó là kết quả render, đã mất ý định gốc. Từ một mớ table/tr/td đã inline CSS, ta không biết "đây vốn là một Button màu accent theo theme" — và không reverse được về block để sửa. Đổi theme/locale/render engine? Với HTML thì chịu; với cây thì chỉ là render lại. Lưu cây = giữ semantics (ý nghĩa), không chỉ giữ bytes (hình hài).
"Anti-lock-in"
Bài học từ các SDK email nhúng: SDK = "iframe + JSON in/out", nhưng JSON đó proprietary — giữ được bytes nhưng không hiểu semantics, và rời SDK là mất hết. Bằng cách tự sở hữu schema (BlockNode/EmailDoc do chính package định nghĩa), hệ thống giữ semantics. MJML chỉ là một target trung gian swappable.
Block "tự mô tả"
typelà hợp đồng (de)serialize: chỉ cần string'button'là tra được trongBlockRegistryra cảrenderEditor+toMjml. Cây không cần "biết" cách vẽ — nó chỉ mangtype, layer khác tra nghĩa (tách dữ liệu khỏi hành vi).idper-instance: scoped binding + sẵn sàng collab/realtime.versiontừng block: migrate một loại block mà không đụng loại khác.texttheo locale,propsdùng chung: nội dung dịch theo ngôn ngữ, màu/căn lề dùng chung.resolveText(t)chọn text theo locale, fallback vềfallbackLocale.
Hai kỷ luật giữ cho cây "bất tử"
1. Schema ADDITIVE-ONLY. Không bao giờ xoá/đổi-nghĩa field đang có — chỉ thêm field optional. Muốn đổi nghĩa = bump version + một migration thuần (vN)→(vN+1) + một golden fixture.
2. Tolerant loader. Gặp type lạ hay field lạ → placeholder/passthrough, không bao giờ crash hay drop:
// render-mjml/render-tree.ts
const def = registry.get(node.type);
if (!def) return [{tagName: 'mj-raw', content: `<!-- unknown block "${node.type}" -->`}];
Một block type chưa biết → nhả ra một comment HTML vô hại, phần còn lại của email vẫn render. Không "một block lỗi làm hỏng cả email".
Phép toán cây: immutable, thuần
Mọi thay đổi cây đi qua treeOps, và mọi op trả về một EmailDoc mới (dùng immer). move có bảo vệ no-cycle (cấm kéo một node vào trong chính con cháu nó) và no-op detection (kéo về đúng chỗ cũ → trả về cùng reference, không tạo entry undo rác):
if (src.parentId === toParentId && src.index === toIndex) return doc; // không đổi → cùng ref
if (subtree.has(toParentId)) return doc; // sẽ tạo vòng → từ chối
Immutable làm cho undo gần như miễn phí (chỉ cần giữ snapshot cũ) và cho phép so sánh "có đổi không" bằng === (reference equality) — cầu nối sang mục 8.
5Một renderTree: preview = send
Bất biến quan trọng nhất CỐT LÕI
"ONE renderTree drives both preview and send — the only difference is merge-tag mode." Đây là lời hứa cốt lõi để diệt tận gốc class lỗi "preview khác email gửi đi" của V1. Chỉ tồn tại một hàm sinh email — renderTree ở render-mjml/render-tree.ts. Preview gọi nó. Send gọi nó. Cùng một code, cùng một output.
renderTree chính là "một nguồn tính" đó cho HTML email.Preview và send khác nhau ở đâu?
Khác đúng một thứ: cách xử lý merge-tag {{tag}}, gói trong ctx.mode + ctx.resolveMergeTags.
mode: 'preview' | mode: 'send' | |
|---|---|---|
{{points_balance}} thành | "1,250" (giá trị sample) | giữ nguyên {{points_balance}} |
| Ai thay giá trị thật | — (chỉ để xem) | backend Liquid, sau render |
Send giữ {{tag}} raw để backend thay giá trị thật về sau — package không bao giờ chạm dữ liệu khách thật, nó chỉ phát template có {{tag}} (mục 9).
Bên trong renderTree
export async function renderTree(doc, ctx, registry): Promise<RenderResult> {
const boundCtx = {
...ctx,
docTheme: doc.theme ?? ctx.docTheme,
renderChildren: (node) => (node.children ?? []).flatMap(c => renderNode(c, boundCtx, registry)),
};
const tree = buildMjmlTree(doc, boundCtx, registry); // cây block → MJML JSON-AST
const {html, errors} = await mjml2html(toMjmlJson(tree), {validationLevel: 'strict'});
// ... gom warnings + đo size (cảnh báo >90KB Gmail clip)
return {html, sizeBytes, warnings};
}
Ba chặng: cây block → MJML JSON-AST (gọi block.toMjml từng node) → mjml2html → HTML + cảnh báo. async vì mjml2html v5 trả Promise (không có đường sync) — chi tiết nhỏ nhưng lan ra contract: EmailRenderer.renderTree cũng async.
"Isomorphic": cùng code, hai môi trường
renderTree static-import lib mjml (bản Node). Nhưng preview chạy trong trình duyệt. Lời giải: build trình duyệt alias mjml → mjml-browser (cùng version, cùng API async). Cùng một file renderTree, ở browser thì cái mjml nó import là mjml-browser, ở Node thì là mjml thật. Một nguồn logic, hai binding môi trường.
UI lấy preview qua callback, KHÔNG import render layer
Một hệ quả của deps một chiều (mục 2): UI không import render-mjml. Thay vào đó UI nhận một callback renderPreview do host wire:
const renderPreview = (doc, {locale, device}) =>
renderTree(doc, { locale, fallbackLocale:'en', mode:'preview',
resolveText, renderChildren, docTheme: doc.theme,
resolveMergeTags: raw => resolver.resolve(raw, 'preview'),
}).then(r => r.html);
<EmailEditor ... renderPreview={renderPreview} />
UI biết "có một hàm cho tôi HTML" mà không biết hàm đó là MJML — dependency injection thuần. Đổi engine không đụng UI.
6Tách React-free cho đường send
Vấn đề: gửi mail chạy ở Node, nhưng block lại "dính" React
Mỗi block khai cả hai mặt (mục 3): renderEditor (vẽ canvas, dùng React, block Text còn kéo Tiptap/ProseMirror) và toMjml (sinh email, không cần React). Đường gửi mail chạy trong Node, chỉ cần toMjml. Nếu nó import "block" ngây thơ — import {buttonBlock} from './button' — thì button.ts lại import React, block Text kéo cả ProseMirror. Kết quả: bundle gửi mail ở Node phình ra ôm cả React + Tiptap — thứ nó không bao giờ dùng.
Lời giải: tách toMjml ra một file riêng KHÔNG React
Mỗi block chẻ làm hai file: button.ts (BlockDefinition đầy đủ với renderEditor React) và button.tomjml.ts (chỉ toMjml + style thuần, không import React).
// button.tomjml.ts — React-free. Chỉ type + helper thuần.
import type {BlockNode, MjmlNode, RenderContext} from '../contracts';
export function buttonToMjml(node: BlockNode, ctx: RenderContext): MjmlNode {
const t = blockTheme(node.props, ctx.docTheme);
const v = buttonVisual(node.props, t); // style dùng chung
return { tagName: 'mj-button', attributes: attrs({ href: ctx.resolveMergeTags(...), ... }),
content: escapeHtml(ctx.resolveMergeTags(ctx.resolveText(node.text))) };
}
// button.ts — React Ở ĐÂY, và import toMjml từ file kia.
import React from 'react';
import {buttonToMjml, buttonVisual} from './button.tomjml';
export const buttonBlock: BlockDefinition = {
type: 'button', fields: [...],
renderEditor({node, ...}) { return React.createElement('div', ...); },
toMjml: buttonToMjml, // ← tái dùng cùng một hàm
};
buttonVisual (tính màu/viền/width theo variant) sống ở file .tomjml và được cả hai mặt dùng chung → canvas và email không thể lệch về style. Một nguồn style, hai nơi vẽ — tinh thần "một nguồn sự thật" ở mức vi mô.
Hai registry: editor vs server
flowchart TB
subgraph editor [Browser - canvas]
A["createDefaultRegistry"] --> B["button.ts + 16 blocks (pulls React / Tiptap)"]
end
subgraph server [Node - send mail]
C["createServerRegistry"] --> D["button.tomjml + 16 *.tomjml (react = 0 in bundle)"]
end
editor == "mirror each other, MJML byte-identical" ==> server
createDefaultRegistry()— gom*.tsđầy đủ → córenderEditor. Dùng cho canvas.createServerRegistry()— chỉ import*.tomjml→ chỉ cótoMjml. Dùng cho send.
Server registry mirror đúng map type→toMjml của editor registry (kể cả wrap hide-on-mobile) → preview và send cho ra MJML byte-identical. Tách runtime không làm hai bên lệch — vẫn giữ invariant "preview = send" (mục 5).
Cổng vào riêng cho server
// render-mjml/server.ts
export function createServerRenderer(): EmailRenderer {
return createEmailRenderer(createServerRegistry()); // React-free registry + renderTree
}
Cú tách được kiểm chứng bằng công cụ: esbuild metafile chứng minh 0 react / react-dom / @tiptap / prosemirror / liquidjs trong graph của entry server — không phải tin lời. Server entry còn cố tình không re-export merge-tag resolver (bản preview kéo liquidjs) để giữ liquidjs khỏi bundle Node send.
Bài học tư duy: tách concern theo RUNTIME
Người ta hay tách concern theo chức năng (UI / logic / data). Ở đây có thêm một trục: theo môi trường chạy. "Cái gì chạy ở trình duyệt" vs "cái gì chạy ở Node" là một đường cắt — và nó quyết định dependency nào được phép có mặt mỗi phía. Khi một thứ "logic-thuần lẽ ra nhẹ" lại kéo theo lib nặng của môi trường khác, đó là dấu hiệu cần một đường cắt như *.tomjml.
7Tích hợp host & flag-gate an toàn
Bối cảnh: package host-agnostic
Package email-editor không biết gì về hệ thống host — nó nhận EmailDoc, nhả HTML, để lại các chỗ cắm (PersistenceAdapter, renderPreview, merge-tag resolver) cho host wire. Mục này tập trung vào điểm tích hợp nguy hiểm nhất: đường gửi mail thật.
Vấn đề rollout: hàng nghìn shop đang chạy V1
Không thể "bật V2 cho tất cả" trong một lần deploy. Email là thứ khách hàng nhận thật — một bug render là một email hỏng gửi đi, không rút lại được. Cần: (1) bật V2 cho một nhóm shop kiểm soát được, mở dần; (2) shop chưa bật phải nhận email byte-identical như trước; (3) V2 render lỗi → không được mất mail, phải tự lui về V1.
Lời giải: HARD GATE bằng flag
// trong handler gửi mail của host (getEmailData)
let useV2 = shop.useEmailEditorV2 && sendMailData.emailDocV2?.root?.length;
if (useV2) {
try {
const locale = shop.primaryLanguage || 'en';
const ctx = {
locale, fallbackLocale: 'en',
resolveText: tt => tt?.[locale] ?? tt?.en ?? Object.values(tt||{})[0] ?? '',
resolveMergeTags: raw => raw, // identity → giữ {{tag}} raw cho Liquid
mode: 'send',
docTheme: sendMailData.emailDocV2.theme,
showBranding: !shop.removeBrandingCheckbox,
};
const {html} = await createServerRenderer().renderTree(sendMailData.emailDocV2, ctx);
content = await prepareBodyHtml({templateContent: html, variable}); // Liquid pass (giống V1)
subject = await prepareContentHtml(sendMailData.emailDocV2.subjectByLocale?.[locale] ?? ..., variable);
} catch (e) {
console.error('[EmailV2] render failed, falling back to V1', e);
useV2 = false; // ← FAIL-SAFE
}
}
if (!useV2) {
/* ... nhánh V1 byte-identical (visual template / customHtml) ... */
}
Ba điều kiện, ba quyết định
1. Gate "cứng": cần CẢ flag CẢ doc. Phải đồng thời: shop bật flag và thực sự có một emailDocV2 có block. Thiếu một → rơi xuống V1. Đây là feature flag kinh điển: hành vi mới nằm sau công tắc per-shop, mở dần.
2. Byte-identical: shop chưa bật đi nhánh cũ nguyên vẹn. Nhánh if (!useV2) chính là code V1 cũ, không đụng tới — khách chưa bật nhận email y hệt từng byte.
3. Fail-safe: lỗi V2 → tự lui V1, không mất mail. Cả khối V2 bọc trong try/catch. Render lỗi → log → useV2 = false → rơi xuống nhánh V1. Một email luôn được gửi — tệ nhất là gửi bản V1 thay vì V2.
flowchart TB
S["Email to send"] --> G{"useEmailEditorV2 AND emailDocV2?"}
G -- "no" --> V1["V1 path (byte-identical)"]
G -- "yes" --> T["renderTree mode=send → prepareBodyHtml"]
T -- "error (try/catch)" --> V1
T -- "ok" --> OK["V2 email"]
Vì sao render V2 vẫn đi qua Liquid như V1
Sau khi renderTree trả HTML, host vẫn chạy prepareBodyHtml({templateContent: html, variable}) — đúng cái Liquid pass V1 dùng, với đúng variable map V1 dùng. Vì renderTree ở mode send giữ {{tag}} raw (mục 5), backend thay giá trị thật ở đúng một chỗ, giống hệt V1. Kết quả: {{tag}} + {% if %} resolve y như V1 — không có đường xử lý biến nào mới phát sinh.
Bài học: ship feature lớn = mặc định OFF + nhánh cũ nguyên + fail-safe
- Mặc định an toàn — flag OFF, không ai bị ảnh hưởng cho tới khi chủ động bật.
- Đường cũ bất khả xâm phạm — không refactor đường cũ khi thêm đường mới; gate chọn đường.
- Hỏng thì lui, đừng sập — code mới bọc try/catch, fallback về cái đã tin được.
Khung này áp được cho bất kỳ migration backend nào (đổi thuật toán, đổi data store): chạy song song, gate per-entity, so sánh, fail-safe.
8State, undo & cây immutable
MỌI thay đổi đi qua một cửa dispatch(action)
Trong editor có hàng chục thao tác đổi dữ liệu. Nếu mỗi component tự sửa doc theo cách riêng thì không ai truy được "ai đổi gì, lúc nào" — và undo thành ác mộng. EditorStore áp một luật: "EVERY change goes through one chokepoint, dispatch(action). No component touches the model/DOM directly." Component không sửa cây; nó mô tả ý định bằng một EditorAction rồi dispatch:
type EditorAction =
| {kind:'insert'; parentId; index; node}
| {kind:'remove'; id}
| {kind:'move'; id; toParentId; toIndex}
| {kind:'updateProps'; id; props}
| {kind:'updateText'; id; locale; value}
| {kind:'duplicate'; id}
| {kind:'setDocTheme'; theme}
| {kind:'select'; id} | {kind:'setLocale'; locale} | ...;
Luồng một lần dispatch
flowchart TB
C["Component"] -- "dispatch(action)" --> D{"action type?"}
D -- "view-state: select/setLocale" --> V["change selectedId/locale — NO undo entry"]
D -- "doc-mutation" --> R["reduceDoc → treeOps"]
R -- "doc === before (no-op)" --> SKIP["skip, no history entry"]
R -- "new doc" --> H["push old snapshot to past, clear future"]
H --> N["present = new state"]
reduceDoc ánh xạ mỗi action.kind về một lời gọi treeOps, và tận dụng immutable để phát hiện no-op:
// updateProps: nếu giá trị y hệt cũ → trả về CÙNG doc ref → caller coi là no-op, không ghi history
const changed = Object.keys(action.props).some(k => node.props[k] !== action.props[k]);
if (!changed) return {doc, selectedId: prev.selectedId};
Undo là HỆ QUẢ của command model, không phải bolt-on
Vì (a) mọi thay đổi qua một cửa, và (b) cây immutable (mỗi op sinh EmailDoc mới — mục 4), nên giữ lịch sử undo chỉ là giữ các snapshot cũ:
interface HistoryState {
present: EditorState; // {doc, selectedId, locale}
past: EditorState[]; // stack snapshot cũ
future: EditorState[]; // stack cho redo
}
function undo() {
const prev = past[past.length-1];
future.push(present); present = prev; past.pop(); // present mang old selectedId → khôi phục cả selection
}
Mỗi entry là full snapshot EditorState — nên undo khôi phục cả selection + locale, không chỉ cây. Vì immutable + structural sharing (immer tái dùng các nhánh không đổi), giữ 200 snapshot không tốn như "copy sâu 200 lần".
Coalescing: gõ liên tục = MỘT bước undo
Nếu mỗi keystroke là một entry undo thì gõ một câu rồi Ctrl-Z sẽ xoá từng-chữ-một. Store gộp các thao tác cùng loại, cùng target, trong cửa sổ ~300ms thành một bước undo:
const canCoalesce =
COALESCIBLE.has(action.kind) && // updateText/updateProps/setDocTheme/setSubject/setPreheader
lc?.kind === action.kind &&
lc.targetId === targetId &&
t - lc.time <= coalesceMs; // 300ms
Coalescing gãy khi: đổi selection, đổi loại thao tác, hoặc quá cửa sổ thời gian — convention của các editor quen thuộc ("một ý chỉnh sửa = một undo").
Vì sao tách state khỏi model
model (treeOps) là phép toán cây thuần — không biết undo, không biết selection. state (EditorStore) điều phối lịch sử + selection + coalescing, và uỷ thác mọi thay đổi cây xuống treeOps. Tách như vậy để treeOps test được một mình (hàm thuần), còn logic undo/coalesce không lẫn vào phép toán cây. Store dùng zustand/vanilla + immer — chọn "compose primitive" thay vì tự viết.
9Merge-tags, Liquid & migrate V1→V2
Ba mảnh "ráp với hệ thống cũ": dữ liệu động, backend Liquid sẵn có, và hàng nghìn email V1 đang tồn tại.
A. Merge-tags: {{tag}} preview-vs-send
Email loyalty đầy biến động: {{points_balance}}, {{customer.first_name}}, {{reward_url}}… Một MergeTag:
interface MergeTag { tag: string; label: string; sample: string; }
// vd: { tag:'{{points_balance}}', label:'Points balance', sample:'1,250' }
Package ship vocabulary (~48–60 tag) phản chiếu nguồn dữ liệu email của host, kèm sample để preview + test. Resolver có hai mode:
resolve(raw, mode) {
if (mode === 'send') return raw; // INVARIANT: send = identity (giữ {{tag}} raw)
try { return engine.parseAndRenderSync(raw, sampleVars); } // preview: chạy Liquid với sample
catch { return raw.replace(mergeTagMatcher(), (full,name)=> sampleByName.get(name) ?? full); }
}
- send = identity (
raw === resolve(raw,'send')): tag đi xuyênrenderTreecòn nguyên, để backend thay ở bước sau (đây là cái neo của đường send — mục 5). - preview chạy
liquidjsvới dữ liệu sample → canvas hiển thị được cả{% if %}/{% for %}/filter chứ không chỉ{{tag}}đơn. Liquid hỏng → bắt lỗi → lui về thay{{tag}}đơn giản bằng regex. Không bao giờ throw (canvas render đồng bộ).
Vì sao preview kéo liquidjs mà send thì không: server entry cố tình không export resolver, để liquidjs không lọt vào bundle Node send (mục 6).
B. Pipeline Liquid: ai resolve {{tag}}, lúc nào
Mấu chốt: render và resolve-biến là hai bước tách rời, do hai lớp khác nhau làm.
flowchart TB
Doc[("EmailDoc")] -- "renderTree mode=send (resolveMergeTags = identity)" --> HTML["HTML keeps {{tag}} raw"]
HTML -- "prepareBodyHtml (Liquid backend, same variable map)" --> FINAL["final HTML + real values"]
FINAL --> Mailgun["send"]
renderTree(package) sinh template HTML còn{{tag}}— không thấy dữ liệu khách.prepareBodyHtml(host) chạy Liquid thay giá trị thật, dùng đúngvariablemap V1 dùng →{{tag}}và{% if %}resolve y hệt V1 (mục 7).
{{tag}} ~ placeholder) cố định, giá trị bơm vào sau. Lợi ích: package không bao giờ chạm PII khách; backend là chỗ duy nhất biết dữ liệu thật.Escape boundary (một gotcha đáng nhớ)
Thứ tự escape là resolveMergeTags → escapeHtml, làm ở block layer: với send, resolve giữ {{tag}} → escapeHtml là no-op trên dấu ngoặc nhọn (tag chỉ chứa ký tự word + dấu chấm) → tag tới backend còn nguyên. Giá trị backend bơm vào {{tag}} ở send-time thì backend phải escape (auto-escape hoặc filter escape) — package không thể, vì giá trị được chèn sau khi template đã render. Ngoài ra render layer còn escape attribute value để một dấu nháy lạc trong font-family không đóng sớm style="..." làm mất các prop sau. Content thì không re-escape — nó là HTML thân bài cố ý (vd strong của rich-text).
C. Migrate V1 → V2: tolerant loader cho dữ liệu cũ
Hàng nghìn email V1 đang ở dạng blob HTML (notificationContent) + map visibleBlocks (boolean show/hide vùng cố định) — không phải cây block. migrateFromV1 suy ra cây từ các vùng cố định:
banner(logo) → header-image(hero) → [heading, content, button] → footer
- Mỗi vùng
visibleBlocks.<x> === false→ không tạo block đó (mặc định ON). emailMode === 'html'→ một blockhtmlcô lập giữcustomHtmlnguyên văn, không reverse-parse.translations[lang](6 field phẳng) → bơm vàoblock.text[lang](per-locale).- Block nội dung (heading/button/footer) khai
allowedParents:['column']→ migration bọc chúng vàocolumns(1) > column; riêngheader-image/bannerở mức section.
Và nó tolerant: input không phải object → coi như V1 rỗng, không crash:
const src = isPlainObject(v1raw) ? v1raw : {};
Vì sao tolerant quan trọng ở migrate: nó chạy trên dữ liệu thật, cũ, hỗn tạp không kiểm soát được hết hình thù. Một doc dị dạng không được làm sập cả batch. Kết hợp với schema additive-only (mục 4), đây là điều khiến rollout/rollback an toàn: code mới đọc được doc cũ, và doc V1 luôn còn đó để lui về. Lưu ý kiến trúc: migrateFromV1 trả về một EmailDoc sạch (chỉ cây V2). Việc giữ field V1 trên doc DB để rollback là việc của lớp wiring host phía trên, không nhồi field V1 vào EmailDoc. Mỗi lớp giữ đúng phần của mình.
10Cách tư duy khi build feature cỡ này
Phần đúc rút. Chín mục trước mô tả cái đã build; mục này rút ra cách nghĩ đã dựng nên nó — một khung áp dụng được cho bất kỳ feature lớn nào.
Trình tự build theo phase
Feature được dựng theo một trình tự rất sạch — không theo "cái gì user thấy trước", mà theo dependency + rủi ro:
Phase 0 Hợp đồng: contracts.ts (khoá) trước tiên
Phase 1 Lõi rủi-ro-cao, mỗi cái một spike có test:
TreeOps (no-cycle) · EditorStore (undo) · renderTree (MJML) · Persistence
Phase 2 Blocks + registry · rich-text · theme · canvas UI (dnd/overlay/resize/inspector/preview)
Phase 3 Tích hợp host: route + persistence + send-gate (flag, fail-safe)
Bốn bài học gói trong trình tự đó.
1. Bắt đầu từ HỢP ĐỒNG, không từ tính năng
Nước đi đầu tiên không phải "một cái nút kéo được" — mà là contracts.ts được khoá. Hợp đồng là giao diện giữa các phần chưa tồn tại; khoá nó trước = mọi layer sau có một mục tiêu đứng yên để build against. Nó ép quyết định những thứ khó trước khi viết logic (cây trông thế nào? render nhận/nhả gì? action có những loại nào?) — trả lời ở mức type rẻ hơn nhiều so với phát hiện sai sau khi đã code 3 layer.
2. Build từ TRONG ra NGOÀI, lõi-rủi-ro-cao trước
Thứ tự: model (đáy) → state/render → blocks → ui (đỉnh) → host. Và Phase 1 cố tình spike các lõi khó nhất trước: cây immutable có chống được cycle không? render MJML có giữ {{tag}} qua được không? undo có đúng không? Mỗi spike có test xanh rồi mới xây tiếp lên.
renderTree hoá ra không khả thi, ta muốn biết sớm, không phải sau khi đã dựng cả UI lên trên nó.3. Bất biến (invariant) = LUẬT, không phải gợi ý
Cả package được giữ thẳng hàng bởi một nhúm bất biến viết ngay đầu contracts.ts: deps một chiều (mục 2), một renderTree (mục 5), cây JSON là source-of-truth (mục 4), schema additive-only. Điểm mấu chốt: bất biến được viết ra thành chữ và kiểm chứng được — vài cái còn được test/đo tự động ("send bundle react=0" chứng minh bằng esbuild metafile; "send là identity" có test round-trip; cross-check test chống hai registry trôi khỏi nhau).
4. Mỗi layer phát triển độc lập, ghép bằng hợp đồng
Ba điều trên khiến các layer có thể được phát triển độc lập (kể cả song song) rồi ghép lại trơn tru:
- Hợp đồng khoá → các phần không cần "nói chuyện" liên tục; chúng build against cùng type.
- Deps một chiều → layer dưới không bị layer trên kéo theo.
- Invariant là luật → người review có tiêu chuẩn khách quan để bắt lỗi, không cãi cảm tính.
Và có review gate trước khi tích hợp: một layer báo xong → review theo contract + invariant → chạy typecheck/test/build (+ visual/e2e khi có UI) → mới ghép.
5. Tách concern theo cả RUNTIME, không chỉ theo "loại việc"
Cú tách *.tomjml (mục 6) và flag-gate fail-safe (mục 7) đến từ một câu hỏi người mới hay bỏ qua: "code này chạy ở đâu, và môi trường đó được phép có dependency gì?" Trình duyệt được có React; bundle gửi mail ở Node thì không. Đường mới được bật cho ai, và hỏng thì lui về đâu? Những câu đó định hình các đường cắt quan trọng nhất.
Tóm gọn — khung 6 câu hỏi cho feature lớn kế tiếp
- Hợp đồng: các type/interface dùng chung là gì? Khoá được chưa?
- Rủi ro: phần nào sai-là-vỡ-kiến-trúc? Spike nó trước, có test.
- Hướng phụ thuộc: deps có một chiều về core không? Có import nào không nên tồn tại?
- Bất biến: 4–6 luật bất khả xâm phạm là gì? Enforce bằng gì?
- Runtime: mỗi phần chạy ở đâu, được phép kéo dependency gì?
- Rollout: bật cho ai (flag), khách cũ có byte-identical không, hỏng thì lui về đâu (fail-safe)?
Trả lời được sáu câu này trước khi code giúp đội ngũ kiểm soát kiến trúc thay vì để nó trôi — khác biệt giữa việc nhận một hộp đen và việc thiết kế có chủ đích.