腳踏esbuild祥雲,胸懷tsx利刃,身披scss羽衣,追尋前端的本質

2023-11-15 15:02:22

本文所有內容,純屬個人觀點,無意與任何人爭論

前端技術的現狀

我覺得前端技術發展到現在有兩個最主要的特徵

  1. 前端工具鏈為前端工程化提供了強有力的支援

這方面主要是webpackrollupesbuild等工具產生的價值,當然還有背後的Node.js

這些工具讓前端開發者可以更從容的開發大型前端專案。

  1. 前端開發框架提升了前端工程師的生產效率

這方面主要是AngularReactVueSvelte等開發框架產生的價值。

這些框架讓開發者可以更容易的開發前端專案

前端工具鏈的價值毋庸置疑,但前端開發框架的價值與影響值得討論。

前端開發框架之所以能提升前端工程師的生產效率,是因為它為我們做了大量的封裝。

這種封裝工作在提升生產效率的同時也帶來了複雜性,甚至有些封裝工作的複雜程度遠超了業務邏輯本身。

比如:我們修改一個變數的值,並把這個值更新到Dom中,

在不使用前端框架時,我們一般會寫這樣的程式碼

let count = 0
count + = 1;
let dom = document.getElementById("id")
dom.innerHTML = count

使用前端框架後,寫的程式碼變成了這樣:

// Vue
// <div>{{count}}</div>
let count = ref(0)
count.value += 1
// React
// <div>{count}</div>
const [count, setCount] = useState(0);
setCount(count + 1);
// Svelte
// <div>{count}</div>
let count = 0;
count += 1;

如你所見,前端開發框架幫開發者做了大量的工作,比如:虛擬DOM,Diff演演算法,代理觀察變化等等。

大有 為了一碟醋,包了一鍋餃子 的嫌疑,就算這鍋餃子是尤雨溪幫我們包的,

我們也很難說餃子餡裡油多了還是油少了,餃子皮是高筋麵粉還是低筋麵粉。

甚至現在大家都不考慮自己的身子適不適合吃餃子了,既然是尤雨溪幫我包的,那我一定要吃呀!

當我們的頁面變卡、頁面佔用的記憶體逐漸上升最後OOM時,

我們有考慮過,如果不用這些框架,是不是這類問題更容易被發現,更容易被控制呢?

