基于qiankun的微前端项目搭建

·  阅读 657

概述

最近在试着把vue2/vue3/react/angular2的基本使用捋一遍,并想把用这些框架做的东西整合在一起,因此选用了近两年颇受欢迎的微前端工具quankun做项目整合。搭建说明写的可能有些简单,可以搭配着我贴出的其他文章链接和项目代码来看。

搭建顺序

因为是一个整合多个框架的微前端实践项目,因此涉及到的目录结构会比较多,所以需要提前列好顺序,一步一步的讲解搭建过程,避免描述的过于混乱。

  1. 搭建基座应用,本文中基座应用的搭配方案是vue2 + typescript + vue-cli4 + qiankun + scss,然后基座中引入的每一个微应用,都会作为菜单中的一个业务模块来展示。
  2. 搭建基于vue3的文章模块,项目搭配方案是vue3 + vite2 + typescript + scss。
  3. 搭建基于react的企划模块,项目搭配方案是react17 + webpack5 + redux + typescript + hooks。
  4. 搭建基于angular2的精灵模块,项目搭配方案是angular10.2.0 + single-spa-angular。
  5. 搭建基于nest.js的服务端程序,用于管理数据,项目搭配方式是nest.js + typescript + mongoose + mongoDB。

搭建基座应用

作为一个微前端项目,需要有一个主应用作为基座,用来连接不同的微应用项目,这些微应用可以是整体项目中的一个大模块,也可以是某个页面中的展示的某一部分内容,就类似于使用iframe一样,可以随自己的项目需要,随意引入想展示的内容。当然用法上比iframe要复杂一点儿,但比iframe更强大,更适合单页面项目的开发。

我的基座应用采用的是vue2 + typescript + vue-cli4 + qiankun + scss的搭配,因此要先安装好vue-cli4,在终端执行如下命令:

npm install -g @vue/cli
复制代码

执行后就会把最新的vue-cli4安装到全局库中。

然后在终端使用vue-cli指令创建项目

vue create project-main
复制代码

执行创建指令,就会进入选择创建配置的界面,每一步的配置都是什么意思,网上有很多相关文章,我就不再赘述了,我就只贴一下我的参考文章:

vue-cli4 + TS构建新项目(基础篇)

创建好项目后,再在终端执行下面指令,下载qiankun:

npm install qiankun --save 或 yarn add qiankun
复制代码

然后根据下面链接文章的步骤,创建相应的微应用文件,就完成了一个初始的基座应用结构:

基于 qiankun 的微前端最佳实践(万字长文) - 从 0 到 1 篇

搭建vue3应用

我的vue3应用采用的是vue3 + typescript + vite2 + scss的搭配。

先在终端执行如下命令,进入vite2项目生成流程:

npm init vite
复制代码

输入项目名称:

image.png

选择要使用的框架:

image.png

选择是用js编写还是用ts编写:

image.png

回车后就生成出了一个vue3 + typescript + vite2的项目

此时目录中没有状态机、没有路由也没有样式预处理工具,因此还需要在终端执行:

npm install vue-router@next vuex@next --save 或 yarn add vue-router@next vuex@next
复制代码
npm install node-sass sass sass-loader --save-dev 或 yarn add node-sass sass sass-loader --dev
复制代码

这样,状态机、路由和样式预处理就都有了,再加上相应的处理文件即可。

由于需要跟基座应用做连接,因此作为微应用或者叫子应用,需要导出三个生命周期函数bootstrap/mount/unmount,因为构建工具是vite2,为了更好的支持qiankun的引入,这里我使用了一个第三方依赖包,在终端执行:

yarn add vite-plugin-qiankun path --dev
复制代码

此依赖包使用说明参见:github.com/tengmaoqing…

然后在vite.config.ts中,加入如下代码:

import { resolve } from 'path'
import qiankun from 'vite-plugin-qiankun'

export default defineConfig({
  plugins: [
    ...内容省略,
    //Vue3MicroApp这个名称是对应的基座应用中src/micro/apps.ts中的name
    qiankun('Vue3MicroApp', {useDevMode: true})
  ]
})
复制代码

在src/main.ts中加入如下代码:

