仓库源文站点原文

项目是怎么跑起来的

一些 webpack 的配置

filename and chunkFilename

path and publicPath

app, vendor and manifest

In a typical application built with webpack, there are three main types of code:

  1. The source code you have written. 自己编写的代码
  2. Any third-party library or "vendor" code your source is dependent on. 第三方库和框架
  3. A webpack runtime and manifest that conducts the interaction of all modules. 记录了打包后代码模块之间的依赖关系,需要第一个被加载

optimization.splitChunks

It is necessary to differentiate between Code Splitting and splitChunks. Code splitting is a feature native to Webpack, which uses the dynamic import statement to move certain modules to a new Chunk. SplitChunks is essentially a further splitting of the Chunks produced by code splitting.

After code splitting, many Chunks will be created, and each Chunk will correspond to one ChunkGroup. SplitChunks is essentially splitting Chunk into more Chunks to form a group and to load groups together, for example, under HTTP/2, a Chunk could be split into a group of 20 Chunks for simultaneous loading.

resolve

css-loader and style-loader

load images

Webpack goes through all the import and require files in your project, and for all those files which have a .png|.jpg|.gif extension, it uses as an input to the webpack file-loader. For each of these files, the file loader emits the file in the output directory and resolves the correct URL to be referenced. Note that this config only works for webpack 4, and Webpack 5 has deprecated the file-loader. If you are using webpack 5 you should change it to asset/resource.

Webpack 4 also has the concept url-loader. It first base64 encodes the file and then inlines it. It will become part of the bundle. That means it will not output a separate file like file-loader does. If you are using webpack 5, then url-loader is deprecated and instead, you should use asset/inline.

Loaders are transformations that are applied to the source code of a module. When you provide a list of loaders, they are applied from right to left, like use: ['third-loader', 'second-loader', 'first-loader']. This makes more sense once you look at a loader as a function that passes its result to the next loader in the chain third(second(first(source))).

webpack.DefinePlugin

The DefinePlugin allows you to create global constants that are replaced at compile time, commonly used to specify environment variables or configuration values that should be available throughout your application during the build process. For example, you might use it to define process.env.NODE_ENV as 'production' or 'development' which webpack will literally replace in your code during bundling.

new webpack.DefinePlugin({
  'process.env.NODE_ENV': '"production"',
  'process.env.BUILD_ENV': buildEnv ? `"${buildEnv}"`: '""',
  'process.env.PLATFORM_ENV': platFormEnv ? `"${platFormEnv}"`: '""'
})

SplitChunksPlugin

Since webpack v4, the CommonsChunkPlugin was removed in favor of optimization.splitChunks (SplitChunksPlugin can be configured through the optimization.splitChunks option). It controls how and when Webpack splits chunks of code into separate files. The default settings works well for most users.

kinds of chunks:

Early Next.js configurations:

webpack in development

something related to tree shaking

Tree shaking means that unused modules will not be included in the bundle (The term was popularized by Rollup). In order to take advantage of tree shaking, you must use ES2015 module syntax. Ensure no compilers transform your ES2015 module syntax into CommonJS modules (this is the default behavior of the popular Babel preset @babel/preset-env).

// babel.config.js
// keep Babel from transpiling ES6 modules to CommonJS modules
export default {
  presets: [
    [
      "@babel/preset-env", {
        modules: false
      }
    ]
  ]
}

Webpack do tree-shake only happens when you're using a esmodule, while lodash is not. Alternatively, you can try to use lodash-es written in ES6. es-toolkit is a modern utility library designed as a lightweight, fast, and tree-shakeable alternative to Lodash and similar libraries.

import cloneDeep from "lodash/cloneDeep"
import { camelCase } from "lodash-es"

import { debounce } from 'es-toolkit';
const debouncedLog = debounce(message => {
  console.log(message);
}, 300);

打包工具构建时静态分析

Critical dependency: the require function is used in a way in which dependencies cannot be statically extracted.

