如何使用ReactNative优雅快速开发一个属于自己的app

先看整体app的效果,源码GitHub代码地址,欢迎大家进行star和一起探讨。

悦月天气

本人代码的开发环境信息:

Mac电脑系统:Mac OS X 10.13.2
IDE:Sublime Text,version 3.0,Build 3143
node版本:v9.3.0
npm版本:5.6.0
yarn版本:  1.3.2(npm和yarn使用其中一个就行)
react-native版本: 0.51.0
Xcode: Version 9.2 (9C40b)
Android Studio 3.0.1

一、工具篇-工欲善其事,必先利其器

我选择的代码编辑器是Sublime Text(可能需要翻墙才能访问下载,我在百度网盘分享一个链接:Sublime Text 3,密码:bmjs),选择他来开发React Native应用的主要原因是他是免费的。但是想要高效使用Sublime开发就必须使用Sublime的插件包管理器Package Control下载几个高效开发的插件:JSX(ES6)语法高亮插件BabelJSX代码格式化插件JsFormatJSX语法检测插件ESLint。目前自己代码开发用到这三个就足够了。

我发现另一个开发工具Expo Snack很强大,直接在浏览器上写RN代码,然后实时查看效果,迅速提升开发效率,还可将app发布到Expo上面进行查看,比如react-navigation的学习demo NavigationPlayground,新手可以尝试使用这个工具进行开发。

  1. 安装包管理器(Package Control
    Tools -> Command Palette... -> 输入:Install ,然后鼠标单击Package Control选项,Sublime编辑器的左下角应该会有文字提示正在下载。查看是否下载成功:Sublime Text -> Preferences 最下面有Package Control

  2. 安装JSX语法高亮插件:Babel
    先看效果:

    sublime安装jsx语法高亮插件前后效果对比

    如今,React Native使用JSX(ES6)语法开发,sublime是没法高亮的,从上图左半部分的右下角和右半部分的右下角的区别在于可以看出默认JavaScript和Javascript(Babel),这正是js和jsx语法的区别,但是默认是没有Babel语言选项的,我们需要Package Control进行下载。
    安装步骤:快捷键Shift+Command+P -> 输入:Install Package,回车 -> 输入:Babel,回车。
    下载Babel

    查看是否下载成功:Sublime Text -> Preferences -> Package Settings 里面是否有Babel。
    Babel下载成功

    切换语法为Babel,点击Sublime右下角语言这里切换为Babel,此源文件就可以高亮了。
    设置Babel

我遇到一个问题是:当我关闭了项目,下次再次打开,js文件的默认语言还是JavaScript,想要高亮还得在重复上一步操作:切换语法为Babel。如果哪位有一劳永逸的设置方法,请告知下!

  1. 安装代码格式化插件:JsFormat
    安装、查看的步骤和2一样,使用Package Control搜索JsFormat。
    最为主要的是需要其他配置:
    首先,代码格式化快捷键ctrl+option+f,为了防止与其他插件快捷键冲突,我们最好在Key Bindings - User中添加如下json
{ "keys": ["ctrl+alt+f"], "command": "js_format", "context": [{"key": "selector", "operator": "equal", "operand": "source.js,source.json"}] }

JsFormat设置

设置user keybindings file

其次,我们需要设置代码格式化对JSX的支持,如上图JsFormat设置我们打开Settings - User,并且添加如下json:

{
    "e4x": true, // 支持jsx
    "format_on_save": false, // 保存自动代码格式化,默认也为false,可以不加
    "brace_style": "collapse-preserve-inline", // {} 不换行,加与不加的效果自己体验
}

当我设置了"format_on_save": true,我发现使用快捷键自动保存代码,触发代码格式化时有的代码会出现错乱,而且只是在ListView这部分会出现这样的情况,如下gif所示,如果哪位解决这个问题我们可以探讨下。

jsFormat.gif

所以当我需要代码格式化时,我一般是选中需要代码格式化的部分进行代码格式化。

  1. 安装语法检测插件ESLint
    4.1 安装ESLint、以及ESLint插件
    我选择的是局部安装(安装在当前项目目录/node_modules/目录下),(如果全局安装则都使用全局安装,安装在/usr/local/lib/node_modules目录下),在项目根目录下依次,终端依次执行以下命令:
$ npm install eslint --save-dev

$ npm install eslint-plugin-react --save-dev # ESLint支持React语义JSX的插件

$ npm install eslint-plugin-react-native # React Native 的插件

$ npm install babel-eslint #支持ES6语法的插件

安装的时候注意是否出现UNMET PEER DEPENDENCY字段,出现了则代表安装失败,那么可以尝试全局安装。
4.2 配置.eslintrc文件
切换到项目根目录,包含packge.json的目录下,执行eslint --init

eslint选项

之后在项目根目录生成了一个.eslintrc.json文件,并且配置如下:

{
    "env": {
        "es6": true,
        "node": true,
        "react-native/react-native": true
    },
    "extends": ["eslint:recommended", "plugin:react/recommended","plugin:react-native/all"],
    "plugins": [
        "react",
        "react-native"
    ],
    "parser": "babel-eslint",
    "parserOptions": { 
        "ecmaVersion": 6,
        "ecmaFeatures": {
            "jsx": true
        }
    },
    "rules": { // 规则,包括React Native语法的规则
        "class-methods-use-this": 0,
        "react-native/no-color-literals": 0,
        "react-native/no-unused-styles": 0,
        "react-native/no-inline-styles": 0,
        "react-native/split-platform-components": 2,
        "no-unused-vars": 0,
        "no-use-before-define": 0,
        "no-console": 0,
        "react/prop-types": 0,
        "react/no-direct-mutation-state": 0
  }
}

想要更详细的配置可以查看下官网的Configuring ESLint
4.3 安装Sublime插件SublimeLinter、SublimeLinter-contrib-eslint
使用package control进行安装,然后配置setting-user文件如下:

{
    "user": {
        "debug": true,
        "delay": 0.25,
        "error_color": "D02000",
        "gutter_theme": "Packages/SublimeLinter/gutter-themes/Default/Default.gutter-theme",
        "gutter_theme_excludes": [],
        "lint_mode": "background",
        "linters": {
            "eslint": {
                "@disable": false,
                "args": [],
                "excludes": []
            },
            "jsxhint": {
                "@disable": false,
                "args": [],
                "excludes": []
            }
        },
        "mark_style": "outline",
        "no_column_highlights_line": false,
        "passive_warnings": false,
        "paths": {
            "linux": [],
            "osx": [],
            "windows": []
        },
        "python_paths": {
            "linux": [],
            "osx": [],
            "windows": []
        },
        "rc_search_limit": 3,
        "shell_timeout": 10,
        "show_errors_on_save": false,
        "show_marks_in_minimap": true,
        "syntax_map": {
            "html (django)": "html",
            "html (rails)": "html",
            "html 5": "html",
            "javascript (babel)": "javascript",
            "magicpython": "python",
            "php": "html",
            "python django": "python",
            "pythonimproved": "python"
        },
        "tooltip_fontsize": "1rem",
        "tooltip_theme": "Packages/SublimeLinter/tooltip-themes/Default/Default.tooltip-theme",
        "tooltip_theme_excludes": [],
        "tooltips": false,
        "use_current_shell_path": false,
        "warning_color": "DDB700",
        "wrap_find": true
    }
}
settings-user

效果如下:


静态语法检测效果

二、代码篇

悦月天气这个app比较简单(GitHub代码地址),主要有两部分:城市天气展示界面,选择城市界面。首页的天气预报的接口使用的是和风天气免费的每天1000次访问的API,城市列表数据使用的郭霖的第一行代码的接口。下面是一套RN代码分别在iPhone6模拟器和android模拟器的效果,代码复用率几乎为100%

android_weather.gif

ios_weather.gif

开发这个app花费我总共一周的时间,包括去学习RN最基本的JSX语法、组件、state状态以及props,到现在全部完成整个app,到目前为止,我也只能算是懂一点皮毛,至于要更加深入的理解、使用,则需要更多的实践和学习,所以这代码部分我更多的是以一个初学者的身份来怎么快速进行编码和解决问题。至于学习教程我建议使用React Native官网的资料,而不是React Native 中文网,但是这个就需要一点英语基础了,遇到实在不懂的,完全可以使用翻译工具解决,看原始英文文档的好处是可以接触最新的技术,同时提升自己的英语水平。比如下面要提及的比如create-react-native-appExpo等等技术。

  1. 创建项目:create-react-native-app
    我使用的是终端命令:create-react-native-app DailyWeather而不是react-native init DailyWeather来创建的项目。这二者最大的区别就是,前者没有嵌入式,直接npm start或者yarn start就能运行项目,再结合Expo app就能在模拟器实时查看运行效果,没有ios和android这两个文件目录,一个纯js项目,不能通过Xcode或者Android Studio开发工具打开项目进行配置。由于我这个app的icon图标和启动图片是使用原生配置,所以需要执行npm run ejectyarn run eject命令,生成这两个ios和android目录,然后通过配置Xcode和Android Studio来配置icon图标和启动页。

    create-react-native-app DailyWeather项目目录

    react-native init DailyWeather 以及 yarn run eject后项目目录

  2. 运行项目
    在项目目录执行yarn run eject之前(也就是有ios和android两个工程项目目录之前),我是直接执行yarn start启动一个开发服务器,然后在模拟器或者手机上使用Expo app 打开悦月天气app进行开发调试;之后我就直接通过Xcode和Android Studio分别打开项目然后运行在模拟器上,运行之前得先将AppDelegate.m的第21行代码jsCodeLocation = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];改为jsCodeLocation = [NSURL URLWithString:@"http://127.0.0.1:8081/index.bundle?platform=ios&dev=true"];否则运行将报错:

  3. 首页代码编写


