RN-JestInReactNative

Initial project

我又重新來接觸一次 React Native

但是修改的部分太多了

重新做了一次部署架構

發現又多了一些問題

紀錄一下過程

一開始一定是先 initial project helloworld

1
2
3
4
5
6
7
8
9
10
11
12
$ react-native init helloworld
$ yarn test
> helloword@0.0.1 test /Users/linweiqin/Projects/helloword
> jest

No tests found
In /Users/linweiqin/Projects/helloword
645 files checked.
testMatch: **/__tests__/**/*.js?(x),**/?(*.)+(spec|test).js?(x) - 1 match
testPathIgnorePatterns: /node_modules/ - 7 matches
Pattern: - 0 matches
npm ERR! Test failed. See above for more details.

因為我是使用 redux-saga

所以建一個測試

tests/saga.test.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import { testSaga } from 'redux-saga-test-plan';
import { take, put, call} from "redux-saga/effects";

function identity(value) {
return value;
}

function* mainSaga(x, y) {
const action = yield take('HELLO');

yield put({ type: 'ADD', payload: x + y });
yield call(identity, action);
}

const action = { type: 'TEST' };

it('works with unit tests', () => {
testSaga(mainSaga, 40, 2)
// advance saga with `next()`
.next()

// assert that the saga yields `take` with `'HELLO'` as type
.take('HELLO')

// pass back in a value to a saga after it yields
.next(action)

// assert that the saga yields `put` with the expected action
.put({ type: 'ADD', payload: 42 })

.next()

// assert that the saga yields a `call` to `identity` with
// the `action` argument
.call(identity, action)

.next()

// assert that the saga is finished
.isDone();
});
1
2
3
$ yarn add redux-saga
$ yarn add -D redux-saga-test-plan
$ yarn test

error1

package.json 要加上一個設定

1
2
3
4
5
6
7
8
9
{
...,
"jest": {
"preset": "react-native",
"transform": {
"^.+\\.js$": "<rootDir>/node_modules/react-native/jest/preprocessor.js"
}
}
}

基本上這樣可以測試一般的

但是希望可以測試 Component

App.test.js

1
2
3
4
5
6
7
8
9
10
// __tests__/App.test.js
import React from 'react';
import App from '../App';

import renderer from 'react-test-renderer';

test('renders correctly', () => {
const tree = renderer.create(<App />).toJSON();
expect(tree).toMatchSnapshot();
});

React 與 React Native

如果使用同一個架構的話 ReactReact Native 是大同小異的

但是基於兩個的底層是完全不同的

一個是 Web HTML 一個是 Native code

希望使用盡量一致的 Lib 來做測試似乎困難度有點高

雖然尚未有完全無違和的測試

還是可以利用 Jest + Enzyme + Jsdom 來為 React Native 模擬 mount 環境

續前章 初步使用 Jest + Enzyme 做 React Native 測試

如果要在 測試中使用 mount 的話

會顯示 document is undefined 的錯誤

所以為了彌補這個問題

我們需要做一些補充

shallow and mount 的不同

shallow

shallow 針對 Component 做單一的單元測試,並不會直接顯示他的 Children Component

mount

mount 會完整的 render 所有的 Component 包含他下層的所有 Component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import React from 'react';
import App from '../App';
import { mount, shallow } from 'enzyme';

import renderer from 'react-test-renderer';

test('renders correctly', () => {
const tree = renderer.create(<App />).toJSON();
expect(tree).toMatchSnapshot();
});

test('mount component', () => {
const wrapper = shallow(<App />);

});

mount error

會產生這個錯誤

因為找不到 global document

1
$ yarn add enzyme jest-enzyme enzyme-adapter-react-16 enzyme-react-16-adapter-setup --dev

需要再 package.json 中增加

1
2
3
4
5
6
7
8
9
10
11
12
13
{
...,
"jest": {
"preset": "react-native",
"setupTestFrameworkScriptFile": "./node_modules/jest-enzyme/lib/index.js",
"setupFiles": [
"enzyme-react-16-adapter-setup"
],
"transform": {
"^.+\\.js$": "<rootDir>/node_modules/react-native/jest/preprocessor.js"
}
}
}

dom error

缺少了 react-dom

1
$ yarn add react-dom --dev

