1. 程式人生 > >原來rollup這麼簡單之 rollup.watch篇

原來rollup這麼簡單之 rollup.watch篇

大家好,我是小雨小雨,致力於分享有趣的、實用的技術文章。
內容分為翻譯和原創,如果有問題,歡迎隨時評論或私信,希望和大家一起進步。
大家的支援是我創作的動力。

計劃

rollup系列打算一章一章的放出,內容更精簡更專一更易於理解

目前打算分為以下幾章:

  • rollup.rollup
  • rollup.generate + rollup.write
  • rollup.watch <==== 當前文章
  • 具體實現或思想的分析,比如tree shaking、外掛的實現等

TL;DR

一圖勝千言啊!

注意點

所有的註釋都在這裡,可自行閱讀

!!!提示 => 標有TODO為具體實現細節,會視情況分析。

!!!注意 => 每一個子標題都是父標題(函式)內部實現

!!!強調 => rollup中模組(檔案)的id就是檔案地址,所以類似resolveID這種就是解析檔案地址的意思,我們可以返回我們想返回的檔案id(也就是地址,相對路徑、決定路徑)來讓rollup載入

rollup是一個核心,只做最基礎的事情,比如提供預設模組(檔案)載入機制, 比如打包成不同風格的內容,我們的外掛中提供了載入檔案路徑,解析檔案內容(處理ts,sass等)等操作,是一種插拔式的設計,和webpack類似
插拔式是一種非常靈活且可長期迭代更新的設計,這也是一箇中大型框架的核心,人多力量大嘛~

主要通用模組以及含義

  1. Graph: 全域性唯一的圖,包含入口以及各種依賴的相互關係,操作方法,快取等。是rollup的核心
  2. PathTracker: 無副作用模組依賴路徑追蹤
  3. PluginDriver: 外掛驅動器,呼叫外掛和提供外掛環境上下文等
  4. FileEmitter: 資源操作器
  5. GlobalScope: 全域性作用局,相對的還有區域性的
  6. ModuleLoader: 模組載入器
  7. NodeBase: ast各語法(ArrayExpression、AwaitExpression等)的構造基類

程式碼解析

  • 兩個方法 三個類

沒錯,主要就五個點,每個點各司其職,條修葉貫,妙啊~

首先是主類: Watcher

,獲取使用者傳遞的配置,然後建立task例項,然後再下一次事件輪詢的時候呼叫watcher例項的run方法啟動rollup構建。
Watcher返回emitter物件,除了供使用者新增鉤子函式外,還提供關閉watcher的功能。

class Watcher {
    constructor(configs: GenericConfigObject[] | GenericConfigObject) {
        this.emitter = new (class extends EventEmitter {
            close: () => void;
            constructor(close: () => void) {
                super();
                // 供使用者關閉使
                this.close = close;
                // 不警告
                // Allows more than 10 bundles to be watched without
                // showing the `MaxListenersExceededWarning` to the user.
                this.setMaxListeners(Infinity);
            }
        })(this.close.bind(this)) as RollupWatcher;

        this.tasks = (Array.isArray(configs) ? configs : configs ? [configs] : []).map(
            config => new Task(this, config) // 一個配置入口一個任務,序列執行
        );
        this.running = true;
        process.nextTick(() => this.run());
    }
    
    private run() {
        this.running = true;

        // 當emit 'event' 事件的時候,統一是傳遞給cli使用,通過code區別不同的執行環節,相當於鉤子函式,我們也可以使用增加監聽event事件來做我們想做的事
        this.emit('event', {
            code: 'START'
        });

        // 初始化promise
        let taskPromise = Promise.resolve();
        // 序列執行task
        for (const task of this.tasks) taskPromise = taskPromise.then(() => task.run());

        return taskPromise
            .then(() => {
                this.running = false;

                this.emit('event', {
                    code: 'END'
                });
            })
            .catch(error => {
                this.running = false;
                this.emit('event', {
                    code: 'ERROR',
                    error
                });
            })
            .then(() => {
                if (this.rerun) {
                    this.rerun = false;
                    this.invalidate();
                }
            });
    }
}

然後是Task,任務類,用來執行rollup構建任務,功能單一。當我們上面new Task的時候,會通過Task的建構函式初始化配置,以供rollup構建使用,其中有input配置、output配置、chokidar配置和使用者過濾的檔案。
當執行task.run()的時候會進行rollup構建,並通過構建結果快取每一個task,供檔案變動時重新構建或監聽關閉時刪除任務。

