朋友:这就是你背着我写的?读取本地项目的在线代码编辑器?

朋友:这就是你背着我写的?读取本地项目的在线代码编辑器?

技术博客 admin 118 浏览

本文用于介绍为了熟练使用ReactTypeScript而开发的一个项目: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;

    以上代码的含义如下:

    1. editorRef:用于将编辑器容器的 DOM 节点传递给 monaco-editor
    2. monacoInstance:存储 monaco-editor 实例,这样可以在组件卸载时调用 dispose() 进行清理。
    3. useEffect:在组件首次渲染时调用 monaco.editor.create() 初始化编辑器,并在组件卸载时销毁实例。
    4. valuelanguagetheme:设置初始内容、语言和主题。

通过上方的操作,就可以把文件的内容显示到这个插件当中,那么一个基本的从点击文件到显示文件就ok了:

文件保存

实现展示文件内容之后,下一个需要处理的点就是:处理文件内容修改,与修改内容相关则是修改之后的内容保存;所以整理一下之后,需要做的有两个点:

  • 监测内容是否改变
  • 保存文件内容
  1. 检测内容变化:使用editor实例的onDidChangeModelContent事件来进行检测
ts
代码解读
复制代码
// 编辑器内容变化 editor.onDidChangeModelContent(() => { // doSomething });
  1. 保存文件:使用editor实例的getValue事件来获取编辑器内容,使用FileSystemFileHandlecreateWritable方法创建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操作本地文件、目录,然后再更新文件树,让页面更新;这里我就着重讲文件操作,树节点更新的部分请移步到项目查看,接下来就分别讲一下这三个的具体操作。

  1. 新建

    API 含义 参数 知识点链接
    getFileHandle(name, {create: true}) 返回一个位于调用此方法的目录句柄内带有指定名称的文件的 FileSystemFileHandle name为文件名;create默认为false,当create设为 true 时,如果没有找到对应的文件,将会创建一个指定名称的文件并将其返回。 getFileHandle
    getDirectoryHandle(name, {create: true}) 返回一个位于调用此方法的目录句柄内带有指定名称的文件的 FileSystemDirectoryHandle name为目录名;create默认为false,当create设为 true 时,如果没有找到对应的目录,将会创建一个指定名称的目录并将其返回。 getDirectoryHandle
  2. 删除

    这里先讲删除,因为后续的修改要用到新建和修改结合。

    API 含义 参数 知识点链接
    removeEntry(name, {recursive: true}) 用于尝试将目录句柄内指定名称的文件或目录移除。 name为文件名或目录名;recursive默认为false,当recursive设为 true 时,条目将会被递归移除。 removeEntry
  3. 修改

    要说明的是,操作文件、目录的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的原因除了,文件操作是异步的之外,还为了配合UILoading效果,当修改文件、目录的时候会,左侧文件树会开启Loading效果,避免长时间页面不更新而误导用户。

Markdown功能

Markdown功能总共使用了以下插件:

插件 用途
marked 一个快速且轻量级的 Markdown 解析器。它可以将 Markdown 文本解析为 HTML。它的主要作用是把原始的 Markdown 文本内容转换成标准的 HTML 格式,以便在网页中渲染和显示。
rehype 一个处理 HTML 的工具库,用于在 HTML 转换过程中进行处理和修改。可以用来处理 marked 生成的 HTML,进行额外的转换、调整或增强。例如,rehype 可以帮助优化 HTML 结构、添加自定义标签或属性等。
rehype-highlight rehype-highlightrehype 的一个插件,专门用于代码高亮。它在处理含有代码块的 Markdown 时特别有用。通过配合 rehype-highlight,可以将 HTML 中的代码块按指定的语法高亮库(例如 Prismhighlight.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标签+网格背景,其中网格背景的作用是在查看某些图片类型(比如pngsvg)时给一个参照。使用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> ); }

    效果如下:

文档文件预览

这里的文档文件是指:docxxlsxlsxpdfpptdoc暂未支持。

  • 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;

    效果如下:

  • xlsxlsx

    这里使用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} />}

    效果如下:

新开窗口

这功能纯粹是想模仿,VsCodectrl/command + shift + N的快捷键打开新VsCode面板的功能,所以其实就是在全局监听了ctrl/command + shift + Lkeydown事件来打开新标签页。

至于为什么不沿用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!