本文用于介绍为了熟练使用
React
和TypeScript
而开发的一个项目:LocalLibrary,在线预览可点击此处。
展示
项目缘由
- 因为从事工作就一直用的
Vue
系列,所以老是想把React
系列加入自己的技术栈,有了这个想法之后,就去学习了语法,熟悉了相关技术栈;在写了一些小demo
之后,由于没有实践的地方,公司用的是Vue
技术栈,也没有具体的React项目。导致懂是懂了,但是没有那种顺手的感觉;所以就准备写一个综合性高一些的项目。 - 在找寻点子的过程中,我发现我经常使用的一款软件:
Obsidian
是直接操作本地文件,编写的时候实时保存;所以我在想浏览器能不能操作本地文件呢?一查发现浏览器提供了这个功能(FileSystemHandle),所以准备做一个这个的仿版;但是在我研究到一定程度之后,我发现实现光处理Markdown
文件,挺单调,达不到综合性高的目标。 - 后来我又根据网页操作本地文件这个知识点扩散开来,就想起了网页版的VsCode,在体验了它的功能之后,我决定就以它为原型做一个小的仿品,来达到熟练运用
React+TypeScript
的目的,这个目标工作量不小、有一定难度,若是能完成,应该是能达到熟练使用的目的。
文章由来
正如文章标题而言,我本来是不准备发这篇文章的,因为我觉得目前这项目还是不够看,对比网页版的VsCode
,一个天上一个地下,很粗糙的。但是我朋友来我家玩,知道我在做这个事情,思考之后,他劝我不要闭门造车,把核心功能作为第一个版本发出去,集思广益同时让有兴趣的人帮助你发现bug。
结果就是我听从了朋友的建议并且你们看到了这篇文章,接下来我就详细介绍一下这个项目:
项目介绍
功能
目前1.0版本具有以下功能:
- ✅ 读取目录形成文件树并排序(文件树按目录>文件+按字母顺序的规则排序)
- 🎯 支持代码编辑器
- 💪 修改文件内容实时保存
- 🐆 新建文件、目录
- 🤟 选中后回车修改文件、目录
- 🥇 选中后点击Backspace删除文件、目录
- 🪐 markdown 双栏编辑+预览功能
- 🦩 媒体文件(图片、视频)预览功能
- ✂️ 文档文件(docx、xls、xlsx、pdf)预览功能
- 📟 支持快捷键ctrl/command + shift + l打开新窗口
想要快速预览功能的话,可以先点击这个在线链接进行体验。
具体实现
以下会展开讲讲功能点的实现逻辑:包含形成文件树
、代码编辑器
、文件保存
、文件、目录操作
、markdown功能
、媒体文件预览
、文档文件预览
、新开窗口
。
形成文件树
形成文件树有四个比较关键的点:
-
window.showDirectoryPicker
:显示目录选择器这个API来获取到操作目录的句柄:
FileSystemDirectoryHandle
,通过这个句柄,我们就能够操作其中的文件。js代码解读复制代码window.showDirectoryPicker({mode: 'readwrite'})
ps:详情请点击这里->window.showDirectoryPicker
-
for await
+FileSystemDirectoryHandle.values
:读取子目录和文件首先我们说
FileSystemDirectoryHandle.values()
,他的作用是返回这个FileSystemDirectoryHandle
对象内每个条目的句柄的异步迭代器,这样我们就能获取到它下级的子文件或者是子目录的句柄。其次就是因为返回的是异步迭代器,所以这里需要用
for await
循环,而不是一般的for
循环;比如看以下项目中的代码:ts代码解读复制代码// 传入父句柄、父节点 export async function handleDirectoryToArray(dirHandle: FileSystemDirectoryHandle, parentNode?: TreeData) { const result = []; // 读取子内容,形成节点 for await (const entry of dirHandle.values()) { if (entry.kind === 'directory') { const subDirHandle = await dirHandle.getDirectoryHandle(entry.name); result.push({ name: entry.name, type: 'directory' as TreeNodeType, children: [], handle: subDirHandle, parentHandle: dirHandle, parentNode, }); } else if (entry.kind === 'file') { result.push({ name: entry.name, type: 'file' as TreeNodeType, handle: entry, parentHandle: dirHandle, parentNode, }); } } // 树排序 handleSortFiles(result); return result; }
-
树节点结构定义
ts代码解读复制代码export type TreeNodeType = 'file' | 'directory' | 'fileEdit' | 'directoryEdit'; export interface TreeData { name: string; type: TreeNodeType; handle: FileSystemFileHandle | FileSystemDirectoryHandle; children?: TreeData[]; parentHandle: FileSystemDirectoryHandle; parentNode?: TreeData; }
节点主要包括以下内容:
字段 含义 name 节点名 type 节点类型(file:文件,directory:目录,fileEdit:文件编辑态,directoryEdit:目录编辑态) handle 操作句柄 children 后代 parentHandle 父句柄 parentNode 父节点 -
<TreeNode />
树节点组件:一个循环引用的节点,匹配树结构并展示;组件具体实现可以在项目中查看,这里不展开。
经过上面的步骤之后,文件树就能够形成在左侧了:
代码编辑器
ok,经过上面的步骤之后,左侧的文件树就显示出来了,那么现在要做的就是当点击文件时,读取文件来显示在右侧,那么实现右侧的代码编辑器有两个步骤:
-
读取文件内容:
这里使用
File.text()
来读取文件,但是这里要注意的是,这种方式来读取文件的编码默认是UTF-8 格式的字符串
,对于某些文件可能会乱码。ps:后续可能会用
FileReader.readAsText()
的方法来替换,这方法允许设置读取的编码格式。 -
使用
monaco-editor
插件显示代码这里要介绍一下这个插件,
Monaco Editor
是驱动VS code
的代码编辑器。如果你感兴趣,你可以点击这里,来查看这个编辑器的详细情况;要说的一点是这个网站很难打开,要多等一下,即使是开启了科学上网工具。能进入官网更好,要是进不去,这里我贴了一段基本使用代码备用,并标注其中含义:
js代码解读复制代码import React, { useRef, useEffect } from 'react'; import * as monaco from 'monaco-editor'; const MonacoEditor = () => { const editorRef = useRef(null); // 创建一个ref来存储编辑器的DOM容器 const monacoInstance = useRef(null); // 存储编辑器实例 useEffect(() => { if (editorRef.current) { // 初始化编辑器 monacoInstance.current = monaco.editor.create(editorRef.current, { value: '// Start coding here...', language: 'javascript', theme: 'vs-dark', }); } // 清除函数,组件卸载时销毁编辑器 return () => monacoInstance.current && monacoInstance.current.dispose(); }, []); return ( <div ref={editorRef} style={{ height: '90vh', width: '100%' }} // 设置编辑器容器的大小 ></div> ); }; export default MonacoEditor;
以上代码的含义如下:
-
editorRef
:用于将编辑器容器的 DOM 节点传递给monaco-editor
。 -
monacoInstance
:存储monaco-editor
实例,这样可以在组件卸载时调用dispose()
进行清理。 -
useEffect
:在组件首次渲染时调用monaco.editor.create()
初始化编辑器,并在组件卸载时销毁实例。 -
value
、language
、theme
:设置初始内容、语言和主题。
-
通过上方的操作,就可以把文件的内容显示到这个插件当中,那么一个基本的从点击文件到显示文件就ok了:
文件保存
实现展示文件内容之后,下一个需要处理的点就是:处理文件内容修改,与修改内容相关则是修改之后的内容保存;所以整理一下之后,需要做的有两个点:
- 监测内容是否改变
- 保存文件内容
- 检测内容变化:使用
editor
实例的onDidChangeModelContent
事件来进行检测
ts
代码解读
复制代码
// 编辑器内容变化
editor.onDidChangeModelContent(() => {
// doSomething
});
- 保存文件:使用
editor
实例的getValue
事件来获取编辑器内容,使用FileSystemFileHandle
的createWritable
方法创建FileSystemWritableFileStream
对象用于写入文件
ts
代码解读
复制代码
const saveFile = async (editor: monaco.editor.IStandaloneCodeEditor) => {
const code = editor.getValue();
const handle = currentNode?.handle as FileSystemFileHandle;
const writeableStream = await handle.createWritable();
await writeableStream?.write(code);
await writeableStream?.close();
};
文件、目录操作
到了这里,文件内容能读取之后,接着就是要操作文件、目录了,这里的操作包含:
- 新建文件、目录
- 修改文件、目录
- 删除文件、目录
其实这三个的逻辑都一样,都是先利用API
操作本地文件、目录,然后再更新文件树,让页面更新;这里我就着重讲文件操作,树节点更新的部分请移步到项目查看,接下来就分别讲一下这三个的具体操作。
-
新建
API 含义 参数 知识点链接 getFileHandle(name, {create: true})
返回一个位于调用此方法的目录句柄内带有指定名称的文件的 FileSystemFileHandle
name
为文件名;create
默认为false,当create
设为true
时,如果没有找到对应的文件,将会创建一个指定名称的文件并将其返回。getFileHandle getDirectoryHandle(name, {create: true})
返回一个位于调用此方法的目录句柄内带有指定名称的文件的 FileSystemDirectoryHandle
name
为目录名;create
默认为false,当create
设为true
时,如果没有找到对应的目录,将会创建一个指定名称的目录并将其返回。getDirectoryHandle -
删除
这里先讲删除,因为后续的修改要用到新建和修改结合。
API 含义 参数 知识点链接 removeEntry(name, {recursive: true})
用于尝试将目录句柄内指定名称的文件或目录移除。 name
为文件名或目录名;recursive
默认为false,当recursive
设为true
时,条目将会被递归移除。removeEntry -
修改
要说明的是,操作文件、目录的
API
中没有修改文件、目录名的;如果要达到这个目的,就是利用前面两个API
:先新建文件或目录,把内容、子文件copy
过去,然后把之前的文件或者目录删除。所以在处理目录的时候,可能会遇到这个目录非常大,递归处理上述的操作会很耗时,造成页面卡顿;这也是为什么在线的vscode.dev不允许修改目录名而只支持修改文件名的原因。这里我就粘一下项目中的修改文件名和目录名的工具函数:
ts代码解读复制代码export async function renameFile(directoryHandle: FileSystemDirectoryHandle, oldFileName: string, newFileName: string): Promise<void> { // 获取旧文件的 FileSystemFileHandle const oldFileHandle = await directoryHandle.getFileHandle(oldFileName); // 读取旧文件内容 const oldFile = await oldFileHandle.getFile(); const oldFileContent = await oldFile.arrayBuffer(); // 创建新文件并写入旧文件内容 const newFileHandle = await directoryHandle.getFileHandle(newFileName, {create: true}); const writable = await newFileHandle.createWritable(); await writable.write(oldFileContent); await writable.close(); // 删除旧文件 await directoryHandle.removeEntry(oldFileName); } export async function renameDirectory(parentDirectoryHandle: FileSystemDirectoryHandle, oldDirectoryName: string, newDirectoryName: string): Promise<void> { try { // 获取旧目录的 FileSystemDirectoryHandle const oldDirectoryHandle = await parentDirectoryHandle.getDirectoryHandle(oldDirectoryName); // 创建新的目录 const newDirectoryHandle = await parentDirectoryHandle.getDirectoryHandle(newDirectoryName, {create: true}); // 递归复制目录内容并等待完成 await copyDirectoryContents(oldDirectoryHandle, newDirectoryHandle); // 删除旧目录 await parentDirectoryHandle.removeEntry(oldDirectoryName, {recursive: true}); // 返回操作完成的 Promise console.log('All operations completed successfully.'); } catch (error) { console.error(`Failed to rename directory: ${error}`); throw error; // 将错误抛出,返回 rejected promise } } async function copyDirectoryContents(oldDirectoryHandle: FileSystemDirectoryHandle, newDirectoryHandle: FileSystemDirectoryHandle): Promise<void> { // 创建一个任务数组来存储所有的复制操作 const tasks: Promise<void>[] = []; // 遍历旧目录中的所有文件和子目录 for await (const [name, handle] of oldDirectoryHandle) { const task = (async () => { try { if (handle.kind === 'file') { // 处理文件复制 const file = await handle.getFile(); const newFileHandle = await newDirectoryHandle.getFileHandle(name, {create: true}); const writable = await newFileHandle.createWritable(); await writable.write(await file.arrayBuffer()); await writable.close(); } else if (handle.kind === 'directory') { // 处理子目录复制 const newSubDirectoryHandle = await newDirectoryHandle.getDirectoryHandle(name, {create: true}); // 递归处理子目录并等待其完成 await copyDirectoryContents(handle, newSubDirectoryHandle); } } catch (error) { console.error(`Failed to copy entry: ${name}. Error: ${error}`); throw error; // 如果出错,抛出错误 } })(); // 将该任务添加到任务数组中 tasks.push(task); } // 等待所有任务完成 await Promise.all(tasks); }
renameFile
方法比较直观:接收父句柄、旧文件名、新文件名,读取旧文件内容,复制到新文件中,然后删除旧文件。renameDirectory
,也是同样的模式:接收父句柄、旧目录名、新目录名,比上面的方法多了一个递归操作。这里把两个方法设计成
Promise
的原因除了,文件操作是异步的之外,还为了配合UI
的Loading
效果,当修改文件、目录的时候会,左侧文件树会开启Loading
效果,避免长时间页面不更新而误导用户。
Markdown功能
Markdown
功能总共使用了以下插件:
插件 | 用途 |
---|---|
marked |
一个快速且轻量级的 Markdown 解析器。它可以将 Markdown 文本解析为 HTML 。它的主要作用是把原始的 Markdown 文本内容转换成标准的 HTML 格式,以便在网页中渲染和显示。 |
rehype |
一个处理 HTML 的工具库,用于在 HTML 转换过程中进行处理和修改。可以用来处理 marked 生成的 HTML ,进行额外的转换、调整或增强。例如,rehype 可以帮助优化 HTML 结构、添加自定义标签或属性等。 |
rehype-highlight |
rehype-highlight 是 rehype 的一个插件,专门用于代码高亮。它在处理含有代码块的 Markdown 时特别有用。通过配合 rehype-highlight ,可以将 HTML 中的代码块按指定的语法高亮库(例如 Prism 或 highlight.js )进行高亮处理,以便代码内容更易读。 |
github-markdown-css/github-markdown.css |
GitHub 样式的 Markdown CSS 文件。它提供了 GitHub 风格的 Markdown 渲染样式,可以让 Markdown 内容在网页上以 GitHub 主题的排版样式显示。包括标题、列表、引用、表格等 Markdown 元素的样式,使页面更接近 GitHub 的视觉效果。通常用于给 Markdown 文本加上统一的排版格式。 |
highlight.js |
highlight.js 提供的 GitHub 风格代码高亮样式文件。用于给代码块加上 GitHub 风格的语法高亮,让代码的显示效果类似 GitHub 的主题。这个 CSS 文件配合 highlight.js 使用,对代码块中的关键词、注释、字符串等内容按语言高亮显示。 |
在这里我就举一个把他们封装成组件的一个示例,来讲解这几个插件如何使用;要提一点的是,项目中并没有封装成组件,项目中是读取文件->预览Markdown
的逻辑。
js
代码解读
复制代码
import React, { useEffect, useState } from 'react';
import { marked } from 'marked';
import rehype from 'rehype';
import rehypeHighlight from 'rehype-highlight';
import 'github-markdown-css/github-markdown.css';
import 'highlight.js/styles/github.css';
const MarkdownRenderer = ({ content }) => {
const [htmlContent, setHtmlContent] = useState('');
useEffect(() => {
// 将 Markdown 转换为 HTML 并添加高亮
const html = marked(content);
// 使用 rehype 处理 HTML
rehype()
.data('settings', { fragment: true })
.use(rehypeHighlight)
.process(html)
.then((file) => {
setHtmlContent(String(file));
});
}, [content]);
return (
<div
className="markdown-body"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
);
};
export default MarkdownRenderer;
除了最基本的显示之外,项目中做了另外的两个操作:
- 文件同步预览:也跟保存文件是一个道理,检测文件变动同步更新
Markdown
渲染情况 - 源码部分和预览部分的滑动同步控制:通过
ref
控制两个窗口的同步滚动
这样下来,一个左侧为文件,右侧是markdown
渲染情况的逻辑就构建好了:
媒体文件预览
这里的媒体文件主要是指图片、视频,音频文件暂未支持。
-
图片预览
图片预览是使用的
img
标签+网格背景,其中网格背景的作用是在查看某些图片类型(比如png
、svg
)时给一个参照。使用FileReader
来将文件读取成url
。ts代码解读复制代码import {useEffect, useState} from 'react'; export default function ImageView({file}: {file: File}) { const [imageSrc, setImageSrc] = useState<string>(); useEffect(() => { const reader = new FileReader(); reader.onloadend = () => { setImageSrc(reader.result as string); }; reader.readAsDataURL(file); }, [file]); return ( <div style={{ backgroundImage: 'linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)', backgroundSize: '20px 20px', backgroundPosition: '0 0, 0 10px, 10px -10px, -10px 0', display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden', }} > <img src={imageSrc} alt="Preview" style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain', }} /> </div> ); }
效果如下:
-
视频预览
视频预览使用的是
video
标签,这个就比较简单;值得一提的就是URL.createObjectURL(file)
,URL 接口的createObjectURL()
静态方法创建一个用于表示参数中给出的对象的URL
的字符串,用这个生成的URL
来渲染视频。ts代码解读复制代码import {useEffect, useRef, useState} from 'react'; export default function VideoView({file}: {file: File}) { const [videoSrc, setVideoSrc] = useState<string>(); const videoRef = useRef<HTMLVideoElement | null>(null); useEffect(() => { if (videoSrc) { // 释放URL URL.revokeObjectURL(videoSrc); } // 生成临时视频URL setVideoSrc(URL.createObjectURL(file)); }, [file]); useEffect(() => { if (videoSrc && videoRef.current) { videoRef.current.load(); // 重新加载视频 } }, [videoSrc]); return ( <div> <video ref={videoRef} controls width="100%" height="100%"> <source src={videoSrc} type={file.type} /> 你的浏览器不支持视频标签。 </video> </div> ); }
效果如下:
文档文件预览
这里的文档文件是指:docx
、xls
、xlsx
、pdf
,ppt
、doc
暂未支持。
-
docx
使用的是docx-preview插件,这个插件的基本使用是调用一个方法将文件渲染到占位的
DOM
节点上,示例如下:js代码解读复制代码import React, { useEffect, useRef } from 'react'; import { renderAsync } from 'docx-preview'; const DocxViewer = ({ file }) => { const containerRef = useRef(null); useEffect(() => { const renderDocx = async () => { if (file && containerRef.current) { // 使用 docx-preview 渲染 DOCX 文件 await renderAsync(file, containerRef.current); } }; renderDocx(); }, [file]); return ( <div ref={containerRef} style={{ border: '1px solid #ddd', padding: '10px', marginTop: '20px' }} > {/* DOCX 文件内容将被渲染到此处 */} </div> ); }; export default DocxViewer;
效果如下:
-
xls
、xlsx
这里使用sheetJs插件用来处理
excel
文件数据,handsontable插件来进行数据处理并显示。同样这里我举个基本示例:ts代码解读复制代码import React, { useEffect, useState } from 'react'; import * as XLSX from 'xlsx'; import { HotTable } from '@handsontable/react'; import 'handsontable/dist/handsontable.full.css'; import {registerAllModules} from 'handsontable/registry'; // 注册模块,使用其中的合并等功能 registerAllModules(); interface ExcelViewerProps { file: ArrayBuffer | Blob; } // 这里注意,非商用要进行标注licenseKey="non-commercial-and-evaluation" // 标注之后才能使用其中的诸如合并等功能 const ExcelViewer: React.FC<ExcelViewerProps> = ({ file }) => { const [data, setData] = useState<(string | number)[][]>([]); useEffect(() => { const processFile = async () => { const arrayBuffer = file instanceof Blob ? await file.arrayBuffer() : file; const workbook = XLSX.read(arrayBuffer, { type: 'array' }); const sheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; const jsonData = XLSX.utils.sheet_to_json<string[]>(worksheet, { header: 1 }); setData(jsonData); }; processFile(); }, [file]); return ( <div> <h1>Excel 文件渲染示例</h1> {data.length > 0 && ( <HotTable data={data} colHeaders={true} rowHeaders={true} width="auto" height="auto" stretchH="all" licenseKey="non-commercial-and-evaluation" /> )} </div> ); }; export default ExcelViewer;
这里要说明的事,这个基本示例没有处理
excel
中多sheet
的情况,而在项目中对此进行了处理,最上方会显示sheet
的情况,点击相应sheet
,数据会切换,最终效果如下: -
pdf
pdf显示就没有使用插件,而是调用的浏览器的能力,使用的是
embed
标签+URL.createObjectURL(file)
,使用如下:js代码解读复制代码{type === 'pdf' && <embed className="pdf-box" src={pdfSrc} type={file.type} />}
效果如下:
新开窗口
这功能纯粹是想模仿,VsCode
的ctrl/command + shift + N
的快捷键打开新VsCode
面板的功能,所以其实就是在全局监听了ctrl/command + shift + L
的keydown
事件来打开新标签页。
至于为什么不沿用N
而是L
,因为在浏览器中ctrl/command + shift + N
是打开私密模式,权限比网页中的事件高,所以处理不了;而且T
也是有浏览器相应操作,所以最后选择了L
。
不足
以上就是当前1.0版本的功能点的介绍,假如你真实去使用这个项目之后,会发现跟VsCode的web版
有很大的差距,是的,非常大的差距。当前1.0版本只能算是个能读取、操作、预览文件的载体,比如目前不具备以下功能点:
- 标签页功能
- 历史项目功能
- 左侧文件树隐藏/展开功能
- 分屏功能
- 无终端面板
以及存在以下不足:
- 大图片读取会卡顿
- 本地修改文件,没有同步到网页编辑器
- 新建文件、目录,父目录没有自动打开
- 编辑器代码提示功能不足,某些文件代码上色不支持
所以,道阻且长吧,目前算是一个能看的东西了,后续的功能点和不足之处可以在后续版本逐步迭代处理。
总结
首先👏感谢你看到这里,花费了你宝贵的时间来看这个粗糙的项目;
写完1.0之后的感受是,确实达到了对React+TypeScript
基本熟悉的目的,比开始做之前熟练不少,还是感觉挺好的。如果你也跟我之前一样,学习了React、TypeScript
但是总感觉不熟悉,也可以通过这个项目进行练习,应该会有一些提升。
那么,以上就是关于LocalLibrary
的详细介绍了,这算是VsCode的web版
的粗糙小仿版,你可以当Obsidian
用,用它来写本地的Markdown
文件来作为日记、记录啥的(目前我是这样用的,毕竟部署在线上了还算是比较方便)。
最后,如果你感兴趣可以点击LocalLibrary在github上查看详情,若是你觉得还不错,也欢迎你对项目点击star
,我会很感激。
ok,我是李仲轩,下一篇文章再见吧👋
源文:朋友:这就是你背着我写的?读取本地项目的在线代码编辑器?
如有侵权请联系站点删除!
Technical cooperation service hotline, welcome to inquire!