如何安全地讀寫深度巢狀的物件?

揹著燈籠發表於2018-12-09

我猜各位 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: "123@example.com"
    // }
  }
}
複製程式碼

我們的 nestedObj 本應該有一個 contact 屬性,裡面有對應的 phoneemail,但是可能因為各種各樣原因(比如:不可靠的服務), contact 並不存在。如果我們想直接讀取 email 資訊,毫無疑問是不可以的,因為contactundefined。有時你不確定 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, 不再報錯了?
複製程式碼

現在我們利用 path 函式可以安全得讀取深度巢狀的物件了,那麼我們如何寫入或者更新深度巢狀的物件呢? 這樣肯定是不行的 nestedObj.contact.email = 123@example.com,因為不能在 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"], "123@example.com", nestedObj)
path(["user", "contact", "email"], nestedObj) // 123@example.com
複製程式碼

我們這裡寫的 assocassocPath 均是 pure function,不會直接修改傳進來的資料。我之前寫了一個庫 js-lens,主要的實現方式就是依賴上面寫的幾個函式,然後加了一些函式式特性,比如 compose。這個庫的實現參考了 ocaml-lensRamda 相關部門的程式碼。下面我們來介紹一下 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, "123@example.com", nestedObj)
view(contactEmailLens, nestedObj) // "123@example.com"

view(thirdColoLens, nestedObj) // "grey"

nestedObj = over(thirdColoLens, color => "dark " + color, nestedObj)
view(thirdColoLens, nestedObj) // "dark grey"

複製程式碼

我來解釋一下上面引用的函式的意思,lensPath 接收 paths 陣列,然後會返回一個 getter 和 一個 setter 函式,view 利用返回的 getter 來讀取對應的屬性,set 利用返回的 setter 函式來更新對應的屬性,overset 的作用一樣,都是用來更新某個屬性,只不過他的第二個引數是一個函式,該函式的返回值用來更新對應的屬性。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
複製程式碼

最後希望本篇文章能對大家有幫助,同時歡迎?大家關注我的專欄:前端路漫漫

相關文章