malevic
v0.20.2
Published
Malevič.js - minimalistic reactive UI library
Downloads
36,504
Maintainers
Readme
Malevič.js
Minimalistic reactive UI library. As simple as possible. Extendable. 5KB gzipped (7KB with animations).
Suitable for building framework-independent dynamic widgets as well as small web apps. Create, manage state, animate!
Samples
Basic example
m()
function creates a DOM node specification that looks like{type, props, children}
.render()
function renders nodes inside a DOM element. If differences with existing DOM nodes are found, necessary nodes or attributes are replaced.
import {m} from 'malevic';
import {render} from 'malevic/dom';
render(document.body,
m('h3', {class: 'heading'},
'Hello, World!'
)
);
Shorthand tag functions can be used:
import {render, tags} from 'malevic/dom';
const {h3, strong} = tags;
render(document.body,
h3({class: 'heading'},
'Hello, ',
strong('World!')
)
);
or
import {render, tag} from 'malevic/dom';
const anchor = tag('a');
render(document.body,
anchor({href: '/'},
'Click Me',
)
);
When DOM node already exists, a sync()
function can be used:
import {sync, tags} from 'malevic/dom';
const {body, h1} = tags;
sync(document.body,
body({class: 'app'},
h1(
'Hello, World!'
)
)
);
Functions can be used as components like this:
import {sync, tags} from 'malevic/dom';
const {body, span, h1, button} = tags;
function Button(props, ...children) {
return button({onclick: props.handler},
span({class: 'icon'}),
...children
);
}
sync(document.body,
body({class: 'app'},
h1('App'),
Button({handler: (e) => alert(e.target)},
'Click me'
)
)
);
For more complex components, that need to store some state or access the DOM nodes,
the component()
function should be used:
import {component, tags} from 'malevic/dom';
const {button, img, span} = tags;
const Button = component((context, props, ...children) => {
const store = context.getStore({clicks: 0});
const onClick = () => {
store.clicks = store.clicks + 1;
context.refresh();
};
return button({onclick: onClick},
img({src: props.icon}),
span('Clicks' + store.clicks),
...children
);
});
See the description for the context
object below.
JSX
m
pragma should be used to make it work with JSX:
- Babel:
{
"plugins": [
["transform-react-jsx", {
"pragma": "m"
}]
]
}
- TypeScript:
{
"compilerOptions": {
"jsx": "react",
"jsxFactory": "m"
}
}
A component written in JSX will look like:
import {m} from 'malevic';
import {sync} from 'malevic/dom';
function Button({handler}, ...children) {
return (
<button onclick={handler}>
{...children}
</button>
);
}
sync(document.body, (
<body class="app">
<h1>App</h1>
<Button handler={(e) => alert(e.target)}>
Click me
</Button>
</body>
));
m
is a factory function for creating a spec tree from JSX, so import {m} from 'malevic';
should be included in every JSX or TSX file.
Listening to events
If attribute starts with on
,
a corresponding event listener is added to a DOM element
(or removed if value is null
).
<button onclick={(e) => alert(e.target)} />
Manipulating class list and styles
- Possible class attribute values:
<div
class="view active"
class={['view', props.isActive ? 'active' : null]}
class={{'view': true, 'active': props.isActive}}
/>
- Possible style attribute values:
<div
style="background: red !important; opacity: 0;"
style={{'background': 'red !important', 'opacity': 0}}
/>
Lifecycle management
oncreate
handler will be invoked after DOM node is created and appended to parent.onupdate
handler will be invoked after all attributes of existing DOM node were synchronized.onrender
handler will be invoked after DOM node is created or updated.onremove
handler will be invoked after DOM node was removed.
function Heading() {
return (
<h4
oncreate={(domNode) => {
domNode.classList.add('rendered');
domNode.textContent = 'Hello';
}}
></h4>
);
}
render(document.body, <Heading/>);
It is possible to assign lifecycle handlers for components as well:
import {component, tags} from 'malevic/dom';
const {div} = tags;
const Component = component((context) => {
context.onCreate((domNode) => domNode.classList.add('init'));
context.onRemove((domNode) => domNode.parentNode == null);
context.onUpdate((domNode) => domNode === context.node);
context.onRender((domNode) => domNode.dataset.renderCount = ++renderCount);
return div('Hello');
}
With JSX it will look like:
import {getContext} from 'malevic/dom';
function Component() {
const context = getContext();
context.onCreate((domNode) => domNode.classList.add('init'));
context.onRemove((domNode) => domNode.parentNode == null);
context.onUpdate((domNode) => domNode === context.node);
context.onRender((domNode) => domNode.dataset.renderCount = ++renderCount);
return <div>Hello</div>;
}
Optimizing component re-renders
When virtual DOM checks should be skipped, context.leave()
function could be used.
function Component(props) {
const {prev} = getContext();
if (prev && prev.props.value === props.value) {
return context.leave();
}
return <Nested value={props.value} />;
}
Here context.prev
property returns a previous component specification.
Virtual nodes matching
By default virtual children are matched by a component type or an element tag name.
key
property should be used to prevent detaching virtual nodes when children order changes:
render(target, (
<List>
<Item key={0} />
<Item key={1} />
<Item key={2} />
</List>
));
// Attached items: 0, 1, 2
render(target, (
<List>
<Item key={3} />
<Item key={2} />
<Item key={1} />
</List>
));
// Attached items: 3
// Detached items: 0
// Updated items: 1, 2
Any value can be used for key
, matching is done by a strict ===
comparison.
Getting DOM node before rendering
It is possible to get a parent DOM node before updating the DOM tree.
import {m} from 'malevic';
import {render, getContext} from 'malevic/dom';
function App() {
const {parent} = getContext();
const rect = parent.getBoundingClientRect();
return (<Array>
<header></header>
<main>
<h3>Size</h3>
<p>{`Width: ${rect.width}`}</p>
<p>{`Height: ${rect.height}`}</p>
</main>
<footer></footer>
</Array>);
}
render(document.body, <App/>);
After a component is rendered,
context.node
property will return an attached DOM node.
If a component creates multiple DOM nodes, context.nodes
property will return all of them:
function Many({items}) {
const {node, nodes} = getContext();
node; // header
nodes; // [header, ..., footer]
return (
<Array>
<header/>
{...items.map((item) => <span>{item}</span>)}
<footer/>
</Array>
);
}
When there is a need of getting a descending DOM node before rendering it's child, then inline functions could be used:
render(document.body, (
<h1>Body size</h1>
<div class="wrapper">
{({parent, node}) => {
const rect = parent.getBoundingClientRect();
return <Array>
<p>Width: {rect.width}px</p>
<p>Height: {rect.height}px</p>
</Array>;
}}
</div>
));
Using DOM node as a child
Yes. You can just create a DOM node and it will be later injected into the DOM tree:
function Component({class: className}) {
let {node} = getContext();
if (!node) {
node = document.createElement('div');
}
node.className = className;
return node;
}
sync(document.body, (
<body>
<Component class="native" />
</body>
));
Other possible spec child types are:
- An object where
type
is a string (will create a DOM element). - An object where
type
is a function (will invoke a component). - An array or an object with
type
equal to Array constructor. - A string (will create a text node).
- An inline function that returns a spec, like
({parent, node}) => <div />
. null
(will leave a placeholder for future nodes).
Animation plug-in
There is a built-in animation plug-in, which makes it possible to schedule attributes' animations.
import {m} from 'malevic';
import {render} from 'malevic/dom';
import {withAnimation, animate} from 'malevic/animation';
const Chart = withAnimation(({width, height}) => (
<svg width={width} height={height}>
<circle
r={5}
fill="red"
cx={animate(90, {duration: 1000})}
cy={animate(10, {duration: 1000})}
/>
<path
fill="none"
stroke="blue"
stroke-width={1}
d={animate('M10,90 Q50,10 90,90')
.initial('M10,10 Q50,90 90,10')}
/>
</svg>
));
render(document.body, (
<Chart width={200} height={150} />
));
It is possible to animate separate style properties:
const Tooltip = withAnimation(({text, color, isVisible, x, y}) => {
return (
<div
class={['tooltip', {'visible': isVisible}]}
style={{
'transform': animate(`translate(${x}px, ${y}px)`),
'background-color': animate(color)
.interpolate(d3.interpolateRgb)
}}
></div>
);
});
A built-in interpolator can interpolate between numbers and strings containing numbers with floating points. For other cases (e.g. colors) please use custom interpolators:
<rect
fill={animate([255, 255, 0], {duration: 2000})
.initial([255, 0, 0])
.interpolate((a, b) => (t) => {
const mix = (x, y) => Math.round(x * (1 - t) + y * t);
const channels = [
mix(a[0], b[0]),
mix(a[1], b[1]),
mix(a[2], b[2])
];
return `rgb(${channels.join(', ')})`;
})}
/>
initial()
method set's the initial value to a newly attached element,
from which it will start animating.
If the initial value was not provided,
the animation will start from the last value
or the final value will be used statically.
It is possible to add multiple keyframes:
<polyline
points={animate()
.from([[0, 0], [10, 10]])
.to([[20, 20], [40, 40]], {duration: 100, easing: 'linear'})
.to([[50, 50], [40, 40]], {delay: 100, duration: 100})
.to([[10, 10], [10, 10]], {easing: (t) => t * t})
.output((points) => points.map(([x, y]) => `${x}, ${y}`).join(' '))}
/>
Sometimes it is easier to manipulate raw values rather than strings.
output()
method could be used to convert data into an attribute or a CSS value.
State plug-in
State plug-in lets re-render a subtree in response for an interaction:
import {m} from 'malevic';
import {withState, useState} from 'malevic/state';
export const Stateful = withState(({items}) => {
const {state, setState} = useState({isExpanded: false});
return (
<div>
<button onclick={() => setState({isExpanded: !state.isExpanded})}>
Expand
</button>
<ul class={{'expanded': state.isExpanded}}>
{items.map((text) => <li>{text}</li>)}
</ul>
</div>
);
});
An initial state should be passed to useState
function.
setState
should not be called inside a component,
only in event handlers or async callbacks.
State plug-in is a shorthand for manipulating context.store
property and context.refresh()
method.
context.store
is an object that is transferred between matched virtual nodes.
Any values can be stored there and used when the next component unboxing happens.
context.getStore(defaults)
returns a store
and assigns it's default values.
context.refresh()
function refreshes a part of the virtual DOM.
It should not be called during the component's unboxing.
import {m} from 'malevic';
import {getContext} from 'malevic/dom';
function Stateful(({items}) {
const context = getContext();
const store = context.getStore({isExpanded: false});
return (
<div>
<button onclick={() => {
store.isExpanded = !store.isExpanded;
context.refresh();
}}>
Expand
</button>
<ul class={{'expanded': store.isExpanded}}>
{items.map((text) => <li>{text}</li>)}
</ul>
</div>
);
});
Forms plug-in
Forms plug-in makes form elements work in a reactive manner:
import {m} from 'malevic';
import {withForms} from 'malevic/forms';
const Form = withForms(({checked, text, num, onCheckChange, onTextChange, onNumChange}) => {
return (
<form onsubmit={(e) => e.preventDefault()}>
<input
type="checkbox"
checked={checked}
onchange={(e) => onCheckChange(e.target.checked)}
/>
<input
type="number"
value={num}
readonly={!checked}
onchange={(e) => {
if (!isNaN(e.target.valueAsNumber)) {
onNumChange(e.target.valueAsNumber);
}
}}
onkeypress={(e) => {
if (e.code === 'Enter' && !isNaN(e.target.valueAsNumber)) {
onNumChange(e.target.valueAsNumber);
}
}}
/>
<textarea oninput={(e) => onTextChange(e.target.value)}>
{text}
</textarea>
</form>
);
});
Server-side rendering
Malevič.js can simply render inside existing HTML without unnecessary DOM tree modifications.
import {m} from 'malevic';
import {stringify} from 'malevic/string';
import {createServer} from 'http';
import App from './app';
createServer((request, response) => response.end(`<!DOCTYPE html>
<html>
<head></head>
${stringify(
<body>
<App state={{}} />
</body>
, {indent: ' '})}
</html>`));
Sometimes a component is tied to DOM and cannot be converted to a string properly.
isStringifying
function comes for rescue:
import {m} from 'malevic';
import {getContext} from 'malevic/dom';
import {isStringifying} from 'malevic/string';
function Component() {
if (isStringifying()) {
return <div class="target" />;
}
const {parent} = getContext();
const rect = parent.getBoundingClientRect();
return <div
class="target"
style={{width: `${rect.width}px`}}
/>
}
Canvas API
There is API for declaring hierarchical structures and drawing them on HTML5 Canvas.
import {m} from 'malevic';
import {draw, getContext} from 'malevic/canvas';
import {render} from 'malevic/dom';
function Rect({width, height, fill}) {
const context = getContext();
context.fillStyle = fill;
context.fillRect(0, 0, width, height);
}
function Transform({translate: {x, y}}, ...children) {
const context = getContext();
// Get previous transform value
const prevTransform = context.getTransform();
// Set new transform value
context.translate(x, y);
return [
// Return children to draw
children,
// Restore transform value
() => context.setTransform(prevTransform),
];
}
const canvas = render(
document.body,
<canvas width={1024} height={768} />
).firstElementChild;
draw(
canvas.getContext('2d'),
<Transform translate={{x: 32, y: 32}}>
<Rect width={256} height={256} fill="black" />
</Transform>
);
getContext()
function returns the current rendering context.
Note that it is possible to use components' specifications,
as well as arrays and (context) => ...
functions as child nodes.
Custom plug-ins
There is API for adding custom logic and making things more complex.
Plugins.add(Component, plugin)
method extends plugins list.- If plugin returns
null
orundefined
the next plugin (added earlier) will be used.
Extendable plug-ins:
dom.createElement
creates a DOM element.dom.setAttribute
sets a DOM element's attribute.string.isVoidTag
determines if HTML tag is void (empty) and cannot have a closing tag.string.skipAttribute
determines whether the attribute should be skipped.string.stringifyAttribute
converts the attribute value to a string. To prevent XSS attacks always useescapeHTML
function.
import {plugins, sync} from 'malevic/dom';
const Component = () => <div/>;
const map = new WeakMap();
plugins.setAttribute
.add(Component, ({element, attr, value, prev}) => {
if (attr === 'data' && value !== prev) {
map.set(element, value);
return true;
}
return null;
});
const div = sync(
document.createElement('div'),
<Component data={5} />
);
map.get(div) === 5;
Breaking changes since version 0.18
- Element lifecycle methods were renamed to
oncreate
,onupdate
andonremove
.onrender
method was added (called when element was created or updated). - Component lifecycle methods were renamed to
onCreate
,onUpdate
andonRemove
.onRender
method was added (called when component was created or updated).
Breaking changes since version 0.12
Everything was broken up:
- Built-in ability to read previous props and store state.
- Parent and target DOM nodes can be retrieved using
getContext()
function. - Lifecycle methods were renamed from
didmount
,didupdate
andwillunmount
toattached
,updated
anddetached
(called after DOM node removal). - Components can return arrays.
native
attribute was removed, just use a DOM node as a child.- Added ability to leave a component without changes.
- Limited plug-ins scope.
- Animation
.duration()
and.easing()
methods were deleted, values should be passed toanimate(value, {duration, easing, delay})
function or.to(value, {duration, easing, delay})
method. - Multiple animation keyframes could be added.