Дело было вечером...

Как многие сказки начинаются с уже привычных фраз «жили‑были...» или «в некотором царстве, в некотором государстве...», так многие пет‑проекты или просто творческие эксперименты, опыты программиста начинаются с фразы «дело было вечером, делать было нечего...». Точно так же и в моем случае — появилось свободное время, так почему бы не потратить его с пользой?

Сразу предупрежу, что ничего кардинально нового в этом проекте не предвидится и я не ставил цели «отжать» пользователей у конкурентов. Просто решил поделиться опытом разработки, возможно, кому‑то будут полезны описанные нюансы и решения. Кроме того, надеюсь и сам получить пользу от ваших замечаний, комментариев и предложений.

Цель минимум — повторить базовый функционал уже существующих решений, цель максимум — добавить новый функционал и внести те изменения, которых мне не хватает. Весь процесс буду выкладывать на GitHub, проект полностью открытый и свободный.

В чем суть проекта?

Бывали ли у вас такие ситуации, когда вам ну очень не хватало того или иного небольшого, но очень полезного функционала на каком-либо сайте? Или просто хотелось немного изменить оформление на более удобное? Лично у меня такое бывало не раз. Но при этом я даже не задумывался, что у данной проблемы может быть решение - это ведь не мой сайт, не мой сервис, как я могу его изменить? Так было до тех пор, пока я не наткнулся на крайне полезное расширение для браузера - User Javascript and CSS. На самом деле позже я обнаружил и другое, более популярное расширение - Tampermonkey, однако мне оно показалось менее дружественным и удобным в пользовании, с более топорным интерфейсом.

Таким образом, учитывая свои предпочтения, за образец при разработке я решил взять User Javascript and CSS.

В данной вводной статье я рассмотрю вопросы, касающиеся подготовки к разработке расширения Chrome на Angular, уделю внимание сложностям, с которыми лично я столкнулся и, которые успешно решил. Объем вводных вопросов, требующих разъяснений, оказался довольно большим, поэтому к реализации основного функционала расширения я приступлю в следующей статье.

А оно вообще нужно?

У тех, кто не пользовался подобного рода расширениями, может возникнуть резонный вопрос - а зачем вообще нужен такой функционал? Вариантов применения множество и, наверняка, поразмыслив, каждый сможет найти какое-то свое новое применение. Чтобы сильно не растягивать статью, приведу лишь несколько примеров использования:

  • Написание простенького кода буквально в пару строчек для автоматического редиректа с Youtube Shorts https://www.youtube.com/shorts/([^/]+) на интерфейс обычных видео https://www.youtube.com/watch?v=$1. Лично для меня стандартный вид привычнее + не люблю, когда нет возможности прокрутить воспроизведение назад, вперед или к нужному моменту.

  • Одно из моих увлечений - чтение, читаю я довольно много и в разных жанрах. Регулярно обращаюсь к livelib.ru и goodreads.com для поисков очередного «литературного приключения». Лично я помимо персональных предпочтений, моего текущего настроения и пр. обращаю внимание прежде всего на рейтинг книги. Да, я понимаю, что он не является 100% гарантией — мне может понравиться книга с низким рейтингом, а книга с высоким рейтингом оказаться скучной и неинтересной. Но все же я склоняюсь к тому, что выбирая книги с высоким рейтингом, я уменьшаю свои шансы на зря потраченное время за неинтересной книгой. Собственно именно для того, чтобы оптимизировать свои поиски, я написал небольшие скрипты для обоих сайтов. На livelib.ru я добавил скрипт, который «пролистывает» все страницы из списка книг (например, список книг определенного жанра, либо определенной подборки) и сортирует книги в порядке убывания рейтинга и количества отзывов. На goodreads.com я сделал примерно также, а на некоторых типах страниц выделил книги с рейтингом более 4.2, а книги с меньшим рейтингом сделал более бледными и черно‑белыми, чтобы не отвлекали взгляд + добавил вывод рейтинга на обложку книги. Теперь я могу быстро просматривать списки, обращая внимание только на книги с высокой оценкой и большим количеством отзывов. Вот так у меня выглядит список книг:

  • Или взять ту же ленту Хабра. Ну надоело мне постоянно скролить это полотно из статей, да еще с кучей отвлекающих картинок. Набросал за 10 минут скрипт, который вытягивает со страницы заголовки статей с их введением, отобразил это все в виде списка + показывается вводный текст при наведении на иконку рядом с заголовком. Все это просто наложил поверх страницы Хабра. Теперь быстро просматриваю список статей, а то, что заинтересует, открываю уже в отдельных вкладках. В итоге вот так у меня выглядит лента Хабра:

  • Другой, совсем простой пример. Сайт сети магазинов Лента. Выкатили они недавно новый дизайн, местами руки оторвать хочется... (знакомая история? такие мысли, думаю, посещают многих время от времени при виде очередного неудачного творения дизайнеров того или иного сайта). Так вот, набираю в поиске лента нужный мне продукт, получаю список разновидностей товара, в котором наименования обрезаны (см. скрин ниже), и различить товары друг от друга можно только наводя на каждый из них, ожидая каждый раз пока появится тайтл с полным наименованием. Такой дизайнерский «косяк» исправился всего лишь одной строкой CSS-кода .lu-product-card-name_sale {-webkit-line-clamp: unset!important;}. Таким образом, корректировкой всего лишь в одну строку иногда можно значительно улучшить наш пользовательский опыт и удобство пользования сайтами.

