gnosis23 / hello-world-blog

还是 issues 里面写文章方便
https://bohao.work
0 stars 0 forks source link

remix #97

Open gnosis23 opened 2 years ago

gnosis23 commented 2 years ago

remix中的哲学:

gnosis23 commented 2 years ago

启动流程

remix dev

调用 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:

Server启动

刚才说了 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 中的方法,把上面的代码作为参数,生成一个处理函数

@remix-run/serve

packages/remix-serve/index.ts 中:

@remix-run/express

这个包其实是转化 Request, Response 等,没做什么实际的事,真正的逻辑在 @remix-run/serve-runtime 里面

@remix-run/serve-runtime

packages/remix-server-runtime/server.ts/createRequestHandler 请求会被分为3种类型:data, document, resources,这里先看 document 类型:

gnosis23 commented 2 years ago

路由

Next.jsRemix 都能根据文件结构自动匹配路由的功能,它们是如何工作的。

比如下面的文件目录结构:

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
  },
  // ...
};
gnosis23 commented 2 years ago

loaders

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
  });
}
gnosis23 commented 2 years ago

客户端渲染

服务端渲染返回的是一个静态的页面,要想进行交互先要进行 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

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"
        />
    </>
  )
}
gnosis23 commented 2 years ago

Catch Boundary

在 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}</>;
}