实现 React 的 Playground(1)

665 阅读2分钟

Playground就是在页面上,左侧编码右侧实时编译并且显示内容,好处是不用再切换编辑器了。

类似 vue 的 playground

原理看看这个:

1.html字符串加入script标签


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>

<script>
    const code2 = `
      function add(a, b){
        return a+b;
      }

      console.log(add(4, 9));
    `;

    const script = document.createElement('script');
    script.textContent = code2;
    document.body.appendChild(script);
</script>
</body>
</html>

执行结果

image.png

就是在字符串里面写一段代码,创建一个script标签,把这个字符串加入script标签里面,你会发现它居然执行。这就是Playground的实现原理,左侧是字符串,把左侧的字符串编译以后,你会得到右侧的展示效果。

2.html里面加入import

如果你想拿到module文件就这样做

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>

<script>
    const addModule =`
    function add(a, b) {
        return a + b;
    }
    export { add };
    `;

    const url = URL.createObjectURL(new Blob([addModule], { type: 'application/javascript' }));
    const code2 = `import { add } from "${url}";

    console.log(add(2, 3));`;

    const script = document.createElement('script');
    script.type="module";
    script.textContent = code2;
    document.body.appendChild(script);
</script>
</body>
</html>

执行结果:

image.png

现在基础模板有了,那就在react里面实现一下看看

react里面实现,最主要的就是要编辑这个字符串了,是不是,怎么认识的babel上场了,但是我们认识的babel只在node环境下运行,它有没有工具能帮我们在非node下面编译呢?找了一圈终于找到了叫:@babel/standalone

3.开始react

现在你要用脚手架创建一个react项目,然后开始我们的第一步,改造app,编译一段字符串看看行不行

npx create-vite
cd 项目
npm install
npm run dev

4.组件使用代码编译

在app.tsx里main写一段代码看看

import { useRef, useState } from 'react';
import { transform } from '@babel/standalone';//@babel/standalone可以在非 Node.js 环境(比如浏览器环境)自动编译含有 text/babel 或 text/jsx 的 type 值的 script标签,并进行编译
import './app.css';

function App() {

  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const textareaRef2 = useRef<HTMLTextAreaElement>(null);

  function onClick() {
    if (!textareaRef.current) {
      return;
    }

    const res = transform(textareaRef.current.value, {
      presets: ['react', 'typescript'],
      filename: 'snow.tsx'
    });
    console.log(res.code);

    if (textareaRef2.current) {
      textareaRef2.current.defaultValue = res.code;
    }
  }

  const code = `import { useEffect, useState } from "react";

  function App() {
    const [num, setNum] = useState(() => {
      const num1 = 1 + 2;
      const num2 = 2 + 3;
      return num1 + num2
    });
  
    return (
      <div onClick={() => setNum((prevNum) => prevNum + 1)}>{num}</div>
    );
  }
  
  export default App;
  `;
  return (
    <div>
      <div className="title">编译之前</div>
      <textarea ref={textareaRef} style={{ width: '500px', height: '300px' }} defaultValue={code}></textarea>
      <div style={{ margin: "16px 0" }}> <button onClick={onClick}>编译</button></div>
      <div className="title">编译之后</div>
      <textarea ref={textareaRef2} style={{ width: '500px', height: '300px' }}></textarea>
    </div>
  );
}

export default App;

页面如下: 点击编译按钮,在下面的textares里面展示数据就好了。

image.png

5.React替换文件地址

编译后你发现from后面的地址还是react,这个地址一定是找不到的,因为在上面的html里面,他的import 的地址长这样:

image.png

blob在我的文章里面应该很熟悉了吧,文件下载的时候是不是用到过?new Worker()的时候是不是也用过,他就是把字符串转成二进制,然后我们就可以用window.URL.createObjectURl()开心的拿到字符串在内存里面的地址了。

URL.createObjectURL(new Blob([code], { type: 'application/javascript' }))

先看看普通文件引入的时候,如何获得url,然后在看react包获取内存url

改造下

import { useRef, useState } from 'react';
import { transform } from '@babel/standalone';//@babel/standalone可以在非 Node.js 环境(比如浏览器环境)自动编译含有 text/babel 或 text/jsx 的 type 值的 script标签,并进行编译
import './app.css';

function App() {

  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const textareaRef2 = useRef<HTMLTextAreaElement>(null);

  const code1 = `
    function add(a, b) {
        return a + b;
    }
    export { add };
  `;

  const url = URL.createObjectURL(new Blob([code1], { type: 'application/javascript' }));
  const transformImportSourcePlugin: any = {
    visitor: {
      ImportDeclaration(path: any) {
        path.node.source.value = url;
      }
    },
  };

  const code = `import { add } from './add.ts'; console.log(add(2, 3));`;


  function onClick() {
    if (!textareaRef.current) {
      return;
    }

    const res = transform(code, {
      presets: ['react', 'typescript'],
      filename: 'add.ts',
      plugins: [transformImportSourcePlugin]
    });

    if (textareaRef2.current) {
      textareaRef2.current.defaultValue = res.code;
    }
  }
  return (
    <div>
      <div className="title">编译之前</div>
      <textarea ref={textareaRef} style={{ width: '500px', height: '300px' }} defaultValue={code}></textarea>
      <div style={{ margin: "16px 0" }}> <button onClick={onClick}>编译</button></div>
      <div className="title">编译之后</div>
      <textarea ref={textareaRef2} style={{ width: '500px', height: '300px' }}></textarea>
    </div>
  );
}

