➜ mkdir module-federation-redux-example

➜ cd module-federation-redux-example

➜ pnpm init

➜ corepack use [email protected]

➜ code .
# module-federation-redux-example/pnpm-workspace.yaml

packages:
  - "apps/*"

2개의 워크스페이스 생성

➜ mkdir apps

➜ cd apps

➜ pnpm create mf-app # (main-app, 3000)

➜ pnpm create mf-app # (remote-app, 3001)

➜ cd ...

➜ pnpm i
{
  "name": "main-app",
  "version": "1.0.0",
  "scripts": {
    "build": "webpack --mode production",
    "build:dev": "webpack --mode development",
    "build:start": "cd dist && PORT=3000 npx serve",
    "start": "webpack serve --open --mode development",
    "start:live": "webpack serve --open --mode development --live-reload --hot"
  },
  "license": "MIT",
  "author": {
    "name": "Jack Herrington",
    "email": "[email protected]"
  },
  "devDependencies": {
    "@babel/core": "^7.15.8",
    "@babel/plugin-transform-runtime": "^7.15.8",
    "@babel/preset-env": "^7.15.8",
    "@babel/preset-react": "^7.14.5",
    "@babel/preset-typescript": "^7.10.4",
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "autoprefixer": "^10.1.0",
    "babel-loader": "^8.2.2",
    "css-loader": "^6.3.0",
    "html-webpack-plugin": "^5.3.2",
    "postcss": "^8.2.1",
    "postcss-loader": "^4.1.0",
    "style-loader": "^3.3.0",
    "typescript": "^4.5.2",
    "webpack": "^5.57.1",
    "webpack-cli": "^4.10.0",
    "webpack-dev-server": "^4.3.1"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}
{
  "name": "remote-app",
  "version": "1.0.0",
  "scripts": {
    "build": "webpack --mode production",
    "build:dev": "webpack --mode development",
    "build:start": "cd dist && PORT=3001 npx serve",
    "start": "webpack serve --open --mode development",
    "start:live": "webpack serve --open --mode development --live-reload --hot"
  },
  "license": "MIT",
  "author": {
    "name": "Jack Herrington",
    "email": "[email protected]"
  },
  "devDependencies": {
    "@babel/core": "^7.15.8",
    "@babel/plugin-transform-runtime": "^7.15.8",
    "@babel/preset-env": "^7.15.8",
    "@babel/preset-react": "^7.14.5",
    "@babel/preset-typescript": "^7.10.4",
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "autoprefixer": "^10.1.0",
    "babel-loader": "^8.2.2",
    "css-loader": "^6.3.0",
    "html-webpack-plugin": "^5.3.2",
    "postcss": "^8.2.1",
    "postcss-loader": "^4.1.0",
    "style-loader": "^3.3.0",
    "typescript": "^4.5.2",
    "webpack": "^5.57.1",
    "webpack-cli": "^4.10.0",
    "webpack-dev-server": "^4.3.1"
  },
  "dependencies": {
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  }
}
➜ pnpm i

➜ pnpm --filter main-app add @reduxjs/toolkit react-redux
➜ pnpm --filter remote-app add @reduxjs/toolkit react-redux

main-app 워크스페이스

// module-federation-redux-example/apps/main-app/src/redux/modules/counter.ts

import { createSlice } from "@reduxjs/toolkit";

const initialState = {
  value: 0,
};

export const { reducer, actions } = createSlice({
  name: "counter",
  initialState,
  reducers: {
    increment: (state) => {
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
  },
});

export const { increment, decrement } = actions;
export default reducer;
// module-federation-redux-example/apps/main-app/src/redux/store.ts

import { configureStore, Reducer, combineReducers } from "@reduxjs/toolkit";
import counter from "./modules/counter";

const createStore = () => {
  const staticReducers = {
    counter,
  };

  const asyncReducers: { [index: string]: Reducer } = {};

  const store = configureStore({
    reducer: {
      ...staticReducers,
    },
  });

  function injectReducer(key: string, asyncReducer: Reducer) {
    asyncReducers[key] = asyncReducer;
    store.replaceReducer(
      combineReducers({
        ...staticReducers,
        ...asyncReducers,
      })
    );
  }

  return {
    store,
    injectReducer,
  };
};

export default createStore;
// module-federation-redux-example/apps/main-app/src/App.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import createStore from "./redux/store";
import { Provider, useDispatch, useSelector } from "react-redux";

import "./index.css";
import { decrement, increment } from "./redux/modules/counter";

const { store, injectReducer } = createStore();

const App = () => {
  const dispatch = useDispatch();
  const counter = useSelector<ReturnType<typeof store.getState>, number>(
    (state) => state.counter.value
  );

  return (
    <div className="container">
      <div>Name: main-app</div>
      <div>Framework: react</div>
      <div>Language: TypeScript</div>
      <div>CSS: Empty CSS</div>
      <div>{counter}</div>
      <div>
        <button onClick={() => dispatch(increment())}>+</button>
        <button onClick={() => dispatch(decrement())}>-</button>
      </div>
    </div>
  );
};

ReactDOM.createRoot(document.getElementById("app")!).render(
  <Provider store={store}>
    <App />
  </Provider>
);
➜ pnpm --filter main-app start:live

➜ pnpm --filter main-app add @babel/runtime

➜ pnpm --filter main-app start:live

remote-app 워크스페이스

// module-federation-redux-example/apps/remote-app/src/redux/modules/name.ts

import { createSlice } from "@reduxjs/toolkit";

export const remoteAppScopeName = "@remote-app/name";

const initialState = {
  value: "remoteApp",
};

export const { reducer, actions } = createSlice({
  name: remoteAppScopeName,
  initialState,
  reducers: {
    setName: (state, action) => {
      state.value = action.payload.name;
    },
  },
});

export const { setName } = actions;
export default reducer;
// module-federation-redux-example/apps/remote-app/src/App.tsx

import React, { useEffect } from "react";
import reducer, { remoteAppScopeName, setName } from "./redux/modules/name";
import { Provider, useSelector, useDispatch, useStore } from "react-redux";

import "./index.css";
import { Reducer, type Store } from "@reduxjs/toolkit";

const App = () => {
  const dispatch = useDispatch();
  const name = useSelector<
    {
      [remoteAppScopeName]?: {
        value: string;
      };
    },
    string | undefined
  >((state) => state?.[remoteAppScopeName]?.value);

  const store = useStore();

  return (
    <div className="container">
      <div>Name: remote-app</div>
      <div>Framework: react</div>
      <div>Language: TypeScript</div>
      <div>CSS: Empty CSS</div>
      <div>
        <button
          onClick={() => {
            dispatch(setName({ name: "리모트앱" }));
          }}
        >
          change
        </button>
      </div>
      {name && <div>{name}</div>}
    </div>
  );
};

const RemoteAppWrapper: React.FC<
  React.PropsWithChildren<{
    store: Store;
    injectReducer: (scope: string, reducer: Reducer) => void;
  }>
> = ({ store, injectReducer }) => {
  useEffect(() => {
    injectReducer(remoteAppScopeName, reducer);
  }, []);

  return (
    <Provider store={store}>
      <App />
    </Provider>
  );
};

export default RemoteAppWrapper;
// module-federation-redux-example/apps/remote-app/src/webpack.config.js

const HtmlWebPackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");

const deps = require("./package.json").dependencies;
module.exports = (_, argv) => ({
  output: {
    publicPath: "<http://localhost:3001/>",
  },

  resolve: {
    extensions: [".tsx", ".ts", ".jsx", ".js", ".json"],
  },

  devServer: {
    port: 3001,
    historyApiFallback: true,
  },

  module: {
    rules: [
      {
        test: /\\.m?js/,
        type: "javascript/auto",
        resolve: {
          fullySpecified: false,
        },
      },
      {
        test: /\\.(css|s[ac]ss)$/i,
        use: ["style-loader", "css-loader", "postcss-loader"],
      },
      {
        test: /\\.(ts|tsx|js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
        },
      },
    ],
  },

  plugins: [
    new ModuleFederationPlugin({
      name: "remote_app",
      filename: "remoteEntry.js",
      remotes: {},
      exposes: {
        "./RemoteApp": "./src/App",
      },
      shared: {
        ...deps,
        react: {
          singleton: true,
          requiredVersion: deps.react,
        },
        "react-dom": {
          singleton: true,
          requiredVersion: deps["react-dom"],
        },
      },
    }),
    new HtmlWebPackPlugin({
      template: "./src/index.html",
    }),
  ],
});

main-app 과 remote-app 통합

import React, { Suspense } from "react";
import ReactDOM from "react-dom/client";
import createStore from "./redux/store";
import { Provider, useDispatch, useSelector } from "react-redux";

import "./index.css";
import { decrement, increment } from "./redux/modules/counter";

const RemoteApp = React.lazy(() => import("remote_app/RemoteApp"));

const { store, injectReducer } = createStore();

const App = () => {
  const dispatch = useDispatch();
  const counter = useSelector<ReturnType<typeof store.getState>, number>(
    (state) => state.counter.value
  );

  return (
    <div className="container">
      <div>Name: main-app</div>
      <div>Framework: react</div>
      <div>Language: TypeScript</div>
      <div>CSS: Empty CSS</div>
      <div>{counter}</div>
      <div>
        <button onClick={() => dispatch(increment())}>+</button>
        <button onClick={() => dispatch(decrement())}>-</button>
      </div>
      <Suspense fallback={<div>Loading...</div>}>
        <RemoteApp store={store} injectReducer={injectReducer} />
      </Suspense>
    </div>
  );
};

ReactDOM.createRoot(document.getElementById("app")!).render(
  <Provider store={store}>
    <App />
  </Provider>
);
{
  "name": "module-federation-router-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "pnpm --parallel start:live",
    "build": "pnpm --parallel build",
    "serve": "pnpm --parallel build:start"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "[email protected]+sha1.77d568bacf41eeefd6695a7087c1282433955b5c"
}
➜ pnpm dev