2023 年了,各大框架纷纷放弃兼容 IE11,如果不考虑 IE11 的情况,便可充分利用浏览器当前全新的特性,那么前端框架该设计成什么样子?
OMI 加入 Signal 之后,现在的 API 设计是我心中最理想的前端框架该有的样子,基于 Tailwind CSS 原子CSS 的 OmiElements 也是我心中理想的前端元素 库的形态。基于元素 库之上的组件库 ,和基于组件库 的OMI 低代码平台未来也会跟大家见面,敬请期待。我们也对主站和各个子站点进行了全新升级改版。
- 全新站点 omijs.org/
- Github仓库 github.com/Tencent/omi
下面从这些关键字一窥 OMI 的设计思路和一些选择的取舍:
- Signal
- Proxy
- Web Components
- JSX
- Tailwind CSS
- Constructable Stylesheets
- OMI Router
- OMI Suspense
- OOP & DOP
- OMI SPA
- OMI 低代码
其中 OMI Elements 和 OMI Tutorial 使用上面这些技术里的大部分能力搭建而成:
两个项目可以在 github.com/Tencent/omi 找到。
信号 Signal 驱动的响应式编程
在刘慈欣的科幻小说《三体》中,叶文洁在收到来自三体世界的信号后,又收到了另一个警告信号。
不要回答!不要回答!不要回答!
叶文洁收到的是信号 ,信号值是不要回答!不要回答!不要回答!
。信号是信号,信号值是信号值,信号
不等于 信号值
,信号值
等于 信号.value
。理解了这个概念,就理解了信号的核心思想。举例说明:
tsx
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)
上面代码中count
是信号,在render
方法中读取信号值 count.value
,这个时候信号会被收集到当前组件作为信号的依赖,当信号值发生变化时,会自动触发依赖该信号值的组件更新,从而实现了响应式。再强调一次,读取信号的值(也就是.value
),读取信号的组件就会被信号收集为依赖,以后信号值变化,信号依赖的组件就会自动更新。
怎么做到的呢?下面介绍一下 OMI Signal 的实现原理。
Proxy:JavaScript 中的强大代理 API
在 JavaScript 中,Proxy 提供了非常强大的元编程能力,它允许开发者在访问、修改对象属性时进行拦截和处理。Proxy 的出现极大地拓展了 JavaScript 编程的可能性,为开发者提供了更多的灵活性和控制力。
Proxy的本质是一个对象,它接受两个参数:目标对象(target)和处理器对象(handler)。目标对象是需要被代理的对象,而处理器对象则包含了一系列用于拦截和处理目标对象操作的方法。
javascript
const target = { foo: 'bar' };
const proxy = new Proxy(target, {
get(target, property) {
console.log(`Getting ${property}`);
return target[property];
}
};);
console.log(proxy.foo); // 输出 "Getting foo" 和 "bar"
在这个例子中,我们创建了一个简单的Proxy对象,它在访问目标对象的属性时会输出一条日志。当我们通过代理对象访问foo
属性时,处理器对象的get
方法会被触发,输出日志并返回属性值。
Proxy支持多种拦截方法,这些方法可以拦截并处理目标对象的各种操作。以下是一些常用的拦截方法:
get(target, property, receiver)
: 当访问代理对象的属性时触发。set(target, property, value, receiver)
: 当设置代理对象的属性值时触发。has(target, property)
: 当使用in
操作符检查代理对象的属性时触发。deleteProperty(target, property)
: 当删除代理对象的属性时触发。
这些拦截方法可以根据需要自由组合,实现各种复杂的逻辑控制。需要注意的是,不是所有的拦截方法都需要实现,未实现的拦截方法将直接访问或操作目标对象。
Proxy 在实际开发中有很多应用场景,数据绑定和响应式更新是最常见的,OMI 框架的信号 Signal 就是基于 Proxy 实现:通过拦截对象属性的访问(收集依赖)和修改操作(更新依赖),实现数据与视图的同步更新。这不是什么新鲜的技术,mobx 很早就使用可观察状态、计算值、依赖跟踪、响应式更新和 action,帮助你更轻松地管理和更新应用状态,它作为独立的状态管理库,OMI 直接内置集成了这些能力,开箱即用。
Web Components
Web Components是一组浏览器原生支持的技术,用于实现可重用、封装良好的自定义 HTML 元素。它为前端开发带来了组件化的革命,使得开发者可以更加高效地构建复杂的 Web 应用。 一些大厂的案例有:
- Adobe Photoshop online 完全是基于 Web Components 进行搭建
- 微软新的 web 版本 windows 商店使用 webcomponents
- 还有 Twitter 嵌入式推文、YouTube、Electronic Arts、Adobe Spectrum等都基于 Web Components 搭建
Web Components包括以下三个主要技术:
- Custom Elements:允许开发者创建自定义的HTML元素,并定义它们的行为。
- Shadow DOM:为自定义元素提供封装的DOM结构,使其与主文档隔离,避免样式和脚本的冲突。
- HTML Templates:提供一种创建HTML模板的方法,这些模板可以在运行时被克隆和填充,提高渲染性能。
比如不使用任何框架实现一个自定义元素:
tsx
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);
在这个例子中,我们创建了一个自定义元素my-element
,并在其内部创建了一个 Shadow DOM。当浏览器遇到<my-element>
标签时,会自动创建一个MyElement
实例,并将其附加到文档中。<my-element>
是框架无关的,任何框架都可以使用该元素。
其中 OMI 框架使用了其中两种:Custom Elements 和 Shadow DOM ,而 HTML Templates 则由编程体验更好 JSX 语法来代替来实现。比如上面的例子,OMI 实现:
tsx
import { tag, Component, h } from 'omi'
@tag('my-element')
class MyElement extends Component {
render() {
return <div>Hello, Shadow DOM!</div>
}
}
JSX 编程体验更好,更短,tag function里模板字符串使用反引号包围,插值需要$
包围。
未来很长一段时间,我们不会使用 HTML Templates或者是 tag function + 真实DOM,而是使用 JSX + 虚拟DOM 语法来实现,因为它的编程体验更好。未来会不会支持 tag function + 真实DOM,同时支持两种语法?,这里我也不敢保证。可以确定的是,除非遇到明显的性能瓶颈,未来很长一段时间都会保持 JSX/TSX + 虚拟DOM。
Tailwind CSS
给变量取名是编程任务中费时的事情之一,比如给一个 HTML 标签想一个精准 class 名称,是非常困难的,千人千面,Tailwind CSS 是解药,帮前端消灭了一门语言,不用考虑命名,不用担心 css 互相影响,不用担心体积膨胀,不用担心项目过大样式文件快速腐化,其收益远大于它的损失,一脚踏进了原子化 CSS 的大门,就再也回不去了。
Tailwind CSS 是用于构建用户界面的 utility-first 的 CSS 框架。致力于通过提供一系列可组合的预设 class 来帮助开发人员快速创建响应式设计。Tailwind CSS 遵循移动优先的设计原则,因此你可以轻松地为不同的设备和屏幕尺寸创建响应式设计。Tailwind CSS 在构建生产版本时,会自动删除未使用的 CSS,从而减小最终文件的大小。
在前年的时候,我就问过 OMI 团队小伙伴,使用原子 CSS 构建组件库可行不可行,最后大家觉得不可行。直到大家看到了 tw-elements 这个项目,原来它是可行的。
在 OMI WebComponents 使用中 Tailwind CSS:
tsx
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>
)
}
}
每个组件都携带了 tailwind,那么是不是需要非常大的内存开销?这里就需要提到 constructable stylesheets,可构造的样式表。
Constructable Stylesheets
Constructable Stylesheets 是 Web Components 的黄金搭档。在使用 Shadow DOM 时创建和分布可重复使用的样式的一种方式,既降低了尺寸,还能提高性能。
在介绍 Constructable Stylesheets 之前,我们先来看一下传统的样式应用方式。在传统的 Web 开发中,我们通常会通过以下几种方式来应用样式:
内联样式
直接在 HTML 元素的 style 属性中写样式。这种方式简单直接,但是样式不能复用,而且难以管理。
<style>
标签:在 HTML 文档的 <head>
中使用 <style>
标签来写样式。这种方式可以应用到整个文档,但是样式仍然不能复用,而且如果样式代码较多,可能会影响 HTML 文档的可读性。
外部样式表
通过 <link rel="stylesheet" href="...">
来引入外部的 CSS 文件。这种方式可以复用样式,而且可以将样式代码和 HTML 代码分离,使得代码更易于管理。然而,每个外部样式表都需要一个 HTTP 请求,如果样式表较多,可能会影响页面的加载性能。
以上这些方式在大多数情况下都能工作得很好,但是在一些特殊的场景下,它们可能会遇到一些问题。例如,在使用 Web Components 时,我们通常需要在每个组件的 Shadow DOM 中应用样式。由于 Shadow DOM 是隔离的,我们不能直接使用外部样式表或者 <style>
标签,而必须在每个 Shadow DOM 中单独写样式。这不仅使得样式难以复用,而且如果组件数量较多,可能会导致大量的样式代码被重复加载,从而影响性能。
为了解决这些问题,Constructable Stylesheets API 提供了一种新的方式来创建、存储和应用样式。这个 API 主要包含以下几个部分:
- CSSStyleSheet 类:这个类代表一个样式表。我们可以通过 new CSSStyleSheet() 来创建一个新的样式表,
然后通过 sheet.replace(text) 或者 sheet.replaceSync(text) 来设置样式表的内容。这里的 text 是一个包含 CSS 代码的字符串。
- adoptedStyleSheets 属性:这个属性存在于 Document 和 ShadowRoot 对象上。
我们可以通过 document.adoptedStyleSheets = [sheet1, sheet2, ...] 或者 shadowRoot.adoptedStyleSheets = [sheet1, sheet2, ...] 来应用样式表。这里的 sheet1, sheet2, ... 是 CSSStyleSheet 对象。
使用 Constructable Stylesheets,我们可以在 JavaScript 中创建和管理样式表,然后在需要的地方动态地应用样式表。这样,我们就可以复用样式,而且只需要加载一次样式代码,从而提高性能。
例如,我们可以创建一个样式表,然后在多个 Shadow DOM 中应用这个样式表,这里举一个不使用 OMI 框架原生使用 Constructable Stylesheets 的例子:
tsx
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>';
}
});
在这个例子中,我们创建了一个样式表 sheet,然后在 my-element 组件的 Shadow DOM 中应用了这个样式表。无论我们创建了多少个 my-element 组件,样式表的代码都只需要加载一次。
SPA & OMI Router & OMI Suspense
SPA(Single Page Application,单页应用)是一种 Web 应用程序开发模式,其特点是在单个 HTML 页面上通过 JavaScript 动态更新和渲染内容,而无需重新加载整个页面。优点包括: 用户体验、响应速度、网络流量减少、前后端分离、离线支持、易于调试和维护。
React Router 使用了下面的方式定义路由,每个 path 指定一个 element,可以支持嵌套路由。
tsx
<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 使用了扁平路由设计进行 SPA 搭建,结合 OMI Suspense 和 浏览器原生支持的 Web Components元素自动升级特性,可以逐步显示 Web 区域的内容:
tsx
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 是一种用于处理异步加载组件的机制。通过使用Suspense,开发者可以为异步加载的组件提供一个"占位符",在组件和组件依赖的数据加载完成之前显示给用户。这样一来,用户就不会在等待组件加载时看到空白页面,从而提高用户体验。
一个 path 就对应 一个界面,易于理解和管理。虽然路由是扁平的,但是你在每个路由的 render 函数中使用了嵌套的组件。这是一种常见的模式,可以让你在保持路由扁平的同时,利用组件的嵌套来复用代码和表示层级关系。只是页面有重复的头部和侧边栏的时候需要一些重复代码,后续我们可以考虑支持声明 children 来支持嵌套的形式,但是它也只是扁平结构的语法糖,最后运行的效果是一样的。
OOP & DOP
这里使用 TodoApp举例子说明 OMI 中 Signal 类 和 signal 响应是函数两种响应式编程的方式。
TodoApp 使用响应式函数
数据驱动编程
在数据驱动编程中,我们将重点放在数据本身和对数据的操作上,而不是数据所在的对象或数据结构。这种编程范式强调的是数据的变化和流动,以及如何响应这些变化。基于响应式函数的 TodoApp 就是一个很好的例子,它使用了响应式编程的概念,当数据(即待办事项列表)发生变化时,UI 会自动更新以反映这些变化。
tsx
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 使用信号类 Signal
面向对象编程
在面向对象编程中,我们将重点放在对象上,对象包含了数据和操作数据的方法。这种编程范式强调的是对象之间的交互和协作,以及如何通过对象的封装、继承和多态性来组织和管理代码。基于响应式函数的 TodoApp 也可以使用面向对象的方式来实现,例如,我们可以创建一个 TodoList 类,这个类包含了待办事项列表的数据和操作这些数据的方法,以及一个 update
方法来更新 UI。
tsx
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)
这里不讨论哪种方式(DOP,OOP)的好坏,使用 omi 两种方式都可以任意选择,也可以通过分层结合一起使用。
我们提倡使用分层的方式来开发前端。使用分层架构的原因很简单,让UI是UI,非UI是非UI。前端框架真是一把双刃剑,可以快速搭建 UI 的同时,很容易让大家把非UI层的,需要认真进行面向对象分析设计的模块被打散夹杂到 UI 层的各种逻辑里面变成一片混沌无序,快速腐化,项目负责人不可替代性越来越强(无人能接,无人能懂啊),强行在 UI 层进行 MVVM/MVC/MVP 分层是错误的。我们希望发挥和享受 OMI 数据驱动的响应式视图、可以快速搭建 UI 的能力的优势,尽量让用户前端框架超越其职责边界,杜绝用户把所有逻辑都塞进 UI 里。
另外我们使用两层架构 和三层架构 分别写了同一款贪吃蛇游戏,可以发现 OOP & DOP 不冲突,可以通过三层架构一起使用。
源码可以在 omijs.org/ 或 github.com/Tencent/omi 里找到。
这里我们的建议是:
- 将前端项目划分为不同的层次,如UI层、中间层、Model层等,各层负责不同的功能,降低耦合度,关注点分离。
- UI层:负责页面布局、样式、动画等视觉表现,杜绝在UI层处理业务逻辑和数据处理。
- 中间层负责把 UI 层输入转成 Model 层需要的格式,把 Model 层的输出转成 UI 层需要的格式。
- Model 层包含软件需要做的所有事情,UI 层只是提供了人机交互界面为 Model 层传送指令。
遵循以上建议,可以有效地提高前端项目的可维护性、可扩展性和可读性。
OMI 低代码
万丈高楼平地起,关于低代码,我们积累了大量的实战经验,每天都能迸发出很多创意和想法,后面会体现在我们的低代码产品当中,敬请期待。
总结
在《股票大作手回忆录》里有个圣杯的概念,许多投资者都试图寻找到它。从客观的角度来看,前端框架也有属于它的圣杯,我们站在巨人的肩膀上,持续寻找前端的圣杯。
本文从非常宏观的角度上,大体地介绍了 OMI 的新特性、和相关技术以及相关官方包,包括 OMI Signal、Web Components、TailwindCSS、OMI Router、OMI Suspense、 Proxy、Constructable Stylesheets、OOP & DOP、JSX、SPA等,希望这些内容能够帮助大家更好地理解和应用 OMI 框架,提高 Web 开发的效率和质量,拥抱趋势。更多详情查看 omijs.org/ 或者在 11月18日,杭州 FEDAY 前端大会,我会分享《响应式 WebComponents》,深入地探讨一下。