tob系统微前端实践总结__Vue.js__前端
发布于 3 年前 作者 banyungong 1579 次浏览 来自 分享
粉丝福利 : 关注VUE中文社区公众号,回复视频领取粉丝福利

theme: juejin highlight: a11y-dark

一、背景

20年毕业到现在,主要做tob业务,负责一条业务线下的若干个产品,这些产品都是服务于一个大平台。

1.1 现状分析

由于历史原因,系统刚交接过来的时候情况是:

  1. 子系统都是独立的,都有一个html入口,子产品通过一个静态(这里的静态是指数据)的聚合页上连接成为一个“大平台”,这个聚合页放在某一个子产品系统工程中;每个子系统有一个切换子系统的菜单组件(以满足用户方便切换系统),也就是说在n个工程维护了n个一样的组件;且切换系统时,整个页面刷新,用户体验很差

  2. “大平台”中各子产品通用的业务功能模块(如审批流、字典、权限)都放在一个独立的子工程中(Z系统),目前这个子工程与其他子产品工程一样交互逻辑一样,也就是说当用户在使用A产品时,想要改A产品的审批时,需要先切换到Z系统,在Z系统中切换到A的管理,再点击审批菜单,才能修改审批

  3. 存在需求:产品插拔式与外部产品联合能力;比如咱们的产品(A、B、C、D、E、F),外部产品A2;外部产品A3,B3,C3;存在需求联合产品【A,B,C,A2】、【A,B,C,D,A3,B3,C3】等等。目前解决方案,新增一个静态聚合页,放不同的产品链接,也就是说维护了多个聚合页;而子系统里面的切换子系统菜单组件未做处理,切换菜单数据永远是(A、B、C、D、E、F)

  4. “大平台”做了单点登录,即在某一个产品下登录成功,其他产品也登陆成功;单个产品独立运行时无问题,但在一个大平台中,每个产品登录校验表现的不一致(子产品处理的不一样)

经过一番调研,决定使用当下比较火的qiankun来完成改造

1.2 解决方案

  1. “大平台”缺少portal(父容器)的概念
    • 新增portal工程,作为html入口,子工程(具体业务工程)通过portal工程按需加载,子系统间切换不再刷新
    • 聚合页和切换子系统菜单都应该在portal工程中,而不是在子系统工程里面,子产品系统应该只需关注自身产品业务
    • 平台缺少工作台的概念,现状仅仅是将几个产品聚合在一起,产品体验很差,在portal工程可加入工作台
  2. 通用的业务功能模块(审批、字典、权限)应该属于portal的一部分能力,历史原因已经在Z工程,出于成本考虑,依旧放在Z工程,但入口可与其他子系统的入口区分开来,把Z系统分成几个portal入口【字典】【审批】【权限】,再利用子系统通信,Z系统默认选中用户上一次操作的子系统。同样的例子:当用户在使用A产品时,想要改A产品的审批时,只需直接点击审批菜单即可
  3. 产品缺少配置化概念,聚合页和切换子系统菜单应该都只需要维护一个页面or组件,页面or组件上子产品数据通过给不同的角色分于不同的产品权限实现可配置化
  4. 子项目无需影响自身业务,只是进行了一些改造,接入微前端这套架构。子产品的登录、退出等功能,当在独立运行时,走自身的处理逻辑,当在“大平台”下时,调用portal的登录、退出功能,交互保持一致。
  5. 其他:子项目原本需要加载的公共部分(如vue、vuex、vue-router、element、私有npm包等),全部由主项目调度,配合webpack的externals功能通过外链的方式按需加载

二、技术实现

qiankun官网:https://qiankun.umijs.org/

前端工程技术栈都是Vue+webpack,vueCli3搭建

实现分析

  • 主应用:搭建主应用工程,作为子应用聚合的容器
  • 子应用:现有工程改造适配接入主应用

2.1 主应用

demo地址:https://github.com/zxyue25/micro-portal-demo.git

step0 创建工程

// vuecli3创建
vue create micro-portal-demo

step1 安装 qiankun

npm i qiankun -S

step2 在主应用中注册微应用

qiankun的文档: image.png 其中:

qiankun实现原理:当微应用信息注册完之后,一旦浏览器的 url 发生变化,便会自动触发 qiankun 的匹配逻辑,所有 activeRule 规则匹配上的微应用就会被插入到指定的 container 中,同时依次调用微应用暴露出的生命周期钩子