(當然,這裡提到的問題,一定是我們吃餃子的姿勢不對導致的,不是餃子本身的問題_

迴歸前端的本質

我們要回到前端開發者刀耕火種、茹毛飲血的時代嗎?當然不是。

那麼哪些東西是我們不想放棄的?

  1. 元件化開發的模式

標題列一個元件,側邊欄一個元件,選單一個元件,各個元件有各個元件各自的業務邏輯。

  1. 困扎程式碼

釋出之前,各個元件的程式碼會被困扎到一起,產出很多個chunk檔案,tree-shake會幫我們移除沒用到的程式碼

  1. 熱更新或熱過載的能力

改了某個元件的程式碼,能實時看到改動後的結果,如果達不到熱更新,那就保留最基本的熱過載能力。

  1. 樣式隔離

不一定要Shadow Dom,我們可以制定一套規則來約束元件的樣式。

  1. 強型別與智慧提示

最好有TypeScript的強型別支援,寫元件的時候最好能有足夠多的智慧提示

除了這些東西之外,

像虛擬Dom,Diff演演算法,Watch物件的變化,元件間通訊,資料繫結等,

我們都可以拋棄,這些本來就是我們自己的工作,不需要框架來幫我們做。

歸根結底:在寫程式碼的時候,我們要始終知道自己在做什麼

方案

  1. 基於 Web Component 技術與相關的輔助工具

單純用 Web Component 開發的話,挺麻煩的。

要寫一個工具才才能提升我們使用這個方案的開發體驗,

比如把templatecss樣式程式碼檔案封裝到一個單獨的元件中

搞定這個工具沒那麼容易,而且搞不好又回到了老路上,等於自己開發了一個前端框架,

我在這個方向上做過一些嘗試,後來就放棄了

  1. 基於 JSX/TSX 技術及相關輔助工具

現在VSCodeJSX/TSX語法支援的很好,esbuild也內建支援對JSX/TSX的困扎

最關鍵的是:實現一個簡單的JSX/TSX解析器非常容易(不依賴React庫)

JSX/TSX解析器

廢話不多說,直接看解析器的程式碼吧:

// React.ts
let appendChild = (children: any,node: Node)=> {
    if (Array.isArray(children)) {
        for (const child of children) {
            if(child) appendChild(child,node)
        }
    } else if (typeof children === "string" || typeof children === "number") {
        let textNode = document.createTextNode(children as any)
        node.appendChild(textNode)
    } else if (typeof children.nodeType === "number") {
        node.appendChild(children)
    }
}
let appendAttr = (attr: object,node: HTMLElement)=>{
    for (let key of Object.keys(attr)) {
        let val = attr[key];
        if(key === "style"){
            node.setAttribute("style", val)
        } else if(typeof val === "function"){
            if(key.startsWith("on")){
                node.addEventListener(key.toLocaleLowerCase().substring(2), val)
            }
        } else if(typeof val === "object"){
            node[key] = val
        }        
        else {
            node.setAttribute(key, val)
        }
    }
}
let createElement = (tag: any, attr: any, ...children: any[]) => {
    if(typeof tag === "string"){
        let node = document.createElement(tag);
        if(attr) appendAttr(attr,node)
        if(children) appendChild(children,node)
        return node;
    } else if(typeof tag === "function"){
        let obj = tag({...attr,children})
        return obj
    }   
}
let Fragment = (attr:any) =>{
    const fragment = document.createDocumentFragment()
    appendChild(attr.children, fragment)
    return fragment
}
export default {
    createElement,
    Fragment
}

沒錯,就這麼4個簡單的方法,就能解析大部分JSX/TSX語法

像在JSX/TSX中使用SVG這類需求,我就直接忽略了,遇到這類需求用原始的HTML方法處理最好

下面是一個簡單的範例

import React from "./React"; 
let App = ()=>{
  let count = 1;
  return <div>{count}</div>
}
document.body.appendChild(<App/>);

這個元件的第一行匯入了前面介紹的四個方法

注意:這個元件中沒有使用任何React物件的方法,也得匯入React物件,而且必須叫React物件,不然esbuild不認。

子元件範例

//主元件  App.tsx
import React from "./React";
import LeftPanel from "./LeftPanel";
import MainPanel from "./MainPanel";
let App = ()=>{
  return <><LeftPanel/><MainPanel/></>
}
document.body.appendChild(<App/>);
// 子元件  LeftPanel.tsx
import React from "./React"; 
export default function () {  
  let count = 1;
  return <div>{count}</div>
}

其他一些動態建立元素的方法也都支援,比如:

//範例1
<div>
    {[...Array(8)].map((v,i)=><div>{`${i}`}</div>) }
</div>
//範例2
let container = document.getElementById("container");
for(let i=0;i<6;i++){
    let row = <div class="row"></div>            
    for(let j=0;j<7;j++){
        let cell = <div><div class="cellHeader">{obj.content}</div></div>        
        row.appendChild(cell)
    }
    container.append(row)
}

用esbuild啟動偵錯伺服器

先來看指令碼程式碼:

// ./script/dev.js
let esbuild = require("esbuild")
let {sassPlugin} = require("esbuild-sass-plugin")
let fs = require("fs")
let startDevServer = async ()=>{
    let content = `<html><head>
    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
    <link rel="stylesheet" href="./Index.css">
</head><body>
    <script src="./Index.js"></script>
    <script>
        new EventSource('/esbuild').addEventListener('change', () => location.reload())
    </script>
</body></html>`;
    await fs.writeFile(`./dist/Index.html`,content)
    let ctx = await esbuild.context({
      entryPoints: [`./Index.tsx`],
      bundle: true,
      outdir: 'dist',
      plugins: [sassPlugin()],
      sourcemap:true
    })  
    await ctx.watch()  
    let { host, port } = await ctx.serve({
      servedir: 'dist',
    })
    let devServerAddr = `http://localhost:${port}/index.html`
    console.log(devServerAddr)
  }
startDevServer();

有了這個指令碼之後,你只要在package.json中加一行這樣的指令

"dev": "node ./script/dev.js",

就可以通過這個命令列命令

npm run dev

啟動你得偵錯頁面了

如你所見,我們為esbuild增加了esbuild-sass-plugin外掛,這樣我們就可以在tsx/jsx元件中使用scss樣式了

import "./Index.scss";

上面的模板html程式碼中有一行這樣得指令碼

new EventSource('/esbuild').addEventListener('change', () => location.reload())

此指令碼為esbuild的熱過載服務,

當我們修改某個元件的程式碼時,整個頁面會跟著重新整理

這不是熱更新,只是熱過載,有它就夠了,上熱更新代價太大,就不要自行車了。

esbuild 打包產物

先看程式碼

// ./script/release.js
let esbuild = require("esbuild")
let {sassPlugin} = require("esbuild-sass-plugin")
let fs = require("fs")

let release = async ()=>{
    let content = `<html><head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<link rel="stylesheet" href="./Index.css">
</head><body><script src="./Index.js"></script></body></html>`;
    await fs.writeFile(`./release/Index.html`,content)
    let ctx = await esbuild.build({
      entryPoints: [`./Index.tsx`],
      bundle: true,
      outdir: 'release',
      plugins: [sassPlugin()],
      minify: true,
      sourcemap:false
    })
    console.log("build ok")
    }
release();

package.json中加入:

"release": "node ./script/release.js"

打包指令:

npm run release

打包程式碼比較簡單,關鍵點是minify設定為true以壓縮輸出產物。

scss 隔離樣式

假設我們約定一個元件的根元素有一個父樣式,

這個父樣式約束著這個元件的所有子元素得樣式

那就可以用下面的程式碼,讓元件的樣式作用於元件內,不汙染全域性樣式

//ViewDay.scss
#ViewDay{
    cursor: pointer;
    .bgLine{        
        //
    }
    #JobContainer{
        //
    }
}
// 子元件  ViewDay.tsx
import React from "./React"; 
import "./ViewDay.scss";
export default function () {  
  return <div id="ViewDay">
    <div class="bgLine"></div>
    <div id="JobContainer"></div>
  </div>
}

這樣 .bgLine#JobContainer 就不會影響其他元件內的同名樣式了