在 web 环境运行 react-native 页面

背景

近两年来react-native构造原生应用异常火爆,在app中用来替代H5页面可以明显提升用户体验,但是在一些场景是需要配套web版本的,比如分享、seo或者react-native报错时的降级方案等。如果适配web再去实现一套H5的页面会增加开发和维护成本,同一套代码能不能跑在浏览器了? 由于react-native的页面都是基于react-native基础组件和API或者自己实现的module,react-native页面的代码是完全可以复用的。 web端实现同样的基础组件和API,webpack打包js文件时做好组件映射,这样同一套业务代码可以运行在三端。

WEB配套react-native基础组件&API

业内也有这方面的实践,淘宝和和Twritter都开源了web组件和API代码就不需要自己去实现了,我选用的是淘宝的React-web,详情见https://github.com/taobaofed/react-web 这个git项目官方差不多停止维护,自己拷贝了一份来维护。

实践&解决问题

项目目录结构,index.web.js为web项目的入口文件,index.ios.js和index.android.js分别为ios和android打包入口文件。

react组件的代码大概这样

import {Component} from 'react'
import {StyleSheet, View, Text} from 'react-native'
import { connect } from 'react-redux'
import ChorusItem from './ChorusItem'
import Gotobar from './Gotobar'
class Chorus extends Component {
       constructor(props) {
         super(props);
       }    
    render(params) {
        const {  chorus, dispatch } = this.props;
        return (
            <View>
                <Gotobar route={'/chorus'} dispatch={this.props.dispatch}>{'合唱推荐'}</Gotobar>
                <View style={styles.itemColumn}>
                    {chorus.map((item, index) => <ChorusItem item={item} dispatch={dispatch} from={'indexPage.chorus'} key={index} index={index + 1}></ChorusItem>)}
                </View>          
            </View>
        )
    }
}
var styles = StyleSheet.create({
    itemColumn: {
        justifyContent: 'flex-start'
    }
})
module.exports = connect((state)=>{
  return {
    chorus: state.indexPage.chorus
  }
})(Chorus)

遇到web和react-native有差异时需要自己区分平台写差异代码,对于比较小的差异可以通过Platform来区分例如

_onScroll(e) {
    let event = e.nativeEvent, y;
    if (Platform.OS == 'web') {
          common.event.emit('scroll.change', event.target.scrollTop)
          common.event.emit('scroll.report', event.target.scrollTop)
    }
  }

差异较大的建议区分平台抽象为组件,通过webpack打包时映射到对应的web组件上,例如路由组件web用的是RouterContext.web.js, native用RouterContext.js

实践过程中有遇到些问题,列举两个影响和改动较大的问题

1 . web为了保持和react-native布局保持一致,页面固定一屏高度采用absolute + overflow:scoll局部滚动布局,IOS下滚动到页面顶部或者底部有回弹效果这时如果再向相反方向滚动页面导致页面无法滚动,如下图:

解决方案:去掉固定一屏高度和局部滚动的布局,采用常规的布局。这样会影响固定顶部、底部、遮罩层的布局,web端需要增加position:fixed样式,和native端的样式需要区分开。

2 .flex兼容问题,react-native采用flex布局,web端flex分为3个版本,2009、2012、final。 2009版本主要是兼容安卓4.4以下的设备,需要对flex属性兼容例如flex属性的映射和补充(flexWrap缺失)以及添加厂商前缀(-webkit)。详情见http://caniuse.com/#search=flex-wrap


//2009 flex属性和值映射
var flexboxProperties = {
      flex: "WebkitBoxFlex",
      order: "WebkitBoxOrdinalGroup",
      // https://github.com/postcss/autoprefixer/blob/master/lib/hacks/flex-direction.coffee
      flexDirection: "WebkitBoxOrient",
      // https://github.com/postcss/autoprefixer/blob/master/lib/hacks/align-items.coffee
      alignItems: "WebkitBoxAlign",
      // https://github.com/postcss/autoprefixer/blob/master/lib/hacks/justify-content.coffee
      justifyContent: "WebkitBoxPack",
      flexWrap: null,
      alignSelf: null,
};
var oldFlexboxValues = {
      flex-end: "end",
      flex-start: "start",
      space-between: "justify",
      space-around: "distribute",
};    
//flexWrap属性补充,组件增加兼容低版本样式_olderStyle属性, 例如
 _renderRow(rowData, sectionID, rowID){
     return (
         <SingleItem item={rowData} from={'single'} _olderStyle={{display: 'inline-block', 'paddingLeft':1}}/>
     )
}
//react-web处理样式生成jsx的style时添加_olderStyle里面的样式
if (flexboxSpec == '2009' && _olderStyle) {
    for (var key in _olderStyle) {
        var value = _olderStyle[key];
          if (!isValidValue(value)) {
            continue;
          }
          result[key] = value;
    }
  }

优化

