Open gnosis23 opened 2 years ago
调用 remix-dev
中的 cli.ts,走 dev 命令;
// packages/remix-dev/cli.ts
case "dev":
if (!process.env.NODE_ENV) process.env.NODE_ENV = "development";
commands.dev(cli.input[1], process.env.NODE_ENV).catch(handleError);
break;
调用 commands.ts
中的 dev:
@remix-run/serve
包,可以将 entry.Server
中的 handler 转为 express 中的方法readConfig
将读取配置:如打包路径、路由信息/build/index.js
刚才说了 express 的入口是 /build/index.js
,那么里面是什么呢?
在脚手架生成的 entry.Server.tsx
中,入口是这样的
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const markup = renderToString(
<RemixServer context={remixContext} url={request.url} />
);
responseHeaders.set("Content-Type", "text/html");
return new Response("<!DOCTYPE html>" + markup, {
status: responseStatusCode,
headers: responseHeaders
});
}
可以看到这只是个请求处理函数,没有其他逻辑。事实上 remix 会在打包的时候,附加上一些逻辑,代码在 packages/remix-dev/compiler.ts:getServerEntryPointModule
中
return `
import * as entryServer from ${JSON.stringify(
path.resolve(config.appDirectory, config.entryServerFile)
)};
${Object.keys(config.routes)
.map((key, index) => {
let route = config.routes[key];
return `import * as route${index} from ${JSON.stringify(
path.resolve(config.appDirectory, route.file)
)};`;
})
.join("\n")}
export { default as assets } from "./assets.json";
export const entry = { module: entryServer };
export const routes = {
${Object.keys(config.routes)
.map((key, index) => {
let route = config.routes[key];
return `${JSON.stringify(key)}: {
id: ${JSON.stringify(route.id)},
parentId: ${JSON.stringify(route.parentId)},
path: ${JSON.stringify(route.path)},
index: ${JSON.stringify(route.index)},
caseSensitive: ${JSON.stringify(route.caseSensitive)},
module: route${index}
}`;
})
.join(",\n ")}
};`;
最后导出的build/index.js
里是这么个结构
interface Manifest {
entry: { module: { default: reqHandler } }, // 处理逻辑
assets: [], // 资源信息
routes: [] // 路由信息
}
express 会调用 @remix-run/serve
中的方法,把上面的代码作为参数,生成一个处理函数
在 packages/remix-serve/index.ts
中:
public/build
下的内容作为静态资源提供@remix-run/express
来处理其他逻辑这个包其实是转化 Request, Response 等,没做什么实际的事,真正的逻辑在 @remix-run/serve-runtime
里面
packages/remix-server-runtime/server.ts/createRequestHandler
请求会被分为3种类型:data
, document
, resources
,这里先看 document 类型:
loader
、action
等内容进行服务端计算像 Next.js
或 Remix
都能根据文件结构自动匹配路由的功能,它们是如何工作的。
比如下面的文件目录结构:
routes
├── demos
│ ├── about
│ │ ├── index.tsx
│ │ └── whoa.tsx
│ ├── about.tsx
│ ├── actions.tsx
│ ├── correct.tsx
│ ├── params
│ │ ├── $id.tsx
│ │ └── index.tsx
│ └── params.tsx
└── index.tsx
Remix 会转换成如下的数据结构
config.routes = {
"root": {
id: "root",
parentId: void 0,
path: "",
index: void 0,
caseSensitive: void 0,
file: "root.tsx"
},
"routes/demos/actions": {
id: "routes/demos/actions",
parentId: "root",
path: "demos/actions",
index: void 0,
caseSensitive: void 0,
file: "routes/demos/actions.tsx"
},
"routes/demos/correct": {
id: "routes/demos/correct",
parentId: "root",
path: "demos/correct",
index: void 0,
caseSensitive: void 0,
file: "routes/demos/correct"
},
// ...
}
然后生成如下入口
import * as entryServer from 'app/entry.Server';
import * as routes0 from 'app/';
import * as routes1 from 'app/routes/demos/actions';
import * as routes2 from 'app/routes/index';
export { default as assets } from './assets.json';
export const entry = { module: entryServer };
export const routes = {
"root": {
id: "root",
parentId: void 0,
path: "",
index: void 0,
caseSensitive: void 0,
module: routes0
},
"routes/demos/actions": {
id: "routes/demos/actions",
parentId: "root",
path: "demos/actions",
index: void 0,
caseSensitive: void 0,
module: routes1
},
// ...
};
Remix的一大特色就是可以在组件里直接写服务端的代码,比如:
export async function loader({ request }) {
// 仅在服务端执行
return getProjects();
}
export default function Projects() {
const projects = useLoaderData();
// ...blabla
}
我们来看看如何是如何实现的。
上文已经讲到服务端会把 entry.Server.tsx
转化一下,最终会调用 packages/remix-server-runtime/server.ts/createRequestHandler
方法
export function createRequestHandler(
build: ServerBuild,
platform: ServerPlatform,
mode?: string
): RequestHandler {
// ...
}
build就是入口里的结构
export interface ServerBuild {
entry: {
module: ServerEntryModule;
};
routes: ServerRouteManifest;
assets: AssetsManifest;
}
代码里首先会对 routes 进行一些转化,然后调用 react-router-dom
的 matchRoutes 方法,获得匹配的路由结构
let matches = matchServerRoutes(routes, url.pathname);
// routeMatching.ts
export function matchServerRoutes(
routes: ServerRoute[],
pathname: string
): RouteMatch<ServerRoute>[] | null {
let matches = matchRoutes(routes as unknown as RouteObject[], pathname);
if (!matches) return null;
return matches.map(match => ({
params: match.params,
pathname: match.pathname,
route: match.route as unknown as ServerRoute
}));
}
这样就能得到对应路由,然后可以获得所有 loader ;然后用 Promise.allSettled 调用所有的 loader 方法,注意下传入的参数。
// remix-server-runtime/server.ts
// renderDocumentRequest()
let routeLoaderResults = await Promise.allSettled(
matchesToLoad.map(match =>
match.route.module.loader
? callRouteLoader({
loadContext,
match,
request
})
: Promise.resolve(undefined)
)
);
如果能处理成功,就把数据存入一个 routeData 里面
routeData[match.route.id] = await extractData(response);
let serverHandoff = {
actionData,
appState: appState,
matches: entryMatches,
routeData
};
let entryContext: EntryContext = {
...serverHandoff,
manifest: build.assets,
routeModules,
serverHandoffString: createServerHandoffString(serverHandoff)
};
然后就回到了入口处 entry.Server.tsx
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const markup = renderToString(
<RemixServer context={remixContext} url={request.url} />
);
responseHeaders.set("Content-Type", "text/html");
return new Response("<!DOCTYPE html>" + markup, {
status: responseStatusCode,
headers: responseHeaders
});
}
服务端渲染返回的是一个静态的页面,要想进行交互先要进行 hydrate(兑水)过程。
客户的入口是 entry.client.tsx
:
import { hydrate } from "react-dom";
import { RemixBrowser } from "remix";
hydrate(<RemixBrowser />, document);
RemixBrowser 主要调用了 RemixEntry 类
export function RemixBrowser(_props: RemixBrowserProps): ReactElement {
let history = xxx;
React.useLayoutEffect(() => history.listen(dispatch), [history]);
let entryContext = window.__remixContext;
entryContext.manifest = window.__remixManifest;
entryContext.routeModules = window.__remixRouteModules;
// blabla
return (
<RemixEntry
context={entryContext}
action={state.action}
location={state.location}
navigator={history}
/>
);
}
这里 RemixBrowser 里面读取了三个全局变量,让我们看看它们是从哪里来的
window.__remixContext
window.__remixContext
来自 Script 组件,它会把 RemixEntryContext
中的 serverHandoffString
渲染出来
export function Scripts(props: ScriptProps) {
let {
serverHandoffString
} = useRemixEntryContext();
let initialScripts = React.useMemo(() => {
let contextScript = serverHandoffString
? `window.__remixContext = ${serverHandoffString};`
: "";
}, []);
return (
<>
<script
{...props}
suppressHydrationWarning
dangerouslySetInnerHTML={createHtml(contextScript)}
/>
</>
)
}
serverHandoffString
是服务端渲染的时候注入的,代码在 remix-server-runtime/server.ts:renderDocumentRequest
中。
里面存储了路由信息、还有loader请求结果等等
async function renderDocumentRequest({}: {
let serverHandoff = {
actionData,
appState: appState,
matches: entryMatches,
routeData
};
let entryContext: EntryContext = {
...serverHandoff,
manifest: build.assets,
routeModules,
serverHandoffString: createServerHandoffString(serverHandoff)
};
}
window.__remixManifest
__remixManifest 来自 esbuild 构建时获取的资源信息,代码在 remix-dev/compiler.ts:generateManifests
中
async function generateManifests(
config: RemixConfig,
metafile: esbuild.Metafile
): Promise<string[]> {
let assetsManifest = await createAssetsManifest(config, metafile);
let filename = `manifest-${assetsManifest.version.toUpperCase()}.js`;
assetsManifest.url = config.publicPath + filename;
return Promise.all([
writeFileSafe(
path.join(config.assetsBuildDirectory, filename),
`window.__remixManifest=${JSON.stringify(assetsManifest)};`
),
writeFileSafe(
path.join(config.serverBuildDirectory, "assets.json"),
JSON.stringify(assetsManifest, null, 2)
)
]);
}
window.__remixRouteModules 也是来自 Script 组件,这个变量通过 import * as route1 from 'xx'
的形式来取到路由组件,然后获得它们的 loader、action 等等方法
export function Scripts(props: ScriptProps) {
let initialScripts = React.useMemo(() => {
let routeModulesScript = `${matches
.map(
(match, index) =>
`import * as route${index} from ${JSON.stringify(
manifest.routes[match.route.id].module
)};`
)
.join("\n")}
window.__remixRouteModules = {${matches
.map((match, index) => `${JSON.stringify(match.route.id)}:route${index}`)
.join(",")}};`;
}, []);
return (
<>
<script
{...props}
dangerouslySetInnerHTML={createHtml(routeModulesScript)}
type="module"
/>
</>
)
}
在 Remix 的路由组件中声明了 CatchBoundary 就能自动捕获 loader 或者 action 里面的异常,比如
import { useCatch } from "remix";
export function CatchBoundary() {
const caught = useCatch();
return (
<div>
<h1>Caught</h1>
<p>Status: {caught.status}</p>
<pre>
<code>{JSON.stringify(caught.data, null, 2)}</code>
</pre>
</div>
);
}
来看看它是如何做到的。客户端渲染里讲到了 RemixBrowser 会调用 RemixEntry,在 RemixEntry 里会对路由做一些初始化
export function RemixEntry({}) {
let clientRoutes = React.useMemo(
() => createClientRoutes(manifest.routes, routeModules, AWSRoute),
[manifest, routeModules]
);
let [transitionManager] = React.useState(() => {
return createTransitionManager({
routes: clientRoutes,
actionData: documentActionData,
loaderData: documentLoaderData,
location: historyLocation,
catch: entryComponentDidCatchEmulator.catch,
catchBoundaryId: entryComponentDidCatchEmulator.catchBoundaryRouteId,
onRedirect: _navigator.replace,
onChange: state => {
setClientState({
catch: state.catch,
error: state.error,
catchBoundaryRouteId: state.catchBoundaryId,
loaderBoundaryRouteId: state.errorBoundaryId,
renderBoundaryRouteId: null,
trackBoundaries: false,
trackCatchBoundaries: false
});
}
});
});
}
createClientRoutes 会间接调用 createClientRoute 方法,该方法会对组件的 loader 和 action 做一些改写
function createClientRoute(
entryRoute: EntryRoute,
routeModulesCache: RouteModules,
Component: RemixRouteComponentType
): ClientRoute {
return {
caseSensitive: !!entryRoute.caseSensitive,
element: <Component id={entryRoute.id} />,
id: entryRoute.id,
path: entryRoute.path,
index: entryRoute.index,
module: entryRoute.module,
loader: createLoader(entryRoute, routeModulesCache),
action: createAction(entryRoute),
shouldReload: createShouldReload(entryRoute, routeModulesCache),
ErrorBoundary: entryRoute.hasErrorBoundary,
CatchBoundary: entryRoute.hasCatchBoundary,
hasLoader: entryRoute.hasLoader
};
}
function createLoader(route: EntryRoute, routeModules: RouteModules) {
let loader: ClientRoute["loader"] = async ({ url, signal, submission }) => {
if (route.hasLoader) {
// 假如服务端 loader 方法报错了,会返还有 X-REMIX-CATCH=yes 的头
let [result] = await Promise.all([
fetchData(url, route.id, signal, submission),
loadRouteModuleWithBlockingLinks(route, routeModules)
]);
if (result instanceof Error) throw result;
let redirect = await checkRedirect(result);
if (redirect) return redirect;
// 这里会检查 X-REMIX-CATCH 头部
if (isCatchResponse(result)) {
throw new CatchValue(
result.status,
result.statusText,
await extractData(result.clone())
);
}
return extractData(result);
} else {
await loadRouteModuleWithBlockingLinks(route, routeModules);
}
};
return loader;
}
当路由变化时,会触发 transitionManager 的 send 方法,它会去调用 loader ,一旦看到返回 CatchValue ,那么就会把这个错误存储到 context 上面去。
最后渲染的时候,也就是 RemixRoute
里,如果你声明了 CatchBoundary 函数,那么它就会把路由对象组件用 CatchBoundary 包裹起来
// 这里的 appState 就是 transitionManager.send 里面更新的 context
// If we tried to render and failed, and this route threw the error, find it
// and pass it to the ErrorBoundary to emulate `componentDidCatch`
let maybeServerCaught =
appState.catch && appState.catchBoundaryRouteId === id
? appState.catch
: undefined;
element = (
<RemixCatchBoundary
location={location}
component={CatchBoundary}
catch={maybeServerCaught}
>
{element}
</RemixCatchBoundary>
);
他的内容很简单,当 catch 值存在的时候,就用组件提供的 CatchBoundary
渲染组件,并把错误值传给另一个 RemixCatchContext
,这样 CatchBoundary
就能用 useCatch
读取到值了
export function RemixCatchBoundary({
catch: catchVal,
component: Component,
children
}: AWSCatchBoundaryProps) {
if (catchVal) {
return (
<RemixCatchContext.Provider value={catchVal}>
<Component />
</RemixCatchContext.Provider>
);
}
return <>{children}</>;
}
remix中的哲学: