学习React Playground:多文件切换

232 阅读2分钟

该文章是学习react小册的React Playground项目的总结

当点击FileNameList组件中文件名称时,Editor组件会显示对应文件内容,Preview组件会渲染对应内容 chrome-capture-2024-7-26.gif

添加zustand

如何可以让多个组件共享一份数据?
可以使用context、redux等
我这边使用Zustand 安装zustand

npm install --save zustand

创建store

image.png 使用create创建store,其中包括文件对象跟增删改查的一些方法

import { create } from 'zustand';
import { EditorFile } from '../components/CodeEditor/Editor';
import { fileName2Language } from '../utils';

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

export interface PlaygroundContext {
  // 文件对象
  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;
}

const useStore = create<PlaygroundContext>((set, get) => ({
  files: initFiles,
  selectedFileName: 'App.tsx',
  setSelectedFileName: (fileName) => set({ selectedFileName: fileName }),
  setFiles: (files) => set({ files }),
  addFile: (name) => {
    const files = get().files;
    files[name] = {
      name,
      language: fileName2Language(name),
      value: '',
    };
    return set({ files });
  },
  removeFile: (name) => {
    const files = get().files;
    delete files[name];
    return set({ files });
  },
  updateFileName: (oldFieldName: string, newFieldName: string) => {
    const files = get().files;
    if (
      !files[oldFieldName] ||
      newFieldName === undefined ||
      newFieldName === null
    )
      return;
    const { [oldFieldName]: value, ...rest } = files;
    const newFile = {
      [newFieldName]: {
        ...value,
        language: fileName2Language(newFieldName),
        name: newFieldName,
      },
    };
    return set({ files: newFile });
  },
}));

export default useStore;

再提供一个可以根据不同的后缀名返回 language,用于不同语法的高亮 image.png

// 根据不同的后缀名返回 language,用于不同语法的高亮
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';
};

为了后续封装一个FileNameItem组件
添加了actived类名,在选中时将字体设置成cyan-400

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

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(
        `inline-flex pt-2  pb-2.5  px-1.5 cursor-pointer text-sm	items-center border-b-4 border-solid  ${
          actived ? 'border-cyan-400' : 'border-transparent'
        }`,
        actived ? 'text-cyan-400' : null
      )}
      onClick={onClick}
    >
      <span>{name}</span>
    </div>
  );
};

在FileNameList组件引入FileNameItem、useStore

import { FC, useEffect, useState } from 'react';
import useStore from '@/store';
import { FileNameItem } from './FileNameItem';

const FileNameList: FC = () => {
  const files = useStore(state => state.files);
  const selectedFileName = useStore(state => state.selectedFileName);
  const setSelectedFileName = useStore(state => state.setSelectedFileName);

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

  useEffect(() => {
    setTabs(Object.keys(files));
  }, [files]);
  return (
    <div className="flex items-center h-12 overflow-x-auto overflow-y-hidden	border-b border-solid  border-gray-400 box-border	text-black	bg-white scroll-bar">
      {tabs.map((item, index) => (
        <FileNameItem
          key={item + index}
          value={item}
          actived={selectedFileName === item}
          onClick={() => setSelectedFileName(item)}
        ></FileNameItem>
      ))}
    </div>
  );
};
export default FileNameList;

渲染文件名称列表

Vue SFC Playground会有几个初始化文件,我们也添加一下

image.png 新建files文件 image.png

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';
import { Files } from './store';

// 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,
  },
};

在useStore中引入

+ import { initFiles } from '../files';

const useStore = create<PlaygroundContext>((set, get) => ({
+  files: initFiles,
}));

显示正常

image.png 原生的滚动条不美观,优化一下

image.png 在App.css利用tailwind的@layer,定义好滚动条样式

+@layer components {
+  .scroll-bar {
+    &::-webkit-scrollbar {
+      height: 1px;
+    }
+
+    &::-webkit-scrollbar-track {
+      @apply bg-neutral-50;
+    }
+
+    &::-webkit-scrollbar-thumb {
+      @apply bg-cyan-400;
+    }
+
+  }
+}

在FileNameList组件添加定义好滚动条的类名scroll-bar

