umijs / mako

An extremely fast, production-grade web bundler based on Rust.
https://makojs.dev
MIT License
1.76k stars 65 forks source link

RFC:CSS 方案 #296

Closed sorrycc closed 1 year ago

sorrycc commented 1 year ago

How

TODO 的部分我做了加粗处理。

1、load 里读取文件,parse 里解析成 ast 。

2、transform 里,analyze_deps 之前,处理非 @import url 的 url 依赖引用,支持 inline_limit,小于 xxK 的转成 base64,否则 emit_asset 输出到 dist 目录,不加到 module_graph 里。

@import url(foo.css);
.a { background:url(foo.png); }
↓↓↓
@import url(foo.css);
.a { background:url(foo.hashhash.png); }
/* 或者 */
.a { background:url(base64:xxxxxxxxx); }

3、transform 里还要加个 Visit 做 @import 语句的提前,和 webpack 保持一致。

.a {}
@import "foo.css";
↓↓↓
@import "foo.css";
.a {}

4、transform_in_generate 里,会做依赖替换。

@import url(foo.css);
@import bar.css;
↓↓↓
@import url(/path/to/foo.css);
@import path/to/bar.css;

5、如果没有配 extract_css,会把 css 转 js 输出,每段 css 会通过 style 标签插入到 head 元素里。

js import css 时用这种方式没有问题,比如 a.js -> a.css 。但 css 内部的 import 目前也是用这种 js 的方式进行 require 和 插 style,比如 a.css -> b.css -> c.css,能想到的就会存在一些问题。1)require 有 cache,后面 require 重复的不会执行,而 css 的优先级是以后面的为准的,所以优先级会不对,2)… 。

所以,需要调整的是,css import css 时,不能用 js 的 require 的方式。计划这样做,1)在 group chunks 时,遇到 css 时就断掉,不往下分析其依赖,2)在 generate_chunks(具体是在 modules_to_js_stmts)里做 css import css 的合并,当遇到 css module 时(此时的 css module 的 parent module 应该全部都是 js module),以此 css module 为入口找到所有依赖,然后深度优先合并依赖到入口 css 模块里。

注:1)一个 css 可能既会被 css import,也可能会被 js import,2)注意 cycle dependency 的处理。

6、怎么做 extract_css ? 1)transform_in_generate 时,保持 css 是 css,不转成 js,2)在 generate_chunks(具体是在 modules_to_js_stmts)里遇到 css module 时,按顺序 merge 到一个 css 里并返回,需要注意去重,已 merge 的 module_id 不能重复 merge,只 merge 第一个。

7、CSS 的加载。分两种,1)js 的方式,2)extract_css 的方式。前者没啥好说的,用 js 的方式一次创建 style 标签插入 head 元素即可。extract_css 也分两种,1)entry chunk 的 css,2)async chunk 的 css 。entry chunk 的不需要处理加载,用户在引 js 时也同时引入 css 来解。async chunk 的需要额外的加载逻辑。Webpack 的实现如下。

