如何手动将 svelte 组件编译为 sapper/svelte 生成的最终 javascript 和 css?

Posted

技术标签:

【中文标题】如何手动将 svelte 组件编译为 sapper/svelte 生成的最终 javascript 和 css?【英文标题】:How can I manually compile a svelte component down to the final javascript and css that sapper/svelte produces? 【发布时间】:2021-04-05 14:28:25 【问题描述】:

我们公司生产一个用 svelte/sapper 编写的自动化框架。一个特点是开发人员可以创建自定义 ui 小部件,目前使用纯 js/html/css 和我们的客户端 api。这些小部件存储在数据库中,而不是文件系统中。

我认为允许他们将小部件创建为 svelte 组件将是一个很大的优势,因为它在一个位置包含所有标记、js 和 css,并且会给他们带来 svelte 反应性的所有好处。

我已经创建了一个使用 svelte 的服务器 API 编译组件的端点,但这似乎只是生成了一个模块,该模块已准备好让 rollup-plugin-svelte/sapper/babel 完成生成浏览器可以完成的工作使用。

如何手动将 svelte 组件编译成 sapper/svelte 生成的最终 javascript 和 css。

【问题讨论】:

这里可以使用REPL,点击右侧的“JS输出”或“CSS输出”选项卡。 svelte.dev/repl/hello-world?version=3 【参考方案1】:

哎呀,艰难的一个。坚持住。

您实际上缺少的是“链接”,即将编译代码中的import 语句解析为浏览器可以使用的内容。这是通常由打包程序完成的工作(例如 Rollup、Webpack...)。

这些导入可以来自用户(小部件开发人员)代码。例如:

import  onMount  from 'svelte'
import  readable  from 'svelte/store'
import  fade  from 'svelte/transition'
import Foo from './Foo.svelte'

或者它们可以由编译器注入,具体取决于组件中使用的功能。例如:

// those ones are inescapable (bellow is just an example, you'll 
// get different imports depending on what the compiled component 
// actually does / uses)
import 
  SvelteComponent,
  detach,
  element,
  init,
  insert,
  noop,
  safe_not_equal,
 from 'svelte/internal'

Svelte 将 .svelte 编译为 .js 和可选的 .css,但它不会对代码中的导入做任何事情。相反,它添加了一些(但仍然没有解决它们,它超出了它的范围)。

您需要解析编译后的代码以查找那些来自编译器的原始导入,这些导入可能指向您的文件系统和node_modules 目录上的路径,并将它们重写为对浏览器有意义的内容 - - 也就是 URLs...

看起来不是很有趣,是吗? (或者太多,取决于你如何看待事物......)幸运的是,你并不孤单,我们有非常强大的工具专门用于这项任务:进入捆绑器!

解决链接问题

解决这个问题的一个相对简单的方法(还有更多,不要太早兴奋)是编译你的小部件,不是使用 Svelte 的编译器 API,而是使用 Rollup 和 Svelte 插件。

Svelte 插件基本上完成了您使用编译器 API 所做的工作,但 Rollup 还将完成重新连接导入和依赖项的所有艰苦工作,以生成浏览器可以使用的整洁的小包(捆绑包)(即不依赖于您的文件系统)。

您可以使用这样的汇总配置编译一个小部件(此处为 Foo.svelte):

rollup.config.Foo.js

import svelte from 'rollup-plugin-svelte'
import commonjs from '@rollup/plugin-commonjs'
import resolve from '@rollup/plugin-node-resolve'
import css from 'rollup-plugin-css-only'
import  terser  from 'rollup-plugin-terser'

const production = !process.env.ROLLUP_WATCH

// include CSS in component's JS for ease of use
//
// set to true to get separate CSS for the component (but then,
// you'll need to inject it yourself at runtime somehow)
//
const emitCss = false

const cmp = 'Foo'

