来自团队 匡凌熙 同学的分享

零、何为跨端write once, run everywhere

一次编写,四处运行就是跨端的真谛。因为前端当下需要处理的场景实在是太多了:android、ios、pc、小程序,甚至智能手表、车载电视等,当某几个场景非常相似的时候,我们希望能够用最少的开发成本来达到最好的效果,而不是每个端都需要一套单独的人力来进行维护,所以跨端技术就诞生了。

那么在跨端方案百花齐放的今天,比如现在最为人们所熟知的react native、flutter、electron等,他们之间有没有什么共同的特点,而我们又是否能够找到其中的本质,就是今天这篇文章想讲述的问题。

一、主流跨端实现方案1.1 h5 hybrid 方案其实,浏览器本就是一个跨端实现方案,因为你只需要输入网址,就能在任何端的浏览器上打开你的网页。那么,如果我们把浏览器嵌入 app 中,再将地址栏等内容隐藏掉,是不是就能将我们的网页嵌入原生 app 了。而这个嵌入 app 的浏览器,我们把它称之为 webview ,所以只要某个端支持 webview ,那么它就能使用这种方案跨端。

同时这也是开发成本最小的一种方案,因为这实际上就是在写前端界面,和我们开发普通的网页并没有太大区别。

1.2 框架层+原生渲染典型的代表是 react-native,它的开发语言选择了 js,使用的语法和 react 完全一致,其实也可以说它就是 react,这就是我们的框架层。而不同于一般 react 应用,它需要借助原生的能力来进行渲染,组件最终都会被渲染为原生组件,这可以给用户带来比较好的体验。

1.3 框架层+自渲染引擎这种方案和上面的区别就是,它并没有直接借用原生能力去渲染组件,而是利用了更底层的渲染能力,自己去渲染组件。这种方式显然链路会比上述方案的链路跟短,那么性能也就会更好,同时在保证多端渲染一致性上也会比上一种方案更加可靠。这类框架的典型例子就是 flutter 。

1.4 另类跨端众所周知,在最近几年有一个东西变得非常火爆:小程序,现在许多大厂都有一套自己的小程序实现,但相互之间还是有不小差异的,通常可以借助 taro ,remax 这类框架实现一套代码,多端运行的效果,这也算是一种另类的跨端,它的实现方式还是比较有意思的,我们后面会展开细讲。

二、react-native 实现2.1 rn的三个线程rn 包含三个线程:

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。2.2 初始化流程native 启动一个原生界面,比如android会起一个新的activity来承载rn,并做一些初始化的操作。加载 js 引擎,运行 js 代码,此时的流程和 react 的启动流程就非常相似了,我们先简单观察调用栈,是不是看见了一些非常熟悉的函数名,在上一讲的基本原理中已经提到过了,这里我们就不再赘述。同时再看一下FiberNode的结构,也和react的保持一致,只不过我们在js层是无法拿到真实结点的,所以stateNode只是一个代号。

js 线程通知shadow thread。在react中,走到createInstance以后我们就可以直接调用createElement来创建真实结点了,但是在rn中我们没办法做到这一步,所以我们会通知native层让它来帮助我们创建一个对应的真实结点。shadow thread 计算布局,通知native Thread 创建原生组件。native 在界面上渲染原生组件,呈现给用户。2.3 更新流程比如某个时候,用户点击了屏幕上的一个按钮触发了一个点击事件,此时界面需要进行相应的更新操作。

native 获取到了点击事件,传给了js threadjs thread根据 react 代码进行相应的处理,比如处理 onClick 函数,触发了 setState。和 react 的更新流程一样,触发了 setState 之后会进行 diff,找到需要更新的结点通知 shadow threadshadow thread 计算布局之后通知 native thread 进行真正的渲染。2.4 特点我们上述说的通知,都是通过 bridge 实现的,bridge本身是用实现C++的,就像一座桥一样,将各个模块关联起来,整个通信是一个「异步」的过程。这样做好处就是各自之间不会有阻塞关系,比如 不会native thread因为js thread而阻塞渲染,给用户良好的体验。但是这种「异步」也存在一个比较明显的问题:因为通信过程花费的时间比较长,所以在一些时效性要求较高场景上体验较差。