新建src/micro文件夹,将微前端相关的文档都放在其中,目录结构如下

├ src
├─ micro
│  │  ├─ app.js // 开发环境子系统配置
│  │  ├─ event.js // 事件总线及Portal提供给子系统的事件
│  │  ├─ index.js // 注册子系统及start方法
│  │  ├─ micro.vue // 提供子系统加载容器及相关逻辑
│  │  └─ route.js // 子应用路由
2.1.1 子系统管理

通过本地配置要集成的子系统,实现子系统集成

// src/micro/app.js
// 配置子系统文件,修改改文件,需要重启webpack
const subAppList = [
  {
    APP_NAME: 'subApp1',
    FE_ADDRESS: 'http://localhost:8084/subApp1',
    API_ADDRESS: '',
  },
  ...
]
module.exports = subAppList
2.1.2 子系统加载

子系统加载,portal系统需要获取子系统的静态资源以及代理子系统接口,并提供子系统加载的容器

2.1.2.1 子系统容器

通过路由判断当前是否为子系统,确定是否展示子系统容器

// src/micro/micro.vue
<template>
  <div v-if="subAppActive" id="micro-sub-app"></div>
</template>

<script>
import store from '@/store'
import subAppList from '@/micro/app'
import { start } from '@/micro'
import { mapState } from 'vuex'
export default {
  data() {
    return {
      subAppList,
    }
  },
  computed: {
    ...mapState(['subAppActive']),
  },
  watch: {
    $route(val) {
      this.handleRouteChange(val)
    },
  },
  beforeRouteEnter(to, from, next) {
    next((vm) => {
      vm.handleRouteChange.apply(vm, [to])
    })
  },
  mounted() {
    if (!window.subappRegister) { //注册一次
      window.subappRegister = true
      start()
    }
  },
  methods: {
    // 监听路由变化,判断访问的是否是子系统
    handleRouteChange() {
      const bol = this.isMicroSub(this.subAppList, this.$route.path)
      store.commit('TOGGLE_SUBAPPACTIVE', bol)
      if (bol) {
        // 获取当前访问的子系统
        const microSub = this.getMicroSub(this.subAppList, this.$route.path)
        // 添加 hash模式全屏
        if (
          this.$route.path.startsWith(`${microSub.entry}/full`) ||
          (this.$route.hash && this.$route.hash.startsWith('#/full'))
        ) {
          // mounted后执行
          setTimeout(() => {
            window.eventCenter.emit('SYSTEM_FULL_SCREEN')
          })
        } else if (window.__IS_FULL_SCREEN) {
          window.eventCenter.emit('SYSTEM_EXIT_FULL_SCREEN')
        }
      } else {
        this.$router.replace({
          path: '/404',
        })
      }
    },
    // 检测路由是否为子应用
    isMicroSub(list, path) {
      return list.some((item) => {
        const context = `/${item.APP_NAME}`
        return path.startsWith(context)
      })
    },
    // 获取激活的子应用
    getMicroSub(list, path) {
      return list.find((item) => {
        const context = `/${item.APP_NAME}`
        return path.startsWith(context)
      })
    },
  },
}
</script>
// src/micro/route.js
import Main from '@/components/main';
const Micro = () => import(/* webpackChunkName: "micro" */ './micro.vue');

export default {
  path: '/',
  component: Main,
  children: [
    {
      path: '*',
      component: Micro,
    },
  ],
};
import MicroRoute from '@/micro/route';
const routes = [...]
routes.push(MicroRoute)
const router = new Router({
  routes,
  mode: 'history',
})
router.beforeEach((to, from, next) => {
  const bol = subAppList.some((item) => {
    const context = `/${item.APP_NAME}`
    return to.path.startsWith(context)
  })
  store.commit('TOGGLE_SUBAPPACTIVE', bol)
  next()
})
2.1.2.2 静态资源代理/接口代理

开发环境:通过webpack proxy实现静态资源代理以及接口代理

// vue.config.js
const appName = process.env.VUE_APP_NAME
const publicPath = process.env.VUE_APP_ENV === 'production' ? `/${appName}/` : '', // 部署相关配置,具体说明见部署章节
const subAppList = require('./src/micro/app')
let proxyObjs = {}

