Preact工程化 之 wmr (认知篇)

1,081 阅读4分钟

"WMR" 是什么意思?

Heh - yeah WMR was originally a bad pun: "Warm Module Replacement".

html/js 中具有发起 http 请求的方式早就了 Native Browser 的可能性

  • <script> 标签
  • <link> 标签
  • ES6 import 语句
  • css 中的 @import 语句

import 语句在一个服务中,能自己发起 http 请求, 例如下面的 html

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>WMR Demo</title>
        <meta name="description" content="WMR Demo" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <link rel="icon" href="data:" />
        <link rel="modulepreload" href="/index.tsx" crossorigin />
        <link rel="stylesheet" href="/style.css" />
    </head>
    <body>
        <script type="module" src="/index.tsx"></script>
    </body>
</html>

以上的 link/script 标签发起 http 请求之后,响应的内容是由服务端确定实际内容, 对于 index.tsx 服务端响应的结果, 如果是浏览器能够直接运行的 js/资源 代码文件, 然后内部使用 import/import() 语句的实现代码导入, 那么就实现了 native browswer,管理依赖.但是它抛出的新的问题就是,如果是直接导入 包名, 这种情况就需要单独处理.

WMR 功能特点

WMR 是一个 Preact 的 esm 的开发服务器,支持下面的主要功能:

特点说明
即时启动服务启动随同启动
HOT热重载模块热替换
无需配置以 html 为入口的 Preact 项目
TypeScript 支持TypeScript 类型支持
生产类型进行优化支持预渲染
rollup 插件兼容与 rollup 生态进行兼容

实现 wmr 大致需要的工具与服务:

  • http 服务 (提供 http 服务)
  • websocket 服务 (前端通信)
  • 文件监听变化服务 (chokidar)
  • 代码转换工具(模拟 rollup -> NoRollup 兼容 rollup 插件)
  • 打包服务(一般用于生产优化,这里适用 rollup)

wmr 依赖列表

基础说明
polka类 express Node.js 服务端框架,简单应用比 Express 快 33-50%
sirv经过优化的轻量级中间件,用于处理对静态资产的请求
sade命令行工具程序
wsweb socket 工具
rollup下一代的 esm 的打包器
chokidar文件监视器
jestjavascript 测试运行器
...

使用 wmr 创建一个 Preact 项目

npm init wmr your-project-name
yarn create wmr your-project-name

目录结果如下:

image.png

理解 wmr 简单设计流程

image.png

cli 从 sada 开始

从 sada 命令行开始(命令行接口的本质,就是将 程序中需要的数据例如 options,映射到到程序内部)

import sade from 'sade';

prog
    .command('start', 'Start a development server', { default: true })
    .option('--public', 'Your web app root directory (default: ./public)')
    .option('--port, -p', 'HTTP port to listen on (default: $PORT or 8080)')
    .option('--host', 'HTTP host to listen on (default: localhost)')
    .option('--http2', 'Use HTTP/2 (default: false)')
    .option('--compress', 'Enable compression (default: enabled)')
    .option('--profile', 'Generate build statistics')
    .option('--reload', 'Switch off hmr and reload on file saves')
    .action(opts => {
            opts.optimize = !/false|0/.test(opts.compress);
            opts.compress = bool(opts.compress);
            if (/true/.test(process.env.PROFILE || '')) opts.profile = true;
            run(start(opts));
    });
    
prog.parse(process.argv);

主要服务和文件变化

如何上的 options 通过处理,传递给了 server 函数,启动一个 polka 服务(并且监听文件变化):

const cloned = deepCloneJSON(options);

let instance = await bootServer(cloned, configWatchFiles);

const watcher = watch(configWatchFiles, {
    cwd: cloned.cwd,
    disableGlobbing: true
});

options 深度 clone 之后,传递给了 bootServer 函数, bootServer 函数中真正的实现了端口的监听:

app.listen(options.port, options.host, () => {
    const addresses = getServerAddresses(app.server.address(), {
        host: options.host,
        https: app.http2
    });

    const message = `dev server running at:`;
    process.stdout.write(formatBootMessage(message, addresses));

    const port = +app.server.address().port;
    resolveActualPort(port);
});

但是在启动监听服务之前,需要一个创建关闭,这里就需要印出关闭的内容:

  • httpServer
  • chokidar文件监听
  • websocket
function makeCloseable(server) {
    /** @type {Set<import('net').Socket>} */
    const sockets = new Set();
    let listened = false;
    server.on('connection', s => {
        sockets.add(s);
        s.on('close', () => sockets.delete(s));
    });
    server.once('listening', () => (listened = true));
    return async () => {
        sockets.forEach(s => s.destroy());
        if (!listened) return;
        await new Promise((resolve, reject) => {
                server.close(err => (err ? reject(err) : resolve()));
        });
    };
}

关闭时,需要关闭已经链接过的 socket 的接口.

核心中间件 options

options.middleware = [].concat(
    // @ts-ignore-next
    options.middleware || [],
    wmrMiddleware({
        ...options,
        onError: sendError,
        onChange: sendChanges
    }),
    injectWmrMiddleware(options)
);

wmrMiddleware 中间件

代码转换用于支持 typescript/less/sass/... 等主流语言等. 转换过程是一个非常复杂的过程,因为对 Native 的支持,需要考虑内容非常多:

  • typescript 支持(语言支持)
  • 模块的加载(commonjs 转转成 esm)
  • ...

wmr 以 rollup 为核心实现了 rollup-plugin-container 的 NoRollup 的容器, rollup-plugin-container 中实现了 rollup 的插件钩子函数以及钩子函数的上下文. 所以要理解 rollup 以及插件体系理解要透彻.

injectWmrMiddleware

将 html 代码中注入 wmr 所需要的js代码: websocket 和模块热替换等功能.

const transformInjectWmr = async tree => {
    tree.walk(node => {
        if (node.tag === 'head') {
            node.content.unshift('\n\t\t', {
                tag: 'script',
                attrs: { type: 'module' },
                content: ["\n\t\t\timport '/_wmr.js';\n\t\t"]
            });
        }
        return node;
    });
};

server

server.js 是 app 的实现最底层, 实现如下

export default async function server({ cwd, root, overlayDir, middleware, http2, compress = true, optimize, alias }) {
    const app = polka({/**/})
    
    // 挂载 websocket
    app.ws = new WebSocketServer(app.server, '/_hmr');
    
    // 处理 @npm 的模块
    app.use('/@npm', npmMiddleware({ alias, optimize, cwd }));
    // ...
    return
}

这里需要说明, wmr 需要将 node_module 中依赖包准换成以 /@npm 开头的包放入.cache/@npm/:

image.png

npm 依赖中间件 npmMiddleware

export default function npmMiddleware({ source = 'npm', alias, optimize, cwd } = {}) {
    const code = await bundleNpmModule(mod, { source, alias, cwd });
}

rollup bundleNpmModule

bundleNpmModule 中 rollup 进行打包(why? 因为 rollup 中的可能也有打包)

const bundle = await rollup.rollup({
    input: mod,
    onwarn: onWarn,
    cache: npmCache,
    shimMissingExports: true,
    treeshake: false,
    preserveEntrySignatures: 'allow-extension',
    plugins: [
        // ...
        npmProviderPlugin,
        // ...
        commonjs({
                extensions: ['.js', '.cjs', ''],
                sourceMap: false,
                transformMixedEsModules: true
        }), 
        // ...
    ]
});

小结

  1. 命令行收集 options 等数据给程序,发生数据的映射过程开始
  2. import/import() 的 native browser
  3. http 服务, websocket 服务,文件监听,中间件处理请求 NoRollup 模拟rullup转换打包过程
  4. rollup 打包与插件, 实现优化
  5. 前端工程化,要求对前端生态熟悉
  6. 开始关注 Preact 生态,因为还有类似的 fresh 轻量级框架开始产生

参考