Tencent / kbone

一个致力于微信小程序和 Web 端同构的解决方案
Other
4.8k stars 457 forks source link

多页程序里面,miniprogram-app、每个入口页为webpack的entry,module内部的单例变量机制失效,如何破? #71

Open huadong opened 4 years ago

huadong commented 4 years ago

使用webpack进行编译,对于多页应用,miniprogram-app、每页一个入口点,打包后module的单例变量机制失效。

这导致多页程序的页面之间完全隔离,如同浏览器打开了两个独立的窗口(比tab窗口还独立),不仅如此,目前cookie的写法,page之间无法共享cookie。因此:

  1. page之间对js模块的引用和原生引用机制不同,js的module里的单例变量机制失效。
  2. page之间的cookie完全不可见,这和浏览器多tab页机制也不一样。

上边两点约束,kbone基本无法用于多页小程序开发,这个如何破 @JuneAndGreen ?

JuneAndGreen commented 4 years ago

page之间对js模块的引用和原生引用机制不同,js的module里的单例变量机制失效。

这个不是很理解,可以给个具体的场景例子么?

page之间的cookie完全不可见,这和浏览器多tab页机制也不一样。

这个目前确实是按页面隔离的,我记一下,后面支持下各个页面的 cookie 互通。

huadong commented 4 years ago

page之间对js模块的引用和原生引用机制不同,js的module里的单例变量机制失效。

这个不是很理解,可以给个具体的场景例子么?

具体的例子就是: a.js

// a.js
const a = {hi: 'hello world'}
export default a;

app.js

// app.js
import a from './a'

console.log('app:', a)
a.hi = 'hi~'
console.log('app:', a)

pageA.js

import a from './a'

console.log('page:',  a) // output: hi~

列如目前的vuex是页面级的,而无法做到全局的,因此没有全局的状态管理机制。 因为每个webpack的入口点为一个page,而每个page对应一个webpack的runtime和window,尤其这个webpack的runtime,使得module里的单例在每个webpack的runtime中都会重新初始化。

是否可以考虑全局一个webpack的runtime? 比如在编译后生成的app.js:

function jsonpcreateAppPush(data){
    let found = false;
    for (let index = 0; index < this.length; index++) {
        const e = this[index];
        if (e[0][0] === data[0][0]) {
            found = true;
            break;
        }
    }
    if (!found && oldJsonpcreateAppPush) {
        oldJsonpcreateAppPush.call(this, data);
    }
}
const webpackJsonpcreateApp = [];
var oldJsonpcreateAppPush = webpackJsonpcreateApp.push;
webpackJsonpcreateApp.push = jsonpcreateAppPush.bind(webpackJsonpcreateApp);
const fakeWindow = { webpackJsonpcreateApp };

// ...

App({
    globalData: { webpackJsonpcreateApp },
   // ...
})

其它生成的page.js:

       this.window.webpackJsonpcreateApp = getApp().globalData.webpackJsonpcreateApp;

类似上面这样。目前如果用上述的方式,page二次打开会有点问题,可能跟router有关,还没有具体调试。对vue的runtime不太了解,不知道全局一个webpack的runtime能否支持。

上述方式对web没有什么破坏。小程序JavaScript里就一个运行时,目前webpack的编译生成多入口,多个runtime,实际上会导致page与page、以及app之间的js module隔离。

JuneAndGreen commented 4 years ago

@huadong 但是页面间的设计本身 runtime 应该隔离的,就如同浏览器中不同 tab 一样,小程序页面本身的设计也是如此,全局数据通常也是通过 getApp 来进行共享,不然页面间可能会有互相干扰出现奇怪的问题。

所以你的需求其实是需要页面间通信的方案?

huadong commented 4 years ago

这是一个非常值得讨论的问题。从目前微信小程序的开发规范来看,整个小程序是一个JS的运行时,对于module的引用遵从js的规则,详见:模块化。此规则的存在,让大家可以实现全局的状态管理,可以使用redux、vuex之类的。只不过这样去使用,开发者需要谨慎控制页面之间的对象引用关系,但是这样大大提高了代码设计的灵活度和复用性,包括对成熟的开源代码的使用。

不过,对于subpackage的使用,规范有严格的要求:

