前言
在某个阳光明媚的早晨,小周正坐在公司办公桌前,享受着刚泡好的咖啡,悠闲地浏览着掘金摸鱼🐟。突然,领导走了过来,眼神中透着一丝神秘。
“小周,有个任务要交给你。”领导微笑着说。 小周放下咖啡杯,精神一振。“什么任务?”
“我们需要一个简易版的网页 IDE,用于查看文件。”领导的语气不容置疑。
小周心中一紧,传说中只能获取文件而无法访问文件夹的前端开发难题浮现在他脑海中。“领导,这个……前端好像只能获取单个文件吧?🐒”
领导拍了拍小周的肩膀,神秘地说:“其实,现代 Web API 可以做到这一点,你需要一点魔法。”
小周决定接受这个挑战。他钻进代码世界,开始了一场前端 API 的探索之旅。他疯狂地翻阅 MDN 发现了一个神奇的 API,
showDirectoryPicker,可以打开整个文件夹,犹如打开了通往新世界的大门。
关键API
showDirectoryPicker
兼容性如下
该方法用于显示一个目录选择器,以允许用户选择一个目录。 参数
| 可选参数 | 包含可选属性 | 作用 |
|---|---|---|
options对象 | id | 为目录指定 ID。不同选择器指定相同 ID 将在同一目录中打开。 |
| & | mode | 默认为 "read",用于只读访问,或 "readwrite" 用于读写访问。 |
| & | startIn | 一个 FileSystemHandle 对象或者代表某个目录的字符串(如:"downloads"、"music"、"pictures"),用于指定起始目录。 |
返回一个 Promise。
成功会抛出一个句柄(一个用来标识对象或者项目的标识符,可以用来描述窗体、文件等)对象。
异常抛出:
-
当用户未经选择直接关闭了目录选择器,或用户代理认为选择的目录过于敏感或者危险,或指定
mode中选择的目录的PermissionStatus.state不是"granted",则抛出此异常。 -
如果调用被同源策略阻止,或者不是通过用户交互(例如按下按钮)调用,则抛出该异常。
因为获取用户文件夹操作是个高危操作,因此必须通过 Features gated by user activation,及用户必须与页面或 UI 元素进行交互才能使该特性正常运行,并且只能在 HTTPS 协议下使用。
句柄对象:
kind 表明当前句柄为文件夹 (directory) 还是文件 (file)。
注意句柄对象拥有一个属性 Symbol.asyncIterator 意味着可以通过 for await of 遍历
得到的结果为他的子句柄。
showOpenFilePicker
兼容性如下
参数比 showDirectoryPicker 多一个可选参数 type
允许选择的文件类型的数组。每个项目都是一个具有以下选项的对象:
-
description可选允许的文件类型类别的可选描述。默认为空字符串。
-
一个Object,其键设置为 MIME 类型,值设置为文件扩展名的数组
因此异常也比 showDirectoryPicker 多一个类型异常
如果无法处理接受类型,则抛出该异常,如果出现以下情况:
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
}
效果就是酱紫
读取单个文件
读取单个文件就需要使用 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 赋予不同的样式
其中一种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)}>
看起来没有任何问题的代码,在实际操作时却出现了错误
这是怎么回事,小周感觉到很困惑🤔。
查询资料后小周发现这样一个参数在 addEventListener 中
看到这里小周终于明白是怎么回事了🤠
因为在滚动、触摸事件中,浏览器会在操作和事件处理之间进行协调。如果 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文件夹)就会导致很长时间无法解析构建出文件树,比如 ↓
(花费了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 } />
}));
直接光速解析🫨
结语
经过小周一顿操作,终于完成了一个简易的文件查看器😏
领导查看后表示很满意,对小周回道:“嗯嗯,我很满意,照这个趋势下去,升职加薪、迎娶白富美那不是手到擒来😏”
小周:“可别小看我们真 Ikun 的力量啊!🏀”
于是小周马上将代码上传到了 gitee 上 FileViewer