Logo

site iconGuoqing Zou

前端工程师,腾讯、MoeGo,业余维护技术社区,关注CS学习氛围和开源文化。技能涵盖Web领域,包括JavaScript、TypeScript、PHP等技术栈。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

Guoqing Zou RSS 预览

关于 React App 业务逻辑的组织

2024-10-26 18:35:15

上篇文章 聊到了 GUI 应用开发的一些参考原则,接下来打算结合我这几年的 React App 开发经验,总结一些在 React 生态下,一些比较好的实践经验和方法论。

一切的原则在于用尽可能少的代码及其潜在的沟通成本去实现业务,让各个模块向高内聚低耦合状态靠近;在维护者的精力分配上,可以更多聚焦于业务逻辑以及对计算机本身的思考,减少不必要的沟通摩擦。

相较于 UI 实现,本文更侧重于业务逻辑的组织上,主要讨论几个点:

  1. 渲染逻辑的拆分
  2. 代码文件的组织
  3. 业务 Flow 的表达
  4. 业务状态的划分策略

渲染逻辑的拆分

在 React App 的开发中,随着组件要渲染的 UI 元素的增多,写代码时很容易不自觉会延续过往写 class 组件的习惯,写一些 render function 拆分渲染逻辑。这种做法看似简洁,实则会因各个拆分后的 render function 依赖的业务逻辑相互交叉,导致组件愈发复杂,越来越不好维护。

一个好的做法是,优先考虑拆子 UI 组件,即使子组件的逻辑非常简单。这么做看似文件和组件数量会变多,但后续扩展无需再动上层的组件。业务快速迭代的背景下,从可维护性的角度来说,采用灵活度更高的方案,要比过早优化好一些。业务逻辑日渐复杂,拆分子组件给运行过程带来的性能损耗,其实远远小于把业务逻辑混杂在一起的维护成本,总的来说还是划算的。

拆子 UI 组件的一个蛋疼点在于,我们要建新的 jsx / tsx 文件、写一堆重复的组件定义代码,手打会比较烦躁。这些烦恼可以借助于 IDE 来改善,VSCode 自带的 snippetsMove to new file 的能力,可以说为此节约了不少的精力和时间。

那么,render function 写法是否就真的一无是处呢?其实不然,在一些涉及到条件渲染、皮肤定制等等的场景,render function 相比子组件会更加灵活,它可以自由地把渲染逻辑插入到父组件的合适地方。甚至你可以考虑把 render function 及其依赖的上下文状态,封装到一个 hook,再用 render function 将其加入到父组件的渲染流程,如例子附上的 FooBarCounter 的 hook 和 render 函数。这样的做法也非常适合需要在历史包袱很重的组件渲染新的 UI 元素、但不想大改旧组件逻辑的场景。

// hook
const useFooBarCounter = () => {
  const [count, setCount] = useState(0);
  return {
    reset: () => setCount(0),
    renderFooBarCounter: () => <div>{count}</div>
  };
};

// app
const App = () => {
  const {
    reset: resetCounter,
    renderFooBarCounter,
  } = useFooBarCounter();
  
  return (
    <div>
    	<button onClick={() => resetCounter()}>Reset</button>
      <div>balabalabala</div>
      {renderFooBarCounter()}
    </div>
  );
};

抛开那些纷繁复杂骚操作,复杂组件渲染逻辑拆分的核心目的,大概在于把控模块间的边界感,避免给交织的业务 Flow 在代码的表达上发生太多相互交叉的情况。

代码文件的组织

众所周知,React 官方并未规定 App 在代码文件的组织策略,开发者写 React App 通常八仙过海各显神通,当业务增长到一定的规模,找代码这件事情的心智负担也随即增大,容易让项目代码走向混乱。这时候就需要一个稳定统一的策略,降低心智负担成本。

大原则是避免过度设计,从组件业务逻辑开发自简单向复杂演进的视角,我们可以划分出几个阶段,每个阶段针对性引入一些的概念和工具去辅助处理。

按业务复杂度大概可以拆成三个阶段,分别起名 “简单组件”“小型组件”“大型组件”

  1. 简单组件:纯 UI 逻辑,非常简单,一个文件就可以写完
  2. 小型组件:由数个模块(组件 / hooks / utils 等)构成,考虑引入目录来划分
  3. 大型组件:由数个 小型组件 构成,按模块类型拆分目录,并引入 modules 的概念来管理子组件

对于 小型组件 来说,我们可以直接用 组件名.模块类型.ts/x 的模式来组织文件名,借助编辑器默认按文件名排序的能力去归类组件本身、样式文件、工具函数 / hooks、子组件 等,整体来看大概是这样的格局:

  • TestComponent
    • TestSub.tsx
    • TestSub.styles.ts
    • TestComponent.tsx
    • TestComponent.styles.ts
    • TestComponent.xxxxx.ts/x
    • TestComponent.styles.css

演进到 大型组件 的复杂度,可以进一步引入目录辅助拆分,然后把对外的根组件相关的模块放在根目录:

  • TestComponent
    • components
    • hooks
    • modules
    • contexts
    • store
    • TestComponent.tsx
    • TestComponent.styles.ts
    • TestComponent.xxxxx.ts/x
    • TestComponent.styles.css

React App 的概念模型,是一棵由组件和子组件以递归的模式构造出来的树。我们对三个阶段的组件的文件组织策略都有了定义,我们可以递归地把它们应用在整个 React App 的组件树设计上,以此实现文件系统的目录树和 React App 的组件树的大致对应。带着一定的思维惯性,读/找代码时的认知负担也随之大大降低。

针对 大型组件,我的一个特殊做法是拆分出 modulescomponents 的概念。主要考虑点在于子组件的可复用性会有强弱之分,有的子组件仅仅是逻辑的拆分,仍然和父组件有着千丝万缕联系,适合放在 modules 里,有的与父组件关联较弱,有机会被其他组件复用,近似于纯 UI 组件,就适合放在 components 下。

对于复用性强的组件,由于业务需求的不确定性,在开发过程我们其实没法预测到它最后会发展到一个什么样的通用程度,有时候会做一些不必要的过度设计,更好的做法是允许它有一个能不断“成长”、“升级”的预期。在开发一个组件时,若发现一个组件可以被通用化,但暂时没有别的组件引用,可以先把它放在当前的 components;随着业务发展,部分子组件可能需要被其他模块引用,这个时候我们可以进一步把它挪到更上层的 components。这样子开发者对组件当下所能作用的 scope 也会一目了然,让文件系统能在潜移默化之间给维护者传递更多关于项目的背景信息。

需要指出的是,这里分三阶段走并非严格策略,核心目的仍在于降低找代码 / 读代码的负担。对于文件/目录的组织,可以抓大放小,在个人直觉可以接受的范围内,是可以允许一定的杂乱存在的。

业务 Flow 的表达

当一个软件承载的业务 Flow 变得多而复杂,代码的维护负担也随之越来越重。相信开发过复杂 React 应用的朋友,应该体会过代码迭代过程中要梳理各种飞来飞去的状态变化、订阅、事件的痛苦。一个比较迫切的需求也在于,如何能在代码里简洁清晰地表达出业务 Flow 的逻辑实现,并保留未来基于此做自动化测试的可能性。

状态机的封装

抽象角度来看,实现一段业务逻辑,实质上是在实现一个状态机,伴随着一系列的 action,驱动 state 的初始化、state 的转移和 state 的结束。大方向上,我们需要一个载体去承载这样的 stateaction

在 React 的世界,我们可以用 hook 去承载这样概念的落地,一个 hook 可以定义 state,也可以定义修改 state 的各种 action。并且,借助于 JS 函数闭包的特性,我们可以通过 hook 导出自带 state 上下文的 callback 函数,实现对外暴露 action 的作用。借助于这样的能力,我们可以显式地把一条 Flow 从开始到结束的所有动作,都集中在一个 hook 里面。

继续以 useFooBarCounter 为例,我们可以围绕着 count 的 state,分别暴露出 resetaddsub 这几个 action,业务可以不用关心内部的变化,直接调用就可以驱动 count 的 state 的更新。

// hook
const useFooBarCounter = () => {
  const [count, setCount] = useState(0);
  return {
    count,
    reset: () => setCount(0),
    add: () => setCount(count => count + 1),
    sub: () => setCount(count => count + 1),
  };
};

Event based -> 命令式

对于一些相对较长的 Flow,尽可能用命令式的写法去组织代码,可以让业务逻辑的表达更加直观清晰。但有时候涉及到类似 Modal 组件 visible,用户交互 confirm 后、再继续往下走的流程,代码上就很容易出现前面说到的业务逻辑跳来跳去的情况。

一个可以参考的做法是,把这种类似 Modal 交互的 Event based 逻辑转换为命令式的写法。代码层面可以用 Promise 构造函数 + Ref 的方式来实现,外层 Flow 直接 await 这个 Promise,当用户在 Modal 确认后,通过 resolve / reject 结束 Modal 的 Flow,再把流程交还给上层 Flow 继续往下走。

如以下例子代码,这里忽略了一些定义、传值的细节。在实际的操作上,你还可以把这类逻辑封装成一个 hook 实现通用化。

