什么是插件化?
何为「 插件化 」,顾名思义,就是把一些核心复杂依赖度高的业务模块封装成独立的插件,然后根据不同业务需求进行不同组合,动态进行替换,可对插件进行管理、更新,后期对插件也可进行版本管理等操作。在插件化中有两个主要概念:
-
宿主
就是需要能提供运行环境,给资源调用提供上下文环境,一般也就是我们主程序 ,要运行的应用,它作为应用的主工程所在,实现了一套插件的加载和管理的框架,插件都是依托于宿主程序而存在的。
- 插件
插件可以想象成每个独立的功能模块封装 ,可以通过在线配置和更新实现插件在宿主程序中的上线和下线,以及动态更新等功能。
插件化的应用场景
-
让用户不用重新安装就能升级应用功能,减少发版本频率,增加用户体验。
-
提供一种快速修复线上 BUG 和更新的能力。
-
按需加载不同的模块,实现灵活的功能配置。
-
模块化、解耦合、并行开发。
Android插件化
插件化技术可以说是Android高级工程师所必须具备的技能之一,从2012年插件化概念的提出(Android版本),到如今插件化的百花争艳,可以说,插件化技术引领着Android技术的进步。
在 Android 中应用插件化技术,其实也就是动态加载的过程,分以下几步:
-
把可执行文件( .so/dex/jar/apk 等)拷贝到应用 APP 内部。
-
加载可执行文件,更换静态资源
-
调用具体的方法执行业务逻辑
经过以上几步, 从而可以实现线上动态更新修复的功能。经过近10年的发展,插件化技术已经沦为修 bug 的工具。这跟插件化的初衷不一样,插件化是实现新功能,而不是修复 bug。
其次,插件化现在有一个更好的替代品——RN。RN依赖js引擎,成为了在原生应用领域中真正实现动态化的最佳方式。借助插件化的技术思想,如何在RN层实现此类功能呢?
React Native 端如何实现
RN已经完美支持了热更新,为什么还需要插件化呢? 热更新和插件化都是为了更好的解决线上app修复更新的问题。在RN中实现插件化, 更多的是以业务需求模块为主,将一些通用代码抽离,借助模块化开发思想,做到多源聚合、并行开发且可扩展的需求场景。例如App换肤、不同音乐平台的音源聚合等等,都是将资源模块作为插件,通过线上下载的方式来实现。优势也非常明显,既做到了代码分离, 也实现了业务高度可扩展。
加载器
RN端依赖js core 引擎实现代码加载,那么我们可以实现一个自执行函数, 将外部代码模块作为插件, 在自执行函数中执行插件代码就可以实现插件化啦。
Function(`
return function() {
${pluginCode}
}
`)()();
pluginCode就是从外部读入的插件代码。同时还可以让插件通过参数传递的方式使用RN宿主程序中的一些API或功能包(例如RN端的npm三方包)。
Function(`
return function(console, axios) {
${funcCode}
}
`)()(console, axios);
插件模块
- 通过require或者类似“全局变量”的方式获取运行时环境
- 通过module.exports导出插件实例
module.exports = {
pluginVersion: '1.0.0',
pluginName: '容器加载器',
// 省略其他代码...
};
上面曾说到在插件模块代码中可以使用RN宿主程序中的一些功能包,例如axios,代码像这样
const axios = require('axios');
module.exports = {
pluginVersion: '1.0.0',
pluginName: '容器加载器',
// 省略其他代码...
};
将其copy到浏览器控制台执行,会发现这段代码不可以独立运行,提示会缺少三个全局变量:require、module和exports。为了让插件内部可以获取到三个特定的全局变量,可以借助宿主通过函数参数传递给插件模块
Function(`
return function(require, module, console, axios) {
${funcCode}
}
`)()(_require, _module, console, axios);
pluginCode内获取到的require、module和exports是RN内运行时环境下的,当这个函数执行完毕后,我们可以从module.exports中获取到我们需要的插件返回值,同时也可以用require加载APP内部的npm包或者RN内置的API。
变量定义
const _module = {
exports: {
}
};
const packages: Record<string, any> = {
axios,
dayjs,
...
};
const _require = (packageName: string) => {
let pkg = packages[packageName];
pkg.default = pkg;
return pkg;
};
以上就是在RN端实现插件化开发的基本思路。在围绕插件加载器上,我们还有周边功能没有实现,比如插件的卸载、更新等等。理解了上面的实现方式,相信这些功能也都会迎刃而解。
Dynamic import
RN官方对于dynamic import 特性暂无支持,借助上面的插件化实现思想,我们可以自己来扩展实现
通常使用 import * from "xxx"
引入普通业务模块,那么该模块的代码都会直接打到业务包中。但在引入按需加载业务模块时,使用的是 import("xxx")
引入的。在使用import函数之前 ,少不了React 16.6 引入了一个新的 API:React.lazy()
,可以帮助我们动态加载组件。
const MyComponent = React.lazy(() => import('./MyComponent'));
在运行时动态import()的默认实现为metro-runtime/src/modules/asyncRequire.Metro 提供了一个名叫 AsyncRequire.js 的文件模版来做动态导入的语法的 polyfill,具体实现如下
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @format
* @flow strict
*/
'use strict';
// $FlowExpectedError Flow does not know about Metro's require extensions.
const dynamicRequire = (require: {importAll: mixed => mixed});
module.exports = function (moduleID: mixed): Promise<mixed> {
return Promise.resolve().then(() => dynamicRequire.importAll(moduleID));
};
总结一下 Metro 默认会如何处理动态导入:在 Transformation 通过 Babel 插件处理动态导入语法,并在中间产物上增加标识 Async
,在 Serialization 阶段用 Asyncrequire.js 作为模板替换动态导入的语法,即
const A = import(A);
变为
const A = function(moduleID) {
return Promise.resolve().then(() => dynamicRequire.importAll(moduleID));
};
所以要实现上面的逻辑, 就需要修改AsyncRequire的默认逻辑,在最新的metro官方文档中已经支持AsyncRequire的自定义配置,通过asyncRequireModulePath即可指定自定义实现
大致思路是: 在 runtime 运行时,业务执行 import("./xxx")
之后,框架层会去判断 ./xxx
路径对应的 module 是否已经 install。如果没有 install,就会通过 ./xxx
路径找到对应 chunk 包的 URL 地址,接着下载和执行 chunk,最后渲染 xxx Component。
const DIAsyncRequire = async moduleId => {
// chunkModuleIdToHashMap 是一个映射白名单
const hashMap = chunkModuleIdToHashMap[moduleId]
if (!hashMap) {
// 一个模块被分解到主包中,但也有异步的地方
await Promise.resolve()
} else if (Array.isArray(hashMap)) {
// TODO 同时执行多个文件
await Promise.all(hashMap.map(v => requireEnsure(v)))
} else {
// 单个执行
await requireEnsure(moduleId)
}
// 执行默认的 asyncRequire 逻辑
return asyncRequire(moduleId)
}
module.exports = DIAsyncRequire
核心在requireEnsure函数模块
const requireEnsure = (chunkId) => {
const installedChunks = global.installedChunks = global.installedChunks || {
// 'buz.ios.bundle': 0 // [chunkId]: [state]
}
const promises = []
let installedChunkData = installedChunks[chunkId]
if (installedChunkData !== 0) {
if (installedChunkData) {
promises.push(installedChunkData[2])
} else {
const promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject]
})
promises.push(installedChunkData[2] = promise)
const error = new Error()
let timeoutId
const onComplete = ({ code, data, msg }) => {
clearTimeout(timeoutId)
const state = installedChunks[chunkId]
if (state === undefined) return
const [resolve, reject] = state
if (code !== 200) {
error.message = msg
error.type = error.name = 'ChunkLoadError'
error.request = resourceUri
installedChunks[chunkId] = undefined
return reject(error)
}
// 自执行函数
Function(`"use strict"; ${data}`)()
installedChunks[chunkId] = 0
resolve()
}
// 超时处理
const timeoutTime = xxx
timeoutId = setTimeout(
onComplete.bind(null, {
code: 0,
msg: `load chunk:${chunkId} failure(timeout:${timeoutTime})`
}),
timeoutTime
)
// 下载资源包
fetch(resourceUri).then(res => {
if (res.status === 200) {
res.text().then(res => {
onComplete({ code: 200, data: res })
}).catch(err => {
onComplete({ code: 0, msg: err.message })
})
} else {
onComplete({ code: res.status, msg: res.statusText })
}
}).catch(err => {
onComplete({ code: 0, msg: err.message })
})
}
}
return Promise.all(promises)
}
核心逻辑就是利用fetch去下载对应文件, 下载完成后通过自制行Function函数去加载资源代码块。
业务初始化时,可以只执行主业务包,在跳转到dynamic页面时,才会动态的下载对应的动态包。退出已进入的 dynamic 页面再次进入,不会再下载,会利用原有的缓存直接渲染 dynamic 页面。
const A = React.lazy(() => import('./A'))
const B = React.lazy(() => import('./B'))
const App = () => (
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Main" component={Main} />
<Stack.Screen name="A" component={A} />
<Stack.Screen name="B" component={B} />
</Stack.Navigator>
</NavigationContainer>
)