如何手動解析vue單文件并預覽?

開頭

筆者之前的文章里介紹過一個代碼在線編輯預覽工具的實現(傳送門:快速搭建一個代碼在線編輯預覽工具),實現了csshtmljs的編輯,但是對于demo場景來說,vue單文件也是一個比較好的代碼組織方式,至少筆者就經常在寫vue項目的同時順便寫寫各種demo,但是分享不太方便,因為單文件不能直接運行看效果,基于此,筆者決定在之前的基礎上再增加一個vue單文件的編輯及預覽功能。

ps.如果沒看過之前的文章也沒關系,這里簡單介紹一下該項目:code-run,基本框架使用的是vue3.x版本, 構建工具使用的是vite,代碼編輯器使用的是monaco-editor,基本原理就是把cssjshtml拼接成完整的html字符串扔到iframe里進行預覽。

另外項目目前存在一些坑:

1.vite不支持使用commonjs模塊(筆者尚未找到解決方法,知道的朋友在評論區留個言?)。

2.三方模塊目前都放在項目的public文件夾下,作為靜態資源按需加載:

image-20210911100741819.png

另外由于Monaco Editor自帶的模塊系統和defined/require沖突,導致目前需要手動修改各個三方模塊,讓它只支持全局對象的方式來使用,比如:

image-20210911100432541.png

基本思路

想要預覽vue單文件,其實就是要想辦法轉成瀏覽器能認識的cssjshtml。首先想到的是使用vue-loader來轉換,但是看了它的文檔,發現還是必須要配合webpack才能使用,不過筆者發現了一個配套的模塊vue-template-compiler,它提供了一些方法,其中有一個parseComponent方法可以用來解析vue單文件,輸出各個部分的內容,輸出結構如下:

image-20210910174158196.png

所以思路就很清晰了:

1.html部分,結構固定為:

<div id="app"></div>

2.css部分,首先判斷有沒有使用css預處理器,有的話就先使用對應的解析器轉換成css,然后再通過style標簽插入到頁面。

3.js部分,以vue2.x版本為例,我們最終需要生成如下所示的結構:

new Vue({
    el: '#app',
    template: '',// 模板部分內容
    // ...其他選項
})

其他選項就是vue-template-compiler解析出的script.content內容,但是單文件里基本都是export default {}形式的;template選項很簡單,就是template.content的內容。

這里的處理思路是通過babel來將export default {}的形式轉換成new Vue的形式,然后再添加上eltemplate兩個屬性即可,這會通過寫一個babel插件來實現。

安裝及使用vue-template-compiler

首先vue-template-compiler模塊我們也會把它放到public文件夾下,那么它的瀏覽器使用版本在哪呢?我們可以先安裝它:npm i vue-template-compiler,然后在node_modules里找到它,會發現其中有一個文件:

image-20210911101252324.png

這個就是我們要的,直接把它復制到public文件夾下(當然也要注釋掉它的模塊導出),然后再把該模塊刪除即可,之后我們便可以通過全局對象使用它:

// code就是vue單文件內容字符串
let componentData = window.VueTemplateCompiler.parseComponent(code)
// 處理style、script、template三部分的內容,最后生成css字符串、js字符串、html字符串
parseVueComponentData(componentData)

生成html字符串

html部分我們要做的就是寫死一個div,用它來掛載vue實例即可:

const parseVueComponentData = async (data) => {
    // html就直接渲染一個掛載vue實例的節點
    let htmlStr = `<div id="app"></div>`
    
    return {
        html: htmlStr
    }
}

生成css字符串

style部分如果沒有使用css預處理器的話那么也很簡單,直接返回樣式內容即可,否則需要先使用對應的預處理器把它轉換成css