class Task {
    constructor(watcher: Watcher, config: GenericConfigObject) {
        // 獲取Watch例項
        this.watcher = watcher;

        this.closed = false;
        this.watched = new Set();

        const { inputOptions, outputOptions } = mergeOptions({
            config
        });
        this.inputOptions = inputOptions;

        this.outputs = outputOptions;
        this.outputFiles = this.outputs.map(output => {
            if (output.file || output.dir) return path.resolve(output.file || output.dir!);
            return undefined as any;
        });

        const watchOptions: WatcherOptions = inputOptions.watch || {};
        if ('useChokidar' in watchOptions)
            (watchOptions as any).chokidar = (watchOptions as any).useChokidar;

        let chokidarOptions = 'chokidar' in watchOptions ? watchOptions.chokidar : !!chokidar;

        if (chokidarOptions) {
            chokidarOptions = {
                ...(chokidarOptions === true ? {} : chokidarOptions),
                disableGlobbing: true,
                ignoreInitial: true
            };
        }

        if (chokidarOptions && !chokidar) {
            throw new Error(
                `watch.chokidar was provided, but chokidar could not be found. Have you installed it?`
            );
        }

        this.chokidarOptions = chokidarOptions as WatchOptions;
        this.chokidarOptionsHash = JSON.stringify(chokidarOptions);

        this.filter = createFilter(watchOptions.include, watchOptions.exclude);
    }

    // 關閉:清理task
    close() {
        this.closed = true;
        for (const id of this.watched) {
            deleteTask(id, this, this.chokidarOptionsHash);
        }
    }

    invalidate(id: string, isTransformDependency: boolean) {
        this.invalidated = true;
        if (isTransformDependency) {
            for (const module of this.cache.modules) {
                if (module.transformDependencies.indexOf(id) === -1) continue;
                // effective invalidation
                module.originalCode = null as any;
            }
        }
        // 再呼叫watcher上的invalidate
        this.watcher.invalidate(id);
    }

    run() {
        // 節流
        if (!this.invalidated) return;
        this.invalidated = false;

        const options = {
            ...this.inputOptions,
            cache: this.cache
        };

        const start = Date.now();
            
        // 鉤子
        this.watcher.emit('event', {
            code: 'BUNDLE_START',
            input: this.inputOptions.input,
            output: this.outputFiles
        });
        
        // 傳遞watcher例項,供rollup方法監聽change和restart的觸發,進而觸發watchChange鉤子
        setWatcher(this.watcher.emitter);
        return rollup(options)
            .then(result => {
                if (this.closed) return undefined as any;
                this.updateWatchedFiles(result);
                return Promise.all(this.outputs.map(output => result.write(output))).then(() => result);
            })
            .then((result: RollupBuild) => {
                this.watcher.emit('event', {
                    code: 'BUNDLE_END',
                    duration: Date.now() - start,
                    input: this.inputOptions.input,
                    output: this.outputFiles,
                    result
                });
            })
            .catch((error: RollupError) => {
                if (this.closed) return;

                if (Array.isArray(error.watchFiles)) {
                    for (const id of error.watchFiles) {
                        this.watchFile(id);
                    }
                }
                if (error.id) {
                    this.cache.modules = this.cache.modules.filter(module => module.id !== error.id);
                }
                throw error;
            });
    }

    private updateWatchedFiles(result: RollupBuild) {
        // 上一次的監聽set
        const previouslyWatched = this.watched;
        // 新建監聽set
        this.watched = new Set();
        // 構建的時候獲取的監聽檔案,賦給watchFiles
        this.watchFiles = result.watchFiles;
        this.cache = result.cache;
        // 將監聽的檔案新增到監聽set中
        for (const id of this.watchFiles) {
            this.watchFile(id);
        }
        for (const module of this.cache.modules) {
            for (const depId of module.transformDependencies) {
                this.watchFile(depId, true);
            }
        }
        // 上次監聽的檔案,這次沒有的話,刪除任務
        for (const id of previouslyWatched) {
            if (!this.watched.has(id)) deleteTask(id, this, this.chokidarOptionsHash);
        }
    }

    private watchFile(id: string, isTransformDependency = false) {
        if (!this.filter(id)) return;
        this.watched.add(id);

        if (this.outputFiles.some(file => file === id)) {
            throw new Error('Cannot import the generated bundle');
        }

        // 增加任務
        // this is necessary to ensure that any 'renamed' files
        // continue to be watched following an error
        addTask(id, this, this.chokidarOptions, this.chokidarOptionsHash, isTransformDependency);
    }
}

到目前為止,我們知道了執行rollup.watch的時候執行了什麼,但是當我們修改檔案的時候,rollup又是如何監聽變化進行rebuild的呢?

這就涉及標題中說的兩個方法,一個是addTask,一個是deleteTask,兩個方法很簡單,就是進行任務的增刪操作,這裡不做解釋,自行翻閱。add新建一個task,新建的時候回撥用最後一個未提及的類: FileWatcher,沒錯,這就是用來監聽變化的。

FileWatcher初始化監聽任務,使用chokidar或node內建的fs.watch容錯進行檔案監聽,使用哪個取決於有沒有傳遞chokidarOptions。

// addTask的時候
const watcher = group.get(id) || new FileWatcher(id, chokidarOptions, group);

當有檔案變化的時候,會觸發invalidate方法

