@ubean/islands 0.1.13 → 0.2.0
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/dist/directive.d.ts +149 -0
- package/dist/directive.js +158 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/dist/runtime.d.ts +304 -1
- package/dist/runtime.js +396 -17
- package/dist/server-component.d.ts +39 -0
- package/dist/server-component.js +70 -0
- package/dist/virtual-registry.d.ts +1 -1
- package/dist/vite.d.ts +61 -4
- package/dist/vite.js +529 -28
- package/package.json +18 -8
package/dist/runtime.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createApp, defineComponent, h } from "vue";
|
|
1
|
+
import { Suspense, createApp, defineComponent, h, onMounted, ref, watch } from "vue";
|
|
2
2
|
//#region src/runtime.ts
|
|
3
3
|
const _global = globalThis;
|
|
4
4
|
function decodeProps(raw) {
|
|
@@ -35,12 +35,13 @@ function collectIslands(root) {
|
|
|
35
35
|
function hydrateIsland(record, component, options = {}) {
|
|
36
36
|
if (record.el.hasAttribute("data-hydrated")) return;
|
|
37
37
|
record.el.setAttribute("data-hydrated", "true");
|
|
38
|
-
const
|
|
38
|
+
const ChildComponent = defineComponent({
|
|
39
39
|
name: `Island-${record.componentName}`,
|
|
40
40
|
setup() {
|
|
41
41
|
return () => h(component, record.props);
|
|
42
42
|
}
|
|
43
|
-
})
|
|
43
|
+
});
|
|
44
|
+
const app = createApp(ChildComponent);
|
|
44
45
|
if (options.appContext) {
|
|
45
46
|
if (options.appContext.config?.globalProperties) Object.assign(app.config.globalProperties, options.appContext.config.globalProperties);
|
|
46
47
|
const appContextInternal = options.appContext;
|
|
@@ -101,19 +102,20 @@ function hydrateIslands(options = {}) {
|
|
|
101
102
|
} else doHydrate();
|
|
102
103
|
} else if (directive === "client:media" && record.mediaQuery) {
|
|
103
104
|
const mql = _global.window?.matchMedia?.(record.mediaQuery);
|
|
104
|
-
if (mql)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
105
|
+
if (mql) {
|
|
106
|
+
if (mql.matches) doHydrate();
|
|
107
|
+
else {
|
|
108
|
+
const fn = (e) => {
|
|
109
|
+
if (e.matches) {
|
|
110
|
+
doHydrate();
|
|
111
|
+
if (mql.removeEventListener) mql.removeEventListener("change", fn);
|
|
112
|
+
else if (mql.removeListener) mql.removeListener(fn);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
if (mql.addEventListener) mql.addEventListener("change", fn);
|
|
116
|
+
else if (mql.addListener) mql.addListener(fn);
|
|
117
|
+
}
|
|
118
|
+
} else doHydrate();
|
|
117
119
|
} else doHydrate();
|
|
118
120
|
}
|
|
119
121
|
}
|
|
@@ -124,5 +126,382 @@ function resolveComponent(name, components, getComponent) {
|
|
|
124
126
|
console.warn(`[ubean:islands] Island component "${name}" not found in registry.\nPossible causes:\n 1. Component is globally registered or dynamically imported — pass it via hydrateIslands({ components: { ${name}: YourComp } })\n 2. Component name mismatch between template tag and import\n 3. Component is auto-imported by unplugin-vue-components (no static import → not in auto-registry)\nRegistered components: ${registered.length > 0 ? registered.join(", ") : "(none)"}`);
|
|
125
127
|
return null;
|
|
126
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* `POST /__server-component` 端点 — 接收 `{ path, props }`,在服务端用注册表中的
|
|
131
|
+
* 组件重新渲染并返回 HTML 片段。由 `createServerComponentMiddleware()` 处理。
|
|
132
|
+
*/
|
|
133
|
+
const SERVER_COMPONENT_ENDPOINT = "/__server-component";
|
|
134
|
+
/**
|
|
135
|
+
* 全局服务端组件注册表:组件绝对路径 → Vue 组件对象。
|
|
136
|
+
*
|
|
137
|
+
* SSR 构建中,`defineServerIsland(Comp, { rerenderOnPropsChange: true }, '/abs/path')`
|
|
138
|
+
* 会调用 `registerServerComponent(path, Comp)` 将真实组件注册到此处。
|
|
139
|
+
* 客户端构建中 `Comp` 是 stub,不会注册。
|
|
140
|
+
*
|
|
141
|
+
* 中间件 `createServerComponentMiddleware()` 通过 `getServerComponent(path)` 取出
|
|
142
|
+
* 组件,用 `renderToString(h(Comp, props))` 重新渲染并返回 HTML。
|
|
143
|
+
*/
|
|
144
|
+
const serverComponentRegistry = /* @__PURE__ */ new Map();
|
|
145
|
+
/**
|
|
146
|
+
* 注册服务端组件到全局注册表 (SSR 构建中由 `defineServerIsland` 自动调用)。
|
|
147
|
+
*/
|
|
148
|
+
function registerServerComponent(path, component) {
|
|
149
|
+
serverComponentRegistry.set(path, component);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* 从全局注册表取出服务端组件 (由 `createServerComponentMiddleware` 调用)。
|
|
153
|
+
* 未注册时返回 `undefined`。
|
|
154
|
+
*/
|
|
155
|
+
function getServerComponent(path) {
|
|
156
|
+
return serverComponentRegistry.get(path);
|
|
157
|
+
}
|
|
158
|
+
/** 清空注册表 (仅用于测试)。 */
|
|
159
|
+
function _clearServerComponentRegistry() {
|
|
160
|
+
serverComponentRegistry.clear();
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* 定义服务端 island — 将异步组件包裹在 `<Suspense>` 边界中,实现 Partial
|
|
164
|
+
* Prerendering / Server Islands 模式。
|
|
165
|
+
*
|
|
166
|
+
* 替代旧版 `server:defer` 编译时指令。对齐 Next.js 16 PPR / Astro 5
|
|
167
|
+
* `server:defer` 语义。
|
|
168
|
+
*
|
|
169
|
+
* ## 工作机制
|
|
170
|
+
*
|
|
171
|
+
* - **预渲染(SSG)阶段**:仅渲染 fallback(生成静态壳)
|
|
172
|
+
* - **流式 SSR 阶段**:fallback 先输出,异步组件解析后通过 Suspense 边界流式输出
|
|
173
|
+
* - **客户端**:Suspense 边界保持,异步组件解析后自动替换 fallback
|
|
174
|
+
*
|
|
175
|
+
* 传入的 `Component` 必须是异步的(`async setup()` 或 `defineAsyncComponent`)
|
|
176
|
+
* 才能触发 Suspense 流式行为;同步组件会立即解析,Suspense 退化为透明包装。
|
|
177
|
+
*
|
|
178
|
+
* ## Task 9.4: Props 重渲染
|
|
179
|
+
*
|
|
180
|
+
* 当 `options.rerenderOnPropsChange: true` 且 Vite 插件注入了组件路径 (第 3 参数):
|
|
181
|
+
*
|
|
182
|
+
* - **SSR**: `defineServerIsland` 将组件注册到全局注册表 (`registerServerComponent`),
|
|
183
|
+
* 供 `POST /__server-component` 中间件查找。SSR 渲染输出与不带此选项时一致。
|
|
184
|
+
* - **客户端**: 包装组件外层渲染 `<ubean-server-island>` 容器 (带 ref),内部仍是
|
|
185
|
+
* `<Suspense><Component /></Suspense>` (client 构建中 `Component` 是 stub)。
|
|
186
|
+
* `onMounted` 后立即 `POST {path, props}` 到 `/__server-component`,用返回的 HTML
|
|
187
|
+
* 替换容器 `innerHTML`;`watch(attrs)` 在 props 变化时重复此流程。
|
|
188
|
+
*
|
|
189
|
+
* ## Props/Slots 透传
|
|
190
|
+
*
|
|
191
|
+
* 包装组件设置 `inheritAttrs: false`,将所有 attrs(含 props)和 slots
|
|
192
|
+
* 透传给内部 `Component`,使用方式与直接渲染 `Component` 一致:
|
|
193
|
+
*
|
|
194
|
+
* ```vue
|
|
195
|
+
* <DashboardIsland :userId="123" #header="slotProps">...</DashboardIsland>
|
|
196
|
+
* ```
|
|
197
|
+
*
|
|
198
|
+
* 等价于:
|
|
199
|
+
*
|
|
200
|
+
* ```vue
|
|
201
|
+
* <Suspense>
|
|
202
|
+
* <template #fallback><!-- fallback 内容 --></template>
|
|
203
|
+
* <Dashboard :userId="123" #header="slotProps">...</Dashboard>
|
|
204
|
+
* </Suspense>
|
|
205
|
+
* ```
|
|
206
|
+
*
|
|
207
|
+
* ## 用法示例
|
|
208
|
+
*
|
|
209
|
+
* ```ts
|
|
210
|
+
* import { defineServerIsland, h } from 'ubean';
|
|
211
|
+
* import Dashboard from './Dashboard.vue';
|
|
212
|
+
*
|
|
213
|
+
* // 1. 字符串 fallback
|
|
214
|
+
* const DashboardIsland = defineServerIsland(Dashboard, {
|
|
215
|
+
* fallback: 'Loading dashboard...'
|
|
216
|
+
* });
|
|
217
|
+
*
|
|
218
|
+
* // 2. 组件 fallback
|
|
219
|
+
* const DashboardIsland = defineServerIsland(Dashboard, {
|
|
220
|
+
* fallback: () => h('div', { class: 'spinner' }, 'Loading...')
|
|
221
|
+
* });
|
|
222
|
+
*
|
|
223
|
+
* // 3. 默认占位(fallback 未提供时渲染 <ubean-defer-fallback/>)
|
|
224
|
+
* const DashboardIsland = defineServerIsland(Dashboard);
|
|
225
|
+
*
|
|
226
|
+
* // 4. Task 9.4: props 变化时重渲染 (Vite 插件会自动注入第 3 参数)
|
|
227
|
+
* const DashboardIsland = defineServerIsland(Dashboard, {
|
|
228
|
+
* rerenderOnPropsChange: true
|
|
229
|
+
* });
|
|
230
|
+
* ```
|
|
231
|
+
*
|
|
232
|
+
* 在 SFC 中使用包装后的组件:
|
|
233
|
+
*
|
|
234
|
+
* ```vue
|
|
235
|
+
* <script setup>
|
|
236
|
+
* import { defineServerIsland } from 'ubean';
|
|
237
|
+
* import Dashboard from './Dashboard.vue';
|
|
238
|
+
* const DashboardIsland = defineServerIsland(Dashboard, {
|
|
239
|
+
* fallback: 'Loading...'
|
|
240
|
+
* });
|
|
241
|
+
* <\/script>
|
|
242
|
+
*
|
|
243
|
+
* <template>
|
|
244
|
+
* <DashboardIsland :userId="123" />
|
|
245
|
+
* </template>
|
|
246
|
+
* ```
|
|
247
|
+
*
|
|
248
|
+
* @param Component 服务端组件 (通常是 `.server.vue` 导入)
|
|
249
|
+
* @param options 选项
|
|
250
|
+
* @param __serverComponentPath 组件绝对路径 (由 Vite 插件自动注入,勿手动传)
|
|
251
|
+
*/
|
|
252
|
+
function defineServerIsland(Component, options, __serverComponentPath) {
|
|
253
|
+
const fallback = options?.fallback;
|
|
254
|
+
const rerenderOnPropsChange = options?.rerenderOnPropsChange === true && !!__serverComponentPath;
|
|
255
|
+
if (rerenderOnPropsChange && typeof window === "undefined" && __serverComponentPath) registerServerComponent(__serverComponentPath, Component);
|
|
256
|
+
return defineComponent({
|
|
257
|
+
name: "ServerIsland",
|
|
258
|
+
inheritAttrs: false,
|
|
259
|
+
setup(_, { slots, attrs }) {
|
|
260
|
+
if (rerenderOnPropsChange) {
|
|
261
|
+
const containerRef = ref(null);
|
|
262
|
+
const fetchAndReplace = async () => {
|
|
263
|
+
const el = containerRef.value;
|
|
264
|
+
if (!el) return;
|
|
265
|
+
try {
|
|
266
|
+
const res = await _fetchServerComponent(__serverComponentPath, { ...attrs });
|
|
267
|
+
if (res.ok) el.innerHTML = await res.text();
|
|
268
|
+
} catch {}
|
|
269
|
+
};
|
|
270
|
+
onMounted(() => {
|
|
271
|
+
fetchAndReplace();
|
|
272
|
+
watch(() => ({ ...attrs }), fetchAndReplace, { deep: true });
|
|
273
|
+
});
|
|
274
|
+
return () => h("ubean-server-island", { ref: containerRef }, [h(Suspense, null, {
|
|
275
|
+
default: () => h(Component, attrs, slots),
|
|
276
|
+
fallback: typeof fallback === "string" ? () => fallback : fallback ? () => h(fallback) : () => h("ubean-defer-fallback")
|
|
277
|
+
})]);
|
|
278
|
+
}
|
|
279
|
+
return () => h(Suspense, null, {
|
|
280
|
+
default: () => h(Component, attrs, slots),
|
|
281
|
+
fallback: typeof fallback === "string" ? () => fallback : fallback ? () => h(fallback) : () => h("ubean-defer-fallback")
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* 内部: POST 到 `/__server-component` 获取重新渲染的 HTML 片段。
|
|
288
|
+
*
|
|
289
|
+
* 抽出为独立函数便于测试时 mock。使用全局 `fetch`。
|
|
290
|
+
*/
|
|
291
|
+
async function _fetchServerComponent(path, props) {
|
|
292
|
+
const res = await globalThis.fetch(SERVER_COMPONENT_ENDPOINT, {
|
|
293
|
+
method: "POST",
|
|
294
|
+
headers: { "Content-Type": "application/json" },
|
|
295
|
+
body: JSON.stringify({
|
|
296
|
+
path,
|
|
297
|
+
props
|
|
298
|
+
})
|
|
299
|
+
});
|
|
300
|
+
return {
|
|
301
|
+
ok: res.ok,
|
|
302
|
+
text: () => res.text()
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* 定义客户端 island — 将组件包装为延迟水合的 island。
|
|
307
|
+
*
|
|
308
|
+
* 这是 `v-client.*` Vue 指令的运行时替代方案,适用于编程式场景
|
|
309
|
+
* (如动态构造 island、在 `.ts` 文件中定义 island、或需要参数化的场景)。
|
|
310
|
+
*
|
|
311
|
+
* **注意**:模板中的 `v-client.*` 指令语法仍然有效,由 Vite 插件
|
|
312
|
+
* (`ubean:islands`)在编译时转换为 `<ubean-island>` 占位元素;
|
|
313
|
+
* `defineIsland` 仅为运行时编程式使用提供,不会替代 Vite 插件的转换逻辑。
|
|
314
|
+
*
|
|
315
|
+
* ## 工作机制
|
|
316
|
+
*
|
|
317
|
+
* - **SSR 阶段**:渲染 `<ubean-island>` 占位元素,包含 `data-island-id`、
|
|
318
|
+
* `data-component`、`data-directive`、`data-props`、`data-media` 属性
|
|
319
|
+
* (与 `v-client.*` 转换后的输出格式完全一致)。
|
|
320
|
+
* - **客户端**:由 `hydrateIslands()` 根据 `data-directive` 选择水合策略
|
|
321
|
+
* (load/idle/visible/media/only),在合适时机挂载组件。
|
|
322
|
+
*
|
|
323
|
+
* ## 用法示例
|
|
324
|
+
*
|
|
325
|
+
* ```ts
|
|
326
|
+
* import { defineIsland } from 'ubean';
|
|
327
|
+
* import Counter from './Counter.vue';
|
|
328
|
+
*
|
|
329
|
+
* // 1. 立即水合(等价于 v-client.load)
|
|
330
|
+
* const CounterIsland = defineIsland(Counter, 'load');
|
|
331
|
+
*
|
|
332
|
+
* // 2. 空闲时水合(等价于 v-client.idle)
|
|
333
|
+
* const HeavyChartIsland = defineIsland(HeavyChart, 'idle');
|
|
334
|
+
*
|
|
335
|
+
* // 3. 进入视口时水合(等价于 v-client.visible)
|
|
336
|
+
* const LazyMapIsland = defineIsland(LazyMap, 'visible');
|
|
337
|
+
*
|
|
338
|
+
* // 4. 媒体查询匹配时水合(等价于 v-client.media)
|
|
339
|
+
* const MobileNavIsland = defineIsland(MobileNav, 'media', {
|
|
340
|
+
* mediaQuery: '(max-width: 768px)'
|
|
341
|
+
* });
|
|
342
|
+
*
|
|
343
|
+
* // 5. 仅客户端渲染(等价于 v-client.only)
|
|
344
|
+
* const ClientOnlyWidgetIsland = defineIsland(ClientOnlyWidget, 'only');
|
|
345
|
+
* ```
|
|
346
|
+
*
|
|
347
|
+
* 在 SFC 中使用包装后的组件:
|
|
348
|
+
*
|
|
349
|
+
* ```vue
|
|
350
|
+
* <script setup>
|
|
351
|
+
* import { defineIsland } from 'ubean';
|
|
352
|
+
* import Counter from './Counter.vue';
|
|
353
|
+
* const CounterIsland = defineIsland(Counter, 'load');
|
|
354
|
+
* <\/script>
|
|
355
|
+
*
|
|
356
|
+
* <template>
|
|
357
|
+
* <CounterIsland :count="5" />
|
|
358
|
+
* </template>
|
|
359
|
+
* ```
|
|
360
|
+
*/
|
|
361
|
+
function defineIsland(Component, strategy, options) {
|
|
362
|
+
const mediaQuery = options?.mediaQuery;
|
|
363
|
+
const staticProps = options?.props;
|
|
364
|
+
const directive = `client:${strategy}`;
|
|
365
|
+
return defineComponent({
|
|
366
|
+
name: "ClientIsland",
|
|
367
|
+
inheritAttrs: false,
|
|
368
|
+
setup(_, { slots, attrs }) {
|
|
369
|
+
const mergedProps = {
|
|
370
|
+
...staticProps,
|
|
371
|
+
...attrs
|
|
372
|
+
};
|
|
373
|
+
return () => h("ubean-island", {
|
|
374
|
+
"data-island-id": `island-runtime-${strategy}`,
|
|
375
|
+
"data-component": Component?.name ?? "AnonymousIsland",
|
|
376
|
+
"data-directive": directive,
|
|
377
|
+
...mediaQuery ? { "data-media": mediaQuery } : {},
|
|
378
|
+
"data-props": escapeIslandProps(mergedProps)
|
|
379
|
+
}, strategy === "only" ? void 0 : h(Component, mergedProps, slots));
|
|
380
|
+
}
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
/** 序列化 props 为 data-props 属性值(与 vite.ts escapeAttr 等价)。 */
|
|
384
|
+
function escapeIslandProps(props) {
|
|
385
|
+
return JSON.stringify(props).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* `.server.vue` 组件在客户端 bundle 中的占位 stub (Task 9.1)。
|
|
389
|
+
*
|
|
390
|
+
* Vite 插件在 client 构建中将 `.server.vue` 的 import 重定向到虚拟 stub 模块,
|
|
391
|
+
* 该模块导出此组件。组件渲染一个空的 `<ubean-server-only>` 元素 —— SSR 已在
|
|
392
|
+
* 该元素内部渲染了完整 HTML,客户端 Vue 水合时匹配该元素但不触碰其子节点
|
|
393
|
+
* (SSR 模板通过 `v-once` 标记为静态内容),从而保留服务端渲染的 HTML 不被清除。
|
|
394
|
+
*
|
|
395
|
+
* 这保证了 `.server.vue` 组件的 JS 不会发送到客户端 —— 客户端只导入此 stub。
|
|
396
|
+
*/
|
|
397
|
+
const ServerComponentStub = defineComponent({
|
|
398
|
+
name: "ServerComponentStub",
|
|
399
|
+
setup() {
|
|
400
|
+
return () => h("ubean-server-only", { "data-server-only": "" });
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
/**
|
|
404
|
+
* `.client.vue` 组件在 SSR 构建中的通用占位符 (Task 9.2)。
|
|
405
|
+
*
|
|
406
|
+
* Vite 插件在 SSR 构建中将 `.client.vue` 的 import 重定向到虚拟模块,
|
|
407
|
+
* 该模块导出此组件。组件渲染 `<div data-client-only></div>` 占位符,
|
|
408
|
+
* 与客户端 `defineClientComponent` 初始渲染输出一致,确保水合无 mismatch。
|
|
409
|
+
*
|
|
410
|
+
* SSR 使用通用占位符(而非真实组件)避免了在服务端导入可能含浏览器 API
|
|
411
|
+
* 的 `.client.vue` 组件代码。
|
|
412
|
+
*/
|
|
413
|
+
const ClientComponentPlaceholder = defineComponent({
|
|
414
|
+
name: "ClientComponentPlaceholder",
|
|
415
|
+
setup() {
|
|
416
|
+
return () => h("div", { "data-client-only": "" });
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
/**
|
|
420
|
+
* 定义客户端组件 —— `.client.vue` 在客户端构建中的包装器 (Task 9.2)。
|
|
421
|
+
*
|
|
422
|
+
* Vite 插件在 client 构建中为每个 `.client.vue` 生成虚拟包装模块:
|
|
423
|
+
* ```ts
|
|
424
|
+
* import RealComp from '/abs/path/Foo.client.vue';
|
|
425
|
+
* import { defineClientComponent } from '@ubean/islands/runtime';
|
|
426
|
+
* export default defineClientComponent(RealComp);
|
|
427
|
+
* ```
|
|
428
|
+
*
|
|
429
|
+
* ## 工作机制
|
|
430
|
+
*
|
|
431
|
+
* - **SSR**: 由 `ClientComponentPlaceholder` 替代,渲染 `<div data-client-only></div>`
|
|
432
|
+
* - **客户端初始渲染**: `isClient` 为 false,渲染相同的 `<div data-client-only></div>`,
|
|
433
|
+
* 与 SSR 输出匹配,水合无 mismatch
|
|
434
|
+
* - **客户端 `onMounted` 后**: `isClient` 变为 true,渲染真实组件,Vue 自动 patch 替换占位符
|
|
435
|
+
*
|
|
436
|
+
* 这种模式确保 `.client.vue` 组件只在客户端渲染,SSR 仅输出占位符,
|
|
437
|
+
* 且不依赖 islands 注册表 / `hydrateIslands()` 机制。
|
|
438
|
+
*
|
|
439
|
+
* ## 用法
|
|
440
|
+
*
|
|
441
|
+
* 通常由 Vite 插件自动生成包装模块,用户无需手动调用。如需编程式使用:
|
|
442
|
+
*
|
|
443
|
+
* ```ts
|
|
444
|
+
* import { defineClientComponent } from '@ubean/islands/runtime';
|
|
445
|
+
* import Widget from './Widget.client.vue';
|
|
446
|
+
* const WidgetClient = defineClientComponent(Widget);
|
|
447
|
+
* ```
|
|
448
|
+
*/
|
|
449
|
+
function defineClientComponent(component) {
|
|
450
|
+
return defineComponent({
|
|
451
|
+
name: "ClientComponent",
|
|
452
|
+
inheritAttrs: false,
|
|
453
|
+
setup(_, { slots, attrs }) {
|
|
454
|
+
const isClient = ref(false);
|
|
455
|
+
onMounted(() => {
|
|
456
|
+
isClient.value = true;
|
|
457
|
+
});
|
|
458
|
+
return () => isClient.value ? h(component, attrs, slots) : h("div", { "data-client-only": "" });
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* 定义配对组件 — `Foo.vue` 同时存在 `.server.vue` + `.client.vue` 兄弟文件时,
|
|
464
|
+
* Vite 插件生成的虚拟包装模块调用此函数 (Task 9.3)。
|
|
465
|
+
*
|
|
466
|
+
* ## 工作机制
|
|
467
|
+
*
|
|
468
|
+
* - **SSR**: 配对 wrapper 模块直接 re-export `.server.vue`,根本不会调用本函数
|
|
469
|
+
* (见 `vite.ts` `load` 钩子 SSR 分支)。SSR 渲染真实服务端组件内容。
|
|
470
|
+
* - **客户端初始渲染**: `isClient` 为 false,渲染 `ServerComp` —— 但在客户端构建中
|
|
471
|
+
* `.server.vue` 已被重定向到 `ServerComponentStub`(渲染空的
|
|
472
|
+
* `<ubean-server-only>` 元素),与 SSR 输出的 `<ubean-server-only v-once>真实内容
|
|
473
|
+
* </ubean-server-only>` 标签匹配,Vue 水合时元素标签一致 (内部子节点因 `v-once`
|
|
474
|
+
* 标记为静态而被保留,虽有 mismatch 但 Vue 通常能容忍)。
|
|
475
|
+
* - **客户端 `onMounted` 后**: `isClient` 变为 true,渲染 `ClientComp` (真实客户端
|
|
476
|
+
* 组件),Vue 自动 patch 替换 stub 内容。
|
|
477
|
+
*
|
|
478
|
+
* ## 局限性
|
|
479
|
+
*
|
|
480
|
+
* SSR 渲染的 HTML 在客户端水合时不会被完美保留 —— Vue 会尝试 patch stub 的空
|
|
481
|
+
* `<ubean-server-only>` 与 SSR 输出的有内容版本,可能导致 SSR 内容被清除后再渲染
|
|
482
|
+
* 客户端组件。这是已知的折中 (与 React Server Components 的 hydration 流程类似)。
|
|
483
|
+
*
|
|
484
|
+
* 通常由 Vite 插件自动生成包装模块,用户无需手动调用。如需编程式使用:
|
|
485
|
+
*
|
|
486
|
+
* ```ts
|
|
487
|
+
* import { definePairedComponent } from '@ubean/islands/runtime';
|
|
488
|
+
* import ServerComp from './Foo.server.vue';
|
|
489
|
+
* import ClientComp from './Foo.client.vue';
|
|
490
|
+
* const Foo = definePairedComponent(ServerComp, ClientComp);
|
|
491
|
+
* ```
|
|
492
|
+
*/
|
|
493
|
+
function definePairedComponent(ServerComp, ClientComp) {
|
|
494
|
+
return defineComponent({
|
|
495
|
+
name: "PairedComponent",
|
|
496
|
+
inheritAttrs: false,
|
|
497
|
+
setup(_, { slots, attrs }) {
|
|
498
|
+
const isClient = ref(false);
|
|
499
|
+
onMounted(() => {
|
|
500
|
+
isClient.value = true;
|
|
501
|
+
});
|
|
502
|
+
return () => isClient.value ? h(ClientComp, attrs, slots) : h(ServerComp, attrs, slots);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
}
|
|
127
506
|
//#endregion
|
|
128
|
-
export { collectIslands, hydrateIsland, hydrateIslands };
|
|
507
|
+
export { ClientComponentPlaceholder, SERVER_COMPONENT_ENDPOINT, ServerComponentStub, _clearServerComponentRegistry, collectIslands, defineClientComponent, defineIsland, definePairedComponent, defineServerIsland, getServerComponent, hydrateIsland, hydrateIslands, registerServerComponent };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { SERVER_COMPONENT_ENDPOINT } from "./runtime.js";
|
|
2
|
+
import { MiddlewareHandler } from "hono";
|
|
3
|
+
import { UbeanEnv } from "@ubean/shared";
|
|
4
|
+
//#region src/server-component.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* 响应头:标识来自 server-component 中间件的响应。
|
|
7
|
+
*/
|
|
8
|
+
declare const SERVER_COMPONENT_RESPONSE_HEADER = "x-ubean-server-component";
|
|
9
|
+
/**
|
|
10
|
+
* 创建 server-component 中间件 — 处理 `POST /__server-component` 请求。
|
|
11
|
+
*
|
|
12
|
+
* 用法 (通常由 `createUbeanApp()` 自动挂载,用户无需手动调用):
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { createUbeanApp } from 'ubean/runtime/app';
|
|
16
|
+
* import { createServerComponentMiddleware, SERVER_COMPONENT_ENDPOINT } from '@ubean/islands/server';
|
|
17
|
+
*
|
|
18
|
+
* const app = createUbeanApp();
|
|
19
|
+
* app.on('POST', SERVER_COMPONENT_ENDPOINT, createServerComponentMiddleware());
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
declare function createServerComponentMiddleware(): MiddlewareHandler<UbeanEnv>;
|
|
23
|
+
/**
|
|
24
|
+
* 检查请求是否指向 server-component 端点。
|
|
25
|
+
*
|
|
26
|
+
* 供路由注册器跳过将该路径注册为普通 API 路由。
|
|
27
|
+
*/
|
|
28
|
+
declare function isServerComponentRequest(c: {
|
|
29
|
+
req: {
|
|
30
|
+
path: string;
|
|
31
|
+
method: string;
|
|
32
|
+
};
|
|
33
|
+
}): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* 检查响应是否来自 server-component 中间件。
|
|
36
|
+
*/
|
|
37
|
+
declare function isServerComponentResponse(response: Response): boolean;
|
|
38
|
+
//#endregion
|
|
39
|
+
export { SERVER_COMPONENT_ENDPOINT, SERVER_COMPONENT_RESPONSE_HEADER, createServerComponentMiddleware, isServerComponentRequest, isServerComponentResponse };
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { SERVER_COMPONENT_ENDPOINT, getServerComponent } from "./runtime.js";
|
|
2
|
+
import { h } from "vue";
|
|
3
|
+
import { renderToString } from "vue/server-renderer";
|
|
4
|
+
//#region src/server-component.ts
|
|
5
|
+
/**
|
|
6
|
+
* 响应头:标识来自 server-component 中间件的响应。
|
|
7
|
+
*/
|
|
8
|
+
const SERVER_COMPONENT_RESPONSE_HEADER = "x-ubean-server-component";
|
|
9
|
+
/**
|
|
10
|
+
* 创建 server-component 中间件 — 处理 `POST /__server-component` 请求。
|
|
11
|
+
*
|
|
12
|
+
* 用法 (通常由 `createUbeanApp()` 自动挂载,用户无需手动调用):
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { createUbeanApp } from 'ubean/runtime/app';
|
|
16
|
+
* import { createServerComponentMiddleware, SERVER_COMPONENT_ENDPOINT } from '@ubean/islands/server';
|
|
17
|
+
*
|
|
18
|
+
* const app = createUbeanApp();
|
|
19
|
+
* app.on('POST', SERVER_COMPONENT_ENDPOINT, createServerComponentMiddleware());
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
function createServerComponentMiddleware() {
|
|
23
|
+
return async (c) => {
|
|
24
|
+
if (!(c.req.header("Content-Type") || "").includes("application/json")) return _htmlError(c, "Content-Type must be application/json", 400);
|
|
25
|
+
let body;
|
|
26
|
+
try {
|
|
27
|
+
body = await c.req.json();
|
|
28
|
+
} catch {
|
|
29
|
+
return _htmlError(c, "Malformed JSON body", 400);
|
|
30
|
+
}
|
|
31
|
+
const path = body?.path;
|
|
32
|
+
if (!path) return _htmlError(c, "Missing \"path\" field", 400);
|
|
33
|
+
const Component = getServerComponent(path);
|
|
34
|
+
if (!Component) return _htmlError(c, `Component not registered for path: ${path}`, 404);
|
|
35
|
+
const props = body?.props ?? {};
|
|
36
|
+
try {
|
|
37
|
+
const html = await renderToString(h(Component, props));
|
|
38
|
+
return c.body(html, 200, {
|
|
39
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
40
|
+
"Cache-Control": "no-store",
|
|
41
|
+
[SERVER_COMPONENT_RESPONSE_HEADER]: "true"
|
|
42
|
+
});
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return _htmlError(c, `Render failed: ${err instanceof Error ? err.message : String(err)}`, 500);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 检查请求是否指向 server-component 端点。
|
|
50
|
+
*
|
|
51
|
+
* 供路由注册器跳过将该路径注册为普通 API 路由。
|
|
52
|
+
*/
|
|
53
|
+
function isServerComponentRequest(c) {
|
|
54
|
+
return c.req.method === "POST" && c.req.path === "/__server-component";
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 检查响应是否来自 server-component 中间件。
|
|
58
|
+
*/
|
|
59
|
+
function isServerComponentResponse(response) {
|
|
60
|
+
return response.headers.get(SERVER_COMPONENT_RESPONSE_HEADER) === "true";
|
|
61
|
+
}
|
|
62
|
+
function _htmlError(c, message, status) {
|
|
63
|
+
return c.body(message, status, {
|
|
64
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
65
|
+
"Cache-Control": "no-store",
|
|
66
|
+
[SERVER_COMPONENT_RESPONSE_HEADER]: "true"
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
export { SERVER_COMPONENT_ENDPOINT, SERVER_COMPONENT_RESPONSE_HEADER, createServerComponentMiddleware, isServerComponentRequest, isServerComponentResponse };
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `virtual:ubean-islands-registry` 的 TypeScript 类型声明。
|
|
3
3
|
*
|
|
4
4
|
* 此虚拟模块由 `ubeanIslandsPlugin` 在构建/开发期生成,导出扫描到的 island 组件注册表。
|
|
5
|
-
*
|
|
5
|
+
* 自动注册机制详见站点指南 [Islands](/guide/islands)(Automatic Hydration 一节)。
|
|
6
6
|
*
|
|
7
7
|
* 用户项目通常无需手动引用此声明 —— 只要 `tsconfig.json` 的 `include` 涵盖了
|
|
8
8
|
* `node_modules/@ubean/islands/dist/**`,TypeScript 会自动发现此 ambient 声明。
|
package/dist/vite.d.ts
CHANGED
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
import { Plugin } from "vite";
|
|
2
2
|
//#region src/vite.d.ts
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* 判断 id 是否为 `.server.vue` 文件 (Task 9.1)。
|
|
5
|
+
*
|
|
6
|
+
* 仅检查路径部分(忽略 `?query`),避免 `?vue&type=template` 子查询误匹配。
|
|
7
|
+
*/
|
|
8
|
+
declare function isServerComponentFile(id: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* 判断 id 是否为 `.client.vue` 文件 (Task 9.2)。
|
|
11
|
+
*
|
|
12
|
+
* 仅检查路径部分(忽略 `?query`)。注意:从虚拟包装模块内部对真实文件的
|
|
13
|
+
* import 不会被此函数拦截(由 `resolveId` 中的 importer 检查排除)。
|
|
14
|
+
*/
|
|
15
|
+
declare function isClientComponentFile(id: string): boolean;
|
|
16
|
+
/** `.server.vue` 在 client 构建中的通用虚拟 stub 模块 ID */
|
|
17
|
+
declare const SERVER_COMPONENT_STUB_VIRTUAL_ID = "virtual:ubean-server-component-stub";
|
|
18
|
+
/** `.client.vue` 在 SSR 构建中的通用占位符虚拟模块 ID */
|
|
19
|
+
declare const CLIENT_COMPONENT_PLACEHOLDER_VIRTUAL_ID = "virtual:ubean-client-component-placeholder";
|
|
20
|
+
/**
|
|
21
|
+
* 将 `.server.vue` SFC 的 `<template>` 内容包裹在 `<ubean-server-only v-once>` 中。
|
|
22
|
+
*
|
|
23
|
+
* SSR 渲染输出 `<ubean-server-only v-once>真实内容</ubean-server-only>`,
|
|
24
|
+
* 客户端 stub 渲染 `<ubean-server-only></ubean-server-only>` (无子节点)。
|
|
25
|
+
* `v-once` 标记内容为静态,Vue 水合时保留 SSR HTML 不清除。
|
|
26
|
+
*
|
|
27
|
+
* 仅在 SSR 上下文运行(client 构建中 `.server.vue` 被 `resolveId` 重定向到 stub,
|
|
28
|
+
* 真实文件不会被加载/转换)。
|
|
29
|
+
*/
|
|
30
|
+
declare function wrapServerComponentTemplate(code: string): string | null;
|
|
4
31
|
/**
|
|
5
32
|
* 一个 island 组件的注册条目。
|
|
6
33
|
*
|
|
@@ -31,13 +58,43 @@ type IslandComponentMap = Map<string, IslandComponentEntry>;
|
|
|
31
58
|
*/
|
|
32
59
|
declare function parseScriptImports(scriptContent: string): Map<string, string>;
|
|
33
60
|
/**
|
|
34
|
-
*
|
|
61
|
+
* Task 9.4: 扫描代码中的 `defineServerIsland(Identifier, { ... rerenderOnPropsChange: true ... })`
|
|
62
|
+
* 调用,解析 `Identifier` 的 import 路径,将其作为第 3 个参数注入:
|
|
63
|
+
*
|
|
64
|
+
* ```ts
|
|
65
|
+
* defineServerIsland(Comp, { rerenderOnPropsChange: true }, "/abs/path.server.vue")
|
|
66
|
+
* ```
|
|
67
|
+
*
|
|
68
|
+
* ## 工作机制
|
|
69
|
+
*
|
|
70
|
+
* 1. 提取 `<script>` 块内容(`.vue` 文件)或整个代码(`.ts` 文件)
|
|
71
|
+
* 2. 查找所有 `defineServerIsland(...)` 调用,跳过注释/字符串/函数声明
|
|
72
|
+
* 3. 对每个调用:
|
|
73
|
+
* - 参数 < 2 → 跳过(至少需要 Component + options)
|
|
74
|
+
* - 参数 ≥ 3 → 跳过(已有路径,幂等)
|
|
75
|
+
* - options 对象不含 `rerenderOnPropsChange: true` → 跳过(非 props 重渲染场景)
|
|
76
|
+
* - 通过 `parseScriptImports` 解析 identifier 的 import 路径,相对路径解析为绝对路径
|
|
77
|
+
* - 在 `)` 前注入 `, "/abs/path"`
|
|
78
|
+
*
|
|
79
|
+
* ## 幂等性
|
|
80
|
+
*
|
|
81
|
+
* 已注入的调用(参数 ≥ 3)不会被再次处理。
|
|
82
|
+
*
|
|
83
|
+
* @param code 完整源码(`.vue` SFC 或 `.ts` 文件)
|
|
84
|
+
* @param sourceFile 源文件绝对路径(用于解析相对 import)
|
|
85
|
+
* @returns 修改后的源码,或 `null` 表示无修改
|
|
86
|
+
*/
|
|
87
|
+
declare function injectServerComponentPath(code: string, sourceFile: string): string | null;
|
|
88
|
+
/**
|
|
89
|
+
* 扫描模板内容,返回所有带 `v-client.*` 指令的组件标签名集合。
|
|
35
90
|
*
|
|
36
91
|
* 复用 `findTagAt` 的标签解析逻辑,确保与 `transformTemplate` 的识别规则一致
|
|
37
92
|
* (仅匹配首字母大写的组件标签)。
|
|
38
93
|
*
|
|
39
94
|
* 与 `transformTemplate` 对称:非 island 标签会递归扫描其 innerHTML,
|
|
40
95
|
* island 标签不递归(其子内容属于该 island 的 SSR 输出,不作为独立 island)。
|
|
96
|
+
*
|
|
97
|
+
* Phase 4: 仅识别 `v-client.*`(旧的 `client:*` 语法已移除)。
|
|
41
98
|
*/
|
|
42
99
|
declare function scanIslandDirectiveNames(template: string): Set<string>;
|
|
43
100
|
/**
|
|
@@ -50,7 +107,7 @@ declare function resolveIslandImportPath(importPath: string, sourceFile: string)
|
|
|
50
107
|
* 步骤:
|
|
51
108
|
* 1. 提取 `<script setup>` / `<script>` 内容
|
|
52
109
|
* 2. 解析 import 语句,建立 { 局部名 → import 路径 } 映射
|
|
53
|
-
* 3. 扫描模板中的 `<Comp client
|
|
110
|
+
* 3. 扫描模板中的 `<Comp v-client.* />` 指令,得到组件名集合
|
|
54
111
|
* 4. 交集:既在 import 映射中、又在 island 指令集合中的组件
|
|
55
112
|
* 5. 将相对 import 路径解析为绝对路径
|
|
56
113
|
*
|
|
@@ -86,4 +143,4 @@ interface UbeanIslandsPluginOptions {
|
|
|
86
143
|
declare const ISLANDS_REGISTRY_VIRTUAL_ID = "virtual:ubean-islands-registry";
|
|
87
144
|
declare function ubeanIslandsPlugin(_options?: UbeanIslandsPluginOptions): Plugin;
|
|
88
145
|
//#endregion
|
|
89
|
-
export {
|
|
146
|
+
export { CLIENT_COMPONENT_PLACEHOLDER_VIRTUAL_ID, ISLANDS_REGISTRY_VIRTUAL_ID, IslandComponentEntry, IslandComponentMap, SERVER_COMPONENT_STUB_VIRTUAL_ID, UbeanIslandsPluginOptions, collectIslandComponents, generateRegistryModule, injectServerComponentPath, isClientComponentFile, isServerComponentFile, parseScriptImports, resolveIslandImportPath, scanIslandDirectiveNames, transformVueSfcIslands, ubeanIslandsPlugin, wrapServerComponentTemplate };
|