rottenpen / blog

日常记录 blog,内容不限于前端,博文在 issue https://github.com/rottenpen/blog/issues
7 stars 0 forks source link

小程序使用 Taro 和原生混合开发方案的探索 #22

Open rottenpen opened 4 years ago

rottenpen commented 4 years ago

小程序使用 Taro 和原生混合开发方案的探索

背景

我们手上有一个很久没有维护的原生小程序项目需要重新开始迭代,但是我们团队早已开始使用 Taro 来开发小程序,有一些在其他项目中使用的Taro 业务模块也希望合并到旧代码中来。于是有了这篇文章。

需求

令 2 年前的原生小程序代码和后续使用 Taro 写的小程序模块能够混合使用。

方案的探索

1.通过 webpack 实现类似 qiankun 的小程序微前端方案

在这个方案中,计划用 webpack 插件通过分包的方法,把不同的小程序包,在 app.js 逻辑抽离的情况下合并成同一个小程序。希望能够让不同版本的 taro 代码,甚至是不同框架的小程序代码,可以在同一个小程序中运行。

可惜理想很丰满,现实很骨感。这个方案仅限于可以在 taro 1.x 版本中运行。从 taro 2.x 开始,taro 开始用 webpack 打包,会往 app.js 里面注入很多 Taro 相关的代码。对应的代码包,需要依赖 app.js 才能正常运行。更别说 Taro 3.x 开始,Taro 实现了一套运行时的方案,它是基于 React 和 React-Reconciler 实现的。必须往 app.js 里面注入相关 chunk 来实现 runtime。如果想自己实现一套这样的机制,难度不亚于自己实现一个 taro-runtime。开发成本以及维护成本过高。

2.通过 Taro convert 把旧代码转换成新的 Taro 代码

Taro 官方为了方便原生用户加入 Taro 的大家庭当中,可以通过 Taro-cli 提供 taro convert 方法,实现原生代码向 Taro 代码的转换。但是这个方案也有很多弊端。

这个方法的原理是,通过 Taro with-weapp 这个装饰器把原生代码 App(options) / Page(options) / Component(options) 的 options 注入到 react 的 class component 内部。在这个过程中就会产生很多问题。class component 的 this 并不是指向原生的 App / Page / Component ,而是指向 class component 的实例。这样会导致原生很多 hack 的代码失效。

同时也因为,这个装饰器没有兼容 3.x 版本。会出现很多报错。为此我还提交了一个 PR 修复其中的 bug,这个 PR 将会在 3.0.8 版本合并。(除非原生代码不是很多,这个方案我大概是不敢用了,逃)

3.Taro 提供的混写方案

https://taro-docs.jd.com/taro/docs/2.2.11/hybrid Taro 提供了 Taro 和 native 的混写的方案。但是这个方案有很多弊端,经过这个方案打包后的原生代码,会出现很多路径不一致的问题。旧代码和新代码无法很好的解耦。不过这个方案是可行的,只是因为原生页面也作为 Entry 被打包进了 webpack 流程里,所以在这个方案的基础上,有了最后的终极方案。

最终方案

既然 Taro 跟原生小程序代码是可以混写的,那我不如不让 Taro 打包我的原生代码,那样就能合理解耦新代码和旧代码了。

  1. 旧包独立抽出来,放进 src 目录。但在 app.js(taro)的 config 里不添加原生代码包的 pages。因为 taro 是通过 config 来把对应的 page 作为 Entry 添加进 webpack 里的,这样做是为了让 taro 只把 taro 相关代码打包进 dist。

  2. 旧包相关代码可以通过 https://taro-docs.jd.com/taro/docs/2.2.11/config-detail 提供的 copy 来实现静态输出。(注意,src 相关目录不要和旧包重名)

    copy: {
        patterns: [
          { from: 'src/native', to: 'dist' }, // 指定需要 copy 的目录
        ]
      },
  3. 由于在第一步,我们没有把旧包的 pages 添加到 config 里去,最后输出的 app.json 是不会把 copy 过来的 pages 添加进去的。所以另外实现了一个 webpack 插件changeAppJsonPlugin 来对最后输出文件的修改。让最终输出的 app.json 跟我们最终想要得到的一样。

    // config/index.js
    const changeAppJsonPlugin = require('./plugin/changeAppJsonPlugin')
    config = {
      // other....
      mini: {
        webpackChain (chain, webpack) {
          chain.plugin('changeAppJsonPlugin')
            .use(changeAppJsonPlugin)
        },
      }
    }

    通过这个插件,我们可以实现在 app.js 的 config 里,增加一个 outputAppJson 的属性来修改最终出来的 app.json。

    config = {
    outputAppJson: {
    // 需要添加进去的原生 pages
    pages: [
    {
    path: 'pages/home/home',
    homePage: true
    },
    'pages/mine/mine'
    ],
    tabbar: {}
    },
    // taro 的 Entry
    pages: []
    }

    需要注意的是,output 里的 pages,是 push 进去最终输出的 pages 数组的,但是当我们需要把原生 pages,作为最终输出首页时,需要把这个页面写成 object 对象,如上面的格式,让 page unshift 进 pages 数组里去。

  4. 由于旧包把很多公共逻辑代码注入到 App 里。所以为了不影响旧代码的正常运行,对 getApp 这个函数进行了切片,把旧包里 App 里的 options 抽出来, 植入 getApp 。

    // app.js
    require('./global')
    // global.js
    const oldGetApp = getApp;
    class Opitons {
      // ....
      // 旧包 App option 里的逻辑
    }
    let res = Object.assign(new options(), oldGetApp());
    getApp = () => {
      return res;
    };

    如此一来,在旧包里 page 通过 getApp 获得的公共逻辑也就能完美覆盖了。

