React router、自定义 hook,利用 CSS 和 webpack 给 app 添加样式

React-router

使用 React Router 库生成导航栏:

import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';

const App = () => {
const padding = {
padding: 5,
};

return (
<Router>
<div>
<Link style={padding} to='/'>
home
</Link>
<Link style={padding} to='/notes'>
notes
</Link>
<Link style={padding} to='/users'>
users
</Link>
</div>

<Routes>
<Route path='/notes' element={<Notes />} />
<Route path='/users' element={<Users />} />
<Route path='/' element={<Home />} />
</Routes>

<div>
<i>Note app, Department of Computer Science 2022</i>
</div>
</Router>
);
};

可以添加参数路由:

<Route path='/notes/:id' element={<Note notes={notes} />} />

使用 useNavigate()navigate('/') 可以手动导航

如果一个用户没有登录,则会被重定向到登录页面:

<Route
path='/users'
element={user ? <Users /> : <Navigate replace to='/login' />}
/>

useMatch 钩子可以计算出所需 id:

const match = useMatch('/notes/:id');
const note = match
? notes.find((note) => note.id === Number(match.params.id))
: null;

自定义 hooks

我们也可以自定义钩子:

const useField = (type) => {
const [value, setValue] = useState('');
const onChange = (event) => {
setValue(event.target.value);
};
return { type, value, onChange };
};

样式进阶

先介绍 Bootstrap,使用 react-bootstrap 软件包,并在 index.htmlhead 中加载 CSS:

<head>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css"
integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3"
crossorigin="anonymous"
/>
</head>

应用的所有内容在容器中渲染,给应用的根 div 元素加上 container 类属性

const App = () => {
return <div className='container'>// ...</div>;
};

表格表单通知导航栏

接下来是 MaterialUI React 库,要安装:

npm install @mui/material @emotion/react @emotion/styled

index.html 中添加:

index.html
<head>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap"
/>
</head>

渲染方式是:

import { Container } from '@mui/material';
const App = () => {
return <Container>// ...</Container>;
};

同样有表格文本框按钮AlertAppBar

还可以使用 styled components 库提供的方式定义样式:

import styled from 'styled-components';

const Input = styled.input`
margin: 0.25em;
`;

然后正常使用即可

Webpack

webpack 可用于创建 app

一个 webpack.config.js 文件的例子:

webpack.config.js
const path = require('path');

const config = {
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'build'),
filename: 'main.js',
},
};

module.exports = config;

可以使用 loaders 来通知 webpack 在捆绑前需要处理的文件,例如将 .jsx 文件转化为普通的 .js 文件:

module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
options: {
presets: ['@babel/preset-react'],
},
},
],
},

还需要安装并引入 core-jsregenerator-runtime

当使用 CSS 时,我们必须使用 cssstyle 加载器。

source map 有助于我们看到源代码

各种各样的 Class components

React 元素构成了一个虚拟 DOM

npm-check-updates 可以用来检查依赖的更新

GraphQL

GraphQL 服务器

REST 是基于资源的,对资源的所有操作通过对其 URL 的 HTTP 请求完成。

但是 GraphQL 的所有查询都被发送到一个地址,类型是 POST

一个定义的模式的例子:

type Person {
name: String!
phone: String
street: String!
city: String!
id: ID!
}

type Query {
personCount: Int!
allPersons: [Person!]!
findPerson(name: String!): Person
}

查询:

query {
allPersons {
name
phone
}
}

返回值:

{
"data": {
"allPersons": [
{
"name": "Arto Hellas",
"phone": "040-123543"
},
{
"name": "Matti Luukkainen",
"phone": "040-432342"
},
{
"name": "Venla Ruuska",
"phone": null
}
]
}
}

也可以带有参数

实现服务器使用 Apollo Server

定义模式:

const { ApolloServer, gql } = require('apollo-server');

const typeDefs = gql`
type Person {
name: String!
phone: String
street: String!
city: String!
id: ID!
}

type Query {
personCount: Int!
allPersons: [Person!]!
findPerson(name: String!): Person
}
`;

const resolvers = {
Query: {
personCount: () => persons.length,
allPersons: () => persons,
findPerson: (root, args) => persons.find((p) => p.name === args.name),
},
};

const server = new ApolloServer({
typeDefs,
resolvers,
});

server.listen().then(({ url }) => {
console.log(`Server ready at ${url}`);
});

对象之间也可以组合,如模式为:

type Address {
street: String!
city: String!
}

