全部文章

welogger app 开发笔记 1 - react-native 初探

2017-11-11 @sunderls

welogger react-native

welogger.com之前一直打算做网页App,不过根据实际使用来看,还是体验太差:

  1. 目前的iOS standalone 模式下每次点击图标都会重新加载,非常闹心,无法回到上一次访问地址。
  2. 加载速度太慢
  3. iOS已经开始开发serice worker的支持,不过不知道还要等多久
  4. 点击添加照片过后,反应时间太长。相册中获取照片或者新拍照后能做的事情有限。虽然可以binary读取进行操作,但是异常麻烦。
  5. webview的滚动边界会出现异常问题
  6. 时间/文字输入等键盘无法控制,UI不搭配,动画效果个人不喜欢
  7. 地理位置每次都需要确认
  8. 用户打开浏览器的习惯难以养成

所以还是决定做welogger的原生APP,趁机摸一摸react-native,第一天做到了如下效果,这里整理下学习到的内容。

初步上手

首先查看react-native的指南,发现上手非常简单。

npm install -g create-react-native-app
create-react-native-app welogger
cd welogger
npm start

然后根据选项选择在手机中打开还是在模拟器中打开,修改App.js后可以得到hot reload的效果

css 调整

样式的写法和普通的css还是有很大区别,需要适应一下,不过问题不大,主要感觉利用的比较多的是flexbox,其他的可以需要的时候去查查文档。 以下是首页的样式。

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    justifyContent: 'center',
    flexDirection: 'column',
    justifyContent: 'space-between',
  },
  main: {
    flex: 1,
    backgroundColor: '#fff',
    justifyContent: 'center',
    flexDirection: 'column',
    justifyContent: 'center'
  },
  title: {
    textAlign: 'center',
    color: '#2196f3',
    fontSize: 30,
    lineHeight: 60,
    fontFamily: 'Avenir Next',
    fontWeight: 'bold'
  },
  intro: {
    textAlign: 'center',
    lineHeight: 25,
    fontSize: 16,
    color: '#555'
  },
  bottom: {
    backgroundColor: '#fff',
    justifyContent: 'center',
    flexDirection: 'row',
    justifyContent: 'space-between',
    paddingLeft: 40,
    paddingRight: 40,
    paddingBottom: 50
  }
});

react-navigation 嵌套

官网有推荐react-navigation,就试了一下。 welogger的页面大概设计如下:

  1. Home -> 根据登录情况显示不同内容,引导登录
  2. Login -> 输入用户密码登录,这里用从下方弹入的modal transition
  3. Profile -> 这里用普通的自右方push in的transition

因为transition不同,直观上采用navigator嵌套

最外面是一个modal的StackNavigator,里面潜逃一个默认的StackNavigator。

App.js

const App = StackNavigator({
  Home: { screen: Home },
  Profile: { screen: Profile }
});

export default StackNavigator({
  App: { screen: App },
  Login: { screen: Login },
}, {
  mode: 'modal',
  headerMode: 'none'
});

注意header的处理,如果不做任何处理,在home和profile会出现两个header。 Home本身的header可以关闭,但是Profile需要有。所以这里简单起见,把最外面的navigator给关掉了,headerMode:'none'。所以需要自己在login界面搞一个cancel。估计还有更好的办法,还没有研究透彻。

JWT来管理session

由于welogger网页版就采用的jwt的形式,所以这里开发APP的时候无缝链接。

  1. jwt存储在AsyncStorage中。和localStorage基本对应
  2. API request的时候带上AsyncStorage的token
  3. API response的header中如果有看到对应的字段,更新AsyncStorage的token
  4. 登陆的时候发行新token,并更新AsyncStorage

Api.js

将API的处理单独抽离出来,直接用fetch。

api.js

import { t } from './Locale';
import { AsyncStorage } from 'react-native';

const API_BASE = 'xxxxx';

function handle(response) {
    const header = response.headers.get('Authorization');
    if (header) {
        const token = header.split(/\s+/)[1];
        AsyncStorage.setItem('token', token);
    }

    return response.json().then(result => {
        if (!response.ok) {
            if (typeof result.error.message === 'string') {
                alert(t(result.error.message));
            } else if (typeof result.error.message === 'object') {
                alert(Object.keys(result.error.message).map(key => {
                    let msg = result.error.message[key];
                     return Array.isArray(msg) ? msg.map(t).join('\n') : t(msg);
                }).join('\n'));
            }
            throw new Error(result.error.message || result.error); 
        }
        return result;
    });
}

const Api = {
    async get(path, params){
        const token = await AsyncStorage.getItem('token');
        return fetch(API_BASE + path, {
            mode: 'cors', 
            method: 'GET', 
            headers: {
                'Authorization': 'Bearer ' + token,
            }
        }).then(handle)
    },

    async post(path, params){
        const token = await AsyncStorage.getItem('token');
        return fetch(API_BASE + path, {
            mode: 'cors', 
            method: 'POST', 
            body: JSON.stringify(params),
            headers: {
            'Authorization': 'Bearer ' + token,
            'Content-Type': 'application/json',
            Accept: 'application/js'
            }
        }).then(handle)
    },

    delete(path, params){
        return fetch(API_BASE + path, {
            mode: 'cors', 
            method: 'DELETE', 
            body: JSON.stringify(params),
            headers: {
            'Authorization': 'Bearer ' + localStorage.getItem('token'),
            'Content-Type': 'application/json',
            Accept: 'application/js'
            }
        }).then(handle)
    }
}

export default Api;

