微前端从思考到实践上线(二)

1,710 阅读10分钟

本篇文章来自团队小伙伴 @OnWork 的一次学习分享,希望跟大家分享与探讨。

求积硅步以致千里,勇于探享生活之美。

微前端知识点

主应用功能分析

主应用作为基座应用,需具有以下功能:

  1. 加载「子应用」、应用挂载初始化程序;
  2. 提供一些必要的验证信息,例如 token 的获取;
  3. 提供一些应用间通讯的字段定义;
  4. 统一处理静态资源;
  5. 静态菜单(有时候左侧菜单栏大可不必是子应用);
  6. 暴露「主应用」方法给「子应用」调用;
  7. 提供全局常量;
  8. 区分是否需要验证的「子应用」;
  9. 404 页面处理(重写 fetch、vue-router 的动态路由匹配)

依据上述所列,我们大致对「主应用」的功能有所了解,知道「主应用」的存在是为了干些什么?(实际需求可根据项目类型动态调整)。

微前端下多应用管理

分析完主应用的功能后,接下来我们就要着手去创建「主应用」。但在这之前,我们需要解决应用(项目)管理的小问题。

微前端的最终成果,就是将一个巨型应用拆分成多个应用。

那么项目管理从单个变成多个:一个「主应用」 + 多个「子应用」,需要考虑分治与聚合,而且应用多了维护时频繁切换项目总不太优雅。

微前端的项目管理:「独立与整体互相依存」,「子应用」保持独立性,又可聚合到一起作为一个整体项目去维护管理。

为了方便管理多个应用,这里推荐使用 Git Submodule 进行项目管理。

Git 通过子模块来解决这个问题。 子模块允许你将一个 Git 仓库作为另一个 Git 仓库的子目录。 它能让你将另一个仓库克隆到自己的项目中,同时还保持提交的独立。

创建基座「主应用」和业务「子应用」

使用 Vue-CLI 脚手架创建, 因此创建时模板相差不大,「子应用」的配置大体相同,不同的是「主应用」的配置。

我们先创建四个应用:

功能简称应用
主应用maindemo-web-main
登录应用logindemo-web-login
系统应用systemdemo-web-system
商品应用orderdemo-web-order
➜  demo-web vue -V
@vue/cli 4.5.9
➜  demo-web vue create demo-web-main
➜  demo-web vue create demo-web-login
➜  demo-web vue create demo-web-system
➜  demo-web vue create demo-web-order

并在这四个应用的根目录下添加 vue.config.js,后续我们会定义一些配置项。

搭建 main 应用

进入 main 应用,添加 vue 的环境配置文件 .env.development

demo-web-main (master) ✔ touch .env.development

在 .env.development 添加代码:

NODE_ENV = 'development'
VUE_APP_RUNTIME = ''
VUE_APP_BASE_API_GW = '/gw'
VUE_APP_NAME = demo-web-main
VUE_APP_BASE_DEVELOPMENT_PORT = 10000
VUE_APP_PROXY_URL = '//web-main.micro.com/'

修改 vue.config.js 里面的 devServer:

const {
  VUE_APP_BASE_DEVELOPMENT_PORT: port, // 取自 .env.development 常量
} = process.env;
module.exports = {
    // publicPath: './',               // 主应用不需要 publicPath
    devServer: {
        hot: true,
        disableHostCheck: true,
        port,                          // 主应用端口设置:9000
        overlay: {
            warnings: false,
            errors: true
        },
        headers: {
          	// 所有应用都需要这个,不然主应用加载子应用都会报跨域
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': '*',
            'Access-Control-Allow-Headers': '*'
        },
        proxy: {
            '/gw': {
                target: 'http://www.api.com', // 代理目标地址
                changeOrigin: true,
                ws: true,
                // 重写路径,一般配合 axios 的 baseUrl 实现代理解决跨域问题
                // 我们团队后台接口都走网关,那么对应的就是 /gw
                pathRewrite: { '^/gw': '' },
                secure: false
            }
        }
    },
}

在 demo-web-main/src 目录里面添加配置文件 settings.js:

module.exports = {
    dashboard: {    // 首页可拆成子应用(看具体需求)
        icon: '',
        title: '首页',
        path: '/dashboard',
        name: 'dashboard',
    },
    error404: {    // 404 页面
        icon: '',
        title: '404',
        path: '/404',
        name: '404',
    },
    extract: {    // 在新标签页中打开标签页
        'help-center': {    // 跳转帮助中心
            icon: '',
            title: '帮助中心',
            path: '/help-center',
            name: 'goHelpCenter',
        },
    },
    // 子应用存放的地方 类似于 public/index.html 中的 <div id="app"></div>
    SUB_APP_PREFIX: 'subapp-viewport',
    MENUS: {
        order: {
            devEntry: '//localhost:9010',
            depEntry: `//web-order.demo.com`,
            moduleName: 'order',    // 子应用唯一表识别此 key (方便调试)
        },
        system: {
            devEntry: '//localhost:9024',
            depEntry: `//web-system.demo.com`,
            moduleName: 'system',
        },
    },
    noAuthApp: [    // 不需要进行验证的应用,比如登录
        {
            module: 'login',
            defaultRegister: true,
            devEntry: '//localhost:9001',
            depEntry: `//web-login.demo.com`,
            routerBase: '/login',
        }
    ]
}

我们既然要做微前端,那么 public/index.html 里面应用挂载点再叫作 #app 就不那么合适了,我们给他改一改名:

<!-- public/index.html -->
- <div id="app"></div>
+ <div id="main-app"></div>

现在 main 应用的初期工作已经完成了,不过距离运行起来还有点距离。

我们现在接入 qiankun,在 demo-web-main 根目录运行:yarn add -D qiankun

一般 Vue 应用 main.js 会做如下操作:

  1. 处理渲染/挂载逻辑;
  2. 注入第三方库依赖;

现在我们稍做改造,在 demo-web-main/src 下面新建 core 文件夹,用于存放 qiankun 相关的东西。

core/render.js 抽离渲染逻辑

我们在 demo-web-main/src/core 下面新建一个 render.js,将原本放在 main.js 里面的初始化程序搬过来:

import Vue from 'vue'
import App from '../App.vue'
import router from '../router'
import store from '../store'

Vue.config.productionTip = false

new Vue({
  router,
  store,
  render: h => h(App)
- }).$mount('#app') // 对应上文改的挂载点
+ }).$mount('#main-app')

把渲染逻辑抽离出来了,那么 main 应用里面的 main.js 里面还放着什么呢?我们需要的第三方依赖之前是怎么安装的呢?是不是一股脑的放在 main.js 里面?这里我们再做抽离。

core/install.js 抽离注入第三方库

在 core 里面新建 install.js 去进行第三方依赖的加载:

import Vue from 'vue';
import Viewer from 'v-viewer';
import 'viewerjs/dist/viewer.css';
// eg. Viewer
Vue.use(Viewer, { defaultOptions: { zIndex: 9999, }, });
...
// 一些其他的 比如 elementui 之类的加载

第三方依赖加载完毕之后,到这里我们就可以启动下 main 应用跑跑看。

core/auth.js 用户验证

在 demo-web-main/src/core 下面新建 auth.js:

// vuex 根据项目来进行编写 这里不给出详细的
import store from '../store';
// 上一节讲到的基础设施搭建
import { LocalStorage, } from '@fe-micro/micro-util'; 
// 导入 qiankun 注册子应用方法 auth.js 顾名思义是用来限制权限
import qianKunStart from './app-register'; 
import defaultSettings from '@/settings';
const microAppStart = () => {
    const token = LocalStorage.getToken();
    if (token) { // 已登录状态获取服务端微应用注册表
      	// 处理 token 状态共享
        store.dispatch('appstore/setToken', token);
        return;
    }
	  // 默认加载(未登录时无需服务端获取的微应用)
    qianKunStart(defaultSettings.noAuthApp);
};
export default microAppStart;

core/app-register.js 子应用的加载注册

在 demo-web-main/src/core 下面新建 app-register.js:

项目中使用「子应用」的加载方式 : 基于路由配置

import { 
  registerMicroApps, 
  setDefaultMountApp, 
  start, 
  initGlobalState, 
} from 'qiankun';
import store from '@/store';
import appStore from '../utils/app-store';

let props = {
    data: store.getters,
    parentStore: store,
};
let isDev = process.env.NODE_ENV === 'development';