subAppList.map((item) => {
  const proxyObj = {
    [`/${item.APP_NAME}/_baseAPI`]: { // 接口代理
      target: item.API_ADDRESS,
      changeOrigin: true,
      pathRewrite: {
        [`^/${item.APP_NAME}/_baseAPI`]: '',
      },
    },
    [`/${item.APP_NAME}`]: { // 静态资源代理
      target: item.FE_ADDRESS,
      secure: false,
      bypass(req) {
        if (req.headers.accept && req.headers.accept.indexOf('html') !== -1) { // 由于portal是单页面应用,portal系统通过 /APP_NAME 访问子系统时 应该返回portal的html
          return '/portal/index.html'
        }
      },
      pathRewrite: {
        [`^/${item.APP_NAME}`]: '',
      },
    },
  }
  proxyObjs = { ...proxyObjs, ...proxyObj }
})

module.exports = {
  productionSourceMap: false,
  publicPath,
  devServer: {
    compress: true,
    // host: 'portal.fe.com',
    port: 8082,
    hotOnly: false,
    disableHostCheck: true,
    headers: { 'Access-Control-Allow-Origin': '*' },
    proxy: {
      ...proxyObjs,
    },
  },
  configureWebpack: {
    output: {
      libraryTarget: 'umd',
      library: appName,
      jsonpFunction: `webpackJsonp_${appName}`,
    },
  },
}

生产环境: 通过nginx实现静态资源及接口代理

server {
        listen 80;

        server_name portal.demo;
        root /export/fe;

        gzip on;
        gzip_buffers 32 4K;
        gzip_comp_level 6;
        gzip_min_length 100;
        gzip_types application/javascript text/css text/xml;
        gzip_vary on;

        location / {
                try_files $uri /portal/index.html;
        }
        # 所有系统入口html不需要缓存
        location ~ index.html$ {
                add_header Cache-Control no-store;
        }

        location /subApp1 {
                #浏览器直接输入子系统地址,先去portal
                if ($http_accept ~ html) {
                        rewrite ^/(.*) /portal/index.html last;
                }
                try_files $uri /subApp1/index.html;
        }
        location /subApp1/_baseAPI {
                proxy_pass http://subApp1/;
        }
    }

整体流程:

image.png

2.1.3 系统通信
事件总线

基于 eventemitter3 创建事件总线并挂载到window.eventCenter对象上,具体使用为:

  • 触发事件 window.eventCenter.emit(事件类型,传递参数)
  • 监听事件 window.eventCenter.on(事件类型,回调方法)
// src/micro/event.js
import { messageBus as eventCenter } from '@/utils/message-bus';
import router from '@/router';
import { logout } from '@/utils/util';
import { Message } from 'element-ui';

const showMessage = ({ showClose = true, message, type = 'error' } = {}) => {
  Message({
    showClose,
    message,
    type,
  });
};

const SYSTEM_FORBIDDEN = 'SYSTEM_FORBIDDEN';
const SYSTEM_USER_INVALID = 'SYSTEM_USER_INVALID';
const SYSTEM_LOGOUT = 'SYSTEM_LOGOUT';
const SYSTEM_FULL_SCREEN = 'SYSTEM_FULL_SCREEN';
const SYSTEM_EXIT_FULL_SCREEN = 'SYSTEM_EXIT_FULL_SCREEN';

/**
 * 401 跳转无权限页
 * @param router
 */
const forbidden = () => {
  showMessage({
    message: '真抱歉!您没有权限访问',
  });
  setTimeout(() => {
    router.push('/401');
  }, 500);
};

/**
 * 触发事件
 * @param type
 */
const eventEmit = (type) => {
  if (window.eventCenter) {
    window.eventCenter.emit(type);
  }
};

/**
 * 初始化 Event
 * @param router
 */
