何为跨端
write once, run everywhere
正如这句话所描述的一样,一次编写,四处运行就是跨端的真谛。因为我们目前的场景实在是太多了,比如 android
、 ios
、 pc
、小程序,甚至智能手表、车载电视等,当某几个场景非常相似的时候,我们希望能够用最少的开发成本来达到最好的效果,而不是每个端都需要一套单独的人力来进行维护,这个时候,跨端技术就诞生了。
那么在跨端方案百花齐放的今天,比如现在最为人们所熟知的 react native
、 flutter
、 electron
等,他们之间有没有什么共同的特点,而我们又是否能够找到其中的本质,就是今天这篇文章想讲述的问题。
各种跨端实现方案
h5 hybrid 方案
其实,浏览器本就是一个跨端实现方案,因为你只需要输入网址,就能在任何端的浏览器上打开你的网页。那么,如果我们把浏览器嵌入 app
中,再将地址栏等内容隐藏掉,是不是就能将我们的网页嵌入原生 app
了。而这个嵌入 app
的浏览器,我们把它称之为 webview
,所以只要某个端支持 webview
,那么它就能使用这种方案跨端。同时这也是开发成本最小的一种方案,因为这实际上就是在写前端界面,和我们开发普通的网页并没有任何区别。
框架层+原生渲染
这是什么意思呢,最典型的代表就是 react-native
。它的开发语言选择了 js
,使用的语法和 react
完全一致,其实也可以说它就是 react
,这就是我们的框架层。而不同于 react
的是它渲染的时候需要借用原生的能力来进行渲染,也就是说我们的组件最终都会被渲染为原生组件,这可以给用户带来比较好的体验。
框架层+自渲染引擎
这种方案和上面的区别就是,它并没有直接借用原生能力去渲染组件,而是利用了更底层的渲染能力,自己去渲染组件。这种方式显然链路会比上述方案的链路跟短,那么性能也就会更好,同时在保证多端渲染一致性上也会比上一种方案更加可靠。这类框架的典型例子就是 flutter
。
另类跨端
众所周知,在最近几年有一个东西变得非常火爆:小程序。这相当于各个厂商将自家 app
的生态开放给了外部开发者,是一个顺应时代而诞生的产物。那么问题就来了,腾讯字节百度这三个属于竞对关系的厂商肯定不可能共同定制一套规范,所以也就出现了字节小程序,百度小程序,微信小程序。虽然大家都是照着微信小程序抄出来的,但是实现总归还是有些许不同,所以就需要开发者针对不同的小程序各自开发一套,这显然也是很耗费时间的事情。所以这时候 taro
这类框架就横空而出,这是一个可以用一套代码来开发出适应各种小程序的框架,这也算是一种另类的跨端,至于它的实现方式还是比较有意思的,我们后续再详细介绍。
react-native 具体实现方案
rn的三个线程
rn
目前主要包括了三个线程
-
native thread
-
js thread
-
shadow thread
native thread
这个线程主要负责原生渲染和调用原生能力。
js thread
js
线程用于解释和执行我们的 js
代码。在大多数情况下, react native
使用的 js
引擎是JSC(
JavaScriptCore) ,在使用 chrome
调试时,所有的 js
代码都运行在 chrome
中,并且通过 与原 websocket
生代码通信。此时的运行环境是 v8
。
shadow thread
要渲染到界面上一个很重要的步骤就是布局,我们需要知道每个组件应该渲染到什么位置,这个过程就是通过 yoga
去实现的,这是一个基于 flexbox
的跨平台布局引擎。 shadow thread
会维护一个 shadow tree
来计算我们的各个组件在 native
页面的实际布局,然后通过 bridge
通知 native thread
渲染 ui
。
初始化流程
-
native
启动一个原生界面,比如android
会起一个新的activity
来承载rn
,并做一些初始化的操作。 -
加载
js
引擎,运行我们的js
代码,此时的流程和react
的启动流程就非常相似了。 -
js
线程通知shadow thread
。 -
shadow thread
计算布局,通知native Thread
创建原生组件。 -
native
在界面上渲染原生组件,呈现给用户。
更新流程
比如某个时候,用户点击了屏幕上的一个按钮触发了一个点击事件,此时界面需要进行相应的更新操作。
-
native
获取到了点击事件,传给了js thread
-
js thread
根据react
代码进行相应的处理,比如处理onClick
函数,触发了setState
。 -
和
react
的更新流程一样,触发了setState
之后会进行diff
,找到需要更新的结点 -
通知
shadow thread
-
shadow thread
计算布局之后通知native thread
进行真正的渲染。
特点
我们上述说的通知,都是通过 bridge
实现的, bridge
本身是用实现 C++
的,就像一座桥一样,将各个模块关联起来,整个通信是一个异步的过程。这样做好处就是各自之间不会有阻塞关系,比如 native thread
不会因为 js thread
而阻塞渲染,给用户良好的体验。但是这种异步也存在一个比较明显的问题:在一些时效性要求较高场景上体验较差。
比如长列表快速滚动的时候或者需要做一些跟手的动画,整个过程是这样的:
-
native thread
监听到了滚动事件,发送消息通知js thread
-
js thread
处理滚动事件,如果需要修改state
需要经过一层js diff
,拿到最终需要更新的结点 -
js thread
通知shadow thread
-
shadow thread
通知native
渲染
而这整个过程其实都是异步的,所以就会导致在快速滑动的时候会出现白屏、卡顿的现象。
从rn看本质
那么既然我们知道了 rn
是如何实现的跨端,那么我们就可以来探究一下它本质上是在干什么。首先,跨端可以分为逻辑跨端和渲染跨端。
逻辑跨端通常通过 vm
来实现,例如利用 v8
引擎,我们就能在各个平台上运行我们的 js
代码,实现逻辑跨端。
那么第二个问题就是渲染的跨端,我们把业务代码的实现抽象为开发层,比如 react-native
中我们写的 react
代码就属于开发层,再把具体要渲染的端称为渲染层。作为开发层来说,我一定知道我想要的 ui
长什么样,但是我没有能力去渲染到界面上,所以当我声明了一个组件之后,我们需要考虑的问题是如何把我想要什么告诉渲染层。
就像这样的关系,那么我们最直观的方式肯定是我能够实现一种通信方式,在开发层将消息通知到各个系统,再由各个系统自己去调用对应的 api
来实现最终的渲染。
function render() {
if(A) {
message.sendA('render', { type: 'View' })
}
if(B) {
message.sendB('render', { type: 'View' })
}
if(C) {
message.sendC('render', { type: 'View' })
}
}
复制代码
比如这样,我就能通过判断平台来通知对应的端去渲染 View
组件。这一部分的工作就是跨端框架需要帮助我们做的,它可以把这一步放到 JS
层,也可以把这一步放到 c++
层。我们应该把这部分工作尽量往底层放,也就是我们可以对各个平台的 api
进行一层封装,上层只负责调用封装的 api
,再由这一层封装层去调用真正的 api
。因为这样可以复用更多的逻辑,否则像上文中我们在 JS
层去发送消息给不同的平台,我们就需要在A\B\C三个平台写三个不同方法去渲染组件。
但是,归根结底就是,一定有一个地方是通过判断不同平台来调用具体实现,也就是下面这样
有一个地方会对系统进行判断,再通过某种通信方式通知到对应的端,最后执行真正的方法。个人认为,所有跨端相关操作其实都在做上图中的这些事情。
比如 hybrid
跨端方案中, webview
其实就充当了桥接层的角色, createElement
, appendChild
等 api
就是给我们封装好的跨平台 api
,底层最终调用到了什么地方,又是如何渲染到界面上的细节都被屏蔽掉了。所以我们利用这些 api
就能很轻松的实现跨端开发,写一个网页,只要能够加载 webview
的地方,我们的代码就能跑在这个上面。
又比如 flutter
的方案通过研发一个自渲染的引擎来实现跨端,这种思路是不是相当于另外一个浏览器?但是不同的点在于 flutter
是一个非常新的东西,而 webview
需要遵循大量的 w3c
规范和背负一堆历史包袱。 flutter
并没有什么历史包袱,所以它能够从架构,设计的方面去做的更好更快,能够做更多的事情。
跨端目前有什么问题
如何做的更好
对于跨端来说,如何屏蔽好各端的细节至关重要,比如针对某个端特有的api如何处理,如何保证渲染细节上各个端始终保持一致。如果一个跨端框架能够让开发者的代码里面不出现 isIos
、 isAndroid
的字眼,或者是为了兼容各种奇怪的渲染而产生的非常诡异的 hack
方式。那我认为它绝对是一个真正成功的框架。可惜就我个人目前而言,先后写过了 h5
、 rn
、小程序,他们都没有真正做到这一点,所以项目里面会出现各种奇奇怪怪的代码。而这个问题其实也是非常难解决的,因为各端的差异还是比较大的,所以说很难去完全屏蔽这些细节。
最经典的就是 h5
中磨人的垂直居中问题,我相信只要开发过移动端页面的都会遇见,就不用我多说了。
如何做的更快
为什么大家其实本质上都是在干一件事情,却出现了这么多的解决方案?因为想要更快更好的性能。我们之前说到 rn
的架构导致了在某些情况下它的表现其实并没有很尽人意, rn
其实自己本身也注意到了这个问题,所以已经在架构上去调整,想要去解决这个问题。而 flutter
的产生也是为了追求更快的性能,所以大家都想通过各种方式去追去极致的性能。
小程序跨端
ok,说了这么多,对于跨端部分的内容其实我想说的已经说的差不多了,还记得上文中跨端实现方案中的跨小程序方案么。为什么说它是另类的跨端,因为它其实并没有实际跨端,只是为了解决各个小程序语法之间不兼容的问题。但是它又确实是一个跨端解决方案,因为它符合 write once, run everything。 下面我们首先介绍一下小程序的背景
什么是小程序
小程序是各个 app
厂商对外开放的一种能力。通过厂商提供的框架,就能在他们的 app
中运行自己的小程序,借助各大 app
的流量来开展自己的业务。同时作为厂商如果能吸引到更多的人加入到开发者大军中来,也能给 app
带来给多的流量,这可以看作一个双赢的业务。那么最终呈现在 app
中的页面是以什么方式进行渲染的呢?其实还是通过 webview
,但是会嵌入一些原生的组件在里面以提供更好的用户体验,比如 video
组件其实并不是 h5 video
,而是 native video
。
什么是小程序跨端
那么到了这里,我们就可以来谈一谈关于小程序跨端的东西了。关于小程序跨端,其实核心内容并不是真正意义上的跨端,虽然小程序也做到了跨端,例如一份代码其实是可以跑在 android
和 Ios
上的,但是实际上这和 hybrid
跨端十分相似。在这里我们想说的其实是,市面上现在有非常多的小程序:字节小程序、百度小程序、微信小程序、支付宝小程序等等等等。虽然他们的 dsl
十分相似,但是终归还是有所不同,那么就意味着如果我想在多个 app
上去开展我的业务,我是否需要维护多套十分相似的代码?我又能否通过一套代码能够跑在各种小程序上?
怎么做
想通过一套代码跑在多个小程序上,和想通过一套代码跑在多个端,这两件事到底是不是一件事呢?我们再回到这张图
在之前的跨端意义中,平台A、B、C可能代表了安卓、苹果或者PC,那么它其实是不是也可以代表字节小程序、百度小程序、微信小程序?我们照抄rn的例子,在开发层,我们还是使用 react
的语法,然后再根据对应的小程序端,写对应的渲染器。然后通过开发层的 react
我们能拿到 vdom
,再将 vdom
传递给渲染器就能让渲染器帮助我们将其真正的渲染到界面上。整个流程是下面这样的:
在开发层经历一遍完整的 react
流程之后,会将最后的结果给到各个小程序,然后再走一遍小程序的 diff
,通信等过程,将内容渲染到界面上。
采用这种做法的典型例子有 remax
、 taro3
,他们宣称用真正的 react
去开发小程序,其实并没有错,因为真的是把 react
的整套东西都搬了过来,和 react
并无差异。
那么这样的架构有什么问题呢,可以很明显的看到会走两遍 diff
,如果我们能够直接对接小程序的渲染 sdk
,那么其实根本没必要走两遍 diff
,通过 react
的 diff
我们已经能够知道需要更新什么内容。而这个问题产生的根本原因是什么,其实就是小程序的 dsl
太垃圾,大家想用一个更好的开发方式去开发小程序,所以才有了用 react
去写小程序的想法,才出来了这一套东西。
其实,这个的本质和普通意义上的跨端框架没有太大的区别,开发层也就是 react
知道自己需要什么东西,但是它没有能力去渲染到界面上,所以需要通过小程序充当渲染层来渲染到真正的界面上。这种开发方式有一种用 react
去写 vue
的意思,但是大家可以思考一下,为什么会出现这种诡异的开发方式,如果这个 vue
做的足够好的话,谁又想去这样折腾。
另一种粗暴的跨端
上述的这些跨端都是通过某种架构方式去实现的,那如果我们粗暴一点的想,我能不能直接把一套代码通过编译的方式去编译到不同的平台。比如我把 js
代码编译成 java
代码、 object-c
代码,其实,个人感觉也不是不行,但是因为这些的差异实在太大,所以在写 js
代码的时候,可能需要非常强的约束性、规范性,把开发者限制在某个区域内,才能很好的编译过去。也就是说,从 js
到 java
其实是一个自由度高到自由度低的一个过程,肯定是无法完全一一对应上的,并且由于开发方式、语法完全不一样,所以想通过编译的方式将 js
编译到 ios
和 android
上去还是比较难的,但是对于小程序来说,尝试把 jsx
编译到 template
似乎是一个可行的方案,实际上,taro1/2 都是这么干的。不过从 jsx
到 template
也是一个自由度从高到低的一个过程,所以个人感觉,肯定是没办法很完美的把所有语法都编译到 template
…
vdom对于跨端的意义
提到跨端,可能很多人第一个想到的东西就是 virtual dom
,因为它是对于 ui
的抽象,脱离了平台,所以可能很多人会觉得 virtual dom
和跨平台已经是绑定在一起的东西了。但是其实个人感觉并不是。
我们之前说到的跨平台的本质是什么?开发层知道自己想要什么,然后告诉渲染层自己想要什么,就这么简单。那对于 react-native
来说,是通过 virtual dom
来判断自己需要更新什么结点的吗?其实并不是,单靠一个 virtual dom
还不足以获取到这个信息,必须还要加上 diff
,所以是 virtual dom
+ diff
获取到了自己想要什么的信息,再通过通信的方式告诉 native
去更新真正的结点。
所以说, virtual dom
在这个里面其实只扮演了一个获取方法的角色,是通过 virtual dom
+ diff
这个方法拿到了我们想要的东西,换而言之,我们也可以通过其他的方法来拿到我们想要什么。对于一些没有virtual dom
的框架,比如百度的san
,它也是能够跨平台的。我们先不管它内部是如何实现的,但是在更新阶段,如果它在某个时刻调用了 createElement
,那么它一定是知道了:自己想要什么。对应上跨端的内容,这个时候就能通过某种手段去告诉 native
,渲染某个东西。
所以,当我们通过其他手段获取到了:我们想要什么这个信息之后,就能通知 native
去渲染真正的内容。
那么 vdom
的优势在于什么地方?个人认为主要是下面两个:
-
开创
jsx
新时代,函数式编程思想 -
强大的表达力。能够使用
template
获取更多优化信息,又能够支持jsx
首先, jsx
的提出我觉得是开创了一个新时代,让我们能够以函数式编程思想去写 ui
,之前谁能想到一个切图仔还能用这样的方式去写 ui
。
其次,我们知道, vue
虽然是使用的 template
作为 dsl
,但是实际上我们也是可以写 jsx
的, jsx
所提供的灵活能力是 template
无法比拟的。而之所以能够同时支持 template
和 jsx
其实就是因为 vdom
的存在,如果 vue
不引入 vdom
,是没办法说去支持 jsx
的语法的,或者说,是没办法去支持真正的 jsx
。
结语
所以,跨端其实就是:我知道我想要什么+我告诉别人我想要什么。他们的本质都非常简单,但是细节却非常难处理,同时对于目前市面上的多种跨端框架,也需要大家根据自己的项目去权衡利弊选择一个最有方案,毕竟目前没有一个框架可以说我能够吊打所有其他框架,适合自己的才是最好的。