Make React Wheels with TypeScript by hand

“This is the first day of my participation in the First Challenge 2022. For details: First Challenge 2022.” This article is based on my own practice and supplement after reading the React Learning series. I also want to deepen my understanding of TS and type ideas by making wheels in Typescript recently. After all, REACT supports TS quite well. The best way to understand the source code is probably to make one yourself. Here are most of my coding ideas of some sort, I hope to help you. If there is something wrong or inaccurate, I hope you can point it out to 🥺

1. Logic of component rendering

Now that we’ve rendered the native DOM element, let’s render the component. In BOTH JSX and TSX, components are used in the form of
and are classified as functional and class components. The type of component must be determined first. In conjunction with the DOM element rendering from the previous section, the render function pseudocode should now look like:

  1. If it is a DOM element, execute the mountDOMElement method
  2. If it is a component, determine the type of component
    • Render functional components
    • Render class components
    • Determine the type of virtual DOM rendered
      • If it is a component, recursively render
      • If it is a DOM element, render recursively

2. Distinguish between components and DOM elements

The first step is to determine if a virtual DOM is a component. Let’s look at what the component looks like when compiled by creatElement:

Define the functional component Greeting and the class component Welcome, respectively, and print them to the console using console.log.

Here I inherit react.componentdirectly when defining the class component, and make a hole for it later when implementing the life cycle or Fiber.

const Greeting = function () {
  return (
    <div>
      <h1>Hello React</h1>
    </div>)}class Welcome extends React.Component {
  render() {
    return (
      <div>
        <h1>Hello React</h1>
      </div>)}}console.log(<Greeting />);
console.log(<Welcome />)
Copy the code

The result printed in the figure below is the result of the createElement method we wrote earlier — both functional and class components will look like this when printed by console.log. You can see that the virtual DOM of the Welcome component (Figure 1), whose type(Welcome()) is different from the “div” of the native DOM element (Figure 2), is a function.


So we can start fromtypeWhether a virtual DOM is a component is determined by whether the property is a function

/* shared/utils.ts */
/** * Determine whether the specified virtual DOM should be rendered as a component or as a native DOM node, using the component virtual DOM's type attribute as function@param type 
 * @returns boolean* /
export const isFunction = <T>(type: T): boolean= > {
  return type && type instanceof Function
}


/* demo/index.tsx */
console.log(isFunction(vDOM)); // false 
console.log(isFunction(Greeting.type)); Property 'type' does not exist on type' () => Element'.
console.log(isFunction(Welcome.type)); Property 'type' does not exist on type' typeof Welcome'.
Copy the code

There is a problem. If we try to determine whether the type of Greeting and Welcome is a function, TS will report an error because they are pure TSX code at this point and have not been compiled into the virtual DOM by the createElement method. The Type attribute exists in the virtual DOM;

const greeting = Greeting() // 
const welcome = new Welcome({}).render()

/ / the console. The log (isFunction (the Greeting. Type)) ❌
console.log(isFunction(greeting.type)) / / ️ true

/ / the console. The log (isFunction (Welcome. Type)) ❌
console.log(isFunction(welcome.type)) // true
Copy the code

3. Distinguish between functional components and class components

If we look closely at the type attribute in the component, we can see the difference between the type function returned by Welcome and Greeting:

If you tried to print a protype for both, it would look like this:

Welcome inherits from the react.componentparent class, React distinguishes Class and Function components by adding the isReactComponent property to Component.

/ / the React within
class Component {}
Component.isReactClass = {};

// We can check it like this
class Welcome extends React.Component {}
console.log(Welcome.isReactClass); / / {}
Copy the code

So I can use the following method to distinguish function-style components from class components:

/* src/shared/utils.ts */

export const isFunction = () = > { / *... * / }

