@lark.js/mvc 0.0.15 → 0.0.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +324 -215
- package/dist/chunk-OGPBFCIK.js +105 -0
- package/dist/client.d.cts +63 -0
- package/dist/client.d.ts +10 -34
- package/dist/compiler.cjs +15534 -15854
- package/dist/compiler.d.cts +1 -1
- package/dist/compiler.d.ts +1 -1
- package/dist/compiler.js +15519 -15851
- package/dist/devtool.cjs +2617 -4152
- package/dist/devtool.d.cts +1 -2
- package/dist/devtool.d.ts +1 -2
- package/dist/devtool.js +2543 -4159
- package/dist/index.cjs +4128 -6008
- package/dist/index.d.cts +571 -1291
- package/dist/index.d.ts +571 -1291
- package/dist/index.js +4058 -5976
- package/dist/rspack.cjs +15648 -15935
- package/dist/rspack.d.cts +8 -45
- package/dist/rspack.d.ts +8 -45
- package/dist/rspack.js +15641 -15934
- package/dist/runtime.cjs +79 -79
- package/dist/runtime.js +19 -85
- package/dist/vite.cjs +15841 -15957
- package/dist/vite.d.cts +6 -7
- package/dist/vite.d.ts +6 -7
- package/dist/vite.js +15834 -15955
- package/dist/webpack.cjs +15648 -15985
- package/dist/webpack.d.cts +5 -33
- package/dist/webpack.d.ts +5 -33
- package/dist/webpack.js +15641 -15984
- package/package.json +3 -4
- package/dist/apply-style.d.ts +0 -9
- package/dist/cache.d.ts +0 -69
- package/dist/common.d.ts +0 -64
- package/dist/compiler/compile-template.d.ts +0 -16
- package/dist/compiler/compile-to-vdom-function.d.ts +0 -13
- package/dist/compiler/extract-global-vars.d.ts +0 -17
- package/dist/compiler/template-syntax.d.ts +0 -61
- package/dist/cross-site.d.ts +0 -29
- package/dist/dom.d.ts +0 -45
- package/dist/event-delegator.d.ts +0 -28
- package/dist/event-emitter.d.ts +0 -38
- package/dist/frame.d.ts +0 -143
- package/dist/framework.d.ts +0 -9
- package/dist/hmr.d.ts +0 -53
- package/dist/index.amd.js +0 -6285
- package/dist/index.umd.js +0 -6272
- package/dist/mark.d.ts +0 -26
- package/dist/module-loader.d.ts +0 -20
- package/dist/router.d.ts +0 -14
- package/dist/runtime.amd.js +0 -94
- package/dist/runtime.umd.js +0 -98
- package/dist/service.d.ts +0 -173
- package/dist/state.d.ts +0 -8
- package/dist/store.d.ts +0 -60
- package/dist/types.d.ts +0 -1259
- package/dist/updater.d.ts +0 -90
- package/dist/url-state.d.ts +0 -32
- package/dist/utils.d.ts +0 -90
- package/dist/vdom.d.ts +0 -45
- package/dist/view-registry.d.ts +0 -20
- package/dist/view.d.ts +0 -214
package/README.md
CHANGED
|
@@ -5,10 +5,10 @@ A TypeScript MVC framework designed for back-office single-page applications and
|
|
|
5
5
|
`@lark.js/mvc` explicitly separates Model, View, and Controller layers: state management aligns with the zustand design (`create` / `getState` / `setState` / `subscribe`), routing supports both history and hash modes, templates compile to functions and render via real DOM diff, and micro-frontends are natively supported through the built-in CrossSite bridge and first-class Webpack Module Federation integration. The framework has zero runtime third-party dependencies; the template runtime helper module weighs approximately 1 KB (`dist/runtime.js` measured at 964 bytes).
|
|
6
6
|
|
|
7
7
|
- Package: `@lark.js/mvc`
|
|
8
|
-
- Version: see `package.json` (currently 0.0.
|
|
9
|
-
- Entry points: `./` main entry, `./vite`
|
|
8
|
+
- Version: see `package.json` (currently 0.0.15)
|
|
9
|
+
- Entry points: `./` main entry, `./vite` Vite plugin, `./webpack` Webpack loader, `./rspack` Rspack loader, `./runtime` template runtime, `./compiler` compile-time API
|
|
10
10
|
- Build: tsup, producing ESM + CJS + `.d.ts` in `dist/`
|
|
11
|
-
- Tests: vitest,
|
|
11
|
+
- Tests: vitest, 24 test files, 500+ test cases covering core modules and HMR
|
|
12
12
|
|
|
13
13
|
## Table of Contents
|
|
14
14
|
|
|
@@ -22,6 +22,7 @@ A TypeScript MVC framework designed for back-office single-page applications and
|
|
|
22
22
|
- Template Syntax
|
|
23
23
|
- Frame and the View Tree
|
|
24
24
|
- Module Federation Micro-Frontend
|
|
25
|
+
- Hot Module Replacement (HMR)
|
|
25
26
|
- Debugging and Devtool Bridge
|
|
26
27
|
- Public API Reference
|
|
27
28
|
- Common Pitfalls
|
|
@@ -33,7 +34,7 @@ A TypeScript MVC framework designed for back-office single-page applications and
|
|
|
33
34
|
|
|
34
35
|
Lark's trade-offs center around one category of requirements: back-office business systems with deep route hierarchies, heavy forms and API calls, and the need to compose several independent applications into a single shell. The framework makes explicit choices along the following dimensions.
|
|
35
36
|
|
|
36
|
-
First, explicit layering. The Model layer provides `State` / `
|
|
37
|
+
First, explicit layering. The Model layer provides `State` / `createStore()` (zustand-style) / `createService()`, the View layer provides `defineView()` / `Updater`, and the Controller layer provides `Router` (history/hash dual mode) / `Frame`. These communicate through explicit interfaces and events, allowing new team members to locate code by layer.
|
|
37
38
|
|
|
38
39
|
Second, native micro-frontend support. The `CrossSite` bridge view + `FrameworkConfig.require` + Module Federation form a complete pipeline. Write `v-lark="remote-app/views/home"` in a template and the remote view loads and mounts automatically, eliminating the need for secondary containers like single-spa or qiankun.
|
|
39
40
|
|
|
@@ -41,7 +42,7 @@ Third, zero runtime dependencies. `@babel/parser` / `@babel/types` are used only
|
|
|
41
42
|
|
|
42
43
|
Fourth, real DOM diff. Templates compile to functions that produce HTML strings, which are parsed into temporary DOM via `document.implementation.createHTMLDocument` and then diffed against the live DOM using keyed comparison. The advantage is that context-sensitive tags like `<table>` / `<select>` / `<svg>` are handled by the native parser. The trade-off is that large templates incur parse overhead, and SSR is not supported.
|
|
43
44
|
|
|
44
|
-
Fifth, debug-friendly. `installFrameDevtoolBridge` exposes the Frame tree to Devtool via `postMessage
|
|
45
|
+
Fifth, debug-friendly. `installFrameDevtoolBridge` exposes the Frame tree to Devtool via `postMessage`, enabling real-time inspection of the view hierarchy.
|
|
45
46
|
|
|
46
47
|
Not suitable for: projects requiring SSR/streaming rendering, cross-platform needs like React Native, or projects needing off-the-shelf Chrome extension panels. For those, consider the React or Vue ecosystems.
|
|
47
48
|
|
|
@@ -91,7 +92,20 @@ export default {
|
|
|
91
92
|
|
|
92
93
|
Two important notes: the `loader` field must be imported as a value (`loader: larkMvcLoader`), not as a string name; you must use `exclude: /index\.html$/` to let `HtmlWebpackPlugin` handle the entry HTML, otherwise it will be compiled as a template.
|
|
93
94
|
|
|
94
|
-
|
|
95
|
+
### Rspack
|
|
96
|
+
|
|
97
|
+
```js
|
|
98
|
+
// rspack.config.mjs
|
|
99
|
+
import { LarkMvcPlugin } from "@lark.js/mvc/rspack";
|
|
100
|
+
|
|
101
|
+
export default {
|
|
102
|
+
plugins: [new LarkMvcPlugin()],
|
|
103
|
+
};
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The Rspack integration mirrors the Webpack plugin API. The loader returns a `Promise<string>` directly (Rspack async loader convention) rather than calling `this.callback()`.
|
|
107
|
+
|
|
108
|
+
All three integrations share the same compilation pipeline: `extractGlobalVars` extracts external variables referenced in the template, and `compileTemplate` produces an ES module that imports helper functions from `@lark.js/mvc/runtime`. HMR snippets are auto-injected by each plugin at compile time (see the HMR section below).
|
|
95
109
|
|
|
96
110
|
## Five-Minute Quick Start
|
|
97
111
|
|
|
@@ -119,18 +133,18 @@ Both integrations share the same compilation pipeline: `extractGlobalVars` extra
|
|
|
119
133
|
// src/view.ts
|
|
120
134
|
import { defineView, Router } from "@lark.js/mvc";
|
|
121
135
|
|
|
122
|
-
export
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
}
|
|
136
|
+
export function withBaseView<P extends Record<string, unknown>>(
|
|
137
|
+
setup: (ctx: import("@lark.js/mvc").ViewCtx, params?: unknown) => P,
|
|
138
|
+
) {
|
|
139
|
+
return defineView((ctx, params) => {
|
|
140
|
+
ctx.updater.set({ appName: "My App" });
|
|
141
|
+
ctx.on("destroy", () => console.log(`view destroyed: ${ctx.id}`));
|
|
142
|
+
return setup(ctx, params);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
131
145
|
```
|
|
132
146
|
|
|
133
|
-
`defineView` is
|
|
147
|
+
`defineView(setup)` is the functional API for defining views. The setup function receives a `ViewCtx` and returns `{ template, events, assign? }`. No `this` binding — all framework APIs are accessed via `ctx`.
|
|
134
148
|
|
|
135
149
|
### View and Template
|
|
136
150
|
|
|
@@ -156,21 +170,17 @@ export default defineView({
|
|
|
156
170
|
|
|
157
171
|
```ts
|
|
158
172
|
// src/views/home.ts
|
|
159
|
-
import { bindStore } from "@lark.js/mvc";
|
|
160
|
-
import View from "../view";
|
|
173
|
+
import { defineView, bindStore } from "@lark.js/mvc";
|
|
161
174
|
import template from "./home.html";
|
|
162
175
|
import useCountStore from "../store/count";
|
|
163
176
|
|
|
164
|
-
export default
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
},
|
|
170
|
-
assign() {
|
|
171
|
-
this.updater.snapshot();
|
|
177
|
+
export default defineView((ctx) => {
|
|
178
|
+
bindStore(ctx, useCountStore, (s) => ({ count: s.count }));
|
|
179
|
+
|
|
180
|
+
const assign = (): boolean | undefined => {
|
|
181
|
+
ctx.updater.snapshot();
|
|
172
182
|
const { count } = useCountStore.getState();
|
|
173
|
-
|
|
183
|
+
ctx.updater.set({
|
|
174
184
|
title: "Home",
|
|
175
185
|
count,
|
|
176
186
|
items: [
|
|
@@ -178,14 +188,21 @@ export default View.extend({
|
|
|
178
188
|
{ id: "b", name: "Beta" },
|
|
179
189
|
],
|
|
180
190
|
});
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
191
|
+
return ctx.updater.altered();
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// Call assign for initial render
|
|
195
|
+
assign();
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
template,
|
|
199
|
+
assign,
|
|
200
|
+
events: {
|
|
201
|
+
"incr<click>": () => {
|
|
202
|
+
useCountStore.getState().increment();
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
189
206
|
});
|
|
190
207
|
```
|
|
191
208
|
|
|
@@ -193,15 +210,15 @@ export default View.extend({
|
|
|
193
210
|
|
|
194
211
|
```ts
|
|
195
212
|
// src/boot.ts
|
|
196
|
-
import { Framework, registerViewClass
|
|
213
|
+
import { Framework, registerViewClass } from "@lark.js/mvc";
|
|
197
214
|
import type { FrameworkConfig } from "@lark.js/mvc";
|
|
198
215
|
import HomeView from "./views/home";
|
|
199
216
|
import AboutView from "./views/about";
|
|
200
217
|
import NotFoundView from "./views/404";
|
|
201
218
|
|
|
202
|
-
registerViewClass("home", HomeView
|
|
203
|
-
registerViewClass("about", AboutView
|
|
204
|
-
registerViewClass("404", NotFoundView
|
|
219
|
+
registerViewClass("home", HomeView);
|
|
220
|
+
registerViewClass("about", AboutView);
|
|
221
|
+
registerViewClass("404", NotFoundView);
|
|
205
222
|
|
|
206
223
|
const config: FrameworkConfig = {
|
|
207
224
|
rootId: "app",
|
|
@@ -220,7 +237,7 @@ const config: FrameworkConfig = {
|
|
|
220
237
|
Framework.boot(config);
|
|
221
238
|
```
|
|
222
239
|
|
|
223
|
-
`Framework.boot()` executes the following steps in order (order is correctness-sensitive): merge user config (including `routeMode`), inject config into Router (which determines history/hash mode), set EventDelegator's frame getter, subscribe to Router/State `changed` events, mark Framework/Router/State as booted, install the Frame Devtool Bridge, create the root Frame via `Frame.createRoot(config.rootId)`, call `Router._bind()` to bind route events (
|
|
240
|
+
`Framework.boot()` executes the following steps in order (order is correctness-sensitive): merge user config (including `routeMode`), inject config into Router (which determines history/hash mode), set EventDelegator's frame getter, subscribe to Router/State `changed` events, mark Framework/Router/State as booted, install the Frame Devtool Bridge, create the root Frame via `Frame.createRoot(config.rootId)`, call `Router._bind()` to bind route events (popstate for history mode, hashchange + popstate for hash mode) and trigger the first `diff()`, and finally mount `defaultView` only if Router did not already initiate a mount. The guard checks `!rootFrame.viewPath` (set synchronously at the start of `mountView`) rather than `!rootFrame.view` (the `viewInstance`, which is only assigned after async view-class loading completes). Checking `viewInstance` loses the race when views load via dynamic `import()`: the Router triggers `mountView("counter")` during `_bind()`, but `viewInstance` is still `undefined` when the boot guard runs, so `defaultView` mounts in parallel and wins the signature race. Step seven must precede step eight because the first `diff()` may immediately trigger `CHANGED` followed by `Frame.getRoot()`, and if the root Frame does not exist it degrades to rendering against the wrong element.
|
|
224
241
|
|
|
225
242
|
## Three Data Pipelines: Updater / State / Store
|
|
226
243
|
|
|
@@ -231,8 +248,8 @@ Lark provides three data flow mechanisms simultaneously, ranging from simple to
|
|
|
231
248
|
`Updater` is each View's local data manager. All intra-view data flow ultimately goes through the Updater:
|
|
232
249
|
|
|
233
250
|
```ts
|
|
234
|
-
|
|
235
|
-
|
|
251
|
+
ctx.updater.set({ count: newCount });
|
|
252
|
+
ctx.updater.digest();
|
|
236
253
|
```
|
|
237
254
|
|
|
238
255
|
Full pipeline: `updater.set(data)` shallow-merges data into the internal data object and collects changed keys. `updater.digest()` calls the compiled template function to generate an HTML string. `domGetNode` uses `tmp.innerHTML = wrap + html` to parse it into temporary DOM. `domSetChildNodes` compares against the live DOM to produce a keyed diff. DOM operations are applied in batch. `endUpdate()` notifies child Frames to complete mounting.
|
|
@@ -250,20 +267,24 @@ State.set({ pageTitle: "Home", isLoggedIn: true });
|
|
|
250
267
|
State.digest();
|
|
251
268
|
```
|
|
252
269
|
|
|
253
|
-
Subscription has two approaches. First, declare `observeState` in a view, and the framework automatically re-renders when the corresponding keys change:
|
|
270
|
+
Subscription has two approaches. First, declare `observeState` in a view setup, and the framework automatically re-renders when the corresponding keys change:
|
|
254
271
|
|
|
255
272
|
```ts
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
273
|
+
import { defineView } from "@lark.js/mvc";
|
|
274
|
+
|
|
275
|
+
export default defineView((ctx) => {
|
|
276
|
+
ctx.observeState("pageTitle,isLoggedIn");
|
|
277
|
+
return {
|
|
278
|
+
template,
|
|
279
|
+
assign: () => {
|
|
280
|
+
ctx.updater.snapshot();
|
|
281
|
+
ctx.updater.set({
|
|
282
|
+
title: State.get("pageTitle"),
|
|
283
|
+
logged: State.get("isLoggedIn"),
|
|
284
|
+
});
|
|
285
|
+
return ctx.updater.altered();
|
|
286
|
+
},
|
|
287
|
+
};
|
|
267
288
|
});
|
|
268
289
|
```
|
|
269
290
|
|
|
@@ -275,12 +296,14 @@ State.on("changed", (e) => {
|
|
|
275
296
|
});
|
|
276
297
|
```
|
|
277
298
|
|
|
278
|
-
State manages lifecycle through reference counting on keys.
|
|
299
|
+
State manages lifecycle through reference counting on keys. Use `State.clean("keys")` to create a cleanup function that automatically reclaims keys when the last observer is destroyed:
|
|
279
300
|
|
|
280
301
|
```ts
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
302
|
+
import { defineView, State } from "@lark.js/mvc";
|
|
303
|
+
|
|
304
|
+
export default defineView((ctx) => {
|
|
305
|
+
State.clean("pageTitle,isLoggedIn")(ctx);
|
|
306
|
+
return { template, events: {} };
|
|
284
307
|
});
|
|
285
308
|
```
|
|
286
309
|
|
|
@@ -288,11 +311,11 @@ Without cleanup, keys persist on global State causing leaks.
|
|
|
288
311
|
|
|
289
312
|
### Store: Zustand-Style State Management
|
|
290
313
|
|
|
291
|
-
The Store API aligns with zustand's design: `
|
|
314
|
+
The Store API aligns with zustand's design: `createStore(name, (set, get) => body)` returns a `StoreApi` object providing `getState` / `setState` / `subscribe` / `destroy`. State is a plain object with no Proxy; all writes must go through `setState` or actions. `bindStore(view, store, selector?)` binds a store to a Lark View with automatic unsubscription on view destruction.
|
|
292
315
|
|
|
293
316
|
```ts
|
|
294
317
|
// src/store/count.ts
|
|
295
|
-
import {
|
|
318
|
+
import { createStore, computed } from "@lark.js/mvc";
|
|
296
319
|
|
|
297
320
|
interface CountStore {
|
|
298
321
|
count: number;
|
|
@@ -304,7 +327,7 @@ interface CountStore {
|
|
|
304
327
|
reset: () => void;
|
|
305
328
|
}
|
|
306
329
|
|
|
307
|
-
const useCountStore =
|
|
330
|
+
const useCountStore = createStore<CountStore>("count", (set, get) => ({
|
|
308
331
|
count: 0,
|
|
309
332
|
step: 1,
|
|
310
333
|
doubled: computed(["count"], () => get().count * 2),
|
|
@@ -328,7 +351,7 @@ const useCountStore = create<CountStore>("count", (set, get) => ({
|
|
|
328
351
|
export default useCountStore;
|
|
329
352
|
```
|
|
330
353
|
|
|
331
|
-
The creator function receives `(set, get)` and executes once during `
|
|
354
|
+
The creator function receives `(set, get)` and executes once during `createStore`. Lark iterates the return value: functions become actions (attached to state, unaffected by `setState`); `computed(deps, fn)` occupies a derived slot, running `fn()` once for the initial value and recomputing whenever any dep key changes via `setState`; all other fields become initial state. Writing to a computed key via `setState` is silently ignored.
|
|
332
355
|
|
|
333
356
|
Reading and writing state:
|
|
334
357
|
|
|
@@ -347,35 +370,41 @@ useCountStore.getState().increment();
|
|
|
347
370
|
Binding in a view:
|
|
348
371
|
|
|
349
372
|
```ts
|
|
350
|
-
import { bindStore } from "@lark.js/mvc";
|
|
373
|
+
import { defineView, bindStore } from "@lark.js/mvc";
|
|
351
374
|
|
|
352
|
-
export default
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
// Bind all non-function state keys to view updater; auto-unsubscribes on destroy
|
|
356
|
-
bindStore(this, useCountStore);
|
|
375
|
+
export default defineView((ctx) => {
|
|
376
|
+
// Bind all non-function state keys to view updater; auto-unsubscribes on destroy
|
|
377
|
+
bindStore(ctx, useCountStore);
|
|
357
378
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
379
|
+
// Or use a selector to sync only specific keys
|
|
380
|
+
bindStore(ctx, useCountStore, (s) => ({ count: s.count }));
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
template,
|
|
384
|
+
events: {
|
|
385
|
+
"increment<click>": () => {
|
|
386
|
+
useCountStore.getState().increment();
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
};
|
|
364
390
|
});
|
|
365
391
|
```
|
|
366
392
|
|
|
367
393
|
Custom subscription callback (when data transformation is needed before sync):
|
|
368
394
|
|
|
369
395
|
```ts
|
|
370
|
-
|
|
396
|
+
import { defineView } from "@lark.js/mvc";
|
|
397
|
+
|
|
398
|
+
export default defineView((ctx) => {
|
|
371
399
|
const syncToView = () => {
|
|
372
400
|
const s = useCountStore.getState();
|
|
373
|
-
|
|
401
|
+
ctx.updater.digest({ count: s.count, isPositive: s.count > 0 });
|
|
374
402
|
};
|
|
375
403
|
const off = useCountStore.subscribe(syncToView);
|
|
376
|
-
|
|
404
|
+
ctx.on("destroy", off);
|
|
377
405
|
syncToView();
|
|
378
|
-
}
|
|
406
|
+
return { template, events: {} };
|
|
407
|
+
});
|
|
379
408
|
```
|
|
380
409
|
|
|
381
410
|
Destroying a store:
|
|
@@ -400,55 +429,38 @@ Selection guide: start with State; upgrade to Store when you need actions, deriv
|
|
|
400
429
|
|
|
401
430
|
## View Definition and Lifecycle
|
|
402
431
|
|
|
403
|
-
###
|
|
432
|
+
### Definition
|
|
404
433
|
|
|
405
|
-
`
|
|
406
|
-
|
|
407
|
-
```ts
|
|
408
|
-
import { View } from "@lark.js/mvc";
|
|
409
|
-
|
|
410
|
-
export default View.extend({
|
|
411
|
-
template,
|
|
412
|
-
init() {
|
|
413
|
-
/* ... */
|
|
414
|
-
},
|
|
415
|
-
assign() {
|
|
416
|
-
/* ... */
|
|
417
|
-
},
|
|
418
|
-
render() {
|
|
419
|
-
/* ... */
|
|
420
|
-
},
|
|
421
|
-
});
|
|
422
|
-
```
|
|
423
|
-
|
|
424
|
-
`defineView({...})` is a typed wrapper that threads the literal's own fields into `this` via `ThisType<P & ViewInterface>`:
|
|
434
|
+
`defineView(setup)` is the functional API for defining views. The setup function receives a `ViewCtx` and optional init params, and returns a descriptor with `template`, `events`, and optional `assign`:
|
|
425
435
|
|
|
426
436
|
```ts
|
|
427
437
|
import { defineView } from "@lark.js/mvc";
|
|
428
438
|
|
|
429
|
-
export default defineView({
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
439
|
+
export default defineView((ctx, params) => {
|
|
440
|
+
// Setup runs once on mount. Hooks (useState, useEffect, useStore)
|
|
441
|
+
// can be called here to manage state and side effects.
|
|
442
|
+
return {
|
|
443
|
+
template,
|
|
444
|
+
events: { "btn<click>": () => {} },
|
|
445
|
+
assign: (options?: unknown) => ctx.updater.altered(),
|
|
446
|
+
};
|
|
434
447
|
});
|
|
435
448
|
```
|
|
436
449
|
|
|
437
|
-
|
|
450
|
+
No `this` binding — all framework APIs are accessed via `ctx`.
|
|
438
451
|
|
|
439
452
|
### Lifecycle
|
|
440
453
|
|
|
441
|
-
-
|
|
442
|
-
- `
|
|
443
|
-
- `
|
|
444
|
-
- `render()` — Default implementation is `this.updater.digest()`. Wrapped by `View.wrapMethod`: increments signature on entry, handles pending endUpdate cleanup on exit.
|
|
454
|
+
- **Setup function** — Called once on mount with `(ctx, params)`. `params` comes from query strings on `v-lark`. Read stores, set initial data, and register hooks here.
|
|
455
|
+
- `assign(options?)` — Called when data may have changed. Pattern: `ctx.updater.snapshot()` at the top, `ctx.updater.set(...)` in the middle, `return ctx.updater.altered()` at the end.
|
|
456
|
+
- `ctx.render()` — Default implementation is `ctx.updater.digest()`. Can be wrapped via `ctx.renderMethod`.
|
|
445
457
|
- Destruction — The framework automatically calls `release(key, true)` to release all `capture`d resources, cleans up event delegation, and sets signature to 0.
|
|
446
458
|
|
|
447
|
-
`
|
|
459
|
+
`ctx.signature` marks async operation validity: greater than 0 means the view is alive (incremented on each render), 0 means destroyed. Never modify it manually.
|
|
448
460
|
|
|
449
461
|
### Event Methods
|
|
450
462
|
|
|
451
|
-
Event methods are named `name<eventType>` or `$selector<eventType
|
|
463
|
+
Event methods are named `name<eventType>` or `$selector<eventType>` and declared in the `events` map returned by the setup function. `registerEvents(ctx)` processes the map at mount time, binding events via `EventDelegator`.
|
|
452
464
|
|
|
453
465
|
| Syntax | Meaning |
|
|
454
466
|
| -------------------------- | -------------------------------------------------- |
|
|
@@ -466,11 +478,11 @@ Event delegation implementation: `EventDelegator` attaches listeners on `documen
|
|
|
466
478
|
|
|
467
479
|
### Resource Management
|
|
468
480
|
|
|
469
|
-
`capture` registers "destroyable objects tied to the view lifecycle":
|
|
481
|
+
`ctx.capture(key, resource, destroyOnRender?)` registers "destroyable objects tied to the view lifecycle":
|
|
470
482
|
|
|
471
483
|
```ts
|
|
472
484
|
const timer = setInterval(tick, 1000);
|
|
473
|
-
|
|
485
|
+
ctx.capture(
|
|
474
486
|
"myTimer",
|
|
475
487
|
{
|
|
476
488
|
destroy() {
|
|
@@ -481,20 +493,18 @@ this.capture(
|
|
|
481
493
|
);
|
|
482
494
|
```
|
|
483
495
|
|
|
484
|
-
The third parameter `destroyOnRender` when `true` causes automatic destruction and removal on the next render call; when `false` cleanup happens only on view destruction. `release(key, destroy = true)` manually removes an entry.
|
|
496
|
+
The third parameter `destroyOnRender` when `true` causes automatic destruction and removal on the next render call; when `false` cleanup happens only on view destruction. `ctx.release(key, destroy = true)` manually removes an entry.
|
|
485
497
|
|
|
486
498
|
### Async Safety
|
|
487
499
|
|
|
488
|
-
Async callbacks may arrive after a view has re-rendered or been destroyed. `wrapAsync` adds a signature check layer:
|
|
500
|
+
Async callbacks may arrive after a view has re-rendered or been destroyed. `ctx.wrapAsync(fn)` adds a signature check layer:
|
|
489
501
|
|
|
490
502
|
```ts
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
safe(data); // Will not execute if view has re-rendered or been destroyed
|
|
497
|
-
}
|
|
503
|
+
const loadData = ctx.wrapAsync((data: unknown) => {
|
|
504
|
+
ctx.updater.set({ items: data }).digest();
|
|
505
|
+
});
|
|
506
|
+
const data = await fetch("/api/items").then((r) => r.json());
|
|
507
|
+
loadData(data); // Will not execute if view has re-rendered or been destroyed
|
|
498
508
|
```
|
|
499
509
|
|
|
500
510
|
`mark(host, key)` / `unmark(host)` is the lower-level equivalent mechanism: returns a `() => boolean` validator. All mark state is stored in a module-level `WeakMap` rather than polluting the host object, so it works on `Object.freeze`d objects.
|
|
@@ -560,21 +570,23 @@ Guards execute in registration order. Any guard that returns/resolves to `false`
|
|
|
560
570
|
|
|
561
571
|
### useUrlState: URL Parameter State Sync
|
|
562
572
|
|
|
563
|
-
`useUrlState(
|
|
573
|
+
`useUrlState(ctx, initialState?)` reads URL query parameters into a state object and provides a `setState` function that writes changes back to the URL (via `Router.to()`). It automatically observes the specified parameter keys, re-rendering the view when the URL changes.
|
|
564
574
|
|
|
565
575
|
```ts
|
|
566
|
-
import { useUrlState } from "@lark.js/mvc";
|
|
567
|
-
|
|
568
|
-
export default
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
576
|
+
import { defineView, useUrlState } from "@lark.js/mvc";
|
|
577
|
+
|
|
578
|
+
export default defineView((ctx) => {
|
|
579
|
+
const [state, setPageState] = useUrlState(ctx, { page: "1", size: "20" });
|
|
580
|
+
ctx.updater.set({ page: state.page, size: state.size }).digest();
|
|
581
|
+
|
|
582
|
+
return {
|
|
583
|
+
template,
|
|
584
|
+
events: {
|
|
585
|
+
"nextPage<click>": () => {
|
|
586
|
+
setPageState((prev) => ({ page: String(Number(prev.page) + 1) }));
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
};
|
|
578
590
|
});
|
|
579
591
|
```
|
|
580
592
|
|
|
@@ -584,19 +596,17 @@ Supports both history and hash routing modes.
|
|
|
584
596
|
|
|
585
597
|
`Service` is a request management layer built on `fetch` (or any synchronous function) with built-in LFU caching, concurrent deduplication, serial queuing, and lifecycle events.
|
|
586
598
|
|
|
587
|
-
### Defining
|
|
599
|
+
### Defining Services and Endpoints
|
|
588
600
|
|
|
589
601
|
```ts
|
|
590
|
-
import {
|
|
602
|
+
import { createService, type PayloadApi } from "@lark.js/mvc";
|
|
591
603
|
|
|
592
|
-
const AppService =
|
|
593
|
-
(payload, callback) => {
|
|
604
|
+
const AppService = createService(
|
|
605
|
+
(payload: PayloadApi, callback: () => void) => {
|
|
594
606
|
fetch(payload.get<string>("url"), {
|
|
595
607
|
method: payload.get<string>("method") || "GET",
|
|
596
608
|
headers: { "Content-Type": "application/json" },
|
|
597
|
-
body: payload.get("data")
|
|
598
|
-
? JSON.stringify(payload.get("data"))
|
|
599
|
-
: undefined,
|
|
609
|
+
body: payload.get("data") ? JSON.stringify(payload.get("data")) : undefined,
|
|
600
610
|
})
|
|
601
611
|
.then((r) => r.json())
|
|
602
612
|
.then((data) => {
|
|
@@ -616,10 +626,7 @@ AppService.add([
|
|
|
616
626
|
url: "/api/users/:id",
|
|
617
627
|
cache: 30_000,
|
|
618
628
|
before(payload) {
|
|
619
|
-
payload.set(
|
|
620
|
-
"url",
|
|
621
|
-
payload.get<string>("url").replace(":id", payload.get<string>("id")),
|
|
622
|
-
);
|
|
629
|
+
payload.set("url", payload.get<string>("url").replace(":id", payload.get<string>("id")));
|
|
623
630
|
},
|
|
624
631
|
after(payload) {
|
|
625
632
|
const data = payload.get("data");
|
|
@@ -633,21 +640,25 @@ AppService.add([
|
|
|
633
640
|
### Using in Views
|
|
634
641
|
|
|
635
642
|
```ts
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
loadData() {
|
|
645
|
-
|
|
643
|
+
import { defineView } from "@lark.js/mvc";
|
|
644
|
+
import template from "./users.html";
|
|
645
|
+
import { AppService } from "../service/app";
|
|
646
|
+
|
|
647
|
+
export default defineView((ctx) => {
|
|
648
|
+
const service = AppService.instance();
|
|
649
|
+
ctx.capture("userService", service, true);
|
|
650
|
+
|
|
651
|
+
const loadData = (): void => {
|
|
652
|
+
service.all("userList", (errors, payload) => {
|
|
646
653
|
if (!errors[0]) {
|
|
647
|
-
|
|
654
|
+
ctx.updater.set({ users: payload.get("data") }).digest();
|
|
648
655
|
}
|
|
649
656
|
});
|
|
650
|
-
}
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
loadData();
|
|
660
|
+
|
|
661
|
+
return { template, events: {} };
|
|
651
662
|
});
|
|
652
663
|
```
|
|
653
664
|
|
|
@@ -684,11 +695,10 @@ Template files use the `.html` extension and are compiled at build time by `lark
|
|
|
684
695
|
### Control Flow
|
|
685
696
|
|
|
686
697
|
```html
|
|
687
|
-
{{if condition}}...{{else if other}}...{{else}}...{{/if}} {{forOf list as item}}
|
|
688
|
-
|
|
689
|
-
{{forOf
|
|
690
|
-
|
|
691
|
-
localVar = expr}}
|
|
698
|
+
{{if condition}}...{{else if other}}...{{else}}...{{/if}} {{forOf list as item}} ... {{/forOf}}
|
|
699
|
+
{{forOf list as item idx}} {{=idx}}: {{=item.name}} {{/forOf}} {{forOf list as {name, age} idx last
|
|
700
|
+
first}} ... {{/forOf}} {{forIn object as value key}} ... {{/forIn}} {{for (let i = 0; i < n; i++)}}
|
|
701
|
+
... {{/for}} {{set localVar = expr}}
|
|
692
702
|
```
|
|
693
703
|
|
|
694
704
|
`forOf` requires the `as` keyword. `{{forOf list item}}` is a compile-time error; the correct form is `{{forOf list as item}}`.
|
|
@@ -725,41 +735,51 @@ Marking large static subtrees with `ldk` can completely skip rendering work. Thi
|
|
|
725
735
|
|
|
726
736
|
## Frame and the View Tree
|
|
727
737
|
|
|
728
|
-
`Frame` manages view mounting and unmounting, maintains parent-child relationships, and provides cross-view method invocation. Each Frame corresponds to one DOM container and one
|
|
738
|
+
`Frame` manages view mounting and unmounting, maintains parent-child relationships, and provides cross-view method invocation. Each Frame corresponds to one DOM container and one `ViewCtx`.
|
|
729
739
|
|
|
730
740
|
### Typed API
|
|
731
741
|
|
|
732
|
-
| API | Description
|
|
733
|
-
| ------------------------------------------------- |
|
|
734
|
-
| `Frame.get(id)` | Look up Frame by DOM id
|
|
735
|
-
| `Frame.getAll()` | All Frames as `Map<string, Frame>`
|
|
736
|
-
| `Frame.getRoot()` | Current root Frame; returns `undefined` if not created
|
|
737
|
-
| `Frame.createRoot(id)` | Idempotent root creation (`Framework.boot` calls this)
|
|
738
|
-
| `
|
|
739
|
-
| `frame.invoke(name, args?)` | Call the owning view's method; if view not mounted, pushes to `invokeList`, flushed by `
|
|
740
|
-
| `frame.children()` | Child Frame id array (order not guaranteed)
|
|
741
|
-
| `frame.parent(level?)` | Ancestor Frame, defaults to one level up
|
|
742
|
-
| `frame.mountFrame(id, viewPath, params?)` | Explicitly create a child Frame
|
|
743
|
-
| `frame.unmountFrame(id)` | Unmount a specific child Frame
|
|
744
|
-
| `frame.mountZone(id?)` / `frame.unmountZone(id?)` | Batch mount/unmount all `v-lark` child nodes in a zone
|
|
745
|
-
| `Frame.on("add" \| "remove", handler)` | Frame instance lifecycle events (static emitter)
|
|
746
|
-
| `frame.on("created" \| "alter", handler)` | All child Frames rendered / child content changed (instance emitter)
|
|
747
|
-
|
|
748
|
-
Frame instances
|
|
742
|
+
| API | Description |
|
|
743
|
+
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
|
744
|
+
| `Frame.get(id)` | Look up Frame by DOM id |
|
|
745
|
+
| `Frame.getAll()` | All Frames as `Map<string, Frame>` |
|
|
746
|
+
| `Frame.getRoot()` | Current root Frame; returns `undefined` if not created |
|
|
747
|
+
| `Frame.createRoot(id)` | Idempotent root creation (`Framework.boot` calls this) |
|
|
748
|
+
| `createFrame(id, parentId?)` | Create an independent Frame instance for micro-frontend / embedded widget scenarios |
|
|
749
|
+
| `frame.invoke(name, args?)` | Call the owning view's method; if view not mounted, pushes to `invokeList`, flushed by `runInvokes(frame)` after mounting |
|
|
750
|
+
| `frame.children()` | Child Frame id array (order not guaranteed) |
|
|
751
|
+
| `frame.parent(level?)` | Ancestor Frame, defaults to one level up |
|
|
752
|
+
| `frame.mountFrame(id, viewPath, params?)` | Explicitly create a child Frame |
|
|
753
|
+
| `frame.unmountFrame(id)` | Unmount a specific child Frame |
|
|
754
|
+
| `frame.mountZone(id?)` / `frame.unmountZone(id?)` | Batch mount/unmount all `v-lark` child nodes in a zone |
|
|
755
|
+
| `Frame.on("add" \| "remove", handler)` | Frame instance lifecycle events (static emitter) |
|
|
756
|
+
| `frame.on("created" \| "alter", handler)` | All child Frames rendered / child content changed (instance emitter) |
|
|
757
|
+
|
|
758
|
+
Frame instances are plain objects created by `createFrame()`. There is no object pool — destroyed frames are removed from the registry and GC'd. Do not retain Frame references after `unmountFrame` as the frame may be stale.
|
|
749
759
|
|
|
750
760
|
## Module Federation Micro-Frontend
|
|
751
761
|
|
|
752
|
-
Lark
|
|
762
|
+
Lark provides first-class micro-frontend support through Webpack Module Federation (or Rspack's MF implementation). The integration centers on three pieces: `FrameworkConfig.require` (the bridge to MF container APIs), `use()` (Lark's module loader that delegates to `require`), and `CrossSite` (a skeleton-screen bridge view for remote loading).
|
|
763
|
+
|
|
764
|
+
### How it works: the loading pipeline
|
|
765
|
+
|
|
766
|
+
When a template contains `v-lark="remote-app/views/home"`, the following sequence executes:
|
|
767
|
+
|
|
768
|
+
1. **`Frame.mountView(viewPath)`** parses the view path and checks the view registry (`getViewClass`). If the view setup is already registered (sync path), it mounts immediately. If not, it enters the async path.
|
|
753
769
|
|
|
754
|
-
|
|
770
|
+
2. **`use(viewClassName, callback)`** is called. This function in `module-loader.ts` checks whether `FrameworkConfig.require` is configured:
|
|
771
|
+
- **MF mode**: delegates to `config.require(names)`, which calls `__webpack_init_sharing__` / `__webpack_share_scopes__` to initialize the shared scope, then resolves the remote container via `window[remoteName]`, calls `container.init(scope)`, and `container.get(modulePath)` to obtain the factory. The factory returns the module, and `use()` extracts the default export.
|
|
772
|
+
- **Fallback mode**: if `require` is not configured, `use()` falls back to dynamic `import()` for ESM-based loading.
|
|
755
773
|
|
|
756
|
-
|
|
774
|
+
3. **Signature guard**: `mountView` captures `frame.signature` before the async load begins. When the callback fires, it checks `sign !== frame.signature` — if the frame was unmounted or re-mounted during the async wait, the callback silently returns, preventing stale mounts.
|
|
775
|
+
|
|
776
|
+
4. **Registration and mount**: the loaded setup function is registered via `registerViewClass(viewClassName, setup)`, then `mountCtx(frame, setup, params)` creates the `ViewCtx`, runs the setup function, and renders.
|
|
757
777
|
|
|
758
778
|
```ts
|
|
759
779
|
Framework.boot({
|
|
760
780
|
rootId: "app",
|
|
761
781
|
projectName: "host-app",
|
|
762
|
-
|
|
782
|
+
crossSites: [
|
|
763
783
|
{
|
|
764
784
|
projectName: "remote-app",
|
|
765
785
|
source: "remote_app@//cdn.example.com/remote-app/remoteEntry.js",
|
|
@@ -787,7 +807,7 @@ Framework.boot({
|
|
|
787
807
|
|
|
788
808
|
Then write `v-lark="remote-app/views/home"` in templates to trigger async loading and mounting of the remote view.
|
|
789
809
|
|
|
790
|
-
###
|
|
810
|
+
### CrossSite bridge view
|
|
791
811
|
|
|
792
812
|
For skeleton screens and remote `prepare` hooks, use `CrossSite`:
|
|
793
813
|
|
|
@@ -800,9 +820,17 @@ registerViewClass("cross-site", CrossSite);
|
|
|
800
820
|
<div v-lark="cross-site?view=remote-app/views/home&bizCode=mybiz"></div>
|
|
801
821
|
```
|
|
802
822
|
|
|
803
|
-
CrossSite
|
|
823
|
+
`CrossSite` is a `ViewSetup` function that:
|
|
824
|
+
|
|
825
|
+
1. Renders a skeleton placeholder (default `Loading…`, overridable via the `skeleton` parameter).
|
|
826
|
+
2. Parses the `view` parameter to extract the project name (`remote-app`) and remote path (`views/home`).
|
|
827
|
+
3. Loads the remote project's `prepare` module via `use("remote-app/prepare", callback)`. The `prepare` module can initialize shared state, register global services, or configure the remote project's API host.
|
|
828
|
+
4. After `prepare` completes, loads the actual remote view via `use(viewPath, callback)`.
|
|
829
|
+
5. Mounts the remote view in a sub-frame via `ctx.owner.mountFrame("mf_" + ctx.id, viewPath, { bizCode, project })`.
|
|
830
|
+
6. Uses a signature counter (`state.sign`) for race condition guards: if the user navigates away during loading, `state.sign` is incremented on destroy, and the pending load callbacks check `currentSign !== state.sign` to bail out.
|
|
831
|
+
7. Re-renders with a hidden container once the remote view is mounted, hiding the skeleton.
|
|
804
832
|
|
|
805
|
-
### Webpack
|
|
833
|
+
### Webpack / Rspack configuration
|
|
806
834
|
|
|
807
835
|
Host:
|
|
808
836
|
|
|
@@ -827,26 +855,90 @@ new ModuleFederationPlugin({
|
|
|
827
855
|
});
|
|
828
856
|
```
|
|
829
857
|
|
|
830
|
-
`@lark.js/mvc` must be `singleton: true
|
|
858
|
+
`@lark.js/mvc` must be `singleton: true` because the framework's module-level singletons (`State`, `Router`, `Frame`, `EventDelegator`, `config`) must be shared across host and remote. Without `singleton`, each bundle gets its own copy of these singletons, causing state divergence — e.g., `State.set()` in the host would not notify views in the remote, and `registerViewClass()` in the host would not be visible to the remote's `Frame.mountView()`.
|
|
831
859
|
|
|
832
860
|
`splitChunks.chunks` must be `"async"`. Using `"all"` extracts `@lark.js/mvc` into a separate vendor chunk, breaking MF shared scope initialization (`ScriptExternalLoadError: Loading script failed`).
|
|
833
861
|
|
|
834
|
-
##
|
|
862
|
+
## Hot Module Replacement (HMR)
|
|
863
|
+
|
|
864
|
+
Lark ships zero-config HMR for Vite, Webpack, and Rspack. When you edit a `.html` template or a `.ts` view file, the framework hot-swaps the changed code in place without a full page reload. View-local state (counter values, form input, scroll-derived data) survives the update.
|
|
865
|
+
|
|
866
|
+
### Zero-config auto-injection
|
|
867
|
+
|
|
868
|
+
The build plugins inject HMR code at compile time, similar to `@vitejs/plugin-react` (React Refresh) and `@vitejs/plugin-vue` (Vue HMR). You add the plugin to your config and HMR works. No per-file `import.meta.hot` boilerplate needed.
|
|
869
|
+
|
|
870
|
+
The Vite plugin uses two hooks: `load` appends a template HMR snippet to compiled `.html` modules, and `transform` injects a view class HMR snippet into `.ts` files that import `.html`. Webpack and Rspack loaders do the same for template modules.
|
|
871
|
+
|
|
872
|
+
### Two HMR layers
|
|
873
|
+
|
|
874
|
+
**Template layer** (`.html` changes): The compiled template module self-accepts. The accept callback calls `hotSwapByTemplate(oldTemplate, newTemplate)` to find every mounted view whose `ctx.getTemplate()` returns the old function reference, replace it via `ctx.setTemplate(newTemplate)`, and force-render. Event handlers are NOT re-delegated because they live in the `events` map returned by the setup function, not in the template.
|
|
875
|
+
|
|
876
|
+
**View setup layer** (`.ts` changes): The plugin rewrites `export default defineView(...)` to `const __larkViewDefault = defineView(...)` and appends an HMR snippet. The accept callback calls `hotSwapByView(oldSetup, newSetup)` which updates the registry and hot-swaps every matching frame via `hotSwapView`.
|
|
835
877
|
|
|
836
|
-
###
|
|
878
|
+
### State preservation: in-place setup re-run
|
|
837
879
|
|
|
838
|
-
|
|
880
|
+
`hotSwapView(frame, newSetup)` preserves the view context entirely:
|
|
839
881
|
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
882
|
+
1. Run old cleanups (`useEffect` cleanup functions)
|
|
883
|
+
2. Unregister old events
|
|
884
|
+
3. Destroy `destroyOnRender` resources
|
|
885
|
+
4. Re-run `newSetup(ctx)` — ctx instance, `updater`, `updater.data`, `resources`, `emitter`, `signature`, `id`, and `owner` all stay the same
|
|
886
|
+
5. Update template, events, and assign from the new descriptor
|
|
887
|
+
6. Register new events
|
|
888
|
+
7. `ctx.signature++; ctx.fire("render"); destroyAllResources(ctx, false); ctx.updater.forceDigest()`
|
|
889
|
+
|
|
890
|
+
The user's setup function runs again, but because it receives the same `ViewCtx` with preserved `updater.data`, any state set via `ctx.updater.set()` in the previous setup survives.
|
|
891
|
+
|
|
892
|
+
### `forceDigest()` on Updater
|
|
893
|
+
|
|
894
|
+
Normal `digest()` only re-renders when data changed. After an HMR swap, the data is the same but the template changed. `forceDigest()` marks every current data key as changed before triggering the digest, forcing a full re-render.
|
|
895
|
+
|
|
896
|
+
### Cross-bundler HMR API differences
|
|
897
|
+
|
|
898
|
+
| Bundler | HMR context | Accept callback receives new module |
|
|
899
|
+
| ------- | ----------------- | ----------------------------------- |
|
|
900
|
+
| Vite | `import.meta.hot` | Yes, via `newModule.default` |
|
|
901
|
+
| Webpack | `module.hot` | No, module already re-executed |
|
|
902
|
+
| Rspack | `module.hot` | No, module already re-executed |
|
|
903
|
+
|
|
904
|
+
In Vite, the accept callback runs in the OLD module's scope (local vars = old values). In Webpack/Rspack, it runs in the NEW module's scope (local vars = new values). Both snippets use `dispose` to `hot.data` to `accept` to pass the old reference across the cycle.
|
|
905
|
+
|
|
906
|
+
### Architecture: why `hmr-inject.ts` is separate from `hmr.ts`
|
|
907
|
+
|
|
908
|
+
The injection code generator (`hmr-inject.ts`) has ZERO runtime imports — it only generates snippet strings. The runtime HMR functions (`hmr.ts`) import `./frame` which imports `./event-delegator` which accesses `document`. The three bundler plugin entry points (`vite.ts`, `webpack.ts`, `rspack.ts`) are loaded in Node.js (to process build config), so they import from `hmr-inject.ts`, NOT `hmr.ts`. This prevents `ReferenceError: document is not defined` in Node.js.
|
|
909
|
+
|
|
910
|
+
### The `__larkTemplate` naming contract
|
|
911
|
+
|
|
912
|
+
`compileTemplate` emits `function __larkTemplate(data, viewId, refData) { ... }` + `export default __larkTemplate;` (named function, not anonymous) so the HMR snippet can reference it by name in the `dispose` callback. See `naming-convention.md` in the package root for the full list of cross-layer naming contracts.
|
|
913
|
+
|
|
914
|
+
### Manual HMR API (fallback)
|
|
915
|
+
|
|
916
|
+
For cases where auto-injection does not cover your needs (e.g., a view file that does not import `.html`), use the manual API:
|
|
917
|
+
|
|
918
|
+
```ts
|
|
919
|
+
import { defineView, acceptView, disposeView } from "@lark.js/mvc";
|
|
920
|
+
import type { HotContext } from "@lark.js/mvc";
|
|
921
|
+
import template from "./home.html";
|
|
922
|
+
|
|
923
|
+
const HomeView = defineView((ctx) => ({ template /* ... */ }));
|
|
924
|
+
|
|
925
|
+
if (import.meta.hot) {
|
|
926
|
+
const hot = import.meta.hot as HotContext;
|
|
927
|
+
disposeView(hot, "home");
|
|
928
|
+
acceptView(hot, "home");
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
export default HomeView;
|
|
932
|
+
```
|
|
933
|
+
|
|
934
|
+
`acceptView(hot, viewPath)` calls `hotSwapFrames(viewPath, newSetup)` (state-preserving). `disposeView(hot, viewPath)` calls `invalidateViewClass(viewPath)`. Both are no-ops when `hot` is `undefined`.
|
|
935
|
+
|
|
936
|
+
### What is NOT preserved across HMR
|
|
937
|
+
|
|
938
|
+
- **`destroyOnRender` resources**: destroyed on every render including HMR force-render (by design)
|
|
939
|
+
- **Setup side effects**: since the setup function re-runs, any one-time setup logic will re-execute. If your setup sets initial data unconditionally, it will overwrite HMR-preserved data. Guard with `if (!ctx.rendered.value)` if needed.
|
|
940
|
+
|
|
941
|
+
## Debugging and Devtool Bridge
|
|
850
942
|
|
|
851
943
|
### Frame Devtool Bridge
|
|
852
944
|
|
|
@@ -867,41 +959,53 @@ The `lark-devtool` sub-project in this repository is the paired Devtool that loa
|
|
|
867
959
|
- `Framework.setConfig(patch)` — Merge configuration, returns merged result.
|
|
868
960
|
- `Framework.use(names, callback?)` — Async view loader; returns `Promise<unknown[]>` when no callback.
|
|
869
961
|
- `Framework.mark(host, key)` / `Framework.unmark(host)` — Async callback validity tracking via module-level `WeakMap`.
|
|
870
|
-
- `Framework.
|
|
962
|
+
- `Framework.dispatchEvent(target, type, init?)` — Trigger custom DOM event.
|
|
871
963
|
- `Framework.task(fn, args?, ctx?)` — Chunked execution: prefers `scheduler.postTask` then `requestIdleCallback` then `setTimeout(0)`, with a fixed 48ms budget or adaptive time slicing.
|
|
872
964
|
- `Framework.delay(ms)` — Promise-wrapped setTimeout.
|
|
873
965
|
- `Framework.waitZoneViewsRendered(viewId, timeout?)` — Wait until all views in a zone have rendered.
|
|
874
|
-
- `Framework.applyStyle(idOrPairs, css?)` — Dynamically inject CSS, returns cleanup function.
|
|
875
966
|
|
|
876
967
|
### Updater
|
|
877
968
|
|
|
878
969
|
- `updater.get(key?)` — Read data; returns entire data object when no key.
|
|
879
970
|
- `updater.set(data, excludes?)` — Shallow merge and collect changed keys.
|
|
880
971
|
- `updater.digest(data?, excludes?, callback?)` — Render; supports re-entry via `digestingQueue`.
|
|
972
|
+
- `updater.forceDigest()` — Force full re-render bypassing change detection. Marks all data keys as changed. Used by HMR to apply a new template against preserved data.
|
|
881
973
|
- `updater.snapshot()` — Record current monotonic version.
|
|
882
974
|
- `updater.altered()` — Check if changed, returns `boolean | undefined`.
|
|
883
975
|
- `updater.translate(value)` — Resolve SPLITTER + number reference tokens to original values.
|
|
884
976
|
- `updater.parse(expr)` — Safe path parser: dot paths (`a.b.c`) or numeric literals only, no eval.
|
|
885
977
|
- `updater.getChangedKeys()` — `ReadonlySet<string>` of keys changed since last digest.
|
|
886
978
|
|
|
979
|
+
### HMR
|
|
980
|
+
|
|
981
|
+
- `hotSwapByTemplate(oldTemplate, newTemplate)` — Template-only HMR: find views by template reference, replace, force-render.
|
|
982
|
+
- `hotSwapByView(oldSetup, newSetup)` — View setup HMR: update registry + hot-swap all matching frames.
|
|
983
|
+
- `hotSwapView(frame, newSetup)` — Single-frame in-place setup re-run (building block).
|
|
984
|
+
- `hotSwapFrames(viewPath, newSetup)` — Batch `hotSwapView` by viewPath.
|
|
985
|
+
- `reloadViews(viewPath)` — Legacy full-remount (destroys ctx, loses state). Prefer `hotSwapFrames`.
|
|
986
|
+
- `acceptView(hot, viewPath)` / `disposeView(hot, viewPath)` — Manual HMR API (fallback).
|
|
987
|
+
- `injectTemplateHmrSnippet(source, bundler)` / `injectViewHmr(source, bundler)` — Snippet generators for plugin internals.
|
|
988
|
+
- `forceDigest()` — On `Updater`, forces full re-render (see Updater section above).
|
|
989
|
+
- `HotContext` interface, `Bundler` type (`"vite" | "webpack" | "rspack"`).
|
|
990
|
+
|
|
887
991
|
### Store (zustand-style)
|
|
888
992
|
|
|
889
|
-
- `
|
|
993
|
+
- `createStore(name, (set, get) => body)` — Create store, returns `StoreApi`.
|
|
890
994
|
- `store.getState()` — Read current state.
|
|
891
995
|
- `store.setState(partial | updater)` — Shallow merge, notify all listeners.
|
|
892
996
|
- `store.subscribe(listener)` — Listen for changes, returns unsubscribe function.
|
|
893
997
|
- `store.destroy()` — Destroy store, clear listeners.
|
|
894
998
|
- `computed(deps, fn)` — Declare derived state.
|
|
895
999
|
- `bindStore(view, store, selector?)` — Bind to Lark View with auto-sync and auto-cleanup.
|
|
896
|
-
- `useUrlState(
|
|
1000
|
+
- `useUrlState(ctx, initialState?)` — URL parameter state sync.
|
|
897
1001
|
|
|
898
1002
|
## Common Pitfalls
|
|
899
1003
|
|
|
900
1004
|
1. `boot.ts` must be inside `src/`: HTML references `/src/boot.ts`; placing it at the project root causes runtime resolution failure.
|
|
901
|
-
2. `registerViewClass` must precede `Framework.boot()`: all
|
|
1005
|
+
2. `registerViewClass` must precede `Framework.boot()`: all view setups (including sub-components) must either be pre-registered or loaded via `FrameworkConfig.require`.
|
|
902
1006
|
3. `.html` imports require build integration: only works in projects compiled by `larkMvcPlugin` / `larkMvcLoader`.
|
|
903
1007
|
4. Write State with `State.set` + `State.digest`, never mutate the returned object directly: Safeguard warns in debug mode, deduplicated by key.
|
|
904
|
-
5. `bindStore` auto-unsubscribes on view destroy; manual `store.subscribe(listener)` calls need explicit cleanup (e.g., `
|
|
1008
|
+
5. `bindStore` auto-unsubscribes on view destroy; manual `store.subscribe(listener)` calls need explicit cleanup (e.g., `ctx.on("destroy", off)`).
|
|
905
1009
|
6. Event methods use `<>` not `()`: write `name<click>`, not `name(click)`.
|
|
906
1010
|
7. `assign()` must have `snapshot` at the top and `return altered()` at the bottom: both are required for the framework to determine re-render necessity.
|
|
907
1011
|
8. Never modify `view.signature`: internally managed; 0 means destroyed, render wrapper auto-increments.
|
|
@@ -911,12 +1015,17 @@ The `lark-devtool` sub-project in this repository is the paired Devtool that loa
|
|
|
911
1015
|
12. Store state is a plain object: `store.getState()` returns the actual state object; reads are direct access, writes must go through `setState()` or actions.
|
|
912
1016
|
13. `forOf` requires `as`: `{{forOf list item}}` is a compile error.
|
|
913
1017
|
14. `wrapAsync` validates by signature: callback only executes when `view.signature` matches the value at wrap time.
|
|
914
|
-
15.
|
|
1018
|
+
15. Do not retain Frame references after `unmountFrame`: destroyed frames are removed from the registry; stale references may point to reused or invalid objects.
|
|
915
1019
|
16. Updater supports digest re-entry: digest during digest enters `digestingQueue`; `null` is the boundary.
|
|
916
1020
|
17. Store creator runs once: state persists across view mount/unmount cycles; call `store.destroy()` to tear down.
|
|
917
|
-
18. State is simple, Store is complex: lightweight shared values use State; use `
|
|
1021
|
+
18. State is simple, Store is complex: lightweight shared values use State; use `createStore()` for actions, derived data, or fine-grained subscriptions; always pair State writes with `State.clean("keys")` to prevent leaks.
|
|
918
1022
|
19. MF view paths use the remote project name as prefix: `v-lark="remote-app/views/home"` triggers async loading via `FrameworkConfig.require` when unregistered; `@lark.js/mvc` must be `singleton: true`.
|
|
919
1023
|
20. `splitChunks.chunks` must be `"async"` in MF projects: `"all"` breaks shared scope initialization.
|
|
1024
|
+
21. HMR is zero-config: do not manually add `import.meta.hot` calls to view files. The plugin auto-injects HMR snippets. Manual `acceptView`/`disposeView` is only for files not covered by auto-injection (e.g., views that don't import `.html`).
|
|
1025
|
+
22. HMR preserves `updater.data` but not setup side effects: since the setup function re-runs during HMR, any unconditional `ctx.updater.set()` calls will overwrite preserved data. Guard with `if (!ctx.rendered.value)` if needed.
|
|
1026
|
+
23. `reloadViews` loses state, `hotSwapFrames` preserves it: `reloadViews` does a full unmount+remount (destroys the view context). `hotSwapFrames` does an in-place setup re-run. `acceptView` uses `hotSwapFrames`. Prefer `hotSwapFrames` / `hotSwapByView` / `hotSwapByTemplate` over `reloadViews`.
|
|
1027
|
+
24. `hmr-inject.ts` must stay separate from `hmr.ts`: the injection functions are pure string generators with zero runtime imports. `hmr.ts` imports `./frame` which accesses `document`. The plugin entry points (`vite.ts`, `webpack.ts`, `rspack.ts`) load in Node.js and must not transitively import `hmr.ts`. Merging them causes `ReferenceError: document is not defined`.
|
|
1028
|
+
25. `compileTemplate` emits `function __larkTemplate(...)`: the named function (not anonymous `export default function`) is a compile-time contract so the HMR snippet can reference it by name. See `naming-convention.md` for the full list of cross-layer naming contracts.
|
|
920
1029
|
|
|
921
1030
|
## Comparison with Vue 3 / React 19
|
|
922
1031
|
|
|
@@ -926,7 +1035,7 @@ Similarities: templates compile to functions; reactivity via Proxy; derived data
|
|
|
926
1035
|
|
|
927
1036
|
| Dimension | Vue 3 | Lark |
|
|
928
1037
|
| --------------------- | -------------------------------------- | ----------------------------------------------------- |
|
|
929
|
-
| Component abstraction | SFC / function components / Options |
|
|
1038
|
+
| Component abstraction | SFC / function components / Options | Functional `defineView(setup)` + `ViewCtx` + Hooks |
|
|
930
1039
|
| Render output | VNode patch | HTML string parsed to real DOM + diff |
|
|
931
1040
|
| Template syntax | `v-if` / `v-for` / `:bind` | `{{if}}` / `{{forOf}}` / `@event` / `v-lark` |
|
|
932
1041
|
| Dependency tracking | Automatic effect tracking | subscribe + bindStore + computed |
|
|
@@ -942,9 +1051,9 @@ Similarities: unidirectional data flow; immutable write-back style; async protec
|
|
|
942
1051
|
|
|
943
1052
|
| Dimension | React 19 | Lark |
|
|
944
1053
|
| --------------------- | ---------------------------------------- | ------------------------------------------------------------ |
|
|
945
|
-
| Component abstraction | Function components + Hooks |
|
|
946
|
-
| State encapsulation | `useState` / `useReducer` | View
|
|
947
|
-
| Side effects | `useEffect` / `useLayoutEffect` | `
|
|
1054
|
+
| Component abstraction | Function components + Hooks | Functional `defineView(setup)` + `ViewCtx` + Hooks |
|
|
1055
|
+
| State encapsulation | `useState` / `useReducer` | View ctx fields, `createStore()` store, `State` |
|
|
1056
|
+
| Side effects | `useEffect` / `useLayoutEffect` | `useEffect` hook + `ctx.capture` / `ctx.release` |
|
|
948
1057
|
| Render interruption | Fiber time-slicing, Suspense, Transition | Synchronous digest, not interruptible |
|
|
949
1058
|
| Compile optimization | React Compiler (auto-memo) | Template compile-time only; no runtime auto-memo |
|
|
950
1059
|
| Server rendering | RSC, streaming SSR | Not supported (design trade-off) |
|
|
@@ -970,4 +1079,4 @@ pnpm format # prettier formatting
|
|
|
970
1079
|
|
|
971
1080
|
## License
|
|
972
1081
|
|
|
973
|
-
|
|
1082
|
+
MIT. See `LICENSE` in the repository root.
|