這時候就可以執行了

但是因為 shallow 只能 render 一層

如果要完整 render 的話要使用 mount

但是這樣會造成因為找不到 global document

要先增加 setupFile.js

1
$ yarn add jsdom enzyme-adapter-react-16 react-native-mock-render --dev
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const { JSDOM } = require('jsdom');

const jsdom = new JSDOM();
const { window } = jsdom;

function copyProps(src, target) {
const props = Object.getOwnPropertyNames(src)
.filter(prop => typeof target[prop] === 'undefined')
.map(prop => Object.getOwnPropertyDescriptor(src, prop));
Object.defineProperties(target, props);
}

global.window = window;
global.document = window.document;
global.navigator = {
userAgent: 'node.js',
};
copyProps(window, global);

import Enzyme from "enzyme";
import Adapter from "enzyme-adapter-react-16";

Enzyme.configure({ adapter: new Adapter() });

// Ignore React Web errors when using React Native
console.error = (message) => {
return message;
};

require('react-native-mock-render/mock');
1
$ yarn remove enzyme-react-16-adapter-setup

package.json 也要做一些調整

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
...,
"jest": {
"preset": "react-native",
"cacheDirectory": "./cache",
"coveragePathIgnorePatterns": [
"./app/utils/vendor"
],
"coverageThreshold": {
"global": {
"statements": 80
}
},
"transformIgnorePatterns": [
"/node_modules/(?!react-native|react-clone-referenced-element|react-navigation)"
],
"transform": {
"^.+\\.js$": "<rootDir>/node_modules/react-native/jest/preprocessor.js"
},
"setupTestFrameworkScriptFile": "./setupFile.js"
}
}

test success

RN-code-push

Initial React Native App

1
$ npx react-native init codepushdemo && cd codepushdemo

Install code push

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Install the CLI
$ npm install -g code-push-cli
$ npm install -g appcenter-cli

# Register for an account via github or email (if you have code-push account you could use **code-push login**)
$ code-push register

# Register your app. We call it AwesomeApp.
$ code-push app add codepushdemo-ios ios react-native
$ code-push app add codepushdemo-android android react-native

# prepare app center
$ yarn add appcenter appcenter-analytics appcenter-crashes --save-exact

$ cd ios && pod install && cd ..
1
2
3
4
5
**Note:**

link 完之 IOS 會自動使用 Pod

所以在 local 端 Xcode 要開啟 `XXXXX.xcworkspace` 才能正常開發

IOS

releasing-updates

Git

code-push 官方只支援 github bitbucketmicrosoft devops 三種版控

這次使用 bitbucket 做 Demo

要先在 bitbucket 建立一個新的 repo

參考資料來源

Get started with ‘CodePush’ (React-Native)

IOS Setup

Android Setup

material-ui-themes

source

material-ui themes

Content

Palette

針對 Application 設定幾個基礎色

theme 中提供幾個 key 來設定基礎色

  • primary - 主要的顏色
  • secondary - 次要的顏色
  • error - 顯示錯誤的時候的顏色

Custom palette

