领导:你,去实现一个简易版网页 IDE 用于查看文件; 我:啊,我吗?????🙃

8,530 阅读9分钟

前言

在某个阳光明媚的早晨,小周正坐在公司办公桌前,享受着刚泡好的咖啡,悠闲地浏览着掘金摸鱼🐟。突然,领导走了过来,眼神中透着一丝神秘。

“小周,有个任务要交给你。”领导微笑着说。 小周放下咖啡杯,精神一振。“什么任务?”

“我们需要一个简易版的网页 IDE,用于查看文件。”领导的语气不容置疑。

小周心中一紧,传说中只能获取文件而无法访问文件夹的前端开发难题浮现在他脑海中。“领导,这个……前端好像只能获取单个文件吧?🐒”

领导拍了拍小周的肩膀,神秘地说:“其实,现代 Web API 可以做到这一点,你需要一点魔法。”

小周决定接受这个挑战。他钻进代码世界,开始了一场前端 API 的探索之旅。他疯狂地翻阅 MDN 发现了一个神奇的 API,showDirectoryPicker,可以打开整个文件夹,犹如打开了通往新世界的大门。

关键API

showDirectoryPicker

兼容性如下 image.png

该方法用于显示一个目录选择器,以允许用户选择一个目录。 参数

可选参数包含可选属性作用
options对象id为目录指定 ID。不同选择器指定相同 ID 将在同一目录中打开。
&mode默认为 "read",用于只读访问,或 "readwrite" 用于读写访问。
&startIn一个 FileSystemHandle 对象或者代表某个目录的字符串(如:"downloads""music""pictures"),用于指定起始目录。

返回一个 Promise。

成功会抛出一个句柄(一个用来标识对象或者项目的标识符,可以用来描述窗体、文件等)对象。

异常抛出:

因为获取用户文件夹操作是个高危操作,因此必须通过 Features gated by user activation,及用户必须与页面或 UI 元素进行交互才能使该特性正常运行,并且只能在 HTTPS 协议下使用。

句柄对象: image.png

kind 表明当前句柄为文件夹 (directory) 还是文件 (file)。

注意句柄对象拥有一个属性 Symbol.asyncIterator 意味着可以通过 for await of 遍历

得到的结果为他的子句柄。

showOpenFilePicker

兼容性如下 image.png

参数比 showDirectoryPicker 多一个可选参数 type

types

允许选择的文件类型的数组。每个项目都是一个具有以下选项的对象:

  • description 可选

    允许的文件类型类别的可选描述。默认为空字符串。

  • accept

    一个Object,其键设置为 MIME 类型,值设置为文件扩展名的数组

因此异常也比 showDirectoryPicker 多一个类型异常

TypeError

如果无法处理接受类型,则抛出该异常,如果出现以下情况:

  • types 选项中任何项目的 accept 选项的任何键字符串都无法解析为有效的 MIME 类型。
  • types 选项中任何项目的 accept 选项的任何值字符串都是无效的,例如,如果它不以 . 开头,或者它以 . 结尾,或者它包含任何无效的值码点且长度大于 16。
  • types 选项为空,excludeAcceptAllOption 选项为 true

其他规范都与 showDirectoryPicker 一样。

小周学习完这两个 API 后茅塞顿开,马上开始编写 Bug 代码🤺。

开始码代码

开发环境 -

@ant-design/icons: "5.3.7"

antd: "5.19.1"

normalize.css: "8.0.1"

react: "18.3.1"

vite: "5.3.1"

读取文件夹构建节点树

通过递归使用 for await of 遍历所有句柄配合 ant 的 Tree 组件就能够构建出一个节点树,注意一定要传递不同值的 Key。

import {useState} from "react";
import {Tree} from "antd";
import {FileOutlined, FolderOutlined} from "@ant-design/icons";

