• 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.小册总结

上节完成了整体布局和代码编辑器部分的开发:

这节继续来做多文件的切换:

点击上面的 tab 可以切换当前选中的文件,然后下面就会展示对应文件的内容。

上面的 FileNameList 组件、下面的 Editor 组件,还有右边的 Preview 组件都需要拿到所有文件的信息:

如何跨多个组件共享同一份数据呢?

很明显要用 Context。

我们先来创建这个 Context:

创建 PlaygroundContext.tsx

import React, { createContext, useState } from "react";

export interface File {
    name: string;
    value: string;
    language: string;
}

export interface Files {
    [key: string]: File;
}

export interface PlaygroundContext {
    files: Files;
    selectedFileName: string;
    setSelectedFileName: (fileName: string) => void;
    setFiles: (files: Files) => void;
    addFile: (fileName: string) => void;
    removeFile: (fileName: string) => void;
    updateFileName: (oldFieldName: string, newFieldName: string) => void;
}

export const PlaygroundContext = createContext<PlaygroundContext>({
    selectedFileName: "App.tsx",
} as PlaygroundContext);

context 里保存了 files 的信息,还有当前选中的文件 selectedFileName

file 的信息是这样保存的:

files 里是键值对方式保存的文件信息,键是文件名,值是文件的信息,包括 name、value、language。

context 里除了 files 和 selectedFileName 外,还有修改它们的方法 setXxx。

以及 addFile、removeFile、updateFileName 的方法。

增删改文件的时候用:

然后提供一个 PlaygroundProvider 组件:

它就是对 Context.Provider 的封装,注入了这些增删改文件的方法的实现:

export const PlaygroundProvider = (props: PropsWithChildren) => {
    const { children } = props;
    const [files, setFiles] = useState<Files>({});
    const [selectedFileName, setSelectedFileName] = useState("App.tsx");

    const addFile = (name: string) => {
        files[name] = {
            name,
            language: fileName2Language(name),
            value: "",
        };
        setFiles({ ...files });
    };

    const removeFile = (name: string) => {
        delete files[name];
        setFiles({ ...files });
    };

    const updateFileName = (oldFieldName: string, newFieldName: string) => {
        if (
            !files[oldFieldName] ||
            newFieldName === undefined ||
            newFieldName === null
        )
            return;
        const { [oldFieldName]: value, ...rest } = files;
        const newFile = {
            [newFieldName]: {
                ...value,
                language: fileName2Language(newFieldName),
                name: newFieldName,
            },
        };
        setFiles({
            ...rest,
            ...newFile,
        });
    };

    return (
        <PlaygroundContext.Provider
            value={{
                files,
                selectedFileName,
                setSelectedFileName,
                setFiles,
                addFile,
                removeFile,
                updateFileName,
            }}>
            {children}
        </PlaygroundContext.Provider>
    );
};

这里的 addFile、removeFile、updateFileName 的实现都比较容易看懂,就是修改 files 的内容。

用到的 fileName2Language 在 utils.ts 里:

export const fileName2Language = (name: string) => {
    const suffix = name.split(".").pop() || "";
    if (["js", "jsx"].includes(suffix)) return "javascript";
    if (["ts", "tsx"].includes(suffix)) return "typescript";
    if (["json"].includes(suffix)) return "json";
    if (["css"].includes(suffix)) return "css";
    return "javascript";
};

就是根据不同的后缀名返回 language。

在 monaco editor 这里会用到,用于不同语法的高亮:

然后我们在 App.tsx 里包一层 Provider:

这样就可以在任意组件用 useContext 读取 context 的值了。

我们在 FileNameList 里读取下:

import { useContext } from "react";
import { PlaygroundContext } from "../../../PlaygroundContext";

export default function FileNameList() {
    const { files, removeFile, addFile, updateFileName, selectedFileName } =
        useContext(PlaygroundContext);

    return <div>FileNameList</div>;
}

然后渲染下 tab:

import { useContext, useEffect, useState } from "react";
import { PlaygroundContext } from "../../../PlaygroundContext";

export default function FileNameList() {
    const { files, removeFile, addFile, updateFileName, selectedFileName } =
        useContext(PlaygroundContext);

    const [tabs, setTabs] = useState([""]);

    useEffect(() => {
        setTabs(Object.keys(files));
    }, [files]);

    return (
        <div>
            {tabs.map((item, index) => (
                <div>{item}</div>
            ))}
        </div>
    );
}

