体验concent依赖收集,赋予react更多想象空间

3,497 阅读13分钟

concent v2版本的发布了,在保留了和v1一模一样的api使用方式上,内置了依赖收集系统,支持同时从状态计算结果副作用3个维度收集依赖,建立其精确更新路径,当然也保留了v1的依赖标记特性,支持用户按需选择是让系统自动收集依赖还是人工管理依赖,大多数场景,推荐使用自动收集依赖,除非非常在意渲染期间自动收集和更新依赖的那一点微弱的额外计算以及非常清楚自己组件对状态的依赖关系,那么可以降级为人工标记依赖,当然了,如果是v1版本,那就没得选了,只能是人工标记了。

组件编程体验统一

在正式了解依赖收集之前,我们先会细聊一下组件编程体验统一这个话题,本质来说concent并没有刻意的要统一类组件和函数组件的编码方式,只是基于为组件实例注入标记了元数据的实例上下文ref ctx的核心运行机制,随着迭代的进行,发现了组件的形态已不再那么重要,它们表达的都是react vdom,并最终会被react-dom转换成的真实的html dom渲染到浏览器窗口里,react开发者针对hook也说过,hook并没有改变react的本质,只是换了一种编码方式书写组件而已,包括状态的定义和生命周期的定义,都可以在类组件和函数组件的不同表达代码里一一映射。

定义初始状态

class ClassComp extends React.Component{
    constructor(props, context){
        super(props, context);
        this.state = {tag:props.tag, name:''}
    }
}

function FnComp(props){
    const propsTag = props.tag;
    const [tag, setTag] = useState(propsTag);
    const [name, setName] = useState('');
}

初次挂载

class ClassComp extends React.Component{
    componentDidMount(){
        //组件初次挂载触发
    }
}

function FnComp(){
    React.useEffect(()=>{
         //组件初次挂载触发
    }, [])
}

存在期渲染完毕

class ClassComp extends React.Component{
    componentDidUpdate(){
        //组件存在期渲染完毕触发
    }
}

function FnComp(){
    const efFlag = React.useRef(0);
    React.useEffect(()=>{
        efFlag.current++;
        if(efFlag.current>1){
            //组件存在期渲染完毕触发
        }
    })
}

组件卸载前

class ClassComp extends React.Component{
    componentWillUnmount(){
        //组件卸载前触发
    }
}

function FnComp(){
    React.useEffect(()=>{
        return ()=>{
            //组件卸载前触发
        }
    }, [])
}

存在期组件收到新的属性

class SomePage extends Component{
    static getDerivedStateFromProps (props, state) {
        if (props.tag !== state.tag) return {tag: props.tag}
        return null
    }
}

function FnComp(props){
    const propsTag = props.tag;
    const [tag, setTag] = useState(propsTag);
    
    React.useEffect(()=>{
        // 首次渲染时,此副作用还是会执行的,在内部巧妙的再比较一次,避免一次多余的ui更新
        // 等价于上面组件类里getDerivedStateFromProps里的逻辑
        if(tag !== propsTag)setTag(tag);
    }, [propsTag, tag]);
}

编程统一实战

既然他们本质上只是表达方式的不同,concent通过setup只在组件初次渲染前执行一次的特性,开辟另一个空间,完美和谐的统一他们的表达方式,并且还顺带额外提供其他可选的特性给开发者使用。

这里提前申明一下,下面的代码演示setup特性以及相关生命周期统一的函数是都是可选的,并非一定要这样编码才能接入concent,你依然可以按照最传统的方式组织代码,使用setState就可以了

以下举一个实战例子:

const api = {
  async fetchProducts() {
    return {
      products: [
        {name:'name_'+Math.random(), author:'zzk_invoke'},
        {name:'name_'+Math.random(), author:'concent_invoke'},
      ]
    };
  }
};

