微前端的应用

362 阅读9分钟

前言

微前端随着前端业务场景越来越复杂,微前端这个概念最近被提起得越来越多,业界也有很多团队开始探索实践并在业务中进行了落地。可以看到,很多团队也遇到了各种各样的问题,但各自也都有着不同的处理方案。目前,我司开发后台管理系统和平台化一体系统都采用了微前端的方式,以此应对物理层和逻辑层的复用。

以商城后台管理系统为例,系统包含以下几个子模块:

  • item 商品管理
  • basis 基础管理
  • order 订单管理
  • account 结算管理

针对业界及公司周边的微前端方案调研,主要有以下几种方案以及它们各自主要的特点:

  • NPM式:子工程以NPM包的形式发布源码;打包构建发布还是由基座工程管理,打包时集成。
  • iframe式:子工程可以使用不同技术栈;子工程之间完全独立,无任何依赖;基座工程和子工程需要建立通信机制;无单页应用体验;路由地址管理困难。
  • 通用中心路由基座式:子工程可以使用不同技术栈;子工程之间完全独立,无任何依赖;统一由基座工程进行管理,按照DOM节点的注册、挂载、卸载来完成。
  • 特定中心路由基座式:子业务线之间使用相同技术栈;基座工程和子工程可以单独开发单独部署;子工程有能力复用基座工程的公共基建。

最后采用了 特定中心路由基座式的开发方案 的方式(有机会重构为通用),主要有以下几个好处:

  • 子工程之间开发互相独立,互不影响。(纯业务代码,不参杂打包构建代码)
  • 子工程可单独打包、单独部署上线。(传统:打包后更换项目服务器静态资源,重启加载,流程长,时间多。接口发布:直接打包上传到数据库和文件服务器)
  • 子工程有能力复用基座工程的公共基建。(数据、方法、环境变量、常量)
  • 保持单页应用的体验,子工程之间切换不刷新。(spa,传统sso不同模块的项目之间切换,会重复加载很多共同的资源,不利于代码复用)

为了方便基座和子工程的安装、开发、构建、发布,在他们中间搭建维护了CLI工程,最后的项目架构如下图

各个子模块都可以单独运行开发、打包,依赖framework-cli和framework-lte两个模块包,相互独立的同时又能在当前模块访问到各个模块的内容。

基座模块framework-lte也可以做到上面相同的功能,是不是很神奇,让我来揭开它神秘的面纱吧!let us

framework-cli

CLI一般指命令行界面。命令行界面(英语:command-line interface,缩写:CLI)。在前端领域里,通常是基于commander为核心库,以及配合周边的一些界面工具库、方法工具库等完成一些特定的功能,比如对文件或数据的增删查改发送请求打包构建,比如webpack-clivue-cli都是很强大、优秀的产出

上图可见,在我们的项目当中framework-cli扮演着不可或缺的使命,比如:

  • package (子模块打包)
  • build (基座打包)
  • publish (子模块上传)
  • dev (开启开发环境)
  • create (生成子模块)

package

webpack支持我们在node中调用,参数也很简单,一个配置对象,一个回调函数,缩略代码如下

const webpackConfig = reqruie('./webpack.package.conf');
const assetsRoot = path.resolve(__dirname, '../dist');
const assetsSubDirectory = 'static'
// 每次打包前,删除文件夹
rm(path.join(assetsRoot, assetsSubDirectory), err => {
    webpack(webpackConfig, (err, stats) => {
      ......
    })
})
// 这里我们关注一下webpack.package.conf里面的output配置
output: {
    library: `$${appName}`, // 将导出的内容赋值给`${appName}` 后面动态加载再提
    libraryTarget: 'umd'
  }

最后构造出来的目录:

|-- dist
|   |——static
|       |—— css
|       |—— js
|       |—— static
|   item.tar

build

再让我们对比来康康功能相似的build任务构造出来的目录:

|—— dist
|   |——static
|       |—— css
|       |—— js
|       |—— static
|       |—— ...
|   index.html

可以看出基座项目打包(build)和子模块的打包(package)主要有以下几个区别:

  • 基座项目会生成整个项目的index.html入口,子模块会生成tar包供发布使用
  • 两者的打包入口有区别
// 基座项目 下面的src/main.js
entry: {
    app: ['babel-polyfill', utils.frameworkPath('src/main.js')]
  }
// 子模块 下面的 src/index.js
let entry = webpackConfig['entry'] = {}
entry[appName] = utils.cwdPath(packageConfig.main) 