比如长列表快速滚动的时候或者需要做一些跟手的动画,整个过程是这样的:

native thread监听到了滚动事件,发送消息通知js threadjs thread 处理滚动事件,如果需要修改 state 需要经过一层js diff,拿到最终需要更新的结点js thread 通知 shadow threadshadow thread 通知 native 渲染当用户操作过快的时候,就会导致界面来不及更新,进而导致在快速滑动的时候会出现白屏、卡顿的现象。

2.5 优化我们很容易看出,这是由rn的架构引出的问题,其实小程序的架构也会有这个问题,所以在rn和小程序上出现一些需要频繁通信的场景时,就会导致页面非常差,流畅度降低。那么如果想解决这个问题,势必要从架构上去进行修改。

三、从rn看本质那么既然我们知道了rn是如何实现的跨端,那么我们就可以来探究一下它本质上是在干什么。首先,跨端可以分为「逻辑跨端」和「渲染跨端」。

「逻辑跨端」通常通过 vm来实现,例如利用 v8 引擎,我们就能在各个平台上运行我们的 js 代码,实现「逻辑跨端」。

那么第二个问题就是「渲染跨端」,我们把业务代码的实现抽象为开发层,比如 react-native 中我们写的 react 代码就属于开发层,再把具体要渲染的端称为渲染层。作为开发层来说,我一定知道我想要的ui长什么样,但是我没有能力去渲染到界面上,所以当我声明了一个组件之后,我们需要考虑的问题是如何把我想要什么告诉渲染层。

就像这样的关系,那么我们最直观的方式肯定是我能够实现一种通信方式,在开发层将消息通知到各个系统,再由各个系统自己去调用对应的 api 来实现最终的渲染。

代码语言:javascript代码运行次数:0运行复制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 并没有什么历史包袱,所以它能够从架构,设计的方面去做的更好更快,能够做更多的事情。

四、跨端目前有什么问题4.1 一致性对于跨端来说,如何屏蔽好各端的细节至关重要,比如针对某个端特有的api如何处理,如何保证渲染细节上各个端始终保持一致。如果一个跨端框架能够让开发者的代码里面不出现 isIos、isAndroid的字眼,或者是为了兼容各种奇怪的渲染而产生的非常诡异的hack方式。那我认为它绝对是一个真正成功的框架。

但是按我经验而言,先后写过的 h5、rn、小程序,他们都没有真正做到这一点,所以项目里面会出现为了解决不同端不一致问题而出现的各种奇奇怪怪的代码。而这个问题其实也是非常难解决的,因为各端的差异还是比较大的,所以说很难去完全屏蔽这些细节。

比如说h5中磨人的垂直居中问题,我相信只要开发过移动端页面的都会遇见,就不用我多说了。

4.2 为什么出现了这么多框架为什么大家其实本质上都是在干一件事情,却出现了这么多的解决方案?其实大家都觉得某些框架没能很好的解决某个问题,所以想自己去造一套。其中可能很多开发者最关心的就是性能问题,比如:

rn因为架构上的原因导致某些场景性能差,所以它就想办法从架构上去进行修改。flutter直接自己搞了一套渲染引擎,同时选用支持AOT的dart作为开发语言。但是其实我们在选择框架的时候性能并不是唯一因素,开发体验、框架生态这些也都是关键因素,我个人感受是,目前rn的生态还是比其他的要好,所以在开发过程中你想要的东西基本都有。

五、小程序跨端ok,说了这么多,对于跨端部分的内容其实我想说的已经说的差不多了,还记得上文提到的 Taro、Uni-app 一类跨小程序方案么。为什么说它是另类的跨端,因为它其实并没有实际跨端,只是为了解决各个小程序语法之间不兼容的问题。但是它又确实是一个跨端解决方案,因为它符合 「write once, run everything。」

