Normal view

There are new articles available, click to refresh the page.
Before yesterdayMain stream

真的不可以在 React 组件内部嵌套定义子组件吗?

By: prin
26 February 2024 at 00:00

最近在 Code Review 时,看到有同事写了这样的代码:

function TodoList() {  const [list, setList] = useState([]);  const TodoItem = useCallback((props) => {    return <li>{props.text}</li>;  }, []);  return <ul>{list.map((item, index) => <TodoItem key={index} text={item} />)}</ul>;}

有经验的 React 开发者肯定一下子就看出问题了:在组件内部嵌套定义组件,会导致子组件每次都重新挂载。因为每次渲染时,创建的函数组件其实都是不同的对象。

但是他又有包了 useCallback 让引用保持一致,好像又没什么问题……?

这波骚操作让我突然有点拿不准了,所以今天咱们一起来验证一下,用 useMemo 或者 useCallback 包裹嵌套定义的子组件,对 React 渲染会有什么影响。以及如果有影响,应该如何用更合适的方法重构。

TL;DR

先说结论:

永远不要在 React 组件内部嵌套定义子组件。

如果你有类似的代码,请使用以下方法替代:

  1. 把子组件移到最外层去,将原有的依赖项作为 props 传入
  2. 把子组件改为「渲染函数」,通过调用函数插入 JSX 节点

为什么?请接着往下看。

组件重新挂载会造成的问题

来看这段代码(可以在 StackBlitz Demo 中运行):

