Работа с файлами в JavaScript

от автора

Доброго времени суток, друзья!

Мнение о том, что JavaScript не умеет взаимодействовать с файловой системой, является не совсем верным. Скорее, речь идет о том, что это взаимодействие существенно ограничено по сравнению с серверными языками программирования, такими как Node.js или PHP. Тем не менее, JavaScript умеет как получать (принимать), так и создавать некоторые типы файлов и успешно обрабатывать их нативными средствами.

В этой статье мы создадим три небольших проекта:

  • Реализуем получение и обработку изображений, аудио, видео и текста в формате txt и pdf
  • Создадим генератор JSON-файлов
  • Напишем две программы: одна будет формировать вопросы (в формате JSON), а другая использовать их для создания теста

Если Вам это интересно, прошу следовать за мной.

Код проекта на GitHub.

Получаем и обрабатываем файлы

Для начала создадим директорию, в которой будут храниться наши проекты. Назовем ее «Work-With-Files-in-JavaScript» или как Вам будет угодно.

В этой директории создадим папку для первого проекта. Назовем ее «File-Reader».

Создаем в ней файл «index.html» следующего содержания:

<div>+</div> <input type="file"> 

Здесь мы имеем контейнер-файлоприемник и инпут с типом «file» (для получения файла; мы будем работать с одиночными файлами; для получения нескольких файлов инпуту следует добавить атрибут «multiple»), который будет спрятан под контейнером.

Стили можно подключить отдельным файлом или в теге «style» внутри head:

