原做者:@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 加載剩下的數據和視圖。
服務端渲染中的「服務端」並非真正的後端,它是沒法接觸到數據庫的。它充當着客戶端與後端的「聯絡員」。在同構時,後端不須要通過任何更改。中間層屬於前端。