return (
-  <div className="flex items-center h-12 overflow-x-auto overflow-y-hidden	border-b border-solid  border-gray-400 box-border	text-black	bg-white ">
+  <div className="flex items-center h-12 overflow-x-auto overflow-y-hidden	border-b border-solid  border-gray-400 box-border	text-black	bg-white scroll-bar">
      {tabs.map((item, index) => (
        <FileNameItem
          key={item + index}
          value={item}
          actived={selectedFileName === item}
          onClick={() => setSelectedFileName(item)}
        ></FileNameItem>
      ))}
    </div>
  );

现在美观很多 image.png 添加横向滚动事件
e.deltaY表示鼠标滚轮在垂直方向上的滚动距离。正值表示向下滚动,负值表示向上滚动。由于我们想要实现横向滚动,所以这里直接使用e.deltaY来改变scrollLeft的值

+  const containerRef = useRef<HTMLDivElement>(null);
+
+  const handleWheel = (e: WheelEvent) => {
+    containerRef.current!.scrollLeft += e.deltaY;
+  }

最外层添加overflow-hidden,为了横向滚动防止会带动外层

chrome-capture-2024-7-28.gif

function App() {
  return (
+    <div className='h-screen overflow-hidden'>
      <Header />
      <Allotment defaultSizes={[100, 100]}>
        <Allotment.Pane minSize={0}>
          <CodeEditor />
        </Allotment.Pane>
        <Allotment.Pane minSize={0}>
          <Preview />
        </Allotment.Pane>
      </Allotment>
    </div>
  );
}

添加后滚动不会带动外层

chrome-capture-2024-7-28111.gif

添加拖拽

安装react-dnd相关的包

npm install react-dnd react-dnd-html5-backend

main.tsx添加dnd的DndProvider,用于在不同组件之间共享数据

+import { DndProvider } from 'react-dnd';
+import { HTML5Backend } from 'react-dnd-html5-backend';

+createRoot(document.getElementById('root')!).render(<DndProvider backend={HTML5Backend}><App></App></DndProvider>);

在FileNameItem组件设置useDrag、useDrop,在isOver时改变背景色,这样拖动更突出一点,在hover时调用swapIndex回传新旧索引值

const ref = useRef(null)

const [, drag] = useDrag({
    type: "FileNameItem",
    item: {
      index
    },
  })

  const [{ isOver }, drop] = useDrop(({
    accept: "FileNameItem",
    hover(item: any) {
      swapIndex(item.index, index)
      item.index = index
    },
    collect(monitor) {
      return {
        isOver: monitor.isOver()
      }
    }
  })
  )

  useEffect(() => {
    drag(ref)
    drop(ref)
  }, [])
<div
+      ref={ref}
      className={classnames(
        `inline-flex pt-2  pb-2.5  px-1.5 cursor-pointer text-sm	items-center border-b-4 border-solid  ${actived ? 'border-cyan-400' : 'border-transparent'
+        } ${isOver ? " bg-cyan-100" : ""}`,
      )}
      onClick={onClick}
    >
      <span>{name}</span>
    </div>

因为拖动是要修改tabs的数据,在FileNameList组件定义swapIndex函数,回传新旧索引值来改变未知

const swapIndex = useCallback((index1: number, index2: number) => {
    const tmp = tabs[index1];
    tabs[index1] = tabs[index2];
    tabs[index2] = tmp;
    setTabs([...tabs]);
  }, [tabs])

<FileNameItem
  key={item + index}
  value={item}
  actived={selectedFileName === item}
  onClick={() => setSelectedFileName(item)}
+  index={index}
+  swapIndex={swapIndex}
></FileNameItem>

chrome-capture-2024-7-29.gif

添加同步修改

修改内容再切换文件,不会保存 chrome-capture-2024-7-29-1.gif 在CodeEditor组件引入useStore,添加一个onEditorChange函数在修改文件内容时,将同步保存到files中

const files = useStore(state => state.files);
  const selectedFileName = useStore(state => state.selectedFileName);
  const setFiles = useStore(state => state.setFiles);
  const file = files[selectedFileName];

  const onEditorChange: OnChange = (value?: string) => {
    files[file.name].value = value!
    setFiles({ ...files })
  }
-      <Editor file={file} />
+      <Editor file={file} onChange={onEditorChange} />

chrome-capture-2024-7-29-2.gif