现代 Web 应用开发有两种常见的架构方法:
-
服务端渲染应用 (Rails, Laravel, Django)
- 渲染:通过服务端模板
- 数据源:通过 ORM 访问数据库
- 路由:由服务端定义
-
单页面应用 (Vue.js, React, Angular, Ember)
- 渲染:通过 JavaScript 组件
- 数据源:通过 REST API 或 GraphQL 获取数据
- 路由:由 JavaScript 定义
本文的主要目标不是比较这两种方法,而是介绍一种我最近一直在使用的技术,它融合了两种方法中我最喜欢的部分:在服务端应用中使用客户端渲染。
一个奇怪的组合
开门见山,我的方法是这样的:
- 渲染:通过 JavaScript 组件 (Vue.js)
- 数据源:通过 ORM 访问数据库 (Active Record)
- 路由:由服务端定义 (Laravel)
我搭建是一个经典的服务端应用,使用服务端路由和控制器,没有使用 API。控制器从数据库中查找数据,然后传递到模板。除此之外,我没有使用任何服务端模板(如 Blade)。取而代之,我使用 Vue.js 在客户端渲染。如果感到困惑,请往下看。
Web 应用无法脱离 JavaScript
简而言之,如果不使用 JavaScript,我无法想象如何去构建一个 Web 应用。 如今的 Web 应用,需要使用大量 JavaScript 去实现各种交互功能。从一些高级表单控件如:日期输入和文件上传,到下拉菜单、模态对话框、动画效果、进度条等等。显然,通过使用控件实现的丰富交互体验是单页应用受欢迎的主要因素。
老方法:混合使用服务端和客户端模板
在进行客户端渲染之前,我将使用服务端渲染的模板,然后在模板之上使用 Vue 组件。这种方法对于使用过 jQuery 的 Web 开发者应该很熟悉,我的每个页面都是从服务端模板(Blade)开始创建,当需要交互的时候,我会在 Blade 模板中创建一个 Vue 组件。如果这个组件需要数据,我会以属性的形式传递数据给它。以下是示例:
<html>
<head>
// ...
</head>
<body>
<h1>{{ $event->title }}</h1>
<div>{{ $event->date->format('F j, Y') }}</div>
<div>{{ $event->description }}</div>
<div>{{ $event->category->name }}</div>
<edit-event
:event="{{ json_encode([
'id' => $event->id,
'title' => $event->title,
'date' => $event->date->format('F j, Y'),
'description' => $event->description,
'category_id' => $event->category_id,
]) }}"
:categories="{{ $categories->map->only(['id', 'name']) }}"
>Edit</edit-event>
</body>
</html>
最终的结果是一个应用结合了两种完全不同的方法进行渲染。这看起来混乱且令人困惑,无法查找模板,通过属性传递数据到 Vue 组件是显得非常繁琐(如上所示)。预测某些部分应该使用服务端模板还是客户端组件常常让人非常纠结:这里到底需要 JavaScript 进行交互还是简单的服务端模板就足够了?显然以上这种方法不太理想。
现代网络应用需要 JavaScrip
简而言之,如果没有某种类型的 JavaScript 呈现系统,我无法想象构建现代的 Web 应用程序。 Web 应用比以往任何时候都需要大量的 JavaScript 来进行各种可能的交互。从日期输入和文件上传之类的高级表单控件,到下拉菜单,模式,动画,加载指示符等等。显然,这是现代单页应用程序受欢迎的主要因素。
旧方法:混合服务器端和客户端模板
在进行客户端渲染之前,我将使用服务器端渲染的模板,然后使用 Vue 组件在 JavaScript 功能之上进行分层。对于在 jQuery 期间从事 Web 开发的任何人来说,这种方法听起来应该都很熟悉。我的每个页面都以服务器端 (刀片) 模板开头。需要交互时,我将创建一个新的 Vue 组件并将其放入 Blade 模板中。如果该组件需要数据,我会将数据作为道具传递给该组件。这是一个例子:
<html>
<head>
// ...
</head>
<body>
<h1>{{ $event->title }}</h1>
<div>{{ $event->date->format('F j, Y') }}</div>
<div>{{ $event->description }}</div>
<div>{{ $event->category->name }}</div>
<edit-event
:event="{{ json_encode([
'id' => $event->id,
'title' => $event->title,
'date' => $event->date->format('F j, Y'),
'description' => $event->description,
'category_id' => $event->category_id,
]) }}"
:categories="{{ $categories->map->only(['id', 'name']) }}"
>编辑</edit-event>
</body>
</html>
最终结果是一个应用程序,将两种根本不同的渲染方法混合在一起。这是混乱的,并且常常令人困惑。没有一个地方可以找到模板。将 prop 数据传递到 Vue 组件很麻烦 (如您在上面看到的)。试图预测某个东西是服务器端模板还是成为客户端组件,这一直是一个不断的努力。它需要一些 JavaScript 交互,还是一个简单的服务器端模板就足够了?不是理想的工作流程。
新方法:整页 Vue 组件
在结合使用 Blade 和 Vue 的时候,我发现了一个有趣的现象。对于某些页面,我已经把他们构建为单个 Vue 组件,这些页面是需要大量的 JavaScript 交互的,或者整个页面需要存在于 Vue 组件的范围之内,以便于我可以根据状态去决定隐藏或显示数据。这时我的服务端模板看起来是这样的:
<html>
<head>
// ...
</head>
<body>
<create-event :categories="{{ $categories->map->only(['id', 'name']) }}"/>
</body>
</html>
基本上是一个空的 body 标签内含单独 Vue 组件。很熟悉吧?它看起来就像一个单页应用,此时你可能会认为:“啊哈!Jonathan 你这是证明了单页应用更有优势!”
别这么快下结论。
虽然客户端渲染看起来是有着 “巨大” 优势,但我不认为这意味着应该放弃服务端路由,或是去编写 API 传递数据。
新方法:整页 Vue 组件
当我与 Blade 和 Vue 一起工作时,一个有趣的模式开始出现。我注意到对于某些页面,实际上我已经将它们构建为单个 Vue 组件。这些页面需要大量的 JavaScript 交互,或者至少需要存在于 Vue 组件的范围内,以便我可以根据某些状态隐藏或显示数据。我的服务器端模板开始看起来像这样:
<html>
<head>
// ...
</head>
<body>
<create-event :categories="{{ $categories->map->only(['id', 'name']) }}"/>
</body>
</html>
基本上只是一个带有单个 Vue 组件的空实体。看起来很熟悉吧?它看起来很像单页应用程序。此时,您可能会想 "啊哈!乔纳森,您刚刚证明单页应用程序更好!"
没那么快。
虽然我同意客户端呈现是一个巨大的好处,但我不认为这意味着我应该放弃服务器端路由,也不认为我应该去写一个 API。
客户端路由和 API 不是必须的
如果你要构建富客户端 Web 应用,几乎可以假定你会有支持它的 REST 或者 GraphQL API,以及客户端路由。但是,你也可以使用经典的服务端路由和控制器去实现。
这里区别非常明显:服务端不是通过应答 API 请求给前端返回数据,而是通过赋值前端组件的属性来传递数据。
不需要客户端路由和 API
如今,如果您要构建富客户端 Web 应用程序,几乎可以假设您拥有支持它的 REST 或 GraphQL API 以及客户端路由。但是,使用经典的服务器端路由和控制器,您绝对可以完成同一件事。
区别非常简单:服务器不是负责通过 API 向数据请求前端,而是负责通过 props 给前端组件提供数据。
也许是我老土,但我就是喜欢简单的服务端 MVC 组织方式,不喜欢复杂的状态管理 (抱歉 Vuex 和 Redux)。每次页面的刷新都能带来新鲜感,不用去纠结浏览器的 History API。只要点击链接,浏览器就能自动处理响应。即使通过客户端路由 处理 404 和其他响应非常简单也不去尝试。
而且,使用这种方法可以带来一定的性能提升,你有访问数据库的完整权限,并且可以在每个页面里精确的获取所需数据。
你无需编写代码即可拥有 GraphQL API 的所有好处。
也许我是高中生,但是我仍然喜欢经典服务器端 MVC 设置的简单性。没有状态管理的复杂性 (抱歉,Vuex 和 Redux)。每次刷新页面都会给您带来新鲜感。浏览器历史记录 API 永无止境。您只需单击链接,浏览器就会 “免费” 处理该链接。甚至处理 404 响应和其他响应也非常简单, 这不是客户端路由的情况。
而且,使用这种方法可以带来一些不错的性能优势。您具有对基础数据库的完全访问权限,并且可以运行非常特定的查询来获取应用程序中每个页面所需的确切数据。
您无需编写任何代码即可获得 GraphQL API 的所有优点。
但是如果需要 API 应该如何做?
坦白的说,如果你的应用需要 API,也许它是多平台的,也许你希望提供 API 供第三方使用,那么本方法可能不太适合你。
于我而言,大多项目并非如此,它们大多不会是多平台的 Web 应用,即使它们是,我也还是有别的选择。当有此需求时,我还可以构建一个补充 API。或者,我可以使用 Turbolinks 之类的技术完全避开该问题。重点是,不应该仅仅因为我想要一个富客户端接口就必须开始编写 API。
根模板
好的,道理讲够了,接下来让我们看看怎么可以把它们拼装在一起。
就像单页应用程序一样,我的应用现在还具有单个服务器端渲染的根模板。该文件的目的是引入依赖项(在 Head 标签内),并将数据从我的控制器传递到 Vue 组件。
<html>
<head>
// ...
</head>
<body>
<div id="app" data-component="{{ $name }}" data-props="{{ json_encode($data) }}"></div>
</body>
</html>
#app
div 标签作为我们的 Vue 组件根元素,标签内两个 “data-” 属性毫无作用。它们只是提供了一种从控制器传递数据到 Vue 组件的方式。
但是,如果需要 API 怎么办?
坦白地说,如果您的应用程序需要 API,可能是因为它是多平台的,或者是因为您想提供第三方 API 访问权限,那么这种方法可能不适合您。
对我来说,大多数项目并非如此。其中大多数是永远不会是多平台的 Web 应用程序。即使是,我仍然有选择。当有此需求时,我可以构建一个补充 API。或者,我可以使用 Turbolinks 之类的技术完全避开该问题。关键是,我不应该仅仅因为我想要一个富客户端接口就必须从 API 开始。
根模板
好的,足够的理论。让我们看看如何将所有这些放在一起。
就像单页应用程序一样,我的应用程序现在还具有单个服务器端呈现的根模板。该文件的目的是包括相关性 (位于 head),并将数据从我的控制器传递到 Vue 组件。
<html>
<head>
// ...
</head>
<body>
<div id="app" data-component="{{ $name }}" data-props="{{ json_encode($data) }}"></div>
</body>
</html>
#app
div 用作我们的 Vue 根组件元素。这两个数据属性毫无关系。它们只是为我们提供了一种从控制器到 Vue 组件获取数据的方式。
基础的 JavaScript 文件
接下来,我们看看负责启动 Vue 组件的基础 JavaScript 文件。
import Vue from 'vue'
// 注册所有Vue组件
const files = require.context('./', true, /\.vue$/i)
files.keys().map(key => Vue.component(key.split('/').pop().split('.')[0], files(key).default))
// 启动当前Vue组件
const root = document.getElementById('app')
window.vue = new Vue({
render: h => h(
Vue.component(root.dataset.component), {
props: JSON.parse(root.dataset.props)
}
)
}).$mount(root)
这可能有点令人困惑,让我们一点一点来分析。
首先我们导入了 Vue,这很简单。
import Vue from 'vue'
然后,我们注册了所有 Vue 组件,这里使用了一个快捷方法,让你无需手动注册每个组件。你可以在 default Laravel project 中找到具体实现。
// 注册所有Vue组件
const files = require.context('./', true, /\.vue$/i)
files.keys().map(key => Vue.component(key.split('/').pop().split('.')[0], files(key).default))
最后,我们来启动当前页面的 Vue 组件,通过 ID 获取根 div 标签,接着访问标签的属性去获取所有数据,然后使用自定义渲染功能创建一个新的 Vue 实例。通过这种方式,我们实际上可以完全忽略 Vue 编译器从而仅在运行时使用 Vue,为我们在最终的 vendor.js
中节省大量的代码。
// 启动当前Vue组件
const root = document.getElementById('app')
window.vue = new Vue({
render: h => h(
Vue.component(root.dataset.component), {
props: JSON.parse(root.dataset.props)
}
)
}).$mount(root)
如果你使用过 Laravel Mix,而且好奇如何仅在运行时使用 Vue,请像下面那样修改你的 webpack.mix.js
文件:
let mix = require('laravel-mix')
mix.js('resources/js/app.js', 'public/js').webpackConfig({
resolve: {
alias: { 'vue$': 'vue/dist/vue.runtime.esm.js' }
}
})
查看宏
接下来,我们将添加一个简单的视图 component
宏。我们将在控制器中使用的宏。它负责显示我们的根模板并向其传递 prop 数据。
use Illuminate\Support\Facades\View;
use Illuminate\View\Factory as ViewFactory;
ViewFactory::macro('component', function ($name, $data = []) {
return View::make('app', ['name' => $name, 'data' => $data]);
});
注意,此宏中引用的 app.blade.php
视图是我们上面的根模板。
示例控制器
接下来在实际控制器中使用此宏。通常情况下会返回 View :: make()
,我们现在将返回 View :: component()
,而不是提供组件名称和 prop 数据。
class EventsController extends Controller
{
public function show(Event $event)
{
return View::component('Event', [
'event' => $event->only('id', 'title', 'start_date', 'description'),
]);
}
}
请记住,从控制器返回的所有数据都将作为 JSON 输出,以供 Vue 组件 (以及最终用户) 使用。并且只返回实际需要的数据。
这种技术也使测试控制器变得非常容易。无需对服务器端模板生成的呈现 HTML 进行断言,只需测试控制器返回的「组件就绪数据」 即可。
案例组件
让我们看一下简化版的 CreateEvent
组件。categories
支撑组件来自于控制器,如同我们在上面的宏示例中看到的。
<template>
<layout title="Create Event">
<h1>Create Event</h1>
<form @submit.prevent="submit">
<input type="text" v-model="title">
<input type="date" v-model="date">
<textarea v-model="description"></textarea>
<select v-model="category_id">
<option v-for="category in categories" :option="category.id">{{ category.name }}</option>
</select>
<button type="submit">Create</button>
</form>
</layout>
</template>
<script>
export default {
props: ['categories'],
data() {
return {
title: null,
date: null,
description: null,
category_id: null,
}
},
methods: {
submit() {
// send request
},
}
}
</script>
请留意如何在页面组件里使用一个 <layout>
组件,还有它是如何将标题传递给它的。这儿有个非常棒的办法让许多页面组件共享页面布局。并且如果你完全不需要布局,比如一个登陆页面,那就直接忽略它!这儿有一个布局组件的例子。通知我们怎么样去使用默认槽 (<slot></slot>
) 显示我们的页面组件和页面布局。
<template>
<div>
<header>
<img src="logo.png">
</header>
<nav>
<a href="/">Dashboard</a>
<a href="/users">Users</a>
<a href="/events">Events</a>
<a href="/events">Profile</a>
<a href="/logout">Logout</a>
</nav>
<main>
<slot></slot>
</main>
</div>
</template>
Turbolinks
如上所述,整个方法仍然使用经典的服务器端路由。这意味着每次点击都会加载整页。一般来说,完全可以。但是,如果您对单页应用的简洁性感兴趣,那么一个非常有趣的选择是使用 Turbolinks,这是由 Basecamp 的优秀人员开发的一个库。Turbolinks 会必要地监视导航事件 (单击链接),然后自动获取页面,替换其 <body>
再合并其 <head>
,所有这些都不会产生整页加载的开销。
实际上,实现 Turbolinks 非常简单。使用前安装库:
npm install --save turbolinks
接下来,您需要更新 JavaScript 文件。将 JavaScript 文件中的 Vue 组件启动替换为以下内容:
// Start Turbolinks
require('turbolinks').start()
// Boot the current Vue component
document.addEventListener('turbolinks:load', (event) => {
const root = document.getElementById('app')
if (window.vue) {
window.vue.$destroy(true)
}
window.vue = new Vue({
render: h => h(
Vue.component(root.dataset.component), {
props: JSON.parse(root.dataset.props)
}
)
}).$mount(root)
})
这里所做的只是在每个 Turbolinks 页面加载事件上实例化 Vue 组件。我们还要确保销毁先前的实例,以防止出现内存问题。
最后,通过将以下元标记添加到您的站点 <head>
来禁用 Turbolinks 缓存。如果您不禁用 Turbolinks 缓存,则当用户在浏览器历史记录中返回 (或向前) 时,Vue 组件将不会被实例化。
<meta name="turbolinks-cache-control" content="no-cache">
这就是所有的啦!说实话,Turbolinks 使我的应用程序精简到令我震撼。
完整的例子
为了说明所有这些代码如何组合在一起,我基于默认的 Laravel 项目整理了一个 complete example project。
注意事项
也许你们中有些人已经在考虑使用客户端渲染的缺点。尽管我认为这些已在业界中广为人知,但在本文中还是值得强调它们,因为我将它与服务器端渲染进行了强有力的对比。
首先要注意的是,您的 JavaScript 文件大小将比经典的服务器端渲染应用程序大得多。例如,在我的情况下,我所有的 Vue 组件都必须捆绑在一起并作为 JavaScript 资产。我使用代码拆分以及适当的缩小和压缩来最小化此问题。实际上,这对我的项目来说不是问题,但是您的工作量可能会有所不同。
其次,与所有客户端渲染的内容一样,搜索引擎将很难索引该信息。再次,这对我来说不是问题,因为我倾向于仅在 Web APP 中使用此技术。如果您要构建面向公众的网站,请务必考虑这一点。
总结
因此,您已经拥有了:一种实用,优雅的方法,可以在服务器端应用程序中执行完整的客户端渲染。这种方法具有拥有丰富的客户端渲染模板的所有优点,同时仍保持经典的服务器端路由和控制器。另外,将 Turbolinks 添加到组合中,使这些应用程序像完整的单页应用程序一样快。
当然,这种方法并非在所有情况下都是正确的。有时候,完整的客户端应用程序和相应的 API 更有意义。有时候,完全经典的服务器端渲染应用程序更有意义。无论哪种方式,我都认为这种方法有很多好处,并且在构建现代 Web APP 时当然值得考虑。