填坑的時候也借鑒了很多人寫的文章。所以有很多部分是融合了超級多前人的經驗總結,然后自己結合實際進行操作的做筆記。部分地方可能有重復,看得懂就好了。
1.全局安裝webpack npm install -g webpack
2.創建項目文件,初始化項目文件目錄 npm init
到項目文件下安裝webpack npm install webpack
3.安裝全局的webpack-cli npm install -g webpack-cli //獲取當前webpack版本號配置文件
4.配置mode
默認有production和development兩種模式可以設置
命令行設置 webpack --mode development
5.新建入口 在項目文件目錄下新建src文件夾,新建index.js文件入口
6.文件打包 命令行輸入 webpack --mode development 或 webpack --mode production
webpack將會默認打包,將./src/index.js文件打包成./dist/main.js文件(自動生成dist文件夾和main.js文件)
7.建立html文件,在項目目錄下建立html文件,可以直接引用dist/main.js文件。
注意,我們的 script 引用的文件是 dist/main.js,而不是 index.js。這正是前端開發領域的一個趨勢:開發的源文件(例子中的 index.js)與最終部署的文件(例子中的 dist/main.js)是區分開的,之所以這樣,是因為開發環境與用戶的使用環境并不一致。比如我們可以在開發環境使用 ES2017 甚至 ES2018 的特性,而用戶的瀏覽器不見得支持 - 這也是 webpack 等打包工具的一個意義,它們能夠輔助我們構建出在目標用戶瀏覽器上正常運行的代碼。
8.其他參數配置
我們如果需要配置webpack指令的其他參數,只需要在webpack –mode production/development后加上其他參數即可,如:
webpack --mode development --watch --progress --display-modules --colors --display-reasons
實時刷新
9.監控文件
watch選項最為直觀,但在默認情況下,watch選項是關閉狀態。
啟用watch選項 webpack --mode development --watch
10.刷新瀏覽器(看官方文檔容易填坑,奈何英語emmmm)
https://github.com/webpack/webpack-dev-server
https://webpack.js.org/configuration/dev-server/#devserver
webpack-dev-server,一個基于expressjs的開發服務器,提供實時刷新瀏覽器頁面的功能。
安裝webpack-dev-server
首先在項目下安裝 webpack-dev-server: npm install -g webpack-dev-server
然后在命令行下執行webpack-dev-server --mode development --output-public-path dist
webpack-dev-server是一個輕量級的服務器,修改文件源碼后,自動刷新頁面將修改同步到頁面上安裝webpack-dev-server:①全局安裝:npm install webpack-dev-server -g
②在項目中安裝并將依賴寫在package.json文件中:npm install webpack-dev-server --save-dev
③使用命令webpack-dev-server --hot --inline完成自動刷新
④默認的端口號是8080,如果需要8080端口被占用,就需要改端口,webpack-dev-server --port 3000(將端口號改為3000)
⑤啟動服務,輸入localhost:端口號,就顯示發布的所有跟馬云祿,如果項目根目錄中沒有index.html文件,就會在瀏覽器中列出項目根目錄中的所有的文件夾。
⑥當使用webpack-dev-server --mode development --hot --inline命令時,在每次修改文件,是將文件打包保存在內存中并沒有寫在磁盤里,這種打包得到的文件和項目根目錄中的index.html位于同一級。使用webpack命令將打包后的文件保存在磁盤中例如在index.html文件中引入通過webpack-dev-server --mode development --hot --inline打包的build.js
<script src="build.js"></script> 在index.html文件中引入通過webpack命令打包的build.js
<script src="./build/build.js"></script>
--inline 內聯模式,在開發服務器的兩種不同模式之間切換。默認情況下, 應用程序將被啟用內嵌模式。這意味著將在包中插入一個腳本來處理實時重裝, 并且生成消息將出現在瀏覽器控制臺中。
--hot 啟用熱模塊更換功能
⑦webpack自帶的watch命令與webpack-dev-server的區別
--watch是文件修改后自動打包,webpack-dev-server是修改后發布到服務器上
⑧webpack-dev-server --mode development --content-base src --inline --hot//顯示只針對src路徑下的文件刷新,文件修改之后瀏覽器自動刷新,如果要打開的文件和打包的文件不在一個文件夾內,最好不要設定文件夾
11.打包css文件
在項目目錄下安裝處理css文件的loader
命令行輸入:npm install css-loader style-loader --save-dev
css-loader //處理css文件
style-loader //將css-loader處理后的文件作為樣式標簽<style>插入到html文件中
在處理css文件的時候要指定loader,如在index.js文件里輸入require('style-loader!css-loader!./style.css')
或者直接在命令行輸入webpack --mode development --module-bind "css=style-loader!css-loader"
12--progress(查看進度)
13--display-modules(顯示隱藏的模塊)
14 --display-reasons(顯示打包原因)
15.配置,webpack需要傳入配置對象,因此進行新建配置文件webpack.config.js,或者使用node.js內置的path模塊進行配置,并在它前面加上 __dirname這個全局變量。可以防止不同操作系統之間的文件路徑問題,并且可以使相對路徑按照預期工作。
①先寫moudule.exports={};進行配置;
②入口文件配置,entry="入口文件路徑,如./src/js/main.js";
③輸出文件配置,output={path:__dirname+"輸出文件路徑,如/dist/js/bundle.js"};//要創建dist文件夾
__dirname為運行時的當前路徑;
另一種方式,先定義const path = require("path");//引入nodejs的path模塊
然后在輸出文件路徑path:path.resolve(__dirname,"./dist/js/bundle.js");
//path.resolve()方法解析了當前路徑,將相對路徑改為絕對路徑。
④重新指定配置文件名
webpack --config 文件名
如webpack --config webpack.dev.config.js
16.定義執行腳本,可以在package.json中設置
在script中設置,如設置"webpack":"webpack --mode development --config webpack.config.js --progress --display-modules --colors --display-reason",//--colors(彩色顯示)
直接執行上面的腳本npm run webpack
17.entry配置(chunk),
①字符串表示,單輸入,所有依賴都要在入口文件中指定,如entry:"./src/js/main.js",
②數組表示,多輸入,兩個需要打包到一起的文件可以在配置文件的entry中用數組表示,如entry:["./app/entry1", "./app/entry2"],//這兩個文件將會打包到一起
③對象表示(哈希),多頁面入口,entry:{page1:"./page1",page2:["./src/a.js","./src/b.js"]},
這三種方式都會把文件打包到輸出文件中。
18.output配置,
①單個入口起點,就設置一個出口,如output:{filename:'bundle.js',path:'/dist/js'}
②多個入口起點,可以設置name或者hash,如output:{filename:'[name].js',path:__dirname+'/dist/js'}
或output:{filename:'[name]-[hash].js',path:__dirname+'/dist/js'}
或output:{filename:'[name]-[chunkhash].js',path:__dirname+'/dist/js'}
hash值可以認為是版本號或者MD5值保證每個文件的唯一性,每一次修改之后生成文件的hash值不一樣,文件名不一樣。
③publicPath可以理解為占位符。當需要上線的時候可以將服務器地址設置到這個參數中,output:{path:'xxx',filename:'xxx',publicPath:'https://cdn.com/'}
插件(plugin)
插件是 webpack 的支柱功能。webpack 自身也是構建在 webpack 配置中用到的相同的插件系統之上。插件目的在于解決 loader 無法實現的其他事。
19.插件html-webpack-plugin
要引用之前先安裝,在項目文件目錄下安裝 npm install html-webpack-plugin --save-dev
安裝好之后,在webpack.config.js配置文件中對插件的引用
var htmlWebpackPlugin = require('html-webpack-plugin');//commonJS寫法
在module.exports中添加plugin部分進行插件初始化,
插件列表,當多個bundle需要共享一些相同的插件時,CommonChunkPlugin可以將這些依賴項提取到一個共享包中,以免重復。
plugins:[
new webpack.optimize.CommonsChunkPlugin({
.....
}),
new htmlChunkPlugin({
template:'index.html',//自定義模板
filename:'index-[hash].html',//生成文件名
inject:'head',//指定鏈接注入在<head>標簽中還是<body>標簽中,為false值時表示不自動注入文件中,需要手動設置
title:'webpack demo',//傳遞參數,可以在index.html模板中引用
minify:{//壓縮html文件,具體參數設置可以查看官方文檔
}
})
]
index.html引用配置文件中的參數,JS語法模式,要使用JS語句可以使用<%%>將每行代碼包裹起來。賦值可以使用<%=xxx %>,如<%=htmlWebpackPlugin.options.title%>就可以取到配置文件中定義的title的值。
在配置文件中可以任意的配置參數向html文件進行傳參。
自定義引用的js文件可以直接寫到html文件中
如在html文件中相對應的位置寫,<script src="<%=htmlWebpackPlugin.files.chunks.main.entry %>"></script>
<script src="<%=htmlWebpackPlugin.chunks.a.entry%>"></script>
chunk是文件入口
以上是單文件引用的示例,多文件引用則需要調用多次的html-webpack-plugin插件,設置方式相同
多頁面使用同一個頁面模板,可以定義htmlWebpackPlugin插件中的chunks參數,進行設置不同的頁面引用不同的chunks,如設置chunks:['main','a']
excludeChunks:['a'],//指出排除的chunk
直接將公共初始化腳本嵌入到html頁面中,inline方式,在html模板中加上腳本源碼引用代碼,
如<script type="text/javascript">
<%=compilation.assets[htmlWebpackPlugin.files.chunks.main.entry.substr(htmlWebpackPlugin.files.publicPath.length)].source()%>
</script>
//.substr()的作用是將刪除publicPath部分的絕對路徑獲取文件的相對路徑。
按照文件順序引用js文件可以手動設置for循環出htmlWebpackPlugin.files.chunks的entry值插入文件中。
20.loader
loader 讓 webpack 能夠去處理那些非 JavaScript 文件(webpack 自身只理解 JavaScript)。loader 可以將所有類型的文件轉換為 webpack 能夠處理的有效模塊,然后你就可以利用 webpack 的打包能力,對它們進行處理。
本質上,webpack loader 將所有類型的文件,轉換為應用程序的依賴圖(和最終的 bundle)可以直接引用的模塊。
loader能夠import導入任何類型的模塊。
在webpack的配置中loader有兩個目標:
①.test屬性,用于表示出應該被對應的loader進行轉換的某個或某些文件。
②.use屬性,表示進行轉換時,應該使用那個loader。
使用方式:
①配置,在webpack.config.js中指定
②內聯,在每個import語句中顯示指定loader
③CLI,在shell命令中指定
在webpack.config.js中配置loader
在module.exports中添加屬性module
如安裝babel插件(js編譯器),使用此插件轉換ES6代碼,如何安裝根據官網進行安裝:
module:{
rules:[
{ test:/\.js$/,
exclude:/node_modules/,
loader:"babel-loader"
}
]
}
設置preset,指定preset(預配置)設置如何處理js文件
①在rules中設置query:{presets:['latest']}
②在根目錄下創建一個.babelrc文件,其中內容為:
{
"presets":["env"]
}
③在package.json中,增加babel屬性:
"babel":{
"presets":["latset"]
}
21.優化
可以在配置文件中,設置打包范圍,如exclude設置不處理哪些模塊,include處理哪些文件下的內容。
具體可以看官方文檔進行配置。
者:liuxuan 前端名獅
轉發鏈接:https://mp.weixin.qq.com/s/6K6GUHcLwLG4mzfaYtVMBQ
SSR大家肯定都不陌生,通過服務端渲染,可以優化SEO抓取,提升首頁加載速度等,我在學習SSR的時候,看過很多文章,有些對我有很大的啟發作用,有些就只是照搬官網文檔。通過幾天的學習,我對SSR有了一些了解,也從頭開始完整的配置出了SSR的開發環境,所以想通過這篇文章,總結一些經驗,同時希望能夠對學習SSR的朋友起到一點幫助。
我會通過五個步驟,一步步帶你完成SSR的配置:
如果你現在對于我上面說的還不太了解,沒有關系,跟著我一步步向下走,最終你也可以獨立配置一個SSR開發項目,所有源碼我會放到github上,大家可以作為參考。
地址:https://github.com/leocoder351/vue-ssr-demo
這個配置相信大家都會,就是基于weback + vue的一個常規開發配置,這里我會放一些關鍵代碼,完整代碼可以去github查看。
- node_modules
- components
- Bar.vue
- Foo.vue
- App.vue
- app.js
- index.html
- webpack.config.js
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
import Vue from 'vue';
import App from './App.vue';
let app = new Vue({
el: '#app',
render: h => h(App)
});
<template>
<div>
<Foo></Foo>
<Bar></Bar>
</div>
</template>
<script>import Foo from './components/Foo.vue';
import Bar from './components/Bar.vue';
export default {
components: {
Foo, Bar
}
}</script>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>純瀏覽器渲染</title>
</head>
<body>
<div id="app"></div>
</body>
</html>
<template>
<div class="foo">
<h1>Foo Component</h1>
</div>
</template>
<style>.foo {
background: yellowgreen;
}</style>
<template>
<div class="bar">
<h1>Bar Component</h1>
</div>
</template>
<style>.bar {
background: bisque;
}</style>
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
module.exports = {
mode: 'development',
entry: './app.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'bundle.js'
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader'
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader']
// 如果需要單獨抽出CSS文件,用下面這個配置
// use: ExtractTextPlugin.extract({
// fallback: 'vue-style-loader',
// use: [
// 'css-loader',
// 'postcss-loader'
// ]
// })
},
{
test: /\.(jpg|jpeg|png|gif|svg)$/,
use: {
loader: 'url-loader',
options: {
limit: 10000 // 10Kb
}
}
},
{
test: /\.vue$/,
use: 'vue-loader'
}
]
},
plugins: [
new VueLoaderPlugin(),
new HtmlWebpackPlugin({
template: './index.html'
}),
// 如果需要單獨抽出CSS文件,用下面這個配置
// new ExtractTextPlugin("styles.css")
]
};
module.exports = {
plugins: [
require('autoprefixer')
]
};
{
"presets": [
"@babel/preset-env"
],
"plugins": [
// 讓其支持動態路由的寫法 const Foo = () => import('../components/Foo.vue')
"dynamic-import-webpack"
]
}
{
"name": "01",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start": "yarn run dev",
"dev": "webpack-dev-server",
"build": "webpack"
},
"dependencies": {
"vue": "^2.5.17"
},
"devDependencies": {
"@babel/core": "^7.1.2",
"@babel/preset-env": "^7.1.0",
"babel-plugin-dynamic-import-webpack": "^1.1.0",
"autoprefixer": "^9.1.5",
"babel-loader": "^8.0.4",
"css-loader": "^1.0.0",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"file-loader": "^2.0.0",
"html-webpack-plugin": "^3.2.0",
"postcss": "^7.0.5",
"postcss-loader": "^3.0.0",
"url-loader": "^1.1.1",
"vue-loader": "^15.4.2",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.5.17",
"webpack": "^4.20.2",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.9"
}
}
yarn start
yarn run build
最終效果截圖:
完整代碼查看:https://github.com/leocoder351/vue-ssr-demo/tree/master/01
服務端渲染SSR,類似于同構,最終要讓一份代碼既可以在服務端運行,也可以在客戶端運行。如果說在SSR的過程中出現問題,還可以回滾到純瀏覽器渲染,保證用戶正常看到頁面。
那么,順著這個思路,肯定就會有兩個webpack的入口文件,一個用于瀏覽器端渲染weboack.client.config.js,一個用于服務端渲染webpack.server.config.js,將它們的公有部分抽出來作為webpack.base.cofig.js,后續通過webpack-merge進行合并。同時,也要有一個server來提供http服務,我這里用的是koa。
我們來看一下新的目錄結構:
- node_modules
- config // 新增
- webpack.base.config.js
- webpack.client.config.js
- webpack.server.config.js
- src
- components
- Bar.vue
- Foo.vue
- App.vue
- app.js
- entry-client.js // 新增
- entry-server.js // 新增
- index.html
- index.ssr.html // 新增
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
在純客戶端應用程序(client-only app)中,每個用戶會在他們各自的瀏覽器中使用新的應用程序實例。對于服務器端渲染,我們也希望如此:每個請求應該都是全新的、獨立的應用程序實例,以便不會有交叉請求造成的狀態污染(cross-request state pollution)。
所以,我們要對app.js做修改,將其包裝為一個工廠函數,每次調用都會生成一個全新的根組件。
app.js
import Vue from 'vue';
import App from './App.vue';
export function createApp() {
const app = new Vue({
render: h => h(App)
});
return { app };
}
在瀏覽器端,我們直接新建一個根組件,然后將其掛載就可以了。
entry-client.js
import { createApp } from './app.js';
const { app } = createApp();
app.$mount('#app');
在服務器端,我們就要返回一個函數,該函數的作用是接收一個context參數,同時每次都返回一個新的根組件。這個context在這里我們還不會用到,后續的步驟會用到它。
entry-server.js
import { createApp } from './app.js';
export default context => {
const { app } = createApp();
return app;
}
然后再來看一下index.ssr.html
index.ssr.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>服務端渲染</title>
</head>
<body>
<!--vue-ssr-outlet-->
<script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>
</body>
</html>
<!--vue-ssr-outlet-->的作用是作為一個占位符,后續通過vue-server-renderer插件,將服務器解析出的組件html字符串插入到這里。
<script type="text/javascript" src="<%= htmlWebpackPlugin.options.files.js %>"></script>是為了將webpack通過webpack.client.config.js打包出的文件放到這里(這里是為了簡單演示,后續會有別的辦法來做這個事情)。
因為服務端吐出來的就是一個html字符串,后續的Vue相關的響應式、事件響應等等,都需要瀏覽器端來接管,所以就需要將為瀏覽器端渲染打包的文件在這里引入。
用官方的詞來說,叫客戶端激活(client-side hydration)。
所謂客戶端激活,指的是 Vue 在瀏覽器端接管由服務端發送的靜態 HTML,使其變為由 Vue 管理的動態 DOM 的過程。
在 entry-client.js 中,我們用下面這行掛載(mount)應用程序:
// 這里假定 App.vue template 根元素的 `id="app"`
app.$mount('#app')
由于服務器已經渲染好了 HTML,我們顯然無需將其丟棄再重新創建所有的 DOM 元素。相反,我們需要"激活"這些靜態的 HTML,然后使他們成為動態的(能夠響應后續的數據變化)。
如果你檢查服務器渲染的輸出結果,你會注意到應用程序的根元素上添加了一個特殊的屬性:
<div id="app" data-server-rendered="true">
Vue在瀏覽器端就依靠這個屬性將服務器吐出來的html進行激活,我們一會自己構建一下就可以看到了。
接下來我們看一下webpack相關的配置:
webpack.base.config.js
const path = require('path');
const VueLoaderPlugin = require('vue-loader/lib/plugin');
module.exports = {
mode: 'development',
resolve: {
extensions: ['.js', '.vue']
},
output: {
path: path.resolve(__dirname, '../dist'),
filename: '[name].bundle.js'
},
module: {
rules: [
{
test: /\.vue$/,
use: 'vue-loader'
},
{
test: /\.js$/,
use: 'babel-loader'
},
{
test: /\.css$/,
use: ['vue-style-loader', 'css-loader', 'postcss-loader']
},
{
test: /\.(jpg|jpeg|png|gif|svg)$/,
use: {
loader: 'url-loader',
options: {
limit: 10000 // 10Kb
}
}
}
]
},
plugins: [
new VueLoaderPlugin()
]
};
webpack.client.config.js
const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');
module.exports = merge(base, {
entry: {
client: path.resolve(__dirname, '../src/entry-client.js')
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.html'),
filename: 'index.html'
})
]
});
注意,這里的入口文件變成了entry-client.js,將其打包出的client.bundle.js插入到index.html中。
webpack.server.config.js
const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const base = require('./webpack.base.config');
module.exports = merge(base, {
target: 'node',
entry: {
server: path.resolve(__dirname, '../src/entry-server.js')
},
output: {
libraryTarget: 'commonjs2'
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.ssr.html'),
filename: 'index.ssr.html',
files: {
js: 'client.bundle.js'
},
excludeChunks: ['server']
})
]
});
這里有幾個點需要注意一下:
這里關于HtmlWebpackPlugin配置的意思是,不要在index.ssr.html中引入打包出的server.bundle.js,要引為瀏覽器打包的client.bundle.js,原因前面說過了,是為了讓Vue可以將服務器吐出來的html進行激活,從而接管后續響應。
那么打包出的server.bundle.js在哪用呢?接著往下看就知道了~~
package.json
{
"name": "01",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"start": "yarn run dev",
"dev": "webpack-dev-server",
"build:client": "webpack --config config/webpack.client.config.js",
"build:server": "webpack --config config/webpack.server.config.js"
},
"dependencies": {
"koa": "^2.5.3",
"koa-router": "^7.4.0",
"koa-static": "^5.0.0",
"vue": "^2.5.17",
"vue-server-renderer": "^2.5.17"
},
"devDependencies": {
"@babel/core": "^7.1.2",
"@babel/preset-env": "^7.1.0",
"autoprefixer": "^9.1.5",
"babel-loader": "^8.0.4",
"css-loader": "^1.0.0",
"extract-text-webpack-plugin": "^4.0.0-beta.0",
"file-loader": "^2.0.0",
"html-webpack-plugin": "^3.2.0",
"postcss": "^7.0.5",
"postcss-loader": "^3.0.0",
"style-loader": "^0.23.0",
"url-loader": "^1.1.1",
"vue-loader": "^15.4.2",
"vue-style-loader": "^4.1.2",
"vue-template-compiler": "^2.5.17",
"webpack": "^4.20.2",
"webpack-cli": "^3.1.2",
"webpack-dev-server": "^3.1.9",
"webpack-merge": "^4.1.4"
}
}
接下來我們看server端關于http服務的代碼:
server/server.js
const Koa = require('koa');
const Router = require('koa-router');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs');
const backendApp = new Koa();
const frontendApp = new Koa();
const backendRouter = new Router();
const frontendRouter = new Router();
const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});
// 后端Server
backendRouter.get('/index', (ctx, next) => {
// 這里用 renderToString 的 promise 返回的 html 有問題,沒有樣式
renderer.renderToString((err, html) => {
if (err) {
console.error(err);
ctx.status = 500;
ctx.body = '服務器內部錯誤';
} else {
console.log(html);
ctx.status = 200;
ctx.body = html;
}
});
});
backendApp.use(serve(path.resolve(__dirname, '../dist')));
backendApp
.use(backendRouter.routes())
.use(backendRouter.allowedMethods());
backendApp.listen(3000, () => {
console.log('服務器端渲染地址:http://localhost:3000');
});
// 前端Server
frontendRouter.get('/index', (ctx, next) => {
let html = fs.readFileSync(path.resolve(__dirname, '../dist/index.html'), 'utf-8');
ctx.type = 'html';
ctx.status = 200;
ctx.body = html;
});
frontendApp.use(serve(path.resolve(__dirname, '../dist')));
frontendApp
.use(frontendRouter.routes())
.use(frontendRouter.allowedMethods());
frontendApp.listen(3001, () => {
console.log('瀏覽器端渲染地址:http://localhost:3001');
});
這里對兩個端口進行監聽,3000端口是服務端渲染,3001端口是直接輸出index.html,然后會在瀏覽器端走Vue的那一套,主要是為了和服務端渲染做對比使用。
這里的關鍵代碼是如何在服務端去輸出html`字符串。
const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.bundle.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});
可以看到,server.bundle.js在這里被使用了,因為它的入口是一個函數,接收context作為參數(非必傳),輸出一個根組件app。
這里我們用到了vue-server-renderer插件,它有兩個方法可以做渲染,一個是createRenderer,另一個是createBundleRenderer。
const { createRenderer } = require('vue-server-renderer')
const renderer = createRenderer({ /* 選項 */ })
const { createBundleRenderer } = require('vue-server-renderer')
const renderer = createBundleRenderer(serverBundle, { /* 選項 */ })
createRenderer無法接收為服務端打包出的server.bundle.js文件,所以這里只能用createBundleRenderer。
serverBundle 參數可以是以下之一:
這里我們引入的是.js文件,后續會介紹如何使用.json文件以及有什么好處。
renderer.renderToString((err, html) => {
if (err) {
console.error(err);
ctx.status = 500;
ctx.body = '服務器內部錯誤';
} else {
console.log(html);
ctx.status = 200;
ctx.body = html;
}
});
使用createRenderer和createBundleRenderer返回的renderer函數包含兩個方法renderToString和renderToStream,我們這里用的是renderToString成功后直接返回一個完整的字符串,renderToStream返回的是一個Node流。
renderToString支持Promise,但是我在使用Prmoise形式的時候樣式會渲染不出來,暫時還不知道原因,如果大家知道的話可以給我留言哦。
配置基本就完成了,來看一下如何運行。
yarn run build:client // 打包瀏覽器端需要bundle
yarn run build:server // 打包SSR需要bundle
yarn start // 其實就是 node server/server.js,提供http服務
最終效果展示:
訪問http://localhost:3000/index
我們看到了前面提過的data-server-rendered="true"屬性,同時會加載client.bundle.js文件,為了讓Vue在瀏覽器端做后續接管。
訪問http://localhost:3001/index還和第一步實現的效果一樣,純瀏覽器渲染,這里就不放截圖了。
完整代碼查看:https://github.com/leocoder351/vue-ssr-demo/tree/master/02
如果SSR需要初始化一些異步數據,那么流程就會變得復雜一些。
我們先提出幾個問題:
帶著問題我們向下走,希望看完這篇文章的時候上面的問題你都找到了答案。
服務器端渲染和瀏覽器端渲染組件經過的生命周期是有區別的,在服務器端,只會經歷beforeCreate和created兩個生命周期。因為SSR服務器直接吐出html字符串就好了,不會渲染DOM結構,所以不存在beforeMount和mounted的,也不會對其進行更新,所以也就不存在beforeUpdate和updated等。
我們先來想一下,在純瀏覽器渲染的Vue項目中,我們是怎么獲取異步數據并渲染到組件中的?一般是在created或者mounted生命周期里發起異步請求,然后在成功回調里執行this.data = xxx,Vue監聽到數據發生改變,走后面的Dom Diff,打patch,做DOM更新。
那么服務端渲染可不可以也這么做呢?答案是不行的。
所以,參考一下官方文檔,我們可以得到以下思路:
正常情況下,通過這幾個步驟,服務端吐出來的html字符串相應組件的數據都是最新的,所以第4步并不會引起DOM更新,但如果出了某些問題,吐出來的html字符串沒有相應數據,Vue也可以在瀏覽器端通過`Vuex注入數據,進行DOM更新。
更新后的目錄結構:
- node_modules
- config
- webpack.base.config.js
- webpack.client.config.js
- webpack.server.config.js
- src
- components
- Bar.vue
- Foo.vue
- store // 新增
store.js
- App.vue
- app.js
- entry-client.js
- entry-server.js
- index.html
- index.ssr.html
- package.json
- yarn.lock
- postcss.config.js
- .babelrc
- .gitignore
先來看一下store.js:
store/store.js
import Vue from 'vue';
import Vuex from 'vuex';
Vue.use(Vuex);
const fetchBar = function() {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('bar 組件返回 ajax 數據');
}, 1000);
});
};
function createStore() {
const store = new Vuex.Store({
state: {
bar: ''
},
mutations: {
'SET_BAR'(state, data) {
state.bar = data;
}
},
actions: {
fetchBar({ commit }) {
return fetchBar().then((data) => {
commit('SET_BAR', data);
}).catch((err) => {
console.error(err);
})
}
}
});
if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
store.replaceState(window.__INITIAL_STATE__);
}
return store;
}
export default createStore;
typeof window
如果不太了解Vuex,可以去Vuex官網先看一些基本概念。
這里fetchBar可以看成是一個異步請求,這里用setTimeout模擬。在成功回調中commit相應的mutation進行狀態修改。
這里有一段關鍵代碼:
if (typeof window !== 'undefined' && window.__INITIAL_STATE__) {
console.log('window.__INITIAL_STATE__', window.__INITIAL_STATE__);
store.replaceState(window.__INITIAL_STATE__);
}
因為store.js同樣也會被打包到服務器運行的server.bundle.js中,所以運行環境不一定是瀏覽器,這里需要對window做判斷,防止報錯,同時如果有window.__INITIAL_STATE__屬性,說明服務器已經把所有初始化需要的異步數據都獲取完成了,要對store中的狀態做一個替換,保證統一。
components/Bar.vue
<template>
<div class="bar">
<h1 @click="onHandleClick">Bar Component</h1>
<h2>異步Ajax數據:</h2>
<span>{{ msg }}</span>
</div>
</template>
<script> const fetchInitialData = ({ store }) => {
store.dispatch('fetchBar');
};
export default {
asyncData: fetchInitialData,
methods: {
onHandleClick() {
alert('bar');
}
},
mounted() {
// 因為服務端渲染只有 beforeCreate 和 created 兩個生命周期,不會走這里
// 所以把調用 Ajax 初始化數據也寫在這里,是為了供單獨瀏覽器渲染使用
let store = this.$store;
fetchInitialData({ store });
},
computed: {
msg() {
return this.$store.state.bar;
}
}
}</script>
<style>.bar {
background: bisque;
}</style>
這里在Bar組件的默認導出對象中增加了一個方法asyncData,在該方法中會dispatch相應的action,進行異步數據獲取。
需要注意的是,我在mounted中也寫了獲取數據的代碼,這是為什么呢? 因為想要做到同構,代碼單獨在瀏覽器端運行,也應該是沒有問題的,又由于服務器沒有mounted生命周期,所以我寫在這里就可以解決單獨在瀏覽器環境使用也可以發起同樣的異步請求去初始化數據。
components/Foo.vue
<template>
<div class="foo">
<h1 @click="onHandleClick">Foo Component</h1>
</div>
</template>
<script>export default {
methods: {
onHandleClick() {
alert('foo');
}
},
}</script>
<style>.foo {
background: yellowgreen;
}</style>
這里我對兩個組件都添加了一個點擊事件,為的是證明在服務器吐出首頁html后,后續的步驟都會被瀏覽器端的Vue接管,可以正常執行后面的操作。
app.js
import Vue from 'vue';
import createStore from './store/store.js';
import App from './App.vue';
export function createApp() {
const store = createStore();
const app = new Vue({
store,
render: h => h(App)
});
return { app, store, App };
}
在建立根組件的時候,要把Vuex的store傳進去,同時要返回,后續會用到。
最后來看一下entry-server.js,關鍵步驟在這里:
entry-server.js
import { createApp } from './app.js';
export default context => {
return new Promise((resolve, reject) => {
const { app, store, App } = createApp();
let components = App.components;
let asyncDataPromiseFns = [];
Object.values(components).forEach(component => {
if (component.asyncData) {
asyncDataPromiseFns.push(component.asyncData({ store }));
}
});
Promise.all(asyncDataPromiseFns).then((result) => {
// 當使用 template 時,context.state 將作為 window.__INITIAL_STATE__ 狀態,自動嵌入到最終的 HTML 中
context.state = store.state;
console.log(222);
console.log(store.state);
console.log(context.state);
console.log(context);
resolve(app);
}, reject);
});
}
我們通過導出的App拿到了所有它下面的components,然后遍歷,找出哪些component有asyncData方法,有的話調用并傳入store,該方法會返回一個Promise,我們使用Promise.all等所有的異步方法都成功返回,才resolve(app)。
context.state = store.state作用是,當使用createBundleRenderer時,如果設置了template選項,那么會把context.state的值作為window.__INITIAL_STATE__自動插入到模板html中。
這里需要大家多思考一下,弄清楚整個服務端渲染的邏輯。
如何運行:
yarn run build:client
yarn run build:server
yarn start
最終效果截圖:
服務端渲染:打開http://localhost:3000/index
可以看到window.__INITIAL_STATE__被自動插入了。
我們來對比一下SSR到底對加載性能有什么影響吧。
服務端渲染時performance截圖:
純瀏覽器端渲染時performance截圖:
同樣都是在fast 3G網絡模式下,純瀏覽器端渲染首屏加載花費時間2.9s,因為client.js加載就花費了2.27s,因為沒有client.js就沒有Vue,也就沒有后面的東西了。
服務端渲染首屏時間花費0.8s,雖然client.js加載扔花費2.27s,但是首屏已經不需要它了,它是為了讓Vue在瀏覽器端進行后續接管。
從這我們可以真正的看到,服務端渲染對于提升首屏的響應速度是很有作用的。
當然有的同學可能會問,在服務端渲染獲取初始ajax數據時,我們還延時了1s,在這個時間用戶也是看不到頁面的。沒錯,接口的時間我們無法避免,就算是純瀏覽器渲染,首頁該調接口還是得調,如果接口響應慢,那么純瀏覽器渲染看到完整頁面的時間會更慢。
完整代碼查看:https://github.com/leocoder351/vue-ssr-demo/tree/master/03
前面我們創建服務端renderer的方法是:
const bundle = fs.readFileSync(path.resolve(__dirname, '../dist/server.js'), 'utf-8');
const renderer = require('vue-server-renderer').createBundleRenderer(bundle, {
template: fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8')
});
serverBundle我們用的是打包出的server.bundle.js文件。這樣做的話,在每次編輯過應用程序源代碼之后,都必須停止并重啟服務。這在開發過程中會影響開發效率。此外,Node.js 本身不支持 source map。
vue-server-renderer 提供一個名為 createBundleRenderer 的 API,用于處理此問題,通過使用 webpack 的自定義插件,server bundle 將生成為可傳遞到 bundle renderer 的特殊 JSON 文件。所創建的 bundle renderer,用法和普通 renderer 相同,但是 bundle renderer 提供以下優點:
preload和prefetch有不了解的話可以自行查一下它們的作用哈。
那么我們來修改webpack配置:
webpack.client.config.js
const path = require('path');
const merge = require('webpack-merge');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin');
const base = require('./webpack.base.config');
module.exports = merge(base, {
entry: {
client: path.resolve(__dirname, '../src/entry-client.js')
},
plugins: [
new VueSSRClientPlugin(), // 新增
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.html'),
filename: 'index.html'
})
]
});
webpack.server.config.js
const path = require('path');
const merge = require('webpack-merge');
const nodeExternals = require('webpack-node-externals');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin');
const base = require('./webpack.base.config');
module.exports = merge(base, {
target: 'node',
// 對 bundle renderer 提供 source map 支持
devtool: '#source-map',
entry: {
server: path.resolve(__dirname, '../src/entry-server.js')
},
externals: [nodeExternals()], // 新增
output: {
libraryTarget: 'commonjs2'
},
plugins: [
new VueSSRServerPlugin(), // 這個要放到第一個寫,否則 CopyWebpackPlugin 不起作用,原因還沒查清楚
new HtmlWebpackPlugin({
template: path.resolve(__dirname, '../src/index.ssr.html'),
filename: 'index.ssr.html',
files: {
js: 'client.bundle.js'
},
excludeChunks: ['server']
})
]
});
因為是服務端引用模塊,所以不需要打包node_modules中的依賴,直接在代碼中require引用就好,所以配置externals: [nodeExternals()]。
兩個配置文件會分別生成vue-ssr-client-manifest.json和vue-ssr-server-bundle.json。作為createBundleRenderer的參數。
來看server.js
server.js
const serverBundle = require(path.resolve(__dirname, '../dist/vue-ssr-server-bundle.json'));
const clientManifest = require(path.resolve(__dirname, '../dist/vue-ssr-client-manifest.json'));
const template = fs.readFileSync(path.resolve(__dirname, '../dist/index.ssr.html'), 'utf-8');
const renderer = createBundleRenderer(serverBundle, {
runInNewContext: false,
template: template,
clientManifest: clientManifest
});
效果和第三步就是一樣的啦,就不截圖了,完整代碼查看github。
這里和第四步不一樣的是引入了vue-router,更接近于實際開發項目。
在src下新增router目錄。
router/index.js
import Vue from 'vue';
import Router from 'vue-router';
import Bar from '../components/Bar.vue';
Vue.use(Router);
function createRouter() {
const routes = [
{
path: '/bar',
component: Bar
},
{
path: '/foo',
component: () => import('../components/Foo.vue') // 異步路由
}
];
const router = new Router({
mode: 'history',
routes
});
return router;
}
export default createRouter;
這里我們把Foo組件作為一個異步組件引入,做成按需加載。
在app.js中引入router,并導出:
app.js
import Vue from 'vue';
import createStore from './store/store.js';
import createRouter from './router';
import App from './App.vue';
export function createApp() {
const store = createStore();
const router = createRouter();
const app = new Vue({
router,
store,
render: h => h(App)
});
return { app, store, router, App };
}
修改App.vue引入路由組件:
App.vue
<template>
<div id="app">
<router-link to="/bar">Goto Bar</router-link>
<router-link to="/foo">Goto Foo</router-link>
<router-view></router-view>
</div>
</template>
<script>export default {
beforeCreate() {
console.log('App.vue beforeCreate');
},
created() {
console.log('App.vue created');
},
beforeMount() {
console.log('App.vue beforeMount');
},
mounted() {
console.log('App.vue mounted');
}
}</script>
最重要的修改在entry-server.js中,
entry-server.js
import { createApp } from './app.js';
export default context => {
return new Promise((resolve, reject) => {
const { app, store, router, App } = createApp();
router.push(context.url);
router.onReady(() => {
const matchedComponents = router.getMatchedComponents();
console.log(context.url)
console.log(matchedComponents)
if (!matchedComponents.length) {
return reject({ code: 404 });
}
Promise.all(matchedComponents.map(component => {
if (component.asyncData) {
return component.asyncData({ store });
}
})).then(() => {
// 當使用 template 時,context.state 將作為 window.__INITIAL_STATE__ 狀態,自動嵌入到最終的 HTML 中
context.state = store.state;
// 返回根組件
resolve(app);
});
}, reject);
});
}
這里前面提到的context就起了大作用,它將用戶訪問的url地址傳進來,供vue-router使用。因為有異步組件,所以在router.onReady的成功回調中,去找該url路由所匹配到的組件,獲取異步數據那一套還和前面的一樣。
于是,我們就完成了一個基本完整的基于Vue + VueRouter + VuexSSR配置,完成代碼查看github。
最終效果演示:
訪問http://localhost:3000/bar:
完整代碼查看github
上面我們通過五個步驟,完成了從純瀏覽器渲染到完整服務端渲染的同構,代碼既可以運行在瀏覽器端,也可以運行在服務器端。那么,回過頭來我們再看一下是否有優化的空間,又或者有哪些擴展的思考。
const stream = renderer.renderToStream(context)
返回的值是 Node.js stream:
let html = ''
stream.on('data', data => {
html += data.toString()
})
stream.on('end', () => {
console.log(html) // 渲染完成
})
stream.on('error', err => {
// handle error...
})
在流式渲染模式下,當 renderer 遍歷虛擬 DOM 樹(virtual DOM tree)時,會盡快發送數據。這意味著我們可以盡快獲得"第一個 chunk",并開始更快地將其發送給客戶端。
然而,當第一個數據 chunk 被發出時,子組件甚至可能不被實例化,它們的生命周期鉤子也不會被調用。這意味著,如果子組件需要在其生命周期鉤子函數中,將數據附加到渲染上下文(render context),當流(stream)啟動時,這些數據將不可用。這是因為,大量上下文信息(context information)(如頭信息(head information)或內聯關鍵 CSS(inline critical CSS))需要在應用程序標記(markup)之前出現,我們基本上必須等待流(stream)完成后,才能開始使用這些上下文數據。
因此,如果你依賴由組件生命周期鉤子函數填充的上下文數據,則不建議使用流式傳輸模式。
webpack優化又是一個大的話題了,這里不展開討論,感興趣的同學可以自行查找一些資料,后續我也可能會專門寫一篇文章來講webpack優化。
答案是不用。Vuex只是為了幫助你實現一套數據存儲、更新、獲取的機制,如果你不用Vuex,那么你就必須自己想一套方案可以將異步獲取到的數據存起來,并且在適當的時機將它注入到組件內,有一些文章提出了一些方案,我會放到參考文章里,大家可以閱讀一下。
這個也是不一定的,任何技術都有使用場景。SSR可以幫助你提升首頁加載速度,優化搜索引擎SEO,但同時由于它需要在node中渲染整套Vue的模板,會占用服務器負載,同時只會執行beforeCreate和created兩個生命周期,對于一些外部擴展庫需要做一定處理才可以在SSR中運行等等。
本文通過五個步驟,從純瀏覽器端渲染開始,到配置一個完整的基于Vue + vue-router + Vuex的SSR環境,介紹了很多新的概念,也許你看完一遍不太理解,那么結合著源碼,去自己手敲幾遍,然后再來看幾遍文章,相信你一定可以掌握SSR。
《記一次Vue3.0技術干貨分享會》
鄒發現現在前端的開發者們在工程化開發方面應用的還是蠻多,就比如說webpack+vue+vuex,或者是webpack+react+redux等等,總之都離不開webpack這個構建神器,今后小鄒也會逐步的給大伙講解關于webpack的一些知識點,希望能幫助到一些前端開發者們。
plugin是用于擴展webpack的功能,各種各樣的plugin幾乎可以讓webpack做任何與構建先關的事情。plugin的配置很簡單,plugins配置項接收一個數組,數組里的每一項都是一個要使用的plugin的實例,plugin需要的參數通過構造函數傳入。
舉個栗子
使用plugin的難點在于plugin本身的配置項,而不是如何在webpack中引入plugin,幾乎所有webpack無法直接實現的功能,都能找到開源的plugin去解決,我們要做的就是去找更據自己的需要找出相應的plugin。
這個plugin曝光率很高,他主要有兩個作用
title:生成html文件的標題
filename:輸出的html的文件名稱
template:html模板所在的文件路徑
根據自己的指定的模板文件來生成特定的 html 文件。這里的模板類型可以是任意你喜歡的模板,可以是 html, jade, ejs, hbs, 等等,但是要注意的是,使用自定義的模板文件時,需要提前安裝對應的 loader, 否則webpack不能正確解析。
如果你設置的 title 和 filename于模板中發生了沖突,那么以你的title 和 filename 的配置值為準。
inject:注入選項。有四個選項值 true, body, head, false.
favicon:給生成的 html 文件生成一個 favicon。屬性值為 favicon 文件所在的路徑名
minify:minify 的作用是對 html 文件進行壓縮,minify 的屬性值是一個壓縮選項或者 false 。默認值為false, 不對生成的 html 文件進行壓縮。
下面羅列了一些常用的配置:
hash:hash選項的作用是 給生成的 js 文件一個獨特的 hash 值,該 hash 值是該次 webpack 編譯的 hash 值。默認值為 false 。同樣看一個例子。
plugins: [ new HtmlWebpackPlugin({ hash: true }) ]
編譯打包后
<script type=text/javascript src=bundle.js?22b9692e22e7be37b57e></script>
執行 webpack 命令后,你會看到你的生成的 html 文件的 script 標簽內引用的 js 文件,是不是有點變化了。bundle.js 文件后跟的一串 hash 值就是此次 webpack 編譯對應的 hash 值。
cache:默認是true的,表示內容變化的時候生成一個新的文件。
showErrors:這個我們自運行項目的時候經常會用到,showErrors 的作用是,如果 webpack 編譯出現錯誤,webpack會將錯誤信息包裹在一個 pre 標簽內,屬性的默認值為 true ,也就是顯示錯誤信息。
開啟這個,方便定位錯誤
chunks:chunks主要用于多入口文件,當你有多個入口文件,那就回編譯后生成多個打包后的文件,那么chunks 就能選擇你要使用那些js文件
entry: { index: path.resolve(__dirname, './src/index.js'), devor: path.resolve(__dirname, './src/devor.js'), main: path.resolve(__dirname, './src/main.js') } plugins: [ new httpWebpackPlugin({ chunks: ['index','main'] }) ]
那么編譯后:
<script type=text/javascript src="index.js"></script> <script type=text/javascript src="main.js"></script>
而如果沒有指定 chunks 選項,默認會全部引用。
excludeChunks:排除掉一些js,
entry: { index: path.resolve(__dirname, './src/index.js'), devor: path.resolve(__dirname, './src/devor.js'), main: path.resolve(__dirname, './src/main.js') } plugins: [ new httpWebpackPlugin({ excludeChunks: ['devor.js']//和的等等效 }) ]
那么編譯后:
<script type=text/javascript src="index.js"></script> <script type=text/javascript src="main.js"></script>
用最新版本的的 html-webpack-plugin你可能還會遇到如下的錯誤:
throw new Error(‘Cyclic dependency’ + nodeRep)
產生這個 bug 的原因是循環引用依賴,如果你沒有這個問題可以忽略。
目前解決方案可以使用 Alpha 版本
npm i –save-dev html-webpack-plugin@next 或者加入chunksSortMode: ‘none’就可以了。
但仔細查看文檔發現設置成chunksSortMode: ‘none’這樣是會有問題的。
這屬性會決定你 chunks 的加載順序,如果設置為none,你的 chunk 加載在頁面中加載的順序就不能夠保證了,可能會出現樣式被覆蓋的情況。比如我在app.css里面修改了一個第三方庫element-ui的樣式,通過加載順序的先后來覆蓋它,但由于設置為了none,打包出來的結果變成了這樣:
<link href="/app.8945fbfc.css" rel="stylesheet"> <link href="/chunk-elementUI.2db88087.css" rel="stylesheet">
app.css被先加載了,之前寫的樣式覆蓋就失效了,除非你使用important或者其它 css 權重的方式覆蓋它,但這明顯是不太合理的。
vue-cli正好也有這個相關 issue,尤雨溪也在不使用@next版本的基礎上 hack 了它,有興趣的可以自己研究一下,
其它 html-webpack-plugin 的配置和之前使用沒有什么區別。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。