const TestComponent = () => {
  const [modalVisible, setModalVisible] = useState<boolean>(false);
  const resolveRef = useRef(null);
  const rejectRef = useRef(null);

  const startModalFlow = () => new Promise((resolve, reject) => {
    resolveRef.current = resolve;
    rejectRef.current = reject;
    setModalVisible(true);
  });
  
  const startMainFlow = async () => {
    // before modal flow logic...
    await startModalFlow();
    // follow up logic...
  };

  return (
    <TestModal
      visible={modalVisible}
      onConfirm={() => {
        resolveRef.current?.();
        setModalVisible(false);
      }}
      onCancel={() => {
        rejectRef.current?.();
        setModalVisible(false);
      }}
      onClose={() => {
        rejectRef.current?.();
        setModalVisible(false);
      }}
  );
};

当我们能把业务流程用命令式的写法串起来以后,即使涉及到流程结束、报错的情况,我们也可以很轻松地兜住,正常结束我们可以用 return,异常结束可以直接 throw 一个定义好的 Exception / Error,只需要简单地用 try / catch 等语法就能实现错误的统一处理。并且,这样的做法也可以促使开发者多多思考业务的错误降级、兜底逻辑,对用户使用过程可能遇到的各种场景都有预期去做兜底,获得更加丝滑的体验。

Flow 的分阶段组织

对于一些要跨越多个不同交互界面的 Flow(比较常见是页面跳转),并不好直接在一个函数就完全处理完,这时候可以把这个 Flow 拆分为多阶段的 Flow 分步处理。

当其中一个 Flow 结束,我们可以在其中安插一些启动新的 Flow 的逻辑。或者也可以通过全局渲染状态的大水漫灌,在需要的模块订阅这个状态的更新,实现下一阶段 Flow 的启动。这属于 React App 开发的常规操作,就不多赘述了。

业务状态的划分策略

状态管理是个老生常谈的话题,之所以老生常谈,也是大家对于应用大小状态的划分不清晰导致,有的 useState 一把梭,有的将其存在外部 store,然后 atomaction / selector 满天飞,阅读代码负担也特别重。相较于徘徊于各种状态库实现的眼花缭乱,我更倾向于把概念搞清晰,具体的实现,根据项目情况因地制宜即可。

针对状态的划分,可以按复杂程度分两种情况去考虑,再通过 hook 对业务暴露 API。

对于逻辑简单的业务,不需要怎么去管理状态,直接放在顶层组件的 state 即可,再通过 Context 来传递状态的方法。一个比较好的做法是直接封装一个业务逻辑组件,类似上面的 useFooBarCounter 那样定义好 stateaction,然后通过 Context Provider 嵌入组件树,再对应暴露一些 hook 给下层 UI 组件去触发业务逻辑组件内的 action

对于逻辑复杂的业务,状态通常是存在外部(或者最顶层组件)的 store 上,也由于 storeselector / action 等能力比较底层,直接暴露出去,很容易发生引用的互相交叉,降低可维护性。

因此我们应该尽可能减少这类底层 API 在上层的直接使用,比较好的做法也是,按照对应业务模块划分 module,module 之间通过 hook 来给业务组件,或其他 module 提供对其业务生命周期方法和状态的引用。

关于对业务组件的 hook API 代码的设计,思路上也类似于楼上说的 useFooBarCounter,我们可以封装一些诸如 fetchXXXinitXXXhandleXXX 的方式,供上层业务去调用。业务依赖 hook 提供的业务接口概念,而非依赖于 store 的底层实现,以此可以减少因为不同业务模块与 store 逻辑互相交叉增大复杂度的情况。

小结

以上简单展开了一些 React App 业务逻辑组织的经验和策略,总的来说,核心都围绕着 “如何减小业务 Flow 之间的交叉” 而进行,实现它的方式有很多,也许你有更好的方案。

从中可以发现,React Hooks 的抽象所提供的业务逻辑拆分能力,可以说是降低复杂 React 应用上手门槛、提升可维护性的一个有力工具,如何用好它,背后有着不少的学问。

值得注意的是,即使是在当下业界强调函数式、响应式编程去实现 UI 的背景,就业务流程的组织而言,命令式的写法仍然十分有用且实用。

相关讨论:关于 React App 业务逻辑的组织 - 0xFFFF

浅谈 GUI 应用开发

2024-08-10 17:30:01

自接触编程起,我一直挺向往 GUI 应用开发的方向,妄想有机会可以做出像千千静听那样能给人一种“哇塞”感觉的软件。自初中开始接触 Adobe Flash / Air 到 HTML5 摸到一些皮毛,再到大学时写 JavaScript 和 React 逐渐上道,满打满算经历了一轮技术浪潮的冲刷,外加几年的实习、工作经历一路走来,也让我有了不少的思考和感悟。

关于 Web 与 GUI 应用开发方面的想法,过去我在 Blog 写下了部分,比如 Web App 发展史单页应用的交互模式R&D 工程师的角色定位 等等。当前我自 Web 开发切入工作也近五年,自觉在 GUI 应用开发方面有了一定的认识,差不多可以写一篇东西展开讲讲,也借此理一理思路,让这件事变得更清晰简单一些。

在聊 GUI 之前需要明确的是,软件这一事物是怎么与外界互动的,在现实中承担着一个什么样的角色。在我看来,计算机世界与现实世界有机结合,才有助于我们找到软件应有的迭代方向。一个软件的使命,在于在计算机世界开辟一个特有的环境,承接住现实的业务流程;在特定的设计下,借助计算机的力量、把一些现实难以完成的流程变得更加顺畅,把适合机械化的部分交给机器,创造性的部分留给人工。

至于哪一部分留给机器,哪些交还给人工,更多属于产品经理、设计师层面思考的话题,这里会更侧重于工程师视角的实现层面。我想可以从 状态管理生命周期交互模式屏幕适配,以及 研发流程 等几方面去展开讨论,主打一个抛砖引玉。

状态管理

状态管理可以说是一个 App 实现的核心部分,某种意义上来说,计算机本身就是一种状态机,一个 App 内部也是,只是相比于机器而言 scope 更小一些。计算机从 01 两种基础状态开始,慢慢发展出许多复杂的数据结构和概念,让人眼花缭乱。人的精力是有限的,很多细节其实无暇顾及,如何在有限精力的局限下,让分散各处的状态变化变得可控,应该是状态管理关注的核心目标。

面对眼花缭乱的 App 状态,需要的是抓住最关键的部分。在 React / Vue 等 UI 库流行的当下,这个关键部分其实已经非常符合直觉,UI = f(state) 的深入人心,意味着我们只需关注那些会引起 UI 变化的 state 部分,其余交给 UI 库,CSS 样式(Web)本身去处理,或自己写写各种样式 Hack 逻辑。

当然,这里的 state 的组成可以很复杂,但整体来说,我想可以拆分为“核心状态”,以及支撑核心状态的“附加状态”两部分。举个例子,在做 B/S 相关的业务时,UI 变化很多时候来自后台数据的更新,用的最多的大概是 loading, success, error 这几种 case。对于这种简单的情况,一般会用 isLoadinghasError 等方式组合实现。更清晰的是将其定义为 Enum,比如说 LoadingStatus,把所有可能性一一列出来。

enum LoadingStatus {
  INIT,
  LOADING,
  SUCCESS,
  ERROR,
}

interface PageState {
  status: LoadingStatus;
  pageData: {};
  errorMessage: '';
}

然后找个地方存一下核心状态和附加状态,再处理状态转移需要的 UI、数据层面的同步逻辑,再在合适的地方驱动状态的流转,一个基本的 GUI 应用就这么成型了。

const pageState: PageState = {
  status: LoadingStatus.INIT,
  pageData: {},
  errorMessage: '',
};

function setPageState(params: {
  nextStatus: LoadingStatus,
  payload: Partial<Pick<PageState, 'pageData' | 'errorMessage'>>,
}) {
  pageState = {
    status: params.nextStatus,
    ...params.payload,
  };
  // 可以是 React / Vue 以及各种 balabala...
  syncWithUI(pageState);
}

对于传统的网页前端而言,以上 LoadingStatus 的关键状态,已能覆盖绝大多数的业务场景,工作量更多集中在 UI 样式的还原,页面结构的规划,与后端开发的联动等方面。当我们的目光上升到 App 的层面,需要关注的状态自然不会这么简单,应对复杂的 App 状态,对应的技术方案自然也会复杂很多,相对应的也是前端百花齐放的状态管理方案。

在 Web 生态的视角,最直接的状态管理,大概还是 “组件化” 拆分代码,对于大型的复杂 App ,采取一种“分而治之”的策略,将状态分散在各个子组件,组件内局部状态自成一套。某种意义上有种“大水漫灌”的感觉,通过上层组件层层向下传值的方式去同步状态的变化,依次确保子组件能同步最新的状态。然后再通过类似 useEffect 的手段,识别状态的变化,在做一些对应的副作用动作。

