文章

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>
  );
}
  • 状态的归属决定了状态可以被哪些组件访问,内部状态只能被当前组件访问,外部状态可以被其他组件访问。

  • 常见的做法是将共享状态提升到更高层次的组件,通过 propscontext 传递给需要的组件。

经典的 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 部分的规划,接下来就是实现各个组件和状态,这里就不展开了,你可以参考文档或搜索相关教程,希望你能在前端开发的道路上越走越远。

License:  CC BY 4.0