跨端杂谈

803 阅读24分钟

跨端杂谈

何为跨端

write once, run everywhere

正如这句话所描述的一样,一次编写,四处运行就是跨端的真谛。因为我们目前的场景实在是太多了,比如 androidiospc 、小程序,甚至智能手表、车载电视等,当某几个场景非常相似的时候,我们希望能够用最少的开发成本来达到最好的效果,而不是每个端都需要一套单独的人力来进行维护,这个时候,跨端技术就诞生了。 那么在跨端方案百花齐放的今天,比如现在最为人们所熟知的 react nativeflutterelectron 等,他们之间有没有什么共同的特点,而我们又是否能够找到其中的本质,就是今天这篇文章想讲述的问题。

各种跨端实现方案

h5 hybrid 方案

其实,浏览器本就是一个跨端实现方案,因为你只需要输入网址,就能在任何端的浏览器上打开你的网页。那么,如果我们把浏览器嵌入 app 中,再将地址栏等内容隐藏掉,是不是就能将我们的网页嵌入原生 app 了。而这个嵌入 app 的浏览器,我们把它称之为 webview ,所以只要某个端支持 webview ,那么它就能使用这种方案跨端。同时这也是开发成本最小的一种方案,因为这实际上就是在写前端界面,和我们开发普通的网页并没有任何区别。

框架层+原生渲染

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

框架层+自渲染引擎

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

另类跨端

众所周知,在最近几年有一个东西变得非常火爆:小程序。现在各个厂都有着自己的小程序,那么问题就来了,腾讯字节百度这三个属于竞对关系的厂商肯定不可能共同定制一套规范,所以字节小程序,百度小程序,微信小程序在语法和用法上也各有区别。虽然大家都是照着微信小程序抄出来的,但是实现总归还是有些许不同,所以就需要开发者针对不同的小程序各自开发一套,这显然也是很耗费时间的事情。所以这时候 taroremax 这类框架就横空而出,这是一个可以用一套代码来开发出适应各种小程序的框架,这也算是一种另类的跨端,至于它的实现方式还是比较有意思的,我们后续再详细介绍。

react-native 具体实现方案

image-14-1634364139605.png

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

初始化流程

  1. native 启动一个原生界面,比如 android 会起一个新的 activity 来承载 rn ,并做一些初始化的操作。

  2. 加载 js 引擎,运行我们的 js 代码,此时的流程和 react 的启动流程就非常相似了,我们来简单看一下调用栈,是不是看见了一些非常熟悉的函数名,如果了解react的同学就会知道这是react渲染过程中会调用的一些函数。同时再看一下 FiberNode 的结构,也和 react 的保持一致,只不过我们在 js 层是无法拿到真实结点的,所以 stateNode 只是一个代号。

image-12-1634364137994.png

stateNode.png

  1. js 线程通知 shadow thread 。在 react 中,走到 createInstance 以后我们就可以直接调用 createElement 来创建真实结点了,但是在 rn 中我们没办法做到这一步,所以我们会通知 native 层让它来帮助我们创建一个对应的真实结点。

image-13-1634364138396.png

rnInstance.png

  1. shadow thread 计算布局,通知 native Thread 创建原生组件。

  2. native 在界面上渲染原生组件,呈现给用户。

更新流程

比如某个时候,用户点击了屏幕上的一个按钮触发了一个点击事件,此时界面需要进行相应的更新操作。

  1. native 获取到了点击事件,传给了 js thread

  2. js thread 根据 react 代码进行相应的处理,比如处理 onClick 函数,触发了 setState

  3. react 的更新流程一样,触发了 setState 之后会进行 diff ,找到需要更新的结点

  4. 通知 shadow thread

  5. shadow thread 计算布局之后通知 native thread 进行真正的渲染。

特点

我们上述说的通知,都是通过 bridge 实现的, bridge 本身是用实现 C++ 的,就像一座桥一样,将各个模块关联起来,整个通信是一个 异步 的过程。这样做好处就是各自之间不会有阻塞关系,比如 不会 native thread 因为 js thread 而阻塞渲染,给用户良好的体验。但是这种 异步 也存在一个比较明显的问题:因为通信过程花费的时间比较长,所以在一些时效性要求较高场景上体验较差。

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

  1. native thread 监听到了滚动事件,发送消息通知 js thread

  2. js thread 处理滚动事件,如果需要修改 state 需要经过一层 js diff ,拿到最终需要更新的结点

  3. js thread 通知 shadow thread

  4. shadow thread 通知 native 渲染