type Person {
name: String!
address: Address!
id: ID!
}

所有引起变化的操作通过 mutation 完成,如添加一个新人:

type Mutation {
addPerson(
name: String!
phone: String
street: String!
city: String!
): Person
}

可以使用 Apollo 的错误处理机制,如抛出 throw new UserInputError('Name must be unique', {invalidArgs: args.name, })

GraphQL 支持枚举类型:

enum YesNo {
YES
NO
}

type Query {
personCount: Int!

allPersons(phone: YesNo): [Person!]!
findPerson(name: String!): Person
}

React 与 GraphQL

客户端使用 ApolloProvider 包装 APP 组件:

import ReactDOM from 'react-dom';
import App from './App';

import {
ApolloClient,
ApolloProvider,
HttpLink,
InMemoryCache,
} from '@apollo/client';

const client = new ApolloClient({
cache: new InMemoryCache(),
link: new HttpLink({
uri: 'http://localhost:4000',
}),
});

ReactDOM.render(
<ApolloProvider client={client}>
<App />
</ApolloProvider>,
document.getElementById('root')
);

查询可以使用 useQuery

import { gql, useQuery } from '@apollo/client';

const ALL_PERSONS = gql`
query {
allPersons {
name
phone
id
}
}
`;

const App = () => {
const result = useQuery(ALL_PERSONS);

if (result.loading) {
return <div>loading...</div>;
}

return <div>{result.data.allPersons.map((p) => p.name).join(', ')}</div>;
};

如果是一个带参数的查询,可以:

const FIND_PERSON = gql`
query findPersonByName($nameToSearch: String!) {
findPerson(name: $nameToSearch) {
name
phone
id
address {
street
city
}
}
}
`;

const [nameToSearch, setNameToSearch] = useState(null);
const result = useQuery(FIND_PERSON, {
variables: { nameToSearch },
skip: !nameToSearch,
});

if (nameToSearch && result.data) {
return (
<Person
person={result.data.findPerson}
onClose={() => setNameToSearch(null)}
/>
);
}

更新缓存有几种方法,第一种是 poll 服务器:

const result = useQuery(ALL_PERSONS, { pollInterval: 2000 });

缺点在于无意义的网络流量

第二种是使用 refetchQueries 定义,每当创建一个新的人,就重新进行获取所有人员的查询:

const [ createPerson ] = useMutation(CREATE_PERSON, {
refetchQueries: [ { query: ALL_PERSONS }, { query: OTHER_QUERY }, { query: ... } ]
})

缺点在于另一个用户更新的数据不会立刻反应给其他用户

可以定义错误处理函数:

const PersonForm = ({ setError }) => {
const [createPerson] = useMutation(CREATE_PERSON, {
refetchQueries: [{ query: ALL_PERSONS }],
onError: (error) => {
setError(error.graphQLErrors[0].message);
},
});
};

数据库与用户管理

用户管理的模式:

type User {
username: String!
friends: [Person!]!
id: ID!
}

type Token {
value: String!
}

type Query {
me: User
}

type Mutation {
createUser(
username: String!
): User
login(
username: String!
password: String!
): Token
}

实现:

const jwt = require('jsonwebtoken')

const JWT_SECRET = 'NEED_HERE_A_SECRET_KEY'

Mutation: {
createUser: async (root, args) => {
const user = new User({ username: args.username })

return user.save()
.catch(error => {
throw new UserInputError(error.message, {
invalidArgs: args,
})
})
},
login: async (root, args) => {
const user = await User.findOne({ username: args.username })

if ( !user || args.password !== 'secret' ) { throw new UserInputError("wrong credentials") }

const userForToken = {
username: user.username,
id: user._id,
}

return { value: jwt.sign(userForToken, JWT_SECRET) }
},
},

在构造函数调用中添加第三个参数 context 来扩展 server 对象的定义。

const server = new ApolloServer({
typeDefs,
resolvers,

context: async ({ req }) => {
const auth = req ? req.headers.authorization : null;
if (auth && auth.toLowerCase().startsWith('bearer ')) {
const decodedToken = jwt.verify(auth.substring(7), JWT_SECRET);
const currentUser = await User.findById(decodedToken.id).populate(
'friends'
);
return { currentUser };
}
},
});

登录与更新缓存

向 header 中添加令牌:

import { setContext } from '@apollo/client/link/context';

const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('phonenumbers-user-token');
return {
headers: {
...headers,
authorization: token ? `bearer ${token}` : null,
},
};
});