!function() {
    if (typeof document === "undefined") return;
    var createStylesheet = function(chunkId, fullhref, oldTag, resolve, reject) {
        var linkTag = document.createElement("link");

        linkTag.rel = "stylesheet";
        linkTag.type = "text/css";
        var onLinkComplete = function(event) {
            // avoid mem leaks.
            linkTag.onerror = linkTag.onload = null;
            if (event.type === 'load') {
                resolve();
            } else {
                var errorType = event && (event.type === 'load' ? 'missing' : event.type);
                var realHref = event && event.target && event.target.href || fullhref;
                var err = new Error("Loading CSS chunk " + chunkId + " failed.\n(" + realHref + ")");
                err.code = "CSS_CHUNK_LOAD_FAILED";
                err.type = errorType;
                err.request = realHref;
                linkTag.parentNode.removeChild(linkTag)
                reject(err);
            }
        }
        linkTag.onerror = linkTag.onload = onLinkComplete;
        linkTag.href = fullhref;

        if (oldTag) {
            oldTag.parentNode.insertBefore(linkTag, oldTag.nextSibling);
        } else {
            document.head.appendChild(linkTag);
        }
        return linkTag;
    };
    var findStylesheet = function(href, fullhref) {
        var existingLinkTags = document.getElementsByTagName("link");
        for(var i = 0; i < existingLinkTags.length; i++) {
            var tag = existingLinkTags[i];
            var dataHref = tag.getAttribute("data-href") || tag.getAttribute("href");
            if(tag.rel === "stylesheet" && (dataHref === href || dataHref === fullhref)) return tag;
        }
        var existingStyleTags = document.getElementsByTagName("style");
        for(var i = 0; i < existingStyleTags.length; i++) {
            var tag = existingStyleTags[i];
            var dataHref = tag.getAttribute("data-href");
            if(dataHref === href || dataHref === fullhref) return tag;
        }
    };
    var loadStylesheet = function(chunkId) {
        return new Promise(function(resolve, reject) {
            var href = __webpack_require__.miniCssF(chunkId);
            var fullhref = __webpack_require__.p + href;
            if(findStylesheet(href, fullhref)) return resolve();
            createStylesheet(chunkId, fullhref, null, resolve, reject);
        });
    }
    // object to store loaded CSS chunks
    var installedCssChunks = {
        826: 0
    };

    __webpack_require__.f.miniCss = function(chunkId, promises) {
        var cssChunks = {"154":1};
        if(installedCssChunks[chunkId]) promises.push(installedCssChunks[chunkId]);
        else if(installedCssChunks[chunkId] !== 0 && cssChunks[chunkId]) {
            promises.push(installedCssChunks[chunkId] = loadStylesheet(chunkId).then(function() {
                installedCssChunks[chunkId] = 0;
            }, function(e) {
                delete installedCssChunks[chunkId];
                throw e;
            }));
        }
    };

    // no hmr
}();

8、HMR Webpack Runtime 的相关逻辑如下。

var oldTags = [];
var newTags = [];
var applyHandler = function(options) {
    return { dispose: function() {
        for(var i = 0; i < oldTags.length; i++) {
            var oldTag = oldTags[i];
            if(oldTag.parentNode) oldTag.parentNode.removeChild(oldTag);
        }
        oldTags.length = 0;
    }, apply: function() {
        for(var i = 0; i < newTags.length; i++) newTags[i].rel = "stylesheet";
        newTags.length = 0;
    } };
}
__webpack_require__.hmrC.miniCss = function(chunkIds, removedChunks, removedModules, promises, applyHandlers, updatedModulesList) {
    applyHandlers.push(applyHandler);
    chunkIds.forEach(function(chunkId) {
        var href = __webpack_require__.miniCssF(chunkId);
        var fullhref = __webpack_require__.p + href;
        var oldTag = findStylesheet(href, fullhref);
        if(!oldTag) return;
        promises.push(new Promise(function(resolve, reject) {
            var tag = createStylesheet(chunkId, fullhref, oldTag, function() {
                tag.as = "style";
                tag.rel = "preload";
                resolve();
            }, reject);
            oldTags.push(oldTag);
            newTags.push(tag);
        }));
    });
}

9、TODO:Layer + Condition Import。

参考

295

https://github.com/umijs/mako/pull/249

PeachScript commented 1 year ago

所以,需要调整的是,css import css 时,不能用 js 的 require 的方式。计划这样做,1)在 group chunks 时,遇到 css 时就断掉,不往下分析其依赖,2)在 generate_chunks(具体是在 modules_to_js_stmts)里做 css import css 的合并,当遇到 css module 时(此时的 css module 的 parent module 应该全部都是 js module),以此 css module 为入口找到所有依赖,然后深度优先合并依赖到入口 css 模块里。

这个方案是否会导致尺寸增加,公共 CSS 会被多个 CSS 模块重复合并

sorrycc commented 1 year ago

