Приветствую! Сегодня хотел бы разобрать пример реализации архитектуры сайта на фреймворке Akili. В данной статье речь пойдет не столько о системе компонентов, как о полноценной реализации приложения, с использованием роутинга, ajax запросов, хранилища и т.д. прямо из коробки, без внешних зависимостей.



Для сборки проекта используется webpack, для компиляции babel с пресетами env, stage-2, stage-3, для отдачи статики node + express + akili-connect. Присутствует eslint.


Структура файлов и описание


Папка для доступа к статике из браузера /public/assets.
Папка с фронтендом /src, входная точка /src/main.js.
Поднятие сервера в app.js.


Бэкенда как такового в примере нет. В файле app.js написана простая реализация отдачи статики + пару строк для серверного рендеринга.


Демонстрационные данные берутся с сайта https://jsonplaceholder.typicode.com/.


Структура фронтенда состоит из трех основных частей:


  • components — папка с универсальными компонентами, которые можно использовать много раз в рамках приложения.
  • controllers — папка с уникальными компонентами, отвечающими за логику приложения: маршрутизацию и распределение данных.
  • actions — папка с функциями для получения и сохранения данных.

И трех второстепенных:


  • fonts – общая папка с шрифтами
  • img – общая папка с изображениями
  • styles – общая папка со стилями

При этом три папки выше со статикой не являются уникальными и у каждого отдельного компонента могут быть свои собственные.


Универсальный(простой) компонент полностью самостоятельный. Данные в него передаются через атрибуты, а обратно мы получаем результат через события. Он не должен работать с хранилищем. Этим занимаются компоненты-контроллеры. Контроллер — это связующее звено между хранилищем и простыми компонентами.


src/main.js


Наилучшим способом регистрировать компоненты во фреймворке является описание статического метода .define() у каждого компонента, и их вызов в точке входа.


import App from './controllers/app/app';
import Posts from './controllers/posts/posts';
import PostEdit from './controllers/post-edit/post-edit';
import Users from './controllers/users/users';
import PostCards from './components/post-cards/post-cards'
import PostForm from './components/post-form/post-form'
import UserCards from './components/user-cards/user-cards'

App.define();
Posts.define();
PostEdit.define();
Users.define();
PostCards.define();
PostForm.define();
UserCards.define();

Для того чтобы совершать ajax запросы мы используем сервис request.


import request, { Request } from 'akili/src/services/request';

request.addInstance('api', new Request('https://jsonplaceholder.typicode.com', { json: true }));

Обратите внимание на интересную деталь. По умолчанию, объект request сам является экземпляром класса Request. И уже с помощью него можно было бы делать любые запросы. Но гораздо удобнее создавать для каждого направления запросов отдельный экземпляр со своими настройками. В данном случаи, для работы с api jsonplaceholder.typicode.com мы создали отдельный.


Теперь мы в любом месте можем использовать его, импортировав лишь объект request, например:


request.use.api.get('/posts').then(res => console.log(res.data));

Запрос будет направлен на https://jsonplaceholder.typicode.com/posts, в заголовках с типом контента json, и в ответе мы сразу получим объект, вместо строки.
Более подробно об ajax запросах тут.


Далее в нашем файле мы видим следующий строки:


import store from 'akili/src/services/store';

window.addEventListener('state-change', () => store.loader = true);
window.addEventListener('state-changed', () => store.loader = false);

Начнем с объекта store. Это хранилище нашего приложения. Здесь можно хранить любые данные. При этом данное хранилище автоматически синхронизируется со всеми местами, где нужны какие-либо изменения. Нужно всего лишь изменить необходимое свойство. В строках выше мы как раз, при определенных событиях, меняем свойство loader, на которое подписан один из компонентов, отображающий прелоадер.


События state-change и state-changed не являются стандартными для window. Их вызывает роутер фреймворка. Первое, перед любым изменением в адресной строке браузера, второе, сразу после него. Это нам нужно для работы прелоадера. Об этом чуть позже.


Далее происходит инициализация роутера и фреймворка после загрузки DOM.


document.addEventListener('DOMContentLoaded', () => {
  router.init('/app/posts', false);
  Akili.init().catch((err) => console.error(err));
});

src/controllers/app/app.js


В этом файле описан корневой контроллер. В нем мы обозначаем первый уровень маршрутизации для отображения хедера в шаблоне и точки входа для вложенных маршрутов.


import './styles/app.scss'
import Akili from 'akili';
import router from 'akili/src/services/router';

export default class App extends Akili.Component {
 static template = require('./app.html');

 static define() {
   Akili.component('app', this);

   router.add('app', '^/app', {
     component: this,
     title: 'Akili example site'        
   });
 }

 compiled() {
   this.store('loader', 'showLoader');
   this.store('posts', posts => this.scope.post = posts.find(p => p.selected));
 }
}

Давайте пройдемся по коду выше. Сначала мы подгружаем стили к данному компоненту. Все статические файлы конкретного компонента, стили, изображения, шрифты, хранятся в его личной папке /src/controllers/app, а не общей.


