Web前端, 如何运行时加载远程插件?

Web前端, 如何运行时加载远程插件?

参考实现:

runtime-importwww.npmjs.com图标

问题

前一段时间我在知乎提了个问题:

前端开发,如何优雅的实现这样一个插件机制?www.zhihu.com图标

场景

其实这个问题是有现实场景的, 比如我们考虑下面这个应用:


假设上图中中红的与蓝色分别代表不同的点位, 而不同点位的信息读写接口, detail界面(就是图中的气泡框), 以及点位图标样式规则不一样. 一般来说, 为了避免在代码中穷举, 我们需要把一种类型的点位封装成一个单独的模块.


继而, 假设我们的地图面板是一个通用前端组件, 并且当我们在使用组件的时候, 不太希望使用依赖注入的方式将点位封装库注入进去, 而是希望运行时去CDN上按需加载. 即远程插件模式.


这样假设有什么意义嘛? 有的. 比如上图这个地图页面, 如果我们希望仅仅通过url参数就能定义地图上展示哪些类型的点位, 并且在这个页面开发完成后每当有新点位类型加入系统的时候, 我们不想改代码发版本, 希望支持自动扩展. 这种情况下, 按照约定的地址格式去CDN上按需加载就很有用了.

关键点

上面这个问题的关键点有两个:

  1. 插件接口如何设计, 这个问题 @小爝 的答案已经比较完善了, 建议参考: 小爝:前端开发,如何优雅的实现这样一个插件机制?
  2. 网页运行时如何加载插件?


这篇文章主要讨论第二个问题.

现有机制回顾

其实这不是个新问题, 类似的机制, 在natvie领域已经被实现过八万多遍了, 从万恶的COM, 到国内互联网厂商在andoid上玩的各种黑魔法(比如携程的这个库:CtripMobile/DynamicAPK), 早就没什么新鲜了.不过前端有一点特殊, 因为前端加载机制一直不是很完善.


那么, 前端有哪些运行时加载机制呢? 我们来梳理一下.


先说javascript.


首先, 是最古老的加载器require.js, 以及相关的amd规范, 这个应该不需要我介绍了.


第二种, 是webpack提供的require.ensure()机制. require.ensure()与其说是远程加载, 不如说是一个代码分割机制. 它允许你编写这样的代码:

$el.click(_ => {
  require.ensure([], function() {
    var foo = require('./foo.js')
    var bar = require('./bar.js')
  })
})


webpack打包的时候, 会将foo.js和bar.js打包为一个chunk. 不跟main chunk打在一起. 然而这好像没有解决我们的问题, 这种方法只能远程同一个工程里面的文件, 而我们是要远程加载任意文件.


第三种, 是es6提供的dynamic import机制, 代码大致是这样:

import('./app').then(mod => {
  const App = mod.default
  const app = new App('#app')
  app.run()
})

如果你尝试加载远程文件, 比如:

import('http://www.baidu.com/app.js').then(mod => {
  const App = mod.default
  const app = new App('#app')
  app.run()
})

使用webpack打包时, 会报这样的错误.


注意, 这个错误是webpack报的, babel本身不会报错. 事实上babel不会对import()调用做任何转译, 哪怕你安装了@babel/plugin-syntax-dynamic-import, import()调用也会原样保留在转译出来的es5代码里. 而webpack会尝试转换import(), 于是会报错.


那么如果webpack不捣乱, 是不是就可以了呢? 确实可以, 如果浏览器支持的话, 比如chrome:

但是这里仍然有两个障碍. 第一个是跨域问题, 浏览器对于dynamic import是有跨域限制的, 如果CDN不支持跨域请求, 就会报错:

关键是CDN要配置这个字段:


而第二个障碍, 就是css的加载了, 如果我们不希望把css打包进js, 就需要单独的加载方式. 而css提供的@import语法, 是用来在css中加载css的, 我们的场景显然不是这样.


最后, 第四种, 是一个不怎么热门的(我也是提了开头的问题, 看到答案中有人提到才知道的)webpack插件:

webpack-require-httpwww.npmjs.com图标


这个插件终于实现了让你在代码中肆无忌惮的require远程文件. 然而问题在于, 它并不知道你加载的东西export了什么......哪怕你将你的插件编译成umd格式.


