2024 启动 Web UI 开发
写给 2024 年,有一定编程经验,想学习前端开发的人,主要是做 Web 和小程序。内容偏向寻找当代前端开发的共性,并简述工程实践,相关框架和工具请查阅文档。
推荐使用 Vite 在本地快速启动各种框架的项目,或 codesandbox.io 在线使用模版进行学习。小程序请使用官方开发工具。
组件化
目前使用最多的前端框架是 React,Vue。这些框架都是基于组件化的思想,即将页面拆分成一个个独立的组件,每个组件负责自己的状态和 UI。组件化的好处是提高代码的复用性和可维护性,同时也方便团队协作。
尝试忘掉之前的前端开发经验。或许你听过 MVC,MVVM,但这些都是设计模式,而组件化是一种更高层次的抽象,它更贴近人体工学。或许你听说过状态管理库,比如 Redux,Vuex,Mobx,但他们不重要,很多时候你只需要一个简单的状态管理就够了。或许你听说过 SPA、SSR、CSR、SSG,这里只讨论 SPA(单页应用),这是最容易入门学习的。
想象一下,在浏览器里,结合框架后,组件才是一等公民,请记住 组件 = 状态 + UI
,这很重要,带着这个公式再看下面的例子,你会发现 React、Vue、微信小程序的组件定义方式有很多相似之处。
React Component:
function Button(props) {
const [count, setCount] = useState(0); // 状态
// UI
return (
<button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
);
}
React 可以通过 class 和 function 两种方式定义组件,推荐使用更简洁的 function 组件。
React 对状态采用 immutable 的方式,通过
useState
来定义状态,通过setCount
来更新状态。
Vue Component:
<!-- UI -->
<template>
<button @click="count++">Clicked {{ count }} times</button>
</template>
<script setup>
import { ref } from "vue";
// 状态
const count = ref(0);
</script>
Vue 单文件在构建后,组件其实是一个对象,包含了模板、状态、方法等。
Vue 对状态采用 mutable 的方式,通过
ref
来定义状态,直接修改状态即可。Vue 3 推荐使用 Composition API,可以更好地组织代码。
微信小程序 Component:
<!-- UI -->
<view>
<button bindtap="handleTap">
Clicked {{ count }} times
</button>
</view>
Component({
data: {
// 状态
count: 0,
},
methods: {
handleTap() {
this.setData({ count: this.data.count + 1 });
},
},
});
微信小程序的 App、Page、Component 都是组件,但是定义方式有所不同,但都是
组件 = 状态 + UI
。微信小程序对状态采用 mutable 的方式,通过
this.setData
来更新状态,跟 Vue2 非常相似。微信小程序的 UI 是 WXML,逻辑是 JS,样式是 WXSS,这是微信小程序的特色,跟传统的 Web 开发有所不同,要注意查看文档。
✨ 彩蛋:如果想在微信小程序中尝试 Vue3 特性,可以使用轻量级的 vue-mini 即可在 JS 部分使用 Vue3 的 Composition API 写法:
import { defineComponent, ref } from "@vue-mini/core";
defineComponent({
setup() {
const count = ref(0);
const handleTap = () => count.value++;
return { count, handleTap };
},
});
需要注意,组件 = 状态 + UI
是一个很抽象的概念,甚至可以没有状态和 UI,比如以下都是合法的 React 组件:
function RenderlessComponent() {
const [count, setCount] = useState(0);
return <></>;
}
function StatelessComponent() {
return <div>hello</div>;
}
function NothingComponent() {
return <></>;
}
组合
组件化的另一个重要特性是组合,即将多个组件组合在一起,形成更复杂的组件。
以 React 为例,组合的方式有以下几种:
Siblings Compose:
App = A + B + C
function App() {
return (
<>
<A />
<B />
<C />
</>
);
}
function A() {
return <a />;
}
function B() {
return <b />;
}
function C() {
return <c />;
}
Slots Compose:
App = A(B(C))
function App() {
return (
<A>
<B>
<C />
</B>
</A>
);
}
function A(props) {
return (
<>
<a />
{props.children}
</>
);
}
function B(props) {
return (
<>
<b />
{props.children}
</>
);
}
function C() {
return <c />;
}
Chain Compose:
App = A -> B -> C
function App() {
return <A />;
}
function A() {
return (
<>
<a />
<B />
</>
);
}
function B() {
return (
<>
<b />
<C />
</>
);
}
function C() {
return <c />;
}
以上三种组合方式得到的 UI 都是一样的,但表达的意图不同,最好根据语义化,设计合适的组合方式。
推荐使用 Siblings Compose + Slots Compose,Chain Compose 可能会导致组件嵌套过深,不易维护。
除了 React,其他框架也有类似的组合方式,比如 Vue 的插槽、微信小程序的 Component,代码风格有所不同,但思想是一样的。
通信
根据组件组合方式,再来看组件的通信,组件的通信总结为显式传递和隐式传递,通常传递的内容是状态和方法。
以 React 为例,显式传递通过 props
实现,隐式传递通过 context
实现:
Chain Compose 经常搭配显式传递:
function App() {
const [count, setCount] = useState(0);
return <A count={count} setCount={setCount} />;
}
function A({ count, setCount }) {
return <B count={count} setCount={setCount} />;
}
function B({ count, setCount }) {
return <C count={count} setCount={setCount} />;
}
function C({ count, setCount }) {
return (
<button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
);
}
显式传递的好处是透明,可以清晰地看到数据是从哪里传递过来的,但是会导致层层传递,不适合深层次的组件。
Slots Compose 经常搭配隐式传递:
const MyClickContext = React.createContext();
function MyClickProvider({ children }) {
const [count, setCount] = useState(0);
return (
<MyClickContext.Provider value={{ count, setState }}>
{children}
</MyClickContext.Provider>
);
}
function App() {
return (
<MyClickProvider>
<A>
<B>
<C />
</B>
</A>
</MyClickProvider>
);
}
function A(props) {
return <>{props.children}</>;
}
function B(props) {
return <>{props.children}</>;
}
function C() {
const { count, setCount } = useContext(MyClickContext);
return (
<button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
);
}
隐式传递的好处是简洁,不需要显式传递,但是会导致不透明,不容易看出数据是从哪里传递过来的。
隐式传递还可以方便地调整状态被的访问范围,如只让 B 和 C 组件访问状态:
function B(props) {
return (
<A>
<MyClickProvider>
<B>
<C />
</B>
</MyClickProvider>
</A>
);
}
这里的 MyClickProvider 也是一个组件,用来提供状态,本身不渲染任何 UI,只是一个状态容器。
状态
组件就是状态容器。忘掉之前的状态管理库,只需要了解组件的状态即可。状态分为内部状态和外部状态。
以 React 为例,内部状态通过 useState
定义,外部状态通过 props
显式传递或 context
隐式传递:
function MyComponent(props /* 外部状态,显式 */) {
const context = useContext(MyContext); // 外部状态,隐式
const [count, setCount] = useState(0); // 内部状态
return (
<button onClick={() => setCount(count + 1)}>Clicked {count} times</button>
);
}
状态的归属决定了状态可以被哪些组件访问,内部状态只能被当前组件访问,外部状态可以被其他组件访问。
常见的做法是将共享状态提升到更高层次的组件,通过
props
或context
传递给需要的组件。
经典的 TodoList 案例
最后,我们用一个经典的 TodoList 案例来总结一下,需求如下:
有一个输入框,可以输入待办事项,点击按钮添加到列表中。
有一个列表,展示待办事项,每一项可以标记为已完成。
有一个按钮,可以清空所有待办事项。
首先分析有哪些状态:
输入框的值是一个状态:InputState
待办事项列表是一个状态:ListState
每一项的是否完成是一个状态:ItemState
然后分析 UI 的组合方式:
输入框和按钮是 Siblings Compose。
待办事项列表是 Slots Compose。
编写 App 的组合伪代码,对组件和状态进行规划,建议把状态也封装成组件,用 Slots Compose 的方式传递状态,后续实现再根据情况合并到对应组件中,毕竟先分离再合并比较容易:
function App() {
return (
<>
<InputStateProvider>
<Input />
<Button />
</InputStateProvider>
<ListStateProvider>
<List>
<ItemStateProvider>
<Item />
</ItemStateProvider>
<ItemStateProvider>
<Item />
</ItemStateProvider>
<ItemStateProvider>
<Item />
</ItemStateProvider>
...
</List>
</ListStateProvider>
</>
);
}
注意,尽可能控制划分组件的粒度,不要过细,也不要过粗,根据实际情况来。以下是两种反例:
过细:把所有 UI 细节都写到 App 中,比如 Input 的样式、Button 的样式,Button 的图标等,这样会导致 App 过于臃肿,不易维护。
过粗:把多个元素放在一个组件里,比如把 Input 和 Button 放在一个组件并命名为 AddTodo,这样会导致组件功能过于复杂,不易复用。
如果你不确定组件的粒度,可以从需求变化的角度来考虑,尽量隔离变化,如 Input 可能加回车提交,Button 可能变成图标按钮,这样就可以把 Input 和 Button 分开。
完成了 App 部分的规划,接下来就是实现各个组件和状态,这里就不展开了,你可以参考文档或搜索相关教程,希望你能在前端开发的道路上越走越远。