在 theme 有 [palette.primary](https://material-ui.com/customization/default-theme/?expend-path=$.palette.primary), [palette.secondary](https://material-ui.com/customization/default-theme/?expend-path=$.palette.secondary), [palette.error](https://material-ui.com/customization/default-theme/?expend-path=$.palette.error) 三個物件可以複寫

在上述三個物件中各有 light, main, dark, contrastText 四個參數可以提供修改

可以直接放入顏色的物件作設定

1
2
3
4
5
6
7
8
import { createMuiTheme } from '@material-ui/core/styles';
import blue from '@material-ui/core/colors/blue';

const theme = createMuiTheme({
palette: {
primary: blue,
},
});

也可以依據各個不同的狀況做設定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
palette: {
primary: {
light: palette.primary[300],
main: palette.primary[500],
dark: palette.primary[700],
contrastText: getContrastText(palette.primary[500]),
},
secondary: {
light: palette.secondary.A200,
main: palette.secondary.A400,
dark: palette.secondary.A700,
contrastText: getContrastText(palette.secondary.A400),
},
error: {
light: palette.error[300],
main: palette.error[500],
dark: palette.error[700],
contrastText: getContrastText(palette.error[500]),
},
},

這個範例說明如何重新建立一個預設 palette

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import { createMuiTheme } from '@material-ui/core/styles';
import indigo from '@material-ui/core/colors/indigo';
import pink from '@material-ui/core/colors/pink';
import red from '@material-ui/core/colors/red';

// All the following keys are optional.
// We try our best to provide a great default value.
const theme = createMuiTheme({
palette: {
primary: indigo,
secondary: pink,
error: red,
// 提供 getContrastText() 使用的值,最大化背景之間的對比度
contrastThreshold: 3,
// E.g., shift from Red 500 to Red 300 or Red 700.
tonalOffset: 0.2,
},
});

也可以客製化自己的顏色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { createMuiTheme } from '@material-ui/core/styles';

const theme = createMuiTheme({
palette: {
primary: {
// light: will be calculated from palette.primary.main,
main: '#ff4400',
// dark: will be calculated from palette.primary.main,
// contrastText: will be calculated to contrast with palette.primary.main
},
secondary: {
light: '#0066ff',
main: '#0044ff',
// dark: will be calculated from palette.secondary.main,
contrastText: '#ffcc00',
},
// error: will use the default color
},
});

也有提供 color tools

Type(light/dark theme)

你可以透過修改 type 改為 dark 來將佈景變暗

雖然只有改變一個參數

但是下述的 key 都會受到影響

  • palette.text
  • palette.divider
  • palette.background
  • palette.action

Typography

一次太多字型 類型大小和 Style 會破壞 Layout

提供一組有限的 Style 來限制

Typography 可以使用在任何的 component 中

Typography section

Font family

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const theme = createMuiTheme({
typography: {
// Use the system font instead of the default Roboto font.
fontFamily: [
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
].join(','),
},
});

Material-UI 的字型大小預設使用 rem 但是他會依據 <html> 的預設字型大小改變

一般來說預設是 16px browser 沒有提供修改的方式

所以 units 提供一個方式來修改設定提升使用者體驗

修改 font-size 為 12 預設是 14

HTML font size
1
2
3
4
5
6
7
const theme = createMuiTheme({
typography: {
// In Japanese the characters are usually larger.
fontSize: 12,
htmlFontSize: 10,
},
});

瀏覽器計算字型大小是透過這個公式

calc font size

Custom variables

也可以透過 styling solution 來客製化你的 component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import React from 'react';
import Checkbox from '@material-ui/core/Checkbox';
import { createMuiTheme, MuiThemeProvider, withStyles } from '@material-ui/core/styles';
import orange from '@material-ui/core/colors/orange';

const styles = theme => ({
root: {
color: theme.status.danger,
'&$checked': {
color: theme.status.danger,
},
},
checked: {},
});

let CustomCheckbox = props =>
<Checkbox
defaultChecked
classes={{
root: props.classes.root,
checked: props.classes.checked,
}}
/>

CustomCheckbox = withStyles(styles)(CustomCheckbox);

const theme = createMuiTheme({
status: {
danger: orange[500],
},
});

function StylingComponent() {
return (
<MuiThemeProvider theme={theme}>
<CustomCheckbox />
</MuiThemeProvider>
);
}

export default StylingComponent;

Customizing all instances of a component type

有時候要針對單一 Component 做設定的時候可以用 overrides 這個 keyword 來做複寫

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import React, { Component } from 'react';
import logo from './logo.svg';
import './App.css';
import {MuiThemeProvider, createMuiTheme} from '@material-ui/core/styles';
import {purple, green, blue} from '@material-ui/core/colors';
import Example from './Example';
import { Button, Typography } from '@material-ui/core';
import ListItems from './ListItems';
import StylingComponent from './StylingComponent';

const theme = createMuiTheme({
overrides: {
// Name of the component ⚛️ / style sheet
MuiButton: {
// Name of the rule
root: {
// Some CSS
background: 'linear-gradient(45deg, #FE6B8B 30%, #FF8E53 90%)',
borderRadius: 3,
border: 0,
color: 'white',
height: 48,
padding: '0 30px',
boxShadow: '0 3px 5px 2px rgba(255, 105, 135, .3)',
},
},
},
});

class App extends Component {
render() {
return (
<MuiThemeProvider theme={theme}>
<Button>hello</Button>
</MuiThemeProvider>
);
}
}

export default App;

Properties

也可以傳入 properties 使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import React, { Component } from 'react';
import {MuiThemeProvider, createMuiTheme} from '@material-ui/core/styles';
import {purple, green, blue} from '@material-ui/core/colors';
import { Button } from '@material-ui/core';

const theme = createMuiTheme({
props: {
// Name of the component ⚛️
MuiButton: {
// The properties to apply
disableRipple: true, // No more ripple, on the whole application 💣!
},
}
});

class App extends Component {
render() {
return (
<MuiThemeProvider theme={theme}>
<Button>hello</Button>
</MuiThemeProvider>
);
}
}

export default App;

可以看到結果 ripple 會被 disabled

Accessing the theme in a component

有時候會希望在某些 Component 中使用 theme 的變數

這時候可以使用 withTheme

範例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import React from 'react';
import PropTypes from 'prop-types';
import Typography from '@material-ui/core/Typography';
import { withTheme } from '@material-ui/core/styles';

function WithTheme(props) {
const { theme } = props;
const primaryText = theme.palette.text.primary;
const primaryColor = theme.palette.primary.main;

const styles = {
primaryText: {
backgroundColor: theme.palette.background.default,
padding: `${theme.spacing.unit}px ${theme.spacing.unit * 2}px`,
color: primaryText,
},
primaryColor: {
backgroundColor: primaryColor,
padding: `${theme.spacing.unit}px ${theme.spacing.unit * 2}px`,
color: theme.palette.common.white,
},
};

return (
<div style={{ width: 300 }}>
<Typography style={styles.primaryColor}>{`Primary color ${primaryColor}`}</Typography>
<Typography style={styles.primaryText}>{`Primary text ${primaryText}`}</Typography>
</div>
);
}

WithTheme.propTypes = {
theme: PropTypes.object.isRequired,
};

export default withTheme()(WithTheme); // Let's get the theme as a property

ReasonReact-LifeCycle

LifeCycle

ReasonReact 也有類似 ReactJS 的生命週期

  • didMount: self => unit

  • willReceiveProps: self => state

  • shouldUpdate: oldAndNewSelf => bool

  • willUpdate: oldAndNewSelf => unit

  • didUpdate: oldAndNewSelf => unit

  • willUnmount: self => unit

Note

  • 移除了所有 component 前綴
  • willReceiveProps 需要回傳的是 state 預設是假設你每次都要修改狀態,不然也可以直接回傳 state
  • didUpdate, willUnmountwillUpdate 不可以修改 state
  • 不支援 willMount 請用 didMount
  • didUpdate, willUpdateshouldUpdate 的 input 是 oldAndNewSelf record, 類型是 {oldSelf: self, newSelf: self}

如果你真的在 lifecycle 中修改 state, 請發一個 Action self.send(DidMountUpdate)

retainedProps

ReactJS 中有時會使用到 prevProps(componentDidUpdate) 或是 nextProps(componentWillUpdate) 這類的 API

但是 ReasonReact 中沒有這個部分, 則是要使用 retainedProps 來實現

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
type item = {
title: string,
completed: bool
};

type retainedProps = {message: string};

module TodoItem = {
let component = ReasonReact.statelessComponentWithRetainedProps("TodoItem");
let make = (~item, ~message, _children) => {
...component,
retainedProps: {message: message},
didUpdate: ({oldSelf, newSelf}) => {
if (oldSelf.retainedProps.message !== newSelf.retainedProps.message) {
Js.log("props `message` changed!")
}
},
render: (_self) =>
<div className="item">
<input
checked=(item.completed)
/* TODO make interactive */
/>
(ReasonReact.string(item.title))
</div>
};
};

type state = {items: list(item)};

type action =
| AddItem;

let component = ReasonReact.reducerComponent("TodoApp");

let newItem = () => {title: "Click a button", completed: true};

let make = (_children) => {
...component,
initialState: () => {
items: [
{title: "Write some things to do", completed: false}
]
},
reducer: (action, {items}) =>
switch action {
| AddItem => ReasonReact.Update({items: [newItem(), ...items]})
},
render: (self) => {
let numItems = List.length(self.state.items);
<div className="app">
<div className="title">
(ReasonReact.string("What to do"))
<button onClick=(_event => self.send(AddItem))>
(ReasonReact.string("Add something"))
</button>
</div>
<div className="items">
(
self.state.items
|> List.map((item) => <TodoItem item />)
|> Array.of_list
|> ReasonReact.array
)
</div>
<div className="items"> (ReasonReact.string("Nothing")) </div>
<div className="footer">
(ReasonReact.string(string_of_int(numItems) ++ " items"))
</div>
</div>
}
};

ReasonReact 提供了 ReasonReact.statelessComponentWithRetainedPropsReasonReact.reducerComponentWithRetainedProps

這兩個方法只是讓你的 make 函數中可以多一個 retainedProps

willReceiveProps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
type item = {
title: string,
completed: bool
};

type retainedProps = {message: string};

module TodoItem = {
let component = ReasonReact.statelessComponentWithRetainedProps("TodoItem");
let make = (~item, ~message, _children) => {
...component,
retainedProps: {message: message},
willReceiveProps: (self) => {
if (self.retainedProps.message === message) {
Js.log("willReceiveProps");
};
},
didUpdate: ({oldSelf, newSelf}) => {
if (oldSelf.retainedProps.message !== newSelf.retainedProps.message) {
Js.log("props `message` changed!")
}
},
render: (_self) =>
<div className="item">
<input
checked=(item.completed)
/* TODO make interactive */
/>
(ReasonReact.string(item.title))
</div>
};
};

type state = {items: list(item)};

type action =
| AddItem;

let component = ReasonReact.reducerComponent("TodoApp");

let newItem = () => {title: "Click a button", completed: true};

let make = (_children) => {
...component,
initialState: () => {
items: [
{title: "Write some things to do", completed: false}
]
},
reducer: (action, {items}) =>
switch action {
| AddItem => ReasonReact.Update({items: [newItem(), ...items]})
},
render: (self) => {
let numItems = List.length(self.state.items);
<div className="app">
<div className="title">
(ReasonReact.string("What to do"))
<button onClick=(_event => self.send(AddItem))>
(ReasonReact.string("Add something"))
</button>
</div>
<div className="items">
(
self.state.items
|> List.map((item) => <TodoItem item />)
|> Array.of_list
|> ReasonReact.array
)
</div>
<div className="items"> (ReasonReact.string("Nothing")) </div>
<div className="footer">
(ReasonReact.string(string_of_int(numItems) ++ " items"))
</div>
</div>
}
};

ReactJS 的 componentWillUpdate 中的參數 nextPropsmake 的參數

而現在的 props (this.props) 是上面的 retainedProps,可以透過 {oldSelf} 得到

didUpdate

ReactJS 的 prevProps 可以透過 retainedProps 拿到,需要使用 oldSelf

shouldUpdate

和 ReactJS 的 shouldComponentUpdate 對應

ReasonReact-auto-deploy

Work With Drone

drone.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
pipeline:
scp:
image: appleboy/drone-scp
pull: true
host:
- your server ip
port: 22
username: root
user: root
secrets: [ ssh_password ]
target: /root
source:
- $DRONE_DIR
when:
branch: master

ssh:
image: appleboy/drone-ssh
host:
- your server ip
username: root
user: root
secrets: [ ssh_password ]
command_timeout: 600
script:
- . /root/.nvm/nvm.sh && nvm use 10.9.0
- mkdir -p Your target path
- cd /root/$DRONE_DIR
- yarn install
- npm run webpack:production
- cp -a build/* Your target path
- cd /root
- rm -rf /root/drone
when:
branch: master

這個範例代表當你的 master 有被push 的時候觸發 pipe 流程

會依序執行

問題

我在 linode 的 ubuntu 16 的 server

安裝 bs-platform 的時候遇到 node: permission denied 的問題

解法

code

github

Nginx

1
2
3
4
5
6
7
8
9
10
server {
server_name YourDomain;

root /var/www/html/YourDomain;
index index.html index.htm index.php;

location / {
try_files $uri $uri/ =404;
}
}

然後要註冊一個 domain 指向你的 IP

Demo

demo

這樣只要有 push

就可以自動更新上去囉

ReasonReact-publish-package

Initial Project

1
$ bsb -init helloworld -theme basic-reason

移除 src/Demo.re

新增一個 src/index.re

src/index.re

1
let add = (a: int, b: int): int => a + b;

package.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"name": "tomas-math",
"version": "1.0.1",
"scripts": {
"build": "bsb -make-world",
"start": "bsb -make-world -w",
"clean": "bsb -clean-world"
},
"keywords": [
"BuckleScript"
],
"files": [
"src/index.bs.js"
],
"main": "src/index.bs.js",
"author": "",
"license": "MIT",
"devDependencies": {
"bs-platform": "^4.0.7"
}
}
1
2
$ npm login
$ npm publish

Install yourname-math

建立一個新的 project

1
2
$ npm init
$ npm install yourname-math

會看到 node_modules

1
2
3
4
5
6
7
8
9
├── index.js
├── node_modules
│ └── tomas-math
│ ├── README.md
│ ├── package.json
│ └── src
│ └── index.bs.js
├── package-lock.json
└── package.json

因為我們指定了 公開 index.bs.js

也是我們主要引入的檔案

index.js

1
2
const {add} = require('tomas-math');
console.log(add(1, 2)); //3

很簡單吧

其實跟原本的 Nodejs 的 publish 大同小異

卻可以使用到 Reason 的形態檢查

明天再來討論如果有第三方套件該如何使用

以 lodash 為範例

ReasonReact-Todo-Tutorial-PartII

Action

昨天進度到了完成了一個 Reducer

但是我們知道要修改 Reducer 只能夠透過 Action

而在 ReasonReact 如何描述一個 Action

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
type action = 
| AddItem;

type item = {
title: string,
completed: bool
};

type state = {
items: list(item)
}

let newItem = () => {title: "Click a button", completed: true};

let component = ReasonReact.reducerComponent("TodoApp");

let make = (_children) => {
...component,
reducer: ((), _) => ReasonReact.NoUpdate,
render: ({state: {items}}) => {
let numItems = List.length(items);
<div className="app">
<div className="title">
(str("What to do"))
<button
onClick=((evt) => AddItem)
>
(ReasonReact.string("Add something"))
</button>
</div>
<div className="items"> (ReasonReact.string("Nothing")) </div>
<div className="footer">
(ReasonReact.string(string_of_int(numItems) ++ " items"))
</div>
</div>
}
}

先幫昨天的範例加上一個 button 有一個 Event onClick

點擊後會 Js.log(“didn’t add something”)

如果在 React 中這邊應該是要呼叫 this.setState

但是在 ReasonReact 中這需要兩個步驟

  1. 建立一個 Action Type
  2. 然後建立一個 Reducer 並且和 Action Type mapping

Reducer And Action

Demo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
type item = {
title: string,
completed: bool
};

type state = {items: list(item)};

type action =
| AddItem;

let component = ReasonReact.reducerComponent("TodoApp");

let newItem = () => {title: "Click a button", completed: true};

let make = (_children) => {
...component,
initialState: () => {
items: [
{title: "Write some things to do", completed: false}
]
},
reducer: (action, {items}) =>
switch action {
| AddItem => ReasonReact.Update({items: [newItem(), ...items]})
},
render: (self) => {
let numItems = List.length(self.state.items);
<div className="app">
<div className="title">
(ReasonReact.string("What to do"))
<button onClick=(_event => self.send(AddItem))>
(ReasonReact.string("Add something"))
</button>
</div>
<div className="items"> (ReasonReact.string("Nothing")) </div>
<div className="footer">
(ReasonReact.string(string_of_int(numItems) ++ " items"))
</div>
</div>
}
};

initialState

React 稱為 getInitialState 在 Reason 中稱為 initialState

不需傳入參數,會回傳一個 state 類型

state 可以是任何類型 string, int, record …etc

Actions and Reducer

在 React 中你會透過 callback handler 來修改 state

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
/* ... other fields */
handleClick: function() {
this.setState({count: this.state.count + 1});
},
handleSubmit: function() {
this.setState(...);
},
render: function() {
return (
<MyForm
onClick={this.handleClick}
onSubmit={this.handleSubmit} />
);
}
}

在 ReasonReact 你會將所有的 Function 整理在同一個地方

如果你看到 self.reduce 這是舊的 API

新的 API 是 self.send

  • action 是使用者定義的類型,他是一個 variant 類型,描述了所有可能的 state 類型
  • Component 的 state 可以透過 self.state 拿到
  • 只有一個 reducer 是 pattern-matches 會針對有可能的 action 去修改 reducer 得值
  • 在 reducer 中 self.handler 不允許狀態的改變,你必須使用 self.send 發送一個 action

例如我們點擊了一個按鈕

會發送一個 Click 的 action

他會依據 Click 的這事件回傳一個新的 state

用 Reducer 更新狀態

ReasonReact.Update 回傳一個新的 state

另外也有一些其他選擇

  • ReasonReact.NoUpdate - 不要更新 state
  • ReasonReact.Update - 更新 state
  • ReasonREact.SideEffects(self => unit) 不需要更新 state 但是需要但是需要觸發行為 e.g.ReasonReact.SideEffects(_self => Js.log("hello!"))
  • ReasonReact.UpdateWithSideEffects(state, self => unit): 更新狀態並且觸發行為

重要提示

  • action 可以帶入參數 payload: onClick=(data => self.send(Click(data.foo)))
  • 不要把事件本身傳遞給 action
  • reducer 必須是 pure function, 用 SideEffects 或者 UpdateWithSideEffects 增加一個 side-effect, 這個 side-effect 會在 state 處理完之後, 下一次 render 前進行
  • ReactEvent.BlablaEvent.preventDefault(event) 請在 self.send 中處理它
  • 可以自由的觸發另一個 action 在 sideeffect, e.g UpdateWithSideEffects(newState, self => self.send(Click))
  • 如果你僅僅只有 state, 你的 Component 僅有 self.handler而沒有用到 self.send 但是依舊要指定 reducer reducer: ((), _state) => ReasonReact.NoUpdate

