├── .eslintignore ├── .prettierignore ├── .yarnrc.yml ├── example ├── .yarnrc.yml ├── public │ └── index.html ├── src │ ├── app │ │ ├── lifecycle.ts │ │ └── render.tsx │ ├── title │ │ ├── render.tsx │ │ └── lifecycle.ts │ ├── worker.ts │ └── index.ts ├── tsconfig.json └── package.json ├── .prettierrc ├── src ├── index.ts ├── worker │ ├── types.tsx │ ├── index.tsx │ ├── nativeComponents │ │ └── Input.tsx │ ├── nativeComponent.tsx │ ├── noopRender.tsx │ ├── App.tsx │ └── getComponentClass.tsx ├── common │ ├── log.tsx │ ├── register.tsx │ ├── ComponentContext.tsx │ ├── utils.ts │ ├── componentPath.tsx │ └── types.ts └── render │ ├── nativeComponents │ ├── Div.tsx │ └── Input.tsx │ ├── index.tsx │ ├── nativeComponent.tsx │ ├── App.tsx │ └── getComponentClass.tsx ├── cypress.json ├── cypress ├── fixtures │ └── example.json ├── support │ ├── index.js │ └── commands.js ├── plugins │ └── index.js └── integration │ └── test.spec.js ├── babel.config.js ├── .travis.yml ├── .eslintrc.js ├── tsconfig.json ├── .gitignore ├── LICENSE ├── package.json └── README.md /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.d.ts 2 | node_modules 3 | build -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | pnpMode: loose 4 | -------------------------------------------------------------------------------- /example/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | pnpMode: loose 4 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * as ReactWorker from './worker/'; 2 | export * as ReactRender from './render/'; 3 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseUrl": "http://localhost:3000/react-worker-render", 3 | "projectId": "wog843" 4 | } 5 | -------------------------------------------------------------------------------- /src/worker/types.tsx: -------------------------------------------------------------------------------- 1 | export interface WorkerComponent { 2 | props: any; 3 | state: any; 4 | setState(state: any): void; 5 | } 6 | -------------------------------------------------------------------------------- /src/common/log.tsx: -------------------------------------------------------------------------------- 1 | const verbose = 0; 2 | export function log(...args: any) { 3 | if (verbose) { 4 | console.log(...args); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | console.log('Load babel config!'); 2 | 3 | module.exports = (api) => { 4 | api.cache(false); 5 | return { 6 | presets: [ 7 | ['@babel/preset-env', { targets: { node: true } }], 8 | ['@babel/preset-typescript'], 9 | ], 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | react-worker-render demo 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /src/render/nativeComponents/Div.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export class Div extends React.Component { 4 | onClick = () => { 5 | this.props.onClick?.(); 6 | }; 7 | render() { 8 | return ( 9 |
10 | {this.props.children} 11 |
12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | notifications: 4 | email: 5 | - yiminghe@gmail.com 6 | 7 | node_js: 8 | - 16.13.1 9 | 10 | before_install: 11 | - corepack enable 12 | 13 | script: 14 | - yarn run bootstrap 15 | - yarn check 16 | - yarn start & 17 | - yarn cypress run --record --key 7ee3b205-230d-40e6-88ff-ddcadb1095a0 18 | - kill $(jobs -p) || true -------------------------------------------------------------------------------- /src/common/register.tsx: -------------------------------------------------------------------------------- 1 | import type { WorkerRenderComponentSpec } from './types'; 2 | 3 | const componentMaps: Record = {}; 4 | 5 | export function registerComponent( 6 | name: string, 7 | desc: WorkerRenderComponentSpec, 8 | ) { 9 | componentMaps[name] = desc; 10 | } 11 | 12 | export function getComponentDesc(name: string) { 13 | return componentMaps[name]; 14 | } 15 | -------------------------------------------------------------------------------- /example/src/app/lifecycle.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | getInitialState() { 3 | return { 4 | count: 1, 5 | }; 6 | }, 7 | onChange(value: string) { 8 | const n = parseInt(value); 9 | if (typeof n === 'number' && !isNaN(n)) { 10 | this.setState({ 11 | count: n, 12 | }); 13 | } 14 | }, 15 | onClick() { 16 | this.setState({ 17 | count: this.state.count + 1, 18 | }); 19 | }, 20 | } as any; 21 | -------------------------------------------------------------------------------- /example/src/title/render.tsx: -------------------------------------------------------------------------------- 1 | export default function render(this: any) { 2 | const { Div } = this.nativeComponents; 3 | return ( 4 |
14 | {this.state.title}@{this.state.now} 15 |
16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/common/ComponentContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { 3 | AppComponent, 4 | ComponentPathMeta, 5 | WorkerRenderComponent, 6 | } from './types'; 7 | 8 | export type ComponentContextValue = { 9 | parent: ComponentPathMeta; 10 | app: AppComponent; 11 | }; 12 | 13 | const ComponentContext = React.createContext({ 14 | parent: null!, 15 | app: null!, 16 | }); 17 | 18 | export default ComponentContext; 19 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | parser: '@typescript-eslint/parser', 8 | plugins: [ 9 | '@typescript-eslint', 10 | ], 11 | extends: [ 12 | 'eslint:recommended', 13 | 'plugin:@typescript-eslint/recommended', 14 | 'prettier', 15 | ], 16 | rules:{ 17 | '@typescript-eslint/no-explicit-any':'off', 18 | '@typescript-eslint/no-non-null-assertion':'off' 19 | } 20 | }; -------------------------------------------------------------------------------- /example/src/title/lifecycle.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | defaultProps: { 3 | defaultId: 1, 4 | }, 5 | getInitialState() { 6 | return { 7 | title: 'react-worker-render', 8 | now: this.props.defaultId, 9 | }; 10 | }, 11 | shouldComponentUpdate(nextProps: any, nextState: any) { 12 | return nextState.now !== this.state.now; 13 | }, 14 | refresh() { 15 | this.setState({ 16 | now: this.state.now + 1, 17 | }); 18 | }, 19 | } as any; 20 | -------------------------------------------------------------------------------- /src/worker/index.tsx: -------------------------------------------------------------------------------- 1 | import { registerComponent } from '../common/register'; 2 | import noopRender from './noopRender'; 3 | import React from 'react'; 4 | import App from './App'; 5 | import { WorkerLike } from '../common/types'; 6 | 7 | export { registerComponent }; 8 | 9 | export function bootstrap({ 10 | worker, 11 | entry, 12 | }: { 13 | worker: WorkerLike; 14 | entry: string; 15 | }) { 16 | noopRender.create(); 17 | } 18 | 19 | export { registerNativeComponent } from './nativeComponent'; 20 | -------------------------------------------------------------------------------- /example/src/worker.ts: -------------------------------------------------------------------------------- 1 | import { ReactWorker } from '../../src/index'; 2 | import lifecycle from './app/lifecycle'; 3 | import render from './app/render'; 4 | import titleLifecycle from './title/lifecycle'; 5 | import titleRender from './title/render'; 6 | 7 | const entry = 'app'; 8 | 9 | ReactWorker.registerComponent(entry, { 10 | ...lifecycle, 11 | render, 12 | }); 13 | 14 | ReactWorker.registerComponent('title', { 15 | ...titleLifecycle, 16 | render: titleRender, 17 | }); 18 | 19 | ReactWorker.bootstrap({ 20 | worker: self, 21 | entry, 22 | }); 23 | -------------------------------------------------------------------------------- /src/common/utils.ts: -------------------------------------------------------------------------------- 1 | export function noop() { 2 | return; 3 | } 4 | 5 | export function cleanFuncJson(json: any) { 6 | const ret: any = {}; 7 | for (const k of Object.keys(json)) { 8 | const value = json[k]; 9 | if (typeof value !== 'function') { 10 | ret[k] = value; 11 | continue; 12 | } 13 | if (k.match(/^on[A-z]/)) { 14 | ret[k] = value.handleName + ''; 15 | } 16 | } 17 | return ret; 18 | } 19 | 20 | export function safeJsonParse(str: string) { 21 | try { 22 | return JSON.parse(str); 23 | } catch (e) { 24 | return {}; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /example/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "ESNext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "jsx": "react-jsx" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /src/render/index.tsx: -------------------------------------------------------------------------------- 1 | import { registerComponent } from '../common/register'; 2 | import App from './App'; 3 | import React from 'react'; 4 | import { WorkerLike } from '../common/types'; 5 | 6 | export { registerComponent }; 7 | 8 | export function bootstrap({ 9 | worker, 10 | render, 11 | entry, 12 | batchedUpdates, 13 | }: { 14 | entry: string; 15 | batchedUpdates: (fn: () => void) => void; 16 | worker: WorkerLike; 17 | render: (element: React.ReactChild) => void; 18 | }) { 19 | render(); 20 | } 21 | 22 | export { registerNativeComponent } from './nativeComponent'; 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "declaration":true, 10 | "outDir":"lib", 11 | "allowJs": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "allowSyntheticDefaultImports": true, 15 | "strict": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "module": "ESNext", 19 | "moduleResolution": "node", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "jsx": "react-jsx" 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules/ 5 | .yarn/ 6 | !/.yarn/patches 7 | !/.yarn/plugins 8 | !/.yarn/releases 9 | !/.yarn/versions 10 | !/.yarn/sdks 11 | 12 | # Swap the comments on the following lines if you don't wish to use zero-installs 13 | # Documentation here: https://yarnpkg.com/features/zero-installs 14 | #!/.yarn/cache 15 | /.pnp.* 16 | 17 | 18 | # testing 19 | /coverage 20 | 21 | # production 22 | /build 23 | /pkg 24 | /lib 25 | 26 | /example/build 27 | 28 | # misc 29 | .DS_Store 30 | .env.local 31 | .env.development.local 32 | .env.test.local 33 | .env.production.local 34 | 35 | npm-debug.log* 36 | yarn-debug.log* 37 | yarn-error.log* 38 | 39 | tmp/ 40 | pkg/ 41 | 42 | /cypress/videos/ -------------------------------------------------------------------------------- /src/worker/nativeComponents/Input.tsx: -------------------------------------------------------------------------------- 1 | import { registerComponent } from '../../render'; 2 | 3 | export default registerComponent('input', { 4 | getInitialState() { 5 | return { 6 | value: this.props.value, 7 | seq: 1, 8 | }; 9 | }, 10 | onChange(e: any) { 11 | // do not need send to render 12 | this.setState(e, undefined, false); 13 | this.props.onChange?.(e.value); 14 | }, 15 | componentDidUpdate(prevProps: any, prevState: any) { 16 | if (Number.isNaN(this.props.value) && Number.isNaN(this.state.value)) { 17 | return; 18 | } 19 | if (this.props.value !== this.state.value) { 20 | this.setState({ 21 | ...this.state, 22 | value: this.props.value, 23 | }); 24 | } 25 | }, 26 | render() { 27 | return null; 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "example", 3 | "private": true, 4 | "scripts": { 5 | "build": "yarn react-scripts build", 6 | "start": "yarn react-scripts start" 7 | }, 8 | "license": "MIT", 9 | "devDependencies": { 10 | "@types/react-dom": "^18.0.2", 11 | "@yiminghe/react-scripts": "^5.0.1", 12 | "react-dom": "18.x", 13 | "react-worker-render": "0.0.x", 14 | "typescript": "4.x" 15 | }, 16 | "homepage": "http://yiminghe.github.io/react-worker-render", 17 | "browserslist": { 18 | "production": [ 19 | ">0.2%", 20 | "not dead", 21 | "not op_mini all" 22 | ], 23 | "development": [ 24 | "last 1 chrome version", 25 | "last 1 firefox version", 26 | "last 1 safari version" 27 | ] 28 | }, 29 | "packageManager": "yarn@3.2.2" 30 | } 31 | -------------------------------------------------------------------------------- /cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | } 23 | -------------------------------------------------------------------------------- /src/worker/nativeComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const nativeComponents: Record = {}; 4 | 5 | export function getNativeComponentClass(name: string): React.ComponentClass { 6 | if (nativeComponents[name]) { 7 | return nativeComponents[name]; 8 | } 9 | 10 | class CC extends React.Component { 11 | render() { 12 | return this.props.children; 13 | } 14 | } 15 | 16 | const C: any = CC; 17 | C.displayName = name; 18 | nativeComponents[name] = C; 19 | return C; 20 | } 21 | 22 | export function registerNativeComponent( 23 | cls: string, 24 | Cls: React.ComponentClass, 25 | ) { 26 | nativeComponents[cls] = Cls; 27 | } 28 | 29 | Object.assign(nativeComponents, { 30 | Div: getNativeComponentClass('div'), 31 | Link: getNativeComponentClass('link'), 32 | A: getNativeComponentClass('a'), 33 | }); 34 | -------------------------------------------------------------------------------- /example/src/index.ts: -------------------------------------------------------------------------------- 1 | import { ReactRender } from '../../src/index'; 2 | import render from './app/render'; 3 | import titleRender from './title/render'; 4 | import { createRoot } from 'react-dom/client'; 5 | import React from 'react'; 6 | import ReactDOM from 'react-dom'; 7 | 8 | const entry = 'app'; 9 | 10 | ReactRender.registerComponent(entry, { 11 | render, 12 | }); 13 | 14 | ReactRender.registerComponent('title', { 15 | render: titleRender, 16 | }); 17 | 18 | const worker = new Worker(new URL('./worker.ts', import.meta.url)); 19 | 20 | const container = document.getElementById('root')!; 21 | const root = createRoot(container); // createRoot(container!) if you use TypeScript 22 | 23 | ReactRender.bootstrap({ 24 | worker, 25 | entry, 26 | batchedUpdates: ReactDOM.unstable_batchedUpdates, 27 | render(element: React.ReactChild) { 28 | root.render(element); 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /src/render/nativeComponent.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Div } from './nativeComponents/Div'; 3 | 4 | export function getNativeComponentClass(name: string): React.ComponentClass { 5 | if (nativeComponents[name]) { 6 | return nativeComponents[name]; 7 | } 8 | 9 | class CC extends React.Component { 10 | render() { 11 | const Tag = name; 12 | return ; 13 | } 14 | } 15 | 16 | const C: any = CC; 17 | C.displayName = name; 18 | nativeComponents[name] = C; 19 | return C; 20 | } 21 | 22 | export const nativeComponents: Record = { 23 | Div, 24 | }; 25 | 26 | Object.assign(nativeComponents, { 27 | A: getNativeComponentClass('a'), 28 | Link: getNativeComponentClass('link'), 29 | }); 30 | 31 | export function registerNativeComponent( 32 | cls: string, 33 | Cls: React.ComponentClass, 34 | ) { 35 | nativeComponents[cls] = Cls; 36 | } 37 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /cypress/integration/test.spec.js: -------------------------------------------------------------------------------- 1 | import config from '../../cypress.json'; 2 | 3 | describe('react-worker-render', () => { 4 | it('init works', () => { 5 | cy.visit(config.baseUrl); 6 | cy.get('#t-input').should('to.have.value','1'); 7 | cy.get('#t-count').should('to.have.text','1'); 8 | cy.get('#t-title').should('to.have.text','react-worker-render@1');; 9 | }); 10 | 11 | it('control input works',()=>{ 12 | cy.visit(config.baseUrl); 13 | cy.get('#t-input').type('o'); 14 | cy.get('#t-input').should('to.have.value','1'); 15 | cy.get('#t-input').type('{selectAll}22'); 16 | cy.get('#t-input').should('to.have.value','22'); 17 | cy.get('#t-count').should('to.have.text','22'); 18 | }); 19 | 20 | it('event works',()=>{ 21 | cy.visit(config.baseUrl); 22 | cy.get('#t-click').click(); 23 | cy.get('#t-title').click(); 24 | cy.get('#t-input').should('to.have.value','2'); 25 | cy.get('#t-count').should('to.have.text','2'); 26 | cy.get('#t-title').should('to.have.text','react-worker-render@2');; 27 | }); 28 | }); -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 yiminghe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /example/src/app/render.tsx: -------------------------------------------------------------------------------- 1 | export default function render(this: any) { 2 | const { Div, Input, Link, A } = this.nativeComponents; 3 | const Title = this.getComponent('title'); 4 | return ( 5 | <> 6 | 7 | <Div> 8 | set(number):{' '} 9 | <Input 10 | id="t-input" 11 | onChange={this.getEventHandle('onChange')} 12 | value={this.state.count} 13 | /> 14 | </Div> 15 | <Div 16 | id="t-click" 17 | style={{ 18 | border: '1px solid red', 19 | margin: 10, 20 | padding: 10, 21 | userSelect: 'none', 22 | }} 23 | onClick={this.getEventHandle('onClick')} 24 | > 25 | click to increment:{' '} 26 | <Div style={{ display: 'inline' }} id="t-count"> 27 | {this.state.count} 28 | </Div> 29 | </Div> 30 | <Link 31 | rel="stylesheet" 32 | href="https://cdnjs.cloudflare.com/ajax/libs/github-fork-ribbon-css/0.2.3/gh-fork-ribbon.min.css" 33 | /> 34 | <Div> 35 | <A 36 | className="github-fork-ribbon" 37 | href="https://github.com/yiminghe/react-worker-render" 38 | data-ribbon="Fork me on GitHub" 39 | title="Fork me on GitHub" 40 | > 41 | Fork me on GitHub 42 | </A> 43 | </Div> 44 | </> 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /src/worker/noopRender.tsx: -------------------------------------------------------------------------------- 1 | import Reconciler from 'react-reconciler'; 2 | 3 | function noop() { 4 | return; 5 | } 6 | 7 | const HostConfig: any = { 8 | supportsMutation: true, 9 | appendInitialChild: noop, 10 | createInstance: noop, 11 | createTextInstance: noop, 12 | finalizeInitialChildren: noop, 13 | getRootHostContext: noop, 14 | getChildHostContext: noop, 15 | now: () => Date.now(), 16 | getPublicInstance: noop, 17 | prepareForCommit: noop, 18 | prepareUpdate: noop, 19 | resetAfterCommit: noop, 20 | shouldSetTextContent: () => true, 21 | appendChild: noop, 22 | appendChildToContainer: noop, 23 | commitTextUpdate: noop, 24 | commitMount: noop, 25 | commitUpdate: noop, 26 | insertBefore: noop, 27 | insertInContainerBefore: noop, 28 | removeChild: noop, 29 | removeChildFromContainer: noop, 30 | resetTextContent: noop, 31 | clearContainer: noop, 32 | }; 33 | 34 | let Render: any; 35 | let id = 0; 36 | 37 | function getRender() { 38 | if (!Render) { 39 | Render = Reconciler(HostConfig); 40 | } 41 | return Render; 42 | } 43 | 44 | export default { 45 | create(element: React.ReactNode) { 46 | const container = getRender().createContainer(++id, false, false); 47 | const entry = { 48 | update(newElement: React.ReactNode) { 49 | getRender().updateContainer(newElement, container, null, null); 50 | }, 51 | unmount() { 52 | getRender().updateContainer(null, container, null); 53 | }, 54 | }; 55 | entry.update(element); 56 | return entry; 57 | }, 58 | batchedUpdates(...args: any) { 59 | return getRender().batchedUpdates(...args); 60 | }, 61 | }; 62 | -------------------------------------------------------------------------------- /src/render/nativeComponents/Input.tsx: -------------------------------------------------------------------------------- 1 | import { registerComponent } from '../../worker'; 2 | import React from 'react'; 3 | import { log } from '../../common/log'; 4 | 5 | interface InputProps { 6 | value: string; 7 | seq: number; 8 | onChange: Function; 9 | } 10 | 11 | interface InputState { 12 | value: string; 13 | seq: number; 14 | } 15 | 16 | class Input extends React.Component<InputProps, InputState> { 17 | constructor(props: any) { 18 | super(props); 19 | this.state = { 20 | value: props.value, 21 | seq: props.seq, 22 | }; 23 | } 24 | static defaultProps = { 25 | seq: 1, 26 | }; 27 | static getDerivedStateFromProps( 28 | nextProps: InputProps, 29 | prevState: InputState, 30 | ) { 31 | if ( 32 | nextProps.seq === prevState.seq && 33 | nextProps.value !== prevState.value 34 | ) { 35 | log('accept input', nextProps.seq, prevState.seq, nextProps.value); 36 | return { 37 | ...prevState, 38 | value: nextProps.value, 39 | }; 40 | } 41 | log('skip input', nextProps.seq, prevState.seq); 42 | return {}; 43 | } 44 | onChange = (e: any) => { 45 | const { value } = e.target; 46 | const current = { 47 | value, 48 | seq: this.state.seq + 1, 49 | }; 50 | this.setState(current); 51 | this.props.onChange(current); 52 | }; 53 | render() { 54 | const { value, onChange, seq, ...rest } = this.props; 55 | return ( 56 | <input {...rest} value={this.state.value} onChange={this.onChange} /> 57 | ); 58 | } 59 | } 60 | 61 | export default registerComponent('input', { 62 | render() { 63 | return ( 64 | <Input 65 | {...this.props} 66 | value={this.state.value} 67 | seq={this.state.seq} 68 | onChange={this.getEventHandle('onChange')} 69 | /> 70 | ); 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /src/common/componentPath.tsx: -------------------------------------------------------------------------------- 1 | import ComponentContext, { ComponentContextValue } from './ComponentContext'; 2 | import React from 'react'; 3 | import { ComponentPathMeta } from './types'; 4 | 5 | function getComponentContext(instance: ComponentPathMeta) { 6 | if (!instance.componentContext) { 7 | instance.componentContext = { 8 | parent: instance, 9 | app: 10 | (instance.context as ComponentContextValue)?.app || (instance as any), 11 | }; 12 | } 13 | return instance.componentContext; 14 | } 15 | 16 | const componentPath = { 17 | classWithContext(ComponentClass: React.ComponentClass) { 18 | ComponentClass.contextType = ComponentContext; 19 | }, 20 | 21 | updateComponentPath(instance: ComponentPathMeta) { 22 | const { parent } = instance.context as ComponentContextValue; 23 | const { componentChildIndexMap } = parent; 24 | if (componentChildIndexMap.has(instance)) { 25 | instance.componentIndex = componentChildIndexMap.get(instance)!; 26 | } else { 27 | instance.componentIndex = ++parent.componentChildIndex; 28 | componentChildIndexMap.set(instance, instance.componentIndex); 29 | instance.componentPath = ''; 30 | } 31 | }, 32 | 33 | getComponentPath(instance: ComponentPathMeta) { 34 | if (!instance.componentPath) { 35 | const { parent } = instance.context as ComponentContextValue; 36 | instance.componentPath = `${componentPath.getComponentPath(parent)}-${ 37 | instance.componentIndex 38 | }`; 39 | } 40 | return instance.componentPath; 41 | }, 42 | 43 | renderWithComponentContext( 44 | instance: ComponentPathMeta, 45 | element: React.ReactNode, 46 | ) { 47 | instance.componentChildIndex = 0; 48 | instance.componentChildIndexMap.clear(); 49 | return ( 50 | <ComponentContext.Provider value={getComponentContext(instance)}> 51 | {element} 52 | </ComponentContext.Provider> 53 | ); 54 | }, 55 | }; 56 | 57 | export default componentPath; 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-worker-render", 3 | "version": "0.0.11", 4 | "description": "move react component lifecycle to worker", 5 | "author": "yiminghe <yiminghe@gmail.com>", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git@github.com:yiminghe/react-worker-render.git" 10 | }, 11 | "files": [ 12 | "lib" 13 | ], 14 | "main": "lib/index", 15 | "module": "lib/index", 16 | "scripts": { 17 | "bootstrap": "cd example && yarn install", 18 | "deploy": "yarn run build && gh-pages -d example/build", 19 | "pub": "yarn run compile && npm publish", 20 | "compile": "tsc", 21 | "build": "cd example && yarn build", 22 | "start": "cd example && yarn start", 23 | "lint-fix": "yarn run lint --fix", 24 | "lint": "yarn run eslint . --ext .ts", 25 | "test": "cypress run", 26 | "check": "rm -rf lib && yarn run lint && tsc --noEmit && cd example && tsc --noEmit", 27 | "prettier": "prettier --write \"{src,scripts,example}/**/*.{js,tsx,ts,jsx}\"" 28 | }, 29 | "devDependencies": { 30 | "@babel/cli": "7.x", 31 | "@babel/core": "7.x", 32 | "@babel/node": "7.x", 33 | "@babel/preset-env": "7.x", 34 | "@babel/preset-typescript": "7.x", 35 | "@pika/pack": "^0.5.0", 36 | "@types/fs-extra": "^9.0.13", 37 | "@types/jest": "27.x", 38 | "@types/node": "^17.0.21", 39 | "@typescript-eslint/eslint-plugin": "^5.15.0", 40 | "@typescript-eslint/parser": "^5.15.0", 41 | "cypress": "^9.2.0", 42 | "eslint": "^8.11.0", 43 | "eslint-config-prettier": "^8.5.0", 44 | "fs-extra": "^10.0.1", 45 | "gh-pages": "^3.2.3", 46 | "prettier": "2.x", 47 | "typescript": "^4.6.2" 48 | }, 49 | "packageManager": "yarn@3.2.2", 50 | "dependencies": { 51 | "@types/react": "^18.0.6", 52 | "@types/react-addons-pure-render-mixin": "^0.14.19", 53 | "@types/react-reconciler": "^0.26.6", 54 | "react": "^18.0.0", 55 | "react-addons-pure-render-mixin": "^15.6.3", 56 | "react-reconciler": "^0.27.0" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ComponentContextValue } from './ComponentContext'; 3 | 4 | export interface ComponentPathMeta<P = any, S = any> 5 | extends React.Component<P, S> { 6 | componentContext?: ComponentContextValue; 7 | componentChildIndex: number; 8 | componentChildIndexMap: Map<ComponentPathMeta, number>; 9 | componentIndex: number; 10 | componentPath: string; 11 | } 12 | 13 | export interface WorkerRenderComponent<P = any, S = any> 14 | extends ComponentPathMeta<P, S> { 15 | id: string; 16 | componentName: string; 17 | setStateState(state: any): void; 18 | getInstanceState(): any; 19 | getInstanceProps(): any; 20 | componentSpec: WorkerRenderComponentSpec; 21 | callMethod(method: string, args: any[]): void; 22 | } 23 | export type ComponentPath = string; 24 | export type ComponentId = string; 25 | 26 | export interface AppComponent<P = any, S = any> 27 | extends ComponentPathMeta<P, S> { 28 | postMessage(msg: any): void; 29 | componentNameDefaultPropsMap: Record<string, string>; 30 | newComponentsPathIdMap: Record<ComponentPath, ComponentId>; 31 | newComponentsIdStateMap: Record<ComponentId, any>; 32 | addComponent(component: WorkerRenderComponent): void; 33 | setStateState(component: WorkerRenderComponent, state: any): void; 34 | removeComponent(component: WorkerRenderComponent): void; 35 | } 36 | 37 | export interface WorkerRenderComponentSpec 38 | extends React.ComponentLifecycle<any, any>, 39 | React.StaticLifecycle<any, any> { 40 | getInitialState?: () => any; 41 | defaultProps?: any; 42 | render: (this: { 43 | nativeComponents: Record<string, React.ComponentClass>; 44 | props: any; 45 | state: any; 46 | getComponent: (name: string) => React.ComponentClass; 47 | getEventHandle: (name: string) => any; 48 | }) => React.ReactNode; 49 | [k: string]: any; 50 | } 51 | 52 | export interface WorkerLike { 53 | postMessage(msg: string): void; 54 | addEventListener: ( 55 | type: 'message', 56 | fn: (e: { data: string }) => void, 57 | ) => void; 58 | removeEventListener: ( 59 | type: 'message', 60 | fn: (e: { data: string }) => void, 61 | ) => void; 62 | } 63 | 64 | export const MSG_TYPE = 'react-worker-render'; 65 | export interface FromWorkerMsg { 66 | type: typeof MSG_TYPE; 67 | newComponentNameDefaultPropsMap: Record<string, string>; 68 | pendingIdStateMap: Record<string, string>; 69 | newComponentsPathIdMap: Record<string, string>; 70 | newComponentsIdStateMap: Record<string, string>; 71 | } 72 | 73 | export interface FromRenderMsg { 74 | type: typeof MSG_TYPE; 75 | componentId: string; 76 | method: string; 77 | args: any[]; 78 | } 79 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-worker-render 2 | 3 | [![NPM version](https://badge.fury.io/js/react-worker-render.png)](http://badge.fury.io/js/react-worker-render) 4 | [![NPM downloads](http://img.shields.io/npm/dm/react-worker-render.svg)](https://npmjs.org/package/react-worker-render) 5 | [![Build Status](https://app.travis-ci.com/yiminghe/react-worker-render.svg?branch=main)](https://app.travis-ci.com/github/yiminghe/react-worker-render) 6 | [![react-worker-render](https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/simple/wog843&style=flat&logo=cypress)](https://dashboard.cypress.io/projects/wog843/runs) 7 | 8 | move react component lifecycle to worker and render to DOM. 9 | 10 | ## example 11 | 12 | https://yiminghe.github.io/react-worker-render 13 | 14 | ## API 15 | 16 | ### types 17 | 18 | ```ts 19 | interface WorkerRenderComponentSpec extends React.ComponentLifecycle<any, any>, React.StaticLifecycle<any, any> { 20 | getInitialState?: () => any; 21 | defaultProps?: any; 22 | render: (this: { 23 | nativeComponents: Record<string, React.ComponentClass>; 24 | props: any; 25 | state: any; 26 | getComponent: (name: string) => React.ComponentClass; 27 | getEventHandle: (name: string) => any; 28 | }) => React.ReactNode; 29 | [k: string]: any; 30 | } 31 | interface WorkerLike { 32 | postMessage(msg: string): void; 33 | onmessage: ((e: any) => void) | null; 34 | } 35 | ``` 36 | 37 | ### ReactWorker 38 | 39 | ```ts 40 | import { ReactWorker } from 'react-worker-render'; 41 | ``` 42 | 43 | ```ts 44 | export declare function registerNativeComponent(cls: string, Cls: React.ComponentClass): void; 45 | export declare function registerComponent(name: string, desc: WorkerRenderComponentSpec): void; 46 | export declare function bootstrap(params: { 47 | worker: WorkerLike; 48 | entry: string; 49 | }): void; 50 | ``` 51 | 52 | ### ReactRender 53 | 54 | ```ts 55 | import { ReactRender } from 'react-worker-render'; 56 | ``` 57 | 58 | ```ts 59 | export declare function registerNativeComponent(cls: string, Cls: React.ComponentClass): void; 60 | export declare function registerComponent(name: string, desc: {render:WorkerRenderComponentSpec['render']}): void; 61 | export declare function bootstrap(params: { 62 | worker: WorkerLike; 63 | entry: string; 64 | batchedUpdates: (fn: () => void) => void; 65 | render: (element: React.ReactChild) => void; 66 | }): void; 67 | ``` 68 | 69 | ## development 70 | 71 | ``` 72 | yarn run bootstrap 73 | yarn start 74 | ``` 75 | 76 | open: http://localhost:3000/ 77 | 78 | ## supported react versions 79 | 80 | 16-18 81 | 82 | App can override react/react-reconciler version using yarn resolutions. 83 | -------------------------------------------------------------------------------- /src/render/App.tsx: -------------------------------------------------------------------------------- 1 | import componentPath from '../common/componentPath'; 2 | import React from 'react'; 3 | import { 4 | AppComponent, 5 | WorkerRenderComponent, 6 | WorkerLike, 7 | FromWorkerMsg, 8 | FromRenderMsg, 9 | MSG_TYPE, 10 | } from '../common/types'; 11 | import { getComponentClass } from './getComponentClass'; 12 | import { noop, safeJsonParse } from '../common/utils'; 13 | import { log } from '../common/log'; 14 | 15 | class App 16 | extends React.Component< 17 | { 18 | worker: WorkerLike; 19 | entry: string; 20 | batchedUpdates: (fn: () => void) => void; 21 | }, 22 | { inited: boolean } 23 | > 24 | implements AppComponent 25 | { 26 | componentIndex = 0; 27 | componentPath = '1'; 28 | componentChildIndex = 0; 29 | componentChildIndexMap = new Map(); 30 | newComponentsPathIdMap = {}; 31 | componentNameDefaultPropsMap = {}; 32 | newComponentsIdStateMap = {}; 33 | pendingIdStateMap = {}; 34 | components: Map<string, WorkerRenderComponent> = new Map(); 35 | componentSpec = null!; 36 | 37 | constructor(props: any) { 38 | super(props); 39 | this.props.worker.addEventListener('message', this.onmessage); 40 | this.state = { 41 | inited: false, 42 | }; 43 | } 44 | onmessage = (e: any) => { 45 | const msg: FromWorkerMsg = safeJsonParse(e.data); 46 | if (msg.type !== MSG_TYPE) { 47 | return; 48 | } 49 | log('from worker', msg); 50 | const { 51 | newComponentsIdStateMap, 52 | newComponentsPathIdMap, 53 | pendingIdStateMap, 54 | newComponentNameDefaultPropsMap, 55 | } = msg; 56 | const { components, componentNameDefaultPropsMap } = this; 57 | Object.assign( 58 | componentNameDefaultPropsMap, 59 | newComponentNameDefaultPropsMap, 60 | ); 61 | for (const name of Object.keys(newComponentNameDefaultPropsMap)) { 62 | getComponentClass(name).defaultProps = 63 | newComponentNameDefaultPropsMap[name]; 64 | } 65 | this.newComponentsIdStateMap = newComponentsIdStateMap; 66 | this.newComponentsPathIdMap = newComponentsPathIdMap; 67 | 68 | this.props.batchedUpdates(() => { 69 | if (!this.state.inited) { 70 | this.setState({ 71 | inited: true, 72 | }); 73 | } 74 | for (const id of Object.keys(pendingIdStateMap)) { 75 | const state = pendingIdStateMap[id]; 76 | const component = components.get(id)!; 77 | component.setStateState(state); 78 | } 79 | }); 80 | }; 81 | postMessage(msg: FromRenderMsg) { 82 | log('send to worker', msg); 83 | this.props.worker.postMessage(JSON.stringify(msg)); 84 | } 85 | 86 | addComponent(component: WorkerRenderComponent) { 87 | this.components.set(component.id, component); 88 | } 89 | removeComponent(component: WorkerRenderComponent) { 90 | this.components.delete(component.id); 91 | } 92 | 93 | setStateState = noop; 94 | 95 | componentWillUnmount() { 96 | this.props.worker.removeEventListener('message', this.onmessage); 97 | } 98 | 99 | render(): React.ReactNode { 100 | if (this.state.inited) { 101 | const Entry = getComponentClass(this.props.entry); 102 | return componentPath.renderWithComponentContext(this, <Entry />); 103 | } else { 104 | return null; 105 | } 106 | } 107 | } 108 | 109 | componentPath.classWithContext(App); 110 | 111 | export default App; 112 | -------------------------------------------------------------------------------- /src/render/getComponentClass.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getComponentDesc } from '../common/register'; 3 | import { 4 | FromRenderMsg, 5 | MSG_TYPE, 6 | WorkerRenderComponent, 7 | } from '../common/types'; 8 | import { nativeComponents } from './nativeComponent'; 9 | import componentPath from '../common/componentPath'; 10 | import ComponentContext, { 11 | ComponentContextValue, 12 | } from '../common/ComponentContext'; 13 | import Input from './nativeComponents/Input'; 14 | import { noop } from '../common/utils'; 15 | import PureRender from 'react-addons-pure-render-mixin'; 16 | 17 | const componentClassCache: Record<string, React.ComponentClass> = {}; 18 | 19 | export function getComponentClass(name: string): React.ComponentClass { 20 | if (componentClassCache[name]) { 21 | return componentClassCache[name]; 22 | } 23 | 24 | const componentSpec = getComponentDesc(name); 25 | 26 | interface State { 27 | __state: any; 28 | __self: Component; 29 | } 30 | 31 | class Component 32 | extends React.Component<any, State> 33 | implements WorkerRenderComponent 34 | { 35 | componentIndex = 0; 36 | componentPath = ''; 37 | componentChildIndex = 0; 38 | componentChildIndexMap = new Map(); 39 | eventHandles: Record<string, () => void> = {}; 40 | componentSpec = componentSpec; 41 | componentName = name; 42 | static contextType = ComponentContext; 43 | publicInstance: any = {}; 44 | id = ''; 45 | constructor(props: any) { 46 | super(props); 47 | this.state = { 48 | __self: this, 49 | __state: {}, 50 | }; 51 | this.publicInstance = Object.create(componentSpec); 52 | Object.defineProperty(this.publicInstance, 'props', { 53 | get: this.getInstanceProps, 54 | }); 55 | Object.defineProperty(this.publicInstance, 'state', { 56 | get: this.getInstanceState, 57 | }); 58 | } 59 | 60 | shouldComponentUpdate = PureRender.shouldComponentUpdate; 61 | 62 | static getDerivedStateFromProps(_: any, { __self }: State) { 63 | const instance: Component = __self; 64 | componentPath.updateComponentPath(instance); 65 | let state; 66 | const { app } = instance.context as ComponentContextValue; 67 | if (!instance.id) { 68 | const path = componentPath.getComponentPath(instance); 69 | instance.id = app.newComponentsPathIdMap[path]; 70 | if (!instance.id) { 71 | throw new Error(`Can not find id from path: ${path}`); 72 | } 73 | app.addComponent(instance); 74 | state = app.newComponentsIdStateMap[instance.id] || {}; 75 | return { __state: state }; 76 | } 77 | return {}; 78 | } 79 | 80 | setStateState(newState: any) { 81 | this.setState(({ __state }) => { 82 | return { 83 | __state: { 84 | ...__state, 85 | ...newState, 86 | }, 87 | }; 88 | }); 89 | } 90 | 91 | getInstanceProps = () => { 92 | return this.props; 93 | }; 94 | getInstanceState() { 95 | return this.state.__state; 96 | } 97 | getContext() { 98 | return this.context as ComponentContextValue; 99 | } 100 | 101 | componentDidMount() { 102 | componentSpec.componentDidMount?.call(this.publicInstance); 103 | } 104 | 105 | callMethod = noop; 106 | 107 | componentDidUpdate(prevProps: any, prevState: State) { 108 | const { publicInstance } = this; 109 | componentSpec.componentDidUpdate?.call( 110 | publicInstance, 111 | prevProps, 112 | prevState.__state, 113 | ); 114 | } 115 | 116 | componentWillUnmount() { 117 | componentSpec.componentWillUnmount?.call(this.publicInstance); 118 | this.getContext().app.removeComponent(this); 119 | } 120 | 121 | getEventHandle = (name: string) => { 122 | const { eventHandles } = this; 123 | const { app } = this.context as ComponentContextValue; 124 | if (eventHandles[name]) { 125 | return eventHandles[name]; 126 | } 127 | eventHandles[name] = (...args: any) => { 128 | const msg: FromRenderMsg = { 129 | type: MSG_TYPE, 130 | componentId: this.id, 131 | method: name, 132 | args, 133 | }; 134 | app.postMessage(msg); 135 | }; 136 | (eventHandles as any).handleName = name; 137 | return eventHandles[name]; 138 | }; 139 | 140 | render(): React.ReactNode { 141 | const element = componentSpec.render.call({ 142 | nativeComponents, 143 | props: this.props, 144 | state: this.getInstanceState(), 145 | getEventHandle: this.getEventHandle, 146 | getComponent: getComponentClass, 147 | }); 148 | return componentPath.renderWithComponentContext(this, element); 149 | } 150 | } 151 | 152 | const C = Component as any; 153 | 154 | C.displayName = name; 155 | componentClassCache[name] = C; 156 | return C; 157 | } 158 | 159 | Object.assign(nativeComponents, { 160 | Input: (Input as any) || getComponentClass('input'), 161 | }); 162 | -------------------------------------------------------------------------------- /src/worker/App.tsx: -------------------------------------------------------------------------------- 1 | import componentPath from '../common/componentPath'; 2 | import React from 'react'; 3 | import { getComponentDesc } from '../common/register'; 4 | import { 5 | AppComponent, 6 | WorkerRenderComponent, 7 | WorkerLike, 8 | FromWorkerMsg, 9 | FromRenderMsg, 10 | MSG_TYPE, 11 | } from '../common/types'; 12 | import { getComponentClass } from './getComponentClass'; 13 | import noopRender from './noopRender'; 14 | import { cleanFuncJson, safeJsonParse } from '../common/utils'; 15 | import { log } from '../common/log'; 16 | 17 | class App 18 | extends React.Component<{ worker: WorkerLike; entry: string }> 19 | implements AppComponent 20 | { 21 | componentIndex = 0; 22 | componentPath = '1'; 23 | componentChildIndex = 0; 24 | componentChildIndexMap = new Map(); 25 | newComponentsPathIdMap: Record<string, string> = {}; 26 | newComponentsIdStateMap: Record<string, any> = {}; 27 | pendingIdStateMap: Record<string, any> = {}; 28 | newComponentIds: Set<string> = new Set(); 29 | components: Map<string, WorkerRenderComponent> = new Map(); 30 | scheduled = false; 31 | componentNameDefaultPropsMap: Record<string, string> = {}; 32 | 33 | constructor(props: any) { 34 | super(props); 35 | this.props.worker.addEventListener('message', this.onmessage); 36 | } 37 | onmessage = (e: { data: string }) => { 38 | const msg: FromRenderMsg = safeJsonParse(e.data); 39 | if (msg.type !== MSG_TYPE) { 40 | return; 41 | } 42 | log('from render', msg); 43 | const { componentId, method, args } = msg; 44 | const component = this.components.get(componentId)!; 45 | noopRender.batchedUpdates(() => { 46 | component.callMethod(method, args); 47 | }); 48 | }; 49 | postMessage(msg: FromWorkerMsg) { 50 | log('send to render', msg); 51 | this.props.worker.postMessage(JSON.stringify(msg)); 52 | } 53 | 54 | afterSendToRender() { 55 | this.pendingIdStateMap = {}; 56 | this.newComponentIds.clear(); 57 | this.newComponentsPathIdMap = {}; 58 | this.newComponentsIdStateMap = {}; 59 | } 60 | 61 | scheduleSendToRender() { 62 | if (this.scheduled) { 63 | return; 64 | } 65 | this.scheduled = true; 66 | Promise.resolve().then(() => { 67 | this.sendToRender(); 68 | this.afterSendToRender(); 69 | this.scheduled = false; 70 | }); 71 | } 72 | 73 | componentDidMount() { 74 | this.scheduleSendToRender(); 75 | } 76 | 77 | componentWillUnmount() { 78 | this.props.worker.removeEventListener('message', this.onmessage); 79 | } 80 | 81 | sendToRender() { 82 | const { 83 | components, 84 | pendingIdStateMap, 85 | newComponentsIdStateMap, 86 | newComponentsPathIdMap, 87 | componentNameDefaultPropsMap, 88 | } = this; 89 | 90 | const newComponentNameDefaultPropsMap: Record<string, string> = {}; 91 | 92 | for (const id of Object.keys(pendingIdStateMap)) { 93 | if (!components.has(id)) { 94 | delete pendingIdStateMap[id]; 95 | } 96 | } 97 | 98 | for (const id of Array.from(this.newComponentIds)) { 99 | const component = components.get(id)!; 100 | const { componentName } = component; 101 | 102 | newComponentsIdStateMap[id] = component.getInstanceState(); 103 | newComponentsPathIdMap[componentPath.getComponentPath(component)] = id; 104 | 105 | if (!componentNameDefaultPropsMap[componentName]) { 106 | componentNameDefaultPropsMap[componentName] = 107 | getComponentDesc(componentName).defaultProps || {}; 108 | newComponentNameDefaultPropsMap[componentName] = 109 | componentNameDefaultPropsMap[componentName]; 110 | } 111 | } 112 | 113 | this.postMessage({ 114 | type: MSG_TYPE, 115 | newComponentsIdStateMap: cleanFuncJson(newComponentsIdStateMap), 116 | newComponentsPathIdMap, 117 | pendingIdStateMap: cleanFuncJson(pendingIdStateMap), 118 | newComponentNameDefaultPropsMap: cleanFuncJson( 119 | newComponentNameDefaultPropsMap, 120 | ), 121 | }); 122 | } 123 | setStateState(component: WorkerRenderComponent, state: any) { 124 | if (this.newComponentIds.has(component.id)) { 125 | return; 126 | } 127 | const { pendingIdStateMap } = this; 128 | const current = pendingIdStateMap[component.id] || {}; 129 | Object.assign(current, state); 130 | pendingIdStateMap[component.id] = current; 131 | this.scheduleSendToRender(); 132 | } 133 | addComponent(component: WorkerRenderComponent) { 134 | if (!this.components.has(component.id)) { 135 | this.newComponentIds.add(component.id); 136 | } 137 | this.components.set(component.id, component); 138 | } 139 | removeComponent(component: WorkerRenderComponent) { 140 | this.newComponentIds.delete(component.id); 141 | this.components.delete(component.id); 142 | } 143 | 144 | render(): React.ReactNode { 145 | const Entry = getComponentClass(this.props.entry); 146 | return componentPath.renderWithComponentContext(this, <Entry />); 147 | } 148 | } 149 | 150 | componentPath.classWithContext(App); 151 | 152 | export default App; 153 | -------------------------------------------------------------------------------- /src/worker/getComponentClass.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { getComponentDesc } from '../common/register'; 3 | import { WorkerRenderComponent } from '../common/types'; 4 | import { nativeComponents } from './nativeComponent'; 5 | import componentPath from '../common/componentPath'; 6 | import ComponentContext, { 7 | ComponentContextValue, 8 | } from '../common/ComponentContext'; 9 | import { WorkerComponent } from './types'; 10 | import NativeInput from './nativeComponents/Input'; 11 | 12 | const componentClassCache: Record<string, React.ComponentClass> = {}; 13 | 14 | let gid = 1; 15 | 16 | export function getComponentClass( 17 | name: string, 18 | native = false, 19 | ): React.ComponentClass { 20 | if (componentClassCache[name]) { 21 | return componentClassCache[name]; 22 | } 23 | 24 | const componentSpec = getComponentDesc(name); 25 | 26 | interface State { 27 | __state: any; 28 | __self: Component; 29 | } 30 | 31 | class Component 32 | extends React.Component<any, State> 33 | implements WorkerRenderComponent 34 | { 35 | id: string; 36 | componentIndex = 0; 37 | componentPath = ''; 38 | componentChildIndex = 0; 39 | componentChildIndexMap = new Map(); 40 | eventHandles: Record<string, () => void>; 41 | componentSpec = componentSpec; 42 | publicInstance: WorkerComponent; 43 | componentName = name; 44 | 45 | static contextType = ComponentContext; 46 | 47 | static defaultProps = componentSpec.defaultProps; 48 | 49 | constructor(props: any) { 50 | super(props); 51 | this.id = ''; 52 | this.publicInstance = Object.create(componentSpec); 53 | this.publicInstance.setState = this.setStateState; 54 | Object.defineProperty(this.publicInstance, 'props', { 55 | get: this.getInstanceProps, 56 | }); 57 | Object.defineProperty(this.publicInstance, 'state', { 58 | get: this.getInstanceState, 59 | }); 60 | this.eventHandles = {}; 61 | this.state = { 62 | __self: this, 63 | __state: {}, 64 | }; 65 | if (componentSpec.getInitialState) { 66 | const state = componentSpec.getInitialState.call(this.publicInstance); 67 | if (state) { 68 | (this.state as State).__state = state; 69 | } 70 | } 71 | } 72 | 73 | shouldComponentUpdate(nextProps: any, nextState: State) { 74 | if (componentSpec.shouldComponentUpdate) { 75 | return componentSpec.shouldComponentUpdate.call( 76 | this.publicInstance, 77 | nextProps, 78 | nextState.__state, 79 | undefined, 80 | ); 81 | } 82 | return true; 83 | } 84 | 85 | callMethod(method: string, args: any[]): void { 86 | const publicInstance: any = this.publicInstance; 87 | publicInstance[method](...args); 88 | } 89 | 90 | setStateState = ( 91 | newState: any, 92 | callback?: () => void, 93 | sendToRender = true, 94 | ) => { 95 | if (!native) { 96 | sendToRender = true; 97 | } 98 | this.setState(({ __state }) => { 99 | let retState: any = {}; 100 | if (typeof newState === 'function') { 101 | retState = newState(__state); 102 | } else { 103 | retState = newState; 104 | } 105 | if (sendToRender) { 106 | this.getContext().app.setStateState(this, retState); 107 | } 108 | return { 109 | __state: { 110 | ...__state, 111 | ...retState, 112 | }, 113 | }; 114 | }, callback); 115 | }; 116 | 117 | static getDerivedStateFromProps(nextProps: any, { __self }: State) { 118 | const instance: Component = __self; 119 | componentPath.updateComponentPath(instance); 120 | const { app } = instance.context as ComponentContextValue; 121 | if (!instance.id) { 122 | instance.id = ++gid + ''; 123 | app.addComponent(instance); 124 | } 125 | if (instance.componentSpec.getDerivedStateFromProps) { 126 | const state = instance.getInstanceState(); 127 | const newState = instance.componentSpec.getDerivedStateFromProps( 128 | nextProps, 129 | state, 130 | ); 131 | return { 132 | __state: { 133 | ...state, 134 | ...newState, 135 | }, 136 | }; 137 | } 138 | return {}; 139 | } 140 | 141 | getContext() { 142 | return this.context as ComponentContextValue; 143 | } 144 | 145 | componentDidMount() { 146 | componentSpec.componentDidMount?.call(this.publicInstance); 147 | } 148 | 149 | componentDidUpdate(prevProps: any, prevState: any) { 150 | const { publicInstance } = this; 151 | componentSpec.componentDidUpdate?.call( 152 | publicInstance, 153 | prevProps, 154 | prevState.__state, 155 | ); 156 | } 157 | 158 | componentWillUnmount() { 159 | componentSpec.componentWillUnmount?.call(this.publicInstance); 160 | this.getContext().app.removeComponent(this); 161 | } 162 | 163 | getInstanceProps = () => { 164 | return this.props; 165 | }; 166 | getInstanceState = () => { 167 | return this.state.__state; 168 | }; 169 | 170 | getEventHandle = (name: string) => { 171 | const { eventHandles } = this; 172 | if (eventHandles[name]) { 173 | return eventHandles[name]; 174 | } 175 | const publicInstance = this.publicInstance as any; 176 | const handle: any = (...args: any) => { 177 | publicInstance[name](...args); 178 | }; 179 | handle.handleName = name; 180 | eventHandles[name] = handle; 181 | return handle; 182 | }; 183 | 184 | render(): React.ReactNode { 185 | const element = componentSpec.render.call({ 186 | nativeComponents, 187 | props: this.props, 188 | state: this.getInstanceState(), 189 | getEventHandle: this.getEventHandle, 190 | getComponent: getComponentClass, 191 | }); 192 | return componentPath.renderWithComponentContext(this, element); 193 | } 194 | } 195 | 196 | const C = Component as any; 197 | 198 | C.displayName = name; 199 | componentClassCache[name] = C; 200 | return C; 201 | } 202 | 203 | Object.assign(nativeComponents, { 204 | Input: (NativeInput as any) || getComponentClass('input', true), 205 | }); 206 | --------------------------------------------------------------------------------