@lark.js/mvc 0.0.3 → 0.0.5
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 +872 -727
- package/dist/{chunk-Y72BUONO.js → chunk-IIIY575B.js} +108 -97
- package/dist/index.cjs +1718 -1115
- package/dist/index.d.cts +1857 -731
- package/dist/index.d.ts +1857 -731
- package/dist/index.js +1708 -1111
- package/dist/runtime.cjs +70 -0
- package/dist/runtime.d.cts +29 -0
- package/dist/runtime.d.ts +29 -0
- package/dist/runtime.js +41 -0
- package/dist/vite.cjs +108 -97
- package/dist/vite.d.cts +3 -3
- package/dist/vite.d.ts +3 -3
- package/dist/vite.js +1 -1
- package/dist/webpack.cjs +108 -97
- package/dist/webpack.js +1 -1
- package/package.json +21 -8
- package/src/client.d.ts +80 -0
package/README.md
CHANGED
|
@@ -1,91 +1,268 @@
|
|
|
1
|
-
|
|
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
|
+
---
|
|
2
19
|
|
|
3
|
-
Lark
|
|
20
|
+
# Lark MVC Framework
|
|
4
21
|
|
|
5
|
-
|
|
22
|
+
`@lark.js/mvc` is a TypeScript MVC framework for single-page applications. It pairs a strict Model-View-Controller layout with Proxy-based reactive state, hash-based routing, and a real-DOM diff renderer. The framework treats micro-frontend integration (Module Federation) as a first-class concern.
|
|
23
|
+
|
|
24
|
+
This guide walks through architecture, the full public API, project layout, the three data pipelines, the template language, the build-tool integrations, and the common pitfalls. For exhaustive API signatures and template syntax, follow the pointers in [References](#references) at the end.
|
|
25
|
+
|
|
26
|
+
## When to reach for this skill
|
|
27
|
+
|
|
28
|
+
Any task that names — or clearly implies — Lark:
|
|
29
|
+
|
|
30
|
+
- Creating, extending, or registering Views; wiring view event handlers; setting up view lifecycle (`init`, `make`, `assign`, `render`, `destroy`).
|
|
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.
|
|
38
|
+
|
|
39
|
+
## Architecture
|
|
40
|
+
|
|
41
|
+
Lark separates code along three orthogonal axes:
|
|
42
|
+
|
|
43
|
+
- **Model**: `State` (simple global singleton, recommended for lightweight cross-view values), `defineStore` (Proxy-based reactive store with handlers, `computed`, multi-instance support — recommended for complex reactive state), `cell`/`observeCell` (standalone reactive cells), `Service` (API request manager with LFU cache + deduplication + serial queue).
|
|
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).
|
|
46
|
+
|
|
47
|
+
### The three data pipelines
|
|
48
|
+
|
|
49
|
+
Lark exposes three ways to flow data to a view. Pick the simplest one that solves the task.
|
|
50
|
+
|
|
51
|
+
1. **Updater pipeline** (view-local). Use when only the current view reads and writes the data.
|
|
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.
|
|
53
|
+
|
|
54
|
+
2. **State pipeline** (simple cross-view, recommended for lightweight shared values like counters, toggles, page title, session info).
|
|
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.
|
|
56
|
+
|
|
57
|
+
3. **Store pipeline** (complex cross-view, recommended when you need reactive handlers, derived data, multi-instance isolation, or store-internal reactions).
|
|
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.
|
|
59
|
+
|
|
60
|
+
### Boot sequence (order matters)
|
|
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
|
|
6
95
|
|
|
7
96
|
```
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
23
114
|
```
|
|
24
115
|
|
|
25
|
-
|
|
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.
|
|
26
117
|
|
|
27
|
-
|
|
118
|
+
## Quick start
|
|
28
119
|
|
|
29
|
-
|
|
120
|
+
### 1. Install
|
|
121
|
+
|
|
122
|
+
```bash
|
|
30
123
|
pnpm add @lark.js/mvc
|
|
31
124
|
```
|
|
32
125
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
1. 配置构建工具
|
|
126
|
+
### 2. Configure your bundler
|
|
36
127
|
|
|
37
|
-
Vite
|
|
128
|
+
Vite (recommended):
|
|
38
129
|
|
|
39
|
-
```
|
|
40
|
-
|
|
130
|
+
```ts
|
|
131
|
+
// vite.config.ts
|
|
132
|
+
import { defineConfig } from "vite";
|
|
133
|
+
import { resolve } from "path";
|
|
134
|
+
import { larkMvcPlugin } from "@lark.js/mvc/vite";
|
|
41
135
|
|
|
42
136
|
export default defineConfig({
|
|
43
137
|
plugins: [larkMvcPlugin()],
|
|
138
|
+
resolve: { alias: { "@": resolve(__dirname, "./src") } },
|
|
44
139
|
});
|
|
45
140
|
```
|
|
46
141
|
|
|
47
|
-
|
|
142
|
+
The plugin runs in the `pre` phase. Its `resolveId` hook tags `.html` imports with a `?lark-template` suffix so Vite doesn't treat them as static assets, then its `load` hook compiles the raw HTML through `extractGlobalVars()` + `compileTemplate()` into an ES module exporting `(data, viewId, refData) => string`.
|
|
48
143
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
144
|
+
For Webpack, mirror the same idea with the loader:
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
// webpack.config.mjs
|
|
148
|
+
import { larkMvcLoader } from "@lark.js/mvc/webpack";
|
|
52
149
|
|
|
53
150
|
export default {
|
|
151
|
+
// ...
|
|
54
152
|
module: {
|
|
55
153
|
rules: [
|
|
154
|
+
{ test: /\.ts$/, use: "ts-loader", exclude: /node_modules/ },
|
|
56
155
|
{
|
|
57
156
|
test: /\.html$/,
|
|
58
|
-
loader: larkMvcLoader,
|
|
157
|
+
use: [{ loader: larkMvcLoader }],
|
|
158
|
+
exclude: /index\.html$/, // HtmlWebpackPlugin handles the entry HTML
|
|
59
159
|
},
|
|
60
160
|
],
|
|
61
161
|
},
|
|
62
162
|
};
|
|
63
163
|
```
|
|
64
164
|
|
|
65
|
-
|
|
165
|
+
Both integrations accept `{ debug: true }` to inject source-position markers into the compiled template, so runtime errors point back to the original `.html` line and expression.
|
|
166
|
+
|
|
167
|
+
### 3. Entry HTML
|
|
168
|
+
|
|
169
|
+
```html
|
|
170
|
+
<!doctype html>
|
|
171
|
+
<html lang="en">
|
|
172
|
+
<head>
|
|
173
|
+
<meta charset="UTF-8" />
|
|
174
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
175
|
+
<title>My Lark App</title>
|
|
176
|
+
</head>
|
|
177
|
+
<body>
|
|
178
|
+
<div id="app"></div>
|
|
179
|
+
<script type="module" src="/src/boot.ts"></script>
|
|
180
|
+
</body>
|
|
181
|
+
</html>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The `<div id="app">` matches `rootId: "app"` in the boot config.
|
|
185
|
+
|
|
186
|
+
### 4. A project-level base View
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
// src/view.ts
|
|
190
|
+
import { defineView, Router } from "@lark.js/mvc";
|
|
191
|
+
|
|
192
|
+
export default defineView({
|
|
193
|
+
make() {
|
|
194
|
+
// Called once per instance via the merged ctors[] pipeline.
|
|
195
|
+
this.updater.set({ appName: "My App" });
|
|
196
|
+
this.on("destroy", () => {
|
|
197
|
+
console.log(`View destroyed: ${this.id}`);
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
navigate(path: string, params?: Record<string, unknown>) {
|
|
201
|
+
Router.to(path, params);
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`defineView` is a thin, type-safe wrapper around `View.extend`: it threads the literal's own shape into `this` via `ThisType<P & ViewInterface>`, so `this.appName` inside `make` is typed without manual casts. Runtime behavior is identical to `View.extend({...})`.
|
|
207
|
+
|
|
208
|
+
### 5. Boot
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
// src/boot.ts
|
|
212
|
+
import { Framework, registerViewClass, View } from "@lark.js/mvc";
|
|
213
|
+
import type { FrameworkConfig } from "@lark.js/mvc";
|
|
214
|
+
import HomeView from "./views/home";
|
|
215
|
+
import AboutView from "./views/about";
|
|
216
|
+
import NotFoundView from "./views/404";
|
|
217
|
+
|
|
218
|
+
registerViewClass("home", HomeView as typeof View);
|
|
219
|
+
registerViewClass("about", AboutView as typeof View);
|
|
220
|
+
registerViewClass("404", NotFoundView as typeof View);
|
|
221
|
+
|
|
222
|
+
const config: FrameworkConfig = {
|
|
223
|
+
rootId: "app",
|
|
224
|
+
defaultPath: "/home",
|
|
225
|
+
defaultView: "home",
|
|
226
|
+
routes: {
|
|
227
|
+
"/home": "home",
|
|
228
|
+
"/about": "about",
|
|
229
|
+
},
|
|
230
|
+
unmatchedView: "404",
|
|
231
|
+
error(e: Error) {
|
|
232
|
+
console.error("Lark error:", e);
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
Framework.boot(config);
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
All view classes must be registered _before_ `Framework.boot()`. The registry lives in `src/view-registry.ts` and is exposed through `registerViewClass` (re-exported from `@lark.js/mvc`).
|
|
240
|
+
|
|
241
|
+
## Defining Stores
|
|
66
242
|
|
|
67
|
-
|
|
243
|
+
### Basic store
|
|
68
244
|
|
|
69
|
-
```
|
|
245
|
+
```ts
|
|
246
|
+
// src/store/count.ts
|
|
70
247
|
import { defineStore } from "@lark.js/mvc";
|
|
71
248
|
|
|
72
|
-
|
|
249
|
+
interface CountStore {
|
|
73
250
|
count: number;
|
|
74
251
|
step: number;
|
|
252
|
+
doubled: number; // computed
|
|
75
253
|
history: string[];
|
|
76
254
|
increment: () => void;
|
|
77
255
|
decrement: () => void;
|
|
78
256
|
reset: () => void;
|
|
79
|
-
setStep: (val: number) => void;
|
|
80
|
-
clearHistory: () => void;
|
|
81
257
|
}
|
|
82
258
|
|
|
83
|
-
const useCountStore = defineStore<CountStore>(
|
|
84
|
-
|
|
259
|
+
const useCountStore = defineStore<CountStore>(
|
|
260
|
+
"count",
|
|
261
|
+
(store, { computed, lazySet, shallowSet }) => ({
|
|
85
262
|
count: 0,
|
|
86
263
|
step: 1,
|
|
264
|
+
doubled: computed(["count"], () => store.count * 2),
|
|
87
265
|
history: [] as string[],
|
|
88
|
-
|
|
89
266
|
increment() {
|
|
90
267
|
store.count = store.count + store.step;
|
|
91
268
|
store.history = [...store.history, `+${store.step} -> ${store.count}`];
|
|
@@ -96,850 +273,818 @@ const useCountStore = defineStore<CountStore>("count", (store) => {
|
|
|
96
273
|
},
|
|
97
274
|
reset() {
|
|
98
275
|
store.count = 0;
|
|
99
|
-
store.history = [...store.history, "Reset -> 0"];
|
|
100
|
-
},
|
|
101
|
-
setStep(val: number) {
|
|
102
|
-
store.step = val;
|
|
103
|
-
},
|
|
104
|
-
clearHistory() {
|
|
105
276
|
store.history = [];
|
|
106
277
|
},
|
|
107
|
-
}
|
|
108
|
-
|
|
278
|
+
}),
|
|
279
|
+
);
|
|
109
280
|
|
|
110
281
|
export default useCountStore;
|
|
111
282
|
```
|
|
112
283
|
|
|
113
|
-
|
|
284
|
+
### How the creator runs
|
|
114
285
|
|
|
115
|
-
|
|
286
|
+
The creator runs once at definition time. Lark walks the return value:
|
|
116
287
|
|
|
117
|
-
|
|
288
|
+
- Function entries become **handlers** on the store proxy (`store.increment()` invokes the original closure).
|
|
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.
|
|
118
291
|
|
|
119
|
-
|
|
292
|
+
### How `useStore(view)` works
|
|
120
293
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
294
|
+
- `useStore()` (no view) — read-only access for module-level utilities.
|
|
295
|
+
- `useStore(view)` — registers the view in the store's `boundViews` set; on view destroy, the view is automatically detached.
|
|
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.
|
|
126
299
|
|
|
127
|
-
|
|
128
|
-
// const template: (data: unknown, selfId: string, refData: unknown) => string;
|
|
129
|
-
template,
|
|
300
|
+
### Subscribing a view
|
|
130
301
|
|
|
131
|
-
|
|
132
|
-
const store = useCountStore(this);
|
|
133
|
-
store.observe(this, ["count", "step", "history"]);
|
|
134
|
-
},
|
|
302
|
+
`store.observe(view, keys?, defaultCallback?)` subscribes the view to store changes. Variations:
|
|
135
303
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
304
|
+
```ts
|
|
305
|
+
// Default: observe every state key in the store (including computeds).
|
|
306
|
+
// Avoids the "two lists to keep in sync" pain point.
|
|
307
|
+
store.observe(this);
|
|
139
308
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
},
|
|
309
|
+
// Explicit: only the listed keys trigger updates.
|
|
310
|
+
store.observe(this, ["count", "step"]);
|
|
143
311
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
312
|
+
// With a custom callback (override the default updater.digest behavior).
|
|
313
|
+
store.observe(this, ["count"], (changedMap) => {
|
|
314
|
+
console.log("count changed", changedMap);
|
|
315
|
+
});
|
|
147
316
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
317
|
+
// Fine-grained ObservePayload entries.
|
|
318
|
+
store.observe(this, [
|
|
319
|
+
{ key: "count", alias: "currentCount", lazy: false },
|
|
320
|
+
{
|
|
321
|
+
key: "items",
|
|
322
|
+
transform: (val) => ({ itemCount: (val as unknown[]).length }),
|
|
152
323
|
},
|
|
324
|
+
]);
|
|
325
|
+
|
|
326
|
+
// Inner observe — no view binding, for store-internal reactions.
|
|
327
|
+
// Keys are required (no useful default for a callback-only observer).
|
|
328
|
+
store.observe(undefined, ["step"], () => {
|
|
329
|
+
store.count = 0; // reset count when step changes
|
|
153
330
|
});
|
|
154
331
|
```
|
|
155
332
|
|
|
156
|
-
|
|
333
|
+
`lazy: true` (the default) calls `updater.digest()` to merge data. `lazy: false` calls `updater.set()` first, then `digest()` — useful when the callback supplies a transformed value that should land on the data object before the next render.
|
|
157
334
|
|
|
158
|
-
|
|
335
|
+
Inner observes are de-duplicated by `key + observeKeys.join("-") + cb.toString()`, so the same inner observe with identical key/callback won't register twice.
|
|
159
336
|
|
|
160
|
-
|
|
161
|
-
<div>
|
|
162
|
-
<div>{{=count}}</div>
|
|
163
|
-
<input type="number" value="{{=step}}" @change="stepChange()" />
|
|
164
|
-
<button @click="decrement()">-{{=step}}</button>
|
|
165
|
-
<button @click="reset()">Reset</button>
|
|
166
|
-
<button @click="increment()">+{{=step}}</button>
|
|
337
|
+
### Multi-instance stores
|
|
167
338
|
|
|
168
|
-
|
|
169
|
-
<ul>
|
|
170
|
-
{{each history as record}}
|
|
171
|
-
<li>{{=record}}</li>
|
|
172
|
-
{{/each}}
|
|
173
|
-
</ul>
|
|
174
|
-
{{else}}
|
|
175
|
-
<p>No history</p>
|
|
176
|
-
{{/if}}
|
|
177
|
-
</div>
|
|
178
|
-
```
|
|
339
|
+
When a component is reused N times and each instance needs its own state:
|
|
179
340
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
通过 registerViewClass 注册 View 类, 配置 routes 路由映射, 调用 Framework.boot(config) 启动框架
|
|
341
|
+
```ts
|
|
342
|
+
import { defineStore, multi } from "@lark.js/mvc";
|
|
183
343
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
344
|
+
const useCounterStore = defineStore("counter", (store) => ({
|
|
345
|
+
count: 0,
|
|
346
|
+
increment() {
|
|
347
|
+
store.count++;
|
|
348
|
+
},
|
|
349
|
+
}));
|
|
188
350
|
|
|
189
|
-
|
|
190
|
-
|
|
351
|
+
// multi() returns [useFn, mixinObj].
|
|
352
|
+
const [useMultiCounter, counterMixin] = multi(useCounterStore);
|
|
191
353
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
"/counter": "counter",
|
|
199
|
-
},
|
|
200
|
-
unmatchedView: "404",
|
|
201
|
-
error(e: Error) {
|
|
202
|
-
console.error("Lark application error:", e);
|
|
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"]);
|
|
203
360
|
},
|
|
204
361
|
});
|
|
205
362
|
```
|
|
206
363
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
Framework — 框架主入口
|
|
364
|
+
`multi()` intercepts the parent frame's `mountFrame` to propagate a `lark-comp-<storeName>` flag down the Frame tree. When `useFn(view)` is called, it looks up the flag and either returns an existing store clone or creates one via `cloneStore()`.
|
|
210
365
|
|
|
211
|
-
|
|
366
|
+
### Standalone reactive cells
|
|
212
367
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
1. 合并配置 (rootId, defaultView, routes, hashbang 等)
|
|
216
|
-
2. `Router._setConfig()` 注入路由配置
|
|
217
|
-
3. `EventDelegator.setFrameGetter()` 注入 Frame 查找器
|
|
218
|
-
4. 绑定 Router 的 changed 事件到 `dispatcherNotifyChange()`
|
|
219
|
-
5. 绑定 State 的 changed 事件到 `dispatcherNotifyChange()`
|
|
220
|
-
6. `Frame.root(rootId)` 创建根 Frame 节点
|
|
221
|
-
7. `Router._bind()` 绑定 hashchange 事件, 执行首次 diff
|
|
222
|
-
8. 如果路由没有已挂载的视图, 则 `rootFrame.mountView(defaultView)` 挂载默认视图
|
|
223
|
-
|
|
224
|
-
Framework 还提供以下工具方法
|
|
225
|
-
|
|
226
|
-
- mark/unmark 异步回调追踪
|
|
227
|
-
- dispatch 自定义 DOM 事件
|
|
228
|
-
- task 分片任务调度
|
|
229
|
-
- delay Promise 延时
|
|
230
|
-
- use 模块加载
|
|
231
|
-
- toMap/toTry/toUrl/parseUrl/has/keys/inside/node/guard/applyStyle 等工具函数
|
|
232
|
-
- Cache, Base, Router, State, View, Frame 等模块的直接访问
|
|
233
|
-
|
|
234
|
-
callFunction 分片执行
|
|
368
|
+
For one-off reactive values that don't need a full store:
|
|
235
369
|
|
|
236
370
|
```ts
|
|
237
|
-
|
|
238
|
-
// 扁平存储: [fn, ctx, args, fn2, ctx2, args2, ...]
|
|
239
|
-
const callList = [];
|
|
240
|
-
// 每片最大执行时间 48ms
|
|
241
|
-
const callBreakTime = 48;
|
|
242
|
-
|
|
243
|
-
function startCall() {
|
|
244
|
-
const last = Date.now(),
|
|
245
|
-
next;
|
|
246
|
-
while (true) {
|
|
247
|
-
next = callList[callIndex - 1];
|
|
248
|
-
// 依次取出函数执行
|
|
249
|
-
if (next) {
|
|
250
|
-
next.apply(callList[callIndex], callList[callIndex + 1]);
|
|
251
|
-
callIndex += 3; // 每次消费 3 个元素: fn/ctx/args
|
|
252
|
-
// 每执行一个函数都会检查一次耗时
|
|
253
|
-
if (Date.now() - last > callBreakTime && callList.length > callIndex) {
|
|
254
|
-
setTimeout(startCall); // 超时则让出主线程
|
|
255
|
-
break;
|
|
256
|
-
}
|
|
257
|
-
} else {
|
|
258
|
-
callList.length = callIndex = 0; // 全部执行, 清空
|
|
259
|
-
break;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function callFunction(fn, args, ctx) {
|
|
265
|
-
callList.push(fn, ctx, args); // 入队
|
|
266
|
-
if (!callIndex) {
|
|
267
|
-
// 如果没有在执行中
|
|
268
|
-
callIndex = 1;
|
|
269
|
-
setTimeout(startCall); // 异步启动
|
|
270
|
-
}
|
|
271
|
-
}
|
|
371
|
+
import { cell, observeCell } from "@lark.js/mvc";
|
|
272
372
|
|
|
273
|
-
|
|
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
|
|
274
377
|
```
|
|
275
378
|
|
|
276
|
-
-
|
|
277
|
-
- 异步启动: 通过 setTimeout(startCall) 推迟到下一个事件循环开始执行
|
|
278
|
-
- 分片执行: startCall 在 while 循环中, 依次取出函数执行, 每执行一个函数都会检查一次耗时
|
|
279
|
-
|
|
280
|
-
这是早期前端的长任务分片实践,用于
|
|
281
|
-
|
|
282
|
-
- 避免一次性执行大量回调, 例如大量 View 渲染完成后的通知导致页面卡顿
|
|
283
|
-
- 保持 60fps (每帧约 16ms, 48ms 约占 3 帧) 的用户交互响应
|
|
284
|
-
- 与 requestIdleCallback 的思路类似, 但 callFunction 设计时浏览器可能还没有该 API
|
|
285
|
-
|
|
286
|
-
优化
|
|
287
|
-
|
|
288
|
-
任务调度: `Framework.task(fn, args, context)` 提供分片执行能力, 调度优先级为
|
|
289
|
-
|
|
290
|
-
- `scheduler.postTask('background')` Chrome 94+
|
|
291
|
-
- `requestIdleCallback` Chrome 47+
|
|
292
|
-
- `setTimeout(0)` 通用 fallback 方案
|
|
293
|
-
|
|
294
|
-
使用 `requestIdleCallback` 时依据 `deadline.timeRemaining()` 自适应确定每片执行数量, 回退到 setTimeout 时使用固定 48ms 时间片
|
|
295
|
-
|
|
296
|
-
| | callFunction | task |
|
|
297
|
-
| -------------- | -------------------------------------- | ----------------------------------------------------- |
|
|
298
|
-
| 调度 API | setTimeout | scheduler.postTask → requestIdleCallback → setTimeout |
|
|
299
|
-
| 时间切片 | 固定 48ms | 自适应 deadline.timeRemaining() + 48ms fallback |
|
|
300
|
-
| 接口签名 | 扁平数组 [fn, ctx, args, ...] 逐个消费 | 相同保持兼容性 |
|
|
301
|
-
| 让出主线程时机 | 仅超时 | |
|
|
302
|
-
|
|
303
|
-
Router — hash 路由器
|
|
304
|
-
|
|
305
|
-
Router hash 路由器基于 location.hash, 支持 #! 前缀的 hashbang 模式
|
|
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).
|
|
306
380
|
|
|
307
|
-
|
|
381
|
+
## Defining Views
|
|
308
382
|
|
|
309
|
-
|
|
310
|
-
2. changed 阶段: 路由改变完成后触发, 通知 Dispatcher 派发器更新视图
|
|
383
|
+
### View template
|
|
311
384
|
|
|
312
|
-
|
|
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>
|
|
313
391
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
392
|
+
{{if count > 0}}
|
|
393
|
+
<p>Positive</p>
|
|
394
|
+
{{else}}
|
|
395
|
+
<p>Zero or negative</p>
|
|
396
|
+
{{/if}}
|
|
318
397
|
|
|
319
|
-
|
|
398
|
+
<ul>
|
|
399
|
+
{{forOf items as item idx}}
|
|
400
|
+
<li id="item-{{=item.id}}">{{=idx}}: {{=item.name}}</li>
|
|
401
|
+
{{/forOf}}
|
|
402
|
+
</ul>
|
|
320
403
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
href: string;
|
|
325
|
-
/** 查询字符串 */
|
|
326
|
-
srcQuery: string;
|
|
327
|
-
/** hash 字符串 */
|
|
328
|
-
srcHash: string;
|
|
329
|
-
/** 查询参数 */
|
|
330
|
-
query: ParsedUri;
|
|
331
|
-
/** 哈希参数 */
|
|
332
|
-
hash: ParsedUri;
|
|
333
|
-
/** 合并参数 */
|
|
334
|
-
params: Record<string, string>;
|
|
335
|
-
/** 视图路径 */
|
|
336
|
-
view?: string;
|
|
337
|
-
/** 路由路径 */
|
|
338
|
-
path?: string;
|
|
339
|
-
/** 参数读取函数 */
|
|
340
|
-
get: (key: string, defaultValue?: string) => string;
|
|
341
|
-
}
|
|
404
|
+
<!-- Sub-view embedding -->
|
|
405
|
+
<div v-lark="components/child"></div>
|
|
406
|
+
</div>
|
|
342
407
|
```
|
|
343
408
|
|
|
344
|
-
|
|
409
|
+
### View class
|
|
345
410
|
|
|
346
411
|
```ts
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
/** 视图变更 */
|
|
353
|
-
view?: ParamDiff;
|
|
354
|
-
/** 是否首次强制变更 */
|
|
355
|
-
force: boolean;
|
|
356
|
-
/** 是否有变更 */
|
|
357
|
-
changed: boolean;
|
|
358
|
-
}
|
|
359
|
-
```
|
|
360
|
-
|
|
361
|
-
beforeunload 事件支持: 监听 window 的 beforeunload 事件, 若监听器设置了 msg 字段, 浏览器弹出离开确认对话框
|
|
412
|
+
// src/views/home.ts
|
|
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";
|
|
362
417
|
|
|
363
|
-
|
|
418
|
+
export default View.extend({
|
|
419
|
+
template,
|
|
364
420
|
|
|
365
|
-
|
|
421
|
+
init() {
|
|
422
|
+
this.assign();
|
|
366
423
|
|
|
367
|
-
|
|
424
|
+
const store = useCountStore(this);
|
|
425
|
+
store.observe(this); // observe every store key (D5 default)
|
|
426
|
+
},
|
|
368
427
|
|
|
369
|
-
|
|
428
|
+
// assign() pulls the latest store + State values into this.updater.
|
|
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();
|
|
433
|
+
|
|
434
|
+
const store = useCountStore();
|
|
435
|
+
this.updater.set({
|
|
436
|
+
title: "Home",
|
|
437
|
+
count: store.count,
|
|
438
|
+
step: store.step,
|
|
439
|
+
items: [
|
|
440
|
+
{ id: "a", name: "Alpha" },
|
|
441
|
+
{ id: "b", name: "Beta" },
|
|
442
|
+
],
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
return this.updater.altered();
|
|
446
|
+
},
|
|
370
447
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
448
|
+
// render() is wrapped by the framework to manage signature/lifecycle.
|
|
449
|
+
// The default implementation calls this.updater.digest().
|
|
450
|
+
render() {
|
|
451
|
+
this.updater.digest();
|
|
452
|
+
},
|
|
374
453
|
|
|
375
|
-
|
|
454
|
+
// Event method naming: `name<eventType>`. See "Event methods" below.
|
|
455
|
+
"navigateTo<click>"(e: Record<string, unknown>) {
|
|
456
|
+
const params = e["params"] as Record<string, string> | undefined;
|
|
457
|
+
if (params?.path) Router.to(params.path);
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
```
|
|
376
461
|
|
|
377
|
-
|
|
462
|
+
### Event methods
|
|
378
463
|
|
|
379
|
-
|
|
464
|
+
Lark scans the View prototype once per class (in `View.prepare`) and builds three event maps on the prototype (`$evtObjMap`, `$selMap`, `$globalEvtList`). DOM events are delegated to `document.body` using **capture-phase** listeners with reference counting — the first binding installs the listener, the last unbinding removes it.
|
|
380
465
|
|
|
381
|
-
|
|
466
|
+
| Pattern | Meaning |
|
|
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 |
|
|
382
475
|
|
|
383
|
-
|
|
476
|
+
Each event handler receives an event object with these augmented fields:
|
|
384
477
|
|
|
385
|
-
|
|
478
|
+
- `e.eventTarget` — the actual DOM element that was clicked.
|
|
479
|
+
- `e.params` — parsed parameters from `@event` attributes (URL query string format).
|
|
480
|
+
- All standard DOM Event properties (`type`, `target`, etc.).
|
|
386
481
|
|
|
387
|
-
|
|
482
|
+
When two mixins define the same event method, they're merged into a single function that calls both in sequence via a `handlerList` array.
|
|
388
483
|
|
|
389
|
-
|
|
484
|
+
### Resource management
|
|
390
485
|
|
|
391
|
-
|
|
486
|
+
`capture` and `release` manage objects whose lifetime tracks the view (timers, services, observers, etc.):
|
|
392
487
|
|
|
393
|
-
```
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
488
|
+
```ts
|
|
489
|
+
const timer = setInterval(() => {
|
|
490
|
+
/* ... */
|
|
491
|
+
}, 1000);
|
|
492
|
+
this.capture(
|
|
493
|
+
"myTimer",
|
|
494
|
+
{
|
|
495
|
+
destroy() {
|
|
496
|
+
clearInterval(timer);
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
true,
|
|
500
|
+
);
|
|
501
|
+
// destroyOnRender=true: destroyed on next render call
|
|
502
|
+
// destroyOnRender=false: destroyed only on view destroy
|
|
400
503
|
```
|
|
401
504
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
defineStore 返回 useStore 函数, 内部维护三个状态: IDLE 未使用、ACTIVE 已激活、DESTROYED 已销毁
|
|
505
|
+
`release(key, destroy=true)` removes the entry (and calls `.destroy()` unless `destroy=false`).
|
|
405
506
|
|
|
406
|
-
|
|
407
|
-
- 所有绑定的 View 销毁后, store 自动进入 DESTROYED 状态
|
|
408
|
-
- 重新调用 useStore(view) 时会重新执行 creator 创建新的 store 实例
|
|
507
|
+
### Async safety with `wrapAsync`
|
|
409
508
|
|
|
410
|
-
|
|
509
|
+
Async callbacks may resolve after the view has been re-rendered or destroyed. `wrapAsync` captures the current signature so the callback short-circuits if the view has moved on:
|
|
411
510
|
|
|
412
511
|
```ts
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
// 可选, 回调函数, 默认为 updater.digest
|
|
420
|
-
defCallback?: (changedMap) => void,
|
|
421
|
-
): () => void
|
|
422
|
-
```
|
|
423
|
-
|
|
424
|
-
```ts
|
|
425
|
-
interface ObservePayload {
|
|
426
|
-
key: string; // 监听的键, 支持 a.b 路径
|
|
427
|
-
alias?: string; // 别名
|
|
428
|
-
cb: (changedMap) => void; // 自定义回调函数
|
|
429
|
-
lazy?: boolean; // 是否延迟执行, 默认 true
|
|
430
|
-
transform?: (val) => object; // 状态派生函数
|
|
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
|
|
431
518
|
}
|
|
432
519
|
```
|
|
433
520
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
- 使用 `freezeData()` 冻结数据, 生成不可变快照, 适配 React 的不可变数据模式
|
|
437
|
-
- 每次状态变更后生成新的快照, 确保引用变化以触发 React 组件重渲染
|
|
438
|
-
|
|
439
|
-
NodeStore: observe 直接执行回调, 不绑定视图生命周期, 适用于 Node.js 环境下的状态管理
|
|
440
|
-
|
|
441
|
-
_lazySet — 批量懒更新_
|
|
442
|
-
|
|
443
|
-
`lazySet(target, data)` 批量设置多个属性值, 不触发任何响应式通知. 内部通过 curLazySetKey 标记正在 lazySet 的属性 key, Proxy set 拦截器检测到匹配时跳过 trigger
|
|
444
|
-
|
|
445
|
-
_shallowSet — 浅层响应式_
|
|
446
|
-
|
|
447
|
-
`shallowSet(target, key, data)` 创建浅层响应式状态: 只有顶层的 key 变化才触发 trigger, 内部嵌套对象的变化不触发
|
|
448
|
-
|
|
449
|
-
_cell / observeCell — 独立的响应式数据_
|
|
450
|
-
|
|
451
|
-
- cell(data) 创建独立的响应式数据
|
|
452
|
-
- observeCell(state, cb, immediate?) 监听 cell 变更
|
|
453
|
-
|
|
454
|
-
cell 本质上是 createState 的轻量封装, 适用于不依赖 View 生命周期的独立的响应式数据
|
|
455
|
-
|
|
456
|
-
_multi — 多实例 store_
|
|
457
|
-
|
|
458
|
-
multi(useStore) 创建支持多实例的 store, 内部通过 flag 标识不同的 View, 首次调用时为每个 flag 克隆一个新的 store 实例 (cloneStore), 每个 store 拥有独立的 storeName 和状态. flag 通过拦截 mountFrame 沿 Frame 树向下传播, 确保同一个 Frame 树共享同一个 store 实例, 不同 Frame 树使用独立的 store 实例
|
|
459
|
-
|
|
460
|
-
- `getStore(name)` / `delStore(name)` / `cloneStore(name, useStore, config?)` 按 store 名称获取、删除、克隆 store 实例
|
|
461
|
-
- `isStoreActive(name)` 检查 store 是否处于 ACTIVE 状态
|
|
462
|
-
- `isState(target)` 检查对象是否为 Proxy 响应式状态
|
|
463
|
-
|
|
464
|
-
## Frame — 视图生命周期树
|
|
465
|
-
|
|
466
|
-
Frame 是管理 View 生命周期的容器, 与 DOM 结构对应组成 Frame 树; 每个 Frame 拥有一个 View 实例, 负责挂载、卸载、子区域管理和方法调用
|
|
467
|
-
|
|
468
|
-
Frame 树的设计思路
|
|
469
|
-
|
|
470
|
-
- Frame 树与 DOM 树结构相同, 每个 Frame 节点对应一个 DOM 元素 (通过 ID 关联)
|
|
471
|
-
- Frame 对象挂载到 DOM 元素的 frame 属性上 [frame.ts](./src/frame.ts#L130)
|
|
521
|
+
### Location observation
|
|
472
522
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
//
|
|
478
|
-
const frame = {
|
|
479
|
-
id: "app", // frameId, 与 DOM 元素 ID 相同
|
|
480
|
-
|
|
481
|
-
parentId: undefined, // rootFrame 没有父节点
|
|
482
|
-
|
|
483
|
-
// childrenCount: 子 frame 数量
|
|
484
|
-
childrenCount: 2, // 有 2 个子 frame
|
|
485
|
-
|
|
486
|
-
// 已触发 created 事件的子 frame 数量
|
|
487
|
-
readyCount: 2, // 2 个子 frame 已触发 created 事件
|
|
488
|
-
|
|
489
|
-
// 所有子 frame 是否已触发 created 事件
|
|
490
|
-
childrenCreated: 1,
|
|
491
|
-
|
|
492
|
-
// 子 frameId 映射
|
|
493
|
-
childrenMap: {
|
|
494
|
-
frame_0: "frame_0", // 对应 v-lark="components/counter-store"
|
|
495
|
-
frame_1: "frame_1", // 对应 v-lark="components/counter-updater"
|
|
496
|
-
},
|
|
497
|
-
|
|
498
|
-
// readyMap 已 ready 的子 frame 集合
|
|
499
|
-
// hasAltered 子 frame 是否被修改
|
|
500
|
-
// signature 异步操作签名
|
|
501
|
-
// invokeList 延迟方法调用列表
|
|
502
|
-
// originalTemplate 挂载前的原始 html
|
|
503
|
-
};
|
|
523
|
+
```ts
|
|
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
|
|
504
528
|
```
|
|
505
529
|
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
- `mountView(viewPath, viewInitParams?)`: 查找 View 类、实例化、执行 `init()` 和 `render()`, 将 HTML 注入 DOM
|
|
509
|
-
- `unmountView()`: 销毁 View 以及所有子 Frame, 清理事件绑定与资源
|
|
510
|
-
- `mountFrame(frameId, viewPath, viewInitParams?)`: 挂载子 Frame, 复用对象池中的 Frame 或创建新 Frame
|
|
511
|
-
- `unmountFrame(id?)`: 卸载子 Frame, 归还 Frame 对象到对象池
|
|
512
|
-
- `mountZone(zoneId?)` / `unmountZone(zoneId?)`: 管理区域内的 v-lark 子视图, 区域内的子 Frame 在 mountZone 时批量创建
|
|
513
|
-
- `invoke(name, args?)`: 沿 Frame 树广播方法调用; 如果 View 已渲染则直接调用, 否则加入 invokeList 延迟执行
|
|
514
|
-
- `children()`: 返回所有子 Frame ID 列表
|
|
515
|
-
- `parent(level?)`: 获取指定层级的父 Frame
|
|
516
|
-
|
|
517
|
-
对象池: Frame 对象使用 frameCache 数组作为对象池, unmountFrame 时将 Frame 归还池中, mountFrame 时优先从池中取出复用, 避免频繁 GC
|
|
518
|
-
|
|
519
|
-
事件通知: notifyCreated 与 notifyAlter 事件沿 Frame 树向上冒泡, 当所有子 Frame 就绪时触发 created 事件, 子 Frame 发生变化时触发 alter 事件. holdFireCreated 标志用于批量挂载时延迟触发 created 事件. 这些事件用于 waitZoneViewsRendered() 等待子视图就绪
|
|
520
|
-
|
|
521
|
-
translateQuery: 当 viewPath 包含 SPLITTER 引用时, 通过父视图的 refData 解析引用参数, 实现父视图向子视图传递对象引用
|
|
522
|
-
|
|
523
|
-
registerViewClass(viewPath, ViewClass): 注册 View 类到 viewClassRegistry, 提供给 mountView 查找
|
|
524
|
-
|
|
525
|
-
View — 视图基类
|
|
526
|
-
|
|
527
|
-
View 视图基类负责用户交互, 提供数据绑定、事件处理、生命周期与视图继承能力
|
|
528
|
-
|
|
529
|
-
_视图继承_
|
|
530
|
+
When the listed params or path change, the framework re-runs the view's render automatically.
|
|
530
531
|
|
|
531
|
-
|
|
532
|
-
- `View.merge(mixin)`: 混入功能到 View 原型, 事件方法以 MixinEventHandler 形式合并, 保留原始 handler 列表
|
|
532
|
+
### State observation (for the State pipeline)
|
|
533
533
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
"$window<resize>"(e) {}
|
|
539
|
-
|
|
540
|
-
// document 上监听 keydown
|
|
541
|
-
"$document<keydown>"(e) {}
|
|
542
|
-
|
|
543
|
-
// 点击 .btn 元素时触发
|
|
544
|
-
"$btn<click>"(e) {}
|
|
534
|
+
```ts
|
|
535
|
+
this.observeState("count,step"); // comma-separated
|
|
536
|
+
this.observeState(["count", "step"]); // array
|
|
537
|
+
```
|
|
545
538
|
|
|
546
|
-
|
|
547
|
-
"$tooltip<mouseenter>"(e) {}
|
|
539
|
+
When State.digest() flips one of these keys, the framework re-renders the view.
|
|
548
540
|
|
|
549
|
-
|
|
550
|
-
// 点击 frame 根节点时触发
|
|
551
|
-
"$<click>"(e) {}
|
|
552
|
-
```
|
|
541
|
+
### Sub-view embedding
|
|
553
542
|
|
|
554
543
|
```html
|
|
555
|
-
<
|
|
556
|
-
<div class="tooltip">不需要写 @event, 适用于批量绑定</div>
|
|
544
|
+
<div v-lark="components/child-view"></div>
|
|
557
545
|
```
|
|
558
546
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
_生命周期方法_
|
|
562
|
-
|
|
563
|
-
- `make()`: 构造函数, View 实例创建时调用
|
|
564
|
-
- `init(options)`: 初始化, 接收路由参数与传入数据; 返回 Promise 对象时, 框架会等待该 Promise 对象 resolve 后再执行 render
|
|
565
|
-
- `render()`: 渲染方法, 内部调用 `updater.digest()` 生成 HTML 并执行 VDOM diff 更新 DOM; render 被 `viewWrapMethod` 包装, 包装函数会: 检查 signature 有效性, 递增 signature, 执行资源清理 (destroyOnRender), 调用 unmark 使旧的异步回调失效, 调用 `assign(])` 判断是否可以跳过完整渲染
|
|
566
|
-
- `assign(options)`: 响应式更新模式, 仅当 assign() 返回 true 时跳过完整渲染, 执行增量更新
|
|
567
|
-
|
|
568
|
-
> Lark 渲染管线对比 react 渲染管线
|
|
569
|
-
|
|
570
|
-
- react 渲染管线: state 变化 → 生成 VDOM 树 (JS 对象树) → 与旧 VDOM 树 diff → 计算最小 DOM 操作 → 批量提交; react 始终操作 JS 对象, 没有生成 HTML 字符串
|
|
571
|
-
- lark 渲染官宣: state 变化 → 调用模板函数生成 HTML 字符串 → 解析为真实 DOM → 与页面上真实 DOM 做 diff → 收集 DOM 操作 → 批量提交
|
|
572
|
-
|
|
573
|
-
Lark 框架的 diff 算法是真实 DOM diff, 只是真实 DOM 不在页面上
|
|
574
|
-
|
|
575
|
-
_数据观察_
|
|
576
|
-
|
|
577
|
-
- `observeLocation(keys, observePath?):` 声明关注的路由参数键, 路由变更时如果匹配的键发生变化, 则自动触发 View 重新渲染
|
|
578
|
-
- `observeState(keys)`: 声明关注的 State 键, State 变更时如果匹配的键发生变化, 则自动触发 View 重新渲染; keys 是逗号分隔的字符串
|
|
579
|
-
|
|
580
|
-
_资源管理_
|
|
581
|
-
|
|
582
|
-
- `capture(key, resource?, destroyOnRender?)`: 注册资源到 View, 当 View 重新渲染时自动销毁有 destroyOnRender 标记的旧资源
|
|
583
|
-
- `release(key, destroy?)`: 手动释放资源
|
|
584
|
-
|
|
585
|
-
_异步安全_
|
|
586
|
-
|
|
587
|
-
- `wrapAsync(fn, context?)`: 包装异步回调, 通过 `mark()` 生成签名校验函数, View 销毁或重新渲染后 (unmark) 回调不会执行; wrapAsync 检查当前 signature 是否与调用时一致
|
|
588
|
-
- `leaveTip(message, condition):` 页面离开提示, 条件不满足时阻止跳转; 监听 Router 的 change 事件和 window 的 beforeunload 事件
|
|
589
|
-
|
|
590
|
-
Updater — 数据绑定与 VDOM diff
|
|
547
|
+
At mount time, `Frame.mountZone` runs `querySelectorAll("[v-lark]")` on the view's root, creates a child Frame for each match, and mounts the registered View class. The container's inner content is replaced by the child view's rendered output.
|
|
591
548
|
|
|
592
|
-
|
|
549
|
+
For dynamic loading (no upfront `registerViewClass`), `mountView` automatically calls `Framework.use()` to load the View class through the configured `require` hook (see Module Federation below).
|
|
593
550
|
|
|
594
|
-
|
|
551
|
+
## Defining the Framework Boot
|
|
595
552
|
|
|
596
|
-
|
|
597
|
-
2. `updater.digest(data?, excludes?)`: 触发渲染管线:
|
|
598
|
-
- 如果传入了 data, 则先调用 `set(data, excludes)`
|
|
599
|
-
- 调用模板函数 `template(refData, viewId, refData)` 生成 HTML 字符串
|
|
600
|
-
- 通过虚拟文档 (vdomGetNode) 解析 HTML 为 DOM 节点
|
|
601
|
-
- 执行 VDOM diff (vdomSetChildNodes) 实际上是真实 DOM diff, 计算最小 DOM 操作集
|
|
602
|
-
- 调用 `applyIdUpdates()` 批量更新元素 ID
|
|
603
|
-
- 调用 `applyVdomOps()` 批量执行 DOM 操作
|
|
604
|
-
- 通知 Frame 处理新增的 v-lark 子视图 (mountZone)
|
|
605
|
-
3. `updater.snapshot()`: 保存当前数据的 JSON 快照
|
|
606
|
-
4. `updater.altered()`: 检测数据是否与上次快照不同 (通过 JSON.stringify 比较)
|
|
607
|
-
5. `updater.get(key?)`: 获取数据, 不传递 key 则返回完整数据对象
|
|
553
|
+
`Framework.boot(config)` config accepts:
|
|
608
554
|
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
静态跳过: 有 ldk 属性且值相同的元素跳过 diff; 有 lak 属性且值相同的元素跳过属性更新
|
|
629
|
-
|
|
630
|
-
DOM 操作编码为数字操作码:
|
|
631
|
-
|
|
632
|
-
- 1 = `appendChild(parent, newChild)`
|
|
633
|
-
- 2 = `removeChild(parent, oldChild)`
|
|
634
|
-
- 4 = `replaceChild(parent, newChild, oldChild)`
|
|
635
|
-
- 8 = `insertBefore(parent, newChild, refChild)`
|
|
636
|
-
|
|
637
|
-
`applyVdomOps(ops)` 批量执行操作码, `applyIdUpdates(updates)` 批量更新元素 ID (用于事件委托选择器匹配)
|
|
638
|
-
|
|
639
|
-
HTML 解析: `vdomGetNode(html, refNode)` 使用 `document.implementation.createHTMLDocument()` 创建虚拟文档, 通过 WrapMeta 映射处理 table, select, SVG, MathML 等需要特定父元素的标签 (例如 thead 需要包在 `<table>` 中, td 需要 `<table>, <tbody>, <tr>`). 自动检测 refNode 的 namespaceURI, SVG 命名空间使用 `<g>` 包裹, MathML 使用 `<math>` 包裹
|
|
640
|
-
|
|
641
|
-
编码工具函数: encodeHTML (HTML 实体编码), encodeSafe (null 安全的字符串转换), encodeURIExtra (URI 编码), encodeQ (引号编码)
|
|
642
|
-
|
|
643
|
-
EventDelegator — DOM 事件委托
|
|
644
|
-
|
|
645
|
-
所有 DOM 事件委托到 document.body, 通过事件冒泡机制捕获. 每个 View 的 @event 属性在编译时编码为 viewId\x1ehandlerName(params) 格式, 运行时由 EventDelegator 解析并分发
|
|
646
|
-
|
|
647
|
-
核心机制:
|
|
555
|
+
```ts
|
|
556
|
+
interface FrameworkConfig {
|
|
557
|
+
rootId: string; // required — DOM id for the root frame
|
|
558
|
+
defaultView?: string; // default view path
|
|
559
|
+
defaultPath?: string; // path when URL hash is empty (defaults to "/")
|
|
560
|
+
routes?: Record<string, string | RouteViewConfig>; // path → view path mapping
|
|
561
|
+
hashbang?: string; // defaults to "#!"
|
|
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
|
|
572
|
+
}
|
|
573
|
+
```
|
|
648
574
|
|
|
649
|
-
|
|
650
|
-
- `domEventProcessor(event)`: 事件处理入口, 从目标元素向上遍历 DOM 树查找 @event 属性, 解析得到 View ID 与处理器名称; 解析使用 EVENT_METHOD_REGEX 正则, 捕获 Frame ID (可选), handler 名称, 参数字符串
|
|
651
|
-
- `findFrameInfo(element)`: View 边界检测, 从目标元素向上查找, 当遇到已绑定的 Frame 元素时停止, 确保事件不会跨 View 传播. 通过 rangeFrameId 属性标记 View 边界, 事件冒泡到边界时停止搜索
|
|
652
|
-
- `clearRangeEvents(frameId)`: 清除指定 View 范围内的事件标记
|
|
575
|
+
After boot, prefer `Framework.getConfig(key)` for reads and `Framework.setConfig(patch)` for writes. The older `Framework.config(...)` overload still works but is `@deprecated`.
|
|
653
576
|
|
|
654
|
-
|
|
577
|
+
## Router
|
|
655
578
|
|
|
656
|
-
|
|
579
|
+
### Navigation
|
|
657
580
|
|
|
658
|
-
|
|
581
|
+
```ts
|
|
582
|
+
Router.to("/list", { page: 2 }); // path + params
|
|
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
|
+
```
|
|
659
587
|
|
|
660
|
-
|
|
588
|
+
### Parsing
|
|
661
589
|
|
|
662
|
-
|
|
590
|
+
```ts
|
|
591
|
+
const loc = Router.parse(); // current location
|
|
592
|
+
const loc = Router.parse("https://x.com/?a=1#!/path?p=v");
|
|
593
|
+
// loc.path, loc.params, loc.hash, loc.query, loc.view, loc.get("key", "default")
|
|
594
|
+
const diff = Router.diff(); // last LocationDiff or undefined
|
|
595
|
+
```
|
|
663
596
|
|
|
664
|
-
|
|
597
|
+
### Two-phase change events (existing API)
|
|
665
598
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
599
|
+
```ts
|
|
600
|
+
Router.on("change", (e) => {
|
|
601
|
+
if (hasUnsavedChanges)
|
|
602
|
+
e.prevent(); // pause subsequent processing
|
|
603
|
+
else if (mustReject)
|
|
604
|
+
e.reject(); // revert URL to lastHash
|
|
605
|
+
else e.resolve(); // commit (auto if neither called)
|
|
606
|
+
});
|
|
607
|
+
Router.on("changed", (diff) => {
|
|
608
|
+
// diff is a LocationDiff: { params, path?, view?, force, changed }
|
|
609
|
+
});
|
|
610
|
+
```
|
|
673
611
|
|
|
674
|
-
|
|
612
|
+
### Async route guards (`Router.beforeEach`)
|
|
675
613
|
|
|
676
|
-
|
|
614
|
+
```ts
|
|
615
|
+
const off = Router.beforeEach(async (to, from) => {
|
|
616
|
+
if (to.path === "/admin") {
|
|
617
|
+
const ok = await checkPermission();
|
|
618
|
+
return ok; // false → revert URL, throw → also reverts
|
|
619
|
+
}
|
|
620
|
+
return true; // or undefined — permits navigation
|
|
621
|
+
});
|
|
622
|
+
// later, off() to unsubscribe
|
|
623
|
+
```
|
|
677
624
|
|
|
678
|
-
|
|
625
|
+
Guards run in registration order. Any guard that returns/resolves to `false`, throws, or rejects aborts the navigation and reverts the URL. Returning `true`, `undefined`, or any non-`false` value permits it.
|
|
679
626
|
|
|
680
|
-
|
|
627
|
+
## Service (API requests)
|
|
681
628
|
|
|
682
|
-
|
|
629
|
+
`Service` is an opinionated layer around `fetch` (or any sync function) with LFU caching, in-flight deduplication, serial task queueing, and lifecycle events.
|
|
683
630
|
|
|
684
|
-
|
|
631
|
+
```ts
|
|
632
|
+
import { Service, type Payload } from "@lark.js/mvc";
|
|
633
|
+
|
|
634
|
+
const AppService = Service.extend(
|
|
635
|
+
(payload, callback) => {
|
|
636
|
+
fetch(payload.get<string>("url"), {
|
|
637
|
+
method: payload.get<string>("method") || "GET",
|
|
638
|
+
headers: { "Content-Type": "application/json" },
|
|
639
|
+
body: payload.get("data")
|
|
640
|
+
? JSON.stringify(payload.get("data"))
|
|
641
|
+
: undefined,
|
|
642
|
+
})
|
|
643
|
+
.then((r) => r.json())
|
|
644
|
+
.then((data) => {
|
|
645
|
+
payload.set(data);
|
|
646
|
+
callback();
|
|
647
|
+
})
|
|
648
|
+
.catch(() => callback());
|
|
649
|
+
},
|
|
650
|
+
20, // cacheMax
|
|
651
|
+
5, // cacheBuffer
|
|
652
|
+
);
|
|
653
|
+
|
|
654
|
+
AppService.add([
|
|
655
|
+
{ name: "userList", url: "/api/users", cache: 60_000 },
|
|
656
|
+
{
|
|
657
|
+
name: "userDetail",
|
|
658
|
+
url: "/api/users/:id",
|
|
659
|
+
cache: 30_000,
|
|
660
|
+
before(payload) {
|
|
661
|
+
payload.set(
|
|
662
|
+
"url",
|
|
663
|
+
payload.get<string>("url").replace(":id", payload.get<string>("id")),
|
|
664
|
+
);
|
|
665
|
+
},
|
|
666
|
+
after(payload) {
|
|
667
|
+
const data = payload.get("data");
|
|
668
|
+
payload.set({ formatted: formatUser(data) });
|
|
669
|
+
},
|
|
670
|
+
cleanKeys: "userList", // invalidate userList cache when this completes
|
|
671
|
+
},
|
|
672
|
+
]);
|
|
673
|
+
```
|
|
685
674
|
|
|
686
|
-
|
|
675
|
+
### Per-subclass isolation
|
|
687
676
|
|
|
688
|
-
|
|
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.
|
|
689
678
|
|
|
690
|
-
|
|
691
|
-
- processViewEvents(): 处理 @event 属性, 添加 \x1f (VIEW_ID_PLACEHOLDER) 前缀 + \x1e (SPLITTER) 分隔符, 将 JS 对象字面量参数转换为 URL 查询参数格式. 例如: @click="handlerName({key: 'value'})" 转换为 @click="\x1f\x1ehandlerName(key=value)"
|
|
679
|
+
### Using a service in a view
|
|
692
680
|
|
|
693
|
-
|
|
681
|
+
```ts
|
|
682
|
+
export default View.extend({
|
|
683
|
+
template,
|
|
684
|
+
init() {
|
|
685
|
+
const service = new AppService();
|
|
686
|
+
this.capture("userService", service, true); // auto-destroy on render
|
|
687
|
+
this.service = service;
|
|
688
|
+
this.loadData();
|
|
689
|
+
},
|
|
690
|
+
loadData() {
|
|
691
|
+
this.service.all("userList", (errors, userListPayload) => {
|
|
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) => {
|
|
700
|
+
if (!errors[0]) {
|
|
701
|
+
this.updater.set({ detail: payload.get("data") }).digest();
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
```
|
|
694
707
|
|
|
695
|
-
|
|
696
|
-
- = (HTML 转义输出): {{=variable}} 转换为 <%=variable%>
|
|
697
|
-
- ! (原始输出): {{!variable}} 转换为 <%!variable%>
|
|
698
|
-
- @ (引用查找): {{@variable}} 转换为 <%@variable%>, 运行时通过 $i() 查找 refData 中的键名
|
|
699
|
-
- : (双向绑定): {{:variable}} 转换为 <%:variable%>, 渲染时与 = 相同
|
|
700
|
-
- each (数组循环): {{each list as item [index]}} 转换为 for 循环, 支持 first/last 辅助变量, 支持解构赋值 {a,b}
|
|
701
|
-
- parse (对象遍历): {{parse obj as val [key]}}
|
|
702
|
-
- for (通用 for 循环): {{for(init;test;update)}}
|
|
703
|
-
- if/else/else if (条件判断)
|
|
704
|
-
- set (变量声明): {{set a = b}}
|
|
705
|
-
- 块级验证: 使用 blockStack 追踪开放的块, 编译结束时若存在未关闭的块则抛出错误
|
|
706
|
-
- addLineMarkers(): 调试模式下, 在每个 {{ 标签前插入 \x1e + lineNo 行号标记
|
|
707
|
-
- extractArtInfo(): 从带行号标记的 {{ }} 块中提取行号与代码
|
|
708
|
+
### Service method summary
|
|
708
709
|
|
|
709
|
-
|
|
710
|
+
| Method | Behavior |
|
|
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 |
|
|
710
718
|
|
|
711
|
-
|
|
712
|
-
- 自动变量提取: extractGlobalVars() 基于 @babel/parser AST 分析, 自动从模板中提取全局变量名. 两遍扫描: 第一遍收集变量声明和函数作用域, 第二遍识别未声明的标识符为全局变量. 排除内置全局 (JS 内置对象, 模板运行时变量, Lark 框架变量). AST 解析失败时回退到正则提取
|
|
713
|
-
- View ID 注入: \x1f (VIEW_ID_PLACEHOLDER) 在函数生成阶段替换为 '+$viewId+', 实现运行时注入 View ID
|
|
714
|
-
- 调试模式: 支持 $expr/$art/$line 行号追踪与 try-catch 错误包装, 错误信息包含原始模板语法和行号
|
|
715
|
-
- 后处理: 清理空字符串拼接 ($p+='';), 优化 $p=''+ 为 $p+=
|
|
719
|
+
### Caching and dedup
|
|
716
720
|
|
|
717
|
-
|
|
721
|
+
The `Cache` class is an LFU cache with single-pass partial-selection eviction (O(n·k)) — see `cache.ts`. The pending-key map (`pendingCacheKeys`) deduplicates concurrent requests for the same `(endpoint, params)`. All pending callbacks are queued and called when the single in-flight request completes.
|
|
718
722
|
|
|
719
|
-
|
|
723
|
+
`defaultCacheKey` memoizes `JSON.stringify(meta)` per `ServiceMetaEntry` via `WeakMap` — meta entries are immutable after `Service.add()`.
|
|
720
724
|
|
|
721
|
-
|
|
725
|
+
## Templates
|
|
722
726
|
|
|
723
|
-
|
|
727
|
+
### Operators
|
|
724
728
|
|
|
725
|
-
|
|
729
|
+
| Operator | Syntax | Meaning |
|
|
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 `=`) |
|
|
726
735
|
|
|
727
|
-
|
|
736
|
+
### Control flow
|
|
728
737
|
|
|
729
|
-
|
|
738
|
+
```html
|
|
739
|
+
{{if condition}}...{{else if other}}...{{else}}...{{/if}} {{forOf list as item}}
|
|
740
|
+
... {{/forOf}} {{forOf list as item idx}} {{=idx}}: {{=item.name}} {{/forOf}}
|
|
741
|
+
{{forOf list as {name, age} idx last first}} ... {{/forOf}} {{forIn object as
|
|
742
|
+
value key}} ... {{/forIn}} {{for (let i = 0; i < n; i++)}} ... {{/for}} {{set
|
|
743
|
+
localVar = expr}}
|
|
744
|
+
```
|
|
730
745
|
|
|
731
|
-
|
|
746
|
+
`forOf` REQUIRES the `as` keyword: `{{forOf list as item}}` is correct; `{{forOf list item}}` is a compile-time error.
|
|
732
747
|
|
|
733
|
-
|
|
748
|
+
### Event binding
|
|
734
749
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
750
|
+
```html
|
|
751
|
+
<button @click="handlerName({key: 'value', other: 123})">Go</button>
|
|
752
|
+
<input @input="onInput()" />
|
|
753
|
+
<form @submit.prevent="onSubmit()">...</form>
|
|
754
|
+
```
|
|
740
755
|
|
|
741
|
-
|
|
756
|
+
The compiler converts JS object literal params (`{a: 1}`) into URL query format (`a=1`) so they can survive transport through DOM attributes. It also injects the current view's `$viewId` and a SPLITTER separator so the EventDelegator can route correctly across nested frames.
|
|
742
757
|
|
|
743
|
-
|
|
758
|
+
### Sub-view embedding
|
|
744
759
|
|
|
745
|
-
|
|
760
|
+
```html
|
|
761
|
+
<div v-lark="components/child"></div>
|
|
762
|
+
<div v-lark="components/child?title=hello&id=42"></div>
|
|
763
|
+
<div v-lark="remote-app/views/home"></div>
|
|
764
|
+
<!-- Module Federation -->
|
|
765
|
+
```
|
|
746
766
|
|
|
747
|
-
|
|
767
|
+
When `v-lark` carries a query string, the params are translated into the child view's `init` arguments. If the value contains a SPLITTER reference, `translateData` resolves it via the parent view's refData before the child mounts.
|
|
748
768
|
|
|
749
|
-
|
|
750
|
-
- get(key): 读取缓存, 命中时递增 frequency 并更新 lastTimestamp
|
|
751
|
-
- del(key): 删除缓存项
|
|
752
|
-
- forEach(callback): 遍历所有缓存项
|
|
769
|
+
### VDOM optimization hints
|
|
753
770
|
|
|
754
|
-
|
|
771
|
+
| Attribute | Effect |
|
|
772
|
+
| --------- | --------------------------------------------------------------------- |
|
|
773
|
+
| `ldk` | "Diff key" — if old and new have the same `ldk`, skip the entire diff |
|
|
774
|
+
| `lak` | "Attribute key" — skip attribute diff but still diff children |
|
|
775
|
+
| `lvk` | "View key" — assign-optimization marker |
|
|
755
776
|
|
|
756
|
-
|
|
777
|
+
Mark large static subtrees with `ldk` to skip rendering work entirely.
|
|
757
778
|
|
|
758
|
-
|
|
779
|
+
## Module Federation (micro-frontend)
|
|
759
780
|
|
|
760
|
-
|
|
781
|
+
### Pattern 1 — Direct async loading
|
|
761
782
|
|
|
762
|
-
|
|
763
|
-
- off(event, handler?): 移除监听器. 传入 handler 时, 将其替换为 noop 而非从数组中移除 (避免在 fire 遍历过程中修改数组); 不传 handler 时, 删除整个事件的所有监听器
|
|
764
|
-
- fire(event, data?, remove?, lastToFirst?): 触发事件, 执行所有监听器. executing 标志防止在 fire 过程中移除的监听器在下次 fire 时被清理. remove 参数触发后自动移除所有监听器. lastToFirst 控制执行顺序 (默认从先到后)
|
|
765
|
-
- onEventName 约定: 若对象上存在 on + EventName 方法, fire 时自动调用
|
|
783
|
+
Configure `FrameworkConfig.require` to resolve unknown view paths through MF:
|
|
766
784
|
|
|
767
|
-
|
|
785
|
+
```ts
|
|
786
|
+
declare const __webpack_init_sharing__: (name: string) => Promise<void>;
|
|
787
|
+
declare const __webpack_share_scopes__: Record<string, Record<string, unknown>>;
|
|
768
788
|
|
|
769
|
-
|
|
789
|
+
Framework.boot({
|
|
790
|
+
rootId: "app",
|
|
791
|
+
projectName: "host-app",
|
|
792
|
+
crossConfigs: [
|
|
793
|
+
{
|
|
794
|
+
projectName: "remote-app",
|
|
795
|
+
source: "remote_app@//cdn.example.com/remote-app/remoteEntry.js",
|
|
796
|
+
},
|
|
797
|
+
],
|
|
798
|
+
require: async (names: string[]) => {
|
|
799
|
+
await __webpack_init_sharing__("default");
|
|
800
|
+
const container = __webpack_share_scopes__["default"];
|
|
801
|
+
return Promise.all(
|
|
802
|
+
names.map(async (name) => {
|
|
803
|
+
const slash = name.indexOf("/");
|
|
804
|
+
const remote = slash > -1 ? name.substring(0, slash) : name;
|
|
805
|
+
const mod = slash > -1 ? name.substring(slash + 1) : "./index";
|
|
806
|
+
const rc = (window as Record<string, unknown>)[remote] as
|
|
807
|
+
| {
|
|
808
|
+
init: (s: Record<string, unknown>) => Promise<void>;
|
|
809
|
+
get: (m: string) => Promise<() => unknown>;
|
|
810
|
+
}
|
|
811
|
+
| undefined;
|
|
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
|
+
);
|
|
821
|
+
},
|
|
822
|
+
});
|
|
823
|
+
```
|
|
770
824
|
|
|
771
|
-
|
|
825
|
+
Then `v-lark="remote-app/views/home"` just works.
|
|
772
826
|
|
|
773
|
-
|
|
827
|
+
### Pattern 2 — CrossSite bridge view (with skeleton + prepare)
|
|
774
828
|
|
|
775
|
-
|
|
829
|
+
For richer scenarios that want a loading skeleton and a `prepare` hook on the remote side:
|
|
776
830
|
|
|
831
|
+
```ts
|
|
832
|
+
import { CrossSite, registerViewClass } from "@lark.js/mvc";
|
|
833
|
+
registerViewClass("cross-site", CrossSite);
|
|
777
834
|
```
|
|
778
|
-
路由变更 / State 变更
|
|
779
|
-
└── dispatcherNotifyChange(data)
|
|
780
|
-
├── 路由 view 变更 → rootFrame.mountView(newViewPath)
|
|
781
|
-
└── 参数/状态变更 → dispatcherUpdate(rootFrame, changedKeys)
|
|
782
|
-
├── 检查 View 是否观察了变更键
|
|
783
|
-
│ ├── observeLocation → viewIsObserveChanged()
|
|
784
|
-
│ └── observeState → stateIsObserveChanged()
|
|
785
|
-
├── 匹配则触发 View.render()
|
|
786
|
-
└── 递归子 Frame → dispatcherUpdate(childFrame, changedKeys)
|
|
787
|
-
```
|
|
788
|
-
|
|
789
|
-
dispatcherUpdate 通过 dispatcherUpdateTag 防止同一更新周期内重复处理同一 Frame; View 的 signature <= 1 时跳过更新 (尚未完成首次渲染); 支持 render() 返回 Promise 的异步场景, 异步渲染完成后递归处理子 Frame
|
|
790
|
-
|
|
791
|
-
渲染管线
|
|
792
835
|
|
|
836
|
+
```html
|
|
837
|
+
<div v-lark="cross-site?view=remote-app/views/home&bizCode=mybiz"></div>
|
|
793
838
|
```
|
|
794
|
-
updater.digest()
|
|
795
|
-
├── 调用模板函数(template) → 生成 HTML 字符串
|
|
796
|
-
├── 虚拟文档解析 HTML → 新 DOM 节点
|
|
797
|
-
├── VDOM diff (新旧节点对比)
|
|
798
|
-
│ ├── 属性差异 → setAttribute / 直接属性赋值
|
|
799
|
-
│ ├── 子节点差异 → appendChild / removeChild / replaceChild / insertBefore
|
|
800
|
-
│ └── 特殊元素 → 直接同步 value / checked / selected
|
|
801
|
-
├── applyIdUpdates() — 批量更新元素 ID
|
|
802
|
-
├── applyVdomOps() — 批量执行 DOM 操作
|
|
803
|
-
└── Frame 处理 v-lark 子视图 → mountZone
|
|
804
|
-
```
|
|
805
|
-
|
|
806
|
-
对比 React 框架
|
|
807
|
-
|
|
808
|
-
虚拟 DOM 与 diff 算法
|
|
809
|
-
|
|
810
|
-
React 使用完整的虚拟 DOM 树表示 UI 结构, 通过 JSX 创建 ReactElement 对象树, diff 算法比较新旧两棵虚拟 DOM 树, 生成最小变更集. React 的 diff 算法基于三个假设: 1) 跨层级的节点移动极少, 2) 不同类型的元素产生不同的树, 3) key 属性标识节点的稳定性
|
|
811
|
-
|
|
812
|
-
Lark 不使用完整的虚拟 DOM 树表示, 而是采用增量式 diff: 新模板渲染生成 HTML 字符串, 通过 document.implementation.createHTMLDocument() 解析为真实 DOM 节点, 然后直接与现有 DOM 树进行 diff. 这意味着 Lark 不需要维护一棵独立的虚拟 DOM 树, 内存占用更低. diff 过程中, Lark 的 vdomSetChildNodes 实现了基于 key 的节点匹配 (通过 id, ldk, v-lark 属性), 类似于 React 的 key 机制, 但匹配策略更简单: 通过哈希表 (keyedNodes) 一次性构建旧节点的 key 映射, 遍历新节点时查找匹配
|
|
813
|
-
|
|
814
|
-
Lark 的 diff 产生四种操作码 (appendChild=1, removeChild=2, replaceChild=4, insertBefore=8), 批量执行; React 的 diff 产生 patch 列表, 通过 React Reconciler 调度执行. Lark 的 diff 是同步的、全量的 (针对单个 View), React 的 diff 可以通过 shouldComponentUpdate / React.memo 等机制跳过子树
|
|
815
|
-
|
|
816
|
-
Fiber 架构
|
|
817
|
-
|
|
818
|
-
React Fiber 是 React 16 引入的协调引擎, 将渲染工作拆分为小单元 (Fiber 节点), 支持可中断的异步渲染. 每个 Fiber 节点包含 type, key, child, sibling, return 等指针, 构成链表结构, 替代了之前的递归式栈帧调用. Fiber 架构的核心优势: 1) 时间切片, 可暂停渲染以响应用户交互; 2) 优先级调度, 高优先级更新 (如用户输入) 可以中断低优先级更新 (如数据请求); 3) 可恢复的渲染, 暂停后可从断点继续
|
|
819
|
-
|
|
820
|
-
Lark 的 Frame 树与 Fiber 树有相似之处: 两者都与 DOM 树同构, 都管理组件/视图的生命周期. 但 Lark 的 Frame 不实现时间切片和可中断渲染, diff 和 DOM 更新是同步执行的. Lark 的 Frame 更侧重于生命周期管理 (mountView/unmountView) 和事件边界检测 (findFrameInfo), 而非渲染调度. Lark 的任务调度能力来自 Framework.task(), 它使用 scheduler.postTask / requestIdleCallback / setTimeout 实现时间切片, 但这只用于通用任务队列, 不用于渲染过程本身
|
|
821
|
-
|
|
822
|
-
渲染模型
|
|
823
839
|
|
|
824
|
-
|
|
840
|
+
CrossSite renders a skeleton container `mf_${viewId}` first, then loads the remote project's `prepare` module via `loadRemoteView()`, then mounts the actual view. Race condition is guarded by `$sign`: if the user navigates away during the async load, the stale mount is aborted. When the remote view path matches the previous one and the existing view supports `assign()`, CrossSite updates in place instead of re-mounting.
|
|
825
841
|
|
|
826
|
-
|
|
842
|
+
`CrossSite.callView(name, ...args)` invokes a method on the embedded remote view via `Frame.invoke()`.
|
|
827
843
|
|
|
828
|
-
|
|
844
|
+
### Module Federation Webpack config
|
|
829
845
|
|
|
830
|
-
|
|
846
|
+
Host:
|
|
831
847
|
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
Vue3 的模板编译器将单文件组件的 <template> 编译为渲染函数, 编译过程分为三步: parse (模板 → AST), transform (AST → JavaScript AST), generate (JavaScript AST → 渲染函数代码). Vue3 的编译器实现了静态提升 (Static Hoisting): 将静态节点提升到渲染函数外部, 避免每次渲染重新创建; PatchFlag 标记动态节点, diff 时只比较标记为动态的部分; Block Tree 优化, 将模板按结构化指令 (v-if, v-for) 拆分为 Block, diff 时扁平化遍历
|
|
848
|
+
```js
|
|
849
|
+
import { ModuleFederationPlugin } from "webpack/container";
|
|
835
850
|
|
|
836
|
-
|
|
851
|
+
export default {
|
|
852
|
+
plugins: [
|
|
853
|
+
new ModuleFederationPlugin({
|
|
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
|
+
};
|
|
864
|
+
```
|
|
837
865
|
|
|
838
|
-
|
|
866
|
+
Remote:
|
|
839
867
|
|
|
840
|
-
|
|
868
|
+
```js
|
|
869
|
+
new ModuleFederationPlugin({
|
|
870
|
+
name: "remote_app",
|
|
871
|
+
filename: "remoteEntry.js",
|
|
872
|
+
exposes: {
|
|
873
|
+
"./views/home": "./src/views/home",
|
|
874
|
+
"./prepare": "./src/prepare",
|
|
875
|
+
},
|
|
876
|
+
shared: {
|
|
877
|
+
"@lark.js/mvc": { singleton: true, requiredVersion: "^1.0.0" },
|
|
878
|
+
},
|
|
879
|
+
});
|
|
880
|
+
```
|
|
841
881
|
|
|
842
|
-
|
|
882
|
+
`@lark.js/mvc` MUST be shared as `singleton: true` so host and remote use the same View/Frame class instances — `instanceof` checks fail across boundaries otherwise.
|
|
843
883
|
|
|
844
|
-
|
|
884
|
+
### `splitChunks.chunks` must be `"async"` in MF projects
|
|
845
885
|
|
|
846
|
-
|
|
886
|
+
| Value | Splits initial chunks | Splits async chunks |
|
|
887
|
+
| ----------- | --------------------- | ------------------- |
|
|
888
|
+
| `"initial"` | yes | no |
|
|
889
|
+
| `"async"` | no | yes |
|
|
890
|
+
| `"all"` | yes | yes |
|
|
847
891
|
|
|
848
|
-
|
|
849
|
-
2. 效果调度: Vue3 的 effect 通过 scheduler 调度执行, 支持异步批量更新 (nextTick); Lark 的 Store 的 observe 直接绑定 View 的 updater.digest, 状态变更后同步触发渲染, 或通过 ObservePayload.lazy 延迟到下一帧
|
|
850
|
-
3. 计算属性: Vue3 提供 computed() 实现惰性求值和缓存; Lark 通过 ObservePayload.transform 实现状态派生, 但不内置缓存
|
|
851
|
-
4. 浅层响应式: Vue3 提供 shallowReactive/shallowRef; Lark 提供 shallowSet(), 只有顶层 key 变化触发通知
|
|
852
|
-
5. 批量更新: Vue3 通过 nextTick 批量处理同一事件循环内的多次状态变更; Lark 通过 lazySet() 批量设置多个属性但只触发一次通知
|
|
853
|
-
6. 原始值响应式: Vue3 使用 ref() 包装原始值; Lark 使用 cell() 创建独立响应式数据单元, 本质是 createState 的轻量封装
|
|
854
|
-
7. 多平台: Lark 的 Store 设计了 LarkStore / ReactStore / NodeStore 三种适配器, ReactStore 使用 freezeData 生成不可变快照适配 React, NodeStore 适用于 Node.js 环境; Vue3 没有类似的多平台设计, 其响应式系统与 Vue 组件生命周期绑定
|
|
892
|
+
If `chunks: "all"` extracts `@lark.js/mvc` into a separate vendor chunk, the MF shared scope initialization fails — `remoteEntry.js` needs `@lark.js/mvc` synchronously available in the initial entry chunk. With `chunks: "async"`, shared singletons stay in the entry chunk and `window.<remote_name>` is set correctly.
|
|
855
893
|
|
|
856
|
-
|
|
894
|
+
### `Frame.root()` vs `new Frame()` for MF
|
|
857
895
|
|
|
858
|
-
|
|
896
|
+
`Frame.root()` (and the newer `Frame.createRoot()`) is a singleton — always returns the first root, ignoring later `id` arguments. For MF containers that need independent rendering contexts, use `new Frame(containerId)` directly so each mount owns its frame tree.
|
|
859
897
|
|
|
860
|
-
|
|
898
|
+
Use the new APIs (preferred):
|
|
861
899
|
|
|
862
|
-
|
|
900
|
+
- `Frame.getRoot()` — pure getter, returns `undefined` if not yet created.
|
|
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`.
|
|
863
904
|
|
|
864
|
-
|
|
905
|
+
### Exposed mount function pattern
|
|
865
906
|
|
|
866
|
-
|
|
867
|
-
| --------------------------------------- | ----------------------------------------- |
|
|
868
|
-
| defineStore(name, creator, config?) | 声明式定义 store |
|
|
869
|
-
| useStore(view?) | 获取 store 代理, 传入 view 时绑定生命周期 |
|
|
870
|
-
| store.observe(view, keys, defCallback?) | 监听状态变更 |
|
|
871
|
-
| lazySet(target, data) | 批量更新, 只触发一次通知 |
|
|
872
|
-
| shallowSet(target, key, data) | 创建浅层响应式状态 |
|
|
873
|
-
| cell(data) | 创建独立响应式数据单元 |
|
|
874
|
-
| observeCell(state, cb, immediate?) | 监听 cell 变更 |
|
|
875
|
-
| multi(useStore) | 创建多实例 store |
|
|
876
|
-
| getStore(name) | 按名称获取 store 实例 |
|
|
877
|
-
| delStore(name) | 删除 store |
|
|
878
|
-
| cloneStore(name, useStore, config?) | 克隆 store |
|
|
879
|
-
| isStoreActive(name) | 检查 store 是否处于 ACTIVE 状态 |
|
|
880
|
-
| isState(target) | 检查对象是否为响应式状态 |
|
|
881
|
-
| createState(data, config?) | 创建响应式状态 |
|
|
907
|
+
For React/other-host integrations, expose a mount function rather than raw View classes:
|
|
882
908
|
|
|
883
|
-
|
|
909
|
+
```ts
|
|
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
|
+
```
|
|
884
945
|
|
|
885
|
-
|
|
946
|
+
The MF remote MUST explicitly import its CSS (`import "../index.css"`) — Webpack only bundles CSS reachable from the exposed module's import graph.
|
|
886
947
|
|
|
887
|
-
|
|
948
|
+
## Three pipelines side-by-side
|
|
888
949
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
| {{else if condition}} | else-if 条件 |
|
|
902
|
-
| {{else}} | else 分支 |
|
|
903
|
-
| {{/if}} / {{/each}} / {{/parse}} / {{/for}} | 关闭块 |
|
|
904
|
-
| {{set var = expr}} | 变量声明 |
|
|
950
|
+
```ts
|
|
951
|
+
// Updater (view-local, manual)
|
|
952
|
+
this.updater.set({ count: newCount }).digest();
|
|
953
|
+
|
|
954
|
+
// State (simple cross-view)
|
|
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")] });
|
|
905
962
|
|
|
906
|
-
|
|
963
|
+
// Store (complex cross-view, recommended for non-trivial state)
|
|
964
|
+
const store = useCountStore(this);
|
|
965
|
+
store.observe(this); // default: all keys (D5)
|
|
966
|
+
useCountStore().increment(); // mutate from anywhere → all observers re-render
|
|
967
|
+
```
|
|
907
968
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
|
911
|
-
|
|
|
912
|
-
|
|
|
913
|
-
|
|
|
914
|
-
|
|
|
915
|
-
|
|
|
916
|
-
|
|
|
917
|
-
|
|
|
918
|
-
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
969
|
+
Key distinctions:
|
|
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
|
|
1086
|
+
|
|
1087
|
+
For deeper detail than this guide:
|
|
1088
|
+
|
|
1089
|
+
- `references/api-reference.md` — Complete API signatures for every Lark module.
|
|
1090
|
+
- `references/template-syntax.md` — Full template language reference, including compilation pipeline, operators, control flow, encoders, and debug mode.
|