const parseVueComponentData = async (data) => {
    // 編譯css
    let cssStr = []
    // vue單文件的style塊可以存在多個,所以解析出的styles是個數組
    for(let i = 0; i < data.styles.length; i++) {
        let style = data.styles[i]
        // 如果使用了css預處理器,lang字段不為空
        let preprocessor = style.lang || 'css'
        if (preprocessor !== 'css') {
            // load方法會去加載對應的三方解析模塊,詳情請閱讀上一篇文章
            await load([preprocessor])
        }
        // css方法會使用對應的解析器來解析,可參考之前的文章
        let cssData = await css(preprocessor, style.content)
        // 把解析后的css字符串添加到結果數組里
        cssStr.push(cssData)
    }
    return {
        // 最后把多個style塊的css拼接成一個
        css: cssStr.join('\r\n')
    }
}

上面的css會調用對應的css預處理器的解析模塊來編譯,比如less的處理如下:

const css = (preprocessor, code) => {
    return new Promise((resolve, reject) => {
        switch (preprocessor) {
            case 'css':
                resolve(code)
                break;
            case 'less':
                window.less.render(code)
                    .then((output) => {
                        resolve(output.css)
                    },
                    (error) => {
                        reject(error)
                    });
                break;
        }
    })
}

生成js字符串

script部分的內容我們會使用babel來編譯:

const parseVueComponentData = async (data, parseVueScriptPlugin) => {
    // babel編譯,通過編寫插件來完成對ast的修改
    let jsStr = data.script ? window.Babel.transform(data.script.content, {
        presets: [
            'es2015',
            'es2016',
            'es2017',
        ],
        plugins: [
            // 插件
            parseVue2ScriptPlugin(data)
        ]
    }).code : ''

    return {
        js: jsStr
    }
}

babel插件其實就是一個函數,接收一個babel對象作為參數,然后需要返回一個對象,我們可以在該對象的visitor屬性里訪問到AST節點,并進行一些修改,visitor中的每個函數都接收2個參數:pathstatepath表示兩個節點之間連接的對象,包含節點信息及一些操作方法,插件開發的詳細文檔請參考:plugin-handbook

基本結構如下:

const parseVue2ScriptPlugin = (data) => {
    return function (babel) {
        let t = babel.types
        return {
            visitor: {
                
            }
        }
    }
}

轉換export default語法

接下來再次明確我們的需求,我們要把export default {}的形式轉換成new Vue的形式,具體怎么做呢,我們可以使用astexplorer這個工具先看看這兩種結構的AST的差別是什么:

image-20210911105430374.png
image-20210911105349962.png

可以發現黃色部分都是一樣的,只是外層的節點不一樣,所以我們可以訪問ExportDefaultDeclaration節點,然后把它替換成ExpressionStatement就行了,創建新節點也很簡單,參考ASTbabel-types文檔即可。

const parseVue2ScriptPlugin = (data) => {
    return function (babel) {
        let t = babel.types
        return {
            visitor: {
                // 訪問export default節點,把export default轉換成new Vue
                ExportDefaultDeclaration(path) {
                    // 替換自身
                    path.replaceWith(
                        // 創建一個表達式語句
                        t.expressionStatement(
                            // 創建一個new表達式
                            t.newExpression(
                                // 創建名稱為Vue的標識符,即函數名
                                t.identifier('Vue'),
                                // 函數參數
                                [
                                    // 參數就是ExportDefaultDeclaration節點的declaration屬性對應的節點
                                    path.get('declaration').node
                                ]
                            )
                        )
                    );
                }
            }
        }
    }
}

效果如下:

image-20210911112037640.png

到這里還沒結束,eltemplate屬性我們還沒有給它加上,同樣可以先在AST工具里面嘗試一下:

image-20210911135050951.png

很明顯我們需要訪問ObjectExpression節點,然后給它的properties屬性添加兩個節點,首先想到的做法是這樣的:

const parseVue2ScriptPlugin = (data) => {
    return function (babel) {
        let t = babel.types
        return {
            visitor: {
                ExportDefaultDeclaration(path) {
                    // ...
                },
                ObjectExpression(path) {
                    // 如果父節點是new語句,那么就添加該節點的properties
                    if (path.parent && path.parent.type === 'NewExpression' ) {
                        path.node.properties.push(
                            // el
                            t.objectProperty(
                                t.identifier('el'),
                                t.stringLiteral('#app')
                            ),
                            // template
                            t.objectProperty(
                                t.identifier('template'),
                                t.stringLiteral(data.template.content)
                            )
                        )
                    }
                }
            }
        }
    }
}

這樣做的問題是什么呢,假設我們要轉換的代碼是這樣的:

new Vue({});
export default {
    created() {
        new Vue({});
    },
    data() {
        return {
            msg: "Hello world!",
        };
    },
    mounted() {
        new Vue({});
    },
};

我們想要的應該只是給export default這個對象添加這兩個屬性,但是實際效果如下:

image-20210913151421503.png

可以看到所有的new語句的對象都被修改了,這顯然不是我們想要的,那么正確的方法是什么呢,我們應該在替換完ExportDefaultDeclaration節點后立馬遞歸遍歷該節點,并且添加完這兩個屬性后立即停止遍歷:

const parseVue2ScriptPlugin = (data) => {
    return function (babel) {
        let t = babel.types
        return {
            visitor: {
                ExportDefaultDeclaration(path) {
                    // export default -> new Vue
                    // ...
                    // 添加el和template屬性
                    path.traverse({
                        ObjectExpression(path2) {
                            if (path2.parent && path2.parent.type === 'NewExpression' ) {
                                path2.node.properties.push(
                                    // el
                                    t.objectProperty(
                                        t.identifier('el'),
                                        t.stringLiteral('#app')
                                    ),
                                    // template
                                    t.objectProperty(
                                        t.identifier('template'),
                                        t.stringLiteral(data.template.content)
                                    ),
                                )
                                path2.stop()
                            }
                        }
                    });
                }
            }
        }
    }
}

效果如下:

image-20210913151937284.png

到這里,htmljscss三部分的內容都處理完了,我們把它們拼成完整的html字符串,然后扔到iframe里即可預覽,效果如下:

image-20210913152256670.png

轉換module.exports語法

除了使用export default語法導出,也是可用使用module.exports = {}的,所以我們也需要適配一下這種情況,基本套路都一樣,先分析AST節點樹的差異,然后替換節點:

image-20210913160831452.png

module.exports本身就是一個ExpressionStatement,所以我們只需要訪問AssignmentExpression節點,并替換成new VuenewExpression節點即可:

const parseVue2ScriptPlugin = (data) => {
    return function (babel) {
        let t = babel.types
        return {
            visitor: {
                // 解析export default模塊語法
                ExportDefaultDeclaration(path) {
                    // ...
                },
                // 解析module.exports模塊語法
                AssignmentExpression(path) {
                    try {
                        let objectNode = path.get('left.object.name')
                        let propertyNode = path.get('left.property.name')
                        if (
                            objectNode 
                            && objectNode.node === 'module' 
                            && propertyNode 
                            && propertyNode.node === 'exports'
                        ) {
                            path.replaceWith(
                                t.newExpression(
                                    t.identifier('Vue'),
                                    [
                                        path.get('right').node
                                    ]
                                )
                            )
                            // 添加el和template屬性
                            // 邏輯同上
                        }
                    } catch (error) {
                        console.log(error)
                    }
                }
            }
        }
    }
}

當然,這樣寫如果存在多個module.exports = {}語句是會出錯的,不過這種場景應該不常見,我們就不管了。

其他工具的做法

社區上有一些工具可以用來在瀏覽器端支持.vue文件的加載及使用,比如http-vue-loader,使用方式如下:

<!doctype html>
<html lang="en">
  <head>
    <script src="https://unpkg.com/vue"></script>
    <script src="https://unpkg.com/http-vue-loader"></script>
  </head>

  <body>
    <div id="my-app">
      <my-component></my-component>
    </div>

    <script type="text/javascript">
      new Vue({
        el: '#my-app',
        components: {
          'my-component': httpVueLoader('my-component.vue')
        }
      });
    </script>
  </body>