引用原则 packageA 无法 require packageB JS 文件,但可以 require app、自己 package 内的 JS 文件 packageA 无法 import packageB 的 template,但可以 require app、自己 package 内的 template packageA 无法使用 packageB 的资源,但可以使用 app、自己 package 内的资源

而目前kbone因为使用webpack独立的runtime机制,进一步增加了隔离,相当于主动削弱了小程序代码支持能力,这会使得kbone的灵活性和扩展性降低,不容易被选用于大规模的生产中。

这里面有个对标问题,小程序是对标H5,还是对标APP。目前原生态小程序可以对标到APP的开发上;而kbone的封装直接对标到了H5上了。kbone从设计思路,代码、文档行文等都非常优秀,但如果仅仅对标到了H5上,就太可惜了。

关于这个问题,#46 里面也提到类似的观点。

huadong commented 4 years ago

整理一下原生小程序的实际情况:

因此:

  1. 全局数据可以放到main包进行管理,遵从js module的引用规范;
  2. 也可以使用getApp().globalData进行共享。

实际上,kbone在设计上也使用了这个能力,比如cache的管理,小程序全局的config等等。

JuneAndGreen commented 4 years ago

我明白的你的诉求了,你是希望:假设在页面 A 和页面 B 都引入了 c.js 的情况下,这个 c.js 是运行时也应该是同一个,页面 A 调 c.js 存入的变量在页面 B 是应该是可以正常获取出来的。

不过这里我还是不能将所有页面改成在同一个 webpack 包裹内,kbone 一开始的设计初衷确实是以实现 Web 端写法为目的的,Web 端不同页面的共用模块是隔离的,所以不能偏离这个设计。举个例子,有个页面比较特殊,需要扩展 Vue 原型,但是其他页面不用,如果在同一个 webpack 包裹内的话可能其他页面也被做了不需要的扩展。

所以换个思路来,如果我提供一个页面间通信方案和共享存储区域能否解决你的问题?比如我注入一个 g 对象,不管在哪个页面拿到的 g 对象都肯定是同一个。

huadong commented 4 years ago

我明白的你的诉求了,你是希望:假设在页面 A 和页面 B 都引入了 c.js 的情况下,这个 c.js 是运行时也应该是同一个,页面 A 调 c.js 存入的变量在页面 B 是应该是可以正常获取出来的。

基本是这个意思

kbone 一开始的设计初衷确实是以实现 Web 端写法为目的的,Web 端不同页面的共用模块是隔离的,所以不能偏离这个设计

这个问题最重要,这是kbone的对标定位问题。不过“写法”和支撑能力却是值得思考的问题。比如说很多“语法糖”提高了代码编写的效率,但并没有限制编译后代码的运行能力。

小程序的page和web端的page是有区别的,有时候感觉更像vue在router下的一个component,是介于component和web page之间的感觉。

webpack对于多entry打包,并没有强制要求每个entry必须是一个隔离的包裹,是允许多个入口点引入到同一个页面中的,因此有了对runtime的独立打包配置:optimization.runtimeChunk

Imported modules are initialized for each runtime chunk separately, so if you include multiple entry points on a page, beware of this behavior. You will probably want to set it to single or use another configuration that allows you to only have one runtime instance.

你提到的Vue的扩展是一个很好的需要隔离的例子。但如果我们引入axios呢,一般我们会在app.js统一配置axios的adapter、interceptors等,然后每个页面直接require axios,直接使用就好了,而无需每个page都要对axios进行一次初始化配置,大家共享一个实例就好了。当然可以把app.js初始化的axios实例通过globalData进行传递,但这个使用就不那么友好了,而且类似这种共享对象实例还不少。

这是一种机制问题,kbone框架本身使用了这种机制,然而对page的约束限制了这种机制的使用;但是使用kbone + webpack打包后,所有代码都无法逃离这个框架。也许使用自定义组件可以?

或者有没有一种方式,可以把一些代码、和指定的第三方依赖包打包放在全局使用?

所以换个思路来,如果我提供一个页面间通信方案和共享存储区域能否解决你的问题?

这里涉及到的其实是个软件工程问题,如果单纯的实现页面通信或者数据共享,直接用globalData就可以了。主要是同一个js module在页面间的唯一性机制没有了,很多第三方包的使用会有问题。无法直接使用了。像kbone那样连Event、Node等都从0造起,对大多数公司和团队来说是做不到的。

