Pull to refresh

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

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

Мнение о том, что 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(...).

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

Благодарю за внимание.
Tags:
Hubs:
+4
Comments1

Articles