使

使用 ReactJS 和 FabricJS 构建 Facebook 故事创建器

2025-06-07

使用 ReactJS 和 FabricJS 构建 Facebook 故事创建器

那时候,我正在开发一个非常大的项目,一个社交媒体应用。我希望我的应用能有一个非常有趣的功能,是的,那就是“故事”功能,用户可以分享一些内容,这些内容会在24小时后自动隐藏。我决定开发一个更简单的版本,今天我想和大家分享一下我开发Facebook故事创建器的经验。

现场演示:https://trunghieu99tt.github.io/Facebook-story-mini/

1. 范围

首先,我们来定义一下范围。Facebook 移动端应用的故事功能非常庞大,其中包含许多小功能,但 Facebook 网站上的故事功能则不然。

在网站上,我们只有两个选项。1. 文字故事,2. 带文字的图片故事。在这篇博客中,我将使用 Facebook 网站上的故事功能,我认为它更简洁。

好的,让我们进一步看看我们该做什么

  • 文字故事:中间一段文字,背景可变换
  • 图片故事:每个故事一张图片,我们还可以添加文本块

看起来很简单,对吧?至少有了文字故事功能。好的,我们进入下一部分

2. 工具、库

我使用 ReactJS 开发了这个功能,对于文本故事来说已经足够了,但对于图像故事,我们需要找到一个库来帮助我们处理文本块的添加/删除、方向、大小调整等等。于是我想到了Fabric。Fabric在画布元素之上提供了交互式对象模型,这正是我们想要实现的。我觉得你最好先去 Fabric 官网了解一下,然后再继续阅读。

3. 开始编码

你可以使用任何你想要的样板代码,对我来说,我会坚持使用 Create React App。我假设你们已经具备 React 的基础知识,并且知道如何创建和运行 React App。另外需要注意的是,在这个项目中,我会使用 Typescript,但我认为大家可能不了解 Typescript,但这没什么大不了的,因为这只是一个小项目。

在这个项目中,我们需要添加另外 2 个包:fabric 和 fabricjs-react(实际上我们不需要这个包,但为了使事情更容易,使用它是可以的)。

运行此命令:

yarn add fabric fabricjs-react
#or
npm install fabric fabricjs-react
Enter fullscreen mode Exit fullscreen mode

好的,现在我们可以开始了。

在进行下一步之前,让我们先定义一下文件夹结构。我们知道组件主要有两种类型:1. 故事表单,用于创建文本或图片故事;2. 查看器组件,用于在创建并保存文本/图片故事后显示来自服务器的数据。我将创建一个如下文件夹结构:

无标题

Constants 文件夹将保存我们在此应用程序中使用的所有常量值。

3.1. 文本故事

关于文本故事,它是更简单的,我们只需要一个 div 和位于该 div 中心的文本。我们还可以更改该 div 的背景。

在 StoryForm 中,创建一个名为 Text 的文件夹,在该文件夹中,创建 3 个文件:index.ts(我们的入口文件)、textStory.module.css 和 TextStory.tsx。

无标题

在 TextStory.tsx 中:

import { ChangeEvent, useState } from "react";

import { BACKGROUND_LIST } from "../../../constants";

import classes from "./textStory.module.css";

const TextStory = () => {
    const [text, setText] = useState("");
    const [background, setBackground] = useState("#000");

    const onChangeText = (e: ChangeEvent<HTMLTextAreaElement>) => {
        const text = e.target.value;
        setText(text);
    };

    const saveToServer = () => {
        const data = {
            type: "text",
            background,
            text,
        };
        localStorage.setItem("data", JSON.stringify(data));
    };

    return (
        <div className={classes.root}>
            <aside className={classes.aside}>
                <textarea
                    className={classes.textarea}
                    onChange={onChangeText}
                    rows={7}
                />
                <p>Change color</p>
                <ul className={classes.backgroundList}>
                    {BACKGROUND_LIST.map((color) => {
                        return (
                            <li
                                onClick={() => setBackground(color)}
                                style={{
                                    background: color,
                                    cursor: "pointer",
                                    outline: `${
                                        color === background
                                            ? "2px solid blue"
                                            : ""
                                    } `,
                                }}
                            ></li>
                        );
                    })}
                </ul>
                <button onClick={saveToServer}>Save</button>
            </aside>
            <div
                className={classes.main}
                style={{
                    background: background,
                }}
            >
                <p className={classes.text}>{text}</p>
            </div>
        </div>
    );
};

export default TextStory;
Enter fullscreen mode Exit fullscreen mode

以上是该组件的完整代码。我们有一个状态用于存储文本,还有一个状态用于存储背景颜色。关于 saveToServer 函数,您可以忽略它,我们将在本博客的稍后部分讨论它。对于背景颜色列表,在本项目中,我们将对其进行硬编码(但您可以将其更改为颜色选择器或任何您想要的更好的功能)。