这样的 warning,是因为 require(...) 是运行时动态行为,它无法静态知道你到底引用了哪个模块,因此构建出的 bundle 不完整或存在不确定性。Webpack 支持懒加载语法 (resolve) => require(['...'], resolve);,表示“这段代码用到的模块是异步加载的,请打包成一个 chunk。” 这种语法是 Webpack 的特定实现,并非 ES 的官方标准,构建工具无法完全静态分析,在迁移到 Rspack 或 Vite 时容易报错。

resolve => require(['...'], resolve) 其实是 Webpack 兼容 AMD 风格的写法,webpack 看到这是个 require([], callback) 就知道你想异步加载模块。

import() 是来做“动态模块加载”的语法,构建工具能很好地支持它,每个 import('./xxx') 的路径生成一份 chunk 文件,并在需要时异步加载。

webpack-bundle-analyzer(检查打包体积)

It will create an interactive treemap visualization of the contents of all your bundles when you build the application. There are two ways to configure webpack bundle analyzer in a webpack project. Either as a plugin or using the command-line interface.

// Configure the webpack bundle analyzer plugin
// npm install --save-dev webpack-bundle-analyzer
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin()
  ]
}

speed-measure-webpack-plugin(检查打包速度)

See how fast (or not) your plugins and loaders are, so you can optimise your builds. This plugin measures your webpack build speed, giving an output in the terminal.

const SpeedMeasurePlugin = require("speed-measure-webpack-plugin");

const smp = new SpeedMeasurePlugin();

const webpackConfig = smp.wrap({
  plugins: [new MyPlugin(), new MyOtherPlugin()],
});

TypeScript and Webpack

Webpack is extensible with "loaders" that can be added to handle particular file formats.

  1. Install typescript and ts-loader as devDependencies.
  2. The default behavior of ts-loader is to act as a drop-in replacement for the tsc command, so it respects the options in tsconfig.json.
  3. If you want to further optimize the code produced by TSC, use babel-loader with ts-loader. We need to compile a .ts file using ts-loader first and then using babel-loader.
  4. ts-loader does not write any file to disk. It compiles TypeScript files and passes the resulting JavaScript to webpack, which happens in memory.

TypeScript doesn't understand .vue files - they aren't actually Typescript modules. So it will throw an error when you try to import Foo.vue. The solution is shims-vue.d.ts in src directory. The filename does not seem to be important, as long as it ends with .d.ts. TypeScript looks for .d.ts files in the same places it looks for your regular .ts files. It basically means, "Any time you import a .vue file, treat its default export as a Vue component (i.e., an instance of Vue)."

// shims-vue.d.ts
declare module "*.vue" {
  import Vue from 'vue';
  export default Vue;
}

Important: Above was created in the days before Vue shipped with TypeScript out of the box. Now the best path to get started is through the official CLI.

build 打包

调用 webpack() 传入配置 webpack.prod.conf 和一个回调函数,webpack stats 对象 作为回调函数的参数,可以通过它获取到 webpack 打包过程中的信息,使用 process.stdout.write(stats.toString(...)) 输出到命令行中 (console.log in Node is just process.stdout.write with formatted output)

使用 chalk 在命令行中清晰地显示一些提示信息。目前大多数工程都是通过脚手架来创建的,使用脚手架的时候最明显的就是与命令行的交互,Inquirer.js 是一组常见的交互式命令行用户界面。Commander.js 作为 node.js 命令行解决方案,是开发 node cli 的必备技能。

The build job uses Kaniko (a tool for building Docker images in Kubernetes). Its main task is to build a Docker image.

prefixOss=`echo ${CI_COMMIT_REF_NAME} | sed -e "s/\_/-/g" -e "s/\//-/g"`

[ -z ${CI_COMMIT_TAG} ] && sed -i -E "s/^  \"version\": \"[0-9\.]+\"/  \"version\": \"0.0.0-${prefixOss}-${CI_COMMIT_SHORT_SHA}\"/g" package.json