body {     margin: 0 auto;     display: flex;     justify-content: center;     align-items: center;     min-height: 100vh;     max-width: 768px;     background: radial-gradient(circle, skyblue, steelblue);     color: #222; }  div {     width: 150px;     height: 150px;     display: flex;     justify-content: center;     align-items: center;     font-size: 10em;     font-weight: bold;     border: 6px solid;     border-radius: 8px;     user-select: none;     cursor: pointer; }  input {     display: none; }  img, audio, video {     max-width: 80vw;     max-height: 80vh; } 

Можете сделать дизайн по своему вкусу.

Не забываем подключить скрипт либо в head с атрибутом «defer» (нам нужно дождаться отрисовки (рендеринга) DOM; можно, конечно, сделать это в скрипте через обработку события «load» или «DOMContentLoaded» объекта «window», но defer намного короче), либо перед закрывающим тегом «body» (тогда не нужен ни атрибут, ни обработчик). Лично я предпочитаю первый вариант.

Откроем index.html в браузере:

Прежде чем переходить к написанию скрипта, следует подготовить файлы для приложения: нам потребуется изображение, аудио, видео, текст в формате txt, pdf и любом другом, например, doc. Можете использовать мою коллекцию или собрать свою.

Нам часто придется обращаться к объектам «document» и «document.body», а также несколько раз выводить результаты в консоль, поэтому предлагаю обернуть наш код в такое IIFE (это не обязательно):

;((D, B, log = arg => console.log(arg)) => {      // наш код      // это позволит обращаться к document и document.body как к D и B, соответственно     // log = arg => console.log(arg) - здесь мы используем параметры по умолчанию     // это позволит вызывать console.log как log })(document, document.body) 

Первым делом объявляем переменные для файлоприемника, инпута и файла (последний не инициализируем, поскольку его значение зависит от способа передачи — через клик по инпуту или бросание (drop) в файлоприемник):

const dropZone = D.querySelector('div') const input = D.querySelector('input') let file 

Отключаем обработку событий «dragover» и «drop» браузером:

D.addEventListener('dragover', ev => ev.preventDefault()) D.addEventListener('drop', ev => ev.preventDefault()) 

Для того, чтобы понять, зачем мы это сделали, попробуйте перенести изображение или другой файл в браузер и посмотрите, что произойдет. А происходит автоматическая обработка файлов, т.е. то, что мы собираемся реализовать самостоятельно в познавательных целях.

Обрабатываем бросание файла в файлоприемник:

dropZone.addEventListener('drop', ev => {     // отключаем поведение по умолчанию     ev.preventDefault()      // смотрим на то, что получаем     log(ev.dataTransfer)      // получаем следующее (в случае передачи изображения)     /*     DataTransfer {dropEffect: "none", effectAllowed: "all", items: DataTransferItemList, types: Array(1), files: FileList}         dropEffect: "none"         effectAllowed: "all"     =>  files: FileList             length: 0         __proto__: FileList         items: DataTransferItemList {length: 0}         types: []         __proto__: DataTransfer     */      // интересующий нас объект (File) хранится в свойстве "files" объекта "DataTransfer"     // извлекаем его     file = ev.dataTransfer.files[0]      // проверяем     log(file)     /*     File {name: "image.png", lastModified: 1593246425244, lastModifiedDate: Sat Jun 27 2020 13:27:05 GMT+0500 (Екатеринбург, стандартное время), webkitRelativePath: "", size: 208474, …}         lastModified: 1593246425244         lastModifiedDate: Sat Jun 27 2020 13:27:05 GMT+0500 (Екатеринбург, стандартное время) {}         name: "image.png"         size: 208474         type: "image/png"         webkitRelativePath: ""         __proto__: File     */      // передаем файл в функцию для дальнейшей обработки     handleFile(file) }) 

Мы только что реализовали простейший механизм «dran’n’drop».

Обрабатываем клик по файлоприемнику (делегируем клик инпуту):

dropZone.addEventListener('click', () => {     // кликаем по скрытому инпуту     input.click()      // обрабатываем изменение инпута     input.addEventListener('change', () => {         // смотрим на то, что получаем         log(input.files)          // получаем следующее (в случае передачи изображения)         /*         FileList {0: File, length: 1}         =>  0: File                 lastModified: 1593246425244                 lastModifiedDate: Sat Jun 27 2020 13:27:05 GMT+0500 (Екатеринбург, стандартное время) {}                 name: "image.png"                 size: 208474                 type: "image/png"                 webkitRelativePath: ""                 __proto__: File             length: 1             __proto__: FileList         */          // извлекаем File         file = input.files[0]          // проверяем         log(file)                  // передаем файл в функцию для дальнейшей обработки         handleFile(file)     }) }) 

Приступаем к обработке файла:

const handleFile = file => {     // дальнейшие рассуждения } 

Удаляем файлоприемник и инпут:

dropZone.remove() input.remove() 

Способ обработки файла зависит от его типа:

log(file.type) // в случае изображения // image/png 

Мы не будем работать с html, css и js-файлами, поэтому запрещаем их обработку:

if (file.type === 'text/html' ||     file.type === 'text/css' ||     file.type === 'text/javascript') return; 

Мы также не будем работать с MS-файлами (имеющими MIME-тип «application/msword», «application/vnd.ms-excel» и т.д.), поскольку их невозможно обработать нативными средствами. Все способы обработки таких файлов, предлагаемые на StackOverflow и других ресурсах, сводятся либо к конвертации в другие форматы с помощью различных библиотек, либо к использованию viewer’ов от Google и Microsoft, которые не хотят работать с файловой системой и localhost. Вместе с тем, тип pdf-файлов также начинается с «application», поэтому такие файлы мы будем обрабатывать отдельно:

if (file.type === 'application/pdf') {     createIframe(file)     return; } 

Для остальных файлов получаем их «групповой» тип:

// нас интересует то, что находится до слеша const type = file.type.replace(/\/.+/, '')  // проверяем log(type) // в случае изображения // image 

Посредством switch..case определяем конкретную функцию обработки файла:

switch (type) {     // если изображение     case 'image':         createImage(file)         break;     // если аудио     case 'audio':         createAudio(file)         break;     // если видео     case 'video':         createVideo(file)         break;     // если текст     case 'text':         createText(file)         break;     // иначе, выводим сообщение о неизвестном формате файла,     // и через две секунды перезагружаем страницу     default:         B.innerHTML = `<h3>Unknown File Format!</h3>`         const timer = setTimeout(() => {             location.reload()             clearTimeout(timer)         }, 2000)         break; } 

Функция обработки изображения:

const createImage = image => {     // создаем элемент "img"     const imageEl = D.createElement('img')     // привязываем его к полученному изображению     imageEl.src = URL.createObjectURL(image)     // проверяем     log(imageEl)     // помещаем в документ     B.append(imageEl)     // удаляем ссылку на файл     URL.revokeObjectURL(image) } 

Функция обработки аудио:

const createAudio = audio => {     // создаем элемент "audio"     const audioEl = D.createElement('audio')     // добавляем панель управления     audioEl.setAttribute('controls', '')     // привязываем элемент к полученному файлу     audioEl.src = URL.createObjectURL(audio)     // проверяем     log(audioEl)     // помещаем в документ     B.append(audioEl)     // запускаем воспроизведение     audioEl.play()     // удаляем ссылку на файл     URL.revokeObjectURL(audio) } 

Функция обработки видео:

const createVideo = video => {     // создаем элемент "video"     const videoEl = D.createElement('video')     // добавляем панель управления     videoEl.setAttribute('controls', '')     // зацикливаем воспроизведение     videoEl.setAttribute('loop', 'true')     // привязываем элемент к полученному файлу     videoEl.src = URL.createObjectURL(video)     // проверяем     log(videoEl)     // помещаем в документ     B.append(videoEl)     // запускаем воспроизведение     videoEl.play()     // удаляем ссылку на файл     URL.revokeObjectURL(video) } 

Функция обработки текста:

const createText = text => {     // создаем экземпляр объекта "FileReader"     const reader = new FileReader()     // читаем файл как текст     // вторым аргументом является кодировка     // по умолчанию - utf-8,     // но она не понимает кириллицу     reader.readAsText(text, 'windows-1251')     // дожидаемся завершения чтения файла     // и помещаем результат в документ     reader.onload = () => B.innerHTML = `<p><pre>${reader.result}</pre></p>` } 

Last, but not least, функция обработки pdf-файлов:

const createIframe = pdf => {     // создаем элемент "iframe"     const iframe = D.createElement('iframe')     // привязываем его к полученному файлу     iframe.src = URL.createObjectURL(pdf)     // увеличиваем размеры фрейма до ширины и высоты области просмотра     iframe.width = innerWidth     iframe.height = innerHeight     // проверяем     log(iframe)     // помещаем в документ     B.append(iframe)     // удаляем ссылку на файл     URL.revokeObjectURL(pdf) } 

Результат:

Создаем JSON-файл

Для второго проекта создадим папку «Create-JSON» в корневой директории (Work-With-Files-in-JavaScript).

Создаем файл «index.html» следующего содержания:

<!-- head --> <!-- materialize css --> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css"> <!-- material icons --> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">  <!-- body --> <h3>Create JSON</h3>  <!-- основной контейнер --> <div class="row main">     <h3>Create JSON</h3>     <form class="col s12">         <!-- первая пара "ключ-значение" -->         <div class="row">             <div class="input-field col s5">                 <label>key</label>                 <input type="text" value="1" required>             </div>             <div class="input-field col s2">                 <p>:</p>             </div>             <div class="input-field col s5">                 <label>value</label>                 <input type="text" value="foo" required>             </div>         </div>          <!-- вторая пара -->         <div class="row">             <div class="input-field col s5">                 <label>key</label>                 <input type="text" value="2" required>             </div>             <div class="input-field col s2">                 <p>:</p>             </div>             <div class="input-field col s5">                 <label>value</label>                 <input type="text" value="bar" required>             </div>         </div>                  <!-- третья пара -->         <div class="row">             <div class="input-field col s5">                 <label>key</label>                 <input type="text" value="3" required>             </div>             <div class="input-field col s2">                 <p>:</p>             </div>             <div class="input-field col s5">                 <label>value</label>                 <input type="text" value="baz" required>             </div>         </div>          <!-- кнопки -->         <div class="row">             <button class="btn waves-effect waves-light create-json">create json                 <i class="material-icons right">send</i>             </button>             <a class="waves-effect waves-light btn get-data"><i class="material-icons right">cloud</i>get data</a>         </div>     </form> </div> 

Для стилизации используется Materialize.

Добавляем парочку собственных стилей:

body {     max-width: 512px;     margin: 0 auto;     text-align: center; }  input {     text-align: center; }  .get-data {     margin-left: 1em; } 

Получаем следующее:

JSON-файлы имеют следующий формат:

{     "ключ": "значение",     "ключ": "значение",     ... } 

Нечетные инпуты с типом «text» — ключи, четные — значения. Присваиваем инпутам значения по умолчанию (значения могут быть любыми). Кнопка с классом «create-json» служит для получения значений, введенных пользователем, и создания файла. Кнопка с классов «get-data» — для получения данных.

Переходим к скрипту:

// находим кнопку с классом "create-json" и обрабатываем нажатие этой кнопки document.querySelector('.create-json').addEventListener('click', ev => {     // любая кнопка в форме имеет тип "submit" по умолчанию, т.е. служит для отправки формы на сервер     // отправка формы влечет за собой перезагрузку страницы     // нам это не нужно, поэтому отключаем стандартное поведение     ev.preventDefault()      // находим все инпуты     const inputs = document.querySelectorAll('input')      // извлекаем значения, введенные пользователем     // это можно сделать разными способами     // такой способ показался мне наиболее оптимальным      // здесь мы реализуем нечто похожее на метод "chunk" библиотеки "lodash"     // значения нечетных инпутов (первого, третьего и пятого) - ключи     // помещаем их в подмассивы в качестве первых элементов     // значения четных инпутов (второго, четвертого и шестого) - значения     // помещаем их в подмассивы в качестве вторых элементов     const arr = []     for (let i = 0; i < inputs.length; ++i) {         arr.push([inputs[i].value, inputs[++i].value])     }      // получаем массив, состоящий из трех подмассивов     console.log(arr)     /*          [             ["1", "foo"]             ["2", "bar"]             ["3", "baz"]         ]     */      // преобразуем массив подмассивов в объект     const data = Object.fromEntries(arr)      // проверяем     console.log(data)     /*          {             1: "foo"             2: "bar"             3: "baz"         }     */          // создаем файл     const file = new Blob(         // сериализуем данные         [JSON.stringify(data)], {             type: 'application/json'         }     )          // проверяем     console.log(file)     /*          {             "1": "foo",             "2": "bar",             "3": "baz"         }     */     // то, что доктор прописал      // создаем элемент "a"     const link = document.createElement('a')     // привязываем атрибут "href" тега "a" к созданному файлу     link.setAttribute('href', URL.createObjectURL(file))     // атрибут "download" позволяет скачивать файлы, на которые указывает ссылка     // значение этого атрибута - название скачиваемого файла     link.setAttribute('download', 'data.json')     // текстовое содержимое ссылки     link.textContent = 'DOWNLOAD DATA'     // помещаем элемент в контейнер с классом "main"     document.querySelector('.main').append(link)     // удаляем ссылку на файл     URL.revokeObjectURL(file)      // { once: true } автоматически удаляет обработчик после первого использования     // повторный клик приводит к перезагрузке страницы }, { once: true }) 

По клику на кнопке «CREATE JSON» формируется файл «data.json», появляется ссылка «DOWNLOAD DATA» для скачивания этого файла.

Что мы можем сделать с этим файлом? Скачиваем его и помещаем в папку «Create-JSON».

Получаем:

// находим кнопку (которая на самом деле ссылка) с классом "get-data" и обрабатываем ее нажатие document.querySelector('.get-data').addEventListener('click', () => {     // с помощью IIFE и async..await получаем данные и выводим их в консоль в виде таблицы     (async () => {         const response = await fetch('data.json')          // разбираем (парсим) ответ         const data = await response.json()          console.table(data)     })() }) 

Результат:

Создаем генератор вопросов и тестер

Генератор вопросов

Для третьего проекта создадим папку «Test-Maker» в корневой директории.

Создаем файл «createTest.html» следующего содержания:

<!-- head --> <!-- Bootstrap CSS --> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"     integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">  <!-- body --> <!-- основной контейнер --> <div class="container">     <h3>Create Test</h3>     <form id="questions-box">         <!-- контейнер для вопросов -->         <div class="question-box">             <br><hr>             <h4 class="title"></h4>             <!-- вопрос -->             <div class="row">                 <input type="text" class="form-control col-11 question-text" value="first question" >                 <!-- кнопка для удаления вопроса-->                 <button class="btn btn-danger col remove-question-btn">X</button>             </div>             <hr>              <h4>Answers:</h4>             <!-- варианты ответов -->             <div class="row answers-box">                 <!-- первый вариант -->                 <div class="input-group">                     <div class="input-group-prepend">                         <div class="input-group-text">                             <input type="radio" checked name="answer">                         </div>                     </div>                      <input class="form-control answer-text" type="text" value="foo" >                      <!-- кнопка для удаления варианта -->                     <div class="input-group-append">                         <button class="btn btn-outline-danger remove-answer-btn">X</button>                     </div>                 </div>                 <!-- второй вариант -->                 <div class="input-group">                     <div class="input-group-prepend">                         <div class="input-group-text">                             <input type="radio" name="answer">                         </div>                     </div>                      <input class="form-control answer-text" type="text" value="bar" >                      <div class="input-group-append">                         <button class="btn btn-outline-danger remove-answer-btn">X</button>                     </div>                 </div>                 <!-- третий вариант -->                 <div class="input-group">                     <div class="input-group-prepend">                         <div class="input-group-text">                             <input type="radio" name="answer">                         </div>                     </div>                      <input class="form-control answer-text" type="text" value="baz" >                      <div class="input-group-append">                         <button class="btn btn-outline-danger remove-answer-btn">X</button>                     </div>                 </div>             </div>             <br>              <!-- кнопка для добавления варианта ответа -->             <button class="btn btn-primary add-answer-btn">Add answer</button>             <hr>              <h4>Explanation:</h4>             <!-- объяснение -->             <div class="row explanation-box">                 <input type="text" value="first explanation" class="form-control explanation-text" >             </div>         </div>     </form>      <br>      <!-- кнопки для добавления вопроса и создания теста -->     <button class="btn btn-primary" id="add-question-btn">Add question</button>     <button class="btn btn-primary" id="create-test-btn">Create test</button> </div> 

На этот раз для стилизации используется Bootstrap. Мы не используем атрибуты «required», поскольку будем валидировать форму в JS (с required поведение формы, состоящей из нескольких обязательных полей, становится раздражающим).

Добавляем парочку собственных стилей:

body {         max-width: 512px;         margin: 0 auto;         text-align: center;     }  input[type="radio"] {     cursor: pointer; } 

Получаем следующее:

У нас имеется шаблон вопроса. Предлагаю вынести его в отдельный файл для использования в качестве компонента с помощью динамического импорта. Создаем файл «Question.js» следующего содержания:

export default (name = Date.now()) => ` <div class="question-box">     <br><hr>     <h4 class="title"></h4>     <div class="row">         <input type="text" class="form-control col-11 question-text">         <button class="btn btn-danger col remove-question-btn">X</button>     </div>     <hr>     <h4>Answers:</h4>     <div class="row answers-box">         <div class="input-group">             <div class="input-group-prepend">                 <div class="input-group-text">                     <input type="radio" checked name="${name}">                 </div>             </div>              <input class="form-control answer-text" type="text" >              <div class="input-group-append">                 <button class="btn btn-outline-danger remove-answer-btn">X</button>             </div>         </div>         <div class="input-group">             <div class="input-group-prepend">                 <div class="input-group-text">                     <input type="radio" name="${name}">                 </div>             </div>              <input class="form-control answer-text" type="text" >              <div class="input-group-append">                 <button class="btn btn-outline-danger remove-answer-btn">X</button>             </div>         </div>         <div class="input-group">             <div class="input-group-prepend">                 <div class="input-group-text">                     <input type="radio" name="${name}">                 </div>             </div>              <input class="form-control answer-text" type="text" >              <div class="input-group-append">                 <button class="btn btn-outline-danger remove-answer-btn">X</button>             </div>         </div>     </div>     <br>     <button class="btn btn-primary add-answer-btn">Add answer</button>     <hr>     <h4>Explanation:</h4>     <div class="row explanation-box">         <input type="text" class="form-control explanation-text">     </div> </div> ` 

Здесь у нас все тоже самое, что и в createTest.html, за исключением того, что мы убрали значения по умолчанию для инпутов и передаем аргумент «name» в качестве значения одноименного атрибута (данный атрибут должен быть уникальным для каждого вопроса — это дает возможность переключать варианты ответов, выбирать один из нескольких). Значением name по умолчанию является время в миллисекундах, прошедшее с 1 января 1970 года, — простая альтернатива генераторам случайных значений типа Nanoid, используемых для получения уникального идентификатора (вряд ли пользователь успеет создать два вопроса за 1 мс).

Переходим к основному скрипту.

Я собираюсь создать несколько вспомогательных (фабричных) функций, но это не обязательно.

Вспомогательные функции:

// функция нахождения одного элемента с указанным селектором const findOne = (element, selector) => element.querySelector(selector) // функция нахождения всех элементов с указанным селектором const findAll = (element, selector) => element.querySelectorAll(selector) // функция добавления обработчика указанного события const addHandler = (element, event, callback) => element.addEventListener(event, callback)  // функция нахождения родительских элементов // одной из проблем Bootstrap является глубокая вложенность элементов, // при работе с DOM часто возникает необходимость обращения к родителю целевого элемента // наша функция принимает два аргумента - элемент и глубину (вложенности), // которая по умолчанию равняется 1 const findParent = (element, depth = 1) => {     // если элемент находится на первом уровне вложенности,     // значит, нам нужен его родительский элемент     let parentEl = element.parentElement      // иначе, мы ищем родителя родителя и т.д. с помощью рекурсии     while (depth > 1) {         // рекурсия         parentEl = findParent(parentEl)         // уменьшаем значение глубины         depth--     }      // возвращаем искомый элемент     return parentEl } 

В нашем случае в поисках родительского элемента мы дойдем до третьего уровня вложенности. Поскольку мы знаем точное количество этих уровней, мы могли бы использовать if..else if или switch..case, однако вариант с рекурсией является более универсальным.

Еще раз: вводить фабричные функции не обязательно, вы вполне можете обойтись стандартным функционалом.

Находим основной контейнер и контейнер для вопросов, а также отключаем отправку формы:

const C = findOne(document.body, '.container') // const C = document.body.querySelector('.container') const Q = findOne(C, '#questions-box')  addHandler(Q, 'submit', ev => ev.preventDefault()) // Q.addEventListener('submit', ev => ev.preventDefault()) 

Функция инициализации кнопок для удаления вопроса:

// функция принимает вопрос в качестве аргумента const initRemoveQuestionBtn = q => {     const removeQuestionBtn = findOne(q, '.remove-question-btn')      addHandler(removeQuestionBtn, 'click', ev => {         // удаляем родителя родителя кнопки         /*         =>  <div class="question-box">                 <br><hr>                 <h4 class="title"></h4>             =>  <div class="row">                     <input type="text" class="form-control col-11 question-text" value="first question" >                 =>  <button class="btn btn-danger col remove-question-btn">X</button>                 </div>                  ...         */         findParent(ev.target, 2).remove()         // ev.target.parentElement.parentElement.remove()          // при удалении вопроса необходимо обновить номера вопросов         initTitles()     }, {         // удаляем обработчик после использования         once: true     }) } 

Функция инициализации кнопок для удаления варианта ответа:

const initRemoveAnswerBtns = q => {     const removeAnswerBtns = findAll(q, '.remove-answer-btn')     // const removeAnswerBtns = q.querySelectorAll('.remove-answer-btn')      removeAnswerBtns.forEach(btn => addHandler(btn, 'click', ev => {         /*         =>  <div class="input-group">               ...            =>  <div class="input-group-append">               =>  <button class="btn btn-outline-danger remove-answer-btn">X</button>               </div>             </div>         */         findParent(ev.target, 2).remove()     }, {         once: true     })) } 

Функция инициализации кнопок для добавления варианта ответа:

const initAddAnswerBtns = q => {     const addAnswerBtns = findAll(q, '.add-answer-btn')      addAnswerBtns.forEach(btn => addHandler(btn, 'click', ev => {         // находим контейнер для ответов         const answers = findOne(findParent(ev.target), '.answers-box')         // const answers = ev.target.parentElement.querySelector('.answers-box')          // атрибут "name" должен быть уникальным для каждого вопроса         let name         answers.children.length > 0             ? name = findOne(answers, 'input[type="radio"]').name             : name = Date.now()          // шаблон варианта ответа         const template = `                 <div class="input-group">                     <div class="input-group-prepend">                         <div class="input-group-text">                             <input type="radio" name="${name}">                         </div>                     </div>                      <input class="form-control answer-text" type="text" value="">                      <div class="input-group-append">                         <button class="btn btn-outline-danger remove-answer-btn">X</button>                     </div>                 </div>                 `         // помещаем шаблон в конец контейнера для ответов         answers.insertAdjacentHTML('beforeend', template)                  // инициализируем кнопки для удаления вариантов ответа         initRemoveAnswerBtns(q)     })) } 

Объединяем функции инициализации кнопок в одну:

const initBtns = q => {     initRemoveQuestionBtn(q)     initRemoveAnswerBtns(q)     initAddAnswerBtns(q) } 

Функция инициализации заголовков вопросов:

const initTitles = () => {     // преобразуем коллекцию в массив с целью дальнейшего определения номера вопроса     const questions = Array.from(findAll(Q, '.question-box'))      // перебираем массив     questions.map(q => {         const title = findOne(q, '.title')         // номер вопроса - это индекс элемента + 1         title.textContent = `Question ${questions.indexOf(q) + 1}`     }) } 

Инициализируем кнопки и заголовок вопроса:

initBtns(findOne(Q, '.question-box'))  initTitles() 

Функция добавления вопроса:

// находим кнопку const addQuestionBtn = findOne(C, '#add-question-btn')  addHandler(addQuestionBtn, 'click', ev => {     // с помощью IIFE и async..await получаем данные посредством динамического импорта     // помещаем их в контейнер для вопросов     // находим добавленный вопрос     // и инициализируем кнопки этого вопроса и все заголовки     (async () => {         const data = await import('./Question.js')         const template = await data.default()         await Q.insertAdjacentHTML('beforeend', template)          const question = findOne(Q, '.question-box:last-child')         initBtns(question)         initTitles()     })() }) 

Функция создания теста:

// обрабатываем клик по кнопке для создания теста addHandler(findOne(C, '#create-test-btn'), 'click', () => createTest())  const createTest = () => {     // создаем пустой объект     const obj = {}      // находим все вопросы     const questions = findAll(Q, '.question-box')      // простая функция валидации формы     // поля не должы быть пустыми     const isEmpty = (...args) => {         // для каждого переданного аргумента         args.map(arg => {             // заменяем два и более пробела на один             // и удаляем пробелы в начале и конце строки             arg = arg.replace(/\s+/g, '').trim()             // если значением аргумента является пустая строка             if (arg === '') {                 // сообщаем об этом пользователю                 alert('Some field is empty!')                 // и выбрасываем исключение                 throw new Error()             }         })     }      // для каждого вопроса     questions.forEach(q => {         // текст вопроса         const questionText = findOne(q, '.question-text').value          // создаем массив для вариантов ответа         // количество вариантов может быть любым         const answersText = []         findAll(q, '.answer-text').forEach(text => answersText.push(text.value))                  // текст правильного ответа - значение соседнего по отношению к вложенному инпуту с атрибутом "checked" инпута с классом "answer-text"         /*         =>  <div class="input-group">               <div class="input-group-prepend">                 <div class="input-group-text">               =>  <input type="radio" checked name="answer">                 </div>               </div>            => <input class="form-control answer-text" type="text" value="foo" >            ...         */         const rightAnswerText = findOne(findParent(findOne(q, 'input:checked'), 3), '.answer-text').value          // текст объяснения         const explanationText = findOne(q, '.explanation-text').value          // валидируем форму         isEmpty(questionText, ...answersText, explanationText)                  // помещаем значения в объект с ключом "индекс вопроса"         obj[questions.indexOf(q)] = {             question: questionText,             answers: answersText,             rightAnswer: rightAnswerText,             explanation: explanationText         }     })      // проверяем     console.table(obj)      // создаем файл     const data = new Blob(         [JSON.stringify(obj)], {             type: 'application/json'         }     )          // если файл уже создан     // удаляем ссылку     if (findOne(C, 'a') !== null) {         findOne(C, 'a').remove()     }          // по старой схеме     const link = document.createElement('a')     link.setAttribute('href', URL.createObjectURL(data))     link.setAttribute('download', 'data.json')     link.className = 'btn btn-success'     link.textContent = 'Download data'     C.append(link)     URL.revokeObjectURL(data) } 

Результат:

Используем данные из файла

С помощью генератора вопросов создадим такой файл:

{     "0": {         "question": "first question",         "answers": ["foo", "bar", "baz"],         "rightAnswer": "foo",         "explanation": "first explanation"     },     "1": {         "question": "second question",         "answers": ["foo", "bar", "baz"],         "rightAnswer": "bar",         "explanation": "second explanation"     },     "2": {         "question": "third question",         "answers": ["foo", "bar", "baz"],         "rightAnswer": "baz",         "explanation": "third explanation"     } } 

Помещаем этот файл (data.json) в папку «Test-Maker».

Создаем файл «useData.html» следующего содержания:

<!-- head --> <!-- Bootstrap CSS --> <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css"         integrity="sha384-9aIt2nRpC12Uk9gS9baDl411NQApFmC26EwAOH8WgZl5MYYxFfc+NcPb1dKGj7Sk" crossorigin="anonymous">  <!-- body --> <h1>Use data</h1> 

Добавляем парочку собственных стилей:

body {     max-width: 512px;     margin: 0 auto;     text-align: center; }  section *:not(h3) {     text-align: left; }  input, button {     margin: .4em; }  label, input {     cursor: pointer; }  .right-answer, .explanation {     display: none; } 

Скрипт:

// функция получения данных const getData = async url => {     const response = await fetch(url)     const data = await response.json()     return data }  // получаем данные getData('data.json')     .then(data => {         // проверяем         console.table(data)                  // передаем данные функции создания теста         createTest(data)     })  // генерируем значение name let name = Date.now() // функция создания теста const createTest = data => {     // data - это объект из объектов     // для каждого объекта     for (const item in data) {         // проверяем         console.log(data[item])          // деструктурируем объект,         // получаем значения вопроса, вариантов ответа, правильного ответа и объяснения         const {             question,             answers,             rightAnswer,             explanation         } = data[item]          // делаем значение name уникальным для каждого вопроса         name++          // шаблон вопроса         const questionTemplate = `             <hr>             <section>                 <h3>Question ${item}: ${question}</h3>                 <form>                     <legend>Answers</legend>                     ${answers.reduce((html, ans) => html += `<label><input type="radio" name="${name}">${ans}</label><br>`, '')}                 </form>                 <p class="right-answer">Right answer: ${rightAnswer}</p>                 <p class="explanation">Explanation: ${explanation}</p>             </section>         `                  // помещаем шаблон в конец документа         document.body.insertAdjacentHTML('beforeend', questionTemplate)     })      // находим вопросы     const forms = document.querySelectorAll('form')      // выбираем первые варианты ответа на все вопросы     forms.forEach(form => {         const input = form.querySelector('input')         input.click()     })      // создаем кнопку для проверки ответов     // и помещаем ее в конец документа     const btn = document.createElement('button')     btn.className = 'btn btn-primary'     btn.textContent = 'Check answers'     document.body.append(btn)      // обрабатываем нажатие этой кнопки     btn.addEventListener('click', () => {         // создаем массив для ответов         const answers = []          // для каждого вопроса         forms.forEach(form => {             // получаем значение выбранного (пользовательского) ответа             const chosenAnswer = form.querySelector('input:checked').parentElement.textContent             // получаем значение правильного ответа             const rightAnswer = form.nextElementSibling.textContent.replace('Right answer: ', '')             // добавляем эти значения в массив в виде подмассива             answers.push([chosenAnswer, rightAnswer])         })          console.log(answers)         // получаем следующее         // в случае, когда ответ на третий вопрос неправильный         /*         Array(3)             0: (2) ["foo", "foo"]             1: (2) ["bar", "bar"]             2: (2) ["foo", "baz"]         */          // передаем массив функции         checkAnswers(answers)     })      // функция проверки (сравнения) ответов     const checkAnswers = answers => {         // счетчики для количества правильных и неправильных ответов         let rightAnswers = 0         let wrongAnswers = 0          // для каждого подмассива,         // где первый элемент - выбранный (пользовательский) ответ,         // а второй элемент - правильный ответ         for (const answer of answers) {             // если выбранный и правильный ответы совпадают             if (answer[0] === answer[1]) {                 // увеличиваем количество правильных ответов                 rightAnswers++             // иначе             } else {                 // увеличиваем количество неправильных ответов                 wrongAnswers++                  // находим вопрос с неправльным ответом                 const wrongSection = forms[answers.indexOf(answer)].parentElement                  // показываем правильный ответ и объяснение                 wrongSection.querySelector('.right-answer').style.display = 'block'                 wrongSection.querySelector('.explanation').style.display = 'block'             }         }          // определяем процент правильных ответов         const percent = parseInt(rightAnswers / answers.length * 100)          // строка-результат         let result = ''                  // в зависимости от процента правильных ответов         // присваиваем result соответствующее значение         if (percent >= 80) {             result = 'Great job, super genius!'         } else if (percent > 50) {             result = 'Not bad, but you can do it better!'         } else {             result = 'Very bad, try again!'         }          // шаблон результатов теста         const resultTemplate = `             <h3>Your result</h3>             <p>Right answers: ${rightAnswers}</p>             <p>Wrong answers: ${wrongAnswers}</p>             <p>Percentage of correct answers: ${percent}</p>             <p>${result}</p>         `                  // помещаем шаблон в конец документа         document.body.insertAdjacentHTML('beforeend', resultTemplate)     } } 

Результат (в случае, когда ответ на третий вопрос неправильный):

Бонус. Записываем данные в CloudFlare

Заходим на cloudflare.com, регистрируемся, нажимаем на Workers справа, затем на кнопку «Create a Worker».



Меняем название воркера на «data» (это не обязательно). В поле "{} Script" вставляем следующий код и нажимаем на кнопку «Save and Deploy»:

// обрабатываем полученный запрос addEventListener('fetch', event => {     event.respondWith(         new Response(             // наши данные             `{                 "0": {                     "question": "first question",                     "answers": ["foo", "bar", "baz"],                     "rightAnswer": "foo",                     "explanation": "first explanation"                 },                 "1": {                     "question": "second question",                     "answers": ["foo", "bar", "baz"],                     "rightAnswer": "bar",                     "explanation": "second explanation"                 },                 "2": {                     "question": "third question",                     "answers": ["foo", "bar", "baz"],                     "rightAnswer": "baz",                     "explanation": "third explanation"                 }             }`,             {                 status: 200,                 // специальный заголовок для преодоления CORS                 headers: new Headers({'Access-Control-Allow-Origin': '*'})             })         )     }) 

Теперь мы можем получать данные с CloudFlare. Для этого достаточно указать URL воркера вместо ‘data.json’ в функции «getData». В моем случае это выглядит так: getData(‘https://data.aio350.workers.dev/’).then(…).

Длинная статья получилась. Надеюсь, Вы нашли в ней для себя что-то полезное.

Благодарю за внимание.

ссылка на оригинал статьи https://habr.com/ru/post/511742/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *