마이크로 프론트엔드 - React Context 공유하기

 

모노레포에 패키지를 만들 것이기 때문에 pnpm-workspace.yaml에 packages를 포함할 수 있도록 다음과 같이 수정한다.

packages:
  - "apps/*"
  - "packages/*"

 

그리고 packages 디렉토리를 만들고, 다음 명령어를 통해 packages에 react 프로젝트를 추가한다.

pnpm create vite shared-library --template react-swc-ts

 

그 다음 shared-library에 NameContext.tsx 파일을 만들어 Context API를 활용한 컴포넌트를 만들고 index.ts에서 외부에 노출할 수 있도록 한 번 더 export 해준다.

// NameContext.tsx
import { createContext, PropsWithChildren } from "react";

const NameContext = createContext<string | null>(null);

type NameProviderProps = PropsWithChildren<{
  name: string;
}>;
const NameProvider = ({ name, children }: NameProviderProps) => {
  return <NameContext.Provider value={name}>{children}</NameContext.Provider>;
};

export { NameContext, NameProvider };

// index.ts
export { NameContext, NameProvider } from "./NameContext";

 

vite로 라이브러리를 빌드할 때, 별도의 타입도 export 해야 하므로 별도의 vite plugin을 설치해주어야 한다.

pnpm --filter shared-library add vite-plugin-dts -D

 

이후 vite 설정을 다음과 같이 변경해주도록 하자.

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";
import dts from "vite-plugin-dts";

// https://vite.dev/config/
export default defineConfig({
  plugins: [
    react(),
    dts({
      insertTypesEntry: true,
      rollupTypes: true,
      tsconfigPath: "./tsconfig.app.json",
    }),
  ],
  build: {
    outDir: "./dist",
    lib: {
      entry: "./src/index.ts",
      name: "shared-library",
      fileName: "index",
    },
    // 패키지를 사용하는 쪽에서 필요로하는 라이브러리 설정
    rollupOptions: {
      external: ["react", "react-dom"],
      output: {
        globals: {
          react: "React",
          "react-dom": "ReactDOM",
        },
      },
    },
  },
});

// 참고: tsconfig.app.json
{
  "compilerOptions": {
    "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedSideEffectImports": true
  },
  "include": ["src"]
}
  • lib: 라이브러리리 모드로 빌드를 한다 (entry를 기준으로 하나의 번들 파일 제작)
  • rollupOptions.external: react, react-dom은 번들 안에 포함하지 않고 외부에서 가져오게 설정 (externalization)
  • output.globals: UMD 빌드 시, 전역변수 이름 지정

 

그리고 rollupOptions에 사용하는 쪽에서의 라이브러리 설정 옵션을 추가해주었으므로, package.json의 dependencies를 peerDependencies로 변경해주도록 하자. 그리고 패키지 개발을 위해 기존의 dependencies에 위치해있던 react, react-dom을 devDependencies로 옮겨준다. 

추가로 exports, types, main, module 옵션도 각각 추가해준다.

{
  "name": "shared-library",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "require": "./dist/index.umd.cjs",
      "import": "./dist/index.js"
    }
  },
  "types": "./dist/index.d.ts",
  "main": "./dist/index.umd.cjs",
  "module": "./dist/index.js",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "peerDependencies": {
	"react": "^18.2.0",
    "react-dom": "^18.2.0",
  },
  "devDependencies": {
    "@eslint/js": "^9.22.0",
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "@vitejs/plugin-react-swc": "^3.8.0",
    "eslint": "^9.22.0",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.19",
    "globals": "^16.0.0",
    "typescript": "~5.7.2",
    "typescript-eslint": "^8.26.1",
    "vite": "^6.3.1",
    "vite-plugin-dts": "^4.5.3",
	"react": "^18.2.0",
    "react-dom": "^18.2.0",
  }
}
  • peerDependencies: 라이브러리를 쓰는 쪽 프로젝트가 직접 의존성을 설치해야한다는 것을 의미한다.
  • exports: 패키지를 import 라이브러리했을 때 각각 상황에 맞는 파일을 제공한다. (환경별 파일 대응 가능)
    • require: CommonJS 방식으로 가져올 때 사용할 파일
    • import: ESM 방식으로 가져올 때 사용할 파일
    • types: 타입스크립트 타입 정보를 가져올 때 사용할 파일(d.ts)
  • types: 타입스크립트가 패키지를 Import할 때 기본으로 볼 .d.ts 파일 경로. 타입스크립트 컴파일러가 이를 읽어서 자동완성, 타입 검사를 해준다
  • main: CJS 환경에서 require 할 때, 기본으로 쓸 파일
    • 옛날 Node.js나 CJS 기반 빌드 툴을 쓰는 경우를 위해 필요 (Webpack 4, Jest, Mocha 등 CJS 환경)
  • module: ESM에서 import 할 때 기본으로 쓸 파일
    • ESM 환경에서 tree-shaking, 최적화된 번들링을 할 수 있다. (Rollup, vite, webpack5, 현대 브라우저)

이제 shared-library를 빌드를 해보도록 하자

pnpm --filter shared-library build

 

빌드 결과물로 index.js, index.umd.cjs, index.d.ts가 나온 것을 확인할 수 있다.

 

이제 component-app과 main-app의 dependencies에 shared-library를 각각 추가해주도록 하자.

"shared-library": "workspace:*"

 

또한, 시맨틱 버저닝이 아닌 "workspace: *"로 버전이 들어가게 되므로 rspack 설정 중 shared 옵션을 다음과 같이 변경하여 싱글톤으로 관리될 수 있도록 한다.

"shared-library": {
  singleton: true,
  requiredVersion: require("../../packages/shared-library/package.json").version
},

 

각 앱에서 Context 공유해서 사용하기

main-app의 App.tsx를 다음과 같이 변경해보도록 하자.

import React from "react";
import ReactDOM from "react-dom/client";
import Button from "component_app/Button";
import { join, map } from "lodash";
import { NameProvider } from "shared-library";

import "./index.css";

const App = () => (
  <NameProvider name="hello">
    <div className="mt-10 text-3xl mx-auto max-w-6xl">
      <div>Name: main-app</div>
      <div>Framework: react-18</div>
      <div>{join(map(["1", "2"]), "-")}</div>
      <Button
        onClick={() => {
          alert("Clicked");
        }}
      >
        Primary
      </Button>
      <Button variant="warning">Warning</Button>
    </div>
  </NameProvider>
);

const root = ReactDOM.createRoot(document.getElementById("app") as HTMLElement);
root.render(<App />);

 

이번엔 component-app에서 컨텍스트를 사용해보도록 하자.

import { ButtonHTMLAttributes, PropsWithChildren, useContext } from "react";
import { join, map } from "lodash";
import { NameContext } from "shared-library";

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: "warning" | "primary";
}

export default function Button({
  variant = "primary",
  children,
  onClick,
}: PropsWithChildren<ButtonProps>) {
  const name = useContext(NameContext);

  const buttonType = variant === "warning" ? "warning" : "primary";

  return (
    <button style={styleMapping[buttonType]} onClick={onClick}>
      {children} {join(map(["1", "2"]), "-")} {name}
    </button>
  );
}

const styleMapping = {
  primary: {
    marginLeft: "10px",
    color: "#fff",
    backgroundColor: "#409eff",
    borderColor: "#409eff",
    padding: "12px 20px",
    fontSize: "14px",
    borderRadius: "4px",
    outline: "none",
    border: "1px solid #dcdfe6",
    cursor: "pointer",
  },
  warning: {
    marginLeft: "10px",
    color: "#fff",
    backgroundColor: "#e6a23c",
    borderColor: "#e6a23c",
    padding: "12px 20px",
    fontSize: "14px",
    borderRadius: "4px",
    outline: "none",
    border: "1px solid #dcdfe6",
    cursor: "pointer",
  },
};

 

그렇게 되면 하나의 shared-library를 받게 되고, 두 개의 마이크로앱이 Context를 공유할 수 있게 된다.