Подготовка проекта Angular для разработки расширения Chrome

Последняя версия Angular на данный момент 18я, с ней мы и будем работать. Первым делом после создания чистого проекта нужно добавить manifest.json в папку public, он будет переноситься в папку с собранным проектом без изменений. В данном файле описываются некоторые параметры разрабатываемого расширения. Формат файла подробно описан в официальной документации, поэтому останавливаться на базовых вещах не буду, отмечу лишь те опции, на которые нужно обратить внимание при разработке нашего проекта.

Структура Chrome расширений предполагает несколько возможных точек входа в приложение - несколько отдельных страниц, разбитых по функциональному назначению. В нашем случае, мы будем использоваться две из них - страница параметров (options) и всплывающая страница (popup). Страница параметров - основная страница, на которой мы будем управлять (добавлять, редактировать и пр.) всей нашей коллекцией созданных кодов (связок js + css) для разных сайтов. Перейти к данной странице можно, вызвав контекстное меню на иконке установленного расширения и, выбрав пункт "Параметры". Всплывающая страница - та страница, которая показывается во всплывающем окне при клике левой кнопкой на иконку нашего расширения. На ней мы будем отображать зарегистрированные программные коды для текущей активной страницы в браузере, а также набор возможных действий для данной страницы (добавить код для страницы, добавить код для всего домена и т.д.).

Страницы параметров (options) и всплывающего окна (popup)

В manifest.json мы должны указать html-файлы, которые будут открываться для страницы параметров и для всплывающего окна, обычно это options.html и popup.html. И вот тут сразу возникает проблема. Дело в том, что Angular на выходе формирует единственный html-файл index.html. Конечно, можно создать отдельные проекты для страницы настроек и для всплывающей страницы, получив в результате два разных html-файла. Однако, это абсолютно неверный подход, т.к. придется дублировать большую часть кода, ведь обе страницы используют общие сущности и общий функционал.

У данной проблемы есть решение - для обеих страниц мы будем использовать один файл index.html, но для того, чтобы на них отображалось разное содержимое, мы задействуем режим роутинга по хэшу. Что это значит? Дефолтный роутер определяет текущий путь из пути, указанного в url. При переключении на режим хэш-роутинга текущий путь берется из хэш-части url. Например, в обычном варианте путь '/options' соответствует url адресу '/options', но после активации хэш роутинга url адрес становится /#/options. Соответственно в нашем случае будет два url /#/options и /#/popup.

В файл app.config.ts добавляем опцию withHashLocation для активации хэш-роутинга:

providers: [  
  provideRouter(routes, withHashLocation()),  
]

После это создаем 2 компонента:

ng g c components/options/options
ng g c components/popup/popup

И прописываем маршруты в app.route.ts, components/options/options.route.ts и components/popup/popup.route.ts:

// app.route.ts

import { Routes } from '@angular/router';  
  
export const routes: Routes = [  
  {  
    path: '',  
    pathMatch: 'full',  
    redirectTo: 'options',  
  },  
  {  
    path: 'popup',  
    loadChildren: () => import('./components/popup/popup.routes').then(c => c.routes)  
  },  
  {  
    path: 'options',  
    loadChildren: () => import('./components/options/options.routes').then(c => c.routes)  
  }  
];
// components/options/options.route.ts

import {Routes} from "@angular/router";  
import {OptionsComponent} from "./options/options.component";  
  
export const routes: Routes = [  
  {  
    path: '',  
    pathMatch: 'full',  
    component: OptionsComponent  
  }  
]
// components/popup/popup.route.ts

import {Routes} from "@angular/router";  
import {PopupComponent} from "./popup/popup.component";  
  
export const routes: Routes = [  
  {  
    path: '',  
    pathMatch: 'full',  
    component: PopupComponent  
  }  
]

Запускаем проект, проверяем, корректно открывается и http://localhost:4200/#/options и http://localhost:4200/#/popup. Теперь запустим сборку проекта и установим как расширение Chrome. Для этого в браузере находим пункт меню "Управление расширениями", в правом верхнем углу включаем опцию "Режим разработчика", жмем "Загрузить распакованное расширение" и выбираем папку с нашим собранным проектом.

После этого, если мы кликнем на иконку установленного расширения, то увидим наш попап:

Если кликнем правой кнопкой по иконке установленного расширения и выберем "Параметры", то откроется наша страница параметров такого вида chrome-extension://ioiccgeogdlddigdjodhdchkhncbddpk/#/options. Вроде все работает? Не совсем. Нажмите "Обновить" на странице параметров и получите ошибку:

Избавляемся от ненужного редиректа

Почему так происходит? Дело в том, что в manifest.json для расширения Chrome мы должны указывать в адресе именно html-файлы, а не привычный путь на сервере (ведь как такового сервера тут нет, расширение использует локальные файлы, установленные на ПК пользователя). Т.е. пути должны быть заданы как index.html#/options и index.html#/popup соответственно. Если открыть такой адрес, Angular обработает его корректно, но произведет редирект (без перезагрузки страницы) на url виде /#/options. Теперь, когда мы пытаемся обновить страницу, Chrome вполне логично выбросит ошибку, т.к. такого пути в расширении не зарегистрировано и не предусмотрено.
Собственно, что нам нужно для решения проблемы, это избавиться от редиректа. Почему вообще происходит редирект? Причина проста и кроется она в <base href="/">, заданном в index.html. Исправляем данное недоразумение одним взмахом, просто заменим на <base href=""> и никакого редиректа происходить не будет, будет открываться корректная страница /index.html#/options.

Отключение механизма асинхронной загрузки стилей

В целях оптимизации скорости загрузки приложения, по умолчанию в Angular предусмотрен внутренний механизм разделения стилей на синхронную загрузку "критически важных" стилей и асинхронную загрузку остальных стилей, чтобы не тормозилась загрузка другого контента и важных ресурсов. Правда данный механизм не включится сразу, а сработает только после добавления стилей в style.css проекта, поэтому поначалу вы даже можете и не столкнуться с описываемой проблемой, но она все равно всплывет в процессе. Асинхронная загрузка реализуется конструкцией вида:

<link rel="stylesheet" href="styles-ZHBDMHPG.css" media="print" onload="this.media='all'">

Проблема в том, что такого рода включения js-кода (onload="this.media='all'") в html-код страницы не разрешены для Chrome расширений (подробнее можно прочитать в официальной документации про Content Security Policy: CSP - inline event handlers). В консоли Chrome появится вот такое сообщение об ошибке:

Refused to execute inline event handler because it violates the following Content Security Policy directive: "script-src 'self'". Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present.
Refused to execute inline event handler because it violates the following Content Security Policy directive: "script-src 'self' 'wasm-unsafe-eval' 'inline-speculation-rules' http://localhost:* http://127.0.0.1:*". Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. Note that hashes do not apply to event handlers, style attributes and javascript: navigations unless the 'unsafe-hashes' keyword is present.

Решается проблема просто, в angular.json отключаем описанный механизм в ветке architect.build.configurations.production:

"optimization": {  
  "styles": {  
    "inlineCritical": false  
  }  
}

Собственно на этом нюансы специфичные для разработки расширения Chrome на Angular заканчиваются. Есть, конечно, еще и другие особенности (например, формирование отдельных файлов скриптов background.js, content.js и т.д.), но в рамках того функционала, который будет реализован в нашем расширении, это пока не требуется.

Укрощение редактора Ace

Теперь можно было бы перейти к основной части разработки, но... Остановимся на одном важном этапе - подключение редактора. С его подключением также, наверняка, у многих могут возникнуть трудности.

Сразу создадим компонент EditorComponent, который затем вставим в шаблон компонента OptionsComponent:

ng g c components/options/editor

Какой редактор выбирать, вопрос у меня не стоял - редактор Ace, который используется в расширении User Javascript and CSS меня полностью устраивает. В нем есть поддержка огромной кучи языков (которые нам, конечно же, не понадобятся), раскраска и форматирование кода, проверка синтаксиса, автодополнение, открытый код. В общем, учитывая, что мы пишем все таки не IDE для профессиональной разработки, а простенькую среду с редактором для написания небольших скриптов, функционала более, чем достаточно.

На обычную html-страницу Ace редактор подключается довольно просто - стандартным включением:

<script src="https://cdnjs.cloudflare.com/ajax/libs/ace/1.36.2/ace.min.js" integrity="sha512-xylzfb6LZn1im1ge493MNv0fISAU4QkshbKz/jVh6MJFAlZ6T1NRDJa0ZKb7ECuhSTO7fVy8wkXkT95/f4R4nA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>

А вот с подключением в Angular, да еще с учетом работы в качестве расширения Chrome, возникают сложности. Разберемся по порядку. Для начала устанавливаем npm-пакет с необходимыми файлами для подключения редактора:

npm i ace-builds

Далее в компоненте EditorComponent, в котором у нас будет отображаться редактор, прописываем:

import {Ace} from "ace-builds"
import * as ace from 'ace-builds/src-noconflict/ace'  
import "ace-builds/src-noconflict/ext-language_tools"  
import "ace-builds/src-noconflict/snippets/javascript"  
import "ace-builds/src-noconflict/snippets/css"

С первой и второй строками все просто - сначала подключаем определения типов, интерфейсов, классов из пространства Ace, затем подключаем основной функционал редактора. Остальные строки подключают дополнительный функционал - атводополнение кода и возможность использования предопределенных сниппетов для JS и CSS.

Это еще не все. Для того чтобы подключить распознавание синтаксиса определенного языка при инициализации редактора, мы должны указать режим, например, "ace/mode/css" или "ace/mode/javascript". После того как мы укажем режим, Ace редактор будет динамически подгружать js-файл для соответствующего режима - mode-javascript.js или mode-css.js соответственно, а также worker-javascript.js и worker-css.js (для проверки синтаксиса). Вот только таких файлов нет в нашем собранном проекте. Как быть? Нам нужно сделать так, чтобы эти файлы автоматически копировались без изменений из ace-builds в выходную папку нашего проекта при сборке. В angular.json добавляем в опцию architect.build.options.assets инструкцию для копирования необходимых файлов:

"assets": [  
  {  
    "glob": "(mode-javascript|worker-javascript|mode-css|worker-css).js",  
    "input": "./node_modules/ace-builds/src-min-noconflict/",  
    "output": "./ace/"  
  }, 
]

