2

来源

tree-shaking 最早由 Rich Harrisrollup 中提出。

为了减少最终构建体积而诞生。

以下是 MDN 中的说明:

tree-shaking 是一个通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code) 行为的术语。

它依赖于 ES2015 中的 import 和 export 语句,用来检测代码模块是否被导出、导入,且被 JavaScript 文件使用。

在现代 JavaScript 应用程序中,我们使用模块打包(如 webpack 或 Rollup)将多个 JavaScript 文件打包为单个文件时自动删除未引用的代码。这对于准备预备发布代码的工作非常重要,这样可以使最终文件具有简洁的结构和最小化大小。

tree-shaking VS dead code elimination

说起 tree-shaking 不得不说起 dead code elimination,简称 DCE

很多人往往把 tree-shaking 当作是一种实现 DCE 的技术。如果都是同一种东西,最终的目标是一致的(更少的代码)。为什么要重新起一个名字叫做 tree-shaking 呢?

tree-shaking 术语的发明者 Rich Harris 在他写的一篇《tree-shaking versus dead code elimination》告诉了我们答案。

Rich Harris 引用了一个做蛋糕的例子。原文如下:

Bad analogy time: imagine that you made cakes by throwing whole eggs into the mixing bowl and smashing them up, instead of cracking them open and pouring the contents out. Once the cake comes out of the oven, you remove the fragments of eggshell, except that’s quite tricky so most of the eggshell gets left in there.

You’d probably eat less cake, for one thing.

That’s what dead code elimination consists of — taking the finished product, and imperfectly removing bits you don’t want. tree-shaking, on the other hand, asks the opposite question: given that I want to make a cake, which bits of what ingredients do I need to include in the mixing bowl?

Rather than excluding dead code, we’re including live code. Ideally the end result would be the same, but because of the limitations of static analysis in JavaScript that’s not the case. Live code inclusion gets better results, and is prima facie a more logical approach to the problem of preventing our users from downloading unused code.

简单来说:DCE 好比做蛋糕时,直接放入整个鸡蛋,做完时再从蛋糕中取出蛋壳。而 tree-shaking 则是先取出蛋壳,在进行做蛋糕。两者结果相同,但是过程是完全不同的。

dead code

dead code 一般具有以下几个特征:

  • 代码不会被执行,不可到达
  • 代码执行的结果不会被用到
  • 代码只会影响死变量(只写不读)

使用 webpackmode: development 模式下对以下代码进行打包:

function app() {
    var test = '我是app';
    function set() {
        return 1;
    }
    return test;
    test = '无法执行';
    return test;
}

export default app;

最终打包结果:

eval(
    "function app() {\n    var test = '我是app';\n    function set() {\n        return 1;\n    }\n    return test;\n    test = '无法执行';\n    return test;\n}\n\napp();\n\n\n//# sourceURL=webpack://webpack/./src/main.js?"
);

可以看到打包的结果内,还是存在无法执行到的代码块。

webpack 不支持 dead code elimination 吗?是的,webpack 不支持。

原来,在 webpack 中实现 dead code elimination 功能并不是 webpack 本身, 而是大名鼎鼎的 uglify

通过阅读源码发现,在 mode: development 模式下,不会加载 terser-webpack-plugin 插件。

// lib/config/defaults.js
D(optimization, 'minimize', production);
A(optimization, 'minimizer', () => [
    {
        apply: (compiler) => {
            // Lazy load the Terser plugin
            const TerserPlugin = require('terser-webpack-plugin');
            new TerserPlugin({
                terserOptions: {
                    compress: {
                        passes: 2
                    }
                }
            }).apply(compiler);
        }
    }
]);

// lib/WebpackOptionsApply.js
if (options.optimization.minimize) {
    for (const minimizer of options.optimization.minimizer) {
        if (typeof minimizer === 'function') {
            minimizer.call(compiler, compiler);
        } else if (minimizer !== '...') {
            minimizer.apply(compiler);
        }
    }
}

terser-webpack-plugin 插件内部使用了 uglify 实现的。

我们在 mode: production 模式下进行打包。

// 格式化后结果
(() => {
    var r = {
            225: (r) => {
                r.exports = '我是app';
            }
        },
    // ...
})();

可以看到最终的结果,已经删除了不可执行部分的代码。除此之外,还帮我们压缩了代码,删除了注释等功能。