Login 处理

Login界面比较简单了,现实两个input,然后调用发行token 的API就OK。

Login.js

import API from '../lib/Api';
import { AsyncStorage } from 'react-native';

export default class Login extends React.Component {
    static navigationOptions = {
        title: 'login',
    }

    constructor(props) {
        super(props);

        this.state = {
            email: ''
        };
    }

    cancel = () => {
        this.props.navigation.goBack();
    }

    login = async () => {
        API.post('/auth', {
            email: this.state.email,
            password: this.state.password
        }).then(async res => {
            await AsyncStorage.setItem('token', res.token);
            this.props.navigation.goBack();
        });
    }

    render() {
        return (
        <View style={styles.container}>
            <View style={styles.main}>
            <TextInput
                style={styles.input}
                onChangeText={(text) => this.setState({email: text})}
                placeholder="email"
                value={this.state.email}
            />
            <TextInput
                style={styles.input}
                onChangeText={(text) => this.setState({password: text})}
                placeholder="password"
                secureTextEntry={true}
                value={this.state.password}
            />
            <Button title="LOGIN" onPress={this.login} />
            <Button title="cancel" onPress={this.cancel} />
            </View>
        </View>
        );
    }
}

Home界面

Home中根据是否API loading以及是否登录,来显示不同文字。

Screen是抽离的一个class,待会儿会说明。

Home.js

export default class Home extends Screen {
  constructor(props) {
    super(props);

    this.state = {
      isLoading: true
    }
  }

  // home页面没有header
  static navigationOptions = {
      header: null
  }

  //前往登录
  gotoLogin = () => {
    this.props.navigation.navigate('Login');
  }

  // 前往profile界面,同时传递给其name
  gotoProfile = () => {
    this.props.navigation.navigate('Profile', { name: this.state.user.name})
  }

  // logout的时候,销毁token就OK
  logout = async () => {
    await AsyncStorage.removeItem('token');
    this.setState({
      user: null
    });
  }

  componentDidMount() {
    super.componentDidMount();

    this.getData().then(this.update)
  }

  getData() {
    return API.get('/init').catch(e => {
      this.setState({
        user: null
      });
    });
  }

  update = (data) => {
    this.setState(Object.assign({}, {
      isLoading: false
    }, data));
  }

  // 当页面从登陆页回来的时候
  // 这里是我们自己加的hook 待会儿说
  componentDidFocus() {
    this.getData().then(this.update)
  }

  render() {
    const state = this.state;
    return (
      <View style={styles.container}>
        <View style={styles.main}>{
          state.isLoading ? <Text style={styles.intro}>loading</Text>
            : state.user ? <Button title={state.user.name} onPress={this.gotoProfile} />
            : <Text style={styles.intro}>welcome</Text>
        }
        </View>

        <View style={styles.bottom}>
          <Button title="SIGNUP" onPress={this.signup}/>
          <Button title="LOGOUT" onPress={this.logout}/>
          <Button title="LOGIN" onPress={this.gotoLogin}/>
        </View>
      </View>
    );
  }
}

profile界面

profile界面简单,没啥说的。留意下this.props.navigation.state.params可以获得参数就好。

Profile.js

export default class Profile extends React.Component {
    static navigationOptions = {
        title: 'Profile',
    }

    constructor(props) {
        super(props);

        this.state = {
            name: ''
        };
    }

    render() {
        return (
        <View style={styles.container}>
            <View style={styles.main}>
            <Text> This is a profile page for {this.props.navigation.state.params.name}</Text>
            </View>
        </View>
        );
    }
}

登陆页面goBack的检测 componentDidFocus

登录成功过后会返回上一个页面,这个时候上一个页面如何知道自己处于最顶层了呢?

这个我暂时还没有查到官方解决办法,目前react-navigation也貌似还在讨论怎么实现。 个人觉得「回到顶部」和「从顶部下来」这两个hook还是非常有必要的,在我前段时间实现的react-page-stack中,我将hook命名为componentDidTop()componentDidHide()取得了很好的效果,这里怎么办呢?

索性的是react-navigation提供了onTransitioStartonTransitionEnd方法,可以在这里检测到下一个页面,然后通过emitter进行模拟。

在公用的Screen.js中我们检测screen.focus,如果其id和自身一致的话,说明自己被focus了,触发自身的componentDidFocus

Screen.js

export default class Screen extends React.Component {
    constructor(props) {
        super(props);
    }

    componentDidMount() {
        emitter.addListener('screen.focus', (data) => {
            console.log('screen.focus', data.key, `scene_${this.props.navigation.state.key}`);
            if (data.key === `scene_${this.props.navigation.state.key}`) {
                this.isFocusing = true;
                if (this.componentDidFocus) {
                    this.componentDidFocus();
                }
            }
        })
    }
}

而在navigator的定义中,我们需要触发screen.focus

App.js

export default StackNavigator({
  App: { screen: App },
  Login: { screen: Login },
}, {
  mode: 'modal',
  headerMode: 'none',
  onTransitionStart: (target, current) => {
    if (target.index < current.index) {

      emitter.emit('screen.focus', {
        key: target.scene.key.replace(/(.*-)(\d+)/, '$1' + target.index)
      });
    }
  }
});

虽然我感觉这个办法有点不对,不过貌似可以用,暂时就先这样了。后来学习到新办法再说。

总结

welogger的最基本简单的部分也就完成了。接下来是一些要点要做的:

  1. i18n
  2. 各个页面的新功能
  3. geolocation
  4. camera
  5. package