前端防護性編程

前言

一個頁面在呈現給用戶以前須要通過靜態資源加載、後端接口請求和渲染這三個過程,咱們要作的就是在各個過程當中防護可能出現的異常狀況,保持流暢的用戶體驗,同時還要應對來自外部的攻擊。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>
  );
}
複製代碼

好處是不用頁面管理本身的狀態,壞處是提示比較重,會阻塞用戶其它操做。

防攻擊

xss

腳本注入攻擊,例如在某個帖子下留言,內容注入一段腳本獲取當前登陸用戶的cookie:

<script>report(document.cookie)</script>
複製代碼

若是該網站沒有作留言內容的輸出轉義,就會被注入腳本,全部訪問該帖子的用戶都將是受害者。

若是網站作了輸出轉義,你們看到就是這樣一坨內容:

&lt;script&gt;report(document.cookie)&lt;/script&gt;
複製代碼

目前主流的庫或框架默認都幫咱們進行了轉義輸出,像React,若是咱們必定要渲染html片斷須要使用dangerouslySetInnerHTML。

csrf

跨站腳本僞造,例如在網站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

相關文章
相關標籤/搜索