最近接了一個公司官網的項目,須要 SEO 友好,因此不能使用前端框架,前端框架自帶的腳手架工具天然也幫不上啥忙。只好本身使用 webpack4 + ejs + express
,從頭搭建一個多頁應用的項目架構。搭建過程當中,遇到許多坑,然而網上的相關參考也是很是少,因此寫個博客記錄一下搭建過程以及注意事項。html
如下我會將重要的細節標紅,給須要的朋友參考。前端
這篇文章發表後,有朋友在評論區問爲何不直接使用一些同構的框架,好比 nextjs
或者 nuxtjs
?這個問題可能也是咱們開發的時候比較糾結的問題之一,我說一下本身的想法。node
其實所謂的「同構」,也只是在加載網頁第一屏的時候,使用了服務端渲染,由服務器解析 VDOM 生成真實 DOM 而後返回。等到網頁第一屏代碼加載完成、前端框架接管瀏覽器的時候,後續的整個流程,就已是客戶端渲染,和服務端沒有關係了。jquery
優勢webpack
缺點ios
傳統意義上的服務端渲染,則是在整個網頁的生命週期內,都由服務端直接生成靜態頁面返回給客戶端。git
優勢github
缺點web
綜合以上,當咱們在面對一個須要考慮 SEO
的項目,何時選擇先後端同構,何時選擇傳統的服務端渲染?
我認爲:若是你的項目是一個 toC
的產品、須要考慮 SEO
、涉及大量用戶交互及頻繁的需求變動,那麼可能同構更適合你。它能以組件的方式構建你的項目,高度抽離和複用,而且可以支持一些在傳統服務端渲染狀況下根本無法作的功能,好比網頁雲音樂網頁客戶端,切換頁面的時候還能保證歌曲不斷,確定使用了同構。
而若是隻是一個 toB
的中小型企業官網,考慮 SEO 可是不涉及大量的用戶交互,一旦作完後期變更也不大,那麼能夠考慮選擇傳統的服務端渲染,也就是下面文章說起的方法。
在動手開發以前,咱們須要先明確這個項目的定位——公司官網,通常來講,官網不會涉及大量的數據交互,比較偏向於數據展現。因此不用前端框架,jquery
便可知足需求。可是考慮到 SEO 因此須要用到服務端渲染,就要使用模板語言(ejs
),配合 node 來完成。
根據以上信息,咱們就能夠肯定打包腳本的基本功能,先來簡單列個清單:
webpack
來打包多頁應用,且不須要每次新增一個視圖文件都添加一個 HTMLWebpackPlugin
和重啓 server ,能作到 webpack 配置和文件名解耦,儘可能的自動化。ejs
模板語言編寫,可以插入變量和外部 includes
文件,最後運行 build 命令的時候能將通用模板文件(<meta>/<title>/<header>/<footer>
等)自動插入每一個視圖文件對應位置。webpack-dev-server
,能使用本身編寫的 node 代碼啓動服務。overlay
功能,能夠像 webpack-dev-server
那樣集成漂亮的 overlay 屏幕報錯。先創建一個空項目,因爲須要本身編寫服務端代碼,因此咱們須要多建一個 /server
文件夾,用來存放 express
的代碼,搭建完成後,咱們的項目結構看起來是這樣。
除此之外,咱們須要初始化一些通用配置文件,包括:
.babelrc
babel 配置文件.gitignore
git 忽略文件.editorConfig
編輯器配置文件.eslintrc.js
eslint 配置文件README.md
文件package.json
文件大的框架出來之後,咱們開始編寫工程代碼。
首先是編寫打包腳本,在/build
文件夾裏新建幾個文件
webpack.base.config.js
,用來存放生產環境和開發環境通用的 webpack 配置webpack.dev.config.js
用來存放開發環境的打包配置webpack.prod.config.js
用來存放生產環境的打包配置config.json
用來存放一些配置常量,例如端口名,路徑名之類。通常來講,webpack.base.config
文件裏,放一些開發生產環境通用的配置,例如 output
、entry
以及一些 loader
, 例如編譯ES6語法的 babel-loader
、打包文件的 file-loader
等。經常使用的 loader 的使用方式咱們能夠查看文檔 webpack loaders,
須要注意的是,這邊有個很是重要的 loader ———— ejs-html-loader
通常來講,咱們使用 html-loader
來對.html
結尾的視圖文件作處理,而後扔給 html-webpack-plugin
生成對應的文件,可是 html-loader
沒法處理 ejs 模板語法中的 <% include ... %>
語法,會報錯。然而在多頁應用裏,這個 include 的功能是必須的,否則每一個視圖文件裏都要手動去寫一份 header/footer
是什麼感受。。。因此咱們須要再多配置一份 ejs-html-loader:
// webpack.base.config.js 部分代碼
module: {
rules: [
...
{
test: /\.ejs$/,
use: [
{
loader: 'html-loader', // 使用 html-loader 處理圖片資源的引用
options: {
attrs: ['img:src', 'img:data-src']
}
},
{
loader: 'ejs-html-loader', // 使用 ejs-html-loader 處理 .ejs 文件的 includes 語法
options: {
production: process.env.ENV === 'production'
}
}
]
}
...
]
}
複製代碼
第一個坑繞過以後,第二個:
entry 入口要怎麼寫?
記得以前公司的一個老項目,五十幾個頁面,五十幾個 entry
和 new HTMLwebpackPlugin()
一個文件展開來能夠繞地球一圈。。。這邊爲了不這種慘狀,寫一個方法,返回一個 entry 數組。
可使用 glob 來處理這些文件,獲取文件名,固然一樣也可使用原生 node 來實現。只要保證 JavaScript
文件名和視圖文件名相同便可,好比,首頁的視圖文件名是 home.ejs
,那麼對應的腳本文件名就要用一樣的名字 home.js
來命名,webpack 打包的時候會找到腳本文件入口,經過映射關係生成對應視圖文件:
// webpack.base.config.js 部分代碼
const Webpack = require('Webpack')
const glob = require('glob')
const { resolve } = require('path')
// webpack 入口文件
const entry = ((filepathList) => {
let entry = {}
filepathList.forEach(filepath => {
const list = filepath.split(/[\/|\/\/|\\|\\\\]/g) // 斜槓分割文件目錄
const key = list[list.length - 1].replace(/\.js/g, '') // 拿到文件的 filename
// 若是是開發環境,才須要引入 hot module
entry[key] = process.env.NODE_ENV === 'development' ? [filepath, 'webpack-hot-middleware/client?reload=true'] : filepath
})
return entry
})(glob.sync(resolve(__dirname, '../src/js/*.js')))
module.exports = {
entry,
...
}
複製代碼
HTMLWebpackPlugin 的配置也同理:
// webpack.base.config.js 部分代碼
...
plugins: [
// 打包文件
...glob.sync(resolve(__dirname, '../src/tpls/*.ejs')).map((filepath, i) => {
const tempList = filepath.split(/[\/|\/\/|\\|\\\\]/g) // 斜槓分割文件目錄
const filename = `views/${tempList[tempList.length - 1]}` // 拿到文件的 filename
const template = filepath // 指定模板地址爲對應的 ejs 視圖文件路徑
const fileChunk = filename.split('.')[0].split(/[\/|\/\/|\\|\\\\]/g).pop() // 獲取到對應視圖文件的 chunkname
const chunks = ['manifest', 'vendors', fileChunk] // 組裝 chunks 數組
return new HtmlWebpackPlugin({ filename, template, chunks }) // 返回 HtmlWebpackPlugin 實例
})
]
...
複製代碼
編寫好 webpack.base.config.js
文件,根據本身項目需求編寫好 webpack.dev.config.js
和 webpack.prod.config.js
,使用 webpack-merge 將基礎配置和對應環境下的配置合併。
webpack 其餘的一些細節配置你們能夠參考 webpack 中文網址
打包腳本編寫完成,咱們開始編寫服務,咱們使用 express
來搭建服務。(因爲是工程架構演示,因此這個服務暫不涉及任何的數據庫的增刪改查,只是包含基本的路由跳轉)
server
簡單的結構以下:
bin/server.js
啓動文件,做爲服務的入口,須要同時啓動本地服務和 webpack 的開發時編譯。通常項目 webpack-dev-server
是寫在 package.json
裏的,當你運行 npm run dev
的時候,就在使用 webpack-dev-server
啓動開發服務,這個 webpack-dev-server 功能十分強大,不只能一鍵啓動本地服務,還能夠監聽模塊,實時編譯。這邊咱們使用 express
+ webpack-dev-middleware 也能夠達到一樣的功能。
webpack-dev-middleware 能夠理解爲一個抽離出來的 webpack-dev-server,只是沒有啓動本地服務的功能,以及使用方式上略有改變。它相比於 webpack-dev-server 的靈活性在於,它以一箇中間件的形式存在,容許開發者編寫本身的服務來使用它。
其實 webpack-dev-server 的內部實現機制也是藉助於 webpack-dev-middleware 和 express 有興趣的朋友能夠去看一下。
如下是服務入口文件的部分代碼
// server/bin/server.js 文件代碼
const path = require('path')
const express = require('express')
const webpack = require('webpack')
const webpackDevMiddleware = require('webpack-dev-middleware')
const webpackHotMiddleware = require('webpack-hot-middleware')
const { routerFactory } = require('../routes')
const isDev = process.env.NODE_ENV === 'development'
let app = express()
let webpackConfig = require('../../build/webpack.dev.config')
let compiler = webpack(webpackConfig)
// 開發環境下才須要啓用實時編譯和熱更新
if (isDev) {
// 用 webpack-dev-middleware 啓動 webpack 編譯
app.use(webpackDevMiddleware(compiler, {
publicPath: webpackConfig.output.publicPath,
overlay: true,
hot: true
}))
// 使用 webpack-hot-middleware 支持熱更新
app.use(webpackHotMiddleware(compiler, {
publicPath: webpackConfig.output.publicPath,
noInfo: true
}))
}
// 添加靜態資源攔截轉發
app.use(webpackConfig.output.publicPath, express.static(path.resolve(__dirname, isDev ? '../../src' : '../../dist')))
// 構造路由
routerFactory(app)
// 錯誤處理
app.use((err, req, res, next) => {
res.status(err.status || 500)
res.send(err.stack || 'Service Error')
})
app.listen(port, () => console.log(`development is listening on port 8888`))
複製代碼
路由的跳轉方式,屬於整個工程中很是重要的一步。不知道閱讀文章的朋友有沒有疑問,本地的視圖文件是 .ejs 後綴結尾的文件,瀏覽器只能識別 .html 後綴文件,這塊視圖數據的渲染是怎麼作的? webpack-dev-middleware 打包出來的資源都是存在內存中的,存儲在內存中的資源文件,服務端要怎麼獲取?
先來看具體的路由代碼,此處以首頁路由做爲演示// server/routs/home.js 文件
const ejs = require('ejs')
const { getTemplate } = require('../common/utils')
const homeRoute = function (app) {
app.get('/', async (req, res, next) => {
try {
const template = await getTemplate('index.ejs') // 獲取 ejs 模板文件
let html = ejs.render(template, { title: '首頁' })
res.send(html)
} catch (e) {
next(e)
}
})
app.get('/home', async (req, res, next) => {
try {
const template = await getTemplate('index.ejs') // 獲取 ejs 模板文件
let html = ejs.render(template, { title: '首頁' })
res.send(html)
} catch (e) {
next(e)
}
})
}
module.exports = homeRoute
複製代碼
能夠看到關鍵點就在 getTemplate 這個方法,咱們看看這個 getTemplate
作了咩
// server/common/utils.js 文件
const axios = require('axios')
const CONFIG = require('../../build/config')
function getTemplate (filename) {
return new Promise((resolve, reject) => {
axios.get(`http://localhost:8888/public/views/${filename}`) // 注意這個 'public' 公共資源前綴很是重要
.then(res => {
resolve(res.data)
})
.catch(reject)
})
}
module.exports = {
getTemplate
}
複製代碼
從上面代碼能夠看到,路由中的作的很是重要的事情,就是直接用對應視圖的 ejs 文件名,去請求自身服務,從而獲取到存在 webpack 緩存中的資源和數據。
經過這種方式拿到模板字符串後,ejs 引擎會用數據渲染對應變量,最終以 html 字符串的形式返回到瀏覽器進行渲染。
本地服務會以一個 publicPath 路徑前綴來標記靜態資源請求,若是服務接受到的請求是帶有 publicPath 前綴,就會被 `/bin/server.js` 中的靜態資源中間件攔截到,映射到對應資源目錄,返回靜態資源,而這個 publicPath 就是 webpack 配置中的 output.publicPath
關於 webpack 的打包時緩存,我以前翻了不少地方都沒有找到很好的文檔和操做工具,這邊給你們推薦兩個連接
- Webpack Custom File Systems (webpack 自定義文件系統官方說明)
- memory-fs(獲取 webpack 編譯到內存中的數據)
完成了服務端渲染、webpack 構建配置後,算是搞定了 80% 的工做量,還有一些小細節須要注意,否則服務啓動起來仍是會報錯。
這個坑就埋在客戶端的視圖文件裏,先來看看坑是什麼:當咱們使用 ejs 語法(<%= title %>)這種語法的時候,webpack 編譯就會報錯,說是 title is undefined
要解決這個問題,須要首先明白 webpack 編譯時的運行機制,它作了什麼。咱們知道,webpack 內部模板機制就是基於的 ejs,因此在咱們服務端渲染以前,也就是 webpack 的編譯階段,已經執行過了一次 ejs.render 了,這個時候,在 webpack 的配置文件裏,咱們是沒有傳遞過 title 這個變量的,因此編譯會報錯。那麼要怎麼寫才能識別呢?答案就在 ejs 的官方文檔
從官網的介紹上能夠看出,當咱們使用 <%% 打頭的時候,會被轉義成 <% 字符串,相似於 html 標籤的轉義,這樣才能避免 webpack 中自帶的 ejs 的錯誤識別,生成正確的 ejs 文件。因此以變量爲例,在代碼中咱們須要這樣寫: <%%= title %>
這樣,webpack 才能順利編譯完成,將 compiler 繼續傳遞到 ejs-html-loader 這裏
若是瞭解 html-loader
的朋友就知道,在項目中,咱們之因此可以在 html 中方便的寫 <img src="../static/imgs/XXX.png">
這種圖片格式,還能被 webpack 正確識別,離不開 html-loader 裏的 attrs
配置項, 可是在 ejs-html-loader 裏,沒有提供這種方便的功能,因此咱們依舊要使用 html-loader
來對 html 中的圖片引用作處理,這邊須要注意 loader 的配置順序
// webpack.base.config.js 部分代碼
module: {
rules: [
...
{
test: /\.ejs$/,
use: [
{
loader: 'html-loader', // 使用 html-loader 處理圖片資源的引用
options: {
attrs: ['img:src', 'img:data-src']
}
},
{
loader: 'ejs-html-loader', // 使用 ejs-html-loader 處理 .ejs 文件的 includes 語法
options: {
production: process.env.ENV === 'production'
}
}
]
}
...
]
}
複製代碼
接下來是配置熱更新,使用 webpack-dev-middleware
時的熱更新配置方式和 webpack-dev-server
略有不一樣,可是 webpack-dev-middleware
稍微簡單一點。webpack 打包多頁應用配置熱更新,一共四步:
entry
入口裏多寫一個 webpack-hot-middleware/client?reload=true
的入口文件// webpack.base.config.js 部分代碼
// webpack 入口文件
const entry = ((filepathList) => {
let entry = {}
filepathList.forEach(filepath => {
...
// 若是是開發環境,才須要引入 hot module
entry[key] = process.env.NODE_ENV === 'development' ? [filepath, 'webpack-hot-middleware/client?reload=true'] : filepath
...
})
return entry
})(...)
module.exports = {
entry,
...
}
複製代碼
plugins
裏多寫三個 plugin:// webpack.dev.config.js 文件部分代碼
plugins: [
...
// OccurrenceOrderPlugin is needed for webpack 1.x only
new Webpack.optimize.OccurrenceOrderPlugin(),
new Webpack.HotModuleReplacementPlugin(),
// Use NoErrorsPlugin for webpack 1.x
new Webpack.NoEmitOnErrorsPlugin()
...
]
複製代碼
bin/server.js
服務入口中引入 webpack-hot-middleware
, 並將 webpack-dev-server
打包完成的 compiler
用 webpack-hot-middleware
包裝起來:// server/bin/server.js 文件
let compiler = webpack(webpackConfig)
// 用 webpack-dev-middleware 啓動 webpack 編譯
app.use(webpackDevMiddleware(compiler, {
publicPath: webpackConfig.output.publicPath,
overlay: true,
hot: true
}))
// 使用 webpack-hot-middleware 支持熱更新
app.use(webpackHotMiddleware(compiler, {
publicPath: webpackConfig.output.publicPath,
reload: true,
noInfo: true
}))
複製代碼
// src/js/index.js 文件
if (module.hot) {
module.hot.accept()
}
複製代碼
關於 webpack-hot-middleware 的更多配置細節,請看文檔
這邊須要注意的是:
1. 光是這麼寫的話,webpack hot module 只能支持 JS 部分的修改,若是須要支持樣式文件( css / less / sass ... )的 hot reload ,就不能使用 extract-text-webpack-plugin 將樣式文件剝離出去,不然沒法監聽修改、實時刷新。
2. webpack hot module 原生是不支持 html 的熱替換的,可是不少開發者對於這塊的需求比較大,因而我找了一個相對比較簡單的方法,來支持視圖文件的熱更新
// src/js/index.js 文件
import axios from 'axios'
// styles
import 'less/index.less'
const isDev = process.env.NODE_ENV === 'development'
// 在開發環境下,使用 raw-loader 引入 ejs 模板文件,強制 webpack 將其視爲須要熱更新的一部分 bundle
if (isDev) {
require('raw-loader!../tpls/index.ejs')
}
...
if (module.hot) {
module.hot.accept()
/** * 監聽 hot module 完成事件,從新從服務端獲取模板,替換掉原來的 document * 這種熱更新方式須要注意: * 1. 若是你在元素上以前綁定了事件,那麼熱更新以後,這些事件可能會失效 * 2. 若是事件在模塊卸載以前未銷燬,可能會致使內存泄漏 */
module.hot.dispose(() => {
const href = window.location.href
axios.get(href).then(res => {
const template = res.data
document.body.innerHTML = template
}).catch(e => {
console.error(e)
})
})
}
複製代碼
// webpack.dev.config.js
plugins: [
...
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify('development')
})
...
]
複製代碼
// webpack.prod.config.js
plugins: [
...
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify('production')
})
...
]
複製代碼
OK,如你所願,如今視圖文件也支持熱更新啦。😃😃
webpack-hot-middleware
默認繼承了 overlay
,因此當熱更新配置完成之後,overlay
報錯功能也能正常使用了
最後來看一下 package.json
裏的啓動腳本,這邊沒啥難度,就直接上代碼了
"scripts": {
"clear": "rimraf dist",
"server": "cross-env NODE_ENV=production node ./server/bin/server.js",
"dev": "cross-env NODE_ENV=development nodemon --watch server ./server/bin/server.js",
"build": "npm run clear && cross-env NODE_ENV=production webpack --env production --config ./build/webpack.prod.config.js",
"test": "echo \"Error: no test specified\" && exit 1"
}
複製代碼
當客戶端代碼變更時 webpack 會自動幫咱們編譯重啓,可是服務端的代碼變更卻不會實時刷新,這時須要用到 nodemon
,設置好監聽目錄之後,服務端的任何代碼修改就能被 nodemon
監聽,服務自動重啓,很是方便。
這邊也有一個小細節須要注意,nodemon --watch 最好指定監聽服務端文件夾,由於畢竟只有服務端的代碼修改才須要重啓服務,否則默認監聽整個根目錄,寫個樣式都能重啓服務,簡直要把人煩死。
項目總體搭完後再回頭看,仍是有很多須要注意和值得學習的地方。雖然踩了很多坑,但也對其中的一些原理有了更深刻的瞭解。
得益於前端腳手架工具,讓咱們能在大部分項目中一鍵生成項目的基礎配置,免去了不少工程搭建的煩惱,但這種方便在造福了開發者的同時,卻也弱化了前端工程師的工程架構能力。現實中總有一些腳手架工具沒辦法的觸及到的業務場景,這時就須要開發者主動尋求解決方案,甚至本身動手構建工程,以得到開發的最佳靈活性。
完整項目地址能夠查看個人 GitHub ,喜歡的話給個 Star⭐️ ,多謝多謝~😃😃