如何从零开始撸一个 react 单文件组件~(2)

1,706 阅读8分钟

同样的工作还需要再来一次

在第二篇章的开始,首先要做的事就是忘记上一章写过的代码,但一定要记得 string -> tokens -> ast 这么一个流程,对于实际的项目中,这个流程会比理论上复杂很多,但整体总会遵循这么一个规则,然后正式开始~

首先脚本运行 npx create-react-app my-app

反正就叫 my-app 就好了,主要还是用这个项目进行测试,顺便附带官方文档

继续脚本运行

 cd my-app
 npm run eject

好了,一个测试用 react 项目就创建完成了,是不是非常简单呢~

一个小 tip,一般情况下,如果需要做 eject 之后相关的测试的话,非常建议先用 git commit 一下 eject 之后的代码,至于有什么用途嘛(手动斜眼

做完上面的事情之后,就可以得到了这么一个文件结构的目录

大多数情况下只需要关心 webpack.config.js 这样一个文件,当然 react 中的这个文件和之前精简版的内容复杂度是完全不一样的,其它的文件基本上也都是在为 webpack.config.js 服务,所以,接下来需要新建 loader/remix-loader.js 并且写入

module.exports = function(source) {
    console.log('source:', source)
    return source;
};

一个和之前一模一样的不做任何内容的 loader,虽然这个 loader 好像和其它对项目重名了,但我感觉目前应该无所谓吧,贴合项目本身但命名感觉会更好一点~

那么问题来了,这个 loader,要如果被正确的引用呢?如果是第一次 eject 代码或者做相关调试的话,对于这个 webpack.config.js 代码应该是一脸迷茫吧大概 orz

不过在忽略一些这次不需要关注的配置之后,大概应该可以找到和精简版类似的数据结构,类似于

{
    ...
    module: {
        ...
        rules: [ ... ],
        ...
    },
    ...
}

虽然多了一些看起来没什么用的代码(大雾),但总归是可以找到 rules 这样一个节点,在忽略了其它配置之后,可以对 webpack.config.js 做这样一个改造

diff --git a/config/webpack.config.js b/config/webpack.config.js
index c058190..66c8513 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -362,35 +362,38 @@ module.exports = function(webpackEnv) {
             // Process application JS with Babel.
             // The preset includes JSX, Flow, TypeScript, and some ESnext features.
             {
-              test: /\.(js|mjs|jsx|ts|tsx)$/,
+              test: /\.(js|mjs|jsx|ts|tsx|rex)$/,
               include: paths.appSrc,
-              loader: require.resolve('babel-loader'),
-              options: {
-                customize: require.resolve(
-                  'babel-preset-react-app/webpack-overrides'
-                ),
-                
-                plugins: [
-                  [
-                    require.resolve('babel-plugin-named-asset-import'),
-                    {
-                      loaderMap: {
-                        svg: {
-                          ReactComponent:
-                            '@svgr/webpack?-svgo,+titleProp,+ref![path]',
+              use: [{
+                loader: require.resolve('babel-loader'),
+                options: {
+                  customize: require.resolve(
+                    'babel-preset-react-app/webpack-overrides'
+                  ),
+                  plugins: [
+                    [
+                      require.resolve('babel-plugin-named-asset-import'),
+                      {
+                        loaderMap: {
+                          svg: {
+                            ReactComponent:
+                              '@svgr/webpack?-svgo,+titleProp,+ref![path]',
+                          },
                         },
                       },
-                    },
+                    ],
                   ],
-                ],
-                // This is a feature of `babel-loader` for webpack (not Babel itself).
-                // It enables caching results in ./node_modules/.cache/babel-loader/
-                // directory for faster rebuilds.
-                cacheDirectory: true,
-                // See #6846 for context on why cacheCompression is disabled
-                cacheCompression: false,
-                compact: isEnvProduction,
-              },
+                  // This is a feature of `babel-loader` for webpack (not Babel itself).
+                  // It enables caching results in ./node_modules/.cache/babel-loader/
+                  // directory for faster rebuilds.
+                  cacheDirectory: true,
+                  // See #6846 for context on why cacheCompression is disabled
+                  cacheCompression: false,
+                  compact: isEnvProduction,
+                },
+              }, {
+                loader: './loader/remix-loader'
+              }],
             },
             // Process any JS outside of the app with Babel.
             // Unlike the application JS, we only compile the standard ES features.

diff 文件看起来有些复杂的原因是因为出现了缩进相关的改动,实际上的改动只有那么一丢丢啊喂

使用 ide 的 diff 果然看起来舒服多了,顺便这里就是之前提到的为什么 eject 之后建议 commit 一下的原因 orz(方便 diff

注意事项要来了哈

1.webpack 的 loader 加载机制是从下到上加载的,即下面 loader 的处理结果丢给上一个 loader
2.webpack 的 loader 加载机制是从下到上加载的 ...
3. ... 从下到上加载的 ...

从下到上的加载机制有那么一丢丢反直觉,想我这种有时候懒得翻文档的人就踩坑了 orz

另外,loader 的引入暂时就先这么写吧,因为懒~

至少目前来看,我们又恢复了上一期视频,呸,上一篇文章的状态,可以专注去实现一个 string -> ... -> js string 这样一个 loader 了噢耶

那么再来定这么一个小目标~

好吧,因为上一个实验性质的 loader 只是非常简单的用正则匹配关键字然后生成 js 代码,没有任何价值的产物。而这次的 loader,可能稍稍会有那么一丢丢的价值。

首先必须要明确一下,因为在 source 阶段,loader 可以拿到所有的源码的字符串,所以要确定哪些代码是在 loader 时期被解决的,哪些代码是在其它时候被解决的,这个非常非常重要。

原则上,loader 应该只做 string -> js string 这样一个转换的过程,但是目前为止,手头上但工具只有 loader, 所以只能先混杂在一起简单处理了

这次要处理但 .rex 文件,大概长这么个样子,看起来比之前复杂了不少呢(手动斜眼

import React from 'react'

@[COMPONENT]

const props = @{RexProps}
const [count, setCount] = React.useState(0)

const onAddCount = () => {
    setCount(x => x + 1)
}

@[RENDER] |> count === 2

<div className="name">render by condition</div>
<div>count: {count}</div>
<div onClick={onAddCount}>add count</div>

@[RENDER]

<div className="name">
    render
    <span> {props.name || 'none' }</span>
</div>
<div>count: {count}</div>
<div onClick={onAddCount}>add count</div>


@[STYLE]

.name {
    color: red;
    
}
.name span {
    color: ${count === 4 ? 'yellow' : 'blue' }
}

如果不出意外的话,编译之后的 js string 的期望大概是下面的结果(简略版

export default function(__REX_PROPS__) {
    const props = __REX_PROPS__
    ... // component code
    
    const __REX_STYLES__ = ` ... ` // style code

    if (count === 2) {
        return (
            <React.Fragment>
            ... // condition render code
            <style jsx>{__REX_STYLES__}</style>
            </React.Fragment>
        )
    }
    return (
        <React.Fragment>
        ... // render code
        <style jsx>{__REX_STYLES__}</style>
        </React.Fragment>
    )
}

和最开始的大目标相比,这里为了方便实现,做了一些简单的修改

  • 使用 @{RexProps} 特殊标示获得 props
  • 魔改管道操作符 |> 的定义,暂时只作为 render 的判断条件使用
  • css 使用 styled-jsx 的方案

好啦,这次的目标就是,可以让这部分代码顺利执行~

如何实现一个正经一点的 loader

作为一个正经的 loader,肯定需要正经的去解析相关的自定义语法了。感觉上和之前一样,还是 string -> ... -> js string 这样一个流程,只不过需要解析的字符串会复杂很多,所以,这个时候就需要去到什么地方 copy 一个算法来实现需求,能在巨人的肩膀上就不要自己慢慢爬了(笑

分享一个可以 copy 的算法:github.com/starkwang/t…

有兴趣的可以学习一下,没兴趣的可以像我一样直接 copy(手动斜眼

const initialToken = {
    type: 'source',
    value: '',
    args: {},
    pipes: [],
}

function clone(obj) {
    return JSON.parse(JSON.stringify(obj));
}

function getTokens(input) {
    const tokens = []
    let cur = 0
    let token = clone(initialToken)
    while(cur < input.length) {
        let char = input[cur]
        // 解析 @[module(key='value')]
        if (char === '@' && input[cur + 1] === '[') {
            let str = ''
            char = input[++cur] // 当前 char: '['
            while(char !== ']' && cur < input.length) {
                char = input[++cur]
                if (char === '\'') {
                    // 归一化引号,便于格式化
                    char = '"'
                }
                str += char
            }
            // str 归一化为小写, 去掉结尾的 ']'
            str = str.toLocaleLowerCase().slice(0, -1)
            // 设置上一个 module 的 token, 并且重置数据
            tokens.push(token)
            token = clone(initialToken)

            // str: MODULE(agrs='value')
            const m = str.split(/\(|\)/).map(x => x.trim()).filter(Boolean)
            token.type = m[0]

            // 判断是否存在 args
            if (m[1]) {
                const s = m[1].split(/\,|=/).map(x => x.trim()).filter(Boolean)
                for (let i = 0; i < s.length; i += 2) {
                    const key = s[i];
                    const value = JSON.parse(s[i + 1])
                    token.args[key] = value
                }
            }
            cur++ // 跳过结尾的 ']'
            continue            
        }
        // 解析 |> 操作符
        if (char === '|' && input[cur + 1] === '>') {
            let str = ''
            cur += 2
            char = input[cur] // 当前 char: '>' + 1
            // 跳过空白字符
            while(char.match(/\s/) && cur < input.length) {
                char = input[++cur]
            }
            let deep = 0
            // 匹配管道操作符
            // 因为这里只匹配表达式,所以匹配做了简单修改 \s -> \n
            while((deep || !char.match(/\n/)) && cur < input.length) {
                str += char
                // 判断括号是否匹配完成
                if (char.match(/\(|\[|\{/)) {
                    deep++
                }
                if (char.match(/\)|\]|\}/)) {
                    deep--
                }
                char = input[++cur]
            }
            token.pipes.push(str)
            continue
        }
        token.value += char
        cur++
    }
    tokens.push(token)
    return tokens
}

const REX_PROPS = '__REX_PROPS__'
const REX_STYLES = '__REX_STYLES__'

function parseTokens(tokens) {
    let component = ''
    let renders = ''
    let source = ''
    let styles = ''
    for (const token of tokens) {
        if (token.type === 'source') {
            // 设置 source code
            source += token.value
        }
        if (token.type === 'component') {
            // 设置 component code, 并且替换 @{RexProps}
            component = token.value.replace('@{RexProps}', REX_PROPS)
        }
        if (token.type === 'render') {
            // 设置 render code
            let render = `return (
                <React.Fragment>
                    ${token.value}
                    <style jsx>{\`${REX_STYLES}\`}</style>
                </React.Fragment>
            )`
            if (token.pipes.length) {
                // 临时使用 pipes[0] 作为是否 render 的条件
                const condition = token.pipes[0]
                render = `if (${condition}) {${render}}`
            }
            render = `\n${render}\n`
            renders += render
        }
        if (token.type === 'style') {
            // 设置 styles code
            styles += token.value
        }
    }
    // 全局替换 __REX_STYLES__ 占位符
    renders = renders.replace(/__REX_STYLES__/g, styles)
    const func = `export default function (${REX_PROPS}) {
        ${component}
        ${renders}
    }`

    source += func
    return source
}

module.exports = function(source) {
    // 只处理 rex 后缀的文件
    if (!this.resourcePath.endsWith('.rex')) {
        return source
    }
    const tokens = getTokens(source)
    const parsedSource = parseTokens(tokens)
    return parsedSource
};

虽然还不是最正经的 loader,但和之前但那个相比,这个已经好了很多了,然后,我讨厌算法,这种代码没有什么可以分析解读的 = =

哦对了,为了支持 styled-jsx,所以需要先运行脚本 yarn add styled-jsx,然后去更改 webpack babel 的配置,就像这样

附上文档链接~ github.com/vercel/styl…

这里遇到了某个不符合期望的地方,就是 styled-jsx 的使用方案并没有完全符合预期,虽然可能提供了符合预期的方案,但使用 styled-jsx 并不是最终期望的结果,不然这个项目的乐趣就少了一大半

不要问我哪里不符合期望,编译结果和期望结果对比一下就知道了 orz

这个测试用项目就不放出源码了,因为改动太少了

出去 loader 之后完整的 diff 如下

diff --git a/config/webpack.config.js b/config/webpack.config.js
index c058190..95a5990 100644
--- a/config/webpack.config.js
+++ b/config/webpack.config.js
@@ -362,35 +362,39 @@ module.exports = function(webpackEnv) {
             // Process application JS with Babel.
             // The preset includes JSX, Flow, TypeScript, and some ESnext features.
             {
-              test: /\.(js|mjs|jsx|ts|tsx)$/,
+              test: /\.(js|mjs|jsx|ts|tsx|rex)$/,
               include: paths.appSrc,
-              loader: require.resolve('babel-loader'),
-              options: {
-                customize: require.resolve(
-                  'babel-preset-react-app/webpack-overrides'
-                ),
-                
-                plugins: [
-                  [
-                    require.resolve('babel-plugin-named-asset-import'),
-                    {
-                      loaderMap: {
-                        svg: {
-                          ReactComponent:
-                            '@svgr/webpack?-svgo,+titleProp,+ref![path]',
+              use: [{
+                loader: require.resolve('babel-loader'),
+                options: {
+                  customize: require.resolve(
+                    'babel-preset-react-app/webpack-overrides'
+                  ),
+                  plugins: [
+                    require.resolve("styled-jsx/babel"),
+                    [
+                      require.resolve('babel-plugin-named-asset-import'),
+                      {
+                        loaderMap: {
+                          svg: {
+                            ReactComponent:
+                              '@svgr/webpack?-svgo,+titleProp,+ref![path]',
+                          },
                         },
                       },
-                    },
+                    ],
                   ],
-                ],
-                // This is a feature of `babel-loader` for webpack (not Babel itself).
-                // It enables caching results in ./node_modules/.cache/babel-loader/
-                // directory for faster rebuilds.
-                cacheDirectory: true,
-                // See #6846 for context on why cacheCompression is disabled
-                cacheCompression: false,
-                compact: isEnvProduction,
-              },
+                  // This is a feature of `babel-loader` for webpack (not Babel itself).
+                  // It enables caching results in ./node_modules/.cache/babel-loader/
+                  // directory for faster rebuilds.
+                  cacheDirectory: true,
+                  // See #6846 for context on why cacheCompression is disabled
+                  cacheCompression: false,
+                  compact: isEnvProduction,
+                },
+              }, {
+                loader: './loader/remix-loader'
+              }],
             },
             // Process any JS outside of the app with Babel.
             // Unlike the application JS, we only compile the standard ES features.

diff --git a/package.json b/package.json
index a6b1dca..428455e 100644
--- a/package.json
+++ b/package.json
@@ -53,6 +53,7 @@
     "sass-loader": "8.0.2",
     "semver": "6.3.0",
     "style-loader": "0.23.1",
+    "styled-jsx": "^3.3.0",
     "terser-webpack-plugin": "2.3.5",
     "ts-pnp": "1.1.6",
     "url-loader": "2.3.0",
diff --git a/src/App.js b/src/App.js
index ce9cbd2..ab8ef09 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,10 +1,12 @@
 import React from 'react';
 import logo from './logo.svg';
 import './App.css';
+import Test from './test.rex'
 
 function App() {
   return (
     <div className="App">
+      <Test name="test name" />
       <header className="App-header">
         <img src={logo} className="App-logo" alt="logo" />
         <p>
diff --git a/src/test.rex b/src/test.rex
new file mode 100644
index 0000000..9269556
--- /dev/null
+++ b/src/test.rex
@@ -0,0 +1,36 @@
+import React from 'react'
+
+@[COMPONENT]
+
+const props = @{RexProps}
+const [count, setCount] = React.useState(0)
+
+const onAddCount = () => {
+    setCount(x => x + 1)
+}
+
+@[RENDER] |> count === 2
+
+<div className="name">render by condition</div>
+<div>count: {count}</div>
+<div onClick={onAddCount}>add count</div>
+
+@[RENDER]
+
+<div className="name">
+    render
+    <span> {props.name || 'none' }</span>
+</div>
+<div>count: {count}</div>
+<div onClick={onAddCount}>add count</div>
+
+
+@[STYLE]
+
+.name {
+    color: red;
+    
+}
+.name span {
+    color: ${count === 4 ? 'yellow' : 'blue' }
+}
\ No newline at end of file

如果要求不高的话,这种代码稍微改改优化一下,基本上可以达到可以用的程度了,但这种没有经过测试和验证的代码并没有什么真正意义上的价值 = =

这里可以列举一下这种方案的一些问题,所以如果还有之后的 part 的话应该就是要解决下面这些问题(手动斜眼

1. 不能买足日常开发的需求(实际上这个是很容易解决的
2. 直接对源码操作太过危险(只用 loader 的情况下只能先用这种方案 orz
3. 果然还是想自己动手撸一个 css-in-js 方案

如果继续开发的这个组件的话,css 方案应该会继续使用 styled-jsx 虽然是我第一次使用这个库,但肯定比我自己手撸的要好用,对,我需要更多的调研~

好啦,可以试着走一次发布了~

从不知道哪里的项目中 copy 一些发布用文件,然后直接 npm publish,这个过程是需要登陆的,具体 npm 发布流程请各位大佬自行百度 orz

项目地址在这里~ github.com/frontend-ki…

实际上就是把之前写的代码 copy 了进去

然后就可以按照正常使用 webpack loader 的方式去使用了,但是呢!通过正常的方式很难去修改 node_modules 里面的 webpack.config.js,所以这么为了应对这么常见的功能,已经有大佬写了一些库来帮助我们,反正随便选一个就行,这里就使用常用的 react-app-rewired(第一次用这个,之前一直在用 carco,反正差不多的样子 orz

然后脚本执行

npx create-react-app my-app
cd my-app
yarn add styled-jsx react-app-rewired remix-loader@0.0.2

不要问我为什么锁了版本,不要问我为什么版本是 0.0.2,问就是不知道 orz(因为翻车了一次

之后按照 react-app-rewired 在根目录创建 config-overrides.js,写入内容

const includeRegexp = /\.(js|mjs|jsx|ts|tsx)$/

module.exports = function(config, env) {
    for (const rule of config.module.rules) {
        if (rule.oneOf) {
            for (const r of rule.oneOf) {
                if (String(r.test) === String(includeRegexp)) {
                    const l = {
                        loader: r.loader,
                        options: r.options,
                    }
                    l.options.plugins.push(require.resolve("styled-jsx/babel"),)
                    delete r.loader
                    delete r.options
                    r.test = /\.(js|mjs|jsx|ts|tsx|rex)$/
                    r.use = [l, {
                        loader: require.resolve('remix-loader')
                    }]
                }
            }
        }
    }
    return config;
}

对,就是把之前直接修改 config 的方式换成了修改 json 节点的方式,直觉告诉我这种修改的时效性应该是十分有限的(万一官方更新了 webpack.config.js 的节点结构,这个肯定就不能用了 orz

配置就只有这么多,剩下就可以当作是一个正常的项目来使用 .rex 文件了

最后附上大量的 diff 文件,基本上除了 config-overrides.js 都是重复内容

diff --git a/config-overrides.js b/config-overrides.js
new file mode 100644
index 0000000..83beca5
--- /dev/null
+++ b/config-overrides.js
@@ -0,0 +1,24 @@
+const includeRegexp = /\.(js|mjs|jsx|ts|tsx)$/
+
+module.exports = function(config, env) {
+    for (const rule of config.module.rules) {
+        if (rule.oneOf) {
+            for (const r of rule.oneOf) {
+                if (String(r.test) === String(includeRegexp)) {
+                    const l = {
+                        loader: r.loader,
+                        options: r.options,
+                    }
+                    l.options.plugins.push(require.resolve("styled-jsx/babel"),)
+                    delete r.loader
+                    delete r.options
+                    r.test = /\.(js|mjs|jsx|ts|tsx|rex)$/
+                    r.use = [l, {
+                        loader: require.resolve('remix-loader')
+                    }]
+                }
+            }
+        }
+    }
+    return config;
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index 76f4685..067b251 100644
--- a/package.json
+++ b/package.json
@@ -7,13 +7,16 @@
     "@testing-library/react": "^9.3.2",
     "@testing-library/user-event": "^7.1.2",
     "react": "^16.13.1",
+    "react-app-rewired": "^2.1.6",
     "react-dom": "^16.13.1",
-    "react-scripts": "3.4.1"
+    "react-scripts": "3.4.1",
+    "remix-loader": "0.0.2",
+    "styled-jsx": "^3.3.0"
   },
   "scripts": {
-    "start": "react-scripts start",
-    "build": "react-scripts build",
-    "test": "react-scripts test",
+    "start": "react-app-rewired start",
+    "build": "react-app-rewired build",
+    "test": "react-app-rewired test",
     "eject": "react-scripts eject"
   },
   "eslintConfig": {
diff --git a/src/App.js b/src/App.js
index ce9cbd2..b46b699 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,10 +1,12 @@
 import React from 'react';
 import logo from './logo.svg';
 import './App.css';
+import Temp from './temp.rex'
 
 function App() {
   return (
     <div className="App">
+      <Temp name="test name" />
       <header className="App-header">
         <img src={logo} className="App-logo" alt="logo" />
         <p>
diff --git a/src/temp.rex b/src/temp.rex
new file mode 100644
index 0000000..9269556
--- /dev/null
+++ b/src/temp.rex
@@ -0,0 +1,36 @@
+import React from 'react'
+
+@[COMPONENT]
+
+const props = @{RexProps}
+const [count, setCount] = React.useState(0)
+
+const onAddCount = () => {
+    setCount(x => x + 1)
+}
+
+@[RENDER] |> count === 2
+
+<div className="name">render by condition</div>
+<div>count: {count}</div>
+<div onClick={onAddCount}>add count</div>
+
+@[RENDER]
+
+<div className="name">
+    render
+    <span> {props.name || 'none' }</span>
+</div>
+<div>count: {count}</div>
+<div onClick={onAddCount}>add count</div>
+
+
+@[STYLE]
+
+.name {
+    color: red;
+    
+}
+.name span {
+    color: ${count === 4 ? 'yellow' : 'blue' }
+}
\ No newline at end of file

虽然 loader 这里留了很多坑,但总归可用一种毕竟正常的方式来使用了,留下的坑会在下一篇章解决,如果还有的话(手动斜眼

再次完结撒花~