/** * You can use the isReactComponent attribute on the prototype of the class component to determine whether the class component is functional or class *@param type 
 * @returns * /
export const isClassComponent = <T extends Function>(type: T): boolean => { return type && !! type.prototype.isReactComponent }Copy the code

4. Render components

The isFunction and isClassComponent methods now implement the logic mentioned above:

  1. If it is a DOM element, executemountDOMElementmethods
  2. If it is a component, executemountComponentTo determine the type of the component
    • Render functional components
    • Render class components
    • Determine the type of virtual DOM rendered
      • If component, execute recursivelymountComponent
      • If it is a DOM element, it is executed recursivelymountDOMElement
export const mountComponent = (virtualDOM: MyReactElement, container: HTMLElement) = > {
  // Get the constructor and properties
  const { type: C, props } = virtualDOM
  let newVirtualDOM: MyReactElement
  // If it is a class component
  if (isClassComponent(virtualDOM.type)) {
    console.log('rendering class component')
    // Create an instance and return
    const c = new C()
    newVirtualDOM = c.render(props || {} )
  }
  // If it is a function component
  else {
    console.log('rendering functional component')
    newVirtualDOM = C(props || {})
  }

  // Record the virtual DOM to facilitate diff algorithm comparison
  container.__virtualDOM = newVirtualDOM

  // Determine whether newVirualDOM is a function
  if (isFunction(newVirtualDOM.type)) {
    mountComponent(newVirtualDOM, container)
  } else {
    mountDOMElement(newVirtualDOM, container)
  }
}
Copy the code

Key points:

  1. Functional components: used during renderingnewVirtualDOM = C(props)
  2. Class components: must be created during renderingCAn instance of thecBefore it can be usedc.render(props)
  3. Determine the type of the virtual DOM after the successful assignment to newVirtualDOM
    • If it is a component, it must be rendered recursivelymountComponent
    • If it is a DOM element, it is executed recursivelymountElementmethods

There is a special case to consider here: when we wrap a component around a native DOM element, as in the following case

const vDOM = (
  <div>
    <Todos>
  </div>
) 
Copy the code

Since the outermost layer is

, the mountDOMElement method is executed first, but in the previous section we only considered rendering the native DOM scene for child elements. We also need to make a few minor changes to the mountDOMElement method to match the case where the child element is a component:
/* MyReact/MyReactDOM.ts */

