状态受控&非受控

可能大家都听说过这两个术语,但是可能不知道他们具体表达的意思,或又经常将他们混淆。

受控:

UI所渲染的某个状态来自props相对于其组件的使用者(同时也是props的注入者)来说,那么这个状态就属于受控状态,而若一个组件所渲染的所有状态都来自props,那么这个组件本身就属于一个受控组件:

const UserDetail = (props) => {
  return <div>
    <img src={user.src} />
    <Profile data={user.info}>
  </div>
}

非受控:

UI所渲染的某个状态来自与自身的state,那么相对于其组件的使用者来说,这个状态就属于非受控状态。

const UserDetail = () => {
  const [user, setUser] = useState({})
  return <div>
    <img src={user.src} />
    <Profile data={user.info}>
  </div>
}

我们不难发现,所谓受控的组件/状态,其实就是指当前组件被其他组件所使用时,当前组件渲染的内容,完全由父组件通过props的方式所控制. 而非受控组件/状态,则是指当前组件所渲染的状态,不受其父组件的影响或者控制。

这两个特性,也是受控和非受控的特点:

  • 受控:因为状态是在使用者层面进行管理的,所以:
  1. 使用者对于状态的控制有更多的自由度,可以随意对状态进行控制
  2. 数据流清晰,因为状态在使用者层面,所以当有其他子组件也想获取到此状态的话就会变得非常容易
  3. 每次使用子组件,都要先对它所需要的状态进行初始化然后注入,带来了一定的麻烦。
  • 非受控: 状态被封装在了组件内部。
  1. 父组件在使用子组件时,无法轻易的通过props对子组件渲染的内容进行控制
  2. 当存在其他组件也需要对此状态进行订阅时,无法轻易的对此状态进行同步
  3. 封装了对内部状态的管理,给组件的使用者带来了遍历

总结来说:受控组件,在使用上有2个优点和缺点。而非受控组件在使用上有对应的2个缺点和1个优点。

而现在hooks的到来,让我们得以比较好的解决状态封装的问题:我们可以封装一个useXXX的hooks,来声明并返回受控组件所需要的所有状态,并且在父组件去引用这个hooks并且把所有所需要的的状态注入进去。

const useUser = () => {
  const [user, setUser] = useState({})
  const [error, setError] = useState({})
  useEffect(() => {
    fetch('/getUser').then(setUser).catch(e => setError)
  },[])
  return { user, setUser, error, setError }
}
function UserDetail(){
  const detailProps = useUser();
  return <Detail {...detailProps } />
}

这样一来,我们就不用在每次用Detail组件的时候去手动写一个又一个的const [user, setUser] = useState({}),因为这些状态的初始化全部被useUser封装起来了,并且还可以随意的复用,这也是hooks的魅力所在。

但这种hooks写法做有个小缺陷:因为class组件不支持hooks,所以如果有class组件需要渲染Detail组件,得使用其他方法去复用这种带有状态的逻辑(使用HOC或者render props都行,不展开讨论)

下面我们再来看看非受控组件如何解决自身的两个缺点

  • 当一个父组件想要控制一个非受控组件内部的数据时,可以通过props传到非受控组件内部,然后通过非受控组件内的useEffect"监控"其props的改变,进而同步其变化:
const Detail = ({data}) => {
  const [user, setUser] = useState()
  useEffect(() => {
    setUser(data)
   },[data])
  return <div>
    <img src={user.src} />
    <Profile data={user.info}>
  </div>
}

