跨端技术的本质是什么?现状如何?
来自团队 匡凌熙 同学的分享
零、何为跨端
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 thread
js thread
根据react
代码进行相应的处理,比如处理onClick
函数,触发了setState
。和 react
的更新流程一样,触发了setState
之后会进行diff
,找到需要更新的结点通知 shadow thread
shadow thread
计算布局之后通知native thread
进行真正的渲染。
2.4 特点
我们上述说的通知,都是通过 bridge
实现的,bridge
本身是用实现C++
的,就像一座桥一样,将各个模块关联起来,整个通信是一个「异步」的过程。这样做好处就是各自之间不会有阻塞关系,比如 不会native thread
因为js thread
而阻塞渲染,给用户良好的体验。但是这种「异步」也存在一个比较明显的问题:因为通信过程花费的时间比较长,所以在一些时效性要求较高场景上体验较差。
比如长列表快速滚动的时候或者需要做一些跟手的动画,整个过程是这样的:
native thread
监听到了滚动事件,发送消息通知js thread
js thread
处理滚动事件,如果需要修改state
需要经过一层js diff
,拿到最终需要更新的结点js thread
通知shadow thread
shadow thread
通知native
渲染
当用户操作过快的时候,就会导致界面来不及更新,进而导致在快速滑动的时候会出现白屏、卡顿的现象。
2.5 优化
我们很容易看出,这是由rn
的架构引出的问题,其实小程序的架构也会有这个问题,所以在rn
和小程序上出现一些需要频繁通信的场景时,就会导致页面非常差,流畅度降低。那么如果想解决这个问题,势必要从架构上去进行修改。
三、从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
并没有什么历史包袱,所以它能够从架构,设计的方面去做的更好更快,能够做更多的事情。
四、跨端目前有什么问题
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
写一个非常简单的例子来看一下:
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}>clickButton>
View>
)
}
}
这是一个用taro
写的组件,把它编译到字节小程序之后是这样的效果:
根据我们之前的分析,在最后生成的文件中,一定包含了一个「小程序渲染器」。它接受的data
就是整个ui
结构,然后通过小程序的渲染能力渲染到界面上,我们去dist
文件中找一下,就能找到一个base.ttm
l的文件,里面的内容是这样的
<template name="taro_tmpl">
<block tt:for="{{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}}" 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}}" 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}}" 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}}" 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}}" 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}}" 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}}" 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}}" 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}}" 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}}" 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
时,将其传给template
,template
就能根据对应的组件名采用不同的模版进行渲染。随后再用一个for
循环将其子组件进行递归渲染,完成整个页面的渲染。这个就可以理解为我们针对不同端写的不同渲染器,如果我们编译到wx
小程序,这里面的内容是会不同的。
总之,「在」**react**
「对其处理完之后,会把数据」**setData**
「传递给「「小程序」」,小程序再用之前写好的各种」**template**
「将其渲染到页面上。」
下面这张图就是经过react
处理之后,能够拿到页面的数据,将其传递给小程序之后,就能递归渲染出来。
那么这样的架构有什么问题呢,可以很明显的看到会走两遍diff
,为什么会走两遍diff
呢?因为在react
层为了获取到我想要什么这个信息,我们必须走一遍diff
,这样才能将最后得到的data
交给小程序。
而交给小程序之后,小程序对于之前的流程是无感知的,所以它为了得到需要更新什么这个信息,也需要过一遍diff
,或者通过一些其他的方式来拿到这个信息(并没有深入了解过小程序的渲染流程,所以不确定是否是通过diff
拿到的),所以这一整套流程就会走两遍diff
。
为什么我们不能将两次diff
合并为一次?因为小程序的渲染对开发者而言就是个黑盒,我们不能干扰到其内部流程。如果我们能够直接对接小程序的渲染sdk
,那么其实根本没必要走两遍diff
,因为前置的 react
的diff
我们已经能够知道需要更新什么内容。
这个问题的本质和普通意义上的跨端框架没有太大的区别,开发层也就是 react
知道自己需要什么东西,但是它没有能力去渲染到界面上,所以需要通过小程序充当渲染层来渲染到真正的界面上。这种开发方式有一种用 react
去写 vue
的意思,但是为什么会出现这种诡异的开发方式,如果这个 vue
做的足够好的话,谁又想去这样折腾?
5.4 组件的嵌套
其实还有一个小问题,wx
的template
是无法支持递归调用的,也就导致了我们想用template
递归渲染data
内容是无法实现的,那么这个问题要如何解决呢..我们看一下上面的代码在wx
小程序中编译出来的结果:
我们可以看到各种template
之间多了0、1、2、3这种标号..就是为了解决无法递归调用的问题,提前多造几个名字不同功能相同的template
,不就能跨过递归调用的限制了么...
六、另一种粗暴的跨端
上述的这些跨端都是通过某种架构方式去实现的,那如果我们粗暴一点的想,我能不能直接把一套代码通过编译的方式去编译到不同的平台。比如我把js
代码编译成java
代码、object-c
代码,其实,个人感觉也不是不行,但是因为这些的差异实在太大,所以在写js
代码的时候,可能需要非常强的约束性、规范性,把开发者限制在某个区域内,才能很好的编译过去。也就是说,从js
到java
其实是一个自由度高到自由度低的一个过程,肯定是无法完全一一对应上的,并且由于开发方式、语法完全不一样,所以想通过编译的方式将js
编译到ios
和android
上去还是比较难的,但是对于小程序来说,尝试把jsx
编译到template
似乎是一个可行的方案,实际上,taro1/2 都是这么干的。不过从jsx
到template
也是一个自由度从高到低的一个过程,所以是没办法绝对完美地将把所有语法都编译到template
...
这里可以给大家分享一个很有意思的例子,最近很火的 SolidJS 框架也支持用 JSX
写代码,但是它完全没有react
这么重的runtime
,因为它的JSX
最终会被编译成一些原生的操作...我们看一个简单的例子:https://playground.solidjs.com/
在 react
语境下,我们在input
框里面输入内容的时候,上面的文案应该跟着改变,但是实际上并没有。这是因为这个东西最后被编译完之后是一些原生的操作,它其实只会运行一遍,最后你触发的各种click
并不会导致函数重新运行,而是直接通过原生操作操作到对应的DOM
上来修改视图,也就导致了上面问题的产生。
其实我觉得这样挺反人类的,虽然是JSX
的语法,但是却缺少了最核心的东西:函数式的思维。(还不如写template
)。
七、virtual dom
7.1 对于跨端的意义
提到跨端,可能很多人第一个想到的东西就是 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**
「去渲染真正的内容。」
7.2 virtual dom的优势
那么vdom
的优势在于什么地方?我认为主要是下面两个:
开创 jsx
新时代,函数式编程思想强大的表达力。能够使用 template
获取更多优化信息,又能够支持jsx
首先,jsx
简直开创了一个新时代,让我们能够以函数式编程思想去写ui
,之前谁能想到一个切图仔还能用这样的方式去写ui
。
其次,我们知道,vue
虽然是使用的template
作为dsl
,但是实际上我们也是可以写jsx
的,jsx
所提供的灵活能力是template
无法比拟的。而之所以能够同时支持template
和jsx
其实就是因为vdom
的存在,如果vue
不引入vdom
,是没办法说去支持jsx
的语法的,或者说,是没办法去支持真正的jsx
。
八、结语
还是那句话,跨端就是:「我知道我想要什么,但是我没有能力去渲染,我要通知有能力渲染的人来帮助我渲染。」
他们的本质都非常简单,但是细节却非常难处理,同时对于目前市面上的多种跨端框架,也需要大家根据自己的项目去权衡利弊选择一个最有方案,毕竟目前没有一个框架能完全吊打所有其他框架,适合自己的才是最好的。