publish

参数为需要发布的域名,默认为xxx.xxx.xxx.cn.qa

// 获取当前发布模块package.json信息
const info = require(utils.cwdPath('package.json'))

module.exports = function publish(host) {
  // 获取上传域名
  host = (host || '').toLowerCase()
  // 引入form-data 并实例化
  const form = new FormData({})
  // 通过文件系统模块获取通过package打包过后的tar包
  form.append('file',
    fs.createReadStream(utils.cwdPath(`dist/${info.name}.tar`)))
  // 追加module参数  
  form.append('module', info.name)
  let url = `http://${host}/api/framework/v1/module/file/upload`
  if (host.startsWith('https://') || host.startsWith('http://')) {
    url = `${host}/api/framework/v1/module/file/upload`
  }
  // 上传请求
  form.submit(url,
    (err, res) => {
      if (err || res.statusCode !== 200) {
        console.log(chalk.red('publish module failed'))
        console.log(chalk.red(err, res && res.statusCode))
        process.exit(1)
      }
      res.resume()
    })
}

dev

虽然基座项目framework-lte和各个子模块都是运行"dev": "node_modules/.bin/framework-cli dev"脚本,都是以src/main.js作为入口编译,基座项目就是找到自身src下面的文件,而子模块则需要找到node-module下面的framework-lte包,再找到对应的main.js。 每个项目下面都有config文件,下面有各种打包配置(包括代理)和环境配置,诸如dev、package等任务都可以从中获取配置,这样便可以灵活针对每个项目进行调整,方便开发

const config = require(path.join(process.cwd(), _path))
// 区分两种类型项目入口
exports.frameworkPath = function (_path) {
  if (process.env.NODE_ENV ==='development-framework') {
    return path.join(process.cwd(), _path)
  }
  return path.join(__dirname, '../../framework-lte', _path)
}

dev核心代码如下:

// express启动服务app
const app = express()
const compiler = webpack(webpackConfig)
// 内存型文件系统。webpack-dev-server 就是一个 express+webpack-dev-middleware 的实现。二者的区别仅在于 webpack-dev-server 是封装好的,除了 webpack.config 和命令行参数之外,很难去做定制型开发。而 webpack-dev-middleware 是中间件,可以编写自己的后端服务然后把它整合进来,相对而言比较灵活自由。
const devMiddleware = require('webpack-dev-middleware')(compiler, {
  publicPath: webpackConfig.output.publicPath,
  quiet: true
})
// 它可以实现浏览器的无刷新更新,这也是webpack文档里常说的模块热更新HMR(Hot Module Replacement)。HMR和热加载的区别是:热加载是刷新整个页面。
const hotMiddleware = require('webpack-hot-middleware')(compiler, {
  log: false,
  heartbeat: 2000
})
// api代理 基座或者子模块中配置的代理
Object.keys(proxyTable).forEach(function (context) {
  let options = proxyTable[context]
  if (typeof options === 'string') {
    options = {target: options}
  }
  app.use(proxyMiddleware(options.filter || context, options))
})

// 解决:当路由模式为history时,服务器端会根据浏览器中的请求地址去匹配资源,从而404
app.use(require('connect-history-api-fallback')())

// serve webpack bundle output
app.use(devMiddleware)

// enable hot-reload and state-preserving
// compilation error display
app.use(hotMiddleware)

const uris = (config.dev.uris || ['http://mro-op-local.yzw.cn.qa', 'http://mro-sp-local.yzw.cn.qa'])
  .map(item => item + `:${port}`)

devMiddleware.waitUntilValid(() => {
  uris.forEach(uri => {
    console.log('> Listening at ' + uri + '\n')
  })
  // when env is testing, don't need open it
  if (autoOpenBrowser && process.env.NODE_ENV !== 'testing') {
    uris.forEach(uri => {
      opn(uri)
    })
  }
  _resolve()
})

const server = app.listen(port)

module.exports = {
  close: () => {
    server.close()
  }
}

create

生成子模块的逻辑相对简单 1 事先定义好模块的目录和必须文件. 2 从命令行中拿到destination路径和定义好的source路径,调用copyfiles库进行文件操作. 3 特殊修改destination下面的package.json和index.js

const destination = process.argv[3]
const source = path.resolve(__dirname, '../template/**/*')

fs.mkdirSync(destination)

