其他
干货 | 携程商旅大前端 React Streaming 的探索之路
19 组清风,携程资深前端开发工程师,负责商旅前端公共基础平台建设,关注 NodeJs、研究效能领域;
ZZR,携程商旅资深前端开发工程师,负责商旅公共平台基础平台建设,致力于高效率、高性能开发。
一、引言
// 获取商品评论信息(延迟3s)
function getComments(): Promise<string[]> {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export default async function Home() {
// 获取评论数据
const comments = await getComments();
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div>
<div>商品</div>
<p>价格</p>
<div>
<p>评论</p>
<input />
<div>
{comments.map((comment) => {
return <p key={comment}>{comment}</p>;
})}
</div>
</div>
</div>
</main>
);
}
// components/Comment.tsx
function getComments(): Promise<string[]> {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export default async function Comments() {
const comments = await getComments();
return (
<div>
<p>评论</p>
<input />
{comments.map((comment) => {
return <p key={comment}>{comment}</p>;
})}
</div>
);
}
// app/page.tsx
import Comment from '@/components/Comments';
import { Suspense } from 'react';
export default async function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-between p-24">
<div>
<div>商品</div>
<p>价格</p>
<div>
{/* Suspense 包裹携带数据请求的 Comment Server 组件 */}
<Suspense fallback={<div>Loading...</div>}>
<Comment />
</Suspense>
</div>
</div>
</main>
);
}
// components/Comment.tsx
import { useRef } from 'react';
function getComments(): Promise<string[]> {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export default async function Comments() {
const comments = await getComments();
const inputRef = useRef<HTMLInputElement>(null);
const onSubmit = () => {
alert(`您提交的评论内容:${inputRef.current?.value}`);
};
return (
<div>
<p>评论</p>
<input ref={inputRef} />
<button onClick={onSubmit}>提交评论</button>
{comments.map((comment) => {
return <p key={comment}>{comment}</p>;
})}
</div>
);
}
// components/Comment.tsx
import EditableComments from './EditableComments';
function getComments(): Promise<string[]> {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export default async function Comments() {
const comments = await getComments();
return (
<div>
<p>评论</p>
{/* RFC 中包裹客户端组件 */}
<EditableComments comments={comments} />
{comments.map((comment) => {
return <p key={comment}>{comment}</p>;
})}
</div>
);
}
// components/EditableComments.tsx
'use client';
import { useRef } from 'react';
export default function EditableComments(props: { comments: string[] }) {
const inputRef = useRef<HTMLInputElement>(null);
const onSubmit = () => {
// 限制评论内容
if (props.comments.length < 10) {
alert(`您提交的评论内容为:${inputRef.current?.value}`);
}
};
return (
<>
<input ref={inputRef} />
<button onClick={onSubmit}>提交评论</button>
</>
);
}
import type { LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export const loader: LoaderFunction = () => {
return json({
name: '19Qingfeng',
});
};
export default function Index() {
const { name } = useLoaderData();
return (
<div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
<h3>Hello {name}!</h3>
</div>
);
}
首先,export const loader 表示该页面导出了一个名为 loader 的方法,用于在服务端的页面数据获取。
其次,导出的 export default function Index 和 NextJs 用法相同。
import type { LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
function getComments(): Promise<string[]> {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export const loader: LoaderFunction = async () => {
const comments = await getComments();
return json({
comments,
});
};
export default function Index() {
const { comments } = useLoaderData<{ comments: string[] }>();
return (
<div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
<div>
<div>商品</div>
<p>价格</p>
<div>
<div>
<p>评论</p>
{comments.map((comment) => {
return <p key={comment}>{comment}</p>;
})}
</div>
</div>
</div>
</div>
);
}
export const loader: LoaderFunction = async () => {
const comments = await getComments();
return json({
comments,
});
};
export const loader: LoaderFunction = async () => {
const comments = getComments();
// 使用 defer 传输 getComments 返回的 Promise
return defer({
comments,
});
};
export default function Index() {
// 使用 loaderFunction 获取中传递的 Promise
const { comments } = useLoaderData<{ comments: Promise<string[]> }>();
// ...
}
import type { LoaderFunction } from '@remix-run/node';
import { defer } from '@remix-run/node';
import { Await, useLoaderData } from '@remix-run/react';
import { Suspense } from 'react';
function getComments(): Promise<string[]> {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export const loader: LoaderFunction = async () => {
const comments = getComments();
return defer({
comments,
});
};
export default function Index() {
const { comments } = useLoaderData<{ comments: string[] }>();
return (
<div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
<div>
<div>商品</div>
<p>价格</p>
<div>
<div>
<p>评论</p>
<Suspense fallback={<div>Loading...</div>}>
<Await<string[]> resolve={comments}>
{(comments) => {
return comments.map((comment) => {
return <p key={comment}>{comment}</p>;
});
}}
</Await>
</Suspense>
</div>
</div>
</div>
</div>
);
}
.
├── README.md 描述文件,如何安装和启动
├── build 客户端产物存放文件
│ └── index.js
├── package.json
├── pnpm-lock.yaml
├── public 静态资源存放目录
│ └── index.css
├── rollup.config.mjs rollup 配置文件
├── server
│ └── render.js 服务端渲染方法
├── server.entry.js 服务端入口文件
└── src
├── App.jsx 页面入口组件
├── html.jsx 页面 HTML 组件,用于 Server Side 生生成 HTML
└── index.jsx 客户端入口文件
{
...
"scripts": {
"dev": "npm-run-all --parallel \"dev:*\"",
"dev:server": "cross-env NODE_ENV=development babel-node server.entry.js",
"dev:client": "cross-env NODE_ENV=development rollup -c -w"
}
}
const express = require('express');
const render = require('./server/render').default;
const app = express();
app.use(express.static('build'));
app.use(express.static('public'));
app.get('/', (req, res) => {
render(res);
});
app.listen(3000, () => {
console.log(`Server on Port: 3000`);
});
import React from 'react';
import App from '../src/App';
import HTML from '../src/html';
import { renderToString } from 'react-dom/server';
function getComments() {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export default async function render(res) {
const comments = await getComments();
res.send(
renderToString(
<HTML comments={comments}>
<App comments={comments} />
</HTML>
)
);
}
// src/html.jsx
import React from 'react';
export default ({children,comments}) => {
return <html>
<head>
<link ref="stylesheet" href="/fwc/index.css"></link>
</head>
<body>
<div id='root'>{children}</div>
</body>
</html>
}
// src/App.jsx
import React, { useRef } from "react";
export default function Index({comments}) {
const inputRef = useRef(null)
const onSubmit = () => {
if(inputRef.current) {
alert(`添加评论内容:${inputRef.current?.value}`)
}
}
return (
<div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
<div>
<div>商品</div>
<p>价格</p>
<input ref={inputRef} />
<button onClick={onSubmit}>添加评论</button>
<div>
<div>
<p>评论</p>
{
Array.isArray(comments) && comments.map(comment => {
return <p key={comment}>{comment}</p>;
})
}
</div>
</div>
</div>
</div>
);
}
import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'
startTransition(() => {
hydrateRoot(document.getElementById('root'),<App />)
})
import React from 'react';
export default ({children,comments}) => {
return <html>
<head>
<link ref="stylesheet" href="/fwc/index.css"></link>
</head>
<body>
<div id='root'>{children}</div>
{/* 添加 JS 脚本注入 */}
<script src="/fwc/index.js"></script>
</body>
</html>
}
import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'
startTransition(() => {
// 客户端发生 hydrate 的 <App /> 组件并没有任何 comments 传入
hydrateRoot(document.getElementById('root'),<App />)
})
import React from 'react';
export default ({children,comments}) => {
return <html>
<head>
<link ref="stylesheet" href="/fwc/index.css"></link>
</head>
<body>
<div id='root'>{children}</div>
<script dangerouslySetInnerHTML={{
__html: `window.__diy_ssr_context=${JSON.stringify(comments)}`
}}></script>
<script src="/fwc/index.js">
</script>
</body>
</html>
}
import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'
startTransition(() => {
hydrateRoot(document.getElementById('root'),<App comments={window.__diy_ssr_context} />)
})
import React from 'react';
import App from '../src/App';
import HTML from '../src/html';
import { renderToPipeableStream } from 'react-dom/server';
function getComments() {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export default async function render(res) {
const comments = await getComments();
// renderToPipeableStream replace renderToString
const { pipe } = renderToPipeableStream(
<HTML comments={comments}>
<App comments={comments} />
</HTML>,
{
onShellReady() {
pipe(res);
},
}
);
}
import React, { useEffect, useState } from 'react';
function getSomeData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve();
}, 3000);
});
}
function Demo() {
const [data, setData] = useState();
useEffect(() => {
getSomeData().then((data) => setData(data));
});
return (
<div>
<h3>Title</h3>
{data && <div>{data}</div>}
</div>
);
}
import React, { Suspense, use } from 'react';
function getSomeData() {
return new Promise((resolve) => {
setTimeout(() => {
resolve('hello demo');
}, 3000);
});
}
export default function Demo() {
// 使用 use hook 传递需要等待的 Promise,并且同步的方式直接获取数据
const data = use(getSomeData());
return (
<div>
<h3>Title</h3>
<>{data}</>
</div>
);
}
export function DemoWrapper() {
return <Suspense fallback={<div>Loading Demo</div>}>
{/* 调用 Suspense 直接包裹 Demo 组件 */}
<Demo />
</Suspense>
}
// server/render.ts
import React from 'react';
import App from '../src/App';
import HTML from '../src/html';
import { renderToPipeableStream } from 'react-dom/server';
function getComments() {
return new Promise((resolve) =>
setTimeout(() => {
resolve(['This is Great.', 'Worthy of recommendation!']);
}, 3000)
);
}
export default async function render(res) {
const comments = getComments();
// server 端
const stream = renderToPipeableStream(
<HTML comments={comments}>
<App comments={comments} />
</HTML>,
{
onShellReady() {
stream.pipe(res);
},
}
);
}
import React from 'react';
export default ({children,comments}) => {
return <html>
<head>
<meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
<link ref="stylesheet" href="/fwc/index.css"></link>
</head>
<body>
<div id='root'>{children}</div>
{/* <script src="/fwc/index.js" /> */}
</body>
</html>
}
// src/App.tsx
import React, { useRef, use, Suspense } from "react";
function Comments({ comments }) {
const commentsResult = use(comments)
return Array.isArray(commentsResult) && commentsResult.map(comment => {
return <p key={comment}>{comment}</p>;
})
}
export default function Index({comments}) {
const inputRef = useRef(null)
const onSubmit = () => {
if(inputRef.current) {
alert(`添加评论内容:${inputRef.current?.value}`)
}
}
return (
<div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.8' }}>
<div>
<div>商品</div>
<p>价格</p>
<input ref={inputRef} />
<button onClick={onSubmit}>添加评论</button>
<div>
<div>
<p>评论</p>
<Suspense fallback={<div>Loading</div>}>
<Comments comments={comments} />
</Suspense>
</div>
</div>
</div>
</div>
);
}
// src/html.jsx
import React from 'react';
export default ({children,comments}) => {
return <html>
<head>
<meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
<link ref="stylesheet" href="/fwc/index.css"></link>
</head>
<body>
<div id='root'>{children}</div>
<script src="/fwc/index.js" />
</body>
</html>
}
// src/index.tsx
import React, { startTransition } from 'react'
import { hydrateRoot } from 'react-dom/client'
import App from './App'
// 目前看来永远不会被 resolve 的 Promise
const clientPromise = new Promise((resolve) => {
window.__setComments_data = (comments) => resolve(comments)
})
startTransition(() => {
hydrateRoot(document.getElementById('root'),<App comments={clientPromise} />)
})
// src/html.jsx
import React, { Suspense , use} from 'react';
function CommentsScript({ comments: commentsPromise }) {
const comments = use(commentsPromise)
return <script dangerouslySetInnerHTML={{
__html: `window.__setComments_data(${JSON.stringify(comments)})`
}}></script>
}
export default ({children,comments}) => {
return <html>
<head>
<meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
<link ref="stylesheet" href="/fwc/index.css"></link>
</head>
<body>
<div id='root'>{children}</div>
<script src="/fwc/index.js" />
<Suspense>
<CommentsScript comments={comments}></CommentsScript>
</Suspense>
</body>
</html>
}
<Suspense>
<CommentsScript comments={comments}></CommentsScript>
</Suspense>
<!-- 3s 前,上半段返回内容 -->
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
<link href="/fwc/index.css" />
</head>
<body>
<div id="root">
<div style="font-family:system-ui, sans-serif;line-height:1.8">
<div>
<div>商品</div>
<p>价格</p><input /><button>添加评论</button>
<div>
<div>
<p>评论</p><!--$?--><template id="B:0"></template>
<div>Loading</div><!--/$-->
</div>
</div>
</div>
</div>
</div>
<script src="/fwc/index.js"></script><!--$?--><template id="B:1"></template><!--/$-->
利用 <Suspense /> 包裹的 <Comments /> 组件。
除了正常应该返回的客户端脚本 index.js 外,额外返回了一个 <template id="B:1"></template> 节点。
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/htmL; charset=utf-8" />
<link href="/fwc/index.css" />
</head>
<body>
<div id="root">
<div style="font-family:system-ui, sans-serif;line-height:1.8">
<div>
<div>商品</div>
<p>价格</p><input /><button>添加评论</button>
<div>
<div>
<p>评论</p><!--$?--><template id="B:0"></template>
<div>Loading</div><!--/$-->
</div>
</div>
</div>
</div>
</div>
<script src="/fwc/index.js"></script><!--$?--><template id="B:1"></template><!--/$-->
<div hidden id="S:0">
<p>This is Great.</p>
<p>Worthy of recommendation!</p>
</div>
<script>$RC = function (b, c, e) { c = document.getElementById(c); c.parentNode.removeChild(c); var a = document.getElementById(b); if (a) { b = a.previousSibling; if (e) b.data = "$!", a.setAttribute("data-dgst", e); else { e = b.parentNode; a = b.nextSibling; var f = 0; do { if (a && 8 === a.nodeType) { var d = a.data; if ("/$" === d) if (0 === f) break; else f--; else "$" !== d && "$?" !== d && "$!" !== d || f++ } d = a.nextSibling; e.removeChild(a); a = d } while (a); for (; c.firstChild;)e.insertBefore(c.firstChild, a); b.data = "$" } b._reactRetry && b._reactRetry() } }; $RC("B:0", "S:0")</script>
<div hidden id="S:1">
<script>window.__setComments_data(["This is Great.", "Worthy of recommendation!"])</script>
</div>
<script>$RC("B:1", "S:1")</script>
</body>
</html>
<div hidden id="S:0">
<p>This is Great.</p>
<p>Worthy of recommendation!</p>
</div>
$RC = function (b, c, e) {
c = document.getElementById(c);
c.parentNode.removeChild(c);
var a = document.getElementById(b);
if (a) {
b = a.previousSibling;
if (e) (b.data = '$!'), a.setAttribute('data-dgst', e);
else {
e = b.parentNode;
a = b.nextSibling;
var f = 0;
do {
if (a && 8 === a.nodeType) {
var d = a.data;
if ('/$' === d)
if (0 === f) break;
else f--;
else ('$' !== d && '$?' !== d && '$!' !== d) || f++;
}
d = a.nextSibling;
e.removeChild(a);
a = d;
} while (a);
for (; c.firstChild; ) e.insertBefore(c.firstChild, a);
b.data = '$';
}
b._reactRetry && b._reactRetry();
}
};
“携程技术”公众号
分享,交流,成长