React-Native從零搭建App(長文)

amandakelake發表於2019-03-04

一、須知

全文技術棧 

  • 核心庫:React-Native@0.54.0 
  • 路由導航:React-Native-Navigation 
  • 狀態管理:Redux、Redux-Thunk、Redux-Saga、Redux-persist 
  • 靜態測試:Flow  

本文適合有對React家族有一定使用經驗,但對從零配置一個App不是很熟悉,又想要從零體驗一把搭建App的同學。

我自己就是這種情況,中途參與到專案中,一直沒有掌控全域性的感覺,所以這次趁著專案重構的機會,自己也跟著從零配置了一遍,並記錄了下來,希望能跟同學們一起學習,如果有說錯的地方,也希望大家指出來,或者有更好的改進方式,歡迎交流。 

如果有時間的同學,跟著親手做一遍是最好的,對於如何搭建一個真實專案比較有幫助。

整個專案已經上傳到github,懶的動手的同學可以直接clone下來跟著看,歡迎一起完善,目前的初步想法是對一部分的同學有所幫助,後面有時間的話,可能會完善成一個比較健壯的RN基礎框架,可以直接clone就開發專案那種

該專案github倉庫傳送門


這裡對每個庫或者內容只做配置和基礎用法介紹  

物理環境:mac,xcode 

window系統的同學也可以看,不過需要自己搞好模擬器開發環境

二、快速建立一個RN App

React-native官網

如果RN的基礎配置環境沒有配置好,請點選上方連結到官網進行配置

react-native init ReactNativeNavigationDemo
cd ReactNativeNavigationDemo
react-native run-ios
複製程式碼

因為一開始就計劃好了用React-Native-Navigation作為導航庫,所以名字起得長了點,大家起個自己喜歡的吧

成功後會看到這個介面

React-Native從零搭建App(長文)

這時候可以看下目錄結構,RN自動整合了babel、git、flow的配置檔案,還是很方便的

三、路由導航:React-Native-Navigation

React Native Navigation


為什麼用React Native Navigation而不用React Navigation ?

它是目前唯一一款使用原生程式碼來實現navigator的外掛,使用後navigator的push/pop的動畫將脫離js執行緒而改由原生的UI執行緒處理, 切屏效果會和原生態一樣流暢, 再也不會出現由於js執行緒渲染導致的navigator切屏動畫的卡頓效果了, 並且該外掛還同時內建實現了原生態版本的tabbar


英文好的同學看著官方文件配就可以了,實在看不懂的可以對照著我下面的圖看。 

iOS的需要用到xcode,沒做過的可能會覺得有點複雜,所以我跑了一遍流程並截圖出來了 至於android的配置,文件寫的很清晰,就不跑了。

1、安裝

yarn add react-native-navigation@latest複製程式碼

2、新增xcode工程檔案

圖中的路徑檔案是指./node_modules/react-native-navigation/ios/ReactNativeNavigation.xcodeproj

React-Native從零搭建App(長文)

3、把上面新增的工程檔案新增到庫中

React-Native從零搭建App(長文)

4、新增路徑

$(SRCROOT)/../node_modules/react-native-navigation/ios記得圖中第5點設定為recursive

React-Native從零搭建App(長文)

5、修改ios/[app name]/AppDelegate.m檔案

把整個檔案內容替換成下面程式碼

#import "AppDelegate.h"
#import <React/RCTBundleURLProvider.h>
#import <React/RCTRootView.h>

#import "RCCManager.h"

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
  NSURL *jsCodeLocation;