在这样的场景下,各类状态管理库,定位上更多是一种针对不同场景定制的“代码拆分”的艺术,以支撑 App 的迭代、测试等各种需要。譬如 React 自带的 useState / useReducer + Context,以及类似 reduxzustandjotai 等等第三方外置状态管理、共享方案。

生命周期

值得思考的一点是,是否所有的业务场景都适合这样的“大水漫灌”模型?在 React 开发中常见一个现象是 useEffect 的滥用,导致 App 代码复杂后难以 debug 的问题。

想象一个软件拥有自己的灵魂,伴随它的一整个生命周期,大概分为三个阶段:

  1. 启动阶段:加载代码、状态初始化
  2. 工作阶段:与操作系统、网络、用户、其他软件等打交道
  3. 退出阶段:清理状态,释放资源,然后被回收

从 React App 角度来说,它也有它自己的生命周期(想必各位面试八股文选手早已耳熟能详),核心在于组件渲染 mountupdateunmount 的过程,以及各种用户事件的处理函数。和外界的交互,主要通过 updateuseEffect 和用户输入、后台接口等来打交道。基于一个个小组件拼装形成成大组件,进而构成了整个 App。

很明显,一个 App 所涉及的交互流程,就不仅仅单纯像命令行 CLI 程序那样只有一根筋的 input / output / error,在生命周期中的每一个动作,大都会牵扯到一条新的流程,有它自己的 inputoutput 以及 error。随着 App 的复杂,流程互相交织,流程所对应的代码逻辑足够简洁清晰,就应该成为代码设计的一个大原则。

概括而言,一条流程有几个生命周期:

  1. 流程的启动,考虑流程的触发源头
  2. 流程的运转,各种不同的逻辑判断,启动新的流程等
  3. 流程的终止,包括正常结束,以及异常终止的情况

在流程启动的视角,它的触发来源并不总是很清晰。比如说启动与退出,它们发生的通常比较自然,Web App 的启动,发生在 HTML 代码加载与渲染之间,退出则伴随着很多意外情况(浏览器标签关闭、系统死机、断电等)。对于 React 的组件来说,许多流程发源于 useEffect 订阅的 props 等状态变化,属于一种 “大水漫灌” 的背后隐含的事件订阅的模型。当然很多时候类似的流程变化,我们没有太大必要“精细化浇灌”、单独去定义订阅和同步的逻辑,交给 “大水漫灌” 是更合适的选择。

进入流程运转的过程,需要明确的一点是,涉及到核心业务逻辑的 case,有必要避免在流程中间过于依赖“大水漫灌”的逻辑。一条业务流程,遇到这种汇集了多种不同流程的步骤,遇到问题的 debug 难度也会直线上升。就像是人走在十字路口的迷茫,缺少背景信息的参考,你不知道下一步会往哪走,只能误打误撞一个个试错。遇到组件相互通信的场景,有时候单独定义新的 event bus 去做 “精细化浇灌”,要比走全局 UI 渲染的“大水漫灌”逻辑更简单直接和可靠。不过独立的 event bus 不能太多,“精细化浇灌” 维护难度会对应增大,通过合理的代码拆分,借助 useEffect ,也可以实现类似的精简版 event bus 模型。

流程终止的场景常常容易被忽略,尤其是在当下大多数团队只满足于“能跑就行”的背景下。如果真心想要把软件做得足够完善,需要更多地从用户视角去思考异常处理。一条流程中途遇到异常导致的中断,这个中断会导致什么影响?需要用户做出什么样的行动,研发视角又怎么监测到这个问题,背后是一整套错误 catch、上升的策略。用户能做的行动通常是 “重试” -> “采取备选方案” -> “寻求研发支持” 的过程;研发的视角则需要做好错误的分级、上报和告警机制,在用户意识到问题前能提前发现并修复,在重要流程适当设计一些降级策略,避免阻塞用户的使用。

到这里,我们对流程有了一个相对清晰的定义。随着软件复杂度的提高,代码迭代可能会影响某些流程而未被发现,进而产生新的 bug,需要有对应的机制去保障流程不会随着软件更新而中断,也是软件测试核心关注的点。在研发的视角,也可以针对一些比较重要的流程,写写单元测试,减少这里反复验证的人力损耗,覆盖单纯从黑盒视角的功能测试难以覆盖的极端 case。

交互模式

上一节聊到了流程的概念,这里关注的是依托浏览器的 Web App 与平台原生 App 运行环境的差异,重点在于 Web App 与 Mobile App 交互模式的差别,以及可能被忽视的细节。

两者一个大的差异点在于 Web App 的工作模式更像是一个单 Screen 的“画布”,而 Mobile App 是多个 Screen 堆叠起来的 Screen Stack。因为 Web 本来是文档渲染起家,在代码环境提供了更多预设能力的画布模型,也就是 DOM,我们可以通过 React / Vue 等 UI 库方便做 state 与 DOM 的相互映射。浏览器同时提供了 History API,开放了 History Stack 以及地址栏给外部操作。浏览器通过 History 跳转到别的页面,相当于整个页面直接刷新,原来的 UI 元素都会重新渲染甚至销毁。并且 History Stack 的每一层都可以保存一份 state,因此如果想要把交互做的精细完善,还需要考虑清楚这个 Web App 应如何管理和应对不同的 History Stack,是 push,还是 replace,以及用户如果直接从地址栏进入页面,又应该如何处理。

Mobile App 的交互和 Web 有些不同,它会涉及到不同页面同时层叠的 UI(微信小程序也是类似交互),因此需要考虑 Screen Stack 的维护,以及多 Screen 并存时可能的互相通信。在一些特殊场景,还需要考虑类似 Web 直接输入地址打开某页面的模式,当用户通过 Deep link / Universal Link 打开 App,打开用户想打开的目标页面,或给出合理反馈。

浏览器与 Native App 还有一个差异点在于窗口之外的 UI 交互,浏览器在处理拖拽、浮层、上下文菜单等能力会有缺失,开发成本考虑,大部分时候会自行在 DOM 上模拟实现;要实现 Native App 的效果,还需要操作系统的参与,这个细节就会涉及更多的 Native API 的调用,就得单独考虑了。

屏幕适配

另一我想说的点是 UI 在不同屏幕、窗口尺寸的适配,原则上应避免简单粗暴的按窗口宽度缩放。在一个网页开发者的视角,思考模式通常更倾向于直接缩放页面元素,用 rem 和浏览器宽度动态控制,或者用 vw 等单位处理(一个典型例子是微信小程序的 rpx 单位),这样的操作,在预期之外的屏幕尺寸上可能会面临一些奇怪表现,比如说在平板上大而难看的字体;或者缩放后字体小于浏览器最小 12px 的限制进而影响布局(更新:Chrome 118 后,默认最小字号放开了),或者因为字体大小舍入原因导致字体发虚等问题。

比较好的做法是统一用 px 单位,并借助 flex 弹性布局的灵活性和 Media Query,老实做好设计层面的适配,在不同的屏幕尺寸有所兜底。缩放和屏幕适配问题,则交给 浏览器 / 宿主平台 处理,现在的浏览器缩放也是基于 px 的,不会再动字体大小。如果需要考虑文本的单独缩放,需要的是研发与设计配合,专门针对其去做适配和处理,引导用户使用产品提供的方案,而非仅凭猜测行动,让用户去动那些充满不确定性的字体大小设置。一个参考:R.I.P. REM, Viva CSS Reference Pixel! | Mind The Shift,关于用 px 还是 rem,也是一个 Web 领域的争议话题。

还有一个比较容易忽视的细节是关于 UI 中作为容器的盒子的尺寸稳定性,尤其是对滚动区域的把控上,Web 开发者常直接依赖添加 overflow: autodiv 盒子,在内容超过最大高度时,浏览器会自动生成滚动条,而这个默认滚动条会挤占被滚动区域的宽度,某些情况下会发生抖动影响用户体验。比较好的做法是显式地指定一个 ScrollView 的角色,在最外层就定义好盒子的宽高与滚动状态,让元素可以相对于它来布局,保持外层容器的稳定性。针对被滚动的区域,可以单独包装一层,定好宽高,避免其被滚动条影响。这么一波操作下来,即使要再兼容适配 safe area、沉浸式刘海等特殊场景,也会非常方便。

研发流程

明确了 GUI 开发涉及到的一些生命周期和交互模式,对应研发流程的关注点也需要做一些调整,在我去年的 总结 有提到,这里类似于 “用户故事地图” 的思维模式。前文说到的“流程”,其实和这里产品经理视角的“用户故事”,设计视角的 “User Flow”,还有 QA 视角的“功能用例”可以说是指向了一个东西,让我们姑且就把它称为 “Flow” 吧。

开发的核心关注点恰好也是在这个 Flow 上面。首先得评估技术方案,明确改动的目标状态,当下状态,改动策略等;在开发任务的前期,铺垫好基本的依赖之后,尽可能早些走通这个 Flow,提前暴露出潜在较大的风险;接下来才是如何完善这个 Flow,让用户操作走的更顺体验更好。