const httpLink = new HttpLink({ uri: 'http://localhost:4000' });

const client = new ApolloClient({
cache: new InMemoryCache(),
link: authLink.concat(httpLink),
});

还有一种手动更新缓存的方式:

const [createPerson] = useMutation(CREATE_PERSON, {
update: (cache, response) => {
cache.updateQuery({ query: ALL_PERSONS }, ({ allPersons }) => {
return {
allPersons: allPersons.concat(response.data.addPerson),
};
});
},
});

Fragments 与 subscriptions

两个查询必须定义完全相同的字段时,可以通过 fragment 来简化,如:

const PERSON_DETAILS = gql`
fragment PersonDetails on Person {
id
name
phone
address {
street
city
}
}
`;

export const FIND_PERSON = gql`
query findPersonByName($nameToSearch: String!) {
findPerson(name: $nameToSearch) {
...PersonDetails
}
}
${PERSON_DETAILS}
`;

Apollo 使用 WebSockets 进行服务器用户的通信

Apollo Server Express 替换 Apollo 服务器

TypeScript

背景与介绍

TypeScript 可以被编译为 JavaScript,是它的一个超集,由语言、编译器、语言服务三部分组成

一个例子:

const birthdayGreeter = (name: string, age: number): string => {
return `Happy birthday ${name}, you are now ${age} years old!`;
};

其中函数的返回值可以不写,能够自动推断出来

支持 callback:

type CallsFunction = (callback: (result: string) => any) => void;

const func: CallsFunction = (cb) => {
cb('done');
cb(1);
};

func((result) => {
return result;
});

TypeScript 的一小步

需要安装的包:

npm install --save-dev ts-node typescript

可以自定义类型:

type Operation = 'multiply' | 'add' | 'divide';

也可以通过定义接口来定义类型:

interface MultiplyValues {
value1: number;
value2: number;
}

npm 包通常有对应的 typescript 版本,名称为 @types/{npm_package},且总是应该安装在 devDependencies 中,并仍需要安装相应的 JavaScript 版本

tsconfig.json 文件中包含了 TypeScript 的核心配置

TypeScript 版的 express 应用

使用 tsc 生成 tsconfig.json 文件

npm run tsc -- --init

nodemon 的 TypeScript 替代品为 ts-node-dev

关键字 as 断言变量类型

可以通过在类型声明中添加 ?,将字段的类型设置为 optional

注意节点模块的加载顺序: ["js", "json", "node", "ts", "tsx"],最好的方法是避免重名

Pick 实用类型允许选择想使用的现有类型的哪些字段:

const getNonSensitiveEntries = (): Pick<
DiaryEntry,
'id' | 'date' | 'weather' | 'visibility'
>[] => {};

Omit 可以用来声明要排除哪些字段:

export type NonSensitiveDiaryEntry = Omit<DiaryEntry, 'comment'>;

注意这只是禁止访问而已,并不是不返回那些被排除的字段而已,所以需要手动排除这些字段:

const getNonSensitiveEntries = (): NonSensitiveDiaryEntry[] => {
return diaries.map(({ id, date }) => ({ id, date }));
};

使用 unknown 可以先验证类型,然后确认预期类型,如:

const parseComment = (comment: unknown): string => {
if (!comment || !isString(comment)) {
throw new Error('Incorrect or missing comment');
}
return comment;
};

利用 TypeScript 编写 React 应用

创建一个 TypeScript 应用:

npx create-react-app my-app --template typescript

组件:

const Welcome = ({ name }: { name: string }) => <h1>Hello, {name}</h1>;

接口可以继承,这样就可以不同类型共享相同的域,然后通过 switch 等语句区分不同的具体类型:

interface CoursePartBase {
name: string;
exerciseCount: number;
}

interface CoursePartOne extends CoursePartBase {
name: 'Fundamentals';
description: string;
}

interface CoursePartTwo extends CoursePartBase {
name: 'Using props to pass data';
groupProjectCount: number;
}

type CoursePart = CoursePartOne | CoursePartTwo;

React Native

React Native 介绍

对于跨平台方案,Cordova 使用标准的网络技术--HTML5、CSS3 和 JavaScript 来开发多平台应用,在嵌入式浏览器窗口中运行,故不能达到使用实际本地用户界面组件的本地应用的性能或外观和感觉的

React Native 将 React 的大量功能带入本地开发,在幕后利用平台的本地组件

使用 Expo 初始化:

npx create-expo-app rate-repository-app --template expo-template-blank@sdk-50