export const setup = ctx => {
  //初始化props.tag到state里,initState会自动做合并
  ctx.initState({ tag: ctx.props.tag });

  const fetchProducts = () => {
    const { type, sex, addr, keyword } = ctx.state;
    api.fetchProducts({ type, sex, addr, keyword })
      .then(({products}) => ctx.setState({ products }))
      .catch(err => alert(err.message));
  };

  ctx.effect(() => {
    fetchProducts();
  }, ["type", "sex", "addr", "keyword"]);
  /** 原函数组件内写法:
    useEffect(() => {
      fetchProducts(type, sex, addr, keyword);
    }, [type, sex, addr, keyword]);
  */

  ctx.effect(() => {
    return () => {
      // 返回一个清理函数
      // 等价于componentWillUnmout, 这里搞清理事情
    };
  }, []);
  /** 原函数组件内写法:
    useEffect(()=>{
      return ()=>{// 返回一个清理函数
        // 等价于componentWillUnmout, 这里搞清理事情
      }
    }, []);//第二位参数传空数组,次副作用只在初次渲染完毕后执行一次
  */

  ctx.effectProps(() => {
    // 对props上的变更书写副作用
    const curTag = ctx.props.tag;
    if (curTag !== ctx.prevProps.tag) ctx.setState({ tag: curTag });
  }, ["tag"]);
  /** 原函数组件内写法:
  useEffect(()=>{
    // 首次渲染时,此副作用还是会执行的,在内部巧妙的再比较一次,避免一次多余的ui更新
    // 等价于上面组件类里getDerivedStateFromProps里的逻辑
    if(tag !== propTag)setTag(tag);
  }, [propTag, tag]);
 */

  return {
    // 返回结果收集在ctx.settings里
    fetchProducts,
    fetchByInfoke: () => ctx.invoke(api.fetchProducts),
    //推荐使用此方式,把方法定义在settings里
    changeType: ctx.sync("type")
  };
};

定义一个初始化状态函数

export const iState = () => ({
  products: [],
  type: "",
  sex: "",
  addr: "",
  keyword: "",
  tag: ""
});

现在我们来看看组件长什么样子吧

  • 函数组件
import { useConcent } from 'concent';

const ConcentFnPage = React.memo(function(props) {
  // useConcent返回ctx,这里直接解构ctx,拿想用的对象或方法
  const { state, settings, sync } = useConcent({ setup, state: iState, props });
  // 渲染需要的数据
  const { products, type, sex, addr, keyword, tag } = state;
  // 装配好的方法
  const { fetchProducts, fetchByInfoke } = settings;

  return <div>... your ui ... </div>
});
  • 类组件
import { register } from 'concent';

@register({ setup, module:'product' })
class ConcentFnModuleClass extends React.Component{
  render(){
    const { state, settings, sync } = this.ctx;
    const { products, type, sex, addr, keyword, tag } = state;
    const { fetchProducts, fetchByInfoke } = settings;
  
    return <div>... your ui ... </div>
  }
}

点我查看上述示例

带来的额外优势

通过观察发现,是不是长得一模一样呢?唯一不同的是实例上下文在类组件里通过this.ctx获得,在函数组件里通过useConcent返回,而且setup相比传统的函数组件带来了几大优势

  • 方法都一次性装配在settings里返回给用户使用,没有了每一轮渲染都生成临时闭包函数的多余消耗以及其他值捕获陷阱、useCallback进一步封装等问题。
  • 依赖列表都传递key名称就够了,concent自动维护着一个上一刻状态和当前状态的引用,同构浅比较直接决定要不要触发副作用函数

下面一个示例演示闭包陷阱和使用setup后如何避免此问题,且复用在类与函数组件之间

// 这是一个普通的函数组件
function NormalDemo() {
  const [count, setCount] = useState(0);
  const dom = useRef(null);
  useEffect(() => {
    const cur = dom.current;
    const add = () => setCount(count + 1);
    cur.addEventListener("click", add);

    return () => cur.removeEventListener("click", add);
  }, [count]);//需要显示传递count值作为依赖
  return <div ref={dom}>normal {count}</div>;
}