在 Constants 文件夹中创建一个 index.ts 文件并将其放入其中:

export const BACKGROUND_LIST = [
    'linear-gradient(138deg, rgba(168,74,217,1) 0%, rgba(202,88,186,1) 55%, rgba(229,83,128,1) 100%)',
    'linear-gradient(138deg, rgba(55,31,68,1) 0%, rgba(115,88,202,1) 55%, rgba(97,0,30,1) 100%)',
    'linear-gradient(138deg, rgba(31,68,64,1) 0%, rgba(202,88,155,1) 55%, rgba(90,97,0,1) 100%)',
    'linear-gradient(138deg, rgba(14,33,240,1) 0%, rgba(88,202,197,1) 55%, rgba(11,97,38,1) 100%)',
    'radial-gradient(circle, rgba(238,174,202,1) 0%, rgba(148,187,233,1) 100%)',
    'linear-gradient(138deg, rgba(14,33,240,1) 0%, rgba(88,202,197,1) 55%, rgba(11,97,38,1) 100%)',
    'radial-gradient(circle, rgba(198,76,129,1) 12%, rgba(218,177,209,1) 27%, rgba(148,187,233,1) 100%',
    'linear-gradient(180deg, rgba(62,66,105,1) 0%, rgba(233,225,107,1) 55%, rgba(11,97,38,1) 100%)',
    'radial-gradient(circle, rgba(117,67,81,1) 2%, rgba(107,233,164,1) 37%, rgba(97,11,11,1) 100%)',
    '#2d88ff',
    '#ececec',
    '#6344ed',
    '#8bd9ff',
    'linear-gradient(315deg, rgba(255,184,0,1) 0%, rgba(237,68,77,0.7175245098039216) 61%, rgba(232,68,237,1) 78%)',
];
Enter fullscreen mode Exit fullscreen mode

关于样式文件,它有点长,所以我就不在这里贴了。不过我会在这篇博文的末尾放一个链接,方便你以后查看。

在 index.ts 文件中,我们只需写一行。

export { default } from './TextStory';
Enter fullscreen mode Exit fullscreen mode

这是我们最终的文本故事形式的结果:

无标题

文本的默认颜色为白色(我使用 CSS 设置它,但您可以列出可用的颜色并让用户根据需要选择颜色)。

3.2. 图像故事

好的,这是本博客的主要部分,而且会比较难。

因为我们必须做这些事情:

  • 显示图像(在本项目中,我们将从 URL 读取图像,但您可以将其更改为从您的机器上传)
  • 添加文本:我们可以添加多文本块,并且对于每个块,我们可以更改其中的文本,拖动、旋转、调整大小。

现在该是织物发挥作用的时候了。

以故事形式创建一个名为 Image 的文件夹。然后在该文件夹中创建一个名为 ImageStory.tsx 的文件。

让我们在其中写一些代码

import React, { ChangeEvent, useState } from "react";
import { FabricJSCanvas, useFabricJSEditor } from "fabricjs-react";

import classes from "./imageStory.module.css";

const ImageStory = () => {
    const { editor, onReady } = useFabricJSEditor()
    return (
        <div className={classes.root}>
            <div className={classes.main}>
                <FabricJSCanvas className={classes.canvas} onReady={onReady} />
            </div>
        </div>
    );
};

export default ImageStory;
Enter fullscreen mode Exit fullscreen mode

现在添加一个表单来保存我们的图像 URL 和该表单的提交功能。

import React, { ChangeEvent, useState } from "react";
import { fabric } from "fabric";
import { FabricJSCanvas, useFabricJSEditor } from "fabricjs-react";

import classes from "./imageStory.module.css";

const ImageStory = () => {
    const [image, setImage] = useState<string | null>(null);
    const [isSubmitted, setIsSubmitted] = useState<boolean>(false);

    const { editor, onReady } = useFabricJSEditor();

    const submitImage = () => {
        if (image && image.startsWith("http")) {
            fabric.Image.fromURL(image, function (img) {
                const canvasWidth = editor?.canvas.getWidth();
                const canvasHeight = editor?.canvas.getHeight();
                editor?.canvas.setWidth(500);
                editor?.canvas.setHeight(500);
                editor?.canvas.add(img);
                const obj = editor?.canvas.getObjects();
                obj?.forEach((o) => {
                    if (o.type === "image") {
                        o.scaleToHeight(canvasWidth || 100);
                        o.scaleToHeight(canvasHeight || 100);
                    }
                });

                editor?.canvas.centerObject(img);
                setIsSubmitted(true);
            });
        }
    };

        const onChange = (e: ChangeEvent<HTMLInputElement>) => {
            const { value } = e.target;
            setImage(value);
        };

    return (
        <div className={classes.root}>
            <div className={classes.main}>
                {!isSubmitted && (
                    <div className={classes.imageForm}>
                        <input type="text" onChange={onChange} />
                        <button onClick={submitImage}>Submit</button>
                    </div>
                )}
                <FabricJSCanvas className={classes.canvas} onReady={onReady} />
            </div>
        </div>
    );
};