import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import { renderWithQiankun, qiankunWindow } from 'vite-plugin-qiankun/dist/helper';
import routes from "@/router/routes";
import { store, key } from "@/store";
import App from '@/App.vue';

let instance: any = null;
let router: any = null;

/**
 * 渲染函数
 * 两种情况:主应用生命周期钩子中运行 / 微应用单独启动时运行
 */
 function render(props: any) {
  // 在 render 中创建 VueRouter,可以保证在卸载微应用时,移除 location 事件监听,防止事件污染
  const routerBase = qiankunWindow.__POWERED_BY_QIANKUN__ ? "/micro/vue3" : "/";
  router = createRouter({
    history: createWebHistory(routerBase),
    routes: routes
  });

  //容器dom或id
  const containerEle = props.container ? props.container.querySelector('#app') : '#app';
  // 挂载应用
  instance = createApp(App);
  instance.use(router).use(store, key).mount(containerEle);
}

renderWithQiankun({
  /**
   * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
   */
  mount(props) {
    console.log("Vue3MicroApp mount", props);
    render(props);
  },
  /**
   * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
   * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
   */
  bootstrap() {
    console.log("Vue3MicroApp bootstraped");
  },
  /**
   * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
   */
  unmount(props: any) {
    console.log("Vue3MicroApp unmount");
    //卸载应用实例
    instance.unmount();
    instance = null;
    router = null;
  },
});

// 独立运行时,直接挂载应用
if (!qiankunWindow.__POWERED_BY_QIANKUN__) {
  render({});
}
复制代码

这样就完成了对接基座应用的功能。

需要注意的是,代码中实例化vue时,我是这样写的:

instance = createApp(App);
instance.use(router).use(store, key).mount(containerEle);
复制代码

而没有这样写:

instance = createApp(App).use(router).use(store, key).mount(containerEle);
复制代码

是因为虽然都能成功创建实例,但第一种返回的是一个vue实例对象,而第二种返回的是一个Proxy对象,因此第二种是找不到unmount方法的,无法执行实例卸载的。

搭建react应用

package列表:

"dependencies": {
    "@babel/runtime-corejs3": "^7.13.10",
    "axios": "^0.21.1",
    "lodash": "^4.17.21",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-redux": "^7.2.5",
    "react-router-dom": "^5.3.0",
    "redux": "^4.1.1",
    "redux-saga": "^1.1.3"
  },
  "devDependencies": {
    "@babel/core": "^7.13.13",
    "@babel/plugin-transform-runtime": "^7.13.10",
    "@babel/preset-env": "^7.13.12",
    "@babel/preset-react": "^7.13.13",
    "@babel/preset-typescript": "^7.13.0",
    "@commitlint/cli": "^12.0.1",
    "@commitlint/config-conventional": "^12.0.1",
    "@types/lodash": "^4.14.176",
    "@types/react": "^17.0.3",
    "@types/react-dom": "^17.0.3",
    "@types/react-router-dom": "^5.3.1",
    "@types/webpack-env": "^1.16.0",
    "@typescript-eslint/eslint-plugin": "^4.19.0",
    "@typescript-eslint/parser": "^4.19.0",
    "autoprefixer": "^10.2.5",
    "babel-loader": "^8.2.2",
    "chalk": "^4.1.0",
    "clean-webpack-plugin": "^3.0.0",
    "conventional-changelog-cli": "^2.1.1",
    "copy-webpack-plugin": "^8.1.0",
    "cross-env": "^7.0.3",
    "css-loader": "^5.2.0",
    "css-minimizer-webpack-plugin": "^1.3.0",
    "detect-port-alt": "^1.1.6",
    "error-overlay-webpack-plugin": "^0.4.2",
    "eslint": "^7.22.0",
    "eslint-config-airbnb": "^18.2.1",
    "eslint-config-prettier": "^8.1.0",
    "eslint-import-resolver-typescript": "^2.4.0",
    "eslint-plugin-import": "^2.22.1",
    "eslint-plugin-jsx-a11y": "^6.4.1",
    "eslint-plugin-prettier": "^3.3.1",
    "eslint-plugin-promise": "^4.3.1",
    "eslint-plugin-react": "^7.23.1",
    "eslint-plugin-react-hooks": "^4.2.0",
    "eslint-plugin-unicorn": "^29.0.0",
    "fork-ts-checker-webpack-plugin": "^6.2.0",
    "html-webpack-plugin": "^5.3.1",
    "husky": "^4.3.8",
    "ip": "^1.1.5",
    "is-root": "^2.1.0",
    "lint-staged": "^10.5.4",
    "mini-css-extract-plugin": "^1.4.0",
    "node-sass": "^5.0.0",
    "postcss": "^8.2.8",
    "postcss-flexbugs-fixes": "^5.0.2",
    "postcss-loader": "^5.2.0",
    "postcss-preset-env": "^6.7.0",
    "prettier": "^2.2.1",
    "sass-loader": "^11.0.1",
    "style-loader": "^2.0.0",
    "stylelint": "^13.12.0",
    "stylelint-config-prettier": "^8.0.2",
    "stylelint-config-rational-order": "^0.1.2",
    "stylelint-config-standard": "^21.0.0",
    "stylelint-declaration-block-no-ignored-properties": "^2.3.0",
    "stylelint-order": "^4.1.0",
    "stylelint-scss": "^3.19.0",
    "terser-webpack-plugin": "^5.1.1",
    "typescript": "^4.2.3",
    "webpack": "^5.58.1",
    "webpack-bundle-analyzer": "^4.4.0",
    "webpack-cli": "^4.9.0",
    "webpack-dev-server": "^4.3.1",
    "webpack-merge": "^5.7.3",
    "webpackbar": "^5.0.0-3"
  }
