Ну что, мальчиши и мальчишки, а не пора ли вам познать джесай до? Не, ну вам то рано еще, а я вот пожалуй начну. Но вы можете присоединиться.
В череде последних статей про jsx/react и прочее я наблюдал наматывание соплей на кулак и прочие непотребства в обсуждениях, дескать как же нам без бабеля, а зачем нам jsx и прочее малодушничание. Будем пресекать. Сегодня мы будем строить webpack, прикрутим к нему typescript и jsx без реакта, соберем на tsx custom element без бабеля, зарегаем его и выведем на странице. Многие из вас сегодня повзрослеют, а некоторые возможно даже станут мужчинами.
Если вы боитесь что мир для вас никогда уже не будет прежним — не бойтесь, он прежним никогда и не был, так что заходите…
Это первая статья из предполагаемого цикла, идти будем по нарастающей, дабы не разорвать неокрепшие мозги. Я дам вам интересных идей, рассчитываю что вы дадите мне интересное общение. Это конечно вряд ли, но я радикальный оптимист.
Начнем со структуры проекта. Она у нас простая (пока), по мере изложения материала будет немножко усложняться. На данный момент имеем:
- сорцы в src
- конфиги в корне (npm, webpack, typescript и tslint)
- в директорию build кладется сборка, откуда она перекочевывает в dist — тут уже будут лежать пакеты, готовые к дистрибуции и деплою (но не в этой статье — это на будущее)
В сорцах все простенько:
- assets — всякая статика (пока только index.html)
- client — собственно наши исходники темы этой статьи. На данный момент только набор кастомных элементов (один, если быть точным), которых мы накидаем для облегчения себе жизни.
На этом со структурой все, двигаем дальше. Да, выглядит это так:
Пройдемся теперь по webpack чей конфиг удачно поместился в скриншот (webpack мы потом конечно выкинем, но пока пусть будет). Статей по его настройке в сети — море, я пройдусь только по значимым. Как видим, в подключении модулей (resolve) поиск начинается с tsx, затем ts и только в случае безнадеги ищутся js файлы. Индексные файлы указаны для того что бы при подключении модуля указывать только директорию — просто удобное соглашения для себя, не более. Далее видим минимально необходимый для удобства набор плагинов, на самом деле они сейчас по большому счету и не нужны, но пусть будут. Обзор плагинов можно посмотреть здесь: Webpack + React. Как уменьшить бандл в 15 раз или в самой документации по вебпэку.
Процесс сборки у нас пока крайне простой — тянем ts/tsx файлы и транпайлим их в js. Соответственно нам нужен только линтер и компилятор typescript. А как же бабель? — спросят самые юные слушатели. Бабель не нужен.
Строим загрузчики — собственно все примитивно:
,tslint: { emitErrors: true ,failOnHint: true } ,module: { preLoaders: [ {test: /\.tsx?$/, loader: "tslint"} ] ,loaders: [ {test: /\.tsx?$/, loader: 'ts'} ] }
После того как примитивность процесса стала нам очевидна, можно прописать зависимости в package.json (мы же порядочные программисты, а порядочные программисты оформляют проекты npm модулями). Зависимостей немного:
package.json
"devDependencies": { "ts-loader": "^1.3.2", "tslint": "^4.0.2", "tslint-loader": "^3.2.1", "typescript": "^2.1.4", "webpack": "^1.14.0", "webpack-bundle-tracker": "0.1.0" },
тут все просто — typescript и его загрузчик для webpack, линтер с загрузчиком, сам webpack (что бы тянуть в конфиге его встроенные плагины) и один плагинчик для трекания процесса сборки. А как же реакт? Реакт тоже не нужен.
Да, typescript у нас второй — он повкуснее, а webpack — первый, второй еще не готов. Собственно тут все.
Но! Конфиг webpack получился лаконичным по той причине что конфиги typescript компилятора и линтера мы вынесли в корень проекта отдельными файлами. Можно их засунуть и в конфиг вебпэка, но отдельным файлом — рекомендуемый путь. Мало ли, вдруг решите поменять систему сборки, а у вас раз — и все готово к переезду.
Так, а теперь взглянем в опции компилятора typescript:
tsconfig.json
{ "compilerOptions": { "jsx": "react" ,"jsxFactory": "dom.createElement" ,"lib": ["ES2017", "DOM"] ,"target": "ESNext" ,"module": "commonjs" ,"removeComments": true ,"noImplicitAny": false } }
Да, тут тоже немного интересно. Первая опция — jsx. Тут мы указываем как именно мы хотим обрабатывать jsx (да, typescript уже умеет jsx, одна из причин почему нам не нужен бабель). Вариантов всего два — не обрабатывать вообще и обрабатывать так же как реакт. Ну ок, говорим что хотим как реакт. А как у нас обрабатывает jsx реакт (а точнее бабель)? А он встретившиеся в коде теги преобразует к такому виду:
было:
<div id="test"> some text <some_tag> another text </some_tag> </div>
стало:
React.createElement("div", { id: 'test'}, "some text", React.createElement("some_tag", null, "another text") )
То есть сигнатура простая — первый параметр — имя тега, второй — объект с аттрибутами тега либо null, все остальные — childs элемента в порядке их объявления в верстке. Как видим — childs — это либо строка для текстовых нод либо уже созданный элемент. Ок. Мы воспользуемся возможностью заменить функцию создания элементов (React.createElement) на свою (да, так можно, поэтому нам не нужен реакт), для этого укажем в настройке jsxFactory имя нашей функции (в нашем случае dom.createElement). То есть на выходе получим вот такое:
dom.createElement("div", { id: 'test'}, "some text", dom.createElement("some_tag", null, "another text") )
Можно вместо настройки jsxFactory указать reactNamespace — тут мы указываем только имя объекта, который предоставляет метод createElement. То есть с этой настройкой наш конфиг выглядел бы так:
{ "compilerOptions": { "jsx": "react" ,"reactNamespace": "dom" ,... } }
Но я предпочитаю явное вместо неявного, плюс я считаю правильнее привязываться к технологии jsx а не к react (есть мнение [мое] что jsx останется а react канет в лету). Не суть. Вы можете поступать в соответствии со своими убеждениями, это нормально. Да, и в дальнейшем эту функцию создания элементов я буду называть сборщиком dom (в смысле builder). В следующей статье поговорим еще о правилах сборки.
Оставшиеся настройки не так критичны с точки зрения темы статьи, пройдемся бегло.
Мы указываем модные либы что бы иметь возможность использовать современные возможности javascript (например деструкцию или метод includes массивов)
Указываем target ESNext — это говорит компилятору что мы хотим получить код который юзает реализованные фишки ES стандарта. Если вам надо в Safari или старые браузеры — укажите здесь что нибудь попроще.
Остальные настройки — как специи, по вкусу. Формат модулей, вырезать комменты.
Особое внимание обратите на noImplicitAny — не позволять использовать по умолчанию any — надо бы добавить репрессий к режиму линтера, но мы этого не делаем. Да, это заноза в заднице, в конце статьи я объясню почему так, а через одну статью расскажу как ее вынимать.
Кстати о линтере — его конфиг я рассматривать не буду — смотрите сами, к теме он не относится. Но вообще считаю хорошей практикой устроить диктат линтера, так что бы шаг в сторону — красная строка и провал сборки. Но это на вкус.
Пытливые юноши обратили внимание на отсутствие тайпингов — совершенно верно, нам они пока не нужны, но появятся в дальнейшем (в будущих статьях) — когда мы начнем юзать сторонние либы и строить бэк (да, да, поэтому у нас сорцы в директории client, потому что потом будет и server)
А с конфигами на этом все. Приступим к коду.
Как я уже говорил в начале статьи, по ходу статьи мы создадим и зарегаем свой кастомный элемент и выведем его на странице. Зайдем с конца — посмотрим что мы хотим получить:
src/assets/index.html
<!doctype html> <html> <head> <title>jsx light</title> <meta charset="utf-8"/> </head> <body> <script src="elements.js"></script> <x-hello-world></x-hello-world> </body> </html>
Хе-хе. Да, старый добрый хелловорд. То есть планируется описание элемента и его регистрацию получить в elements.js и пронаблюдать его появление в верстке в браузере.
Ну ок.
В конфиге вебпэка мы указали что собирать elements.js мы будем с src/client/index.ts. Но если вы взглянете в этот файл то увидите что там ничего интересного, поэтому давайте сначала заглянем в реализацию кастомного элемента, расположенную в файле:
src/client/elements/helloword/index.tsx
declare var customElements; import {dom} from '../inc/dom'; import {WAElement} from '../inc/'; export const HelloWorld = function(dom: any){ return class HelloWorld extends WAElement { constructor(){ super(); } connectedCallback() { this.appendChild( <div>hello, world!</div> ); } static register() { customElements.define('x-hello-world', HelloWorld); } }; }(dom);
Ну что же, рассмотрим поподробнее.
Первой строкой мы объявляем глобал customElements, который предоставляется согласно первой версии стандарта (которая на самом деле вторая версия потому что первой версией была нулевая). Стандарт еще не принят поэтому этого глобала нет в тайпингах typescript, приходится просто объявлять.
Есть еще другой путь — можно подрихтовать предоставляемый typescript-ом lib.d.ts (или соотв. выбранному target), а именно тайпинг объекта document, положить его локально в проект и подключать в своем коде. В этом случае можно в коде указывать document.customElements и радоваться благам строгой типизации. Дождаться момента когда стандарт примут и просто удалить свой самодельный тайпинг, подправив заодно одну строку в tsd.d.ts, оставив весь остальной код без изменений. И это правильный путь, по крайней мере в своем экспериментальном проде я сделал так. Но тут у нас статья с закосом под туториал, так что упрощаю и делаю НЕПРАВИЛЬНО, но понятно, с объяснением того что это неправильно.
Далее идут два импорта — dom и WAElement. Кто такие? dom — это как раз наш сборщик dom (эдакий react для custom elements), то, что мы обсуждали при рассмотрении настроек компилятора typescript — jsxFactory. Ее мы рассмотрим отдельно и подробно, сейчас пробежимся по WAElement.
Один из самых вкусных моментов вебкомпонент v1 — возможность ровно наследоваться от нативных классов элементов. Это дает нам возможность расширять их, не теряя или не реализовывая повторно уже имеющийся у элементов функционал. В конце статьи есть ссылки, в том числе и на статью где этот момент рассмотрен поподробнее. Правда оно пока еще толком не работает и нормально отнаследоваться получилось только от HTMLElement, но я думаю ситуация исправится.
Так вот. Представим себе как это будет. Мы пишем свои расширения стандартных тегов, свой, уникальный функционал — реализуем в своих классах, стандартный функционал уже реализован в нативных элементах. И вот написав пару десятков тегов мы начинаем замечать что часть функционала у них общая, но при этом в нативных элементах отсутствующая. Логично такой функционал свести в один класс и в дальнейшем уже отнаследоваться от него. WAElement — именно такой класс-прокладка между нашими классами и HTMLElement, причем пока что пустой, но пусть полежит.
Ok. Давайте пройдемся дальше по коду и вернемся затем к реализации dom.
Новый тег в вебкомпонентах регистрируется при помощи вызова document.customElements.define(name, class), на входе ожидается класс, реализующий поведение нового элемента. А значит возвращать наш модуль должен класс нашего нового тега. Как видим, именно это и происходит, но наметанный взгляд увидит не прямой экспорт класса а IIFE вызов. Зачем? Как видим, при этом вызове передается в скоп класса наш dom сборщик. Дело в том что в тот момент когда линтер смотрит наш код, вызова dom, в который трансформируется наша html верстка — еще нет. И импорт dom без дальнейшего использования приведет к unused variable ошибке. Это дело можно пресечь, дав директиву линтеру в самом коде, но тогда можно прохлопать настоящие unused. Да и как то раздражают директивы линтера в коде. Поэтому я импортирую dom и явно передаю его в скоп класса. А вот после того как будет преобразована верстка — в коде класса появятся вызовы dom.createElement и пазл сложится.
Вторая причина, по которой вы можете захотеть сделать так же — это использование нескольких сборщиков dom в одном модуле. Предположим вы экспортируете из модуля несколько классов, и эти классы используют два (или более) соглашения по постройке dom (что это такое будет подробнее в следующей статье, а пока просто представьте что хотите строить dom по разным правилам). Так вот, в настройках typescript мы можем указать только одно имя. Но использовать можем сколько нам вздумается конструкторов по вот такой схеме:
declare var customElements; import {dom1} from '../inc/dom1'; import {dom2} from '../inc/dom2'; import {WAElement} from '../inc/'; export const HelloWorld = function(dom: any){ return class HelloWorld extends WAElement { }; }(dom1); export const GoodByeWorld = function(dom: any){ return class GoodByeWorld extends WAElement { }; }(dom2);
То есть тут все простенько — внутрь классов передаются разные сборщики, в обоих случаях внутри класса они видны под именем dom, верстка тоже транспайлится в вызов dom но при этом каждый класс использует свой собственный сборщик.
Ок, в этим вроде понятно. Плавно подкрались к прологу кульминации. Метод connectedCallback — собственно то, ради чего все это действо затевалось. Вставляем свой кусок верстки во внутренности новоиспеченного тега, указывая верстку непосредственно в коде. Собственно что тут объяснять — по коду все понятно. Тонкости объявления и жизненного цикла веб компонент в этой статье я объяснять не буду, пока вы с нетерпением ждете от меня продолжения можете не торопясь ознакомиться со статьями из списка внизу.
Ну и последний момент — метод register. Зачем он нужен? Чтобы элемент мог зарегистрироваться под тем именем, на которое рассчитывал его создатель и так КАК рассчитывал его создатель. Дело в том что при регистрации можно указать, какие именно теги расширяет наш элемент (реализация поведения через атрибут is — интересная тема, достойная отдельной статьи, внизу есть ссылка). Если вы по каким то причинам смотрите на мир иначе — всегда можно сделать импорт класса а элемент регистрировать самостоятельно под тем именем которое вам нравится больше.
Что же, с этим тоже, я надеюсь, серьезных вопросов не возникнет. Давайте теперь вернемся, как и обещал, к сборщику dom и рассмотрим процесс транспайлинга верстки jsx в js-код, собирающий DOM.
Повторю приведенный выше пример:
было:
<div id="test"> some text <some_tag> another text </some_tag> </div>
стало:
dom.createElement("div", { id: 'test'}, "some text", dom.createElement("some_tag", null, "another text") )
То есть нам надо сообразить функцию (или класс), собирающий DOM. Я до этого пару раз упоминал некие правила сборки — держите это в голове.
Посмотрим, что же у нас лежит в dom.ts.
src/client/elements/inc/dom.ts
const isStringLike = function(target: any){ return ['string', 'number', 'boolean'].includes(typeof target) || target instanceof String; }; const setAttributes = function(target: HTMLElement, attrs: {[key:string] : any}){ if(!attrs){ return; } if('object' === typeof attrs){ for(let i in attrs){ switch(true){ case !attrs.hasOwnProperty(i): continue; case isStringLike(attrs[i]): target.setAttribute(i, attrs[i]); break; default: // do something strange here } } } }; export const dom = { createElement: function (tagName: string, attrs: {[key:string] : any}, ...dom: any[]){ const res = document.createElement(tagName); setAttributes(res, attrs); for(let i of dom){ switch(true){ case isStringLike(i): res.appendChild(document.createTextNode(i)); break; case (i instanceof HTMLElement): res.appendChild(i); break; default: // do something strange here } } return res; } };
Как видим, здесь тоже не rocket science, все простенько и со вкусом. Классом я оформлять не стал, просто экспортируем объект, предоставляющий функцию createElement. Её работу и рассмотрим.
На входе мы получаем имя тега, его атрибуты и потомков (уже собранные ноды). Поскольку число аргументов переменное — используем в сигнатуре rest параметр (неплохая статья про деструкцию). Создаем нужный нам элемент через document.createElement и затем выставляем ему атрибуты.
Тут обратите внимание на два момента.
Во первых мы можем получить запрос на создание кастомного элемента (это произойдет в ситуации когда мы в своем jsx используем другие кастомные элементы). Никак особо мы эту ситуацию не отслеживаем, независимо от того — объявлен такой элемент или нет — по стандарту кастомный элемент можно использовать в верстке до его объявления и регистрации.
На второй момент многие падаваны уже наверняка отреагировали — это установка атрибутов. document.createElement позволяет задавать атрибуты при создании элемента, так зачем нам своя функция для этой тривиальной операции? Хо-хо, господа. Это не тривиальная операция, это вишенка. Но если мы посмотрим на реализацию функции setAttributes, то увидим что никакой вишенки там нет, банально все что можно привести к строке (скаляры и объекты String) — устанавливается в атрибут, все остальное игнорится. Совершенно верно. Мы идем от простого к сложному и в коде есть только то что нужно для регистрации и вывода нашего нехитрого тега. Но это не значит что мы проигнорируем потенциал, вовсе нет, мы его немножко осмотрим.
В текущей реализации мы видим что работает только вот этот кусочек кода:
case isStringLike(attrs[i]): target.setAttribute(i, attrs[i]);
Но представьте себе на секунду что мы добавили вот такой кусочек:
case isStringLike(attrs[i]): target.setAttribute(i, attrs[i]); break; case i.substring(0, 2) === 'on' && 'function' === typeof attrs[i]: let eventName = i.substring(2).toLowerCase(); if(eventName.length){ target.addEventListener(eventName, attrs[i]); } break;
Казалось бы. Но зато теперь мы можем сделать так:
@autobind onClick(evt: Event) { . . evt.preventDefault(); } connectedCallback() { this.appendChild( <a href='/' onClick={this.onClick}>Click me!</a> ); }
Ну как бы сиханы реакта сейчас конечно скривились в усмешке, давайте это исправим. Добавим что то вроде этого:
case isStringLike(attrs[i]): target.setAttribute(i, attrs[i]); break; case attrs[i] instanceof Store: attrs[i].bindAttribute(i, this); break;
И после этого мы можем делать легкий биндинг:
private url: new Store('http://some_default_url'); connectedCallback() { this.appendChild( <a href={this.url}>Click me!</a> ); }
То есть мы можем делать односторонний, двухсторонний биндинг, можем особым образом обрабатывать экземпляры отдельных классов, да хоть засунуть в атрибуты DOM-объект и нужным нам образом за ним бдить. То же самое мы можем сделать не только с атрибутами но и с содержимым тега. При этом мы не заморачиваемся за перерисовку — это делает за нас браузер. Не страдаем с двумя системами эвентов и двумя деревьями атрибутов. Все нативно, модно, молодежно.
Помните я говорил про правила построения DOM? Это вот оно и есть. Вы можете выработать удобные вам правила и реализовать их. Можете менять их от проекта к проекту или даже от тега к тегу — если вам это облегчает жизнь и не вносит путаницы. То есть глядя на реакт понимаешь что это всего лишь частный случай сборщика DOM, и вы можете наштамповать таких себе по самое не надо. Но не сейчас — это как раз тема следующей статьи. А пока я её пишу а вы с нетерпением её ждете — можно обсудить это ниже или пофантазировать на тему в качестве домашнего задания.
Ну и последний момент — сведем это все в одно. Мы выше пропустили процесс регистрации тега, вот он:
src/client/index.tsx
import { HelloWorld } from './elements/helloworld/'; HelloWorld.register();
То есть по мере штампования тегов вы просто добавляете их импорты в этом файле и регистрируете. Типа вот этого:
import { Header } from './elements/header/'; import { Workplace } from './elements/workplace/'; import { HeaderMessage } from './elements/header_message/'; import { Email } from './elements/email/'; import { ChangeButton } from './elements/change/'; import { CopyButton } from './elements/copy/'; import { MessageAboutMail } from './elements/message_about_mail/'; import { ReadMail } from './elements/read_mail/'; import { AdBanner } from './elements/ad_banner/'; [Header, HeaderMessage, Workplace, Email, ChangeButton, CopyButton, MessageAboutMail, ReadMail, AdBanner] .forEach((element) => element.register());
Вот как бы и все по коду.
Что же, давайте те же уже соберем проект, запустим и посмотрим.
git clone https://github.com/gonzazoid/jsx-light.git cd jsx-light npm install npm run build
Должно собраться примерно так:
Далее набиваем:
webpack-dev-server --content-base dist/public/
Если webpack-dev-server не стоит — поднимаем npm i -g webpack-dev-server. Ну или воспользуйтесь тем инструментом который больше соответствует вашим скрепам.
Открываем браузер, открываем нашу сборку. Должно быть что то вроде:
Как видим — наш тег имеет содержимое, то есть все собралось и работает, как и ожидалось.
Для тех кто все еще в смятении коротенько поясняю что здесь произошло — мы заюзали jsx при описании webcomponent-ы без реакта и бабеля. Собственно все.
Так, а теперь вернемся к настройке компилятора — noImplicitAny. Мы выставили ее в false, разрешив тем самым по умолчанию приводить неопределенные типы к any. Ну и нафига тогда нужен typescript спросит разочарованно читатель и будет прав. Конечно, там где типы указаны — проверки осуществляются, но использовать неявное приведение к any — злостное зло. Но в данном случае мы (временно) вынуждены это делать по следующей причине. Дело в том что jsx пока что нигде не рассматривается отдельно как самостоятельная технология, и где бы он не появился — тень реакта висит над ним. А процесс транспайлинга верстки в js немного более сложный, чем я описал ранее. И этапов там тоже немного больше. В общих чертах — теги, которые реализованы нативно (с точки зрения react, то есть не являются react компонентами) должны быть указаны в JSX.IntrinsicElements — специальный глобал. И это не проблема на самом деле, достаточно указать в коде что то вроде :
declare global { namespace JSX { interface IntrinsicElements extends ElementTagNameMap{ 'and-some-custom-tag': WAElement; } } }
И мы имеем интерфейс, отнаследованный от списка в описании DOM typescript-а, с правильным указанием типов. И вот тут то мы получаем удар ножом в спину! Ни бабель, ни typescript не предполагают что типы созданных элементов могут быть разными — они хотят что бы мы указали ОДИН тип в JSX.ElementClass. Для реакта это, очевидно React.Component — в этом несложно убедиться, вглянув в тайпинг реакта:
npm install @types/react
Да, во втором тайпскрипте тайпинги распространяются как npm модули и это прикольная фишка (там еще есть и про объявление тайпингов в своем модуле, вторая версия неплохо синтегрирована с npm). Загляните в node_modules проекта после выполнения команды и изучите содержимое react/index.d.ts — конкретно интерфейс JSX. Это даст поверхностное понимание процесса (не забудьте потом удалить этот тайпинг, иначе он будет мешать сборке).
Так вот, научить тайпскрипт собирать кастомные элементы с использованием jsx — пол-дела. А вот научить его собирать их так, что бы в дальнейшем работала типизация — ооо! Это тема отдельной статьи. Я сейчас добиваю патч компилятора, надеюсь довести это дело до конца и оформить статьей и возможно коммитом. Потому как workflow обработки jsx немного меняется, а применить это можно далеко не только в сборке кастомных элементов.
Так вот, возвращаясь к noImplicitAny — если выставим ее в true — нарвемся на ошибки компилятора, устранить которые мы можем объявлением интерфейса JSX, где натыкаемся на необходимость объявить все нативные и кастомные элементы одним классом — а это не айс. Поэтому пока оставляем так.
Ну что же, статья получилась коротенькой и даже немного поверхностной, поэтому пройдусь еще немного по моментам, оставшимся за бортом:
* За бортом осталось множество моментов работы кастомных элементов — аттрибут is, разница между созданием элемента из верстки или в js-вызове, работа с содержимым кастомного элемента, видимоcть DOM и куча куча всего. Какие то моменты мы в будущем затронем, какие то нет, на самом деле статьи в сети есть, пока немного и довольно поверхностные, но думаю по мере развития технологии статьи будут появляться. Если вам надо поближе к внутренностям — на гитхабе есть неплохие обсуждения.
* Практически не раскрыт потенциал сборщиков DOM — это тема следующей статьи.
* Так же в следующей статье будут схемы построения кастомных элементов — event-based, state-based, дистрибуция созданных универсальных компонент, интернационализация и прочие неудобства для программиста.
* Не раскрыт и НЕ БУДЕТ раскрыт вопрос совместимости с браузерами — есть полифилл, есть caniuse, этой темы я касаться не буду. Весь цикл статей не о том как использовать это в продакшине а о том КАК и ГДE оно МОЖЕТ быть использовано. Использовать или нет — собственно для обсуждения этого статьи и пишутся.
В общем как бы все, извините что коротенько, следующие статьи будут с более глубоким погружением, это была все таки вводная.
Хорошего настроения и чистого кода!
- Ссылка на github репу к этой статье
- введение в jsx
- jsx in depth
- опции компилятора typescript
- введение в custom elements v1
- реальные пацаны выясняют за атрибут is
ссылка на оригинал статьи https://habrahabr.ru/post/317542/
Добавить комментарий