Дальше идет объявление компонента. Метод .define() не является обязательным, но это очень удобный способ для настройки каждого отдельного компонента. В нем мы описываем все действия, которые необходимы для работы, и потом вызываем его в точке входа (src/main.js).


Akili.component('app', this); 

Строка выше регистрирует компонент под тэгом app, чтобы мы могли использовать его в шаблоне. Дальше идет добавление маршрута в роутер и.т.д.


.compiled() — один из методов лайвцикла компонента, который вызывается после компиляции. В нем происходят две подписки на хранилище. Об одной из них мы говорили ранее:


this.store('loader', 'showLoader');

Этой строкой мы связали свойства хранилища loader и свойство скоупа текущего компонента showLoader. По умолчанию, связь создается в обе стороны. Если поменяется store.loader, то мы получим изменения в scope.showLoader и наоборот.


src/controllers/app/app.html


Здесь находится шаблон компонента-контроллера app.
Мы его указали как статическое свойство template в компоненте.


static template = require('./app.html');

Рассмотрим интересный кусок из шаблона:


<img
  src="./img/logo.svg"
  width="60"
  class="d-inline-block align-middle mr-1 ${ utils.class({loader: this.showLoader}) }"
>

Это изображение логотипа. Оно же и прелоадер. Если к нему добавить класс loader, то изображение начнет крутиться. Теперь должна быть понятна вся цепь событий связанных с прелоадером. В src/main.js мы подписались на два события. Перед изменением адресной строки мы меняем store.loader на true. В этот момент свойство showLoader в скоупе компонента App тоже станет true, и выражение utils.class({loader: this.showLoader}) вернет класс loader. Когда загрузка будет завершена, все поменяется на false и класс исчезнет.


Еще важный кусок:


<div class="container pb-5">
 <route></route>
</div>

route — специальный компонент, в который подгружается шаблон соответствующего по уровню вложенности маршрута. В данном случаи, это уже второй уровень. То есть любой маршрут-наследник от app будет подгружен сюда. А сам app был загружен в route, который был указан в body в /public/main.html.


src/controllers/posts/posts.js


Здесь описан компонент-контроллер постов.


import Akili from 'akili';
import router from 'akili/src/services/router';
import store from 'akili/src/services/store';
import { getAll as getPosts } from '../../actions/posts';

export default class Posts extends Akili.Component {
 static template = require('./posts.html');

 static define() {
   Akili.component('posts', this);

   router.add('app.posts', '/posts', {
     component: this,
     title: 'Akili example | posts',
     handler: () => getPosts()
   });
 }

 created() {
   this.scope.setPosts = this.setPosts.bind(this);
   this.scope.posts = store.posts;
 }

 setPosts(posts = []) {
   store.posts = this.scope.posts = posts;
 }
}

Многое вам уже знакомо, но есть и новые моменты. Например, для указания вложенности мы используем точку в названии маршрута: app.posts. Теперь posts наследуется от app.


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


В примере выше мы взяли данные из хранилища:


this.scope.posts = store.posts;

Потому что наша функция .getPosts() заодно его туда сохранила, но мы могли взять данные и из транзита:


this.scope.posts = router.transition.path.data;

Такой вариант вы можете увидеть в контроллере users.


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


this.scope.setPosts = this.setPosts.bind(this);

src/controllers/posts/posts.html


Это шаблон постов. Основная задача здесь — отобразить список постов. Но поскольку данный компонент является контроллером, то мы не будем делать этого непосредственно здесь. Ведь список постов это нечто универсальное, мы должны иметь возможность использовать его где угодно. Поэтому он вынесен в отдельный компонент src/components/post-cards.


<post-cards
  data="${ this.filteredPosts = utils.filter(this.posts, this.filter, ['title', 'body']) }"
  on-data="${ this.setPosts(event.detail) }"
></post-cards>

Теперь мы просто передадим в компонент PostCards нужный массив, а он уже отобразит все как надо. Правда, у нас здесь есть еще поиск.


<input class="form-control" placeholder="search..." on-debounce="${ this.filter = event.target.value }">

<if is="${ !this.filteredPosts.length }">
 <p class="alert alert-warning">Not found anything</p>
</if>

Поэтому данные (this.posts) мы передаем отфильтрованные. Событие on-debounce кастомное. Оно возникает с задержкой по последнему нажатию клавиши в инпуте. Можно было бы использовать стандартное on-input, но при большом количестве данных это будет значительно менее производительно. Про события в целом тут.


При изменении данных внутри PostCards, он вызовет кастомное событие on-data, обработав которое, мы сохраняем изменения постов в хранилище, вызвав this.setPosts(event.detail).


src/controllers/post-edit/post-edit.js


Здесь описывается компонент-контроллер страницы редактирования поста.
Весь код анализировать смысла нет, поскольку в примерах выше почти все аналогично. Остановимся на отличии:


router.add('app.post-edit', '/post-edit/:id', {
   component: this,
   title: transition => `Akili example | ${ transition.path.data.title }`,
   handler: transition => getPost(transition.path.params.id)
});