huadong commented 4 years ago

@JuneAndGreen 再举个例子,你看看如果用kbone去做一个多tab的小程序,实现类似QQ音乐APP的底部浮动播放器的功能,会有什么不便利的地方。

功能细节要求:

  1. 如果有音乐在播放,每个tab page都出现浮动播放器,没有音乐,则不出现。
  2. 下拉Android顶部菜单,或者上划iOS底部菜单,通过Android、iOS系统界面停止/播放音乐播放,要求小程序每个浮动播放器的播放状态自动从播放变成停止/播放状态。
YikaJ commented 4 years ago

小程序在 kbone 跨页面使用 Event 库也有这类问题,一个页面监听的事件,另一个页面触发也不会生效的。我能想到的就是像这类需要跨页面使用的库,只能通过 getApp 挂载到小程序全局上,再来使用了。但对应到 web 的多页,又没法找到合适的办法进行兼容。

或者说官方是否有针对这类情况提供一个推荐的兼容解决方案?

JuneAndGreen commented 4 years ago

主要还是最开始的前提问题,因为 Web 端页面是隔离的,所以肯定不能破坏这个隔离的机制。你这种应该是单纯想用 web 语法来编写小程序,所以更重视小程序的设计而不是偏向于 Web 端的设计,这个得思忖一下有没有其他的思路才行,比如你的建议:把部分文件打到公共 runtime 之类。这个我记录一下。

huadong commented 4 years ago

这两天尝试修改了一下kbone的webpack插件mp-webpack-plugin,使用optimization.runtimeChunk = single进行打包,调整了一下webpack的runtime引用方式,在app.js引入runtime,各pages.js复用这个runtime,总算进一步理解kbone的window囚笼了:

function wrapChunks(compilation, chunks, globalVarsConfig) {
    chunks.forEach(chunk => {
        chunk.files.forEach(fileName => {
            if (ModuleFilenameHelpers.matchObject({test: /\.js$/}, fileName)) {
                // 页面 js
                const headerContent = 'module.exports = function(window, document) {const App = function(options) {window.appOptions = options};' + globalVars.map(item => `var ${item} = window.${item}`).join(';') + ';'
                let customHeaderContent = globalVarsConfig.map(item => `var ${item[0]} = ${item[1] ? item[1] : 'window[\'' + item[0] + '\']'}`).join(';')
                customHeaderContent = customHeaderContent ? customHeaderContent + ';' : ''
                const footerContent = '}'

                compilation.assets[fileName] = new ConcatSource(headerContent + customHeaderContent, compilation.assets[fileName], footerContent)
            }
        })
    })
}

这里的:

function(window, document) {}

由webpack bundle + window 组成的囚笼确实很牢固的,虽然可能可以通过webpack的optimization.splitChunks进行深度的打包配置,再加上使用mp-webpack-plugin进行bundle拆离引入,但可能还是很晦涩。这让我想起了GitHub CTO Jason Warner说的: 如果你发现自己受制于你所写的技术,那么在接下来的 18 个月里,你将会陷入痛苦的漩涡中。

这意味着在kbone当前的设计下,很难实现全局的状态管理机制,类似redux、vuex、mobix之类的。

算来算去,kbone有点“鸡肋”了,感觉有点可惜。

其实我希望的不是单纯用web的语法来写小程序,而是希望能在写小程序的时候可以引用海量的、成熟的、可复用的项目代码。

JuneAndGreen commented 4 years ago

这样理解不对,kbone 本身是作为适配器一般的存在,来支持原本的 Web 端代码在小程序运行,Web 端代码是按页面粒度分离,所以到小程序端亦是如此实现。

但是想两个平台完全互通兼容目前来说确实是做不到的。事实上在 Web 端两个页面的全局状态也是不互通的,除非是 spa 中的单页切换,页面不刷新就可以保留全局状态。这也就是设计问题,小程序的页面对标了多页 Web 应用的页面,而不是 spa 中的单页。

如果想将 spa 直接对标一个小程序,这是另一种不错的设计思路,但是孰优孰劣其实没法直接衡量,也许后面可以尝试给出另一种插件来实现这个设计。

YikaJ commented 4 years ago

这样理解不对,kbone 本身是作为适配器一般的存在,来支持原本的 Web 端代码在小程序运行,Web 端代码是按页面粒度分离,所以到小程序端亦是如此实现。

但是想两个平台完全互通兼容目前来说确实是做不到的。事实上在 Web 端两个页面的全局状态也是不互通的,除非是 spa 中的单页切换,页面不刷新就可以保留全局状态。这也就是设计问题,小程序的页面对标了多页 Web 应用的页面,而不是 spa 中的单页。

如果想将 spa 直接对标一个小程序,这是另一种不错的设计思路,但是孰优孰劣其实没法直接衡量,也许后面可以尝试给出另一种插件来实现这个设计。

我们就是正在尝试采用该方法,小程序的多页对应 Web 的 SPA,这样可以让两个平台都有比较正常的开发和使用体验。 @Ryqsky

huadong commented 4 years ago

这样理解不对,kbone 本身是作为适配器一般的存在,来支持原本的 Web 端代码在小程序运行,Web 端代码是按页面粒度分离,所以到小程序端亦是如此实现。

让10多亿人内置kbone的核心组件,同时在小程序社区进行了大力的宣扬,而且围绕kbone还做了很多的支持性开发,难免对kbone有所期许。因为对于已存在的Web端代码,其后端一般是连着公众号的业务代码,按目前微信的技术生态体系,它的迁移还是需要颇费一番功夫。与其迁移,不如重写一个小程序版的;而且好多微站都是技术服务商提供的开发。

如果想将 spa 直接对标一个小程序,这是另一种不错的设计思路,但是孰优孰劣其实没法直接衡量,也许后面可以尝试给出另一种插件来实现这个设计。

反过来,按小程序目前的runtime设计,更适合对标的就是SPA。我觉得今天微信小程序所获得的发展,和小程序类似SPA的开发技术、同时在用户操作上更贴近APP的习惯是分不开的。相当于复用了熟悉SPA技术的程序员,解决了H5微站不那么友好的用户操作问题。

Kbone的整体设计思路、代码行文可圈可点,通过简单的webpack打包配置和mp-webpack-plugin的少量修改,可以实现webpack的runtime按全局配置还是按照pages独立配置。当前通过createPage()封装调用,使得page+window+document是捆绑的;如果修改kbone的miniprogram-render(当然这么一改就失去了微信小程序的内置😭),是不是可以实现SPA的对标?拿Vue举例,小程序的多pages相当于SPA里面的多Vue实例(多root)。

对前端技术没太多的研究,应该还有更好的实现方案。还是那句话:“kbone当前的支持能力,有点可惜了。”期待它的进一步发展!

JuneAndGreen commented 4 years ago

跨页 cookie 已支持,即所有页面 cookie 共享一个存储。已发布在 miniprogram-render@0.8.0 & miniprogram-element@0.8.0 上,相关文档:https://wechat-miniprogram.github.io/kbone/docs/config/#runtime-cookiestore

huadong commented 4 years ago

cookie 这么parse会有bug的:

        // key-value
        const parseKeyValue = /^([^=;\x00-\x1F]+)=([^;\n\r\0\x00-\x1F]*).*/.exec(cookieStr.shift())
        if (!parseKeyValue) return null

        const key = (parseKeyValue[1] || '').trim()
        const value = (parseKeyValue[2] || '').trim()

我提交一个PR吧 #81 。

JuneAndGreen commented 4 years ago

此 pr 有问题哈,cookie 如此解析主要是要对齐 document.cookie 的 setter 方法。

huadong commented 4 years ago

腾讯小程序的不同版本API也会有Bug。出现过第一下不是key=value的版本。

JuneAndGreen commented 4 years ago

如果是微信小程序/公众平台/开放平台这边提供的接口出现这样的问题,可以到 https://developers.weixin.qq.com/ 社区这边提问哈,把规范甩上去。如果是其他应用提供的接口,估计得去相应的反馈渠道去反馈了。

xmsz commented 4 years ago

我的看法

确实对于大多数人来说,小程序对标的是Web的单页

市面上的框架也好,插件也好都是把改小程序改造的更像单页应用。这个是大家默认的方向和事情。 所以之前第一次用 kbone 的时候,是有点满头雾水的。因为我不知道如果把小程序做成多页的优势是什么? 是类似与一些其他同构方案,实现在原有框架中,嵌入一个别的框架写的页面?

