The problem background

There is a page layout structure with a menu on the left and a long page on the right that you can scroll down. Click the left menu, then the right page will automatically scroll to the corresponding module position, similarly, the right page scroll to the left menu will be highlighted.

I look at this long page and show it all at once? Can’t it be paginated? So he told the product that it might not be good to show so many features at once, but the product didn’t care and he wanted it. In line with the purpose of improving myself, I decided not to negotiate with the product, and I will try to optimize the performance problems when the time comes.

A common optimization for long page scrolling is dynamic loading, but this idea was abandoned in consideration of the two-way linkage between menu and module content.

And then why do I use context to pass data to these components. Because there are many modules, a smallest module is divided into a component, the component probably has to write dozens. And, importantly, because it’s a page, the back end decided to give me the data directly using a socket. There is no way for me to tune the data within each module, so I have to fetch it from the top and pass it to the bottom components.

Then why not use redux? Because lazy, HHHH.

Context is easy to use, just inject the data down through the Provider.

And then the problem is that the socket pushes dozens of times, because there are dozens of modules, and every time I push I have to update the value of the context to make sure that the underlying component gets the latest value. This update has a big problem.

Every update to the context causes the component using the context to trigger re-render, even if the component is wrapped in memo and the props are unchanged

Then each component will re-render dozens of times… WTF, as I scroll down the page, I can see the gridlock.

So why don’t I just not change the context? But how do you get the new value without modifying the context child? This is where the observer pattern comes in, and then we simply add a dependency, and when the dependency is updated, publish the message so that the subscriber, the child component, can receive the latest value and trigger the update.

Design a Hook using the observer pattern

Ideally, a subcomponent call to retrieve the context would look like this:

Context {value1: ", value2: "}
// Pass a dependency to the Hook. The update is triggered only if the dependency's property value1 is updated
const { value1 } = useModel(['value1']);
Copy the code

So the publisher that starts writing the message, the publisher has to be able to store the value of the context, and be able to read that value, including collecting subscriptions from subscribers. Each time the context value is changed, it is published to all subscribers, who then decide whether to update the component.

class Listenable {
    constructor(state) {
        this._listeners = [];
        this.value = state;
    }

    getValue() {
        return this.value;
    }

    setValue(value) {
        const previous = this.value;
        this.value = value;
        this.notifyListeners(this.value, previous);
    }

    addListener(listener) {
        this._listeners.push(listener);
    }

    removeListener(listener) {
        const index = this._listeners.indexOf(listener);
        if (index > -1) {
            this._listeners.splice(index, 1); }}hasListener() {
        return this._listeners.length > 0;
    }

    notifyListeners(current, previous) {
        if (!this.hasListener()) {
            return;
        }
        for (const listener of this._listeners) { listener(current, previous); }}}Copy the code

Then there is the subscriber, which is the Hook that the child component calls

import isEqual from 'lodash.isequal';

export default function useModel(context, deps = []) {
    const [state, setState] = useState(context.getValue());
    const stateRef = useRef(state);
    stateRef.current = state;
    
    const listener = useCallback((curr, pre) = > {
        // If there are dependencies, only the dependent part is judged
        let [current, previous] = getDepsData(curr, pre);

        if(isChange(current, previous)) { setState(current); }} []);// If state is changed by another component before the component adds a listener, then this is called to update the state value
    For example, if A component changes state in useEffect, B component and A are siblings, but B component is rendered later, both components use useModel to get state, and both components get their original state
    // Then the listener of component A is added. The useListener is added first, and the useEffect state is changed in component A
    // At this point, the state is changed, but component B already got the state, which is the old value, so it needs to be updated
    const onListen = useCallback(() = > {
        let [current, previous] = getDepsData(context.getValue(), stateRef.current);
        if (isChange(current, previous)) {
            listener(current, previous);
        }
    }, [context, listener]);

    useListener(context, listener, onListen);

    // Get before and after values based on dependencies
    const getDepsData = useCallback((current, previous) = > {
        if (deps.length) {
            let currentTmp = {};
            let previousTmp = {};

            deps.map(k= > {
                currentTmp[k] = current[k];
                previousTmp[k] = previous[k];
            });

            current = currentTmp;
            previous = previousTmp;
        }

        return[current, previous]; } []);// Compare changes
    const isChange = useCallback((current, previous) = > {
        if (current instanceof Object) {
            return! isEqual(current, previous); }return false; } []);const setContextValue = useCallback((v) = > {
        context.setValue(v);
    }, [context]);

    return [context.getValue(), setContextValue];
}
Copy the code

Final invocation

Parent component use
import Context from './context';
import { ShareState } from 'use-selected-context';

export default() = > {const [value] = useState(new ShareState({a: 0.b: 0}))
    
    // Modify the call value.setValue()
    const onClick = () = > {
        let o = value.getValue()
        let nd = {a: o.a, b: o.b + 1};
        value.setValue(nd);
    }
    
    return (
        <div>
            <Context.Provider value={value}>
                <Child />
                <Child2 />
                <Button onClick={onClick}>b+1</Button>
            </Context.Provider>
        </div>)}Copy the code
Child component call
import context from './context';
import useModel from 'use-selected-context'

export default() = > {const contextValue = useContext(context)
    // Pass in an array of dependency properties, only a will re-render the component
    // When no dependencies are passed in, the component is refreshed as other properties are updated
    const [v, setV] = useModel(contextValue, ['a']); 

    return (
        <div>The value of a is: {v.a}</div>)}Copy the code

The source code

use-selected-context