Web组件化 - 微前端实践

第一篇介绍了如何将React组件转换为Web Component
第二篇介绍了子应用(Web Component)中的路由可以正常作用与Shell App
第三篇介绍了Sub App与Shell App通过属性或自定义事件交互

前文已经介绍过如何将React组件编译为Web组件(Web Components),并在html中引用。本文在此基础上实现在Shell App中动态加载Sub App,从而实现微前端。 当然您可能会问为什么不用现有框架,如SingleSPA、Qiankun?
原因有二
1)Web Component是一种新的思路,原理上也可以实现微前端,不是非要使用第三方框架。
2)Qiankun等框架虽然好,但并不了解其实现细节,如果要修改会有麻烦。

TL, DR
以下为实现过程,首先建立两个Sub App,使用express将编译后的bundle文件暴露出来。主要代码如下(两个sub app大致相同):

app.tsx

import React from 'react';
import ReactDOM from 'react-dom';
import {
  BrowserRouter as Router,
  Route,
  Switch,
  Link
  } from "react-router-dom";

function Home() {
    return (
      <div>
        App Home
      </div>
    );
  }

function About() {
  return (
    <div>
      About Page
    </div>
  );
}

function Account() {
  const myRef = React.useRef(null);

  React.useEffect(()=> {
    myRef.current.addEventListener('show', e =>
      console.log('from account')
    );
  }, []);

    return (
      <div ref={myRef}>
        My Account
      </div>
    );
  }

type AppProp = {
  name: string;
}

function App(prop: AppProp) {
    return (
      <Router>
        <div>
          <ul>
            <li>
              <Link to="/">Home</Link>
            </li>
            <li>
              <Link to="/about">About</Link>
            </li>
            <li>
              <Link to="/account">Account - {prop.name}</Link>
            </li>
          </ul>

          <hr />
          <Switch>
            <Route exact path="/">
              <Home />
            </Route>
            <Route path="/about">
              <About />
            </Route>
            <Route path="/account">
              <Account />
            </Route>
          </Switch>
        </div>
      </Router>
      
    )
}

class HelloElement extends HTMLElement {
    connectedCallback() {
      const myName = this.getAttribute('my-name');
      
      ReactDOM.render(
        <div>
            <App name={myName}></App>        
        </div>,
        this
      );
    }
  }
  
  const tagName = "hello-component";
  
  if (!window.customElements.get(tagName)) {
    window.customElements.define(tagName, HelloElement);
  }

server.js

const path = require('path');
const express = require('express')
const app = express()
const port = 5001
const fileRoot = path.resolve(__dirname, '../public');

app.use(express.static(path.join(__dirname, '../public')))

app.get('/', (req, res) => {
    res.sendFile('index.html', { root: fileRoot });
})

app.get('/bundle', (req, res) => {
    res.sendFile('bundle.js', { root: fileRoot });
})

app.listen(port, () => {
  console.log(`Example app listening at http://localhost:${port}`)
})

.babelrc

{
    "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"],
    "plugins": ["@babel/plugin-proposal-class-properties"]
}

package.json

{
  "name": "web-component",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "webpack --mode=development",
    "build": "webpack --mode=production"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@babel/core": "^7.15.8",
    "@babel/preset-env": "^7.15.8",
    "@babel/preset-react": "^7.14.5",
    "@babel/preset-typescript": "^7.15.0",
    "@types/react": "^17.0.31",
    "@types/react-dom": "^17.0.10",
    "babel-loader": "^8.2.3",
    "babel-plugin-transform-class-properties": "^6.24.1",
    "react-router": "^5.2.1",
    "webpack": "^5.59.1",
    "webpack-cli": "^4.9.1"
  },
  "dependencies": {
    "express": "^4.17.1",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-router-dom": "^5.3.0",
    "typescript": "^4.4.4"
  }
}

webpack.config.js

const path = require('path');
module.exports = {
  entry: './src/app.tsx',
  module: {
    rules: [
      {
        test: /\.(ts|js)x?$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
          options: {
            presets: [
              "@babel/preset-env",
              "@babel/preset-react",
              "@babel/preset-typescript",
            ],
          },
        }
      }
    ],
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  output: {
    path: path.join(__dirname, 'public'),
    filename: 'bundle.js'
  }
};

