frejs / fre

:ghost: Tiny Concurrent UI library with Fiber.
https://fre.deno.dev
MIT License
3.71k stars 349 forks source link

Alternative ideas for a V2 #106

Closed mindplay-dk closed 4 years ago

mindplay-dk commented 4 years ago

Your V2 proposal (#35) tries to solve a very common performance problem with React and JSX in general - a problem that I've also been trying to solve in different ways for a while.

It made me think again about some ideas I've wanted to share with you for a while - I haven't done so, because it's probably a somewhat radical departure from React-likes.

There are two problems, really - let me start with the simplest problem.

Problem with hooks

The concept of hooks is partially responsible for some of the performance problems - mainly, hooks (by design) ignore the fact that components actually have at least a two-step life-cycle (initialization and rendering) and often a three-step life-cycle. (destruction.)

That is, things like initialization of state gets crammed into the render function, and get executed every time - for example:

function Component() {
  const [state, setState] = useState([1,2,3,4,5,6,7,8,9,10,11,12,13,14,15]);
  return (
    // ...
  );
}

Because state initialization is crammed into a single function, that function will construct a 15-element array at every render - but it won't actually use it after the first render, it just gets ignored and thrown away.

This can be quite weird and surprising - if you're used to reading JavaScript, and you're just learning about hooks, this is "magic" - everything we've been taught not to do, with a design that completely relies on side-effects.

Do you know ivi?

Without going too much into detail, ivi enables the same kind of functional composition as React hooks, without relying on magic - the core difference between ivi and React components is the component signature:

               (props) => VDOM      <- React
(component) => (props) => VDOM      <- ivi

As you can see, ivi adds an initialization function wrapping the render-function itself. This is not only more efficient, it's also easier to understand - the component instance isn't a hidden internal detail, the render function doesn't rely on side-effects and doesn't ignore (or repeat) state (or effect) initializations after the first render. It works just like regular JS - no surprises, and you don't need to learn any rules of hooks or use a linter to tell you about all the pitfalls and exemptions from regular JS.

This has other benefits, like avoiding closure regeneration and unnecessary caching/updates/removals of event-handlers - your const onClick=() => {} function evaluates only once during the initialization phase, and remains constant during updates, meaning the reconciler can tell that lastHandler === newHandler when diffing during updates and avoid extra calls to removeEventHandler and addEventHandler just to reattach a new instance of the exact same function.

I did manage to implement a prototype of a similar pattern in React - look at the examples in the second half of the code, you can see examples of most of what I just explained here.

Hooks are great, because they allow composition of behavior - but I think we should be able to achieve that without resorting to magic, sacrificing on performance, or requiring a linter or documentation to teach users about caveats that don't apply to normal JS.

Adding a wrapper function to the API creates a convenient and natural scope for the initialization of the component instance, which I feel is very much lacking with React hooks.

On to the second problem.

Problem with control of render boundaries

You highlighted this problem in your V2 proposal: render boundaries in React (and inherently with JSX in general) are strictly equal to component boundaries - Babel's React transformation doesn't enable an engine to implement (for example) memoization of individual nodes, as these are just expressions, and there's no way to know which expressions depend on specific properties of the props and state objects. (Side note: the JSX spec does not prescribe what the compiled output should look like - the normal transformation of JSX to JS is defined by React itself, not by the JSX standard. I'll return to this point later.)

This is particularly problematic if you use e.g. Redux or context and useReducer as shown in this article and many others recently - what they largely ignore, is the fact that, if your root component depends on the application state, then any update affecting that state will update the entire UI. For example, a single character being typed into an input on a form, might well cause an update of the entire UI.

And then, yes, of course there are ways to optimize with shouldComponentUpdate or useMemo, etc. - but the frameworks default to poor performance in these scenarios: making a seemingly insignificant change to a large program can have surprising and drastic performance implications.

Frameworks such as Svelte (and Marko, Imba, probably others) get around this problem by inventing new syntax and using a compiler and static analysis to learn, ahead of run-time, which names (from props/state) are required to update each individual node in the tree: render boundaries aren't equal to component boundaries; updates can happen anywhere in the DOM when the dependencies of expressions used in specific nodes actually change. It can differentiate nodes that can change from nodes that can't - so nodes with no expressions never need to update, and so on.

The idea of compiling React components ahead of time was even discussed here, and I think someone even implemented a prototype of a compiler, though I couldn't find that link.

I attempted to solve this problem myself with this state container prototype, which uses extra component instances to establish render boundaries - try clicking around, and watch the red render counters... as you can see, the root application never updates, only the exact nodes affected by a specific state change get updated, so updates can be as small as a value on a single input or a single text-node.