#ifdef DEBUG
  jsCodeLocation = [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index" fallbackResource:nil];
#else
   jsCodeLocation = [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
#endif

  self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
  self.window.backgroundColor = [UIColor whiteColor];
  [[RCCManager sharedInstance] initBridgeWithBundleURL:jsCodeLocation launchOptions:launchOptions];

  return YES;
}

@end
複製程式碼

6、基礎使用

1、先新建幾個頁面,結構如圖

cd src
mkdir home mine popularize
touch home/index.js mine/index.js popularize/index.js
複製程式碼

React-Native從零搭建App(長文)

每個index.js檔案裡面都是一樣的結構,非常簡單

import React, { Component } from 'react';
import { Text, View } from 'react-native';


type Props = {};
export default class MineHome extends Component<Props> {
  
  render() {
    return (
      <View>
        <Text>MineHome</Text>
      </View>
    );
  }
}
複製程式碼

2、src/index.js 註冊所有的頁面,統一管理 

import { Navigation } from 'react-native-navigation';

import Home from './home/index';
import PopularizeHome from './popularize/index';
import MineHome from './mine/index';

// 註冊所有的頁面
export function registerScreens() {
  Navigation.registerComponent('home',() => home);
  Navigation.registerComponent('popularize',() => popularize);
  Navigation.registerComponent('mine',() => mine);
}
複製程式碼

在這裡先插一句,如果要引入Redux的話,就在這裡直接傳入store和Provider

export function registerScreens(store,Provider) {
    Navigation.registerComponent('home',() => PageOne,store,Provider)
}
複製程式碼

3、App.js 檔案修改app的啟動方式,並稍微修改一下頁面樣式

import { Navigation } from 'react-native-navigation';
import { registerScreens } from './src/screen/index';

// 執行註冊頁面方法
registerScreens();

// 啟動app
Navigation.startTabBasedApp({
  tabs: [
    {
      label: 'home',
      screen: 'home',
      title: '首頁',
      icon: require('./src/assets/home.png'),
    },
    {
      screen: 'popularize',
      title: '推廣',
      icon: require('./src/assets/add.png'),
      iconInsets: {
        top: 5, 
        left: 0,
        bottom: -5, 
        right: 0
      },
    },
    {
      label: 'mine',
      screen: 'mine',
      title: '我',
      icon: require('./src/assets/mine.png'),
    }
  ],
  appStyle: {
    navBarBackgroundColor: '#263136',//頂部導航欄背景顏色
    navBarTextColor: 'white'//頂部導航欄字型顏色
  },
  tabsStyle: {
    tabBarButtonColor: '#ccc',//底部按鈕顏色
    tabBarSelectedButtonColor: '#08cb6a',//底部按鈕選擇狀態顏色
    tabBarBackgroundColor: '#E6E6E6'//頂部條背景顏色
  }
});
複製程式碼


啟動App,目前模擬器能看到的介面

React-Native從零搭建App(長文)

7、頁面跳轉和傳遞引數

在screen/home資料夾下面新建一個NextPage.js檔案,記得到src/screen/index.js裡面註冊該頁面

Navigation.registerComponent('nextPage', () => NextPage, store, Provider);複製程式碼

React-Native從零搭建App(長文)

然後在src/screen/home/index.js檔案裡面加一個跳轉按鈕,並傳遞一個props資料

React-Native從零搭建App(長文)


React-Native從零搭建App(長文)

React-Native從零搭建App(長文)


四、狀態管理:Redux

redux中文文件

1、初始化

1、安裝

yarn add redux react-redux複製程式碼

2、目錄構建

目前有以下兩種常見的目錄構建方式

 一是把同一個頁面的action和reducer寫在同一個資料夾下面(可以稱之為元件化),如下

React-Native從零搭建App(長文)

二是把所有的action放在一個資料夾,所有的reducer放在一個資料夾,統一管理

這兩種方式各有好壞,不在此探究,這裡我用第二種

一通操作猛如虎,先建立各種資料夾和檔案

cd src
mkdir action reducer store
touch action/index.js reducer/index.js store/index.js
touch action/home.js action/mine.js action/popularize.js
touch reducer/home.js reducer/mine.js reducer/popularize.js
複製程式碼

以上命令敲完後,目錄結構應該長下面這樣,每個頁面都分別擁有自己的action和reducer檔案,但都由index.js檔案集中管理輸出

React-Native從零搭建App(長文)

關於建立這三塊內容的先後順序,理論上來說,應該是先有store,然後有reducer,再到action 

但寫的多了之後,就比較隨心了,那個順手就先寫哪個。 

按照我自己的習慣,我喜歡從無寫到有,比如說 store裡面要引入合併後的reducer,那我就會先去把reducer給寫了

import combinedReducer from '../reducer'複製程式碼

但寫reducer之前,好像又需要先引入action,所以我由可能跑去先寫action 

這裡不討論正確的書寫順序,我就暫且按照自己的習慣來寫吧

3、action

我喜歡集中管理的模式,所以所有的antion我都會集中起來 index.js檔案作為總的輸出口

這裡定義了所有的action-type常量

// home頁面
export const HOME_ADD = 'HOME_ADD';
export const HOME_CUT = 'HOME_CUT';

// mine頁面
export const MINE_ADD = 'MINE_ADD';
export const MINE_CUT = 'MINE_CUT';

// popularize頁面
export const POPULARIZE_ADD = 'POPULARIZE_ADD';
export const POPULARIZE_CUT = 'POPULARIZE_CUT';
複製程式碼

然後去寫其他各自頁面的action.js檔案,這裡只以home頁面作為例子,其他頁面就不寫了,開啟action/home.js檔案

import * as actionTypes from './index';

export function homeAdd(num) {
  return {
    type: actionTypes.HOME_ADD,
    num
  }
}

export function homeCut(num) {
  return {
    type: actionTypes.HOME_CUT,
    num
  }
}
複製程式碼

最是返回了一個最簡單的action物件


4、reducer

先寫一個home頁面的reducer,開啟reducer/home.js檔案 其他頁面也同理

import * as actionTypes from '../action/index';

// 初始state,我先隨手定義了幾個,後面可能會用到
const initState = {
  initCount: 0,
  name: '',
  age: '',
  job: ''
}

export default function count(state = initState, action) {
  switch (action.type) {
    case actionTypes.HOME_ADD:
      return {
        ...state,
        ...action.initCount: 
      }
    case actionTypes.HOME_CUT: 
      return {
        ...state,
        ...action.initCount
      }
    default:
      return state;
  }
}
複製程式碼

然後把所有子reducer頁面合併到reducer/index.js檔案進行集中輸出

import homeReducer from './home';
import popularizeReducer from './popularize';
import mineReducer from './mine';

const combineReducers = {
  home: homeReducer,
  popularize: popularizeReducer,
  mine: mineReducer
}

export default combineReducers複製程式碼


5、建立store

建立好reducer之後,開啟store/index.js檔案

import {createStore } from 'redux';

import combineReducers from '../reducer/index';

const store = createStore(combineReducers)

export default store;
複製程式碼

就是這麼簡單


6、store注入

使用過redux的同學都知道,react-redux上場了,它提供了Provider和connect方法 

前面有提到react-native-navigation注入redux的方式,其實差不多 但需要每個子頁面都注入store、Provider 

src/index.js修改如下

import { Navigation } from 'react-native-navigation';

import Home from './home/index';
import PopularizeHome from './popularize/index';
import MineHome from './mine/index';

// 註冊所有的頁面
export function registerScreens(store, Provider) {
  Navigation.registerComponent('home', () => Home, store, Provider);
  Navigation.registerComponent('popularize', () => PopularizeHome, store, Provider);
  Navigation.registerComponent('mine', () => MineHome, store, Provider);
}
複製程式碼

App.js修改執行頁面註冊的方法即可 

import { Provider } from 'react-redux';
import store from './src/store/index';

// 執行註冊頁面方法
registerScreens(store, Provider);
複製程式碼

2、體驗Redux

現在來體驗一下redux,開啟src/screen/home/index.js檔案

import兩個方法

import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
複製程式碼

匯入action

import * as homeActions from '../../action/home';
複製程式碼

定義兩個方法,並connect起來

function mapStateToProps(state) {
  return {
    home: state.home
  };
}

function mapDispatchToProps(dispatch) {
  return {
    homeActions: bindActionCreators(homeActions, dispatch)
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(Home);
複製程式碼

現在在頁面上列印initCount來看一下,只要被connect過的元件,以及從該元件通過路由push跳轉的子頁面都可以通過this.props拿到資料

src/screen/home/index.js完整程式碼如下

import React, { Component } from 'react';
import { Text, View, StyleSheet } from 'react-native';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as homeActions from '../../action/home';

type Props = {};
class Home extends Component<Props> {
  render() {
    return (
      <View style={styles.container}>
        <Text>Home</Text>
        <Text>initCount: {this.props.home.initCount}</Text>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: '#ccc',
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center'
  }
})

function mapStateToProps(state) {
  return {
    home: state.home
  };
}

function mapDispatchToProps(dispatch) {
  return {
    homeActions: bindActionCreators(homeActions, dispatch)
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(Home);
複製程式碼

從頁面可以看到,已經讀到狀態樹裡面的資料了,initCount為0

React-Native從零搭建App(長文)


讓我們再來試一下action的加法和減法

src/screen/home/index.js完整程式碼如下 

import React, { Component } from 'react';
import { Text, View, StyleSheet, TouchableOpacity } from 'react-native';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as homeActions from '../../action/home';

type Props = {};
class Home extends Component<Props> {
  render() {
    return (
      <View style={styles.container}>
        <Text>Home</Text>
        <Text>initCount: {this.props.home.initCount}</Text>

        <TouchableOpacity
          style={styles.addBtn}
          onPress={() => {
            this.props.homeActions.homeAdd({
              initCount: this.props.home.initCount + 2
            });
          }}
        >
          <Text style={styles.btnText}>加2</Text>
        </TouchableOpacity>

        <TouchableOpacity
          style={styles.cutBtn}
          onPress={() => {
            this.props.homeActions.homeCut({
              initCount: this.props.home.initCount - 2
            });
          }}
        >
          <Text style={styles.btnText}>減2</Text>
        </TouchableOpacity>
      </View>
    );
  }
}

const styles = StyleSheet.create({
  container: {
    backgroundColor: '#ccc',
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center'
  },
  addBtn: {
    backgroundColor: 'green',
    marginVertical: 20,
    width: 200,
    height: 59,
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 10
  },
  cutBtn: {
    backgroundColor: 'red',
    width: 200,
    height: 59,
    justifyContent: 'center',
    alignItems: 'center',
    borderRadius: 10
  },
  btnText: {
    fontSize: 18,
    color: 'white'
  }
});

function mapStateToProps(state) {
  return {
    home: state.home
  };
}

function mapDispatchToProps(dispatch) {
  return {
    homeActions: bindActionCreators(homeActions, dispatch)
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(Home);
複製程式碼

現在點選兩個按鈕都應該能得到反饋

React-Native從零搭建App(長文)

現在再來驗證下一個東西,這個頁面改完store裡面的狀態後,另一個頁面mine會不會同步,也就是全域性資料有沒有共享了

src/mine/index.js檔案修改如下 

import React, { Component } from 'react';
import { Text, View } from 'react-native';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as homeActions from '../../action/home';

type Props = {};
class MineHome extends Component<Props> {
  
  render() {
    return (
      <View>
        <Text>initCount: {this.props.home.initCount}</Text>
      </View>
    );
  }
}


function mapStateToProps(state) {
  return {
    home: state.home
  };
}

function mapDispatchToProps(dispatch) {
  return {
    homeActions: bindActionCreators(homeActions, dispatch)
  };
}

export default connect(mapStateToProps, mapDispatchToProps)(MineHome);
複製程式碼

在該頁面上讀取同一個資料this.props.home.initCount,然後在第一個頁面home上加減資料,再看mine頁面,會發現initCount也同步變化 也就是說明:我們已經在進行狀態管理了


到這裡是不是很開心,redux雖然有點繞,但如果跟著做下來應該也有了一定的輪廓了


五、狀態跟蹤:Redux-logger

這個時候,我們會發現,雖然狀態共享了,但目前還沒有辦法跟蹤狀態,以及每一步操作帶來的狀態變化。

 但總不能每次都手動列印狀態到控制檯裡面吧?

redux-logger該上場了

它大概長下面這樣,把每次派發action的前後狀態都自動輸出到控制檯上

React-Native從零搭建App(長文)

具體使用看下官方文件,很簡單,直接上程式碼吧

redux-logger

安裝

yarn add redux-logger
複製程式碼

它作為一箇中介軟體,中介軟體的用法請回 redux中文文件 查閱

store/index.js檔案修改如下

import { createStore, applyMiddleware } from 'redux';

import combineReducers from '../reducer/index';
import logger from 'redux-logger';

const store = createStore(combineReducers, applyMiddleware(logger));

export default store;
複製程式碼

command+R重新整理一下模擬器,再點選一下+2,看看控制檯是不是長下面這樣?

React-Native從零搭建App(長文)

接下來每次派發action,控制檯都會自動列印出來,是不是省心省事?


六、非同步管理:Redux-Thunk

基礎理解

redux-thunk是什麼請移步 redux-thunk github

出發點:需要元件對同步或非同步的 action 無感,呼叫非同步 action 時不需要顯式地傳入 dispatch

通過使用指定的 middleware,action 建立函式除了返回 action 物件外還可以返回函式。這時,這個 action 建立函式就成為了 thunk 
當 action 建立函式返回函式時,這個函式會被 Redux Thunk middleware 執行。這個函式並不需要保持純淨;它還可以帶有副作用,包括執行非同步 API 請求。這個函式還可以 dispatch action,就像 dispatch 前面定義的同步 action 一樣 > thunk 的一個優點是它的結果可以再次被 dispatch


安裝

yarn add redux-thunk
複製程式碼


注入store

作為一箇中介軟體,它的使用方式和上面logger一樣,stroe/index.js直接引入即可

import thunk from 'redux-thunk';
middleware.push(thunk);
複製程式碼


使用方式

action/home.js檔案修改如下

import post from '../utils/fetch';

export function getSomeData() {
  return dispatch => {
    post('/get/data',{}, res => {
      const someData = res.data.someData;
      dispatch({
        type: actionTypes.HOME_GET_SOMEDATA,
        someData
      })
    })
  }
}複製程式碼


題外話:封裝請求函式post

此處稍微插入一句,關於封裝請求函式post(以下是精簡版,只保留了核心思想)

cd src
mkdir utils
touch utils/fetch.js
複製程式碼

公用的方法和函式都封裝在utils資料夾中

utils/fetch.js檔案如下 

export default function post(url, data, sucCB, errCB) {
  // 域名、body、header等根據各自專案配置,還有部分安全,加密方面的設定,
  const host = 'www.host.com';
  const requestUrl = `${host}/${url}`;
  const body = {};
  const headers = {
    'Content-Type': 'application/json',
    'User-Agent': ''
  };
    // 用的是fetch函式
  fetch(requestUrl, {
    method: 'POST',
    headers: headers,
    body: body
  }).then(res => {
    if (res && res.status === 200) {
      return res.json();
    } else {
      throw new Error('server');
    }
  }).then(res => {
    // 精簡版判斷
    if(res && res.code === 200 && res.enmsg === 'ok') {
      // 成功後的回撥
      sucCB(res);
    }else {
      // 失敗後的回撥
      errCB(res);
    }
  }).catch(err => {
    // 處理錯誤
  })
}
複製程式碼



七、非同步管理:Redux-Saga

基本概念請移步

自述 | Redux-saga 中文文件

出發點:需要宣告式地來表述複雜非同步資料流(如長流程表單,請求失敗後重試等),命令式的 thunk 對於複雜非同步資料流的表現力有限


安裝

yarn add redux-saga複製程式碼

建立saga檔案

建立順序有點像reducer 我們先建立saga相關資料夾和檔案,最後再來注入store裡面

cd src
mkdir saga
touch saga/index.js saga/home.js saga/popularize.js saga/mine.js複製程式碼


先修改saga/home.js檔案

import { put, call, takeLatest } from 'redux-saga/effects';

import * as actionTypes from '../action/index';
import * as homeActions from '../action/home';
import * as mineActions from '../action/mine';

import post from '../utils/fetch';

function getSomeThing() {
  post('/someData', {}, res => {}, err => {});
}

// 這個函式中的請求方法都是隨手寫的,沒引入真實API,
function* getUserInfo({ sucCb, errCB }) {
  try {
    const res = yield call(getSomeThing());
    const data = res.data;
    yield put(homeActions.getSomeData())
    yield put(homeActions.setSomeData(data))
    yield call(sucCb);
  } catch (err) {
    yield call(errCB, err);
  }
}

export const homeSagas = [
  takeLatest(actionTypes.HOME_GET_SOMEDATA, getUserInfo)
]
複製程式碼

saga/mine.js檔案

export const mineSagas = []
複製程式碼

saga/popularize.js檔案

export const popularizeSagas = []
複製程式碼


saga/index.js檔案作為總輸出口,修改如下

import { all } from 'redux-saga/effects';

import { homeSagas } from './home';
import { mineSagas } from './mine';
import { popularizeSagas } from './popularize';

export default function* rootSaga() {
  yield all([...homeSagas, ...mineSagas, ...popularizeSagas]);
}
複製程式碼


把saga注入store

store/index.js檔案修改

import createSagaMiddleware from 'redux-saga';
import rootSaga from '../saga/index';
// 生成saga中介軟體
const sagaMiddleware = createSagaMiddleware(rootSaga);

middleware.push(sagaMiddleware);
複製程式碼


八:資料持久化:Redux-persist

GitHub - rt2zz/redux-persist: persist and rehydrate a redux store

顧名思義,資料持久化,一般用來儲存登入資訊等需要儲存在本地的資料

因為store中的資料,在每次重新開啟app後,都會回覆到reducer中的initState的初始狀態,所以像登入資訊這種資料就需要持久化的儲存了。 

RN自帶的AsyncStorage可以實現這個功能,但使用起來比較繁瑣,而且沒有注入到store中去,沒辦法實現統一狀態管理,所以redux-persist就出場了

安裝

yarn add redux-persist複製程式碼

注入store

store/index.js檔案完整程式碼如下

import { createStore, applyMiddleware } from 'redux';
import logger from 'redux-logger';
import thunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';
import { persistStore, persistCombineReducers } from 'redux-persist';
import storage from 'redux-persist/es/storage';

import combineReducers from '../reducer/index';
import rootSaga from '../saga/index';

const persistConfig = {
  key: 'root',
  storage,
  // 白名單:只有mine的資料會被persist
  whitelist: ['mine']
};
// 對reducer資料進行persist配置
const persistReducer = persistCombineReducers(persistConfig, combineReducers);

const sagaMiddleware = createSagaMiddleware();

// 中介軟體
const createStoreWithMiddleware = applyMiddleware(
  thunk,
  sagaMiddleware,
  logger
)(createStore);

const configuerStore = onComplete => {
  let store = createStoreWithMiddleware(persistReducer);
  let persistor = persistStore(store, null, onComplete);
  sagaMiddleware.run(rootSaga);
  return { persistor, store };
};

export default configuerStore;
複製程式碼


這個地方,不再把middleware當做陣列,而是直接寫入applyMiddleware方法中

store也不再直接匯出,而是到處一個生成store的函式configuerStore 相應,

App.js檔案的引入也要修改一點點

import configuerStore from './src/store/index';
const { store } = configuerStore(() => { });複製程式碼


九:靜態測試:Flow

待更新,這有一篇連結可以先看React Native填坑之旅--Flow篇(番外)



十、後話


到目前為止,我們已經引入了redux-logger、redux-thunk、redux-saga、redux-persist

核心開發程式碼庫已經配置完畢了

專案已經傳上github,歡迎star

該專案github倉庫傳送門


接下來還有一些可以作為開發時的輔助性配置,比如Flow 、Babel(RN初始化時已經配好了)、Eslint等等

另外,既然是App,那最終目的當然就是要上架App Store和各大安卓市場,後面可能還會分享一下關於極光推送jPush、熱更新CodePush、打包上傳稽核等方面的內容。


感謝您耐心看到這裡,希望有所收穫! 

如果不是很忙的話,麻煩點個star⭐【Github部落格傳送門】,舉手之勞,卻是對作者莫大的鼓勵。 

我在學習過程中喜歡做記錄,分享的是自己在前端之路上的一些積累和思考,希望能跟大家一起交流與進步,更多文章請看【amandakelake的Github部落格】





相關文章