两个Sub-app代码差不多,区别只是运行端口不同。 最后运行npm run build 和 node ./src/server.js
此时,我们访问:http://localhost:5001/bundle 就可以看到打包结果。

接下来是Shell App,为了简单起见,我们就用npx create-react-app shell来建立项目。
这里,我们建立一个custom hook来动态加载js

useScript

import { useEffect, useState } from "react";
export function useScript(src) {
  // Keep track of script status ("idle", "loading", "ready", "error")
  const [status, setStatus] = useState(src ? "loading" : "idle");
  useEffect(
    () => {
      // Allow falsy src value if waiting on other data needed for
      // constructing the script URL passed to this hook.
      if (!src) {
        setStatus("idle");
        return;
      }
      // Fetch existing script element by src
      // It may have been added by another intance of this hook
      let script = document.querySelector(`script[src="${src}"]`);
      if (!script) {
        // Create script
        script = document.createElement("script");
        script.src = src;
        script.async = true;
        script.setAttribute("data-status", "loading");
        // Add script to document body
        document.body.appendChild(script);
        // Store status in attribute on script
        // This can be read by other instances of this hook
        const setAttributeFromEvent = (event) => {
          script.setAttribute(
            "data-status",
            event.type === "load" ? "ready" : "error"
          );
        };
        script.addEventListener("load", setAttributeFromEvent);
        script.addEventListener("error", setAttributeFromEvent);
      } else {
        // Grab existing script status from attribute and set to state.
        setStatus(script.getAttribute("data-status"));
      }
      // Script event handler to update status in state
      // Note: Even if the script already exists we still need to add
      // event handlers to update the state for *this* hook instance.
      const setStateFromEvent = (event) => {
        setStatus(event.type === "load" ? "ready" : "error");
      };
      // Add event listeners
      script.addEventListener("load", setStateFromEvent);
      script.addEventListener("error", setStateFromEvent);
      // Remove event listeners on cleanup
      return () => {
        if (script) {
          script.removeEventListener("load", setStateFromEvent);
          script.removeEventListener("error", setStateFromEvent);
        }
      };
    },
    [src] // Only re-run effect if script src changes
  );
  return status;
}

修改app.js,加入两个sub-app,请注意其中的web component定义:hello-component和sub-app-02,这两个标签都是在sub-app中定义好的。

import * as React from 'react';
import { useScript } from "./useScript";

function SubApp01() {
  const externalScript = "http://localhost:5001/bundle";
  const loadingState = useScript(externalScript);
  return(
    <div>
      {loadingState === "loading" && <p>Loading...</p>}
      {loadingState === "ready" && <hello-component my-name="Andy" />}
    </div>
  );
}

function SubApp02() {
  const externalScript = "http://localhost:5002/bundle";
  const loadingState = useScript(externalScript);
  return(
    <div>
      {loadingState === "loading" && <p>Loading...</p>}
      {loadingState === "ready" && <sub-app-02 />}
    </div>
  );
}

const app01 = 'app01';
const app02 = 'app02';

function App() {
  const [app, setApp] = React.useState(app01);

  return (
    <div className="App">
      Shell App
      <br />
      <input type='button' value='Sub-App 01' onClick={()=> setApp(app01)} />
      <input type='button' value='Sub-App 02' onClick={()=> setApp(app02)} />
      <hr />
      {
        app === app01? <SubApp01></SubApp01> : <SubApp02></SubApp02>
      }
    </div>
  );
}

export default App;

最后运行npm run start, 效果如下:

点击Sub-App 02按钮,则切换到SubApp-02

最后,给出代码链接

当然,此Demo虽然可以运行,却并不完美。比如两个sub-app都将react, react-dom, react-router-dom编入bundle文件。 我们将在后续文章再做探讨。

posted @ 2021-10-26 17:18  老胡Andy  阅读(377)  评论(0编辑  收藏  举报