The above checks if the environment variable CI_COMMIT_TAG is empty (meaning it's not a tag build). If that's the case, it uses sed to perform an in-place replacement in the package.json file. Specifically, it looks for lines that start with "version": "[0-9.]+" and replaces them with a new version format 0.0.0-${prefixOss}-${CI_COMMIT_SHORT_SHA}. This script appears to be adjusting versioning and paths based on the branch or tag being built in a CI/CD pipeline.

The s command is for substitute, to replace text -- the format is s/[text to select]/[text to replace]/. For example, sed 's/target/replacement/g' file.txt will globally substitute the word target with replacement.

微信扫码登录逻辑

二维码登录使用 websocket 连接,message 中定义不同的 op 代表不同的操作,比如 requestlogin 会返回微信生成的二维码(包括 qrcode, ticket, expire_seconds 等),扫码成功返回类型是 loginsuccess,并附带 OpenID, UnionID, Name, UserID, Auth 等信息,前端拿到这些信息后可以请求后端登录的 http 接口,拿到 sessionid,并被种在 cookie 里。

(messenger) ws op case 'requestlogin':

(messenger) await rainSquare.getQrcode() -> loginId = uuidV4() -> request `${config.HOST.INNER_NODE}/wechat/wxapi/qrcode` with loginid and expire_seconds

(messenger) let { loginid, qrcode, ticket } = results.body;

(messenger) rainSquare.qrcodes[qrcodeInfo.loginid].ws = ws;

(drop) app.use('/wechat/drop', ...)

(drop) pipeMsg: (msg) => {
  // 将消息推送到 Redis 队列
  // listKey: config.REDIS.KEYS.PIPE_QUEUE
  client.rpush(listKey, JSON.stringify(msg), callback)
}

// 阻塞地从列表中取元素,常用于“任务队列”消费
(pipe) new Consumer(listKey, Handle, config).start() -> this.client.blpop(listKey, 0, ...)

// https://developers.weixin.qq.com/doc/subscription/guide/product/message/Receiving_event_pushes.html
(pipe) handleMessage: MsgType is event -> new WXEvent(this) -> case 'SCAN' -> new EventScan() -> request APIS.SPY_LOGIN with loginid

(messenger) app.post('/api/login', ...) -> request APIS.USERINFO -> square.publish(op: 'loginsuccess') -> redis publish

// redis发布订阅
(messenger) square.onMessage and case 'loginsuccess':

(messenger) let ws = this.qrcodes[loginid].ws -> ws.send(msg)

(frontend) case 'loginsuccess' -> Api.pc_web_login with UserID and Auth
// getQrcode

1. 从 Redis 缓存中获取已有的二维码
2. 检查二维码是否过期 (expire_seconds > now)
3. 检查冷却时间 (cooldown < now)
4. 如果可用直接返回,否则生成新的二维码
5. 设置冷却时间 (防止立即重复使用) 和过期时间
6. 更新二维码池

常规的密码存储:

A Rainbow Table is a precomputed table of hashes and their inputs. This allows an attacker to simply look up the hash in the table to find the input. This means that if an attacker gets access to your database, they can simply look up the hashes to find the passwords.

To protect against this, password hashing algorithms use a salt. A salt is a random string that is added to the password before hashing. This means that even if two users have the same password, their hashes will be different. This makes it impossible for an attacker to use a rainbow table to find the passwords.

A great library for generating bcrypt hashes is bcryptjs which will generate a random salt for you. This means that you don't need to worry about generating a salt and you can simply store the whole thing as is. Then when the user logs in, you provide the stored hash and the password they provide to bcryptjs's compare function will verify the password is correct.

微信网页授权

申请公众号/小程序的时候,都有一个 APPID 作为当前账号的标识,OpenID 就是用户在某一公众平台下的标识(用户微信号和公众平台的 APPID 两个数据加密得到的字符串)。如果开发者拥有多个应用,可以通过获取用户基本信息中的 UnionID 来区分用户的唯一性,因为同一用户,在同一微信开放平台下的不同应用,UnionID 应是相同的,代表同一个人,当然前提是各个公众平台需要先绑定到同一个开放平台。OpenID 同一用户同一应用唯一,UnionID 同一用户不同应用唯一,获取用户的 OpenID 是无需用户同意的,获取用户的基本信息则需要用户同意。

向用户发起授权申请,即打开如下页面: https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect

  1. appid 是公众号的唯一标识。
  2. redirect_uri 替换为回调页面地址,用户授权完成后,微信会帮你重定向到该地址,并携带相应的参数如 code,回调页面所在域名必须与后台配置一致。在微信公众号请求用户网页授权之前,开发者需要先到公众平台官网中配置授权回调域名。
  3. scope 根据业务需要选择 snsapi_basesnsapi_userinfo。其中 snsapi_base 为静默授权,不弹出授权页面,直接跳转,只能获取用户的 openid,而 snsapi_userinfo 会弹出授权页面,需要用户同意,但无需关注公众号,可在授权后获取用户的基本信息。(对于已关注公众号的用户,如果用户从公众号的会话或者自定义菜单进入本公众号的网页授权页,即使是 scopesnsapi_userinfo,也是静默授权,用户无感知。)
  4. state 不是必须的,重定向后会带上 state 参数,开发者可以填写 a-zA-Z0-9 的参数值,最多 128 字节。
  5. 如果用户同意授权,页面将跳转至 redirect_uri/?code=CODE&state=STATEcode 作为换取 access_token 的票据,每次用户授权带上的 code 不一样,code 只能使用一次,5分钟未被使用自动过期。
  6. 获取 code 后,请求 https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code 获取 access_tokenopenid (未关注公众号时,用户访问公众号的网页,也会产生一个唯一的 openid)。如果 scopesnsapi_userinfo 还会同时获得到 unionid
  7. 如果网页授权作用域为 snsapi_userinfo,则此时可以请求 https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN 拉取用户信息,比如用户昵称、头像、unionid 等,不再返回用户性别及地区信息。
  8. 公众号的 secret 和获取到的 access_token 安全级别都非常高,必须只保存在服务器,不允许传给客户端。后续刷新 access_token 以及通过 access_token 获取用户信息等步骤,也必须从服务器发起。
  1. 微信公众平台接口测试帐号申请: https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login
  2. 某个公众号的关注页面地址为 https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=MzI0NDA2OTc2Nw==#wechat_redirect 其中 biz 字符串是微信公众号标识,在浏览器打开该公众号下的任意一篇文章,查看网页源代码,搜索 var biz 这样的关键字即可得到。
  3. 在微信开发中,JS 接口安全域名和网页授权域名是两个不同的配置项。JS 接口安全域名用于控制哪些域名下的页面可以调用微信的 JS-SDK 接口(如分享、拍照、支付、定位等)。网页授权域名用于控制哪些域名下的页面可以发起微信网页授权,用户授权后,后端可通过 code 换取用户信息(如 openid、nickname 等)。

微信授权也符合通常的 OAuth 流程:
You first need to register your app with your provider to get the required credentials. You’ll be asked to define a callback URL or a redirect URI.

  1. Redirect the user to the provider.
  2. User is authenticated by the provider.
  3. User is redirected back to your server with a secret code.
  4. Exchange that secret code for the user’s access token.
  5. Use the access token access the user’s data.

唤起微信小程序

微信外网页通过小程序链接 URL Scheme,微信内通过微信开放标签,且微信内不会直接拉起小程序,需要手动点击按钮跳转。这是官方提供的一个例子 https://postpay-2g5hm2oxbbb721a4-1258211818.tcloudbaseapp.com/jump-mp.html 可以用手机浏览器查看效果,直接跳转小程序。

微信小程序相关的仓库,比如 WeUI 组件库、微信小程序示例、computed / watch 扩展等: https://github.com/wechat-miniprogram

国产 APP 各自套壳 Chromium 内核版本,最大的问题就是更新不及时,而且大多被改造过。

vue vite 打包后白屏问题,推测就是 webview 版本太旧了,使用 @vitejs/plugin-legacy 做兼容。它的内部使用 @babel/preset-env 以及 core-js 等一系列基础库来进行语法降级和 Polyfill 注入,以解决在旧版浏览器上的兼容性问题。(默认情况下,Vite 的目标是能够支持原生 ESM script 标签、支持原生 ESM 动态导入 和 import.meta 的浏览器)

课堂业务

Lesson(classroomID, lessonID, teacher, allStudents, checkinStudents, presentation)
Presentation(presentationID, slideIndex, content, problems)

1. start lesson
- sql getClassroomId from a universityId
- local presentation json data -> getTitle()
- sql all students(role=1 -> teacher, role=5 -> allStudents)
- API.NEW_LESSON(teacherId, classroomId, presentationTitle) -> get a lessonID

2. upload presentation
- API.NEW_PRESENTATION(presentationContent, teacherId, lessonId)
- parse slides, get content and problem slides

3. connectWS
- sql app_openid, weixin_unionid, user_id... from teacherId
- API.GET_USER_INFO to get Auth
- new WebSocket to send op=hello, userId, role, auth

4. checkin
- API.LESSON_CHECK_IN(studentId, lessonId, source)

5. showPresentation
- get presentation curent slide
- ws send op=showpresentation, lessonId, presentationId, slideId

6. rollcall
- Based on the mode, eligiblePool is checked in students or all students
- Count how many times each student has been called
- Find minimum call count among called students
- Create fair selection pools "neverCalled" and "leastCalled"
- selectionPool is assigned as "neverCalled" first, then "leastCalled"
- Randomly select a student from the selectionPool

7. end lesson
- API.END_LESSON(teacherId, lessonId)

HTTP 请求相关

Some features about Axios:

  1. Axios automatically converts the data to JSON returned from the server.
  2. In Axios, HTTP error responses (like 404 or 500) automatically reject the promise, so you can handle them using catch block.
  3. One of the main selling points of Axios is its wide browser support. Even old browsers like IE11 can run Axios without any issues. This is because it uses XMLHttpRequest under the hood.
  4. 注意 Axios 遇到 302 的返回:重定向直接被浏览器拦截处理,浏览器 redirect 后,被视为 Axios 发起了跨域请求,所以抛异常。Axios 捕获异常,进入 catch 逻辑。

Use $fetch, useFetch, or useAsyncData in Nuxt: https://masteringnuxt.com/blog/when-to-use-fetch-usefetch-or-useasyncdata-in-nuxt-a-comprehensive-guide

Preventing Duplicate Requests:

  1. UI Blocking
  2. Request Debounce: Delay execution until user stops clicking
  3. Request isSubmitting in progress flag
  4. AbortController to cancel pending requests if a new one is made
let controller = new AbortController();

async function makeRequest() {
  // Cancel previous request if it exists
  controller.abort();
  controller = new AbortController();

  try {
    const response = await fetch('/api/endpoint', {
      signal: controller.signal
    });
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('Request was cancelled');
    }
  }
}