copyFiles([source, destination],
  e => {
    if (e) {
      process.exit(1)
    }
    let packagePath = path.resolve(destination, 'package.json')
    let content = fs.readFileSync(packagePath)

    let info = JSON.parse(content)
    info.name = destination
    fs.writeFileSync(packagePath, JSON.stringify(info, null, 4))
    let indexPath = path.resolve(destination, 'src/index.js')
    content = fs.readFileSync(indexPath, 'utf8')
    content = content.replace('${name}', destination)
    fs.writeFileSync(indexPath, content)
  })

framework-lte

花了很长的时间去理解cli的运作原理,现在我么来看看微前端的主角——framewrok-lte(基座项目),其核心原理是基于Vue的全局注册,将基座路由、数据、过滤器、指令和业务组件等全局化,使子模块可以通过this或$vm获取并使用。加载子模块则是通过接口的形式获取模路由对应的块模块对应文件在服务端的位置,然后动态加载的以及添加路由的过程

main.js是整个基座和子模块开发的入口文件,这个至关重要的入口文件主要做了如下几个事情,最后挂载在#app节点上,最终形成我们熟悉的单页面应用

  • import framework(注册全局指令、过滤器、http-ng、utils、公共业务组件等)
  • 加载路由(portal、404等,并注册路由守卫)
  • 加载vuex数据
  • 获取全局配置并挂载根结点

加载framework

这里为了满足子工程有能力复用基座工程的公共基建能力,注册了很多全局指令、方法、过滤器、组件等,组装成一个对象暴露出去(须包含install方法),调用Vue.use()

// framework.js
export {
  install: (Vue) => {
    // 获取所有业务组件 通过require.context注册全局组件
    const requireComponent = require.context(
      '../components/lib',
      false,
      /lte-[\w-]+\.(vue|js)$/
    )
    // 遍历注册业务组件
    requireComponent.keys().forEach(fileName => {
      let componentConfig = requireComponent(fileName)
      componentConfig = componentConfig.default || componentConfig
      Vue.component(componentConfig.name, componentConfig)
    })
    Vue.use(Router)
    Vue.use(ElementUI, {
      size: 'small'
    })

    Object.defineProperties(Vue.prototype, {
      $http: {
        get() {
          return http
        }
      },
      ...
    })
  }
}

加载路由

route.js主要定义了基座的一些基础路由,比如demo、权限页面等并导出,主要的是如对Vue-router路由守卫的enhance以及方法的集成和新增:

为了实现效果,新开发了LteRouter class,该类直接继承了vue-router的Router类,在此基础上增加了部分方法,下面我们伴随着路由守卫和定义的方法具体讲一下项目的动态路由加载。

守卫一:获取基座项目配置(全局配置,如上传、登录、sso等地址信息)
router.beforeEach((to, from, next) => {
  let vm = router.app
  config.load()
  .then(next)
  .catch(({description}) => {
    vm.$message.error(description)
  })
})
// 相关load方法
 load() {
    if (state.isConfigurationLoaded) {
      return Promise.resolve()
    }
    return Promise.all([axios.get(frameworkUrl.Settings), axios.get(frameworkUrl.CommonConfig)])
      .then(([configuration, commonConfig]) => {
        ...
        return Promise.resolve(state.configuration)
      })
  }
守卫二:动态匹配路由加载模块(根据标识item、account等加载对应模块)
router.beforeEach((to, from, next) => {
  const l = router.resolve({path: to.path})
  // resolved.matched 初始为空数组 加载过后会有匹配到的路由对象
  if (_.isEmpty(l.resolved.matched)) {
    router.loadModule(to, from, next)
  } else {
    next()
  }
})

下面我们按照调用栈一个个来分析流程:

router.loadModule

 loadModule(to, from, next) {
    // ["", "portal", "account", "admin", "home", "index"]
    // 截取到path的标识符
    let segments = to.path.split('/')
    let name = segments[2]
    let isModuleLoaded = this.app.$store.getters[Getter.ModuleLoaded]
    if (isModuleLoaded(name)) {
      return super.push({name: Route.NotFound})
    }
    // 加载模块
    loader.loadModule(name)
      .then(({routes, init}) => {
      
    // _buildRoutes 根据每个路由的layout:portal or preview 分类, 得到{ portal: { children: [], component: Com, name, path}}
        this.addRoutes(this._buildRoutes(name, routes))
        super.push({path: to.path, query: to.query, params: to.params, hash: to.hash})
      })
      .catch(e => {
        this.app.$logger.exception(e)
      })
  }

