Skip to content

Latest commit

 

History

History
172 lines (108 loc) · 13.5 KB

README.md

File metadata and controls

172 lines (108 loc) · 13.5 KB

Meact.js

Meact.js - My [Custom Implementation of] React.js

I developed Meact.js as a highly stimulating and educational project, where I built a core "Render Tree" and a DIFF reconciliation algorithm inspired by React with APIs for most commonly used hooks. This includes a client-side renderer similar to "react-dom."

Additionally, I have created a ["Multi-Page Application" (MPA) framework]((./meact-framework/README.md) atop the "meact" and "meact-dom" libraries. This framework uses server-side routing, with a page-based routing pattern inspired by Next.js and a server-side loader/action pattern borrowed from Remix.js. The setup leverages Esbuild for preparing tree-shaken and code-split JS/CSS bundles at build time, while Express.js dynamically serves these pages by injecting the appropriate bundles into "index.html" and running loaders through middleware.

See the HackerNews Clone app built using this framework to test and prove its production readiness

Implementation

  • Functional Components (i.e. reusable UI elements) built with "Template Literals" and global State & Event Handler Managers

  • React-like (createElement and render) API

  • Declarative UI Programming using library's functions

  • Render Tree (of MeactElement objects as nodes)

    • Render Tree is plotted in browser as well for debugging
  • Event Handlers

  • props

  • Children Elements & Components (and children as render props)

  • useState (I needed closure!)

  • DIFF Reconciliation during Re-rendering

  • useEffect

  • useRef

  • Custom Hooks

  • useMemo

    • useCallback (redundant after useMemo)
  • memo

  • createContext and useContext (! Scope for improvement)

  • JSX Syntax

  • Esbuild Bundler (to manually compile JSX into createElement calls)

  • JSX Fragment

  • /jsx-runtime Entrypoint API

  • MDX Support (using MDXjs compiler)

  • Virtual DOM Nodes (i.e. DocumentFragment nodes)

  • Multi-Page Application (i.e. Server-side Routing)

  • Page-based Router

  • Client-Side Rendering (CSR)

  • Tree Shaking (by routes)

  • Code splitting (chunks shared between routes)

  • Meta Tags (Server-side Hydration)

  • Server-side Loader and useLoaderData Hook Pattern (like Remix.js) which prepares data needed by page (and its components) at request time and sends it alongside HTML content thus saving a round-trip

  • Server-side Action and useActionData Hook Pattern (like Remix.js) using custom FormAction component and useMutation hook see "Submit Page" implementation

  • Framework's Abstraction

Constraints

  • A component must be defined as a named function only, and not as an anonymous function.

    • Why is this okay?: Because anonymous functions as component definitions don't bring any benefits in ergonomics, while an anonymous component function is bound to be recreated on every render (due to a new node ID with the render tree each time) and it could be harder to debug in stack traces or logs as well.
  • app/index.js should default export component that should be rendered for a URL/path, and each page component (inside app/pages/*.js) should also default export their corresponding function definitions.

Optimizations

  • DIFF Reconciliation: Reconciliation is the algorithm behind what is popularly understood as the "virtual DOM." When the application is rendered, a tree of nodes (i.e. the "render tree") that describes the app is generated and saved in memory. The renderer translates the render tree into a set of DOM operations. It's designed such that reconciliation and rendering are separate phases where the reconciler does the work of computing which parts of the tree have changed and the renderer then uses this information about the minimum set of changes required to update the actual DOM so as to update the rendered app. (This is why "virtual DOM" is a bit of a misnomer.)

    • Fancy side note: We could maybe say that the render tree is a more specific implementation that embodies the concept of the virtual DOM. Like, Inversion of Control (IoC) is a design principle (concept) where the control flow of a program is inverted from our custom logic to the framework or runtime and the Spring frmaework (an IoC container) implements that as dependency injection using reflection at runtime.
  • useMemo caches results of expensive calculations or references to arrays/objects/functions so that these result values or references are not recreated across re-renders, are then same values across renders when passed via props and are thus not queued as "updates" in the browser DOM by the UI library during a re-render.

    • React's doc says: The only benefit to useCallback is that it lets you avoid writing an extra nested function inside. It doesn’t do anything else.

      function useCallback(fn, dependencies) {
        return useMemo(() => fn, dependencies);
      }
  • memo is a different optimization feature which is used to force stop the evaluation and thus updation of a child subtree (in the "Render Tree") itself when its parent component re-renders given child's props are same across renders.

Interesting Takeaways

  • In-browser debugging is very robust, got to play with "Sources" tab a lot while debugging recursive library functions & APIs.

  • JSX Spec - "Why not Template Literals?"

  • React uses the order of hooks inside a component's function definition to manage state and side effects.

  • I have actually understood the importance of immutability in object references and its impact on work done during re-rendering given how React.js uses shallow comparison to strike a balance between performance and correctness.

    • If a property itself is an object (or array), a shallow comparison will only check if the references (or addresses) of these objects are the same, without recursively comparing nested objects or arrays. For insatnce, defining objects in inline style attribute will create a new object reference on every render and thus resulting in re-rendering of that element in the browser DOM - so, either define them outside the component or use useMemo.
  • How different frameworks handle the difference between DOM properties and attributes

  • A JSX Runtime is a set of functions that a compiler (like Babel) uses to transform JSX syntax into JavaScript function calls. In the React ecosystem, these are provided by the "@jsx-runtime/react" package. Instead of just a single createElement function, modern JSX runtimes usually define:

    • Fragment: A component used to group multiple elements without adding extra nodes to the DOM.
    • jsx: For handling JSX elements.
    • jsxs: For handling JSX elements with multiple children (used for optimization).
    • jsxDEV: A version of jsx with additional development features (like better error messages).
  • DocumentFragment: To mimic React.js's Fragment or to create a DOM node respective to a functional component node from the render tree, we can use a DocumentFragment object.

    • A DocumentFragment is a lightweight, minimalistic document object that can hold and manipulate a group of nodes. When we append a DocumentFragment to the DOM, its children are appended, but the fragment itself is not (similar to how a React Fragment works). That is, just like a React Fragment, a DocumentFragment doesn’t create an extra node in the DOM tree, and only its child elements are added. But, Document and DocumentFragment nodes can never have a parent, so parentNode will always return null.
  • React Compiler is a new experimental compiler which requires React 19 RC. It automatically memoizes code using React's rules. Also, React Compiler's eslint plugin can be used independently of the compiler to display any violations of the rules of React in your editor.

Build Tools

Why do we need a Bundler Tool

Why do we need a Build Setup

  • I keep forgetting to re-generate the bundle after editing code, besides it being a unproductive flow-breaker. So, "live reload" would be much appreciated!

  • When you import a script via a <script> tag, the code is expected to be directly executable in the browser without any additional environment.

    • require() is a Node.js module system function, and it's not supported in browsers.

    • ES Modules (ESM) are a standardized module system in JavaScript. They use import and export statements and are supported by modern browsers. However, for the browser to recognize and correctly parse a JavaScript file that uses ES Modules, you need to indicate this with the type="module" attribute in the <script> tag. But, type="module" causes following issue which can be solved using a server.

      Access to script at '.../components.js' from origin "null" has been blocked by CORS policy: Cross origin requests are only supported for protocol schemes: http, data, isolated-app, chrome-extension, chrome, https, chrome-untrusted.
      
    • By using format: "iife", you ensure that the output is wrapped in an Immediately Invoked Function Expression, which allows it to run directly in the browser.

Out of Scope

meact.js does synchronous state updates instead of batching them and applying them in an asynchronous manner like React.js, and its performance implication ("CPU fan noise") can be observed by running ./lab/15-react-sim-sync-setstate-performance/index.html where the UI implements a React Component which renders a colored circle following the mouse position, and another colored circle following first circle's position delayed by 100ms and so on (for total 5-6 circles).

Why is setState in react.js Async instead of Sync?

  1. In a UI, it's not necessary for every update to be applied immediately; in fact, doing so can be wasteful, causing frames to drop and degrading the user experience.
  2. Different types of updates have different priorities — an animation update needs to complete more quickly than, say, an update from a data store.
  3. A push-based approach requires the app (you, the programmer) to decide how to schedule work. A pull-based approach allows the framework (React) to be smart and make those decisions for you. React doesn't currently take advantage of scheduling in a significant way; an update results in the entire subtree being re-rendered immediately. Overhauling React's core algorithm to take advantage of scheduling is the driving idea behind Fiber.
  • A simple example to demonstrate this, is that if you call setState as a reaction to a user action, then the state will probably be updated immediately (although, again, you can't count on it), so the user won't feel any delay, but if you call setState in reaction to an ajax call response or some other event that isn't triggered by the user, then the state might be updated with a slight delay, since the user won't really feel this delay, and it will improve performance by waiting to batch multiple state updates together and rerender the DOM fewer times.

WHAT's MISSING

Beyond many for-scale things, meact.js doesn't implement these necessary ones (versus react.js)

  • Great documentation, extensive testing and OSS community
  • Dev tools and Hot Reload in dev environment
  • Scoped CSS; CSS-in-JS
  • Synthetic events and Cross-browser API for setting DOM node attributes and properties
  • Ecosystem of libraries and frameworks (e.g. Astro, Next.js, client or async state management, UI components, caching, etc)
  • Metadata, SEO tuning, robots.txt, sitemap
  • Server side rendering
  • Concurrent rendering
  • Performance optimizations:
    • Scheduling of state updates
    • Memory management (e.g. garbage collection)
    • Compiler like Svelte/Solid (under development)