• Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • Babel 插件通关秘籍
  • Git 原理详解及实用指南
  • Nest 通关秘籍
  • React 通关秘籍
  • TypeScript 全面进阶指南
  • TypeScript 类型体操通关秘籍
  • 现代CSS
  • React 通关秘籍

    • 1.关于本小册
    • 2.一网打尽组件常用Hook
    • 3.Hook的闭包陷阱的成因和解决方案
    • 4.React组件如何写TypeScript类型
    • 5.React组件如何调试
    • 6.受控模式VS非受控模式
    • 7.组件实战:迷你Calendar
    • 8.组件实战:Calendar日历组件(上)
    • 9.组件实战:Calendar日历组件(下)
    • 10.快速掌握Storybook
    • 11.React组件如何写单测
    • 12.深入理解Suspense和ErrorBoundary
    • 13.组件实战:Icon图标组件
    • 14.组件实战:Space间距组件
    • 15.React.Children和它的两种替代方案
    • 16.三个简单组件的封装
    • 17.浏览器的5种Observer
    • 18.组件实战:Watermark防删除水印组件
    • 19.手写react-lazyload
    • 20.图解网页的各种距离
    • 21.自定义hook练习
    • 22.自定义hook练习(二)
    • 23.用react-spring做弹簧动画
    • 24.react-spring结合use-gesture手势库实现交互动画
    • 25.用react-transition-group和react-spring做过渡动画
    • 26.快速掌握Tailwind:最流行的原子化CSS框架
    • 27.用CSSModules避免样式冲突
    • 28.CSSInJS:快速掌握styled-components
    • 29.react-spring实现滑入滑出的转场动画
    • 30.组件实战:Message全局提示组件
    • 31.组件实战:Popover气泡卡片组件
    • 32.项目里如何快速定位组件源码
    • 33.一次超爽的React调试体验
    • 34.组件实战:ColorPicker颜色选择器(一)
    • 35.组件实战:ColorPicker颜色选择器(二)
    • 36.组件实战:onBoarding漫游式引导组件
    • 37.组件实战:Upload拖拽上传
    • 38.组件实战:Form表单组件
    • 39.React组件库都是怎么构建的
    • 40.组件库实战:构建esm和cjs产物,发布到npm
    • 41.组件库实战:构建umd产物,通过unpkg访问
    • 42.数据不可变:immutable和immer
    • 43.基于ReactRouter实现keepalive
    • 44.Historyapi和ReactRouter实现原理
    • 45.ReactContext的实现原理和在antd里的应用
    • 46.ReactContext的性能缺点和解决方案
    • 47.手写一个Zustand
    • 48.原子化状态管理库Jotai
    • 49.用react-intl实现国际化
    • 50.国际化资源包如何通过Excel和GoogleSheet分享给产品经理
    • 51.基于react-dnd实现拖拽排序
    • 52.react-dnd实战:拖拽版TodoList
    • 53.ReactPlayground项目实战:需求分析、实现原理
    • 54.ReactPlayground项目实战:布局、代码编辑器
    • 55.ReactPlayground项目实战:多文件切换
    • 56.ReactPlayground项目实战:babel编译、iframe预览
    • 57.ReactPlayground项目实战:文件增删改
    • 58.ReactPlayground项目实战:错误显示、主题切换
    • 59.ReactPlayground项目实战:链接分享、代码下载
    • 60.ReactPlayground项目实战:WebWorker性能优化
    • 61.ReactPlayground项目实战:总结
    • 62.手写MiniReact:思路分析
    • 63.手写MiniReact:代码实现
    • 64.手写MiniReact:和真实React源码的对比
    • 65.React18的并发机制是怎么实现的
    • 66.Ref的实现原理
    • 67.低代码编辑器:核心数据结构、全局store
    • 68.低代码编辑器:拖拽组件到画布、拖拽编辑json
    • 69.低代码编辑器:画布区hover展示高亮框
    • 70.低代码编辑器:画布区click展示编辑框
    • 71.低代码编辑器:组件属性、样式编辑
    • 72.低代码编辑器:预览、大纲
    • 73.低代码编辑器:事件绑定
    • 74.低代码编辑器:动作弹窗
    • 75.低代码编辑器:自定义JS
    • 76.低代码编辑器:组件联动
    • 77.低代码编辑器:拖拽优化、Table组件
    • 78.低代码编辑器:Form组件、store持久化
    • 79.低代码编辑器:项目总结
    • 80.快速掌握ReactFlow画流程图
    • 81.ReactFlow振荡器调音:项目介绍
    • 82.ReactFlow振荡器调音:流程图绘制
    • 83.ReactFlow振荡器调音:合成声音
    • 84.AudioContext实现在线钢琴
    • 85.React服务端渲染:从SSR到hydrate
    • 86.小册总结