阿里云 CDN

阿里云 CDN 对于文件是否支持缓存是以 X-Cache 头部来确定,缓存时间是以 X-Swift-CacheTime 头部来确认。

阿里云 CDN 在全球拥有 3200+ 节点。中国内地拥有 2300+ 节点,覆盖 31 个省级区域。

  1. CDN 节点是指与最终接入用户之间具有较少中间环节的网络节点,对最终接入用户有相对于源站而言更好的响应能力和连接速度。当节点没有缓存用户请求的内容时,节点会返回源站获取资源数据并返回给用户。阿里云 CDN 的源站可以是对象存储OSS、函数计算、自有源站(IP、源站域名)。
  2. 默认情况下将使用 OSS 的 Bucket 地址作为 HOST 地址(如 ***.oss-cn-hangzhou.aliyuncs.com)。如果源站 OSS Bucket 绑定了自定义域名(如 origin.developer.aliyundoc.com),则需要配置回源 HOST 为自定义域名。
  3. 加速域名即网站域名、是终端用户实际访问的域名。CNAME 域名是 CDN 生成的,当您在阿里云 CDN 控制台添加加速域名后,系统会为加速域名分配一个 *.*kunlun*.com 形式的 CNAME 域名。
  4. 添加加速域名后,需要在 DNS 解析服务商处,添加一条 CNAME 记录,将加速域名的 DNS 解析记录指向 CNAME 域名,记录生效后该域名所有的请求都将转向 CDN 节点,达到加速效果。CNAME 域名将会解析到具体哪个节点 IP 地址,将由 CDN 的调度系统综合多个条件来决定。