总结

通过以上的探索历程,总算很好地把 Taro 以及原生完美地跑起来了。虽然试了很多的错,但是通过这个过程,也让我对 Taro 有了重新的认识。作为一个 Taro 的新司机,以后 Taro 这车哪里有坑我也可以自己修了(误。



后续

在后面开发过程中,发现实际工作中还需要通过一些额外的手段,来打通原生代码跟 Taro 代码之间的交互。所以额外写了以下内容。

mixin.js

在后续的开发中,我改造了上文的 global.js,重新命名为 mixin.js,同时做了两点改造:

  1. 通过hack手段直接获取 app.js 内部的 options,不再手动复制粘贴。
  2. 打通新旧代码的公共函数,防止重复引入依赖。
  3. 覆盖旧代码的逻辑。

共享

一些工具函数的封装,如用来记录数据的 sessionStore,封装了业务逻辑的 Request 库。如果两端都引入相同依赖,会引起评论区有人提到依赖重复打包问题。所以建议旧包页面引用的一些依赖可以放到 app 里获取。

覆盖

但是旧包有一些依赖,可能已经落后于现在的新代码。例如在旧包里,我们自己实现了一个 eventCenter,功能和 Taro 的 eventCenter 但是有一定差异。为了最少量的修改旧包代码逻辑,在 Taro eventCenter 的基础上进行兼容封装,让 Taro eventCenter 直接能在旧包上使用。

更新迭代

新版本的更新迭代,旧包还是不可避免要进行修改。在这个问题上只能去权衡,哪些页面需要继续使用旧代码,哪些页面适合重写。 分享一些我在开发过程中总结的小tips:

  1. 需要用到 Taro 组件的页面最好还是重写一下。
  2. 改动不大的页面也可以通过 usingComponent 的方式把旧页面作为组件,和新页面组合开发。
  3. 可以在旧代码中尝试直接用 Taro 生成的 component。(不过这个地方有坑,慎入!

代码

// 新包的 app.js
require('./mixin')('koolcard');

// mixin.js
module.exports = nativeDirName => {
  const oldApp = App;
  let options;
  App = option => {
    options = option;
  };
  (function() {
    require(`./${nativeDirName}/app.js`);
  })();
  App = oldApp;
  const oldGetApp = getApp;
  let res = Object.assign(options, oldGetApp(), {
    request: Request,
    event: Event,
    sessionData: Session,
    service,
    isIpx,
    eventNames: EventNames,
    globalData,
    getPhoneNumber,
    bystesLength,
    iconStyle,
    DEFAULT_AVATAR: DEFAULT_AVATAR,
    newTip,
    settings: new Setting(),
  });
  getApp = () => {
    return res;
  };
};

TODO

usingComponent 会产生编译过程中会报错找不到组件的问题,虽然不影响编译打包。强迫症的我还是要想想办法解决....(虽然还没想到办法 hh

luckyadam commented 4 years ago

学习了~

rottenpen commented 4 years ago

@luckyadam 不敢当不敢当 都是基于你们开的口子做的

wzono commented 4 years ago

问题很大,包体积增加很多(因为app.js)重复打包,很容易超过wechat的2MB限制

rottenpen commented 4 years ago

@wingsico 你指的重复打包是?如果本来的 app.config 写上 native 包的 page 的话是会被重复打包

rottenpen commented 4 years ago

@wingsico 更新了上面的文章 你可以看看

wzono commented 4 years ago

@wingsico 你指的重复打包是?如果本来的 app.config 写上 native 包的 page 的话是会被重复打包

问题存在于app.js,因为app.js引入了global.js,global.js存在对一些较大的文件(假设在native的utils目录中)的引入,那么打包后的app.js包含这些大文件,同时dist目录中由于使用了cp,把native的所有文件都复制过去了,此时就会造成app.js里有一份代码,native里还有一份。

wzono commented 4 years ago

@wingsico 你指的重复打包是?如果本来的 app.config 写上 native 包的 page 的话是会被重复打包

问题存在于app.js,因为app.js引入了global.js,global.js存在对一些较大的文件(假设在native的utils目录中)的引入,那么打包后的app.js包含这些大文件,同时dist目录中由于使用了cp,把native的所有文件都复制过去了,此时就会造成app.js里有一份代码,native里还有一份。

除此之外,global的编写也存在一些小问题,假设原有的App option逻辑中含有生命周期钩子,那么仅使用这样的写法是不会调用生命周期钩子的(对于Taro Next Vue版本来说,React应该没有类似问题)

wzono commented 4 years ago

实际上usingComponents还是比较好用的,可以通过这个来引用原生的组件,但没法在原生里引用Taro组件(目前来说)。

而且要运行原生小程序,在app.config.js里写pages指向原生页面即可,目前没遇到什么问题。

之前困扰我的是如何处理在原生中的顶层(App.js)逻辑,看了你的方案得到启发,可以提取出来重写getApp,但你的方案存在一定漏洞,因此我稍稍更改了一下,更符合Vue的风格,同时最小程度的入侵Vue。

// global.js
const _getApp = getApp;
const _options = { ... };
const _app = Object.assign(_options, _getApp());

export default {
  install: () => { getApp = () => app }
}

// 注入生命周期(Vue自带的Mixin无法混入小程序生命周期)
export const withMixin = App => {
  const { $options } = App;
  const { onHide, onLaunch, onError, onShow } = $options;
  App.$options = {
    ...$options,
    onLaunch(options) {
      _app.onLaunch && _app.onLaunch(options);
      onLaunch && onLaunch();
    },
    onShow(options) {
      _app.onShow && _app.onShow(options);
      onShow && onShow();
    },
    onHide() {
      _app.onHide && _app.onHide();
      onHide && onHide();
    },
    onError(err) {
      _app.onError && _app.onError(err);
      onError && onError();
    }
  };
  return App;
};
// app.js
import GetAppPlugin, { withMixin } from "./global";
Vue.use(GetAppPlugin);
const App = new Vue({
  store,
  onLaunch(options) {},
  onShow(options) {},
  onHide() {},
  onError(err) {},
  render(h) {
    // this.$slots.default 是将要会渲染的页面
    return h("block", this.$slots.default);
  }
});

export default withMixin(App);

这样的话可以正常触发生命周期,且可以通过Taro打包原生(若原生想引用vuex的状态变量,在global.js中的options注入即可)

rottenpen commented 4 years ago

@wingsico 你指的重复打包是?如果本来的 app.config 写上 native 包的 page 的话是会被重复打包

问题存在于app.js,因为app.js引入了global.js,global.js存在对一些较大的文件(假设在native的utils目录中)的引入,那么打包后的app.js包含这些大文件,同时dist目录中由于使用了cp,把native的所有文件都复制过去了,此时就会造成app.js里有一份代码,native里还有一份。

除此之外,global的编写也存在一些小问题,假设原有的App option逻辑中含有生命周期钩子,那么仅使用这样的写法是不会调用生命周期钩子的(对于Taro Next Vue版本来说,React应该没有类似问题)

主要是我 native 的包 app.js 没有注入太多生命周期,没想到相关生命周期的问题。后面我写demo出来会注意这点的,谢谢大佬提醒啦。

关于重复打包的问题你看看要不要用 addchunkpage 来抽离 taro 包的依赖引入?

rottenpen commented 4 years ago

实际上usingComponents还是比较好用的,可以通过这个来引用原生的组件,但没法在原生里引用Taro组件(目前来说)。

而且要运行原生小程序,在app.config.js里写pages指向原生页面即可,目前没遇到什么问题。

之前困扰我的是如何处理在原生中的顶层(App.js)逻辑,看了你的方案得到启发,可以提取出来重写getApp,但你的方案存在一定漏洞,因此我稍稍更改了一下,更符合Vue的风格,同时最小程度的入侵Vue。

// global.js
const _getApp = getApp;
const _options = { ... };
const _app = Object.assign(_options, _getApp());

export default {
  install: () => { getApp = () => app }
}

// 注入生命周期(Vue自带的Mixin无法混入小程序生命周期)
export const withMixin = App => {
  const { $options } = App;
  const { onHide, onLaunch, onError, onShow } = $options;
  App.$options = {
    ...$options,
    onLaunch(options) {
      _app.onLaunch && _app.onLaunch(options);
      onLaunch && onLaunch();
    },
    onShow(options) {
      _app.onShow && _app.onShow(options);
      onShow && onShow();
    },
    onHide() {
      _app.onHide && _app.onHide();
      onHide && onHide();
    },
    onError(err) {
      _app.onError && _app.onError(err);
      onError && onError();
    }
  };
  return App;
};
// app.js
import GetAppPlugin, { withMixin } from "./global";
Vue.use(GetAppPlugin);
const App = new Vue({
  store,
  onLaunch(options) {},
  onShow(options) {},
  onHide() {},
  onError(err) {},
  render(h) {
    // this.$slots.default 是将要会渲染的页面
    return h("block", this.$slots.default);
  }
});

export default withMixin(App);

这样的话可以正常触发生命周期,且可以通过Taro打包原生(若原生想引用vuex的状态变量,在global.js中的options注入即可)

用usingComponents,page要改成componnet, 还有里面生命周期命名修改也很麻烦吧?

rottenpen commented 4 years ago

实际上usingComponents还是比较好用的,可以通过这个来引用原生的组件,但没法在原生里引用Taro组件(目前来说)。 而且要运行原生小程序,在app.config.js里写pages指向原生页面即可,目前没遇到什么问题。 之前困扰我的是如何处理在原生中的顶层(App.js)逻辑,看了你的方案得到启发,可以提取出来重写getApp,但你的方案存在一定漏洞,因此我稍稍更改了一下,更符合Vue的风格,同时最小程度的入侵Vue。

// global.js
const _getApp = getApp;
const _options = { ... };
const _app = Object.assign(_options, _getApp());

export default {
  install: () => { getApp = () => app }
}

// 注入生命周期(Vue自带的Mixin无法混入小程序生命周期)
export const withMixin = App => {
  const { $options } = App;
  const { onHide, onLaunch, onError, onShow } = $options;
  App.$options = {
    ...$options,
    onLaunch(options) {
      _app.onLaunch && _app.onLaunch(options);
      onLaunch && onLaunch();
    },
    onShow(options) {
      _app.onShow && _app.onShow(options);
      onShow && onShow();
    },
    onHide() {
      _app.onHide && _app.onHide();
      onHide && onHide();
    },
    onError(err) {
      _app.onError && _app.onError(err);
      onError && onError();
    }
  };
  return App;
};
// app.js
import GetAppPlugin, { withMixin } from "./global";
Vue.use(GetAppPlugin);
const App = new Vue({
  store,
  onLaunch(options) {},
  onShow(options) {},
  onHide() {},
  onError(err) {},
  render(h) {
    // this.$slots.default 是将要会渲染的页面
    return h("block", this.$slots.default);
  }
});

export default withMixin(App);

这样的话可以正常触发生命周期,且可以通过Taro打包原生(若原生想引用vuex的状态变量,在global.js中的options注入即可)

用usingComponents,page要改成componnet, 还有里面生命周期命名修改也很麻烦吧?

虽然也可以像 vant 那样封装一个新的 component 方法,来进行 mixin @wingsico

wzono commented 4 years ago

实际上usingComponents还是比较好用的,可以通过这个来引用原生的组件,但没法在原生里引用Taro组件(目前来说)。 而且要运行原生小程序,在app.config.js里写pages指向原生页面即可,目前没遇到什么问题。 之前困扰我的是如何处理在原生中的顶层(App.js)逻辑,看了你的方案得到启发,可以提取出来重写getApp,但你的方案存在一定漏洞,因此我稍稍更改了一下,更符合Vue的风格,同时最小程度的入侵Vue。

// global.js
const _getApp = getApp;
const _options = { ... };
const _app = Object.assign(_options, _getApp());

export default {
  install: () => { getApp = () => app }
}

// 注入生命周期(Vue自带的Mixin无法混入小程序生命周期)
export const withMixin = App => {
  const { $options } = App;
  const { onHide, onLaunch, onError, onShow } = $options;
  App.$options = {
    ...$options,
    onLaunch(options) {
      _app.onLaunch && _app.onLaunch(options);
      onLaunch && onLaunch();
    },
    onShow(options) {
      _app.onShow && _app.onShow(options);
      onShow && onShow();
    },
    onHide() {
      _app.onHide && _app.onHide();
      onHide && onHide();
    },
    onError(err) {
      _app.onError && _app.onError(err);
      onError && onError();
    }
  };
  return App;
};
// app.js
import GetAppPlugin, { withMixin } from "./global";
Vue.use(GetAppPlugin);
const App = new Vue({
  store,
  onLaunch(options) {},
  onShow(options) {},
  onHide() {},
  onError(err) {},
  render(h) {
    // this.$slots.default 是将要会渲染的页面
    return h("block", this.$slots.default);
  }
});

export default withMixin(App);

这样的话可以正常触发生命周期,且可以通过Taro打包原生(若原生想引用vuex的状态变量,在global.js中的options注入即可)

用usingComponents,page要改成componnet, 还有里面生命周期命名修改也很麻烦吧?

page不需要改的,因为直接使用 app 中的 pages 直接指向就ok了,usingComponents 只是在Taro引用小程序组件的时候使用。

wzono commented 4 years ago

@wingsico 你指的重复打包是?如果本来的 app.config 写上 native 包的 page 的话是会被重复打包

问题存在于app.js,因为app.js引入了global.js,global.js存在对一些较大的文件(假设在native的utils目录中)的引入,那么打包后的app.js包含这些大文件,同时dist目录中由于使用了cp,把native的所有文件都复制过去了,此时就会造成app.js里有一份代码,native里还有一份。

除此之外,global的编写也存在一些小问题,假设原有的App option逻辑中含有生命周期钩子,那么仅使用这样的写法是不会调用生命周期钩子的(对于Taro Next Vue版本来说,React应该没有类似问题)

主要是我 native 的包 app.js 没有注入太多生命周期,没想到相关生命周期的问题。后面我写demo出来会注意这点的,谢谢大佬提醒啦。

关于重复打包的问题你看看要不要用 addchunkpage 来抽离 taro 包的依赖引入?

主要是一些原生小程序依赖的一些第三方库,比如im sdk之类的,这部分没法抽离(我没想到办法),因为Taro和原生都可能依赖这一部分,势必需要进行Taro打包,而原生页面需要引用未打包的第三方库,这样就有冲突,必须保留两份。

rottenpen commented 4 years ago

我目前好像没找到比较好的不改变原包代码处理重复打包的方法。 一个比较low的方法是, 在 global 的地方通过 wx 这个全局变量, 把相关依赖注入 wx 内部。然后在原生 page 中:

import xx from ‘util’
// 改成
const xx = wx.xx

这样可以在修改量最少的情况下处理重复打包的问题。 @luckyadam 不知道大佬有什么建议呢。

shinken008 commented 4 years ago

学习了~

hanzhongxing commented 4 years ago

https://github.com/Kujiale-Mobile/plugin-taro-wx-mix

rottenpen commented 4 years ago

@hanzhongxing 如果是用我的那个方案 是要求你进行混合开发的 在旧代码不用大面积重构的情况下慢慢向vue迁移。目前我们这边已经有2个小程序已经能稳定迁移了。

awen1011 commented 3 years ago

上面提到的重复打包会导致一些问题,比如新代码引入模块后对模块内的内容做了修改,旧代码引入的模块并不能更新修改的内容,不知道有没有什么方法来解决

rottenpen commented 3 years ago

@623282611 我们是把模块放到getApp里使用 旧报和新包都可以使用共同模块了

kq1314 commented 3 years ago

image 这个链接404了,可以提供一下新的吗?

rottenpen commented 3 years ago

image 这个链接404了,可以提供一下新的吗?

https://www.npmjs.com/package/plugin-taro-wx-mix @kq1314

kq1314 commented 3 years ago

image 这个链接404了,可以提供一下新的吗?

https://www.npmjs.com/package/plugin-taro-wx-mix @kq1314

多谢

kq1314 commented 3 years ago

如何在taro里定义globalData呢?我这边getApp().globalData一直是undefined呢

rottenpen commented 3 years ago

如何在taro里定义globalData呢?我这边getApp().globalData一直是undefined呢

https://github.com/rottenpen/blog/issues/23 @kq1314 参考这篇文章,globalData 只是我自己定义的一个切片而已,你可以在 mixin 函数里插入任何你想公共使用的值

jesse-li commented 4 months ago

不打包编译原生代码,不就失去了多端的能力了?

rottenpen commented 4 months ago

不打包编译原生代码,不就失去了多端的能力了?

如果都是小程序的dsl 就没所谓