//定义一个setup函数
const setup = ctx => {
  const addCount = () => {
    const count = ctx.state.count;
    ctx.setState({ count: count + 1 });
  };

  // 因为锁住了count在ctx.state里,这里不需要重复绑定和去绑定click事件了
  ctx.effect(() => {
    const cur = ctx.refs.dom.current;
    cur.addEventListener("click", addCount);
    return () => cur.removeEventListener("click", addCount);
  }, []);

  return { addCount };
};

function ConcentFnDemo() {
  const { useRef, state, settings } = useConcent({
    setup,
    state: { count: 0 }
  });
  return (
    <div>
      <div ref={useRef("dom")}>click me fn {state.count}</div>
      <button onClick={settings.addCount}>add</button>
    </div>
  );
}

// or @register({setup, state:{count:0}})
@register({ setup })
class ConcentClassDemo extends React.Component {
  state = { count: 0 };
  render() {
    const { useRef, state, settings } = this.ctx;
    // this.ctx.state === this.state
    return (
      <div>
        <div ref={useRef("dom")}>click me class {state.count}</div>
        <button onClick={settings.addCount}>add</button>
      </div>
    );
  }
}

点我查看上述示例

通过上面的示例代码我们发现,协调类组件和函数组件的共享和复用业务逻辑的方式是如此的简单与轻松,但这并不是必需的,你依然可以像传统方式一样为类组件和函数组件组织代码,不过仅仅是多了一种更棒的方式提供给你罢了。

依赖收集,助力精确更新

我们已提到依赖收集,首先会想到vue框架,依赖收集作为其核心驱动视图精确的原理,让不少react需要人工维护shouldComponentUpdateuseCallback等额外api才能写出性能更好的react代码眼馋,不管是vue2definePropertyvue3proxy,本质上都能隐式的收集视图对数据的依赖关系来做到精确更新。

那么concent又怎样来实现依赖收集呢?还是离不开我们提到的实例上下文,它将作为我们收集到依赖的重要媒介,来帮助我们毫无违和感的书写具有依赖收集的react代码。

为什么说毫无违和感?因为你书写的代码和原始react代码并没有区别,依然保持react的味道。

普通的Concent组件

我们定义一个普通的Concent组件

run();

const iState = ()=>({firstName:'Jim', lastName:'Green'});
function NormalPerson(){
    const { state, sync } = useConcent({state:iState});
    return (
        <div className="box">
            <input value={state.firstName} onChange={sync('firstName')} />
            <input value={state.lastName} onChange={sync('lastName')} />
        </div>
    );
}

如果我们渲染这两个组件的话,它们的状态是各自独立的

export default function App() {
  return (
    <div className="App">
      <NormalPerson />
      <NormalPerson />
    </div>
  );
}

共享状态的Concent组件

我们提升一下状态,让所有示例共享 定义一个模块名为login

const iState = ()=>({firstName:'Jim', lastName:'Green'});

run(
  {
    login: {// 定义login模块
      state: iState, // 传递状态初始化函数,当然了这里也可以传对象
    }
  }
);

然后指定组件属于login模块

function SharedPerson(){
    const { state, sync } = useConcent('login');
    return (
        <div className="box">
            <input value={state.firstName} onChange={sync('firstName')} />
            <input value={state.lastName} onChange={sync('lastName')} />
        </div>
    );
}

渲染它们看看效果吧

点我查看此在线示例

是不是提升状态是从没有感觉过如此轻松惬意,无Provider包裹根组件,仅仅只是标记模块,就完成了状态提升和共享,示例是为了方便使用sync,如果我们更传统一点,应该是这样的

const { state, setState } = useConcent('login');
const changeFirstName = (e)=> setState({firstName: e.target.value})

<input value={state.firstName} onChange={changeFirstName} />

当然对于类组件也是一样的,并没有任何改变你认知的react组件形态