class HomeScreen extends Component < {} > {

  //组件的构造方法,在组建创建的时候调用
  constructor(props) {
    super(props);
    var ds = new ListView.DataSource({
      rowHasChanged: (r1, r2) => r1 !== r2
    });
    this.state = {
      isLoading: true,
      quiltyColor: true,
      refreshing: false,
      dataSource: ds,
      suggegstions: {}, // 代表一个空json,生活建议
      aqi: {}, // aqi
      basic: {},
      title: '海淀' // 默认获取海淀区的天气
    };
  }

  // 页面的渲染
  render() {
    if (this.state.isLoading) {
      return (
        <View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
          <ActivityIndicator/>
        </View>
      )
    }
    return (
      <View style={{flex: 1}}>
        {/*北京图*/}
        <ImageBackground source={{uri: 'http://cn.bing.com/az/hprichbg/rb/BarHarborCave_ROW9345444229_1920x1080.jpg'}}  style={{width: screenWidth, height: screenHeight}}>
          <ScrollView style = {{flex: 1}} 
            refreshControl={
              <RefreshControl refreshing={this.state.refreshing} onRefresh={this._onRefresh.bind(this)}/>
            }>
            {/*渲染头部信息*/}
            {this.reanderHeader()}
            {/*渲染天气预报列表*/}
            {this.reanderForecast()}
            {/*渲染天气质量*/}
            {this.renderAirquilty()}
            {/*渲染生活指数*/}
            {this.renderSuggestion()}
          </ScrollView> 
        </ImageBackground>
      </View>
    );
  }

