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文件。 我们将在后续文章再做探讨。