这节我们继续完善 playground 的功能。

首先,我们预览出错时,iframe 会白屏,并不会显示错误。

比如当依赖的模块找不到的时候:

这时候在 devtools 可以看到错误信息:

但总不能让开发者自己打开 devtools 看,我们要在页面做一下错误的显示。

新增 components/Message/index.tsx 组件

import classnames from "classnames";
import React, { useEffect, useState } from "react";

import styles from "./index.module.scss";

export interface MessageProps {
    type: "error" | "warn";
    content: string;
}

export const Message: React.FC<MessageProps> = (props) => {
    const { type, content } = props;
    const [visible, setVisible] = useState(false);

    useEffect(() => {
        setVisible(!!content);
    }, [content]);

    return visible ? (
        <div className={classnames(styles.msg, styles[type])}>
            <pre dangerouslySetInnerHTML={{ __html: content }} />
            <button
                className={styles.dismiss}
                onClick={() => {
                    setVisible(false);
                }}>
                ✕
            </button>
        </div>
    ) : null;
};

传入两个参数,type 是 error 还是 warn,还有错误内容 content。

这里 cotent 要作为 html 的方式设置到 pre 标签的标签体。

React 里设置 html 要用 dangerouslySetInnerHTML={{\_html: 'xxx'}} 的方式。

用 visible 的 state 控制显示隐藏,当传入内容的时候,设置 visible 为 true。

写下样式:

index.module.scss

.msg {
    position: absolute;
    right: 8px;
    bottom: 0;
    left: 8px;
    z-index: 10;

    display: flex;
    max-height: calc(100% - 300px);
    min-height: 40px;
    margin-bottom: 8px;
    color: var(--color);

    background-color: var(--bg-color);
    border: 2px solid #fff;
    border-radius: 6px;

    border-color: var(--color);

    &.error {
        --color: #f56c6c;
        --bg-color: #fef0f0;
    }

    &.warn {
        --color: #e6a23c;
        --bg-color: #fdf6ec;
    }
}

pre {
    padding: 12px 20px;
    margin: 0;
    overflow: auto;
    white-space: break-spaces;
}

.dismiss {
    position: absolute;
    top: 2px;
    right: 2px;

    display: block;
    width: 18px;
    height: 18px;
    padding: 0;

    font-size: 9px;
    line-height: 18px;
    color: var(--bg-color);

    text-align: center;
    cursor: pointer;
    background-color: var(--color);
    border: none;
    border-radius: 9px;
}

.msg 绝对定位在底部,设置下宽高。

.dismss 绝对定位在 .msg 的右上角。

注意,.error 和 .warn 的时候 color 和 background-color 都不同,我们声明了两个 css 变量。

css 变量可以在它元素和子元素 css 里生效,所以切换了 .error 和 .warn 就切换了整体的颜色:

在 Preview 组件引入下试试:

<Message type="warn" content={new Error().stack!.toString()} />

看下效果:

把 type 换成 error,再缩小下窗口试试:

没啥问题。

那展示的错误内容从哪里来呢?

从 iframe 里传出来。

<script>
    window.addEventListener("error", (e) => {
        window.parent.postMessage({ type: "ERROR", message: e.message });
    });
</script>

通过 postMessage 传递消息给父窗口。

然后在 Preview 组件里监听下:

import { useContext, useEffect, useState } from "react";
import { PlaygroundContext } from "../../PlaygroundContext";
import { IMPORT_MAP_FILE_NAME } from "../../files";
import Editor from "../CodeEditor/Editor";
import { Message } from "../Message";
import { compile } from "./compiler";
import iframeRaw from "./iframe.html?raw";

interface MessageData {
    data: {
        type: string;
        message: string;
    };
}