/** * Render native DOM elements *@param VirtualDOM virtualDOM *@param Container Indicates the parent container */
export const mountDOMElement = (virtualDOM: MyReactElement, container: HTMLElement | null) = > {
  / * * / has been eliminated 
  else {
    // Create the element
    newElement = document.createElement(type)
    // Update attributes
    attachProps(virtualDOM, newElement)
    // Recursively render child elementsprops? .children.forEach((child: MyReactElement) = > {
      // mountDOMElement(child, newElement)
      mountElement(child, newElement) // You need to take into account the fact that the child elements are components})}//* Record the current virtual DOM when creating the DOM elementnewElement.__virtualDOM = virtualDOM container? .appendChild(newElement) }Copy the code

The mountElement method separates the code that determines the virtual DOM type and renders it:

/* MyReact/MyReactRender.ts */

/** * Render method *@param virtualDOM 
 * @param container 
 * @returns * /
export const mountElement = (virtualDOM: MyReactElement, container: MyHTMLElement) = > {
  if(! container)return
  // Render components render DOM elements
  if (isFunction(virtualDOM.type)) {
    // Render component
    mountComponent(virtualDOM, container)
  } else {
    // Render native DOM elements
    console.log('Rendering DOM Element')
    mountDOMElement(virtualDOM, container)
  }
}
Copy the code

So our mountComponent can now be written more succinctly like this:

export const mountComponent = (virtualDOM: MyReactElement, container: HTMLElement) = > {
  // Get the constructor and properties
  const { type: C, props } = virtualDOM
  let newVirtualDOM: MyReactElement
  // If it is a class component
  if (isClassComponent(virtualDOM.type)) {
    console.log('rendering class component')
    // Create an instance and return
    const c = new virtualDOM.type()
    newVirtualDOM = c.render(props || {} )
  }
  // If it is a function component
  else {
    console.log('rendering functional component')
    newVirtualDOM = C(props || {})
  }

  // Record the virtual DOM to facilitate diff algorithm comparison
  container.__virtualDOM = newVirtualDOM
  // Determine the type of element to render recursively
  mountElement(newVirtualDOM, container)
}

Copy the code

5. Test

Next we can test the completion of this component rendering logic in rendering multi-layer components. The main tests are:

  • Todo is a class component that renders native DOM elements
  • Todos is a class component. Todos renders multiple Todo components and passes props from the App component as well as its own props
  • App is the component for the function that passes props to Todos

Here is our demo code:

/* demo/index.tsx */

// react.component.props 
      

accepts two parameters, P = props and S = state

,>
export class Todo extends React.Component<{ task: string, completed? :boolean, event? : MouseEventHandler<HTMLLIElement> }> {render() { const { completed, task, event } = this.props return ( <li className={completed ? 'completed' : 'ongoing'} onClick={event}> {task} </li>)}}Todos is a functional component export const Todos = (props: { type: string }) = > { const { type } = props const engList = ( <section className="todos eng" role="list"> <Todo task="createElement" completed={true} /> <Todo task="render" completed={true} /> <Todo task="diff" completed={false} /> </section> ) const cnList = ( <section className="todos chi" role="list"> <Todo task="createElement" completed={true} /> <Todo task="render" completed={true} /> <Todo task="diff" completed={false} /> <Todo task="Virtual DOM" completed={true} /> <Todo task="Rendering" completed={true} /> <Todo task="Diff algorithm" /> </section> ) return type= = ='one' ? engList : cnList } export const App = function (props: { type: string }) { return ( <Todos type={props.type} />)}const root = document.getElementById('app') as MyHTMLElement MyReact.render(<App type="two" />, root) Copy the code

The choice of test framework is recommended to use JEST, just need to use jest-DOM basic can cover the above demo.

5.1 Preparations for using THE Jest test

First install dependencies:

  • Install the jest:yarn add -D jest babel-jest ts-node
  • Install jEST test library:yarn add -D @testing-library/dom @testing-library/jest-dom
  • Install TS code tips:yarn add -D @types/jest

Then there is the simple configuration of JEST

/* jest.config.ts */

export default {
  // Provide test coverage with V8 engines
  coverageProvider: "v8".// Test the root directory, where React is written in the __tests__ folder
  roots: [
    "<rootDir>/__tests__"].// Automatically find the suffix name
  moduleFileExtensions: [
    "js"."jsx"."ts"."tsx"."json"."node"].// Test environment, because the main test is DOM rendering, so use jsDOM
  testEnvironment: "jsdom".// Specify the converter
  transform: {
    '^.+\\.(js|jsx|ts|tsx)$': '<rootDir>/node_modules/babel-jest',},// The converter re is ignored
  transformIgnorePatterns: [
    '[/\\\\]node_modules[/\\\\].+\\.(js|jsx|ts|tsx)$'.'^.+\\.module\\.(css|sass|scss)$']}Copy the code

Since the test files will be written in Typescript, @babel/preset- Typescript is needed at the end of the Babel configuration

{
  "presets": [
    "@babel/preset-env"["@babel/preset-react",
      {
        "pragma": "MyReact.createElement"}]."@babel/preset-typescript"]}Copy the code

For more on React testing in action, see the article Try Front-end Automated Testing, Portal

5.2 Writing unit tests

For the two components in the demo, Todo, Todos(ignoring App), we can write the following two simple unit tests. The test cases are roughly as follows

Todo

The main test is

  • Can you put the incomingtaskString output to<li>In this element
  • Is there sufficient evidencecompletedBooleans for this field render different styles
  • Whether to insert insert the event that is passed in<li>Elements of theeventListenerIn the
import React from 'react'
import * as MyReact from ".. /src/MyReact";
import { Todo } from '.. /demo'
import '@testing-library/jest-dom'
import { getByText } from '@testing-library/dom'

let container: any

beforeEach(() = > {
  container = document.createElement('div')
  document.body.appendChild(container)

})

afterEach(() = > {
  document.body.removeChild(container)
  container = null
})

describe('Todo components'.() = > {
  
  describe('Can render the backlog item name correctly'.() = > {
    // Can pass task attributes correctly
    it('should render task correctly'.() = > {
      MyReact.render(<Todo task='add testing' completed={false} />, container)
      expect(getByText(container, 'add testing')).toBeInTheDocument()
    })
  })

  describe('Able to render styles correctly'.() = > {
    // The render style completed: false
    it('should render class correctly => completed: false'.() = > {
      MyReact.render(<Todo task='add testing' completed={false} />, container)
      expect(getByText(container, 'add testing')).toHaveClass('ongoing')})// The render style completed: true
    it('should render class correctly => completed: true'.() = > {
      MyReact.render(<Todo task='add testing' completed={true} />, container)
      expect(getByText(container, 'add testing')).toHaveClass('completed')})// Render the style correctly completed: Not given
    it('should render class correctly => completed: not given'.() = > {
      MyReact.render(<Todo task='add testing' />, container)
      expect(getByText(container, 'add testing')).toHaveClass('ongoing')
    })
  })

  describe('Can trigger click events correctly'.() = > {
    it('should trigger click event correctly'.() = > {
      // Create a mock method
      const clickSpy = jest.fn()

      // Click the event as a mock method
      MyReact.render(<Todo task='add testing' event={clickSpy} />, container)

      // Trigger the click event
      const todo = getByText(container, 'add testing')
      todo.click()

      expect(clickSpy).toHaveBeenCalled()
    })
  })
})

Copy the code

Todos

The main test is

  • Can you according totypeThis field renders the Chinese and English listings separately
  • Can we be togetherdivRender multiple times inTodocomponent
import React from 'react'
import * as MyReact from ".. /src/MyReact";
import { Todos } from '.. /demo'
import '@testing-library/jest-dom'
import { getByText, getByRole } from '@testing-library/dom'


let container: any

beforeEach(() = > {
  container = document.createElement('div')
  document.body.appendChild(container)
})

afterEach(() = > {
  document.body.removeChild(container)
  container = null
})

describe('Todos components'.() = > {
  describe('Can display Chinese and English lists correctly'.() = > {
    it('should diplay English list'.() = > {
      MyReact.render(<Todos type="one" />, container)
      expect(getByRole(container, 'list')).toHaveClass('eng')
    })
    it('should diplay Chinese list'.() = > {
      MyReact.render(<Todos type="two" />, container)
      expect(getByRole(container, 'list')).toHaveClass('chi')
    })
  })

  describe('Ability to render multiple Todo components'.() = > {
    it('Eng list should have 3 todos'.() = > {
      MyReact.render(<Todos type="one" />, container)
      expect(getByRole(container, 'list').childElementCount).toBe(3)
    })

    it('Chinese List should have 6 todos'.() = > {
      MyReact.render(<Todos type="two" />, container)
      expect(getByRole(container, 'list').childElementCount).toBe(6)})})})Copy the code

5.3 If you don’t care about testing

If you’ve already skipped the unit tests section, take a look at these two screenshots.

conclusion

In this section, we implement it

  • When rendering the virtual DOM, we distinguish between components and native elements
  • Functional and class components are rendered correctly
  • After the perfectrenderThe method passed the unit test without a hitch

That concludes the rendering of the component! If you are interested, you can also go to my Github to have a look at the source code (update Diff algorithm in the next article)