下面我们先来了解下小程序的背景。

5.1 什么是小程序小程序是各个app厂商对外开放的一种能力。通过厂商提供的框架,就能在他们的app中运行自己的小程序,借助各大app的流量来开展自己的业务。同时作为厂商如果能吸引到更多的人加入到开发者大军中来,也能给app带来给多的流量,这可以看作一个双赢的业务。那么最终呈现在app中的页面是以什么方式进行渲染的呢?其实还是通过webview,但是会嵌入一些原生的组件在里面以提供更好的用户体验,比如video组件其实并不是h5 video,而是native video。

5.2 什么是小程序跨端那么到了这里,我们就可以来谈一谈关于小程序跨端的东西了。关于小程序跨端,核心并不是真正意义上的跨端,虽然小程序也做到了跨端,例如一份代码其实是可以跑在android和Ios上的,但是实际上这和hybrid跨端十分相似。

在这里我想说的其实是,市面上现在有非常多的小程序:字节小程序、百度小程序、微信小程序、支付宝小程序等等等等。虽然他们的dsl十分相似,但是终归还是有所不同,那么就意味着如果我想在多个app上去开展我的业务,我是否需要维护多套十分相似的代码?我又能否通过一套代码能够跑在各种小程序上?

5.3 怎么做想通过一套代码跑在多个小程序上,和想通过一套代码跑在多个端,这两件事到底是不是一件事呢?我们再回到这张图

这些平台是否可以对应上不同的小程序?

再回到那句话:「我知道我想要什么,但是我没有能力去渲染,我要通知有能力渲染的人来帮助我渲染。」

现在来理一下我们的需求:

小程序的语法不好用,我希望用 react 开发;我希望尽可能低的成本让小程序跑在多个平台上。那么从这句话来看:「我」代表了什么,「有能力渲染的人」又代表了什么?

第二个很容易对应上,「有能力渲染的人」就是小程序本身,只有它才能帮助我们把内容真正渲染到界面上。

而「我」又是什么呢?其实这个「我」可以是很多东西,不过这里我们的需求是想用react进行开发,所以我们回想一下第一讲中react的核心流程,当它拿到vdom的时候,是不是就已经知道【我想要什么】了?所以我们把react拿到vdom之前的流程搬过来,这样就能获取到「我知道我想要什么」的信息,但是「我没有能力去渲染」,因为这不是web,没有dom api,所以我需要通知小程序来帮助我渲染,我还可以根据不同的端来通知不同的小程序帮助我渲染。

所以整个流程就是下面这样的:

前面三个流程都在我们的js层,也就是开发层,我们写的代码经历一遍完整的 react 流程之后,会将最后的结果给到各个小程序,然后再走小程序自己的内部流程,将其真正的渲染到界面上。

采用这种做法的典型例子有remax、taro3,他们宣称用真正的react去开发小程序,其实并没有错,因为真的是把react的整套东西都搬了过来,和react并无差异。我们用taro写一个非常简单的例子来看一下:

代码语言:javascript代码运行次数:0运行复制import { Component } from 'react'

import { View, Text, Button } from '@tarojs/components'

import './index.css'

export default class Index extends Component {

state = {

random: Math.random()

}

componentWillMount () { }

componentDidMount () { }

componentWillUnmount () { }

componentDidShow () { }

componentDidHide () { }

handleClick = () => {

debugger;

console.log("Math.random()", Math.random());

this.setState({random: Math.random()})

}

render () {

return (

Hello world! {this.state.random}

)

}

}

这是一个用taro写的组件,把它编译到字节小程序之后是这样的效果:

根据我们之前的分析,在最后生成的文件中,一定包含了一个「小程序渲染器」。它接受的data就是整个ui结构,然后通过小程序的渲染能力渲染到界面上,我们去dist文件中找一下,就能找到一个base.ttml的文件,里面的内容是这样的

代码语言:javascript代码运行次数:0运行复制