可以使用安卓模拟器或 Expo Go 移动应用查看

安装 eslint:

npm install --save-dev eslint @babel/eslint-parser eslint-plugin-react eslint-plugin-react-native

React Native 入门

React Native 中有很多预先定义好的核心组件,例如文本:

import { Text } from 'react-native';
const HelloWorld = (props) => {
return <Text>Hello world!</Text>;
};

类似的还有 ViewTextInputPressable 等组件

注意 react native 中所有样式属性都是无单位的,可用样式属性详见 React Native Styling Cheat Sheet,其用法为:

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

const styles = StyleSheet.create({
container: {
padding: 20,
},
text: {
color: 'blue',
fontSize: 24,
fontWeight: '700',
},
});

const BigBlueText = () => {
return (
<View style={styles.container}>
<Text style={styles.text}>
Big blue text
<Text>
</View>
);
};

使用 flexbox 布局:

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

const styles = StyleSheet.create({
flexContainer: {
display: 'flex',
},
flexItemA: {
flexGrow: 0,
backgroundColor: 'green',
},
flexItemB: {
flexGrow: 1,
backgroundColor: 'blue',
},
});

const FlexboxExample = () => {
return (
<View style={styles.flexContainer}>
<View style={styles.flexItemA}>
<Text>Flex item A</Text>
</View>
<View style={styles.flexItemB}>
<Text>Flex item B</Text>
</View>
</View>
);
};

使用 react-router-native 来实现路由,添加到组件:

import { StatusBar } from 'expo-status-bar';
import { NativeRouter } from 'react-router-native';
const App = () => {
return (
<>
<NativeRouter>
<Main />
</NativeRouter>
<StatusBar style='auto' />
</>
);
};

添加一个路由:

const Main = () => {
return (
<View>
<AppBar />
<Routes>
<Route path='/' element={<RepositoryList />} exact />
<Route path='*' element={<Navigate to='/' replace />} />
</Routes>
</View>
);
};

Formik 来管理表单:

import { useFormik } from 'formik';

const initialValues = {
mass: '',
};

const BodyMassIndexForm = ({ onSubmit }) => {
const formik = useFormik({
initialValues,
onSubmit,
});

return (
<View>
<TextInput
placeholder="Weight (kg)"
value={formik.values.mass}
onChangeText={formik.handleChange('mass')}
/>
<Pressable onPress={formik.handleSubmit}>
<Text>Calculate</Text>
</Pressable>
</View>
);
};

const BodyMassIndexCalculator = () => {
const onSubmit = values => {...};

return <BodyMassIndexForm onSubmit={onSubmit} />;
};

export default BodyMassIndexCalculator;

使用 Yup 验证:

import * as yup from 'yup';

const validationSchema = yup.object().shape({
mass: yup
.number()
.min(1, 'Weight must be greater or equal to 1')
.required('Weight is required'),
});

const formik = useFormik({
initialValues,
validationSchema,
onSubmit,
});

条件渲染错误提示:

{
formik.touched.mass && formik.errors.mass && (
<Text style={{ color: 'red' }}>{formik.errors.mass}</Text>
);
}

可以通过 Platform.OS 常量来访问用户的平台:

Platform.OS === 'android' ? 'green' : 'blue';

Platform.select 方法:给定一个对象,其键值为 iosandroidnativedefault 之一:

const styles = StyleSheet.create({
text: {
color: Platform.select({
android: 'green',
ios: 'blue',
default: 'black',
}),
},
});

与服务端通信

React Native 提供了 Fetch API,如:

fetch('https://my-api.com/post-end-point', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
firstParam: 'firstValue',
secondParam: 'secondValue',
}),
});

也可以通过 XMLHttpRequest API 使用 Axios 等第三方库

这里作为 GraphQL 和 Apollo client,需要安装:

npm install @apollo/client graphql @expo/metro-config

在根目录下的 metro.config.js 添加:

const { getDefaultConfig } = require('@expo/metro-config');
const defaultConfig = getDefaultConfig(__dirname);
defaultConfig.resolver.sourceExts.push('cjs');
module.exports = defaultConfig;

创建一个 apolloClient.js 文件,配置连接到 Apollo 服务器的客户端:

import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';

const httpLink = createHttpLink({ uri: 'http://192.168.100.16:4000/graphql' });

const createApolloClient = () => {
return new ApolloClient({
link: httpLink,
cache: new InMemoryCache(),
});
};

export default createApolloClient;

添加到组件:

import { ApolloProvider } from '@apollo/client';
const apolloClient = createApolloClient();

const App = () => {
return (
<NativeRouter>
<ApolloProvider client={apolloClient}>
<Main />
</ApolloProvider>
</NativeRouter>
);
};

在组织代码时,可以在 graphql 目录下创建 queries.js,每个查询都存储在一个变量中,并一个个导出。改变则储存在 mutations.js,同时善于使用 fragments.js 来精简代码

我们有组件A,它渲染了组件 B 和 C,则目录可以为:

components/
A/
B.jsx
C.jsx
index.jsx

而不需要改动引入组件 A 的代码,其自动匹配到 A/index.jsx

React Native 没有对环境变量的直接支持,但可以通过改变 app.json 文件中定义的 Expo 配置来实现:

import 'dotenv/config';

export default {
name: 'rate-repository-app',
extra: {
env: process.env.ENV,
},
};

并配合熟悉的 dotenv

持久化本地存储 AsyncStorage

expo install @react-native-async-storage/async-storage

localStorage 类似,但顾名思义,其操作是异步的,因为是在全局命名空间操作,故最好添加一个命名空间

import AsyncStorage from '@react-native-async-storage/async-storage';

class ShoppingCartStorage {
constructor(namespace = 'shoppingCart') {
this.namespace = namespace;
}

async getProducts() {
const rawProducts = await AsyncStorage.getItem(
`${this.namespace}:products`
);
return rawProducts ? JSON.parse(rawProducts) : [];
}

async addProduct(productId) {
const currentProducts = await this.getProducts();
const newProducts = [...currentProducts, productId];
await AsyncStorage.setItem(
`${this.namespace}:products`,
JSON.stringify(newProducts)
);
}

async clearProducts() {
await AsyncStorage.removeItem(`${this.namespace}:products`);
}
}

const doShopping = async () => {
const shoppingCartA = new ShoppingCartStorage('shoppingCartA');
await shoppingCartA.addProduct('chips');
const productsA = await shoppingCartA.getProducts();
await shoppingCartA.clearProducts();
};

将访问令牌发送给 Apollo 服务器:

import { ApolloClient, InMemoryCache, createHttpLink } from '@apollo/client';
import Constants from 'expo-constants';
import { setContext } from '@apollo/client/link/context';

const { apolloUri } = Constants.manifest.extra;
const httpLink = createHttpLink({ uri: apolloUri });

const createApolloClient = (authStorage) => {
const authLink = setContext(async (_, { headers }) => {
try {
const accessToken = await authStorage.getAccessToken();
return {
headers: {
...headers,
authorization: accessToken ? `Bearer ${accessToken}` : '',
},
};
} catch (e) {
console.log(e);
return {
headers,
};
}
});
return new ApolloClient({
link: authLink.concat(httpLink),
cache: new InMemoryCache(),
});
};

export default createApolloClient;

使用 React Context 来使挂钩访问到 token:

import React from 'react';

const AuthStorageContext = React.createContext();

export default AuthStorageContext;

app.js 中:

import AuthStorageContext from './src/contexts/AuthStorageContext';

const authStorage = new AuthStorage();
const App = () => {
return (
<AuthStorageContext.Provider value={authStorage}>
<Main />
</AuthStorageContext.Provider>
);
};

使用 useContext 钩访问:

import { useContext } from 'react';

import AuthStorageContext from '../contexts/AuthStorageContext';

const useAuthStorage = () => {
return useContext(AuthStorageContext);
};

export default useAuthStorage;

测试与扩展我们的应用

安装 Jest

npm install --save-dev jest jest-expo eslint-plugin-jest

在 package.json 文件中加入 Jest 配置:

{
"scripts": {
"test": "jest"
},

"jest": {
"preset": "jest-expo",
"transform": {
"^.+\\.jsx?$": "babel-jest"
},
"transformIgnorePatterns": [
"node_modules/(?!((jest-)?react-native|@react-native(-community)?)|expo(nent)?|@expo(nent)?/.*|@expo-google-fonts/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|react-native-svg|react-router-native)"
]
}
}

把 eslint-plugin-jest 包含在 .eslintrc 文件的插件和扩展数组中:

"extends": ["eslint:recommended", "plugin:react/recommended", "plugin:jest/recommended"],

存放 test 文件有两种方式,第一种是把测试文件放在其相应的子目录中,就像代码本身一样,如:

src/
__tests__/
components/
AppBar.js
RepositoryList.js
utils/
authStorage.js