当用户操作过快的时候,就会导致界面来不及更新,进而导致在快速滑动的时候会出现白屏、卡顿的现象。

优化

我们很容易看出,这是由 rn 的架构引出的问题,其实小程序的架构也会有这个问题,所以在 rn 和小程序上出现一些需要频繁通信的场景时,就会导致页面非常差,流畅度降低。那么如果想解决这个问题,势必要从架构上去进行修改,具体细节可以参考这篇文章: React Native重大架构升级即将发布

从rn看本质

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

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

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

image-16-1634364142631.png

就像这样的关系,那么我们最直观的方式肯定是我能够实现一种通信方式,在开发层将消息通知到各个系统,再由各个系统自己去调用对应的 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三个平台写三个不同方法去渲染组件。

但是,归根结底就是,一定有一个地方是通过判断不同平台来调用具体实现,也就是下面这样

image-15-1634364140415.png

有一个地方会对系统进行判断,再通过某种通信方式通知到对应的端,最后执行真正的方法。其实,所有跨端相关操作其实都在做上图中的这些事情。所有的跨端也可以总结为下面这句话:

我知道我想要什么,但是我没有能力去渲染,我要通知有能力渲染的人来帮助我渲染

比如 hybrid 跨端方案中, webview 其实就充当了桥接层的角色, createElementappendChildapi 就是给我们封装好的跨平台 api ,底层最终调用到了什么地方,又是如何渲染到界面上的细节都被屏蔽掉了。所以我们利用这些 api 就能很轻松的实现跨端开发,写一个网页,只要能够加载 webview 的地方,我们的代码就能跑在这个上面。

又比如 flutter 的方案通过研发一个自渲染的引擎来实现跨端,这种思路是不是相当于另外一个浏览器?但是不同的点在于 flutter 是一个非常新的东西,而 webview 需要遵循大量的 w3c 规范和背负一堆历史包袱。 flutter 并没有什么历史包袱,所以它能够从架构,设计的方面去做的更好更快,能够做更多的事情。

跨端目前有什么问题

一致性

对于跨端来说,如何屏蔽好各端的细节至关重要,比如针对某个端特有的 api 如何处理,如何保证渲染细节上各个端始终保持一致。如果一个跨端框架能够让开发者的代码里面不出现 isIosisAndroid 的字眼,或者是为了兼容各种奇怪的渲染而产生的非常诡异的 hack 方式。那我认为它绝对是一个真正成功的框架。但是就我个人目前而言,先后写过了 h5rn 、小程序,他们都没有真正做到这一点,所以项目里面会出现为了解决不同端不一致问题而出现的各种奇奇怪怪的代码。而这个问题其实也是非常难解决的,因为各端的差异还是比较大的,所以说很难去完全屏蔽这些细节。

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

为什么出现了这么多框架

为什么大家其实本质上都是在干一件事情,却出现了这么多的解决方案?其实大家都觉得某些框架没能很好的解决某个问题,所以想自己去造一套。其中可能很多开发者最关心的就是性能问题,比如: rn 因为架构上的原因导致某些场景性能差,所以它就想办法从架构上去进行修改。 flutter 直接自己搞了一套渲染引擎,同时选用支持 AOTdart 作为开发语言。

但是其实我们在选择框架的时候性能并不是唯一因素,其实开发体验、框架生态这些也在我们的考虑范围之内,从个人的感受而言,目前 rn 的生态还是比其他的要好,所以在开发过程中你想要的东西基本都有,还是一个不错的选择。

小程序跨端

ok,说了这么多,对于跨端部分的内容其实我想说的已经说的差不多了,还记得上文中跨端实现方案中的跨小程序方案么。为什么说它是另类的跨端,因为它其实并没有实际跨端,只是为了解决各个小程序语法之间不兼容的问题。但是它又确实是一个跨端解决方案,因为它符合 write once, run everything。 下面我们首先介绍一下小程序的背景

什么是小程序

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

什么是小程序跨端