В данном маршруте мы указали динамический параметр id.
Поэтому в функции-обработчике мы имеем доступ к его значению в transition.path.params.id. В данном случае это id поста, чтобы получить нужный.


src/controllers/post-edit/post-edit.html


Также как и со списком постов, здесь мы форму вынесли в отдельный компонент PostForm, чтобы можно было использовать ее много раз.


<post-form post="${ this.post }" on-save="${ this.savePost(event.detail) }"></post-form>

src/components/post-form/post-form.js


Рассмотрим данный компонент.
Обратите внимание на комментарии:


/**
* Universal component to display a post form
*
* {@link https://akilijs.com/docs/best#docs_encapsulation_through_attributes}
*
* @tag post-form
* @attr {object} post - actual post
* @scope {object} post - actual post
* @message {object} post - sent on any post's property change 
* @message {object} save - sent on form save
*/

Это js-doc с некоторыми кастомными тэгами.


  • @tag — это название компонента при регистрации
  • @selector — точный селектор, описывающий элементы, подходящие под этот компонент
  • @attr — атрибут для передачи данных в компонент извне
  • @scope — свойство скоупа компонента
  • @message — сообщение, посылаемое при вызове кастомного события

Комментарии в исходниках фреймворка написаны в таком же стиле.


compiled() {
   this.attr('post', 'post'); 
}

В куске кода выше, мы создали связь между атрибутом post и свойством скоупа компонента post. То есть, если передать этот атрибут с каким-то значением, то тут же получим изменения в scope.post. При изменении же scope.post в компоненте, будет автоматически вызвано событие on-post.


<post-form post=”${ this.parentPost }” on-post=”${ this.parentPost = event.detail }”>

Если бы мы написали html код выше где-нибудь, то получилась бы двойная связь между родительским scope.parentPost и текущим scope.post.


Но наша форма работает немного иначе. Нам нужно сохранить изменившийся пост только по нажатию кнопки, а не при каждом изменении. Поэтому мы используем свое собственное событие по клику:


static events = ['save'];

save() {
   this.attrs.onSave.trigger(this.scope.post);
}

В первой строке мы зарегистрировали кастомное событие. Метод .save() вызывается при клике на кнопку в форме. В нем мы триггерим наше зарегистрированное событие save и передаем новый пост


<post-form post="${ this.post }" on-save="${ this.savePost(event.detail) }"></post-form>

Этот кусок кода из шаблона контроллера PageEdit. То есть мы передали пост через атрибут post в компонент PostForm, а обратно получаем изменившийся, обработав on-save.


src/actions


Действия — это всего лишь функции для получения и сохранения данных. Для чистоты и удобства они вынесены в отдельную папку.


Например, src/actions/posts.js:


import request from 'akili/src/services/request';
import store from 'akili/src/services/store';

export function getAll() {
 if(store.posts) {
   return Promise.resolve(store.posts);
 }

 return request.use.api.get('/posts').then(res => store.posts = res.data);
}

export function getPost(id) {
 return getAll().then(posts => {
   let post = posts.find(post => post.id == id);

   if(!post) {
     throw new Error(`Not fount post with id "${id}"`);
   } 

   return post;
 });
}

export function updatePost(post) {
 return request.use.api.put(`/posts/${post.id}`, { json: post }).then(res => {
   store.posts = store.posts.map(item => item.id == post.id? {...item, ...post}: item);
   return res.data;
 });
}

Все достаточно просто. Три функции: для получения списка постов, получения конкретного поста и для обновления поста.


Подведем итоги


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


В этой статье я не хотел расписывать подробно о всех возможностях компонентной системы фреймворка, хоть это и очень важная составляющая. На сайте есть много примеров: реализация дерева, todo лист, setInterval, табы и.т.д. Документация также полна примерами и достаточно полная. Основной целью было показать как можно легко и быстро создать приложение на Akili.


Что в итоге получаем, используя Akili:


  • Мощную и интуитивно-понятную компонентную систему, позволяющую стереть грань между разметкой и логикой приложения. Кроме того в нее можно легко обернуть любой сторонний модуль. Будь то, перетаскивания элементов, аккордеоны и прочее.
  • Хранилище для сохранения и распределения данных между компонентами приложения. А-ля redux, но еще проще. Куда еще проще редакса спросите вы? Смотрим ))
  • Маршрутизацию из коробки. Поддерживает весь основной функционал: наследование, динамические данные, шаблоны, работа с hash и без, изменение document.title, resolving данных, абстрактные маршруты, редиректы и многое другое.
  • Возможность совершать ajax запросы из коробки. Можно создавать разные инстансы со своими настройками. Наличие системы кэширования. Отправление любых типов данных, без предварительных танцев с бубнами и.т.д.
  • Серверный рендеринг. Правда на данный момент реализован с ограничением. Код выполняется и на сервере и на клиенте. В планах есть передача хотя бы части стэйта на клиент.
  • Отсутствие всего, что не предусматривают по умолчанию html и javascript. Никаких магических надстроек к разметке или коду.

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