在JavaScript中如何使用宏詳解
在語言當(dāng)中,宏常見用途有實(shí)現(xiàn) DSL 。通過宏,開發(fā)者可以自定義一些語言的格式,比如實(shí)現(xiàn) JSX 語法。在 WASM 已經(jīng)實(shí)現(xiàn)的今天,用其他語言來寫網(wǎng)頁其實(shí)并不是沒有可能。像 Rust 語言就帶有強(qiáng)大的宏功能,這使得基于 Rust 的 Yew 框架,不需要實(shí)現(xiàn)類似 Babel 的東西,而是靠語言本身就能實(shí)現(xiàn)類似 JSX 的語法。 一個(gè) Yew 組件的例子,支持類 JSX 的語法。
impl Component for MyComponent { // ... fn view(&self) -> Html { let onclick = self.link.callback(|_| Msg::Click); html! { <button onclick=onclick>{ self.props.button_text }</button> } } }
JavaScript 宏的局限性
不同于 Rust ,JavaScript 本身是不支持宏的,所以整個(gè)工具鏈也是沒有考慮宏的。因此,你是可以寫個(gè)識(shí)別自定義語法的宏,但是由于配套的工具鏈并不支持,比如最常見的 VSCode 和 Typescript ,你會(huì)得到一個(gè)語法錯(cuò)誤。同樣對(duì)于 babel 本身所用的 parser 也是不支持?jǐn)U展語法的,除非你另 Fork 出來一個(gè) Babel 。因此 babel-plugin-macros 不支持自定義語法。 不過,借助模板字符串函數(shù),我們可以曲線救國,至少獲得部分自定義語法樹的能力。 一個(gè) GraphQL 的例子,支持在 JavaScript 中直接編寫 GraphQL。
import { gql } from 'graphql.macro'; const query = gql` query User { user(id: 5) { lastName ...UserEntry1 } } `; // 在編譯期會(huì)轉(zhuǎn)換成 ↓ ↓ ↓ ↓ ↓ ↓ const query = { "kind": "Document", "definitions": [{ ...
為什么要用宏而非 Babel 插件
Babel 插件的能力確實(shí)遠(yuǎn)大于宏,而且有些情況下確實(shí)是不得不用插件。宏比起 Babel 插件好的一點(diǎn)在于,宏的理念在于開箱即用。使用 React 的開發(fā)者,相信都聽過的大名鼎鼎的 Create-React-App ,幫你封裝好了各種底層細(xì)節(jié),開發(fā)者專注于編寫代碼即可。但是 CRA 的問題在于其封裝的太嚴(yán)了,但凡你有一點(diǎn)需要自定義 Babel 插件的需求,基本上就需要執(zhí)行yarn react-script eject,將所有底層細(xì)節(jié)暴露出來。 而對(duì)于宏來說,你只需要在項(xiàng)目的 Babel 配置內(nèi)添加一個(gè) babel-plugin-macros 插件,那么對(duì)于任何自定義的 Babel 宏都可以完美支持,而不是像插件一樣,需要下載各種各樣的插件。 CRA 已經(jīng)內(nèi)置了 babel-plugin-macros ,你可以在 CRA 項(xiàng)目中使用任意的 Babel 宏。
如何寫一個(gè)宏?
介紹
一個(gè)宏非常像一個(gè) Babel 插件,因此事先了解如何編寫 Babel 插件是非常有幫助的,對(duì)于如何編寫 Babel 插件, Babel 官方有一本手冊(cè),專門介紹了如何從零編寫一個(gè) Babel 插件。 在知道如何編寫 Babel 插件之后,我們首先通過一個(gè)使用宏的例子,來介紹下, Babel 是如何識(shí)別文件中的宏的。是某種的特殊的語法,還是用爛的 $ 符號(hào)?
import preval from 'preval.macro' const one = preval`module.exports = 1 + 2 - 1 - 1`
這是非常常見的一個(gè)宏,其作用是在編譯期間執(zhí)行字符串中的 JavaScript 代碼,然后將執(zhí)行的結(jié)果替換到相應(yīng)的地方,如上的代碼在編譯期會(huì)被展開為:
import preval from 'preval.macro' const one = 1
從使用來方式來看,唯一與識(shí)別宏沾點(diǎn)關(guān)系的就是*.macro字符,這也確實(shí)就是 Babel 如何識(shí)別宏的方式,實(shí)際上不僅對(duì)于*.macro的形式, Babel 認(rèn)為庫名匹配正則/[./]macro(\.c?js)?$/表達(dá)式的庫就是 Babel 宏,這些匹配表達(dá)式的一些例子:
'my.macro' 'my.macro.js' 'my.macro.cjs' 'my/macro' 'my/macro.js' 'my/macro.cjs'
編寫
接下來,我們將簡(jiǎn)單編寫一個(gè)importURL宏,其作用是通過 url 來引入一些庫,并在編譯期間將這些庫的代碼預(yù)先拉取下來,處理一下然后引入到文件中。我知道有些 Webpack 插件已經(jīng)支持 從 url 來引入庫,不過這同樣是一個(gè)很好的例子來學(xué)習(xí)如何編寫宏,為了有趣!以及如何在 NodeJS 中發(fā)起同步請(qǐng)求! :)
準(zhǔn)備
首先創(chuàng)建一個(gè)名為 importURL 的文件夾,執(zhí)行npm init -y,來快速創(chuàng)建一個(gè)項(xiàng)目。在項(xiàng)目使用宏的人需要安裝babel-plugin-macros,同樣的,編寫宏的同樣需要安裝這個(gè)插件,在寫之前,我們也需要提前安裝一些其他的庫來輔助我們編寫宏,在開發(fā)之前,需要事先:
- 在package.json將name改為import-url.macro,符合 Babel 識(shí)別宏的格式
- 我們需要用 Babel 提供的輔助方法來創(chuàng)建宏。執(zhí)行yarn add babel-plugin-macros
- yarn add fs-extra,一個(gè)更容易使用的代替 Nodefs模塊的庫
- yarn add find-root,編寫宏的過程我們需要根據(jù)所處理文件的路徑找到其所在的工作目錄,從而寫入緩存,這是一個(gè)已經(jīng)封裝好的庫
示例
我們的目標(biāo)就是將如下代碼轉(zhuǎn)換成
import importURL from 'importurl.macros'; const React = importURL('https://unpkg.com/react@17.0.1/umd/react.development.js'); // 編譯成 import importURL from 'importurl.macros'; const React = require('../cache/pkg1.js');
我們會(huì)解析代碼 importURL 函數(shù)的第一個(gè)參數(shù),當(dāng)做遠(yuǎn)程庫的地址,然后在編譯期間同步的通過 Get 請(qǐng)求拉取代碼內(nèi)容。然后寫入項(xiàng)目頂層文件夾下.chache下,并替換相應(yīng)的 importURL 語句成require(...)語句,路徑...則是使用importURL的文件相對(duì).cache文件中的相對(duì)路徑,使得 webpack 在最終打包的時(shí)候能夠找到對(duì)應(yīng)的代碼。
開始
我們先看看最終的代碼長什么樣子
import { execSync } from 'child_process'; import findRoot from 'find-root'; import path from 'path'; import fse from 'fs-extra'; import { createMacro } from 'babel-plugin-macros'; const syncGet = (url) => { const data = execSync(`curl -L ${url}`).toString(); if (data === '') { throw new Error('empty data'); } return data; } let count = 0; export const genUniqueName = () => `pkg${++count}.js`; module.exports = createMacro((ctx) => { const { references, // 文件中所有對(duì)宏的引用 babel: { types: t, } } = ctx; // babel 會(huì)把當(dāng)前處理的文件路徑設(shè)置到 ctx.state.filename const workspacePath = findRoot(ctx.state.filename); // 計(jì)算出緩存文件夾 const cacheDirPath = path.join(workspacePath, '.cache'); // const calls = references.default.map(path => path.findParent(path => path.node.type === 'CallExpression' )); calls.forEach(nodePath => { // 確定 astNode 的類型 if (nodePath.node.type === 'CallExpression') { // 確定函數(shù)的第一個(gè)參數(shù)是純字符串 if (nodePath.node.arguments[0]?.type === 'StringLiteral') { // 獲取一個(gè)參數(shù),當(dāng)做遠(yuǎn)程庫的地址 const url = nodePath.node.arguments[0].value; // 根據(jù) url 拉取代碼 const codes = syncGet(url); // 生成一個(gè)唯一包名,防止沖突 const pkgName = genUniqueName(); // 確定最終要寫入的文件路徑 const cahceFilename = path.join(cacheDirPath, pkgName); // 通過 fse 庫,將內(nèi)容寫入, outputFileSync 會(huì)自動(dòng)創(chuàng)建不存在的文件夾 fse.outputFileSync(cahceFilename, codes); // 計(jì)算出相對(duì)路徑 const relativeFilename = path.relative(ctx.state.filename, cahceFilename); // 最終計(jì)算替換 importURL 語句 nodePath.replaceWith(t.stringLiteral(`require('${relativeFilename}')`)) } } }); });
創(chuàng)建一個(gè)宏
我們通過createMacro函數(shù)來創(chuàng)建一個(gè)宏,createMacro接受我們編寫的函數(shù)當(dāng)做參數(shù)來生成一個(gè)宏,但實(shí)際上我們并不關(guān)心createMacro的返回時(shí)值是什么,因?yàn)槲覀兊拇a最終都將會(huì)被自己替換掉,不會(huì)在運(yùn)行期間執(zhí)行到。 我們編寫的函數(shù)的第一個(gè)參數(shù)是 Babel 傳遞給我們的一些狀態(tài),我們可以大概看下其類型都有什么。
function createMacro(handler: MacroHandler, options?: Options): any; interface MacroParams { references: { default: Babel.NodePath[] } & References; state: Babel.PluginPass; babel: typeof Babel; config?: { [key: string]: any }; } export interface PluginPass { file: BabelFile; key: string; opts: PluginOptions; cwd: string; filename: string; [key: string]: unknown; }
可視化 AST
我們可以通過astexplorer來觀察我們將要處理代碼的語法樹,對(duì)于如下代碼
import importURL from 'importurl.macros'; const React = importURL('https://unpkg.com/react@17.0.1/umd/react.development.js');
會(huì)生成如下語法樹
紅色標(biāo)紅的語法樹節(jié)點(diǎn),就是 Babel 會(huì)通過ctx.references傳遞給我們的,因此我們需要通過.findParent()方法來向上找到父節(jié)點(diǎn)CallExpresstion,才能去獲取arguments屬性下的參數(shù),拿到遠(yuǎn)程庫的 URL 地址。
同步請(qǐng)求
這里的一個(gè)難點(diǎn)在于, Babel 不支持異步轉(zhuǎn)換,所有的轉(zhuǎn)換操作都是同步的,因此在發(fā)起請(qǐng)求時(shí)也必須是同步的請(qǐng)求。我本來以為這是一件很簡(jiǎn)單的事情, Node 會(huì)提供一個(gè)類似sync: true的選項(xiàng)。但是并沒有的, Node 確實(shí)不支持任何同步請(qǐng)求,除非你選擇用下面這種很怪異的方式
const syncGet = (url) => { const data = execSync(`curl -L ${url}`).toString(); if (data === '') { throw new Error('empty data'); } return data; }
收尾
在拿到代碼后,我們將代碼寫入到開始計(jì)算出的文件路徑中,這里我們使用fs-extra的目的在于,fs-extra在寫入的時(shí)候如果遇到不存在文件夾,不會(huì)像fs一樣直接拋出錯(cuò)誤,而是自動(dòng)創(chuàng)建相應(yīng)的文件件。在寫入完成后,我們通過 Babel 提供的輔助方法stringLiteral創(chuàng)字符串節(jié)點(diǎn),隨后替換掉我們的importURL(...),自此我們的整個(gè)轉(zhuǎn)換流程就結(jié)束了。
最后
這個(gè)宏存在一些缺陷,有興趣的同學(xué)可以繼續(xù)完善:
沒有識(shí)別同一 URL 的庫,進(jìn)行復(fù)用,不過我想這些已經(jīng)滿足如何編寫一個(gè)宏的目的了。
genUniqueName在跨文件是會(huì)計(jì)算出重復(fù)包名,正確的算法應(yīng)該是根據(jù) url 計(jì)算哈希值來當(dāng)做唯一包名
到此這篇關(guān)于在JavaScript中如何使用宏的文章就介紹到這了,更多相關(guān)JavaScript使用宏內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
一個(gè)簡(jiǎn)單的JavaScript數(shù)據(jù)緩存系統(tǒng)實(shí)現(xiàn)代碼
數(shù)據(jù)緩存系統(tǒng),主要是將一些可復(fù)用的數(shù)據(jù)臨時(shí)存放一下,放下數(shù)據(jù)后面的再次調(diào)用。2010-10-10微信公眾號(hào)開發(fā) 實(shí)現(xiàn)點(diǎn)擊返回按鈕就返回到聊天界面
本文分享了微信公眾號(hào)開發(fā)實(shí)現(xiàn)點(diǎn)擊返回按鈕就返回到聊天界面的示例代碼。需要的朋友一起來看下吧2016-12-12將中國標(biāo)準(zhǔn)時(shí)間轉(zhuǎn)換成標(biāo)準(zhǔn)格式的代碼
這篇文章主要介紹了將中國標(biāo)準(zhǔn)時(shí)間轉(zhuǎn)換成標(biāo)準(zhǔn)格式的方法,需要的朋友可以參考下2014-03-03ES6 Set結(jié)構(gòu)的應(yīng)用實(shí)例分析
這篇文章主要介紹了ES6 Set結(jié)構(gòu)的應(yīng)用,結(jié)合實(shí)例形式分析了ES6 set結(jié)構(gòu)的功能、特點(diǎn)、常見用法及相關(guān)操作注意事項(xiàng),需要的朋友可以參考下2019-06-06Next.js解決axios獲取真實(shí)ip問題方法分析
這篇文章主要介紹了Next.js解決axios獲取真實(shí)ip問題方法分析,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-09-09javascript正則表達(dá)式之search()用法實(shí)例
這篇文章主要介紹了javascript正則表達(dá)式之search()用法,實(shí)例分析了search()的使用技巧,需要的朋友可以參考下2015-01-01JS實(shí)現(xiàn)切換標(biāo)簽頁效果實(shí)例代碼
這篇文章介紹了JS實(shí)現(xiàn)切換標(biāo)簽頁效果實(shí)例代碼,有需要的朋友可以參考一下2013-11-11