Чтобы Ace редактор знал, из какого места загружать дополнительные файлы, задаем в коде компонента EditorComponent:

ace.config.set('basePath', 'ace');

И последний нюанс - проверку синтаксиса Ace редактор запускает в отдельном worker'е через Blob. Такой подход запрещен для расширений Chrome. Необходимо, чтобы worker запускался по обычному url, поэтому пропишем:

ace.config.set("loadWorkerFromBlob", false)

Интеграция редактора в FormGroup родительского компонента

Редактор у нас подключен и работает, но чтобы уже полностью закрыть вопрос с редактором, давайте разберемся, как правильно интегрировать его в приложение. Нам нужны будут два экземпляра редактора - для Javascript и для CSS кода. При этом компонент редактора будет подключаться из другого, родительского компонента - OptionsComponent. В этом родительском компоненте создадим FormGroup для которого код, набранный в редакторе, должен стать одним из дочерних свойств. Получается такая ситуация, что у компонента EditorComponent нет прямого доступа к FormGroup родительского компонента, но их нужно как-то связать.

Как этого добиться? Сразу отмечу, что можно придумать разные варианты решения данной задачи и предложенный вариант не является единственным.

Включаем EditorComponent в шаблон OptionsComponent внутри области действия директивы formGroup. Для того чтобы дочерний компонент получил доступ к экземпляру FormGroup родительского компонента, в конструкторе используем механизм dependecy injection для получения доступа к директиве FormGroupDirective:

constructor(  
  private formGroupDirective: FormGroupDirective,  
) { }

После этого через this.formGroupDirective.form мы получаем доступ к объекту FormGroup родительского компонента и свободно можем реализовать как получение (через подписку на this.formGroupDirective.form.get('_CONTROL_NAME_').valueChanges), так и передачу (через this.formGroupDirective.form.patchValue) данных в объект.

Со всеми сложностями разобрались. На этом подготовительные работы закончены, можно засучить рукава и приступить к самому приятному, но... Это вы уже увидите в следующей серии...

P.S. А вы используете подобные расширения? Расскажите о ваших юзеркейсах их применения, для чего используете? Думаю, и мне и всем остальным будет очень полезно узнать о возможных вариантах применения с целью повышения удобства пользования различными популярными сервисами и сайтами.

P.P.S. Это мой первый опыт публичной публикации. Буду рад любым отзывам, как по самому содержанию публикации, так и по стилю, структуре публикации, по коду. Позитивные комментарии и одобрение всегда очень ценны, т.к. поднимают мотивацию для дальнейшей деятельности. Замечания же, рекомендации и адекватная критика вовсе бесценны, т.к., если уметь беспристрастно и в здоровом ключе воспринимать критику, то она становится источником развития и самосовершенствования. В общем, всем заранее спасибо за любые комментарии, обязательно все прочту и учту на будущее.

Комментарии (2)


  1. LeshaRB
    16.10.2024 22:00

    Или взять ту же ленту Хабра. Ну надоело мне постоянно скролить это полотно из статей, да еще с кучей отвлекающих картинок. Набросал за 10 минут скрипт, который вытягивает со страницы заголовки статей с их введением, отобразил это все в виде списка + показывается вводный текст при наведении на иконку рядом

    Так вроде RSS у хабра работает без нареканий.
    Даже если уйти от хабра его поддерживают многие... Далее через новостные агреггаторопы пронняют как мне надо

    На GitHub лежат скрипты для YouTube каналов


    1. kanasero Автор
      16.10.2024 22:00

      Да, про RSS безусловно верно, есть такой вариант. Однако, в моем случае я получаю не просто список, а список с необходимым дополнительным функционалом. В частности по названию статьи иногда не совсем понятно, о чем она - тогда подношу курсор к иконке возле названия и у меня показывается превью. В случае с RSS, как я понимаю, нужно открывать для этого страницу. В любом случае, тут дело "вкуса" - если устраивает просто список, то, конечно, RSS подойдет. Если же нужен какой-то минимальный функционал, то легко его написать скриптом за несколько минут.