@register('login')
class SharedPersonC extends React.Component{
    changeFirstName = (e)=> this.setState({firstName: e.target.value})
    render(){
        const { state, sync } = this.ctx;
        return (
            <div className="box">
                <input value={state.firstName} onChange={this.changeFirstName} />
                <input value={state.lastName} onChange={sync('lastName')} />
            </div>
        );
    }
}

事实上this.state上可以定义额外的key作为私有状态

@register('login')
class SharedPersonC extends React.Component{
    // 因为privKey并不是模块里的key,所以这个key的状态变更仅影响当前实例,
    // 并不会派发到其他同属于login模块的实例
    state = {privKey:'key1'}
    render(){
        // this.state
        // {firstName:'', lastName:'', privKey:'key1'}
    }
}

如果我们不喜欢共享状态状态合并到this.state,那就使用connect就好了,connect支持传递数组意味着可以跨多个模块消费共享数据。

function SharedPerson(){
    const { connectedState, sync } = useConcent({connect:['login']});
    // connectedState.login.firstName
}

@register({connect:['login']})
class SharedPersonC extends React.Component{
    render(){
        const { connectedState, sync } = this.ctx;
        // connectedState.login.firstName
    }
}

探索状态依赖收集

铺垫了这么久,我们说的依赖收集在哪里,体现在何处,不要慌,我们给组件加个开关,控制firstNamelastName是否显示

const spState = () => ({ showF: true, showL: true });
function SharedPerson() {
  const { state, sync, syncBool } = useConcent({
    module: "login",
    state: spState
  });
  return (
    <div className="box">
      {state.showF ? (
        <input value={state.firstName} onChange={sync("firstName")} />
      ) : (
        ""
      )}
      {state.showL ? (
        <input value={state.lastName} onChange={sync("lastName")} />
      ) : (
        ""
      )}
      <br />
      <button onClick={syncBool('showF')}>toggle showF</button>
      <button onClick={syncBool('showL')}>toggle showL</button>
    </div>
  );
}

如果我们实例化2个实例,将第一个showF值置为false,意味着视图里不再有读取state.firstName的行为,那么当前组件的依赖列表里仅有lastName一个字段了,我们在另一个组件实例里对lastName输入新内容时,会触发第一个实例渲染,但是对firstName输入新内容时不应该触发第一个实例渲染,现在我们看看效果吧。

点我查看此在线示例

当然了用户一定会有一个疑问,实例1不触发更新,那么当我需要用这个firstName时,是不是已经过期了,的确,如果你切换实例1的showF为true,stata.firstName会拿到最新的值渲染,但是如果你不切换,而是直接点击实例1的某个按钮直接用firstName作业务逻辑处理的话,从state.firstName取到的的确是旧值,你只需从ctx.moduleState上去取就解决了,取到的值一定是最新值,因为所有属于login模块的实例的moduleState指向的是同一个对象,当然就不存在值过期的问题,当然你可以一开始在视图里使用模块数据时,就从moduleState里取(一样能收集到依赖),而不是从合并后的state上取,就不会造成渲染逻辑从state取而业务逻辑从moduleState里取同一个值的违和感了。

探索计算依赖收集

我们知道concent是支持定义计算函数的,分为实例级别的计算和模块级别的计算,我们一个个来说

  • 定义实例计算

首先我们通过setup一次性定义好实例计算函数,然后交给useConcent

const setup = ctx=>{
  ctx.computed('fullName', (newState, oldState)=>{
    return `${newState.firstName}_${newState.lastName}`;
  })
}

const spState = () => ({ showF: true, showL: true });
function SharedPerson() {
  // 从refComputed取实例计算结果
  const { state, sync, ccUniqueKey, syncBool, refComputed } = useConcent({
    module: "login",
    state: spState,
    setup,
  });
  console.log(`%c${ccUniqueKey}`, "color:green");
  return (
    <div className="box">
      {state.showF ? (
        <input value={state.firstName} onChange={sync("firstName")} />
      ) : (
        ""
      )}
      {state.showL ? (
        <input value={state.lastName} onChange={sync("lastName")} />
      ) : (
        ""
      )}
      <br />
      {/** 此处渲染实例计算结果 */}
      fullName: {refComputed.fullName}
      <br />
      <button onClick={syncBool('showF')}>toggle showF</button>
      <button onClick={syncBool('showL')}>toggle showL</button>
    </div>
  );
}