这里大概可以形成一个 Roadmap:

  1. 评估技术方案,改动影响
  2. 开发走通 MVP,形成一个草稿状态的 Demo
  3. 产品、设计介入,拉不同角色联调,完善,产品化这个 Demo
  4. 产品、设计、QA 介入,进一步覆盖 Edge Case 和打磨细节,提升软件在各种复杂环境的鲁棒性和兼容性

从 Flow 这个视角来看,所有的参与方的目标是非常一致的,可以尽可能减少沟通上的拉扯和损耗,开发贯穿于其始终,起到一个承上启下的作用,因此对于研发工程师而言,这种对 Flow 的目标感就非常重要,毕竟很难有一个角色可以完整关注到从想法到实现的流程,乃至于每个设计细节的落地。当然,这里的 Roadmap 会更侧重于需求开发阶段,至于其他阶段如何行动,在我之前写的关于 R&D Engineer 的文也有 聊到,关于一名研发工程师在软件开发运维工作中的一个角色定位。

以上更多是个目标,从执行角度来看,要想达成更高的交付质量,总结起来大概有三个值得注意的点:

  1. 放下完美主义,抓大放小,在有限的资源下尽量做到全局最优。开发功能的最初,更大的目标在于核心 Flow 的走通,就不需要过早去关注一些比较边界的细节,细节可以放到需求的后期,或者是上线后再做一些 Follow up 优化版本;
  2. 需重视测试建设,由于 Flow 可预见的会越来越多,可能涉及到流程互相交织的情况,仅凭人脑经验很难完整覆盖,交付质量的把关就变得困难起来;对于一些比较追求精确性的模块,可以适当写写单元测试;
  3. 保持强的同理心,在核心逻辑得以保障,有机会做些优先级相对低的优化的情况,多思考用户是怎么用自己正在开发的软件,发现一些可能影响体验的问题时,对应做处理。同时也通过一些比较细致的情感化的扩展,给软件注入多一些灵魂,给用户带来多一点品牌理念的传达与归属感觉。

小结

写到这里,对于 Web 向 GUI 开发的工作,大概就形成了一个比较完善的思维模型。当然这里也伴随着一点视野的局限,更多是在 Web 前端的视角所展开的,Mobile Native App 和 Desktop Native App 我暂时没有太多研究,也许未来有机会研究到那里的时候,会有些新的感悟,到时候再继续写写和聊聊吧。

相关讨论:浅谈 GUI 应用开发 - 0xFFFF

个人网站的再次重建

2024-07-25 23:00:04

自从我 22 年中迁移博客 后,事情发展算是比较符合预期,过往旺盛的表达欲也有所集中,从知识管理、人生体系、个人的方向和规划等视角,随着文章的梳理,也经历了一轮重塑。

但在技术层面,我对于这套方案仍有些不满,只是因为工作生活的精力分配原因一直没有投入时间去改。一方面是 Notion 的表达力的有限,以及生成的站点 CSS 样式的结构略复杂,不好定制;另一方面是其 RSS 订阅的逻辑会消耗大量的 Vercel 的云函数运行时长,频频超限;比较不能忍的是有的 Page 莫名其妙无法访问,但我不知道背后发生了什么,只能复制粘贴重新搞,这也让我对以 Notion 作为博客后端 CMS 的方案信心大减。

这件事情的推进,伴随着许多巧合和水到渠成:某天偶然意外拿到了 zgq.me 域名;设计师好友 Yvon 在思考自己新的职业规划,尝试做了自己的 作品集站点;去年和 @johnbanq 说起要搞个人网站,最近终于上线;外加去年在写 0xFFFF 计算机入门专题 的时候也比较重度地使用了 Markdown 文件和静态网站生成器,重建个人网站的的动机愈发旺盛。在酝酿了好多个周末以后,终于是落地了一个版本。

由此,也在这里简单梳理一下重建个人网站这事儿的考虑和选择,以供参考。

目标

大的目标还是聚焦,在 个人知识管理体系 中,将所有对外的输出,归集到 zgq.me 域名下,比如较长的文字、个人状态、项目介绍等等,引用的源头都来自于此(Single source of truth 原则)。与此同时,将内容页面都统一在一套代码仓库,解除第三方依赖,确保数据的完整与安全。

在技术栈的角度,我想把个人的关注点集中在 Web App 开发,积累尽可能聚焦在这一领域。所以这里也继续用已有的前端技术去实现,继承 Nobelium 的衣钵,把内容来源换成 Markdown 文件。

数据导出

这里好像没什么可复用的经验,主要是把 Notion 中存放的文章一股脑导出,然后再针对性地做些处理。

Notion 导出 Database,它会将数据打包到一个 zip 文件中,其中有一个 csv 存 Database 的各个字段,以及一系列 Markdown 的 md 文件,以及 md 文件对应引用的图片资源等。

这里一个核心是,以文章的 slug 作为文章的 key,然后以此建立一个目录,存放其引用的各项资源(主要是图片)。md 文件内容参考 Hexo / Jekyll 中名为 Front Matter 的做法,头部将元数据(发布时间、标签等)以 yaml 的格式输出至 md 文件的头部,紧接着才是文章正文。

目录结构上,以年为单位划分(毕竟一年产出也没有几篇),整理出来大概类似以下的结构:

内容目录的结构内容目录的结构

核心

最开始考虑 Hexo,其生态有些复杂,懒人有点不想琢磨,遂先战术放弃。实质上我只是想要一个方便的 Markdown 转 HTML 能力,以及生成网站其他必要部分的框架,并不想在别的方面纠结太多,所以先用成熟的方案跑起来即可。所以这里网站构建上还是会继续用 Next.js,它的 SSG 方案基本可以满足我这里的需求。当然也保留使用其他方案(比如 Waku)的空间,目前看 Next.js 耦合太多 Vercel Only 的东西,并不必要。

然后需要关注的是 Markdown -> HTML DOM 的解析方案,这里就直接用 @mdx-js/mdx 梭哈,综合下来还涉及到一些扩展组件,如 Front Matter、数学公式、表格等 Markdown 扩展语法的支持,基本上抄抄默认配置就可以。

接下来是网站的 CSS 样式方案,原则上基础 CSS 样式需要尽可能薄。这里直接用 Tailwind CSS 来做,日常用到的 CSS 样式基本都可以被它 cover 住,并且它背后是一套完整的 PostCSS 工具链支撑,基于它做样式方案代码可以更加简洁,尽可能减少全局的重复样式定义。基础文章排版则直接引用 @tailwindcss/typography 的样式。

RSS Feed 与 sitemap 的方案参考 Nobelium 的,用 feednext-sitemap 整一个,也挺简单方便的。

图片处理

图片是直接从博文 Markdown 中引用的,但由于 Next.js 的设定,无法直接访问到 public 目录外的静态资源,显然不是太方便。查了一堆资料,暂时没找到更好的解法,只能单独 copy 到 static 目录,然后用 _next/static 来访问到对应的文件。

config.plugins.push(
  new CopyPlugin({
    patterns: [{
      from: './content/',
      to: './static/assets/',
      filter: async (resourcePath) => {
        if (resourcePath.includes('.md')) {
          return false;
        }
        return true;
      }
    }],
  })
);

另一个问题是在我把网站部署 Vercel 之后,发现每次打开页面都会请求一次图片资源,虽然是 304 Not Modified,但其实也很影响体验。检查请求发现 cache 的 max-age 竟然是 0...

找了很久没有找到 Next.js 和 Vercel 可以怎么调整文件缓存,我还是在 CDN 上配置好了,在 Next 这边做一组 URL 替换,以及禁用默认在 Vercel 烧钱的图片自动压缩服务。另外尽可能在后端读到各个图片的 size,避免页面初始化加载过程发生 Layout Shift(类似我之前在 Flarum 上的 做法)。

整体处理代码如下:

const components: ReturnType<UseMdxComponents> = {
  img: (props) => {
    const { src, alt } = props;
    const realSrc = `${CDN_URL || ''}/_next/static/assets/${pathBase}/${src}`;

    // next/image support
    const { width, height } = (src && imageSizeMap?.[src]) || {};
    const useNextImage = width && height;
    const imgWithAltElement = useNextImage ? (
      <Image width={width} height={height} className="mx-auto mb-4 max-h-[300px] rounded-l object-contain" src={realSrc} alt={alt || ''} />
    ) : (
      <img className="mx-auto mb-4 max-h-[300px] rounded-l" src={realSrc} alt={alt} />
    );
    const imgWithOutAltElement = useNextImage ? (
      <Image width={width} height={height} className="mx-auto max-h-[300px] rounded-l object-contain" src={realSrc} alt="" />
    ) : (
      <img className="mx-auto max-h-[300px] rounded-l" src={realSrc} alt="" />
    );

    if (alt) {
      return (
        <span className="block">
          {imgWithAltElement}
          <span className="block mb-4 text-center text-xs text-gray-400 px-4">{alt}</span>
        </span>
      );
    }
    return imgWithOutAltElement;
  },
};

评论区

不想再用 cusdis,感觉逻辑有些复杂,并且 Randy Lu 没有很积极的维护它。所以我先把原有的评论随着数据导出一起存了一份,准备写一个新的,尽可能极简,减小维护的负担。

