原做者:@LinuxerPHL
原连接:基于 Webpack 4 多入口生成模板用于服务端渲染的方案及实战javascript
警告:本做品遵循 署名-非商业性使用-禁止演绎3.0 未本地化版本(CC BY-NC-ND 3.0) 协议发布。你应该明白与本文有关的一切行为都应该遵循此协议。css
这是什么?
现代化的前端项目中不少都使用了客户端渲染(Client-side Rendering, CSR)的单页面应用(Single Page Application, SPA)。在大多数状况下,它们都应该经过加载 JavaScript 脚本在浏览器中执行以将页面的大部分视图渲染出来,以及获取页面所须要的数据。单页面应用有着许多很是显著的优点,如它们(单页面应用)依赖的公共资源一般仅需加载一次。数据都是经过异步的 JavaScript 与 XML 技术(Asynchoronous JavaScript and XML, Ajax))加载的,异步性能每每很是高。在路由切换时,仅需刷新和(或)更改页面的一部分,而不须要从新加载整个页面以达到切换路由的目的,所以路由的切换在单页面应用中显得比较流畅天然。然而,单页面应用也存在着不少缺陷,它们包括但不限于:html
对于单页面应用上述的缺点,咱们能够考虑利用 Webpack 的多入口配置,将原有的单页面应用同构成与原先的前端路由类似甚至相同的目录结构,指定打包后输出的 HTML 模板。在 Webpack 对整个应用打包以后,将根据入口配置从指定的 HTML 模板生成对应的 HTML 文件,交给位于前端页面与后端之间的中间层(一般使用 Node.js 编写,做为服务端渲染(Server-side Rendering, SSR)的服务器)。注意,此时 Webpack 生成的这些 HTML 文件并不能彻底被浏览器解析,由于这些文件里还有提供给中间层渲染使用的一些插值,在用户访问中间层路由时,这些 HTML 文件将被用做服务端渲染的模板,将中间层从后端 API 获取的数据按照插值的格式填充(也能够称为“渲染”),最后发送给用户。前端
到目前为止,这个描述仍是十分使人困惑。不过咱们没有必要一直纠结这些问题,由于下面的图片也许能够帮助咱们进一步了解整个流程:java
有了上图的帮助,咱们能够将整个流程概括为:首先,和单页面应用同样,前端代码先要通过 Webpack 的打包编译,产物也和单页面应用同样,也是 HTML、JavaScript、CSS 文件。惟独与单页面应用不一样的是,此时的 HTML 文件还存在着插值,这些是要给中间层插入数据最后渲染给用户的;其次,用户开始请求一个路由(图中 1.1),他们如今请求的路由并不是前端的路由而是中间件路由(所谓的“中间层”其实能够理解为一个 Node.js 服务器),若是请求的路由与中间件中定义的路由相匹配,中间件就会根据路逻辑向后端服务器获取指定的数据(图中 1.2),后端返回相应的数据(图中 1.3),通过必定的处理后将数据插入到指定的 HTML 文件中(这些 HTML 文件是从 Webpack 打包生成的);而后中间层将渲染出的最终 HTML 发送给客户端(图中 1.4)。客户端收到响应后,接下来的流程就和单页面应用同样了:浏览器解析中间层发来的 HTML 文件,执行里面的 Bundle。也许 Bundle 中包含了 Ajax 请求,所以浏览器向中间件发送了 Ajax 请求(图中 2.1)。可是中间件并不直接提供后端 API 服务,所以,它必须提供一个能够将请求转发至后端 API 的代理(图中 2.2)。后端将数据返回给中间层(图中 2.3),中间层的代理又将数据发送给客户端。所以,这种状况下的 Ajax 请求,不管是对于客户端仍是后端,都是透明的,即它们在使用 Ajax 进行数据传送时,对中间层的存在都绝不知情。node
将整个流程用一句话描述,即: 客户端访问了中间层路由,中间层返回渲染好一部分数据的带 Bundle 的 HTML 文件,再由浏览器执行 Bundle 以加载完剩下的数据。
基于上面的全部描述,咱们大体清楚了中间层应该至少扮演两种角色:react
至此,咱们已经对整个过程有了充分的认识。接下来,咱们能够讨论一些实际性的配置,例如:咱们应该如何对 Webpack 进行配置,如何编写一个中间层等等。linux
为了更方便地了解本身的水平是否适合继续了解和掌握下面的内容,如下列出了下文中使用到的技术:webpack
关于咱们为什么使用 TypeScript 构建咱们的实践项目,请参阅 5 Key Benefits of Angular and TypeScript
咱们能够从 GitHub API 中获取 GitHub 上指定用户的 Gists,根据返回的数据进行渲染。若是时间容许的话,咱们也许还能将全部的 Gist 进行分页渲染。git
笔者在实际项目中使用这个方案开发时,遇到了诸多问题。幸运的是,笔者已经基本排除了绝大部分可控的错误而且提供了一份最基础的模板。在接下来的探索中,咱们将使用这套模板编写一个小 Demo:从 GitHub Public API 中获取一些数据,并按照必定的逻辑进行访问的渲染。不过,笔者仍但愿介绍一下这套模板中的配置,以及为何须要这样进行配置。由于笔者坚信:授人以鱼,不如授人以渔。
查看这套模板的 GitHub 仓库请注意,笔者并不打算直接将这套模板做为实例继续开发,所以,一个新的仓库是十分必要的。
笔者将会在 lenconda/webpack-ssr-practice 中同步这篇文章的全部更改。在每一段结束后,若是有必要的话,笔者也会提供对应时间点的 Commit ID,以便于咱们对整个过程有更深入的印象。
一般,各类前端框架的脚手架都有本身的 Webpack 配置,以便于开发者快速进入开发状态。然而,在本文中,咱们可能没法找到知足咱们需求的配置方案。所以,为了达到咱们预期的结果,咱们应该从零开始配置 Webpack。
若你但愿在这以前对 Webpack 有更深刻的了解,请移步 Webpack 的 官方文档。
在 Webpack 中,指定入口文件应该在 entry
字段中以键值对的形式声明。其中,键为入口的名称,值为该入口所在的文件的路径,路径能够是相对路径,也能够是绝对路径。在本文中,若是没有特殊声明,路径一概采用绝对路径的形式。
在打包时,Webpack 会读取每一个 entry
,通过相对应的 loaders,根据 output
字段的配置生成输出文件(有时也称为“出口文件”)。例如,有以下一段配置:
module.exports = { entry: { 'root': '/path/to/root.js' }, output: { path: '../dist', filename: 'static/js/[name].[hash:8].js', publicPath: '/' } }
Webpack 将会输出一个相似于 ../dist/static/js/root.ae5fb09e.js
的出口文件。其中,[name]
是 entry
字段中每一个键的占位符,[hash]
是文件哈希值的占位符,[hash:8]
指的是取文件哈希值的前 8 位。
对 Webpack 生成的文件哈希值感兴趣,或者想进一步了解为何打包出的文件须要将哈希值插入文件名中,请移步 Webpack 的 官方文档
因为咱们采用了多入口方案,而且将每一个页面做为一个入口,所以咱们没法估量一个项目究竟有多少个页面(即咱们没法估量一个项目有多少个入口)。所以,咱们应该编写一个方法,按照一个特定的模式匹配指定路径下的入口文件,递归生成一个入口列表。咱们能够编写以下的方法:
function getEntries(searchPath, root) { const files = glob.sync(searchPath); const entries = files.map((value, index) => { const relativePath = path.relative(root, value); return { name: value.split('/')[value.split('/').length - 2], path: path.resolve('./', value), route: relativePath.split('/').filter((value, index) => value !== 'index.tsx').join('/') }; }); return entries; }
接下来,咱们编写如下代码:
test.js
console.log(getEntries( path.join(__dirname, '../pages/**/index.tsx'), path.join(__dirname, '../pages') ));
这段代码被指望能够从当前路径父级目录的 pages
目录下找到全部包含 index.tsx
文件的目录。咱们在项目根目录中运行这段代码,能够获得以下的结果:
其中,name
指定了入口的名称,path
指定了入口文件的路径,route
指定了入口文件的路由名称(这个字段将在生成出口文件名以及生成 HTML 模板中发挥做用)。
如今,咱们能够获得入口文件列表了:
const entries = getEntries( path.join(__dirname, '../pages/**/index.tsx'), path.join(__dirname, '../pages') );
所以,entry
和 output
能够是这样的:
entry: { ...Object.assign(...entries.map((value, index) => { const entryObject = {}; entryObject[value.name === 'pages' ? 'app_root' : value.route.split('/').join('_')] = value.path; return entryObject; })) }, output: { path: path.join( __dirname, (config.isDev ? '../../' : '../../dist/') + 'server-bundle' ), filename: 'static/js/[name]-route.[hash:8].js', chunkFilename: 'static/js/[name].[hash:8].chunk.js', publicPath: '/' }
以上这段代码中,咱们也许能够发现不少难以理解的配置项。不过咱们没必要担忧它们,只需理解咱们是如何用 getEntries()
的输出来配置入口和出口的。其中的一些技术细节(如 ...
,Object.assign()
等),因篇幅所限,在这里不作详细阐述。
若你但愿继续深刻理解这些操做符或方法,请移步:
Object.assign() - MDN - Mozilla
展开语法- JavaScript | MDN
咱们使用 HtmlWebpackPlugin
的 Webpack 插件将多入口输出至对应的 HTML 中。值得注意的是,咱们所须要的 HTML 模板可能不止一个,由于不一样的页面可能有不一样的搜索引擎优化(Search Engine Optimization, SEO)配置。所以,咱们须要将公共部分(如 footer、共用的head等)提取到一个独立的 HTML 文件中,再在每一个 HTML 模板中将它们引入。这种代码一般像这样:
<%= require('html-loader!./parts/footer.html') %>
请注意,这里使用了相对路径写法。
正如你所看见的,这种操做须要 html-loader
的支持,若是如今项目中没有安装这个依赖,能够这样安装:
npm i html-loader -D
也许你很好奇,上面这种写法彷佛并不像 HTML 的写法。的确,这实际上是 EJS 的语法,从每个 <%=
开头到 %>
结尾中间的内容,是能够被改变的,咱们能够将它们理解为“变量”。那么,是谁会将值插入这些变量呢?实际上是 HtmlWebpackPlugin
中的 templateParameter
字段。咱们能够在这个字段中向 HTML 模板中注入咱们但愿的值,例如:
/path/to/test.template.html
<title><%= title %></title>
const HtmlWepackPlugin = require('html-webpack-plugin'); new HtmlWebpackPlugin({ filename: 'test.html', template: '/path/to/test.template.html', templateParameter: { title: 'Hello, world!' } });
若是不出意外,咱们也许能够看到下面的输出结果:
test.html
<title>Hello, world!</title>
在服务端渲染时,咱们可能也须要相似于以上的配置。不幸的是,Webpack 的某些插件已经使用了 EJS 的语法以传递数据。所以,咱们已经没法使用 EJS 做为服务端渲染时的模板引擎了。不过,目前还有许多结构和 HTML 基本一致的模板渲染引擎,咱们选择的是 Handlebars,由于这是结构最接近 HTML 的语法。咱们能够写出下面的代码:
<title><%= title %></title> <p>Hello, {{name}}</p>
Webpack 配置仍然沿用上一个例子。
若是不出意外,咱们可能能够看到下面的输出结果:
<title>Hello, world!</title> <p>Hello, {{name}}</p>
看到这样的结果,说明用于服务端渲染的插值依然还在,也就代表这种语法对于 Webpack 来讲是“安全的”。
基于上文的探讨,咱们大体能够得出一份可行的 HtmlWebpackPlugin 配置:
...entries.map((value, index) => { return new HtmlWebpackPlugin({ filename: path.join( __dirname, (config.isDev ? '../../' : '../../dist/') + 'server-templates/', value.route === '' ? 'index.html' : value.route + '/index.html' ), template: path.resolve( __dirname, '../templates/' + (pages[value.route] && (pages[value.route].template || 'index.html') || 'index.html') ), templateParameters: { title: pages[value.route] && (pages[value.route].title || config.name) || config.name }, inject: true, chunks: [(value.name === 'pages' ? 'app_root' : value.route.split('/').join('_')), 'common'] }); })
你也许会发现这段代码缺乏一些上下文变量,如 config
、pages
等。由于这段代码是直接从一个上线项目中拷贝来的。不过这并不伤大雅,并且以后的案例中还会使用这个案例的所有配置。所以,咱们仍然不用过分关心这段代码的上下文,仅需理解每一个配置项分别意味着什么。
咱们已经将 Webpack 核心的配置都梳理出来了。如今,咱们还须要对这份配置进行一些优化。优化的方法能够是下面说起的:
中的一种或多种。可是具体的优化步骤因为篇幅所限,不作详细阐述。
在选择中间层用何种语言(或技术)时,笔者选择了 Node.js (平台)和 Koa.js (框架)进行中间层开发。原则上,中间层的选择搭配能够是随意的,例如 Java、Python + Flask 等。可是笔者推荐的仍是基于 Node.js 平台的框架,由于 Node.js 使用的语言仍然是 JavaScript,所以,咱们编写中间层的学习成本和重构成本是极低的。
不一样于 Express.js,Koa.js 并不原生提供渲染引擎。所以,咱们须要安装 koa-views
赋予 Koa.js 渲染 HTML 模板的能力。
npm i koa-views @types/koa-views -S
咱们能够在服务端代码中编写以下的代码:
/server/index.ts
import views from 'koa-views'; ... app.use(views(path.join(__dirname, '../server-templates'), { map: { html: 'handlebars' } }));
这段代码指定了要在当前目录父级目录下的 server-templates
中指定的模板。若是咱们的项目中存在 /path/to/project/server-dist/index.html
,则能够经过下面的代码找到它,并将它渲染出来:
import Router from 'koa-router'; const indexRouter = new Router(); indexRouter.get('/', async (ctx, next) => { await ctx.render('index.html'); });
这并不是 Koa.js 原生的语法,而是
koa-router
路由匹配的语法。若你但愿对它有进一步的了解,请移步
koa-router - npm。
在 Node.js 服务端程序中,代理某些请求一般可使用 http-proxy-middleware
的中间件(请注意,这里的“中间件”并非上文说起的“中间层”)。但在 Koa.js 中,咱们并不能直接使用它做为代理转发请求。咱们还须要将它包装进 koa2-connect
中。咱们的代码应该像这样:
app.use(async (ctx, next) => { if (ctx.url.startsWith('/api')) { ctx.respond = false; await connect(proxy({ target: 'SOME_API_URL', changeOrigin: true, // pathRewrite: { // '^/api': '' // }, secure: config.isDev ? false : true, }))(ctx, next); } await next(); });
也许你已经注意到,Webpack 中对路径扫描生成入口列表的方式已经决定了咱们的前端目录结构应该要遵照某种约定。在这个实例中,咱们经过阅读 Webpack 配置能够了解到:Webpack 将会扫描 /src/page
目录下全部包含 index.tsx
文件的目录,根据指定的相对路径根目录(即 getEntries()
的第二个参数,咱们使用了 /src/pages
)计算出对应的路由(例如:假设存在 /src/pages/test/hello/index.tsx
,那么从它计算出的路由是 test/hello
)。
在每一个包含 index.tsx
的目录中,index.tsx
应该像这样:
import React from 'react'; import ReactDOM from 'react-dom'; import App from './App'; ReactDOM.render(<App />, document.getElementById('root'));
所以,咱们也许能够得出这样一个结论:/src/pages
目录下的每个子目录,包括它自己,都是一个独立的入口,而每一个入口也能够是一个独立的 React App。
/src/pages/App.tsx
这个页面将会向中间层的 /api/users
发送一个 Ajax 请求。显然,此时的 Ajax 请求是通过中间层代理转发的。同时,咱们也许能够发现,这个页面并无使用服务端渲染,而是经过中间层直接渲染出来的。由于这个页面没有必要作服务端渲染,也没法使用服务端渲染。
import React, { useState } from 'react'; import './App.scss'; import http from '../utils/http'; const App = (): JSX.Element => { const [inputValue, setInputValue] = useState<string>(''); // 执行搜索用户的方法,该方法会被 button 调用 const searchUser = () => { // 经过 Ajax 获取指定用户的信息,若是存在,就跳转至相应的页面 http .get(`/api/users/${inputValue}`) .then(res => { if (res.data) { window.location.href = `/user/${inputValue}`; } }); }; return ( <div className="container"> <div className="row"> <div className="col-8"> {* 在输入时,将输入的内容用 Hooks 传入 inputValue *} <input type="text" className="form-control" onChange={event => setInputValue(event.target.value)} /> </div> <div className="col-4"> <button className="btn btn-primary" onClick={searchUser}>Search</button> </div> </div> </div> ); }; export default App;
/src/pages/user/App.tsx
import React from 'react'; import './App.scss'; const App = (): JSX.Element => { return ( <div className="container"> <div className="row"> Gists </div> </div> ); }; export default App;
/src/templates/user.html
<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <title><%= title %></title> <%= require('html-loader!./parts/head.html') %> </head> <body> <div id="gists"></div> <div id="root"> <div class="container"> <div class="row"> <!-- Handlebars 的循环写法 --> {{#data}} <div class="col-12"> <div class="card w-100" style="margin-bottom: 30px;"> <div class="card-body"> <h5 class="card-title">{{description}}</h5> <h6 class="card-subtitle mb-2 text-muted">{{id}}</h6> <p class="card-text"> <ul> {{#files}} <li> <a href="{{{raw_url}}}">{{name}}</a> </li> {{/files}} </ul> </p> <a href="{{{url}}}" class="card-link">Detail →</a> </div> </div> </div> {{/data}} </div> </div> </div> <%= require('html-loader!./parts/footer.html') %> </body> </html>
/server/routers/index.ts
import Router from 'koa-router'; import http from '../utils/http'; const indexRouter = new Router(); indexRouter.get('/', async (ctx, next) => { await ctx.render('index.html'); }); // 匹配 /user 路径,其 ID 能够经过 ctx.params.id 得到 indexRouter.get('/user/:id', async (ctx, next) => { // 中间件发送 HTTP 请求给后端,从后端获取数据 const res = await http.get(`/users/${ctx.params.id}/gists`); // 根据后端返回的数据进行处理,使它与模板中的插值一致 const result = res.data.map((value, index) => { return { url: value.url, id: value.id, description: value.description, files: Object.keys(value.files).map((key, index) => { return { name: key, raw_url: value.files[key].raw_url }; }) }; }); // 渲染相应的页面,将插值传递到第二个变量中 await ctx.render('user/index.html', { data: result }); }); export default indexRouter;
这个页面稍微有点复杂,不管是中间层的路由逻辑仍是 HTML 模板。可是你可能已经发现,和首页不一样的是,/src/pages/user/App.tsx
中的代码其实很是少,这是由于这个页面彻底使用了服务端渲染,所以它的 React.js 逻辑(也就是前端逻辑)很是简单,而 HTML 模板和路由逻辑稍微复杂一些。
笔者在模板中预置了咱们可能须要的 npm 脚本。
如今,咱们须要使用构建命令将前端代码经过 Webpack 打包编译,以及将使用 TypeScript 编写的中间层代码编译为 JavaScript 代码(这并非必须的,由于几乎全部持久化产品(如 nodemon、pm二、forever 等)都支持直接运行 TypeScript 代码)。
npm run build
打包编译仅需数十秒的时间。完成以后,咱们能够在项目根目录下看见一个名为 dist
的目录,里面的内容可能像这样:
. ├── server │ ├── config.js │ ├── config.js.map │ ├── index.js │ ├── index.js.map │ ├── routers │ │ ├── index.js │ │ └── index.js.map │ └── utils │ ├── http.js │ └── http.js.map ├── server-bundle │ ├── app_root.33a07d26.css │ ├── assets │ │ └── css │ │ └── index.css │ ├── static │ │ └── js │ │ ├── app_root-route.33a07d26.js │ │ ├── common.33a07d26.chunk.js │ │ └── user-route.33a07d26.js │ └── user.33a07d26.css └── server-templates ├── index.html └── user └── index.html
如今,咱们已经有了打包编译后的全部文件。为了启动测试用的服务器,咱们应该执行
node dist/server/index.js
若是没有在系统环境变量中指定 PORT
的值,它将会在 127.0.0.1:5000
启动一个 Node.js 服务器,也就是所谓的中间层服务器。
咱们直接访问 http://localhost:5000
,页面也许像这样:
咱们也能够看一看首页路由究竟返回了什么:
咱们并不能直接在返回的 HTML 中找到首页上的视图。由于视图都是经过 HTML 末尾处引入的 Bundle 渲染的。前文中,咱们已经知道,这是由于这个页面并无使用服务端渲染。
咱们再来看一看用户 Gist 页面。咱们在首页输入 “octocat”,点击 “Search”,稍等一会,页面就会跳转到 /user/octocat
:
此时,中间层的 Ajax 代理捕获了一个由客户端发送给后端的 Ajax 请求:
![]()
那么如今,咱们看一看这个路由返回了什么:
咱们发现,用红色墨迹圈出的部分是已经在服务端渲染出来的。
笔者使用本文讨论的方案构建了一个简单的 Web 应用,目前已经在线上运行了。这个应用的页面不多,其中使用了服务端渲染的页面也很是少(由于大部分页面实在没有使用服务端渲染的必要,也不但愿被搜索引擎爬虫抓取),可是笔者认为它彻底能够体现这篇文章的核心思想。
请移步 lenconda/tracelink_web
线上地址: https://tracel.ink
到此为止,咱们已经达到了咱们所要讨论的预期目的:使用 Webpack 的多入口特性,生成能够用于服务端渲染的模板,进行服务端渲染。和大部分现有的方案不一样的是,咱们使用了更“另类”的方式:尽最大的努力是中间层以原生(在本文中指的是 Koa.js 的模板渲染引擎),而无需考虑如何针对特定的前端框架同构(如 Next.js 之于 React.js、Nuxt.js 之于 Vue.js 等)。
即便咱们“大费周章”地将这套方案详细地讨论,疑惑仍然仍是存在的。笔者将这套方案分享给身边的朋友时,他们几乎都没有立刻理解和彻底接受。不过,他们提出的问题也许很是有价值。咱们不妨来帮忙解答其中的一些问题:
笔者选择 React.js 做为前端框架是由于我的喜爱。固然,彻底能够其余任何框架甚至原生 JavaScript。由于中间层只须要 HTML 模板进行渲染,而咱们则是经过 Webpack 打包编译的。Webpack 多入口和任何框架都没有关系,不管是用 React.js、Vue.js 仍是 Angular,只要前端逻辑同样,它们打包编译出来的代码也几乎彻底同样。所以,咱们能够为所欲为选择本身喜好的技术编写前端代码。
由于在真实的项目中,咱们不可能彻底依赖于服务端渲染,也不可能彻底靠客户端渲染。咱们必须明白一个最核心的原则:对于咱们但愿搜索引擎爬虫爬取的内容,咱们应该尽量地使用服务端渲染;对于咱们不但愿,或者不必被搜索引擎爬虫爬取的内容,咱们应该尽量地使用客户端渲染(即 Ajax 方式)。所以,咱们须要服务端渲染咱们想要渲染的数据,再将带着 Bundle 的渲染完毕的 HTML 发送给浏览器,由浏览器继续执行 Bundle 加载剩下的数据和视图。
服务端渲染中的“服务端”并非真正的后端,它是没法接触到数据库的。它充当着客户端与后端的“联络员”。在同构时,后端不须要通过任何更改。中间层属于前端。