function TodoList() {  const [list, setList] = useState([]);  // 嵌套定义子组件(好孩子不要学哦)  const TodoItem = (props) => {    return <li>{props.text}</li>;  };  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      {/* 渲染刚才定义的子组件 */}      <ul>        {list.map((item, index) => (          <TodoItem key={index} text={item} />        ))}      </ul>    </div>  );}

可能不少初学者都写出过类似的代码:JavaScript 语言可以嵌套定义函数,React 函数式组件就是函数,那 React 组件不也可以嵌套定义?

还真不是这么回事。我们来实际运行一下这段代码看看:

Tips: 这里使用了 useTilg 这个库来展示组件生命周期。

nested-component

可以看到,每次点击 Add 按钮在 <TodoList/> 列表中添加元素时,之前旧的 <TodoItem/> 组件实例就会被卸载 (unmount)、销毁。React 会创建全新的组件实例,然后再挂载 (mount) 上去。

也就是说,这些组件实例全都变成一次性的了

这还只是一个简单的示例,如果是实际应用场景,一个组件和它的子组件中,可能包含了成百上千个 DOM 节点。如果每次状态更新都会导致这些组件和对应的 DOM 节点被卸载、创建、重新挂载……那应用性能可就是「画面太美我不敢看」了。

更严重的是,组件的卸载还会导致其内部的状态全部丢失

那怎么会这样呢?这要从 React 的渲染机制,以及 Reconciliation 流程说起。

React 渲染机制之 Reconciliation

我们知道 React 的渲染大致可以分为两个阶段

  1. Render 阶段:执行组件的渲染方法,找出更新前后节点树的变化,计算需要执行的改动;
  2. Commit 阶段:已经知道了需要执行哪些改动,于是操作真实 DOM 完成节点的修改。

其中,「找出变化 + 计算改动」这个过程就被叫做 Reconciliation (协调)。React 的协调算法可以在保证效率的同时,最大程度复用已有的 DOM,使得对 DOM 做出的修改尽量小。

render-and-commit

▲ 图片来自:Render and Commit – React

那么问题来了,React 怎么知道一个组件对应的 DOM 需要更新呢?

简单来说,React 在比较两棵 Fiber 树时,会从根节点开始递归遍历所有节点:

  1. 如果节点类型和之前一致
    1. 对于 DOM 元素,保持元素实例不变,仅更新有变化的属性
    2. 对于组件元素,需要重渲染的,使用新属性调用组件渲染方法
  2. 如果节点类型有改变
    1. 卸载该节点及其子节点 ⚠️
    2. 将对应的 DOM 元素标记为待删除
    3. 创建新的节点
    4. 将新的 DOM 元素标记为待插入

上面所说的子组件被卸载再挂载、状态丢失等问题,其实都是因为它们被判断为了「节点类型有改变」

引用相等性与组件重渲染

在 JavaScript 中,比较值时有两种相等性:

  • 值相等性 (Value Equality),即两个值类型一致,内容也一致
  • 引用相等性 (Reference Equality),即两个对象的引用在内存中指向同一块区域

举个例子:

// 两个长得一样的对象const a = { name: 'Anon Tokyo' };const b = { name: 'Anon Tokyo' };// "引用相等性" 比较 - falseconsole.log(a === b);console.log(Object.is(a, b));// "值相等性" 比较 - trueconsole.log(lodash.isEqual(a, b));console.log(a.name === b.name);

JavaScript 函数也是对象,所以这对于函数(React 组件)也成立。

到这里问题就比较明朗了。

function TodoList() {  const [list, setList] = useState([]);  // WARN: 这个语句每次都会创建一个全新的 TodoItem 函数组件!  const TodoItem = (props) => {    return <li>{props.text}</li>;  };  return <ul>{list.map((item, index) => <TodoItem key={index} text={item} />)}</ul>;}

在这段代码中,每次 <TodoList/> 组件重渲染时(即 TodoList 函数被调用时),其内部创建的 TodoItem 都是一个全新的函数组件。

虽然它们长得一样,但它们的「引用相等性」是不成立的。

回到上一节介绍的渲染流程中,React 在比较节点的 type,使用的是 === 严格相等。也就是说像上面那样不同的函数引用,会被视作不同的组件类型。进而导致在触发重渲染时,该组件的节点及其子节点全部被卸载,内部的状态也被全部丢弃。

如果我用 useMemo 包一下呢

到这里我们已经介绍了「在组件内部嵌套定义组件」会造成问题的原理。

这时候可能就有小机灵鬼要问了,既然组件每次都被判断为是不同 type 的原因是对象引用不同,那我用 useMemo / useCallback Hooks,让它每次都返回相同的函数对象不就行了?

能考虑到这一层的都是爱动脑筋的同学,点个赞!让我们再来试验一下:

function TodoList() {  const [list, setList] = useState([]);  // useMemo 允许我们缓存一个值,每次重渲染时拿到的缓存是一样的  // 这里我们返回了一个函数组件,让 useMemo 把这个子组件的函数对象缓存下来  const TodoItem = useMemo(    () => (props) => {      return <li>{props.text}</li>;    },    []  );  // 或者用 useCallback 也可以,都一样  // const TodoItem = useCallback((props) => {  //   return <li>{props.text}</li>;  // }, []);  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      <ul>        {list.map((item, index) => (          <TodoItem key={index} text={item} />        ))}      </ul>    </div>  );}

nested-usememo

可以看到,包一层 useMemo 之后,子组件确实不会再被 unmount 了。看起来十分正常呢!

让我们再拿 React.memo 来包一下,在 props 相同时跳过不必要的重渲染:

const TodoItem = useMemo(  () =>    memo((props) => {      return <li>{props.text}</li>;    }),  []);

nested-usememo-memo

OHHHHHHHHH!!

如果一个东西看起来像鸭子,叫起来也像鸭子,那么它就是鸭子。

同理,如果我们通过一系列操作可以让「嵌套定义的 React 组件」在渲染时表现得与「在外层定义的组件」一致,那是不是就意味着这种操作其实也是 OK 的呢?

嗯……答案是:没那么简单。

稍微偏个题,你可能会好奇 Hooks 和 memo 为什么也可以在嵌套定义的子组件内正常使用,因为这看起来和我们平时的用法完全不同。

实际上不管是模块顶层定义的函数组件,还是嵌套定义的函数组件,在 React Reconciler 看来都是独立的组件类型,且在渲染时都有着自己的 Fiber 节点来储存状态,而定义该函数的作用域是什么并不重要。想想看:HOC 高阶组件有时候也会返回内联定义的函数组件,其实是一个道理。

useMemo 的缓存失效策略

第一点,useMemouseCallback 的缓存并非完全可靠。

在某些条件下,缓存的值会被 React 丢弃。如果缓存失效,函数组件就会被重新创建,同样会被判断为是不同的组件类型。React 官方肯定不会推荐你把 Hooks 用于这种歪门邪道的用途。

In the future, React may add more features that take advantage of throwing away the cache—for example, if React adds built-in support for virtualized lists in the future, it would make sense to throw away the cache for items that scroll out of the virtualized table viewport. This should be fine if you rely on useMemo solely as a performance optimization.

Ref: useMemo – React

第二点,useMemouseCallback 都有依赖数组。

虽然上面的示例里嵌套组件定义的依赖数组都是空的,但是我们再想想,什么情况下会想要在组件内部定义子组件,而非将其拆成一个单独的组件呢?最主要的原因就是,这个子组件想要直接访问父组件函数作用域中的某些变量。

function TodoList() {  const [list, setList] = useState([]);  const TodoItem = useMemo(    () =>      memo((props) => {        // 注意看,这里子组件直接使用了父级作用域中的 list 变量        return <li>{`${props.text} of ${list.length}`}</li>;      }),    [list.length]  );  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      <ul>        {list.map((item, index) => (          <TodoItem key={index} text={item} />        ))}      </ul>    </div>  );}

nested-usememo-memo-deps

从实际测试中可以看到,有了依赖项的 useMemo + 嵌套组件,又退化成了最开始的样子,每次都会被当成不同的组件类型,每次都会被 unmount。之前所做的努力全部木大!(顺带一提用 useRef 也是一样的,有依赖就歇菜)

也就是说,只有你的嵌套子组件完全不依赖父组件作用域时,才能保证 useMemo 的缓存一直有效,才能做到完全不影响渲染性能。但既然都已经完全不依赖了,那么又还有什么理由一定要把它定义在父组件内部呢?

重构包含嵌套组件的代码

所以我再重复一遍开头的结论:永远不要在 React 组件内部嵌套定义子组件。

因为这在大部分情况下会造成渲染问题,即使对这种写法做优化也没有意义,因为一不留神就可能掉进坑里,还有可能会误导其他看到你的代码的人。

如果你的代码库中已经有了这样的 💩 代码,可以使用下面的方法重构。

第一种方法,把子组件移到最外层去。

这种方法适用于子组件依赖项不多的情况,如果有之前直接使用的父级作用域中的变量,可以将其改造为 props 传入的方式。

// 组件定义移到模块顶层const TodoItem = memo((props) => {  return <li>{`${props.text} of ${props.listLength}`}</li>;});function TodoList() {  const [list, setList] = useState(['Item 1']);  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      <ul>        {list.map((item, index) => (          // 改造后:从 props 传入原来的依赖项          <TodoItem key={index} text={item} listLength={list.length} />        ))}      </ul>    </div>  );}

第二种方法,把子组件改为渲染函数 (Render Function)。

JSX 的本质就是 React.createElement(type),React 节点的本质其实就是一个 JavaScript 对象。你在组件 return 语句中直接写 JSX,和定义一个函数返回 JSX 然后再调用这个函数,本质上是一样的。

function TodoList() {  const [list, setList] = useState([]);  // 这不是函数组件,只是一个「返回 JSX 的函数」(函数名首字母非大写)  // 所以每次渲染都重新创建也没问题,也可以直接访问作用域内的变量  const renderTodoItem = (key, text) => {    return <li key={key}>{`${text} of ${list.length}`}</li>;  };  const handleAdd = () => setList([...list, `Item ${list.length + 1}`]);  return (    <div>      <button onClick={handleAdd}>Add</button>      {/* 调用的时候也和调用普通函数一样,而非组件的标签形式 */}      <ul>{list.map((item, index) => renderTodoItem(index, item))}</ul>    </div>  );}

不过需要注意的是,在使用「渲染函数」时,一定要搞清楚和「函数组件」的区别:

  • 渲染函数虽然和组件一样都返回 JSX,但它不是组件;
  • 渲染函数就是普通 JavaScript 函数,没有状态,也没有对应的 Fiber 节点;
  • 渲染函数只是当前组件的一部分,对于 React 渲染来说没有额外开销;
  • 渲染函数内部不能使用 Hooks,只有组件内部才能使用 Hooks;
  • 渲染函数命名一般以 render 开头,首字母小写(否则容易和组件搞混)。

另外,当渲染函数作为 props 传入其他组件时,它也被叫做渲染属性 (Render Props)。这种设计模式在 React 生态中有着大量的应用,可以放心使用。

结语

最后聊一下,如何避免这类问题的发生。

第一,配置 Lint 规则。

防范于未然,合理的 Lint 配置可以减少起码 80% 的代码规范问题。比如本文介绍的坑,其实完全可以通过 react/no-unstable-nested-components + react-rfc/no-component-def-in-render 规则提前规避。

最好再配合代码提交后的 CI 卡点检查,有效避免因开发者环境配置不当或者偷摸跳过检查,导致规则形同虚设的情况。

第二,定期 Code Review。

代码腐化是难以避免的,但我们可以通过流程和规范提早暴露、纠正问题,减缓腐化的速度。Code Review 同时也是一个知识共享、学习和成长的过程,对于 reviewer 和 reviewee 来说都是。

没有人一开始就什么都会,大家都是在不断的学习中成长起来的。

第三,了解一些 React 的原理与内部实现。

因为我自己就是吃这碗饭的,之前写过 React 的 Custom Renderer,也做过渲染性能优化,所以底层原理看的比较多,自然也就知道什么样的代码对性能会有影响。

我一直以来秉持的观点就是,学习框架时也要学习它「引擎盖下」的东西,知其然且知其所以然。如果你希望在这条路上一直走下去,相信这一定会对你有所帮助。


扩展阅读:

React 入门:实现基本登录注册功能

By: prin
10 February 2021 at 13:27

前端老年人复健篇,堂堂连载!

两年多没碰前端,感觉自己已经完全与时代脱节了。作为一个从 jQuery 一把梭时代过来的选手,对于现在那些「大前端」的玩意儿基本都是只懂个皮毛的状态(指看过 Hello world)。四年前我引用过这么一篇文章,《在 2016 年学 JavaScript 是一种什么样的体验?》,放到现在要是出个 2021 版本,估计会更唬人吧。

不过嘛,主要的技术其实还是那么几个:

  • 前端工程化(模块化、组件化、自动化等)
  • TypeScript(支持静态类型检查的 JavaScript)
  • ES6+(箭头函数、模板字符串、async/await 等新语法)
  • 三大框架(Angular、React、Vue)
  • 跨端技术(Electron、React Native、Flutter、小程序)

对于初学者来说可能是有些复杂,但应该还没到「求不要更新了,老子学不动了」的程度。

举个例子,最开始,大家都用原生 HTML、CSS、JavaScript 三板斧来开发网页。后来有人觉得原生的那些方法操作 DOM 啥的太麻烦了,于是弄出了 jQuery 和大家喜闻乐见的 $('#id');觉得代码组织不方便,于是弄出了各种模块化解决方案;觉得 Callback Hell 太傻X了,于是有了 Promise 和 async/await。觉得 JS 没有类型系统太容易出 BUG,于是有了 TS;觉得手动监听事件、操作 DOM 太麻烦了,于是有了数据绑定和三大框架;因为上面这些乱七八糟的最后都得拿去浏览器跑,所以有了 Babel、Webpack 和各种工具链;还有人觉得前端技术这么牛逼,光写网页怎么够,于是有了各种大前端、跨端开发技术……

如此种种,其实没有哪个技术是凭空蹦出来的,都是有需求才有人去做。如果你在实际开发中体会到了这个需求,那么这些新技术的出现也就变得理所当然了。而这也是我推荐新人学一门编程语言不要从框架学起的原因:比如从 Vue 开始学前端,从 Laravel 开始学 PHP,从 Spring 开始学 Java,很容易知其然而不知其所以然。

好了废话就到这里,今天咱们来用 React 实现一个基本的登录注册系统。


【后面的内容鸽了】

面向产品设计的 Web 前端分享会(分享会记录)

By: 李瑞东
2 July 2023 at 18:04

最近听说部门里面的产品或本地化运营对 Web 前端相关的内容比较感兴趣,正好我有相关的实践经验,所以在公司做了一个 Web 前端相关的分享会。分享内容包含:

  1. 使用 Devtools:介绍 Chrome 浏览器内的模拟、编辑和审查工具;
  2. 网页和部署:介绍 HTML, CSS, JavaScript, React,以及网站的部署和托管;
  3. 网页性能指标:介绍网页性能常用指标和测量工具;
  4. 资源分享:分享浏览器插件、网站和课程推荐。

与以往不同的是,这次分享会中加入了互动环节。我做了一个代码 Playground,尝试帮助观众了解 React,以及 React Props 的概念,并留了两个小任务,给观众尝试去实践对 React 项目进行编码。

完整的分享内容内容请继续浏览本文。

使用 Devtools

这个章节主要介绍 Chrome Devtools 一些可能不为人知的功能,来帮助我们提高日常工作中的效率和解决一些问题。先介绍 Devtool 里面「模拟」相关的功能。

模拟设备和屏幕尺寸

在 Devtool 里打开设备工具栏,在这里除了能够自由调整网页宽高,还能够模拟各种主流设备的屏幕。

甚至还能读取到网页里面的断点样式,提供快捷切换各种断点的方式。

需要注意的是,这里模拟的设备是会带上 UA 的,所以如果想在电脑里调试一些做了移动端特化处理的网站(比如访问主域名时,判断到是手机设备,则会跳到移动端的专门网站),是需要用到这个功能的。

模拟伪类

Devtools 还可以帮助我们排查各种交互状态下的样式问题,最常用的是,比如说我们想仔细排查某个元素的悬停和按下状态的样式,则可以在选中元素之后,勾选对应的伪类选项。

模拟媒体

在渲染面板(需要手动开启,浏览器默认是没有打开这个面板的)能够模拟部分系统设置,比如亮暗模式、打印模式、高对比度和减少动态效果等。

与之对应地,可以扩展一个概念叫做 CSS 的媒体查询,CSS 还可以探测到很多用户设备的属性或者设置,比如设备指针精度、视窗比例、当前是否全屏模式、设备方向等…

能探测的内容很多,但实际能用起来的可能只有寥寥数个,最全面的信息可以取 MDN 上查看。

编辑网页文字样式

Devtools 还提供了一个新的字体编辑器,能够让我们实时更改网页中的字体家族、字体大小、字重等属性。

编辑网页内容

我们在 Devtools 控制台里面执行代码document.designMode = 'on' 后,就可以实时在本地修改网页文字内容了,就跟平常打字一样。很适合用在测试文案长度的场景。最后也会分享一个浏览器插件,能够对网页做更多的编辑。

审查 React 组件

最后介绍一个审查 React 组件的方法,有时候我们想看某个元素是不是用的组件库,或者这个组件包含了什么属性之类的,可以下载 React Developer Tools,然后点选网页中的任意元素,进行审查。

网页和部署

接下来我介绍一下网页构成和网站部署相关的内容。

通常来说,HTML, CSS, JavaScript 是构成网站的三个要素。其中:

  • HTML 用来用于定义网页的结构和内容,可以用来创建网站的各个部分,比如标题、段落、图片、链接等。
  • CSS 用来定义网页的样式和布局,这个可能会是咱们设计师比较熟悉的部分,我们能够利用 CSS 来定义 HTML 元素的各种样式,控制它们的布局和位置。
  • JavaScript 用来实现各种功能逻辑和操作交互。比如响应点击事件、动态修改网页内容,根据条件执行动画效果或展示特定内容等。

CSS 预处理器

上述的三种语言,都有各自对应的语法规则。而 CSS 预处理器,则改进了原有的 CSS 语法,使我们能使用更复杂的逻辑语法,比如使用变量、代码嵌套和继承等。

简单来说,CSS 预处理器能让我们写样式代码的过程更顺畅,使代码有更良好的可读性和扩展性,帮助我们更好地维护代码。

举个简单的例子,比如原本的 CSS 语法中要求我们给每一个元素写样式时,必须以花括号开始和结尾,而且每一条样式规则直接都要以分号隔开,而 Stylus 则能够让我们跳出这个限制。直接用换行和缩进来代替。

CSS 框架

另一个值得一提的概念是 CSS 框架。CSS 框架则提供了一套预设样式,比如颜色板、字体梯度,布局和断点设定等;以及一些常用组件,如导航栏、对话框和页脚等。

简单来说,就是提供了一批开箱即用的样式,便于开发者快速启动项目,同时也会保留高度自定义的空间,用于支持各种各样的需求。通常 CSS 框架都会包含使用某个 CSS 预处理器,甚至内置了一些图标库,主打一个 “开箱即用”。

这里稍微介绍一下一个 CSS 框架:Tailwind CSS。是一个高度定制化的 CSS 框架,通过大量的预定义类名,使开发人员快速构建和设计网页界面。

与其他 CSS 框架相比,有一个显著的特点是 Tailwind CSS 本身不会包装一个组件出来,比如按钮、输入框的样式,没有预设好的。取而代之的是,Tailwind CSS 将各种原子级的 CSS 类名包装起来,比如:

  • 设置左右两边的 Padding,用 px-[...] 类名来实现;
  • 设置一个元素为块级元素, 用block 类名来实现…

如果想要在 TailwindCSS 中,使用打包好的组件,达到开箱即用的效果,可以通过各种官方/非官方的模版或组件生态来进行。比如:

React

接下来介绍另一个概念:React。这是一个用于构建 Web 和原生交互界面的库(是的,它能够用来做 App,不仅仅是网页)。而且引入了 JSX 语法,将 HTML 和 JS 结合起来,以一种更直观和易于理解的方式描述界面的结构和内容。

React 有一点和我们的设计稿很像,就是它的组件思维。在构建用户界面时,React 主张先把内容分解成一个个可以复用的组件(把具体的交互、功能逻辑写在组件里面)。然后在页面中将各个组件连接起来,使数据流经它们。

下图引用了官网中的一个例子,其中:

  1. 完整的应用,可以理解为由多个组件拼接成的完成网页;
  2. 搜索组件,用来获取用户输入;
  3. 数据列表,会根据用户搜索来过滤和展示内容;
  4. 每个列表的表头;
  5. 表格内每条数据。

现在我们用一个具体例子来简单介绍下 React 的组件。

在上图中,展示了一个页面页面 App.jsx 包含了 Profile、Gallery 和 FAQ 组件,以及 Profile.jsx 组件的代码。右侧是输出页面,展示了三个组件拼接而成的页面效果示意图,其中 Profile 组件模块里展示的内容,是和 Profile.jsx 文件内代码一一对应的。

上述的组件只是将一个模块包装起来,使其能够被其他地方复用。但组件内容是固定的。接下来会为大家展示如何向组件传递 Props,实现上文提到的一句话 “使数据流经他们” 。

在上图中,我们先将一些 Props 传递给组件 Profile(比如这里传递了图片的地址、人物姓名和描述),然后在 Profile 组件内接收这些 Props,并在组件代码内使用这些数据。

现在,我们就做出了一个可以复用的组件了,可以根据不同的内容来展示相关的人物信息。

大家有没有觉得这种做法有点熟悉?是的,在 Figma 中,我们的组件里面也有类似的做法。Figma 组件同样同样传递字符串、布尔和组件等内容。

实际上 React 组件可以传递的参数不仅仅只是上面例子中的字符串和布尔值,还能传递数值、函数、对象、Node 类型甚至另一个组件等。

我做了一个简单的 Playground,提前封装好了一个 Profile 组件,会传递一些字符串、布尔值(是否展示网站标签)以及数值(圆角大小),帮助大家更好地理解。

🛝 Playground

我做了一个 🛝 Playground ,大家可以在里面看到这个组件的具体的情况,实际看一遍代码可能会帮助理解 React 的组建和 Props 概念。

同时我也写了两个小任务给到大家去尝试,大家可以在上面的编辑器中自由尝试。

发布网站

到了这里,相信大家对构建一个网站已经有了初步的认识,接下来我为大家介绍下如何将构建好的网站发布的互联网当中,能够真正地被世界各地的人们浏览。

方法一:部署到服务器

这是比较传统的方法,先将项目相关的文件放进服务器里面(比如阿里云 ECS,或轻量服务器等)。然后在服务器内安装 NGINX,索引到项目文件夹,定义好首页、端口、404 等场景,最后将域名解析到服务器 IP。之后我们的网站就能在互联网上被人们访问了。

方法二:托管到服务商

这种是相对省心的方法,将我们项目所在的 GitHub 仓库,链接到服务商的托管服务当中。等于是由服务商来帮我们部署、发布项目,不用自己来配置服务器的各种内容了。下图列举了几种常见的网站托管服务商,分别是:Vercel,Github Pages 和 Netlify。

以 Vercel 来举例,除了能够托管网站之外,对每一次发布进行管理,甚至能够是对不同代码分支进行独立发布,还能收集网站访问数据等。

网页性能

接下来为大家介绍网页性能相关的内容。通常一个网站性能好不好,我们能够在体验时主观地感受到,比如打开时很慢、滚动时卡顿,或者点击按钮后很久才响应等等。但如果要准确地判断到网页的性能到底如何,是需要依赖具体指标的。

下面介绍三个常用的指标,分别是:FCP(首次内容绘制)、LCP(最大内容绘制)以及 CLS(积累布局偏移)。

FCP(首次内容绘制)

FCP 是一个关键指标,用来测量页面从开始加载到第一个页面内容在屏幕上完成渲染的时间。越快的 FCP 时间能够让用户感知到网页有在正常运行,而不是停滞、无响应。

这里提到的 “内容” ,指的是文本、图像(包括背景图像)、<svg>元素或非白色的<canvas>元素。如下图所示,FCP 出现在第二帧。

LCP(最大内容绘制)

LCP 指的从页面开始加载到可视区域内可见的「最大图像」或「文本块」完成渲染的时间。

这里提到的「最大图像」或「文本块」,具体来说是包含<img>元素、内嵌在<svg>元素内的<image>元素、块级文本等。

而测量方式,则是在页面加载过程中,记录视窗内的元素的渲染大小,取尺寸最大的元素,回溯这个元素被完整渲染的时间。注意,如果元素的有一部分在视窗外,在视窗外的部分不会参与到尺寸比较当中。

如下图所示,LCP 发生在第二帧,因为这个时候渲染尺寸最大的文本块被渲染出来了。后续帧当中,可能渲染出了一些图片,但尺寸都比文本块小,所以文本块依然是这个视窗内的最大元素。

CLS(积累布局偏移)

CLS 是指可视区域内发生的最大布局偏移分数。简单来说就是测量页面在加载时,元素的位置出现意外的偏移情况,如果元素尺寸大,而且位置偏移比较远,那么 CLS 分数就会显著增高。

这个指标会跟实际的用户操作或者体验有直接相关,所以应该也会是咱们设计师需要重点关注的内容,因为有时候布局偏移,是会比较影响用户获取信息、或者进行操作,甚至引发一些不可挽回的损失。

然后我来介绍一下测量网页性能的工具吧。我自己用过这两个,发现其实没啥差别,大家看喜好使用即可:

两个工具都能模拟桌面设备或者移动设备,记录多项关键指标的数据,并给出改进建议。

观察页面性能情况,不仅仅是前端技术人员要做的事情,了解到设计师也是可以参与到其中的。

比如 Guillaume Granger,他会比较想控制页面中 JavaScript 的数量,所以它提到,他会将所有用了 JavaScript 相关信息记录在表格当中。之后每次在网页中使用 JavaScript 时,都会跟之前的记录进行比对,判断重要性,决定是否在这个位置上使用 JavaScript。

开发者 Thomas Kelly 则提出了当意识到页面性能出现瓶颈时,需要做的事情,比如:

  • 制定一个目标,团队一起往这个目标前进;
  • 高频收集页面性能数据;
  • 尝试用不同方式来解决同一个问题;
  • 与同伴分享对性能的一些新发现…

资源分享

最后来分享一下相关的资源吧,包含两个插件、三个学习网站以及一个 React 课程。

插件:VisBug

介绍一个谷歌官方出品的插件:VisBug,主要用来帮助用户在浏览网页时进行调试和设计,包括编辑和可视化页面的 CSS,尺寸和字体等元素。

插件:Motion DevTools

Motion DevTools 是一个检查网页动效的插件,可视化和分析用户交互设计中的滚动、动画效果,并支持实时编辑、预览或导出等功能。

网站推荐

接下来介绍三个在国内外拥有较高知名度和影响力的设计师和开发人员。他们的观点、经验分享往往能给我带来一些新的启发。尤其是他们对钻研新技术的热情,是非常强烈的。

课程推荐

最后强烈推荐一门 React 课程——The Joy of React,这个课程我在年初的文章也有提到过,是以互动式课程的形式,由浅入深地讲解 React。从基础的组件 props 和 JSX 知识,到 Hooks、API 设计等等,讲述非常清晰,强烈推荐。

分享会感想

分享完之后感觉效果可能还不错,大家都有各自的收获。而且分享会中也不时有人提出相关问题,我也一一进行解答了。

或者也有对我分享内容的一些补充,比如我在分享完 Devtools 环节的时候,有同事也分享了一个在 Application — Cookie 面板里快速切换网页语言的方法。

后面了解到大家对于 CSS 和 React 那块听的比较迷糊,因为原本没有实践过的话,会对这些没有什么概念。而且大家好像对 🛝Playground 没有什么兴趣,并没有人对里面的内容有什么提问和看法之类的,可能到这一步都比较迷糊?🤔

指标那块倒是有不少同事关心,问了几个问题,比如有哪些方法来去改进几个指标的数据,或者在设计过程中是否可以提前避免性能问题等等。

总体来说和之前的分享会相比,这次分享会的参与度比较不错。

用 Next.js 重构个人网站 (二):核心和通用模块

By: 李瑞东
19 March 2023 at 11:12
封面图:Maxime Bourgeois

一部分我会记录下在重构 LRD.IM 项目中,部分核心或比较通用功能的实现过程,以及一些我花了点巧思来设计的地方。

数据源和循环遍历

我的网站中常常会有字段是能在多个地方复用的,比如作品的标题、描述等信息。

作品集首页字段应用的示意。应用在首页、作品详情页、head 标签、复制链接时的拼接。

这些字段会在多个页面中使用,当文案调整时也需要同步改动,影响到所有相关的地方。所以为了达到「一改全改」的效果,改进网站迭代的效率,这里我会用到数据源和循环遍历来做(以前还真就手动一个个来改的)。

数据源
数据源像是一个专门存储数据的库,里面记载了某个模块会被用到的字段名称及其对应的值。这里面本身没有功能、交互和样式,就像一个仓库。作用就是给到具体的页面或者组件来调用。

循环遍历
有了数据源之后会需要将其逐个安放进统一的容器里面。容器的样式是一致的,布局和适配也通过 CSS 来统一处理好,只是里面的文本、配图、跳转链接等这些属于数据源的内容有差异,所以这里会用到map()来遍历数据,将数据逐一安放进容器里。

功能实现

构建数据源
举个例子,「作品」页面包含了好几个字段:

  1. 公司名称
  2. 公司 LOGO 图片链接
  3. 公司官网网址
  4. 公司相关作品
  5. 作品名称
  6. 作品描述
  7. 作品缩略图链接
  8. 作品跳转链接

这其中「公司」和「作品」是一对多的关系,一个公司可以对应有多个作品。所以这里据源的结构是:

const ProjectItemData = [
{
name: "公司名称",
img: "公司 LOGO 图片链接",
url: "公司官网网址",
projects: [
{
title: "作品名称",
desc: "作品描述",
img: "作品缩略图链接",
url: "作品跳转链接",
},
],
},
];
export default ProjectItemData;

应用在实际页面中

数据源在我的网站里有两种使用方式:直接引用数据源里的某一个字段,或者通过遍历循环来填充进容器里面。

直接引用字段
比如作品详情页里面的 <head> 标签,需要将作品标题,siteMetadata 里的一个字段拼接,我会直接将他们引用到页面当中。

// 导入数据:
import siteMetadata from "/data/siteMetadata";
import ProjectItemData from "/data/ProjectItemData";

// 使用数据:
const title = ProjectItemData[2].projects[0].title; // 指定获取某个字段
<title>{title} - {siteMetadata.title}</title> // 应用在页面中

// 输出:
// <title>SHOPLINE App 组件库构建 - 李瑞东 LRD.IM</title>

循环遍历,将内容逐一填进容器
LRD.IM 的首页会列出我之前任职公司时的实际项目,实际上这就可以将一组数据按顺序地填入设定好样式和交互的容器当中。

我使用 Array.prototype.map() 来实现这个循环遍历。

import ProjectItemData from "/data/ProjectItemData";  //导入数据源
<>
{ProjectItemData.map((company) => ( //查找数据源中的一级数据
<div>
<Image src={company.img} /> //公司 LOGO 图片
<Link href={company.url}> {company.name} </Link> //公司名称,点击跳转到公司官网网址
</div>
<div>
{company.projects.map((project) => ( //查找数据源中的二级数据
<Link href={project.url}> //作品跳转链接
<Image src={project.img} /> //作品缩略图
<h3>{project.title}</h3> //作品名称
<p>{project.desc}</p> // 作品描述
</Link>
))}
</div>
))}
</>

影响范围

数据源循环遍历这个做法频繁出现在重构后的个人网站里。除了上面提到的首页作品列表,还有比如网站的 Header 导航栏、作品详情页平铺图片的模块、以及 What’s New 页面的内容等等。

map 遍历用在Header 导航栏、作品详情页平铺图片的模块、以及 What’s New 页面的内容的示意图

如果后续某一个模块的内容需要增删改,我只要修改数据源里面的内容,操作完之后在具体页面中检查一遍就可以了。再也不用在编辑器里打开数十个 html 文件逐个修改了。也不用在一堆代码高亮后的标签内寻找内容了。

一个模块或组件对应一个数据源,然后数据也和页面分离,👍🏻。

不得不接受的局限性

这个看似完美的功能实际上也会有一个令我有点难受局限性。前文提到一句话:

有了数据源之后会需要将其逐个安放进统一的容器里面…

这种按部就班,放进「统一的容器」里的做法会丧失灵活性,比如在:一组容器里面我想要特意给某一个容器添加样式,或者某个字段的值是字符串,我想给里面的某个词语设定一个样式。这似乎不容易办到。

具体来说,在我前年写的一篇「Apple 官网里三个令我惊叹的中文排版细节」里,我学会了给标题或重要文本的最后一个词语和标点设置一个不换行的样式white-space: nowrap;来防止出现孤字,这是需要在 HTML 内对单独每一句话来设定的样式。

重构前避免出现孤字的做法

而通过数据源来填充文本内容,我似乎没法进行这样的操作了。所以重构后的网站,一些地方可能会出现无法避免的孤字。

后续我会继续关注这个问题,尝试找到低成本的解决办法。这并非毫无希望,起码最近有了解到 React Wrap Balancer 这个组件似乎能改善孤字的现象,后续我会继续跟进。有新进展也会在博客里更新。

React Wrap Balancer 的效果预览动图。
当前的解决方案:关键内容/复用较少的内容会写在 JSX 中,而不使用数据源,给到最大的灵活性。而像大标题或副标题,则是使用了 word-break: keep-all; 的方式减少孤字出现的可能性(参考此处)。其他更细的情况无处理。

深色模式

重构前的 LRD.IM,网页的深色和浅色模式是只会跟随用户系统设置的,而这次做到的效果是:

  1. 默认颜色主题跟随用户系统设置;
  2. 允许有一个按钮来切换深色或浅色模式;
  3. 手动更改过颜色主题后,访问网站内其他页面也会受到影响;
  4. 页面加载、跳转时没有背景色闪烁。

深色模式在 Next.js 和 Tailwind CSS 结合使用的时候会变得无比简单。

功能实现

功能上我是跟着 Danilo Leal 博客里的这篇文章,一步一步实现适配到深色模式的。主要是使用了 next-themes 提供的能力。没什么难度和卡点。

样式支持

标题是 Tailwind CSS 3.2 的配图,使用科技风格的蓝紫色渐变作为背景,图片来自 Tailwind CSS 官方。
图片来自 Tailwind CSS

使用 Tailwind CSS 之后,设计深色模式的样式也变得简单很多了。只要在类名前加上dark:,比如这样:text-neutral-800 dark:text-neutral-100,dark:后面的内容就是当切换成深色模式后的样式。

而且在与媒体查询、:hover、:active 等效果配合使用时候,也可以直接叠加,不需要在 CSS 内过多的声明或嵌套。

比如一个按钮的背景色,在不同的颜色主题和状态(默认或 :hover)下色值会不同。同时我也希望在移动端设备尺寸下,不要有 :hover 改变背景色的效果。那么类名大概会是这样:

<Button
className="
bg-neutral-100 //浅色模式时,按钮的默认背景色
dark:bg-neutral-900 //深色模式时,按钮的默认背景色
sm:hover:bg-neutral-200 //视窗宽度是移动端以上时,:hover 状态的背景色
dark:sm:hover:bg-neutral-800 //深色模式且视窗宽度是移动端以上时,:hover 状态的背景色
">...
</Button>

在我看来,这种做法比传统的 CSS 做法更直观,不用专门语义化一个颜色,或者在冗长的 CSS 里维护两套主题色。所以得益于使用了 Tailwind CSS,我在写样式的时候效率提升了很多。

使用外部组件/图标库

这里可以记录一个我做成了响应式适配的组件:Header。在宽度足够时会将各页面入口平铺展示;而在移动端尺寸下,会收起在一个按钮中,点击后展开一个菜单,在里面提供页面和功能的入口。

拖动右侧手柄,缩小容器宽度来查看 Header 组件的适配的效果。默认是按钮平铺展示,容器宽度小到一定程度后将按钮收在汉堡包菜单中,点击图标出现相应的按钮。

组件

实际上我是通过媒体查询视窗宽度来实现的响应式适配。Header 操作区会分成两部分,一部分是平铺的按钮,另一部分是菜单功能。即:

  1. 当视窗宽度 > 移动端尺寸时,菜单功能的按钮被隐藏;
  2. 而视窗宽度 ≤ 移动端尺寸时,平铺的按钮被隐藏。
<>
<div className="hidden sm:block"> // 平铺的按钮,视窗宽度 > 移动端尺寸时出现
<Link>作品</Link>
<Link>博客</Link>
<Link>关于我</Link>
</div>
<div className="sm:hidden"> // Headless UI 的菜单组件,视窗宽度 ≤ 移动端尺寸时出现
<Menu> ... </Menu>
</div>
</>

至于展开的菜单,我是用了 Headless UI 的 Menu 组件来实现的(经过轻微改造),这里面内置了展开收起的动画,以及无障碍访问相关的特点。组件挺好使的,而且文档也很完善,提供了很多功能和描述。

Headless UI 的 Menu 组件界面截图

重构后博客文章里的目录,我也是用类似的方法来做响应式适配的。这部分的内容在下一篇文章中呈现。

图标

虽然说在工作上我做过不少图标,也负责过图标库的管理和迭代。但在这次重构 LRD.IM 的项目中,我没有自己去设计图标,用的都是外部资源。

因为在我看来图标只要能辅助表达出意思就行了,没有必要在这上面花太多功夫。把时间和精力放在其他更有意义的地方。

网站中我主要依赖外部的图标库 React-Icons。里面包含了很多主流图标库的内容,比如:Ant Design、Boostrape、Remix 等等。

React-Icons 网站界面截图

而我主要使用了 Ionicons 5 系列的图标,比较符合设计风格。用起来很简单快捷:

import { IoLink, IoList } from "react-icons/io5"; // 加载图标库

<IoLink /> //直接使用图标
<IoList className="text-xl" /> //为图标添加样式
使用框架来构建网站的好处是:能够很方便地用上各种插件和库。这是在旧版网站里不敢想象的事情。

The Joy of React 课程的收获

上一篇文章中提到在项目后期我参加了 The Joy of React 课程,在系统地学习了一些 React 的知识后对代码做了些改进,比如对图片展示组件的改进。

设计图片组件

在我的作品详情页面中,「图片」是一个会被频繁用到的媒体。而图片的呈现又有多种方式。

用三张截图分别展示图片组件的多种变量。从上至下分别是图片尺寸、图片描述、长图滚动和图片放大。

上图中能看到,我的图片组件会有六个属性允许配置或填写。

  1. src:填入图片的路径(必须)
  2. alt 文本
  3. 图片尺寸:大 / 小
  4. 图片滚动:允许 / 禁止
  5. 点击放大:允许 / 禁止
  6. 图片描述:内容 / 为空

而其中这几种属性可以互相组合,比如:

  1. 大尺寸图片 + 允许滚动 + 禁止点击放大 + 有图片描述
  2. 小尺寸图片 + 禁止滚动 + 允许点击放大 + 无图片描述
  3. 小尺寸图片 + 允许滚动 + 禁止点击放大 + 无图片描述

所以我在设计的图片组件时,开放了这些属性,允许在页面中自由配置某一张图片的样式和功能。同时将常被用到的属性会被设定为默认值,尽量减少在页面中的代码量。得出这样的一个 API:

export default function ProjectImage({ src, alt, size, scroll, zoom, children }) {
return (
<>
<figure className={scroll === true ? "scroll" : ""}> //scroll 为 true 则添加允许图片滚动的样式
<div className={`${size === "small" ? "max-w-2xl" : ""}`}> //scroll 为 small 则添加限制图片容器宽度的样式
{zoom === false ? ( //zoom 被设置为 false 时,使用不能点击放大的图片组件
<Image
src={src} //读取外部填写的图片路径
alt={alt} //读取外部填写的图片 alt 文本
/>
) : (<Zoom //zoom 被设置为 true (默认值)时,使用允许点击放大的图片组件
src={src}
alt={alt}
></Zoom>
)}
</div>
</figure>
{children && <figcaption>{children}</figcaption>} //children 非空时,将其渲染出来
</>
);
}

同时又存在着一个互斥关系,比如:允许滚动的图片,禁止点击放大;禁止滚动的图片,允许点击放大。(因为当下我用到的图片放大组件对长图片的支持并没有很完美),补充相应代码:

export default function ProjectImage({ src, alt, size, scroll, zoom, children
}) {
zoom = !scroll;
return (
<>...</>
);
}

最后在页面中使用该组件时,只需要填上必填的src,如果有其他尺寸或滚动上的属性配置,也传递进去就好了。

//默认:大尺寸图片 + 禁止滚动 + 允许点击放大 + 无图片描述
<ProjectImage src="01.png" />

//配置为:大尺寸图片 + 允许滚动 + 禁止点击放大 + 有图片描述
<ProjectImage src="02.png" scroll={true}>
一个文本示例。
</ProjectImage>

//配置为:小尺寸图片 + 禁止滚动 + 允许点击放大 + 有图片描述
<ProjectImage src="03.png" size="small">
另一个文本示例。
</ProjectImage>

可以对比一下在使用组件前后,源文件的对比。代码可读性提高了许多,同时也更方便管理和迭代了。

后如果我想有什么调整或者新功能,只要改动组件就好,再也不用在编辑器里打开数十个 html 文件逐个修改了。也不用在一堆代码高亮后的标签内寻找内容了。

用两张在代码编辑器里的截图展示使用组件前后,源文件代码量的对比。左侧是使用前,用红框标记代码量,一共 43 行代码;右侧是使用后,用绿框标记代码量,一共是 11 行代码。

图片组件比较常用,后面也会根据具体用途的变动和个人能力的长进来不断优化。

预告

下一篇文章将会介绍我是如何使用 mdx-bundler 来重构整个博客功能模块,包括 mdx-remark 插件、内容生成方式、目录组件、RSS 生成等内容。以及我为了增强阅读体验所做的工作。敬请期待吧~(自说自话 🙄)

本文于 2023 年 03 月 12 日首发于 LRD.IM

复刻在腾讯微博中的回忆

By: 胡中元
20 July 2018 at 21:47

大概是微博这个东西刚刚流行起来之时,也就是我初中的时候,我便用心的经营着我的腾讯微博,倒不是想要成为微博大咖,只是认为在同龄人坐在电脑前都只会打游戏时,我刷刷微博、发表一下自己的看法和见解,是更有意思的一件事。

然而腾讯微博迅速就被新浪微博超越,市场占有率几乎为 0 了。我自然也投靠了人多势众的新浪微博,但之前在腾讯微博中发的超过 1000 条微博是我的回忆 —— 中二青春。

我有一种预感,过不了多久腾讯微博就要被腾讯关停了,我可不能让之前写的那些碎碎念就这么消失,于是我用 Python 写了一个爬虫,将所有 [微博+图片+时间+转发微博+转发微博的所有信息] 都给爬到了本地数据库中,然后使用 React 做成了一个网站,名曰“复刻版腾讯微博”,将我发的微博放心地永远留在了自己的服务器中。

查看我的腾讯微博复刻网站,请点击:

https://hzy.pw/i/qqweibo/

## 基于服务器心情而工作的爬虫

截至目前,我的腾讯微博上共 1661 条微博,收听 65 人,听众 765 人。然而爬虫运行完毕之后获取到的微博数量为 1620,另外 41 条数据不翼而飞。我发布的微博和转发的微博中共包含了 1220 张图片,其中 6 张已被他们服务器丢失。微博中共包含 98 个视频,其中的 88 个均丢失(这是视频网站的锅,我们上传到优酷上的视频真的会被他们永远存放着吗,想想也是不可能的)。

微博中还包括了 785 条诸如 http://url.cn/482SZS 这样的短链接,其中 90% 均已失效,访问时直接提示 您访问的网址有误或该网址已过期 :( 此外,虽然 2011 年的微博也还给我留着,但所有微博的评论均没有了,数据被删掉了。。。

我想说的是,要是再不使用爬虫将这些宝贵的回忆取回,真说不定哪天就被腾讯给删掉了 ToT

讲真,各种复杂的情况都被我遇到了: 微博不提供 API,使用 Python 爬取 HTML 再解析,关键是 HTML 结构每次都会变,我花了很久很久的时间才适配了所有情况。另外服务器返回的数据并不可信,第一次得到的数据显示我在某一天发了 1 条微博,带有图片,再获取一次变成了发了 4 条,却无任何图片上传。(这不是腾讯为了防爬虫设计出来的,因为浏览器访问也是这样的,大概是腾讯微博在临死前,为了降低服务器负载而采用的拒绝式服务。。。)

于是我的爬虫在经过数天的完善后,拥有了应对前后数据不一致、连接握手失败、适应 HTTP 结构变化的功能。在此基础上又运行了四五天,才完成了爬取。因为对我那 1000 条微博的每一躺爬取,结果都是不一致的,直到最后连续运行十个小时也没爬出新数据后,我才认为是爬完了。

最终顺利爬取了能找到的所有数据,并存在了数据库里,真的是超级辛苦,让我激动的发了个微博(新浪微博~~)

数据清洗

数据清洗除了格式上的规范,还标记了一些重复的微博,这些微博在我的博客、空间里面重复,我的微博镜像站中没有必要包含这一部分内容。

此外为了制作微博镜像站,使用 Pillow 库将图片原图批量压缩成了 webp 格式的缩略图,在我的微博镜像站中,点击缩略图即可查看大图。 然而事实证明选择 webp 格式是错误的 ,虽然谷歌的 webp 格式拥有很高的压缩率,但是兼容性是个问题,不支持 Firefox、IE 和 iOS,几乎是只有 Chrome 能显示,所谓的 WebP JS 兼容性修复库其实是使用了 Flash 实现,然而后者本身就不值得使用。 所以说 WebP 格式的图片只适合客户端而不适合浏览器端。

最终我还是选择了 jpg 格式作为缩略图。毕竟我的服务器拥有 自动转换为 WebP 功能

愉悦的 React 开发体验

感谢 facebook/create-react-app 提供的脚手架,webpack+eslint+react 开发环境开箱即用。另外不得不感叹 React 的模块化使得逻辑相当清晰,很方便省心。

另外还要感谢 clean-blog CSS 主题lightgallery.js 图片灯箱插件

接下来

如果 QQ 空间、朋友圈、微博、豆瓣 这些网站在某一天宣布关停,我也会把自己的数据通通扒回本地,当我真心不希望这样,因为这个网站本身,就是一代回忆。

有空的话还要干几件事:试着统计下我发的微博中的一些有趣的数据,比如口头禅、文字情感之类的。再来就是把微博中的短链接替换成为长链接,因为正如上文提到的那样,很多短链接都在陆续失效了。

就酱。

现已完成,对我的腾讯微博的大数据统计挺有意思,请访问: https://hzy.pw/p/2569

❌
❌