Description
最近项目一直在使用 Monaco Editor 这个库. 在我加了一个新功能之后, 整个编辑器开始变的非常卡, 我试图解决这个性能问题. 但是发现有一些棘手...
评论以及文末有更新
场景
其实我加的新功能很简单, 以 vscode 为例, 他底部有一个 status bar, 用于显示当前编辑器的一些信息. 我当时加的功能是模仿 vscode, 在底部增加一个能显示当前光标位置/选中位置, 以及选中的单词的长度. 由于是公司代码的原因我这里只是简单放一下伪代码, 实际代码会比这个这个伪代码复杂和抽象很多
文件结构:
<App />
// ... other components
<div>
<Explorer />
<Editor />
</div>
<StatusBar />
简单说明一下, 有一个组件叫 App
, 由于历史遗留原因这二组件是个 class
组件, 而且代码很多, 这个组件下有非常多的子组件, Editor
和 StatusBar
就是其中的两个子组件
而我现在的需求是: 用户在 Editor
里做任何操作, StatusBar
组件都需要显示出对应用户光标的位置
我思路也很简单:
cursor
(光标) 部分的状态在Editor
中是可以拿到的- 由于
StatusBar
这个组件需要根据cursor
状态来渲染, 因此做状态提升, 到App
中 App
中初始化cursor
状态, 传入相关setState()
回调函数到Editor
中,Editor
组件调用更新cursor
状态, 最后StatusBar
获取cursor
最新的状态进行渲染.
实现
所以代码可能长这样
// App.js
import Editor from './Editor'
import StatusBar from './StatusBar'
export default class App {
constructor(props) {
super(props) {
this.state = {
// ...other state
cursorPosition: {
lineNumber: 1,
column: 1
}
}
this.editorRef = React.createRef()
}
}
setCursorPosition = (cursorPosition) => {
this.setState({
cursorPosition,
})
}
render() {
return (
// ... other components
<div>
<Explorer />
<Editor
setCursorPosition={this.setCursorPosition}
editorRef={this.editorRef}
/>
</div>
<StatusBar cursorPosition={this.cursorPosition} />
)
}
}
// Editor.js
import MonacoEditor from 'some-thirdparty-react-monaco-package'
export default function Editor(props) {
const { setCursorPosition, editorRef } = props
const handleEditorDidMount = () => {
editorRef.current.onDidChangeCursorPosition(ev => {
setCursorPosition(ev.position)
})
}
return (
<MonacoEditor editorDidMount={handleEditorDidMount} />
)
}
// StatusBar.js
import Button from '@material-ui/core/Button';
export default function StatusBar(props) {
const { cursorPosition } = props
return (
<Button size="small">
{cursorPosition}
</Button>
)
}
看上去好像没啥问题, 我当时这么写也没考虑太多
问题
实际上最后写完我测试发现了很大的性能问题
其实很简单, 我每次在 Editor
里面调用父组件(App
) 的 setState()
, 都会导致父组件重新渲染. 而 App
这个组件是一个很大的类组件, 里面还渲染很多别的组件, 而用户在编辑器里面只要敲一点东西, 光标几乎都会改变(或者直接点, 用户闲着没事在编辑器里直接乱点一通, 也能达到相同的效果). 这直接导致重新渲染 App
的频率相当频繁...
然后我页面就卡爆了...
我当时有点懵, 因为说实话这种渲染确实好像没法避免, 我每次的 cursor
状态确实不一样, 我没法直接通过 shouldComponentUpdate
来避免不必要的重复渲染
当然有时候可以避免, 就是当用户的每次都点击同一个地方, cursorPosition
就一样了...
方法
我一开始想到的一个办法是, 将 Editor
组件和 StatusBar
重新写在一个新的组件, 这样所有的状态就只在这个新组件(可以看成这是一个中间组件)里面管理, 不会触发 App
这个大组件的重新渲染了.
但我还是很快放弃了这个想法, 因为把 StatusBar
和 Editor
放一起其实不简单.... 从 dom 上来看, 他们其实不是严格的兄弟组件, 中间还有着别的组件, 如果要抽出来还必须连带着别的组件一起重写:
export default function MyNewComponent(props) {
const {
editorRef,
// ...还有很多别组件的 props...
} = props
// ...
return (
<div>
<CompA />
<CompB />
// ...other component
<div>
<Explorer />
<Editor editorRef={editorRef} />
</div>
<StatusBar />
<div>
)
}
总之, 我这么重构 effort 其实挺大...
可能的解决方案?
后来和另一个组里的实习生交流的时候, 他提出尝试把 cursor
部分的状态放到 redux
里面管理, 在 Editor
里面 dispatch
相应改变 cursorPosition
的 action
, 在 StatusBar
里面连接 redux 拿到最新的 cursorPosition
状态. 这样直接绕过 App
这一层, 避免了重复渲染
其实这个思路和之前的想法类似 都是要避开 App
这个大组件, 只不过用 redux 这种状态管理库似乎代码写起来简单一些
代码最后就变成这样了:
// Editor.js
import { useDispatch } from 'react-redux'
import { setCursorPosition } from './editorActions.js'
import MonacoEditor from 'some-thirdparty-react-monaco-package'
export default function Editor(props) {
const { setCursorPosition, editorRef } = props
const dispatch = useDispatch()
const handleEditorDidMount = () => {
editorRef.current.onDidChangeCursorPosition(ev => {
dispatch(setCursorPosition(ev.position))
})
}
return (
<MonacoEditor editorDidMount={handleEditorDidMount} />
)
}
// StatusBar.js
import Button from '@material-ui/core/Button';
import { useSelector, shallowEqual } from 'react-redux'
export default function StatusBar(props) {
const { cursorPosition } = useSelector(state => state.editor, shallowEqual)
return (
<Button size="small">
{cursorPosition}
</Button>
)
}
不过这么讲也存在别的一些问题:
cursorPosition
这个状态只在一个组件里被用到, Redux 本意还是为了做状态的管理, 多个组件可能都会共享到这个状态, 但现在只有一个, 似乎有点大材小用了- 我们项目里 Redux 已经放了很多的状态了, mentor 不希望我再放别的进去了...
- 我没见过像我一样用 Redux 来做性能优化的...
可能有更好的解法?
如果你恰好能明白我在讲什么, 并且有更好的办法, 请务必告诉我...
更新
文章发布之后有几位前辈评论了一下, 都非常好. 我自己总结然后实践了一下, 以下是我的解决思路
不管是我之前用的 Redux, 还是评论里面提到的 Context, 其实都是 Pub/Sub (发布订阅)这个思想的实践. 不过由于目前使用 Redux 有点太重, 所以其实用 Context 会更好
不过在使用 Context 的时候存在一个问题: 就是如果 context
的 value
是一个对象这种复杂结构, 然后存在多个消费者, 每个消费者可能只是订阅一部分 value
. 但是由于 context
的设计, 只要 value
部分变了, 那么所有的消费者都会被通知, 那么有很大的可能所有的消费者组件都被重新渲染了.
基于这个问题 Dan Abramov 也是有给出一些解法. 其实最直白的做法就是将多个 context 分离成几个更小的. 不过我在搜索的过程中发现了另外一个似乎更精巧的解法, 虽然可能有点简陋. 但是用在我们项目里我觉得应该够了(其实我不确定, 等过两天 mentor 给我 review 代码的时候再问问)?
context
先看看最基本的使用 context 的做法, 也是评论里 @李引证 提到的做法, 不过这里我略有修改.
// editorContext.js
import React from 'react'
// actions
export const setCursorPosition = () => {
// ...
}
export const setSelections = () => {
// ...
}
// context
export const EditorStateContext = React.createContext()
export const EditorDispatchContext = React.createContext()
const initialState = {
cursorPosition: {
lineNumber: 1,
column: 1
},
selections: ['']
}
function editorReducer(state, action) {
// ... reducer logic
}
export function EditorProvider({ children }) {
const [state, dispatch] = React.useReducer(editorReducer, initialState)
return (
<EditorStateContext.Provider value={state}>
<EditorDispatchContext.Provider value={dispatch}>
{children}
</EditorDispatchContext.Provider>
</EditorStateContext.Provider>
)
}
export function useEditorState() {
const context = React.useContext(EditorStateContext)
return context
}
export function useEditorDispatch() {
const context = React.useContext(EditorDispatchContext)
return context
}
这里我选择用 useReducer()
也是因为其实我的 editor
状态有点复杂, 而且 cursorPosition
和 selections
是对应两个不同的消费者组件. 如果单纯这么用, 其实是有一点我之前提到的性能问题的
mapStateToProps
参考了 React Context API and avoiding re-renders 这个问题下的一个回答. 其实核心就在于用 React.memo
以及将相关对应的 context
上的 value
map 到对应的消费者组件的 props
上, 相关 props
变了, 组件才重新渲染. 虽然感觉兜兜转转绕了半天又绕到了 Redux 上....
// editorContext.js
// ...
export const useEditorCursorState = () => {
const { cursorPosition } = useEditorState()
return {
cursorPosition
}
}
export const useEditorSelectionState = () => {
const { selections } = useEditorState()
return {
selections
}
}
export function connectToContext(WrappedComponent, select) {
return props => {
const selectors = select()
return <WrappedComponent {...selectors} {...props} />
}
}
结合我之前的 Editor
和 StatusBar
一起用:
// App.js
import Editor from './Editor'
import StatusBar from './StatusBar'
export default class App {
constructor(props) {
super(props) {
this.editorRef = React.createRef()
}
}
render() {
return (
// ... other components
<EditorProvider>
<div>
<Explorer />
<Editor
editorRef={this.editorRef}
/>
</div>
<StatusBar />
</EditorProvider>
)
}
}
// Editor.js
import React from 'react'
import {
useEditorDispatch,
setCursorPosition,
setSelections
} from './editorContext'
import MonacoEditor from 'some-thirdparty-react-monaco-package'
const Editor = React.memo((props) => {
const { setCursorPosition, editorRef } = props
const editorDispatch = useEditorDispatch()
const handleEditorDidMount = () => {
editorRef.current.onDidChangeCursorPosition(ev => {
useEditorDispatch(setCursorPosition(ev.position))
})
editorRef.current.onDidChangeCursorSelection(ev => {
useEditorDispatch(setSelections(ev.selections))
})
}
return (
<MonacoEditor editorDidMount={handleEditorDidMount} />
)
})
export default Editor
// StatusBar
import React from 'react'
import Button from '@material-ui/core/Button';
import { connectToContext, useEditorCursorState } from './editorContext'
const StatusBar = React.memo((props) => {
const { cursorPosition } = props
return (
<Button size="small">
{cursorPosition}
</Button>
)
})
export default connectToContext(StatusBar, useEditorCursorState)
如果你有更优雅的解法, 请一定告诉我...