那么到了这里,我们就可以来谈一谈关于小程序跨端的东西了。关于小程序跨端,其实核心内容并不是真正意义上的跨端,虽然小程序也做到了跨端,例如一份代码其实是可以跑在 androidIos 上的,但是实际上这和 hybrid 跨端十分相似。在这里我们想说的其实是,市面上现在有非常多的小程序:字节小程序、百度小程序、微信小程序、支付宝小程序等等等等。虽然他们的 dsl 十分相似,但是终归还是有所不同,那么就意味着如果我想在多个 app 上去开展我的业务,我是否需要维护多套十分相似的代码?我又能否通过一套代码能够跑在各种小程序上?

怎么做

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

image-14-1634364139605.png

再回到那句话: 我知道我想要什么,但是我没有能力去渲染,我要通知有能力渲染的人来帮助我渲染。 现在来理一下我们的需求

小程序的语法太烂了,我不想用小程序的原生语法来进行开发,我想用react进行开发,同时我不仅仅想跑在一个小程序上,我还想我的代码能够同时运行在多个小程序上​小程序的语法太烂了,我不想用小程序的原生语法来进行开发,我想用react进行开发,同时我不仅仅想跑在一个小程序上,我还想我的代码能够同时运行在多个小程序上

那么从这句话来看: 代表了什么, 有能力渲染的人 又代表了什么?

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

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

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

image-17-1634364143234.png

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

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

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 (
      <View className='index'>
        <Text>Hello world! {this.state.random}</Text>
        <Button onClick={this.handleClick}>click</Button>
      </View>
    )
  }
}

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

image-18-1634364144642.png

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

<template name="taro_tmpl">
  <block tt:for="{{[root.cn](http://root.cn/)}}" tt:key="uid">
    <template is="tmpl_0_container" data="{{i:item}}" />
  </block>
</template>

<template name="tmpl_0_catch-view">
  <view hover-class="{{i.hoverClass===undefined?'none':i.hoverClass}}" hover-stop-propagation="{{i.hoverStopPropagation===undefined?false:i.hoverStopPropagation}}" hover-start-time="{{i.hoverStartTime===undefined?50:i.hoverStartTime}}" hover-stay-time="{{i.hoverStayTime===undefined?400:i.hoverStayTime}}" animation="{{i.animation}}" bindtouchstart="eh" bindtouchend="eh" bindtouchcancel="eh" bindlongpress="eh" bindanimationstart="eh" bindanimationiteration="eh" bindanimationend="eh" bindtransitionend="eh" style="{{i.st}}" class="{{i.cl}}" bindtap="eh" catchtouchmove="eh"  id="{{i.uid}}">
    <block tt:for="{{[i.cn](http://i.cn/)}}" tt:key="uid">
      <template is="tmpl_0_container" data="{{i:item}}" />
    </block>
  </view>
</template>

<template name="tmpl_0_static-view">
  <view hover-class="{{i.hoverClass===undefined?'none':i.hoverClass}}" hover-stop-propagation="{{i.hoverStopPropagation===undefined?false:i.hoverStopPropagation}}" hover-start-time="{{i.hoverStartTime===undefined?50:i.hoverStartTime}}" hover-stay-time="{{i.hoverStayTime===undefined?400:i.hoverStayTime}}" animation="{{i.animation}}" style="{{i.st}}" class="{{i.cl}}"  id="{{i.uid}}">
    <block tt:for="{{[i.cn](http://i.cn/)}}" tt:key="uid">
      <template is="tmpl_0_container" data="{{i:item}}" />
    </block>
  </view>
</template>

<template name="tmpl_0_pure-view">
  <view style="{{i.st}}" class="{{i.cl}}"  id="{{i.uid}}">
    <block tt:for="{{[i.cn](http://i.cn/)}}" tt:key="uid">
      <template is="tmpl_0_container" data="{{i:item}}" />
    </block>
  </view>
</template>

<template name="tmpl_0_view">
  <view hover-class="{{i.hoverClass===undefined?'none':i.hoverClass}}" hover-stop-propagation="{{i.hoverStopPropagation===undefined?false:i.hoverStopPropagation}}" hover-start-time="{{i.hoverStartTime===undefined?50:i.hoverStartTime}}" hover-stay-time="{{i.hoverStayTime===undefined?400:i.hoverStayTime}}" animation="{{i.animation}}" bindtouchstart="eh" bindtouchmove="eh" bindtouchend="eh" bindtouchcancel="eh" bindlongpress="eh" bindanimationstart="eh" bindanimationiteration="eh" bindanimationend="eh" bindtransitionend="eh" style="{{i.st}}" class="{{i.cl}}" bindtap="eh"  id="{{i.uid}}">
    <block tt:for="{{[i.cn](http://i.cn/)}}" tt:key="uid">
      <template is="tmpl_0_container" data="{{i:item}}" />
    </block>
  </view>
