remix-rescript로 rescript 바인딩 알아보기

😎 왜 알아보는가

현재 업무에서 rescript 를 사용하고, 얼마 전에 나온 핫한 remix.run 을 써보고 싶었다.
그래서 두 개 다 사용하는 스택으로 rescript-remix-blog-template 라는 원대한 꿈을 가진 저장소를 만들었다.
이미 바인딩이 되어 있는 rescript-remix를 발견하고, 기뻐하기도 잠시 LoaderFunction 라는 함수가 필요했는데 바인딩이 되어있지 않았다.
바인딩이 된 상태로 올라와 있는 rescript-remix PR#21 을 보고, 코멘트를 남겼으나 메인테이너가 n개월 째 저장소에 찾아오지 않고 있다.

그래서 그럼 내가 해서 써야지 뭐 라는 생각을 했으나 바인딩이라는 용어 자체도 처음 들어봤기에 rescript-remix 코드를 읽어보면서 알아보고자 한다.

주의! 바인딩에 완전 처음인 사람이 무식하게 읽어보며 적는 글로 이미 잘 알고 계신 분이 보면 답답함을 느낄 수 있습니다.

🤩 이제 알아보자

일단 자바스크립트 함수에 바인딩하기를 읽고, 기초 지식을 익혔다.

Remix.res 에 바인딩 코드가 있었고, 무엇을 한 것인지 알아봤다.
remix 저장소에 가서 검색을 해보니 remix-react/magicExports/remix.ts#L21 에서 export되고 있는 모듈을 바인딩하고 있었다.

🔸 RemixBrowser

서버에서 받은 HTML을 hydrate를 하며, remix app 진입점이다.

  • @react.component 가 뭘까?
module RemixBrowser = {
  @module("remix") @react.component
  external make: unit => React.element = "RemixBrowser"
}
  • RemixBrowser 의 타입이 ReactElement 이므로 React.element로 바인딩
  • make: 는 @react.component 데코레이터 사용을 위함
  • unit => 는 props가 없기 때문
export function RemixBrowser(_props: RemixBrowserProps): ReactElement {

🔸 RemixServer

HTML을 생성하며, 서버에서 구동되는 remix app의 진입점

type entryContext
 
module RemixServer = {
  @module("remix") @react.component
  external make: (~context: entryContext, ~url: string) => React.element = "RemixServer"
}
  • entryContext 가 뭘까?
    • EntryContext
    • type entryContext 적어준 것만으로 어떻게 import 된 걸까...?
  • props의 url의 타입이 string | URL 이지만 string 으로만 받도록 되어있음
export interface RemixServerProps {
  context: EntryContext;
  url: string | URL;
}
 
export function RemixServer({ context, url }: RemixServerProps): ReactElement {

🔸 Outlet

중첩 라우팅에 사용 outlet

module Outlet = {
  @module("remix") @react.component
  external make: (~context: 'a=?) => React.element = "Outlet"
}
  • react-router에서 export됨
  • unknown'a=? 으로 사용
export interface OutletProps {
  context?: unknown;
}
 
export function Outlet(props: OutletProps): React.ReactElement | null {
  return useOutlet(props.context);
}

🔸 LiveReload

dev 모드일 때 코드 변경 시 자동 리로드

module LiveReload = {
  @module("remix") @react.component
  external make: (~port: int=?) => React.element = "LiveReload"
}
  • LiveReloadnull | React.element 일 거라고 예상했는데
  • React.element 인 이유가 뭘까?
export const LiveReload =
  process.env.NODE_ENV !== "development"
    ? () => null
    : function LiveReload({
        port = Number(process.env.REMIX_DEV_SERVER_WS_PORT || 8002),
      }: {port?: number;})

🔸 Link

앵커 tag 렌더

module Link = {
  @module("remix") @react.component
  external make: (
    ~prefetch: [#intent | #render | #none]=?,
    ~to: string,
    ~reloadDocument: bool=?,
    ~replace: bool=?,
    ~state: 'a=?,
    ~children: React.element,
  ) => React.element = "Link"
}
  • "intent" | "render" | "none"Polymorphic Variant로 표현
  • forwardRef이므로 children props 필요
type PrefetchBehavior = "intent" | "render" | "none";
 
export interface RemixLinkProps extends LinkProps {
  prefetch?: PrefetchBehavior;
}
 
let Link = React.forwardRef<HTMLAnchorElement, RemixLinkProps>(
  • Link가 반환하는 컴포넌트는 react-router-dom의 Link
  • any'a=? 로 표현
export interface LinkProps
  extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, "href"> {
  reloadDocument?: boolean;
  replace?: boolean;
  state?: any;
  to: To;
}
 
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(

🔸 Form

제출 후 모든 페이지의 loader가 리로드되며 데이터가 최신화, form values 자동 직렬화

module Form = {
  @module("remix") @react.component
  external make: (
    ~method: [#get | #post | #put | #patch | #delete]=?,
    ~action: string=?,
    ~encType: [#"application/x-www-form-urlencoded" | #"multipart/form-data"]=?,
    ~reloadDocument: bool=?,
    ~replace: bool=?,
    ~onSubmit: @uncurry ReactEvent.Form.t => unit=?,
  ) => React.element = "Form"
}
  • FormMethod, FormEncType는 Polymorphic Variant로 표현
  • FormEventHandler 는 왜 @uncurry 했을까?
    • 예상: 안정성
export type FormMethod = "get" | "post" | "put" | "patch" | "delete";
export interface FormProps extends FormHTMLAttributes<HTMLFormElement> {
  method?: FormMethod;
  action?: string;
  encType?: FormEncType;
  reloadDocument?: boolean;
  replace?: boolean;
  onSubmit?: React.FormEventHandler<HTMLFormElement>;
}
 
let Form = React.forwardRef<HTMLFormElement, FormProps>((props, ref) => {

🔸 Cookie

module Cookie = {
  type t
 
  @get external name: t => string = "name"
  @get external isSigned: t => bool = "isSigned"
  ...생략
}
 
module CreateCookieOptions = {
  type t
 
  @obj
  external make: (
    ~decode: string => string=?,
    ~encode: string => string=?,
    ...생략
  ) => t = ""
}
 
@module("remix") external createCookie: string => Cookie.t = "createCookie"
@module("remix")
external createCookieWithOptions: (string, CreateCookieOptions.t) => Cookie.t = "createCookie"
  • 같은 모듈을 바인딩할 때 props에 따라 다르게 할 수 있다.
export interface Cookie {
  readonly name: string;
  readonly isSigned: boolean;
  readonly expires?: Date;
  parse(
    cookieHeader: string | null,
    options?: CookieParseOptions
  ): Promise<any>;
  serialize(value: any, options?: CookieSerializeOptions): Promise<string>;
}
 
export type CreateCookieFunction = (
  name: string,
  cookieOptions?: CookieOptions
) => Cookie;
 
export const createCookieFactory =
  ({
    sign,
    unsign,
  }: {
    sign: SignFunction;
    unsign: UnsignFunction;
  }): CreateCookieFunction =>

😰 여전히 어렵다

ts라이브러리를 바인딩해둔 코드를 보고 읽어봤지만 여전히 잘 모르는 상태이다. ㅠ_ㅠ

rescript-remix/RouteConventions.res 에 remix의 파일 기반 라우팅을 변환하는 코드가 있다.
읽어봤지만 지금은 잘 모르겠다. 언젠가 이해할 수 있으면 좋겠다. 😂

언젠가 바인딩을 내 손으로 할 수 있는 날이 오겠지...