变成多页的好处没有,倒是局限变得多了。这一点更难理解,明明大部分情况下使用单页就是为了优化多页的体验,现在又变回去了,只为了做到规范的「隔离」?

所以我也很矛盾,因为一方面如果只站在技术的角度,我觉得kbone 的做法合情合理,也本来就是这么做。 但是从使用上、目的上又希望把小程序做出单页,这样更贴近需求

huadong commented 4 years ago

对标问题:

应用场景不太一样。目前大家都是对标着APP开发小程序的。

JuneAndGreen commented 4 years ago

已提供跨页面的通信和数据存储方法,文档:https://wechat-miniprogram.github.io/kbone/docs/guide/advanced.html#%E8%B7%A8%E9%A1%B5%E9%9D%A2%E9%80%9A%E4%BF%A1%E5%92%8C%E8%B7%A8%E9%A1%B5%E9%9D%A2%E6%95%B0%E6%8D%AE%E5%85%B1%E4%BA%AB

参考例子(此例子中所有页面共享一个 vuex 的 state):https://github.com/Tencent/kbone/tree/develop/examples/demo22

hanjunspirit commented 4 years ago

@JuneAndGreen demo22中的共享vuex state的方式存在一定的问题:

一个是内存泄漏问题: 多个页面的store使用的是同一个state对象,每个页面都把state对象定义成响应式的(也就是给每个属性定义getter,setter,第二个页面的getter,setter会在前一个的基础上再套一层),当页面被销毁时,这些getter,setter还在。 当你把一个对象赋值到state的某个属性时会触setter,vue会把这个对象定义成响应式的,问题在于这些被销毁的页面的setter仍然在定义自己的响应式。显然这些响应式永远用不着,这里会有一定的内存泄漏

一个是响应式对象的ob属性相互覆盖问题: vue会为响应式对象添加ob属性,来表示这个对象已经是响应式的。多个页面的ob属性会相互覆盖,这导致某些依赖这个ob属性的场景会出现非预期的结果,比如以下两点需要注意

比如把一个已经被定义成响应式的对象赋值到state的某个属性时,因为vue内部是通过instanceof Observer来判断的 image 多个页面的Observer不是同一个对象。这会导致vue认为这对象不是响应式的,会重复定义响应式。如果频繁设置会导致严重的内存泄露。

比如调用数组的push,vue通过改写数组push,unshift,splice方法来实现数组这类变化的监听,只有ob属性所属的页面才能收到变化的通知。 image

JuneAndGreen commented 4 years ago

@hanjunspirit 明白,确实可能有这个问题,先前写这个 demo 考虑不够周详。我看看能否提供一个工具方法来处理这个问题。

JuneAndGreen commented 3 years ago

@JuneAndGreen demo22中的共享vuex state的方式存在一定的问题:

一个是内存泄漏问题: 多个页面的store使用的是同一个state对象,每个页面都把state对象定义成响应式的(也就是给每个属性定义getter,setter,第二个页面的getter,setter会在前一个的基础上再套一层),当页面被销毁时,这些getter,setter还在。 当你把一个对象赋值到state的某个属性时会触setter,vue会把这个对象定义成响应式的,问题在于这些被销毁的页面的setter仍然在定义自己的响应式。显然这些响应式永远用不着,这里会有一定的内存泄漏

一个是响应式对象的ob属性相互覆盖问题: vue会为响应式对象添加ob属性,来表示这个对象已经是响应式的。多个页面的ob属性会相互覆盖,这导致某些依赖这个ob属性的场景会出现非预期的结果,比如以下两点需要注意

比如把一个已经被定义成响应式的对象赋值到state的某个属性时,因为vue内部是通过instanceof Observer来判断的 image 多个页面的Observer不是同一个对象。这会导致vue认为这对象不是响应式的,会重复定义响应式。如果频繁设置会导致严重的内存泄露。

比如调用数组的push,vue通过改写数组push,unshift,splice方法来实现数组这类变化的监听,只有ob属性所属的页面才能收到变化的通知。 image

内存泄漏问题,目前暂时没有其他更优雅的方法,还是比较建议每次页面进入时刷新 state 的方式来干掉其他页面加入的 getter 和 setter。具体可参考 demo22。