使用 FaunaDB 和 Netlify Serverless 函数创建一个书签管理器应用程序
介绍
本文将介绍如何使用 FaunaDB GraphQL API 和 Netlify 无服务器函数创建一个书签管理器应用程序。
通过使用 FaunaDB 创建此应用程序,您将了解 FaunaDB 如何帮助快速创建 GraphQL 应用程序,而无需担心自行管理 GraphQL 服务器及其配置。
Fauna 带有 GraphQL playground,所以你只需要提供 schema,Fauna 就会在幕后完成所有神奇的操作。
您可以在下方视频中观看最终版应用程序的实时演示。
- 如果您是 GraphQL 和 Apollo Client 的新手,请查看我之前的文章,了解GraphQL 的基础知识。
- 如果你是 Serverless 函数(Lambda 函数)的新手,可以看看我之前的文章(链接在此)。
FaunaDB GraphQL 配置
- 使用您的 GitHub / Netlify 帐户或电子邮件和密码登录 FaunaDB。
- 登录后,点击按钮创建新数据库
NEW DATABASE。
- 输入数据库名称,然后点击
SAVE按钮
- 您将看到以下屏幕
- 点击倒数
GRAPHQL第二个位置显示的菜单
- 在桌面上创建一个名为 `<filename>` 的新文件
bookmarks.graphql,并将以下代码添加到该文件中:
type Bookmark {
title: String!
url: String!
tag: String!
}
type Query {
bookmarks: [Bookmark!]!
}
- 现在,点击
IMPORT SCHEMA上图所示的按钮,然后选择bookmarks.graphql文件
如您所见,Fauna 会立即为您创建查询和变更,并提供一个友好的 GraphQL Playground,您可以在其中运行查询和变更来执行 CRUD(创建、读取、更新和删除)操作。Fauna 还将所有数据存储在其自身的数据库中,因此您的应用程序无需连接其他数据库。
- 现在,点击
SECURITY菜单下方的菜单GRAPHQL,然后点击NEW KEY按钮创建一个密钥,您可以使用该密钥向 FaunaDB 发出 API 请求。
- 输入您想要为密钥指定的名称,并在下拉菜单
Server中选择相应选项Role,然后点击SAVE按钮。
- 请记下您生成的密钥,因为它将不会再次显示,请妥善保管。
PS:请勿使用上方截图中显示的密钥,我已经重新生成了新密钥,所以上面的密钥无法使用😊。另外,请勿与任何人分享您的密钥,否则任何人都可以篡改您的数据。
现在,让我们开始编写书签管理应用程序的代码。
初始设置
使用以下命令创建新项目create-react-app:
create-react-app bookmark-manager
项目创建完成后,删除文件夹中的所有文件,并在该文件夹内src创建index.js文件。同时,在该文件夹内创建、、、和文件夹。styles.scsssrcactionscomponentscustom-hooksreducersrouterstoreutilssrc
安装必要的依赖项:
yarn add @apollo/client@3.1.4 apollo-boost@0.4.9 axios@0.20.0 bootstrap@4.5.2 cross-fetch@3.0.5 dotenv@8.2.0 graphql@15.3.0 lodash@4.17.20 node-sass@4.14.1 react-bootstrap@1.3.0 redux@4.0.5 react-redux@7.2.1 react-router-dom@5.2.0 redux-thunk@2.3.0 subscriptions-transport-ws@0.9.18 uuid@8.3.0
打开并把这里styles.scss的内容添加到里面。
编写代码
在 components 文件夹内创建一个名为 Header.js 的新文件,并添加以下内容:
import React from 'react';
import { Link } from 'react-router-dom';
const Header = () => {
return (
<header className="header">
<h1 className="main-heading">Bookmark Manager</h1>
<div className="header-links">
<Link to="/add" className="link">
Add Bookmark
</Link>
<Link to="/" className="link">
Bookmarks List
</Link>
</div>
</header>
);
};
export default Header;
BookmarkSearch.js在文件夹内创建一个新文件components,内容如下:
import React, { useState } from 'react';
import { Form } from 'react-bootstrap';
const BookmarkSearch = ({ handleSearch }) => {
const [searchTerm, setSearchTerm] = useState('');
const handleInputChange = (event) => {
const value = event.target.value;
setSearchTerm(value);
handleSearch(value);
};
return (
<div className="bookmark-search">
<Form>
<Form.Group controlId="location">
<Form.Control
type="text"
name="searchTerm"
className="searchTerm"
value={searchTerm || ''}
placeholder="Search by title or url"
onChange={handleInputChange}
autoComplete="off"
/>
</Form.Group>
</Form>
</div>
);
};
export default BookmarkSearch;
在这个文件中,我们添加了一个输入搜索框,用于搜索书签列表。
constants.js在文件夹内创建一个新文件utils,内容如下:
export const SET_BOOKMARKS = 'SET_BOOKMARKS';
export const ADD_BOOKMARK = 'ADD_BOOKMARK';
export const EDIT_BOOKMARK = 'EDIT_BOOKMARK';
export const DELETE_BOOKMARK = 'DELETE_BOOKMARK';
export const GET_ERRORS = 'GET_ERRORS';
export const TAGS = [
'All',
'React',
'Node.js',
'JavaScript',
'Beginners',
'Other'
];
在这个文件中,我们创建了要在 redux 中使用的常量,以及一组标签,我们可以将每个书签分组到这些标签中。
Filters.js在文件夹内创建一个新文件components,内容如下:
import React from 'react';
import { TAGS } from '../utils/constants';
const Filters = ({ activeFilter, filterResults, handleFilterClick }) => {
const handleClick = (tag) => {
filterResults(tag);
handleFilterClick(tag);
};
return (
<div className="filters-list">
<div className="filters">
{TAGS.map((tag, index) => (
<div
key={index}
onClick={() => handleClick(tag)}
className={activeFilter === tag ? 'active' : ''}
>
{tag}
</div>
))}
</div>
</div>
);
};
export default Filters;
在这个文件中,我们将遍历文件中添加的标签列表constant.js,并将其显示在屏幕上。
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;
在这个文件中,我们创建了一个加载器组件,它将显示带有背景叠加层的加载消息。
要将其添加到 DOM 中,请打开public/index.html文件,并在具有 id 的 div 元素之后root添加另一个具有 id 的 div 元素。loader
<div id="root"></div>
<div id="loader"></div>
BookmarkItem.js在文件夹内创建一个新文件components,内容如下:
import React from 'react';
import { Button } from 'react-bootstrap';
const BookmarkItem = ({ _id, title, url, tag, handleEdit, handleDelete }) => {
return (
<div className="bookmark">
<div>
<div className="title">
<strong>Title: </strong>
{title}
</div>
<div className="url">
<strong>URL: </strong>
{url}
</div>
<div className="tag">
<strong>Tag: </strong>
{tag}
</div>
</div>
<div className="buttons">
<div className="btn">
<Button
variant="info"
type="submit"
size="sm"
onClick={() => handleEdit(_id)}
>
Edit
</Button>
</div>
<div className="btn">
<Button
variant="danger"
type="submit"
size="sm"
onClick={() => handleDelete(_id, title)}
>
Delete
</Button>
</div>
</div>
</div>
);
};
export default BookmarkItem;
在这个文件中,我们显示了带有按钮的各个edit书签delete。
BookmarkList.js在文件夹内创建一个新文件components,内容如下:
import React from 'react';
import BookmarkItem from './BookmarkItem';
const BookmarkList = ({ bookmarks, handleEdit, handleDelete }) => {
return (
<div className="bookmarks-list">
{bookmarks.map((bookmark) => (
<BookmarkItem
key={bookmark._id}
{...bookmark}
handleEdit={handleEdit}
handleDelete={handleDelete}
/>
))}
</div>
);
};
export default BookmarkList;
在这个文件中,我们将遍历书签列表并将其显示在屏幕上。
useLoader.js在文件夹内创建一个新文件custom-hooks,内容如下:
import { useState } from 'react';
const useLoader = () => {
const [isLoading, setIsLoading] = useState(false);
const showLoader = () => {
setIsLoading(true);
};
const hideLoader = () => {
setIsLoading(false);
};
return { isLoading, showLoader, hideLoader };
};
export default useLoader;
在这个文件中,我们将显示和隐藏加载器分离到一个自定义钩子中。
BookmarkForm.js在文件夹内创建一个新文件components,内容如下:
import React, { useState } from 'react';
import { Form, Button } from 'react-bootstrap';
import { TAGS } from '../utils/constants';
const BookmarkForm = (props) => {
const [state, setState] = useState({
title: props.title ? props.title : '',
url: props.url ? props.url : '',
tag: props.tag ? props.tag : 'React',
tags: TAGS,
errorMsg: ''
});
const onInputChange = (event) => {
const { name, value } = event.target;
setState((prevState) => ({
...prevState,
[name]: value
}));
};
const onFormSubmit = (event) => {
event.preventDefault();
const { title, url, tag } = state;
const { _id } = props;
const isEditPage = !!props.title;
if (title.trim() !== '' && url.trim() !== '' && tag.trim() !== '') {
let data = { title, url, tag };
if (isEditPage) {
data = { ...data, _id };
}
props.onSubmit(data);
} else {
setState((prevState) => ({
...prevState,
errorMsg: 'Please fill out all the fields.'
}));
}
};
const { title, url, tags, tag, errorMsg } = state;
return (
<form onSubmit={onFormSubmit}>
{errorMsg && <p className="errorMsg">{errorMsg}</p>}
<Form.Group controlId="title">
<Form.Label>Title</Form.Label>
<Form.Control
type="text"
name="title"
value={title || ''}
onChange={onInputChange}
/>
</Form.Group>
<Form.Group controlId="description">
<Form.Label>URL</Form.Label>
<Form.Control
type="text"
name="url"
value={url || ''}
onChange={onInputChange}
/>
</Form.Group>
<Form.Group controlId="amount">
<Form.Label>Tag</Form.Label>
<Form.Control
as="select"
name="tag"
value={tag || ''}
onChange={onInputChange}
>
{tags.map((tag, index) => (
<option key={index}>{tag}</option>
))}
</Form.Control>
</Form.Group>
<Button variant="info" type="submit">
Submit
</Button>
</form>
);
};
export default BookmarkForm;
在这个文件中,我们创建了一个用于添加和编辑书签功能的表单。
AddBookmark.js在文件夹内创建一个新文件components,内容如下:
import React from 'react';
import { connect } from 'react-redux';
import BookmarkForm from './BookmarkForm';
import { initiateAddBookmark } from '../actions/bookmarks';
import Loader from './Loader';
import useLoader from '../custom-hooks/useLoader';
const AddBookmark = (props) => {
const { isLoading, showLoader, hideLoader } = useLoader();
const onSubmit = (bookmark) => {
showLoader();
props.dispatch(initiateAddBookmark(bookmark)).then(() => {
hideLoader();
props.history.push('/');
});
};
return (
<div>
<Loader show={isLoading}>Loading...</Loader>
<BookmarkForm {...props} onSubmit={onSubmit} />
</div>
);
};
export default connect()(AddBookmark);
在这个文件中,我们添加了一个onSubmit处理程序,它会调用initiateAddBookmark函数将书签添加到 FaunaDB 中。我们initiateAddBookmark稍后会在本文中编写相应的代码。
EditBookmark.js在文件夹内创建一个新文件router,内容如下:
import React from 'react';
import { connect } from 'react-redux';
import _ from 'lodash';
import { Redirect } from 'react-router-dom';
import BookmarkForm from './BookmarkForm';
import { initiateEditBookmark } from '../actions/bookmarks';
import useLoader from '../custom-hooks/useLoader';
import Loader from './Loader';
const EditBookmark = (props) => {
const { isLoading, showLoader, hideLoader } = useLoader();
const onSubmit = (bookmark) => {
showLoader();
props.dispatch(initiateEditBookmark(bookmark)).then(() => {
hideLoader();
props.history.push('/');
});
};
return (
<div>
{!_.isEmpty(props.bookmark) ? (
<React.Fragment>
<Loader show={isLoading}>Loading...</Loader>
<BookmarkForm onSubmit={onSubmit} {...props} {...props.bookmark} />
</React.Fragment>
) : (
<Redirect to="/" />
)}
</div>
);
};
const mapStateToProps = (state, props) => ({
bookmark: state.bookmarks.find(
(bookmark) => bookmark._id === props.match.params.id
)
});
export default connect(mapStateToProps)(EditBookmark);
在这个文件中,当用户编辑书签后提交时,我们会调用initiateEditBookmark函数来更新 FaunaDB 中的书签。
Home.js在文件夹内创建一个新文件components,内容如下:
import React, { useState, useEffect } from 'react';
import { connect } from 'react-redux';
import {
initiateGetBookmarks,
initiateDeleteBookmark
} from '../actions/bookmarks';
import BookmarkList from './BookmarkList';
import BookmarkSearch from './BookmarkSearch';
import Filters from './Filters';
import Loader from './Loader';
import useLoader from '../custom-hooks/useLoader';
import { isMatch } from '../utils/functions';
const Home = ({ bookmarksList, errorMsg, dispatch, history }) => {
const [bookmarks, setBookmarks] = useState([]);
const [activeFilter, setActiveFilter] = useState('All');
const { isLoading, showLoader, hideLoader } = useLoader();
const getBookmarks = () => {
showLoader();
dispatch(initiateGetBookmarks())
.then(() => {
setBookmarks(bookmarksList);
hideLoader();
})
.catch(() => hideLoader());
};
useEffect(() => {
getBookmarks();
}, []);
useEffect(() => {
setBookmarks(bookmarksList);
}, [bookmarksList]);
const handleEdit = (id) => {
history.push(`/edit/${id}`);
};
const handleDelete = (id, title) => {
const shouldDelete = window.confirm(
`Are you sure you want to delete the bookmark with title ${title}?`
);
if (shouldDelete) {
showLoader();
dispatch(initiateDeleteBookmark(id))
.then(() => {
handleFilterClick('All');
hideLoader();
})
.catch(() => hideLoader());
}
};
const handleSearch = (searchTerm) => {
if (searchTerm) {
setBookmarks(
bookmarksList.filter((bookmark) => {
const isTagMatch = isMatch(bookmark.tag, activeFilter);
if (activeFilter !== '' && activeFilter !== 'All' && !isTagMatch) {
return false;
}
const isTitleMatch = isMatch(bookmark.title, searchTerm);
const isURLMatch = isMatch(bookmark.url, searchTerm);
if (isTitleMatch || isURLMatch) {
return true;
}
return false;
})
);
} else {
if (activeFilter !== 'All') {
setBookmarks(
bookmarksList.filter((bookmark) =>
isMatch(bookmark.tag, activeFilter)
)
);
} else {
setBookmarks(bookmarksList);
}
}
};
const filterResults = (tag) => {
if (tag !== 'All') {
setBookmarks(bookmarksList.filter((bookmark) => bookmark.tag === tag));
} else {
setBookmarks(bookmarksList);
}
};
const handleFilterClick = (tag) => {
setActiveFilter(tag);
};
return (
<React.Fragment>
<BookmarkSearch handleSearch={handleSearch} />
<Filters
filterResults={filterResults}
activeFilter={activeFilter}
handleFilterClick={handleFilterClick}
/>
{errorMsg && <p className="errorMsg">{errorMsg}</p>}
<Loader show={isLoading}>Loading...</Loader>
{bookmarks.length > 0 ? (
<BookmarkList
bookmarks={bookmarks}
handleEdit={handleEdit}
handleDelete={handleDelete}
/>
) : (
<p className="no-result">No bookmarks found.</p>
)}
</React.Fragment>
);
};
const mapStateToProps = (state) => ({
bookmarksList: state.bookmarks,
errorMsg: state.errorMsg
});
export default connect(mapStateToProps)(Home);
这是封装所有其他组件的主组件文件。
在这个文件中,首先,我们通过传递一个空数组作为第二个参数来调用钩子getBookmarks函数useEffect,这样该函数就只会运行一次。
useEffect(() => {
getBookmarks();
}, []);
在函数内部,getBookmarks我们将书签数组设置为使用返回的书签列表。setBookmarks(bookmarksList);
如果 Redux store 发生任何更新(例如书签被添加、编辑或删除),我们会将更新后的书签重新赋值给书签数组。
useEffect(() => {
setBookmarks(bookmarksList);
}, [bookmarksList]);
这类似于componentDidUpdate类的方法,如果 prop(通过 mapStateToProps 传递给组件的 prop)发生任何变化bookmarksList,则会执行此 useEffect。
然后,在方法内部,我们通过传递已编辑的书签 idhandleEdit将用户重定向到组件。EditBookmark
在该handleDelete方法内部,一旦用户确认删除,我们就会调用initiateDeleteBookmark删除书签的方法。
在该handleSearch方法内部,我们使用数组筛选方法检查书签列表中特定标签(activeFilter)内的标题或书签是否与搜索词匹配,并根据结果更新书签数组。
在该filterResults方法内部,我们根据点击的标签按钮来筛选书签。
AppRouter.js在文件夹内创建一个新文件router,内容如下:
import React from 'react';
import { BrowserRouter, Route, Switch } from 'react-router-dom';
import Home from '../components/Home';
import AddBookmark from '../components/AddBookmark';
import EditBookmark from '../components/EditBookmark';
import BookmarkList from '../components/BookmarkList';
import Header from '../components/Header';
const AppRouter = () => (
<BrowserRouter>
<div className="container">
<Header />
<div className="bookmark-form">
<Switch>
<Route component={Home} path="/" exact={true} />
<Route component={BookmarkList} path="/list" />
<Route component={AddBookmark} path="/add" />
<Route component={EditBookmark} path="/edit/:id" />
</Switch>
</div>
</div>
</BrowserRouter>
);
export default AppRouter;
在这里,我们使用库为各个页面设置了路由react-router-dom。
bookmarks.js在文件夹内创建一个新文件reducers,内容如下:
import {
SET_BOOKMARKS,
ADD_BOOKMARK,
EDIT_BOOKMARK,
DELETE_BOOKMARK
} from '../utils/constants';
const bookmarksReducer = (state = [], action) => {
switch (action.type) {
case SET_BOOKMARKS:
return action.bookmarks.reverse();
case ADD_BOOKMARK:
return [action.bookmark, ...state];
case EDIT_BOOKMARK:
return state.map((bookmark) => {
if (bookmark._id === action._id) {
return {
...bookmark,
...action.bookmark
};
} else {
return bookmark;
}
});
case DELETE_BOOKMARK:
return state.filter((bookmark) => bookmark._id !== action._id);
default:
return state;
}
};
export default bookmarksReducer;
在这个 reducer 文件中,对于SET_BOOKMARKSaction 类型,我们按相反的顺序返回书签,这样在 UI 上显示时,组件首次加载时,最新添加的书签将显示在顶部。
在ADD_BOOKMARK操作类型中,我们通过将新添加的书签作为数组的第一个元素来返回数组,然后使用扩展运算符将所有其他书签附加到数组中。
在EDIT_BOOKMARK操作类型中,我们使用数组映射方法检查传递的 id 是否与书签数组中的任何 id 匹配,如果匹配,则通过展开书签的所有属性,然后展开书签的更新值,返回一个新对象。
例如,如果它bookmark看起来像这样:
{_id: "276656761265455623221", title: "FaunaDB", url: "https://fauna.com/", tag: "React"}
看起来action.bookmark是这样的:
{_id: "276656761265455623221", title: "FaunaDB Website", url: "https://fauna.com/", tag: "React"}
其中,仅更改标题,使用扩展运算符后的{...bookmark, ...action.bookmark}结果将为:
{_id: "276656761265455623221", title: "FaunaDB", url: "https://fauna.com/", tag: "React", _id: "276656761265455623221", title: "FaunaDB Website", url: "https://fauna.com/", tag: "React"}
因此,如果已经存在同名键,则后添加的键的值将覆盖前一个键的值。所以最终结果将是:
{_id: "276656761265455623221", title: "FaunaDB Website", url: "https://fauna.com/", tag: "React"}
在DELETE_BOOKMARK操作类型中,我们使用数组过滤器方法删除具有匹配 _id 的书签。
errors.js在文件夹内创建一个新文件reducers,内容如下:
import { GET_ERRORS } from '../utils/constants';
const errorsReducer = (state = '', action) => {
switch (action.type) {
case GET_ERRORS:
return action.errorMsg;
default:
return state;
}
};
export default errorsReducer;
在这个文件中,我们会添加在添加、编辑或删除书签时,来自 FaunaDB 的任何错误消息。
store.js在文件夹内创建一个新文件store,内容如下:
import { createStore, applyMiddleware, compose, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import bookmarksReducer from '../reducers/bookmarks';
import errorsReducer from '../reducers/errors';
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const store = createStore(
combineReducers({
bookmarks: bookmarksReducer,
errorMsg: errorsReducer
}),
composeEnhancers(applyMiddleware(thunk))
);
store.subscribe(() => {
console.log(store.getState());
});
export default store;
在这里,我们创建了一个 redux store,bookmarksReducer并将errorsReducer其组合在一起,以便我们可以从AppRouter.js文件中定义的任何组件访问 store 数据。
functions.js在文件夹内创建一个新文件utils,内容如下:
export const isMatch = (original, search) =>
original.toLowerCase().indexOf(search.toLowerCase()) > -1;
现在,打开src/index.js文件并将以下内容添加到其中:
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import AppRouter from './router/AppRouter';
import store from './store/store';
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles.scss';
ReactDOM.render(
<Provider store={store}>
<AppRouter />
</Provider>,
document.getElementById('root')
);
在这里,我们添加了一个Provider组件,该组件会将 redux store 传递给组件中声明的所有路由AppRouter。
bookmarks.js在文件夹内创建一个新文件actions,内容如下:
import axios from 'axios';
import {
SET_BOOKMARKS,
ADD_BOOKMARK,
EDIT_BOOKMARK,
DELETE_BOOKMARK
} from '../utils/constants';
import { getErrors } from './errors';
export const setBookmarks = (bookmarks) => ({
type: SET_BOOKMARKS,
bookmarks
});
export const addBookmark = (bookmark) => ({
type: ADD_BOOKMARK,
bookmark
});
export const editBookmark = (bookmark) => ({
type: EDIT_BOOKMARK,
_id: bookmark._id,
bookmark
});
export const deleteBookmark = (_id) => ({
type: DELETE_BOOKMARK,
_id
});
export const initiateGetBookmarks = () => {
return async (dispatch) => {
try {
const { data } = await axios({
url: '/api/getBookmarks',
method: 'POST'
});
return dispatch(setBookmarks(data));
} catch (error) {
error.response && dispatch(getErrors(error.response.data));
}
};
};
export const initiateAddBookmark = (bookmark) => {
return async (dispatch) => {
try {
const { data } = await axios({
url: '/api/addBookmark',
method: 'POST',
data: bookmark
});
return dispatch(addBookmark(data));
} catch (error) {
error.response && dispatch(getErrors(error.response.data));
}
};
};
export const initiateEditBookmark = (bookmark) => {
return async (dispatch) => {
try {
const { data } = await axios({
url: '/api/editBookmark',
method: 'PUT',
data: bookmark
});
return dispatch(editBookmark(data));
} catch (error) {
error.response && dispatch(getErrors(error.response.data));
}
};
};
export const initiateDeleteBookmark = (_id) => {
return async (dispatch) => {
try {
const { data } = await axios({
url: '/api/deleteBookmark',
method: 'DELETE',
data: { _id }
});
return dispatch(deleteBookmark(data._id));
} catch (error) {
error.response && dispatch(getErrors(error.response.data));
}
};
};
errors.js在文件夹内创建一个新文件actions,内容如下:
import { GET_ERRORS } from '../utils/constants';
export const getErrors = (errorMsg) => ({
type: GET_ERRORS,
errorMsg
});
在项目文件夹内创建一个新文件.env,内容如下:
FAUNA_GRAPHQL_SECRET_KEY=your_fauna_secret_key
请在此处使用您的 faunaDB 私钥。
打开.gitignore文件并添加.env新行,这样该.env文件就不会被推送到 Git 仓库。
在项目文件夹内创建一个新文件netlify.toml,内容如下:
[build]
command="CI= yarn run build"
publish="build"
functions="functions"
[[redirects]]
from="/api/*"
to="/.netlify/functions/:splat"
status=200
force=true
这是 Netlify 的配置文件,我们在这里指定构建配置。
让我们来详细分析一下。
- 该
command命令指定了创建生产构建文件夹所需的执行命令。此命令CI=专用于 Netlify,因此 Netlify 在部署应用程序时不会抛出错误。 - 它
publish指定用于部署应用程序的文件夹名称。 - 这
functions指定了存储我们所有无服务器函数的文件夹名称。 - 所有无服务器函数部署到 Netlify 后,都可通过 URL 访问,
/.netlify/functions/因此在进行 API 调用时,无需每次都指定完整路径,只需指示 Netlify,每当收到对 的请求时/api/function_name,将其重定向到 即可/.netlify/functions/function_name。 :splat明确指出,其后的所有内容都应在//api/之后使用。/.netlify/functions
functions在项目根目录下创建一个文件夹,我们将在其中编写无服务器函数。
在文件夹内functions创建一个新文件夹,并将以下内容的文件utils添加到该文件夹中:bookmarks.graphql
type Bookmark {
title: String!
url: String!
tag: String!
}
type Query {
bookmarks: [Bookmark!]!
}
client.js在文件夹内创建一个新文件functions/utils,内容如下:
const { ApolloClient, InMemoryCache, HttpLink } = require('@apollo/client');
const { API_URL } = require('./constants');
const fetch = require('cross-fetch');
require('dotenv').config();
const getClient = ({ method = 'POST' } = {}) => {
const client = new ApolloClient({
link: new HttpLink({
uri: API_URL,
fetch,
headers: {
Authorization: `Bearer ${process.env.FAUNA_GRAPHQL_SECRET_KEY}`
},
method
}),
cache: new InMemoryCache()
});
return client;
};
module.exports = { getClient };
constants.js在文件夹内创建一个新文件functions/utils,内容如下:
const API_URL = 'https://graphql.fauna.com/graphql';
const SET_BOOKMARKS = 'SET_BOOKMARKS';
const ADD_BOOKMARK = 'ADD_BOOKMARK';
const EDIT_BOOKMARK = 'EDIT_BOOKMARK';
const DELETE_BOOKMARK = 'DELETE_BOOKMARK';
module.exports = {
API_URL,
SET_BOOKMARKS,
ADD_BOOKMARK,
EDIT_BOOKMARK,
DELETE_BOOKMARK
};
请注意API_URL,这里的 URL 与我们正在使用的 FaunaDB GraphQL playground 中显示的 URL 相同。
queries.js在文件夹内创建一个新文件functions/utils,内容如下:
const { gql } = require('apollo-boost');
const GET_BOOKMARKS = gql`
query {
bookmarks {
data {
_id
title
url
tag
}
}
}
`;
const ADD_BOOKMARK = gql`
mutation($title: String!, $url: String!, $tag: String!) {
createBookmark(data: { title: $title, url: $url, tag: $tag }) {
_id
title
url
tag
}
}
`;
const EDIT_BOOKMARK = gql`
mutation($id: ID!, $title: String!, $url: String!, $tag: String!) {
updateBookmark(id: $id, data: { title: $title, url: $url, tag: $tag }) {
_id
title
url
tag
}
}
`;
const DELETE_BOOKMARK = gql`
mutation($id: ID!) {
deleteBookmark(id: $id) {
_id
}
}
`;
module.exports = {
GET_BOOKMARKS,
ADD_BOOKMARK,
EDIT_BOOKMARK,
DELETE_BOOKMARK
};
getBookmarks.js在文件夹内创建一个新文件functions,内容如下:
const { GET_BOOKMARKS } = require('./utils/queries');
const { getClient } = require('./utils/client');
exports.handler = async (event, context, callback) => {
try {
const client = getClient();
let { data } = await client.query({
query: GET_BOOKMARKS
});
const result = data.bookmarks.data;
return {
statusCode: 200,
body: JSON.stringify(result)
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify(
'Something went wrong while fetching bookmarks. Try again later.'
)
};
}
};
在这个文件中,我们实际上是向 FaunaDB GraphQL API 发出 API 调用,并将响应返回到文件initiateGetBookmarks中定义的函数,src/actions/bookmarks.js因为从该initiateGetBookmarks函数内部,我们正在调用一个/api/getBookmarks无functions/getBookmarks.js服务器函数。
运行应用程序
现在,让我们运行应用程序来查看输出结果。在此之前,我们需要安装netlify-clinpm 库,它将运行我们的无服务器函数以及 React 应用程序。
在终端中执行以下命令来安装库:
npm install netlify-cli -g
如果您使用的是 Linux/Mac 系统,则可能需要sudo在安装命令前添加一些参数才能全局安装:
sudo npm install netlify-cli -g
现在,在项目文件夹内的终端中运行以下命令来启动应用程序。
netlify dev
netlify dev该命令将首先运行我们的无服务器函数,然后运行我们的 React 应用程序,并且它会自动管理代理,因此在从 React 应用程序访问无服务器函数时不会出现 CORS 错误。
现在,访问http://localhost:8888/并检查应用程序。
添加书签
目前我们还没有添加任何书签,所以应用程序显示了No bookmarks found这条消息。那么,让我们添加一些书签吧。
addBookmark.js在文件夹内创建一个新文件functions,内容如下:
const { ADD_BOOKMARK } = require('./utils/queries');
const { getClient } = require('./utils/client');
exports.handler = async (event, context, callback) => {
try {
if (event.httpMethod !== 'POST') {
return {
statusCode: 405,
body: JSON.stringify({
error: 'only POST http method is allowed.'
})
};
}
const { title, url, tag } = JSON.parse(event.body);
const variables = { title, url, tag };
const client = getClient();
const { data } = await client.mutate({
mutation: ADD_BOOKMARK,
variables
});
const result = data.createBookmark;
return {
statusCode: 200,
body: JSON.stringify(result)
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify('Something went wrong. Try again later!')
};
}
};
现在,重新启动服务器netlify dev,然后点击Add Bookmark标题栏中的链接添加书签。
添加编辑和删除书签功能
现在我们来添加编辑和删除书签的无服务器功能。
editBookmark.js在文件夹内创建一个新文件functions,内容如下:
const { EDIT_BOOKMARK } = require('./utils/queries');
const { getClient } = require('./utils/client');
exports.handler = async (event, context, callback) => {
try {
if (event.httpMethod !== 'PUT') {
return {
statusCode: 405,
body: JSON.stringify({
error: 'only PUT http method is allowed.'
})
};
}
const { _id: id, title, url, tag } = JSON.parse(event.body);
const variables = { id, title, url, tag };
const client = getClient({ method: 'PUT' });
const { data } = await client.mutate({
mutation: EDIT_BOOKMARK,
variables
});
const result = data.createBookmark;
return {
statusCode: 200,
body: JSON.stringify(result)
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify(
'Something went wrong while editing bookmarks. Try again later.'
)
};
}
};
deleteBookmark.js在文件夹内创建一个新文件functions,内容如下:
const { DELETE_BOOKMARK } = require('./utils/queries');
const { getClient } = require('./utils/client');
exports.handler = async (event, context, callback) => {
try {
if (event.httpMethod !== 'DELETE') {
return {
statusCode: 405,
body: JSON.stringify({
error: 'only DELETE http method is allowed.'
})
};
}
const { _id: id } = JSON.parse(event.body);
const variables = { id };
const client = getClient({ method: 'DELETE' });
const { data } = await client.mutate({
mutation: DELETE_BOOKMARK,
variables
});
const result = data.deleteBookmark;
return {
statusCode: 200,
body: JSON.stringify(result)
};
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify(
'Something went wrong while deleting bookmark. Try again later.'
)
};
}
};
现在,重新启动服务器netlify dev,然后检查编辑和删除书签功能。
编辑书签功能
删除书签功能
我们再添加几个不同标签的书签。
现在,我们已经添加了一些书签,让我们来验证一下搜索书签功能。
测试来自 FaunaDB GraphQL Playground 的数据
让我们验证一下它们是否真的被添加到了 FaunaDB 中。
从 FaunaDB 控制面板导航到 GraphQL 菜单,将用于从文件中获取所有书签的查询粘贴functions/utils/queries.js到 playground 中并进行验证。
query {
bookmarks {
data {
_id
title
url
tag
}
}
}
如您所见,书签已正确保存到 FaunaDB 中,因此即使刷新页面,我们的书签也会保留下来。
让我们回顾一下该应用程序的工作原理。
- 当应用程序加载时,我们会调用文件中
initiateGetBookmarks的函数。actions/bookmarks.jscomponents/Home.js - 该
initiateGetBookmarks函数向 URL 发出 API 调用,/api/getBookmarks该 URL 是一个用functions/getBookmarks.js文件编写的无服务器函数,最终调用 FaunaDB GraphQL API 来获取书签列表。 - 当我们编辑/删除书签时,会从
functions文件夹中调用相应的无服务器函数,向 FaunaDB 发出 API 调用。
将应用程序部署到 Netlify
现在,申请流程已经完成了。
要将应用程序部署到 Netlify,请按照本文中您喜欢的任何方法进行操作。
请确保将
FAUNA_GRAPHQL_SECRET_KEY包含您的 动物群密钥的环境变量添加到Site settings => Build & deploy => Environment VariablesNetlify 的相应部分,然后重新部署网站。
结论
正如你所看到的,FaunDB 让创建 GraphQL 服务器和将数据存储在数据库中变得非常容易,因此我们不必担心使用额外的数据库来存储数据。
我们已经使用速度极快的 FaunaDB GraphQL API 和 Netlify 完成了我们出色的书签管理器 JAMStack 应用程序的创建。
您可以在此存储库中找到此应用程序的完整源代码。
别忘了订阅我的每周新闻简报,直接在您的收件箱中接收精彩的技巧、窍门和文章。点击此处订阅。
文章来源:https://dev.to/myogeshchavan97/create-a-bookmark-manager-app-using-faunadb-and-netlify-serverless-functions-4cp0