接下来我们要说此处有趣的事了,我们依然渲染两个实例,当我们点击第一个实例toggle showF按钮设置showF为false,但是注意哦,实例1的读取了refComputed.fullName,而这个值是通过${newState.firstName}_${newState.lastName}计算出来的数据,所以尽管视图不显示firstName了,但是当前实例的依赖列表依然为firstName, lastName,所以我们在实例2里输入firstName,依然能触发实例1渲染

点我查看此在线示例

  • 定义模块计算
    我们发现两个实例对同样的模块状态计算输出是一样的,所以显然每个实例都来一次计算就造成了浪费,更好的处理是将其提升到模块里,这样只用算一次,然后让所有实例共享
run({
  login: {
    // 定义login模块
    state: iState, // 传递状态初始化函数,当然了这里也可以传对象
    computed:{
      fullName(newState, oldState){
        return `${newState.firstName}_${newState.lastName}`;
      }
    }
  }
});

现在我们的组件代码仅需将refComputed.fullName改为moduleComputed.fullName即可

  // const { state, sync, ccUniqueKey, syncBool, refComputed } = useConcent({
  // 改为从moduleComputed取实例计算结果
  const { state, sync, ccUniqueKey, syncBool, moduleComputed } = useConcent({
    module: "login",
    state: spState,
    setup,
  });

让我们看看效果吧

点我查看此在线示例

探索副作用依赖收集

还记得开文里我们说组件编程体验统一里提到的ctx.effect吗,埋了这么久的伏笔,在这里终于要排上用场了,ctx.effect的执行时机是组件渲染完毕,检查依赖列表里是否有变化从而决定是否要触发副作用函数。

在这里我们简单定义一个副作用即firstName发生变化时打印一句话。

const setup2 = ctx=>{
  ctx.effect(()=>{
    console.log('firstName changed');
  }, ['firstName']);
}

嘿嘿,接下来我们声明一个空组件并将其传给它

function EmptyPerson() {
  console.log('render EmptyPerson');
  useConcent({module:'login', setup:setup2});
  return <h1>EmptyPerson</h1>
}

这个组件仅仅是标记它属于login模块,但是我们并没有读取任何模块状态用于渲染,只不过在setup里定义了一个副作用,依赖列表里有firstName,所以当我们把EmptyPersonSharedPerson放一起实例化后,当我们在SharedPerson实例里输入firstName新内容时,会触发EmptyPerson渲染并触发它的副作用函数。

点我查看在线示例

结语

随着不再考虑古老的浏览器支持,拥抱es6新特性后,v2的concent已携带一整套完整的方案,能够渐进式的开发react组件,即不干扰react本身的开发哲学和组件形态,同时也能够获得巨大的性能收益,这意味着我们可以至下而上的增量式的迭代,状态模块的划分,派生数据的管理,事件模型的分类,业务代码的分隔都可以逐步在开发过程勾勒和剥离出来,其过程是丝滑柔顺的,也允许我们至上而下统筹式的开发,一开始吧所有的领域模型和业务模块抽象的清清楚楚,同时在迭代过程中也能非常快速的灵活调整而影响整个项目架构,期望读到此文的你能够了解到concent在依赖收集到所做的努力并有兴趣开始了解和试用。

彩蛋

某一个夜晚,我做了个梦,发现基于现有的concent运行机制,加以适当的约束,好像可以让reactvue之间相互转译似乎有那么一点点可能.....

❤ star me if you like concent ^_^

Edit on CodeSandbox

https://codesandbox.io/s/concent-guide-xvcej

Edit on StackBlitz

https://stackblitz.com/edit/cc-multi-ways-to-wirte-code

如果有关于concent的疑问,可以扫码加群咨询,会尽力答疑解惑,帮助你了解更多。