Tip

盡量縮小你的 reducer 你可以更容易使用 ReasonReact.SideEffectsReasonReact.UpdateWithSideEffects 來更新 reducer

Async State updated

在 ReactJs 中你可能會這樣使用 setState

1
setInterval(() => this.setState(...), 1000);

但是在 ReasonReact 中會是

1
Js.Global.setInterval(() => self.send(Tick), 1000)

ReasonReact-Todo-Tutorial-PartIII

Rendering items

希望有一個區塊 Component 來顯示輸入的 items

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
type item = {
title: string,
completed: bool
};

module TodoItem = {
let component = ReasonReact.statelessComponent("TodoItem");
let make = (~item, _children) => {
...component,
render: (_self) =>
<div className="item">
<input
_type="checkbox"
checked=(item.completed)
/* TODO make interactive */
/>
(ReasonReact.string(item.title))
</div>
};
};

type state = {items: list(item)};

type action =
| AddItem;

let component = ReasonReact.reducerComponent("TodoApp");

let newItem = () => {title: "Click a button", completed: true};

let make = (_children) => {
...component,
initialState: () => {
items: [
{title: "Write some things to do", completed: false}
]
},
reducer: (action, {items}) =>
switch action {
| AddItem => ReasonReact.Update({items: [newItem(), ...items]})
},
render: (self) => {
let numItems = List.length(self.state.items);
<div className="app">
<div className="title">
(ReasonReact.string("What to do"))
<button onClick=(_event => self.send(AddItem))>
(ReasonReact.string("Add something"))
</button>
</div>
<div className="items">
(
ReasonReact.array(Array.of_list(
List.map((item) => <TodoItem item />, self.state.items)
))
)
</div>
<div className="items"> (ReasonReact.string("Nothing")) </div>
<div className="footer">
(ReasonReact.string(string_of_int(numItems) ++ " items"))
</div>
</div>
}
};

