本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!
引言
通常在 Server Side Render 下,服务端和客户端的数据通讯机制都是在服务端获取数据进行序列化后注入到 HTML 模版中,从而客户端可以在全局环境中获取这部分初始化的服务端数据。
这部分经过双端传输的数据大多数情况下都是通过字符串的形式进行传输,某些场景下开发者们希望在服务端可以序列化一些具有状态的内容(Promise)从而在客户端根据内容的状态配合(Suspense 等)进行有状态的渐进性页面加载。
这篇文章我们就来聊聊在服务端渲染下,我们应该如何序列化一些无法被序列化的数据。
Promise 只是无法序列化数据比较有代表性的一部分而已,诸如此类经过简单的字符串转化后会丢失原本对象属性的还有 Regexp、Date、Symbol、BigInt 等等。
现状
了解过服务端渲染的同学应该都清楚,通常 Web 应用程序会选择将部分数据请求在服务中发起(由于网络延迟、服务器性能、内网链接等多种因素服务端中的请求大多数情况会比客户端发起获得更少的耗时)。
选择在服务端发起部分请求,自然就少不了服务端请求结束后的数据输入,这个过程更多像是:
上图中的蓝色横线部分表示在服务端发起请求后将获取的数据需要传递给客户端,客户端如果要获取在服务端请求到的数据则必须要有一个传入的介质。
这个介质也并没有多么神秘,基本上都是通过在服务端返回的 HTML 模版中携带一段 script 脚本从而为 window 上挂载这部分数据,从而客户端脚本可以通过 window.xxx 获取这部分数据。
比如,使用 nextjs 的携程商旅 正是通过上述的方式进行数据传输:
困境
我们提到过,传统 Web 服务端渲染的框架皆是通过全局变量的形式来进行数据传输。自然,大多数 Web 应用(框架)都会选择通过 JSON.stringify 将服务端进行数据进行序列化后传递到客户端中。
比如,我们在服务端中获取到的数据会通过 script 的脚本形式进行下发:
html
<!-- NodeServer HTML 模版中 -->
<script>
window.__nextData = JSON.stringify(`
{
name: 'wang.haoyu',
nickName: '19QIngfeng',
pos: 'China'
}
`)
</script>
对于常见的基本数据类型,这样的方式无疑是最为直接且最佳效率的方式。
不过这种直接按照字符串序列化的方式有两个隐患的弊端:
- 冗余的数据传输
通常对于具有相同值的数据在传输过程中通过 stingify 的方式是无法进行数据去重的。
比如:
json
{
"user1" : "A",
"user2": "A",
"user3": "A"
// ...
}
上面的 JSON 对象中直接通过 stringify 方式处理的话在网络传输层的确会稍微有些冗余。
当然大多数场景下我们并不会在服务端渲染下选择首屏加载在服务端请求太多数据。
不过当你的确需要在服务端传输大量重复数据给客户端使用时,冗余的数据传输的确会拖慢你的页面性能。
- 无法序列化特殊数据类型
更重要的一点则是直接使用 stringify 的方式实际上是无法序列化所有的数据的,上边我们也提到过如果对于 Promise、Date 等类型直接进行 stringify 操作,实际并不会满足我们的预期。
Promise 序列化后会变成一个字符串的空对象,丢失原本方法和状态:
Date 类型序列化后会成为一个 UTC 格式的时间字符串,丢失原本的属性和方法:
当然,许多同学会好奇为什么我们需要序列化诸如 Promise 之类的特殊例子到客户端。
其实随着 Web Streaming 的发展,目前非常多的 Web 框架已经将 HTML 的渲染结合 Streaming 把首屏页面实现"流式渲染"的效果。
在所谓的"流式渲染"效果下,服务端渲染的请求并不会阻塞页面的渲染(配合 Suspense 实现部分元素渐进式加载)。
自然我们就需要实现在服务端发起请求后,当请求 Promise 状态完成后通知客户端进行渲染,看起来类似于需要在双端序列化 Promise 并且维持原本状态的传输。
Streaming 并不是这篇文章的重点,有兴趣了解 Streaming 的同学可以参考我之前的文章 详解 React Streaming 过程。
方案
在搞清楚了问题之后,接下来的内容让我们一起来探索如何解决服务端渲染时如何保持数据原始的状态。
方案一 - Remix 序列化思路
第一种方式是在服务端渲染时通过在客户端构造虚拟的 Promise 配合在服务端渲染时 HTML 推送 <script />
脚本的方式来完成的,这也是目前 Remix 中使用的方式。
这个过程用一个图来表示的话就像是:
红色代表服务端动作,绿色代表客户端执行。
上图可能稍微有些抽象,接下来我们会逐步分析这一过程。
实际上,Remix 中实现序列化 Promise 的传递并不是通过字符串序列化的方式来传递,更像是用了一种取巧的方式维持了客户端和服务端的 Promise 状态、数据传递。
在 Remix 中实现"序列化" Promise 的方式主要使用了 defer Api
、<Scripts />
、<Await />
以及 <Suspense />
这四个 Api/Component 来实现。
用法
在 Remix 中可以使用 loaderFunction 配合 defer 在 loaderFunction 中将需要序列化的 Promise 使用 defer 方法进行返回。
之后在客户端使用 useloaderData 配合 <Await />
组件就可以实现在服务端传递 Promise 给客户端同时保留 Promise 的状态和数据。
tsx
import type { LoaderFunctionArgs } from "@remix-run/node"; // or cloudflare/deno
import { defer } from "@remix-run/node"; // or cloudflare/deno
import { Await, useLoaderData } from "@remix-run/react";
import React, { Suspense } from "react";
const fetchSomeData = () => {
return new Promise((resolve) => {
resolve({
name: "19Qingfeng",
});
});
};
export async function loader({ params }: LoaderFunctionArgs) {
// 👇 获取用户名称
let namePromise = fetchSomeData();
return defer({
namePromise,
});
}
export default function Product() {
let { namePromise } = useLoaderData();
return (
<Suspense fallback={<div>Loading...</div>}>
<Await<{ name: string }> resolve={namePromise}>
{(data) => {
return (
<div>
<p>User Name: {data.name}</p>
</div>
);
}}
</Await>
</Suspense>
);
}
在 Remix 中默认情况下 loader 仅会在服务端代码中调用,这也就意味着 fetchSomeData 方法是在服务端发起数据请求,返回的 namePromise 是运行在服务端的 Promise 。
我们在服务端环境中使用 defer 方法包裹了 namePromise 在 loader 中进行返回。
而 Product 组件在客户端渲染时,使用 useLoaderData 获取到了服务端运行环境中的 namePromise 同时配合 Await 组件获取到了 namePromise resolve 后的值。
通过 Remix 中提供的 Api 可以方便的为我们解决 Promise 无法序列化的问题,将在服务端生成的 Promise 可以保持原始的状态同步传递给客户端,从而在客户端可以获得完全相同状态的 Promise。
原理
这一切看起来都非常神奇对吧,那么 Remix 是如何做到将服务端生成的 Promise 保留原始状态传递给客户端同时还可以同步双端 Promise 的状态呢?接下来就让我们来一探究竟。
loader
在 Remix 中,当某个页面需要存在服务端请求时紧需在该页面所在文件具名导出一个 loader 方法该方法及会在用户访问该页面时立即被调用。
不难想象 loader 的调用实际是在静态文件编译时就已经确认好的,@remix-run/dev 会在编译时确认每一个路径下拥有的 loader 和 action 方法:
关于 Remix 的静态编译并不是文章中的重点,大家只要了解 loader 是在静态编译时根据路径确定好,从而在用户访问对应页面时在服务端被调用即可。
defer
首先,在 Remix 中需要将服务端传递给客户端的 Promise 使用 defer 方法包裹起来。
所谓 defer ,顾名思义就是延迟的意思。Remix 中仅仅是将 React Router 的 defer 的进行了导出。
当我们调用 defer 方法传入一个对象时,ReactRouter 会创建一个 DeferredData 的示例。
DeferredData 的构造函数中核心的操作便是调用 Object.entries 遍历传入的对象,对于传入对象中每一项进行 trackPromise 的操作。
在 trackPromise 方法中,核心有两步操作:
-
对于遍历到的每一个 Promise fulfilled 后的 data/error 再次调用 onSettle 操作。
-
同时,为遍历到的 promise 标记属性
_tracked
属性为 true,表示该 Promise 已经被 defer 函数处理过。
将 promise 标记 _tracked
属性后,我们再来看看 onSettle 方法:
可以看到 onSettle 方法的内容也比较简单,实际上在 defer 传入的每一个 Promise ,在 Promise 状态为 fulfilled 后 defer 都会为该完成态的 Promise 通过属性来追踪该 Promise fulfilled 的值:
-
Promise Resolved 之后,onSettle 方法会为 Promise 标记属性
_data
为 resolved 的值。 -
Promise Rejected 之后,onSettle 方法会为 Promise 标记属性
_error
为 rejected 的值。
整体来说在 loader 中调用的 Defer 方法仅会在服务端调用,它的执行过程用一张图来表示就是:
稍稍总结一下,Remix LoaderFunction 的 defer 方法会在服务端执行时,遍历调用 defer 时传入的对象,为每一项 Promise 标记 _track 特殊属性,以及为 Fulfilled 状态后为该 Promise 标记 _track/_error 属性。
Scripts
我们了解了 Remix 首先会在静态编译时编译出每个页面在服务端需要被调用的 loader 方法,之后也大概清楚了 defer 方法则是遍历传入的 Object,为 Object 中的每一项增加 track 标记。
那么,Remix 是如何将在服务端调用 loader 返回的 defer 传递给客户端呢?
实际上,在 Remix 中有一个所谓的 Scripts 内置组件,Remix 可以轻松的将服务端的 Promise 具有状态化的传递给客户端正是通过 Scripts 来实现的。
简单来说 Remix 通过 Scripts 组件在 HTML 中挂载所有生成的 JavaScript 脚本链接,也就是正是通过 Scripts 组件才可以让页面拥有一系列的:
Scripts 组件除了承载正常生成的 Script 脚本外,其实还通过一些"独特"的方式来承载了在客户端获取服务端生成的 Promise 的能力。
在 Remix 的 Scripts 脚本中,有一段关于 activeDeferreds 的特殊逻辑。简单来表达下这段代码就像是:
tsx
function Scripts(props: ScriptProps) {
let {
manifest,
serverHandoffString,
abortDelay,
serializeError,
isSpaMode,
future,
renderMeta,
} = useRemixContext();
let { static: isStatic, staticContext } = useDataRouterContext();
// ...
// 默认 unstable_singleFetch 为 false
let activeDeferreds = future.unstable_singleFetch
? undefined
: staticContext?.activeDeferreds;
// ...
}
简单来说 <Scripts />
脚本中在服务端渲染时会在用户访问当前 URL 时根据当前访问路径生成对应 HTML 模版的 script 标签内容。
我们提到的 activeDeferreds 实际上是通过 useDataRouterContext
中从 staticContext.activeDeferreds
中获取到的。
那么,deferredData
是什么东西呢?
比如,平常在我们在编写业务代码时,希望通过 loader 配合 defer 将服务端的 Promise 传递给客户端会编写下面这样的代码:
tsx
// 文件中声明 loader 方法,同时在 loader 中调用 defer 处理服务端的 Promise
export const loader: LoaderFunction = async () => {
return defer({
data: Promise.resolve("Hello, World!"),
});
};
export default function Index() {
// 客户端调用 useLoaderData 获取服务端传递的 Promise
const { data } = useLoaderData();
// ...
}
在进行服务端渲染时,此时在 node 中所谓的 activeDeferreds 正是我们通过上边讲过 defer 方法生成的 deferredData:
清楚了 deferredData 是什么之后,它又是怎么来的呢?
首先,Remix 中的 loader 更多是一种编译时的写法(loaderFunction 会在每个文件编译后就确定),也就说当用户访问每个 URL 时需要被触发的 loader 已在编译时确定好了。
上图为编译后的 nodejs 代码,也就是假使用户访问我们的 Index 组件对应的页面。在 Node 端会立即触发 Index 对应的 loader 进行执行,同时会将 loader 执行后的结果通过 context 传递给服务端的模版渲染中。
自然,在服务端执行应用组件的渲染时就可以通过 Context 的方式获取到 staticContext.activeDeferreds
。
需要注意的是,当
<Scripts />
组件在客户端进行重构时,由于客户端并不存在任何 loader 自然也并不会存在 Context 传递,对于staticContext.activeDeferreds
相应也就是 undefined。
deferredData 又有什么作用呢?
在 <Scripts />
的源代码中有这样一段逻辑:
这段逻辑是在服务端生成 HTML 模版中的静态 runtime JS 代码时的逻辑。
也就是说这段逻辑实际是在 HTML Response 中生成类似于这部分内链 JS:
图中更多只是一个内容示意,你只需要清楚 Scripts 标签这里的逻辑就是为了生成静态 runtime 的 script 内容即可。
接下来,我们再来逐步拆分上图中的逻辑。首先,deferredScripts
是一个提前声明好的空数组 []
。
在 initialScripts
中的 useMemo
执行后会调用 Object.entries
遍历 activeDeferreds
也就是当前 URL 对应的 loaderDefer 返回值:
我们上边有过 deferredData 相关的讲解,实际上在 DeferredData 上有一个 pendingKeys 的 get 访问器会返回当前 defer 包裹的 pending 状态的 Promise 个数:
这段代码中对于 activeDeferreds 进行了遍历,挑选出当前匹配路径下的所有 defer 调用中所有状态还是 pending 状态的 Promise 后放进了 <DeferredHydrationScript />
组件中。
之后,在 <Scripts />
组件中返回了填充的 deferredScripts
数组,用于在服务端渲染时生成的 JS 脚本:
此时,客户端接收到 <Scripts />
收到的 DeferredHydrationScript
生成的 <scirpt />
标签后就可以做到将服务端 loader defer 中的 Promise 传递给客户端。
DeferredHydrationScript 又是如何将服务端的 Promise 传递给客户端呢?
实际上 <DeferredHydrationScript />
中使用了一种非常取巧的方式,就像我们上图中的方式。
主要还是通过在服务端追踪服务端 loader 请求 Promise 的状态,然后在 Promise 状态改变后结合 React 中的 Streaming 和 Suspense 将完成态的 Promise 值流转给客户端,从而实现一种双端下的 Promise 传递。
tsx
function DeferredHydrationScript({
dataKey,
deferredData,
routeId,
scriptProps,
serializeData,
serializeError,
}: {
dataKey?: string;
deferredData?: DeferredData;
routeId?: string;
scriptProps?: ScriptProps;
serializeData: (routeId: string, key: string, data: unknown) => string;
serializeError: (routeId: string, key: string, error: unknown) => string;
}) {
return (
<React.Suspense
fallback={
typeof document === "undefined" &&
deferredData &&
dataKey &&
routeId ? null : (
<script
{...scriptProps}
async
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: " " }}
/>
)
}
>
{typeof document === "undefined" && deferredData && dataKey && routeId ? (
<Await
resolve={deferredData.data[dataKey]}
errorElement={
<ErrorDeferredHydrationScript
dataKey={dataKey}
routeId={routeId}
scriptProps={scriptProps}
serializeError={serializeError}
/>
}
children={(data) => {
return (
<script
{...scriptProps}
async
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: serializeData(routeId, dataKey, data),
}}
/>
);
}}
/>
) : (
<script
{...scriptProps}
async
suppressHydrationWarning
dangerouslySetInnerHTML={{ __html: " " }}
/>
)}
</React.Suspense>
);
}
上图中我对于 DeferredHydrationScript
稍微做了部分简化,总之 DeferredHydrationScript 接收的 deferredData props 正是在服务端 loader defer 中的 Promise。
同时当应用在服务器上本渲染(render)时,<DeferredHydrationScript />
会经历以下步骤:
- 使用
React.Suspense
进行外层 Loading 占位返回。 - 同时,在服务器环境下会使用 ReactRouter 中的
<Await />
组件等待deferredData.data[dataKey]
也就是 loader defer 中包裹的 Promise 状态变化。 - 当
deferredData.data[dataKey]
状态为 resolved 后,服务器上的Await
组件会进入到 children 的逻辑。
Await 中的 children 会获得 Reslved Promise 的 data,传递给 children 的函数,然后通过 Streaming 传递返回给浏览器下一段 scirpt
脚本,就是下面:
serializeData
是外部传入 DeferredHydrationScript
的 props:
所谓 serializeDataImp 其实也非常简单,他会将正常返回的数据使用 JSON.stringify
处理后然后生成一段字符串模版的方法调用:
那么,__remixContext
又是什么东西呢?
serializeDataImp 实际上是将 routeId、key 以及返回的 Promise Data 使用 __remixContext.r
拼装调用后返回的字符串。
换句话说,DeferredHydrationScript 最终会在浏览器的 HTML 响应中得到这样的一段 script 脚本:
细心的同学可能会发现在上边我们讲到 activeDeferreds
遍历 deferredData 时,除了为 deferredScripts 中 push 进入 DeferredHydrationScript 外实际对于每一个 Pending 状态的 Promise 还返回了一段 __remixContext.n
的拼装:
那么,__remixContext
究竟是什么东西呢?
实际上 Remix 中能做到客户端可以维持服务端 Promise 的值和状态传递,__remixContext
功不可没。
所谓 __remixContext
是用于延迟 defer Promise 的传递,简单来说 remix 在服务端生成 HTML 模版时 <Script />
标签中会静态注入一系列 __remixContext
的对应方法,比如:
__remixContext.n
通过上边的逻辑梳理,我们了解到 __remixContext.n
方法是会在服务端的 defer 返回一个 Pending 状态的 Promise 后在客户端返回一个静态的内链 script
脚本。
js
__remixContext.n("routes/_index", "data")})
同时,在 <Scirpts />
组件中会注入 __remixContext.n
的静态实现内容:
整理后的 __remixContext.n
:
js
__remixContext.n = function (i, k) {
__remixContext.t = __remixContext.t || {};
__remixContext.t[i] = __remixContext.t[i] || {};
let p = new Promise((r, e) => {
__remixContext.t[i][k] = {
r: (v) => {
r(v);
},
e: (v) => {
e(v);
},
};
});
setTimeout(() => {
if (typeof p._error !== "undefined" || typeof p._data !== "undefined") {
return;
}
__remixContext.t[i][k].e(new Error("Server timeout."));
}, 5000);
return p;
};
实际上,在 Remix 的 <Scripts />
标签中初始化时对于每一个被 loader defer 包裹的 Pending 状态的 Promise, Scripts 组件都会返回一段 script 标签。
当该标签在客户端执行时,会根据传入的 i、k 也就是 routeId 以及当前 routeId 下的 deferKey 构造一个 Promise 放入 __remixContext.t
中。
而,在服务器上当 loader defer 中的 Promise 状态发生变化后,DeferredHydrationScript
组件中的 Await
会执行 children 的逻辑:
serializeData
会调用 __remixContext.r
传入 routeId、defer key 以及 Promise 的值:
关于 __remixContext.r
同样因为也在开始时跟随 __remixContext.n
已经注入到页面全局中,当调用 __remixContext.r
时:
tsx
__remixContext.r = function(i,k,v,e,p,x) {
p = __remixContext.t[i][k];
if (typeof e !== 'undefined') {
x=new Error(e.message);
x.stack=e.stack;
p.e(x);
} else {
p.r(v);
}
}
由于 __remixContext.r
在执行时仅仅是从服务端获取到的 Promise Reolved 之后的值,自然调用 p.r
时传入服务端 Promise Resolved 的值去调用客户端 __remixContext.n
生成的 Promise 的 resolve 函数就会实现一种类似于可以在双端同步 Promise 状态以及值的假象。
所谓,之所以 Remix 可以通过 loader/defer/Await 三个组件实现服务端和客户端的 Promise 序列化传递正是使用了上述一种取巧的步骤。
实际上,更多是通过在客户端构造 Promise 利用 Streaming 和 Suspense 等待服务端 Promise 完成后传递给客户端服务端 Promise 的值从而调用客户端构造的 Promise 的 Resolved 方法实现双端同步的效果。
这篇文章中并没有对于
<Await />
组件的讲解,之前在 如何使用 Router 为你页面带来更快的加载速度#Await 有过<Await />
组件的源码分析,有兴趣的同学可以移步观看。
方案二 - Turbo-Stream 思路
方案一中,我们可以看到实际上 Remix 目前进行 Promise 的双端序列化传递更多还是在借助了一种取巧的类似发布订阅的方式,在服务端 Promise 完成后通知客户端 Resolved 后的 Data。
实际上还是需要对于数据进行序列化传递,但凡涉及序列化就逃不过 JSON.stringify
对于 RexExp、Date 等格式的破坏。
同时,对于不同 Promise 即使 Resolved 后是一模一样的值也同样序列化后进行传输,这无疑也是会造成冗余的网络传输。
接下来,我们来看看方案二中的 Turbo-Stream 方式。
相比方案一中类似取巧的方式,Turbo-Stream 则是实打实的对于 Promise、Regexp、Date 等类型在服务端进行序列化。
Turbo-Stream 中提供了一个 encode
方法,用于在服务端将数据进行编码处理,之后通过 Web Streaming 的方式将 encode
后的 stream 数据在服务端传递给客户端。
客户端在接受这部分 encode 的数据后,可以通过 Turbo-Stream 提供的 decode 方法进行解码,从而可以获取到服务端传递给客户端的数据。
实际上 Turbo-Stream 也是借助了 Web Stream 的能力,在服务端调用 encode 方法将需要传递的数据进行序列化,不过 Turbo-Stream 中的序列化并不是粗暴的调用 JSON.stringify 而是自己编写 decode
的逻辑:
上图为 turbo-stream 中序列化数据 stringify 的部分逻辑。
在服务端通过将需要传递的数据进行 stringify 后,通过 webStreaming 的方式传递给客户端,之后在客户端获取到 streaming 的数据后,调用 decode
方法解构出服务端的数据。
上图为 turbo-stream 中客户端中解构
decode
方法内的部分逻辑。
通过上述两张图我们大概了解到,实际上在 turbo-stream 需要传递的数据大概会经历:
-
- 首先,数据会在服务端调用
encode
方法,encode 方法内部会将数据进行内部 stringify 处理(其实简单点说就是对于不同的数据格式拼装不同的 key)。
- 首先,数据会在服务端调用
-
- 之后,在通过下发 webStreaming 的方式将返回的数据依次 enqueue 到 streaming 中,客户端会不停的接收 webStreaming 返回的内容,从而实现服务端 Promise 状态变化后,streaming 中返回状态变化后的 promise 的 value 通知客户端组件更新(这点和方案一有异曲同工之妙)。
-
- 客户端调用
decode
方法解码服务端返回的值,经过 turbo-stream decode 方法解码后的值则是服务端传递给客户端的值。
- 客户端调用
当然,由于 Turbo-Stream 内部自定义了 stringify 以及 decode 的逻辑。
所以对于 Date、RexExg、Map、Set 等特殊类型,也可以做到服务端和客户端双端安全的传输。
同时,由于在 encode 方法中 turbo-stream 维护了一个 stringified
的数组,对于相同值的传递 stringified
会保证值的唯一从而兼容冗余的网络传输。
简单来说,假如服务端中有两个异步的 Promise 请求,在 1s 和 2s 过后返回的是一个相同值。那么由于 stringified
中已经存储过相同的值了,2s 的 Promise 在服务端完成后并不会给 stringified
中添加相同的值,而是会标记上对应已经存在的 index 下标位置。
通知客户端 Promise 状态发生变化后,同时也会通知客户端 Promise 直接从 index 下标位置获取已经传输过的相同值从而减少网络传输内容。
如果你有兴趣了解 Turbo-Stream 的详细用法的话,我在这里提交了一个基本版的 Demo,有兴趣的同学可以 clone 查看。
其实在上边 Remix 的章节中,有一段这样的代码:
tsx
let activeDeferreds = future.unstable_singleFetch
? undefined
: staticContext?.activeDeferreds;
实际上,在最新的 Remix 中也已经通过 future.unstable_singleFetch
可以支持 Turbo-Stream 方式进行双端数据序列化传输了。
由于 Turbo-Stream 了解的同学并不是特别多,所以这篇文章中就不再对于 Turbo-Stream 的源码实现进行过多的讲解。
有兴趣了解 Turbo-Stream 原理以及 Remix 最新的 unstable_singleFetch
特性的同学可以关注我的后续文章,我会单独有对应的文章来讲解 Turbo-Stream 是如何实现双端数据的传递。
结尾
随着 Streaming 的普及大多数 Web 应用程序对于服务端和客户端的数据传输会越来越频繁和复杂。
希望文章中介绍的两种数据序列化传输原理可以帮助到大家,谢谢。