复制代码

依照上面目录安装好依赖包后,在入口文件app.tsx中加入如下代码,完成qiankun的连接:

function renderApp(props: any) {
  const { container } = props;
  const appEle = container? container.querySelector('#root') : document.getElementById('root');
  ReactDOM.render(
    <Provider store={store}>
      <BasicRoute />
    </Provider>
    , appEle
  );
}

if((window as any).__POWERED_BY_QIANKUN__) {
  // 动态设置 webpack publicPath,防止资源加载出错
  // eslint-disable-next-line no-undef
  __webpack_public_path__ = (window as any).__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

if(!(window as any).__POWERED_BY_QIANKUN__) {
  renderApp({});
}

/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log("ReactMicroApp bootstraped");
}

/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props: any) {
  console.log("ReactMicroApp mount");
  renderApp(props);
}

/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount(props: any) {
  console.log("ReactMicroApp unmount");
  const { container } = props;
  const appEle = container? container.querySelector('#root') : document.getElementById('root');
  ReactDOM.unmountComponentAtNode(appEle);
}
复制代码

此项目的webpack配置我是参照下面链接写的:

还在困惑项目脚手架代码为什么那么写?那这篇webpack5 + react + typescript环境配置代码完全指南送给你

但是这个链接中的一些配置项,与webpack5的配置还是有些出入,因此这里还需要特别说一下有出入的地方:

scripts/config/webpack.dev.js中,output和devServer配置项以下面代码为准,上面的链接文章中的配置与webpack5的官方文档用法不一致

output: {
  filename: 'js/[name].js',
  path: paths.appBuild,
  library: {
    name: `${paths.microAppName}`,
    type: 'umd',
  },
  chunkLoadingGlobal: `webpackJsonp_${paths.microAppName}`,
  globalObject: 'window'
},
devServer: {
  compress: true,
  client: {
    logging: 'info',
    overlay: true,
    progress: true,
  },
  open: true,
  hot: false,
  proxy: {
    ...require(paths.appProxySetup),
  },
  headers: {
    'Access-Control-Allow-Origin': '*',
  },
  historyApiFallback: true,
  liveReload: false,
},
复制代码

scripts/config/webpack.prod.js中,output配置项以下面代码为准:

output: {
  filename: 'js/[name].[contenthash:8].js',
  path: paths.appBuild,
  assetModuleFilename: 'images/[name].[contenthash:8].[ext]',
  library: {
    name: `${paths.microAppName}-[name]`,
    type: 'umd'
  },
  chunkLoadingGlobal: `webpackJsonp_${paths.microAppName}`,
  globalObject: 'window'
},
复制代码

