Notes on work projects (in Chinese)
项目是怎么跑起来的
- 项目里面有很多子项目(
pages/*
),借助 webpack 多⼊⼝配置,打包成多个不同的子项目产出,总体结构来自于一个比较老的模板 https://github.com/vuejs-templates/webpack - 在 webpack 配置的 entry 里可以看到这些子项目入口,里面列举了所有的入口 js 文件,也可以通过遍历
src/pages
得到所有入口。 - 对于每一个 page,都有对应的
HtmlWebpackPlugin
指定它的模板,并注入它需要的 chunks (对应每一个 entry 打包出的 js),本地直接通过localhost/xx.html
访问,线上通过配置 nginx 路由映射访问try_files $uri /static/xx.html
- 指定
chunks
是因为项目是多 entry 会生成多个编译后的 js 文件,chunks 决定使用哪些 js 文件,如果没有指定默认会全部引用。inject
值为 true,表明 chunks js 会被注入到 html 文件的 head 中,以 script defer 标签的形式引入。对于 css, 使用mini-css-extract-plugin
从 bundle 中分离出单独的 css 文件并在 head 中以 link 标签引入。(extract-text-webpack-plugin 是老版本 webpack 用来提取 css 文件的插件,从 webpack v4 被 mini-css-extract-plugin 替代) - 每一个 page 里的 js 文件(入口文件)会创建该子项目的 Vue 实例,指定对应的 component, router, store, 同时会把
request
,API
,i18n
这些对象挂载在 window 对象上,子组件中不需要单独引用。 - 每一个 page 有对应的
router
文件,这是子项目的路由,而且每个路由加载的 component 都是异步获取,在访问该路由时按需加载。 - webpack 打包时(
dist/
)会 emit 出所有HtmlWebpackPlugin
生成的 html 文件(这也是浏览器访问的入口),相对每个 entry 打包出的 js 文件js/[name].[chunkhash].js
(对应 output.filename),所有异步加载的组件 jsjs/[id].[chunkhash].js
(对应 output.chunkFilename)。这些 chunk 基本来自 vue-router 配置的路由component: resolve => require(['@/components/foo'], resolve)
,这样懒加载的组件会生成一个 js 文件。 copy-webpack-plugin
用来把那些已经在项目目录中的文件(比如public/
或static/
)拷贝到打包后的产出中,这些文件不需要 build,不需要 webpack 的处理。另外可以使用ignore: ["**/file.*", "**/ignored-directory/**"]
这样的语法忽略一些文件不进行拷贝。- 图片、音乐、字体等资源的打包处理使用
url-loader
结合limit
的设置,如果资源比较大会默认使用file-loader
生成img/[name].[hash:7].[ext]
这样的文件;如果资源小,会自动转成 base64。(DEPREACTED for v5: please consider migrating to asset modules) performance
属性用来设置当打包资源和入口文件超过一定的大小给出警告或报错,可以分别设置它们的上限和哪些文件被检查。具体多大的文件算“过大”,则需要用到maxEntrypointSize
和maxAssetSize
两个参数,单位是 byte。- 对于代码压缩,使用
terser-webpack-plugin
来压缩 JS,webpack 5 自带,但如果需要自定义配置,那么仍需要安装该插件,在 webpack 配置文件里设置optimization
来引用这个插件。HtmlWebpackPlugin
里设置minify
可以压缩 HTML,production 模式下是默认是 true(会使用html-minifier-terser
插件去掉空格、注释等),自己传入一个 minify 对象,可以定制化压缩设置。 - 对于 js 的压缩使用了
uglifyjs-webpack-plugin
,里面传入compress
定制化压缩设置。比如有的项目没有 console 输出,可能就是因为这里设置了drop_console
。 - 使用
friendly-errors-webpack-plugin
简化命令行的输出,可以只显示构建成功、警告、错误的提示,从而优化命令⾏的构建日志。 - webpack 设置请求代理 proxy(其背后使用的是 http-proxy-middleware),默认情况下假设前端是
localhost:3000
,后端是localhost:8082
,那么后端通过request.getHeader("Host")
获取的依旧是localhost:3000
。如果设置了changeOrigin: true
,那么后端才会看到的是localhost:8082
, 代理服务器会根据请求的 target 地址修改 Host(这个在浏览器里看请求头是看不到改变的)。如果某个接口 404,一般就是这个路径没有配置代理。 - 路由中加载组件的方式为
component: () => import('@/views/About.vue')
可以做到 code-splitting,这样会单独产出一个文件名为About.[hash].js
的 chunk 文件,路由被访问时才会被加载。
Vue 项目
Vue npm 包有不同的 Vue.js 构建版本,可以在 node_modules/vue/dist
中看到它们,大致包括完整版、编译器(编译template)、运行时版本、UMD 版本(通过 <script>
标签直接用在浏览器中)、CommonJS 版本(用于很老的打包工具)、ES Module 版本。总的来说,Runtime + Compiler 版本是包含编译代码的,可以把编译过程放在运行时做,如果需要在客户端编译模板 (比如传入一个字符串给 template 选项),就需要加上编译器的完整版。Runtime 版本不包含编译代码,需要借助 webpack 的 vue-loader
事先把 *.vue
文件内的模板编译成 render
函数,在最终打好的包里实际上是不需要编译器的,只用运行时版本即可。
- Standalone build: includes both the compiler and the runtime.
- Runtime only build: since it doesn’t include the compiler, you need to either pre-compiled templates in a compile step, or manually written render functions. The npm package will export this build by default, since when consuming Vue from npm, you will likely be using a compilation step (with Webpack), during which vue-loader will perform the template pre-compilation.
// Using Runtime + Compiler
new Vue({
el: '#app',
router,
template: '<App/>',
components: { App }
})
// build/webpack.base.conf.js
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js',
}
}
// Using Runtime-only
new Vue({
el: '#app',
router,
render: h => h(App)
})
Vue 是如何被编译的详细介绍:https://vue-compiler.iamouyang.cn/template/baseCompile.html
Vue 3 在 2022 年 2 月代替 Vue 2 成为 Vue 的默认版本。
- create-vite 是 Vite 官方推荐的一个脚手架工具,可以创建基于 Vite 的不同技术栈基础模板。
npm create vite
可创建一个基于 Vite 的基础空项目。 - create-vue 是 Vue 官方推出的一个脚手架,可以创建基于 Vite 的 Vue 基础模板。
npm init vue@3
然后根据命令行的提示操作。 - Vue 3 + SSR + Vite Vue 3 + SSR 使用 Vite 进行开发的模板。
- 如果不习惯 Vite,依然可以使用 Vue CLI 作为开发脚手架,它使用的构建工具还是基于 Webpack。使用命令
vue create hello-vue3
根据提示创建项目。(Vue CLI is in Maintenance Mode. For new projects, it is now recommended to use create-vue to scaffold Vite-based projects.) - Volar 是 Vue 官方推荐的 VSCode 扩展,用以代替 Vue 2 时代的 Vetur 插件。(Volar extension is deprecated. Use the Vue - Official extension instead.)
The Vue Language Tools are essential for providing language features such as autocompletion, type checking, and diagnostics when working with Vue’s SFCs. While Volar
powers the language tools, the official extension for Vue is titled Vue - Official
now on the VSCode marketplace.
Vue DevTools is designed to enhance the Vue developer experience. There are multiple options to add this tool to your projects by Vite plugin, Standalone App, or Chrome Extension. Note that The v7 version of devtools only supports Vue3. If your application is still using Vue2, please install the v6 version.
一些 webpack 的配置
- Webpack 5 boilerplate: https://github.com/taniarascia/webpack-boilerplate
- Create App: https://createapp.dev/webpack
- Webpack articles: https://blog.jakoblind.no/tags/webpack
- Geektime webpack course: https://github.com/cpselvis/geektime-webpack-course
filename and chunkFilename
filename
是对应于 entry 里面的输入文件,经过打包后输出文件的名称。chunkFilename
指未被列在 entry 中,却又需要被打包出来的 chunk 文件的名称(non-initial chunk files),一般是要懒加载的代码。output.filename
的输出文件名是js/[name].[chunkhash].js
,[name]
根据 entry 的配置推断为 index,所以输出为index.[chunkhash].js
。output.chunkFilename
默认使用[id].js
, 会把[name]
替换为 chunk 文件的 id 号。- By prepending
js/
to the filename inoutput.filename
, webpack will write bundled files to a js sub-directory in theoutput.path
. This allows you to organize files of a particular type in appropriately named sub-directories. chunkFileName
不能灵活自定义,但可以通过/* webpackChunkName: "foo" */
这样的 Magic Comments,给 import 语句添加注释来命名 chunk。chunkhash
根据不同的入口文件构建对应的 chunk,生成对应的哈希值,来源于同一个 chunk,则 hash 值就一样。
path and publicPath
output.path
represents the absolute path for webpack file output in the file system. In other words,path
is the physical location on disk where webpack will write the bundled files.output.publicPath
represents the path from which bundled files should be accessed by the browser. You can load assets from a custom directory (/assets/
) or a CDN (https://cdn.example.com/assets/
). The value of the option is prefixed to every URL created by the runtime or loaders.
app, vendor and manifest
In a typical application built with webpack, there are three main types of code:
- The source code you have written. 自己编写的代码
- Any third-party library or “vendor” code your source is dependent on. 第三方库和框架
- 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 import('package')
statement to move certain modules to a new Chunk. SplitChunks is essentially a further splitting of the Chunks produced by code splitting.
Code splitting also has some drawbacks. There’s a delay between loading the entry point chunk (e.g., the top-level app with the client-side router) and loading the initial page (e.g., home). The way to improve this is by injecting a small script in the head of the HTML, when executed, it preloads the necessary files for the current path by manually adding them to the HTML page as link
rel="preload"
.
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.
chunks: ‘all’ | ‘initial’ | ‘async’:
all
means both dynamically imported modules and statically imported modules will be selected for optimization.initial
means only statically imported modules;async
means only dynamically imported modules.
resolve
- extensions 数组,在 import 不带文件后缀时,webpack 会自动带上后缀去尝试访问文件是否存在,默认值
['.js', '.json', '.wasm']
. - mainFiles 数组,the filename to be used while resolving directories, defaults to
['index']
. - alias 配置别名,把导入路径映射成一个新的导入路径,比如
"@": path.join(__dirname, 'src')
. - modules 数组,tell webpack what directories should be searched when resolving modules, 默认值
['node_modules']
,即从 node_modules 目录下寻找。
css-loader and style-loader
css-loader
takes a CSS file and returns the CSS with@import
andurl(...)
resolved. It doesn’t actually do anything with the returned CSS and is not responsible for how CSS is ultimately displayed on the page.style-loader
takes those styles and creates a<style>
tag in the page’s<head>
element containing those styles. The order of CSS insertion is completely consistent with the import order.- We often chain the
sass-loader
with thecss-loader
and thestyle-loader
to immediately apply all styles to the DOM or themini-css-extract-plugin
to extract it into a separate file.
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 file-loader
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 chainthird(second(first(source)))
.
webpack in development
webpack-dev-server
doesn’t write any output files after compiling. Instead, it keeps bundle files in memory and serves them as if they were real files mounted at the server’s root path.webpack-dev-middleware
is an express-style development middleware that will emit files processed by webpack to a server. This is used inwebpack-dev-server
internally.- Want to access
webpack-dev-server
from the mobile in local network: runwebpack-dev-server
with--host 0.0.0.0
, which lets the server listen for requests from the network (all IP addresses on the local machine), not just localhost. But Chrome won’t accesshttp://0.0.0.0:8089
(Safari can open). It’s not the IP, it just means it is listening on all the network interfaces, so you can use any IP the host has.
difference between --watch
and --hot
-
webpack --watch
: watch for the file changes and compile again when the source files changes.webpack-dev-server
uses webpack’s watch mode by default. -
webpack-dev-server --hot
: add the HotModuleReplacementPlugin to the webpack configuration, which will allow you to only reload the component that is changed instead of doing a full page refresh.watchOptions: { ignored: /node_modules/, // 监听到变化发生后会等 300ms 再去执行,默认300ms aggregateTimeout: 300, // 判断文件是否发生变化是通过不停询问系统指定文件有没有变化实现的,默认每秒 1000 次 poll: 1000, }
something related to bundling/tree shaking
-
Every component will get its own scope, and when it imports another module, webpack will check if the required file was already included or not in the bundle.
-
Webpack v5 comes with the latest
terser-webpack-plugin
out of the box.optimization.minimize
is set totrue
by default, telling webpack to minimize the bundle using theTerserPlugin
. -
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
).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.
- import cloneDeep from “lodash/cloneDeep”
- import { camelCase } from “lodash-es”
- import * as _ from “lodash-es”
// babel.config.js // keep Babel from transpiling ES6 modules to CommonJS modules export default { presets: [ [ "@babel/preset-env", { modules: false } ] ] } // if you're using Webpack and `babel-loader` { test: /\.js$/, exclude: /node_modules/, use: { loader: 'babel-loader', options: { type: 'module' } } }
-
The
sideEffects
property ofpackage.json
declares whether a module has side effects on import. When side effects are present, unused modules and unused exports may not be tree shaken due to the limitations of static analysis.
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()
]
}
- stat - This is the “input” size of your files, before any transformations like minification. It is called “stat size” because it’s obtained from Webpack’s stats object.
- parsed - This is the “output” size of your files. If you’re using a Webpack plugin such as Uglify, then this value will reflect the minified size of your code.
- gzip - This is the size of running the parsed bundles/modules through gzip compression.
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()],
});
Webpack 5 fails if using smp.wrap()
the config, with the error: “You forgot to add mini-css-extract-plugin
plugin”. As a hacky workaround, you can append MiniCssExtractPlugin
after wrapping with speed-measure-webpack-plugin
.
TypeScript and Webpack
Webpack is extensible with “loaders” that can be added to handle particular file formats.
- Install
typescript
and ts-loader as devDependencies. - The default behavior of
ts-loader
is to act as a drop-in replacement for thetsc
command, so it respects the options intsconfig.json
. - If you want to further optimize the code produced by TSC, use
babel-loader
withts-loader
. We need to compile a.ts
file usingts-loader
first and then usingbabel-loader
. 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, “every time you import a module with the name *.vue
, then treat it as if it had these contents, and the type of Foo
will be Vue.”
// shims-vue.d.ts
declare module "*.vue" {
import Vue from 'vue';
export default Vue;
}
If that doesn’t help, make sure the module you are trying to import is tracked by TypeScript. It should be covered in your include
array setting and not be present in the exclude
array in your tsconfig.json
file.
{
"compilerOptions": {
// ...
},
"include": ["src/**/*"],
"exclude": ["node_modules", "src/**/*.spec.ts"]
}
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.
配置 babel-loader 选择性编译引入的 sdk 文件
Transpiling is an expensive process and many projects have thousands of lines of code imported in that babel would need to run over. Your node_modules
should already be runnable without transpiling and there are simple ways to exclude your node_modules
but transpile any code that needs it.
{
test: /\.js$/,
exclude: /node_modules\/(?!(my_main_package\/what_i_need_to_include)\/).*/,
use: {
loader: 'babel-loader',
options: ...
}
}
打包时插入 git 提交信息
git-revision-webpack-plugin generates VERSION and COMMITHASH files during build.
const GitRevisionPlugin = require('git-revision-webpack-plugin');
const gitRevisionPlugin = new GitRevisionPlugin();
plugins: [
new DefinePlugin({
'VERSION': JSON.stringify(gitRevisionPlugin.version()),
'COMMITHASH': JSON.stringify(gitRevisionPlugin.commithash()),
'BRANCH': JSON.stringify(gitRevisionPlugin.branch()),
}),
]
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 defineprocess.env.NODE_ENV
as ‘production’ or ‘development’ which webpack will literally replace in your code during bundling.
本地 build 与上线 build
- 公共组件库 C 需要先 build,再
npm link
映射到全局的 node_modules,然后被其他项目npm link C
引用。(关于npm link
的使用场景可以看看 https://github.com/atian25/blog/issues/17) - 项目 A 的上线脚本中会先进入组件库 C,执行
npm build
和npm link
,之后再进入项目 A 本身,执行npm link C
,npm build
等项目本身的构建。 - 项目 C 会在本地构建(静态资源传七牛),远程仓库中包括
server-static
存放 build 后的静态文件,它的上线脚本里并不含构建过程,只是在拷贝仓库中的server-static
目录。因为源文件中会有对组件库的引用import foo from 'C/dist/foo.js
,本地 build 时组件库已经被打包进去。
The build job uses Kaniko (a tool for building Docker images in Kubernetes). Its main task is to build a Docker image.
- For master, dev, or tagged commits: builds and pushes the Docker image
- For other branches: builds but doesn’t push the 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 iss/[text to select]/[text to replace]/
. For example,sed 's/target/replacement/g' file.txt
will globally substitute the wordtarget
withreplacement
.
本地 build 脚本
- 使用 ora 做 spinner,提示 building for production…
- 使用 rimraf 删除打包路径下的资源 (
rimraf
command is an alternative to the Linux commandrm -rf
) - 调用
webpack()
传入配置webpack.prod.conf
和一个回调函数,webpack stats 对象 作为回调函数的参数,可以通过它获取到 webpack 打包过程中的信息,使用process.stdout.write(stats.toString(...))
输出到命令行中 (console.log
in Node is justprocess.stdout.write
with formatted output) - 使用 chalk 在命令行中显示一些提示信息。
- 补充:目前大多数工程都是通过脚手架来创建的,使用脚手架的时候最明显的就是与命令行的交互,Inquirer.js 是一组常见的交互式命令行用户界面。Commander.js 作为 node.js 命令行解决方案,是开发 node cli 的必备技能。
后端模板
有些 url 请求是后端直出页面返回 html,通过类似 render_to_response(template, data)
的方法,将数据打到模板中,模板里会引用 xx/static/js
路径下的 js 文件,这些 js 使用 require 框架,导入需要的其他 js 文件或 tpl 模板,再结合业务逻辑使用 underscore 的 template 方法(_.template(xx)
)可以将 tpl 渲染为 html,然后被 jquery .html()
方法插入到 DOM 中。
- 请求
/web?old=1
后端会返回 html 扫码登录页面,这里面有一个/static/vue/login.js?_dt=xxxxx
,里面有登录和加载网页版首页的逻辑,这样就会展示出 h5 中的页面,其中的 iframe 可以嵌套任意 pc 或 h5 中的页面(只要有路由支持),这个 iframe 的链接自然也可以被单独访问。 - h5 发起的第一次页面请求是走服务器,后端返回一个模板 html,这里面有一个 app 元素是 Vue 挂载的地方,前端通过一个老的 vue router API
router.start(App, 'app')
创建 vue 实例并进行挂载 (https://github.com/vuejs/vue-router/blob/1.0/docs/en/api/start.md),这之后才会被前端路由接管。而且这个 html 只能在手机端访问(根据 ua),否则会跳到 web 端的逻辑。
# urls.py
urlpatterns = [
url(r'^v/index', foo.index),
url(r'^web', foo.web),
]
# views.py
# def index(request):
return render_to_response('foo/vue_index.html', context)
# def web(request):
return render_to_response('foo/login.html', context)
# import scripts in above template html
<script>
var isInIframe = window.frames.length !== parent.frames.length;
var ua = window.navigator.userAgent;
if (!isInIframe && !ua.toLowerCase().match(/micromessenger|android|iphone/i)) {
window.location.href = '/web/?next=' + window.location.pathname;
}
</script>
<script src="https://cdn.example.com/assets/login.js"></script>
关于 iframe 的配置:https://iframegenerator.top
登录逻辑
- 二维码登录先使用 websocket 连接,message 中定义不同的
op
代表不同的操作,比如 requestlogin 会返回微信生成的二维码(包括 qrcode, ticket, expire_seconds 等), 扫码成功返回类型是 loginsuccess,并附带 OpenID, UnionID, Name, UserID, Auth 等信息,前端拿到这些信息后可以请求后端登录的 http 接口,拿到 sessionid,并被种在 cookie 里。 - 账密登录,前端使用 JSEncrypt 给密码加密并请求后端登录接口,成功的话后端会把 sessionid 种在 cookie 里。
常规的扫码登录原理(涉及 PC 端、手机端、服务端):
- PC 端携带设备信息向服务端发起生成二维码的请求,生成的二维码中封装了 uuid 信息,并且跟 PC 设备信息关联起来,二维码有失效时间。PC 端轮询检查是否已经扫码登录。
- 手机(已经登录过)进行扫码,将手机端登录的信息凭证(token)和二维码 uuid 发送给服务端,此时的手机一定是登录的,不存在没登录的情况。服务端生成一个一次性 token 返回给移动端,用作确认时候的凭证。
- 移动端携带上一步的临时 token 确认登录,服务端校对完成后,会更新二维码状态,并且给 PC 端一个正式的 token,后续 PC 端就是持有这个 token 访问服务端。
- 流程参考 https://github.com/ahu/scan_qrcode_login/blob/master/qr.js
常规的密码存储:
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. In fact a common practice is to simply append the salt to the hash. This will make it so that the salt is always available when you need to verify the password.
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.
import { compare, hash } from 'bcryptjs';
import { SignJWT } from 'jose';
// Takes a string as input, and returns a Uint8Array containing UTF-8 encoded text
const key = new TextEncoder().encode(process.env.AUTH_SECRET);
const SALT_ROUNDS = 10; // Salt length to generate
export async function hashPassword(password) {
return hash(password, SALT_ROUNDS);
}
export async function comparePasswords(plainTextPassword, hashedPassword) {
return compare(plainTextPassword, hashedPassword);
}
export async function signToken(payload: SessionData) {
// https://github.com/panva/jose/blob/main/docs/classes/jwt_sign.SignJWT.md
return await new SignJWT(payload)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1 day from now')
.sign(key);
}
export async function setSession(user: NewUser) {
const expiresInOneDay = new Date(Date.now() + 24 * 60 * 60 * 1000);
const session: SessionData = {
user: { id: user.id! },
expires: expiresInOneDay.toISOString(),
};
const encryptedSession = await signToken(session);
cookies().set('session', encryptedSession, {
expires: expiresInOneDay,
httpOnly: true,
secure: true, // Only over HTTPS
sameSite: 'strict', // Prevent CSRF
});
}
微信网页授权
申请公众号/小程序的时候,都有一个 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
appid
是公众号的唯一标识。redirect_uri
替换为回调页面地址,用户授权完成后,微信会帮你重定向到该地址,并携带相应的参数如code
,回调页面所在域名必须与后台配置一致。在微信公众号请求用户网页授权之前,开发者需要先到公众平台官网中配置授权回调域名。scope
根据业务需要选择snsapi_base
或snsapi_userinfo
。其中snsapi_base
为静默授权,不弹出授权页面,直接跳转,只能获取用户的openid
,而snsapi_userinfo
会弹出授权页面,需要用户同意,但无需关注公众号,可在授权后获取用户的基本信息。(对于已关注公众号的用户,如果用户从公众号的会话或者自定义菜单进入本公众号的网页授权页,即使是scope
为snsapi_userinfo
,也是静默授权,用户无感知。)state
不是必须的,重定向后会带上state
参数,开发者可以填写 a-zA-Z0-9 的参数值,最多 128 字节。- 如果用户同意授权,页面将跳转至
redirect_uri/?code=CODE&state=STATE
,code
作为换取access_token
的票据,每次用户授权带上的code
不一样,code
只能使用一次,5分钟未被使用自动过期。 - 获取
code
后,请求 https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code 获取access_token
和openid
(未关注公众号时,用户访问公众号的网页,也会产生一个唯一的 openid)。如果scope
为snsapi_userinfo
还会同时获得到unionid
。 - 如果网页授权作用域为
snsapi_userinfo
,则此时可以请求 https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN 拉取用户信息,比如用户昵称、头像、unionid
等,不再返回用户性别及地区信息。 - 公众号的
secret
和获取到的access_token
安全级别都非常高,必须只保存在服务器,不允许传给客户端。后续刷新access_token
以及通过access_token
获取用户信息等步骤,也必须从服务器发起。
- 微信公众平台接口测试帐号申请: https://mp.weixin.qq.com/debug/cgi-bin/sandbox?t=sandbox/login
- 某个公众号的关注页面地址为 https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=MzI0NDA2OTc2Nw==#wechat_redirect 其中 biz 字符串是微信公众号标识,在浏览器打开该公众号下的任意一篇文章,查看网页源代码,搜索
var biz
这样的关键字即可得到。
微信授权也符合通常的 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.
- Redirect the user to the provider.
- User is authenticated by the provider.
- User is redirected back to your server with a secret code.
- Exchange that secret code for the user’s access token.
- Use the access token access the user’s data.
唤起微信小程序
微信外网页通过小程序链接 URL Scheme,微信内通过微信开放标签,且微信内不会直接拉起小程序,需要手动点击按钮跳转。这是官方提供的一个例子 https://postpay-2g5hm2oxbbb721a4-1258211818.tcloudbaseapp.com/jump-mp.html 可以用手机浏览器查看效果,直接跳转小程序。
- 使用微信开放标签
<wx-open-launch-weapp>
,提供要跳转小程序的原始 ID 和路径,标签内插入自定义的 html 元素。开放标签会被渲染成一个 iframe,所以外部的样式是不会生效的。另外在开放标签上模拟 click 事件也不生效,即不可以在微信内不通过点击直接跳转小程序。可以监听<wx-open-launch-weapp>
元素的launch
事件,用户点击跳转按钮并对确认弹窗进行操作后触发。 - 通过服务端接口或在小程序管理后台的「工具」入口可以获取打开小程序任意页面的 URL Scheme。适用于从短信、邮件、微信外网页等场景打开小程序。
微信小程序相关的仓库,比如 WeUI 组件库、微信小程序示例、computed / watch 扩展等: https://github.com/wechat-miniprogram
国产 APP 各自套壳 Chromium 内核版本,最大的问题就是更新不及时,而且大多被改造过。
- iOS 方面,根据 App Store 审核指南,上架 App Store 的应用不允许使用自己的浏览器内核。如果 app 会浏览网页,则必须使用相应的 WebKit 框架和 WebKit Javascript。
- Android 方面,不限制应用使用自己的浏览器内核。安卓微信之前的浏览器为基于 WebKit 的 X5 浏览器,后为了和小程序的浏览器内核同构,大概 2020-05 从 X5 迁移到 XWeb,官方一般会有内核版本升级体验通告,比如2023-06 更新:当前安卓微信 XWeb 开发版基于 111 新内核,现网仍基于 107 内核。
Debug iOS Safari from your Mac
- On your iPhone, go to Settings > Safari > Advanced and toggle on
Web Inspector
. - On your Mac, open Safari and go to Safari > Preferences > Advanced then check
Show Develop menu in menu bar
. - Connect your iPhone to your Mac with the USB cable.
- On your iPhone, open the web site that you want to debug.
- On your Mac, in Safari, the name of the iOS device will appear as a submenu in the
Develop menu
. This will open a Web Inspector window on your Mac.
HTTP 请求相关
- 使用 vue-resource
-
vue-resource 是一个轻量级的用于处理 HTTP 请求的插件,通过
Vue.use
使用自定义的插件。 -
全局对象使用
Vue.http.get()
,在一个组件内使用this.$http.get()
-
可以定义 inteceptor 在请求发送前和接收响应前做一些处理,比如设置业务相关的请求头、添加 CSRF token、请求加 loading 状态、query 参数加时间戳等。
Vue.http.interceptors.push((request, next) => { // 请求发送前的处理逻辑(比如判断传入的 request.no_loading 是否显示 loading) // if (request.method === 'GET') {...} // if (request.method === 'POST') {...} next((response) => { // 请求结果返回给 successCallback 或 errorCallback 之前,根据 `response.ok` 或 `response.status` 加一些处理逻辑 // ... return response }) });
- 自己对 axios 封装
- 通过
axios.defaults.headers['xyz'] = 'abc'
这样的方式添加需要的请求头 - 统一对 query 参数做处理,拼在 url 后面
- 加 csrf token,加业务需要的 header
- 根据不同的错误码做页面跳转
注意 Axios 遇到 302 的返回:重定向直接被浏览器拦截处理,浏览器 redirect 后,被视为 Axios 发起了跨域请求,所以抛异常。Axios 捕获异常,进入 catch 逻辑。
const handleResponse = (res) => {
if(res.headers && res.headers['set-auth']) {
window.Authorization = res.headers['set-auth'];
}
// 之后根据状态码做不同处理...
}
export default {
get(url, params) {
// 统一加请求头
axios.defaults.headers['X-Client'] = 'web';
if (window.Authorization) {
axios.defaults.headers['Authorization'] = 'Bearer ' + window.Authorization;
}
return axios
.get(url)
.then(function(response) {
return response
})
.then(handleResponse) // 统一处理 redirect, 赋值 location.href
.catch(errorResponseGet) // 统一处理错误码 4xx, 5xx
},
post(url, params) {
// ...
}
}
// Show progress of Axios during request
await axios.get('https://fetch-progress.anthum.com/30kbps/images/sunrise-baseline.jpg', {
onDownloadProgress: progressEvent => {
const percentCompleted = Math.floor(progressEvent.loaded / progressEvent.total * 100)
setProgress(percentCompleted)
}
})
.then(res => {
console.log("All DONE")
return res.data
})
- 使用 VueRequest,管理请求状态,支持 SWR、轮询、错误重试、缓存、分页等常用功能。
useRequest
接收一个 service 函数,service 是一个异步的请求函数,换句话说,还可以使用 axios 来获取数据,然后返回一个 Promise。useRequest
会返回 data、loading、error 等,它们的值会根据请求状态和结果进行修改。返回的 run 方法,可以手动触发 service 请求。
API 版本和 URI 连字符
API 版本可以放在两个地方: 在 url 中指定 API 的版本,例如 example.com/api/v1
,这样不同版本的协议解析可以放在不同的服务器上,不用考虑协议兼容性,开发方便,升级也不受影响。另一种是放在 HTTP header 中,url 显得干净,符合 RESTful 惯例,毕竟版本号不属于资源的属性。缺点是需要解析头部,判断返回。
URI 中尽量使用连字符 -
代替下划线 _
的使用,连字符用来分割 URI 中出现的单词,提高 URI 的可读性。下划线会和链接的样式冲突重叠。URI 是对大小写敏感的,为了避免歧义,我们尽量用小写字符。但主机名(Host)和协议名(Scheme)对大小写是不敏感的。
阿里云 CDN
阿里云 CDN 对于文件是否支持缓存是以 X-Cache
头部来确定,缓存时间是以 X-Swift-CacheTime
头部来确认。
Age
表示该文件在 CDN 节点上缓存的时间,单位为秒。只有文件存在于节点上 Age 字段才会出现,当文件被刷新后或者文件被清除的首次访问,在此前文件并未缓存,无 Age 头部字段。当 Age 为 0 时,表示节点已有文件的缓存,但由于缓存已过期,本次无法直接使用该缓存,需回源校验。X-Swift-SaveTime
该文件是在什么时间缓存到 CDN 节点上的。(GMT时间,Greenwich Mean Time Zone)X-Swift-CacheTime
该文件可以在 CDN 节点上缓存多久,是指文件在 CDN 节点缓存的总时间。通过X-Swift-CacheTime – Age
计算还有多久需要回源刷新。
阿里云 CDN 在全球拥有 3200+ 节点。中国内地拥有 2300+ 节点,覆盖 31 个省级区域。
- CDN 节点是指与最终接入用户之间具有较少中间环节的网络节点,对最终接入用户有相对于源站而言更好的响应能力和连接速度。当节点没有缓存用户请求的内容时,节点会返回源站获取资源数据并返回给用户。阿里云 CDN 的源站可以是对象存储OSS、函数计算、自有源站(IP、源站域名)。
- 默认情况下将使用 OSS 的 Bucket 地址作为 HOST 地址(如
***.oss-cn-hangzhou.aliyuncs.com
)。如果源站 OSS Bucket 绑定了自定义域名(如origin.developer.aliyundoc.com
),则需要配置回源 HOST 为自定义域名。- 加速域名即网站域名、是终端用户实际访问的域名。CNAME 域名是 CDN 生成的,当您在阿里云 CDN 控制台添加加速域名后,系统会为加速域名分配一个
*.*kunlun*.com
形式的 CNAME 域名。- 添加加速域名后,需要在 DNS 解析服务商处,添加一条 CNAME 记录,将加速域名的 DNS 解析记录指向 CNAME 域名,记录生效后该域名所有的请求都将转向 CDN 节点,达到加速效果。CNAME 域名将会解析到具体哪个节点 IP 地址,将由 CDN 的调度系统综合多个条件来决定。
秒杀系统设计
- https://github.com/resumejob/How-to-design-a-spike-system
- https://github.com/sunshineshu/-How-to-design-a-spike-system/blob/master/SUMMARY.md
开发自己的调试工具
- https://kentcdodds.com/blog/make-your-own-dev-tools
- https://app-dev-tools.netlify.app
- https://github.com/coryhouse/switchboard
日常开发 Tips and Tricks
-
The
input
event is fired every time the value of the element changes. This is unlike thechange
event, which only fires when the value is committed, such as by pressing the enter key or selecting a value from a list of options. Note thatonChange
in React behaves like the browserinput
event. (in React it is idiomatic to useonChange
instead ofonInput
) -
The order in which the events are fired:
mousedown
—>mouseup
—>click
. When you add ablur
event, it is actually fired before themouseup
event and after themousedown
event of the button. Refer to https://codepen.io/mudassir0909/full/qBjvzL -
Reading content with
textContent
is much faster thaninnerText
(innerText
had the overhead of checking to see if the element was visible or not yet). TheinsertAdjacentHTML
method is much faster thaninnerHTML
because it doesn’t have to destroy the DOM first before inserting. -
HTML files input change event doesn’t fire upon selecting the same file. You can put
this.value = null
at the end of theonchange
event, which will reset the input’s value and trigger theonchange
event again. -
If we are appending each list item to the DOM as we create it, this is inefficient because the DOM is updated each time we append a new list item. Instead, we can create a document fragment using
document.createDocumentFragment()
and append all of the list items to the fragment. Then, we can append the fragment to the DOM. This way, the DOM is only updated once. -
Vue parent component will wait for its children to mount before it mounts its own template to the DOM. The order should be: parent created -> child created -> child mounted -> parent mouted.
-
Sometimes I need to detect whether a click happens inside or outside of a particular element.
window.addEventListener('mousedown', e => { // Get the element that was clicked const clickedEl = e.target; // `el` is the element you're detecting clicks outside of // https://developer.mozilla.org/en-US/docs/Web/API/Node/contains if (el.contains(clickedEl)) { // Clicked inside of `el` } else { // Clicked outside of `el` } });
-
Change the style of
:before
pseudo-elements using JS. (It’s not possible to directly access pseudo-elements with JS as they’re not part of the DOM.)let style = document.querySelector('.foo').style; style.setProperty('--background', 'red');
.foo::before { background: var(--background); content: ''; display: block; width: 200px; height: 200px; }
-
The default behavior of
scrollIntoView()
is that the top of the element will be aligned to the top of the visible area of the scrollable ancestor. If it shifts the complete page, you could either call it with the parameterfalse
to indicate that it should aligned to the bottom of the ancestor or just usescrollTop
instead ofscrollIntoView()
.let target = document.getElementById("target"); target.parentNode.scrollTop = target.offsetTop; // can also add the css `scroll-behavior: smooth;`
-
If several listeners are attached to the same element for the same event type, they are called in the order in which they were added. If
stopImmediatePropagation()
is invoked during one such call, no remaining listeners will be called. -
To detect if a user has their keyboard’s caps lock turn on, we’ll employ KeyboardEvent’s
getModifierState
method (which returns the current state of the specified modifier key,true
if the modifier is active):document.querySelector('input[type=password]').addEventListener('keyup', function (keyboardEvent) { const capsLockOn = keyboardEvent.getModifierState('CapsLock'); if (capsLockOn) { // Warn the user that their caps lock is on } });
-
npmmirror 已内置支持类似 unpkg cdn 解析能力,可以简单理解为访问 unpkg 地址时,在回源服务里面根据 URL 参数,去 npm registry 下载对应的 npm 包,解压后响应对应的文件内容。即只需要遵循约定的 URL 进行访问,即可在页面中加载任意 npm 包里面的文件内容。
# 获取目录信息 /${pkg}/${versionOrTag}/files?meta https://registry.npmmirror.com/antd/5.5.2/files?meta # 获取文件内容 /${pkg}/${versionOrTag}/files/${path} https://registry.npmmirror.com/antd/5.5.0/files/lib/index.js # 获取入口文件内容 /${pkg}/${versionOrTag}/files https://registry.npmmirror.com/antd/latest/files
-
播放器与字幕的跨域问题:由于加了字幕,但字幕地址是跨域的,所以播放器标签上必须加
crossorigin="anonymous"
也就是改变了原来请求视频的方式(no-cors 是 HTML 元素发起请求的默认状态;现在会创建一个状态为 anonymous 的 cors 请求,不发 cookie),此时服务端必须响应Access-Control-Allow-Origin
才可以。『播放器不设置跨域 只给字幕配 cors 响应头』这个方案是不行的,因为必须要先发一个 cors 请求才可以,服务端配置的响应头才有用处。- Add
crossorigin="anonymous"
to the video tag to allow load VTT files from different domains. - Even if your CORS is set correctly on the server, you may need to have your HTML tag label itself as anonymous for the CORS policy to work.
- 与 HTML 元素不同的是,Fetch API 的 mode 的默认值是 cors;当你发送一个状态为 no-cors 的跨域请求,会发现返回的 response body 是空,也就是说,虽然请求成功,但仍然无法访问返回的资源。
- Unlike classic scripts, module scripts (
<script type="module"
>) require the use of the CORS protocol for cross-origin fetching.
- Add
-
防止重复提交:前端防抖,按钮点击后立即禁用。后端接口幂等性设计。
-
iframe 技术方案(浏览器原生的隔离方案)
- iframe 内部的路由变化要体现在浏览器地址栏上
- 刷新页面时要把当前状态的 url 传递给 iframe
- 浏览器前进后退符合预期
- 弹窗全局居中
- CSP, sandbox 等安全属性
- 局限性:每次刷新页面都可能使 iframe 内部状态丢失;postMessage 只能传递可序列化的数据;iframe 白屏时间相对较长
桌面端 Electron 的本地构建过程
Electron是一个集成项目,允许开发者使用前端技术开发桌面端应用。其中 Chromium 基础能力可以让应用渲染 HTML 页面,执行页面的 JS 脚本,让应用可以在 Cookie 或 LocalStorage 中存取数据。Electron 还继承了 Chromium 的多进程架构,分一个主进程和多个渲染进程,主进程进行核心的调度启动,不同的 GUI 窗口独立渲染,做到进程间的隔离,进程与进程之间实现了 IPC 通信。Node.js 基础能力可以让开发者读写本地磁盘的文件,通过 socket 访问网络,创建和控制子进程等。Electron 内置模块可以支持创建操作系统的托盘图标,访问操作系统的剪切板,获取屏幕信息,发送系统通知,收集崩溃报告等。
Node.js(内置 libuv)有自己的消息循环,要想把这个消息循环和应用程序的消息循环合并到一起并不容易。Electron 的做法是创建了一个单独的线程并使用系统调用来轮询 libuv 的 fd (文件描述符),以获得 libuv 的消息,再把消息交给 GUI 主线程,由主线程的消息循环处理 libuv 的消息。
- 调用
greeting()
方法,根据终端窗口的宽度process.stdout.columns
显示不同样式的问候语。 - 使用
Promise.all()
同时启动主进程和渲染进程的构建,两者分别有自己的 webpack 配置文件webpack.main.config
和webpack.renderer.config
- 对于渲染进程,使用类似 web 端的 webpack 配置,设置入口文件、产出位置、需要的 loaders 和 plugins,并根据是否为 production 环境补充引入一些 plugin,在 npm 脚本打包的时候可以通过
cross-env BUILD_ENV=abc
设置一些环境变量。创建一个 WebpackDevServer,传入 webpack 配置,设置代理,监听某一端口,其实这就是启动一个本地服务,使用浏览器也可以访问构建后的页面,这里只是用 electron 的壳子把它加载进来。对于主进程,也使用了 webpack,设置入口文件用来打包产出。 - 利用 webpack 编译的 hooks 在构建完成后会打印日志,
logStats()
函数接收进程名 (Main or Renderer) 和具体输出的内容。 - 在主进程和渲染进程都构建完成后,即主进程有一个打包后的
main.js
且渲染进程本地服务可以访问,这个时候启动 electron,即通常项目的 npm 脚本会执行electron .
,这里是通过 Node API,使用child_process.spawn()
的方式启动 electron 并传入需要的参数,然后对 electron 进程的 stdout 和 stderr 监听,打印对应的日志。
桌面端状态持久化存储
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')
.
appData
, which by default points to~/Library/Application Support
on macOS.userData
(storing your app’s configuration files), which by default is the appData directory appended with your app’s name.
Advantages over localStorage
:
localStorage
only works in the browser process.localStorage
is not very fault tolerant, so if your app encounters an error and quits unexpectedly, you could lose the data.localStorage
only supports persisting strings. This module supports any JSON supported type.- The API of this module is much nicer. You can set and get nested properties. You can set default initial config.
vuex-electron uses electron-store
to share your Vuex Store between all processes (including main).
Electron 相关记录
- 如果安装 Electron 遇到问题,可以直接在 https://npmmirror.com/mirrors/electron/ 下载需要的版本,然后保存到本地缓存中
~/Library/Caches/electron
- 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 getelectron
from npm to use your built app. Therefore it matches well the definition of adevDependency
. (When you publish your package, if the consumer project needs other packages to use yours, then these must be listed asdependencies
.) For example, VS Code properly listselectron
as a devDependency only: https://github.com/microsoft/vscode/blob/main/package.json - 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
- 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 inhash
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.hash
和onhashchange
事件,利用#
后面的内容不会被发送到服务端实现单页应用。history mode 要手动设置mode: 'history'
, 是基于 History API 来实现的,这也是浏览器本身的功能,地址不会被请求到服务端。
- 关于 Icon 图标,Windows(.ico 文件)和 Mac(.icns 文件)的都是复合格式,包含了多种尺寸和颜色模式,Linux 就是多张 png。注意不要把 png 直接改成 ico,可以使用在线工具转换。如果 Windows 窗口或任务栏图标未更换成功,可能是 ico 文件中缺少小尺寸图标,如缺少 16x16 或 32x32 的图标。
- 可以通过命令行启动程序,查看打包后的主进程日志,Mac 进入到
/Applications/Demo.app/Contents/MacOS/
路径,执行./Demo
启动应用层序。Windows 上打开 Powershell 进入到程序的安装目录,执行.\Demo.exe
,如果文件名中有空格,需要用双引号把文件名引起来。 - Electron 参考项目: