一個頁面在呈現給用戶以前須要通過靜態資源加載、後端接口請求和渲染這三個過程,咱們要作的就是在各個過程當中防護可能出現的異常狀況,保持流暢的用戶體驗,同時還要應對來自外部的攻擊。javascript
目前主流的研發模式都是先後端分離,拿React舉例來講html
function App() {
const [data, setData] = useState(null);
useEffect(() => {
(async () => {
const data = await request();
setData(data);
})();
});
if (!data) return null;
return (
<div className="App"> <h1>Hello {data.name}</h1> </div>
);
}
複製代碼
網絡較差數據返回慢,頁面不渲染,一直展現空白頁,體驗不好,通常咱們會加個過渡變成這樣:前端
function App() {
...
if (!data) return <Loading />; ... } 複製代碼
查看demo:CodeSandboxjava
這個能解決數據返回以前頁面白屏的問題,可是忽略了靜態資源加載的時長,這段時間頁面仍是處於白屏的狀態,因此在加載靜態資源以前也應該有個過渡效果,試着修改上面的例子:node
<html>
<head>
<title>首頁</title>
<style> .loading { ... } </style>
</head>
<body>
<div id="app">
<div class="loading"><span></span></div>
</div>
<script src="manifest.js"></script>
<script src="vendor.js"></script>
<script src="main.js"></script>
</body>
</html>
複製代碼
查看demo:CodeSandboxreact
先加載loading片斷,再加載資源,看起來解決了總體的過渡問題,可是你們仔細觀察會發現動畫播放了一會又從新開始了,破碎感比較嚴重,緣由相信你們也比較清楚,React從新渲染了loading的節點,因此在數據回來前,不該該讓React接管頁面,試着再次改造:ios
/* render.js */
import React from "react";
import ReactDOM from "react-dom";
export default function render(Component, props) {
const rootElement = document.getElementById("root");
ReactDOM.render(<Component {...props} />, rootElement); } /* index.js */ import render from "./render"; import request from "./request"; import App from "./App"; (async () => { const data = await request(); render(App, { data }); })(); 複製代碼
查看demo:CodeSandbox程序員
在頁面內容呈現給用戶以前,會一直保持loading動畫的效果,避免因網絡緣由形成用戶體驗的中斷。json
靜態資源加載完成以後,咱們開始和後端進行通訊獲取頁面數據,首先咱們須要處理如下幾種可能異常的狀況。axios
一個網頁從訪問到呈現出來,用戶能容忍的等待時間大概是3~5s,在除去靜態資源加載的時間大概1~2s左右,接口請求應該在3s內返回結果。
若是碰到用戶網絡較差,而咱們又沒有設置接口超時,頁面會一直處於loading的狀態,用戶得不到有效的反饋會直接離開。因此咱們須要設置合理的超時時間,並在觸發超時的狀況下給予用戶反饋。
咱們選擇使用原生fetch發起請求,很不巧,fetch不支持超時的參數設置,須要咱們手動包裝:
async function request(url, options = {}) {
const { timeout, ...restOptions } = options;
const response = await Promise.race([
fetch(url, restOptions),
timeoutFn(timeout),
]);
const { data } = await response.json();
return data;
}
function timeoutFn(ms = 3000) {
return new Promise((resolve, reject) => {
setTimeout(() => {
reject(Error('timeout'));
}, ms);
});
}
複製代碼
而後在超時的狀況下進行提示:
async function request(url, options = {}) {
const { timeout, ...restOptions } = options;
try {
const response = await Promise.race([
baseRequest(url, restOptions),
timeoutFn(timeout),
]);
const { data } = await response.json();
return data;
} catch (error) {
if (error.message === 'timeout') {
render(() => <span>請求超時,請重試</span>);
}
throw error;
}
}
複製代碼
超時提示的功能有了,可是用戶重試要麼自主刷新頁面,要麼只能退出重進,體驗仍是不夠友好。
理想的狀況應該讓用戶在當前的頁面上直接操做重試,不要有頁面刷新或者跳出的過程。咱們再次對代碼進行調整,模擬一個相對完整的例子:
查看demo:CodeSandbox
拿到請求的結果以後,首先咱們把網絡相關的錯誤處理掉:
const statusText = {
401: '請從新登陸',
403: '沒有操做權限',
404: '請求不存在',
500: '服務器異常',
...
};
function request(url, options = {}, callback) {
const { timeout, ...restOptions } = options;
try {
const response = await Promise.race([
fetch(url, restOptions),
timeoutFn(timeout),
]);
const { status } = response;
if (status === 200) {
const { data } = await response.json();
callback(data);
return;
}
render(
PageError,
{
children: statusText[status] || '系統異常,請稍後重試'
}
);
} catch (error) {
if (error.message === 'timeout') {
render(PageError, {
key: Math.random(),
onFetch() {
request(url, options, callback);
},
children: '請求超時,點擊重試'
});
}
throw error;
}
}
複製代碼
接下來再處理後端正常返回的業務錯誤,先和後端約定下返回的數據結構:
{
success: true/false,
data: { // success爲true時返回
id: '69887645366',
desc: '這是產品描述',
},
errorCode: 'E123456', // success爲false時返回
errorMsg: '產品id不能爲空', // success爲false時返回
}
複製代碼
處理錯誤:
if (status === 200) {
const { success, data, errorMsg } = await response.json();
if (success) {
callback(data);
return;
}
render(PageError, { children: errorMsg });
}
複製代碼
查看demo:CodeSandbox
若是你們常常寫React SPA的頁面,應該碰到過這種錯誤:
緣由是進入組件A發起了請求,快速切換到組件B,組件A被銷燬了,等請求回來後調用setState就報錯了,看個簡單例子:
查看demo:CodeSandbox
解法也很簡單,組件unmount的時候取消請求,惋惜的是fetch也不支持,改造下:
function request(url, options = {}, callback) {
const fetchPromise = fetch(url, options)
.then(response => response.json());
let abort;
const abortPromise = new Promise((resolve, reject) => {
abort = () => {
reject(Error('abort'));
};
});
Promise.race([fetchPromise, abortPromise])
.then(({ data }) => {
callback(data);
}).catch(() => { });
return abort;
}
useEffect(() => {
const abort = request('https://cnodejs.org/api/v1/topic/5433d5e4e737cbe96dcef312', {}, setData);
return () => {
abort();
};
});
複製代碼
查看demo:CodeSandbox
到目前爲止,咱們基本上解決了接口異常的各類狀況,接下來能夠進入到業務邏輯的編寫當中了。
建議你們在生產環境中選擇相似axios的Http請求庫,原生fetch能力太弱
假設有個頁面,展現用戶餘額,大概長這個樣子
後端正常返回的數據結構是這樣的:
{ rest: { amount: "10" } }
複製代碼
前端渲染邏輯通常是這樣的:
<div>
<strong>餘額:</strong>
<span className="highlight">{rest.amount}元</span>
</div>
複製代碼
有一天後端寫了個bug,請求成功了,可是沒有正常返回rest結構,按照上面的寫法,果斷報錯,Cannot read property 'amount' of undefined
,頁面白屏。
也許有些人的作法是判空:
<span className="highlight">{rest && rest.amount}元</span>
複製代碼
這種處理會帶來兩個問題
折中的方案是進行一個錯誤的提示,避免白屏,在React中咱們能夠經過ErrorBoundary進行統一的處理:
class ErrorBoundary extends Component {
static getDerivedStateFromError() {
return { hasError: true };
}
state = {
hasError: false,
};
componentDidCatch(error, info) {
// reportError(error, info);
}
render() {
const { hasError } = this.state;
const { children } = this.props;
if (hasError) {
return <div>系統異常,請稍後再試</div>;
}
return children;
}
}
function render(Component, props) {
const rootElement = document.getElementById("root");
ReactDOM.render(
<ErrorBoundary> <Component {...props} /> </ErrorBoundary>, rootElement ); } 複製代碼
查看demo:CodeSandbox
某天業務提了個需求,在餘額頁面的底部加個banner廣告,新增了廣告的獲取接口:
function requestAd(callback) {
callback({ ad: { desc: "這是一個廣告" } });
}
複製代碼
很不幸,廣告接口不穩定,返回的數據常常出問題,按照上面例子的處理方式,會直接走到ErrorBoundary顯示異常了,明顯是不符合咱們預期的。
因此咱們須要對非核心業務降級處理:
<div>
<strong>餘額:</strong>
<span className="highlight">{rest.amount}元</span>
<ErrorBoundary fallback>
<Ad />
</ErrorBoundary>
</div>
複製代碼
查看demo:CodeSandbox
表單提交是一個很常見的場景,通常防重複點擊的方式有兩種
在按鈕上加防重,例如:
function App() {
const [applying, setApplying] = useState(false);
const handleSubmit = async () => {
if (applying) return;
setApplying(true);
try {
await request();
} catch (error) {
setApplying(false);
}
};
return (
<div className='App'> <button onClick={handleSubmit}> {applying ? '提交中...' : '提交'} </button> </div>
);
}
複製代碼
好處是不影響用戶對總體頁面的操做,壞處是須要頁面管理狀態。
進行頁面的總體遮蓋,例如:
function request(url) {
Loading.show('請求中...');
try {
await fetch(url);
} catch (error) {
// show error
} finally {
Loading.hide();
}
}
function App() {
const handleSubmit = () => {
request();
};
return (
<div className='App'> <button onClick={handleSubmit}>提交</button> </div>
);
}
複製代碼
好處是不用頁面管理本身的狀態,壞處是提示比較重,會阻塞用戶其它操做。
腳本注入攻擊,例如在某個帖子下留言,內容注入一段腳本獲取當前登陸用戶的cookie:
<script>report(document.cookie)</script>
複製代碼
若是該網站沒有作留言內容的輸出轉義,就會被注入腳本,全部訪問該帖子的用戶都將是受害者。
若是網站作了輸出轉義,你們看到就是這樣一坨內容:
<script>report(document.cookie)</script>
複製代碼
目前主流的庫或框架默認都幫咱們進行了轉義輸出,像React,若是咱們必定要渲染html片斷須要使用dangerouslySetInnerHTML。
跨站腳本僞造,例如在網站www.a.com的某個帖子下面留言,貼了一個釣魚連接,連接會跳到攻擊者開發的頁面www.b.com,該頁面的內容很簡單,自動發起一個帖子回覆的請求
<form action="http://www.a.com/replay">
<input type="text" name="content" value="這是自動回覆">
</form>
複製代碼
瀏覽帖子的用戶無心中點到了該連接,就會進行自動回覆。常見的防護方式是加token校驗,www.a.com經過cookie下發token,進行寫操做的時候讀取cookie當中的token並放入請求頭中進行服務端驗證。因爲瀏覽器同源策略的限制,b網站是沒法讀取a網站的token的。
還有一種方式是添加referer校驗,只有白名單中的域名才容許進行寫操做。通常是兩種方式結合使用,確保網站安全。
csrf是網絡請求層面須要防護的,只有框架纔會提供完整的功能,例如Angular,通常狀況下須要咱們本身集成。
上述列舉的各類異常狀況,在實際當中只佔了估計1%不到,可是幾乎咱們99%的基礎代碼都是爲此而編寫的。合格的程序員在編碼的過程當中首先考慮的就是怎麼防護極端異常,只有作好這1%異常處理,才能更好的服務於剩下的99%。
關於咱們:
咱們是螞蟻保險體驗技術團隊,來自螞蟻金服保險事業羣。咱們是一個年輕的團隊(沒有歷史技術棧包袱),目前平均年齡92年(去除一個最高分8x年-團隊leader,去除一個最低分97年-實習小老弟)。咱們支持了阿里集團幾乎全部的保險業務。18年咱們產出的相互寶轟動保險界,19年咱們更有多個重量級項目籌備動員中。現伴隨着事業羣的高速發展,團隊也在迅速擴張,歡迎各位前端高手加入咱們~
咱們但願你是:技術上基礎紮實、某領域深刻(Node/互動營銷/數據可視化等);學習上善於沉澱、持續學習;性格上樂觀開朗、活潑外向。
若有興趣加入咱們,歡迎發送簡歷至郵箱:jacky.yyy@antfin.com