function App() {
    const [dirTreeData, setDirTreeData] = useState([]);
    const selectDir = async () => {
        // 选择一个文件夹后获取到一个句柄
        const rootHandel = await showDirectoryPicker();
        // 设置根节点
        const rootTreeNode = createTreeNode(rootHandel.kind, rootHandel.name, "/" + rootHandel.name);
        // 构建所有的节点
        rootTreeNode.children = await setDirTree(rootHandel, "/" + rootHandel.name);
        // 添加到根节点下
        setDirTreeData([rootTreeNode]);
    }
    const setDirTree = async (rootHandel, parentKey) => {
        const currentRankFiles = [];
        let index = 0;
        // 遍历得到子句柄
        for await (let currentHandel of rootHandel) {
            const handelEle = currentHandel[1];
            // 以文件路径作为 key
            const uniqueKey = parentKey + "/" + handelEle.name;
            currentRankFiles.push(createTreeNode(
                handelEle.kind, 
                handelEle.name, 
                uniqueKey,
                handelEle.kind === "file" ? await handelEle.getFile() : null)
            );
            if (handelEle.kind === "directory") {
                // 递归构建节点树
                currentRankFiles[index].children = await setDirTree(handelEle, uniqueKey);
            }
            index++;
        }
        // 按照文件夹在前,文件在后排序
        currentRankFiles.sort((a,b) => {
            if (a.kind === 'directory' && b.kind === 'file') {
                return -1;
            }
            if (a.kind === 'file' && b.kind === 'directory') {
                return 1;
            }
            return 0;
        })
        
        return currentRankFiles;
    }
    // 这里生成一个节点
    const createTreeNode = (kind, name, key,file = null) => {
        const res = {
            title: name,
            kind,
            file,
            icon: kind === "directory" ?
                <FolderOutlined FileOutlined style={{color: "#d686ff"}}/> :
                <FileOutlined  FileOutlined style={{color: "#79d7dc"}}/>,
            key
        }
        if (kind === "directory") {
            res.children = [];
            res.isLeaf = false;
        }
        return res
    }

效果就是酱紫

image.png

读取单个文件

读取单个文件就需要使用 showOpenFilePicker,当然也可以使用其他方式,这里只是介绍该 API。

(因为代码越来越庞大了就抽取了一下)

import {useRef, useState} from "react";
import {FileOutlined, FolderOutlined} from "@ant-design/icons";
import FilesList from "./filesList/index.jsx";
import FileViewer from "./fileViewer/index.jsx";

function App() {
    const filesListRef = useRef();
    const fileReaderRef = useRef();
    // 左侧文件栏选中文件
    const [currentSelectFile, setCurrentSelectFile] = useState({key: "", file: null});

    // 打开选择文件夹弹窗
    const selectDir = () => {
        filesListRef.current.selectDir();
    }
    // 打开选择文件弹窗
    const selectFile = async () => {
        const fileHandel = await showOpenFilePicker();
        // 因为可以多选,所以提取第一个
        const currentFile = await fileHandel[0].getFile();
        fileReaderRef.current.readFile(currentFile)
        setCurrentSelectFile({key: "", file: null})
    }
    return <>
        <div className="tools">
            <div className="selectDirBtn" onClick={selectDir}><FolderOutlined/> 文件夹</div>
            <div className="selectFileBtn" onClick={selectFile}><FileOutlined/> 文件</div>
        </div>
        <div className="contextView">
            <FilesList
                currentSelectFile={currentSelectFile}
                ref={filesListRef}
                setCurrentSelectFile={setCurrentSelectFile}
            />
            <FileViewer
                currentSelectFile={currentSelectFile}
                ref={fileReaderRef}
            />
        </div>
    </>;
}

export default App;

左侧文件栏就需要保存选中的文件

onSelect 函数是 Tree 组件提供的函数,可以获取到创建该节点时绑定的值

// FileList.jsx

const onSelect = (_key, e) => {
    // 如果选中的是文件
    if(e.node.file) {
        setCurrentSelectFile({key: e.node.key,file: e.node.file})
    }
}
  • 选中文件获取到文件的真实数据后判断文件的类型,对于能够转换为 text 并正常展示的文件使用 fileReader 读取为 text

  • 对于图片文件通过 createObjectURL 临时创建一个图片 URL。

// fileViewer.jsx

import {forwardRef, memo, useEffect, useImperativeHandle, useMemo, useRef, useState} from "react";
import {message} from "antd";

// 定义不能通过 text 展示的二进制文件类型
const binaryFileTypes = [
    'application/octet-stream',
    'application/zip',
    'application/pdf',
    'application/vnd.android.package-archive',
    'audio/',
    'video/',
    'image/'
    // 根据需要添加更多类型
];

const FileViewer = memo(forwardRef((props, ref) => {
    const {currentSelectFile} = props;
    const fileReader = useRef(new FileReader());
    const [imgReaderSrc, setImgReaderSrc] = useState(null);
    const [currentFileText, setCurrentFileText] = useState(null);
    useMemo(() => {
        fileReader.current.addEventListener("load", () => {
            setCurrentFileText(fileReader.current.result)
        })
    }, [])

    useEffect(() => {
        if (currentSelectFile.file) {
            readFile(currentSelectFile.file)
        }
    }, [currentSelectFile]);


    const readFile = (file) => {
        const fileType = file.type;
        const isBinary = binaryFileTypes.some(type => fileType.startsWith(type));

        // 处理能够正常转换为 TXT 的文件
        if (!isBinary) {
            fileReader.current.readAsText(file);
            if(imgReaderSrc) {
                setImgReaderSrc(null);
            }
        } else if (fileType.startsWith('image/')) {
            // 处理图片文件
            const imageUrl = URL.createObjectURL(file);
            setImgReaderSrc(imageUrl)
        } else {
            message.error("不支持的文件类型!")
        }
    }

    useImperativeHandle(ref, () => ({
        readFile
    }))
}));

export default FileViewer;

展示文件

  • text 文件使用 react-syntax-highlighter 工具美化代码 该工具提供了一个组件,通过配置内容、类型(默认 JavaScript )、高亮样式(由该工具提供的一个对象)即可在页面中展示
import * as allHightLightStyles from "react-syntax-highlighter/dist/esm/styles/hljs";
import SyntaxHighlighter from "react-syntax-highlighter";
import {LightAsync} from 'react-syntax-highlighter';

<SyntaxHighlighter innerHTML={ true } language={ fileType } style={ style }>
    { currentFileText }
</SyntaxHighlighter>

原理是通过关键字匹配拆分 text,然后根据不同的 style 赋予不同的样式

image.png

其中一种style值:

  • 图片文件直接使用 img 元素展示
const imageUrl = URL.createObjectURL(file);
setImgReaderSrc(imageUrl)

踩坑点

小周希望实现一个 ctrl + 滚轮缩放字体大小的功能,于是他写下了这样的代码

const fileTextAreaWheel = (event) => {
    // 是否同时按下了唱跳 rap 篮球(ctrl)键
    if (event.ctrlKey) {
        // 阻止默认行为,例如页面滚动或缩放
        event.preventDefault();

        if (event.deltaY < 0) { // 向上滚动
            console.log(1)
        } else {
            console.log(2)
        }
    }
}

<div onWheel={(e) => fileTextAreaWheel(e)}>

看起来没有任何问题的代码,在实际操作时却出现了错误

image.png 无法在被动事件侦听器调用中阻止 Default。不能够阻止默认操作导致页面还是被缩放了。

这是怎么回事,小周感觉到很困惑🤔。

查询资料后小周发现这样一个参数在 addEventListener

image.png image.png

看到这里小周终于明白是怎么回事了🤠

因为在滚动、触摸事件中,浏览器会在操作和事件处理之间进行协调。如果 passive 参数为 true ,浏览器需要等待事件监听器的执行结果,以确定是否调用了 event.preventDefault() 来取消默认的滚动行为。这种等待会导致滚动性能的下降,尤其是在滚动频繁且监听器处理时间较长的情况下。

所以大部分浏览器(IE 请自觉退出前端圈🫡)默认该值为 true,表示永远不会阻止你的默认事件,只需要专注处理滚动。

因此在这个场景下小周希望阻止默认事件就会遭到浏览器的反噬,说好的不阻止我的默认事件呢?🙃🙃🙃

解决方法也很简单,只需要使用 addEventListener 并设置 passive 为 false 就好了

useEffect(() => {
    const fileShowTextEle = fileShowTextRef.current;
    if (!fileShowTextEle) return;

    const handleWheel = (event) => {
        // 是否同时按下了唱跳 rap 篮球(ctrl)键
        if (event.ctrlKey) {
            // 阻止浏览器默认缩放
            event.preventDefault();
            setFontSizeTipShow();
            if (event.deltaY < 0) { // 向上滚动
                setFontSize(size => {
                    if (size + 1 > fontSizeRange[1]) return size
                    return size + 1
                });
            } else { // 向下滚动
                setFontSize(size => {
                    if (size - 1 < fontSizeRange[0]) return size
                    return size - 1
                });
            }
        }
    };

    fileShowTextEle.addEventListener('wheel', handleWheel, {passive: false});

    return () => {
        fileShowTextEle.removeEventListener('wheel', handleWheel, {passive: false});
    };
}, [fileShowTextRef]);

这样问题就被完美的解决了

终极优化大法🗡️

在测试的过程中,小周发现如果解析的文件夹包含特别多且大的文件(比如一个项目包含的node_modules文件夹)就会导致很长时间无法解析构建出文件树,比如 ↓

GIF 2024-07-24 10-20-51.gif(花费了40s才解析完成一个小项目🥲)

于是小周开始翻阅 Ant 的 Tree 组件,了解到了异步构建树的方法后改造了之前的代码

const updateTreeData = (list, key, children) => {
    return list.map((node) => {
        if (node.key === key) {
            return {
                ...node,
                children,
            };
        }
        if (node.children) {
            return {
                ...node,
                children: updateTreeData(node.children, key, children),
            };
        }
        return node;
    });
}

const FilesList = memo(forwardRef((props, ref) => {
    const {currentSelectFileInLeftList, setCurrentSelectFileInLeftList} = props;
    const [dirTreeData, setDirTreeData] = useState([]);

    const selectDir = async () => {
        // 选择一个文件夹后获取到一个句柄
        const rootHandel = await showDirectoryPicker();
        // 设置根节点
        const rootTreeNode = createTreeNode(
            rootHandel,
            "/" + rootHandel.name
        )
        setDirTreeData([rootTreeNode]);
    }

    const onLoadData = ({children, key, handel}) => {
        return new Promise((resolve) => {
            if (children) {
                resolve();
                return;
            }
            setDirTree(handel, key).then(res => {
                setDirTreeData((origin) =>
                    updateTreeData(origin, key, res),
                );
                resolve();
            })
        });
    }

    const setDirTree = async (rootHandel, parentKey) => {
        const currentRankFiles = [];
        for await (let currentHandel of rootHandel) {
            const handel = currentHandel[1];
            // 以文件路径作为 key
            const uniqueKey = parentKey + "/" + handel.name;
            currentRankFiles.push(createTreeNode(
                handel,
                uniqueKey,
                handel.kind === "file" ? await handel.getFile() : null))
        }
        // 按照文件夹在前,文件在后排序
        currentRankFiles.sort((a, b) => {
            if (a.kind === 'directory' && b.kind === 'file') {
                return -1;
            }
            if (a.kind === 'file' && b.kind === 'directory') {
                return 1;
            }
            return 0;
        })
        return currentRankFiles;
    }

    const createTreeNode = (handel, key, file = null) => {
        return {
            handel,
            title: handel.name,
            kind: handel.kind,
            file,
            icon: handel.kind === "directory" ?
                <FolderOutlined style={ {color: "#d686ff"} }/> :
                <FileOutlined style={ {color: "#79d7dc"} }/>,
            key,
            isLeaf: handel.kind !== "directory" // 是否为叶子节点
        }
    }

    return <Tree treeData={ dirTreeData } loadData={ onLoadData } />
}));

直接光速解析🫨

recording.gif

结语

经过小周一顿操作,终于完成了一个简易的文件查看器😏

recording.gif

领导查看后表示很满意,对小周回道:“嗯嗯,我很满意,照这个趋势下去,升职加薪、迎娶白富美那不是手到擒来😏”

小周:“可别小看我们真 Ikun 的力量啊!🏀”

于是小周马上将代码上传到了 gitee 上 FileViewer