Emmm, 有没有一种熟悉的感觉?这不就是我们刚才提到的状态冗余的写法吗?这种写法确实可以解决上述两个问题. 但是这里还是需要做一点额外的工作,即在父组件定义那些需要被其他子组件共享的状态,还是给父组件带来了一点额外的工作量,当然我们可以也可以通过hooks帮我们把部分工作抽象出去。

  • 解决状态需要被外部状态订阅的问题:我们像刚刚第一种写法一样,在父组件重新定义一个状态,然后把状态的setter传到子组件,监听子组件内部状态的变化并且调用这个setter, `直接把状态当做props并监听其更改来同步子组件
const Detail = ({onChange,data}) => {
  const [user, setUser] = useState()
  useEffect(() => {
    setUser(data) // 把父组件的状态的变更同步到子组件
  },[data])
  useEffect(() => {
    onChange && onChange(user) // 把子组件状态变更通知到父组件。
   },[user])
  return <div>
    <img src={user.src} />
    <Profile data={user.info}>
  </div>
}

const UserDetail = () => {
  const [data,setData] = useState({})
  return <div>
    <Detail data={data} onChange={setData}/>
    <div onClick={() => setData(undefined)}>重置</div>
    <div>用户名: {data.name}</div> // 这里的状态就与Detai组件保持同步了
 </div> // 同步子组件状态到父组件
}

我们的组件现在同时支持受控/非受控了。在组件内部的状态没有"需要受到父组件控制"和"外部也需要这个状态"的需求下,我们使用组件的方式非常简单,什么参数都不用传。而如果我们有了这两点需求的时候,又可以通过受控的方式,把状态本身的和其setter都通过props传到了子组件,然后通过监听此状态的变化进而同步组件内部的状态。但同时这样一来,表达同一份业务的状态,在我们组件中就存在了2份.

Single source of truth

single source of truth表示一份状态仅由1份数据来表达解释,完全受控的情况下,子组件和父组件仅仅是共享了一份状态的getter和setter。而如果是上述非受控的情况下,这份状态实际上通过2个状态互相通信、同步来表达了。这两份数据都有各自的setter

这样做带来的问题:

  • 因为我们是通过useEffect来做同步的,所以每次状态更新的时候,子组件都会render 两次。

Control Props

const Detail = (props) => {
  const [user, setUser] = useState()
  const controlled = 'data' in props
  const componundUser = controlled ? data : user
  const componundSetter = controlled ? onChange : setUser
  return <div>
    <img src={componundUser.src} />
    <Profile data={componundUser.info}>
  </div>
}

上述写法描述了Detail组件的行为:

  1. 当父组件给Detail传了props的时候,他就变成了受控组件,并且不需要维护两份state.
  2. 当父组件没有给Detail传props的时候,他就变成了非受控组件。

同样的,这里的状态我们也可以通过hooks抽象出去:

const useConditionalState = (props) => {
  const controlled = 'data' in props;
  const [data,setData] = useState();
  const user = controlled ? data : user;
  const setter = controlled ? onChange : setUser
  return [user, setter] 
}
const Detail = (props) => {
  const [user, setUser] = useConditionalState(props)
  return <div>
    <img src={user.src} />
    <Profile data={user.info}>
  </div>
}
function Page(){
  return <Detail />
}
function Page(){
  const [user,setUser] = useState()
  return <Detail onChange={setUser} user={user} />
}

如果我们的Detail是这样实现的话,那么我们后续想要把非受控的用法换成受控写法就会变得非常简单。

组合的艺术

我们知道,hooks在Vue里对标的是composition API,composition是组合的意思,我们知道这也是函数的一个特性,让我们看看如何用受控+组合的方式设计我们的组件。
通常,我们一个组件是由3部分组成:状态+属性+视图。举个例子:

const Detail = ({id}) => {
  const { data:user } = useRequest(getUserDetail)
  const userImg = id === 'kezhi' ? '吴彦祖' : user.src
  return <div>
    <div>姓名 : {user.name}</div>
    <img src={userImg } />
    <div>ID: {id}</div>
  </div>
}
const Page = () => {
 return <Detail id={'kezhi'}/>
}

这里我们Detail 返回的JSX结构,即使我们组件的骨架,而jsx所渲染的内容,可能是内部的状态(这里的user.name),也可能是传进来的外部属性(props.id) 甚至或许还需要我们结合props和state计算得到的复合状态:userImg。所以我们使用hooks的思想,把一个组件分为三层:
DOM: 底层,负责组件的HTML结构。是组件的骨架,完全受控。只负责接受props,并且用props结合以有的HTML进行渲染。 这里我们可以定义一个Detail UI

const Detail_UI = (props)=> {
  return <div>
    <div>姓名:{props.name}</div>
    <img src={props.userImg} />
    <div>ID: {props.id}</div>
  </div>
}

接着,我们来聚合Detail_UI所要渲染的内容,这个聚合函数的返回类型和刚刚我们定义的UI的参数类型完全匹配的.

const getPropsMapper = (state,props) => {
  return {
    name: state.data.name,
    userImg: props.id === 'kezhi' ? '吴彦祖' : user.src,
    id: props.id,
  }
}

接着,最后我们来定义我们可复用的带有状态的逻辑

const useDetailState = ({service, params}) => {
  const requestResult = useRequest(service)
  return requestResult
}

接着,我们把这三者结合起来得到了一个非受控Detail组件:

const Detail = (props) => {
  const state = useDetailState(getUserDetail);
  const ui_props = getPropsMapper({state,...props})
  return <Detail_UI  {...ui_props} />
}

//非常丝滑,使用的方式跟我们最开始定义的非受控Detail组件一模一样。
const Page = () => {
 return <Detail id={'kezhi'}/> 
}

如果接下来,我们来了个需求,说需要外部有一个按钮,只要点击这个按钮,可以一键把Detail里的头像换成吴彦祖。
我们扭动下魔方,马上就能得到一个受控的Detail组件:

const Detail = ({state,...props}) => {
  const ui_props = getPropsMapper({state,...props})
  return <Detail_UI  {...ui_props} />
}
const Page = () => {
 const state = useDetailState(getUserDetail,{id:'wkz'});
 return  <div>
 <button onClick={() => state.mutate('wzy')}>一键切换成吴彦祖</button>
 <Detail state={state} id={'wkz'}>
</div>
}

假如我们此时又接到需求说,在A页面下,所有的详情里的姓名后面都必须加上性别男,那么我们可以很方便的组装一个特点场景下的Detail组件:

const ManDetail = (props) => {
  const state = useDetailState(getUserDetail);
  const ui_props = getPropsMapper({state,...props})
  return <Detail_UI  {...ui_props} name={ui_props.name + '(男)'} />
}
const Page = () => {
 return <ManDetai id={'wkz'}>
}

假如此时需求又来了,说现在有个定制化页面,这个页面为了突出用户的头像,头像必须展示在姓名之前,但是数据要和详情保持一致,那我们只需要改一下Detail_UI的html结构就能实现:

const UpdownDetail = (props) => {
  const state = useDetailState(getUserDetail);
  const ui_props = getPropsMapper({state,...props})
  return <Detail_Revert_UI  {...ui_props} name={ui_props.name + '(男)'} />
}
const Detail_Revert_UI = (props)=> {
  return <div>
    <img src={props.userImg} />
    <div>ID: {props.id}</div>
    <div>姓名:{props.name}</div>
  </div>
}
const Page = () => {
 return <ManDetai id={'wkz'}>
}

这样写的好处:

  1. 上述根据需求组合而成的不同场景下的Detail组件在内容或者结构上或许有所不同,但是他们都享有同样的数据流,他们的业务逻辑都是用的共同的复用的。
  2. 非受控可以完全由受控的方式组合而成,若用户不想要状态暴露到外部,可以直接使用非受控的写法。
  3. 受控+hooks注入的方式简单自然,给用户带来了极大的状态上的灵活性和便利性。
  4. hooks的方式不仅将状态提示并注入了,还封装了统一的状态逻辑。比如安超的可拖拽列,若想要columns支持拖拽,使用这种方式编写的组件一行都不用改,我只需要在hooks里多添加一个状态,在Table组件里去对这个状态进行消费就好, 不仅如此,因为我的状态不是定义在Table内部的,在Table之外当我 想要导出带有当前列顺序的表格时可以直接拿到hooks.order。

总结

组件的受控和非受控可以互相转化,只要将当前组件的状态提升到父组件,当前组件就变成了受控组件。非受控和受控的组件各有各的优点和缺点,在实际场景中,我们往往根据需求需要分情况讨论。

在设计我们的组件的时候,若我们是采用传统的非受控写法,那么后续在我们有需求把状态提升的时候,会带来重构上的工作量(两个方面:1、内部状态useEffect同步外部。2、外部声明状态和注入的过程。
非受控写法可以结合上述的control props写法可以解决上述的第一个问题,避免让组件内部useEffect来对外部的同步。

而组合式组件的写法,可以让我们轻松的将状态提升,并且不用父子状态通过useEffect同步,并且无论是状态、UI的属性、UI的结构,都给我们带来了极大的灵活性。 组合式组件的利弊:一开始可能会把我们绕晕,props传了又传。但是一旦理解之后,后续用起来和改起来都很方便。

传统组件的写法,非常符合直觉,没那么多函数嵌套,理解起来较简单,但是后续面对复杂多变的需求不是那么方便。

而目前安超的基类使用的是注入+受控写法。CS使用的是非受控写法。大家有空可以去参考一下这两个基类,看看各有什么优缺点。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270