export default 
  // our widget as input
  input: `widgets/$cmp.svelte`,

  output: 
    format: 'es',
    file: `public/build/widgets/$cmp.js`,
    sourcemap: true,
  ,

  // usual plugins for Svelte... customize as needed
  plugins: [
    svelte(
      emitCss,
      compilerOptions: 
        dev: !production,
      ,
    ),

    emitCss && css( output: `$cmp.css` ),

    resolve(
      browser: true,
      dedupe: ['svelte'],
    ),
    commonjs(),
    production && terser(),
  ],

这里没什么特别的...这基本上是来自官方 Svelte 模板的配置,减去与开发服务器有关的部分。

将上述配置与如下命令一起使用:

rollup --config rollup.config.Foo.js

您将在public/build/Foo.js 中获得浏览器已编译好的 Foo 小部件!

Rollup 还有一个 JS API,因此您可以根据需要从 Web 服务器或其他任何地方以编程方式运行它。

然后你就可以动态导入这个模块,然后在你的应用中使用类似这样的东西:

const widget = 'Foo'
const url = `/build/widgets/$widget.js`

const  default: WidgetComponent  = await import(url)

const cmp = new WidgetComponent( target, props )

在您的情况下可能需要动态导入,因为您在构建主应用程序时不会知道小部件 - 因此您需要在运行时像上面一样动态构建导入 URL。请注意,导入 URL 是动态字符串这一事实将阻止 Rollup 在捆绑时尝试解析它。这意味着导入将在浏览器中以上述方式结束,并且它必须是浏览器能够解析的 URL(不是您机器上的文件路径)。

这是因为我们使用浏览器原生动态导入编译的小部件,我们需要在汇总配置中将 output.format 设置为 es。 Svelte 组件将使用现代浏览器本机理解的 export default ... 语法公开。

当前浏览器很好地支持动态导入。值得注意的例外是“旧”Edge(在它本质上成为 Chrome 之前)。如果您需要支持旧版浏览器,可以使用 polyfills(实际上其中有很多——例如 dimport)。

此配置可以进一步自动化,以便能够编译任何小部件,而不仅仅是Foo。例如,像这样:

rollup.config.widget.js

... // same as above essentially

// using Rollup's --configXxx feature to dynamically generate config
export default ( configWidget: cmp ) => (
  input: `widgets/$cmp.svelte`,
  output: 
    ...
    file: `public/build/widgets/$cmp.js`,
  ,
  ...
)

然后你可以像这样使用它:

rollup --config rollup.config.widget.js --configTarget Bar

我们正在取得进展,但仍有一些注意事项和障碍需要注意(并且可能会进一步优化——您的电话)。

警告:共享依赖项

上述方法应该为您的小部件提供已编译的代码,您可以在浏览器中运行,没有未解析的导入。好的。但是,它通过在构建时解析给定小部件的所有依赖项,并将所有这些依赖项捆绑在同一个文件中来实现。

