Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

简单聊一聊一个前端编辑器的性能优化 #15

Open
hacker0limbo opened this issue Nov 20, 2020 · 0 comments
Open

简单聊一聊一个前端编辑器的性能优化 #15

hacker0limbo opened this issue Nov 20, 2020 · 0 comments
Labels
react react 笔记整理 redux redux 笔记整理

Comments

@hacker0limbo
Copy link
Owner

hacker0limbo commented Nov 20, 2020

最近项目一直在使用 Monaco Editor 这个库. 在我加了一个新功能之后, 整个编辑器开始变的非常卡, 我试图解决这个性能问题. 但是发现有一些棘手...

评论以及文末有更新

场景

其实我加的新功能很简单, 以 vscode 为例, 他底部有一个 status bar, 用于显示当前编辑器的一些信息. 我当时加的功能是模仿 vscode, 在底部增加一个能显示当前光标位置/选中位置, 以及选中的单词的长度. 由于是公司代码的原因我这里只是简单放一下伪代码, 实际代码会比这个这个伪代码复杂和抽象很多

文件结构:

<App />
  // ... other components
  <div>
    <Explorer />
    <Editor />
  </div> 
  <StatusBar />

简单说明一下, 有一个组件叫 App, 由于历史遗留原因这二组件是个 class 组件, 而且代码很多, 这个组件下有非常多的子组件, EditorStatusBar 就是其中的两个子组件

而我现在的需求是: 用户在 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 这个大组件的重新渲染了.

但我还是很快放弃了这个想法, 因为把 StatusBarEditor 放一起其实不简单.... 从 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 相应改变 cursorPositionaction, 在 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 的时候存在一个问题: 就是如果 contextvalue 是一个对象这种复杂结构, 然后存在多个消费者, 每个消费者可能只是订阅一部分 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 状态有点复杂, 而且 cursorPositionselections 是对应两个不同的消费者组件. 如果单纯这么用, 其实是有一点我之前提到的性能问题的

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} />
  }
}

结合我之前的 EditorStatusBar 一起用:

// 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)

如果你有更优雅的解法, 请一定告诉我...

@hacker0limbo hacker0limbo added redux redux 笔记整理 react react 笔记整理 labels Nov 20, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
react react 笔记整理 redux redux 笔记整理
Projects
None yet
Development

No branches or pull requests

1 participant