概述
最近在试着把vue2/vue3/react/angular2的基本使用捋一遍,并想把用这些框架做的东西整合在一起,因此选用了近两年颇受欢迎的微前端工具quankun做项目整合。
搭建顺序
因为是一个整合多个框架的微前端实践项目,因此涉及到的目录结构会比较多,所以需要提前列好顺序,一步一步的讲解搭建过程,避免描述的过于混乱。
- 搭建基座应用,本文中基座应用的搭配方案是vue2 + typescript + vue-cli4 + qiankun + scss,然后基座中引入的每一个微应用,都会作为菜单中的一个业务模块来展示。
- 搭建基于vue3的文章模块,项目搭配方案是vue3 + vite2 + typescript + scss。
- 搭建基于react的企划模块,项目搭配方案是react17 + webpack5 + redux + typescript + hooks。
- 搭建基于angular2的精灵模块,项目搭配方案是angular10.2.0 + single-spa-angular。
- 搭建基于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
执行创建指令,就会进入选择创建配置的界面,每一步的配置都是什么意思,网上有很多相关文章,我就不再赘述了,我就只贴一下我的参考文章:
创建好项目后,再在终端执行下面指令,下载qiankun:
npm install qiankun --save 或 yarn add qiankun
然后根据下面链接文章的步骤,创建相应的微应用文件,就完成了一个初始的基座应用结构:
基于 qiankun 的微前端最佳实践(万字长文) - 从 0 到 1 篇
搭建vue3应用
我的vue3应用采用的是vue3 + typescript + vite2 + scss的搭配。
先在终端执行如下命令,进入vite2项目生成流程:
npm init vite
输入项目名称:
选择要使用的框架:
选择是用js编写还是用ts编写:
回车后就生成出了一个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的使用讲的还是挺清楚的:
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的使用我主要参考了下面掘友写的文章,我觉得写的都挺不错的:
TypeScript下使用Hooks的方式重新学习Redux和React-Redux
使用React Hooks代替Redux进行状态管理和进行异步请求, 基于Typescript
结尾
写这篇文章除了交流分享,也是为了给自己做个记录,万一什么时候有些东西忘了,还能再翻出来看看,写得不好敬请见谅!