Obsługuj pliki lepiej dzięki File System Access Api

Spis treści

  1. Wprowadzenie
  2. Notka o modułach
  3. Obecny sposób obsługi plików
    1. Implementacja
    2. Niedogodności
  4. Nowy sposób obsługi plików z File System Access API
    1. Implementacja
    2. Wątpliwości dotyczące File System Access API
  5. Kod przykładu na Github
  6. Klikalny przykład na Github Pages
  7. Podsumowanie

Wprowadzenie

Kiedy ostatnio spojrzałeś w katalog plików pobranych na Twoim dysku? Czy przeszukiwanie go zawsze jest koszmarem, z ogromną ilością duplikatów i plikami nazwanymi w taki sposób: somefile.txt, somefile (1).txt, somefile (2).txt, somefile (2) (1).txt itd.

Jeżeli miałeś przyjemność pracować na dokumentach do których zmiany dodaje kilka osób to praca wygląda często tak:

  1. Pobierasz plik somedocs.txt bo chcesz coś dodać
  2. Wpisujesz ważne rzeczy i wrzucasz na Slacka/Teamsy.
  3. Ktoś doda zmiany, pobierasz plik i trafia on do katalogu Pobrane, ale ma nazwę somedocs (1).txt żeby nie nadpisać obecnego pliku
  4. Wrzucasz, ktoś bierze pliczek, znowu coś dopisuje
  5. Pobierasz, tym razem ma nazwę somedocs (1) (1).txt
  6. Robi się nieporządek

I jestem w stanie wymienić bez zastanowienia sytuację, gdy spotkałem się z takim namnażaniem się chaosu 🙂 Mianowicie:

Pliczek z pytaniami do rekrutacji – nie chcesz by ten plik był dostępny dla wszystkich, by współpracownicy nie wrzucali go swoim znajomym aplikującym do firmy w której pracujesz, a fajnie mieć plik z którego możesz brać pytania.

Na szczęście pojawia się API, które pozwoli nam zapanować nad chaosem – File System Access API. Pozwala ono między innymi na zapis plików na dysku z wyborem nazwy – w analogiczny sposób jak zapisują pliki natywne aplikacje.

W tym poście porównamy obie wersje.

Notka o modułach

Choć nie jest to głównym tematem tego postu, natywne moduły JS przydadzą nam się w porównaniu tych API – ponieważ utworzymy pliki old.js i new.js.

Zastosowanie modułów, mimo że kod będzie zwykłym Javascriptem, pozwoli zapomnieć o paru problemach – zmienne i funkcje tworzone w modułach mają zasięg tego modułu. Dzięki czemu, przeciwieństwie do zwykłego kodu JS, nie musimy się martwić o duplikowanie się nazw bez konieczności stosowania obejść typu IIFE.

Moduły mają jeszcze jedną ważną cechę – zachowują się tak, jakby miały ustawiony atrybut defer.

Skrypt z tym parametrem możemy umieścić w head zamiast na samym dole strony bez obawy o blokowanie renderowania, dzięki czemu:

  1. Przeglądarka nie musi czekać z rozpoczęciem pobierania aż „doparsuje się” do samego dołu strony, może zacząć pobieranie od razu, ale:
  2. Wczytywanie jest nieblokujące, więc elementy poniżej będą renderowane jakby skryptu nie było

Sam kod w skrypcie zostanie natomiast odpalony już po wyrenderowaniu się HTML, nie ma więc obaw że jakichś elementów HTML będzie brakować i znika potrzeba podpinania się pod DOMContentLoaded.

Obecny sposób obsługi plików

Implementacja

UI samego edytora pliku jest bardzo prosty – ponieważ zakładamy edycję plików tekstowych wystarczy nam textarea:

<label for="file-editor">File editor:</label>
<br>
<textarea id="file-editor" cols="30" rows="20"></textarea>
<br>
<span id="file-name"></span>

Potrzebować będziemy elementów dzięki którym wczytamy a potem zapiszemy plik:

<div>
  <h2>Old way</h2>
  <label for="file">Load file old way: </label>
  <br>
  <input type="file" id="file" accept="text/plain">
  <br>
  <button id="save">
    Save file
  </button>
  <br>
</div>

Całość wygląda następująco:

Spójrzmy teraz na kod obsługujący te elementy kod JS który znajdzie się w pliku old.mjs:

const fileEditor = document.querySelector('#file-editor');

const fileName = document.querySelector('#file-name');
const saveButton = document.querySelector('#save');
const fileInput = document.querySelector('#file');

fileInput.addEventListener('change', async e => {
  const file = e.target.files[0];
  fileEditor.value = await file.text();
  fileName.innerHTML = file.name;
});
saveButton.addEventListener('click', () => {
  const a = document.createElement("a");
  const file = new Blob([fileEditor.value], {type: 'text/plain'});
  const href = URL.createObjectURL(file);
  a.href = href;
  a.download = fileName.innerHTML;
  a.click();
  URL.revokeObjectURL(href);
});

Widzimy więc dobrze znane wszystkim frontendowcom elementy:

  1. Wczytanie pliku tekstowego za pomocą input type="file". Ciekawostka – zamiast używać FileReader do odczytu pliku jako tekstu w nowszych przeglądarkach możesz użyć metody text() zwracającej Promise. Musisz jednak uważać używając tej metody na produkcji, ponieważ nie jest ona wspierana m.in. w Safari 13.
  2. Powszechnie stosowany sposób pobierania pliku, polegający na zasymulowania kliknięcia w link prowadzący do pliku, z atrybutem download który ustawiamy na nazwę wczytanego wcześniej pliku.

Niedogodności

