CSS组件作用域 scoped 原理

12/3/2021, 7:29:47 PM

上篇梳理了 vue-loader 的基本流程,结尾留了个小问题,组件 CSS scoped 如何实现,这篇了解下大致逻辑

实现原理

scoped 的原理很简单,通过CSS的属性选择器实现。组件内的 style 添加了 scoped 属性后,编译后的CSS都会带上一个 data-v-id 的属性,同时组件内的dom也会带有该属性,每个组件id唯一,实现CSS作用域。

模板样式编译

vue-loader通过 postcss 实现添加属性选择器到样式。原理依然是通过 postcss parse 出的样式AST,遍历每个样式节点,依次添加属性。

执行pitcher loader 的pitch 函数中, 当 type === 'style' 时,会注入style-post-loader。源码中也给出了注释,在 css-loader 前注入 style-post-loader 实现 scoped css

export function pitch() {
    // ...

    // Inject style-post-loader before css-loader for scoped CSS and trimming
    if (query.type === `style`) {
        const cssLoaderIndex = loaders.findIndex(isCSSLoader);
        if (cssLoaderIndex > -1) {
            // if inlined, ignore any loaders after css-loader and replace w/ inline
            // loader
            const afterLoaders = query.inline != null
                ? [styleInlineLoaderPath]
                : loaders.slice(0, cssLoaderIndex + 1);
            const beforeLoaders = loaders.slice(cssLoaderIndex + 1);
            return genProxyModule([...afterLoaders, stylePostLoaderPath, ...beforeLoaders], context, !!query.module || query.inline != null);
        }
    }
    // ....
}    
const StylePostLoader = function (source, inMap) {
    const query = qs.parse(this.resourceQuery.slice(1));
    const { code, map, errors } = (0, compiler_sfc_1.compileStyle)({
        source: source,
        filename: this.resourcePath,
        id: `data-v-${query.id}`,
        map: inMap,
        scoped: !!query.scoped,
        trim: true,
        isProd: this.mode === 'production' || process.env.NODE_ENV === 'production',
    });
    if (errors.length) {
        this.callback(errors[0]);
    }
    else {
        this.callback(null, code, map);
    }
};

style-post-loader 具体逻辑在compiler-sfc中,之前 compiler-sfc 是一个独立的依赖,需要手动安装和vue版本对应的包,因为一些npm机制的原因偶尔会由于版本问题导致一些编译后的bug,现在已经集成到了vue包中。

function doCompileStyle(options) {
    const { filename, id, scoped = false, trim = true, isProd = false, modules = false, modulesOptions = {}, preprocessLang, postcssOptions, postcssPlugins } = options;
    // ....
    const source = preProcessedSource ? preProcessedSource.code : options.source;
    const shortId = id.replace(/^data-v-/, '');
    const longId = `data-v-${shortId}`;
    const plugins = (postcssPlugins || []).slice();
    plugins.unshift(cssVarsPlugin({ id: shortId, isProd }));
    if (trim) {
        plugins.push(trimPlugin());
    }
    if (scoped) {
        plugins.push(scopedPlugin(longId));
    }
    let cssModules;
    // ....
    let result;
    let code;
    let outMap;
    // stylus output include plain css. so need remove the repeat item
    const dependencies = new Set(preProcessedSource ? preProcessedSource.dependencies : []);
    // sass has filename self when provided filename option
    dependencies.delete(filename);
    const errors = [];
    if (preProcessedSource && preProcessedSource.errors.length) {
        errors.push(...preProcessedSource.errors);
    }
    const recordPlainCssDependencies = (messages) => {
        messages.forEach(msg => {
            if (msg.type === 'dependency') {
                // postcss output path is absolute position path
                dependencies.add(msg.file);
            }
        });
        return dependencies;
    };
    try {
        result = _postcss__default(plugins).process(source, postCSSOptions);
        // In async mode, return a promise.
        if (options.isAsync) {
            return result
                .then(result => ({
                code: result.css || '',
                map: result.map && result.map.toJSON(),
                errors,
                modules: cssModules,
                rawResult: result,
                dependencies: recordPlainCssDependencies(result.messages)
            }))
                .catch(error => ({
                code: '',
                map: undefined,
                errors: [...errors, error],
                rawResult: undefined,
                dependencies
            }));
        }
        recordPlainCssDependencies(result.messages);
        // force synchronous transform (we know we only have sync plugins)
        code = result.css;
        outMap = result.map;
    }
    catch (e) {
        errors.push(e);
    }
    return {
        code: code || ``,
        map: outMap && outMap.toJSON(),
        errors,
        rawResult: result,
        dependencies
    };
}