  _onRefresh() {
    this.setupData(this.state.title);
  }

  // 组件已经装载,绘制完毕调用
  componentDidMount() {
    console.log(screenWidth)
    console.log(screenHeight)
    this.setupData(this.state.title);
  }

  // 组件即将卸载,组件要被从界面上移除时调用
  componentWillUnmount() {
    console.log('HomeScreen','componentWillUnmount')
  }

  setupData(cityname) {
    // cityid可以使用cityid 也可以使用 城市名,选择城市回调回来
    let urlstr = 'http://guolin.tech/api/weather?key=41dd96cb90344217acbf5fe0813f16cd' + '&cityid=' + cityname
    console.log(urlstr)
    fetch(urlstr)
      .then((response) => response.json())
      .then((responseJson) => {
        console.log('success1', responseJson);
        console.log('success2', responseJson.HeWeather[0].daily_forecast);
        console.log('success3', responseJson.HeWeather[0].suggestion);

        this.setState({
          refreshing: false,
          isLoading: false,
          dataSource: this.state.dataSource.cloneWithRows(responseJson.HeWeather[0].daily_forecast),
          suggegstions: responseJson.HeWeather[0].suggestion,
          aqi: responseJson.HeWeather[0].aqi,
          title: responseJson.HeWeather[0].basic.city,
          des: responseJson.HeWeather[0].now.cond.txt,
          temp: responseJson.HeWeather[0].now.tmp,
        })
      })
      .catch((error) => {
        console.error(error);
      });
  }