tree shaking 无效

tree shaking 本质上是通过分析静态的 ES 模块,来剔除未使用代码的。

_ESModule_ 的特点

只能作为模块顶层的语句出现,不能出现在 function 里面或是 if 里面。(ECMA-262 15.2)
import 的模块名只能是字符串常量。(ECMA-262 15.2.2)
不管 import 的语句出现的位置在哪里,在模块初始化的时候所有的 import 都必须已经导入完成。(ECMA-262 15.2.1.16.4 - 8.a)
import binding 是 immutable 的,类似 const。比如说你不能 import { a } from ‘./a’ 然后给 a 赋值个其他什么东西。(ECMA-262 15.2.1.16.4 - 12.c.3)
—–引用自尤雨溪

我们来看看 tree shaking 的功效。

我们有一个模块

// ./src/app.js
export const firstName = 'firstName'

export function getName ( x ) {
    return x.a
}

getName({ a: 123 })

export function app ( x ) {
    return x * x * x;
}

export default app;

底下是 7 个实例。

// 1*********************************************
// import App from './app'

// export function main() {
//     var test = '我是index';
//     return test;
// }

// console.log(main)

// 2*********************************************

// import App from './app'

// export function main() {
//     var test = '我是index';
//     console.log(App(1))
//     return test;
// }

// console.log(main)


// 3*********************************************

// import App from './app'

// export function main() {
//     var test = '我是index';
//     App.square(1)
//     return test;
// }

// console.log(main)


// 4*********************************************

// import App from './app'

// export function main() {
//     var test = '我是index';
//     let methodName = 'square'
//     App[methodName](1)
//     return test;
// }

// console.log(main)

// 6*********************************************

// import * as App from './app'

// export function main() {
//     var test = '我是index';
//     App.square(1)
//     return test;
// }

// console.log(main)

// 7*********************************************

// import * as App from './app'

// export function main() {
//     var test = '我是index';
//     let methodName = 'square'
//     App[methodName](1)
//     return test;
// }

// console.log(main)

使用 最简单的webpack配置进行打包

// webpack.config.js
module.exports = {
    entry: './src/index.js',
    output: {
        filename: 'dist.js'
    },
    mode: 'production'
};

通过结果可以看到,前 6 中的打包结果,都对死代码进行了消除,只有第 7 种,消除失败。

/* ... */
const r = 'firstName';
function o(e) {
    return e.a;
}
function n(e) {
    return e * e * e;
}
o({ a: 123 });
const a = n;
console.log(function () {
    return t.square(1), '我是index';
});

本人没有详细了解过,只能猜测下,由于 JavaScript 动态语言的特性使得静态分析比较困难,目前的的解析器是通过静态解析的,还无法分析全量导入,动态使用的语法。

对于更多 tree shaking 执行相关的可以参考一下链接:

当然了,机智的程序员是不会被这个给难住的,既然静态分析不行,那就由开发者手动来将文件标记为无副作用(side-effect-free)。

tree shaking 和 sideEffects

sideEffects 支持两种写法,一种是 false,另一种是数组

  • 如果所有代码都不包含副作用,我们就可以简单地将该属性标记为 false
  • 如果你的代码确实有一些副作用,可以改为提供一个数组

可以在 package.js 中进行设置。

// boolean
{
  "sideEffects": false
}

// array
{
  "sideEffects": ["./src/app.js", "*.css"]
}

也可以在 module.rules 中进行设置。

module.exports = {
  module: {
    rules: [
      {
        test: /\.jsx?$/,
        exclude: /(node_modules)/,
        use: {
          loader: 'babel-loader',
        },
        sideEffects: false || []
      }
    ]
  },
}

设置了 sideEffects: false,后在重新打包

 var e = {
            225: (e, r, t) => {
                (e = t.hmd(e)).exports = '我是main';
            }
        },

只剩下 main.js 模块的代码,已经把 app.js 的代码消除了。

usedExports

webpack 中除了 sideEffects 还提供了一种另一种标记消除的方式。那就是通过配置项 usedExports

由 optimization.usedExports 收集的信息会被其它优化手段或者代码生成使用,比如未使用的导出内容不会被生成,当所有的使用都适配,导出名称会被处理做单个标记字符。 在压缩工具中的无用代码清除会受益于该选项,而且能够去除未使用的导出内容。