一个评论框业务逻辑本身并不复杂,主要是提交评论的组件,以及后端存储的数据库。

嵌入文章的 UI 评论组件,先糊一个 React 组件自用,后续看情况再扩展到 iframe 嵌入的 widgets。

数据库的考虑,个人博客站点的评论量其实很小,SQLite 已足够支撑。核心在于针对某个页面维护一个 comment 列表,以及 comment 之间可能的父子关系。所以用一个 comments 表,补上核心数据以及一些必要的字段,就足够。

然后需要一个针对博主的简单 CRUD 管理后台,实现评论回复,删除 / 审核,并根据用户回复的动态,实现 Email 通知提醒的能力。

准备是用 Koa + SQLite + Vite React 先做一个,写好再整理分享出来。整体结构类似 weibo-rss;因为只有我一个用户,所以后台的登陆权限校验相关,用 JWT 认证就足够,hash 直接写死;Email 发送直接走 SES。主打一个「老夫写代码就是一把梭」。

Reference

新网站的建设,参考了不少同路人的实现,特此鸣谢:

  1. 博客的内容结构,参考了 Limboy 的 我的博客系统演变之路,还有 板桥工学Sukka's Blog
  2. 关于 content 目录的组织,友情链接的设定,参考了二花的 This Cute World

2023

2024-01-06 20:00:00

又到了新的一年,22年的总结 还未写完,23年不觉也结束了,就如 21 年底回看 19 年 那会 的我,可谓非常真实。现在在写博客方面改变了策略,尽量避免堆积到年底再去整理与回顾,一些相对成型的想法,其实都已拆分成了单独的博文,只求减少年底回首往事时,想法太多,思绪太杂,导致可能的不必要负担。

总的来看,这两年的经历,更多是一个借着业务变动离开“大厂”的契机,在逐渐寻找自我定位的过程,相比于19年那会近似自我革命式的变化,这两年来的转变,算是在预期之内,在方向上没有大的波动。总的而言算在比较平稳的行进状态,只是好像多了一点在社会时钟之下,可能不太属于我这个年龄的从容。

对于这篇总结,我想也是从自我的视角出发,还有这两年来的工作学习生活书影音游等等几个角度,大概写一写那些对我影响较大的经历与思考。

自我

22 年来感觉对自我的认知要清晰不少,伴随着裁员和新工作的洗礼,还有待在深圳原子化生活的孤独与渺小感觉,也意识到许多问题的背后,关键的关键在于自己的主动,而非呆在原地等待环境改变。从决策的视角来看,无意识中选择 “随缘”,本质上也是策略一种,更多是随环境和文化而定的被动选择。当自己身处一个文化多元交织的环境,“随缘”并不一定能带来满意的结果。这就如一个人在大海中的扬帆航行,是选择随风飘荡,还是说去寻找属于自己的方向,向着心中的彼岸迎风而行?

于是我也开始整理自己 写过的文,以及 博客知识管理工具 的重构迁移,试图收拢自己在技术领域的关注点,还有日常工作的方法模式。因为 tianheg二花 了解到 Derek Sivers 的 Now Page 的 思路, 然后也学习了下 搞了个,整理下我自己当前生活工作学习关注的目标。一方面希望自己精力和行动能更专注聚焦一些,避免因兴趣的发散或陷入宏大话题导致的各种浅尝辄止;一方面关心我的朋友们也可以以此了解到最近我在关注的目标,一起交流进步。

另一块比较漫长的思考是关于自我定位与规划方面,写了人生规划与体系搭建,关于自我与各种不确定性的相处心得、人类科学知识体系的依托、以及与现实既成体制的平衡与改变的话题。疫情以来许多事情都不太明朗,我也在关注有什么事情是自身力所能及的,就写了 疫情后的一些思考,关于如何对于中国社会现状建立基础的认知,立足于自身做些力所能及可以抓住的行动。

在这其中,因为社区经营、以及在各种网站、社交 App 观察人类多样性,外加受 MoeGo 创始人 Ethan 所重视的务实向长期主义创业价值观的启发,我也开始关注自己在纷繁复杂的世界中,做选择和决策的一些原则。结合我过往的成长经历,自己在不同的视角,也有了一些相对稳定的参考方向,整理下来,大概有这几个维度:

  • 人生:主要是关于“有限人生”与“无限选择”的矛盾,庄子“人生而有涯,而知也无涯;以有涯随无涯,殆已” 是一直在警醒我的一句话,我也曾为此栽过跟头;在有限的人生里,不得不需要做一些选择,并专注于自身的选择,印象最深的是侯捷老师《左手程序右手诗》中化用红楼梦的话,“弱水三千,只取一瓢,并且安心好好饮这一瓢”。
  • 知识:这方面我受胡适的影响比较多,目标上注重“深度”和“广度”,“为学当如金字塔,要能广大要能高”;心态上则安心“日拱一卒”,“怕什么真理无穷,进一寸有一寸的欢喜”,积少成多,自然“功不唐捐”。
  • 关系:一方面是“无常”与“爱”的关系,近年听的许多歌曲,有许多关于它们的思考与表达;另一方面,对待他人时,保持真诚,但也需尊重彼此的独立性,与外界和他人相处中秉承课题分离原则,保持一定的边界感,给予彼此的尊重与接纳。
  • 工作:“平衡”与“专注” 是我自实习以来在工作上的一个重心,职业上希望是做一名靠谱的工程师,专注于研发的 核心价值;行事上秉承健康可持续的原则,力求达成多方共赢,而非社会达尔文主义盛行当下各种各样零和博弈的内耗。
  • 商业:关注 “信任”与“影响”,在与他人的交往中,注重信誉积累、个人口碑的沉淀,构建完善属于自己的价值体系,金钱作为其中的一个载体存在而非以此为目的;与此同时,还需要适当做一定的影响力建设,让自己的努力可以被看见,类似在技术层面上搞搞 域名与个人主页开源项目 等等。

以上列了很多,更多在于方向与目标,有的我可能未能完全做到,但确实在努力接近。随着我的进一步观察,这些维度未来可能会接着变化,但就目前来看还挺自洽的。我想这也属于我当前的一个“航向”所在,同时它也作为一个“牵引之力”,对抗自然的“无序熵增”,继续组织着身边各式各样的“养料”,去浇灌属于我的“人生之树”吧。

路过南外看到的校训:“像树一样成长”路过南外看到的校训:“像树一样成长”

工作

22年经历着变化,从腾讯出来来到 MoeGo 至今,满打满算待了一年半,总体来说我挺满意。遇到了非常 nice 的同事们,有很多主动推进事情的自由,外加团队重视知识沉淀、信息相互引用链接的状态,注重有效的结果而非流于形式,也伴随着许多的锻炼和成长。从最初的工作节奏变化、慢慢调整自己,到今年逐渐步入正轨,经历过一些迷茫,也逐步开始有些产出。

工作内容上,延续着在腾讯时的业务导向,算是比较深度地在 React 生态之间完成各种各样的 UI 和交互,相对复杂的表单逻辑,antd form 的各种小细节也踩过一轮。技术的版图上集中在 Web 与 React、RN 这块,写代码上,基本稳定在类似 React Hooks / Jotai 的思维模式,以及 CSS 与 Web API 等,在实现业务逻辑方面,基本没有什么特别障碍。

背后伴随着一些对工作的小迷茫,一点迷茫来自于偏理想主义的技术研究动力与现实的商业需求的冲突。对技术有兴趣的朋友,多少有些将个人的技术情怀寄托于工作的冲动,这在工作的层面也容易走偏。老板更在意的是你能为他解决的问题,工作能带来的影响和改变,带着一个回报方面的预期。在一个相对务实的环境,也促使我去思考一个自我角色定位的话题,写了 Research & Development Engineer,关于一名 R&D Engineer 在团队中究竟在产出什么样的价值,自己领的这份薪水又付在了哪里。

另一点迷茫在于,我过往的经验更侧重于 UI 细节的精细化还原,偏重逻辑的应用经验相对较少。年中逮着了一次做 C 端页面的机会,算是独立负责的一个最大的模块,上线后有许多来自客户和同事们的正反馈,还是挺开心的。也感谢 leader 的支持与引导,下半年的工作状态也比较专注,基本是在独立负责一些 B 侧模块的开发支撑,完整地经历了几轮从需求 Kick-off、方案设计、开发测试到上线运维的过程。某个 feature 在年底肝了俩月,基本把手上最复杂的模块完整撸过一遍,对于工作的信心也增强不少。

当下比较棘手的一个问题也是,时长估算和预期管理方面还有些欠缺,常把 buffer 用尽。反思自身,我有点像是一个最近火热的 MBTI 测试中的 “P人” 状态,相比于计划而言,更多是基于自身能量驱动去做事情。并倾向于以一个共情的视角代入事情其中,有时候难以从中抽身。仔细想来,比较需要的也是通过自身的观察,解锁一个全局的视野。对个人的行动力的把握,在 Logseq 的帮助下 有改善很多;在时间的掌控方面,还需要更多完整而独立的项目的磨练。

下半年因为所在公寓的社群活动,意外参加了一场关于《用户故事地图》的读书讨论活动,主要由 ThoughtWorks BeeArt 团队的冰沁老师组织。在这其中经历了一些有意思的碰撞,发现其实我当前的工作也和 “用户故事地图”方法 所关注的思维模型非常相似。

故事的开始故事的开始

研发工程师的职能,在于承载产品需求从想法到最终落地的一整个过程,对于工作中的难以掌控,很多时候也是因为在产品落地的过程中,自身没有相对完善的方法模型,在不同角色的对接间疲于奔命所导致。当业务复杂到一定的规模,各个用户交互流程互相交织时,这里的瓶颈也开始暴露出来,故事地图这一思维模型,属于解决这一瓶颈的一个思路所在。

行动上,初步的想法也是,先从大的角度去捋清自己所负责的业务,维护一个大体的脑图框架,迭代功能时候可以以此作为一个上下文,去和 PM、Design、QA 等角色更好沟通。在项目开发中则以此作为核心目标,形成技术方案,大致界定模块各部分的优先级,把自身有限的行动力尽量花在刀刃上。

这里在我下半年跟进的几个项目之间已有初步实践,大概符合一个从 开发走通 MVP产品 + 设计介入完善 再到 产品 + 设计 + QA 介入进一步打磨细节 的过程。根据同事的反馈,我经手的需求的上线质量还可以,但在提测阶段产品、QA 和走查的设计同学的体验会略有些痛苦;分析背后原因,我想可能是我前期的时间投入相对较多,压缩了后续阶段参与同学的时间,导致其实没有太完善的 MVP 直接走向了提测阶段。时间资源的把控和分配策略,也是我下一步需要改进的点。

进一步的计划也是,先从手上项目实践经验出发,继续去做些探索,未来有心得时再进一步写写,或许可以形成一些新的文章,主题大概是关于如何应对复杂交互 GUI 应用开发的一系列方法集合。

2024.8.10 更新
延续这里的想法,最近写了:浅谈 GUI 应用开发

2024.10.26 更新
想法具体化到 React:关于 React App 业务逻辑的组织

技术学习

这一年花在技术的时间主要都在工作内了,可以肯定的是,社畜模式后,即使下班不晚,在工作以外能专门投入学习的时间其实并不是很多。幸运的是我的职业与兴趣在一个比较 match 的状态,也算是有所成长,日常开发学习到许多奇技淫巧,常在团队中小小分享一下。

在技术上一个比较核心的主题还是,寻找适合我自己的细分课题,在腾讯时候也和 ld 聊起过自身关注点相对较发散的困惑。实习工作到现在也四年有余,也差不多需要选择属于自身专注的子领域了。在现实的工作与理想的技术兴趣相平衡下来,继续搞 Web App 大致也符合我当前的生存状态,也能顾及到知识、技术方面的积累。但 Web 本身概念太大,于个人而言精力有限,关注点需要收拢,目前社区潮流的 Jamstack 也如当年的 LAMP 一般,属于一个关注点收拢后的一个大致方向,感觉可以是一个聚焦的概念。

在这方面更具体的思考,我想我可能可以引用一下我之前写过的帖子博文

Jamstack 这一概念把 Web 在「文档」与「应用」的层面,把技术栈都收拢到了 JavaScript 生态下,覆盖纯 Client Rendering 到 Server-Side Rendering 的场景,再到更精细的 React Server Component 等方案。这也恰好也和我过往在 Flash / React / PHP 的兴趣方向相重叠,就 Web App 开发而言,觉着或许也是一个未来值得专注的发展方向。

基于 Web App 作为锚点,可以再进一步引申出 RN、Flutter 等更贴近操作系统的 App 开发方案,差不多可以符合一个独立承接产品落地的 “Web 全栈工程师” 的角色定位。对我而言,下一步的课题大概也是,围着这样的定位和目标,继续一整个专注学习的过程。

近年来的学习工作经历让我愈发觉得,“技术”并不是生活的全部,视野的打开还是挺重要的,加之我不太喜欢在某种丛林法则之下相互内卷厮杀的生活状态,未来希望能从出海的视角多看一看,无论在国内外,最好都能以一个国际化的视野去面对生活的方方面面。从这个角度出发,尽量习惯英文的语境,是我接下来无法避开的课题,也需抓住已有的机会多关注关注。

0xFFFF

相比于21、22年的各种整活,今年在社区运营上走向佛系,忙于工作和生活,精力投入不是很多。

考虑到 150 定律的魔咒,以及人群价值观的多样,不刻意再做额外宣传,更多立足于身边的小圈子,还有参与人中相对种子的用户;这一年在活动上,主要是和在广深的群友随机约饭,谋求更多的线下交流,上半年回了一趟华师,找老师和图灵班的师弟师妹喝茶;也趁社区建立的重要推手 johnbanq 假期回国时,组织了一些在深圳的朋友们小聚了一下。第一次拉 @Miigon@johnbanq 见面,想来还是非常有纪念意义,背后是一个比较神奇的世界线收束故事,这里先不展开。

小聚、喝茶与聊天小聚、喝茶与聊天

今年在网站架构层面,做了不少的变更,主要是把论坛的代码仓库收拢到 Flarum 0x,并将附件上传等能力的目标都收到对象存储中,减小对服务器本地文件系统的依赖;网站的部署上开始尝试 fly.io,成本与访问速度我都很满意,虽然后来因为备份问题,还是搬回了独立 vps 部署的方案。下半年来花了不少时间去想办法优化网站的访问速度,在 DNS / 对象存储 等方面脱离了对 Cloudflare 的依赖,通过 阿里云香港、fly.io、DNSPod 等服务实现了内外网分流的能力,大大改善了大陆内网打开网站的速度。最终整理得到的建站方案,也收到了这篇文 适合个人网站的云服务组合

其中也经历了一些小挫折,由于我这里的变更较为激进,备份方案没有做好,在下半年一次日常升级中意外把 MySQL 服务的 volume 数据整丢了,还好之前有手动备份一部分,但一部分数据丢失无法再找回,在 @Miigon 的帮助下,利用搜索引擎的 cache 找回并恢复了绝大部分关键帖子。这也让我充分体悟到了数据安全的重要性,单机部署数据库,多少带着一些风险,备份和 DBA 相关的方案也是其中非常重要的一环。感谢力铭安利他们的 TiDB Serverless,但目前论坛数据规模并不大,还没有到一个亟待拆分 db 的状态,考虑大量 SQL 查询在外网传输的响应时间会增加不少,且 Flarum 本身没有比较完善的 cache 优化机制,所以暂未采用。

今年社区运营的一大重心,在于重修 0xFFFF Wiki 的 入门专题,我希望能把我酝酿已久的关于比较理想的大学学习状态,理想与现实的冲突解决思路等等,再做一次比较系统性的梳理,算是了却我在大学时的一份执念,也希望未来同路人们遗憾可以少一些。同时希望它可以作为社区基本世界观的基础,确立一些基本的原则,更具体的学习方法、资源等,可以基于此再持续延伸。

入门专题的 核心部分,写了两个多月基本成型。还有些具体操作方法相关的手尾,由于下半年更多忙于自己的工作生活,一直找不到感觉继续去写,与原定计划出入比较大。想来更多是我自身的动力不足所致,和群友线下吃饭也有聊到,不知不觉,我在现实中的大学毕业也将近四年了,离学生的生活状态还有本科阶段的迷茫也越来越远。有的在我身边圈子已经理所当然的观念,我很难再从一个抽离的视角去强调。

未来我有我需要关注的生活,入门指南相关的部分,估计也只能先放下。因此就入门指南而言,我的想法也是,先将其完篇,剩下的细分领域方面的内容完善,就留给后辈去做吧。

再看 把博客作为知识管理的一部分 的感想再看 把博客作为知识管理的一部分 的感想

接下来在运营上,除了日常的约饭,估计可能试试搞些 Linux、Web 开发相关的一次性分享活动,看能否激励下后辈在这方面做些事情;也许还可以尝试做一些无主题的讨论,乃至于在播客等自媒体方面有些探索。从一个务虚的方向来看也是,希望在一个持续长期的投入之间,能发展出专注沉淀不浮躁的计算机学习文化,以一个相对较好的氛围,去支撑我自己和身边朋友的持续成长。

书影音游

回看我这一年在读书观影等文艺向的活动,算是一个少而精的状态。感怀于时间精力的有限,也希望心灵多少能有些走在路上,这里作些简单记录,就不太仔细展开了。

今年的读书侧重于个人成长方面,陆续看了比较有感触的几本,刘未鹏《暗时间》、阿德勒《自卑与超越》、梁永安老师的《阅读、游历与爱情》、斯科特派克的《少有人走的路》,还有前文说到的《用户故事地图》,偶尔会再翻翻《黑客与画家》。

电影电视剧方面,今年比过往看得多一些,也有不少的感触和感动。受同事的影响,去了几次线下影院多点,年中时候B站重温了下《音乐之声》;在电影院看了理想主义向的《宇宙探索编辑部》,虽然我不太能 get 到男主的执着,我所期待的理想主义,更多是能落地的那种;中间看过主打唐诗三百首的《长安三万里》,还有漫威 / DC 视觉向的《闪电侠》、《碟中谍7》;年底搬家入了个大电视,在晚饭之余看了《无法成为野兽的我们》,还有《肖申克的救赎》。