该loader实际上就是根据各种条件,添加不同的plugin,最终传入到postcss-loader中进行source处理,最终返回处理后的code。

scoped的实现,依赖  scopedPlugin,当传入的options 有scoped属性时,会在plugins数组中添加该plugin

const scopedPlugin = (id = '') => {
  debugger
    const keyframes = Object.create(null);
    const shortId = id.replace(/^data-v-/, '');
    return {
        postcssPlugin: 'vue-sfc-scoped',
        Rule(rule) {
            processRule(id, rule);
        },
        AtRule(node) {
            if (/-?keyframes$/.test(node.name) &&
                !node.params.endsWith(`-${shortId}`)) {
                // register keyframes
                keyframes[node.params] = node.params = node.params + '-' + shortId;
            }
        },
        OnceExit(root) {
            if (Object.keys(keyframes).length) {
                // If keyframes are found in this <style>, find and rewrite animation names
                // in declarations.
                // Caveat: this only works for keyframes and animation rules in the same
                // <style> element.
                // individual animation-name declaration
                root.walkDecls(decl => {
                    if (animationNameRE.test(decl.prop)) {
                        decl.value = decl.value
                            .split(',')
                            .map(v => keyframes[v.trim()] || v.trim())
                            .join(',');
                    }
                    // shorthand
                    if (animationRE.test(decl.prop)) {
                        decl.value = decl.value
                            .split(',')
                            .map(v => {
                            const vals = v.trim().split(/\s+/);
                            const i = vals.findIndex(val => keyframes[val]);
                            if (i !== -1) {
                                vals.splice(i, 1, keyframes[vals[i]]);
                                return vals.join(' ');
                            }
                            else {
                                return v;
                            }
                        })
                            .join(',');
                    }
                });
            }
        }
    };
};

配置了一些postcss提供的处理不同节点时的钩子函数,核心的部分在 Rule(表示一个 CSS 规则:一个选择器后跟一个声明块)中,当遇到选择器节点时执行 调用processRule 处理节点

function processRule(id, rule) {
    if (processedRules.has(rule) ||
        (rule.parent &&
            rule.parent.type === 'atrule' &&
            /-?keyframes$/.test(rule.parent.name))) {
        return;
    }
    processedRules.add(rule);
    rule.selector = selectorParser(selectorRoot => {
        selectorRoot.each(selector => {
            rewriteSelector(id, selector, selectorRoot);
        });
    }).processSync(rule.selector);
}

processRule 方法核心逻辑是重写 rule 的selector,也就是当前节点的选择器,为选择器添加 [data-v-id] 属性