mode: productions 下是默认开启的。

module.exports = {
  //...
  optimization: {
    usedExports: true,
  },
};

usedExports 会使用 terser 判断代码有没有 sideEffect,如果没有用到,又没有 sideEffect 的话,就会在打包时替它标记上 unused harmony。

最后由 TerserUglifyJSDCE 工具“摇”掉这部分无效代码。

terser 测试

tree shaking 实现原理

tree shaking 本身也是采用静态分析的方法。

程序静态分析(Static Code Analysis)是指在不运行代码的方式下,通过词法分析、语法分析、控制流分析、数据流分析等技术对程序代码进行扫描,验证代码是否满足规范性、安全性、可靠性、可维护性等指标的一种代码分析技术

tree shaking 使用的前提是模块必须采用ES6Module语法,因为tree Shaking 依赖 ES6 的语法:importexport

接下来我们来看看远古版本的 rollup 是怎么实现 tree shaking 的。

  1. 根据入口模块内容初始化 Module,并使用 acorn 进行 ast 转化
  2. 分析 ast。 寻找 importexport 关键字,建立依赖关系
  3. 分析 ast,收集当前模块存在的函数、变量等信息
  4. 再一次分析 ast, 收集各函数变量的使用情况,因为我们是根据依赖关系进行收集代码,如果函数变量未被使用,
  5. 根据收集到的函数变量标识符等信息,进行判断,如果是 import,则进行 Module 的创建,重新走上几步。否则的话,把对应的代码信息存放到一个统一的 result 中。
  6. 根据最终的结果生成 bundle

file

源码版本:v0.3.1

通过 entry 入口文件进行创建 bundle,执行 build 方法,开始进行打包。

export function rollup ( entry, options = {} ) {
    const bundle = new Bundle({
        entry,
        resolvePath: options.resolvePath
    });

    return bundle.build().then( () => {
        return {
            generate: options => bundle.generate( options ),
            write: ( dest, options = {} ) => {
                let { code, map } = bundle.generate({
                    dest,
                    format: options.format,
                    globalName: options.globalName
                });

                code += `\n//# ${SOURCEMAPPING_URL}=${basename( dest )}.map`;

                return Promise.all([
                    writeFile( dest, code ),
                    writeFile( dest + '.map', map.toString() )
                ]);
            }
        };
    });
}

build 内部执行 fetchModule 方法,根据文件名,readFile 读取文件内容,创建 Module

build () {
    return this.fetchModule( this.entryPath, null )
        .then( entryModule => {
            this.entryModule = entryModule;

            if ( entryModule.exports.default ) {
                let defaultExportName = makeLegalIdentifier( basename( this.entryPath ).slice( 0, -extname( this.entryPath ).length ) );
                while ( entryModule.ast._scope.contains( defaultExportName ) ) {
                    defaultExportName = `_${defaultExportName}`;
                }

                entryModule.suggestName( 'default', defaultExportName );
            }

            return entryModule.expandAllStatements( true );
        })
        .then( statements => {
            this.statements = statements;
            this.deconflict();
        });
}

fetchModule ( importee, importer ) {
    return Promise.resolve( importer === null ? importee : this.resolvePath( importee, importer ) )
        .then( path => {
                /*
                    缓存处理
                */

                this.modulePromises[ path ] = readFile( path, { encoding: 'utf-8' })
                    .then( code => {
                        const module = new Module({
                            path,
                            code,
                            bundle: this
                        });

                        return module;
                    });

            return this.modulePromises[ path ];
        });
}

根据读取到的文件内容,使用 acorn 编译器进行进行 ast 的转化。