日常听歌方面,循环的重心从许巍慢慢转向了 伍佰 & China Blue,除了他演唱会常唱的几首,《白鸽》还有《枫叶》我也挺经常循环,还有闽南语的《世界第一等》。越来越喜欢伍佰身上某种历经沧桑后的一点洒脱与浪漫,和我现在心境好像也有些共鸣,虽然我年龄不到 25 岁,但好像也经历了不少的无奈与成长。

今年在音乐上多了许多现场的成分,5月去看了一场拼盘演出,主办方和场地音响等浓浓的割韭菜气息,但还是被 逃跑计划 & 夏日入侵企画 & 房东的猫 的那种扑面而来的真诚和感染力所治愈。端午朋友来深,一起去看了《歌剧魅影》,后来零星冲了几场音乐会,挺喜欢那种“高雅艺术”震颤心灵的感觉。

吉他练习还在继续,相对较挑战的 F / Bm 和弦、以及常用和弦的转换目前初步熟练,可以刷一些简单的和弦走向了。进一步大概得关注一番乐理相关的知识,不过意识到自己的精力有限,且这里不是我主攻投入的领域,所以就先佛系推进吧。

骑车通勤时会听听播客,偏向于技术和商业、投资理财等方面。不知不觉把《内核恐慌》多年的老节目都听过了一轮,听 Rio 和 吴涛 唠嗑各种计算机的各种技术细节等等,带来很多有趣的脑洞发散,一些属于技术宅世界的陪伴感觉;其中顺藤摸瓜听了蛮多期 Rio 的《疯投圈》,聊到消费与品牌营销相关的话题,解锁了一些新的视角。偶尔听听孟岩整的《知行小酒馆》《无人知晓》,蛮喜欢 E10 让万物穿过自己 聊到的,每个人都有着各自不同的认知与情感的“房子”;在某种抽离的视角去观察人类、反思自己应该是什么样的生活状态,也是一件挺有意思的事情。

今年比较大件事是《塞尔达传说:王国之泪》终于上线,兴冲冲买来卡带,可惜有心思玩的时间不是太多,周末有心情有时间时打一打,或者带带亲戚小朋友,大半年来游戏时长不过 30 多小时;另一个感觉很不错的游戏是《Fitness Boxing 2》,感觉是非常适合我等肥宅的运动起步方式,稍微玩玩就能出一身汗。

日常

总的来说,这一年没怎么出去玩,忙碌于工作与生活为主,伴随着一些变化。

一点变化是今年我终于下定决心开始考驾照,过程比较漫长,大概五六月份开始刷题,7月份考科目一,9月份连续三周早晨七点出门去练车场练科目二,10月份放空了一下,11月连续练了两周的科目三,幸好在12月一次考过终于拿到证,免去了许多来回拉扯、请假调时间的痛苦。总体感受上偏应试教育,不过对开车这件事本身,一波流程走下来,确实恐惧感要小很多。报名时选的 C1,现在来看我日常能接触到的手动挡的车,应该只有教练车吧,在路况复杂情况下连续换挡确实略有些痛苦,理论上 C2 驾照已经足够。

另一点变化是今年多了一丢丢的理财意识,最初我对于金钱是一个完全无所谓的无欲无求态度,随着自我的成长,也慢慢意识到,出来打工,并不是拼命赚钱存着就完事儿,还有许多问题和风险是需要关注的,保持现状反而可能是更坏的选择。因此也开始学习了一下相关的知识,划分了一下大致可能花钱目标,参考 四笔钱 的思路,适当地按回报与流动性的不同预期分了一下,放到银行定期等各类理财产品;并为自己与父母配置了一些保险,在经济理性的层面,适当对冲一下生活中潜在的各类风险,借此也放下了许多的焦虑与不安。

年底公寓到期,搬了个家,从公寓搬到小区房,也终于有了独立的厨房和客厅,外加适合看片打游戏的电视和音响。其中进一步提高了一点预算,与老一辈能省则省的观念有些冲突,在积极的沟通下相互有了许多理解。接下来解锁了日常做饭的模式,主打一个照着小红书教程糊弄熟能吃状态,下班回家开始捣鼓,一份晚上吃,一份顺便可以带饭到公司,还是挺舒适的。

事实上我爸妈在行动上表示真香 🤭,开始寄一些家乡菜来深圳,慢慢我可选的食材从万能番茄、万能生菜、万能鸡蛋、万能胡萝卜、万能猪肉 / 排骨、万能生菜油麦菜,再扩展到西兰花、家乡的腊肠、鱼干、酸菜、鸡肉等等等。周末做做饭煲煲汤,确实非常治愈和放松。未来可能再进化一下,整点减脂餐啥的,保持一个健康的饮食习惯。

感情

偶尔和家人、亲戚聊起恋爱方面话题,感觉就我当前的生活状态来看,单身应该会是一个常态,工作学习的社交圈,在异性接触上也相对有些局限:在深圳的圈子大多家境尚可、难以理解小镇做题一路出来的艰辛与割裂;在家乡的圈子,大都有了比较大的认知差异,总不可能强求对方为了和我同频、继续这样的折腾与破圈吧?因此我这两年开始观察一些偏相亲导向的社交 App、小红书的交友帖等等,在一些问题的引导下,思考我自身的一个定位和方向,还有在深同龄人的生活状态。

有些难过于现代社会的原子化,以及在这背景之下部分年轻人对待恋爱与婚姻,近乎可怕的现实乃至算计。最近看到一段项飙老师与许知远 在《十三邀》的一段对话视频 给我莫大的安慰,他们俩聊到一点,在现代社会中,我们逐渐丧失了在“附近”中构建爱的关系的自信,有的人回归于血缘构建的关系网络,有的人则通过理性计算的手段去实现各种“门当户对”。但基于经济理性的计算,有时候会破坏那种自然的“爱”的感觉。于现代人而言,如何应对这样的自信的消失,又尽量不破坏那种自然的感觉,以此稳步发展、构建出稳定的“爱”的关系,是一个需要大家共同面对的课题。

梁永安老师的《阅读、游历与爱情》在这里也让我有些启发,他说,现代社会更像是一个“游牧民族”、“海洋民族”、“农业民族”的多重属性结合,意味着有更复杂的生活状态互相交织。传统的二十五六岁开始买房成家立业等等压力,更多是传统农业民族的一个思维缩影,但到了深圳等一线城市,其实更像是一个游牧民族乃至于海洋民族的生活状态。这也就意味着这背后有存在很多矛盾冲突,处理这样的矛盾,没有太多前人的路子可以循着走,我们需要从中去探索适合自己的生活方式。

22年在B站看到一期 关于爱情的思考的 Vlog 给我很大震撼,大概聊到了同龄人在感情上的一些困境,以及比较理想自洽的恋爱形态。再结合一些我所观察到的农村传统社会与现代社会的差异点,以及在网络中看到的风格各异的价值观和思维模式,慢慢思考和观察自我,做了个大致的总结,也就是这篇 对恋爱与婚姻的抽象观察

这样对恋爱与婚姻的思考,也和我学习理财的思路类似,从理性的视角,大概对自身的长短板、观念和期待等等,有个兜底的预期,借此放下一些焦虑,在能力圈内腾出一个自由探索的空间,避免被中间穿插的理性现实成分影响太多,以此可以容纳更多的相对感性的“爱”的成分。由此,我对未来会持一个乐观态度,更多就交给缘分好了。

希冀

随着认知的提升,做选择的过程,伴随着许多有益的思考,这其中也带着许多前人智慧的延续,我想这也是读书学习最大的意义所在,由衷地感激所有陪伴、启发与指导过我的朋友、老师、前辈们。

当前的经济大环境处于一个低迷周期,个人对此在策略上也是,不与周期做太多对抗,不强求大富大贵,只求身体平安健康,保持知识与财富的持续积累,过好当下,知足常乐,行稳也致远。

但生活的锤子仍接踵而至,还是希望未来能比较平稳地接住它们,保持住一份对生活的热情。摘录一段这两年来我一直很喜欢也很打动我的话,来自林沛满前辈的《一个技术男的自白》

关于生活,IT男们已经被打上了太多标签:宅、木讷、生活简单。这当然是一种偏见,至少我身边的朋友就不是这样。不过比起国外的工程师群体,我们的业余生活似乎是单调了些。比如与我合作多年的国外同事中,有组乐队的、当冰球教练的、玩帆船的、DIY花园的……有些朋友对此羡慕不已,以为发达国家才玩得起多样化的娱乐,对此我不敢苟同。比如中国学习乐器的人数早就全球第一,在我屈指可数的女同事中,至少有三位在小时候考过钢琴十级。我所住的小区一楼都配有朝南的大院子,园艺条件极佳,只是户户都铺砖硬化了……所以细想起来,经济上并不是主因,只是不够热情罢了。工程师本来就是最擅长DIY的群体,只要行动起来,完全可以让业余生活更加丰富,成为一个更加有趣的人。

