发布于 2026-01-06 0 阅读
0

使用 FaunaDB 和 Netlify Serverless 函数创建一个书签管理器应用程序

使用 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第二个位置显示的菜单

GraphQL 屏幕

  • 在桌面上创建一个名为 `<filename>` 的新文件bookmarks.graphql,并将以下代码添加到该文件中:
type Bookmark {
  title: String!
  url: String!
  tag: String!
}
type Query {
  bookmarks: [Bookmark!]!
}
Enter fullscreen mode Exit fullscreen mode
  • 现在,点击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
Enter fullscreen mode Exit fullscreen mode

项目创建完成后,删除文件夹中的所有文件,并在该文件夹内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
Enter fullscreen mode Exit fullscreen mode

打开并把这里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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们添加了一个输入搜索框,用于搜索书签列表。

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'
];
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们创建了要在 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;
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们将遍历文件中添加的标签列表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;
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们创建了一个加载器组件,它将显示带有背景叠加层的加载消息。

要将其添加到 DOM 中,请打开public/index.html文件,并在具有 id 的 div 元素之后root添加另一个具有 id 的 div 元素。loader

<div id="root"></div>
<div id="loader"></div>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们显示了带有按钮的各个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;
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们将遍历书签列表并将其显示在屏幕上。

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;
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们将显示和隐藏加载器分离到一个自定义钩子中。

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;
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们创建了一个用于添加和编辑书签功能的表单。

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);
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们添加了一个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);
Enter fullscreen mode Exit fullscreen mode

在这个文件中,当用户编辑书签后提交时,我们会调用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);
Enter fullscreen mode Exit fullscreen mode

这是封装所有其他组件的主组件文件。
在这个文件中,首先,我们通过传递一个空数组作为第二个参数来调用钩子getBookmarks函数useEffect,这样该函数就只会运行一次。

useEffect(() => {
 getBookmarks();
}, []);
Enter fullscreen mode Exit fullscreen mode

在函数内部,getBookmarks我们将书签数组设置为使用返回的书签列表。setBookmarks(bookmarksList);

如果 Redux store 发生任何更新(例如书签被添加、编辑或删除),我们会将更新后的书签重新赋值给书签数组。

useEffect(() => {
  setBookmarks(bookmarksList);
}, [bookmarksList]);
Enter fullscreen mode Exit fullscreen mode

这类似于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;
Enter fullscreen mode Exit fullscreen mode

在这里,我们使用库为各个页面设置了路由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;
Enter fullscreen mode Exit fullscreen mode

在这个 reducer 文件中,对于SET_BOOKMARKSaction 类型,我们按相反的顺序返回书签,这样在 UI 上显示时,组件首次加载时,最新添加的书签将显示在顶部。

ADD_BOOKMARK操作类型中,我们通过将新添加的书签作为数组的第一个元素来返回数组,然后使用扩展运算符将所有其他书签附加到数组中。

EDIT_BOOKMARK操作类型中,我们使用数组映射方法检查传递的 id 是否与书签数组中的任何 id 匹配,如果匹配,则通过展开书签的所有属性,然后展开书签的更新值,返回一个新对象。

例如,如果它bookmark看起来像这样:

{_id: "276656761265455623221", title: "FaunaDB", url: "https://fauna.com/", tag: "React"}
Enter fullscreen mode Exit fullscreen mode

看起来action.bookmark是这样的:

{_id: "276656761265455623221", title: "FaunaDB Website", url: "https://fauna.com/", tag: "React"}
Enter fullscreen mode Exit fullscreen mode

其中,仅更改标题,使用扩展运算符后的{...bookmark, ...action.bookmark}结果将为:

{_id: "276656761265455623221", title: "FaunaDB", url: "https://fauna.com/", tag: "React", _id: "276656761265455623221", title: "FaunaDB Website", url: "https://fauna.com/", tag: "React"}
Enter fullscreen mode Exit fullscreen mode

因此,如果已经存在同名键,则后添加的键的值将覆盖前一个键的值。所以最终结果将是:

{_id: "276656761265455623221", title: "FaunaDB Website", url: "https://fauna.com/", tag: "React"}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们会添加在添加、编辑或删除书签时,来自 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;
Enter fullscreen mode Exit fullscreen mode

在这里,我们创建了一个 redux store,bookmarksReducer并将errorsReducer其组合在一起,以便我们可以从AppRouter.js文件中定义的任何组件访问 store 数据。

functions.js在文件夹内创建一个新文件utils,内容如下:

export const isMatch = (original, search) =>
  original.toLowerCase().indexOf(search.toLowerCase()) > -1;
Enter fullscreen mode Exit fullscreen mode

现在,打开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')
);
Enter fullscreen mode Exit fullscreen mode

在这里,我们添加了一个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));
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

errors.js在文件夹内创建一个新文件actions,内容如下:

import { GET_ERRORS } from '../utils/constants';

export const getErrors = (errorMsg) => ({
  type: GET_ERRORS,
  errorMsg
});
Enter fullscreen mode Exit fullscreen mode

在项目文件夹内创建一个新文件.env,内容如下:

FAUNA_GRAPHQL_SECRET_KEY=your_fauna_secret_key
Enter fullscreen mode Exit fullscreen mode

请在此处使用您的 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
Enter fullscreen mode Exit fullscreen mode

这是 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!]!
}
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

请注意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
};
Enter fullscreen mode Exit fullscreen mode

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.'
      )
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

在这个文件中,我们实际上是向 FaunaDB GraphQL API 发出 API 调用,并将响应返回到文件initiateGetBookmarks中定义的函数,src/actions/bookmarks.js因为从该initiateGetBookmarks函数内部,我们正在调用一个/api/getBookmarksfunctions/getBookmarks.js服务器函数。

运行应用程序

现在,让我们运行应用程序来查看输出结果。在此之前,我们需要安装netlify-clinpm 库,它将运行我们的无服务器函数以及 React 应用程序。

在终端中执行以下命令来安装库:

npm install netlify-cli -g
Enter fullscreen mode Exit fullscreen mode

如果您使用的是 Linux/Mac 系统,则可能需要sudo在安装命令前添加一些参数才能全局安装:

sudo npm install netlify-cli -g
Enter fullscreen mode Exit fullscreen mode

现在,在项目文件夹内的终端中运行以下命令来启动应用程序。

netlify dev
Enter fullscreen mode Exit fullscreen mode

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!')
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

现在,重新启动服务器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.'
      )
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

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.'
      )
    };
  }
};
Enter fullscreen mode Exit fullscreen mode

现在,重新启动服务器netlify dev,然后检查编辑和删除书签功能。

编辑书签功能

编辑书签

删除书签功能

删除书签

我们再添加几个不同标签的书签。

书签

现在,我们已经添加了一些书签,让我们来验证一下搜索书签功能。

求职

测试来自 FaunaDB GraphQL Playground 的数据

让我们验证一下它们是否真的被添加到了 FaunaDB 中。

从 FaunaDB 控制面板导航到 GraphQL 菜单,将用于从文件中获取所有书签的查询粘贴functions/utils/queries.js到 playground 中并进行验证。

query {
  bookmarks {
    data {
      _id
      title
      url
      tag
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

验证并获取书签

如您所见,书签已正确保存到 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