// 
export default class Module {
    constructor ({ path, code, bundle }) {
        /*
        初始化
        */
        this.ast = parse(code, {
            ecmaVersion: 6,
            sourceType: 'module',
            onComment: (block, text, start, end) =>
            this.comments.push({ block, text, start, end })
        });
        this.analyse();
    }

file

遍历节点信息。寻找 importexport 关键字,这一步就是我们常说的根据 esm 的静态结构进行分析。

import 的信息,收集到 this.imports 对象中,把 exports 的信息,收集到 this.exports 中.

this.ast.body.forEach( node => {
    let source;
    if ( node.type === 'ImportDeclaration' ) {
        source = node.source.value;

        node.specifiers.forEach( specifier => {
            const isDefault = specifier.type === 'ImportDefaultSpecifier';
            const isNamespace = specifier.type === 'ImportNamespaceSpecifier';

            const localName = specifier.local.name;
            const name = isDefault ? 'default' : isNamespace ? '*' : specifier.imported.name;

            if ( has( this.imports, localName ) ) {
                const err = new Error( `Duplicated import '${localName}'` );
                err.file = this.path;
                err.loc = getLocation( this.code.original, specifier.start );
                throw err;
            }

            this.imports[ localName ] = {
                source, // 模块id
                name,
                localName
            };
        });
    }

    else if ( /^Export/.test( node.type ) ) {
        if ( node.type === 'ExportDefaultDeclaration' ) {
            const isDeclaration = /Declaration$/.test( node.declaration.type );

            this.exports.default = {
                node,
                name: 'default',
                localName: isDeclaration ? node.declaration.id.name : 'default',
                isDeclaration
            };
        }

        else if ( node.type === 'ExportNamedDeclaration' ) {
            // export { foo } from './foo';
            source = node.source && node.source.value;

            if ( node.specifiers.length ) {
                node.specifiers.forEach( specifier => {
                    const localName = specifier.local.name;
                    const exportedName = specifier.exported.name;

                    this.exports[ exportedName ] = {
                        localName,
                        exportedName
                    };

                    if ( source ) {
                        this.imports[ localName ] = {
                            source,
                            localName,
                            name: exportedName
                        };
                    }
                });
            }

            else {
                let declaration = node.declaration;

                let name;

                if ( declaration.type === 'VariableDeclaration' ) {
                    name = declaration.declarations[0].id.name;
                } else {
                    name = declaration.id.name;
                }

                this.exports[ name ] = {
                    node,
                    localName: name,
                    expression: declaration
                };
            }
        }
    }
}

file

    analyse () {
        // imports and exports, indexed by ID
        this.imports = {};
        this.exports = {};

        // 遍历 ast 查找对应的 import、export 关联
        this.ast.body.forEach( node => {
            let source;

            // import foo from './foo';
            // import { bar } from './bar';
            if ( node.type === 'ImportDeclaration' ) {
                source = node.source.value;

                node.specifiers.forEach( specifier => {
                    const isDefault = specifier.type === 'ImportDefaultSpecifier';
                    const isNamespace = specifier.type === 'ImportNamespaceSpecifier';

                    const localName = specifier.local.name;
                    const name = isDefault ? 'default' : isNamespace ? '*' : specifier.imported.name;

                    if ( has( this.imports, localName ) ) {
                        const err = new Error( `Duplicated import '${localName}'` );
                        err.file = this.path;
                        err.loc = getLocation( this.code.original, specifier.start );
                        throw err;
                    }

                    this.imports[ localName ] = {
                        source, // 模块id
                        name,
                        localName
                    };
                });
            }

            else if ( /^Export/.test( node.type ) ) {
                // export default function foo () {}
                // export default foo;
                // export default 42;
                if ( node.type === 'ExportDefaultDeclaration' ) {
                    const isDeclaration = /Declaration$/.test( node.declaration.type );

                    this.exports.default = {
                        node,
                        name: 'default',
                        localName: isDeclaration ? node.declaration.id.name : 'default',
                        isDeclaration
                    };
                }

                // export { foo, bar, baz }
                // export var foo = 42;
                // export function foo () {}
                else if ( node.type === 'ExportNamedDeclaration' ) {
                    // export { foo } from './foo';
                    source = node.source && node.source.value;

                    if ( node.specifiers.length ) {
                        // export { foo, bar, baz }
                        node.specifiers.forEach( specifier => {
                            const localName = specifier.local.name;
                            const exportedName = specifier.exported.name;

                            this.exports[ exportedName ] = {
                                localName,
                                exportedName
                            };

                            if ( source ) {
                                this.imports[ localName ] = {
                                    source,
                                    localName,
                                    name: exportedName
                                };
                            }
                        });
                    }

                    else {
                        let declaration = node.declaration;

                        let name;

                        if ( declaration.type === 'VariableDeclaration' ) {
                            name = declaration.declarations[0].id.name;
                        } else {
                            name = declaration.id.name;
                        }

                        this.exports[ name ] = {
                            node,
                            localName: name,
                            expression: declaration
                        };
                    }
                }
            }
        }

        // 查找函数,变量,类,块级作用与等,并根据引用关系进行关联
        analyse( this.ast, this.code, this );     
}

接下来查找函数,变量,类,块级作用与等,并根据引用关系进行关联。

使用 magicString 为每一个 statement 节点增加内容修改的功能。

遍历整颗 ast 树,先初始化一个 Scope,作为当前模块的命名空间。如果是函数或块级作用域等则新建一个 Scope。各 Scope 之间通过 parent 进行关联,建立起一个根据命名空间关系树。

如果是变量和函数,则与当前的 Scope 进行关联, 把对应的标识符名称增加到 Scope 的中。到这一步,已经收集到了各节点上出现的函数和变量。

file

接下来,再一次遍历 ast。查找变量函数,是否只是被读取过,或者只是修改过。

根据 Identifier 类型查找标识符,如果当前标识符能在 Scope 中找到,说明有对其进行过读取。存放在 _dependsOn 集合中。

file

接下来根据 AssignmentExpressionUpdateExpressionCallExpression 类型节点,收集我们的标识符,有没有被修改过或被当前参数传递过。并将结果存放在 _modifies 中。

function analyse(ast, magicString, module) {
    var scope = new Scope();
    var currentTopLevelStatement = undefined;

    function addToScope(declarator) {
        var name = declarator.id.name;
        scope.add(name, false);

        if (!scope.parent) {
            currentTopLevelStatement._defines[name] = true;
        }
    }

    function addToBlockScope(declarator) {
        var name = declarator.id.name;
        scope.add(name, true);

        if (!scope.parent) {
            currentTopLevelStatement._defines[name] = true;
        }
    }

    // first we need to generate comprehensive scope info
    var previousStatement = null;
    var commentIndex = 0;

    ast.body.forEach(function (statement) {
        currentTopLevelStatement = statement; // so we can attach scoping info

        Object.defineProperties(statement, {
            _defines: { value: {} },
            _modifies: { value: {} },
            _dependsOn: { value: {} },
            _included: { value: false, writable: true },
            _module: { value: module },
            _source: { value: magicString.snip(statement.start, statement.end) }, // TODO don't use snip, it's a waste of memory
            _margin: { value: [0, 0] },
            _leadingComments: { value: [] },
            _trailingComment: { value: null, writable: true } });

        var trailing = !!previousStatement;

        // attach leading comment
        do {
            var comment = module.comments[commentIndex];

            if (!comment || comment.end > statement.start) break;

            // attach any trailing comment to the previous statement
            if (trailing && !/\n/.test(magicString.slice(previousStatement.end, comment.start))) {
                previousStatement._trailingComment = comment;
            }

            // then attach leading comments to this statement
            else {
                statement._leadingComments.push(comment);
            }

            commentIndex += 1;
            trailing = false;
        } while (module.comments[commentIndex]);

        // determine margin
        var previousEnd = previousStatement ? (previousStatement._trailingComment || previousStatement).end : 0;
        var start = (statement._leadingComments[0] || statement).start;

        var gap = magicString.original.slice(previousEnd, start);
        var margin = gap.split('\n').length;

        if (previousStatement) previousStatement._margin[1] = margin;
        statement._margin[0] = margin;

        walk(statement, {
            enter: function (node) {
                var newScope = undefined;

                magicString.addSourcemapLocation(node.start);

                switch (node.type) {
                    case 'FunctionExpression':
                    case 'FunctionDeclaration':
                    case 'ArrowFunctionExpression':
                        var names = node.params.map(getName);

                        if (node.type === 'FunctionDeclaration') {
                            addToScope(node);
                        } else if (node.type === 'FunctionExpression' && node.id) {
                            names.push(node.id.name);
                        }

                        newScope = new Scope({
                            parent: scope,
                            params: names, // TODO rest params?
                            block: false
                        });

                        break;

                    case 'BlockStatement':
                        newScope = new Scope({
                            parent: scope,
                            block: true
                        });

                        break;

                    case 'CatchClause':
                        newScope = new Scope({
                            parent: scope,
                            params: [node.param.name],
                            block: true
                        });

                        break;

                    case 'VariableDeclaration':
                        node.declarations.forEach(node.kind === 'let' ? addToBlockScope : addToScope); // TODO const?
                        break;

                    case 'ClassDeclaration':
                        addToScope(node);
                        break;
                }

                if (newScope) {
                    Object.defineProperty(node, '_scope', { value: newScope });
                    scope = newScope;
                }
            },
            leave: function (node) {
                if (node === currentTopLevelStatement) {
                    currentTopLevelStatement = null;
                }

                if (node._scope) {
                    scope = scope.parent;
                }
            }
        });

        previousStatement = statement;
    });

    // then, we need to find which top-level dependencies this statement has,
    // and which it potentially modifies
    ast.body.forEach(function (statement) {
        function checkForReads(node, parent) {
            if (node.type === 'Identifier') {
                // disregard the `bar` in `foo.bar` - these appear as Identifier nodes
                if (parent.type === 'MemberExpression' && node !== parent.object) {
                    return;
                }

                // disregard the `bar` in { bar: foo }
                if (parent.type === 'Property' && node !== parent.value) {
                    return;
                }

                var definingScope = scope.findDefiningScope(node.name);

                if ((!definingScope || definingScope.depth === 0) && !statement._defines[node.name]) {
                    statement._dependsOn[node.name] = true;
                }
            }
        }

        function checkForWrites(node) {
            function addNode(node, disallowImportReassignments) {
                while (node.type === 'MemberExpression') {
                    node = node.object;
                }

                // disallow assignments/updates to imported bindings and namespaces
                if (disallowImportReassignments && has(module.imports, node.name) && !scope.contains(node.name)) {
                    var err = new Error('Illegal reassignment to import \'' + node.name + '\'');
                    err.file = module.path;
                    err.loc = getLocation(module.code.toString(), node.start);
                    throw err;
                }

                if (node.type !== 'Identifier') {
                    return;
                }

                statement._modifies[node.name] = true;
            }

            if (node.type === 'AssignmentExpression') {
                addNode(node.left, true);
            } else if (node.type === 'UpdateExpression') {
                addNode(node.argument, true);
            } else if (node.type === 'CallExpression') {
                node.arguments.forEach(function (arg) {
                    return addNode(arg, false);
                });
            }

            // TODO UpdateExpressions, method calls?
        }

        walk(statement, {
            enter: function (node, parent) {
                // skip imports
                if (/^Import/.test(node.type)) return this.skip();

                if (node._scope) scope = node._scope;

                checkForReads(node, parent);
                checkForWrites(node, parent);

                //if ( node.type === 'ReturnStatement')
            },
            leave: function (node) {
                if (node._scope) scope = scope.parent;
            }
        });
    });

    ast._scope = scope;
}

执行完结果如下:

file

在上一步种,我们为函数,变量,类,块级作用与等声明与我们当前节点进行了关联,现在要把节点上的这些信息,统一收集起来,放到 Module

//  
this.ast.body.forEach( statement => {
    Object.keys( statement._defines ).forEach( name => {
        this.definitions[ name ] = statement;
    });

    Object.keys( statement._modifies ).forEach( name => {
        if ( !has( this.modifications, name ) ) {
            this.modifications[ name ] = [];
        }

        this.modifications[ name ].push( statement );
    });
});

file

从中我们可以看到每个 statement 中,依赖了哪些,修改了哪些。

当我们在入口模块的操作完成后,在遍历 statement 节点,根据 _dependsOn 的中的信息,执行 define

如果 _dependsOn 的数据,在 this.imports 中,能够找到,说明该标识符是一个导入模块,调用 fetchModule 方法,重复上面的逻辑。

如果是正常函数变量之类的,则收集对应 statement 。执行到最后,我们就可以把相关联的 statement 都收集起来,未被收集到,说明其就是无用代码,已经被过滤了。

最后在重组成 bundle,通过 fs 在发送到我们的文件。

留在最后

tree shaking 还要很多点值得挖掘,如:

  • css 的 tree shaking
  • webpack 的 tree shaking 实现
  • 如何避免 tree shaking 无效
  • ...

参考资料


袋鼠云数栈UED
277 声望33 粉丝

我们是袋鼠云数栈 UED 团队,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。


下一篇 »
CSS SandBox