export default App;

测试

image.png

在这里不管import后面的文件名是add.ts还是minus.js,最终它都会被替换成window的内存地址。

image.png

6.React替换包地址

至始至终我们最想解决的问题是:import { useEffect, useState } from "react",你想下,解决了react的包,我们是不是就相当于解决很多外部引入包的问题?

你想下react,lodash,redux这些包加起来,那么大,我总不可能都放到浏览器的内存里面吧,是不是很搞笑,那有没有缓存包的地方,我去调用下就好了呀,你还别说真的有,他叫:

这个 esm.sh 就是专门提供 esm 模块的 CDN 服务

image.png

案例如下

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <script type="importmap">
        {
            "imports": {
                "react": "https://esm.sh/react@18.2.0"
            }
        }
    </script>
    <script type="module">
        import React from "react";

        console.log(React);
    </script>
</body>
</html>

现在我们终于可以开心的开发代码了,引入本地文件用window.URL.createObjectURL,引入包文件用esm.sh。咱们试试看。

import { useRef, useState } from 'react';
import { transform } from '@babel/standalone';//@babel/standalone可以在非 Node.js 环境(比如浏览器环境)自动编译含有 text/babel 或 text/jsx 的 type 值的 script标签,并进行编译
import './app.css';

function App() {

  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const textareaRef2 = useRef<HTMLTextAreaElement>(null);

  const code1 = `
    function add(a, b) {
        return a + b;
    }
    export { add };
  `;

  const url = URL.createObjectURL(new Blob([code1], { type: 'application/javascript' }));
  const transformImportSourcePlugin: any = {
    visitor: {
      ImportDeclaration(path: any) {
        console.log(path.node.source.value, 9999);
        if (path.node.source.value !== 'react') {
          path.node.source.value = url;
        }
      }
    },
  };

  const code = `
  import add from './add.ts';
  import { useEffect, useState } from "react";

  function App() {
    const [num, setNum] = useState(0);
  
    return (
      <div onClick={() => setNum(() => add(9,9))}>{num}</div>
    );
  }
  
  export default App;
  `;

  const reactImport = `{
    "imports": {
      "react": "https://esm.sh/react@18.2.0"
    }
  }`;


  function onClick() {
    if (!textareaRef.current) {
      return;
    }
    const script = document.createElement('script');
    script.type = "importmap";
    script.textContent = reactImport;
    document.body.appendChild(script);

    const res = transform(textareaRef.current.value, {
      presets: ['react', 'typescript'],
      filename: 'snow.tsx',
      plugins: [transformImportSourcePlugin]
    });

    console.log(res.code, 999);

    if (textareaRef2.current) {
      textareaRef2.current.defaultValue = res.code;
    }
  }
  return (
    <div>
      <div className="title">编译之前</div>
      <textarea ref={textareaRef} style={{ width: '500px', height: '300px' }} defaultValue={code}></textarea>
      <div style={{ margin: "16px 0" }}> <button onClick={onClick}>编译</button></div>
      <div className="title">编译之后</div>
      <textarea ref={textareaRef2} style={{ width: '500px', height: '300px' }}></textarea>
    </div>
  );
}

export default App;

测试结果如下:

image.png

完美的解决了路径问题。

7.@monaco-editor/react来编辑react

npm install @monaco-editor/react

使用

import Editor from '@monaco-editor/react';
<Editor height="500px" defaultLanguage="javascript" defaultValue={code} />;

image.png

使用这个编辑器以后是不是又离我们的目标近了一点?左侧的编辑好了,右侧的预览还差一点

8.react的预览雏形

在第六节,替换包名称用下面这个方法

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <script type="importmap">
        {
            "imports": {
                "react": "https://esm.sh/react@18.2.0"
            }
        }
    </script>
    <script type="module">
        import React from "react";

        console.log(React);
    </script>
</body>
</html>

我们用这个方式写个简单的react样例看看:创建iframe.html

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Preview</title>
</head>
<body>
<script type="importmap">
  {
    "imports": {
      "react": "https://esm.sh/react@18.2.0",
      "react-dom/client": "https://esm.sh/react-dom@18.2.0"
    }
  }
</script>
<script>

</script>
<script type="module">
  import React, {useState, useEffect} from 'react';
  import ReactDOM from 'react-dom/client';

  const App = () => {
    return React.createElement('div', null, '3333333333');
  };

  window.addEventListener('load', () => {
    const root = document.getElementById('root')
    ReactDOM.createRoot(root).render(React.createElement(App, null))
  })