另一种方法是在实现附近组织测试,即在同一目录下(注意添加 .test 后缀):

src/
components/
AppBar/
AppBar.test.jsx
index.jsx

测试组件安装:

npm install --save-dev react-test-renderer@17.0.1 @testing-library/react-native @testing-library/jest-native

一个测试的例子:

import { Text, View } from 'react-native';
import { render } from '@testing-library/react-native';

const Greeting = ({ name }) => {
return (
<View>
<Text>Hello {name}!</Text>
</View>
);
};

describe('Greeting', () => {
it('renders a greeting message based on the name prop', () => {
const { debug, getByText } = render(<Greeting name='Kalle' />);
debug();
expect(getByText('Hello Kalle!')).toBeDefined();
});
});

对于一些非纯函数,如需要与服务器通信,可以将非纯的部分提取出来,单独测试纯函数部分:

export const RepositoryListContainer = ({ repositories }) => {
const repositoryNodes = repositories
? repositories.edges.map((edge) => edge.node)
: [];
return <FlatList data={repositoryNodes} />;
};

const RepositoryList = () => {
const { repositories } = useRepositories();
return <RepositoryListContainer repositories={repositories} />;
};

export default RepositoryList;

当 API 从某个集合中返回一个有序的项目列表时,它通常会返回整个项目集的一个子集,以减少所需的带宽,并降低客户端应用的内存使用。客户端就可以请求例如列表中某个索引后的前 20 个项目。这种技术通常被称为分页

当项目可以在由游标定义的某个项目之后被请求时,我们谈论的就是基于游标的分页

这是一个查询:

{
repositories(first: 2) {
edges {
node {
fullName
}
cursor
}
pageInfo {
endCursor
startCursor
hasNextPage
}
}
}

然后使用返回的 endCursor 进行下一步查询

无限滚动的实现方法:

export const RepositoryListContainer = ({ repositories, onEndReach }) => {
const repositoryNodes = repositories
? repositories.edges.map((edge) => edge.node)
: [];

return (
<FlatList
data={repositoryNodes}
onEndReached={onEndReach}
onEndReachedThreshold={0.5}
/>
);
};

const RepositoryList = () => {
const { repositories } = useRepositories(/* ... */);
const onEndReach = () => {
fetchMore();
};

return (
<RepositoryListContainer
repositories={repositories}
onEndReach={onEndReach}
/>
);
};

export default RepositoryList;

并定义相关的 fetchMore 函数

const useRepositories = (variables) => {
const { data, loading, fetchMore, ...result } = useQuery(GET_REPOSITORIES, {
variables,
});

const handleFetchMore = () => {
const canFetchMore = !loading && data?.repositories.pageInfo.hasNextPage;

if (!canFetchMore) {
return;
}

fetchMore({
variables: {
after: data.repositories.pageInfo.endCursor,
...variables,
},
});
};

return {
repositories: data?.repositories,
fetchMore: handleFetchMore,
loading,
...result,
};
};

Awesome React Native 中有丰富的 React Native 资源,如 React Native PaperStyled-componentsReact-springReact Navigation

CI/CD

CI/CD 简介

CI 持续集成:经常性地将开发人员的修改合并到主分支上,通常有以下的步骤:

  • 提示:保持我们的代码清洁和可维护
  • 构建:将我们所有的代码整合成软件
  • 测试:以确保我们不会破坏现有的功能
  • 打包:把它全部放在一个容易移动的批次中
  • 上传/部署:将它提供给全世界

CD 主分支始终保持可部署,两者并没有清晰的界限