This is implemented by maintaining subscriptions at every possible render-boundary, which isn't very efficient - it requires a lot of overhead from subscription management and the extra component instance at every possible render-boundary. Obviously, what e.g. Svelte does is much more efficient and much more user-friendly, and this was just a proof-of-concept attempting to solve the problem with JSX at run-time.

What am I getting at? šŸ˜

I'd really like to see a framework that solves both of these problems without inventing another custom syntax.

As I mentioned earlier, the JSX specification does not in fact prescribe what the compiled output of a JSX expression should look like - just that we're used to the standard React transformation where the output is a single expression with inline JS expressions, which inherently get evaluated when the JSX expression itself gets evaluated.

What if we create a different JSX transformation?

One that:

So for example:

/** @jsx h */

import { h, createComponent, render } from "fre";

const App = createComponent(component => {
  let list = [1,2,3];
  let count = 0;

  function setCount(value) {
    count = value;
  }

  component.useEffect(() => {
    console.log("effect");

    return () => console.log("clean up");
  });

  return (
    <div>
      <ul>
        {list.map(item => (
          <li>{item}</li>
        ))}
      </ul>
      <h1>{count}</h1>
      <button onClick={()=>setCount(count+1)}>add count</button>
    </div>
  );
});

render(<App/>, document.body);

With the React JSX tranform, this example is meaningless - the compiled output wouldn't work. For example, setting count = value would have no effect.

I don't have a complete idea yet, but imagine a different JSX transform - one that sees createComponent being imported from fre, and transforms the AST of the function being passed in a call to that function.

This would be able to see that e.g. {count} refers to a variable in the parent scope, and therefore emits more than just the literal JS - for example:

I don't know yet precisely what the compiled JSX nodes would look like, but likely these would be functions of some sort as well, and the emitted containers and node update functions would depend on each other somehow.

I know this is very long and a very loose idea - and possibly too far removed from current fre to even make sense as a version 2 - but what do you think?

I have seen talk of compiling JSX, but I haven't seen anyone doing it yet? šŸ¤”

yisar commented 4 years ago

First of all, thank you for the two proposals you mentioned. I have read them carefully. Next, let me talk about them:

New hooks API

In fact, the hooks API today does have the syntactic limitations you mentioned

I have seen the implementation of ivi and can be sure that we can have a way to implement a better API.

Do you know vue-composition-api?

The same with ivi, they all return an closure reder function, and the component initialize only once.

So we can reach a consensus on this point.

JSX+ compiler

I have seen talk of compiling JSX, but I haven't seen anyone doing it yet

In fact, in my country, China, many people try to add compiler for JSX, which we called jsx-plus

such as, https://rax.js.org/docs/guide/jsxplus

Adding compilers for JSX or introducing compilers into fre may be a new breakthrough, but I don't think we can introduce the whole AOT like svelte. We still need fre core runtime.

But fortunately, I'm really interested in the direction of svelte, but I didn't think about introducing compilers into fre before this issue.

So maybe we can develop a JSX + specification in V2?

What I'm worried about is that if our specifications can't be widely recognized, itmay become even use for minority, such as Ember Lang.

mindplay-dk commented 4 years ago

I have seen the implementation of ivi and can be sure that we can have a way to implement a better API.

Do you know vue-composition-api?

I've seen this, yeah - someone is implementing this as an optional feature in Preact as well.

It seems a lot of people want to go this way - I don't know why the React team didn't listen, because this was probably the most-debated and most-upvoted topics in their early hooks RFC.

I think this is definitely a direction worth exploring.

I have seen talk of compiling JSX, but I haven't seen anyone doing it yet

In fact, in my country, China, many people try to add compiler for JSX, which we called jsx-plus

Without being able to read the descriptions, from the examples, this looks very different from what I'm suggesting - this is an extension to JSX. What I'm suggesting is more like an alternative meaning for JSX syntax, but without changing the JSX syntax itself.

Adding compilers for JSX or introducing compilers into fre may be a new breakthrough, but I don't think we can introduce the whole AOT like svelte. We still need fre core runtime.

Agreed, that's not the direction I'm trying to suggest.

The traditional React-style JSX -> JS compiler (as supported by the Babel extension and TypeScript) has many uses - it supports different engines with different concepts.

What I'm trying to describe is a different type of JSX -> JS compiler that produces more information and bindings ahead-of-time, e.g. function hooks that do require a core run-time, but these hooks and information would enable the run-time to perform updates at any sub-tree boundary, not only at component boundaries.

The compiler shouldn't be purpose-built for fre - it should be generic enough that others could potentially build their own run-times as they do with the React-style JSX -> JS compilers today.

This might be possible, for example, by emitting something more like an AST (with function literals for expressions) enabling a run-time to traverse the entire tree once at start-up to initialize components. As said, I don't know precisely what that would look like - this would require some exploration in itself. šŸ˜‰

yisar commented 4 years ago

What I'm suggesting is more like an alternative meaning for JSX syntax, but without changing the JSX syntax itself

I have looked at your code again. I think I know what you said. You mean that like svelte, you have JS native reactivity through compilation.

I need time to think about the prototype šŸ˜†. Maybe you have an idea here, general APIs?

I don't know why the React team didn't listen

I am also thinking about what kind of API I should implement, which is more suitable for fiber architecture.

mindplay-dk commented 4 years ago

I have looked at your code again. I think I know what you said. You mean that like svelte, you have JS native reactivity through compilation.

Yeah, this would enable you to do something that works similarly to Svelte, but without compiling for a specific (embedded) run-time like Svelte.

Svelte builds/embeds the entire run-time, whereas this would build a kind of minimal "specification" - so it's more like what the React transform does, but the compiled output wouldn't just be expressions that evaluate immediately, but rather hooks/functions that the run-time will invoke to implement reactivity.

Obviously this would be easier to explain if I could show you what that might look like, but I don't really know yet. šŸ˜Š

fantasticsoul commented 4 years ago

hi, my friend, my name is fantasticsoul, I am the author of Concent, the problem you point out above: things like initialization of state gets crammed into the render function, and get executed every time, has been solved perfectly by Concent's setup feature.

here is online demo: js ver https://codesandbox.io/s/concent-guide-xvcej ts ver https://codesandbox.io/s/concent-guide-ts-zrxd5

let us open our imagination, we can treat hooks as a special portal in react, it offer us amazing features like define state, define effect and etc.

So Concent use hook ability to create setup feature, now you can define component like this:

import { registerHookComp, useConcent } from "concent";

const iState = ()=> ({
  visible: false,
  activeKeys: [],
  name: '',
});

// setup will only been executed before component instance first rendering
const setup = ctx => {
  ctx.on("openMenu", (eventParam) => { /** code here */ });
  ctx.computed("visible", (newVal, oldVal) => { /** code here */ });
  ctx.watch("visible", (newVal, oldVal) => { /** code here */ });
  ctx.effect( () => { 
     /** code here */ 
     return ()=>console.log('clean up');
   }, []);
   // if visible or name changed, this effect callback will been triggered!
   ctx.effect( () => { /** code here */ }, ['visible', 'name']);
   ctx.effect( () => { /** will been triggered in every render period */ });
   // second param[depStateKeys] pass null means effect cb will been executed after every render
   // third param[immediate] pass false means let Concent ignore it after first render
   ctx.effect( () => { /** mock componentDidUpdate */ }, null, false);

  const doFoo = param =>  ctx.dispatch('doFoo', param);
  const doBar = param =>  ctx.dispatch('doBar', param);
  const emitSomething =() =>  ctx.emit('emitSomething', param);
  const syncName = ctx.sync('name');

  return { doFoo, doBar, syncName, emitSomething };
};

const render = ctx => {
  const {state, settings} = ctx;

  return (
    <div className="ccMenu">
      <input value={state.name} onChange={settings.syncName} />
      <button onClick={settings.doFoo}>doFoo</button>
      <button onClick={settings.doBar}>doBar</button>
    </div>
  );
};

export default registerHookComp({
  state: iState, 
  setup,  
  module:'foo',
  render
});

// this export is equal as code below:
export React.memo(function(props){
  const ctx = useConcent({
      state: iState, 
      setup,  
      module:'foo',
  });

  const {state, settings} = ctx;
  // return your ui
})

to know more details you can check the online demo, with setup:

fantasticsoul commented 4 years ago

sorry for disturbing your discussion , I just want to let you know a cute idea.....

yisar commented 4 years ago

Here I want to make a conclusion about V2

  1. JSX compiler. I have studied how to compile JSX into DOM expressions without vdom, this way is simplar with svelte.

But I don't think this applies to fre, because fre currently relies on vdom to achieve about 1KB, DOM expression will compile more codes.

  1. extact updating and Composition API

In the past, I wanted to build in memo to make the component update exactly, but it seems that the shallow comparison of props is not a good way.

Now I know how Vue does this, because its props are reactively, not shallow comparisons.

To be able to use similar state updates in react, I wrote another library: https://github.com/yisar/doux

In conclusion, V2 still hasn't changed much, but I think the combination of the above two points is a good way. Maybe the new framework will use them.

yisar commented 4 years ago

Fre2 is coming, and it has a new feature: recoverable exception, which is almost the same as fre1.

I still don't give up the source syntx of react. I will spend more time optimizing performance and code in the future.