@lark.js/mvc 0.0.5 → 0.0.6
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 +608 -721
- package/dist/{chunk-IIIY575B.js → chunk-3HSA7OHB.js} +6 -6
- package/dist/index.cjs +225 -924
- package/dist/index.d.cts +106 -182
- package/dist/index.d.ts +106 -182
- package/dist/index.js +221 -907
- package/dist/runtime.cjs +1 -1
- package/dist/runtime.js +1 -1
- package/dist/vite.cjs +6 -6
- package/dist/vite.js +1 -1
- package/dist/webpack.cjs +6 -6
- package/dist/webpack.js +1 -1
- package/package.json +22 -21
- package/src/client.d.ts +1 -0
package/README.md
CHANGED
|
@@ -1,131 +1,59 @@
|
|
|
1
|
-
|
|
2
|
-
name: lark-mvc
|
|
3
|
-
description: >
|
|
4
|
-
Comprehensive guide to the Lark MVC Framework (@lark.js/mvc) for building
|
|
5
|
-
TypeScript SPAs. Use this skill any time the user works with Lark — creating
|
|
6
|
-
Views with View.extend() or defineView(), defining reactive Stores with
|
|
7
|
-
defineStore() / computed() / multi(), wiring State for cross-view data,
|
|
8
|
-
setting up Router (including Router.beforeEach async guards), writing
|
|
9
|
-
HTML templates with {{=}}/{{forOf}}/{{if}}/@event/v-lark syntax, configuring
|
|
10
|
-
the Vite plugin or Webpack loader, registering Views with registerViewClass,
|
|
11
|
-
integrating Module Federation with CrossSite, calling Service for API
|
|
12
|
-
requests with caching/dedup/queue, or anything mentioning Frame trees,
|
|
13
|
-
hash routing, real-DOM diff, capture-phase event delegation, or the v-lark
|
|
14
|
-
attribute. Also trigger on Lark-related debugging (window.__lark_Debug,
|
|
15
|
-
Frame Visualizer Bridge, ldk/lak/lvk attributes) and on questions about
|
|
16
|
-
Lark's three data pipelines (Updater / State / Store) or migration patterns
|
|
17
|
-
between them.
|
|
18
|
-
---
|
|
1
|
+
## @lark.js/mvc
|
|
19
2
|
|
|
20
|
-
|
|
3
|
+
A TypeScript MVC framework designed for back-office single-page applications and micro-frontend scenarios.
|
|
21
4
|
|
|
22
|
-
`@lark.js/mvc`
|
|
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).
|
|
23
6
|
|
|
24
|
-
|
|
7
|
+
- Package: `@lark.js/mvc`
|
|
8
|
+
- Version: see `package.json` (currently 0.0.5)
|
|
9
|
+
- Entry points: `./` main entry, `./vite` build plugin, `./webpack` loader, `./runtime` template runtime
|
|
10
|
+
- Build: tsup, producing ESM + CJS + `.d.ts` in `dist/`
|
|
11
|
+
- Tests: vitest, 16 test files covering core modules
|
|
25
12
|
|
|
26
|
-
##
|
|
13
|
+
## Table of Contents
|
|
27
14
|
|
|
28
|
-
|
|
15
|
+
- Design Goals and Use Cases
|
|
16
|
+
- Installation and Build Tool Configuration
|
|
17
|
+
- Five-Minute Quick Start
|
|
18
|
+
- Three Data Pipelines: Updater / State / Store
|
|
19
|
+
- View Definition and Lifecycle
|
|
20
|
+
- Router and Route Guards
|
|
21
|
+
- Service Request Layer
|
|
22
|
+
- Template Syntax
|
|
23
|
+
- Frame and the View Tree
|
|
24
|
+
- Module Federation Micro-Frontend
|
|
25
|
+
- Debugging and DevTools Bridge
|
|
26
|
+
- Public API Reference
|
|
27
|
+
- Common Pitfalls
|
|
28
|
+
- Recent API Changes
|
|
29
|
+
- Comparison with Vue 3 / React 19
|
|
30
|
+
- Testing and Local Development
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
- Designing reactive state with `defineStore`, `cell`, `computed`, `multi`, or cross-view sharing through `State`.
|
|
32
|
-
- Routing tasks: hash navigation, route guards (`Router.beforeEach`), two-phase `change`/`changed` events, `Router.to(...)`.
|
|
33
|
-
- Authoring `.html` templates with the `{{=}}` / `{{forOf}}` / `{{if}}` / `@event` / `v-lark` syntax.
|
|
34
|
-
- Configuring the Vite plugin (`larkMvcPlugin`) or Webpack loader (`larkMvcLoader`).
|
|
35
|
-
- Embedding remote views via Module Federation (`CrossSite`, `FrameworkConfig.require`).
|
|
36
|
-
- API request layers using `Service.extend`, `Service.add`, `service.all/one/save`, `cleanKeys`.
|
|
37
|
-
- Debugging Frame trees, working with `window.__lark_Debug`, or the Frame Visualizer Bridge.
|
|
32
|
+
## Design Goals and Use Cases
|
|
38
33
|
|
|
39
|
-
|
|
34
|
+
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.
|
|
40
35
|
|
|
41
|
-
|
|
36
|
+
First, explicit layering. The Model layer provides `State` / `Store` (zustand-style) / `Service`, the View layer provides `View` / `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.
|
|
42
37
|
|
|
43
|
-
-
|
|
44
|
-
- **View**: `View.extend()` and the typed `defineView()` factory both produce View subclasses. Views own templates, event handlers, the lifecycle, the per-view `Updater`, and resource bookkeeping.
|
|
45
|
-
- **Controller**: `Router` (hash routing, two-phase change confirmation, `beforeEach` async guards), `Updater` (per-view data binding, change tracking, VDOM diff), `Frame` (the runtime tree of view containers, mount/unmount lifecycle, deferred `invoke` queue).
|
|
38
|
+
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.
|
|
46
39
|
|
|
47
|
-
|
|
40
|
+
Third, zero runtime dependencies. `@babel/parser` / `@babel/types` are used only at build time for template parsing. The runtime helper module `@lark.js/mvc/runtime` contains five functions (`strSafe` / `encHtml` / `encUri` / `encQuote` / `refFn`) and weighs approximately 1 KB as ESM.
|
|
48
41
|
|
|
49
|
-
|
|
42
|
+
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.
|
|
50
43
|
|
|
51
|
-
|
|
52
|
-
`updater.set(data)` → `updater.digest()` → compiled template function → HTML string → `vdomGetNode` parses it into a temporary DOM tree → `vdomSetChildNodes` diffs against the live DOM → DOM ops applied → `endUpdate()` notifies child frames.
|
|
44
|
+
Fifth, debug-friendly. `window.__lark_Debug = true` enables Safeguard Proxy protection against cross-page pollution and accidental writes. `installFrameVisualizerBridge` exposes the Frame tree to visual DevTools via `postMessage`. A set of `window.__lark_*` global shortcuts cover Framework / State / Router / Frame / View and HMR helpers.
|
|
53
45
|
|
|
54
|
-
|
|
55
|
-
`State.set(data)` → `State.digest()` → `changed` event fires with `keys: ReadonlySet<string>` → views listening read via `State.get()` in their `assign()` → standard Updater path. State uses key reference counting; pair with `mixins: [State.clean("a,b")]` so keys are removed when the last view unmounts.
|
|
46
|
+
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.
|
|
56
47
|
|
|
57
|
-
|
|
58
|
-
`store.key = value` → Proxy `set` trap → `trigger()` → `GlobalDeps` lookup → microtask-batched `Queue` → `store.observe` callbacks → standard Updater path. Supports `computed(deps, fn)` for derived state.
|
|
48
|
+
## Installation and Build Tool Configuration
|
|
59
49
|
|
|
60
|
-
###
|
|
61
|
-
|
|
62
|
-
`Framework.boot(config)` runs these steps in this exact order:
|
|
63
|
-
|
|
64
|
-
1. Merge user config into the shared `config` object.
|
|
65
|
-
2. Inject the merged config into `Router` via `Router._setConfig`.
|
|
66
|
-
3. Set the EventDelegator's frame getter so global events can find views.
|
|
67
|
-
4. Subscribe Router and State `changed` events to the dispatcher.
|
|
68
|
-
5. Mark Framework / Router / State as booted.
|
|
69
|
-
6. Install the Frame Visualizer Bridge (`postMessage` listener for DevTools).
|
|
70
|
-
7. **Create the root Frame with `Frame.createRoot(config.rootId)` BEFORE step 8.**
|
|
71
|
-
8. **Bind `Router._bind()` so hashchange/popstate/beforeunload fire — and Router.diff() runs once initially.**
|
|
72
|
-
9. Mount the `defaultView` ONLY if Router didn't already mount one (e.g., after a page reload with `#!/counter`).
|
|
73
|
-
|
|
74
|
-
The root must exist before `Router._bind()` because the initial `diff()` may immediately fire CHANGED → `dispatcherNotifyChange` → `Frame.getRoot()`. If the root didn't exist yet, Router would default to `"root"` and the view would render into the wrong element.
|
|
75
|
-
|
|
76
|
-
### Window globals
|
|
77
|
-
|
|
78
|
-
After boot, the framework attaches these to `window` for debugging and HMR:
|
|
79
|
-
|
|
80
|
-
| Global | Value | Purpose |
|
|
81
|
-
| ------------------------------------ | ---------------- | ------------------------------------- |
|
|
82
|
-
| `window.__lark_Framework` | Framework object | Direct framework access |
|
|
83
|
-
| `window.__lark_State` | State object | Direct state access |
|
|
84
|
-
| `window.__lark_Router` | Router object | Direct router access |
|
|
85
|
-
| `window.__lark_Frame` | Frame class | Direct Frame class access |
|
|
86
|
-
| `window.__lark_View` | View class | Direct View class access |
|
|
87
|
-
| `window.__lark_registerViewClass` | function | HMR helper: re-register a View class |
|
|
88
|
-
| `window.__lark_invalidateViewClass` | function | HMR helper: drop a View from registry |
|
|
89
|
-
| `window.__lark_getViewClassRegistry` | function | HMR helper: read the View registry |
|
|
90
|
-
| `window.__lark_Debug` | boolean (opt-in) | Enables Safeguard Proxy debug checks |
|
|
91
|
-
|
|
92
|
-
Set `window.__lark_Debug = true` before boot to enable Safeguard Proxy wrapping on `State.get()` reads, `Router.diff()` results, Location params, and `Updater.get()` — it warns when data set on one page is read from a different page, and when something tries to mutate `State.get()` data directly instead of going through `State.set()` + `State.digest()`.
|
|
93
|
-
|
|
94
|
-
## Project structure
|
|
95
|
-
|
|
96
|
-
```
|
|
97
|
-
project/
|
|
98
|
-
├─ index.html # entry, references <script type="module" src="/src/boot.ts">
|
|
99
|
-
├─ vite.config.ts # OR webpack.config.mjs
|
|
100
|
-
└─ src/
|
|
101
|
-
├─ boot.ts # registerViewClass(...) + Framework.boot(config)
|
|
102
|
-
├─ view.ts # project-wide base view (re-export of defineView/View.extend)
|
|
103
|
-
├─ styles.css
|
|
104
|
-
├─ store/
|
|
105
|
-
│ └─ count.ts # defineStore declarations
|
|
106
|
-
├─ views/
|
|
107
|
-
│ ├─ home.ts
|
|
108
|
-
│ ├─ home.html # compiled by larkMvcPlugin / larkMvcLoader
|
|
109
|
-
│ ├─ about.ts
|
|
110
|
-
│ └─ about.html
|
|
111
|
-
└─ components/ # sub-views embedded via v-lark
|
|
112
|
-
├─ counter-store.ts
|
|
113
|
-
└─ counter-store.html
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
`boot.ts` must live inside `src/` — `index.html` references it as `/src/boot.ts`. Putting it at the project root breaks the import resolution at runtime.
|
|
117
|
-
|
|
118
|
-
## Quick start
|
|
119
|
-
|
|
120
|
-
### 1. Install
|
|
50
|
+
### Installation
|
|
121
51
|
|
|
122
52
|
```bash
|
|
123
53
|
pnpm add @lark.js/mvc
|
|
124
54
|
```
|
|
125
55
|
|
|
126
|
-
###
|
|
127
|
-
|
|
128
|
-
Vite (recommended):
|
|
56
|
+
### Vite (Recommended)
|
|
129
57
|
|
|
130
58
|
```ts
|
|
131
59
|
// vite.config.ts
|
|
@@ -139,40 +67,42 @@ export default defineConfig({
|
|
|
139
67
|
});
|
|
140
68
|
```
|
|
141
69
|
|
|
142
|
-
|
|
70
|
+
`larkMvcPlugin()` registers at the `enforce: "pre"` stage: the `resolveId` hook appends a `?lark-template` suffix to `.html` imports to prevent Vite from treating them as static assets; the `load` hook calls `extractGlobalVars()` and `compileTemplate()` to compile templates into ES modules exporting a render function of the form `(data, viewId, refData) => string`. With `{ debug: true }`, source location markers are injected into the output so runtime errors can be traced back to the original HTML line.
|
|
143
71
|
|
|
144
|
-
|
|
72
|
+
### Webpack
|
|
145
73
|
|
|
146
74
|
```js
|
|
147
75
|
// webpack.config.mjs
|
|
148
76
|
import { larkMvcLoader } from "@lark.js/mvc/webpack";
|
|
149
77
|
|
|
150
78
|
export default {
|
|
151
|
-
// ...
|
|
152
79
|
module: {
|
|
153
80
|
rules: [
|
|
154
81
|
{ test: /\.ts$/, use: "ts-loader", exclude: /node_modules/ },
|
|
155
82
|
{
|
|
156
83
|
test: /\.html$/,
|
|
157
84
|
use: [{ loader: larkMvcLoader }],
|
|
158
|
-
exclude: /index\.html$/,
|
|
85
|
+
exclude: /index\.html$/,
|
|
159
86
|
},
|
|
160
87
|
],
|
|
161
88
|
},
|
|
162
89
|
};
|
|
163
90
|
```
|
|
164
91
|
|
|
165
|
-
|
|
92
|
+
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
|
+
Both 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`.
|
|
95
|
+
|
|
96
|
+
## Five-Minute Quick Start
|
|
166
97
|
|
|
167
|
-
###
|
|
98
|
+
### Entry HTML
|
|
168
99
|
|
|
169
100
|
```html
|
|
170
101
|
<!doctype html>
|
|
171
102
|
<html lang="en">
|
|
172
103
|
<head>
|
|
173
104
|
<meta charset="UTF-8" />
|
|
174
|
-
<
|
|
175
|
-
<title>My Lark App</title>
|
|
105
|
+
<title>Lark App</title>
|
|
176
106
|
</head>
|
|
177
107
|
<body>
|
|
178
108
|
<div id="app"></div>
|
|
@@ -181,9 +111,9 @@ Both integrations accept `{ debug: true }` to inject source-position markers int
|
|
|
181
111
|
</html>
|
|
182
112
|
```
|
|
183
113
|
|
|
184
|
-
|
|
114
|
+
`<div id="app">` corresponds to `rootId: "app"` in the boot configuration. `boot.ts` must reside in `src/` because the HTML references `/src/boot.ts`; placing it at the project root will cause a runtime resolution failure.
|
|
185
115
|
|
|
186
|
-
###
|
|
116
|
+
### Project-Level Base View
|
|
187
117
|
|
|
188
118
|
```ts
|
|
189
119
|
// src/view.ts
|
|
@@ -191,11 +121,8 @@ import { defineView, Router } from "@lark.js/mvc";
|
|
|
191
121
|
|
|
192
122
|
export default defineView({
|
|
193
123
|
make() {
|
|
194
|
-
// Called once per instance via the merged ctors[] pipeline.
|
|
195
124
|
this.updater.set({ appName: "My App" });
|
|
196
|
-
this.on("destroy", () => {
|
|
197
|
-
console.log(`View destroyed: ${this.id}`);
|
|
198
|
-
});
|
|
125
|
+
this.on("destroy", () => console.log(`view destroyed: ${this.id}`));
|
|
199
126
|
},
|
|
200
127
|
navigate(path: string, params?: Record<string, unknown>) {
|
|
201
128
|
Router.to(path, params);
|
|
@@ -203,9 +130,66 @@ export default defineView({
|
|
|
203
130
|
});
|
|
204
131
|
```
|
|
205
132
|
|
|
206
|
-
`defineView` is a
|
|
133
|
+
`defineView` is a typed wrapper around `View.extend`: via `ThisType<P & ViewInterface>` it threads the literal's own fields into `this`, so writing `this.appName` in `make` requires no cast. Runtime behavior is equivalent to `View.extend({...})`.
|
|
134
|
+
|
|
135
|
+
### View and Template
|
|
136
|
+
|
|
137
|
+
```html
|
|
138
|
+
<!-- src/views/home.html -->
|
|
139
|
+
<div>
|
|
140
|
+
<h1>{{=title}}</h1>
|
|
141
|
+
<p>Count: {{=count}}</p>
|
|
142
|
+
<button @click="incr()">+1</button>
|
|
143
|
+
{{if count > 0}}
|
|
144
|
+
<p>Positive</p>
|
|
145
|
+
{{else}}
|
|
146
|
+
<p>Zero or negative</p>
|
|
147
|
+
{{/if}}
|
|
148
|
+
<ul>
|
|
149
|
+
{{forOf items as item idx}}
|
|
150
|
+
<li id="item-{{=item.id}}">{{=idx}}: {{=item.name}}</li>
|
|
151
|
+
{{/forOf}}
|
|
152
|
+
</ul>
|
|
153
|
+
<div v-lark="components/counter-store"></div>
|
|
154
|
+
</div>
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// src/views/home.ts
|
|
159
|
+
import { bindStore } from "@lark.js/mvc";
|
|
160
|
+
import View from "../view";
|
|
161
|
+
import template from "./home.html";
|
|
162
|
+
import useCountStore from "../store/count";
|
|
163
|
+
|
|
164
|
+
export default View.extend({
|
|
165
|
+
template,
|
|
166
|
+
init() {
|
|
167
|
+
this.assign();
|
|
168
|
+
bindStore(this, useCountStore, (s) => ({ count: s.count }));
|
|
169
|
+
},
|
|
170
|
+
assign() {
|
|
171
|
+
this.updater.snapshot();
|
|
172
|
+
const { count } = useCountStore.getState();
|
|
173
|
+
this.updater.set({
|
|
174
|
+
title: "Home",
|
|
175
|
+
count,
|
|
176
|
+
items: [
|
|
177
|
+
{ id: "a", name: "Alpha" },
|
|
178
|
+
{ id: "b", name: "Beta" },
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
return this.updater.altered();
|
|
182
|
+
},
|
|
183
|
+
render() {
|
|
184
|
+
this.updater.digest();
|
|
185
|
+
},
|
|
186
|
+
"incr<click>"() {
|
|
187
|
+
useCountStore.getState().increment();
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
```
|
|
207
191
|
|
|
208
|
-
###
|
|
192
|
+
### Boot
|
|
209
193
|
|
|
210
194
|
```ts
|
|
211
195
|
// src/boot.ts
|
|
@@ -228,7 +212,7 @@ const config: FrameworkConfig = {
|
|
|
228
212
|
"/about": "about",
|
|
229
213
|
},
|
|
230
214
|
unmatchedView: "404",
|
|
231
|
-
error(e
|
|
215
|
+
error(e) {
|
|
232
216
|
console.error("Lark error:", e);
|
|
233
217
|
},
|
|
234
218
|
};
|
|
@@ -236,397 +220,357 @@ const config: FrameworkConfig = {
|
|
|
236
220
|
Framework.boot(config);
|
|
237
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 Visualizer 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` if Router has not mounted a view. 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.
|
|
240
224
|
|
|
241
|
-
##
|
|
225
|
+
## Three Data Pipelines: Updater / State / Store
|
|
242
226
|
|
|
243
|
-
|
|
227
|
+
Lark provides three data flow mechanisms simultaneously, ranging from simple to complex, corresponding to "view-private", "lightweight cross-view sharing", and "complex reactive cross-view sharing". Choose the simplest approach that meets your needs to reduce cognitive overhead.
|
|
244
228
|
|
|
245
|
-
|
|
246
|
-
// src/store/count.ts
|
|
247
|
-
import { defineStore } from "@lark.js/mvc";
|
|
229
|
+
### Updater: View-Private
|
|
248
230
|
|
|
249
|
-
|
|
250
|
-
count: number;
|
|
251
|
-
step: number;
|
|
252
|
-
doubled: number; // computed
|
|
253
|
-
history: string[];
|
|
254
|
-
increment: () => void;
|
|
255
|
-
decrement: () => void;
|
|
256
|
-
reset: () => void;
|
|
257
|
-
}
|
|
231
|
+
`Updater` is each View's local data manager. All intra-view data flow ultimately goes through the Updater:
|
|
258
232
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
count: 0,
|
|
263
|
-
step: 1,
|
|
264
|
-
doubled: computed(["count"], () => store.count * 2),
|
|
265
|
-
history: [] as string[],
|
|
266
|
-
increment() {
|
|
267
|
-
store.count = store.count + store.step;
|
|
268
|
-
store.history = [...store.history, `+${store.step} -> ${store.count}`];
|
|
269
|
-
},
|
|
270
|
-
decrement() {
|
|
271
|
-
store.count = store.count - store.step;
|
|
272
|
-
store.history = [...store.history, `-${store.step} -> ${store.count}`];
|
|
273
|
-
},
|
|
274
|
-
reset() {
|
|
275
|
-
store.count = 0;
|
|
276
|
-
store.history = [];
|
|
277
|
-
},
|
|
278
|
-
}),
|
|
279
|
-
);
|
|
280
|
-
|
|
281
|
-
export default useCountStore;
|
|
233
|
+
```ts
|
|
234
|
+
this.updater.set({ count: newCount });
|
|
235
|
+
this.updater.digest();
|
|
282
236
|
```
|
|
283
237
|
|
|
284
|
-
|
|
238
|
+
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. `vdomGetNode` uses `tmp.innerHTML = wrap + html` to parse it into temporary DOM. `vdomSetChildNodes` compares against the live DOM to produce a keyed diff. DOM operations are applied in batch. `endUpdate()` notifies child Frames to complete mounting.
|
|
285
239
|
|
|
286
|
-
|
|
240
|
+
Supports digest re-entry: calling `updater.digest()` during an active digest does not nest; instead it queues to `digestingQueue` and executes after the current digest completes. `null` serves as a digest boundary sentinel in the queue.
|
|
287
241
|
|
|
288
|
-
|
|
289
|
-
- `computed(deps, fn)` markers occupy a reactive state slot. After every other state key is initialized, `computed` fires its `fn()` to produce the initial value, then registers an internal `track` so the dep keys re-run `fn` when they change. Writes to a computed key are silently ignored.
|
|
290
|
-
- Everything else becomes initial state and is registered with the reactive Proxy.
|
|
242
|
+
### State: Lightweight Cross-View
|
|
291
243
|
|
|
292
|
-
|
|
244
|
+
`State` is a global singleton key-value container, suitable for lightweight shared values like page title, login info, or current theme:
|
|
293
245
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
- Reading a state key (e.g. `store.count`) outside the creator returns a **deep-cloned copy** (via the native `structuredClone` when available). Mutating the returned value does NOT trigger reactivity.
|
|
297
|
-
- Writing a state key (`store.count = 5`) goes through the reactive Proxy and triggers observers.
|
|
298
|
-
- **Inside the creator**, `store.count` returns the raw reactive Proxy — direct mutation works and is reactive.
|
|
246
|
+
```ts
|
|
247
|
+
import { State } from "@lark.js/mvc";
|
|
299
248
|
|
|
300
|
-
|
|
249
|
+
State.set({ pageTitle: "Home", isLoggedIn: true });
|
|
250
|
+
State.digest();
|
|
251
|
+
```
|
|
301
252
|
|
|
302
|
-
|
|
253
|
+
Subscription has two approaches. First, declare `observeState` in a view, and the framework automatically re-renders when the corresponding keys change:
|
|
303
254
|
|
|
304
255
|
```ts
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
256
|
+
export default View.extend({
|
|
257
|
+
template,
|
|
258
|
+
observeState: "pageTitle,isLoggedIn",
|
|
259
|
+
assign() {
|
|
260
|
+
this.updater.snapshot();
|
|
261
|
+
this.updater.set({
|
|
262
|
+
title: State.get("pageTitle"),
|
|
263
|
+
logged: State.get("isLoggedIn"),
|
|
264
|
+
});
|
|
265
|
+
return this.updater.altered();
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
```
|
|
308
269
|
|
|
309
|
-
|
|
310
|
-
store.observe(this, ["count", "step"]);
|
|
270
|
+
Second, listen directly to the `changed` event where `e.keys` is a `ReadonlySet<string>`:
|
|
311
271
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
console.log("
|
|
272
|
+
```ts
|
|
273
|
+
State.on("changed", (e) => {
|
|
274
|
+
if (e.keys?.has("pageTitle")) console.log("Title changed");
|
|
315
275
|
});
|
|
276
|
+
```
|
|
316
277
|
|
|
317
|
-
|
|
318
|
-
store.observe(this, [
|
|
319
|
-
{ key: "count", alias: "currentCount", lazy: false },
|
|
320
|
-
{
|
|
321
|
-
key: "items",
|
|
322
|
-
transform: (val) => ({ itemCount: (val as unknown[]).length }),
|
|
323
|
-
},
|
|
324
|
-
]);
|
|
278
|
+
State manages lifecycle through reference counting on keys. Best practice is to add a `State.clean` mixin to all consumers, ensuring that when the last observer is destroyed, the key is automatically reclaimed:
|
|
325
279
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
280
|
+
```ts
|
|
281
|
+
export default View.extend({
|
|
282
|
+
mixins: [State.clean("pageTitle,isLoggedIn")],
|
|
283
|
+
template,
|
|
330
284
|
});
|
|
331
285
|
```
|
|
332
286
|
|
|
333
|
-
|
|
287
|
+
Without cleanup, keys persist on global State causing leaks.
|
|
334
288
|
|
|
335
|
-
|
|
289
|
+
### Store: Zustand-Style State Management
|
|
336
290
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
When a component is reused N times and each instance needs its own state:
|
|
291
|
+
The Store API aligns with zustand's design: `create(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.
|
|
340
292
|
|
|
341
293
|
```ts
|
|
342
|
-
|
|
294
|
+
// src/store/count.ts
|
|
295
|
+
import { create, computed } from "@lark.js/mvc";
|
|
343
296
|
|
|
344
|
-
|
|
297
|
+
interface CountStore {
|
|
298
|
+
count: number;
|
|
299
|
+
step: number;
|
|
300
|
+
doubled: number;
|
|
301
|
+
history: string[];
|
|
302
|
+
increment: () => void;
|
|
303
|
+
decrement: () => void;
|
|
304
|
+
reset: () => void;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const useCountStore = create<CountStore>("count", (set, get) => ({
|
|
345
308
|
count: 0,
|
|
309
|
+
step: 1,
|
|
310
|
+
doubled: computed(["count"], () => get().count * 2),
|
|
311
|
+
history: [] as string[],
|
|
346
312
|
increment() {
|
|
347
|
-
|
|
313
|
+
const { count, step } = get();
|
|
314
|
+
set({
|
|
315
|
+
count: count + step,
|
|
316
|
+
history: [...get().history, `+${step} -> ${count + step}`],
|
|
317
|
+
});
|
|
318
|
+
},
|
|
319
|
+
decrement() {
|
|
320
|
+
const { count, step } = get();
|
|
321
|
+
set({ count: count - step });
|
|
322
|
+
},
|
|
323
|
+
reset() {
|
|
324
|
+
set({ count: 0, history: [] });
|
|
348
325
|
},
|
|
349
326
|
}));
|
|
350
327
|
|
|
351
|
-
|
|
352
|
-
const [useMultiCounter, counterMixin] = multi(useCounterStore);
|
|
353
|
-
|
|
354
|
-
export default defineView({
|
|
355
|
-
mixins: [counterMixin], // its make() stamps a per-instance flag onto the view
|
|
356
|
-
template,
|
|
357
|
-
init() {
|
|
358
|
-
const store = useMultiCounter(this); // each instance gets its own store clone
|
|
359
|
-
store.observe(this, ["count"]);
|
|
360
|
-
},
|
|
361
|
-
});
|
|
328
|
+
export default useCountStore;
|
|
362
329
|
```
|
|
363
330
|
|
|
364
|
-
`
|
|
331
|
+
The creator function receives `(set, get)` and executes once during `create`. 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.
|
|
365
332
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
For one-off reactive values that don't need a full store:
|
|
333
|
+
Reading and writing state:
|
|
369
334
|
|
|
370
335
|
```ts
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
const count = cell({ value: 0 });
|
|
374
|
-
const off = observeCell(count, () => console.log("changed:", count.value));
|
|
375
|
-
count.value = 1; // triggers callback
|
|
376
|
-
off(); // clean up
|
|
377
|
-
```
|
|
378
|
-
|
|
379
|
-
`cell(data)` creates a Proxy with `belong: "lark-global"` and a unique `linkKeys`. `observeCell(state, cb, immediate?)` registers a tracker (defaults to firing immediately).
|
|
336
|
+
// Read
|
|
337
|
+
const { count, step } = useCountStore.getState();
|
|
380
338
|
|
|
381
|
-
|
|
339
|
+
// Write (shallow merge)
|
|
340
|
+
useCountStore.setState({ count: 5 });
|
|
341
|
+
useCountStore.setState((prev) => ({ count: prev.count + 1 }));
|
|
382
342
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
```html
|
|
386
|
-
<!-- src/views/home.html -->
|
|
387
|
-
<div>
|
|
388
|
-
<h1>{{=title}}</h1>
|
|
389
|
-
<div>Count: {{=count}}</div>
|
|
390
|
-
<button @click="navigateTo({path: '/about'})">About</button>
|
|
391
|
-
|
|
392
|
-
{{if count > 0}}
|
|
393
|
-
<p>Positive</p>
|
|
394
|
-
{{else}}
|
|
395
|
-
<p>Zero or negative</p>
|
|
396
|
-
{{/if}}
|
|
397
|
-
|
|
398
|
-
<ul>
|
|
399
|
-
{{forOf items as item idx}}
|
|
400
|
-
<li id="item-{{=item.id}}">{{=idx}}: {{=item.name}}</li>
|
|
401
|
-
{{/forOf}}
|
|
402
|
-
</ul>
|
|
403
|
-
|
|
404
|
-
<!-- Sub-view embedding -->
|
|
405
|
-
<div v-lark="components/child"></div>
|
|
406
|
-
</div>
|
|
343
|
+
// Call action
|
|
344
|
+
useCountStore.getState().increment();
|
|
407
345
|
```
|
|
408
346
|
|
|
409
|
-
|
|
347
|
+
Binding in a view:
|
|
410
348
|
|
|
411
349
|
```ts
|
|
412
|
-
|
|
413
|
-
import { Router } from "@lark.js/mvc";
|
|
414
|
-
import View from "../view"; // project-level base
|
|
415
|
-
import template from "./home.html";
|
|
416
|
-
import useCountStore from "../store/count";
|
|
350
|
+
import { bindStore } from "@lark.js/mvc";
|
|
417
351
|
|
|
418
352
|
export default View.extend({
|
|
419
353
|
template,
|
|
420
|
-
|
|
421
354
|
init() {
|
|
422
|
-
|
|
355
|
+
// Bind all non-function state keys to view updater; auto-unsubscribes on destroy
|
|
356
|
+
bindStore(this, useCountStore);
|
|
423
357
|
|
|
424
|
-
|
|
425
|
-
|
|
358
|
+
// Or use a selector to sync only specific keys
|
|
359
|
+
bindStore(this, useCountStore, (s) => ({ count: s.count }));
|
|
426
360
|
},
|
|
361
|
+
"increment<click>"() {
|
|
362
|
+
useCountStore.getState().increment();
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
```
|
|
427
366
|
|
|
428
|
-
|
|
429
|
-
// Always call snapshot() at the top and return altered() at the end
|
|
430
|
-
// so the framework knows whether a re-digest is needed.
|
|
431
|
-
assign() {
|
|
432
|
-
this.updater.snapshot();
|
|
367
|
+
Custom subscription callback (when data transformation is needed before sync):
|
|
433
368
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
return this.updater.altered();
|
|
446
|
-
},
|
|
369
|
+
```ts
|
|
370
|
+
init() {
|
|
371
|
+
const syncToView = () => {
|
|
372
|
+
const s = useCountStore.getState();
|
|
373
|
+
this.updater.digest({ count: s.count, isPositive: s.count > 0 });
|
|
374
|
+
};
|
|
375
|
+
const off = useCountStore.subscribe(syncToView);
|
|
376
|
+
this.on("destroy", off);
|
|
377
|
+
syncToView();
|
|
378
|
+
}
|
|
379
|
+
```
|
|
447
380
|
|
|
448
|
-
|
|
449
|
-
// The default implementation calls this.updater.digest().
|
|
450
|
-
render() {
|
|
451
|
-
this.updater.digest();
|
|
452
|
-
},
|
|
381
|
+
Destroying a store:
|
|
453
382
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
const params = e["params"] as Record<string, string> | undefined;
|
|
457
|
-
if (params?.path) Router.to(params.path);
|
|
458
|
-
},
|
|
459
|
-
});
|
|
383
|
+
```ts
|
|
384
|
+
useCountStore.destroy(); // Clears all listeners, removes from registry
|
|
460
385
|
```
|
|
461
386
|
|
|
462
|
-
###
|
|
387
|
+
### Comparison
|
|
463
388
|
|
|
464
|
-
|
|
389
|
+
| Dimension | State | Store |
|
|
390
|
+
|-----------|-------|-------|
|
|
391
|
+
| Write | `State.set(...)` + `State.digest()` | `store.setState(partial)` or action |
|
|
392
|
+
| Read | `State.get(key)` | `store.getState()` |
|
|
393
|
+
| Subscribe | `observeState` or `on("changed")` | `store.subscribe(listener)` or `bindStore` |
|
|
394
|
+
| View binding | `observeState("keys")` | `bindStore(view, store, selector?)` |
|
|
395
|
+
| Lifecycle | `State.clean` mixin auto-reclaims keys | `store.destroy()` manual teardown |
|
|
396
|
+
| Derived data | Not supported | `computed(deps, fn)` |
|
|
397
|
+
| Use case | Page title, login state, theme | Business entities, forms, complex cross-view state |
|
|
465
398
|
|
|
466
|
-
|
|
467
|
-
| -------------------------- | ------------------------------------------------- |
|
|
468
|
-
| `handler<click>` | Root event on the view element |
|
|
469
|
-
| `$selector<click>` | Delegated event matching CSS selector `.selector` |
|
|
470
|
-
| `$<click>` | Empty selector — frame boundary event only |
|
|
471
|
-
| `$window<resize>` | Global event on `window` |
|
|
472
|
-
| `$document<keydown>` | Global event on `document` |
|
|
473
|
-
| `handler<click,mousedown>` | Multi-event binding |
|
|
474
|
-
| `name<click><ctrl>` | Modifier filter — only fires when Ctrl is pressed |
|
|
399
|
+
Selection guide: start with State; upgrade to Store when you need actions, derived data, or fine-grained subscriptions; view-private data always goes through Updater.
|
|
475
400
|
|
|
476
|
-
|
|
401
|
+
## View Definition and Lifecycle
|
|
477
402
|
|
|
478
|
-
|
|
479
|
-
- `e.params` — parsed parameters from `@event` attributes (URL query string format).
|
|
480
|
-
- All standard DOM Event properties (`type`, `target`, etc.).
|
|
403
|
+
### Two Definition Approaches
|
|
481
404
|
|
|
482
|
-
|
|
405
|
+
`View.extend({...})` is the low-level primitive approach where all mixins, event methods, and lifecycle hooks are declared in the passed object:
|
|
483
406
|
|
|
484
|
-
|
|
407
|
+
```ts
|
|
408
|
+
import { View } from "@lark.js/mvc";
|
|
485
409
|
|
|
486
|
-
|
|
410
|
+
export default View.extend({
|
|
411
|
+
template,
|
|
412
|
+
init() { /* ... */ },
|
|
413
|
+
assign() { /* ... */ },
|
|
414
|
+
render() { /* ... */ },
|
|
415
|
+
});
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
`defineView({...})` is a typed wrapper that threads the literal's own fields into `this` via `ThisType<P & ViewInterface>`:
|
|
487
419
|
|
|
488
420
|
```ts
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
destroy() {
|
|
496
|
-
clearInterval(timer);
|
|
497
|
-
},
|
|
421
|
+
import { defineView } from "@lark.js/mvc";
|
|
422
|
+
|
|
423
|
+
export default defineView({
|
|
424
|
+
customField: "x",
|
|
425
|
+
init() {
|
|
426
|
+
console.log(this.customField);
|
|
498
427
|
},
|
|
499
|
-
|
|
500
|
-
);
|
|
501
|
-
// destroyOnRender=true: destroyed on next render call
|
|
502
|
-
// destroyOnRender=false: destroyed only on view destroy
|
|
428
|
+
});
|
|
503
429
|
```
|
|
504
430
|
|
|
505
|
-
|
|
431
|
+
Both produce equivalent runtime artifacts; the difference is purely in TypeScript inference.
|
|
506
432
|
|
|
507
|
-
###
|
|
433
|
+
### Lifecycle
|
|
508
434
|
|
|
509
|
-
|
|
435
|
+
- `init(params?)` — Called when the view is first instantiated. `params` comes from query strings on `v-lark`. Read stores and call `this.assign()` to prepare initial data here.
|
|
436
|
+
- `make()` — Called by the merged ctors pipeline; each mixin's `make` executes in order. Suitable for "run once per instance" initialization.
|
|
437
|
+
- `assign()` — Should be called when data may have changed. Pattern: `this.updater.snapshot()` at the top, `this.updater.set(...)` in the middle, `return this.updater.altered()` at the end. The framework uses `altered()` to determine whether re-render is needed.
|
|
438
|
+
- `render()` — Default implementation is `this.updater.digest()`. Wrapped by `View.wrapMethod`: increments signature on entry, handles pending endUpdate cleanup on exit.
|
|
439
|
+
- Destruction — The framework automatically calls `release(key, true)` to release all `capture`d resources, cleans up event delegation, and sets signature to 0.
|
|
510
440
|
|
|
511
|
-
|
|
512
|
-
async loadData() {
|
|
513
|
-
const safeCallback = this.wrapAsync((data) => {
|
|
514
|
-
this.updater.set({ items: data }).digest();
|
|
515
|
-
});
|
|
516
|
-
const data = await fetch("/api/items").then(r => r.json());
|
|
517
|
-
safeCallback(data); // no-op if view re-rendered or destroyed
|
|
518
|
-
}
|
|
519
|
-
```
|
|
441
|
+
`view.signature` marks async operation validity: greater than 0 means the view is alive (incremented on each render), 0 means destroyed. Never modify it manually.
|
|
520
442
|
|
|
521
|
-
###
|
|
443
|
+
### Event Methods
|
|
522
444
|
|
|
523
|
-
|
|
524
|
-
// In a view:
|
|
525
|
-
this.observeLocation("page,size", true); // params + observePath
|
|
526
|
-
this.observeLocation(["page", "size"]); // array form
|
|
527
|
-
this.observeLocation({ params: ["page"], path: true }); // object form
|
|
528
|
-
```
|
|
445
|
+
Event methods are named `name<eventType>` or `$selector<eventType>`. `View.prepare` scans the prototype at class definition time, parsing methods into three maps (`$evtObjMap` / `$selMap` / `$globalEvtList`) written to the prototype, managed at runtime by `EventDelegator`.
|
|
529
446
|
|
|
530
|
-
|
|
447
|
+
| Syntax | Meaning |
|
|
448
|
+
|--------|---------|
|
|
449
|
+
| `handler<click>` | Event on the view's root element |
|
|
450
|
+
| `$selector<click>` | Delegated to child elements matching `.selector` |
|
|
451
|
+
| `$<click>` | Empty selector, triggers Frame boundary event only |
|
|
452
|
+
| `$window<resize>` | Delegated to `window` |
|
|
453
|
+
| `$document<keydown>` | Delegated to `document` |
|
|
454
|
+
| `handler<click,mousedown>` | Multi-event binding |
|
|
455
|
+
| `name<click><ctrl>` | Fires only when Ctrl modifier is held |
|
|
531
456
|
|
|
532
|
-
|
|
457
|
+
The event callback receives an object `e` that, beyond standard Event fields, provides `e.eventTarget` (the actual hit DOM element) and `e.params` (parsed from the `@event` parameter string). Multiple mixins defining the same event method name are merged into a handler chain called in mixin order.
|
|
533
458
|
|
|
534
|
-
|
|
535
|
-
this.observeState("count,step"); // comma-separated
|
|
536
|
-
this.observeState(["count", "step"]); // array
|
|
537
|
-
```
|
|
459
|
+
Event delegation implementation: `EventDelegator` attaches listeners on `document.body` in the capture phase. When an event fires, it walks from `e.target` up to body, calling `findFrameInfo` at each level to locate the owning View and filter handlers by selector. Reference counting manages addition/removal of same-name events on body to prevent duplicate binding or premature unbinding.
|
|
538
460
|
|
|
539
|
-
|
|
461
|
+
### Resource Management
|
|
540
462
|
|
|
541
|
-
|
|
463
|
+
`capture` registers "destroyable objects tied to the view lifecycle":
|
|
542
464
|
|
|
543
|
-
```
|
|
544
|
-
|
|
465
|
+
```ts
|
|
466
|
+
const timer = setInterval(tick, 1000);
|
|
467
|
+
this.capture("myTimer", { destroy() { clearInterval(timer); } }, true);
|
|
545
468
|
```
|
|
546
469
|
|
|
547
|
-
|
|
470
|
+
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.
|
|
548
471
|
|
|
549
|
-
|
|
472
|
+
### Async Safety
|
|
550
473
|
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
`Framework.boot(config)` config accepts:
|
|
474
|
+
Async callbacks may arrive after a view has re-rendered or been destroyed. `wrapAsync` adds a signature check layer:
|
|
554
475
|
|
|
555
476
|
```ts
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
unmatchedView?: string; // 404 view path
|
|
563
|
-
rewrite?: (path, params, routes) => string; // dynamic path rewriting
|
|
564
|
-
error?: (e: Error) => void; // global error handler (do not re-throw)
|
|
565
|
-
extensions?: string[]; // extension view paths loaded at startup
|
|
566
|
-
initModule?: string; // init module path
|
|
567
|
-
skipViewRendered?: boolean;
|
|
568
|
-
projectName?: string; // for Module Federation discriminator
|
|
569
|
-
crossConfigs?: CrossSiteConfig[]; // MF remote configs
|
|
570
|
-
require?: (names: string[], params?) => Promise<unknown[]>; // async View loader
|
|
571
|
-
[k: string]: unknown; // custom keys are allowed
|
|
477
|
+
async loadData() {
|
|
478
|
+
const safe = this.wrapAsync((data: unknown) => {
|
|
479
|
+
this.updater.set({ items: data }).digest();
|
|
480
|
+
});
|
|
481
|
+
const data = await fetch("/api/items").then((r) => r.json());
|
|
482
|
+
safe(data); // Will not execute if view has re-rendered or been destroyed
|
|
572
483
|
}
|
|
573
484
|
```
|
|
574
485
|
|
|
575
|
-
|
|
486
|
+
`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.
|
|
487
|
+
|
|
488
|
+
## Router and Route Guards
|
|
576
489
|
|
|
577
|
-
|
|
490
|
+
Router supports two routing modes, configured via `FrameworkConfig.routeMode`:
|
|
578
491
|
|
|
579
|
-
|
|
492
|
+
- `"history"` (default): uses `history.pushState` / `popstate`, URLs like `/home?page=2`
|
|
493
|
+
- `"hash"`: uses URL hash fragment, URLs like `#!/home?page=2`
|
|
494
|
+
|
|
495
|
+
All state parses into a single `Location` object; cache hits skip parsing.
|
|
496
|
+
|
|
497
|
+
### Basic Usage
|
|
580
498
|
|
|
581
499
|
```ts
|
|
582
|
-
|
|
583
|
-
Router.to({ page: 3 }); // params-only — keeps current path
|
|
584
|
-
Router.to("/list", { page: 2 }, true); // replace (no history entry)
|
|
585
|
-
Router.to("/list", { page: 2 }, false, true); // silent (no events)
|
|
586
|
-
```
|
|
500
|
+
import { Router } from "@lark.js/mvc";
|
|
587
501
|
|
|
588
|
-
|
|
502
|
+
Router.to("/list", { page: 2 }); // path + params
|
|
503
|
+
Router.to({ page: 3 }); // params only
|
|
504
|
+
Router.to("/list", { page: 2 }, true); // replace mode
|
|
505
|
+
Router.to("/list", { page: 2 }, false, true); // silent, no events
|
|
506
|
+
```
|
|
589
507
|
|
|
590
508
|
```ts
|
|
591
|
-
const loc = Router.parse();
|
|
592
|
-
const
|
|
593
|
-
|
|
594
|
-
const diff = Router.diff(); // last LocationDiff or undefined
|
|
509
|
+
const loc = Router.parse(); // current Location
|
|
510
|
+
const loc2 = Router.parse("https://x/?a=1#!/path?p=v");
|
|
511
|
+
const diff = Router.diff(); // most recent LocationDiff
|
|
595
512
|
```
|
|
596
513
|
|
|
597
|
-
|
|
514
|
+
`Location` provides `path` / `params` / `hash` / `query` / `view` and a `get(key, defaultValue?)` method.
|
|
515
|
+
|
|
516
|
+
### Two-Phase Change Event
|
|
598
517
|
|
|
599
518
|
```ts
|
|
600
519
|
Router.on("change", (e) => {
|
|
601
|
-
if (hasUnsavedChanges)
|
|
602
|
-
|
|
603
|
-
else
|
|
604
|
-
e.reject(); // revert URL to lastHash
|
|
605
|
-
else e.resolve(); // commit (auto if neither called)
|
|
520
|
+
if (hasUnsavedChanges) e.prevent();
|
|
521
|
+
else if (mustReject) e.reject();
|
|
522
|
+
else e.resolve();
|
|
606
523
|
});
|
|
607
524
|
Router.on("changed", (diff) => {
|
|
608
|
-
// diff
|
|
525
|
+
// diff: LocationDiff { params, path?, view?, force, changed }
|
|
609
526
|
});
|
|
610
527
|
```
|
|
611
528
|
|
|
612
|
-
|
|
529
|
+
The `change` phase allows `prevent` (suspend further processing), `reject` (rollback URL to `lastHash`), or `resolve` (commit; if none is called explicitly, resolve is the default). The `changed` phase is the final notification where the framework re-mounts views.
|
|
530
|
+
|
|
531
|
+
### Async Route Guards
|
|
613
532
|
|
|
614
533
|
```ts
|
|
615
534
|
const off = Router.beforeEach(async (to, from) => {
|
|
616
535
|
if (to.path === "/admin") {
|
|
617
536
|
const ok = await checkPermission();
|
|
618
|
-
return ok;
|
|
537
|
+
return ok;
|
|
619
538
|
}
|
|
620
|
-
return true;
|
|
539
|
+
return true;
|
|
540
|
+
});
|
|
541
|
+
// Unregister
|
|
542
|
+
off();
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Guards execute in registration order. Any guard that returns/resolves to `false`, throws, or rejects will abort the navigation and rollback the URL. Returning `true` / `undefined` / any non-`false` value allows passage.
|
|
546
|
+
|
|
547
|
+
### useUrlState: URL Parameter State Sync
|
|
548
|
+
|
|
549
|
+
`useUrlState(view, 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.
|
|
550
|
+
|
|
551
|
+
```ts
|
|
552
|
+
import { useUrlState } from "@lark.js/mvc";
|
|
553
|
+
|
|
554
|
+
export default View.extend({
|
|
555
|
+
template,
|
|
556
|
+
init() {
|
|
557
|
+
const [state, setState] = useUrlState(this, { page: "1", size: "20" });
|
|
558
|
+
this.updater.set({ page: state.page, size: state.size }).digest();
|
|
559
|
+
this.setPageState = setState;
|
|
560
|
+
},
|
|
561
|
+
"nextPage<click>"() {
|
|
562
|
+
this.setPageState((prev) => ({ page: String(Number(prev.page) + 1) }));
|
|
563
|
+
},
|
|
621
564
|
});
|
|
622
|
-
// later, off() to unsubscribe
|
|
623
565
|
```
|
|
624
566
|
|
|
625
|
-
|
|
567
|
+
Supports both history and hash routing modes.
|
|
568
|
+
|
|
569
|
+
## Service Request Layer
|
|
626
570
|
|
|
627
|
-
|
|
571
|
+
`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.
|
|
628
572
|
|
|
629
|
-
|
|
573
|
+
### Defining Subclasses and Endpoints
|
|
630
574
|
|
|
631
575
|
```ts
|
|
632
576
|
import { Service, type Payload } from "@lark.js/mvc";
|
|
@@ -636,19 +580,14 @@ const AppService = Service.extend(
|
|
|
636
580
|
fetch(payload.get<string>("url"), {
|
|
637
581
|
method: payload.get<string>("method") || "GET",
|
|
638
582
|
headers: { "Content-Type": "application/json" },
|
|
639
|
-
body: payload.get("data")
|
|
640
|
-
? JSON.stringify(payload.get("data"))
|
|
641
|
-
: undefined,
|
|
583
|
+
body: payload.get("data") ? JSON.stringify(payload.get("data")) : undefined,
|
|
642
584
|
})
|
|
643
585
|
.then((r) => r.json())
|
|
644
|
-
.then((data) => {
|
|
645
|
-
payload.set(data);
|
|
646
|
-
callback();
|
|
647
|
-
})
|
|
586
|
+
.then((data) => { payload.set(data); callback(); })
|
|
648
587
|
.catch(() => callback());
|
|
649
588
|
},
|
|
650
589
|
20, // cacheMax
|
|
651
|
-
5,
|
|
590
|
+
5, // cacheBuffer
|
|
652
591
|
);
|
|
653
592
|
|
|
654
593
|
AppService.add([
|
|
@@ -658,94 +597,83 @@ AppService.add([
|
|
|
658
597
|
url: "/api/users/:id",
|
|
659
598
|
cache: 30_000,
|
|
660
599
|
before(payload) {
|
|
661
|
-
payload.set(
|
|
662
|
-
"url",
|
|
663
|
-
payload.get<string>("url").replace(":id", payload.get<string>("id")),
|
|
664
|
-
);
|
|
600
|
+
payload.set("url", payload.get<string>("url").replace(":id", payload.get<string>("id")));
|
|
665
601
|
},
|
|
666
602
|
after(payload) {
|
|
667
603
|
const data = payload.get("data");
|
|
668
604
|
payload.set({ formatted: formatUser(data) });
|
|
669
605
|
},
|
|
670
|
-
cleanKeys: "userList",
|
|
606
|
+
cleanKeys: "userList",
|
|
671
607
|
},
|
|
672
608
|
]);
|
|
673
609
|
```
|
|
674
610
|
|
|
675
|
-
###
|
|
676
|
-
|
|
677
|
-
`Service.extend()` produces a subclass with its own `_metaList`, `_payloadCache`, `_pendingCacheKeys`, `_syncFn`, `_staticEmitter`, `_cacheMax`, and `_cacheBuffer` via `static override`. This isolation is intentional — endpoints registered on one subclass never leak into another. Refactoring these `static override` declarations away would break the isolation.
|
|
678
|
-
|
|
679
|
-
### Using a service in a view
|
|
611
|
+
### Using in Views
|
|
680
612
|
|
|
681
613
|
```ts
|
|
682
614
|
export default View.extend({
|
|
683
615
|
template,
|
|
684
616
|
init() {
|
|
685
617
|
const service = new AppService();
|
|
686
|
-
this.capture("userService", service, true);
|
|
618
|
+
this.capture("userService", service, true);
|
|
687
619
|
this.service = service;
|
|
688
620
|
this.loadData();
|
|
689
621
|
},
|
|
690
622
|
loadData() {
|
|
691
|
-
this.service.all("userList", (errors,
|
|
692
|
-
if (!errors[0]) {
|
|
693
|
-
this.updater.set({ users: userListPayload.get("data") }).digest();
|
|
694
|
-
}
|
|
695
|
-
});
|
|
696
|
-
},
|
|
697
|
-
refreshDetail(id: string) {
|
|
698
|
-
// save() bypasses cache, always makes a real request.
|
|
699
|
-
this.service.save({ name: "userDetail", id }, (errors, payload) => {
|
|
623
|
+
this.service.all("userList", (errors, payload) => {
|
|
700
624
|
if (!errors[0]) {
|
|
701
|
-
this.updater.set({
|
|
625
|
+
this.updater.set({ users: payload.get("data") }).digest();
|
|
702
626
|
}
|
|
703
627
|
});
|
|
704
628
|
},
|
|
705
629
|
});
|
|
706
630
|
```
|
|
707
631
|
|
|
708
|
-
|
|
632
|
+
| Method | Behavior |
|
|
633
|
+
|--------|----------|
|
|
634
|
+
| `service.all(attrs, done)` | Fetch all endpoints; callback `(errors, p1, p2, ...)` when all complete |
|
|
635
|
+
| `service.one(attrs, done)` | Fetch all endpoints; callback `(error, payload, isLast, index)` on each completion |
|
|
636
|
+
| `service.save(attrs, done)` | Same as `all` but skips cache, always makes a fresh request |
|
|
637
|
+
| `service.enqueue(task)` | Add to serial queue |
|
|
638
|
+
| `service.dequeue(...args)` | Take one item and execute |
|
|
639
|
+
| `service.destroy()` | Destroy instance and cancel pending callbacks |
|
|
709
640
|
|
|
710
|
-
|
|
711
|
-
| --------------------------- | ----------------------------------------------------------------------------- |
|
|
712
|
-
| `service.all(attrs, done)` | Fetch all endpoints; callback with `(errors, p1, p2, ...)` when ALL complete |
|
|
713
|
-
| `service.one(attrs, done)` | Fetch all endpoints; callback PER endpoint: `(error, payload, isLast, index)` |
|
|
714
|
-
| `service.save(attrs, done)` | Like `all` but always skips cache (force refresh) |
|
|
715
|
-
| `service.enqueue(task)` | Queue a task for serial execution |
|
|
716
|
-
| `service.dequeue(...args)` | Pop and run the next queued task |
|
|
717
|
-
| `service.destroy()` | Destroy instance; cancel further callbacks |
|
|
641
|
+
### Caching and Deduplication
|
|
718
642
|
|
|
719
|
-
|
|
643
|
+
`Cache` implements an LFU-style bounded cache: sorted by `(frequency, lastTimestamp)`, evicting `bufferSize` entries via single-pass partial selection (O(n*k), k typically 5) when capacity exceeds `maxSize + bufferSize`. `del` immediately removes from the `entries` array and `lookup` Map.
|
|
720
644
|
|
|
721
|
-
|
|
645
|
+
`_pendingCacheKeys` tracks in-flight requests per `(endpoint, params)` key. Concurrent calls to the same key are added to a callback chain; a single request completes and invokes all callbacks, avoiding redundant network round-trips.
|
|
722
646
|
|
|
723
|
-
`
|
|
647
|
+
`cleanKeys: "userList"` means the current endpoint, upon completion, clears the corresponding cache entry — commonly used to invalidate list queries after a write operation.
|
|
724
648
|
|
|
725
|
-
##
|
|
649
|
+
## Template Syntax
|
|
726
650
|
|
|
727
|
-
|
|
651
|
+
Template files use the `.html` extension and are compiled at build time by `larkMvcPlugin` / `larkMvcLoader` into ES modules exporting a `(data, viewId, refData) => string` render function.
|
|
728
652
|
|
|
729
|
-
|
|
730
|
-
| -------- | --------------- | ------------------------------------------------------------------ |
|
|
731
|
-
| `=` | `{{=variable}}` | HTML-escaped output (`&`, `<`, `>`, `"`, `'`, backtick) |
|
|
732
|
-
| `!` | `{{!variable}}` | Raw output (no escaping). Use with care for user-generated content |
|
|
733
|
-
| `@` | `{{@variable}}` | Reference lookup — stores a JS value in refData, emits a token |
|
|
734
|
-
| `:` | `{{:variable}}` | Two-way binding marker (renders identically to `=`) |
|
|
653
|
+
### Expression Operators
|
|
735
654
|
|
|
736
|
-
|
|
655
|
+
| Syntax | Meaning |
|
|
656
|
+
|--------|---------|
|
|
657
|
+
| `{{=variable}}` | HTML-escaped output (escapes `& < > " ' \``) |
|
|
658
|
+
| `{{!variable}}` | Raw output, use with caution (potential XSS) |
|
|
659
|
+
| `{{@variable}}` | Reference lookup: stores the JS value in refData and produces a token, used with `@event` to pass live references |
|
|
660
|
+
| `{{:variable}}` | Two-way binding marker; renders equivalently to `=` |
|
|
661
|
+
|
|
662
|
+
### Control Flow
|
|
737
663
|
|
|
738
664
|
```html
|
|
739
|
-
{{if condition}}...{{else if other}}...{{else}}...{{/if}}
|
|
740
|
-
|
|
741
|
-
{{forOf list as
|
|
742
|
-
|
|
743
|
-
|
|
665
|
+
{{if condition}}...{{else if other}}...{{else}}...{{/if}}
|
|
666
|
+
{{forOf list as item}} ... {{/forOf}}
|
|
667
|
+
{{forOf list as item idx}} {{=idx}}: {{=item.name}} {{/forOf}}
|
|
668
|
+
{{forOf list as {name, age} idx last first}} ... {{/forOf}}
|
|
669
|
+
{{forIn object as value key}} ... {{/forIn}}
|
|
670
|
+
{{for (let i = 0; i < n; i++)}} ... {{/for}}
|
|
671
|
+
{{set localVar = expr}}
|
|
744
672
|
```
|
|
745
673
|
|
|
746
|
-
`forOf`
|
|
674
|
+
`forOf` requires the `as` keyword. `{{forOf list item}}` is a compile-time error; the correct form is `{{forOf list as item}}`.
|
|
747
675
|
|
|
748
|
-
### Event
|
|
676
|
+
### Event Binding
|
|
749
677
|
|
|
750
678
|
```html
|
|
751
679
|
<button @click="handlerName({key: 'value', other: 123})">Go</button>
|
|
@@ -753,80 +681,91 @@ localVar = expr}}
|
|
|
753
681
|
<form @submit.prevent="onSubmit()">...</form>
|
|
754
682
|
```
|
|
755
683
|
|
|
756
|
-
The compiler converts JS object literal
|
|
684
|
+
The compiler converts JS object literal parameters (`{a:1}`) to URL query string format (`a=1`) for transmission through DOM attributes, and injects the current view's `$viewId` with SPLITTER delimiters into the attribute so that EventDelegator routes events to the correct view and method across nested Frame boundaries.
|
|
757
685
|
|
|
758
|
-
###
|
|
686
|
+
### Child View Embedding
|
|
759
687
|
|
|
760
688
|
```html
|
|
761
689
|
<div v-lark="components/child"></div>
|
|
762
690
|
<div v-lark="components/child?title=hello&id=42"></div>
|
|
763
691
|
<div v-lark="remote-app/views/home"></div>
|
|
764
|
-
<!-- Module Federation -->
|
|
765
692
|
```
|
|
766
693
|
|
|
767
|
-
|
|
694
|
+
With query strings, parameters are translated into the first argument of the child view's `init`. When containing SPLITTER reference tokens, `translateData` resolves original JS values from the parent view's refData before passing them to the child.
|
|
768
695
|
|
|
769
|
-
### VDOM
|
|
696
|
+
### VDOM Optimization Hints
|
|
770
697
|
|
|
771
|
-
| Attribute |
|
|
772
|
-
|
|
773
|
-
| `ldk`
|
|
774
|
-
| `lak`
|
|
775
|
-
| `lvk`
|
|
698
|
+
| Attribute | Purpose |
|
|
699
|
+
|-----------|---------|
|
|
700
|
+
| `ldk` | diff key: when old and new ldk match, the entire subtree's diff is skipped |
|
|
701
|
+
| `lak` | attribute key: skips attribute diff but children continue to diff |
|
|
702
|
+
| `lvk` | view key: assign optimization marker |
|
|
776
703
|
|
|
777
|
-
|
|
704
|
+
Marking large static subtrees with `ldk` can completely skip rendering work. This is currently the framework's only "fine-grained skip diff" mechanism; the compiler does not automatically mark fully static subtrees.
|
|
778
705
|
|
|
779
|
-
##
|
|
706
|
+
## Frame and the View Tree
|
|
780
707
|
|
|
781
|
-
|
|
708
|
+
`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 View instance.
|
|
782
709
|
|
|
783
|
-
|
|
710
|
+
### Typed API
|
|
784
711
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
712
|
+
| API | Description |
|
|
713
|
+
|-----|-------------|
|
|
714
|
+
| `Frame.get(id)` | Look up Frame by DOM id |
|
|
715
|
+
| `Frame.getAll()` | All Frames as `Map<string, Frame>` |
|
|
716
|
+
| `Frame.getRoot()` | Current root Frame; returns `undefined` if not created |
|
|
717
|
+
| `Frame.createRoot(id)` | Idempotent root creation (`Framework.boot` calls this) |
|
|
718
|
+
| `Frame.root(id)` | `@deprecated` alias, forwards to `createRoot` |
|
|
719
|
+
| `new Frame(containerId)` | Independent Frame instance for micro-frontend / embedded widget scenarios |
|
|
720
|
+
| `frame.invoke(name, args?)` | Call the owning view's method; if view not mounted, pushes to `invokeList`, flushed by `View.runInvokes(frame)` after mounting |
|
|
721
|
+
| `frame.children()` | Child Frame id array (order not guaranteed) |
|
|
722
|
+
| `frame.parent(level?)` | Ancestor Frame, defaults to one level up |
|
|
723
|
+
| `frame.mountFrame(id, viewPath, params?)` | Explicitly create a child Frame |
|
|
724
|
+
| `frame.unmountFrame(id)` | Unmount a specific child Frame |
|
|
725
|
+
| `frame.mountZone(id?)` / `frame.unmountZone(id?)` | Batch mount/unmount all `v-lark` child nodes in a zone |
|
|
726
|
+
| `Frame.on("add" \| "remove", handler)` | Frame instance lifecycle events (static emitter) |
|
|
727
|
+
| `frame.on("created" \| "alter", handler)` | All child Frames rendered / child content changed (instance emitter) |
|
|
788
728
|
|
|
729
|
+
Frame instances enter `frameCache` object pool upon destruction, caching up to `MAX_FRAME_POOL = 64`; beyond that threshold they are GC'd. Do not retain Frame references after unmounting as the object may be reused.
|
|
730
|
+
|
|
731
|
+
## Module Federation Micro-Frontend
|
|
732
|
+
|
|
733
|
+
Lark treats Module Federation as a first-class citizen, providing two integration modes.
|
|
734
|
+
|
|
735
|
+
### Mode 1: Direct Async Loading
|
|
736
|
+
|
|
737
|
+
Via `FrameworkConfig.require`, resolve unregistered view paths to remote modules:
|
|
738
|
+
|
|
739
|
+
```ts
|
|
789
740
|
Framework.boot({
|
|
790
741
|
rootId: "app",
|
|
791
742
|
projectName: "host-app",
|
|
792
743
|
crossConfigs: [
|
|
793
|
-
{
|
|
794
|
-
projectName: "remote-app",
|
|
795
|
-
source: "remote_app@//cdn.example.com/remote-app/remoteEntry.js",
|
|
796
|
-
},
|
|
744
|
+
{ projectName: "remote-app", source: "remote_app@//cdn.example.com/remote-app/remoteEntry.js" },
|
|
797
745
|
],
|
|
798
746
|
require: async (names: string[]) => {
|
|
799
747
|
await __webpack_init_sharing__("default");
|
|
800
748
|
const container = __webpack_share_scopes__["default"];
|
|
801
|
-
return Promise.all(
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
if (!rc) return undefined;
|
|
813
|
-
await rc.init(container);
|
|
814
|
-
const factory = await rc.get(`./${mod}`);
|
|
815
|
-
const raw = factory();
|
|
816
|
-
return raw && (raw as Record<string, unknown>).__esModule
|
|
817
|
-
? (raw as Record<string, unknown>).default
|
|
818
|
-
: raw;
|
|
819
|
-
}),
|
|
820
|
-
);
|
|
749
|
+
return Promise.all(names.map(async (name) => {
|
|
750
|
+
const slash = name.indexOf("/");
|
|
751
|
+
const remote = slash > -1 ? name.substring(0, slash) : name;
|
|
752
|
+
const mod = slash > -1 ? name.substring(slash + 1) : "./index";
|
|
753
|
+
const rc = (window as Record<string, unknown>)[remote];
|
|
754
|
+
if (!rc) return undefined;
|
|
755
|
+
await rc.init(container);
|
|
756
|
+
const factory = await rc.get(`./${mod}`);
|
|
757
|
+
const raw = factory();
|
|
758
|
+
return raw && raw.__esModule ? raw.default : raw;
|
|
759
|
+
}));
|
|
821
760
|
},
|
|
822
761
|
});
|
|
823
762
|
```
|
|
824
763
|
|
|
825
|
-
Then `v-lark="remote-app/views/home"`
|
|
764
|
+
Then write `v-lark="remote-app/views/home"` in templates to trigger async loading and mounting of the remote view.
|
|
826
765
|
|
|
827
|
-
###
|
|
766
|
+
### Mode 2: CrossSite Bridge View
|
|
828
767
|
|
|
829
|
-
For
|
|
768
|
+
For skeleton screens and remote `prepare` hooks, use `CrossSite`:
|
|
830
769
|
|
|
831
770
|
```ts
|
|
832
771
|
import { CrossSite, registerViewClass } from "@lark.js/mvc";
|
|
@@ -837,30 +776,18 @@ registerViewClass("cross-site", CrossSite);
|
|
|
837
776
|
<div v-lark="cross-site?view=remote-app/views/home&bizCode=mybiz"></div>
|
|
838
777
|
```
|
|
839
778
|
|
|
840
|
-
CrossSite renders a skeleton
|
|
779
|
+
CrossSite first renders as a normal view showing a skeleton (default `Loading...`, overridable via `skeleton` parameter) and occupies a `<div id="mf_${viewId}">` sub-container. `updateView()` uses `++this.$sign` to get a sequence number, loads the remote prepare module via `use(projectName/prepare)` and executes it. Race guard: if after loading `this.$sign !== sign`, return immediately (user navigated away). If the same view path as last time and the remote view exposes an `assign` method, it calls `assign` + `render` in-place to reuse the existing view; otherwise it calls `owner.mountFrame('mf_' + this.id, this.$view, this.$params)` to actually mount.
|
|
841
780
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
### Module Federation Webpack config
|
|
781
|
+
### Webpack Configuration
|
|
845
782
|
|
|
846
783
|
Host:
|
|
847
784
|
|
|
848
785
|
```js
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
name: "host_app",
|
|
855
|
-
remotes: {
|
|
856
|
-
"remote-app": "remote_app@//cdn.example.com/remote-app/remoteEntry.js",
|
|
857
|
-
},
|
|
858
|
-
shared: {
|
|
859
|
-
"@lark.js/mvc": { singleton: true, requiredVersion: "^1.0.0" },
|
|
860
|
-
},
|
|
861
|
-
}),
|
|
862
|
-
],
|
|
863
|
-
};
|
|
786
|
+
new ModuleFederationPlugin({
|
|
787
|
+
name: "host_app",
|
|
788
|
+
remotes: { "remote-app": "remote_app@//cdn.example.com/remote-app/remoteEntry.js" },
|
|
789
|
+
shared: { "@lark.js/mvc": { singleton: true, requiredVersion: "^1.0.0" } },
|
|
790
|
+
});
|
|
864
791
|
```
|
|
865
792
|
|
|
866
793
|
Remote:
|
|
@@ -869,222 +796,182 @@ Remote:
|
|
|
869
796
|
new ModuleFederationPlugin({
|
|
870
797
|
name: "remote_app",
|
|
871
798
|
filename: "remoteEntry.js",
|
|
872
|
-
exposes: {
|
|
873
|
-
|
|
874
|
-
"./prepare": "./src/prepare",
|
|
875
|
-
},
|
|
876
|
-
shared: {
|
|
877
|
-
"@lark.js/mvc": { singleton: true, requiredVersion: "^1.0.0" },
|
|
878
|
-
},
|
|
799
|
+
exposes: { "./views/home": "./src/views/home", "./prepare": "./src/prepare" },
|
|
800
|
+
shared: { "@lark.js/mvc": { singleton: true, requiredVersion: "^1.0.0" } },
|
|
879
801
|
});
|
|
880
802
|
```
|
|
881
803
|
|
|
882
|
-
`@lark.js/mvc`
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
804
|
+
`@lark.js/mvc` must be `singleton: true`; otherwise host and remote hold different View/Frame class instances and all `instanceof` checks fail across boundaries.
|
|
805
|
+
|
|
806
|
+
`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`).
|
|
807
|
+
|
|
808
|
+
## Debugging and DevTools Bridge
|
|
809
|
+
|
|
810
|
+
### Global Objects
|
|
811
|
+
|
|
812
|
+
After `Framework.boot` completes, the following are attached to `window`:
|
|
813
|
+
|
|
814
|
+
| Global | Value | Purpose |
|
|
815
|
+
|--------|-------|---------|
|
|
816
|
+
| `window.__lark_Framework` | Framework object | Direct access |
|
|
817
|
+
| `window.__lark_State` | State object | Direct access |
|
|
818
|
+
| `window.__lark_Router` | Router object | Direct access |
|
|
819
|
+
| `window.__lark_Frame` | Frame class | Direct access |
|
|
820
|
+
| `window.__lark_View` | View class | Direct access |
|
|
821
|
+
| `window.__lark_registerViewClass` | Function | HMR: re-register View class |
|
|
822
|
+
| `window.__lark_invalidateViewClass` | Function | HMR: remove View from registry |
|
|
823
|
+
| `window.__lark_getViewClassRegistry` | Function | HMR: read registry |
|
|
824
|
+
| `window.__lark_Debug` | boolean, must be set manually | Enable Safeguard Proxy debug checks |
|
|
825
|
+
|
|
826
|
+
### Safeguard Debug Mode
|
|
827
|
+
|
|
828
|
+
Set `window.__lark_Debug = true` before boot, and the framework wraps `State.get()` / `Router.diff()` results and `Updater.get()` return values with Safeguard Proxy:
|
|
829
|
+
|
|
830
|
+
- Warns when reading data written by another page (potential cross-page pollution).
|
|
831
|
+
- Warns immediately when assigning directly to objects returned by `State.get()` (deduplicated by key); the correct approach is `State.set(patch)` + `State.digest()`.
|
|
832
|
+
|
|
833
|
+
### Frame Visualizer Bridge
|
|
834
|
+
|
|
835
|
+
`installFrameVisualizerBridge()` is automatically installed during `Framework.boot`, listening for `window` message events and communicating with DevTools via postMessage:
|
|
836
|
+
|
|
837
|
+
- `LARK_VIS_PING` — responds with `LARK_VIS_PONG` to confirm this page is a Lark application.
|
|
838
|
+
- `LARK_VIS_REQUEST_TREE` — responds with `LARK_VIS_TREE` carrying `SerializedFrameTree`.
|
|
839
|
+
- Internally listens to `Frame.on('add' | 'remove')` and automatically pushes `LARK_VIS_TREE_DELTA`; JSON.stringify is compared with `lastTreeJson` before pushing to avoid flooding when nothing changed.
|
|
840
|
+
|
|
841
|
+
The `lark-visual` sub-project in this repository is the paired visual DevTools that loads the target application via iframe to display the real-time Frame tree.
|
|
842
|
+
|
|
843
|
+
## Public API Reference
|
|
844
|
+
|
|
845
|
+
### Framework
|
|
846
|
+
|
|
847
|
+
- `Framework.boot(config)` — Start the application.
|
|
848
|
+
- `Framework.getConfig()` / `Framework.getConfig(key)` — Read configuration.
|
|
849
|
+
- `Framework.setConfig(patch)` — Merge configuration, returns merged result.
|
|
850
|
+
- `Framework.use(names, callback?)` — Async view loader; returns `Promise<unknown[]>` when no callback.
|
|
851
|
+
- `Framework.mark(host, key)` / `Framework.unmark(host)` — Async callback validity tracking via module-level `WeakMap`.
|
|
852
|
+
- `Framework.dispatch(target, type, init?)` — Trigger custom DOM event.
|
|
853
|
+
- `Framework.task(fn, args?, ctx?)` — Chunked execution: prefers `scheduler.postTask` then `requestIdleCallback` then `setTimeout(0)`, with a fixed 48ms budget or adaptive time slicing.
|
|
854
|
+
- `Framework.delay(ms)` — Promise-wrapped setTimeout.
|
|
855
|
+
- `Framework.waitZoneViewsRendered(viewId, timeout?)` — Wait until all views in a zone have rendered.
|
|
856
|
+
- `Framework.applyStyle(idOrPairs, css?)` — Dynamically inject CSS, returns cleanup function.
|
|
857
|
+
|
|
858
|
+
### Updater
|
|
859
|
+
|
|
860
|
+
- `updater.get(key?)` — Read data; returns entire data object when no key.
|
|
861
|
+
- `updater.set(data, excludes?)` — Shallow merge and collect changed keys.
|
|
862
|
+
- `updater.digest(data?, excludes?, callback?)` — Render; supports re-entry via `digestingQueue`.
|
|
863
|
+
- `updater.snapshot()` — Record current monotonic version.
|
|
864
|
+
- `updater.altered()` — Check if changed, returns `boolean | undefined`.
|
|
865
|
+
- `updater.translate(value)` — Resolve SPLITTER + number reference tokens to original values.
|
|
866
|
+
- `updater.parse(expr)` — Safe path parser: dot paths (`a.b.c`) or numeric literals only, no eval.
|
|
867
|
+
- `updater.getChangedKeys()` — `ReadonlySet<string>` of keys changed since last digest.
|
|
868
|
+
|
|
869
|
+
### Store (zustand-style)
|
|
870
|
+
|
|
871
|
+
- `create(name, (set, get) => body)` — Create store, returns `StoreApi`.
|
|
872
|
+
- `store.getState()` — Read current state.
|
|
873
|
+
- `store.setState(partial | updater)` — Shallow merge, notify all listeners.
|
|
874
|
+
- `store.subscribe(listener)` — Listen for changes, returns unsubscribe function.
|
|
875
|
+
- `store.destroy()` — Destroy store, clear listeners.
|
|
876
|
+
- `computed(deps, fn)` — Declare derived state.
|
|
877
|
+
- `bindStore(view, store, selector?)` — Bind to Lark View with auto-sync and auto-cleanup.
|
|
878
|
+
- `useUrlState(view, initialState?)` — URL parameter state sync.
|
|
879
|
+
|
|
880
|
+
## Common Pitfalls
|
|
881
|
+
|
|
882
|
+
1. `boot.ts` must be inside `src/`: HTML references `/src/boot.ts`; placing it at the project root causes runtime resolution failure.
|
|
883
|
+
2. `registerViewClass` must precede `Framework.boot()`: all View classes (including sub-components) must either be pre-registered or loaded via `FrameworkConfig.require`.
|
|
884
|
+
3. `.html` imports require build integration: only works in projects compiled by `larkMvcPlugin` / `larkMvcLoader`.
|
|
885
|
+
4. Write State with `State.set` + `State.digest`, never mutate the returned object directly: Safeguard warns in debug mode, deduplicated by key.
|
|
886
|
+
5. `bindStore` auto-unsubscribes on view destroy; manual `store.subscribe(listener)` calls need explicit cleanup (e.g., `this.on("destroy", off)`).
|
|
887
|
+
6. Event methods use `<>` not `()`: write `name<click>`, not `name(click)`.
|
|
888
|
+
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.
|
|
889
|
+
8. Never modify `view.signature`: internally managed; 0 means destroyed, render wrapper auto-increments.
|
|
890
|
+
9. `v-lark` container content is replaced: do not put scaffold text inside.
|
|
891
|
+
10. Webpack must use `exclude: /index\.html$/`: entry HTML is handled by HtmlWebpackPlugin.
|
|
892
|
+
11. Webpack loader must be imported as a value: `loader: larkMvcLoader`, not a string name.
|
|
893
|
+
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.
|
|
894
|
+
13. `forOf` requires `as`: `{{forOf list item}}` is a compile error.
|
|
895
|
+
14. `wrapAsync` validates by signature: callback only executes when `view.signature` matches the value at wrap time.
|
|
896
|
+
15. Frame object pool caps at `MAX_FRAME_POOL = 64`: do not retain Frame references after `unmountFrame`.
|
|
897
|
+
16. Updater supports digest re-entry: digest during digest enters `digestingQueue`; `null` is the boundary.
|
|
898
|
+
17. Store creator runs once: state persists across view mount/unmount cycles; call `store.destroy()` to tear down.
|
|
899
|
+
18. State is simple, Store is complex: lightweight shared values use State; use `create()` for actions, derived data, or fine-grained subscriptions; always pair State writes with `mixins: [State.clean("keys")]` to prevent leaks.
|
|
900
|
+
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`.
|
|
901
|
+
20. `splitChunks.chunks` must be `"async"` in MF projects: `"all"` breaks shared scope initialization.
|
|
902
|
+
|
|
903
|
+
## Recent API Changes
|
|
904
|
+
|
|
905
|
+
- Store rewrite (zustand-style):
|
|
906
|
+
- `defineStore(name, (store) => body)` replaced by `create(name, (set, get) => body)`. `defineStore` retained as deprecated alias.
|
|
907
|
+
- `store.key = value` (Proxy write) replaced by `set({ key: value })`.
|
|
908
|
+
- `store.key` reads in actions replaced by `get().key`.
|
|
909
|
+
- `useStore(view)` + `store.observe(view, keys?)` replaced by `bindStore(view, store, selector?)`.
|
|
910
|
+
- `useStore()` (read-only access) replaced by `store.getState()`.
|
|
911
|
+
- `store.observe(undefined, keys, cb)` (internal reaction) replaced by `store.subscribe((state, prev) => ...)`.
|
|
912
|
+
- Removed: `multi()`, `cell()`, `observeCell()`, `cloneStore()`, `getStore()`, `delStore()`, `getUseStore()`, `isStoreActive()`, `createState()`, `shallowSet()`, `lazySet()`, `cloneData()`, `isState()`, `storeMark`, `storeUnmark`, `getPlatform`, `Platform`, `StoreConfig`, `ObservePayload`, `StoreMethods`, `LarkUseStore`, `ReactUseStore`, `NodeUseStore`.
|
|
913
|
+
- Router history mode support:
|
|
914
|
+
- Added `FrameworkConfig.routeMode` (`"history"` default, `"hash"` optional).
|
|
915
|
+
- In history mode, path comes from `window.location.pathname`, params from search query string.
|
|
916
|
+
- Added `useUrlState(view, initialState?)` for URL parameter state sync.
|
|
917
|
+
- `ChangeEvent.keys` changed to `ReadonlySet<string>` (was `Record<string, 1>`). Use `keys.has("foo")` instead of `keys.foo`.
|
|
918
|
+
- `StateInterface.diff()` returns `ReadonlySet<string>`.
|
|
919
|
+
- `Updater.set/digest`, `State.set/digest`, `setData` `excludes?` changed to `ReadonlySet<string>` (was `Set<string>`).
|
|
920
|
+
- `Frame.root(id)` `@deprecated`. Read via `Frame.getRoot()`, create singleton via `Frame.createRoot(id)`, independent mount via `new Frame(id)`.
|
|
921
|
+
- `Updater.parse` no longer evals; only supports safe paths and literals.
|
|
922
|
+
- `mark.ts` no longer writes magic keys to host objects; uses module-level `WeakMap`, works on `Object.freeze`d objects.
|
|
923
|
+
- `Cache.del` immediately removes from `entries` array and `lookup` Map (previously left tombstones until next eviction).
|
|
895
924
|
|
|
896
|
-
|
|
925
|
+
## Comparison with Vue 3 / React 19
|
|
897
926
|
|
|
898
|
-
|
|
927
|
+
### vs Vue 3
|
|
899
928
|
|
|
900
|
-
|
|
901
|
-
- `Frame.createRoot(id)` — idempotent create (Framework.boot calls this).
|
|
902
|
-
- `new Frame(containerId)` — independent root for MF or for an embeddable widget.
|
|
903
|
-
- `Frame.root(id)` — `@deprecated` alias that delegates to `createRoot`.
|
|
929
|
+
Similarities: templates compile to functions; reactivity via Proxy; derived data with `computed`; microtask batching; component-level granular updates.
|
|
904
930
|
|
|
905
|
-
|
|
931
|
+
| Dimension | Vue 3 | Lark |
|
|
932
|
+
|-----------|-------|------|
|
|
933
|
+
| Component abstraction | SFC / function components / Options | Class inheritance `View.extend` / `defineView` |
|
|
934
|
+
| Render output | VNode patch | HTML string parsed to real DOM + diff |
|
|
935
|
+
| Template syntax | `v-if` / `v-for` / `:bind` | `{{if}}` / `{{forOf}}` / `@event` / `v-lark` |
|
|
936
|
+
| Dependency tracking | Automatic effect tracking | subscribe + bindStore + computed |
|
|
937
|
+
| Compile optimizations | PatchFlag / hoistStatic / cacheHandler | Only `ldk` / `lak` / `lvk` user-manual markers |
|
|
938
|
+
| Micro-frontend | Third-party (qiankun / wujie etc.) | Built-in `CrossSite` + `FrameworkConfig.require` |
|
|
939
|
+
| Scheduling | Microtask batching + nextTick | Microtask batching + `Framework.task` sliceable queue |
|
|
906
940
|
|
|
907
|
-
|
|
941
|
+
The key difference is render output: Vue uses virtual node patching; Lark generates HTML strings, parses them via innerHTML into a temporary div, then diffs the resulting real DOM. The advantage is that context-sensitive tags (`<table>` / `<select>` / `<svg>`) are handled by the native parser nearly for free; the disadvantage is the absence of PatchFlag-style compile-time annotations (only user-manual `ldk` / `lak` / `lvk`).
|
|
908
942
|
|
|
909
|
-
|
|
910
|
-
// src/exposed/counter-view.ts
|
|
911
|
-
import {
|
|
912
|
-
Framework,
|
|
913
|
-
Frame,
|
|
914
|
-
registerViewClass,
|
|
915
|
-
EventDelegator,
|
|
916
|
-
Router,
|
|
917
|
-
State,
|
|
918
|
-
} from "@lark.js/mvc";
|
|
919
|
-
import CounterView from "../views/counter";
|
|
920
|
-
import "../index.css"; // MF remote must explicitly import CSS
|
|
921
|
-
|
|
922
|
-
const MF_COUNTER = "mf/counter";
|
|
923
|
-
registerViewClass(MF_COUNTER, CounterView);
|
|
924
|
-
|
|
925
|
-
export function mountCounter(container: HTMLElement): () => void {
|
|
926
|
-
const containerId = container.id || "mf-counter-root";
|
|
927
|
-
container.id = containerId;
|
|
928
|
-
|
|
929
|
-
Framework.setConfig({ rootId: containerId, error: console.error });
|
|
930
|
-
EventDelegator.setFrameGetter((id: string) => Frame.get(id));
|
|
931
|
-
Reflect.set(Router, "_booted", true);
|
|
932
|
-
Reflect.set(State, "_booted", true);
|
|
933
|
-
|
|
934
|
-
const frame = new Frame(containerId); // NOT Frame.createRoot
|
|
935
|
-
frame.mountView(MF_COUNTER);
|
|
936
|
-
|
|
937
|
-
return () => {
|
|
938
|
-
frame.unmountView();
|
|
939
|
-
Frame.getAll().delete(containerId);
|
|
940
|
-
const el = document.getElementById(containerId);
|
|
941
|
-
if (el) Reflect.set(el, "frameBound", 0);
|
|
942
|
-
};
|
|
943
|
-
}
|
|
944
|
-
```
|
|
943
|
+
### vs React 19
|
|
945
944
|
|
|
946
|
-
|
|
945
|
+
Similarities: unidirectional data flow; immutable write-back style; async protection (`wrapAsync` is analogous to `useEffect` cleanup + AbortController); microtask batching; global error boundary (`FrameworkConfig.error` + `funcWithTry`).
|
|
947
946
|
|
|
948
|
-
|
|
947
|
+
| Dimension | React 19 | Lark |
|
|
948
|
+
|-----------|----------|------|
|
|
949
|
+
| Component abstraction | Function components + Hooks | Class inheritance `View.extend` / `defineView` |
|
|
950
|
+
| State encapsulation | `useState` / `useReducer` | View instance fields, `create()` store, `State` |
|
|
951
|
+
| Side effects | `useEffect` / `useLayoutEffect` | `init` / `make` + `capture` / `release` |
|
|
952
|
+
| Render interruption | Fiber time-slicing, Suspense, Transition | Synchronous digest, not interruptible |
|
|
953
|
+
| Compile optimization | React Compiler (auto-memo) | Template compile-time only; no runtime auto-memo |
|
|
954
|
+
| Server rendering | RSC, streaming SSR | Not supported (design trade-off) |
|
|
955
|
+
| Cross-platform | React Native / DOM | Web DOM only |
|
|
956
|
+
| Event system | Synthetic Event | `document.body` capture-phase delegation + selector matching |
|
|
957
|
+
| Route guards | Third-party router libraries | Built-in `Router.beforeEach(asyncGuard)` + two-phase change |
|
|
949
958
|
|
|
950
|
-
|
|
951
|
-
// Updater (view-local, manual)
|
|
952
|
-
this.updater.set({ count: newCount }).digest();
|
|
959
|
+
The key difference is scheduling: React 19's Concurrent mode can interrupt and restart renders by lane priority. Lark's `Updater.digest()` is synchronous (though the internal `digestingQueue` supports re-entry) and never yields the main thread. For large lists or frequent updates, Lark has no time-slicing mechanism, which may cause long tasks; the advantage is predictable behavior and simpler debugging.
|
|
953
960
|
|
|
954
|
-
|
|
955
|
-
State.set({ count: newCount }).digest();
|
|
956
|
-
// to react:
|
|
957
|
-
State.on("changed", (e) => {
|
|
958
|
-
if (e.keys?.has("count")) this.assign();
|
|
959
|
-
});
|
|
960
|
-
// to clean up on view destroy:
|
|
961
|
-
export default View.extend({ mixins: [State.clean("count")] });
|
|
961
|
+
## Testing and Local Development
|
|
962
962
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
963
|
+
```bash
|
|
964
|
+
pnpm install
|
|
965
|
+
pnpm test # vitest unit tests
|
|
966
|
+
pnpm test:coverage # coverage report
|
|
967
|
+
pnpm test:watch # watch mode
|
|
968
|
+
pnpm typecheck # tsc --noEmit
|
|
969
|
+
pnpm build # tsup produces ESM + CJS + dts
|
|
970
|
+
pnpm format # prettier formatting
|
|
967
971
|
```
|
|
968
972
|
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
| | State | Store |
|
|
972
|
-
| -------------- | ------------------------------------------- | ----------------------------------------------------------- |
|
|
973
|
-
| Write API | `State.set()` + `State.digest()` | direct: `store.key = value` |
|
|
974
|
-
| Reactivity | Manual `digest()` notification | Automatic via Proxy |
|
|
975
|
-
| Subscribe | `State.on("changed", ...)` + manual cleanup | `store.observe(view, ...)` with auto-cleanup |
|
|
976
|
-
| Memory | Auto-deleted via `State.clean()` mixin | Persists until `store.$destroyFn()` |
|
|
977
|
-
| Derived data | not supported | `computed(deps, fn)` |
|
|
978
|
-
| Multi-instance | not supported | `multi(useStore)` |
|
|
979
|
-
| Best for | Counters, toggles, page title, session info | Reactive handlers, derived state, multi-instance components |
|
|
980
|
-
|
|
981
|
-
## Updater APIs worth knowing
|
|
982
|
-
|
|
983
|
-
- `updater.get(key?)` — read data; without key returns the whole data object.
|
|
984
|
-
- `updater.set(data, excludes?)` — merge `data` into the view's data, track changed keys.
|
|
985
|
-
- `updater.digest(data?, excludes?, callback?)` — render; optional `data` is set first. Supports re-digest during an active digest via an internal queue (the `null` sentinel marks digest boundaries).
|
|
986
|
-
- `updater.snapshot()` — record the current monotonic `version`; pair with `altered()` to detect changes cheaply (no JSON.stringify).
|
|
987
|
-
- `updater.altered()` — returns `boolean | undefined`. `undefined` if `snapshot` was never called.
|
|
988
|
-
- `updater.translate(value)` — resolve a `SPLITTER + digits` ref token to its original value. Non-ref strings are returned as-is. The protocol is strict: only `SPLITTER` followed by ASCII digits qualifies.
|
|
989
|
-
- `updater.parse(expr)` — **safe** path resolver. Accepts a dotted property path (`a.b.c`) or a numeric literal (`42`, `-1.5`). Anything else returns `undefined`. Does NOT eval arbitrary JS — CSP-safe.
|
|
990
|
-
- `updater.getChangedKeys()` — `ReadonlySet<string>` of keys changed since the last digest.
|
|
991
|
-
|
|
992
|
-
## Frame APIs worth knowing
|
|
993
|
-
|
|
994
|
-
- `Frame.get(id)` — look up a Frame by DOM id.
|
|
995
|
-
- `Frame.getAll()` — registry as `Map<string, Frame>`.
|
|
996
|
-
- `Frame.getRoot()` — current root or `undefined`.
|
|
997
|
-
- `Frame.createRoot(id)` — create root (idempotent; ignores `id` after first creation).
|
|
998
|
-
- `Frame.root(id)` — `@deprecated` alias to `createRoot`.
|
|
999
|
-
- `frame.invoke(name, args?)` — call a method on the frame's view. If the view isn't yet rendered, the call is deferred until render.
|
|
1000
|
-
- `frame.invokeTyped<V, K>(name, args)` — type-safe variant; carries the view's method signature through TS.
|
|
1001
|
-
- `frame.children()` — array of child Frame ids (order is not stable).
|
|
1002
|
-
- `frame.parent(level?)` — ancestor frame; defaults to parent (level=1).
|
|
1003
|
-
- `frame.mountFrame(id, viewPath, params?)` — explicit child Frame creation.
|
|
1004
|
-
- `frame.unmountFrame(id)` / `frame.mountZone(id?)` / `frame.unmountZone(id?)` — bulk operations.
|
|
1005
|
-
- `Frame.on("add" | "remove", handler)` — lifecycle events.
|
|
1006
|
-
- `frame.on("created" | "alter", handler)` — fires when all children have rendered / when child content changes.
|
|
1007
|
-
|
|
1008
|
-
## Framework APIs worth knowing
|
|
1009
|
-
|
|
1010
|
-
- `Framework.boot(config)` — start the app.
|
|
1011
|
-
- `Framework.getConfig()` / `Framework.getConfig(key)` — read config.
|
|
1012
|
-
- `Framework.setConfig(patch)` — merge into config; returns the merged result.
|
|
1013
|
-
- `Framework.config(...)` — `@deprecated`; still works.
|
|
1014
|
-
- `Framework.isBooted()` — boolean.
|
|
1015
|
-
- `Framework.use(names, callback?)` — async View loader. Returns `Promise<unknown[]>` when no callback is passed.
|
|
1016
|
-
- `Framework.mark(host, key)` / `Framework.unmark(host)` — async callback validity tracking. Stored in a module-level `WeakMap`, does NOT pollute the host object with magic keys.
|
|
1017
|
-
- `Framework.dispatch(target, type, init?)` — fire a custom DOM event.
|
|
1018
|
-
- `Framework.task(fn, args?, ctx?)` — schedule a function for chunked execution (`scheduler.postTask` → `requestIdleCallback` → `setTimeout(0)`).
|
|
1019
|
-
- `Framework.delay(ms)` — Promise-based setTimeout.
|
|
1020
|
-
- `Framework.waitZoneViewsRendered(viewId, timeout?)` — Promise resolving to `Framework.WAIT_OK` (1) or `Framework.WAIT_TIMEOUT_OR_NOT_FOUND` (0).
|
|
1021
|
-
- `Framework.applyStyle(idOrPairs, css?)` — inject CSS dynamically; returns a cleanup function.
|
|
1022
|
-
- `Framework.guid(prefix?)` / `Framework.toMap(list, key?)` / `Framework.toUrl(...)` / `Framework.parseUrl(url)` / `Framework.mix(target, ...sources)` / `Framework.keys(obj)` / `Framework.inside(a, b)` / `Framework.node(idOrEl)` / `Framework.nodeId(el)` — utility helpers.
|
|
1023
|
-
- `Framework.guard(o)` — Safeguard Proxy wrap (no-op outside debug mode).
|
|
1024
|
-
- `Framework.Base` / `Framework.View` / `Framework.Frame` / `Framework.Cache` / `Framework.State` / `Framework.Router` — class re-exports.
|
|
1025
|
-
|
|
1026
|
-
## Vite vs Webpack at a glance
|
|
1027
|
-
|
|
1028
|
-
| Feature | Vite (`larkMvcPlugin`) | Webpack (`larkMvcLoader`) |
|
|
1029
|
-
| ------------------- | -------------------------------------------------------- | ------------------------------------------------------------ |
|
|
1030
|
-
| Import path | `@lark.js/mvc/vite` | `@lark.js/mvc/webpack` |
|
|
1031
|
-
| Type | Vite plugin (`resolveId` + `load` hooks, `enforce: pre`) | Standard Webpack loader |
|
|
1032
|
-
| Configuration | `plugins: [larkMvcPlugin()]` | `module.rules` with the loader rule |
|
|
1033
|
-
| Debug mode | `larkMvcPlugin({ debug: true })` | `use: [{ loader: larkMvcLoader, options: { debug: true } }]` |
|
|
1034
|
-
| HTML entry handling | Vite handles `index.html` natively | MUST `exclude: /index\.html$/` so HtmlWebpackPlugin owns it |
|
|
1035
|
-
| Dev server | Vite dev server (fast HMR) | webpack-dev-server |
|
|
1036
|
-
| Template pipeline | Same: `extractGlobalVars` → `compileTemplate` | Same: `extractGlobalVars` → `compileTemplate` |
|
|
1037
|
-
|
|
1038
|
-
Both produce compiled `.html` modules that import their runtime helpers from `@lark.js/mvc/runtime` (a 948-byte module containing `encHtml`, `strSafe`, `encUri`, `encQuote`, `refFn`).
|
|
1039
|
-
|
|
1040
|
-
## Common pitfalls
|
|
1041
|
-
|
|
1042
|
-
1. **`boot.ts` must live inside `src/`** — the entry HTML references `/src/boot.ts`, not `/boot.ts`.
|
|
1043
|
-
2. **`registerViewClass` before `Framework.boot()`** — all view classes (and their sub-components) must be registered before boot, OR you must provide a `FrameworkConfig.require` so unknown paths can be loaded on demand.
|
|
1044
|
-
3. **`.html` imports require the bundler integration** — they only work because the Vite plugin or Webpack loader compiles them at build time.
|
|
1045
|
-
4. **Use `State.set` + `State.digest`, not direct mutation** — direct mutation bypasses change detection. Debug mode (`window.__lark_Debug = true`) warns synchronously and dedupes the warning per key.
|
|
1046
|
-
5. **`observe` requires view binding for auto-cleanup** — `store.observe(this, ...)` tears down when the view destroys. Inner observes (no view) require explicit `keys` and explicit unsubscribe.
|
|
1047
|
-
6. **Event method names use `<>`, not `()`** — the pattern is `name<click>`, not `name(click)`.
|
|
1048
|
-
7. **`assign()` must call `snapshot()` and return `altered()`** — otherwise the framework can't tell if data actually changed.
|
|
1049
|
-
8. **Do not modify `view.signature`** — it's managed internally. Setting it to 0 destroys the view. The wrapped `render()` increments it.
|
|
1050
|
-
9. **`v-lark` containers are replaced** — content inside a `v-lark` element gets replaced by the child view's rendered output. Don't put authoring text there.
|
|
1051
|
-
10. **Webpack: exclude `index.html`** — `larkMvcLoader` must not process the entry HTML; HtmlWebpackPlugin owns it.
|
|
1052
|
-
11. **Webpack: import the loader as a value** — `loader: larkMvcLoader`, not `loader: "larkMvcLoader"`.
|
|
1053
|
-
12. **Store reads return cloned data** — `useStore(view).count` returns a deep clone (via `structuredClone`). Mutating it does NOT trigger reactivity. Only writes through `store.key = value` are reactive.
|
|
1054
|
-
13. **`forOf` requires `as`** — `{{forOf list item}}` is invalid; use `{{forOf list as item}}`.
|
|
1055
|
-
14. **Inner observe deduplication** — `store.observe(undefined, keys, callback)` is deduped on `key + observeKeys.join("-") + cb.toString()`. The same inner observe registers only once.
|
|
1056
|
-
15. **`wrapAsync` is signature-based** — the callback runs only if `view.signature` hasn't changed since `wrapAsync` was called.
|
|
1057
|
-
16. **Frame object pooling has a cap** — destroyed Frame objects are pooled up to `MAX_FRAME_POOL = 64`. Don't hold references to Frame instances after `unmountFrame()`.
|
|
1058
|
-
17. **Updater supports re-entrant digest** — calling `updater.digest()` inside an active digest is supported through `digestingQueue`. The `null` sentinel marks digest boundaries.
|
|
1059
|
-
18. **Store creator runs once** — at definition time. State persists across view mounts/unmounts. Call `useStore.$destroyFn()` (set via `Object.defineProperties` on the use-fn) to tear it down.
|
|
1060
|
-
19. **State for simple, Store for complex** — use `State.set` + `State.digest` for lightweight shared values. Reach for `defineStore` when you need reactive handlers, derived data via `computed(deps, fn)`, multi-instance isolation via `multi()`, or store-internal reactions via inner `observe`. Always pair State writes with `State.clean(keys)` mixin on consumers so data doesn't leak globally.
|
|
1061
|
-
20. **MF view paths use the remote project prefix** — `v-lark="remote-app/views/home"` triggers async loading through `FrameworkConfig.require` if the path isn't yet registered. Ensure `require` is configured AND `ModuleFederationPlugin` shares `@lark.js/mvc` as a singleton.
|
|
1062
|
-
21. **`CrossSite` is the export name** — register it as `registerViewClass("cross-site", CrossSite)`.
|
|
1063
|
-
22. **CrossSite uses `view=` not `xview=`** — `v-lark="cross-site?view=remote-app/views/home"`.
|
|
1064
|
-
23. **`Framework.use()` returns a Promise** — without the optional callback, it resolves to `unknown[]`. Without a configured `require`, it falls back to dynamic `import()`.
|
|
1065
|
-
24. **`Updater.parse` is path-only, no eval** — it accepts dotted paths and numeric literals. `updater.parse("1 + 2")` returns `undefined`. CSP-safe by design.
|
|
1066
|
-
25. **`LarkInnerKeys` for VDOM short-circuits** — `ldk` skips the entire diff for static elements; `lak` skips attribute diff but still diffs children; `lvk` is an assign-optimization marker.
|
|
1067
|
-
26. **MF: `splitChunks.chunks` MUST be `"async"`** — using `"all"` extracts `@lark.js/mvc` into a separate vendor chunk, breaking shared-scope initialization. The error surfaces as `ScriptExternalLoadError: Loading script failed (missing)`.
|
|
1068
|
-
27. **MF: `new Frame(containerId)` for independent contexts** — `Frame.createRoot()` (and the deprecated `Frame.root()`) is a singleton that ignores later id arguments. Each MF mount needs its own `new Frame()`.
|
|
1069
|
-
28. **MF: remote must explicitly import CSS** — Webpack bundles only CSS reachable from the exposed module's import graph. Without an `import "../index.css"` in the exposed entry, host pages won't receive utility classes used in the templates.
|
|
1070
|
-
29. **Sub-component `v-lark` paths must match exactly** — template strings embed the paths at build time; renaming a `registerViewClass` path without updating the template breaks the load.
|
|
1071
|
-
30. **Dynamic `import()` shape is unknown** — for chunk splitting, use a small `extractDefault()` helper to unwrap the ESM default, then cast with `as typeof View` (NOT `as any`).
|
|
1072
|
-
|
|
1073
|
-
## Migration notes (recent API changes)
|
|
1074
|
-
|
|
1075
|
-
- `ChangeEvent.keys` is now `ReadonlySet<string>` (was `Record<string, 1>`). Use `keys.has("foo")` instead of `keys.foo`. Affects `State.on("changed")` handlers and `view.observeState` callbacks.
|
|
1076
|
-
- `StateInterface.diff()` returns `ReadonlySet<string>`.
|
|
1077
|
-
- `Framework.toUrl(path, params, keepEmpty?)` — `keepEmpty` is now `Set<string>` (was `Record<string, number>`).
|
|
1078
|
-
- `Updater.set/digest`, `State.set/digest`, and `setData` take `excludes?: ReadonlySet<string>` (was `Set<string>`).
|
|
1079
|
-
- `Frame.root(id)` is `@deprecated`. Use `Frame.getRoot()` for reads, `Frame.createRoot(id)` for the explicit singleton creation, or `new Frame(id)` for independent mounts.
|
|
1080
|
-
- `Framework.config(...)` is `@deprecated`. Use `Framework.getConfig(key?)` and `Framework.setConfig(patch)`.
|
|
1081
|
-
- `Updater.parse` no longer evals — only safe path/literal resolution. Migrate to a small helper function if you needed expression eval.
|
|
1082
|
-
- `mark.ts` no longer writes magic keys onto host objects — it uses a module-level `WeakMap`. Works on frozen objects.
|
|
1083
|
-
- `Cache.del` now splices immediately (was leaving tombstones until the next eviction).
|
|
1084
|
-
|
|
1085
|
-
## References
|
|
973
|
+
`vitest.config.ts` targets the `tests/` directory with 16 test files covering core modules. `tsup.config.ts` defines four entry points (`index` / `vite` / `webpack` / `runtime`) with output in `dist/`.
|
|
1086
974
|
|
|
1087
|
-
|
|
975
|
+
## License
|
|
1088
976
|
|
|
1089
|
-
|
|
1090
|
-
- `references/template-syntax.md` — Full template language reference, including compilation pipeline, operators, control flow, encoders, and debug mode.
|
|
977
|
+
ISC. See `LICENSE` in the repository root.
|