Micro component/application for react-native

238 阅读7分钟

最近一段时间在研究如何将react-native进行拆包,如何使用react-native实现小程序,在网上找了一些资料都不是很满意,要么不能解决问题,要么方案太复杂。需求如下:

  • 可以实现拆包功能,类似于webpack的code splitting
  • 包可以按照工程进行切分,而不是在一个工程里写所有的代码,方便多团队共同协作
  • 每个包可以独立运行也可以组合运行,和小程序类似
  • 独立更新
  • 开箱即用,和react-native生态无兼容问题
  • 纯JS解决方案

根据我的想法,可以使用webpack将需要分离的代码进行独立打包,同时解决react-native相关依赖应该就可以达到目的,下面我就来说说我的实现。

名词解释

  • 宿主:这里指主App
  • 微应用/微组件:这里指通过http加载的远程应用或者组件

流程

废话不多说,直接上图

cerberus-flow

流程图说明:

  1. 根据提供的entry下载需要运行的javascript代码
  2. 下载成功后需要对代码进行编译,同时注入宿主依赖:ReactReactNativeModulesModules主要自定义导出的依赖
  3. 生成目标component。
  4. 最后进行渲染。
useCerberusdebug模式下会自动开启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/>的代码是不能正常访问到图片的。

  1. webpack打包的资源文件和js文件一起都在服务器上,并且和js文件保持在同一级目录中
  2. <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的微应用基本实现了。

总结

总结下来两个核心问题

  1. 公共依赖
  2. react-native中如何动态的加载javascript

不管用何种方式实现,只要能够解决上面的问题就可以在react-native中实现微应用/微组件。至于编译工具可以根据自己的喜好选择,除了webpack,你也可以尝试使用metrorollup等工具。

这种实现除了实现了我的需求以外,还有一个最大的好处就是:你在开发react-native应用时不需要关心任何配置问题,就按照正常的流程开发就行了,如果某个模块或者功能需要进行拆包,懒加载,或者代码是其他团队进行提供,只需要对相关文件进行独立编译,部署,然后在主app中进行简单的引用就好了,使用非常方便,没有任何繁琐的配置工作,和react-native生态完美兼容,老的react-native项目也可以直接使用。

我已经把上面的内容封装成了工具,如果需要可以直接拿来使用或者学习。

  • cerberus js动态加载(目前只实现了useCerberus hooks,后面会实现Cerberus组件来兼容component
  • Cerberus Example

如果觉得文章对你有帮助记得留个star,如果文章有什么不妥的地方欢迎指正,共同进步,谢谢!

声明:本文内容为原创,如果需要转载请注明出处,有任何问题可以电邮