最近一段时间在研究如何将react-native进行拆包,如何使用react-native实现小程序,在网上找了一些资料都不是很满意,要么不能解决问题,要么方案太复杂。需求如下:
- 可以实现拆包功能,类似于
webpack的code splitting - 包可以按照工程进行切分,而不是在一个工程里写所有的代码,方便多团队共同协作
- 每个包可以独立运行也可以组合运行,和小程序类似
- 独立更新
- 开箱即用,和
react-native生态无兼容问题 - 纯JS解决方案
根据我的想法,可以使用webpack将需要分离的代码进行独立打包,同时解决react-native相关依赖应该就可以达到目的,下面我就来说说我的实现。
名词解释
- 宿主:这里指主App
- 微应用/微组件:这里指通过
http加载的远程应用或者组件
流程
废话不多说,直接上图
流程图说明:
- 根据提供的
entry下载需要运行的javascript代码
- 下载成功后需要对代码进行编译,同时注入宿主依赖:
React,ReactNative,Modules。Modules主要自定义导出的依赖
- 生成目标component。
- 最后进行渲染。
useCerberus在debug模式下会自动开启reload
中间任何一个环节发生错误都会直接结束并返回错误信息,错误需要自己处理,useCerberus内部不处理任何逻辑
流程很简单,和web的加载方式差不多,但是这里我们有两个问题需要解决
- 如果处理公共依赖,即在微应用中如何使用
react,react-native等公共依赖 - 如果动态加载script,在宿主中如何使用
http加载javascript代码
接下来我们就来解决上面提到的问题。
entry的代码并不是由Metro进行编译的,而是使用webpack进行编译。一般在开发的时候我们会将包按照功能,业务等维度使用不同的工程来管理。因此宿主可能只会实现一些很基础的东西即可,甚至可以连一个页面都没有。
处理公共依赖
一般我们在处理web的公共依赖时会使用dllPlugin(webpack插件)来实现就好了,但是我们现在使用的是两套不同的bundle方式,所以dllPlugin已经不能解决我们的问题,我们需要自己来处理公共依赖。
因此我希望达到如下的效果:
function ($REACT$,$REACTNATIVE$,$MODULES$){
return /*codes of webpack output*/
}
$REACT$,$REACTNATIVE$,$MODULES$就是我们需要注入的公共依赖,webpack在打包的时候将忽略掉这些依赖项。要达到这个目的只有在编译层解决这个问题,因此我们自然就想到了使用babel。
比如
import * as React from "react"
import React2 from "react"
import {memo,useState} from "react"
import {useRef as ur} from "react"
import {Text} from "react-native"
输出成下面这样
const React = $REACT$;
const React2 = $REACT$;
const memo = $REACT$.memo;
const useState = $REACT$.useState;
const ur = $REACT$.useRef;
const Text = $REACTNATIVE$.Text;
这样我们就能实现公共依赖的替换。但是这里有另外一个问题,比如我使用了dateformat这样的第三方依赖,正好宿主中也使用了dateformat,那有没有可能将dateformat也进行替换呢?答案肯定是可以!
比如
import df from "dateformat"
import {get as getPath} from "object-path"
输出
const df = $MODULES$["dateformat"];
const getPath = $MODULES$["object-path"].get;
之前已经提到过$MODULES$就是我们自定义导出的一些公共依赖,我们只需要给babel插件添加一些自定义配置项就可以实现了,比如我现在写的这个插件可以这样配置cerberus-babel-plugin-transform插件
{
//当前插件的名字为:@m860/cerberus-babel-plugin-transform
"plugins":[["@m860/cerberus-babel-plugin-transform",{"modules":["dateformat","object-path"]}]]
}
有一点需要注意,modules目前只支持Array<string>类型,同时在$MODULES$中进行导出时名字必须和require的名字保持一致。
通过上面的例子我们就能够解决公共依赖问题,babel插件的参考代码如下:
const DefaultModules = ["react", "react-native" /*后面的是自定义模块*/, "dateformat", "object-path"];
const ReactModuleName = "$REACT$";
const ReactNativeModuleName = "$REACTNATIVE$";
const ModulesModuleName = "$MODULES$";
function getBuiltinModule(node, spec, types) {
const name = node.source.value;
switch (name) {
case "react":
if (spec.type === "ImportSpecifier") {
return types.memberExpression(types.identifier(ReactModuleName), types.identifier(spec.imported.name));
}
return types.identifier(ReactModuleName);
case "react-native":
if (spec.type === "ImportSpecifier") {
return types.memberExpression(types.identifier(ReactNativeModuleName), types.identifier(spec.imported.name));
}
return types.identifier(ReactNativeModuleName);
default:
if (spec.type === "ImportSpecifier") {
return types.memberExpression(
types.memberExpression(types.identifier(ModulesModuleName), types.stringLiteral(name), true),
types.identifier(spec.imported.name)
);
}
return types.memberExpression(types.identifier(ModulesModuleName), types.stringLiteral(name), true);
}
}
module.exports = function(babel) {
const { types } = babel;
return {
name: "cerberus-transform", // not required
visitor: {
ImportDeclaration(path, { opts }) {
const excludeModules = opts && opts.modules && opts.modules.length > 0 ? DefaultModules.concat(opts.modules) : DefaultModules;
let codes = [];
const { node } = path;
const { specifiers } = node;
const name = node.source.value;
const existsInExclude = excludeModules.indexOf(name) >= 0;
if (existsInExclude) {
if (specifiers) {
specifiers.forEach(function(spec) {
switch (spec.type) {
case "ImportNamespaceSpecifier":
case "ImportDefaultSpecifier":
codes.push(
types.variableDeclaration("const", [
types.variableDeclarator(types.identifier(spec.local.name), getBuiltinModule(node, spec, types))
])
);
break;
case "ImportSpecifier":
codes.push(
types.variableDeclaration("const", [
types.variableDeclarator(types.identifier(spec.local.name), getBuiltinModule(node, spec, types))
])
);
break;
}
});
}
}
if (codes.length > 0) {
path.replaceWithMultiple(codes);
}
}
}
};
};
关于babel插件的开发可以参考babel插件开发手册, babel-types API
接下来我们看看cerberus-babel-plugin-transform插件输出的代码是什么样的。
import * as React from "react"
import {Text} from "react-native"
export default function(){
return <Text>Hello</Text>
}
输出结果
...
// 以下是webpack编译后的代码片段
"use strict";
__webpack_require__.r(__webpack_exports__);
var React = $REACT$;
var Text = $REACTNATIVE$.Text;
/* harmony default export */ __webpack_exports__["default"] = (function () {
return React.createElement(Text, null, "Hello!");
});
...
以上编译结果和我们的预期是一样的
关于webpack的具体配置我这里就不细说了,和普通的web配置是一样的,关键点还是在上面babel插件的实现上。
动态加载script
不像在web里那么方便,直接一个script标签就可以加载一段javascript代码。这里我们使用new Function的方式来加载javascript片段。
关键代码如下
//code就是webpack生成的代码
const result = (new Function(`$REACT$`, `$REACTNATIVE$`, `$MODULES$`, `return ${code}`))(React, ReactNative, {
//自定义公共依赖
...injectModules(),
//此参数另有用处,可以先忽略,后面会讲到
__BASE_URL__: baseURL
})
通过上面的代码我们就能成功的加载javascript片段。
如何正确的访问资源文件
上面的所有工作只是解决了如何加载一段javascript代码,对于图片,视频,文件等其他资源在开发中可能会存在无法访问的问题。这是为什么呢?
比如
// 引用图片资源
const image=require("image.png")
// 通过webpack编译之后会生成成如下代码
const image="[HASH].png"
那么我们的在react-native中会生成什么样的代码呢?
// react-native代码片段
const ele=<Image source={require("image.png")}/>
// 编译之后(这里只是代码演示,并不是最终的编译结果)
const ele=<Image source={"xxxxxx.png"}/>
上面<Image/>的代码是不能正常访问到图片的。
webpack打包的资源文件和js文件一起都在服务器上,并且和js文件保持在同一级目录中<Image/>中访问网络图片需要使用uri的方式
因此要解决上面的问题,我们需要对require(资源文件)进行编译处理,调整为react-native可以识别的方式(即uri)
比如
import ImageSource from "img0.png"
const img=require("img1.png")
编译后
const ImageSource = {
uri: $MODULES$["__BASE_URL__"] + require("img0.png")
};
const img = {
uri: $MODULES$["__BASE_URL__"] + require("img1.png")
};
__BASE_URL__这个属性前面我们已经提到过,就是根据entry地址生成的一个BaseURL用于访问资源文件,这样在应用程序中我们就能够正确的访问资源文件了。
因此我们需要对babel插件添加资源处理的逻辑,同时我们会新增一个resourceTest属性用于自定义资源文件的匹配。
完整代码如下:
const DefaultModules = ["react", "react-native" /*后面的是自定义模块*/, "dateformat", "object-path"];
const DefaultResourceTest = /\.(gif|png|jpeg|jpg|svg)$/i;
const ReactModuleName = "$REACT$";
const ReactNativeModuleName = "$REACTNATIVE$";
const ModulesModuleName = "$MODULES$";
function getBuiltinModule(node, spec, types) {
const name = node.source.value;
switch (name) {
case "react":
if (spec.type === "ImportSpecifier") {
return types.memberExpression(types.identifier(ReactModuleName), types.identifier(spec.imported.name));
}
return types.identifier(ReactModuleName);
case "react-native":
if (spec.type === "ImportSpecifier") {
return types.memberExpression(types.identifier(ReactNativeModuleName), types.identifier(spec.imported.name));
}
return types.identifier(ReactNativeModuleName);
default:
if (spec.type === "ImportSpecifier") {
return types.memberExpression(
types.memberExpression(types.identifier(ModulesModuleName), types.stringLiteral(name), true),
types.identifier(spec.imported.name)
);
}
return types.memberExpression(types.identifier(ModulesModuleName), types.stringLiteral(name), true);
}
}
function toUriSource(types, sourceName) {
return types.objectExpression([
types.objectProperty(
types.identifier("uri"),
types.binaryExpression(
"+",
types.memberExpression(types.identifier(ModulesModuleName), types.stringLiteral("__BASE_URL__"), true),
types.callExpression(types.identifier("require"), [types.stringLiteral(sourceName)])
)
)
]);
}
module.exports = function(babel) {
const { types } = babel;
return {
name: "cerberus-transform", // not required
visitor: {
CallExpression(path, { opts }) {
let codes = [];
const { node } = path;
const calleeName = node.callee.name;
if (calleeName === "require") {
if (node.arguments.length === 1) {
const arg = node.arguments[0];
if (arg.type === "StringLiteral") {
const { value } = arg;
const test = opts.resourceTest || DefaultResourceTest;
if (test.test(value)) {
codes.push(toUriSource(types, value));
}
}
}
}
if (codes.length > 0) {
path.replaceWithMultiple(codes);
}
path.skip();
},
ImportDeclaration(path, { opts }) {
const excludeModules = opts && opts.modules && opts.modules.length > 0 ? DefaultModules.concat(opts.modules) : DefaultModules;
let codes = [];
const { node } = path;
const { specifiers } = node;
const name = node.source.value;
const existsInExclude = excludeModules.indexOf(name) >= 0;
if (existsInExclude) {
if (specifiers) {
specifiers.forEach(function(spec) {
switch (spec.type) {
case "ImportNamespaceSpecifier":
case "ImportDefaultSpecifier":
codes.push(
types.variableDeclaration("const", [
types.variableDeclarator(types.identifier(spec.local.name), getBuiltinModule(node, spec, types))
])
);
break;
case "ImportSpecifier":
codes.push(
types.variableDeclaration("const", [
types.variableDeclarator(types.identifier(spec.local.name), getBuiltinModule(node, spec, types))
])
);
break;
}
});
}
} else {
const test = opts.resourceTest || DefaultResourceTest;
if (test.test(name)) {
if (specifiers) {
specifiers.forEach(function(spec) {
switch (spec.type) {
case "ImportDefaultSpecifier":
codes.push(
types.variableDeclaration("const", [
types.variableDeclarator(
types.identifier(spec.local.name),
types.callExpression(types.identifier("require"), [types.stringLiteral(name)])
)
])
);
break;
}
});
}
}
}
if (codes.length > 0) {
path.replaceWithMultiple(codes);
}
}
}
};
};
至此基于react-native的微应用基本实现了。
总结
总结下来两个核心问题
- 公共依赖
- 在
react-native中如何动态的加载javascript
不管用何种方式实现,只要能够解决上面的问题就可以在react-native中实现微应用/微组件。至于编译工具可以根据自己的喜好选择,除了webpack,你也可以尝试使用metro,rollup等工具。
这种实现除了实现了我的需求以外,还有一个最大的好处就是:你在开发react-native应用时不需要关心任何配置问题,就按照正常的流程开发就行了,如果某个模块或者功能需要进行拆包,懒加载,或者代码是其他团队进行提供,只需要对相关文件进行独立编译,部署,然后在主app中进行简单的引用就好了,使用非常方便,没有任何繁琐的配置工作,和react-native生态完美兼容,老的react-native项目也可以直接使用。
我已经把上面的内容封装成了工具,如果需要可以直接拿来使用或者学习。
- cerberus js动态加载(目前只实现了
useCerberushooks,后面会实现Cerberus组件来兼容component) - Cerberus Example
如果觉得文章对你有帮助记得留个star,如果文章有什么不妥的地方欢迎指正,共同进步,谢谢!
声明:本文内容为原创,如果需要转载请注明出处,有任何问题可以电邮我