invalidate(id: string, isTransformDependency: boolean) {
    this.invalidated = true;
    if (isTransformDependency) {
        for (const module of this.cache.modules) {
            if (module.transformDependencies.indexOf(id) === -1) continue;
            // effective invalidation
            module.originalCode = null as any;
        }
    }
    // 再呼叫watcher上的invalidate
    this.watcher.invalidate(id);
}

watcher上的invalidate方法

invalidate(id?: string) {
    if (id) {
        this.invalidatedIds.add(id);
    }
    // 防止刷刷刷
    if (this.running) {
        this.rerun = true;
        return;
    }
    
    // clear pre
    if (this.buildTimeout) clearTimeout(this.buildTimeout);

    this.buildTimeout = setTimeout(() => {
        this.buildTimeout = null;
        for (const id of this.invalidatedIds) {
            // 觸發rollup.rollup中監聽的事件
            this.emit('change', id);
        }
        this.invalidatedIds.clear();
        // 觸發rollup.rollup中監聽的事件
        this.emit('restart');
        // 又走了一遍構建
        this.run();
    }, DELAY);
}

FileWatcher類如下,可自行閱讀


class FileWatcher {

    constructor(id: string, chokidarOptions: WatchOptions, group: Map<string, FileWatcher>) {
        this.id = id;
        this.tasks = new Set();
        this.transformDependencyTasks = new Set();

        let modifiedTime: number;

        // 檔案狀態
        try {
            const stats = fs.statSync(id);
            modifiedTime = +stats.mtime;
        } catch (err) {
            if (err.code === 'ENOENT') {
                // can't watch files that don't exist (e.g. injected
                // by plugins somehow)
                return;
            }
            throw err;
        }

        // 處理檔案不同的更新狀態
        const handleWatchEvent = (event: string) => {
            if (event === 'rename' || event === 'unlink') {
                // 重新命名 link時觸發
                this.close();
                group.delete(id);
                this.trigger(id);
                return;
            } else {
                let stats: fs.Stats;
                try {
                    stats = fs.statSync(id);
                } catch (err) {
                    // 檔案找不到的時候
                    if (err.code === 'ENOENT') {
                        modifiedTime = -1;
                        this.trigger(id);
                        return;
                    }
                    throw err;
                }
                // 重新觸發構建,且避免多次重複操作
                // debounce
                if (+stats.mtime - modifiedTime > 15) this.trigger(id);
            }
        };

        // 通過handleWatchEvent處理所有檔案更新狀態
        this.fsWatcher = chokidarOptions
            ? chokidar.watch(id, chokidarOptions).on('all', handleWatchEvent)
            : fs.watch(id, opts, handleWatchEvent);

        group.set(id, this);
    }

    addTask(task: Task, isTransformDependency: boolean) {
        if (isTransformDependency) this.transformDependencyTasks.add(task);
        else this.tasks.add(task);
    }

    close() {
        // 關閉檔案監聽
        if (this.fsWatcher) this.fsWatcher.close();
    }

    deleteTask(task: Task, group: Map<string, FileWatcher>) {
        let deleted = this.tasks.delete(task);
        deleted = this.transformDependencyTasks.delete(task) || deleted;

        if (deleted && this.tasks.size === 0 && this.transformDependencyTasks.size === 0) {
            group.delete(this.id);
            this.close();
        }
    }

    trigger(id: string) {
        for (const task of this.tasks) {
            task.invalidate(id, false);
        }
        for (const task of this.transformDependencyTasks) {
            task.invalidate(id, true);
        }
    }
}

總結

rollup的watch功能還是很清晰的,值得我們借鑑學習,但是他並沒有把內容打進記憶體中,而是直接生成,相比來說速度會略遜一籌,不過這個或許已有外掛支援,這裡不做討論,我們懂得他是怎麼運動的,想加東西信手拈來的,幹就完了,小夥伴們。

下一期在猶豫出什麼,是外掛篇還是tree shaking篇,看到這裡的朋友有什麼想法可以跟我說下哈。

這期差不多就到這了,說點題外話。

時間飛快,'被寒假'估計就要結束了,之前一直想要是能在家裡辦公可太棒了,現在也是體驗了一把,怎麼碩呢..

效率嗷嗷的啊,一週的活,兩天就幹完了,也有時間幹自己的事情了,那感覺不要太爽,哈哈哈

估計有這種想法的人數應該也有一部分,搞不好以後就有云辦公了,人人都是外包公司 (狗頭保命

又想到一句話:

夫鈍兵挫銳,屈力殫貨,則諸侯乘其弊而起,雖有智者,不能善其後矣。故兵聞拙速,未睹巧之久也。

其中的拙速,曾國藩理解為準備要慢,動手要快。

說的很對,我們對待每個需求都應該這樣,準備要充分,幹活要麻利,然而在公司的時候,或許並不都是這樣的。


如果這篇文章對大家有一點點幫助,希望得到大家的支援,這是我最大的動力,拜了個拜~