前言
在网页中,前端已经可以读取本地文件系统,对本地的文件进行IO读写,甚至可以制作一个简单的VScode编辑器。这篇文章以渐进式方式实现此功能,文末附上所有代码。
首先看整体功能演示
功能概述
我们将实现一个简单的 Web 应用,具备以下功能:
- 选择本地目录:用户可以选择本地目录并显示其结构。
- 文件浏览:用户可以浏览目录中的文件和子目录。
- 文件编辑:用户可以选择文件并在网页上进行编辑。
- 文件保存:用户可以将编辑后的文件保存到本地。
核心实现步骤
我们将功能拆分为以下几个核心步骤:
- 选择本地目录
- 构建文件树
- 读取和编辑文件
- 保存编辑后的文件
1. 选择本地目录
选择本地目录是实现这个功能的第一步。我们使用 File System Access API 的 showDirectoryPicker
方法来选择目录。
document.getElementById('selectDirectoryButton').addEventListener('click', async function() {try {const directoryHandle = await window.showDirectoryPicker();console.log(directoryHandle); // 打印目录句柄} catch (error) {console.error('Error: ', error);}
});
2. 构建文件树
选择目录后,我们需要递归地构建文件树,并在页面上显示文件和子目录。
async function buildFileTree(directoryHandle, parentElement) {for await (const [name, entryHandle] of directoryHandle.entries()) {const li = document.createElement('li');li.textContent = name;if (entryHandle.kind === 'file') {li.classList.add('file');li.addEventListener('click', async function() {currentFileHandle = entryHandle;const file = await entryHandle.getFile();const fileContent = await file.text();document.getElementById('fileContent').textContent = fileContent;document.getElementById('editArea').value = fileContent;document.getElementById('editArea').style.display = 'block';document.getElementById('saveButton').style.display = 'block';});} else if (entryHandle.kind === 'directory') {li.classList.add('folder');const ul = document.createElement('ul');ul.style.display = 'none';li.appendChild(ul);li.addEventListener('click', function() {ul.style.display = ul.style.display === 'none' ? 'block' : 'none';});await buildFileTree(entryHandle, ul);}parentElement.appendChild(li);}
}
3. 读取和编辑文件
当用户点击文件时,我们读取文件内容,并在文本区域中显示以便编辑。
li.addEventListener('click', async function() {currentFileHandle = entryHandle;const file = await entryHandle.getFile();const fileContent = await file.text();document.getElementById('fileContent').textContent = fileContent;document.getElementById('editArea').value = fileContent;document.getElementById('editArea').style.display = 'block';document.getElementById('saveButton').style.display = 'block';
});
4. 保存编辑后的文件
编辑完成后,用户可以点击保存按钮将修改后的文件内容保存回本地文件。
document.getElementById('saveButton').addEventListener('click', async function() {if (currentFileHandle) {const editArea = document.getElementById('editArea');const updatedContent = editArea.value;// 创建一个 writable 流并写入编辑后的文件内容const writable = await currentFileHandle.createWritable();await writable.write(updatedContent);await writable.close();// 更新显示区域的内容document.getElementById('fileContent').textContent = updatedContent;}
});
核心 API 介绍
window.showDirectoryPicker()
该方法打开目录选择对话框,并返回一个 FileSystemDirectoryHandle
对象,代表用户选择的目录。
const directoryHandle = await window.showDirectoryPicker();
FileSystemDirectoryHandle.entries()
该方法返回一个异步迭代器,用于遍历目录中的所有文件和子目录。
for await (const [name, entryHandle] of directoryHandle.entries()) {// 处理每个文件或目录
}
FileSystemFileHandle.getFile()
该方法返回一个 File
对象,表示文件的内容。
const file = await fileHandle.getFile();
FileSystemFileHandle.createWritable()
该方法创建一个可写流,用于写入文件内容。
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
总结
通过以上步骤,我们能够选择本地目录、浏览文件和子目录、读取和编辑文件内容,并将编辑后的文件保存回本地。同时,我们使用 Highlight.js 实现了代码高亮显示。
源码
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Local File Browser with Edit and Save</title><link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.4.0/styles/atom-one-dark.min.css"><script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.4.0/highlight.min.js"></script><style>body {font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;margin: 0;padding: 0;display: flex;height: 100vh;overflow: hidden;background-color: #2e2e2e;color: #f1f1f1;}#sidebar {width: 20%;background-color: #333;border-right: 1px solid #444;padding: 20px;box-sizing: border-box;overflow-y: auto;}#content {width: 40%;padding: 20px;box-sizing: border-box;overflow-y: auto;}#preview {width: 40%;padding: 20px;box-sizing: border-box;overflow-y: auto;background-color: #1e1e1e;border-left: 1px solid #444;}#fileTree {list-style-type: none;padding: 0;}#fileTree li {margin-bottom: 5px;cursor: pointer;user-select: none; /* 禁止文本选中 */}#fileTree .folder::before {content: "📂";margin-right: 5px;}#fileTree .file::before {content: "📄";margin-right: 5px;}#fileContent {white-space: pre-wrap; /* Preserve whitespace */background-color: #1e1e1e;padding: 10px;border: 1px solid #444;min-height: 200px;color: #f1f1f1;}#editArea {width: 100%;height: calc(100% - 40px);background-color: #1e1e1e;color: #f1f1f1;border: 1px solid #444;padding: 10px;box-sizing: border-box;}#saveButton {margin-top: 10px;background-color: #4caf50;color: white;border: none;padding: 10px 15px;cursor: pointer;border-radius: 5px;}#saveButton:hover {background-color: #45a049;}h1 {font-size: 1.2em;margin-bottom: 10px;}::-webkit-scrollbar {width: 8px;}::-webkit-scrollbar-track {background: #333;}::-webkit-scrollbar-thumb {background-color: #555;border-radius: 10px;border: 2px solid #333;}.hidden {display: none;}</style>
</head>
<body><div id="sidebar"><h1>选择目录</h1><button id="selectDirectoryButton">选择目录</button><ul id="fileTree"></ul></div><div id="content"><h1>编辑文件</h1><textarea id="editArea"></textarea><button id="saveButton">保存编辑后的文件内容</button></div><div id="preview"><h1>本地文件修改后的实时预览</h1><pre><code id="fileContent" class="plaintext"></code></pre></div><script>let currentFileHandle = null;document.getElementById('selectDirectoryButton').addEventListener('click', async function() {try {const directoryHandle = await window.showDirectoryPicker();const fileTree = document.getElementById('fileTree');fileTree.innerHTML = ''; // 清空文件树async function buildFileTree(directoryHandle, parentElement) {for await (const [name, entryHandle] of directoryHandle.entries()) {const li = document.createElement('li');li.textContent = name;if (entryHandle.kind === 'file') {li.classList.add('file');li.addEventListener('click', async function() {currentFileHandle = entryHandle;const file = await entryHandle.getFile();const fileContent = await file.text();const fileExtension = name.split('.').pop();const codeElement = document.getElementById('fileContent');const editArea = document.getElementById('editArea');codeElement.textContent = fileContent;editArea.value = fileContent;codeElement.className = ''; // 清除之前的语言类codeElement.classList.add(getHighlightLanguage(fileExtension));hljs.highlightElement(codeElement);// 显示编辑区域和保存按钮editArea.style.display = 'block';document.getElementById('saveButton').style.display = 'block';});} else if (entryHandle.kind === 'directory') {li.classList.add('folder');const ul = document.createElement('ul');ul.classList.add('hidden'); // 默认隐藏子目录li.appendChild(ul);li.addEventListener('click', function(event) {event.stopPropagation(); // 阻止事件冒泡ul.classList.toggle('hidden');});await buildFileTree(entryHandle, ul);}parentElement.appendChild(li);}}await buildFileTree(directoryHandle, fileTree);} catch (error) {console.log('Error: ', error);}});// 获取代码高亮语言类型function getHighlightLanguage(extension) {switch (extension) {case 'js': return 'javascript';case 'html': return 'html';case 'css': return 'css';case 'json': return 'json';case 'xml': return 'xml';case 'py': return 'python';case 'java': return 'java';default: return 'plaintext';}}// 保存编辑后的文件内容document.getElementById('saveButton').addEventListener('click', async function() {if (currentFileHandle) {const editArea = document.getElementById('editArea');const updatedContent = editArea.value;// 创建一个 writable 流并写入编辑后的文件内容const writable = await currentFileHandle.createWritable();await writable.write(updatedContent);await writable.close();// 更新高亮显示区域的内容const codeElement = document.getElementById('fileContent');codeElement.textContent = updatedContent;hljs.highlightElement(codeElement);}});// 初始化 highlight.jshljs.initHighlightingOnLoad();</script>
</body>
</html>