export default function Preview() {
    const { files } = useContext(PlaygroundContext);
    const [compiledCode, setCompiledCode] = useState("");

    useEffect(() => {
        const res = compile(files);
        setCompiledCode(res);
    }, [files]);

    const getIframeUrl = () => {
        const res = iframeRaw
            .replace(
                '<script type="importmap"></script>',
                `<script type="importmap">${
                    files[IMPORT_MAP_FILE_NAME].value
                }</script>`
            )
            .replace(
                '<script type="module" id="appSrc"></script>',
                `<script type="module" id="appSrc">${compiledCode}</script>`
            );
        return URL.createObjectURL(new Blob([res], { type: "text/html" }));
    };

    const [iframeUrl, setIframeUrl] = useState(getIframeUrl());

    useEffect(() => {
        setIframeUrl(getIframeUrl());
    }, [files[IMPORT_MAP_FILE_NAME].value, compiledCode]);

    const [error, setError] = useState("");

    const handleMessage = (msg: MessageData) => {
        const { type, message } = msg.data;
        if (type === "ERROR") {
            setError(message);
        }
    };

    useEffect(() => {
        window.addEventListener("message", handleMessage);
        return () => {
            window.removeEventListener("message", handleMessage);
        };
    }, []);

    return (
        <div style={{ height: "100%" }}>
            <iframe
                src={iframeUrl}
                style={{
                    width: "100%",
                    height: "100%",
                    padding: 0,
                    border: "none",
                }}
            />
            <Message type="error" content={error} />
        </div>
    );
}

试下效果:

错误展示出来了,这就是控制台那个报错:

这里暂时用不到 warn,后面用到 warn 再切换 type。

然后再来做下主题切换:

这个同样要在 context 里保存配置:

然后加一个 theme 对应的 className:

写下用到的样式:

.light {
    --text: #444;
    --bg: #fff;
}

.dark {
    --text: #fff;
    --bg: #1a1a1a;
}

还记得前面讲过 css 变量的生效范围么?

在元素和它的所有子元素里生效。

所以只要把之前 css 的样式值改成这些变量就可以了。

比如我们在 Header 组件里用下:

然后把 theme 初始值改为 dark

这时候 Header 就切换为暗色主题了:

改为 light 就会变回来:

这就是主题切换的原理:

声明一些全局的 css 变量,写样式的时候用这些变量。切换主题时切换不同的全局变量值即可。

切成暗色主题后可以看到周边有点间距,加下重置样式:

然后我们在 Header 加一个切换主题的按钮:

import { MoonOutlined, SunOutlined } from "@ant-design/icons";
import { useContext } from "react";
import { PlaygroundContext } from "../../PlaygroundContext";
import logoSvg from "./icons/logo.svg";
import styles from "./index.module.scss";

export default function Header() {
    const { theme, setTheme } = useContext(PlaygroundContext);

    return (
        <div className={styles.header}>
            <div className={styles.logo}>
                <img alt="logo" src={logoSvg} />
                <span>React Playground</span>
            </div>
            <div className={styles.links}>
                {theme === "light" && (
                    <MoonOutlined
                        title="切换暗色主题"
                        className={styles.theme}
                        onClick={() => setTheme("dark")}
                    />
                )}
                {theme === "dark" && (
                    <SunOutlined
                        title="切换亮色主题"
                        className={styles.theme}
                        onClick={() => setTheme("light")}
                    />
                )}
            </div>
        </div>
    );
}

安装用到的 icon 包:

npm install @ant-design/icons --save

试下效果:

确实能切换了,不过我们要完善下暗色主题的样式。

其余地方也是同理。

再改下 FileNameList 的样式:

编辑器同样也可以切换主题,这个是 monaco editor 自带的。

这样,主题切换功能就完成了。

案例代码上传了小册仓库,可以切换到这个 commit 查看:

git reset --hard 4a77f1bf3dc8be9e270cc346145fde6a6a896b89

总结

这节我们实现了错误显示和主题切换功能。

我们创建了 Message 组件来显示错误,iframe 里监听 error 事件,发生错误的时候通过 postMessage 传递给父窗口。

父窗口里监听 message 事件传过来的错误,用 Message 组件显示。

主题切换就是在根元素加一个 .light、.dark 的 className,里面声明 css 变量,因为 css 变量可以在子元素里生效,子元素写样式基于这些变量,那切换了 className 也就切换了这些变量的值,从而实现主题切换。

实现这两个功能后,我们的 playground 就更完善了。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
57.ReactPlayground项目实战:文件增删改
Next
59.ReactPlayground项目实战:链接分享、代码下载