export const initEvent = () => {
  if (window.eventCenter) return;

  // 声明事件总线
  window.eventCenter = eventCenter;

  // 监听全局系统 401无权限页
  window.eventCenter.on(SYSTEM_FORBIDDEN, () => forbidden());

  // 监听全局系统 登录态失效
  window.eventCenter.on(SYSTEM_USER_INVALID, () => logout());

  // 监听全局登出操作
  window.eventCenter.on(SYSTEM_LOGOUT, () => logout());

  // 全屏
  window.eventCenter.on(SYSTEM_FULL_SCREEN, () => {
    window.__IS_FULL_SCREEN = true;
    const headDom = document.querySelector('.sys-head');
    const asideDom = document.querySelector('.portal-app-aside');
    if (headDom) {
      headDom.style.display = 'none';
    }
    if (asideDom) {
      asideDom.style.display = 'none';
    }
  });

  window.eventCenter.on(SYSTEM_EXIT_FULL_SCREEN, () => {
    window.__IS_FULL_SCREEN = false;
    const headDom = document.querySelector('.sys-head');
    const asideDom = document.querySelector('.portal-app-aside');
    if (headDom) {
      headDom.style.display = 'none';
    }
    if (asideDom) {
      asideDom.style.display = 'none';
    }
  });
};

/**
 * 主应用直接调用、防止第一次加载时 Event 没有注册
 */
export const baseSystemLogout = () => logout();
export const baseSystemForbidden = () => forbidden();

/**
 * 子应用必须通过 Emit 通知、通过 active 子应用去下发
 */
export const systemForbidden = () => eventEmit(SYSTEM_FORBIDDEN);
export const systemLogout = () => eventEmit(SYSTEM_LOGOUT);

message-bus.js

// src/utils/message-bus.js
import Event from 'eventemitter3';

export const messageBus = new Event();
数据传递

在主应用中利用qiankun API initGlobalState定义全局状态,并返回通信方法,微应用通过 props 获取通信方法

// src/micro/index.js
import {
  registerMicroApps,
  addGlobalUncaughtErrorHandler,
  removeGlobalUncaughtErrorHandler,
  initGlobalState,
  start as s,
} from 'qiankun'
import { initEvent } from './event'
import { Loading, Message } from 'element-ui'
import subAppList from './app'
import store from '@/store'

// 主应用与子应用通信
const initialState = {
  preActiveApp: store.state.preActiveApp,
}

// 初始化 state
const actions = initGlobalState(initialState)

actions.onGlobalStateChange((state) => {
  //监听公共状态的变化
  store.commit('CHANGE_PREACTIVEAPP', state.preActiveApp)
})

const consoleStyle = 'color:#fff;background:#2c68ff;line-height:28px;padding:0 40px;font-size:16px;'

/**
 * 注册-子应用集合
 * @param subAppList
 */
export const register = () => {
  let loading
  try {
    const subApps = subAppList.map((subApp) => {
      const { APP_NAME, FE_ADDRESS } = subApp
      return {
        name: APP_NAME,
        entry: process.env.VUE_APP_ENV === 'production' ? `/${APP_NAME}` : FE_ADDRESS, //生产环境与开发环境不一致
        container: '#micro-sub-app', // 主应用承接子应用容器id
        activeRule: (location) => location.pathname.startsWith(`/${APP_NAME}`),
        props: { preActiveApp: store.state.preActiveApp }, // 传递通信数据
      }
    })
    registerMicroApps(subApps, {
      beforeLoad: [
        async (app) => {
          console.log(`%c${app.name} before load`, consoleStyle)
          loading = Loading.service({
            target: '#micro-sub-app',
            lock: true,
            text: ' ',
            spinner: 'base-loading-type1',
            background: 'hsla(0,0%,100%,.8)',
          })
        },
      ],
      beforeMount: [
        async (app) => {
          console.log(`%c${app.name} before mount`, consoleStyle)
          const body = document.getElementsByTagName('body')[0]
          if (body) {
            body.setAttribute('id', app.name)
            body.setAttribute('class', app.name)
          }
        },
      ],
      afterMount: [
        async (app) => {
          console.log(`%c${app.name} after mount`, consoleStyle)
          setTimeout(() => {
            loading.close()
          }, 1000)
        },
      ],
      beforeUnmount: [
        async (app) => {
          actions.setGlobalState({
            preActiveApp: app.name ? app.name : store.state.preActiveApp, // 在上一个微应用卸载前记录appName
          })
        },
      ],
      afterUnmount: [
        async (app) => {
          console.log(`%c${app.name} after unmount`, consoleStyle)
          const body = document.getElementsByTagName('body')[0]
          if (body) {
            body.setAttribute('id', '')
          }
        },
      ],
    })
  } catch (e) {
    throw new Error(e)
  }
}
/**
 * 初始化事件
 */
