摘要:好在后是支持服務端渲染的,零零散散花費了兩三周事件,通過改造現有項目,基本完成了在現有項目中實踐了服務端渲染。在服務端生成對應的字符串,客戶端接收到對應的字符串,能立即渲染,最高效的首屏耗時。服務端渲染的原理是虛擬。實現前后端同構應用。
隨著各大前端框架的誕生和演變,SPA開始流行,單頁面應用的優勢在于可以不重新加載整個頁面的情況下,通過ajax和服務器通信,實現整個Web應用拒不更新,帶來了極致的用戶體驗。然而,對于需要SEO、追求極致的首屏性能的應用,前端渲染的SPA是糟糕的。好在Vue 2.0后是支持服務端渲染的,零零散散花費了兩三周事件,通過改造現有項目,基本完成了在現有項目中實踐了Vue服務端渲染。
關于Vue服務端渲染的原理、搭建,官方文檔已經講的比較詳細了,因此,本文不是抄襲文檔,而是文檔的補充。特別是對于如何與現有項目進行很好的結合,還是需要費很大功夫的。本文主要對我所在的項目中進行Vue服務端渲染的改造過程進行闡述,加上一些個人的理解,作為分享與學習。
概述本文主要分以下幾個方面:
什么是服務端渲染?服務端渲染的原理是什么?
如何在基于Koa的Web Server Frame上配置服務端渲染?
基本用法
Webpack配置
開發環境搭建
渲染中間件配置
如何對現有項目進行改造?
基本目錄改造;
在服務端用vue-router分割代碼;
在服務端預拉取數據;
客戶端托管全局狀態;
常見問題的解決方案;
什么是服務端渲染?服務端渲染的原理是什么?Vue.js是構建客戶端應用程序的框架。默認情況下,可以在瀏覽器中輸出Vue組件,進行生成DOM和操作DOM。然而,也可以將同一個組件渲染為服務器端的HTML字符串,將它們直接發送到瀏覽器,最后將這些靜態標記"激活"為客戶端上完全可交互的應用程序。
上面這段話是源自Vue服務端渲染文檔的解釋,用通俗的話來說,大概可以這么理解:
服務端渲染的目的是:性能優勢。 在服務端生成對應的HTML字符串,客戶端接收到對應的HTML字符串,能立即渲染DOM,最高效的首屏耗時。此外,由于服務端直接生成了對應的HTML字符串,對SEO也非常友好;
服務端渲染的本質是:生成應用程序的“快照”。將Vue及對應庫運行在服務端,此時,Web Server Frame實際上是作為代理服務器去訪問接口服務器來預拉取數據,從而將拉取到的數據作為Vue組件的初始狀態。
服務端渲染的原理是:虛擬DOM。在Web Server Frame作為代理服務器去訪問接口服務器來預拉取數據后,這是服務端初始化組件需要用到的數據,此后,組件的beforeCreate和created生命周期會在服務端調用,初始化對應的組件后,Vue啟用虛擬DOM形成初始化的HTML字符串。之后,交由客戶端托管。實現前后端同構應用。
如何在基于Koa的Web Server Frame上配置服務端渲染? 基本用法需要用到Vue服務端渲染對應庫vue-server-renderer,通過npm安裝:
npm install vue vue-server-renderer --save
最簡單的,首先渲染一個Vue實例:
// 第 1 步:創建一個 Vue 實例 const Vue = require("vue"); const app = new Vue({ template: `Hello World` }); // 第 2 步:創建一個 renderer const renderer = require("vue-server-renderer").createRenderer(); // 第 3 步:將 Vue 實例渲染為 HTML renderer.renderToString(app, (err, html) => { if (err) { throw err; } console.log(html); // =>Hello World});
與服務器集成:
module.exports = async function(ctx) { ctx.status = 200; let html = ""; try { // ... html = await renderer.renderToString(app, ctx); } catch (err) { ctx.logger("Vue SSR Render error", JSON.stringify(err)); html = await ctx.getErrorPage(err); // 渲染出錯的頁面 } ctx.body = html; }
使用頁面模板:
當你在渲染Vue應用程序時,renderer只從應用程序生成HTML標記。在這個示例中,我們必須用一個額外的HTML頁面包裹容器,來包裹生成的HTML標記。
為了簡化這些,你可以直接在創建renderer時提供一個頁面模板。多數時候,我們會將頁面模板放在特有的文件中:
Hello
然后,我們可以讀取和傳輸文件到Vue renderer中:
const tpl = fs.readFileSync(path.resolve(__dirname, "./index.html"), "utf-8"); const renderer = vssr.createRenderer({ template: tpl, });Webpack配置
然而在實際項目中,不止上述例子那么簡單,需要考慮很多方面:路由、數據預取、組件化、全局狀態等,所以服務端渲染不是只用一個簡單的模板,然后加上使用vue-server-renderer完成的,如下面的示意圖所示:
如示意圖所示,一般的Vue服務端渲染項目,有兩個項目入口文件,分別為entry-client.js和entry-server.js,一個僅運行在客戶端,一個僅運行在服務端,經過Webpack打包后,會生成兩個Bundle,服務端的Bundle會用于在服務端使用虛擬DOM生成應用程序的“快照”,客戶端的Bundle會在瀏覽器執行。
因此,我們需要兩個Webpack配置,分別命名為webpack.client.config.js和webpack.server.config.js,分別用于生成客戶端Bundle與服務端Bundle,分別命名為vue-ssr-client-manifest.json與vue-ssr-server-bundle.json,關于如何配置,Vue官方有相關示例vue-hackernews-2.0
開發環境搭建我所在的項目使用Koa作為Web Server Frame,項目使用koa-webpack進行開發環境的構建。如果是在產品環境下,會生成vue-ssr-client-manifest.json與vue-ssr-server-bundle.json,包含對應的Bundle,提供客戶端和服務端引用,而在開發環境下,一般情況下放在內存中。使用memory-fs模塊進行讀取。
const fs = require("fs") const path = require( "path" ); const webpack = require( "webpack" ); const koaWpDevMiddleware = require( "koa-webpack" ); const MFS = require("memory-fs"); const appSSR = require("./../../app.ssr.js"); let wpConfig; let clientConfig, serverConfig; let wpCompiler; let clientCompiler, serverCompiler; let clientManifest; let bundle; // 生成服務端bundle的webpack配置 if ((fs.existsSync(path.resolve(cwd,"webpack.server.config.js")))) { serverConfig = require(path.resolve(cwd, "webpack.server.config.js")); serverCompiler = webpack( serverConfig ); } // 生成客戶端clientManifest的webpack配置 if ((fs.existsSync(path.resolve(cwd,"webpack.client.config.js")))) { clientConfig = require(path.resolve(cwd, "webpack.client.config.js")); clientCompiler = webpack(clientConfig); } if (serverCompiler && clientCompiler) { let publicPath = clientCompiler.output && clientCompiler.output.publicPath; const koaDevMiddleware = await koaWpDevMiddleware({ compiler: clientCompiler, devMiddleware: { publicPath, serverSideRender: true }, }); app.use(koaDevMiddleware); // 服務端渲染生成clientManifest app.use(async (ctx, next) => { const stats = ctx.state.webpackStats.toJson(); const assetsByChunkName = stats.assetsByChunkName; stats.errors.forEach(err => console.error(err)); stats.warnings.forEach(err => console.warn(err)); if (stats.errors.length) { console.error(stats.errors); return; } // 生成的clientManifest放到appSSR模塊,應用程序可以直接讀取 let fileSystem = koaDevMiddleware.devMiddleware.fileSystem; clientManifest = JSON.parse(fileSystem.readFileSync(path.resolve(cwd,"./dist/vue-ssr-client-manifest.json"), "utf-8")); appSSR.clientManifest = clientManifest; await next(); }); // 服務端渲染的server bundle 存儲到內存里 const mfs = new MFS(); serverCompiler.outputFileSystem = mfs; serverCompiler.watch({}, (err, stats) => { if (err) { throw err; } stats = stats.toJson(); if (stats.errors.length) { console.error(stats.errors); return; } // 生成的bundle放到appSSR模塊,應用程序可以直接讀取 bundle = JSON.parse(mfs.readFileSync(path.resolve(cwd,"./dist/vue-ssr-server-bundle.json"), "utf-8")); appSSR.bundle = bundle; }); }渲染中間件配置
產品環境下,打包后的客戶端和服務端的Bundle會存儲為vue-ssr-client-manifest.json與vue-ssr-server-bundle.json,通過文件流模塊fs讀取即可,但在開發環境下,我創建了一個appSSR模塊,在發生代碼更改時,會觸發Webpack熱更新,appSSR對應的bundle也會更新,appSSR模塊代碼如下所示:
let clientManifest; let bundle; const appSSR = { get bundle() { return bundle; }, set bundle(val) { bundle = val; }, get clientManifest() { return clientManifest; }, set clientManifest(val) { clientManifest = val; } }; module.exports = appSSR;
通過引入appSSR模塊,在開發環境下,就可以拿到clientManifest和ssrBundle,項目的渲染中間件如下:
const fs = require("fs"); const path = require("path"); const ejs = require("ejs"); const vue = require("vue"); const vssr = require("vue-server-renderer"); const createBundleRenderer = vssr.createBundleRenderer; const dirname = process.cwd(); const env = process.env.RUN_ENVIRONMENT; let bundle; let clientManifest; if (env === "development") { // 開發環境下,通過appSSR模塊,拿到clientManifest和ssrBundle let appSSR = require("./../../core/app.ssr.js"); bundle = appSSR.bundle; clientManifest = appSSR.clientManifest; } else { bundle = JSON.parse(fs.readFileSync(path.resolve(__dirname, "./dist/vue-ssr-server-bundle.json"), "utf-8")); clientManifest = JSON.parse(fs.readFileSync(path.resolve(__dirname, "./dist/vue-ssr-client-manifest.json"), "utf-8")); } module.exports = async function(ctx) { ctx.status = 200; let html; let context = await ctx.getTplContext(); ctx.logger("進入SSR,context為: ", JSON.stringify(context)); const tpl = fs.readFileSync(path.resolve(__dirname, "./newTemplate.html"), "utf-8"); const renderer = createBundleRenderer(bundle, { runInNewContext: false, template: tpl, // (可選)頁面模板 clientManifest: clientManifest // (可選)客戶端構建 manifest }); ctx.logger("createBundleRenderer renderer:", JSON.stringify(renderer)); try { html = await renderer.renderToString({ ...context, url: context.CTX.url, }); } catch(err) { ctx.logger("SSR renderToString 失敗: ", JSON.stringify(err)); console.error(err); } ctx.body = html; };如何對現有項目進行改造? 基本目錄改造
使用Webpack來處理服務器和客戶端的應用程序,大部分源碼可以使用通用方式編寫,可以使用Webpack支持的所有功能。
一個基本項目可能像是這樣:
src ├── components │ ├── Foo.vue │ ├── Bar.vue │ └── Baz.vue ├── frame │ ├── app.js # 通用 entry(universal entry) │ ├── entry-client.js # 僅運行于瀏覽器 │ ├── entry-server.js # 僅運行于服務器 │ └── index.vue # 項目入口組件 ├── pages ├── routers └── store
app.js是我們應用程序的「通用entry」。在純客戶端應用程序中,我們將在此文件中創建根Vue實例,并直接掛載到DOM。但是,對于服務器端渲染(SSR),責任轉移到純客戶端entry文件。app.js簡單地使用export導出一個createApp函數:
import Router from "~ut/router"; import { sync } from "vuex-router-sync"; import Vue from "vue"; import { createStore } from "./../store"; import Frame from "./index.vue"; import myRouter from "./../routers/myRouter"; function createVueInstance(routes, ctx) { const router = Router({ base: "/base", mode: "history", routes: [routes], }); const store = createStore({ ctx }); // 把路由注入到vuex中 sync(store, router); const app = new Vue({ router, render: function(h) { return h(Frame); }, store, }); return { app, router, store }; } module.exports = function createApp(ctx) { return createVueInstance(myRouter, ctx); }
注:在我所在的項目中,需要動態判斷是否需要注冊DicomView,只有在客戶端才初始化DicomView,由于Node.js環境沒有window對象,對于代碼運行環境的判斷,可以通過typeof window === "undefined"來進行判斷。避免創建單例
如Vue SSR文檔所述:
當編寫純客戶端 (client-only) 代碼時,我們習慣于每次在新的上下文中對代碼進行取值。但是,Node.js 服務器是一個長期運行的進程。當我們的代碼進入該進程時,它將進行一次取值并留存在內存中。這意味著如果創建一個單例對象,它將在每個傳入的請求之間共享。如基本示例所示,我們為每個請求創建一個新的根 Vue 實例。這與每個用戶在自己的瀏覽器中使用新應用程序的實例類似。如果我們在多個請求之間使用一個共享的實例,很容易導致交叉請求狀態污染 (cross-request state pollution)。因此,我們不應該直接創建一個應用程序實例,而是應該暴露一個可以重復執行的工廠函數,為每個請求創建新的應用程序實例。同樣的規則也適用于 router、store 和 event bus 實例。你不應該直接從模塊導出并將其導入到應用程序中,而是需要在 createApp 中創建一個新的實例,并從根 Vue 實例注入。
如上代碼所述,createApp方法通過返回一個返回值創建Vue實例的對象的函數調用,在函數createVueInstance中,為每一個請求創建了Vue,Vue Router,Vuex實例。并暴露給entry-client和entry-server模塊。
在客戶端entry-client.js只需創建應用程序,并且將其掛載到DOM中:
import { createApp } from "./app"; // 客戶端特定引導邏輯…… const { app } = createApp(); // 這里假定 App.vue 模板中根元素具有 `id="app"` app.$mount("#app");
服務端entry-server.js使用default export 導出函數,并在每次渲染中重復調用此函數。此時,除了創建和返回應用程序實例之外,它不會做太多事情 - 但是稍后我們將在此執行服務器端路由匹配和數據預取邏輯:
import { createApp } from "./app"; export default context => { const { app } = createApp(); return app; }在服務端用vue-router分割代碼
與Vue實例一樣,也需要創建單例的vueRouter對象。對于每個請求,都需要創建一個新的vueRouter實例:
function createVueInstance(routes, ctx) { const router = Router({ base: "/base", mode: "history", routes: [routes], }); const store = createStore({ ctx }); // 把路由注入到vuex中 sync(store, router); const app = new Vue({ router, render: function(h) { return h(Frame); }, store, }); return { app, router, store }; }
同時,需要在entry-server.js中實現服務器端路由邏輯,使用router.getMatchedComponents方法獲取到當前路由匹配的組件,如果當前路由沒有匹配到相應的組件,則reject到404頁面,否則resolve整個app,用于Vue渲染虛擬DOM,并使用對應模板生成對應的HTML字符串。
const createApp = require("./app"); module.exports = context => { return new Promise((resolve, reject) => { // ... // 設置服務器端 router 的位置 router.push(context.url); // 等到 router 將可能的異步組件和鉤子函數解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents(); // 匹配不到的路由,執行 reject 函數,并返回 404 if (!matchedComponents.length) { return reject("匹配不到的路由,執行 reject 函數,并返回 404"); } // Promise 應該 resolve 應用程序實例,以便它可以渲染 resolve(app); }, reject); }); }在服務端預拉取數據
在Vue服務端渲染,本質上是在渲染我們應用程序的"快照",所以如果應用程序依賴于一些異步數據,那么在開始渲染過程之前,需要先預取和解析好這些數據。服務端Web Server Frame作為代理服務器,在服務端對接口服務發起請求,并將數據拼裝到全局Vuex狀態中。
另一個需要關注的問題是在客戶端,在掛載到客戶端應用程序之前,需要獲取到與服務器端應用程序完全相同的數據 - 否則,客戶端應用程序會因為使用與服務器端應用程序不同的狀態,然后導致混合失敗。
目前較好的解決方案是,給路由匹配的一級子組件一個asyncData,在asyncData方法中,dispatch對應的action。asyncData是我們約定的函數名,表示渲染組件需要預先執行它獲取初始數據,它返回一個Promise,以便我們在后端渲染的時候可以知道什么時候該操作完成。注意,由于此函數會在組件實例化之前調用,所以它無法訪問this。需要將store和路由信息作為參數傳遞進去:
舉個例子:
在entry-server.js中,我們可以通過路由獲得與router.getMatchedComponents()相匹配的組件,如果組件暴露出asyncData,我們就調用這個方法。然后我們需要將解析完成的狀態,附加到渲染上下文中。
const createApp = require("./app"); module.exports = context => { return new Promise((resolve, reject) => { const { app, router, store } = createApp(context); // 針對沒有Vue router 的Vue實例,在項目中為列表頁,直接resolve app if (!router) { resolve(app); } // 設置服務器端 router 的位置 router.push(context.url.replace("/base", "")); // 等到 router 將可能的異步組件和鉤子函數解析完 router.onReady(() => { const matchedComponents = router.getMatchedComponents(); // 匹配不到的路由,執行 reject 函數,并返回 404 if (!matchedComponents.length) { return reject("匹配不到的路由,執行 reject 函數,并返回 404"); } Promise.all(matchedComponents.map(Component => { if (Component.asyncData) { return Component.asyncData({ store, route: router.currentRoute, }); } })).then(() => { // 在所有預取鉤子(preFetch hook) resolve 后, // 我們的 store 現在已經填充入渲染應用程序所需的狀態。 // 當我們將狀態附加到上下文,并且 `template` 選項用于 renderer 時, // 狀態將自動序列化為 `window.__INITIAL_STATE__`,并注入 HTML。 context.state = store.state; resolve(app); }).catch(reject); }, reject); }); }客戶端托管全局狀態
當服務端使用模板進行渲染時,context.state將作為window.__INITIAL_STATE__狀態,自動嵌入到最終的HTML 中。而在客戶端,在掛載到應用程序之前,store就應該獲取到狀態,最終我們的entry-client.js被改造為如下所示:
import createApp from "./app"; const { app, router, store } = createApp(); // 客戶端把初始化的store替換為window.__INITIAL_STATE__ if (window.__INITIAL_STATE__) { store.replaceState(window.__INITIAL_STATE__); } if (router) { router.onReady(() => { app.$mount("#app") }); } else { app.$mount("#app"); }常見問題的解決方案
至此,基本的代碼改造也已經完成了,下面說的是一些常見問題的解決方案:
在服務端沒有window、location對象:
對于舊項目遷移到SSR肯定會經歷的問題,一般為在項目入口處或是created、beforeCreate生命周期使用了DOM操作,或是獲取了location對象,通用的解決方案一般為判斷執行環境,通過typeof window是否為"undefined",如果遇到必須使用location對象的地方用于獲取url中的相關參數,在ctx對象中也可以找到對應參數。
vue-router報錯Uncaught TypeError: _Vue.extend is not _Vue function,沒有找到_Vue實例的問題:
通過查看Vue-router源碼發現沒有手動調用Vue.use(Vue-Router);。沒有調用Vue.use(Vue-Router);在瀏覽器端沒有出現問題,但在服務端就會出現問題。對應的Vue-router源碼所示:
VueRouter.prototype.init = function init (app /* Vue component instance */) { var this$1 = this; process.env.NODE_ENV !== "production" && assert( install.installed, "not installed. Make sure to call `Vue.use(VueRouter)` " + "before creating root instance." ); // ... }
服務端無法獲取hash路由的參數
由于hash路由的參數,會導致vue-router不起效果,對于使用了vue-router的前后端同構應用,必須換為history路由。
接口處獲取不到cookie的問題:
由于客戶端每次請求都會對應地把cookie帶給接口側,而服務端Web Server Frame作為代理服務器,并不會每次維持cookie,所以需要我們手動把
cookie透傳給接口側,常用的解決方案是,將ctx掛載到全局狀態中,當發起異步請求時,手動帶上cookie,如下代碼所示:
// createStore.js // 在創建全局狀態的函數`createStore`時,將`ctx`掛載到全局狀態 export function createStore({ ctx }) { return new Vuex.Store({ state: { ...state, ctx, }, getters, actions, mutations, modules: { // ... }, plugins: debug ? [createLogger()] : [], }); }
當發起異步請求時,手動帶上cookie,項目中使用的是Axios:
// actions.js // ... const actions = { async getUserInfo({ commit, state }) { let requestParams = { params: { random: tool.createRandomString(8, true), }, headers: { "X-Requested-With": "XMLHttpRequest", }, }; // 手動帶上cookie if (state.ctx.request.headers.cookie) { requestParams.headers.Cookie = state.ctx.request.headers.cookie; } // ... let res = await Axios.get(`${requestUrlOrigin}${url.GET_A}`, requestParams); commit(globalTypes.SET_A, { res: res.data, }); } }; // ...
接口請求時報connect ECONNREFUSED 127.0.0.1:80的問題
原因是改造之前,使用客戶端渲染時,使用了devServer.proxy代理配置來解決跨域問題,而服務端作為代理服務器對接口發起異步請求時,不會讀取對應的webpack配置,對于服務端而言會對應請求當前域下的對應path下的接口。
解決方案為去除webpack的devServer.proxy配置,對于接口請求帶上對應的origin即可:
const requestUrlOrigin = requestUrlOrigin = state.ctx.URL.origin; const res = await Axios.get(`${requestUrlOrigin}${url.GET_A}`, requestParams);
對于vue-router配置項有base參數時,初始化時匹配不到對應路由的問題
在官方示例中的entry-server.js:
// entry-server.js import { createApp } from "./app"; export default context => { // 因為有可能會是異步路由鉤子函數或組件,所以我們將返回一個 Promise, // 以便服務器能夠等待所有的內容在渲染前, // 就已經準備就緒。 return new Promise((resolve, reject) => { const { app, router } = createApp(); // 設置服務器端 router 的位置 router.push(context.url); // ... }); }
原因是設置服務器端router的位置時,context.url為訪問頁面的url,并帶上了base,在router.push時應該去除base,如下所示:
router.push(context.url.replace("/base", ""));小結
本文為筆者通過對現有項目進行改造,給現有項目加上Vue服務端渲染的實踐過程的總結。
首先闡述了什么是Vue服務端渲染,其目的、本質及原理,通過在服務端使用Vue的虛擬DOM,形成初始化的HTML字符串,即應用程序的“快照”。帶來極大的性能優勢,包括SEO優勢和首屏渲染的極速體驗。之后闡述了Vue服務端渲染的基本用法,即兩個入口、兩個webpack配置,分別作用于客戶端和服務端,分別生成vue-ssr-client-manifest.json與vue-ssr-server-bundle.json作為打包結果。最后通過對現有項目的改造過程,包括對路由進行改造、數據預獲取和狀態初始化,并解釋了在Vue服務端渲染項目改造過程中的常見問題,幫助我們進行現有項目往Vue服務端渲染的遷移。
文章最后,打個廣告:騰訊醫療部門招前端工程師啦,HC無限多,社招、校招均可內推。如果有想來騰訊的小伙伴,可以添加我的微信:xingbofeng001,如果有想交朋友、交流技術的小伙伴也歡迎添加我的微信~
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/102700.html
摘要:同構的關鍵要素完善的屬性及生命周期與客戶端的時機是同構的關鍵。的一致性在前后端渲染相同的,將輸出一致的結構。以上便是在同構服務端渲染的提供的基礎條件。可以將封裝至的中,在服務端上生成隨機數并傳入到這個中,從而保證隨機數在客戶端和服務端一致。 原文地址 React 的實踐從去年在 PC QQ家校群開始,由于 PC 上的網絡及環境都相當好,所以在使用時可謂一帆風順,偶爾遇到點小磕絆,也能夠...
摘要:在美團支付的前端技術體系里,通過預渲染提升網頁首幀優化,從而優化了白屏問題,提升用戶體驗,并形成了最佳實踐。我們團隊主要負責美團支付相關的業務,如果網站太慢會影響用戶的支付體驗,會造成客訴或資損。 前言 自JavaScript誕生以來,前端技術發展非常迅速。移動端白屏優化是前端界面體驗的一個重要優化方向,Web 前端誕生了 SSR 、CSR、預渲染等技術。在美團支付的前端技術體系里,通...
摘要:我們的目標是讓的頁面也能夠擁有般的體驗,如果你還在尋求什么技術能夠讓老板虎軀一震拯救你的,那么這篇文章或許能夠幫助到你。這是一個使用編寫的頁面,運行于多端,包括企鵝輔導手機手機瀏覽器。經過我們的測試發現安卓基本上都是支持的,需要以上才支持。 本文由云+社區發表作者:思衍Jax showImg(https://segmentfault.com/img/remote/1460000017...
摘要:我們的目標是讓的頁面也能夠擁有般的體驗,如果你還在尋求什么技術能夠讓老板虎軀一震拯救你的,那么這篇文章或許能夠幫助到你。這是一個使用編寫的頁面,運行于多端,包括企鵝輔導手機手機瀏覽器。經過我們的測試發現安卓基本上都是支持的,需要以上才支持。 本文由云+社區發表 作者:思衍Jax 天下武功,唯 (wei) 快(fu) 不(bu) 破(po)。 隨著近幾年的前端技術的高速發展,越來越多的...
閱讀 2432·2021-11-22 13:53
閱讀 1126·2021-09-22 16:06
閱讀 1369·2021-09-02 15:21
閱讀 1895·2019-08-30 15:55
閱讀 3116·2019-08-29 11:19
閱讀 1911·2019-08-26 13:23
閱讀 930·2019-08-23 18:23
閱讀 1747·2019-08-23 16:06