一个运行了十多年的 jQuery 单体项目,如何在不引入 Vue/React、不改造构建体系、不推翻现有代码的前提下,获得声明式渲染和响应式数据流的开发体验?本文分享我在生产项目中落地的一套 Lit + MobX 分层架构,核心是让新组件体系和存量 jQuery 代码互不影响地长期共存。希望能给同样维护 jQuery 老项目的团队提供一种迁移方向。
一、背景:老 jQuery 项目的真实痛点
我们的项目是一个典型的"古典"Web 应用:后端服务端渲染(PHP),前端在视图里直接引 JS/CSS,用 jQuery 驱动页面。没有模块系统,没有前端构建流水线,页面脚本挂全局变量。
项目里有一套自研的表单控件体系:每个表单项对应一个 JS 类,负责渲染自己的 DOM、校验、取值、和其他表单项联动。这套体系承载了大量业务逻辑,运转多年,不可能推翻。
但它的痛点随着 UI 复杂度上升越来越明显:
js
// 老代码的典型写法:命令式拼 DOM + 手动维护状态
render() {
let html = '<div class="site-list">';
this.siteList.forEach((site) => {
html += '<div class="site-item" data-id="' + site.id + '">'
+ '<input value="' + site.title + '"/>'
+ (site.overLimit ? '<span class="warn">超长</span>' : '')
+ '</div>';
});
this.$container.append(html + '</div>');
}
// 数据变了?自己去找 DOM 一个个改
onSiteChange(selectedSites) {
$('.site-item', this.$container).each(function () {
// 隐藏?删除?重建?每个分支都要手写,漏一个就是 bug
});
}
问题可以归纳为三点:
- 状态和视图是两份账。数据存在 JS 变量里,视图存在 DOM 里,每次数据变化都要手动写"如何更新 DOM"。列表渲染、条件显隐、嵌套结构一多,更新逻辑呈组合爆炸,极易漏更新。
- 代码不可读 。字符串拼 HTML + 事件委托 +
$().val()散落各处,新人接手一个复杂控件要通读几百行才敢改。 - 复用困难。UI 和业务逻辑绞在一起,同样的"输入框 + 单位选择"在不同页面各写一份。
用现代框架的话说:我们缺的是声明式渲染 (UI = f(state))和响应式更新(改数据,视图自动变)。
二、约束:为什么不直接上 Vue / React / 微前端
做技术选型前,先明确约束------这决定了方案的形态:
| 约束 | 含义 |
|---|---|
| 无构建流水线 | 页面是服务端视图 + <script> 直引,不能要求每个页面走 webpack/vite |
| jQuery 插件生态必须保留 | Select2、TinyMCE 等成熟插件深度绑定业务,重写成本不可接受 |
| 存量业务逻辑必须保留 | 校验、取值、联动、权限等逻辑全在老控件类里,是资产不是负债 |
| 全局 CSS | Bootstrap + 十年积累的自定义样式,新组件必须能被现有 CSS 作用到 |
| 渐进式 | 不能停下业务做大重构,新旧模式必须长期共存 |
为什么不是 Vue/React:它们是"框架",倾向于接管整个页面的渲染和生命周期,和 jQuery 插件、服务端视图的共存成本高;且需要为每个页面引入构建环节。
为什么不是微前端 :微前端(qiankun / single-spa / module federation)解决的是多团队独立开发、独立部署 的组织架构问题,粒度是"子应用"。我们是单团队单体应用,痛点在组件级的 DOM 维护成本,上微前端只会白付沙箱、路由分发和加载复杂度的成本。这两个问题经常被混为一谈,后文会专门对比。
为什么是 Lit + MobX:
- Lit 是库不是框架 。它基于 Web Components 标准,产出的是原生 custom element,
document.createElement('ui-input')就能挂进任何 jQuery 页面,宿主零感知。运行时 ~5KB gzip。 - Lit 提供了我们最缺的两样东西:声明式模板 (
html模板字面量 + 局部 diff 更新)和 reactive properties(属性变了自动重渲染),恰好够用,不多不少。 - MobX 补深层响应 。Lit 的 reactive property 只做引用比较,对象内部字段变化不会触发渲染;MobX 的 observable proxy +
@adobe/lit-mobx提供的MobxLitElement正好补上这块,还顺便提供了跨组件状态联动的能力(reaction)。 - 三者用 esbuild 打包成一个全局 JS 文件 ,页面照旧
<script>直引,完全不改变现有的资源加载方式。对老项目来说,"不动构建"本身就是最大的兼容。
三、整体架构:四层分工
text
┌─ 运行时层
│ lit 3.x + mobx 6 + @adobe/lit-mobx,esbuild 打成单文件挂全局
│ 再封装 define / emit 等少量工具函数
│
├─ 组件层(纯渲染"哑组件")
│ reactive properties 进、CustomEvent 出,统一 Light DOM
│ jQuery 插件封装成 Bridge 组件(如 <ui-select> 内部包着 Select2)
│
├─ 控制器层(业务逻辑,接在老体系上)
│ 老控件基类原样保留;新增一个薄的桥接基类负责挂载组件、
│ 托管"UI ↔ 状态"双向同步和生命周期清理
│
└─ 状态层
每个表单宿主一个 mobx observable store + watch 联动 API
核心理念一句话:
Lit 组件是"哑组件",只负责渲染和冒泡事件;所有业务逻辑、数据计算、数据收集都在控制器层完成。控制器直接给组件的 reactive properties 赋值,视图自动更新。
数据流是单向闭环:
text
控制器设置 el.xxx = newValue ──→ Lit 组件自动重渲染
↑ │
store 回灌 / 联动 │ 用户操作 DOM
│ ↓
observable store ←── value-change 事件冒泡,桥接基类回写 store + 触发校验
下面拆开讲每层的关键设计决策。
四、组件层:哑组件原则 + Light DOM
4.1 刻意放弃 Shadow DOM
所有组件统一:
js
createRenderRoot() {
return this; // Light DOM,不用 Shadow DOM
}
这是整套架构里最反直觉但最重要的决定。Shadow DOM 的样式隔离在绿地项目是优点,在老项目里是灾难:
- 现有 CSS(Bootstrap、全局样式)必须能直接作用于组件内部;
- jQuery 选择器必须能找到组件内的真实 DOM(下文 Bridge 组件依赖这一点);
- 第三方 jQuery 插件(Select2、TinyMCE)要操作组件内部的真实节点。
代价是没有样式隔离,靠统一的 class 前缀命名纪律规避冲突。在老项目里,"融入"比"隔离"更重要。
4.2 哑组件:properties 进,events 出,仅此而已
一个最小组件长这样:
js
import { LitElement, html } from "lit";
class UiInput extends LitElement {
static properties = {
value: { type: String },
placeholder: { type: String },
disabled: { type: Boolean },
};
createRenderRoot() { return this; }
onInput(e) {
this.value = e.target.value || "";
// 冒泡通知外层,detail 携带数据
this.dispatchEvent(new CustomEvent("value-change", {
detail: { value: this.value },
bubbles: true,
}));
}
render() {
return html`
<input class="ui-input"
.value=${this.value ?? ""}
placeholder="${this.placeholder || ""}"
?disabled=${this.disabled}
@input=${(e) => this.onInput(e)} />
`;
}
}
customElements.define("ui-input", UiInput);
为了让组件保持"哑",我们定了几条硬性禁令:
| 禁令 | 原因 |
|---|---|
❌ 组件内不写 get xxx() 计算属性 |
计算逻辑内联到 render() 的局部变量。一旦允许 getter,组件会慢慢长出一层"派生状态",读代码时要在 getter 和模板之间来回跳 |
❌ 不对外暴露 getValue() / setXxx() 之类的方法 |
外层直接读写 reactive property(el.value)。一旦开了方法口子,调用方就退化回"拿到实例调方法"的命令式风格,和 jQuery 插件没有区别了 |
❌ 不用 updated() + querySelector 给子组件传配置 |
用模板属性绑定 .config=${this.config},保持声明式。命令式找节点赋值会绕过 Lit 的更新机制 |
| ❌ 组件内禁止引用任何外部业务对象(全局事件总线、页面上下文、共享 store 等) | 组件只认 properties 和 events 两条通道。摸了外部对象,组件就和某个页面耦死,复用性归零 |
这些禁令的本质是守住接口面:组件对外只有"数据下行(property)+ 事件上行(event)"两条通道。守住了,组件就是可预测、可复用、可测试的;守不住,就会慢慢腐化成第二个 jQuery 插件。
4.3 Bridge 组件:让 jQuery 插件活在 Lit 世界里
这是新旧共存最关键的一环。成熟的 jQuery 插件(Select2、TinyMCE......)不重写,而是封装成 Bridge 组件 ,对外呈现和普通 Lit 组件完全一致的接口。以包装 Select2 的 <ui-select> 为例:
js
class UiSelect extends LitElement {
static properties = {
value: {},
options: { type: Array },
disabled: { type: Boolean },
multiple: { type: Boolean },
};
createRenderRoot() { return this; }
render() {
return html`
<select ?multiple=${this.multiple}>
${(this.options || []).map(
(o) => html`<option value="${o.id}">${o.text}</option>`
)}
</select>
`;
}
firstUpdated() {
this.$select = $("select", this); // Light DOM,jQuery 找得到
this.initPlugin();
}
// 方向一:Lit property 变化 → 推给插件
updated(changedProps) {
if (changedProps.has("options")) this.rebuildPlugin();
else if (changedProps.has("value")) this.syncValueToPlugin();
}
// 方向二:插件事件 → 写回 Lit property + 冒泡
initPlugin() {
this.$select.select2({ /* ... */ });
this.$select.on("change.ui-select", () => {
if (this.isSyncing) return; // 防同步回声
this.value = this.$select.val();
this.dispatchEvent(new CustomEvent("value-change", {
detail: { value: this.value }, bubbles: true,
}));
});
}
syncValueToPlugin() {
this.isSyncing = true;
this.$select.val(this.value).trigger("change.select2");
this.isSyncing = false;
}
disconnectedCallback() {
this.$select.off(".ui-select");
this.$select.select2("destroy"); // 生命周期托管,防内存泄漏
super.disconnectedCallback();
}
}
Bridge 组件的三条规则:
- 双向同步必须完整 :property 变化推给插件(
updated()),插件变化写回 property 并派发事件------用isSyncing标志位防止两个方向互相触发的"同步回声"。 - Bridge 是唯一允许 在
updated()里用querySelector/jQuery 的场景,普通组件不开这个口子。 - 依然不暴露方法。外界完全看不出它内部包着一个 jQuery 插件------今天包 Select2,明天换成原生实现,调用方零改动。这就是防腐层(Anti-Corruption Layer)的价值。
老生态不是包袱,包一层就是资产。 我们用同样的模式还封装了富文本编辑器(TinyMCE)等重型插件。
五、控制器层:新基类叠加,老体系一行不动
组件有了,怎么接进现有的业务控件体系?答案是加一个中间桥接基类,而不是改老基类:
text
FieldBase(老基类,一行不动:校验/取值/联动/权限都在这)
└── LitFieldBase ← 新增的桥
├── LitSelectField ← 单/多选场景的再封装
├── ObservableField ← 对象/数组值场景(走 MobX proxy)
└── 各个具体业务控件
↕ this.componentEl
Lit 组件
桥接基类覆写老基类的渲染入口,把"拼 jQuery DOM"换成"挂 custom element",并把重复样板全部托管:
js
class LitFieldBase extends FieldBase {
static litTag = ''; // 子类声明要挂的组件标签名
initView() {
const el = document.createElement(this.constructor.litTag);
this.componentEl = el;
this.$container.append(el);
// UI → store:基类统一托管 同步 + 校验 + 业务钩子
el.addEventListener('value-change', () => {
const v = el.value;
this.syncToStore(v);
this.onValueChange(v); // 子类业务钩子
this.validate(); // 老基类的校验体系原样复用
});
this.onComponentMount(); // 子类初始化钩子
// store → UI:外部改了 store,自动回灌组件(带自触发短路)
this.watchField(this.fieldKey, (val) => {
if (el.value === val) return;
el.value = val;
});
}
}
于是一个新的业务控件只剩下声明和业务:
js
class PolicyField extends LitSelectField {
// litTag = 'ui-select' 已由 LitSelectField 声明
onComponentMount() {
const el = this.componentEl;
el.options = this.buildPolicyOptions(); // 设 property,UI 自动渲染
el.value = this.getInitVal();
}
onValueChange(v) { /* 只写业务联动,同步/校验基类已托管 */ }
getVal() {
return { [this.fieldKey]: this.componentEl.value }; // 直接读 property
}
}
对比老写法:没有 HTML 字符串拼接,没有 $().on('change'),没有手动收集解绑函数------样板代码下沉到基类,业务代码只剩声明式的数据操作。而校验、权限、取值协议等老基类的能力原封不动地继承下来。
这就是渐进式的关键,也是新旧互不影响的答案:
- 新架构是叠加层,不是替代层。老控件继续按老方式跑,新控件走桥接基类,一个页面里两代控件并存,彼此只通过同一套取值/校验协议交互,互相感知不到对方的渲染方式。
- 存量代码零改动、零回归风险;增量代码强制走新路,存量随业务迭代自然被逐步替换(绞杀者模式,strangler fig)。
六、状态层:分层的响应式策略
响应式不是越多越好。我们按数据形态分了三档,避免"为了响应式而响应式":
6.1 基础类型:Lit 自带的 reactive property 就够了
字符串/数字/布尔单值,Lit 的引用比较就能驱动更新,不引入 MobX。上一节桥接基类里的双向同步覆盖这类场景。
6.2 对象/数组值:让 store 和组件持有"同一个 observable proxy"
对象值的难点:el.value.time = '12' 改的是对象内部字段,引用没变,Lit 不会重渲染。传统解法是整体重赋值(el.value = { ...el.value, time }),但字段一多、嵌套一深就又回到手动管理。
我们的解法是让 store 和组件持有同一个 MobX observable proxy:
js
class ObservableField extends LitFieldBase {
onComponentMount() {
const init = this.parseInitVal() || this.getDefaultShape();
const proxy = mobx.observable(init);
this.store.form[this.fieldKey] = proxy; // store 存这个 proxy
this.componentEl.value = proxy; // 组件也持有同一个 proxy
this.onProxyReady(proxy);
}
getVal() {
// 提交给后端前深克隆回普通对象
return { [this.fieldKey]: mobx.toJS(this.componentEl.value) };
}
}
组件侧继承 MobxLitElement(来自 @adobe/lit-mobx),render() 中读到的 observable 字段会自动建立依赖追踪。效果是:控制器写 proxy.unit = 'days',组件自动重渲染;组件内部改字段,控制器的 watch 也能感知------任一侧改字段都双向响应,且零手动同步代码。
6.3 跨组件联动:observable store + watch
同一表单下的多个控件需要联动(经典场景:类目 → 性别 → 主属性三级联动)。老方案是全局事件总线:手动拼事件名字符串、手动收集解绑函数、发布方和订阅方互相知道对方存在。新方案是每个表单宿主一个 observable store:
js
class FormStore {
constructor() {
this.form = mobx.observable({}); // 表单内共享状态
}
// 支持 'a.b.c' 点路径;deep 模式做结构化比较,底层是 mobx.reaction
watchField(key, effect, { deep = false } = {}) {
const read = () => this.form[key];
return mobx.reaction(
deep ? () => mobx.toJS(read()) : read,
effect,
deep ? { equals: mobx.comparer.structural } : {},
);
}
}
联动方只声明依赖,不需要知道数据源是谁:
js
class MainAttributeField extends LitSelectField {
onComponentMount() {
this.watchField('sizeCategory', (v) => { this.categoryId = v; this.reload(); });
this.watchField('gender', (v) => { this.gender = v; this.reload(); });
}
}
如果你写过 Vue,会发现这套 API 的心智模型几乎是无缝的:store.form 就是 reactive() 的数据源,watchField 就是 watch(deep 选项都长得一样),需要派生值时用 mobx.computed 对应 computed。开发者不需要学习一套新范式,把 Vue 的组合式直觉平移过来就能写------这对一个长期写 jQuery 的团队来说,迁移的心智负担非常小。
reaction 的 disposer 由桥接基类自动登记到控件的清理队列,控件卸载即解绑(对应 Vue 组件卸载自动停掉 watcher)。同时我们明确了新旧两种通信机制的边界,同一条数据流只选一种:
| 场景 | 用什么 |
|---|---|
| 状态查询/依赖("X 现在是多少"、"X 变了我要跟着变") | observable store:watchField / 直接读 |
| 命令/广播("X 发生了,执行一次 Y") | 老的事件总线继续用 |
七、工程化收口:纪律靠清单,不靠自觉
分层架构最大的风险是腐化------写着写着组件里长出业务逻辑,控制器里又开始拼 DOM。我们的做法:
- 把规范写成结构化文档,主文档给概览,分文件写各层细则;并且让 AI 编码助手也读同一份规范(我们放在仓库的 skill 目录里),人和 AI 产出的代码遵守同一套约定,code review 时逐条对清单。
- 明确"所有新控件必须走新架构"的方针。老代码不强制回改,但增量必须走新路------存量自然随迭代逐步替换。
- 文件组织固定:通用组件、业务域组件、桥接基类、store 各归其位;通用组件改动后统一重打 bundle。
核心检查清单(节选):
- 组件:Light DOM?无 getter/方法暴露?未引用业务对象?事件走 CustomEvent 冒泡?
- 控制器:继承桥接基类?钩子里只做配置与联动订阅?改状态直接设 property?watch 回调不回写自身 key(防环)?
- Bridge:双向同步完整(含防回声)?只暴露 property + event?生命周期销毁了插件?
八、这和微前端是一回事吗?
不是,而且值得专门澄清,因为两者都常打着"Web Components""渐进式改造老项目"的旗号:
| 本文的 Lit 组件化 | 微前端 | |
|---|---|---|
| 解决的问题 | 单体内部 DOM 维护成本(渲染范式升级) | 多团队独立开发/构建/部署(组织架构) |
| 拆分粒度 | 组件(一个输入框、一个下拉) | 子应用(整个路由模块) |
| 构建部署 | 同仓库、同一次构建、一个 bundle | 各子应用独立构建,运行时动态加载 |
| 隔离 | 刻意不隔离(Light DOM 共享全局 CSS/jQuery) | JS 沙箱、CSS 隔离是核心能力 |
| 技术栈 | 收敛到 Lit + MobX | 允许各子应用异构 |
判断标准很简单:你的痛是"改一个复杂表单心惊胆战",还是"三个团队在一个仓库里互相阻塞发版"? 前者是本文的场景,后者才需要微前端。对单团队的老项目,微前端的沙箱、应用注册、通信协议都是纯开销。
两者也不冲突:Web Components 是标准资产,将来真要拆微前端,这批组件可以被任何宿主直接复用。
九、总结:一种可复制的迁移方向
回头看,这套架构能落地,靠的不是某个库,而是几条可迁移的原则:
- 选"库"不选"框架" :Lit 产出标准 custom element,能以
createElement的粒度嵌进任何老页面,这是渐进式的前提。 - Light DOM 换共存:老项目里放弃样式隔离,换取全局 CSS 和 jQuery 生态的无缝作用,是务实的取舍。
- 哑组件 + 外部控制器:视图层只有 property/event 两条通道,业务逻辑留在原有体系里,新旧叠加而非替换------这是"jQuery 代码和新组件互不影响"的结构保证。
- Bridge 模式盘活存量:成熟 jQuery 插件包成组件就是资产,防腐层保证将来可替换。
- 响应式分档:单值用 Lit 自带 reactive property,深层结构才上 MobX proxy,跨组件联动走 observable store------按需引入,保持最简。
- 规范落成文档和清单,靠机制而不是自觉防腐化。
目前这套架构已在我们最复杂的表单编辑域跑通(含三级联动、对象值组件、富文本/下拉框桥接),新控件的代码量和维护成本相比 jQuery 时代有明显下降。下一步我们计划把它从表单域推广到列表页、弹窗等更多场景------得益于"哑组件 + 薄桥接"的切分,扩展新宿主只需要为它写一个新的桥接基类,组件层和状态层原样复用。
如果你也守着一个"不能重写、还得继续长"的 jQuery 老项目,希望这条路线能给你一些参考。