我們又增加了一些東西

和一般的 JSX 有一點不一樣 <TodoItem item />

他原本也是 <TodoItem item=item /> 的簡寫

JSX 中則會被解釋為 <TodoItem item={true} />

1
2
3
ReasonReact.array(Array.of_list(
List.map((item) => <TodoItem item />, self.state.items)
))

可以看到這個寫法

但是其實也可以改成

1
self.state.items |> List.map((item) => <TodoItem item />) |> Array.of_list |> ReasonReact.array

ReasonReact-Todo-Tutorial-PartI

調整 initial 的 project

之前我們有用 bsb initial 了一個 project

但是要做一些小小的調整

先新增一個 .re

TodoApp.re

1
2
3
4
5
6
7
8
9
10
11
12
13
14
let component = ReasonReact.statelessComponent("TodoApp");

let make = (children) => {
...component,
render: (self) =>
<div className="app">
<div className="title">
(ReasonReact.string("What to do"))
</div>
<div className="items">
(ReasonReact.string("Nothing"))
</div>
</div>
}

index.re

1
ReactDOMRe.renderToElementWithId(<TodoApp />, "root");

index.html

1
2
3
4
5
6
7
8
9
10
11
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ReasonReact Examples</title>
</head>
<body>
<div id="root"></div>
<script src="Index.js"></script>
</body>
</html>