  reanderHeader() {
    return (
      <View style={styles.header}>

            <View style={{flexDirection: 'row', alignItems: 'center' ,marginTop: 44, marginBottom: 10}}>
              <Text style={styles.headerTitle}>{this.state.title}</Text>
              <TouchableOpacity onPress={() => this.props.navigation.navigate('City',{name: this.state.title, currentLevel: 0, callBack: (data) => {
                  console.log(data) // weather_id
                  this.setupData(data);
                }})}>
                <Image source={require('./address.png')}/>
              </TouchableOpacity>
            </View>
            <Text style={styles.headerDes}>{this.state.des}</Text>
            <Text style={styles.headerTepe}>{this.state.temp}℃</Text>
      </View>
    );
  }

  reanderForecast() {
    return (
      <View style={styles.forecast}>
            <Text style={{color: 'white', fontSize: 20, marginBottom: 10}}>预报</Text>
            <ListView 
              dataSource={this.state.dataSource}
              renderRow={(rowData) =>  (
                <View style={styles.listView}>
                  <Text style={{color: 'white',flex: 1}}>{rowData.date}</Text>
                  <Text style={{color: 'white',flex: 1}}>{rowData.cond.txt_d}</Text>
                  <Text style={{color: 'white',flex: 1}}>{rowData.tmp.max}</Text>
                  <Text style={{color: 'white',flex: 1}}>{rowData.tmp.min}</Text>
                </View>
              )}/> 
      </View>
    );
  }

  renderAirquilty() {
    let {
      aqi
    } = this.state;
    return (
      <View>
          <View style={styles.suggestion}>
            <View style={{flexDirection: 'row',justifyContent: 'flex-start', alignItems: 'center',padding: 20}}>
              <Text style={{color: 'white',fontSize: 20}}>空气质量:</Text>
              <Text style={{color: 'red',fontSize: 17}}>{aqi.city.qlty}</Text>
            </View>
            <View style={{flex: 1,flexDirection: 'row'}}>
              <View style={{flex: 1, alignItems: 'center' }}>
                <Text style={styles.airQuiltyDes}>AQI指数</Text>
                <Text style={styles.airQuiltyValue}>{aqi.city.aqi}</Text>
              </View>
              <View style={{flex: 1, alignItems: 'center' }}>
                <Text style={styles.airQuiltyDes}>PM2.5</Text>
                <Text style={styles.airQuiltyValue}>{aqi.city.pm25}</Text>
              </View>
            </View>
            <View style={{flex: 1,flexDirection: 'row'}}>
              <View style={{flex: 1,alignItems: 'center' }}>
                <Text style={styles.airQuiltyDes}>PM10</Text>
                <Text style={styles.airQuiltyValue}>{aqi.city.pm10}</Text>
              </View>
              <View style={{flex: 1, alignItems: 'center' }}>
                <Text style={styles.airQuiltyDes}>O3指数</Text>
                <Text style={styles.airQuiltyValue}>{aqi.city.o3}</Text>
              </View>
            </View>
          </View>
        </View>
    )
  }

  renderSuggestion() {
    let {
      suggegstions
    } = this.state;
    return (
      <View style={styles.suggestion}>
          <Text style={{color: 'white',fontSize: 20,marginBottom: 20,marginTop: 20,marginLeft: 20}}>生活建议</Text>

          <Text style={styles.suggestionDes}>空气质量:{suggegstions.air.txt}</Text>
          <Text style={styles.suggestionDes}>舒适度:{suggegstions.comf.txt}</Text>
          <Text style={styles.suggestionDes}>洗车:{suggegstions.cw.txt}</Text>
          <Text style={styles.suggestionDes}>穿衣:{suggegstions.drsg.txt}</Text>
          <Text style={styles.suggestionDes}>感冒:{suggegstions.flu.txt}</Text>
          <Text style={styles.suggestionDes}>运动:{suggegstions.sport.txt}</Text>
          <Text style={styles.suggestionDes}>旅游:{suggegstions.trav.txt}</Text>
          <Text style={styles.suggestionDes}>紫外线:{suggegstions.uv.txt}</Text>
        </View>
    );
  }
}

我们主要看render()方法,这个方法主要进行页面的渲染。说实话,刚开始使用Flexbox布局时还真别扭(使用iOS布局技术AutoLayout久了),最后按照使用Flexbox倒腾了几个 demo,也就慢慢会了基本布局。背景图使用的是ImageBackground组件,首页信息显示使用的是ScrollView组件,三天的预报使用的ListView组件,其他的就是其他组件嵌套布局起来的。这里遇到过的两个阻碍,一是页面背景原先使用Image组件,但是发现他不能够嵌套其他元素;其次就是页面路由,使用的react-navigation的StackNavigator,在这里花费了两天学习,包括他的 官方demo NavigationPlayground(得先下载Expo app进行扫面二维码打开)

  1. 城市列表代码编写