react-web生成的页面在体验方面有些不太理想,比如js文件大小、首屏可见时间等,所以在某些做了些优化。

1 . 支持后端渲染直出提升首屏渲染可见时间,常规的静态页面渲染要经过js下载、执行,react组件渲染、数据加载、组件更新等耗时时间较长,如下图所示,在无缓存+wifi+笔记本i5+8g环境下,js大小为100kb,js下载+执行耗时300+ms

由于flex兼容判断是依赖浏览器环境,后端渲染需要去掉这些依赖补全全部的兼容样式,服务端渲染首屏主要耗时在后端渲染耗时较短200ms内基本可以返回html内容。

2 .按需加载组件减少不必要的依赖从而减少js文件大小 import {StyleSheet, View} from 'react-native' -> import View from 'react-native/View/View.web' import StyleSheet from 'react-native/View/View.web'

按需import前所有组件压缩后300kb 按需import后常用的组件压缩后80kb(stylesheet,view,text,image,touch,listview,scrollvie…)等

3 .常用组件+react+redux打包压缩后大小有300+kb依然不够理想, react+reactDom+redux占了160kb,可以用类react库替代react,从文件大小考虑最后用preact替换掉react,迁移也相对容易。 preact是react的规范的一种简单高效实现体积非常小,包含特性:vnode、component、lifecycle、context、props&state、Refs,精简掉的特性:PropType、 Synthetic Events。 由于preact去掉了合成事件,所有的事件都是绑定到dom上,对应的react-native的触摸手势事件需要用原生事件替代,组件上的手势事件prop改为原生的touch事件prop。

const EVENT_MAP = {
 'onStartShouldSetResponder': 'onTouchStart',
 'onMoveShouldSetResponder': 'onTouchMove',
 'onStartShouldSetResponderCapture': 'onTouchStartCapture',
 'onMoveShouldSetResponderCapture': 'onTouchMoveCapture',
 'onResponderGrant': 'onTouchStart',
 'onResponderTerminate': 'onTouchCancle',
 'onResponderMove': 'onTouchMove',
 'onResponderRelease': 'onTouchEnd'
}
//preact设置组件属性的时候会加上事件
if (name[0]=='o' && name[1]=='n') {
 let useCapture = name !== (name=name.replace(/Capture$/, ''));
 name = name.toLowerCase().substring(2);
 if (value) {
     if (!old) node.addEventListener(name, eventProxy, useCapture);
 }
 else {
     node.removeEventListener(name, eventProxy, useCapture);
 }
 (node._listeners || (node._listeners = {}))[name] = value;
}

preact的事件是直接绑定到DOM节点上的,当事件过多时建议采用事件代理来减少事件监听。 preact性能测试数据参考https://rawgit.com/krausest/js-framework-benchmark/master/webdriver-ts-results/table.html

React+redux+reactDom打包压缩后的大小为160kb

Preact+preactcompat+redux打包压缩后大小为38kb

4 .react-web生成的页面样式都是内联到style属性上,这些样式属性可以从代码里提取出来生成css文件,这样就可以缓存页面的css也可以减少一些flex兼容的计算。

实现方式是编写webpack babel插件,利用静态抽象树AST来找出StyleSheet.create调用函数的参数,根据这个参数过滤出可以直接提取的样式对象并删除这些样式对应的AST节点,用过滤出来的样式对象生成classname样式文件,然后遍历jsx节点的style属性并给节点加上对应的className属性, 关于babel插件编写可参考https://github.com/thejameskyle/babel-handbook/blob/master/translations/zh-Hans/plugin-handbook.md, 里面的 API和教程相当详细。

抽取css文件的主要流程如下图:(注:无法转化为样式字符串的style是指需要通过表达式计算得出的样式。) 举个例子:

转换前的style对象

let styles = StyleSheet.create({

    subtitleContainer: {

        flexDirection: 'row',

        justifyContent: 'flex-start',

        alignItems: 'center'

    },

    subtitleText: {

        fontSize: txt_1,

        color: '#F04F43',

    },

    iconRight : {

        height: 7,

        width: 7,

        borderTopWidth: 2,

        borderRightWidth: 2,

        borderColor: '#F04F43',

        //backgroundColor: '#F04F43',

        borderStyle: 'solid',

        transform: [{ 'rotate': '45deg' }]

    }

});

//JSX

<View style={styles.subtitleContainer}>

    <Text style={styles.subtitleText}>查看特权</Text>

        <View style={styles.iconRight}></View>

    </View>        

//转换完成后的CSS 类名经过hash处理

._17cytc2 {

    -webkit-box-orient: horizontal;

    -webkit-box-direction: normal;

    -webkit-flex-direction: row;

    flex-direction: row;

    -webkit-box-pack: start;

    -webkit-justify-content: flex-start;

    justify-content: flex-start;

    -webkit-box-align: center;

    -webkit-align-items: center;

    align-items: center;

}