</template>

<template name="tmpl_0_static-text">
  <text selectable="{{i.selectable===undefined?false:i.selectable}}" space="{{i.space}}" decode="{{i.decode===undefined?false:i.decode}}" style="{{i.st}}" class="{{i.cl}}"  id="{{i.uid}}">
    <block tt:for="{{[i.cn](http://i.cn/)}}" tt:key="uid">
      <template is="tmpl_0_container" data="{{i:item}}" />
    </block>
  </text>
</template>

<template name="tmpl_0_text">
  <text selectable="{{i.selectable===undefined?false:i.selectable}}" space="{{i.space}}" decode="{{i.decode===undefined?false:i.decode}}" style="{{i.st}}" class="{{i.cl}}" bindtap="eh"  id="{{i.uid}}">
    <block tt:for="{{[i.cn](http://i.cn/)}}" tt:key="uid">
      <template is="tmpl_0_container" data="{{i:item}}" />
    </block>
  </text>
</template>

<template name="tmpl_0_button">
  <button size="{{i.size===undefined?'default':i.size}}" type="{{i.type}}" plain="{{i.plain===undefined?false:i.plain}}" disabled="{{i.disabled}}" loading="{{i.loading===undefined?false:i.loading}}" form-type="{{i.formType}}" open-type="{{i.openType}}" hover-class="{{i.hoverClass===undefined?'button-hover':i.hoverClass}}" hover-stop-propagation="{{i.hoverStopPropagation===undefined?false:i.hoverStopPropagation}}" hover-start-time="{{i.hoverStartTime===undefined?20:i.hoverStartTime}}" hover-stay-time="{{i.hoverStayTime===undefined?70:i.hoverStayTime}}" name="{{i.name}}" bindtouchstart="eh" bindtouchmove="eh" bindtouchend="eh" bindtouchcancel="eh" bindlongpress="eh" bindgetphonenumber="eh" data-channel="{{i.dataChannel}}" style="{{i.st}}" class="{{i.cl}}" bindtap="eh"  id="{{i.uid}}">
    <block tt:for="{{[i.cn](http://i.cn/)}}" tt:key="uid">
      <template is="tmpl_0_container" data="{{i:item}}" />
    </block>
  </button>
</template>

<template name="tmpl_0_scroll-view">
  <scroll-view scroll-x="{{i.scrollX===undefined?false:i.scrollX}}" scroll-y="{{i.scrollY===undefined?false:i.scrollY}}" upper-threshold="{{i.upperThreshold===undefined?50:i.upperThreshold}}" lower-threshold="{{i.lowerThreshold===undefined?50:i.lowerThreshold}}" scroll-top="{{i.scrollTop}}" scroll-left="{{i.scrollLeft}}" scroll-into-view="{{i.scrollIntoView}}" scroll-with-animation="{{i.scrollWithAnimation===undefined?false:i.scrollWithAnimation}}" enable-back-to-top="{{i.enableBackToTop===undefined?false:i.enableBackToTop}}" bindscrolltoupper="eh" bindscrolltolower="eh" bindscroll="eh" bindtouchstart="eh" bindtouchmove="eh" bindtouchend="eh" bindtouchcancel="eh" bindlongpress="eh" bindanimationstart="eh" bindanimationiteration="eh" bindanimationend="eh" bindtransitionend="eh" style="{{i.st}}" class="{{i.cl}}" bindtap="eh"  id="{{i.uid}}">
    <block tt:for="{{[i.cn](http://i.cn/)}}" tt:key="uid">
      <template is="tmpl_0_container" data="{{i:item}}" />
    </block>
  </scroll-view>
</template>

<template name="tmpl_0_static-image">
  <image src="{{i.src}}" mode="{{i.mode===undefined?'scaleToFill':i.mode}}" lazy-load="{{i.lazyLoad===undefined?false:i.lazyLoad}}" style="{{i.st}}" class="{{i.cl}}"  id="{{i.uid}}">
    <block tt:for="{{[i.cn](http://i.cn/)}}" tt:key="uid">
      <template is="tmpl_0_container" data="{{i:item}}" />
    </block>
  </image>
