Akili framework application architecture

image


Greetings! Today I would like to make an example of the implementation of the site architecture on the Akili framework . This article will focus not only on the component system, but on the full implementation of the application, using routing, ajax requests, storage, etc. right out of the box, without external dependencies.



To build the project, webpack is used , to compile babel with presets env , stage-2 , stage-3 , to return the static node + express + akili-connect . There is an eslint .


File Structure and Description


The folder for accessing statics from the browser / public / assets .
Frontend /src/main.js .
Raising server app.js .


There is no backend as such in the example. A simple implementation of returning statics + a couple of lines for server rendering is written in the app.js file .


Sample data is taken from https://jsonplaceholder.typicode.com/ .


The frontend structure consists of three main parts:


  • components - a folder with universal components that can be used many times within the application.
  • controllers - a folder with unique components responsible for the application logic: routing and data distribution.
  • actions - a folder with functions for receiving and saving data.

And three minor:


  • fonts - shared folder with fonts
  • img - shared folder with images
  • styles - shared folder with styles

In this case, the three folders above with statics are not unique and each individual component can have its own.


The universal (simple) component is completely independent. Data is transferred to it through attributes , and back we get the result through events . It should not work with storage . This is done by the component controllers. A controller is the link between storage and simple components.


src / main.js


The best way to register components in the framework is to describe the static .define () method for each component and call them at the entry point.


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();

In order to make ajax requests, we use the request service .


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

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

Pay attention to an interesting detail. By default, the request object itself is an instance of the Request class . And already with the help of it one could make any requests. But it is much more convenient to create a separate instance for each direction of requests with its own settings. In this case, we created a separate one for working with the api jsonplaceholder.typicode.com .


Now we can use it anywhere by importing only the request object , for example:


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

The request will be sent to https://jsonplaceholder.typicode.com/posts , in the headers with the json content type , and in the response we will immediately receive an object, instead of a string.
More details about ajax requests here .


Further in our file we see the following lines:


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

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

Let's start with the store object . This is the repository of our application. Any data can be stored here. At the same time, this storage is automatically synchronized with all places where any changes are needed. It is only necessary to change the necessary property. In the lines above, we just, at certain events, change the loader . roperty , to which one of the components that displays the pre loader . s subscribed.


State-change and state-changed are not standard for window . They are called by a router of a framework . The first, before any change in the address bar of the browser, the second, immediately after it. We need this for the pre loader . o work. More about this later.


Next, the router and the framework are initialized after loading the DOM.


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

src / controllers / app / app.js


This file describes the root controller. In it, we denote the first level of routing to display the header in the template and entry points for nested routes.


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));
 }
}

Let's go through the code above. First, we load the styles for this component. All static files of a particular component, styles, images, fonts, are stored in its personal folder / src / controllers / app , and not shared.


Next comes the component declaration. The .define () method is optional, but it is a very convenient way to configure each individual component. In it, we describe all the actions that are necessary for work, and then call it at the entry point (src / main.js).


Akili.component('app', this); 

The line above registers the component under the app tag so that we can use it in the template. Next is adding a route to the router , etc.


.compiled () is one of the component's lifecycle methods that is called after compilation. It has two storage subscriptions. We talked about one of them earlier:


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

With and the scope property of the current showLoader component . By default, a link is created in both directions. If store. loader . hanges , then we get changes in scope.showLoader and vice versa.


src / controllers / app / app.html


This is where the app controller component template is located.
We specified it as the static property of template in the component.


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

Consider an interesting piece from the template:


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

This is a logo image. It is also a pre loader . If you add the loader . lass to it , the image will begin to spin. Now the whole chain of events related to the pre loader . hould be clear. At src / main.js we signed up for two events. Before changing the address bar, we change store. loader . o true . At this point, the showLoader property in the scope of the App component will also become true , and the utils.class expression ({ loader . this.showLoader}) will return the class loader . When the download is complete, everything changes to false and the class disappears.


Another important piece:


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

route - a special component into which the template of the route corresponding to the level of nesting is loaded. In this case, this is the second level. That is, any successor route from the app will be loaded here. And the app itself was loaded into the route , which was specified in the body in /public/main.html .


src / controllers / posts / posts.js


The post controller component is described here.


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;
 }
}