至此,react应用的微应用配合和webpack配置就都写好了。

搭建angular应用

在终端执行下面指令安装angular脚手架:

npm install -g @angular/cli
复制代码

安装好脚手架后,执行ng new angular-demo就可以创建一个angular项目,这里我直接贴出package列表:

  "dependencies": {
    "@angular/animations": "~10.2.0",
    "@angular/common": "~10.2.0",
    "@angular/compiler": "~10.2.0",
    "@angular/core": "~10.2.0",
    "@angular/forms": "~10.2.0",
    "@angular/platform-browser": "~10.2.0",
    "@angular/platform-browser-dynamic": "~10.2.0",
    "@angular/router": "~10.2.0",
    "@fortawesome/angular-fontawesome": "^0.7.0",
    "@fortawesome/fontawesome-svg-core": "^1.2.32",
    "@fortawesome/free-brands-svg-icons": "^5.15.1",
    "@fortawesome/free-regular-svg-icons": "^5.15.1",
    "@fortawesome/free-solid-svg-icons": "^5.15.1",
    "angular2-websocket": "^0.9.8",
    "axios": "^0.21.0",
    "jsencrypt": "^3.0.0-rc.1",
    "rxjs": "~6.6.0",
    "single-spa": ">=4.0.0",
    "single-spa-angular": "4.9.2",
    "tslib": "^2.0.0",
    "zone.js": "~0.10.2"
  },
  "devDependencies": {
    "@angular-builders/custom-webpack": "10.1.0-beta.0",
    "@angular-devkit/build-angular": "~0.1002.0",
    "@angular/cli": "~10.2.0",
    "@angular/compiler-cli": "~10.2.0",
    "@types/node": "^12.11.1",
    "@types/jasmine": "~3.5.0",
    "@types/jasminewd2": "~2.0.3",
    "codelyzer": "^6.0.0",
    "jasmine-core": "~3.6.0",
    "jasmine-spec-reporter": "~5.0.0",
    "karma": "~5.0.0",
    "karma-chrome-launcher": "~3.1.0",
    "karma-coverage-istanbul-reporter": "~3.0.2",
    "karma-jasmine": "~4.0.0",
    "karma-jasmine-html-reporter": "^1.5.0",
    "protractor": "~7.0.0",
    "ts-node": "~8.3.0",
    "tslint": "~6.1.0",
    "typescript": "~4.0.2"
  }
复制代码

angular项目的构建配置文件是angular.json,现在最新的版本是angular12,而我这里用的angular10,因此我的angular.json与目前最新版的angular的配置可能会略有不同。

angular的使用还需要参照官方文档看:angular.cn/docs

我这里angular的微应用接入主要是参照下面的链接:

基于 qiankun 的微前端最佳实践(万字长文) - 从 0 到 1 篇

使用的single-spa-angular来完成的angular的qiankun连接,不过我的angular应用通过基座访问时,虽然显示qiankun连接成功,但会报下面的错误,而且页面内容始终渲染不出来:

Navigation triggered outside Angular zone, did you forget to call 'ngZone.run()'?

如果有知道怎么解决的朋友,希望在评论区告诉我,谢谢~~

服务端程序搭建

先执行下面指令安装nest.js的脚手架:

npm i -g @nestjs/cli
复制代码

然后执行下面指令创建nest项目:

nest new project-serve
复制代码

创建好项目后,我的nest搭建过程完全是参照的下面链接做的:

如何用 Nest.js、MongoDB 和 Vue.js 搭建一个博客

照着链接中的文章描述做一遍,就能搭建出一套nest.js + mongoose的服务端程序,很简单。

框架基本使用

下面来简单聊一下vue3和react17框架的基本使用。

vue3的使用

目前vue3有下面三种写法:

  • 一种是与vue2基本一致的写法,个人猜测这种写法应该是为了方便vue2的项目迁移而做的。
