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

354 阅读10分钟

根本没什么人期待的第三篇章来啦~

前两篇链接 orz

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

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

解决一下之前遗留的一个大坑

在上一篇章中,对于目前的单文件组件还遗留了大概三个左右的问题,这次就来解决其中的一个:直接操作源码太过危险 虽然时间可能有些久远了,但小伙伴们应该还能依稀记得某个略微有些奇怪的自定义语法 @[MODULE(arg1 = 'value', arg2 = 1)] |> pipe1 |> pipe2

是时候解释一下为什么要定义成这种奇怪的样子啦~

首先最开始的 @ 符号,这个并没有什么特殊的意义,可能只是个人爱好/避免语法冲突

然后就是这样一个东东 [MODULE(agr1 = 'value')],这一整个玩意基本上就是直接照搬了 c# attribute 的语法规则,意外的我还蛮喜欢这个东西的,然后在这个项目中,这个规则和前面的 @ 强绑定在了一起,是自定义语法规则的第一部分~

第二部分就是一个当前时间节点还在提案中的未来语法,管道操作符 |>,所以第二部分的作用就是可以对第一部分进行虚拟的管道操作,感官上还是比较不错的~

第三部分就是被这两部分语法分割开的普通 js 代码,虽然之前的实现是有奇怪的东西混进来了,这次要做的事情就是把混进来的东西干掉(手动斜眼

所以呢?为什么要分成三个部分呢?

回到最开始的那个问题上来,我们要做的事情,其实就是一个 string -> js string 的一个过程,但是,之前由于工具链的限制(只有 loader),所以并不能完全将非 js 代码抽离出来(其实想做也没什么不可以的,但成本太高 = =

所以在最开始的抽象定义中,直接就将语法抽象成了 自定义语法 -> 类 js 语法 -> js 语法 三个部分,因为是完全解藕的设计,所以扩展性和维护性会好一些 orz

什么?你说可以在 loader 中继续做扩展?醒醒吧!仔细想想 loader 里面做了一些什么奇怪的事情吧,直接替换源码字符串这种操作,从来都不是一件很安全的事情

而且 loader 里面对于 js 部分的操作完全无力,总不能在 loader 中手写 AST 解析器吧 orz

所以,就需要引入另外一个工具来基于 js 语法进行魔改,对就是那个经常见到听到但可能不知道具体做什么 babel

这次的小目标是什么呢?

单独把之前有问题的两个语法点拎出来说

...

@[COMPONENT]
const props = @{RexProps}

...

@[RENDER] |> count === 2

...

就是这个看不出来是什么奇怪语法的东西混进去了,从进度上看这次依旧用不到 (arg1 = 'value') 这部分的自定义语法,原则上上面说的第一部分的工作在 loader 中处理,其余部分在 babel 中处理,因为 |> 操作符还在提案,所以就现在 loader 中实现这样一个操作符~

好的呢?这次只改动这么一丢丢的东西,将上面语法中非 js 语法的部分转换一下,就像这样

import { rexConstructor, rexIf } from 'remix.macro'
...

const [props] = rexConstructor()

@[COMPONENT]

...

@[RENDER] |> rexIf(count === 2)

...

虽然看起来变化并不是很大,但这样就可以很好的支持 js 相关的一些特性,比如说,虽然没有做什么额外的工作,但当这个小目标完成之后,component 就可以很好的支持 hoc 语法了呢!

稍微说一下 rexConstructor 这个东东,为什么看起来有点奇怪,因为想要一个方法同时支持 exportprops 的获取,万一以后想支持 ts 了怎么办,总要有点梦想是不是(不过这么写 props 的作用域会有点问题,不过先这样吧 orz

这次要做的是两个语法点的转换

const [props, comp] = rexConstructor()
function __REX_COMPONENT__() {
   ...
}
⬇️
function comp(props) {
   ...
}

// 另外一个
function comp() {
    rexIf(...)(...)
}
⬇️
function comp() {
    if (...) {
        return ...
    }
}

这次的小目标就是这个啦,看上去非常简单的样纸呢~

一个很好玩的玩具 babel macro

因为 babel 所以才有现在的前端(个人主观感觉 orz)所以要感谢 babel 的诞生可以让我们可以用到一些奇怪的语法特性

然后让我们正式开始~

首先,依旧是例行公事的脚本运行/创建项目,项目的搭建直接参考了这个项目(抱歉我太菜了 orz github.com/kentcdodds/…

mkdir remix-macro
cd remix-macro
npm init // 狂按回车
yarn add babel-macros babel-plugin-macros babel-plugin-tester jest --dev

然后就可以开始快乐的写代码了~

等等,是不是漏了什么?由于 babel macro 的某些流程比较复杂,所以之前参考之前上面那个链接项目的代码就好,或者直接来参考我这个(这玩意以后有可能会更新 github.com/frontend-ki…

然后 babel 这个东西,稍稍还是有点麻烦的,官方文档肯定比我说的要好,所以附上链接 = = github.com/jamiebuilds…

zh-cn 手册在这里 github.com/jamiebuilds…

文档里有一个 AST Explorer 的链接,不过容易漏掉,这里就拉出来了 astexplorer.net/

吐槽一下这次粘的链接好多(粘这么多链接还写什么写,哼

为了一切从简,所以这个项目也只做最核心的部分了 orz

新建 lib/index.macro.js 写入以下内容,然后 npm run test,注意啦~这次是 test

const { createMacro } = require('babel-plugin-macros')

module.exports = createMacro(({ references, state, babel }) => {
    const { rexIf = [], rexConstructor = [] } =  references
    rexIf.forEach(path => {
        const funPath = path.getFunctionParent()
        // 判断调用环境是否在 function 中
        if (!(babel.types.isFunctionDeclaration(funPath) || 
            babel.types.isArrowFunctionExpression(funPath))) {
            // 如果不是在 function 中调用,则删除此节点
            path.getStatementParent().remove()
            return
        }
        // 生成目标 AST 的对应结构并替换当前节点
        path.getStatementParent().replaceWith(babel.types.ifStatement(
            path.parent.arguments[0],
            babel.types.blockStatement([
                babel.types.returnStatement(
                    path.parentPath.parent.arguments[0]
                )
            ])
        ))
    })
    rexConstructor.forEach(path => {
        const funPath = path.getFunctionParent()
        // 判断调用环境是否在 program 中
        if (!babel.types.isProgram(funPath)) {
            // 如果不是在 program 中调用,则删除此节点
            path.getStatementParent().remove()
            return
        }
        const paramNode = path.parentPath.parentPath.get('id').node
        // 暂时只支持结构赋值的方式取 props
        if (!babel.types.isArrayPattern(paramNode)) {
            return
        }
        const [propsNode, funNode] = paramNode.elements
        // 替换 fun 中的内容
        funPath.traverse({
            FunctionDeclaration(fpath) {
                // 如果根节点的 fun 是自定义标示的 __REX_COMPONENT__,则进行替换
                if (fpath.get('id').node.name === '__REX_COMPONENT__') {
                    if (funNode) {
                        fpath.get('id').node.name = funNode.name
                    }
                    if (propsNode) {
                        fpath.node.params = [propsNode]
                    }
                }
            }
        })
        path.getStatementParent().remove()
    })
})

对,babel macro 对文件名/项目名都有一些限制,这个就没有什么办法了,大佬们好好看文档 orz

和之前 loader 的算法一样,babel 这部分的说明我也十分无能为力,再说我写的也很烂呀

核心代码只有这么一点点,非常非常的少,大致思路就是,通过变化 AST 的结构,使得生成的 js 代码变成自己想要的形状

然后简单的 npm 发布一下,babel macro 的部分就实现啦~

PS:babel 有点难 = = PS2:确实是有点难,也很麻烦 orz PS?: 好像也么有那么难,不过确实很麻烦(笑

babel plugin/babel macro 的开发应该是有那么一点难度的,这里只是做了两个非常非常简单的 macro,如果想认真去研究 AST 估计只能去看文档了,这篇文章主要目的是浏览 webpack 的基本工作流程,所以 babel 的部分就只提供方案了 orz

不要忘记陪伴过我们一段时间的 loader

或许还能想起来大明湖畔一个被 reject 过的项目,由于 loader 项目没有做 test 相关的搭建,还是在之前那个旧项目上继续改造吧 orz

需要先脚本运行一下 yarn add remix.macro@0.0.2 记得要锁版本,因为这个是我发上去的,说不定什么时候版本号就会更新了,而且很荣幸的这个版本号变成了 0.0.2

由于 rexConstructor 不明原因的不符合预期,临时补一个 rexProps 的 macro,先不考虑 ts 的支持问题了 orz

rexProps.forEach(path => {
    const funPath = path.getFunctionParent()
    // 判断调用环境是否在 functionif (!(babel.types.isFunctionDeclaration(funPath) || 
        babel.types.isArrowFunctionExpression(funPath))) {
        // 如果不是在 function 中调用,则删除此节点
        path.getStatementParent().remove()
        return
    }
    funPath.node.params = [path.parentPath.parent.id]
    path.getStatementParent().remove()
})

然后放出当前项目的 diff,实际上主要是 loader 的改动

diff --git a/loader/remix-loader.js b/loader/remix-loader.js
index c9332e0..7ec656c 100644
--- a/loader/remix-loader.js
+++ b/loader/remix-loader.js
@@ -60,8 +60,7 @@ function getTokens(input) {
             }
             let deep = 0
             // 匹配管道操作符
-            // 因为这里只匹配表达式,所以匹配做了简单修改 \s -> \n
-            while((deep || !char.match(/\n/)) && cur < input.length) {
+            while((deep || !char.match(/\s/)) && cur < input.length) {
                 str += char
                 // 判断括号是否匹配完成
                 if (char.match(/\(|\[|\{/)) {
@@ -82,36 +81,51 @@ function getTokens(input) {
     return tokens
 }
 
-const REX_PROPS = '__REX_PROPS__'
+const REX_COMPONENT = '__REX_COMPONENT__'
 const REX_STYLES = '__REX_STYLES__'
 
 function parseTokens(tokens) {
     let component = ''
+    let componentPipes = []
     let renders = ''
     let source = ''
     let styles = ''
+    let hasDefaultRender = false
+    let hasDefaultExport = true
     for (const token of tokens) {
         if (token.type === 'source') {
             // 设置 source code
             source += token.value
+            if (token.value.includes('export default')) {
+                hasDefaultExport = false
+            }
         }
         if (token.type === 'component') {
-            // 设置 component code, 并且替换 @{RexProps}
-            component = token.value.replace('@{RexProps}', REX_PROPS)
+            // 设置 component code
+            component = token.value
+            // 获取 component 设置的 pipes
+            componentPipes = token.pipes
         }
         if (token.type === 'render') {
-            // 设置 render code
-            let render = `return (
+            // 设置基本的 render code
+            let render = `
                 <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}}`
+            `
+            // 判断是否设置了 pipes
+            if (token.pipes.length === 0) {
+                hasDefaultRender = true
+                // 如果没有 pipes 则默认直接 return render
+                render = `return (${render})`
+            } else {
+                // 解析并设置 render 的 pipes
+                for (const pipe of token.pipes) {
+                    render = `${pipe}(${render})`
+                }
             }
+            // 稍微使生成的代码好看一点点
             render = `\n${render}\n`
             renders += render
         }
@@ -122,13 +136,18 @@ function parseTokens(tokens) {
     }
     // 全局替换 __REX_STYLES__ 占位符
     renders = renders.replace(/__REX_STYLES__/g, styles)
-    const func = `export default function (${REX_PROPS}) {
+    
+    // 设置组件的 func 模版
+    const func = `function ${REX_COMPONENT}() {
         ${component}
-        ${renders}
+        ${hasDefaultRender ? renders : 'return null'}
     }`
 
-    source += func
-    return source
+    // 如果没有 export default,则设置默认导出为当前组件
+    if (hasDefaultExport) {
+        source += `\n export default ${REX_COMPONENT}\n`
+    }
+    return source + func
 }
 
 module.exports = function(source) {
diff --git a/package.json b/package.json
index 428455e..c6dcd2b 100644
--- a/package.json
+++ b/package.json
@@ -48,6 +48,7 @@
     "react-app-polyfill": "^1.0.6",
     "react-dev-utils": "^10.2.1",
     "react-dom": "^16.13.1",
+    "remix.macro": "^0.0.2",
     "resolve": "1.15.0",
     "resolve-url-loader": "3.1.1",
     "sass-loader": "8.0.2",
diff --git a/src/test.rex b/src/test.rex
index 9269556..e14f0cb 100644
--- a/src/test.rex
+++ b/src/test.rex
@@ -1,15 +1,15 @@
 import React from 'react'
+import { rexProps, rexIf } from 'remix.macro'
 
 @[COMPONENT]
-
-const props = @{RexProps}
+const props = rexProps()
 const [count, setCount] = React.useState(0)
 
 const onAddCount = () => {
     setCount(x => x + 1)
 }
 
-@[RENDER] |> count === 2
+@[RENDER] |> rexIf(count === 2)
 
 <div className="name">render by condition</div>
 <div>count: {count}</div>

把 loader 的代码 copy 到之前那个用于发布的项目,然后发布~

其实这样做并不好,最好还是能搭建一下相关 test 流程

最后回到那个被 react-app-rewired 调教过的项目上去,在真实的项目中演练一下之前的成果,脚本运行 yarn add remix.macro@0.0.2 remix-loader@0.0.3

之后将 temp.rex 替换为 remix.macro 版本,就是这么简单的步骤,大功告成~

放出这次改动的 diff

diff --git a/package.json b/package.json
index 067b251..41d7913 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
     "react-app-rewired": "^2.1.6",
     "react-dom": "^16.13.1",
     "react-scripts": "3.4.1",
-    "remix-loader": "0.0.2",
+    "remix-loader": "0.0.3",
+    "remix.macro": "0.0.2",
     "styled-jsx": "^3.3.0"
   },
   "scripts": {
diff --git a/src/temp.rex b/src/temp.rex
index 9269556..85da0cb 100644
--- a/src/temp.rex
+++ b/src/temp.rex
@@ -1,15 +1,16 @@
 import React from 'react'
+import { rexProps, rexIf } from 'remix.macro'
 
 @[COMPONENT]
 
-const props = @{RexProps}
+const props = rexProps()
 const [count, setCount] = React.useState(0)
 
 const onAddCount = () => {
     setCount(x => x + 1)
 }
 
-@[RENDER] |> count === 2
+@[RENDER] |> rexIf(count === 2)
 
 <div className="name">render by condition</div>
 <div>count: {count}</div>

好啦,完结撒花~~~

虽然这次改动看起来并不大,但这次的目的主要是为了解耦还有提升扩展性,接下来就可以在这个基础上按照自己的喜好随意揉捏了(笑

好啦好啦,到现在为止这个单文件组件已经基本上完成啦,剩下主要就是功能方面的扩展 stage12345 ... 有生之年系列 orz

什么?说好的手撸一个 css-in-js 方案呢?

肯定是在下期啦~如果还有的话,也有可能真的没有了 orz

再次完结撒花~~~