// 用于加载 login 应用(不需要 token 的应用)
const qianKunStart = (list = []) => {
    let apps = []; 					// 子应用数组盒子
    let defaultApp = null;  // 默认注册应用路由前缀
    list.forEach(i => {
        apps.push({
            name: i.module,
            entry: isDev ? i.devEntry : i.depEntry,
            container: `#subapp-viewport-${i.module}`,
            activeRule: i.routerBase, // 登录的 base router 是写死的
            props: {
                ...props,
                routes: i.data,
                routerBase: i.routerBase,
            },
        });
        if (i.defaultRegister) defaultApp = i.routerBase;
    });
    registerMicroApps(apps);
    // 设置默认进入的子应用 需要进入的子应用路由前缀
    setDefaultMountApp(defaultApp + '/');
    // 启动微前端
    start({
        prefetch: 'all',
        singular: false,
    });
    // 启动qiankun应用间通信机制
    appStore(initGlobalState);
};

export default qianKunStart;

// 用于加载需要token的应用
export const qkAppStart = menus => {
    let apps = []; // 子应用数组盒子
    let isProd = ['production', 'prod'].includes(process.env.NODE_ENV);
    menus.forEach(i => {
        apps.push({
            name: i.module,
            entry: isProd ? i.depEntry : i.devEntry,
            container: `#subapp-viewport-${i.module}`,
            activeRule: i.routerBase, // 除登录外的 base router 是根据后端返回的 url 拼接的
            props: {
                ...props,
                routes: i.children,
                routerBase: i.routerBase,
            },
        });
    });
    registerMicroApps(apps);
    appStore(initGlobalState);
    start({
        prefetch: 'all',
        singular: false,
    });
};

现在我们在 main 应用里面的 main.js 引用加载这三个 js 文件,使其生效:

import './core/render';
import './core/install';
import microAppStart from './core/auth';
microAppStart();

完成上面的步骤那么 main 应用基本搭建完毕了。这时候我们去 yarn serve 启动 main 项目,我们不仅能看到项目正常启动,而且还会看到控制台抛出加载不到 login 应用资源的错误,这是因为 login 还没有启动。

那么下一步我们将补全 login 的配置。

搭建 login 应用

切换目录至 login 应用:

➜  demo-web-main (master) ✗ cd ..
➜  demo-web cd demo-web-login
➜  demo-web-login (master) ✔

那么我们先来对 login 应用进行改造,先把 package.json 中 name 进行修改:

{
-  "name": "login",
+  "name": "demo-web-login",
...
}

添加 vue 的环境配置文件 .env.development:

demo-web-login (master) ✔ touch .env.development
demo-web-login (master) ✔

在 .env.development 里面添加代码:

NODE_ENV = 'development'
VUE_APP_RUNTIME = ''
VUE_APP_BASE_API_GW = '/gw'
VUE_APP_NAME = demo-web-login
VUE_APP_BASE_DEVELOPMENT_PORT = 10001

打开 vue.config.js:

const {
  VUE_APP_NAME: name,
  VUE_APP_BASE_DEVELOPMENT_PORT: port,
} = process.env;
const dev = process.env.NODE_ENV === 'development';
module.exports = {
    publicPath: dev ? `//localhost:${port}` : '/',
    outputDir: 'dist',
    assetsDir: 'static',
    filenameHashing: true,
    devServer: {
    hot: true,
    disableHostCheck: true,
    port,
    overlay: {
      warnings: false,
      errors: true,
    },
    headers: {
      'Access-Control-Allow-Origin': '*', // 每个应用都需要配置
    },
  },
  configureWebpack: {
    resolve: {
      alias: {
        '@': resolve('src'),
      },
    },
    output: {
      // 把子应用打包成 umd 库格式
      // library: `${name}-[name]`,
      library: 'demo-web-login',
      libraryTarget: 'umd',
      jsonpFunction: `webpackJsonp_${name}`,
    },
  }
}

我们照样把 login 应用根目录里面的 public/index.html 中 divid 改成 app-login

这个 id 是跟 core/render 里面 vue 初始化 $mount 相关的。每个子应用都可参考 login 的配置。

我们同样在 src 目录下面创建 core 目录,并且 创建 install.js、life-cycle.js 两个文件。

install.js 里面存放第三方加载需要的东西,和 main 应用 install.js 配置一样的功能。