export default ImageStory;
Enter fullscreen mode Exit fullscreen mode

我们有一个存储图像 URL 的状态

因为我希望仅在未提交图片时显示表单,所以我添加了 isSubmitted 状态来处理这个问题。只有当 isSubbmitted = false 时,我们才会显示图片表单。

好的,我们来看看onSubmit函数:

const submitImage = () => {
        if (image && image.startsWith("http")) {
            fabric.Image.fromURL(image, function (img) {
                                // Note that img now will be an fabric object

                                // get width and height of canvas container
                const canvasWidth = editor?.canvas.getWidth();
                const canvasHeight = editor?.canvas.getHeight();

                                // add image object 
                editor?.canvas.add(img);

                                // get all fabric objects in editor
                const obj = editor?.canvas.getObjects();

                                // This will not optimal way, but currently
                                // we only have one image, so It should be fine
                obj?.forEach((o) => {
                    if (o.type === "image") {
                                                // resize image to fit with editor width and height
                        o.scaleToHeight(canvasWidth || 100);
                        o.scaleToHeight(canvasHeight || 100);
                    }
                });

                editor?.canvas.centerObject(img);
                setIsSubmitted(true);
            });
        }
    };
Enter fullscreen mode Exit fullscreen mode

fabric 支持从 URL 读取图片,然后返回一个 fabric 对象。在回调函数中,我们将该对象添加到当前编辑器。需要注意的是,图片现在会保持其初始大小,因此可能不适合我们的编辑器区域,我们需要调整其大小以适应编辑器区域。我目前的解决方案是获取编辑器中的所有对象,然后如果是图片则调整其大小。由于每个故事只有一张图片,因此此解决方案可以正常工作。

现在,如果你运行应用,将有效的图片 URL 粘贴到表单并点击提交,我们就会看到图片显示在编辑器区域。你可以与该图片进行交互(拖动、调整大小、旋转……)。干得好!😄

我们完成了第一个目标,现在让我们转向第二个目标。

该结构还支持文本块,因此向我们的编辑器添加文本很容易。

更改我们的 ImageStory 组件:

import React, { ChangeEvent, useState } from "react";
import { fabric } from "fabric";
import { FabricJSCanvas, useFabricJSEditor } from "fabricjs-react";

import classes from "./imageStory.module.css";

const ImageStory = () => {
    const [image, setImage] = useState<string | null>(null);
    const [isSubmitted, setIsSubmitted] = useState<boolean>(false);

    const { editor, onReady } = useFabricJSEditor();

    const onAddText = () => {
        try {
            editor?.canvas.add(
                new fabric.Textbox("Type something...", {
                    fill: "red",
                    fontSize: 20,
                    fontFamily: "Arial",
                    fontWeight: "bold",
                    textAlign: "center",
                    name: "my-text",
                })
            );
            editor?.canvas.renderAll();
        } catch (error) {
            console.log(error);
        }
    };

    const onChange = (e: ChangeEvent<HTMLInputElement>) => {
        const { value } = e.target;
        setImage(value);
    };

    const submitImage = () => {
        if (image && image.startsWith("http")) {
            fabric.Image.fromURL(image, function (img) {
                const canvasWidth = editor?.canvas.getWidth();
                const canvasHeight = editor?.canvas.getHeight();
                editor?.canvas.add(img);
                const obj = editor?.canvas.getObjects();
                obj?.forEach((o) => {
                    if (o.type === "image") {
                        o.scaleToHeight(canvasWidth || 100);
                        o.scaleToHeight(canvasHeight || 100);
                    }
                });

                editor?.canvas.centerObject(img);
                setIsSubmitted(true);
            });
        }
    };

    return (
        <div className={classes.root}>
            {isSubmitted && (
                <aside className={classes.aside}>
                    <button onClick={onAddText}>Add Text</button>
                    <button onClick={saveToServer}>Save</button>
                </aside>
            )}

            <div className={classes.main}>
                {!isSubmitted && (
                    <div className={classes.imageForm}>
                        <input type="text" onChange={onChange} />
                        <button onClick={submitImage}>Submit</button>
                    </div>
                )}
                <FabricJSCanvas className={classes.canvas} onReady={onReady} />
            </div>
        </div>
    );
};

export default ImageStory;
Enter fullscreen mode Exit fullscreen mode

