从传统 MVC 到同构:Web 发展历程

December 22, 2017

Web 技术一直在不断发展和演进,从最早的静态页面到如今的复杂单页应用,这其中经历了许多阶段和技术的迭代。本文将从最传统的 MVC 模式一直介绍到目前常用的同构模式,探讨各种技术的优缺点,帮助读者更好地理解 Web 技术的发展历程。无论是 Web 开发初学者,还是有一定经验的开发者,都能从中获得一些启示和思考。

最传统的 MVC 模式

浏览器发出请求,服务器收到后渲染出完整的 HTML 页面返回给浏览器,在这个阶段里大部分业务逻辑都包含在服务端端代码里,如果想要获取新的页面内容,需要重新发送请求,服务器再渲染出完整的页面返回给浏览器,即使实际改变的内容只有一个字也需要服务器重新渲染完整的页面。

这种模式最大的缺点在于客户端无法在不刷新页面的情况下动态更改页面内容,可以很明显看出性能的巨大浪费,以及用户体验的差劲

AJAX

在这个阶段,借助浏览器提供的 XMLHttpRequest 对象,可以实现在不刷新页面的情况下向服务器请求新的数据,然后使用 JS 动态渲染到页面中,但是点击链接的时候还是需要刷新整个页面,无法做到像原生 APP 一样的体验。

SPA (Single page application)

这个阶段和之前的大不相同,浏览器请求页面只会获得一个静态的 HTML 入口文件,例如:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <div id="root"></div>
  <script src="/assets/js/app.js"></script>
</body>
</html>

页面中的实际内容完全由浏览器里的 JS 渲染出来,客户端会维护自己的路由、控制器和模型,与服务器之间完全通过 API 来交流数据,此时服务端代码不再处理视图层相关逻辑,可以专注于数据。

整个流程通常是这样的:

  1. 浏览器发出请求
  2. 服务端返回 HTML 入口文件
  3. 浏览器下载并执行 HTML 入口文件里包含的 JS 文件
  4. JS 通过 API 向服务器请求初始数据
  5. 服务器返回数据后 JS 会结合数据渲染出实际的内容填充到页面中

这种模式可以在切换页面时避免刷新整个页面,做到类似原生 APP 的用户体验,但是也有两个缺点:

  1. 爬虫只能爬到 HTML 入口文件,SEO 不友好
  2. 首屏呈现时间比较慢,也就是第一次打开时,用户会看到比较久的白屏或者加载动画

对于 SEO 不友好的问题,有一种解决方法是对爬虫返回使用 Headless 浏览器预渲染页面,这种方法需要额外维护预渲染相关的逻辑,投入回报是个问题

对于首屏呈现时间慢的问题,通常是在 HTML 入口页面中返回首屏需要的初始数据,例如:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>
<body>
  <div id="root"></div>
  <script>
    window.__INITIAL_STATE__ = { data: '...' }
  </script>
  <script src="/assets/js/app.js"></script>
</body>
</html>

JS 可以直接读取 window.__INITIAL_STATE__ 中的初始数据,这样可以省下通过 Ajax 获取首屏数据的时间,效果比较明显,但是在 JS 代码下载、parsing,执行之前用户还是会看到白屏或者加载动画,而 JS 代码的下载和 parsing 又是比较耗时的,所以在移动网络下这个问题会尤其明显。

同构

所谓同构就是指,JS 代码可以同时运行在服务端和浏览器,凭借虚拟 DOM,现在服务器也可以渲染出 HTML 字符串了,例如 React 或者 Vue 这些使用了虚拟 DOM 的库和框架,它们可以运行的服务端和浏览器,同样的代码在浏览器中渲染出真实 DOM,在服务器中渲染出 HTML 字符串。

如果一个 SPA 使用 React 或者 Vue 构建而成,那么它可以很容易转换为同构代码,在同构代码中通常有以下几个注意点:

隔离环境特定代码

例如在 Node 环境中没有windowdocument 之类的仅存在于浏览器环境的对象,所以在我们写同构代码时要注意尽量避免副作用,例如:

// moduleA.js
const list = document.querySelectorAll('ul li')
export default list

上面的代码就不能称为同构代码,它只能在浏览器环境运行,如果想让它变为同构代码通常有两种办法:

  1. 将副作用封装到函数里
// moduleA.js
const getList = () => document.querySelectorAll('ul li')
export default getList
  1. 只在浏览器运行,例如在 React 中可以在 componentDidMount 中执行
class App extends React.Component {
  state = { list: [] }
  componentDidMount() {
    this.setState({ list: document.querySelectorAll('ul li') })
  }
}

共享状态

在浏览器环境很多状态天然就是单独的,但在服务端它们就是共享状态了,如果我们有一个 API 可以获取用户收藏帖子,如下:

import axios from 'axios'

const getFavoritePosts = () => axios('/api/posts').then((res) => res.data)

export default getPosts

上面的代码在浏览器中使用是没有问题的,浏览器会自动发送用户的 cookie 给服务器,但是在服务器环境中需要将用户的 cookie 手动传给 API 服务器,我们修改代码为如下:

import axios from 'axios'

const getFavoritePosts = (axiosInstance) => {
  const ins = axiosInstance || axios
  return ins('/api/posts').then((res) => res.data)
}

export default getPosts

此时,我们在服务端调用 API 时需要传入 axios 实例

import getFavoritePosts from './api'

class IndexPage extends React.Component {
  static getInitialProps({ axios }) {
    return getFavoritePosts(axios)
  }
  render() {
    return this.props.posts.map((post) => (
      <h3 key={post.id}>
        <a href={post.url}>{post.title}</a>
      </h3>
    ))
  }
}

这种做法固然解决了问题,但是每次使用比较繁琐,更好的办法是创建一个通用的 http 请求函数,由它负责动态适配当前运行环境,同时服务端的每个请求都需要携带当前请求的 cookie 和必要的 header 信息。

function request(requestConfig, req) {
  if (isServer) {
    // 从 req 读取 cookie 和 header
  } else {
    // 浏览器环境
  }
}

class IndexPage extends React.Component {
  static getInitialProps({ req }) {
    return request({ url: '/api/xxx' }, req)
  }
  render() {
    return this.props.posts.map((post) => (
      <h3 key={post.id}>
        <a href={post.url}>{post.title}</a>
      </h3>
    ))
  }
}

如果你喜欢我的内容,请考虑请我喝杯咖啡☕吧,非常感谢🥰 。

If you like my contents, please support me via BuyMeCoffee, Thanks a lot.