@lark.js/mvc 0.0.4 → 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 +828 -451
- package/dist/{chunk-ANWA22AX.js → chunk-IIIY575B.js} +24 -25
- package/dist/index.cjs +1032 -569
- package/dist/index.d.cts +548 -111
- package/dist/index.d.ts +548 -111
- package/dist/index.js +1023 -567
- 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 +24 -25
- package/dist/vite.d.cts +3 -3
- package/dist/vite.d.ts +3 -3
- package/dist/vite.js +1 -1
- package/dist/webpack.cjs +24 -25
- package/dist/webpack.js +1 -1
- package/package.json +21 -8
- package/src/client.d.ts +80 -0
package/README.md
CHANGED
|
@@ -1,97 +1,133 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lark-mvc
|
|
3
3
|
description: >
|
|
4
|
-
Lark MVC Framework (@lark.js/mvc)
|
|
5
|
-
Use this skill
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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.
|
|
11
18
|
---
|
|
12
19
|
|
|
13
20
|
# Lark MVC Framework
|
|
14
21
|
|
|
15
|
-
|
|
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.
|
|
16
23
|
|
|
17
|
-
|
|
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.
|
|
18
25
|
|
|
19
|
-
|
|
26
|
+
## When to reach for this skill
|
|
20
27
|
|
|
21
|
-
|
|
22
|
-
- **View**: `View.extend()` creates view subclasses with template rendering, event delegation, and lifecycle hooks
|
|
23
|
-
- **Controller**: `Router` for hash-based navigation, `Updater` for per-view data binding and VDOM diff, `Frame` for view lifecycle management
|
|
28
|
+
Any task that names — or clearly implies — Lark:
|
|
24
29
|
|
|
25
|
-
|
|
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.
|
|
26
38
|
|
|
27
|
-
|
|
28
|
-
2. **State Pipeline** (default cross-view): `State.set(data)` -> `State.digest()` -> `changed` event -> views read `State.get()` in `assign()` -> `updater.digest()` -> VDOM diff -> DOM patch. State uses key reference counting: when no view observes a key, the data is automatically deleted on view destroy via `State.clean()`
|
|
29
|
-
3. **Store Pipeline** (cross-view, recommended): `store.key = value` -> Proxy set trap -> `trigger()` -> `GlobalDeps` lookup -> Queue microtask batch -> `store.observe` callback -> `updater.digest()` / `updater.set()` -> VDOM diff -> DOM patch
|
|
39
|
+
## Architecture
|
|
30
40
|
|
|
31
|
-
|
|
41
|
+
Lark separates code along three orthogonal axes:
|
|
32
42
|
|
|
33
|
-
`
|
|
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).
|
|
34
46
|
|
|
35
|
-
|
|
47
|
+
### The three data pipelines
|
|
36
48
|
|
|
37
|
-
|
|
49
|
+
Lark exposes three ways to flow data to a view. Pick the simplest one that solves the task.
|
|
38
50
|
|
|
39
|
-
|
|
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.
|
|
40
53
|
|
|
41
|
-
|
|
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.
|
|
42
56
|
|
|
43
|
-
|
|
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.
|
|
44
59
|
|
|
45
|
-
|
|
46
|
-
| ------------------------- | ---------------- | --------------------------------- |
|
|
47
|
-
| `window.__lark_Framework` | Framework object | Direct framework access |
|
|
48
|
-
| `window.__lark_State` | State object | Direct state access |
|
|
49
|
-
| `window.__lark_Router` | Router object | Direct router access |
|
|
50
|
-
| `window.__lark_Frame` | Frame class | Direct Frame class access |
|
|
51
|
-
| `window.__lark_View` | View class | Direct View class access |
|
|
52
|
-
| `window.__lark_Debug` | boolean | Enable safeguard Proxy debug mode |
|
|
60
|
+
### Boot sequence (order matters)
|
|
53
61
|
|
|
54
|
-
|
|
62
|
+
`Framework.boot(config)` runs these steps in this exact order:
|
|
55
63
|
|
|
56
|
-
|
|
57
|
-
|
|
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`).
|
|
58
73
|
|
|
59
|
-
|
|
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
|
|
60
95
|
|
|
61
96
|
```
|
|
62
97
|
project/
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
counter-store.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
### 1. Install and Configure
|
|
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
|
|
87
121
|
|
|
88
122
|
```bash
|
|
89
123
|
pnpm add @lark.js/mvc
|
|
90
124
|
```
|
|
91
125
|
|
|
92
|
-
### 2. Configure
|
|
126
|
+
### 2. Configure your bundler
|
|
93
127
|
|
|
94
|
-
|
|
128
|
+
Vite (recommended):
|
|
129
|
+
|
|
130
|
+
```ts
|
|
95
131
|
// vite.config.ts
|
|
96
132
|
import { defineConfig } from "vite";
|
|
97
133
|
import { resolve } from "path";
|
|
@@ -99,122 +135,38 @@ import { larkMvcPlugin } from "@lark.js/mvc/vite";
|
|
|
99
135
|
|
|
100
136
|
export default defineConfig({
|
|
101
137
|
plugins: [larkMvcPlugin()],
|
|
102
|
-
resolve: {
|
|
103
|
-
alias: {
|
|
104
|
-
"@": resolve(__dirname, "./src"),
|
|
105
|
-
},
|
|
106
|
-
},
|
|
138
|
+
resolve: { alias: { "@": resolve(__dirname, "./src") } },
|
|
107
139
|
});
|
|
108
140
|
```
|
|
109
141
|
|
|
110
|
-
The `
|
|
111
|
-
|
|
112
|
-
How it works internally:
|
|
113
|
-
|
|
114
|
-
1. **resolveId hook**: Intercepts `.html` import source strings. When a module imports a `.html` file, the plugin resolves the full path and appends the `?lark-template` suffix (internal constant `LARK_TEMPLATE_SUFFIX`). This prevents Vite from treating the file as a static asset.
|
|
115
|
-
2. **load hook**: When Vite requests a module whose ID ends with `?lark-template`, the plugin reads the raw HTML file from disk, auto-extracts global variables via `extractGlobalVars()` AST analysis, and compiles the template through `compileTemplate()`. The compiled output is an ES module exporting a function with signature `(data, selfId, refData) => string`.
|
|
116
|
-
3. **enforce: "pre"**: The plugin is registered as a pre-phase plugin to ensure it runs before other Vite plugins that might also handle `.html` files.
|
|
117
|
-
|
|
118
|
-
For debug mode with line tracking and detailed error messages:
|
|
119
|
-
|
|
120
|
-
```typescript
|
|
121
|
-
plugins: [larkMvcPlugin({ debug: true })];
|
|
122
|
-
```
|
|
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`.
|
|
123
143
|
|
|
124
|
-
|
|
144
|
+
For Webpack, mirror the same idea with the loader:
|
|
125
145
|
|
|
126
|
-
```
|
|
146
|
+
```js
|
|
127
147
|
// webpack.config.mjs
|
|
128
|
-
import path from "path";
|
|
129
|
-
import { fileURLToPath } from "url";
|
|
130
|
-
import HtmlWebpackPlugin from "html-webpack-plugin";
|
|
131
148
|
import { larkMvcLoader } from "@lark.js/mvc/webpack";
|
|
132
149
|
|
|
133
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
134
|
-
|
|
135
150
|
export default {
|
|
136
|
-
|
|
137
|
-
output: {
|
|
138
|
-
path: path.resolve(__dirname, "dist"),
|
|
139
|
-
filename: "js/[name].[contenthash:8].js",
|
|
140
|
-
clean: true,
|
|
141
|
-
},
|
|
142
|
-
resolve: {
|
|
143
|
-
extensions: [".ts", ".js"],
|
|
144
|
-
alias: {
|
|
145
|
-
"@": path.resolve(__dirname, "src"),
|
|
146
|
-
},
|
|
147
|
-
},
|
|
151
|
+
// ...
|
|
148
152
|
module: {
|
|
149
153
|
rules: [
|
|
150
|
-
{
|
|
151
|
-
test: /\.ts$/,
|
|
152
|
-
use: "ts-loader",
|
|
153
|
-
exclude: /node_modules/,
|
|
154
|
-
},
|
|
155
|
-
{
|
|
156
|
-
test: /\.css$/,
|
|
157
|
-
use: ["style-loader", "css-loader", "postcss-loader"],
|
|
158
|
-
},
|
|
159
|
-
// Lark template processing - larkMvcLoader compiles .html to JS functions
|
|
154
|
+
{ test: /\.ts$/, use: "ts-loader", exclude: /node_modules/ },
|
|
160
155
|
{
|
|
161
156
|
test: /\.html$/,
|
|
162
157
|
use: [{ loader: larkMvcLoader }],
|
|
163
|
-
exclude: /index\.html$/, //
|
|
158
|
+
exclude: /index\.html$/, // HtmlWebpackPlugin handles the entry HTML
|
|
164
159
|
},
|
|
165
160
|
],
|
|
166
161
|
},
|
|
167
|
-
plugins: [
|
|
168
|
-
new HtmlWebpackPlugin({
|
|
169
|
-
template: "./index.html",
|
|
170
|
-
inject: "body",
|
|
171
|
-
minify: false,
|
|
172
|
-
}),
|
|
173
|
-
],
|
|
174
|
-
devServer: {
|
|
175
|
-
port: 3001,
|
|
176
|
-
open: true,
|
|
177
|
-
hot: true,
|
|
178
|
-
},
|
|
179
|
-
devtool: "source-map",
|
|
180
162
|
};
|
|
181
163
|
```
|
|
182
164
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
1. Receiving the raw HTML source string as input from Webpack.
|
|
186
|
-
2. Auto-extracting global variables via `extractGlobalVars()` AST analysis (the same function used by the Vite plugin).
|
|
187
|
-
3. Compiling the template through `compileTemplate()` to produce an ES module string.
|
|
188
|
-
4. Returning the compiled result to Webpack via `this.callback()`.
|
|
189
|
-
|
|
190
|
-
Key configuration points:
|
|
191
|
-
|
|
192
|
-
- Import `larkMvcLoader` from `@lark.js/mvc/webpack` (not from a file path).
|
|
193
|
-
- Use the loader object directly as `loader: larkMvcLoader` (it is a function, not a string name).
|
|
194
|
-
- Exclude `index.html` from the loader rule -- the entry HTML page should be processed by HtmlWebpackPlugin, not by larkMvcLoader.
|
|
195
|
-
- Use `HtmlWebpackPlugin` to inject scripts into the entry HTML page.
|
|
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.
|
|
196
166
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
```javascript
|
|
200
|
-
{
|
|
201
|
-
test: /\.html$/,
|
|
202
|
-
use: [
|
|
203
|
-
{
|
|
204
|
-
loader: larkMvcLoader,
|
|
205
|
-
options: { debug: true },
|
|
206
|
-
},
|
|
207
|
-
],
|
|
208
|
-
exclude: /index\.html$/,
|
|
209
|
-
},
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
The `debug` option enables line tracking and detailed compile-time/runtime error messages with source mapping.
|
|
213
|
-
|
|
214
|
-
### 4. Create Entry HTML
|
|
167
|
+
### 3. Entry HTML
|
|
215
168
|
|
|
216
169
|
```html
|
|
217
|
-
<!-- index.html -->
|
|
218
170
|
<!doctype html>
|
|
219
171
|
<html lang="en">
|
|
220
172
|
<head>
|
|
@@ -229,18 +181,17 @@ The `debug` option enables line tracking and detailed compile-time/runtime error
|
|
|
229
181
|
</html>
|
|
230
182
|
```
|
|
231
183
|
|
|
232
|
-
The `<div id="app">`
|
|
233
|
-
|
|
234
|
-
For Webpack, the entry HTML uses HtmlWebpackPlugin instead of a `<script>` tag. HtmlWebpackPlugin automatically injects the bundled script.
|
|
184
|
+
The `<div id="app">` matches `rootId: "app"` in the boot config.
|
|
235
185
|
|
|
236
|
-
###
|
|
186
|
+
### 4. A project-level base View
|
|
237
187
|
|
|
238
|
-
```
|
|
188
|
+
```ts
|
|
239
189
|
// src/view.ts
|
|
240
|
-
import {
|
|
190
|
+
import { defineView, Router } from "@lark.js/mvc";
|
|
241
191
|
|
|
242
|
-
export default
|
|
192
|
+
export default defineView({
|
|
243
193
|
make() {
|
|
194
|
+
// Called once per instance via the merged ctors[] pipeline.
|
|
244
195
|
this.updater.set({ appName: "My App" });
|
|
245
196
|
this.on("destroy", () => {
|
|
246
197
|
console.log(`View destroyed: ${this.id}`);
|
|
@@ -252,100 +203,142 @@ export default View.extend({
|
|
|
252
203
|
});
|
|
253
204
|
```
|
|
254
205
|
|
|
255
|
-
|
|
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
|
|
256
242
|
|
|
257
|
-
###
|
|
243
|
+
### Basic store
|
|
258
244
|
|
|
259
|
-
```
|
|
245
|
+
```ts
|
|
260
246
|
// src/store/count.ts
|
|
261
|
-
import { defineStore
|
|
247
|
+
import { defineStore } from "@lark.js/mvc";
|
|
262
248
|
|
|
263
|
-
|
|
249
|
+
interface CountStore {
|
|
264
250
|
count: number;
|
|
265
251
|
step: number;
|
|
252
|
+
doubled: number; // computed
|
|
266
253
|
history: string[];
|
|
267
254
|
increment: () => void;
|
|
268
255
|
decrement: () => void;
|
|
269
256
|
reset: () => void;
|
|
270
|
-
setStep: (val: number) => void;
|
|
271
|
-
clearHistory: () => void;
|
|
272
|
-
registerObservers: () => void;
|
|
273
257
|
}
|
|
274
258
|
|
|
275
259
|
const useCountStore = defineStore<CountStore>(
|
|
276
260
|
"count",
|
|
277
|
-
(store, { lazySet, shallowSet }) => {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
store.step = val;
|
|
296
|
-
},
|
|
297
|
-
clearHistory() {
|
|
298
|
-
store.history = [];
|
|
299
|
-
},
|
|
300
|
-
registerObservers() {
|
|
301
|
-
// Inner observe (no view binding) for store-internal reactions
|
|
302
|
-
store.observe(undefined, ["step"], () => {
|
|
303
|
-
store.count = 0; // Reset count when step changes
|
|
304
|
-
});
|
|
305
|
-
},
|
|
306
|
-
};
|
|
307
|
-
},
|
|
261
|
+
(store, { computed, lazySet, shallowSet }) => ({
|
|
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
|
+
}),
|
|
308
279
|
);
|
|
309
280
|
|
|
310
281
|
export default useCountStore;
|
|
311
282
|
```
|
|
312
283
|
|
|
313
|
-
|
|
284
|
+
### How the creator runs
|
|
314
285
|
|
|
315
|
-
|
|
316
|
-
- `apis` — `{ lazySet, shallowSet }` utility functions:
|
|
317
|
-
- `lazySet(target, data)` — Batch-set properties without triggering observers (used during `createState` initialization)
|
|
318
|
-
- `shallowSet(target, key, data)` — Create a shallow reactive sub-state where only top-level property changes trigger observers
|
|
286
|
+
The creator runs once at definition time. Lark walks the return value:
|
|
319
287
|
|
|
320
|
-
|
|
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.
|
|
321
291
|
|
|
322
|
-
|
|
292
|
+
### How `useStore(view)` works
|
|
323
293
|
|
|
324
|
-
|
|
325
|
-
|
|
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.
|
|
299
|
+
|
|
300
|
+
### Subscribing a view
|
|
301
|
+
|
|
302
|
+
`store.observe(view, keys?, defaultCallback?)` subscribes the view to store changes. Variations:
|
|
326
303
|
|
|
327
|
-
|
|
328
|
-
|
|
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);
|
|
329
308
|
|
|
330
|
-
//
|
|
331
|
-
|
|
332
|
-
|
|
309
|
+
// Explicit: only the listed keys trigger updates.
|
|
310
|
+
store.observe(this, ["count", "step"]);
|
|
311
|
+
|
|
312
|
+
// With a custom callback (override the default updater.digest behavior).
|
|
313
|
+
store.observe(this, ["count"], (changedMap) => {
|
|
314
|
+
console.log("count changed", changedMap);
|
|
333
315
|
});
|
|
334
316
|
|
|
335
|
-
//
|
|
336
|
-
|
|
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 }),
|
|
323
|
+
},
|
|
324
|
+
]);
|
|
337
325
|
|
|
338
|
-
//
|
|
339
|
-
|
|
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
|
|
330
|
+
});
|
|
340
331
|
```
|
|
341
332
|
|
|
342
|
-
`
|
|
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.
|
|
343
334
|
|
|
344
|
-
|
|
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.
|
|
345
336
|
|
|
346
|
-
|
|
337
|
+
### Multi-instance stores
|
|
347
338
|
|
|
348
|
-
|
|
339
|
+
When a component is reused N times and each instance needs its own state:
|
|
340
|
+
|
|
341
|
+
```ts
|
|
349
342
|
import { defineStore, multi } from "@lark.js/mvc";
|
|
350
343
|
|
|
351
344
|
const useCounterStore = defineStore("counter", (store) => ({
|
|
@@ -355,28 +348,70 @@ const useCounterStore = defineStore("counter", (store) => ({
|
|
|
355
348
|
},
|
|
356
349
|
}));
|
|
357
350
|
|
|
358
|
-
// multi() returns [useFn, mixinObj]
|
|
351
|
+
// multi() returns [useFn, mixinObj].
|
|
359
352
|
const [useMultiCounter, counterMixin] = multi(useCounterStore);
|
|
360
353
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
mixins: [counterMixin], // make() generates per-instance flag
|
|
354
|
+
export default defineView({
|
|
355
|
+
mixins: [counterMixin], // its make() stamps a per-instance flag onto the view
|
|
364
356
|
template,
|
|
365
357
|
init() {
|
|
366
|
-
const store = useMultiCounter(this); //
|
|
358
|
+
const store = useMultiCounter(this); // each instance gets its own store clone
|
|
367
359
|
store.observe(this, ["count"]);
|
|
368
360
|
},
|
|
369
361
|
});
|
|
370
362
|
```
|
|
371
363
|
|
|
372
|
-
`multi()`
|
|
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()`.
|
|
365
|
+
|
|
366
|
+
### Standalone reactive cells
|
|
367
|
+
|
|
368
|
+
For one-off reactive values that don't need a full store:
|
|
369
|
+
|
|
370
|
+
```ts
|
|
371
|
+
import { cell, observeCell } from "@lark.js/mvc";
|
|
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).
|
|
380
|
+
|
|
381
|
+
## Defining Views
|
|
382
|
+
|
|
383
|
+
### View template
|
|
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>
|
|
407
|
+
```
|
|
373
408
|
|
|
374
|
-
###
|
|
409
|
+
### View class
|
|
375
410
|
|
|
376
|
-
```
|
|
411
|
+
```ts
|
|
377
412
|
// src/views/home.ts
|
|
378
413
|
import { Router } from "@lark.js/mvc";
|
|
379
|
-
import View from "../view";
|
|
414
|
+
import View from "../view"; // project-level base
|
|
380
415
|
import template from "./home.html";
|
|
381
416
|
import useCountStore from "../store/count";
|
|
382
417
|
|
|
@@ -386,11 +421,13 @@ export default View.extend({
|
|
|
386
421
|
init() {
|
|
387
422
|
this.assign();
|
|
388
423
|
|
|
389
|
-
// Observe count store -- changes trigger automatic view re-render
|
|
390
424
|
const store = useCountStore(this);
|
|
391
|
-
store.observe(this
|
|
425
|
+
store.observe(this); // observe every store key (D5 default)
|
|
392
426
|
},
|
|
393
427
|
|
|
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.
|
|
394
431
|
assign() {
|
|
395
432
|
this.updater.snapshot();
|
|
396
433
|
|
|
@@ -399,315 +436,655 @@ export default View.extend({
|
|
|
399
436
|
title: "Home",
|
|
400
437
|
count: store.count,
|
|
401
438
|
step: store.step,
|
|
439
|
+
items: [
|
|
440
|
+
{ id: "a", name: "Alpha" },
|
|
441
|
+
{ id: "b", name: "Beta" },
|
|
442
|
+
],
|
|
402
443
|
});
|
|
403
444
|
|
|
404
445
|
return this.updater.altered();
|
|
405
446
|
},
|
|
406
447
|
|
|
448
|
+
// render() is wrapped by the framework to manage signature/lifecycle.
|
|
449
|
+
// The default implementation calls this.updater.digest().
|
|
407
450
|
render() {
|
|
408
451
|
this.updater.digest();
|
|
409
452
|
},
|
|
410
453
|
|
|
454
|
+
// Event method naming: `name<eventType>`. See "Event methods" below.
|
|
411
455
|
"navigateTo<click>"(e: Record<string, unknown>) {
|
|
412
456
|
const params = e["params"] as Record<string, string> | undefined;
|
|
413
|
-
|
|
414
|
-
if (path) Router.to(path);
|
|
457
|
+
if (params?.path) Router.to(params.path);
|
|
415
458
|
},
|
|
416
459
|
});
|
|
417
460
|
```
|
|
418
461
|
|
|
419
|
-
###
|
|
462
|
+
### Event methods
|
|
420
463
|
|
|
421
|
-
|
|
422
|
-
<!-- src/views/home.html -->
|
|
423
|
-
<div>
|
|
424
|
-
<h1>{{=title}}</h1>
|
|
425
|
-
<div>Count: {{=count}}</div>
|
|
426
|
-
<button @click="navigateTo({path: '/about'})">About</button>
|
|
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.
|
|
427
465
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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 |
|
|
433
475
|
|
|
434
|
-
|
|
435
|
-
{{forOf items as item idx}}
|
|
436
|
-
<li>{{=idx}}: {{=item}}</li>
|
|
437
|
-
{{/forOf}}
|
|
438
|
-
</ul>
|
|
476
|
+
Each event handler receives an event object with these augmented fields:
|
|
439
477
|
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
```
|
|
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.).
|
|
444
481
|
|
|
445
|
-
|
|
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.
|
|
446
483
|
|
|
447
|
-
|
|
448
|
-
// src/boot.ts
|
|
449
|
-
import { Framework, Router, View, registerViewClass } from "@lark.js/mvc";
|
|
450
|
-
import type { FrameworkConfig } from "@lark.js/mvc";
|
|
451
|
-
import HomeView from "./views/home";
|
|
452
|
-
import AboutView from "./views/about";
|
|
453
|
-
import CounterView from "./views/counter";
|
|
454
|
-
import NotFoundView from "./views/404";
|
|
455
|
-
import CounterStoreComponent from "./components/counter-store";
|
|
456
|
-
import CounterUpdaterComponent from "./components/counter-updater";
|
|
484
|
+
### Resource management
|
|
457
485
|
|
|
458
|
-
|
|
459
|
-
registerViewClass("home", HomeView as typeof View);
|
|
460
|
-
registerViewClass("about", AboutView as typeof View);
|
|
461
|
-
registerViewClass("counter", CounterView as typeof View);
|
|
462
|
-
registerViewClass("404", NotFoundView as typeof View);
|
|
463
|
-
registerViewClass(
|
|
464
|
-
"components/counter-store",
|
|
465
|
-
CounterStoreComponent as typeof View,
|
|
466
|
-
);
|
|
467
|
-
registerViewClass(
|
|
468
|
-
"components/counter-updater",
|
|
469
|
-
CounterUpdaterComponent as typeof View,
|
|
470
|
-
);
|
|
486
|
+
`capture` and `release` manage objects whose lifetime tracks the view (timers, services, observers, etc.):
|
|
471
487
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
error(e: Error) {
|
|
483
|
-
console.error("Lark error:", e);
|
|
488
|
+
```ts
|
|
489
|
+
const timer = setInterval(() => {
|
|
490
|
+
/* ... */
|
|
491
|
+
}, 1000);
|
|
492
|
+
this.capture(
|
|
493
|
+
"myTimer",
|
|
494
|
+
{
|
|
495
|
+
destroy() {
|
|
496
|
+
clearInterval(timer);
|
|
497
|
+
},
|
|
484
498
|
},
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
499
|
+
true,
|
|
500
|
+
);
|
|
501
|
+
// destroyOnRender=true: destroyed on next render call
|
|
502
|
+
// destroyOnRender=false: destroyed only on view destroy
|
|
488
503
|
```
|
|
489
504
|
|
|
490
|
-
|
|
505
|
+
`release(key, destroy=true)` removes the entry (and calls `.destroy()` unless `destroy=false`).
|
|
491
506
|
|
|
492
|
-
###
|
|
507
|
+
### Async safety with `wrapAsync`
|
|
493
508
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
| Pattern | Meaning |
|
|
497
|
-
| -------------------------- | ------------------------------------------------- |
|
|
498
|
-
| `handler<click>` | Root event on the view element |
|
|
499
|
-
| `$selector<click>` | Delegated event matching CSS selector `.selector` |
|
|
500
|
-
| `$window<resize>` | Global event on `window` |
|
|
501
|
-
| `$document<keydown>` | Global event on `document` |
|
|
502
|
-
| `handler<click,mousedown>` | Multi-event binding |
|
|
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:
|
|
503
510
|
|
|
504
|
-
|
|
511
|
+
```ts
|
|
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
|
+
```
|
|
505
520
|
|
|
506
|
-
|
|
521
|
+
### Location observation
|
|
507
522
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
|
528
|
+
```
|
|
511
529
|
|
|
512
|
-
When
|
|
530
|
+
When the listed params or path change, the framework re-runs the view's render automatically.
|
|
513
531
|
|
|
514
|
-
###
|
|
532
|
+
### State observation (for the State pipeline)
|
|
515
533
|
|
|
516
|
-
```
|
|
517
|
-
//
|
|
518
|
-
|
|
534
|
+
```ts
|
|
535
|
+
this.observeState("count,step"); // comma-separated
|
|
536
|
+
this.observeState(["count", "step"]); // array
|
|
537
|
+
```
|
|
519
538
|
|
|
520
|
-
|
|
521
|
-
store.observe(this, ["count"], (changedMap) => {
|
|
522
|
-
console.log("count changed", changedMap);
|
|
523
|
-
});
|
|
539
|
+
When State.digest() flips one of these keys, the framework re-renders the view.
|
|
524
540
|
|
|
525
|
-
|
|
526
|
-
store.observe(this, [
|
|
527
|
-
{ key: "count", alias: "currentCount", lazy: false },
|
|
528
|
-
{ key: "items", transform: (val) => ({ itemCount: val.length }) },
|
|
529
|
-
]);
|
|
541
|
+
### Sub-view embedding
|
|
530
542
|
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
});
|
|
543
|
+
```html
|
|
544
|
+
<div v-lark="components/child-view"></div>
|
|
545
|
+
```
|
|
535
546
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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.
|
|
548
|
+
|
|
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).
|
|
550
|
+
|
|
551
|
+
## Defining the Framework Boot
|
|
552
|
+
|
|
553
|
+
`Framework.boot(config)` config accepts:
|
|
554
|
+
|
|
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
|
+
}
|
|
539
573
|
```
|
|
540
574
|
|
|
541
|
-
|
|
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`.
|
|
542
576
|
|
|
543
|
-
|
|
577
|
+
## Router
|
|
544
578
|
|
|
545
|
-
|
|
546
|
-
- **Writing state keys** (e.g., `store.count = 5`) — sets the value on the internal reactive state, triggering dependency tracking
|
|
547
|
-
- **Accessing handlers** (e.g., `store.increment()`) — calls the function defined in the creator
|
|
548
|
-
- **Inside the creator**, `store.count` reads the raw reactive Proxy (no cloning), enabling direct mutation and reactivity
|
|
579
|
+
### Navigation
|
|
549
580
|
|
|
550
|
-
|
|
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
|
+
```
|
|
551
587
|
|
|
552
|
-
###
|
|
588
|
+
### Parsing
|
|
553
589
|
|
|
554
|
-
|
|
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
|
+
```
|
|
555
596
|
|
|
556
|
-
|
|
557
|
-
2. **`changed` event** -- URL has been updated, views are re-rendered
|
|
597
|
+
### Two-phase change events (existing API)
|
|
558
598
|
|
|
559
|
-
```
|
|
599
|
+
```ts
|
|
560
600
|
Router.on("change", (e) => {
|
|
561
|
-
if (hasUnsavedChanges)
|
|
562
|
-
e.prevent(); //
|
|
563
|
-
|
|
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 }
|
|
564
609
|
});
|
|
565
610
|
```
|
|
566
611
|
|
|
567
|
-
###
|
|
612
|
+
### Async route guards (`Router.beforeEach`)
|
|
568
613
|
|
|
569
|
-
|
|
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
|
+
```
|
|
570
624
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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.
|
|
626
|
+
|
|
627
|
+
## Service (API requests)
|
|
628
|
+
|
|
629
|
+
`Service` is an opinionated layer around `fetch` (or any sync function) with LFU caching, in-flight deduplication, serial task queueing, and lifecycle events.
|
|
630
|
+
|
|
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 },
|
|
577
656
|
{
|
|
578
|
-
|
|
579
|
-
|
|
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) });
|
|
580
669
|
},
|
|
670
|
+
cleanKeys: "userList", // invalidate userList cache when this completes
|
|
581
671
|
},
|
|
582
|
-
|
|
583
|
-
);
|
|
584
|
-
// destroyOnRender=true: destroyed on next render call
|
|
585
|
-
// destroyOnRender=false: destroyed only on view destroy
|
|
672
|
+
]);
|
|
586
673
|
```
|
|
587
674
|
|
|
588
|
-
###
|
|
675
|
+
### Per-subclass isolation
|
|
589
676
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
680
|
+
|
|
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
|
+
});
|
|
598
706
|
```
|
|
599
707
|
|
|
600
|
-
###
|
|
708
|
+
### Service method summary
|
|
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 |
|
|
601
718
|
|
|
602
|
-
|
|
719
|
+
### Caching and dedup
|
|
720
|
+
|
|
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.
|
|
722
|
+
|
|
723
|
+
`defaultCacheKey` memoizes `JSON.stringify(meta)` per `ServiceMetaEntry` via `WeakMap` — meta entries are immutable after `Service.add()`.
|
|
724
|
+
|
|
725
|
+
## Templates
|
|
726
|
+
|
|
727
|
+
### Operators
|
|
728
|
+
|
|
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 `=`) |
|
|
735
|
+
|
|
736
|
+
### Control flow
|
|
603
737
|
|
|
604
738
|
```html
|
|
605
|
-
|
|
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}}
|
|
606
744
|
```
|
|
607
745
|
|
|
608
|
-
|
|
746
|
+
`forOf` REQUIRES the `as` keyword: `{{forOf list as item}}` is correct; `{{forOf list item}}` is a compile-time error.
|
|
609
747
|
|
|
610
|
-
###
|
|
748
|
+
### Event binding
|
|
611
749
|
|
|
612
|
-
|
|
750
|
+
```html
|
|
751
|
+
<button @click="handlerName({key: 'value', other: 123})">Go</button>
|
|
752
|
+
<input @input="onInput()" />
|
|
753
|
+
<form @submit.prevent="onSubmit()">...</form>
|
|
754
|
+
```
|
|
613
755
|
|
|
614
|
-
|
|
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.
|
|
615
757
|
|
|
616
|
-
|
|
617
|
-
|
|
758
|
+
### Sub-view embedding
|
|
759
|
+
|
|
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 -->
|
|
618
765
|
```
|
|
619
766
|
|
|
620
|
-
|
|
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.
|
|
621
768
|
|
|
622
|
-
|
|
623
|
-
// Write: set data and digest to notify
|
|
624
|
-
State.set({ count: newCount }).digest();
|
|
769
|
+
### VDOM optimization hints
|
|
625
770
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
}
|
|
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 |
|
|
632
776
|
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
777
|
+
Mark large static subtrees with `ldk` to skip rendering work entirely.
|
|
778
|
+
|
|
779
|
+
## Module Federation (micro-frontend)
|
|
780
|
+
|
|
781
|
+
### Pattern 1 — Direct async loading
|
|
782
|
+
|
|
783
|
+
Configure `FrameworkConfig.require` to resolve unknown view paths through MF:
|
|
784
|
+
|
|
785
|
+
```ts
|
|
786
|
+
declare const __webpack_init_sharing__: (name: string) => Promise<void>;
|
|
787
|
+
declare const __webpack_share_scopes__: Record<string, Record<string, unknown>>;
|
|
788
|
+
|
|
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
|
+
},
|
|
636
822
|
});
|
|
823
|
+
```
|
|
637
824
|
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
825
|
+
Then `v-lark="remote-app/views/home"` just works.
|
|
826
|
+
|
|
827
|
+
### Pattern 2 — CrossSite bridge view (with skeleton + prepare)
|
|
828
|
+
|
|
829
|
+
For richer scenarios that want a loading skeleton and a `prepare` hook on the remote side:
|
|
830
|
+
|
|
831
|
+
```ts
|
|
832
|
+
import { CrossSite, registerViewClass } from "@lark.js/mvc";
|
|
833
|
+
registerViewClass("cross-site", CrossSite);
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
```html
|
|
837
|
+
<div v-lark="cross-site?view=remote-app/views/home&bizCode=mybiz"></div>
|
|
838
|
+
```
|
|
839
|
+
|
|
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.
|
|
841
|
+
|
|
842
|
+
`CrossSite.callView(name, ...args)` invokes a method on the embedded remote view via `Frame.invoke()`.
|
|
843
|
+
|
|
844
|
+
### Module Federation Webpack config
|
|
845
|
+
|
|
846
|
+
Host:
|
|
847
|
+
|
|
848
|
+
```js
|
|
849
|
+
import { ModuleFederationPlugin } from "webpack/container";
|
|
850
|
+
|
|
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
|
+
```
|
|
865
|
+
|
|
866
|
+
Remote:
|
|
867
|
+
|
|
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
|
+
},
|
|
641
879
|
});
|
|
642
880
|
```
|
|
643
881
|
|
|
644
|
-
|
|
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.
|
|
645
883
|
|
|
646
|
-
|
|
647
|
-
- State keys are auto-deleted when reference count reaches 0 (via `State.clean()` mixin), Store persists until `store.$destroyFn()` is called
|
|
648
|
-
- State requires manual event listening (`State.on("changed", ...)`), Store provides `store.observe()` with auto-cleanup
|
|
884
|
+
### `splitChunks.chunks` must be `"async"` in MF projects
|
|
649
885
|
|
|
650
|
-
|
|
886
|
+
| Value | Splits initial chunks | Splits async chunks |
|
|
887
|
+
| ----------- | --------------------- | ------------------- |
|
|
888
|
+
| `"initial"` | yes | no |
|
|
889
|
+
| `"async"` | no | yes |
|
|
890
|
+
| `"all"` | yes | yes |
|
|
651
891
|
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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.
|
|
893
|
+
|
|
894
|
+
### `Frame.root()` vs `new Frame()` for MF
|
|
895
|
+
|
|
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.
|
|
897
|
+
|
|
898
|
+
Use the new APIs (preferred):
|
|
899
|
+
|
|
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`.
|
|
904
|
+
|
|
905
|
+
### Exposed mount function pattern
|
|
906
|
+
|
|
907
|
+
For React/other-host integrations, expose a mount function rather than raw View classes:
|
|
908
|
+
|
|
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
|
+
}
|
|
657
944
|
```
|
|
658
945
|
|
|
659
|
-
|
|
946
|
+
The MF remote MUST explicitly import its CSS (`import "../index.css"`) — Webpack only bundles CSS reachable from the exposed module's import graph.
|
|
660
947
|
|
|
661
|
-
|
|
948
|
+
## Three pipelines side-by-side
|
|
662
949
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
- `alter` -- child Frame content changed
|
|
950
|
+
```ts
|
|
951
|
+
// Updater (view-local, manual)
|
|
952
|
+
this.updater.set({ count: newCount }).digest();
|
|
667
953
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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();
|
|
671
959
|
});
|
|
960
|
+
// to clean up on view destroy:
|
|
961
|
+
export default View.extend({ mixins: [State.clean("count")] });
|
|
962
|
+
|
|
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
|
|
672
967
|
```
|
|
673
968
|
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
|
677
|
-
|
|
|
678
|
-
|
|
|
679
|
-
|
|
|
680
|
-
|
|
|
681
|
-
|
|
|
682
|
-
|
|
|
683
|
-
|
|
|
684
|
-
|
|
|
685
|
-
|
|
686
|
-
##
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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).
|
|
707
1084
|
|
|
708
1085
|
## References
|
|
709
1086
|
|
|
710
|
-
For
|
|
1087
|
+
For deeper detail than this guide:
|
|
711
1088
|
|
|
712
|
-
- `references/api-reference.md`
|
|
713
|
-
- `references/template-syntax.md`
|
|
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.
|