我们来看一下 onAddText 函数。我们通过调用 new fabric.Textbox() 创建了一个新的 fabric Textbox 对象。

 editor?.canvas.add(
                new fabric.Textbox("Type something...", {
                    fill: "red",
                    fontSize: 20,
                    fontFamily: "Arial",
                    fontWeight: "bold",
                    textAlign: "center",
                    name: "my-text",
                })
            );
 editor?.canvas.renderAll();
Enter fullscreen mode Exit fullscreen mode

让我解释一下我们传递的参数:第一个参数是初始文本,第二个参数是一个包含文本框文本配置的对象。在上面的代码中,我将创建一个包含红色粗体文本的文本框,其字体大小为 20,字体系列为 Arial,文本框将居中对齐。创建文本框后,我们将使用 editor.canvas.add(..) 将其添加到编辑器中,最后,我们重新渲染编辑器以获取最新状态。
这是我们的最终结果:

无标题

好了,到目前为止,我们已经完成了图片和文字的添加。那么删除呢?有了 Fabric,删除就变得轻而易举了。Fabric 有一个移除方法,我们只需要传入想要移除的对象,Fabric 就会帮我们处理。但是,我们如何获取要传递给移除方法的对象呢?

还记得我们删除东西时,会先选中它吗?Fabric 有一个名为“getActiveObjects”的方法,通过该方法,我们可以获取所有选中的对象。哈哈,问题解决了,我们只需要获取所有活动对象,然后循环遍历它们并调用 remove 方法即可。

像这样:

const deleteSelected = () => {
        editor?.canvas.getActiveObjects().forEach((object) => {
            editor?.canvas.remove(object);
        });
    };
Enter fullscreen mode Exit fullscreen mode

好了,所有基本功能都讲完了。现在我们进入下一步。

3.3. 保存并显示数据

到目前为止,我们可以添加、移动内容,但我们的应用不仅仅是交互,我们还需要将数据存储到数据库中并显示其中的数据,对吗?那么,我们该如何使用 fabricjs 来实现呢?

在这个小项目中,我将使用本地存储作为数据库,以简化操作。关于数据形式,我认为文本是最好的。我们只需要创建一个对象,然后对该对象使用 JSON.stringify 即可。

有了文本故事功能,我们要做的事情就不多了,我们需要存储的信息就是文本内容和背景颜色。

const saveToServer = () => {
        const data = {
            background,
            text,
        };
        localStorage.setItem("data", JSON.stringify(data));
    };
Enter fullscreen mode Exit fullscreen mode

将此功能添加到 Text Story Form 组件并添加一个按钮,其 onClick 事件为 saveToServer,我们就完成了。

现在转到图像故事,再次感谢 fabric,我们有一个名为 toJSON() 的方法,它将编辑器中的对象数据转换为 JSON,现在我们只需要使用转换后的对象数据调用 JSON.stringify 并将其保存到本地存储

const saveToServer = () => {
        const objects = editor?.canvas.toJSON();
        if (objects) {
            localStorage.setItem("data", JSON.stringify(objects));
        }
    };
Enter fullscreen mode Exit fullscreen mode

为了显示数据,首先,我们从本地存储中获取数据并使用 JSON.parse 对该数据进行解析

const showResultFromServer = () => {
        const json = localStorage.getItem("data");
        if (json) {
            const objects = JSON.parse(json);
              // store it to component state. 
        }
    };
Enter fullscreen mode Exit fullscreen mode

有了文本故事,解析数据后,我们现在有了文本内容和背景颜色。用它来显示数据很容易,对吧?我们唯一关心的是如何显示图像故事,因为它是由 fabric 控制的。幸运的是,fabric 有一个名为“loadFromJSON”的方法,我们只需要传递从 toJSON 方法获取的 JSON 数据,fabric 会为我们处理剩下的事情。

例如,我们可以这样做:

editor.canvas.loadFromJSON(
                data,
                () = {}
            );
Enter fullscreen mode Exit fullscreen mode

loadFromJSON 有两个参数,第一个是 JSON 数据,第二个是回调函数。回调函数会在 JSON 解析完成后调用,并初始化相应的对象(在本例中是图片对象和文本对象)。我们不需要回调函数,所以暂时将其设置为空函数。

好的,我们已经完成了。

完整的源代码可以在这里找到:

https://github.com/trunghieu99tt/Facebook-story-mini

在本教程中,我一边学习,一边撰写这篇博客,所以或许有更好的方法来使用 fabricjs,或者有更好的方法来处理我在这篇博客中提到的问题。:D 如果您有任何建议,请随时发表评论,我会查看。非常感谢。

文章来源:https://dev.to/trunghieu99tt/build-a-facebook-story-creator-using-reactjs-and-fabricjs-5gl2
PREV
Meteor、Webpack 和 Parcel 对比
NEXT
成为更优秀的前端开发人员的 5 个可行步骤