Good weekend, friends! 😚
In the previous post, I have introduced you to a project that I'm currently working on - the TiniJS Framework, if you haven't read it yet, I invite you to check it out - I've created yet another JavaScript framework.
Today we are going to explore the basic concepts of a TiniJS app, including project structure, dev/build tools and components.
To get started, you can download a starter template, or run npx @tinijs/cli@latest new my-app
, or try the example apps on Stackblitz:
- Photo Gallery App: https://stackblitz.com/edit/try-tinijs
- To Do App: https://stackblitz.com/edit/try-tinijs-todo-app
Project structure
For a quick note about the term Projects
in the TiniJS platform. Since the TiniJS platform is designed to be as versatile as possible, which means it is able to work with many favorite tools and other frameworks or no frameworks. Therefore, a TiniJS project could be literally any project as long as it involves one or more TiniJS aspects. For example: a Vue app using Tini UI, a React app using Tini Content, a project using Tini CLI expansion, ... More about interoperable will be discussed along the way with future articles.
For this article, we will focus on projects involving an app built using TiniJS core framework. That's being said, let explore TiniJS apps.
A TiniJS app may have any folder structure as you see fit for what you are comfortable to work with. But as a convention, I recommend you use the below structure for most of the case. At the very basic, an app must have two files:
Item | Description |
---|---|
app/index.html
|
The entry point of the single page app, where you define: title, meta tags, includes fonts, init app root, ... |
app/app.ts
|
The app-root element is where you create a TiniJS client app, register config, setup router, setup UI, ... |
As your app grows, we will add different types of code, there are places for different things inside a TiniJS app, we can organize them into these files and folders:
Item | Description |
---|---|
tini.config.ts |
The main configuration source for various purposes across the TiniJS platform. |
app/routes.ts
|
For Tini Router, where you define routing behavior of the app. |
app/providers.ts
|
Working with services, utils, ... depend on the pattern, you may choose to provide dependencies at the app level, then lazy load them later and inject to pages and components. |
app/assets
|
For static assets such as images, SVG icons, ... |
app/public
|
For assets which will be copied as is upon build time and can be accessed from public URLs. |
app/types
|
Shared Typescript types. |
app/configs
|
Client app configuration files based on environments: development.ts, qa.ts, stage.ts, production.ts, ... when using with the Default Compiler, depend on the target environment, a specific config file will be applied. |
app/components
|
Reusable app components implement the TiniComponent class. |
app/pages
|
App pages for routing purpose. |
app/layouts
|
Layouts for pages. |
app/icons
|
Reusable icon components. |
app/partials
|
Small re-usable html templates which can be included in components and pages. |
app/utils
|
Any type of shareable logic functions, depend on the pattern, you can either import or inject them. |
app/services
|
Groups of related utilities, you can either import or inject them. |
app/consts
|
Shared constants. |
app/classes
|
Constructors which are intended to be used to construct objects. |
app/stores
|
Stores for global states management. |
app/contexts
|
Consumable contexts for mitigating prop-drilling. |
When integrate with other tools and frameworks, the specific integrated code will live in its own folder at the same level as the app
folder.
Dev and build tools
The development and build workflow of TiniJS are backed by any of your favorite tools. You can either set them up manually or automatically using Tini CLI and official builders. Some of the tools currently available are: Vite, Parcel and Webpack.
In theory, you can use any other tools (Turbo Pack, Gulp, ...). But I don't have time to try them, it is opened up for community contribution.
Vite (recommended, default)
Homepage: https://vitejs.dev/
Option 1: Via Tini CLI (recommended)
- Install:
npm i -D @tinijs/cli @tinijs/vite-builder
- Add scripts:
-
dev:
tini dev
-
build:
tini build
-
dev:
Option 2: Or, manually
- Install:
npm i -D vite
- Add scripts:
-
dev:
vite app
-
build:
vite build app --outDir www
-
dev:
Parcel
Homepage: https://parceljs.org/
Option 1: Via Tini CLI (recommended)
- Install:
npm i -D @tinijs/cli @tinijs/parcel-builder
- Config tini.config.ts, set build.builder to
@tinijs/parcel-builder
- Add scripts:
-
dev:
tini dev
-
build:
tini build
-
dev:
Option 2: Or, manually
- Install:
npm i -D parcel @parcel/config-default
- Add scripts:
-
dev:
parcel app/index.html --dist-dir www
-
build:
parcel build app/index.html --dist-dir www
-
dev:
Additional setup
Either using Tini CLI or setup manually, you need to do these additional setup.
- Modify package.json
{
"browserslist": "> 0.5%, last 2 versions, not dead",
"@parcel/resolver-default": {
"packageExports": true
}
}
Webpack
Homepage: https://webpack.js.org/
Option 1: Via Tini CLI (recommended)
- Install:
npm i -D @tinijs/cli @tinijs/webpack-builder
- Config tini.config.ts, set build.builder to
@tinijs/webpack-builder
- Add scripts:
-
dev:
tini dev
-
build:
tini build
-
dev:
Option 2: Or, manually
- Install:
npm i -D webpack webpack-cli webpack-dev-server html-bundler-webpack-plugin ts-loader
- Add webpack.config.cjs, please see example.
- Add scripts:
-
dev:
webpack serve --history-api-fallback --mode development
-
build:
webpack build --mode production
-
dev:
Working with Components
Components are basic building blocks of TiniJS apps. They are custom elements which extend the standard HTMLElement
- the base class of native HTML elements. Since TiniJS is based on Lit, so it is nice to know how to define a component using LitElement, but it is not required because we will explore the basic concepts together.
Create components
To quickly scaffold a component using Tini CLI, run npx tini generate component <name>
, a component file will be created at app/components/<name>.ts
.
A TiniJS component looks like this:
import {html, css} from 'lit';
import {Component, TiniComponent} from '@tinijs/core';
@Component()
export class AppXXXComponent extends TiniComponent {
static readonly defaultTagName = 'app-xxx';
// Logic here
protected render() {
return html`<p>Template here</p>`;
}
static styles = css`/* Style here */`;
}
There are 3 main sections:
- Logic: class properties and methods for defining properties, internal states, events and other logic.
- Template: HTML template with Lit html template literal syntax.
- Style: CSS for styling the template.
Using components
To consume components, you must first register them, either globally or locally.
Register components globally at the app level, this is convenient since you only need to do it once per component, but it has the drawback that the initial bundle also includes all the related constructors.
// register components globally in app/app.ts
import {AppXXXComponent} from './components/xxx.js';
@App({
components: [AppXXXComponent]
})
export class AppRoot extends TiniComponent {}
Components can also be registering locally at layout, app or component level. The benefit is that certain components will come with lazy-load pages instead of app initialization. The drawback is that it is repetitive (I think of auto import in the future, it may help a little).
// register components locally
import {AppXXXComponent} from '../components/xxx.js';
@Component|Page|Layout({
components: [AppXXXComponent]
})
export class ComponentOrPageOrLayout extends TiniComponent {}
Notice that there is defaultTagName = '...'
. It is the default tag name of the component, you can register a component with a different tag name, use this syntax:
// AppFooComponent has the default tag name
static readonly defaultTagName = 'app-foo';
// register a different tag name
{
components: [
AppXXXComponent,
[AppFooComponent, 'bar-baz-qux']
]
}
After register, you can use the tag <app-xxx></app-xxx>
and <bar-baz-qux></bar-baz-qux>
just like they are native HTML tags.
Props and events
Components usually has properties and events for data exchange and interaction.
Properties
Use the decorator @Input()
or @property()
to define properties.
import {property} from 'lit/decorators/property.js';
import {Input} from '@tinijs/core';
@Component()
export class AppXXXComponent extends TiniComponent {
// Lit syntax
@property() prop1?: string;
// or, TiniJS syntax
@Input() prop2?: {foo: number};
}
Passing properties to components is similar to set attributes in native HTML elements, string values as in key="value"
and non-string as in .key=${varOrValue}
.
html`<app-xxx prop1="Lorem ipsum" .prop2=${{ foo: 999 }}></app-xxx>`
Beside define properties, you can also use Contexts as a form of communicating data. Sometime certain values are required by many components in a long-nested chain of components, passing values down the whole chain (aka. prop drilling) would be very annoying. Use contexts to provide and consume such values is more efficient. Please see more detail at https://lit.dev/docs/data/context/.
Events
Use the decorator @Output()
to define events.
import {Output, type EventEmitter} from '@tinijs/core';
@Component()
export class AppXXXComponent extends TiniComponent {
@Output() event1!: EventEmitter<string>;
@Output() event2!: EventEmitter<{ foo: number }>;
emitEvent1() {
this.event1.emit('Lorem ipsum');
}
protected render() {
return html`<button @click=${() => this.event2.emit({ foo: 999 })}></button>`;
}
}
Event payloads can be accessed via the detail
field.
html`
<app-xxx
@event1=${({detail: event1Payload}: CustomEvent<string>) => console.log(event1Payload)}
@event2=${({detail: event2Payload}: CustomEvent<{ foo: number }>) => console.log(event2Payload)}
></app-xxx>
`
Handle states
By default, class properties changes won't trigger the UI changes. In order to trigger render()
every time a value changes you must implicitly define a state, states can either be local or global.
Local states
The properties defined using @property()
or @Input()
as we see above are already local states which means changing those properties will trigger render()
.
To define local states but not in the form of property, use the @state()
or @Reactive()
.
import {state} from 'lit/decorators/state.js';
import {Reactive} from '@tinijs/core';
@Component()
export class AppXXXComponent extends TiniComponent {
// Lit syntax
@state() state1?: string;
// or, TiniJS syntax
@Reactive() state2: number = 123;
protected render() {
return html`
<div>${this.state1}</div>
<div>${this.state2}</div>
`;
}
}
Global states
States can also be organized in some central places (aka. stores). You can use Tini Store (very simple, ~50 lines) or other state management solutions such as MobX, TinyX, ...
Getting started with Tini Store by install npm i @tinijs/store
. Then create a store:
import {createStore} from '@tinijs/store';
export const mainStore = createStore({
foo: 'bar'
});
After creating a store, you now can access its states, subscribe to state changes and mutate states.
import {Subscribe} from '@tinijs/store';
import {mainStore} from './stores/main.js';
/*
* Access states
*/
const foo = mainStore.foo;
/*
* Mutate states
*/
// assign a new value
mainStore.foo = 'bar2';
// or, using the 'commit' method
mainStore.commit('foo', 'bar3');
/*
* Subscribe to state changes
*/
@Component()
export class AppXXXComponent extends TiniComponent {
// Use the @Subscribe() decorator
// this.foo will be updated when mainStore.foo changes it is reactive by default
@Subscribe(mainStore) foo = mainStore.foo;
// use a different variable name
@Subscribe(mainStore, 'foo') xyz = mainStore.foo;
// to turn of reactive, set the third argument to false
@Subscribe(mainStore, null, false) foo = mainStore.foo;
// Or, subscribe and unsubscribe manually
onInit() {
this.unsubscribeFoo = mainStore.subscribe('foo', value => {
// do something with the new value
});
}
// NOTE: remember to unsubscribe when the component is destroyed
onDestroy() {
this.unsubscribeFoo();
}
}
Use Signals
Another method for managing states is using Signals. Signals are an easy way to create shared observable state, state that many elements can use and update when it changes. Please see more detail at https://www.npmjs.com/package/@lit-labs/preact-signals.
Lifecyle hooks
Custom elements created by extending HTMLElement
as well as LitElement
have their lifecycle hooks for tapping into when needed, please see https://lit.dev/docs/components/lifecycle/ for more detail.
There are some other hooks supported by TiniComponent
, includes: OnCreate, OnDestroy, OnChanges, OnFirstRender, OnRenders, OnInit, OnReady, OnChildrenRender, OnChildrenReady. Here is a quick summary of them.
OnCreate
Alias of connectedCallback()
without the need of calling super.connectedCallback()
. The very beginning of a component, the element has got connected to the DOM (more detail).
import {type OnCreate} from '@tinijs/core';
export class Something extends TiniComponent implements OnCreate {
onCreate() {}
}
OnDestroy
Alias of disconnectedCallback()
without the need of calling super.disconnectedCallback()
. The end of an element, got removed from the DOM (more detail).
import {type OnDestroy} from '@tinijs/core';
export class Something extends TiniComponent implements OnDestroy {
onDestroy() {}
}
OnChanges
Alias of willUpdate()
of LitElement. Used to computed values using in the render()
(more detail).
import {type OnChanges} from '@tinijs/core';
export class Something extends TiniComponent implements OnChanges {
onChanges() {}
}
OnFirstRender
Alias of firstUpdated()
of LitElement. The render()
has run the first time (more detail).
import {type OnFirstRender} from '@tinijs/core';
export class Something extends TiniComponent implements OnFirstRender {
onFirstRender() {}
}
OnRenders
Alias of updated()
of LitElement. Changes has been updated and rendered (more detail).
import {type OnRenders} from '@tinijs/core';
export class Something extends TiniComponent implements OnRenders {
onRenders() {}
}
OnInit
Can be used in interchangeable with OnCreate
. When use with Lazy DI injection, injected dependencies available starting from onInit()
, usually used to handle async tasks.
import {type OnInit} from '@tinijs/core';
export class Something extends TiniComponent implements OnInit {
async onInit() {}
}
OnReady
Similar to OnFirstRender
but it only counts after any async tasks in OnInit
has been resolved and render (first stateful render).
import {type OnReady} from '@tinijs/core';
export class Something extends TiniComponent implements OnReady {
async onReady() {}
}
Next topic: Working with pages, layouts, meta management and the Router.
Thank you for spending time with me. If there was anything not working for you, please leave a comment or open an issue or ask for help on Discord, I'm happy to assist.
Wish you all the best and happy coding! 💖