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

mr.*_*eze 9 server-side-rendering svelte sapper

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

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

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

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

rix*_*ixo 27

哦,艰难的一个。挂紧。

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

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

import { onMount } from 'svelte'
import { readable } from 'svelte/store'
import { fade } from 'svelte/transition'
import Foo from './Foo.svelte'
Run Code Online (Sandbox Code Playgroud)

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

// 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'
Run Code Online (Sandbox Code Playgroud)

Svelte 编译.svelte.js并且(可选).css,但它不会对代码中的导入执行任何操作。相反,它增加了一些(但仍然没有解决它们,它超出了它的范围)。

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

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

解决链接问题

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

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

您可以Foo.svelte使用一些 Rollup 配置来编译一个小部件(此处),如下所示:

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(),
  ],
}
Run Code Online (Sandbox Code Playgroud)

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

将上面的配置与这样的命令一起使用:

rollup --config rollup.config.Foo.js
Run Code Online (Sandbox Code Playgroud)

您将在public/build/Foo.js!

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 })
Run Code Online (Sandbox Code Playgroud)

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

这是因为我们消耗了浏览器本地动态的进口,我们需要设置编译窗口小部件output.format,以es在汇总配置。Svelte 组件将使用export default ...现代浏览器本身可以理解的语法公开。

当前浏览器非常支持动态导入。值得注意的例外是“旧”Edge(在它基本上成为 Chrome 之前)。如果您需要支持较旧的浏览器,可以使用polyfill(其中许多实际上 - 例如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`,
  },
  ...
})
Run Code Online (Sandbox Code Playgroud)

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

rollup --config rollup.config.widget.js --configTarget Bar
Run Code Online (Sandbox Code Playgroud)

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

警告:共享依赖项

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

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

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

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

无论如何,这是一般的想法。您可以将上面的 Rollup 配置更改为如下所示:

rollup.config.widget-all.js

...

export default {
  input: ['widgets/Foo.svelte', 'widgets/Bar.svelte', ...],
  output: {
    format: 'es',
    dir: 'public/build/widgets',
  },
  ...
}
Run Code Online (Sandbox Code Playgroud)

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

进一步的观点

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

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

实际上,当您将一堆小部件捆绑在一起(甚至只是一个小部件)并让 Rollup 提取依赖项时,捆绑器有可能知道消费代码实际需要依赖项的哪些部分并跳过其余部分(请记住:Rollup 是将摇树作为其主要优先事项之一构建的——如果不是它的主要优先事项,而 Svelte 是由同一个人构建的——这意味着:您可以期望 Svelte 对摇树非常友好!) . 另一方面,如果您自己手动提取一些依赖项:它消除了一次捆绑所有消耗代码的需要,但是您必须公开整个消耗的依赖项,因为您将无法提前知道他们将需要的部分。

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

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

注意陷阱:Svelte 无法复制

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

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

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

这行不通:

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} />
Run Code Online (Sandbox Code Playgroud)

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

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

所以我们被困住了。

还是我们?

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

...

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

const bar = new Bar({ target: document.querySelector('#bar') })
Run Code Online (Sandbox Code Playgroud)

就 Svelte 而言,这里foobar将是完全独立的组件树。这样的代码将总是工作的毫无来由如何以及何时(以及与斯维尔特版本等),Foo以及Bar被编译和捆绑。

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

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} />
Run Code Online (Sandbox Code Playgroud)

我们从我们的主应用程序创建一个目标 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}
Run Code Online (Sandbox Code Playgroud)

而且……我们到了?让我们总结一下!

概括

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

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

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

  • 不要尝试将不使用相同 Svelte 副本的组件混合在一起(本质上意味着捆绑在一起,除非您已经开始进行一些非凡的黑客攻击)。乍一看它可能会起作用,但实际上不会。

  • 我仍在消化所有这些内容,但让我感谢您花时间如此彻底地回答。 (4认同)
  • @rixo,哇!多么令人惊奇的写作啊!我能够通过使用“external”选项进行汇总来删除“svelte”和“svelte/internals”运行时。具体来说, `external: ['svelte', 'svelte/internal']` 现在我可以在浏览器或父捆绑器中进行后期绑定。再次感谢您的撰写! (3认同)
  • 好吧,这是我在 1000 票赞成中见过的最令人惊奇的答案。再次感谢。 (2认同)
  • @rixo,很抱歉,StackOverflow 不是一个可以写一本关于这个主题的精彩书籍的地方,你这该死的传奇人物。(说真的,谢谢你,这是一个非常宝贵的资源。):) (2认同)

mr.*_*eze 5

Thanks to the detailed post by @rixo I was able to get this working. I basically created a rollup.widget.js like this:

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()
    ]
}
Run Code Online (Sandbox Code Playgroud)

Then generate the svelte components from the database and compile:

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);
        })    
    })    
}
Run Code Online (Sandbox Code Playgroud)

And then consume the dynamic widget as @rixo proposed:

<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>
Run Code Online (Sandbox Code Playgroud)

A few notes/observations:

  1. I had much better luck using rollup/dist/loadConfigFile than trying to use rollup.rollup directly.
  2. I went down a rabbit hole of trying to create both client and server globals for all of the svelte modules and marking them as external in the widget rollup so that everything used the same svelte internals. This ended up being a mess and gave the widgets access to more than I wanted.
  3. 如果您尝试使用 <svelte:component 将动态编译的小部件嵌入到主应用程序中,它会起作用,但如果您尝试从另一个小部件引用动态小部件,则会出现可怕的 outros.c undefined 错误。发生这种情况后,现实崩溃了,应用程序处于奇怪的状态。
  4. @rixo 总是对的。我事先就收到了有关这些事情的警告,结果与预期完全一致。