如何安全地讀寫深度巢狀的物件?
我猜各位 JSer,或多或少都遇到過這種錯誤:Uncaught TypeError: Cannot read property 'someProp' of undefined
。當我們從null
或者undefined
上去讀某個屬性時,就會報這種錯誤。尤其一個複雜的前端專案可能會對接各種各樣的後端服務,某些服務不可靠,返回的資料並不是約定的格式時,就很容易出現這種錯誤。
這裡有一個深度巢狀的象:
let nestedObj = { user: { name: 'Victor', favoriteColors: ["black", "white", "grey"], // contact info doesn't appear here // contact: { //phone: 123, //email: "[email protected]" // } } } 複製程式碼
我們的nestedObj
本應該有一個contact
屬性,裡面有對應的phone
和email
,但是可能因為各種各樣原因(比如:不可靠的服務),contact
並不存在。如果我們想直接讀取 email 資訊,毫無疑問是不可以的,因為contact
是undefined
。有時你不確定contact
是否存在, 為了安全的讀到email
資訊,你可能會寫出下面這樣的程式碼:
const { contact: { email } = {} } = nestedObj // 或者這樣 const email2 = (nestedObj.contact || {}).email // 又或者這樣 const email3 = nestedObj.contact && nestedObj.contact.email 複製程式碼
上面做法就是給某些可能不存在的屬性加一個預設值或者判斷屬性是否存在,這樣我們就可以安全地讀它的屬性。這種手動加預設的辦法或者判斷的方法,在物件巢狀不深的情況下還可以接受,但是當物件巢狀很深時,採用這種方法就會讓人崩潰。會寫類似這樣的程式碼:const res = a.b && a.b.c && ...
。
讀取深度巢狀的物件
下面我們來看看如何讀取深度巢狀的物件:
const path = (paths, obj) => { return paths.reduce((val, key) => { // val 是 null 或者 undefined, 我們返回undefined,否則的話我們讀取「下一層」的資料 if (val == null) { return undefined } return val[key] }, obj) } path(["user", "contact", "email"], nestedObj) // 返回undefined, 不再報錯了:+1: 複製程式碼
現在我們利用path
函式可以安全得讀取深度巢狀的物件了,那麼我們如何寫入或者更新深度巢狀的物件呢?
這樣肯定是不行的nestedObj.contact.email = [email protected]
,因為不能在 undefined 上寫入任何屬性。
更新深度巢狀的物件
下面我們來看看如何安全的更新屬性:
// assoc 在 x 上新增或者修改一個屬性,返回修改後的物件/陣列,不改變傳入的 x const assoc = (prop, val, x) => { if (Number.isInteger(prop) && Array.isArray(x)) { const newX = [...x] newX[prop] = val return newX } else { return { ...x, [prop]: val } } } // 根據提供的 path 和 val,在 obj 上新增或者修改對應的屬性,不改變傳入的 obj const assocPath = (paths, val, obj) => { // paths 是 [],返回 val if (paths.length === 0) { return val } const firstPath = paths[0]; obj = (obj != null) ? obj : (Number.isInteger(firstPath) ? [] : {}); // 退出遞迴 if (paths.length === 1) { return assoc(firstPath, val, obj); } // 藉助上面的 assoc 函式,遞迴地修改 paths 裡包含屬性 return assoc( firstPath, assocPath(paths.slice(1), val, obj[firstPath]), obj ); }; nestedObj = assocPath(["user", "contact", "email"], "[email protected]", nestedObj) path(["user", "contact", "email"], nestedObj) // [email protected] 複製程式碼
我們這裡寫的assoc
和assocPath
均是pure function
,不會直接修改傳進來的資料。我之前寫了一個庫ofollow,noindex">
js-lens
,主要的實現方式就是依賴上面寫的幾個函式,然後加了一些函式式特性,比如compose
。這個庫的實現參考了
ocaml-lens
和Ramda
相關部門的程式碼。下面我們來介紹一下lens
相關的內容:
const { lensPath, lensCompose, view, set, over } = require('js-lens') const contactLens = lensPath(['user', 'contact']) const colorLens = lensPath(['user', 'favoriteColors']) const emailLens = lensPath(['email']) const contactEmailLens = lensCompose(contactLens, emailLens) const thirdColoLens = lensCompose(colorLens, lensPath([2])) view(contactEmailLens, nestedObj) // undefined nestedObj = set(contactEmailLens, "[email protected]", nestedObj) view(contactEmailLens, nestedObj) // "[email protected]" view(thirdColoLens, nestedObj) // "grey" nestedObj = over(thirdColoLens, color => "dark " + color, nestedObj) view(thirdColoLens, nestedObj) // "dark grey" 複製程式碼
我來解釋一下上面引用的函式的意思,lensPath
接收paths
陣列,然後會返回一個getter
和 一個setter
函式,view
利用返回的getter
來讀取對應的屬性,set
利用返回的setter
函式來更新對應的屬性,over
和set
的作用一樣,都是用來更新某個屬性,只不過他的第二個引數是一個函式,該函式的返回值用來更新對應的屬性。lensCompose
可以把傳入lens
compose 起來, 返回一個getter
和 一個setter
函式,當我們資料變得很複雜,巢狀很深的時候,它的作用就很明顯了。
處理巢狀表單
下面我們來看一個例子,利用lens
可以非常方便地處理「巢狀型表單」,例子的完整程式碼在這裡
。
import React, { useState } from 'react' import { lensPath, lensCompose, view, set } from 'js-lens' const contactLens = lensPath(['user', 'contact']) const nameLens = lensPath(['user', 'name']) const emailLens = lensPath(['email']) const addressLens = lensPath(['addressLens']) const contactAddressLens = lensCompose(contactLens, addressLens) const contactEmailLens = lensCompose(contactLens, emailLens) const NestedForm = () => { const [data, setData] = useState({}) const value = (lens, defaultValue = '') => view(lens, data) || defaultValue const update = (lens, v) => setData(prev => set(lens, v, prev)) return ( <form onSubmit={(e) => { e.preventDefault() console.log(data) }} > {JSON.stringify(data)} <br /> <input type="text" placeholder="name" value={value(nameLens)} onChange={e => update(nameLens, e.target.value)} /> <input type="text" placeholder="email" value={value(contactEmailLens)} onChange={e => update(contactEmailLens, e.target.value)} /> <input type="text" placeholder="address" value={value(contactAddressLens)} onChange={e => update(contactAddressLens, e.target.value)} /> <br /> <button type="submit">submit</button> </form> ) } export default NestedForm 複製程式碼
最後希望本篇文章能對大家有幫助,同時歡迎:clap:大家關注我的專欄:前端路漫漫。