另外说,在多个小部件之间共享的所有依赖项将为每个小部件复制,特别是 Svelte 依赖项(即从 sveltesvelte/* 导入)。这并不全是坏事,因为它为您提供了非常独立的小部件......不幸的是,这也为您的小部件代码增加了一些权重。我们说的可能是 20-30 kb 的 JS 添加到每个小部件中,这些小部件可以在所有小部件之间共享。

此外,我们很快就会看到,在您的应用中拥有 Svelte 内部的独立副本有一些我们需要考虑的缺点...

提取公共依赖项以便共享而不是复制它们的一种简单方法是一次性捆绑所有小部件。这可能不适用于所有用户的所有小部件,但也许可以在个人用户级别上实现?

无论如何,这是一般的想法。您可以将上述汇总配置更改为以下内容:

rollup.config.widget-all.js

...

export default 
  input: ['widgets/Foo.svelte', 'widgets/Bar.svelte', ...],
  output: 
    format: 'es',
    dir: 'public/build/widgets',
  ,
  ...

我们正在传递一组文件,而不是一个文件,就像input(您可能会通过在给定目录中列出文件来自动执行此步骤),并且我们正在将output.file 更改为output.dir ,因为现在我们将一次生成多个文件。这些文件将包含汇总将提取的小部件的常见依赖项,并且所有小部件将在它们之间共享以供重用。

进一步的观点

通过自己提取一些共享依赖项(例如,Svelte...)并将它们作为 URL 提供给浏览器(即通过您的 Web 服务器提供它们),可以进一步推动。这样,您可以将编译代码中的这些导入重写为那些已知的 URL,而不是依赖 Rollup 来解析它们。

这将完全减少代码重复,减轻重量,而且这将允许在使用它们的所有小部件之间共享这些依赖项的单个版本。这样做还可以减轻同时构建所有共享依赖项的小部件的需要,这很有吸引力......但是,这将非常(!)复杂的设置,并且您实际上会快速达到收益递减。

实际上,当您将一堆小部件(或什至只是一个)捆绑在一起并让 Rollup 提取依赖项时,捆绑程序可能知道消费代码实际需要依赖项的哪些部分,并且跳过其余部分(请记住:Rollup 是用摇树作为其主要优先级之一 - 如果不是一个 - 构建的,而 Svelte 是由同一个人构建的 - 意思是:你可以期望 Svelte 是 非常 摇树友好!)。另一方面,如果您自己手动提取一些依赖项:它无需一次性捆绑所有消费代码,但您将不得不公开整个消费依赖项,因为您无法提前知道他们需要的部分。

您需要在什么是有效的和什么是实用的之间找到一个平衡点,因为每个解决方案都会增加您的设置的复杂性。鉴于您的用例,我自己的感觉是,最佳点是完全独立地捆绑每个小部件,或者将来自同一用户的一堆小部件捆绑在一起以节省一些重量,如上所述。更加努力可能会是一个有趣的技术挑战,但它只会获得一点点额外的好处,但复杂性会爆炸式增长......

好的,我们现在知道如何为浏览器捆绑我们的小部件。我们甚至可以在一定程度上控制如何完全独立地打包我们的小部件,或者承担一些额外的基础设施复杂性来共享它们之间的依赖关系并减轻一些重量。现在,当我们决定如何制作漂亮的小数据包(错误、捆绑)时,我们需要考虑一个特殊的依赖关系:这就是 Svelte 本身......

注意陷阱:Svelte 无法复制

所以我们知道,当我们用 Rollup 捆绑单个小部件时,它的所有依赖项都将包含在“捆绑包”中(在这种情况下只是一个小部件文件)。如果您以这种方式捆绑 2 个小部件并且它们共享一些依赖项,则这些依赖项将在每个捆绑包中重复。特别是,您将获得 2 个 Svelte 副本,每个小部件中都有一个。同样,与某些小部件共享的“主”应用程序的依赖项仍然会在浏览器中复制。您将拥有相同代码的多个副本,这些副本将被这些不同的捆绑包使用——您的应用程序、不同的小部件......

但是,您需要了解 Svelte 的一些特别之处:它不支持复制。 svelte/internal 模块是有状态的,它包含一些全局变量,如果您有此代码的多个副本(见上文),这些变量将被复制。这意味着,在实践中,不使用相同 Svelte 内部副本的 Svelte 组件不能一起使用。

例如,如果您有一个App.svelte 组件(您的主应用程序)和一个Foo.svelte 组件(例如一个用户小部件)已经独立捆绑,那么您不能在App 中使用Foo ,否则你会得到奇怪的错误。

这行不通:

App.svelte

<script>
  // as we've seen, in real life, this would surely be a 
  // dynamic import but whatever, you get the idea
  import Foo from '/build/widgets/Foo.js'
</script>

<!-- NO -->
<Foo />

<!-- NO -->
<svelte:component this=Foo />

这也是您在官方 Svelte 模板的汇总配置中具有此 dedupe: ['svelte'] 选项的原因...这是为了防止捆绑 Svelte 的不同副本,例如,如果您曾经使用过链接包,就会发生这种情况。

无论如何,在您的情况下,最终在浏览器中使用多个 Svelte 副本是不可避免的,因为您可能不想在用户添加或更改其中一个小部件时重建整个主应用程序。 . 除了不遗余力地自己提取、集中和重写 Svelte 导入;但是,正如我所说,我认为这不是一个合理且可持续的方法。

所以我们被困住了。

我们是吗?

只有当冲突的组件是同一组件树的一部分时,才会出现重复 Svelte 副本的问题。也就是说,当您让 Svelte 创建和管理组件实例时,就像上面一样。当您自己创建和管理组件实例时,问题不存在。

...

const foo = new Foo( target: document.querySelector('#foo') )

const bar = new Bar( target: document.querySelector('#bar') )

就 Svelte 而言,这里的 foobar 将是完全独立的组件树。这样的代码将始终有效,与编译和捆绑 FooBar 的方式和时间(以及使用哪个 Svelte 版本等)无关。

据我了解您的用例,这不是主要障碍。您将无法使用 &lt;svelte:component /&gt; 之类的东西将用户的小部件嵌入到您的主应用程序中......但是,没有什么能阻止您自己在正确的位置创建和管理小部件实例。您可以创建一个包装器组件(在您的主应用程序中)来推广这种方法。像这样的:

Widget.svelte

<script>
  import  onDestroy  from 'svelte'

  let component
  export  component as this 

  let target
  let cmp

  const create = () => 
    cmp = new component(
      target,
      props: $$restProps,
    )
  

  const cleanup = () => 
    if (!cmp) return
    cmp.$destroy()
    cmp = null
  

  $: if (component && target) 
    cleanup()
    create()
  

  $: if (cmp) 
    cmp.$set($$restProps)
  

  onDestroy(cleanup)
</script>

<div bind:this=target />

我们从我们的主应用程序创建一个目标 DOM 元素,在其中渲染一个“外部”组件,传递所有道具(我们正在代理响应性),并且不要忘记在我们的代理组件被销毁时进行清理。

这种方法的主要限制是应用的 Svelte 上下文 (setContext / getContext) 对代理组件不可见。

再一次,在小部件用例中,这似乎不是一个问题——也许更好:我们真的希望小部件能够访问周围应用程序的每一部分吗?如果确实需要,您始终可以通过 props 将一些上下文传递给小部件组件。

上面的Widget 代理组件将在您的主应用程序中像这样使用:

<script>
  import Widget from './Widget.svelte'

  const widgetName = 'Foo'

  let widget

  import(`/build/widgets/$widgetName.js`)
    .then(module => 
      widget = module.default
    )
    .catch(err => 
      console.error(`Failed to load $widgetName`, err)
    )
</script>

#if widget
  <Widget this=widget prop="Foo" otherProp="Bar" />
/if

然后……我们到了?总结一下吧!

总结

使用 Rollup 编译您的小部件,而不是直接使用 Svelte 编译器,以生成浏览器就绪包。

在简单、重复和额外重量之间找到适当的平衡。

使用动态导入来使用您的小部件,这些小部件将独立于您的主应用程序在浏览器中构建。

不要尝试将不使用同一个 Svelte 副本的组件混合在一起(本质上意味着捆绑在一起,除非您已经启动了一些非凡的 hack)。一开始它可能看起来有效,但它不会。

【讨论】:

我还在消化所有这些,但让我说谢谢你抽出时间来如此彻底地回答。 好的,这是我在 S.O. 上见过的最神奇的答案。 1000 票。再次感谢。 @rixo 很抱歉,*** 不是写一本关于这个主题的精彩书籍的地方,你这该死的传奇。 (说真的,谢谢,这是一个非常宝贵的资源。):) @rixo,哇!多么了不起的文章啊!我能够使用 external 汇总选项删除 sveltesvelte/internals 运行时。具体来说,external: ['svelte', 'svelte/internal'] 现在我可以在浏览器或父捆绑器中进行后期绑定。再次感谢您的写作! 正是我想要的。谢谢! @rixo:有没有办法监听代理/包装器组件上的调度事件?典型的“on”指令结合子组件的“dispatch”动作不起作用。【参考方案2】:

感谢@rixo 的详细帖子,我得以完成这项工作。我基本上像这样创建了一个 rollup.widget.js:

import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import svelte from 'rollup-plugin-svelte';
import path from "path";
import fs from "fs";

let basePath = path.join(__dirname,'../widgets');
let srcFiles = fs.readdirSync(basePath).filter(f=>path.extname(f) === '.svelte').map(m=> path.join(basePath,m ));

export default 
    input: srcFiles,
    output: 
        format: 'es',
        dir: basePath,
        sourcemap: true,
    ,
    plugins: [
        json(),
        svelte(
            emitCss: false,
            compilerOptions: 
                dev: false,
            ,
        ),
        resolve(
            browser: true,
            dedupe: ['svelte']
        ),
        commonjs()
    ]

然后从数据库中生成svelte组件并编译:

const loadConfigFile = require('rollup/dist/loadConfigFile');
        
function compile(widgets)

    return new Promise(function(resolve, reject)
        let basePath = path.join(__dirname,'../widgets');
        
        if (!fs.existsSync(basePath))
            fs.mkdirSync(basePath);
        

        for (let w of widgets)
            if (w.config.source)
                let srcFile = path.join(basePath,w.name + '.svelte');
                fs.writeFileSync(srcFile,w.config.source);
                console.log('writing widget source file:', srcFile)
            
        

        //ripped off directly from the rollup docs
        loadConfigFile(path.resolve(__dirname, 'rollup.widgets.js'),  format: 'es' ).then(
            async ( options, warnings ) => 
                console.log(`widget warning count: $warnings.count`);
                warnings.flush();

                for (const optionsObj of options) 
                    const bundle = await rollup(optionsObj);
                    await Promise.all(optionsObj.output.map(bundle.write));
                

                resolve(success: true);
            
        ).catch(function(x)
            reject(x);
        )    
    )    

然后按照@rixo 的建议使用动态小部件:

<script>
    import onMount, onDestroy, tick from 'svelte';
    import Widget from "../containers/Widget.svelte";

    export let title = '';
    export let name = '';
    export let config = ;

    let component;
    let target;

    $: if (name)
        loadComponent().then(f=>).catch(x=> console.warn(x.message));
    

    onMount(async function () 
        console.log('svelte widget mounted');
    )

    onDestroy(cleanup);

    async function cleanup()
        if (component)
            console.log('cleaning up svelte widget');
            component.$destroy();
            component = null;
            await tick();
        
    

    async function loadComponent()
        await cleanup();
        let url = `/widgets/$name.js?$parseInt(Math.random() * 1000000)`
        let comp = await import(url);
        component = new comp.default(
            target: target,
            props: config.props || 
        )
        console.log('loading svelte widget component:', url);
    

</script>
<Widget name=name title=title ...config>
    <div bind:this=target class="svelte-widget-wrapper"></div>
</Widget>

一些注释/观察:

    我使用 rollup/dist/loadConfigFile 比直接使用 rollup.rollup 要好得多。 我陷入了一个兔子洞,试图为所有苗条模块创建客户端和服务器全局变量,并在小部件汇总中将它们标记为外部,以便所有内容都使用相同的苗条内部结构。这最终变得一团糟,并且让小部件可以访问比我想要的更多的东西。 如果您尝试使用 @rixo 永远是对的。我提前被警告过这些事情,结果完全符合预期。

【讨论】:

还想知道这是否可以使用esbuild 来实现。本周将有一场比赛,如果我设法得到一些工作,请报告。 有趣。我很想知道 esbuild 方法的结果。

以上是关于如何手动将 svelte 组件编译为 sapper/svelte 生成的最终 javascript 和 css?的主要内容,如果未能解决你的问题,请参考以下文章

我对Sapper / Svelte有一些疑问

如何将 Sapper 添加到现有的 Svelte 项目中?

如何将 Svelte 应用程序用作另一个 Svelte 应用程序中的组件?

如何在没有 Sapper 的情况下使用 Svelte 进行代码拆分

Svelte/Sapper.js - 如何使用 localStorage 数据初始化存储?

更改 Svelte / Sapper 中预处理器的顺序