React Native For Web
React Native
的出现,让前端工程师拥有了使用
JavaScript
编写原生
APP
的能力。
Facebook
对
React Native
的愿景是
Write once
,
render anywhere
;但随着时间的推移,
React Native
可以兼容
iOS
和
Android
两个平台,并没有兼容
Web
端,这是因为
Facebook
开发人员认为
Web
端天生兼容就巨麻烦,同时,平台差异性是注定要保存的;因此
React Native
的愿景变为了
Learn once, write anywhere
。但不管是从产品角度还是用户诉求,
Write once
,
render anywhere
都业务方的刚需需求。
引擎
在写React Native代码时,所有的控件都是import自react-native,但这些组件或API只是兼容了iOS和Android两个平台。
思考一下
如果将这些平台无关的组件以及API搬到Web上,用div代替View,img代替Image以及默认样式等,这样RN代码不就可以迁移到Web上了。
:mag:Google一下:mag:
果然,Facebook的一位大神开源了react-native-web,此库就做了这些牛逼的操作。react-native-web 对原项目没有侵入性,无需改动原来的代码,只需在项目中加入一些 webpack 构建配置,就能构建出和 React Native 应用一致效果的 Web 应用。咱们JDReact平台基于它,进行了二次研发,赋能RN业务具备了转换Web的能力,解决了业务研发的痛点。
下面咱们从 3
个方面分享一下
react-native-web
:
1、如何将react-native-web整合到React Native项目中;
2、了解
react-native-web
的实现;
3、JDReact中react-native-web的使用。
01
react-native-web的整合
搭建 React Native
项目:
下面是简化版流程,建议查看原文档。
配置开发环境:(平台:macOS,iOS)
brew install node # Watchman监视文件系统变更的工具 brew install watchman # 使用nrm工具切换淘宝源 nrm use taobao
创建新项目并安装依赖:
react-native init rnweb cd rnweb npm install
编译并运行rnweb应用:
yarn ios # 或者 yarn react-native run-ios
咦,这菇凉好漂亮哦。
完成了React Native项目的搭建,下面看看如何将react-native-web接入吧!
接入 react-native-web:
React Native项目有react-native-cli这样的命令行构建工具,Facebook也为React项目打造了一个无需配置的、用于快速构建开发环境的脚手架工具create-react-app,但react-native-web是个爹不疼娘不爱的苦孩子,需要使用者借助webpack + babel全家桶搞一个。
1. 核心依赖:
npm install react react-dom react-native-web
2. 入口文件
index.web.js
在项目根目录建立index.web.js文件,这个文件是程序的入口。
import {AppRegistry} from 'react-native'; import App from './App'; import {name as appName} from './app.json'; AppRegistry.registerComponent(appName, () => App); AppRegistry.runApplication(appName, { initialProps: {}, rootTag: document.getElementById('root') });
3. 安装
webpack
、开发服务及相关
Loader
npm install webpack webpack-cli webpack-dev-server --save-dev npm install babel-loader --save-dev
4. 配置
webpack.config.js
在项目根目录建立webpack.config.js文件,这个文件是进行webpack配置的,先建立基本的入口和出口文件。
const path = require('path'); module.exports = { /* webpack 入口文件 */ entry: './index.web.js', /* webpack 输出文件 */ output: { filename: 'index.web.js', path: path.resolve(__dirname, 'build'), }, /* webpack mode 配置 */ mode: 'development', }
5.Html 模版
在根目录新建index.html文件,代码如下:
<html> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no,viewport-fit=cover"> <title>React Native For Web</title> </head> <body> <div id="root"> React Native For Web </div> </body> </html>
6. 开发环境配置
开发过程中,修改的内容需要立马live reloading的功能;webpack-dev-server 提供了一个简单的 web 服务器,并提供了实时加载的功能。我们需要在 webpack.config.js 中通过 devServer 配置服务启动的环境;同时,webpack-dev-server需要指定一个html文件,我们可以借助html-webpack-plugin,它可以在 webpack打包时,创建一个 html 文件,并把 webpack 打包后的静态文件自动插入到这个 html 文件当中。配置如下:
npm install html-webpack-plugin --save-dev const path = require('path'); var HTMLWebpackPlugin = require('html-webpack-plugin'); module.exports = { /* webpack 自动编译配置 webpack-dev-server */ devServer: { contentBase: path.resolve(__dirname, 'build'), port: 3001 }, /* webpack 插件配置 html-webpack-plugin */ plugins: [ new HTMLWebpackPlugin( {template: path.join(__dirname, './index.html')} ) ] }
7. 加入打包命令
打开package.json文件,在scripts属性中加入dev、build命令,如下:
"scripts": { "dev": "webpack-dev-server", "build": "webpack" },
走
到这里,:joy::joy::joy:,是不是已经有按耐不住的同学,执行了npm run dev,一顿操作猛如虎,终端输出error,顿时心里哇凉哇凉的;其实这个时候咱们还缺少关键的几步,配置babel全家桶,那咱们继续吧!
8. 安装
babel
及相关
preset / plugin
npm install @babel/core @babel/runtime @babel/preset-react @babel/preset-env @babel/preset-flow --save-dev
9. 配置
module
在babel全家桶安装完成后,可以到webpack.config.js里配置module,也就是配置我们常说的loader;由于项目层级比较深,引用react-native-web的组件路径比较复杂,可以在webpack.config.js新增resolve.alias,配置如下:
module.exports = { module: { rules: [{ test: /\.js$/, exclude: /(node_modules|bower_components)/, use: {loader: 'babel-loader',} }] }, /* webpack resolve配置 解析路径&loader*/ resolve: { alias: { 'react-native$': 'react-native-web' } }, }
10. 执行
npm run dev
在终端执行npm run dev命令,执行完就可以在浏览器中访问http://loaclhost:3001。:joy::joy::joy: 由于咱们的webpack配置是最低级的,所以漂亮的菇凉来了个华丽转身后变成村姑了。大家可以参考webpack管网,为咱们漂亮的菇凉新增更多配置。
小结
经过上面一波操作,一个基础的React Native项目,即可以支持iOS、Android平台,又可以支持Web端了。其实大家发现上面大部分内容都是在讲如何配置化一个项目支持转Web,这部分内容不仅繁琐而且容易出错,同时是每个项目都不可避免的问题。思考一下,是不是可以把这部分内容做成一个脚手架呢。
0 2
react-native-web的实现
AppRegistry:
在接入react-native-web的第2步中,增加的index.web.js文件就是web程序的入口,指定该文件为程序入口是在webpack配置中配置的;不管是在index.web.js还是在index.js中都是对AppRegistry的API调用:
//RN入口文件index.js import {AppRegistry} from 'react-native'; import App from './App'; import {name as appName} from '../app.json'; AppRegistry.registerComponent(appName, () => App); web入口文件index.web.js import {AppRegistry} from 'react-native'; import App from './App'; import {name as appName} from '../app.json'; AppRegistry.registerComponent(appName, () => App); AppRegistry.runApplication(appName, { initialProps: {}, rootTag: document.getElementById('root') });
那
AppRegistry
到底是个什么啥呢!
官网这么讲:
AppRegistry
所有
React Native
应用的
JS
入口。
应用的根组件应当通过
AppRegistry.registerComponent
方法注册自己,然后原生系统才可以加载应用的代码包并且在启动完成之后通过调用
AppRegistry.runApplication
来真正运行应用。
RN 中的实现:
那看一下这两个API是干什么的呢!在开发过程中会遇到有多个rn界面入口的需求,目前RCTRootView提供了两种方式,但都与AppRegistry.registerComponentAPI有关:
- 使用initialProperties传入props属性,在React中读取属性,通过逻辑来渲染不同的Component。
- 配置moduleName,然后利用AppRegistry.registerComponent注册同名的页面入口。
//iOS工程代码
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { RCTBridge *bridge = [[RCTBridge alloc] initWithDelegate:self launchOptions:launchOptions]; RCTRootView *rootView = [[RCTRootView alloc] initWithBridge:bridge moduleName:@"rnweb" initialProperties: @{ @"screenProps" : @{ @"initialRouteName" : @"Home", }, }];
rootView.backgroundColor = [[UIColor alloc] initWithRed:1.0f green:1.0f blue:1.0f alpha:1];
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds]; UIViewController *rootViewController = [UIViewController new]; rootViewController.view = rootView; self.window.rootViewController = rootViewController; [self.window makeKeyAndVisible]; return YES; }
//JS 代码
AppRegistry.registerComponent('rnweb', () => App);
通过AppRegistry.registerComponent方法注册根组件,在RCTRootView初始化过程中,监听了RCTJavaScriptDidLoadNotification通知,然后在事件中回调JS的AppRegistry.runApplication来运行应用。
//iOS源码,存在删减 //初始化RCTRootView - (instancetype)initWithBridge:(RCTBridge *)bridge moduleName:(NSString *)moduleName initialProperties:(NSDictionary *)initialProperties { if (self = [super initWithFrame:CGRectZero]) { //监听JS load 完成
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(javaScriptDidLoad:) name:RCTJavaScriptDidLoadNotification object:_bridge]; return self; } //监听事件 - (void)javaScriptDidLoad:(NSNotification *)notification { RCTBridge *bridge = notification.userInfo[@"bridge"]; if (bridge != _contentView.bridge) { [self bundleFinishedLoading:bridge]; } } //渲染的准备工作_contentView - (void)bundleFinishedLoading:(RCTBridge *)bridge { [self runApplication:bridge]; } //回调JS AppRegistry的runApplication方法,启动应用
- (void)runApplication:(RCTBridge *)bridge { NSString *moduleName = _moduleName ?: @""; NSDictionary *appParameters = @{ @"rootTag": _contentView.reactTag, @"initialProps": _appProperties ?: @{}, };
RCTLogInfo(@"Running application %@ (%@)", moduleName, appParameters); [bridge enqueueJSCall:@"AppRegistry" method:@"runApplication" args:@[moduleName, appParameters] completion:NULL]; }
web 中的实现:
看完RN的实现,是不是可以想到react-native-web中的两个API也有同样的作用呢!通过源码可以发现,有个AppRegistry文件目录,其中有三个文件分别为index.js、AppContainer、renderApplication, index.js文件也就是AppRegistry的实现,定义一个全局的线程容器runnables,以appKey为key存储了应用的事件;通过AppRegistry.registerComponent(‘rnweb’, () => App);注册应用,在runnables中key为rnweb,value是包含getApplication、run事件的对象,也就说,通过key获取应用事件对象,然后执行。同时,也可以自定义应用的事件。代码如下(代码有删减):
/** * `AppRegistry` is the JS entry point to running all React Native apps. */ var runnables = {}; var AppRegistry = /*#__PURE__*/ function () { function AppRegistry() {} //获取当前key所对应的应用 AppRegistry.getApplication = function getApplication(appKey, appParameters) { return runnables[appKey].getApplication(appParameters); };
//AppRegistry是JS运行所有React Native应用的入口。应用的根组件应当通过AppRegistry.registerComponent方法注册自己,
AppRegistry.registerComponent = function registerComponent(appKey, componentProvider) { runnables[appKey] = { //获取当前key所对应的Application信息,包括(element,style)
getApplication: function getApplication(appParameters) { return _getApplication(componentProviderInstrumentationHook(componentProvider), appParameters ? appParameters.initialProps : emptyObject, wrapperComponentProvider && wrapperComponentProvider(appParameters)); }, //渲染当前Application run: function run(appParameters) { return renderApplication(componentProviderInstrumentationHook(componentProvider), wrapperComponentProvider && wrapperComponentProvider(appParameters), appParameters.callback, { hydrate: appParameters.hydrate || false, initialProps: appParameters.initialProps || emptyObject, rootTag: appParameters.rootTag }); } }; return appKey; };
// 静态方法, 进行注册配置信息 AppRegistry.registerConfig = function registerConfig(config) { config.forEach(function (_ref) { var appKey = _ref.appKey, component = _ref.component, run = _ref.run;
if (run) { AppRegistry.registerRunnable(appKey, run); } else { invariant(component, 'No component provider passed in'); AppRegistry.registerComponent(appKey, component); } }); };
//进行注册线程
AppRegistry.registerRunnable = function registerRunnable(appKey, run) { runnables[appKey] = { run: run }; return appKey; };
//进行运行应用 AppRegistry.runApplication = function runApplication(appKey, appParameters) { runnables[appKey].run(appParameters); };
//应用销毁 AppRegistry.unmountApplicationComponentAtRootTag = function unmountApplicationComponentAtRootTag(rootTag) { unmountComponentAtNode(rootTag); };
return AppRegistry; }();
从源码可以看出run方法调用了renderApplication的renderApplication方法,该方法有几个参数RootComponent、callback、options、WrapperComponent,其中options中的参数是透传appParameters,比如:initialProps参数初始props,rootTag参数设置root dom节点,源码如下:
export default function renderApplication(RootComponent, WrapperComponent, callback, options) { //配置信息 var shouldHydrate = options.hydrate, initialProps = options.initialProps, rootTag = options.rootTag; //本地渲染或混合渲染 var renderFn = shouldHydrate ? hydrate : render; //渲染UI 将JSX代码转为JS renderFn(React.createElement(AppContainer, { rootTag: rootTag, WrapperComponent: WrapperComponent }, React.createElement(RootComponent, initialProps)), rootTag, callback); }
该方法通过react-dom的render或hydrate进行渲染,根据react-dom的ReactDOM.render(element, container[, callback])API可以推断上面内容会被转换为如下:
//渲染一个 React 元素到由 container 提供的 DOM 中,并且返回组件的一个 引用(reference) ReactDOM.render(React.createElement(AppContainer, { rootTag: rootTag, WrapperComponent: WrapperComponent }, React.createElement(RootComponent, initialProps)), rootTag, callback)
在开发中大多数使用的是JSX语法,但是浏览器是不认识JSX的,这时候可以将JSX语法转化为JS语法,会用到一个React.createElement(type,[props],[…children])的API,该方法有三个参数,第三个以后的都会被作为child组件,参数type是指创建元素的类型,可以是div或span再或者React的组件类型;如下示例代码:
//JSX <View style={styles.sectionContainer}> <Text style={styles.sectionDescription}> Read the docs to discover what to do next: </Text>
</View>
//JS React.createElement( View,
{style: styles.sectionContainer},
React.createElement( Text,
{style: styles.sectionDescription}, "Read the docs to discover what to do next:" ) );
Component:
有了注册和渲染,下面咱们看一下react-native-web中Component的实现,下面咱们以Text组件来说一下,源码如下:(有删减,具体可以看源码):
var Text = function (_React$Component) { //Text 对象引用
var _proto = Text.prototype; //Text 嵌套使用时,元素类型是‘span’还是‘div’,以及props属性也有所不同
_proto.renderText = function renderText(hasTextAncestor) { var _this$props = this.props, dir = _this$props.dir, forwardedRef = _this$props.forwardedRef, numberOfLines = _this$props.numberOfLines, onPress = _this$props.onPress, selectable = _this$props.selectable, style = _this$props.style; var supportedProps = filterSupportedProps(this.props);
supportedProps.classList = [classes.text, hasTextAncestor === true && classes.textHasAncestor, numberOfLines === 1 && classes.textOneLine, numberOfLines != null && numberOfLines > 1 && classes.textMultiLine]; // allow browsers to automatically infer the language writing direction
supportedProps.dir = dir !== undefined ? dir : 'auto'; supportedProps.ref = forwardedRef; supportedProps.style = [style, numberOfLines != null && numberOfLines > 1 && { WebkitLineClamp: numberOfLines }, selectable === false && styles.notSelectable, onPress && styles.pressable]; var component = hasTextAncestor ? 'span' : 'div'; return createElement(component, supportedProps); }; // Text的render事件,
_proto.render = function render() { var _this = this; //将JSX语法转JS语法
return React.createElement(TextAncestorContext.Consumer, null, function (hasTextAncestor) { var element = _this.renderText(hasTextAncestor); //如果当前Text的父级是Text,当前Text是‘span’类型的组件,同时可以获取父级的数据,否则为‘div’,
return hasTextAncestor ? element : React.createElement(TextAncestorContext.Provider, { value: true }, element); }); }; return Text; }(React.Component);
Text.displayName = 'Text'; //样式
var classes = css.create({ text: { border: '0 solid black', boxSizing: 'border-box', color: 'black', display: 'inline', font: '14px System', margin: 0, padding: 0, whiteSpace: 'pre-wrap', wordWrap: 'break-word' } }); var styles = StyleSheet.create({ notSelectable: { userSelect: 'none' }, pressable: { cursor: 'pointer' } }); //输出组件 export default applyLayout(applyNativeMethods(Text));
从源码可以看出几个关键的地方,比如:applyNativeMethods方法是将一些Native方法dom化,而applyLayout方法是将组件的生命周期函数以及onLayout事件进行了重写,createElement和 React.createElement方法创建了React Element以及在这个过程中取到domProps;再比如render方法做了一些视图层级、数据传递、props是否支持等逻辑处理。RN中的flex样式布局是CSS的一个子集,从源码可以看出有两种样式一个是css.create,一个是StyleSheet.create,其实StyleSheet.create方法是变了styles,然后使用键值key做参数,调用ReactNativePropRegistry.register方法获取对应的id,然后再将id存起来,返回key->id的数据。代码如下:
create: function create(styles) { var result = {}; Object.keys(styles).forEach(function (key) { var id = styles[key] && ReactNativePropRegistry.register(styles[key]); result[key] = id; }); return result; },
小结
不管RN还是Web,在上层都是使用的react的JSX语法开发的UI组件,RN测是通过react-native引擎bridge桥接到iOS或Android的原生组件,而web测的react-native-web是通过react-dom将React组件渲染到网页;所以RN和Web还是有一定的区别,这个时候就需要Platform.OS、.ios.js、.android.js、.web.js等方式来处理专属平台的逻辑。
0 3
JDReact的实践
JDReact平台在React Native开源框架基础上,针对京东业务做了好多定制化功能研发。不仅打通了Android/iOS/Web三端平台,而且不依赖发版就能无缝集成到客户端(android/iOS)或者转换成Web页面进行线上部署,真正实现了一次开发,快速部署三端。
JDReact是将react-native-webfork到了自己的仓库,进行了二次开发和功能扩展,同时,搭建了自己的转Web server脚手架,产出了京东自己的xxx-web库。下面咱们从一个业务项目入手,揭开它红红的头纱。
项目初始化:
1. 项目模版
从JDReact平台申请业务插件,拿到手的目录结构大概如下所示;结合咱们上面讲的RN项目整合react-native-web,你是不是从中发现了点秘密呢!有个.web.js的文件,JDReact平台默认创建好了Web入口文件;同时,这个项目初始化是脚本一键生成的,思路大概是:执行shell脚本时,输入创建业务名称,然后创建xxxx-jdreactxxxx文件,进入目录后可以将本地项目初始化模版复制过来或者clone远端项目初始化模版,再遍历当前目录下的文件夹以及文件,将模版名改为业务名称,最后将该文件推送到远端仓库,提供给业务研发进行业务开发。
|-xxxx-jdreactxxxx |-README.md |-jsbundles | |-JDReactxxxx | | |-index.js | |-JDReactxxxx.js | |-JDReactxxxx.version | |-JDReactxxxx.web.js |-package.json
2.package.json 文件
package.json文件包含关于项目的重要信息;在devDependencies字段下增加JDReact的核心web库依赖,这个库只是给Web端使用的,为不影响iOS和Android包大小,并且Web的操作都是在开发阶段,devDependencies可以明确地指出生产环境不需要哪些依赖项;xxxx-web库包含两部内容,一部分是常用的组件及API,另一部就是脚手架;在scripts字段下增加三个指令分别为web-init、web-start、web-bundle,这三个指令涵盖了上面RN项目整合Web从配置到运行打包的操作;web-init指令是将咱们需要的html模版、webpack配置以及Babel全家桶复制到咱们项目的根目录;web-start、web-bundle指令是启动本地开发环境以及输出上线需要的资源文件。package.json如下:
{ "scripts": { "web-init": "node ./xxxx-web/xxx/cli.js init", "web-start": "node ./xxxx-web/xxx/cli.js start", "web-bundle": "rm -rf build-web && node ./xxxx-web/xxx/cli.js bundle" }, "dependencies": { "xxxx-lib": "^2.0.11", "react": "16.8.3", "react-native": "0.59.9" }, "devDependencies": { "xxxx-web": "^2.0.2" } }
项目转 Web:
初始化 web
在项目根目录执行npm run web-init命令后,在项目根目录就会生成一个web文件夹,有html,webpack配置等文件;目录结构如下:
|-xxxx-jdreactxxxx |-README.md |-jsbundles | |-JDReactxxxx | | |-index.js | |-JDReactxxxx.js | |-JDReactxxxx.version | |-JDReactxxxx.web.js |-package.json |-web | |-config.js | |-index.tpl.vm | |-webpack.config.base.js | |-webpack.config.dev.js | |-webpack.config.prod.js
执行npm run web-start命令后,就可以启动本地调试服务,可以在浏览器中通过http://localhost:3001(端口可以自己配置)来访问;该命令总要做了些文件处理、编译配置和webpack-dev-server配置启动服务;webpack-dev-server的详细配置可看官网;部分配置如下:
function runDevServer(compiler, webpackConfig, cliConfig) { var devServer = new WebpackDevServer(compiler, { compress: false, clientLogLevel: 'none', hot: true, publicPath: webpackConfig.output.publicPath, quiet: true, https: !!cliConfig.https, host: cliConfig.host || '0.0.0.0' });
// Launch WebpackDevServer. devServer.listen(cliConfig.port, (err, result) => { if (err) { return console.log(err); } }); }
小结
JDReact在这方面做了很多工作,包括部分组件的实现、兼容性问题的处理;JDReact对webpack做了基础的配置,如果业务没有特殊需求,是完全可以满足大部分业务的需求;由于不同平台部署Web的方式不同,所以会涉及到修改webpackd的一些配置,比如说plugins的HtmlWebpackPlugin;再或者是项目中使用了第三方库,需要在resolve.alias配置一下。
本文内容涉及的技术点比较多,难免有些纰漏,欢迎各位大佬批评指正。其实本文内容还涉及转化过程兼容性问题处理和转化后的上线部署等技术点,但由于这部分内容也比较多,考虑到本篇文章的篇幅,不再一一赘述。
附录:
完整版: React Native
项目整合
react-native-web
的
webpack
配置:
const path = require('path'); var HTMLWebpackPlugin = require('html-webpack-plugin'); module.exports = { /* webpack 入口文件 */ entry: './index.web.js', /* webpack 输出文件 */ output: { filename: 'index.web.js', path: path.resolve(__dirname, 'build'), }, /* webpack mode 配置 */ mode: 'development', /* webpack loaders配置 */ module: { rules: [{ test: /\.js$/, exclude: /(node_modules|bower_components)/, use: {loader: 'babel-loader',} }] }, /* webpack resolve配置 解析路径&loader*/ resolve: { alias: { 'react-native$': 'react-native-web' } }, /* webpack 自动编译配置 webpack-dev-server */ devServer: { contentBase: path.resolve(__dirname, 'build'), // compress: true, port: 3000 }, /* webpack 插件配置 html-webpack-plugin */ plugins: [ new HTMLWebpackPlugin( {template: path.join(__dirname, 'index.html')} ) ]}