用 useContext 读取 context 中的 files,用来渲染 tab。

当然,现在 context 里的 files 没有内容,我们初始化下数据。

在 src/ReactPlayground 目录下创建个 files.ts

import { Files } from "./PlaygroundContext";
import importMap from "./template/import-map.json?raw";
import AppCss from "./template/App.css?raw";
import App from "./template/App.tsx?raw";
import main from "./template/main.tsx?raw";
import { fileName2Language } from "./utils";

// app 文件名
export const APP_COMPONENT_FILE_NAME = "App.tsx";
// esm 模块映射文件名
export const IMPORT_MAP_FILE_NAME = "import-map.json";
// app 入口文件名
export const ENTRY_FILE_NAME = "main.tsx";

export const initFiles: Files = {
    [ENTRY_FILE_NAME]: {
        name: ENTRY_FILE_NAME,
        language: fileName2Language(ENTRY_FILE_NAME),
        value: main,
    },
    [APP_COMPONENT_FILE_NAME]: {
        name: APP_COMPONENT_FILE_NAME,
        language: fileName2Language(APP_COMPONENT_FILE_NAME),
        value: App,
    },
    "App.css": {
        name: "App.css",
        language: "css",
        value: AppCss,
    },
    [IMPORT_MAP_FILE_NAME]: {
        name: IMPORT_MAP_FILE_NAME,
        language: fileName2Language(IMPORT_MAP_FILE_NAME),
        value: importMap,
    },
};

导出的 initFiles 包含 App.tsx、main.tsx、App.css、import-map.json 这几个文件。

import 模块的时候加一个 ?raw,就是直接文本的方式引入模块内容。

在 template 目录下添加这四个文件:

App.tsx

import { useState } from "react";
import "./App.css";

function App() {
    const [count, setCount] = useState(0);

    return (
        <>
            <h1>Hello World</h1>
            <div className="card">
                <button onClick={() => setCount((count) => count + 1)}>
                    count is {count}
                </button>
            </div>
        </>
    );
}

export default App;

App.css

:root {
    font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    font-weight: 400;
    line-height: 1.5;
    color: rgb(255 255 255 / 87%);
    text-rendering: optimizelegibility;
    text-size-adjust: 100%;
    background-color: #242424;
    color-scheme: light dark;
    font-synthesis: none;
}

#root {
    max-width: 1280px;
    padding: 2rem;
    margin: 0 auto;
    text-align: center;
}

body {
    display: flex;
    min-width: 320px;
    min-height: 100vh;
    margin: 0;
    place-items: center;
}

h1 {
    font-size: 3.2em;
    line-height: 1.1;
}

button {
    padding: 0.6em 1.2em;
    font-family: inherit;
    font-size: 1em;
    font-weight: 500;
    cursor: pointer;
    background-color: #1a1a1a;
    border: 1px solid transparent;
    border-radius: 8px;
    transition: border-color 0.25s;
}

button:hover {
    border-color: #646cff;
}

button:focus,
button:focus-visible {
    outline: 4px auto -webkit-focus-ring-color;
}

@media (prefers-color-scheme: light) {
    :root {
        color: #213547;
        background-color: #fff;
    }

    button {
        background-color: #f9f9f9;
    }
}

main.tsx

import React from "react";
import ReactDOM from "react-dom/client";

import App from "./App";

ReactDOM.createRoot(document.getElementById("root")!).render(
    <React.StrictMode>
        <App />
    </React.StrictMode>
);

import-map.json

{
    "imports": {
        "react": "https://esm.sh/react@18.2.0",
        "react-dom/client": "https://esm.sh/react-dom@18.2.0"
    }
}

然后在 Provider 里初始化 files:

看下效果:

上面的 tab 展示出来了,下面的 editor 还没有展示对应的文件内容。

改一下:

const { files, setFiles, selectedFileName, setSelectedFileName } =
    useContext(PlaygroundContext);

const file = files[selectedFileName];

换成从 context 读取的当前选中的 file 就好了。

然后点击文件名的时候做下 selectedFileName 的切换:

现在,点击 tab 就会切换编辑的文件,并且语法高亮也是对的。

