It's almost 2024, major frameworks have abandoned compatibility with IE11. If we don't consider IE11, we can fully utilize the latest features of modern browsers. So, what should the front-end framework look like?
After OMI joined Signal, the current API design has become the ideal frontend framework in my mind. The OmiElements, based on Tailwind CSS atomic CSS, represent the perfect form of a frontend element library. In the future, we will introduce the component library built on top of the element library, as well as the OMI low-code platform based on the component library. Please look forward to these developments. Additionally, we have completely upgraded and redesigned the main site and its various sub-sites.
- Home: http://omijs.org/
- Github: https://github.com/Tencent/omi
Now let's take a glimpse at the design ideas and some trade-offs of OMI from these keywords:
- Signal
- Proxy
- Web Components
- JSX
- Tailwind CSS
- Constructable Stylesheets
- OMI Router
- OMI Suspense
- OOP & DOP
- OMI SPA
- OMI Low-Code
OMI Elements and OMI Tutorial are built using most of the capabilities from the above technologies:
Two projects can be found at https://github.com/Tencent/omi.
Signal-driven reactive programming
In Liu Cixin's science fiction novel "The Three-Body Problem," after receiving a signal from the Trisolaran world, Ye Wenjie also received another warning signal.
Don't answer!
Ye Wenjie received a signal, and the value of the signal was "Don't answer! " A signal is a signal, and the value of the signal is represented by signal.value. It's important to understand that the signal itself is not equal to its value; rather, the value of the signal is equal to signal.value. Understanding this concept is crucial to grasping the core idea of a signal. Here's an example to illustrate this:
import { render, signal, tag, Component, h } from 'omi'
const count = signal(0)
function add() {
count.value++
}
function sub() {
count.value--
}
@tag('counter-demo')
class CounterDemo extends Component {
render() {
return (
<>
<button onClick={sub}>-</button>
<span>{count.value}</span>
<button onClick={add}>+</button>
</>
)
}
}
render(<counter-demo />, document.body)
In the above code, count is a signal. In the render method, the signal value is read as count.value. At this point, the signal is collected as a dependency for the current component. When the signal value changes, the components that depend on this signal will be automatically updated, achieving reactivity. To emphasize once again, when the value of the signal is read (i.e., .value), the component reading the signal will be collected as a dependency. In the future, when the signal value changes, the components dependent on the signal will be automatically updated.
How is it achieved? Below is an introduction to the implementation principle of OMI Signal.
Proxy: Powerful Proxy API in JavaScript
In JavaScript, Proxy provides a powerful metaprogramming capability that allows developers to intercept and handle access and modification of object properties. The introduction of Proxy greatly expands the possibilities of JavaScript programming, providing developers with more flexibility and control.
The essence of Proxy is an object that accepts two parameters: the target object and the handler object. The target object is the object to be proxied, while the handler object contains a series of methods for intercepting and handling operations on the target object.
const target = { foo: 'bar' };
const proxy = new Proxy(target, {
get(target, property) {
console.log(`Getting ${property}`);
return target[property];
}
};);
console.log(proxy.foo); // output: "Getting foo" 和 "bar"
In this example, we created a simple Proxy object that logs a message when accessing the properties of the target object. When we access the foo property through the proxy object, the get method of the handler object is triggered, logging the message and returning the property value.
Proxy supports various interception methods that can intercept and handle various operations on the target object. Here are some commonly used interception methods:
- get(target, property, receiver): Triggered when accessing a property of the proxy object.
- set(target, property, value, receiver): Triggered when setting a property value of the proxy object.
- has(target, property): Triggered when using the in operator to check for a property in the proxy object.
- deleteProperty(target, property): Triggered when deleting a property of the proxy object.
These interception methods can be freely combined to implement complex logic control as needed. It's important to note that not all interception methods need to be implemented, and unimplemented interception methods will directly access or operate on the target object.
Proxy has many practical use cases in real-world development, with data binding and reactive updates being the most common ones. The Signal feature in the OMI framework is based on Proxy: by intercepting access to object properties (collecting dependencies) and modification operations (updating dependencies), it achieves synchronization between data and views. This is not a new technology, as MobX has long been using observable states, computed values, dependency tracking, reactive updates, and actions to help manage and update application states more easily. As a standalone state management library, OMI directly integrates these capabilities out of the box.
Web Components
Web Components is a set of browser-native technologies that enable the creation of reusable and well-encapsulated custom HTML elements. It has revolutionized frontend development, allowing developers to efficiently build complex web applications. Some examples of big companies using Web Components include:
- Adobe Photoshop Online, which is entirely built using Web Components.
- Microsoft's new web version of the Windows Store utilizes web components.
- Twitter embedded tweets, YouTube, Electronic Arts, Adobe Spectrum, and others are also built using Web Components.
Web Components consist of three main technologies:
- Custom Elements: It allows developers to create custom HTML elements and define their behavior.
- Shadow DOM: It provides encapsulated DOM structures for custom elements, isolating them from the main document to avoid style and script conflicts.
- HTML Templates: It offers a way to create HTML templates that can be cloned and populated at runtime, improving rendering performance.
For example, here's how you can implement a custom element without using any framework:
class MyElement extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: 'open' });
const div = document.createElement('div');
div.textContent = 'Hello, Shadow DOM!';
shadowRoot.appendChild(div);
}
}
customElements.define('my-element', MyElement);
In this example, we create a custom element called my-element and create a Shadow DOM inside it. When the browser encounters the <my-element>
tag, it automatically creates an instance of MyElement and attaches it to the document. <my-element>
is framework-agnostic, meaning any framework can use this element.
OMI framework utilizes two features mentioned above: Custom Elements and Shadow DOM. HTML Templates are replaced by a more programmer-friendly syntax called JSX to achieve better programming experience. For example, in the above example, OMI implements it as follows:
import { tag, Component, h } from 'omi'
@tag('my-element')
class MyElement extends Component {
render() {
return <div>Hello, Shadow DOM!</div>
}
}
JSX programming experience is better and shorter. Template strings are enclosed in backticks within tag functions, and interpolation requires the use of $ symbols.
For the foreseeable future, we won't be using HTML Templates or tag functions with real DOM. Instead, we'll be using JSX + virtual DOM syntax because it provides a better programming experience. I can't guarantee whether tag functions with real DOM will be supported in the future alongside JSX. However, what I can say is that unless there are obvious performance bottlenecks, we will continue to use JSX/TSX + virtual DOM for a long time.
Tailwind CSS
Naming variables is indeed a time-consuming task in programming, especially when it comes to assigning precise class names to HTML tags. Tailwind CSS serves as a solution by eliminating the need to worry about naming conventions, CSS conflicts, file size inflation, or the rapid decay of large style files. Its benefits far outweigh its drawbacks, as once you embrace the concept of atomic CSS, there's no turning back.
Tailwind CSS is a utility-first CSS framework designed for building user interfaces. It aims to assist developers in rapidly creating responsive designs by providing a set of composable preset classes. Following a mobile-first design approach, Tailwind CSS allows for easy creation of responsive designs for different devices and screen sizes. When building for production, Tailwind CSS automatically removes unused CSS, resulting in smaller final file sizes.
A couple of years ago, I asked the OMI team if it was feasible to build a component library using atomic CSS, and initially, the consensus was that it wasn't. However, that perception changed when we came across the tw-elements project, which proved that it was indeed possible.
In the context of using Tailwind CSS with OMI WebComponents:
import { tag, Component } from 'omi'
import { tailwind } from '@/tailwind'
@tag('my-element')
export class MyElement extends Component {
static css = [tailwind]
render() {
return (
<figure class="md:flex bg-slate-100 rounded-xl p-8 md:p-0 dark:bg-slate-800">
<img class="w-24 h-24 md:w-48 md:h-auto md:rounded-none rounded-full mx-auto" src="/sarah-dayan.jpg" alt="" width="384" height="512" />
<div class="pt-6 md:p-8 text-center md:text-left space-y-4">
<blockquote>
<p class="text-lg font-medium">
“Tailwind CSS is the only framework that I've seen scale
on large teams. It’s easy to customize, adapts to any design,
and the build size is tiny.”
</p>
</blockquote>
<figcaption class="font-medium">
<div class="text-sky-500 dark:text-sky-400">
Sarah Dayan
</div>
<div class="text-slate-700 dark:text-slate-500">
Staff Engineer, Algolia
</div>
</figcaption>
</div>
</figure>
)
}
}
When each component carries Tailwind CSS, one might wonder if it results in significant memory overhead. This is where constructable stylesheets come into play.
Constructable Stylesheets
Constructable stylesheets are the perfect companion for Web Components. They provide a way to create and distribute reusable styles when using Shadow DOM, reducing size and improving performance.
Before diving into constructable stylesheets, let's first explore the traditional ways of applying styles in web development.
Inline Styles
Inline styles involve directly writing styles within the style attribute of HTML elements. This approach is simple and straightforward, but the styles cannot be reused and can be difficult to manage.
<style>
Tag: The <style
tag is used within the <head>
of an HTML document to write styles. This method allows styles to be applied to the entire document, but the styles still cannot be reused, and if there is a large amount of style code, it may affect the readability of the HTML document.
External Stylesheets
External stylesheets are referenced using the <link rel="stylesheet" href="...">
tag to import an external CSS file. This approach allows for style reuse and separates the style code from the HTML code, making it easier to manage. However, each external stylesheet requires an HTTP request, and if there are multiple stylesheets, it may impact the page loading performance.
These approaches generally work well in most cases, but in certain scenarios, they may encounter some issues. For example, when using Web Components, it is common to apply styles within the Shadow DOM of each component. Since the Shadow DOM is isolated, we cannot directly use external stylesheets or <style>
tags, and instead, we must write styles separately within each Shadow DOM. This not only makes styles difficult to reuse but also leads to a large amount of duplicated style code being loaded, which can impact performance.
To address these issues, the Constructable Stylesheets API provides a new way to create, store, and apply styles. This API consists of the following main parts:
CSSStyleSheet class: This class represents a stylesheet. We can create a new stylesheet using new CSSStyleSheet(), and then set the content of the stylesheet using sheet.replace(text) or sheet.replaceSync(text), where text is a string containing CSS code.
adoptedStyleSheets property: This property exists on the Document and ShadowRoot objects. We can apply stylesheets using document.adoptedStyleSheets = [sheet1, sheet2, ...] or shadowRoot.adoptedStyleSheets = [sheet1, sheet2, ...], where sheet1, sheet2, ... are CSSStyleSheet objects.
With Constructable Stylesheets, we can create and manage stylesheets in JavaScript and dynamically apply them where needed. This allows for style reuse and only requires loading the style code once, improving performance.
For example, we can create a stylesheet and apply it to multiple Shadow DOMs. Here's an example of using Constructable Stylesheets without the native support of the OMI framework.
const sheet = new CSSStyleSheet();
sheet.replaceSync('p { color: red; }');
customElements.define('my-element', class extends HTMLElement {
constructor() {
super();
this.attachShadow({mode: 'open'});
}
connectedCallback() {
this.shadowRoot.adoptedStyleSheets = [sheet];
this.shadowRoot.innerHTML = '<p>Hello, world!</p>';
}
});
In this example, we create a stylesheet sheet and apply it to the Shadow DOM of the my-element component. Regardless of how many my-element components we create, the code of the stylesheet only needs to be loaded once.
SPA & OMI Router & OMI Suspense
SPA (Single Page Application) is a web application development pattern characterized by dynamically updating and rendering content on a single HTML page using JavaScript, without the need to reload the entire page. Its advantages include improved user experience, faster response times, reduced network traffic, separation of front-end and back-end, offline support, and ease of debugging and maintenance.
React Router defines routes using the following approach, where each path corresponds to an element and supports nested routes.
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="*" element={<NoMatch />} />
</Route>
</Routes>
OMI Router uses a flat routing design to build SPAs (Single Page Applications). It combines OMI Suspense with the automatic upgrade feature of Web Components, supported natively by browsers, to progressively display the content of web areas.
export const routes = [{
path: '/user/:id/profile',
render(router: Router) {
return (
<o-suspense
imports={[
import('./components/user-info'),
]}
>
<div slot="pending">Loading user...</div>
<div slot="fallback">Failed to load user</div>
<user-info>
<o-suspense
imports={[
import('./components/user-profile'),
]}
data={async () => {
return await fetchUserProfile(router?.params.id as string)
}}
onDataLoaded={(event: CustomEvent) => {
userProfile.value = event.detail
}}
>
<div slot="pending">Loading user profile...</div>
<div slot="fallback">Failed to load user profile</div>
<user-profile></user-profile>
</o-suspense>
</user-info>
</o-suspense>
)
}
}
]
Suspense is a mechanism used to handle asynchronous loading of components. By using Suspense, developers can provide a "placeholder" for asynchronously loaded components, which is displayed to the user until the component and its dependent data are loaded. This prevents users from seeing a blank page while waiting for the component to load, thus improving the user experience.
In OMI Router, each path corresponds to a specific interface, making it easy to understand and manage. Although the routing is flat, nested components are used in the render function of each route. This is a common pattern that allows code reuse and represents hierarchical relationships while keeping the routing flat. However, when there are repeated headers and sidebars on pages, some code duplication may occur. In the future, we can consider supporting the declaration of children to enable nested forms. But it would still be syntactic sugar for the flat structure, and the final result would be the same.
OOP & DOP
Here, let's use the example of TodoApp to explain the Signal class and signal response, which are two ways of reactive programming in OMI.
TodoApp using reactive functions
Data-driven programming
In data-driven programming, the focus is on the data itself and the operations performed on the data, rather than the object or data structure where the data resides. This programming paradigm emphasizes the changes and flow of data, as well as how to respond to these changes. TodoApp based on reactive functions is a good example of this. It utilizes the concept of reactive programming, where the UI automatically updates to reflect changes in the data (i.e., the to-do list) when they occur.
import { render, signal, computed, tag, Component, h } from 'omi'
const todos = signal([
{ text: 'Learn OMI', completed: true },
{ text: 'Learn Web Components', completed: false },
{ text: 'Learn JSX', completed: false },
{ text: 'Learn Signal', completed: false }
])
const completedCount = computed(() => {
return todos.value.filter(todo => todo.completed).length
})
const newItem = signal('')
function addTodo() {
// api a,不会重新创建数组
todos.value.push({ text: newItem.value, completed: false })
todos.update() // 非值类型的数据更新需要手动调用 update 方法
// api b, 和上面的 api a 效果一样,但是会创建新的数组
// todos.value = [...todos.value, { text: newItem.value, completed: false }]
newItem.value = '' // 值类型的数据更新需会自动 update
}
function removeTodo(index: number) {
todos.value.splice(index, 1)
todos.update() // 非值类型的数据更新需要手动调用 update 方法
}
@tag('todo-list')
class TodoList extends Component {
onInput = (event: Event) => {
const target = event.target as HTMLInputElement
newItem.value = target.value
}
render() {
return (
<>
<input type="text" value={newItem.value} onInput={this.onInput} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.value.map((todo, index) => {
return (
<li>
<label>
<input
type="checkbox"
checked={todo.completed}
onInput={() => {
todo.completed = !todo.completed
todos.update()
}}
/>
{todo.completed ? <s>{todo.text}</s> : todo.text}
</label>
{' '}
<button onClick={() => removeTodo(index)}>❌</button>
</li>
)
})}
</ul>
<p>Completed count: {completedCount.value}</p>
</>
)
}
}
render(<todo-list />, document.body)
TodoApp using the Signal class
Object-oriented programming
In object-oriented programming, the focus is on objects, which encapsulate both data and the methods to operate on that data. This programming paradigm emphasizes the interaction and collaboration between objects, as well as how to organize and manage code through encapsulation, inheritance, and polymorphism. TodoApp based on reactive functions can also be implemented using an object-oriented approach. For example, we can create a TodoList class that contains the data for the to-do list and the methods to manipulate that data, along with an update method to update the UI.
import { render, Signal, tag, Component, h, computed } from 'omi'
type Todo = { text: string, completed: boolean }
class TodoApp extends Signal<{ todos: Todo[], filter: string, newItem: string }> {
completedCount: ReturnType<typeof computed>
constructor(todos: Todo[] = []) {
super({ todos, filter: 'all', newItem: '' })
this.completedCount = computed(() => this.value.todos.filter(todo => todo.completed).length)
}
addTodo = () => {
// api a
this.value.todos.push({ text: this.value.newItem, completed: false })
this.value.newItem = ''
this.update()
// api b, same as api a
// this.update((value) => {
// value.todos.push({ text: value.newItem, completed: false })
// value.newItem = ''
// })
}
toggleTodo = (index: number) => {
const todo = this.value.todos[index]
todo.completed = !todo.completed
this.update()
}
removeTodo = (index: number) => {
this.value.todos.splice(index, 1)
this.update()
}
}
const todoApp = new TodoApp([
{ text: 'Learn OMI', completed: true },
{ text: 'Learn Web Components', completed: false },
{ text: 'Learn JSX', completed: false },
{ text: 'Learn Signal', completed: false }
])
@tag('todo-list')
class TodoList extends Component {
onInput = (event: Event) => {
const target = event.target as HTMLInputElement
todoApp.value.newItem = target.value
}
render() {
const { todos } = todoApp.value
const { completedCount, toggleTodo, addTodo, removeTodo } = todoApp
return (
<>
<input type="text" value={todoApp.value.newItem} onInput={this.onInput} />
<button onClick={addTodo}>Add</button>
<ul>
{todos.map((todo, index) => {
return (
<li>
<label>
<input
type="checkbox"
checked={todo.completed}
onInput={() => toggleTodo(index)}
/>
{todo.completed ? <s>{todo.text}</s> : todo.text}
</label>
{' '}
<button onClick={() => removeTodo(index)}>❌</button>
</li>
)
})}
</ul>
<p>Completed count: {completedCount.value}</p>
</>
)
}
}
render(<todo-list />, document.body)
Here, we are not discussing the merits or drawbacks of either approach (DOP or OOP). In OMI, you have the freedom to choose either approach or even combine them using a layered architecture.
We advocate for developing front-end applications using a layered approach. The reason for using a layered architecture is simple: let UI be UI and non-UI be non-UI. Front-end frameworks can be a double-edged sword. While they enable rapid UI development, they can also lead to a chaotic mix of non-UI logic that should be carefully analyzed and designed using object-oriented principles. Forcing MVVM/MVC/MVP layers within the UI layer is a mistake. We aim to leverage the advantages of OMI's data-driven reactive views and its ability to quickly build UIs while minimizing the inclusion of all logic within the UI layer.
Additionally, we have developed the same Snake game using both a two-layer and three-layer architecture, demonstrating that OOP and DOP can coexist and be used together.
Source Code can be found here: http://omijs.org/ 或 https://github.com/Tencent/omi
Our suggestion is as follows:
- Divide the front-end project into different layers, such as UI layer, middleware layer, and Model layer, each responsible for different functionalities, reducing coupling and separating concerns.
- UI layer: Responsible for page layout, styling, animations, and visual presentation. Avoid handling business logic and data processing in the UI layer.
- Middleware layer: Responsible for converting UI layer inputs into the format required by the Model layer and converting Model layer outputs into the format required by the UI layer.
- Model layer: Contains all the actions the software needs to perform, with the UI layer only providing a human-computer interaction interface to send instructions to the Model layer.
By following these suggestions, you can effectively improve the maintainability, scalability, and readability of your front-end projects.
OMI Low-Code
Building on our extensive practical experience, we constantly generate ideas and creativity related to low-code development. These will be reflected in our low-code products in the future. Stay tuned!
Summary
In "Reminiscences of a Stock Operator," there is a concept of the Holy Grail that many investors try to find. From an objective perspective, front-end frameworks also have their own Holy Grail, and we continue to search for it, standing on the shoulders of giants.
This article provides a high-level overview of OMI's new features, related technologies, and official packages, including OMI Signal, Web Components, TailwindCSS, OMI Router, OMI Suspense, Proxy, Constructable Stylesheets, OOP & DOP, JSX, SPA, etc. The aim is to help readers better understand and apply the OMI framework, improve the efficiency and quality of web development, and embrace the trends.For more details, please visit http://omijs.org/.
Top comments (0)