Мы создадим простой, но реалистичный модуль комментариев для блога, упрощенный аналог модуля комментариев реального времени, предлагаемый такими ресурсами как Disqus, LiveFyre и Facebook.
Мы обеспечим:
- Представление для отображения всех комментариев
- Форму для ввода и отправки комментариев
- Задел на будущее, для подключения настоящего бэк-енда
Также будут реализованы:
- Optimistic commenting: комментарии появляются на странице раньше чем они сохраняются на сервере, что визуально ускорит наш модуль
- Live updates: комментарии других пользователей появляются на странице в реальном времени
- Markdown formatting: пользователи могут использовать Markdown-разметку для форматирования текста
Финальная версия
Запуск сервера
Перед тем как приступить к руководству, нам необходимо запустить сервер. Он представляет из себя простой API, который мы будем использовать для получения и хранения данных. Мы уже написали его для вас на нескольких интерпретируемых языках, он обладает минимально необходимым функционалом. Вы можете ознакомиться с исходным кодом или скачать zip архив содержащий все необходимое.
Приступая
В этом руководстве мы постараемся реализовать все на столько просто, на сколько это возможно. В архиве, что мы упоминали выше, вы найдете HTML файл, в котором мы продолжим работать. Откройте файл public/index.html в вашем редакторе кода. Он должен выглядеть так:
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>React Tutorial</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.0.2/react.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/15.0.2/react-dom.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-core/5.8.23/browser.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.2.0/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/0.3.5/marked.min.js"></script>
</head>
<body>
<div id="content"></div>
<script type="text/babel" src="scripts/example.js"></script> <!-- Вот эту строчку нужно удалить -->
<script type="text/babel">
// Для начала работы удалите тег script, загружающий scripts/example.js
// и начните писать код из руководства здесь.
</script>
</body>
</html>
Весь JavaScript код из руководства мы будем писать в теге script. Поскольку у нас не реализован live-reloading, вам придется обновлять страницу проекта в браузере каждый раз после сохранения изменений. Отслеживать свои результаты вы можете открыв ссылку http://localhost:3000 в своем браузере(после запуска сервера). Когда вы откроете ссылку в первый раз, без каких либо изменений кода, вы увидите финальную версию нашего модуля комментариев. Для того чтоб приступить к работе, необходимо удалить первый тег script, загружающий код финальной версии проекта "scripts/example.js".
Заметка: Мы используем jQuery в нашем проекте для упрощения кода наших будущих ajax запросов, но это НЕ обязательная для работы React библиотека.
Ваш первый компонент
React — модульный, компонуемый фреймворк. Наш проект состоит из нескольких компонентов находящихся в следующей структуре:
- CommentBox
- CommentList
- Comment
- CommentForm
- CommentList
Создадим CommentBox компонент, который на выходе будет обычным тегом <div>
:
// tutorial1.js
var CommentBox = React.createClass({
render: function() {
return (
<div className="commentBox">
Hello, world! I am a CommentBox.
</div>
);
}
});
ReactDOM.render(
<CommentBox />,
document.getElementById('content')
);
Обратите внимание, названия HTML элементов начинаются с маленькой буквы, когда как названия React классов с большой.
JSX Синтаксис
Первое, что бросается в глаза — это XML-подобный синтаксис в представленном JavaScript коде. Мы используем простой прекомпилятор, который на выходе дает чистый JavaScript:
// tutorial1-raw.js
var CommentBox = React.createClass({displayName: 'CommentBox',
render: function() {
return (
React.createElement('div', {className: "commentBox"},
"Hello, world! I am a CommentBox."
)
);
}
});
ReactDOM.render(
React.createElement(CommentBox, null),
document.getElementById('content')
);
Использовать прекомпиляторы не обязательно, можно писать на чистом JavaScript, но в нашем руководстве мы будем использовать JSX синтаксис, на наш взгляд он проще и понятней. Подробнее ознакомиться с ним можно на странице JSX Syntax article.
Что происходит в коде
Мы передаем JavaScript объект с несколькими методами в React.createClass() для создания нового React компонента. Самый важный из переданных методов называется render, он возвращает дерево React компонентов, которое в итоге будет преобразовано в HTML.
<div>
теги не являются настоящими DOM узлами, это реализация React <div>
компонентов. Вы можете считать их маркерами или кусочками данных, которые React знает как обрабатывать. React безопасен, с точки зрения XSS уязвимостей.
Вам не требуется возвращать HTML код. Вы можете вернуть дерево компонентов, что вы(или кто-то другой) создали. Такой подход делает React компонуемым: ключевой признак сопровождаемой и хорошо спроектированной архитектуры фронтенда.
ReactDOM.render() создает экземпляр корневого компонента, запускает фреймворк и вставляет разметку в DOM элемент, переданный вторым аргументом.
Объект ReactDom содержит методы для работы с DOM, в то время как объект React содержит корневые методы, используемые в других библиотеках, например React Native.
Вызов ReactDOM.render должен осуществляться после объявления всех компонентов. Это важно.
Комбинированные компоненты
Создадим скелет для CommentList и CommentForm, которые будут обычными <div>
. Добавьте эти два компонента в ваш файл, оставив CommentBox и ReactDOM.render из предыдущего примера на своих местах:
// tutorial2.js
var CommentList = React.createClass({
render: function() {
return (
<div className="commentList">
Hello, world! I am a CommentList.
</div>
);
}
});
var CommentForm = React.createClass({
render: function() {
return (
<div className="commentForm">
Hello, world! I am a CommentForm.
</div>
);
}
});
Далее, внесите изменения в компонент CommentBox для использования наших новых компонентов(строки с пометкой "// new"):
// tutorial3.js
var CommentBox = React.createClass({
render: function() {
return (
<div className="commentBox">
//new start
<h1>Comments</h1>
<CommentList />
<CommentForm />
//new end
</div>
);
}
});
Обратите внимание на то, как мы смешиваем HTML теги и компоненты, что мы создали. HTML компоненты являются стандартными React компонентами, как и те, что мы объявили, но лишь с одним отличием. JSX препроцессор автоматически перепишет HTML теги в React.createElement(tagName) выражения и оставит все остальное в покое. Это необходимо для предотвращения засорения глобального пространства имен.
Использование реквизитов
Создадим Comment компонент, который будет зависеть от переданных родительским компонентом данных. Данные переданные от родителя доступны как свойство в дочернем компоненте. Доступ к свойствам осуществляется через this.props. Используя реквизиты, мы сможем прочитать данные переданные в Comment из CommentList, и отобразить разметку:
// tutorial4.js
var Comment = React.createClass({
render: function() {
return (
<div className="comment">
<h2 className="commentAuthor">
{this.props.author}
</h2>
{this.props.children}
</div>
);
}
});
Заключив JavaScript выражение в фигурные скобки внутри JSX, вы можете добавить текст или React компоненты в древо. Мы получаем доступ к именованным атрибутам переданным компоненту как ключи в this.props и к любым вложенным элементам, например this.props.children.
Свойства компонентов
Теперь, когда у нас есть объявленный компонент Comments, мы передадим в него имя автора и текст комментария. Это позволит нам многократно использовать один и тот же код для каждого комментария. Теперь добавим несколько комментариев в компонент CommentList:
// tutorial5.js
var CommentList = React.createClass({
render: function() {
return (
<div className="commentList">
//new start
<Comment author="Pete Hunt">This is one comment</Comment>
<Comment author="Jordan Walke">This is *another* comment</Comment>
//new end
</div>
);
}
});
Обратите внимание как мы передали данные из родительского компонента CommentList в дочерние компоненты Comment. Для примера, мы передали Pete Hunt (через атрибут) и This is one connent (через XML-подобный дочерний узел) в первый Comment. Как упоминалось ранее, Comment компонент получает доступ к этим свойствам через this.props.author и this.props.children.
Добавляем Markdown-разметку
Markdown — это удобный способ форматирования текста. Для примера, текст обернутый в звездочки будет подчеркнутым на выходе.
В этом руководстве мы используем стороннюю библиотеку marked, которая конвертирует Markdown разметку в чистый HTML. Мы уже подключили эту библиотеку ранее в нашем HTML файле, так что мы можем начать её использовать. Давайте конвертируем текст комментария с учетом Markdown разметки и выведем его:
// tutorial6.js
var Comment = React.createClass({
render: function() {
return (
<div className="comment">
<h2 className="commentAuthor">
{this.props.author}
</h2>
{marked(this.props.children.toString())} // new
</div>
);
}
});
Все что мы здесь сделали, это вызвали marked библиотеку. Теперь необходимо конвертировать this.props.children из React-подобного текста в обычную строку, которую поймет marked, так что мы специально для этого вызываем функцию toString().
Но здесь есть проблема! Наши обработанные компоненты выглядят в браузере так: "<p>
This is <em>
another </em>
comment </p>
". Нам необходимо преобразовать все теги в разметку для HTML текста.
Так React защищает вас от XSS атак. Ниже представлен способ обойти это:
// tutorial7.js
//new start
var Comment = React.createClass({
rawMarkup: function() {
var rawMarkup = marked(this.props.children.toString(), {sanitize: true});
return { __html: rawMarkup };
},
//new end
render: function() {
return (
<div className="comment">
<h2 className="commentAuthor">
{this.props.author}
</h2>
<span dangerouslySetInnerHTML={this.rawMarkup()} /> //new
</div>
);
}
});
Это специальный API, который намеренно усложняет работу с чистым HTML, но мы сделаем исключение для marked.
Внимание!: используя подобное исключения, вы целиком полагаетесь на безопасность библиотеки marked. Для этого мы передаем второй аргумент senitize: true, который включает очистку от любых HTML тегов.
Подключаем модель данных
До этого момента мы вставляли комментарии напрямую из кода. Теперь попробуем преобразовать JSON объект в лист комментариев. Далее мы будем их брать с сервера, а пока добавим эти строки в наш код:
// tutorial8.js
var data = [
{id: 1, author: "Pete Hunt", text: "This is one comment"},
{id: 2, author: "Jordan Walke", text: "This is *another* comment"}
];
Теперь нам необходимо передать этот объект в CommentList, соблюдая модульность. Изменим CommentBox и RenderDOM.render(), для передачи данных в компонент CommentList, используя метод props:
// tutorial9.js
var CommentBox = React.createClass({
render: function() {
return (
<div className="commentBox">
<h1>Comments</h1>
<CommentList data={this.props.data} /> //new
<CommentForm />
</div>
);
}
});
ReactDOM.render(
<CommentBox data={data} />, //new
document.getElementById('content')
);
Теперь данные доступны в компоненте CommentList, попробуем динамически отобразить комментарии:
// tutorial10.js
var CommentList = React.createClass({
render: function() {
//new start
var commentNodes = this.props.data.map(function(comment) {
return (
<Comment author={comment.author} key={comment.id}>
{comment.text}
</Comment>
);
});
//new end
return (
<div className="commentList">
{commentNodes} // new
</div>
);
}
});
Готово!
Получение комментариев с сервера
Заменим комментарии зашитые в коде на данные с сервера. Для этого замените атрибут data на url, как показано далее:
// tutorial11.js
ReactDOM.render(
<CommentBox url="/api/comments" />, // new
document.getElementById('content')
);
Внимание. На этом этапе код не работает.
Реактивное состояние
До сих пор, основываясь на своих параметрах, каждый компонент отрисовывал себя единожды, props неизменны — это значит, что они передаются от родителя и он остается их владельцем. Для организации взаимодействия мы добавим изменяемое свойство в компонент. this.state является приватным для компонента и может быть изменен через вызов this.setState(). После обновления свойства, компонент заново отрисует себя.
render() методы написаны декларативно, как функции this.props и this.state.
React гарантирует соответствие данных на сервере и в интерфейсе пользователя.
Когда сервер отправляет данные, нам необходимо изменить комментарии в интерфейсе. Добавим в CommentBox компонент отдельным параметром массив с комментариями:
// tutorial12.js
var CommentBox = React.createClass({
//new start
getInitialState: function() {
return {data: []};
},
//new end
render: function() {
return (
<div className="commentBox">
<h1>Comments</h1>
<CommentList data={this.state.data} /> // new
<CommentForm />
</div>
);
}
});
getInitialState() выполняется один раз, в процессе жизненного цикла компонента и устанавливает первоначальное состояние компонента.
Состояние обновления
После создания компонента, мы хотим получить JSON от сервера и обновить данные в компоненте, для отображения их в интерфейсе. Для асинхронных запросов к серверу мы будем использовать jQuery. Данные уже есть на сервере(хранятся в comments.json), что вы запустили в самом начале. Когда данные будут получены с сервера, this.state.data будет содержать:
[
{"id": "1", "author": "Pete Hunt", "text": "This is one comment"},
{"id": "2", "author": "Jordan Walke", "text": "This is *another* comment"}
]
// tutorial13.js
var CommentBox = React.createClass({
getInitialState: function() {
return {data: []};
},
//new start
componentDidMount: function() {
$.ajax({
url: this.props.url,
dataType: 'json',
cache: false,
success: function(data) {
this.setState({data: data});
}.bind(this),
error: function(xhr, status, err) {
console.error(this.props.url, status, err.toString());
}.bind(this)
});
},
//new end
render: function() {
return (
<div className="commentBox">
<h1>Comments</h1>
<CommentList data={this.state.data} />
<CommentForm />
</div>
);
}
});
Метод componentDidMount автоматически вызывается React'ом после первоначальной отрисовки компонента. Метод this.setState() несет ответственность за динамическое обновление. Мы заменим старый массив комментариев новым с сервера и наш интерфейс автоматически обновит сам себя. Благодаря этому, нам потребуется сделать незначительные правки для добавления обновления в реальном времени. Для простоты мы будем использовать технологию polling(Частые запросы), но в дальнейшем вы сможете без проблем воспользоваться WebSockets или любой другой технологией.
// tutorial14.js
var CommentBox = React.createClass({
loadCommentsFromServer: function() { // new
$.ajax({
url: this.props.url,
dataType: 'json',
cache: false,
success: function(data) {
this.setState({data: data});
}.bind(this),
error: function(xhr, status, err) {
console.error(this.props.url, status, err.toString());
}.bind(this)
});
}, // new
getInitialState: function() {
return {data: []};
},
componentDidMount: function() {
//new start
this.loadCommentsFromServer();
setInterval(this.loadCommentsFromServer, this.props.pollInterval);
//new end
},
render: function() {
return (
<div className="commentBox">
<h1>Comments</h1>
<CommentList data={this.state.data} />
<CommentForm />
</div>
);
}
});
ReactDOM.render(
<CommentBox url="/api/comments" pollInterval={2000} />, // new
document.getElementById('content')
);
Здесь мы переместили AJAX запрос в отдельный метод и инициируем его вызов после первой загрузки компонента и каждые 2 секунды после. Теперь попробуйте открыть нашу страницу комментариев в браузере и внесите изменения в comments.json файл(в корневой директории вашего сервера); в течении 2-х секунд вы увидите изменения на странице.
Добавление новых комментариев
Теперь пришло время создать форму комментариев. Наш компонент CommentForm должен запрашивать у пользователя имя и текст комментария, далее отправлять запрос на сервер для дальнейшего сохранения комментария.
// tutorial15.js
var CommentForm = React.createClass({
render: function() {
return (
//new start
<form className="commentForm">
<input type="text" placeholder="Your name" />
<input type="text" placeholder="Say something..." />
<input type="submit" value="Post" />
</form>
//new end
);
}
});
Контролируемые компоненты
В традиционном DOM, input элемент отрисовывается и уже затем бразуер устанавливает его значение. Как результат, значение DOM будет отлично от значения компонента. Плохо, когда значение в представлении отличается от значения в компоненте. В React'е компонент всегда должен соответствовать представлению и не только в момент его инициализации.
Следовательно, мы будем использовать this.state для хранения введенных пользователем данных. Объявим первоначальное состояние state с двумя свойствами author и text и присвоим им значение пустой строки. В нашем <input>
элементе мы присвоим параметру value значение state, и повесим на него обработчик onChange. Этот элемент <input>
с установленным значением атрибута value называется контролируемым компонентом. Подробнее прочитать про контролируемые компоненты вы можете в статье Forms article.
// tutorial16.js
var CommentForm = React.createClass({
//new start
getInitialState: function() {
return {author: '', text: ''};
},
handleAuthorChange: function(e) {
this.setState({author: e.target.value});
},
handleTextChange: function(e) {
this.setState({text: e.target.value});
},
//new end
render: function() {
return (
<form className="commentForm">
//new start
<input
type="text"
placeholder="Your name"
value={this.state.author}
onChange={this.handleAuthorChange}
/>
<input
type="text"
placeholder="Say something..."
value={this.state.text}
onChange={this.handleTextChange}
/>
//new end
<input type="submit" value="Post" />
</form>
);
}
});
События
React обработчики событий используют camelCase соглашение об именовании. Мы повесили onChange обработчики на два элемента <input>
. Теперь, когда пользователь ввел данные в поле <input>
, обработчик событий совершает обратный вызов и модифицирует значение компонента. В последствии значение input будет обновлено для того чтоб отразить текущее значение компонента.
Отправка формы
Сделаем форму интерактивной. После того как пользователь отправит форму, мы должны её очистить, отправить запрос на сервер и обновить список комментариев. Для начала получим данные формы и очистим её.
// tutorial17.js
var CommentForm = React.createClass({
getInitialState: function() {
return {author: '', text: ''};
},
handleAuthorChange: function(e) {
this.setState({author: e.target.value});
},
handleTextChange: function(e) {
this.setState({text: e.target.value});
},
//new start
handleSubmit: function(e) {
e.preventDefault();
var author = this.state.author.trim();
var text = this.state.text.trim();
if (!text || !author) {
return;
}
// TODO: отправить запрос на сервер
this.setState({author: '', text: ''});
},
//new end
render: function() {
return (
<form className="commentForm" onSubmit={this.handleSubmit}> // new
<input
type="text"
placeholder="Your name"
value={this.state.author}
onChange={this.handleAuthorChange}
/>
<input
type="text"
placeholder="Say something..."
value={this.state.text}
onChange={this.handleTextChange}
/>
<input type="submit" value="Post" />
</form>
);
}
});
Мы вешаем обработчик onSubmit на форму, который очистит её когда форма будет заполнена правильными данными и отправлена.
Вызываем preventDefault() для предотвращения действий браузера по отправке формы по умолчанию.
Обратные вызовы как параметры
Когда пользователь отправляет комментарий, нам необходимо обновить лист комментариев, для добавления нового. Имеет смысл реализовать всю эту логику в CommentBox, с тех пор как CommentBox управляет списком комментариев.
Мы должны передать от дочернего компонента, родительскому. Мы будем делать это через наш родительский метод render, передав новый обратный вызов (handleCommentSubmit) дочернему, связав его с событием onCommentSubmit дочернего компонента. Каждый раз, когда происходит событие, вызывается функция обратного вызова:
// tutorial18.js
var CommentBox = React.createClass({
loadCommentsFromServer: function() {
$.ajax({
url: this.props.url,
dataType: 'json',
cache: false,
success: function(data) {
this.setState({data: data});
}.bind(this),
error: function(xhr, status, err) {
console.error(this.props.url, status, err.toString());
}.bind(this)
});
},
//new start
handleCommentSubmit: function(comment) {
// TODO: отправить на сервер и обновить лист комментариев
},
//new end
getInitialState: function() {
return {data: []};
},
componentDidMount: function() {
this.loadCommentsFromServer();
setInterval(this.loadCommentsFromServer, this.props.pollInterval);
},
render: function() {
return (
<div className="commentBox">
<h1>Comments</h1>
<CommentList data={this.state.data} />
<CommentForm onCommentSubmit={this.handleCommentSubmit} /> // new
</div>
);
}
});
Теперь когда компонент CommentBox предоставил доступ к функции обратного вызова компоненту CommentForm через параметр onCommentSubmit, компонент CommentForm может вызвать функцию обратного вызова когда пользовател отправит форму:
// tutorial19.js
var CommentForm = React.createClass({
getInitialState: function() {
return {author: '', text: ''};
},
handleAuthorChange: function(e) {
this.setState({author: e.target.value});
},
handleTextChange: function(e) {
this.setState({text: e.target.value});
},
handleSubmit: function(e) {
e.preventDefault();
var author = this.state.author.trim();
var text = this.state.text.trim();
if (!text || !author) {
return;
}
this.props.onCommentSubmit({author: author, text: text}); // new
this.setState({author: '', text: ''});
},
render: function() {
return (
<form className="commentForm" onSubmit={this.handleSubmit}>
<input
type="text"
placeholder="Your name"
value={this.state.author}
onChange={this.handleAuthorChange}
/>
<input
type="text"
placeholder="Say something..."
value={this.state.text}
onChange={this.handleTextChange}
/>
<input type="submit" value="Post" />
</form>
);
}
});
Теперь, когда у нас есть функция обратного вызова, нам остается только отправить данные на сервер и обновить лист комментариев:
// tutorial20.js
var CommentBox = React.createClass({
loadCommentsFromServer: function() {
$.ajax({
url: this.props.url,
dataType: 'json',
cache: false,
success: function(data) {
this.setState({data: data});
}.bind(this),
error: function(xhr, status, err) {
console.error(this.props.url, status, err.toString());
}.bind(this)
});
},
handleCommentSubmit: function(comment) {
//new start
$.ajax({
url: this.props.url,
dataType: 'json',
type: 'POST',
data: comment,
success: function(data) {
this.setState({data: data});
}.bind(this),
error: function(xhr, status, err) {
console.error(this.props.url, status, err.toString());
}.bind(this)
});
//new end
},
getInitialState: function() {
return {data: []};
},
componentDidMount: function() {
this.loadCommentsFromServer();
setInterval(this.loadCommentsFromServer, this.props.pollInterval);
},
render: function() {
return (
<div className="commentBox">
<h1>Comments</h1>
<CommentList data={this.state.data} />
<CommentForm onCommentSubmit={this.handleCommentSubmit} />
</div>
);
}
});
Оптимизация: оптимистические обновления
Наше приложение готово, но ожидание завершения запроса к серверу и появления вашего комментария на странице делает его визуально медленным. Мы можем сразу добавить наш комментарий в лист, не дожидаяс завершения запроса к серверу, и это будет происходить почти мгновенно.
// tutorial21.js
var CommentBox = React.createClass({
loadCommentsFromServer: function() {
$.ajax({
url: this.props.url,
dataType: 'json',
cache: false,
success: function(data) {
this.setState({data: data});
}.bind(this),
error: function(xhr, status, err) {
console.error(this.props.url, status, err.toString());
}.bind(this)
});
},
handleCommentSubmit: function(comment) {
//new start
var comments = this.state.data;
// Optimistically set an id on the new comment. It will be replaced by an
// id generated by the server. In a production application you would likely
// not use Date.now() for this and would have a more robust system in place.
comment.id = Date.now();
var newComments = comments.concat([comment]);
this.setState({data: newComments});
//new end
$.ajax({
url: this.props.url,
dataType: 'json',
type: 'POST',
data: comment,
success: function(data) {
this.setState({data: data});
}.bind(this),
error: function(xhr, status, err) {
this.setState({data: comments}); // new
console.error(this.props.url, status, err.toString());
}.bind(this)
});
},
getInitialState: function() {
return {data: []};
},
componentDidMount: function() {
this.loadCommentsFromServer();
setInterval(this.loadCommentsFromServer, this.props.pollInterval);
},
render: function() {
return (
<div className="commentBox">
<h1>Comments</h1>
<CommentList data={this.state.data} />
<CommentForm onCommentSubmit={this.handleCommentSubmit} />
</div>
);
}
});
Поздравляем!
Вы создали модуль комментариев за несколько простых шагов. Узнайте больше о том зачем использовать React, или перейдите сразу к изучению API и начинайте писать код! Удачи!