.osilsm {

    color: #F04F43;

}

._41z5ub {

    height: 7px;

    width: 7px;

    border-width: 0px;

    border-top-width: 2px;

    border-right-width: 2px;

    border-color: #F04F43;

    border-style: solid;

    -webkit-transform: rotate(45deg);

    transform: rotate(45deg);

}    

//转换过后的代码

var styles = __WEBPACK_IMPORTED_MODULE_4_react_native_StyleSheet_StyleSheet_web__["a" /* default */].create({

    subtitleText: {

        fontSize: txt_1

    }

});

//JSX

__WEBPACK_IMPORTED_MODULE_0_react___default.a.createElement(

    __WEBPACK_IMPORTED_MODULE_1_react_native_View_View_web__["a" /* default */],

    {

        className: "_17cytc2",

        style: styles.subtitleContainer },

    __WEBPACK_IMPORTED_MODULE_0_react___default.a.createElement(

        __WEBPACK_IMPORTED_MODULE_2_react_native_Text_Text_web__["a" /* default */],

        {

            className: "osilsm",

            style: styles.subtitleText },

        "\u67E5\u770B\u7279\u6743"

    ),

    __WEBPACK_IMPORTED_MODULE_0_react___default.a.createElement(__WEBPACK_IMPORTED_MODULE_1_react_native_View_View_web__["a" /* default */], {

        className: "_41z5ub",

        style: styles.iconRight })

)

优化前后对比

环境为桌面chrome61 i7+wifi

1. 页面js加载和执行耗时如下

优化前

script加载和执行耗时168ms

优化后

script加载和执行耗时125ms 主要缩减react+reactweb组件大小, 大小从251kb缩减到117kb

2.组件渲染和首屏时间如下

优化前

优化后

组件渲染时长从105ms降到86ms,首屏可见事件从292ms提前到了230ms

线上数据

优化后页面是从9月29日开始 总资源加载耗时

页面开始导航到可交互耗时

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

编辑于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏c#开发者

如何在DataGrid里面产生滚动条而不滚动题头

我们在开发的时候一定遇到,使用DataGrid的时候由于不想分页(数据没有那么多)但是又显示不在一页里面,此时我们希望在DataGrid里面出现一个滚动条,可以...

33611
来自专栏从零开始学 Web 前端

从零开始学 Web 之 移动Web(五)touch事件的缺陷,移动端常用插件

我们在上面《页面分类》的项目中,对 tap 事件的处理使用的是 touch 事件处理的,因为如果使用 click 事件的话,总会有延时。

542
来自专栏刘望舒

Android绘制优化(一)绘制性能分析

前言 一个优秀的应用不仅仅是要有吸引人的功能和交互,同时在性能上也有很高的要求。运行Android系统的手机,虽然配置在不断的提升,但仍旧无法和PC相比,无法做...

1855
来自专栏前端杂货铺

关于首屏时间采集自动化的解决方案

关于首屏 首屏时间是指从转向该页面到屏幕中该页面所有内容都可见时的时间。已经有太多的关于首屏时间的计算,在本文中并不重复阐述这些已经被提出或者实现的方案,而旨...

3537
来自专栏IMWeb前端团队

:before,:after伪元素妙用

本文作者:IMWeb 黎清龙 原文出处:IMWeb社区 未经同意,禁止转载 这两个伪元素分别表示元素内容的【前】【后】,利用这两个伪元素可以在元素内容...

20610
来自专栏IMWeb前端团队

移动端重构实战系列3——各种等分

本文作者:IMWeb 结一 原文出处:IMWeb社区 未经同意,禁止转载 ”本系列教程为实战教程,是本人移动端重构经验及思想的一次总结,也是对sand...

1.1K7
来自专栏陈纪庚

HTML5 drag和drop的亲手实践

最近在公司打杂的时候,突然分到了一个锅,就是要支持一个新的功能:用户可以通过拖曳组件来改变组件的顺序。因此,这阵子就看了一下网上的一些drag和drog的文章以...

713
来自专栏张戈的专栏

为iFrame添加动态载入效果,提高用户体验

中国博客联盟-成员展示导航一直都是直勾勾的加载,并且未加载完成之前还会强行占据一大片空白区域,体验很不友好!昨天在制作展示导航 WordPress 插件时,把这...

2784
来自专栏青青天空树

MFC-简单的函数使用

1.   MessageBox(str);很简单的一个函数,该函数参数为字符串.用来弹出一个窗口显示str的内容,str为一个字符串.

1004
来自专栏曾大稳的博客

ffmpeg 封装格式转换 MP4转AVI

格式转换直接将视音频压缩码流从一种封装格式文件中获取出来然后打包成另外一种封装格式的文件。因为不需要进行视音频的编码和解码,所以不会有视音频的压缩损伤。

1803

扫码关注云+社区