</template>

<template name="tmpl_0_image">
  <image src="{{i.src}}" mode="{{i.mode===undefined?'scaleToFill':i.mode}}" lazy-load="{{i.lazyLoad===undefined?false:i.lazyLoad}}" binderror="eh" bindload="eh" bindtouchstart="eh" bindtouchmove="eh" bindtouchend="eh" bindtouchcancel="eh" bindlongpress="eh" style="{{i.st}}" class="{{i.cl}}" bindtap="eh"  id="{{i.uid}}">
    <block tt:for="{{[i.cn](http://i.cn/)}}" tt:key="uid">
      <template is="tmpl_0_container" data="{{i:item}}" />
    </block>
  </image>
</template>

<template name="tmpl_0_#text" data="{{i:i}}">
  <block>{{i.v}}</block>
</template>

<template name="tmpl_0_container">
  <template is="{{'tmpl_0_' + i.nn}}" data="{{i:i}}" />
</template>

从名字可以看出,这是用于渲染各种组件的 template ,所以当我们拿到 react 传递过来的 data 时,将其传给 templatetemplate 就能根据对应的组件名采用不同的模版进行渲染。随后再用一个 for 循环将其子组件进行递归渲染,完成整个页面的渲染。这个就可以理解为我们针对不同端写的不同渲染器,如果我们编译到 wx 小程序,这里面的内容是会不同的。

用一句话描述整个流程就是,在 **react** 对其处理完之后,会把数据 **setData** 传递给小程序,小程序再用之前写好的各种 **template** 将其渲染到页面上。

下面这张图就是经过 react 处理之后,能够拿到页面的数据,将其传递给小程序之后,就能递归渲染出来。

image-19-1634364146852.png

那么这样的架构有什么问题呢,可以很明显的看到会走两遍 diff ,为什么会走两遍 diff 呢?因为在 react 层为了获取到我想要什么这个信息,我们必须走一遍 diff ,这样才能将最后得到的 data 交给小程序。

而交给小程序之后,小程序对于之前的流程是无感知的,所以它为了得到需要更新什么这个信息,也需要过一遍 diff ,或者通过一些其他的方式来拿到这个信息(并没有深入了解过小程序的渲染流程,所以不确定是否是通过 diff 拿到的),所以这一整套流程就会走两遍 diff

为什么我们不能将两次 diff 合并为一次?因为小程序的渲染对于我们来说是黑盒,我们并不能干扰到内部流程。如果我们能够直接对接小程序的渲染 sdk ,那么其实根本没必要走两遍 diff ,通过 reactdiff 我们已经能够知道需要更新什么内容。而这个问题产生的根本原因是什么,其实就是小程序的 dsl 太垃圾,大家想用一个更好的开发方式去开发小程序,所以才有了用 react 去写小程序的想法,才出来了这一套东西。

其实,这个的本质和普通意义上的跨端框架没有太大的区别,开发层也就是 react 知道自己需要什么东西,但是它没有能力去渲染到界面上,所以需要通过小程序充当渲染层来渲染到真正的界面上。这种开发方式有一种用 react 去写 vue 的意思,但是大家可以思考一下,为什么会出现这种诡异的开发方式,如果这个 vue 做的足够好的话,谁又想去这样折腾。

同时,如果你理解了这整套流程是如何运作的,你也就理解了跨端到底在干什么。

组件的嵌套

其实还有一个小问题, wxtemplate 是无法支持递归调用的,也就导致了我们想用 template 递归渲染 data 内容是无法实现的,那么这个问题要如何解决呢..我们看一下上面的代码在 wx 小程序中编译出来的结果:

image-20-1634364148060.png

我们可以看到各种 template 之间多了0、1、2、3这种标号..就是为了解决无法递归调用的问题,提前多造几个名字不同功能相同的 template ,不就能跨过递归调用的限制了么...

另一种粗暴的跨端