@PeachScript

webpack 会重复;esbuild 会做优化,只保留最后出现的那一个。参考 #295 第 4 点。

PeachScript commented 1 year ago

5、如果没有配 extract_css,会把 css 转 js 输出,每段 css 会通过 style 标签插入到 head 元素里。 6、怎么做 extract_css ? 1)transform_in_generate 时,保持 css 是 css,不转成 js,2)在 generate_chunks(具体是在 modules_to_js_stmts)里遇到 css module 时,按顺序 merge 到一个 css 里并返回,需要注意去重,已 merge 的 module_id 不能重复 merge,只 merge 第一个。

关于 5、6 做了二次调研,同步下相关信息:

Mako 实现思路:

PeachScript commented 1 year ago

几个想到的测试用例,包含与 webpack 及 esbuild 的对比,webpack 使用 mini-css-extract-plugin + cssnano 压缩去重:

  1. 测试 CSS 里的 import 合并顺序及去重:
/* index.css */
@import 'a.css';
@import 'b.css';

/* a.css */
@import 'c.css';
.a {}

/* b.css */
@import 'c.css';
.b {}

/* index.js 只引入 index.css,mako 结果 */
.a {}
.c {}
.b {}

/* webpack 结果 */
.a {}
.c {}
.b {}

/* esbuild 结果 */
.a {}
.c {}
.b {}
  1. 测试 JS 重复 import 合并顺序及去重(CSS 文件内容与 case 1 一致):
// index.js
import './a.css';
import './b.css';
import './c.css'; // 刻意在最后重复引入 c.css

// mako 结果
.a {}
.b {}
.c {}

// webpack 结果
.c {}
.a {}
.b {}

// esbuild 结果
.a {}
.b {}
.c {}
  1. 测试 JS 嵌套重复 import 合并顺序及去重(CSS 文件内容与 case 1 一致):
// a1.js 刻意提升 a.css 的引用层级深度
import './a.css';

// a.js
import './a1';

// b1.js 刻意提升 b.css 的引用层级深度
import './b.css';

// b.js
import './b1';

// index.js
import './a';
import './c.css';
import './b';

// mako 结果
.a {}
.c {}
.b {}

// webpack 结果
.c {}
.a {}
.b {}

// esbuild 结果
.a {}
.c {}
.b {}

MiniCssExtractPlugin 的用例参考:https://github.com/webpack-contrib/mini-css-extract-plugin/tree/master/test/cases

PeachScript commented 1 year ago

更新了一下 3 个用例在 mako/webpack/esbuild 下的执行结果,目前看 mako 和 esbuild 的行为是一致的,webpack 特立独行可能与其 CSS 实现并非原生内置有关(loader + plugin 实现),所以 mako 先维持目前的逻辑,后续在实际 Umi/Bigfish 项目中看下是否会产生 Breaking Change

PeachScript commented 1 year ago

7、CSS 的加载

这部分碰到个卡点,需要搜集 cssChunksIdToUrlMap 的数据用来异步加载 CSS,需要跑完所有 chunks 才能知道每个 chunk 是否存在 CSS 以及文件名是什么,最后再生成 entry chunk;但目前 generate_chunks.rs 里是多线程跑的,无法确保 entry chunk 最后生成。

目前想到的方案:

  1. 不动现有逻辑: a. ✅ 在 generate_chunks 的开始阶段去计算每个 chunks 是否包含 CSS,生成 cssChunksIdToUrlMap 数据给 entry 用 b. 在 generate_chunks 的最后阶段用 AST 去给 entry chunk 设置 cssChunksIdToUrlMap,改造量较小但和 JS Chunks 生成逻辑的一致性较差
  2. 动现有逻辑:把 generate_chunks 的 chunks 遍历拆成两部分,非 entry 先遍历,最后再遍历 chunks,因为涉及公共逻辑复用,预计改造量会比较大但结构上可能更合理
sorrycc commented 1 year ago

已完成。