export default {
  name: 'TestOne',
  props: {
    msg: {
      type: String,
      default: ''
    }
  },
  data() {
    return {
      testList: [
        {name: '列表一'},
        {name: '列表二'},
        {name: '列表三'},
      ]
    }
  },
  computed: {
  
  },
  created() {
    
  },
  mounted() {
    
  },
  mothods: {

  }
};
复制代码
  • 一种写法是js代码都写在setup中,然后通过vue3提供的各种钩子处理数据和逻辑。
import { 
  defineComponent,
  onBeforeMount,
  onMounted,
  reactive,
  toRefs
} from 'vue';

type PageState = {
  list: any
}

export default defineComponent({
  name: 'TestTwo',
  setup() {
    let state = reactive<PageState>({
      list: []
    });

    const getList = () => {
      state.list = [
        {name: '列表一'}, 
        {name: '列表二'}, 
        {name: '列表三'},
      ];
    };

    onBeforeMount(() => {
      getList();
    });

    onMounted(() => {
      
    });

    return {
      ...toRefs(state)
    }
  }
});
复制代码
  • 一种是直接在script标签带上属性setup,表示内部执行的都是setup运行的代码。
<script lang="ts" setup>
import { defineProps, computed } from 'vue';
import { useStore } from 'vuex';
import { key } from '@src/store';

type Props = {
  msg: string
}
defineProps<Props>();
const store = useStore(key);
const count = computed(() => {
  return store.state.count;
});
const inCrement = () => {
  store.commit('increment');
};
</script>
复制代码

具体选择哪一种写法更好,还要根据个人项目而定。

这里有两篇其他掘友写的文章,对vue3的使用讲的还是挺清楚的:

让你30分钟快速掌握vue 3

Vue3 script setup 语法糖详解

react + hooks的使用

hooks的出现让函数组件也可以像class组件一样使用生命周期,且更有利于做tree shaking,写法看起来也比之前更加简便了,个人猜想vue3的诞生过程应该受了react很大的启发。

基本代码如下:

import { useState, useEffect } from 'react';
import { useDispatch } from "react-redux";
import { useHistory } from 'react-router-dom';
import * as planApi from '@src/api/plan';

interface PlanItem {
  readonly _id: string;
  readonly title: string;
  readonly time: number;
  readonly desc: string;
}

const PlanList: React.FC<{}> = () => {
  const [list, setList] = useState([]);
  const dispatch = useDispatch();
  const routerHistory = useHistory();

  /*
   * useEffect的作用相当于
   * componentDidMount/componentDidUpdate/componentWillUnmount三个生命周期的组合
   */
  useEffect(() => {
    (async () => {
      const planList: any = await planApi.getPlanList({});
      setList(planList)
    })();
  }, []);

  //跳转到详情页
  const goDetail = (id: string) => {
    routerHistory.push({
      pathname: '/planDetail',
      state: {
        id: id
      }
    });
  }

  let getList = (list: Array<PlanItem>) => {
    return list.map(item => {
      return (
        <li className="item" onClick={() => goDetail(item._id)}>
          <div className="p1">
            <div className="item-title">{item.title}</div>
            <div className="item-time">{item.time}</div>
          </div>
          <div className="p2">{item.desc}</div>
        </li>
      )
    })
  }

  return (
    <div className="planList">
      <ul>
        {getList(list)}
      </ul>
    </div>
  )
}
复制代码

由上面代码可以看出,hooks用法也是利用封装好的各种钩子函数,来完成生命周期、数据管理、页面跳转等事情的。需要注意的是,hooks函数都是以use为开头命名的,且在组件中调用时,必须放在函数顶部位置,不能放在判断或嵌套函数中,否则可能会出现检测不到钩子函数的问题。

react16以后还新增了一个配合懒加载使用的Suspense组件,让懒加载组件变得更优雅。

react + hooks的使用我主要参考了下面掘友写的文章,我觉得写的都挺不错的:

React + TypeScript实践

2021-TypeScript + React 最佳实践

TypeScript下使用Hooks的方式重新学习Redux和React-Redux

不要再问React Hooks能否取代 Redux 了

使用React Hooks代替Redux进行状态管理和进行异步请求, 基于Typescript

结尾

写这篇文章除了交流分享,也是为了给自己做个记录,万一什么时候有些东西忘了,还能再翻出来看看,写得不好敬请见谅!

分类:
前端
分类:
前端