const microEvent = () => {
  // 监听子应用加载失败
  addGlobalUncaughtErrorHandler((e) => {
    if (
      e instanceof PromiseRejectionEvent ||
      e.message === 'ResizeObserver loop limit exceeded'
    ) {
      return
    }
    const eMessage = e.message || ''
    if (
      eMessage.search('died in status LOADING_SOURCE_CODE') !== -1 ||
      eMessage.search('died in status SKIP_BECAUSE_BROKEN') !== -1
    ) {
       Message({
          message: '系统注册失败,请稍后重试',
          type: 'error',
        })
    } else if (eMessage.search('Failed to fetch') !== -1) {
      Message({
        message: '资源未找到,请检查是否部署',
        type: 'error',
      })
    }
    removeGlobalUncaughtErrorHandler((error) => console.log('remove', error))
  })
  
  /**
   * 初始化全局事件
   */
  initEvent()
}

microEvent()

function getPublicPath(entry) {
  if (typeof entry === 'object') {
    return '/'
  }
  try {
    // URL 构造函数不支持使用 // 前缀的 url
    const { origin, pathname } = new URL(
      entry.startsWith('//') ? `${location.protocol}${entry}` : entry,
      location.href
    )
    const paths = pathname.split('/')
    // paths.pop();
    const r = `${origin}${paths.join('/')}/`
    return r
  } catch (e) {
    console.warn(e)
    return ''
  }
}
/**
 * 启动路由监听
 * @param prefetch
 * @param appList
 * @returns {Promise<unknown>}
 */
export function start({ prefetch = false } = {}) {
  return new Promise((resolve, reject) => {
    try {
      // 注册
      register()
      // 启动
      s({ prefetch, sandbox: false, getPublicPath })
      resolve()
    } catch (e) {
      Message({
        message: '系统注册失败,请稍后重试',
        type: 'error',
      })
      reject(e)
    }
  })
}

2.2 微应用

demo地址:子应用:https://github.com/zxyue25/micro-subapp-demo.git step0 创建工程

// vuecli3创建
vue create micro-subapp-demo

step1 修改入口文件,导出相应的生命周期钩子

微应用不需要额外安装任何其他依赖即可接入 qiankun 主应用。在main.js导出 bootstrap、mount、unmount 三个生命周期钩子,以供主应用在适当的时机调用

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

let instance = null
function render(props = {}) {
  const { container } = props

  instance = new Vue({
    router,
    store,
    render: (h) => h(App),
  }).$mount(container ? container.querySelector('#app') : '#app')
}

// 独立运行时
if (!window.__POWERED_BY_QIANKUN__) {
  render()
}

export async function bootstrap() {
  console.log('[vue] vue app bootstraped')
}
export async function mount(props) {
  console.log('[vue] props from main framework', props)// 接受主应用传过来的数据
  render(props)
}
export async function unmount() {
  instance.$destroy()
  instance.$el.innerHTML = ''
  instance = null
}

step 2. 配置微应用的打包工具

// vue.config.js
const appName = process.env.VUE_APP_NAME
const publicPath = `/${appName}/`

module.exports = {
  publicPath,
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
    // host: 'subapp1.fe.com',
    port: '8083',
    proxy: {
      [`${publicPath}_baseAPI`]: {
        target: 'http://subapp1.be.com',
        changeOrigin: true,
        pathRewrite: {
          [`^${publicPath}_baseAPI`]: '',
        },
      },
    },
  },
  configureWebpack: {
    output: {
      library: appName,
      libraryTarget: 'umd', // 把微应用打包成 umd 库格式
      jsonpFunction: `webpackJsonp_${appName}`,
    },
  },
}

step 3. 配置微应用集成到主应用时,隐藏头部

subApp1子应用独立运行截图: image.png 主应用运行截图: image.png 在主应用src/micro/app.js中配置subApp1,在主应用中集成subApp1运行截图: image.png 可以看到子应用成功集成在主应用的id=micro-sub-app的容器,但是头部也出来了

// src/constants.js
if (window.__POWERED_BY_QIANKUN__) {
  __webpack_public_path__ = window.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
}

const isMicro = window.__POWERED_BY_QIANKUN__ || false
const publicPath = __webpack_public_path__ || ''