新的一年,没有太多额外的期待,希望可以多出去走走看看吧。

适合个人网站的云服务组合

2023-12-21 20:00:00

回想起来我折腾博客和各类网站已将近 10 年光阴,在维护 0xFFFF 的几年来,也尝试过各类不同的建站方案。当前用到的各类服务,综合速度和成本而言,感觉可能的选择方向差不多已经成型,可以做一个总结。

若你也想低成本搭建属于自己的网站,且想拥有不俗的访问速度,这里的方案也许可以是你的一个参考。

基本结构

抽象地说,运行一个网站需要的能力主要分两大块,一是域名与 DNS 解析,二是负责处理浏览器请求的服务器端。从需求来看,域名和 DNS 的能力相对比较固定,而服务器端需要的能力就比较多样,按角色和常用的业务来区分,可以分为这几类:

  1. 接入层网关、反向代理:接收用户流量,转发到对应的服务
  2. 静态页面:HTML 网页 / Jamstack 应用的 Service-side Rendering
  3. JS / CSS 等:通常会通过第三方域名 CDN 分发,提高网页的打开速度,减小对主站的依赖
  4. 媒体类附件:网站流量消耗的大头,通常托管在对象存储,也可通过 CDN 分发
  5. 动态服务:当一个网站需要交互的能力,需要有服务承载相关的 API 接口

动态服务而言,大致可以按“无状态服务”和“有状态服务”去区分,无状态服务可以随时创建和销毁,有状态服务则需要持久化服务产生的数据。部分服务可能还需要数据库和缓存服务等的支持,类似 MySQL、PostgresSQL、Redis 等等。

当前云计算已经足够发达,基本你能想到的点,都有对应的云服务提供,因此也可以根据实际需要和网站的功能,来选择合适的服务去支撑。写这篇文章的主要目的,也是分享我当前在用的服务,以及选择 / 不选择它们背后的一些考虑点。

DNS 与接入层网关

DNS 方面我选择 DNSPod,考虑点在于它有中国大陆内的解析节点,并且在专业版可以支持全球服务器的解析,价格还可接受。最关键是它提供了精细化地在不同的地域去做分流解析的能力,这对于部署在海外的站点是非常有利的,可以针对大陆用户去专门部署优化的线路,相比于 Cloudflare、ClouDNS 等会更加灵活。

接入层网关方面,关注目标在于,地域上离用户越近越好,可以针对不同地域用户使用针对其最优的线路,以减少网络层面的不确定性,尽可能和用户维持稳定的连接。对应的服务选择,一是支持动态请求链路优化的 CDN 服务,二是网络条件较好的云服务器,三是现今一些支持边缘计算的网站服务商(如 VercelFly.io 等),通常它们的边缘节点之间的链路会做一些特殊优化,可以合理使用利用起来。

这里我选择二、三两种方案结合,以一个阿里云香港的轻量服务器来承接来自大陆的流量,其他地域的流量通过 fly.io 承接,用 DNSPod 实现按地域的分流。目前(2023年底)的阿里云 hk 轻量服务器的网络质量非常优秀,全大陆访问的延迟可以 控制在 50ms 内;Fly.io 的好处则在于,他们有一个连通全球的边缘计算网络,理论上我只需要在我想要的地域部署服务,其他地域可以直接通过边缘节点接收用户请求,然后再在内部转发到服务所在地域的计算实例,网络请求相对也会更加可控一些。两者结合下来,实现各自中国大陆内外优势的互补。

对于反向代理的程序,这里直接用 Caddy Server,无论在轻量服务器还是 fly.io,只需做一些简单配置,就能实现流量转发,并自动申请签发配置好 https 证书,非常简单方便。

:80 {
  redir https://0xffff.one
}

0xffff.one {
  reverse_proxy http://123.123.123.123:80 {
    # 可以在 header 加个标记供下游服务器识别来源
		header_up +From-HK "true"
  }
}

静态页面

众所周知,大部分网站、Web App 等实质上由静态的 HTML 网页以及各种脚本、样式图片音频资源等组合,因此托管静态页面和资源,应该是建站最基本的需求。

这方面的托管,基本没什么障碍的点,无非就是维护成本方面的差异,无论 VercelFly.ioGitHub PagesCloudflare Pages,还是自己架 HTTP 服务器部署,都没有什么毛病。部分服务还提供了适应静态页面渲染的边缘节点。

当前我是用 Vercel 多一些,本博客和 0xFFFF Wiki 都放在这上面,主打一个简单直接方便,push 代码直接就能自动触发构建和部署,非常省心。

静态资源、CDN

浏览器下载 HTML 后,通常需要加载脚本、样式、图片等资源,这会带来一个额外的流量消耗。接入层网关本身的带宽成本相对要贵一些,且访问速度方面不太可控,通常会单独对 HTML 外的资源,去根据访问情况做分布式的缓存。这也是 CDN 的一大作用,CDN 以大带宽低成本的优势,分摊源站点的流量,提升用户的访问体验。

这里我主要选择了 Bunny CDN,以及腾讯云 CDN 做大陆的静态资源加速。前者在价格和速度方面非常有优势;后者结合已备案域名,在大陆的访问体验更佳。主要考虑的点还是在速度方面的提升。

媒体类附件

一个网站的静态资源通常分为两部分,一部分是网站本身业务逻辑的一部分,这部分可以提前处理好交给服务托管;还有一部分是用户操作过程中新增的上传、后台处理需要的图片等,这种类型的数据就很适合放在对象存储上。

适合个人站长的选择不是太多,这方面鼻祖自然是 Amazon S3,不过传闻贼贵,不是一般学生可以承担的那种;另外还有国内大厂的 COS / OSS,Cloudflare R2,Backblaze B2 等。

Cloudflare R2 是很好的选择,它流量费全免的机制非常适合新手上路研究,但 Cloudflare 要求绑定的域名的 NS 记录都指向他们的 DNS 服务器。也就是说会和 DNSpod 的分流策略所冲突,无奈忍痛放弃。

经过一番尝试,发现 Blackblaze B2,这一服务在成本方面极具优势,虽然没有 Cloudflare 的 CDN 免流量费香,配合 Bunny CDN 去处理,效果还挺不错,0xFFFF 主站重度使用半个月下来,流量也才 2G 左右,费用 0.04 美分。

注册时记得选择 US West,距离大陆近一些,注册成功后改不了。注册时记得选择 US West,距离大陆近一些,注册成功后改不了。

Bunny CDN 和 Backblaze B2 有 合作,可以优化接入相关体验。B2 官方还会有一些教程和文章,做的还挺贴心细致的:AWS CloudFront vs. bunny.net: How Do the CDNs Compare

动态服务

对于功能更强大的网站,静态的网页文件不太能满足需求,这时候就需要有后端服务支持,通常这类后端服务可能会提供以下的能力:

  1. 动态生成 HTML
  2. 根据请求 API,动态生成响应,驱动业务逻辑
  3. 可能根据需要,生成其他资源(图片 / 待下载的文件等)

如开头所说,动态服务有无状态服务和有状态服务两种:

  • 无状态服务:fly.io 会更适合,它可以以 Firecracker 虚拟机的形势一键部署运行 Docker 镜像,并快速部署到合适的地域,减少网络延迟的影响
  • 有状态服务:用一个单独的 VPS 来处理比较合适,这里我继续用了阿里云香港的轻量服务器,业务规模不大的情况下,一台机器足矣。这里可选项有很多,腾讯云、AWS 的 LightSail、Vultr、Linode 等也是不错的选择,最重要是稳定和可靠

一个可行的方向是尽可能把服务做得无状态,可以部署到边缘节点的话速度会更具优势(但同时也需要考虑不同地域之间状态共享的问题)。

数据库

我原本比较倾向在本机去部署网站的数据库,但经历过一次误操作导致 论坛数据丢失 的事件,也让我心有余悸,一方面日常备份得加强,另一方面也在考虑把数据库维护的工作交给专业的 DBA 服务。

这类服务通常很贵很贵,但感谢互联网,低成本的 Serverless 托管 DB 方案还是有的,目前看 TiDB Cloud 和 PlanetScale 的免费额度能覆盖到,大概一点缺点是公网访问数据库,延迟会对应提高,需要做好 cache 的方案。目前亚太地区只有新加坡和东京机房,可能需要考虑把计算实例挪到新加坡区域(接入层可以不动)。

有的站点会依赖 Redis 做 cache 或者 DB,这时候也需要考虑备份和托管的方案,目前 Upstash Redis 应该还不错,看 fly.io 和他们合作搞了内部方案

如果业务涉及到大量 SQL 查询(博客 / 论坛等),还是需要单机部署数据库,那就需要从备份入手。这里的一点经验也是,在跑数据库的容器加入定时任务,然后定期 mysql dump 出最新的 sql,gzip 压缩作备份,再保存到备份专用的对象存储桶中,备份软件推荐 Deplicati,存储桶我用的腾讯云的 COS。

引荐链接

若本文对你有所帮助,有需要的话,可以通过下方的引荐链接注册对应服务,可以为我多增加一些账户余额:

参考