</html>

接下來按它的原理我們再來實現一遍。

我們先不管樣式,看一下基本的htmljs部分:

const parseVueComponentData2 = (data) => {
    let htmlStr = `
        <div id="app">
            <vue-component></vue-component>
        </div>
    `
    // 把vue單文件字符串里的/轉成\/是因為如果不轉,那么瀏覽器會錯把模板里的標簽當成頁面的實際標簽,會造成頁面解析錯誤
    let jsStr = `
        new Vue({
            el: '#app',
            components: {
                'vue-component': VueLoader(\`${data.replaceAll('/', '\\/')}\`)
            }
        });
    `
    return {
        html: htmlStr,
        js: jsStr,
        css: ''
    }
}

可以看到我們這次把vue單文件當成一個組件來使用,然后我們要實現一個全局方法VueLoader,接收單文件的內容,返回一個組件選項對象。

接下來我們不使用vue-template-compiler,而是自己來解析,原理是創建一個新的HTML文檔,然后把vue單文件的內容扔到該文檔的body節點,然后再遍歷body節點的子節點,根據標簽名來判斷各個部分:

// 全局方法
window.VueLoader = (str) => {
    let {
        templateEl,
        scriptEl,
        styleEls
    } = parseBlock(str)
}

// 解析出vue單文件的各個部分,返回對應的節點 
const parseBlock = (str) => {
    // 創建一個新的HTML文檔
    let doc = document.implementation.createHTMLDocument('');
    // vue單文件的內容添加到body節點下
    doc.body.innerHTML = str
    let templateEl = null
    let scriptEl = null
    let styleEls = []
    // 遍歷body節點的子節點
    for (let node = doc.body.firstChild; node; node = node.nextSibling) {
        switch (node.nodeName) {
            case 'TEMPLATE':
                templateEl = node
                break;
            case 'SCRIPT':
                scriptEl = node
                break;
            case 'STYLE':
                styleEls.push(node)
                break;
        }
    }
    return {
        templateEl,
        scriptEl,
        styleEls
    }
}
image-20210913181743922.png

接下來解析script塊的內容,我們最終是要返回一個選項對象,也就是這樣的:

{
    name: 'vue-component',
    data () {
        return {
            msg: 'Hello world!'
        }
    },
    template: ''
}

然后再看看上面的截圖,你應該有想法了,我們可以手動創建一個module.exports對象,然后讓script的代碼運行時能訪問到該對象,那么不就相當于把這個選項對象賦值到我們定義的module.exports對象上了嗎。

window.VueLoader = (str) => {
    // ...
    let options = parseScript(scriptEl)
}

// 解析script
const parseScript = (el) => {
    let str = el.textContent
    let module = {
        exports: {}
    }
    let fn = new Function('exports', 'module', str)
    fn(module.exports, module)
    return module.exports
}
image-20210913183203423.png

接下來再把模板選項和組件名稱添加到該對象上,最后返回該對象即可:

window.VueLoader = (str) => {
    // ...
    options.template = parseTemplate(templateEl)
    options.name = 'vue-component'
    return options
}

// 返回模板內容字符串
const parseTemplate = (el) => {
    return el.innerHTML
}

css部分的解析和之前我們的做法是一樣的,這里不再贅述,但是http-vue-loader還實現了樣式的scoped處理。

這個工具的一個缺點是不支持export default模塊語法。

參考鏈接

最終效果預覽:https://wanglin2.github.io/code-run-online/

完整代碼請移步項目倉庫:https://github.com/wanglin2/code-run

快速搭建一個代碼在線編輯預覽工具

astexplorer

http-vue-loader

Babel plugin-handbook

vue-template-compiler

babel-types

?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,533評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,055評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,365評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,561評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,346評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,889評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,978評論 3 439
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,118評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,637評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,558評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,739評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,246評論 5 355
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,980評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,362評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,619評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,347評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,702評論 2 370

推薦閱讀更多精彩內容