使用 React 构建出色的求职应用程序
在本文中,您将使用Github Jobs API构建一个漂亮的求职应用程序
通过构建此应用程序,您将了解:
- 如何在 React 中延迟加载图像
- 如何使用 React Context API 在组件之间共享数据
- 为什么 React 不会渲染 JSX Expression 中使用的 HTML,以及如何在需要时正确显示它
- 如何在实际图像下载时显示备用加载图像
- 如何使用 React Portal 创建你自己版本的加载器
- 如何添加“加载更多”功能
还有更多。
您可以在此处查看该应用程序的现场演示
让我们开始吧
初始设置
使用以下方式创建新项目create-react-app
create-react-app github-jobs-react-app
项目创建完成后,删除src
文件夹中的所有文件,并在文件夹中创建index.js
文件。同时,在文件夹中src
创建actions
、components
、context,css
、custom-hooks
、images
、reducers
、和文件夹。router
store
utils
src
安装必要的依赖项
yarn add axios@0.19.2 bootstrap@4.5.0 lodash@4.17.15 moment@2.27.0 node-sass@4.14.1 prop-types@15.7.2 react-bootstrap@1.0.1 react-redux@7.2.0 redux@4.0.5 redux-thunk@2.3.0
server
在文件夹外创建一个新文件夹,并从文件夹src
执行以下命令server
yarn init -y
这将在文件夹内创建一个package.json
文件server
。
server
从文件夹安装所需的依赖项
yarn add axios@0.19.2 express@4.17.1 cors@2.8.5 nodemon@2.0.4
.gitignore
在文件夹中创建一个新文件,server
并在其中添加以下行,这样node_modules
文件夹就不会受到版本控制
node_modules
初始页面显示更改
styles.scss
现在,在文件夹中创建一个新文件并从此处src/css
向其中添加内容。
jobs.js
在文件夹中创建一个新文件src/reducers
,内容如下
const jobsReducer = (state = [], action) => {
switch (action.type) {
case 'SET_JOBS':
return action.jobs;
case 'LOAD_MORE_JOBS':
return [...state, ...action.jobs];
default:
return state;
}
};
export default jobsReducer;
SET_JOBS
在这个文件中,我们使用action添加来自 redux 中的 API 的新作业数据,并使用LOAD_MORE_JOBS
action 获取更多作业并使用扩展运算符将其添加到已经存在的作业数组中。
[...state, ...action.jobs]
errors.js
在文件夹中创建一个新文件src/reducers
,内容如下
const errorsReducer = (state = {}, action) => {
switch (action.type) {
case 'SET_ERRORS':
return {
error: action.error
};
case 'RESET_ERRORS':
return {};
default:
return state;
}
};
export default errorsReducer;
在此文件中,我们通过分派操作将任何 API 错误(如果有)添加到 redux 存储中SET_ERRORS
,如果通过分派操作从 API 获取响应时没有错误,则从 redux 存储中删除错误对象RESET_ERRORS
。
store.js
在文件夹中创建一个新文件src
,内容如下
import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
import thunk from 'redux-thunk';
import jobsReducer from '../reducers/jobs';
import errorsReducer from '../reducers/errors';
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
combineReducers({
jobs: jobsReducer,
errors: errorsReducer
}),
composeEnhancers(applyMiddleware(thunk))
);
console.log(store.getState());
export default store;
在这个文件中,我们创建一个 redux 存储,它使用combineReducers
并添加 thunk 作为redux-thunk
管理异步 API 处理的中间件。
我们还使用 添加了 redux devtool 配置composeEnhandlers
。
如果您不熟悉 redux-thunk 和 redux devtool 配置,请查看我之前的文章(此处)以了解如何使用它。
现在,在src/index.js
文件中添加以下内容
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store/store';
import HomePage from './components/HomePage';
import 'bootstrap/dist/css/bootstrap.min.css';
import './css/styles.scss';
ReactDOM.render(
<Provider store={store}>
<HomePage />
</Provider>,
document.getElementById('root')
);
在这个文件中,我们使用Provider
组件来与react-redux
我们共享存储数据HomePage
以及它的所有子组件。
现在,在文件夹HomePage.js
内创建一个src/components
包含以下内容的新文件。
import React from 'react';
const HomePage = () => {
return <div className="container">Home Page</div>;
};
export default HomePage;
现在,开放public/index.html
并改变
<title>React App</title>
到
<title>Github Job Search</title>
github-jobs-react-app
现在通过从文件夹运行以下命令来启动 React 应用程序
yarn start
您将看到应用程序显示主页文本
添加基本搜索 UI
现在,在文件夹Header.js
中创建一个components
包含以下内容的新文件
import React from 'react';
const Header = () => (
<header className="header">
<div className="title">Github Job Search</div>
</header>
);
export default Header;
Search.js
在文件夹中创建一个新文件components
,内容如下
import React, { useState } from 'react';
import { Form, Button, Row, Col } from 'react-bootstrap';
const Search = (props) => {
const [state, setState] = useState({
description: '',
location: '',
full_time: false
});
const handleInputChange = (event) => {
const { name, value } = event.target;
if (name === 'full_time') {
setState((prevState) => ({ ...state, [name]: !prevState.full_time }));
} else {
setState({ ...state, [name]: value });
}
};
const handleSearch = (event) => {
event.preventDefault();
console.log(state);
};
return (
<div className="search-section">
<Form className="search-form" onSubmit={handleSearch}>
<Row>
<Col>
<Form.Group controlId="description">
<Form.Control
type="text"
name="description"
value={state.description || ''}
placeholder="Enter search term"
onChange={handleInputChange}
/>
</Form.Group>
</Col>
<Col>
<Form.Group controlId="location">
<Form.Control
type="text"
name="location"
value={state.location || ''}
placeholder="Enter location"
onChange={handleInputChange}
/>
</Form.Group>
</Col>
<Col>
<Button variant="primary" type="submit" className="btn-search">
Search
</Button>
</Col>
</Row>
<div className="filters">
<Form.Group controlId="full_time">
<Form.Check
type="checkbox"
name="full_time"
className="full-time-checkbox"
label="Full time only"
checked={state.full_time}
onChange={handleInputChange}
/>
</Form.Group>
</div>
</Form>
</div>
);
};
export default Search;
在这个文件中,我们添加了两个输入文本字段来从用户那里获取描述和位置,并添加了一个复选框来仅获取全职工作。
我们还onChange
向每个输入字段添加了一个处理程序来更新状态值。
现在,打开HomePage.js
并将其替换为以下内容
import React from 'react';
import Header from './Header';
import Search from './Search';
const HomePage = () => {
return (
<div>
<Header />
<Search />
</div>
);
};
export default HomePage;
现在,如果您在输入字段中输入值并单击Search
按钮,您将看到输入的数据显示在控制台中
在 UI 上显示作业列表
现在,创建包含以下内容的errors.js
内部文件夹src/actions
export const setErrors = (error) => ({
type: 'SET_ERRORS',
error
});
export const resetErrors = () => ({
type: 'RESET_ERRORS'
});
在这个文件中,我们添加了动作创建器函数,我们将调用它来将动作分派给减速器。
constants.js
在文件夹中创建一个新文件utils
,内容如下
export const BASE_API_URL = 'http://localhost:5000';
jobs.js
在文件夹中创建一个新文件src/actions
,内容如下
import axios from 'axios';
import moment from 'moment';
import { BASE_API_URL } from '../utils/constants';
import { setErrors } from './errors';
export const initiateGetJobs = (data) => {
return async (dispatch) => {
try {
let { description, full_time, location, page } = data;
description = description ? encodeURIComponent(description) : '';
location = location ? encodeURIComponent(location) : '';
full_time = full_time ? '&full_time=true' : '';
if (page) {
page = parseInt(page);
page = isNaN(page) ? '' : `&page=${page}`;
}
const jobs = await axios.get(
`${BASE_API_URL}/jobs?description=${description}&location=${location}${full_time}${page}`
);
const sortedJobs = jobs.data.sort(
(a, b) =>
moment(new Date(b.created_at)) - moment(new Date(a.created_at))
);
return dispatch(setJobs(sortedJobs));
} catch (error) {
error.response && dispatch(setErrors(error.response.data));
}
};
};
export const setJobs = (jobs) => ({
type: 'SET_JOBS',
jobs
});
export const setLoadMoreJobs = (jobs) => ({
type: 'LOAD_MORE_JOBS',
jobs
});
在这个文件中,我们添加了一个initiateGetJobs
函数,该函数通过对 Node.js 中的 Express 服务器进行 API 调用来获取 JSON 数据,一旦收到数据,就会分派操作,通过执行文件中的 switch caseSET_JOBS
将所有作业数据添加到 redux 存储中。SET_JOBS
reducers/jobs.js
现在,在文件夹server.js
中创建一个server
包含以下内容的新文件
const path = require('path');
const axios = require('axios');
const cors = require('cors');
const express = require('express');
const app = express();
const PORT = process.env.PORT || 5000;
const buildPath = path.join(__dirname, '..', 'build');
app.use(express.static(buildPath));
app.use(cors());
app.get('/jobs', async (req, res) => {
try {
let { description = '', full_time, location = '', page = 1 } = req.query;
description = description ? encodeURIComponent(description) : '';
location = location ? encodeURIComponent(location) : '';
full_time = full_time === 'true' ? '&full_time=true' : '';
if (page) {
page = parseInt(page);
page = isNaN(page) ? '' : `&page=${page}`;
}
const query = `https://jobs.github.com/positions.json?description=${description}&location=${location}${full_time}${page}`;
const result = await axios.get(query);
res.send(result.data);
} catch (error) {
res.status(400).send('Error while getting list of jobs.Try again later.');
}
});
app.listen(PORT, () => {
console.log(`server started on port ${PORT}`);
});
在这个文件中,我们创建了一个/jobs
使用服务器的获取 API Express
。
在这里,我们通过传递和来Github Jobs API
调用以获取可用作业列表。description
location
默认情况下,API 仅提供最新作业列表50
,但我们可以通过发送page
值为 1、2、3 等的查询参数来获取更多作业。
因此我们通过以下代码验证页面查询参数
if (page) {
page = parseInt(page);
page = isNaN(page) ? '' : `&page=${page}`;
}
如果我们只想查找全职工作,那么我们需要full_time
在查询字符串中添加一个附加参数,其值为true
full_time = full_time === 'true' ? '&full_time=true' : '';
最后,我们通过组合所有参数值来创建 API URL。
`https://jobs.github.com/positions.json?description=${description}&location=${location}${full_time}${page}`;
为每个输入字段添加的原因encodeURIComponent
是为了将任何特殊字符(如空格)转换为 %20。
initiateGetJobs
如果您注意到,我们还在文件内的函数中添加了相同的解析代码actions/jobs.js
。
将其包含在服务器代码中的原因还在于,我们也可以直接访问/jobs
获取 API,而无需任何应用程序,只需进行我们添加条件的额外检查即可。
现在,在文件夹JobItem.js
中创建一个components
包含以下内容的新文件
import React from 'react';
import moment from 'moment';
const JobItem = (props) => {
const {
id,
type,
created_at,
company,
location,
title,
company_logo,
index
} = props;
return (
<div className="job-item" index={index + 1}>
<div className="company-logo">
<img src={company_logo} alt={company} width="100" height="100" />
</div>
<div className="job-info">
<div className="job-title">{title}</div>
<div className="job-location">
{location} | {type}
</div>
<div className="company-name">{company}</div>
</div>
<div className="post-info">
<div className="post-time">
Posted {moment(new Date(created_at)).fromNow()}
</div>
</div>
</div>
);
};
export default JobItem;
在此文件中,我们显示来自 API 的数据,在文件夹中
创建一个包含以下内容的新文件Results.js
components
import React from 'react';
import JobItem from './JobItem';
const Results = ({ results }) => {
return (
<div className="search-results">
{results.map((job, index) => (
<JobItem key={job.id} {...job} index={index} />
))}
</div>
);
};
export default Results;
在这个文件中,我们循环遍历结果数组中的每个作业对象,并传递单个作业数据以显示在JobItem
之前创建的组件中。
现在,打开components/HomePage.js
文件并将其替换为以下内容
import React, { useState, useEffect } from 'react';
import _ from 'lodash';
import { connect } from 'react-redux';
import { initiateGetJobs } from '../actions/jobs';
import { resetErrors } from '../actions/errors';
import Header from './Header';
import Search from './Search';
import Results from './Results';
const HomePage = (props) => {
const [results, setResults] = useState([]);
const [errors, setErrors] = useState(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setResults(props.jobs);
}, [props.jobs]);
useEffect(() => {
setErrors(props.errors);
}, [props.errors]);
const loadJobs = (selection) => {
const { dispatch } = props;
const { description, location, full_time, page = 1 } = selection;
dispatch(resetErrors());
setIsLoading(true);
dispatch(initiateGetJobs({ description, location, full_time, page }))
.then(() => {
setIsLoading(false);
})
.catch(() => setIsLoading(false));
};
const handleSearch = (selection) => {
loadJobs(selection);
};
return (
<div>
<Header />
<Search onSearch={handleSearch} />
{!_.isEmpty(errors) && (
<div className="errorMsg">
<p>{errors.error}</p>
</div>
)}
<Results results={results} />
{isLoading && <p className="loading">Loading...</p>}
</div>
);
};
const mapStateToProps = (state) => ({
jobs: state.jobs,
errors: state.errors
});
export default connect(mapStateToProps)(HomePage);
在这个文件中,我们现在开始使用 React Hooks。如果你是 React Hooks 的新手,可以查看我之前的文章,了解 Hooks 的介绍。
让我们来理解一下组件的代码HomePage
。
首先,我们使用useState
钩子声明了状态变量,将 API 的结果存储在数组中,并使用标志位来显示加载状态,并声明了对象来指示错误。
const [results, setResults] = useState([]);
const [errors, setErrors] = useState(null);
const [isLoading, setIsLoading] = useState(false);
然后我们调用useEffect
Hook 来获取作业列表,并获取错误信息(如果有)
useEffect(() => {
setResults(props.jobs);
}, [props.jobs]);
useEffect(() => {
setErrors(props.errors);
}, [props.errors]);
我们通过将依赖项数组作为第二个参数传递,使用钩子来实现componentDidUpdate
类组件的生命周期方法。因此,每个钩子仅在其依赖项发生变化时才会执行。例如,当依赖项发生变化时。由于我们在文件末尾添加了一个方法,因此数据可以在 props 中使用。useEffect
useEffect
props.jobs
props.errors
mapStateToProps
const mapStateToProps = (state) => ({
jobs: state.jobs,
errors: state.errors
});
并将其传递给连接库的方法react-redux
。
export default connect(mapStateToProps)(HomePage);
然后,我们将onSearch
prop 传递给Search
值为handleSearch
函数的组件。
<Search onSearch={handleSearch} />
在这个函数内部,我们调用调用动作创建器函数loadJobs
的函数initiateGetJobs
来向服务器发出 API 调用Express
。
我们正在将onSearch
prop 传递给Search
组件,但我们还没有使用它,所以让我们先使用它。
打开 Search.js 组件并更改
const handleSearch = (event) => {
event.preventDefault();
console.log(state);
};
到
const handleSearch = (event) => {
event.preventDefault();
console.log(state);
props.onSearch(state);
};
所以现在,当我们单击Search
按钮时,我们正在调用从组件传递给组件的onSearch
prop 的函数。Search
HomePage
现在,让我们运行该应用程序。在运行之前,我们需要做一些更改。
打开server/package.json
文件并在其中添加启动脚本
"start": "nodemon server.js"
因此,package.json
来自server
文件夹将如下所示
{
"name": "server",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start": "nodemon server.js"
},
"dependencies": {
"axios": "0.19.2",
"cors": "2.8.5",
"express": "4.17.1",
"nodemon": "^2.0.4",
}
}
server
现在,从文件夹执行启动命令
yarn run start
这将启动Express
服务器。
打开另一个终端,并从github-jobs-react-app
文件夹中执行yarn run start
命令。这将启动你的 React 应用程序。
description
和location
是可选参数,因此如果Github Jobs API
您不输入任何值并单击Search
按钮,您将获得按发布日期排序显示在屏幕上的所有可用职位
数据按文件initiateGetJobs
内的函数创建日期排序actions/jobs.js
const sortedJobs = jobs.data.sort(
(a, b) =>
moment(new Date(b.created_at)) - moment(new Date(a.created_at))
);
如果你想深入了解此代码如何对数据进行排序的细节,请查看我之前的文章
您可以在此处找到到目前为止的源代码
显示作业详情页面
现在,当我们点击任意作业时,获取该作业的详细信息
JobDetails.js
在文件夹中创建一个新文件components
,内容如下
import React from 'react';
const JobDetails = ({ details, onResetPage }) => {
const {
type,
title,
description,
location,
company,
company_url,
company_logo,
how_to_apply
} = details;
return (
<div className="job-details">
<div className="back-link">
<a href="/#" onClick={onResetPage}>
<< Back to results
</a>
</div>
<div>
{type} / {location}
</div>
<div className="main-section">
<div className="left-section">
<div className="title">{title}</div>
<hr />
<div className="job-description">{description}</div>
</div>
<div className="right-section">
<div className="company-details">
<h3>About company</h3>
<img src={company_logo} alt={company} className="company-logo" />
<div className="company-name">{company}</div>
<a className="company-url" href={company_url}>
{company_url}
</a>
</div>
<div className="how-to-apply">
<h3>How to apply</h3>
<div>{how_to_apply}</div>
</div>
</div>
</div>
</div>
);
};
export default JobDetails;
这里我们展示的是工作细节的描述。
现在,我们需要一个标志来决定何时显示详细信息页面以及何时显示工作列表。
因此,在文件中创建一个新的状态变量,HomePage.js
其默认值为home
,并创建一个变量来跟踪被点击作业的 ID
const [jobId, setJobId] = useState(-1);
const [page, setPage] = useState('home');
打开HomePage.js
文件并将其替换为以下内容
import React, { useState, useEffect } from 'react';
import _ from 'lodash';
import { connect } from 'react-redux';
import { initiateGetJobs } from '../actions/jobs';
import { resetErrors } from '../actions/errors';
import Header from './Header';
import Search from './Search';
import Results from './Results';
import JobDetails from './JobDetails';
const HomePage = (props) => {
const [results, setResults] = useState([]);
const [errors, setErrors] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [jobId, setJobId] = useState(-1);
const [page, setPage] = useState('home');
useEffect(() => {
setResults(props.jobs);
}, [props.jobs]);
useEffect(() => {
setErrors(props.errors);
}, [props.errors]);
const loadJobs = (selection) => {
const { dispatch } = props;
const { description, location, full_time, page = 1 } = selection;
dispatch(resetErrors());
setIsLoading(true);
dispatch(initiateGetJobs({ description, location, full_time, page }))
.then(() => {
setIsLoading(false);
})
.catch(() => setIsLoading(false));
};
const handleSearch = (selection) => {
loadJobs(selection);
};
const handleItemClick = (jobId) => {
setPage('details');
setJobId(jobId);
};
const handleResetPage = () => {
setPage('home');
};
let jobDetails = {};
if (page === 'details') {
jobDetails = results.find((job) => job.id === jobId);
}
return (
<div>
<div className={`${page === 'details' && 'hide'}`}>
<Header /> <Search onSearch={handleSearch} />
{!_.isEmpty(errors) && (
<div className="errorMsg">
<p>{errors.error}</p>
</div>
)}
{isLoading && <p className="loading">Loading...</p>}
<div>
<Results results={results} onItemClick={handleItemClick} />
</div>
</div>
<div className={`${page === 'home' && 'hide'}`}>
<JobDetails details={jobDetails} onResetPage={handleResetPage} />
</div>
</div>
);
};
const mapStateToProps = (state) => ({
jobs: state.jobs,
errors: state.errors
});
export default connect(mapStateToProps)(HomePage);
在这个文件中,我们添加了handleItemClick
和handleResetPage
函数。
此外,当我们点击详情页时,我们会从results
数组中筛选出相应的职位
let jobDetails = {};
if (page === 'details') {
jobDetails = results.find((job) => job.id === jobId);
}
并将其传递给JobDetails
组件
<JobDetails details={jobDetails} onResetPage={handleResetPage} />
如果页面值为home
,则显示Header
,Search
和Results
组件;如果值为details
,则显示JobDetails
页面,因为我们添加了隐藏 CSS 类来显示相应的组件
注意,我们还将onItemClick
prop 传递给了Results
组件。
<Results results={results} onItemClick={handleItemClick} />
然后从Results
组件中,我们将其传递到JobItem
组件,并在该组件内部将该处理程序添加到最顶层的 div
<div className="job-item" index={index + 1} onClick={() => onItemClick(id)}>
我们从 props 中解构 id 并将其传递给onItemClick
函数
现在,通过运行命令重新启动React
应用程序和服务器并验证更改Express
yarn run start
现在,当我们点击任何工作时,我们都可以看到该工作的详细信息,但如果你注意到详细信息页面,你会看到详细信息页面的 HTML 按原样显示,这意味着
标签显示为静态文本而不是呈现段落。
这是因为,默认情况下,React 在 JSX 表达式中使用时不会直接显示 HTML 内容,以避免跨站点脚本 (XSS) 攻击。React 会对 JSX 表达式中提供的所有 HTML 内容(大括号内的内容)进行转义,因此会按原样打印。
如果您检查上述 API 响应,您会发现描述字段包含 HTML 内容,并且我们将描述打印在JobDetails.js
文件中
<div className="job-description">{description}</div>
此外,how to apply
在
<div>{how_to_apply}</div>
如果在我们的例子中需要显示 HTML 内容,我们需要使用一个特殊的 prop,dangerouslySetInnerHTML
并将字段中的 HTML 传递给它,__html
如下所示
<div className="job-description" dangerouslySetInnerHTML={{ __html: description }}></div>
和
<div dangerouslySetInnerHTML={{ __html: how_to_apply }}></div>
因此,在文件中进行这些更改JobDetails.js
并检查应用程序,您将看到 HTML 正确呈现
惊人的!
还有一件事,在构建应用程序时,每次测试时都向实际服务器发送请求是不好的,因此,通过从此处保存 API 的响应,在公共文件夹中创建一个新文件 jobs.json ,并在actions/jobs.js
文件中添加以下行的注释
const jobs = await axios.get(
`${BASE_API_URL}/jobs?description=${description}&location=${location}${full_time}${page}`
);
并在其下方添加以下代码。
const jobs = await axios.get('./jobs.json');
所以现在,每当我们点击搜索按钮时,我们都会从存储在公共文件夹中的 JSON 文件中获取数据,这将提供更快的响应,并且不会增加对实际 Github API 的请求数量。
如果您使用其他 API,它们可能会限制请求数量,如果超出限制,可能会向您收费。
注意:Github Jobs API 是免费的,不会根据请求数量向您收费,但最好使用缓存响应,并且仅在需要处理特定场景时才使用实际 API 而不是缓存 API。
您可以在此处找到到目前为止的代码
使用 Context API 避免 Prop Drilling
现在,如果您检查 HomePage 组件,我们将onItemClick
函数传递给Results
组件,而Results
组件将其传递给JobItem
组件而不使用它,因此为了避免这种 prop 钻探并使从HomePage
组件返回的 JSX 更简单,我们可以React Context API
在这里使用。
如果你不熟悉,请React Context API
查看我之前的文章
在文件夹中src/context
,创建一个jobs.js
包含以下内容的新文件
import React from 'react';
const JobsContext = React.createContext();
export default JobsContext;
在这里,我们只是创建一个Context
可以用来访问其他组件中的数据的文件
,HomePage.js
在文件顶部导入此上下文
import JobsContext from '../context/jobs';
在返回 JSX 之前,创建一个值对象,其中包含我们想要在其他组件中访问的数据
const value = {
results,
details: jobDetails,
onSearch: handleSearch,
onItemClick: handleItemClick,
onResetPage: handleResetPage
};
将返回的 JSX 从
return (
<div>
<div className={`${page === 'details' && 'hide'}`}>
<Header />
<Search onSearch={handleSearch} />
{!_.isEmpty(errors) && (
<div className="errorMsg">
<p>{errors.error}</p>
</div>
)}
{isLoading && <p className="loading">Loading...</p>}
<Results results={results} onItemClick={handleItemClick} />
</div>
<div className={`${page === 'home' && 'hide'}`}>
<JobDetails details={jobDetails} onResetPage={handleResetPage} />
</div>
</div>
);
到
return (
<JobsContext.Provider value={value}>
<div className={`${page === 'details' && 'hide'}`}>
<Header />
<Search />
{!_.isEmpty(errors) && (
<div className="errorMsg">
<p>{errors.error}</p>
</div>
)}
{isLoading && <p className="loading">Loading...</p>}
<Results />
</div>
<div className={`${page === 'home' && 'hide'}`}>
<JobDetails />
</div>
</JobsContext.Provider>
);
正如你所见,我们删除了传递给Search
、Results
和JobDetails
组件的所有 props,并且我们正在使用
<JobsContext.Provider value={value}>
传递所有这些值,因为Provider
组件需要一个值 prop,并且现在在开始和结束标签之间的所有组件都JobsContext.Provider
可以访问作为 prop 传递的值对象中的任何值。
现在,打开Search.js
文件并在顶部添加上下文的导入。同时useContext
在顶部导入钩子
import React, { useState, useContext } from 'react';
现在,要从值对象访问数据,请在Search
组件内添加以下代码
const { onSearch } = useContext(JobsContext);
现在,您可以删除传递给组件和handleSearch
函数内部的 props 参数,然后更改
props.onSearch(state);
只是
onSearch(state);
现在,你的Search
组件将如下所示
import React, { useState, useContext } from 'react';
import { Form, Button, Row, Col } from 'react-bootstrap';
import JobsContext from '../context/jobs';
const Search = () => {
const { onSearch } = useContext(JobsContext);
const [state, setState] = useState({
description: '',
location: '',
full_time: false
});
const handleInputChange = (event) => {
const { name, value } = event.target;
if (name === 'full_time') {
setState((prevState) => ({ ...state, [name]: !prevState.full_time }));
} else {
setState({ ...state, [name]: value });
}
};
const handleSearch = (event) => {
event.preventDefault();
console.log(state);
onSearch(state);
};
return (
<div className="search-section">
<Form className="search-form" onSubmit={handleSearch}>
<Row>
<Col>
<Form.Group controlId="description">
<Form.Control
type="text"
name="description"
value={state.description || ''}
placeholder="Enter search term"
onChange={handleInputChange}
/>
</Form.Group>
</Col>
<Col>
<Form.Group controlId="location">
<Form.Control
type="text"
name="location"
value={state.location || ''}
placeholder="Enter location"
onChange={handleInputChange}
/>
</Form.Group>
</Col>
<Col>
<Button variant="primary" type="submit" className="btn-search">
Search
</Button>
</Col>
</Row>
<div className="filters">
<Form.Group controlId="full_time">
<Form.Check
type="checkbox"
name="full_time"
className="full-time-checkbox"
label="Full time only"
checked={state.full_time}
onChange={handleInputChange}
/>
</Form.Group>
</div>
</Form>
</div>
);
};
export default Search;
Results
现在,让我们在组件中使用上下文
删除传递给组件的两个 props
在文件顶部导入上下文
import JobsContext from '../context/jobs';
从上下文中取出所需值
const { results } = useContext(JobsContext);
现在,您可以删除onItemClick
传递给JobItem
组件的prop
import React, { useContext } from 'react';
import JobItem from './JobItem';
import JobsContext from '../context/jobs';
const Results = () => {
const { results } = useContext(JobsContext);
return (
<div className="search-results">
{results.map((job, index) => (
<JobItem key={job.id} {...job} index={index} />
))}
</div>
);
};
export default Results;
现在,让我们重构JobDetails
组件
在文件顶部导入上下文
import JobsContext from '../context/jobs';
从上下文中取出所需值
const { details, onResetPage } = useContext(JobsContext);
现在,你的JobDetails.js
文件将如下所示
import React, { useContext } from 'react';
import JobsContext from '../context/jobs';
const JobDetails = () => {
const { details, onResetPage } = useContext(JobsContext);
const {
type,
title,
description,
location,
company,
company_url,
company_logo,
how_to_apply
} = details;
return (
<div className="job-details">
<div className="back-link">
<a href="/#" onClick={onResetPage}>
<< Back to results
</a>
</div>
<div>
{type} / {location}
</div>
<div className="main-section">
<div className="left-section">
<div className="title">{title}</div> <hr />
<div
className="job-description"
dangerouslySetInnerHTML={{ __html: description }}
></div>
</div>
<div className="right-section">
<div className="company-details">
<h3>About company</h3>
<img src={company_logo} alt={company} className="company-logo" />
<div className="company-name">{company}</div>
<a className="company-url" href={company_url}>
{company_url}
</a>
</div>
<div className="how-to-apply">
<h3>How to apply</h3>
<div dangerouslySetInnerHTML={{ __html: how_to_apply }}></div>
</div>
</div>
</div>
</div>
);
};
export default JobDetails;
现在,让我们重构JobItem
组件
在文件顶部导入上下文
import JobsContext from '../context/jobs';
从上下文中取出所需值
const { onItemClick } = useContext(JobsContext);
现在,你的JobItem.js
文件将如下所示
import React, { useContext } from 'react';
import moment from 'moment';
import JobsContext from '../context/jobs';
const JobItem = (props) => {
const { onItemClick } = useContext(JobsContext);
const {
id,
type,
created_at,
company,
location,
title,
company_logo,
index
} = props;
return (
<div className="job-item" index={index + 1} onClick={() => onItemClick(id)}>
<div className="company-logo">
<img src={company_logo} alt={company} width="100" height="100" />
</div>
<div className="job-info">
<div className="job-title">{title}</div>
<div className="job-location">
{location} | {type}
</div>
<div className="company-name">{company}</div>
</div>
<div className="post-info">
<div className="post-time">
Posted {moment(new Date(created_at)).fromNow()}
</div>
</div>
</div>
);
};
export default JobItem;
现在,检查您的应用程序,您会发现应用程序的工作方式与以前相同,但现在我们避免了不必要的 prop 钻孔并使代码更易于理解
您可以在此处找到到目前为止的代码
重置滚动位置
您可能已经注意到,当我们在工作列表上向下滚动一点并点击任何工作时,页面滚动仍然在同一位置,我们看到的是页面的底部而不是顶部
这是因为我们只是向单击任何作业时不需要的组件添加隐藏类,因此滚动位置不会改变。
要解决此问题,请打开JobDetail.js
文件并添加以下代码
useEffect(() => {
window.scrollTo(0, 0);
}, []);
所以现在,当JobDetails
组件显示时,我们会自动显示在页面顶部。
空数组指定该代码仅在组件安装时执行(类似于componentDidMount
生命周期方法),并且不再执行。
我们还需要确保,JobDetails
只有当我们点击任何作业时才会加载组件,因此打开HomePage.js
文件并更改
<div className={`${page === 'home' && 'hide'}`}>
<JobDetails />
</div>
到
<div className={`${page === 'home' && 'hide'}`}>
{page === 'details' && <JobDetails />}
</div>
现在,如果您检查应用程序,您会看到单击任何工作时都会显示页面顶部。
添加加载更多功能
50
我们已经知道,当我们访问 Github Jobs API 时,我们只能获得最新的工作,为了获得更多的工作,我们需要传递page
带有递增数字的查询参数,因此让我们在应用程序中实现加载更多功能。
让我们创建一个pageNumber
状态变量,其初始HomePage.js
值为1
selection
const [pageNumber, setPageNumber] = useState(1);
const [selection, setSelection] = useState(null);
添加代码以在HomePage.js
文件中显示加载更多按钮
{
results.length > 0 && _.isEmpty(errors) && (
<div className="load-more" onClick={isLoading ? null : handleLoadMore}>
<button disabled={isLoading} className={`${isLoading ? 'disabled' : ''}`}>
Load More Jobs
</button>
</div>
);
}
另外,将加载条件从之前移到之后
因此你的 JSX 返回表单HomePage.js
将如下所示
return (
<JobsContext.Provider value={value}>
<div className={`${page === 'details' && 'hide'}`}>
<Header /> <Search />
{!_.isEmpty(errors) && (
<div className="errorMsg">
<p>{errors.error}</p>
</div>
)}
<Results />
{isLoading && <p className="loading">Loading...</p>}
{results.length > 0 && _.isEmpty(errors) && (
<div className="load-more" onClick={isLoading ? null : handleLoadMore}>
<button
disabled={isLoading}
className={`${isLoading ? 'disabled' : ''}`}
>
Load More Jobs
</button>
</div>
)}
</div>
<div className={`${page === 'home' && 'hide'}`}>
{page === 'details' && <JobDetails />}
</div>
</JobsContext.Provider>
);
class
在上面的“添加更多按钮”div 中,我们通过添加 disabled和disabled
属性,在用户点击按钮后禁用该按钮
className={`${isLoading ? 'disabled' : ''}`}
我们还确保handleLoadMore
按钮禁用时不会执行该函数,因此通过null
从onClick
处理程序返回来禁用按钮。如果用户在开发工具中编辑并移除了禁用属性,这将非常有用。
现在在组件handleLoadMore
内添加函数HomePage
const handleLoadMore = () => {
loadJobs({ ...selection, page: pageNumber + 1 });
setPageNumber(pageNumber + 1);
};
现在,我们将递增的页码传递给loadJobs
函数,但我们需要进一步将其传递给我们的动作调度函数,因此在loadJobs
函数内部dispatch(resetErrors());
添加以下代码
let isLoadMore = false;
if (selection.hasOwnProperty('page')) {
isLoadMore = true;
}
并将 isLoadMore 作为最后一个参数传递给initiateGetJobs
函数。
因此你的loadJobs
函数将如下所示
const loadJobs = (selection) => {
const { dispatch } = props;
const { description, location, full_time, page = 1 } = selection;
let isLoadMore = false;
if (selection.hasOwnProperty('page')) {
isLoadMore = true;
}
dispatch(resetErrors());
setIsLoading(true);
dispatch(
initiateGetJobs({ description, location, full_time, page }, isLoadMore)
)
.then(() => {
setIsLoading(false);
})
.catch(() => setIsLoading(false));
};
并在函数内部handleSearchction
调用setSelection
设置状态的函数
const handleSearch = (selection) => {
loadJobs(selection);
setSelection(selection);
};
现在,打开actions/jobs.js
文件并接受isLoadMore
作为第二个参数
export const initiateGetJobs = (data, isLoadMore) => {
并改变
return dispatch(setJobs(sortedJobs));
到
if (isLoadMore) {
return dispatch(setLoadMoreJobs(sortedJobs));
} else {
return dispatch(setJobs(sortedJobs));
}
在此代码中,如果单击“加载更多”按钮,则我们将调用setLoadMoreJobs
函数将新作业添加到已经存在的results
数组中。
如果isLoadMore
为假,则意味着我们点击了Search
页面上的按钮,然后我们调用setJobs
函数将结果添加到新数组中。
现在,React
通过运行yarn run start
命令重新启动应用程序,您可以看到加载更多功能正在按预期工作。
您可以在此处找到到目前为止的代码
为 Overlay 创建自定义加载器组件
但你会注意到,我们把加载消息移到了“加载更多”按钮的上方。所以,如果在结果显示完毕后,我们在“描述”和“位置”字段中输入一些值,点击Search
按钮时,我们将看不到加载消息,因为需要滚动页面。这可不是好的用户体验。
另外,即使显示了加载消息,用户在加载过程中仍然可以点击任何作业,这也是意料之外的。
因此,让我们创建自己的加载器,用来React Portal
显示覆盖层,这样用户在加载时将无法点击任何作业,并且我们也能清楚地看到加载指示。
如果你不知道,请React Portal
查看我之前的文章
Loader.js
在文件夹中创建一个新文件components
,内容如下
import { useState, useEffect } from 'react';
import ReactDOM from 'react-dom';
const Loader = (props) => {
const [node] = useState(document.createElement('div'));
const loader = document.querySelector('#loader');
useEffect(() => {
loader.appendChild(node).classList.add('message');
}, [loader, node]);
useEffect(() => {
if (props.show) {
loader.classList.remove('hide');
document.body.classList.add('loader-open');
} else {
loader.classList.add('hide');
document.body.classList.remove('loader-open');
}
}, [loader, props.show]);
return ReactDOM.createPortal(props.children, node);
};
export default Loader;
现在打开public/index.html
并在带有 id 的 div 旁边root
添加另一个带有 id 的 divloader
<div id="root"></div>
<div id="loader"></div>
ReactDOM.createPortal
我们使用的方法会Loader.js
在 div 内部创建一个带有 id 的加载器,loader
因此它会位于React
应用程序 DOM 层次结构的外部,因此我们可以使用它为整个应用程序提供覆盖层。这就是使用 来创建加载器的主要原因React Portal
。
因此,即使我们将Loader
组件包含在文件中HomePage.js
,它也将在所有 div 之外但在带有 id 加载器的 div 内部呈现。
在Loader.js
文件中,我们首先创建一个 div,在其中添加加载器消息
const [node] = useState(document.createElement('div'));
然后,我们将message
类添加到该 div,并将该 div 添加到添加的 divindex.html
document.querySelector('#loader').appendChild(node).classList.add('message');
并根据从HomePage
组件传递的 show 属性,我们将添加或删除hide
类,然后最终Loader
使用
ReactDOM.createPortal(props.children, node);
然后,我们loader-open
向页面的 body 标签添加或删除类,这将禁用或启用页面的滚动
document.body.classList.add('loader-open');
document.body.classList.remove('loader-open');
在这里,我们将在开始和结束标签之间传递的数据Loader
将在内部可用,props.children
以便我们可以显示一个简单的加载消息,或者我们可以包含一个要显示为加载器的图像。
现在,让我们使用这个组件
打开HomePage.js
文件并在行后<JobsContext.Provider value={value}>
添加 Loader 组件
<Loader show={isLoading}>Loading...</Loader>
Loader
另外,在文件顶部导入
import Loader from './Loader';
现在,您可以删除之前使用的以下行
{
isLoading && <p className="loading">Loading...</p>;
}
那么,什么时候我们才会停止加载更多商品呢?
显然,当没有更多商品时。
当没有更多作业时,将返回一个空数组,您可以通过向此处的 API 传递更大的页码来Github Jobs API
检查。[]
因此,为了处理打开的HomePage.js
文件并在loadJobs
函数中,在.then
处理程序中添加以下代码
if (response && response.jobs.length === 0) {
setHideLoadMore(true);
} else {
setHideLoadMore(false);
}
setIsLoading(false);
所以你的loadJobs
函数将会是这样的
const loadJobs = (selection) => {
const { dispatch } = props;
const { description, location, full_time, page = 1 } = selection;
let isLoadMore = false;
if (selection.hasOwnProperty('page')) {
isLoadMore = true;
}
dispatch(resetErrors());
setIsLoading(true);
dispatch(
initiateGetJobs({ description, location, full_time, page }, isLoadMore)
)
.then((response) => {
if (response && response.jobs.length === 0) {
setHideLoadMore(true);
} else {
setHideLoadMore(false);
}
setIsLoading(false);
})
.catch(() => setIsLoading(false));
};
添加另一个状态变量
const [hideLoadMore, setHideLoadMore] = useState(false);
对于加载更多按钮代码,更改
{results.length > 0 && _.isEmpty(errors) && (
到
{results.length > 0 && _.isEmpty(errors) && !hideLoadMore && (
因此,我们只需添加一个额外的!hideLoadMore
条件,现在,如果没有更多来自响应的工作,我们将隐藏加载更多工作按钮。
现在,如果你检查你的应用程序,你会发现,如果点击“加载更多职位”按钮时没有其他职位需要加载,它就不会显示。Loader
像这样在开始和结束标签之间添加要显示的数据的好处在于
<Loader show={isLoading}>Loading...</Loader>
是,我们可以在标签之间包含任何内容,甚至是图像,并且该图像将显示而不是Loading
文本,因为我们使用props.children
在加载器 div 内显示
ReactDOM.createPortal(props.children, node);
您可以在此处找到到目前为止的代码
添加延迟加载图像功能
正如您现在所知,当我们从 Jobs API 发出请求时,我们最初会获得一份工作列表,50
并且当我们在列表页面上显示公司徽标时,浏览器必须下载这些50
图像,这可能需要一些时间,因此您有时可能会在图像完全加载之前看到空白区域。
此外,如果您在移动设备上浏览应用程序并且使用的网络连接速度较慢,则可能需要更多时间来下载图像,并且MB
即使您没有滚动页面查看其他工作列表,浏览器也可能会下载大量不必要的图像,这不是很好的用户体验。
如果您检查当前功能,直到我们单击“搜索”按钮而不输入任何值时,对我来说,总共有大约 个99
请求,占用了2MB
数据。
我们可以通过延迟加载图片来解决这个问题。这样,直到用户滚动到列表中的作业时,图片才会被下载,这样效率更高。
那么我们就从它开始吧。
observer.js
在文件夹中创建一个新文件custom-hooks
,内容如下
import { useEffect, useState } from 'react';
const useObserver = (targetRef) => {
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
if (!isVisible) {
setIsVisible(true);
}
observer.unobserve(entry.target);
} else {
setIsVisible(false);
}
});
});
const current = targetRef.current;
observer.observe(current);
return () => {
observer.unobserve(current);
};
}, [isVisible, targetRef]);
return [isVisible];
};
export default useObserver;
在这个文件中,我们使用 Intersection Observer API 来识别页面当前显示的哪个区域,并且只下载该区域内的图像。
如果您不了解 Intersection Observer,请查看我之前的文章,其中详细解释了如何在 JavaScript 中进行延迟加载、滑动动画和滚动播放/暂停视频。
因此,在observer.js
文件中,我们获取一个 ref,并将其添加ref
到观察者中
observer.observe(current);
如果添加的图像ref
显示在屏幕上,那么我们将调用setIsVisible(true);
并从这个自定义钩子返回theisVisible
值,并且根据theisVisible
标志我们可以决定是否要显示图像。
因此打开JobItem.js
文件并为我们刚刚创建的自定义钩子添加一个导入
import useObserver from '../custom-hooks/observer';
useRef
在文件顶部导入钩子
import React, { useRef } from 'react';
创建一个ref
我们可以分配给图像的
const imageRef = useRef();
调用自定义钩子并获取isVisible
值
const [isVisible] = useObserver(imageRef);
改变
<div className="company-logo">
<img src={company_logo} alt={company} width="100" height="100" />
</div>
到
<div className="company-logo" ref={imageRef}>
{isVisible && (
<img src={company_logo} alt={company} width="100" height="100" />
)}
</div>
现在,React
通过运行重新启动您的应用程序yarn run start
并检查延迟加载功能。
正如您所看到的,最初只5
发送请求并且只下载两个徽标图像,当您滚动页面时,将下载接下来显示的图像。
这比以前一次性下载所有图片的体验好多了,页面加载速度也更快,节省网络带宽。
您可以在此处找到到目前为止的代码
添加默认加载图像
如果您注意到,即使我们延迟加载图像,最初您也会看到空白区域而不是图像,直到图像完全加载。
我们可以通过提供替代图像来解决此问题,并在完全下载后将其替换为原始图像。
这样我们就可以避免空白区域,并且是一种不显示空白图像区域的广泛使用的方法。
从此处下载加载器图像并将其添加到 src/images 文件夹中
用于创建图像的网站是这个。
您可以指定所需图像的width
、height
和。text
用于生成该加载图像的 URL 是这样的
https://via.placeholder.com/100x100?text=Loading
Image.js
在文件夹中创建一个新文件components
,内容如下
import React from 'react';
import { useState } from 'react';
import loading from '../images/loading.png';
/* https://via.placeholder.com/100x100?text=Loading */
const Image = ({ src, alt, ...props }) => {
const [isVisible, setIsVisible] = useState(false);
const changeVisibility = () => {
setIsVisible(true);
};
return (
<React.Fragment>
<img
src={loading}
alt={alt}
width="100"
height="100"
style={{ display: isVisible ? 'none' : 'inline' }}
{...props}
/>
<img
src={src}
alt={alt}
width="100"
height="100"
onLoad={changeVisibility}
style={{ display: isVisible ? 'inline' : 'none' }}
{...props}
/>
</React.Fragment>
);
};
export default Image;
在这个文件中,我们最初显示的是加载图像而不是实际图像。
标签img
已onLoad
添加处理程序,当图像完全加载时将触发该处理程序,我们将isVisible
标志设置为 true,一旦为 true,我们将显示该图像并使用显示 CSS 属性隐藏之前加载的图像。
现在打开JobItem.js
文件并更改
{
isVisible && (
<img src={company_logo} alt={company} width="100" height="100" />
);
}
到
{
isVisible && (
<Image src={company_logo} alt={company} width="100" height="100" />
);
}
另外,Image
在文件顶部导入组件
import Image from './Image';
注意,我们刚刚更改img
为,并且我们正在访问组件Image
中的附加道具Image
const Image = ({ src, alt, ...props }) => {
因此,除了宽度、高度等所有其他道具外src
,alt
其他所有道具都将存储在名为道具的数组中,然后我们通过传播道具数组将这些道具传递给实际图像,{...props}
我们可以在详细信息页面上为公司徽标添加相同的功能。
打开JobDetails.js
文件并更改
<img src={company_logo} alt={company} className="company-logo" />
到
<Image src={company_logo} alt={company} className="company-logo" />
另外,Image
在文件顶部导入组件
import Image from './Image';
现在,React
通过运行重新启动您的应用程序yarn run start
并检查它
这就是本文的全部内容。
您可以在此处找到此应用程序的完整 Github 源代码,并在此处找到现场演示
不要忘记订阅我的每周新闻通讯,其中包含精彩的提示、技巧和文章,直接发送到您的收件箱中。
文章来源:https://dev.to/myogeshchavan97/build-an-amazing-job-search-app-using-react-42p