You already know a lot, but there are new points. For example, to indicate nesting, we use a dot in the name of the route: app.posts . Now posts are inherited from the app .


Also, when declaring a route, we specified the handler function . It will be called if the user gets to the corresponding url. As an argument, a special object will be passed to it, where all information about the current transit is stored. What we return in this function will also fall into this object. The link to the transit object is located in router.transition and is available everywhere.


In the example above, we took data from the storage:


this.scope.posts = store.posts;

Because our .getPosts () function at the same time saved it there, but we could take the data from transit:


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

You can see this option in the users controller .


I would also like to note that the methods of the component are not in the scope of its template. To call any function in the template, you need to add it to the template scope:


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

src / controllers / posts / posts.html


This is a post template. The main task here is to display a list of posts. But since this component is a controller, we will not do this directly here. After all, the list of posts is something universal, we should be able to use it anywhere. Therefore, it is moved to a separate component 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>

Now we just pass the necessary array to the PostCards component , and it will already display everything as it should. True, we still have a search here.


<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>

Therefore, the data (this.posts) we transmit is filtered . Event on-debounce custom. It occurs with a delay by the last keystroke in the input. It would be possible to use the standard on-input, but with a lot of data this will be much less productive. About events in general here .


When data is changed inside PostCards , it will trigger a custom on-data event , by processing which we save the post changes to the repository by calling this.setPosts (event.detail) .


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


The controller component of the post editing page is described here.
It makes no sense to analyze all the code, since in the examples above almost everything is similar. Let us dwell on the differences:


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)
});

In this route we specified the dynamic parameter id .
Therefore, in the handler function, we have access to its value in transition.path.params.id . In this case, this is the post id to get the one you need.


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


As well as with the list of posts, here we moved the form into a separate component of PostForm so that you can use it many times.


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

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


Consider this component.
Pay attention to the comments:


/**
* 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
*/

This is js-doc with some custom tags.


  • @tag Is the name of the component during registration
  • @selector - an exact selector describing the elements suitable for this component
  • @attr - attribute to transfer data to the component from the outside
  • @scope - component scope property
  • @message - message sent when a custom event is called

Comments in the framework source are written in the same style.


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

In the code snippet above, we have created a link between the attribute post and property Scope component post . That is, if you pass this attribute with some value, then we immediately get the changes in scope.post . If scope.post in the component changes, the on-post event will be automatically called .


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

If we wrote the html code above somewhere, we would get a double connection between the parent scope.parentPost and the current scope.post .


But our form works a little differently. We need to save the changed post only at the click of a button, and not with every change. Therefore, we use our own click event :


static events = ['save'];

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

In the first line, we registered a custom event. The .save () method is called when a button on a form is clicked. In it, we trigger our registered save event and submit a new post


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

This piece of code is from a PageEdit controller template . That is, we passed the post through the post attribute to the PostForm component , and back we get the changed one by processing on-save .


src / actions


Actions are just functions for receiving and saving data. For cleanliness and convenience, they are placed in a separate folder.


For example, 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;
 });
}

Everything is quite simple. Three functions: to get a list of posts, get a specific post, and to update a post.


To summarize


We will not consider files with user components, since there almost all the logic is similar to the one described above.


In this article, I did not want to paint in detail about all the features of the component system of the framework, although this is a very important component. There are many examples on the site : tree implementation, todo list, setInterval, tabs , etc. The documentation is also full of examples and quite complete. The main goal was to show how to easily and quickly create an application on Akili.


What we finally get using Akili:


  • A powerful and intuitive component system that allows you to erase the line between markup and application logic. In addition, any third-party module can be easily wrapped in it. Be it drag and drop items, accordions and more.
  • Storage for storing and distributing data between application components. A la redux, but even easier. Where is the editor even easier to ask? We look ))
  • Routing out of the box. It supports all the basic functionality: inheritance, dynamic data, templates, working with and without hash, changing document.title, resolving data, abstract routes, redirects, and much more.
  • Ability to make ajax requests out of the box. You can create different instances with your own settings. The presence of a caching system. Sending any type of data, without preliminary dances with tambourines, etc.
  • Server-side rendering . The truth is currently implemented with a limitation. The code runs on both the server and the client. The plans include transferring at least part of the state to the client.
  • Lack of everything that html and javascript do not provide by default. No magic add-ons for markup or code.