接下来只要完善下样式就好了。

这部分还是挺复杂的,单独抽个 FileNameItem 组件。

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

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

export interface FileNameItemProps {
    value: string;
    actived: boolean;
    onClick: () => void;
}

export const FileNameItem: React.FC<FileNameItemProps> = (props) => {
    const { value, actived = false, onClick } = props;

    const [name, setName] = useState(value);

    return (
        <div
            className={classnames(
                styles["tab-item"],
                actived ? styles.actived : null
            )}
            onClick={onClick}>
            <span>{name}</span>
        </div>
    );
};

传入 value、actived、onClick 参数。

如果是 actived 也就是选中的,就加上 actived 的 className。

安装用到的包:

npm install --save classnames

这里用了 css modules 来做 css 模块化。

写下 index.module.scss

.tabs {
    display: flex;
    align-items: center;

    height: 38px;
    overflow-x: auto;
    overflow-y: hidden;
    border-bottom: 1px solid #ddd;
    box-sizing: border-box;

    color: #444;
    background-color: #fff;

    .tab-item {
        display: inline-flex;
        padding: 8px 10px 6px;
        font-size: 13px;
        line-height: 20px;
        cursor: pointer;
        align-items: center;
        border-bottom: 3px solid transparent;

        &.actived {
            color: skyblue;
            border-bottom: 3px solid skyblue;
        }

        &:first-child {
            cursor: text;
        }
    }
}

分别写下整体 .tabs 的样式,.tab-item 的样式。

这部分就是用 flex 布局,然后设置 tab-item 的 padding 即可。

但是 tab-item 可能很多,所以 overflw-x 设置为 auto,也就是会有滚动条。

在 CodeEditor 里引入下 FileNameItem 组件,并加上 tabs 的 className:

import { useContext, useEffect, useState } from "react";
import { PlaygroundContext } from "../../../PlaygroundContext";

import { FileNameItem } from "./FileNameItem";
import styles from "./index.module.scss";

export default function FileNameList() {
    const {
        files,
        removeFile,
        addFile,
        updateFileName,
        selectedFileName,
        setSelectedFileName,
    } = useContext(PlaygroundContext);

    const [tabs, setTabs] = useState([""]);

    useEffect(() => {
        setTabs(Object.keys(files));
    }, [files]);

    return (
        <div className={styles.tabs}>
            {tabs.map((item, index) => (
                <FileNameItem
                    key={item + index}
                    value={item}
                    actived={selectedFileName === item}
                    onClick={() => setSelectedFileName(item)}></FileNameItem>
            ))}
        </div>
    );
}

selectedFileName 对应的 item 的 actived 为 true。

看下效果:

好看多了。

在 initFiles 里多加点文件,我们测试下滚动条:

确实有滚动条,就是有点丑。

改下滚动条样式:

&::-webkit-scrollbar {
    height: 1px;
}

&::-webkit-scrollbar-track {
    background-color: #ddd;
}

&::-webkit-scrollbar-thumb {
    background-color: #ddd;
}

现在滚动条就不明显了。

我们现在并没有在编辑的时候修改 context 的 files 内容,所以切换 tab 又会变回去:

只要在编辑器内容改变的时候修改下 files 就好了:

function onEditorChange(value?: string) {
    files[file.name].value = value!;
    setFiles({ ...files });
}

没啥问题。

不过编辑是个频繁触发的事件,我们最好加一下防抖:

npm install --save lodash-es
npm install --save-dev @types/lodash-es

安装 lodash,然后调用下 debounce:

这样性能好一点。

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

git reset --hard 4621920c63b265b1c69865adbaabbba7babe66da

总结

这节我们实现了多文件的切换。

在 Context 中保存全局数据,比如 files、selectedFileName,还有对应的增删改的方法。

对 Context.Provider 封装了一层来注入初始化数据和方法,提供了 initFiles 的信息。

然后在 FileNameList 里读取 context 里的 files 来渲染文件列表。

点击 tab 的时候切换 selectedFileName,从而切换编辑器的内容。

这样,多文件的切换和编辑就完成了。

上次更新: 6/21/25, 9:42 AM
贡献者: YNight
Prev
54.ReactPlayground项目实战:布局、代码编辑器
Next
56.ReactPlayground项目实战:babel编译、iframe预览