偷师 Next.js:我学到的 6 个设计技巧
写在前面
最近在研究 SSR 的过程中,也对 Next.js 有了更多的认识:
设计技巧:本文
本文作为 Next.js 系列的第三篇(也是最后一篇),记录了我从中发现的设计技巧,包括 API 设计、文档设计、框架设计等,也分享给你
定义基类,可能不如定义模块
首先,类(Class)和模块(Module)都是组织代码的可选方式,放到 API 设计的场景,都能用来约束写法,暴露框架能力。而在模块概念成为正统之前,前端框架大多提供基类来满足这种需要,因为没得选
典型的,React 通过React.Component
基类暴露出各种生命周期 Hook,同时定义了组件写法:
// Components
class Clock extends React.Component {
// Props
constructor(props) {
super(props);
// State
this.state = {date: new Date()};
}
// Lifecycle
componentDidMount() { }
componentWillUnmount() { }
render() {
// Template
return (
<div>
<h1>Hello, world!</h1>
<h2>It is {this.state.date.toLocaleTimeString()}.</h2>
</div>
);
}
}
将 Props、State、Lifecycle、Template 等框架能力整合成一个 Class,称之为组件。并且,在很长的一段时间里,React 中能称为组件的只有 Class
这段很长的时间有多长?
从 React 诞生之初一直到React Hooks推出并进化成完全形态。目前(2021/1/2)React Hooks 仍然不是完全形态,componentDidCatch
、getSnapshotBeforeUpdate
、getDerivedStateFromError
等特性还不健全,具体见Do Hooks cover all use cases for classes?
也就是说,时至今日,React Components 仍等价于 Class Components,早期的函数式组件只能叫 Stateless Components,获得 Hooks 加持之后的函数式组件虽然摆脱了 Stateless,但与完全形态的 Class Components 还有一点点差距
将 Components 概念与 Class 强绑定在一起真是个糟糕的选择,被寄予厚望的 Hooks 充分说明了这一点。但 Props、State、Lifecycle、Template 这些框架能力又总要有东西来承载,那么,更好的选择是什么呢?
可能是 Module。强调可能,是因为仅在组织代码这一点上,Module 比 Class 更纯粹。Module 只组织代码,将变量、函数等语法元素圈在一起,而不像 Class 会强加实例状态、成员方法等额外概念
例如,Next.js 的 Page 定义就只是个文件模块:
// pages/about.js
function About() {
return <div>About</div>
}
export default About
最简单的 Page,只要默认暴露出一个 React 组件即可。需要用到更多功能,再按需暴露更多的既定 API:
// pages/blog.js
function Blog({ posts }) {
// Render posts...
}
// API 1
export async function getStaticProps() { }
// API 2
export async function getStaticPaths() { }
// API 3
export async function getServerSideProps() { }
// API n
export async function xxx() { }
export default Blog
对比 Class 形式的 API 设计,这种Module 式 API 设计更加纯粹,不强加额外语法元素(尤其是 Class 这种根基庞大的语法元素,带来一众super()
、bind(this)
、static
),在某些场景下不失为一种更好的选择
文件约定路由
Next.js 里没有Router.register
、没有new Route()
、也没有app.use()
,没有一切你能想到的路由定义 API
因为根本没有 API,路由采用的是文件路径约定:
// 静态路由
pages/index.js → /
pages/blog/index.js → /blog
pages/blog/first-post.js → /blog/first-post
pages/dashboard/settings/username.js → /dashboard/settings/username
// 动态路由
pages/blog/[slug].js → /blog/:slug (/blog/hello-world)
pages/[username]/settings.js → /:username/settings (/foo/settings)
pages/post/[...all].js → /post/* (/post/2020/id/title)
也就是说,通过源码所在文件路径来标识路由,甚至还能支持通配符,这么神奇,当然要亲眼看看源码目录才能感受到视觉冲击:
pages
├── _app.js
├── _document.tsx
├── api
│ ├── collection
│ │ ├── [id].tsx
│ │ └── index.tsx
│ ├── photo
│ │ ├── [id].tsx
│ │ ├── download
│ │ │ └── [id].tsx
│ │ └── index.tsx
│ ├── stats
│ │ └── index.tsx
│ └── user
│ └── index.tsx
├── collection
│ └── [slug].tsx
└── index.tsx
API 之间的无缝联动
通过前两篇文章,我们知道 Next.js 要解决的问题是预渲染,围绕预渲染探索出了 SSG、SSR 两种渲染模式,并在此基础上支持了包括 CSR 在内的不同渲染模式混用:
ISR(Incremental Static Regeneration):增量静态再生成,运行时定期重新生成静态 HTML
SSG 降级 SSR:未命中预先生成的静态 HTML 时,立即进行 SSR
SSR 带静态缓存:SSR 完成之后,将结果缓存起来,下次命中静态缓存直接返回(相当于 SSG)
SSG 结合 CSR:编译时生成静态部分(页面外框),CSR 填充动态部分(页面内容)
SSR 联动 CSR:URL 直接访问走更快的 SSR,SPA 跳转过来走体验更优的 CSR
从 API 设计的角度乍一看,似乎需要给每种组合取个别致的名字,并暴露出专门的 API,就像 SSGwithFallback、SSRwithStaticCache、PartialSSG、SPAMode…
然而,Next.js 不仅支持了所有这些混用特性,而且没有增加任何顶层 API,它的做法是增加一些选项,例如:
// SSG 基础款
export async function getStaticProps(context) {
return {
props: {}, // will be passed to the page component as props
}
}
// SSG 变身 ISR,给返回值添上 revalidate 属性
export async function getStaticProps(context) {
return {
props: {}, // will be passed to the page component as props
// Next.js will attempt to re-generate the page:
// - When a request comes in
// - At most once every second
revalidate: 1, // In seconds
}
}
// SSG 感知路由的高级款,实现了 getStaticPaths
export async function getStaticPaths() {
return {
paths: [
{ params: { ... } } // See the "paths" section below
],
fallback: false
};
}
// SSG 变身 SSR带静态缓存,fallback选项改为true
export async function getStaticPaths() {
return {
paths: [
{ params: { ... } } // See the "paths" section below
],
fallback: true
};
}
// SSG 变身 SSG降级SSR,fallback选项改为'blocking'
export async function getStaticPaths() {
return {
paths: [
{ params: { ... } } // See the "paths" section below
],
fallback: 'blocking'
};
}
这种基于细分选项的 API 联动用起来更轻量,始终保持带给用户的渐进式体感,不需要一上来就了解全部 API、相关设计概念,从顶层区分我的场景属于哪类,该用哪个 API,而是随着场景的深入,发现那个最合适的 API/选项就在那里
能从文档够明显地感受到这种差异,例如,Next.js 介绍 ISR 的地方将用户指引到与之关联的 SSR 带静态缓存模式:
Incremental Static Regeneration
With getStaticProps you don’t have to stop relying on dynamic content, as static content can also be dynamic. Incremental Static Regeneration allows you to update existing pages by re-rendering them in the background as traffic comes in.
This works perfectly with fallback: true. Because now you can have a list of posts that’s always up to date with the latest posts, and have a blog post page that generates blog posts on-demand, no matter how many posts you add or update.
积分、互动式新手教程
这一点算作文档设计技巧(文档,当然也要有设计),看过许多官方文档/教程,留下深刻印象的只有 3 个:
Redux 文档:故事性文档,手把手一点点把 redux 设计出来,读起来根本停不下来
Electron Demo App:交互式文档,准确地说是带完整文档的 Demo,在体验 Demo App 的同时了解相关特性用法,是比React 在做中学更偷懒的办法了
Next.js 教程:积分、互动式新手教程,几十页的教程一口气看完
P.S.Redux 文档指的是2017 年的版本,现在貌似改过许多版,读着很差劲了(这么点儿概念怎么能整出来那么多文档)
积分、互动式新手教程威力大到什么程度?
让我能在困到迷糊的状态下坚持看完教程的全部内容,答对所有测试题目,积满 500 分(当然,不用幻想,全对是没有任何奖励的),事后回想起来也觉得不可思议,其中的技巧在于:
教程与文档分离:导航栏一级菜单明确区分 Docs 与 Learn,教程中的部分概念有链到文档,但不看完全也完全跟得上
积分:教程醒目位置置顶展示获得积分,每点一篇加分
互动:关键章节有测试题,答对题目也加分,总积分可分享社交平台(Twitter)
如此看来,在文档中融入少量在线教育的成熟模式,可能效果极佳
默认提供最佳实践
读过体验科技与好的产品,对其中玉伯提出的默认好用印象很深,而 Next.js 算是默认好用在框架设计上的一个真实案例
例如:
Link 自动预加载
Image 自动懒加载
“自动”采用最佳渲染模式:这个自动不同于前两个,强调的是框架角度对用户按需使用特性的回应,由框架来判断渲染模式(该走 SSR 还是 SSG),而无需用户显式指定/切换
从生产活动的角度来看,最佳实践本就应该是默认提供的,将新出现的最佳实践不断地下沉到环境层,就像 npm package、ES Module、Babel 等,如今的前端开发者已经几乎不需要关心这些曾经的最佳实践
仅从框架设计角度而言,默认好用要求在提供最佳实践的基础上更进一步,要把最佳实践做没,让使用者能够偷懒地以为一切本该如此。因此,最佳实践只是一个临时态,尚未形成最佳实践的部分才是开发者需要关心,并体现差异化竞争力的地方,一旦形成广泛认同的最佳实践,就应该沉淀成为默认的基础设施,开发者无需关心即可获得这些最佳实践带来的种种好处
从尚未形成最佳实践,到提供最佳实践,到默认提供最佳实践,这 3 个阶段可以通过一个图片懒加载的示例来理解:
// 第一阶段:尚未形成最佳实践
scroll
IntersectionObserver
// 业务各自实现,不存在用法示例
// 第二阶段:提供最佳实践
React Lazy Load Component
// 用法示例
<LazyLoad height={683} offsetTop={200}>
<img src='http://apod.nasa.gov/apod/image/1502/2015_02_20_conj_bourque1024.jpg' />
</LazyLoad>
// 第三阶段:默认提供最佳实践
next/image
// 用法示例
<Image
src="/me.png"
alt="Picture of the author"
layout="fill"
/>
第三阶段与第二阶段的区别在于,开发者不必关心哪个组件能够提供懒加载功能(选择最佳实践),直接使用组件库中最普通的 Image 组件,该有的功能自然就有,而懒加载只是其中一项
向 Serverless 延伸
Serverless 浪潮之下,前端生态也正在发生着一些变化,涌现出各式各样的一体化应用:
以前端项目/后端项目为主体的一体化应用:如 Midway Serverless,支持集成 React、Vue 等前端项目
以 SSR 为主体的一体化应用:如 Next.js,支持将 SSR 和数据接口(API endpoints)部署成 Serverless Functions
Next.js 提供 SSR 支持,本就需要服务端环境,Serverless 的兴起很好地解决了 SSR 渲染服务的运维问题,因此,其 Vercel 平台默认支持以 Serverless Functions 的形式部署 SSR 服务与 API:
Pages that use Server-Side Rendering and API routes will automatically become isolated Serverless Functions. This allows page rendering and API requests to scale infinitely.
诸如此类的一体化应用虽未形成最佳实践,但传统的前端框架正在历经变革。也许,在未来的某一天,取而代之的是与 Serverless 技术充分融合的一体化应用框架,Universal 体系大行其道也未可知