export {
  isMicro,
  publicPath
}
// header.vue
import { isMicro } from "@/constants";

通过isMicro判断,isMicro为true隐藏头部,否则显示头部。如下图隐藏成功,可以看到通过props传过来的preActiveApp也接收到了 image.png step4 子应用统一调用主应用的登录校验失败事件

// src/utils/https.js
axiosInstance.interceptors.response.use(
  (response) => {
    const { data } = response
    if (data.code == '20001' || data.error === 'NotLogin') {
      // window.location.href = ''
    } else if (data.code == '20002' || data.error === 'NotPermission') {
      if (!isMicro) {
        router.replace({ path: '/401' })
      } else {
        window.eventCenter && window.eventCenter.emit('SYSTEM_USER_INVALID') // 
      }
    }
    ...
  },
  (error) => {
    ...
  }
)

2.3 其他

2.3.1 部署(nginx)

主应用,微应用webpack配置加publicPath: /${appName}/,主应用的appName为portal,子应用依次为subApp1subApp2...;用路径区分不同资源,防止加载资源404

server {
        listen 80;

        server_name portal.demo;
        root /export/fe;

        gzip on;
        gzip_buffers 32 4K;
        gzip_comp_level 6;
        gzip_min_length 100;
        gzip_types application/javascript text/css text/xml;
        gzip_vary on;

        location / {
                try_files $uri /portal/index.html;
        }
        # 所有系统入口html不需要缓存
        location ~ index.html$ {
                add_header Cache-Control no-store;
        }

        location /subApp1 {
                #浏览器直接输入子系统地址,先去portal
                if ($http_accept ~ html) {
                        rewrite ^/(.*) /portal/index.html last;
                }
                try_files $uri /subApp1/index.html;
        }
        
        location /subApp1/_baseAPI {
                proxy_pass http://subApp1-demo/;
        }
        
        location /subApp2 {
                #浏览器直接输入子系统地址,先去portal
                if ($http_accept ~ html) {
                        rewrite ^/(.*) /portal/index.html last;
                }
                try_files $uri /subApp2/index.html;
        }
        
        location /subApp2/_baseAPI {
                proxy_pass http://subApp2-demo/;
        }
        
        ...
}
2.3.2 打包部署后css中的字体文件404

原因是:qiankun将外链样式改成了内联样式,但是字体文件和背景图片的加载路径是相对路径。而 css 文件一旦打包完成,就无法通过动态修改 publicPath 来修正其中的字体文件和背景图片的路径

link标签以CDN形式引用,微应用加上ignore属性,防止重复加载资源

主应用

// public/index.html
<link rel="stylesheet" href="https://unpkg.com/element-ui/lib/theme-chalk/index.css"/>

微应用

// public/index.html
<link rel="stylesheet" ignore href="https://unpkg.com/element-ui/lib/theme-chalk/index.css"/>
2.3.3 公共资源处理

子项目原本需要加载的公共部分(如vue、vuex、vue-router、element、私有npm包等),全部由主项目调度,配合webpack的externals功能通过外链的方式按需加载

三、思考

微前端本质: 让不同技术栈的SPA跑在同一套路由下,无论是single-spa还是qiankun,包括其他现存的微前端方案,其实用的都是一样的思路,往umd的全局变量上挂生命周期,然后在路由切换的时机去执行它们,都是同一个套路,但都不靠谱

发展:

  1. webpack5 module federation Module Federation | webpack,如果不考虑沙箱,单纯只是为了不同技术栈共存,那么wmf就可以做到,但是有一点,就是要借助webpack,这玩意就是一个runtime的code splitting,是挂到webpack全局变量上的,本质其实和挂umd类似

  2. Realm API tc39/proposal-realms一个新提案,天然的微前端沙箱,虽然不知道等到猴年马月

  3. portals WICG/portals也是新提案,iframe的替代品

上面这些新的提案,和我们通过umd实现的模块共享,通过Proxy实现的沙箱,如出一辙,一旦这些提案真的落地,那其实完全就不需要框架了,所以回到问题本身,当前市面上的前端有且只有一种,换汤不换药,就一种方案,谈不上真假,至于未来,随着各种新提案的跟进,沙箱,依赖共存,会成为常态,直接内置不需要二次封装了

版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: jjjona0215 原文链接:https://juejin.im/post/6974664014987001864

回到顶部