解决方法

那到头来应该怎么办呢? 下面是我表演"怀孕三年生个促织"的时间了, 啰嗦了那么久, 其实我的方案非常简单.


首先明确一点, 除了浏览器原生实现的dynamic import之外, 现有的运行时加载都是利用动态插入script和link标签实现的. 这一点没什么花头可玩. 别考虑eval, 不靠谱.


那我们要做的, 就是利用动态插入标签实现加载, 同时解决两个问题:

  1. 跨域.
  2. 知道插件到底export了什么.


跨域其实不用解决, 动态插标签是不需要考虑跨域问题的. 而第二个问题, 如果你能对插件的打包和项目运行环境做一些约束的话, 其实并不难实现.


为什么webpack-require-http不知道插件export了什么呢? 我们写一个umd格式的插件试一下就很清楚了, 比如这样:

// plugin.js

(function (root, factory) {
  if (typeof exports === 'object' && typeof module === 'object') {
    console.log('module.exports')
    module.exports = factory()
  } else if (typeof define === 'function' && define.amd) {
    console.log('amd')
    define('xxx', [], factory)
  } else if (typeof exports === 'object') {
    console.log('exports')
    exports['xxx'] = factory()
  } else {
    console.log('global')
    root['xxx'] = factory()
  }
})(this, function () {
  return {}
})

你会发现用webpack-require-http加载会打印出'global', 原因不用我解释了吧?


那怎么才能知道插件export了什么呢? 我们只需要做两个约束:

  • 插件编译为umd格式.
  • 运行环境中不能存在require.js等任何amd加载器.


有了这两个约束, 我们看下上面的umd格式插件代码, amd那个分支永远不会被运行到:

既然不会被运行, 那我们就废物利用一下, 要做的非常简单:

  1. 在window上临时挂一个define函数, 并且设置define.amd = true.
  2. 动态创建标签, 并监听load和error事件, 以对外提供Promise
  3. 插件加载之后会自动调用define函数, 我们在define函数里将插件export出来的对象通过Promise传递给上层应用.


整个loader的接口使用, 类似这样:

import load from 'plugin-loader'

export default class App extends React.Component {
  async componentDidMount() {
    const pointData = await request('/api/point')
    const { type } = pointData

    // 加载插件
    const plugin = await load({
      js: `https://www.xxx-cdn.com/yyy/${type}.js`,
      css: `https://www.xxx-cdn.com/yyy/${type}.css`
    })

    this.setState({
      pointData,
      DetailComponent: plugin.DetailComponent
    })
  }

  render () {
    const { pointData, DetailComponent } = this.state

    if (!DetailComponent) {
      return <Loading />
    }

    return <DetailComponent data={pointData} />
  }
}


nice, 看起来不错. 不过这里面其实还有很多细节问题需要考虑.


问题一: 如果我连续load插件A和B:

const pluginA = await load(`https://www.xxx-cdn.com/yyy/a.js`)
const pluginB = await load(`https://www.xxx-cdn.com/yyy/b.js`)

请问能否保证加载顺序顺序?

答: 当然可以保证. 你load pluginA的时候都await了, 怎么可能保证不了?

但是另外还有一种情况,就是两个脚本中分别异步加载了脚本,这时候我们应该把script标签的async属性,设置为false,保证脚本按照添加到html中的顺序执行。


问题二: 如果我在主程序中用load加载插件, 在插件中又用load加载"子插件", 会怎样?

答: 不会怎样. 但是似乎并没有这样的必要.


问题三: 如何保证不重复加载?

答: require.js如何保证, 你就如何保证. 内部要实现一个cache机制, 将脚本加载分为三种状态: NOT_LOAD, LOADING, LOADED, 分别处理.


问题四: 如果我的插件不是一个js而是好几个js怎么办?

答: 别找事儿, 老老实实把插件打包成一个umd文件, 如果要外置依赖, 老老实实用external, 把依赖库写在html里.


问题五: 我为什么不直接用requrie.js?

答: require.js入侵性太强, 我们还是希望保持webpack占主导.


问题六: 思考题, 开头的地图中有一所世界一流大学, 请问是哪一所?

编辑于 2019-01-28

文章被以下专栏收录