可以看到基本的顯示

demo

STEP1 增加一些 State

宣告一些類型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
type item = {
title: string,
completed: bool
};

type state = {
items: array(item)
};

let component = ReasonReact.statelessComponent("TodoApp");

let make = (children) => {
...component,
render: (_self) =>
<div className="app">
<div className="title">
(ReasonReact.string("What to do"))
</div>
<div className="items">
(ReasonReact.string("Nothing"))
</div>
</div>
}

State

ReaconReact 的有狀態 Component 和 React 的有狀態 Component 是類似的

只是多了 reducer 的概念 (類似 Redux)

只要把它當成狀態管理系統就好

將宣告 statefulComponent 取代 statelessComponent

使用 ReasonReact.reducerComponent("MyComponentName") 這個 API

Stateful 範例

index.re

1
ReactDOMRe.renderToElementWithId(<StatefulComponent greeting="greeting" />, "root");

StatefulComponent.re

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
type state = {
count: int,
show: bool
};

type action =
| Click
| Toggle;

let component = ReasonReact.reducerComponent("Example");

let make = (~greeting, _children) => {
...component,
initialState: () => {count: 0, show: true},
reducer: (action, state) =>
switch (action) {
| Click => ReasonReact.Update({...state, count: state.count + 1})
| Toggle => ReasonReact.Update({...state, show: !state.show})
},
render: (self) => {
let message = "You've clicked this " ++ string_of_int(self.state.count) ++ " times(s)";
<div>
<button onClick=(_event => self.send(Click))>
(ReasonReact.string(message))
</button>
<button onClick=(_event => self.send(Toggle))>
(ReasonReact.string("Toggle greeting"))
</button>
(
self.state.show
? ReasonReact.string(greeting)
: ReasonReact.null
)
</div>;
}
};