class CityScreen extends React.Component {

  static navigationOptions = ({navigation}) => ({
    headerMode: 'float',
    gesturesEnabled: true,
    headerTitle: `${navigation.state.params.name}`,
    headerLeft:  (
      <TouchableOpacity onPress={() => {
          navigation.goBack(null)        
      }}>
        <Image source={require('./moments_btn_back.png')} style={{marginLeft: 8}}/>    
      </TouchableOpacity>
      ),
    headerStyle: {
      backgroundColor: '#6666ff',
    },
    headerTintColor: 'white',
    headerTitleStyle: {
      fontSize: 20
    }
  });

  constructor(props) {
    super(props);
  
    this.state = {
      loading: true,
      currentLevel: 0, // 0:province, 1:city, 2:county;
      provinces: [{}],
      provinceData: {},
      cityData: {}
    };
  }

  render() {
    const { navigation } = this.props;
    const { state, setParams, goBack } = navigation;
    const { params, currentLevel } = state;
    if (this.state.loading) {
      return  (<View style={{flex: 1, justifyContent: 'center', alignItems: 'center'}}>
        <ActivityIndicator/>
      </View>)
    } 
    return (
        <FlatList style={{backgroundColor: 'white'}}
          data={this.state.provinces}
          renderItem={({item}) => { 
            return (
              <TouchableOpacity 
                  style={{justifyContent: 'center', alignItems: 'center',height: 44}}
                  onPress={() => {
                    setParams({name: item.name})
                    let level = this.state.currentLevel
                    if (this.state.currentLevel === 0) {
                      this.state.provinceData = item
                    } else if (this.state.currentLevel === 1) {
                      this.state.cityData = item
                    }
                    if (this.state.currentLevel === 2) {
                      console.log("goBack")
                      state.params.callBack(item.weather_id) // 回调
                      goBack(null)
                    } else {
                      this.state.currentLevel = level + 1
                      setParams({currentLevel: level + 1})
                      console.log(this.state.currentLevel)
                      // console.log(this.state.provinceData )
                      this.state.loading = true 
                      this.setupData(this.state.currentLevel)
                    }
                  }}>
                  <Text style={{ color: 'gray', fontSize: 20}} >{item.name} </Text>   
              </TouchableOpacity>
             
            )} }
          ItemSeparatorComponent={ () => { return (
            <View style={{height: 1, backgroundColor: '#eee'}}/> //</View>
            )}}
          keyExtractor={
            (item, index) => item.id
          }
        />
      )
  }

  componentDidMount() {
    this.setupData(0)
  }

  setupData(level) {
  
    var urlstr = ''
    
    if (level == 0) {
      urlstr = 'http://guolin.tech/api/china'
    } else if (level == 1) {
      let provinceData = this.state.provinceData
      console.log(provinceData)
      urlstr = 'http://guolin.tech/api/china/' + `${provinceData.id}`
    } else if (level == 2) {
      let provinceData = this.state.provinceData
      let cityData = this.state.cityData
      urlstr = 'http://guolin.tech/api/china/' + `${provinceData.id}` + '/' + `${cityData.id}`
    }
    console.log(urlstr)
    fetch(urlstr)
    .then((response) => response.json())
    .then((responseJson) => {
      console.log(responseJson)
      this.setState({
        loading: false,
        provinces: responseJson,
        currentLevel: level,
      })
    })
    .catch((error) => {
        console.error(error);
    });
  }
}

列表我是用的是FlatList组件,这个页面遇到的阻碍有两个,一是点击item更换navigation的headertitle;二是最后的点击城市回调数据给首页同时更新首页数据。

总结

你可能会说这个app很简单,的确他很简单(比如没有地图,没有数据持久化等等,未来说不定就有了),只是一个简单的界面展示。但是只有当你真正实践去开发一个属于你的app时,你会发现其实过程没有你想的简单;但是当你完成时,你就会感觉小有成就,特别是能够分享出去,让更多的人看到。前两天看到池建强老师的2018技术趋势预测,移动开发学点js也很不错。

推荐阅读更多精彩内容