上述的这些跨端都是通过某种架构方式去实现的,那如果我们粗暴一点的想,我能不能直接把一套代码通过编译的方式去编译到不同的平台。比如我把 js 代码编译成 java 代码、 object-c 代码,其实,个人感觉也不是不行,但是因为这些的差异实在太大,所以在写 js 代码的时候,可能需要非常强的约束性、规范性,把开发者限制在某个区域内,才能很好的编译过去。也就是说,从 jsjava 其实是一个自由度高到自由度低的一个过程,肯定是无法完全一一对应上的,并且由于开发方式、语法完全不一样,所以想通过编译的方式将 js 编译到 iosandroid 上去还是比较难的,但是对于小程序来说,尝试把 jsx 编译到 template 似乎是一个可行的方案,实际上,taro1/2 都是这么干的。不过从 jsxtemplate 也是一个自由度从高到低的一个过程,所以个人感觉,肯定是没办法很完美的把所有语法都编译到 template ...

但是在这里可以给大家分享一个很有意思的例子,不知道大家是否知道一个新框架叫 SolidJS ,它也支持用 JSX 去写代码,但是它完全没有 react 这么重的 runtime ,因为它的 JSX 最终会被编译成一些原生的操作...我们看一个简单的例子: Solid Playground

根据 react 的思维,我们在 input 框里面输入内容的时候,上面的文案应该跟着改变,但是实际上并没有。这是因为这个东西最后被编译完之后是一些原生的操作,它其实只会运行一遍,最后你触发的各种 click 并不会导致函数重新运行,而是直接通过原生操作操作到对应的 DOM 上来修改视图,也就导致了上面问题的产生。

其实我觉得这样挺反人类的,虽然是 JSX 的语法,但是却缺少了最核心的东西:函数式的思维。(还不如写 template )。

virtual dom

对于跨端的意义

提到跨端,可能很多人第一个想到的东西就是 virtual dom ,因为它是对于 ui 的抽象,脱离了平台,所以可能很多人会觉得 virtual dom 和跨平台已经是绑定在一起的东西了。但是其实个人感觉并不是。

首先我们回想一下,我们之前说到的跨平台的本质是什么?开发层知道自己想要什么,然后告诉渲染层自己想要什么,就这么简单。那对于 react-native 来说,是通过 virtual dom 来判断自己需要更新什么结点的吗?其实并不是,单靠一个 virtual dom 还不足以获取到这个信息,必须还要加上 diff ,所以是 virtual dom + diff 获取到了自己想要什么的信息,再通过通信的方式告诉 native 去更新真正的结点。

所以 virtual dom 在这个里面其实只扮演了一个获取方法的角色,是通过 virtual dom + diff 这个方法拿到了我们想要的东西。换而言之,我们也可以通过其他的方法来拿到我们想要什么。比如之前分享的 san 框架,这是一个没有 virtual dom 的框架,但是它为什么能够跨平台,我们先不管它内部是如何实现的,但是在更新阶段,如果它在某个时刻调用了 createElement ,那么它一定是知道了:自己想要什么。对应上跨端的内容,这个时候就能通过某种手段去告诉 native ,渲染某个东西。

所以,当我们通过其他手段获取到了:我们想要什么这个信息之后,就能通知 **native** 去渲染真正的内容。

virtual dom的优势

那么 vdom 的优势在于什么地方?个人认为主要是下面两个:

  1. 开创 jsx 新时代,函数式编程思想

  2. 强大的表达力。能够使用 template 获取更多优化信息,又能够支持 jsx

首先, jsx 的提出我觉得是开创了一个新时代,让我们能够以函数式编程思想去写 ui ,之前谁能想到一个切图仔还能用这样的方式去写 ui

其次,我们知道, vue 虽然是使用的 template 作为 dsl ,但是实际上我们也是可以写 jsx 的, jsx 所提供的灵活能力是 template 无法比拟的。而之所以能够同时支持 templatejsx 其实就是因为 vdom 的存在,如果 vue 不引入 vdom ,是没办法说去支持 jsx 的语法的,或者说,是没办法去支持真正的 jsx

结语

还是那句话,跨端就是: 我知道我想要什么,但是我没有能力去渲染,我要通知有能力渲染的人来帮助我渲染

他们的本质都非常简单,但是细节却非常难处理,同时对于目前市面上的多种跨端框架,也需要大家根据自己的项目去权衡利弊选择一个最有方案,毕竟目前没有一个框架可以说我能够吊打所有其他框架,适合自己的才是最好的。