閒來無事,研究一下SSR,主要緣由在於上週一位後端同窗在一次組內技術分享的時候說,對先後端分離、服務端渲染特別感興趣,在他分享了後端微服務以後,專門點名邀請我下週分享服務端渲染,而後我還沒贊成,領導就內定讓我下週分享了(其實就是下週願意下週分享,我是那個替死鬼)。php
本人主要從我的角度介紹了對服務端渲染的理解,讀完本文後,你將瞭解到:css
原文地址 歡迎starhtml
在講服務度渲染以前,咱們先回顧一下頁面的渲染流程:前端
能夠看到,頁面的渲染其實就是瀏覽器將HTML文本轉化爲頁面幀的過程。而現在咱們大部分WEB應用都是使用 JavaScript 框架(Vue、React、Angular)進行頁面渲染的,也就是說,在執行 JavaScript 腳本的時候,HTML頁面已經開始解析而且構建DOM樹了,JavaScript 腳本只是動態的改變 DOM 樹的結構,使得頁面成爲但願成爲的樣子,這種渲染方式叫動態渲染,也能夠叫客戶端渲染(client side rende)。vue
那麼什麼是服務端渲染(server side render)?顧名思義,服務端渲染就是在瀏覽器請求頁面URL的時候,服務端將咱們須要的HTML文本組裝好,並返回給瀏覽器,這個HTML文本被瀏覽器解析以後,不須要通過 JavaScript 腳本的執行,便可直接構建出但願的 DOM 樹並展現到頁面中。這個服務端組裝HTML的過程,叫作服務端渲染。node
在沒有AJAX的時候,也就是web1.0時代,幾乎全部應用都是服務端渲染(此時服務器渲染非如今的服務器渲染),那個時候的頁面渲染大概是這樣的,瀏覽器請求頁面URL,而後服務器接收到請求以後,到數據庫查詢數據,將數據丟到後端的組件模板(php、asp、jsp等)中,並渲染成HTML片斷,接着服務器在組裝這些HTML片斷,組成一個完整的HTML,最後返回給瀏覽器,這個時候,瀏覽器已經拿到了一個完整的被服務器動態組裝出來的HTML文本,而後將HTML渲染到頁面中,過程沒有任何JavaScript代碼的參與。webpack
在WEB1.0時代,服務端渲染看起來是一個當時的最好的渲染方式,可是隨着業務的日益複雜和後續AJAX的出現,也漸漸開始暴露出了WEB1.0服務器渲染的缺點。git
並且那個時候,根本就沒有前端工程師這一職位,前端js的活通常都由後端同窗 jQuery 一把梭。可是隨着前端頁面漸漸地複雜了以後,後端開始發現js好麻煩,雖然很簡單,可是坑太多了,因而讓公司招聘了一些專門寫js的人,也就是前端,這個時候,先後端的鄙視鏈就出現了,後端鄙視前端,由於後端以爲js太簡單,無非就是寫寫頁面的特效(JS),切切圖(CSS),根本算不上是真正的程序員。程序員
隨之 nodejs 的出現,前端看到了翻身的契機,爲了擺脫後端的指指點點,前端開啓了一場先後端分離的運動,但願能夠脫離後端獨立發展。先後端分離,表面上看上去是代碼分離,其實是爲了先後端人員分離,也就是先後端分家,前端再也不歸屬於後端團隊。github
先後端分離以後,網頁開始被當成了獨立的應用程序(SPA,Single Page Application),前端團隊接管了全部頁面渲染的事,後端團隊只負責提供全部數據查詢與處理的API,大致流程是這樣的:首先瀏覽器請求URL,前端服務器直接返回一個空的靜態HTML文件(不須要任何查數據庫和模板組裝),這個HTML文件中加載了不少渲染頁面須要的 JavaScript 腳本和 CSS 樣式表,瀏覽器拿到 HTML 文件後開始加載腳本和樣式表,而且執行腳本,這個時候腳本請求後端服務提供的API,獲取數據,獲取完成後將數據經過JavaScript腳本動態的將數據渲染到頁面中,完成頁面顯示。
這一個先後端分離的渲染模式,也就是客戶端渲染(CSR)。
隨着單頁應用(SPA)的發展,程序員們漸漸發現 SEO(Search Engine Optimazition,即搜索引擎優化)出了問題,並且隨着應用的複雜化,JavaScript 腳本也不斷的臃腫起來,使得首屏渲染相比於 Web1.0時候的服務端渲染,也慢了很多。
本身選的路,跪着也要走下去。因而前端團隊選擇了使用 nodejs 在服務器進行頁面的渲染,進而再次出現了服務端渲染。大致流程與客戶端渲染有些類似,首先是瀏覽器請求URL,前端服務器接收到URL請求以後,根據不一樣的URL,前端服務器向後端服務器請求數據,請求完成後,前端服務器會組裝一個攜帶了具體數據的HTML文本,而且返回給瀏覽器,瀏覽器獲得HTML以後開始渲染頁面,同時,瀏覽器加載並執行 JavaScript 腳本,給頁面上的元素綁定事件,讓頁面變得可交互,當用戶與瀏覽器頁面進行交互,如跳轉到下一個頁面時,瀏覽器會執行 JavaScript 腳本,向後端服務器請求數據,獲取完數據以後再次執行 JavaScript 代碼動態渲染頁面。
相比於客戶端渲染,服務端渲染有什麼優點?
有利於SEO,其實就是有利於爬蟲來爬你的頁面,而後在別人使用搜索引擎搜索相關的內容時,你的網頁排行能靠得更前,這樣你的流量就有越高。那爲何服務端渲染更利於爬蟲爬你的頁面呢?其實,爬蟲也分低級爬蟲和高級爬蟲。
也就是說,低級爬蟲對客戶端渲染的頁面來講,簡直無能爲力,由於返回的HTML是一個空殼,它須要執行 JavaScript 腳本以後纔會渲染真正的頁面。而目前像百度、谷歌、微軟等公司,有一部分年代老舊的爬蟲還屬於低級爬蟲,使用服務端渲染,對這些低級爬蟲更加友好一些。
相對於客戶端渲染,服務端渲染在瀏覽器請求URL以後已經獲得了一個帶有數據的HTML文本,瀏覽器只須要解析HTML,直接構建DOM樹就能夠。而客戶端渲染,須要先獲得一個空的HTML頁面,這個時候頁面已經進入白屏,以後還須要通過加載並執行 JavaScript、請求後端服務器獲取數據、JavaScript 渲染頁面幾個過程才能夠看到最後的頁面。特別是在複雜應用中,因爲須要加載 JavaScript 腳本,越是複雜的應用,須要加載的 JavaScript 腳本就越多、越大,這會致使應用的首屏加載時間很是長,進而下降了體驗感。
並非全部的WEB應用都必須使用SSR,這須要開發者本身來權衡,由於服務端渲染會帶來如下問題:
因此在使用服務端渲染SSR以前,須要開發者考慮投入產出比,好比大部分應用系統都不須要SEO,並且首屏時間並無很是的慢,若是使用SSR反而小題大作了。
知道了服務器渲染的利弊後,假如咱們須要在項目中使用服務端渲染,咱們須要作什麼呢?那就是同構咱們的項目。
在服務端渲染中,有兩種頁面渲染的方式:
這兩種渲染方式有一個不一樣點就是,一個是在服務端中組裝html的,一個是在客戶端中組裝html的,運行環境是不同的。所謂同構,就是讓一份代碼,既能夠在服務端中執行,也能夠在客戶端中執行,而且執行的效果都是同樣的,都是完成這個html的組裝,正確的顯示頁面。也就是說,一份代碼,既能夠客戶端渲染,也能夠服務端渲染。
爲了實現同構,咱們須要知足什麼條件呢?首先,咱們思考一個應用中一個頁面的組成,假如咱們使用的是Vue.js
,當咱們打開一個頁面時,首先是打開這個頁面的URL,這個URL,能夠經過應用的路由
匹配,找到具體的頁面,不一樣的頁面有不一樣的視圖,那麼,視圖是什麼?從應用的角度來看,視圖 = 模板
+ 數據
,那麼在 Vue.js 中, 模板能夠理解成組件
,數據能夠理解爲數據模型
,即響應式數據。因此,對於同構應用來講,咱們必須實現客戶端與服務端的路由、模型組件、數據模型的共享。
知道了服務端渲染、同構的原理以後,下面從頭開始,一步一步完成一次同構,經過實踐來了解SSR。
首先,模擬一個最簡單的服務器渲染,只須要向頁面返回咱們須要的html文件。
const express = require('express'); const app = express(); app.get('/', function(req, res) { res.send(` <html> <head> <title>SSR</title> </head> <body> <p>hello world</p> </body> </html> `); }); app.listen(3001, function() { console.log('listen:3001'); });
啓動以後打開localhost:3001能夠看到頁面顯示了hello world。並且打開網頁源代碼:
也就是說,當瀏覽器拿到服務器返回的這一段HTML源代碼的時候,不須要加載任何JavaScript腳本,就能夠直接將hello world顯示出來。
咱們用 vue-cli
新建一個vue項目,修改一個App.vue組件:
<template> <div> <p>hello world</p> <button @click="sayHello">say hello</button> </div> </template> <script> export default { methods: { sayHello() { alert('hello ssr'); } } } </script>
而後運行npm run serve
啓動項目,打開瀏覽器,同樣能夠看到頁面顯示了 hello world,可是打開咱們開網頁源代碼:
除了簡單的兼容性處理 noscript 標籤之外,只有一個簡單的id爲app的div標籤,沒有關於hello world的任何字眼,能夠說這是一個空的頁面(白屏),而當加載了下面的 script 標籤的 JavaScript 腳本以後,頁面開始這行這些腳本,執行結束,hello world 正常顯示。也就是說真正渲染 hello world 的是 JavaScript 腳本。
模板組件的共享,其實就是使用同一套組件代碼,爲了實現 Vue 組件能夠在服務端中運行,首先咱們須要解決代碼編譯問題。通常狀況,vue項目使用的是webpack進行代碼構建,一樣,服務端代碼的構建,也可使用webpack,借用官方的一張。
由前面的圖能夠看到,在服務端代碼構建結束後,須要將構建結果運行在nodejs服務器上,可是,對於服務端代碼的構建,有一下內容須要注意:
因而,咱們獲得一個服務端的 webpack 構建配置文件 vue.server.config.js
const nodeExternals = require("webpack-node-externals"); const VueSSRServerPlugin = require('vue-server-renderer/server-plugin') module.exports = { css: { extract: false // 不提取 CSS }, configureWebpack: () => ({ entry: `./src/server-entry.js`, // 服務器入口文件 devtool: 'source-map', target: 'node', // 構建目標爲nodejs環境 output: { libraryTarget: 'commonjs2' // 構建目標加載模式 commonjs }, // 跳過 node_mdoules,運行時會自動加載,不須要編譯 externals: nodeExternals({ allowlist: [/\.css$/] // 容許css文件,方便css module }), optimization: { splitChunks: false // 關閉代碼切割 }, plugins: [ new VueSSRServerPlugin() ] }) };
使用 vue-server-renderer
提供的server-plugin
,這個插件主要配合下面講到的client-plugin
使用,做用主要是用來實現nodejs在開發過程當中的熱加載、source-map、生成html文件。
在構建客戶端代碼時,使用的是客戶端的執行入口文件,構建結束後,將構建結果在瀏覽器運行便可,可是在服務端渲染中,HTML是由服務端渲染的,也就是說,咱們要加載那些JavaScript腳本,是服務端決定的,由於HTML中的script標籤是由服務端拼接的,因此在客戶端代碼構建的時候,咱們須要使用插件,生成一個構建結果清單,這個清單是用來告訴服務端,當前頁面須要加載哪些JS腳本和CSS樣式表。
因而咱們獲得了客戶端的構建配置,vue.client.config.js
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin') module.exports = { configureWebpack: () => ({ entry: `./src/client-entry.js`, devtool: 'source-map', target: 'web', plugins: [ new VueSSRClientPlugin() ] }), chainWebpack: config => { // 去除全部關於客戶端生成的html配置,由於已經交給後端生成 config.plugins.delete('html'); config.plugins.delete('preload'); config.plugins.delete('prefetch'); } };
使用vue-server-renderer
提供的client-server
,主要做用是生成構建加過清單vue-ssr-client-manifest.json
,服務端在渲染頁面時,根據這個清單來渲染HTML中的script標籤(JavaScript)和link標籤(CSS)。
接下來,咱們須要將vue.client.config.js和vue.server.config.js都交給vue-cli內置的構建配置文件vue.config.js,根據環境變量使用不一樣的配置
// vue.config.js const TARGET_NODE = process.env.WEBPACK_TARGET === 'node'; const serverConfig = require('./vue.server.config'); const clientConfig = require('./vue.client.config'); if (TARGET_NODE) { module.exports = serverConfig; } else { module.exports = clientConfig; }
使用cross-env
區分環境
{ "scripts": { "server": "babel-node src/server.js", "serve": "vue-cli-service serve", "build": "vue-cli-service build", "build:server": "cross-env WEBPACK_TARGET=node vue-cli-service build --mode server" } }
爲了實現模板組件共享,咱們須要將獲取 Vue 渲染實例寫成通用代碼,以下 createApp:
import Vue from 'vue'; import App from './App'; export default function createApp (context) { const app = new Vue({ render: h => h(App) }); return { app }; };
新建客戶端項目的入口文件,client-entry.js
import Vue from 'vue' import createApp from './createApp'; const {app} = createApp(); app.$mount('#app');
client-entry.js是瀏覽器渲染的入口文件,在瀏覽器加載了客戶端編譯後的代碼後,組件會被渲染到id爲app的元素節點上。
新建服務端代碼的入口文件,server-entry.js
import createApp from './createApp' export default context => { const { app } = createApp(context); return app; }
server-entry.js是提供給服務器渲染vue組件的入口文件,在瀏覽器經過URL訪問到服務器後,服務器須要使用server-entry.js提供的函數,將組件渲染成html。
全部東西的準備好以後,咱們須要修改nodejs的HTTP服務器的啓動文件。首先,加載服務端代碼server-entry.js的webpack構建結果
const path = require('path'); const serverBundle = path.resolve(process.cwd(), 'serverDist', 'vue-ssr-server-bundle.json'); const {createBundleRenderer} = require('vue-server-renderer'); const serverBundle = path.resolve(process.cwd(), 'serverDist', 'vue-ssr-server-bundle.json');
加載客戶端代碼client-entry.js的webpack構建結果
const clientManifestPath = path.resolve(process.cwd(), 'dist', 'vue-ssr-client-manifest.json'); const clientManifest = require(clientManifestPath);
使用 vue-server-renderer 的createBundleRenderer
建立一個html渲染器:
const template = fs.readFileSync(path.resolve(__dirname, 'index.html'), 'utf-8'); const renderer = createBundleRenderer(serverBundle, { template, // 使用HTML模板 clientManifest // 將客戶端的構建結果清單傳入 });
建立HTML模板,index.html
<html> <head> <title>SSR</title> </head> <body> <!--vue-ssr-outlet--> </body> </html>
在HTML模板中,經過傳入的客戶端渲染結果clientManifest
,將自動注入全部link樣式表標籤,而佔位符<!--vue-ssr-outlet-->將會被替換成模板組件被渲染後的具體的HTML片斷和script腳本標籤。
HTML準備完成後,咱們在server中掛起全部路由請求
const express = require('express'); const app = express(); /* code todo 實例化渲染器renderer */ app.get('*', function(req, res) { renderer.renderToString({}, (err, html) => { if (err) { res.send('500 server error'); return; } res.send(html); }) });
接下來,咱們構建客戶端、服務端項目,而後執行 node server.js,打開頁面源代碼,
看起來是符合預期的,可是發現控制檯有報錯,加載不到客戶端構建css和js,報404,緣由很明確,咱們沒有把客戶端的構建結果文件掛載到服務器的靜態資源目錄,在掛載路由前加入下面代碼:
app.use(express.static(path.resolve(process.cwd(), 'dist')));
看起來大功告成,點擊say hello也彈出了消息,細心的同窗會發現根節點有一個data-server-rendered
屬性,這個屬性有什麼做用呢?
因爲服務器已經渲染好了 HTML,咱們顯然無需將其丟棄再從新建立全部的 DOM 元素。相反,咱們須要"激活"這些靜態的 HTML,而後使他們成爲動態的(可以響應後續的數據變化)。
若是檢查服務器渲染的輸出結果,應用程序的根元素上添加了一個特殊的屬性:
<div id="app" data-server-rendered="true">
data-server-rendered
是特殊屬性,讓客戶端 Vue 知道這部分 HTML 是由 Vue 在服務端渲染的,而且應該以激活模式進行掛載。
完成了模板組件的共享以後,下面完成路由的共享,咱們前面服務器使用的路由是*
,接受任意URL,這容許全部URL請求交給Vue路由處理,進而完成客戶端路由與服務端路由的複用。
爲了實現複用,與createApp同樣,咱們建立一個createRouter.js
import Vue from 'vue'; import Router from 'vue-router'; import Home from './views/Home'; import About from './views/About'; Vue.use(Router) const routes = [{ path: '/', name: 'Home', component: Home }, { path: '/about', name: 'About', component: About }]; export default function createRouter() { return new Router({ mode: 'history', routes }) }
在createApp.js中建立router
import Vue from 'vue'; import App from './App'; import createRouter from './createRouter'; export default function createApp(context) { const router = createRouter(); // 建立 router 實例 const app = new Vue({ router, // 注入 router 到根 Vue 實例 render: h => h(App) }); return { router, app }; };
router準備好了以後,修改server-entry.js,將請求的URL傳遞給router,使得在建立app的時候能夠根據URL匹配到對應的路由,進而可知道須要渲染哪些組件
import createApp from './createApp'; export default context => { // 由於有可能會是異步路由鉤子函數或組件,因此咱們將返回一個 Promise, // 以便服務器可以等待全部的內容在渲染前就已經準備就緒。 return new Promise((resolve, reject) => { const { app, router } = createApp(); // 設置服務器端 router 的位置 router.push(context.url) // onReady 等到 router 將可能的異步組件和鉤子函數解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents(); // 匹配不到的路由,執行 reject 函數,並返回 404 if (!matchedComponents.length) { return reject({ code: 404 }); } // Promise 應該 resolve 應用程序實例,以便它能夠渲染 resolve(app) }, reject) }) }
修改server.js的路由,把url傳遞給renderer
app.get('*', function(req, res) { const context = { url: req.url }; renderer.renderToString(context, (err, html) => { if (err) { console.log(err); res.send('500 server error'); return; } res.send(html); }) });
爲了測試,咱們將App.vue修改成router-view
<template> <div id="app"> <router-link to="/">Home</router-link> <router-link to="/about">About</router-link> <router-view /> </div> </template>
Home.vue
<template> <div>Home Page</div> </template>
About.vue
<template> <div>About Page</div> </template>
編譯,運行,查看源代碼
點擊路由並無刷新頁面,而是客戶端路由跳轉的,一切符合預期。
前面咱們簡單的實現了服務端渲染,可是實際狀況下,咱們在訪問頁面的時候,還須要獲取須要渲染的數據,而且渲染成HTML,也就是說,在渲染HTML以前,咱們須要將全部數據都準備好,而後傳遞給renderer。
通常狀況下,在Vue中,咱們將狀態數據交給Vuex進行管理,固然,狀態也能夠保存在組件內部,只不過須要組件實例化的時候本身去同步數據。
首先第一步,與createApp相似,建立一個createStore.js,用來實例化store,同時提供給客戶端和服務端使用
import Vue from 'vue'; import Vuex from 'vuex'; import {fetchItem} from './api'; Vue.use(Vuex); export default function createStore() { return new Vuex.Store({ state: { item: {} }, actions: { fetchItem({ commit }, id) { return fetchItem(id).then(item => { commit('setItem', item); }) } }, mutations: { setItem(state, item) { Vue.set(state.item, item); } } }) }
actions封裝了請求數據的函數,mutations用來設置狀態。
將createStore加入到createApp中,並將store注入到vue實例中,讓全部Vue組件能夠獲取到store實例
export default function createApp(context) { const router = createRouter(); const store = createStore(); const app = new Vue({ router, store, // 注入 store 到根 Vue 實例 render: h => h(App) }); return { router, store, app }; };
爲了方便測試,咱們mock一個遠程服務函數fetchItem,用於查詢對應item
export function fetchItem(id) { const items = [ { name: 'item1', id: 1 }, { name: 'item2', id: 2 }, { name: 'item3', id: 3 } ]; const item = items.find(i => i.id == id); return Promise.resolve(item); }
通常狀況下,咱們須要經過訪問路由,來決定獲取哪部分數據,這也決定了哪些組件須要渲染。事實上,給定路由所需的數據,也是在該路由上渲染組件時所需的數據。因此,咱們須要在路由的組件中放置數據預取邏輯函數。
在Home組件中自定義一個靜態函數asyncData
,須要注意的是,因爲此函數會在組件實例化以前調用,因此它沒法訪問 this
。須要將 store 和路由信息做爲參數傳遞進去
<template> <div> <div>id: {{item.id}}</div> <div>name: {{item.name}}</div> </div> </template> <script> export default { asyncData({ store, route }) { // 觸發 action 後,會返回 Promise return store.dispatch('fetchItems', route.params.id) }, computed: { // 從 store 的 state 對象中的獲取 item。 item() { return this.$store.state.item; } } } </script>
在服務器的入口文件server-entry.js
中,咱們經過URL路由匹配 router.getMatchedComponents()
獲得了須要渲染的組件,這個時候咱們能夠調用組件內部的asyncData
方法,將所須要的全部數據都獲取完後,傳遞給渲染器renderer上下文。
修改createApp,在路由組件匹配到了以後,調用asyncData方法,獲取數據後傳遞給renderer
import createApp from './createApp'; export default context => { // 由於有可能會是異步路由鉤子函數或組件,因此咱們將返回一個 Promise, // 以便服務器可以等待全部的內容在渲染前就已經準備就緒。 return new Promise((resolve, reject) => { const { app, router, store } = createApp(); // 設置服務器端 router 的位置 router.push(context.url) // onReady 等到 router 將可能的異步組件和鉤子函數解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents(); // 匹配不到的路由,執行 reject 函數,並返回 404 if (!matchedComponents.length) { return reject({ code: 404 }) } // 對全部匹配的路由組件調用 `asyncData()` Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store, route: router.currentRoute }); } })).then(() => { // 狀態傳遞給renderer的上下文,方便後面客戶端激活數據 context.state = store.state resolve(app) }).catch(reject); }, reject); }) }
將state存入context後,在服務端渲染HTML時候,也就是渲染template的時候,context.state會被序列化到window.__INITIAL_STATE__
中,方便客戶端激活數據。
服務端預請求數據以後,經過將數據注入到組件中,渲染組件並轉化成HTML,而後吐給客戶端,那麼客戶端爲了激活後端返回的HTML被解析後的DOM節點,須要將後端渲染組件時用的store的state也同步到瀏覽器的store中,保證在頁面渲染的時候保持與服務器渲染時的數據是一致的,才能完成DOM的激活,也就是咱們前面說到的data-server-rendered
標記。
在服務端的渲染中,state已經被序列化到了window.__INITIAL_STATE__
,好比咱們訪問 http://localhost:3001?id=1,而後查看頁面源代碼
能夠看到,狀態已經被序列化到window.__INITIAL_STATE__
中,咱們須要作的就是將這個window.__INITIAL_STATE__
在客戶端渲染以前,同步到客戶端的store中,下面修改client-entry.js
const { app, router, store } = createApp(); if (window.__INITIAL_STATE__) { // 激活狀態數據 store.replaceState(window.__INITIAL_STATE__); } router.onReady(() => { app.$mount('#app', true); });
經過使用store的replaceState函數,將window.__INITIAL_STATE__
同步到store內部,完成數據模型的狀態同步。
當瀏覽器訪問服務端渲染項目時,服務端將URL傳給到預選構建好的VUE應用渲染器,渲染器匹配到對應的路由的組件以後,執行咱們預先在組件內定義的asyncData方法獲取數據,並將獲取完的數據傳遞給渲染器的上下文,利用template組裝成HTML,並將HTML和狀態state一併吐給前端瀏覽器,瀏覽器加載了構建好的客戶端VUE應用後,將state數據同步到前端的store中,並根據數據激活後端返回的被瀏覽器解析爲DOM元素的HTML文本,完成了數據狀態、路由、組件的同步,同時使得頁面獲得直出,較少了白屏時間,有了更好的加載體驗,同時更有利於SEO。
我的以爲了解服務端渲染,有助於提高前端工程師的綜合能力,由於它的內容除了前端框架,還有前端構建和後端內容,是一個性價比還挺高的知識,不學白不學,加油!