</script>
<div id="root">
</div>
</body>
</html>

你用live-server打开这个html文件,页面展示如下:

image.png

这就是预览的雏形。

对比这种加载react

<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8"/>
  <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
  <title>Preview</title>
</head>
<body>
  
<!-- 引入React -->
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
<!-- 引入React DOM -->
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
<!-- 引入Babel,用于将JSX转换为JavaScript -->
<script src="https://unpkg.com/babel-standalone@6/babel.min.js"></script>

<script type="text/babel">
  // 定义一个简单的函数组件
  function HelloComponent() {
    return <h1>ttttttttttttttt</h1>;
  }
 
  ReactDOM.render(<HelloComponent />, document.getElementById('root'));
</script>
<div id="root">
</div>
</body>
</html>


他只从静态库里面拿react的包和react-dom的包,这样做的不好处就是你使用useState还得专门再去编译一遍,根本就没有上面这个esm.sh的方式好。

9.实时渲染

之前页面上渲染的是3333333333,是不是我们把这个替换成自己的写在编辑器里面的代码就好了?试试看: 现在我们要做的是把刚才这个iframe.html用字符串的形式引到我的react组件里面来。

我们打算做这样一个东西

image.png

代码如下:

image.png

现在依照上面的代码我们已经把基本的雏形搭建出来了。

接下来就我是把编辑区的代码带过来执行了。

10.打通编辑和预览

实现这个效果

image.png

代码如下:

editor.tsx文件

import React, { useContext, useEffect } from 'react';
import Editor from '@monaco-editor/react';
import { CodeContext } from '../app';


function EditComoinent() {

  const { setCode } = useContext(CodeContext);

  const code = `
  import React,{ useEffect, useState } from "react";

   const App = () => {
    return React.createElement('div', null, '3333333333');
  };
`;

  useEffect(() => {
    setCode(code);
  }, []);

  return <Editor height="500px" defaultLanguage="javascript" defaultValue={code} />;
};

export default EditComoinent;

Reader.tsx

import React, { useContext, useEffect, useState } from 'react';
import { CodeContext } from '../app';
import iframeRaw from '../iframe.html?raw';

const Preview: React.FC = () => {
  const { code } = useContext(CodeContext);
  const [url, setUrl] = useState('');

  useEffect(() => {
    const code1 = `
      import ReactDOM from 'react-dom/client';
      ${code}
    
      window.addEventListener('load', () => {
        const root = document.getElementById('root')
        ReactDOM.createRoot(root).render(React.createElement(App, null))
      })
    `;

    const str = iframeRaw.replace(`<script type="module"></script>`, `<script type="module">${code1}</script>`);
    console.log(str, 4546);
    const iframeUrl = URL.createObjectURL(new Blob([str], { type: 'text/html' }));

    setUrl(() => iframeUrl);
  }, [code]);

  return (
    <iframe
      src={url}
      style={{
        width: '100%',
        height: '100%',
        padding: 0,
        border: 'none'
      }}
    />
  );
};

export default Preview;

app.tsx

import { useRef, useState, createContext } from 'react';
import Editor from './components/Editor';
import Reader from './components/Reader';
import './app.css';

interface IContext {
  code?: string;
  setCode?: any;
}

export const CodeContext = createContext<IContext>({});

function App() {
  const [code, setCode] = useState('');

  return (
    <CodeContext.Provider value={{ setCode, code }}>
      <div class="container">
        <div class="content">
          <div>
            <h1>编辑</h1>
          </div>
          <div class="editor-content">
            <Editor></Editor>
          </div>
        </div>
        <div class="content">
          <div>
            <h1>预览</h1>
          </div>
          <div class="reader-content">
            <Reader></Reader>
          </div>
        </div>
      </div>
    </CodeContext.Provider>
  );
}

export default App;

一个简易版的playground就好了!

总结:

  1. 在代码里面创建一个script标签,然后在这个标签里面加上字符模板定义的代码,然后用append把标签添加到body下面,这个代码是会被浏览器执行的。

2.外部插进来的代码是字符串,我们可以用window.URL.createObjectURL()拿到他的内存地址,然后import 导入即可。

3.浏览器编译react,我们使用 '@babel/standalone';

4.esm.sh 就是专门提供 esm 模块的 CDN 服务

5.这样引入包

<script type="importmap">
{ "imports": { "react": "https://esm.sh/react@18.2.0" } }
</script>

6.react的编译器是# @monaco-editor/react

7.react的预览器用的iframe,我们把编译过的代码丢给iframe,它会自动执行。

8.用字符串的形式导入html用:import iframeRaw from '../iframe.html?raw';

最主要解决了2个问题,一个是字符串代码的引入地址,另一个是包的引入方式。其他问题代码都是字符串,只要编译过,浏览器就会执行。

参考文案

React Playground 项目实战:需求分析、实现原理

感谢神光的编程秘籍,要我学到了很多东西,这是本人的学习笔记,有用就拿,没用跳过!