Funkcjonalność jest prosta, jeśli jednak wczytamy plik o nazwie some_file.txt z katalogu Pobrane, to wtedy pobranie go spowoduje dodanie nowego pliku a nie zastąpienie starego. Nowy plik będzie miał nazwę taką jak stary + doklejony numerek:

Przywykliśmy do takiego zachowania. Aczkolwiek – czy nie byłoby miło mieć możliwość wyboru gdzie i pod jaką nazwą zapisujemy plik, jak w aplikacjach desktopowych?

Nowy sposób obsługi plików z File System Access API

Implementacja

W przykładzie File System Access API potrzebne będą nam trzy przyciski . Do wczytania pliku, zapisania nowych danych we wczytanym pliku i zapisania pliku pod dowolną nazwą:

File System Access API - rezultat

Na początek musimy wczytać wszystkie potrzebne nam elementy DOM oraz utworzymy obiekt z opcjami odczytu plików, który pozwoli odczytywać tylko pliki tekstowe w pliku new.mjs:

const fileEditor = document.querySelector('#file-editor');
const loadFile = document.querySelector('#load-file-new-way');
const saveFileToCurrent = document.querySelector('#save-to-current');
const saveFileToNew = document.querySelector('#save-to-new');

const options = {
  excludeAcceptAllOption: true,
  types: [
    {
      description: 'Simple text files',
      accept: {
        'text/plain': ['.txt'],
      },
    },
  ],
};

Zauważ, że linia:

const fileEditor = document.querySelector('#file-editor'); 

Powtarza się w obu plikach – możemy tak zrobić, ponieważ wczytujemy pliki z kodem JS jako moduły . Zmienne są dostępne w zakresie modułu a nie globalnym. Nie zachodzi więc redeklaracja consta – bez modułów musielibyśmy posłużyć się rozwiązaniem typu IIFE.

Wczytanie pliku wygląda następująco:

let handler = null;

loadFile.addEventListener('click', async () => {
  [handler] = await window.showOpenFilePicker(options);
  const file = await handler.getFile();
  fileEditor.value = await file.text();
});

Funkcja showOpenFilePicker spowoduje otwarcie okna wyboru pliku i po wybraniu pliku zwróci tablicę handlerów, z której interesuje nas tylko handler pierwszego pliku. Z handlera pobieramy plik a potem importujemy wartość tekstową , którą wrzucamy do edytora.

Sam handler jest widoczny w całym module – potrzebujemy go, by móc zapisać zmiany w pliku:

const saveFile = async handler => {
  const writable = await handler.createWritable();
  await writable.write(fileEditor.value);
  return writable.close();
}

saveFileToCurrent.addEventListener('click', async () => {
  await saveFile(handler);
});

Funkcja createWritable zwraca element umożliwiający nam pisanie do strumienia, a także zamknięcie go co spowoduje wpisanie zmian do pliku.

Aby zapisać plik pod nową nazwą użyjemy następującego kodu:

saveFileToNew.addEventListener('click', async () => {
  const newHandler = await window.showSaveFilePicker(options);
  await saveFile(newHandler);
});

Czyli użyjemy poprzedniej funkcji saveFile, jednak przekażemy do niej nowy handler. Spowoduje to otwarcie okna „Zapisywanie jako”.

Wątpliwości dotyczące File System Access API

Gdy zapisałem zmiany zaskoczyło mnie powiadomienie:

File System Access API - ostrzeżenie przed zapisem

Jego treść sugeruje, że po jednokrotnym zapisie kolejne powiadomienia już się nie pokażą i tak jest w rzeczywistości. Po wyrażeniu zgody na zapis kolejne zapisy przechodzą bez zapytania o zgodę. Moim zdaniem lepiej byłoby, gdyby prośba o potwierdzenie pokazywała się zawsze, z dwóch powodów:

  1. Nie byłoby konieczności pokazywania zagadkowego tekstu „…będzie mogła edytować plik X, dopóki nie zamkniesz wszystkich jej kart”. Wg mnie brzmi on złowieszczo i nie wzbudza zaufania 🙂
  2. Bez potwierdzenia łatwo będzie utracić zawartość, jeżeli aplikacja korzystająca z tego API będzie pozwalać na to z poziomu UI

Inną kwestią jest poziom wsparcia – na ten moment tylko przeglądarki desktopowe oparte na Chromium wspierają to API. Brak wsparcia chociażby na Chrome dla Androidzie: Can I use – File System Access API.

Niepewne jest wsparcie na przeglądarce Firefox, ponieważ Mozilla uznała dostęp do lokalnych plików użytkownika za potencjalną lukę bezpieczeństwa. Na ten moment nie ma zamiaru się nim zajmować.

Kod przykładu na Github

Pełny kod przykładu jest dostępny pod adresem:

https://github.com/radek-anuszewski/file-system-access-api-demo

Klikalny przykład na Github Pages

Pamiętaj, by używać Chrome, Edge lub Opery aby móc wypróbować nowe API. Przykład dostępny jest pod adresem:

https://radek-anuszewski.github.io/file-system-access-api-demo/index.html

Podsumowanie

Sama przyszłość nowego API nie jest pewna, jak i również może się ono zmieniać w przyszłości. Jednak już kilka minut obcowania z nim pokazuje, że jest potrzebne i użyteczne. Jest oczywiście ryzyko, że jeśli inne przeglądarki, jak Firefox, nie zaimplementują tego API to poleci ono do kosza. Ale jego użyteczność pokazuje, że w jakiś sposób zarządzanie plikami z poziomu przeglądarki należy usprawnić.
Moim zdaniem na ten moment raczej zbyt ryzykowne jest używanie go w produkcyjnych aplikacjach. Należy jednak mieć na oku w którą stronę podąży rozwój obsługi plików w przeglądarce.