CI 有两种选择:托管我们自己的服务器(Jenkins)或使用云服务(GitHub Actions

开始认识 GitHub Actions 吧

GitHub Action 的基础是工作流,执行过程为:

  1. 触发事件发生
  2. 带有该触发器的工作流被执行
  3. 清理

每个工作流必须指定至少一个 job,其中包含一组 step 来执行单个任务。jobs 并行执行,每个 job 中的 steps 会顺序执行

工作流存储在 ``.github/workflows中,格式为YAML`

工作流也有 hello world:

name: Hello World!

on:
push:
branches:
- master

jobs:
hello_world_job:
runs-on: ubuntu-20.04
steps:
- name: Say hello
run: echo "Hello World!"

使用 usess 执行定义好的动作,如:

jobs:
simple_deployment_pipeline:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v2
with:
node-version: '20'
- name: npm install
run: npm install
- name: lint
run: npm run eslint

部署

任何事情都有可能出错

保持健康状态

为了保持主分支的绿色,通常避免将任何修改直接提交到主分支,而是在一个基于主分支的最新版本的分支上提交代码,然后创建 PR 合并到主分支

向工作流中加入 PR 时自动检查代码的功能:

on:
push:
branches:
- master

pull_request:
branches: [master]
types: [opened, synchronize]

版本管理:

  • 语义版本管理:易读,通常用于发布
  • 哈希版本控制:保证唯一,通常用于开发

github 可以设置受保护的分支

再扩展一下

在构建失败时发送通知

随着项目越来越复杂,其构建时间也越来越长,要权衡取舍

容器

容器介绍

使用 Docker

容器也有 hello world

docker container run hello-world

docker container ls -a 列出所有的容器

docker start 容器名或 ID 启动容器

docker container run -it ubuntu bashbash 命令行下,以交互式启动 ubuntu image

docker kill 容器名或 ID 停止某个容器

docker commit 容器 镜像 从容器中创建一个新的镜像

docker image ls 列出所有镜像

docker container rm 容器名或 ID 删除某个容器

构建配置环境

Dockerfile 文件中配置,如:

FROM node:16
WORKDIR /usr/src/app
COPY ./index.js ./index.js
CMD node index.js

使用 docker build 来构建一个基于 Docker 文件的镜像,-t 用于命名

.dockerignore 文件可以防止我们不小心将不需要的文件复制到镜像中

Docker-compose 可以帮助我们管理容器

这里将之前的命令转化为一个 yaml 文件:

version: '3.8'
services:
app: # 服务的名字
image: express-server # 声明使用的镜像
build: . # 如果镜像没找到,则在哪里 build 一个镜像
ports: # 声明发布的端口
- 3000:3000

docker-compose up 可以构建应用并运行应用,-d 参数表示在后台运行,docker-compose down 关闭

绑定挂载是将主机上的文件与容器中的文件绑定的行为,在 Docker Compose 中的 volumes 键下声明:

mongo:
image: mongo
ports:
- 3456:27017
environment:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: example
MONGO_INITDB_DATABASE: the_database
volumes:
- ./mongo/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js

Docker 的 exec 命令可以在一个容器运行时直接跳入它,如 docker exec -it wonderful_ramanujan bash

Redis 是一个非关系型数据库,默认情况下在内存中工作,故可以用作缓存。除此之外也可以实现“发布-订阅”模式,即作为多个应用之间的消息代理工作

编排基础

CMD ["serve", "build"]

是 CMD 的 exec 形式

多阶段构建:将构建过程分成许多独立的阶段,可以限制镜像文件的大小:

# 第一个 FROM 是 build-stage 阶段
FROM node:16 AS build-stage
WORKDIR /usr/src/app
COPY . .
RUN npm ci
RUN npm run build
# 新的阶段,之前的都完成了,除了我们想要 COPY 的文件
FROM nginx:1.20-alpine
# 从 build-stage 中构建的目录复制到 /usr/share/nginx/html
COPY --from=build-stage /usr/src/app/build /usr/share/nginx/html

也可以设置为直接在容器中开发程序:

services:
app:
image: hello-front-dev
build:
context: . # The context will pick this directory as the "build context"
dockerfile: dev.Dockerfile # This will simply tell which dockerfile to read
volumes:
- ./:/usr/src/app # The path can be relative, so ./ is enough to say "the same location as the docker-compose.yml"
ports:
- 3000:3000
container_name: hello-front-dev # This will name the container hello-front-dev

不过安装新的依赖比较麻烦,最好是在容器中安装,如 docker exec hello-front-dev npm install axios 并重新 docker build

Busybox 可以帮助我们调试我们的配置,加入

debug-helper:
image: busybox

如发送一个请求:docker-compose run debug-helper wget -O - http://app:3000

注意到 docker 的容器间有一个 DNS,app 域名被解析为某容器中的网络了

使用关系型数据库

用 Sequelize 使用关系型数据库

之前使用的 MongoDB 是无模式的,这里介绍一个关系型数据库——PostgreSQL

安装完成后,有图形用户界面,如 pgAdmin,这里使用命令行 psql

\d 命令可以告诉数据库的内容

一个创建表的例子:

CREATE TABLE notes (
id SERIAL PRIMARY KEY,
content text NOT NULL,
important boolean,
date time
);

\d notes 查看表格 notes

添加内容:

insert into notes (content, important) values ('Relational databases rule the world', true);
insert into notes (content, important) values ('MongoDB is webscale', false);

sequelize 库可以在不使用 SQL 语言的情况下存储 JavaScript 对象

与数据库建立连接的方法:

require('dotenv').config();
const { Sequelize } = require('sequelize');

const sequelize = new Sequelize(process.env.DATABASE_URL, {
dialectOptions: {
ssl: {
require: true,
rejectUnauthorized: false,
},
},
});

const main = async () => {
try {
await sequelize.authenticate();
console.log('Connection has been established successfully.');
sequelize.close();
} catch (error) {
console.error('Unable to connect to the database:', error);
}
};

main();

建立代码中的模型:

class Note extends Model {}
Note.init(
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
content: {
type: DataTypes.TEXT,
allowNull: false,
},
important: {
type: DataTypes.BOOLEAN,
},
date: {
type: DataTypes.DATE,
},
},
{
sequelize,
underscored: true,
timestamps: false,
modelName: 'note',
}
);

查找全部:

app.get('/api/notes', async (req, res) => {
const notes = await Note.findAll();
res.json(notes);
});

创建新的对象:

const note = await Note.create(req.body);

也可以分为两步:

const note = Note.build(req.body);
note.important = true;
await note.save();

根据 id 查找:

const note = await Note.findByPk(req.params.id);

加入表与查询

项目的文件结构如下:

index.js
util
config.js
db.js
models
index.js
note.js
controllers
notes.js

两个表之间建立一对多的关系:

User.hasMany(Note);
Note.belongsTo(User);
Note.sync({ alter: true });
User.sync({ alter: true });

查询时包括的内容也一并返回:

const users = await User.findAll({
include: { model: Note },
});

还可以进一步限制想要的字段的值:

const notes = await Note.findAll({
attributes: { exclude: ['userId'] },
include: {
model: User,
attributes: ['name'],
},
});

我们可以实现一个带参数的查询,如 http://localhost:3001/api/notes?search=database&important=true

const where = {};
if (req.query.important) {
where.important = req.query.important === 'true';
}
if (req.query.search) {
where.content = { [Op.substring]: req.query.search };
}
const notes = await Note.findAll({
attributes: { exclude: ['userId'] },
include: {
model: user,
attributes: ['name'],
},
where,
});

迁移,多对多关系

迁移 migrantion 是一个单一的 JavaScript 文件,描述了对数据库的一些修改,迁移引起的变化同步到数据库模式中,最终实现了修改可控

这是一个迁移的例子:

module.exports = {
up: async ({ context: queryInterface }) => {
await queryInterface.createTable('notes', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
content: {
type: DataTypes.TEXT,
allowNull: false,
},
});
await queryInterface.createTable('users', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
username: {
type: DataTypes.STRING,
unique: true,
allowNull: false,
},
});
await queryInterface.addColumn('notes', 'user_id', {
type: DataTypes.INTEGER,
allowNull: false,
references: { model: 'users', key: 'id' },
});
},
down: async ({ context: queryInterface }) => {
await queryInterface.dropTable('notes');
await queryInterface.dropTable('users');
},
};

函数 up 是定义了在执行迁移时应该如何修改数据库,而 down 定义如何撤销迁移

迁移文件命名总是以日期和序号开始,如 migrations/20211209_00_initialize_notes_and_users.js

Umzug 库可以从程序中手动执行迁移:

const { Umzug, SequelizeStorage } = require('umzug');

const runMigrations = async () => {
const migrator = new Umzug({
migrations: {
glob: 'migrations/*.js',
},
storage: new SequelizeStorage({ sequelize, tableName: 'migrations' }),
context: sequelize.getQueryInterface(),
logger: console,
});
const migrations = await migrator.up();
console.log('Migrations up to date', {
files: migrations.map((mig) => mig.name),
});
};

const connectToDatabase = async () => {
await sequelize.authenticate();
await runMigrations();
};

对于多对多模型,需要用到连接模型:

class Membership extends Model {}
Membership.init(
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
user_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: { model: 'users', key: 'id' },
},
team_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: { model: 'teams', key: 'id' },
},
},
{
sequelize,
underscored: true,
timestamps: false,
modelName: 'membership',
}
);

然后连接:

const User = require('./user');
const Team = require('./team');
const Membership = require('./membership');

User.belongsToMany(Team, { through: Membership });
Team.belongsToMany(User, { through: Membership });

注意到模型和迁移的代码很多部分是重复的,但是代码可能会随着时间改变,而迁移则充当了“记录”的功能,故不可复用数据,只能手动复制