loader.loadModule

 loadModule(name) {
    let self = this
    // 当前如果是子模块的开发环境,直接加载@也就是子模块对应的入口 获取对象,{name, routes}
     if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !==
      'development-framework') {
      return require.ensure([],
        () => require('@'))
        .then(component => {
          if (component.default.name !== name) {
            return self._loadModule(name)
          }
          // 如果当前子模块和name匹配,直接返回
          return Promise.resolve(component.default)
        })
    }
    return self._loadModule(name)
  }

loader._loadModule

  _loadModule(name) {
    let self = this
    // getApp(name)获取对应模块信息(publish打包上传生成)
    // : {filePaths: [{name, url}], module, version}
    return framework.getApp(name)
      .then(app => {
        session.set(`${Session.ModuleVersion}/${name}`, app.version)
        let payload = {module: name, files: app.filePaths}
        store.commit(Action.FrameworkMenuModuleLoad, payload)
        return self._dynamicLoad(self._rearrangeFiles(app.filePaths))
      })
      .then(() => {
        return self._findRoutes(name)
      })
  }

loader._dynamicLoad

  _dynamicLoad(urls) {
    let self = this
    let promise = Promise.resolve()
    for (let url of urls) {
      if (_.endsWith(_.toLower(url.name), '.js')) {
        promise = promise.then(() => self._dynamicLoadScript(url.url))
      } else if (_.endsWith(_.toLower(url.name), '.css')) {
        self._dynamicLoadStyle(url)
      }
    }
    return promise
  }

loader._findRoutes

_findRoutes(name) {
    if (!window[`?{name}`]) {
      return Promise.reject(new Error('module not found'))
    }
    // 加载了对应的js  就能拿到对应模块导出的路由对象
    return Promise.resolve(window[`?{name}`].default)
  }

整体流程

整个动态路由加载流程就是拿到当前path,解析出模块名name,如果缓存中没有该模块,再判断当前开发环境是否子模块开发并且子模块名和我们刚才解析出来的模块名一致,如果是就直接返回子模块当中导出的路由对象,否则就先通过api拿到对应的filepaths,动态加载js(promise链式实现)、css,还记得当时我们提过的 library:?{appName}吗, webpack这个配置能使我们打包出来的东西赋给变量,供全局使用此对象,也就是对应模块的路由对象,最后通过结构化路由生成我们需要格式({ portal: { children: [], component: Com, name, path}}),调用vue-route的addroutes方法,最后再push到path即可

守卫三:路由鉴权

拿到模块路由和文件后,紧接着我们要做的就是路由鉴权,大致分为3步

  • 获取用户信息,并保存,配合vuex生成各种getter 方便业务代码功能、按钮鉴权
  • 根据当前用户查询系统配置好的菜单
  • 判断即将跳转的to.path是否存在用户自身的菜单中,如果没有跳转到403等页面

集成

大致了解了两个重要工具人的功能和实现,现在我们来详细说说它是怎么集成成我们的微前端的

framework-cli的发布

通过上面对它的介绍中,我们可以知道cli作为命令行界面,实现了很重要的桥接作用,子模块和基座模块里面都会安装使用它,通过不同的打包入口来实现代码从创建->开发->打包->发布的整套流程。 作为npm包也很少去修改或者重构,属于比较稳定的工具包

framework-lte的发布

作为基座项目的它,开发和打包依赖于楼上的cli,为业务子模块抽象了很多必须的功能,比如上面我们提到的加载全局指令和方法、动态路由加载、路由鉴权等,可以让开发业务的同学在遵循很少量限制条件的情况下尽快迭代、开发出相应的功能,和传统的开发模式相比,一个字

子模块中的集成(item, account, order, basis)

业务为王的公司,一直都在追求怎么提高投入/产出的比率,有了cli和lte的存在,可以帮助实现快速新模块、新功能,只需要按照生成的代码规则,编写页面、调用组件、修改代理即可

总结

以上就是我们微前端工程的整体实现,可以来总结下微前端究竟能帮我们解决哪些问题:

  • 对于开发业务的同学来说,学习成本极低(项目多的时候后端同学也在写前端业务),产出快
  • 子模块可单独开发、打包、部署,各模块间零耦合
  • 保留了传统spa体验的同时,业务模块的大小合理可控,不会因为传统的模块多、代码量大造成性能问题
  • ........

本文使用 mdnice 排版