日常开发 Tips and Tricks

iframe 方案的利弊

用一句话概括 iframe 的作用就是在一个 web 应用中可以独立的运行另一个 web 应用,这个概念和微前端是类似的。采用 iframe 的优点是使用简单、隔离完美、页面上可以摆放多个 iframe 来组合多应用业务。但是缺点也非常明显:

所以我们需要考虑:

  1. iframe 内部的路由变化要体现在浏览器地址栏上
  2. 刷新页面时要把当前状态的 url 传递给 iframe
  3. 浏览器前进后退符合预期
  4. 弹窗全局居中
  5. CSP, sandbox 等安全属性

桌面端 Electron 相关

Electron是一个集成项目,允许开发者使用前端技术开发桌面端应用。其中 Chromium 基础能力可以让应用渲染 HTML 页面,执行页面的 JS 脚本,让应用可以在 Cookie 或 LocalStorage 中存取数据。Electron 还继承了 Chromium 的多进程架构,分一个主进程和多个渲染进程,主进程进行核心的调度启动,不同的 GUI 窗口独立渲染,做到进程间的隔离,进程与进程之间实现了 IPC 通信。Node.js 基础能力可以让开发者读写本地磁盘的文件,通过 socket 访问网络,创建和控制子进程等。Electron 内置模块可以支持创建操作系统的托盘图标,访问操作系统的剪切板,获取屏幕信息,发送系统通知,收集崩溃报告等。