Reason-ExternalPromiseAPI

External Mongo client

1
2
3
4
5
let url = "mongodb://localhost:27017";
let dbName = "myproject";

[@bs.module "mongodb"] external connect: (string) => Js.Promise.t('a) = "";
let connection = (url) => connect(url);

connection會取得一個 Promise 的回傳值

之後只要利用它就可以用 Promise 的模式來對 MongoDB 進行控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
[@bs.module "mongodb"] external connect: (string) => Js.Promise.t('a) = "";
let connect = (url) => connect(url);

module ObjectID = {
type t;
[@bs.send.pipe : t] external toHexString : string = "";
[@bs.new] [@bs.module "mongodb"] external from_string : string => t = "ObjectID";
[@bs.new] [@bs.module "mongodb"] external make : t = "ObjectID";
};

module type TConfig = {
let url: string;
let dbName: string;
};

module CreateConnection = (Config: TConfig) => {
let url = Config.url;
let dbName = Config.dbName;

module Db = {
type t;
};

module Client = {
type t;
[@bs.send] external db : (t, string) => Db.t = "";
let db = (client, name) => db(client, name);
};

module Collection = {
type t;
};

let clientPromise: Js.Promise.t(Client.t) = connect(url);

let createDB: string => Js.Promise.t(Db.t) = (name) =>
clientPromise |> Js.Promise.then_((client) => {
Js.Promise.resolve(Client.db(client, name));
});
}

module Config = {
let url = "mongodb://localhost:27017";
let dbName = "myproject";
};

module Connection = CreateConnection(Config);

幾本的程式碼

其中也有一部分利用 functor 建立一個 module

但是總覺得這樣並不好

詳細要怎樣時做可能還需要一點時間處理

要準備開始 ReasonReact 了

先繼續往下走吧

|