手把手带你手写一个qiankun 微前端核心原理

718 阅读5分钟

前言

大家好这里是阳九,一个中途转行的野路子码农,热衷于研究和手写前端工具.

我的宗旨就是 万物皆可手写

新手创作不易,有问题欢迎指出和轻喷,谢谢

本文章适合有一定React开发经验,使用过qiankun框架,对webpack有一定了解的前端攻城狮,如果没有请绕道恶补基础知识)

本文章内的代码只显示了关键思路,无法真实运行

注册微前端项目

用过乾坤的朋友都知道,我们需要在基座项目中注册微前端,配置对应的参数.

  // 注册微前端(乾坤)
  registerMicroApps([
    {
      name: 'react app-1',
      entry: '//localhost:3000',     // 入口
      container: '#microApp_React_1',// 挂载点的id (挂载到哪个div中)
      activeRule: '/microReact_1',// 微前端的路由
    },
    {
      name: 'react app-2',
      entry: '//localhost:3001',
      container: '#microApp_React_2',
      activeRule: '/microReact_2',
    },
  ]);

基座项目中准备好对应的微前端容器

// 基座App中添加微前端挂载点
function App() {
  return (
    <div className="App">
      <h1>LZY-QIANKUN-基座</h1>
      <div id='microApp_React_1'></div> // 注册挂载点的id 
      <div id='microApp_React_2'></div>
    </div>
  );
}

export default App;

子应用导出对应的生命周期函数

1. 首先我们在子应用中导出三个生命周期函数

这三个函数在乾坤中进行调用,用来渲染和切换不同的子应用, 通过传入container,渲染到对应的div节点中

子应用的index.js文件
...
export async function bootstrap() {
  console.log('[react18] react app bootstraped');
}

export async function mount(props) {
  console.log('[react18] props from main framework', props);
  const { container } = props; // 这里的container就是之前注册的div
  const root = ReactDOM.createRoot(container);
  root.render(<App />);
}

export async function unmount(props) {
  const { container } = props;
  const root = ReactDOM.createRoot(container);
  root.unmount()
}

获取JS,CSS,html资源

理论上,一个完整的前端项目只需要三个文件 bundle.js , index.css , index.html 那么我们的目标就很明确了,获取这三个文件并执行

**1. 我们将两个react项目通过webpack-dev-server运行,一个在3000端口 一个在4000端口 **

npm run start // reactApp port:3000
npm run start // reactApp port 4000

devServer运行时,会生成对应的bundle,html,css文件,只不过并没有写入硬盘,而是存放在内存中并托管到服务器上.

2. 此时我们可以通过ajax,fetch等方法请求到对应的html资源,通过注册时的entry即可获取

 const url = app.entry // 就是之前注册时的入口  比如//localhost:3000
 const html = await fetch(url).then(res => res.text()) //请求html

3. 此时我们就获取到了类似这样的html字符串

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>React App</title>
</head>

<body>
    <div id="root"></div>
</body>

<link rel="stylesheet" href="index.css">
<script src="bundle.js"></script>

</html>

4. 之后我们将其用div进行包裹,就可以当作普通的html结构来解析 (多个script,使用数组进行解析)

    // 用div包裹
    const htmlTemplate = document.createElement('div')
    htmlTemplate.innerHTML = html
    // 直接用querySelectorAll获取script标签内容
    const scripts = htmlTemplate.querySelectorAll('script')

5. 通过script标签上的src,拿到js的路径,拼接上entry,请求即可获得对应的JS代码

// 例子
 <script src="./bundle.js"/>
 // 获取路径
  const script = htmlTemplate.querySelector('script')
  const src = script.getAttribute('src')
  const path = path.join(url,src)  //也就是 localhost:3000/bundle.js
  const jsCodeRes = fetch(jsURL).then(res => res.text()) // fetch获取 

6. 我们如法炮制,获得css代码,此时我们就已经获得了一个项目的所有部分,js,css,html

通过eval获取微应用导出的三个生命周期方法(重点)

1. UMD模块化规范 我们来看一下什么是UMD模块化规范(以下是UMD模块化方案打包后的webpack代码)

可以看到 用于适配各种模块化方案 用四种不同模式将factory()进行导出

所以我们通过手动构建cjs环境,并插入eval的代码中,将factory函数接出来

(function webpackUniversalModuleDefinition(root, factory) {
     if (typeof exports === 'object' && typeof module === 'object') //todo 适配cjs 用module.export 导出factory函数
         module.exports = factory();
     else if (typeof define === 'function' && define.amd) //todo 适配amd 用define 导出factory函数
        define([], factory);
     else if (typeof exports === 'object')   //todo 适配esm 用export 导出factory函数
        exports["react_app-main"] = factory();
     else
         root["react_app-main"] = factory();//todo 默认导出(在浏览器端root为window)
 })((window: Window) => {
 // 自己的代码
 })

注意: eval方法可以访问外部变量,所以我们直接在外部定义一个module.export变量, 由于模块执行了

 module.exports = factory();// factory()也就是导出的三个生命周期方法

这串代码,我们可以在外部接收到三个生命周期方法

// 手动构建cjs环境
    const module = { exports: {} }
    const exports = module.exports
// 执行上面的UMD模块代码
    eval(code)
    coneole.log(module.exports)  // 获得了三个生命周期方法

执行渲染

此时我们已经有了 三个生命周期方法 ,html结构,css代码 直接执行生命周期方法即可渲染项目

function renderMicroAppByRoute(){
    // 这里需要先将上一个App卸载掉
    prevApp.unmount()


    const {bootstarp,mount,unmount} = module.exports
    const container = document.querySelector(app.container) // 根据id获取容器div
    await bootstarp() 
    await app.mount({ container })// 执行渲染,传入对应的div容器
}

劫持路由跳转,渲染微前端

我们通过全局的history对象,劫持路由跳转,判断路由path来渲染不同的子应用

    window.addEventListener('popstate', () => { // 监听popState方法
        renderMicroAppByRoute()
    })

    const originPushState = window.history.pushState  // 劫持原生的pushState方法
    window.history.pushState = (...args) => {
        originPushState.apply(window.history, args)
        renderMicroAppByRoute()
    }

    const originReplaceState = window.history.replaceState  // 劫持原生replace方法
    window.history.replaceState = (...args) => {
        originReplaceState.apply(window.history, args)
        renderMicroAppByRoute()
    }

解决静态资源无法加载的问题

1.我们的基座项目运行在8888端口,子应用请求静态资源也会从这个端口请求 image.png

2. 我们需要在子应用里配置一下,子应用根目录下创建public-path.js文件 配置webpack的全局变量

// public-path.js
// webpack构建时,遇到__webpack_public_path__变量会自动拼接 改变资源的请求路径
// 配置后子应用都会从3000端口请求资源
__webpack_public_path__ = 'http://localhost:3000/' 

可以看到微应用的请求端口变为了3000

image.png

3.我们在子应用初始化时,在全局记录一下这个路径, 然后从这里请求资源

    //设置全局变量 让app知道自己是在qiankun里运行
    //设置子应用的publicPath,构建时修改
    window.__POWER_BY_LZY_QIANKUN__ = true
    window.__INJECTED_PUBLIC_PATH_BY_LZY_QIANKUN__ = app.entry + '/'

解决CSS样式冲突

乾坤源码里使用shadow dom解决样式冲突(一个沙箱隔离环境) 在start时进行配置(详细可学习shadow dom和前端沙箱知识) image.png