桌面端状态持久化存储

Electron doesn't have a built-in way to persist user preferences and other data. electron-store handles that for you, so you can focus on building your app. The data is saved in a JSON file in app.getPath('userData').

Advantages over localStorage:

vuex-electron uses electron-store to share your Vuex Store between all processes (including main).

Electron 相关记录

  1. 如果安装 Electron 遇到问题,可以直接在 https://npmmirror.com/mirrors/electron/ 下载需要的版本,然后保存到本地缓存中 ~/Library/Caches/electron
  2. In the case of an electron app, the electron package is bundled as part of the built output. There is no need for your user to get electron from npm to use your built app. Therefore it matches well the definition of a devDependency. (When you publish your package, if the consumer project needs other packages to use yours, then these must be listed as dependencies.) For example, VS Code properly lists electron as a devDependency only: https://github.com/microsoft/vscode/blob/main/package.json
  3. In case you are using an unsupported browser, or if you have other specific needs (for example your application is in Electron), you can use the standalone Vue devtools
  4. Blank screen on builds, but works fine on serve. This issue is likely caused when Vue Router is operating in history mode. In Electron, it only works in hash mode.
    • 本地开发时是 http 服务,当访问某个地址的时候,其实真实目录下是没有这个文件的,本地服务可以帮助重定向到 /index.html 这是一定存在的入口文件,相当于走前端路由。一但打包之后,页面就是静态文件存放在目录中了,Electron 是找不到类似 /index/page/1/2 这样的目录的,所以需要使用 /index.html#page/1/2 这样的 hash 模式。同样,如果是 Web 项目使用了 history 模式打包,如果不在 nginx 中将全部 url 指向 ./index.html 的话,也会出现 404 的错误,也就是需要把路由移交给前端去控制。
    • hash mode 是默认模式,原理是使用 location.hashonhashchange 事件,利用 # 后面的内容不会被发送到服务端实现单页应用。history mode 要手动设置 mode: 'history', 是基于 History API 来实现的,这也是浏览器本身的功能,地址不会被请求到服务端。
  5. 关于 Icon 图标,Windows(.ico 文件)和 Mac(.icns 文件)的都是复合格式,包含了多种尺寸和颜色模式,Linux 就是多张 png。注意不要把 png 直接改成 ico,可以使用在线工具转换。如果 Windows 窗口或任务栏图标未更换成功,可能是 ico 文件中缺少小尺寸图标,如缺少 16x16 或 32x32 的图标。
  6. 可以通过命令行启动程序,查看打包后的主进程日志,Mac 进入到 /Applications/Demo.app/Contents/MacOS/ 路径,执行 ./Demo 启动应用层序。Windows 上打开 Powershell 进入到程序的安装目录,执行 .\Demo.exe,如果文件名中有空格,需要用双引号把文件名引起来。
  7. 在 Electron 打包后,__dirname 在渲染进程中指向的是 app.asar 内部的虚拟路径。渲染进程无法直接访问物理资源路径(如 macOS 的 xx.app/Contents/Resources/),但可以在主进程通过 process.resourcesPath 获取该路径,以 IPC 的方式传递给渲染进程。
  8. Electron 参考项目:

展厅项目架构

打包遥控器页面 + 一台 server 端主机 + 一台 client 端主机

"winserver": "npm run build:web && cross-env PLATFORM_ENV=server npm run pack:windows",
"winclient": "cross-env PLATFORM_ENV=client npm run pack:windows",

遥控器 就是常规的页面,一个独立的 SPA 使用 webpack 构建产出放在 dist/web 目录下 (打包后在 path.join(process.resourcesPath, 'app.asar.unpacked', 'web'))。它要建立 ws 连接,角色是 REMOTE:

server 端主机启动一个 ws 服务 (new WebSocketServer({ port })),接收遥控器和 client 端发送过来的消息,如果是 client 端消息,需要转发给遥控器。如果是遥控器通知开启、关闭等,需要通过 emitter.emit 传递处理,并且转发给另一台主机 client 端。上下文 context 中保存着当前激活的屏幕和之前激活的屏幕序号。此外,它还启动了一个 express 服务,作为遥控器的 web 服务端,处理页面展示和嘉宾图片上传。

wss.clients.forEach((client) => {
  if (client.role === RemoteClientRoleMap.CLIENT) {
    // ...
    client.send(msg);
  }
})

client 端主机建立 ws 连接 (new WebSocket(wssURL)),角色是 CLIENT,接收启动、重启、关闭、激活屏幕、指令控制等消息,都会通过 emitter.emit 发送给 ipc-exhibition-hall 这个 service,它会在主进程的入口文件中被注册上。

主进程入口

// 根据安装包配置参数 确定是否是server端
// isServer = process.env.PLATFORM_ENV === 'server';
if (isServer) {
  remoteWSServer.start();
  // 方便发送消息
  global.rain.remoteWSServer = remoteWSServer;
} else {
  global.rain.remoteWSClinet = remoteWSClinet;

  setTimeout(()=>{
    remoteWSClinet.startConnect();
  }, 1000)
}

通信服务 ipc-exhibition-hall 文件

ipcMain.on('teaching-screen-ready', async (event, data) => {
  let index = 1;

  if (config && config.DisplaysEnable) {
    displaysEnable = config.DisplaysEnable;
  }

  if (displaysEnable[DisplaySerialNumber.SIXTH_DISPLAY]) {
    studentWin.show({ index });
    index += 1;
  }
});

// active, launch, restart, close, ExecRemoteCommand...
emitter.on('xxx', (data) => {
  // ...
  if (isServer) {
    exhibitionHallWinMap[activeIndex]?.send('message-from-process', data);
  } else {
    exhibitionHallWinMap[activeIndex]?.send('message-from-process', data);
  }
})