Эта статья о том, как написать универсальный JavaScript-компонент, который можно будет использовать
- как React-компонент;
- как Preact-компонент;
- как Angular-компонент;
- как Web Component;
- как jQuery функцию для рендеринга в DOMElement;
- как нативную функцию для рендеринга в DOMElement.
Зачем и кому это нужно
Мир JavaScript-разработки очень фрагментирован. Есть десятки популярных фреймворков, большая часть из которых абсолютно несовместима друг с другом. В таких условиях разработчики JavaScript-компонентов и библиотек, выбирая один конкретный фреймворк, автоматически отказываются от очень большой аудитории, которая данный фреймворк не использует. Это серьезная проблема, и в статье предложено ее решение.
Как все будет реализовано
- Напишем React-компонент.
- Используя JavaScript-библиотеки preact и preact-compat, которые вместе работают точно так же как React и при этом весят жалкие 20 килобайт, напишем обертки для всего остального.
- Настроим сборку с помощью Webpack-а.
Пишем код компонента
Для примера разработаем Donut Chart такого вида:
Здесь ничего удивительного мы не увидим — просто код.
import React from 'react';
export default class DonutChart extends React.Component {
render() {
const { radius, holeSize, text, value, total, backgroundColor, valueColor } = this.props;
const r = radius * (1 - (1 - holeSize)/2);
const width = radius * (1 - holeSize);
const circumference = 2 * Math.PI * r;
const strokeDasharray = ((value * circumference) / total) + ' ' + circumference;
const transform = 'rotate(-90 ' + radius + ',' + radius + ')';
const fontSize = r * holeSize * 0.6;
return (
<div style = {{ textAlign: 'center', fontFamily: 'sans-serif' }}>
<svg width = {radius * 2 + 'px'} height = {radius * 2 + 'px'}>
<circle
r = {r + 'px'} cx = {radius + 'px'} cy = {radius + 'px'}
transform = {transform} fill = 'none'
stroke = {backgroundColor} strokeWidth = {width}
/>
<circle
r = {r + 'px'} cx = {radius + 'px'} cy = {radius + 'px'}
transform = {transform} fill = 'none'
stroke = {valueColor} strokeWidth = {width}
strokeDasharray = {strokeDasharray}
/>
<text
x = {radius + 'px'} y = {radius + 'px' }dy = {fontSize/3 + 'px'}
textAnchor = 'middle' fill = {valueColor} fontSize = {fontSize + 'px'}
>
{~~(value * 1000 / total) / 10}%
</text>
</svg>
<div style = {{ marginTop: '10px' }}>
{text}
</div>
</div>
);
}
}
DonutChart.defaultProps = {
holeSize : 0.8,
radius : 65,
backgroundColor : '#d1d8e7',
valueColor : '#49649f'
};
Что должно получиться в итоге
Настраиваем сборку Webpack-ом
var webpack = require('webpack');
module.exports = {
output: {
path: './dist'
},
resolve: {
extensions: ['', '.js'],
},
module: {
loaders: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
query: {
presets: [
'latest',
'stage-0',
'react'
],
plugins: [
'transform-react-remove-prop-types',
'transform-react-constant-elements'
]
}
}
]
},
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': "'production'"
}),
new webpack.optimize.DedupePlugin(),
new webpack.optimize.OccurrenceOrderPlugin(),
new webpack.optimize.AggressiveMergingPlugin(),
new webpack.optimize.UglifyJsPlugin({
compress: { warnings: false },
comments: false,
sourceMap: true,
mangle: true,
minimize: true
})
]
};
Добавляем в package.json скрипты для сборки проекта
"scripts": {
"build:preact": "node ./scripts/build-as-preact-component.js",
"build:react": "node ./scripts/build-as-react-component.js",
"build:webcomponent": "node ./scripts/build-as-web-component.js",
"build:vanila": "node ./scripts/build-as-vanila-component.js",
"build:jquery": "node ./scripts/build-as-jquery-component",
"build:angular": "node ./scripts/build-as-angular-component",
"build": "npm run build:preact && npm run build:react && npm run build:webcomponent && npm run build:vanila && npm run build:jquery && npm run build:angular"
}
Сборка Webpack-ом и обертка для Web Components
var webpack = require('webpack');
var config = require('./webpack.config');
var statsConfig = require('./statsConfig');
config.resolve.alias = {
'react': 'preact-compat',
'react-dom': 'preact-compat'
};
config.entry = './src/DonutChartWebComponent.js';
config.output.filename = 'DonutChartWebComponent.js';
webpack(config).run(function (err, stats) {
console.log(stats.toString(statsConfig));
});
Обертка
import React from 'react';
import ReactDOM from 'react-dom';
import DonutChart from './DonutChart';
const proto = Object.create(HTMLElement.prototype, {
attachedCallback: {
value: function() {
const mountPoint = document.createElement('span');
this.createShadowRoot().appendChild(mountPoint);
const props = {
radius : +this.getAttribute('radius') || undefined,
holeSize : +this.getAttribute('hole-size') || undefined,
text : this.getAttribute('text') || undefined,
value : +this.getAttribute('value') || undefined,
total : +this.getAttribute('total') || undefined,
backgroundColor : this.getAttribute('background-color') || undefined,
valueColor : this.getAttribute('value-color') || undefined
};
ReactDOM.render((
<DonutChart {...props}/>
), mountPoint);
}
}
});
document.registerElement('donut-chart', {prototype: proto});
Пример использования
<donut-chart value="39.6" total="100" text="Hello Web Components"></donut-chart>
Результат
Сборка Webpack-ом и обертка для Angular
var webpack = require('webpack');
var config = require('./webpack.config');
var statsConfig = require('./statsConfig');
config.resolve.alias = {
'react': 'preact-compat',
'react-dom': 'preact-compat'
};
config.entry = './src/DonutChartAngularComponent.js';
config.output.filename = 'DonutChartAngularComponent.js';
config.output.library = 'DonutChart';
config.output.libraryTarget = 'umd';
webpack(config).run(function (err, stats) {
console.log(stats.toString(statsConfig));
});
Обертка
import React from 'react';
import ReactDOM from 'react-dom';
import DonutChart from './DonutChart';
const module = angular.module('future-charts-example', []);
module.directive('donutChart', function() {
return {
restrict: 'E',
link: function(scope, element, attrs) {
const props = {
radius : +attrs['radius'] || undefined,
holeSize : +attrs['hole-size'] || undefined,
text : attrs['text'] || undefined,
value : +attrs['value'] || undefined,
total : +attrs['total'] || undefined,
backgroundColor : attrs['background-color'] || undefined,
valueColor : attrs['value-color'] || undefined
};
ReactDOM.render((
<DonutChart {...props}/>
), element[0]);
}
};
});
Пример использования
<body ng-app="future-charts-example">
<donut-chart value="89.6" total="100" text="Hello Angular"></donut-chart>
</body>
Результат
Сборка Webpack-ом и обертка для jQuery
var webpack = require('webpack');
var config = require('./webpack.config');
var statsConfig = require('./statsConfig');
config.resolve.alias = {
'react': 'preact-compat',
'react-dom': 'preact-compat'
};
config.entry = './src/DonutChartJQueryComponent.js';
config.output.filename = 'DonutChartJQueryComponent.js';
config.output.library = 'DonutChart';
config.output.libraryTarget = 'umd';
webpack(config).run(function (err, stats) {
console.log(stats.toString(statsConfig));
});
Обертка
import React from 'react';
import ReactDOM from 'react-dom';
import DonutChart from './DonutChart';
jQuery.fn.extend({
DonutChart: function(props) {
this.each(
function () {
ReactDOM.render((
<DonutChart {...props}/>
), this);
}
);
}
});
Пример использования
$('#app').DonutChart({
value : 42.1,
total : 100,
text : 'Hello jQuery'
});
Результат
Сборка Webpack-ом и обертка для VanilaJS (использование из нативной функции)
var webpack = require('webpack');
var config = require('./webpack.config');
var statsConfig = require('./statsConfig');
config.resolve.alias = {
'react': 'preact-compat',
'react-dom': 'preact-compat'
};
config.entry = './src/DonutChartVanilaComponent.js';
config.output.filename = 'DonutChartVanilaComponent.js';
config.output.library = 'DonutChart';
config.output.libraryTarget = 'umd';
webpack(config).run(function (err, stats) {
console.log(stats.toString(statsConfig));
});
Обертка
import React from 'react';
import ReactDOM from 'react-dom';
import DonutChart from './DonutChart';
module.exports = function DonutChartVanilaComponent(mountPoint, props) {
ReactDOM.render((
<DonutChart {...props}/>
), mountPoint);
};
Пример использования
DonutChart(document.getElementById('app'), {
value : 57.4,
total : 100,
text : 'Hello Vanila'
});
Результат
Сборка Webpack-ом для React
var webpack = require('webpack');
var config = require('./webpack.config');
var statsConfig = require('./statsConfig');
var react = {
root: 'React',
commonjs2: 'react',
commonjs: 'react'
};
var reactDom = {
root: 'ReactDOM',
commonjs2: 'react-dom',
commonjs: 'react-dom'
};
config.externals = {
'react': react,
'react-dom': reactDom
};
config.entry = './src/DonutChartUMD.js';
config.output.filename = 'DonutChartReact.js';
config.output.library = 'DonutChart';
config.output.libraryTarget = 'umd';
webpack(config).run(function (err, stats) {
console.log(stats.toString(statsConfig));
});
Результат
Сборка Webpack-ом для Preact
var webpack = require('webpack');
var config = require('./webpack.config');
var statsConfig = require('./statsConfig');
var preactCompat = {
root: 'preactCompat',
commonjs2: 'preact-compat',
commonjs: 'preact-compat'
};
config.externals = {
'react': preactCompat,
'react-dom': preactCompat
};
config.entry = './src/DonutChartUMD.js';
config.output.filename = 'DonutChartPreact.js';
config.output.library = 'DonutChart';
config.output.libraryTarget = 'umd';
webpack(config).run(function (err, stats) {
console.log(stats.toString(statsConfig));
});
Результат
Заключение
Сколько в итоге будет весить каждый из вариантов:
React | Preact | VanilaJS | jQuery | Angular | Web Components |
---|---|---|---|---|---|
Код компонента (3кб) | Код компонента (3кб) | Код компонента (3кб) | Код компонента (3кб) | Код компонента (3кб) | Код компонента (3кб) |
Обертка (1кб) | Обертка(1кб) | Обертка(1кб) | Обертка (1кб) | ||
preact.min.js (3кб) | preact.min.js (3кб) | preact.min.js (3кб) | preact.min.js (3кб) | ||
preact-compat.min.js (18кб) | preact-compat.min.js (18кб) | preact-compat.min.js (18кб) | preact-compat.min.js (18кб) | ||
3кб | 3кб | 25кб | 25кб | 25кб | 25кб |
Оверхед в 20 килобайт за возможность использовать React-компоненты в любых других фреймворках или в качестве Web Components — это прекрасный результат. Если вы разрабатываете какие-то React-компоненты, знайте — вы можете сделать их доступными всем и каждому — это очень просто. Надеюсь, что этот туториал поможет сделать мир хотя бы чуточку лучше и сократит страшную фрагментацию вселенной JavaScript-разработки.
Комментарии (45)
jbubsk
05.12.2016 08:53+5Не думаю, что разработчики на ангуляре будут с неистовством мешать в проекты компоненты написанные на реакте. Разумнее было бы написать компонент на ваниле и адаптировать под конкретный фреймворк или библиотеку.
MrCheater
05.12.2016 09:26+6Писать на VanilaJS, вручную отслеживать изменение состояния компонента, самостоятельно управлять перерисовкой всего и вся внутри — дело непростое. Требуется гораздно больше кода и времени на его поддержку.
Представьте, например, что есть библиотека с чартами, которая работает по принципу, описанному в статье — так почему бы ее не использовать в проекте, если она хорошая. Какая разница программисту, что там внутри, если она работает и много килобайт не отжирает.
nazarpc
05.12.2016 09:49+1Мне всегда казалось что использование Web Components единственное решение, которое на 100% совместимо с чем угодно другим. Зачем ещё кучу всего сверху?
MrCheater
05.12.2016 10:10+8На данный момент нативно Web Components очень мало где поддерживается
C полифилами ситуация лучше
И опять же — в Web Components — внутри все DOM-операции и отслеживание изменений состояния компонента нужно делать ручками, что не так удобно, как в React. Уровень абстракции другой
nazarpc
05.12.2016 10:18React нативно вообще нигде не поддерживается и не будет.
Уровень абстракции не хуже. При использовании Polymer есть и биндинги, и возможности рендерить кучу элементов из массива по шаблону, включая реакцию на изменение состояния (в новом проекте я использую Redux вместе с веб-компонентами), и декларативные подписки на события, и многое другое.
Интегрировать это в любой фреймворк весьма просто, ибо там нет ничего кроме родных браузерных событий и свойств элементов. Если ваш фреймворк не может с этим интегрироваться, то стоит подумать о том, чтобы выбросить его в окно.
В противовес попробуйте интегрировать один React компонент в не-React проект — у вас и оверхед React будет похуже оверхеда полифиллов + Polymer, да и без трехэтажной системы сборки не обойтись. А веб-компоненты просто работают. В совершенно любом проекте, так же как в любом проекте работает
<div>
.MrCheater
05.12.2016 10:23+2В противовес попробуйте интегрировать один React компонент в не-React проект — у вас и оверхед React будет похуже оверхеда полифиллов + Polymer
В статье я показал, как добиться оверхеда в 20кб.
nazarpc
05.12.2016 10:27+3x-tags, к примеру, тоже 20 КБ, а в браузерах что поддерживают нативно будет вообще 0 (как не крути, таких подавляющее большинство).
При этом будет быстрее и проще, ибо используются нативные интерфейсы.
RealFLYNN
05.12.2016 16:29Присоединяюсь к предыдущему комментатору. Polymer 2.0 больше не является фреймворком — теперь это библиотека с сахаром для нативных компонентов + привязка данных. Полимер, таким образом, можно вывести в зависимость через html imports — сколько бы не было на странице компонентов, ссылающихся на него, свои N килобайт он загрузит только один раз.
В описанном способе (насколько я понял) технологический оверхед зашит в каждый компонент, так что 8 компонентов на одной странице по 25 килобайт каждый — это уже солидный жирок. Поправьте, если я ошибаюсьMrCheater
05.12.2016 16:43+3так что 8 компонентов на одной странице по 25 килобайт каждый
Я предполагаю, что разработчик компонентов даст вам эти 8 компонентов, собранных в одну либу вместе с одним preact-ом.
@salex772 предложил вариант получше https://habrahabr.ru/company/devexpress/blog/316358/?reply_to=9946908#comment_9946898
Я думаю, это вполне реально реализоватьRealFLYNN
05.12.2016 16:51+2Я предполагаю, что разработчик компонентов даст вам эти 8 компонентов, собранных в одну либу вместе с одним preact-ом.
Это, разумеется, если будет возможность собрать все нужные компоненты в одном месте для такой сборки. В данный момент работаю над проектом, для которого жизненно необходим импорт сторонних компонентов по кросс-домейну вместе с компонентами-зависимостями.
Я думаю, это вполне реально реализовать
Вот тут, да, хотя все равно выглядит сложнее нативного компонента + html imports.
justboris
05.12.2016 14:00+2React нативно вообще нигде не поддерживается и не будет.
При использовании PolymerИзначально речь шла о нативных веб-компонентах. Polymer — это тоже фреймворк и тоже поставляется отдельно от браузера, а значит, сколько-то весит.
Дальше — выбор, на каком фреймворке строить внутренности компонента, это уже дело личных предпочтений каждого, и React (Preact) тут тоже хороший вариант
salex772
05.12.2016 16:17В чем конкретно будет оверхед? В условной загрузке 20кб? Это только первый раз, после этого все это будет в кэше броузера.
kxl
05.12.2016 10:09+1Оверхед в 20 килобайт......- это прекрасный результат.
нда, на моем первом компьютере 48К было, из них 8 — на видео память и сиcтемные переменные уходило…
эх…
shanhaichik
05.12.2016 10:21+5Как говорится смешивать, но не взбалтывать.
Из своего опыта знаю, что лучше наоборот писать компоненты или какие-то не большие библиотеки на VanilaJS. А для фреймворков писать уже адапторы, если это необходимо.
Хотя ваша мысль мне то де нравится, но как и с любыми решениями такого рода, наверняка есть свои подводные камни.
DarthVictor
05.12.2016 11:33+3Хороший способ, но хотелось бы подробностей про дополнительный вес оберток и его масштабирование. Во сколько килобайт превратится оверхед в 22КБ на один компонент скажем для 10, 30 или 100 компонент? Какая часть этих 22КБ может быть общая? Просто если там общих 21КБ из 22КБ, то это одно, а если 12КБ, то другое.
MrCheater
05.12.2016 11:44Я думаю, что создание оберток можно автоматизировать. Например генерить их налету из propTypes
Пример PropTypesDonutChart.propTypes = {
radius: React.PropTypes.number.isRequired,
holeSize: React.PropTypes.number.isRequired, //0...1
text: React.PropTypes.string.isRequired,
value: React.PropTypes.number.isRequired,
total: React.PropTypes.number.isRequired,
backgroundColor: React.PropTypes.string.isRequired,
valueColor: React.PropTypes.string.isRequired
};salex772
05.12.2016 12:20Я так думаю, что неплохо было вынести preact в externals и подключать динамически через scriptJs в зависимости от того, есть ли внешние библиотеки в window. Не обязательно для каждого компонента тянуть свой реакт.
MrCheater
05.12.2016 12:21Про дублирование Preact-а на каждый компонент речи не идёт. Если 100 компонентов в сборке будет — то получится 100 компонентов + один Preact
salex772
05.12.2016 14:26А если один, то webpack включит его в сборку, при этом на странице он уже может быть в составе другого компонента вне вашей сборки… Я уверен, preact или react надо делать внешним компонентом и тянуть его опционально, если его там нет.
MrCheater
05.12.2016 14:30Возможно вы и правы, но как реализовать динамическую подгрузку preact-а, чтобы компонент не успел до этого поломаться из-за
ReferenceError
это еще нужно серьезно подуматьsalex772
05.12.2016 16:21+1Ну я бы сделал через сторонний загрузчик — это первое что должно исполнятся внутри UMD модуля. Например scriptjs, Он проверит зависимости, скачает что надо пл CDN, вернет промис, на resolve() которого нужно повесить инициализацию реакта. К моменту инициализации компонента все ссылки уже должны быть удовлетворены.
duodvk
05.12.2016 18:35+3А чем плох webpack конфиг externals. Можно задать его как-то так (на примере react):
externals: { 'react': { root: 'React', commonjs2: 'react', commonjs: 'react', amd: 'react' } }
в итоге потребитель может использовать несколько подобных универсальных библиотек от разных авторов и никакого дублирования preact. И для обёрток в 1 кб можно для тех же целей, оформить отельную библиотеку.
MrCheater
05.12.2016 18:38Лично мне бы не хотелось заставлять программиста, который использует мою библиотеку, подключать какие-то react/preact-ы. Ибо это детали реализации уже.
Но предложенный вами вариант тоже имеет место быть. Многим он подойдетduodvk
05.12.2016 18:41Я понимаю, но тогда возникнет проблема с размером при использовании множества таких компонентов.
salex772
10.12.2016 02:15Externals просто говорит о том, что это все «в другом месте». А речь про то, как это все подключить помимо сборки, я и предложил использовать загрузчики типа LABjs или ScriptJS
nsinreal
05.12.2016 13:03А что с управлением состоянием? Просто все очень хорошо с реюзом пока у компонентов не появится внутреннего состояния, а вам не понадобится history management.
MrCheater
05.12.2016 13:35Внутри компонента может быть свой
state
, свое внутреннее локальное состояние, которое никуда не денется, пока экземпляр компонента не будет уничтожен.
Если же компонент будет переодически уничтожаться и создаваться заново, и нужно чтобы данные не пропадали, или какой-то другой сложный сценарий использования компонента — тогда придется вынести состояние еще куда-то, но сделать это можно.salex772
05.12.2016 14:29на каком-нибудь легаси сайте на каждой странице компонент будет уничтожаться и создаваться каждый раз, ИМХО при обновлении state нужна сериализация каждый раз.
MrCheater
05.12.2016 14:37Компонент можно написать так, чтобы он поддерживал помимо uncontrolled режима, в котором state внутри создается, еще и controlled режим, при котором state не нужен. В controlled режиме компонент будет дергать колбеки, они будут менять state и отдавать его обратно в props (в аргументы). Так что никакой сериализации. Просто данные можно хранить вне компонента, если нужно
salex772
05.12.2016 16:23+1Да, согласен, так лучше будет. А и сам стараюсь сверху передавать колбеки и и обрабатывать их на уровне контейнера.
novoxudonoser
05.12.2016 15:20Я правильно понимаю что при таком коде
<svg width = {radius * 2 + 'px'} height = {radius * 2 + 'px'}>
у вас вся шаблонизация будет проходить на клиенте?Punk_UnDeaD
06.12.2016 23:13При желании вы можете делать всю или часть шаблонизации на клиенте.
Что именно вас интересует?
vintage
07.12.2016 09:09+1Для таких кроссплатформенных компонент лучше использовать какой-нибудь микромодульный фреймворк, который бы в комплексте с компонентом поставлял лишь минимально необходимый набор микромодулей, а не всю библиотеку целиком.
Кроме того, почему вы не пользуетесь возможностями CSS? Например, мне нужен такой же компонент, но с
перлазаливкой внутри круга.MrCheater
07.12.2016 11:34В Web Components огромные заморочки с настоящим CSS, поэтому я его не использовал. Но всегда можно взять что-то из аттрибутов(props) и на основе этого выставить
style
у нужных элементов. Внутри Shadow DOM там как-то не так CSS работает, я мало с этим работал — особо не подскажу.
Насчет микромодулей/микрофреймворка — для меня важна совместимость с React, так что это не мой путь. И я готов заплатить за него 20 лишними килобайтами
duodvk
08.12.2016 13:40А можно какой-нибудь пример такого фреймворка? Просто 20 килобайт — это и так вроде не много, но если можно ещё меньше, то почему нет.
vintage
08.12.2016 14:03
Я попробовал собрать donut, но там получается всё вместе в минифициованном виде 20 кб, что не сильно лучше варианта с преактом. Но тут и компонент очень простой без особых зависимостей.
webmasterx
А что насчет биндинга в ангуляре?