现在我们重点来说一下 life-cycle.js 生命周期,涉及应用间通讯及子应用渲染。

core/life-cycle.js

import { renderSubchild, } from '@fe-micro/micro-core';
import { routeMatch, } from '@fe-micro/micro-router';
import App from '@/App.vue';
import store from '@/store';              // login 应用自己的 vuex
import selfRoutes from '@/router/routes'; // login 应用自己的 路由配置
import messages from '@/lang';

// 官方通信方法
import appStore from '@/utils/app-store';
const __qiankun__ = window.__POWERED_BY_QIANKUN__;
let instance = null;
let parentStore = null;
// 导出生命周期函数
const lifeCycle = () => ({
    // 只会在微应用初始化的时候调用一次
    async bootstrap() {},
    // 每次进入都会调用 mount 方法
    async mount(props) {
        props.parentStore.dispatch('appstore/setAppName', 'login');
        appStore(props); // 应用间通讯
        parentStore = props.parentStore;
        render(props);   // 渲染子应用
    },
    // 应用切换、卸载
    async unmount() {
        // 子应用 - 应用级的 keep-alive 其实 login 应用不需要这个的 其他应用可以照抄以下内容
        // 应用内 - 需要各自应用实现自己的 keep-alive
        const cachedInstance = instance.cachedInstance || instance;
        window.__CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__ = cachedInstance;
        const cachedNode = cachedInstance._vnode;
        if (!cachedNode.data.keepAlive) cachedNode.data.keepAlive = true;
        cachedInstance.catchRoute = {
            apps: [...instance.$router.apps]
        };
        if (instance.cachedInstance || instance) {
            instance.$destroy();
            instance = null;
        }
    },
    // 还有一些生命周期方法请自行查阅官网进行配置
});

// 子应用渲染
const render = ({ routes, routerBase, container, i18n, }) => {
    const macthRoutes = routeMatch(routes, routerBase); // 详情页
    const fullMacth = [...macthRoutes, ...selfRoutes];
    const fullSelf = [...selfRoutes];
    const routeBase = __qiankun__ ? routerBase : '/'; // 目前并没有独立运行 所有 / 可以忽略
    const __routes = __qiankun__ ? fullMacth : fullSelf;
    Object.keys(messages).forEach(key => {
        i18n && i18n.mergeLocaleMessage(key, messages[key]);
    });
    const { originInstance, } = renderSubchild({
        routes: __routes,
        routerBase: routeBase,
        store,
        parentStore,
        container,
        i18n,
        subappKey: '#app-login',
        mountPoint: App,
    });
    instance = originInstance;
});
export { lifeCycle, render }; // 导出给 quankun 调用

我们去修改 login 应用的 main.js:

import { loadPublicPath, } from '@fe-micro/micro-core';
import { lifeCycle, render, } from './core/life-cycle';
loadPublicPath(); // 封装了 qiankun 提供的 public-path.js
import './core/install';
const { bootstrap, mount, unmount, } = lifeCycle();
export { bootstrap, mount, unmount };

// 独立运行,但是目前并没有去整这个。还是依赖 main 去跑项目
const __qiankun__ = window.__POWERED_BY_QIANKUN__;
if (!__qiankun__) {
    render();
}

我们现在运行 login 应用:yarn serve,如果完成了上面的步骤我们会有两个 node 进程:

  1. 10000 端口的 main 应用;
  2. 10001 端口的 login 应用

因为 10001 端口的 login 应用没有做独立部署运行,所以我们只能打开 10000 端口的 main 应用,依靠 main 应用去获取 login 应用的资源来进行显示。

打开 main 我们会得到一个错误:

没错,这个错误提示我们需要一个类似于 <router-view /> 的东西去显示我们 login 应用里面的内容。

main 加载显示 login 页面

进入 main 应用修改它的 App.vue 添加相关代码:

html 部分:

<template>
    <div id="root" class="root-container">
        <!-- 有 Token 进入子应用视图 -->
        <template v-if="hasToken">
            <!-- 左侧菜单区 -->
            <sidebar id="sidebar-container" class="sidebar-container"/>
            <!-- 右侧视图 -->
            <div class="container-panel">
                <div class="main-container-content">
                    <!-- 上部导航区 -->
                    <Nav />
                    <!-- 子应用渲染区 -->
                    <div class="main-container-view">
                        <el-scrollbar class="main-scroll">
                            <div v-for="subapp in subappList" :id="subapp.key" :key="subapp.key" v-show="$route.path.startsWith(`/${subapp.pathPrefix}`)"></div>
                        </el-scrollbar>
                    </div>
                </div>
            </div>
        </template>
        <!-- 没有 Token 进入登录视图 -->
        <div
            v-else
            id="subapp-viewport-login"
            v-show="$route.path.startsWith('/login/')"
        ></div>
    </div>
</template>

javascript 部分:

<script>
import { Sidebar, } from '@/components/sidebar';
import Nav from '@/components/nav/Nav.vue';
import defaultSettings from '@/settings';

export default {
    name: 'mainView',
    components: {
        Sidebar,
        Nav,
    },
    computed: {
        hasToken() {
            return !!this.$store.getters.token;
        },
        subappList() {
            return Object.keys(defaultSettings.MENUS).map(key => ({
                key: `${defaultSettings.SUB_APP_PREFIX}-${key}`,
                pathPrefix: key,
            }));
        },
    },
};
</script>

样式部分:

<style lang="scss">
@import '@/styles/mixin.scss';
html,
body {
    margin: 0;
    padding: 0;
    height: 100%;
}
.root-container {
    display: flex;
    width: 100%;
    height: 100%;
}
.container-panel {
    position: relative;
    flex: 1;
    height: 100vh;
    display: flex;
    flex-flow: column;
    overflow-x: overlay;
    overflow-y: hidden;
    .main-container-content {
        flex: 1;
        display: flex;
        flex-flow: column;
        height: 100%;
        overflow-x: overlay;
        overflow-y: hidden;
        .main-container-view {
            background: $white;
            box-sizing: border-box;
            overflow-y: overlay;
            height: 100%;
            .main-scroll {
                width: 100%;
                height: 100%;
                background: #fff;
                border-radius: 4px;
                .el-scrollbar__wrap {
                    overflow-x: hidden;
                }
            }
        }
    }
}
[id^='subapp-viewport'] {
    width: 100%;
    height: 100%;
    box-sizing: border-box;
    [id^='__qiankun_microapp_wrapper_'] {
        height: 100%;
    }
}
</style>

完成上述步骤我们再重新跑一下 login 应用,效果如图:

那现在 main 应用 和 login 应用都起来了,我们可以把 login 页面做漂亮一点,做个简单的登录,其实就是为了设置 token

有了 token 之后,我们在 settings 里面配置的 MENUS 菜单才有意义。

因为菜单渲染这一块是业务强相关的,所以在文章里面就不放代码了,不过关键的逻辑无非循环或递归去实现的,有兴趣的同学可以看我文章尾部放出来的小 demo。

我们需要在拿到后台给的菜单数据之后,立马去调用 app-register 里面 qkAppStart 方法进行「子应用」加载,同时也进行菜单的渲染。

根据上图可以看出刷新的时候去加载了:

  1. system 应用: http://localhost:10002/
  2. order 应用: http://localhost:10003/

由于暂未完善 systemorder 的配置,并且没有启动,所以造成这两个应用标红报错。

我们可参照 mainlogin 的配置,来完成 systemorder 的初始化工作,只要完成其中一个子应用,即可根据其复制粘贴配置其它子应用(前提是都基于 Vue技术栈),然后稍作修改:

  1. 挂载点 ID : public/index.html 中 divid
  2. 端口配置: env.development 对应 main 里面 settings 的端口
  3. 子应用里面 core/life-cycle 中 subappKey 对应 public/index.html 改过的挂载点 id
  4. 子应用 vue.config.js 中 configureWebpack.output.library

效果展示

我们看下最终的效果:

浏览器地址栏输入 http://localhost:10000 会通过 main 应用的 router 重定向到http://localhost:10000/login/ ,地址栏上的 login 后面要带斜杠不然无法触发 login 应用去加载,会造成 <div id="app-login"></div> 里面内 DOM 结构的问题。

注意事项

1. 子应用的 router

子应用的 src/router 文件夹里面此时导出的应当是 routes 数组,而不是 vue-router 的实例。

2. SyntaxError: entry should not be empty

在实际业务场景中如果出现 SyntaxError: entry should not be empty! 这个错误是因为 entry 没有配置对,建议使用 ip 或者域名。

