在微信小程序内实现沙盒环境的方案
发布于 3 年前 作者 gaoping 4914 次浏览 来自 分享

背景和需求

随着微盟业务的拓展,我们在产品形态上允许商户使用不同的业务模块动态生成一种解决方案。这时我们就需要去解决不同的业务模块开发团队所面临的协作问题。
一般情况下,我们在多个团队协作开发小程序过程中,如果某个团队的模块对全局的JS变量进行了修改,比如:wx、Page、App等,都会影响到其它模块,甚至出现一些莫名其妙的bug。所以为避免某个模块对全局的修改影响到其它模块,我们希望在使用其它团队的模块时,对该模块进行一个沙箱化(Sandbox)处理,让其对全局的修改只会在该模块下有效。


解决思路

通常的一些解决方案比如在H5中使用 with+Function、借助iframe,在nodejs中使用 vm/vm2 等方式去实现代码环境隔离,但是在小程序中这些方式都不可用。
故我们通过在编译时,在代码中插入沙箱代码实现环境隔离,以下是实现的2个思路:

方式一: 在代码最外层套一层外壳, 将代码包含在其中.

比如原始代码为:

Page({
  onLoad() {
    wx.request({ /* 请求参数 */ })
  }
});

转换后代码:

// 注入局部环境变量
((wx, Page, /* 其它全局变量 */) => { /* 注入的代码 */

Page({
  onLoad() {
    wx.request({ /* 请求参数 */ })
  }
});

})(...global.getModuleEnv('moduleA')) /* 注入的代码 */

说明: getModuleEnv 是提前挂在 global 上的一个方法, 用于获取每个模块的的环境变量, 返回值是一个数组, 数组元素顺序与注入局部环境变量顺序一致, 伪代码如下:

const envs = {};
global.getModuleEnv = (moduleName) => {
  if (!envs[moduleName]) {
    envs[moduleName] = [
      createScopedWx(wx),
      createScopedPage(Page),
      // 其它环境变量
    ];
  }
  return envs[moduleName];
}

优点: 实现简单, 只需对模块内的每个js文件都包装一下这个外壳就好.
缺点:

  1. 如果模块内某块代码对全局变量进行赋值操作, 如下:
// init.js
Page = proxified(Page, (_Page, that, args) => {
  /* 对 pageOpts 进行修改, 注入业务逻辑等  */
  return _Page.apply(that, args);
});

// user-page.js
Page({ // 这里的 Page 应该是被修改过的
  /* 代码逻辑 */
})

方式一这种用法将无效, init.js无法影响到同模块内其他JS文件.

  1. 如果某个js代码中写了如下代码:
const wx = {}; // 虽然没有人会这么写, 但是正常情况下是不会报错的

经过一层代码包裹之后, 运行时将会报Duplicate declaration "wx"的错误

方式二: 使用 Babel 对代码中的全局变量进行替换, 实现思路如下:

说明:

1. 判断是否需要被局部化

使用 path.node.name 判断是否需要被替换

2. 判断是否被引用的标识符

使用 path.isReferencedIdentifier()

3. 判断是否是赋值操作

使用 t.isAssignmentExpression(path.parent)

举例:

// 原代码
Page = function() {
  // 代码
}

// 替换结果为
_$_.Page = function() {
  // 代码
}

4. 判断是否是赋默认值操作

使用 t.isAssignmentPattern(path.parent)

举例:

// 原代码
function fn(origWx = wx) {
  // 代码
}

// 替换结果为
function fn(origWx = _$_.wx) {
  // 代码
}

5. 判断是否是数组结构赋值

使用 t.isArrayPattern(path.parent) && !t.isVariableDeclarator(path.parent.parent)

举例:

// 原代码
[Page, Component] = [
  newPage,
  newComponent,
];

// 替换结果为
[_$_.Page, _$_.Component] = [
  newPage,
  newComponent,
];

6. 没有定义过该名称

使用 path.scope.getBinding(name) == null

举例:

function fn(wx) {
  // 这里的 wx 不应该被替换
  wx.showToast({})
}

Program节点的exit阶段中判断是否进行过上面的代码替换操作,如果是则在顶部插入一句

const _$_ = global.getModuleEnv('moduleA');

最终转换效果如下:
原代码为:

Page({
  onLoad() {
    wx.request({ /* 请求参数 */ })
  }
});

转换后代码为:

const _$_ = global.getModuleEnv('moduleA');
_$_.Page({
  onLoad() {
    _$_.wx.request({ /* 请求参数 */ })
  }
});

说明:getModuleEnv 是提前挂在 global 上的一个方法, 用于获取每个模块的的环境变量, 返回值是一个对象, 对象中包含所有需要进行隔离的变量, 伪代码如下:

const envs = {};
global.getModuleEnv = (moduleName) => {
  if (!envs[moduleName]) {
    envs[moduleName] = {
      wx: createScopedWx(moduleName, wx),
      Page: createScopedPage(moduleName, Page),
      // 其它环境变量
    };
  }
  return envs[moduleName];
}

优点:模块内的任何修改都仅作用于该模块内,并且没有方式一的缺点,实现了我们想要的效果,并且可以自定义注入全局API,不用挂到globalwx
缺点:实现较为复杂,需要接入Babel。

其它

对于每个模块的本地存储也需要进行隔离,可以在方式二的基础上,在createScopedWx中对Storage相关API进行包装,给key加上模块名称,这样每个模块的本地存储也不会被相关影响了。
同时一些全局的公共key需要加白名单功能(比如:存用户信息的key),避免模块内读取不到公共的本地存储数据

后记

使用Babel这种方式实现了我们想要的效果,并且在此过程中对Babel以及AST有了一个全新的认识。最终我们在项目中使用的是方式二这种模式。

回到顶部