selectorParser,导入的 postcss 的内置的 postcss-selector-parser 包,主要提供一些处理选择器 selector 的方法。
selectorParser 第一个参数为 transform,传入了一个方法,通过each方法遍历根节点的直接子级,重写每个节点的selector
function rewriteSelector(id, selector, selectorRoot, slotted = false) {
    let node = null;
    let shouldInject = true;
    // find the last child node to insert attribute selector
    selector.each(n => {
        // DEPRECATED ">>>" and "/deep/" combinator
        if (n.type === 'combinator' &&
            (n.value === '>>>' || n.value === '/deep/')) {
            n.value = ' ';
            n.spaces.before = n.spaces.after = '';
            warn(`the >>> and /deep/ combinators have been deprecated. ` +
                `Use :deep() instead.`);
            return false;
        }
        if (n.type === 'pseudo') {
            const { value } = n;
            // deep: inject [id] attribute at the node before the ::v-deep
            // combinator.
            if (value === ':deep' || value === '::v-deep') {
                if (n.nodes.length) {
                    // .foo ::v-deep(.bar) -> .foo[xxxxxxx] .bar
                    // replace the current node with ::v-deep's inner selector
                    let last = n;
                    n.nodes[0].each(ss => {
                        selector.insertAfter(last, ss);
                        last = ss;
                    });
                    // insert a space combinator before if it doesn't already have one
                    const prev = selector.at(selector.index(n) - 1);
                    if (!prev || !isSpaceCombinator(prev)) {
                        selector.insertAfter(n, selectorParser.combinator({
                            value: ' '
                        }));
                    }
                    selector.removeChild(n);
                }
                else {
                    // DEPRECATED usage
                    // .foo ::v-deep .bar -> .foo[xxxxxxx] .bar
                    warn(`::v-deep usage as a combinator has ` +
                        `been deprecated. Use :deep(<inner-selector>) instead.`);
                    const prev = selector.at(selector.index(n) - 1);
                    if (prev && isSpaceCombinator(prev)) {
                        selector.removeChild(prev);
                    }
                    selector.removeChild(n);
                }
                return false;
            }
            // slot: use selector inside `::v-slotted` and inject [id + '-s']
            // instead.
            // ::v-slotted(.foo) -> .foo[xxxxxxx-s]
            if (value === ':slotted' || value === '::v-slotted') {
                rewriteSelector(id, n.nodes[0], selectorRoot, true /* slotted */);
                let last = n;
                n.nodes[0].each(ss => {
                    selector.insertAfter(last, ss);
                    last = ss;
                });
                // selector.insertAfter(n, n.nodes[0])
                selector.removeChild(n);
                // since slotted attribute already scopes the selector there's no
                // need for the non-slot attribute.
                shouldInject = false;
                return false;
            }
            // global: replace with inner selector and do not inject [id].
            // ::v-global(.foo) -> .foo
            if (value === ':global' || value === '::v-global') {
                selectorRoot.insertAfter(selector, n.nodes[0]);
                selectorRoot.removeChild(selector);
                return false;
            }
        }
        if (n.type !== 'pseudo' && n.type !== 'combinator') {
            node = n;
        }
    });
    if (node) {
        node.spaces.after = '';
    }
    else {
        // For deep selectors & standalone pseudo selectors,
        // the attribute selectors are prepended rather than appended.
        // So all leading spaces must be eliminated to avoid problems.
        selector.first.spaces.before = '';
    }
    if (shouldInject) {
        const idToAdd = slotted ? id + '-s' : id;
        selector.insertAfter(
        // If node is null it means we need to inject [id] at the start
        // insertAfter can handle `null` here
        node, selectorParser.attribute({
            attribute: idToAdd,
            value: idToAdd,
            raws: {},
            quoteMark: `"`
        }));
    }
}

rewriteSelector 方法主要用来实现 样式穿透及添加一个 [data-v-id] 的attribute 到 selector 的子节点。样式穿透部分源码中注释也很清楚,实现 scoped 的核心是 selector.insertAfter,给 selector 插入一个新的 attribute 节点

遍历处理完成后继续调用 processSync 方法处理当前 rule 节点的 selector,将 attribute 添加到选择器,最终返回添加了 [data-v-id] 的选择器 string

rule.selector = selectorParser(selectorRoot => {
    selectorRoot.each(selector => {
      rewriteSelector(id, selector, selectorRoot)
    })
  }).processSync(rule.selector)