3. qkAppStart 里面 activeRule 的路径

一定要注意 qkAppStart 里面 activeRule 的路径,一般是 /${子应用名称} 不然会遇到到至无法预料的问题,连排查都不好排查。

4. 全量加载所有子应用

如果一进入会加载全部的子应用并显示 DOM,那是因为在 app-registry.js 中没有设置 setDefaultMountApp 默认挂载的子应用。

5. 主应用子应用未能共享一个 Vuex

Vuex 我们试验过各种方案,目前采用的是将「主应用」 Vuex 暴露给「子应用」使用,如果各位有更好的方案请不吝分享。

主应用的 Vuex 已经通过 app-registry.js 里面的 qkAppStart 下发到子应用在子应用 life-cycle 中 renderSubchild 的封装方法中,自动绑定在 Vue 上。

「子应用」使用 Vuex :

import { mapParentGetters, } from '@fe-micro/micro-core'; 
// or
this.$parentStore

应用间的 keep-alive

应用间 keep-alive 的核心代码如下:

export const renderSubchild = ({ routes, routerBase, container, subappKey, store, parentStore, mountPoint, i18n, } = {}) => {
    // 踩坑点:一定要在方法里面进行这个的获取,要结合子应用里面 life-cycle 设置的。
    const { __CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__, } = window;
    let instance = null;
    const router = new VueRouter({
        mode: 'history',
        base: routerBase,
        routes: routes,
    })
    router.onError(err => {
        console.log(err, 35);
        window.history.pushState(null, null, '/404')
    });
    /*
        set subapplication's routes
        will need when add tag
    */
    LocalStorage.setSubapplicationRoutes(routes.map(i => {
        const prefix = i.path.slice(0, routerBase.length + 1);
        let path = i.path;
        if (prefix !== `${routerBase}/`) {
            path = `${routerBase}${path}`;
        }
        return {
            ...i,
            path,
        }
    }));
    Vue.prototype.$parentStore = parentStore;
    if (__POWERED_BY_QIANKUN__ && __CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__) {
        const cachedInstance = __CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE__;

        // 从最初的Vue实例上获得_vnode
        const cachedNode = cachedInstance._vnode;

        // 让当前路由在最初的Vue实例上可用
        router.apps.push(...cachedInstance?.catchRoute?.apps);

        instance = new Vue({
            router,
            store,
            i18n,
            render: () => cachedNode
        });
        // 缓存最初的Vue实例
        instance.cachedInstance = cachedInstance;

        router.onReady(() => {
            const { path } = router.currentRoute;
            const { path: oldPath } = cachedInstance.$router.currentRoute;
            // 当前路由和上一次卸载时不一致,则切换至新路由
            if (path !== oldPath) {
                cachedInstance.$router.push(path);
            }
        });
        instance.$mount(container ? container.querySelector(subappKey) : subappKey);
    } else {
        instance = new Vue({
            router,
            store,
            i18n,
            render: h => h(mountPoint)
        }).$mount(container ? container.querySelector(subappKey) : subappKey)
    }
    return { originInstance: instance, originRouter: router };
};

然后再就是 life-cycle.js 里面的 unmount 的全部代码。如果想要实现按需 keep-alive 只要在卸载之前 instance.$destroy()window.**CACHE_INSTANCE_BY_QIAN_KUN_FOR_VUE** 赋值为 false 而不是 cachedInstance

代码也是来自 qiankun 官网的 issues。

那做完这些我们再来演示一下应用间的 keep-alive: 应用间的 keep-alive

最后来演示一下我们本地开发的项目:

本地开发项目

Demo 地址

附上 Demo 地址,感兴趣的小伙伴们可自行查阅:

功能简称应用
main主应用demo-web-main
login登录应用demo-web-login
system系统应用demo-web-system
order商品应用demo-web-order
server后台获取菜单(eggjs)demo-server

参考文章:

以上便是本次分享的全部内容,希望对你有所帮助 ^_^

喜欢的话别忘了动动手指,点赞、收藏、关注三连一波带走。


关于我们

我们是万拓科创前端团队,左手组件库,右手工具库,各种技术野蛮生长。

一个人跑得快,不如一群人跑得远。欢迎加入我们的小分队,牛年牛气轰轰往前冲。

VANTOP前端团队